baxian 1.2.4 → 1.2.5

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.
@@ -146,6 +146,499 @@ async function gateDevForPostApproveRedispatch(bus, manager, task) {
146
146
  });
147
147
  return false;
148
148
  }
149
+ async function handlePrMergeReady(bus, manager, event) {
150
+ const taskId = event.taskId;
151
+ const taskNow = await manager.getTask(taskId);
152
+ if (!taskNow)
153
+ return;
154
+ const eventPrNumber = event.data.prNumber;
155
+ const eventPrUrl = event.data.prUrl;
156
+ const needsPatch = (eventPrNumber !== undefined && eventPrNumber !== taskNow.prNumber)
157
+ || (eventPrUrl !== undefined && eventPrUrl !== taskNow.prUrl);
158
+ if (needsPatch) {
159
+ await manager.updateTask(taskId, {
160
+ ...(eventPrNumber !== undefined ? { prNumber: eventPrNumber } : {}),
161
+ ...(eventPrUrl !== undefined ? { prUrl: eventPrUrl } : {}),
162
+ });
163
+ }
164
+ const verdictAgentId = event.data.verdictAgentId;
165
+ // PhaseSignalWatcher unified `data.token`; old PostApproveSignalWatcher used
166
+ // `data.signalToken` — reading the wrong field strands approved tasks.
167
+ const signalToken = event.data.token;
168
+ const completion = await manager.getPostApproveCompletion(taskNow.id);
169
+ if (taskNow.status !== 'approved'
170
+ || verdictAgentId !== taskNow.agentId
171
+ || !signalToken
172
+ || completion?.token !== signalToken)
173
+ return;
174
+ const ok = await manager.markAgentWaiting(taskNow.agentId, taskNow.id);
175
+ if (!ok) {
176
+ await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
177
+ phase: 'post-approve-dev-wait-gate-failed',
178
+ });
179
+ return;
180
+ }
181
+ const freshTask = await manager.getTask(taskNow.id);
182
+ const freshCompletion = await manager.getPostApproveCompletion(taskNow.id);
183
+ if (!freshTask
184
+ || freshTask.status !== 'approved'
185
+ || freshTask.agentId !== taskNow.agentId
186
+ || freshCompletion?.token !== signalToken) {
187
+ await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
188
+ phase: 'post-approve-merge-skipped-stale-task',
189
+ });
190
+ return;
191
+ }
192
+ if (freshCompletion.pendingRedispatch) {
193
+ const nextCount = (freshCompletion.redispatchCount ?? 0) + 1;
194
+ if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
195
+ await manager.clearPostApproveCompletion(freshTask.id);
196
+ await emitIntervention(bus, freshTask.projectId, freshTask.agentId, freshTask.id, {
197
+ phase: 'post-approve-redispatch-cap-exceeded',
198
+ redispatchCount: freshCompletion.redispatchCount ?? 0,
199
+ cap: POST_APPROVE_REDISPATCH_CAP,
200
+ });
201
+ return;
202
+ }
203
+ await dispatchDevPostApproveCheck(bus, manager, freshTask, freshCompletion.approvedHeadSha, { redispatchCount: nextCount });
204
+ return;
205
+ }
206
+ // merge:'auto' decides what confirm executes; persist the approved head so
207
+ // confirm's merge guard catches a push inside the gate window.
208
+ const readied = await manager.transitionTaskStatus(freshTask.id, 'merge-ready', { fromStatus: ['approved'] }, { latestHeadSha: freshCompletion.approvedHeadSha });
209
+ if (readied) {
210
+ await manager.clearPostApproveCompletionIfMatches(freshTask.id, signalToken);
211
+ }
212
+ }
213
+ async function handlePrFeedback(bus, manager, event) {
214
+ const taskId = event.taskId;
215
+ const taskNow = await manager.getTask(taskId);
216
+ if (!taskNow)
217
+ return;
218
+ const eventPrNumber = event.data.prNumber;
219
+ const eventPrUrl = event.data.prUrl;
220
+ const eventKind = event.data.kind;
221
+ const prPatch = {
222
+ ...(eventPrNumber !== undefined ? { prNumber: eventPrNumber } : {}),
223
+ ...(eventPrUrl !== undefined ? { prUrl: eventPrUrl } : {}),
224
+ };
225
+ const needsPatch = (eventPrNumber !== undefined && eventPrNumber !== taskNow.prNumber)
226
+ || (eventPrUrl !== undefined && eventPrUrl !== taskNow.prUrl);
227
+ if (needsPatch) {
228
+ await manager.updateTask(taskId, prPatch);
229
+ }
230
+ if (taskNow.status !== 'approved')
231
+ return;
232
+ const isNewFeedback = eventKind === 'comment' || eventKind === 'review-comment';
233
+ if (!isNewFeedback)
234
+ return;
235
+ const completion = await manager.getPostApproveCompletion(taskNow.id);
236
+ if (!completion) {
237
+ await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
238
+ phase: 'post-approve-approved-head-unavailable',
239
+ });
240
+ return;
241
+ }
242
+ // Don't Ctrl-C dev mid-pass on its own webhook echo — coalesce via pendingRedispatch.
243
+ const devState = await manager.getAgentState(taskNow.agentId);
244
+ if (devState?.taskId === taskNow.id) {
245
+ if (!completion.pendingRedispatch) {
246
+ await manager.setPostApproveCompletion(taskNow.id, {
247
+ token: completion.token,
248
+ approvedHeadSha: completion.approvedHeadSha,
249
+ ...(typeof completion.redispatchCount === 'number'
250
+ ? { redispatchCount: completion.redispatchCount } : {}),
251
+ pendingRedispatch: true,
252
+ });
253
+ }
254
+ return;
255
+ }
256
+ const nextCount = (completion.redispatchCount ?? 0) + 1;
257
+ if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
258
+ await manager.clearPostApproveCompletion(taskNow.id);
259
+ await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
260
+ phase: 'post-approve-redispatch-cap-exceeded',
261
+ redispatchCount: completion.redispatchCount ?? 0,
262
+ cap: POST_APPROVE_REDISPATCH_CAP,
263
+ });
264
+ return;
265
+ }
266
+ const ready = await gateDevForPostApproveRedispatch(bus, manager, taskNow);
267
+ if (!ready)
268
+ return;
269
+ await dispatchDevPostApproveCheck(bus, manager, { ...taskNow, ...prPatch }, completion.approvedHeadSha, { redispatchCount: nextCount });
270
+ }
271
+ async function handlePrCodePush(bus, manager, event) {
272
+ const taskId = event.taskId;
273
+ const agentId = event.agentId;
274
+ const eventPrNumber = event.data.prNumber;
275
+ const eventPrUrl = event.data.prUrl;
276
+ const eventKind = event.data.kind;
277
+ const eventHeadSha = validHeadSha(event.data.headSha);
278
+ // Only `push` freshens `latestHeadSha` — legacy events (kind=undefined) use headSha
279
+ // for the review anchor only, not for the staleness fallback cache.
280
+ const prPatch = {
281
+ ...(eventPrNumber !== undefined ? { prNumber: eventPrNumber } : {}),
282
+ ...(eventPrUrl !== undefined ? { prUrl: eventPrUrl } : {}),
283
+ ...(eventKind === 'push' && eventHeadSha ? { latestHeadSha: eventHeadSha } : {}),
284
+ };
285
+ const taskBeforeTransition = await manager.getTask(taskId);
286
+ if (!taskBeforeTransition)
287
+ return;
288
+ const willHavePrNumber = taskBeforeTransition.prNumber !== undefined || eventPrNumber !== undefined;
289
+ if (taskBeforeTransition.status === 'in_progress' && !willHavePrNumber) {
290
+ console.warn(`[EventHandler] pr.updated: task ${taskId} in_progress but neither task nor event has prNumber; ` +
291
+ `deferring catch-up`);
292
+ return;
293
+ }
294
+ // Anchor at dispatch time — must NOT shift if a subsequent push lands mid-review.
295
+ const anchorAtDispatch = eventHeadSha ?? validHeadSha(taskBeforeTransition.latestHeadSha);
296
+ const result = await manager.transitionTaskStatus(taskId, 'review', { fromStatus: ['in_progress', 'fixing', 'review', 'approved', 'merge-ready'] }, {
297
+ ...prPatch,
298
+ ...(anchorAtDispatch ? { reviewHeadAnchorSha: anchorAtDispatch } : {}),
299
+ reviewDispatchedAt: new Date().toISOString(),
300
+ // Rotate token atomically so an old QA's late verdict is rejected by the gate.
301
+ signalToken: createSignalToken(),
302
+ });
303
+ if (!result)
304
+ return;
305
+ const { task: transitioned, previousStatus } = result;
306
+ const expectedRound = transitioned.reviewRound;
307
+ await manager.clearPostApproveCompletion(transitioned.id);
308
+ let devAlreadyWaiting = false;
309
+ if (previousStatus === 'approved' || previousStatus === 'merge-ready') {
310
+ devAlreadyWaiting = await manager
311
+ .releaseAgentForTask(transitioned.agentId, transitioned.id, 'waiting')
312
+ .catch(err => {
313
+ console.error(`[EventHandler] pr.updated releaseAgentForTask(dev=${transitioned.agentId}) before approved→recheck failed:`, err);
314
+ return false;
315
+ });
316
+ if (!devAlreadyWaiting) {
317
+ await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
318
+ phase: 'post-approve-dev-wait-gate-failed-before-recheck',
319
+ devAgentId: transitioned.agentId,
320
+ });
321
+ return;
322
+ }
323
+ }
324
+ if (previousStatus === 'review' && transitioned.qaAgentId) {
325
+ const released = await manager
326
+ .releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle')
327
+ .catch(err => {
328
+ console.error(`[EventHandler] pr.updated releaseAgentForTask(QA=${transitioned.qaAgentId}) for review→review push failed:`, err);
329
+ return false;
330
+ });
331
+ if (!released) {
332
+ await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
333
+ phase: 'qa-release-failed-cannot-recheck',
334
+ qaAgentId: transitioned.qaAgentId,
335
+ });
336
+ return;
337
+ }
338
+ }
339
+ const qaPhase = previousStatus === 'fixing' || previousStatus === 'review'
340
+ || previousStatus === 'approved' || previousStatus === 'merge-ready'
341
+ ? 'recheck'
342
+ : 'review';
343
+ const qa = manager.findQaPartner(agentId);
344
+ if (!qa) {
345
+ if (!devAlreadyWaiting && !(await manager.markAgentWaiting(agentId, transitioned.id))) {
346
+ await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
347
+ phase: 'dev-wait-gate-failed-no-qa',
348
+ });
349
+ }
350
+ return;
351
+ }
352
+ const acquired = await manager.acquireAgentForTask(qa.id, transitioned.id, qaPhase);
353
+ if (!acquired) {
354
+ await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
355
+ phase: 'qa-acquire-failed',
356
+ qaAgentId: qa.id,
357
+ qaPhase,
358
+ });
359
+ return;
360
+ }
361
+ // Persist qaAgentId BEFORE setting up so a pane-fallback verdict's review.submitted
362
+ // handler can read it for the release path.
363
+ await manager.updateTask(transitioned.id, { qaAgentId: qa.id });
364
+ const { armed } = await manager.rotateAndSetupPhaseSignal(transitioned.id, qa.id, ['pr-approved', 'pr-changes-requested']);
365
+ if (!armed) {
366
+ console.warn(`[EventHandler] pr.updated verdict watcher failed to arm for task=${transitioned.id} (${qaPhase}); rolling back recheck dispatch`);
367
+ if (previousStatus === 'in_progress' || previousStatus === 'fixing') {
368
+ // Full rollback: restore status+token+anchor and re-arm so the dev's already-emitted
369
+ // signal isn't stranded by the token rotation.
370
+ await manager.rollbackVerdictArmFailure(transitioned.id, {
371
+ status: previousStatus,
372
+ signalToken: taskBeforeTransition.signalToken,
373
+ reviewHeadAnchorSha: taskBeforeTransition.reviewHeadAnchorSha,
374
+ reviewDispatchedAt: taskBeforeTransition.reviewDispatchedAt,
375
+ });
376
+ }
377
+ else {
378
+ // approved/review: don't restore status (approved with cleared completion is unsafe;
379
+ // review was already current). Leave in review for operator follow-up.
380
+ await manager.updateTask(transitioned.id, { qaAgentId: undefined });
381
+ }
382
+ await manager.releaseAgentForTask(qa.id, transitioned.id, 'idle').catch(err => {
383
+ console.error(`[EventHandler] pr.updated releaseAgentForTask(QA=${qa.id}) after arm-failure rollback failed:`, err);
384
+ return false;
385
+ });
386
+ await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
387
+ phase: previousStatus === 'approved' || previousStatus === 'merge-ready' ? 'qa-recheck-arm-failed-after-approved-push' : 'qa-recheck-arm-failed',
388
+ qaAgentId: qa.id,
389
+ qaPhase,
390
+ });
391
+ return;
392
+ }
393
+ let started = false;
394
+ let dispatchErr = null;
395
+ try {
396
+ started = await manager.startSession(transitioned.id, qa.id, qaPhase);
397
+ }
398
+ catch (err) {
399
+ dispatchErr = err;
400
+ console.error(`[EventHandler] pr.updated startSession(QA=${qa.id}, ${qaPhase}) hard error:`, err);
401
+ }
402
+ if (!started) {
403
+ console.warn(`[EventHandler] pr.updated QA ${qaPhase} not started; previousStatus=${previousStatus}`);
404
+ if (dispatchErr instanceof DispatchTerminalError) {
405
+ await manager.failTaskForDispatchError(transitioned.id, qaPhase, qa.id, dispatchErr);
406
+ }
407
+ else if (dispatchErr instanceof EnsureSessionError && dispatchErr.partial.handled) {
408
+ // handleDialogPendingFromRuntime already marked QA Held + fail task + release partners
409
+ }
410
+ else {
411
+ await manager.updateTask(transitioned.id, { qaAgentId: undefined });
412
+ await manager.releaseAgentForTask(qa.id, transitioned.id, 'idle')
413
+ .catch(err => {
414
+ console.error(`[EventHandler] pr.updated releaseAgentForTask(QA=${qa.id}) after start-not-true failed:`, err);
415
+ return false;
416
+ });
417
+ if (previousStatus === 'approved' || previousStatus === 'merge-ready') {
418
+ await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
419
+ phase: 'qa-recheck-failed-after-approved-push',
420
+ qaAgentId: qa.id,
421
+ });
422
+ }
423
+ else if (previousStatus !== 'review') {
424
+ await manager.transitionTaskStatus(transitioned.id, previousStatus, { fromStatus: ['review'] });
425
+ }
426
+ else {
427
+ await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
428
+ phase: 'qa-recheck-failed-after-stop',
429
+ qaAgentId: qa.id,
430
+ });
431
+ }
432
+ }
433
+ return;
434
+ }
435
+ // Bump round for first review / approved re-review only; fixing/review→review recheck
436
+ // of the in-flight pass must NOT bump.
437
+ if (previousStatus === 'in_progress' || previousStatus === 'approved' || previousStatus === 'merge-ready') {
438
+ await manager.bumpReviewRoundIfStillAt(transitioned.id, expectedRound);
439
+ }
440
+ const ok = devAlreadyWaiting || (await manager.markAgentWaiting(agentId, transitioned.id));
441
+ if (!ok) {
442
+ await manager.markAwaitingHuman(qa.id, 'dev-wait-gate-failed-after-qa-started', `QA review for task ${transitioned.id} started but dev wait-gate failed; QA prompt may still be running, needs operator decision.`, { expectedTaskId: transitioned.id }).catch(err => {
443
+ console.error(`[EventHandler] pr.updated markAwaitingHuman(QA=${qa.id}) after dev-wait-gate-fail:`, err);
444
+ });
445
+ }
446
+ }
447
+ async function handleReviewApproval(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch) {
448
+ if (!reviewedHeadSha) {
449
+ await emitIntervention(bus, task.projectId, task.agentId, task.id, {
450
+ phase: 'approval-reviewed-head-unavailable',
451
+ });
452
+ return;
453
+ }
454
+ const anchor = await resolveAuthoritativeHead(manager, task, {
455
+ payloadCurrentHeadSha: currentHeadSha,
456
+ });
457
+ if (anchor.source === 'fetch' && anchor.headSha && task.latestHeadSha !== anchor.headSha) {
458
+ await manager.updateTask(task.id, { latestHeadSha: anchor.headSha });
459
+ }
460
+ if (anchor.headSha && reviewedHeadSha !== anchor.headSha) {
461
+ await emitIntervention(bus, task.projectId, task.agentId, task.id, {
462
+ phase: 'stale-approval-head-mismatch',
463
+ reviewedHeadSha,
464
+ currentHeadSha: anchor.headSha,
465
+ source: anchor.source,
466
+ ...(anchor.fetchError ? { fetchError: anchor.fetchError } : {}),
467
+ });
468
+ return;
469
+ }
470
+ const result = await manager.transitionTaskStatus(task.id, 'approved', { fromStatus: ['review'] },
471
+ // reviewRound 0 = first pass entered via catch-up (deferred bump). Count it now.
472
+ task.reviewRound === 0 ? { ...prPatch, reviewRound: 1 } : prPatch);
473
+ if (!result)
474
+ return;
475
+ const { task: transitioned } = result;
476
+ // Verdict consumed → tear down the fallback verdict watcher (poller path leaves
477
+ // it set-up-but-unfired; pane path already removed its entry — this is a no-op).
478
+ manager.stopPhaseSignalWatcher(transitioned.id);
479
+ if (transitioned.qaAgentId) {
480
+ await manager.releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle', { allowAwaitingHuman: true })
481
+ .catch(err => console.error(`[EventHandler] APPROVE releaseAgentForTask(QA=${transitioned.qaAgentId}) failed:`, err));
482
+ }
483
+ await dispatchDevPostApproveCheck(bus, manager, transitioned, reviewedHeadSha);
484
+ }
485
+ async function handleReviewRequestChanges(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch) {
486
+ const approvedCompletion = task.status === 'approved'
487
+ ? await manager.getPostApproveCompletion(task.id)
488
+ : null;
489
+ const anchor = await resolveAuthoritativeHead(manager, task, {
490
+ payloadCurrentHeadSha: currentHeadSha,
491
+ legacyFallback: approvedCompletion?.approvedHeadSha,
492
+ });
493
+ if (anchor.source === 'fetch' && anchor.headSha && task.latestHeadSha !== anchor.headSha) {
494
+ await manager.updateTask(task.id, { latestHeadSha: anchor.headSha });
495
+ }
496
+ if (reviewedHeadSha && anchor.headSha && reviewedHeadSha !== anchor.headSha) {
497
+ await emitIntervention(bus, task.projectId, task.agentId, task.id, {
498
+ phase: 'stale-request-changes-head-mismatch',
499
+ reviewedHeadSha,
500
+ currentHeadSha: anchor.headSha,
501
+ source: anchor.source,
502
+ ...(anchor.fetchError ? { fetchError: anchor.fetchError } : {}),
503
+ });
504
+ return;
505
+ }
506
+ const reviewedRound = task.reviewRound === 0 ? 1 : task.reviewRound;
507
+ const nextRound = reviewedRound + 1;
508
+ if (task.status === 'approved' && nextRound <= manager.getConfig().review.rounds) {
509
+ // Post-approve check may still be running — clearing completion blocks auto-merge;
510
+ // fix dispatch deferred to avoid prompt collision with the in-flight check.
511
+ const devState = await manager.getAgentState(task.agentId);
512
+ const postApproveActive = await manager.getPostApproveCompletion(task.id);
513
+ if (devState?.taskId === task.id && postApproveActive) {
514
+ await manager.clearPostApproveCompletion(task.id);
515
+ await emitIntervention(bus, task.projectId, task.agentId, task.id, {
516
+ phase: 'request-changes-during-post-approve',
517
+ devAgentId: task.agentId,
518
+ note: 'Dev is still running post-approve check; fix dispatch deferred to avoid prompt collision. PostApproveCompletion cleared to block auto-merge. Operator: wait for post-approve signal to complete, then re-trigger REQUEST_CHANGES manually or cancel the task.',
519
+ });
520
+ return;
521
+ }
522
+ const ready = await manager
523
+ .releaseAgentForTask(task.agentId, task.id, 'waiting')
524
+ .catch(err => {
525
+ console.error(`[EventHandler] REQUEST_CHANGES releaseAgentForTask(dev=${task.agentId}) before fix failed:`, err);
526
+ return false;
527
+ });
528
+ if (!ready) {
529
+ await emitIntervention(bus, task.projectId, task.agentId, task.id, {
530
+ phase: 'post-approve-dev-wait-gate-failed-before-fix',
531
+ devAgentId: task.agentId,
532
+ });
533
+ return;
534
+ }
535
+ }
536
+ if (nextRound > manager.getConfig().review.rounds) {
537
+ return handleMaxRounds(bus, manager, task, prPatch, reviewedRound);
538
+ }
539
+ const result = await manager.transitionTaskStatus(task.id, 'fixing', { fromStatus: ['review', 'approved', 'merge-ready'] }, { ...prPatch, reviewRound: nextRound, fixDispatchedAt: new Date().toISOString() });
540
+ if (!result)
541
+ return;
542
+ const { task: transitioned, previousStatus } = result;
543
+ await manager.clearPostApproveCompletion(transitioned.id);
544
+ manager.stopPhaseSignalWatcher(transitioned.id);
545
+ if (previousStatus === 'review' && transitioned.qaAgentId) {
546
+ const qaReleased = await manager
547
+ .releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle', { allowAwaitingHuman: true })
548
+ .catch(err => {
549
+ console.error(`[EventHandler] REQUEST_CHANGES releaseAgentForTask(QA=${transitioned.qaAgentId}) failed:`, err);
550
+ return false;
551
+ });
552
+ if (!qaReleased) {
553
+ await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
554
+ phase: 'qa-release-failed-but-dev-dispatched',
555
+ qaAgentId: transitioned.qaAgentId,
556
+ });
557
+ }
558
+ }
559
+ const acquired = await manager.acquireAgentForTask(transitioned.agentId, transitioned.id, 'fix');
560
+ if (!acquired) {
561
+ await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
562
+ phase: 'dev-acquire-failed-fix',
563
+ devAgentId: transitioned.agentId,
564
+ });
565
+ return;
566
+ }
567
+ const { armed } = await manager.rotateAndSetupPhaseSignal(transitioned.id, transitioned.agentId, 'pr-fixed');
568
+ if (!armed) {
569
+ console.warn(`[EventHandler] REQUEST_CHANGES pr-fixed watcher failed to arm for task=${transitioned.id}; holding dev (not dispatching fix)`);
570
+ await manager.markAwaitingHuman(transitioned.agentId, 'signal-arm-failed:pr-fixed', 'pr-fixed watcher failed to arm; the fix was not dispatched (its completion signal would have no consumer). Cancel the task or delete the agent to retry.', { expectedTaskId: transitioned.id });
571
+ return;
572
+ }
573
+ let resumed = false;
574
+ let dispatchErr = null;
575
+ try {
576
+ resumed = await manager.continueSession(transitioned.id, transitioned.agentId, 'fix');
577
+ }
578
+ catch (err) {
579
+ dispatchErr = err;
580
+ console.error(`[EventHandler] REQUEST_CHANGES continueSession(dev=${transitioned.agentId}, fix) failed:`, err);
581
+ }
582
+ if (!resumed) {
583
+ console.warn(`[EventHandler] REQUEST_CHANGES dev=${transitioned.agentId} not resumable for task=${transitioned.id}; ` +
584
+ `task remains in 'fixing' but no dev session is attached`);
585
+ if (dispatchErr instanceof DispatchTerminalError) {
586
+ await manager.failTaskForDispatchError(transitioned.id, 'fix', transitioned.agentId, dispatchErr);
587
+ }
588
+ else {
589
+ await manager.markAgentWaiting(transitioned.agentId, transitioned.id)
590
+ .catch(err => {
591
+ console.error(`[EventHandler] REQUEST_CHANGES markAgentWaiting(dev=${transitioned.agentId}) rollback failed:`, err);
592
+ return false;
593
+ });
594
+ await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
595
+ phase: 'fix-resume-failed',
596
+ reviewRound: transitioned.reviewRound,
597
+ });
598
+ }
599
+ }
600
+ }
601
+ async function handleMaxRounds(bus, manager, task, prPatch, reviewedRound) {
602
+ const result = await manager.transitionTaskStatus(task.id, 'max_rounds', { fromStatus: ['review', 'approved', 'merge-ready'] }, { ...prPatch, reviewRound: reviewedRound });
603
+ if (!result)
604
+ return;
605
+ const { task: transitioned } = result;
606
+ await manager.clearPostApproveCompletion(transitioned.id);
607
+ manager.stopPhaseSignalWatcher(transitioned.id);
608
+ if (transitioned.qaAgentId) {
609
+ const qaState = await manager.getAgentState(transitioned.qaAgentId);
610
+ if (qaState?.taskId === transitioned.id) {
611
+ const qaReleased = await manager
612
+ .releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle', { allowAwaitingHuman: true })
613
+ .catch(err => {
614
+ console.error(`[EventHandler] max_rounds releaseAgentForTask(QA=${transitioned.qaAgentId}) failed:`, err);
615
+ return false;
616
+ });
617
+ if (qaReleased) {
618
+ await manager.updateTask(transitioned.id, { qaAgentId: undefined })
619
+ .catch(err => console.error(`[EventHandler] max_rounds clear qaAgentId(${transitioned.id}) failed:`, err));
620
+ }
621
+ }
622
+ else {
623
+ await manager.updateTask(transitioned.id, { qaAgentId: undefined })
624
+ .catch(err => console.error(`[EventHandler] max_rounds clear stale qaAgentId(${transitioned.id}) failed:`, err));
625
+ }
626
+ }
627
+ try {
628
+ await bus.emit({
629
+ id: '',
630
+ type: 'review.max_rounds',
631
+ timestamp: new Date().toISOString(),
632
+ projectId: transitioned.projectId,
633
+ agentId: transitioned.agentId,
634
+ taskId: transitioned.id,
635
+ data: { reviewRound: transitioned.reviewRound },
636
+ });
637
+ }
638
+ catch (emitErr) {
639
+ console.warn(`[EventHandler] max_rounds emit failed:`, emitErr);
640
+ }
641
+ }
149
642
  export function registerEventHandlers(bus, manager) {
150
643
  bus.on('pr.created', async (event) => {
151
644
  if (!event.taskId || !event.agentId)
@@ -336,12 +829,8 @@ export function registerEventHandlers(bus, manager) {
336
829
  // pr.merged stays open: external merges of a ready PR finish through it.
337
830
  if (await isServerModeTask(manager, event.taskId))
338
831
  return;
339
- const eventPrNumber = event.data.prNumber;
340
- const eventPrUrl = event.data.prUrl;
341
832
  const eventKind = event.data.kind;
342
- // spec phase 由 server 评审链驱动;spec doc push/comment 不应进入 code-review 流程
343
- // (避免 QA recheck → gh pr review --approve → 误派 post-approve)。
344
- // pr-merge-ready 是 dev 内部状态推进,不受 phase gate 限制。
833
+ // spec phase 由 server 评审链驱动;pr-merge-ready dev 内部状态推进,不受限。
345
834
  if (eventKind !== 'pr-merge-ready') {
346
835
  const taskNow = await manager.getTask(event.taskId);
347
836
  if (taskNow?.phase === 'spec') {
@@ -349,320 +838,11 @@ export function registerEventHandlers(bus, manager) {
349
838
  return;
350
839
  }
351
840
  }
352
- // Only `push` freshens `latestHeadSha` — other event payloads can regress it under reorder.
353
- const eventHeadSha = validHeadSha(event.data.headSha);
354
- const prPatch = {
355
- ...(eventPrNumber !== undefined ? { prNumber: eventPrNumber } : {}),
356
- ...(eventPrUrl !== undefined ? { prUrl: eventPrUrl } : {}),
357
- ...(eventKind === 'push' && eventHeadSha ? { latestHeadSha: eventHeadSha } : {}),
358
- };
359
- if (eventKind === 'pr-merge-ready') {
360
- const taskNow = await manager.getTask(event.taskId);
361
- if (!taskNow)
362
- return;
363
- const needsPatch = (eventPrNumber !== undefined && eventPrNumber !== taskNow.prNumber)
364
- || (eventPrUrl !== undefined && eventPrUrl !== taskNow.prUrl);
365
- if (needsPatch) {
366
- await manager.updateTask(event.taskId, prPatch);
367
- }
368
- const verdictAgentId = event.data.verdictAgentId;
369
- // PhaseSignalWatcher emits the per-signal token under `data.token` for all
370
- // kinds (unified field). Old PostApproveSignalWatcher used `data.signalToken`
371
- // — that name is gone; reading the wrong field made every pr-merge-ready
372
- // event fail the freshness gate below and silently strand approved tasks.
373
- const signalToken = event.data.token;
374
- const completion = await manager.getPostApproveCompletion(taskNow.id);
375
- if (taskNow.status !== 'approved'
376
- || verdictAgentId !== taskNow.agentId
377
- || !signalToken
378
- || completion?.token !== signalToken)
379
- return;
380
- const ok = await manager.markAgentWaiting(taskNow.agentId, taskNow.id);
381
- if (!ok) {
382
- await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
383
- phase: 'post-approve-dev-wait-gate-failed',
384
- });
385
- return;
386
- }
387
- const freshTask = await manager.getTask(taskNow.id);
388
- const freshCompletion = await manager.getPostApproveCompletion(taskNow.id);
389
- if (!freshTask
390
- || freshTask.status !== 'approved'
391
- || freshTask.agentId !== taskNow.agentId
392
- || freshCompletion?.token !== signalToken) {
393
- await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
394
- phase: 'post-approve-merge-skipped-stale-task',
395
- });
396
- return;
397
- }
398
- // pendingRedispatch means new feedback arrived mid-pass — redispatch instead of merging.
399
- if (freshCompletion.pendingRedispatch) {
400
- const nextCount = (freshCompletion.redispatchCount ?? 0) + 1;
401
- if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
402
- await manager.clearPostApproveCompletion(freshTask.id);
403
- await emitIntervention(bus, freshTask.projectId, freshTask.agentId, freshTask.id, {
404
- phase: 'post-approve-redispatch-cap-exceeded',
405
- redispatchCount: freshCompletion.redispatchCount ?? 0,
406
- cap: POST_APPROVE_REDISPATCH_CAP,
407
- });
408
- return;
409
- }
410
- await dispatchDevPostApproveCheck(bus, manager, freshTask, freshCompletion.approvedHeadSha, { redispatchCount: nextCount });
411
- return;
412
- }
413
- // Human gate (spec §10): checks passed — surface merge-ready and wait for the
414
- // operator. merge:'auto' no longer merges here; it decides what the confirm
415
- // endpoint executes (server merges on confirm vs human merges by hand).
416
- // Persist the post-approve head: confirm's merge guards on it so a push
417
- // landing inside the gate window can never be merged blind.
418
- const readied = await manager.transitionTaskStatus(freshTask.id, 'merge-ready', { fromStatus: ['approved'] }, { latestHeadSha: freshCompletion.approvedHeadSha });
419
- if (readied) {
420
- await manager.clearPostApproveCompletionIfMatches(freshTask.id, signalToken);
421
- }
422
- return;
423
- }
424
- const isCodeUpdate = eventKind === 'push' || eventKind === undefined;
425
- if (!isCodeUpdate) {
426
- const taskNow = await manager.getTask(event.taskId);
427
- if (!taskNow)
428
- return;
429
- const needsPatch = (eventPrNumber !== undefined && eventPrNumber !== taskNow.prNumber)
430
- || (eventPrUrl !== undefined && eventPrUrl !== taskNow.prUrl);
431
- if (needsPatch) {
432
- await manager.updateTask(event.taskId, prPatch);
433
- }
434
- const completion = taskNow.status === 'approved'
435
- ? await manager.getPostApproveCompletion(taskNow.id)
436
- : null;
437
- const isNewFeedback = eventKind === 'comment' || eventKind === 'review-comment';
438
- if (taskNow.status === 'approved' && isNewFeedback) {
439
- if (!completion) {
440
- await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
441
- phase: 'post-approve-approved-head-unavailable',
442
- });
443
- return;
444
- }
445
- // Don't Ctrl-C Dev mid-pass on its own webhook echo — coalesce via pendingRedispatch instead.
446
- const devState = await manager.getAgentState(taskNow.agentId);
447
- if (devState?.taskId === taskNow.id) {
448
- if (!completion.pendingRedispatch) {
449
- await manager.setPostApproveCompletion(taskNow.id, {
450
- token: completion.token,
451
- approvedHeadSha: completion.approvedHeadSha,
452
- ...(typeof completion.redispatchCount === 'number'
453
- ? { redispatchCount: completion.redispatchCount } : {}),
454
- pendingRedispatch: true,
455
- });
456
- }
457
- return;
458
- }
459
- const nextCount = (completion.redispatchCount ?? 0) + 1;
460
- if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
461
- // Clear completion so an in-flight signal can't auto-merge past the cap.
462
- await manager.clearPostApproveCompletion(taskNow.id);
463
- await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
464
- phase: 'post-approve-redispatch-cap-exceeded',
465
- redispatchCount: completion.redispatchCount ?? 0,
466
- cap: POST_APPROVE_REDISPATCH_CAP,
467
- });
468
- return;
469
- }
470
- const ready = await gateDevForPostApproveRedispatch(bus, manager, taskNow);
471
- if (!ready)
472
- return;
473
- await dispatchDevPostApproveCheck(bus, manager, { ...taskNow, ...prPatch }, completion.approvedHeadSha, { redispatchCount: nextCount });
474
- }
475
- return;
476
- }
477
- const taskBeforeTransition = await manager.getTask(event.taskId);
478
- if (!taskBeforeTransition)
479
- return;
480
- const willHavePrNumber = taskBeforeTransition.prNumber !== undefined || eventPrNumber !== undefined;
481
- if (taskBeforeTransition.status === 'in_progress' && !willHavePrNumber) {
482
- console.warn(`[EventHandler] pr.updated: task ${event.taskId} in_progress but neither task nor event has prNumber; ` +
483
- `deferring catch-up`);
484
- return;
485
- }
486
- // Pin the review anchor SHA at dispatch time: if this push provided a head,
487
- // use it; otherwise fall back to the existing latestHeadSha (last known
488
- // head from poller). The anchor must NOT shift if a subsequent push lands
489
- // mid-review.
490
- const anchorAtDispatch = eventHeadSha ?? validHeadSha(taskBeforeTransition.latestHeadSha);
491
- const result = await manager.transitionTaskStatus(event.taskId, 'review', { fromStatus: ['in_progress', 'fixing', 'review', 'approved', 'merge-ready'] }, {
492
- ...prPatch,
493
- ...(anchorAtDispatch ? { reviewHeadAnchorSha: anchorAtDispatch } : {}),
494
- reviewDispatchedAt: new Date().toISOString(),
495
- // Rotate the per-pass token ATOMICALLY with the anchors. If the redispatch's
496
- // later acquire/startSession fails and returns, the token still changed here,
497
- // so an old QA's late verdict (carrying the prior token) is rejected by the
498
- // token gate. rotateAndSetupPhaseSignal below rotates once more for the set up —
499
- // harmless, since the gate only needs the value to differ from the old pass.
500
- signalToken: createSignalToken(),
501
- });
502
- if (!result)
503
- return;
504
- const { task: transitioned, previousStatus } = result;
505
- // Round captured before any verdict can land — the count-once token (see pr.created).
506
- const expectedRound = transitioned.reviewRound;
507
- await manager.clearPostApproveCompletion(transitioned.id);
508
- let devAlreadyWaiting = false;
509
- if (previousStatus === 'approved' || previousStatus === 'merge-ready') {
510
- devAlreadyWaiting = await manager
511
- .releaseAgentForTask(transitioned.agentId, transitioned.id, 'waiting')
512
- .catch(err => {
513
- console.error(`[EventHandler] pr.updated releaseAgentForTask(dev=${transitioned.agentId}) before approved→recheck failed:`, err);
514
- return false;
515
- });
516
- if (!devAlreadyWaiting) {
517
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
518
- phase: 'post-approve-dev-wait-gate-failed-before-recheck',
519
- devAgentId: transitioned.agentId,
520
- });
521
- return;
522
- }
523
- }
524
- // Release stale QA before recheck — a half-released REPL must not receive new prompt.
525
- if (previousStatus === 'review' && transitioned.qaAgentId) {
526
- const released = await manager
527
- .releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle')
528
- .catch(err => {
529
- console.error(`[EventHandler] pr.updated releaseAgentForTask(QA=${transitioned.qaAgentId}) for review→review push failed:`, err);
530
- return false;
531
- });
532
- if (!released) {
533
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
534
- phase: 'qa-release-failed-cannot-recheck',
535
- qaAgentId: transitioned.qaAgentId,
536
- });
537
- return;
538
- }
539
- }
540
- // Any push once the task is already past initial dispatch (review/fixing/approved)
541
- // is a re-look → 'recheck'. The recheck prompt is phrased neutrally ("re-check the
542
- // new commits and any prior feedback"), so it never falsely claims "dev addressed
543
- // your prior changes-requested"; keeping 'recheck' for previousStatus='review'
544
- // preserves the recheck framing for a push DURING a recheck (don't downgrade it).
545
- const qaPhase = previousStatus === 'fixing' || previousStatus === 'review'
546
- || previousStatus === 'approved' || previousStatus === 'merge-ready'
547
- ? 'recheck'
548
- : 'review';
549
- const qa = manager.findQaPartner(event.agentId);
550
- if (!qa) {
551
- if (!devAlreadyWaiting && !(await manager.markAgentWaiting(event.agentId, transitioned.id))) {
552
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
553
- phase: 'dev-wait-gate-failed-no-qa',
554
- });
555
- }
556
- return;
557
- }
558
- const acquired = await manager.acquireAgentForTask(qa.id, transitioned.id, qaPhase);
559
- if (!acquired) {
560
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
561
- phase: 'qa-acquire-failed',
562
- qaAgentId: qa.id,
563
- qaPhase,
564
- });
565
- return;
566
- }
567
- // Persist qaAgentId BEFORE setting up so a pane-fallback verdict's review.submitted
568
- // handler can read it for the release path (same as pr.created).
569
- await manager.updateTask(transitioned.id, { qaAgentId: qa.id });
570
- // Poller-authoritative verdict; set up the verdict watcher as the same-identity
571
- // (422) fallback — see pr.created. Torn down on poller verdict in review.submitted.
572
- const { armed } = await manager.rotateAndSetupPhaseSignal(transitioned.id, qa.id, ['pr-approved', 'pr-changes-requested']);
573
- if (!armed) {
574
- // No armed watcher → a same-identity verdict would have no consumer.
575
- console.warn(`[EventHandler] pr.updated verdict watcher failed to arm for task=${transitioned.id} (${qaPhase}); rolling back recheck dispatch`);
576
- if (previousStatus === 'in_progress' || previousStatus === 'fixing') {
577
- // Full rollback: the dev's prior-phase prompt (spec-done/pr-created or pr-fixed) used the
578
- // pre-rotation token; restore status+token+anchor and re-arm so its already-emitted signal
579
- // isn't stranded by the token rotation.
580
- await manager.rollbackVerdictArmFailure(transitioned.id, {
581
- status: previousStatus,
582
- signalToken: taskBeforeTransition.signalToken,
583
- reviewHeadAnchorSha: taskBeforeTransition.reviewHeadAnchorSha,
584
- reviewDispatchedAt: taskBeforeTransition.reviewDispatchedAt,
585
- });
586
- }
587
- else {
588
- // approved/review: do NOT restore status. 'approved' is unsafe to restore (its post-approve
589
- // completion was already cleared and the new push is unreviewed); 'review' was already
590
- // current. Leave the task in review for operator/poller follow-up (matches the start-failure
591
- // branch below) and just drop the QA we acquired.
592
- await manager.updateTask(transitioned.id, { qaAgentId: undefined });
593
- }
594
- await manager.releaseAgentForTask(qa.id, transitioned.id, 'idle').catch(err => {
595
- console.error(`[EventHandler] pr.updated releaseAgentForTask(QA=${qa.id}) after arm-failure rollback failed:`, err);
596
- return false;
597
- });
598
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
599
- phase: previousStatus === 'approved' || previousStatus === 'merge-ready' ? 'qa-recheck-arm-failed-after-approved-push' : 'qa-recheck-arm-failed',
600
- qaAgentId: qa.id,
601
- qaPhase,
602
- });
603
- return;
604
- }
605
- let started = false;
606
- let dispatchErr = null;
607
- try {
608
- started = await manager.startSession(transitioned.id, qa.id, qaPhase);
609
- }
610
- catch (err) {
611
- dispatchErr = err;
612
- console.error(`[EventHandler] pr.updated startSession(QA=${qa.id}, ${qaPhase}) hard error:`, err);
613
- }
614
- if (!started) {
615
- console.warn(`[EventHandler] pr.updated QA ${qaPhase} not started; previousStatus=${previousStatus}`);
616
- if (dispatchErr instanceof DispatchTerminalError) {
617
- await manager.failTaskForDispatchError(transitioned.id, qaPhase, qa.id, dispatchErr);
618
- }
619
- else if (dispatchErr instanceof EnsureSessionError && dispatchErr.partial.handled) {
620
- // handleDialogPendingFromRuntime 已标 QA Held + fail task + release partners;不能再 release
621
- // 否则 boundTask terminal 会让 shouldReleaseHeldBinding 放行 → 解锁仍卡 dialog 的 pane。
622
- }
623
- else {
624
- // Rollback pre-set up qaAgentId so the task doesn't keep a binding to a QA we never started.
625
- await manager.updateTask(transitioned.id, { qaAgentId: undefined });
626
- await manager.releaseAgentForTask(qa.id, transitioned.id, 'idle')
627
- .catch(err => {
628
- console.error(`[EventHandler] pr.updated releaseAgentForTask(QA=${qa.id}) after start-not-true failed:`, err);
629
- return false;
630
- });
631
- if (previousStatus === 'approved' || previousStatus === 'merge-ready') {
632
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
633
- phase: 'qa-recheck-failed-after-approved-push',
634
- qaAgentId: qa.id,
635
- });
636
- }
637
- else if (previousStatus !== 'review') {
638
- await manager.transitionTaskStatus(transitioned.id, previousStatus, { fromStatus: ['review'] });
639
- }
640
- else {
641
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
642
- phase: 'qa-recheck-failed-after-stop',
643
- qaAgentId: qa.id,
644
- });
645
- }
646
- }
647
- return;
648
- }
649
- // New QA pass started → count it (1-based Round). Bump on the success path only.
650
- // First review (in_progress→review) and re-review after approval (approved→review,
651
- // dev pushed before merge) each start a new pass; fixing/review→review is a recheck
652
- // of the in-flight pass and must NOT bump. The count-once token no-ops if a
653
- // same-identity verdict already counted this pass mid-dispatch — symmetric for the
654
- // first review (expected 0) and an approved re-review (expected ≥1).
655
- if (previousStatus === 'in_progress' || previousStatus === 'approved' || previousStatus === 'merge-ready') {
656
- await manager.bumpReviewRoundIfStillAt(transitioned.id, expectedRound);
657
- }
658
- const ok = devAlreadyWaiting || (await manager.markAgentWaiting(event.agentId, transitioned.id));
659
- if (!ok) {
660
- // 同 pr.created 路径:QA review prompt 已粘进 pane 在跑,裸 release 让下一 review
661
- // 派同一 QA 时新 prompt 灌进仍在跑旧 review 的 pane。标 awaiting_human 让 operator 处理。
662
- await manager.markAwaitingHuman(qa.id, 'dev-wait-gate-failed-after-qa-started', `QA review for task ${transitioned.id} started but dev wait-gate failed; QA prompt may still be running, needs operator decision.`, { expectedTaskId: transitioned.id }).catch(err => {
663
- console.error(`[EventHandler] pr.updated markAwaitingHuman(QA=${qa.id}) after dev-wait-gate-fail:`, err);
664
- });
665
- }
841
+ if (eventKind === 'pr-merge-ready')
842
+ return handlePrMergeReady(bus, manager, event);
843
+ if (eventKind !== 'push' && eventKind !== undefined)
844
+ return handlePrFeedback(bus, manager, event);
845
+ return handlePrCodePush(bus, manager, event);
666
846
  });
