baxian 1.2.61 → 2.0.0

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 (178) 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 -2
  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 +5 -44
  17. package/dist/agent/dispatch-reconciler.js.map +1 -1
  18. package/dist/agent/manager.d.ts +175 -82
  19. package/dist/agent/manager.d.ts.map +1 -1
  20. package/dist/agent/manager.js +2453 -1447
  21. package/dist/agent/manager.js.map +1 -1
  22. package/dist/agent/phase-signal-watcher.d.ts +5 -1
  23. package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
  24. package/dist/agent/phase-signal-watcher.js +22 -11
  25. package/dist/agent/phase-signal-watcher.js.map +1 -1
  26. package/dist/agent/phase-signal.d.ts +2 -7
  27. package/dist/agent/phase-signal.d.ts.map +1 -1
  28. package/dist/agent/phase-signal.js +4 -4
  29. package/dist/agent/phase-signal.js.map +1 -1
  30. package/dist/agent/preflight.d.ts +4 -1
  31. package/dist/agent/preflight.d.ts.map +1 -1
  32. package/dist/agent/preflight.js +46 -45
  33. package/dist/agent/preflight.js.map +1 -1
  34. package/dist/agent/prompt.d.ts +8 -0
  35. package/dist/agent/prompt.d.ts.map +1 -1
  36. package/dist/agent/prompt.js +30 -13
  37. package/dist/agent/prompt.js.map +1 -1
  38. package/dist/agent/repo-store.d.ts +4 -0
  39. package/dist/agent/repo-store.d.ts.map +1 -1
  40. package/dist/agent/repo-store.js +37 -8
  41. package/dist/agent/repo-store.js.map +1 -1
  42. package/dist/agent/review-transport.d.ts +20 -1
  43. package/dist/agent/review-transport.d.ts.map +1 -1
  44. package/dist/agent/review-transport.js +68 -20
  45. package/dist/agent/review-transport.js.map +1 -1
  46. package/dist/api/config.d.ts.map +1 -1
  47. package/dist/api/config.js +12 -11
  48. package/dist/api/config.js.map +1 -1
  49. package/dist/api/hosts.d.ts.map +1 -1
  50. package/dist/api/hosts.js +3 -2
  51. package/dist/api/hosts.js.map +1 -1
  52. package/dist/api/platform-guard.d.ts +10 -2
  53. package/dist/api/platform-guard.d.ts.map +1 -1
  54. package/dist/api/platform-guard.js +112 -10
  55. package/dist/api/platform-guard.js.map +1 -1
  56. package/dist/api/projects.d.ts.map +1 -1
  57. package/dist/api/projects.js +34 -9
  58. package/dist/api/projects.js.map +1 -1
  59. package/dist/api/tasks.d.ts.map +1 -1
  60. package/dist/api/tasks.js +21 -36
  61. package/dist/api/tasks.js.map +1 -1
  62. package/dist/app.d.ts +4 -4
  63. package/dist/app.d.ts.map +1 -1
  64. package/dist/config/hot-reload.d.ts +11 -2
  65. package/dist/config/hot-reload.d.ts.map +1 -1
  66. package/dist/config/hot-reload.js +43 -8
  67. package/dist/config/hot-reload.js.map +1 -1
  68. package/dist/config/loader.js +1 -1
  69. package/dist/config/loader.js.map +1 -1
  70. package/dist/config/validator.d.ts +3 -1
  71. package/dist/config/validator.d.ts.map +1 -1
  72. package/dist/config/validator.js +53 -31
  73. package/dist/config/validator.js.map +1 -1
  74. package/dist/event/handlers.d.ts.map +1 -1
  75. package/dist/event/handlers.js +616 -1067
  76. package/dist/event/handlers.js.map +1 -1
  77. package/dist/event/server-handlers.d.ts.map +1 -1
  78. package/dist/event/server-handlers.js +667 -440
  79. package/dist/event/server-handlers.js.map +1 -1
  80. package/dist/index.d.ts +4 -1
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +54 -79
  83. package/dist/index.js.map +1 -1
  84. package/dist/platform/command-renderer.d.ts +1 -0
  85. package/dist/platform/command-renderer.d.ts.map +1 -1
  86. package/dist/platform/command-renderer.js +16 -2
  87. package/dist/platform/command-renderer.js.map +1 -1
  88. package/dist/platform/comment-cursor.d.ts +9 -0
  89. package/dist/platform/comment-cursor.d.ts.map +1 -1
  90. package/dist/platform/comment-cursor.js +63 -5
  91. package/dist/platform/comment-cursor.js.map +1 -1
  92. package/dist/platform/driver-host.d.ts.map +1 -1
  93. package/dist/platform/driver-host.js +0 -2
  94. package/dist/platform/driver-host.js.map +1 -1
  95. package/dist/platform/driver-spec.d.ts.map +1 -1
  96. package/dist/platform/driver-spec.js +17 -6
  97. package/dist/platform/driver-spec.js.map +1 -1
  98. package/dist/platform/feedback.d.ts.map +1 -1
  99. package/dist/platform/feedback.js +0 -9
  100. package/dist/platform/feedback.js.map +1 -1
  101. package/dist/platform/git-driver.d.ts +3 -0
  102. package/dist/platform/git-driver.d.ts.map +1 -1
  103. package/dist/platform/git-driver.js +45 -3
  104. package/dist/platform/git-driver.js.map +1 -1
  105. package/dist/platform/platform-poller.d.ts +18 -5
  106. package/dist/platform/platform-poller.d.ts.map +1 -1
  107. package/dist/platform/platform-poller.js +282 -33
  108. package/dist/platform/platform-poller.js.map +1 -1
  109. package/dist/platform/plugins/github/baxian-plugin.json +1 -1
  110. package/dist/platform/plugins/github/driver.json +14 -3
  111. package/dist/platform/plugins/github/skills/baxian-cli-gh/SKILL.md +7 -6
  112. package/dist/platform/plugins/github/skills/baxian-cli-gh/agents/openai.yaml +2 -0
  113. package/dist/platform/pr-binding.d.ts.map +1 -1
  114. package/dist/platform/pr-binding.js +0 -2
  115. package/dist/platform/pr-binding.js.map +1 -1
  116. package/dist/platform/pr-conversation-cache.d.ts.map +1 -0
  117. package/dist/platform/pr-conversation-cache.js.map +1 -0
  118. package/dist/platform/row-schema.d.ts.map +1 -1
  119. package/dist/platform/row-schema.js +1 -0
  120. package/dist/platform/row-schema.js.map +1 -1
  121. package/dist/platform/startup.d.ts +29 -1
  122. package/dist/platform/startup.d.ts.map +1 -1
  123. package/dist/platform/startup.js +82 -4
  124. package/dist/platform/startup.js.map +1 -1
  125. package/dist/platform/types.d.ts +2 -0
  126. package/dist/platform/types.d.ts.map +1 -1
  127. package/dist/platform/types.js +6 -4
  128. package/dist/platform/types.js.map +1 -1
  129. package/dist/platform/verdict-engine.d.ts.map +1 -1
  130. package/dist/platform/verdict-engine.js +0 -2
  131. package/dist/platform/verdict-engine.js.map +1 -1
  132. package/dist/shared/types.d.ts +73 -5
  133. package/dist/shared/types.d.ts.map +1 -1
  134. package/dist/shared/types.js.map +1 -1
  135. package/dist/skills/baxian-pr-feedback/SKILL.md +4 -12
  136. package/dist/skills/baxian-pr-recheck/SKILL.md +3 -44
  137. package/dist/skills/baxian-pr-review/SKILL.md +4 -43
  138. package/dist/skills/baxian-server-feedback/SKILL.md +7 -5
  139. package/dist/skills/baxian-signals/SKILL.md +5 -7
  140. package/dist/skills/baxian-task-check/SKILL.md +3 -5
  141. package/dist/state/agent-store.js +0 -2
  142. package/dist/state/agent-store.js.map +1 -1
  143. package/dist/state/review-store.d.ts +21 -1
  144. package/dist/state/review-store.d.ts.map +1 -1
  145. package/dist/state/review-store.js +132 -0
  146. package/dist/state/review-store.js.map +1 -1
  147. package/dist/state/task-store.d.ts +6 -0
  148. package/dist/state/task-store.d.ts.map +1 -1
  149. package/dist/state/task-store.js +259 -18
  150. package/dist/state/task-store.js.map +1 -1
  151. package/dist/web/assets/index-BXfgaFNm.js +18 -0
  152. package/dist/web/index.html +1 -1
  153. package/package.json +1 -1
  154. package/dist/github/mapper.d.ts +0 -9
  155. package/dist/github/mapper.d.ts.map +0 -1
  156. package/dist/github/mapper.js +0 -25
  157. package/dist/github/mapper.js.map +0 -1
  158. package/dist/github/poller.d.ts +0 -73
  159. package/dist/github/poller.d.ts.map +0 -1
  160. package/dist/github/poller.js +0 -556
  161. package/dist/github/poller.js.map +0 -1
  162. package/dist/github/pr-conversation-cache.d.ts.map +0 -1
  163. package/dist/github/pr-conversation-cache.js.map +0 -1
  164. package/dist/github/pr-conversation.d.ts +0 -7
  165. package/dist/github/pr-conversation.d.ts.map +0 -1
  166. package/dist/github/pr-conversation.js +0 -175
  167. package/dist/github/pr-conversation.js.map +0 -1
  168. package/dist/github/resolver.d.ts +0 -8
  169. package/dist/github/resolver.d.ts.map +0 -1
  170. package/dist/github/resolver.js +0 -28
  171. package/dist/github/resolver.js.map +0 -1
  172. package/dist/state/post-approve-store.d.ts +0 -23
  173. package/dist/state/post-approve-store.d.ts.map +0 -1
  174. package/dist/state/post-approve-store.js +0 -82
  175. package/dist/state/post-approve-store.js.map +0 -1
  176. package/dist/web/assets/index-CUJtd0bC.js +0 -16
  177. /package/dist/{github → platform}/pr-conversation-cache.d.ts +0 -0
  178. /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,112 @@ 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'
