baxian 1.2.4 → 1.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import { createSignalToken } from '../agent/phase-signal.js';
2
- import { TASK_TERMINAL_STATUS_SET } from '../shared/index.js';
2
+ import { BAXIAN_PR_CLAIM } from '../agent/prompt.js';
3
+ import { BRANCH_PREFIX, TASK_TERMINAL_STATUS_SET, isValidBranchName } from '../shared/index.js';
3
4
  import { DispatchTerminalError, EnsureSessionError } from '../agent/manager.js';
4
5
  const HEAD_SHA_RE = /^[0-9a-f]{40}$/i;
5
6
  function validHeadSha(value) {
@@ -7,9 +8,9 @@ function validHeadSha(value) {
7
8
  }
8
9
  // Re-establish the develop-phase watcher after a handler-side rejection so the same
9
10
  // task can consume a corrected emit (same token) without a server restart.
10
- async function reArmDevelopWatcher(manager, task, agentId) {
11
+ async function reArmDevelopWatcher(manager, task, agentId, opts = {}) {
11
12
  const kinds = task.phase === 'code' ? ['pr-created'] : ['spec-done', 'pr-created'];
12
- await manager.setupPhaseSignal(task.id, agentId, kinds);
13
+ await manager.setupPhaseSignal(task.id, agentId, kinds, { skipSnapshot: opts.skipSnapshot ?? true });
13
14
  }
14
15
  async function emitIntervention(bus, projectId, agentId, taskId, data) {
15
16
  try {
@@ -146,6 +147,499 @@ async function gateDevForPostApproveRedispatch(bus, manager, task) {
146
147
  });
147
148
  return false;
148
149
  }
150
+ async function handlePrMergeReady(bus, manager, event) {
151
+ const taskId = event.taskId;
152
+ const taskNow = await manager.getTask(taskId);
153
+ if (!taskNow)
154
+ return;
155
+ const eventPrNumber = event.data.prNumber;
156
+ const eventPrUrl = event.data.prUrl;
157
+ const needsPatch = (eventPrNumber !== undefined && eventPrNumber !== taskNow.prNumber)
158
+ || (eventPrUrl !== undefined && eventPrUrl !== taskNow.prUrl);
159
+ if (needsPatch) {
160
+ await manager.updateTask(taskId, {
161
+ ...(eventPrNumber !== undefined ? { prNumber: eventPrNumber } : {}),
162
+ ...(eventPrUrl !== undefined ? { prUrl: eventPrUrl } : {}),
163
+ });
164
+ }
165
+ const verdictAgentId = event.data.verdictAgentId;
166
+ // PhaseSignalWatcher unified `data.token`; old PostApproveSignalWatcher used
167
+ // `data.signalToken` — reading the wrong field strands approved tasks.
168
+ const signalToken = event.data.token;
169
+ const completion = await manager.getPostApproveCompletion(taskNow.id);
170
+ if (taskNow.status !== 'approved'
171
+ || verdictAgentId !== taskNow.agentId
172
+ || !signalToken
173
+ || completion?.token !== signalToken)
174
+ return;
175
+ const ok = await manager.markAgentWaiting(taskNow.agentId, taskNow.id);
176
+ if (!ok) {
177
+ await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
178
+ phase: 'post-approve-dev-wait-gate-failed',
179
+ });
180
+ return;
181
+ }
182
+ const freshTask = await manager.getTask(taskNow.id);
183
+ const freshCompletion = await manager.getPostApproveCompletion(taskNow.id);
184
+ if (!freshTask
185
+ || freshTask.status !== 'approved'
186
+ || freshTask.agentId !== taskNow.agentId
187
+ || freshCompletion?.token !== signalToken) {
188
+ await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
189
+ phase: 'post-approve-merge-skipped-stale-task',
190
+ });
191
+ return;
192
+ }
193
+ if (freshCompletion.pendingRedispatch) {
194
+ const nextCount = (freshCompletion.redispatchCount ?? 0) + 1;
195
+ if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
196
+ await manager.clearPostApproveCompletion(freshTask.id);
197
+ await emitIntervention(bus, freshTask.projectId, freshTask.agentId, freshTask.id, {
198
+ phase: 'post-approve-redispatch-cap-exceeded',
199
+ redispatchCount: freshCompletion.redispatchCount ?? 0,
200
+ cap: POST_APPROVE_REDISPATCH_CAP,
201
+ });
202
+ return;
203
+ }
204
+ await dispatchDevPostApproveCheck(bus, manager, freshTask, freshCompletion.approvedHeadSha, { redispatchCount: nextCount });
205
+ return;
206
+ }
207
+ // merge:'auto' decides what confirm executes; persist the approved head so
208
+ // confirm's merge guard catches a push inside the gate window.
209
+ const readied = await manager.transitionTaskStatus(freshTask.id, 'merge-ready', { fromStatus: ['approved'] }, { latestHeadSha: freshCompletion.approvedHeadSha });
210
+ if (readied) {
211
+ await manager.clearPostApproveCompletionIfMatches(freshTask.id, signalToken);
212
+ }
213
+ }
214
+ async function handlePrFeedback(bus, manager, event) {
215
+ const taskId = event.taskId;
216
+ const taskNow = await manager.getTask(taskId);
217
+ if (!taskNow)
218
+ return;
219
+ const eventPrNumber = event.data.prNumber;
220
+ const eventPrUrl = event.data.prUrl;
221
+ const eventKind = event.data.kind;
222
+ const prPatch = {
223
+ ...(eventPrNumber !== undefined ? { prNumber: eventPrNumber } : {}),
224
+ ...(eventPrUrl !== undefined ? { prUrl: eventPrUrl } : {}),
225
+ };
226
+ const needsPatch = (eventPrNumber !== undefined && eventPrNumber !== taskNow.prNumber)
227
+ || (eventPrUrl !== undefined && eventPrUrl !== taskNow.prUrl);
228
+ if (needsPatch) {
229
+ await manager.updateTask(taskId, prPatch);
230
+ }
231
+ if (taskNow.status !== 'approved')
232
+ return;
233
+ const isNewFeedback = eventKind === 'comment' || eventKind === 'review-comment';
234
+ if (!isNewFeedback)
235
+ return;
236
+ const completion = await manager.getPostApproveCompletion(taskNow.id);
237
+ if (!completion) {
238
+ await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
239
+ phase: 'post-approve-approved-head-unavailable',
240
+ });
241
+ return;
242
+ }
243
+ // Don't Ctrl-C dev mid-pass on its own webhook echo — coalesce via pendingRedispatch.
244
+ const devState = await manager.getAgentState(taskNow.agentId);
245
+ if (devState?.taskId === taskNow.id) {
246
+ if (!completion.pendingRedispatch) {
247
+ await manager.setPostApproveCompletion(taskNow.id, {
248
+ token: completion.token,
249
+ approvedHeadSha: completion.approvedHeadSha,
250
+ ...(typeof completion.redispatchCount === 'number'
251
+ ? { redispatchCount: completion.redispatchCount } : {}),
252
+ pendingRedispatch: true,
253
+ });
254
+ }
255
+ return;
256
+ }
257
+ const nextCount = (completion.redispatchCount ?? 0) + 1;
258
+ if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
259
+ await manager.clearPostApproveCompletion(taskNow.id);
260
+ await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
261
+ phase: 'post-approve-redispatch-cap-exceeded',
262
+ redispatchCount: completion.redispatchCount ?? 0,
263
+ cap: POST_APPROVE_REDISPATCH_CAP,
264
+ });
265
+ return;
266
+ }
267
+ const ready = await gateDevForPostApproveRedispatch(bus, manager, taskNow);
268
+ if (!ready)
269
+ return;
270
+ await dispatchDevPostApproveCheck(bus, manager, { ...taskNow, ...prPatch }, completion.approvedHeadSha, { redispatchCount: nextCount });
271
+ }
272
+ async function handlePrCodePush(bus, manager, event) {
273
+ const taskId = event.taskId;
274
+ const agentId = event.agentId;
275
+ const eventPrNumber = event.data.prNumber;
276
+ const eventPrUrl = event.data.prUrl;
277
+ const eventKind = event.data.kind;
278
+ const eventHeadSha = validHeadSha(event.data.headSha);
279
+ // Only `push` freshens `latestHeadSha` — legacy events (kind=undefined) use headSha
280
+ // for the review anchor only, not for the staleness fallback cache.
281
+ const prPatch = {
282
+ ...(eventPrNumber !== undefined ? { prNumber: eventPrNumber } : {}),
283
+ ...(eventPrUrl !== undefined ? { prUrl: eventPrUrl } : {}),
284
+ ...(eventKind === 'push' && eventHeadSha ? { latestHeadSha: eventHeadSha } : {}),
285
+ };
286
+ const taskBeforeTransition = await manager.getTask(taskId);
287
+ if (!taskBeforeTransition)
288
+ return;
289
+ const willHavePrNumber = taskBeforeTransition.prNumber !== undefined || eventPrNumber !== undefined;
290
+ if (taskBeforeTransition.status === 'in_progress' && !willHavePrNumber) {
291
+ console.warn(`[EventHandler] pr.updated: task ${taskId} in_progress but neither task nor event has prNumber; ` +
292
+ `deferring catch-up`);
293
+ return;
294
+ }
295
+ // Anchor at dispatch time — must NOT shift if a subsequent push lands mid-review.
296
+ const anchorAtDispatch = eventHeadSha ?? validHeadSha(taskBeforeTransition.latestHeadSha);
297
+ const result = await manager.transitionTaskStatus(taskId, 'review', { fromStatus: ['in_progress', 'fixing', 'review', 'approved', 'merge-ready'] }, {
298
+ ...prPatch,
299
+ ...(anchorAtDispatch ? { reviewHeadAnchorSha: anchorAtDispatch } : {}),
300
+ reviewDispatchedAt: new Date().toISOString(),
301
+ // Rotate token atomically so an old QA's late verdict is rejected by the gate.
302
+ signalToken: createSignalToken(),
303
+ });
304
+ if (!result)
305
+ return;
306
+ const { task: transitioned, previousStatus } = result;
307
+ const expectedRound = transitioned.reviewRound;
308
+ await manager.clearPostApproveCompletion(transitioned.id);
309
+ let devAlreadyWaiting = false;
310
+ if (previousStatus === 'approved' || previousStatus === 'merge-ready') {
311
+ devAlreadyWaiting = await manager
312
+ .releaseAgentForTask(transitioned.agentId, transitioned.id, 'waiting')
313
+ .catch(err => {
314
+ console.error(`[EventHandler] pr.updated releaseAgentForTask(dev=${transitioned.agentId}) before approved→recheck failed:`, err);
315
+ return false;
316
+ });
317
+ if (!devAlreadyWaiting) {
318
+ await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
319
+ phase: 'post-approve-dev-wait-gate-failed-before-recheck',
320
+ devAgentId: transitioned.agentId,
321
+ });
322
+ return;
323
+ }
324
+ }
325
+ if (previousStatus === 'review' && transitioned.qaAgentId) {
326
+ const released = await manager
327
+ .releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle')
328
+ .catch(err => {
329
+ console.error(`[EventHandler] pr.updated releaseAgentForTask(QA=${transitioned.qaAgentId}) for review→review push failed:`, err);
330
+ return false;
331
+ });
332
+ if (!released) {
333
+ await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
334
+ phase: 'qa-release-failed-cannot-recheck',
335
+ qaAgentId: transitioned.qaAgentId,
336
+ });
337
+ return;
338
+ }
339
+ }
340
+ const qaPhase = previousStatus === 'fixing' || previousStatus === 'review'
341
+ || previousStatus === 'approved' || previousStatus === 'merge-ready'
342
+ ? 'recheck'
343
+ : 'review';
344
+ const qa = manager.findQaPartner(agentId);
345
+ if (!qa) {
346
+ if (!devAlreadyWaiting && !(await manager.markAgentWaiting(agentId, transitioned.id))) {
347
+ await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
348
+ phase: 'dev-wait-gate-failed-no-qa',
349
+ });
350
+ }
351
+ return;
352
+ }
353
+ const acquired = await manager.acquireAgentForTask(qa.id, transitioned.id, qaPhase);
354
+ if (!acquired) {
355
+ await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
356
+ phase: 'qa-acquire-failed',
357
+ qaAgentId: qa.id,
358
+ qaPhase,
359
+ });
360
+ return;
361
+ }
362
+ // Persist qaAgentId BEFORE setting up so a pane-fallback verdict's review.submitted
363
+ // handler can read it for the release path.
364
+ await manager.updateTask(transitioned.id, { qaAgentId: qa.id });
365
+ const { armed } = await manager.rotateAndSetupPhaseSignal(transitioned.id, qa.id, ['pr-approved', 'pr-changes-requested']);
366
+ if (!armed) {
367
+ console.warn(`[EventHandler] pr.updated verdict watcher failed to arm for task=${transitioned.id} (${qaPhase}); rolling back recheck dispatch`);
368
+ if (previousStatus === 'in_progress' || previousStatus === 'fixing') {
369
+ // Full rollback: restore status+token+anchor and re-arm so the dev's already-emitted
370
+ // signal isn't stranded by the token rotation.
371
+ await manager.rollbackVerdictArmFailure(transitioned.id, {
372
+ status: previousStatus,
373
+ signalToken: taskBeforeTransition.signalToken,
374
+ reviewHeadAnchorSha: taskBeforeTransition.reviewHeadAnchorSha,
375
+ reviewDispatchedAt: taskBeforeTransition.reviewDispatchedAt,
376
+ });
377
+ }
378
+ else {
379
+ // approved/review: don't restore status (approved with cleared completion is unsafe;
380
+ // review was already current). Leave in review for operator follow-up.
381
+ await manager.updateTask(transitioned.id, { qaAgentId: undefined });
382
+ }
383
+ await manager.releaseAgentForTask(qa.id, transitioned.id, 'idle').catch(err => {
384
+ console.error(`[EventHandler] pr.updated releaseAgentForTask(QA=${qa.id}) after arm-failure rollback failed:`, err);
385
+ return false;
386
+ });
387
+ await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
388
+ phase: previousStatus === 'approved' || previousStatus === 'merge-ready' ? 'qa-recheck-arm-failed-after-approved-push' : 'qa-recheck-arm-failed',
389
+ qaAgentId: qa.id,
390
+ qaPhase,
391
+ });
392
+ return;
393
+ }
394
+ let started = false;
395
+ let dispatchErr = null;
396
+ try {
397
+ started = await manager.startSession(transitioned.id, qa.id, qaPhase);
398
+ }
399
+ catch (err) {
400
+ dispatchErr = err;
401
+ console.error(`[EventHandler] pr.updated startSession(QA=${qa.id}, ${qaPhase}) hard error:`, err);
402
+ }
403
+ if (!started) {
404
+ console.warn(`[EventHandler] pr.updated QA ${qaPhase} not started; previousStatus=${previousStatus}`);
405
+ if (dispatchErr instanceof DispatchTerminalError) {
406
+ await manager.failTaskForDispatchError(transitioned.id, qaPhase, qa.id, dispatchErr);
407
+ }
408
+ else if (dispatchErr instanceof EnsureSessionError && dispatchErr.partial.handled) {
409
+ // handleDialogPendingFromRuntime already marked QA Held + fail task + release partners
410
+ }
411
+ else {
412
+ await manager.updateTask(transitioned.id, { qaAgentId: undefined });
413
+ await manager.releaseAgentForTask(qa.id, transitioned.id, 'idle')
414
+ .catch(err => {
415
+ console.error(`[EventHandler] pr.updated releaseAgentForTask(QA=${qa.id}) after start-not-true failed:`, err);
416
+ return false;
417
+ });
418
+ if (previousStatus === 'approved' || previousStatus === 'merge-ready') {
419
+ await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
420
+ phase: 'qa-recheck-failed-after-approved-push',
421
+ qaAgentId: qa.id,
422
+ });
423
+ }
424
+ else if (previousStatus !== 'review') {
425
+ await manager.transitionTaskStatus(transitioned.id, previousStatus, { fromStatus: ['review'] });
426
+ }
427
+ else {
428
+ await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
429
+ phase: 'qa-recheck-failed-after-stop',
430
+ qaAgentId: qa.id,
431
+ });
432
+ }
433
+ }
434
+ return;
435
+ }
436
+ // Bump round for first review / approved re-review only; fixing/review→review recheck
437
+ // of the in-flight pass must NOT bump.
438
+ if (previousStatus === 'in_progress' || previousStatus === 'approved' || previousStatus === 'merge-ready') {
439
+ await manager.bumpReviewRoundIfStillAt(transitioned.id, expectedRound);
440
+ }
441
+ const ok = devAlreadyWaiting || (await manager.markAgentWaiting(agentId, transitioned.id));
442
+ if (!ok) {
443
+ 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 => {
444
+ console.error(`[EventHandler] pr.updated markAwaitingHuman(QA=${qa.id}) after dev-wait-gate-fail:`, err);
445
+ });
446
+ }
447
+ }
448
+ async function handleReviewApproval(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch) {
449
+ if (!reviewedHeadSha) {
450
+ await emitIntervention(bus, task.projectId, task.agentId, task.id, {
451
+ phase: 'approval-reviewed-head-unavailable',
452
+ });
453
+ return;
454
+ }
455
+ const anchor = await resolveAuthoritativeHead(manager, task, {
456
+ payloadCurrentHeadSha: currentHeadSha,
457
+ });
458
+ if (anchor.source === 'fetch' && anchor.headSha && task.latestHeadSha !== anchor.headSha) {
459
+ await manager.updateTask(task.id, { latestHeadSha: anchor.headSha });
460
+ }
461
+ if (anchor.headSha && reviewedHeadSha !== anchor.headSha) {
462
+ await emitIntervention(bus, task.projectId, task.agentId, task.id, {
463
+ phase: 'stale-approval-head-mismatch',
464
+ reviewedHeadSha,
465
+ currentHeadSha: anchor.headSha,
466
+ source: anchor.source,
467
+ ...(anchor.fetchError ? { fetchError: anchor.fetchError } : {}),
468
+ });
469
+ return;
470
+ }
471
+ const result = await manager.transitionTaskStatus(task.id, 'approved', { fromStatus: ['review'] },
472
+ // reviewRound 0 = first pass entered via catch-up (deferred bump). Count it now.
473
+ task.reviewRound === 0 ? { ...prPatch, reviewRound: 1 } : prPatch);
474
+ if (!result)
475
+ return;
476
+ const { task: transitioned } = result;
477
+ // Verdict consumed → tear down the fallback verdict watcher (poller path leaves
478
+ // it set-up-but-unfired; pane path already removed its entry — this is a no-op).
479
+ manager.stopPhaseSignalWatcher(transitioned.id);
480
+ if (transitioned.qaAgentId) {
481
+ await manager.releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle', { allowAwaitingHuman: true })
482
+ .catch(err => console.error(`[EventHandler] APPROVE releaseAgentForTask(QA=${transitioned.qaAgentId}) failed:`, err));
483
+ }
484
+ await dispatchDevPostApproveCheck(bus, manager, transitioned, reviewedHeadSha);
485
+ }
486
+ async function handleReviewRequestChanges(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch) {
487
+ const approvedCompletion = task.status === 'approved'
488
+ ? await manager.getPostApproveCompletion(task.id)
489
+ : null;
490
+ const anchor = await resolveAuthoritativeHead(manager, task, {
491
+ payloadCurrentHeadSha: currentHeadSha,
492
+ legacyFallback: approvedCompletion?.approvedHeadSha,
493
+ });
494
+ if (anchor.source === 'fetch' && anchor.headSha && task.latestHeadSha !== anchor.headSha) {
495
+ await manager.updateTask(task.id, { latestHeadSha: anchor.headSha });
496
+ }
497
+ if (reviewedHeadSha && anchor.headSha && reviewedHeadSha !== anchor.headSha) {
498
+ await emitIntervention(bus, task.projectId, task.agentId, task.id, {
499
+ phase: 'stale-request-changes-head-mismatch',
500
+ reviewedHeadSha,
501
+ currentHeadSha: anchor.headSha,
502
+ source: anchor.source,
503
+ ...(anchor.fetchError ? { fetchError: anchor.fetchError } : {}),
504
+ });
505
+ return;
506
+ }
507
+ const reviewedRound = task.reviewRound === 0 ? 1 : task.reviewRound;
508
+ const nextRound = reviewedRound + 1;
509
+ if (task.status === 'approved' && nextRound <= manager.getConfig().review.rounds) {
510
+ // Post-approve check may still be running — clearing completion blocks auto-merge;
511
+ // fix dispatch deferred to avoid prompt collision with the in-flight check.
512
+ const devState = await manager.getAgentState(task.agentId);
513
+ const postApproveActive = await manager.getPostApproveCompletion(task.id);
514
+ if (devState?.taskId === task.id && postApproveActive) {
515
+ await manager.clearPostApproveCompletion(task.id);
516
+ await emitIntervention(bus, task.projectId, task.agentId, task.id, {
517
+ phase: 'request-changes-during-post-approve',
518
+ devAgentId: task.agentId,
519
+ 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.',
520
+ });
521
+ return;
522
+ }
523
+ const ready = await manager
524
+ .releaseAgentForTask(task.agentId, task.id, 'waiting')
525
+ .catch(err => {
526
+ console.error(`[EventHandler] REQUEST_CHANGES releaseAgentForTask(dev=${task.agentId}) before fix failed:`, err);
527
+ return false;
528
+ });
529
+ if (!ready) {
530
+ await emitIntervention(bus, task.projectId, task.agentId, task.id, {
531
+ phase: 'post-approve-dev-wait-gate-failed-before-fix',
532
+ devAgentId: task.agentId,
533
+ });
534
+ return;
535
+ }
536
+ }
537
+ if (nextRound > manager.getConfig().review.rounds) {
538
+ return handleMaxRounds(bus, manager, task, prPatch, reviewedRound);
539
+ }
540
+ const result = await manager.transitionTaskStatus(task.id, 'fixing', { fromStatus: ['review', 'approved', 'merge-ready'] }, { ...prPatch, reviewRound: nextRound, fixDispatchedAt: new Date().toISOString() });
541
+ if (!result)
542
+ return;
543
+ const { task: transitioned, previousStatus } = result;
544
+ await manager.clearPostApproveCompletion(transitioned.id);
545
+ manager.stopPhaseSignalWatcher(transitioned.id);
546
+ if (previousStatus === 'review' && transitioned.qaAgentId) {
547
+ const qaReleased = await manager
548
+ .releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle', { allowAwaitingHuman: true })
549
+ .catch(err => {
550
+ console.error(`[EventHandler] REQUEST_CHANGES releaseAgentForTask(QA=${transitioned.qaAgentId}) failed:`, err);
551
+ return false;
552
+ });
553
+ if (!qaReleased) {
554
+ await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
555
+ phase: 'qa-release-failed-but-dev-dispatched',
556
+ qaAgentId: transitioned.qaAgentId,
557
+ });
558
+ }
559
+ }
560
+ const acquired = await manager.acquireAgentForTask(transitioned.agentId, transitioned.id, 'fix');
561
+ if (!acquired) {
562
+ await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
563
+ phase: 'dev-acquire-failed-fix',
564
+ devAgentId: transitioned.agentId,
565
+ });
566
+ return;
567
+ }
568
+ const { armed } = await manager.rotateAndSetupPhaseSignal(transitioned.id, transitioned.agentId, 'pr-fixed');
569
+ if (!armed) {
570
+ console.warn(`[EventHandler] REQUEST_CHANGES pr-fixed watcher failed to arm for task=${transitioned.id}; holding dev (not dispatching fix)`);
571
+ 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 });
572
+ return;
573
+ }
574
+ let resumed = false;
575
+ let dispatchErr = null;
576
+ try {
577
+ resumed = await manager.continueSession(transitioned.id, transitioned.agentId, 'fix');
578
+ }
579
+ catch (err) {
580
+ dispatchErr = err;
581
+ console.error(`[EventHandler] REQUEST_CHANGES continueSession(dev=${transitioned.agentId}, fix) failed:`, err);
582
+ }
583
+ if (!resumed) {
584
+ console.warn(`[EventHandler] REQUEST_CHANGES dev=${transitioned.agentId} not resumable for task=${transitioned.id}; ` +
585
+ `task remains in 'fixing' but no dev session is attached`);
586
+ if (dispatchErr instanceof DispatchTerminalError) {
587
+ await manager.failTaskForDispatchError(transitioned.id, 'fix', transitioned.agentId, dispatchErr);
588
+ }
589
+ else {
590
+ await manager.markAgentWaiting(transitioned.agentId, transitioned.id)
591
+ .catch(err => {
592
+ console.error(`[EventHandler] REQUEST_CHANGES markAgentWaiting(dev=${transitioned.agentId}) rollback failed:`, err);
593
+ return false;
594
+ });
595
+ await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
596
+ phase: 'fix-resume-failed',
597
+ reviewRound: transitioned.reviewRound,
598
+ });
599
+ }
600
+ }
601
+ }
602
+ async function handleMaxRounds(bus, manager, task, prPatch, reviewedRound) {
603
+ const result = await manager.transitionTaskStatus(task.id, 'max_rounds', { fromStatus: ['review', 'approved', 'merge-ready'] }, { ...prPatch, reviewRound: reviewedRound });
604
+ if (!result)
605
+ return;
606
+ const { task: transitioned } = result;
607
+ await manager.clearPostApproveCompletion(transitioned.id);
608
+ manager.stopPhaseSignalWatcher(transitioned.id);
609
+ if (transitioned.qaAgentId) {
610
+ const qaState = await manager.getAgentState(transitioned.qaAgentId);
611
+ if (qaState?.taskId === transitioned.id) {
612
+ const qaReleased = await manager
613
+ .releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle', { allowAwaitingHuman: true })
614
+ .catch(err => {
615
+ console.error(`[EventHandler] max_rounds releaseAgentForTask(QA=${transitioned.qaAgentId}) failed:`, err);
616
+ return false;
617
+ });
618
+ if (qaReleased) {
619
+ await manager.updateTask(transitioned.id, { qaAgentId: undefined })
620
+ .catch(err => console.error(`[EventHandler] max_rounds clear qaAgentId(${transitioned.id}) failed:`, err));
621
+ }
622
+ }
623
+ else {
624
+ await manager.updateTask(transitioned.id, { qaAgentId: undefined })
625
+ .catch(err => console.error(`[EventHandler] max_rounds clear stale qaAgentId(${transitioned.id}) failed:`, err));
626
+ }
627
+ }
628
+ try {
629
+ await bus.emit({
630
+ id: '',
631
+ type: 'review.max_rounds',
632
+ timestamp: new Date().toISOString(),
633
+ projectId: transitioned.projectId,
634
+ agentId: transitioned.agentId,
635
+ taskId: transitioned.id,
636
+ data: { reviewRound: transitioned.reviewRound },
637
+ });
638
+ }
639
+ catch (emitErr) {
640
+ console.warn(`[EventHandler] max_rounds emit failed:`, emitErr);
641
+ }
642
+ }
149
643
  export function registerEventHandlers(bus, manager) {
150
644
  bus.on('pr.created', async (event) => {
151
645
  if (!event.taskId || !event.agentId)
@@ -166,22 +660,35 @@ export function registerEventHandlers(bus, manager) {
166
660
  return;
167
661
  }
168
662
  }
169
- // Pane-signal prNumber is agent-emitted; verify the PR's headRefName equals
170
- // task.branch before persisting. Without this, a typo / hallucinated /
171
- // cross-task PR number would route QA review and later auto-merge to the
172
- // wrong PR. Poller-sourced events skip this because the poller already
173
- // routes by `bx/<taskId>`.
174
663
  let paneVerifiedHeadSha;
664
+ let reconciledBranch;
175
665
  if (event.data.source === 'pane-signal' && event.data.prNumber !== undefined) {
176
666
  try {
177
- const verified = await manager.verifyPaneSignalPrNumber(event.taskId, event.data.prNumber);
667
+ const prNumber = event.data.prNumber;
668
+ let verified = await manager.verifyPaneSignalPrNumber(event.taskId, prNumber);
669
+ if (!verified) {
670
+ const taskNow = await manager.getTask(event.taskId);
671
+ if (taskNow) {
672
+ const prInfo = await manager.fetchPrHeadRef(prNumber, taskNow.projectId);
673
+ if (prInfo) {
674
+ const ownPrefix = BRANCH_PREFIX + event.taskId;
675
+ const isForeignBxBranch = prInfo.headRefName.startsWith(BRANCH_PREFIX)
676
+ && prInfo.headRefName !== ownPrefix;
677
+ const bound = await manager.findTaskByBranch(prInfo.headRefName, taskNow.projectId);
678
+ const hasClaimMarker = prInfo.body.includes(BAXIAN_PR_CLAIM);
679
+ if (!isForeignBxBranch && hasClaimMarker && isValidBranchName(prInfo.headRefName)
680
+ && (!bound || bound.id === taskNow.id)) {
681
+ reconciledBranch = prInfo.headRefName;
682
+ verified = prInfo;
683
+ }
684
+ }
685
+ }
686
+ }
178
687
  if (!verified) {
179
688
  const taskNow = await manager.getTask(event.taskId);
180
689
  console.warn(`[EventHandler] pr.created REJECT pane prNumber=${event.data.prNumber} for task ${event.taskId} ` +
181
690
  `(branch mismatch: task.branch=${taskNow?.branch})`);
182
691
  if (taskNow) {
183
- // The watcher fired+removed its entry on this signal; without re-establish
184
- // a corrected emit (same token) would strand the task until restart.
185
692
  await reArmDevelopWatcher(manager, taskNow, event.agentId);
186
693
  await emitIntervention(bus, taskNow.projectId, event.agentId, event.taskId, {
187
694
  phase: 'pane-pr-created-branch-mismatch',
@@ -195,11 +702,15 @@ export function registerEventHandlers(bus, manager) {
195
702
  }
196
703
  catch (err) {
197
704
  console.warn(`[EventHandler] pr.created: verifyPaneSignalPrNumber failed for task ${event.taskId}:`, err);
198
- // Re-establish so a retry once the underlying issue clears (gh transient
199
- // failure, network) can still be consumed without a server restart.
200
705
  const taskNow = await manager.getTask(event.taskId);
201
- if (taskNow)
202
- await reArmDevelopWatcher(manager, taskNow, event.agentId);
706
+ if (taskNow) {
707
+ await reArmDevelopWatcher(manager, taskNow, event.agentId, { skipSnapshot: false });
708
+ await emitIntervention(bus, taskNow.projectId, event.agentId, event.taskId, {
709
+ phase: 'pane-pr-created-verify-error',
710
+ claimedPrNumber: event.data.prNumber,
711
+ error: err instanceof Error ? err.message : String(err),
712
+ });
713
+ }
203
714
  return;
204
715
  }
205
716
  }
@@ -211,19 +722,30 @@ export function registerEventHandlers(bus, manager) {
211
722
  ...(event.data.prNumber !== undefined ? { prNumber: event.data.prNumber } : {}),
212
723
  ...(event.data.prUrl !== undefined ? { prUrl: event.data.prUrl } : {}),
213
724
  ...(createdHeadSha ? { latestHeadSha: createdHeadSha, reviewHeadAnchorSha: createdHeadSha } : {}),
725
+ ...(reconciledBranch ? { branch: reconciledBranch } : {}),
214
726
  reviewDispatchedAt: new Date().toISOString(),
215
- // Rotate the per-pass token ATOMICALLY with the anchor (same as pr.updated and
216
- // dispatchReviewToQa). On a fixing→review re-review there is a prior QA pass; if
217
- // the QA acquire below fails the token would otherwise lag until
218
- // rotateAndSetupPhaseSignal never runs, letting that old pass's late stamped
219
- // verdict (token still == task.signalToken) slip past the gate.
220
727
  signalToken: createSignalToken(),
221
728
  });
222
729
  if (!result) {
223
730
  console.warn(`[EventHandler] pr.created: cannot transition task ${event.taskId} (terminal or invalid from-state)`);
731
+ if (event.data.source === 'pane-signal') {
732
+ const taskNow = await manager.getTask(event.taskId);
733
+ if (taskNow && ['in_progress', 'fixing'].includes(taskNow.status)) {
734
+ await reArmDevelopWatcher(manager, taskNow, event.agentId);
735
+ await emitIntervention(bus, taskNow.projectId, event.agentId, event.taskId, {
736
+ phase: 'pane-pr-created-transition-failed',
737
+ claimedPrNumber: event.data.prNumber,
738
+ taskBranch: taskNow.branch ?? '',
739
+ });
740
+ }
741
+ }
224
742
  return;
225
743
  }
226
744
  const { task: transitioned, previousStatus } = result;
745
+ if (reconciledBranch) {
746
+ console.log(`[EventHandler] pr.created reconciled branch for task ${event.taskId}: ` +
747
+ `${taskBeforeTransition?.branch} → ${reconciledBranch}`);
748
+ }
227
749
  // Round captured before any verdict can land (watcher armed only after this) — the
228
750
  // count-once token for bumpReviewRoundIfStillAt on the dispatch success path.
229
751
  const expectedRound = transitioned.reviewRound;
@@ -336,12 +858,8 @@ export function registerEventHandlers(bus, manager) {
336
858
  // pr.merged stays open: external merges of a ready PR finish through it.
337
859
  if (await isServerModeTask(manager, event.taskId))
338
860
  return;
339
- const eventPrNumber = event.data.prNumber;
340
- const eventPrUrl = event.data.prUrl;
341
861
  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 限制。
862
+ // spec phase 由 server 评审链驱动;pr-merge-ready dev 内部状态推进,不受限。
345
863
  if (eventKind !== 'pr-merge-ready') {
346
864
  const taskNow = await manager.getTask(event.taskId);
347
865
  if (taskNow?.phase === 'spec') {
@@ -349,320 +867,11 @@ export function registerEventHandlers(bus, manager) {
349
867
  return;
350
868
  }
351
869
  }
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
- }
870
+ if (eventKind === 'pr-merge-ready')
871
+ return handlePrMergeReady(bus, manager, event);
872
+ if (eventKind !== 'push' && eventKind !== undefined)
873
+ return handlePrFeedback(bus, manager, event);
874
+ return handlePrCodePush(bus, manager, event);
666
875
  });
667
876
  bus.on('pr.merged', async (event) => {
668
877
  if (!event.taskId)
@@ -839,246 +1048,10 @@ export function registerEventHandlers(bus, manager) {
839
1048
  }
840
1049
  }
841
1050
  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;
1051
+ return handleReviewApproval(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch);
887
1052
  }
888
1053
  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
- }
1054
+ return handleReviewRequestChanges(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch);
1082
1055
  }
1083
1056
  });
1084
1057
  // Dev emitted pr-fixed: it claims the fixing round is done. Verify on GitHub