baxian 1.2.3 → 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.
- package/dist/agent/detect/debounce.d.ts +7 -0
- package/dist/agent/detect/debounce.d.ts.map +1 -0
- package/dist/agent/detect/debounce.js +23 -0
- package/dist/agent/detect/debounce.js.map +1 -0
- package/dist/agent/detect/gate.d.ts +11 -0
- package/dist/agent/detect/gate.d.ts.map +1 -0
- package/dist/agent/detect/gate.js +115 -0
- package/dist/agent/detect/gate.js.map +1 -0
- package/dist/agent/detect/index.d.ts +5 -0
- package/dist/agent/detect/index.d.ts.map +1 -0
- package/dist/agent/detect/index.js +5 -0
- package/dist/agent/detect/index.js.map +1 -0
- package/dist/agent/detect/manifest.d.ts +27 -0
- package/dist/agent/detect/manifest.d.ts.map +1 -0
- package/dist/agent/detect/manifest.js +75 -0
- package/dist/agent/detect/manifest.js.map +1 -0
- package/dist/agent/detect/manifests/claude-code.json +233 -0
- package/dist/agent/detect/manifests/codex.json +115 -0
- package/dist/agent/detect/region.d.ts +6 -0
- package/dist/agent/detect/region.d.ts.map +1 -0
- package/dist/agent/detect/region.js +118 -0
- package/dist/agent/detect/region.js.map +1 -0
- package/dist/agent/manager.d.ts +1 -0
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +16 -13
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/review-transport.d.ts +1 -1
- package/dist/agent/review-transport.d.ts.map +1 -1
- package/dist/agent/review-transport.js +1 -4
- package/dist/agent/review-transport.js.map +1 -1
- package/dist/agent/tmux-probe-poller.d.ts +5 -0
- package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
- package/dist/agent/tmux-probe-poller.js +82 -26
- package/dist/agent/tmux-probe-poller.js.map +1 -1
- package/dist/agent/tmux.d.ts +1 -1
- package/dist/agent/tmux.d.ts.map +1 -1
- package/dist/agent/tmux.js +12 -20
- package/dist/agent/tmux.js.map +1 -1
- package/dist/config/loader.d.ts +1 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +1 -3
- package/dist/config/loader.js.map +1 -1
- package/dist/config/validator.d.ts +1 -1
- package/dist/config/validator.d.ts.map +1 -1
- package/dist/config/validator.js +1 -4
- package/dist/config/validator.js.map +1 -1
- package/dist/event/handlers.d.ts.map +1 -1
- package/dist/event/handlers.js +501 -557
- package/dist/event/handlers.js.map +1 -1
- package/dist/shared/types.d.ts +1 -0
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js +3 -1
- package/dist/shared/types.js.map +1 -1
- package/package.json +1 -1
package/dist/event/handlers.js
CHANGED
|
@@ -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 评审链驱动;
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|