baxian 1.2.49 → 1.2.51

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 (36) hide show
  1. package/dist/agent/lineage.d.ts +14 -0
  2. package/dist/agent/lineage.d.ts.map +1 -0
  3. package/dist/agent/lineage.js +43 -0
  4. package/dist/agent/lineage.js.map +1 -0
  5. package/dist/agent/manager.d.ts +19 -0
  6. package/dist/agent/manager.d.ts.map +1 -1
  7. package/dist/agent/manager.js +280 -59
  8. package/dist/agent/manager.js.map +1 -1
  9. package/dist/agent/net-exec.d.ts +16 -0
  10. package/dist/agent/net-exec.d.ts.map +1 -0
  11. package/dist/agent/net-exec.js +78 -0
  12. package/dist/agent/net-exec.js.map +1 -0
  13. package/dist/agent/preflight.d.ts.map +1 -1
  14. package/dist/agent/preflight.js +19 -5
  15. package/dist/agent/preflight.js.map +1 -1
  16. package/dist/agent/repo-store.d.ts +3 -0
  17. package/dist/agent/repo-store.d.ts.map +1 -1
  18. package/dist/agent/repo-store.js +52 -8
  19. package/dist/agent/repo-store.js.map +1 -1
  20. package/dist/agent/review-transport.d.ts.map +1 -1
  21. package/dist/agent/review-transport.js +8 -1
  22. package/dist/agent/review-transport.js.map +1 -1
  23. package/dist/agent/worktree.d.ts +1 -0
  24. package/dist/agent/worktree.d.ts.map +1 -1
  25. package/dist/agent/worktree.js +41 -17
  26. package/dist/agent/worktree.js.map +1 -1
  27. package/dist/event/handlers.d.ts.map +1 -1
  28. package/dist/event/handlers.js +76 -28
  29. package/dist/event/handlers.js.map +1 -1
  30. package/dist/event/server-handlers.d.ts.map +1 -1
  31. package/dist/event/server-handlers.js +117 -52
  32. package/dist/event/server-handlers.js.map +1 -1
  33. package/dist/web/assets/index-CJhHx7jR.js +11 -0
  34. package/dist/web/index.html +1 -1
  35. package/package.json +1 -1
  36. package/dist/web/assets/index-Bc2bgdOQ.js +0 -11
@@ -3,11 +3,13 @@ import { join } from 'node:path';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { createSignalToken } from './phase-signal.js';
5
5
  import { tmuxInstallHint } from './preflight.js';
6
- 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, isGitHubRepo, repoSlug, } from '../shared/index.js';
6
+ 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, isGitHubRepo, parseGitRemote, repoSlug, } from '../shared/index.js';
7
7
  import { AGENT_STORE_NOOP } from '../state/agent-store.js';
8
8
  import { PostApproveStore } from '../state/post-approve-store.js';
9
9
  import { SkillRegistry } from '../skill/registry.js';
10
10
  import { createRunner, LocalRunner, shellQuote, resolveAgentHost, hostGroupKey } from './runner.js';
11
+ import { GH_EXEC_TIMEOUT_MS, GIT_NET_ENV, execNetwork } from './net-exec.js';
12
+ import { findForeignTaskTip } from './lineage.js';
11
13
  import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
12
14
  import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, hasOscTitleWorking, hasOscTitleIdle, screenAllowsTitleIdle, } from './tmux.js';
13
15
  import { WorktreeManager } from './worktree.js';
@@ -74,6 +76,8 @@ function agentRuntimeKindFor(agent) {
74
76
  }
75
77
  const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
76
78
  const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
79
+ const GH_NET = { timeout: GH_EXEC_TIMEOUT_MS, retries: 1 };
80
+ const MANUAL_SERVER_REVIEW_STATUSES = ['in_progress', 'review', 'fixing'];
77
81
  const IMAGE_DISPATCH_PHASES = new Set(['develop', 'code', 'fix', 'server-feedback']);
78
82
  const RUNTIME_LIVENESS_SAMPLES = 3;