191
+ resumed = await manager.continueSession(live.id, live.agentId, 'post-approve', {
192
+ signalToken: key.token,
193
+ postApproveEpisode: key,
194
+ ...(opts.redispatchCount !== undefined
128
195
  ? { postApproveRedispatchCount: opts.redispatchCount }
129
196
  : {}),
130
197
  });
131
198
  }
132
199
  catch (err) {
133
200
  dispatchErr = err;
134
- console.error(`[EventHandler] APPROVE continueSession(dev=${task.agentId}, post-approve) failed:`, err);
201
+ console.error(`[EventHandler] post-approve dispatch failed for task=${live.id}:`, err);
135
202
  }
136
203
  if (resumed) {
137
- if (task.reviewMode === 'git')
138
- await manager.confirmPostApprovePromptDelivered(task.id, signalToken);
204
+ await manager.confirmPostApprovePromptDelivered(live.id, key);
139
205
  return;
140
206
  }
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
207
  if (dispatchErr instanceof DispatchTerminalError) {
147
- await manager.failTaskForDispatchError(task.id, 'post-approve', task.agentId, dispatchErr);
208
+ await manager.failTaskForDispatchError(live.id, 'post-approve', live.agentId, dispatchErr, {
209
+ expectedPostApproveEpisode: key,
210
+ });
148
211
  return;
149
212
  }
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;
213
+ await manager.markAgentWaiting(live.agentId, live.id, {
214
+ expectedPostApproveEpisode: key,
215
+ }).catch(err => {
216
+ console.error(`[EventHandler] post-approve rollback failed for task=${live.id}:`, err);
154
217
  });
155
- await emitIntervention(bus, task.projectId, task.agentId, task.id, {
218
+ await emitIntervention(bus, live.projectId, live.agentId, live.id, {
156
219
  phase: 'post-approve-dispatch-failed',
157
- reviewRound: task.reviewRound,
158
- ...(dispatchErr instanceof DispatchTerminalError ? { terminalReason: dispatchErr.reason } : {}),
220
+ reviewRound: live.reviewRound,
221
+ generation: key.generation,
159
222
  ...(dispatchErr ? { error: dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr) } : {}),
160
223
  });
161
224
  }
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
225
  async function handlePrMergeReady(bus, manager, event) {
178
226
  const taskId = event.taskId;
179
227
  const taskNow = await manager.getTask(taskId);
180
- if (!taskNow)
228
+ if (!taskNow || taskNow.reviewMode !== 'git')
181
229
  return;
182
230
  const eventPrNumber = event.data.prNumber;
183
231
  const eventPrUrl = event.data.prUrl;
@@ -197,7 +245,12 @@ async function handlePrMergeReady(bus, manager, event) {
197
245
  || !signalToken
198
246
  || completion?.token !== signalToken)
199
247
  return;
200
- const ok = await manager.markAgentWaiting(taskNow.agentId, taskNow.id);
248
+ const entryGitEpisode = postApproveEpisodeKey(taskNow);
249
+ if (entryGitEpisode?.token !== signalToken)
250
+ return;
251
+ const ok = await manager.markAgentWaiting(taskNow.agentId, taskNow.id, {
252
+ expectedPostApproveEpisode: entryGitEpisode,
253
+ });
201
254
  if (!ok) {
202
255
  await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
203
256
  phase: 'post-approve-dev-wait-gate-failed',
@@ -210,69 +263,91 @@ async function handlePrMergeReady(bus, manager, event) {
210
263
  ]);
211
264
  if (!freshTask
212
265
  || freshTask.status !== 'approved'
266
+ || freshTask.reviewMode !== 'git'
213
267
  || freshTask.agentId !== taskNow.agentId
214
268
  || freshTask.postApproveRevoked !== undefined
215
- || entryCompletion?.token !== signalToken
216
- || freshTask.postApproveHeadSha === undefined
217
- || entryCompletion.approvedHeadSha !== freshTask.postApproveHeadSha) {
269
+ || entryCompletion?.token !== signalToken) {
218
270
  await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
219
271
  phase: 'post-approve-merge-skipped-stale-task',
220
272
  });
221
273
  return;
222
274
  }
223
- // 'git' merge-ready 接收即 server 权威双复核(spec §6/§10:provenance + ack 完成集,同一次全源扫描):
224
- // agent 自查是尽责而非授权
225
275
  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;
