baxian 1.2.57 → 1.2.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/dist/agent/branch.d.ts +6 -1
  2. package/dist/agent/branch.d.ts.map +1 -1
  3. package/dist/agent/branch.js +45 -9
  4. package/dist/agent/branch.js.map +1 -1
  5. package/dist/agent/manager.d.ts +182 -5
  6. package/dist/agent/manager.d.ts.map +1 -1
  7. package/dist/agent/manager.js +2235 -147
  8. package/dist/agent/manager.js.map +1 -1
  9. package/dist/agent/phase-signal-watcher.d.ts +8 -1
  10. package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
  11. package/dist/agent/phase-signal-watcher.js +66 -15
  12. package/dist/agent/phase-signal-watcher.js.map +1 -1
  13. package/dist/agent/phase-signal.d.ts +3 -1
  14. package/dist/agent/phase-signal.d.ts.map +1 -1
  15. package/dist/agent/phase-signal.js +27 -4
  16. package/dist/agent/phase-signal.js.map +1 -1
  17. package/dist/agent/tmux.d.ts +8 -1
  18. package/dist/agent/tmux.d.ts.map +1 -1
  19. package/dist/agent/tmux.js +61 -1
  20. package/dist/agent/tmux.js.map +1 -1
  21. package/dist/api/config.d.ts.map +1 -1
  22. package/dist/api/config.js +16 -4
  23. package/dist/api/config.js.map +1 -1
  24. package/dist/api/platform-guard.d.ts +7 -0
  25. package/dist/api/platform-guard.d.ts.map +1 -0
  26. package/dist/api/platform-guard.js +32 -0
  27. package/dist/api/platform-guard.js.map +1 -0
  28. package/dist/api/projects.d.ts.map +1 -1
  29. package/dist/api/projects.js +34 -6
  30. package/dist/api/projects.js.map +1 -1
  31. package/dist/config/normalizer.d.ts.map +1 -1
  32. package/dist/config/normalizer.js +1 -0
  33. package/dist/config/normalizer.js.map +1 -1
  34. package/dist/config/validator.d.ts +3 -1
  35. package/dist/config/validator.d.ts.map +1 -1
  36. package/dist/config/validator.js +104 -7
  37. package/dist/config/validator.js.map +1 -1
  38. package/dist/event/handlers.d.ts +1 -0
  39. package/dist/event/handlers.d.ts.map +1 -1
  40. package/dist/event/handlers.js +661 -89
  41. package/dist/event/handlers.js.map +1 -1
  42. package/dist/github/mapper.d.ts +1 -6
  43. package/dist/github/mapper.d.ts.map +1 -1
  44. package/dist/github/mapper.js.map +1 -1
  45. package/dist/github/poller.d.ts +3 -3
  46. package/dist/github/poller.d.ts.map +1 -1
  47. package/dist/github/poller.js +2 -11
  48. package/dist/github/poller.js.map +1 -1
  49. package/dist/github/resolver.js +1 -1
  50. package/dist/github/resolver.js.map +1 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +33 -2
  53. package/dist/index.js.map +1 -1
  54. package/dist/platform/body-digest.d.ts +4 -0
  55. package/dist/platform/body-digest.d.ts.map +1 -0
  56. package/dist/platform/body-digest.js +13 -0
  57. package/dist/platform/body-digest.js.map +1 -0
  58. package/dist/platform/command-renderer.d.ts +22 -0
  59. package/dist/platform/command-renderer.d.ts.map +1 -0
  60. package/dist/platform/command-renderer.js +82 -0
  61. package/dist/platform/command-renderer.js.map +1 -0
  62. package/dist/platform/comment-cursor.d.ts +36 -0
  63. package/dist/platform/comment-cursor.d.ts.map +1 -0
  64. package/dist/platform/comment-cursor.js +235 -0
  65. package/dist/platform/comment-cursor.js.map +1 -0
  66. package/dist/platform/driver-host.d.ts +7 -0
  67. package/dist/platform/driver-host.d.ts.map +1 -0
  68. package/dist/platform/driver-host.js +18 -0
  69. package/dist/platform/driver-host.js.map +1 -0
  70. package/dist/platform/driver-spec.d.ts +7 -0
  71. package/dist/platform/driver-spec.d.ts.map +1 -0
  72. package/dist/platform/driver-spec.js +475 -0
  73. package/dist/platform/driver-spec.js.map +1 -0
  74. package/dist/platform/feedback.d.ts +31 -0
  75. package/dist/platform/feedback.d.ts.map +1 -0
  76. package/dist/platform/feedback.js +85 -0
  77. package/dist/platform/feedback.js.map +1 -0
  78. package/dist/platform/field-mapper.d.ts +7 -0
  79. package/dist/platform/field-mapper.d.ts.map +1 -0
  80. package/dist/platform/field-mapper.js +117 -0
  81. package/dist/platform/field-mapper.js.map +1 -0
  82. package/dist/platform/git-driver.d.ts +55 -0
  83. package/dist/platform/git-driver.d.ts.map +1 -0
  84. package/dist/platform/git-driver.js +171 -0
  85. package/dist/platform/git-driver.js.map +1 -0
  86. package/dist/platform/manifest.d.ts +12 -0
  87. package/dist/platform/manifest.d.ts.map +1 -0
  88. package/dist/platform/manifest.js +57 -0
  89. package/dist/platform/manifest.js.map +1 -0
  90. package/dist/platform/markers.d.ts +46 -0
  91. package/dist/platform/markers.d.ts.map +1 -0
  92. package/dist/platform/markers.js +121 -0
  93. package/dist/platform/markers.js.map +1 -0
  94. package/dist/platform/platform-poller.d.ts +68 -0
  95. package/dist/platform/platform-poller.d.ts.map +1 -0
  96. package/dist/platform/platform-poller.js +605 -0
  97. package/dist/platform/platform-poller.js.map +1 -0
  98. package/dist/platform/plugin-registry.d.ts +31 -0
  99. package/dist/platform/plugin-registry.d.ts.map +1 -0
  100. package/dist/platform/plugin-registry.js +238 -0
  101. package/dist/platform/plugin-registry.js.map +1 -0
  102. package/dist/platform/plugin-roots.d.ts +3 -0
  103. package/dist/platform/plugin-roots.d.ts.map +1 -0
  104. package/dist/platform/plugin-roots.js +11 -0
  105. package/dist/platform/plugin-roots.js.map +1 -0
  106. package/dist/platform/plugins/github/baxian-plugin.json +8 -0
  107. package/dist/platform/plugins/github/driver.json +134 -0
  108. package/dist/platform/plugins/github/skills/baxian-cli-gh/SKILL.md +99 -0
  109. package/dist/platform/poller-health.d.ts +3 -0
  110. package/dist/platform/poller-health.d.ts.map +1 -0
  111. package/dist/platform/poller-health.js +12 -0
  112. package/dist/platform/poller-health.js.map +1 -0
  113. package/dist/platform/pr-binding.d.ts +22 -0
  114. package/dist/platform/pr-binding.d.ts.map +1 -0
  115. package/dist/platform/pr-binding.js +28 -0
  116. package/dist/platform/pr-binding.js.map +1 -0
  117. package/dist/platform/response-parser.d.ts +6 -0
  118. package/dist/platform/response-parser.d.ts.map +1 -0
  119. package/dist/platform/response-parser.js +92 -0
  120. package/dist/platform/response-parser.js.map +1 -0
  121. package/dist/platform/row-schema.d.ts +12 -0
  122. package/dist/platform/row-schema.d.ts.map +1 -0
  123. package/dist/platform/row-schema.js +127 -0
  124. package/dist/platform/row-schema.js.map +1 -0
  125. package/dist/platform/startup.d.ts +16 -0
  126. package/dist/platform/startup.d.ts.map +1 -0
  127. package/dist/platform/startup.js +52 -0
  128. package/dist/platform/startup.js.map +1 -0
  129. package/dist/platform/types.d.ts +62 -0
  130. package/dist/platform/types.d.ts.map +1 -0
  131. package/dist/platform/types.js +32 -0
  132. package/dist/platform/types.js.map +1 -0
  133. package/dist/platform/verdict-engine.d.ts +61 -0
  134. package/dist/platform/verdict-engine.d.ts.map +1 -0
  135. package/dist/platform/verdict-engine.js +134 -0
  136. package/dist/platform/verdict-engine.js.map +1 -0
  137. package/dist/shared/constants.d.ts +2 -0
  138. package/dist/shared/constants.d.ts.map +1 -1
  139. package/dist/shared/constants.js +3 -0
  140. package/dist/shared/constants.js.map +1 -1
  141. package/dist/shared/git-url.d.ts +8 -0
  142. package/dist/shared/git-url.d.ts.map +1 -1
  143. package/dist/shared/git-url.js +38 -0
  144. package/dist/shared/git-url.js.map +1 -1
  145. package/dist/shared/types.d.ts +52 -1
  146. package/dist/shared/types.d.ts.map +1 -1
  147. package/dist/shared/types.js.map +1 -1
  148. package/dist/state/agent-store.js +2 -0
  149. package/dist/state/agent-store.js.map +1 -1
  150. package/dist/state/task-store.d.ts.map +1 -1
  151. package/dist/state/task-store.js +91 -4
  152. package/dist/state/task-store.js.map +1 -1
  153. package/dist/web/assets/index-DskVWe7e.js +13 -0
  154. package/dist/web/index.html +1 -1
  155. package/package.json +1 -1
  156. package/dist/web/assets/index-3Ypk8Gfo.js +0 -13
