baxian 2.0.1 → 2.0.2
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/README.md +5 -7
- package/README.zh-CN.md +5 -7
- package/dist/agent/bootstrap.d.ts.map +1 -1
- package/dist/agent/bootstrap.js +2 -4
- package/dist/agent/bootstrap.js.map +1 -1
- package/dist/agent/branch.d.ts +0 -12
- package/dist/agent/branch.d.ts.map +1 -1
- package/dist/agent/branch.js +1 -71
- package/dist/agent/branch.js.map +1 -1
- package/dist/agent/dispatch-reconciler.d.ts.map +1 -1
- package/dist/agent/dispatch-reconciler.js +9 -9
- package/dist/agent/dispatch-reconciler.js.map +1 -1
- package/dist/agent/manager.d.ts +65 -138
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +928 -2442
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/pane-streamer.d.ts +4 -1
- package/dist/agent/pane-streamer.d.ts.map +1 -1
- package/dist/agent/pane-streamer.js +25 -5
- package/dist/agent/pane-streamer.js.map +1 -1
- package/dist/agent/phase-signal-watcher.d.ts +2 -11
- package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
- package/dist/agent/phase-signal-watcher.js +38 -87
- package/dist/agent/phase-signal-watcher.js.map +1 -1
- package/dist/agent/phase-signal.d.ts +14 -40
- package/dist/agent/phase-signal.d.ts.map +1 -1
- package/dist/agent/phase-signal.js +47 -90
- package/dist/agent/phase-signal.js.map +1 -1
- package/dist/agent/preflight.d.ts +0 -1
- package/dist/agent/preflight.d.ts.map +1 -1
- package/dist/agent/preflight.js +8 -15
- package/dist/agent/preflight.js.map +1 -1
- package/dist/agent/prompt.d.ts +1 -30
- package/dist/agent/prompt.d.ts.map +1 -1
- package/dist/agent/prompt.js +21 -148
- package/dist/agent/prompt.js.map +1 -1
- package/dist/agent/repo-store.d.ts.map +1 -1
- package/dist/agent/repo-store.js +0 -4
- package/dist/agent/repo-store.js.map +1 -1
- package/dist/agent/root-agent-runtime.d.ts.map +1 -1
- package/dist/agent/root-agent-runtime.js +4 -2
- package/dist/agent/root-agent-runtime.js.map +1 -1
- package/dist/agent/root-recovery-coordinator.d.ts +0 -1
- package/dist/agent/root-recovery-coordinator.d.ts.map +1 -1
- package/dist/agent/root-recovery-coordinator.js +39 -61
- package/dist/agent/root-recovery-coordinator.js.map +1 -1
- package/dist/agent/vt-visible-text.d.ts +42 -0
- package/dist/agent/vt-visible-text.d.ts.map +1 -0
- package/dist/agent/vt-visible-text.js +482 -0
- package/dist/agent/vt-visible-text.js.map +1 -0
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +6 -2
- package/dist/api/config.js.map +1 -1
- package/dist/api/platform-guard.js +2 -2
- package/dist/api/platform-guard.js.map +1 -1
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +598 -318
- package/dist/api/projects.js.map +1 -1
- package/dist/api/tasks.d.ts.map +1 -1
- package/dist/api/tasks.js +15 -39
- package/dist/api/tasks.js.map +1 -1
- package/dist/config/loader.d.ts +5 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +36 -24
- package/dist/config/loader.js.map +1 -1
- package/dist/config/validator.d.ts +7 -4
- package/dist/config/validator.d.ts.map +1 -1
- package/dist/config/validator.js +57 -73
- package/dist/config/validator.js.map +1 -1
- package/dist/event/handlers.d.ts.map +1 -1
- package/dist/event/handlers.js +359 -226
- package/dist/event/handlers.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -5
- package/dist/index.js.map +1 -1
- package/dist/platform/command-renderer.d.ts +6 -0
- package/dist/platform/command-renderer.d.ts.map +1 -1
- package/dist/platform/command-renderer.js +20 -0
- package/dist/platform/command-renderer.js.map +1 -1
- package/dist/platform/driver-host.d.ts.map +1 -1
- package/dist/platform/driver-host.js +3 -1
- package/dist/platform/driver-host.js.map +1 -1
- package/dist/platform/driver-spec.d.ts.map +1 -1
- package/dist/platform/driver-spec.js +32 -4
- package/dist/platform/driver-spec.js.map +1 -1
- package/dist/platform/git-driver.d.ts +2 -0
- package/dist/platform/git-driver.d.ts.map +1 -1
- package/dist/platform/git-driver.js +9 -3
- package/dist/platform/git-driver.js.map +1 -1
- package/dist/platform/platform-poller.d.ts +0 -1
- package/dist/platform/platform-poller.d.ts.map +1 -1
- package/dist/platform/platform-poller.js +0 -6
- package/dist/platform/platform-poller.js.map +1 -1
- package/dist/platform/plugins/github/driver.json +5 -0
- package/dist/platform/plugins/github/skills/baxian-cli-gh/SKILL.md +20 -14
- package/dist/platform/startup.d.ts.map +1 -1
- package/dist/platform/startup.js +5 -5
- package/dist/platform/startup.js.map +1 -1
- package/dist/platform/types.d.ts +1 -0
- package/dist/platform/types.d.ts.map +1 -1
- package/dist/platform/types.js +3 -3
- package/dist/platform/types.js.map +1 -1
- package/dist/shared/constants.d.ts +1 -6
- package/dist/shared/constants.d.ts.map +1 -1
- package/dist/shared/constants.js +2 -29
- package/dist/shared/constants.js.map +1 -1
- package/dist/shared/types.d.ts +28 -124
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js +7 -6
- package/dist/shared/types.js.map +1 -1
- package/dist/skills/baxian-pr-feedback/SKILL.md +5 -3
- package/dist/skills/baxian-pr-recheck/SKILL.md +4 -0
- package/dist/skills/baxian-pr-review/SKILL.md +4 -0
- package/dist/skills/baxian-signals/SKILL.md +15 -32
- package/dist/skills/baxian-task-check/SKILL.md +13 -14
- package/dist/state/snapshot.d.ts.map +1 -1
- package/dist/state/snapshot.js +5 -2
- package/dist/state/snapshot.js.map +1 -1
- package/dist/state/task-store.d.ts.map +1 -1
- package/dist/state/task-store.js +56 -140
- package/dist/state/task-store.js.map +1 -1
- package/dist/web/assets/index-BKiM0MSM.js +15 -0
- package/dist/web/assets/index-BS2adAfI.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/agent/review-transport.d.ts +0 -92
- package/dist/agent/review-transport.d.ts.map +0 -1
- package/dist/agent/review-transport.js +0 -512
- package/dist/agent/review-transport.js.map +0 -1
- package/dist/event/server-handlers.d.ts +0 -4
- package/dist/event/server-handlers.d.ts.map +0 -1
- package/dist/event/server-handlers.js +0 -1294
- package/dist/event/server-handlers.js.map +0 -1
- package/dist/skills/baxian-research/SKILL.md +0 -28
- package/dist/skills/baxian-server-feedback/SKILL.md +0 -40
- package/dist/skills/baxian-server-feedback/agents/openai.yaml +0 -2
- package/dist/skills/baxian-server-review/SKILL.md +0 -58
- package/dist/skills/baxian-server-review/agents/openai.yaml +0 -2
- package/dist/state/review-store.d.ts +0 -33
- package/dist/state/review-store.d.ts.map +0 -1
- package/dist/state/review-store.js +0 -272
- package/dist/state/review-store.js.map +0 -1
- package/dist/web/assets/index-BPv6vv3U.css +0 -1
- package/dist/web/assets/index-BXfgaFNm.js +0 -18
package/dist/agent/manager.js
CHANGED
|
@@ -4,7 +4,7 @@ import { join, normalize } from 'node:path';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { createSignalToken, PASSIVE_VERDICT_WATCH } 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,
|
|
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, effectiveTaskReviewRound, isSpecStagePhase, isTaskOpen, repoIdentityKey, taskGenerationGuard, taskReviewRound, taskMatchesGeneration, } from '../shared/index.js';
|
|
8
8
|
import { buildProjectDriver, makeDriverExec } from '../platform/driver-host.js';
|
|
9
9
|
import { DriverOpError, buildDriverRunContext, GitDriver, safeDriverErrorText } from '../platform/git-driver.js';
|
|
10
10
|
import { checkOpenPrBinding, checkPrBinding } from '../platform/pr-binding.js';
|
|
@@ -14,20 +14,19 @@ import { platformBindingMismatch, taskNeedsPlatformBindingAudit, } from '../plat
|
|
|
14
14
|
import { AGENT_STORE_NOOP } from '../state/agent-store.js';
|
|
15
15
|
import { SkillRegistry } from '../skill/registry.js';
|
|
16
16
|
import { createRunner, LocalRunner, shellQuote, resolveAgentHost, hostGroupKey, workdirHostGroupKey, } from './runner.js';
|
|
17
|
-
import {
|
|
18
|
-
import { findForeignTaskTip } from './lineage.js';
|
|
17
|
+
import { isTransientNetworkFailure } from './net-exec.js';
|
|
19
18
|
import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
|
|
20
19
|
import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, hasOscTitleWorking, hasOscTitleIdle, screenAllowsTitleIdle, PaneGoneError, } from './tmux.js';
|
|
21
20
|
import { BranchManager, DirtyWorkdirError, ReviewHeadMismatchError, isAutoDeletableTaskBranch } from './branch.js';
|
|
22
21
|
import { RepoStore, createRepoStoreCache, moveFileIntoPlace, stageFileGuarded, canonicalSelfGuard } from './repo-store.js';
|
|
23
22
|
import { PhaseSignalWatcher, } from './phase-signal-watcher.js';
|
|
24
|
-
import { ReviewTransport, resolveServerPayloads } from './review-transport.js';
|
|
25
|
-
import { reviewFindingsDigest } from '../state/review-store.js';
|
|
26
23
|
import { buildPromptInline, buildGreetingPrompt, PromptSizeError, RequiredSkillsMissingError, MAX_PROMPT_BYTES_ROUTE_LIMIT, } from './prompt.js';
|
|
27
24
|
import { ApiError } from '../errors.js';
|
|
28
25
|
import { prepareConfig } from '../config/loader.js';
|
|
29
|
-
import {
|
|
26
|
+
import { resolveProjectTool } from '../config/validator.js';
|
|
30
27
|
import { SHA_HEX_SOURCE } from '../platform/types.js';
|
|
28
|
+
import { bodyDigest } from '../platform/body-digest.js';
|
|
29
|
+
import { validateCommentBody } from '../platform/command-renderer.js';
|
|
31
30
|
// 这两个入口冲突证明当前调用已被并发 pass 接管,应判 stale 而不是派发失败。
|
|
32
31
|
const BENIGN_DISPATCH_CONFLICT_CODES = new Set(['dispatch-superseded', 'dispatch-in-flight']);
|
|
33
32
|
export function isBenignDispatchConflict(err) {
|
|
@@ -36,6 +35,11 @@ export function isBenignDispatchConflict(err) {
|
|
|
36
35
|
&& err.code !== undefined
|
|
37
36
|
&& BENIGN_DISPATCH_CONFLICT_CODES.has(err.code);
|
|
38
37
|
}
|
|
38
|
+
function requireTaskQaAgentId(task, operation) {
|
|
39
|
+
if (!task.qaAgentId)
|
|
40
|
+
throw new Error(`${operation}: task ${task.id} has no QA participant`);
|
|
41
|
+
return task.qaAgentId;
|
|
42
|
+
}
|
|
39
43
|
export class EnsureSessionError extends Error {
|
|
40
44
|
partial;
|
|
41
45
|
constructor(partial, message) {
|
|
@@ -177,8 +181,7 @@ export class PlatformMergeRecheckError extends Error {
|
|
|
177
181
|
this.name = 'PlatformMergeRecheckError';
|
|
178
182
|
}
|
|
179
183
|
}
|
|
180
|
-
const
|
|
181
|
-
const IMAGE_DISPATCH_PHASES = new Set(['develop', 'research', 'code', 'fix', 'server-feedback']);
|
|
184
|
+
const IMAGE_DISPATCH_PHASES = new Set(['develop', 'code', 'fix']);
|
|
182
185
|
const RUNTIME_LIVENESS_SAMPLES = 3;
|
|
183
186
|
export function canDispatchWithBinding(binding) {
|
|
184
187
|
return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
|
|
@@ -193,14 +196,6 @@ export function isRecoverableQaDispatchHold(binding) {
|
|
|
193
196
|
&& binding.awaitingPhase != null
|
|
194
197
|
&& RECOVERABLE_QA_DISPATCH_HOLD_PHASES.has(binding.awaitingPhase);
|
|
195
198
|
}
|
|
196
|
-
function shouldBumpServerReviewRound(requested, task, qaBinding) {
|
|
197
|
-
if (requested !== undefined)
|
|
198
|
-
return requested;
|
|
199
|
-
return !(task.status === 'review'
|
|
200
|
-
&& qaBinding?.taskId === task.id
|
|
201
|
-
&& isRecoverableQaDispatchHold(qaBinding));
|
|
202
|
-
}
|
|
203
|
-
const TURN_COMPLETED_AWAITING_PHASES = new Set();
|
|
204
199
|
const CANCEL_CLEANUP_HOLD_PHASES = new Set(['cancel-clearing', 'cancel-interrupt-failed']);
|
|
205
200
|
function isCancelCleanupHold(binding) {
|
|
206
201
|
return binding?.awaitingPhase != null && CANCEL_CLEANUP_HOLD_PHASES.has(binding.awaitingPhase);
|
|
@@ -225,7 +220,6 @@ function shouldReleaseHeldBinding(state, boundTask) {
|
|
|
225
220
|
if (state.awaitingPhase != null && REGREET_REQUIRED_HOLD_PHASES.has(state.awaitingPhase))
|
|
226
221
|
return false;
|
|
227
222
|
const taskIsTerminal = !!boundTask && TERMINAL_STATUSES.includes(boundTask.status);
|
|
228
|
-
const turnCompleted = state.awaitingPhase != null && TURN_COMPLETED_AWAITING_PHASES.has(state.awaitingPhase);
|
|
229
223
|
// 任务已退回 verdict 门禁且当前持有者不是本 agent:这次绑定是转码失败的残留
|
|
230
224
|
// (fresh-acquire 释放被脏树挡下),Resume 时补完释放,否则 dev 被永久占住
|
|
231
225
|
const staleCodeHandoff = state.awaitingPhase != null
|
|
@@ -233,7 +227,10 @@ function shouldReleaseHeldBinding(state, boundTask) {
|
|
|
233
227
|
&& !!boundTask
|
|
234
228
|
&& (boundTask.status === 'spec-ready' || boundTask.status === 'max_rounds')
|
|
235
229
|
&& boundTask.agentId !== state.id;
|
|
236
|
-
|
|
230
|
+
const qaAtVerdictGate = !!boundTask
|
|
231
|
+
&& (boundTask.status === 'spec-ready' || boundTask.status === 'max_rounds')
|
|
232
|
+
&& boundTask.qaAgentId === state.id;
|
|
233
|
+
return !boundTask || taskIsTerminal || staleCodeHandoff || qaAtVerdictGate;
|
|
237
234
|
}
|
|
238
235
|
export class AgentManager {
|
|
239
236
|
config;
|
|
@@ -251,9 +248,6 @@ export class AgentManager {
|
|
|
251
248
|
platformDriverExec;
|
|
252
249
|
phaseSignalWatcher;
|
|
253
250
|
errorRecordStore;
|
|
254
|
-
reviewStore;
|
|
255
|
-
reviewTransportInstance;
|
|
256
|
-
serverReviewDriver;
|
|
257
251
|
dispatchAckTimeoutMs;
|
|
258
252
|
needInputRetryIntervalMs;
|
|
259
253
|
dispatchSettleTimeoutMs;
|
|
@@ -277,10 +271,8 @@ export class AgentManager {
|
|
|
277
271
|
readyStableSpacingMs = 1_000;
|
|
278
272
|
pendingDispatchRetryByTask = new Map();
|
|
279
273
|
cancelInterruptGuardWaitMs = DEFAULT_DISPATCH_ACK_TIMEOUT_MS + 5_000;
|
|
280
|
-
manualReviewInFlight = new Set();
|
|
281
274
|
markCompleteInFlight = new Set();
|
|
282
275
|
specVerdictInFlight = new Set();
|
|
283
|
-
codeVerdictInFlight = new Set();
|
|
284
276
|
// 引用计数:cancelTask 与 startCreatedTaskSession 的清理段可合法并存(启动期 Stop),先完成者不得清掉后者的 guard。
|
|
285
277
|
cancelCleanupInFlight = new Map();
|
|
286
278
|
deletionInFlight = new Set();
|
|
@@ -313,7 +305,6 @@ export class AgentManager {
|
|
|
313
305
|
})
|
|
314
306
|
: undefined);
|
|
315
307
|
this.needInputRetryIntervalMs = deps.needInputRetryIntervalMs ?? DEFAULT_NEED_INPUT_RETRY_INTERVAL_MS;
|
|
316
|
-
this.reviewStore = deps.reviewStore;
|
|
317
308
|
this.dispatchAckTimeoutMs = deps.dispatchAckTimeoutMs ?? DEFAULT_DISPATCH_ACK_TIMEOUT_MS;
|
|
318
309
|
this.dispatchSettleTimeoutMs = deps.dispatchSettleTimeoutMs ?? DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS;
|
|
319
310
|
this.cancelInterruptGuardWaitMs = this.dispatchAckTimeoutMs + 5_000;
|
|
@@ -378,106 +369,6 @@ export class AgentManager {
|
|
|
378
369
|
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
379
370
|
return state;
|
|
380
371
|
}
|
|
381
|
-
getReviewStore() {
|
|
382
|
-
return this.reviewStore;
|
|
383
|
-
}
|
|
384
|
-
async putReviewRoundIfCurrent(taskId, data, expectedTask) {
|
|
385
|
-
return this.withTaskLock(async () => {
|
|
386
|
-
const task = await this.taskStore.get(taskId);
|
|
387
|
-
if (!task || !taskMatchesGeneration(task, expectedTask))
|
|
388
|
-
return false;
|
|
389
|
-
if (!this.reviewStore)
|
|
390
|
-
throw new Error('ReviewStore is not configured');
|
|
391
|
-
await this.reviewStore.putRound(taskId, data.phase, data);
|
|
392
|
-
return true;
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
|
-
async deleteReviewExchangeIfCurrent(taskId, agent, artifact, expectedTask, expectedRecovery) {
|
|
396
|
-
return this.withTaskLock(async () => {
|
|
397
|
-
const task = await this.taskStore.get(taskId);
|
|
398
|
-
if (!task || !taskMatchesGeneration(task, expectedTask))
|
|
399
|
-
return false;
|
|
400
|
-
const recovery = task.serverSignalRecovery;
|
|
401
|
-
if (expectedRecovery && (!recovery
|
|
402
|
-
|| recovery.mode !== expectedRecovery.mode
|
|
403
|
-
|| recovery.signalKind !== expectedRecovery.signalKind
|
|
404
|
-
|| recovery.phase !== expectedRecovery.phase
|
|
405
|
-
|| recovery.round !== expectedRecovery.round
|
|
406
|
-
|| recovery.sourceToken !== expectedRecovery.sourceToken
|
|
407
|
-
|| recovery.failureSignature !== expectedRecovery.failureSignature
|
|
408
|
-
|| recovery.createdAt !== expectedRecovery.createdAt))
|
|
409
|
-
return false;
|
|
410
|
-
const transport = this.getReviewTransport();
|
|
411
|
-
if (artifact === 'findings') {
|
|
412
|
-
await transport.deleteFindings(agent);
|
|
413
|
-
}
|
|
414
|
-
else {
|
|
415
|
-
await transport.deleteResponse(agent);
|
|
416
|
-
}
|
|
417
|
-
return true;
|
|
418
|
-
});
|
|
419
|
-
}
|
|
420
|
-
setServerReviewDriver(driver) {
|
|
421
|
-
this.serverReviewDriver = driver;
|
|
422
|
-
}
|
|
423
|
-
effectiveReviewMode(projectId) {
|
|
424
|
-
const project = this.getProjectConfig(projectId);
|
|
425
|
-
return project?.review?.mode ?? this.config.review.mode ?? 'git';
|
|
426
|
-
}
|
|
427
|
-
resolveAfterDone(task) {
|
|
428
|
-
if (task.afterDone !== undefined)
|
|
429
|
-
return task.afterDone;
|
|
430
|
-
return this.coerceAfterDone(task.projectId);
|
|
431
|
-
}
|
|
432
|
-
coerceAfterDone(projectId) {
|
|
433
|
-
const project = this.getProjectConfig(projectId);
|
|
434
|
-
return project ? projectAfterDone(this.config, project) : (this.config.review.afterDone ?? null);
|
|
435
|
-
}
|
|
436
|
-
async commitServerAfterDone(taskId, expectedTask) {
|
|
437
|
-
return this.withTaskLock(async () => {
|
|
438
|
-
const task = await this.taskStore.get(taskId);
|
|
439
|
-
if (!task)
|
|
440
|
-
throw new ApiError(404, `Task ${taskId} not found`);
|
|
441
|
-
if (expectedTask && !taskMatchesGeneration(task, expectedTask)) {
|
|
442
|
-
throw new ApiError(409, `Task ${taskId} review generation changed before afterDone was committed`, 'dispatch-superseded');
|
|
443
|
-
}
|
|
444
|
-
if (task.afterDone === 'pr' && task.platformBinding === undefined) {
|
|
445
|
-
throw new Error(`platform binding missing for task ${task.id}; refusing to reconstruct it from live config`);
|
|
446
|
-
}
|
|
447
|
-
if (task.afterDone === 'pr')
|
|
448
|
-
this.assertPlatformBinding(task);
|
|
449
|
-
const afterDone = this.resolveAfterDone(task);
|
|
450
|
-
const shouldSnapshotBinding = task.afterDone === undefined && afterDone === 'pr';
|
|
451
|
-
const defaultBaseBranch = afterDone === 'pr' && task.baseBranch === undefined
|
|
452
|
-
? this.platformDefaultBranchOf?.(task.projectId)
|
|
453
|
-
: undefined;
|
|
454
|
-
if (task.afterDone === afterDone && !shouldSnapshotBinding && defaultBaseBranch === undefined)
|
|
455
|
-
return afterDone;
|
|
456
|
-
await this.taskStore.set({
|
|
457
|
-
...task,
|
|
458
|
-
afterDone,
|
|
459
|
-
...(shouldSnapshotBinding ? { platformBinding: this.platformBindingFor(task.projectId) } : {}),
|
|
460
|
-
...(defaultBaseBranch !== undefined ? { baseBranch: defaultBaseBranch } : {}),
|
|
461
|
-
updatedAt: new Date().toISOString(),
|
|
462
|
-
});
|
|
463
|
-
return afterDone;
|
|
464
|
-
});
|
|
465
|
-
}
|
|
466
|
-
getReviewTransport() {
|
|
467
|
-
this.reviewTransportInstance ??= new ReviewTransport({
|
|
468
|
-
createRunnerFor: (agent) => this.createRunnerFor(agent),
|
|
469
|
-
resolveWorkdir: (agentId) => this.bindingWorkdirCache.get(agentId),
|
|
470
|
-
});
|
|
471
|
-
return this.reviewTransportInstance;
|
|
472
|
-
}
|
|
473
|
-
async prepareDispatchArtifacts(agent, workdir, phase, opts) {
|
|
474
|
-
const transport = this.getReviewTransport();
|
|
475
|
-
if (!opts.preserveOutputs)
|
|
476
|
-
await transport.clearDispatchOutputs(agent, workdir, phase);
|
|
477
|
-
if (opts.specDocuments) {
|
|
478
|
-
await transport.replaceSpecDocuments(agent, workdir, opts.specDocuments, opts.assertOwner);
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
372
|
bindingWorkdirCache = new Map();
|
|
482
373
|
async refreshWorkdirCacheFor(agentId) {
|
|
483
374
|
const state = await this.agentStore.get(agentId);
|
|
@@ -596,13 +487,13 @@ export class AgentManager {
|
|
|
596
487
|
this.deletionInFlight.add(id);
|
|
597
488
|
return null;
|
|
598
489
|
}
|
|
599
|
-
// Scan + claim share createTask/dispatchPendingTask's withTaskLock, so a delete can't pass an
|
|
600
|
-
async
|
|
490
|
+
// Scan + claim share createTask/dispatchPendingTask's withTaskLock, so a delete can't pass an open-task scan that a concurrent commit then orphans.
|
|
491
|
+
async scanOpenThenClaimDeletion(targets) {
|
|
601
492
|
return this.withTaskLock(async () => {
|
|
602
493
|
for (const id of targets) {
|
|
603
494
|
const state = await this.agentStore.get(id);
|
|
604
495
|
const task = state?.taskId ? await this.taskStore.get(state.taskId) : null;
|
|
605
|
-
if (task &&
|
|
496
|
+
if (task && isTaskOpen(task.status)) {
|
|
606
497
|
return { ok: false, code: 'active', agentId: id, taskId: task.id };
|
|
607
498
|
}
|
|
608
499
|
if (state?.status === 'awaiting_human')
|
|
@@ -611,12 +502,11 @@ export class AgentManager {
|
|
|
611
502
|
return { ok: false, code: 'bootstrapping', agentId: id };
|
|
612
503
|
}
|
|
613
504
|
const targetSet = new Set(targets);
|
|
614
|
-
const referencing = (await this.taskStore.list({})).find(t =>
|
|
505
|
+
const referencing = (await this.taskStore.list({})).find(t => isTaskOpen(t.status)
|
|
615
506
|
&& ((t.preferredAgentId !== '' && targetSet.has(t.preferredAgentId))
|
|
616
507
|
|| (!!t.agentId && targetSet.has(t.agentId))
|
|
617
508
|
|| (!!t.devAgentId && targetSet.has(t.devAgentId))
|
|
618
|
-
|| (!!t.qaAgentId && targetSet.has(t.qaAgentId))
|
|
619
|
-
|| (!!t.researchAgentId && targetSet.has(t.researchAgentId))));
|
|
509
|
+
|| (!!t.qaAgentId && targetSet.has(t.qaAgentId))));
|
|
620
510
|
if (referencing)
|
|
621
511
|
return { ok: false, code: 'referencing', taskId: referencing.id };
|
|
622
512
|
const conflict = this.tryClaimDeletion(targets);
|
|
@@ -648,7 +538,6 @@ export class AgentManager {
|
|
|
648
538
|
add(task.agentId);
|
|
649
539
|
add(task.devAgentId, 'dev');
|
|
650
540
|
add(task.qaAgentId, 'qa');
|
|
651
|
-
add(task.researchAgentId, 'research');
|
|
652
541
|
return { taskId: task.id, projectId: task.projectId, participants: [...byId.values()] };
|
|
653
542
|
});
|
|
654
543
|
}
|
|
@@ -749,9 +638,8 @@ export class AgentManager {
|
|
|
749
638
|
}
|
|
750
639
|
agentSkillScope(agentId) {
|
|
751
640
|
const projectId = this.getAgentConfig(agentId)?.projectId;
|
|
752
|
-
if (projectId === undefined
|
|
641
|
+
if (projectId === undefined)
|
|
753
642
|
return { pluginTools: [] };
|
|
754
|
-
}
|
|
755
643
|
const project = this.getProjectConfig(projectId);
|
|
756
644
|
const tool = project === undefined ? undefined : resolveProjectTool(project);
|
|
757
645
|
return { pluginTools: tool === undefined ? [] : [tool] };
|
|
@@ -1773,10 +1661,7 @@ export class AgentManager {
|
|
|
1773
1661
|
? await this.resolveTaskLockToken(state, taskId)
|
|
1774
1662
|
: null;
|
|
1775
1663
|
const sameTaskLocked = existingToken !== null;
|
|
1776
|
-
const reentryPhases = new Set([
|
|
1777
|
-
'fix', 'post-approve', 'code',
|
|
1778
|
-
'server-feedback', 'server-after-done',
|
|
1779
|
-
]);
|
|
1664
|
+
const reentryPhases = new Set(['fix', 'post-approve', 'code']);
|
|
1780
1665
|
const sameTaskReentry = state?.taskId === taskId &&
|
|
1781
1666
|
!state.creationToken &&
|
|
1782
1667
|
state.status !== 'awaiting_human' &&
|
|
@@ -2342,7 +2227,7 @@ export class AgentManager {
|
|
|
2342
2227
|
// ---- need-input watermark: epoch-fenced monotonic persistence + retry convergence ----
|
|
2343
2228
|
needInputRetry = new Map();
|
|
2344
2229
|
needInputRetryInFlight = new Set();
|
|
2345
|
-
|
|
2230
|
+
gitReviewRecoveryAlerted = new Set();
|
|
2346
2231
|
// Watermark writes currently in the store chain. A restore bump snapshots these along
|
|
2347
2232
|
// with the queue: an answer whose write has not settled yet would otherwise be invisible
|
|
2348
2233
|
// to the migration and its error-enqueue would land after the arm already cleared the
|
|
@@ -3097,15 +2982,11 @@ export class AgentManager {
|
|
|
3097
2982
|
if (result.redispatchCodeTaskId) {
|
|
3098
2983
|
try {
|
|
3099
2984
|
const task = await this.taskStore.get(result.redispatchCodeTaskId);
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
await this.markAwaitingHuman(agentId, 'code-dispatch-failed', 'Code-phase redispatch cannot continue because the persisted Spec handoff is missing or invalid. Restore the review record or cancel the task.', { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
|
|
3106
|
-
return { resumed: false, releasedBinding: false, reason: 'Persisted Spec handoff is missing or invalid.' };
|
|
3107
|
-
}
|
|
3108
|
-
const resumed = await this.dispatchCodePhasePrompt(task, agentId, task.signalToken, stored.documents, round);
|
|
2985
|
+
if (!task || task.phase !== 'code' || !task.signalToken) {
|
|
2986
|
+
await this.markAwaitingHuman(agentId, 'code-dispatch-failed', 'Code-phase redispatch cannot continue because the persisted task handoff is missing or invalid. Restore the task state or cancel the task.', { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
|
|
2987
|
+
return { resumed: false, releasedBinding: false, reason: 'Persisted task handoff is missing or invalid.' };
|
|
2988
|
+
}
|
|
2989
|
+
const resumed = await this.dispatchCodePhasePrompt(task, agentId, task.signalToken);
|
|
3109
2990
|
if (!resumed) {
|
|
3110
2991
|
const reason = 'Code-phase redispatch on Resume was not delivered; Resume again to retry or cancel the task.';
|
|
3111
2992
|
await this.markAwaitingHuman(agentId, 'code-dispatch-failed', reason, { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
|
|
@@ -3113,10 +2994,8 @@ export class AgentManager {
|
|
|
3113
2994
|
}
|
|
3114
2995
|
// 重派与首派同样不经 startSession 的 post-ack finalize:残留的 bootstrap 标记
|
|
3115
2996
|
// 会让下一次 recover 把已送达的 code prompt 再判成未送达
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
await this.finalizeReplayedBootstrap(agentId, result.redispatchCodeTaskId, 'code', lockToken);
|
|
3119
|
-
}
|
|
2997
|
+
const lockToken = (await this.agentStore.get(agentId))?.lockToken;
|
|
2998
|
+
await this.finalizeReplayedBootstrap(agentId, result.redispatchCodeTaskId, 'code', lockToken);
|
|
3120
2999
|
}
|
|
3121
3000
|
catch (err) {
|
|
3122
3001
|
console.error(`[AgentManager] resumeAgent code redispatch failed for ${agentId}:`, err);
|
|
@@ -3161,7 +3040,7 @@ export class AgentManager {
|
|
|
3161
3040
|
&& resumeEntry.signalToken === reviewTask?.signalToken
|
|
3162
3041
|
&& resumeEntry.qaPhase !== undefined
|
|
3163
3042
|
? resumeEntry.qaPhase
|
|
3164
|
-
: (reviewTask
|
|
3043
|
+
: (reviewTask && taskReviewRound(reviewTask) === 0 ? 'review' : undefined);
|
|
3165
3044
|
await this.dispatchReviewToQa(result.redispatchReviewTaskId, {
|
|
3166
3045
|
bumpRound: reviewTask?.reviewRoundPending === true,
|
|
3167
3046
|
fromStatus: ['review'],
|
|
@@ -3513,7 +3392,7 @@ export class AgentManager {
|
|
|
3513
3392
|
for (const t of tasks) {
|
|
3514
3393
|
const bound = t.agentId === agentId || t.qaAgentId === agentId;
|
|
3515
3394
|
// spec-ready 不豁免:approve/打回都依赖 dev Workdir,dev 丢失后停驻只是假活
|
|
3516
|
-
if (t.status === '
|
|
3395
|
+
if (t.status === 'merge-ready')
|
|
3517
3396
|
continue;
|
|
3518
3397
|
if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
|
|
3519
3398
|
const failedTask = this.stripGitStatusScopedState(t, 'failed');
|
|
@@ -3807,11 +3686,10 @@ export class AgentManager {
|
|
|
3807
3686
|
if (expected && !taskMatchesGeneration(loadedTask, expected))
|
|
3808
3687
|
return false;
|
|
3809
3688
|
const rootGenerationMatches = (fresh, signalToken = expected?.signalToken) => !expected || taskMatchesGeneration(fresh, { ...expected, signalToken });
|
|
3810
|
-
|
|
3689
|
+
const task = loadedTask;
|
|
3811
3690
|
if (task.agentId !== agentId)
|
|
3812
3691
|
return false;
|
|
3813
|
-
|
|
3814
|
-
if (participantId !== agentId)
|
|
3692
|
+
if (task.devAgentId !== agentId)
|
|
3815
3693
|
return false;
|
|
3816
3694
|
const bindingAtEntry = await this.agentStore.get(agentId);
|
|
3817
3695
|
const entryHold = {
|
|
@@ -3841,28 +3719,6 @@ export class AgentManager {
|
|
|
3841
3719
|
if (task.status === 'approved' && agent.role === 'dev') {
|
|
3842
3720
|
return await this.replayApprovedHolderAfterReplRestart(agentId, task, entryHold, { clearEntryHold, holdReplayFailure, rootGenerationMatches });
|
|
3843
3721
|
}
|
|
3844
|
-
if (task.serverSignalRecovery) {
|
|
3845
|
-
const recovery = task.serverSignalRecovery;
|
|
3846
|
-
if (recovery.mode !== 'hold') {
|
|
3847
|
-
if (!(await clearEntryHold()))
|
|
3848
|
-
return false;
|
|
3849
|
-
await this.recoverServerSignalRecovery(task);
|
|
3850
|
-
return true;
|
|
3851
|
-
}
|
|
3852
|
-
const cleared = await this.updateTaskIfStatus(task.id, task.status, { serverSignalRecovery: undefined }, {
|
|
3853
|
-
signalToken: task.signalToken,
|
|
3854
|
-
phase: task.phase,
|
|
3855
|
-
...(recovery.phase === 'spec'
|
|
3856
|
-
? { specReviewRound: task.specReviewRound }
|
|
3857
|
-
: { reviewRound: task.reviewRound }),
|
|
3858
|
-
});
|
|
3859
|
-
if (!cleared)
|
|
3860
|
-
return false;
|
|
3861
|
-
const fresh = await this.taskStore.get(task.id);
|
|
3862
|
-
if (!fresh)
|
|
3863
|
-
return false;
|
|
3864
|
-
task = fresh;
|
|
3865
|
-
}
|
|
3866
3722
|
const mapped = this.mapTaskStateToExpectedWatcher(task);
|
|
3867
3723
|
if (!mapped || mapped.agentId !== agentId)
|
|
3868
3724
|
return false;
|
|
@@ -3898,7 +3754,6 @@ export class AgentManager {
|
|
|
3898
3754
|
continueWith: (newToken, fence) => this.continueSession(taskId, agentId, phase, {
|
|
3899
3755
|
...opts,
|
|
3900
3756
|
signalToken: newToken,
|
|
3901
|
-
preserveDispatchOutputs: true,
|
|
3902
3757
|
expectedHold: { phase: undefined, since: undefined, nonce: undefined },
|
|
3903
3758
|
...fence,
|
|
3904
3759
|
}),
|
|
@@ -3917,9 +3772,6 @@ export class AgentManager {
|
|
|
3917
3772
|
}
|
|
3918
3773
|
});
|
|
3919
3774
|
};
|
|
3920
|
-
if (task.status === 'in_progress' && task.phase === 'research' && agent.role === 'research') {
|
|
3921
|
-
return await replayInitialBootstrap('research', {});
|
|
3922
|
-
}
|
|
3923
3775
|
if (task.status === 'in_progress' && task.phase === undefined && agent.role === 'dev') {
|
|
3924
3776
|
// An undelivered bootstrap never started work, so the fresh-dispatch clean gate still applies.
|
|
3925
3777
|
const delivered = (await this.agentStore.get(agentId))?.bootstrappingTaskId !== taskId;
|
|
@@ -3929,53 +3781,25 @@ export class AgentManager {
|
|
|
3929
3781
|
}
|
|
3930
3782
|
if (task.status === 'in_progress' && task.phase === 'code' && agent.role === 'dev') {
|
|
3931
3783
|
const state = await this.agentStore.get(agentId);
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
if (state?.bootstrappingTaskId === taskId || !stored || stored.phase !== 'spec') {
|
|
3936
|
-
return await holdReplayFailure('code-dispatch-failed', 'REPL restarted but the code-phase prompt cannot be resumed in place. Resume to replay the persisted Spec documents, or cancel the task.');
|
|
3784
|
+
const opts = { allowDirtyWorkdir: true };
|
|
3785
|
+
if (state?.bootstrappingTaskId === taskId) {
|
|
3786
|
+
return await replayInitialBootstrap('code', opts);
|
|
3937
3787
|
}
|
|
3938
3788
|
if (!(await clearEntryHold()))
|
|
3939
3789
|
return false;
|
|
3940
|
-
return replayTask('code',
|
|
3941
|
-
allowDirtyWorkdir: true,
|
|
3942
|
-
specDocuments: stored.documents,
|
|
3943
|
-
...(task.specReviewRound !== undefined ? { currentSpecRound: task.specReviewRound } : {}),
|
|
3944
|
-
});
|
|
3790
|
+
return replayTask('code', opts);
|
|
3945
3791
|
}
|
|
3946
3792
|
if (task.status === 'fixing' && task.phase === 'spec') {
|
|
3947
|
-
const round = task.specReviewRound ?? 1;
|
|
3948
|
-
const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
|
|
3949
|
-
if (!stored?.findings) {
|
|
3950
|
-
return await holdReplayFailure('restart-redispatch-failed', `REPL restarted during the spec fixing round but round ${round} findings are not persisted; the feedback prompt cannot be replayed. Cancel the task or re-run the spec review.`);
|
|
3951
|
-
}
|
|
3952
3793
|
if (!(await clearEntryHold()))
|
|
3953
3794
|
return false;
|
|
3954
|
-
return replayTask('
|
|
3795
|
+
return replayTask('fix', {
|
|
3955
3796
|
...(agent.role === 'dev' ? { allowDirtyWorkdir: true } : {}),
|
|
3956
|
-
serverPriorFindings: JSON.stringify(stored.findings),
|
|
3957
|
-
serverFindingsDigest: reviewFindingsDigest(stored.findings),
|
|
3958
|
-
...(task.specReviewRound !== undefined ? { currentSpecRound: task.specReviewRound } : {}),
|
|
3959
3797
|
});
|
|
3960
3798
|
}
|
|
3961
3799
|
// A direct (no-SDD) pass never persists a phase, so fixing + undefined is the plain code-feedback round.
|
|
3962
3800
|
if (task.status === 'fixing'
|
|
3963
3801
|
&& (task.phase === 'code' || task.phase === undefined)
|
|
3964
3802
|
&& agent.role === 'dev') {
|
|
3965
|
-
if (task.reviewMode === 'server') {
|
|
3966
|
-
const round = Math.max(task.reviewRound, 1);
|
|
3967
|
-
const stored = await this.reviewStore?.getRound(taskId, 'code', round);
|
|
3968
|
-
if (!stored?.findings) {
|
|
3969
|
-
return await holdReplayFailure('restart-redispatch-failed', `REPL restarted during the code fixing round but round ${round} findings are not persisted; the feedback prompt cannot be replayed. Cancel the task or re-run the code review.`);
|
|
3970
|
-
}
|
|
3971
|
-
if (!(await clearEntryHold()))
|
|
3972
|
-
return false;
|
|
3973
|
-
return replayTask('server-feedback', {
|
|
3974
|
-
allowDirtyWorkdir: true,
|
|
3975
|
-
serverPriorFindings: JSON.stringify(stored.findings),
|
|
3976
|
-
serverFindingsDigest: reviewFindingsDigest(stored.findings),
|
|
3977
|
-
});
|
|
3978
|
-
}
|
|
3979
3803
|
if (!(await clearEntryHold()))
|
|
3980
3804
|
return false;
|
|
3981
3805
|
return replayTask('fix', {
|
|
@@ -4032,70 +3856,10 @@ export class AgentManager {
|
|
|
4032
3856
|
const fresh = await this.taskStore.get(taskId);
|
|
4033
3857
|
return !fresh || !taskMatchesGeneration(fresh, expected) ? 'stale' : 'unsupported';
|
|
4034
3858
|
}
|
|
4035
|
-
// approved hosts
|
|
3859
|
+
// approved hosts the post-approve holder pass.
|
|
4036
3860
|
async replayApprovedHolderAfterReplRestart(agentId, task, entryHold, holdGen) {
|
|
4037
3861
|
const taskId = task.id;
|
|
4038
3862
|
const { clearEntryHold, holdReplayFailure, rootGenerationMatches } = holdGen;
|
|
4039
|
-
if (task.reviewMode === 'server') {
|
|
4040
|
-
if (!task.publishDispatchedAt)
|
|
4041
|
-
return false;
|
|
4042
|
-
if (!task.signalToken) {
|
|
4043
|
-
return holdReplayFailure('restart-redispatch-failed', 'REPL restarted mid-publish but the rotated publish token is missing; the publish prompt cannot be replayed. Cancel the task or mark-complete to re-dispatch the publish.');
|
|
4044
|
-
}
|
|
4045
|
-
const token = task.signalToken;
|
|
4046
|
-
const afterDone = this.resolveAfterDone(task);
|
|
4047
|
-
if (!afterDone) {
|
|
4048
|
-
return holdReplayFailure('restart-redispatch-failed', 'REPL restarted mid-publish but the after-done target cannot be resolved anymore. Resume then mark-complete to retry the publish, or cancel the task.');
|
|
4049
|
-
}
|
|
4050
|
-
let violation;
|
|
4051
|
-
try {
|
|
4052
|
-
violation = await this.findLineageViolation(taskId);
|
|
4053
|
-
}
|
|
4054
|
-
catch (err) {
|
|
4055
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4056
|
-
return holdReplayFailure('restart-redispatch-failed', `REPL restarted mid-publish but the branch lineage re-check failed: ${message}. Resume then Restart REPL to retry, or cancel the task.`);
|
|
4057
|
-
}
|
|
4058
|
-
if (violation) {
|
|
4059
|
-
return holdReplayFailure('restart-redispatch-failed', `The task branch embeds another active task's commits (${violation.branch}); rebase onto origin/HEAD, then mark-complete to retry the publish.`);
|
|
4060
|
-
}
|
|
4061
|
-
if (!(await clearEntryHold()))
|
|
4062
|
-
return false;
|
|
4063
|
-
const stillCurrent = async (signalToken) => {
|
|
4064
|
-
const fresh = await this.taskStore.get(taskId);
|
|
4065
|
-
return fresh?.agentId === agentId
|
|
4066
|
-
&& fresh.status === task.status
|
|
4067
|
-
&& fresh.phase === task.phase
|
|
4068
|
-
&& fresh.signalToken === signalToken
|
|
4069
|
-
&& rootGenerationMatches(fresh, signalToken);
|
|
4070
|
-
};
|
|
4071
|
-
return this.runRotatedReplay({
|
|
4072
|
-
agentId,
|
|
4073
|
-
taskAtEntry: task,
|
|
4074
|
-
oldToken: token,
|
|
4075
|
-
expectedKinds: ['code-ready'],
|
|
4076
|
-
rotateUnderTaskLock: async (newToken) => {
|
|
4077
|
-
const fresh = await this.taskStore.get(taskId);
|
|
4078
|
-
if (fresh?.agentId !== agentId
|
|
4079
|
-
|| fresh.status !== task.status
|
|
4080
|
-
|| fresh.phase !== task.phase
|
|
4081
|
-
|| fresh.signalToken !== token
|
|
4082
|
-
|| !rootGenerationMatches(fresh, token))
|
|
4083
|
-
return false;
|
|
4084
|
-
await this.taskStore.set({ ...fresh, signalToken: newToken, updatedAt: new Date().toISOString() });
|
|
4085
|
-
return true;
|
|
4086
|
-
},
|
|
4087
|
-
stillCurrent,
|
|
4088
|
-
holdFailure: (newToken, reason) => this.holdReplayFailureIfCurrent(agentId, { ...task, signalToken: newToken }, 'restart-redispatch-failed', reason, entryHold, async (fresh) => rootGenerationMatches(fresh, newToken)),
|
|
4089
|
-
continueWith: (newToken, fence) => this.continueSession(taskId, agentId, 'server-after-done', {
|
|
4090
|
-
signalToken: newToken,
|
|
4091
|
-
preserveDispatchOutputs: true,
|
|
4092
|
-
expectedHold: { phase: undefined, since: undefined, nonce: undefined },
|
|
4093
|
-
allowDirtyWorkdir: true,
|
|
4094
|
-
serverAfterDone: { kind: afterDone, branch: task.branch ?? BRANCH_PREFIX + taskId },
|
|
4095
|
-
...fence,
|
|
4096
|
-
}),
|
|
4097
|
-
});
|
|
4098
|
-
}
|
|
4099
3863
|
if (task.postApproveRevoked) {
|
|
4100
3864
|
return holdReplayFailure('restart-redispatch-failed', `The post-approve completion was deliberately revoked (${task.postApproveRevoked.reason}); ` +
|
|
4101
3865
|
'the block stays effective, so the pass is not replayed. Re-request changes to restart the review round, or cancel the task.');
|
|
@@ -4128,7 +3892,6 @@ export class AgentManager {
|
|
|
4128
3892
|
&& fresh.phase === task.phase
|
|
4129
3893
|
&& fresh.signalToken === task.signalToken
|
|
4130
3894
|
&& rootGenerationMatches(fresh, task.signalToken)
|
|
4131
|
-
&& fresh.reviewMode === task.reviewMode
|
|
4132
3895
|
&& fresh.postApproveRevoked === undefined
|
|
4133
3896
|
&& fresh.postApproveHeadSha === task.postApproveHeadSha
|
|
4134
3897
|
&& fresh.postApproveGeneration === task.postApproveGeneration
|
|
@@ -4167,7 +3930,6 @@ export class AgentManager {
|
|
|
4167
3930
|
token: newToken,
|
|
4168
3931
|
headSha: task.postApproveHeadSha,
|
|
4169
3932
|
},
|
|
4170
|
-
preserveDispatchOutputs: true,
|
|
4171
3933
|
expectedHold: { phase: undefined, since: undefined, nonce: undefined },
|
|
4172
3934
|
allowDirtyWorkdir: true,
|
|
4173
3935
|
...fence,
|
|
@@ -4190,8 +3952,6 @@ export class AgentManager {
|
|
|
4190
3952
|
const project = this.getProjectConfig(cfg.projectId);
|
|
4191
3953
|
if (!project)
|
|
4192
3954
|
throw new Error(`Unknown project: ${cfg.projectId}`);
|
|
4193
|
-
if (cfg.role !== 'dev')
|
|
4194
|
-
return { targets: [agentId] };
|
|
4195
3955
|
const group = this.findAgentGroup(agentId);
|
|
4196
3956
|
return { targets: group?.map(agent => agent.id) ?? [agentId] };
|
|
4197
3957
|
}
|
|
@@ -4292,12 +4052,15 @@ export class AgentManager {
|
|
|
4292
4052
|
}
|
|
4293
4053
|
const workdirGuess = workdirForEstimate ?? '/'.padEnd(64, 'x');
|
|
4294
4054
|
const now = new Date().toISOString();
|
|
4295
|
-
|
|
4055
|
+
if (cfg.role !== 'dev')
|
|
4056
|
+
throw new Error(`Agent ${cfg.id} is not a dev agent`);
|
|
4296
4057
|
const group = this.findAgentGroup(cfg.id);
|
|
4297
|
-
const devAgentId =
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4058
|
+
const devAgentId = cfg.id;
|
|
4059
|
+
const qaAgentId = input.preferredAgentId === ''
|
|
4060
|
+
? '_unassigned_preview_qa'
|
|
4061
|
+
: group?.find(agent => agent.role === 'qa')?.id;
|
|
4062
|
+
if (!qaAgentId)
|
|
4063
|
+
throw new Error(`Agent ${cfg.id} has no qa agent in its group`);
|
|
4301
4064
|
const fakeTask = {
|
|
4302
4065
|
id: 'task-9999999999',
|
|
4303
4066
|
projectId,
|
|
@@ -4306,11 +4069,9 @@ export class AgentManager {
|
|
|
4306
4069
|
preferredAgentId: input.preferredAgentId,
|
|
4307
4070
|
agentId: cfg.id,
|
|
4308
4071
|
devAgentId,
|
|
4309
|
-
|
|
4310
|
-
...(isResearch ? { researchAgentId: cfg.id, phase: 'research' } : {}),
|
|
4072
|
+
qaAgentId,
|
|
4311
4073
|
branch: `${BRANCH_PREFIX}task-9999999999`,
|
|
4312
4074
|
reviewRound: 0,
|
|
4313
|
-
reviewMode: this.effectiveReviewMode(projectId),
|
|
4314
4075
|
...this.platformBindingFields(projectId),
|
|
4315
4076
|
status: 'in_progress',
|
|
4316
4077
|
createdAt: now,
|
|
@@ -4318,15 +4079,12 @@ export class AgentManager {
|
|
|
4318
4079
|
};
|
|
4319
4080
|
const fullPrompt = buildPromptInline({
|
|
4320
4081
|
task: fakeTask,
|
|
4321
|
-
phase:
|
|
4082
|
+
phase: 'develop',
|
|
4322
4083
|
agent: cfg,
|
|
4323
4084
|
workdir: workdirGuess,
|
|
4324
4085
|
skillRegistry: this.skillRegistry,
|
|
4325
4086
|
signalToken: 'preview-signal-token',
|
|
4326
|
-
|
|
4327
|
-
...(!isResearch && fakeTask.reviewMode === 'git'
|
|
4328
|
-
? { platformCli: this.platformCliContextOf(fakeTask) }
|
|
4329
|
-
: {}),
|
|
4087
|
+
platformCli: this.platformCliContextOf(fakeTask),
|
|
4330
4088
|
});
|
|
4331
4089
|
return Buffer.byteLength(fullPrompt, 'utf8');
|
|
4332
4090
|
}
|
|
@@ -4378,6 +4136,9 @@ export class AgentManager {
|
|
|
4378
4136
|
async getAgentState(agentId) {
|
|
4379
4137
|
return this.agentStore.get(agentId);
|
|
4380
4138
|
}
|
|
4139
|
+
async getAgentLockOwner(agentId) {
|
|
4140
|
+
return this.lockManager.ownerOf(agentId);
|
|
4141
|
+
}
|
|
4381
4142
|
async getPostApproveCompletion(taskId) {
|
|
4382
4143
|
const task = await this.taskStore.get(taskId);
|
|
4383
4144
|
return task ? this.completionOf(task) : null;
|
|
@@ -4410,7 +4171,7 @@ export class AgentManager {
|
|
|
4410
4171
|
async armGitPostApproveEpisode(taskId, expected, opts = {}) {
|
|
4411
4172
|
return this.withTaskLock(async () => {
|
|
4412
4173
|
const task = await this.taskStore.get(taskId);
|
|
4413
|
-
if (!task ||
|
|
4174
|
+
if (!task || !this.postApproveEpisodeMatches(task, expected))
|
|
4414
4175
|
return false;
|
|
4415
4176
|
if (!this.phaseSignalWatcher)
|
|
4416
4177
|
return true;
|
|
@@ -4428,7 +4189,7 @@ export class AgentManager {
|
|
|
4428
4189
|
async rotateGitPostApproveEpisode(taskId, expected, redispatchCount) {
|
|
4429
4190
|
return this.withTaskLock(async () => {
|
|
4430
4191
|
const task = await this.taskStore.get(taskId);
|
|
4431
|
-
if (!task || task.
|
|
4192
|
+
if (!task || task.pendingRedispatch !== true
|
|
4432
4193
|
|| !this.postApproveEpisodeMatches(task, expected))
|
|
4433
4194
|
return null;
|
|
4434
4195
|
const token = createSignalTokenExcluding(expected.generation, expected.token);
|
|
@@ -4456,10 +4217,17 @@ export class AgentManager {
|
|
|
4456
4217
|
const next = nextStatus === 'approved'
|
|
4457
4218
|
? { ...task }
|
|
4458
4219
|
: this.stripPostApproveEpisodeFields(task);
|
|
4459
|
-
if (
|
|
4220
|
+
if (nextStatus !== 'review') {
|
|
4460
4221
|
delete next.reviewDispatch;
|
|
4461
4222
|
delete next.reviewRoundPending;
|
|
4462
4223
|
}
|
|
4224
|
+
if (nextStatus !== task.status && next.outbox) {
|
|
4225
|
+
const outbox = next.outbox.filter(entry => entry.type !== 'git.spec-verdict');
|
|
4226
|
+
if (outbox.length > 0)
|
|
4227
|
+
next.outbox = outbox;
|
|
4228
|
+
else
|
|
4229
|
+
delete next.outbox;
|
|
4230
|
+
}
|
|
4463
4231
|
return next;
|
|
4464
4232
|
}
|
|
4465
4233
|
async confirmPostApprovePromptDelivered(taskId, expected) {
|
|
@@ -4615,9 +4383,7 @@ export class AgentManager {
|
|
|
4615
4383
|
async recordClosedUnmergedAnchor(taskId, prNumber) {
|
|
4616
4384
|
return this.withTaskLock(async () => {
|
|
4617
4385
|
const task = await this.taskStore.get(taskId);
|
|
4618
|
-
|
|
4619
|
-
|| (task?.reviewMode === 'server' && task.afterDone === 'pr' && task.platformBinding !== undefined);
|
|
4620
|
-
if (!task || !tracksPlatformLifecycle || TERMINAL_STATUSES.includes(task.status))
|
|
4386
|
+
if (!task || TERMINAL_STATUSES.includes(task.status))
|
|
4621
4387
|
return false;
|
|
4622
4388
|
const anchor = task.closedUnmergedAnchor;
|
|
4623
4389
|
if (anchor?.prNumber === prNumber && anchor.cleared !== true)
|
|
@@ -4658,43 +4424,58 @@ export class AgentManager {
|
|
|
4658
4424
|
return true;
|
|
4659
4425
|
});
|
|
4660
4426
|
}
|
|
4427
|
+
specVerdictOutbox(task) {
|
|
4428
|
+
return task.outbox?.find((entry) => entry.type === 'git.spec-verdict');
|
|
4429
|
+
}
|
|
4430
|
+
async mutateTaskOutboxEntry(taskId, key, mutate) {
|
|
4431
|
+
return this.withTaskLock(async () => {
|
|
4432
|
+
const task = await this.taskStore.get(taskId);
|
|
4433
|
+
const entry = task?.outbox?.find(current => current.key === key);
|
|
4434
|
+
if (!task || !entry)
|
|
4435
|
+
return null;
|
|
4436
|
+
const changed = mutate(entry);
|
|
4437
|
+
if (changed === null)
|
|
4438
|
+
return null;
|
|
4439
|
+
const outbox = changed === undefined
|
|
4440
|
+
? task.outbox.filter(current => current.key !== key)
|
|
4441
|
+
: task.outbox.map(current => current.key === key ? changed : current);
|
|
4442
|
+
const next = { ...task, outbox, updatedAt: new Date().toISOString() };
|
|
4443
|
+
if (outbox.length === 0)
|
|
4444
|
+
delete next.outbox;
|
|
4445
|
+
await this.taskStore.set(next);
|
|
4446
|
+
return next;
|
|
4447
|
+
});
|
|
4448
|
+
}
|
|
4661
4449
|
async deliverTaskOutbox(taskId) {
|
|
4662
4450
|
const task = await this.taskStore.get(taskId);
|
|
4663
4451
|
if (!task?.outbox?.length)
|
|
4664
4452
|
return;
|
|
4665
4453
|
for (const entry of task.outbox) {
|
|
4666
4454
|
try {
|
|
4455
|
+
if (entry.type === 'git.spec-verdict') {
|
|
4456
|
+
await this.deliverSpecVerdictOutbox(taskId, entry.key);
|
|
4457
|
+
continue;
|
|
4458
|
+
}
|
|
4667
4459
|
await this.eventBus.emit({
|
|
4668
4460
|
id: `outbox:${entry.key}`,
|
|
4669
|
-
type:
|
|
4461
|
+
type: 'human.intervention',
|
|
4670
4462
|
timestamp: new Date().toISOString(),
|
|
4671
4463
|
projectId: task.projectId,
|
|
4672
4464
|
agentId: task.agentId,
|
|
4673
4465
|
taskId: task.id,
|
|
4674
4466
|
data: entry.data,
|
|
4675
4467
|
});
|
|
4468
|
+
await this.mutateTaskOutboxEntry(taskId, entry.key, current => current.type === entry.type ? undefined : null);
|
|
4676
4469
|
}
|
|
4677
4470
|
catch (err) {
|
|
4678
4471
|
console.warn(`[AgentManager] outbox delivery failed for task=${taskId} key=${entry.key}:`, err);
|
|
4679
4472
|
continue;
|
|
4680
4473
|
}
|
|
4681
|
-
await this.withTaskLock(async () => {
|
|
4682
|
-
const fresh = await this.taskStore.get(taskId);
|
|
4683
|
-
if (!fresh?.outbox?.some(e => e.key === entry.key))
|
|
4684
|
-
return;
|
|
4685
|
-
const remaining = fresh.outbox.filter(e => e.key !== entry.key);
|
|
4686
|
-
const { outbox: _outbox, ...rest } = fresh;
|
|
4687
|
-
await this.taskStore.set({
|
|
4688
|
-
...rest,
|
|
4689
|
-
...(remaining.length > 0 ? { outbox: remaining } : {}),
|
|
4690
|
-
updatedAt: new Date().toISOString(),
|
|
4691
|
-
});
|
|
4692
|
-
});
|
|
4693
4474
|
}
|
|
4694
4475
|
}
|
|
4695
4476
|
async rearmPostApproveSignal(taskId, opts = {}) {
|
|
4696
4477
|
const task = await this.taskStore.get(taskId);
|
|
4697
|
-
if (!task || task.
|
|
4478
|
+
if (!task || task.status !== 'approved')
|
|
4698
4479
|
return false;
|
|
4699
4480
|
const completion = this.completionOf(task);
|
|
4700
4481
|
if (!completion)
|
|
@@ -4723,8 +4504,6 @@ export class AgentManager {
|
|
|
4723
4504
|
if (!this.pluginRegistry)
|
|
4724
4505
|
return;
|
|
4725
4506
|
for (const project of next.project) {
|
|
4726
|
-
if (projectReviewMode(next, project) !== 'git')
|
|
4727
|
-
continue;
|
|
4728
4507
|
const tool = resolveProjectTool(project);
|
|
4729
4508
|
if (tool === undefined)
|
|
4730
4509
|
continue;
|
|
@@ -4764,19 +4543,17 @@ export class AgentManager {
|
|
|
4764
4543
|
}
|
|
4765
4544
|
}
|
|
4766
4545
|
gitReviewLeaseMatches(task, lease) {
|
|
4767
|
-
return task.
|
|
4768
|
-
&& task.status === 'review'
|
|
4546
|
+
return task.status === 'review'
|
|
4769
4547
|
&& task.signalToken === lease.signalToken
|
|
4770
4548
|
&& task.reviewHeadAnchorSha === lease.headSha
|
|
4771
4549
|
&& task.passToken === lease.passToken
|
|
4772
4550
|
&& task.failToken === lease.failToken
|
|
4773
|
-
&& lease.effectiveRound ===
|
|
4551
|
+
&& lease.effectiveRound === effectiveTaskReviewRound(task);
|
|
4774
4552
|
}
|
|
4775
4553
|
// startSession 按 agent 的 projectId 解析 repo/workdir,项目归属不入判会跨仓库检出
|
|
4776
4554
|
gitReviewQaConfigUsable(task) {
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
const config = this.getAgentConfig(task.qaAgentId);
|
|
4555
|
+
const qaAgentId = requireTaskQaAgentId(task, 'gitReviewQaConfigUsable');
|
|
4556
|
+
const config = this.getAgentConfig(qaAgentId);
|
|
4780
4557
|
return config?.role === 'qa' && config.projectId === task.projectId;
|
|
4781
4558
|
}
|
|
4782
4559
|
async beginGitReviewPass(taskId, opts) {
|
|
@@ -4787,7 +4564,12 @@ export class AgentManager {
|
|
|
4787
4564
|
if (this.markCompleteInFlight.has(taskId) && opts.allowDuringComplete !== true)
|
|
4788
4565
|
return null;
|
|
4789
4566
|
const current = await this.taskStore.get(taskId);
|
|
4790
|
-
if (!current ||
|
|
4567
|
+
if (!current || TERMINAL_STATUSES.includes(current.status))
|
|
4568
|
+
return null;
|
|
4569
|
+
if (current.phase === undefined)
|
|
4570
|
+
return null;
|
|
4571
|
+
if (current.status === 'in_progress'
|
|
4572
|
+
&& current.deliveryConfirmation?.phase !== current.phase)
|
|
4791
4573
|
return null;
|
|
4792
4574
|
if (opts.expectedTask && !taskMatchesGeneration(current, opts.expectedTask))
|
|
4793
4575
|
return null;
|
|
@@ -4810,14 +4592,16 @@ export class AgentManager {
|
|
|
4810
4592
|
const signalToken = createSignalTokenExcluding(pair.passToken, pair.failToken);
|
|
4811
4593
|
const generation = createSignalTokenExcluding(signalToken, pair.passToken, pair.failToken);
|
|
4812
4594
|
const reviewRoundPending = opts.bumpRound ? true : undefined;
|
|
4813
|
-
const effectiveRound = Math.max(1, current.
|
|
4595
|
+
const effectiveRound = Math.max(1, (current.phase === 'spec' ? (current.specReviewRound ?? 0) : current.reviewRound)
|
|
4596
|
+
+ (reviewRoundPending === true ? 1 : 0));
|
|
4814
4597
|
const qaPhase = opts.qaPhase ?? (current.status === 'in_progress'
|
|
4815
|
-
|| (current.status === 'review'
|
|
4598
|
+
|| (current.status === 'review'
|
|
4599
|
+
&& (current.phase === 'spec' ? (current.specReviewRound ?? 0) : current.reviewRound) === 0
|
|
4600
|
+
&& opts.bumpRound)
|
|
4816
4601
|
? 'review'
|
|
4817
4602
|
: 'recheck');
|
|
4818
4603
|
const next = this.stripPostApproveEpisodeFields(current);
|
|
4819
4604
|
delete next.passProvenance;
|
|
4820
|
-
delete next.serverSignalRecovery;
|
|
4821
4605
|
Object.assign(next, opts.patch ?? {}, {
|
|
4822
4606
|
status: 'review',
|
|
4823
4607
|
signalToken,
|
|
@@ -4875,7 +4659,7 @@ export class AgentManager {
|
|
|
4875
4659
|
}
|
|
4876
4660
|
return this.withTaskLock(async () => {
|
|
4877
4661
|
const current = await this.taskStore.get(taskId);
|
|
4878
|
-
if (!current || current.
|
|
4662
|
+
if (!current || current.status !== 'review')
|
|
4879
4663
|
return null;
|
|
4880
4664
|
if (current.signalToken !== opts.expectedSignalToken
|
|
4881
4665
|
|| current.passToken !== opts.provenance.token
|
|
@@ -4956,22 +4740,20 @@ export class AgentManager {
|
|
|
4956
4740
|
|| current.generation !== expectedGeneration
|
|
4957
4741
|
|| !this.gitReviewLeaseMatches(task, current))
|
|
4958
4742
|
return null;
|
|
4959
|
-
const qaId = task
|
|
4743
|
+
const qaId = requireTaskQaAgentId(task, 'confirmUncertainGitReviewDispatch');
|
|
4960
4744
|
let qaHold;
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
if (qa
|
|
4964
|
-
if (qa.awaitingPhase !== 'dispatch-failed:ack_unknown')
|
|
4965
|
-
return null;
|
|
4966
|
-
qaHold = {
|
|
4967
|
-
phase: qa.awaitingPhase,
|
|
4968
|
-
since: qa.awaitingSince,
|
|
4969
|
-
nonce: qa.awaitingNonce,
|
|
4970
|
-
};
|
|
4971
|
-
}
|
|
4972
|
-
else if (qa?.taskId !== task.id && !canDispatchWithBinding(qa)) {
|
|
4745
|
+
const qa = await this.agentStore.get(qaId);
|
|
4746
|
+
if (qa?.taskId === task.id && qa.status === 'awaiting_human') {
|
|
4747
|
+
if (qa.awaitingPhase !== 'dispatch-failed:ack_unknown')
|
|
4973
4748
|
return null;
|
|
4974
|
-
|
|
4749
|
+
qaHold = {
|
|
4750
|
+
phase: qa.awaitingPhase,
|
|
4751
|
+
since: qa.awaitingSince,
|
|
4752
|
+
nonce: qa.awaitingNonce,
|
|
4753
|
+
};
|
|
4754
|
+
}
|
|
4755
|
+
else if (qa?.taskId !== task.id && !canDispatchWithBinding(qa)) {
|
|
4756
|
+
return null;
|
|
4975
4757
|
}
|
|
4976
4758
|
const now = new Date().toISOString();
|
|
4977
4759
|
const { claimId: _claimId, claimedAt: _claimedAt, ...rest } = current;
|
|
@@ -4988,7 +4770,7 @@ export class AgentManager {
|
|
|
4988
4770
|
updatedAt: now,
|
|
4989
4771
|
};
|
|
4990
4772
|
await this.taskStore.set(next);
|
|
4991
|
-
if (qaHold !== undefined
|
|
4773
|
+
if (qaHold !== undefined) {
|
|
4992
4774
|
const cleared = await this.clearAwaitingHuman(qaId, {
|
|
4993
4775
|
expectedTaskId: task.id,
|
|
4994
4776
|
expectedHold: qaHold,
|
|
@@ -5024,7 +4806,10 @@ export class AgentManager {
|
|
|
5024
4806
|
const now = new Date().toISOString();
|
|
5025
4807
|
const next = { ...task, updatedAt: now };
|
|
5026
4808
|
if (next.reviewRoundPending === true) {
|
|
5027
|
-
next.
|
|
4809
|
+
if (next.phase === 'spec')
|
|
4810
|
+
next.specReviewRound = (next.specReviewRound ?? 0) + 1;
|
|
4811
|
+
else
|
|
4812
|
+
next.reviewRound += 1;
|
|
5028
4813
|
delete next.reviewRoundPending;
|
|
5029
4814
|
}
|
|
5030
4815
|
next.reviewDispatchedAt = now;
|
|
@@ -5039,10 +4824,10 @@ export class AgentManager {
|
|
|
5039
4824
|
const current = task?.reviewDispatch;
|
|
5040
4825
|
if (!task || !current || current.phase !== 'pending'
|
|
5041
4826
|
|| current.generation !== expectedGeneration
|
|
5042
|
-
|| !this.gitReviewLeaseMatches(task, current)
|
|
5043
|
-
|| task.qaAgentId === undefined)
|
|
4827
|
+
|| !this.gitReviewLeaseMatches(task, current))
|
|
5044
4828
|
return null;
|
|
5045
|
-
const
|
|
4829
|
+
const qaAgentId = requireTaskQaAgentId(task, 'recoverPendingGitReviewDispatchHold');
|
|
4830
|
+
const qa = await this.agentStore.get(qaAgentId);
|
|
5046
4831
|
if (qa?.taskId !== task.id
|
|
5047
4832
|
|| qa.status !== 'awaiting_human'
|
|
5048
4833
|
|| qa.awaitingPhase !== 'dispatch-failed:ack_unknown')
|
|
@@ -5067,16 +4852,17 @@ export class AgentManager {
|
|
|
5067
4852
|
const tasks = await this.taskStore.list();
|
|
5068
4853
|
for (const snapshot of tasks) {
|
|
5069
4854
|
const lease = snapshot.reviewDispatch;
|
|
5070
|
-
if (
|
|
4855
|
+
if (!lease || lease.phase !== 'claimed')
|
|
5071
4856
|
continue;
|
|
5072
|
-
const
|
|
4857
|
+
const qaAgentId = requireTaskQaAgentId(snapshot, 'recoverClaimedGitReviewDispatches');
|
|
4858
|
+
const qa = await this.agentStore.get(qaAgentId);
|
|
5073
4859
|
if (!qa || qa.taskId !== snapshot.id) {
|
|
5074
4860
|
await this.resetGitReviewDispatchClaim(snapshot.id, lease);
|
|
5075
4861
|
continue;
|
|
5076
4862
|
}
|
|
5077
4863
|
const uncertain = await this.markGitReviewDispatchUncertain(snapshot.id, lease);
|
|
5078
4864
|
if (uncertain) {
|
|
5079
|
-
await this.emitIntervention(snapshot.projectId,
|
|
4865
|
+
await this.emitIntervention(snapshot.projectId, qaAgentId, snapshot.id, {
|
|
5080
4866
|
phase: 'git-review-dispatch-recovery-uncertain',
|
|
5081
4867
|
generation: lease.generation,
|
|
5082
4868
|
claimId: lease.claimId,
|
|
@@ -5088,41 +4874,97 @@ export class AgentManager {
|
|
|
5088
4874
|
async retryPendingGitReviewDispatches() {
|
|
5089
4875
|
const tasks = await this.taskStore.list();
|
|
5090
4876
|
for (const snapshot of tasks) {
|
|
5091
|
-
|
|
5092
|
-
if (
|
|
5093
|
-
|
|
5094
|
-
|
|
4877
|
+
let task = snapshot;
|
|
4878
|
+
if (task.status === 'in_progress'
|
|
4879
|
+
&& task.phase !== undefined
|
|
4880
|
+
&& task.deliveryConfirmation?.phase === task.phase
|
|
4881
|
+
&& task.reviewDispatch === undefined
|
|
4882
|
+
&& task.prNumber !== undefined) {
|
|
4883
|
+
try {
|
|
4884
|
+
const verified = await this.platformVerifyPrBinding(task.id, task.prNumber);
|
|
4885
|
+
if (!verified.ok) {
|
|
4886
|
+
const latch = [
|
|
4887
|
+
'delivery-binding',
|
|
4888
|
+
task.id,
|
|
4889
|
+
task.phase,
|
|
4890
|
+
task.signalToken ?? '',
|
|
4891
|
+
task.prNumber,
|
|
4892
|
+
verified.reason,
|
|
4893
|
+
].join(':');
|
|
4894
|
+
if (!this.gitReviewRecoveryAlerted.has(latch)) {
|
|
4895
|
+
const live = await this.taskStore.get(task.id);
|
|
4896
|
+
if (live?.status === 'in_progress'
|
|
4897
|
+
&& live.phase === task.phase
|
|
4898
|
+
&& live.signalToken === task.signalToken
|
|
4899
|
+
&& live.deliveryConfirmation?.phase === task.phase
|
|
4900
|
+
&& live.reviewDispatch === undefined
|
|
4901
|
+
&& live.prNumber === task.prNumber) {
|
|
4902
|
+
const alerted = await this.emitIntervention(live.projectId, live.agentId || undefined, live.id, {
|
|
4903
|
+
phase: 'git-review-delivery-binding-invalid',
|
|
4904
|
+
reason: verified.reason,
|
|
4905
|
+
prNumber: task.prNumber,
|
|
4906
|
+
deliveryPhase: task.phase,
|
|
4907
|
+
});
|
|
4908
|
+
if (alerted)
|
|
4909
|
+
this.gitReviewRecoveryAlerted.add(latch);
|
|
4910
|
+
}
|
|
4911
|
+
}
|
|
4912
|
+
continue;
|
|
4913
|
+
}
|
|
4914
|
+
if (PLATFORM_HEAD_SHA_RE.test(verified.headSha)) {
|
|
4915
|
+
const begun = await this.beginGitReviewPass(task.id, {
|
|
4916
|
+
fromStatus: ['in_progress'],
|
|
4917
|
+
headSha: verified.headSha,
|
|
4918
|
+
bumpRound: true,
|
|
4919
|
+
qaPhase: 'review',
|
|
4920
|
+
expectSignalToken: task.signalToken,
|
|
4921
|
+
expectPhase: task.phase,
|
|
4922
|
+
expectedTask: taskGenerationGuard(task),
|
|
4923
|
+
patch: { latestHeadSha: verified.headSha },
|
|
4924
|
+
});
|
|
4925
|
+
task = begun?.task ?? (await this.taskStore.get(task.id)) ?? task;
|
|
4926
|
+
}
|
|
4927
|
+
}
|
|
4928
|
+
catch (err) {
|
|
4929
|
+
console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${task.id} confirmed-delivery recovery failed, continuing:`, err);
|
|
4930
|
+
continue;
|
|
4931
|
+
}
|
|
4932
|
+
}
|
|
4933
|
+
const lease = task.reviewDispatch;
|
|
4934
|
+
if (lease?.phase !== 'pending')
|
|
5095
4935
|
continue;
|
|
5096
|
-
|
|
4936
|
+
const qaAgentId = requireTaskQaAgentId(task, 'retryPendingGitReviewDispatches');
|
|
4937
|
+
if (!this.gitReviewQaConfigUsable(task)) {
|
|
5097
4938
|
// 快照可能已过期:告警前 live 复核;按 lease 代际告警一次,emit 成功才落闩
|
|
5098
|
-
const latch =
|
|
5099
|
-
if (!this.
|
|
4939
|
+
const latch = `qa-config:${task.id}:${lease.generation}`;
|
|
4940
|
+
if (!this.gitReviewRecoveryAlerted.has(latch)) {
|
|
5100
4941
|
try {
|
|
5101
|
-
const live = await this.taskStore.get(
|
|
4942
|
+
const live = await this.taskStore.get(task.id);
|
|
5102
4943
|
const liveLease = live?.reviewDispatch;
|
|
5103
4944
|
if (live && liveLease?.phase === 'pending'
|
|
5104
4945
|
&& liveLease.generation === lease.generation
|
|
5105
|
-
&& live.qaAgentId ===
|
|
4946
|
+
&& live.qaAgentId === qaAgentId
|
|
5106
4947
|
&& !this.gitReviewQaConfigUsable(live)) {
|
|
5107
|
-
const
|
|
4948
|
+
const liveQaAgentId = requireTaskQaAgentId(live, 'retryPendingGitReviewDispatches');
|
|
4949
|
+
const alerted = await this.emitIntervention(live.projectId, liveQaAgentId, live.id, {
|
|
5108
4950
|
phase: 'git-review-qa-config-missing',
|
|
5109
|
-
qaAgentId:
|
|
4951
|
+
qaAgentId: liveQaAgentId,
|
|
5110
4952
|
generation: lease.generation,
|
|
5111
4953
|
});
|
|
5112
4954
|
if (alerted)
|
|
5113
|
-
this.
|
|
4955
|
+
this.gitReviewRecoveryAlerted.add(latch);
|
|
5114
4956
|
}
|
|
5115
4957
|
}
|
|
5116
4958
|
catch (err) {
|
|
5117
|
-
console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${
|
|
4959
|
+
console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${task.id} qa-config alert check failed, continuing:`, err);
|
|
5118
4960
|
}
|
|
5119
4961
|
}
|
|
5120
4962
|
continue;
|
|
5121
4963
|
}
|
|
5122
4964
|
try {
|
|
5123
|
-
const recovered = await this.recoverPendingGitReviewDispatchHold(
|
|
4965
|
+
const recovered = await this.recoverPendingGitReviewDispatchHold(task.id, lease.generation);
|
|
5124
4966
|
if (recovered) {
|
|
5125
|
-
await this.emitIntervention(
|
|
4967
|
+
await this.emitIntervention(task.projectId, qaAgentId, task.id, {
|
|
5126
4968
|
phase: 'git-review-dispatch-recovery-uncertain',
|
|
5127
4969
|
generation: lease.generation,
|
|
5128
4970
|
claimId: recovered.claimId,
|
|
@@ -5130,28 +4972,52 @@ export class AgentManager {
|
|
|
5130
4972
|
});
|
|
5131
4973
|
continue;
|
|
5132
4974
|
}
|
|
5133
|
-
const entry = this.getPendingDispatchRetry(
|
|
4975
|
+
const entry = this.getPendingDispatchRetry(task.id);
|
|
5134
4976
|
if (entry?.kind === 'qa-recheck' && entry.signalToken === lease.signalToken)
|
|
5135
4977
|
continue;
|
|
5136
|
-
if (
|
|
5137
|
-
throw new Error(`task ${
|
|
4978
|
+
if (task.prNumber === undefined) {
|
|
4979
|
+
throw new Error(`task ${task.id} has no PR bound to its pending review lease`);
|
|
4980
|
+
}
|
|
4981
|
+
const verified = await this.platformVerifyPrBinding(task.id, task.prNumber);
|
|
4982
|
+
if (!verified.ok) {
|
|
4983
|
+
const latch = `lease-binding:${task.id}:${lease.generation}`;
|
|
4984
|
+
if (!this.gitReviewRecoveryAlerted.has(latch)) {
|
|
4985
|
+
const live = await this.taskStore.get(task.id);
|
|
4986
|
+
const liveLease = live?.reviewDispatch;
|
|
4987
|
+
if (live && liveLease?.phase === 'pending'
|
|
4988
|
+
&& liveLease.generation === lease.generation
|
|
4989
|
+
&& liveLease.signalToken === lease.signalToken
|
|
4990
|
+
&& live.prNumber === task.prNumber
|
|
4991
|
+
&& live.qaAgentId === qaAgentId) {
|
|
4992
|
+
const alerted = await this.emitIntervention(live.projectId, qaAgentId, live.id, {
|
|
4993
|
+
phase: 'git-review-lease-binding-invalid',
|
|
4994
|
+
reason: verified.reason,
|
|
4995
|
+
prNumber: task.prNumber,
|
|
4996
|
+
generation: lease.generation,
|
|
4997
|
+
deliveryPhase: live.phase,
|
|
4998
|
+
qaPhase: lease.qaPhase,
|
|
4999
|
+
});
|
|
5000
|
+
if (alerted)
|
|
5001
|
+
this.gitReviewRecoveryAlerted.add(latch);
|
|
5002
|
+
}
|
|
5003
|
+
}
|
|
5004
|
+
continue;
|
|
5138
5005
|
}
|
|
5139
|
-
|
|
5140
|
-
|
|
5141
|
-
console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${snapshot.id} binding/head changed; ` +
|
|
5006
|
+
if (verified.headSha.toLowerCase() !== lease.headSha.toLowerCase()) {
|
|
5007
|
+
console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${task.id} binding/head changed; ` +
|
|
5142
5008
|
`preserving generation=${lease.generation} without dispatch`);
|
|
5143
5009
|
continue;
|
|
5144
5010
|
}
|
|
5145
|
-
await this.dispatchGitReviewLease(
|
|
5011
|
+
await this.dispatchGitReviewLease(task.id, {
|
|
5146
5012
|
expectedGeneration: lease.generation,
|
|
5147
5013
|
});
|
|
5148
5014
|
}
|
|
5149
5015
|
catch (err) {
|
|
5150
|
-
if (err instanceof DispatchTerminalError && err.reason !== 'ack_unknown'
|
|
5151
|
-
await this.failTaskForDispatchError(
|
|
5016
|
+
if (err instanceof DispatchTerminalError && err.reason !== 'ack_unknown') {
|
|
5017
|
+
await this.failTaskForDispatchError(task.id, lease.qaPhase, qaAgentId, err, { expectedReviewDispatch: lease });
|
|
5152
5018
|
continue;
|
|
5153
5019
|
}
|
|
5154
|
-
console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${
|
|
5020
|
+
console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${task.id} still failing:`, err);
|
|
5155
5021
|
}
|
|
5156
5022
|
}
|
|
5157
5023
|
}
|
|
@@ -5159,7 +5025,7 @@ export class AgentManager {
|
|
|
5159
5025
|
const tasks = await this.taskStore.list();
|
|
5160
5026
|
for (const task of tasks) {
|
|
5161
5027
|
const intent = task.remoteCleanup;
|
|
5162
|
-
if (
|
|
5028
|
+
if (!intent || intent.stage === 'manual')
|
|
5163
5029
|
continue;
|
|
5164
5030
|
try {
|
|
5165
5031
|
await this.processGitRemoteCleanup(task.id, intent.generation);
|
|
@@ -5182,7 +5048,7 @@ export class AgentManager {
|
|
|
5182
5048
|
async consumeGitFeedbackRevision(taskId, consume, opts = {}) {
|
|
5183
5049
|
return this.withTaskLock(async () => {
|
|
5184
5050
|
const task = await this.taskStore.get(taskId);
|
|
5185
|
-
if (!task
|
|
5051
|
+
if (!task)
|
|
5186
5052
|
return { kind: 'gone' };
|
|
5187
5053
|
if (task.consumedFeedback?.[consume.key] !== undefined)
|
|
5188
5054
|
return { kind: 'duplicate' };
|
|
@@ -5242,8 +5108,6 @@ export class AgentManager {
|
|
|
5242
5108
|
async setupRecoveredPostApproveSignals() {
|
|
5243
5109
|
const tasks = await this.taskStore.list({ status: 'approved' });
|
|
5244
5110
|
for (const task of tasks) {
|
|
5245
|
-
if (task.reviewMode !== 'git')
|
|
5246
|
-
continue;
|
|
5247
5111
|
if (task.postApproveRevoked) {
|
|
5248
5112
|
// Finish an interrupted revoke: the marker is authoritative, the leftover record must not re-arm.
|
|
5249
5113
|
try {
|
|
@@ -5283,15 +5147,6 @@ export class AgentManager {
|
|
|
5283
5147
|
async setupRecoveredSpecSignals() {
|
|
5284
5148
|
const tasks = await this.taskStore.list();
|
|
5285
5149
|
for (const task of tasks) {
|
|
5286
|
-
if (task.serverSignalRecovery) {
|
|
5287
|
-
try {
|
|
5288
|
-
await this.recoverServerSignalRecovery(task);
|
|
5289
|
-
}
|
|
5290
|
-
catch (err) {
|
|
5291
|
-
console.warn(`[AgentManager] setupRecoveredSpecSignals: failed to recover server signal for task=${task.id}:`, err);
|
|
5292
|
-
}
|
|
5293
|
-
continue;
|
|
5294
|
-
}
|
|
5295
5150
|
if (!this.phaseSignalWatcher)
|
|
5296
5151
|
continue;
|
|
5297
5152
|
if (!task.signalToken)
|
|
@@ -5304,213 +5159,44 @@ export class AgentManager {
|
|
|
5304
5159
|
}
|
|
5305
5160
|
}
|
|
5306
5161
|
}
|
|
5307
|
-
async recoverServerSignalRecovery(task) {
|
|
5308
|
-
const recovery = task.serverSignalRecovery;
|
|
5309
|
-
const successorToken = task.signalToken;
|
|
5310
|
-
if (!recovery || !successorToken)
|
|
5311
|
-
return;
|
|
5312
|
-
const agentId = recovery.signalKind.endsWith('-reviewed') ? (task.qaAgentId ?? '') : task.agentId;
|
|
5313
|
-
const hold = async (phase, reason) => {
|
|
5314
|
-
const held = await this.holdServerSignalRecovery(task.id, agentId, successorToken, phase, reason);
|
|
5315
|
-
if (!held)
|
|
5316
|
-
return;
|
|
5317
|
-
await this.emitIntervention(task.projectId, agentId, task.id, {
|
|
5318
|
-
phase,
|
|
5319
|
-
recoveryReason: recovery.reason,
|
|
5320
|
-
successorToken,
|
|
5321
|
-
});
|
|
5322
|
-
};
|
|
5323
|
-
if (recovery.mode === 'hold') {
|
|
5324
|
-
if (!agentId)
|
|
5325
|
-
return;
|
|
5326
|
-
const state = await this.agentStore.get(agentId);
|
|
5327
|
-
if (state?.status === 'awaiting_human')
|
|
5328
|
-
return;
|
|
5329
|
-
await hold(recovery.failurePhase ?? `server-signal-${recovery.reason}`, `A consumed ${recovery.signalKind} signal is held for ${recovery.reason}. `
|
|
5330
|
-
+ 'Resolve the cause, then Restart or Cancel the task.');
|
|
5331
|
-
return;
|
|
5332
|
-
}
|
|
5333
|
-
const stored = await this.reviewStore?.getRound(task.id, recovery.phase, recovery.round);
|
|
5334
|
-
if (!stored?.findings || !recovery.findingsDigest
|
|
5335
|
-
|| reviewFindingsDigest(stored.findings) !== recovery.findingsDigest) {
|
|
5336
|
-
await hold(`server-${recovery.phase}-feedback-recovery-findings-missing`, `Cannot recover the rejected response because round ${recovery.round} findings are missing or changed.`);
|
|
5337
|
-
return;
|
|
5338
|
-
}
|
|
5339
|
-
const responseReasons = new Set([
|
|
5340
|
-
'response-missing', 'response-invalid', 'round-mismatch', 'token-mismatch',
|
|
5341
|
-
'findings-digest-mismatch', 'coverage-gap',
|
|
5342
|
-
]);
|
|
5343
|
-
const dev = agentId ? this.getAgentConfig(agentId) : undefined;
|
|
5344
|
-
if (!dev || !recovery.failureSignature
|
|
5345
|
-
|| (recovery.signalKind !== 'code-fixed' && recovery.signalKind !== 'spec-fixed')
|
|
5346
|
-
|| !responseReasons.has(recovery.reason)) {
|
|
5347
|
-
await hold(`server-${recovery.phase}-feedback-recovery-invalid`, 'The persisted response recovery intent is incomplete and cannot be resumed safely.');
|
|
5348
|
-
return;
|
|
5349
|
-
}
|
|
5350
|
-
const readResponse = async () => {
|
|
5351
|
-
try {
|
|
5352
|
-
const result = await this.getReviewTransport().readResponseWithRaw(task, dev);
|
|
5353
|
-
if (result.kind === 'unknown') {
|
|
5354
|
-
await hold(`server-${recovery.phase}-feedback-recovery-read-failed`, 'The rejected response could not be read while finishing crash recovery; the file was preserved.');
|
|
5355
|
-
return null;
|
|
5356
|
-
}
|
|
5357
|
-
return result;
|
|
5358
|
-
}
|
|
5359
|
-
catch (err) {
|
|
5360
|
-
await hold(`server-${recovery.phase}-feedback-recovery-read-failed`, `The rejected response could not be read while finishing crash recovery: `
|
|
5361
|
-
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
5362
|
-
return null;
|
|
5363
|
-
}
|
|
5364
|
-
};
|
|
5365
|
-
const holdChangedResponse = async () => hold(`server-${recovery.phase}-feedback-recovery-response-changed`, 'The response changed after its token was retired, so recovery preserved the file for manual inspection.');
|
|
5366
|
-
if (recovery.mode === 'classify-response') {
|
|
5367
|
-
const result = await readResponse();
|
|
5368
|
-
if (!result)
|
|
5369
|
-
return;
|
|
5370
|
-
let rawResponse;
|
|
5371
|
-
let responseDigest;
|
|
5372
|
-
if (recovery.reason === 'response-missing') {
|
|
5373
|
-
if (result.kind !== 'absent') {
|
|
5374
|
-
await holdChangedResponse();
|
|
5375
|
-
return;
|
|
5376
|
-
}
|
|
5377
|
-
}
|
|
5378
|
-
else if (result.kind === 'absent' || result.responseDigest !== recovery.responseDigest) {
|
|
5379
|
-
await holdChangedResponse();
|
|
5380
|
-
return;
|
|
5381
|
-
}
|
|
5382
|
-
else {
|
|
5383
|
-
rawResponse = result.raw;
|
|
5384
|
-
responseDigest = result.responseDigest;
|
|
5385
|
-
}
|
|
5386
|
-
let recorded;
|
|
5387
|
-
try {
|
|
5388
|
-
recorded = await this.reviewStore.recordServerResponseFailure(task.id, recovery.phase, recovery.round, {
|
|
5389
|
-
signalKind: recovery.signalKind,
|
|
5390
|
-
sourceToken: recovery.sourceToken,
|
|
5391
|
-
successorToken,
|
|
5392
|
-
failureSignature: recovery.failureSignature,
|
|
5393
|
-
reason: recovery.reason,
|
|
5394
|
-
missingFindingIds: [...(recovery.missingFindingIds ?? [])],
|
|
5395
|
-
unknownFindingIds: [...(recovery.unknownFindingIds ?? [])],
|
|
5396
|
-
schemaViolationCodes: [...(recovery.schemaViolationCodes ?? [])],
|
|
5397
|
-
createdAt: recovery.createdAt,
|
|
5398
|
-
...(responseDigest ? { responseDigest } : {}),
|
|
5399
|
-
...(rawResponse !== undefined ? { rawResponse } : {}),
|
|
5400
|
-
});
|
|
5401
|
-
}
|
|
5402
|
-
catch (err) {
|
|
5403
|
-
await hold(`server-${recovery.phase}-feedback-recovery-audit-failed`, `Could not archive the rejected response during recovery: `
|
|
5404
|
-
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
5405
|
-
return;
|
|
5406
|
-
}
|
|
5407
|
-
const mode = recorded.disposition === 'auto-correct' ? 'correct-response' : 'hold';
|
|
5408
|
-
const finalized = await this.setServerSignalRecoveryMode(task.id, successorToken, mode);
|
|
5409
|
-
if (!finalized)
|
|
5410
|
-
return;
|
|
5411
|
-
if (mode === 'hold') {
|
|
5412
|
-
await hold(recorded.disposition === 'hold-repeated-signature'
|
|
5413
|
-
? 'server-feedback-response-repeated'
|
|
5414
|
-
: 'server-feedback-correction-limit', `Automatic correction stopped during recovery (${recorded.disposition}).`);
|
|
5415
|
-
return;
|
|
5416
|
-
}
|
|
5417
|
-
}
|
|
5418
|
-
if (agentId && (await this.agentStore.get(agentId))?.status === 'awaiting_human')
|
|
5419
|
-
return;
|
|
5420
|
-
const liveResponse = await readResponse();
|
|
5421
|
-
if (!liveResponse)
|
|
5422
|
-
return;
|
|
5423
|
-
if (liveResponse.kind !== 'absent') {
|
|
5424
|
-
if (!recovery.responseDigest || liveResponse.responseDigest !== recovery.responseDigest) {
|
|
5425
|
-
await holdChangedResponse();
|
|
5426
|
-
return;
|
|
5427
|
-
}
|
|
5428
|
-
const fresh = await this.taskStore.get(task.id);
|
|
5429
|
-
const current = fresh?.serverSignalRecovery;
|
|
5430
|
-
const currentRound = recovery.phase === 'spec' ? (fresh?.specReviewRound ?? 0) : fresh?.reviewRound;
|
|
5431
|
-
if (fresh?.status !== task.status
|
|
5432
|
-
|| fresh.phase !== task.phase
|
|
5433
|
-
|| fresh.signalToken !== successorToken
|
|
5434
|
-
|| currentRound !== recovery.round
|
|
5435
|
-
|| current?.mode !== 'correct-response'
|
|
5436
|
-
|| current.sourceToken !== recovery.sourceToken
|
|
5437
|
-
|| current.failureSignature !== recovery.failureSignature) {
|
|
5438
|
-
return;
|
|
5439
|
-
}
|
|
5440
|
-
try {
|
|
5441
|
-
const deleted = await this.deleteReviewExchangeIfCurrent(task.id, dev, 'response', taskGenerationGuard(fresh), current);
|
|
5442
|
-
if (!deleted)
|
|
5443
|
-
return;
|
|
5444
|
-
}
|
|
5445
|
-
catch (err) {
|
|
5446
|
-
await hold(`server-${recovery.phase}-feedback-recovery-cleanup-failed`, `The rejected response was archived but could not be removed during recovery: `
|
|
5447
|
-
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
5448
|
-
return;
|
|
5449
|
-
}
|
|
5450
|
-
}
|
|
5451
|
-
await this.dispatchServerFeedbackCorrection(task.id, JSON.stringify(stored.findings));
|
|
5452
|
-
}
|
|
5453
5162
|
async setupRecoveredWatcherForMapping(task, signalToken, mapped, hasSiblings) {
|
|
5454
5163
|
{
|
|
5455
5164
|
const { expectedKinds, agentId } = mapped;
|
|
5456
|
-
const specStage = isSpecStagePhase(task.phase);
|
|
5457
5165
|
const interventionKindLabel = expectedKinds.length === 0 ? undefined
|
|
5458
|
-
:
|
|
5459
|
-
:
|
|
5460
|
-
:
|
|
5461
|
-
: !specStage && task.status === 'fixing' ? 'pr-fixed'
|
|
5462
|
-
: undefined;
|
|
5463
|
-
const isServerProtocol = task.reviewMode === 'server' || specStage;
|
|
5464
|
-
const scanSnapshotOnRecover = isServerProtocol
|
|
5465
|
-
|| (task.phase === undefined && task.status === 'in_progress')
|
|
5466
|
-
|| (!specStage && (task.status === 'review' || task.status === 'fixing'));
|
|
5467
|
-
// 侧道读 dev workdir,对 head 评审会注入未验证内容——mode 不明时关侧道、发干预交人工。
|
|
5468
|
-
const allowRecoveredReadFile = task.status === 'review'
|
|
5469
|
-
&& (specStage
|
|
5470
|
-
|| (task.reviewMode === 'server' && task.batchTotal !== undefined)
|
|
5471
|
-
|| (task.reviewMode === 'server' && task.reviewCheckoutMode === 'base'));
|
|
5472
|
-
const ambiguousCheckout = task.status === 'review'
|
|
5473
|
-
&& task.reviewMode === 'server'
|
|
5474
|
-
&& !specStage
|
|
5475
|
-
&& task.reviewCheckoutMode === undefined
|
|
5476
|
-
&& task.batchTotal === undefined;
|
|
5166
|
+
: task.status === 'review' ? 'pr-verdict-comment'
|
|
5167
|
+
: task.status === 'fixing' ? 'pr-fixed'
|
|
5168
|
+
: undefined;
|
|
5477
5169
|
try {
|
|
5478
|
-
await this.startPhaseSignalWatch({
|
|
5170
|
+
const armed = await this.startPhaseSignalWatch({
|
|
5479
5171
|
taskId: task.id,
|
|
5480
5172
|
projectId: task.projectId,
|
|
5481
5173
|
agentId,
|
|
5482
5174
|
expectedKinds,
|
|
5483
5175
|
token: signalToken,
|
|
5484
|
-
skipSnapshot:
|
|
5176
|
+
skipSnapshot: false,
|
|
5485
5177
|
recovered: true,
|
|
5486
5178
|
...(hasSiblings ? { replaceScope: 'agent' } : {}),
|
|
5487
|
-
...(allowRecoveredReadFile
|
|
5488
|
-
? { onReadFile: (req) => { void this.handleReadFileRequest(task.id, agentId, req); } }
|
|
5489
|
-
: {}),
|
|
5490
5179
|
});
|
|
5491
|
-
if (interventionKindLabel
|
|
5492
|
-
|
|
5493
|
-
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
|
|
5497
|
-
|
|
5498
|
-
|
|
5499
|
-
|
|
5500
|
-
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
|
|
5504
|
-
|
|
5505
|
-
|
|
5506
|
-
|
|
5507
|
-
|
|
5508
|
-
|
|
5509
|
-
|
|
5510
|
-
|
|
5511
|
-
note: 'Task is waiting for a spec signal after server recovery; if no signal arrives, the prompt may not have been fully delivered before the previous crash. Inspect the agent pane and consider manual retry or transition.',
|
|
5512
|
-
});
|
|
5513
|
-
}
|
|
5180
|
+
if (interventionKindLabel && armed && this.phaseSignalWatcher?.isSettling(task.id)) {
|
|
5181
|
+
await this.phaseSignalWatcher.awaitSettled(task.id);
|
|
5182
|
+
}
|
|
5183
|
+
const current = interventionKindLabel && armed
|
|
5184
|
+
? await this.taskStore.get(task.id)
|
|
5185
|
+
: undefined;
|
|
5186
|
+
const generationStillWaiting = current != null
|
|
5187
|
+
&& current.status === task.status
|
|
5188
|
+
&& current.phase === task.phase
|
|
5189
|
+
&& current.signalToken === signalToken
|
|
5190
|
+
&& current.agentId === task.agentId
|
|
5191
|
+
&& current.reviewRound === task.reviewRound
|
|
5192
|
+
&& current.specReviewRound === task.specReviewRound
|
|
5193
|
+
&& this.phaseSignalWatcher?.has(task.id, agentId) === true;
|
|
5194
|
+
if (interventionKindLabel && generationStillWaiting) {
|
|
5195
|
+
await this.emitIntervention(task.projectId, agentId, task.id, {
|
|
5196
|
+
phase: 'phase-signal-setup-during-recovery',
|
|
5197
|
+
kind: interventionKindLabel,
|
|
5198
|
+
note: 'Task is waiting for a recovered phase signal. Inspect the agent pane and retry the phase manually if the original signal is no longer available.',
|
|
5199
|
+
});
|
|
5514
5200
|
}
|
|
5515
5201
|
}
|
|
5516
5202
|
catch (err) {
|
|
@@ -5546,9 +5232,7 @@ export class AgentManager {
|
|
|
5546
5232
|
}
|
|
5547
5233
|
// 创建期快照(spec §4):git 任务在建任务那一刻定身份。
|
|
5548
5234
|
platformBindingFields(projectId) {
|
|
5549
|
-
return this.
|
|
5550
|
-
? { platformBinding: this.platformBindingFor(projectId) }
|
|
5551
|
-
: {};
|
|
5235
|
+
return { platformBinding: this.platformBindingFor(projectId) };
|
|
5552
5236
|
}
|
|
5553
5237
|
platformBindingFor(projectId) {
|
|
5554
5238
|
const project = this.getProjectConfig(projectId);
|
|
@@ -5558,15 +5242,13 @@ export class AgentManager {
|
|
|
5558
5242
|
if (tool === undefined)
|
|
5559
5243
|
throw new Error(`project ${projectId} has no resolved platform tool`);
|
|
5560
5244
|
return {
|
|
5561
|
-
mode:
|
|
5245
|
+
mode: 'git',
|
|
5562
5246
|
repoKey: repoIdentityKey(project.repo),
|
|
5563
5247
|
tool,
|
|
5564
5248
|
};
|
|
5565
5249
|
}
|
|
5566
5250
|
// agent 面一律 PATH 解析 tool;server 面 binary/env 不进入该轨。
|
|
5567
5251
|
agentGitPreflightContext(projectId) {
|
|
5568
|
-
if (this.effectiveReviewMode(projectId) !== 'git')
|
|
5569
|
-
return undefined;
|
|
5570
5252
|
const project = this.getProjectConfig(projectId);
|
|
5571
5253
|
if (!project || !this.pluginRegistry)
|
|
5572
5254
|
return undefined;
|
|
@@ -5589,8 +5271,6 @@ export class AgentManager {
|
|
|
5589
5271
|
// cli 字段族每次派发实时渲染,不做创建时快照。渲染前按持久化 binding 校验身份——
|
|
5590
5272
|
// 离线漂移的配置会把旧任务的 PR 号/分支/令牌带到新仓库,与平台操作同一守卫。
|
|
5591
5273
|
platformCliContextOf(task) {
|
|
5592
|
-
if (task.reviewMode !== 'git')
|
|
5593
|
-
return undefined;
|
|
5594
5274
|
this.assertPlatformBinding(task);
|
|
5595
5275
|
const project = this.getProjectConfig(task.projectId);
|
|
5596
5276
|
if (!project)
|
|
@@ -5609,7 +5289,7 @@ export class AgentManager {
|
|
|
5609
5289
|
}
|
|
5610
5290
|
// base 快照一经持久化不可变;无快照保持缺省,由 agent 现场显式查询。
|
|
5611
5291
|
async ensureGitBaseSnapshot(task, phase) {
|
|
5612
|
-
if (
|
|
5292
|
+
if (phase !== 'develop' && phase !== 'code')
|
|
5613
5293
|
return task;
|
|
5614
5294
|
if (task.baseBranch !== undefined)
|
|
5615
5295
|
return task;
|
|
@@ -5699,6 +5379,7 @@ export class AgentManager {
|
|
|
5699
5379
|
}
|
|
5700
5380
|
return {
|
|
5701
5381
|
ok: true,
|
|
5382
|
+
prUrl: String(row.prUrl),
|
|
5702
5383
|
headSha: String(row.headSha),
|
|
5703
5384
|
branch: String(row.branch),
|
|
5704
5385
|
targetBranch: String(row.targetBranch),
|
|
@@ -5772,7 +5453,7 @@ export class AgentManager {
|
|
|
5772
5453
|
async processGitRemoteCleanup(taskId, expectedGeneration) {
|
|
5773
5454
|
const snapshot = await this.taskStore.get(taskId);
|
|
5774
5455
|
const snapshotIntent = snapshot?.remoteCleanup;
|
|
5775
|
-
if (!snapshot ||
|
|
5456
|
+
if (!snapshot || !snapshotIntent || snapshotIntent.stage === 'manual')
|
|
5776
5457
|
return;
|
|
5777
5458
|
if (expectedGeneration !== undefined && snapshotIntent.generation !== expectedGeneration)
|
|
5778
5459
|
return;
|
|
@@ -6037,7 +5718,7 @@ export class AgentManager {
|
|
|
6037
5718
|
if (String(row.headSha) !== opts.expectedHeadSha) {
|
|
6038
5719
|
throw new Error(`merge blocked: head moved (expected ${opts.expectedHeadSha}, saw ${String(row.headSha)})`);
|
|
6039
5720
|
}
|
|
6040
|
-
if (
|
|
5721
|
+
if (opts.humanOverride !== true) {
|
|
6041
5722
|
const provenance = task.passProvenance;
|
|
6042
5723
|
if (!provenance) {
|
|
6043
5724
|
throw new PlatformMergeRecheckError('provenance-invalid', 'no accepted pass provenance on task');
|
|
@@ -6070,9 +5751,7 @@ export class AgentManager {
|
|
|
6070
5751
|
}
|
|
6071
5752
|
const fresh = await this.taskStore.get(taskId);
|
|
6072
5753
|
if (!fresh
|
|
6073
|
-
||
|
|
6074
|
-
? fresh.status !== 'merge-ready'
|
|
6075
|
-
: (fresh.status !== 'merge-ready' && fresh.status !== 'ready'))
|
|
5754
|
+
|| fresh.status !== 'merge-ready'
|
|
6076
5755
|
|| fresh.latestHeadSha !== opts.expectedHeadSha
|
|
6077
5756
|
|| fresh.signalToken !== task.signalToken
|
|
6078
5757
|
|| fresh.pendingRedispatch === true) {
|
|
@@ -6116,7 +5795,7 @@ export class AgentManager {
|
|
|
6116
5795
|
return [];
|
|
6117
5796
|
const key = repoIdentityKey(project.repo);
|
|
6118
5797
|
const entryBinding = {
|
|
6119
|
-
mode:
|
|
5798
|
+
mode: 'git',
|
|
6120
5799
|
repoKey: key,
|
|
6121
5800
|
tool: resolveProjectTool(project) ?? '',
|
|
6122
5801
|
};
|
|
@@ -6126,31 +5805,16 @@ export class AgentManager {
|
|
|
6126
5805
|
&& this.taskBelongsToPlatformEntry(task, entryBinding));
|
|
6127
5806
|
}
|
|
6128
5807
|
async findTaskByBranch(branch, projectId) {
|
|
6129
|
-
const
|
|
6130
|
-
if (!
|
|
5808
|
+
const repo = this.getProjectConfig(projectId)?.repo;
|
|
5809
|
+
if (!repo)
|
|
6131
5810
|
return undefined;
|
|
5811
|
+
const repoKey = repoIdentityKey(repo);
|
|
6132
5812
|
const all = await this.taskStore.list();
|
|
6133
|
-
return all.find(
|
|
6134
|
-
|
|
6135
|
-
|
|
6136
|
-
|
|
6137
|
-
|
|
6138
|
-
if (!task || task.reviewMode !== 'server' || task.afterDone !== 'pr'
|
|
6139
|
-
|| task.platformBinding === undefined || TERMINAL_STATUSES.includes(task.status))
|
|
6140
|
-
return false;
|
|
6141
|
-
if (task.prNumber !== undefined && task.prNumber !== pr.prNumber)
|
|
6142
|
-
return false;
|
|
6143
|
-
await this.taskStore.set({
|
|
6144
|
-
...task,
|
|
6145
|
-
prNumber: pr.prNumber,
|
|
6146
|
-
...(pr.prUrl !== undefined ? { prUrl: pr.prUrl } : {}),
|
|
6147
|
-
...(pr.headSha !== undefined ? { latestHeadSha: pr.headSha } : {}),
|
|
6148
|
-
...(task.baseBranch === undefined && pr.targetBranch !== undefined
|
|
6149
|
-
? { baseBranch: pr.targetBranch }
|
|
6150
|
-
: {}),
|
|
6151
|
-
updatedAt: new Date().toISOString(),
|
|
6152
|
-
});
|
|
6153
|
-
return true;
|
|
5813
|
+
return all.find((task) => {
|
|
5814
|
+
const candidateRepo = this.getProjectConfig(task.projectId)?.repo;
|
|
5815
|
+
return task.branch === branch
|
|
5816
|
+
&& candidateRepo !== undefined
|
|
5817
|
+
&& repoIdentityKey(candidateRepo) === repoKey;
|
|
6154
5818
|
});
|
|
6155
5819
|
}
|
|
6156
5820
|
async updateTaskIfStatus(taskId, expectedStatus, updates, alsoExpect = {}) {
|
|
@@ -6173,184 +5837,6 @@ export class AgentManager {
|
|
|
6173
5837
|
return true;
|
|
6174
5838
|
});
|
|
6175
5839
|
}
|
|
6176
|
-
async claimServerSignalRecovery(taskAtEntry, agentId, signalKind, input) {
|
|
6177
|
-
const sourceToken = taskAtEntry.signalToken;
|
|
6178
|
-
if (!sourceToken)
|
|
6179
|
-
return null;
|
|
6180
|
-
const currentRound = input.phase === 'spec'
|
|
6181
|
-
? (taskAtEntry.specReviewRound ?? 0)
|
|
6182
|
-
: taskAtEntry.reviewRound;
|
|
6183
|
-
const phaseMatches = input.phase === 'spec'
|
|
6184
|
-
? taskAtEntry.phase !== 'code'
|
|
6185
|
-
: taskAtEntry.phase === 'code' || taskAtEntry.phase === undefined;
|
|
6186
|
-
if (!phaseMatches || input.round !== currentRound)
|
|
6187
|
-
return null;
|
|
6188
|
-
let successorToken = createSignalToken();
|
|
6189
|
-
while (successorToken === sourceToken)
|
|
6190
|
-
successorToken = createSignalToken();
|
|
6191
|
-
const recovery = {
|
|
6192
|
-
...input,
|
|
6193
|
-
signalKind,
|
|
6194
|
-
sourceToken,
|
|
6195
|
-
missingFindingIds: [...(input.missingFindingIds ?? [])].sort(),
|
|
6196
|
-
unknownFindingIds: [...(input.unknownFindingIds ?? [])].sort(),
|
|
6197
|
-
schemaViolationCodes: [...(input.schemaViolationCodes ?? [])].sort(),
|
|
6198
|
-
createdAt: new Date().toISOString(),
|
|
6199
|
-
};
|
|
6200
|
-
const expectedRound = input.phase === 'spec'
|
|
6201
|
-
? { specReviewRound: taskAtEntry.specReviewRound }
|
|
6202
|
-
: { reviewRound: taskAtEntry.reviewRound };
|
|
6203
|
-
const claimed = await this.updateTaskIfStatus(taskAtEntry.id, taskAtEntry.status, {
|
|
6204
|
-
signalToken: successorToken,
|
|
6205
|
-
serverSignalRecovery: recovery,
|
|
6206
|
-
...(taskAtEntry.status === 'fixing' ? { fixDispatchedAt: new Date().toISOString() } : {}),
|
|
6207
|
-
}, {
|
|
6208
|
-
signalToken: sourceToken,
|
|
6209
|
-
phase: taskAtEntry.phase,
|
|
6210
|
-
...expectedRound,
|
|
6211
|
-
});
|
|
6212
|
-
if (!claimed)
|
|
6213
|
-
return null;
|
|
6214
|
-
if (agentId)
|
|
6215
|
-
this.phaseSignalWatcher?.stopAgentIfToken(taskAtEntry.id, agentId, sourceToken);
|
|
6216
|
-
else
|
|
6217
|
-
this.phaseSignalWatcher?.stopIfToken(taskAtEntry.id, sourceToken);
|
|
6218
|
-
const fresh = await this.taskStore.get(taskAtEntry.id);
|
|
6219
|
-
if (fresh?.signalToken !== successorToken
|
|
6220
|
-
|| fresh.serverSignalRecovery?.sourceToken !== sourceToken
|
|
6221
|
-
|| fresh.serverSignalRecovery.signalKind !== signalKind) {
|
|
6222
|
-
return null;
|
|
6223
|
-
}
|
|
6224
|
-
if (input.mode === 'hold' && agentId) {
|
|
6225
|
-
const holdPhase = input.failurePhase ?? `server-signal-${input.reason}`;
|
|
6226
|
-
const held = await this.markAwaitingHuman(agentId, holdPhase, `The ${signalKind} marker was consumed but could not be completed (${input.reason}). `
|
|
6227
|
-
+ 'The old token is retired. Restart or cancel the task after resolving the reported cause.', { expectedTaskId: taskAtEntry.id });
|
|
6228
|
-
if (!held) {
|
|
6229
|
-
console.error(`[AgentManager] failed to hold ${agentId} for consumed ${signalKind} on ${taskAtEntry.id}; `
|
|
6230
|
-
+ `recovery=${successorToken}`);
|
|
6231
|
-
}
|
|
6232
|
-
}
|
|
6233
|
-
return fresh;
|
|
6234
|
-
}
|
|
6235
|
-
async holdConsumedServerSignal(taskAtEntry, agentId, signalKind, input) {
|
|
6236
|
-
return this.claimServerSignalRecovery(taskAtEntry, agentId, signalKind, {
|
|
6237
|
-
mode: 'hold',
|
|
6238
|
-
...input,
|
|
6239
|
-
});
|
|
6240
|
-
}
|
|
6241
|
-
async setServerSignalRecoveryMode(taskId, successorToken, mode) {
|
|
6242
|
-
const task = await this.taskStore.get(taskId);
|
|
6243
|
-
const recovery = task?.serverSignalRecovery;
|
|
6244
|
-
if (!task || !recovery || task.signalToken !== successorToken)
|
|
6245
|
-
return null;
|
|
6246
|
-
const currentRound = recovery.phase === 'spec' ? (task.specReviewRound ?? 0) : task.reviewRound;
|
|
6247
|
-
const phaseMatches = recovery.phase === 'spec'
|
|
6248
|
-
? task.phase !== 'code'
|
|
6249
|
-
: task.phase === 'code' || task.phase === undefined;
|
|
6250
|
-
if (!phaseMatches || currentRound !== recovery.round)
|
|
6251
|
-
return null;
|
|
6252
|
-
const expectedRound = recovery.phase === 'spec'
|
|
6253
|
-
? { specReviewRound: task.specReviewRound }
|
|
6254
|
-
: { reviewRound: task.reviewRound };
|
|
6255
|
-
const updated = await this.updateTaskIfStatus(taskId, task.status, { serverSignalRecovery: { ...recovery, mode } }, {
|
|
6256
|
-
signalToken: successorToken,
|
|
6257
|
-
phase: task.phase,
|
|
6258
|
-
...expectedRound,
|
|
6259
|
-
});
|
|
6260
|
-
if (!updated)
|
|
6261
|
-
return null;
|
|
6262
|
-
const fresh = await this.taskStore.get(taskId);
|
|
6263
|
-
if (fresh?.signalToken !== successorToken
|
|
6264
|
-
|| fresh.serverSignalRecovery?.mode !== mode
|
|
6265
|
-
|| fresh.serverSignalRecovery.sourceToken !== recovery.sourceToken) {
|
|
6266
|
-
return null;
|
|
6267
|
-
}
|
|
6268
|
-
return fresh;
|
|
6269
|
-
}
|
|
6270
|
-
async holdServerSignalRecovery(taskId, agentId, successorToken, holdPhase, reason) {
|
|
6271
|
-
const task = await this.setServerSignalRecoveryMode(taskId, successorToken, 'hold');
|
|
6272
|
-
if (!task)
|
|
6273
|
-
return null;
|
|
6274
|
-
this.phaseSignalWatcher?.stopAgentIfToken(taskId, agentId, successorToken);
|
|
6275
|
-
if (agentId) {
|
|
6276
|
-
const held = await this.markAwaitingHuman(agentId, holdPhase, reason, { expectedTaskId: taskId });
|
|
6277
|
-
if (!held) {
|
|
6278
|
-
console.error(`[AgentManager] failed to hold ${agentId} for recovery ${taskId}/${successorToken}`);
|
|
6279
|
-
}
|
|
6280
|
-
}
|
|
6281
|
-
return task;
|
|
6282
|
-
}
|
|
6283
|
-
async dispatchServerFeedbackCorrection(taskId, findingsJson) {
|
|
6284
|
-
const task = await this.taskStore.get(taskId);
|
|
6285
|
-
const recovery = task?.serverSignalRecovery;
|
|
6286
|
-
if (!task || !recovery || recovery.mode !== 'correct-response' || !task.signalToken)
|
|
6287
|
-
return false;
|
|
6288
|
-
const agentId = task.agentId;
|
|
6289
|
-
const successorToken = task.signalToken;
|
|
6290
|
-
if (!agentId || !this.getAgentConfig(agentId)) {
|
|
6291
|
-
await this.holdServerSignalRecovery(taskId, agentId, successorToken, `server-${recovery.phase}-feedback-correction-no-dev`, 'The response was rejected, but the correction prompt cannot be delivered because the dev agent is unavailable.');
|
|
6292
|
-
return false;
|
|
6293
|
-
}
|
|
6294
|
-
const stillCurrent = async () => {
|
|
6295
|
-
const fresh = await this.taskStore.get(taskId);
|
|
6296
|
-
const current = fresh?.serverSignalRecovery;
|
|
6297
|
-
const round = recovery.phase === 'spec' ? (fresh?.specReviewRound ?? 0) : fresh?.reviewRound;
|
|
6298
|
-
return fresh?.status === task.status
|
|
6299
|
-
&& fresh.phase === task.phase
|
|
6300
|
-
&& fresh.signalToken === successorToken
|
|
6301
|
-
&& round === recovery.round
|
|
6302
|
-
&& current?.mode === 'correct-response'
|
|
6303
|
-
&& current.sourceToken === recovery.sourceToken
|
|
6304
|
-
&& current.failureSignature === recovery.failureSignature;
|
|
6305
|
-
};
|
|
6306
|
-
try {
|
|
6307
|
-
const delivered = await this.continueSession(taskId, agentId, 'server-feedback', {
|
|
6308
|
-
allowDirtyWorkdir: true,
|
|
6309
|
-
signalToken: successorToken,
|
|
6310
|
-
serverPriorFindings: findingsJson,
|
|
6311
|
-
serverFindingsDigest: recovery.findingsDigest,
|
|
6312
|
-
serverFeedbackCorrection: {
|
|
6313
|
-
reason: recovery.reason,
|
|
6314
|
-
missingFindingIds: recovery.missingFindingIds,
|
|
6315
|
-
unknownFindingIds: recovery.unknownFindingIds,
|
|
6316
|
-
schemaViolationCodes: recovery.schemaViolationCodes,
|
|
6317
|
-
},
|
|
6318
|
-
...(recovery.phase === 'spec' ? { currentSpecRound: recovery.round } : {}),
|
|
6319
|
-
armBeforeInject: () => this.setupPhaseSignalWatcher(taskId, agentId, recovery.signalKind, successorToken, {
|
|
6320
|
-
skipSnapshot: true,
|
|
6321
|
-
onlyReplaceOwnToken: true,
|
|
6322
|
-
replaceFromToken: recovery.sourceToken,
|
|
6323
|
-
needInputMode: 'fresh',
|
|
6324
|
-
}),
|
|
6325
|
-
guardBeforeInject: stillCurrent,
|
|
6326
|
-
});
|
|
6327
|
-
if (!delivered)
|
|
6328
|
-
throw new Error('correction prompt was not delivered');
|
|
6329
|
-
const cleared = await this.updateTaskIfStatus(taskId, task.status, { serverSignalRecovery: undefined }, {
|
|
6330
|
-
signalToken: successorToken,
|
|
6331
|
-
phase: task.phase,
|
|
6332
|
-
...(recovery.phase === 'spec'
|
|
6333
|
-
? { specReviewRound: task.specReviewRound }
|
|
6334
|
-
: { reviewRound: task.reviewRound }),
|
|
6335
|
-
});
|
|
6336
|
-
if (!cleared && await stillCurrent()) {
|
|
6337
|
-
throw new Error('correction prompt delivered but recovery intent could not be cleared');
|
|
6338
|
-
}
|
|
6339
|
-
return true;
|
|
6340
|
-
}
|
|
6341
|
-
catch (err) {
|
|
6342
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
6343
|
-
const held = await this.holdServerSignalRecovery(taskId, agentId, successorToken, `server-${recovery.phase}-feedback-correction-failed`, `${message}. The old token remains retired; Restart or Cancel the task.`);
|
|
6344
|
-
if (!held)
|
|
6345
|
-
return false;
|
|
6346
|
-
await this.emitIntervention(task.projectId, agentId, taskId, {
|
|
6347
|
-
phase: `server-${recovery.phase}-feedback-correction-failed`,
|
|
6348
|
-
error: message,
|
|
6349
|
-
successorToken,
|
|
6350
|
-
});
|
|
6351
|
-
return false;
|
|
6352
|
-
}
|
|
6353
|
-
}
|
|
6354
5840
|
async updateTask(taskId, updates) {
|
|
6355
5841
|
await this.withTaskLock(async () => {
|
|
6356
5842
|
const task = await this.taskStore.get(taskId);
|
|
@@ -6360,6 +5846,68 @@ export class AgentManager {
|
|
|
6360
5846
|
await this.taskStore.set(task);
|
|
6361
5847
|
});
|
|
6362
5848
|
}
|
|
5849
|
+
async recordGitPrObservation(taskId, observation, expected) {
|
|
5850
|
+
return this.persistGitPrObservation(taskId, observation, undefined, expected);
|
|
5851
|
+
}
|
|
5852
|
+
async confirmGitDelivery(taskId, confirmation) {
|
|
5853
|
+
return this.persistGitPrObservation(taskId, confirmation, confirmation);
|
|
5854
|
+
}
|
|
5855
|
+
async persistGitPrObservation(taskId, observation, confirmation, expected) {
|
|
5856
|
+
return this.withTaskLock(async () => {
|
|
5857
|
+
const task = await this.taskStore.get(taskId);
|
|
5858
|
+
if (!task || TERMINAL_STATUSES.includes(task.status))
|
|
5859
|
+
return null;
|
|
5860
|
+
if (expected
|
|
5861
|
+
&& (!taskMatchesGeneration(task, expected.task)
|
|
5862
|
+
|| task.latestHeadSha !== expected.latestHeadSha))
|
|
5863
|
+
return null;
|
|
5864
|
+
if (task.prNumber !== undefined && task.prNumber !== observation.prNumber)
|
|
5865
|
+
return null;
|
|
5866
|
+
if (confirmation !== undefined) {
|
|
5867
|
+
if (task.status !== 'in_progress'
|
|
5868
|
+
&& (task.phase !== confirmation.phase
|
|
5869
|
+
|| task.deliveryConfirmation?.phase !== confirmation.phase))
|
|
5870
|
+
return null;
|
|
5871
|
+
if (Object.hasOwn(confirmation, 'expectedSignalToken')
|
|
5872
|
+
&& task.signalToken !== confirmation.expectedSignalToken)
|
|
5873
|
+
return null;
|
|
5874
|
+
if (task.phase !== undefined && task.phase !== confirmation.phase)
|
|
5875
|
+
return null;
|
|
5876
|
+
}
|
|
5877
|
+
if (confirmation?.branch !== undefined && confirmation.branch !== task.branch) {
|
|
5878
|
+
const existing = await this.findTaskByBranch(confirmation.branch, task.projectId);
|
|
5879
|
+
if (existing && existing.id !== taskId)
|
|
5880
|
+
return null;
|
|
5881
|
+
}
|
|
5882
|
+
const now = new Date().toISOString();
|
|
5883
|
+
const next = {
|
|
5884
|
+
...task,
|
|
5885
|
+
prNumber: observation.prNumber,
|
|
5886
|
+
...(observation.prUrl !== undefined ? { prUrl: observation.prUrl } : {}),
|
|
5887
|
+
latestHeadSha: observation.headSha,
|
|
5888
|
+
...(observation.targetBranch !== undefined && task.baseBranch === undefined
|
|
5889
|
+
? { baseBranch: observation.targetBranch }
|
|
5890
|
+
: {}),
|
|
5891
|
+
...(confirmation !== undefined
|
|
5892
|
+
? {
|
|
5893
|
+
phase: confirmation.phase,
|
|
5894
|
+
deliveryConfirmation: task.status === 'in_progress'
|
|
5895
|
+
? { phase: confirmation.phase, source: confirmation.source, at: now }
|
|
5896
|
+
: task.deliveryConfirmation,
|
|
5897
|
+
...(confirmation.branch !== undefined ? { branch: confirmation.branch } : {}),
|
|
5898
|
+
replyActorId: confirmation.actorId,
|
|
5899
|
+
replyActorStatus: 'verified',
|
|
5900
|
+
pendingPrSignalToken: undefined,
|
|
5901
|
+
}
|
|
5902
|
+
: task.replyActorStatus !== 'verified' && observation.prAuthorId !== undefined
|
|
5903
|
+
? { replyActorId: observation.prAuthorId, replyActorStatus: 'provisional' }
|
|
5904
|
+
: {}),
|
|
5905
|
+
updatedAt: now,
|
|
5906
|
+
};
|
|
5907
|
+
await this.taskStore.set(next);
|
|
5908
|
+
return next;
|
|
5909
|
+
});
|
|
5910
|
+
}
|
|
6363
5911
|
async transitionTaskStatus(taskId, toStatus, guard, patch) {
|
|
6364
5912
|
return this.withTaskLock(async () => {
|
|
6365
5913
|
const transition = async () => {
|
|
@@ -6383,20 +5931,31 @@ export class AgentManager {
|
|
|
6383
5931
|
return null;
|
|
6384
5932
|
if (Object.hasOwn(guard, 'expectPhase') && task.phase !== guard.expectPhase)
|
|
6385
5933
|
return null;
|
|
5934
|
+
const expectedOutbox = guard.expectOutbox;
|
|
5935
|
+
if (expectedOutbox !== undefined
|
|
5936
|
+
&& !task.outbox?.some(entry => entry.key === expectedOutbox.key && entry.type === expectedOutbox.type))
|
|
5937
|
+
return null;
|
|
6386
5938
|
if (guard.expectPostApproveEpisode !== undefined
|
|
6387
5939
|
&& !this.postApproveEpisodeMatches(task, guard.expectPostApproveEpisode))
|
|
6388
5940
|
return null;
|
|
6389
5941
|
if (guard.expectReviewDispatch !== undefined
|
|
6390
5942
|
&& !this.gitReviewDispatchMatches(task, guard.expectReviewDispatch))
|
|
6391
5943
|
return null;
|
|
5944
|
+
if (Object.hasOwn(guard, 'expectLatestHeadSha')
|
|
5945
|
+
&& task.latestHeadSha !== guard.expectLatestHeadSha)
|
|
5946
|
+
return null;
|
|
5947
|
+
if (Object.hasOwn(guard, 'expectReviewHeadAnchorSha')
|
|
5948
|
+
&& task.reviewHeadAnchorSha !== guard.expectReviewHeadAnchorSha)
|
|
5949
|
+
return null;
|
|
5950
|
+
if (Object.hasOwn(guard, 'expectPrNumber')
|
|
5951
|
+
&& task.prNumber !== guard.expectPrNumber)
|
|
5952
|
+
return null;
|
|
6392
5953
|
if (patch?.branch && patch.branch !== task.branch) {
|
|
6393
5954
|
const existing = await this.findTaskByBranch(patch.branch, task.projectId);
|
|
6394
5955
|
if (existing && existing.id !== taskId)
|
|
6395
5956
|
return null;
|
|
6396
5957
|
}
|
|
6397
5958
|
const next = this.stripGitStatusScopedState(task, toStatus);
|
|
6398
|
-
if (previousStatus !== toStatus)
|
|
6399
|
-
delete next.serverSignalRecovery;
|
|
6400
5959
|
Object.assign(next, patch ?? {}, {
|
|
6401
5960
|
status: toStatus,
|
|
6402
5961
|
updatedAt: new Date().toISOString(),
|
|
@@ -6421,9 +5980,7 @@ export class AgentManager {
|
|
|
6421
5980
|
if (this.repoStoreFactory) {
|
|
6422
5981
|
return this.repoStoreFactory(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir);
|
|
6423
5982
|
}
|
|
6424
|
-
const cloneViaGh =
|
|
6425
|
-
? resolveProjectTool(project) === 'gh'
|
|
6426
|
-
: undefined;
|
|
5983
|
+
const cloneViaGh = resolveProjectTool(project) === 'gh';
|
|
6427
5984
|
return new RepoStore(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir, cloneViaGh);
|
|
6428
5985
|
}
|
|
6429
5986
|
async ensureWorkdir(agent, project, runner) {
|
|
@@ -6518,7 +6075,7 @@ export class AgentManager {
|
|
|
6518
6075
|
throw new ApiError(400, `Agent ${preferredAgentId} not in project ${projectId}`);
|
|
6519
6076
|
}
|
|
6520
6077
|
if (!TASK_OWNER_ROLES.has(cfg.role)) {
|
|
6521
|
-
throw new ApiError(400, `Agent ${preferredAgentId} is not dev
|
|
6078
|
+
throw new ApiError(400, `Agent ${preferredAgentId} is not a dev agent`);
|
|
6522
6079
|
}
|
|
6523
6080
|
const state = await this.agentStore.get(preferredAgentId);
|
|
6524
6081
|
if (!canDispatchWithBinding(state))
|
|
@@ -6574,7 +6131,6 @@ export class AgentManager {
|
|
|
6574
6131
|
reviewRound: 0,
|
|
6575
6132
|
branch: taskBranch,
|
|
6576
6133
|
branchCreatedByBaxian,
|
|
6577
|
-
reviewMode: this.effectiveReviewMode(projectId),
|
|
6578
6134
|
...this.platformBindingFields(projectId),
|
|
6579
6135
|
createdAt: now,
|
|
6580
6136
|
updatedAt: now,
|
|
@@ -6592,18 +6148,15 @@ export class AgentManager {
|
|
|
6592
6148
|
// Capture generation before the first config/group read so a DELETE→same-id recreate in the read→commit window fails the gate.
|
|
6593
6149
|
const genAtEntry = this.deletionGenerationOf(input.preferredAgentId);
|
|
6594
6150
|
const target = await this.pickAgent(projectId, input.preferredAgentId);
|
|
6595
|
-
const targetConfig = this.getAgentConfig(input.preferredAgentId);
|
|
6596
6151
|
const group = this.findAgentGroup(input.preferredAgentId);
|
|
6597
6152
|
const dev = group?.find(agent => agent.role === 'dev');
|
|
6598
6153
|
if (!dev)
|
|
6599
6154
|
throw new ApiError(409, `Agent ${input.preferredAgentId} has no dev agent in its group`);
|
|
6600
6155
|
const qa = group?.find(agent => agent.role === 'qa');
|
|
6601
|
-
|
|
6602
|
-
|
|
6603
|
-
? { researchAgentId: research.id, phase: 'research' }
|
|
6604
|
-
: {};
|
|
6156
|
+
if (!qa)
|
|
6157
|
+
throw new ApiError(409, `Agent ${input.preferredAgentId} has no qa agent in its group`);
|
|
6605
6158
|
// Pin EVERY participant's generation: a member (e.g. QA) deleted→reintroduced after the snapshot leaves isDeletionInFlight false and the preferred generation unchanged, yet the task would still carry its stale id.
|
|
6606
|
-
const participantGens = new Map([dev.id,
|
|
6159
|
+
const participantGens = new Map([dev.id, qa.id]
|
|
6607
6160
|
.map(id => [id, this.deletionGenerationOf(id)]));
|
|
6608
6161
|
const participantsFresh = () => this.deletionGenerationOf(input.preferredAgentId) === genAtEntry
|
|
6609
6162
|
&& !this.isDeletionInFlight(input.preferredAgentId)
|
|
@@ -6612,9 +6165,8 @@ export class AgentManager {
|
|
|
6612
6165
|
...taskBase,
|
|
6613
6166
|
agentId: '',
|
|
6614
6167
|
devAgentId: dev.id,
|
|
6615
|
-
|
|
6168
|
+
qaAgentId: qa.id,
|
|
6616
6169
|
status: 'pending',
|
|
6617
|
-
...(qa ? { qaAgentId: qa.id } : {}),
|
|
6618
6170
|
};
|
|
6619
6171
|
// A DELETE→recreate of the preferred OR any participant after the group snapshot must not persist a
|
|
6620
6172
|
// pending task carrying its stale devAgentId/qaAgentId; reject and let the client re-select.
|
|
@@ -6698,8 +6250,7 @@ export class AgentManager {
|
|
|
6698
6250
|
...taskBase,
|
|
6699
6251
|
agentId: target.id,
|
|
6700
6252
|
devAgentId: dev.id,
|
|
6701
|
-
|
|
6702
|
-
...researchFields,
|
|
6253
|
+
qaAgentId: qa.id,
|
|
6703
6254
|
status: 'in_progress',
|
|
6704
6255
|
};
|
|
6705
6256
|
try {
|
|
@@ -6736,7 +6287,7 @@ export class AgentManager {
|
|
|
6736
6287
|
const task = await this.createTask(projectId, input);
|
|
6737
6288
|
if (task.status === 'in_progress' && task.agentId) {
|
|
6738
6289
|
const signalToken = createSignalToken();
|
|
6739
|
-
await this.updateTask(task.id, this.dispatchTokenFields(
|
|
6290
|
+
await this.updateTask(task.id, this.dispatchTokenFields(signalToken));
|
|
6740
6291
|
const dispatchLockToken = (await this.agentStore.get(task.agentId))?.lockToken;
|
|
6741
6292
|
const start = this.startCreatedTaskSession(task.id, task.agentId, signalToken, dispatchLockToken);
|
|
6742
6293
|
if (opts.background) {
|
|
@@ -6756,7 +6307,7 @@ export class AgentManager {
|
|
|
6756
6307
|
const initialTask = await this.taskStore.get(taskId);
|
|
6757
6308
|
if (!initialTask)
|
|
6758
6309
|
return null;
|
|
6759
|
-
const initialDispatch = this.resolveInitialDispatch(
|
|
6310
|
+
const initialDispatch = this.resolveInitialDispatch();
|
|
6760
6311
|
const dispatchPhase = initialDispatch.phase;
|
|
6761
6312
|
let started = false;
|
|
6762
6313
|
let dispatchErr = null;
|
|
@@ -6791,7 +6342,7 @@ export class AgentManager {
|
|
|
6791
6342
|
await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true });
|
|
6792
6343
|
return null;
|
|
6793
6344
|
}
|
|
6794
|
-
const initialKinds = this.resolveInitialDispatch(
|
|
6345
|
+
const initialKinds = this.resolveInitialDispatch().kinds;
|
|
6795
6346
|
try {
|
|
6796
6347
|
await this.armPostDispatchSignalOrHold(taskId, agentId, initialKinds, signalToken);
|
|
6797
6348
|
}
|
|
@@ -7006,7 +6557,7 @@ export class AgentManager {
|
|
|
7006
6557
|
`${phase} queued for redispatch when idle (${(err.message.split('\n')[0])})`);
|
|
7007
6558
|
return new EnsureSessionError({ createdSession, agentId, handled: true, busyPending: true }, `QA REPL busy; ${phase} dispatch for task ${taskId} queued for redispatch when idle`);
|
|
7008
6559
|
}
|
|
7009
|
-
async
|
|
6560
|
+
async clearRuntimeForDispatchBoundary(tmux, pane, agentId, runtime, revalidate) {
|
|
7010
6561
|
await this.acquireCompactGuard(agentId);
|
|
7011
6562
|
try {
|
|
7012
6563
|
await this.waitForReplPromptReady(tmux, pane, runtime, this.cleanComposerWaitMs, { stableIdle: true });
|
|
@@ -7025,7 +6576,7 @@ export class AgentManager {
|
|
|
7025
6576
|
});
|
|
7026
6577
|
await this.waitForReplPromptReady(tmux, pane, runtime, this.dispatchAckTimeoutMs, { stableIdle: true });
|
|
7027
6578
|
if (await this.hasRuntimeSlashCommandRejection(tmux, pane, '/clear')) {
|
|
7028
|
-
throw new Error('Runtime rejected /clear at the
|
|
6579
|
+
throw new Error('Runtime rejected /clear at the dispatch boundary; refusing to reuse prior context');
|
|
7029
6580
|
}
|
|
7030
6581
|
await revalidate();
|
|
7031
6582
|
}
|
|
@@ -7110,7 +6661,7 @@ export class AgentManager {
|
|
|
7110
6661
|
return { task: fresh, errorCode: 400, error: `Agent ${agentId} not in project ${fresh.projectId}` };
|
|
7111
6662
|
}
|
|
7112
6663
|
if (!TASK_OWNER_ROLES.has(cfg.role)) {
|
|
7113
|
-
return { task: fresh, errorCode: 400, error: `Agent ${agentId} is not dev
|
|
6664
|
+
return { task: fresh, errorCode: 400, error: `Agent ${agentId} is not a dev agent` };
|
|
7114
6665
|
}
|
|
7115
6666
|
if (fresh.preferredAgentId !== '' && fresh.preferredAgentId !== agentId) {
|
|
7116
6667
|
return {
|
|
@@ -7143,9 +6694,12 @@ export class AgentManager {
|
|
|
7143
6694
|
const qaId = initiallyUnassigned
|
|
7144
6695
|
? group?.find(agent => agent.role === 'qa')?.id
|
|
7145
6696
|
: fresh.qaAgentId;
|
|
7146
|
-
|
|
6697
|
+
if (!qaId) {
|
|
6698
|
+
await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
|
|
6699
|
+
return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no qa agent in its group` };
|
|
6700
|
+
}
|
|
7147
6701
|
// Pin every participant's generation (mirror createTask) so a member deleted→recreated before the active write isn't baked in.
|
|
7148
|
-
const participantGens = new Map([devId,
|
|
6702
|
+
const participantGens = new Map([devId, qaId]
|
|
7149
6703
|
.map(pid => [pid, this.deletionGenerationOf(pid)]));
|
|
7150
6704
|
const participantsFresh = () => [...participantGens].every(([pid, gen]) => this.deletionGenerationOf(pid) === gen && !this.isDeletionInFlight(pid));
|
|
7151
6705
|
const claimedTask = {
|
|
@@ -7153,9 +6707,8 @@ export class AgentManager {
|
|
|
7153
6707
|
preferredAgentId: agentId,
|
|
7154
6708
|
agentId,
|
|
7155
6709
|
devAgentId: devId,
|
|
7156
|
-
researchAgentId: researchId,
|
|
7157
6710
|
qaAgentId: qaId,
|
|
7158
|
-
phase:
|
|
6711
|
+
phase: undefined,
|
|
7159
6712
|
status: 'in_progress',
|
|
7160
6713
|
updatedAt: now,
|
|
7161
6714
|
};
|
|
@@ -7248,10 +6801,10 @@ export class AgentManager {
|
|
|
7248
6801
|
return claim;
|
|
7249
6802
|
const claimed = claim.task;
|
|
7250
6803
|
const signalToken = createSignalToken();
|
|
7251
|
-
await this.updateTask(claimed.id, this.dispatchTokenFields(
|
|
6804
|
+
await this.updateTask(claimed.id, this.dispatchTokenFields(signalToken));
|
|
7252
6805
|
let started = false;
|
|
7253
6806
|
let dispatchErr = null;
|
|
7254
|
-
const initialDispatch = this.resolveInitialDispatch(
|
|
6807
|
+
const initialDispatch = this.resolveInitialDispatch();
|
|
7255
6808
|
try {
|
|
7256
6809
|
started = await this.startSession(claimed.id, claimed.agentId, initialDispatch.phase);
|
|
7257
6810
|
}
|
|
@@ -7430,30 +6983,13 @@ export class AgentManager {
|
|
|
7430
6983
|
throw busyPend;
|
|
7431
6984
|
throw err;
|
|
7432
6985
|
}
|
|
7433
|
-
const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
|
|
7434
|
-
const isServerCodeReviewPhase = phase === 'server-review' || phase === 'server-recheck';
|
|
7435
|
-
let reviewCheckout;
|
|
7436
6986
|
const dispatchWorkdir = workdir;
|
|
7437
6987
|
try {
|
|
7438
|
-
if (
|
|
7439
|
-
reviewCheckout = await branchManager.materializeReviewHead(workdir, {
|
|
7440
|
-
patch: opts.serverContent,
|
|
7441
|
-
baseSha: opts.serverBaseSha,
|
|
7442
|
-
headSha: opts.serverHeadSha,
|
|
7443
|
-
headTree: opts.serverHeadTree,
|
|
7444
|
-
});
|
|
7445
|
-
}
|
|
7446
|
-
else if (isServerQaPhase) {
|
|
7447
|
-
await branchManager.switchToDefaultDetached(workdir);
|
|
7448
|
-
}
|
|
7449
|
-
else if (phase === 'review' || phase === 'recheck') {
|
|
6988
|
+
if (phase === 'review' || phase === 'recheck') {
|
|
7450
6989
|
if (!task.branch)
|
|
7451
6990
|
throw new Error(`Task ${taskId} has no review branch`);
|
|
7452
6991
|
await this.switchToVerifiedReviewHead(branchManager, workdir, task, assertOwner);
|
|
7453
6992
|
}
|
|
7454
|
-
else if (phase === 'research') {
|
|
7455
|
-
await branchManager.switchToDefaultDetached(workdir);
|
|
7456
|
-
}
|
|
7457
6993
|
else {
|
|
7458
6994
|
if (!task.branch)
|
|
7459
6995
|
throw new Error(`Task ${taskId} has no task branch`);
|
|
@@ -7465,7 +7001,7 @@ export class AgentManager {
|
|
|
7465
7001
|
}
|
|
7466
7002
|
await assertOwner();
|
|
7467
7003
|
if (!ensure.freshRuntime) {
|
|
7468
|
-
await this.
|
|
7004
|
+
await this.clearRuntimeForDispatchBoundary(tmux, pane, agentId, agent.runtime, assertOwner);
|
|
7469
7005
|
if (ensure.skillsStale)
|
|
7470
7006
|
await this.tagSessionSkillsVersion(tmux, agentId, ensure.sessionRef);
|
|
7471
7007
|
}
|
|
@@ -7522,32 +7058,9 @@ export class AgentManager {
|
|
|
7522
7058
|
};
|
|
7523
7059
|
});
|
|
7524
7060
|
const promptSignalToken = opts.signalToken ?? task.signalToken;
|
|
7525
|
-
const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
|
|
7526
|
-
const hasQaPartner = task.qaAgentId !== undefined;
|
|
7527
7061
|
let prompt;
|
|
7528
7062
|
try {
|
|
7529
|
-
await this.prepareDispatchArtifacts(agent, dispatchWorkdir, phase, {
|
|
7530
|
-
specDocuments: opts.specDocuments,
|
|
7531
|
-
preserveOutputs: false,
|
|
7532
|
-
assertOwner,
|
|
7533
|
-
});
|
|
7534
7063
|
const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
|
|
7535
|
-
let payloadOpts = {};
|
|
7536
|
-
if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
|
|
7537
|
-
payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, dispatchWorkdir, {
|
|
7538
|
-
phase,
|
|
7539
|
-
taskPhase: task.phase,
|
|
7540
|
-
...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
|
|
7541
|
-
reviewRound: task.reviewRound,
|
|
7542
|
-
...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
|
|
7543
|
-
...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
|
|
7544
|
-
...(reviewCheckout?.mode === 'head' ? { forceContentFile: true } : {}),
|
|
7545
|
-
...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
|
|
7546
|
-
...(opts.serverInterdiff !== undefined ? { serverInterdiff: opts.serverInterdiff } : {}),
|
|
7547
|
-
...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
|
|
7548
|
-
...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
|
|
7549
|
-
});
|
|
7550
|
-
}
|
|
7551
7064
|
const taskForPrompt = await this.ensureGitBaseSnapshot(task, phase);
|
|
7552
7065
|
const platformCli = this.platformCliContextOf(taskForPrompt);
|
|
7553
7066
|
prompt = buildPromptInline({
|
|
@@ -7556,15 +7069,9 @@ export class AgentManager {
|
|
|
7556
7069
|
agent,
|
|
7557
7070
|
workdir: dispatchWorkdir,
|
|
7558
7071
|
skillRegistry: this.skillRegistry,
|
|
7559
|
-
hasQaPartner,
|
|
7560
7072
|
...(platformCli ? { platformCli } : {}),
|
|
7561
7073
|
...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
|
|
7562
|
-
...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
|
|
7563
7074
|
...(imagePaths.length ? { imagePaths } : {}),
|
|
7564
|
-
...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
|
|
7565
|
-
...(opts.serverFindingsDigest ? { serverFindingsDigest: opts.serverFindingsDigest } : {}),
|
|
7566
|
-
...(opts.serverFeedbackCorrection ? { serverFeedbackCorrection: opts.serverFeedbackCorrection } : {}),
|
|
7567
|
-
...payloadOpts,
|
|
7568
7075
|
});
|
|
7569
7076
|
}
|
|
7570
7077
|
catch (err) {
|
|
@@ -7610,7 +7117,7 @@ export class AgentManager {
|
|
|
7610
7117
|
await cleanupCheckoutOrHold();
|
|
7611
7118
|
return false;
|
|
7612
7119
|
}
|
|
7613
|
-
if (opts.armBeforeInject && !(await opts.armBeforeInject(
|
|
7120
|
+
if (opts.armBeforeInject && !(await opts.armBeforeInject())) {
|
|
7614
7121
|
await cleanupCheckoutOrHold();
|
|
7615
7122
|
return false;
|
|
7616
7123
|
}
|
|
@@ -8095,37 +7602,9 @@ export class AgentManager {
|
|
|
8095
7602
|
await this.clearBranchLocalCleaned(taskId);
|
|
8096
7603
|
}
|
|
8097
7604
|
}
|
|
8098
|
-
else if (agent.role === 'research') {
|
|
8099
|
-
await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
|
|
8100
|
-
await new BranchManager(runner).switchToDefaultDetached(verifiedWorkdir);
|
|
8101
|
-
await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
|
|
8102
|
-
}
|
|
8103
|
-
const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
|
|
8104
7605
|
let prompt;
|
|
8105
7606
|
try {
|
|
8106
|
-
await this.prepareDispatchArtifacts(agent, verifiedWorkdir, phase, {
|
|
8107
|
-
specDocuments: opts.specDocuments,
|
|
8108
|
-
preserveOutputs: opts.preserveDispatchOutputs ?? false,
|
|
8109
|
-
assertOwner: async () => {
|
|
8110
|
-
await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
|
|
8111
|
-
},
|
|
8112
|
-
});
|
|
8113
7607
|
const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
|
|
8114
|
-
let payloadOpts = {};
|
|
8115
|
-
if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
|
|
8116
|
-
payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, verifiedWorkdir, {
|
|
8117
|
-
phase,
|
|
8118
|
-
taskPhase: task.phase,
|
|
8119
|
-
...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
|
|
8120
|
-
reviewRound: task.reviewRound,
|
|
8121
|
-
...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
|
|
8122
|
-
...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
|
|
8123
|
-
...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
|
|
8124
|
-
...(opts.serverInterdiff !== undefined ? { serverInterdiff: opts.serverInterdiff } : {}),
|
|
8125
|
-
...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
|
|
8126
|
-
...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
|
|
8127
|
-
});
|
|
8128
|
-
}
|
|
8129
7608
|
const taskForPrompt = await this.ensureGitBaseSnapshot(task, phase);
|
|
8130
7609
|
const platformCli = this.platformCliContextOf(taskForPrompt);
|
|
8131
7610
|
prompt = buildPromptInline({
|
|
@@ -8134,17 +7613,9 @@ export class AgentManager {
|
|
|
8134
7613
|
agent,
|
|
8135
7614
|
workdir: verifiedWorkdir,
|
|
8136
7615
|
skillRegistry: this.skillRegistry,
|
|
8137
|
-
hasQaPartner: task.qaAgentId !== undefined,
|
|
8138
7616
|
...(platformCli ? { platformCli } : {}),
|
|
8139
7617
|
...(signalToken ? { signalToken } : {}),
|
|
8140
|
-
...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
|
|
8141
7618
|
...(imagePaths.length ? { imagePaths } : {}),
|
|
8142
|
-
...(opts.serverReviewCheckout ? { serverReviewCheckout: opts.serverReviewCheckout } : {}),
|
|
8143
|
-
...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
|
|
8144
|
-
...(opts.serverAfterDone ? { serverAfterDone: opts.serverAfterDone } : {}),
|
|
8145
|
-
...(opts.serverFindingsDigest ? { serverFindingsDigest: opts.serverFindingsDigest } : {}),
|
|
8146
|
-
...(opts.serverFeedbackCorrection ? { serverFeedbackCorrection: opts.serverFeedbackCorrection } : {}),
|
|
8147
|
-
...payloadOpts,
|
|
8148
7619
|
});
|
|
8149
7620
|
}
|
|
8150
7621
|
catch (err) {
|
|
@@ -8212,7 +7683,33 @@ export class AgentManager {
|
|
|
8212
7683
|
return false;
|
|
8213
7684
|
}
|
|
8214
7685
|
const guardBeforeInject = opts.guardBeforeInject ?? (phase === 'post-approve' ? postApprovePassStillLive : undefined);
|
|
8215
|
-
if (
|
|
7686
|
+
if (!ensure.freshRuntime && ensure.skillsStale) {
|
|
7687
|
+
const revalidateSkillRefresh = async () => {
|
|
7688
|
+
const [currentTask, currentAgent] = await Promise.all([
|
|
7689
|
+
this.taskStore.get(taskId),
|
|
7690
|
+
this.agentStore.get(agentId),
|
|
7691
|
+
]);
|
|
7692
|
+
const taskStillDispatchable = currentTask !== null
|
|
7693
|
+
&& !TERMINAL_STATUSES.includes(currentTask.status)
|
|
7694
|
+
&& (opts.bypassTaskStatusGate || expectedStatuses.includes(currentTask.status));
|
|
7695
|
+
const bindingStillDispatchable = PHASE_REQUIRES_AGENT_BOUND_TO_TASK[phase]
|
|
7696
|
+
? currentAgent?.taskId === taskId
|
|
7697
|
+
: currentAgent?.taskId === undefined || currentAgent.taskId === taskId;
|
|
7698
|
+
if (!taskStillDispatchable
|
|
7699
|
+
|| !bindingStillDispatchable
|
|
7700
|
+
|| currentAgent?.lockToken !== lockToken
|
|
7701
|
+
|| currentAgent.workdir !== verifiedWorkdir) {
|
|
7702
|
+
throw new Error(`Task or agent ownership changed while refreshing skills for ${phase} dispatch`);
|
|
7703
|
+
}
|
|
7704
|
+
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
7705
|
+
if (guardBeforeInject && !(await guardBeforeInject())) {
|
|
7706
|
+
throw new Error(`Task generation changed while refreshing skills for ${phase} dispatch`);
|
|
7707
|
+
}
|
|
7708
|
+
};
|
|
7709
|
+
await this.clearRuntimeForDispatchBoundary(tmux, pane, agentId, agent.runtime, revalidateSkillRefresh);
|
|
7710
|
+
await this.tagSessionSkillsVersion(tmux, agentId, ensure.sessionRef);
|
|
7711
|
+
}
|
|
7712
|
+
if (opts.armBeforeInject && !(await opts.armBeforeInject())) {
|
|
8216
7713
|
return false;
|
|
8217
7714
|
}
|
|
8218
7715
|
const now = new Date().toISOString();
|
|
@@ -8295,15 +7792,13 @@ export class AgentManager {
|
|
|
8295
7792
|
}
|
|
8296
7793
|
if (boundTask.status !== 'in_progress' || boundTask.agentId !== state.id)
|
|
8297
7794
|
return false;
|
|
8298
|
-
const dispatchPhase = boundTask.
|
|
8299
|
-
? 'research'
|
|
8300
|
-
: boundTask.specReviewRound !== undefined ? 'code' : 'develop';
|
|
7795
|
+
const dispatchPhase = boundTask.specReviewRound !== undefined ? 'code' : 'develop';
|
|
8301
7796
|
if (await this.bootstrapPromptWasDelivered(state.taskId, boundTask.createdAt, state.id, dispatchPhase)) {
|
|
8302
7797
|
await this.clearBootstrapMarker(state.id, state.taskId);
|
|
8303
7798
|
return false;
|
|
8304
7799
|
}
|
|
8305
7800
|
if (dispatchPhase === 'code') {
|
|
8306
|
-
await this.markAwaitingHuman(state.id, 'code-dispatch-failed', 'Code-phase handoff was interrupted before the prompt was delivered. Resume to replay the
|
|
7801
|
+
await this.markAwaitingHuman(state.id, 'code-dispatch-failed', 'Code-phase handoff was interrupted before the prompt was delivered. Resume to replay the code-phase prompt.', { expectedTaskId: state.taskId });
|
|
8307
7802
|
return true;
|
|
8308
7803
|
}
|
|
8309
7804
|
console.warn(`[recover] agent ${state.id} was mid-bootstrap for in_progress task ${state.taskId} ` +
|
|
@@ -8686,7 +8181,6 @@ export class AgentManager {
|
|
|
8686
8181
|
let devToRelease;
|
|
8687
8182
|
let qaToRelease;
|
|
8688
8183
|
let remoteCleanupGeneration;
|
|
8689
|
-
let publishedCleanup;
|
|
8690
8184
|
this.phaseSignalWatcher?.stop(taskId);
|
|
8691
8185
|
let cleanupClaimed = false;
|
|
8692
8186
|
const result = await this.withTaskLock(async () => {
|
|
@@ -8707,27 +8201,13 @@ export class AgentManager {
|
|
|
8707
8201
|
devToRelease = task.agentId;
|
|
8708
8202
|
if (task.qaAgentId)
|
|
8709
8203
|
qaToRelease = task.qaAgentId;
|
|
8710
|
-
const publishedAtGate = task.status === 'ready'
|
|
8711
|
-
|| (task.status === 'approved' && !!task.publishDispatchedAt);
|
|
8712
|
-
if (task.reviewMode === 'server' && publishedAtGate && task.agentId) {
|
|
8713
|
-
const afterDone = this.resolveAfterDone(task);
|
|
8714
|
-
if (afterDone === 'pr' && task.branch && task.prNumber !== undefined) {
|
|
8715
|
-
publishedCleanup = {
|
|
8716
|
-
afterDone,
|
|
8717
|
-
branch: task.branch,
|
|
8718
|
-
prNumber: task.prNumber,
|
|
8719
|
-
devAgentId: task.agentId,
|
|
8720
|
-
mayBeInFlight: task.status === 'approved',
|
|
8721
|
-
};
|
|
8722
|
-
}
|
|
8723
|
-
}
|
|
8724
8204
|
for (const id of [devToRelease, qaToRelease]) {
|
|
8725
8205
|
if (id)
|
|
8726
8206
|
await this.markPaneCancelClearing(id, taskId);
|
|
8727
8207
|
}
|
|
8728
8208
|
if (!alreadyTerminal) {
|
|
8729
8209
|
const now = new Date().toISOString();
|
|
8730
|
-
if (task.
|
|
8210
|
+
if (task.prNumber !== undefined && task.branch !== undefined) {
|
|
8731
8211
|
const expectedHeadSha = typeof task.latestHeadSha === 'string'
|
|
8732
8212
|
&& PLATFORM_HEAD_SHA_RE.test(task.latestHeadSha) ? task.latestHeadSha : undefined;
|
|
8733
8213
|
const intent = task.remoteCleanup ?? {
|
|
@@ -8761,7 +8241,6 @@ export class AgentManager {
|
|
|
8761
8241
|
// stop again post-write: a rollback may have re-armed between the pre-lock stop() and the cancelled write
|
|
8762
8242
|
if (cleanupClaimed)
|
|
8763
8243
|
this.phaseSignalWatcher?.stop(taskId);
|
|
8764
|
-
let devStopConfirmed = false;
|
|
8765
8244
|
// 两阶段:先中断全部 pane 再释放任一 binding,避免 dev 先空闲、被派新任务时旧任务的 qa prompt 仍在跑。
|
|
8766
8245
|
try {
|
|
8767
8246
|
const stopped = [];
|
|
@@ -8775,11 +8254,10 @@ export class AgentManager {
|
|
|
8775
8254
|
continue;
|
|
8776
8255
|
}
|
|
8777
8256
|
if (!(await this.interruptPaneAndWaitReady(state, cfg))) {
|
|
8778
|
-
await this.markAwaitingHuman(id, 'cancel-interrupt-failed',
|
|
8257
|
+
await this.markAwaitingHuman(id, 'cancel-interrupt-failed', `Task is ${result.status}, but ESC / REPL ready check failed; agent may still be running its prompt. ` +
|
|
8258
|
+
'Attach via web terminal to verify, then Resume or Delete.', { expectedTaskId: taskId });
|
|
8779
8259
|
continue;
|
|
8780
8260
|
}
|
|
8781
|
-
if (id === publishedCleanup?.devAgentId)
|
|
8782
|
-
devStopConfirmed = true;
|
|
8783
8261
|
stopped.push(id);
|
|
8784
8262
|
}
|
|
8785
8263
|
for (const id of stopped) {
|
|
@@ -8808,46 +8286,6 @@ export class AgentManager {
|
|
|
8808
8286
|
});
|
|
8809
8287
|
}
|
|
8810
8288
|
}
|
|
8811
|
-
if (publishedCleanup) {
|
|
8812
|
-
if (publishedCleanup.mayBeInFlight && !devStopConfirmed) {
|
|
8813
|
-
await this.emitIntervention(result.projectId, undefined, taskId, {
|
|
8814
|
-
phase: 'cancel-published-artifact-cleanup-skipped',
|
|
8815
|
-
afterDone: publishedCleanup.afterDone,
|
|
8816
|
-
branch: publishedCleanup.branch,
|
|
8817
|
-
prNumber: publishedCleanup.prNumber,
|
|
8818
|
-
reason: 'dev pane stop unconfirmed; the publish prompt may still be running and would recreate the remote artifacts',
|
|
8819
|
-
});
|
|
8820
|
-
return result;
|
|
8821
|
-
}
|
|
8822
|
-
try {
|
|
8823
|
-
const { driver } = await this.platformTaskAndDriver(taskId);
|
|
8824
|
-
await driver.runOp('close', { prNumber: publishedCleanup.prNumber });
|
|
8825
|
-
await this.safeEmit({
|
|
8826
|
-
id: '',
|
|
8827
|
-
type: 'task.updated',
|
|
8828
|
-
timestamp: new Date().toISOString(),
|
|
8829
|
-
projectId: result.projectId,
|
|
8830
|
-
taskId,
|
|
8831
|
-
data: {
|
|
8832
|
-
status: 'cancelled',
|
|
8833
|
-
operation: 'server-pr-close',
|
|
8834
|
-
outcome: 'succeeded',
|
|
8835
|
-
branch: publishedCleanup.branch,
|
|
8836
|
-
prNumber: publishedCleanup.prNumber,
|
|
8837
|
-
},
|
|
8838
|
-
});
|
|
8839
|
-
}
|
|
8840
|
-
catch (err) {
|
|
8841
|
-
console.warn(`[AgentManager] cancelTask remote retirement failed for ${taskId}:`, err);
|
|
8842
|
-
await this.emitIntervention(result.projectId, undefined, taskId, {
|
|
8843
|
-
phase: 'cancel-published-artifact-cleanup-failed',
|
|
8844
|
-
afterDone: publishedCleanup.afterDone,
|
|
8845
|
-
branch: publishedCleanup.branch,
|
|
8846
|
-
prNumber: publishedCleanup.prNumber,
|
|
8847
|
-
error: err instanceof Error ? err.message : String(err),
|
|
8848
|
-
});
|
|
8849
|
-
}
|
|
8850
|
-
}
|
|
8851
8289
|
return result;
|
|
8852
8290
|
}
|
|
8853
8291
|
async validateTaskDispatch(projectId, input) {
|
|
@@ -8860,7 +8298,7 @@ export class AgentManager {
|
|
|
8860
8298
|
throw new ApiError(400, `Agent ${input.preferredAgentId} not in project ${projectId}`);
|
|
8861
8299
|
}
|
|
8862
8300
|
if (!TASK_OWNER_ROLES.has(cfg.role)) {
|
|
8863
|
-
throw new ApiError(400, `Agent ${input.preferredAgentId} is not dev
|
|
8301
|
+
throw new ApiError(400, `Agent ${input.preferredAgentId} is not a dev agent`);
|
|
8864
8302
|
}
|
|
8865
8303
|
}
|
|
8866
8304
|
let previewBytes;
|
|
@@ -8911,9 +8349,13 @@ export class AgentManager {
|
|
|
8911
8349
|
opts.onSideEffect?.();
|
|
8912
8350
|
const { task, lease } = claimed;
|
|
8913
8351
|
const qaId = task.qaAgentId;
|
|
8914
|
-
if (!qaId
|
|
8352
|
+
if (!qaId) {
|
|
8915
8353
|
await this.resetGitReviewDispatchClaim(taskId, lease);
|
|
8916
|
-
throw new
|
|
8354
|
+
throw new Error(`dispatchGitReviewLease: task ${taskId} has no QA participant`);
|
|
8355
|
+
}
|
|
8356
|
+
if (!this.gitReviewQaConfigUsable(task)) {
|
|
8357
|
+
await this.resetGitReviewDispatchClaim(taskId, lease);
|
|
8358
|
+
throw new ApiError(409, `Task ${taskId} QA participant is unavailable`);
|
|
8917
8359
|
}
|
|
8918
8360
|
const qaPhase = lease.qaPhase;
|
|
8919
8361
|
const previousQa = await this.agentStore.get(qaId);
|
|
@@ -9041,7 +8483,64 @@ export class AgentManager {
|
|
|
9041
8483
|
}
|
|
9042
8484
|
async dispatchReviewToQa(taskId, opts = {}) {
|
|
9043
8485
|
let routeTask = await this.taskStore.get(taskId);
|
|
9044
|
-
if (
|
|
8486
|
+
if (!routeTask)
|
|
8487
|
+
throw new ApiError(404, `Task ${taskId} not found`);
|
|
8488
|
+
{
|
|
8489
|
+
if (opts.prNumber !== undefined
|
|
8490
|
+
&& (!Number.isSafeInteger(opts.prNumber) || opts.prNumber < 1)) {
|
|
8491
|
+
throw new ApiError(400, 'prNumber must be a positive safe integer', 'dispatch-unsupported');
|
|
8492
|
+
}
|
|
8493
|
+
if (routeTask.prNumber !== undefined
|
|
8494
|
+
&& opts.prNumber !== undefined
|
|
8495
|
+
&& routeTask.prNumber !== opts.prNumber) {
|
|
8496
|
+
throw new ApiError(409, `Task ${taskId} is already bound to PR #${routeTask.prNumber}, not #${opts.prNumber}`, 'dispatch-unsupported');
|
|
8497
|
+
}
|
|
8498
|
+
const deliveryUnconfirmed = routeTask.phase === undefined
|
|
8499
|
+
|| routeTask.deliveryConfirmation?.phase !== routeTask.phase;
|
|
8500
|
+
if (deliveryUnconfirmed && opts.stage === undefined) {
|
|
8501
|
+
throw new ApiError(400, `Task ${taskId} has no verified delivery phase; stage is required for manual recovery`, 'dispatch-unsupported');
|
|
8502
|
+
}
|
|
8503
|
+
if (opts.stage !== undefined && routeTask.phase !== undefined && routeTask.phase !== opts.stage) {
|
|
8504
|
+
throw new ApiError(409, `Task ${taskId} is in ${routeTask.phase}, not ${opts.stage}`);
|
|
8505
|
+
}
|
|
8506
|
+
if (deliveryUnconfirmed
|
|
8507
|
+
|| routeTask.replyActorStatus !== 'verified'
|
|
8508
|
+
|| routeTask.prNumber === undefined) {
|
|
8509
|
+
const actorId = routeTask.replyActorStatus === 'verified'
|
|
8510
|
+
? routeTask.replyActorId
|
|
8511
|
+
: opts.actorId?.trim();
|
|
8512
|
+
if (!actorId) {
|
|
8513
|
+
throw new ApiError(400, `Task ${taskId} has no verified PR actor; actorId is required for manual recovery`, 'dispatch-unsupported');
|
|
8514
|
+
}
|
|
8515
|
+
const prNumber = routeTask.prNumber ?? opts.prNumber;
|
|
8516
|
+
if (prNumber === undefined) {
|
|
8517
|
+
throw new ApiError(400, `Task ${taskId} has no tracked PR; prNumber is required for manual recovery`, 'dispatch-unsupported');
|
|
8518
|
+
}
|
|
8519
|
+
const verified = await this.platformVerifyPrBinding(taskId, prNumber);
|
|
8520
|
+
if (!verified.ok) {
|
|
8521
|
+
throw new ApiError(409, `Cannot recover git review for ${taskId}: binding ${verified.reason}`);
|
|
8522
|
+
}
|
|
8523
|
+
const phase = opts.stage ?? routeTask.phase;
|
|
8524
|
+
if (phase === undefined) {
|
|
8525
|
+
throw new ApiError(409, `Task ${taskId} changed before its delivery phase could be recovered`);
|
|
8526
|
+
}
|
|
8527
|
+
const confirmed = await this.confirmGitDelivery(taskId, {
|
|
8528
|
+
phase,
|
|
8529
|
+
source: 'human',
|
|
8530
|
+
prNumber,
|
|
8531
|
+
prUrl: verified.prUrl,
|
|
8532
|
+
branch: verified.branch,
|
|
8533
|
+
headSha: verified.headSha,
|
|
8534
|
+
targetBranch: verified.targetBranch,
|
|
8535
|
+
actorId,
|
|
8536
|
+
expectedSignalToken: routeTask.signalToken,
|
|
8537
|
+
});
|
|
8538
|
+
if (!confirmed) {
|
|
8539
|
+
throw new ApiError(409, `Task ${taskId} changed during manual delivery recovery`, 'dispatch-superseded');
|
|
8540
|
+
}
|
|
8541
|
+
routeTask = confirmed;
|
|
8542
|
+
opts.onSideEffect?.();
|
|
8543
|
+
}
|
|
9045
8544
|
if (opts.expectedTask && !taskMatchesGeneration(routeTask, opts.expectedTask)) {
|
|
9046
8545
|
throw new ApiError(409, `Task ${taskId} review generation changed during redispatch`, 'dispatch-superseded');
|
|
9047
8546
|
}
|
|
@@ -9054,8 +8553,9 @@ export class AgentManager {
|
|
|
9054
8553
|
if (Object.hasOwn(opts, 'expectPhase') && routeTask.phase !== opts.expectPhase) {
|
|
9055
8554
|
throw new ApiError(409, `Task ${taskId} review phase changed during redispatch`, 'dispatch-superseded');
|
|
9056
8555
|
}
|
|
9057
|
-
|
|
9058
|
-
|
|
8556
|
+
const routeQaAgentId = requireTaskQaAgentId(routeTask, 'dispatchReviewToQa');
|
|
8557
|
+
if (this.getAgentConfig(routeQaAgentId)?.role !== 'qa') {
|
|
8558
|
+
throw new ApiError(409, `Task ${taskId} QA participant is unavailable`, 'dispatch-unsupported');
|
|
9059
8559
|
}
|
|
9060
8560
|
let lease = routeTask.reviewDispatch;
|
|
9061
8561
|
if (lease?.phase === 'uncertain') {
|
|
@@ -9083,9 +8583,9 @@ export class AgentManager {
|
|
|
9083
8583
|
throw new ApiError(409, `Task ${taskId} review generation changed during uncertain-delivery confirmation`, 'dispatch-superseded');
|
|
9084
8584
|
}
|
|
9085
8585
|
}
|
|
9086
|
-
const routeQaId = routeTask
|
|
9087
|
-
if (
|
|
9088
|
-
throw new ApiError(409, `Task ${taskId}
|
|
8586
|
+
const routeQaId = requireTaskQaAgentId(routeTask, 'dispatchReviewToQa');
|
|
8587
|
+
if (this.getAgentConfig(routeQaId)?.role !== 'qa') {
|
|
8588
|
+
throw new ApiError(409, `Task ${taskId} QA participant is unavailable`, 'dispatch-unsupported');
|
|
9089
8589
|
}
|
|
9090
8590
|
const qaBinding = await this.agentStore.get(routeQaId);
|
|
9091
8591
|
if (qaBinding?.taskId === taskId
|
|
@@ -9137,110 +8637,75 @@ export class AgentManager {
|
|
|
9137
8637
|
opts.onPassArmed?.(lease.signalToken);
|
|
9138
8638
|
return dispatched;
|
|
9139
8639
|
}
|
|
9140
|
-
const claim = await this.withTaskLock(async () => {
|
|
9141
|
-
if (this.manualReviewInFlight.has(taskId)) {
|
|
9142
|
-
throw new ApiError(409, `Manual review already in progress for task ${taskId}`, 'dispatch-in-flight');
|
|
9143
|
-
}
|
|
9144
|
-
if (this.markCompleteInFlight.has(taskId)) {
|
|
9145
|
-
throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`, 'dispatch-in-flight');
|
|
9146
|
-
}
|
|
9147
|
-
// 早闸在 claim 前零副作用拒绝:driver 的 putRound/删 response 副作用都在终闸之前。
|
|
9148
|
-
if (this.phaseSignalWatcher?.isSettling(taskId)) {
|
|
9149
|
-
throw new ApiError(409, `Task ${taskId} has a review completion settling; retry after it lands`, 'review-settling');
|
|
9150
|
-
}
|
|
9151
|
-
const task = await this.taskStore.get(taskId);
|
|
9152
|
-
if (!task)
|
|
9153
|
-
throw new ApiError(404, `Task ${taskId} not found`);
|
|
9154
|
-
if (task.reviewMode !== 'server' && !isSpecStagePhase(task.phase)) {
|
|
9155
|
-
throw new ApiError(409, `Task ${taskId} review mode changed during dispatch; retry`, 'dispatch-superseded');
|
|
9156
|
-
}
|
|
9157
|
-
if (opts.expectedTask && !taskMatchesGeneration(task, opts.expectedTask)) {
|
|
9158
|
-
throw new ApiError(409, `Task ${taskId} review generation changed during redispatch`, 'dispatch-superseded');
|
|
9159
|
-
}
|
|
9160
|
-
if (opts.fromStatus && !opts.fromStatus.includes(task.status)) {
|
|
9161
|
-
throw new ApiError(409, `Task ${taskId} status is ${task.status}; review dispatch requires ${opts.fromStatus.join('/')}`, 'dispatch-superseded');
|
|
9162
|
-
}
|
|
9163
|
-
if (Object.hasOwn(opts, 'expectSignalToken') && task.signalToken !== opts.expectSignalToken) {
|
|
9164
|
-
throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (signalToken rotated); aborting`, 'dispatch-superseded');
|
|
9165
|
-
}
|
|
9166
|
-
if (Object.hasOwn(opts, 'expectPhase') && task.phase !== opts.expectPhase) {
|
|
9167
|
-
throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (phase changed); aborting`, 'dispatch-superseded');
|
|
9168
|
-
}
|
|
9169
|
-
if (!MANUAL_SERVER_REVIEW_STATUSES.includes(task.status)) {
|
|
9170
|
-
throw new ApiError(409, `Task ${taskId} status is ${task.status}; manual server-side review requires ${MANUAL_SERVER_REVIEW_STATUSES.join('/')}`, 'dispatch-unsupported');
|
|
9171
|
-
}
|
|
9172
|
-
if (!this.serverReviewDriver) {
|
|
9173
|
-
throw new ApiError(409, `Server review pipeline is not configured; cannot dispatch review for ${taskId}`, 'dispatch-unsupported');
|
|
9174
|
-
}
|
|
9175
|
-
if (task.status === 'in_progress' && task.phase === undefined) {
|
|
9176
|
-
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`, 'dispatch-unsupported');
|
|
9177
|
-
}
|
|
9178
|
-
const qaAvailable = task.qaAgentId !== undefined && this.getAgentConfig(task.qaAgentId)?.role === 'qa';
|
|
9179
|
-
if (!qaAvailable) {
|
|
9180
|
-
throw new ApiError(400, `Task ${taskId} has no QA partner configured; manual review requires a QA agent`, 'dispatch-unsupported');
|
|
9181
|
-
}
|
|
9182
|
-
const isSpec = isSpecStagePhase(task.phase);
|
|
9183
|
-
const qaBinding = await this.agentStore.get(task.qaAgentId);
|
|
9184
|
-
const bumpRound = shouldBumpServerReviewRound(opts.bumpRound, task, qaBinding);
|
|
9185
|
-
const cap = this.config.review.rounds + (task.maxRoundsContinues ?? 0);
|
|
9186
|
-
const round = isSpec ? (task.specReviewRound ?? 0) : task.reviewRound;
|
|
9187
|
-
const nextRound = bumpRound ? round + 1 : round;
|
|
9188
|
-
if (nextRound < 1) {
|
|
9189
|
-
throw new ApiError(409, `Task ${taskId} has no current review round to redispatch`, 'dispatch-unsupported');
|
|
9190
|
-
}
|
|
9191
|
-
if (nextRound > cap) {
|
|
9192
|
-
throw new ApiError(409, `Task ${taskId} reached the review round cap (${cap}); continue or cancel it instead`, 'dispatch-unsupported');
|
|
9193
|
-
}
|
|
9194
|
-
this.manualReviewInFlight.add(taskId);
|
|
9195
|
-
return {
|
|
9196
|
-
isSpec,
|
|
9197
|
-
reviewPass: {
|
|
9198
|
-
status: task.status,
|
|
9199
|
-
phase: task.phase,
|
|
9200
|
-
signalToken: task.signalToken,
|
|
9201
|
-
agentId: task.agentId,
|
|
9202
|
-
reviewRound: task.reviewRound,
|
|
9203
|
-
specReviewRound: task.specReviewRound,
|
|
9204
|
-
},
|
|
9205
|
-
bumpRound,
|
|
9206
|
-
onSideEffect: opts.onSideEffect,
|
|
9207
|
-
};
|
|
9208
|
-
});
|
|
9209
|
-
return this.runManualServerReview(taskId, claim);
|
|
9210
8640
|
}
|
|
9211
|
-
async
|
|
8641
|
+
async dispatchGitFixToDev(taskId) {
|
|
8642
|
+
const task = await this.taskStore.get(taskId);
|
|
8643
|
+
if (!task || task.status !== 'fixing')
|
|
8644
|
+
return false;
|
|
8645
|
+
const qaAgentId = requireTaskQaAgentId(task, 'dispatchGitFixToDev');
|
|
8646
|
+
const qaState = await this.agentStore.get(qaAgentId);
|
|
8647
|
+
const qaReleased = qaState?.taskId !== task.id
|
|
8648
|
+
? true
|
|
8649
|
+
: await this.releaseAgentForTask(qaAgentId, task.id, 'idle', { allowAwaitingHuman: true }).catch(err => {
|
|
8650
|
+
console.error(`[AgentManager] git fix QA release(${qaAgentId}) failed:`, err);
|
|
8651
|
+
return false;
|
|
8652
|
+
});
|
|
8653
|
+
if (!qaReleased) {
|
|
8654
|
+
await this.emitIntervention(task.projectId, task.agentId, task.id, {
|
|
8655
|
+
phase: 'qa-release-failed-but-dev-dispatched',
|
|
8656
|
+
qaAgentId,
|
|
8657
|
+
});
|
|
8658
|
+
}
|
|
8659
|
+
const acquired = await this.acquireAgentForTask(task.agentId, task.id, 'fix');
|
|
8660
|
+
if (!acquired) {
|
|
8661
|
+
await this.emitIntervention(task.projectId, task.agentId, task.id, {
|
|
8662
|
+
phase: 'dev-acquire-failed-fix',
|
|
8663
|
+
devAgentId: task.agentId,
|
|
8664
|
+
});
|
|
8665
|
+
return false;
|
|
8666
|
+
}
|
|
8667
|
+
const { token, armed } = await this.rotateAndSetupPhaseSignal(task.id, task.agentId, 'pr-fixed', { expectedStatus: 'fixing', expectedToken: task.signalToken });
|
|
8668
|
+
if (!armed) {
|
|
8669
|
+
await this.markAwaitingHuman(task.agentId, 'signal-arm-failed:pr-fixed', 'pr-fixed watcher failed to arm; the fix was not dispatched (its completion signal would have no consumer). Cancel the task or delete the agent to retry.', { expectedTaskId: task.id });
|
|
8670
|
+
return false;
|
|
8671
|
+
}
|
|
8672
|
+
let dispatchError;
|
|
9212
8673
|
try {
|
|
9213
|
-
const
|
|
9214
|
-
|
|
9215
|
-
|
|
9216
|
-
|
|
9217
|
-
|
|
9218
|
-
|
|
9219
|
-
|
|
9220
|
-
|
|
9221
|
-
|
|
9222
|
-
}
|
|
9223
|
-
const driverOpts = {
|
|
9224
|
-
bumpRound: claim.bumpRound,
|
|
9225
|
-
expectedTask: claim.reviewPass,
|
|
9226
|
-
...(claim.onSideEffect !== undefined ? { onSideEffect: claim.onSideEffect } : {}),
|
|
9227
|
-
...(ownerPermit !== undefined ? { settlePermit: ownerPermit } : {}),
|
|
9228
|
-
};
|
|
9229
|
-
const dispatched = claim.isSpec
|
|
9230
|
-
? await this.serverReviewDriver.dispatchSpecReview(fresh, driverOpts)
|
|
9231
|
-
: await this.serverReviewDriver.dispatchCodeReview(fresh, driverOpts);
|
|
9232
|
-
if (!dispatched) {
|
|
9233
|
-
throw new ApiError(500, `Manual review dispatch for ${taskId} did not start; check the event feed for the cause`);
|
|
9234
|
-
}
|
|
9235
|
-
return (await this.taskStore.get(taskId));
|
|
9236
|
-
};
|
|
9237
|
-
return this.phaseSignalWatcher
|
|
9238
|
-
? await this.phaseSignalWatcher.runExclusive(taskId, run)
|
|
9239
|
-
: await run();
|
|
8674
|
+
const resumed = await this.continueSession(task.id, task.agentId, 'fix', {
|
|
8675
|
+
signalToken: token,
|
|
8676
|
+
guardBeforeInject: async () => {
|
|
8677
|
+
const fresh = await this.taskStore.get(task.id);
|
|
8678
|
+
return fresh?.status === 'fixing' && fresh.signalToken === token;
|
|
8679
|
+
},
|
|
8680
|
+
});
|
|
8681
|
+
if (resumed)
|
|
8682
|
+
return true;
|
|
9240
8683
|
}
|
|
9241
|
-
|
|
9242
|
-
|
|
8684
|
+
catch (err) {
|
|
8685
|
+
dispatchError = err;
|
|
8686
|
+
console.error(`[AgentManager] git fix dispatch(${task.id}) failed:`, err);
|
|
8687
|
+
}
|
|
8688
|
+
if (dispatchError instanceof DispatchTerminalError) {
|
|
8689
|
+
await this.failTaskForDispatchError(task.id, 'fix', task.agentId, dispatchError);
|
|
8690
|
+
return false;
|
|
8691
|
+
}
|
|
8692
|
+
const parked = await this.markAgentWaiting(task.agentId, task.id).catch(err => {
|
|
8693
|
+
console.error(`[AgentManager] git fix park(${task.agentId}) failed:`, err);
|
|
8694
|
+
return false;
|
|
8695
|
+
});
|
|
8696
|
+
if (parked && (dispatchError instanceof ReplNotReadyError || dispatchError instanceof DirtyWorkdirError)) {
|
|
8697
|
+
this.registerPendingDispatchRetry(task.id, {
|
|
8698
|
+
kind: 'dev-fix',
|
|
8699
|
+
agentId: task.agentId,
|
|
8700
|
+
signalToken: token,
|
|
8701
|
+
});
|
|
8702
|
+
return false;
|
|
9243
8703
|
}
|
|
8704
|
+
await this.emitIntervention(task.projectId, task.agentId, task.id, {
|
|
8705
|
+
phase: 'fix-resume-failed',
|
|
8706
|
+
reviewRound: taskReviewRound(task),
|
|
8707
|
+
});
|
|
8708
|
+
return false;
|
|
9244
8709
|
}
|
|
9245
8710
|
async continueDevRound(taskId) {
|
|
9246
8711
|
if (this.markCompleteInFlight.has(taskId)) {
|
|
@@ -9255,47 +8720,6 @@ export class AgentManager {
|
|
|
9255
8720
|
if (isSpecStagePhase(task.phase)) {
|
|
9256
8721
|
throw new ApiError(409, `Continue one round is only supported for code-phase tasks`);
|
|
9257
8722
|
}
|
|
9258
|
-
if (task.reviewMode === 'server') {
|
|
9259
|
-
if (!task.agentId) {
|
|
9260
|
-
throw new ApiError(400, `Task ${taskId} has no dev agent; cannot continue`);
|
|
9261
|
-
}
|
|
9262
|
-
const stored = await this.reviewStore?.getRound(taskId, 'code', Math.max(task.reviewRound, 1));
|
|
9263
|
-
if (!stored?.findings) {
|
|
9264
|
-
throw new ApiError(409, `Task ${taskId} has no stored findings to continue from; cancel instead`);
|
|
9265
|
-
}
|
|
9266
|
-
await this.withTaskLock(async () => {
|
|
9267
|
-
if (this.markCompleteInFlight.has(taskId)) {
|
|
9268
|
-
throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`);
|
|
9269
|
-
}
|
|
9270
|
-
const fresh = await this.taskStore.get(taskId);
|
|
9271
|
-
if (!fresh || fresh.status !== 'max_rounds') {
|
|
9272
|
-
throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${fresh?.status ?? 'gone'})`);
|
|
9273
|
-
}
|
|
9274
|
-
fresh.maxRoundsContinues = (fresh.maxRoundsContinues ?? 0) + 1;
|
|
9275
|
-
fresh.updatedAt = new Date().toISOString();
|
|
9276
|
-
await this.taskStore.set(fresh);
|
|
9277
|
-
});
|
|
9278
|
-
let dispatched = null;
|
|
9279
|
-
try {
|
|
9280
|
-
dispatched = await this.dispatchServerFixToDev(taskId, JSON.stringify(stored.findings));
|
|
9281
|
-
}
|
|
9282
|
-
finally {
|
|
9283
|
-
if (!dispatched) {
|
|
9284
|
-
await this.withTaskLock(async () => {
|
|
9285
|
-
const fresh = await this.taskStore.get(taskId);
|
|
9286
|
-
if (!fresh)
|
|
9287
|
-
return;
|
|
9288
|
-
fresh.maxRoundsContinues = Math.max(0, (fresh.maxRoundsContinues ?? 0) - 1);
|
|
9289
|
-
fresh.updatedAt = new Date().toISOString();
|
|
9290
|
-
await this.taskStore.set(fresh);
|
|
9291
|
-
}).catch(() => undefined);
|
|
9292
|
-
}
|
|
9293
|
-
}
|
|
9294
|
-
if (!dispatched) {
|
|
9295
|
-
throw new ApiError(500, `Failed to dispatch server fix round for task ${taskId}`);
|
|
9296
|
-
}
|
|
9297
|
-
return dispatched;
|
|
9298
|
-
}
|
|
9299
8723
|
if (!task.prNumber || !task.branch) {
|
|
9300
8724
|
throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot continue`);
|
|
9301
8725
|
}
|
|
@@ -9314,56 +8738,44 @@ export class AgentManager {
|
|
|
9314
8738
|
throw new ApiError(409, `Task ${taskId} changed status during continue; aborted`);
|
|
9315
8739
|
}
|
|
9316
8740
|
const rollback = async () => {
|
|
9317
|
-
await this.
|
|
9318
|
-
|
|
9319
|
-
|
|
9320
|
-
|
|
9321
|
-
|
|
9322
|
-
|
|
9323
|
-
|
|
9324
|
-
|
|
9325
|
-
if (!armed) {
|
|
9326
|
-
await this.markAwaitingHuman(devAgentId, 'signal-arm-failed:pr-fixed', 'pr-fixed watcher failed to arm; the fix was not dispatched (its completion signal would have no consumer). Cancel the task or delete the agent to retry.', { expectedTaskId: taskId });
|
|
9327
|
-
await rollback();
|
|
9328
|
-
throw new ApiError(500, `Failed to arm pr-fixed watcher for task ${taskId}`);
|
|
9329
|
-
}
|
|
9330
|
-
const rollbackAndRepark = async () => {
|
|
9331
|
-
await rollback();
|
|
9332
|
-
await this.markAgentWaiting(devAgentId, taskId).catch(() => undefined);
|
|
8741
|
+
const current = await this.taskStore.get(taskId);
|
|
8742
|
+
if (current?.signalToken) {
|
|
8743
|
+
this.clearPendingDispatchRetryIfMatches(taskId, {
|
|
8744
|
+
agentId: devAgentId,
|
|
8745
|
+
signalToken: current.signalToken,
|
|
8746
|
+
});
|
|
8747
|
+
}
|
|
8748
|
+
return (await this.transitionTaskStatus(taskId, 'max_rounds', { fromStatus: ['fixing'] }, { reviewRound: prevReviewRound })) !== null;
|
|
9333
8749
|
};
|
|
9334
|
-
let resumed = false;
|
|
9335
8750
|
try {
|
|
9336
|
-
|
|
8751
|
+
if (await this.dispatchGitFixToDev(taskId))
|
|
8752
|
+
return (await this.taskStore.get(taskId));
|
|
9337
8753
|
}
|
|
9338
8754
|
catch (err) {
|
|
9339
|
-
if (
|
|
9340
|
-
|
|
9341
|
-
throw new ApiError(500, `Continue dispatch failed: ${err.message}`);
|
|
8755
|
+
if ((await this.taskStore.get(taskId))?.status !== 'failed' && !await rollback()) {
|
|
8756
|
+
throw new ApiError(409, `Task ${taskId} changed while its failed fix dispatch was rolling back`);
|
|
9342
8757
|
}
|
|
9343
|
-
await rollbackAndRepark();
|
|
9344
8758
|
throw err;
|
|
9345
8759
|
}
|
|
9346
|
-
|
|
9347
|
-
|
|
9348
|
-
throw new ApiError(500, `
|
|
8760
|
+
const after = await this.taskStore.get(taskId);
|
|
8761
|
+
if (after?.status === 'failed') {
|
|
8762
|
+
throw new ApiError(500, `Fix dispatch failed terminally for task ${taskId}; use Retry`);
|
|
9349
8763
|
}
|
|
9350
|
-
|
|
9351
|
-
|
|
8764
|
+
if (!await rollback()) {
|
|
8765
|
+
throw new ApiError(409, `Task ${taskId} changed while its failed fix dispatch was rolling back`);
|
|
8766
|
+
}
|
|
8767
|
+
throw new ApiError(500, `Failed to dispatch fix to dev ${devAgentId} for task ${taskId}`);
|
|
9352
8768
|
}
|
|
9353
|
-
devInitialSignalKinds(
|
|
9354
|
-
return
|
|
9355
|
-
? ['spec-done', 'code-done']
|
|
9356
|
-
: ['spec-done', 'pr-created'];
|
|
8769
|
+
devInitialSignalKinds() {
|
|
8770
|
+
return ['spec-done', 'pr-created'];
|
|
9357
8771
|
}
|
|
9358
|
-
resolveInitialDispatch(
|
|
9359
|
-
|
|
9360
|
-
return { phase: 'research', kinds: ['spec-done'] };
|
|
9361
|
-
return { phase: 'develop', kinds: this.devInitialSignalKinds(task.reviewMode) };
|
|
8772
|
+
resolveInitialDispatch() {
|
|
8773
|
+
return { phase: 'develop', kinds: this.devInitialSignalKinds() };
|
|
9362
8774
|
}
|
|
9363
8775
|
mapTaskStateToExpectedWatchers(task) {
|
|
9364
8776
|
const single = this.mapTaskStateToExpectedWatcher(task);
|
|
9365
8777
|
const mappings = single ? [single] : [];
|
|
9366
|
-
if (task.
|
|
8778
|
+
if (task.status === 'review'
|
|
9367
8779
|
&& task.replyActorStatus !== 'verified' && task.pendingPrSignalToken !== undefined
|
|
9368
8780
|
&& task.devAgentId && task.prNumber !== undefined) {
|
|
9369
8781
|
mappings.push({
|
|
@@ -9375,24 +8787,19 @@ export class AgentManager {
|
|
|
9375
8787
|
return mappings;
|
|
9376
8788
|
}
|
|
9377
8789
|
mapTaskStateToExpectedWatcher(task) {
|
|
9378
|
-
if (task.
|
|
9379
|
-
return this.mapServerTaskToExpectedWatcher(task);
|
|
9380
|
-
const specStage = isSpecStagePhase(task.phase);
|
|
9381
|
-
if (task.status === 'review' && task.qaAgentId) {
|
|
8790
|
+
if (task.status === 'review') {
|
|
9382
8791
|
return {
|
|
9383
|
-
expectedKinds:
|
|
9384
|
-
agentId: task
|
|
8792
|
+
expectedKinds: PASSIVE_VERDICT_WATCH,
|
|
8793
|
+
agentId: requireTaskQaAgentId(task, 'mapTaskStateToExpectedWatcher'),
|
|
9385
8794
|
};
|
|
9386
8795
|
}
|
|
9387
8796
|
if (task.status === 'fixing' && task.agentId) {
|
|
9388
8797
|
return {
|
|
9389
|
-
expectedKinds: [
|
|
8798
|
+
expectedKinds: ['pr-fixed'],
|
|
9390
8799
|
agentId: task.agentId,
|
|
9391
8800
|
};
|
|
9392
8801
|
}
|
|
9393
8802
|
if (task.status === 'in_progress' && task.agentId) {
|
|
9394
|
-
if (task.phase === 'research')
|
|
9395
|
-
return { expectedKinds: ['spec-done'], agentId: task.agentId };
|
|
9396
8803
|
if (task.phase === 'code')
|
|
9397
8804
|
return { expectedKinds: ['pr-created'], agentId: task.agentId };
|
|
9398
8805
|
if (task.phase === undefined) {
|
|
@@ -9401,27 +8808,6 @@ export class AgentManager {
|
|
|
9401
8808
|
}
|
|
9402
8809
|
return undefined;
|
|
9403
8810
|
}
|
|
9404
|
-
mapServerTaskToExpectedWatcher(task) {
|
|
9405
|
-
const isSpec = isSpecStagePhase(task.phase);
|
|
9406
|
-
if (task.status === 'review' && task.qaAgentId) {
|
|
9407
|
-
return { expectedKinds: [isSpec ? 'spec-reviewed' : 'code-reviewed'], agentId: task.qaAgentId };
|
|
9408
|
-
}
|
|
9409
|
-
if (task.status === 'fixing' && task.agentId) {
|
|
9410
|
-
return { expectedKinds: [isSpec ? 'spec-fixed' : 'code-fixed'], agentId: task.agentId };
|
|
9411
|
-
}
|
|
9412
|
-
if (task.status === 'in_progress' && task.agentId) {
|
|
9413
|
-
if (task.phase === 'research')
|
|
9414
|
-
return { expectedKinds: ['spec-done'], agentId: task.agentId };
|
|
9415
|
-
if (task.phase === 'code') {
|
|
9416
|
-
return { expectedKinds: ['code-done'], agentId: task.agentId };
|
|
9417
|
-
}
|
|
9418
|
-
return { expectedKinds: ['spec-done', 'code-done'], agentId: task.agentId };
|
|
9419
|
-
}
|
|
9420
|
-
if (task.status === 'approved' && task.agentId) {
|
|
9421
|
-
return { expectedKinds: ['code-ready'], agentId: task.agentId };
|
|
9422
|
-
}
|
|
9423
|
-
return undefined;
|
|
9424
|
-
}
|
|
9425
8811
|
async setupPhaseSignal(taskId, agentId, expectedKinds, opts = {}) {
|
|
9426
8812
|
const task = await this.taskStore.get(taskId);
|
|
9427
8813
|
if (!task?.signalToken)
|
|
@@ -9446,7 +8832,7 @@ export class AgentManager {
|
|
|
9446
8832
|
const input = {
|
|
9447
8833
|
title: old.title,
|
|
9448
8834
|
description: old.description,
|
|
9449
|
-
preferredAgentId: old
|
|
8835
|
+
preferredAgentId: this.resolveRetryPreferredAgentId(old),
|
|
9450
8836
|
};
|
|
9451
8837
|
if (old.images?.length) {
|
|
9452
8838
|
input.images = await this.readStagedImages(old.id, old.images);
|
|
@@ -9457,6 +8843,27 @@ export class AgentManager {
|
|
|
9457
8843
|
}
|
|
9458
8844
|
return this.createAndStartTask(old.projectId, input);
|
|
9459
8845
|
}
|
|
8846
|
+
resolveRetryPreferredAgentId(task) {
|
|
8847
|
+
const configured = this.getAgentConfig(task.preferredAgentId);
|
|
8848
|
+
if (configured?.projectId === task.projectId && TASK_OWNER_ROLES.has(configured.role)) {
|
|
8849
|
+
return task.preferredAgentId;
|
|
8850
|
+
}
|
|
8851
|
+
const anchors = new Set([task.qaAgentId, task.devAgentId, task.agentId]
|
|
8852
|
+
.filter((agentId) => typeof agentId === 'string' && agentId !== ''));
|
|
8853
|
+
for (const anchor of anchors) {
|
|
8854
|
+
const dev = this.findAgentGroup(anchor)?.find(agent => TASK_OWNER_ROLES.has(agent.role));
|
|
8855
|
+
const current = dev ? this.getAgentConfig(dev.id) : undefined;
|
|
8856
|
+
if (current?.projectId === task.projectId)
|
|
8857
|
+
return current.id;
|
|
8858
|
+
}
|
|
8859
|
+
const project = this.getProjectConfig(task.projectId);
|
|
8860
|
+
if (project?.agent.length === 1) {
|
|
8861
|
+
const dev = project.agent[0].find(agent => TASK_OWNER_ROLES.has(agent.role));
|
|
8862
|
+
if (dev)
|
|
8863
|
+
return dev.id;
|
|
8864
|
+
}
|
|
8865
|
+
return task.preferredAgentId;
|
|
8866
|
+
}
|
|
9460
8867
|
async editTask(taskId, patch) {
|
|
9461
8868
|
return this.withTaskLock(async () => {
|
|
9462
8869
|
const task = await this.taskStore.get(taskId);
|
|
@@ -9476,7 +8883,6 @@ export class AgentManager {
|
|
|
9476
8883
|
task.devAgentId = '';
|
|
9477
8884
|
delete task.phase;
|
|
9478
8885
|
delete task.qaAgentId;
|
|
9479
|
-
delete task.researchAgentId;
|
|
9480
8886
|
}
|
|
9481
8887
|
else {
|
|
9482
8888
|
const cfg = this.getAgentConfig(patch.preferredAgentId);
|
|
@@ -9486,28 +8892,18 @@ export class AgentManager {
|
|
|
9486
8892
|
throw new ApiError(400, `Agent not in project ${task.projectId}`);
|
|
9487
8893
|
}
|
|
9488
8894
|
if (!TASK_OWNER_ROLES.has(cfg.role))
|
|
9489
|
-
throw new ApiError(400,
|
|
8895
|
+
throw new ApiError(400, 'Agent is not a dev agent');
|
|
9490
8896
|
const group = this.findAgentGroup(cfg.id);
|
|
9491
8897
|
const dev = group?.find(agent => agent.role === 'dev');
|
|
9492
8898
|
if (!dev)
|
|
9493
8899
|
throw new ApiError(409, `Agent ${cfg.id} has no dev agent in its group`);
|
|
8900
|
+
const qa = group?.find(agent => agent.role === 'qa');
|
|
8901
|
+
if (!qa)
|
|
8902
|
+
throw new ApiError(409, `Agent ${cfg.id} has no qa agent in its group`);
|
|
9494
8903
|
task.preferredAgentId = patch.preferredAgentId;
|
|
9495
8904
|
task.devAgentId = dev.id;
|
|
9496
|
-
|
|
9497
|
-
|
|
9498
|
-
task.researchAgentId = cfg.id;
|
|
9499
|
-
}
|
|
9500
|
-
else {
|
|
9501
|
-
delete task.phase;
|
|
9502
|
-
delete task.researchAgentId;
|
|
9503
|
-
}
|
|
9504
|
-
const qaId = group?.find(agent => agent.role === 'qa')?.id;
|
|
9505
|
-
if (qaId) {
|
|
9506
|
-
task.qaAgentId = qaId;
|
|
9507
|
-
}
|
|
9508
|
-
else {
|
|
9509
|
-
delete task.qaAgentId;
|
|
9510
|
-
}
|
|
8905
|
+
task.qaAgentId = qa.id;
|
|
8906
|
+
delete task.phase;
|
|
9511
8907
|
}
|
|
9512
8908
|
}
|
|
9513
8909
|
task.updatedAt = new Date().toISOString();
|
|
@@ -9520,72 +8916,32 @@ export class AgentManager {
|
|
|
9520
8916
|
if (!task || !task.prNumber) {
|
|
9521
8917
|
throw new Error(`mergePr: no PR number for task ${taskId}`);
|
|
9522
8918
|
}
|
|
9523
|
-
if (task.reviewMode === 'git') {
|
|
9524
|
-
const expectedHeadSha = opts.matchHeadSha ?? task.latestHeadSha;
|
|
9525
|
-
if (!expectedHeadSha)
|
|
9526
|
-
throw new Error(`mergePr: no expected head sha for git task ${taskId}`);
|
|
9527
|
-
await this.platformConfirmMerge(taskId, {
|
|
9528
|
-
expectedHeadSha,
|
|
9529
|
-
...(opts.humanOverride === true || task.status === 'max_rounds' ? { humanOverride: true } : {}),
|
|
9530
|
-
});
|
|
9531
|
-
return;
|
|
9532
|
-
}
|
|
9533
8919
|
const expectedHeadSha = opts.matchHeadSha ?? task.latestHeadSha;
|
|
9534
|
-
if (!expectedHeadSha
|
|
9535
|
-
throw new Error(`mergePr: no
|
|
9536
|
-
|
|
9537
|
-
|
|
8920
|
+
if (!expectedHeadSha)
|
|
8921
|
+
throw new Error(`mergePr: no expected head sha for task ${taskId}`);
|
|
8922
|
+
await this.platformConfirmMerge(taskId, {
|
|
8923
|
+
expectedHeadSha,
|
|
8924
|
+
...(opts.humanOverride === true || task.status === 'max_rounds' ? { humanOverride: true } : {}),
|
|
8925
|
+
});
|
|
9538
8926
|
}
|
|
9539
8927
|
async markTaskComplete(taskId) {
|
|
9540
8928
|
const peek = await this.taskStore.get(taskId);
|
|
9541
8929
|
if (!peek)
|
|
9542
8930
|
throw new ApiError(404, `Task ${taskId} not found`);
|
|
9543
|
-
if (peek.status === '
|
|
8931
|
+
if (peek.status === 'merge-ready') {
|
|
9544
8932
|
return this.confirmHumanGate(taskId);
|
|
9545
8933
|
}
|
|
9546
|
-
const task = await this.claimCompleteGate(taskId, ['max_rounds'
|
|
8934
|
+
const task = await this.claimCompleteGate(taskId, ['max_rounds']);
|
|
9547
8935
|
try {
|
|
9548
|
-
|
|
9549
|
-
if (!serverApprovedRetry && task.status !== 'max_rounds') {
|
|
8936
|
+
if (task.status !== 'max_rounds') {
|
|
9550
8937
|
throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
|
|
9551
8938
|
}
|
|
9552
8939
|
if (isSpecStagePhase(task.phase)) {
|
|
9553
8940
|
throw new ApiError(409, `Mark complete is only supported for code-phase tasks`);
|
|
9554
8941
|
}
|
|
9555
|
-
if (
|
|
8942
|
+
if (!task.prNumber || !task.branch) {
|
|
9556
8943
|
throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot mark complete`);
|
|
9557
8944
|
}
|
|
9558
|
-
if (serverApprovedRetry) {
|
|
9559
|
-
const publishState = this.checkPublishInFlight(taskId, task.publishDispatchedAt);
|
|
9560
|
-
if (publishState === 'live') {
|
|
9561
|
-
throw new ApiError(409, `Task ${taskId} publish is in flight; retry only after it fails`);
|
|
9562
|
-
}
|
|
9563
|
-
if (publishState === 'delivered') {
|
|
9564
|
-
throw new ApiError(409, `Task ${taskId} publish was delivered and is awaiting code-ready; ` +
|
|
9565
|
-
`retry only after it fails (if the publish is verifiably dead, Cancel the task)`);
|
|
9566
|
-
}
|
|
9567
|
-
const afterDone = this.resolveAfterDone(task);
|
|
9568
|
-
if (afterDone === null) {
|
|
9569
|
-
throw new ApiError(409, `Task ${taskId} is approved with no afterDone step; nothing to retry`);
|
|
9570
|
-
}
|
|
9571
|
-
await this.dispatchServerAfterDone(taskId, afterDone);
|
|
9572
|
-
return (await this.taskStore.get(taskId));
|
|
9573
|
-
}
|
|
9574
|
-
if (task.reviewMode === 'server') {
|
|
9575
|
-
const afterDone = await this.commitServerAfterDone(taskId);
|
|
9576
|
-
if (afterDone === null) {
|
|
9577
|
-
const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['max_rounds'] });
|
|
9578
|
-
if (!done)
|
|
9579
|
-
throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
|
|
9580
|
-
await this.releaseTaskAgents(taskId);
|
|
9581
|
-
return (await this.taskStore.get(taskId));
|
|
9582
|
-
}
|
|
9583
|
-
const approved = await this.transitionTaskStatus(taskId, 'approved', { fromStatus: ['max_rounds'] });
|
|
9584
|
-
if (!approved)
|
|
9585
|
-
throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
|
|
9586
|
-
await this.dispatchServerAfterDone(taskId, afterDone);
|
|
9587
|
-
return (await this.taskStore.get(taskId));
|
|
9588
|
-
}
|
|
9589
8945
|
for (const agentId of [task.agentId, task.qaAgentId]) {
|
|
9590
8946
|
if (!agentId)
|
|
9591
8947
|
continue;
|
|
@@ -9966,15 +9322,6 @@ export class AgentManager {
|
|
|
9966
9322
|
stopPhaseSignalWatcher(taskId) {
|
|
9967
9323
|
this.phaseSignalWatcher?.stop(taskId);
|
|
9968
9324
|
}
|
|
9969
|
-
checkPublishInFlight(taskId, publishDispatchedAt) {
|
|
9970
|
-
if (this.phaseSignalWatcher?.expectedKindsFor(taskId).has('code-ready')) {
|
|
9971
|
-
if (!this.phaseSignalWatcher.isRecovered(taskId) || publishDispatchedAt)
|
|
9972
|
-
return 'live';
|
|
9973
|
-
this.phaseSignalWatcher.stop(taskId);
|
|
9974
|
-
return false;
|
|
9975
|
-
}
|
|
9976
|
-
return publishDispatchedAt ? 'delivered' : false;
|
|
9977
|
-
}
|
|
9978
9325
|
async setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, opts = {}) {
|
|
9979
9326
|
if (!this.phaseSignalWatcher)
|
|
9980
9327
|
return true;
|
|
@@ -10012,7 +9359,6 @@ export class AgentManager {
|
|
|
10012
9359
|
...(opts.onlyReplaceOwnToken ? { onlyReplaceOwnToken: true } : {}),
|
|
10013
9360
|
...(opts.replaceFromToken ? { replaceFromToken: opts.replaceFromToken } : {}),
|
|
10014
9361
|
...(opts.replaceScope ? { replaceScope: opts.replaceScope } : {}),
|
|
10015
|
-
...(opts.onReadFile ? { onReadFile: opts.onReadFile } : {}),
|
|
10016
9362
|
...(opts.needInputMode ? { needInputMode: opts.needInputMode } : {}),
|
|
10017
9363
|
});
|
|
10018
9364
|
}
|
|
@@ -10021,14 +9367,12 @@ export class AgentManager {
|
|
|
10021
9367
|
return false;
|
|
10022
9368
|
}
|
|
10023
9369
|
}
|
|
10024
|
-
async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false
|
|
9370
|
+
async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false) {
|
|
10025
9371
|
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, {
|
|
10026
9372
|
skipSnapshot,
|
|
10027
9373
|
// Every post-dispatch arm follows a freshly injected prompt: the previous
|
|
10028
|
-
// question is superseded, its watermark must not carry over
|
|
10029
|
-
// continuations included).
|
|
9374
|
+
// question is superseded, its watermark must not carry over.
|
|
10030
9375
|
needInputMode: 'fresh',
|
|
10031
|
-
...(onReadFile ? { onReadFile } : {}),
|
|
10032
9376
|
});
|
|
10033
9377
|
if (!armed)
|
|
10034
9378
|
await this.holdAgentForUnarmedSignal(taskId, agentId, expectedKinds);
|
|
@@ -10037,14 +9381,12 @@ export class AgentManager {
|
|
|
10037
9381
|
const label = (Array.isArray(expectedKinds) ? [...expectedKinds] : [expectedKinds]).join(',');
|
|
10038
9382
|
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 });
|
|
10039
9383
|
}
|
|
10040
|
-
dispatchTokenFields(
|
|
10041
|
-
|
|
10042
|
-
&& this.devInitialSignalKinds(task.reviewMode).includes('pr-created');
|
|
10043
|
-
return { signalToken, ...(wantsPrCreated ? { pendingPrSignalToken: signalToken } : {}) };
|
|
9384
|
+
dispatchTokenFields(signalToken) {
|
|
9385
|
+
return { signalToken, pendingPrSignalToken: signalToken };
|
|
10044
9386
|
}
|
|
10045
9387
|
async rearmGitReconciliationWatcher(taskId, opts = {}) {
|
|
10046
9388
|
const task = await this.taskStore.get(taskId);
|
|
10047
|
-
if (!task || task.
|
|
9389
|
+
if (!task || task.status !== 'review')
|
|
10048
9390
|
return;
|
|
10049
9391
|
if (task.replyActorStatus === 'verified' || task.pendingPrSignalToken === undefined)
|
|
10050
9392
|
return;
|
|
@@ -10060,21 +9402,20 @@ export class AgentManager {
|
|
|
10060
9402
|
const result = await this.withTaskLock(async () => {
|
|
10061
9403
|
const task = await this.taskStore.get(taskId);
|
|
10062
9404
|
if (!task)
|
|
10063
|
-
return { token: newToken, armed: false
|
|
10064
|
-
|
|
10065
|
-
|
|
10066
|
-
throw new Error('git phase-signal rotation requires a status/token guard');
|
|
9405
|
+
return { token: newToken, armed: false };
|
|
9406
|
+
if (guard === undefined) {
|
|
9407
|
+
throw new Error('phase-signal rotation requires a status/token guard');
|
|
10067
9408
|
}
|
|
10068
|
-
if (
|
|
10069
|
-
|| (guard.expectedToken !== undefined && task.signalToken !== guard.expectedToken))
|
|
10070
|
-
return { token: newToken, armed: false
|
|
9409
|
+
if (task.status !== guard.expectedStatus
|
|
9410
|
+
|| (guard.expectedToken !== undefined && task.signalToken !== guard.expectedToken)) {
|
|
9411
|
+
return { token: newToken, armed: false };
|
|
10071
9412
|
}
|
|
10072
9413
|
await this.taskStore.set({ ...task, signalToken: newToken, updatedAt: new Date().toISOString() });
|
|
10073
9414
|
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, newToken, {
|
|
10074
9415
|
needInputMode: 'fresh',
|
|
10075
|
-
|
|
9416
|
+
replaceScope: 'agent',
|
|
10076
9417
|
});
|
|
10077
|
-
return { token: newToken, armed
|
|
9418
|
+
return { token: newToken, armed };
|
|
10078
9419
|
});
|
|
10079
9420
|
return { token: result.token, armed: result.armed };
|
|
10080
9421
|
}
|
|
@@ -10085,13 +9426,19 @@ export class AgentManager {
|
|
|
10085
9426
|
const transition = await this.transitionTaskStatus(taskId, 'spec-ready', {
|
|
10086
9427
|
fromStatus: ['review', 'in_progress', 'fixing'],
|
|
10087
9428
|
...(opts.expectedTask !== undefined ? { expectTask: opts.expectedTask } : {}),
|
|
9429
|
+
...(opts.expectedSignalToken !== undefined ? { expectSignalToken: opts.expectedSignalToken } : {}),
|
|
10088
9430
|
}, {
|
|
10089
9431
|
phase: 'spec',
|
|
10090
9432
|
...(opts.specReviewRound !== undefined ? { specReviewRound: opts.specReviewRound } : {}),
|
|
9433
|
+
...(opts.prNumber !== undefined ? { prNumber: opts.prNumber } : {}),
|
|
9434
|
+
...(opts.prUrl !== undefined ? { prUrl: opts.prUrl } : {}),
|
|
9435
|
+
...(opts.headSha !== undefined ? { latestHeadSha: opts.headSha } : {}),
|
|
9436
|
+
...(opts.passProvenance !== undefined ? { passProvenance: opts.passProvenance } : {}),
|
|
10091
9437
|
});
|
|
10092
9438
|
if (!transition)
|
|
10093
9439
|
return null;
|
|
10094
9440
|
const parked = transition.task;
|
|
9441
|
+
const qaAgentId = requireTaskQaAgentId(parked, 'parkTaskAtSpecReady');
|
|
10095
9442
|
const parkedGeneration = {
|
|
10096
9443
|
status: parked.status,
|
|
10097
9444
|
phase: parked.phase,
|
|
@@ -10112,17 +9459,15 @@ export class AgentManager {
|
|
|
10112
9459
|
});
|
|
10113
9460
|
}
|
|
10114
9461
|
}
|
|
10115
|
-
|
|
10116
|
-
|
|
10117
|
-
|
|
10118
|
-
|
|
10119
|
-
|
|
10120
|
-
|
|
10121
|
-
|
|
10122
|
-
|
|
10123
|
-
|
|
10124
|
-
});
|
|
10125
|
-
}
|
|
9462
|
+
const released = await this.releaseAgentForTask(qaAgentId, taskId, 'idle', {
|
|
9463
|
+
expectedTask: parkedGeneration,
|
|
9464
|
+
})
|
|
9465
|
+
.catch(() => false);
|
|
9466
|
+
if (!released) {
|
|
9467
|
+
await this.emitIntervention(parked.projectId, qaAgentId, taskId, {
|
|
9468
|
+
phase: 'spec-ready-qa-release-failed',
|
|
9469
|
+
qaAgentId,
|
|
9470
|
+
});
|
|
10126
9471
|
}
|
|
10127
9472
|
return parked;
|
|
10128
9473
|
}
|
|
@@ -10135,12 +9480,17 @@ export class AgentManager {
|
|
|
10135
9480
|
const fresh = await this.taskStore.get(taskId);
|
|
10136
9481
|
if (!fresh)
|
|
10137
9482
|
throw new ApiError(404, `Task ${taskId} not found`);
|
|
10138
|
-
const
|
|
10139
|
-
if (
|
|
10140
|
-
|
|
9483
|
+
const pending = this.specVerdictOutbox(fresh);
|
|
9484
|
+
if (pending !== undefined) {
|
|
9485
|
+
if (verdict !== 'request-changes' || pending.data.comments !== trimmed) {
|
|
9486
|
+
throw new ApiError(409, `Task ${taskId} has a different spec verdict already in progress`);
|
|
9487
|
+
}
|
|
10141
9488
|
}
|
|
10142
|
-
|
|
10143
|
-
|
|
9489
|
+
else {
|
|
9490
|
+
const specMaxRounds = fresh.status === 'max_rounds' && isSpecStagePhase(fresh.phase);
|
|
9491
|
+
if (fresh.status !== 'spec-ready' && !specMaxRounds) {
|
|
9492
|
+
throw new ApiError(409, `Task ${taskId} is ${fresh.status}; spec verdict requires spec-ready or spec-phase max_rounds`);
|
|
9493
|
+
}
|
|
10144
9494
|
}
|
|
10145
9495
|
if (this.specVerdictInFlight.has(taskId)) {
|
|
10146
9496
|
throw new ApiError(409, `Task ${taskId} spec verdict is already being processed`);
|
|
@@ -10149,322 +9499,274 @@ export class AgentManager {
|
|
|
10149
9499
|
return fresh;
|
|
10150
9500
|
});
|
|
10151
9501
|
try {
|
|
10152
|
-
return await this.
|
|
9502
|
+
return await this.executeGitSpecVerdict(task, verdict, trimmed);
|
|
10153
9503
|
}
|
|
10154
9504
|
finally {
|
|
10155
9505
|
this.specVerdictInFlight.delete(taskId);
|
|
10156
9506
|
}
|
|
10157
9507
|
}
|
|
10158
|
-
|
|
10159
|
-
const
|
|
10160
|
-
|
|
10161
|
-
|
|
10162
|
-
|
|
10163
|
-
|
|
10164
|
-
|
|
10165
|
-
|
|
10166
|
-
if (!roundData || roundData.phase !== 'spec') {
|
|
10167
|
-
throw new ApiError(409, `Task ${taskId} has no persisted spec review round ${round}`);
|
|
10168
|
-
}
|
|
10169
|
-
const base = roundData;
|
|
10170
|
-
if (verdict === 'archive') {
|
|
10171
|
-
await store.putRound(taskId, 'spec', {
|
|
10172
|
-
...base,
|
|
10173
|
-
userDecision: { verdict, ...(trimmed ? { comments: trimmed } : {}), at },
|
|
10174
|
-
});
|
|
10175
|
-
const transition = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['spec-ready', 'max_rounds'] });
|
|
10176
|
-
if (!transition)
|
|
10177
|
-
throw new ApiError(409, `Task ${taskId} changed while archiving`);
|
|
10178
|
-
this.stopPhaseSignalWatcher(taskId);
|
|
10179
|
-
const participants = new Set([
|
|
10180
|
-
task.agentId,
|
|
10181
|
-
task.devAgentId,
|
|
10182
|
-
task.qaAgentId,
|
|
10183
|
-
task.researchAgentId,
|
|
10184
|
-
].filter((id) => typeof id === 'string' && id !== ''));
|
|
10185
|
-
for (const agentId of participants) {
|
|
10186
|
-
const state = await this.agentStore.get(agentId);
|
|
10187
|
-
if (state?.taskId !== taskId)
|
|
10188
|
-
continue;
|
|
10189
|
-
const released = await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true }).catch(() => false);
|
|
10190
|
-
if (!released) {
|
|
10191
|
-
await this.emitIntervention(task.projectId, agentId, taskId, {
|
|
10192
|
-
phase: 'spec-archive-agent-release-failed',
|
|
10193
|
-
agentId,
|
|
10194
|
-
});
|
|
10195
|
-
}
|
|
10196
|
-
}
|
|
10197
|
-
return (await this.taskStore.get(taskId));
|
|
10198
|
-
}
|
|
10199
|
-
if (verdict === 'approve') {
|
|
10200
|
-
await store.putRound(taskId, 'spec', { ...base, userDecision: { verdict, at } });
|
|
10201
|
-
let result;
|
|
10202
|
-
try {
|
|
10203
|
-
result = await this.transitionToCodePhase(taskId);
|
|
10204
|
-
}
|
|
10205
|
-
catch (err) {
|
|
10206
|
-
if (err instanceof ApiError)
|
|
10207
|
-
throw err;
|
|
10208
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
10209
|
-
const after = await this.taskStore.get(taskId);
|
|
10210
|
-
if (after?.status === 'failed') {
|
|
10211
|
-
const reason = err instanceof DispatchTerminalError ? ` (${err.reason})` : '';
|
|
10212
|
-
throw new ApiError(500, `Code-phase dispatch failed terminally for task ${taskId}${reason}: ${message}. `
|
|
10213
|
-
+ 'The task has been marked failed; use Retry to run it as a fresh task.');
|
|
10214
|
-
}
|
|
10215
|
-
if (after?.phase === 'code' && after.status === 'in_progress') {
|
|
10216
|
-
throw new ApiError(500, `Code-phase dispatch failed for task ${taskId}: ${message}. `
|
|
10217
|
-
+ 'The task already moved to the code phase and the dev is held; '
|
|
10218
|
-
+ 'Resume the dev agent to redeliver the prompt.');
|
|
10219
|
-
}
|
|
10220
|
-
throw new ApiError(500, `Code-phase dispatch failed for task ${taskId}: ${message}. `
|
|
10221
|
-
+ 'The task was rolled back to await the verdict; fix the dev Workdir '
|
|
10222
|
-
+ '(Resume the dev first if it is still held), then retry the verdict.');
|
|
10223
|
-
}
|
|
10224
|
-
if (!result) {
|
|
10225
|
-
const after = await this.taskStore.get(taskId);
|
|
10226
|
-
if (after?.phase === 'code' && after.status === 'in_progress') {
|
|
10227
|
-
throw new ApiError(500, `Code-phase prompt was not delivered for task ${taskId}; the task already moved to the `
|
|
10228
|
-
+ 'code phase and the dev is held. Resume the dev agent to redeliver the prompt '
|
|
10229
|
-
+ '(retrying the verdict will be rejected).');
|
|
10230
|
-
}
|
|
10231
|
-
throw new ApiError(409, `Dev is unavailable for task ${taskId}; approval was recorded and can be retried`);
|
|
10232
|
-
}
|
|
10233
|
-
return result;
|
|
10234
|
-
}
|
|
10235
|
-
if (!trimmed)
|
|
10236
|
-
throw new ApiError(400, 'comments is required for request-changes');
|
|
10237
|
-
const priorFindings = base.findings?.findings ?? [];
|
|
10238
|
-
const nextUserId = `u-${priorFindings.filter(f => f.id.startsWith('u-')).length + 1}`;
|
|
10239
|
-
const mergedFindings = {
|
|
10240
|
-
round,
|
|
10241
|
-
verdict: 'request-changes',
|
|
10242
|
-
findings: [...priorFindings, { id: nextUserId, severity: 'major', message: trimmed }],
|
|
10243
|
-
};
|
|
10244
|
-
const rejectedRound = {
|
|
10245
|
-
...base,
|
|
10246
|
-
findings: mergedFindings,
|
|
10247
|
-
userDecision: { verdict, comments: trimmed, at },
|
|
10248
|
-
};
|
|
10249
|
-
// 到达上限后用户仍显式打回 = 对「再评一轮」的当场决策:扩 cap 而非拒绝。
|
|
10250
|
-
// holder 恢复与扩 cap 正交:max_rounds 入态时 holder 已被释放清空,即便 cap
|
|
10251
|
-
// 因配置热更新出现余量,打回前也必须先恢复 holder
|
|
10252
|
-
const cap = this.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
|
|
10253
|
-
const claim = task.status === 'max_rounds' || round >= cap
|
|
10254
|
-
? await this.claimSpecFixDispatch(taskId, { extendCapToFit: round >= cap ? round : null })
|
|
10255
|
-
: null;
|
|
10256
|
-
let dispatched = null;
|
|
9508
|
+
specVerdictCommentBody(entry) {
|
|
9509
|
+
const body = [
|
|
9510
|
+
'Human spec verdict: request changes',
|
|
9511
|
+
'',
|
|
9512
|
+
entry.data.comments,
|
|
9513
|
+
'',
|
|
9514
|
+
`<!-- baxian:spec-verdict:${entry.key} -->`,
|
|
9515
|
+
].join('\n');
|
|
10257
9516
|
try {
|
|
10258
|
-
|
|
10259
|
-
dispatched = await this.dispatchServerFixToDev(taskId, JSON.stringify(mergedFindings));
|
|
9517
|
+
validateCommentBody(body);
|
|
10260
9518
|
}
|
|
10261
9519
|
catch (err) {
|
|
10262
|
-
|
|
10263
|
-
throw err;
|
|
10264
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
10265
|
-
const after = await this.taskStore.get(taskId);
|
|
10266
|
-
if (after?.status === 'failed') {
|
|
10267
|
-
const reason = err instanceof DispatchTerminalError ? ` (${err.reason})` : '';
|
|
10268
|
-
throw new ApiError(500, `Spec fix dispatch failed terminally for task ${taskId}${reason}: ${message}. `
|
|
10269
|
-
+ 'The task has been marked failed with the rejection kept on record; '
|
|
10270
|
-
+ 'use Retry to run it as a fresh task.');
|
|
10271
|
-
}
|
|
10272
|
-
if (after?.status === 'fixing') {
|
|
10273
|
-
throw new ApiError(500, `Spec fix dispatch was interrupted for task ${taskId}: ${message}. `
|
|
10274
|
-
+ 'The task moved to fixing with the rejection recorded; resume the holder agent to redeliver it.');
|
|
10275
|
-
}
|
|
10276
|
-
throw new ApiError(500, `Spec fix dispatch failed for task ${taskId}: ${message}. `
|
|
10277
|
-
+ 'The rejection was rolled back; fix the agent runtime or configuration, then retry.');
|
|
9520
|
+
throw new ApiError(400, `Invalid spec verdict comment: ${err instanceof Error ? err.message : String(err)}`);
|
|
10278
9521
|
}
|
|
10279
|
-
|
|
10280
|
-
if (!dispatched)
|
|
10281
|
-
await this.recoverSpecRejectionState(taskId, claim, base);
|
|
10282
|
-
}
|
|
10283
|
-
if (!dispatched)
|
|
10284
|
-
throw new ApiError(500, `Failed to dispatch spec fix for task ${taskId}`);
|
|
10285
|
-
return dispatched;
|
|
9522
|
+
return body;
|
|
10286
9523
|
}
|
|
10287
|
-
async
|
|
10288
|
-
|
|
10289
|
-
|
|
10290
|
-
|
|
10291
|
-
|
|
10292
|
-
|
|
10293
|
-
throw new ApiError(409, `Task ${taskId} changed status during the spec verdict; aborted`);
|
|
10294
|
-
}
|
|
10295
|
-
const holder = fresh.agentId || fresh.researchAgentId || fresh.devAgentId;
|
|
10296
|
-
if (!holder || !this.getAgentConfig(holder)) {
|
|
10297
|
-
throw new ApiError(409, `Task ${taskId} spec holder agent ${holder || '(none)'} is not in the current config; `
|
|
10298
|
-
+ 'restore the agent config or cancel the task');
|
|
10299
|
-
}
|
|
10300
|
-
const originalAgentId = fresh.agentId;
|
|
10301
|
-
const originalCounter = fresh.maxRoundsContinues ?? 0;
|
|
10302
|
-
let counterAfter = originalCounter;
|
|
10303
|
-
if (opts.extendCapToFit !== null) {
|
|
10304
|
-
// cap 必须容下下一轮(round+1);rounds 被调低时一次 +1 可能不够
|
|
10305
|
-
counterAfter = Math.max(originalCounter + 1, opts.extendCapToFit + 1 - this.getConfig().review.rounds);
|
|
10306
|
-
fresh.maxRoundsContinues = counterAfter;
|
|
10307
|
-
}
|
|
10308
|
-
fresh.agentId = holder;
|
|
10309
|
-
fresh.updatedAt = new Date().toISOString();
|
|
10310
|
-
await this.taskStore.set(fresh);
|
|
10311
|
-
return { originalAgentId, originalCounter, holderWritten: holder, counterAfter };
|
|
10312
|
-
});
|
|
9524
|
+
async reconcileSpecVerdictComment(driver, entry) {
|
|
9525
|
+
const expectedDigest = bodyDigest(this.specVerdictCommentBody(entry));
|
|
9526
|
+
const scans = await this.platformScanComments(driver, entry.data.prNumber);
|
|
9527
|
+
if (scans.some(scan => scan.rows.some(row => row.bodyDigest === expectedDigest)))
|
|
9528
|
+
return 'present';
|
|
9529
|
+
return scans.every(scan => scan.ok) ? 'absent' : 'unknown';
|
|
10313
9530
|
}
|
|
10314
|
-
|
|
10315
|
-
|
|
10316
|
-
|
|
10317
|
-
const
|
|
10318
|
-
|
|
10319
|
-
|
|
10320
|
-
|
|
10321
|
-
|
|
10322
|
-
|
|
10323
|
-
|
|
10324
|
-
|
|
10325
|
-
|
|
10326
|
-
|
|
10327
|
-
|
|
10328
|
-
|
|
10329
|
-
|
|
10330
|
-
|
|
10331
|
-
|
|
9531
|
+
async settleSpecVerdictOutbox(taskId, key) {
|
|
9532
|
+
const task = await this.taskStore.get(taskId);
|
|
9533
|
+
const entry = task ? this.specVerdictOutbox(task) : undefined;
|
|
9534
|
+
const round = task?.specReviewRound;
|
|
9535
|
+
if (!task || entry?.key !== key || round === undefined || round < 1)
|
|
9536
|
+
return null;
|
|
9537
|
+
const holder = task.agentId || task.devAgentId;
|
|
9538
|
+
if (!holder || this.getAgentConfig(holder)?.role !== 'dev') {
|
|
9539
|
+
throw new ApiError(409, `Task ${taskId} spec holder agent ${holder || '(none)'} is not in the current config; restore the agent config or cancel the task`);
|
|
9540
|
+
}
|
|
9541
|
+
const maxRoundsContinues = Math.max(task.maxRoundsContinues ?? 0, round + 1 - this.getConfig().review.rounds);
|
|
9542
|
+
return (await this.transitionTaskStatus(taskId, 'fixing', {
|
|
9543
|
+
fromStatus: ['spec-ready', 'max_rounds'],
|
|
9544
|
+
expectTask: taskGenerationGuard(task),
|
|
9545
|
+
expectOutbox: { key, type: 'git.spec-verdict' },
|
|
9546
|
+
}, {
|
|
9547
|
+
agentId: holder,
|
|
9548
|
+
specReviewRound: round + 1,
|
|
9549
|
+
maxRoundsContinues,
|
|
9550
|
+
fixDispatchedAt: new Date().toISOString(),
|
|
9551
|
+
}))?.task ?? null;
|
|
9552
|
+
}
|
|
9553
|
+
async deliverSpecVerdictOutbox(taskId, key) {
|
|
9554
|
+
let task = await this.taskStore.get(taskId);
|
|
9555
|
+
let entry = task ? this.specVerdictOutbox(task) : undefined;
|
|
9556
|
+
if (!task || !entry || entry.key !== key)
|
|
9557
|
+
return;
|
|
9558
|
+
const driver = this.platformDriverFor(task.projectId);
|
|
9559
|
+
if (!driver)
|
|
9560
|
+
throw new ApiError(503, `Git driver unavailable for task ${task.id}`);
|
|
9561
|
+
if (entry.data.writeAttemptedAt !== undefined) {
|
|
9562
|
+
const reconciled = await this.reconcileSpecVerdictComment(driver, entry);
|
|
9563
|
+
if (reconciled === 'present') {
|
|
9564
|
+
const settled = await this.settleSpecVerdictOutbox(task.id, entry.key);
|
|
9565
|
+
if (!settled)
|
|
10332
9566
|
return;
|
|
10333
|
-
|
|
10334
|
-
fresh.maxRoundsContinues = claim.originalCounter;
|
|
10335
|
-
fresh.agentId = claim.originalAgentId;
|
|
10336
|
-
fresh.updatedAt = new Date().toISOString();
|
|
10337
|
-
await this.taskStore.set(fresh);
|
|
10338
|
-
}
|
|
10339
|
-
await store?.putRound(taskId, 'spec', baseRound);
|
|
10340
|
-
}).catch(err => {
|
|
10341
|
-
console.error(`[AgentManager] spec rejection recovery(${taskId}) failed:`, err);
|
|
10342
|
-
});
|
|
10343
|
-
}
|
|
10344
|
-
async submitCodeVerdict(taskId, comments) {
|
|
10345
|
-
const trimmed = comments?.trim();
|
|
10346
|
-
if (!trimmed) {
|
|
10347
|
-
throw new ApiError(400, 'comments is required for request-changes');
|
|
10348
|
-
}
|
|
10349
|
-
const task = await this.withTaskLock(async () => {
|
|
10350
|
-
const fresh = await this.taskStore.get(taskId);
|
|
10351
|
-
if (!fresh)
|
|
10352
|
-
throw new ApiError(404, `Task ${taskId} not found`);
|
|
10353
|
-
if (fresh.status !== 'ready') {
|
|
10354
|
-
throw new ApiError(409, `Task ${taskId} is ${fresh.status}; code verdict requires ready`);
|
|
9567
|
+
task = settled;
|
|
10355
9568
|
}
|
|
10356
|
-
if (
|
|
10357
|
-
|
|
9569
|
+
else if (reconciled === 'unknown'
|
|
9570
|
+
|| Date.now() - Date.parse(entry.data.writeAttemptedAt) < driver.visibilityLagMs) {
|
|
9571
|
+
throw new ApiError(503, `Spec verdict comment outcome is still being reconciled for task ${task.id}`);
|
|
10358
9572
|
}
|
|
10359
|
-
|
|
10360
|
-
|
|
9573
|
+
}
|
|
9574
|
+
if (task.status !== 'fixing') {
|
|
9575
|
+
const expectedAttempt = entry.data.writeAttemptedAt;
|
|
9576
|
+
const claimed = await this.mutateTaskOutboxEntry(task.id, entry.key, current => current.type === 'git.spec-verdict' && current.data.writeAttemptedAt === expectedAttempt
|
|
9577
|
+
? { ...current, data: { ...current.data, writeAttemptedAt: new Date().toISOString() } }
|
|
9578
|
+
: null);
|
|
9579
|
+
const claimedEntry = claimed ? this.specVerdictOutbox(claimed) : undefined;
|
|
9580
|
+
if (!claimed || claimedEntry?.key !== entry.key) {
|
|
9581
|
+
throw new ApiError(503, `Spec verdict comment write is already claimed for task ${task.id}`);
|
|
10361
9582
|
}
|
|
10362
|
-
|
|
10363
|
-
|
|
9583
|
+
task = claimed;
|
|
9584
|
+
entry = claimedEntry;
|
|
9585
|
+
let writeFailure;
|
|
9586
|
+
try {
|
|
9587
|
+
await driver.runOp('comment', {
|
|
9588
|
+
prNumber: entry.data.prNumber,
|
|
9589
|
+
body: this.specVerdictCommentBody(entry),
|
|
9590
|
+
});
|
|
10364
9591
|
}
|
|
10365
|
-
|
|
10366
|
-
|
|
9592
|
+
catch (error) {
|
|
9593
|
+
writeFailure = { error };
|
|
9594
|
+
}
|
|
9595
|
+
const reconciled = await this.reconcileSpecVerdictComment(driver, entry);
|
|
9596
|
+
if (writeFailure !== undefined) {
|
|
9597
|
+
const { error } = writeFailure;
|
|
9598
|
+
if (reconciled !== 'present') {
|
|
9599
|
+
const uncertain = !(error instanceof DriverOpError)
|
|
9600
|
+
|| error.info.exitCode === 255
|
|
9601
|
+
|| isTransientNetworkFailure(error.info.stderrTail ?? '')
|
|
9602
|
+
|| isTransientNetworkFailure(error.message);
|
|
9603
|
+
if (reconciled === 'unknown' || uncertain) {
|
|
9604
|
+
throw new ApiError(503, `Spec verdict comment outcome is unknown for task ${task.id}; the persisted outbox will reconcile it`);
|
|
9605
|
+
}
|
|
9606
|
+
await this.mutateTaskOutboxEntry(task.id, key, current => current.type === 'git.spec-verdict' ? undefined : null);
|
|
9607
|
+
throw new ApiError(502, `Spec verdict comment was rejected for task ${task.id}: ${safeDriverErrorText(error)}`);
|
|
9608
|
+
}
|
|
10367
9609
|
}
|
|
10368
|
-
if (
|
|
10369
|
-
throw new ApiError(
|
|
9610
|
+
if (reconciled !== 'present') {
|
|
9611
|
+
throw new ApiError(503, `Spec verdict comment is not yet visible for task ${task.id}; the persisted outbox will reconcile it`);
|
|
10370
9612
|
}
|
|
10371
|
-
this.
|
|
10372
|
-
|
|
10373
|
-
|
|
10374
|
-
|
|
10375
|
-
return await this.executeCodeVerdict(task, trimmed);
|
|
9613
|
+
const settled = await this.settleSpecVerdictOutbox(task.id, entry.key);
|
|
9614
|
+
if (!settled)
|
|
9615
|
+
return;
|
|
9616
|
+
task = settled;
|
|
10376
9617
|
}
|
|
10377
|
-
|
|
10378
|
-
|
|
9618
|
+
const dispatched = await this.dispatchGitFixToDev(task.id);
|
|
9619
|
+
const fresh = (await this.taskStore.get(task.id)) ?? task;
|
|
9620
|
+
if (!dispatched) {
|
|
9621
|
+
if (fresh.status === 'failed') {
|
|
9622
|
+
throw new ApiError(500, `Spec fix dispatch failed terminally for task ${task.id}; use Retry`);
|
|
9623
|
+
}
|
|
9624
|
+
throw new ApiError(500, `Spec fix dispatch is pending for task ${task.id}; the fixing-state reconciler will retry it`);
|
|
10379
9625
|
}
|
|
10380
9626
|
}
|
|
10381
|
-
async
|
|
10382
|
-
|
|
10383
|
-
|
|
10384
|
-
|
|
10385
|
-
|
|
10386
|
-
|
|
10387
|
-
|
|
10388
|
-
|
|
10389
|
-
|
|
10390
|
-
|
|
10391
|
-
|
|
10392
|
-
|
|
10393
|
-
|
|
10394
|
-
|
|
10395
|
-
|
|
10396
|
-
|
|
10397
|
-
|
|
10398
|
-
|
|
10399
|
-
|
|
10400
|
-
|
|
10401
|
-
|
|
10402
|
-
|
|
10403
|
-
|
|
10404
|
-
|
|
10405
|
-
|
|
10406
|
-
|
|
9627
|
+
async executeGitSpecVerdict(task, verdict, trimmed) {
|
|
9628
|
+
if (verdict === 'approve') {
|
|
9629
|
+
const humanOverride = task.status === 'max_rounds';
|
|
9630
|
+
if (task.prNumber === undefined || task.phase !== 'spec'
|
|
9631
|
+
|| task.specReviewRound === undefined || task.specReviewRound < 1
|
|
9632
|
+
|| task.signalToken === undefined
|
|
9633
|
+
|| (!humanOverride && task.passProvenance === undefined)) {
|
|
9634
|
+
throw new ApiError(409, `Task ${task.id} has no complete reviewed spec PR generation`);
|
|
9635
|
+
}
|
|
9636
|
+
const prNumber = task.prNumber;
|
|
9637
|
+
const specReviewRound = task.specReviewRound;
|
|
9638
|
+
const signalToken = task.signalToken;
|
|
9639
|
+
const passProvenance = task.passProvenance;
|
|
9640
|
+
const reviewedHead = passProvenance?.anchorSha;
|
|
9641
|
+
if (!humanOverride && (reviewedHead === undefined || !PLATFORM_HEAD_SHA_RE.test(reviewedHead))) {
|
|
9642
|
+
throw new ApiError(409, `Task ${task.id} has no valid reviewed spec head`);
|
|
9643
|
+
}
|
|
9644
|
+
let verified;
|
|
9645
|
+
try {
|
|
9646
|
+
verified = await this.platformVerifyPrBinding(task.id, prNumber);
|
|
9647
|
+
}
|
|
9648
|
+
catch (err) {
|
|
9649
|
+
throw new ApiError(503, `Cannot verify the current PR head for task ${task.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
9650
|
+
}
|
|
9651
|
+
if (!verified.ok) {
|
|
9652
|
+
throw new ApiError(409, `Cannot approve task ${task.id}: PR binding ${verified.reason}`);
|
|
9653
|
+
}
|
|
9654
|
+
const observed = await this.recordGitPrObservation(task.id, {
|
|
9655
|
+
prNumber,
|
|
9656
|
+
...(task.prUrl !== undefined ? { prUrl: task.prUrl } : {}),
|
|
9657
|
+
headSha: verified.headSha,
|
|
9658
|
+
targetBranch: verified.targetBranch,
|
|
9659
|
+
}, {
|
|
9660
|
+
task: taskGenerationGuard(task),
|
|
9661
|
+
latestHeadSha: task.latestHeadSha,
|
|
10407
9662
|
});
|
|
9663
|
+
if (!observed || !taskMatchesGeneration(observed, taskGenerationGuard(task))) {
|
|
9664
|
+
throw new ApiError(409, `Task ${task.id} changed while validating the current PR head`);
|
|
9665
|
+
}
|
|
9666
|
+
task = observed;
|
|
9667
|
+
if (!humanOverride && verified.headSha.toLowerCase() !== reviewedHead.toLowerCase()) {
|
|
9668
|
+
if (task.status === 'spec-ready') {
|
|
9669
|
+
try {
|
|
9670
|
+
await this.dispatchReviewToQa(task.id, {
|
|
9671
|
+
fromStatus: ['spec-ready'],
|
|
9672
|
+
bumpRound: true,
|
|
9673
|
+
qaPhase: 'recheck',
|
|
9674
|
+
expectSignalToken: signalToken,
|
|
9675
|
+
expectPhase: 'spec',
|
|
9676
|
+
expectedTask: taskGenerationGuard(task),
|
|
9677
|
+
});
|
|
9678
|
+
}
|
|
9679
|
+
catch (err) {
|
|
9680
|
+
throw new ApiError(409, `Task ${task.id} PR head changed after spec review; re-review could not start: ${err instanceof Error ? err.message : String(err)}`);
|
|
9681
|
+
}
|
|
9682
|
+
}
|
|
9683
|
+
throw new ApiError(409, `Task ${task.id} PR head changed after spec review; approve only after the current head is reviewed`);
|
|
9684
|
+
}
|
|
9685
|
+
try {
|
|
9686
|
+
const transitioned = await this.transitionToCodePhase(task.id, taskGenerationGuard(task), {
|
|
9687
|
+
specReviewRound,
|
|
9688
|
+
expectedSignalToken: signalToken,
|
|
9689
|
+
prNumber,
|
|
9690
|
+
...(task.prUrl !== undefined ? { prUrl: task.prUrl } : {}),
|
|
9691
|
+
headSha: verified.headSha,
|
|
9692
|
+
...(humanOverride
|
|
9693
|
+
? { humanOverride: true }
|
|
9694
|
+
: { passProvenance: passProvenance }),
|
|
9695
|
+
});
|
|
9696
|
+
if (transitioned) {
|
|
9697
|
+
if (humanOverride) {
|
|
9698
|
+
await this.safeEmit({
|
|
9699
|
+
id: '',
|
|
9700
|
+
type: 'task.updated',
|
|
9701
|
+
timestamp: new Date().toISOString(),
|
|
9702
|
+
projectId: transitioned.projectId,
|
|
9703
|
+
taskId: transitioned.id,
|
|
9704
|
+
data: {
|
|
9705
|
+
status: transitioned.status,
|
|
9706
|
+
operation: 'git-spec-approve',
|
|
9707
|
+
source: 'human-override',
|
|
9708
|
+
headSha: verified.headSha,
|
|
9709
|
+
},
|
|
9710
|
+
});
|
|
9711
|
+
}
|
|
9712
|
+
return transitioned;
|
|
9713
|
+
}
|
|
9714
|
+
}
|
|
9715
|
+
catch (err) {
|
|
9716
|
+
const after = await this.taskStore.get(task.id);
|
|
9717
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
9718
|
+
if (after?.status === 'failed') {
|
|
9719
|
+
throw new ApiError(500, `Code-phase dispatch failed terminally for task ${task.id}: ${message}. The task has been marked failed; use Retry.`);
|
|
9720
|
+
}
|
|
9721
|
+
throw new ApiError(500, `Code-phase dispatch failed for task ${task.id}: ${message}. Resume the dev agent.`);
|
|
9722
|
+
}
|
|
9723
|
+
throw new ApiError(409, `Task ${task.id} changed before spec approval could advance it`);
|
|
10408
9724
|
}
|
|
10409
|
-
|
|
10410
|
-
|
|
10411
|
-
|
|
10412
|
-
|
|
10413
|
-
|
|
10414
|
-
|
|
10415
|
-
|
|
10416
|
-
|
|
10417
|
-
|
|
10418
|
-
|
|
10419
|
-
|
|
10420
|
-
|
|
10421
|
-
|
|
10422
|
-
|
|
10423
|
-
throw new ApiError(500, `Failed to dispatch code fix for task ${taskId}`);
|
|
10424
|
-
return dispatched;
|
|
10425
|
-
}
|
|
10426
|
-
async synthesizeEmptyCodeRound(task, round, at) {
|
|
10427
|
-
const empty = { round, phase: 'code', content: '', startedAt: at };
|
|
10428
|
-
const dev = task.agentId ? this.getAgentConfig(task.agentId) : undefined;
|
|
10429
|
-
if (!dev || !task.agentId)
|
|
10430
|
-
return empty;
|
|
10431
|
-
// 读取失败不阻断打回:留痕与 fix 闭环优先于 diff 展示
|
|
10432
|
-
try {
|
|
10433
|
-
await this.refreshWorkdirCacheFor(task.agentId);
|
|
10434
|
-
const content = await this.getReviewTransport().readContent(task, dev, 'code');
|
|
10435
|
-
return {
|
|
10436
|
-
round,
|
|
10437
|
-
phase: 'code',
|
|
10438
|
-
content: content.content,
|
|
10439
|
-
...(content.diffstat ? { diffstat: content.diffstat } : {}),
|
|
10440
|
-
...(content.baseSha ? { baseSha: content.baseSha } : {}),
|
|
10441
|
-
startedAt: at,
|
|
9725
|
+
if (!trimmed)
|
|
9726
|
+
throw new ApiError(400, 'comments is required for request-changes');
|
|
9727
|
+
if (task.prNumber === undefined || task.phase !== 'spec') {
|
|
9728
|
+
throw new ApiError(409, `Task ${task.id} has no bound spec PR`);
|
|
9729
|
+
}
|
|
9730
|
+
if (task.specReviewRound === undefined || task.specReviewRound < 1) {
|
|
9731
|
+
throw new ApiError(409, `Task ${task.id} has no completed spec review round`);
|
|
9732
|
+
}
|
|
9733
|
+
let entry = this.specVerdictOutbox(task);
|
|
9734
|
+
if (!entry) {
|
|
9735
|
+
entry = {
|
|
9736
|
+
key: randomBytes(6).toString('hex'),
|
|
9737
|
+
type: 'git.spec-verdict',
|
|
9738
|
+
data: { prNumber: task.prNumber, comments: trimmed },
|
|
10442
9739
|
};
|
|
9740
|
+
this.specVerdictCommentBody(entry);
|
|
9741
|
+
const created = await this.withTaskLock(async () => {
|
|
9742
|
+
const fresh = await this.taskStore.get(task.id);
|
|
9743
|
+
if (!fresh || !taskMatchesGeneration(fresh, taskGenerationGuard(task))
|
|
9744
|
+
|| this.specVerdictOutbox(fresh) !== undefined) {
|
|
9745
|
+
return null;
|
|
9746
|
+
}
|
|
9747
|
+
const next = {
|
|
9748
|
+
...fresh,
|
|
9749
|
+
outbox: [...(fresh.outbox ?? []), entry],
|
|
9750
|
+
updatedAt: new Date().toISOString(),
|
|
9751
|
+
};
|
|
9752
|
+
await this.taskStore.set(next);
|
|
9753
|
+
return next;
|
|
9754
|
+
});
|
|
9755
|
+
if (!created)
|
|
9756
|
+
throw new ApiError(409, `Task ${task.id} changed while recording the spec verdict`);
|
|
9757
|
+
task = created;
|
|
10443
9758
|
}
|
|
10444
|
-
|
|
10445
|
-
|
|
10446
|
-
return empty;
|
|
10447
|
-
}
|
|
10448
|
-
}
|
|
10449
|
-
isResearchHandoff(task, devAgentId) {
|
|
10450
|
-
return task.researchAgentId !== undefined && task.researchAgentId !== devAgentId;
|
|
9759
|
+
await this.deliverSpecVerdictOutbox(task.id, entry.key);
|
|
9760
|
+
return (await this.taskStore.get(task.id)) ?? task;
|
|
10451
9761
|
}
|
|
10452
|
-
dispatchCodePhasePrompt(task, devAgentId, signalToken
|
|
10453
|
-
const expectedKind = task.reviewMode === 'server' ? 'code-done' : 'pr-created';
|
|
9762
|
+
dispatchCodePhasePrompt(task, devAgentId, signalToken) {
|
|
10454
9763
|
const dispatchOpts = {
|
|
10455
9764
|
signalToken,
|
|
10456
|
-
|
|
10457
|
-
...(currentSpecRound !== undefined ? { currentSpecRound } : {}),
|
|
10458
|
-
armBeforeInject: () => this.setupPhaseSignalWatcher(task.id, devAgentId, expectedKind, signalToken, { needInputMode: 'fresh' }),
|
|
9765
|
+
armBeforeInject: () => this.setupPhaseSignalWatcher(task.id, devAgentId, 'pr-created', signalToken, { needInputMode: 'fresh' }),
|
|
10459
9766
|
};
|
|
10460
|
-
return this.
|
|
10461
|
-
? this.startSession(task.id, devAgentId, 'code', {
|
|
10462
|
-
...dispatchOpts,
|
|
10463
|
-
preserveBindingOnFailure: true,
|
|
10464
|
-
})
|
|
10465
|
-
: this.continueSession(task.id, devAgentId, 'code', dispatchOpts);
|
|
9767
|
+
return this.continueSession(task.id, devAgentId, 'code', dispatchOpts);
|
|
10466
9768
|
}
|
|
10467
|
-
async transitionToCodePhase(taskId, expectedTask) {
|
|
9769
|
+
async transitionToCodePhase(taskId, expectedTask, approval) {
|
|
10468
9770
|
const task = await this.withTaskLock(async () => {
|
|
10469
9771
|
const current = await this.taskStore.get(taskId);
|
|
10470
9772
|
if (expectedTask && current && !taskMatchesGeneration(current, expectedTask))
|
|
@@ -10474,6 +9776,7 @@ export class AgentManager {
|
|
|
10474
9776
|
if (!task)
|
|
10475
9777
|
return null;
|
|
10476
9778
|
const devAgentId = task.devAgentId;
|
|
9779
|
+
const qaAgentId = requireTaskQaAgentId(task, 'transitionToCodePhase');
|
|
10477
9780
|
const dev = devAgentId ? this.getAgentConfig(devAgentId) : undefined;
|
|
10478
9781
|
if (!devAgentId || dev?.role !== 'dev') {
|
|
10479
9782
|
await this.emitIntervention(task.projectId, task.agentId, taskId, {
|
|
@@ -10482,17 +9785,6 @@ export class AgentManager {
|
|
|
10482
9785
|
});
|
|
10483
9786
|
return null;
|
|
10484
9787
|
}
|
|
10485
|
-
const round = task.specReviewRound ?? 1;
|
|
10486
|
-
const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
|
|
10487
|
-
if (!stored || stored.phase !== 'spec') {
|
|
10488
|
-
await this.emitIntervention(task.projectId, task.agentId, taskId, {
|
|
10489
|
-
phase: 'code-spec-round-missing',
|
|
10490
|
-
round,
|
|
10491
|
-
});
|
|
10492
|
-
return null;
|
|
10493
|
-
}
|
|
10494
|
-
const researchAgentId = task.researchAgentId;
|
|
10495
|
-
const researchHandoff = this.isResearchHandoff(task, devAgentId);
|
|
10496
9788
|
const releaseParticipant = async (agentId, failurePhase) => {
|
|
10497
9789
|
const released = await this.releaseAgentIfBound(agentId, taskId, {
|
|
10498
9790
|
allowAwaitingHuman: true,
|
|
@@ -10504,11 +9796,7 @@ export class AgentManager {
|
|
|
10504
9796
|
}
|
|
10505
9797
|
return released;
|
|
10506
9798
|
};
|
|
10507
|
-
if (
|
|
10508
|
-
if (!await releaseParticipant(researchAgentId, 'code-phase-research-release-failed'))
|
|
10509
|
-
return null;
|
|
10510
|
-
}
|
|
10511
|
-
if (task.qaAgentId && !await releaseParticipant(task.qaAgentId, 'code-phase-qa-release-failed')) {
|
|
9799
|
+
if (!await releaseParticipant(qaAgentId, 'code-phase-qa-release-failed')) {
|
|
10512
9800
|
return null;
|
|
10513
9801
|
}
|
|
10514
9802
|
const devBoundBefore = (await this.agentStore.get(devAgentId))?.taskId === taskId;
|
|
@@ -10528,7 +9816,32 @@ export class AgentManager {
|
|
|
10528
9816
|
const transition = await this.transitionTaskStatus(taskId, 'in_progress', {
|
|
10529
9817
|
fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready', 'max_rounds'],
|
|
10530
9818
|
...(expectedTask !== undefined ? { expectTask: expectedTask } : {}),
|
|
10531
|
-
|
|
9819
|
+
...(approval !== undefined ? { expectSignalToken: approval.expectedSignalToken } : {}),
|
|
9820
|
+
...(approval !== undefined ? {
|
|
9821
|
+
expectLatestHeadSha: approval.headSha,
|
|
9822
|
+
...(approval.humanOverride === true
|
|
9823
|
+
? {}
|
|
9824
|
+
: { expectReviewHeadAnchorSha: approval.headSha }),
|
|
9825
|
+
} : {}),
|
|
9826
|
+
}, {
|
|
9827
|
+
agentId: devAgentId,
|
|
9828
|
+
phase: 'code',
|
|
9829
|
+
deliveryConfirmation: undefined,
|
|
9830
|
+
maxRoundsContinues: 0,
|
|
9831
|
+
...(approval !== undefined ? {
|
|
9832
|
+
specReviewRound: approval.specReviewRound,
|
|
9833
|
+
latestHeadSha: approval.headSha,
|
|
9834
|
+
...(approval.humanOverride === true
|
|
9835
|
+
? {
|
|
9836
|
+
reviewHeadAnchorSha: approval.headSha,
|
|
9837
|
+
passProvenance: undefined,
|
|
9838
|
+
}
|
|
9839
|
+
: { passProvenance: approval.passProvenance }),
|
|
9840
|
+
...(approval.prNumber !== undefined ? { prNumber: approval.prNumber } : {}),
|
|
9841
|
+
...(approval.prUrl !== undefined ? { prUrl: approval.prUrl } : {}),
|
|
9842
|
+
} : {}),
|
|
9843
|
+
...this.dispatchTokenFields(newToken),
|
|
9844
|
+
});
|
|
10532
9845
|
if (!transition) {
|
|
10533
9846
|
if (!devBoundBefore) {
|
|
10534
9847
|
try {
|
|
@@ -10547,7 +9860,7 @@ export class AgentManager {
|
|
|
10547
9860
|
this.stopPhaseSignalWatcher(taskId);
|
|
10548
9861
|
let started = false;
|
|
10549
9862
|
try {
|
|
10550
|
-
started = await this.dispatchCodePhasePrompt(task, devAgentId, newToken
|
|
9863
|
+
started = await this.dispatchCodePhasePrompt(task, devAgentId, newToken);
|
|
10551
9864
|
}
|
|
10552
9865
|
catch (err) {
|
|
10553
9866
|
if (err instanceof DispatchTerminalError) {
|
|
@@ -10568,7 +9881,7 @@ export class AgentManager {
|
|
|
10568
9881
|
+ 'moved on; leaving its status as-is');
|
|
10569
9882
|
}
|
|
10570
9883
|
else if (!devBoundBefore) {
|
|
10571
|
-
// 转码前 dev 未持有本任务(如
|
|
9884
|
+
// 转码前 dev 未持有本任务(如 max_rounds 重占用):任务已退回
|
|
10572
9885
|
// verdict 门禁,释放本次新占用,否则 dev 会被回退后的任务永久占住
|
|
10573
9886
|
const released = await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
|
|
10574
9887
|
.catch(() => false);
|
|
@@ -10589,768 +9902,16 @@ export class AgentManager {
|
|
|
10589
9902
|
if (!started) {
|
|
10590
9903
|
await this.markAwaitingHuman(devAgentId, 'code-dispatch-failed', 'Code-phase prompt was not delivered after spec approval; the task looks in_progress but the dev never received it. Resume/restart the agent or cancel the task.', { expectedTaskId: taskId }).catch(() => undefined);
|
|
10591
9904
|
await this.emitIntervention(task.projectId, devAgentId, taskId, {
|
|
10592
|
-
phase:
|
|
9905
|
+
phase: 'code-resume-failed',
|
|
10593
9906
|
devAgentId,
|
|
10594
9907
|
});
|
|
10595
9908
|
return null;
|
|
10596
9909
|
}
|
|
10597
9910
|
// 重占用(released dev)的 acquire 会写 bootstrappingTaskId,而 continueSession 不做
|
|
10598
9911
|
// startSession 的 post-ack finalize;残留标记会让 recover 把已运行的任务按未送达回滚
|
|
10599
|
-
|
|
10600
|
-
await this.finalizeReplayedBootstrap(devAgentId, taskId, 'code', acquiredLockToken);
|
|
10601
|
-
}
|
|
9912
|
+
await this.finalizeReplayedBootstrap(devAgentId, taskId, 'code', acquiredLockToken);
|
|
10602
9913
|
return await this.taskStore.get(taskId);
|
|
10603
9914
|
}
|
|
10604
|
-
async dispatchServerReviewToQa(taskId, opts) {
|
|
10605
|
-
const dispatchPhase = opts.phase === 'spec'
|
|
10606
|
-
? 'server-spec-review'
|
|
10607
|
-
: (opts.recheck ? 'server-recheck' : 'server-review');
|
|
10608
|
-
const expectedKind = opts.phase === 'spec' ? 'spec-reviewed' : 'code-reviewed';
|
|
10609
|
-
const claim = await this.withTaskLock(async () => {
|
|
10610
|
-
// 终闸与 newToken 铸发同临界区:settling 中仅结算 handler 链(settle permit)
|
|
10611
|
-
// 或当前互斥 FIFO owner(手工先入者)可开新 pass。
|
|
10612
|
-
if (this.phaseSignalWatcher?.isSettling(taskId)
|
|
10613
|
-
&& !this.phaseSignalWatcher.settlePermitMatches(taskId, opts.settlePermit)
|
|
10614
|
-
&& !this.phaseSignalWatcher.exclusiveOwnerMatches(taskId, opts.settlePermit)) {
|
|
10615
|
-
throw new ApiError(409, `Task ${taskId} has a review completion settling; retry after it lands`, 'review-settling');
|
|
10616
|
-
}
|
|
10617
|
-
const task = await this.taskStore.get(taskId);
|
|
10618
|
-
if (!task)
|
|
10619
|
-
throw new Error(`dispatchServerReviewToQa: task ${taskId} not found`);
|
|
10620
|
-
if (task.reviewMode !== 'server' && opts.phase !== 'spec') {
|
|
10621
|
-
throw new Error(`dispatchServerReviewToQa: task ${taskId} is not in server review mode`);
|
|
10622
|
-
}
|
|
10623
|
-
if (opts.expectedTask && !taskMatchesGeneration(task, opts.expectedTask)) {
|
|
10624
|
-
throw new ApiError(409, `Task ${taskId} review generation changed before server dispatch`, 'dispatch-superseded');
|
|
10625
|
-
}
|
|
10626
|
-
const qaId = task.qaAgentId;
|
|
10627
|
-
if (!qaId) {
|
|
10628
|
-
if (!opts.callerOwnsConsumedSignalFailure) {
|
|
10629
|
-
const entryKind = task.status === 'fixing'
|
|
10630
|
-
? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
|
|
10631
|
-
: (opts.phase === 'spec' ? 'spec-done' : 'code-done');
|
|
10632
|
-
await this.setupPhaseSignal(taskId, task.agentId, entryKind, { skipSnapshot: true });
|
|
10633
|
-
await this.emitIntervention(task.projectId, task.agentId, taskId, {
|
|
10634
|
-
phase: 'server-review-no-qa-partner',
|
|
10635
|
-
devAgentId: task.agentId,
|
|
10636
|
-
});
|
|
10637
|
-
}
|
|
10638
|
-
return null;
|
|
10639
|
-
}
|
|
10640
|
-
if (this.getAgentConfig(qaId)?.role !== 'qa') {
|
|
10641
|
-
if (!opts.callerOwnsConsumedSignalFailure) {
|
|
10642
|
-
const entryKind = task.status === 'fixing'
|
|
10643
|
-
? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
|
|
10644
|
-
: (opts.phase === 'spec' ? 'spec-done' : 'code-done');
|
|
10645
|
-
await this.setupPhaseSignal(taskId, task.agentId, entryKind, { skipSnapshot: true });
|
|
10646
|
-
await this.emitIntervention(task.projectId, task.agentId, taskId, {
|
|
10647
|
-
phase: 'server-review-qa-unavailable',
|
|
10648
|
-
qaAgentId: qaId,
|
|
10649
|
-
});
|
|
10650
|
-
}
|
|
10651
|
-
return null;
|
|
10652
|
-
}
|
|
10653
|
-
const roundField = opts.phase === 'spec' ? (task.specReviewRound ?? 0) : task.reviewRound;
|
|
10654
|
-
const qaBinding = await this.agentStore.get(qaId);
|
|
10655
|
-
const bumpRound = shouldBumpServerReviewRound(opts.bumpRound, task, qaBinding);
|
|
10656
|
-
const newRound = opts.continuation
|
|
10657
|
-
? Math.max(roundField, 1)
|
|
10658
|
-
: bumpRound
|
|
10659
|
-
? roundField + 1
|
|
10660
|
-
: roundField;
|
|
10661
|
-
if (newRound < 1) {
|
|
10662
|
-
throw new Error(`dispatchServerReviewToQa: task ${taskId} has no current ${opts.phase} review round to redispatch`);
|
|
10663
|
-
}
|
|
10664
|
-
return {
|
|
10665
|
-
qaId,
|
|
10666
|
-
devAgentId: task.agentId,
|
|
10667
|
-
projectId: task.projectId,
|
|
10668
|
-
newToken: createSignalToken(),
|
|
10669
|
-
newRound,
|
|
10670
|
-
originalStatus: task.status,
|
|
10671
|
-
originalToken: task.signalToken,
|
|
10672
|
-
originalRound: roundField,
|
|
10673
|
-
originalBatchIndex: task.batchIndex,
|
|
10674
|
-
originalBatchTotal: task.batchTotal,
|
|
10675
|
-
originalPhase: task.phase,
|
|
10676
|
-
originalReviewCheckoutMode: task.reviewCheckoutMode,
|
|
10677
|
-
};
|
|
10678
|
-
});
|
|
10679
|
-
if (!claim)
|
|
10680
|
-
return null;
|
|
10681
|
-
const { qaId, devAgentId, projectId, newToken, newRound } = claim;
|
|
10682
|
-
const rollback = async (installedPass) => {
|
|
10683
|
-
try {
|
|
10684
|
-
return (await this.transitionTaskStatus(taskId, claim.originalStatus, {
|
|
10685
|
-
fromStatus: [installedPass.status],
|
|
10686
|
-
expectPhase: installedPass.phase,
|
|
10687
|
-
expectSignalToken: installedPass.signalToken,
|
|
10688
|
-
expectTask: installedPass,
|
|
10689
|
-
}, {
|
|
10690
|
-
signalToken: claim.originalToken,
|
|
10691
|
-
batchIndex: claim.originalBatchIndex,
|
|
10692
|
-
batchTotal: claim.originalBatchTotal,
|
|
10693
|
-
phase: claim.originalPhase,
|
|
10694
|
-
// fresh 派发在 install 时清过 mode:不还原会把仍在跑的 base pass 降级成 unknown
|
|
10695
|
-
reviewCheckoutMode: claim.originalReviewCheckoutMode,
|
|
10696
|
-
...(opts.phase === 'spec'
|
|
10697
|
-
? { specReviewRound: claim.originalRound }
|
|
10698
|
-
: { reviewRound: claim.originalRound }),
|
|
10699
|
-
})) !== null;
|
|
10700
|
-
}
|
|
10701
|
-
catch (err) {
|
|
10702
|
-
console.error(`[AgentManager] dispatchServerReviewToQa rollback failed for ${taskId}:`, err);
|
|
10703
|
-
return false;
|
|
10704
|
-
}
|
|
10705
|
-
};
|
|
10706
|
-
const rearmEntrySignal = async () => {
|
|
10707
|
-
if (opts.callerOwnsConsumedSignalFailure)
|
|
10708
|
-
return;
|
|
10709
|
-
// 从 review 重派(手工发起):入口信号早已被消费,dev 停驻等待中,无可重挂
|
|
10710
|
-
if (claim.originalStatus === 'review')
|
|
10711
|
-
return;
|
|
10712
|
-
const entryKind = claim.originalStatus === 'fixing'
|
|
10713
|
-
? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
|
|
10714
|
-
: (opts.phase === 'spec' ? 'spec-done' : 'code-done');
|
|
10715
|
-
await this.setupPhaseSignal(taskId, devAgentId, entryKind, { skipSnapshot: true });
|
|
10716
|
-
};
|
|
10717
|
-
let acquiredLockToken;
|
|
10718
|
-
const ownAcquire = () => acquiredLockToken === undefined
|
|
10719
|
-
? {}
|
|
10720
|
-
: { expectedLockToken: acquiredLockToken };
|
|
10721
|
-
const releaseOwnAcquire = async (context) => {
|
|
10722
|
-
try {
|
|
10723
|
-
const released = await this.releaseAgentForTask(qaId, taskId, 'idle', ownAcquire());
|
|
10724
|
-
if (!released) {
|
|
10725
|
-
console.warn(`[AgentManager] dispatchServerReviewToQa: QA release refused for ${qaId}/${taskId} (${context})`);
|
|
10726
|
-
}
|
|
10727
|
-
return released;
|
|
10728
|
-
}
|
|
10729
|
-
catch (err) {
|
|
10730
|
-
console.error(`[AgentManager] dispatchServerReviewToQa: QA release failed for ${qaId}/${taskId} (${context}):`, err);
|
|
10731
|
-
return false;
|
|
10732
|
-
}
|
|
10733
|
-
};
|
|
10734
|
-
if (!opts.continuation) {
|
|
10735
|
-
const prevQa = await this.agentStore.get(qaId);
|
|
10736
|
-
if (prevQa?.taskId === taskId) {
|
|
10737
|
-
if (prevQa.status === 'awaiting_human' && !isRecoverableQaDispatchHold(prevQa)) {
|
|
10738
|
-
throw new ApiError(409, `QA agent ${qaId} is awaiting human (${prevQa.awaitingPhase ?? 'unknown phase'}); resolve that hold before redispatching server review`, 'dispatch-unsupported');
|
|
10739
|
-
}
|
|
10740
|
-
// 手工重派:上一 pass 的 QA 仍绑定本任务,先释放再重新 acquire
|
|
10741
|
-
const released = await this.releaseAgentForTask(qaId, taskId, 'idle', {
|
|
10742
|
-
...(opts.expectedTask !== undefined ? { expectedTask: opts.expectedTask } : {}),
|
|
10743
|
-
...(prevQa.lockToken !== undefined ? { expectedLockToken: prevQa.lockToken } : {}),
|
|
10744
|
-
...(prevQa.status === 'awaiting_human'
|
|
10745
|
-
? {
|
|
10746
|
-
allowAwaitingHuman: true,
|
|
10747
|
-
expectedHold: {
|
|
10748
|
-
phase: prevQa.awaitingPhase,
|
|
10749
|
-
since: prevQa.awaitingSince,
|
|
10750
|
-
nonce: prevQa.awaitingNonce,
|
|
10751
|
-
},
|
|
10752
|
-
}
|
|
10753
|
-
: {}),
|
|
10754
|
-
});
|
|
10755
|
-
if (!released) {
|
|
10756
|
-
await rearmEntrySignal();
|
|
10757
|
-
return null;
|
|
10758
|
-
}
|
|
10759
|
-
opts.onSideEffect?.();
|
|
10760
|
-
}
|
|
10761
|
-
const acquired = await this.acquireAgentForTask(qaId, taskId, dispatchPhase, {
|
|
10762
|
-
onAcquired: (lockToken) => {
|
|
10763
|
-
acquiredLockToken = lockToken;
|
|
10764
|
-
opts.onSideEffect?.();
|
|
10765
|
-
},
|
|
10766
|
-
});
|
|
10767
|
-
if (!acquired) {
|
|
10768
|
-
await rearmEntrySignal();
|
|
10769
|
-
if (!opts.callerOwnsConsumedSignalFailure) {
|
|
10770
|
-
await this.emitIntervention(projectId, qaId, taskId, {
|
|
10771
|
-
phase: 'server-review-qa-acquire-failed',
|
|
10772
|
-
qaAgentId: qaId,
|
|
10773
|
-
});
|
|
10774
|
-
}
|
|
10775
|
-
return null;
|
|
10776
|
-
}
|
|
10777
|
-
if (devAgentId) {
|
|
10778
|
-
const devOk = await this.markAgentWaiting(devAgentId, taskId, {
|
|
10779
|
-
...(opts.expectedTask !== undefined ? { expectedTask: opts.expectedTask } : {}),
|
|
10780
|
-
});
|
|
10781
|
-
if (!devOk) {
|
|
10782
|
-
await releaseOwnAcquire('dev park failed');
|
|
10783
|
-
await rearmEntrySignal();
|
|
10784
|
-
if (!opts.callerOwnsConsumedSignalFailure) {
|
|
10785
|
-
await this.emitIntervention(projectId, devAgentId, taskId, {
|
|
10786
|
-
phase: 'server-review-dev-park-failed',
|
|
10787
|
-
devAgentId,
|
|
10788
|
-
});
|
|
10789
|
-
}
|
|
10790
|
-
return null;
|
|
10791
|
-
}
|
|
10792
|
-
}
|
|
10793
|
-
}
|
|
10794
|
-
else {
|
|
10795
|
-
const qaState = await this.agentStore.get(qaId);
|
|
10796
|
-
if (qaState?.taskId !== taskId) {
|
|
10797
|
-
if (!opts.callerOwnsConsumedSignalFailure) {
|
|
10798
|
-
await this.setupPhaseSignal(taskId, qaId, expectedKind, { skipSnapshot: true });
|
|
10799
|
-
await this.emitIntervention(projectId, qaId, taskId, {
|
|
10800
|
-
phase: 'server-review-continuation-qa-not-bound',
|
|
10801
|
-
qaAgentId: qaId,
|
|
10802
|
-
});
|
|
10803
|
-
}
|
|
10804
|
-
return null;
|
|
10805
|
-
}
|
|
10806
|
-
}
|
|
10807
|
-
const roundPatch = opts.phase === 'spec'
|
|
10808
|
-
? { specReviewRound: newRound, phase: 'spec' }
|
|
10809
|
-
: { reviewRound: newRound };
|
|
10810
|
-
const transition = await this.transitionTaskStatus(taskId, 'review', {
|
|
10811
|
-
fromStatus: [claim.originalStatus],
|
|
10812
|
-
expectPhase: claim.originalPhase,
|
|
10813
|
-
expectSignalToken: claim.originalToken,
|
|
10814
|
-
...(opts.expectedTask !== undefined ? { expectTask: opts.expectedTask } : {}),
|
|
10815
|
-
}, {
|
|
10816
|
-
signalToken: newToken,
|
|
10817
|
-
reviewDispatchedAt: new Date().toISOString(),
|
|
10818
|
-
...(opts.reviewHeadAnchorSha ? { reviewHeadAnchorSha: opts.reviewHeadAnchorSha } : {}),
|
|
10819
|
-
...(opts.batch
|
|
10820
|
-
? { batchIndex: opts.batch.index, batchTotal: opts.batch.total }
|
|
10821
|
-
: { batchIndex: undefined, batchTotal: undefined }),
|
|
10822
|
-
// Fresh dispatch: clear any stale mode until startSession materializes the review checkout,
|
|
10823
|
-
// so a crash in this window recovers conservatively (no read-file) rather than
|
|
10824
|
-
// re-arming it for what might be a head-mode review.
|
|
10825
|
-
...(opts.continuation ? {} : { reviewCheckoutMode: undefined }),
|
|
10826
|
-
...roundPatch,
|
|
10827
|
-
});
|
|
10828
|
-
if (!transition) {
|
|
10829
|
-
if (!opts.continuation) {
|
|
10830
|
-
await releaseOwnAcquire('pass transition rejected');
|
|
10831
|
-
}
|
|
10832
|
-
if (!opts.callerOwnsConsumedSignalFailure) {
|
|
10833
|
-
await this.emitIntervention(projectId, qaId, taskId, {
|
|
10834
|
-
phase: 'server-review-transition-failed',
|
|
10835
|
-
qaAgentId: qaId,
|
|
10836
|
-
});
|
|
10837
|
-
}
|
|
10838
|
-
return null;
|
|
10839
|
-
}
|
|
10840
|
-
opts.onSideEffect?.();
|
|
10841
|
-
const installedPass = taskGenerationGuard(transition.task);
|
|
10842
|
-
const passStillCurrent = async () => {
|
|
10843
|
-
const current = await this.taskStore.get(taskId);
|
|
10844
|
-
return current !== null && taskMatchesGeneration(current, installedPass);
|
|
10845
|
-
};
|
|
10846
|
-
let dispatchedCheckoutMode;
|
|
10847
|
-
const sessionOpts = {
|
|
10848
|
-
bypassTaskStatusGate: true,
|
|
10849
|
-
signalToken: newToken,
|
|
10850
|
-
dispatchPassToken: newToken,
|
|
10851
|
-
guardBeforeInject: passStillCurrent,
|
|
10852
|
-
serverContent: opts.content,
|
|
10853
|
-
...(opts.interdiff !== undefined ? { serverInterdiff: opts.interdiff } : {}),
|
|
10854
|
-
...(opts.diffstat !== undefined ? { serverDiffstat: opts.diffstat } : {}),
|
|
10855
|
-
...(opts.baseSha !== undefined ? { serverBaseSha: opts.baseSha } : {}),
|
|
10856
|
-
...(opts.reviewHeadAnchorSha !== undefined ? { serverHeadSha: opts.reviewHeadAnchorSha } : {}),
|
|
10857
|
-
...(opts.headTree !== undefined ? { serverHeadTree: opts.headTree } : {}),
|
|
10858
|
-
...(opts.batch ? { serverBatch: opts.batch } : {}),
|
|
10859
|
-
...(opts.priorFindingsJson ? { serverPriorFindings: opts.priorFindingsJson } : {}),
|
|
10860
|
-
...(opts.priorResponseJson ? { serverPriorResponse: opts.priorResponseJson } : {}),
|
|
10861
|
-
...(opts.phase === 'spec' ? { currentSpecRound: newRound } : {}),
|
|
10862
|
-
armBeforeInject: (ctx) => this.withTaskLock(async () => {
|
|
10863
|
-
const current = await this.taskStore.get(taskId);
|
|
10864
|
-
if (!current || !taskMatchesGeneration(current, installedPass))
|
|
10865
|
-
return false;
|
|
10866
|
-
dispatchedCheckoutMode = ctx.serverReviewCheckout;
|
|
10867
|
-
const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.serverReviewCheckout === 'base';
|
|
10868
|
-
return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, {
|
|
10869
|
-
needInputMode: 'fresh',
|
|
10870
|
-
...(allowReadFile
|
|
10871
|
-
? { onReadFile: (req) => { void this.handleReadFileRequest(taskId, qaId, req); } }
|
|
10872
|
-
: {}),
|
|
10873
|
-
});
|
|
10874
|
-
}),
|
|
10875
|
-
};
|
|
10876
|
-
const rearmConsumedSignal = async () => {
|
|
10877
|
-
if (opts.callerOwnsConsumedSignalFailure)
|
|
10878
|
-
return;
|
|
10879
|
-
if (opts.continuation) {
|
|
10880
|
-
await this.setupPhaseSignal(taskId, qaId, expectedKind, { skipSnapshot: true });
|
|
10881
|
-
}
|
|
10882
|
-
else {
|
|
10883
|
-
await rearmEntrySignal();
|
|
10884
|
-
}
|
|
10885
|
-
};
|
|
10886
|
-
let started = false;
|
|
10887
|
-
try {
|
|
10888
|
-
// 批次轮早于 head 物化且其持久化 mode 不可信(改名丢失/尽力而为),续传恒重发 base。
|
|
10889
|
-
started = opts.continuation
|
|
10890
|
-
? await this.continueSession(taskId, qaId, dispatchPhase, { ...sessionOpts, serverReviewCheckout: 'base' })
|
|
10891
|
-
: await this.startSession(taskId, qaId, dispatchPhase, sessionOpts);
|
|
10892
|
-
}
|
|
10893
|
-
catch (err) {
|
|
10894
|
-
this.phaseSignalWatcher?.stopAgentIfToken(taskId, qaId, newToken);
|
|
10895
|
-
if (err instanceof DispatchTerminalError) {
|
|
10896
|
-
await this.failTaskForDispatchError(taskId, dispatchPhase, qaId, err, {
|
|
10897
|
-
...ownAcquire(),
|
|
10898
|
-
expectedTask: installedPass,
|
|
10899
|
-
});
|
|
10900
|
-
}
|
|
10901
|
-
else if (err instanceof EnsureSessionError && err.partial.handled) {
|
|
10902
|
-
}
|
|
10903
|
-
else {
|
|
10904
|
-
const rolledBack = await rollback(installedPass);
|
|
10905
|
-
if (rolledBack && !opts.continuation) {
|
|
10906
|
-
await releaseOwnAcquire('session dispatch threw');
|
|
10907
|
-
}
|
|
10908
|
-
if (rolledBack)
|
|
10909
|
-
await rearmConsumedSignal();
|
|
10910
|
-
}
|
|
10911
|
-
throw err;
|
|
10912
|
-
}
|
|
10913
|
-
if (!started) {
|
|
10914
|
-
this.phaseSignalWatcher?.stopAgentIfToken(taskId, qaId, newToken);
|
|
10915
|
-
const rolledBack = await rollback(installedPass);
|
|
10916
|
-
if (rolledBack && !opts.continuation) {
|
|
10917
|
-
await releaseOwnAcquire('session did not start');
|
|
10918
|
-
}
|
|
10919
|
-
if (rolledBack)
|
|
10920
|
-
await rearmConsumedSignal();
|
|
10921
|
-
if (rolledBack && !opts.callerOwnsConsumedSignalFailure) {
|
|
10922
|
-
await this.emitIntervention(projectId, qaId, taskId, {
|
|
10923
|
-
phase: 'server-review-start-failed',
|
|
10924
|
-
qaAgentId: qaId,
|
|
10925
|
-
});
|
|
10926
|
-
}
|
|
10927
|
-
return null;
|
|
10928
|
-
}
|
|
10929
|
-
// Persist the checkout mode so base-mode recovery can re-arm read-file.
|
|
10930
|
-
if (opts.phase !== 'spec' && !opts.continuation) {
|
|
10931
|
-
try {
|
|
10932
|
-
const persisted = await this.updateTaskIfStatus(taskId, installedPass.status, { reviewCheckoutMode: dispatchedCheckoutMode }, installedPass);
|
|
10933
|
-
if (!persisted) {
|
|
10934
|
-
console.warn(`[AgentManager] review checkout mode for ${taskId} was superseded before persistence; skipping stale write`);
|
|
10935
|
-
return await this.taskStore.get(taskId);
|
|
10936
|
-
}
|
|
10937
|
-
}
|
|
10938
|
-
catch (err) {
|
|
10939
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
10940
|
-
console.warn(`[AgentManager] failed to persist review checkout mode for ${taskId}: ${message}`);
|
|
10941
|
-
await this.emitIntervention(projectId, qaId, taskId, {
|
|
10942
|
-
phase: 'review-checkout-mode-persist-failed',
|
|
10943
|
-
error: message,
|
|
10944
|
-
note: 'The review prompt is running, but restart recovery cannot safely infer its checkout mode.',
|
|
10945
|
-
});
|
|
10946
|
-
}
|
|
10947
|
-
}
|
|
10948
|
-
return await this.taskStore.get(taskId);
|
|
10949
|
-
}
|
|
10950
|
-
async dispatchServerFixToDev(taskId, _findingsJson, opts = {}) {
|
|
10951
|
-
const claim = await this.withTaskLock(async () => {
|
|
10952
|
-
const task = await this.taskStore.get(taskId);
|
|
10953
|
-
if (!task)
|
|
10954
|
-
throw new Error(`dispatchServerFixToDev: task ${taskId} not found`);
|
|
10955
|
-
if (task.reviewMode !== 'server' && !isSpecStagePhase(task.phase)) {
|
|
10956
|
-
throw new Error(`dispatchServerFixToDev: task ${taskId} is not in server review mode`);
|
|
10957
|
-
}
|
|
10958
|
-
if (opts.expectedTask && !taskMatchesGeneration(task, opts.expectedTask)) {
|
|
10959
|
-
throw new ApiError(409, `Task ${taskId} review generation changed before server fix dispatch`, 'dispatch-superseded');
|
|
10960
|
-
}
|
|
10961
|
-
if (!task.agentId)
|
|
10962
|
-
throw new Error(`dispatchServerFixToDev: task ${taskId} has no dev agent`);
|
|
10963
|
-
return {
|
|
10964
|
-
devAgentId: task.agentId,
|
|
10965
|
-
qaAgentId: task.qaAgentId,
|
|
10966
|
-
projectId: task.projectId,
|
|
10967
|
-
newToken: createSignalToken(),
|
|
10968
|
-
taskPhase: task.phase,
|
|
10969
|
-
currentReviewRound: task.reviewRound,
|
|
10970
|
-
currentSpecRound: task.specReviewRound,
|
|
10971
|
-
originalStatus: task.status,
|
|
10972
|
-
originalToken: task.signalToken,
|
|
10973
|
-
entryGeneration: taskGenerationGuard(task),
|
|
10974
|
-
};
|
|
10975
|
-
});
|
|
10976
|
-
if (!claim)
|
|
10977
|
-
return null;
|
|
10978
|
-
const { devAgentId, qaAgentId, projectId, newToken, taskPhase, currentSpecRound, entryGeneration, } = claim;
|
|
10979
|
-
const expectedKind = taskPhase === 'spec' ? 'spec-fixed' : 'code-fixed';
|
|
10980
|
-
const rearmReviewedSignal = async (expectedTask = entryGeneration) => {
|
|
10981
|
-
if (opts.callerOwnsConsumedSignalFailure)
|
|
10982
|
-
return;
|
|
10983
|
-
if (!qaAgentId)
|
|
10984
|
-
return;
|
|
10985
|
-
const reviewedKind = taskPhase === 'spec' ? 'spec-reviewed' : 'code-reviewed';
|
|
10986
|
-
await this.withTaskLock(async () => {
|
|
10987
|
-
const current = await this.taskStore.get(taskId);
|
|
10988
|
-
if (!current?.signalToken || !taskMatchesGeneration(current, expectedTask))
|
|
10989
|
-
return;
|
|
10990
|
-
await this.setupPhaseSignal(taskId, qaAgentId, reviewedKind, {
|
|
10991
|
-
skipSnapshot: true,
|
|
10992
|
-
});
|
|
10993
|
-
});
|
|
10994
|
-
};
|
|
10995
|
-
const devBoundBefore = (await this.agentStore.get(devAgentId))?.taskId === taskId;
|
|
10996
|
-
let acquiredDevLockToken;
|
|
10997
|
-
const releaseFreshAcquire = async (devLockToken) => {
|
|
10998
|
-
if (devBoundBefore)
|
|
10999
|
-
return;
|
|
11000
|
-
try {
|
|
11001
|
-
const released = await this.releaseAgentForTask(devAgentId, taskId, 'idle', {
|
|
11002
|
-
expectedTask: entryGeneration,
|
|
11003
|
-
expectedLockToken: devLockToken,
|
|
11004
|
-
});
|
|
11005
|
-
if (!released) {
|
|
11006
|
-
console.error(`[AgentManager] dispatchServerFixToDev: releasing freshly acquired dev `
|
|
11007
|
-
+ `${devAgentId} for ${taskId} was refused`);
|
|
11008
|
-
}
|
|
11009
|
-
}
|
|
11010
|
-
catch (err) {
|
|
11011
|
-
console.error(`[AgentManager] dispatchServerFixToDev: releasing freshly acquired dev `
|
|
11012
|
-
+ `${devAgentId} for ${taskId} failed:`, err);
|
|
11013
|
-
}
|
|
11014
|
-
};
|
|
11015
|
-
const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-feedback', {
|
|
11016
|
-
onAcquired: (lockToken) => { acquiredDevLockToken = lockToken; },
|
|
11017
|
-
});
|
|
11018
|
-
if (!acquired) {
|
|
11019
|
-
await rearmReviewedSignal();
|
|
11020
|
-
if (!opts.callerOwnsConsumedSignalFailure) {
|
|
11021
|
-
await this.emitIntervention(projectId, devAgentId, taskId, {
|
|
11022
|
-
phase: 'server-fix-dev-acquire-failed',
|
|
11023
|
-
devAgentId,
|
|
11024
|
-
});
|
|
11025
|
-
}
|
|
11026
|
-
return null;
|
|
11027
|
-
}
|
|
11028
|
-
const devLockToken = acquiredDevLockToken;
|
|
11029
|
-
if (!devLockToken) {
|
|
11030
|
-
throw new Error(`dispatchServerFixToDev: acquireAgentForTask did not return the Dev lock token for ${devAgentId}`);
|
|
11031
|
-
}
|
|
11032
|
-
if (qaAgentId) {
|
|
11033
|
-
const released = await this.releaseAgentIfBound(qaAgentId, taskId, { expectedTask: entryGeneration })
|
|
11034
|
-
.catch((err) => {
|
|
11035
|
-
console.error(`[AgentManager] dispatchServerFixToDev: releasing QA ${qaAgentId} for ${taskId} failed:`, err);
|
|
11036
|
-
return false;
|
|
11037
|
-
});
|
|
11038
|
-
if (!released) {
|
|
11039
|
-
await releaseFreshAcquire(devLockToken);
|
|
11040
|
-
await rearmReviewedSignal();
|
|
11041
|
-
if (!opts.callerOwnsConsumedSignalFailure) {
|
|
11042
|
-
await this.emitIntervention(projectId, qaAgentId, taskId, {
|
|
11043
|
-
phase: 'server-fix-qa-release-failed',
|
|
11044
|
-
qaAgentId,
|
|
11045
|
-
});
|
|
11046
|
-
}
|
|
11047
|
-
return null;
|
|
11048
|
-
}
|
|
11049
|
-
}
|
|
11050
|
-
const transition = await this.transitionTaskStatus(taskId, 'fixing', {
|
|
11051
|
-
fromStatus: ['review', 'max_rounds', 'spec-ready', 'ready'],
|
|
11052
|
-
expectSignalToken: claim.originalToken,
|
|
11053
|
-
expectTask: entryGeneration,
|
|
11054
|
-
expectAgentLock: { agentId: devAgentId, token: devLockToken },
|
|
11055
|
-
}, { signalToken: newToken, fixDispatchedAt: new Date().toISOString() });
|
|
11056
|
-
if (!transition) {
|
|
11057
|
-
await releaseFreshAcquire(devLockToken);
|
|
11058
|
-
if (!opts.callerOwnsConsumedSignalFailure) {
|
|
11059
|
-
await this.emitIntervention(projectId, devAgentId, taskId, {
|
|
11060
|
-
phase: 'server-fix-transition-failed',
|
|
11061
|
-
devAgentId,
|
|
11062
|
-
});
|
|
11063
|
-
}
|
|
11064
|
-
return null;
|
|
11065
|
-
}
|
|
11066
|
-
const installedFix = taskGenerationGuard(transition.task);
|
|
11067
|
-
const fixingPassStillCurrent = async () => {
|
|
11068
|
-
const current = await this.taskStore.get(taskId);
|
|
11069
|
-
return current !== null && taskMatchesGeneration(current, installedFix);
|
|
11070
|
-
};
|
|
11071
|
-
const rollbackToEntry = async () => {
|
|
11072
|
-
try {
|
|
11073
|
-
const rolledBack = await this.transitionTaskStatus(taskId, claim.originalStatus, {
|
|
11074
|
-
fromStatus: ['fixing'],
|
|
11075
|
-
expectTask: installedFix,
|
|
11076
|
-
expectAgentLock: { agentId: devAgentId, token: devLockToken },
|
|
11077
|
-
}, { signalToken: claim.originalToken });
|
|
11078
|
-
if (!rolledBack) {
|
|
11079
|
-
console.warn(`[AgentManager] dispatchServerFixToDev rollback for ${taskId} was superseded `
|
|
11080
|
-
+ `by a newer task or Dev lock generation`);
|
|
11081
|
-
}
|
|
11082
|
-
return rolledBack?.task ?? null;
|
|
11083
|
-
}
|
|
11084
|
-
catch (err) {
|
|
11085
|
-
console.error(`[AgentManager] dispatchServerFixToDev rollback failed for ${taskId}:`, err);
|
|
11086
|
-
return null;
|
|
11087
|
-
}
|
|
11088
|
-
};
|
|
11089
|
-
const cleanupUndeliveredFix = async () => {
|
|
11090
|
-
const rolledBack = await rollbackToEntry();
|
|
11091
|
-
if (!rolledBack)
|
|
11092
|
-
return false;
|
|
11093
|
-
const rolledBackGeneration = taskGenerationGuard(rolledBack);
|
|
11094
|
-
const released = await this.releaseAgentForTask(devAgentId, taskId, 'idle', {
|
|
11095
|
-
expectedTask: rolledBackGeneration,
|
|
11096
|
-
expectedLockToken: devLockToken,
|
|
11097
|
-
}).catch((err) => {
|
|
11098
|
-
console.error(`[AgentManager] dispatchServerFixToDev: releasing dev ${devAgentId} after rollback failed:`, err);
|
|
11099
|
-
return false;
|
|
11100
|
-
});
|
|
11101
|
-
if (!released) {
|
|
11102
|
-
console.error(`[AgentManager] dispatchServerFixToDev: releasing dev ${devAgentId} after rollback was refused`);
|
|
11103
|
-
}
|
|
11104
|
-
await rearmReviewedSignal(rolledBackGeneration);
|
|
11105
|
-
return true;
|
|
11106
|
-
};
|
|
11107
|
-
let resumed = false;
|
|
11108
|
-
try {
|
|
11109
|
-
const feedbackPhase = taskPhase === 'spec' ? 'spec' : 'code';
|
|
11110
|
-
const feedbackRound = feedbackPhase === 'spec'
|
|
11111
|
-
? (currentSpecRound ?? 1)
|
|
11112
|
-
: Math.max(claim.currentReviewRound, 1);
|
|
11113
|
-
const stored = await this.reviewStore?.getRound(taskId, feedbackPhase, feedbackRound);
|
|
11114
|
-
if (!stored?.findings) {
|
|
11115
|
-
throw new Error(`dispatchServerFixToDev: persisted aggregate findings missing for ${taskId}/${feedbackPhase}/${feedbackRound}`);
|
|
11116
|
-
}
|
|
11117
|
-
const findingsJson = JSON.stringify(stored.findings);
|
|
11118
|
-
const findingsDigest = reviewFindingsDigest(stored.findings);
|
|
11119
|
-
resumed = await this.continueSession(taskId, devAgentId, 'server-feedback', {
|
|
11120
|
-
bypassTaskStatusGate: true,
|
|
11121
|
-
signalToken: newToken,
|
|
11122
|
-
serverPriorFindings: findingsJson,
|
|
11123
|
-
serverFindingsDigest: findingsDigest,
|
|
11124
|
-
armBeforeInject: () => this.withTaskLock(async () => {
|
|
11125
|
-
const current = await this.taskStore.get(taskId);
|
|
11126
|
-
if (!current || !taskMatchesGeneration(current, installedFix))
|
|
11127
|
-
return false;
|
|
11128
|
-
return this.setupPhaseSignalWatcher(taskId, devAgentId, expectedKind, newToken, {
|
|
11129
|
-
needInputMode: 'fresh',
|
|
11130
|
-
});
|
|
11131
|
-
}),
|
|
11132
|
-
guardBeforeInject: fixingPassStillCurrent,
|
|
11133
|
-
...(taskPhase === 'spec' && currentSpecRound !== undefined
|
|
11134
|
-
? { currentSpecRound }
|
|
11135
|
-
: {}),
|
|
11136
|
-
});
|
|
11137
|
-
}
|
|
11138
|
-
catch (err) {
|
|
11139
|
-
this.phaseSignalWatcher?.stopAgentIfToken(taskId, devAgentId, newToken);
|
|
11140
|
-
if (err instanceof DispatchTerminalError) {
|
|
11141
|
-
await this.failTaskForDispatchError(taskId, 'server-feedback', devAgentId, err, {
|
|
11142
|
-
expectedTask: installedFix,
|
|
11143
|
-
expectedLockToken: devLockToken,
|
|
11144
|
-
});
|
|
11145
|
-
}
|
|
11146
|
-
else if (err instanceof EnsureSessionError && err.partial.handled) {
|
|
11147
|
-
}
|
|
11148
|
-
else {
|
|
11149
|
-
await cleanupUndeliveredFix();
|
|
11150
|
-
}
|
|
11151
|
-
throw err;
|
|
11152
|
-
}
|
|
11153
|
-
if (!resumed) {
|
|
11154
|
-
this.phaseSignalWatcher?.stopAgentIfToken(taskId, devAgentId, newToken);
|
|
11155
|
-
const cleaned = await cleanupUndeliveredFix();
|
|
11156
|
-
if (cleaned && !opts.callerOwnsConsumedSignalFailure) {
|
|
11157
|
-
await this.emitIntervention(projectId, devAgentId, taskId, {
|
|
11158
|
-
phase: 'server-fix-resume-failed',
|
|
11159
|
-
devAgentId,
|
|
11160
|
-
});
|
|
11161
|
-
}
|
|
11162
|
-
return null;
|
|
11163
|
-
}
|
|
11164
|
-
return await this.taskStore.get(taskId);
|
|
11165
|
-
}
|
|
11166
|
-
projectRepoKey(projectId) {
|
|
11167
|
-
const repo = this.getProjectConfig(projectId)?.repo;
|
|
11168
|
-
if (!repo)
|
|
11169
|
-
return null;
|
|
11170
|
-
if (isGitHubRepo(repo))
|
|
11171
|
-
return `gh:${repoSlug(repo).toLowerCase()}`;
|
|
11172
|
-
const parsed = parseGitRemote(repo);
|
|
11173
|
-
if (parsed)
|
|
11174
|
-
return `git:${parsed.host.toLowerCase()}/${parsed.path}`;
|
|
11175
|
-
return `raw:${repo.trim()}`;
|
|
11176
|
-
}
|
|
11177
|
-
async computeCodeInterdiff(taskId, round) {
|
|
11178
|
-
const store = this.getReviewStore();
|
|
11179
|
-
if (!store || !Number.isInteger(round) || round < 2)
|
|
11180
|
-
return { ok: false, reason: 'no-anchor' };
|
|
11181
|
-
const [cur, prev] = await Promise.all([
|
|
11182
|
-
store.getRound(taskId, 'code', round),
|
|
11183
|
-
store.getRound(taskId, 'code', round - 1),
|
|
11184
|
-
]);
|
|
11185
|
-
const curSha = cur?.headSha;
|
|
11186
|
-
const prevSha = prev?.headSha;
|
|
11187
|
-
if (!curSha || !prevSha)
|
|
11188
|
-
return { ok: false, reason: 'no-anchor' };
|
|
11189
|
-
const task = await this.taskStore.get(taskId);
|
|
11190
|
-
if (!task?.agentId)
|
|
11191
|
-
return { ok: false, reason: 'released' };
|
|
11192
|
-
const agent = this.getAgentConfig(task.agentId);
|
|
11193
|
-
const agentState = await this.agentStore.get(task.agentId);
|
|
11194
|
-
// A rebound agent's Workdir checkout belongs to its new task — its git state describes
|
|
11195
|
-
// another branch, so the interdiff would be meaningless. Same skip rule as
|
|
11196
|
-
// findLineageViolation: the Workdir must still be bound to THIS task.
|
|
11197
|
-
if (!agent || agentState?.taskId !== taskId || !agentState.workdir) {
|
|
11198
|
-
return { ok: false, reason: 'released' };
|
|
11199
|
-
}
|
|
11200
|
-
await this.refreshWorkdirCacheFor(task.agentId);
|
|
11201
|
-
const diff = await this.getReviewTransport().readInterdiff(agent, prevSha, curSha);
|
|
11202
|
-
return { ok: true, diff };
|
|
11203
|
-
}
|
|
11204
|
-
async findLineageViolation(taskId, baseSha) {
|
|
11205
|
-
const task = await this.taskStore.get(taskId);
|
|
11206
|
-
if (!task?.agentId)
|
|
11207
|
-
return null;
|
|
11208
|
-
const agent = this.getAgentConfig(task.agentId);
|
|
11209
|
-
const agentState = await this.agentStore.get(task.agentId);
|
|
11210
|
-
// A rebound agent's Workdir checkout belongs to its new task — checking it would
|
|
11211
|
-
// produce verdicts about the wrong branch. Skip; the dispatch path's own
|
|
11212
|
-
// binding guards (acquire) surface the real failure.
|
|
11213
|
-
if (agentState?.taskId !== taskId)
|
|
11214
|
-
return null;
|
|
11215
|
-
const workdir = agentState.workdir;
|
|
11216
|
-
if (!agent || !workdir)
|
|
11217
|
-
return null;
|
|
11218
|
-
const runner = this.createRunnerFor(agent);
|
|
11219
|
-
let base = baseSha;
|
|
11220
|
-
if (!base) {
|
|
11221
|
-
// Callers without a baseSha (publish) run long after the review diff was
|
|
11222
|
-
// read; refresh origin/HEAD first or upstream commits merged since then
|
|
11223
|
-
// would linger in base..HEAD and flag tasks that leak nothing.
|
|
11224
|
-
const fetch = await execNetwork(runner, `${canonicalSelfGuard(workdir)} && ${GIT_NET_ENV} git -C ${shellQuote(workdir)} fetch origin --quiet`);
|
|
11225
|
-
if (fetch.exitCode !== 0) {
|
|
11226
|
-
throw new Error(`lineage fetch failed in ${workdir}: ${fetch.stderr.trim()}`);
|
|
11227
|
-
}
|
|
11228
|
-
const mb = await runner.exec(`git -C ${shellQuote(workdir)} merge-base origin/HEAD HEAD`);
|
|
11229
|
-
if (mb.exitCode !== 0) {
|
|
11230
|
-
throw new Error(`lineage merge-base failed in ${workdir}: ${mb.stderr.trim()}`);
|
|
11231
|
-
}
|
|
11232
|
-
base = mb.stdout.trim();
|
|
11233
|
-
}
|
|
11234
|
-
// Projects pointing at the same repo share one store and branch namespace,
|
|
11235
|
-
// so candidates are scoped by repo identity, not by projectId.
|
|
11236
|
-
const selfRepoKey = this.projectRepoKey(task.projectId);
|
|
11237
|
-
if (!selfRepoKey)
|
|
11238
|
-
return null;
|
|
11239
|
-
const tasks = await this.taskStore.list();
|
|
11240
|
-
const candidates = tasks
|
|
11241
|
-
.filter(t => t.id !== taskId
|
|
11242
|
-
&& !TERMINAL_STATUSES.includes(t.status)
|
|
11243
|
-
&& this.projectRepoKey(t.projectId) === selfRepoKey)
|
|
11244
|
-
.map(t => ({ taskId: t.id, branch: t.branch ?? BRANCH_PREFIX + t.id }));
|
|
11245
|
-
if (candidates.length === 0)
|
|
11246
|
-
return null;
|
|
11247
|
-
return findForeignTaskTip((cmd) => runner.exec(cmd), workdir, base, candidates);
|
|
11248
|
-
}
|
|
11249
|
-
async dispatchServerAfterDone(taskId, kind) {
|
|
11250
|
-
const task = await this.taskStore.get(taskId);
|
|
11251
|
-
if (!task)
|
|
11252
|
-
throw new Error(`dispatchServerAfterDone: task ${taskId} not found`);
|
|
11253
|
-
const devAgentId = task.agentId;
|
|
11254
|
-
if (!devAgentId)
|
|
11255
|
-
throw new Error(`dispatchServerAfterDone: task ${taskId} has no dev agent`);
|
|
11256
|
-
// 幂等兜底:主路径已在 approve 前经 commitServerAfterDone 落盘 afterDone+binding;
|
|
11257
|
-
// 重启/重试直连本函数时,这里补齐(binding 不可变,已在则短路)。
|
|
11258
|
-
await this.commitServerAfterDone(taskId);
|
|
11259
|
-
let violation;
|
|
11260
|
-
try {
|
|
11261
|
-
violation = await this.findLineageViolation(taskId);
|
|
11262
|
-
}
|
|
11263
|
-
catch (err) {
|
|
11264
|
-
await this.emitIntervention(task.projectId, devAgentId, taskId, {
|
|
11265
|
-
phase: 'server-after-done-lineage-check-failed',
|
|
11266
|
-
error: err instanceof Error ? err.message : String(err),
|
|
11267
|
-
note: 'Publish was not dispatched; mark-complete retries it once the repo state is fixed.',
|
|
11268
|
-
});
|
|
11269
|
-
return null;
|
|
11270
|
-
}
|
|
11271
|
-
if (violation) {
|
|
11272
|
-
await this.emitIntervention(task.projectId, devAgentId, taskId, {
|
|
11273
|
-
phase: 'server-after-done-lineage-violation',
|
|
11274
|
-
offendingTaskId: violation.taskId,
|
|
11275
|
-
offendingBranch: violation.branch,
|
|
11276
|
-
offendingSha: violation.sha,
|
|
11277
|
-
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.',
|
|
11278
|
-
});
|
|
11279
|
-
return null;
|
|
11280
|
-
}
|
|
11281
|
-
const branch = task.branch ?? BRANCH_PREFIX + taskId;
|
|
11282
|
-
const originalToken = task.signalToken;
|
|
11283
|
-
const newToken = createSignalToken();
|
|
11284
|
-
await this.updateTask(taskId, { signalToken: newToken });
|
|
11285
|
-
const rollbackToken = async () => {
|
|
11286
|
-
await this.updateTask(taskId, { signalToken: originalToken, publishDispatchedAt: undefined })
|
|
11287
|
-
.catch(() => undefined);
|
|
11288
|
-
};
|
|
11289
|
-
const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-after-done');
|
|
11290
|
-
if (!acquired) {
|
|
11291
|
-
await rollbackToken();
|
|
11292
|
-
await this.emitIntervention(task.projectId, devAgentId, taskId, { phase: 'server-after-done-dev-acquire-failed', devAgentId });
|
|
11293
|
-
return null;
|
|
11294
|
-
}
|
|
11295
|
-
await this.updateTask(taskId, { publishDispatchedAt: new Date().toISOString() });
|
|
11296
|
-
let resumed = false;
|
|
11297
|
-
try {
|
|
11298
|
-
resumed = await this.continueSession(taskId, devAgentId, 'server-after-done', {
|
|
11299
|
-
bypassTaskStatusGate: true,
|
|
11300
|
-
signalToken: newToken,
|
|
11301
|
-
serverAfterDone: { kind, branch },
|
|
11302
|
-
});
|
|
11303
|
-
}
|
|
11304
|
-
catch (err) {
|
|
11305
|
-
if (err instanceof DispatchTerminalError) {
|
|
11306
|
-
await this.failTaskForDispatchError(taskId, 'server-after-done', devAgentId, err);
|
|
11307
|
-
}
|
|
11308
|
-
else if (!(err instanceof EnsureSessionError && err.partial.handled)) {
|
|
11309
|
-
await rollbackToken();
|
|
11310
|
-
}
|
|
11311
|
-
throw err;
|
|
11312
|
-
}
|
|
11313
|
-
if (!resumed) {
|
|
11314
|
-
await rollbackToken();
|
|
11315
|
-
await this.emitIntervention(task.projectId, devAgentId, taskId, {
|
|
11316
|
-
phase: 'server-after-done-resume-failed',
|
|
11317
|
-
devAgentId,
|
|
11318
|
-
note: 'Publish prompt was not delivered; mark-complete retries the publish dispatch.',
|
|
11319
|
-
});
|
|
11320
|
-
return null;
|
|
11321
|
-
}
|
|
11322
|
-
await this.armPostDispatchSignalOrHold(taskId, devAgentId, 'code-ready', newToken);
|
|
11323
|
-
return await this.taskStore.get(taskId);
|
|
11324
|
-
}
|
|
11325
|
-
async handleReadFileRequest(taskId, qaAgentId, req) {
|
|
11326
|
-
const task = await this.taskStore.get(taskId);
|
|
11327
|
-
if (!task)
|
|
11328
|
-
return;
|
|
11329
|
-
const dev = this.getAgentConfig(task.agentId);
|
|
11330
|
-
if (!dev)
|
|
11331
|
-
return;
|
|
11332
|
-
await this.refreshWorkdirCacheFor(task.agentId);
|
|
11333
|
-
let body;
|
|
11334
|
-
try {
|
|
11335
|
-
const text = await this.getReviewTransport().readFileRange(dev, req.file, req.startLine, req.endLine);
|
|
11336
|
-
body = `=== baxian read-file ${req.file}:${req.startLine}-${req.endLine} ===\n${text}\n=== end read-file ===`;
|
|
11337
|
-
}
|
|
11338
|
-
catch (err) {
|
|
11339
|
-
const reason = err instanceof Error ? err.message : String(err);
|
|
11340
|
-
body = `=== baxian read-file ${req.file}:${req.startLine}-${req.endLine} REFUSED: ${reason} ===`;
|
|
11341
|
-
}
|
|
11342
|
-
const qaState = await this.agentStore.get(qaAgentId);
|
|
11343
|
-
if (qaState?.taskId !== taskId) {
|
|
11344
|
-
console.warn(`[AgentManager] read-file response dropped: qa=${qaAgentId} no longer bound to ${taskId} (got ${qaState?.taskId})`);
|
|
11345
|
-
return;
|
|
11346
|
-
}
|
|
11347
|
-
try {
|
|
11348
|
-
await this.injectTextToAgent(qaAgentId, body, { expectedTaskId: taskId });
|
|
11349
|
-
}
|
|
11350
|
-
catch (err) {
|
|
11351
|
-
console.warn(`[AgentManager] read-file injection to ${qaAgentId} failed:`, err);
|
|
11352
|
-
}
|
|
11353
|
-
}
|
|
11354
9915
|
async injectTextToAgent(agentId, text, opts = {}) {
|
|
11355
9916
|
const cfg = this.getAgentConfig(agentId);
|
|
11356
9917
|
if (!cfg)
|
|
@@ -11409,35 +9970,13 @@ export class AgentManager {
|
|
|
11409
9970
|
}
|
|
11410
9971
|
}
|
|
11411
9972
|
async confirmHumanGate(taskId) {
|
|
11412
|
-
const task = await this.claimCompleteGate(taskId, ['
|
|
9973
|
+
const task = await this.claimCompleteGate(taskId, ['merge-ready']);
|
|
11413
9974
|
try {
|
|
11414
9975
|
const project = this.getProjectConfig(task.projectId);
|
|
11415
9976
|
const mergeAuto = project?.merge === 'auto';
|
|
11416
|
-
|
|
11417
|
-
if (task.status === 'merge-ready') {
|
|
11418
|
-
if (mergeAuto && task.prNumber) {
|
|
11419
|
-
if (!task.latestHeadSha) {
|
|
11420
|
-
throw new ApiError(409, `Task ${taskId} has no approved head recorded; cannot safely merge`);
|
|
11421
|
-
}
|
|
11422
|
-
await this.executeConfirmMerge(task, () => this.mergePr(taskId, {
|
|
11423
|
-
matchHeadSha: task.latestHeadSha,
|
|
11424
|
-
}));
|
|
11425
|
-
await this.eventBus.emit({
|
|
11426
|
-
id: '',
|
|
11427
|
-
type: 'pr.merged',
|
|
11428
|
-
timestamp: new Date().toISOString(),
|
|
11429
|
-
projectId: task.projectId,
|
|
11430
|
-
agentId: task.agentId,
|
|
11431
|
-
taskId: task.id,
|
|
11432
|
-
data: { prNumber: task.prNumber, ...(task.prUrl ? { prUrl: task.prUrl } : {}) },
|
|
11433
|
-
});
|
|
11434
|
-
return (await this.taskStore.get(taskId));
|
|
11435
|
-
}
|
|
11436
|
-
return this.finishTaskAsDone(taskId);
|
|
11437
|
-
}
|
|
11438
|
-
if (afterDone === 'pr' && mergeAuto && task.prNumber) {
|
|
9977
|
+
if (mergeAuto && task.prNumber) {
|
|
11439
9978
|
if (!task.latestHeadSha) {
|
|
11440
|
-
throw new ApiError(409, `Task ${taskId} has no
|
|
9979
|
+
throw new ApiError(409, `Task ${taskId} has no approved head recorded; cannot safely merge`);
|
|
11441
9980
|
}
|
|
11442
9981
|
await this.executeConfirmMerge(task, () => this.mergePr(taskId, {
|
|
11443
9982
|
matchHeadSha: task.latestHeadSha,
|
|
@@ -11453,13 +9992,6 @@ export class AgentManager {
|
|
|
11453
9992
|
});
|
|
11454
9993
|
return (await this.taskStore.get(taskId));
|
|
11455
9994
|
}
|
|
11456
|
-
if (afterDone === 'branch' && mergeAuto && task.branch) {
|
|
11457
|
-
await this.executeConfirmMerge(task, () => this.ffMergeBranch(task));
|
|
11458
|
-
const merged = await this.transitionTaskStatus(taskId, 'merged', { fromStatus: ['ready'] });
|
|
11459
|
-
if (merged)
|
|
11460
|
-
await this.releaseTaskAgents(taskId);
|
|
11461
|
-
return (await this.taskStore.get(taskId));
|
|
11462
|
-
}
|
|
11463
9995
|
return this.finishTaskAsDone(taskId);
|
|
11464
9996
|
}
|
|
11465
9997
|
finally {
|
|
@@ -11477,9 +10009,6 @@ export class AgentManager {
|
|
|
11477
10009
|
if (this.markCompleteInFlight.has(taskId)) {
|
|
11478
10010
|
throw new ApiError(409, `Task ${taskId} is already being completed`);
|
|
11479
10011
|
}
|
|
11480
|
-
if (this.codeVerdictInFlight.has(taskId)) {
|
|
11481
|
-
throw new ApiError(409, `Task ${taskId} code verdict is already being processed`);
|
|
11482
|
-
}
|
|
11483
10012
|
this.markCompleteInFlight.add(taskId);
|
|
11484
10013
|
return fresh;
|
|
11485
10014
|
});
|
|
@@ -11490,7 +10019,7 @@ export class AgentManager {
|
|
|
11490
10019
|
}
|
|
11491
10020
|
catch (err) {
|
|
11492
10021
|
const message = err instanceof Error ? err.message : String(err);
|
|
11493
|
-
if (err instanceof PlatformMergeRecheckError && task.
|
|
10022
|
+
if (err instanceof PlatformMergeRecheckError && task.status === 'merge-ready') {
|
|
11494
10023
|
let retreated = false;
|
|
11495
10024
|
if (err.kind === 'pending-feedback') {
|
|
11496
10025
|
retreated = await this.withTaskLock(async () => {
|
|
@@ -11564,7 +10093,7 @@ export class AgentManager {
|
|
|
11564
10093
|
}
|
|
11565
10094
|
}
|
|
11566
10095
|
async finishTaskAsDone(taskId) {
|
|
11567
|
-
const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['
|
|
10096
|
+
const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['merge-ready'] });
|
|
11568
10097
|
if (!done)
|
|
11569
10098
|
throw new ApiError(409, `Task ${taskId} changed status during confirm; aborted`);
|
|
11570
10099
|
await this.releaseTaskAgents(taskId);
|
|
@@ -11604,49 +10133,6 @@ export class AgentManager {
|
|
|
11604
10133
|
})().catch(err => console.warn(`[AgentManager] releaseTaskAgents compaction(${agentId}) failed:`, err));
|
|
11605
10134
|
return true;
|
|
11606
10135
|
}
|
|
11607
|
-
repoMergeQueue = new Map();
|
|
11608
|
-
async ffMergeBranch(task) {
|
|
11609
|
-
const dev = this.getAgentConfig(task.agentId);
|
|
11610
|
-
if (!dev)
|
|
11611
|
-
throw new Error(`ffMergeBranch: no dev agent for task ${task.id}`);
|
|
11612
|
-
const state = await this.agentStore.get(task.agentId);
|
|
11613
|
-
const workdir = state?.workdir;
|
|
11614
|
-
if (!workdir)
|
|
11615
|
-
throw new Error(`ffMergeBranch: no Workdir for agent ${task.agentId}`);
|
|
11616
|
-
const branch = task.branch ?? BRANCH_PREFIX + task.id;
|
|
11617
|
-
const runner = this.createRunnerFor(dev);
|
|
11618
|
-
const prev = this.repoMergeQueue.get(task.projectId) ?? Promise.resolve();
|
|
11619
|
-
const run = prev.then(async () => {
|
|
11620
|
-
// fetch/push reach a remote derived from THIS Workdir; a rebound Workdir would redirect the push to a foreign repo, so each mutation carries the canonical self-guard in its own command.
|
|
11621
|
-
const guard = `${canonicalSelfGuard(workdir)} && `;
|
|
11622
|
-
const cd = `cd ${shellQuote(workdir)} && `;
|
|
11623
|
-
const db = await runner.exec(`${cd}git symbolic-ref --short refs/remotes/origin/HEAD`);
|
|
11624
|
-
const defaultBranch = db.stdout.trim().replace(/^origin\//, '');
|
|
11625
|
-
if (db.exitCode !== 0 || defaultBranch === '') {
|
|
11626
|
-
throw new Error(`ffMergeBranch: cannot resolve default branch: ${db.stderr.trim() || 'empty origin/HEAD'}`);
|
|
11627
|
-
}
|
|
11628
|
-
const fetch = await execNetwork(runner, `${guard}${cd}${GIT_NET_ENV} git fetch origin`);
|
|
11629
|
-
if (fetch.exitCode !== 0) {
|
|
11630
|
-
throw new Error(`ffMergeBranch [git fetch] failed: ${fetch.stderr.trim()}`);
|
|
11631
|
-
}
|
|
11632
|
-
if (task.latestHeadSha) {
|
|
11633
|
-
const remoteHead = await runner.exec(`${cd}git rev-parse ${shellQuote(`origin/${branch}`)}`);
|
|
11634
|
-
if (remoteHead.exitCode !== 0 || remoteHead.stdout.trim() !== task.latestHeadSha) {
|
|
11635
|
-
throw new Error(`ffMergeBranch: origin/${branch} head ${remoteHead.stdout.trim() || '<unresolved>'} ` +
|
|
11636
|
-
`!= reviewed head ${task.latestHeadSha}; refusing to merge un-reviewed commits`);
|
|
11637
|
-
}
|
|
11638
|
-
}
|
|
11639
|
-
else {
|
|
11640
|
-
throw new Error(`ffMergeBranch: no reviewed head recorded for task ${task.id}; cannot safely merge`);
|
|
11641
|
-
}
|
|
11642
|
-
const push = await execNetwork(runner, `${guard}${cd}${GIT_NET_ENV} git push origin ${shellQuote(`origin/${branch}`)}:${shellQuote(defaultBranch)}`);
|
|
11643
|
-
if (push.exitCode !== 0) {
|
|
11644
|
-
throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
|
|
11645
|
-
}
|
|
11646
|
-
});
|
|
11647
|
-
this.repoMergeQueue.set(task.projectId, run.catch(() => undefined));
|
|
11648
|
-
await run;
|
|
11649
|
-
}
|
|
11650
10136
|
}
|
|
11651
10137
|
function buildAgentIndex(config) {
|
|
11652
10138
|
const index = new Map();
|