276
+ const gitEpisodeKey = postApproveEpisodeKey(freshTask);
277
+ if (!gitEpisodeKey || gitEpisodeKey.token !== signalToken) {
278
+ await emitIntervention(bus, freshTask.projectId, freshTask.agentId, freshTask.id, {
279
+ phase: 'post-approve-merge-skipped-stale-task',
280
+ });
281
+ return;
282
+ }
283
+ await manager.markPostApproveSignalReceived(freshTask.id, gitEpisodeKey);
284
+ const scanFailedRetry = async (error) => {
285
+ await manager.updatePostApproveCompletionIfToken(freshTask.id, signalToken, { pendingRedispatch: true }, { expectedGeneration: gitEpisodeKey.generation, expectedHeadSha: gitEpisodeKey.headSha });
286
+ await emitIntervention(bus, freshTask.projectId, freshTask.agentId, freshTask.id, {
287
+ phase: 'post-approve-merge-skipped-scan-failed',
288
+ error,
289
+ });
290
+ };
291
+ let verified;
292
+ try {
293
+ verified = await manager.platformVerifyAcceptedPass(freshTask.id);
294
+ }
295
+ catch (err) {
296
+ await scanFailedRetry(err instanceof Error ? err.message : String(err));
297
+ return;
298
+ }
299
+ if (verified.kind === 'retryable') {
300
+ await scanFailedRetry(verified.reason);
301
+ return;
302
+ }
303
+ if (verified.kind === 'provenance-invalid') {
304
+ const begun = await manager.beginGitReviewPass(freshTask.id, {
305
+ fromStatus: [freshTask.status],
306
+ headSha: gitEpisodeKey.headSha,
307
+ bumpRound: true,
308
+ qaPhase: 'recheck',
309
+ expectPhase: freshTask.phase,
310
+ expectSignalToken: freshTask.signalToken,
311
+ patch: { latestHeadSha: gitEpisodeKey.headSha },
312
+ });
313
+ if (begun?.task.reviewDispatch) {
314
+ try {
315
+ await manager.dispatchGitReviewLease(freshTask.id, {
316
+ expectedGeneration: begun.task.reviewDispatch.generation,
317
+ });
249
318
  }
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 };
319
+ catch (err) {
320
+ if (err instanceof DispatchTerminalError && begun.task.qaAgentId) {
321
+ await manager.failTaskForDispatchError(freshTask.id, 'recheck', begun.task.qaAgentId, err, { expectedReviewDispatch: begun.task.reviewDispatch });
322
+ return;
323
+ }
324
+ await emitIntervention(bus, freshTask.projectId, freshTask.agentId, freshTask.id, {
325
+ phase: 'post-approve-provenance-redispatch-failed',
326
+ reason: verified.reason,
327
+ error: err instanceof Error ? err.message : String(err),
328
+ });
260
329
  }
261
330
  }
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
- }
331
+ return;
332
+ }
333
+ if (verified.pendingCount > 0 && !freshCompletion.pendingRedispatch) {
334
+ if (await manager.updatePostApproveCompletionIfToken(freshTask.id, signalToken, { pendingRedispatch: true }, { expectedGeneration: gitEpisodeKey.generation, expectedHeadSha: gitEpisodeKey.headSha })) {
335
+ freshCompletion = { ...freshCompletion, pendingRedispatch: true };
336
+ }
337
+ }
338
+ else if (verified.pendingCount === 0 && freshCompletion.pendingRedispatch) {
339
+ if (await manager.updatePostApproveCompletionIfToken(freshTask.id, signalToken, { pendingRedispatch: false }, { expectedGeneration: gitEpisodeKey.generation, expectedHeadSha: gitEpisodeKey.headSha })) {
340
+ freshCompletion = { ...freshCompletion, pendingRedispatch: false };
267
341
  }
268
342
  }
269
- // A pending flag landing between the check and the transition re-enters here instead of losing the feedback.
270
343
  for (;;) {
271
344
  if (freshCompletion.pendingRedispatch) {
272
345
  const nextCount = (freshCompletion.redispatchCount ?? 0) + 1;
273
346
  if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
274
347
  const revoked = await manager.revokePostApproveCompletion(freshTask.id, 'redispatch-cap', {
275
348
  expectedToken: freshCompletion.token,
349
+ expectedGeneration: gitEpisodeKey.generation,
350
+ expectedHeadSha: gitEpisodeKey.headSha,
276
351
  });
277
352
  if (!revoked) {
278
353
  // The pass rotated after the entry re-validation; this merge-ready and its cap decision are stale.
@@ -288,10 +363,10 @@ async function handlePrMergeReady(bus, manager, event) {
288
363
  });
289
364
  return;
290
365
  }
291
- await dispatchDevPostApproveCheck(bus, manager, freshTask, freshCompletion.approvedHeadSha, { redispatchCount: nextCount });
366
+ await dispatchGitDevPostApproveCheck(bus, manager, freshTask, freshCompletion.approvedHeadSha, { redispatchCount: nextCount });
292
367
  return;
293
368
  }
294
- const completed = await manager.completeApprovedPassToMergeReady(freshTask.id, signalToken);
369
+ const completed = await manager.completeApprovedPassToMergeReady(freshTask.id, gitEpisodeKey);
295
370
  if ('task' in completed)
296
371
  return;
297
372
  if (completed.refused === 'pending') {
@@ -310,156 +385,53 @@ async function handlePrMergeReady(bus, manager, event) {
310
385
  async function handlePrFeedback(bus, manager, event) {
311
386
  const taskId = event.taskId;
312
387
  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)
388
+ if (!taskNow || taskNow.reviewMode !== 'git')
326
389
  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 });
390
+ const revision = parseGitFeedbackRevision(event.data.revision);
391
+ if (!revision) {
392
+ await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
393
+ phase: 'git-feedback-revision-invalid',
394
+ });
363
395
  return;
364
396
  }
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')
397
+ const revisionKey = ackRevisionKey(revision.sourceKey, revision.id, revision.bodyDigest);
398
+ if (taskNow.consumedFeedback?.[revisionKey] !== undefined)
376
399
  return;
377
- if (!isNewFeedback)
400
+ const revisionTime = revision.versionTime ?? Date.parse(event.timestamp);
401
+ const outcome = await manager.consumeGitFeedbackRevision(taskId, { key: revisionKey, versionTime: revisionTime }, { feedbackAt: event.timestamp });
402
+ if (outcome.kind !== 'pending' && outcome.kind !== 'returned')
378
403
  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
- });
404
+ const effected = outcome.task;
405
+ const completion = await manager.getPostApproveCompletion(effected.id);
406
+ const devState = await manager.getAgentState(effected.agentId);
407
+ if (outcome.kind === 'pending' && completion !== null && devState?.taskId === effected.id)
384
408
  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',
409
+ const nextCount = (effected.redispatchCount ?? completion?.redispatchCount ?? 0) + 1;
410
+ if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
411
+ const key = postApproveEpisodeKey(effected);
412
+ await manager.revokePostApproveCompletion(effected.id, 'redispatch-cap', key
413
+ ? {
414
+ expectedToken: key.token,
415
+ expectedGeneration: key.generation,
416
+ expectedHeadSha: key.headSha,
417
+ }
418
+ : {});
419
+ await emitIntervention(bus, effected.projectId, effected.agentId, effected.id, {
420
+ phase: 'post-approve-redispatch-cap-exceeded',
421
+ redispatchCount: effected.redispatchCount ?? 0,
422
+ cap: POST_APPROVE_REDISPATCH_CAP,
391
423
  });
392
424
  return;
393
425
  }
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 });
426
+ const approvedHead = effected.postApproveHeadSha ?? validHeadSha(effected.latestHeadSha);
427
+ if (!approvedHead) {
428
+ await emitIntervention(bus, effected.projectId, effected.agentId, effected.id, {
429
+ phase: 'post-approve-approved-head-unavailable',
430
+ });
458
431
  return;
459
432
  }
433
+ await dispatchGitDevPostApproveCheck(bus, manager, effected, approvedHead, { redispatchCount: nextCount });
460
434
  }