@@ -1,7 +1,10 @@
1
- import { createSignalToken } from '../agent/phase-signal.js';
2
- import { BRANCH_PREFIX, TASK_TERMINAL_STATUS_SET, isSpecStagePhase, isValidBranchName, } from '../shared/index.js';
3
- import { DispatchTerminalError, EnsureSessionError } from '../agent/manager.js';
4
- const HEAD_SHA_RE = /^[0-9a-f]{40}$/i;
1
+ import { createSignalToken, decodeSignalActorId } from '../agent/phase-signal.js';
2
+ import { ackRevisionKey } from '../platform/markers.js';
3
+ import { SHA_HEX_SOURCE } from '../platform/types.js';
4
+ import { BRANCH_PREFIX, TASK_TERMINAL_STATUS_SET, isRecord, isSpecStagePhase, isValidBranchName, } from '../shared/index.js';
5
+ import { DispatchTerminalError, EnsureSessionError, isRecoverableQaDispatchHold } from '../agent/manager.js';
6
+ // 平台 SHA 文法单点(spec §5.3:7-64 位 hex);legacy github 值恒 40 位,放宽为 additive
7
+ const HEAD_SHA_RE = new RegExp(`^${SHA_HEX_SOURCE}$`);
5
8
  function validHeadSha(value) {
6
9
  return typeof value === 'string' && HEAD_SHA_RE.test(value) ? value : undefined;
7
10
  }
@@ -75,18 +78,45 @@ async function dispatchDevPostApproveCheck(bus, manager, task, approvedHeadSha,
75
78
  }
76
79
  const acquired = await manager.acquireAgentForTask(task.agentId, task.id, 'post-approve');
77
80
  if (!acquired) {
81
+ if (task.reviewMode === 'git') {
82
+ // durable retry 锚:acquire 失败发生在 completion 安装前,无 pending 则 sweep 永远看不到
83
+ await manager.updateTask(task.id, { pendingRedispatch: true }).catch(() => undefined);
84
+ }
78
85
  await emitIntervention(bus, task.projectId, task.agentId, task.id, {
79
86
  phase: 'post-approve-dev-acquire-failed',
80
87
  devAgentId: task.agentId,
81
88
  });
82
89
  return;
83
90
  }
91
+ // Persisted after acquire and only while still approved: a stale write would forge rebuild evidence.
92
+ const headPersisted = await manager.updateTaskIfStatus(task.id, 'approved', {
93
+ postApproveHeadSha: approvedHeadSha,
94
+ }, { signalToken: task.signalToken, reviewRound: task.reviewRound });
95
+ if (!headPersisted) {
96
+ await manager.markAgentWaiting(task.agentId, task.id)
97
+ .catch(err => {
98
+ console.error(`[EventHandler] post-approve head persist refused for task=${task.id}; release failed:`, err);
99
+ });
100
+ return;
101
+ }
84
102
  const signalToken = createSignalToken();
85
- await manager.setPostApproveCompletion(task.id, {
103
+ const installed = await manager.setPostApproveCompletion(task.id, {
86
104
  token: signalToken,
87
105
  approvedHeadSha,
88
106
  ...(typeof opts.redispatchCount === 'number' ? { redispatchCount: opts.redispatchCount } : {}),
89
- });
107
+ // 'git' 至少一次投递(spec §6):pending 随安装保持,continueSession 确认后才清——
108
+ // 安装与注入之间的崩溃由恢复扫描按 pending 补派
109
+ ...(task.reviewMode === 'git' ? { pendingRedispatch: true } : {}),
110
+ },
111
+ // A fresh human verdict unlocks a standing revocation; automated redispatches never do.
112
+ { clearRevocation: typeof opts.redispatchCount !== 'number' });
113
+ if (!installed) {
114
+ await manager.markAgentWaiting(task.agentId, task.id)
115
+ .catch(err => {
116
+ console.error(`[EventHandler] post-approve install refused by revocation for task=${task.id}; release failed:`, err);
117
+ });
118
+ return;
119
+ }
90
120
  let resumed = false;
91
121
  let dispatchErr = null;
92
122
  try {
@@ -101,9 +131,16 @@ async function dispatchDevPostApproveCheck(bus, manager, task, approvedHeadSha,
101
131
  dispatchErr = err;
102
132
  console.error(`[EventHandler] APPROVE continueSession(dev=${task.agentId}, post-approve) failed:`, err);
103
133
  }
104
- if (resumed)
134
+ if (resumed) {
135
+ if (task.reviewMode === 'git')
136
+ await manager.confirmPostApprovePromptDelivered(task.id, signalToken);
105
137
  return;
106
- await manager.clearPostApproveCompletionIfMatches(task.id, signalToken);
138
+ }
139
+ // git:未确认送达的失败保留 installed+pending 交 sweep 补派(DispatchTerminalError 单独终结);
140
+ // legacy 维持清除语义
141
+ if (task.reviewMode !== 'git' || dispatchErr instanceof DispatchTerminalError) {
142
+ await manager.clearPostApproveCompletionIfMatches(task.id, signalToken);
143
+ }
107
144
  if (dispatchErr instanceof DispatchTerminalError) {
108
145
  await manager.failTaskForDispatchError(task.id, 'post-approve', task.agentId, dispatchErr);
109
146
  return;
@@ -165,36 +202,107 @@ async function handlePrMergeReady(bus, manager, event) {
165
202
  });
166
203
  return;
167
204
  }
168
- const [freshTask, freshCompletion] = await Promise.all([
205
+ const [freshTask, entryCompletion] = await Promise.all([
169
206
  manager.getTask(taskNow.id),
170
207
  manager.getPostApproveCompletion(taskNow.id),
171
208
  ]);
172
209
  if (!freshTask
173
210
  || freshTask.status !== 'approved'
174
211
  || freshTask.agentId !== taskNow.agentId
175
- || freshCompletion?.token !== signalToken) {
212
+ || freshTask.postApproveRevoked !== undefined
213
+ || entryCompletion?.token !== signalToken
214
+ || freshTask.postApproveHeadSha === undefined
215
+ || entryCompletion.approvedHeadSha !== freshTask.postApproveHeadSha) {
176
216
  await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
177
217
  phase: 'post-approve-merge-skipped-stale-task',
178
218
  });
179
219
  return;
180
220
  }
