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.
- package/dist/agent/manager.d.ts +116 -1
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +1002 -46
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/phase-signal-watcher.d.ts +6 -1
- package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
- package/dist/agent/phase-signal-watcher.js +51 -27
- package/dist/agent/phase-signal-watcher.js.map +1 -1
- package/dist/agent/phase-signal.d.ts +3 -1
- package/dist/agent/phase-signal.d.ts.map +1 -1
- package/dist/agent/phase-signal.js +27 -4
- package/dist/agent/phase-signal.js.map +1 -1
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +16 -4
- package/dist/api/config.js.map +1 -1
- package/dist/api/platform-guard.d.ts +7 -0
- package/dist/api/platform-guard.d.ts.map +1 -0
- package/dist/api/platform-guard.js +32 -0
- package/dist/api/platform-guard.js.map +1 -0
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +16 -3
- package/dist/api/projects.js.map +1 -1
- package/dist/event/handlers.d.ts +1 -0
- package/dist/event/handlers.d.ts.map +1 -1
- package/dist/event/handlers.js +509 -47
- package/dist/event/handlers.js.map +1 -1
- package/dist/github/mapper.d.ts +1 -6
- package/dist/github/mapper.d.ts.map +1 -1
- package/dist/github/mapper.js.map +1 -1
- package/dist/github/poller.d.ts +3 -3
- package/dist/github/poller.d.ts.map +1 -1
- package/dist/github/poller.js +2 -11
- package/dist/github/poller.js.map +1 -1
- package/dist/github/resolver.js +1 -1
- package/dist/github/resolver.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -2
- package/dist/index.js.map +1 -1
- package/dist/platform/driver-host.d.ts +7 -0
- package/dist/platform/driver-host.d.ts.map +1 -0
- package/dist/platform/driver-host.js +18 -0
- package/dist/platform/driver-host.js.map +1 -0
- package/dist/platform/feedback.d.ts +31 -0
- package/dist/platform/feedback.d.ts.map +1 -0
- package/dist/platform/feedback.js +85 -0
- package/dist/platform/feedback.js.map +1 -0
- package/dist/platform/platform-poller.d.ts +3 -2
- package/dist/platform/platform-poller.d.ts.map +1 -1
- package/dist/platform/platform-poller.js +32 -59
- package/dist/platform/platform-poller.js.map +1 -1
- package/dist/platform/poller-health.d.ts +3 -0
- package/dist/platform/poller-health.d.ts.map +1 -0
- package/dist/platform/poller-health.js +12 -0
- package/dist/platform/poller-health.js.map +1 -0
- package/dist/platform/pr-binding.d.ts +22 -0
- package/dist/platform/pr-binding.d.ts.map +1 -0
- package/dist/platform/pr-binding.js +28 -0
- package/dist/platform/pr-binding.js.map +1 -0
- package/dist/platform/types.d.ts +6 -0
- package/dist/platform/types.d.ts.map +1 -1
- package/dist/platform/types.js.map +1 -1
- package/dist/platform/verdict-engine.d.ts +7 -0
- package/dist/platform/verdict-engine.d.ts.map +1 -1
- package/dist/platform/verdict-engine.js +33 -24
- package/dist/platform/verdict-engine.js.map +1 -1
- package/dist/shared/types.d.ts +35 -0
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js.map +1 -1
- package/dist/state/task-store.d.ts.map +1 -1
- package/dist/state/task-store.js +71 -2
- package/dist/state/task-store.js.map +1 -1
- package/package.json +1 -1
package/dist/agent/manager.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
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 =
|
|
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
|
-
|
|
3434
|
-
|
|
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(
|
|
3440
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
3502
|
-
if (
|
|
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:
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 =
|
|
6228
|
-
|
|
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
|
|
6250
|
-
if (
|
|
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:
|
|
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
|
-
|
|
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
|
|
6911
|
-
if (
|
|
7828
|
+
const mappings = this.mapTaskStateToExpectedWatchers(task);
|
|
7829
|
+
if (mappings.length === 0)
|
|
6912
7830
|
return;
|
|
6913
|
-
|
|
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',
|
|
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,
|