461
- // 恢复期消费 durable pendingRedispatch(spec §6 至少一次):merge-ready 退回或 path C 的 CAS
462
- // 落盘后、派发前崩溃的残局在此补派;dev 仍绑定任务时不派(其在途 pass 的 merge-ready 循环接手)。
463
435
  export async function recoverGitPostApprovePending(bus, manager) {
464
436
  const tasks = await manager.listActiveGitTasks();
465
437
  for (const task of tasks) {
@@ -467,7 +439,6 @@ export async function recoverGitPostApprovePending(bus, manager) {
467
439
  await recoverOneGitPendingTask(bus, manager, task);
468
440
  }
469
441
  catch (err) {
470
- // 单任务失败不得饿死其后的 durable pending(至少一次):报告并继续
471
442
  console.warn(`[EventHandler] recoverGitPostApprovePending: task=${task.id} failed, continuing:`, err);
472
443
  await emitIntervention(bus, task.projectId, task.agentId, task.id, {
473
444
  phase: 'post-approve-recovery-failed',
@@ -478,328 +449,159 @@ export async function recoverGitPostApprovePending(bus, manager) {
478
449
  }
479
450
  async function recoverOneGitPendingTask(bus, manager, task) {
480
451
  {
481
- if (task.status !== 'approved' || task.pendingRedispatch !== true)
452
+ const live = await manager.getTask(task.id);
453
+ if (!live || live.reviewMode !== 'git' || live.status !== 'approved' || live.pendingRedispatch !== true)
454
+ return;
455
+ if (live.postApproveRevoked)
456
+ return;
457
+ const updatedAt = Date.parse(live.updatedAt);
458
+ if (!Number.isFinite(updatedAt)) {
459
+ if (unparseableUpdatedAtWarned.get(live.id) !== live.updatedAt) {
460
+ unparseableUpdatedAtWarned.set(live.id, live.updatedAt);
461
+ console.warn(`[EventHandler] recoverGitPostApprovePending: task=${live.id} updatedAt unparseable, skipping recovery:`, live.updatedAt);
462
+ }
482
463
  return;
483
- if (task.postApproveRevoked)
464
+ }
465
+ unparseableUpdatedAtWarned.delete(live.id);
466
+ if (Date.now() - updatedAt < POST_APPROVE_RECOVERY_MIN_AGE_MS)
484
467
  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);
468
+ if (live.postApprovePhase === 'delivered') {
469
+ const devState = await manager.getAgentState(live.agentId);
470
+ if (devState?.taskId === live.id) {
471
+ const rearmed = await manager.rearmPostApproveSignal(live.id);
472
+ if (!rearmed) {
473
+ const fresh = await manager.getTask(live.id);
474
+ if (fresh?.status === 'approved'
475
+ && fresh.pendingRedispatch === true
476
+ && fresh.postApprovePhase === 'delivered'
477
+ && fresh.postApproveGeneration === live.postApproveGeneration
478
+ && fresh.postApproveToken === live.postApproveToken) {
479
+ throw new Error(`post-approve signal re-arm refused for active task ${live.id}`);
480
+ }
481
+ }
491
482
  return;
492
483
  }
493
484
  }
494
- const approvedHead = validHeadSha(task.postApproveHeadSha ?? task.latestHeadSha);
485
+ const approvedHead = validHeadSha(live.postApproveHeadSha ?? live.latestHeadSha);
495
486
  if (!approvedHead) {
496
- await emitIntervention(bus, task.projectId, task.agentId, task.id, {
487
+ await emitIntervention(bus, live.projectId, live.agentId, live.id, {
497
488
  phase: 'post-approve-approved-head-unavailable',
498
489
  });
499
490
  return;
500
491
  }
501
- const nextCount = (task.redispatchCount ?? 0) + 1;
492
+ const nextCount = (live.redispatchCount ?? 0) + 1;
502
493
  if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
503
- await manager.revokePostApproveCompletion(task.id, 'redispatch-cap');
504
- await emitIntervention(bus, task.projectId, task.agentId, task.id, {
494
+ const key = postApproveEpisodeKey(live);
495
+ await manager.revokePostApproveCompletion(live.id, 'redispatch-cap', key
496
+ ? {
497
+ expectedToken: key.token,
498
+ expectedGeneration: key.generation,
499
+ expectedHeadSha: key.headSha,
500
+ }
501
+ : {});
502
+ await emitIntervention(bus, live.projectId, live.agentId, live.id, {
505
503
  phase: 'post-approve-redispatch-cap-exceeded',
506
- redispatchCount: task.redispatchCount ?? 0,
504
+ redispatchCount: live.redispatchCount ?? 0,
507
505
  cap: POST_APPROVE_REDISPATCH_CAP,
508
506
  });
509
507
  return;
510
508
  }
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;
509
+ await dispatchGitDevPostApproveCheck(bus, manager, live, approvedHead, {
510
+ redispatchCount: nextCount,
511
+ expectedEpisode: postApproveEpisodeKey(live) ?? undefined,
512
+ });
522
513
  }
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
514
  }
530
515
  async function handlePrCodePush(bus, manager, event) {
531
516
  const taskId = event.taskId;
532
- const agentId = event.agentId;
533
517
  const eventPrNumber = event.data.prNumber;
534
518
  const eventPrUrl = event.data.prUrl;
535
519
  const eventKind = event.data.kind;
536
520
  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)
521
+ const task = await manager.getTask(taskId);
522
+ if (!task || task.reviewMode !== 'git')
546
523
  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
524
+ if (eventKind === 'push' && eventHeadSha
551
525
  && 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`);
526
+ && task.reviewDispatch === undefined
527
+ && task.latestHeadSha === eventHeadSha
528
+ && task.reviewHeadAnchorSha === eventHeadSha) {
561
529
  return;
562
530
  }
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)
531
+ if (task.status === 'in_progress' && task.prNumber === undefined && eventPrNumber === undefined) {
532
+ console.warn(`[EventHandler] pr.updated: task ${taskId} in_progress but neither task nor event has prNumber; deferring catch-up`);
581
533
  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
534
  }
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
- }
535
+ if (!PR_UPDATED_REVIEW_FROM_STATUSES.includes(task.status))
642
536
  return;
537
+ let reviewHead = eventHeadSha ?? validHeadSha(task.latestHeadSha);
538
+ const prNumber = eventPrNumber ?? task.prNumber;
539
+ if (reviewHead === undefined && prNumber !== undefined) {
540
+ const verified = await manager.platformVerifyPrBinding(taskId, prNumber);
541
+ if (verified.ok)
542
+ reviewHead = validHeadSha(verified.headSha);
643
543
  }
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,
544
+ if (reviewHead === undefined) {
545
+ await emitIntervention(bus, task.projectId, task.agentId, taskId, {
546
+ phase: 'git-review-head-unavailable',
651
547
  });
652
548
  return;
653
549
  }
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; },
550
+ const bumpRound = task.status === 'in_progress'
551
+ || task.status === 'approved'
552
+ || task.status === 'merge-ready'
553
+ || (task.status === 'review' && task.reviewRoundPending === true);
554
+ const begun = await manager.beginGitReviewPass(taskId, {
555
+ fromStatus: [task.status],
556
+ headSha: reviewHead,
557
+ bumpRound,
558
+ expectPhase: task.phase,
559
+ expectSignalToken: task.signalToken,
560
+ patch: {
561
+ ...(eventPrNumber !== undefined ? { prNumber: eventPrNumber } : {}),
562
+ ...(eventPrUrl !== undefined ? { prUrl: eventPrUrl } : {}),
563
+ latestHeadSha: reviewHead,
564
+ },
659
565
  });
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
- }
566
+ if (!begun?.task.reviewDispatch) {
567
+ const current = await manager.getTask(taskId);
568
+ if (current?.reviewDispatch?.phase === 'uncertain') {
569
+ const qaPhase = current.reviewDispatch.qaPhase;
570
+ await emitIntervention(bus, current.projectId, current.agentId, taskId, {
571
+ phase: 'git-review-dispatch-uncertain',
572
+ qaPhase,
573
+ headSha: reviewHead,
574
+ });
692
575
  }
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
576
  return;
703
577
  }
704
- let started = false;
705
- let dispatchErr = null;
578
+ const qaPhase = begun.task.reviewDispatch.qaPhase;
706
579
  try {
707
- started = await manager.startSession(transitioned.id, qa.id, qaPhase, {
708
- dispatchPassToken: dispatchToken,
580
+ await manager.dispatchGitReviewLease(taskId, {
581
+ expectedGeneration: begun.task.reviewDispatch.generation,
709
582
  });
710
583
  }
711
584
  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,
585
+ console.error(`[EventHandler] pr.updated git review dispatch failed for task=${taskId}:`, err);
586
+ if (err instanceof DispatchTerminalError && err.reason === 'ack_unknown') {
587
+ await emitIntervention(bus, begun.task.projectId, begun.task.agentId, taskId, {
588
+ phase: 'git-review-dispatch-uncertain',
738
589
  qaPhase,
739
- ...(heldPhase !== undefined ? { awaitingPhase: heldPhase } : {}),
740
- note,
590
+ error: err.message,
741
591
  });
742
592
  }
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
- }
593
+ else if (err instanceof DispatchTerminalError && begun.task.qaAgentId) {
594
+ await manager.failTaskForDispatchError(taskId, qaPhase, begun.task.qaAgentId, err, {
595
+ expectedReviewDispatch: begun.task.reviewDispatch,
596
+ });
770
597
  }
771
598
  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;
599
+ await emitIntervention(bus, begun.task.projectId, begun.task.agentId, taskId, {
600
+ phase: 'git-review-dispatch-failed',
601
+ qaPhase,
602
+ error: err instanceof Error ? err.message : String(err),
776
603
  });
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
604
  }
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
605
  }
804
606
  }
805
607
  async function redispatchReviewForStaleVerdict(manager, task) {
@@ -830,6 +632,7 @@ async function redispatchReviewForStaleVerdict(manager, task) {
830
632
  await manager.dispatchReviewToQa(task.id, {
831
633
  fromStatus: ['review'],
832
634
  bumpRound: task.reviewRoundPending === true,
635
+ expectPhase: task.phase,
833
636
  expectSignalToken: task.signalToken,
834
637
  ...(qaPhase !== undefined ? { qaPhase } : {}),
835
638
  });
@@ -842,157 +645,80 @@ async function redispatchReviewForStaleVerdict(manager, task) {
842
645
  return true;
843
646
  }
844
647
  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) {
648
+ if (reviewedHeadSha !== currentHeadSha) {
862
649
  await emitIntervention(bus, task.projectId, task.agentId, task.id, {
863
650
  phase: 'stale-approval-head-mismatch',
864
651
  reviewedHeadSha,
865
- currentHeadSha: anchor.headSha,
866
- source: anchor.source,
867
- ...('fetchError' in anchor && anchor.fetchError ? { fetchError: anchor.fetchError } : {}),
652
+ currentHeadSha,
653
+ source: 'payload-self',
868
654
  });
869
655
  return;
870
656
  }
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,
657
+ if (task.failToken === undefined || task.signalToken === undefined)
658
+ return;
659
+ const provenance = {
660
+ sourceKey: gitVerdict.carrier.sourceKey,
661
+ id: gitVerdict.carrier.id,
662
+ bodyDigest: gitVerdict.carrier.bodyDigest,
663
+ token: gitVerdict.verdictToken,
664
+ failToken: task.failToken,
665
+ anchorSha: reviewedHeadSha,
666
+ };
667
+ const reviewedRound = Math.max(1, task.reviewRound + (task.reviewRoundPending === true ? 1 : 0));
668
+ const transitioned = await manager.approveGitReviewPass(task.id, {
669
+ expectedSignalToken: task.signalToken,
670
+ headSha: reviewedHeadSha,
671
+ reviewRound: reviewedRound,
672
+ ...(prPatch.prNumber !== undefined ? { prNumber: prPatch.prNumber } : {}),
673
+ ...(prPatch.prUrl !== undefined ? { prUrl: prPatch.prUrl } : {}),
674
+ provenance,
897
675
  });
898
- if (!result)
676
+ if (!transitioned)
899
677
  return;
900
- const { task: transitioned } = result;
901
678
  manager.stopPhaseSignalWatcher(transitioned.id);
902
679
  if (transitioned.qaAgentId) {
903
680
  await manager.releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle', { allowAwaitingHuman: true })
904
681
  .catch(err => console.error(`[EventHandler] APPROVE releaseAgentForTask(QA=${transitioned.qaAgentId}) failed:`, err));
905
682
  }
906
- await dispatchDevPostApproveCheck(bus, manager, transitioned, reviewedHeadSha);
683
+ await dispatchGitDevPostApproveCheck(bus, manager, transitioned, reviewedHeadSha);
907
684
  }
908
685
  function gitHeadPatch(task, headSha) {
909
- return task.reviewMode === 'git' && headSha !== undefined && task.latestHeadSha !== headSha
686
+ return headSha !== undefined && task.latestHeadSha !== headSha
910
687
  ? { latestHeadSha: headSha }
911
688
  : {};
912
689
  }
913
690
  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) {
691
+ if (reviewedHeadSha !== currentHeadSha) {
927
692
  await emitIntervention(bus, task.projectId, task.agentId, task.id, {
928
693
  phase: 'stale-request-changes-head-mismatch',
929
694
  reviewedHeadSha,
930
- currentHeadSha: anchor.headSha,
931
- source: anchor.source,
932
- ...('fetchError' in anchor && anchor.fetchError ? { fetchError: anchor.fetchError } : {}),
695
+ currentHeadSha,
696
+ source: 'payload-self',
933
697
  });
934
698
  return;
935
699
  }
936
700
  const reviewedRound = Math.max(1, task.reviewRound + (task.reviewRoundPending === true ? 1 : 0));
937
701
  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
702
  if (nextRound > manager.getConfig().review.rounds) {
975
703
  return handleMaxRounds(bus, manager, task, prPatch, reviewedRound);
976
704
  }
977
705
  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 } : {}),
706
+ fromStatus: ['review'],
707
+ expectSignalToken: task.signalToken,
981
708
  }, {
982
709
  ...prPatch,
983
- ...gitHeadPatch(task, anchor.headSha),
710
+ ...gitHeadPatch(task, currentHeadSha),
984
711
  reviewRound: nextRound,
985
712
  reviewRoundPending: undefined,
986
- // verdict 即送达证据(同 APPROVE):不消费会让 sweep 在 fixing 态按 stale pending 把任务拽回 review
987
- reviewDispatchPending: undefined,
713
+ reviewDispatch: undefined,
988
714
  fixDispatchedAt: new Date().toISOString(),
989
715
  });
990
716
  if (!result)
991
717
  return;
992
- const { task: transitioned, previousStatus } = result;
718
+ const { task: transitioned } = result;
993
719
  await manager.clearPostApproveCompletion(transitioned.id);
994
720
  manager.stopPhaseSignalWatcher(transitioned.id);
995
- if (previousStatus === 'review' && transitioned.qaAgentId) {
721
+ if (transitioned.qaAgentId) {
996
722
  const qaReleased = await manager
997
723
  .releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle', { allowAwaitingHuman: true })
998
724
  .catch(err => {
@@ -1014,7 +740,7 @@ async function handleReviewRequestChanges(bus, manager, task, reviewedHeadSha, c
1014
740
  });
1015
741
  return;
1016
742
  }
1017
- const { token: fixToken, armed } = await manager.rotateAndSetupPhaseSignal(transitioned.id, transitioned.agentId, 'pr-fixed');
743
+ const { token: fixToken, armed } = await manager.rotateAndSetupPhaseSignal(transitioned.id, transitioned.agentId, 'pr-fixed', { expectedStatus: 'fixing', expectedToken: transitioned.signalToken });
1018
744
  if (!armed) {
1019
745
  console.warn(`[EventHandler] REQUEST_CHANGES pr-fixed watcher failed to arm for task=${transitioned.id}; holding dev (not dispatching fix)`);
1020
746
  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 +795,14 @@ async function handleReviewRequestChanges(bus, manager, task, reviewedHeadSha, c
1069
795
  }
1070
796
  async function handleMaxRounds(bus, manager, task, prPatch, reviewedRound) {
1071
797
  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 });
798
+ fromStatus: ['review'],
799
+ expectSignalToken: task.signalToken,
800
+ }, {
801
+ ...prPatch,
802
+ reviewRound: reviewedRound,
803
+ reviewRoundPending: undefined,
804
+ reviewDispatch: undefined,
805
+ });
1075
806
  if (!result)
1076
807
  return;
1077
808
  const { task: transitioned } = result;
@@ -1108,18 +839,31 @@ export function registerEventHandlers(bus, manager) {
1108
839
  if (!event.taskId || !event.agentId)
1109
840
  return;
1110
841
  const taskAtEntry = await manager.getTask(event.taskId);
1111
- if (taskAtEntry?.reviewMode === 'server')
842
+ if (!taskAtEntry)
1112
843
  return;
1113
- if (taskAtEntry && isSpecStagePhase(taskAtEntry.phase)) {
844
+ if (taskAtEntry.reviewMode === 'server') {
845
+ const prNumber = event.data.prNumber;
846
+ if (!Number.isInteger(prNumber) || prNumber < 1)
847
+ return;
848
+ const adopted = await manager.adoptServerPr(event.taskId, {
849
+ prNumber: prNumber,
850
+ ...(typeof event.data.prUrl === 'string' ? { prUrl: event.data.prUrl } : {}),
851
+ ...(validHeadSha(event.data.headSha) !== undefined ? { headSha: validHeadSha(event.data.headSha) } : {}),
852
+ ...(typeof event.data.targetBranch === 'string' ? { targetBranch: event.data.targetBranch } : {}),
853
+ });
854
+ if (!adopted) {
855
+ await emitIntervention(bus, taskAtEntry.projectId, taskAtEntry.agentId, taskAtEntry.id, {
856
+ phase: 'server-pr-adoption-refused',
857
+ prNumber,
858
+ });
859
+ }
860
+ return;
861
+ }
862
+ if (isSpecStagePhase(taskAtEntry.phase)) {
1114
863
  console.warn(`[EventHandler] pr.created ignored for task ${event.taskId}: task in spec phase`);
1115
864
  return;
1116
865
  }
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'
866
+ if (event.data.source === 'pane-signal'
1123
867
  && taskAtEntry.prNumber !== undefined && taskAtEntry.prNumber === event.data.prNumber
1124
868
  && taskAtEntry.status !== 'in_progress') {
1125
869
  if (taskAtEntry.pendingPrSignalToken === undefined
@@ -1131,8 +875,6 @@ export function registerEventHandlers(bus, manager) {
1131
875
  ? decodeSignalActorId(event.data.actorB64)
1132
876
  : undefined;
1133
877
  if (actorId === undefined) {
1134
- // watcher 单发已被本事件消费:不重臂就永失 reconciliation 入口。skipSnapshot=true
1135
- // 只等待后续正确的五段信号,避免滚动区里同一条三段残文立即重触发成环
1136
878
  await manager.rearmGitReconciliationWatcher(taskAtEntry.id, { skipSnapshot: true });
1137
879
  await emitIntervention(bus, taskAtEntry.projectId, event.agentId, taskAtEntry.id, {
1138
880
  phase: 'git-pr-created-actor-missing',
@@ -1146,23 +888,20 @@ export function registerEventHandlers(bus, manager) {
1146
888
  pendingPrSignalToken: undefined,
1147
889
  });
1148
890
  }
1149
- // fixing 态同键 watcher 已是 pr-fixed:拆掉会吞掉本轮完成信号,reconciliation 条目只存在于 review
1150
891
  if (taskAtEntry.status === 'review') {
1151
892
  manager.stopPhaseSignalWatcherAgent(taskAtEntry.id, event.agentId);
1152
893
  }
1153
894
  return;
1154
895
  }
1155
- if (isGit && taskAtEntry && event.data.source !== 'pane-signal'
896
+ if (event.data.source !== 'pane-signal'
1156
897
  && taskAtEntry.prNumber !== undefined && taskAtEntry.prNumber === event.data.prNumber
1157
- && taskAtEntry.reviewDispatchPending !== true
1158
898
  && !['in_progress', 'fixing'].includes(taskAtEntry.status)) {
1159
- // in_progress/fixing 或派发未完成(pending)的同号重放走完整收编重试(至少一次)
1160
899
  return;
1161
900
  }
1162
901
  let paneVerifiedHeadSha;
1163
902
  let reconciledBranch;
1164
- let gitVerifiedTargetBranch;
1165
- if (isGit && event.data.source === 'pane-signal' && event.data.prNumber !== undefined) {
903
+ let verifiedTargetBranch;
904
+ if (event.data.source === 'pane-signal' && event.data.prNumber !== undefined) {
1166
905
  const prNumber = event.data.prNumber;
1167
906
  try {
1168
907
  let verify = await manager.platformVerifyPrBinding(event.taskId, prNumber);
@@ -1170,11 +909,11 @@ export function registerEventHandlers(bus, manager) {
1170
909
  const taskNow = await manager.getTask(event.taskId);
1171
910
  const prBranch = verify.prBranch;
1172
911
  if (taskNow) {
1173
- const ownPrefix = BRANCH_PREFIX + event.taskId;
1174
- const isForeignBxBranch = prBranch.startsWith(BRANCH_PREFIX) && prBranch !== ownPrefix;
912
+ const ownBranch = BRANCH_PREFIX + event.taskId;
913
+ const foreignBxBranch = prBranch.startsWith(BRANCH_PREFIX) && prBranch !== ownBranch;
1175
914
  const bound = await manager.findTaskByBranch(prBranch, taskNow.projectId);
1176
- if (!isForeignBxBranch && isValidBranchName(prBranch)
1177
- && (prBranch === ownPrefix || (!!bound && bound.id === taskNow.id))) {
915
+ if (!foreignBxBranch && isValidBranchName(prBranch)
916
+ && (prBranch === ownBranch || bound?.id === taskNow.id)) {
1178
917
  verify = await manager.platformVerifyPrBinding(event.taskId, prNumber, { branchOverride: prBranch });
1179
918
  if (verify.ok)
1180
919
  reconciledBranch = prBranch;
@@ -1196,13 +935,12 @@ export function registerEventHandlers(bus, manager) {
1196
935
  return;
1197
936
  }
1198
937
  paneVerifiedHeadSha = verify.headSha;
1199
- gitVerifiedTargetBranch = verify.targetBranch;
938
+ verifiedTargetBranch = verify.targetBranch;
1200
939
  }
1201
940
  catch (err) {
1202
941
  console.warn(`[EventHandler] pr.created: platformVerifyPrBinding failed for task ${event.taskId}:`, err);
1203
942
  const taskNow = await manager.getTask(event.taskId);
1204
943
  if (taskNow) {
1205
- // skipSnapshot 缺省 true:滚动区里刚消费的信号残文在平台持续失败时会形成无退避热循环
1206
944
  await reArmDevelopWatcher(manager, taskNow, event.agentId);
1207
945
  await emitIntervention(bus, taskNow.projectId, event.agentId, event.taskId, {
1208
946
  phase: 'pane-pr-created-verify-error',
@@ -1213,243 +951,127 @@ export function registerEventHandlers(bus, manager) {
1213
951
  return;
1214
952
  }
1215
953
  }
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.
954
+ const task = await manager.getTask(event.taskId);
955
+ if (!task || task.reviewMode !== 'git')
956
+ return;
1278
957
  if (event.data.source !== 'pane-signal'
1279
- && taskBeforeTransition
1280
- && taskBeforeTransition.branch !== BRANCH_PREFIX + event.taskId
1281
- && taskBeforeTransition.prNumber !== event.data.prNumber) {
958
+ && task.branch !== BRANCH_PREFIX + event.taskId
959
+ && task.prNumber !== event.data.prNumber) {
1282
960
  console.warn(`[EventHandler] pr.created: ignoring poller-sourced PR ${event.data.prNumber} on custom branch ` +
1283
961
  `for task ${event.taskId} (untracked; custom-branch PRs adopt via the pane signal)`);
1284
962
  return;
1285
963
  }
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';
964
+ const adoption = {};
965
+ const target = event.data.source === 'pane-signal'
966
+ ? verifiedTargetBranch
967
+ : (typeof event.data.targetBranch === 'string' ? event.data.targetBranch : undefined);
968
+ if (target !== undefined && task.baseBranch === undefined)
969
+ adoption.baseBranch = target;
970
+ if (event.data.source === 'pane-signal') {
971
+ const actorId = typeof event.data.actorB64 === 'string'
972
+ ? decodeSignalActorId(event.data.actorB64)
973
+ : undefined;
974
+ if (actorId !== undefined) {
975
+ adoption.replyActorId = actorId;
976
+ adoption.replyActorStatus = 'verified';
977
+ adoption.pendingPrSignalToken = undefined;
1310
978
  }
1311
979
  }
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 } : {}),
980
+ else if (typeof event.data.prAuthorId === 'string' && task.replyActorStatus !== 'verified') {
981
+ adoption.replyActorId = event.data.prAuthorId;
982
+ adoption.replyActorStatus = 'provisional';
983
+ }
984
+ let reviewHead = validHeadSha(event.data.headSha) ?? paneVerifiedHeadSha;
985
+ if (reviewHead === undefined && typeof event.data.prNumber === 'number') {
986
+ const verified = await manager.platformVerifyPrBinding(event.taskId, event.data.prNumber);
987
+ if (verified.ok)
988
+ reviewHead = validHeadSha(verified.headSha);
989
+ }
990
+ if (reviewHead === undefined) {
991
+ await emitIntervention(bus, task.projectId, task.agentId, event.taskId, {
992
+ phase: 'git-review-head-unavailable',
993
+ });
994
+ return;
995
+ }
996
+ if (task.status !== 'in_progress' && task.status !== 'fixing')
997
+ return;
998
+ const begun = await manager.beginGitReviewPass(event.taskId, {
999
+ fromStatus: [task.status],
1000
+ headSha: reviewHead,
1001
+ bumpRound: task.status === 'in_progress',
1002
+ qaPhase: 'review',
1003
+ expectPhase: task.phase,
1004
+ expectSignalToken: task.signalToken,
1005
+ patch: {
1006
+ ...(event.data.prNumber !== undefined ? { prNumber: event.data.prNumber } : {}),
1007
+ ...(event.data.prUrl !== undefined ? { prUrl: event.data.prUrl } : {}),
1008
+ latestHeadSha: reviewHead,
1009
+ ...(reconciledBranch ? { branch: reconciledBranch } : {}),
1010
+ ...adoption,
1011
+ },
1322
1012
  });
1323
- if (!result) {
1324
- console.warn(`[EventHandler] pr.created: cannot transition task ${event.taskId} (terminal or invalid from-state)`);
1013
+ if (!begun?.task.reviewDispatch) {
1014
+ console.warn(`[EventHandler] pr.created: cannot begin git review pass for task ${event.taskId}`);
1325
1015
  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, {
1016
+ const current = await manager.getTask(event.taskId);
1017
+ if (current && ['in_progress', 'fixing'].includes(current.status)) {
1018
+ await reArmDevelopWatcher(manager, current, event.agentId);
1019
+ await emitIntervention(bus, current.projectId, event.agentId, event.taskId, {
1330
1020
  phase: 'pane-pr-created-transition-failed',
1331
1021
  claimedPrNumber: event.data.prNumber,
1332
- taskBranch: taskNow.branch ?? '',
1022
+ taskBranch: current.branch ?? '',
1333
1023
  });
1334
1024
  }
1335
1025
  }
1336
1026
  return;
1337
1027
  }
1338
- const { task: transitioned, previousStatus } = result;
1339
1028
  if (reconciledBranch) {
1340
- console.log(`[EventHandler] pr.created reconciled branch for task ${event.taskId}: ` +
1341
- `${taskBeforeTransition?.branch} → ${reconciledBranch}`);
1029
+ console.log(`[EventHandler] pr.created reconciled branch for task ${event.taskId}: ${task.branch} → ${reconciledBranch}`);
1342
1030
  }
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, {
1031
+ if (!begun.task.qaAgentId) {
1032
+ const parked = await manager.markAgentWaiting(event.agentId, begun.task.id);
1033
+ if (!parked) {
1034
+ await emitIntervention(bus, begun.task.projectId, begun.task.agentId, begun.task.id, {
1348
1035
  phase: 'dev-wait-gate-failed-no-qa',
1349
1036
  });
1350
1037
  }
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,
1038
+ await emitIntervention(bus, begun.task.projectId, begun.task.agentId, begun.task.id, {
1039
+ phase: 'git-review-no-qa-partner',
1401
1040
  });
1402
1041
  return;
1403
1042
  }
1404
- let started = false;
1405
- let dispatchErr = null;
1406
1043
  try {
1407
- started = await manager.startSession(transitioned.id, qa.id, 'review', {
1408
- dispatchPassToken: dispatchToken,
1044
+ await manager.dispatchGitReviewLease(begun.task.id, {
1045
+ expectedGeneration: begun.task.reviewDispatch.generation,
1409
1046
  });
1410
1047
  }
1411
1048
  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);
1049
+ console.error(`[EventHandler] pr.created git review dispatch failed for task=${begun.task.id}:`, err);
1050
+ if (err instanceof DispatchTerminalError && err.reason === 'ack_unknown') {
1051
+ await emitIntervention(bus, begun.task.projectId, begun.task.agentId, begun.task.id, {
1052
+ phase: 'git-review-dispatch-uncertain',
1053
+ qaPhase: 'review',
1054
+ error: err.message,
1055
+ });
1420
1056
  }
1421
- else if (dispatchErr instanceof EnsureSessionError && dispatchErr.partial.handled) {
1057
+ else if (err instanceof DispatchTerminalError) {
1058
+ await manager.failTaskForDispatchError(begun.task.id, 'review', begun.task.qaAgentId, err, {
1059
+ expectedReviewDispatch: begun.task.reviewDispatch,
1060
+ });
1422
1061
  }
1423
1062
  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,
1063
+ await emitIntervention(bus, begun.task.projectId, begun.task.agentId, begun.task.id, {
1064
+ phase: 'git-review-dispatch-failed',
1065
+ qaPhase: 'review',
1066
+ error: err instanceof Error ? err.message : String(err),
1432
1067
  });
1433
1068
  }
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
1069
  }
1446
1070
  });
1447
1071
  bus.on('pr.updated', async (event) => {
1448
- if (!event.taskId || !event.agentId)
1072
+ if (!event.taskId)
1449
1073
  return;
1450
1074
  const taskAtEntry = await manager.getTask(event.taskId);
1451
- if (taskAtEntry?.reviewMode === 'server')
1452
- return;
1453
1075
  const eventKind = event.data.kind;
1454
1076
  if (taskAtEntry && isSpecStagePhase(taskAtEntry.phase)) {
1455
1077
  console.warn(`[EventHandler] pr.updated (kind=${eventKind ?? 'push'}) ignored for task ${event.taskId}: task in spec phase`);
@@ -1457,7 +1079,6 @@ export function registerEventHandlers(bus, manager) {
1457
1079
  }
1458
1080
  if (eventKind === 'pr-merge-ready')
1459
1081
  return handlePrMergeReady(bus, manager, event);
1460
- // closed-unmerged/reopened 只由 PlatformPoller 对 'git' 任务合成(spec §6 outbox 协议)
1461
1082
  if (eventKind === 'closed-unmerged' || eventKind === 'reopened') {
1462
1083
  const prNumber = event.data.prNumber;
1463
1084
  if (typeof prNumber !== 'number')
@@ -1466,13 +1087,18 @@ export function registerEventHandlers(bus, manager) {
1466
1087
  await manager.recordClosedUnmergedAnchor(event.taskId, prNumber);
1467
1088
  else
1468
1089
  await manager.clearClosedUnmergedAnchor(event.taskId, prNumber);
1469
- // 无条件投递:滞留的 outbox 条目借重复观察重试,而不是等到重启(spec §6 至少一次)
1470
1090
  await manager.deliverTaskOutbox(event.taskId);
1471
1091
  return;
1472
1092
  }
1473
- if (eventKind !== 'push' && eventKind !== undefined)
1093
+ if (!event.agentId)
1094
+ return;
1095
+ if (taskAtEntry?.reviewMode === 'server')
1096
+ return;
1097
+ if (eventKind === 'comment' || eventKind === 'review-comment') {
1474
1098
  return handlePrFeedback(bus, manager, event);
1475
- return handlePrCodePush(bus, manager, event);
1099
+ }
1100
+ if (eventKind === 'push' || eventKind === undefined)
1101
+ return handlePrCodePush(bus, manager, event);
1476
1102
  });
1477
1103
  bus.on('pr.merged', async (event) => {
1478
1104
  if (!event.taskId)
@@ -1526,36 +1152,55 @@ export function registerEventHandlers(bus, manager) {
1526
1152
  bus.on('review.submitted', async (event) => {
1527
1153
  if (!event.taskId)
1528
1154
  return;
1529
- const action = event.data.action;
1530
- let task = await manager.getTask(event.taskId);
1155
+ const task = await manager.getTask(event.taskId);
1531
1156
  if (!task)
1532
1157
  return;
1533
1158
  if (task.reviewMode === 'server')
1534
1159
  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)`);
1160
+ if (event.data.source === 'pane-signal') {
1161
+ console.log(`[EventHandler] review.submitted pane verdict ignored for task ${task.id} (comment-token protocol is the sole authority)`);
1538
1162
  return;
1539
1163
  }
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
- }
1164
+ const verdictTask = task;
1165
+ const rejectGitPayload = async (err) => {
1166
+ await emitIntervention(bus, verdictTask.projectId, verdictTask.agentId, verdictTask.id, {
1167
+ phase: 'git-verdict-payload-invalid',
1168
+ error: err.message,
1169
+ ...(typeof event.data.source === 'string' ? { source: event.data.source } : {}),
1170
+ });
1171
+ };
1172
+ let gitPayload;
1173
+ try {
1174
+ gitPayload = parseGitVerdictPayload(event.data);
1175
+ }
1176
+ catch (err) {
1177
+ if (!(err instanceof GitVerdictPayloadError))
1178
+ throw err;
1179
+ await rejectGitPayload(err);
1180
+ return;
1181
+ }
1182
+ const action = gitPayload.action;
1183
+ if (task.signalToken === undefined || gitPayload.reviewPassToken !== task.signalToken) {
1184
+ if (task.status !== 'review') {
1185
+ await rejectGitPayload(new GitVerdictPayloadError('review pass token is not current'));
1186
+ return;
1558
1187
  }
1188
+ if (!(await redispatchReviewForStaleVerdict(manager, task))) {
1189
+ await emitIntervention(bus, task.projectId, task.agentId, task.id, {
1190
+ phase: 'stale-verdict-wrong-pass',
1191
+ action,
1192
+ verdictPassToken: gitPayload.reviewPassToken,
1193
+ currentToken: task.signalToken,
1194
+ source: 'poller',
1195
+ });
1196
+ }
1197
+ return;
1198
+ }
1199
+ const expectedVerdictToken = action === 'APPROVE' ? task.passToken : task.failToken;
1200
+ if (expectedVerdictToken === undefined || gitPayload.verdictToken !== expectedVerdictToken
1201
+ || task.failToken === undefined) {
1202
+ await rejectGitPayload(new GitVerdictPayloadError('verdict token is not current'));
1203
+ return;
1559
1204
  }
1560
1205
  if (isSpecStagePhase(task.phase)) {
1561
1206
  console.warn(`[EventHandler] review.submitted (action=${action}) ignored for task ${task.id}: task in spec phase`);
@@ -1578,72 +1223,18 @@ export function registerEventHandlers(bus, manager) {
1578
1223
  return;
1579
1224
  }
1580
1225
  }
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
1226
  const prPatch = {
1598
- ...(eventPrNumber !== undefined && eventPrNumber !== task.prNumber ? { prNumber: eventPrNumber } : {}),
1599
- ...(eventPrUrl !== undefined && eventPrUrl !== task.prUrl ? { prUrl: eventPrUrl } : {}),
1227
+ ...(gitPayload.prNumber !== task.prNumber ? { prNumber: gitPayload.prNumber } : {}),
1228
+ ...(gitPayload.prUrl !== task.prUrl ? { prUrl: gitPayload.prUrl } : {}),
1600
1229
  };
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
- }
1230
+ const reviewedHeadSha = gitPayload.headSha;
1231
+ const currentHeadSha = gitPayload.currentHeadSha;
1232
+ if (task.status !== 'review') {
1233
+ await rejectGitPayload(new GitVerdictPayloadError(`task is ${task.status}, not review`));
1234
+ return;
1644
1235
  }
1645
1236
  if (action === 'APPROVE') {
1646
- return handleReviewApproval(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch, gitVerdict);
1237
+ return handleReviewApproval(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch, gitPayload);
1647
1238
  }
1648
1239
  if (action === 'REQUEST_CHANGES') {
1649
1240
  return handleReviewRequestChanges(bus, manager, task, reviewedHeadSha, currentHeadSha, prPatch);
@@ -1658,14 +1249,12 @@ export function registerEventHandlers(bus, manager) {
1658
1249
  const { projectId, agentId } = task;
1659
1250
  const stayFixing = async (data) => {
1660
1251
  await emitIntervention(bus, projectId, agentId, task.id, data);
1661
- // 同代 fence:平台查询等待期间任务可能已被手动重派进 review——旧失败分支不得覆盖
1662
- // successor 的 QA/reconciliation watcher
1663
1252
  const fresh = await manager.getTask(task.id);
1664
1253
  if (!fresh || fresh.status !== 'fixing' || fresh.signalToken !== task.signalToken)
1665
1254
  return;
1666
1255
  await manager.setupPhaseSignal(task.id, agentId, 'pr-fixed', {
1667
1256
  skipSnapshot: true,
1668
- ...(fresh.reviewMode === 'git' ? { replaceScope: 'agent' } : {}),
1257
+ replaceScope: 'agent',
1669
1258
  });
1670
1259
  };
1671
1260
  const token = typeof event.data.token === 'string' ? event.data.token : undefined;
@@ -1677,92 +1266,52 @@ export function registerEventHandlers(bus, manager) {
1677
1266
  });
1678
1267
  return;
1679
1268
  }
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;
1269
+ if (task.prNumber === undefined) {
1270
+ await stayFixing({ phase: 'fix-verify-no-anchor' });
1271
+ return;
1272
+ }
1273
+ let verify;
1274
+ try {
1275
+ verify = await manager.platformVerifyPrBinding(task.id, task.prNumber);
1276
+ }
1277
+ catch (err) {
1278
+ await stayFixing({
1279
+ phase: 'fix-verify-head-fetch-failed',
1280
+ error: err instanceof Error ? err.message : String(err),
1281
+ });
1282
+ return;
1283
+ }
1284
+ if (!verify.ok) {
1285
+ // PR 在修复期间被关闭/转 draft/retarget:推进 recheck 只会造出无裁决入口的评审轮
1286
+ await stayFixing({ phase: 'fix-verify-binding-mismatch', reason: verify.reason });
1287
+ return;
1288
+ }
1289
+ const headSha = verify.headSha;
1290
+ if (!task.reviewHeadAnchorSha) {
1291
+ await stayFixing({ phase: 'fix-verify-no-anchor', headSha });
1292
+ return;
1293
+ }
1294
+ if (headSha === task.reviewHeadAnchorSha) {
1295
+ let pendingCount;
1687
1296
  try {
1688
- verify = await manager.platformVerifyPrBinding(task.id, task.prNumber);
1297
+ pendingCount = (await manager.platformPendingFeedback(task.id)).pending.size;
1689
1298
  }
1690
1299
  catch (err) {
1691
1300
  await stayFixing({
1692
- phase: 'fix-verify-head-fetch-failed',
1301
+ phase: 'fix-verify-replies-fetch-failed',
1693
1302
  error: err instanceof Error ? err.message : String(err),
1694
1303
  });
1695
1304
  return;
1696
1305
  }
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) {
1306
+ if (pendingCount > 0) {
1709
1307
  await stayFixing({
1710
- phase: 'fix-verify-head-fetch-failed',
1711
- error: err instanceof Error ? err.message : String(err),
1308
+ phase: 'fix-no-op-pending-feedback',
1309
+ pendingCount,
1310
+ note: 'Dev emitted pr-fixed on an unchanged head while feedback revisions still lack a valid ack. Inspect the PR replies.',
1712
1311
  });
1713
1312
  return;
1714
1313
  }
1715
1314
  }
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
1315
  // head fetch / 回复检查是秒级网络等待,期间真 push / verdict 可能已换代 pass;旧信号不得再驱动派发
1767
1316
  const preEmit = await manager.getTask(task.id);
1768
1317
  if (!preEmit || preEmit.status !== 'fixing' || preEmit.signalToken !== token)