181
- if (freshCompletion.pendingRedispatch) {
182
- const nextCount = (freshCompletion.redispatchCount ?? 0) + 1;
183
- if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
184
- await manager.clearPostApproveCompletion(freshTask.id);
221
+ // 'git' merge-ready 接收即 server 权威双复核(spec §6/§10:provenance + ack 完成集,同一次全源扫描):
222
+ // agent 自查是尽责而非授权
223
+ let freshCompletion = entryCompletion;
224
+ if (freshTask.reviewMode === 'git') {
225
+ // 信号已消费(token 已验证):durable 记录 signaled——瞬态复核失败时 dev 不会重发信号,
226
+ // 恢复/sweep 凭此相位重派而不是等一个不会再来的信号
227
+ await manager.markPostApproveSignalReceived(freshTask.id, signalToken);
228
+ const scanFailedRetry = async (error) => {
229
+ await manager.updatePostApproveCompletionIfToken(freshTask.id, signalToken, { pendingRedispatch: true });
185
230
  await emitIntervention(bus, freshTask.projectId, freshTask.agentId, freshTask.id, {
186
- phase: 'post-approve-redispatch-cap-exceeded',
187
- redispatchCount: freshCompletion.redispatchCount ?? 0,
188
- cap: POST_APPROVE_REDISPATCH_CAP,
231
+ phase: 'post-approve-merge-skipped-scan-failed',
232
+ error,
233
+ });
234
+ };
235
+ let verified;
236
+ try {
237
+ verified = await manager.platformVerifyAcceptedPass(freshTask.id);
238
+ }
239
+ catch (err) {
240
+ await scanFailedRetry(err instanceof Error ? err.message : String(err));
241
+ return;
242
+ }
243
+ if (!verified.ok) {
244
+ if (verified.reason === 'source scan incomplete') {
245
+ await scanFailedRetry(verified.reason);
246
+ return;
247
+ }
248
+ // pass 已被撤销/编辑(确定性失效):拒绝迁移滞留 approved,交人工重派评审(spec §6 merge 条)
249
+ await emitIntervention(bus, freshTask.projectId, freshTask.agentId, freshTask.id, {
250
+ phase: 'post-approve-merge-skipped-provenance',
251
+ reason: verified.reason,
189
252
  });
190
253
  return;
191
254
  }
192
- await dispatchDevPostApproveCheck(bus, manager, freshTask, freshCompletion.approvedHeadSha, { redispatchCount: nextCount });
193
- return;
255
+ if (verified.pendingCount > 0 && !freshCompletion.pendingRedispatch) {
256
+ if (await manager.updatePostApproveCompletionIfToken(freshTask.id, signalToken, { pendingRedispatch: true })) {
257
+ freshCompletion = { ...freshCompletion, pendingRedispatch: true };
258
+ }
259
+ }
260
+ else if (verified.pendingCount === 0 && freshCompletion.pendingRedispatch) {
261
+ // 权威扫描为空即清 stale pending:dev 已在本轮内补齐 ack,不再白派一轮
262
+ if (await manager.updatePostApproveCompletionIfToken(freshTask.id, signalToken, { pendingRedispatch: false })) {
263
+ freshCompletion = { ...freshCompletion, pendingRedispatch: false };
264
+ }
265
+ }
194
266
  }
195
- const readied = await manager.transitionTaskStatus(freshTask.id, 'merge-ready', { fromStatus: ['approved'] }, { latestHeadSha: freshCompletion.approvedHeadSha });
196
- if (readied) {
197
- await manager.clearPostApproveCompletionIfMatches(freshTask.id, signalToken);
267
+ // A pending flag landing between the check and the transition re-enters here instead of losing the feedback.
268
+ for (;;) {
269
+ if (freshCompletion.pendingRedispatch) {
270
+ const nextCount = (freshCompletion.redispatchCount ?? 0) + 1;
271
+ if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
272
+ const revoked = await manager.revokePostApproveCompletion(freshTask.id, 'redispatch-cap', {
273
+ expectedToken: freshCompletion.token,
274
+ });
275
+ if (!revoked) {
276
+ // The pass rotated after the entry re-validation; this merge-ready and its cap decision are stale.
277
+ await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
278
+ phase: 'post-approve-merge-skipped-stale-task',
279
+ });
280
+ return;
281
+ }
282
+ await emitIntervention(bus, freshTask.projectId, freshTask.agentId, freshTask.id, {
283
+ phase: 'post-approve-redispatch-cap-exceeded',
284
+ redispatchCount: freshCompletion.redispatchCount ?? 0,
285
+ cap: POST_APPROVE_REDISPATCH_CAP,
286
+ });
287
+ return;
288
+ }
289
+ await dispatchDevPostApproveCheck(bus, manager, freshTask, freshCompletion.approvedHeadSha, { redispatchCount: nextCount });
290
+ return;
291
+ }
292
+ const completed = await manager.completeApprovedPassToMergeReady(freshTask.id, signalToken);
293
+ if ('task' in completed)
294
+ return;
295
+ if (completed.refused === 'pending') {
296
+ const latest = await manager.getPostApproveCompletion(freshTask.id);
297
+ if (latest?.token === signalToken) {
298
+ freshCompletion = latest;
299
+ continue;
300
+ }
301
+ }
302
+ await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
303
+ phase: 'post-approve-merge-skipped-stale-task',
304
+ });
305
+ return;
198
306
  }
199
307
  }
200
308
  async function handlePrFeedback(bus, manager, event) {
@@ -206,10 +314,52 @@ async function handlePrFeedback(bus, manager, event) {
206
314
  const eventPrUrl = event.data.prUrl;
207
315
  const eventKind = event.data.kind;
208
316
  const isNewFeedback = eventKind === 'comment' || eventKind === 'review-comment';
317
+ const revision = taskNow.reviewMode === 'git' && isRecord(event.data.revision)
318
+ ? event.data.revision
319
+ : undefined;
320
+ const revisionKey = revision ? ackRevisionKey(revision.sourceKey, revision.id, revision.bodyDigest) : undefined;
321
+ const revisionTime = typeof revision?.versionTime === 'number' ? revision.versionTime : Date.parse(event.timestamp);
322
+ // 消费幂等(spec §6):ledger 落盘前崩溃的重投在此收敛,redispatch 计数不重复生效
323
+ if (revisionKey !== undefined && taskNow.consumedFeedback?.[revisionKey] !== undefined)
324
+ return;
209
325
  const prPatch = {
210
326
  ...(eventPrNumber !== undefined ? { prNumber: eventPrNumber } : {}),
211
327
  ...(eventPrUrl !== undefined ? { prUrl: eventPrUrl } : {}),
212
328
  };
329
+ // 消费与效果由 manager 的单一锁内状态机决定(spec §6):merge-ready→退回+pending、approved→
330
+ // pending、其它→仅消费——入口快照与提交之间的并发迁移(completeApprovedPass 等)不再能让
331
+ // revision 既被 poller 记 delivered 又不产生效果
332
+ if (taskNow.reviewMode === 'git' && isNewFeedback && revisionKey !== undefined) {
333
+ const outcome = await manager.consumeGitFeedbackRevision(taskId, { key: revisionKey, versionTime: revisionTime }, { feedbackAt: event.timestamp });
334
+ if (outcome.kind !== 'pending' && outcome.kind !== 'returned')
335
+ return;
336
+ const effected = outcome.task;
337
+ const completion = await manager.getPostApproveCompletion(effected.id);
338
+ const devState = await manager.getAgentState(effected.agentId);
339
+ if (outcome.kind === 'pending' && completion !== null && devState?.taskId === effected.id) {
340
+ // 在途 pass 会经 merge-ready 循环消化 pending;无 completion 的 durable pending 由下方补派
341
+ return;
342
+ }
343
+ const nextCount = (effected.redispatchCount ?? completion?.redispatchCount ?? 0) + 1;
344
+ if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
345
+ await manager.revokePostApproveCompletion(effected.id, 'redispatch-cap');
346
+ await emitIntervention(bus, effected.projectId, effected.agentId, effected.id, {
347
+ phase: 'post-approve-redispatch-cap-exceeded',
348
+ redispatchCount: effected.redispatchCount ?? 0,
349
+ cap: POST_APPROVE_REDISPATCH_CAP,
350
+ });
351
+ return;
352
+ }
353
+ const approvedHead = effected.postApproveHeadSha ?? validHeadSha(effected.latestHeadSha);
354
+ if (!approvedHead) {
355
+ await emitIntervention(bus, effected.projectId, effected.agentId, effected.id, {
356
+ phase: 'post-approve-approved-head-unavailable',
357
+ });
358
+ return;
359
+ }
360
+ await dispatchDevPostApproveCheck(bus, manager, effected, approvedHead, { redispatchCount: nextCount });
361
+ return;
362
+ }
213
363
  const taskPatch = {
214
364
  ...prPatch,
215
365
  ...(isNewFeedback ? { prFeedbackReceivedAt: event.timestamp } : {}),
@@ -224,40 +374,140 @@ async function handlePrFeedback(bus, manager, event) {
224
374
  return;
225
375
  if (!isNewFeedback)
226
376
  return;
227
- const completion = await manager.getPostApproveCompletion(taskNow.id);
377
+ let completion = await manager.getPostApproveCompletion(taskNow.id);
228
378
  if (!completion) {
229
379
  await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
230
380
  phase: 'post-approve-approved-head-unavailable',
231
381
  });
232
382
  return;
233
383
  }
234
- const devState = await manager.getAgentState(taskNow.agentId);
235
- if (devState?.taskId === taskNow.id) {
236
- if (!completion.pendingRedispatch) {
237
- await manager.setPostApproveCompletion(taskNow.id, {
238
- token: completion.token,
239
- approvedHeadSha: completion.approvedHeadSha,
240
- ...(typeof completion.redispatchCount === 'number'
241
- ? { redispatchCount: completion.redispatchCount } : {}),
384
+ // Rotated successors are installed under a head CAS, so only the entry snapshot needs this proof.
385
+ if (taskNow.postApproveHeadSha === undefined
386
+ || completion.approvedHeadSha !== taskNow.postApproveHeadSha) {
387
+ await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
388
+ phase: 'post-approve-completion-episode-mismatch',
389
+ });
390
+ return;
391
+ }
392
+ // Every CAS miss below means the completion rotated mid-flight; re-decide against the live pass so the feedback is never dropped.
393
+ for (;;) {
394
+ const devState = await manager.getAgentState(taskNow.agentId);
395
+ if (devState?.taskId === taskNow.id) {
396
+ if (completion.pendingRedispatch) {
397
+ // pending 已立不改效果,但本条 revision 的消费键仍须落盘:崩溃后重投不得再计一轮
398
+ if (revisionKey !== undefined) {
399
+ await manager.updatePostApproveCompletionIfToken(taskNow.id, completion.token, {}, { consumeRevision: { key: revisionKey, versionTime: revisionTime } });
400
+ }
401
+ const fresh = await manager.getPostApproveCompletion(taskNow.id);
402
+ // Same token: the pending mark is already on the live pass.
403
+ if (!fresh || fresh.token === completion.token)
404
+ return;
405
+ completion = fresh;
406
+ continue;
407
+ }
408
+ if (await manager.updatePostApproveCompletionIfToken(taskNow.id, completion.token, {
409
+ pendingRedispatch: true,
410
+ }, revisionKey !== undefined ? { consumeRevision: { key: revisionKey, versionTime: revisionTime } } : {}))
411
+ return;
412
+ const fresh = await manager.getPostApproveCompletion(taskNow.id);
413
+ // Same token: refused in place (revoked marker) — the successor fixing flow owns this feedback.
414
+ if (!fresh || fresh.token === completion.token)
415
+ return;
416
+ completion = fresh;
417
+ continue;
418
+ }
419
+ if (taskNow.reviewMode === 'git' && revisionKey !== undefined) {
420
+ if (await manager.updatePostApproveCompletionIfToken(taskNow.id, completion.token, {
242
421
  pendingRedispatch: true,
422
+ }, { consumeRevision: { key: revisionKey, versionTime: revisionTime } })) {
423
+ completion = { ...completion, pendingRedispatch: true };
424
+ }
425
+ else {
426
+ const fresh = await manager.getPostApproveCompletion(taskNow.id);
427
+ if (!fresh || fresh.token === completion.token)
428
+ return;
429
+ completion = fresh;
430
+ continue;
431
+ }
432
+ }
433
+ const nextCount = (completion.redispatchCount ?? 0) + 1;
434
+ if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
435
+ const revoked = await manager.revokePostApproveCompletion(taskNow.id, 'redispatch-cap', {
436
+ expectedToken: completion.token,
243
437
  });
438
+ if (!revoked) {
439
+ const fresh = await manager.getPostApproveCompletion(taskNow.id);
440
+ if (!fresh || fresh.token === completion.token)
441
+ return;
442
+ completion = fresh;
443
+ continue;
444
+ }
445
+ await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
446
+ phase: 'post-approve-redispatch-cap-exceeded',
447
+ redispatchCount: completion.redispatchCount ?? 0,
448
+ cap: POST_APPROVE_REDISPATCH_CAP,
449
+ });
450
+ return;
244
451
  }
452
+ const ready = await gateDevForPostApproveRedispatch(bus, manager, taskNow);
453
+ if (!ready)
454
+ return;
455
+ await dispatchDevPostApproveCheck(bus, manager, { ...taskNow, ...taskPatch }, completion.approvedHeadSha, { redispatchCount: nextCount });
245
456
  return;
246
457
  }
