baxian 1.2.61 → 2.0.0

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 (178) hide show
  1. package/README.md +7 -4
  2. package/README.zh-CN.md +7 -4
  3. package/dist/agent/bootstrap-poller.d.ts +1 -1
  4. package/dist/agent/bootstrap-poller.d.ts.map +1 -1
  5. package/dist/agent/bootstrap-poller.js.map +1 -1
  6. package/dist/agent/bootstrap.d.ts +1 -1
  7. package/dist/agent/bootstrap.d.ts.map +1 -1
  8. package/dist/agent/bootstrap.js +6 -2
  9. package/dist/agent/bootstrap.js.map +1 -1
  10. package/dist/agent/branch.d.ts +1 -2
  11. package/dist/agent/branch.d.ts.map +1 -1
  12. package/dist/agent/branch.js +2 -2
  13. package/dist/agent/branch.js.map +1 -1
  14. package/dist/agent/dispatch-reconciler.d.ts +0 -1
  15. package/dist/agent/dispatch-reconciler.d.ts.map +1 -1
  16. package/dist/agent/dispatch-reconciler.js +5 -44
  17. package/dist/agent/dispatch-reconciler.js.map +1 -1
  18. package/dist/agent/manager.d.ts +175 -82
  19. package/dist/agent/manager.d.ts.map +1 -1
  20. package/dist/agent/manager.js +2453 -1447
  21. package/dist/agent/manager.js.map +1 -1
  22. package/dist/agent/phase-signal-watcher.d.ts +5 -1
  23. package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
  24. package/dist/agent/phase-signal-watcher.js +22 -11
  25. package/dist/agent/phase-signal-watcher.js.map +1 -1
  26. package/dist/agent/phase-signal.d.ts +2 -7
  27. package/dist/agent/phase-signal.d.ts.map +1 -1
  28. package/dist/agent/phase-signal.js +4 -4
  29. package/dist/agent/phase-signal.js.map +1 -1
  30. package/dist/agent/preflight.d.ts +4 -1
  31. package/dist/agent/preflight.d.ts.map +1 -1
  32. package/dist/agent/preflight.js +46 -45
  33. package/dist/agent/preflight.js.map +1 -1
  34. package/dist/agent/prompt.d.ts +8 -0
  35. package/dist/agent/prompt.d.ts.map +1 -1
  36. package/dist/agent/prompt.js +30 -13
  37. package/dist/agent/prompt.js.map +1 -1
  38. package/dist/agent/repo-store.d.ts +4 -0
  39. package/dist/agent/repo-store.d.ts.map +1 -1
  40. package/dist/agent/repo-store.js +37 -8
  41. package/dist/agent/repo-store.js.map +1 -1
  42. package/dist/agent/review-transport.d.ts +20 -1
  43. package/dist/agent/review-transport.d.ts.map +1 -1
  44. package/dist/agent/review-transport.js +68 -20
  45. package/dist/agent/review-transport.js.map +1 -1
  46. package/dist/api/config.d.ts.map +1 -1
  47. package/dist/api/config.js +12 -11
  48. package/dist/api/config.js.map +1 -1
  49. package/dist/api/hosts.d.ts.map +1 -1
  50. package/dist/api/hosts.js +3 -2
  51. package/dist/api/hosts.js.map +1 -1
  52. package/dist/api/platform-guard.d.ts +10 -2
  53. package/dist/api/platform-guard.d.ts.map +1 -1
  54. package/dist/api/platform-guard.js +112 -10
  55. package/dist/api/platform-guard.js.map +1 -1
  56. package/dist/api/projects.d.ts.map +1 -1
  57. package/dist/api/projects.js +34 -9
  58. package/dist/api/projects.js.map +1 -1
  59. package/dist/api/tasks.d.ts.map +1 -1
  60. package/dist/api/tasks.js +21 -36
  61. package/dist/api/tasks.js.map +1 -1
  62. package/dist/app.d.ts +4 -4
  63. package/dist/app.d.ts.map +1 -1
  64. package/dist/config/hot-reload.d.ts +11 -2
  65. package/dist/config/hot-reload.d.ts.map +1 -1
  66. package/dist/config/hot-reload.js +43 -8
  67. package/dist/config/hot-reload.js.map +1 -1
  68. package/dist/config/loader.js +1 -1
  69. package/dist/config/loader.js.map +1 -1
  70. package/dist/config/validator.d.ts +3 -1
  71. package/dist/config/validator.d.ts.map +1 -1
  72. package/dist/config/validator.js +53 -31
  73. package/dist/config/validator.js.map +1 -1
  74. package/dist/event/handlers.d.ts.map +1 -1
  75. package/dist/event/handlers.js +616 -1067
  76. package/dist/event/handlers.js.map +1 -1
  77. package/dist/event/server-handlers.d.ts.map +1 -1
  78. package/dist/event/server-handlers.js +667 -440
  79. package/dist/event/server-handlers.js.map +1 -1
  80. package/dist/index.d.ts +4 -1
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +54 -79
  83. package/dist/index.js.map +1 -1
  84. package/dist/platform/command-renderer.d.ts +1 -0
  85. package/dist/platform/command-renderer.d.ts.map +1 -1
  86. package/dist/platform/command-renderer.js +16 -2
  87. package/dist/platform/command-renderer.js.map +1 -1
  88. package/dist/platform/comment-cursor.d.ts +9 -0
  89. package/dist/platform/comment-cursor.d.ts.map +1 -1
  90. package/dist/platform/comment-cursor.js +63 -5
  91. package/dist/platform/comment-cursor.js.map +1 -1
  92. package/dist/platform/driver-host.d.ts.map +1 -1
  93. package/dist/platform/driver-host.js +0 -2
  94. package/dist/platform/driver-host.js.map +1 -1
  95. package/dist/platform/driver-spec.d.ts.map +1 -1
  96. package/dist/platform/driver-spec.js +17 -6
  97. package/dist/platform/driver-spec.js.map +1 -1
  98. package/dist/platform/feedback.d.ts.map +1 -1
  99. package/dist/platform/feedback.js +0 -9
  100. package/dist/platform/feedback.js.map +1 -1
  101. package/dist/platform/git-driver.d.ts +3 -0
  102. package/dist/platform/git-driver.d.ts.map +1 -1
  103. package/dist/platform/git-driver.js +45 -3
  104. package/dist/platform/git-driver.js.map +1 -1
  105. package/dist/platform/platform-poller.d.ts +18 -5
  106. package/dist/platform/platform-poller.d.ts.map +1 -1
  107. package/dist/platform/platform-poller.js +282 -33
  108. package/dist/platform/platform-poller.js.map +1 -1
  109. package/dist/platform/plugins/github/baxian-plugin.json +1 -1
  110. package/dist/platform/plugins/github/driver.json +14 -3
  111. package/dist/platform/plugins/github/skills/baxian-cli-gh/SKILL.md +7 -6
  112. package/dist/platform/plugins/github/skills/baxian-cli-gh/agents/openai.yaml +2 -0
  113. package/dist/platform/pr-binding.d.ts.map +1 -1
  114. package/dist/platform/pr-binding.js +0 -2
  115. package/dist/platform/pr-binding.js.map +1 -1
  116. package/dist/platform/pr-conversation-cache.d.ts.map +1 -0
  117. package/dist/platform/pr-conversation-cache.js.map +1 -0
  118. package/dist/platform/row-schema.d.ts.map +1 -1
  119. package/dist/platform/row-schema.js +1 -0
  120. package/dist/platform/row-schema.js.map +1 -1
  121. package/dist/platform/startup.d.ts +29 -1
  122. package/dist/platform/startup.d.ts.map +1 -1
  123. package/dist/platform/startup.js +82 -4
  124. package/dist/platform/startup.js.map +1 -1
  125. package/dist/platform/types.d.ts +2 -0
  126. package/dist/platform/types.d.ts.map +1 -1
  127. package/dist/platform/types.js +6 -4
  128. package/dist/platform/types.js.map +1 -1
  129. package/dist/platform/verdict-engine.d.ts.map +1 -1
  130. package/dist/platform/verdict-engine.js +0 -2
  131. package/dist/platform/verdict-engine.js.map +1 -1
  132. package/dist/shared/types.d.ts +73 -5
  133. package/dist/shared/types.d.ts.map +1 -1
  134. package/dist/shared/types.js.map +1 -1
  135. package/dist/skills/baxian-pr-feedback/SKILL.md +4 -12
  136. package/dist/skills/baxian-pr-recheck/SKILL.md +3 -44
  137. package/dist/skills/baxian-pr-review/SKILL.md +4 -43
  138. package/dist/skills/baxian-server-feedback/SKILL.md +7 -5
  139. package/dist/skills/baxian-signals/SKILL.md +5 -7
  140. package/dist/skills/baxian-task-check/SKILL.md +3 -5
  141. package/dist/state/agent-store.js +0 -2
  142. package/dist/state/agent-store.js.map +1 -1
  143. package/dist/state/review-store.d.ts +21 -1
  144. package/dist/state/review-store.d.ts.map +1 -1
  145. package/dist/state/review-store.js +132 -0
  146. package/dist/state/review-store.js.map +1 -1
  147. package/dist/state/task-store.d.ts +6 -0
  148. package/dist/state/task-store.d.ts.map +1 -1
  149. package/dist/state/task-store.js +259 -18
  150. package/dist/state/task-store.js.map +1 -1
  151. package/dist/web/assets/index-BXfgaFNm.js +18 -0
  152. package/dist/web/index.html +1 -1
  153. package/package.json +1 -1
  154. package/dist/github/mapper.d.ts +0 -9
  155. package/dist/github/mapper.d.ts.map +0 -1
  156. package/dist/github/mapper.js +0 -25
  157. package/dist/github/mapper.js.map +0 -1
  158. package/dist/github/poller.d.ts +0 -73
  159. package/dist/github/poller.d.ts.map +0 -1
  160. package/dist/github/poller.js +0 -556
  161. package/dist/github/poller.js.map +0 -1
  162. package/dist/github/pr-conversation-cache.d.ts.map +0 -1
  163. package/dist/github/pr-conversation-cache.js.map +0 -1
  164. package/dist/github/pr-conversation.d.ts +0 -7
  165. package/dist/github/pr-conversation.d.ts.map +0 -1
  166. package/dist/github/pr-conversation.js +0 -175
  167. package/dist/github/pr-conversation.js.map +0 -1
  168. package/dist/github/resolver.d.ts +0 -8
  169. package/dist/github/resolver.d.ts.map +0 -1
  170. package/dist/github/resolver.js +0 -28
  171. package/dist/github/resolver.js.map +0 -1
  172. package/dist/state/post-approve-store.d.ts +0 -23
  173. package/dist/state/post-approve-store.d.ts.map +0 -1
  174. package/dist/state/post-approve-store.js +0 -82
  175. package/dist/state/post-approve-store.js.map +0 -1
  176. package/dist/web/assets/index-CUJtd0bC.js +0 -16
  177. /package/dist/{github → platform}/pr-conversation-cache.d.ts +0 -0
  178. /package/dist/{github → platform}/pr-conversation-cache.js +0 -0
