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.
- package/dist/agent/lineage.d.ts +14 -0
- package/dist/agent/lineage.d.ts.map +1 -0
- package/dist/agent/lineage.js +43 -0
- package/dist/agent/lineage.js.map +1 -0
- package/dist/agent/manager.d.ts +19 -0
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +280 -59
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/net-exec.d.ts +16 -0
- package/dist/agent/net-exec.d.ts.map +1 -0
- package/dist/agent/net-exec.js +78 -0
- package/dist/agent/net-exec.js.map +1 -0
- package/dist/agent/preflight.d.ts.map +1 -1
- package/dist/agent/preflight.js +19 -5
- package/dist/agent/preflight.js.map +1 -1
- package/dist/agent/repo-store.d.ts +3 -0
- package/dist/agent/repo-store.d.ts.map +1 -1
- package/dist/agent/repo-store.js +52 -8
- package/dist/agent/repo-store.js.map +1 -1
- package/dist/agent/review-transport.d.ts.map +1 -1
- package/dist/agent/review-transport.js +8 -1
- package/dist/agent/review-transport.js.map +1 -1
- package/dist/agent/worktree.d.ts +1 -0
- package/dist/agent/worktree.d.ts.map +1 -1
- package/dist/agent/worktree.js +41 -17
- package/dist/agent/worktree.js.map +1 -1
- package/dist/event/handlers.d.ts.map +1 -1
- package/dist/event/handlers.js +76 -28
- package/dist/event/handlers.js.map +1 -1
- package/dist/event/server-handlers.d.ts.map +1 -1
- package/dist/event/server-handlers.js +117 -52
- package/dist/event/server-handlers.js.map +1 -1
- package/dist/web/assets/index-CJhHx7jR.js +11 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-Bc2bgdOQ.js +0 -11
package/dist/agent/manager.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
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
|
|
3827
|
-
`--comment ${shellQuote('Task cancelled in baxian; closing the published PR.')}
|
|
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)
|
|
3836
|
-
|
|
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
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4871
|
-
if (!
|
|
4983
|
+
const task = await this.taskStore.get(taskId);
|
|
4984
|
+
if (!task?.signalToken)
|
|
4872
4985
|
return;
|
|
4873
|
-
if (TERMINAL_STATUSES.includes(
|
|
4986
|
+
if (TERMINAL_STATUSES.includes(task.status))
|
|
4874
4987
|
return;
|
|
4875
|
-
const mapped = this.mapTaskStateToExpectedWatcher(
|
|
4876
|
-
if (mapped)
|
|
4877
|
-
|
|
4878
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
});
|