247
- const nextCount = (completion.redispatchCount ?? 0) + 1;
248
- if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
249
- await manager.clearPostApproveCompletion(taskNow.id);
250
- await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
251
- phase: 'post-approve-redispatch-cap-exceeded',
252
- redispatchCount: completion.redispatchCount ?? 0,
253
- cap: POST_APPROVE_REDISPATCH_CAP,
254
- });
255
- return;
458
+ }
459
+ // 恢复期消费 durable pendingRedispatch(spec §6 至少一次):merge-ready 退回或 path C 的 CAS
460
+ // 落盘后、派发前崩溃的残局在此补派;dev 仍绑定任务时不派(其在途 pass 的 merge-ready 循环接手)。
461
+ export async function recoverGitPostApprovePending(bus, manager) {
462
+ const tasks = await manager.listActiveGitTasks();
463
+ for (const task of tasks) {
464
+ try {
465
+ await recoverOneGitPendingTask(bus, manager, task);
466
+ }
467
+ catch (err) {
468
+ // 单任务失败不得饿死其后的 durable pending(至少一次):报告并继续
469
+ console.warn(`[EventHandler] recoverGitPostApprovePending: task=${task.id} failed, continuing:`, err);
470
+ await emitIntervention(bus, task.projectId, task.agentId, task.id, {
471
+ phase: 'post-approve-recovery-failed',
472
+ error: err instanceof Error ? err.message : String(err),
473
+ });
474
+ }
475
+ }
476
+ }
477
+ async function recoverOneGitPendingTask(bus, manager, task) {
478
+ {
479
+ if (task.status !== 'approved' || task.pendingRedispatch !== true)
480
+ return;
481
+ if (task.postApproveRevoked)
482
+ return;
483
+ if (task.postApprovePhase === 'delivered') {
484
+ const devState = await manager.getAgentState(task.agentId);
485
+ if (devState?.taskId === task.id) {
486
+ // prompt 已送达且尚未回信号(dev 在跑):补派会向运行中的会话注入第二个 prompt 并轮换
487
+ // token——只重臂 watcher 等信号,pending 由 merge-ready 循环消化
488
+ await manager.rearmPostApproveSignal(task.id).catch(() => false);
489
+ return;
490
+ }
491
+ }
492
+ const approvedHead = validHeadSha(task.postApproveHeadSha ?? task.latestHeadSha);
493
+ if (!approvedHead) {
494
+ await emitIntervention(bus, task.projectId, task.agentId, task.id, {
495
+ phase: 'post-approve-approved-head-unavailable',
496
+ });
497
+ return;
498
+ }
499
+ const nextCount = (task.redispatchCount ?? 0) + 1;
500
+ if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
501
+ await manager.revokePostApproveCompletion(task.id, 'redispatch-cap');
502
+ await emitIntervention(bus, task.projectId, task.agentId, task.id, {
503
+ phase: 'post-approve-redispatch-cap-exceeded',
504
+ redispatchCount: task.redispatchCount ?? 0,
505
+ cap: POST_APPROVE_REDISPATCH_CAP,
506
+ });
507
+ return;
508
+ }
509
+ await dispatchDevPostApproveCheck(bus, manager, task, approvedHead, { redispatchCount: nextCount });
256
510
  }
257
- const ready = await gateDevForPostApproveRedispatch(bus, manager, taskNow);
258
- if (!ready)
259
- return;
260
- await dispatchDevPostApproveCheck(bus, manager, { ...taskNow, ...taskPatch }, completion.approvedHeadSha, { redispatchCount: nextCount });
261
511
  }
