baxian 1.2.58 → 1.2.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/agent/manager.d.ts +116 -1
  2. package/dist/agent/manager.d.ts.map +1 -1
  3. package/dist/agent/manager.js +1002 -46
  4. package/dist/agent/manager.js.map +1 -1
  5. package/dist/agent/phase-signal-watcher.d.ts +6 -1
  6. package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
  7. package/dist/agent/phase-signal-watcher.js +51 -27
  8. package/dist/agent/phase-signal-watcher.js.map +1 -1
  9. package/dist/agent/phase-signal.d.ts +3 -1
  10. package/dist/agent/phase-signal.d.ts.map +1 -1
  11. package/dist/agent/phase-signal.js +27 -4
  12. package/dist/agent/phase-signal.js.map +1 -1
  13. package/dist/api/config.d.ts.map +1 -1
  14. package/dist/api/config.js +16 -4
  15. package/dist/api/config.js.map +1 -1
  16. package/dist/api/platform-guard.d.ts +7 -0
  17. package/dist/api/platform-guard.d.ts.map +1 -0
  18. package/dist/api/platform-guard.js +32 -0
  19. package/dist/api/platform-guard.js.map +1 -0
  20. package/dist/api/projects.d.ts.map +1 -1
  21. package/dist/api/projects.js +16 -3
  22. package/dist/api/projects.js.map +1 -1
  23. package/dist/event/handlers.d.ts +1 -0
  24. package/dist/event/handlers.d.ts.map +1 -1
  25. package/dist/event/handlers.js +509 -47
  26. package/dist/event/handlers.js.map +1 -1
  27. package/dist/github/mapper.d.ts +1 -6
  28. package/dist/github/mapper.d.ts.map +1 -1
  29. package/dist/github/mapper.js.map +1 -1
  30. package/dist/github/poller.d.ts +3 -3
  31. package/dist/github/poller.d.ts.map +1 -1
  32. package/dist/github/poller.js +2 -11
  33. package/dist/github/poller.js.map +1 -1
  34. package/dist/github/resolver.js +1 -1
  35. package/dist/github/resolver.js.map +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +26 -2
  38. package/dist/index.js.map +1 -1
  39. package/dist/platform/driver-host.d.ts +7 -0
  40. package/dist/platform/driver-host.d.ts.map +1 -0
  41. package/dist/platform/driver-host.js +18 -0
  42. package/dist/platform/driver-host.js.map +1 -0
  43. package/dist/platform/feedback.d.ts +31 -0
  44. package/dist/platform/feedback.d.ts.map +1 -0
  45. package/dist/platform/feedback.js +85 -0
  46. package/dist/platform/feedback.js.map +1 -0
  47. package/dist/platform/platform-poller.d.ts +3 -2
  48. package/dist/platform/platform-poller.d.ts.map +1 -1
  49. package/dist/platform/platform-poller.js +32 -59
  50. package/dist/platform/platform-poller.js.map +1 -1
  51. package/dist/platform/poller-health.d.ts +3 -0
  52. package/dist/platform/poller-health.d.ts.map +1 -0
  53. package/dist/platform/poller-health.js +12 -0
  54. package/dist/platform/poller-health.js.map +1 -0
  55. package/dist/platform/pr-binding.d.ts +22 -0
  56. package/dist/platform/pr-binding.d.ts.map +1 -0
  57. package/dist/platform/pr-binding.js +28 -0
  58. package/dist/platform/pr-binding.js.map +1 -0
  59. package/dist/platform/types.d.ts +6 -0
  60. package/dist/platform/types.d.ts.map +1 -1
  61. package/dist/platform/types.js.map +1 -1
  62. package/dist/platform/verdict-engine.d.ts +7 -0
  63. package/dist/platform/verdict-engine.d.ts.map +1 -1
  64. package/dist/platform/verdict-engine.js +33 -24
  65. package/dist/platform/verdict-engine.js.map +1 -1
  66. package/dist/shared/types.d.ts +35 -0
  67. package/dist/shared/types.d.ts.map +1 -1
  68. package/dist/shared/types.js.map +1 -1
  69. package/dist/state/task-store.d.ts.map +1 -1
  70. package/dist/state/task-store.js +71 -2
  71. package/dist/state/task-store.js.map +1 -1
  72. package/package.json +1 -1
@@ -4,7 +4,12 @@ import { join, normalize } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { createSignalToken } from './phase-signal.js';
6
6
  import { tmuxInstallHint } from './preflight.js';
7
- import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, TASK_OWNER_ROLES, isSpecStagePhase, isGitHubRepo, parseGitRemote, repoSlug, } from '../shared/index.js';
7
+ import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, TASK_OWNER_ROLES, isSpecStagePhase, isGitHubRepo, parseGitRemote, repoIdentityKey, repoSlug, } from '../shared/index.js';
8
+ import { buildProjectDriver, makeDriverExec } from '../platform/driver-host.js';
9
+ import { DriverOpError } from '../platform/git-driver.js';
10
+ import { checkOpenPrBinding } from '../platform/pr-binding.js';
11
+ import { collectPendingFeedback, scanCommentSourcesOnce } from '../platform/feedback.js';
12
+ import { recheckPassProvenance } from '../platform/verdict-engine.js';
8
13
  import { AGENT_STORE_NOOP } from '../state/agent-store.js';
9
14
  import { PostApproveStore } from '../state/post-approve-store.js';
10
15
  import { SkillRegistry } from '../skill/registry.js';
@@ -13,13 +18,15 @@ import { GH_EXEC_TIMEOUT_MS, GIT_NET_ENV, execNetwork } from './net-exec.js';
13
18
  import { findForeignTaskTip } from './lineage.js';
14
19
  import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
15
20
  import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, hasOscTitleWorking, hasOscTitleIdle, screenAllowsTitleIdle, } from './tmux.js';
16
- import { BranchManager, DirtyWorkdirError, ReviewHeadMismatchError } from './branch.js';
21
+ import { BranchManager, DirtyWorkdirError, ReviewHeadMismatchError, isAutoDeletableTaskBranch } from './branch.js';
17
22
  import { RepoStore, createRepoStoreCache, moveFileIntoPlace, stageFileGuarded } from './repo-store.js';
18
23
  import { PhaseSignalWatcher } from './phase-signal-watcher.js';
19
24
  import { ReviewTransport, resolveServerPayloads } from './review-transport.js';
20
25
  import { buildPromptInline, buildGreetingPrompt, PromptSizeError, RequiredSkillsMissingError, MAX_PROMPT_BYTES_ROUTE_LIMIT, } from './prompt.js';
21
26
  import { ApiError } from '../errors.js';
22
27
  import { prepareConfig } from '../config/loader.js';
