baxian 1.2.61 → 2.0.1

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 (245) hide show
  1. package/README.md +7 -4
  2. package/README.zh-CN.md +7 -4
  3. package/dist/agent/bootstrap-poller.d.ts +1 -1
  4. package/dist/agent/bootstrap-poller.d.ts.map +1 -1
  5. package/dist/agent/bootstrap-poller.js.map +1 -1
  6. package/dist/agent/bootstrap.d.ts +1 -1
  7. package/dist/agent/bootstrap.d.ts.map +1 -1
  8. package/dist/agent/bootstrap.js +6 -2
  9. package/dist/agent/bootstrap.js.map +1 -1
  10. package/dist/agent/branch.d.ts +1 -3
  11. package/dist/agent/branch.d.ts.map +1 -1
  12. package/dist/agent/branch.js +2 -2
  13. package/dist/agent/branch.js.map +1 -1
  14. package/dist/agent/dispatch-reconciler.d.ts +0 -1
  15. package/dist/agent/dispatch-reconciler.d.ts.map +1 -1
  16. package/dist/agent/dispatch-reconciler.js +15 -49
  17. package/dist/agent/dispatch-reconciler.js.map +1 -1
  18. package/dist/agent/manager.d.ts +212 -90
  19. package/dist/agent/manager.d.ts.map +1 -1
  20. package/dist/agent/manager.js +2983 -1574
  21. package/dist/agent/manager.js.map +1 -1
  22. package/dist/agent/net-exec.d.ts +3 -0
  23. package/dist/agent/net-exec.d.ts.map +1 -1
  24. package/dist/agent/net-exec.js +6 -0
  25. package/dist/agent/net-exec.js.map +1 -1
  26. package/dist/agent/pane-streamer-manager.d.ts +6 -6
  27. package/dist/agent/pane-streamer-manager.d.ts.map +1 -1
  28. package/dist/agent/pane-streamer-manager.js.map +1 -1
  29. package/dist/agent/pane-streamer.d.ts +2 -2
  30. package/dist/agent/pane-streamer.d.ts.map +1 -1
  31. package/dist/agent/pane-streamer.js +46 -36
  32. package/dist/agent/pane-streamer.js.map +1 -1
  33. package/dist/agent/phase-signal-watcher.d.ts +20 -2
  34. package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
  35. package/dist/agent/phase-signal-watcher.js +135 -17
  36. package/dist/agent/phase-signal-watcher.js.map +1 -1
  37. package/dist/agent/phase-signal.d.ts +9 -7
  38. package/dist/agent/phase-signal.d.ts.map +1 -1
  39. package/dist/agent/phase-signal.js +18 -9
  40. package/dist/agent/phase-signal.js.map +1 -1
  41. package/dist/agent/preflight.d.ts +4 -1
  42. package/dist/agent/preflight.d.ts.map +1 -1
  43. package/dist/agent/preflight.js +46 -45
  44. package/dist/agent/preflight.js.map +1 -1
  45. package/dist/agent/prompt.d.ts +8 -5
  46. package/dist/agent/prompt.d.ts.map +1 -1
  47. package/dist/agent/prompt.js +34 -30
  48. package/dist/agent/prompt.js.map +1 -1
  49. package/dist/agent/repo-store.d.ts +8 -1
  50. package/dist/agent/repo-store.d.ts.map +1 -1
  51. package/dist/agent/repo-store.js +70 -15
  52. package/dist/agent/repo-store.js.map +1 -1
  53. package/dist/agent/review-transport.d.ts +20 -1
  54. package/dist/agent/review-transport.d.ts.map +1 -1
  55. package/dist/agent/review-transport.js +68 -20
  56. package/dist/agent/review-transport.js.map +1 -1
  57. package/dist/agent/root-agent-runtime.d.ts +80 -0
  58. package/dist/agent/root-agent-runtime.d.ts.map +1 -0
  59. package/dist/agent/root-agent-runtime.js +635 -0
  60. package/dist/agent/root-agent-runtime.js.map +1 -0
  61. package/dist/agent/root-recovery-coordinator.d.ts +125 -0
  62. package/dist/agent/root-recovery-coordinator.d.ts.map +1 -0
  63. package/dist/agent/root-recovery-coordinator.js +1579 -0
  64. package/dist/agent/root-recovery-coordinator.js.map +1 -0
  65. package/dist/agent/runner.d.ts +1 -0
  66. package/dist/agent/runner.d.ts.map +1 -1
  67. package/dist/agent/runner.js +103 -7
  68. package/dist/agent/runner.js.map +1 -1
  69. package/dist/agent/tmux-probe-poller.d.ts +1 -0
  70. package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
  71. package/dist/agent/tmux-probe-poller.js +16 -10
  72. package/dist/agent/tmux-probe-poller.js.map +1 -1
  73. package/dist/agent/tmux.d.ts +3 -0
  74. package/dist/agent/tmux.d.ts.map +1 -1
  75. package/dist/agent/tmux.js +11 -5
  76. package/dist/agent/tmux.js.map +1 -1
  77. package/dist/api/agents.d.ts.map +1 -1
  78. package/dist/api/agents.js +42 -1
  79. package/dist/api/agents.js.map +1 -1
  80. package/dist/api/config.d.ts.map +1 -1
  81. package/dist/api/config.js +88 -17
  82. package/dist/api/config.js.map +1 -1
  83. package/dist/api/hosts.d.ts.map +1 -1
  84. package/dist/api/hosts.js +71 -15
  85. package/dist/api/hosts.js.map +1 -1
  86. package/dist/api/platform-guard.d.ts +10 -2
  87. package/dist/api/platform-guard.d.ts.map +1 -1
  88. package/dist/api/platform-guard.js +112 -10
  89. package/dist/api/platform-guard.js.map +1 -1
  90. package/dist/api/projects.d.ts.map +1 -1
  91. package/dist/api/projects.js +57 -16
  92. package/dist/api/projects.js.map +1 -1
  93. package/dist/api/tasks.d.ts.map +1 -1
  94. package/dist/api/tasks.js +21 -36
  95. package/dist/api/tasks.js.map +1 -1
  96. package/dist/app.d.ts +6 -4
  97. package/dist/app.d.ts.map +1 -1
  98. package/dist/app.js +6 -0
  99. package/dist/app.js.map +1 -1
  100. package/dist/cli.d.ts +1 -1
  101. package/dist/cli.d.ts.map +1 -1
  102. package/dist/cli.js +49 -7
  103. package/dist/cli.js.map +1 -1
  104. package/dist/config/hot-reload.d.ts +11 -2
  105. package/dist/config/hot-reload.d.ts.map +1 -1
  106. package/dist/config/hot-reload.js +43 -8
  107. package/dist/config/hot-reload.js.map +1 -1
  108. package/dist/config/loader.d.ts.map +1 -1
  109. package/dist/config/loader.js +23 -2
  110. package/dist/config/loader.js.map +1 -1
  111. package/dist/config/normalizer.d.ts.map +1 -1
  112. package/dist/config/normalizer.js +18 -0
  113. package/dist/config/normalizer.js.map +1 -1
  114. package/dist/config/validator.d.ts +3 -1
  115. package/dist/config/validator.d.ts.map +1 -1
  116. package/dist/config/validator.js +247 -54
  117. package/dist/config/validator.js.map +1 -1
  118. package/dist/event/handlers.d.ts.map +1 -1
  119. package/dist/event/handlers.js +615 -1069
  120. package/dist/event/handlers.js.map +1 -1
  121. package/dist/event/server-handlers.d.ts.map +1 -1
  122. package/dist/event/server-handlers.js +787 -487
  123. package/dist/event/server-handlers.js.map +1 -1
  124. package/dist/index.d.ts +4 -1
  125. package/dist/index.d.ts.map +1 -1
  126. package/dist/index.js +99 -79
  127. package/dist/index.js.map +1 -1
  128. package/dist/platform/command-renderer.d.ts +1 -0
  129. package/dist/platform/command-renderer.d.ts.map +1 -1
  130. package/dist/platform/command-renderer.js +16 -2
  131. package/dist/platform/command-renderer.js.map +1 -1
  132. package/dist/platform/comment-cursor.d.ts +9 -0
  133. package/dist/platform/comment-cursor.d.ts.map +1 -1
  134. package/dist/platform/comment-cursor.js +63 -5
  135. package/dist/platform/comment-cursor.js.map +1 -1
  136. package/dist/platform/driver-host.d.ts.map +1 -1
  137. package/dist/platform/driver-host.js +0 -2
  138. package/dist/platform/driver-host.js.map +1 -1
  139. package/dist/platform/driver-spec.d.ts.map +1 -1
  140. package/dist/platform/driver-spec.js +17 -6
  141. package/dist/platform/driver-spec.js.map +1 -1
  142. package/dist/platform/feedback.d.ts.map +1 -1
  143. package/dist/platform/feedback.js +0 -9
  144. package/dist/platform/feedback.js.map +1 -1
  145. package/dist/platform/git-driver.d.ts +3 -0
  146. package/dist/platform/git-driver.d.ts.map +1 -1
  147. package/dist/platform/git-driver.js +45 -3
  148. package/dist/platform/git-driver.js.map +1 -1
  149. package/dist/platform/platform-poller.d.ts +18 -5
  150. package/dist/platform/platform-poller.d.ts.map +1 -1
  151. package/dist/platform/platform-poller.js +283 -38
  152. package/dist/platform/platform-poller.js.map +1 -1
  153. package/dist/platform/plugins/github/baxian-plugin.json +1 -1
  154. package/dist/platform/plugins/github/driver.json +14 -3
  155. package/dist/platform/plugins/github/skills/baxian-cli-gh/SKILL.md +7 -6
  156. package/dist/platform/plugins/github/skills/baxian-cli-gh/agents/openai.yaml +2 -0
  157. package/dist/platform/pr-binding.d.ts.map +1 -1
  158. package/dist/platform/pr-binding.js +0 -2
  159. package/dist/platform/pr-binding.js.map +1 -1
  160. package/dist/platform/pr-conversation-cache.d.ts.map +1 -0
  161. package/dist/platform/pr-conversation-cache.js.map +1 -0
  162. package/dist/platform/row-schema.d.ts.map +1 -1
  163. package/dist/platform/row-schema.js +1 -0
  164. package/dist/platform/row-schema.js.map +1 -1
  165. package/dist/platform/startup.d.ts +29 -1
  166. package/dist/platform/startup.d.ts.map +1 -1
  167. package/dist/platform/startup.js +82 -4
  168. package/dist/platform/startup.js.map +1 -1
  169. package/dist/platform/types.d.ts +2 -0
  170. package/dist/platform/types.d.ts.map +1 -1
  171. package/dist/platform/types.js +6 -4
  172. package/dist/platform/types.js.map +1 -1
  173. package/dist/platform/verdict-engine.d.ts.map +1 -1
  174. package/dist/platform/verdict-engine.js +0 -2
  175. package/dist/platform/verdict-engine.js.map +1 -1
  176. package/dist/shared/constants.d.ts +7 -1
  177. package/dist/shared/constants.d.ts.map +1 -1
  178. package/dist/shared/constants.js +5 -0
  179. package/dist/shared/constants.js.map +1 -1
  180. package/dist/shared/types.d.ts +98 -7
  181. package/dist/shared/types.d.ts.map +1 -1
  182. package/dist/shared/types.js +18 -0
  183. package/dist/shared/types.js.map +1 -1
  184. package/dist/skills/baxian-pr-feedback/SKILL.md +5 -15
  185. package/dist/skills/baxian-pr-recheck/SKILL.md +5 -44
  186. package/dist/skills/baxian-pr-review/SKILL.md +6 -43
  187. package/dist/skills/baxian-research/SKILL.md +1 -1
  188. package/dist/skills/baxian-server-feedback/SKILL.md +7 -5
  189. package/dist/skills/baxian-server-review/SKILL.md +2 -2
  190. package/dist/skills/baxian-signals/SKILL.md +11 -7
  191. package/dist/skills/baxian-task-check/SKILL.md +3 -5
  192. package/dist/state/agent-store.js +0 -2
  193. package/dist/state/agent-store.js.map +1 -1
  194. package/dist/state/init.d.ts.map +1 -1
  195. package/dist/state/init.js +1 -0
  196. package/dist/state/init.js.map +1 -1
  197. package/dist/state/lock.d.ts +6 -0
  198. package/dist/state/lock.d.ts.map +1 -1
  199. package/dist/state/lock.js +8 -0
  200. package/dist/state/lock.js.map +1 -1
  201. package/dist/state/review-store.d.ts +21 -1
  202. package/dist/state/review-store.d.ts.map +1 -1
  203. package/dist/state/review-store.js +132 -0
  204. package/dist/state/review-store.js.map +1 -1
  205. package/dist/state/root-recovery-store.d.ts +107 -0
  206. package/dist/state/root-recovery-store.d.ts.map +1 -0
  207. package/dist/state/root-recovery-store.js +400 -0
  208. package/dist/state/root-recovery-store.js.map +1 -0
  209. package/dist/state/task-store.d.ts +6 -0
  210. package/dist/state/task-store.d.ts.map +1 -1
  211. package/dist/state/task-store.js +266 -21
  212. package/dist/state/task-store.js.map +1 -1
  213. package/dist/terminal/attach.d.ts +3 -3
  214. package/dist/terminal/attach.d.ts.map +1 -1
  215. package/dist/terminal/attach.js.map +1 -1
  216. package/dist/terminal/stream-ws.d.ts.map +1 -1
  217. package/dist/terminal/stream-ws.js.map +1 -1
  218. package/dist/web/assets/index-BXfgaFNm.js +18 -0
  219. package/dist/web/index.html +1 -1
  220. package/package.json +1 -1
  221. package/dist/github/mapper.d.ts +0 -9
  222. package/dist/github/mapper.d.ts.map +0 -1
  223. package/dist/github/mapper.js +0 -25
  224. package/dist/github/mapper.js.map +0 -1
  225. package/dist/github/poller.d.ts +0 -73
  226. package/dist/github/poller.d.ts.map +0 -1
  227. package/dist/github/poller.js +0 -556
  228. package/dist/github/poller.js.map +0 -1
  229. package/dist/github/pr-conversation-cache.d.ts.map +0 -1
  230. package/dist/github/pr-conversation-cache.js.map +0 -1
  231. package/dist/github/pr-conversation.d.ts +0 -7
  232. package/dist/github/pr-conversation.d.ts.map +0 -1
  233. package/dist/github/pr-conversation.js +0 -175
  234. package/dist/github/pr-conversation.js.map +0 -1
  235. package/dist/github/resolver.d.ts +0 -8
  236. package/dist/github/resolver.d.ts.map +0 -1
  237. package/dist/github/resolver.js +0 -28
  238. package/dist/github/resolver.js.map +0 -1
  239. package/dist/state/post-approve-store.d.ts +0 -23
  240. package/dist/state/post-approve-store.d.ts.map +0 -1
  241. package/dist/state/post-approve-store.js +0 -82
  242. package/dist/state/post-approve-store.js.map +0 -1
  243. package/dist/web/assets/index-CUJtd0bC.js +0 -16
  244. /package/dist/{github → platform}/pr-conversation-cache.d.ts +0 -0
  245. /package/dist/{github → platform}/pr-conversation-cache.js +0 -0
@@ -1,15 +1,100 @@
1
- import { createSignalToken, decodeSignalActorId } from '../agent/phase-signal.js';
1
+ import { decodeSignalActorId } from '../agent/phase-signal.js';
2
+ import { BODY_DIGEST_SOURCE } from '../platform/body-digest.js';
2
3
  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';
4
+ import { LINE_SAFE_ID_RE } from '../platform/row-schema.js';
5
+ import { SHA_HEX_SOURCE, SOURCE_KEY_PATTERN } from '../platform/types.js';
6
+ import { BRANCH_PREFIX, isRecord, isSpecStagePhase, isValidBranchName, } from '../shared/index.js';
7
+ import { DispatchTerminalError, isRecoverableQaDispatchHold, } from '../agent/manager.js';
6
8
  import { ReplNotReadyError } from '../agent/tmux.js';
7
9
  import { DirtyWorkdirError } from '../agent/branch.js';
8
- // 平台 SHA 文法单点(spec §5.3:7-64 位 hex);legacy github 值恒 40 位,放宽为 additive
9
10
  const HEAD_SHA_RE = new RegExp(`^${SHA_HEX_SOURCE}$`);
11
+ const BODY_DIGEST_RE = new RegExp(`^${BODY_DIGEST_SOURCE}$`);
12
+ const PR_UPDATED_REVIEW_FROM_STATUSES = [
13
+ 'in_progress', 'fixing', 'review', 'approved', 'merge-ready',
14
+ ];
10
15
  function validHeadSha(value) {
11
16
  return typeof value === 'string' && HEAD_SHA_RE.test(value) ? value : undefined;
12
17
  }