79
83
  export function canDispatchWithBinding(binding) {
@@ -120,6 +124,7 @@ export class AgentManager {
120
124
  errorRecordStore;
121
125
  reviewStore;
122
126
  reviewTransportInstance;
127
+ serverReviewDriver;
123
128
  dispatchAckTimeoutMs;
124
129
  dispatchSettleTimeoutMs;
125
130
  dispatchAckResendIntervalMs = 3_000;
@@ -189,6 +194,9 @@ export class AgentManager {
189
194
  getReviewStore() {
190
195
  return this.reviewStore;
191
196
  }
197
+ setServerReviewDriver(driver) {
198
+ this.serverReviewDriver = driver;
199
+ }
192
200
  effectiveReviewMode(projectId) {
193
201
  const project = this.getProjectConfig(projectId);
194
202
  return project?.review?.mode ?? this.config.review.mode ?? 'github';
@@ -2130,7 +2138,7 @@ export class AgentManager {
2130
2138
  });
2131
2139
  }
2132
2140
  async ghCreatedAt(endpoint, jq) {
2133
- const result = await this.platformRunner.exec(`gh api --paginate ${shellQuote(endpoint)} --jq ${shellQuote(jq)}`);
2141
+ const result = await execNetwork(this.platformRunner, `gh api --paginate ${shellQuote(endpoint)} --jq ${shellQuote(jq)}`, GH_NET);
2134
2142
  if (result.exitCode !== 0) {
2135
2143
  throw new Error(`gh api ${endpoint} failed: ${result.stderr || result.stdout}`);
2136
2144
  }
@@ -2145,7 +2153,7 @@ export class AgentManager {
2145
2153
  if (!project) {
2146
2154
  throw new Error(`fetchPrHeadSha: unknown project ${task.projectId}`);
2147
2155
  }
2148
- const result = await this.platformRunner.exec(`gh pr view ${task.prNumber} --repo ${shellQuote(repoSlug(project.repo))} --json headRefOid --jq .headRefOid`);
2156
+ const result = await execNetwork(this.platformRunner, `gh pr view ${task.prNumber} --repo ${shellQuote(repoSlug(project.repo))} --json headRefOid --jq .headRefOid`, GH_NET);
2149
2157
  if (result.exitCode !== 0) {
2150
2158
  throw new Error(`gh pr view failed for PR #${task.prNumber}: ${result.stderr || result.stdout}`);
2151
2159
  }
@@ -2162,8 +2170,8 @@ export class AgentManager {
2162
2170
  const project = this.getProjectConfig(task.projectId);
2163
2171
  if (!project)
2164
2172
  return undefined;
2165
- const result = await this.platformRunner.exec(`gh pr view ${prNumber} --repo ${shellQuote(repoSlug(project.repo))} --json headRefName,headRefOid --jq '.headRefName + "\\t" + .headRefOid'`);
2166
- if (result.exitCode !== 0)
2173
+ const result = await execNetwork(this.platformRunner, `gh pr view ${prNumber} --repo ${shellQuote(repoSlug(project.repo))} --json headRefName,headRefOid --jq '.headRefName + "\\t" + .headRefOid'`, GH_NET).catch(() => undefined);
2174
+ if (!result || result.exitCode !== 0)
2167
2175
  return undefined;
2168
2176
  const [headRefName, headSha] = result.stdout.trim().split('\t');
2169
2177
  if (!headRefName || !/^[0-9a-f]{40}$/i.test(headSha))
@@ -2176,7 +2184,7 @@ export class AgentManager {
2176
2184
  const project = this.getProjectConfig(projectId);
2177
2185
  if (!project)
2178
2186
  return undefined;
2179
- const result = await this.platformRunner.exec(`gh pr view ${prNumber} --repo ${shellQuote(repoSlug(project.repo))} --json headRefName,headRefOid,body,state,isCrossRepository --jq '.headRefName + "\\t" + .headRefOid + "\\t" + .state + "\\t" + (.isCrossRepository | tostring) + "\\t" + .body'`);
2187
+ const result = await execNetwork(this.platformRunner, `gh pr view ${prNumber} --repo ${shellQuote(repoSlug(project.repo))} --json headRefName,headRefOid,body,state,isCrossRepository --jq '.headRefName + "\\t" + .headRefOid + "\\t" + .state + "\\t" + (.isCrossRepository | tostring) + "\\t" + .body'`, GH_NET);
2180
2188
  if (result.exitCode !== 0) {
2181
2189
  const stderr = result.stderr ?? '';
2182
2190
  if (stderr.includes('Could not resolve to a PullRequest'))
@@ -2284,12 +2292,19 @@ export class AgentManager {
2284
2292
  }
2285
2293
  async resolveAutoBaseRef(runner, workdir) {
2286
2294
  const result = await runner.exec(`git -C ${shellQuote(workdir)} rev-parse --verify --quiet origin/HEAD`);
2287
- return result.exitCode === 0 ? 'origin/HEAD' : undefined;
2295
+ if (result.exitCode !== 0) {
2296
+ // Falling back to the shared clone's HEAD would seed the worktree with whatever
2297
+ // another agent left checked out — refuse instead of silently cross-contaminating.
2298
+ throw new Error(`origin/HEAD is unresolvable in ${workdir}; ` +
2299
+ `run "git -C ${workdir} remote set-head origin --auto" and redispatch`);
2300
+ }
2301
+ return 'origin/HEAD';
2288
2302
  }
2289
2303
  getRepoCache() {
2290
2304
  return this.repoCache;
2291
2305
  }
2292
- async rollbackFailedDispatch(taskId, agentId) {
2306
+ async rollbackFailedDispatch(taskId, agentId, reason) {
2307
+ let rolledBack = null;
2293
2308
  await this.withTaskLock(async () => {
2294
2309
  const task = await this.taskStore.get(taskId);
2295
2310
  if (!task)
@@ -2300,7 +2315,23 @@ export class AgentManager {
2300
2315
  task.status = 'pending';
2301
2316
  task.updatedAt = new Date().toISOString();
2302
2317
  await this.taskStore.set(task);
2318
+ rolledBack = { projectId: task.projectId };
2303
2319
  });
2320
+ if (rolledBack && reason) {
2321
+ await this.safeEmit({
2322
+ id: '',
2323
+ type: 'human.intervention',
2324
+ timestamp: new Date().toISOString(),
2325
+ projectId: rolledBack.projectId,
2326
+ agentId,
2327
+ taskId,
2328
+ data: {
2329
+ phase: reason.phase,
2330
+ message: reason.message,
2331
+ note: 'Dispatch failed; the task is back in pending — re-dispatch it once the cause is resolved.',
2332
+ },
2333
+ });
2334
+ }
2304
2335
  const existing = await this.agentStore.get(agentId);
2305
2336
  if (existing && existing.taskId !== taskId) {
2306
2337
  console.warn(`[AgentManager] rollback: agent ${agentId} taskId mismatch (expected ${taskId}, got ${existing.taskId}); ` +
@@ -2578,7 +2609,10 @@ export class AgentManager {
2578
2609
  await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true });
2579
2610
  }
2580
2611
  else {
2581
- await this.rollbackFailedDispatch(taskId, agentId);
2612
+ await this.rollbackFailedDispatch(taskId, agentId, dispatchErr ? {
2613
+ phase: 'dispatch-rollback',
2614
+ message: dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr),
2615
+ } : undefined);
2582
2616
  }
2583
2617
  }
2584
2618
  return (await this.taskStore.get(taskId)) ?? null;
@@ -2834,7 +2868,10 @@ export class AgentManager {
2834
2868
  else if (dispatchErr instanceof EnsureSessionError && dispatchErr.partial.handled) {
2835
2869
  }
2836
2870
  else {
2837
- await this.rollbackFailedDispatch(claimed.id, claimed.agentId);
2871
+ await this.rollbackFailedDispatch(claimed.id, claimed.agentId, dispatchErr ? {
2872
+ phase: 'dispatch-rollback',
2873
+ message: dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr),
2874
+ } : undefined);
2838
2875
  }
2839
2876
  const refreshed = await this.taskStore.get(claimed.id);
2840
2877
  if (dispatchErr === null) {
@@ -2906,16 +2943,15 @@ export class AgentManager {
2906
2943
  const runner = this.createRunnerFor(agent);
2907
2944
  const worktree = new WorktreeManager(runner);
2908
2945
  const tmux = new TmuxManager(runner);
2909
- const baseRef = agent.workdir
2910
- ? undefined
2911
- : await this.resolveAutoBaseRef(runner, workdir);
2912
2946
  const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
2913
2947
  const customBranch = task.branch && !task.branch.startsWith(BRANCH_PREFIX) ? task.branch : undefined;
2948
+ // review/recheck check out the PR branch detached and never touch origin/HEAD,
2949
+ // so the fail-fast base resolution only runs where the default base is used.
2914
2950
  const worktreePath = isServerQaPhase
2915
2951
  ? await worktree.createDetachedAtBase(workdir, taskId)
2916
2952
  : phase === 'review' || phase === 'recheck'
2917
2953
  ? await worktree.createDetached(workdir, taskId, task.branch)
2918
- : await worktree.create(workdir, taskId, baseRef, customBranch);
2954
+ : await worktree.create(workdir, taskId, agent.workdir ? undefined : await this.resolveAutoBaseRef(runner, workdir), customBranch);
2919
2955
  await this.agentStore.update(agentId, (stateNow) => {
2920
2956
  if (!stateNow || stateNow.taskId !== taskId)
2921
2957
  return AGENT_STORE_NOOP;
@@ -3713,11 +3749,14 @@ export class AgentManager {
3713
3749
  const task = await this.taskStore.get(taskId);
3714
3750
  if (!task)
3715
3751
  throw new ApiError(404, 'Task not found');
3716
- if (TERMINAL_STATUSES.includes(task.status))
3717
- return task;
3718
3752
  if (this.markCompleteInFlight.has(taskId)) {
3719
3753
  throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`);
3720
3754
  }
3755
+ // 终态不早退:状态不再改写,但残留的 pane/绑定(上次清理中途失败)仍走同一条强制清理路径。
3756
+ // 唯一例外:清理已在进行中的重复取消——重跑只会重复中断同一批 pane,等它收尾即可。
3757
+ const alreadyTerminal = TERMINAL_STATUSES.includes(task.status);
3758
+ if (alreadyTerminal && this.cancelCleanupInFlight.has(taskId))
3759
+ return task;
3721
3760
  if (task.agentId)
3722
3761
  devToRelease = task.agentId;
3723
3762
  if (task.qaAgentId)
@@ -3736,12 +3775,14 @@ export class AgentManager {
3736
3775
  };
3737
3776
  }
3738
3777
  }
3739
- else if (task.status === 'merge-ready' && task.prNumber !== undefined && task.branch && task.agentId) {
3778
+ else if (!alreadyTerminal && task.reviewMode !== 'server' && task.prNumber !== undefined && task.branch) {
3779
+ // github 模式持有开放 PR 的任务(review/fixing/approved/merge-ready/max_rounds 等)取消时关 PR 删分支;
3780
+ // server 模式仍只信 gate+marker——非 gate 状态上的孤立 prNumber 不足以证明远端有待回收的工件
3740
3781
  publishedCleanup = {
3741
3782
  afterDone: 'pr',
3742
3783
  branch: task.branch,
3743
3784
  prNumber: task.prNumber,
3744
- devAgentId: task.agentId,
3785
+ ...(task.agentId ? { devAgentId: task.agentId } : {}),
3745
3786
  mayBeInFlight: false,
3746
3787
  };
3747
3788
  }
@@ -3749,18 +3790,20 @@ export class AgentManager {
3749
3790
  if (id)
3750
3791
  await this.markPaneCancelClearing(id, taskId);
3751
3792
  }
3752
- const now = new Date().toISOString();
3753
- task.status = 'cancelled';
3754
- task.updatedAt = now;
3755
- await this.taskStore.set(task);
3756
- await this.safeEmit({
3757
- id: '',
3758
- type: 'task.updated',
3759
- timestamp: now,
3760
- projectId: task.projectId,
3761
- taskId,
3762
- data: { status: 'cancelled' },
3763
- });
3793
+ if (!alreadyTerminal) {
3794
+ const now = new Date().toISOString();
3795
+ task.status = 'cancelled';
3796
+ task.updatedAt = now;
3797
+ await this.taskStore.set(task);
3798
+ await this.safeEmit({
3799
+ id: '',
3800
+ type: 'task.updated',
3801
+ timestamp: now,
3802
+ projectId: task.projectId,
3803
+ taskId,
3804
+ data: { status: 'cancelled' },
3805
+ });
3806
+ }
3764
3807
  this.claimCancelCleanup(taskId);
3765
3808
  cleanupClaimed = true;
3766
3809
  return task;
@@ -3823,18 +3866,31 @@ export class AgentManager {
3823
3866
  const project = this.getProjectConfig(result.projectId);
3824
3867
  try {
3825
3868
  if (publishedCleanup.afterDone === 'pr' && publishedCleanup.prNumber !== undefined && project) {
3826
- const close = await this.platformRunner.exec(`gh pr close ${publishedCleanup.prNumber} --repo ${shellQuote(repoSlug(project.repo))} ` +
3827
- `--comment ${shellQuote('Task cancelled in baxian; closing the published PR.')} --delete-branch`);
3869
+ const close = await execNetwork(this.platformRunner, `gh pr close ${publishedCleanup.prNumber} --repo ${shellQuote(repoSlug(project.repo))} ` +
3870
+ `--comment ${shellQuote('Task cancelled in baxian; closing the published PR.')}`, GH_NET);
3828
3871
  if (close.exitCode !== 0)
3829
3872
  throw new Error(close.stderr.trim() || close.stdout.trim());
3873
+ // Branch deletion is a separate idempotent step, not `--delete-branch`:
3874
+ // a retried close that finds the PR already closed exits 0 before gh's
3875
+ // branch-deletion block, which would report success while leaking the
3876
+ // published branch.
3877
+ const refPath = `repos/${repoSlug(project.repo)}/git/refs/heads/` +
3878
+ encodeURIComponent(publishedCleanup.branch).replace(/%2F/gi, '/');
3879
+ const del = await execNetwork(this.platformRunner, `gh api -X DELETE ${shellQuote(refPath)}`, GH_NET);
3880
+ if (del.exitCode !== 0 && !del.stderr.includes('Reference does not exist')) {
3881
+ throw new Error(del.stderr.trim() || del.stdout.trim());
3882
+ }
3830
3883
  }
3831
- else {
3884
+ else if (publishedCleanup.devAgentId) {
3832
3885
  const dev = this.getAgentConfig(publishedCleanup.devAgentId);
3833
3886
  const state = await this.agentStore.get(publishedCleanup.devAgentId);
3834
3887
  if (dev && state?.repoPath) {
3835
- const del = await this.createRunnerFor(dev).exec(`cd ${shellQuote(state.repoPath)} && git push origin --delete ${shellQuote(publishedCleanup.branch)}`);
3836
- if (del.exitCode !== 0)
3888
+ const del = await execNetwork(this.createRunnerFor(dev), `cd ${shellQuote(state.repoPath)} && ${GIT_NET_ENV} git push origin --delete ${shellQuote(publishedCleanup.branch)}`);
3889
+ // The goal is "branch absent": a retried delete whose first round
3890
+ // landed reports a missing remote ref, which is success, not failure.
3891
+ if (del.exitCode !== 0 && !del.stderr.includes('remote ref does not exist')) {
3837
3892
  throw new Error(del.stderr.trim() || del.stdout.trim());
3893
+ }
3838
3894
  }
3839
3895
  }
3840
3896
  }
@@ -3903,11 +3959,31 @@ export class AgentManager {
3903
3959
  if (opts.expectSignalToken !== undefined && task.signalToken !== opts.expectSignalToken) {
3904
3960
  throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (signalToken rotated); aborting`);
3905
3961
  }
3906
- if (task.reviewMode === 'server') {
3907
- throw new ApiError(409, `Task ${taskId} uses server review mode; Call review applies only to github-mode tasks`);
3908
- }
3909
- if (task.phase === 'spec' && task.status === 'max_rounds') {
3910
- throw new ApiError(409, `Call review is not supported for spec-phase max_rounds tasks (use Retry or Cancel)`);
3962
+ if (task.reviewMode === 'server' || task.phase === 'spec') {
3963
+ if (!MANUAL_SERVER_REVIEW_STATUSES.includes(task.status)) {
3964
+ throw new ApiError(409, `Task ${taskId} status is ${task.status}; manual server-side review requires ${MANUAL_SERVER_REVIEW_STATUSES.join('/')}`);
3965
+ }
3966
+ if (!this.serverReviewDriver) {
3967
+ throw new ApiError(409, `Server review pipeline is not configured; cannot dispatch review for ${taskId}`);
3968
+ }
3969
+ // in_progress 且 phase 未定:dev 既可能交 spec-done 也可能交 code-done,评审对象无从判定
3970
+ if (task.status === 'in_progress' && task.phase === undefined) {
3971
+ throw new ApiError(409, `Task ${taskId} has no phase yet (the dev has not delivered spec-done/code-done); wait for the dev signal or use Cancel/Retry`);
3972
+ }
3973
+ // 手工发起=明确要求跑一次 QA 评审;无 QA 时必须拒绝,不得落入自动通过/停驻兜底
3974
+ const qaAvailable = (task.qaAgentId !== undefined && !!this.getAgentConfig(task.qaAgentId))
3975
+ || !!this.findQaPartner(task.agentId);
3976
+ if (!qaAvailable) {
3977
+ throw new ApiError(400, `Task ${taskId} has no QA partner configured; manual review requires a QA agent`);
3978
+ }
3979
+ const isSpec = task.phase === 'spec';
3980
+ const cap = this.config.review.rounds + (task.maxRoundsContinues ?? 0);
3981
+ const round = isSpec ? (task.specReviewRound ?? 0) : task.reviewRound;
3982
+ if (round + 1 > cap) {
3983
+ throw new ApiError(409, `Task ${taskId} reached the review round cap (${cap}); continue or cancel it instead`);
3984
+ }
3985
+ this.manualReviewInFlight.add(taskId);
3986
+ return { mode: 'server', isSpec, claimToken: task.signalToken };
3911
3987
  }
3912
3988
  if (!task.prNumber) {
3913
3989
  throw new ApiError(400, `Task ${taskId} has no PR yet; cannot dispatch review`);
@@ -3929,8 +4005,11 @@ export class AgentManager {
3929
4005
  qaId = qa.id;
3930
4006
  }
3931
4007
  this.manualReviewInFlight.add(taskId);
3932
- return { qaId, devAgentId: task.agentId, taskStatusAtClaim: task.status };
4008
+ return { mode: 'github', qaId, devAgentId: task.agentId, taskStatusAtClaim: task.status };
3933
4009
  });
4010
+ if (claim.mode === 'server') {
4011
+ return this.runManualServerReview(taskId, claim);
4012
+ }
3934
4013
  try {
3935
4014
  const { qaId, devAgentId, taskStatusAtClaim } = claim;
3936
4015
  const isTerminal = TERMINAL_STATUSES.includes(taskStatusAtClaim);
@@ -4061,6 +4140,27 @@ export class AgentManager {
4061
4140
  this.manualReviewInFlight.delete(taskId);
4062
4141
  }
4063
4142
  }
4143
+ async runManualServerReview(taskId, claim) {
4144
+ try {
4145
+ // 不在此处预释放旧 QA:可失败的准备工作(读 diff/spec、存轮次)失败时旧 pass 必须原样保留;
4146
+ // 同任务的 QA 重新绑定由 dispatchServerReviewToQa 在派发前完成
4147
+ const fresh = await this.taskStore.get(taskId);
4148
+ if (!fresh || fresh.signalToken !== claim.claimToken
4149
+ || !MANUAL_SERVER_REVIEW_STATUSES.includes(fresh.status)) {
4150
+ throw new ApiError(409, `Task ${taskId} changed during manual review dispatch; aborting`);
4151
+ }
4152
+ const dispatched = claim.isSpec
4153
+ ? await this.serverReviewDriver.dispatchSpecReview(fresh)
4154
+ : await this.serverReviewDriver.dispatchCodeReview(fresh);
4155
+ if (!dispatched) {
4156
+ throw new ApiError(500, `Manual review dispatch for ${taskId} did not start; check the event feed for the cause`);
4157
+ }
4158
+ return (await this.taskStore.get(taskId));
4159
+ }
4160
+ finally {
4161
+ this.manualReviewInFlight.delete(taskId);
4162
+ }
4163
+ }
4064
4164
  async continueDevRound(taskId) {
4065
4165
  if (this.markCompleteInFlight.has(taskId)) {
4066
4166
  throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`);
@@ -4413,7 +4513,9 @@ export class AgentManager {
4413
4513
  const matchHead = opts.matchHeadSha
4414
4514
  ? ` --match-head-commit ${shellQuote(opts.matchHeadSha)}`
4415
4515
  : '';
4416
- const result = await this.platformRunner.exec(`gh pr merge ${task.prNumber} --repo ${shellQuote(repoSlug(project.repo))}${matchHead} --squash --delete-branch`);
4516
+ // A merge is not idempotent from the caller's view (user-triggered, retryable
4517
+ // in the UI), so it gets a timeout but no automatic retry.
4518
+ const result = await execNetwork(this.platformRunner, `gh pr merge ${task.prNumber} --repo ${shellQuote(repoSlug(project.repo))}${matchHead} --squash --delete-branch`, { retries: 0 });
4417
4519
  if (result.exitCode !== 0) {
4418
4520
  throw new Error(`gh pr merge failed for PR #${task.prNumber}: ${result.stderr || result.stdout}`);
4419
4521
  }
@@ -4848,13 +4950,17 @@ export class AgentManager {
4848
4950
  const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, newToken);
4849
4951
  return { token: newToken, armed };
4850
4952
  }
4851
- async rollbackVerdictArmFailure(taskId, restore) {
4852
- await this.withTaskLock(async () => {
4953
+ async rollbackVerdictArmFailure(taskId, restore, opts = {}) {
4954
+ const expected = opts.expect;
4955
+ const rolledBack = await this.withTaskLock(async () => {
4853
4956
  const fresh = await this.taskStore.get(taskId);
4854
4957
  if (!fresh)
4855
- return;
4958
+ return false;
4856
4959
  if (TERMINAL_STATUSES.includes(fresh.status))
4857
- return;
4960
+ return false;
4961
+ // pass 已被并发接管(token 轮换或状态推进)时禁止回滚,否则会覆盖接管方刚写入的新 pass
4962
+ if (expected && (fresh.status !== expected.status || fresh.signalToken !== expected.signalToken))
4963
+ return false;
4858
4964
  await this.taskStore.set({
4859
4965
  ...fresh,
4860
4966
  status: restore.status,
@@ -4864,19 +4970,26 @@ export class AgentManager {
4864
4970
  qaAgentId: undefined,
4865
4971
  updatedAt: new Date().toISOString(),
4866
4972
  });
4973
+ return true;
4867
4974
  });
4975
+ if (!rolledBack)
4976
+ return false;
4977
+ await this.rearmPhaseSignalForCurrentPass(taskId, { skipSnapshot: opts.rearmSkipSnapshot });
4978
+ return true;
4979
+ }
4980
+ async rearmPhaseSignalForCurrentPass(taskId, opts = {}) {
4868
4981
  if (!this.phaseSignalWatcher)
4869
4982
  return;
4870
- const restored = await this.taskStore.get(taskId);
4871
- if (!restored?.signalToken)
4983
+ const task = await this.taskStore.get(taskId);
4984
+ if (!task?.signalToken)
4872
4985
  return;
4873
- if (TERMINAL_STATUSES.includes(restored.status))
4986
+ if (TERMINAL_STATUSES.includes(task.status))
4874
4987
  return;
4875
- const mapped = this.mapTaskStateToExpectedWatcher(restored);
4876
- if (mapped) {
4877
- await this.setupPhaseSignal(taskId, mapped.agentId, mapped.expectedKinds);
4878
- await this.tearDownWatcherIfTaskTerminal(taskId);
4879
- }
4988
+ const mapped = this.mapTaskStateToExpectedWatcher(task);
4989
+ if (!mapped)
4990
+ return;
4991
+ await this.setupPhaseSignal(taskId, mapped.agentId, mapped.expectedKinds, opts.skipSnapshot ? { skipSnapshot: true } : undefined);
4992
+ await this.tearDownWatcherIfTaskTerminal(taskId);
4880
4993
  }
4881
4994
  async parkTaskAtSpecReady(taskId, opts = {}) {
4882
4995
  const task = await this.taskStore.get(taskId);
@@ -5078,7 +5191,13 @@ export class AgentManager {
5078
5191
  if (task.reviewMode !== 'server' && opts.phase !== 'spec') {
5079
5192
  throw new Error(`dispatchServerReviewToQa: task ${taskId} is not in server review mode`);
5080
5193
  }
5081
- const qaId = task.qaAgentId ?? this.findQaPartner(task.agentId)?.id;
5194
+ let recordedQaId = task.qaAgentId;
5195
+ if (recordedQaId && !this.getAgentConfig(recordedQaId)) {
5196
+ console.warn(`[dispatchServerReviewToQa] task ${taskId}.qaAgentId="${recordedQaId}" no longer in config; ` +
5197
+ `falling back to findQaPartner(${task.agentId})`);
5198
+ recordedQaId = undefined;
5199
+ }
5200
+ const qaId = recordedQaId ?? this.findQaPartner(task.agentId)?.id;
5082
5201
  if (!qaId) {
5083
5202
  const entryKind = task.status === 'fixing'
5084
5203
  ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
@@ -5125,12 +5244,20 @@ export class AgentManager {
5125
5244
  }).catch(() => undefined);
5126
5245
  };
5127
5246
  const rearmEntrySignal = async () => {
5247
+ // 从 review 重派(手工发起):入口信号早已被消费,dev 停驻等待中,无可重挂
5248
+ if (claim.originalStatus === 'review')
5249
+ return;
5128
5250
  const entryKind = claim.originalStatus === 'fixing'
5129
5251
  ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
5130
5252
  : (opts.phase === 'spec' ? 'spec-done' : 'code-done');
5131
5253
  await this.setupPhaseSignal(taskId, devAgentId, entryKind, { skipSnapshot: true });
5132
5254
  };
5133
5255
  if (!opts.continuation) {
5256
+ const prevQa = await this.agentStore.get(qaId);
5257
+ if (prevQa?.taskId === taskId) {
5258
+ // 手工重派:上一 pass 的 QA 仍绑定本任务,先释放再重新 acquire(与 github 手工路径一致)
5259
+ await this.releaseAgentForTask(qaId, taskId, 'idle');
5260
+ }
5134
5261
  const acquired = await this.acquireAgentForTask(qaId, taskId, dispatchPhase);
5135
5262
  if (!acquired) {
5136
5263
  await rearmEntrySignal();
@@ -5372,6 +5499,62 @@ export class AgentManager {
5372
5499
  await this.armPostDispatchSignalOrHold(taskId, devAgentId, expectedKind, newToken);
5373
5500
  return await this.taskStore.get(taskId);
5374
5501
  }
5502
+ projectRepoKey(projectId) {
5503
+ const repo = this.getProjectConfig(projectId)?.repo;
5504
+ if (!repo)
5505
+ return null;
5506
+ if (isGitHubRepo(repo))
5507
+ return `gh:${repoSlug(repo).toLowerCase()}`;
5508
+ const parsed = parseGitRemote(repo);
5509
+ if (parsed)
5510
+ return `git:${parsed.host.toLowerCase()}/${parsed.path}`;
5511
+ return `raw:${repo.trim()}`;
5512
+ }
5513
+ async findLineageViolation(taskId, baseSha) {
5514
+ const task = await this.taskStore.get(taskId);
5515
+ if (!task?.agentId)
5516
+ return null;
5517
+ const agent = this.getAgentConfig(task.agentId);
5518
+ const agentState = await this.agentStore.get(task.agentId);
5519
+ // A rebound agent's worktree belongs to its new task — checking it would
5520
+ // produce verdicts about the wrong branch. Skip; the dispatch path's own
5521
+ // binding guards (acquire) surface the real failure.
5522
+ if (agentState?.taskId !== taskId)
5523
+ return null;
5524
+ const worktree = agentState.worktreePath;
5525
+ if (!agent || !worktree)
5526
+ return null;
5527
+ const runner = this.createRunnerFor(agent);
5528
+ let base = baseSha;
5529
+ if (!base) {
5530
+ // Callers without a baseSha (publish) run long after the review diff was
5531
+ // read; refresh origin/HEAD first or upstream commits merged since then
5532
+ // would linger in base..HEAD and flag tasks that leak nothing.
5533
+ const fetch = await execNetwork(runner, `${GIT_NET_ENV} git -C ${shellQuote(worktree)} fetch origin --quiet`);
5534
+ if (fetch.exitCode !== 0) {
5535
+ throw new Error(`lineage fetch failed in ${worktree}: ${fetch.stderr.trim()}`);
5536
+ }
5537
+ const mb = await runner.exec(`git -C ${shellQuote(worktree)} merge-base origin/HEAD HEAD`);
5538
+ if (mb.exitCode !== 0) {
5539
+ throw new Error(`lineage merge-base failed in ${worktree}: ${mb.stderr.trim()}`);
5540
+ }
5541
+ base = mb.stdout.trim();
5542
+ }
5543
+ // Projects pointing at the same repo share one store and branch namespace,
5544
+ // so candidates are scoped by repo identity, not by projectId.
5545
+ const selfRepoKey = this.projectRepoKey(task.projectId);
5546
+ if (!selfRepoKey)
5547
+ return null;
5548
+ const tasks = await this.taskStore.list();
5549
+ const candidates = tasks
5550
+ .filter(t => t.id !== taskId
5551
+ && !TERMINAL_STATUSES.includes(t.status)
5552
+ && this.projectRepoKey(t.projectId) === selfRepoKey)
5553
+ .map(t => ({ taskId: t.id, branch: t.branch ?? BRANCH_PREFIX + t.id }));
5554
+ if (candidates.length === 0)
5555
+ return null;
5556
+ return findForeignTaskTip((cmd) => runner.exec(cmd), worktree, base, candidates);
5557
+ }
5375
5558
  async dispatchServerAfterDone(taskId, kind) {
5376
5559
  const task = await this.taskStore.get(taskId);
5377
5560
  if (!task)
@@ -5379,6 +5562,44 @@ export class AgentManager {
5379
5562
  const devAgentId = task.agentId;
5380
5563
  if (!devAgentId)
5381
5564
  throw new Error(`dispatchServerAfterDone: task ${taskId} has no dev agent`);
5565
+ let violation;
5566
+ try {
5567
+ violation = await this.findLineageViolation(taskId);
5568
+ }
5569
+ catch (err) {
5570
+ await this.safeEmit({
5571
+ id: '',
5572
+ type: 'human.intervention',
5573
+ timestamp: new Date().toISOString(),
5574
+ projectId: task.projectId,
5575
+ agentId: devAgentId,
5576
+ taskId,
5577
+ data: {
5578
+ phase: 'server-after-done-lineage-check-failed',
5579
+ error: err instanceof Error ? err.message : String(err),
5580
+ note: 'Publish was not dispatched; mark-complete retries it once the repo state is fixed.',
5581
+ },
5582
+ });
5583
+ return null;
5584
+ }
5585
+ if (violation) {
5586
+ await this.safeEmit({
5587
+ id: '',
5588
+ type: 'human.intervention',
5589
+ timestamp: new Date().toISOString(),
5590
+ projectId: task.projectId,
5591
+ agentId: devAgentId,
5592
+ taskId,
5593
+ data: {
5594
+ phase: 'server-after-done-lineage-violation',
5595
+ offendingTaskId: violation.taskId,
5596
+ offendingBranch: violation.branch,
5597
+ offendingSha: violation.sha,
5598
+ note: 'The task branch embeds another active task\'s commits; publishing would leak them into this PR. Rebase the branch onto origin/HEAD, then mark-complete to retry the publish.',
5599
+ },
5600
+ });
5601
+ return null;
5602
+ }
5382
5603
  const branch = task.branch ?? BRANCH_PREFIX + taskId;
5383
5604
  const originalToken = task.signalToken;
5384
5605
  const newToken = createSignalToken();
@@ -5640,7 +5861,7 @@ export class AgentManager {
5640
5861
  if (db.exitCode !== 0 || defaultBranch === '') {
5641
5862
  throw new Error(`ffMergeBranch: cannot resolve default branch: ${db.stderr.trim() || 'empty origin/HEAD'}`);
5642
5863
  }
5643
- const fetch = await runner.exec(`${cd}git fetch origin`);
5864
+ const fetch = await execNetwork(runner, `${cd}${GIT_NET_ENV} git fetch origin`);
5644
5865
  if (fetch.exitCode !== 0) {
5645
5866
  throw new Error(`ffMergeBranch [git fetch] failed: ${fetch.stderr.trim()}`);
5646
5867
  }
@@ -5654,12 +5875,12 @@ export class AgentManager {
5654
5875
  else {
5655
5876
  throw new Error(`ffMergeBranch: no reviewed head recorded for task ${task.id}; cannot safely merge`);
5656
5877
  }
5657
- const push = await runner.exec(`${cd}git push origin ${shellQuote(`origin/${branch}`)}:${shellQuote(defaultBranch)}`);
5878
+ const push = await execNetwork(runner, `${cd}${GIT_NET_ENV} git push origin ${shellQuote(`origin/${branch}`)}:${shellQuote(defaultBranch)}`);
5658
5879
  if (push.exitCode !== 0) {
5659
5880
  throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
5660
5881
  }
5661
- const del = await runner.exec(`${cd}git push origin --delete ${shellQuote(branch)}`);
5662
- if (del.exitCode !== 0) {
5882
+ const del = await execNetwork(runner, `${cd}${GIT_NET_ENV} git push origin --delete ${shellQuote(branch)}`);
5883
+ if (del.exitCode !== 0 && !del.stderr.includes('remote ref does not exist')) {
5663
5884
  console.warn(`[AgentManager] ffMergeBranch: post-merge branch delete failed for ${branch}: ${del.stderr.trim() || del.stdout.trim()}`);
5664
5885
  }
5665
5886
  });