262
512
  async function releaseQaAfterSkippedRollback(manager, taskId, qaId, logPrefix) {
263
513
  const now = await manager.getTask(taskId);
@@ -291,6 +541,16 @@ async function handlePrCodePush(bus, manager, event) {
291
541
  const taskBeforeTransition = await manager.getTask(taskId);
292
542
  if (!taskBeforeTransition)
293
543
  return;
544
+ // 'git' push 重投收敛(spec §6 生命周期幂等):同 head 的评审轮已派发过,重放在任何后继态
545
+ // 都是有害动作(QA 释放/令牌轮换/计轮/撤销 approval),恰一次由 TaskState 锚判定。
546
+ // pr-fixed 合成 push 豁免——no-code fix 的 recheck 派发正是靠同 head 重入(spec §7 同 head 复检)。
547
+ if (taskBeforeTransition.reviewMode === 'git' && eventKind === 'push' && eventHeadSha
548
+ && event.data.source !== 'pr-fixed'
549
+ && taskBeforeTransition.reviewDispatchPending !== true
550
+ && taskBeforeTransition.latestHeadSha === eventHeadSha
551
+ && taskBeforeTransition.reviewHeadAnchorSha === eventHeadSha) {
552
+ return;
553
+ }
294
554
  const willHavePrNumber = taskBeforeTransition.prNumber !== undefined || eventPrNumber !== undefined;
295
555
  if (taskBeforeTransition.status === 'in_progress' && !willHavePrNumber) {
296
556
  console.warn(`[EventHandler] pr.updated: task ${taskId} in_progress but neither task nor event has prNumber; ` +
@@ -303,6 +563,10 @@ async function handlePrCodePush(bus, manager, event) {
303
563
  ...(anchorAtDispatch ? { reviewHeadAnchorSha: anchorAtDispatch } : {}),
304
564
  reviewDispatchedAt: new Date().toISOString(),
305
565
  signalToken: createSignalToken(),
566
+ // pair 与新轮可见性原子(spec §7):transition 后到 rotate 的窗口内旧 pair 不得授权本轮
567
+ ...(taskBeforeTransition.reviewMode === 'git'
568
+ ? { ...manager.mintReviewTokenPair(), reviewDispatchPending: true }
569
+ : {}),
306
570
  });
307
571
  if (!result)
308
572
  return;
@@ -375,12 +639,16 @@ async function handlePrCodePush(bus, manager, event) {
375
639
  const { token: dispatchToken, armed } = await manager.rotateAndSetupPhaseSignal(transitioned.id, qa.id, ['pr-approved', 'pr-changes-requested']);
376
640
  if (!armed) {
377
641
  console.warn(`[EventHandler] pr.updated verdict watcher failed to arm for task=${transitioned.id} (${qaPhase}); rolling back recheck dispatch`);
378
- if (previousStatus === 'in_progress' || previousStatus === 'fixing') {
642
+ if (previousStatus === 'in_progress' || previousStatus === 'fixing'
643
+ || (previousStatus === 'review' && taskBeforeTransition.reviewMode === 'git')) {
379
644
  const rolledBack = await manager.rollbackVerdictArmFailure(transitioned.id, {
380
645
  status: previousStatus,
381
646
  signalToken: taskBeforeTransition.signalToken,
382
647
  reviewHeadAnchorSha: taskBeforeTransition.reviewHeadAnchorSha,
383
648
  reviewDispatchedAt: taskBeforeTransition.reviewDispatchedAt,
649
+ ...(taskBeforeTransition.reviewMode === 'git'
650
+ ? { restorePair: true, passToken: taskBeforeTransition.passToken, failToken: taskBeforeTransition.failToken }
651
+ : {}),
384
652
  }, { expect: { status: 'review', signalToken: dispatchToken }, rearmSkipSnapshot });
385
653
  if (!rolledBack) {
386
654
  await releaseQaAfterSkippedRollback(manager, transitioned.id, qa.id, '[EventHandler] pr.updated arm-failure');
@@ -413,8 +681,25 @@ async function handlePrCodePush(bus, manager, event) {
413
681
  await manager.failTaskForDispatchError(transitioned.id, qaPhase, qa.id, dispatchErr);
414
682
  }
415
683
  else if (dispatchErr instanceof EnsureSessionError && dispatchErr.partial.handled) {
684
+ // handled 不止代表可恢复派发 hold(checkout-cleanup-failed / dialog 等也走这里),指引须按实际相位分流
685
+ const qaState = await manager.getAgentState(qa.id).catch((stateErr) => {
686
+ console.warn(`[EventHandler] pr.updated could not read held QA state for ${qa.id}:`, stateErr);
687
+ return null;
688
+ });
689
+ const heldPhase = qaState?.status === 'awaiting_human' ? qaState.awaitingPhase : undefined;
690
+ const note = isRecoverableQaDispatchHold(qaState)
691
+ ? 'QA dispatch was held awaiting human; the task keeps the rotated review pass. Resume the QA agent or POST /tasks/:id/review to redispatch.'
692
+ : `QA dispatch was held awaiting human (${heldPhase ?? 'hold not persisted'}); this hold is not auto-redispatchable — inspect the QA agent's awaiting reason, resolve it, then redispatch via POST /tasks/:id/review.`;
693
+ await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
694
+ phase: 'qa-dispatch-held-awaiting-human',
695
+ qaAgentId: qa.id,
696
+ qaPhase,
697
+ ...(heldPhase !== undefined ? { awaitingPhase: heldPhase } : {}),
698
+ note,
699
+ });
416
700
  }
417
- else if (previousStatus === 'in_progress' || previousStatus === 'fixing') {
701
+ else if (previousStatus === 'in_progress' || previousStatus === 'fixing'
702
+ || (previousStatus === 'review' && taskBeforeTransition.reviewMode === 'git')) {
418
703
  // 仅回滚 status 会留下 transition 轮换后的 token,dev 在途 pass 的完成信号将永久失配;
419
704
  // startSession 窗口内 pass 可能已被并发接管,此时回滚与 QA 释放都要让位于接管方
420
705
  const rolledBack = await manager.rollbackVerdictArmFailure(transitioned.id, {
@@ -422,6 +707,9 @@ async function handlePrCodePush(bus, manager, event) {
422
707
  signalToken: taskBeforeTransition.signalToken,
423
708
  reviewHeadAnchorSha: taskBeforeTransition.reviewHeadAnchorSha,
424
709
  reviewDispatchedAt: taskBeforeTransition.reviewDispatchedAt,
710
+ ...(taskBeforeTransition.reviewMode === 'git'
711
+ ? { restorePair: true, passToken: taskBeforeTransition.passToken, failToken: taskBeforeTransition.failToken }
712
+ : {}),
425
713
  }, { expect: { status: 'review', signalToken: dispatchToken }, rearmSkipSnapshot });
426
714
  if (rolledBack) {
427
715
  await manager.releaseAgentForTask(qa.id, transitioned.id, 'idle')
@@ -455,6 +743,9 @@ async function handlePrCodePush(bus, manager, event) {
455
743
  }
456
744
  return;
457
745
  }
746
+ if (taskBeforeTransition.reviewMode === 'git') {
747
+ await manager.clearReviewDispatchPending(transitioned.id, dispatchToken);
748
+ }
458
749
  if (previousStatus === 'in_progress' || previousStatus === 'approved' || previousStatus === 'merge-ready') {
459
750
  await manager.bumpReviewRoundIfStillAt(transitioned.id, expectedRound);
460
751
  }
@@ -469,9 +760,18 @@ async function redispatchReviewForStaleVerdict(manager, task) {
469
760
  if (task.status !== 'review')
470
761
  return false;
471
762
  if (task.qaAgentId) {
472
- // 当前 pass 的 QA 会话还在跑时,迟到的旧 verdict 只是噪音,不能重派打断
473
- const qaState = await manager.getAgentState(task.qaAgentId);
474
- if (qaState?.taskId === task.id)
763
+ // 当前 pass 的 QA 会话还在跑时,迟到的旧 verdict 只是噪音,不能重派打断;
764
+ // 派发期 hold(checkout 未备好)意味着从未开工,等同无会话,可安全重派
765
+ let qaState;
766
+ try {
767
+ qaState = await manager.getAgentState(task.qaAgentId);
768
+ }
769
+ catch (err) {
770
+ // 无状态证据时 fail closed:不自动重派,让调用方落 stale-verdict 兜底介入
771
+ console.warn(`[EventHandler] stale-verdict QA state read failed for ${task.qaAgentId}; not redispatching:`, err);
772
+ return false;
773
+ }
774
+ if (qaState?.taskId === task.id && !isRecoverableQaDispatchHold(qaState))
475
775
  return false;
476
776
  }
477
777
  try {
@@ -490,16 +790,20 @@ async function redispatchReviewForStaleVerdict(manager, task) {
490
790
  console.warn(`[EventHandler] stale verdict for task ${task.id} arrived with no active QA session; redispatched review`);
491
791
  return true;
492
792
  }
493
- async function handleReviewApproval(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch) {
793
+ async function handleReviewApproval(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch, gitVerdict) {
494
794
  if (!reviewedHeadSha) {
495
795
  await emitIntervention(bus, task.projectId, task.agentId, task.id, {
496
796
  phase: 'approval-reviewed-head-unavailable',
497
797
  });
498
798
  return;
499
799
  }
500
- const anchor = await resolveAuthoritativeHead(manager, task, {
501
- payloadCurrentHeadSha: currentHeadSha,
502
- });
800
+ // 'git' 载荷已经 engine 端 fresh prView 复核(spec §7 handler 收紧):二次解析的缓存回退
801
+ // 链会重新打开 engine 已关闭的竞态,跳过
802
+ const anchor = task.reviewMode === 'git'
803
+ ? { headSha: currentHeadSha ?? reviewedHeadSha, source: 'payload-self' }
804
+ : await resolveAuthoritativeHead(manager, task, { payloadCurrentHeadSha: currentHeadSha });
805
+ // git 的 head 更新并入下方带 expectSignalToken 的 transition:入口校验后并发 push 换代时,
806
+ // 旧 verdict 的裸 updateTask 会把 successor 的 latestHeadSha 覆写回旧值
503
807
  if (anchor.source === 'fetch' && anchor.headSha && task.latestHeadSha !== anchor.headSha) {
504
808
  await manager.updateTask(task.id, { latestHeadSha: anchor.headSha });
505
809
  }
@@ -509,11 +813,29 @@ async function handleReviewApproval(bus, manager, task, reviewedHeadSha, current
509
813
  reviewedHeadSha,
510
814
  currentHeadSha: anchor.headSha,
511
815
  source: anchor.source,
512
- ...(anchor.fetchError ? { fetchError: anchor.fetchError } : {}),
816
+ ...('fetchError' in anchor && anchor.fetchError ? { fetchError: anchor.fetchError } : {}),
513
817
  });
514
818
  return;
515
819
  }
516
- const result = await manager.transitionTaskStatus(task.id, 'approved', { fromStatus: ['review'] }, task.reviewRound === 0 ? { ...prPatch, reviewRound: 1 } : prPatch);
820
+ const provenanceFields = task.reviewMode === 'git' && gitVerdict !== undefined && task.failToken !== undefined
821
+ ? {
822
+ passProvenance: {
823
+ sourceKey: gitVerdict.carrier.sourceKey,
824
+ id: gitVerdict.carrier.id,
825
+ bodyDigest: gitVerdict.carrier.bodyDigest,
826
+ token: gitVerdict.token,
827
+ failToken: task.failToken,
828
+ anchorSha: reviewedHeadSha,
829
+ },
830
+ }
831
+ : {};
832
+ const result = await manager.transitionTaskStatus(task.id, 'approved', {
833
+ fromStatus: ['review'],
834
+ // 校验后提交前的并发换代(push/重派轮换 token+pair)使旧 APPROVE 失效(spec §7)
835
+ ...(task.reviewMode === 'git' ? { expectSignalToken: task.signalToken } : {}),
836
+ }, task.reviewRound === 0
837
+ ? { ...prPatch, ...provenanceFields, ...gitHeadPatch(task, anchor.headSha), reviewRound: 1 }
838
+ : { ...prPatch, ...provenanceFields, ...gitHeadPatch(task, anchor.headSha) });
517
839
  if (!result)
518
840
  return;
519
841
  const { task: transitioned } = result;
@@ -524,14 +846,21 @@ async function handleReviewApproval(bus, manager, task, reviewedHeadSha, current
524
846
  }
525
847
  await dispatchDevPostApproveCheck(bus, manager, transitioned, reviewedHeadSha);
526
848
  }
849
+ function gitHeadPatch(task, headSha) {
850
+ return task.reviewMode === 'git' && headSha !== undefined && task.latestHeadSha !== headSha
851
+ ? { latestHeadSha: headSha }
852
+ : {};
853
+ }
527
854
  async function handleReviewRequestChanges(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch) {
528
855
  const approvedCompletion = task.status === 'approved'
529
856
  ? await manager.getPostApproveCompletion(task.id)
530
857
  : null;
531
- const anchor = await resolveAuthoritativeHead(manager, task, {
532
- payloadCurrentHeadSha: currentHeadSha,
533
- legacyFallback: approvedCompletion?.approvedHeadSha,
534
- });
858
+ const anchor = task.reviewMode === 'git'
859
+ ? { headSha: currentHeadSha ?? reviewedHeadSha, source: 'payload-self' }
860
+ : await resolveAuthoritativeHead(manager, task, {
861
+ payloadCurrentHeadSha: currentHeadSha,
862
+ legacyFallback: approvedCompletion?.approvedHeadSha,
863
+ });
535
864
  if (anchor.source === 'fetch' && anchor.headSha && task.latestHeadSha !== anchor.headSha) {
536
865
  await manager.updateTask(task.id, { latestHeadSha: anchor.headSha });
537
866
  }
@@ -541,7 +870,7 @@ async function handleReviewRequestChanges(bus, manager, task, reviewedHeadSha, c
541
870
  reviewedHeadSha,
542
871
  currentHeadSha: anchor.headSha,
543
872
  source: anchor.source,
544
- ...(anchor.fetchError ? { fetchError: anchor.fetchError } : {}),
873
+ ...('fetchError' in anchor && anchor.fetchError ? { fetchError: anchor.fetchError } : {}),
545
874
  });
546
875
  return;
547
876
  }
@@ -551,11 +880,21 @@ async function handleReviewRequestChanges(bus, manager, task, reviewedHeadSha, c
551
880
  const devState = await manager.getAgentState(task.agentId);
552
881
  const postApproveActive = await manager.getPostApproveCompletion(task.id);
553
882
  if (devState?.taskId === task.id && postApproveActive) {
554
- await manager.clearPostApproveCompletion(task.id);
883
+ let revoked = await manager.revokePostApproveCompletion(task.id, 'request-changes', {
884
+ expectedToken: postApproveActive.token,
885
+ });
886
+ if (!revoked) {
887
+ // Token rotation within the episode still gets blocked; a successor approved episode (fresh head) must not.
888
+ revoked = await manager.revokePostApproveCompletion(task.id, 'request-changes', {
889
+ expectedHeadSha: postApproveActive.approvedHeadSha,
890
+ });
891
+ }
555
892
  await emitIntervention(bus, task.projectId, task.agentId, task.id, {
556
893
  phase: 'request-changes-during-post-approve',
557
894
  devAgentId: task.agentId,
558
- 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.',
895
+ note: revoked
896
+ ? '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.'
897
+ : 'Dev is still running post-approve check and the task moved past the reviewed approved episode before the block could apply; the verdict belongs to a superseded pass. Re-trigger REQUEST_CHANGES if it still applies, or cancel the task.',
559
898
  });
560
899
  return;
561
900
  }
@@ -576,7 +915,11 @@ async function handleReviewRequestChanges(bus, manager, task, reviewedHeadSha, c
576
915
  if (nextRound > manager.getConfig().review.rounds) {
577
916
  return handleMaxRounds(bus, manager, task, prPatch, reviewedRound);
578
917
  }
579
- const result = await manager.transitionTaskStatus(task.id, 'fixing', { fromStatus: ['review', 'approved', 'merge-ready'] }, { ...prPatch, reviewRound: nextRound, fixDispatchedAt: new Date().toISOString() });
918
+ const result = await manager.transitionTaskStatus(task.id, 'fixing', {
919
+ fromStatus: ['review', 'approved', 'merge-ready'],
920
+ // fail 方向同样按入口代际 CAS(spec §7):await 窗口内 push/重派换代后旧 RC 不得推进新轮
921
+ ...(task.reviewMode === 'git' ? { expectSignalToken: task.signalToken } : {}),
922
+ }, { ...prPatch, ...gitHeadPatch(task, anchor.headSha), reviewRound: nextRound, fixDispatchedAt: new Date().toISOString() });
580
923
  if (!result)
581
924
  return;
582
925
  const { task: transitioned, previousStatus } = result;
@@ -639,7 +982,10 @@ async function handleReviewRequestChanges(bus, manager, task, reviewedHeadSha, c
639
982
  }
640
983
  }
641
984
  async function handleMaxRounds(bus, manager, task, prPatch, reviewedRound) {
642
- const result = await manager.transitionTaskStatus(task.id, 'max_rounds', { fromStatus: ['review', 'approved', 'merge-ready'] }, { ...prPatch, reviewRound: reviewedRound });
985
+ const result = await manager.transitionTaskStatus(task.id, 'max_rounds', {
986
+ fromStatus: ['review', 'approved', 'merge-ready'],
987
+ ...(task.reviewMode === 'git' ? { expectSignalToken: task.signalToken } : {}),
988
+ }, { ...prPatch, reviewRound: reviewedRound });
643
989
  if (!result)
644
990
  return;
645
991
  const { task: transitioned } = result;
@@ -682,9 +1028,106 @@ export function registerEventHandlers(bus, manager) {
682
1028
  console.warn(`[EventHandler] pr.created ignored for task ${event.taskId}: task in spec phase`);
683
1029
  return;
684
1030
  }
1031
+ const isGit = taskAtEntry?.reviewMode === 'git';
1032
+ // 迟到 publish 信号只做 actor reconciliation(spec §5.3 ④):poller 已抢先收编、任务不在
1033
+ // 可收编态时,同 prNumber + reconciliation token 归位 replyActor,随后拆 dev 侧条目、不动 QA。
1034
+ // fixing 也归 reconciliation:同 prNumber 的迟到信号在 fix 轮重收编会把任务拽回 review、
1035
+ // 孤儿化 dev 的 fix 会话——非 in_progress 的同号信号只承载 actor 归位(spec §5.3 ④)
1036
+ if (isGit && taskAtEntry && event.data.source === 'pane-signal'
1037
+ && taskAtEntry.prNumber !== undefined && taskAtEntry.prNumber === event.data.prNumber
1038
+ && taskAtEntry.status !== 'in_progress') {
1039
+ if (taskAtEntry.pendingPrSignalToken === undefined
1040
+ || event.data.token !== taskAtEntry.pendingPrSignalToken) {
1041
+ return;
1042
+ }
1043
+ if (taskAtEntry.replyActorStatus !== 'verified') {
1044
+ const actorId = typeof event.data.actorB64 === 'string'
1045
+ ? decodeSignalActorId(event.data.actorB64)
1046
+ : undefined;
1047
+ if (actorId === undefined) {
1048
+ // watcher 单发已被本事件消费:不重臂就永失 reconciliation 入口。skipSnapshot=true
1049
+ // 只等待后续正确的五段信号,避免滚动区里同一条三段残文立即重触发成环
1050
+ await manager.rearmGitReconciliationWatcher(taskAtEntry.id, { skipSnapshot: true });
1051
+ await emitIntervention(bus, taskAtEntry.projectId, event.agentId, taskAtEntry.id, {
1052
+ phase: 'git-pr-created-actor-missing',
1053
+ claimedPrNumber: event.data.prNumber,
1054
+ });
1055
+ return;
1056
+ }
1057
+ await manager.updateTask(taskAtEntry.id, {
1058
+ replyActorId: actorId,
1059
+ replyActorStatus: 'verified',
1060
+ pendingPrSignalToken: undefined,
1061
+ });
1062
+ }
1063
+ // fixing 态同键 watcher 已是 pr-fixed:拆掉会吞掉本轮完成信号,reconciliation 条目只存在于 review
1064
+ if (taskAtEntry.status === 'review') {
1065
+ manager.stopPhaseSignalWatcherAgent(taskAtEntry.id, event.agentId);
1066
+ }
1067
+ return;
1068
+ }
1069
+ if (isGit && taskAtEntry && event.data.source !== 'pane-signal'
1070
+ && taskAtEntry.prNumber !== undefined && taskAtEntry.prNumber === event.data.prNumber
1071
+ && taskAtEntry.reviewDispatchPending !== true
1072
+ && !['in_progress', 'fixing'].includes(taskAtEntry.status)) {
1073
+ // in_progress/fixing 或派发未完成(pending)的同号重放走完整收编重试(至少一次)
1074
+ return;
1075
+ }
685
1076
  let paneVerifiedHeadSha;
686
1077
  let reconciledBranch;
687
- if (event.data.source === 'pane-signal' && event.data.prNumber !== undefined) {
1078
+ let gitVerifiedTargetBranch;
1079
+ if (isGit && event.data.source === 'pane-signal' && event.data.prNumber !== undefined) {
1080
+ const prNumber = event.data.prNumber;
1081
+ try {
1082
+ let verify = await manager.platformVerifyPrBinding(event.taskId, prNumber);
1083
+ if (!verify.ok && verify.reason === 'branch' && verify.prBranch !== undefined) {
1084
+ const taskNow = await manager.getTask(event.taskId);
1085
+ const prBranch = verify.prBranch;
1086
+ if (taskNow) {
1087
+ const ownPrefix = BRANCH_PREFIX + event.taskId;
1088
+ const isForeignBxBranch = prBranch.startsWith(BRANCH_PREFIX) && prBranch !== ownPrefix;
1089
+ const bound = await manager.findTaskByBranch(prBranch, taskNow.projectId);
1090
+ if (!isForeignBxBranch && isValidBranchName(prBranch)
1091
+ && (prBranch === ownPrefix || (!!bound && bound.id === taskNow.id))) {
1092
+ verify = await manager.platformVerifyPrBinding(event.taskId, prNumber, { branchOverride: prBranch });
1093
+ if (verify.ok)
1094
+ reconciledBranch = prBranch;
1095
+ }
1096
+ }
1097
+ }
1098
+ if (!verify.ok) {
1099
+ const taskNow = await manager.getTask(event.taskId);
1100
+ console.warn(`[EventHandler] pr.created REJECT pane prNumber=${prNumber} for git task ${event.taskId} (${verify.reason})`);
1101
+ if (taskNow) {
1102
+ await reArmDevelopWatcher(manager, taskNow, event.agentId);
1103
+ await emitIntervention(bus, taskNow.projectId, event.agentId, event.taskId, {
1104
+ phase: 'pane-pr-created-branch-mismatch',
1105
+ claimedPrNumber: prNumber,
1106
+ taskBranch: taskNow.branch ?? '',
1107
+ reason: verify.reason,
1108
+ });
1109
+ }
1110
+ return;
1111
+ }
1112
+ paneVerifiedHeadSha = verify.headSha;
1113
+ gitVerifiedTargetBranch = verify.targetBranch;
1114
+ }
1115
+ catch (err) {
1116
+ console.warn(`[EventHandler] pr.created: platformVerifyPrBinding failed for task ${event.taskId}:`, err);
1117
+ const taskNow = await manager.getTask(event.taskId);
1118
+ if (taskNow) {
1119
+ // skipSnapshot 缺省 true:滚动区里刚消费的信号残文在平台持续失败时会形成无退避热循环
1120
+ await reArmDevelopWatcher(manager, taskNow, event.agentId);
1121
+ await emitIntervention(bus, taskNow.projectId, event.agentId, event.taskId, {
1122
+ phase: 'pane-pr-created-verify-error',
1123
+ claimedPrNumber: prNumber,
1124
+ error: err instanceof Error ? err.message : String(err),
1125
+ });
1126
+ }
1127
+ return;
1128
+ }
1129
+ }
1130
+ else if (event.data.source === 'pane-signal' && event.data.prNumber !== undefined) {
688
1131
  try {
689
1132
  const prNumber = event.data.prNumber;
690
1133
  let verified = await manager.verifyPaneSignalPrNumber(event.taskId, prNumber);
@@ -754,13 +1197,41 @@ export function registerEventHandlers(bus, manager) {
754
1197
  `for task ${event.taskId} (untracked; custom-branch PRs adopt via the pane signal)`);
755
1198
  return;
756
1199
  }
1200
+ const gitAdoptionFields = {};
1201
+ if (isGit) {
1202
+ const target = event.data.source === 'pane-signal'
1203
+ ? gitVerifiedTargetBranch
1204
+ : (typeof event.data.targetBranch === 'string' ? event.data.targetBranch : undefined);
1205
+ // baseBranch 快照一经写入不可变(spec §6):默认分支后续改名不影响既有任务
1206
+ if (target !== undefined && taskBeforeTransition?.baseBranch === undefined) {
1207
+ gitAdoptionFields.baseBranch = target;
1208
+ }
1209
+ if (event.data.source === 'pane-signal') {
1210
+ const actorId = typeof event.data.actorB64 === 'string'
1211
+ ? decodeSignalActorId(event.data.actorB64)
1212
+ : undefined;
1213
+ if (actorId !== undefined) {
1214
+ gitAdoptionFields.replyActorId = actorId;
1215
+ gitAdoptionFields.replyActorStatus = 'verified';
1216
+ gitAdoptionFields.pendingPrSignalToken = undefined;
1217
+ }
1218
+ }
1219
+ else if (typeof event.data.prAuthorId === 'string'
1220
+ && taskBeforeTransition?.replyActorStatus !== 'verified') {
1221
+ // 自动收编只落 provisional:PR 作者可能是对已推送 head 抢建 PR 的第三方(spec §5.3 ④)
1222
+ gitAdoptionFields.replyActorId = event.data.prAuthorId;
1223
+ gitAdoptionFields.replyActorStatus = 'provisional';
1224
+ }
1225
+ }
757
1226
  const result = await manager.transitionTaskStatus(event.taskId, 'review', { fromStatus: ['in_progress', 'fixing'] }, {
758
1227
  ...(event.data.prNumber !== undefined ? { prNumber: event.data.prNumber } : {}),
759
1228
  ...(event.data.prUrl !== undefined ? { prUrl: event.data.prUrl } : {}),
760
1229
  ...(createdHeadSha ? { latestHeadSha: createdHeadSha, reviewHeadAnchorSha: createdHeadSha } : {}),
761
1230
  ...(reconciledBranch ? { branch: reconciledBranch } : {}),
1231
+ ...gitAdoptionFields,
762
1232
  reviewDispatchedAt: new Date().toISOString(),
763
1233
  signalToken: createSignalToken(),
1234
+ ...(isGit ? { ...manager.mintReviewTokenPair(), reviewDispatchPending: true } : {}),
764
1235
  });
765
1236
  if (!result) {
766
1237
  console.warn(`[EventHandler] pr.created: cannot transition task ${event.taskId} (terminal or invalid from-state)`);
@@ -818,6 +1289,9 @@ export function registerEventHandlers(bus, manager) {
818
1289
  signalToken: taskBeforeTransition?.signalToken,
819
1290
  reviewHeadAnchorSha: taskBeforeTransition?.reviewHeadAnchorSha,
820
1291
  reviewDispatchedAt: taskBeforeTransition?.reviewDispatchedAt,
1292
+ ...(taskBeforeTransition?.reviewMode === 'git'
1293
+ ? { restorePair: true, passToken: taskBeforeTransition.passToken, failToken: taskBeforeTransition.failToken }
1294
+ : {}),
821
1295
  }, { expect: { status: 'review', signalToken: dispatchToken } });
822
1296
  if (!rolledBack) {
823
1297
  await releaseQaAfterSkippedRollback(manager, transitioned.id, qa.id, '[EventHandler] pr.created arm-failure');
@@ -886,6 +1360,19 @@ export function registerEventHandlers(bus, manager) {
886
1360
  }
887
1361
  if (eventKind === 'pr-merge-ready')
888
1362
  return handlePrMergeReady(bus, manager, event);
1363
+ // closed-unmerged/reopened 只由 PlatformPoller 对 'git' 任务合成(spec §6 outbox 协议)
1364
+ if (eventKind === 'closed-unmerged' || eventKind === 'reopened') {
1365
+ const prNumber = event.data.prNumber;
1366
+ if (typeof prNumber !== 'number')
1367
+ return;
1368
+ if (eventKind === 'closed-unmerged')
1369
+ await manager.recordClosedUnmergedAnchor(event.taskId, prNumber);
1370
+ else
1371
+ await manager.clearClosedUnmergedAnchor(event.taskId, prNumber);
1372
+ // 无条件投递:滞留的 outbox 条目借重复观察重试,而不是等到重启(spec §6 至少一次)
1373
+ await manager.deliverTaskOutbox(event.taskId);
1374
+ return;
1375
+ }
889
1376
  if (eventKind !== 'push' && eventKind !== undefined)
890
1377
  return handlePrFeedback(bus, manager, event);
891
1378
  return handlePrCodePush(bus, manager, event);
@@ -948,6 +1435,11 @@ export function registerEventHandlers(bus, manager) {
948
1435
  return;
949
1436
  if (task.reviewMode === 'server')
950
1437
  return;
1438
+ // 'git' 唯一裁决授权是评论配对令牌(spec §7):pane verdict 恒拒绝,命中只记日志
1439
+ if (task.reviewMode === 'git' && event.data.source === 'pane-signal') {
1440
+ console.log(`[EventHandler] review.submitted pane verdict ignored for git task ${task.id} (comment-token protocol is the sole authority)`);
1441
+ return;
1442
+ }
951
1443
  {
952
1444
  const terminalQaId = task.qaAgentId;
953
1445
  if (terminalQaId && TASK_TERMINAL_STATUS_SET.has(task.status)) {
@@ -1031,8 +1523,30 @@ export function registerEventHandlers(bus, manager) {
1031
1523
  return;
1032
1524
  }
1033
1525
  }
1526
+ const carrier = event.data.verdictCarrier;
1527
+ const gitVerdict = task.reviewMode === 'git'
1528
+ && typeof event.data.verdictToken === 'string' && carrier !== undefined
1529
+ ? { token: event.data.verdictToken, carrier }
1530
+ : undefined;
1531
+ // 'git' 的裁决只信任 engine 载荷:reviewPassToken(评审轮绑定)与 verdict 令牌(评论对绑定)
1532
+ // 都必须在场且属当前轮——缺失即绕过 signalToken 门的 malformed 事件,fail closed
1533
+ if (task.reviewMode === 'git' && (action === 'APPROVE' || action === 'REQUEST_CHANGES')) {
1534
+ const expectedToken = action === 'APPROVE' ? task.passToken : task.failToken;
1535
+ const roundToken = typeof event.data.reviewPassToken === 'string' ? event.data.reviewPassToken : undefined;
1536
+ if (gitVerdict === undefined || expectedToken === undefined || task.failToken === undefined
1537
+ || gitVerdict.token !== expectedToken
1538
+ || roundToken === undefined || task.signalToken === undefined || roundToken !== task.signalToken) {
1539
+ console.warn(`[EventHandler] review.submitted ${action} rejected for git task ${task.id}: verdict payload missing or not the current round token`);
1540
+ await emitIntervention(bus, task.projectId, task.agentId, task.id, {
1541
+ phase: 'git-verdict-payload-invalid',
1542
+ action,
1543
+ hasCarrier: gitVerdict !== undefined,
1544
+ });
1545
+ return;
1546
+ }
1547
+ }
1034
1548
  if (action === 'APPROVE') {
1035
- return handleReviewApproval(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch);
1549
+ return handleReviewApproval(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch, gitVerdict);
1036
1550
  }
1037
1551
  if (action === 'REQUEST_CHANGES') {
1038
1552
  return handleReviewRequestChanges(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch);
@@ -1047,7 +1561,15 @@ export function registerEventHandlers(bus, manager) {
1047
1561
  const { projectId, agentId } = task;
1048
1562
  const stayFixing = async (data) => {
1049
1563
  await emitIntervention(bus, projectId, agentId, task.id, data);
1050
- await manager.setupPhaseSignal(task.id, agentId, 'pr-fixed', { skipSnapshot: true });
1564
+ // 同代 fence:平台查询等待期间任务可能已被手动重派进 review——旧失败分支不得覆盖
1565
+ // successor 的 QA/reconciliation watcher
1566
+ const fresh = await manager.getTask(task.id);
1567
+ if (!fresh || fresh.status !== 'fixing' || fresh.signalToken !== task.signalToken)
1568
+ return;
1569
+ await manager.setupPhaseSignal(task.id, agentId, 'pr-fixed', {
1570
+ skipSnapshot: true,
1571
+ ...(fresh.reviewMode === 'git' ? { replaceScope: 'agent' } : {}),
1572
+ });
1051
1573
  };
1052
1574
  const token = typeof event.data.token === 'string' ? event.data.token : undefined;
1053
1575
  if (!token || !task.signalToken || token !== task.signalToken) {
@@ -1059,41 +1581,91 @@ export function registerEventHandlers(bus, manager) {
1059
1581
  return;
1060
1582
  }
1061
1583
  let headSha;
1062
- try {
1063
- headSha = await fetchVerifiedHeadSha(manager, task.id);
1064
- }
1065
- catch (err) {
1066
- await stayFixing({
1067
- phase: 'fix-verify-head-fetch-failed',
1068
- error: err instanceof Error ? err.message : String(err),
1069
- });
1070
- return;
1071
- }
1072
- if (!task.reviewHeadAnchorSha) {
1073
- await stayFixing({ phase: 'fix-verify-no-anchor', headSha });
1074
- return;
1075
- }
1076
- if (headSha === task.reviewHeadAnchorSha) {
1077
- const since = task.fixDispatchedAt ?? task.reviewDispatchedAt;
1078
- let hasReplies;
1584
+ if (task.reviewMode === 'git') {
1585
+ if (task.prNumber === undefined) {
1586
+ await stayFixing({ phase: 'fix-verify-no-anchor' });
1587
+ return;
1588
+ }
1589
+ let verify;
1079
1590
  try {
1080
- hasReplies = since ? await manager.prHasDevReplySince(task.id, since) : false;
1591
+ verify = await manager.platformVerifyPrBinding(task.id, task.prNumber);
1081
1592
  }
1082
1593
  catch (err) {
1083
1594
  await stayFixing({
1084
- phase: 'fix-verify-replies-fetch-failed',
1595
+ phase: 'fix-verify-head-fetch-failed',
1085
1596
  error: err instanceof Error ? err.message : String(err),
1086
1597
  });
1087
1598
  return;
1088
1599
  }
1089
- if (!hasReplies) {
1600
+ if (!verify.ok) {
1601
+ // PR 在修复期间被关闭/转 draft/retarget:推进 recheck 只会造出无裁决入口的评审轮
1602
+ await stayFixing({ phase: 'fix-verify-binding-mismatch', reason: verify.reason });
1603
+ return;
1604
+ }
1605
+ headSha = verify.headSha;
1606
+ }
1607
+ else {
1608
+ try {
1609
+ headSha = await fetchVerifiedHeadSha(manager, task.id);
1610
+ }
1611
+ catch (err) {
1090
1612
  await stayFixing({
1091
- phase: 'fix-no-op-no-commit-no-reply',
1092
- note: 'Dev emitted pr-fixed but pushed no commit and left no reply on the PR — the fixing round changed nothing. Inspect the pane.',
1613
+ phase: 'fix-verify-head-fetch-failed',
1614
+ error: err instanceof Error ? err.message : String(err),
1093
1615
  });
1094
1616
  return;
1095
1617
  }
1096
1618
  }
1619
+ if (!task.reviewHeadAnchorSha) {
1620
+ await stayFixing({ phase: 'fix-verify-no-anchor', headSha });
1621
+ return;
1622
+ }
1623
+ if (headSha === task.reviewHeadAnchorSha) {
1624
+ if (task.reviewMode === 'git') {
1625
+ // no-op fix 通过条件 = 无未 ack 的待处理反馈 revision 且全源拉取成功(spec §6/§8);
1626
+ // 墙钟比较与「任意评论计回复」在 'git' 均废除
1627
+ let pendingCount;
1628
+ try {
1629
+ pendingCount = (await manager.platformPendingFeedback(task.id)).pending.size;
1630
+ }
1631
+ catch (err) {
1632
+ await stayFixing({
1633
+ phase: 'fix-verify-replies-fetch-failed',
1634
+ error: err instanceof Error ? err.message : String(err),
1635
+ });
1636
+ return;
1637
+ }
1638
+ if (pendingCount > 0) {
1639
+ await stayFixing({
1640
+ phase: 'fix-no-op-pending-feedback',
1641
+ pendingCount,
1642
+ note: 'Dev emitted pr-fixed on an unchanged head while feedback revisions still lack a valid ack. Inspect the PR replies.',
1643
+ });
1644
+ return;
1645
+ }
1646
+ }
1647
+ else {
1648
+ const since = task.fixDispatchedAt ?? task.reviewDispatchedAt;
1649
+ let hasReplies;
1650
+ try {
1651
+ hasReplies = since ? await manager.prHasDevReplySince(task.id, since) : false;
1652
+ }
1653
+ catch (err) {
1654
+ await stayFixing({
1655
+ phase: 'fix-verify-replies-fetch-failed',
1656
+ error: err instanceof Error ? err.message : String(err),
1657
+ });
1658
+ return;
1659
+ }
1660
+ if (!hasReplies) {
1661
+ await stayFixing({
1662
+ phase: 'fix-no-op-no-commit-no-reply',
1663
+ note: 'Dev emitted pr-fixed but pushed no commit and left no reply on the PR — the fixing round changed nothing. Inspect the pane.',
1664
+ });
1665
+ return;
1666
+ }
1667
+ }
1668
+ }
1097
1669
  // head fetch / 回复检查是秒级网络等待,期间真 push / verdict 可能已换代 pass;旧信号不得再驱动派发
1098
1670
  const preEmit = await manager.getTask(task.id);
1099
1671
  if (!preEmit || preEmit.status !== 'fixing' || preEmit.signalToken !== token)