667
847
  bus.on('pr.merged', async (event) => {
668
848
  if (!event.taskId)
@@ -839,246 +1019,10 @@ export function registerEventHandlers(bus, manager) {
839
1019
  }
840
1020
  }
841
1021
  if (action === 'APPROVE') {
842
- if (!reviewedHeadSha) {
843
- await emitIntervention(bus, task.projectId, task.agentId, task.id, {
844
- phase: 'approval-reviewed-head-unavailable',
845
- });
846
- return;
847
- }
848
- const anchor = await resolveAuthoritativeHead(manager, task, {
849
- payloadCurrentHeadSha: currentHeadSha,
850
- });
851
- // Refresh cache BEFORE the reject decision so a fetch outage can't fall back to a stale store.
852
- if (anchor.source === 'fetch' && anchor.headSha && task.latestHeadSha !== anchor.headSha) {
853
- await manager.updateTask(task.id, { latestHeadSha: anchor.headSha });
854
- }
855
- // No anchor (fetch + fallbacks all missing) ⇒ proceed; can't prove staleness either way.
856
- if (anchor.headSha && reviewedHeadSha !== anchor.headSha) {
857
- await emitIntervention(bus, task.projectId, task.agentId, task.id, {
858
- phase: 'stale-approval-head-mismatch',
859
- reviewedHeadSha,
860
- currentHeadSha: anchor.headSha,
861
- source: anchor.source,
862
- ...(anchor.fetchError ? { fetchError: anchor.fetchError } : {}),
863
- });
864
- return;
865
- }
866
- const result = await manager.transitionTaskStatus(event.taskId, 'approved', { fromStatus: ['review'] },
867
- // reviewRound 0 here ⇒ first pass entered via catch-up (deferred bump). Count it
868
- // now that the verdict is accepted. A stale verdict returns above and never
869
- // reaches this point, so the round is consumed only on a real acceptance.
870
- task.reviewRound === 0 ? { ...prPatch, reviewRound: 1 } : prPatch);
871
- if (!result)
872
- return;
873
- const { task: transitioned } = result;
874
- // Verdict consumed → tear down the fallback verdict watcher. A distinct-identity
875
- // task got its verdict via the poller, leaving the pane watcher set up-but-unfired;
876
- // a same-identity pane verdict already removed its own entry, so this is a no-op.
877
- // Placed AFTER the transition so a head-stale rejection above never tears it down
878
- // (and never touches an approved task's pr-merge-ready watcher).
879
- manager.stopPhaseSignalWatcher(transitioned.id);
880
- if (transitioned.qaAgentId) {
881
- // outcome 到达 = QA turn 完成,即使 QA 之前 Held(dev_wait_gate_failed / ack_unknown)也可放行。
882
- await manager.releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle', { allowAwaitingHuman: true })
883
- .catch(err => console.error(`[EventHandler] APPROVE releaseAgentForTask(QA=${transitioned.qaAgentId}) failed:`, err));
884
- }
885
- await dispatchDevPostApproveCheck(bus, manager, transitioned, reviewedHeadSha);
886
- return;
1022
+ return handleReviewApproval(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch);
887
1023
  }
888
1024
  if (action === 'REQUEST_CHANGES') {
889
- const approvedCompletion = task.status === 'approved'
890
- ? await manager.getPostApproveCompletion(task.id)
891
- : null;
892
- const anchor = await resolveAuthoritativeHead(manager, task, {
893
- payloadCurrentHeadSha: currentHeadSha,
894
- legacyFallback: approvedCompletion?.approvedHeadSha,
895
- });
896
- if (anchor.source === 'fetch' && anchor.headSha && task.latestHeadSha !== anchor.headSha) {
897
- await manager.updateTask(task.id, { latestHeadSha: anchor.headSha });
898
- }
899
- if (reviewedHeadSha && anchor.headSha && reviewedHeadSha !== anchor.headSha) {
900
- await emitIntervention(bus, task.projectId, task.agentId, task.id, {
901
- phase: 'stale-request-changes-head-mismatch',
902
- reviewedHeadSha,
903
- currentHeadSha: anchor.headSha,
904
- source: anchor.source,
905
- ...(anchor.fetchError ? { fetchError: anchor.fetchError } : {}),
906
- });
907
- return;
908
- }
909
- // reviewRound 0 here ⇒ first pass entered via catch-up (deferred bump); the pass
910
- // being judged is round 1. Otherwise the round was already counted on dispatch.
911
- const reviewedRound = task.reviewRound === 0 ? 1 : task.reviewRound;
912
- const nextRound = reviewedRound + 1;
913
- if (task.status === 'approved' && nextRound <= manager.getConfig().review.rounds) {
914
- // post-approve check 可能仍在 dev pane 中跑——release(waiting) 只 bump updatedAt 不 wait ready,
915
- // 后续 continueSession(fix) 会把 fix prompt 灌进 busy pane (pr.updated approved+new feedback 已通过
916
- // pendingRedispatch 走 coalesce;review.submitted 这里需要同等 gate)。检测 dev 仍绑 task + post-approve
917
- // completion 仍存在 → emit intervention + skip 派发;signal 完成后 (pr-merge-ready handler)
918
- // task 会回到 approved,operator 可以重新触发 REQUEST_CHANGES manual review 或 cancel task。
919
- const devState = await manager.getAgentState(task.agentId);
920
- const postApproveActive = await manager.getPostApproveCompletion(task.id);
921
- if (devState?.taskId === task.id && postApproveActive) {
922
- // 必须先清 PostApproveCompletion 阻止 signal 完成后 auto-merge:
923
- // pr-merge-ready handler 看 completion.token 仍匹配 + freshTask.status='approved'
924
- // + pendingRedispatch=false 会调 mergePr——但我们刚收到 REQUEST_CHANGES,绝不能 merge。
925
- await manager.clearPostApproveCompletion(task.id);
926
- await emitIntervention(bus, task.projectId, task.agentId, task.id, {
927
- phase: 'request-changes-during-post-approve',
928
- devAgentId: task.agentId,
929
- note: 'Dev is still running post-approve check; fix dispatch deferred to avoid prompt collision. PostApproveCompletion cleared to block auto-merge. Operator: wait for post-approve signal to complete, then re-trigger REQUEST_CHANGES manually or cancel the task.',
930
- });
931
- return;
932
- }
933
- const ready = await manager
934
- .releaseAgentForTask(task.agentId, task.id, 'waiting')
935
- .catch(err => {
936
- console.error(`[EventHandler] REQUEST_CHANGES releaseAgentForTask(dev=${task.agentId}) before fix failed:`, err);
937
- return false;
938
- });
939
- if (!ready) {
940
- await emitIntervention(bus, task.projectId, task.agentId, task.id, {
941
- phase: 'post-approve-dev-wait-gate-failed-before-fix',
942
- devAgentId: task.agentId,
943
- });
944
- return;
945
- }
946
- }
947
- if (nextRound > manager.getConfig().review.rounds) {
948
- const result = await manager.transitionTaskStatus(event.taskId, 'max_rounds', { fromStatus: ['review', 'approved', 'merge-ready'] },
949
- // Persist the reviewed round (no-op for the normal path; records the
950
- // deferred first-review count for a catch-up that hit the cap).
951
- { ...prPatch, reviewRound: reviewedRound });
952
- if (!result)
953
- return;
954
- const { task: transitioned } = result;
955
- await manager.clearPostApproveCompletion(transitioned.id);
956
- manager.stopPhaseSignalWatcher(transitioned.id);
957
- // Reconcile the QA reference regardless of how we reached the cap (REQUEST_CHANGES can
958
- // hit it from review OR approved/merge-ready). max_rounds is active and failTasksForAgent
959
- // matches by task.qaAgentId, so a stale id pointing at a released QA would let that QA's
960
- // later failure false-fail this paused task. The next review re-sets qaAgentId.
961
- if (transitioned.qaAgentId) {
962
- const qaState = await manager.getAgentState(transitioned.qaAgentId);
963
- if (qaState?.taskId === transitioned.id) {
964
- // QA still bound (review path): release it via the outcome path. allowAwaitingHuman —
965
- // the verdict arriving IS the QA turn completing, so a Held QA must still be releasable.
966
- // Clear only on a successful release; if refused (still bound/held), keep the reference
967
- // so a later Cancel/Complete can reclaim it.
968
- const qaReleased = await manager
969
- .releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle', { allowAwaitingHuman: true })
970
- .catch(err => {
971
- console.error(`[EventHandler] max_rounds releaseAgentForTask(QA=${transitioned.qaAgentId}) failed:`, err);
972
- return false;
973
- });
974
- if (qaReleased) {
975
- await manager.updateTask(transitioned.id, { qaAgentId: undefined })
976
- .catch(err => console.error(`[EventHandler] max_rounds clear qaAgentId(${transitioned.id}) failed:`, err));
977
- }
978
- }
979
- else {
980
- // QA already unbound (approved/merge-ready path released it earlier without clearing the
981
- // id) — drop the stale reference so it can't false-fail this active task.
982
- await manager.updateTask(transitioned.id, { qaAgentId: undefined })
983
- .catch(err => console.error(`[EventHandler] max_rounds clear stale qaAgentId(${transitioned.id}) failed:`, err));
984
- }
985
- }
986
- // Dev is intentionally NOT released: this is the code-phase review loop
987
- // (review.submitted early-returns for spec phase). The dev stays parked
988
- // 'waiting' + bound with its worktree intact, so "continue one round"
989
- // reuses the checkout (no -B branch reset / lost commits). It is freed by
990
- // mark-complete (post-merge cleanup) or cancel.
991
- try {
992
- await bus.emit({
993
- id: '',
994
- type: 'review.max_rounds',
995
- timestamp: new Date().toISOString(),
996
- projectId: transitioned.projectId,
997
- agentId: transitioned.agentId,
998
- taskId: transitioned.id,
999
- data: { reviewRound: transitioned.reviewRound },
1000
- });
1001
- }
1002
- catch (emitErr) {
1003
- console.warn(`[EventHandler] max_rounds emit failed:`, emitErr);
1004
- }
1005
- return;
1006
- }
1007
- const result = await manager.transitionTaskStatus(event.taskId, 'fixing', { fromStatus: ['review', 'approved', 'merge-ready'] },
1008
- // fixDispatchedAt marks the round start so the pr-fixed verifier doesn't
1009
- // count QA/human comments left during the prior review as dev activity.
1010
- { ...prPatch, reviewRound: nextRound, fixDispatchedAt: new Date().toISOString() });
1011
- if (!result)
1012
- return;
1013
- const { task: transitioned, previousStatus } = result;
1014
- await manager.clearPostApproveCompletion(transitioned.id);
1015
- manager.stopPhaseSignalWatcher(transitioned.id);
1016
- let qaReleased = true;
1017
- if (previousStatus === 'review' && transitioned.qaAgentId) {
1018
- // outcome 到达 = QA turn 完成,allowAwaitingHuman 让 Held QA (ack_unknown / dev_wait_gate_failed) 也可放行。
1019
- qaReleased = await manager
1020
- .releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle', { allowAwaitingHuman: true })
1021
- .catch(err => {
1022
- console.error(`[EventHandler] REQUEST_CHANGES releaseAgentForTask(QA=${transitioned.qaAgentId}) failed:`, err);
1023
- return false;
1024
- });
1025
- if (!qaReleased) {
1026
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
1027
- phase: 'qa-release-failed-but-dev-dispatched',
1028
- qaAgentId: transitioned.qaAgentId,
1029
- });
1030
- }
1031
- }
1032
- // Explicit acquire short-circuits if a concurrent DELETE/restart-repl released the lock.
1033
- const acquired = await manager.acquireAgentForTask(transitioned.agentId, transitioned.id, 'fix');
1034
- if (!acquired) {
1035
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
1036
- phase: 'dev-acquire-failed-fix',
1037
- devAgentId: transitioned.agentId,
1038
- });
1039
- return;
1040
- }
1041
- // Set up the pr-fixed completion watcher BEFORE the prompt. rotateAndSetupPhaseSignal
1042
- // rotates the per-pass token atomically; continueSession then reads that token and
1043
- // embeds it in the fix prompt, so the dev's pr-fixed signal advances fixing→review
1044
- // even when the round produced no push.
1045
- const { armed } = await manager.rotateAndSetupPhaseSignal(transitioned.id, transitioned.agentId, 'pr-fixed');
1046
- if (!armed) {
1047
- // pr-fixed watcher didn't arm → the fix completion signal (esp. the no-push case) would have
1048
- // no consumer. Don't dispatch; hold the dev explicitly so the stuck state is operator-handleable
1049
- // (resumeAgent refuses signal-arm-failed → cancel/delete), instead of leaving task=fixing with
1050
- // the dev merely 'waiting' and no fix prompt running (snapshot would mislabel it as working).
1051
- console.warn(`[EventHandler] REQUEST_CHANGES pr-fixed watcher failed to arm for task=${transitioned.id}; holding dev (not dispatching fix)`);
1052
- await manager.markAwaitingHuman(transitioned.agentId, 'signal-arm-failed:pr-fixed', 'pr-fixed watcher failed to arm; the fix was not dispatched (its completion signal would have no consumer). Cancel the task or delete the agent to retry.', { expectedTaskId: transitioned.id });
1053
- return;
1054
- }
1055
- let resumed = false;
1056
- let dispatchErr = null;
1057
- try {
1058
- resumed = await manager.continueSession(transitioned.id, transitioned.agentId, 'fix');
1059
- }
1060
- catch (err) {
1061
- dispatchErr = err;
1062
- console.error(`[EventHandler] REQUEST_CHANGES continueSession(dev=${transitioned.agentId}, fix) failed:`, err);
1063
- }
1064
- if (!resumed) {
1065
- console.warn(`[EventHandler] REQUEST_CHANGES dev=${transitioned.agentId} not resumable for task=${transitioned.id}; ` +
1066
- `task remains in 'fixing' but no dev session is attached`);
1067
- if (dispatchErr instanceof DispatchTerminalError) {
1068
- await manager.failTaskForDispatchError(transitioned.id, 'fix', transitioned.agentId, dispatchErr);
1069
- }
1070
- else {
1071
- await manager.markAgentWaiting(transitioned.agentId, transitioned.id)
1072
- .catch(err => {
1073
- console.error(`[EventHandler] REQUEST_CHANGES markAgentWaiting(dev=${transitioned.agentId}) rollback failed:`, err);
1074
- return false;
1075
- });
1076
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
1077
- phase: 'fix-resume-failed',
1078
- reviewRound: transitioned.reviewRound,
1079
- });
1080
- }
1081
- }
1025
+ return handleReviewRequestChanges(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch);
1082
1026
  }
1083
1027
  });
1084
1028
  // Dev emitted pr-fixed: it claims the fixing round is done. Verify on GitHub