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.
- package/dist/agent/detect/manifests/claude-code.json +11 -8
- package/dist/agent/manager.d.ts +7 -1
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +43 -13
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/prompt.d.ts.map +1 -1
- package/dist/agent/prompt.js +4 -0
- package/dist/agent/prompt.js.map +1 -1
- package/dist/api/tasks.d.ts.map +1 -1
- package/dist/api/tasks.js +3 -2
- package/dist/api/tasks.js.map +1 -1
- package/dist/event/handlers.d.ts.map +1 -1
- package/dist/event/handlers.js +550 -577
- package/dist/event/handlers.js.map +1 -1
- package/dist/event/ws.d.ts.map +1 -1
- package/dist/event/ws.js +4 -3
- package/dist/event/ws.js.map +1 -1
- package/dist/shared/constants.d.ts +1 -0
- package/dist/shared/constants.d.ts.map +1 -1
- package/dist/shared/constants.js +1 -0
- package/dist/shared/constants.js.map +1 -1
- package/dist/shared/types.d.ts +2 -0
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js.map +1 -1
- package/dist/skills/pr-recheck/SKILL.md +18 -0
- package/dist/skills/pr-review/SKILL.md +18 -0
- package/dist/state/snapshot.d.ts +1 -0
- package/dist/state/snapshot.d.ts.map +1 -1
- package/dist/state/snapshot.js +10 -0
- package/dist/state/snapshot.js.map +1 -1
- package/dist/web/assets/index-D773dyK5.js +4 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-BQ-vJO9b.js +0 -4
package/dist/event/handlers.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createSignalToken } from '../agent/phase-signal.js';
|
|
2
|
-
import {
|
|
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
|
|
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 评审链驱动;
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|