@@ -1,7 +1,14 @@
1
1
  import { isSpecStagePhase } from '../shared/index.js';
2
- import { ReviewExchangeError } from '../agent/review-transport.js';
2
+ import { ReviewExchangeError, } from '../agent/review-transport.js';
3
+ import { computeBackoffMs } from '../timing/backoff.js';
4
+ import { reviewFindingsDigest, serverResponseFailureSignature, } from '../state/review-store.js';
3
5
  const LEGACY_DIFF_LARGE_THRESHOLD = 2000;
4
6
  const FILE_HEADER_RE = /^diff --git a\/(.+?) b\//;
7
+ const SERVER_HANDLER_ATTEMPTS = 3;
8
+ const SERVER_HANDLER_BACKOFF = { baseMs: 10, maxMs: 20, factor: 2, jitter: 0 };
9
+ const SERVER_SIGNAL_KINDS = new Set([
10
+ 'code-done', 'code-reviewed', 'code-fixed', 'code-ready', 'spec-done', 'spec-reviewed', 'spec-fixed',
11
+ ]);
5
12
  function splitLegacyDiffByFile(diff) {
6
13
  if (diff.trim() === '')
7
14
  return [];
@@ -96,10 +103,14 @@ function aggregateBatchFindings(batches, round) {
96
103
  function coverageGaps(findings, responseIds) {
97
104
  const findingIds = new Set(findings.findings.map(f => f.id));
98
105
  return {
99
- missing: [...findingIds].filter(id => !responseIds.has(id)),
100
- unknown: [...responseIds].filter(id => !findingIds.has(id)),
106
+ missing: [...findingIds].filter(id => !responseIds.has(id)).sort(),
107
+ unknown: [...responseIds].filter(id => !findingIds.has(id)).sort(),
101
108
  };
102
109
  }
110
+ function waitForHandlerRetry(attempt) {
111
+ const ms = computeBackoffMs(attempt, SERVER_HANDLER_BACKOFF);
112
+ return new Promise(resolve => setTimeout(resolve, ms));
113
+ }
103
114
  async function emitIntervention(bus, task, data) {
104
115
  try {
105
116
  await bus.emit({
@@ -129,7 +140,9 @@ async function gate(bus, manager, event, expect) {
129
140
  const token = event.data?.token;
130
141
  const phaseOk = expect.phase === 'any'
131
142
  || (expect.phase === 'spec' ? task.phase !== 'code' : !isSpecStagePhase(task.phase));
132
- if (task.status !== expect.status || !phaseOk || !token || token !== task.signalToken) {
143
+ if (token && token !== task.signalToken)
144
+ return null;
145
+ if (task.status !== expect.status || !phaseOk || !token) {
133
146
  await emitIntervention(bus, task, {
134
147
  phase: `${event.type}-stale`,
135
148
  taskStatus: task.status,
@@ -167,24 +180,206 @@ export function registerServerEventHandlers(bus, manager) {
167
180
  }
168
181
  }
169
182
  async function putVerdictRound(task, agentId, kind, data) {
183
+ let lastError;
184
+ for (let attempt = 1; attempt <= SERVER_HANDLER_ATTEMPTS; attempt++) {
185
+ try {
186
+ await reviewStore.putRound(task.id, data.phase, data);
187
+ return true;
188
+ }
189
+ catch (err) {
190
+ lastError = err;
191
+ try {
192
+ const stored = await reviewStore.getRound(task.id, data.phase, data.round);
193
+ if (stored && JSON.stringify(stored) === JSON.stringify(data))
194
+ return true;
195
+ }
196
+ catch (readBackError) {
197
+ lastError = readBackError;
198
+ }
199
+ if (attempt < SERVER_HANDLER_ATTEMPTS)
200
+ await waitForHandlerRetry(attempt);
201
+ }
202
+ }
203
+ return holdConsumedSignal(task, agentId, kind, 'verdict-store-failed', `server-${data.phase}-verdict-store-failed`, {
204
+ round: data.round,
205
+ error: lastError instanceof Error ? lastError.message : String(lastError),
206
+ });
207
+ }
208
+ async function holdConsumedSignal(task, agentId, kind, reason, failurePhase, data = {}) {
209
+ if (!SERVER_SIGNAL_KINDS.has(kind)) {
210
+ throw new Error(`unsupported consumed server signal kind: ${kind}`);
211
+ }
212
+ const phase = kind.startsWith('spec-') ? 'spec' : 'code';
213
+ const round = phase === 'spec' ? (task.specReviewRound ?? 0) : task.reviewRound;
214
+ const held = await manager.holdConsumedServerSignal(task, agentId, kind, { phase, round, reason, failurePhase });
215
+ if (!held)
216
+ return false;
217
+ await emitIntervention(bus, held, {
218
+ phase: failurePhase,
219
+ ...data,
220
+ successorToken: held.signalToken,
221
+ });
222
+ return false;
223
+ }
224
+ async function readResponseWithRetry(task, dev) {
225
+ let result;
226
+ for (let attempt = 1; attempt <= SERVER_HANDLER_ATTEMPTS; attempt++) {
227
+ result = await transport().readResponseWithRaw(task, dev);
228
+ if (result.kind !== 'unknown' || attempt === SERVER_HANDLER_ATTEMPTS)
229
+ return result;
230
+ await waitForHandlerRetry(attempt);
231
+ }
232
+ return result;
233
+ }
234
+ async function runConsumedSignal(task, agentId, kind, handler) {
170
235
  try {
171
- await reviewStore.putRound(task.id, data.phase, data);
236
+ await handler();
237
+ }
238
+ catch (err) {
239
+ await holdConsumedSignal(task, agentId, kind, 'handler-failed', `server-${kind.startsWith('spec-') ? 'spec' : 'code'}-handler-failed`, { error: err instanceof Error ? err.message : String(err) });
240
+ }
241
+ }
242
+ async function deleteFindingsOrHold(task, qa, kind) {
243
+ try {
244
+ await transport().deleteFindings(qa);
172
245
  return true;
173
246
  }
174
247
  catch (err) {
175
- await emitIntervention(bus, task, {
176
- phase: `server-${data.phase}-verdict-store-failed`,
177
- round: data.round,
178
- error: err instanceof Error ? err.message : String(err),
179
- });
180
- await manager.setupPhaseSignal(task.id, agentId, kind, { skipSnapshot: true });
248
+ await holdConsumedSignal(task, qa.id, kind, 'handler-failed', `server-${kind.startsWith('spec-') ? 'spec' : 'code'}-findings-cleanup-failed`, { error: err instanceof Error ? err.message : String(err) });
181
249
  return false;
182
250
  }
183
251
  }
184
- async function rearmOrHold(task, agentId, kind) {
185
- const armed = await manager.setupPhaseSignal(task.id, agentId, kind, { skipSnapshot: true });
186
- if (!armed)
187
- await manager.holdAgentForUnarmedSignal(task.id, agentId, kind);
252
+ async function rejectServerResponse(task, dev, roundData, failure) {
253
+ const phase = roundData.phase;
254
+ const signalKind = `${phase}-fixed`;
255
+ const sourceToken = task.signalToken;
256
+ if (!sourceToken)
257
+ return false;
258
+ const findingsDigest = reviewFindingsDigest(roundData.findings);
259
+ const missingFindingIds = [...(failure.missingFindingIds ?? [])].sort();
260
+ const unknownFindingIds = [...(failure.unknownFindingIds ?? [])].sort();
261
+ const schemaViolationCodes = [...(failure.schemaViolationCodes ?? [])].sort();
262
+ const failureSignature = serverResponseFailureSignature({
263
+ phase,
264
+ round: roundData.round,
265
+ findingsDigest,
266
+ reason: failure.reason,
267
+ missingFindingIds,
268
+ unknownFindingIds,
269
+ schemaViolationCodes,
270
+ });
271
+ const claimed = await manager.claimServerSignalRecovery(task, dev.id, signalKind, {
272
+ mode: 'classify-response',
273
+ phase,
274
+ round: roundData.round,
275
+ findingsDigest,
276
+ failureSignature,
277
+ reason: failure.reason,
278
+ failurePhase: failure.failurePhase,
279
+ missingFindingIds,
280
+ unknownFindingIds,
281
+ schemaViolationCodes,
282
+ ...(failure.responseDigest ? { responseDigest: failure.responseDigest } : {}),
283
+ });
284
+ if (!claimed?.signalToken)
285
+ return false;
286
+ let recorded;
287
+ try {
288
+ recorded = await reviewStore.recordServerResponseFailure(task.id, phase, roundData.round, {
289
+ signalKind,
290
+ sourceToken,
291
+ successorToken: claimed.signalToken,
292
+ failureSignature,
293
+ reason: failure.reason,
294
+ missingFindingIds,
295
+ unknownFindingIds,
296
+ schemaViolationCodes,
297
+ createdAt: new Date().toISOString(),
298
+ ...(failure.responseDigest ? { responseDigest: failure.responseDigest } : {}),
299
+ ...(failure.rawResponse !== undefined ? { rawResponse: failure.rawResponse } : {}),
300
+ });
301
+ }
302
+ catch (err) {
303
+ const message = err instanceof Error ? err.message : String(err);
304
+ const held = await manager.holdServerSignalRecovery(task.id, dev.id, claimed.signalToken, `server-${phase}-response-audit-store-failed`, `${message}. The response file was preserved and the old token is retired.`);
305
+ if (!held)
306
+ return false;
307
+ await emitIntervention(bus, held, {
308
+ phase: `server-${phase}-response-audit-store-failed`,
309
+ round: roundData.round,
310
+ error: message,
311
+ successorToken: claimed.signalToken,
312
+ });
313
+ return false;
314
+ }
315
+ const nextMode = recorded.disposition === 'auto-correct' ? 'correct-response' : 'hold';
316
+ const finalized = await manager.setServerSignalRecoveryMode(task.id, claimed.signalToken, nextMode);
317
+ if (!finalized)
318
+ return false;
319
+ if (recorded.disposition !== 'auto-correct') {
320
+ const holdPhase = recorded.disposition === 'hold-repeated-signature'
321
+ ? 'server-feedback-response-repeated'
322
+ : 'server-feedback-correction-limit';
323
+ const held = await manager.holdServerSignalRecovery(task.id, dev.id, claimed.signalToken, holdPhase, `The ${phase} response failed with ${failure.reason}; automatic correction stopped `
324
+ + `(${recorded.disposition}). Rewrite it deliberately, then Restart or Cancel the task.`);
325
+ if (!held)
326
+ return false;
327
+ await emitIntervention(bus, held, {
328
+ phase: holdPhase,
329
+ reviewPhase: phase,
330
+ round: roundData.round,
331
+ reason: failure.reason,
332
+ disposition: recorded.disposition,
333
+ missingFindingIds,
334
+ unknownFindingIds,
335
+ findingsDigest,
336
+ successorToken: claimed.signalToken,
337
+ });
338
+ return false;
339
+ }
340
+ const beforeCleanup = await manager.getTask(task.id);
341
+ const currentRecovery = beforeCleanup?.serverSignalRecovery;
342
+ const currentRound = phase === 'spec' ? beforeCleanup?.specReviewRound : beforeCleanup?.reviewRound;
343
+ if (!beforeCleanup
344
+ || beforeCleanup.status !== task.status
345
+ || beforeCleanup.phase !== task.phase
346
+ || beforeCleanup.signalToken !== claimed.signalToken
347
+ || currentRound !== roundData.round
348
+ || currentRecovery?.mode !== 'correct-response'
349
+ || currentRecovery.sourceToken !== sourceToken
350
+ || currentRecovery.failureSignature !== failureSignature) {
351
+ return false;
352
+ }
353
+ try {
354
+ await transport().deleteResponse(dev);
355
+ }
356
+ catch (err) {
357
+ const message = err instanceof Error ? err.message : String(err);
358
+ const held = await manager.holdServerSignalRecovery(task.id, dev.id, claimed.signalToken, `server-${phase}-response-cleanup-failed`, `${message}. The rejected response is archived, but the live file could not be removed safely.`);
359
+ if (!held)
360
+ return false;
361
+ await emitIntervention(bus, held, {
362
+ phase: `server-${phase}-response-cleanup-failed`,
363
+ round: roundData.round,
364
+ error: message,
365
+ successorToken: claimed.signalToken,
366
+ });
367
+ return false;
368
+ }
369
+ await emitIntervention(bus, finalized, {
370
+ phase: failure.failurePhase,
371
+ round: roundData.round,
372
+ reason: failure.reason,
373
+ missingFindingIds,
374
+ unknownFindingIds,
375
+ schemaViolationCodes,
376
+ findingsDigest,
377
+ responseDigest: failure.responseDigest,
378
+ successorToken: claimed.signalToken,
379
+ ...failure.details,
380
+ });
381
+ await manager.dispatchServerFeedbackCorrection(task.id, JSON.stringify(roundData.findings));
382
+ return false;
188
383
  }
189
384
  function resolveActiveQa(task) {
190
385
  if (!task.qaAgentId)
@@ -195,25 +390,21 @@ export function registerServerEventHandlers(bus, manager) {
195
390
  async function pauseForUnavailableQa(task, entryKind, phase) {
196
391
  if (!task.qaAgentId || resolveActiveQa(task))
197
392
  return false;
198
- await emitIntervention(bus, task, { phase, qaAgentId: task.qaAgentId });
199
- if (task.agentId)
200
- await rearmOrHold(task, task.agentId, entryKind);
393
+ await holdConsumedSignal(task, task.agentId, entryKind, 'handler-failed', phase, { qaAgentId: task.qaAgentId });
201
394
  return true;
202
395
  }
203
396
  async function autoApproveCode(task, entryKind = 'code-done') {
204
- const afterDone = manager.resolveAfterDone(task);
205
- if (task.afterDone === undefined) {
206
- await manager.updateTask(task.id, { afterDone });
207
- }
397
+ const afterDone = await manager.commitServerAfterDone(task.id);
208
398
  if (afterDone === null) {
209
399
  const ready = await manager.transitionTaskStatus(task.id, 'ready', { fromStatus: ['in_progress', 'fixing'] });
210
- if (!ready)
211
- await emitIntervention(bus, task, { phase: 'server-code-auto-approve-transition-failed' });
400
+ if (!ready) {
401
+ await holdConsumedSignal(task, task.agentId, entryKind, 'handler-failed', 'server-code-auto-approve-transition-failed');
402
+ }
212
403
  return;
213
404
  }
214
405
  const dev = manager.getAgentConfig(task.agentId);
215
406
  if (!dev) {
216
- await emitIntervention(bus, task, { phase: 'server-code-auto-approve-no-dev-agent' });
407
+ await holdConsumedSignal(task, task.agentId, entryKind, 'fix-no-dev-agent', 'server-code-auto-approve-no-dev-agent');
217
408
  return;
218
409
  }
219
410
  await manager.refreshWorkdirCacheFor(task.agentId);
@@ -222,19 +413,18 @@ export function registerServerEventHandlers(bus, manager) {
222
413
  reviewHeadAnchorSha = await transport().readHeadSha(dev);
223
414
  }
224
415
  catch (err) {
225
- await emitIntervention(bus, task, {
226
- phase: 'server-code-auto-approve-head-capture-failed',
227
- error: err instanceof Error ? err.message : String(err),
228
- });
229
- await manager.setupPhaseSignal(task.id, task.agentId, entryKind, { skipSnapshot: true });
416
+ await holdConsumedSignal(task, task.agentId, entryKind, 'handler-failed', 'server-code-auto-approve-head-capture-failed', { error: err instanceof Error ? err.message : String(err) });
230
417
  return;
231
418
  }
232
419
  const approved = await manager.transitionTaskStatus(task.id, 'approved', { fromStatus: ['in_progress', 'fixing'] }, { reviewHeadAnchorSha });
233
420
  if (!approved) {
234
- await emitIntervention(bus, task, { phase: 'server-code-auto-approve-transition-failed' });
421
+ await holdConsumedSignal(task, task.agentId, entryKind, 'handler-failed', 'server-code-auto-approve-transition-failed');
235
422
  return;
236
423
  }
237
- await manager.dispatchServerAfterDone(task.id, afterDone);
424
+ const dispatched = await manager.dispatchServerAfterDone(task.id, afterDone);
425
+ if (!dispatched) {
426
+ await holdConsumedSignal(task, task.agentId, entryKind, 'handler-failed', 'server-code-auto-approve-publish-dispatch-failed');
427
+ }
238
428
  }
239
429
  function specApprovalIsHuman(task) {
240
430
  return task.researchAgentId !== undefined
@@ -246,45 +436,35 @@ export function registerServerEventHandlers(bus, manager) {
246
436
  ? await manager.parkTaskAtSpecReady(task.id)
247
437
  : await manager.transitionToCodePhase(task.id);
248
438
  if (!result) {
249
- await emitIntervention(bus, task, { phase: failurePhase });
439
+ await holdConsumedSignal(task, task.qaAgentId ?? task.agentId, 'spec-reviewed', 'handler-failed', failurePhase);
250
440
  }
251
441
  }
252
442
  catch (err) {
253
- await emitIntervention(bus, task, {
254
- phase: failurePhase,
255
- error: err instanceof Error ? err.message : String(err),
256
- });
443
+ await holdConsumedSignal(task, task.qaAgentId ?? task.agentId, 'spec-reviewed', 'handler-failed', failurePhase, { error: err instanceof Error ? err.message : String(err) });
257
444
  }
258
445
  }
259
446
  async function autoApproveSpec(task) {
260
447
  try {
261
448
  const result = await manager.transitionToCodePhase(task.id);
262
449
  if (!result) {
263
- await emitIntervention(bus, task, { phase: 'server-spec-auto-approve-transition-failed' });
450
+ await holdConsumedSignal(task, task.agentId, task.status === 'fixing' ? 'spec-fixed' : 'spec-done', 'handler-failed', 'server-spec-auto-approve-transition-failed');
264
451
  return false;
265
452
  }
266
453
  return true;
267
454
  }
268
455
  catch (err) {
269
- await emitIntervention(bus, task, {
270
- phase: 'server-spec-auto-approve-transition-failed',
271
- error: err instanceof Error ? err.message : String(err),
272
- });
456
+ await holdConsumedSignal(task, task.agentId, task.status === 'fixing' ? 'spec-fixed' : 'spec-done', 'handler-failed', 'server-spec-auto-approve-transition-failed', { error: err instanceof Error ? err.message : String(err) });
273
457
  return false;
274
458
  }
275
459
  }
276
460
  async function prepareAndDispatchCodeReview(task, opts) {
277
461
  const dev = manager.getAgentConfig(task.agentId);
278
462
  if (!dev) {
279
- await emitIntervention(bus, task, { phase: 'server-code-review-no-dev-agent' });
280
- if (task.agentId) {
281
- const kind = task.status === 'fixing' ? 'code-fixed' : 'code-done';
282
- await rearmOrHold(task, task.agentId, kind);
283
- }
284
- return false;
463
+ const kind = task.status === 'fixing' ? 'code-fixed' : 'code-done';
464
+ return holdConsumedSignal(task, task.agentId, kind, 'fix-no-dev-agent', 'server-code-review-no-dev-agent');
285
465
  }
286
466
  const cap = manager.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
287
- const nextRound = task.reviewRound + 1;
467
+ const nextRound = opts.bumpRound === false ? task.reviewRound : task.reviewRound + 1;
288
468
  if (nextRound > cap) {
289
469
  const capResult = await manager.transitionTaskStatus(task.id, 'max_rounds', { fromStatus: ['in_progress', 'fixing'] });
290
470
  if (!capResult)
@@ -303,49 +483,50 @@ export function registerServerEventHandlers(bus, manager) {
303
483
  });
304
484
  return false;
305
485
  }
306
- await manager.refreshWorkdirCacheFor(task.agentId);
307
486
  let content;
308
487
  let reviewHeadAnchorSha;
309
- try {
310
- content = await transport().readContent(task, dev, 'code');
311
- if (!content.headSha)
312
- throw new ReviewExchangeError('head-failed', 'review head missing from captured content');
313
- reviewHeadAnchorSha = content.headSha;
314
- }
315
- catch (err) {
316
- await emitIntervention(bus, task, {
317
- phase: 'server-code-content-read-failed',
318
- error: err instanceof Error ? err.message : String(err),
319
- });
320
- const kind = task.status === 'fixing' ? 'code-fixed' : 'code-done';
321
- await manager.setupPhaseSignal(task.id, task.agentId, kind, { skipSnapshot: true });
322
- return false;
323
- }
324
- const rearmEntry = async () => {
325
- const kind = task.status === 'fixing' ? 'code-fixed' : 'code-done';
326
- await manager.setupPhaseSignal(task.id, task.agentId, kind, { skipSnapshot: true });
327
- };
328
- try {
329
- const violation = await manager.findLineageViolation(task.id, content.baseSha);
330
- if (violation) {
331
- await emitIntervention(bus, task, {
332
- phase: 'server-code-lineage-violation',
333
- offendingTaskId: violation.taskId,
334
- offendingBranch: violation.branch,
335
- offendingSha: violation.sha,
336
- note: 'The task branch embeds another active task\'s commits — reviewing it would leak foreign work into this task. Have the dev rebase onto origin/HEAD and re-emit the signal.',
337
- });
338
- await rearmEntry();
339
- return false;
488
+ if (opts.bumpRound === false) {
489
+ const stored = await reviewStore.getRound(task.id, 'code', nextRound);
490
+ if (!stored || stored.phase !== 'code' || !stored.headSha) {
491
+ throw new Error(`Task ${task.id} has no persisted code review round ${nextRound} to redispatch`);
340
492
  }
493
+ content = {
494
+ content: stored.content,
495
+ ...(stored.diffstat ? { diffstat: stored.diffstat } : {}),
496
+ ...(stored.baseSha ? { baseSha: stored.baseSha } : {}),
497
+ headSha: stored.headSha,
498
+ ...(stored.headTree ? { headTree: stored.headTree } : {}),
499
+ };
500
+ reviewHeadAnchorSha = stored.headSha;
341
501
  }
342
- catch (err) {
343
- await emitIntervention(bus, task, {
344
- phase: 'server-code-lineage-check-failed',
345
- error: err instanceof Error ? err.message : String(err),
346
- });
347
- await rearmEntry();
348
- return false;
502
+ else {
503
+ await manager.refreshWorkdirCacheFor(task.agentId);
504
+ try {
505
+ content = await transport().readContent(task, dev, 'code');
506
+ if (!content.headSha)
507
+ throw new ReviewExchangeError('head-failed', 'review head missing from captured content');
508
+ reviewHeadAnchorSha = content.headSha;
509
+ }
510
+ catch (err) {
511
+ const kind = task.status === 'fixing' ? 'code-fixed' : 'code-done';
512
+ return holdConsumedSignal(task, task.agentId, kind, 'handler-failed', 'server-code-content-read-failed', { error: err instanceof Error ? err.message : String(err) });
513
+ }
514
+ try {
515
+ const violation = await manager.findLineageViolation(task.id, content.baseSha);
516
+ if (violation) {
517
+ const kind = task.status === 'fixing' ? 'code-fixed' : 'code-done';
518
+ return holdConsumedSignal(task, task.agentId, kind, 'handler-failed', 'server-code-lineage-violation', {
519
+ offendingTaskId: violation.taskId,
520
+ offendingBranch: violation.branch,
521
+ offendingSha: violation.sha,
522
+ note: 'The task branch embeds another active task\'s commits — reviewing it would leak foreign work into this task. Have the dev rebase onto origin/HEAD and re-emit the signal.',
523
+ });
524
+ }
525
+ }
526
+ catch (err) {
527
+ const kind = task.status === 'fixing' ? 'code-fixed' : 'code-done';
528
+ return holdConsumedSignal(task, task.agentId, kind, 'handler-failed', 'server-code-lineage-check-failed', { error: err instanceof Error ? err.message : String(err) });
529
+ }
349
530
  }
350
531
  const round = {
351
532
  round: nextRound,
@@ -357,21 +538,7 @@ export function registerServerEventHandlers(bus, manager) {
357
538
  ...(content.headTree ? { headTree: content.headTree } : {}),
358
539
  startedAt: new Date().toISOString(),
359
540
  };
360
- const putEntryRound = async (data) => {
361
- try {
362
- await reviewStore.putRound(task.id, 'code', data);
363
- return true;
364
- }
365
- catch (err) {
366
- await emitIntervention(bus, task, {
367
- phase: 'server-code-round-store-failed',
368
- error: err instanceof Error ? err.message : String(err),
369
- });
370
- const kind = task.status === 'fixing' ? 'code-fixed' : 'code-done';
371
- await manager.setupPhaseSignal(task.id, task.agentId, kind, { skipSnapshot: true });
372
- return false;
373
- }
374
- };
541
+ const putEntryRound = (data) => putVerdictRound(task, task.agentId, task.status === 'fixing' ? 'code-fixed' : 'code-done', data);
375
542
  const resolveRecheckInterdiff = async () => {
376
543
  if (!opts.recheck)
377
544
  return undefined;
@@ -385,12 +552,13 @@ export function registerServerEventHandlers(bus, manager) {
385
552
  return undefined;
386
553
  }
387
554
  };
388
- if (!(await putEntryRound(round)))
555
+ if (opts.bumpRound !== false && !(await putEntryRound(round)))
389
556
  return false;
390
557
  const interdiff = await resolveRecheckInterdiff();
391
558
  const dispatched = await manager.dispatchServerReviewToQa(task.id, {
392
559
  phase: 'code',
393
560
  recheck: opts.recheck,
561
+ ...(opts.bumpRound === false ? { bumpRound: false } : {}),
394
562
  content: content.content,
395
563
  reviewHeadAnchorSha,
396
564
  ...(content.baseSha ? { baseSha: content.baseSha } : {}),
@@ -399,8 +567,12 @@ export function registerServerEventHandlers(bus, manager) {
399
567
  ...(content.diffstat ? { diffstat: content.diffstat } : {}),
400
568
  ...(opts.priorFindingsJson ? { priorFindingsJson: opts.priorFindingsJson } : {}),
401
569
  ...(opts.priorResponseJson ? { priorResponseJson: opts.priorResponseJson } : {}),
570
+ callerOwnsConsumedSignalFailure: true,
402
571
  });
403
- return dispatched !== null;
572
+ if (dispatched)
573
+ return true;
574
+ const kind = task.status === 'fixing' ? 'code-fixed' : 'code-done';
575
+ return holdConsumedSignal(task, task.agentId, kind, 'handler-failed', 'server-code-review-dispatch-failed');
404
576
  }
405
577
  async function dispatchLegacyBatch(taskId, batches, index, diffstat, opts) {
406
578
  const text = batches[index].map(f => f.text).join('\n');
@@ -413,6 +585,7 @@ export function registerServerEventHandlers(bus, manager) {
413
585
  batch: { index, total: batches.length },
414
586
  ...(opts.priorFindingsJson ? { priorFindingsJson: opts.priorFindingsJson } : {}),
415
587
  ...(opts.priorResponseJson ? { priorResponseJson: opts.priorResponseJson } : {}),
588
+ callerOwnsConsumedSignalFailure: true,
416
589
  });
417
590
  return dispatched !== null;
418
591
  }
@@ -424,131 +597,125 @@ export function registerServerEventHandlers(bus, manager) {
424
597
  if (!gated)
425
598
  return;
426
599
  const { task } = gated;
427
- if (await pauseForUnavailableQa(task, 'code-done', 'server-code-qa-unavailable'))
428
- return;
429
- if (!task.qaAgentId) {
430
- await autoApproveCode(task);
431
- return;
432
- }
433
- await prepareAndDispatchCodeReview(task, { recheck: false });
600
+ await runConsumedSignal(task, task.agentId, 'code-done', async () => {
601
+ if (await pauseForUnavailableQa(task, 'code-done', 'server-code-qa-unavailable'))
602
+ return;
603
+ if (!task.qaAgentId) {
604
+ await autoApproveCode(task);
605
+ return;
606
+ }
607
+ await prepareAndDispatchCodeReview(task, { recheck: false });
608
+ });
434
609
  });
435
610
  bus.on('server.code.review.submitted', async (event) => {
436
611
  const gated = await gate(bus, manager, event, { status: 'review', phase: 'code', requireServerMode: true });
437
612
  if (!gated)
438
613
  return;
439
614
  const { task } = gated;
440
- const qa = manager.getAgentConfig(task.qaAgentId ?? '');
441
- if (!qa) {
442
- await emitIntervention(bus, task, { phase: 'server-code-review-no-qa-agent' });
443
- if (task.qaAgentId) {
444
- await rearmOrHold(task, task.qaAgentId, 'code-reviewed');
445
- }
446
- return;
447
- }
448
- const round = Math.max(task.reviewRound, 1);
449
- const roundData = await reviewStore.getRound(task.id, 'code', round);
450
- if (!roundData) {
451
- await emitIntervention(bus, task, { phase: 'server-code-review-round-missing', round });
452
- await manager.setupPhaseSignal(task.id, qa.id, 'code-reviewed', { skipSnapshot: true });
453
- return;
454
- }
455
- await manager.refreshWorkdirCacheFor(qa.id);
456
- let findings;
457
- try {
458
- findings = await transport().readFindings(task, qa);
459
- }
460
- catch (err) {
461
- const reason = err instanceof ReviewExchangeError ? err.reason : 'unknown';
462
- await emitIntervention(bus, task, {
463
- phase: 'server-code-findings-invalid',
464
- reason,
465
- error: err instanceof Error ? err.message : String(err),
466
- });
467
- await manager.setupPhaseSignal(task.id, qa.id, 'code-reviewed', { skipSnapshot: true });
468
- return;
469
- }
470
- let current = roundData;
471
- if (findings === null) {
472
- const alreadyStored = task.batchTotal !== undefined
473
- ? (roundData.batchFindings?.length ?? 0) > (task.batchIndex ?? 0)
474
- : roundData.findings !== undefined;
475
- if (!alreadyStored) {
476
- await emitIntervention(bus, task, { phase: 'server-code-findings-missing', round });
477
- await manager.setupPhaseSignal(task.id, qa.id, 'code-reviewed', { skipSnapshot: true });
615
+ await runConsumedSignal(task, task.qaAgentId ?? task.agentId, 'code-reviewed', async () => {
616
+ const qa = manager.getAgentConfig(task.qaAgentId ?? '');
617
+ if (!qa) {
618
+ await holdConsumedSignal(task, task.qaAgentId ?? '', 'code-reviewed', 'handler-failed', 'server-code-review-no-qa-agent');
478
619
  return;
479
620
  }
480
- }
481
- else if (findings.round !== round) {
482
- await emitIntervention(bus, task, {
483
- phase: 'server-code-findings-round-mismatch',
484
- round,
485
- payloadRound: findings.round,
486
- });
487
- await transport().deleteFindings(qa);
488
- await manager.setupPhaseSignal(task.id, qa.id, 'code-reviewed', { skipSnapshot: true });
489
- return;
490
- }
491
- else if (task.batchTotal !== undefined && task.batchIndex !== undefined) {
492
- const batchFindings = [...(roundData.batchFindings ?? [])];
493
- batchFindings[task.batchIndex] = findings;
494
- current = { ...roundData, batchFindings };
495
- if (!(await putVerdictRound(task, qa.id, 'code-reviewed', current)))
621
+ const round = Math.max(task.reviewRound, 1);
622
+ const roundData = await reviewStore.getRound(task.id, 'code', round);
623
+ if (!roundData) {
624
+ await holdConsumedSignal(task, qa.id, 'code-reviewed', 'handler-failed', 'server-code-review-round-missing', { round });
496
625
  return;
497
- await transport().deleteFindings(qa);
498
- }
499
- else {
500
- current = { ...roundData, findings, completedAt: new Date().toISOString() };
501
- if (!(await putVerdictRound(task, qa.id, 'code-reviewed', current)))
626
+ }
627
+ await manager.refreshWorkdirCacheFor(qa.id);
628
+ let findings;
629
+ try {
630
+ findings = await transport().readFindings(task, qa);
631
+ }
632
+ catch (err) {
633
+ const reason = err instanceof ReviewExchangeError ? err.reason : 'unknown';
634
+ await holdConsumedSignal(task, qa.id, 'code-reviewed', 'handler-failed', 'server-code-findings-invalid', { reason, error: err instanceof Error ? err.message : String(err) });
502
635
  return;
503
- await transport().deleteFindings(qa);
504
- }
505
- const isRecheck = round > 1;
506
- if (task.batchTotal !== undefined && task.batchIndex !== undefined) {
507
- const nextIndex = task.batchIndex + 1;
508
- if (nextIndex < task.batchTotal) {
509
- const prev = isRecheck ? await reviewStore.getRound(task.id, 'code', round - 1) : null;
510
- await dispatchLegacyBatch(task.id, rebuildLegacyBatches(current), nextIndex, current.diffstat, {
511
- recheck: isRecheck,
512
- ...(prev?.findings ? { priorFindingsJson: JSON.stringify(prev.findings) } : {}),
513
- ...(prev?.response ? { priorResponseJson: JSON.stringify(prev.response) } : {}),
514
- });
636
+ }
637
+ let current = roundData;
638
+ if (findings === null) {
639
+ const alreadyStored = task.batchTotal !== undefined
640
+ ? (roundData.batchFindings?.length ?? 0) > (task.batchIndex ?? 0)
641
+ : roundData.findings !== undefined;
642
+ if (!alreadyStored) {
643
+ await holdConsumedSignal(task, qa.id, 'code-reviewed', 'handler-failed', 'server-code-findings-missing', { round });
644
+ return;
645
+ }
646
+ }
647
+ else if (findings.round !== round) {
648
+ await holdConsumedSignal(task, qa.id, 'code-reviewed', 'handler-failed', 'server-code-findings-round-mismatch', { round, payloadRound: findings.round });
515
649
  return;
516
650
  }
517
- const aggregated = current.findings
518
- ?? aggregateBatchFindings((current.batchFindings ?? []).filter(Boolean), round);
519
- if (!current.findings) {
520
- const stored = await putVerdictRound(task, qa.id, 'code-reviewed', {
521
- ...current,
522
- findings: aggregated,
523
- completedAt: new Date().toISOString(),
524
- });
525
- if (!stored)
651
+ else if (task.batchTotal !== undefined && task.batchIndex !== undefined) {
652
+ const batchFindings = [...(roundData.batchFindings ?? [])];
653
+ batchFindings[task.batchIndex] = findings;
654
+ current = { ...roundData, batchFindings };
655
+ if (!(await putVerdictRound(task, qa.id, 'code-reviewed', current)))
656
+ return;
657
+ if (!(await deleteFindingsOrHold(task, qa, 'code-reviewed')))
526
658
  return;
527
659
  }
528
- await manager.updateTask(task.id, { batchIndex: undefined, batchTotal: undefined });
529
- await routeCodeVerdict({ ...task, batchIndex: undefined, batchTotal: undefined }, aggregated);
530
- return;
531
- }
532
- await routeCodeVerdict(task, current.findings);
660
+ else {
661
+ current = { ...roundData, findings, completedAt: new Date().toISOString() };
662
+ if (!(await putVerdictRound(task, qa.id, 'code-reviewed', current)))
663
+ return;
664
+ if (!(await deleteFindingsOrHold(task, qa, 'code-reviewed')))
665
+ return;
666
+ }
667
+ const isRecheck = round > 1;
668
+ if (task.batchTotal !== undefined && task.batchIndex !== undefined) {
669
+ const nextIndex = task.batchIndex + 1;
670
+ if (nextIndex < task.batchTotal) {
671
+ const prev = isRecheck ? await reviewStore.getRound(task.id, 'code', round - 1) : null;
672
+ const dispatched = await dispatchLegacyBatch(task.id, rebuildLegacyBatches(current), nextIndex, current.diffstat, {
673
+ recheck: isRecheck,
674
+ ...(prev?.findings ? { priorFindingsJson: JSON.stringify(prev.findings) } : {}),
675
+ ...(prev?.response ? { priorResponseJson: JSON.stringify(prev.response) } : {}),
676
+ });
677
+ if (!dispatched) {
678
+ await holdConsumedSignal(task, qa.id, 'code-reviewed', 'handler-failed', 'server-code-review-continuation-dispatch-failed');
679
+ }
680
+ return;
681
+ }
682
+ const aggregated = current.findings
683
+ ?? aggregateBatchFindings((current.batchFindings ?? []).filter(Boolean), round);
684
+ if (!current.findings) {
685
+ const stored = await putVerdictRound(task, qa.id, 'code-reviewed', {
686
+ ...current,
687
+ findings: aggregated,
688
+ completedAt: new Date().toISOString(),
689
+ });
690
+ if (!stored)
691
+ return;
692
+ }
693
+ await manager.updateTask(task.id, { batchIndex: undefined, batchTotal: undefined });
694
+ await routeCodeVerdict({ ...task, batchIndex: undefined, batchTotal: undefined }, aggregated);
695
+ return;
696
+ }
697
+ await routeCodeVerdict(task, current.findings);
698
+ });
533
699
  });
534
700
  async function routeCodeVerdict(task, findings) {
535
701
  if (findings.verdict === 'approve') {
536
- const afterDone = manager.resolveAfterDone(task);
537
- if (task.afterDone === undefined) {
538
- await manager.updateTask(task.id, { afterDone });
539
- }
702
+ const afterDone = await manager.commitServerAfterDone(task.id);
540
703
  if (afterDone === null) {
541
704
  const ready = await manager.transitionTaskStatus(task.id, 'ready', { fromStatus: ['review'] });
542
- if (!ready)
543
- await emitIntervention(bus, task, { phase: 'server-code-ready-transition-failed' });
705
+ if (!ready) {
706
+ await holdConsumedSignal(task, task.qaAgentId ?? task.agentId, 'code-reviewed', 'handler-failed', 'server-code-ready-transition-failed');
707
+ }
544
708
  return;
545
709
  }
546
710
  const approved = await manager.transitionTaskStatus(task.id, 'approved', { fromStatus: ['review'] });
547
711
  if (!approved) {
548
- await emitIntervention(bus, task, { phase: 'server-code-approved-transition-failed' });
712
+ await holdConsumedSignal(task, task.qaAgentId ?? task.agentId, 'code-reviewed', 'handler-failed', 'server-code-approved-transition-failed');
549
713
  return;
550
714
  }
551
- await manager.dispatchServerAfterDone(task.id, afterDone);
715
+ const dispatched = await manager.dispatchServerAfterDone(task.id, afterDone);
716
+ if (!dispatched) {
717
+ await holdConsumedSignal(task, task.qaAgentId ?? task.agentId, 'code-reviewed', 'handler-failed', 'server-code-publish-dispatch-failed');
718
+ }
552
719
  return;
553
720
  }
554
721
  const cap = manager.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
@@ -571,7 +738,10 @@ export function registerServerEventHandlers(bus, manager) {
571
738
  });
572
739
  return;
573
740
  }
574
- await manager.dispatchServerFixToDev(task.id, JSON.stringify(findings));
741
+ const dispatched = await manager.dispatchServerFixToDev(task.id, JSON.stringify(findings), { callerOwnsConsumedSignalFailure: true });
742
+ if (!dispatched) {
743
+ await holdConsumedSignal(task, task.qaAgentId ?? task.agentId, 'code-reviewed', 'handler-failed', 'server-code-fix-dispatch-failed');
744
+ }
575
745
  }
576
746
  async function recheckCodeOrAutoApprove(task, priorFindingsJson, priorResponseJson) {
577
747
  if (await pauseForUnavailableQa(task, 'code-fixed', 'server-code-qa-unavailable-after-fix')) {
@@ -586,154 +756,184 @@ export function registerServerEventHandlers(bus, manager) {
586
756
  async function runCodeFixSubmission(task) {
587
757
  const dev = manager.getAgentConfig(task.agentId);
588
758
  if (!dev) {
589
- await emitIntervention(bus, task, { phase: 'server-code-fix-no-dev-agent' });
590
- if (task.agentId) {
591
- await rearmOrHold(task, task.agentId, 'code-fixed');
592
- }
593
- return false;
759
+ return holdConsumedSignal(task, task.agentId, 'code-fixed', 'fix-no-dev-agent', 'server-code-fix-no-dev-agent');
594
760
  }
595
761
  const round = Math.max(task.reviewRound, 1);
596
762
  const roundData = await reviewStore.getRound(task.id, 'code', round);
597
763
  if (!roundData?.findings) {
598
- await emitIntervention(bus, task, { phase: 'server-code-fix-findings-missing', round });
599
- await manager.setupPhaseSignal(task.id, task.agentId, 'code-fixed', { skipSnapshot: true });
600
- return false;
764
+ return holdConsumedSignal(task, task.agentId, 'code-fixed', 'fix-findings-missing', 'server-code-fix-findings-missing', { round });
601
765
  }
602
766
  await manager.refreshWorkdirCacheFor(task.agentId);
603
- let response;
767
+ let readResult;
604
768
  try {
605
- response = await transport().readResponse(task, dev);
769
+ readResult = await readResponseWithRetry(task, dev);
606
770
  }
607
771
  catch (err) {
608
- await emitIntervention(bus, task, {
609
- phase: 'server-code-response-invalid',
772
+ return holdConsumedSignal(task, dev.id, 'code-fixed', 'response-read-failed', 'server-code-response-read-failed', {
773
+ reason: err instanceof ReviewExchangeError ? err.reason : 'read-failed',
610
774
  error: err instanceof Error ? err.message : String(err),
611
775
  });
612
- await manager.setupPhaseSignal(task.id, dev.id, 'code-fixed', { skipSnapshot: true });
613
- return false;
614
776
  }
615
- if (response === null) {
777
+ if (readResult.kind === 'unknown') {
778
+ return holdConsumedSignal(task, dev.id, 'code-fixed', 'response-read-failed', 'server-code-response-read-failed', { reason: readResult.error.reason, error: readResult.error.message });
779
+ }
780
+ if (readResult.kind === 'invalid') {
781
+ return rejectServerResponse(task, dev, roundData, {
782
+ reason: 'response-invalid',
783
+ failurePhase: 'server-code-response-invalid',
784
+ rawResponse: readResult.raw,
785
+ responseDigest: readResult.responseDigest,
786
+ schemaViolationCodes: readResult.schemaViolationCodes,
787
+ details: { error: readResult.error.message },
788
+ });
789
+ }
790
+ if (readResult.kind === 'absent') {
616
791
  if (roundData.response === undefined) {
617
- await emitIntervention(bus, task, { phase: 'server-code-response-missing', round });
618
- await manager.setupPhaseSignal(task.id, dev.id, 'code-fixed', { skipSnapshot: true });
619
- return false;
792
+ return rejectServerResponse(task, dev, roundData, {
793
+ reason: 'response-missing',
794
+ failurePhase: 'server-code-response-missing',
795
+ });
620
796
  }
621
797
  return recheckCodeOrAutoApprove(task, JSON.stringify(roundData.findings), JSON.stringify(roundData.response));
622
798
  }
799
+ const { response } = readResult;
623
800
  if (response.round !== round) {
624
- await emitIntervention(bus, task, {
625
- phase: 'server-code-response-round-mismatch',
626
- round,
627
- payloadRound: response.round,
801
+ return rejectServerResponse(task, dev, roundData, {
802
+ reason: 'round-mismatch',
803
+ failurePhase: 'server-code-response-round-mismatch',
804
+ rawResponse: readResult.raw,
805
+ responseDigest: readResult.responseDigest,
806
+ details: { payloadRound: response.round },
807
+ });
808
+ }
809
+ if (response.token !== task.signalToken) {
810
+ return rejectServerResponse(task, dev, roundData, {
811
+ reason: 'token-mismatch',
812
+ failurePhase: 'server-code-response-token-mismatch',
813
+ rawResponse: readResult.raw,
814
+ responseDigest: readResult.responseDigest,
815
+ });
816
+ }
817
+ const findingsDigest = reviewFindingsDigest(roundData.findings);
818
+ if (response.findingsDigest !== findingsDigest) {
819
+ return rejectServerResponse(task, dev, roundData, {
820
+ reason: 'findings-digest-mismatch',
821
+ failurePhase: 'server-code-response-findings-digest-mismatch',
822
+ rawResponse: readResult.raw,
823
+ responseDigest: readResult.responseDigest,
628
824
  });
629
- await transport().deleteResponse(dev);
630
- await manager.setupPhaseSignal(task.id, dev.id, 'code-fixed', { skipSnapshot: true });
631
- return false;
632
825
  }
633
826
  const gaps = coverageGaps(roundData.findings, new Set(response.responses.map(r => r.findingId)));
634
827
  if (gaps.missing.length > 0 || gaps.unknown.length > 0) {
635
- await emitIntervention(bus, task, {
636
- phase: 'server-code-response-coverage-gap',
637
- round,
828
+ return rejectServerResponse(task, dev, roundData, {
829
+ reason: 'coverage-gap',
830
+ failurePhase: 'server-code-response-coverage-gap',
831
+ rawResponse: readResult.raw,
832
+ responseDigest: readResult.responseDigest,
638
833
  missingFindingIds: gaps.missing,
639
834
  unknownFindingIds: gaps.unknown,
640
835
  });
641
- await manager.setupPhaseSignal(task.id, dev.id, 'code-fixed', { skipSnapshot: true });
642
- return false;
643
836
  }
644
837
  if (!(await putVerdictRound(task, dev.id, 'code-fixed', { ...roundData, response })))
645
838
  return false;
646
- await transport().deleteResponse(dev);
839
+ try {
840
+ await transport().deleteResponse(dev);
841
+ }
842
+ catch (err) {
843
+ return holdConsumedSignal(task, dev.id, 'code-fixed', 'handler-failed', 'server-code-response-cleanup-failed', { error: err instanceof Error ? err.message : String(err) });
844
+ }
647
845
  return recheckCodeOrAutoApprove(task, JSON.stringify(roundData.findings), JSON.stringify(response));
648
846
  }
649
847
  bus.on('server.code.fix.submitted', async (event) => {
650
848
  const gated = await gate(bus, manager, event, { status: 'fixing', phase: 'code', requireServerMode: true });
651
849
  if (!gated)
652
850
  return;
653
- await runCodeFixSubmission(gated.task);
851
+ await runConsumedSignal(gated.task, gated.task.agentId, 'code-fixed', async () => {
852
+ await runCodeFixSubmission(gated.task);
853
+ });
654
854
  });
655
855
  bus.on('server.code.published', async (event) => {
656
856
  const gated = await gate(bus, manager, event, { status: 'approved', phase: 'code', requireServerMode: true });
657
857
  if (!gated)
658
858
  return;
659
859
  const { task } = gated;
660
- const prNumber = typeof event.data?.prNumber === 'number' ? event.data.prNumber : undefined;
661
- if (prNumber === undefined && manager.resolveAfterDone(task) === 'pr' && task.prNumber === undefined) {
662
- await emitIntervention(bus, task, { phase: 'server-code-published-missing-pr-number' });
663
- await manager.setupPhaseSignal(task.id, task.agentId, 'code-ready', { skipSnapshot: true });
664
- return;
665
- }
666
- if (prNumber !== undefined) {
667
- const verified = await manager.verifyPaneSignalPrNumber(task.id, prNumber);
668
- if (!verified) {
669
- await emitIntervention(bus, task, {
670
- phase: 'server-code-published-pr-number-unverified',
860
+ await runConsumedSignal(task, task.agentId, 'code-ready', async () => {
861
+ const prNumber = typeof event.data?.prNumber === 'number' ? event.data.prNumber : task.prNumber;
862
+ if (prNumber === undefined && manager.resolveAfterDone(task) === 'pr') {
863
+ await holdConsumedSignal(task, task.agentId, 'code-ready', 'handler-failed', 'server-code-published-missing-pr-number');
864
+ return;
865
+ }
866
+ const dev = manager.getAgentConfig(task.agentId);
867
+ if (!dev) {
868
+ await holdConsumedSignal(task, task.agentId, 'code-ready', 'fix-no-dev-agent', 'server-code-published-no-dev-agent');
869
+ return;
870
+ }
871
+ await manager.refreshWorkdirCacheFor(task.agentId);
872
+ let publishedHead;
873
+ try {
874
+ publishedHead = await transport().readHeadSha(dev);
875
+ }
876
+ catch (err) {
877
+ await holdConsumedSignal(task, task.agentId, 'code-ready', 'handler-failed', 'server-code-published-head-capture-failed', { error: err instanceof Error ? err.message : String(err) });
878
+ return;
879
+ }
880
+ if (publishedHead !== task.reviewHeadAnchorSha) {
881
+ await holdConsumedSignal(task, task.agentId, 'code-ready', 'handler-failed', 'server-code-published-head-mismatch', {
882
+ publishedHead,
883
+ reviewedHead: task.reviewHeadAnchorSha ?? null,
884
+ });
885
+ return;
886
+ }
887
+ if (prNumber !== undefined) {
888
+ const verified = await manager.verifyPaneSignalPrNumber(task.id, prNumber);
889
+ if (!verified) {
890
+ await holdConsumedSignal(task, task.agentId, 'code-ready', 'handler-failed', 'server-code-published-pr-number-unverified', { prNumber });
891
+ return;
892
+ }
893
+ if (verified.headSha !== publishedHead) {
894
+ await holdConsumedSignal(task, task.agentId, 'code-ready', 'handler-failed', 'server-code-published-pr-head-mismatch', {
895
+ prNumber,
896
+ remoteHead: verified.headSha,
897
+ publishedHead,
898
+ reviewedHead: task.reviewHeadAnchorSha,
899
+ });
900
+ return;
901
+ }
902
+ await manager.updateTask(task.id, {
671
903
  prNumber,
904
+ ...(task.baseBranch === undefined ? { baseBranch: verified.targetBranch } : {}),
672
905
  });
673
- await manager.setupPhaseSignal(task.id, task.agentId, 'code-ready', { skipSnapshot: true });
906
+ }
907
+ try {
908
+ await manager.updateTask(task.id, { latestHeadSha: publishedHead });
909
+ }
910
+ catch (err) {
911
+ await holdConsumedSignal(task, task.agentId, 'code-ready', 'handler-failed', 'server-code-published-head-capture-failed', { error: err instanceof Error ? err.message : String(err) });
674
912
  return;
675
913
  }
676
- await manager.updateTask(task.id, { prNumber });
677
- }
678
- const dev = manager.getAgentConfig(task.agentId);
679
- if (!dev) {
680
- await emitIntervention(bus, task, { phase: 'server-code-published-no-dev-agent' });
681
- return;
682
- }
683
- await manager.refreshWorkdirCacheFor(task.agentId);
684
- let publishedHead;
685
- try {
686
- publishedHead = await transport().readHeadSha(dev);
687
- }
688
- catch (err) {
689
- await emitIntervention(bus, task, {
690
- phase: 'server-code-published-head-capture-failed',
691
- error: err instanceof Error ? err.message : String(err),
692
- });
693
- await manager.setupPhaseSignal(task.id, task.agentId, 'code-ready', { skipSnapshot: true });
694
- return;
695
- }
696
- if (publishedHead !== task.reviewHeadAnchorSha) {
697
- await emitIntervention(bus, task, {
698
- phase: 'server-code-published-head-mismatch',
699
- publishedHead,
700
- reviewedHead: task.reviewHeadAnchorSha ?? null,
701
- });
702
- return;
703
- }
704
- try {
705
- await manager.updateTask(task.id, { latestHeadSha: publishedHead });
706
- }
707
- catch (err) {
708
- await emitIntervention(bus, task, {
709
- phase: 'server-code-published-head-capture-failed',
710
- error: err instanceof Error ? err.message : String(err),
711
- });
712
- await manager.setupPhaseSignal(task.id, task.agentId, 'code-ready', { skipSnapshot: true });
713
- return;
714
- }
715
- const ready = await manager.transitionTaskStatus(task.id, 'ready', { fromStatus: ['approved'] });
716
- if (!ready)
717
- await emitIntervention(bus, task, { phase: 'server-code-published-transition-failed' });
914
+ const ready = await manager.transitionTaskStatus(task.id, 'ready', { fromStatus: ['approved'] });
915
+ if (!ready) {
916
+ await holdConsumedSignal(task, task.agentId, 'code-ready', 'handler-failed', 'server-code-published-transition-failed');
917
+ }
918
+ });
718
919
  });
719
920
  bus.on('server.spec.ready', async (event) => {
720
921
  const gated = await gate(bus, manager, event, { status: 'in_progress', phase: 'spec', requireServerMode: false });
721
922
  if (!gated)
722
923
  return;
723
- await dispatchSpecReview(gated.task);
924
+ await runConsumedSignal(gated.task, gated.task.agentId, 'spec-done', async () => {
925
+ await dispatchSpecReview(gated.task);
926
+ });
724
927
  });
725
- async function dispatchSpecReview(task, prior) {
928
+ async function dispatchSpecReview(task, prior, opts = {}) {
726
929
  const dev = manager.getAgentConfig(task.agentId);
727
930
  if (!dev) {
728
- await emitIntervention(bus, task, { phase: 'server-spec-review-no-dev-agent' });
729
- if (task.agentId) {
730
- const kind = task.status === 'fixing' ? 'spec-fixed' : 'spec-done';
731
- await rearmOrHold(task, task.agentId, kind);
732
- }
733
- return false;
931
+ const kind = task.status === 'fixing' ? 'spec-fixed' : 'spec-done';
932
+ return holdConsumedSignal(task, task.agentId, kind, 'fix-no-dev-agent', 'server-spec-review-no-dev-agent');
734
933
  }
735
934
  const cap = manager.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
736
- const nextRound = (task.specReviewRound ?? 0) + 1;
935
+ const currentRound = task.specReviewRound ?? 0;
936
+ const nextRound = opts.bumpRound === false ? currentRound : currentRound + 1;
737
937
  if (nextRound > cap) {
738
938
  const capResult = await manager.transitionTaskStatus(task.id, 'max_rounds', { fromStatus: ['in_progress', 'fixing'] });
739
939
  if (!capResult)
@@ -745,39 +945,38 @@ export function registerServerEventHandlers(bus, manager) {
745
945
  await releaseAtCap(paused, paused.agentId, 'agentId');
746
946
  return false;
747
947
  }
748
- await manager.refreshWorkdirCacheFor(task.agentId);
749
948
  let content;
750
- try {
751
- content = await transport().readContent(task, dev, 'spec');
752
- }
753
- catch (err) {
754
- await emitIntervention(bus, task, {
755
- phase: 'server-spec-content-read-failed',
756
- error: err instanceof Error ? err.message : String(err),
757
- });
758
- const kind = task.status === 'fixing' ? 'spec-fixed' : 'spec-done';
759
- await manager.setupPhaseSignal(task.id, task.agentId, kind, { skipSnapshot: true });
760
- return false;
761
- }
762
- try {
763
- if (!content.documents)
764
- throw new Error('spec transport returned no documents');
765
- await reviewStore.putRound(task.id, 'spec', {
766
- round: nextRound,
767
- phase: 'spec',
768
- content: content.content,
769
- documents: content.documents,
770
- startedAt: new Date().toISOString(),
771
- });
949
+ if (opts.bumpRound === false) {
950
+ const stored = await reviewStore.getRound(task.id, 'spec', nextRound);
951
+ if (!stored || stored.phase !== 'spec') {
952
+ throw new Error(`Task ${task.id} has no persisted spec review round ${nextRound} to redispatch`);
953
+ }
954
+ content = { content: stored.content, documents: stored.documents };
772
955
  }
773
- catch (err) {
774
- await emitIntervention(bus, task, {
775
- phase: 'server-spec-round-store-failed',
776
- error: err instanceof Error ? err.message : String(err),
777
- });
778
- const kind = task.status === 'fixing' ? 'spec-fixed' : 'spec-done';
779
- await manager.setupPhaseSignal(task.id, task.agentId, kind, { skipSnapshot: true });
780
- return false;
956
+ else {
957
+ await manager.refreshWorkdirCacheFor(task.agentId);
958
+ try {
959
+ content = await transport().readContent(task, dev, 'spec');
960
+ }
961
+ catch (err) {
962
+ const kind = task.status === 'fixing' ? 'spec-fixed' : 'spec-done';
963
+ return holdConsumedSignal(task, task.agentId, kind, 'handler-failed', 'server-spec-content-read-failed', { error: err instanceof Error ? err.message : String(err) });
964
+ }
965
+ try {
966
+ if (!content.documents)
967
+ throw new Error('spec transport returned no documents');
968
+ await reviewStore.putRound(task.id, 'spec', {
969
+ round: nextRound,
970
+ phase: 'spec',
971
+ content: content.content,
972
+ documents: content.documents,
973
+ startedAt: new Date().toISOString(),
974
+ });
975
+ }
976
+ catch (err) {
977
+ const kind = task.status === 'fixing' ? 'spec-fixed' : 'spec-done';
978
+ return holdConsumedSignal(task, task.agentId, kind, 'verdict-store-failed', 'server-spec-round-store-failed', { error: err instanceof Error ? err.message : String(err) });
979
+ }
781
980
  }
782
981
  if (!resolveActiveQa(task)) {
783
982
  if (task.qaAgentId) {
@@ -788,164 +987,188 @@ export function registerServerEventHandlers(bus, manager) {
788
987
  }
789
988
  if (specApprovalIsHuman(task) || task.qaAgentId) {
790
989
  const parked = await manager.parkTaskAtSpecReady(task.id, { specReviewRound: nextRound });
791
- if (!parked)
792
- await emitIntervention(bus, task, { phase: 'server-spec-park-transition-failed' });
990
+ if (!parked) {
991
+ await holdConsumedSignal(task, task.agentId, task.status === 'fixing' ? 'spec-fixed' : 'spec-done', 'handler-failed', 'server-spec-park-transition-failed');
992
+ }
793
993
  return !!parked;
794
994
  }
795
995
  return autoApproveSpec(task);
796
996
  }
797
997
  const dispatched = await manager.dispatchServerReviewToQa(task.id, {
798
998
  phase: 'spec',
999
+ ...(opts.bumpRound === false ? { bumpRound: false } : {}),
799
1000
  content: content.content,
800
1001
  ...(prior ? { priorFindingsJson: prior.findingsJson } : {}),
801
1002
  ...(prior?.responseJson ? { priorResponseJson: prior.responseJson } : {}),
1003
+ callerOwnsConsumedSignalFailure: true,
802
1004
  });
803
- return dispatched !== null;
1005
+ if (dispatched)
1006
+ return true;
1007
+ return holdConsumedSignal(task, task.agentId, task.status === 'fixing' ? 'spec-fixed' : 'spec-done', 'handler-failed', 'server-spec-review-dispatch-failed');
804
1008
  }
805
1009
  bus.on('server.spec.review.submitted', async (event) => {
806
1010
  const gated = await gate(bus, manager, event, { status: 'review', phase: 'spec', requireServerMode: false });
807
1011
  if (!gated)
808
1012
  return;
809
1013
  const { task } = gated;
810
- const qa = manager.getAgentConfig(task.qaAgentId ?? '');
811
- if (!qa) {
812
- await emitIntervention(bus, task, { phase: 'server-spec-review-no-qa-agent' });
813
- if (task.qaAgentId) {
814
- await rearmOrHold(task, task.qaAgentId, 'spec-reviewed');
1014
+ await runConsumedSignal(task, task.qaAgentId ?? task.agentId, 'spec-reviewed', async () => {
1015
+ const qa = manager.getAgentConfig(task.qaAgentId ?? '');
1016
+ if (!qa) {
1017
+ await holdConsumedSignal(task, task.qaAgentId ?? '', 'spec-reviewed', 'handler-failed', 'server-spec-review-no-qa-agent');
1018
+ return;
815
1019
  }
816
- return;
817
- }
818
- const round = task.specReviewRound ?? 1;
819
- const roundData = await reviewStore.getRound(task.id, 'spec', round);
820
- if (!roundData) {
821
- await emitIntervention(bus, task, { phase: 'server-spec-review-round-missing', round });
822
- await manager.setupPhaseSignal(task.id, qa.id, 'spec-reviewed', { skipSnapshot: true });
823
- return;
824
- }
825
- await manager.refreshWorkdirCacheFor(qa.id);
826
- let findings;
827
- try {
828
- findings = await transport().readFindings(task, qa);
829
- }
830
- catch (err) {
831
- await emitIntervention(bus, task, {
832
- phase: 'server-spec-findings-invalid',
833
- error: err instanceof Error ? err.message : String(err),
834
- });
835
- await manager.setupPhaseSignal(task.id, qa.id, 'spec-reviewed', { skipSnapshot: true });
836
- return;
837
- }
838
- let effective = findings;
839
- if (effective === null) {
840
- if (roundData.findings === undefined) {
841
- await emitIntervention(bus, task, { phase: 'server-spec-findings-missing', round });
842
- await manager.setupPhaseSignal(task.id, qa.id, 'spec-reviewed', { skipSnapshot: true });
1020
+ const round = task.specReviewRound ?? 1;
1021
+ const roundData = await reviewStore.getRound(task.id, 'spec', round);
1022
+ if (!roundData) {
1023
+ await holdConsumedSignal(task, qa.id, 'spec-reviewed', 'handler-failed', 'server-spec-review-round-missing', { round });
843
1024
  return;
844
1025
  }
845
- effective = roundData.findings;
846
- }
847
- else if (effective.round !== round) {
848
- await emitIntervention(bus, task, {
849
- phase: 'server-spec-findings-round-mismatch',
850
- round,
851
- payloadRound: effective.round,
852
- });
853
- await transport().deleteFindings(qa);
854
- await manager.setupPhaseSignal(task.id, qa.id, 'spec-reviewed', { skipSnapshot: true });
855
- return;
856
- }
857
- else {
858
- const stored = await putVerdictRound(task, qa.id, 'spec-reviewed', {
859
- ...roundData,
860
- findings: effective,
861
- completedAt: new Date().toISOString(),
862
- });
863
- if (!stored)
1026
+ await manager.refreshWorkdirCacheFor(qa.id);
1027
+ let findings;
1028
+ try {
1029
+ findings = await transport().readFindings(task, qa);
1030
+ }
1031
+ catch (err) {
1032
+ await holdConsumedSignal(task, qa.id, 'spec-reviewed', 'handler-failed', 'server-spec-findings-invalid', { error: err instanceof Error ? err.message : String(err) });
864
1033
  return;
865
- await transport().deleteFindings(qa);
866
- }
867
- if (effective.verdict === 'approve') {
868
- await advanceApprovedSpec(task, 'server-spec-approve-transition-failed');
869
- return;
870
- }
871
- const cap = manager.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
872
- if ((task.specReviewRound ?? 0) >= cap) {
873
- const capResult = await manager.transitionTaskStatus(task.id, 'max_rounds', { fromStatus: ['review'] });
874
- if (!capResult)
1034
+ }
1035
+ let effective = findings;
1036
+ if (effective === null) {
1037
+ if (roundData.findings === undefined) {
1038
+ await holdConsumedSignal(task, qa.id, 'spec-reviewed', 'handler-failed', 'server-spec-findings-missing', { round });
1039
+ return;
1040
+ }
1041
+ effective = roundData.findings;
1042
+ }
1043
+ else if (effective.round !== round) {
1044
+ await holdConsumedSignal(task, qa.id, 'spec-reviewed', 'handler-failed', 'server-spec-findings-round-mismatch', { round, payloadRound: effective.round });
875
1045
  return;
876
- const paused = capResult.task;
877
- if (paused.qaAgentId)
878
- await releaseAtCap(paused, paused.qaAgentId, 'qaAgentId');
879
- if (paused.agentId)
880
- await releaseAtCap(paused, paused.agentId, 'agentId');
881
- return;
882
- }
883
- await manager.dispatchServerFixToDev(task.id, JSON.stringify(effective));
1046
+ }
1047
+ else {
1048
+ const stored = await putVerdictRound(task, qa.id, 'spec-reviewed', {
1049
+ ...roundData,
1050
+ findings: effective,
1051
+ completedAt: new Date().toISOString(),
1052
+ });
1053
+ if (!stored)
1054
+ return;
1055
+ if (!(await deleteFindingsOrHold(task, qa, 'spec-reviewed')))
1056
+ return;
1057
+ }
1058
+ if (effective.verdict === 'approve') {
1059
+ await advanceApprovedSpec(task, 'server-spec-approve-transition-failed');
1060
+ return;
1061
+ }
1062
+ const cap = manager.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
1063
+ if ((task.specReviewRound ?? 0) >= cap) {
1064
+ const capResult = await manager.transitionTaskStatus(task.id, 'max_rounds', { fromStatus: ['review'] });
1065
+ if (!capResult)
1066
+ return;
1067
+ const paused = capResult.task;
1068
+ if (paused.qaAgentId)
1069
+ await releaseAtCap(paused, paused.qaAgentId, 'qaAgentId');
1070
+ if (paused.agentId)
1071
+ await releaseAtCap(paused, paused.agentId, 'agentId');
1072
+ return;
1073
+ }
1074
+ const dispatched = await manager.dispatchServerFixToDev(task.id, JSON.stringify(effective), { callerOwnsConsumedSignalFailure: true });
1075
+ if (!dispatched) {
1076
+ await holdConsumedSignal(task, task.qaAgentId ?? task.agentId, 'spec-reviewed', 'handler-failed', 'server-spec-fix-dispatch-failed');
1077
+ }
1078
+ });
884
1079
  });
885
1080
  async function runSpecFixSubmission(task) {
886
1081
  const dev = manager.getAgentConfig(task.agentId);
887
1082
  if (!dev) {
888
- await emitIntervention(bus, task, { phase: 'server-spec-fix-no-dev-agent' });
889
- if (task.agentId) {
890
- await rearmOrHold(task, task.agentId, 'spec-fixed');
891
- }
892
- return false;
1083
+ return holdConsumedSignal(task, task.agentId, 'spec-fixed', 'fix-no-dev-agent', 'server-spec-fix-no-dev-agent');
893
1084
  }
894
1085
  const round = task.specReviewRound ?? 1;
895
1086
  const roundData = await reviewStore.getRound(task.id, 'spec', round);
896
1087
  if (!roundData?.findings) {
897
- await emitIntervention(bus, task, { phase: 'server-spec-fix-findings-missing', round });
898
- await manager.setupPhaseSignal(task.id, task.agentId, 'spec-fixed', { skipSnapshot: true });
899
- return false;
1088
+ return holdConsumedSignal(task, task.agentId, 'spec-fixed', 'fix-findings-missing', 'server-spec-fix-findings-missing', { round });
900
1089
  }
901
1090
  await manager.refreshWorkdirCacheFor(task.agentId);
902
- let response;
1091
+ let readResult;
903
1092
  try {
904
- response = await transport().readResponse(task, dev);
1093
+ readResult = await readResponseWithRetry(task, dev);
905
1094
  }
906
1095
  catch (err) {
907
- await emitIntervention(bus, task, {
908
- phase: 'server-spec-response-invalid',
1096
+ return holdConsumedSignal(task, dev.id, 'spec-fixed', 'response-read-failed', 'server-spec-response-read-failed', {
1097
+ reason: err instanceof ReviewExchangeError ? err.reason : 'read-failed',
909
1098
  error: err instanceof Error ? err.message : String(err),
910
1099
  });
911
- await manager.setupPhaseSignal(task.id, dev.id, 'spec-fixed', { skipSnapshot: true });
912
- return false;
913
1100
  }
914
- if (response === null) {
1101
+ if (readResult.kind === 'unknown') {
1102
+ return holdConsumedSignal(task, dev.id, 'spec-fixed', 'response-read-failed', 'server-spec-response-read-failed', { reason: readResult.error.reason, error: readResult.error.message });
1103
+ }
1104
+ if (readResult.kind === 'invalid') {
1105
+ return rejectServerResponse(task, dev, roundData, {
1106
+ reason: 'response-invalid',
1107
+ failurePhase: 'server-spec-response-invalid',
1108
+ rawResponse: readResult.raw,
1109
+ responseDigest: readResult.responseDigest,
1110
+ schemaViolationCodes: readResult.schemaViolationCodes,
1111
+ details: { error: readResult.error.message },
1112
+ });
1113
+ }
1114
+ if (readResult.kind === 'absent') {
915
1115
  if (roundData.response === undefined) {
916
- await emitIntervention(bus, task, { phase: 'server-spec-response-missing', round });
917
- await manager.setupPhaseSignal(task.id, dev.id, 'spec-fixed', { skipSnapshot: true });
918
- return false;
1116
+ return rejectServerResponse(task, dev, roundData, {
1117
+ reason: 'response-missing',
1118
+ failurePhase: 'server-spec-response-missing',
1119
+ });
919
1120
  }
920
1121
  return dispatchSpecReview(task, {
921
1122
  findingsJson: JSON.stringify(roundData.findings),
922
1123
  responseJson: JSON.stringify(roundData.response),
923
1124
  });
924
1125
  }
1126
+ const { response } = readResult;
925
1127
  if (response.round !== round) {
926
- await emitIntervention(bus, task, {
927
- phase: 'server-spec-response-round-mismatch',
928
- round,
929
- payloadRound: response.round,
1128
+ return rejectServerResponse(task, dev, roundData, {
1129
+ reason: 'round-mismatch',
1130
+ failurePhase: 'server-spec-response-round-mismatch',
1131
+ rawResponse: readResult.raw,
1132
+ responseDigest: readResult.responseDigest,
1133
+ details: { payloadRound: response.round },
1134
+ });
1135
+ }
1136
+ if (response.token !== task.signalToken) {
1137
+ return rejectServerResponse(task, dev, roundData, {
1138
+ reason: 'token-mismatch',
1139
+ failurePhase: 'server-spec-response-token-mismatch',
1140
+ rawResponse: readResult.raw,
1141
+ responseDigest: readResult.responseDigest,
1142
+ });
1143
+ }
1144
+ const findingsDigest = reviewFindingsDigest(roundData.findings);
1145
+ if (response.findingsDigest !== findingsDigest) {
1146
+ return rejectServerResponse(task, dev, roundData, {
1147
+ reason: 'findings-digest-mismatch',
1148
+ failurePhase: 'server-spec-response-findings-digest-mismatch',
1149
+ rawResponse: readResult.raw,
1150
+ responseDigest: readResult.responseDigest,
930
1151
  });
931
- await transport().deleteResponse(dev);
932
- await manager.setupPhaseSignal(task.id, dev.id, 'spec-fixed', { skipSnapshot: true });
933
- return false;
934
1152
  }
935
1153
  const gaps = coverageGaps(roundData.findings, new Set(response.responses.map(r => r.findingId)));
936
1154
  if (gaps.missing.length > 0 || gaps.unknown.length > 0) {
937
- await emitIntervention(bus, task, {
938
- phase: 'server-spec-response-coverage-gap',
939
- round,
1155
+ return rejectServerResponse(task, dev, roundData, {
1156
+ reason: 'coverage-gap',
1157
+ failurePhase: 'server-spec-response-coverage-gap',
1158
+ rawResponse: readResult.raw,
1159
+ responseDigest: readResult.responseDigest,
940
1160
  missingFindingIds: gaps.missing,
941
1161
  unknownFindingIds: gaps.unknown,
942
1162
  });
943
- await manager.setupPhaseSignal(task.id, dev.id, 'spec-fixed', { skipSnapshot: true });
944
- return false;
945
1163
  }
946
1164
  if (!(await putVerdictRound(task, dev.id, 'spec-fixed', { ...roundData, response })))
947
1165
  return false;
948
- await transport().deleteResponse(dev);
1166
+ try {
1167
+ await transport().deleteResponse(dev);
1168
+ }
1169
+ catch (err) {
1170
+ return holdConsumedSignal(task, dev.id, 'spec-fixed', 'handler-failed', 'server-spec-response-cleanup-failed', { error: err instanceof Error ? err.message : String(err) });
1171
+ }
949
1172
  return dispatchSpecReview(task, {
950
1173
  findingsJson: JSON.stringify(roundData.findings),
951
1174
  responseJson: JSON.stringify(response),
@@ -955,7 +1178,9 @@ export function registerServerEventHandlers(bus, manager) {
955
1178
  const gated = await gate(bus, manager, event, { status: 'fixing', phase: 'spec', requireServerMode: false });
956
1179
  if (!gated)
957
1180
  return;
958
- await runSpecFixSubmission(gated.task);
1181
+ await runConsumedSignal(gated.task, gated.task.agentId, 'spec-fixed', async () => {
1182
+ await runSpecFixSubmission(gated.task);
1183
+ });
959
1184
  });
960
1185
  async function latestVerdictRound(taskId, phase, fromRound) {
961
1186
  for (let r = fromRound; r >= 1; r--) {
@@ -968,26 +1193,28 @@ export function registerServerEventHandlers(bus, manager) {
968
1193
  // 手工发起复用自然入口的完整协议:fixing 走 fix-submission(读/校验 dev 的 response 后派 recheck,
969
1194
  // 缺 response 拒绝);其余状态按最近结论轮携带 prior 上下文,否则 QA 无从核对上一轮 findings
970
1195
  manager.setServerReviewDriver({
971
- dispatchCodeReview: async (task) => {
1196
+ dispatchCodeReview: async (task, opts = {}) => {
972
1197
  if (task.status === 'fixing')
973
1198
  return runCodeFixSubmission(task);
974
1199
  const prior = await latestVerdictRound(task.id, 'code', task.reviewRound);
975
1200
  return prepareAndDispatchCodeReview(task, {
976
1201
  recheck: prior !== null,
1202
+ ...(opts.bumpRound === false ? { bumpRound: false } : {}),
977
1203
  ...(prior?.findings ? { priorFindingsJson: JSON.stringify(prior.findings) } : {}),
978
1204
  ...(prior?.response ? { priorResponseJson: JSON.stringify(prior.response) } : {}),
979
1205
  });
980
1206
  },
981
- dispatchSpecReview: async (task) => {
1207
+ dispatchSpecReview: async (task, opts = {}) => {
982
1208
  if (task.status === 'fixing')
983
1209
  return runSpecFixSubmission(task);
984
1210
  const prior = await latestVerdictRound(task.id, 'spec', task.specReviewRound ?? 0);
985
- return dispatchSpecReview(task, prior?.findings
1211
+ const priorContext = prior?.findings
986
1212
  ? {
987
1213
  findingsJson: JSON.stringify(prior.findings),
988
1214
  ...(prior.response ? { responseJson: JSON.stringify(prior.response) } : {}),
989
1215
  }
990
- : undefined);
1216
+ : undefined;
1217
+ return dispatchSpecReview(task, priorContext, opts);
991
1218
  },
992
1219
  });
993
1220
  }