18
+ class GitVerdictPayloadError extends Error {
19
+ constructor(message) {
20
+ super(`malformed git verdict: ${message}`);
21
+ this.name = 'GitVerdictPayloadError';
22
+ }
23
+ }
24
+ function parseGitVerdictPayload(data) {
25
+ if (data.action !== 'APPROVE' && data.action !== 'REQUEST_CHANGES') {
26
+ throw new GitVerdictPayloadError('unknown action');
27
+ }
28
+ if (data.source !== 'platform-poller')
29
+ throw new GitVerdictPayloadError('invalid source');
30
+ if (typeof data.reviewPassToken !== 'string' || !/^[0-9a-f]{12}$/.test(data.reviewPassToken)) {
31
+ throw new GitVerdictPayloadError('invalid review pass token');
32
+ }
33
+ if (typeof data.verdictToken !== 'string' || !/^[0-9a-f]{12}$/.test(data.verdictToken)) {
34
+ throw new GitVerdictPayloadError('invalid verdict token');
35
+ }
36
+ const headSha = validHeadSha(data.headSha);
37
+ const currentHeadSha = validHeadSha(data.currentHeadSha);
38
+ if (!headSha || !currentHeadSha)
39
+ throw new GitVerdictPayloadError('invalid head sha');
40
+ if (!Number.isInteger(data.prNumber) || data.prNumber < 1) {
41
+ throw new GitVerdictPayloadError('invalid PR number');
42
+ }
43
+ if (typeof data.prUrl !== 'string' || data.prUrl.trim() === '' || data.prUrl.length > 4096) {
44
+ throw new GitVerdictPayloadError('invalid PR URL');
45
+ }
46
+ if (typeof data.branch !== 'string' || data.branch.trim() === '' || data.branch.length > 1024) {
47
+ throw new GitVerdictPayloadError('invalid PR branch');
48
+ }
49
+ if (data.submittedAt !== undefined
50
+ && (typeof data.submittedAt !== 'string' || !Number.isFinite(Date.parse(data.submittedAt)))) {
51
+ throw new GitVerdictPayloadError('invalid submission time');
52
+ }
53
+ if (data.verdictConflict !== undefined && typeof data.verdictConflict !== 'boolean') {
54
+ throw new GitVerdictPayloadError('invalid conflict flag');
55
+ }
56
+ if (!isRecord(data.verdictCarrier))
57
+ throw new GitVerdictPayloadError('missing carrier');
58
+ const carrier = data.verdictCarrier;
59
+ if (typeof carrier.sourceKey !== 'string' || carrier.sourceKey.trim() === '' || carrier.sourceKey.length > 128
60
+ || typeof carrier.id !== 'string' || carrier.id.trim() === '' || carrier.id.length > 512
61
+ || typeof carrier.bodyDigest !== 'string' || !/^[0-9a-f]{64}$/.test(carrier.bodyDigest)) {
62
+ throw new GitVerdictPayloadError('invalid carrier');
63
+ }
64
+ return {
65
+ action: data.action,
66
+ reviewPassToken: data.reviewPassToken,
67
+ verdictToken: data.verdictToken,
68
+ headSha,
69
+ currentHeadSha,
70
+ prNumber: data.prNumber,
71
+ prUrl: data.prUrl,
72
+ carrier: {
73
+ sourceKey: carrier.sourceKey,
74
+ id: carrier.id,
75
+ bodyDigest: carrier.bodyDigest,
76
+ },
77
+ };
78
+ }
79
+ function parseGitFeedbackRevision(value) {
80
+ if (!isRecord(value))
81
+ return null;
82
+ if (typeof value.sourceKey !== 'string' || !SOURCE_KEY_PATTERN.test(value.sourceKey))
83
+ return null;
84
+ if (typeof value.id !== 'string' || !LINE_SAFE_ID_RE.test(value.id))
85
+ return null;
86
+ if (typeof value.bodyDigest !== 'string' || !BODY_DIGEST_RE.test(value.bodyDigest))
87
+ return null;
88
+ if (value.versionTime !== undefined
89
+ && (typeof value.versionTime !== 'number' || !Number.isFinite(value.versionTime)))
90
+ return null;
91
+ return {
92
+ sourceKey: value.sourceKey,
93
+ id: value.id,
94
+ bodyDigest: value.bodyDigest,
95
+ ...(value.versionTime !== undefined ? { versionTime: value.versionTime } : {}),
96
+ };
97
+ }
13
98
  async function reArmDevelopWatcher(manager, task, agentId, opts = {}) {
14
99
  const kinds = task.phase === 'research'
15
100
  ? ['spec-done']
@@ -35,149 +120,109 @@ async function emitIntervention(bus, projectId, agentId, taskId, data) {
35
120
  console.warn(`[EventHandler] human.intervention emit failed (phase=${data.phase}):`, emitErr);
36
121
  }
37
122
  }
38
- async function resolveAuthoritativeHead(manager, task, opts = {}) {
39
- try {
40
- const headSha = await manager.fetchPrHeadSha(task.id);
41
- return { headSha, source: 'fetch' };
42
- }
43
- catch (err) {
44
- const fetchError = err instanceof Error ? err.message : String(err);
45
- console.warn(`[handlers] fetchPrHeadSha(task=${task.id}) failed; falling back to stored / payload:`, err);
46
- const stored = validHeadSha(task.latestHeadSha);
47
- if (stored)
48
- return { headSha: stored, source: 'task-store', fetchError };
49
- if (opts.payloadCurrentHeadSha) {
50
- return { headSha: opts.payloadCurrentHeadSha, source: 'payload-self', fetchError };
51
- }
52
- if (opts.legacyFallback) {
53
- return { headSha: opts.legacyFallback, source: 'completion-approved', fetchError };
54
- }
55
- return { headSha: undefined, source: 'unknown', fetchError };
56
- }
57
- }
58
- async function fetchVerifiedHeadSha(manager, taskId) {
59
- let lastErr;
60
- for (let attempt = 0; attempt < 3; attempt++) {
61
- try {
62
- return await manager.fetchPrHeadSha(taskId);
63
- }
64
- catch (err) {
65
- lastErr = err;
66
- if (attempt < 2)
67
- await new Promise(resolve => setTimeout(resolve, 300));
68
- }
69
- }
70
- throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
71
- }
72
123
  const POST_APPROVE_REDISPATCH_CAP = 10;
124
+ const POST_APPROVE_RECOVERY_MIN_AGE_MS = 60_000;
125
+ // 损坏值不可自愈,每周期 sweep 会重踩同一分支:按 task+值去重,值变化或修复后允许重报
126
+ const unparseableUpdatedAtWarned = new Map();
73
127
  const VERDICT_FRESHNESS_SKEW_MS = 5000;