28
+ import { resolveProjectTool } from '../config/validator.js';
29
+ import { SHA_HEX_SOURCE } from '../platform/types.js';
23
30
  export class EnsureSessionError extends Error {
24
31
  partial;
25
32
  constructor(partial, message) {
@@ -141,6 +148,24 @@ export function skillSubdirFor(runtime) {
141
148
  const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
142
149
  const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
143
150
  const GH_NET = { timeout: GH_EXEC_TIMEOUT_MS, retries: 1 };
151
+ const PLATFORM_HEAD_SHA_RE = new RegExp(`^${SHA_HEX_SOURCE}$`);
152
+ // 复核类失败(provenance/ack)与平台/瞬态失败分型:前者调用方退回 approved 重入闭环(计划 Task 8)
153
+ export class PlatformCloseError extends Error {
154
+ step;
155
+ constructor(step, cause) {
156
+ super(`platform ${step} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
157
+ this.step = step;
158
+ this.name = 'PlatformCloseError';
159
+ }
160
+ }
161
+ export class PlatformMergeRecheckError extends Error {
162
+ reason;
163
+ constructor(reason) {
164
+ super(`merge blocked: ${reason}`);
165
+ this.reason = reason;
166
+ this.name = 'PlatformMergeRecheckError';
167
+ }
168
+ }
144
169
  const MANUAL_SERVER_REVIEW_STATUSES = ['in_progress', 'review', 'fixing'];
145
170
  const IMAGE_DISPATCH_PHASES = new Set(['develop', 'research', 'code', 'fix', 'server-feedback']);
146
171
  const RUNTIME_LIVENESS_SAMPLES = 3;
@@ -204,6 +229,8 @@ export class AgentManager {
204
229
  repoCache;
205
230
  paneStreamerManager;
206
231
  postApproveStore;
232
+ pluginRegistry;
233
+ platformDriverExec;
207
234
  phaseSignalWatcher;
208
235
  errorRecordStore;
209
236
  reviewStore;
@@ -246,6 +273,7 @@ export class AgentManager {
246
273
  this.repoStoreFactory = deps.repoStoreFactory;
247
274
  this.paneStreamerManager = deps.paneStreamerManager;
248
275
  this.postApproveStore = deps.postApproveStore ?? new PostApproveStore();
276
+ this.pluginRegistry = deps.pluginRegistry;
249
277
  this.errorRecordStore = deps.errorRecordStore;
250
278
  this.phaseSignalWatcher = deps.phaseSignalWatcher
251
279
  ?? (deps.paneStreamerManager
@@ -261,6 +289,7 @@ export class AgentManager {
261
289
  this.cancelInterruptGuardWaitMs = this.dispatchAckTimeoutMs + 5_000;
262
290
  this.agentIndex = buildAgentIndex(config);
263
291
  this.platformRunner = deps.platformRunner ?? new LocalRunner();
292
+ this.platformDriverExec = makeDriverExec(this.platformRunner);
264
293
  this.imageStagingRoot = deps.imageStagingRoot ?? join(tmpdir(), 'baxian-task-images');
265
294
  this.repoCache = createRepoStoreCache();
266
295
  this.bootstrapTimeoutsMs = {
@@ -2891,6 +2920,9 @@ export class AgentManager {
2891
2920
  const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, {
2892
2921
  skipSnapshot: true,
2893
2922
  onlyReplaceOwnToken: true,
2923
+ // git 双 watcher 下 fence 只应比较自己的 (taskId, agentId) 条目:task 域会拿
2924
+ // sibling(reconciliation)的不同 token 否决本应合法的重布防
2925
+ ...(task.reviewMode === 'git' ? { replaceScope: 'agent' } : {}),
2894
2926
  });
2895
2927
  if (!armed) {
2896
2928
  if (!(await stillCurrent()))
@@ -2907,6 +2939,8 @@ export class AgentManager {
2907
2939
  if (task.status === 'approved' && agent.role === 'dev') {
2908
2940
  return await this.replayApprovedHolderAfterReplRestart(agentId, task, taskStillCurrent, fencedReplay, { clearEntryHold, holdReplayFailure });
2909
2941
  }
2942
+ // reconciliation 条目不是 replay 目标:它没有对应的可重放 prompt,误选会以 QA 轮换 token
2943
+ // 重注 dev 的 publish 提示(spec §5.3 ④ 只允许一次 actor 归位)
2910
2944
  const mapped = this.mapTaskStateToExpectedWatcher(task);
2911
2945
  if (!mapped || mapped.agentId !== agentId)
2912
2946
  return false;
@@ -3106,6 +3140,9 @@ export class AgentManager {
3106
3140
  await this.clearPostApproveCompletionIfMatches(taskId, completion.token);
3107
3141
  throw err;
3108
3142
  }
3143
+ if (resumed && task.reviewMode === 'git') {
3144
+ await this.confirmPostApprovePromptDelivered(taskId, completion.token);
3145
+ }
3109
3146
  if (!resumed && completion.rebuilt) {
3110
3147
  await this.clearPostApproveCompletionIfMatches(taskId, completion.token);
3111
3148
  const fresh = await this.taskStore.get(taskId);
@@ -3142,6 +3179,19 @@ export class AgentManager {
3142
3179
  || fresh.postApproveHeadSha !== approvedHeadSha) {
3143
3180
  return false;
3144
3181
  }
3182
+ if (fresh.reviewMode === 'git') {
3183
+ if (fresh.postApproveToken !== undefined)
3184
+ return false;
3185
+ // rebuild 与常规安装同一 installed→delivered 协议:写后崩溃由 sweep 按 pending 补派
3186
+ await this.taskStore.set({
3187
+ ...fresh,
3188
+ postApproveToken: token,
3189
+ postApprovePhase: 'installed',
3190
+ pendingRedispatch: true,
3191
+ updatedAt: new Date().toISOString(),
3192
+ });
3193
+ return true;
3194
+ }
3145
3195
  if (await this.postApproveStore.get(task.id))
3146
3196
  return false;
3147
3197
  await this.postApproveStore.set(task.id, { token, approvedHeadSha });
@@ -3292,6 +3342,17 @@ export class AgentManager {
3292
3342
  getProjectByRepo(repo) {
3293
3343
  return this.config.project.find(p => repoSlug(p.repo) === repo);
3294
3344
  }
3345
+ getProjectByRepoIdentity(repoKey) {
3346
+ return this.config.project.find(p => repoIdentityKey(p.repo) === repoKey);
3347
+ }
3348
+ platformDriverFor(projectId) {
3349
+ if (!this.pluginRegistry)
3350
+ return undefined;
3351
+ const project = this.getProjectConfig(projectId);
3352
+ if (!project)
3353
+ return undefined;
3354
+ return buildProjectDriver(project, this.pluginRegistry, this.platformDriverExec);
3355
+ }
3295
3356
  findAgentGroup(anchorAgentId) {
3296
3357
  for (const project of this.config.project) {
3297
3358
  for (const group of project.agent) {
@@ -3327,8 +3388,28 @@ export class AgentManager {
3327
3388
  return this.agentStore.get(agentId);
3328
3389
  }
3329
3390
  async getPostApproveCompletion(taskId) {
3391
+ const task = await this.taskStore.get(taskId);
3392
+ if (task?.reviewMode === 'git')
3393
+ return this.gitCompletionOf(task);
3330
3394
  return this.postApproveStore.get(taskId);
3331
3395
  }
3396
+ // 'git' 的 completion 与效果字段同居 TaskState:consumedFeedback/pendingRedispatch 与
3397
+ // token CAS 必须同一次持久化(spec §6 双写协议);legacy github 沿 PostApproveStore 至 M3c。
3398
+ gitCompletionOf(task) {
3399
+ if (task.postApproveToken === undefined || task.postApproveHeadSha === undefined)
3400
+ return null;
3401
+ return {
3402
+ token: task.postApproveToken,
3403
+ approvedHeadSha: task.postApproveHeadSha,
3404
+ updatedAt: task.updatedAt,
3405
+ ...(task.redispatchCount !== undefined ? { redispatchCount: task.redispatchCount } : {}),
3406
+ ...(task.pendingRedispatch !== undefined ? { pendingRedispatch: task.pendingRedispatch } : {}),
3407
+ };
3408
+ }
3409
+ stripGitCompletionFields(task) {
3410
+ const { postApproveToken: _t, pendingRedispatch: _p, redispatchCount: _r, postApprovePhase: _ph, ...rest } = task;
3411
+ return rest;
3412
+ }
3332
3413
  // Install CASes on the current approved episode; only a fresh human verdict (clearRevocation) unlocks a standing marker.
3333
3414
  async setPostApproveCompletion(taskId, value, opts = {}) {
3334
3415
  return this.withTaskLock(async () => {
@@ -3341,37 +3422,111 @@ export class AgentManager {
3341
3422
  if (task.postApproveHeadSha !== undefined && task.postApproveHeadSha !== value.approvedHeadSha)
3342
3423
  return false;
3343
3424
  }
3344
- await this.postApproveStore.set(taskId, value);
3425
+ if (task?.reviewMode === 'git') {
3426
+ const { postApproveRevoked: _revoked, ...rest } = task;
3427
+ // 全量替换语义与 PostApproveStore.set 一致:未携带的 pending/redispatch 字段清空
3428
+ await this.taskStore.set({
3429
+ ...this.stripGitCompletionFields(opts.clearRevocation ? rest : task),
3430
+ postApproveToken: value.token,
3431
+ postApprovePhase: 'installed',
3432
+ ...(value.redispatchCount !== undefined ? { redispatchCount: value.redispatchCount } : {}),
3433
+ ...(value.pendingRedispatch !== undefined ? { pendingRedispatch: value.pendingRedispatch } : {}),
3434
+ updatedAt: new Date().toISOString(),
3435
+ });
3436
+ }
3437
+ else {
3438
+ await this.postApproveStore.set(taskId, value);
3439
+ }
3345
3440
  if (!task)
3346
3441
  return true;
3347
- if (task.postApproveRevoked) {
3442
+ if (task.postApproveRevoked && task.reviewMode !== 'git') {
3348
3443
  const { postApproveRevoked: _revoked, ...rest } = task;
3349
3444
  await this.taskStore.set({ ...rest, updatedAt: new Date().toISOString() });
3350
3445
  }
3351
3446
  if (!this.phaseSignalWatcher)
3352
3447
  return true;
3353
- await this.startPhaseSignalWatch({
3448
+ const armed = await this.startPhaseSignalWatch({
3354
3449
  taskId,
3355
3450
  projectId: task.projectId,
3356
3451
  agentId: task.agentId,
3357
3452
  expectedKinds: 'pr-merge-ready',
3358
3453
  token: value.token,
3359
3454
  });
3455
+ if (!armed) {
3456
+ // completion 没有 consumer 就不算安装成功:撤销本次写,durable pending(若有)随原状保留
3457
+ if (task.reviewMode === 'git') {
3458
+ await this.taskStore.set({ ...task, updatedAt: new Date().toISOString() });
3459
+ }
3460
+ else {
3461
+ await this.postApproveStore.clear(taskId);
3462
+ }
3463
+ return false;
3464
+ }
3360
3465
  return true;
3361
3466
  });
3362
3467
  }
3468
+ // post-approve prompt 的至少一次投递(spec §6):pendingRedispatch 随 completion 安装保持 true,
3469
+ // 仅在 continueSession 确认送达后清除——安装与投递之间的崩溃由恢复扫描按 pending 补派。
3470
+ async confirmPostApprovePromptDelivered(taskId, token) {
3471
+ await this.withTaskLock(async () => {
3472
+ const task = await this.taskStore.get(taskId);
3473
+ if (!task || task.reviewMode !== 'git' || task.postApproveToken !== token)
3474
+ return;
3475
+ await this.taskStore.set({
3476
+ ...task,
3477
+ pendingRedispatch: false,
3478
+ postApprovePhase: 'delivered',
3479
+ updatedAt: new Date().toISOString(),
3480
+ });
3481
+ });
3482
+ }
3483
+ async markPostApproveSignalReceived(taskId, token) {
3484
+ await this.withTaskLock(async () => {
3485
+ const task = await this.taskStore.get(taskId);
3486
+ if (!task || task.reviewMode !== 'git' || task.postApproveToken !== token)
3487
+ return;
3488
+ if (task.postApprovePhase === 'signaled')
3489
+ return;
3490
+ await this.taskStore.set({ ...task, postApprovePhase: 'signaled', updatedAt: new Date().toISOString() });
3491
+ });
3492
+ }
3363
3493
  async clearPostApproveCompletion(taskId) {
3364
3494
  await this.withTaskLock(async () => {
3365
- await this.postApproveStore.clear(taskId);
3495
+ const task = await this.taskStore.get(taskId);
3496
+ if (task?.reviewMode === 'git') {
3497
+ if (task.postApproveToken !== undefined || task.pendingRedispatch !== undefined
3498
+ || task.redispatchCount !== undefined) {
3499
+ await this.taskStore.set({
3500
+ ...this.stripGitCompletionFields(task),
3501
+ updatedAt: new Date().toISOString(),
3502
+ });
3503
+ }
3504
+ }
3505
+ else {
3506
+ await this.postApproveStore.clear(taskId);
3507
+ }
3366
3508
  this.phaseSignalWatcher?.stop(taskId);
3367
3509
  });
3368
3510
  }
3369
3511
  // CAS for an existing pass: never creates a record nor touches the marker, so it cannot resurrect a revoked completion.
3370
- async updatePostApproveCompletionIfToken(taskId, expectedToken, patch) {
3512
+ async updatePostApproveCompletionIfToken(taskId, expectedToken, patch, opts = {}) {
3371
3513
  return this.withTaskLock(async () => {
3372
3514
  const task = await this.taskStore.get(taskId);
3373
3515
  if (task?.postApproveRevoked)
3374
3516
  return false;
3517
+ if (task?.reviewMode === 'git') {
3518
+ if (task.postApproveToken !== expectedToken)
3519
+ return false;
3520
+ await this.taskStore.set({
3521
+ ...task,
3522
+ ...patch,
3523
+ ...(opts.consumeRevision
3524
+ ? { consumedFeedback: { ...task.consumedFeedback, [opts.consumeRevision.key]: opts.consumeRevision.versionTime } }
3525
+ : {}),
3526
+ updatedAt: new Date().toISOString(),
3527
+ });
3528
+ return true;
3529
+ }
3375
3530
  const current = await this.postApproveStore.get(taskId);
3376
3531
  if (!current || current.token !== expectedToken)
3377
3532
  return false;
@@ -3391,9 +3546,24 @@ export class AgentManager {
3391
3546
  && task.postApproveHeadSha !== opts.expectedHeadSha)
3392
3547
  return false;
3393
3548
  if (opts.expectedToken !== undefined) {
3394
- const current = await this.postApproveStore.get(taskId);
3395
- if (current?.token !== opts.expectedToken)
3396
- return false;
3549
+ if (task.reviewMode === 'git') {
3550
+ if (task.postApproveToken !== opts.expectedToken)
3551
+ return false;
3552
+ }
3553
+ else {
3554
+ const current = await this.postApproveStore.get(taskId);
3555
+ if (current?.token !== opts.expectedToken)
3556
+ return false;
3557
+ }
3558
+ }
3559
+ if (task.reviewMode === 'git') {
3560
+ await this.taskStore.set({
3561
+ ...this.stripGitCompletionFields(task),
3562
+ postApproveRevoked: { reason, at: new Date().toISOString() },
3563
+ updatedAt: new Date().toISOString(),
3564
+ });
3565
+ this.phaseSignalWatcher?.stop(taskId);
3566
+ return true;
3397
3567
  }
3398
3568
  // Marker lands first: a crash before the clear leaves marker+completion, which every consumer fences on.
3399
3569
  await this.taskStore.set({
@@ -3408,6 +3578,17 @@ export class AgentManager {
3408
3578
  }
3409
3579
  async clearPostApproveCompletionIfMatches(taskId, token) {
3410
3580
  return this.withTaskLock(async () => {
3581
+ const task = await this.taskStore.get(taskId);
3582
+ if (task?.reviewMode === 'git') {
3583
+ if (task.postApproveToken !== token)
3584
+ return false;
3585
+ await this.taskStore.set({
3586
+ ...this.stripGitCompletionFields(task),
3587
+ updatedAt: new Date().toISOString(),
3588
+ });
3589
+ this.phaseSignalWatcher?.stop(taskId);
3590
+ return true;
3591
+ }
3411
3592
  const cleared = await this.postApproveStore.clearIfMatches(taskId, token);
3412
3593
  if (cleared)
3413
3594
  this.phaseSignalWatcher?.stop(taskId);
@@ -3421,7 +3602,9 @@ export class AgentManager {
3421
3602
  if (!task || task.status !== 'approved' || task.postApproveRevoked) {
3422
3603
  return { refused: 'stale' };
3423
3604
  }
3424
- const completion = await this.postApproveStore.get(taskId);
3605
+ const completion = task.reviewMode === 'git'
3606
+ ? this.gitCompletionOf(task)
3607
+ : await this.postApproveStore.get(taskId);
3425
3608
  if (!completion
3426
3609
  || completion.token !== expectedToken
3427
3610
  || task.postApproveHeadSha === undefined
@@ -3430,16 +3613,319 @@ export class AgentManager {
3430
3613
  }
3431
3614
  if (completion.pendingRedispatch)
3432
3615
  return { refused: 'pending' };
3433
- delete task.postApproveHeadSha;
3434
- Object.assign(task, {
3616
+ const next = task.reviewMode === 'git' ? this.stripGitCompletionFields(task) : task;
3617
+ delete next.postApproveHeadSha;
3618
+ Object.assign(next, {
3435
3619
  status: 'merge-ready',
3436
3620
  latestHeadSha: completion.approvedHeadSha,
3437
3621
  updatedAt: new Date().toISOString(),
3438
3622
  });
3439
- await this.taskStore.set(task);
3440
- await this.postApproveStore.clear(taskId);
3623
+ await this.taskStore.set(next);
3624
+ if (task.reviewMode !== 'git')
3625
+ await this.postApproveStore.clear(taskId);
3441
3626
  this.phaseSignalWatcher?.stop(taskId);
3442
- return { task };
3627
+ return { task: next };
3628
+ });
3629
+ }
3630
+ // 修剪顺序单向(spec §6):仅在对应源 cursor durable 落盘后调用;水位停滞的源不修剪,
3631
+ // 有界性由水位前进保证,禁止按容量淘汰未覆盖键。
3632
+ async pruneConsumedFeedback(taskId, sourceKey, watermarkTime) {
3633
+ await this.withTaskLock(async () => {
3634
+ const task = await this.taskStore.get(taskId);
3635
+ if (!task?.consumedFeedback)
3636
+ return;
3637
+ const prefix = `${sourceKey}:`;
3638
+ const kept = Object.entries(task.consumedFeedback)
3639
+ .filter(([key, time]) => !key.startsWith(prefix) || time >= watermarkTime);
3640
+ if (kept.length === Object.keys(task.consumedFeedback).length)
3641
+ return;
3642
+ await this.taskStore.set({
3643
+ ...task,
3644
+ consumedFeedback: Object.fromEntries(kept),
3645
+ updatedAt: new Date().toISOString(),
3646
+ });
3647
+ });
3648
+ }
3649
+ // TaskState 效果+outbox 先 durable、投递成功再清(spec §6 双写协议):崩溃于任一侧,
3650
+ // 重启从未清 outbox 重投,事件携确定性 key 供展示端去重——效果恰一次、通知至少一次。
3651
+ async recordClosedUnmergedAnchor(taskId, prNumber) {
3652
+ return this.withTaskLock(async () => {
3653
+ const task = await this.taskStore.get(taskId);
3654
+ if (!task || task.reviewMode !== 'git' || TERMINAL_STATUSES.includes(task.status))
3655
+ return false;
3656
+ const anchor = task.closedUnmergedAnchor;
3657
+ if (anchor?.prNumber === prNumber && anchor.cleared !== true)
3658
+ return false;
3659
+ const generation = (anchor?.generation ?? 0) + 1;
3660
+ const eventKey = `${taskId}:${prNumber}:mr-closed-unmerged:${generation}`;
3661
+ await this.taskStore.set({
3662
+ ...task,
3663
+ closedUnmergedAnchor: { prNumber, generation },
3664
+ outbox: [...(task.outbox ?? []), {
3665
+ key: eventKey,
3666
+ type: 'human.intervention',
3667
+ data: { phase: 'mr-closed-unmerged', prNumber, eventKey },
3668
+ }],
3669
+ updatedAt: new Date().toISOString(),
3670
+ });
3671
+ return true;
3672
+ });
3673
+ }
3674
+ async clearClosedUnmergedAnchor(taskId, prNumber) {
3675
+ return this.withTaskLock(async () => {
3676
+ const task = await this.taskStore.get(taskId);
3677
+ const anchor = task?.closedUnmergedAnchor;
3678
+ if (!task || anchor?.prNumber !== prNumber || anchor.cleared === true)
3679
+ return false;
3680
+ const generation = anchor.generation;
3681
+ const eventKey = `${taskId}:${prNumber}:mr-reopened:${generation}`;
3682
+ await this.taskStore.set({
3683
+ ...task,
3684
+ closedUnmergedAnchor: { ...anchor, cleared: true },
3685
+ outbox: [...(task.outbox ?? []), {
3686
+ key: eventKey,
3687
+ type: 'human.intervention',
3688
+ data: { phase: 'mr-reopened', prNumber, eventKey },
3689
+ }],
3690
+ updatedAt: new Date().toISOString(),
3691
+ });
3692
+ return true;
3693
+ });
3694
+ }
3695
+ async deliverTaskOutbox(taskId) {
3696
+ const task = await this.taskStore.get(taskId);
3697
+ if (!task?.outbox?.length)
3698
+ return;
3699
+ for (const entry of task.outbox) {
3700
+ try {
3701
+ await this.eventBus.emit({
3702
+ // 确定性 id(spec §6 outbox):重投/并发 flush 的重复条目同 id,消费/展示端可去重
3703
+ id: `outbox:${entry.key}`,
3704
+ type: entry.type,
3705
+ timestamp: new Date().toISOString(),
3706
+ projectId: task.projectId,
3707
+ agentId: task.agentId,
3708
+ taskId: task.id,
3709
+ data: entry.data,
3710
+ });
3711
+ }
3712
+ catch (err) {
3713
+ console.warn(`[AgentManager] outbox delivery failed for task=${taskId} key=${entry.key}:`, err);
3714
+ continue;
3715
+ }
3716
+ await this.withTaskLock(async () => {
3717
+ const fresh = await this.taskStore.get(taskId);
3718
+ if (!fresh?.outbox?.some(e => e.key === entry.key))
3719
+ return;
3720
+ const remaining = fresh.outbox.filter(e => e.key !== entry.key);
3721
+ const { outbox: _outbox, ...rest } = fresh;
3722
+ await this.taskStore.set({
3723
+ ...rest,
3724
+ ...(remaining.length > 0 ? { outbox: remaining } : {}),
3725
+ updatedAt: new Date().toISOString(),
3726
+ });
3727
+ });
3728
+ }
3729
+ }
3730
+ // merge-ready 信号是单发消费:git 分支在消费点引入网络复核,失败时必须重 arm(skipSnapshot
3731
+ // false 让仍在面板上的信号立即重放),否则一次瞬态失败吞掉整轮完成信号。
3732
+ async rearmPostApproveSignal(taskId, opts = {}) {
3733
+ const task = await this.taskStore.get(taskId);
3734
+ if (!task || task.status !== 'approved')
3735
+ return false;
3736
+ const completion = task.reviewMode === 'git'
3737
+ ? this.gitCompletionOf(task)
3738
+ : await this.postApproveStore.get(taskId);
3739
+ if (!completion)
3740
+ return false;
3741
+ if (!this.phaseSignalWatcher)
3742
+ return true;
3743
+ return this.startPhaseSignalWatch({
3744
+ taskId,
3745
+ projectId: task.projectId,
3746
+ agentId: task.agentId,
3747
+ expectedKinds: 'pr-merge-ready',
3748
+ token: completion.token,
3749
+ // 刚消费的信号仍在滚动区:扫描失败路径必须 live-only,否则与持续失败叠加成无退避热循环
3750
+ ...(opts.skipSnapshot ? { skipSnapshot: true } : {}),
3751
+ });
3752
+ }
3753
+ // 配置身份切换与任务创建共栅栏(TOCTOU):blocker 扫描与提交在 withTaskLock 内执行,
3754
+ // createTask 的「读配置快照+落任务」同经该锁,扫描时不可见的并发新任务不存在。
3755
+ async guardGitConfigCommit(current, next, scan, commit) {
3756
+ return this.withTaskLock(async () => {
3757
+ const blockers = await scan(this, current, next);
3758
+ if (blockers.length > 0)
3759
+ return { ok: false, blockers };
3760
+ await commit();
3761
+ return { ok: true };
3762
+ });
3763
+ }
3764
+ async flushGitOutbox() {
3765
+ const tasks = await this.taskStore.list();
3766
+ for (const task of tasks) {
3767
+ if (task.reviewMode !== 'git' || !task.outbox?.length)
3768
+ continue;
3769
+ try {
3770
+ await this.deliverTaskOutbox(task.id);
3771
+ }
3772
+ catch (err) {
3773
+ // 单任务失败不得饿死其后的 durable outbox(至少一次的重启保证)
3774
+ console.warn(`[AgentManager] flushGitOutbox: task=${task.id} failed, continuing:`, err);
3775
+ }
3776
+ }
3777
+ }
3778
+ // cancel 清理的部分成功可重试(spec §6 close 条 + 破坏性清理不静默失败):close 已成而
3779
+ // deleteBranch 瞬态失败时任务已终态,重启在守卫成立的前提下幂等补删(REF_NOT_FOUND 即成功)。
3780
+ // 只清本代 pending:A 的 start 晚于 B 的失败返回时,无条件清除会吞掉 B 留下的重试锚
3781
+ async clearReviewDispatchPending(taskId, expectedToken) {
3782
+ await this.withTaskLock(async () => {
3783
+ const task = await this.taskStore.get(taskId);
3784
+ if (!task || task.reviewDispatchPending !== true)
3785
+ return;
3786
+ if (task.status !== 'review' || task.signalToken !== expectedToken)
3787
+ return;
3788
+ const { reviewDispatchPending: _p, ...rest } = task;
3789
+ await this.taskStore.set({ ...rest, updatedAt: new Date().toISOString() });
3790
+ });
3791
+ }
3792
+ // 评审派发的至少一次(sweep 消费):transition 落 pending、QA session 启动成功才清——
3793
+ // QA 缺失/忙/arm/start 失败的每条早退路径都由此在 60s 内自愈,无需依赖事件重放。
3794
+ async retryPendingGitReviewDispatches() {
3795
+ const tasks = await this.listActiveGitTasks();
3796
+ for (const task of tasks) {
3797
+ if (task.reviewDispatchPending !== true)
3798
+ continue;
3799
+ // arm/start 失败回滚会退到 in_progress/fixing 且无第二触发源(poller 已记 pushSha、
3800
+ // watcher 已消费):pending 在场即重新拉入 review
3801
+ if (!['review', 'in_progress', 'fixing'].includes(task.status))
3802
+ continue;
3803
+ try {
3804
+ await this.dispatchReviewToQa(task.id, { bumpRound: false });
3805
+ }
3806
+ catch (err) {
3807
+ console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${task.id} still failing:`, err);
3808
+ }
3809
+ }
3810
+ }
3811
+ async retryGitRemoteBranchCleanup() {
3812
+ const tasks = await this.taskStore.list({ status: 'cancelled' });
3813
+ for (const task of tasks) {
3814
+ const reason = task.branchCleanupPending?.reason;
3815
+ if (task.reviewMode !== 'git'
3816
+ || (reason !== 'remote-delete-failed' && reason !== 'remote-close-failed'))
3817
+ continue;
3818
+ try {
3819
+ const driver = this.platformDriverFor(task.projectId);
3820
+ const branch = task.branch;
3821
+ if (!driver || branch === undefined)
3822
+ continue;
3823
+ // close 先补(幂等:closed PR 再 PATCH state=closed 为 no-op),再按守卫补删分支
3824
+ if (reason === 'remote-close-failed' && task.prNumber !== undefined) {
3825
+ await driver.runOp('close', { prNumber: task.prNumber });
3826
+ }
3827
+ if (!isAutoDeletableTaskBranch({
3828
+ taskId: task.id,
3829
+ taskBranch: branch,
3830
+ branchCreatedByBaxian: task.branchCreatedByBaxian,
3831
+ actualRef: `refs/heads/${branch}`,
3832
+ })) {
3833
+ if (reason !== 'remote-close-failed')
3834
+ continue;
3835
+ }
3836
+ else {
3837
+ await driver.runOp('deleteBranch', { branch });
3838
+ }
3839
+ await this.withTaskLock(async () => {
3840
+ const fresh = await this.taskStore.get(task.id);
3841
+ const freshReason = fresh?.branchCleanupPending?.reason;
3842
+ if (freshReason !== 'remote-delete-failed' && freshReason !== 'remote-close-failed')
3843
+ return;
3844
+ const { branchCleanupPending: _pending, ...rest } = fresh;
3845
+ await this.taskStore.set({ ...rest, updatedAt: new Date().toISOString() });
3846
+ });
3847
+ }
3848
+ catch (err) {
3849
+ console.warn(`[AgentManager] retryGitRemoteBranchCleanup: task=${task.id} still failing:`, err);
3850
+ }
3851
+ }
3852
+ }
3853
+ // 反馈消费的唯一状态机(spec §6 双写协议 + merge 条):锁内按实时状态决定效果,效果字段
3854
+ // 与消费键同一次持久化——入口快照与提交之间的并发状态迁移不再能让 revision 落空。
3855
+ async consumeGitFeedbackRevision(taskId, consume, opts = {}) {
3856
+ return this.withTaskLock(async () => {
3857
+ const task = await this.taskStore.get(taskId);
3858
+ if (!task || task.reviewMode !== 'git')
3859
+ return { kind: 'gone' };
3860
+ if (task.consumedFeedback?.[consume.key] !== undefined)
3861
+ return { kind: 'duplicate' };
3862
+ const consumed = { ...task.consumedFeedback, [consume.key]: consume.versionTime };
3863
+ const stamp = { updatedAt: new Date().toISOString() };
3864
+ if (TERMINAL_STATUSES.includes(task.status)) {
3865
+ await this.taskStore.set({ ...task, consumedFeedback: consumed, ...stamp });
3866
+ return { kind: 'consumed' };
3867
+ }
3868
+ if (task.status === 'merge-ready') {
3869
+ const approvedHead = typeof task.latestHeadSha === 'string' && PLATFORM_HEAD_SHA_RE.test(task.latestHeadSha)
3870
+ ? task.latestHeadSha
3871
+ : undefined;
3872
+ if (approvedHead !== undefined) {
3873
+ const next = {
3874
+ ...task,
3875
+ status: 'approved',
3876
+ postApproveHeadSha: approvedHead,
3877
+ pendingRedispatch: true,
3878
+ consumedFeedback: consumed,
3879
+ ...(opts.feedbackAt !== undefined ? { prFeedbackReceivedAt: opts.feedbackAt } : {}),
3880
+ ...stamp,
3881
+ };
3882
+ await this.taskStore.set(next);
3883
+ return { kind: 'returned', task: next };
3884
+ }
3885
+ }
3886
+ if (task.status === 'approved' && task.postApproveRevoked === undefined) {
3887
+ const next = {
3888
+ ...task,
3889
+ pendingRedispatch: true,
3890
+ consumedFeedback: consumed,
3891
+ ...(opts.feedbackAt !== undefined ? { prFeedbackReceivedAt: opts.feedbackAt } : {}),
3892
+ ...stamp,
3893
+ };
3894
+ await this.taskStore.set(next);
3895
+ return { kind: 'pending', task: next };
3896
+ }
3897
+ await this.taskStore.set({
3898
+ ...task,
3899
+ consumedFeedback: consumed,
3900
+ ...(opts.feedbackAt !== undefined ? { prFeedbackReceivedAt: opts.feedbackAt } : {}),
3901
+ ...stamp,
3902
+ });
3903
+ return { kind: 'consumed' };
3904
+ });
3905
+ }
3906
+ // merge-ready 不是评论面的冻结点(spec §6 merge 条):新反馈单写退回 approved + 消费键,
3907
+ // 与 push 使 merge-ready 失效的既有语义对称。
3908
+ async returnMergeReadyToApproved(taskId, consume) {
3909
+ return this.withTaskLock(async () => {
3910
+ const task = await this.taskStore.get(taskId);
3911
+ if (!task || task.reviewMode !== 'git' || task.status !== 'merge-ready')
3912
+ return null;
3913
+ const approvedHead = typeof task.latestHeadSha === 'string' && PLATFORM_HEAD_SHA_RE.test(task.latestHeadSha)
3914
+ ? task.latestHeadSha
3915
+ : undefined;
3916
+ if (!approvedHead)
3917
+ return null;
3918
+ // pendingRedispatch 与状态翻转/消费键同写:崩溃于派发前,恢复扫描凭它补派(spec §6 至少一次)
3919
+ const next = {
3920
+ ...task,
3921
+ status: 'approved',
3922
+ postApproveHeadSha: approvedHead,
3923
+ pendingRedispatch: true,
3924
+ consumedFeedback: { ...task.consumedFeedback, [consume.key]: consume.versionTime },
3925
+ updatedAt: new Date().toISOString(),
3926
+ };
3927
+ await this.taskStore.set(next);
3928
+ return next;
3443
3929
  });
3444
3930
  }
3445
3931
  async setupRecoveredPostApproveSignals() {
@@ -3448,14 +3934,26 @@ export class AgentManager {
3448
3934
  if (task.postApproveRevoked) {
3449
3935
  // Finish an interrupted revoke: the marker is authoritative, the leftover record must not re-arm.
3450
3936
  try {
3451
- await this.postApproveStore.clear(task.id);
3937
+ if (task.reviewMode === 'git') {
3938
+ if (task.postApproveToken !== undefined) {
3939
+ await this.taskStore.set({
3940
+ ...this.stripGitCompletionFields(task),
3941
+ updatedAt: new Date().toISOString(),
3942
+ });
3943
+ }
3944
+ }
3945
+ else {
3946
+ await this.postApproveStore.clear(task.id);
3947
+ }
3452
3948
  }
3453
3949
  catch (err) {
3454
3950
  console.warn(`[AgentManager] setupRecoveredPostApproveSignals: revoked completion cleanup failed for task=${task.id}:`, err);
3455
3951
  }
3456
3952
  continue;
3457
3953
  }
3458
- const completion = await this.postApproveStore.get(task.id);
3954
+ const completion = task.reviewMode === 'git'
3955
+ ? this.gitCompletionOf(task)
3956
+ : await this.postApproveStore.get(task.id);
3459
3957
  if (!completion)
3460
3958
  continue;
3461
3959
  if (task.postApproveHeadSha === undefined) {
@@ -3465,7 +3963,14 @@ export class AgentManager {
3465
3963
  }
3466
3964
  if (completion.approvedHeadSha !== task.postApproveHeadSha) {
3467
3965
  try {
3468
- await this.postApproveStore.clear(task.id);
3966
+ if (task.reviewMode === 'git') {
3967
+ await this.taskStore.set({
3968
+ ...this.stripGitCompletionFields(task),
3969
+ updatedAt: new Date().toISOString(),
3970
+ });
3971
+ }
3972
+ else
3973
+ await this.postApproveStore.clear(task.id);
3469
3974
  console.warn(`[AgentManager] setupRecoveredPostApproveSignals: task=${task.id} retired a completion bound to head ${completion.approvedHeadSha} (persisted approved head ${task.postApproveHeadSha})`);
3470
3975
  }
3471
3976
  catch (err) {
@@ -3498,9 +4003,16 @@ export class AgentManager {
3498
4003
  for (const task of tasks) {
3499
4004
  if (!task.signalToken)
3500
4005
  continue;
3501
- const mapped = this.mapTaskStateToExpectedWatcher(task);
3502
- if (!mapped)
4006
+ const mappings = this.mapTaskStateToExpectedWatchers(task);
4007
+ if (mappings.length === 0)
3503
4008
  continue;
4009
+ for (const mapped of mappings) {
4010
+ await this.setupRecoveredWatcherForMapping(task, mapped.token ?? task.signalToken, mapped, mappings.length > 1);
4011
+ }
4012
+ }
4013
+ }
4014
+ async setupRecoveredWatcherForMapping(task, signalToken, mapped, hasSiblings) {
4015
+ {
3504
4016
  const { expectedKinds, agentId } = mapped;
3505
4017
  const specStage = isSpecStagePhase(task.phase);
3506
4018
  const interventionKindLabel = specStage && task.status === 'review' ? 'spec-reviewed'
@@ -3522,9 +4034,10 @@ export class AgentManager {
3522
4034
  projectId: task.projectId,
3523
4035
  agentId,
3524
4036
  expectedKinds,
3525
- token: task.signalToken,
4037
+ token: signalToken,
3526
4038
  skipSnapshot: !scanSnapshotOnRecover,
3527
4039
  recovered: true,
4040
+ ...(hasSiblings ? { replaceScope: 'agent' } : {}),
3528
4041
  ...(allowRecoveredReadFile
3529
4042
  ? { onReadFile: (req) => { void this.handleReadFileRequest(task.id, agentId, req); } }
3530
4043
  : {}),
@@ -3629,6 +4142,249 @@ export class AgentManager {
3629
4142
  return undefined;
3630
4143
  return { headRefName, headSha, body };
3631
4144
  }
4145
+ platformBindingFields(projectId) {
4146
+ if (this.effectiveReviewMode(projectId) !== 'git')
4147
+ return {};
4148
+ const project = this.getProjectConfig(projectId);
4149
+ if (!project)
4150
+ return {};
4151
+ const tool = resolveProjectTool(project);
4152
+ if (tool === undefined)
4153
+ return {};
4154
+ return { platformBinding: { mode: 'git', repoKey: repoIdentityKey(project.repo), tool } };
4155
+ }
4156
+ // 指纹为纯身份三元组(spec §4):binary/env/skill 文本 live 解析可演进,身份失配即拒绝
4157
+ assertPlatformBinding(task) {
4158
+ const binding = task.platformBinding;
4159
+ if (!binding) {
4160
+ if (task.reviewMode !== 'git')
4161
+ return;
4162
+ // D11 无兼容负担:不存在合法的无快照 git 任务,缺失即损坏/手工恢复,按失配同级拒绝
4163
+ void this.emitIntervention(task.projectId, task.agentId || undefined, task.id, {
4164
+ phase: 'platform-binding-mismatch',
4165
+ reason: 'missing-binding-snapshot',
4166
+ });
4167
+ throw new Error(`platform binding missing for git task ${task.id}; refusing platform operations`);
4168
+ }
4169
+ const project = this.getProjectConfig(task.projectId);
4170
+ const live = project === undefined ? undefined : {
4171
+ mode: this.effectiveReviewMode(task.projectId),
4172
+ repoKey: repoIdentityKey(project.repo),
4173
+ tool: resolveProjectTool(project),
4174
+ };
4175
+ if (live !== undefined && live.mode === binding.mode
4176
+ && live.repoKey === binding.repoKey && live.tool === binding.tool)
4177
+ return;
4178
+ void this.emitIntervention(task.projectId, task.agentId || undefined, task.id, {
4179
+ phase: 'platform-binding-mismatch',
4180
+ binding,
4181
+ ...(live !== undefined ? { live: { mode: live.mode, repoKey: live.repoKey, tool: live.tool ?? '' } } : {}),
4182
+ });
4183
+ throw new Error(`platform binding mismatch for task ${task.id}: snapshot ${binding.mode}/${binding.repoKey}/${binding.tool} no longer matches the live config`);
4184
+ }
4185
+ async platformTaskAndDriver(taskId) {
4186
+ const task = await this.taskStore.get(taskId);
4187
+ if (!task)
4188
+ throw new Error(`task ${taskId} not found`);
4189
+ this.assertPlatformBinding(task);
4190
+ const driver = this.platformDriverFor(task.projectId);
4191
+ if (!driver)
4192
+ throw new Error(`no git driver resolvable for project ${task.projectId}`);
4193
+ return { task, driver };
4194
+ }
4195
+ async listActiveGitTasks(projectId) {
4196
+ const tasks = await this.taskStore.list(projectId !== undefined ? { projectId } : undefined);
4197
+ return tasks.filter(t => t.reviewMode === 'git' && !TERMINAL_STATUSES.includes(t.status));
4198
+ }
4199
+ async platformFetchPrView(taskId) {
4200
+ const { task, driver } = await this.platformTaskAndDriver(taskId);
4201
+ if (task.prNumber === undefined)
4202
+ throw new Error(`task ${taskId} has no bound PR`);
4203
+ const [row] = await driver.runOp('prView', { prNumber: task.prNumber });
4204
+ return row;
4205
+ }
4206
+ async platformVerifyPrBinding(taskId, prNumber, opts = {}) {
4207
+ const { task, driver } = await this.platformTaskAndDriver(taskId);
4208
+ const [row] = await driver.runOp('prView', { prNumber });
4209
+ let expectedBase = task.baseBranch;
4210
+ if (expectedBase === undefined) {
4211
+ try {
4212
+ const [project] = await driver.runOp('projectView');
4213
+ if (typeof project?.defaultBranch === 'string')
4214
+ expectedBase = project.defaultBranch;
4215
+ }
4216
+ catch {
4217
+ // 快照与实时默认分支都拿不到 → unverifiable:核验失败不是核验通过(fail closed)
4218
+ }
4219
+ }
4220
+ const check = checkOpenPrBinding(row, { branch: opts.branchOverride ?? task.branch, expectedBase });
4221
+ if (!check.ok) {
4222
+ return {
4223
+ ...check,
4224
+ ...(check.reason === 'branch' && typeof row.branch === 'string' ? { prBranch: row.branch } : {}),
4225
+ };
4226
+ }
4227
+ return {
4228
+ ok: true,
4229
+ headSha: String(row.headSha),
4230
+ branch: String(row.branch),
4231
+ targetBranch: String(row.targetBranch),
4232
+ };
4233
+ }
4234
+ stopPhaseSignalWatcherAgent(taskId, agentId) {
4235
+ this.phaseSignalWatcher?.stopAgent(taskId, agentId);
4236
+ }
4237
+ async platformClosePr(taskId, opts) {
4238
+ const { task, driver } = await this.platformTaskAndDriver(taskId);
4239
+ if (task.prNumber === undefined)
4240
+ throw new Error(`task ${taskId} has no bound PR`);
4241
+ try {
4242
+ await driver.runOp('close', { prNumber: task.prNumber });
4243
+ }
4244
+ catch (err) {
4245
+ throw new PlatformCloseError('close', err);
4246
+ }
4247
+ const branch = task.branch;
4248
+ const deletable = opts.deleteBranch && branch !== undefined && isAutoDeletableTaskBranch({
4249
+ taskId: task.id,
4250
+ taskBranch: branch,
4251
+ branchCreatedByBaxian: task.branchCreatedByBaxian,
4252
+ actualRef: `refs/heads/${branch}`,
4253
+ });
4254
+ if (deletable) {
4255
+ try {
4256
+ await driver.runOp('deleteBranch', { branch });
4257
+ }
4258
+ catch (err) {
4259
+ throw new PlatformCloseError('deleteBranch', err);
4260
+ }
4261
+ }
4262
+ }
4263
+ async platformScanComments(driver, prNumber) {
4264
+ return scanCommentSourcesOnce(driver, prNumber, () => Date.now());
4265
+ }
4266
+ // merge-ready 迁移与 merge confirm 的 provenance/ack 双复核共用一次全源扫描(spec §6/§10)
4267
+ async platformVerifyAcceptedPass(taskId) {
4268
+ const { task, driver } = await this.platformTaskAndDriver(taskId);
4269
+ if (task.prNumber === undefined)
4270
+ return { ok: false, reason: 'no bound PR' };
4271
+ const provenance = task.passProvenance;
4272
+ if (!provenance)
4273
+ return { ok: false, reason: 'no accepted pass provenance' };
4274
+ const scans = await this.platformScanComments(driver, task.prNumber);
4275
+ const recheck = recheckPassProvenance({
4276
+ token: provenance.token,
4277
+ failToken: provenance.failToken,
4278
+ anchorSha: provenance.anchorSha,
4279
+ carrier: { sourceKey: provenance.sourceKey, id: provenance.id, bodyDigest: provenance.bodyDigest },
4280
+ }, scans);
4281
+ if (!recheck.ok)
4282
+ return { ok: false, reason: `provenance ${recheck.reason}` };
4283
+ const pending = collectPendingFeedback(scans, {
4284
+ replyActorId: task.replyActorId,
4285
+ replyActorStatus: task.replyActorStatus,
4286
+ });
4287
+ if (!pending.allSourcesOk)
4288
+ return { ok: false, reason: 'source scan incomplete' };
4289
+ return { ok: true, pendingCount: pending.pending.size };
4290
+ }
4291
+ async platformPendingFeedback(taskId) {
4292
+ const { task, driver } = await this.platformTaskAndDriver(taskId);
4293
+ if (task.prNumber === undefined)
4294
+ throw new Error(`task ${taskId} has no bound PR`);
4295
+ const scans = await this.platformScanComments(driver, task.prNumber);
4296
+ const failed = scans.filter(s => !s.ok).map(s => s.key);
4297
+ if (failed.length > 0) {
4298
+ throw new Error(`pending-feedback scan incomplete; failed sources: ${failed.join(', ')}`);
4299
+ }
4300
+ return collectPendingFeedback(scans, {
4301
+ replyActorId: task.replyActorId,
4302
+ replyActorStatus: task.replyActorStatus,
4303
+ });
4304
+ }
4305
+ async platformConfirmMerge(taskId, opts) {
4306
+ const { task, driver } = await this.platformTaskAndDriver(taskId);
4307
+ if (task.prNumber === undefined)
4308
+ throw new Error(`task ${taskId} has no bound PR`);
4309
+ const prNumber = task.prNumber;
4310
+ const [row] = await driver.runOp('prView', { prNumber });
4311
+ const binding = checkOpenPrBinding(row, { branch: task.branch, expectedBase: task.baseBranch });
4312
+ if (!binding.ok)
4313
+ throw new Error(`merge blocked: binding ${binding.reason}`);
4314
+ if (String(row.headSha) !== opts.expectedHeadSha) {
4315
+ throw new Error(`merge blocked: head moved (expected ${opts.expectedHeadSha}, saw ${String(row.headSha)})`);
4316
+ }
4317
+ // 人工兜底(max_rounds Mark complete)绕过 QA 授权复核(该路径本就无 pass),
4318
+ // binding/head/REST sha 的安全检查保留(spec §7 威胁模型:merge 有人工 confirm)
4319
+ if (opts.humanOverride !== true) {
4320
+ const provenance = task.passProvenance;
4321
+ if (!provenance)
4322
+ throw new PlatformMergeRecheckError('no accepted pass provenance on task');
4323
+ if (provenance.anchorSha.toLowerCase() !== opts.expectedHeadSha.toLowerCase()) {
4324
+ throw new PlatformMergeRecheckError(`pass provenance anchors ${provenance.anchorSha}, not the merge head ${opts.expectedHeadSha}`);
4325
+ }
4326
+ const scans = await this.platformScanComments(driver, prNumber);
4327
+ const recheck = recheckPassProvenance({
4328
+ token: provenance.token,
4329
+ failToken: provenance.failToken,
4330
+ anchorSha: provenance.anchorSha,
4331
+ carrier: { sourceKey: provenance.sourceKey, id: provenance.id, bodyDigest: provenance.bodyDigest },
4332
+ }, scans);
4333
+ if (!recheck.ok) {
4334
+ // 瞬态扫描不完整留在 gate 可重试;只有确定性失效(撤销/编辑/缺失)才退回 approved
4335
+ if (recheck.reason === 'source-scan-incomplete') {
4336
+ throw new Error('merge blocked: provenance source-scan-incomplete');
4337
+ }
4338
+ throw new PlatformMergeRecheckError(`provenance ${recheck.reason}`);
4339
+ }
4340
+ const pending = collectPendingFeedback(scans, {
4341
+ replyActorId: task.replyActorId,
4342
+ replyActorStatus: task.replyActorStatus,
4343
+ });
4344
+ if (!pending.allSourcesOk)
4345
+ throw new Error('merge blocked: pending-feedback scan incomplete');
4346
+ if (pending.pending.size > 0) {
4347
+ throw new PlatformMergeRecheckError(`${pending.pending.size} pending feedback revision(s) without ack`);
4348
+ }
4349
+ }
4350
+ // 临门 CAS:扫描期间 durable 退回(approved/pendingRedispatch/fixing)必须使本次 Confirm 失效;
4351
+ // REST sha 只保护 source head,保护不了评论面与 TaskState(残余网络窗口按 spec §6 如实声明)
4352
+ const fresh = await this.taskStore.get(taskId);
4353
+ if (!fresh
4354
+ || (opts.humanOverride === true
4355
+ // override 只绕过 provenance/ack,不绕过本次认领的 gate 代际:并发 RC 转 fixing 即失效
4356
+ ? fresh.status !== 'merge-ready'
4357
+ : (fresh.status !== 'merge-ready' && fresh.status !== 'ready'))
4358
+ || fresh.latestHeadSha !== opts.expectedHeadSha
4359
+ || fresh.pendingRedispatch === true) {
4360
+ throw new Error(`merge blocked: task left its merge gate (status=${fresh?.status ?? 'gone'})`);
4361
+ }
4362
+ try {
4363
+ await driver.runOp('merge', { prNumber, expectedHeadSha: opts.expectedHeadSha });
4364
+ }
4365
+ catch (err) {
4366
+ if (err instanceof DriverOpError && err.info.errorClass === 'MERGE_BLOCKED') {
4367
+ let mergeStatus;
4368
+ try {
4369
+ const [fresh] = await driver.runOp('prView', { prNumber });
4370
+ if (typeof fresh?.detailedMergeStatus === 'string')
4371
+ mergeStatus = fresh.detailedMergeStatus;
4372
+ }
4373
+ catch {
4374
+ // 归因查询失败不遮蔽原始错误
4375
+ }
4376
+ throw new Error(`merge blocked by platform${mergeStatus ? ` (${mergeStatus})` : ''}: ${err.message}`);
4377
+ }
4378
+ throw err;
4379
+ }
4380
+ }
4381
+ mintReviewTokenPair() {
4382
+ const passToken = createSignalToken();
4383
+ let failToken = createSignalToken();
4384
+ while (failToken === passToken)
4385
+ failToken = createSignalToken();
4386
+ return { passToken, failToken };
4387
+ }
3632
4388
  async listTasksByProject(projectId) {
3633
4389
  return this.taskStore.list({ projectId });
3634
4390
  }
@@ -3684,6 +4440,7 @@ export class AgentManager {
3684
4440
  return null;
3685
4441
  if (!guard.fromStatus.includes(previousStatus))
3686
4442
  return null;
4443
+ // 代际 CAS(spec §7):校验后提交前的并发换代(push/重派轮换 token+pair)使旧 verdict 失效
3687
4444
  if (guard.expectSignalToken !== undefined && task.signalToken !== guard.expectSignalToken)
3688
4445
  return null;
3689
4446
  if (patch?.branch && patch.branch !== task.branch) {
@@ -3871,6 +4628,7 @@ export class AgentManager {
3871
4628
  branch: taskBranch,
3872
4629
  branchCreatedByBaxian,
3873
4630
  reviewMode: this.effectiveReviewMode(projectId),
4631
+ ...this.platformBindingFields(projectId),
3874
4632
  createdAt: now,
3875
4633
  updatedAt: now,
3876
4634
  ...(imageFilenames ? { images: imageFilenames } : {}),
@@ -3946,7 +4704,7 @@ export class AgentManager {
3946
4704
  const task = await this.createTask(projectId, input);
3947
4705
  if (task.status === 'in_progress' && task.agentId) {
3948
4706
  const signalToken = createSignalToken();
3949
- await this.updateTask(task.id, { signalToken });
4707
+ await this.updateTask(task.id, this.dispatchTokenFields(task, signalToken));
3950
4708
  const dispatchLockToken = (await this.agentStore.get(task.agentId))?.lockToken;
3951
4709
  const start = this.startCreatedTaskSession(task.id, task.agentId, signalToken, dispatchLockToken);
3952
4710
  if (opts.background) {
@@ -4306,7 +5064,7 @@ export class AgentManager {
4306
5064
  return claim;
4307
5065
  const claimed = claim.task;
4308
5066
  const signalToken = createSignalToken();
4309
- await this.updateTask(claimed.id, { signalToken });
5067
+ await this.updateTask(claimed.id, this.dispatchTokenFields(claimed, signalToken));
4310
5068
  let started = false;
4311
5069
  let dispatchErr = null;
4312
5070
  const initialDispatch = this.resolveInitialDispatch(claimed);
@@ -5385,6 +6143,13 @@ export class AgentManager {
5385
6143
  }
5386
6144
  }
5387
6145
  async recover() {
6146
+ // 'git' outbox 重投先行:崩溃于「效果已写、通知未达」的窗口由此闭合(spec §6),legacy 任务零涉
6147
+ await this.flushGitOutbox().catch(err => {
6148
+ console.warn('[AgentManager] recover: git outbox flush failed:', err);
6149
+ });
6150
+ await this.retryGitRemoteBranchCleanup().catch(err => {
6151
+ console.warn('[AgentManager] recover: git remote branch cleanup retry failed:', err);
6152
+ });
5388
6153
  const states = await this.agentStore.list();
5389
6154
  await this.releaseOrphanedLocks(states);
5390
6155
  const deferredCleanups = [];
@@ -5775,7 +6540,11 @@ export class AgentManager {
5775
6540
  }
5776
6541
  const project = this.getProjectConfig(result.projectId);
5777
6542
  try {
5778
- if (project) {
6543
+ if (project && result.reviewMode === 'git') {
6544
+ // close 不附评论(spec §10 有意差异);deleteBranch 由 isAutoDeletableTaskBranch 守卫
6545
+ await this.platformClosePr(taskId, { deleteBranch: true });
6546
+ }
6547
+ else if (project) {
5779
6548
  const close = await execNetwork(this.platformRunner, `gh pr close ${publishedCleanup.prNumber} --repo ${shellQuote(repoSlug(project.repo))} ` +
5780
6549
  `--comment ${shellQuote('Task cancelled in baxian; closing the published PR.')}`, GH_NET);
5781
6550
  if (close.exitCode !== 0)
@@ -5784,6 +6553,26 @@ export class AgentManager {
5784
6553
  }
5785
6554
  catch (err) {
5786
6555
  console.warn(`[AgentManager] cancelTask remote retirement failed for ${taskId}:`, err);
6556
+ if (result.reviewMode === 'git') {
6557
+ await this.withTaskLock(async () => {
6558
+ const fresh = await this.taskStore.get(taskId);
6559
+ if (!fresh)
6560
+ return;
6561
+ await this.taskStore.set({
6562
+ ...fresh,
6563
+ branchCleanupPending: {
6564
+ agentId: fresh.devAgentId,
6565
+ reason: err instanceof PlatformCloseError && err.step === 'close'
6566
+ ? 'remote-close-failed'
6567
+ : 'remote-delete-failed',
6568
+ updatedAt: new Date().toISOString(),
6569
+ },
6570
+ updatedAt: new Date().toISOString(),
6571
+ });
6572
+ }).catch(persistErr => {
6573
+ console.warn(`[AgentManager] cancelTask cleanup marker persist failed for ${taskId}:`, persistErr);
6574
+ });
6575
+ }
5787
6576
  await this.emitIntervention(result.projectId, undefined, taskId, {
5788
6577
  phase: 'cancel-published-artifact-cleanup-failed',
5789
6578
  afterDone: publishedCleanup.afterDone,
@@ -5946,6 +6735,8 @@ export class AgentManager {
5946
6735
  signalToken: pre?.signalToken,
5947
6736
  reviewHeadAnchorSha: pre?.reviewHeadAnchorSha,
5948
6737
  reviewDispatchedAt: pre?.reviewDispatchedAt,
6738
+ passToken: pre?.passToken,
6739
+ failToken: pre?.failToken,
5949
6740
  };
5950
6741
  const isTerminalAtClaim = TERMINAL_STATUSES.includes(taskStatusAtClaim);
5951
6742
  // 仅当恢复的是任务当前 review pass 时沿用轮次;任务已离开 review 的手工调用是新 pass,保留加轮
@@ -5960,8 +6751,45 @@ export class AgentManager {
5960
6751
  await this.emitManualReviewDevParkedQaFailedIntervention(devAgentId, taskId);
5961
6752
  };
5962
6753
  let transitionToken;
6754
+ let reviewAnchor;
5963
6755
  if (!isTerminalAtClaim) {
5964
- const reviewAnchor = await this.fetchPrHeadSha(taskId).catch(() => undefined);
6756
+ let gitDispatchBlocked;
6757
+ const anchorViaDriver = async () => {
6758
+ const preTask = await this.taskStore.get(taskId);
6759
+ if (preTask?.prNumber === undefined) {
6760
+ gitDispatchBlocked = 'no bound PR';
6761
+ return undefined;
6762
+ }
6763
+ // 与收编/pr-fixed/merge 同一完整谓词:closed/draft/retarget 的 PR 派出去只会造出
6764
+ // 不可裁决的评审轮(spec §6 统一谓词)
6765
+ const verify = await this.platformVerifyPrBinding(taskId, preTask.prNumber);
6766
+ if (!verify.ok) {
6767
+ gitDispatchBlocked = `binding ${verify.reason}`;
6768
+ return undefined;
6769
+ }
6770
+ return PLATFORM_HEAD_SHA_RE.test(verify.headSha) ? verify.headSha : undefined;
6771
+ };
6772
+ const isGitDispatch = (await this.taskStore.get(taskId))?.reviewMode === 'git';
6773
+ reviewAnchor = await (isGitDispatch ? anchorViaDriver() : this.fetchPrHeadSha(taskId))
6774
+ .catch(() => undefined);
6775
+ if (isGitDispatch && reviewAnchor === undefined) {
6776
+ // 无锚评审轮不可裁决(verdictEligible 要求 anchorSha 且 pane verdict 已退役):
6777
+ // 派出去只会卡死在 review,fail loud 让调用方看到确定性原因或重试瞬态失败
6778
+ const released = await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => false);
6779
+ if (!released) {
6780
+ // 预检失败叠加释放失败:QA 仍绑定会让下一次派发报 busy,必须显式暴露
6781
+ const blockedTask = await this.taskStore.get(taskId);
6782
+ await this.emitIntervention(blockedTask?.projectId ?? '', qaId, taskId, {
6783
+ phase: 'qa-release-failed-after-precheck',
6784
+ qaAgentId: qaId,
6785
+ }).catch(() => undefined);
6786
+ }
6787
+ if (devParked)
6788
+ await this.emitManualReviewDevParkedQaFailedIntervention(devAgentId, taskId);
6789
+ throw gitDispatchBlocked !== undefined
6790
+ ? new ApiError(409, `Cannot dispatch git review for ${taskId}: ${gitDispatchBlocked}`)
6791
+ : new ApiError(502, `Cannot anchor the review round for ${taskId}: platform prView failed`);
6792
+ }
5965
6793
  const preDispatched = await this.transitionTaskStatus(taskId, 'review', {
5966
6794
  fromStatus: [taskStatusAtClaim],
5967
6795
  // 入口 claim 到此处之间 pass 可能被并发轮换;expect token 必须与写入同一原子段复核
@@ -5970,6 +6798,8 @@ export class AgentManager {
5970
6798
  reviewHeadAnchorSha: reviewAnchor,
5971
6799
  reviewDispatchedAt: new Date().toISOString(),
5972
6800
  signalToken: createSignalToken(),
6801
+ // pair/pending 与新轮锚点同一原子写可见(spec §7):两写之间崩溃不得留下无 pending 的新轮
6802
+ ...(isGitDispatch ? { ...this.mintReviewTokenPair(), reviewDispatchPending: true } : {}),
5973
6803
  });
5974
6804
  if (!preDispatched) {
5975
6805
  // fence 拒绝可能源于 successor 已换代并 re-acquire 同一 QA;释放必须钉在本次 acquire 的锁代上
@@ -5990,10 +6820,16 @@ export class AgentManager {
5990
6820
  if (!isTerminalAtClaim && (fresh.status !== 'review' || fresh.signalToken !== transitionToken)) {
5991
6821
  return { ok: false, reason: fresh.status !== 'review' ? `status=${fresh.status}` : 'pass superseded' };
5992
6822
  }
6823
+ // 并发 push 在 prView 读取与本提交之间前进 head 时,本次手动锚点已陈旧(TOCTOU)
6824
+ if (fresh.reviewMode === 'git' && fresh.latestHeadSha !== undefined
6825
+ && reviewAnchor !== undefined && fresh.latestHeadSha !== reviewAnchor) {
6826
+ return { ok: false, reason: 'head advanced past the manual anchor' };
6827
+ }
5993
6828
  await this.taskStore.set({
5994
6829
  ...fresh,
5995
6830
  reviewRound: bumpRound ? fresh.reviewRound + 1 : fresh.reviewRound,
5996
6831
  signalToken: armedToken,
6832
+ ...(fresh.reviewMode === 'git' ? { reviewDispatchPending: true } : {}),
5997
6833
  updatedAt: new Date().toISOString(),
5998
6834
  });
5999
6835
  return { ok: true };
@@ -6003,7 +6839,11 @@ export class AgentManager {
6003
6839
  await abortDispatch(transitionToken);
6004
6840
  throw new ApiError(409, `Task ${taskId} left its review pass during dispatch (${claim2.reason})`);
6005
6841
  }
6006
- const armed = await this.setupPhaseSignalWatcher(taskId, qaId, ['pr-approved', 'pr-changes-requested'], armedToken);
6842
+ const dispatchTaskAfterClaim = await this.taskStore.get(taskId);
6843
+ const armed = await this.setupPhaseSignalWatcher(taskId, qaId, ['pr-approved', 'pr-changes-requested'], armedToken, dispatchTaskAfterClaim?.reviewMode === 'git' ? { replaceScope: 'agent' } : {});
6844
+ if (armed && dispatchTaskAfterClaim?.reviewMode === 'git') {
6845
+ await this.rearmGitReconciliationWatcher(taskId);
6846
+ }
6007
6847
  if (!armed) {
6008
6848
  await abortDispatch(armedToken);
6009
6849
  throw new ApiError(500, `Failed to arm review verdict watcher for ${taskId}`);
@@ -6043,6 +6883,7 @@ export class AgentManager {
6043
6883
  await abortDispatch(armedToken);
6044
6884
  throw new ApiError(500, `Failed to start QA review session for ${taskId}`);
6045
6885
  }
6886
+ await this.clearReviewDispatchPending(taskId, armedToken);
6046
6887
  const final = await this.taskStore.get(taskId);
6047
6888
  return final;
6048
6889
  }
@@ -6207,6 +7048,9 @@ export class AgentManager {
6207
7048
  qaAgentId: snapshot.qaAgentId,
6208
7049
  signalToken: snapshot.signalToken,
6209
7050
  reviewDispatchedAt: snapshot.reviewDispatchedAt,
7051
+ // 本代新铸的 pair 从未交付 QA:不随快照恢复会让旧 QA 的在途 verdict 被拒、新 pair 无人知
7052
+ passToken: snapshot.passToken,
7053
+ failToken: snapshot.failToken,
6210
7054
  updatedAt: new Date().toISOString(),
6211
7055
  };
6212
7056
  if (!isTerminalAtClaim && !drifted) {
@@ -6224,8 +7068,10 @@ export class AgentManager {
6224
7068
  return qaTakenOverInReview;
6225
7069
  }
6226
7070
  if (afterRollback?.status === 'approved') {
6227
- const completion = await this.postApproveStore.get(taskId);
6228
- const task = await this.taskStore.get(taskId);
7071
+ const completion = afterRollback.reviewMode === 'git'
7072
+ ? this.gitCompletionOf(afterRollback)
7073
+ : await this.postApproveStore.get(taskId);
7074
+ const task = afterRollback;
6229
7075
  if (completion && task) {
6230
7076
  try {
6231
7077
  await this.startPhaseSignalWatch({
@@ -6246,21 +7092,30 @@ export class AgentManager {
6246
7092
  const restored = await this.taskStore.get(taskId);
6247
7093
  if (!restored || !restored.signalToken)
6248
7094
  return qaTakenOverInReview;
6249
- const mapped = this.mapTaskStateToExpectedWatcher(restored);
6250
- if (!mapped)
7095
+ const mappings = this.mapTaskStateToExpectedWatchers(restored);
7096
+ if (mappings.length === 0)
6251
7097
  return qaTakenOverInReview;
7098
+ for (const mapped of mappings) {
7099
+ try {
7100
+ await this.startPhaseSignalWatch({
7101
+ taskId,
7102
+ projectId: restored.projectId,
7103
+ agentId: mapped.agentId,
7104
+ expectedKinds: mapped.expectedKinds,
7105
+ token: mapped.token ?? restored.signalToken,
7106
+ ...(mappings.length > 1 ? { replaceScope: 'agent' } : {}),
7107
+ });
7108
+ }
7109
+ catch (err) {
7110
+ console.warn(`[AgentManager] rollback: re-establish ${mapped.expectedKinds.join(',')} failed for task=${taskId}:`, err);
7111
+ }
7112
+ }
7113
+ // 回滚路径不得因 tearDown 探测失败上抛:那会顶替调用方本应抛出的 ApiError 并跳过 QA 释放
6252
7114
  try {
6253
- await this.startPhaseSignalWatch({
6254
- taskId,
6255
- projectId: restored.projectId,
6256
- agentId: mapped.agentId,
6257
- expectedKinds: mapped.expectedKinds,
6258
- token: restored.signalToken,
6259
- });
6260
7115
  await this.tearDownWatcherIfTaskTerminal(taskId);
6261
7116
  }
6262
7117
  catch (err) {
6263
- console.warn(`[AgentManager] rollback: re-establish ${mapped.expectedKinds.join(',')} failed for task=${taskId}:`, err);
7118
+ console.warn(`[AgentManager] rollback: terminal watcher teardown failed for task=${taskId}:`, err);
6264
7119
  }
6265
7120
  return qaTakenOverInReview;
6266
7121
  }
@@ -6282,6 +7137,22 @@ export class AgentManager {
6282
7137
  return { phase: 'research', kinds: ['spec-done'] };
6283
7138
  return { phase: 'develop', kinds: this.devInitialSignalKinds(task.reviewMode) };
6284
7139
  }
7140
+ mapTaskStateToExpectedWatchers(task) {
7141
+ const single = this.mapTaskStateToExpectedWatcher(task);
7142
+ const mappings = single ? [single] : [];
7143
+ // review 窗口内 dev/QA 双条目并存:review 之外 dev 侧另有 pr-fixed/pr-merge-ready 同 agent
7144
+ // 期望,(taskId, agentId) 基数无法同 agent 双 token 并存——错过窗口按 fail-closed 交人工(spec §5.3 ④)
7145
+ if (task.reviewMode === 'git' && task.status === 'review'
7146
+ && task.replyActorStatus !== 'verified' && task.pendingPrSignalToken !== undefined
7147
+ && task.devAgentId && task.prNumber !== undefined) {
7148
+ mappings.push({
7149
+ expectedKinds: ['pr-created'],
7150
+ agentId: task.devAgentId,
7151
+ token: task.pendingPrSignalToken,
7152
+ });
7153
+ }
7154
+ return mappings;
7155
+ }
6285
7156
  mapTaskStateToExpectedWatcher(task) {
6286
7157
  if (task.reviewMode === 'server')
6287
7158
  return this.mapServerTaskToExpectedWatcher(task);
@@ -6334,8 +7205,9 @@ export class AgentManager {
6334
7205
  const task = await this.taskStore.get(taskId);
6335
7206
  if (!task?.signalToken)
6336
7207
  return false;
6337
- return this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, task.signalToken, {
7208
+ return this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, opts.tokenOverride ?? task.signalToken, {
6338
7209
  skipSnapshot: opts.skipSnapshot ?? false,
7210
+ ...(opts.replaceScope ? { replaceScope: opts.replaceScope } : {}),
6339
7211
  });
6340
7212
  }
6341
7213
  async emitManualReviewDevParkedQaFailedIntervention(agentId, expectedTaskId) {
@@ -6443,6 +7315,17 @@ export class AgentManager {
6443
7315
  if (!project) {
6444
7316
  throw new Error(`mergePr: unknown project ${task.projectId}`);
6445
7317
  }
7318
+ if (task.reviewMode === 'git') {
7319
+ const expectedHeadSha = opts.matchHeadSha ?? task.latestHeadSha;
7320
+ if (!expectedHeadSha)
7321
+ throw new Error(`mergePr: no expected head sha for git task ${taskId}`);
7322
+ // max_rounds 的 Mark complete 在调用前已被转成 merge-ready:override 必须显式随调用链传入
7323
+ await this.platformConfirmMerge(taskId, {
7324
+ expectedHeadSha,
7325
+ ...(opts.humanOverride === true || task.status === 'max_rounds' ? { humanOverride: true } : {}),
7326
+ });
7327
+ return;
7328
+ }
6446
7329
  const matchHead = opts.matchHeadSha
6447
7330
  ? ` --match-head-commit ${shellQuote(opts.matchHeadSha)}`
6448
7331
  : '';
@@ -6517,7 +7400,8 @@ export class AgentManager {
6517
7400
  throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
6518
7401
  }
6519
7402
  try {
6520
- await this.mergePr(taskId);
7403
+ // 从 max_rounds 认领而来:人工兜底 override 必须显式随调用链传入(此刻 status 已是 merge-ready)
7404
+ await this.mergePr(taskId, { humanOverride: true });
6521
7405
  }
6522
7406
  catch (err) {
6523
7407
  await this.transitionTaskStatus(taskId, 'max_rounds', { fromStatus: ['merge-ready'] })
@@ -6847,6 +7731,7 @@ export class AgentManager {
6847
7731
  token,
6848
7732
  skipSnapshot: opts.skipSnapshot ?? false,
6849
7733
  ...(opts.onlyReplaceOwnToken ? { onlyReplaceOwnToken: true } : {}),
7734
+ ...(opts.replaceScope ? { replaceScope: opts.replaceScope } : {}),
6850
7735
  ...(opts.onReadFile ? { onReadFile: opts.onReadFile } : {}),
6851
7736
  });
6852
7737
  }
@@ -6867,10 +7752,40 @@ export class AgentManager {
6867
7752
  const label = (Array.isArray(expectedKinds) ? [...expectedKinds] : [expectedKinds]).join(',');
6868
7753
  await this.markAwaitingHuman(agentId, `signal-arm-failed:${label}`, 'Pane-signal watcher failed to arm after dispatch; the prompt expects a signal with no consumer. Cancel the task or delete the agent to retry.', { expectedTaskId: taskId });
6869
7754
  }
7755
+ // git 任务的初始 dev 派发把 pr-created 期望 token 独立持久化:评审轮轮换不得使 late
7756
+ // publish 信号失去 reconciliation 凭据(spec §5.3 ④)。
7757
+ dispatchTokenFields(task, signalToken) {
7758
+ const wantsPrCreated = task.reviewMode === 'git'
7759
+ && this.devInitialSignalKinds(task.reviewMode).includes('pr-created');
7760
+ return { signalToken, ...(wantsPrCreated ? { pendingPrSignalToken: signalToken } : {}) };
7761
+ }
7762
+ // review 窗口内 QA verdict arm 之后重建 dev reconciliation 条目(spec §5.3 ④):approval 路径的
7763
+ // stop(taskId) 与 fix 轮的同键替换都会拆掉它,重入 review 时在此恢复;skipSnapshot=false 让仍在
7764
+ // 面板滚动区的迟到信号立即重放。
7765
+ async rearmGitReconciliationWatcher(taskId, opts = {}) {
7766
+ const task = await this.taskStore.get(taskId);
7767
+ if (!task || task.reviewMode !== 'git' || task.status !== 'review')
7768
+ return;
7769
+ if (task.replyActorStatus === 'verified' || task.pendingPrSignalToken === undefined)
7770
+ return;
7771
+ if (!task.devAgentId || task.prNumber === undefined)
7772
+ return;
7773
+ await this.setupPhaseSignalWatcher(taskId, task.devAgentId, ['pr-created'], task.pendingPrSignalToken, {
7774
+ replaceScope: 'agent',
7775
+ ...(opts.skipSnapshot ? { skipSnapshot: true } : {}),
7776
+ }).catch(() => false);
7777
+ }
6870
7778
  async rotateAndSetupPhaseSignal(taskId, agentId, expectedKinds) {
6871
7779
  const newToken = createSignalToken();
7780
+ const task = await this.taskStore.get(taskId);
7781
+ const kinds = typeof expectedKinds === 'string' ? [expectedKinds] : [...expectedKinds];
7782
+ const isGit = task?.reviewMode === 'git';
7783
+ // pair 的重铸已随「进入 review 的 transition」单写落盘(spec §7 与新轮可见性原子);
7784
+ // 这里只轮换 signal token 并布防
6872
7785
  await this.updateTask(taskId, { signalToken: newToken });
6873
- const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, newToken);
7786
+ const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, newToken, isGit ? { replaceScope: 'agent' } : {});
7787
+ if (armed && isGit && kinds.includes('pr-approved'))
7788
+ await this.rearmGitReconciliationWatcher(taskId);
6874
7789
  return { token: newToken, armed };
6875
7790
  }
6876
7791
  async rollbackVerdictArmFailure(taskId, restore, opts = {}) {
@@ -6890,6 +7805,9 @@ export class AgentManager {
6890
7805
  signalToken: restore.signalToken,
6891
7806
  reviewHeadAnchorSha: restore.reviewHeadAnchorSha,
6892
7807
  reviewDispatchedAt: restore.reviewDispatchedAt,
7808
+ ...(restore.restorePair === true
7809
+ ? { passToken: restore.passToken, failToken: restore.failToken }
7810
+ : {}),
6893
7811
  updatedAt: new Date().toISOString(),
6894
7812
  });
6895
7813
  return true;
@@ -6907,10 +7825,16 @@ export class AgentManager {
6907
7825
  return;
6908
7826
  if (TERMINAL_STATUSES.includes(task.status))
6909
7827
  return;
6910
- const mapped = this.mapTaskStateToExpectedWatcher(task);
6911
- if (!mapped)
7828
+ const mappings = this.mapTaskStateToExpectedWatchers(task);
7829
+ if (mappings.length === 0)
6912
7830
  return;
6913
- await this.setupPhaseSignal(taskId, mapped.agentId, mapped.expectedKinds, opts.skipSnapshot ? { skipSnapshot: true } : undefined);
7831
+ for (const mapped of mappings) {
7832
+ await this.setupPhaseSignal(taskId, mapped.agentId, mapped.expectedKinds, {
7833
+ ...(opts.skipSnapshot ? { skipSnapshot: true } : {}),
7834
+ ...(mappings.length > 1 ? { replaceScope: 'agent' } : {}),
7835
+ ...(mapped.token !== undefined ? { tokenOverride: mapped.token } : {}),
7836
+ });
7837
+ }
6914
7838
  await this.tearDownWatcherIfTaskTerminal(taskId);
6915
7839
  }
6916
7840
  async parkTaskAtSpecReady(taskId, opts = {}) {
@@ -7328,7 +8252,7 @@ export class AgentManager {
7328
8252
  const acquiredLockToken = (await this.agentStore.get(devAgentId))?.lockToken;
7329
8253
  const newToken = createSignalToken();
7330
8254
  // spec 阶段的扩轮决策不延续到 code 评审:cap 共享字段,进入编码时归零
7331
- const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready', 'max_rounds'] }, { agentId: devAgentId, phase: 'code', signalToken: newToken, maxRoundsContinues: 0 });
8255
+ const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready', 'max_rounds'] }, { agentId: devAgentId, phase: 'code', maxRoundsContinues: 0, ...this.dispatchTokenFields({ ...task, phase: 'code' }, newToken) });
7332
8256
  if (!transition) {
7333
8257
  await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
7334
8258
  .catch(() => undefined);
@@ -8007,6 +8931,38 @@ export class AgentManager {
8007
8931
  }
8008
8932
  catch (err) {
8009
8933
  const message = err instanceof Error ? err.message : String(err);
8934
+ if (err instanceof PlatformMergeRecheckError && task.reviewMode === 'git' && task.status === 'merge-ready') {
8935
+ // 复核类失效(provenance 撤销/未 ack 反馈)没有「再 Confirm」的出路:退回 approved 置
8936
+ // durable pending 重入 post-approve 闭环(计划 Task 8),把失效授权留在 gate 只剩 Cancel
8937
+ const retreated = await this.withTaskLock(async () => {
8938
+ const fresh = await this.taskStore.get(task.id);
8939
+ if (!fresh || fresh.status !== 'merge-ready')
8940
+ return false;
8941
+ const approvedHead = typeof fresh.latestHeadSha === 'string' && PLATFORM_HEAD_SHA_RE.test(fresh.latestHeadSha)
8942
+ ? fresh.latestHeadSha
8943
+ : undefined;
8944
+ if (approvedHead === undefined)
8945
+ return false;
8946
+ await this.taskStore.set({
8947
+ ...fresh,
8948
+ status: 'approved',
8949
+ postApproveHeadSha: approvedHead,
8950
+ pendingRedispatch: true,
8951
+ updatedAt: new Date().toISOString(),
8952
+ });
8953
+ return true;
8954
+ });
8955
+ await this.emitIntervention(task.projectId, undefined, task.id, {
8956
+ phase: 'confirm-merge-recheck-failed',
8957
+ gate: task.status,
8958
+ error: message,
8959
+ retreated,
8960
+ note: retreated
8961
+ ? 'Accepted pass no longer verifies; task returned to approved for the post-approve loop to re-settle.'
8962
+ : 'Accepted pass no longer verifies and the task could not be returned automatically; inspect and re-dispatch.',
8963
+ });
8964
+ throw new ApiError(409, `Merge blocked for task ${task.id}: ${message}`);
8965
+ }
8010
8966
  await this.emitIntervention(task.projectId, undefined, task.id, {
8011
8967
  phase: 'confirm-merge-failed',
8012
8968
  gate: task.status,