74
- async function dispatchDevPostApproveCheck(bus, manager, task, approvedHeadSha, opts = {}) {
128
+ function postApproveEpisodeKey(task) {
129
+ if (task.postApproveGeneration === undefined
130
+ || task.postApproveToken === undefined
131
+ || task.postApproveHeadSha === undefined)
132
+ return null;
133
+ return {
134
+ generation: task.postApproveGeneration,
135
+ token: task.postApproveToken,
136
+ headSha: task.postApproveHeadSha,
137
+ };
138
+ }
139
+ async function dispatchGitDevPostApproveCheck(bus, manager, snapshot, approvedHeadSha, opts = {}) {
75
140
  if (!approvedHeadSha) {
76
- await emitIntervention(bus, task.projectId, task.agentId, task.id, {
141
+ await emitIntervention(bus, snapshot.projectId, snapshot.agentId, snapshot.id, {
77
142
  phase: 'post-approve-approved-head-unavailable',
78
143
  });
79
144
  return;
80
145
  }
81
- const acquired = await manager.acquireAgentForTask(task.agentId, task.id, 'post-approve');
82
- if (!acquired) {
83
- if (task.reviewMode === 'git') {
84
- // durable retry 锚:acquire 失败发生在 completion 安装前,无 pending 则 sweep 永远看不到
85
- await manager.updateTask(task.id, { pendingRedispatch: true }).catch(() => undefined);
86
- }
87
- await emitIntervention(bus, task.projectId, task.agentId, task.id, {
88
- phase: 'post-approve-dev-acquire-failed',
89
- devAgentId: task.agentId,
146
+ const live = await manager.getTask(snapshot.id);
147
+ if (!live || live.status !== 'approved' || live.reviewMode !== 'git')
148
+ return;
149
+ let key = postApproveEpisodeKey(live);
150
+ if (!key || key.headSha !== approvedHeadSha || live.postApprovePhase === undefined) {
151
+ await emitIntervention(bus, snapshot.projectId, snapshot.agentId, snapshot.id, {
152
+ phase: 'post-approve-episode-invalid',
90
153
  });
91
154
  return;
92
155
  }
93
- // Persisted after acquire and only while still approved: a stale write would forge rebuild evidence.
94
- const headPersisted = await manager.updateTaskIfStatus(task.id, 'approved', {
95
- postApproveHeadSha: approvedHeadSha,
96
- }, { signalToken: task.signalToken, reviewRound: task.reviewRound });
97
- if (!headPersisted) {
98
- await manager.markAgentWaiting(task.agentId, task.id)
99
- .catch(err => {
100
- console.error(`[EventHandler] post-approve head persist refused for task=${task.id}; release failed:`, err);
156
+ if (opts.expectedEpisode
157
+ && (key.generation !== opts.expectedEpisode.generation
158
+ || key.token !== opts.expectedEpisode.token
159
+ || key.headSha !== opts.expectedEpisode.headSha))
160
+ return;
161
+ if (opts.redispatchCount !== undefined) {
162
+ const rotated = await manager.rotateGitPostApproveEpisode(live.id, opts.expectedEpisode ?? key, opts.redispatchCount);
163
+ if (!rotated)
164
+ return;
165
+ key = rotated;
166
+ }
167
+ const acquired = await manager.acquireAgentForTask(live.agentId, live.id, 'post-approve');
168
+ if (!acquired) {
169
+ await emitIntervention(bus, live.projectId, live.agentId, live.id, {
170
+ phase: 'post-approve-dev-acquire-failed',
171
+ devAgentId: live.agentId,
172
+ generation: key.generation,
101
173
  });
102
174
  return;
103
175
  }
104
- const signalToken = createSignalToken();
105
- const installed = await manager.setPostApproveCompletion(task.id, {
106
- token: signalToken,
107
- approvedHeadSha,
108
- ...(typeof opts.redispatchCount === 'number' ? { redispatchCount: opts.redispatchCount } : {}),
109
- // 'git' 至少一次投递(spec §6):pending 随安装保持,continueSession 确认后才清——
110
- // 安装与注入之间的崩溃由恢复扫描按 pending 补派
111
- ...(task.reviewMode === 'git' ? { pendingRedispatch: true } : {}),
112
- },
113
- // A fresh human verdict unlocks a standing revocation; automated redispatches never do.
114
- { clearRevocation: typeof opts.redispatchCount !== 'number' });
115
- if (!installed) {
116
- await manager.markAgentWaiting(task.agentId, task.id)
117
- .catch(err => {
118
- console.error(`[EventHandler] post-approve install refused by revocation for task=${task.id}; release failed:`, err);
176
+ if (!(await manager.armGitPostApproveEpisode(live.id, key))) {
177
+ await manager.markAgentWaiting(live.agentId, live.id, {
178
+ expectedPostApproveEpisode: key,
179
+ }).catch(err => {
180
+ console.error(`[EventHandler] post-approve arm refusal release failed for task=${live.id}:`, err);
181
+ });
182
+ await emitIntervention(bus, live.projectId, live.agentId, live.id, {
183
+ phase: 'post-approve-signal-arm-failed',
184
+ generation: key.generation,
119
185
  });
120
186
  return;
121
187
  }
122
188
  let resumed = false;
123
- let dispatchErr = null;
189
+ let dispatchErr;
124
190
  try {
125
- resumed = await manager.continueSession(task.id, task.agentId, 'post-approve', {
126
- signalToken,
127
- ...(typeof opts.redispatchCount === 'number'
128
- ? { postApproveRedispatchCount: opts.redispatchCount }
129
- : {}),
191
+ resumed = await manager.continueSession(live.id, live.agentId, 'post-approve', {
192
+ signalToken: key.token,
193
+ postApproveEpisode: key,
130
194
  });
131
195
  }
132
196
  catch (err) {
133
197
  dispatchErr = err;
134
- console.error(`[EventHandler] APPROVE continueSession(dev=${task.agentId}, post-approve) failed:`, err);
198
+ console.error(`[EventHandler] post-approve dispatch failed for task=${live.id}:`, err);
135
199
  }
136
200
  if (resumed) {
137
- if (task.reviewMode === 'git')
138
- await manager.confirmPostApprovePromptDelivered(task.id, signalToken);
201
+ await manager.confirmPostApprovePromptDelivered(live.id, key);
139
202
  return;
140
203
  }
141
- // git:未确认送达的失败保留 installed+pending 交 sweep 补派(DispatchTerminalError 单独终结);
142
- // legacy 维持清除语义
143
- if (task.reviewMode !== 'git' || dispatchErr instanceof DispatchTerminalError) {
144
- await manager.clearPostApproveCompletionIfMatches(task.id, signalToken);
145
- }
146
204
  if (dispatchErr instanceof DispatchTerminalError) {
147
- await manager.failTaskForDispatchError(task.id, 'post-approve', task.agentId, dispatchErr);
205
+ await manager.failTaskForDispatchError(live.id, 'post-approve', live.agentId, dispatchErr, {
206
+ expectedPostApproveEpisode: key,
207
+ });
148
208
  return;
149
209
  }
150
- await manager.markAgentWaiting(task.agentId, task.id)
151
- .catch(err => {
152
- console.error(`[EventHandler] APPROVE markAgentWaiting(dev=${task.agentId}) rollback failed:`, err);
153
- return false;
210
+ await manager.markAgentWaiting(live.agentId, live.id, {
211
+ expectedPostApproveEpisode: key,
212
+ }).catch(err => {
213
+ console.error(`[EventHandler] post-approve rollback failed for task=${live.id}:`, err);
154
214
  });
155
- await emitIntervention(bus, task.projectId, task.agentId, task.id, {
215
+ await emitIntervention(bus, live.projectId, live.agentId, live.id, {
156
216
  phase: 'post-approve-dispatch-failed',
157
- reviewRound: task.reviewRound,
158
- ...(dispatchErr instanceof DispatchTerminalError ? { terminalReason: dispatchErr.reason } : {}),
217
+ reviewRound: live.reviewRound,
218
+ generation: key.generation,
159
219
  ...(dispatchErr ? { error: dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr) } : {}),
160
220
  });
161
221
  }
162
- async function gateDevForPostApproveRedispatch(bus, manager, task) {
163
- const ready = await manager
164
- .releaseAgentForTask(task.agentId, task.id, 'waiting')
165
- .catch(err => {
166
- console.error(`[EventHandler] post-approve redispatch releaseAgentForTask(dev=${task.agentId}) failed:`, err);
167
- return false;
168
- });
169
- if (ready)
170
- return true;
171
- await emitIntervention(bus, task.projectId, task.agentId, task.id, {
172
- phase: 'post-approve-dev-wait-gate-failed-before-redispatch',
173
- devAgentId: task.agentId,
174
- });
175
- return false;
176
- }
177
222
  async function handlePrMergeReady(bus, manager, event) {
178
223
  const taskId = event.taskId;
179
224
  const taskNow = await manager.getTask(taskId);
180
- if (!taskNow)
225
+ if (!taskNow || taskNow.reviewMode !== 'git')
181
226
  return;
182
227
  const eventPrNumber = event.data.prNumber;
183
228
  const eventPrUrl = event.data.prUrl;
@@ -197,7 +242,12 @@ async function handlePrMergeReady(bus, manager, event) {
197
242
  || !signalToken
198
243
  || completion?.token !== signalToken)
199
244
  return;
200
- const ok = await manager.markAgentWaiting(taskNow.agentId, taskNow.id);
245
+ const entryGitEpisode = postApproveEpisodeKey(taskNow);
246
+ if (entryGitEpisode?.token !== signalToken)
247
+ return;
248
+ const ok = await manager.markAgentWaiting(taskNow.agentId, taskNow.id, {
249
+ expectedPostApproveEpisode: entryGitEpisode,
250
+ });
201
251
  if (!ok) {
202
252
  await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
203
253
  phase: 'post-approve-dev-wait-gate-failed',
@@ -210,69 +260,91 @@ async function handlePrMergeReady(bus, manager, event) {
210
260
  ]);
211
261
  if (!freshTask
212
262
  || freshTask.status !== 'approved'
263
+ || freshTask.reviewMode !== 'git'
213
264
  || freshTask.agentId !== taskNow.agentId
214
265
  || freshTask.postApproveRevoked !== undefined
215
- || entryCompletion?.token !== signalToken
216
- || freshTask.postApproveHeadSha === undefined
217
- || entryCompletion.approvedHeadSha !== freshTask.postApproveHeadSha) {
266
+ || entryCompletion?.token !== signalToken) {
218
267
  await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
219
268
  phase: 'post-approve-merge-skipped-stale-task',
220
269
  });
221
270
  return;
222
271
  }
223
- // 'git' merge-ready 接收即 server 权威双复核(spec §6/§10:provenance + ack 完成集,同一次全源扫描):
224
- // agent 自查是尽责而非授权
225
272
  let freshCompletion = entryCompletion;
226
- if (freshTask.reviewMode === 'git') {
227
- // 信号已消费(token 已验证):durable 记录 signaled——瞬态复核失败时 dev 不会重发信号,
228
- // 恢复/sweep 凭此相位重派而不是等一个不会再来的信号
229
- await manager.markPostApproveSignalReceived(freshTask.id, signalToken);
230
- const scanFailedRetry = async (error) => {
231
- await manager.updatePostApproveCompletionIfToken(freshTask.id, signalToken, { pendingRedispatch: true });
232
- await emitIntervention(bus, freshTask.projectId, freshTask.agentId, freshTask.id, {
233
- phase: 'post-approve-merge-skipped-scan-failed',
234
- error,
235
- });
236
- };
237
- let verified;
238
- try {
239
- verified = await manager.platformVerifyAcceptedPass(freshTask.id);
240
- }
241
- catch (err) {
242
- await scanFailedRetry(err instanceof Error ? err.message : String(err));
243
- return;
244
- }
245
- if (!verified.ok) {
246
- if (verified.reason === 'source scan incomplete') {
247
- await scanFailedRetry(verified.reason);
248
- return;
273
+ const gitEpisodeKey = postApproveEpisodeKey(freshTask);
274
+ if (!gitEpisodeKey || gitEpisodeKey.token !== signalToken) {
275
+ await emitIntervention(bus, freshTask.projectId, freshTask.agentId, freshTask.id, {
276
+ phase: 'post-approve-merge-skipped-stale-task',
277
+ });
278
+ return;
279
+ }
280
+ await manager.markPostApproveSignalReceived(freshTask.id, gitEpisodeKey);
281
+ const scanFailedRetry = async (error) => {
282
+ await manager.updatePostApproveCompletionIfToken(freshTask.id, signalToken, { pendingRedispatch: true }, { expectedGeneration: gitEpisodeKey.generation, expectedHeadSha: gitEpisodeKey.headSha });
283
+ await emitIntervention(bus, freshTask.projectId, freshTask.agentId, freshTask.id, {
284
+ phase: 'post-approve-merge-skipped-scan-failed',
285
+ error,
286
+ });
287
+ };
288
+ let verified;
289
+ try {
290
+ verified = await manager.platformVerifyAcceptedPass(freshTask.id);
291
+ }
292
+ catch (err) {
293
+ await scanFailedRetry(err instanceof Error ? err.message : String(err));
294
+ return;
295
+ }
296
+ if (verified.kind === 'retryable') {
297
+ await scanFailedRetry(verified.reason);
298
+ return;
299
+ }
300
+ if (verified.kind === 'provenance-invalid') {
301
+ const begun = await manager.beginGitReviewPass(freshTask.id, {
302
+ fromStatus: [freshTask.status],
303
+ headSha: gitEpisodeKey.headSha,
304
+ bumpRound: true,
305
+ qaPhase: 'recheck',
306
+ expectPhase: freshTask.phase,
307
+ expectSignalToken: freshTask.signalToken,
308
+ patch: { latestHeadSha: gitEpisodeKey.headSha },
309
+ });
310
+ if (begun?.task.reviewDispatch) {
311
+ try {
312
+ await manager.dispatchGitReviewLease(freshTask.id, {
313
+ expectedGeneration: begun.task.reviewDispatch.generation,
314
+ });
249
315
  }
250
- // pass 已被撤销/编辑(确定性失效):拒绝迁移滞留 approved,交人工重派评审(spec §6 merge 条)
251
- await emitIntervention(bus, freshTask.projectId, freshTask.agentId, freshTask.id, {
252
- phase: 'post-approve-merge-skipped-provenance',
253
- reason: verified.reason,
254
- });
255
- return;
256
- }
257
- if (verified.pendingCount > 0 && !freshCompletion.pendingRedispatch) {
258
- if (await manager.updatePostApproveCompletionIfToken(freshTask.id, signalToken, { pendingRedispatch: true })) {
259
- freshCompletion = { ...freshCompletion, pendingRedispatch: true };
316
+ catch (err) {
317
+ if (err instanceof DispatchTerminalError && begun.task.qaAgentId) {
318
+ await manager.failTaskForDispatchError(freshTask.id, 'recheck', begun.task.qaAgentId, err, { expectedReviewDispatch: begun.task.reviewDispatch });
319
+ return;
320
+ }
321
+ await emitIntervention(bus, freshTask.projectId, freshTask.agentId, freshTask.id, {
322
+ phase: 'post-approve-provenance-redispatch-failed',
323
+ reason: verified.reason,
324
+ error: err instanceof Error ? err.message : String(err),
325
+ });
260
326
  }
261
327
  }
262
- else if (verified.pendingCount === 0 && freshCompletion.pendingRedispatch) {
263
- // 权威扫描为空即清 stale pending:dev 已在本轮内补齐 ack,不再白派一轮
264
- if (await manager.updatePostApproveCompletionIfToken(freshTask.id, signalToken, { pendingRedispatch: false })) {
265
- freshCompletion = { ...freshCompletion, pendingRedispatch: false };
266
- }
328
+ return;
329
+ }
330
+ if (verified.pendingCount > 0 && !freshCompletion.pendingRedispatch) {
331
+ if (await manager.updatePostApproveCompletionIfToken(freshTask.id, signalToken, { pendingRedispatch: true }, { expectedGeneration: gitEpisodeKey.generation, expectedHeadSha: gitEpisodeKey.headSha })) {
332
+ freshCompletion = { ...freshCompletion, pendingRedispatch: true };
333
+ }
334
+ }
335
+ else if (verified.pendingCount === 0 && freshCompletion.pendingRedispatch) {
336
+ if (await manager.updatePostApproveCompletionIfToken(freshTask.id, signalToken, { pendingRedispatch: false }, { expectedGeneration: gitEpisodeKey.generation, expectedHeadSha: gitEpisodeKey.headSha })) {
337
+ freshCompletion = { ...freshCompletion, pendingRedispatch: false };
267
338
  }
268
339
  }
269
- // A pending flag landing between the check and the transition re-enters here instead of losing the feedback.
270
340
  for (;;) {
271
341
  if (freshCompletion.pendingRedispatch) {
272
342
  const nextCount = (freshCompletion.redispatchCount ?? 0) + 1;
273
343
  if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
274
344
  const revoked = await manager.revokePostApproveCompletion(freshTask.id, 'redispatch-cap', {
275
345
  expectedToken: freshCompletion.token,
346
+ expectedGeneration: gitEpisodeKey.generation,
347
+ expectedHeadSha: gitEpisodeKey.headSha,
276
348
  });
277
349
  if (!revoked) {
278
350
  // The pass rotated after the entry re-validation; this merge-ready and its cap decision are stale.
@@ -288,10 +360,10 @@ async function handlePrMergeReady(bus, manager, event) {
288
360
  });
289
361
  return;
290
362
  }
291
- await dispatchDevPostApproveCheck(bus, manager, freshTask, freshCompletion.approvedHeadSha, { redispatchCount: nextCount });
363
+ await dispatchGitDevPostApproveCheck(bus, manager, freshTask, freshCompletion.approvedHeadSha, { redispatchCount: nextCount });
292
364
  return;
293
365
  }
294
- const completed = await manager.completeApprovedPassToMergeReady(freshTask.id, signalToken);
366
+ const completed = await manager.completeApprovedPassToMergeReady(freshTask.id, gitEpisodeKey);
295
367
  if ('task' in completed)
296
368
  return;
297
369
  if (completed.refused === 'pending') {
@@ -310,156 +382,53 @@ async function handlePrMergeReady(bus, manager, event) {
310
382
  async function handlePrFeedback(bus, manager, event) {
311
383
  const taskId = event.taskId;
312
384
  const taskNow = await manager.getTask(taskId);
313
- if (!taskNow)
314
- return;
315
- const eventPrNumber = event.data.prNumber;
316
- const eventPrUrl = event.data.prUrl;
317
- const eventKind = event.data.kind;
318
- const isNewFeedback = eventKind === 'comment' || eventKind === 'review-comment';
319
- const revision = taskNow.reviewMode === 'git' && isRecord(event.data.revision)
320
- ? event.data.revision
321
- : undefined;
322
- const revisionKey = revision ? ackRevisionKey(revision.sourceKey, revision.id, revision.bodyDigest) : undefined;
323
- const revisionTime = typeof revision?.versionTime === 'number' ? revision.versionTime : Date.parse(event.timestamp);
324
- // 消费幂等(spec §6):ledger 落盘前崩溃的重投在此收敛,redispatch 计数不重复生效
325
- if (revisionKey !== undefined && taskNow.consumedFeedback?.[revisionKey] !== undefined)
385
+ if (!taskNow || taskNow.reviewMode !== 'git')
326
386
  return;
327
- const prPatch = {
328
- ...(eventPrNumber !== undefined ? { prNumber: eventPrNumber } : {}),
329
- ...(eventPrUrl !== undefined ? { prUrl: eventPrUrl } : {}),
330
- };
331
- // 消费与效果由 manager 的单一锁内状态机决定(spec §6):merge-ready→退回+pending、approved→
332
- // pending、其它→仅消费——入口快照与提交之间的并发迁移(completeApprovedPass 等)不再能让
333
- // revision 既被 poller 记 delivered 又不产生效果
334
- if (taskNow.reviewMode === 'git' && isNewFeedback && revisionKey !== undefined) {
335
- const outcome = await manager.consumeGitFeedbackRevision(taskId, { key: revisionKey, versionTime: revisionTime }, { feedbackAt: event.timestamp });
336
- if (outcome.kind !== 'pending' && outcome.kind !== 'returned')
337
- return;
338
- const effected = outcome.task;
339
- const completion = await manager.getPostApproveCompletion(effected.id);
340
- const devState = await manager.getAgentState(effected.agentId);
341
- if (outcome.kind === 'pending' && completion !== null && devState?.taskId === effected.id) {
342
- // 在途 pass 会经 merge-ready 循环消化 pending;无 completion 的 durable pending 由下方补派
343
- return;
344
- }
345
- const nextCount = (effected.redispatchCount ?? completion?.redispatchCount ?? 0) + 1;
346
- if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
347
- await manager.revokePostApproveCompletion(effected.id, 'redispatch-cap');
348
- await emitIntervention(bus, effected.projectId, effected.agentId, effected.id, {
349
- phase: 'post-approve-redispatch-cap-exceeded',
350
- redispatchCount: effected.redispatchCount ?? 0,
351
- cap: POST_APPROVE_REDISPATCH_CAP,
352
- });
353
- return;
354
- }
355
- const approvedHead = effected.postApproveHeadSha ?? validHeadSha(effected.latestHeadSha);
356
- if (!approvedHead) {
357
- await emitIntervention(bus, effected.projectId, effected.agentId, effected.id, {
358
- phase: 'post-approve-approved-head-unavailable',
359
- });
360
- return;
361
- }
362
- await dispatchDevPostApproveCheck(bus, manager, effected, approvedHead, { redispatchCount: nextCount });
387
+ const revision = parseGitFeedbackRevision(event.data.revision);
388
+ if (!revision) {
389
+ await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
390
+ phase: 'git-feedback-revision-invalid',
391
+ });
363
392
  return;
364
393
  }
365
- const taskPatch = {
366
- ...prPatch,
367
- ...(isNewFeedback ? { prFeedbackReceivedAt: event.timestamp } : {}),
368
- };
369
- const needsPatch = (eventPrNumber !== undefined && eventPrNumber !== taskNow.prNumber)
370
- || (eventPrUrl !== undefined && eventPrUrl !== taskNow.prUrl)
371
- || isNewFeedback;
372
- if (needsPatch) {
373
- await manager.updateTask(taskId, taskPatch);
374
- }
375
- if (taskNow.status !== 'approved')
394
+ const revisionKey = ackRevisionKey(revision.sourceKey, revision.id, revision.bodyDigest);
395
+ if (taskNow.consumedFeedback?.[revisionKey] !== undefined)
376
396
  return;
377
- if (!isNewFeedback)
397
+ const revisionTime = revision.versionTime ?? Date.parse(event.timestamp);
398
+ const outcome = await manager.consumeGitFeedbackRevision(taskId, { key: revisionKey, versionTime: revisionTime }, { feedbackAt: event.timestamp });
399
+ if (outcome.kind !== 'pending' && outcome.kind !== 'returned')
378
400
  return;
379
- let completion = await manager.getPostApproveCompletion(taskNow.id);
380
- if (!completion) {
381
- await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
382
- phase: 'post-approve-approved-head-unavailable',
383
- });
401
+ const effected = outcome.task;
402
+ const completion = await manager.getPostApproveCompletion(effected.id);
403
+ const devState = await manager.getAgentState(effected.agentId);
404
+ if (outcome.kind === 'pending' && completion !== null && devState?.taskId === effected.id)
384
405
  return;
385
- }
386
- // Rotated successors are installed under a head CAS, so only the entry snapshot needs this proof.
387
- if (taskNow.postApproveHeadSha === undefined
388
- || completion.approvedHeadSha !== taskNow.postApproveHeadSha) {
389
- await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
390
- phase: 'post-approve-completion-episode-mismatch',
406
+ const nextCount = (effected.redispatchCount ?? completion?.redispatchCount ?? 0) + 1;
407
+ if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
408
+ const key = postApproveEpisodeKey(effected);
409
+ await manager.revokePostApproveCompletion(effected.id, 'redispatch-cap', key
410
+ ? {
411
+ expectedToken: key.token,
412
+ expectedGeneration: key.generation,
413
+ expectedHeadSha: key.headSha,
414
+ }
415
+ : {});
416
+ await emitIntervention(bus, effected.projectId, effected.agentId, effected.id, {
417
+ phase: 'post-approve-redispatch-cap-exceeded',
418
+ redispatchCount: effected.redispatchCount ?? 0,
419
+ cap: POST_APPROVE_REDISPATCH_CAP,
391
420
  });
392
421
  return;
393
422
  }
394
- // Every CAS miss below means the completion rotated mid-flight; re-decide against the live pass so the feedback is never dropped.
395
- for (;;) {
396
- const devState = await manager.getAgentState(taskNow.agentId);
397
- if (devState?.taskId === taskNow.id) {
398
- if (completion.pendingRedispatch) {
399
- // pending 已立不改效果,但本条 revision 的消费键仍须落盘:崩溃后重投不得再计一轮
400
- if (revisionKey !== undefined) {
401
- await manager.updatePostApproveCompletionIfToken(taskNow.id, completion.token, {}, { consumeRevision: { key: revisionKey, versionTime: revisionTime } });
402
- }
403
- const fresh = await manager.getPostApproveCompletion(taskNow.id);
404
- // Same token: the pending mark is already on the live pass.
405
- if (!fresh || fresh.token === completion.token)
406
- return;
407
- completion = fresh;
408
- continue;
409
- }
410
- if (await manager.updatePostApproveCompletionIfToken(taskNow.id, completion.token, {
411
- pendingRedispatch: true,
412
- }, revisionKey !== undefined ? { consumeRevision: { key: revisionKey, versionTime: revisionTime } } : {}))
413
- return;
414
- const fresh = await manager.getPostApproveCompletion(taskNow.id);
415
- // Same token: refused in place (revoked marker) — the successor fixing flow owns this feedback.
416
- if (!fresh || fresh.token === completion.token)
417
- return;
418
- completion = fresh;
419
- continue;
420
- }
421
- if (taskNow.reviewMode === 'git' && revisionKey !== undefined) {
422
- if (await manager.updatePostApproveCompletionIfToken(taskNow.id, completion.token, {
423
- pendingRedispatch: true,
424
- }, { consumeRevision: { key: revisionKey, versionTime: revisionTime } })) {
425
- completion = { ...completion, pendingRedispatch: true };
426
- }
427
- else {
428
- const fresh = await manager.getPostApproveCompletion(taskNow.id);
429
- if (!fresh || fresh.token === completion.token)
430
- return;
431
- completion = fresh;
432
- continue;
433
- }
434
- }
435
- const nextCount = (completion.redispatchCount ?? 0) + 1;
436
- if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
437
- const revoked = await manager.revokePostApproveCompletion(taskNow.id, 'redispatch-cap', {
438
- expectedToken: completion.token,
439
- });
440
- if (!revoked) {
441
- const fresh = await manager.getPostApproveCompletion(taskNow.id);
442
- if (!fresh || fresh.token === completion.token)
443
- return;
444
- completion = fresh;
445
- continue;
446
- }
447
- await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
448
- phase: 'post-approve-redispatch-cap-exceeded',
449
- redispatchCount: completion.redispatchCount ?? 0,
450
- cap: POST_APPROVE_REDISPATCH_CAP,
451
- });
452
- return;
453
- }
454
- const ready = await gateDevForPostApproveRedispatch(bus, manager, taskNow);
455
- if (!ready)
456
- return;
457
- await dispatchDevPostApproveCheck(bus, manager, { ...taskNow, ...taskPatch }, completion.approvedHeadSha, { redispatchCount: nextCount });
423
+ const approvedHead = effected.postApproveHeadSha ?? validHeadSha(effected.latestHeadSha);
424
+ if (!approvedHead) {
425
+ await emitIntervention(bus, effected.projectId, effected.agentId, effected.id, {
426
+ phase: 'post-approve-approved-head-unavailable',
427
+ });
458
428
  return;
459
429
  }
430
+ await dispatchGitDevPostApproveCheck(bus, manager, effected, approvedHead, { redispatchCount: nextCount });
460
431
  }
461
- // 恢复期消费 durable pendingRedispatch(spec §6 至少一次):merge-ready 退回或 path C 的 CAS
462
- // 落盘后、派发前崩溃的残局在此补派;dev 仍绑定任务时不派(其在途 pass 的 merge-ready 循环接手)。
463
432
  export async function recoverGitPostApprovePending(bus, manager) {
464
433
  const tasks = await manager.listActiveGitTasks();
465
434
  for (const task of tasks) {
@@ -467,7 +436,6 @@ export async function recoverGitPostApprovePending(bus, manager) {
467
436
  await recoverOneGitPendingTask(bus, manager, task);
468
437
  }
469
438
  catch (err) {
470
- // 单任务失败不得饿死其后的 durable pending(至少一次):报告并继续
471
439
  console.warn(`[EventHandler] recoverGitPostApprovePending: task=${task.id} failed, continuing:`, err);
472
440
  await emitIntervention(bus, task.projectId, task.agentId, task.id, {
473
441
  phase: 'post-approve-recovery-failed',
@@ -478,328 +446,159 @@ export async function recoverGitPostApprovePending(bus, manager) {
478
446
  }
479
447
  async function recoverOneGitPendingTask(bus, manager, task) {
480
448
  {
481
- if (task.status !== 'approved' || task.pendingRedispatch !== true)
449
+ const live = await manager.getTask(task.id);
450
+ if (!live || live.reviewMode !== 'git' || live.status !== 'approved' || live.pendingRedispatch !== true)
451
+ return;
452
+ if (live.postApproveRevoked)
453
+ return;
454
+ const updatedAt = Date.parse(live.updatedAt);
455
+ if (!Number.isFinite(updatedAt)) {
456
+ if (unparseableUpdatedAtWarned.get(live.id) !== live.updatedAt) {
457
+ unparseableUpdatedAtWarned.set(live.id, live.updatedAt);
458
+ console.warn(`[EventHandler] recoverGitPostApprovePending: task=${live.id} updatedAt unparseable, skipping recovery:`, live.updatedAt);
459
+ }
482
460
  return;
483
- if (task.postApproveRevoked)
461
+ }
462
+ unparseableUpdatedAtWarned.delete(live.id);
463
+ if (Date.now() - updatedAt < POST_APPROVE_RECOVERY_MIN_AGE_MS)
484
464
  return;
485
- if (task.postApprovePhase === 'delivered') {
486
- const devState = await manager.getAgentState(task.agentId);
487
- if (devState?.taskId === task.id) {
488
- // prompt 已送达且尚未回信号(dev 在跑):补派会向运行中的会话注入第二个 prompt 并轮换
489
- // token——只重臂 watcher 等信号,pending 由 merge-ready 循环消化
490
- await manager.rearmPostApproveSignal(task.id).catch(() => false);
465
+ if (live.postApprovePhase === 'delivered') {
466
+ const devState = await manager.getAgentState(live.agentId);
467
+ if (devState?.taskId === live.id) {
468
+ const rearmed = await manager.rearmPostApproveSignal(live.id);
469
+ if (!rearmed) {
470
+ const fresh = await manager.getTask(live.id);
471
+ if (fresh?.status === 'approved'
472
+ && fresh.pendingRedispatch === true
473
+ && fresh.postApprovePhase === 'delivered'
474
+ && fresh.postApproveGeneration === live.postApproveGeneration
475
+ && fresh.postApproveToken === live.postApproveToken) {
476
+ throw new Error(`post-approve signal re-arm refused for active task ${live.id}`);
477
+ }
478
+ }
491
479
  return;
492
480
  }
493
481
  }
494
- const approvedHead = validHeadSha(task.postApproveHeadSha ?? task.latestHeadSha);
482
+ const approvedHead = validHeadSha(live.postApproveHeadSha ?? live.latestHeadSha);
495
483
  if (!approvedHead) {
496
- await emitIntervention(bus, task.projectId, task.agentId, task.id, {
484
+ await emitIntervention(bus, live.projectId, live.agentId, live.id, {
497
485
  phase: 'post-approve-approved-head-unavailable',
498
486
  });
499
487
  return;
500
488
  }
501
- const nextCount = (task.redispatchCount ?? 0) + 1;
489
+ const nextCount = (live.redispatchCount ?? 0) + 1;
502
490
  if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
503
- await manager.revokePostApproveCompletion(task.id, 'redispatch-cap');
504
- await emitIntervention(bus, task.projectId, task.agentId, task.id, {
491
+ const key = postApproveEpisodeKey(live);
492
+ await manager.revokePostApproveCompletion(live.id, 'redispatch-cap', key
493
+ ? {
494
+ expectedToken: key.token,
495
+ expectedGeneration: key.generation,
496
+ expectedHeadSha: key.headSha,
497
+ }
498
+ : {});
499
+ await emitIntervention(bus, live.projectId, live.agentId, live.id, {
505
500
  phase: 'post-approve-redispatch-cap-exceeded',
506
- redispatchCount: task.redispatchCount ?? 0,
501
+ redispatchCount: live.redispatchCount ?? 0,
507
502
  cap: POST_APPROVE_REDISPATCH_CAP,
508
503
  });
509
504
  return;
510
505
  }
511
- await dispatchDevPostApproveCheck(bus, manager, task, approvedHead, { redispatchCount: nextCount });
512
- }
513
- }
514
- async function releaseQaAfterSkippedRollback(manager, taskId, qaId, logPrefix, ownAcquire = {}) {
515
- const now = await manager.getTask(taskId);
516
- if (now && !TASK_TERMINAL_STATUS_SET.has(now.status)) {
517
- // pass 被并发接管:QA 已被接管方 re-acquire,释放会误清其绑定;
518
- // 但本次派发的 arm 可能已顶掉接管方的 watcher(start 先 stop 同 task 旧 watcher),按当前 pass 重建
519
- console.warn(`${logPrefix} rollback skipped for task=${taskId}: pass superseded`);
520
- await manager.rearmPhaseSignalForCurrentPass(taskId);
521
- return;
506
+ await dispatchGitDevPostApproveCheck(bus, manager, live, approvedHead, {
507
+ redispatchCount: nextCount,
508
+ expectedEpisode: postApproveEpisodeKey(live) ?? undefined,
509
+ });
522
510
  }
523
- // 终态/已删:没有接管方持有这只 QA,而 cancel 清理可能没见到刚写入的 qaAgentId。
524
- // 仍钉住本次 acquire 世代——本 handler 的每一条失败释放都只清自己那一代
525
- await manager.releaseAgentForTask(qaId, taskId, 'idle', ownAcquire).catch(err => {
526
- console.error(`${logPrefix} releaseAgentForTask(QA=${qaId}) after terminal-skip rollback failed:`, err);
527
- return false;
528
- });
529
511
  }
530
512
  async function handlePrCodePush(bus, manager, event) {
531
513
  const taskId = event.taskId;
532
- const agentId = event.agentId;
533
514
  const eventPrNumber = event.data.prNumber;
534
515
  const eventPrUrl = event.data.prUrl;
535
516
  const eventKind = event.data.kind;
536
517
  const eventHeadSha = validHeadSha(event.data.headSha);
537
- // 合成 push 的触发信号已被消费但仍留在 pane,失败回滚重建 pr-fixed watcher 时跳过快照,否则立即重放成循环
538
- const rearmSkipSnapshot = event.data.source === 'pr-fixed';
539
- const prPatch = {
540
- ...(eventPrNumber !== undefined ? { prNumber: eventPrNumber } : {}),
541
- ...(eventPrUrl !== undefined ? { prUrl: eventPrUrl } : {}),
542
- ...(eventKind === 'push' && eventHeadSha ? { latestHeadSha: eventHeadSha } : {}),
543
- };
544
- const taskBeforeTransition = await manager.getTask(taskId);
545
- if (!taskBeforeTransition)
518
+ const task = await manager.getTask(taskId);
519
+ if (!task || task.reviewMode !== 'git')
546
520
  return;
547
- // 'git' push 重投收敛(spec §6 生命周期幂等):同 head 的评审轮已派发过,重放在任何后继态
548
- // 都是有害动作(QA 释放/令牌轮换/计轮/撤销 approval),恰一次由 TaskState 锚判定。
549
- // pr-fixed 合成 push 豁免——no-code fix 的 recheck 派发正是靠同 head 重入(spec §7 同 head 复检)。
550
- if (taskBeforeTransition.reviewMode === 'git' && eventKind === 'push' && eventHeadSha
521
+ if (eventKind === 'push' && eventHeadSha
551
522
  && event.data.source !== 'pr-fixed'
552
- && taskBeforeTransition.reviewDispatchPending !== true
553
- && taskBeforeTransition.latestHeadSha === eventHeadSha
554
- && taskBeforeTransition.reviewHeadAnchorSha === eventHeadSha) {
555
- return;
556
- }
557
- const willHavePrNumber = taskBeforeTransition.prNumber !== undefined || eventPrNumber !== undefined;
558
- if (taskBeforeTransition.status === 'in_progress' && !willHavePrNumber) {
559
- console.warn(`[EventHandler] pr.updated: task ${taskId} in_progress but neither task nor event has prNumber; ` +
560
- `deferring catch-up`);
523
+ && task.reviewDispatch === undefined
524
+ && task.latestHeadSha === eventHeadSha
525
+ && task.reviewHeadAnchorSha === eventHeadSha) {
561
526
  return;
562
527
  }
563
- const anchorAtDispatch = eventHeadSha ?? validHeadSha(taskBeforeTransition.latestHeadSha);
564
- const result = await manager.transitionTaskStatus(taskId, 'review', { fromStatus: ['in_progress', 'fixing', 'review', 'approved', 'merge-ready'] }, {
565
- ...prPatch,
566
- ...(anchorAtDispatch ? { reviewHeadAnchorSha: anchorAtDispatch } : {}),
567
- reviewDispatchedAt: new Date().toISOString(),
568
- signalToken: createSignalToken(),
569
- // 这些起点的轮次在 prompt 确认送达后才计(consumeReviewRoundIntent);持久化「未计」意图,
570
- // 供 busy pending/可恢复 hold/进程重启后的任何补派方恰好补计一次
571
- reviewRoundPending: (taskBeforeTransition.status === 'in_progress'
572
- || taskBeforeTransition.status === 'approved'
573
- || taskBeforeTransition.status === 'merge-ready') ? true
574
- : (taskBeforeTransition.status === 'review' ? taskBeforeTransition.reviewRoundPending : undefined),
575
- // pair 与新轮可见性原子(spec §7):transition 后到 rotate 的窗口内旧 pair 不得授权本轮
576
- ...(taskBeforeTransition.reviewMode === 'git'
577
- ? { ...manager.mintReviewTokenPair(), reviewDispatchPending: true }
578
- : {}),
579
- });
580
- if (!result)
528
+ if (task.status === 'in_progress' && task.prNumber === undefined && eventPrNumber === undefined) {
529
+ console.warn(`[EventHandler] pr.updated: task ${taskId} in_progress but neither task nor event has prNumber; deferring catch-up`);
581
530
  return;
582
- const { task: transitioned, previousStatus } = result;
583
- await manager.clearPostApproveCompletion(transitioned.id);
584
- let devAlreadyWaiting = false;
585
- if (previousStatus === 'approved' || previousStatus === 'merge-ready') {
586
- devAlreadyWaiting = await manager
587
- .releaseAgentForTask(transitioned.agentId, transitioned.id, 'waiting')
588
- .catch(err => {
589
- console.error(`[EventHandler] pr.updated releaseAgentForTask(dev=${transitioned.agentId}) before approved→recheck failed:`, err);
590
- return false;
591
- });
592
- if (!devAlreadyWaiting) {
593
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
594
- phase: 'post-approve-dev-wait-gate-failed-before-recheck',
595
- devAgentId: transitioned.agentId,
596
- });
597
- return;
598
- }
599
531
  }
600
- // review 起点且首评从未送达(round=0 + 未计轮 intent 在场)仍是首评:不得换成 recheck prompt/skill
601
- const deferredFirstReview = previousStatus === 'review'
602
- && taskBeforeTransition.reviewRound === 0
603
- && taskBeforeTransition.reviewRoundPending === true;
604
- const qaPhase = previousStatus === 'in_progress' || deferredFirstReview ? 'review' : 'recheck';
605
- if (previousStatus === 'review' && transitioned.qaAgentId) {
606
- const released = await manager
607
- .releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle', { deferWhenBusy: true })
608
- .catch(err => {
609
- console.error(`[EventHandler] pr.updated releaseAgentForTask(QA=${transitioned.qaAgentId}) for review→review push failed:`, err);
610
- return false;
611
- });
612
- if (!released) {
613
- // 忙碌拒绝的形状:QA 仍绑定本任务且未落 hold(release 对 REPL 忙碌不再写 hold)。
614
- // 这是「欠一次投递」而非故障:登记 pending,由对账在 pane 空闲后按标准入口补派
615
- const qaAfter = await manager.getAgentState(transitioned.qaAgentId).catch(() => null);
616
- const deferrable = qaAfter?.taskId === transitioned.id && qaAfter.status !== 'awaiting_human';
617
- if (deferrable && transitioned.signalToken !== undefined) {
618
- manager.registerPendingDispatchRetry(transitioned.id, {
619
- kind: 'qa-recheck',
620
- agentId: transitioned.qaAgentId,
621
- signalToken: transitioned.signalToken,
622
- qaPhase,
623
- });
624
- console.warn(`[EventHandler] pr.updated QA ${qaPhase} deferred for task=${transitioned.id}: QA still busy on the ` +
625
- `previous turn; reconciler will redispatch when idle`);
626
- return;
627
- }
628
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
629
- phase: 'qa-release-failed-cannot-recheck',
630
- qaAgentId: transitioned.qaAgentId,
631
- });
632
- return;
633
- }
634
- }
635
- const qaId = transitioned.qaAgentId;
636
- if (!qaId) {
637
- if (!devAlreadyWaiting && !(await manager.markAgentWaiting(agentId, transitioned.id))) {
638
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
639
- phase: 'dev-wait-gate-failed-no-qa',
640
- });
641
- }
532
+ if (!PR_UPDATED_REVIEW_FROM_STATUSES.includes(task.status))
642
533
  return;
534
+ let reviewHead = eventHeadSha ?? validHeadSha(task.latestHeadSha);
535
+ const prNumber = eventPrNumber ?? task.prNumber;
536
+ if (reviewHead === undefined && prNumber !== undefined) {
537
+ const verified = await manager.platformVerifyPrBinding(taskId, prNumber);
538
+ if (verified.ok)
539
+ reviewHead = validHeadSha(verified.headSha);
643
540
  }
644
- const qa = manager.getAgentConfig(qaId);
645
- if (!qa || qa.role !== 'qa') {
646
- if (!devAlreadyWaiting)
647
- await manager.markAgentWaiting(agentId, transitioned.id).catch(() => false);
648
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
649
- phase: 'qa-config-missing',
650
- qaAgentId: qaId,
541
+ if (reviewHead === undefined) {
542
+ await emitIntervention(bus, task.projectId, task.agentId, taskId, {
543
+ phase: 'git-review-head-unavailable',
651
544
  });
652
545
  return;
653
546
  }
654
- // 锁代由 acquire 原子交出(事后重读只会读到 successor 的代):派发放弃时只释放自己这一代,
655
- // successor 若已重新 acquire 同一 QA(同 task 同 agent,绕过 release 的 taskId 校验)不得被误清
656
- let ownLockToken;
657
- const acquired = await manager.acquireAgentForTask(qa.id, transitioned.id, qaPhase, {
658
- onAcquired: (lockToken) => { ownLockToken = lockToken; },
547
+ const bumpRound = task.status === 'in_progress'
548
+ || task.status === 'approved'
549
+ || task.status === 'merge-ready'
550
+ || (task.status === 'review' && task.reviewRoundPending === true);
551
+ const begun = await manager.beginGitReviewPass(taskId, {
552
+ fromStatus: [task.status],
553
+ headSha: reviewHead,
554
+ bumpRound,
555
+ expectPhase: task.phase,
556
+ expectSignalToken: task.signalToken,
557
+ patch: {
558
+ ...(eventPrNumber !== undefined ? { prNumber: eventPrNumber } : {}),
559
+ ...(eventPrUrl !== undefined ? { prUrl: eventPrUrl } : {}),
560
+ latestHeadSha: reviewHead,
561
+ },
659
562
  });
660
- if (!acquired) {
661
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
662
- phase: 'qa-acquire-failed',
663
- qaAgentId: qa.id,
664
- qaPhase,
665
- });
666
- return;
667
- }
668
- const ownAcquire = ownLockToken !== undefined ? { expectedLockToken: ownLockToken } : {};
669
- const { token: dispatchToken, armed } = await manager.rotateAndSetupPhaseSignal(transitioned.id, qa.id, ['pr-approved', 'pr-changes-requested']);
670
- if (!armed) {
671
- console.warn(`[EventHandler] pr.updated verdict watcher failed to arm for task=${transitioned.id} (${qaPhase}); rolling back recheck dispatch`);
672
- if (previousStatus === 'in_progress' || previousStatus === 'fixing'
673
- || (previousStatus === 'review' && taskBeforeTransition.reviewMode === 'git')) {
674
- const rolledBack = await manager.rollbackVerdictArmFailure(transitioned.id, {
675
- status: previousStatus,
676
- signalToken: taskBeforeTransition.signalToken,
677
- reviewHeadAnchorSha: taskBeforeTransition.reviewHeadAnchorSha,
678
- reviewDispatchedAt: taskBeforeTransition.reviewDispatchedAt,
679
- // git 回滚保留 durable pending 供 sweep 重试:计轮 intent 必须同持(transition 写入值),
680
- // 否则最终送达的首评按 stale intent 少计一轮
681
- reviewRoundPending: taskBeforeTransition.reviewMode === 'git'
682
- ? transitioned.reviewRoundPending
683
- : taskBeforeTransition.reviewRoundPending,
684
- ...(taskBeforeTransition.reviewMode === 'git'
685
- ? { restorePair: true, passToken: taskBeforeTransition.passToken, failToken: taskBeforeTransition.failToken }
686
- : {}),
687
- }, { expect: { status: 'review', signalToken: dispatchToken }, rearmSkipSnapshot });
688
- if (!rolledBack) {
689
- await releaseQaAfterSkippedRollback(manager, transitioned.id, qa.id, '[EventHandler] pr.updated arm-failure', ownAcquire);
690
- return;
691
- }
563
+ if (!begun?.task.reviewDispatch) {
564
+ const current = await manager.getTask(taskId);
565
+ if (current?.reviewDispatch?.phase === 'uncertain') {
566
+ const qaPhase = current.reviewDispatch.qaPhase;
567
+ await emitIntervention(bus, current.projectId, current.agentId, taskId, {
568
+ phase: 'git-review-dispatch-uncertain',
569
+ qaPhase,
570
+ headSha: reviewHead,
571
+ });
692
572
  }
693
- await manager.releaseAgentForTask(qa.id, transitioned.id, 'idle', ownAcquire).catch(err => {
694
- console.error(`[EventHandler] pr.updated releaseAgentForTask(QA=${qa.id}) after arm-failure rollback failed:`, err);
695
- return false;
696
- });
697
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
698
- phase: previousStatus === 'approved' || previousStatus === 'merge-ready' ? 'qa-recheck-arm-failed-after-approved-push' : 'qa-recheck-arm-failed',
699
- qaAgentId: qa.id,
700
- qaPhase,
701
- });
702
573
  return;
703
574
  }
704
- let started = false;
705
- let dispatchErr = null;
575
+ const qaPhase = begun.task.reviewDispatch.qaPhase;
706
576
  try {
707
- started = await manager.startSession(transitioned.id, qa.id, qaPhase, {
708
- dispatchPassToken: dispatchToken,
577
+ await manager.dispatchGitReviewLease(taskId, {
578
+ expectedGeneration: begun.task.reviewDispatch.generation,
709
579
  });
710
580
  }
711
581
  catch (err) {
712
- dispatchErr = err;
713
- console.error(`[EventHandler] pr.updated startSession(QA=${qa.id}, ${qaPhase}) hard error:`, err);
714
- }
715
- if (!started) {
716
- console.warn(`[EventHandler] pr.updated QA ${qaPhase} not started; previousStatus=${previousStatus}`);
717
- if (dispatchErr instanceof DispatchTerminalError) {
718
- await manager.failTaskForDispatchError(transitioned.id, qaPhase, qa.id, dispatchErr);
719
- }
720
- else if (dispatchErr instanceof EnsureSessionError && dispatchErr.partial.busyPending) {
721
- // 遇忙不是故障:pass 保持 armed、QA 保持绑定,pending 登记已在 startSession 内完成,交对账补派
722
- console.warn(`[EventHandler] pr.updated QA ${qaPhase} deferred for task=${transitioned.id}: QA pane busy; ` +
723
- `reconciler will redispatch when idle`);
724
- }
725
- else if (dispatchErr instanceof EnsureSessionError && dispatchErr.partial.handled) {
726
- // handled 不止代表可恢复派发 hold(checkout-cleanup-failed / dialog 等也走这里),指引须按实际相位分流
727
- const qaState = await manager.getAgentState(qa.id).catch((stateErr) => {
728
- console.warn(`[EventHandler] pr.updated could not read held QA state for ${qa.id}:`, stateErr);
729
- return null;
730
- });
731
- const heldPhase = qaState?.status === 'awaiting_human' ? qaState.awaitingPhase : undefined;
732
- const note = isRecoverableQaDispatchHold(qaState)
733
- ? 'QA dispatch was held awaiting human; the task keeps the rotated review pass. Resume the QA agent or POST /tasks/:id/review to redispatch.'
734
- : `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.`;
735
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
736
- phase: 'qa-dispatch-held-awaiting-human',
737
- qaAgentId: qa.id,
582
+ console.error(`[EventHandler] pr.updated git review dispatch failed for task=${taskId}:`, err);
583
+ if (err instanceof DispatchTerminalError && err.reason === 'ack_unknown') {
584
+ await emitIntervention(bus, begun.task.projectId, begun.task.agentId, taskId, {
585
+ phase: 'git-review-dispatch-uncertain',
738
586
  qaPhase,
739
- ...(heldPhase !== undefined ? { awaitingPhase: heldPhase } : {}),
740
- note,
587
+ error: err.message,
741
588
  });
742
589
  }
743
- else if (previousStatus === 'in_progress' || previousStatus === 'fixing'
744
- || (previousStatus === 'review' && taskBeforeTransition.reviewMode === 'git')) {
745
- // 仅回滚 status 会留下 transition 轮换后的 token,dev 在途 pass 的完成信号将永久失配;
746
- // startSession 窗口内 pass 可能已被并发接管,此时回滚与 QA 释放都要让位于接管方
747
- const rolledBack = await manager.rollbackVerdictArmFailure(transitioned.id, {
748
- status: previousStatus,
749
- signalToken: taskBeforeTransition.signalToken,
750
- reviewHeadAnchorSha: taskBeforeTransition.reviewHeadAnchorSha,
751
- reviewDispatchedAt: taskBeforeTransition.reviewDispatchedAt,
752
- // git 保留 durable pending 即保留计轮 intent(同 arm-failure 站点)
753
- reviewRoundPending: taskBeforeTransition.reviewMode === 'git'
754
- ? transitioned.reviewRoundPending
755
- : taskBeforeTransition.reviewRoundPending,
756
- ...(taskBeforeTransition.reviewMode === 'git'
757
- ? { restorePair: true, passToken: taskBeforeTransition.passToken, failToken: taskBeforeTransition.failToken }
758
- : {}),
759
- }, { expect: { status: 'review', signalToken: dispatchToken }, rearmSkipSnapshot });
760
- if (rolledBack) {
761
- await manager.releaseAgentForTask(qa.id, transitioned.id, 'idle', ownAcquire)
762
- .catch(err => {
763
- console.error(`[EventHandler] pr.updated releaseAgentForTask(QA=${qa.id}) after start-not-true failed:`, err);
764
- return false;
765
- });
766
- }
767
- else {
768
- await releaseQaAfterSkippedRollback(manager, transitioned.id, qa.id, '[EventHandler] pr.updated recheck', ownAcquire);
769
- }
590
+ else if (err instanceof DispatchTerminalError && begun.task.qaAgentId) {
591
+ await manager.failTaskForDispatchError(taskId, qaPhase, begun.task.qaAgentId, err, {
592
+ expectedReviewDispatch: begun.task.reviewDispatch,
593
+ });
770
594
  }
771
595
  else {
772
- await manager.releaseAgentForTask(qa.id, transitioned.id, 'idle', ownAcquire)
773
- .catch(err => {
774
- console.error(`[EventHandler] pr.updated releaseAgentForTask(QA=${qa.id}) after start-not-true failed:`, err);
775
- return false;
596
+ await emitIntervention(bus, begun.task.projectId, begun.task.agentId, taskId, {
597
+ phase: 'git-review-dispatch-failed',
598
+ qaPhase,
599
+ error: err instanceof Error ? err.message : String(err),
776
600
  });
777
- if (previousStatus === 'approved' || previousStatus === 'merge-ready') {
778
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
779
- phase: 'qa-recheck-failed-after-approved-push',
780
- qaAgentId: qa.id,
781
- });
782
- }
783
- else {
784
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
785
- phase: 'qa-recheck-failed-after-stop',
786
- qaAgentId: qa.id,
787
- });
788
- }
789
601
  }
790
- return;
791
- }
792
- if (taskBeforeTransition.reviewMode === 'git') {
793
- await manager.clearReviewDispatchPending(transitioned.id, dispatchToken);
794
- }
795
- // 送达确认后按本次 pass CAS 消费未计轮 intent:flag 本身已编码「哪些起点开新轮」
796
- // (review→review 的既计轮 recheck 无 flag 即天然 no-op),successor 换代则留给它自己消费
797
- await manager.consumeReviewRoundIntent(transitioned.id, dispatchToken);
798
- const ok = devAlreadyWaiting || (await manager.markAgentWaiting(agentId, transitioned.id));
799
- if (!ok) {
800
- 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 => {
801
- console.error(`[EventHandler] pr.updated markAwaitingHuman(QA=${qa.id}) after dev-wait-gate-fail:`, err);
802
- });
803
602
  }
804
603
  }
805
604
  async function redispatchReviewForStaleVerdict(manager, task) {
@@ -830,6 +629,7 @@ async function redispatchReviewForStaleVerdict(manager, task) {
830
629
  await manager.dispatchReviewToQa(task.id, {
831
630
  fromStatus: ['review'],
832
631
  bumpRound: task.reviewRoundPending === true,
632
+ expectPhase: task.phase,
833
633
  expectSignalToken: task.signalToken,
834
634
  ...(qaPhase !== undefined ? { qaPhase } : {}),
835
635
  });
@@ -842,157 +642,80 @@ async function redispatchReviewForStaleVerdict(manager, task) {
842
642
  return true;
843
643
  }
844
644
  async function handleReviewApproval(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch, gitVerdict) {
845
- if (!reviewedHeadSha) {
846
- await emitIntervention(bus, task.projectId, task.agentId, task.id, {
847
- phase: 'approval-reviewed-head-unavailable',
848
- });
849
- return;
850
- }
851
- // 'git' 载荷已经 engine 端 fresh prView 复核(spec §7 handler 收紧):二次解析的缓存回退
852
- // 链会重新打开 engine 已关闭的竞态,跳过
853
- const anchor = task.reviewMode === 'git'
854
- ? { headSha: currentHeadSha ?? reviewedHeadSha, source: 'payload-self' }
855
- : await resolveAuthoritativeHead(manager, task, { payloadCurrentHeadSha: currentHeadSha });
856
- // git 的 head 更新并入下方带 expectSignalToken 的 transition:入口校验后并发 push 换代时,
857
- // 旧 verdict 的裸 updateTask 会把 successor 的 latestHeadSha 覆写回旧值
858
- if (anchor.source === 'fetch' && anchor.headSha && task.latestHeadSha !== anchor.headSha) {
859
- await manager.updateTask(task.id, { latestHeadSha: anchor.headSha });
860
- }
861
- if (anchor.headSha && reviewedHeadSha !== anchor.headSha) {
645
+ if (reviewedHeadSha !== currentHeadSha) {
862
646
  await emitIntervention(bus, task.projectId, task.agentId, task.id, {
863
647
  phase: 'stale-approval-head-mismatch',
864
648
  reviewedHeadSha,
865
- currentHeadSha: anchor.headSha,
866
- source: anchor.source,
867
- ...('fetchError' in anchor && anchor.fetchError ? { fetchError: anchor.fetchError } : {}),
649
+ currentHeadSha,
650
+ source: 'payload-self',
868
651
  });
869
652
  return;
870
653
  }
871
- const provenanceFields = task.reviewMode === 'git' && gitVerdict !== undefined && task.failToken !== undefined
872
- ? {
873
- passProvenance: {
874
- sourceKey: gitVerdict.carrier.sourceKey,
875
- id: gitVerdict.carrier.id,
876
- bodyDigest: gitVerdict.carrier.bodyDigest,
877
- token: gitVerdict.token,
878
- failToken: task.failToken,
879
- anchorSha: reviewedHeadSha,
880
- },
881
- }
882
- : {};
883
- const result = await manager.transitionTaskStatus(task.id, 'approved', {
884
- fromStatus: ['review'],
885
- // 校验后提交前的并发换代(push/重派轮换 token+pair)使旧 APPROVE 失效(spec §7)
886
- ...(task.reviewMode === 'git' ? { expectSignalToken: task.signalToken } : {}),
887
- }, {
888
- ...prPatch,
889
- ...provenanceFields,
890
- ...gitHeadPatch(task, anchor.headSha),
891
- // 崩溃在「prompt 已送达、bump 未落盘」窗口时 pending 轮次由 verdict 消费补计
892
- reviewRound: Math.max(1, task.reviewRound + (task.reviewRoundPending === true ? 1 : 0)),
893
- reviewRoundPending: undefined,
894
- // verdict 即送达证据:durable pending 随 transition 原子消费,封死「送达后 verdict 抢先、
895
- // startSession 的 clear 因 status/token 已变而拒绝」留下 stale 凭据的竞态
896
- reviewDispatchPending: undefined,
654
+ if (task.failToken === undefined || task.signalToken === undefined)
655
+ return;
656
+ const provenance = {
657
+ sourceKey: gitVerdict.carrier.sourceKey,
658
+ id: gitVerdict.carrier.id,
659
+ bodyDigest: gitVerdict.carrier.bodyDigest,
660
+ token: gitVerdict.verdictToken,
661
+ failToken: task.failToken,
662
+ anchorSha: reviewedHeadSha,
663
+ };
664
+ const reviewedRound = Math.max(1, task.reviewRound + (task.reviewRoundPending === true ? 1 : 0));
665
+ const transitioned = await manager.approveGitReviewPass(task.id, {
666
+ expectedSignalToken: task.signalToken,
667
+ headSha: reviewedHeadSha,
668
+ reviewRound: reviewedRound,
669
+ ...(prPatch.prNumber !== undefined ? { prNumber: prPatch.prNumber } : {}),
670
+ ...(prPatch.prUrl !== undefined ? { prUrl: prPatch.prUrl } : {}),
671
+ provenance,
897
672
  });
898
- if (!result)
673
+ if (!transitioned)
899
674
  return;
900
- const { task: transitioned } = result;
901
675
  manager.stopPhaseSignalWatcher(transitioned.id);
902
676
  if (transitioned.qaAgentId) {
903
677
  await manager.releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle', { allowAwaitingHuman: true })
904
678
  .catch(err => console.error(`[EventHandler] APPROVE releaseAgentForTask(QA=${transitioned.qaAgentId}) failed:`, err));
905
679
  }
906
- await dispatchDevPostApproveCheck(bus, manager, transitioned, reviewedHeadSha);
680
+ await dispatchGitDevPostApproveCheck(bus, manager, transitioned, reviewedHeadSha);
907
681
  }
908
682
  function gitHeadPatch(task, headSha) {
909
- return task.reviewMode === 'git' && headSha !== undefined && task.latestHeadSha !== headSha
683
+ return headSha !== undefined && task.latestHeadSha !== headSha
910
684
  ? { latestHeadSha: headSha }
911
685
  : {};
912
686
  }
913
687
  async function handleReviewRequestChanges(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch) {
914
- const approvedCompletion = task.status === 'approved'
915
- ? await manager.getPostApproveCompletion(task.id)
916
- : null;
917
- const anchor = task.reviewMode === 'git'
918
- ? { headSha: currentHeadSha ?? reviewedHeadSha, source: 'payload-self' }
919
- : await resolveAuthoritativeHead(manager, task, {
920
- payloadCurrentHeadSha: currentHeadSha,
921
- legacyFallback: approvedCompletion?.approvedHeadSha,
922
- });
923
- if (anchor.source === 'fetch' && anchor.headSha && task.latestHeadSha !== anchor.headSha) {
924
- await manager.updateTask(task.id, { latestHeadSha: anchor.headSha });
925
- }
926
- if (reviewedHeadSha && anchor.headSha && reviewedHeadSha !== anchor.headSha) {
688
+ if (reviewedHeadSha !== currentHeadSha) {
927
689
  await emitIntervention(bus, task.projectId, task.agentId, task.id, {
928
690
  phase: 'stale-request-changes-head-mismatch',
929
691
  reviewedHeadSha,
930
- currentHeadSha: anchor.headSha,
931
- source: anchor.source,
932
- ...('fetchError' in anchor && anchor.fetchError ? { fetchError: anchor.fetchError } : {}),
692
+ currentHeadSha,
693
+ source: 'payload-self',
933
694
  });
934
695
  return;
935
696
  }
936
697
  const reviewedRound = Math.max(1, task.reviewRound + (task.reviewRoundPending === true ? 1 : 0));
937
698
  const nextRound = reviewedRound + 1;
938
- if (task.status === 'approved' && nextRound <= manager.getConfig().review.rounds) {
939
- const devState = await manager.getAgentState(task.agentId);
940
- const postApproveActive = await manager.getPostApproveCompletion(task.id);
941
- if (devState?.taskId === task.id && postApproveActive) {
942
- let revoked = await manager.revokePostApproveCompletion(task.id, 'request-changes', {
943
- expectedToken: postApproveActive.token,
944
- });
945
- if (!revoked) {
946
- // Token rotation within the episode still gets blocked; a successor approved episode (fresh head) must not.
947
- revoked = await manager.revokePostApproveCompletion(task.id, 'request-changes', {
948
- expectedHeadSha: postApproveActive.approvedHeadSha,
949
- });
950
- }
951
- await emitIntervention(bus, task.projectId, task.agentId, task.id, {
952
- phase: 'request-changes-during-post-approve',
953
- devAgentId: task.agentId,
954
- note: revoked
955
- ? '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.'
956
- : '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.',
957
- });
958
- return;
959
- }
960
- const ready = await manager
961
- .releaseAgentForTask(task.agentId, task.id, 'waiting')
962
- .catch(err => {
963
- console.error(`[EventHandler] REQUEST_CHANGES releaseAgentForTask(dev=${task.agentId}) before fix failed:`, err);
964
- return false;
965
- });
966
- if (!ready) {
967
- await emitIntervention(bus, task.projectId, task.agentId, task.id, {
968
- phase: 'post-approve-dev-wait-gate-failed-before-fix',
969
- devAgentId: task.agentId,
970
- });
971
- return;
972
- }
973
- }
974
699
  if (nextRound > manager.getConfig().review.rounds) {
975
700
  return handleMaxRounds(bus, manager, task, prPatch, reviewedRound);
976
701
  }
977
702
  const result = await manager.transitionTaskStatus(task.id, 'fixing', {
978
- fromStatus: ['review', 'approved', 'merge-ready'],
979
- // fail 方向同样按入口代际 CAS(spec §7):await 窗口内 push/重派换代后旧 RC 不得推进新轮
980
- ...(task.reviewMode === 'git' ? { expectSignalToken: task.signalToken } : {}),
703
+ fromStatus: ['review'],
704
+ expectSignalToken: task.signalToken,
981
705
  }, {
982
706
  ...prPatch,
983
- ...gitHeadPatch(task, anchor.headSha),
707
+ ...gitHeadPatch(task, currentHeadSha),
984
708
  reviewRound: nextRound,
985
709
  reviewRoundPending: undefined,
986
- // verdict 即送达证据(同 APPROVE):不消费会让 sweep 在 fixing 态按 stale pending 把任务拽回 review
987
- reviewDispatchPending: undefined,
710
+ reviewDispatch: undefined,
988
711
  fixDispatchedAt: new Date().toISOString(),
989
712
  });
990
713
  if (!result)
991
714
  return;
992
- const { task: transitioned, previousStatus } = result;
715
+ const { task: transitioned } = result;
993
716
  await manager.clearPostApproveCompletion(transitioned.id);
994
717
  manager.stopPhaseSignalWatcher(transitioned.id);
995
- if (previousStatus === 'review' && transitioned.qaAgentId) {
718
+ if (transitioned.qaAgentId) {
996
719
  const qaReleased = await manager
997
720
  .releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle', { allowAwaitingHuman: true })
998
721
  .catch(err => {
@@ -1014,7 +737,7 @@ async function handleReviewRequestChanges(bus, manager, task, reviewedHeadSha, c
1014
737
  });
1015
738
  return;
1016
739
  }
1017
- const { token: fixToken, armed } = await manager.rotateAndSetupPhaseSignal(transitioned.id, transitioned.agentId, 'pr-fixed');
740
+ const { token: fixToken, armed } = await manager.rotateAndSetupPhaseSignal(transitioned.id, transitioned.agentId, 'pr-fixed', { expectedStatus: 'fixing', expectedToken: transitioned.signalToken });
1018
741
  if (!armed) {
1019
742
  console.warn(`[EventHandler] REQUEST_CHANGES pr-fixed watcher failed to arm for task=${transitioned.id}; holding dev (not dispatching fix)`);
1020
743
  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 });
@@ -1069,9 +792,14 @@ async function handleReviewRequestChanges(bus, manager, task, reviewedHeadSha, c
1069
792
  }
1070
793
  async function handleMaxRounds(bus, manager, task, prPatch, reviewedRound) {
1071
794
  const result = await manager.transitionTaskStatus(task.id, 'max_rounds', {
1072
- fromStatus: ['review', 'approved', 'merge-ready'],
1073
- ...(task.reviewMode === 'git' ? { expectSignalToken: task.signalToken } : {}),
1074
- }, { ...prPatch, reviewRound: reviewedRound, reviewRoundPending: undefined, reviewDispatchPending: undefined });
795
+ fromStatus: ['review'],
796
+ expectSignalToken: task.signalToken,
797
+ }, {
798
+ ...prPatch,
799
+ reviewRound: reviewedRound,
800
+ reviewRoundPending: undefined,
801
+ reviewDispatch: undefined,
802
+ });
1075
803
  if (!result)
1076
804
  return;
1077
805
  const { task: transitioned } = result;
@@ -1108,18 +836,31 @@ export function registerEventHandlers(bus, manager) {
1108
836
  if (!event.taskId || !event.agentId)
1109
837
  return;
1110
838
  const taskAtEntry = await manager.getTask(event.taskId);
1111
- if (taskAtEntry?.reviewMode === 'server')
839
+ if (!taskAtEntry)
1112
840
  return;
1113
- if (taskAtEntry && isSpecStagePhase(taskAtEntry.phase)) {
841
+ if (taskAtEntry.reviewMode === 'server') {
842
+ const prNumber = event.data.prNumber;
843
+ if (!Number.isInteger(prNumber) || prNumber < 1)
844
+ return;
845
+ const adopted = await manager.adoptServerPr(event.taskId, {
846
+ prNumber: prNumber,
847
+ ...(typeof event.data.prUrl === 'string' ? { prUrl: event.data.prUrl } : {}),
848
+ ...(validHeadSha(event.data.headSha) !== undefined ? { headSha: validHeadSha(event.data.headSha) } : {}),
849
+ ...(typeof event.data.targetBranch === 'string' ? { targetBranch: event.data.targetBranch } : {}),
850
+ });
851
+ if (!adopted) {
852
+ await emitIntervention(bus, taskAtEntry.projectId, taskAtEntry.agentId, taskAtEntry.id, {
853
+ phase: 'server-pr-adoption-refused',
854
+ prNumber,
855
+ });
856
+ }
857
+ return;
858
+ }
859
+ if (isSpecStagePhase(taskAtEntry.phase)) {
1114
860
  console.warn(`[EventHandler] pr.created ignored for task ${event.taskId}: task in spec phase`);
1115
861
  return;
1116
862
  }
1117
- const isGit = taskAtEntry?.reviewMode === 'git';
1118
- // 迟到 publish 信号只做 actor reconciliation(spec §5.3 ④):poller 已抢先收编、任务不在
1119
- // 可收编态时,同 prNumber + reconciliation token 归位 replyActor,随后拆 dev 侧条目、不动 QA。
1120
- // fixing 也归 reconciliation:同 prNumber 的迟到信号在 fix 轮重收编会把任务拽回 review、
1121
- // 孤儿化 dev 的 fix 会话——非 in_progress 的同号信号只承载 actor 归位(spec §5.3 ④)
1122
- if (isGit && taskAtEntry && event.data.source === 'pane-signal'
863
+ if (event.data.source === 'pane-signal'
1123
864
  && taskAtEntry.prNumber !== undefined && taskAtEntry.prNumber === event.data.prNumber
1124
865
  && taskAtEntry.status !== 'in_progress') {
1125
866
  if (taskAtEntry.pendingPrSignalToken === undefined
@@ -1131,8 +872,6 @@ export function registerEventHandlers(bus, manager) {
1131
872
  ? decodeSignalActorId(event.data.actorB64)
1132
873
  : undefined;
1133
874
  if (actorId === undefined) {
1134
- // watcher 单发已被本事件消费:不重臂就永失 reconciliation 入口。skipSnapshot=true
1135
- // 只等待后续正确的五段信号,避免滚动区里同一条三段残文立即重触发成环
1136
875
  await manager.rearmGitReconciliationWatcher(taskAtEntry.id, { skipSnapshot: true });
1137
876
  await emitIntervention(bus, taskAtEntry.projectId, event.agentId, taskAtEntry.id, {
1138
877
  phase: 'git-pr-created-actor-missing',
@@ -1146,23 +885,20 @@ export function registerEventHandlers(bus, manager) {
1146
885
  pendingPrSignalToken: undefined,
1147
886
  });
1148
887
  }
1149
- // fixing 态同键 watcher 已是 pr-fixed:拆掉会吞掉本轮完成信号,reconciliation 条目只存在于 review
1150
888
  if (taskAtEntry.status === 'review') {
1151
889
  manager.stopPhaseSignalWatcherAgent(taskAtEntry.id, event.agentId);
1152
890
  }
1153
891
  return;
1154
892
  }
1155
- if (isGit && taskAtEntry && event.data.source !== 'pane-signal'
893
+ if (event.data.source !== 'pane-signal'
1156
894
  && taskAtEntry.prNumber !== undefined && taskAtEntry.prNumber === event.data.prNumber
1157
- && taskAtEntry.reviewDispatchPending !== true
1158
895
  && !['in_progress', 'fixing'].includes(taskAtEntry.status)) {
1159
- // in_progress/fixing 或派发未完成(pending)的同号重放走完整收编重试(至少一次)
1160
896
  return;
1161
897
  }
1162
898
  let paneVerifiedHeadSha;
1163
899
  let reconciledBranch;
1164
- let gitVerifiedTargetBranch;
1165
- if (isGit && event.data.source === 'pane-signal' && event.data.prNumber !== undefined) {
900
+ let verifiedTargetBranch;
901
+ if (event.data.source === 'pane-signal' && event.data.prNumber !== undefined) {
1166
902
  const prNumber = event.data.prNumber;
1167
903
  try {
1168
904
  let verify = await manager.platformVerifyPrBinding(event.taskId, prNumber);
@@ -1170,11 +906,11 @@ export function registerEventHandlers(bus, manager) {
1170
906
  const taskNow = await manager.getTask(event.taskId);
1171
907
  const prBranch = verify.prBranch;
1172
908
  if (taskNow) {
1173
- const ownPrefix = BRANCH_PREFIX + event.taskId;
1174
- const isForeignBxBranch = prBranch.startsWith(BRANCH_PREFIX) && prBranch !== ownPrefix;
909
+ const ownBranch = BRANCH_PREFIX + event.taskId;
910
+ const foreignBxBranch = prBranch.startsWith(BRANCH_PREFIX) && prBranch !== ownBranch;
1175
911
  const bound = await manager.findTaskByBranch(prBranch, taskNow.projectId);
1176
- if (!isForeignBxBranch && isValidBranchName(prBranch)
1177
- && (prBranch === ownPrefix || (!!bound && bound.id === taskNow.id))) {
912
+ if (!foreignBxBranch && isValidBranchName(prBranch)
913
+ && (prBranch === ownBranch || bound?.id === taskNow.id)) {
1178
914
  verify = await manager.platformVerifyPrBinding(event.taskId, prNumber, { branchOverride: prBranch });
1179
915
  if (verify.ok)
1180
916
  reconciledBranch = prBranch;
@@ -1196,13 +932,12 @@ export function registerEventHandlers(bus, manager) {
1196
932
  return;
1197
933
  }
1198
934
  paneVerifiedHeadSha = verify.headSha;
1199
- gitVerifiedTargetBranch = verify.targetBranch;
935
+ verifiedTargetBranch = verify.targetBranch;
1200
936
  }
1201
937
  catch (err) {
1202
938
  console.warn(`[EventHandler] pr.created: platformVerifyPrBinding failed for task ${event.taskId}:`, err);
1203
939
  const taskNow = await manager.getTask(event.taskId);
1204
940
  if (taskNow) {
1205
- // skipSnapshot 缺省 true:滚动区里刚消费的信号残文在平台持续失败时会形成无退避热循环
1206
941
  await reArmDevelopWatcher(manager, taskNow, event.agentId);
1207
942
  await emitIntervention(bus, taskNow.projectId, event.agentId, event.taskId, {
1208
943
  phase: 'pane-pr-created-verify-error',
@@ -1213,243 +948,127 @@ export function registerEventHandlers(bus, manager) {
1213
948
  return;
1214
949
  }
1215
950
  }
1216
- else if (event.data.source === 'pane-signal' && event.data.prNumber !== undefined) {
1217
- try {
1218
- const prNumber = event.data.prNumber;
1219
- let verified = await manager.verifyPaneSignalPrNumber(event.taskId, prNumber);
1220
- if (!verified) {
1221
- const taskNow = await manager.getTask(event.taskId);
1222
- if (taskNow) {
1223
- const prInfo = await manager.fetchPrHeadRef(prNumber, taskNow.projectId);
1224
- if (prInfo) {
1225
- const ownPrefix = BRANCH_PREFIX + event.taskId;
1226
- const isForeignBxBranch = prInfo.headRefName.startsWith(BRANCH_PREFIX)
1227
- && prInfo.headRefName !== ownPrefix;
1228
- const bound = await manager.findTaskByBranch(prInfo.headRefName, taskNow.projectId);
1229
- const isOwnBxBranch = prInfo.headRefName === ownPrefix;
1230
- const boundToThisTask = !!bound && bound.id === taskNow.id;
1231
- // Adopt only on deterministic ownership — our own bx/<taskId> namespace or a branch this
1232
- // task already owns. A dev-chosen branch that is neither is rejected to intervention, not
1233
- // adopted on an unverifiable claim (a mis-reported PR number must not rebind the task).
1234
- if (!isForeignBxBranch && isValidBranchName(prInfo.headRefName)
1235
- && (isOwnBxBranch || boundToThisTask)) {
1236
- reconciledBranch = prInfo.headRefName;
1237
- verified = prInfo;
1238
- }
1239
- }
1240
- }
1241
- }
1242
- if (!verified) {
1243
- const taskNow = await manager.getTask(event.taskId);
1244
- console.warn(`[EventHandler] pr.created REJECT pane prNumber=${event.data.prNumber} for task ${event.taskId} ` +
1245
- `(branch mismatch: task.branch=${taskNow?.branch})`);
1246
- if (taskNow) {
1247
- await reArmDevelopWatcher(manager, taskNow, event.agentId);
1248
- await emitIntervention(bus, taskNow.projectId, event.agentId, event.taskId, {
1249
- phase: 'pane-pr-created-branch-mismatch',
1250
- claimedPrNumber: event.data.prNumber,
1251
- taskBranch: taskNow.branch ?? '',
1252
- });
1253
- }
1254
- return;
1255
- }
1256
- paneVerifiedHeadSha = verified.headSha;
1257
- }
1258
- catch (err) {
1259
- console.warn(`[EventHandler] pr.created: verifyPaneSignalPrNumber failed for task ${event.taskId}:`, err);
1260
- const taskNow = await manager.getTask(event.taskId);
1261
- if (taskNow) {
1262
- await reArmDevelopWatcher(manager, taskNow, event.agentId, { skipSnapshot: false });
1263
- await emitIntervention(bus, taskNow.projectId, event.agentId, event.taskId, {
1264
- phase: 'pane-pr-created-verify-error',
1265
- claimedPrNumber: event.data.prNumber,
1266
- error: err instanceof Error ? err.message : String(err),
1267
- });
1268
- }
1269
- return;
1270
- }
1271
- }
1272
- const createdHeadSha = validHeadSha(event.data.headSha) ?? paneVerifiedHeadSha;
1273
- const taskBeforeTransition = await manager.getTask(event.taskId);
1274
- // A poller-sourced pr.created adopts by branch match alone (no pane verify). bx/<taskId> is unique
1275
- // to this task; a custom branch name can be reused, so for a custom-branch task only accept a PR it
1276
- // already tracks — a new custom-branch PR must arrive via the deterministic pane signal, not by the
1277
- // poller matching a stale/foreign open PR that happens to share the branch name.
951
+ const task = await manager.getTask(event.taskId);
952
+ if (!task || task.reviewMode !== 'git')
953
+ return;
1278
954
  if (event.data.source !== 'pane-signal'
1279
- && taskBeforeTransition
1280
- && taskBeforeTransition.branch !== BRANCH_PREFIX + event.taskId
1281
- && taskBeforeTransition.prNumber !== event.data.prNumber) {
955
+ && task.branch !== BRANCH_PREFIX + event.taskId
956
+ && task.prNumber !== event.data.prNumber) {
1282
957
  console.warn(`[EventHandler] pr.created: ignoring poller-sourced PR ${event.data.prNumber} on custom branch ` +
1283
958
  `for task ${event.taskId} (untracked; custom-branch PRs adopt via the pane signal)`);
1284
959
  return;
1285
960
  }
1286
- const gitAdoptionFields = {};
1287
- if (isGit) {
1288
- const target = event.data.source === 'pane-signal'
1289
- ? gitVerifiedTargetBranch
1290
- : (typeof event.data.targetBranch === 'string' ? event.data.targetBranch : undefined);
1291
- // baseBranch 快照一经写入不可变(spec §6):默认分支后续改名不影响既有任务
1292
- if (target !== undefined && taskBeforeTransition?.baseBranch === undefined) {
1293
- gitAdoptionFields.baseBranch = target;
1294
- }
1295
- if (event.data.source === 'pane-signal') {
1296
- const actorId = typeof event.data.actorB64 === 'string'
1297
- ? decodeSignalActorId(event.data.actorB64)
1298
- : undefined;
1299
- if (actorId !== undefined) {
1300
- gitAdoptionFields.replyActorId = actorId;
1301
- gitAdoptionFields.replyActorStatus = 'verified';
1302
- gitAdoptionFields.pendingPrSignalToken = undefined;
1303
- }
1304
- }
1305
- else if (typeof event.data.prAuthorId === 'string'
1306
- && taskBeforeTransition?.replyActorStatus !== 'verified') {
1307
- // 自动收编只落 provisional:PR 作者可能是对已推送 head 抢建 PR 的第三方(spec §5.3 ④)
1308
- gitAdoptionFields.replyActorId = event.data.prAuthorId;
1309
- gitAdoptionFields.replyActorStatus = 'provisional';
961
+ const adoption = {};
962
+ const target = event.data.source === 'pane-signal'
963
+ ? verifiedTargetBranch
964
+ : (typeof event.data.targetBranch === 'string' ? event.data.targetBranch : undefined);
965
+ if (target !== undefined && task.baseBranch === undefined)
966
+ adoption.baseBranch = target;
967
+ if (event.data.source === 'pane-signal') {
968
+ const actorId = typeof event.data.actorB64 === 'string'
969
+ ? decodeSignalActorId(event.data.actorB64)
970
+ : undefined;
971
+ if (actorId !== undefined) {
972
+ adoption.replyActorId = actorId;
973
+ adoption.replyActorStatus = 'verified';
974
+ adoption.pendingPrSignalToken = undefined;
1310
975
  }
1311
976
  }
1312
- const result = await manager.transitionTaskStatus(event.taskId, 'review', { fromStatus: ['in_progress', 'fixing'] }, {
1313
- ...(event.data.prNumber !== undefined ? { prNumber: event.data.prNumber } : {}),
1314
- ...(event.data.prUrl !== undefined ? { prUrl: event.data.prUrl } : {}),
1315
- ...(createdHeadSha ? { latestHeadSha: createdHeadSha, reviewHeadAnchorSha: createdHeadSha } : {}),
1316
- ...(reconciledBranch ? { branch: reconciledBranch } : {}),
1317
- ...gitAdoptionFields,
1318
- reviewDispatchedAt: new Date().toISOString(),
1319
- reviewRoundPending: taskBeforeTransition?.status === 'in_progress' ? true : undefined,
1320
- signalToken: createSignalToken(),
1321
- ...(isGit ? { ...manager.mintReviewTokenPair(), reviewDispatchPending: true } : {}),
977
+ else if (typeof event.data.prAuthorId === 'string' && task.replyActorStatus !== 'verified') {
978
+ adoption.replyActorId = event.data.prAuthorId;
979
+ adoption.replyActorStatus = 'provisional';
980
+ }
981
+ let reviewHead = validHeadSha(event.data.headSha) ?? paneVerifiedHeadSha;
982
+ if (reviewHead === undefined && typeof event.data.prNumber === 'number') {
983
+ const verified = await manager.platformVerifyPrBinding(event.taskId, event.data.prNumber);
984
+ if (verified.ok)
985
+ reviewHead = validHeadSha(verified.headSha);
986
+ }
987
+ if (reviewHead === undefined) {
988
+ await emitIntervention(bus, task.projectId, task.agentId, event.taskId, {
989
+ phase: 'git-review-head-unavailable',
990
+ });
991
+ return;
992
+ }
993
+ if (task.status !== 'in_progress' && task.status !== 'fixing')
994
+ return;
995
+ const begun = await manager.beginGitReviewPass(event.taskId, {
996
+ fromStatus: [task.status],
997
+ headSha: reviewHead,
998
+ bumpRound: task.status === 'in_progress',
999
+ qaPhase: 'review',
1000
+ expectPhase: task.phase,
1001
+ expectSignalToken: task.signalToken,
1002
+ patch: {
1003
+ ...(event.data.prNumber !== undefined ? { prNumber: event.data.prNumber } : {}),
1004
+ ...(event.data.prUrl !== undefined ? { prUrl: event.data.prUrl } : {}),
1005
+ latestHeadSha: reviewHead,
1006
+ ...(reconciledBranch ? { branch: reconciledBranch } : {}),
1007
+ ...adoption,
1008
+ },
1322
1009
  });
1323
- if (!result) {
1324
- console.warn(`[EventHandler] pr.created: cannot transition task ${event.taskId} (terminal or invalid from-state)`);
1010
+ if (!begun?.task.reviewDispatch) {
1011
+ console.warn(`[EventHandler] pr.created: cannot begin git review pass for task ${event.taskId}`);
1325
1012
  if (event.data.source === 'pane-signal') {
1326
- const taskNow = await manager.getTask(event.taskId);
1327
- if (taskNow && ['in_progress', 'fixing'].includes(taskNow.status)) {
1328
- await reArmDevelopWatcher(manager, taskNow, event.agentId);
1329
- await emitIntervention(bus, taskNow.projectId, event.agentId, event.taskId, {
1013
+ const current = await manager.getTask(event.taskId);
1014
+ if (current && ['in_progress', 'fixing'].includes(current.status)) {
1015
+ await reArmDevelopWatcher(manager, current, event.agentId);
1016
+ await emitIntervention(bus, current.projectId, event.agentId, event.taskId, {
1330
1017
  phase: 'pane-pr-created-transition-failed',
1331
1018
  claimedPrNumber: event.data.prNumber,
1332
- taskBranch: taskNow.branch ?? '',
1019
+ taskBranch: current.branch ?? '',
1333
1020
  });
1334
1021
  }
1335
1022
  }
1336
1023
  return;
1337
1024
  }
1338
- const { task: transitioned, previousStatus } = result;
1339
1025
  if (reconciledBranch) {
1340
- console.log(`[EventHandler] pr.created reconciled branch for task ${event.taskId}: ` +
1341
- `${taskBeforeTransition?.branch} → ${reconciledBranch}`);
1026
+ console.log(`[EventHandler] pr.created reconciled branch for task ${event.taskId}: ${task.branch} → ${reconciledBranch}`);
1342
1027
  }
1343
- const qaId = transitioned.qaAgentId;
1344
- if (!qaId) {
1345
- const ok = await manager.markAgentWaiting(event.agentId, transitioned.id);
1346
- if (!ok) {
1347
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
1028
+ if (!begun.task.qaAgentId) {
1029
+ const parked = await manager.markAgentWaiting(event.agentId, begun.task.id);
1030
+ if (!parked) {
1031
+ await emitIntervention(bus, begun.task.projectId, begun.task.agentId, begun.task.id, {
1348
1032
  phase: 'dev-wait-gate-failed-no-qa',
1349
1033
  });
1350
1034
  }
1351
- return;
1352
- }
1353
- const qa = manager.getAgentConfig(qaId);
1354
- if (!qa || qa.role !== 'qa') {
1355
- await manager.markAgentWaiting(event.agentId, transitioned.id).catch(() => false);
1356
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
1357
- phase: 'qa-config-missing',
1358
- qaAgentId: qaId,
1359
- });
1360
- return;
1361
- }
1362
- let ownLockToken;
1363
- const acquired = await manager.acquireAgentForTask(qa.id, transitioned.id, 'review', {
1364
- onAcquired: (lockToken) => { ownLockToken = lockToken; },
1365
- });
1366
- if (!acquired) {
1367
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
1368
- phase: 'qa-acquire-failed',
1369
- qaAgentId: qa.id,
1370
- });
1371
- return;
1372
- }
1373
- const ownAcquire = ownLockToken !== undefined ? { expectedLockToken: ownLockToken } : {};
1374
- const { token: dispatchToken, armed } = await manager.rotateAndSetupPhaseSignal(transitioned.id, qa.id, ['pr-approved', 'pr-changes-requested']);
1375
- if (!armed) {
1376
- console.warn(`[EventHandler] pr.created verdict watcher failed to arm for task=${transitioned.id}; rolling back review dispatch`);
1377
- const rolledBack = await manager.rollbackVerdictArmFailure(transitioned.id, {
1378
- status: previousStatus,
1379
- signalToken: taskBeforeTransition?.signalToken,
1380
- reviewHeadAnchorSha: taskBeforeTransition?.reviewHeadAnchorSha,
1381
- reviewDispatchedAt: taskBeforeTransition?.reviewDispatchedAt,
1382
- // git 保留 durable pending 即保留计轮 intent(同 pr.updated 回滚站点)
1383
- reviewRoundPending: taskBeforeTransition?.reviewMode === 'git'
1384
- ? transitioned.reviewRoundPending
1385
- : taskBeforeTransition?.reviewRoundPending,
1386
- ...(taskBeforeTransition?.reviewMode === 'git'
1387
- ? { restorePair: true, passToken: taskBeforeTransition.passToken, failToken: taskBeforeTransition.failToken }
1388
- : {}),
1389
- }, { expect: { status: 'review', signalToken: dispatchToken } });
1390
- if (!rolledBack) {
1391
- await releaseQaAfterSkippedRollback(manager, transitioned.id, qa.id, '[EventHandler] pr.created arm-failure', ownAcquire);
1392
- return;
1393
- }
1394
- await manager.releaseAgentForTask(qa.id, transitioned.id, 'idle', ownAcquire).catch(err => {
1395
- console.error(`[EventHandler] pr.created releaseAgentForTask(QA=${qa.id}) after arm-failure rollback failed:`, err);
1396
- return false;
1397
- });
1398
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
1399
- phase: 'qa-review-arm-failed',
1400
- qaAgentId: qa.id,
1035
+ await emitIntervention(bus, begun.task.projectId, begun.task.agentId, begun.task.id, {
1036
+ phase: 'git-review-no-qa-partner',
1401
1037
  });
1402
1038
  return;
1403
1039
  }
1404
- let started = false;
1405
- let dispatchErr = null;
1406
1040
  try {
1407
- started = await manager.startSession(transitioned.id, qa.id, 'review', {
1408
- dispatchPassToken: dispatchToken,
1041
+ await manager.dispatchGitReviewLease(begun.task.id, {
1042
+ expectedGeneration: begun.task.reviewDispatch.generation,
1409
1043
  });
1410
1044
  }
1411
1045
  catch (err) {
1412
- dispatchErr = err;
1413
- console.error(`[EventHandler] pr.created startSession(QA=${qa.id}) hard error:`, err);
1414
- }
1415
- if (!started) {
1416
- console.warn(`[EventHandler] pr.created QA session not started for task=${transitioned.id}; ` +
1417
- `task stays in 'review' but no active QA — emitting human.intervention`);
1418
- if (dispatchErr instanceof DispatchTerminalError) {
1419
- await manager.failTaskForDispatchError(transitioned.id, 'review', qa.id, dispatchErr);
1046
+ console.error(`[EventHandler] pr.created git review dispatch failed for task=${begun.task.id}:`, err);
1047
+ if (err instanceof DispatchTerminalError && err.reason === 'ack_unknown') {
1048
+ await emitIntervention(bus, begun.task.projectId, begun.task.agentId, begun.task.id, {
1049
+ phase: 'git-review-dispatch-uncertain',
1050
+ qaPhase: 'review',
1051
+ error: err.message,
1052
+ });
1420
1053
  }
1421
- else if (dispatchErr instanceof EnsureSessionError && dispatchErr.partial.handled) {
1054
+ else if (err instanceof DispatchTerminalError) {
1055
+ await manager.failTaskForDispatchError(begun.task.id, 'review', begun.task.qaAgentId, err, {
1056
+ expectedReviewDispatch: begun.task.reviewDispatch,
1057
+ });
1422
1058
  }
1423
1059
  else {
1424
- await manager.releaseAgentForTask(qa.id, transitioned.id, 'idle', ownAcquire)
1425
- .catch(err => {
1426
- console.error(`[EventHandler] pr.created releaseAgentForTask(QA=${qa.id}) after start-not-true failed:`, err);
1427
- return false;
1428
- });
1429
- await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
1430
- phase: 'qa-review-start-failed',
1431
- qaAgentId: qa.id,
1060
+ await emitIntervention(bus, begun.task.projectId, begun.task.agentId, begun.task.id, {
1061
+ phase: 'git-review-dispatch-failed',
1062
+ qaPhase: 'review',
1063
+ error: err instanceof Error ? err.message : String(err),
1432
1064
  });
1433
1065
  }
1434
- return;
1435
- }
1436
- if (taskBeforeTransition?.reviewMode === 'git') {
1437
- await manager.clearReviewDispatchPending(transitioned.id, dispatchToken);
1438
- }
1439
- await manager.consumeReviewRoundIntent(transitioned.id, dispatchToken);
1440
- const ok = await manager.markAgentWaiting(event.agentId, transitioned.id);
1441
- if (!ok) {
1442
- 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 => {
1443
- console.error(`[EventHandler] pr.created markAwaitingHuman(QA=${qa.id}) after dev-wait-gate-fail:`, err);
1444
- });
1445
1066
  }
1446
1067
  });
1447
1068
  bus.on('pr.updated', async (event) => {
1448
- if (!event.taskId || !event.agentId)
1069
+ if (!event.taskId)
1449
1070
  return;
1450
1071
  const taskAtEntry = await manager.getTask(event.taskId);
1451
- if (taskAtEntry?.reviewMode === 'server')
1452
- return;
1453
1072
  const eventKind = event.data.kind;
1454
1073
  if (taskAtEntry && isSpecStagePhase(taskAtEntry.phase)) {
1455
1074
  console.warn(`[EventHandler] pr.updated (kind=${eventKind ?? 'push'}) ignored for task ${event.taskId}: task in spec phase`);
@@ -1457,7 +1076,6 @@ export function registerEventHandlers(bus, manager) {
1457
1076
  }
1458
1077
  if (eventKind === 'pr-merge-ready')
1459
1078
  return handlePrMergeReady(bus, manager, event);
1460
- // closed-unmerged/reopened 只由 PlatformPoller 对 'git' 任务合成(spec §6 outbox 协议)
1461
1079
  if (eventKind === 'closed-unmerged' || eventKind === 'reopened') {
1462
1080
  const prNumber = event.data.prNumber;
1463
1081
  if (typeof prNumber !== 'number')
@@ -1466,13 +1084,18 @@ export function registerEventHandlers(bus, manager) {
1466
1084
  await manager.recordClosedUnmergedAnchor(event.taskId, prNumber);
1467
1085
  else
1468
1086
  await manager.clearClosedUnmergedAnchor(event.taskId, prNumber);
1469
- // 无条件投递:滞留的 outbox 条目借重复观察重试,而不是等到重启(spec §6 至少一次)
1470
1087
  await manager.deliverTaskOutbox(event.taskId);
1471
1088
  return;
1472
1089
  }
1473
- if (eventKind !== 'push' && eventKind !== undefined)
1090
+ if (!event.agentId)
1091
+ return;
1092
+ if (taskAtEntry?.reviewMode === 'server')
1093
+ return;
1094
+ if (eventKind === 'comment' || eventKind === 'review-comment') {
1474
1095
  return handlePrFeedback(bus, manager, event);
1475
- return handlePrCodePush(bus, manager, event);
1096
+ }
1097
+ if (eventKind === 'push' || eventKind === undefined)
1098
+ return handlePrCodePush(bus, manager, event);
1476
1099
  });
1477
1100
  bus.on('pr.merged', async (event) => {
1478
1101
  if (!event.taskId)
@@ -1526,36 +1149,55 @@ export function registerEventHandlers(bus, manager) {
1526
1149
  bus.on('review.submitted', async (event) => {
1527
1150
  if (!event.taskId)
1528
1151
  return;
1529
- const action = event.data.action;
1530
- let task = await manager.getTask(event.taskId);
1152
+ const task = await manager.getTask(event.taskId);
1531
1153
  if (!task)
1532
1154
  return;
1533
1155
  if (task.reviewMode === 'server')
1534
1156
  return;
1535
- // 'git' 唯一裁决授权是评论配对令牌(spec §7):pane verdict 恒拒绝,命中只记日志
1536
- if (task.reviewMode === 'git' && event.data.source === 'pane-signal') {
1537
- console.log(`[EventHandler] review.submitted pane verdict ignored for git task ${task.id} (comment-token protocol is the sole authority)`);
1157
+ if (event.data.source === 'pane-signal') {
1158
+ console.log(`[EventHandler] review.submitted pane verdict ignored for task ${task.id} (comment-token protocol is the sole authority)`);
1538
1159
  return;
1539
1160
  }
1540
- {
1541
- const terminalQaId = task.qaAgentId;
1542
- if (terminalQaId && TASK_TERMINAL_STATUS_SET.has(task.status)) {
1543
- const qaState = await manager.getAgentState(terminalQaId);
1544
- if (qaState?.taskId === task.id) {
1545
- const terminalTask = task;
1546
- await manager
1547
- .releaseAgentForTask(terminalQaId, terminalTask.id, 'idle')
1548
- .catch(err => console.error(`[EventHandler] terminal-task manual review releaseAgentForTask(QA=${terminalQaId}) failed:`, err));
1549
- await emitIntervention(bus, terminalTask.projectId, terminalTask.agentId, terminalTask.id, {
1550
- phase: 'manual-review-on-terminal-task-completed',
1551
- qaAgentId: terminalQaId,
1552
- taskStatus: terminalTask.status,
1553
- reviewAction: action,
1554
- note: 'Manual QA review on a terminal task finished. Task state untouched; QA released. Operator owns any follow-up.',
1555
- });
1556
- return;
1557
- }
1161
+ const verdictTask = task;
1162
+ const rejectGitPayload = async (err) => {
1163
+ await emitIntervention(bus, verdictTask.projectId, verdictTask.agentId, verdictTask.id, {
1164
+ phase: 'git-verdict-payload-invalid',
1165
+ error: err.message,
1166
+ ...(typeof event.data.source === 'string' ? { source: event.data.source } : {}),
1167
+ });
1168
+ };
1169
+ let gitPayload;
1170
+ try {
1171
+ gitPayload = parseGitVerdictPayload(event.data);
1172
+ }
1173
+ catch (err) {
1174
+ if (!(err instanceof GitVerdictPayloadError))
1175
+ throw err;
1176
+ await rejectGitPayload(err);
1177
+ return;
1178
+ }
1179
+ const action = gitPayload.action;
1180
+ if (task.signalToken === undefined || gitPayload.reviewPassToken !== task.signalToken) {
1181
+ if (task.status !== 'review') {
1182
+ await rejectGitPayload(new GitVerdictPayloadError('review pass token is not current'));
1183
+ return;
1558
1184
  }
1185
+ if (!(await redispatchReviewForStaleVerdict(manager, task))) {
1186
+ await emitIntervention(bus, task.projectId, task.agentId, task.id, {
1187
+ phase: 'stale-verdict-wrong-pass',
1188
+ action,
1189
+ verdictPassToken: gitPayload.reviewPassToken,
1190
+ currentToken: task.signalToken,
1191
+ source: 'poller',
1192
+ });
1193
+ }
1194
+ return;
1195
+ }
1196
+ const expectedVerdictToken = action === 'APPROVE' ? task.passToken : task.failToken;
1197
+ if (expectedVerdictToken === undefined || gitPayload.verdictToken !== expectedVerdictToken
1198
+ || task.failToken === undefined) {
1199
+ await rejectGitPayload(new GitVerdictPayloadError('verdict token is not current'));
1200
+ return;
1559
1201
  }
1560
1202
  if (isSpecStagePhase(task.phase)) {
1561
1203
  console.warn(`[EventHandler] review.submitted (action=${action}) ignored for task ${task.id}: task in spec phase`);
@@ -1578,72 +1220,18 @@ export function registerEventHandlers(bus, manager) {
1578
1220
  return;
1579
1221
  }
1580
1222
  }
1581
- const verdictPassToken = (typeof event.data.reviewPassToken === 'string' ? event.data.reviewPassToken : undefined)
1582
- ?? (typeof event.data.token === 'string' ? event.data.token : undefined);
1583
- if (verdictPassToken && task.signalToken && verdictPassToken !== task.signalToken) {
1584
- if (!(await redispatchReviewForStaleVerdict(manager, task))) {
1585
- await emitIntervention(bus, task.projectId, task.agentId, task.id, {
1586
- phase: 'stale-verdict-wrong-pass',
1587
- action,
1588
- verdictPassToken,
1589
- currentToken: task.signalToken,
1590
- source: event.data.source === 'pane-signal' ? 'pane-signal' : 'poller',
1591
- });
1592
- }
1593
- return;
1594
- }
1595
- const eventPrNumber = event.data.prNumber;
1596
- const eventPrUrl = event.data.prUrl;
1597
1223
  const prPatch = {
1598
- ...(eventPrNumber !== undefined && eventPrNumber !== task.prNumber ? { prNumber: eventPrNumber } : {}),
1599
- ...(eventPrUrl !== undefined && eventPrUrl !== task.prUrl ? { prUrl: eventPrUrl } : {}),
1224
+ ...(gitPayload.prNumber !== task.prNumber ? { prNumber: gitPayload.prNumber } : {}),
1225
+ ...(gitPayload.prUrl !== task.prUrl ? { prUrl: gitPayload.prUrl } : {}),
1600
1226
  };
1601
- const isPaneSignal = event.data.source === 'pane-signal';
1602
- const reviewedHeadSha = validHeadSha(event.data.headSha)
1603
- ?? (isPaneSignal ? validHeadSha(task.reviewHeadAnchorSha) : undefined);
1604
- const currentHeadSha = validHeadSha(event.data.currentHeadSha)
1605
- ?? (isPaneSignal ? validHeadSha(task.reviewHeadAnchorSha) : undefined);
1606
- if ((task.status === 'in_progress' || task.status === 'fixing') && eventPrNumber !== undefined) {
1607
- const catchup = await manager.transitionTaskStatus(event.taskId, 'review', { fromStatus: ['in_progress', 'fixing'] }, prPatch);
1608
- if (catchup) {
1609
- task = catchup.task;
1610
- if (task.agentId) {
1611
- const ok = await manager.markAgentWaiting(task.agentId, task.id);
1612
- if (!ok) {
1613
- await emitIntervention(bus, task.projectId, task.agentId, task.id, {
1614
- phase: 'dev-wait-gate-failed-late-catchup',
1615
- });
1616
- }
1617
- }
1618
- }
1619
- else {
1620
- return;
1621
- }
1622
- }
1623
- const carrier = event.data.verdictCarrier;
1624
- const gitVerdict = task.reviewMode === 'git'
1625
- && typeof event.data.verdictToken === 'string' && carrier !== undefined
1626
- ? { token: event.data.verdictToken, carrier }
1627
- : undefined;
1628
- // 'git' 的裁决只信任 engine 载荷:reviewPassToken(评审轮绑定)与 verdict 令牌(评论对绑定)
1629
- // 都必须在场且属当前轮——缺失即绕过 signalToken 门的 malformed 事件,fail closed
1630
- if (task.reviewMode === 'git' && (action === 'APPROVE' || action === 'REQUEST_CHANGES')) {
1631
- const expectedToken = action === 'APPROVE' ? task.passToken : task.failToken;
1632
- const roundToken = typeof event.data.reviewPassToken === 'string' ? event.data.reviewPassToken : undefined;
1633
- if (gitVerdict === undefined || expectedToken === undefined || task.failToken === undefined
1634
- || gitVerdict.token !== expectedToken
1635
- || roundToken === undefined || task.signalToken === undefined || roundToken !== task.signalToken) {
1636
- console.warn(`[EventHandler] review.submitted ${action} rejected for git task ${task.id}: verdict payload missing or not the current round token`);
1637
- await emitIntervention(bus, task.projectId, task.agentId, task.id, {
1638
- phase: 'git-verdict-payload-invalid',
1639
- action,
1640
- hasCarrier: gitVerdict !== undefined,
1641
- });
1642
- return;
1643
- }
1227
+ const reviewedHeadSha = gitPayload.headSha;
1228
+ const currentHeadSha = gitPayload.currentHeadSha;
1229
+ if (task.status !== 'review') {
1230
+ await rejectGitPayload(new GitVerdictPayloadError(`task is ${task.status}, not review`));
1231
+ return;
1644
1232
  }
1645
1233
  if (action === 'APPROVE') {
1646
- return handleReviewApproval(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch, gitVerdict);
1234
+ return handleReviewApproval(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch, gitPayload);
1647
1235
  }
1648
1236
  if (action === 'REQUEST_CHANGES') {
1649
1237
  return handleReviewRequestChanges(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch);
@@ -1658,14 +1246,12 @@ export function registerEventHandlers(bus, manager) {
1658
1246
  const { projectId, agentId } = task;
1659
1247
  const stayFixing = async (data) => {
1660
1248
  await emitIntervention(bus, projectId, agentId, task.id, data);
1661
- // 同代 fence:平台查询等待期间任务可能已被手动重派进 review——旧失败分支不得覆盖
1662
- // successor 的 QA/reconciliation watcher
1663
1249
  const fresh = await manager.getTask(task.id);
1664
1250
  if (!fresh || fresh.status !== 'fixing' || fresh.signalToken !== task.signalToken)
1665
1251
  return;
1666
1252
  await manager.setupPhaseSignal(task.id, agentId, 'pr-fixed', {
1667
1253
  skipSnapshot: true,
1668
- ...(fresh.reviewMode === 'git' ? { replaceScope: 'agent' } : {}),
1254
+ replaceScope: 'agent',
1669
1255
  });
1670
1256
  };
1671
1257
  const token = typeof event.data.token === 'string' ? event.data.token : undefined;
@@ -1677,92 +1263,52 @@ export function registerEventHandlers(bus, manager) {
1677
1263
  });
1678
1264
  return;
1679
1265
  }
1680
- let headSha;
1681
- if (task.reviewMode === 'git') {
1682
- if (task.prNumber === undefined) {
1683
- await stayFixing({ phase: 'fix-verify-no-anchor' });
1684
- return;
1685
- }
1686
- let verify;
1266
+ if (task.prNumber === undefined) {
1267
+ await stayFixing({ phase: 'fix-verify-no-anchor' });
1268
+ return;
1269
+ }
1270
+ let verify;
1271
+ try {
1272
+ verify = await manager.platformVerifyPrBinding(task.id, task.prNumber);
1273
+ }
1274
+ catch (err) {
1275
+ await stayFixing({
1276
+ phase: 'fix-verify-head-fetch-failed',
1277
+ error: err instanceof Error ? err.message : String(err),
1278
+ });
1279
+ return;
1280
+ }
1281
+ if (!verify.ok) {
1282
+ // PR 在修复期间被关闭/转 draft/retarget:推进 recheck 只会造出无裁决入口的评审轮
1283
+ await stayFixing({ phase: 'fix-verify-binding-mismatch', reason: verify.reason });
1284
+ return;
1285
+ }
1286
+ const headSha = verify.headSha;
1287
+ if (!task.reviewHeadAnchorSha) {
1288
+ await stayFixing({ phase: 'fix-verify-no-anchor', headSha });
1289
+ return;
1290
+ }
1291
+ if (headSha === task.reviewHeadAnchorSha) {
1292
+ let pendingCount;
1687
1293
  try {
1688
- verify = await manager.platformVerifyPrBinding(task.id, task.prNumber);
1294
+ pendingCount = (await manager.platformPendingFeedback(task.id)).pending.size;
1689
1295
  }
1690
1296
  catch (err) {
1691
1297
  await stayFixing({
1692
- phase: 'fix-verify-head-fetch-failed',
1298
+ phase: 'fix-verify-replies-fetch-failed',
1693
1299
  error: err instanceof Error ? err.message : String(err),
1694
1300
  });
1695
1301
  return;
1696
1302
  }
1697
- if (!verify.ok) {
1698
- // PR 在修复期间被关闭/转 draft/retarget:推进 recheck 只会造出无裁决入口的评审轮
1699
- await stayFixing({ phase: 'fix-verify-binding-mismatch', reason: verify.reason });
1700
- return;
1701
- }
1702
- headSha = verify.headSha;
1703
- }
1704
- else {
1705
- try {
1706
- headSha = await fetchVerifiedHeadSha(manager, task.id);
1707
- }
1708
- catch (err) {
1303
+ if (pendingCount > 0) {
1709
1304
  await stayFixing({
1710
- phase: 'fix-verify-head-fetch-failed',
1711
- error: err instanceof Error ? err.message : String(err),
1305
+ phase: 'fix-no-op-pending-feedback',
1306
+ pendingCount,
1307
+ note: 'Dev emitted pr-fixed on an unchanged head while feedback revisions still lack a valid ack. Inspect the PR replies.',
1712
1308
  });
1713
1309
  return;
1714
1310
  }
1715
1311
  }
1716
- if (!task.reviewHeadAnchorSha) {
1717
- await stayFixing({ phase: 'fix-verify-no-anchor', headSha });
1718
- return;
1719
- }
1720
- if (headSha === task.reviewHeadAnchorSha) {
1721
- if (task.reviewMode === 'git') {
1722
- // no-op fix 通过条件 = 无未 ack 的待处理反馈 revision 且全源拉取成功(spec §6/§8);
1723
- // 墙钟比较与「任意评论计回复」在 'git' 均废除
1724
- let pendingCount;
1725
- try {
1726
- pendingCount = (await manager.platformPendingFeedback(task.id)).pending.size;
1727
- }
1728
- catch (err) {
1729
- await stayFixing({
1730
- phase: 'fix-verify-replies-fetch-failed',
1731
- error: err instanceof Error ? err.message : String(err),
1732
- });
1733
- return;
1734
- }
1735
- if (pendingCount > 0) {
1736
- await stayFixing({
1737
- phase: 'fix-no-op-pending-feedback',
1738
- pendingCount,
1739
- note: 'Dev emitted pr-fixed on an unchanged head while feedback revisions still lack a valid ack. Inspect the PR replies.',
1740
- });
1741
- return;
1742
- }
1743
- }
1744
- else {
1745
- const since = task.fixDispatchedAt ?? task.reviewDispatchedAt;
1746
- let hasReplies;
1747
- try {
1748
- hasReplies = since ? await manager.prHasDevReplySince(task.id, since) : false;
1749
- }
1750
- catch (err) {
1751
- await stayFixing({
1752
- phase: 'fix-verify-replies-fetch-failed',
1753
- error: err instanceof Error ? err.message : String(err),
1754
- });
1755
- return;
1756
- }
1757
- if (!hasReplies) {
1758
- await stayFixing({
1759
- phase: 'fix-no-op-no-commit-no-reply',
1760
- note: 'Dev emitted pr-fixed but pushed no commit and left no reply on the PR — the fixing round changed nothing. Inspect the pane.',
1761
- });
1762
- return;
1763
- }
1764
- }
1765
- }
1766
1312
  // head fetch / 回复检查是秒级网络等待,期间真 push / verdict 可能已换代 pass;旧信号不得再驱动派发
1767
1313
  const preEmit = await manager.getTask(task.id);
1768
1314
  if (!preEmit || preEmit.status !== 'fixing' || preEmit.signalToken !== token)