baxian 2.0.0 → 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 -13
- 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 +19 -14
- package/dist/agent/dispatch-reconciler.js.map +1 -1
- package/dist/agent/manager.d.ts +90 -134
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +1154 -2265
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/net-exec.d.ts +3 -0
- package/dist/agent/net-exec.d.ts.map +1 -1
- package/dist/agent/net-exec.js +6 -0
- package/dist/agent/net-exec.js.map +1 -1
- package/dist/agent/pane-streamer-manager.d.ts +6 -6
- package/dist/agent/pane-streamer-manager.d.ts.map +1 -1
- package/dist/agent/pane-streamer-manager.js.map +1 -1
- package/dist/agent/pane-streamer.d.ts +6 -3
- package/dist/agent/pane-streamer.d.ts.map +1 -1
- package/dist/agent/pane-streamer.js +70 -40
- package/dist/agent/pane-streamer.js.map +1 -1
- package/dist/agent/phase-signal-watcher.d.ts +7 -2
- package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
- package/dist/agent/phase-signal-watcher.js +90 -32
- package/dist/agent/phase-signal-watcher.js.map +1 -1
- package/dist/agent/phase-signal.d.ts +19 -38
- package/dist/agent/phase-signal.d.ts.map +1 -1
- package/dist/agent/phase-signal.js +54 -88
- 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 -35
- package/dist/agent/prompt.d.ts.map +1 -1
- package/dist/agent/prompt.js +22 -162
- package/dist/agent/prompt.js.map +1 -1
- package/dist/agent/repo-store.d.ts +4 -1
- package/dist/agent/repo-store.d.ts.map +1 -1
- package/dist/agent/repo-store.js +33 -11
- package/dist/agent/repo-store.js.map +1 -1
- package/dist/agent/root-agent-runtime.d.ts +80 -0
- package/dist/agent/root-agent-runtime.d.ts.map +1 -0
- package/dist/agent/root-agent-runtime.js +637 -0
- package/dist/agent/root-agent-runtime.js.map +1 -0
- package/dist/agent/root-recovery-coordinator.d.ts +124 -0
- package/dist/agent/root-recovery-coordinator.d.ts.map +1 -0
- package/dist/agent/root-recovery-coordinator.js +1557 -0
- package/dist/agent/root-recovery-coordinator.js.map +1 -0
- package/dist/agent/runner.d.ts +1 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +103 -7
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/tmux-probe-poller.d.ts +1 -0
- package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
- package/dist/agent/tmux-probe-poller.js +16 -10
- package/dist/agent/tmux-probe-poller.js.map +1 -1
- package/dist/agent/tmux.d.ts +3 -0
- package/dist/agent/tmux.d.ts.map +1 -1
- package/dist/agent/tmux.js +11 -5
- package/dist/agent/tmux.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/agents.d.ts.map +1 -1
- package/dist/api/agents.js +42 -1
- package/dist/api/agents.js.map +1 -1
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +83 -9
- package/dist/api/config.js.map +1 -1
- package/dist/api/hosts.d.ts.map +1 -1
- package/dist/api/hosts.js +68 -13
- package/dist/api/hosts.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 +621 -325
- 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/app.d.ts +2 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +6 -0
- package/dist/app.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +49 -7
- package/dist/cli.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 +57 -24
- package/dist/config/loader.js.map +1 -1
- package/dist/config/normalizer.d.ts.map +1 -1
- package/dist/config/normalizer.js +18 -0
- package/dist/config/normalizer.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 +251 -96
- package/dist/config/validator.js.map +1 -1
- package/dist/event/handlers.d.ts.map +1 -1
- package/dist/event/handlers.js +359 -229
- package/dist/event/handlers.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +45 -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 +1 -11
- 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 +8 -7
- package/dist/shared/constants.d.ts.map +1 -1
- package/dist/shared/constants.js +6 -28
- package/dist/shared/constants.js.map +1 -1
- package/dist/shared/types.d.ts +52 -125
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js +24 -5
- package/dist/shared/types.js.map +1 -1
- package/dist/skills/baxian-pr-feedback/SKILL.md +6 -6
- package/dist/skills/baxian-pr-recheck/SKILL.md +6 -0
- package/dist/skills/baxian-pr-review/SKILL.md +6 -0
- package/dist/skills/baxian-signals/SKILL.md +17 -28
- package/dist/skills/baxian-task-check/SKILL.md +13 -14
- package/dist/state/init.d.ts.map +1 -1
- package/dist/state/init.js +1 -0
- package/dist/state/init.js.map +1 -1
- package/dist/state/lock.d.ts +6 -0
- package/dist/state/lock.d.ts.map +1 -1
- package/dist/state/lock.js +8 -0
- package/dist/state/lock.js.map +1 -1
- package/dist/state/root-recovery-store.d.ts +107 -0
- package/dist/state/root-recovery-store.d.ts.map +1 -0
- package/dist/state/root-recovery-store.js +400 -0
- package/dist/state/root-recovery-store.js.map +1 -0
- 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 +57 -137
- package/dist/state/task-store.js.map +1 -1
- package/dist/terminal/attach.d.ts +3 -3
- package/dist/terminal/attach.d.ts.map +1 -1
- package/dist/terminal/attach.js.map +1 -1
- package/dist/terminal/stream-ws.d.ts.map +1 -1
- package/dist/terminal/stream-ws.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 -1221
- 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,32 @@ 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';
|
|
30
|
+
// 这两个入口冲突证明当前调用已被并发 pass 接管,应判 stale 而不是派发失败。
|
|
31
|
+
const BENIGN_DISPATCH_CONFLICT_CODES = new Set(['dispatch-superseded', 'dispatch-in-flight']);
|
|
32
|
+
export function isBenignDispatchConflict(err) {
|
|
33
|
+
return err instanceof ApiError
|
|
34
|
+
&& err.status === 409
|
|
35
|
+
&& err.code !== undefined
|
|
36
|
+
&& BENIGN_DISPATCH_CONFLICT_CODES.has(err.code);
|
|
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
|
+
}
|
|
31
43
|
export class EnsureSessionError extends Error {
|
|
32
44
|
partial;
|
|
33
45
|
constructor(partial, message) {
|
|
@@ -169,8 +181,7 @@ export class PlatformMergeRecheckError extends Error {
|
|
|
169
181
|
this.name = 'PlatformMergeRecheckError';
|
|
170
182
|
}
|
|
171
183
|
}
|
|
172
|
-
const
|
|
173
|
-
const IMAGE_DISPATCH_PHASES = new Set(['develop', 'research', 'code', 'fix', 'server-feedback']);
|
|
184
|
+
const IMAGE_DISPATCH_PHASES = new Set(['develop', 'code', 'fix']);
|
|
174
185
|
const RUNTIME_LIVENESS_SAMPLES = 3;
|
|
175
186
|
export function canDispatchWithBinding(binding) {
|
|
176
187
|
return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
|
|
@@ -185,7 +196,6 @@ export function isRecoverableQaDispatchHold(binding) {
|
|
|
185
196
|
&& binding.awaitingPhase != null
|
|
186
197
|
&& RECOVERABLE_QA_DISPATCH_HOLD_PHASES.has(binding.awaitingPhase);
|
|
187
198
|
}
|
|
188
|
-
const TURN_COMPLETED_AWAITING_PHASES = new Set();
|
|
189
199
|
const CANCEL_CLEANUP_HOLD_PHASES = new Set(['cancel-clearing', 'cancel-interrupt-failed']);
|
|
190
200
|
function isCancelCleanupHold(binding) {
|
|
191
201
|
return binding?.awaitingPhase != null && CANCEL_CLEANUP_HOLD_PHASES.has(binding.awaitingPhase);
|
|
@@ -210,7 +220,6 @@ function shouldReleaseHeldBinding(state, boundTask) {
|
|
|
210
220
|
if (state.awaitingPhase != null && REGREET_REQUIRED_HOLD_PHASES.has(state.awaitingPhase))
|
|
211
221
|
return false;
|
|
212
222
|
const taskIsTerminal = !!boundTask && TERMINAL_STATUSES.includes(boundTask.status);
|
|
213
|
-
const turnCompleted = state.awaitingPhase != null && TURN_COMPLETED_AWAITING_PHASES.has(state.awaitingPhase);
|
|
214
223
|
// 任务已退回 verdict 门禁且当前持有者不是本 agent:这次绑定是转码失败的残留
|
|
215
224
|
// (fresh-acquire 释放被脏树挡下),Resume 时补完释放,否则 dev 被永久占住
|
|
216
225
|
const staleCodeHandoff = state.awaitingPhase != null
|
|
@@ -218,7 +227,10 @@ function shouldReleaseHeldBinding(state, boundTask) {
|
|
|
218
227
|
&& !!boundTask
|
|
219
228
|
&& (boundTask.status === 'spec-ready' || boundTask.status === 'max_rounds')
|
|
220
229
|
&& boundTask.agentId !== state.id;
|
|
221
|
-
|
|
230
|
+
const qaAtVerdictGate = !!boundTask
|
|
231
|
+
&& (boundTask.status === 'spec-ready' || boundTask.status === 'max_rounds')
|
|
232
|
+
&& boundTask.qaAgentId === state.id;
|
|
233
|
+
return !boundTask || taskIsTerminal || staleCodeHandoff || qaAtVerdictGate;
|
|
222
234
|
}
|
|
223
235
|
export class AgentManager {
|
|
224
236
|
config;
|
|
@@ -236,9 +248,6 @@ export class AgentManager {
|
|
|
236
248
|
platformDriverExec;
|
|
237
249
|
phaseSignalWatcher;
|
|
238
250
|
errorRecordStore;
|
|
239
|
-
reviewStore;
|
|
240
|
-
reviewTransportInstance;
|
|
241
|
-
serverReviewDriver;
|
|
242
251
|
dispatchAckTimeoutMs;
|
|
243
252
|
needInputRetryIntervalMs;
|
|
244
253
|
dispatchSettleTimeoutMs;
|
|
@@ -262,10 +271,8 @@ export class AgentManager {
|
|
|
262
271
|
readyStableSpacingMs = 1_000;
|
|
263
272
|
pendingDispatchRetryByTask = new Map();
|
|
264
273
|
cancelInterruptGuardWaitMs = DEFAULT_DISPATCH_ACK_TIMEOUT_MS + 5_000;
|
|
265
|
-
manualReviewInFlight = new Set();
|
|
266
274
|
markCompleteInFlight = new Set();
|
|
267
275
|
specVerdictInFlight = new Set();
|
|
268
|
-
codeVerdictInFlight = new Set();
|
|
269
276
|
// 引用计数:cancelTask 与 startCreatedTaskSession 的清理段可合法并存(启动期 Stop),先完成者不得清掉后者的 guard。
|
|
270
277
|
cancelCleanupInFlight = new Map();
|
|
271
278
|
deletionInFlight = new Set();
|
|
@@ -298,7 +305,6 @@ export class AgentManager {
|
|
|
298
305
|
})
|
|
299
306
|
: undefined);
|
|
300
307
|
this.needInputRetryIntervalMs = deps.needInputRetryIntervalMs ?? DEFAULT_NEED_INPUT_RETRY_INTERVAL_MS;
|
|
301
|
-
this.reviewStore = deps.reviewStore;
|
|
302
308
|
this.dispatchAckTimeoutMs = deps.dispatchAckTimeoutMs ?? DEFAULT_DISPATCH_ACK_TIMEOUT_MS;
|
|
303
309
|
this.dispatchSettleTimeoutMs = deps.dispatchSettleTimeoutMs ?? DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS;
|
|
304
310
|
this.cancelInterruptGuardWaitMs = this.dispatchAckTimeoutMs + 5_000;
|
|
@@ -363,67 +369,6 @@ export class AgentManager {
|
|
|
363
369
|
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
364
370
|
return state;
|
|
365
371
|
}
|
|
366
|
-
getReviewStore() {
|
|
367
|
-
return this.reviewStore;
|
|
368
|
-
}
|
|
369
|
-
setServerReviewDriver(driver) {
|
|
370
|
-
this.serverReviewDriver = driver;
|
|
371
|
-
}
|
|
372
|
-
effectiveReviewMode(projectId) {
|
|
373
|
-
const project = this.getProjectConfig(projectId);
|
|
374
|
-
return project?.review?.mode ?? this.config.review.mode ?? 'git';
|
|
375
|
-
}
|
|
376
|
-
resolveAfterDone(task) {
|
|
377
|
-
if (task.afterDone !== undefined)
|
|
378
|
-
return task.afterDone;
|
|
379
|
-
return this.coerceAfterDone(task.projectId);
|
|
380
|
-
}
|
|
381
|
-
coerceAfterDone(projectId) {
|
|
382
|
-
const project = this.getProjectConfig(projectId);
|
|
383
|
-
return project ? projectAfterDone(this.config, project) : (this.config.review.afterDone ?? null);
|
|
384
|
-
}
|
|
385
|
-
async commitServerAfterDone(taskId) {
|
|
386
|
-
return this.withTaskLock(async () => {
|
|
387
|
-
const task = await this.taskStore.get(taskId);
|
|
388
|
-
if (!task)
|
|
389
|
-
throw new ApiError(404, `Task ${taskId} not found`);
|
|
390
|
-
if (task.afterDone === 'pr' && task.platformBinding === undefined) {
|
|
391
|
-
throw new Error(`platform binding missing for task ${task.id}; refusing to reconstruct it from live config`);
|
|
392
|
-
}
|
|
393
|
-
if (task.afterDone === 'pr')
|
|
394
|
-
this.assertPlatformBinding(task);
|
|
395
|
-
const afterDone = this.resolveAfterDone(task);
|
|
396
|
-
const shouldSnapshotBinding = task.afterDone === undefined && afterDone === 'pr';
|
|
397
|
-
const defaultBaseBranch = afterDone === 'pr' && task.baseBranch === undefined
|
|
398
|
-
? this.platformDefaultBranchOf?.(task.projectId)
|
|
399
|
-
: undefined;
|
|
400
|
-
if (task.afterDone === afterDone && !shouldSnapshotBinding && defaultBaseBranch === undefined)
|
|
401
|
-
return afterDone;
|
|
402
|
-
await this.taskStore.set({
|
|
403
|
-
...task,
|
|
404
|
-
afterDone,
|
|
405
|
-
...(shouldSnapshotBinding ? { platformBinding: this.platformBindingFor(task.projectId) } : {}),
|
|
406
|
-
...(defaultBaseBranch !== undefined ? { baseBranch: defaultBaseBranch } : {}),
|
|
407
|
-
updatedAt: new Date().toISOString(),
|
|
408
|
-
});
|
|
409
|
-
return afterDone;
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
getReviewTransport() {
|
|
413
|
-
this.reviewTransportInstance ??= new ReviewTransport({
|
|
414
|
-
createRunnerFor: (agent) => this.createRunnerFor(agent),
|
|
415
|
-
resolveWorkdir: (agentId) => this.bindingWorkdirCache.get(agentId),
|
|
416
|
-
});
|
|
417
|
-
return this.reviewTransportInstance;
|
|
418
|
-
}
|
|
419
|
-
async prepareDispatchArtifacts(agent, workdir, phase, opts) {
|
|
420
|
-
const transport = this.getReviewTransport();
|
|
421
|
-
if (!opts.preserveOutputs)
|
|
422
|
-
await transport.clearDispatchOutputs(agent, workdir, phase);
|
|
423
|
-
if (opts.specDocuments) {
|
|
424
|
-
await transport.replaceSpecDocuments(agent, workdir, opts.specDocuments, opts.assertOwner);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
372
|
bindingWorkdirCache = new Map();
|
|
428
373
|
async refreshWorkdirCacheFor(agentId) {
|
|
429
374
|
const state = await this.agentStore.get(agentId);
|
|
@@ -542,13 +487,13 @@ export class AgentManager {
|
|
|
542
487
|
this.deletionInFlight.add(id);
|
|
543
488
|
return null;
|
|
544
489
|
}
|
|
545
|
-
// Scan + claim share createTask/dispatchPendingTask's withTaskLock, so a delete can't pass an
|
|
546
|
-
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) {
|
|
547
492
|
return this.withTaskLock(async () => {
|
|
548
493
|
for (const id of targets) {
|
|
549
494
|
const state = await this.agentStore.get(id);
|
|
550
495
|
const task = state?.taskId ? await this.taskStore.get(state.taskId) : null;
|
|
551
|
-
if (task &&
|
|
496
|
+
if (task && isTaskOpen(task.status)) {
|
|
552
497
|
return { ok: false, code: 'active', agentId: id, taskId: task.id };
|
|
553
498
|
}
|
|
554
499
|
if (state?.status === 'awaiting_human')
|
|
@@ -557,12 +502,11 @@ export class AgentManager {
|
|
|
557
502
|
return { ok: false, code: 'bootstrapping', agentId: id };
|
|
558
503
|
}
|
|
559
504
|
const targetSet = new Set(targets);
|
|
560
|
-
const referencing = (await this.taskStore.list({})).find(t =>
|
|
505
|
+
const referencing = (await this.taskStore.list({})).find(t => isTaskOpen(t.status)
|
|
561
506
|
&& ((t.preferredAgentId !== '' && targetSet.has(t.preferredAgentId))
|
|
562
507
|
|| (!!t.agentId && targetSet.has(t.agentId))
|
|
563
508
|
|| (!!t.devAgentId && targetSet.has(t.devAgentId))
|
|
564
|
-
|| (!!t.qaAgentId && targetSet.has(t.qaAgentId))
|
|
565
|
-
|| (!!t.researchAgentId && targetSet.has(t.researchAgentId))));
|
|
509
|
+
|| (!!t.qaAgentId && targetSet.has(t.qaAgentId))));
|
|
566
510
|
if (referencing)
|
|
567
511
|
return { ok: false, code: 'referencing', taskId: referencing.id };
|
|
568
512
|
const conflict = this.tryClaimDeletion(targets);
|
|
@@ -594,7 +538,6 @@ export class AgentManager {
|
|
|
594
538
|
add(task.agentId);
|
|
595
539
|
add(task.devAgentId, 'dev');
|
|
596
540
|
add(task.qaAgentId, 'qa');
|
|
597
|
-
add(task.researchAgentId, 'research');
|
|
598
541
|
return { taskId: task.id, projectId: task.projectId, participants: [...byId.values()] };
|
|
599
542
|
});
|
|
600
543
|
}
|
|
@@ -695,9 +638,8 @@ export class AgentManager {
|
|
|
695
638
|
}
|
|
696
639
|
agentSkillScope(agentId) {
|
|
697
640
|
const projectId = this.getAgentConfig(agentId)?.projectId;
|
|
698
|
-
if (projectId === undefined
|
|
641
|
+
if (projectId === undefined)
|
|
699
642
|
return { pluginTools: [] };
|
|
700
|
-
}
|
|
701
643
|
const project = this.getProjectConfig(projectId);
|
|
702
644
|
const tool = project === undefined ? undefined : resolveProjectTool(project);
|
|
703
645
|
return { pluginTools: tool === undefined ? [] : [tool] };
|
|
@@ -1719,10 +1661,7 @@ export class AgentManager {
|
|
|
1719
1661
|
? await this.resolveTaskLockToken(state, taskId)
|
|
1720
1662
|
: null;
|
|
1721
1663
|
const sameTaskLocked = existingToken !== null;
|
|
1722
|
-
const reentryPhases = new Set([
|
|
1723
|
-
'fix', 'post-approve', 'code',
|
|
1724
|
-
'server-feedback', 'server-after-done',
|
|
1725
|
-
]);
|
|
1664
|
+
const reentryPhases = new Set(['fix', 'post-approve', 'code']);
|
|
1726
1665
|
const sameTaskReentry = state?.taskId === taskId &&
|
|
1727
1666
|
!state.creationToken &&
|
|
1728
1667
|
state.status !== 'awaiting_human' &&
|
|
@@ -1814,9 +1753,13 @@ export class AgentManager {
|
|
|
1814
1753
|
}
|
|
1815
1754
|
if (opts.expectedTask
|
|
1816
1755
|
&& boundTask
|
|
1817
|
-
&& (boundTask.status !== opts.expectedTask.status
|
|
1818
|
-
|| boundTask.phase !== opts.expectedTask.phase
|
|
1819
|
-
|| boundTask.signalToken !== opts.expectedTask.signalToken)
|
|
1756
|
+
&& (('status' in opts.expectedTask && boundTask.status !== opts.expectedTask.status)
|
|
1757
|
+
|| ('phase' in opts.expectedTask && boundTask.phase !== opts.expectedTask.phase)
|
|
1758
|
+
|| ('signalToken' in opts.expectedTask && boundTask.signalToken !== opts.expectedTask.signalToken)
|
|
1759
|
+
|| ('agentId' in opts.expectedTask && boundTask.agentId !== opts.expectedTask.agentId)
|
|
1760
|
+
|| ('reviewRound' in opts.expectedTask && boundTask.reviewRound !== opts.expectedTask.reviewRound)
|
|
1761
|
+
|| ('specReviewRound' in opts.expectedTask
|
|
1762
|
+
&& boundTask.specReviewRound !== opts.expectedTask.specReviewRound))) {
|
|
1820
1763
|
console.warn(`[AgentManager] releaseAgentForTask: task ${expectedTaskId} moved past the captured pass; skipping ${mode} transition`);
|
|
1821
1764
|
return false;
|
|
1822
1765
|
}
|
|
@@ -2284,7 +2227,7 @@ export class AgentManager {
|
|
|
2284
2227
|
// ---- need-input watermark: epoch-fenced monotonic persistence + retry convergence ----
|
|
2285
2228
|
needInputRetry = new Map();
|
|
2286
2229
|
needInputRetryInFlight = new Set();
|
|
2287
|
-
|
|
2230
|
+
gitReviewRecoveryAlerted = new Set();
|
|
2288
2231
|
// Watermark writes currently in the store chain. A restore bump snapshots these along
|
|
2289
2232
|
// with the queue: an answer whose write has not settled yet would otherwise be invisible
|
|
2290
2233
|
// to the migration and its error-enqueue would land after the arm already cleared the
|
|
@@ -2930,6 +2873,9 @@ export class AgentManager {
|
|
|
2930
2873
|
status: boundTask.status,
|
|
2931
2874
|
phase: boundTask.phase,
|
|
2932
2875
|
signalToken: boundTask.signalToken,
|
|
2876
|
+
agentId: boundTask.agentId,
|
|
2877
|
+
reviewRound: boundTask.reviewRound,
|
|
2878
|
+
specReviewRound: boundTask.specReviewRound,
|
|
2933
2879
|
},
|
|
2934
2880
|
heldLockToken: state.lockToken,
|
|
2935
2881
|
projectId: state.projectId,
|
|
@@ -3036,15 +2982,11 @@ export class AgentManager {
|
|
|
3036
2982
|
if (result.redispatchCodeTaskId) {
|
|
3037
2983
|
try {
|
|
3038
2984
|
const task = await this.taskStore.get(result.redispatchCodeTaskId);
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
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);
|
|
3045
|
-
return { resumed: false, releasedBinding: false, reason: 'Persisted Spec handoff is missing or invalid.' };
|
|
3046
|
-
}
|
|
3047
|
-
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);
|
|
3048
2990
|
if (!resumed) {
|
|
3049
2991
|
const reason = 'Code-phase redispatch on Resume was not delivered; Resume again to retry or cancel the task.';
|
|
3050
2992
|
await this.markAwaitingHuman(agentId, 'code-dispatch-failed', reason, { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
|
|
@@ -3052,10 +2994,8 @@ export class AgentManager {
|
|
|
3052
2994
|
}
|
|
3053
2995
|
// 重派与首派同样不经 startSession 的 post-ack finalize:残留的 bootstrap 标记
|
|
3054
2996
|
// 会让下一次 recover 把已送达的 code prompt 再判成未送达
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
await this.finalizeReplayedBootstrap(agentId, result.redispatchCodeTaskId, 'code', lockToken);
|
|
3058
|
-
}
|
|
2997
|
+
const lockToken = (await this.agentStore.get(agentId))?.lockToken;
|
|
2998
|
+
await this.finalizeReplayedBootstrap(agentId, result.redispatchCodeTaskId, 'code', lockToken);
|
|
3059
2999
|
}
|
|
3060
3000
|
catch (err) {
|
|
3061
3001
|
console.error(`[AgentManager] resumeAgent code redispatch failed for ${agentId}:`, err);
|
|
@@ -3100,12 +3040,13 @@ export class AgentManager {
|
|
|
3100
3040
|
&& resumeEntry.signalToken === reviewTask?.signalToken
|
|
3101
3041
|
&& resumeEntry.qaPhase !== undefined
|
|
3102
3042
|
? resumeEntry.qaPhase
|
|
3103
|
-
: (reviewTask
|
|
3043
|
+
: (reviewTask && taskReviewRound(reviewTask) === 0 ? 'review' : undefined);
|
|
3104
3044
|
await this.dispatchReviewToQa(result.redispatchReviewTaskId, {
|
|
3105
3045
|
bumpRound: reviewTask?.reviewRoundPending === true,
|
|
3106
3046
|
fromStatus: ['review'],
|
|
3107
3047
|
expectPhase: reviewPass.phase,
|
|
3108
3048
|
expectSignalToken: reviewPass.signalToken,
|
|
3049
|
+
expectedTask: reviewPass,
|
|
3109
3050
|
...(resumeQaPhase !== undefined ? { qaPhase: resumeQaPhase } : {}),
|
|
3110
3051
|
onQaAcquired: (lockToken) => { recoveryLockToken = lockToken; },
|
|
3111
3052
|
});
|
|
@@ -3163,9 +3104,7 @@ export class AgentManager {
|
|
|
3163
3104
|
let recoveryReadFailed = false;
|
|
3164
3105
|
try {
|
|
3165
3106
|
const taskNow = await this.taskStore.get(reviewTaskId);
|
|
3166
|
-
passSuperseded = taskNow
|
|
3167
|
-
|| taskNow.phase !== reviewPass.phase
|
|
3168
|
-
|| taskNow.signalToken !== reviewPass.signalToken;
|
|
3107
|
+
passSuperseded = !taskNow || !taskMatchesGeneration(taskNow, reviewPass);
|
|
3169
3108
|
}
|
|
3170
3109
|
catch (readErr) {
|
|
3171
3110
|
recoveryReadFailed = true;
|
|
@@ -3386,9 +3325,16 @@ export class AgentManager {
|
|
|
3386
3325
|
...(opts.expectedReviewDispatch !== undefined
|
|
3387
3326
|
? { expectReviewDispatch: opts.expectedReviewDispatch }
|
|
3388
3327
|
: {}),
|
|
3328
|
+
...(opts.expectedTask !== undefined ? { expectTask: opts.expectedTask } : {}),
|
|
3329
|
+
...(opts.expectedLockToken !== undefined
|
|
3330
|
+
? { expectAgentLock: { agentId, token: opts.expectedLockToken } }
|
|
3331
|
+
: {}),
|
|
3389
3332
|
});
|
|
3390
3333
|
if (!transitioned
|
|
3391
|
-
&& (opts.expectedPostApproveEpisode !== undefined
|
|
3334
|
+
&& (opts.expectedPostApproveEpisode !== undefined
|
|
3335
|
+
|| opts.expectedReviewDispatch !== undefined
|
|
3336
|
+
|| opts.expectedTask !== undefined
|
|
3337
|
+
|| opts.expectedLockToken !== undefined)) {
|
|
3392
3338
|
console.warn(`[AgentManager] failTaskForDispatchError: dispatch generation for ${taskId} was superseded; skipping stale failure`);
|
|
3393
3339
|
return;
|
|
3394
3340
|
}
|
|
@@ -3446,7 +3392,7 @@ export class AgentManager {
|
|
|
3446
3392
|
for (const t of tasks) {
|
|
3447
3393
|
const bound = t.agentId === agentId || t.qaAgentId === agentId;
|
|
3448
3394
|
// spec-ready 不豁免:approve/打回都依赖 dev Workdir,dev 丢失后停驻只是假活
|
|
3449
|
-
if (t.status === '
|
|
3395
|
+
if (t.status === 'merge-ready')
|
|
3450
3396
|
continue;
|
|
3451
3397
|
if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
|
|
3452
3398
|
const failedTask = this.stripGitStatusScopedState(t, 'failed');
|
|
@@ -3732,16 +3678,18 @@ export class AgentManager {
|
|
|
3732
3678
|
retireOldOnce();
|
|
3733
3679
|
}
|
|
3734
3680
|
}
|
|
3735
|
-
async redispatchTaskPromptAfterReplRestart(agentId, taskId) {
|
|
3681
|
+
async redispatchTaskPromptAfterReplRestart(agentId, taskId, expected) {
|
|
3736
3682
|
const agent = this.getAgentConfig(agentId);
|
|
3737
3683
|
const loadedTask = await this.taskStore.get(taskId);
|
|
3738
3684
|
if (!agent || !loadedTask || !TASK_OWNER_ROLES.has(agent.role))
|
|
3739
3685
|
return false;
|
|
3740
|
-
|
|
3686
|
+
if (expected && !taskMatchesGeneration(loadedTask, expected))
|
|
3687
|
+
return false;
|
|
3688
|
+
const rootGenerationMatches = (fresh, signalToken = expected?.signalToken) => !expected || taskMatchesGeneration(fresh, { ...expected, signalToken });
|
|
3689
|
+
const task = loadedTask;
|
|
3741
3690
|
if (task.agentId !== agentId)
|
|
3742
3691
|
return false;
|
|
3743
|
-
|
|
3744
|
-
if (participantId !== agentId)
|
|
3692
|
+
if (task.devAgentId !== agentId)
|
|
3745
3693
|
return false;
|
|
3746
3694
|
const bindingAtEntry = await this.agentStore.get(agentId);
|
|
3747
3695
|
const entryHold = {
|
|
@@ -3754,7 +3702,8 @@ export class AgentManager {
|
|
|
3754
3702
|
return fresh?.agentId === agentId
|
|
3755
3703
|
&& fresh.status === task.status
|
|
3756
3704
|
&& fresh.phase === task.phase
|
|
3757
|
-
&& fresh.signalToken === signalToken
|
|
3705
|
+
&& fresh.signalToken === signalToken
|
|
3706
|
+
&& rootGenerationMatches(fresh, signalToken);
|
|
3758
3707
|
};
|
|
3759
3708
|
const clearEntryHold = async () => this.withTaskLock(async () => {
|
|
3760
3709
|
if (!(await taskStillCurrent()))
|
|
@@ -3765,32 +3714,10 @@ export class AgentManager {
|
|
|
3765
3714
|
}
|
|
3766
3715
|
return this.clearAwaitingHuman(agentId, { expectedHold: entryHold });
|
|
3767
3716
|
});
|
|
3768
|
-
const holdReplayFailure = (phase, reason) => this.holdReplayFailureIfCurrent(agentId, task, phase, reason, entryHold);
|
|
3717
|
+
const holdReplayFailure = (phase, reason) => this.holdReplayFailureIfCurrent(agentId, task, phase, reason, entryHold, async (fresh) => rootGenerationMatches(fresh, task.signalToken));
|
|
3769
3718
|
try {
|
|
3770
3719
|
if (task.status === 'approved' && agent.role === 'dev') {
|
|
3771
|
-
return await this.replayApprovedHolderAfterReplRestart(agentId, task, entryHold, { clearEntryHold, holdReplayFailure });
|
|
3772
|
-
}
|
|
3773
|
-
if (task.serverSignalRecovery) {
|
|
3774
|
-
const recovery = task.serverSignalRecovery;
|
|
3775
|
-
if (recovery.mode !== 'hold') {
|
|
3776
|
-
if (!(await clearEntryHold()))
|
|
3777
|
-
return false;
|
|
3778
|
-
await this.recoverServerSignalRecovery(task);
|
|
3779
|
-
return true;
|
|
3780
|
-
}
|
|
3781
|
-
const cleared = await this.updateTaskIfStatus(task.id, task.status, { serverSignalRecovery: undefined }, {
|
|
3782
|
-
signalToken: task.signalToken,
|
|
3783
|
-
phase: task.phase,
|
|
3784
|
-
...(recovery.phase === 'spec'
|
|
3785
|
-
? { specReviewRound: task.specReviewRound }
|
|
3786
|
-
: { reviewRound: task.reviewRound }),
|
|
3787
|
-
});
|
|
3788
|
-
if (!cleared)
|
|
3789
|
-
return false;
|
|
3790
|
-
const fresh = await this.taskStore.get(task.id);
|
|
3791
|
-
if (!fresh)
|
|
3792
|
-
return false;
|
|
3793
|
-
task = fresh;
|
|
3720
|
+
return await this.replayApprovedHolderAfterReplRestart(agentId, task, entryHold, { clearEntryHold, holdReplayFailure, rootGenerationMatches });
|
|
3794
3721
|
}
|
|
3795
3722
|
const mapped = this.mapTaskStateToExpectedWatcher(task);
|
|
3796
3723
|
if (!mapped || mapped.agentId !== agentId)
|
|
@@ -3809,7 +3736,8 @@ export class AgentManager {
|
|
|
3809
3736
|
if (fresh?.agentId !== agentId
|
|
3810
3737
|
|| fresh.status !== task.status
|
|
3811
3738
|
|| fresh.phase !== task.phase
|
|
3812
|
-
|| fresh.signalToken !== signalToken
|
|
3739
|
+
|| fresh.signalToken !== signalToken
|
|
3740
|
+
|| !rootGenerationMatches(fresh, signalToken))
|
|
3813
3741
|
return false;
|
|
3814
3742
|
const rotatePendingPr = mapped.expectedKinds.includes('pr-created')
|
|
3815
3743
|
&& fresh.pendingPrSignalToken === signalToken;
|
|
@@ -3822,11 +3750,10 @@ export class AgentManager {
|
|
|
3822
3750
|
return true;
|
|
3823
3751
|
},
|
|
3824
3752
|
stillCurrent: taskStillCurrent,
|
|
3825
|
-
holdFailure: (newToken, reason) => this.holdReplayFailureIfCurrent(agentId, { ...task, signalToken: newToken }, 'restart-redispatch-failed', reason, entryHold),
|
|
3753
|
+
holdFailure: (newToken, reason) => this.holdReplayFailureIfCurrent(agentId, { ...task, signalToken: newToken }, 'restart-redispatch-failed', reason, entryHold, async (fresh) => rootGenerationMatches(fresh, newToken)),
|
|
3826
3754
|
continueWith: (newToken, fence) => this.continueSession(taskId, agentId, phase, {
|
|
3827
3755
|
...opts,
|
|
3828
3756
|
signalToken: newToken,
|
|
3829
|
-
preserveDispatchOutputs: true,
|
|
3830
3757
|
expectedHold: { phase: undefined, since: undefined, nonce: undefined },
|
|
3831
3758
|
...fence,
|
|
3832
3759
|
}),
|
|
@@ -3845,9 +3772,6 @@ export class AgentManager {
|
|
|
3845
3772
|
}
|
|
3846
3773
|
});
|
|
3847
3774
|
};
|
|
3848
|
-
if (task.status === 'in_progress' && task.phase === 'research' && agent.role === 'research') {
|
|
3849
|
-
return await replayInitialBootstrap('research', {});
|
|
3850
|
-
}
|
|
3851
3775
|
if (task.status === 'in_progress' && task.phase === undefined && agent.role === 'dev') {
|
|
3852
3776
|
// An undelivered bootstrap never started work, so the fresh-dispatch clean gate still applies.
|
|
3853
3777
|
const delivered = (await this.agentStore.get(agentId))?.bootstrappingTaskId !== taskId;
|
|
@@ -3857,53 +3781,25 @@ export class AgentManager {
|
|
|
3857
3781
|
}
|
|
3858
3782
|
if (task.status === 'in_progress' && task.phase === 'code' && agent.role === 'dev') {
|
|
3859
3783
|
const state = await this.agentStore.get(agentId);
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
if (state?.bootstrappingTaskId === taskId || !stored || stored.phase !== 'spec') {
|
|
3864
|
-
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);
|
|
3865
3787
|
}
|
|
3866
3788
|
if (!(await clearEntryHold()))
|
|
3867
3789
|
return false;
|
|
3868
|
-
return replayTask('code',
|
|
3869
|
-
allowDirtyWorkdir: true,
|
|
3870
|
-
specDocuments: stored.documents,
|
|
3871
|
-
...(task.specReviewRound !== undefined ? { currentSpecRound: task.specReviewRound } : {}),
|
|
3872
|
-
});
|
|
3790
|
+
return replayTask('code', opts);
|
|
3873
3791
|
}
|
|
3874
3792
|
if (task.status === 'fixing' && task.phase === 'spec') {
|
|
3875
|
-
const round = task.specReviewRound ?? 1;
|
|
3876
|
-
const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
|
|
3877
|
-
if (!stored?.findings) {
|
|
3878
|
-
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.`);
|
|
3879
|
-
}
|
|
3880
3793
|
if (!(await clearEntryHold()))
|
|
3881
3794
|
return false;
|
|
3882
|
-
return replayTask('
|
|
3795
|
+
return replayTask('fix', {
|
|
3883
3796
|
...(agent.role === 'dev' ? { allowDirtyWorkdir: true } : {}),
|
|
3884
|
-
serverPriorFindings: JSON.stringify(stored.findings),
|
|
3885
|
-
serverFindingsDigest: reviewFindingsDigest(stored.findings),
|
|
3886
|
-
...(task.specReviewRound !== undefined ? { currentSpecRound: task.specReviewRound } : {}),
|
|
3887
3797
|
});
|
|
3888
3798
|
}
|
|
3889
3799
|
// A direct (no-SDD) pass never persists a phase, so fixing + undefined is the plain code-feedback round.
|
|
3890
3800
|
if (task.status === 'fixing'
|
|
3891
3801
|
&& (task.phase === 'code' || task.phase === undefined)
|
|
3892
3802
|
&& agent.role === 'dev') {
|
|
3893
|
-
if (task.reviewMode === 'server') {
|
|
3894
|
-
const round = Math.max(task.reviewRound, 1);
|
|
3895
|
-
const stored = await this.reviewStore?.getRound(taskId, 'code', round);
|
|
3896
|
-
if (!stored?.findings) {
|
|
3897
|
-
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.`);
|
|
3898
|
-
}
|
|
3899
|
-
if (!(await clearEntryHold()))
|
|
3900
|
-
return false;
|
|
3901
|
-
return replayTask('server-feedback', {
|
|
3902
|
-
allowDirtyWorkdir: true,
|
|
3903
|
-
serverPriorFindings: JSON.stringify(stored.findings),
|
|
3904
|
-
serverFindingsDigest: reviewFindingsDigest(stored.findings),
|
|
3905
|
-
});
|
|
3906
|
-
}
|
|
3907
3803
|
if (!(await clearEntryHold()))
|
|
3908
3804
|
return false;
|
|
3909
3805
|
return replayTask('fix', {
|
|
@@ -3921,68 +3817,49 @@ export class AgentManager {
|
|
|
3921
3817
|
throw err;
|
|
3922
3818
|
}
|
|
3923
3819
|
}
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
if (
|
|
3929
|
-
|
|
3930
|
-
return false;
|
|
3931
|
-
if (!task.signalToken) {
|
|
3932
|
-
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.');
|
|
3933
|
-
}
|
|
3934
|
-
const token = task.signalToken;
|
|
3935
|
-
const afterDone = this.resolveAfterDone(task);
|
|
3936
|
-
if (!afterDone) {
|
|
3937
|
-
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.');
|
|
3938
|
-
}
|
|
3939
|
-
let violation;
|
|
3820
|
+
async redispatchCurrentTaskPhase(taskId, expected) {
|
|
3821
|
+
const current = await this.taskStore.get(taskId);
|
|
3822
|
+
if (!current || !taskMatchesGeneration(current, expected))
|
|
3823
|
+
return 'stale';
|
|
3824
|
+
if (current.status === 'review') {
|
|
3825
|
+
let changedByRedispatch = false;
|
|
3940
3826
|
try {
|
|
3941
|
-
|
|
3827
|
+
await this.dispatchReviewToQa(taskId, {
|
|
3828
|
+
fromStatus: ['review'],
|
|
3829
|
+
bumpRound: false,
|
|
3830
|
+
expectSignalToken: current.signalToken,
|
|
3831
|
+
expectPhase: current.phase,
|
|
3832
|
+
expectedTask: expected,
|
|
3833
|
+
onSideEffect: () => { changedByRedispatch = true; },
|
|
3834
|
+
});
|
|
3835
|
+
return 'dispatched';
|
|
3942
3836
|
}
|
|
3943
3837
|
catch (err) {
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3838
|
+
if (!changedByRedispatch && isBenignDispatchConflict(err))
|
|
3839
|
+
return 'stale';
|
|
3840
|
+
if (!changedByRedispatch
|
|
3841
|
+
&& err instanceof ApiError
|
|
3842
|
+
&& err.code === 'dispatch-unsupported') {
|
|
3843
|
+
return 'unsupported';
|
|
3844
|
+
}
|
|
3845
|
+
if (changedByRedispatch)
|
|
3846
|
+
throw err;
|
|
3953
3847
|
const fresh = await this.taskStore.get(taskId);
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
};
|
|
3959
|
-
return this.runRotatedReplay({
|
|
3960
|
-
agentId,
|
|
3961
|
-
taskAtEntry: task,
|
|
3962
|
-
oldToken: token,
|
|
3963
|
-
expectedKinds: ['code-ready'],
|
|
3964
|
-
rotateUnderTaskLock: async (newToken) => {
|
|
3965
|
-
const fresh = await this.taskStore.get(taskId);
|
|
3966
|
-
if (fresh?.agentId !== agentId
|
|
3967
|
-
|| fresh.status !== task.status
|
|
3968
|
-
|| fresh.phase !== task.phase
|
|
3969
|
-
|| fresh.signalToken !== token)
|
|
3970
|
-
return false;
|
|
3971
|
-
await this.taskStore.set({ ...fresh, signalToken: newToken, updatedAt: new Date().toISOString() });
|
|
3972
|
-
return true;
|
|
3973
|
-
},
|
|
3974
|
-
stillCurrent,
|
|
3975
|
-
holdFailure: (newToken, reason) => this.holdReplayFailureIfCurrent(agentId, { ...task, signalToken: newToken }, 'restart-redispatch-failed', reason, entryHold),
|
|
3976
|
-
continueWith: (newToken, fence) => this.continueSession(taskId, agentId, 'server-after-done', {
|
|
3977
|
-
signalToken: newToken,
|
|
3978
|
-
preserveDispatchOutputs: true,
|
|
3979
|
-
expectedHold: { phase: undefined, since: undefined, nonce: undefined },
|
|
3980
|
-
allowDirtyWorkdir: true,
|
|
3981
|
-
serverAfterDone: { kind: afterDone, branch: task.branch ?? BRANCH_PREFIX + taskId },
|
|
3982
|
-
...fence,
|
|
3983
|
-
}),
|
|
3984
|
-
});
|
|
3848
|
+
if (!fresh || !taskMatchesGeneration(fresh, expected))
|
|
3849
|
+
return 'stale';
|
|
3850
|
+
throw err;
|
|
3851
|
+
}
|
|
3985
3852
|
}
|
|
3853
|
+
const redispatched = await this.redispatchTaskPromptAfterReplRestart(current.agentId, taskId, expected);
|
|
3854
|
+
if (redispatched)
|
|
3855
|
+
return 'dispatched';
|
|
3856
|
+
const fresh = await this.taskStore.get(taskId);
|
|
3857
|
+
return !fresh || !taskMatchesGeneration(fresh, expected) ? 'stale' : 'unsupported';
|
|
3858
|
+
}
|
|
3859
|
+
// approved hosts the post-approve holder pass.
|
|
3860
|
+
async replayApprovedHolderAfterReplRestart(agentId, task, entryHold, holdGen) {
|
|
3861
|
+
const taskId = task.id;
|
|
3862
|
+
const { clearEntryHold, holdReplayFailure, rootGenerationMatches } = holdGen;
|
|
3986
3863
|
if (task.postApproveRevoked) {
|
|
3987
3864
|
return holdReplayFailure('restart-redispatch-failed', `The post-approve completion was deliberately revoked (${task.postApproveRevoked.reason}); ` +
|
|
3988
3865
|
'the block stays effective, so the pass is not replayed. Re-request changes to restart the review round, or cancel the task.');
|
|
@@ -4014,7 +3891,7 @@ export class AgentManager {
|
|
|
4014
3891
|
&& fresh.status === task.status
|
|
4015
3892
|
&& fresh.phase === task.phase
|
|
4016
3893
|
&& fresh.signalToken === task.signalToken
|
|
4017
|
-
&& fresh
|
|
3894
|
+
&& rootGenerationMatches(fresh, task.signalToken)
|
|
4018
3895
|
&& fresh.postApproveRevoked === undefined
|
|
4019
3896
|
&& fresh.postApproveHeadSha === task.postApproveHeadSha
|
|
4020
3897
|
&& fresh.postApproveGeneration === task.postApproveGeneration
|
|
@@ -4053,7 +3930,6 @@ export class AgentManager {
|
|
|
4053
3930
|
token: newToken,
|
|
4054
3931
|
headSha: task.postApproveHeadSha,
|
|
4055
3932
|
},
|
|
4056
|
-
preserveDispatchOutputs: true,
|
|
4057
3933
|
expectedHold: { phase: undefined, since: undefined, nonce: undefined },
|
|
4058
3934
|
allowDirtyWorkdir: true,
|
|
4059
3935
|
...fence,
|
|
@@ -4076,8 +3952,6 @@ export class AgentManager {
|
|
|
4076
3952
|
const project = this.getProjectConfig(cfg.projectId);
|
|
4077
3953
|
if (!project)
|
|
4078
3954
|
throw new Error(`Unknown project: ${cfg.projectId}`);
|
|
4079
|
-
if (cfg.role !== 'dev')
|
|
4080
|
-
return { targets: [agentId] };
|
|
4081
3955
|
const group = this.findAgentGroup(agentId);
|
|
4082
3956
|
return { targets: group?.map(agent => agent.id) ?? [agentId] };
|
|
4083
3957
|
}
|
|
@@ -4178,12 +4052,15 @@ export class AgentManager {
|
|
|
4178
4052
|
}
|
|
4179
4053
|
const workdirGuess = workdirForEstimate ?? '/'.padEnd(64, 'x');
|
|
4180
4054
|
const now = new Date().toISOString();
|
|
4181
|
-
|
|
4055
|
+
if (cfg.role !== 'dev')
|
|
4056
|
+
throw new Error(`Agent ${cfg.id} is not a dev agent`);
|
|
4182
4057
|
const group = this.findAgentGroup(cfg.id);
|
|
4183
|
-
const devAgentId =
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
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`);
|
|
4187
4064
|
const fakeTask = {
|
|
4188
4065
|
id: 'task-9999999999',
|
|
4189
4066
|
projectId,
|
|
@@ -4192,11 +4069,9 @@ export class AgentManager {
|
|
|
4192
4069
|
preferredAgentId: input.preferredAgentId,
|
|
4193
4070
|
agentId: cfg.id,
|
|
4194
4071
|
devAgentId,
|
|
4195
|
-
|
|
4196
|
-
...(isResearch ? { researchAgentId: cfg.id, phase: 'research' } : {}),
|
|
4072
|
+
qaAgentId,
|
|
4197
4073
|
branch: `${BRANCH_PREFIX}task-9999999999`,
|
|
4198
4074
|
reviewRound: 0,
|
|
4199
|
-
reviewMode: this.effectiveReviewMode(projectId),
|
|
4200
4075
|
...this.platformBindingFields(projectId),
|
|
4201
4076
|
status: 'in_progress',
|
|
4202
4077
|
createdAt: now,
|
|
@@ -4204,15 +4079,12 @@ export class AgentManager {
|
|
|
4204
4079
|
};
|
|
4205
4080
|
const fullPrompt = buildPromptInline({
|
|
4206
4081
|
task: fakeTask,
|
|
4207
|
-
phase:
|
|
4082
|
+
phase: 'develop',
|
|
4208
4083
|
agent: cfg,
|
|
4209
4084
|
workdir: workdirGuess,
|
|
4210
4085
|
skillRegistry: this.skillRegistry,
|
|
4211
4086
|
signalToken: 'preview-signal-token',
|
|
4212
|
-
|
|
4213
|
-
...(!isResearch && fakeTask.reviewMode === 'git'
|
|
4214
|
-
? { platformCli: this.platformCliContextOf(fakeTask) }
|
|
4215
|
-
: {}),
|
|
4087
|
+
platformCli: this.platformCliContextOf(fakeTask),
|
|
4216
4088
|
});
|
|
4217
4089
|
return Buffer.byteLength(fullPrompt, 'utf8');
|
|
4218
4090
|
}
|
|
@@ -4264,6 +4136,9 @@ export class AgentManager {
|
|
|
4264
4136
|
async getAgentState(agentId) {
|
|
4265
4137
|
return this.agentStore.get(agentId);
|
|
4266
4138
|
}
|
|
4139
|
+
async getAgentLockOwner(agentId) {
|
|
4140
|
+
return this.lockManager.ownerOf(agentId);
|
|
4141
|
+
}
|
|
4267
4142
|
async getPostApproveCompletion(taskId) {
|
|
4268
4143
|
const task = await this.taskStore.get(taskId);
|
|
4269
4144
|
return task ? this.completionOf(task) : null;
|
|
@@ -4296,7 +4171,7 @@ export class AgentManager {
|
|
|
4296
4171
|
async armGitPostApproveEpisode(taskId, expected, opts = {}) {
|
|
4297
4172
|
return this.withTaskLock(async () => {
|
|
4298
4173
|
const task = await this.taskStore.get(taskId);
|
|
4299
|
-
if (!task ||
|
|
4174
|
+
if (!task || !this.postApproveEpisodeMatches(task, expected))
|
|
4300
4175
|
return false;
|
|
4301
4176
|
if (!this.phaseSignalWatcher)
|
|
4302
4177
|
return true;
|
|
@@ -4314,7 +4189,7 @@ export class AgentManager {
|
|
|
4314
4189
|
async rotateGitPostApproveEpisode(taskId, expected, redispatchCount) {
|
|
4315
4190
|
return this.withTaskLock(async () => {
|
|
4316
4191
|
const task = await this.taskStore.get(taskId);
|
|
4317
|
-
if (!task || task.
|
|
4192
|
+
if (!task || task.pendingRedispatch !== true
|
|
4318
4193
|
|| !this.postApproveEpisodeMatches(task, expected))
|
|
4319
4194
|
return null;
|
|
4320
4195
|
const token = createSignalTokenExcluding(expected.generation, expected.token);
|
|
@@ -4342,10 +4217,17 @@ export class AgentManager {
|
|
|
4342
4217
|
const next = nextStatus === 'approved'
|
|
4343
4218
|
? { ...task }
|
|
4344
4219
|
: this.stripPostApproveEpisodeFields(task);
|
|
4345
|
-
if (
|
|
4220
|
+
if (nextStatus !== 'review') {
|
|
4346
4221
|
delete next.reviewDispatch;
|
|
4347
4222
|
delete next.reviewRoundPending;
|
|
4348
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
|
+
}
|
|
4349
4231
|
return next;
|
|
4350
4232
|
}
|
|
4351
4233
|
async confirmPostApprovePromptDelivered(taskId, expected) {
|
|
@@ -4501,9 +4383,7 @@ export class AgentManager {
|
|
|
4501
4383
|
async recordClosedUnmergedAnchor(taskId, prNumber) {
|
|
4502
4384
|
return this.withTaskLock(async () => {
|
|
4503
4385
|
const task = await this.taskStore.get(taskId);
|
|
4504
|
-
|
|
4505
|
-
|| (task?.reviewMode === 'server' && task.afterDone === 'pr' && task.platformBinding !== undefined);
|
|
4506
|
-
if (!task || !tracksPlatformLifecycle || TERMINAL_STATUSES.includes(task.status))
|
|
4386
|
+
if (!task || TERMINAL_STATUSES.includes(task.status))
|
|
4507
4387
|
return false;
|
|
4508
4388
|
const anchor = task.closedUnmergedAnchor;
|
|
4509
4389
|
if (anchor?.prNumber === prNumber && anchor.cleared !== true)
|
|
@@ -4544,43 +4424,58 @@ export class AgentManager {
|
|
|
4544
4424
|
return true;
|
|
4545
4425
|
});
|
|
4546
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
|
+
}
|
|
4547
4449
|
async deliverTaskOutbox(taskId) {
|
|
4548
4450
|
const task = await this.taskStore.get(taskId);
|
|
4549
4451
|
if (!task?.outbox?.length)
|
|
4550
4452
|
return;
|
|
4551
4453
|
for (const entry of task.outbox) {
|
|
4552
4454
|
try {
|
|
4455
|
+
if (entry.type === 'git.spec-verdict') {
|
|
4456
|
+
await this.deliverSpecVerdictOutbox(taskId, entry.key);
|
|
4457
|
+
continue;
|
|
4458
|
+
}
|
|
4553
4459
|
await this.eventBus.emit({
|
|
4554
4460
|
id: `outbox:${entry.key}`,
|
|
4555
|
-
type:
|
|
4461
|
+
type: 'human.intervention',
|
|
4556
4462
|
timestamp: new Date().toISOString(),
|
|
4557
4463
|
projectId: task.projectId,
|
|
4558
4464
|
agentId: task.agentId,
|
|
4559
4465
|
taskId: task.id,
|
|
4560
4466
|
data: entry.data,
|
|
4561
4467
|
});
|
|
4468
|
+
await this.mutateTaskOutboxEntry(taskId, entry.key, current => current.type === entry.type ? undefined : null);
|
|
4562
4469
|
}
|
|
4563
4470
|
catch (err) {
|
|
4564
4471
|
console.warn(`[AgentManager] outbox delivery failed for task=${taskId} key=${entry.key}:`, err);
|
|
4565
4472
|
continue;
|
|
4566
4473
|
}
|
|
4567
|
-
await this.withTaskLock(async () => {
|
|
4568
|
-
const fresh = await this.taskStore.get(taskId);
|
|
4569
|
-
if (!fresh?.outbox?.some(e => e.key === entry.key))
|
|
4570
|
-
return;
|
|
4571
|
-
const remaining = fresh.outbox.filter(e => e.key !== entry.key);
|
|
4572
|
-
const { outbox: _outbox, ...rest } = fresh;
|
|
4573
|
-
await this.taskStore.set({
|
|
4574
|
-
...rest,
|
|
4575
|
-
...(remaining.length > 0 ? { outbox: remaining } : {}),
|
|
4576
|
-
updatedAt: new Date().toISOString(),
|
|
4577
|
-
});
|
|
4578
|
-
});
|
|
4579
4474
|
}
|
|
4580
4475
|
}
|
|
4581
4476
|
async rearmPostApproveSignal(taskId, opts = {}) {
|
|
4582
4477
|
const task = await this.taskStore.get(taskId);
|
|
4583
|
-
if (!task || task.
|
|
4478
|
+
if (!task || task.status !== 'approved')
|
|
4584
4479
|
return false;
|
|
4585
4480
|
const completion = this.completionOf(task);
|
|
4586
4481
|
if (!completion)
|
|
@@ -4609,8 +4504,6 @@ export class AgentManager {
|
|
|
4609
4504
|
if (!this.pluginRegistry)
|
|
4610
4505
|
return;
|
|
4611
4506
|
for (const project of next.project) {
|
|
4612
|
-
if (projectReviewMode(next, project) !== 'git')
|
|
4613
|
-
continue;
|
|
4614
4507
|
const tool = resolveProjectTool(project);
|
|
4615
4508
|
if (tool === undefined)
|
|
4616
4509
|
continue;
|
|
@@ -4650,19 +4543,17 @@ export class AgentManager {
|
|
|
4650
4543
|
}
|
|
4651
4544
|
}
|
|
4652
4545
|
gitReviewLeaseMatches(task, lease) {
|
|
4653
|
-
return task.
|
|
4654
|
-
&& task.status === 'review'
|
|
4546
|
+
return task.status === 'review'
|
|
4655
4547
|
&& task.signalToken === lease.signalToken
|
|
4656
4548
|
&& task.reviewHeadAnchorSha === lease.headSha
|
|
4657
4549
|
&& task.passToken === lease.passToken
|
|
4658
4550
|
&& task.failToken === lease.failToken
|
|
4659
|
-
&& lease.effectiveRound ===
|
|
4551
|
+
&& lease.effectiveRound === effectiveTaskReviewRound(task);
|
|
4660
4552
|
}
|
|
4661
4553
|
// startSession 按 agent 的 projectId 解析 repo/workdir,项目归属不入判会跨仓库检出
|
|
4662
4554
|
gitReviewQaConfigUsable(task) {
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
const config = this.getAgentConfig(task.qaAgentId);
|
|
4555
|
+
const qaAgentId = requireTaskQaAgentId(task, 'gitReviewQaConfigUsable');
|
|
4556
|
+
const config = this.getAgentConfig(qaAgentId);
|
|
4666
4557
|
return config?.role === 'qa' && config.projectId === task.projectId;
|
|
4667
4558
|
}
|
|
4668
4559
|
async beginGitReviewPass(taskId, opts) {
|
|
@@ -4673,7 +4564,14 @@ export class AgentManager {
|
|
|
4673
4564
|
if (this.markCompleteInFlight.has(taskId) && opts.allowDuringComplete !== true)
|
|
4674
4565
|
return null;
|
|
4675
4566
|
const current = await this.taskStore.get(taskId);
|
|
4676
|
-
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)
|
|
4573
|
+
return null;
|
|
4574
|
+
if (opts.expectedTask && !taskMatchesGeneration(current, opts.expectedTask))
|
|
4677
4575
|
return null;
|
|
4678
4576
|
if (!opts.fromStatus.includes(current.status))
|
|
4679
4577
|
return null;
|
|
@@ -4694,14 +4592,16 @@ export class AgentManager {
|
|
|
4694
4592
|
const signalToken = createSignalTokenExcluding(pair.passToken, pair.failToken);
|
|
4695
4593
|
const generation = createSignalTokenExcluding(signalToken, pair.passToken, pair.failToken);
|
|
4696
4594
|
const reviewRoundPending = opts.bumpRound ? true : undefined;
|
|
4697
|
-
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));
|
|
4698
4597
|
const qaPhase = opts.qaPhase ?? (current.status === 'in_progress'
|
|
4699
|
-
|| (current.status === 'review'
|
|
4598
|
+
|| (current.status === 'review'
|
|
4599
|
+
&& (current.phase === 'spec' ? (current.specReviewRound ?? 0) : current.reviewRound) === 0
|
|
4600
|
+
&& opts.bumpRound)
|
|
4700
4601
|
? 'review'
|
|
4701
4602
|
: 'recheck');
|
|
4702
4603
|
const next = this.stripPostApproveEpisodeFields(current);
|
|
4703
4604
|
delete next.passProvenance;
|
|
4704
|
-
delete next.serverSignalRecovery;
|
|
4705
4605
|
Object.assign(next, opts.patch ?? {}, {
|
|
4706
4606
|
status: 'review',
|
|
4707
4607
|
signalToken,
|
|
@@ -4726,7 +4626,7 @@ export class AgentManager {
|
|
|
4726
4626
|
return { task: next, previousStatus };
|
|
4727
4627
|
});
|
|
4728
4628
|
}
|
|
4729
|
-
async claimGitReviewDispatch(taskId, expectedGeneration) {
|
|
4629
|
+
async claimGitReviewDispatch(taskId, expectedGeneration, expectedTask) {
|
|
4730
4630
|
return this.withTaskLock(async () => {
|
|
4731
4631
|
if (this.markCompleteInFlight.has(taskId))
|
|
4732
4632
|
return null;
|
|
@@ -4734,6 +4634,8 @@ export class AgentManager {
|
|
|
4734
4634
|
const lease = task?.reviewDispatch;
|
|
4735
4635
|
if (!task || !lease || lease.phase !== 'pending')
|
|
4736
4636
|
return null;
|
|
4637
|
+
if (expectedTask && !taskMatchesGeneration(task, expectedTask))
|
|
4638
|
+
return null;
|
|
4737
4639
|
if (expectedGeneration !== undefined && lease.generation !== expectedGeneration)
|
|
4738
4640
|
return null;
|
|
4739
4641
|
if (!this.gitReviewLeaseMatches(task, lease))
|
|
@@ -4757,7 +4659,7 @@ export class AgentManager {
|
|
|
4757
4659
|
}
|
|
4758
4660
|
return this.withTaskLock(async () => {
|
|
4759
4661
|
const current = await this.taskStore.get(taskId);
|
|
4760
|
-
if (!current || current.
|
|
4662
|
+
if (!current || current.status !== 'review')
|
|
4761
4663
|
return null;
|
|
4762
4664
|
if (current.signalToken !== opts.expectedSignalToken
|
|
4763
4665
|
|| current.passToken !== opts.provenance.token
|
|
@@ -4838,22 +4740,20 @@ export class AgentManager {
|
|
|
4838
4740
|
|| current.generation !== expectedGeneration
|
|
4839
4741
|
|| !this.gitReviewLeaseMatches(task, current))
|
|
4840
4742
|
return null;
|
|
4841
|
-
const qaId = task
|
|
4743
|
+
const qaId = requireTaskQaAgentId(task, 'confirmUncertainGitReviewDispatch');
|
|
4842
4744
|
let qaHold;
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
if (qa
|
|
4846
|
-
if (qa.awaitingPhase !== 'dispatch-failed:ack_unknown')
|
|
4847
|
-
return null;
|
|
4848
|
-
qaHold = {
|
|
4849
|
-
phase: qa.awaitingPhase,
|
|
4850
|
-
since: qa.awaitingSince,
|
|
4851
|
-
nonce: qa.awaitingNonce,
|
|
4852
|
-
};
|
|
4853
|
-
}
|
|
4854
|
-
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')
|
|
4855
4748
|
return null;
|
|
4856
|
-
|
|
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;
|
|
4857
4757
|
}
|
|
4858
4758
|
const now = new Date().toISOString();
|
|
4859
4759
|
const { claimId: _claimId, claimedAt: _claimedAt, ...rest } = current;
|
|
@@ -4870,7 +4770,7 @@ export class AgentManager {
|
|
|
4870
4770
|
updatedAt: now,
|
|
4871
4771
|
};
|
|
4872
4772
|
await this.taskStore.set(next);
|
|
4873
|
-
if (qaHold !== undefined
|
|
4773
|
+
if (qaHold !== undefined) {
|
|
4874
4774
|
const cleared = await this.clearAwaitingHuman(qaId, {
|
|
4875
4775
|
expectedTaskId: task.id,
|
|
4876
4776
|
expectedHold: qaHold,
|
|
@@ -4906,7 +4806,10 @@ export class AgentManager {
|
|
|
4906
4806
|
const now = new Date().toISOString();
|
|
4907
4807
|
const next = { ...task, updatedAt: now };
|
|
4908
4808
|
if (next.reviewRoundPending === true) {
|
|
4909
|
-
next.
|
|
4809
|
+
if (next.phase === 'spec')
|
|
4810
|
+
next.specReviewRound = (next.specReviewRound ?? 0) + 1;
|
|
4811
|
+
else
|
|
4812
|
+
next.reviewRound += 1;
|
|
4910
4813
|
delete next.reviewRoundPending;
|
|
4911
4814
|
}
|
|
4912
4815
|
next.reviewDispatchedAt = now;
|
|
@@ -4921,10 +4824,10 @@ export class AgentManager {
|
|
|
4921
4824
|
const current = task?.reviewDispatch;
|
|
4922
4825
|
if (!task || !current || current.phase !== 'pending'
|
|
4923
4826
|
|| current.generation !== expectedGeneration
|
|
4924
|
-
|| !this.gitReviewLeaseMatches(task, current)
|
|
4925
|
-
|| task.qaAgentId === undefined)
|
|
4827
|
+
|| !this.gitReviewLeaseMatches(task, current))
|
|
4926
4828
|
return null;
|
|
4927
|
-
const
|
|
4829
|
+
const qaAgentId = requireTaskQaAgentId(task, 'recoverPendingGitReviewDispatchHold');
|
|
4830
|
+
const qa = await this.agentStore.get(qaAgentId);
|
|
4928
4831
|
if (qa?.taskId !== task.id
|
|
4929
4832
|
|| qa.status !== 'awaiting_human'
|
|
4930
4833
|
|| qa.awaitingPhase !== 'dispatch-failed:ack_unknown')
|
|
@@ -4949,16 +4852,17 @@ export class AgentManager {
|
|
|
4949
4852
|
const tasks = await this.taskStore.list();
|
|
4950
4853
|
for (const snapshot of tasks) {
|
|
4951
4854
|
const lease = snapshot.reviewDispatch;
|
|
4952
|
-
if (
|
|
4855
|
+
if (!lease || lease.phase !== 'claimed')
|
|
4953
4856
|
continue;
|
|
4954
|
-
const
|
|
4857
|
+
const qaAgentId = requireTaskQaAgentId(snapshot, 'recoverClaimedGitReviewDispatches');
|
|
4858
|
+
const qa = await this.agentStore.get(qaAgentId);
|
|
4955
4859
|
if (!qa || qa.taskId !== snapshot.id) {
|
|
4956
4860
|
await this.resetGitReviewDispatchClaim(snapshot.id, lease);
|
|
4957
4861
|
continue;
|
|
4958
4862
|
}
|
|
4959
4863
|
const uncertain = await this.markGitReviewDispatchUncertain(snapshot.id, lease);
|
|
4960
4864
|
if (uncertain) {
|
|
4961
|
-
await this.emitIntervention(snapshot.projectId,
|
|
4865
|
+
await this.emitIntervention(snapshot.projectId, qaAgentId, snapshot.id, {
|
|
4962
4866
|
phase: 'git-review-dispatch-recovery-uncertain',
|
|
4963
4867
|
generation: lease.generation,
|
|
4964
4868
|
claimId: lease.claimId,
|
|
@@ -4970,41 +4874,97 @@ export class AgentManager {
|
|
|
4970
4874
|
async retryPendingGitReviewDispatches() {
|
|
4971
4875
|
const tasks = await this.taskStore.list();
|
|
4972
4876
|
for (const snapshot of tasks) {
|
|
4973
|
-
|
|
4974
|
-
if (
|
|
4975
|
-
|
|
4976
|
-
|
|
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')
|
|
4977
4935
|
continue;
|
|
4978
|
-
|
|
4936
|
+
const qaAgentId = requireTaskQaAgentId(task, 'retryPendingGitReviewDispatches');
|
|
4937
|
+
if (!this.gitReviewQaConfigUsable(task)) {
|
|
4979
4938
|
// 快照可能已过期:告警前 live 复核;按 lease 代际告警一次,emit 成功才落闩
|
|
4980
|
-
const latch =
|
|
4981
|
-
if (!this.
|
|
4939
|
+
const latch = `qa-config:${task.id}:${lease.generation}`;
|
|
4940
|
+
if (!this.gitReviewRecoveryAlerted.has(latch)) {
|
|
4982
4941
|
try {
|
|
4983
|
-
const live = await this.taskStore.get(
|
|
4942
|
+
const live = await this.taskStore.get(task.id);
|
|
4984
4943
|
const liveLease = live?.reviewDispatch;
|
|
4985
4944
|
if (live && liveLease?.phase === 'pending'
|
|
4986
4945
|
&& liveLease.generation === lease.generation
|
|
4987
|
-
&& live.qaAgentId ===
|
|
4946
|
+
&& live.qaAgentId === qaAgentId
|
|
4988
4947
|
&& !this.gitReviewQaConfigUsable(live)) {
|
|
4989
|
-
const
|
|
4948
|
+
const liveQaAgentId = requireTaskQaAgentId(live, 'retryPendingGitReviewDispatches');
|
|
4949
|
+
const alerted = await this.emitIntervention(live.projectId, liveQaAgentId, live.id, {
|
|
4990
4950
|
phase: 'git-review-qa-config-missing',
|
|
4991
|
-
qaAgentId:
|
|
4951
|
+
qaAgentId: liveQaAgentId,
|
|
4992
4952
|
generation: lease.generation,
|
|
4993
4953
|
});
|
|
4994
4954
|
if (alerted)
|
|
4995
|
-
this.
|
|
4955
|
+
this.gitReviewRecoveryAlerted.add(latch);
|
|
4996
4956
|
}
|
|
4997
4957
|
}
|
|
4998
4958
|
catch (err) {
|
|
4999
|
-
console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${
|
|
4959
|
+
console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${task.id} qa-config alert check failed, continuing:`, err);
|
|
5000
4960
|
}
|
|
5001
4961
|
}
|
|
5002
4962
|
continue;
|
|
5003
4963
|
}
|
|
5004
4964
|
try {
|
|
5005
|
-
const recovered = await this.recoverPendingGitReviewDispatchHold(
|
|
4965
|
+
const recovered = await this.recoverPendingGitReviewDispatchHold(task.id, lease.generation);
|
|
5006
4966
|
if (recovered) {
|
|
5007
|
-
await this.emitIntervention(
|
|
4967
|
+
await this.emitIntervention(task.projectId, qaAgentId, task.id, {
|
|
5008
4968
|
phase: 'git-review-dispatch-recovery-uncertain',
|
|
5009
4969
|
generation: lease.generation,
|
|
5010
4970
|
claimId: recovered.claimId,
|
|
@@ -5012,28 +4972,52 @@ export class AgentManager {
|
|
|
5012
4972
|
});
|
|
5013
4973
|
continue;
|
|
5014
4974
|
}
|
|
5015
|
-
const entry = this.getPendingDispatchRetry(
|
|
4975
|
+
const entry = this.getPendingDispatchRetry(task.id);
|
|
5016
4976
|
if (entry?.kind === 'qa-recheck' && entry.signalToken === lease.signalToken)
|
|
5017
4977
|
continue;
|
|
5018
|
-
if (
|
|
5019
|
-
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;
|
|
5020
5005
|
}
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
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; ` +
|
|
5024
5008
|
`preserving generation=${lease.generation} without dispatch`);
|
|
5025
5009
|
continue;
|
|
5026
5010
|
}
|
|
5027
|
-
await this.dispatchGitReviewLease(
|
|
5011
|
+
await this.dispatchGitReviewLease(task.id, {
|
|
5028
5012
|
expectedGeneration: lease.generation,
|
|
5029
5013
|
});
|
|
5030
5014
|
}
|
|
5031
5015
|
catch (err) {
|
|
5032
|
-
if (err instanceof DispatchTerminalError && err.reason !== 'ack_unknown'
|
|
5033
|
-
await this.failTaskForDispatchError(
|
|
5016
|
+
if (err instanceof DispatchTerminalError && err.reason !== 'ack_unknown') {
|
|
5017
|
+
await this.failTaskForDispatchError(task.id, lease.qaPhase, qaAgentId, err, { expectedReviewDispatch: lease });
|
|
5034
5018
|
continue;
|
|
5035
5019
|
}
|
|
5036
|
-
console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${
|
|
5020
|
+
console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${task.id} still failing:`, err);
|
|
5037
5021
|
}
|
|
5038
5022
|
}
|
|
5039
5023
|
}
|
|
@@ -5041,7 +5025,7 @@ export class AgentManager {
|
|
|
5041
5025
|
const tasks = await this.taskStore.list();
|
|
5042
5026
|
for (const task of tasks) {
|
|
5043
5027
|
const intent = task.remoteCleanup;
|
|
5044
|
-
if (
|
|
5028
|
+
if (!intent || intent.stage === 'manual')
|
|
5045
5029
|
continue;
|
|
5046
5030
|
try {
|
|
5047
5031
|
await this.processGitRemoteCleanup(task.id, intent.generation);
|
|
@@ -5064,7 +5048,7 @@ export class AgentManager {
|
|
|
5064
5048
|
async consumeGitFeedbackRevision(taskId, consume, opts = {}) {
|
|
5065
5049
|
return this.withTaskLock(async () => {
|
|
5066
5050
|
const task = await this.taskStore.get(taskId);
|
|
5067
|
-
if (!task
|
|
5051
|
+
if (!task)
|
|
5068
5052
|
return { kind: 'gone' };
|
|
5069
5053
|
if (task.consumedFeedback?.[consume.key] !== undefined)
|
|
5070
5054
|
return { kind: 'duplicate' };
|
|
@@ -5124,8 +5108,6 @@ export class AgentManager {
|
|
|
5124
5108
|
async setupRecoveredPostApproveSignals() {
|
|
5125
5109
|
const tasks = await this.taskStore.list({ status: 'approved' });
|
|
5126
5110
|
for (const task of tasks) {
|
|
5127
|
-
if (task.reviewMode !== 'git')
|
|
5128
|
-
continue;
|
|
5129
5111
|
if (task.postApproveRevoked) {
|
|
5130
5112
|
// Finish an interrupted revoke: the marker is authoritative, the leftover record must not re-arm.
|
|
5131
5113
|
try {
|
|
@@ -5165,15 +5147,6 @@ export class AgentManager {
|
|
|
5165
5147
|
async setupRecoveredSpecSignals() {
|
|
5166
5148
|
const tasks = await this.taskStore.list();
|
|
5167
5149
|
for (const task of tasks) {
|
|
5168
|
-
if (task.serverSignalRecovery) {
|
|
5169
|
-
try {
|
|
5170
|
-
await this.recoverServerSignalRecovery(task);
|
|
5171
|
-
}
|
|
5172
|
-
catch (err) {
|
|
5173
|
-
console.warn(`[AgentManager] setupRecoveredSpecSignals: failed to recover server signal for task=${task.id}:`, err);
|
|
5174
|
-
}
|
|
5175
|
-
continue;
|
|
5176
|
-
}
|
|
5177
5150
|
if (!this.phaseSignalWatcher)
|
|
5178
5151
|
continue;
|
|
5179
5152
|
if (!task.signalToken)
|
|
@@ -5186,187 +5159,43 @@ export class AgentManager {
|
|
|
5186
5159
|
}
|
|
5187
5160
|
}
|
|
5188
5161
|
}
|
|
5189
|
-
async recoverServerSignalRecovery(task) {
|
|
5190
|
-
const recovery = task.serverSignalRecovery;
|
|
5191
|
-
const successorToken = task.signalToken;
|
|
5192
|
-
if (!recovery || !successorToken)
|
|
5193
|
-
return;
|
|
5194
|
-
const agentId = recovery.signalKind.endsWith('-reviewed') ? (task.qaAgentId ?? '') : task.agentId;
|
|
5195
|
-
const hold = async (phase, reason) => {
|
|
5196
|
-
const held = await this.holdServerSignalRecovery(task.id, agentId, successorToken, phase, reason);
|
|
5197
|
-
if (!held)
|
|
5198
|
-
return;
|
|
5199
|
-
await this.emitIntervention(task.projectId, agentId, task.id, {
|
|
5200
|
-
phase,
|
|
5201
|
-
recoveryReason: recovery.reason,
|
|
5202
|
-
successorToken,
|
|
5203
|
-
});
|
|
5204
|
-
};
|
|
5205
|
-
if (recovery.mode === 'hold') {
|
|
5206
|
-
if (!agentId)
|
|
5207
|
-
return;
|
|
5208
|
-
const state = await this.agentStore.get(agentId);
|
|
5209
|
-
if (state?.status === 'awaiting_human')
|
|
5210
|
-
return;
|
|
5211
|
-
await hold(recovery.failurePhase ?? `server-signal-${recovery.reason}`, `A consumed ${recovery.signalKind} signal is held for ${recovery.reason}. `
|
|
5212
|
-
+ 'Resolve the cause, then Restart or Cancel the task.');
|
|
5213
|
-
return;
|
|
5214
|
-
}
|
|
5215
|
-
const stored = await this.reviewStore?.getRound(task.id, recovery.phase, recovery.round);
|
|
5216
|
-
if (!stored?.findings || !recovery.findingsDigest
|
|
5217
|
-
|| reviewFindingsDigest(stored.findings) !== recovery.findingsDigest) {
|
|
5218
|
-
await hold(`server-${recovery.phase}-feedback-recovery-findings-missing`, `Cannot recover the rejected response because round ${recovery.round} findings are missing or changed.`);
|
|
5219
|
-
return;
|
|
5220
|
-
}
|
|
5221
|
-
const responseReasons = new Set([
|
|
5222
|
-
'response-missing', 'response-invalid', 'round-mismatch', 'token-mismatch',
|
|
5223
|
-
'findings-digest-mismatch', 'coverage-gap',
|
|
5224
|
-
]);
|
|
5225
|
-
const dev = agentId ? this.getAgentConfig(agentId) : undefined;
|
|
5226
|
-
if (!dev || !recovery.failureSignature
|
|
5227
|
-
|| (recovery.signalKind !== 'code-fixed' && recovery.signalKind !== 'spec-fixed')
|
|
5228
|
-
|| !responseReasons.has(recovery.reason)) {
|
|
5229
|
-
await hold(`server-${recovery.phase}-feedback-recovery-invalid`, 'The persisted response recovery intent is incomplete and cannot be resumed safely.');
|
|
5230
|
-
return;
|
|
5231
|
-
}
|
|
5232
|
-
const readResponse = async () => {
|
|
5233
|
-
try {
|
|
5234
|
-
const result = await this.getReviewTransport().readResponseWithRaw(task, dev);
|
|
5235
|
-
if (result.kind === 'unknown') {
|
|
5236
|
-
await hold(`server-${recovery.phase}-feedback-recovery-read-failed`, 'The rejected response could not be read while finishing crash recovery; the file was preserved.');
|
|
5237
|
-
return null;
|
|
5238
|
-
}
|
|
5239
|
-
return result;
|
|
5240
|
-
}
|
|
5241
|
-
catch (err) {
|
|
5242
|
-
await hold(`server-${recovery.phase}-feedback-recovery-read-failed`, `The rejected response could not be read while finishing crash recovery: `
|
|
5243
|
-
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
5244
|
-
return null;
|
|
5245
|
-
}
|
|
5246
|
-
};
|
|
5247
|
-
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.');
|
|
5248
|
-
if (recovery.mode === 'classify-response') {
|
|
5249
|
-
const result = await readResponse();
|
|
5250
|
-
if (!result)
|
|
5251
|
-
return;
|
|
5252
|
-
let rawResponse;
|
|
5253
|
-
let responseDigest;
|
|
5254
|
-
if (recovery.reason === 'response-missing') {
|
|
5255
|
-
if (result.kind !== 'absent') {
|
|
5256
|
-
await holdChangedResponse();
|
|
5257
|
-
return;
|
|
5258
|
-
}
|
|
5259
|
-
}
|
|
5260
|
-
else if (result.kind === 'absent' || result.responseDigest !== recovery.responseDigest) {
|
|
5261
|
-
await holdChangedResponse();
|
|
5262
|
-
return;
|
|
5263
|
-
}
|
|
5264
|
-
else {
|
|
5265
|
-
rawResponse = result.raw;
|
|
5266
|
-
responseDigest = result.responseDigest;
|
|
5267
|
-
}
|
|
5268
|
-
let recorded;
|
|
5269
|
-
try {
|
|
5270
|
-
recorded = await this.reviewStore.recordServerResponseFailure(task.id, recovery.phase, recovery.round, {
|
|
5271
|
-
signalKind: recovery.signalKind,
|
|
5272
|
-
sourceToken: recovery.sourceToken,
|
|
5273
|
-
successorToken,
|
|
5274
|
-
failureSignature: recovery.failureSignature,
|
|
5275
|
-
reason: recovery.reason,
|
|
5276
|
-
missingFindingIds: [...(recovery.missingFindingIds ?? [])],
|
|
5277
|
-
unknownFindingIds: [...(recovery.unknownFindingIds ?? [])],
|
|
5278
|
-
schemaViolationCodes: [...(recovery.schemaViolationCodes ?? [])],
|
|
5279
|
-
createdAt: recovery.createdAt,
|
|
5280
|
-
...(responseDigest ? { responseDigest } : {}),
|
|
5281
|
-
...(rawResponse !== undefined ? { rawResponse } : {}),
|
|
5282
|
-
});
|
|
5283
|
-
}
|
|
5284
|
-
catch (err) {
|
|
5285
|
-
await hold(`server-${recovery.phase}-feedback-recovery-audit-failed`, `Could not archive the rejected response during recovery: `
|
|
5286
|
-
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
5287
|
-
return;
|
|
5288
|
-
}
|
|
5289
|
-
const mode = recorded.disposition === 'auto-correct' ? 'correct-response' : 'hold';
|
|
5290
|
-
const finalized = await this.setServerSignalRecoveryMode(task.id, successorToken, mode);
|
|
5291
|
-
if (!finalized)
|
|
5292
|
-
return;
|
|
5293
|
-
if (mode === 'hold') {
|
|
5294
|
-
await hold(recorded.disposition === 'hold-repeated-signature'
|
|
5295
|
-
? 'server-feedback-response-repeated'
|
|
5296
|
-
: 'server-feedback-correction-limit', `Automatic correction stopped during recovery (${recorded.disposition}).`);
|
|
5297
|
-
return;
|
|
5298
|
-
}
|
|
5299
|
-
}
|
|
5300
|
-
if (agentId && (await this.agentStore.get(agentId))?.status === 'awaiting_human')
|
|
5301
|
-
return;
|
|
5302
|
-
const liveResponse = await readResponse();
|
|
5303
|
-
if (!liveResponse)
|
|
5304
|
-
return;
|
|
5305
|
-
if (liveResponse.kind !== 'absent') {
|
|
5306
|
-
if (!recovery.responseDigest || liveResponse.responseDigest !== recovery.responseDigest) {
|
|
5307
|
-
await holdChangedResponse();
|
|
5308
|
-
return;
|
|
5309
|
-
}
|
|
5310
|
-
const fresh = await this.taskStore.get(task.id);
|
|
5311
|
-
const current = fresh?.serverSignalRecovery;
|
|
5312
|
-
const currentRound = recovery.phase === 'spec' ? (fresh?.specReviewRound ?? 0) : fresh?.reviewRound;
|
|
5313
|
-
if (fresh?.status !== task.status
|
|
5314
|
-
|| fresh.phase !== task.phase
|
|
5315
|
-
|| fresh.signalToken !== successorToken
|
|
5316
|
-
|| currentRound !== recovery.round
|
|
5317
|
-
|| current?.mode !== 'correct-response'
|
|
5318
|
-
|| current.sourceToken !== recovery.sourceToken
|
|
5319
|
-
|| current.failureSignature !== recovery.failureSignature) {
|
|
5320
|
-
return;
|
|
5321
|
-
}
|
|
5322
|
-
try {
|
|
5323
|
-
await this.getReviewTransport().deleteResponse(dev);
|
|
5324
|
-
}
|
|
5325
|
-
catch (err) {
|
|
5326
|
-
await hold(`server-${recovery.phase}-feedback-recovery-cleanup-failed`, `The rejected response was archived but could not be removed during recovery: `
|
|
5327
|
-
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
5328
|
-
return;
|
|
5329
|
-
}
|
|
5330
|
-
}
|
|
5331
|
-
await this.dispatchServerFeedbackCorrection(task.id, JSON.stringify(stored.findings));
|
|
5332
|
-
}
|
|
5333
5162
|
async setupRecoveredWatcherForMapping(task, signalToken, mapped, hasSiblings) {
|
|
5334
5163
|
{
|
|
5335
5164
|
const { expectedKinds, agentId } = mapped;
|
|
5336
|
-
const specStage = isSpecStagePhase(task.phase);
|
|
5337
5165
|
const interventionKindLabel = expectedKinds.length === 0 ? undefined
|
|
5338
|
-
:
|
|
5339
|
-
:
|
|
5340
|
-
:
|
|
5341
|
-
: !specStage && task.status === 'fixing' ? 'pr-fixed'
|
|
5342
|
-
: undefined;
|
|
5343
|
-
const isServerProtocol = task.reviewMode === 'server' || specStage;
|
|
5344
|
-
const scanSnapshotOnRecover = isServerProtocol
|
|
5345
|
-
|| (task.phase === undefined && task.status === 'in_progress')
|
|
5346
|
-
|| (!specStage && (task.status === 'review' || task.status === 'fixing'));
|
|
5347
|
-
const allowRecoveredReadFile = task.status === 'review'
|
|
5348
|
-
&& (specStage
|
|
5349
|
-
|| (task.reviewMode === 'server' && task.batchTotal !== undefined)
|
|
5350
|
-
|| (task.reviewMode === 'server' && task.reviewCheckoutMode === 'base'));
|
|
5166
|
+
: task.status === 'review' ? 'pr-verdict-comment'
|
|
5167
|
+
: task.status === 'fixing' ? 'pr-fixed'
|
|
5168
|
+
: undefined;
|
|
5351
5169
|
try {
|
|
5352
|
-
await this.startPhaseSignalWatch({
|
|
5170
|
+
const armed = await this.startPhaseSignalWatch({
|
|
5353
5171
|
taskId: task.id,
|
|
5354
5172
|
projectId: task.projectId,
|
|
5355
5173
|
agentId,
|
|
5356
5174
|
expectedKinds,
|
|
5357
5175
|
token: signalToken,
|
|
5358
|
-
skipSnapshot:
|
|
5176
|
+
skipSnapshot: false,
|
|
5359
5177
|
recovered: true,
|
|
5360
5178
|
...(hasSiblings ? { replaceScope: 'agent' } : {}),
|
|
5361
|
-
...(allowRecoveredReadFile
|
|
5362
|
-
? { onReadFile: (req) => { void this.handleReadFileRequest(task.id, agentId, req); } }
|
|
5363
|
-
: {}),
|
|
5364
5179
|
});
|
|
5365
|
-
if (interventionKindLabel) {
|
|
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) {
|
|
5366
5195
|
await this.emitIntervention(task.projectId, agentId, task.id, {
|
|
5367
|
-
phase: '
|
|
5196
|
+
phase: 'phase-signal-setup-during-recovery',
|
|
5368
5197
|
kind: interventionKindLabel,
|
|
5369
|
-
note: 'Task is waiting for a
|
|
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.',
|
|
5370
5199
|
});
|
|
5371
5200
|
}
|
|
5372
5201
|
}
|
|
@@ -5403,9 +5232,7 @@ export class AgentManager {
|
|
|
5403
5232
|
}
|
|
5404
5233
|
// 创建期快照(spec §4):git 任务在建任务那一刻定身份。
|
|
5405
5234
|
platformBindingFields(projectId) {
|
|
5406
|
-
return this.
|
|
5407
|
-
? { platformBinding: this.platformBindingFor(projectId) }
|
|
5408
|
-
: {};
|
|
5235
|
+
return { platformBinding: this.platformBindingFor(projectId) };
|
|
5409
5236
|
}
|
|
5410
5237
|
platformBindingFor(projectId) {
|
|
5411
5238
|
const project = this.getProjectConfig(projectId);
|
|
@@ -5415,15 +5242,13 @@ export class AgentManager {
|
|
|
5415
5242
|
if (tool === undefined)
|
|
5416
5243
|
throw new Error(`project ${projectId} has no resolved platform tool`);
|
|
5417
5244
|
return {
|
|
5418
|
-
mode:
|
|
5245
|
+
mode: 'git',
|
|
5419
5246
|
repoKey: repoIdentityKey(project.repo),
|
|
5420
5247
|
tool,
|
|
5421
5248
|
};
|
|
5422
5249
|
}
|
|
5423
5250
|
// agent 面一律 PATH 解析 tool;server 面 binary/env 不进入该轨。
|
|
5424
5251
|
agentGitPreflightContext(projectId) {
|
|
5425
|
-
if (this.effectiveReviewMode(projectId) !== 'git')
|
|
5426
|
-
return undefined;
|
|
5427
5252
|
const project = this.getProjectConfig(projectId);
|
|
5428
5253
|
if (!project || !this.pluginRegistry)
|
|
5429
5254
|
return undefined;
|
|
@@ -5446,8 +5271,6 @@ export class AgentManager {
|
|
|
5446
5271
|
// cli 字段族每次派发实时渲染,不做创建时快照。渲染前按持久化 binding 校验身份——
|
|
5447
5272
|
// 离线漂移的配置会把旧任务的 PR 号/分支/令牌带到新仓库,与平台操作同一守卫。
|
|
5448
5273
|
platformCliContextOf(task) {
|
|
5449
|
-
if (task.reviewMode !== 'git')
|
|
5450
|
-
return undefined;
|
|
5451
5274
|
this.assertPlatformBinding(task);
|
|
5452
5275
|
const project = this.getProjectConfig(task.projectId);
|
|
5453
5276
|
if (!project)
|
|
@@ -5466,7 +5289,7 @@ export class AgentManager {
|
|
|
5466
5289
|
}
|
|
5467
5290
|
// base 快照一经持久化不可变;无快照保持缺省,由 agent 现场显式查询。
|
|
5468
5291
|
async ensureGitBaseSnapshot(task, phase) {
|
|
5469
|
-
if (
|
|
5292
|
+
if (phase !== 'develop' && phase !== 'code')
|
|
5470
5293
|
return task;
|
|
5471
5294
|
if (task.baseBranch !== undefined)
|
|
5472
5295
|
return task;
|
|
@@ -5556,6 +5379,7 @@ export class AgentManager {
|
|
|
5556
5379
|
}
|
|
5557
5380
|
return {
|
|
5558
5381
|
ok: true,
|
|
5382
|
+
prUrl: String(row.prUrl),
|
|
5559
5383
|
headSha: String(row.headSha),
|
|
5560
5384
|
branch: String(row.branch),
|
|
5561
5385
|
targetBranch: String(row.targetBranch),
|
|
@@ -5629,7 +5453,7 @@ export class AgentManager {
|
|
|
5629
5453
|
async processGitRemoteCleanup(taskId, expectedGeneration) {
|
|
5630
5454
|
const snapshot = await this.taskStore.get(taskId);
|
|
5631
5455
|
const snapshotIntent = snapshot?.remoteCleanup;
|
|
5632
|
-
if (!snapshot ||
|
|
5456
|
+
if (!snapshot || !snapshotIntent || snapshotIntent.stage === 'manual')
|
|
5633
5457
|
return;
|
|
5634
5458
|
if (expectedGeneration !== undefined && snapshotIntent.generation !== expectedGeneration)
|
|
5635
5459
|
return;
|
|
@@ -5796,6 +5620,29 @@ export class AgentManager {
|
|
|
5796
5620
|
return;
|
|
5797
5621
|
}
|
|
5798
5622
|
if (branch.headSha === undefined) {
|
|
5623
|
+
try {
|
|
5624
|
+
await this.eventBus.emit({
|
|
5625
|
+
id: `outbox:${task.id}:${intent.prNumber}:remote-branch-deleted:${generation}`,
|
|
5626
|
+
type: 'task.updated',
|
|
5627
|
+
timestamp: new Date().toISOString(),
|
|
5628
|
+
projectId: task.projectId,
|
|
5629
|
+
taskId: task.id,
|
|
5630
|
+
data: {
|
|
5631
|
+
status: task.status,
|
|
5632
|
+
operation: 'git-branch-delete',
|
|
5633
|
+
outcome: 'succeeded',
|
|
5634
|
+
generation,
|
|
5635
|
+
prNumber: intent.prNumber,
|
|
5636
|
+
branch: intent.branch,
|
|
5637
|
+
expectedHeadSha: intent.expectedHeadSha,
|
|
5638
|
+
remoteProjectId: intent.remoteProjectId,
|
|
5639
|
+
},
|
|
5640
|
+
});
|
|
5641
|
+
}
|
|
5642
|
+
catch (err) {
|
|
5643
|
+
await this.recordRemoteCleanupFailure(task, generation, 'persist', safeDriverErrorText(err));
|
|
5644
|
+
throw err;
|
|
5645
|
+
}
|
|
5799
5646
|
await this.mutateRemoteCleanup(taskId, generation, () => undefined);
|
|
5800
5647
|
return;
|
|
5801
5648
|
}
|
|
@@ -5871,7 +5718,7 @@ export class AgentManager {
|
|
|
5871
5718
|
if (String(row.headSha) !== opts.expectedHeadSha) {
|
|
5872
5719
|
throw new Error(`merge blocked: head moved (expected ${opts.expectedHeadSha}, saw ${String(row.headSha)})`);
|
|
5873
5720
|
}
|
|
5874
|
-
if (
|
|
5721
|
+
if (opts.humanOverride !== true) {
|
|
5875
5722
|
const provenance = task.passProvenance;
|
|
5876
5723
|
if (!provenance) {
|
|
5877
5724
|
throw new PlatformMergeRecheckError('provenance-invalid', 'no accepted pass provenance on task');
|
|
@@ -5904,9 +5751,7 @@ export class AgentManager {
|
|
|
5904
5751
|
}
|
|
5905
5752
|
const fresh = await this.taskStore.get(taskId);
|
|
5906
5753
|
if (!fresh
|
|
5907
|
-
||
|
|
5908
|
-
? fresh.status !== 'merge-ready'
|
|
5909
|
-
: (fresh.status !== 'merge-ready' && fresh.status !== 'ready'))
|
|
5754
|
+
|| fresh.status !== 'merge-ready'
|
|
5910
5755
|
|| fresh.latestHeadSha !== opts.expectedHeadSha
|
|
5911
5756
|
|| fresh.signalToken !== task.signalToken
|
|
5912
5757
|
|| fresh.pendingRedispatch === true) {
|
|
@@ -5950,7 +5795,7 @@ export class AgentManager {
|
|
|
5950
5795
|
return [];
|
|
5951
5796
|
const key = repoIdentityKey(project.repo);
|
|
5952
5797
|
const entryBinding = {
|
|
5953
|
-
mode:
|
|
5798
|
+
mode: 'git',
|
|
5954
5799
|
repoKey: key,
|
|
5955
5800
|
tool: resolveProjectTool(project) ?? '',
|
|
5956
5801
|
};
|
|
@@ -5960,31 +5805,16 @@ export class AgentManager {
|
|
|
5960
5805
|
&& this.taskBelongsToPlatformEntry(task, entryBinding));
|
|
5961
5806
|
}
|
|
5962
5807
|
async findTaskByBranch(branch, projectId) {
|
|
5963
|
-
const
|
|
5964
|
-
if (!
|
|
5808
|
+
const repo = this.getProjectConfig(projectId)?.repo;
|
|
5809
|
+
if (!repo)
|
|
5965
5810
|
return undefined;
|
|
5811
|
+
const repoKey = repoIdentityKey(repo);
|
|
5966
5812
|
const all = await this.taskStore.list();
|
|
5967
|
-
return all.find(
|
|
5968
|
-
|
|
5969
|
-
|
|
5970
|
-
|
|
5971
|
-
|
|
5972
|
-
if (!task || task.reviewMode !== 'server' || task.afterDone !== 'pr'
|
|
5973
|
-
|| task.platformBinding === undefined || TERMINAL_STATUSES.includes(task.status))
|
|
5974
|
-
return false;
|
|
5975
|
-
if (task.prNumber !== undefined && task.prNumber !== pr.prNumber)
|
|
5976
|
-
return false;
|
|
5977
|
-
await this.taskStore.set({
|
|
5978
|
-
...task,
|
|
5979
|
-
prNumber: pr.prNumber,
|
|
5980
|
-
...(pr.prUrl !== undefined ? { prUrl: pr.prUrl } : {}),
|
|
5981
|
-
...(pr.headSha !== undefined ? { latestHeadSha: pr.headSha } : {}),
|
|
5982
|
-
...(task.baseBranch === undefined && pr.targetBranch !== undefined
|
|
5983
|
-
? { baseBranch: pr.targetBranch }
|
|
5984
|
-
: {}),
|
|
5985
|
-
updatedAt: new Date().toISOString(),
|
|
5986
|
-
});
|
|
5987
|
-
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;
|
|
5988
5818
|
});
|
|
5989
5819
|
}
|
|
5990
5820
|
async updateTaskIfStatus(taskId, expectedStatus, updates, alsoExpect = {}) {
|
|
@@ -5996,6 +5826,8 @@ export class AgentManager {
|
|
|
5996
5826
|
return false;
|
|
5997
5827
|
if ('phase' in alsoExpect && task.phase !== alsoExpect.phase)
|
|
5998
5828
|
return false;
|
|
5829
|
+
if ('agentId' in alsoExpect && task.agentId !== alsoExpect.agentId)
|
|
5830
|
+
return false;
|
|
5999
5831
|
if ('reviewRound' in alsoExpect && task.reviewRound !== alsoExpect.reviewRound)
|
|
6000
5832
|
return false;
|
|
6001
5833
|
if ('specReviewRound' in alsoExpect && task.specReviewRound !== alsoExpect.specReviewRound)
|
|
@@ -6005,184 +5837,6 @@ export class AgentManager {
|
|
|
6005
5837
|
return true;
|
|
6006
5838
|
});
|
|
6007
5839
|
}
|
|
6008
|
-
async claimServerSignalRecovery(taskAtEntry, agentId, signalKind, input) {
|
|
6009
|
-
const sourceToken = taskAtEntry.signalToken;
|
|
6010
|
-
if (!sourceToken)
|
|
6011
|
-
return null;
|
|
6012
|
-
const currentRound = input.phase === 'spec'
|
|
6013
|
-
? (taskAtEntry.specReviewRound ?? 0)
|
|
6014
|
-
: taskAtEntry.reviewRound;
|
|
6015
|
-
const phaseMatches = input.phase === 'spec'
|
|
6016
|
-
? taskAtEntry.phase !== 'code'
|
|
6017
|
-
: taskAtEntry.phase === 'code' || taskAtEntry.phase === undefined;
|
|
6018
|
-
if (!phaseMatches || input.round !== currentRound)
|
|
6019
|
-
return null;
|
|
6020
|
-
let successorToken = createSignalToken();
|
|
6021
|
-
while (successorToken === sourceToken)
|
|
6022
|
-
successorToken = createSignalToken();
|
|
6023
|
-
const recovery = {
|
|
6024
|
-
...input,
|
|
6025
|
-
signalKind,
|
|
6026
|
-
sourceToken,
|
|
6027
|
-
missingFindingIds: [...(input.missingFindingIds ?? [])].sort(),
|
|
6028
|
-
unknownFindingIds: [...(input.unknownFindingIds ?? [])].sort(),
|
|
6029
|
-
schemaViolationCodes: [...(input.schemaViolationCodes ?? [])].sort(),
|
|
6030
|
-
createdAt: new Date().toISOString(),
|
|
6031
|
-
};
|
|
6032
|
-
const expectedRound = input.phase === 'spec'
|
|
6033
|
-
? { specReviewRound: taskAtEntry.specReviewRound }
|
|
6034
|
-
: { reviewRound: taskAtEntry.reviewRound };
|
|
6035
|
-
const claimed = await this.updateTaskIfStatus(taskAtEntry.id, taskAtEntry.status, {
|
|
6036
|
-
signalToken: successorToken,
|
|
6037
|
-
serverSignalRecovery: recovery,
|
|
6038
|
-
...(taskAtEntry.status === 'fixing' ? { fixDispatchedAt: new Date().toISOString() } : {}),
|
|
6039
|
-
}, {
|
|
6040
|
-
signalToken: sourceToken,
|
|
6041
|
-
phase: taskAtEntry.phase,
|
|
6042
|
-
...expectedRound,
|
|
6043
|
-
});
|
|
6044
|
-
if (!claimed)
|
|
6045
|
-
return null;
|
|
6046
|
-
if (agentId)
|
|
6047
|
-
this.phaseSignalWatcher?.stopAgentIfToken(taskAtEntry.id, agentId, sourceToken);
|
|
6048
|
-
else
|
|
6049
|
-
this.phaseSignalWatcher?.stopIfToken(taskAtEntry.id, sourceToken);
|
|
6050
|
-
const fresh = await this.taskStore.get(taskAtEntry.id);
|
|
6051
|
-
if (fresh?.signalToken !== successorToken
|
|
6052
|
-
|| fresh.serverSignalRecovery?.sourceToken !== sourceToken
|
|
6053
|
-
|| fresh.serverSignalRecovery.signalKind !== signalKind) {
|
|
6054
|
-
return null;
|
|
6055
|
-
}
|
|
6056
|
-
if (input.mode === 'hold' && agentId) {
|
|
6057
|
-
const holdPhase = input.failurePhase ?? `server-signal-${input.reason}`;
|
|
6058
|
-
const held = await this.markAwaitingHuman(agentId, holdPhase, `The ${signalKind} marker was consumed but could not be completed (${input.reason}). `
|
|
6059
|
-
+ 'The old token is retired. Restart or cancel the task after resolving the reported cause.', { expectedTaskId: taskAtEntry.id });
|
|
6060
|
-
if (!held) {
|
|
6061
|
-
console.error(`[AgentManager] failed to hold ${agentId} for consumed ${signalKind} on ${taskAtEntry.id}; `
|
|
6062
|
-
+ `recovery=${successorToken}`);
|
|
6063
|
-
}
|
|
6064
|
-
}
|
|
6065
|
-
return fresh;
|
|
6066
|
-
}
|
|
6067
|
-
async holdConsumedServerSignal(taskAtEntry, agentId, signalKind, input) {
|
|
6068
|
-
return this.claimServerSignalRecovery(taskAtEntry, agentId, signalKind, {
|
|
6069
|
-
mode: 'hold',
|
|
6070
|
-
...input,
|
|
6071
|
-
});
|
|
6072
|
-
}
|
|
6073
|
-
async setServerSignalRecoveryMode(taskId, successorToken, mode) {
|
|
6074
|
-
const task = await this.taskStore.get(taskId);
|
|
6075
|
-
const recovery = task?.serverSignalRecovery;
|
|
6076
|
-
if (!task || !recovery || task.signalToken !== successorToken)
|
|
6077
|
-
return null;
|
|
6078
|
-
const currentRound = recovery.phase === 'spec' ? (task.specReviewRound ?? 0) : task.reviewRound;
|
|
6079
|
-
const phaseMatches = recovery.phase === 'spec'
|
|
6080
|
-
? task.phase !== 'code'
|
|
6081
|
-
: task.phase === 'code' || task.phase === undefined;
|
|
6082
|
-
if (!phaseMatches || currentRound !== recovery.round)
|
|
6083
|
-
return null;
|
|
6084
|
-
const expectedRound = recovery.phase === 'spec'
|
|
6085
|
-
? { specReviewRound: task.specReviewRound }
|
|
6086
|
-
: { reviewRound: task.reviewRound };
|
|
6087
|
-
const updated = await this.updateTaskIfStatus(taskId, task.status, { serverSignalRecovery: { ...recovery, mode } }, {
|
|
6088
|
-
signalToken: successorToken,
|
|
6089
|
-
phase: task.phase,
|
|
6090
|
-
...expectedRound,
|
|
6091
|
-
});
|
|
6092
|
-
if (!updated)
|
|
6093
|
-
return null;
|
|
6094
|
-
const fresh = await this.taskStore.get(taskId);
|
|
6095
|
-
if (fresh?.signalToken !== successorToken
|
|
6096
|
-
|| fresh.serverSignalRecovery?.mode !== mode
|
|
6097
|
-
|| fresh.serverSignalRecovery.sourceToken !== recovery.sourceToken) {
|
|
6098
|
-
return null;
|
|
6099
|
-
}
|
|
6100
|
-
return fresh;
|
|
6101
|
-
}
|
|
6102
|
-
async holdServerSignalRecovery(taskId, agentId, successorToken, holdPhase, reason) {
|
|
6103
|
-
const task = await this.setServerSignalRecoveryMode(taskId, successorToken, 'hold');
|
|
6104
|
-
if (!task)
|
|
6105
|
-
return null;
|
|
6106
|
-
this.phaseSignalWatcher?.stopAgentIfToken(taskId, agentId, successorToken);
|
|
6107
|
-
if (agentId) {
|
|
6108
|
-
const held = await this.markAwaitingHuman(agentId, holdPhase, reason, { expectedTaskId: taskId });
|
|
6109
|
-
if (!held) {
|
|
6110
|
-
console.error(`[AgentManager] failed to hold ${agentId} for recovery ${taskId}/${successorToken}`);
|
|
6111
|
-
}
|
|
6112
|
-
}
|
|
6113
|
-
return task;
|
|
6114
|
-
}
|
|
6115
|
-
async dispatchServerFeedbackCorrection(taskId, findingsJson) {
|
|
6116
|
-
const task = await this.taskStore.get(taskId);
|
|
6117
|
-
const recovery = task?.serverSignalRecovery;
|
|
6118
|
-
if (!task || !recovery || recovery.mode !== 'correct-response' || !task.signalToken)
|
|
6119
|
-
return false;
|
|
6120
|
-
const agentId = task.agentId;
|
|
6121
|
-
const successorToken = task.signalToken;
|
|
6122
|
-
if (!agentId || !this.getAgentConfig(agentId)) {
|
|
6123
|
-
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.');
|
|
6124
|
-
return false;
|
|
6125
|
-
}
|
|
6126
|
-
const stillCurrent = async () => {
|
|
6127
|
-
const fresh = await this.taskStore.get(taskId);
|
|
6128
|
-
const current = fresh?.serverSignalRecovery;
|
|
6129
|
-
const round = recovery.phase === 'spec' ? (fresh?.specReviewRound ?? 0) : fresh?.reviewRound;
|
|
6130
|
-
return fresh?.status === task.status
|
|
6131
|
-
&& fresh.phase === task.phase
|
|
6132
|
-
&& fresh.signalToken === successorToken
|
|
6133
|
-
&& round === recovery.round
|
|
6134
|
-
&& current?.mode === 'correct-response'
|
|
6135
|
-
&& current.sourceToken === recovery.sourceToken
|
|
6136
|
-
&& current.failureSignature === recovery.failureSignature;
|
|
6137
|
-
};
|
|
6138
|
-
try {
|
|
6139
|
-
const delivered = await this.continueSession(taskId, agentId, 'server-feedback', {
|
|
6140
|
-
allowDirtyWorkdir: true,
|
|
6141
|
-
signalToken: successorToken,
|
|
6142
|
-
serverPriorFindings: findingsJson,
|
|
6143
|
-
serverFindingsDigest: recovery.findingsDigest,
|
|
6144
|
-
serverFeedbackCorrection: {
|
|
6145
|
-
reason: recovery.reason,
|
|
6146
|
-
missingFindingIds: recovery.missingFindingIds,
|
|
6147
|
-
unknownFindingIds: recovery.unknownFindingIds,
|
|
6148
|
-
schemaViolationCodes: recovery.schemaViolationCodes,
|
|
6149
|
-
},
|
|
6150
|
-
...(recovery.phase === 'spec' ? { currentSpecRound: recovery.round } : {}),
|
|
6151
|
-
armBeforeInject: () => this.setupPhaseSignalWatcher(taskId, agentId, recovery.signalKind, successorToken, {
|
|
6152
|
-
skipSnapshot: true,
|
|
6153
|
-
onlyReplaceOwnToken: true,
|
|
6154
|
-
replaceFromToken: recovery.sourceToken,
|
|
6155
|
-
needInputMode: 'fresh',
|
|
6156
|
-
}),
|
|
6157
|
-
guardBeforeInject: stillCurrent,
|
|
6158
|
-
});
|
|
6159
|
-
if (!delivered)
|
|
6160
|
-
throw new Error('correction prompt was not delivered');
|
|
6161
|
-
const cleared = await this.updateTaskIfStatus(taskId, task.status, { serverSignalRecovery: undefined }, {
|
|
6162
|
-
signalToken: successorToken,
|
|
6163
|
-
phase: task.phase,
|
|
6164
|
-
...(recovery.phase === 'spec'
|
|
6165
|
-
? { specReviewRound: task.specReviewRound }
|
|
6166
|
-
: { reviewRound: task.reviewRound }),
|
|
6167
|
-
});
|
|
6168
|
-
if (!cleared && await stillCurrent()) {
|
|
6169
|
-
throw new Error('correction prompt delivered but recovery intent could not be cleared');
|
|
6170
|
-
}
|
|
6171
|
-
return true;
|
|
6172
|
-
}
|
|
6173
|
-
catch (err) {
|
|
6174
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
6175
|
-
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.`);
|
|
6176
|
-
if (!held)
|
|
6177
|
-
return false;
|
|
6178
|
-
await this.emitIntervention(task.projectId, agentId, taskId, {
|
|
6179
|
-
phase: `server-${recovery.phase}-feedback-correction-failed`,
|
|
6180
|
-
error: message,
|
|
6181
|
-
successorToken,
|
|
6182
|
-
});
|
|
6183
|
-
return false;
|
|
6184
|
-
}
|
|
6185
|
-
}
|
|
6186
5840
|
async updateTask(taskId, updates) {
|
|
6187
5841
|
await this.withTaskLock(async () => {
|
|
6188
5842
|
const task = await this.taskStore.get(taskId);
|
|
@@ -6192,40 +5846,127 @@ export class AgentManager {
|
|
|
6192
5846
|
await this.taskStore.set(task);
|
|
6193
5847
|
});
|
|
6194
5848
|
}
|
|
6195
|
-
async
|
|
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) {
|
|
6196
5856
|
return this.withTaskLock(async () => {
|
|
6197
5857
|
const task = await this.taskStore.get(taskId);
|
|
6198
|
-
if (!task)
|
|
6199
|
-
return null;
|
|
6200
|
-
const previousStatus = task.status;
|
|
6201
|
-
if (TERMINAL_STATUSES.includes(previousStatus))
|
|
6202
|
-
return null;
|
|
6203
|
-
if (!guard.fromStatus.includes(previousStatus))
|
|
6204
|
-
return null;
|
|
6205
|
-
if (Object.hasOwn(guard, 'expectSignalToken') && task.signalToken !== guard.expectSignalToken)
|
|
6206
|
-
return null;
|
|
6207
|
-
if (Object.hasOwn(guard, 'expectPhase') && task.phase !== guard.expectPhase)
|
|
5858
|
+
if (!task || TERMINAL_STATUSES.includes(task.status))
|
|
6208
5859
|
return null;
|
|
6209
|
-
if (
|
|
6210
|
-
&& !
|
|
5860
|
+
if (expected
|
|
5861
|
+
&& (!taskMatchesGeneration(task, expected.task)
|
|
5862
|
+
|| task.latestHeadSha !== expected.latestHeadSha))
|
|
6211
5863
|
return null;
|
|
6212
|
-
if (
|
|
6213
|
-
&& !this.gitReviewDispatchMatches(task, guard.expectReviewDispatch))
|
|
5864
|
+
if (task.prNumber !== undefined && task.prNumber !== observation.prNumber)
|
|
6214
5865
|
return null;
|
|
6215
|
-
if (
|
|
6216
|
-
|
|
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);
|
|
6217
5879
|
if (existing && existing.id !== taskId)
|
|
6218
5880
|
return null;
|
|
6219
5881
|
}
|
|
6220
|
-
const
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
|
|
6224
|
-
|
|
6225
|
-
|
|
6226
|
-
|
|
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
|
+
};
|
|
6227
5907
|
await this.taskStore.set(next);
|
|
6228
|
-
return
|
|
5908
|
+
return next;
|
|
5909
|
+
});
|
|
5910
|
+
}
|
|
5911
|
+
async transitionTaskStatus(taskId, toStatus, guard, patch) {
|
|
5912
|
+
return this.withTaskLock(async () => {
|
|
5913
|
+
const transition = async () => {
|
|
5914
|
+
if (guard.expectAgentLock) {
|
|
5915
|
+
const binding = await this.agentStore.get(guard.expectAgentLock.agentId);
|
|
5916
|
+
if (binding?.taskId !== taskId
|
|
5917
|
+
|| binding.lockToken !== guard.expectAgentLock.token)
|
|
5918
|
+
return null;
|
|
5919
|
+
}
|
|
5920
|
+
const task = await this.taskStore.get(taskId);
|
|
5921
|
+
if (!task)
|
|
5922
|
+
return null;
|
|
5923
|
+
const previousStatus = task.status;
|
|
5924
|
+
if (TERMINAL_STATUSES.includes(previousStatus))
|
|
5925
|
+
return null;
|
|
5926
|
+
if (!guard.fromStatus.includes(previousStatus))
|
|
5927
|
+
return null;
|
|
5928
|
+
if (guard.expectTask && !taskMatchesGeneration(task, guard.expectTask))
|
|
5929
|
+
return null;
|
|
5930
|
+
if (Object.hasOwn(guard, 'expectSignalToken') && task.signalToken !== guard.expectSignalToken)
|
|
5931
|
+
return null;
|
|
5932
|
+
if (Object.hasOwn(guard, 'expectPhase') && task.phase !== guard.expectPhase)
|
|
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;
|
|
5938
|
+
if (guard.expectPostApproveEpisode !== undefined
|
|
5939
|
+
&& !this.postApproveEpisodeMatches(task, guard.expectPostApproveEpisode))
|
|
5940
|
+
return null;
|
|
5941
|
+
if (guard.expectReviewDispatch !== undefined
|
|
5942
|
+
&& !this.gitReviewDispatchMatches(task, guard.expectReviewDispatch))
|
|
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;
|
|
5953
|
+
if (patch?.branch && patch.branch !== task.branch) {
|
|
5954
|
+
const existing = await this.findTaskByBranch(patch.branch, task.projectId);
|
|
5955
|
+
if (existing && existing.id !== taskId)
|
|
5956
|
+
return null;
|
|
5957
|
+
}
|
|
5958
|
+
const next = this.stripGitStatusScopedState(task, toStatus);
|
|
5959
|
+
Object.assign(next, patch ?? {}, {
|
|
5960
|
+
status: toStatus,
|
|
5961
|
+
updatedAt: new Date().toISOString(),
|
|
5962
|
+
});
|
|
5963
|
+
await this.taskStore.set(next);
|
|
5964
|
+
return { task: next, previousStatus };
|
|
5965
|
+
};
|
|
5966
|
+
if (!guard.expectAgentLock)
|
|
5967
|
+
return transition();
|
|
5968
|
+
const guarded = await this.lockManager.runIfOwner(guard.expectAgentLock.agentId, taskId, guard.expectAgentLock.token, transition);
|
|
5969
|
+
return guarded.ran ? guarded.value : null;
|
|
6229
5970
|
});
|
|
6230
5971
|
}
|
|
6231
5972
|
createRunnerFor(agent) {
|
|
@@ -6239,9 +5980,7 @@ export class AgentManager {
|
|
|
6239
5980
|
if (this.repoStoreFactory) {
|
|
6240
5981
|
return this.repoStoreFactory(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir);
|
|
6241
5982
|
}
|
|
6242
|
-
const cloneViaGh =
|
|
6243
|
-
? resolveProjectTool(project) === 'gh'
|
|
6244
|
-
: undefined;
|
|
5983
|
+
const cloneViaGh = resolveProjectTool(project) === 'gh';
|
|
6245
5984
|
return new RepoStore(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir, cloneViaGh);
|
|
6246
5985
|
}
|
|
6247
5986
|
async ensureWorkdir(agent, project, runner) {
|
|
@@ -6336,7 +6075,7 @@ export class AgentManager {
|
|
|
6336
6075
|
throw new ApiError(400, `Agent ${preferredAgentId} not in project ${projectId}`);
|
|
6337
6076
|
}
|
|
6338
6077
|
if (!TASK_OWNER_ROLES.has(cfg.role)) {
|
|
6339
|
-
throw new ApiError(400, `Agent ${preferredAgentId} is not dev
|
|
6078
|
+
throw new ApiError(400, `Agent ${preferredAgentId} is not a dev agent`);
|
|
6340
6079
|
}
|
|
6341
6080
|
const state = await this.agentStore.get(preferredAgentId);
|
|
6342
6081
|
if (!canDispatchWithBinding(state))
|
|
@@ -6392,7 +6131,6 @@ export class AgentManager {
|
|
|
6392
6131
|
reviewRound: 0,
|
|
6393
6132
|
branch: taskBranch,
|
|
6394
6133
|
branchCreatedByBaxian,
|
|
6395
|
-
reviewMode: this.effectiveReviewMode(projectId),
|
|
6396
6134
|
...this.platformBindingFields(projectId),
|
|
6397
6135
|
createdAt: now,
|
|
6398
6136
|
updatedAt: now,
|
|
@@ -6410,18 +6148,15 @@ export class AgentManager {
|
|
|
6410
6148
|
// Capture generation before the first config/group read so a DELETE→same-id recreate in the read→commit window fails the gate.
|
|
6411
6149
|
const genAtEntry = this.deletionGenerationOf(input.preferredAgentId);
|
|
6412
6150
|
const target = await this.pickAgent(projectId, input.preferredAgentId);
|
|
6413
|
-
const targetConfig = this.getAgentConfig(input.preferredAgentId);
|
|
6414
6151
|
const group = this.findAgentGroup(input.preferredAgentId);
|
|
6415
6152
|
const dev = group?.find(agent => agent.role === 'dev');
|
|
6416
6153
|
if (!dev)
|
|
6417
6154
|
throw new ApiError(409, `Agent ${input.preferredAgentId} has no dev agent in its group`);
|
|
6418
6155
|
const qa = group?.find(agent => agent.role === 'qa');
|
|
6419
|
-
|
|
6420
|
-
|
|
6421
|
-
? { researchAgentId: research.id, phase: 'research' }
|
|
6422
|
-
: {};
|
|
6156
|
+
if (!qa)
|
|
6157
|
+
throw new ApiError(409, `Agent ${input.preferredAgentId} has no qa agent in its group`);
|
|
6423
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.
|
|
6424
|
-
const participantGens = new Map([dev.id,
|
|
6159
|
+
const participantGens = new Map([dev.id, qa.id]
|
|
6425
6160
|
.map(id => [id, this.deletionGenerationOf(id)]));
|
|
6426
6161
|
const participantsFresh = () => this.deletionGenerationOf(input.preferredAgentId) === genAtEntry
|
|
6427
6162
|
&& !this.isDeletionInFlight(input.preferredAgentId)
|
|
@@ -6430,9 +6165,8 @@ export class AgentManager {
|
|
|
6430
6165
|
...taskBase,
|
|
6431
6166
|
agentId: '',
|
|
6432
6167
|
devAgentId: dev.id,
|
|
6433
|
-
|
|
6168
|
+
qaAgentId: qa.id,
|
|
6434
6169
|
status: 'pending',
|
|
6435
|
-
...(qa ? { qaAgentId: qa.id } : {}),
|
|
6436
6170
|
};
|
|
6437
6171
|
// A DELETE→recreate of the preferred OR any participant after the group snapshot must not persist a
|
|
6438
6172
|
// pending task carrying its stale devAgentId/qaAgentId; reject and let the client re-select.
|
|
@@ -6516,8 +6250,7 @@ export class AgentManager {
|
|
|
6516
6250
|
...taskBase,
|
|
6517
6251
|
agentId: target.id,
|
|
6518
6252
|
devAgentId: dev.id,
|
|
6519
|
-
|
|
6520
|
-
...researchFields,
|
|
6253
|
+
qaAgentId: qa.id,
|
|
6521
6254
|
status: 'in_progress',
|
|
6522
6255
|
};
|
|
6523
6256
|
try {
|
|
@@ -6554,7 +6287,7 @@ export class AgentManager {
|
|
|
6554
6287
|
const task = await this.createTask(projectId, input);
|
|
6555
6288
|
if (task.status === 'in_progress' && task.agentId) {
|
|
6556
6289
|
const signalToken = createSignalToken();
|
|
6557
|
-
await this.updateTask(task.id, this.dispatchTokenFields(
|
|
6290
|
+
await this.updateTask(task.id, this.dispatchTokenFields(signalToken));
|
|
6558
6291
|
const dispatchLockToken = (await this.agentStore.get(task.agentId))?.lockToken;
|
|
6559
6292
|
const start = this.startCreatedTaskSession(task.id, task.agentId, signalToken, dispatchLockToken);
|
|
6560
6293
|
if (opts.background) {
|
|
@@ -6574,7 +6307,7 @@ export class AgentManager {
|
|
|
6574
6307
|
const initialTask = await this.taskStore.get(taskId);
|
|
6575
6308
|
if (!initialTask)
|
|
6576
6309
|
return null;
|
|
6577
|
-
const initialDispatch = this.resolveInitialDispatch(
|
|
6310
|
+
const initialDispatch = this.resolveInitialDispatch();
|
|
6578
6311
|
const dispatchPhase = initialDispatch.phase;
|
|
6579
6312
|
let started = false;
|
|
6580
6313
|
let dispatchErr = null;
|
|
@@ -6609,7 +6342,7 @@ export class AgentManager {
|
|
|
6609
6342
|
await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true });
|
|
6610
6343
|
return null;
|
|
6611
6344
|
}
|
|
6612
|
-
const initialKinds = this.resolveInitialDispatch(
|
|
6345
|
+
const initialKinds = this.resolveInitialDispatch().kinds;
|
|
6613
6346
|
try {
|
|
6614
6347
|
await this.armPostDispatchSignalOrHold(taskId, agentId, initialKinds, signalToken);
|
|
6615
6348
|
}
|
|
@@ -6824,7 +6557,7 @@ export class AgentManager {
|
|
|
6824
6557
|
`${phase} queued for redispatch when idle (${(err.message.split('\n')[0])})`);
|
|
6825
6558
|
return new EnsureSessionError({ createdSession, agentId, handled: true, busyPending: true }, `QA REPL busy; ${phase} dispatch for task ${taskId} queued for redispatch when idle`);
|
|
6826
6559
|
}
|
|
6827
|
-
async
|
|
6560
|
+
async clearRuntimeForDispatchBoundary(tmux, pane, agentId, runtime, revalidate) {
|
|
6828
6561
|
await this.acquireCompactGuard(agentId);
|
|
6829
6562
|
try {
|
|
6830
6563
|
await this.waitForReplPromptReady(tmux, pane, runtime, this.cleanComposerWaitMs, { stableIdle: true });
|
|
@@ -6843,7 +6576,7 @@ export class AgentManager {
|
|
|
6843
6576
|
});
|
|
6844
6577
|
await this.waitForReplPromptReady(tmux, pane, runtime, this.dispatchAckTimeoutMs, { stableIdle: true });
|
|
6845
6578
|
if (await this.hasRuntimeSlashCommandRejection(tmux, pane, '/clear')) {
|
|
6846
|
-
throw new Error('Runtime rejected /clear at the
|
|
6579
|
+
throw new Error('Runtime rejected /clear at the dispatch boundary; refusing to reuse prior context');
|
|
6847
6580
|
}
|
|
6848
6581
|
await revalidate();
|
|
6849
6582
|
}
|
|
@@ -6928,7 +6661,7 @@ export class AgentManager {
|
|
|
6928
6661
|
return { task: fresh, errorCode: 400, error: `Agent ${agentId} not in project ${fresh.projectId}` };
|
|
6929
6662
|
}
|
|
6930
6663
|
if (!TASK_OWNER_ROLES.has(cfg.role)) {
|
|
6931
|
-
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` };
|
|
6932
6665
|
}
|
|
6933
6666
|
if (fresh.preferredAgentId !== '' && fresh.preferredAgentId !== agentId) {
|
|
6934
6667
|
return {
|
|
@@ -6961,9 +6694,12 @@ export class AgentManager {
|
|
|
6961
6694
|
const qaId = initiallyUnassigned
|
|
6962
6695
|
? group?.find(agent => agent.role === 'qa')?.id
|
|
6963
6696
|
: fresh.qaAgentId;
|
|
6964
|
-
|
|
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
|
+
}
|
|
6965
6701
|
// Pin every participant's generation (mirror createTask) so a member deleted→recreated before the active write isn't baked in.
|
|
6966
|
-
const participantGens = new Map([devId,
|
|
6702
|
+
const participantGens = new Map([devId, qaId]
|
|
6967
6703
|
.map(pid => [pid, this.deletionGenerationOf(pid)]));
|
|
6968
6704
|
const participantsFresh = () => [...participantGens].every(([pid, gen]) => this.deletionGenerationOf(pid) === gen && !this.isDeletionInFlight(pid));
|
|
6969
6705
|
const claimedTask = {
|
|
@@ -6971,9 +6707,8 @@ export class AgentManager {
|
|
|
6971
6707
|
preferredAgentId: agentId,
|
|
6972
6708
|
agentId,
|
|
6973
6709
|
devAgentId: devId,
|
|
6974
|
-
researchAgentId: researchId,
|
|
6975
6710
|
qaAgentId: qaId,
|
|
6976
|
-
phase:
|
|
6711
|
+
phase: undefined,
|
|
6977
6712
|
status: 'in_progress',
|
|
6978
6713
|
updatedAt: now,
|
|
6979
6714
|
};
|
|
@@ -7066,10 +6801,10 @@ export class AgentManager {
|
|
|
7066
6801
|
return claim;
|
|
7067
6802
|
const claimed = claim.task;
|
|
7068
6803
|
const signalToken = createSignalToken();
|
|
7069
|
-
await this.updateTask(claimed.id, this.dispatchTokenFields(
|
|
6804
|
+
await this.updateTask(claimed.id, this.dispatchTokenFields(signalToken));
|
|
7070
6805
|
let started = false;
|
|
7071
6806
|
let dispatchErr = null;
|
|
7072
|
-
const initialDispatch = this.resolveInitialDispatch(
|
|
6807
|
+
const initialDispatch = this.resolveInitialDispatch();
|
|
7073
6808
|
try {
|
|
7074
6809
|
started = await this.startSession(claimed.id, claimed.agentId, initialDispatch.phase);
|
|
7075
6810
|
}
|
|
@@ -7248,30 +6983,13 @@ export class AgentManager {
|
|
|
7248
6983
|
throw busyPend;
|
|
7249
6984
|
throw err;
|
|
7250
6985
|
}
|
|
7251
|
-
const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
|
|
7252
|
-
const isServerCodeReviewPhase = phase === 'server-review' || phase === 'server-recheck';
|
|
7253
|
-
let reviewCheckout;
|
|
7254
6986
|
const dispatchWorkdir = workdir;
|
|
7255
6987
|
try {
|
|
7256
|
-
if (
|
|
7257
|
-
reviewCheckout = await branchManager.materializeReviewHead(workdir, {
|
|
7258
|
-
patch: opts.serverContent,
|
|
7259
|
-
baseSha: opts.serverBaseSha,
|
|
7260
|
-
headSha: opts.serverHeadSha,
|
|
7261
|
-
headTree: opts.serverHeadTree,
|
|
7262
|
-
});
|
|
7263
|
-
}
|
|
7264
|
-
else if (isServerQaPhase) {
|
|
7265
|
-
await branchManager.switchToDefaultDetached(workdir);
|
|
7266
|
-
}
|
|
7267
|
-
else if (phase === 'review' || phase === 'recheck') {
|
|
6988
|
+
if (phase === 'review' || phase === 'recheck') {
|
|
7268
6989
|
if (!task.branch)
|
|
7269
6990
|
throw new Error(`Task ${taskId} has no review branch`);
|
|
7270
6991
|
await this.switchToVerifiedReviewHead(branchManager, workdir, task, assertOwner);
|
|
7271
6992
|
}
|
|
7272
|
-
else if (phase === 'research') {
|
|
7273
|
-
await branchManager.switchToDefaultDetached(workdir);
|
|
7274
|
-
}
|
|
7275
6993
|
else {
|
|
7276
6994
|
if (!task.branch)
|
|
7277
6995
|
throw new Error(`Task ${taskId} has no task branch`);
|
|
@@ -7283,7 +7001,7 @@ export class AgentManager {
|
|
|
7283
7001
|
}
|
|
7284
7002
|
await assertOwner();
|
|
7285
7003
|
if (!ensure.freshRuntime) {
|
|
7286
|
-
await this.
|
|
7004
|
+
await this.clearRuntimeForDispatchBoundary(tmux, pane, agentId, agent.runtime, assertOwner);
|
|
7287
7005
|
if (ensure.skillsStale)
|
|
7288
7006
|
await this.tagSessionSkillsVersion(tmux, agentId, ensure.sessionRef);
|
|
7289
7007
|
}
|
|
@@ -7340,32 +7058,9 @@ export class AgentManager {
|
|
|
7340
7058
|
};
|
|
7341
7059
|
});
|
|
7342
7060
|
const promptSignalToken = opts.signalToken ?? task.signalToken;
|
|
7343
|
-
const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
|
|
7344
|
-
const hasQaPartner = task.qaAgentId !== undefined;
|
|
7345
7061
|
let prompt;
|
|
7346
7062
|
try {
|
|
7347
|
-
await this.prepareDispatchArtifacts(agent, dispatchWorkdir, phase, {
|
|
7348
|
-
specDocuments: opts.specDocuments,
|
|
7349
|
-
preserveOutputs: false,
|
|
7350
|
-
assertOwner,
|
|
7351
|
-
});
|
|
7352
7063
|
const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
|
|
7353
|
-
let payloadOpts = {};
|
|
7354
|
-
if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
|
|
7355
|
-
payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, dispatchWorkdir, {
|
|
7356
|
-
phase,
|
|
7357
|
-
taskPhase: task.phase,
|
|
7358
|
-
...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
|
|
7359
|
-
reviewRound: task.reviewRound,
|
|
7360
|
-
...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
|
|
7361
|
-
...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
|
|
7362
|
-
...(reviewCheckout?.mode === 'head' ? { forceContentFile: true } : {}),
|
|
7363
|
-
...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
|
|
7364
|
-
...(opts.serverInterdiff !== undefined ? { serverInterdiff: opts.serverInterdiff } : {}),
|
|
7365
|
-
...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
|
|
7366
|
-
...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
|
|
7367
|
-
});
|
|
7368
|
-
}
|
|
7369
7064
|
const taskForPrompt = await this.ensureGitBaseSnapshot(task, phase);
|
|
7370
7065
|
const platformCli = this.platformCliContextOf(taskForPrompt);
|
|
7371
7066
|
prompt = buildPromptInline({
|
|
@@ -7374,20 +7069,9 @@ export class AgentManager {
|
|
|
7374
7069
|
agent,
|
|
7375
7070
|
workdir: dispatchWorkdir,
|
|
7376
7071
|
skillRegistry: this.skillRegistry,
|
|
7377
|
-
hasQaPartner,
|
|
7378
7072
|
...(platformCli ? { platformCli } : {}),
|
|
7379
7073
|
...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
|
|
7380
|
-
...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
|
|
7381
7074
|
...(imagePaths.length ? { imagePaths } : {}),
|
|
7382
|
-
...(reviewCheckout ? { serverReviewCheckout: reviewCheckout.mode } : {}),
|
|
7383
|
-
...(reviewCheckout?.fallbackReason ? { serverReviewFallbackReason: reviewCheckout.fallbackReason } : {}),
|
|
7384
|
-
...(opts.serverBaseSha !== undefined ? { serverBaseSha: opts.serverBaseSha } : {}),
|
|
7385
|
-
...(opts.serverHeadSha !== undefined ? { serverHeadSha: opts.serverHeadSha } : {}),
|
|
7386
|
-
...(opts.serverHeadTree !== undefined ? { serverHeadTree: opts.serverHeadTree } : {}),
|
|
7387
|
-
...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
|
|
7388
|
-
...(opts.serverFindingsDigest ? { serverFindingsDigest: opts.serverFindingsDigest } : {}),
|
|
7389
|
-
...(opts.serverFeedbackCorrection ? { serverFeedbackCorrection: opts.serverFeedbackCorrection } : {}),
|
|
7390
|
-
...payloadOpts,
|
|
7391
7075
|
});
|
|
7392
7076
|
}
|
|
7393
7077
|
catch (err) {
|
|
@@ -7433,7 +7117,7 @@ export class AgentManager {
|
|
|
7433
7117
|
await cleanupCheckoutOrHold();
|
|
7434
7118
|
return false;
|
|
7435
7119
|
}
|
|
7436
|
-
if (opts.armBeforeInject && !(await opts.armBeforeInject(
|
|
7120
|
+
if (opts.armBeforeInject && !(await opts.armBeforeInject())) {
|
|
7437
7121
|
await cleanupCheckoutOrHold();
|
|
7438
7122
|
return false;
|
|
7439
7123
|
}
|
|
@@ -7918,40 +7602,9 @@ export class AgentManager {
|
|
|
7918
7602
|
await this.clearBranchLocalCleaned(taskId);
|
|
7919
7603
|
}
|
|
7920
7604
|
}
|
|
7921
|
-
else if (agent.role === 'research') {
|
|
7922
|
-
await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
|
|
7923
|
-
await new BranchManager(runner).switchToDefaultDetached(verifiedWorkdir);
|
|
7924
|
-
await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
|
|
7925
|
-
}
|
|
7926
|
-
const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
|
|
7927
7605
|
let prompt;
|
|
7928
7606
|
try {
|
|
7929
|
-
await this.prepareDispatchArtifacts(agent, verifiedWorkdir, phase, {
|
|
7930
|
-
specDocuments: opts.specDocuments,
|
|
7931
|
-
preserveOutputs: opts.preserveDispatchOutputs ?? false,
|
|
7932
|
-
assertOwner: async () => {
|
|
7933
|
-
await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
|
|
7934
|
-
},
|
|
7935
|
-
});
|
|
7936
|
-
const useIncrementalNudge = typeof opts.postApproveRedispatchCount === 'number'
|
|
7937
|
-
&& opts.postApproveRedispatchCount > 0
|
|
7938
|
-
&& !ensure.freshRuntime;
|
|
7939
7607
|
const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
|
|
7940
|
-
let payloadOpts = {};
|
|
7941
|
-
if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
|
|
7942
|
-
payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, verifiedWorkdir, {
|
|
7943
|
-
phase,
|
|
7944
|
-
taskPhase: task.phase,
|
|
7945
|
-
...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
|
|
7946
|
-
reviewRound: task.reviewRound,
|
|
7947
|
-
...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
|
|
7948
|
-
...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
|
|
7949
|
-
...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
|
|
7950
|
-
...(opts.serverInterdiff !== undefined ? { serverInterdiff: opts.serverInterdiff } : {}),
|
|
7951
|
-
...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
|
|
7952
|
-
...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
|
|
7953
|
-
});
|
|
7954
|
-
}
|
|
7955
7608
|
const taskForPrompt = await this.ensureGitBaseSnapshot(task, phase);
|
|
7956
7609
|
const platformCli = this.platformCliContextOf(taskForPrompt);
|
|
7957
7610
|
prompt = buildPromptInline({
|
|
@@ -7960,19 +7613,9 @@ export class AgentManager {
|
|
|
7960
7613
|
agent,
|
|
7961
7614
|
workdir: verifiedWorkdir,
|
|
7962
7615
|
skillRegistry: this.skillRegistry,
|
|
7963
|
-
hasQaPartner: task.qaAgentId !== undefined,
|
|
7964
7616
|
...(platformCli ? { platformCli } : {}),
|
|
7965
7617
|
...(signalToken ? { signalToken } : {}),
|
|
7966
|
-
...(useIncrementalNudge
|
|
7967
|
-
? { postApproveRedispatchCount: opts.postApproveRedispatchCount }
|
|
7968
|
-
: {}),
|
|
7969
|
-
...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
|
|
7970
7618
|
...(imagePaths.length ? { imagePaths } : {}),
|
|
7971
|
-
...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
|
|
7972
|
-
...(opts.serverAfterDone ? { serverAfterDone: opts.serverAfterDone } : {}),
|
|
7973
|
-
...(opts.serverFindingsDigest ? { serverFindingsDigest: opts.serverFindingsDigest } : {}),
|
|
7974
|
-
...(opts.serverFeedbackCorrection ? { serverFeedbackCorrection: opts.serverFeedbackCorrection } : {}),
|
|
7975
|
-
...payloadOpts,
|
|
7976
7619
|
});
|
|
7977
7620
|
}
|
|
7978
7621
|
catch (err) {
|
|
@@ -8040,7 +7683,33 @@ export class AgentManager {
|
|
|
8040
7683
|
return false;
|
|
8041
7684
|
}
|
|
8042
7685
|
const guardBeforeInject = opts.guardBeforeInject ?? (phase === 'post-approve' ? postApprovePassStillLive : undefined);
|
|
8043
|
-
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())) {
|
|
8044
7713
|
return false;
|
|
8045
7714
|
}
|
|
8046
7715
|
const now = new Date().toISOString();
|
|
@@ -8123,15 +7792,13 @@ export class AgentManager {
|
|
|
8123
7792
|
}
|
|
8124
7793
|
if (boundTask.status !== 'in_progress' || boundTask.agentId !== state.id)
|
|
8125
7794
|
return false;
|
|
8126
|
-
const dispatchPhase = boundTask.
|
|
8127
|
-
? 'research'
|
|
8128
|
-
: boundTask.specReviewRound !== undefined ? 'code' : 'develop';
|
|
7795
|
+
const dispatchPhase = boundTask.specReviewRound !== undefined ? 'code' : 'develop';
|
|
8129
7796
|
if (await this.bootstrapPromptWasDelivered(state.taskId, boundTask.createdAt, state.id, dispatchPhase)) {
|
|
8130
7797
|
await this.clearBootstrapMarker(state.id, state.taskId);
|
|
8131
7798
|
return false;
|
|
8132
7799
|
}
|
|
8133
7800
|
if (dispatchPhase === 'code') {
|
|
8134
|
-
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 });
|
|
8135
7802
|
return true;
|
|
8136
7803
|
}
|
|
8137
7804
|
console.warn(`[recover] agent ${state.id} was mid-bootstrap for in_progress task ${state.taskId} ` +
|
|
@@ -8514,7 +8181,6 @@ export class AgentManager {
|
|
|
8514
8181
|
let devToRelease;
|
|
8515
8182
|
let qaToRelease;
|
|
8516
8183
|
let remoteCleanupGeneration;
|
|
8517
|
-
let publishedCleanup;
|
|
8518
8184
|
this.phaseSignalWatcher?.stop(taskId);
|
|
8519
8185
|
let cleanupClaimed = false;
|
|
8520
8186
|
const result = await this.withTaskLock(async () => {
|
|
@@ -8535,27 +8201,13 @@ export class AgentManager {
|
|
|
8535
8201
|
devToRelease = task.agentId;
|
|
8536
8202
|
if (task.qaAgentId)
|
|
8537
8203
|
qaToRelease = task.qaAgentId;
|
|
8538
|
-
const publishedAtGate = task.status === 'ready'
|
|
8539
|
-
|| (task.status === 'approved' && !!task.publishDispatchedAt);
|
|
8540
|
-
if (task.reviewMode === 'server' && publishedAtGate && task.agentId) {
|
|
8541
|
-
const afterDone = this.resolveAfterDone(task);
|
|
8542
|
-
if (afterDone === 'pr' && task.branch && task.prNumber !== undefined) {
|
|
8543
|
-
publishedCleanup = {
|
|
8544
|
-
afterDone,
|
|
8545
|
-
branch: task.branch,
|
|
8546
|
-
prNumber: task.prNumber,
|
|
8547
|
-
devAgentId: task.agentId,
|
|
8548
|
-
mayBeInFlight: task.status === 'approved',
|
|
8549
|
-
};
|
|
8550
|
-
}
|
|
8551
|
-
}
|
|
8552
8204
|
for (const id of [devToRelease, qaToRelease]) {
|
|
8553
8205
|
if (id)
|
|
8554
8206
|
await this.markPaneCancelClearing(id, taskId);
|
|
8555
8207
|
}
|
|
8556
8208
|
if (!alreadyTerminal) {
|
|
8557
8209
|
const now = new Date().toISOString();
|
|
8558
|
-
if (task.
|
|
8210
|
+
if (task.prNumber !== undefined && task.branch !== undefined) {
|
|
8559
8211
|
const expectedHeadSha = typeof task.latestHeadSha === 'string'
|
|
8560
8212
|
&& PLATFORM_HEAD_SHA_RE.test(task.latestHeadSha) ? task.latestHeadSha : undefined;
|
|
8561
8213
|
const intent = task.remoteCleanup ?? {
|
|
@@ -8589,7 +8241,6 @@ export class AgentManager {
|
|
|
8589
8241
|
// stop again post-write: a rollback may have re-armed between the pre-lock stop() and the cancelled write
|
|
8590
8242
|
if (cleanupClaimed)
|
|
8591
8243
|
this.phaseSignalWatcher?.stop(taskId);
|
|
8592
|
-
let devStopConfirmed = false;
|
|
8593
8244
|
// 两阶段:先中断全部 pane 再释放任一 binding,避免 dev 先空闲、被派新任务时旧任务的 qa prompt 仍在跑。
|
|
8594
8245
|
try {
|
|
8595
8246
|
const stopped = [];
|
|
@@ -8603,11 +8254,10 @@ export class AgentManager {
|
|
|
8603
8254
|
continue;
|
|
8604
8255
|
}
|
|
8605
8256
|
if (!(await this.interruptPaneAndWaitReady(state, cfg))) {
|
|
8606
|
-
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 });
|
|
8607
8259
|
continue;
|
|
8608
8260
|
}
|
|
8609
|
-
if (id === publishedCleanup?.devAgentId)
|
|
8610
|
-
devStopConfirmed = true;
|
|
8611
8261
|
stopped.push(id);
|
|
8612
8262
|
}
|
|
8613
8263
|
for (const id of stopped) {
|
|
@@ -8636,46 +8286,6 @@ export class AgentManager {
|
|
|
8636
8286
|
});
|
|
8637
8287
|
}
|
|
8638
8288
|
}
|
|
8639
|
-
if (publishedCleanup) {
|
|
8640
|
-
if (publishedCleanup.mayBeInFlight && !devStopConfirmed) {
|
|
8641
|
-
await this.emitIntervention(result.projectId, undefined, taskId, {
|
|
8642
|
-
phase: 'cancel-published-artifact-cleanup-skipped',
|
|
8643
|
-
afterDone: publishedCleanup.afterDone,
|
|
8644
|
-
branch: publishedCleanup.branch,
|
|
8645
|
-
prNumber: publishedCleanup.prNumber,
|
|
8646
|
-
reason: 'dev pane stop unconfirmed; the publish prompt may still be running and would recreate the remote artifacts',
|
|
8647
|
-
});
|
|
8648
|
-
return result;
|
|
8649
|
-
}
|
|
8650
|
-
try {
|
|
8651
|
-
const { driver } = await this.platformTaskAndDriver(taskId);
|
|
8652
|
-
await driver.runOp('close', { prNumber: publishedCleanup.prNumber });
|
|
8653
|
-
await this.safeEmit({
|
|
8654
|
-
id: '',
|
|
8655
|
-
type: 'task.updated',
|
|
8656
|
-
timestamp: new Date().toISOString(),
|
|
8657
|
-
projectId: result.projectId,
|
|
8658
|
-
taskId,
|
|
8659
|
-
data: {
|
|
8660
|
-
status: 'cancelled',
|
|
8661
|
-
operation: 'server-pr-close',
|
|
8662
|
-
outcome: 'succeeded',
|
|
8663
|
-
branch: publishedCleanup.branch,
|
|
8664
|
-
prNumber: publishedCleanup.prNumber,
|
|
8665
|
-
},
|
|
8666
|
-
});
|
|
8667
|
-
}
|
|
8668
|
-
catch (err) {
|
|
8669
|
-
console.warn(`[AgentManager] cancelTask remote retirement failed for ${taskId}:`, err);
|
|
8670
|
-
await this.emitIntervention(result.projectId, undefined, taskId, {
|
|
8671
|
-
phase: 'cancel-published-artifact-cleanup-failed',
|
|
8672
|
-
afterDone: publishedCleanup.afterDone,
|
|
8673
|
-
branch: publishedCleanup.branch,
|
|
8674
|
-
prNumber: publishedCleanup.prNumber,
|
|
8675
|
-
error: err instanceof Error ? err.message : String(err),
|
|
8676
|
-
});
|
|
8677
|
-
}
|
|
8678
|
-
}
|
|
8679
8289
|
return result;
|
|
8680
8290
|
}
|
|
8681
8291
|
async validateTaskDispatch(projectId, input) {
|
|
@@ -8688,7 +8298,7 @@ export class AgentManager {
|
|
|
8688
8298
|
throw new ApiError(400, `Agent ${input.preferredAgentId} not in project ${projectId}`);
|
|
8689
8299
|
}
|
|
8690
8300
|
if (!TASK_OWNER_ROLES.has(cfg.role)) {
|
|
8691
|
-
throw new ApiError(400, `Agent ${input.preferredAgentId} is not dev
|
|
8301
|
+
throw new ApiError(400, `Agent ${input.preferredAgentId} is not a dev agent`);
|
|
8692
8302
|
}
|
|
8693
8303
|
}
|
|
8694
8304
|
let previewBytes;
|
|
@@ -8732,22 +8342,27 @@ export class AgentManager {
|
|
|
8732
8342
|
&& this.gitReviewLeaseMatches(task, lease);
|
|
8733
8343
|
}
|
|
8734
8344
|
async dispatchGitReviewLease(taskId, opts = {}) {
|
|
8735
|
-
const claimed = await this.claimGitReviewDispatch(taskId, opts.expectedGeneration);
|
|
8345
|
+
const claimed = await this.claimGitReviewDispatch(taskId, opts.expectedGeneration, opts.expectedTask);
|
|
8736
8346
|
if (!claimed) {
|
|
8737
8347
|
throw new ApiError(409, `Git review dispatch for ${taskId} was already claimed or superseded`, 'dispatch-superseded');
|
|
8738
8348
|
}
|
|
8349
|
+
opts.onSideEffect?.();
|
|
8739
8350
|
const { task, lease } = claimed;
|
|
8740
8351
|
const qaId = task.qaAgentId;
|
|
8741
|
-
if (!qaId
|
|
8352
|
+
if (!qaId) {
|
|
8353
|
+
await this.resetGitReviewDispatchClaim(taskId, lease);
|
|
8354
|
+
throw new Error(`dispatchGitReviewLease: task ${taskId} has no QA participant`);
|
|
8355
|
+
}
|
|
8356
|
+
if (!this.gitReviewQaConfigUsable(task)) {
|
|
8742
8357
|
await this.resetGitReviewDispatchClaim(taskId, lease);
|
|
8743
|
-
throw new ApiError(409, `Task ${taskId}
|
|
8358
|
+
throw new ApiError(409, `Task ${taskId} QA participant is unavailable`);
|
|
8744
8359
|
}
|
|
8745
8360
|
const qaPhase = lease.qaPhase;
|
|
8746
8361
|
const previousQa = await this.agentStore.get(qaId);
|
|
8747
8362
|
if (previousQa?.taskId === taskId) {
|
|
8748
8363
|
if (previousQa.status === 'awaiting_human' && !isRecoverableQaDispatchHold(previousQa)) {
|
|
8749
8364
|
await this.resetGitReviewDispatchClaim(taskId, lease);
|
|
8750
|
-
throw new ApiError(409, `QA agent ${qaId} is awaiting human (${previousQa.awaitingPhase ?? 'unknown phase'})
|
|
8365
|
+
throw new ApiError(409, `QA agent ${qaId} is awaiting human (${previousQa.awaitingPhase ?? 'unknown phase'})`, 'dispatch-unsupported');
|
|
8751
8366
|
}
|
|
8752
8367
|
const released = await this.releaseAgentForTask(qaId, taskId, 'idle', {
|
|
8753
8368
|
expectedTask: { status: 'review', phase: task.phase, signalToken: lease.signalToken },
|
|
@@ -8768,11 +8383,13 @@ export class AgentManager {
|
|
|
8768
8383
|
await this.resetGitReviewDispatchClaim(taskId, lease);
|
|
8769
8384
|
throw new ApiError(409, `QA agent ${qaId} could not be released for git review dispatch`);
|
|
8770
8385
|
}
|
|
8386
|
+
opts.onSideEffect?.();
|
|
8771
8387
|
}
|
|
8772
8388
|
let acquiredLockToken;
|
|
8773
8389
|
const acquired = await this.acquireAgentForTask(qaId, taskId, qaPhase, {
|
|
8774
8390
|
onAcquired: token => {
|
|
8775
8391
|
acquiredLockToken = token;
|
|
8392
|
+
opts.onSideEffect?.();
|
|
8776
8393
|
opts.onQaAcquired?.(token);
|
|
8777
8394
|
},
|
|
8778
8395
|
});
|
|
@@ -8866,7 +8483,67 @@ export class AgentManager {
|
|
|
8866
8483
|
}
|
|
8867
8484
|
async dispatchReviewToQa(taskId, opts = {}) {
|
|
8868
8485
|
let routeTask = await this.taskStore.get(taskId);
|
|
8869
|
-
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
|
+
}
|
|
8544
|
+
if (opts.expectedTask && !taskMatchesGeneration(routeTask, opts.expectedTask)) {
|
|
8545
|
+
throw new ApiError(409, `Task ${taskId} review generation changed during redispatch`, 'dispatch-superseded');
|
|
8546
|
+
}
|
|
8870
8547
|
if (opts.fromStatus && !opts.fromStatus.includes(routeTask.status)) {
|
|
8871
8548
|
throw new ApiError(409, `Task ${taskId} status is ${routeTask.status}; review dispatch requires ${opts.fromStatus.join('/')}`, 'dispatch-superseded');
|
|
8872
8549
|
}
|
|
@@ -8876,6 +8553,10 @@ export class AgentManager {
|
|
|
8876
8553
|
if (Object.hasOwn(opts, 'expectPhase') && routeTask.phase !== opts.expectPhase) {
|
|
8877
8554
|
throw new ApiError(409, `Task ${taskId} review phase changed during redispatch`, 'dispatch-superseded');
|
|
8878
8555
|
}
|
|
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');
|
|
8559
|
+
}
|
|
8879
8560
|
let lease = routeTask.reviewDispatch;
|
|
8880
8561
|
if (lease?.phase === 'uncertain') {
|
|
8881
8562
|
if (opts.confirmUncertainNotDelivered !== true) {
|
|
@@ -8897,17 +8578,32 @@ export class AgentManager {
|
|
|
8897
8578
|
}
|
|
8898
8579
|
routeTask = confirmed;
|
|
8899
8580
|
lease = confirmed.reviewDispatch;
|
|
8581
|
+
opts.onSideEffect?.();
|
|
8582
|
+
if (opts.expectedTask && !taskMatchesGeneration(routeTask, opts.expectedTask)) {
|
|
8583
|
+
throw new ApiError(409, `Task ${taskId} review generation changed during uncertain-delivery confirmation`, 'dispatch-superseded');
|
|
8584
|
+
}
|
|
8585
|
+
}
|
|
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');
|
|
8589
|
+
}
|
|
8590
|
+
const qaBinding = await this.agentStore.get(routeQaId);
|
|
8591
|
+
if (qaBinding?.taskId === taskId
|
|
8592
|
+
&& qaBinding.status === 'awaiting_human'
|
|
8593
|
+
&& !isRecoverableQaDispatchHold(qaBinding)) {
|
|
8594
|
+
throw new ApiError(409, `QA agent ${routeQaId} is awaiting human (${qaBinding.awaitingPhase ?? 'unknown phase'})`, 'dispatch-unsupported');
|
|
8900
8595
|
}
|
|
8596
|
+
let dispatchExpectedTask = opts.expectedTask;
|
|
8901
8597
|
if (lease === undefined) {
|
|
8902
8598
|
if (routeTask.prNumber === undefined) {
|
|
8903
|
-
throw new ApiError(400, `Task ${taskId} has no PR yet; cannot dispatch review
|
|
8599
|
+
throw new ApiError(400, `Task ${taskId} has no PR yet; cannot dispatch review`, 'dispatch-unsupported');
|
|
8904
8600
|
}
|
|
8905
8601
|
const verified = await this.platformVerifyPrBinding(taskId, routeTask.prNumber);
|
|
8906
8602
|
if (!verified.ok) {
|
|
8907
|
-
throw new ApiError(409, `Cannot dispatch git review for ${taskId}: binding ${verified.reason}
|
|
8603
|
+
throw new ApiError(409, `Cannot dispatch git review for ${taskId}: binding ${verified.reason}`, 'dispatch-unsupported');
|
|
8908
8604
|
}
|
|
8909
8605
|
if (!PLATFORM_HEAD_SHA_RE.test(verified.headSha)) {
|
|
8910
|
-
throw new ApiError(502, `Cannot dispatch git review for ${taskId}: invalid platform head
|
|
8606
|
+
throw new ApiError(502, `Cannot dispatch git review for ${taskId}: invalid platform head`, 'dispatch-unsupported');
|
|
8911
8607
|
}
|
|
8912
8608
|
const begun = await this.beginGitReviewPass(taskId, {
|
|
8913
8609
|
fromStatus: [routeTask.status],
|
|
@@ -8916,106 +8612,100 @@ export class AgentManager {
|
|
|
8916
8612
|
...(opts.qaPhase !== undefined ? { qaPhase: opts.qaPhase } : {}),
|
|
8917
8613
|
expectSignalToken: routeTask.signalToken,
|
|
8918
8614
|
expectPhase: routeTask.phase,
|
|
8615
|
+
...(opts.expectedTask !== undefined ? { expectedTask: opts.expectedTask } : {}),
|
|
8919
8616
|
patch: { latestHeadSha: verified.headSha },
|
|
8920
8617
|
});
|
|
8921
8618
|
if (!begun?.task.reviewDispatch) {
|
|
8922
8619
|
throw new ApiError(409, `Task ${taskId} changed before git review pass creation`, 'dispatch-superseded');
|
|
8923
8620
|
}
|
|
8621
|
+
opts.onSideEffect?.();
|
|
8924
8622
|
lease = begun.task.reviewDispatch;
|
|
8623
|
+
if (opts.expectedTask) {
|
|
8624
|
+
dispatchExpectedTask = {
|
|
8625
|
+
...opts.expectedTask,
|
|
8626
|
+
signalToken: begun.task.signalToken,
|
|
8627
|
+
};
|
|
8628
|
+
}
|
|
8925
8629
|
}
|
|
8926
8630
|
const dispatched = await this.dispatchGitReviewLease(taskId, {
|
|
8927
8631
|
expectedGeneration: lease.generation,
|
|
8632
|
+
...(dispatchExpectedTask !== undefined ? { expectedTask: dispatchExpectedTask } : {}),
|
|
8928
8633
|
...(opts.pendingBudget !== undefined ? { pendingBudget: opts.pendingBudget } : {}),
|
|
8929
8634
|
...(opts.onQaAcquired !== undefined ? { onQaAcquired: opts.onQaAcquired } : {}),
|
|
8635
|
+
...(opts.onSideEffect !== undefined ? { onSideEffect: opts.onSideEffect } : {}),
|
|
8930
8636
|
});
|
|
8931
8637
|
opts.onPassArmed?.(lease.signalToken);
|
|
8932
8638
|
return dispatched;
|
|
8933
8639
|
}
|
|
8934
|
-
const claim = await this.withTaskLock(async () => {
|
|
8935
|
-
if (this.manualReviewInFlight.has(taskId)) {
|
|
8936
|
-
throw new ApiError(409, `Manual review already in progress for task ${taskId}`, 'dispatch-in-flight');
|
|
8937
|
-
}
|
|
8938
|
-
if (this.markCompleteInFlight.has(taskId)) {
|
|
8939
|
-
throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`, 'dispatch-in-flight');
|
|
8940
|
-
}
|
|
8941
|
-
const task = await this.taskStore.get(taskId);
|
|
8942
|
-
if (!task)
|
|
8943
|
-
throw new ApiError(404, `Task ${taskId} not found`);
|
|
8944
|
-
if (task.reviewMode !== 'server' && !isSpecStagePhase(task.phase)) {
|
|
8945
|
-
throw new ApiError(409, `Task ${taskId} review mode changed during dispatch; retry`, 'dispatch-superseded');
|
|
8946
|
-
}
|
|
8947
|
-
if (opts.fromStatus && !opts.fromStatus.includes(task.status)) {
|
|
8948
|
-
throw new ApiError(409, `Task ${taskId} status is ${task.status}; review dispatch requires ${opts.fromStatus.join('/')}`, 'dispatch-superseded');
|
|
8949
|
-
}
|
|
8950
|
-
if (Object.hasOwn(opts, 'expectSignalToken') && task.signalToken !== opts.expectSignalToken) {
|
|
8951
|
-
throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (signalToken rotated); aborting`, 'dispatch-superseded');
|
|
8952
|
-
}
|
|
8953
|
-
if (Object.hasOwn(opts, 'expectPhase') && task.phase !== opts.expectPhase) {
|
|
8954
|
-
throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (phase changed); aborting`, 'dispatch-superseded');
|
|
8955
|
-
}
|
|
8956
|
-
if (!MANUAL_SERVER_REVIEW_STATUSES.includes(task.status)) {
|
|
8957
|
-
throw new ApiError(409, `Task ${taskId} status is ${task.status}; manual server-side review requires ${MANUAL_SERVER_REVIEW_STATUSES.join('/')}`);
|
|
8958
|
-
}
|
|
8959
|
-
if (!this.serverReviewDriver) {
|
|
8960
|
-
throw new ApiError(409, `Server review pipeline is not configured; cannot dispatch review for ${taskId}`);
|
|
8961
|
-
}
|
|
8962
|
-
if (task.status === 'in_progress' && task.phase === undefined) {
|
|
8963
|
-
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`);
|
|
8964
|
-
}
|
|
8965
|
-
const qaAvailable = task.qaAgentId !== undefined && this.getAgentConfig(task.qaAgentId)?.role === 'qa';
|
|
8966
|
-
if (!qaAvailable) {
|
|
8967
|
-
throw new ApiError(400, `Task ${taskId} has no QA partner configured; manual review requires a QA agent`);
|
|
8968
|
-
}
|
|
8969
|
-
const isSpec = isSpecStagePhase(task.phase);
|
|
8970
|
-
const cap = this.config.review.rounds + (task.maxRoundsContinues ?? 0);
|
|
8971
|
-
const round = isSpec ? (task.specReviewRound ?? 0) : task.reviewRound;
|
|
8972
|
-
const nextRound = opts.bumpRound === false ? round : round + 1;
|
|
8973
|
-
if (nextRound < 1) {
|
|
8974
|
-
throw new ApiError(409, `Task ${taskId} has no current review round to redispatch`);
|
|
8975
|
-
}
|
|
8976
|
-
if (nextRound > cap) {
|
|
8977
|
-
throw new ApiError(409, `Task ${taskId} reached the review round cap (${cap}); continue or cancel it instead`);
|
|
8978
|
-
}
|
|
8979
|
-
this.manualReviewInFlight.add(taskId);
|
|
8980
|
-
return {
|
|
8981
|
-
isSpec,
|
|
8982
|
-
reviewPass: {
|
|
8983
|
-
status: task.status,
|
|
8984
|
-
phase: task.phase,
|
|
8985
|
-
signalToken: task.signalToken,
|
|
8986
|
-
},
|
|
8987
|
-
bumpRound: opts.bumpRound,
|
|
8988
|
-
};
|
|
8989
|
-
});
|
|
8990
|
-
return this.runManualServerReview(taskId, claim);
|
|
8991
8640
|
}
|
|
8992
|
-
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;
|
|
8993
8673
|
try {
|
|
8994
|
-
|
|
8995
|
-
|
|
8996
|
-
|
|
8997
|
-
|
|
8998
|
-
|
|
8999
|
-
|
|
9000
|
-
|
|
9001
|
-
|
|
9002
|
-
|
|
9003
|
-
}
|
|
9004
|
-
const dispatched = claim.isSpec
|
|
9005
|
-
? claim.bumpRound === false
|
|
9006
|
-
? await this.serverReviewDriver.dispatchSpecReview(fresh, { bumpRound: false })
|
|
9007
|
-
: await this.serverReviewDriver.dispatchSpecReview(fresh)
|
|
9008
|
-
: claim.bumpRound === false
|
|
9009
|
-
? await this.serverReviewDriver.dispatchCodeReview(fresh, { bumpRound: false })
|
|
9010
|
-
: await this.serverReviewDriver.dispatchCodeReview(fresh);
|
|
9011
|
-
if (!dispatched) {
|
|
9012
|
-
throw new ApiError(500, `Manual review dispatch for ${taskId} did not start; check the event feed for the cause`);
|
|
9013
|
-
}
|
|
9014
|
-
return (await this.taskStore.get(taskId));
|
|
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;
|
|
9015
8683
|
}
|
|
9016
|
-
|
|
9017
|
-
|
|
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;
|
|
9018
8703
|
}
|
|
8704
|
+
await this.emitIntervention(task.projectId, task.agentId, task.id, {
|
|
8705
|
+
phase: 'fix-resume-failed',
|
|
8706
|
+
reviewRound: taskReviewRound(task),
|
|
8707
|
+
});
|
|
8708
|
+
return false;
|
|
9019
8709
|
}
|
|
9020
8710
|
async continueDevRound(taskId) {
|
|
9021
8711
|
if (this.markCompleteInFlight.has(taskId)) {
|
|
@@ -9030,47 +8720,6 @@ export class AgentManager {
|
|
|
9030
8720
|
if (isSpecStagePhase(task.phase)) {
|
|
9031
8721
|
throw new ApiError(409, `Continue one round is only supported for code-phase tasks`);
|
|
9032
8722
|
}
|
|
9033
|
-
if (task.reviewMode === 'server') {
|
|
9034
|
-
if (!task.agentId) {
|
|
9035
|
-
throw new ApiError(400, `Task ${taskId} has no dev agent; cannot continue`);
|
|
9036
|
-
}
|
|
9037
|
-
const stored = await this.reviewStore?.getRound(taskId, 'code', Math.max(task.reviewRound, 1));
|
|
9038
|
-
if (!stored?.findings) {
|
|
9039
|
-
throw new ApiError(409, `Task ${taskId} has no stored findings to continue from; cancel instead`);
|
|
9040
|
-
}
|
|
9041
|
-
await this.withTaskLock(async () => {
|
|
9042
|
-
if (this.markCompleteInFlight.has(taskId)) {
|
|
9043
|
-
throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`);
|
|
9044
|
-
}
|
|
9045
|
-
const fresh = await this.taskStore.get(taskId);
|
|
9046
|
-
if (!fresh || fresh.status !== 'max_rounds') {
|
|
9047
|
-
throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${fresh?.status ?? 'gone'})`);
|
|
9048
|
-
}
|
|
9049
|
-
fresh.maxRoundsContinues = (fresh.maxRoundsContinues ?? 0) + 1;
|
|
9050
|
-
fresh.updatedAt = new Date().toISOString();
|
|
9051
|
-
await this.taskStore.set(fresh);
|
|
9052
|
-
});
|
|
9053
|
-
let dispatched = null;
|
|
9054
|
-
try {
|
|
9055
|
-
dispatched = await this.dispatchServerFixToDev(taskId, JSON.stringify(stored.findings));
|
|
9056
|
-
}
|
|
9057
|
-
finally {
|
|
9058
|
-
if (!dispatched) {
|
|
9059
|
-
await this.withTaskLock(async () => {
|
|
9060
|
-
const fresh = await this.taskStore.get(taskId);
|
|
9061
|
-
if (!fresh)
|
|
9062
|
-
return;
|
|
9063
|
-
fresh.maxRoundsContinues = Math.max(0, (fresh.maxRoundsContinues ?? 0) - 1);
|
|
9064
|
-
fresh.updatedAt = new Date().toISOString();
|
|
9065
|
-
await this.taskStore.set(fresh);
|
|
9066
|
-
}).catch(() => undefined);
|
|
9067
|
-
}
|
|
9068
|
-
}
|
|
9069
|
-
if (!dispatched) {
|
|
9070
|
-
throw new ApiError(500, `Failed to dispatch server fix round for task ${taskId}`);
|
|
9071
|
-
}
|
|
9072
|
-
return dispatched;
|
|
9073
|
-
}
|
|
9074
8723
|
if (!task.prNumber || !task.branch) {
|
|
9075
8724
|
throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot continue`);
|
|
9076
8725
|
}
|
|
@@ -9089,56 +8738,44 @@ export class AgentManager {
|
|
|
9089
8738
|
throw new ApiError(409, `Task ${taskId} changed status during continue; aborted`);
|
|
9090
8739
|
}
|
|
9091
8740
|
const rollback = async () => {
|
|
9092
|
-
await this.
|
|
9093
|
-
|
|
9094
|
-
|
|
9095
|
-
|
|
9096
|
-
|
|
9097
|
-
|
|
9098
|
-
|
|
9099
|
-
|
|
9100
|
-
if (!armed) {
|
|
9101
|
-
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 });
|
|
9102
|
-
await rollback();
|
|
9103
|
-
throw new ApiError(500, `Failed to arm pr-fixed watcher for task ${taskId}`);
|
|
9104
|
-
}
|
|
9105
|
-
const rollbackAndRepark = async () => {
|
|
9106
|
-
await rollback();
|
|
9107
|
-
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;
|
|
9108
8749
|
};
|
|
9109
|
-
let resumed = false;
|
|
9110
8750
|
try {
|
|
9111
|
-
|
|
8751
|
+
if (await this.dispatchGitFixToDev(taskId))
|
|
8752
|
+
return (await this.taskStore.get(taskId));
|
|
9112
8753
|
}
|
|
9113
8754
|
catch (err) {
|
|
9114
|
-
if (
|
|
9115
|
-
|
|
9116
|
-
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`);
|
|
9117
8757
|
}
|
|
9118
|
-
await rollbackAndRepark();
|
|
9119
8758
|
throw err;
|
|
9120
8759
|
}
|
|
9121
|
-
|
|
9122
|
-
|
|
9123
|
-
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`);
|
|
9124
8763
|
}
|
|
9125
|
-
|
|
9126
|
-
|
|
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}`);
|
|
9127
8768
|
}
|
|
9128
|
-
devInitialSignalKinds(
|
|
9129
|
-
return
|
|
9130
|
-
? ['spec-done', 'code-done']
|
|
9131
|
-
: ['spec-done', 'pr-created'];
|
|
8769
|
+
devInitialSignalKinds() {
|
|
8770
|
+
return ['spec-done', 'pr-created'];
|
|
9132
8771
|
}
|
|
9133
|
-
resolveInitialDispatch(
|
|
9134
|
-
|
|
9135
|
-
return { phase: 'research', kinds: ['spec-done'] };
|
|
9136
|
-
return { phase: 'develop', kinds: this.devInitialSignalKinds(task.reviewMode) };
|
|
8772
|
+
resolveInitialDispatch() {
|
|
8773
|
+
return { phase: 'develop', kinds: this.devInitialSignalKinds() };
|
|
9137
8774
|
}
|
|
9138
8775
|
mapTaskStateToExpectedWatchers(task) {
|
|
9139
8776
|
const single = this.mapTaskStateToExpectedWatcher(task);
|
|
9140
8777
|
const mappings = single ? [single] : [];
|
|
9141
|
-
if (task.
|
|
8778
|
+
if (task.status === 'review'
|
|
9142
8779
|
&& task.replyActorStatus !== 'verified' && task.pendingPrSignalToken !== undefined
|
|
9143
8780
|
&& task.devAgentId && task.prNumber !== undefined) {
|
|
9144
8781
|
mappings.push({
|
|
@@ -9150,24 +8787,19 @@ export class AgentManager {
|
|
|
9150
8787
|
return mappings;
|
|
9151
8788
|
}
|
|
9152
8789
|
mapTaskStateToExpectedWatcher(task) {
|
|
9153
|
-
if (task.
|
|
9154
|
-
return this.mapServerTaskToExpectedWatcher(task);
|
|
9155
|
-
const specStage = isSpecStagePhase(task.phase);
|
|
9156
|
-
if (task.status === 'review' && task.qaAgentId) {
|
|
8790
|
+
if (task.status === 'review') {
|
|
9157
8791
|
return {
|
|
9158
|
-
expectedKinds:
|
|
9159
|
-
agentId: task
|
|
8792
|
+
expectedKinds: PASSIVE_VERDICT_WATCH,
|
|
8793
|
+
agentId: requireTaskQaAgentId(task, 'mapTaskStateToExpectedWatcher'),
|
|
9160
8794
|
};
|
|
9161
8795
|
}
|
|
9162
8796
|
if (task.status === 'fixing' && task.agentId) {
|
|
9163
8797
|
return {
|
|
9164
|
-
expectedKinds: [
|
|
8798
|
+
expectedKinds: ['pr-fixed'],
|
|
9165
8799
|
agentId: task.agentId,
|
|
9166
8800
|
};
|
|
9167
8801
|
}
|
|
9168
8802
|
if (task.status === 'in_progress' && task.agentId) {
|
|
9169
|
-
if (task.phase === 'research')
|
|
9170
|
-
return { expectedKinds: ['spec-done'], agentId: task.agentId };
|
|
9171
8803
|
if (task.phase === 'code')
|
|
9172
8804
|
return { expectedKinds: ['pr-created'], agentId: task.agentId };
|
|
9173
8805
|
if (task.phase === undefined) {
|
|
@@ -9176,27 +8808,6 @@ export class AgentManager {
|
|
|
9176
8808
|
}
|
|
9177
8809
|
return undefined;
|
|
9178
8810
|
}
|
|
9179
|
-
mapServerTaskToExpectedWatcher(task) {
|
|
9180
|
-
const isSpec = isSpecStagePhase(task.phase);
|
|
9181
|
-
if (task.status === 'review' && task.qaAgentId) {
|
|
9182
|
-
return { expectedKinds: [isSpec ? 'spec-reviewed' : 'code-reviewed'], agentId: task.qaAgentId };
|
|
9183
|
-
}
|
|
9184
|
-
if (task.status === 'fixing' && task.agentId) {
|
|
9185
|
-
return { expectedKinds: [isSpec ? 'spec-fixed' : 'code-fixed'], agentId: task.agentId };
|
|
9186
|
-
}
|
|
9187
|
-
if (task.status === 'in_progress' && task.agentId) {
|
|
9188
|
-
if (task.phase === 'research')
|
|
9189
|
-
return { expectedKinds: ['spec-done'], agentId: task.agentId };
|
|
9190
|
-
if (task.phase === 'code') {
|
|
9191
|
-
return { expectedKinds: ['code-done'], agentId: task.agentId };
|
|
9192
|
-
}
|
|
9193
|
-
return { expectedKinds: ['spec-done', 'code-done'], agentId: task.agentId };
|
|
9194
|
-
}
|
|
9195
|
-
if (task.status === 'approved' && task.agentId) {
|
|
9196
|
-
return { expectedKinds: ['code-ready'], agentId: task.agentId };
|
|
9197
|
-
}
|
|
9198
|
-
return undefined;
|
|
9199
|
-
}
|
|
9200
8811
|
async setupPhaseSignal(taskId, agentId, expectedKinds, opts = {}) {
|
|
9201
8812
|
const task = await this.taskStore.get(taskId);
|
|
9202
8813
|
if (!task?.signalToken)
|
|
@@ -9221,7 +8832,7 @@ export class AgentManager {
|
|
|
9221
8832
|
const input = {
|
|
9222
8833
|
title: old.title,
|
|
9223
8834
|
description: old.description,
|
|
9224
|
-
preferredAgentId: old
|
|
8835
|
+
preferredAgentId: this.resolveRetryPreferredAgentId(old),
|
|
9225
8836
|
};
|
|
9226
8837
|
if (old.images?.length) {
|
|
9227
8838
|
input.images = await this.readStagedImages(old.id, old.images);
|
|
@@ -9232,6 +8843,27 @@ export class AgentManager {
|
|
|
9232
8843
|
}
|
|
9233
8844
|
return this.createAndStartTask(old.projectId, input);
|
|
9234
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
|
+
}
|
|
9235
8867
|
async editTask(taskId, patch) {
|
|
9236
8868
|
return this.withTaskLock(async () => {
|
|
9237
8869
|
const task = await this.taskStore.get(taskId);
|
|
@@ -9251,7 +8883,6 @@ export class AgentManager {
|
|
|
9251
8883
|
task.devAgentId = '';
|
|
9252
8884
|
delete task.phase;
|
|
9253
8885
|
delete task.qaAgentId;
|
|
9254
|
-
delete task.researchAgentId;
|
|
9255
8886
|
}
|
|
9256
8887
|
else {
|
|
9257
8888
|
const cfg = this.getAgentConfig(patch.preferredAgentId);
|
|
@@ -9261,28 +8892,18 @@ export class AgentManager {
|
|
|
9261
8892
|
throw new ApiError(400, `Agent not in project ${task.projectId}`);
|
|
9262
8893
|
}
|
|
9263
8894
|
if (!TASK_OWNER_ROLES.has(cfg.role))
|
|
9264
|
-
throw new ApiError(400,
|
|
8895
|
+
throw new ApiError(400, 'Agent is not a dev agent');
|
|
9265
8896
|
const group = this.findAgentGroup(cfg.id);
|
|
9266
8897
|
const dev = group?.find(agent => agent.role === 'dev');
|
|
9267
8898
|
if (!dev)
|
|
9268
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`);
|
|
9269
8903
|
task.preferredAgentId = patch.preferredAgentId;
|
|
9270
8904
|
task.devAgentId = dev.id;
|
|
9271
|
-
|
|
9272
|
-
|
|
9273
|
-
task.researchAgentId = cfg.id;
|
|
9274
|
-
}
|
|
9275
|
-
else {
|
|
9276
|
-
delete task.phase;
|
|
9277
|
-
delete task.researchAgentId;
|
|
9278
|
-
}
|
|
9279
|
-
const qaId = group?.find(agent => agent.role === 'qa')?.id;
|
|
9280
|
-
if (qaId) {
|
|
9281
|
-
task.qaAgentId = qaId;
|
|
9282
|
-
}
|
|
9283
|
-
else {
|
|
9284
|
-
delete task.qaAgentId;
|
|
9285
|
-
}
|
|
8905
|
+
task.qaAgentId = qa.id;
|
|
8906
|
+
delete task.phase;
|
|
9286
8907
|
}
|
|
9287
8908
|
}
|
|
9288
8909
|
task.updatedAt = new Date().toISOString();
|
|
@@ -9295,72 +8916,32 @@ export class AgentManager {
|
|
|
9295
8916
|
if (!task || !task.prNumber) {
|
|
9296
8917
|
throw new Error(`mergePr: no PR number for task ${taskId}`);
|
|
9297
8918
|
}
|
|
9298
|
-
if (task.reviewMode === 'git') {
|
|
9299
|
-
const expectedHeadSha = opts.matchHeadSha ?? task.latestHeadSha;
|
|
9300
|
-
if (!expectedHeadSha)
|
|
9301
|
-
throw new Error(`mergePr: no expected head sha for git task ${taskId}`);
|
|
9302
|
-
await this.platformConfirmMerge(taskId, {
|
|
9303
|
-
expectedHeadSha,
|
|
9304
|
-
...(opts.humanOverride === true || task.status === 'max_rounds' ? { humanOverride: true } : {}),
|
|
9305
|
-
});
|
|
9306
|
-
return;
|
|
9307
|
-
}
|
|
9308
8919
|
const expectedHeadSha = opts.matchHeadSha ?? task.latestHeadSha;
|
|
9309
|
-
if (!expectedHeadSha
|
|
9310
|
-
throw new Error(`mergePr: no
|
|
9311
|
-
|
|
9312
|
-
|
|
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
|
+
});
|
|
9313
8926
|
}
|
|
9314
8927
|
async markTaskComplete(taskId) {
|
|
9315
8928
|
const peek = await this.taskStore.get(taskId);
|
|
9316
8929
|
if (!peek)
|
|
9317
8930
|
throw new ApiError(404, `Task ${taskId} not found`);
|
|
9318
|
-
if (peek.status === '
|
|
8931
|
+
if (peek.status === 'merge-ready') {
|
|
9319
8932
|
return this.confirmHumanGate(taskId);
|
|
9320
8933
|
}
|
|
9321
|
-
const task = await this.claimCompleteGate(taskId, ['max_rounds'
|
|
8934
|
+
const task = await this.claimCompleteGate(taskId, ['max_rounds']);
|
|
9322
8935
|
try {
|
|
9323
|
-
|
|
9324
|
-
if (!serverApprovedRetry && task.status !== 'max_rounds') {
|
|
8936
|
+
if (task.status !== 'max_rounds') {
|
|
9325
8937
|
throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
|
|
9326
8938
|
}
|
|
9327
8939
|
if (isSpecStagePhase(task.phase)) {
|
|
9328
8940
|
throw new ApiError(409, `Mark complete is only supported for code-phase tasks`);
|
|
9329
8941
|
}
|
|
9330
|
-
if (
|
|
8942
|
+
if (!task.prNumber || !task.branch) {
|
|
9331
8943
|
throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot mark complete`);
|
|
9332
8944
|
}
|
|
9333
|
-
if (serverApprovedRetry) {
|
|
9334
|
-
const publishState = this.checkPublishInFlight(taskId, task.publishDispatchedAt);
|
|
9335
|
-
if (publishState === 'live') {
|
|
9336
|
-
throw new ApiError(409, `Task ${taskId} publish is in flight; retry only after it fails`);
|
|
9337
|
-
}
|
|
9338
|
-
if (publishState === 'delivered') {
|
|
9339
|
-
throw new ApiError(409, `Task ${taskId} publish was delivered and is awaiting code-ready; ` +
|
|
9340
|
-
`retry only after it fails (if the publish is verifiably dead, Cancel the task)`);
|
|
9341
|
-
}
|
|
9342
|
-
const afterDone = this.resolveAfterDone(task);
|
|
9343
|
-
if (afterDone === null) {
|
|
9344
|
-
throw new ApiError(409, `Task ${taskId} is approved with no afterDone step; nothing to retry`);
|
|
9345
|
-
}
|
|
9346
|
-
await this.dispatchServerAfterDone(taskId, afterDone);
|
|
9347
|
-
return (await this.taskStore.get(taskId));
|
|
9348
|
-
}
|
|
9349
|
-
if (task.reviewMode === 'server') {
|
|
9350
|
-
const afterDone = await this.commitServerAfterDone(taskId);
|
|
9351
|
-
if (afterDone === null) {
|
|
9352
|
-
const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['max_rounds'] });
|
|
9353
|
-
if (!done)
|
|
9354
|
-
throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
|
|
9355
|
-
await this.releaseTaskAgents(taskId);
|
|
9356
|
-
return (await this.taskStore.get(taskId));
|
|
9357
|
-
}
|
|
9358
|
-
const approved = await this.transitionTaskStatus(taskId, 'approved', { fromStatus: ['max_rounds'] });
|
|
9359
|
-
if (!approved)
|
|
9360
|
-
throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
|
|
9361
|
-
await this.dispatchServerAfterDone(taskId, afterDone);
|
|
9362
|
-
return (await this.taskStore.get(taskId));
|
|
9363
|
-
}
|
|
9364
8945
|
for (const agentId of [task.agentId, task.qaAgentId]) {
|
|
9365
8946
|
if (!agentId)
|
|
9366
8947
|
continue;
|
|
@@ -9741,15 +9322,6 @@ export class AgentManager {
|
|
|
9741
9322
|
stopPhaseSignalWatcher(taskId) {
|
|
9742
9323
|
this.phaseSignalWatcher?.stop(taskId);
|
|
9743
9324
|
}
|
|
9744
|
-
checkPublishInFlight(taskId, publishDispatchedAt) {
|
|
9745
|
-
if (this.phaseSignalWatcher?.expectedKindsFor(taskId).has('code-ready')) {
|
|
9746
|
-
if (!this.phaseSignalWatcher.isRecovered(taskId) || publishDispatchedAt)
|
|
9747
|
-
return 'live';
|
|
9748
|
-
this.phaseSignalWatcher.stop(taskId);
|
|
9749
|
-
return false;
|
|
9750
|
-
}
|
|
9751
|
-
return publishDispatchedAt ? 'delivered' : false;
|
|
9752
|
-
}
|
|
9753
9325
|
async setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, opts = {}) {
|
|
9754
9326
|
if (!this.phaseSignalWatcher)
|
|
9755
9327
|
return true;
|
|
@@ -9787,7 +9359,6 @@ export class AgentManager {
|
|
|
9787
9359
|
...(opts.onlyReplaceOwnToken ? { onlyReplaceOwnToken: true } : {}),
|
|
9788
9360
|
...(opts.replaceFromToken ? { replaceFromToken: opts.replaceFromToken } : {}),
|
|
9789
9361
|
...(opts.replaceScope ? { replaceScope: opts.replaceScope } : {}),
|
|
9790
|
-
...(opts.onReadFile ? { onReadFile: opts.onReadFile } : {}),
|
|
9791
9362
|
...(opts.needInputMode ? { needInputMode: opts.needInputMode } : {}),
|
|
9792
9363
|
});
|
|
9793
9364
|
}
|
|
@@ -9796,14 +9367,12 @@ export class AgentManager {
|
|
|
9796
9367
|
return false;
|
|
9797
9368
|
}
|
|
9798
9369
|
}
|
|
9799
|
-
async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false
|
|
9370
|
+
async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false) {
|
|
9800
9371
|
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, {
|
|
9801
9372
|
skipSnapshot,
|
|
9802
9373
|
// Every post-dispatch arm follows a freshly injected prompt: the previous
|
|
9803
|
-
// question is superseded, its watermark must not carry over
|
|
9804
|
-
// continuations included).
|
|
9374
|
+
// question is superseded, its watermark must not carry over.
|
|
9805
9375
|
needInputMode: 'fresh',
|
|
9806
|
-
...(onReadFile ? { onReadFile } : {}),
|
|
9807
9376
|
});
|
|
9808
9377
|
if (!armed)
|
|
9809
9378
|
await this.holdAgentForUnarmedSignal(taskId, agentId, expectedKinds);
|
|
@@ -9812,14 +9381,12 @@ export class AgentManager {
|
|
|
9812
9381
|
const label = (Array.isArray(expectedKinds) ? [...expectedKinds] : [expectedKinds]).join(',');
|
|
9813
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 });
|
|
9814
9383
|
}
|
|
9815
|
-
dispatchTokenFields(
|
|
9816
|
-
|
|
9817
|
-
&& this.devInitialSignalKinds(task.reviewMode).includes('pr-created');
|
|
9818
|
-
return { signalToken, ...(wantsPrCreated ? { pendingPrSignalToken: signalToken } : {}) };
|
|
9384
|
+
dispatchTokenFields(signalToken) {
|
|
9385
|
+
return { signalToken, pendingPrSignalToken: signalToken };
|
|
9819
9386
|
}
|
|
9820
9387
|
async rearmGitReconciliationWatcher(taskId, opts = {}) {
|
|
9821
9388
|
const task = await this.taskStore.get(taskId);
|
|
9822
|
-
if (!task || task.
|
|
9389
|
+
if (!task || task.status !== 'review')
|
|
9823
9390
|
return;
|
|
9824
9391
|
if (task.replyActorStatus === 'verified' || task.pendingPrSignalToken === undefined)
|
|
9825
9392
|
return;
|
|
@@ -9835,21 +9402,20 @@ export class AgentManager {
|
|
|
9835
9402
|
const result = await this.withTaskLock(async () => {
|
|
9836
9403
|
const task = await this.taskStore.get(taskId);
|
|
9837
9404
|
if (!task)
|
|
9838
|
-
return { token: newToken, armed: false
|
|
9839
|
-
|
|
9840
|
-
|
|
9841
|
-
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');
|
|
9842
9408
|
}
|
|
9843
|
-
if (
|
|
9844
|
-
|| (guard.expectedToken !== undefined && task.signalToken !== guard.expectedToken))
|
|
9845
|
-
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 };
|
|
9846
9412
|
}
|
|
9847
9413
|
await this.taskStore.set({ ...task, signalToken: newToken, updatedAt: new Date().toISOString() });
|
|
9848
9414
|
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, newToken, {
|
|
9849
9415
|
needInputMode: 'fresh',
|
|
9850
|
-
|
|
9416
|
+
replaceScope: 'agent',
|
|
9851
9417
|
});
|
|
9852
|
-
return { token: newToken, armed
|
|
9418
|
+
return { token: newToken, armed };
|
|
9853
9419
|
});
|
|
9854
9420
|
return { token: result.token, armed: result.armed };
|
|
9855
9421
|
}
|
|
@@ -9857,27 +9423,53 @@ export class AgentManager {
|
|
|
9857
9423
|
const task = await this.taskStore.get(taskId);
|
|
9858
9424
|
if (!task)
|
|
9859
9425
|
return null;
|
|
9860
|
-
const transition = await this.transitionTaskStatus(taskId, 'spec-ready', {
|
|
9426
|
+
const transition = await this.transitionTaskStatus(taskId, 'spec-ready', {
|
|
9427
|
+
fromStatus: ['review', 'in_progress', 'fixing'],
|
|
9428
|
+
...(opts.expectedTask !== undefined ? { expectTask: opts.expectedTask } : {}),
|
|
9429
|
+
...(opts.expectedSignalToken !== undefined ? { expectSignalToken: opts.expectedSignalToken } : {}),
|
|
9430
|
+
}, {
|
|
9861
9431
|
phase: 'spec',
|
|
9862
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 } : {}),
|
|
9863
9437
|
});
|
|
9864
9438
|
if (!transition)
|
|
9865
9439
|
return null;
|
|
9440
|
+
const parked = transition.task;
|
|
9441
|
+
const qaAgentId = requireTaskQaAgentId(parked, 'parkTaskAtSpecReady');
|
|
9442
|
+
const parkedGeneration = {
|
|
9443
|
+
status: parked.status,
|
|
9444
|
+
phase: parked.phase,
|
|
9445
|
+
signalToken: parked.signalToken,
|
|
9446
|
+
agentId: parked.agentId,
|
|
9447
|
+
reviewRound: parked.reviewRound,
|
|
9448
|
+
specReviewRound: parked.specReviewRound,
|
|
9449
|
+
};
|
|
9866
9450
|
this.stopPhaseSignalWatcher(taskId);
|
|
9867
|
-
if (
|
|
9868
|
-
const devOk = await this.markAgentWaiting(
|
|
9451
|
+
if (parked.agentId) {
|
|
9452
|
+
const devOk = await this.markAgentWaiting(parked.agentId, taskId, {
|
|
9453
|
+
expectedTask: parkedGeneration,
|
|
9454
|
+
}).catch(() => false);
|
|
9869
9455
|
if (!devOk) {
|
|
9870
|
-
await this.emitIntervention(
|
|
9456
|
+
await this.emitIntervention(parked.projectId, parked.agentId, taskId, {
|
|
9457
|
+
phase: 'spec-ready-dev-park-failed',
|
|
9458
|
+
devAgentId: parked.agentId,
|
|
9459
|
+
});
|
|
9871
9460
|
}
|
|
9872
9461
|
}
|
|
9873
|
-
|
|
9874
|
-
|
|
9875
|
-
|
|
9876
|
-
|
|
9877
|
-
|
|
9878
|
-
|
|
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
|
+
});
|
|
9879
9471
|
}
|
|
9880
|
-
return
|
|
9472
|
+
return parked;
|
|
9881
9473
|
}
|
|
9882
9474
|
async submitSpecVerdict(taskId, verdict, comments) {
|
|
9883
9475
|
const trimmed = comments?.trim();
|
|
@@ -9888,12 +9480,17 @@ export class AgentManager {
|
|
|
9888
9480
|
const fresh = await this.taskStore.get(taskId);
|
|
9889
9481
|
if (!fresh)
|
|
9890
9482
|
throw new ApiError(404, `Task ${taskId} not found`);
|
|
9891
|
-
const
|
|
9892
|
-
if (
|
|
9893
|
-
|
|
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
|
+
}
|
|
9894
9488
|
}
|
|
9895
|
-
|
|
9896
|
-
|
|
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
|
+
}
|
|
9897
9494
|
}
|
|
9898
9495
|
if (this.specVerdictInFlight.has(taskId)) {
|
|
9899
9496
|
throw new ApiError(409, `Task ${taskId} spec verdict is already being processed`);
|
|
@@ -9902,326 +9499,284 @@ export class AgentManager {
|
|
|
9902
9499
|
return fresh;
|
|
9903
9500
|
});
|
|
9904
9501
|
try {
|
|
9905
|
-
return await this.
|
|
9502
|
+
return await this.executeGitSpecVerdict(task, verdict, trimmed);
|
|
9906
9503
|
}
|
|
9907
9504
|
finally {
|
|
9908
9505
|
this.specVerdictInFlight.delete(taskId);
|
|
9909
9506
|
}
|
|
9910
9507
|
}
|
|
9911
|
-
|
|
9912
|
-
const
|
|
9913
|
-
|
|
9914
|
-
|
|
9915
|
-
|
|
9916
|
-
|
|
9917
|
-
|
|
9918
|
-
|
|
9919
|
-
if (!roundData || roundData.phase !== 'spec') {
|
|
9920
|
-
throw new ApiError(409, `Task ${taskId} has no persisted spec review round ${round}`);
|
|
9921
|
-
}
|
|
9922
|
-
const base = roundData;
|
|
9923
|
-
if (verdict === 'archive') {
|
|
9924
|
-
await store.putRound(taskId, 'spec', {
|
|
9925
|
-
...base,
|
|
9926
|
-
userDecision: { verdict, ...(trimmed ? { comments: trimmed } : {}), at },
|
|
9927
|
-
});
|
|
9928
|
-
const transition = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['spec-ready', 'max_rounds'] });
|
|
9929
|
-
if (!transition)
|
|
9930
|
-
throw new ApiError(409, `Task ${taskId} changed while archiving`);
|
|
9931
|
-
this.stopPhaseSignalWatcher(taskId);
|
|
9932
|
-
const participants = new Set([
|
|
9933
|
-
task.agentId,
|
|
9934
|
-
task.devAgentId,
|
|
9935
|
-
task.qaAgentId,
|
|
9936
|
-
task.researchAgentId,
|
|
9937
|
-
].filter((id) => typeof id === 'string' && id !== ''));
|
|
9938
|
-
for (const agentId of participants) {
|
|
9939
|
-
const state = await this.agentStore.get(agentId);
|
|
9940
|
-
if (state?.taskId !== taskId)
|
|
9941
|
-
continue;
|
|
9942
|
-
const released = await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true }).catch(() => false);
|
|
9943
|
-
if (!released) {
|
|
9944
|
-
await this.emitIntervention(task.projectId, agentId, taskId, {
|
|
9945
|
-
phase: 'spec-archive-agent-release-failed',
|
|
9946
|
-
agentId,
|
|
9947
|
-
});
|
|
9948
|
-
}
|
|
9949
|
-
}
|
|
9950
|
-
return (await this.taskStore.get(taskId));
|
|
9951
|
-
}
|
|
9952
|
-
if (verdict === 'approve') {
|
|
9953
|
-
await store.putRound(taskId, 'spec', { ...base, userDecision: { verdict, at } });
|
|
9954
|
-
let result;
|
|
9955
|
-
try {
|
|
9956
|
-
result = await this.transitionToCodePhase(taskId);
|
|
9957
|
-
}
|
|
9958
|
-
catch (err) {
|
|
9959
|
-
if (err instanceof ApiError)
|
|
9960
|
-
throw err;
|
|
9961
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
9962
|
-
const after = await this.taskStore.get(taskId);
|
|
9963
|
-
if (after?.status === 'failed') {
|
|
9964
|
-
const reason = err instanceof DispatchTerminalError ? ` (${err.reason})` : '';
|
|
9965
|
-
throw new ApiError(500, `Code-phase dispatch failed terminally for task ${taskId}${reason}: ${message}. `
|
|
9966
|
-
+ 'The task has been marked failed; use Retry to run it as a fresh task.');
|
|
9967
|
-
}
|
|
9968
|
-
if (after?.phase === 'code' && after.status === 'in_progress') {
|
|
9969
|
-
throw new ApiError(500, `Code-phase dispatch failed for task ${taskId}: ${message}. `
|
|
9970
|
-
+ 'The task already moved to the code phase and the dev is held; '
|
|
9971
|
-
+ 'Resume the dev agent to redeliver the prompt.');
|
|
9972
|
-
}
|
|
9973
|
-
throw new ApiError(500, `Code-phase dispatch failed for task ${taskId}: ${message}. `
|
|
9974
|
-
+ 'The task was rolled back to await the verdict; fix the dev Workdir '
|
|
9975
|
-
+ '(Resume the dev first if it is still held), then retry the verdict.');
|
|
9976
|
-
}
|
|
9977
|
-
if (!result) {
|
|
9978
|
-
const after = await this.taskStore.get(taskId);
|
|
9979
|
-
if (after?.phase === 'code' && after.status === 'in_progress') {
|
|
9980
|
-
throw new ApiError(500, `Code-phase prompt was not delivered for task ${taskId}; the task already moved to the `
|
|
9981
|
-
+ 'code phase and the dev is held. Resume the dev agent to redeliver the prompt '
|
|
9982
|
-
+ '(retrying the verdict will be rejected).');
|
|
9983
|
-
}
|
|
9984
|
-
throw new ApiError(409, `Dev is unavailable for task ${taskId}; approval was recorded and can be retried`);
|
|
9985
|
-
}
|
|
9986
|
-
return result;
|
|
9987
|
-
}
|
|
9988
|
-
if (!trimmed)
|
|
9989
|
-
throw new ApiError(400, 'comments is required for request-changes');
|
|
9990
|
-
const priorFindings = base.findings?.findings ?? [];
|
|
9991
|
-
const nextUserId = `u-${priorFindings.filter(f => f.id.startsWith('u-')).length + 1}`;
|
|
9992
|
-
const mergedFindings = {
|
|
9993
|
-
round,
|
|
9994
|
-
verdict: 'request-changes',
|
|
9995
|
-
findings: [...priorFindings, { id: nextUserId, severity: 'major', message: trimmed }],
|
|
9996
|
-
};
|
|
9997
|
-
const rejectedRound = {
|
|
9998
|
-
...base,
|
|
9999
|
-
findings: mergedFindings,
|
|
10000
|
-
userDecision: { verdict, comments: trimmed, at },
|
|
10001
|
-
};
|
|
10002
|
-
// 到达上限后用户仍显式打回 = 对「再评一轮」的当场决策:扩 cap 而非拒绝。
|
|
10003
|
-
// holder 恢复与扩 cap 正交:max_rounds 入态时 holder 已被释放清空,即便 cap
|
|
10004
|
-
// 因配置热更新出现余量,打回前也必须先恢复 holder
|
|
10005
|
-
const cap = this.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
|
|
10006
|
-
const claim = task.status === 'max_rounds' || round >= cap
|
|
10007
|
-
? await this.claimSpecFixDispatch(taskId, { extendCapToFit: round >= cap ? round : null })
|
|
10008
|
-
: null;
|
|
10009
|
-
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');
|
|
10010
9516
|
try {
|
|
10011
|
-
|
|
10012
|
-
dispatched = await this.dispatchServerFixToDev(taskId, JSON.stringify(mergedFindings));
|
|
9517
|
+
validateCommentBody(body);
|
|
10013
9518
|
}
|
|
10014
9519
|
catch (err) {
|
|
10015
|
-
|
|
10016
|
-
throw err;
|
|
10017
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
10018
|
-
const after = await this.taskStore.get(taskId);
|
|
10019
|
-
if (after?.status === 'failed') {
|
|
10020
|
-
const reason = err instanceof DispatchTerminalError ? ` (${err.reason})` : '';
|
|
10021
|
-
throw new ApiError(500, `Spec fix dispatch failed terminally for task ${taskId}${reason}: ${message}. `
|
|
10022
|
-
+ 'The task has been marked failed with the rejection kept on record; '
|
|
10023
|
-
+ 'use Retry to run it as a fresh task.');
|
|
10024
|
-
}
|
|
10025
|
-
if (after?.status === 'fixing') {
|
|
10026
|
-
throw new ApiError(500, `Spec fix dispatch was interrupted for task ${taskId}: ${message}. `
|
|
10027
|
-
+ 'The task moved to fixing with the rejection recorded; resume the holder agent to redeliver it.');
|
|
10028
|
-
}
|
|
10029
|
-
throw new ApiError(500, `Spec fix dispatch failed for task ${taskId}: ${message}. `
|
|
10030
|
-
+ 'The rejection was rolled back; fix the agent runtime or configuration, then retry.');
|
|
10031
|
-
}
|
|
10032
|
-
finally {
|
|
10033
|
-
if (!dispatched)
|
|
10034
|
-
await this.recoverSpecRejectionState(taskId, claim, base);
|
|
9520
|
+
throw new ApiError(400, `Invalid spec verdict comment: ${err instanceof Error ? err.message : String(err)}`);
|
|
10035
9521
|
}
|
|
10036
|
-
|
|
10037
|
-
throw new ApiError(500, `Failed to dispatch spec fix for task ${taskId}`);
|
|
10038
|
-
return dispatched;
|
|
9522
|
+
return body;
|
|
10039
9523
|
}
|
|
10040
|
-
async
|
|
10041
|
-
|
|
10042
|
-
|
|
10043
|
-
|
|
10044
|
-
|
|
10045
|
-
|
|
10046
|
-
throw new ApiError(409, `Task ${taskId} changed status during the spec verdict; aborted`);
|
|
10047
|
-
}
|
|
10048
|
-
const holder = fresh.agentId || fresh.researchAgentId || fresh.devAgentId;
|
|
10049
|
-
if (!holder || !this.getAgentConfig(holder)) {
|
|
10050
|
-
throw new ApiError(409, `Task ${taskId} spec holder agent ${holder || '(none)'} is not in the current config; `
|
|
10051
|
-
+ 'restore the agent config or cancel the task');
|
|
10052
|
-
}
|
|
10053
|
-
const originalAgentId = fresh.agentId;
|
|
10054
|
-
const originalCounter = fresh.maxRoundsContinues ?? 0;
|
|
10055
|
-
let counterAfter = originalCounter;
|
|
10056
|
-
if (opts.extendCapToFit !== null) {
|
|
10057
|
-
// cap 必须容下下一轮(round+1);rounds 被调低时一次 +1 可能不够
|
|
10058
|
-
counterAfter = Math.max(originalCounter + 1, opts.extendCapToFit + 1 - this.getConfig().review.rounds);
|
|
10059
|
-
fresh.maxRoundsContinues = counterAfter;
|
|
10060
|
-
}
|
|
10061
|
-
fresh.agentId = holder;
|
|
10062
|
-
fresh.updatedAt = new Date().toISOString();
|
|
10063
|
-
await this.taskStore.set(fresh);
|
|
10064
|
-
return { originalAgentId, originalCounter, holderWritten: holder, counterAfter };
|
|
10065
|
-
});
|
|
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';
|
|
10066
9530
|
}
|
|
10067
|
-
|
|
10068
|
-
|
|
10069
|
-
|
|
10070
|
-
const
|
|
10071
|
-
|
|
10072
|
-
|
|
10073
|
-
|
|
10074
|
-
|
|
10075
|
-
|
|
10076
|
-
|
|
10077
|
-
|
|
10078
|
-
|
|
10079
|
-
|
|
10080
|
-
|
|
10081
|
-
|
|
10082
|
-
|
|
10083
|
-
|
|
10084
|
-
|
|
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)
|
|
10085
9566
|
return;
|
|
10086
|
-
|
|
10087
|
-
fresh.maxRoundsContinues = claim.originalCounter;
|
|
10088
|
-
fresh.agentId = claim.originalAgentId;
|
|
10089
|
-
fresh.updatedAt = new Date().toISOString();
|
|
10090
|
-
await this.taskStore.set(fresh);
|
|
10091
|
-
}
|
|
10092
|
-
await store?.putRound(taskId, 'spec', baseRound);
|
|
10093
|
-
}).catch(err => {
|
|
10094
|
-
console.error(`[AgentManager] spec rejection recovery(${taskId}) failed:`, err);
|
|
10095
|
-
});
|
|
10096
|
-
}
|
|
10097
|
-
async submitCodeVerdict(taskId, comments) {
|
|
10098
|
-
const trimmed = comments?.trim();
|
|
10099
|
-
if (!trimmed) {
|
|
10100
|
-
throw new ApiError(400, 'comments is required for request-changes');
|
|
10101
|
-
}
|
|
10102
|
-
const task = await this.withTaskLock(async () => {
|
|
10103
|
-
const fresh = await this.taskStore.get(taskId);
|
|
10104
|
-
if (!fresh)
|
|
10105
|
-
throw new ApiError(404, `Task ${taskId} not found`);
|
|
10106
|
-
if (fresh.status !== 'ready') {
|
|
10107
|
-
throw new ApiError(409, `Task ${taskId} is ${fresh.status}; code verdict requires ready`);
|
|
9567
|
+
task = settled;
|
|
10108
9568
|
}
|
|
10109
|
-
if (
|
|
10110
|
-
|
|
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}`);
|
|
10111
9572
|
}
|
|
10112
|
-
|
|
10113
|
-
|
|
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}`);
|
|
10114
9582
|
}
|
|
10115
|
-
|
|
10116
|
-
|
|
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
|
+
});
|
|
10117
9591
|
}
|
|
10118
|
-
|
|
10119
|
-
|
|
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
|
+
}
|
|
10120
9609
|
}
|
|
10121
|
-
if (
|
|
10122
|
-
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`);
|
|
10123
9612
|
}
|
|
10124
|
-
this.
|
|
10125
|
-
|
|
10126
|
-
|
|
10127
|
-
|
|
10128
|
-
return await this.executeCodeVerdict(task, trimmed);
|
|
9613
|
+
const settled = await this.settleSpecVerdictOutbox(task.id, entry.key);
|
|
9614
|
+
if (!settled)
|
|
9615
|
+
return;
|
|
9616
|
+
task = settled;
|
|
10129
9617
|
}
|
|
10130
|
-
|
|
10131
|
-
|
|
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`);
|
|
10132
9625
|
}
|
|
10133
9626
|
}
|
|
10134
|
-
async
|
|
10135
|
-
|
|
10136
|
-
|
|
10137
|
-
|
|
10138
|
-
|
|
10139
|
-
|
|
10140
|
-
|
|
10141
|
-
|
|
10142
|
-
|
|
10143
|
-
|
|
10144
|
-
|
|
10145
|
-
|
|
10146
|
-
|
|
10147
|
-
|
|
10148
|
-
|
|
10149
|
-
|
|
10150
|
-
|
|
10151
|
-
|
|
10152
|
-
|
|
10153
|
-
|
|
10154
|
-
|
|
10155
|
-
|
|
10156
|
-
|
|
10157
|
-
|
|
10158
|
-
|
|
10159
|
-
|
|
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,
|
|
10160
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`);
|
|
10161
9724
|
}
|
|
10162
|
-
|
|
10163
|
-
|
|
10164
|
-
|
|
10165
|
-
|
|
10166
|
-
|
|
10167
|
-
|
|
10168
|
-
|
|
10169
|
-
|
|
10170
|
-
|
|
10171
|
-
|
|
10172
|
-
|
|
10173
|
-
|
|
10174
|
-
|
|
10175
|
-
|
|
10176
|
-
throw new ApiError(500, `Failed to dispatch code fix for task ${taskId}`);
|
|
10177
|
-
return dispatched;
|
|
10178
|
-
}
|
|
10179
|
-
async synthesizeEmptyCodeRound(task, round, at) {
|
|
10180
|
-
const empty = { round, phase: 'code', content: '', startedAt: at };
|
|
10181
|
-
const dev = task.agentId ? this.getAgentConfig(task.agentId) : undefined;
|
|
10182
|
-
if (!dev || !task.agentId)
|
|
10183
|
-
return empty;
|
|
10184
|
-
// 读取失败不阻断打回:留痕与 fix 闭环优先于 diff 展示
|
|
10185
|
-
try {
|
|
10186
|
-
await this.refreshWorkdirCacheFor(task.agentId);
|
|
10187
|
-
const content = await this.getReviewTransport().readContent(task, dev, 'code');
|
|
10188
|
-
return {
|
|
10189
|
-
round,
|
|
10190
|
-
phase: 'code',
|
|
10191
|
-
content: content.content,
|
|
10192
|
-
...(content.diffstat ? { diffstat: content.diffstat } : {}),
|
|
10193
|
-
...(content.baseSha ? { baseSha: content.baseSha } : {}),
|
|
10194
|
-
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 },
|
|
10195
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;
|
|
10196
9758
|
}
|
|
10197
|
-
|
|
10198
|
-
|
|
10199
|
-
return empty;
|
|
10200
|
-
}
|
|
9759
|
+
await this.deliverSpecVerdictOutbox(task.id, entry.key);
|
|
9760
|
+
return (await this.taskStore.get(task.id)) ?? task;
|
|
10201
9761
|
}
|
|
10202
|
-
|
|
10203
|
-
return task.researchAgentId !== undefined && task.researchAgentId !== devAgentId;
|
|
10204
|
-
}
|
|
10205
|
-
dispatchCodePhasePrompt(task, devAgentId, signalToken, specDocuments, currentSpecRound) {
|
|
10206
|
-
const expectedKind = task.reviewMode === 'server' ? 'code-done' : 'pr-created';
|
|
9762
|
+
dispatchCodePhasePrompt(task, devAgentId, signalToken) {
|
|
10207
9763
|
const dispatchOpts = {
|
|
10208
9764
|
signalToken,
|
|
10209
|
-
|
|
10210
|
-
...(currentSpecRound !== undefined ? { currentSpecRound } : {}),
|
|
10211
|
-
armBeforeInject: () => this.setupPhaseSignalWatcher(task.id, devAgentId, expectedKind, signalToken, { needInputMode: 'fresh' }),
|
|
9765
|
+
armBeforeInject: () => this.setupPhaseSignalWatcher(task.id, devAgentId, 'pr-created', signalToken, { needInputMode: 'fresh' }),
|
|
10212
9766
|
};
|
|
10213
|
-
return this.
|
|
10214
|
-
? this.startSession(task.id, devAgentId, 'code', {
|
|
10215
|
-
...dispatchOpts,
|
|
10216
|
-
preserveBindingOnFailure: true,
|
|
10217
|
-
})
|
|
10218
|
-
: this.continueSession(task.id, devAgentId, 'code', dispatchOpts);
|
|
9767
|
+
return this.continueSession(task.id, devAgentId, 'code', dispatchOpts);
|
|
10219
9768
|
}
|
|
10220
|
-
async transitionToCodePhase(taskId) {
|
|
10221
|
-
const task = await this.
|
|
9769
|
+
async transitionToCodePhase(taskId, expectedTask, approval) {
|
|
9770
|
+
const task = await this.withTaskLock(async () => {
|
|
9771
|
+
const current = await this.taskStore.get(taskId);
|
|
9772
|
+
if (expectedTask && current && !taskMatchesGeneration(current, expectedTask))
|
|
9773
|
+
return null;
|
|
9774
|
+
return current;
|
|
9775
|
+
});
|
|
10222
9776
|
if (!task)
|
|
10223
9777
|
return null;
|
|
10224
9778
|
const devAgentId = task.devAgentId;
|
|
9779
|
+
const qaAgentId = requireTaskQaAgentId(task, 'transitionToCodePhase');
|
|
10225
9780
|
const dev = devAgentId ? this.getAgentConfig(devAgentId) : undefined;
|
|
10226
9781
|
if (!devAgentId || dev?.role !== 'dev') {
|
|
10227
9782
|
await this.emitIntervention(task.projectId, task.agentId, taskId, {
|
|
@@ -10230,34 +9785,25 @@ export class AgentManager {
|
|
|
10230
9785
|
});
|
|
10231
9786
|
return null;
|
|
10232
9787
|
}
|
|
10233
|
-
const round = task.specReviewRound ?? 1;
|
|
10234
|
-
const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
|
|
10235
|
-
if (!stored || stored.phase !== 'spec') {
|
|
10236
|
-
await this.emitIntervention(task.projectId, task.agentId, taskId, {
|
|
10237
|
-
phase: 'code-spec-round-missing',
|
|
10238
|
-
round,
|
|
10239
|
-
});
|
|
10240
|
-
return null;
|
|
10241
|
-
}
|
|
10242
|
-
const researchAgentId = task.researchAgentId;
|
|
10243
|
-
const researchHandoff = this.isResearchHandoff(task, devAgentId);
|
|
10244
9788
|
const releaseParticipant = async (agentId, failurePhase) => {
|
|
10245
|
-
const released = await this.releaseAgentIfBound(agentId, taskId, {
|
|
9789
|
+
const released = await this.releaseAgentIfBound(agentId, taskId, {
|
|
9790
|
+
allowAwaitingHuman: true,
|
|
9791
|
+
...(expectedTask !== undefined ? { expectedTask } : {}),
|
|
9792
|
+
})
|
|
10246
9793
|
.catch(() => false);
|
|
10247
9794
|
if (!released) {
|
|
10248
9795
|
await this.emitIntervention(task.projectId, agentId, taskId, { phase: failurePhase, agentId });
|
|
10249
9796
|
}
|
|
10250
9797
|
return released;
|
|
10251
9798
|
};
|
|
10252
|
-
if (
|
|
10253
|
-
if (!await releaseParticipant(researchAgentId, 'code-phase-research-release-failed'))
|
|
10254
|
-
return null;
|
|
10255
|
-
}
|
|
10256
|
-
if (task.qaAgentId && !await releaseParticipant(task.qaAgentId, 'code-phase-qa-release-failed')) {
|
|
9799
|
+
if (!await releaseParticipant(qaAgentId, 'code-phase-qa-release-failed')) {
|
|
10257
9800
|
return null;
|
|
10258
9801
|
}
|
|
10259
9802
|
const devBoundBefore = (await this.agentStore.get(devAgentId))?.taskId === taskId;
|
|
10260
|
-
|
|
9803
|
+
let acquiredLockToken;
|
|
9804
|
+
const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'code', {
|
|
9805
|
+
onAcquired: token => { acquiredLockToken = token; },
|
|
9806
|
+
});
|
|
10261
9807
|
if (!acquired) {
|
|
10262
9808
|
await this.emitIntervention(task.projectId, devAgentId, taskId, {
|
|
10263
9809
|
phase: 'code-dev-acquire-failed',
|
|
@@ -10265,19 +9811,56 @@ export class AgentManager {
|
|
|
10265
9811
|
});
|
|
10266
9812
|
return null;
|
|
10267
9813
|
}
|
|
10268
|
-
const acquiredLockToken = (await this.agentStore.get(devAgentId))?.lockToken;
|
|
10269
9814
|
const newToken = createSignalToken();
|
|
10270
9815
|
// spec 阶段的扩轮决策不延续到 code 评审:cap 共享字段,进入编码时归零
|
|
10271
|
-
const transition = await this.transitionTaskStatus(taskId, 'in_progress', {
|
|
9816
|
+
const transition = await this.transitionTaskStatus(taskId, 'in_progress', {
|
|
9817
|
+
fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready', 'max_rounds'],
|
|
9818
|
+
...(expectedTask !== undefined ? { expectTask: expectedTask } : {}),
|
|
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
|
+
});
|
|
10272
9845
|
if (!transition) {
|
|
10273
|
-
|
|
10274
|
-
|
|
9846
|
+
if (!devBoundBefore) {
|
|
9847
|
+
try {
|
|
9848
|
+
await this.releaseAgentForTask(devAgentId, taskId, 'idle', {
|
|
9849
|
+
allowAwaitingHuman: true,
|
|
9850
|
+
...(expectedTask !== undefined ? { expectedTask } : {}),
|
|
9851
|
+
...(acquiredLockToken !== undefined ? { expectedLockToken: acquiredLockToken } : {}),
|
|
9852
|
+
});
|
|
9853
|
+
}
|
|
9854
|
+
catch (err) {
|
|
9855
|
+
console.warn(`[AgentManager] transitionToCodePhase(${taskId}): stale-generation acquire rollback failed:`, err);
|
|
9856
|
+
}
|
|
9857
|
+
}
|
|
10275
9858
|
return null;
|
|
10276
9859
|
}
|
|
10277
9860
|
this.stopPhaseSignalWatcher(taskId);
|
|
10278
9861
|
let started = false;
|
|
10279
9862
|
try {
|
|
10280
|
-
started = await this.dispatchCodePhasePrompt(task, devAgentId, newToken
|
|
9863
|
+
started = await this.dispatchCodePhasePrompt(task, devAgentId, newToken);
|
|
10281
9864
|
}
|
|
10282
9865
|
catch (err) {
|
|
10283
9866
|
if (err instanceof DispatchTerminalError) {
|
|
@@ -10298,7 +9881,7 @@ export class AgentManager {
|
|
|
10298
9881
|
+ 'moved on; leaving its status as-is');
|
|
10299
9882
|
}
|
|
10300
9883
|
else if (!devBoundBefore) {
|
|
10301
|
-
// 转码前 dev 未持有本任务(如
|
|
9884
|
+
// 转码前 dev 未持有本任务(如 max_rounds 重占用):任务已退回
|
|
10302
9885
|
// verdict 门禁,释放本次新占用,否则 dev 会被回退后的任务永久占住
|
|
10303
9886
|
const released = await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
|
|
10304
9887
|
.catch(() => false);
|
|
@@ -10319,635 +9902,16 @@ export class AgentManager {
|
|
|
10319
9902
|
if (!started) {
|
|
10320
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);
|
|
10321
9904
|
await this.emitIntervention(task.projectId, devAgentId, taskId, {
|
|
10322
|
-
phase:
|
|
9905
|
+
phase: 'code-resume-failed',
|
|
10323
9906
|
devAgentId,
|
|
10324
9907
|
});
|
|
10325
9908
|
return null;
|
|
10326
9909
|
}
|
|
10327
9910
|
// 重占用(released dev)的 acquire 会写 bootstrappingTaskId,而 continueSession 不做
|
|
10328
9911
|
// startSession 的 post-ack finalize;残留标记会让 recover 把已运行的任务按未送达回滚
|
|
10329
|
-
|
|
10330
|
-
await this.finalizeReplayedBootstrap(devAgentId, taskId, 'code', acquiredLockToken);
|
|
10331
|
-
}
|
|
10332
|
-
return await this.taskStore.get(taskId);
|
|
10333
|
-
}
|
|
10334
|
-
async dispatchServerReviewToQa(taskId, opts) {
|
|
10335
|
-
const dispatchPhase = opts.phase === 'spec'
|
|
10336
|
-
? 'server-spec-review'
|
|
10337
|
-
: (opts.recheck ? 'server-recheck' : 'server-review');
|
|
10338
|
-
const expectedKind = opts.phase === 'spec' ? 'spec-reviewed' : 'code-reviewed';
|
|
10339
|
-
const claim = await this.withTaskLock(async () => {
|
|
10340
|
-
const task = await this.taskStore.get(taskId);
|
|
10341
|
-
if (!task)
|
|
10342
|
-
throw new Error(`dispatchServerReviewToQa: task ${taskId} not found`);
|
|
10343
|
-
if (task.reviewMode !== 'server' && opts.phase !== 'spec') {
|
|
10344
|
-
throw new Error(`dispatchServerReviewToQa: task ${taskId} is not in server review mode`);
|
|
10345
|
-
}
|
|
10346
|
-
const qaId = task.qaAgentId;
|
|
10347
|
-
if (!qaId) {
|
|
10348
|
-
if (!opts.callerOwnsConsumedSignalFailure) {
|
|
10349
|
-
const entryKind = task.status === 'fixing'
|
|
10350
|
-
? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
|
|
10351
|
-
: (opts.phase === 'spec' ? 'spec-done' : 'code-done');
|
|
10352
|
-
await this.setupPhaseSignal(taskId, task.agentId, entryKind, { skipSnapshot: true });
|
|
10353
|
-
await this.emitIntervention(task.projectId, task.agentId, taskId, {
|
|
10354
|
-
phase: 'server-review-no-qa-partner',
|
|
10355
|
-
devAgentId: task.agentId,
|
|
10356
|
-
});
|
|
10357
|
-
}
|
|
10358
|
-
return null;
|
|
10359
|
-
}
|
|
10360
|
-
if (this.getAgentConfig(qaId)?.role !== 'qa') {
|
|
10361
|
-
if (!opts.callerOwnsConsumedSignalFailure) {
|
|
10362
|
-
const entryKind = task.status === 'fixing'
|
|
10363
|
-
? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
|
|
10364
|
-
: (opts.phase === 'spec' ? 'spec-done' : 'code-done');
|
|
10365
|
-
await this.setupPhaseSignal(taskId, task.agentId, entryKind, { skipSnapshot: true });
|
|
10366
|
-
await this.emitIntervention(task.projectId, task.agentId, taskId, {
|
|
10367
|
-
phase: 'server-review-qa-unavailable',
|
|
10368
|
-
qaAgentId: qaId,
|
|
10369
|
-
});
|
|
10370
|
-
}
|
|
10371
|
-
return null;
|
|
10372
|
-
}
|
|
10373
|
-
const roundField = opts.phase === 'spec' ? (task.specReviewRound ?? 0) : task.reviewRound;
|
|
10374
|
-
const newRound = opts.continuation
|
|
10375
|
-
? Math.max(roundField, 1)
|
|
10376
|
-
: opts.bumpRound === false
|
|
10377
|
-
? roundField
|
|
10378
|
-
: roundField + 1;
|
|
10379
|
-
if (newRound < 1) {
|
|
10380
|
-
throw new Error(`dispatchServerReviewToQa: task ${taskId} has no current ${opts.phase} review round to redispatch`);
|
|
10381
|
-
}
|
|
10382
|
-
return {
|
|
10383
|
-
qaId,
|
|
10384
|
-
devAgentId: task.agentId,
|
|
10385
|
-
projectId: task.projectId,
|
|
10386
|
-
newToken: createSignalToken(),
|
|
10387
|
-
newRound,
|
|
10388
|
-
originalStatus: task.status,
|
|
10389
|
-
originalToken: task.signalToken,
|
|
10390
|
-
originalRound: roundField,
|
|
10391
|
-
originalBatchIndex: task.batchIndex,
|
|
10392
|
-
originalBatchTotal: task.batchTotal,
|
|
10393
|
-
originalPhase: task.phase,
|
|
10394
|
-
};
|
|
10395
|
-
});
|
|
10396
|
-
if (!claim)
|
|
10397
|
-
return null;
|
|
10398
|
-
const { qaId, devAgentId, projectId, newToken, newRound } = claim;
|
|
10399
|
-
const rollback = async (installedPass) => {
|
|
10400
|
-
try {
|
|
10401
|
-
return (await this.transitionTaskStatus(taskId, claim.originalStatus, {
|
|
10402
|
-
fromStatus: [installedPass.status],
|
|
10403
|
-
expectPhase: installedPass.phase,
|
|
10404
|
-
expectSignalToken: installedPass.signalToken,
|
|
10405
|
-
}, {
|
|
10406
|
-
signalToken: claim.originalToken,
|
|
10407
|
-
batchIndex: claim.originalBatchIndex,
|
|
10408
|
-
batchTotal: claim.originalBatchTotal,
|
|
10409
|
-
phase: claim.originalPhase,
|
|
10410
|
-
...(opts.phase === 'spec'
|
|
10411
|
-
? { specReviewRound: claim.originalRound }
|
|
10412
|
-
: { reviewRound: claim.originalRound }),
|
|
10413
|
-
})) !== null;
|
|
10414
|
-
}
|
|
10415
|
-
catch (err) {
|
|
10416
|
-
console.error(`[AgentManager] dispatchServerReviewToQa rollback failed for ${taskId}:`, err);
|
|
10417
|
-
return false;
|
|
10418
|
-
}
|
|
10419
|
-
};
|
|
10420
|
-
const rearmEntrySignal = async () => {
|
|
10421
|
-
if (opts.callerOwnsConsumedSignalFailure)
|
|
10422
|
-
return;
|
|
10423
|
-
// 从 review 重派(手工发起):入口信号早已被消费,dev 停驻等待中,无可重挂
|
|
10424
|
-
if (claim.originalStatus === 'review')
|
|
10425
|
-
return;
|
|
10426
|
-
const entryKind = claim.originalStatus === 'fixing'
|
|
10427
|
-
? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
|
|
10428
|
-
: (opts.phase === 'spec' ? 'spec-done' : 'code-done');
|
|
10429
|
-
await this.setupPhaseSignal(taskId, devAgentId, entryKind, { skipSnapshot: true });
|
|
10430
|
-
};
|
|
10431
|
-
let acquiredLockToken;
|
|
10432
|
-
const ownAcquire = () => acquiredLockToken === undefined
|
|
10433
|
-
? {}
|
|
10434
|
-
: { expectedLockToken: acquiredLockToken };
|
|
10435
|
-
const releaseOwnAcquire = async (context) => {
|
|
10436
|
-
try {
|
|
10437
|
-
const released = await this.releaseAgentForTask(qaId, taskId, 'idle', ownAcquire());
|
|
10438
|
-
if (!released) {
|
|
10439
|
-
console.warn(`[AgentManager] dispatchServerReviewToQa: QA release refused for ${qaId}/${taskId} (${context})`);
|
|
10440
|
-
}
|
|
10441
|
-
return released;
|
|
10442
|
-
}
|
|
10443
|
-
catch (err) {
|
|
10444
|
-
console.error(`[AgentManager] dispatchServerReviewToQa: QA release failed for ${qaId}/${taskId} (${context}):`, err);
|
|
10445
|
-
return false;
|
|
10446
|
-
}
|
|
10447
|
-
};
|
|
10448
|
-
if (!opts.continuation) {
|
|
10449
|
-
const prevQa = await this.agentStore.get(qaId);
|
|
10450
|
-
if (prevQa?.taskId === taskId) {
|
|
10451
|
-
// 手工重派:上一 pass 的 QA 仍绑定本任务,先释放再重新 acquire(与 github 手工路径一致)
|
|
10452
|
-
const released = await this.releaseAgentForTask(qaId, taskId, 'idle', {
|
|
10453
|
-
...(prevQa.lockToken !== undefined ? { expectedLockToken: prevQa.lockToken } : {}),
|
|
10454
|
-
});
|
|
10455
|
-
if (!released) {
|
|
10456
|
-
await rearmEntrySignal();
|
|
10457
|
-
return null;
|
|
10458
|
-
}
|
|
10459
|
-
}
|
|
10460
|
-
const acquired = await this.acquireAgentForTask(qaId, taskId, dispatchPhase, {
|
|
10461
|
-
onAcquired: (lockToken) => { acquiredLockToken = lockToken; },
|
|
10462
|
-
});
|
|
10463
|
-
if (!acquired) {
|
|
10464
|
-
await rearmEntrySignal();
|
|
10465
|
-
if (!opts.callerOwnsConsumedSignalFailure) {
|
|
10466
|
-
await this.emitIntervention(projectId, qaId, taskId, {
|
|
10467
|
-
phase: 'server-review-qa-acquire-failed',
|
|
10468
|
-
qaAgentId: qaId,
|
|
10469
|
-
});
|
|
10470
|
-
}
|
|
10471
|
-
return null;
|
|
10472
|
-
}
|
|
10473
|
-
if (devAgentId) {
|
|
10474
|
-
const devOk = await this.markAgentWaiting(devAgentId, taskId);
|
|
10475
|
-
if (!devOk) {
|
|
10476
|
-
await releaseOwnAcquire('dev park failed');
|
|
10477
|
-
await rearmEntrySignal();
|
|
10478
|
-
if (!opts.callerOwnsConsumedSignalFailure) {
|
|
10479
|
-
await this.emitIntervention(projectId, devAgentId, taskId, {
|
|
10480
|
-
phase: 'server-review-dev-park-failed',
|
|
10481
|
-
devAgentId,
|
|
10482
|
-
});
|
|
10483
|
-
}
|
|
10484
|
-
return null;
|
|
10485
|
-
}
|
|
10486
|
-
}
|
|
10487
|
-
}
|
|
10488
|
-
else {
|
|
10489
|
-
const qaState = await this.agentStore.get(qaId);
|
|
10490
|
-
if (qaState?.taskId !== taskId) {
|
|
10491
|
-
if (!opts.callerOwnsConsumedSignalFailure) {
|
|
10492
|
-
await this.setupPhaseSignal(taskId, qaId, expectedKind, { skipSnapshot: true });
|
|
10493
|
-
await this.emitIntervention(projectId, qaId, taskId, {
|
|
10494
|
-
phase: 'server-review-continuation-qa-not-bound',
|
|
10495
|
-
qaAgentId: qaId,
|
|
10496
|
-
});
|
|
10497
|
-
}
|
|
10498
|
-
return null;
|
|
10499
|
-
}
|
|
10500
|
-
}
|
|
10501
|
-
const roundPatch = opts.phase === 'spec'
|
|
10502
|
-
? { specReviewRound: newRound, phase: 'spec' }
|
|
10503
|
-
: { reviewRound: newRound };
|
|
10504
|
-
const transition = await this.transitionTaskStatus(taskId, 'review', {
|
|
10505
|
-
fromStatus: [claim.originalStatus],
|
|
10506
|
-
expectPhase: claim.originalPhase,
|
|
10507
|
-
expectSignalToken: claim.originalToken,
|
|
10508
|
-
}, {
|
|
10509
|
-
signalToken: newToken,
|
|
10510
|
-
reviewDispatchedAt: new Date().toISOString(),
|
|
10511
|
-
...(opts.reviewHeadAnchorSha ? { reviewHeadAnchorSha: opts.reviewHeadAnchorSha } : {}),
|
|
10512
|
-
...(opts.batch
|
|
10513
|
-
? { batchIndex: opts.batch.index, batchTotal: opts.batch.total }
|
|
10514
|
-
: { batchIndex: undefined, batchTotal: undefined }),
|
|
10515
|
-
// Fresh dispatch: clear any stale mode until startSession materializes the review checkout,
|
|
10516
|
-
// so a crash in this window recovers conservatively (no read-file) rather than
|
|
10517
|
-
// re-arming it for what might be a head-mode review.
|
|
10518
|
-
...(opts.continuation ? {} : { reviewCheckoutMode: undefined }),
|
|
10519
|
-
...roundPatch,
|
|
10520
|
-
});
|
|
10521
|
-
if (!transition) {
|
|
10522
|
-
if (!opts.continuation) {
|
|
10523
|
-
await releaseOwnAcquire('pass transition rejected');
|
|
10524
|
-
}
|
|
10525
|
-
if (!opts.callerOwnsConsumedSignalFailure) {
|
|
10526
|
-
await this.emitIntervention(projectId, qaId, taskId, {
|
|
10527
|
-
phase: 'server-review-transition-failed',
|
|
10528
|
-
qaAgentId: qaId,
|
|
10529
|
-
});
|
|
10530
|
-
}
|
|
10531
|
-
return null;
|
|
10532
|
-
}
|
|
10533
|
-
const installedPass = {
|
|
10534
|
-
status: transition.task.status,
|
|
10535
|
-
phase: transition.task.phase,
|
|
10536
|
-
signalToken: transition.task.signalToken,
|
|
10537
|
-
};
|
|
10538
|
-
let dispatchedCheckoutMode;
|
|
10539
|
-
const sessionOpts = {
|
|
10540
|
-
bypassTaskStatusGate: true,
|
|
10541
|
-
signalToken: newToken,
|
|
10542
|
-
serverContent: opts.content,
|
|
10543
|
-
...(opts.interdiff !== undefined ? { serverInterdiff: opts.interdiff } : {}),
|
|
10544
|
-
...(opts.diffstat !== undefined ? { serverDiffstat: opts.diffstat } : {}),
|
|
10545
|
-
...(opts.baseSha !== undefined ? { serverBaseSha: opts.baseSha } : {}),
|
|
10546
|
-
...(opts.reviewHeadAnchorSha !== undefined ? { serverHeadSha: opts.reviewHeadAnchorSha } : {}),
|
|
10547
|
-
...(opts.headTree !== undefined ? { serverHeadTree: opts.headTree } : {}),
|
|
10548
|
-
...(opts.batch ? { serverBatch: opts.batch } : {}),
|
|
10549
|
-
...(opts.priorFindingsJson ? { serverPriorFindings: opts.priorFindingsJson } : {}),
|
|
10550
|
-
...(opts.priorResponseJson ? { serverPriorResponse: opts.priorResponseJson } : {}),
|
|
10551
|
-
...(opts.phase === 'spec' ? { currentSpecRound: newRound } : {}),
|
|
10552
|
-
armBeforeInject: (ctx) => {
|
|
10553
|
-
dispatchedCheckoutMode = ctx.serverReviewCheckout;
|
|
10554
|
-
const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.serverReviewCheckout === 'base';
|
|
10555
|
-
return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, {
|
|
10556
|
-
needInputMode: 'fresh',
|
|
10557
|
-
...(allowReadFile
|
|
10558
|
-
? { onReadFile: (req) => { void this.handleReadFileRequest(taskId, qaId, req); } }
|
|
10559
|
-
: {}),
|
|
10560
|
-
});
|
|
10561
|
-
},
|
|
10562
|
-
};
|
|
10563
|
-
const rearmConsumedSignal = async () => {
|
|
10564
|
-
if (opts.callerOwnsConsumedSignalFailure)
|
|
10565
|
-
return;
|
|
10566
|
-
if (opts.continuation) {
|
|
10567
|
-
await this.setupPhaseSignal(taskId, qaId, expectedKind, { skipSnapshot: true });
|
|
10568
|
-
}
|
|
10569
|
-
else {
|
|
10570
|
-
await rearmEntrySignal();
|
|
10571
|
-
}
|
|
10572
|
-
};
|
|
10573
|
-
let started = false;
|
|
10574
|
-
try {
|
|
10575
|
-
started = opts.continuation
|
|
10576
|
-
? await this.continueSession(taskId, qaId, dispatchPhase, sessionOpts)
|
|
10577
|
-
: await this.startSession(taskId, qaId, dispatchPhase, sessionOpts);
|
|
10578
|
-
}
|
|
10579
|
-
catch (err) {
|
|
10580
|
-
this.stopPhaseSignalWatcher(taskId);
|
|
10581
|
-
if (err instanceof DispatchTerminalError) {
|
|
10582
|
-
await this.failTaskForDispatchError(taskId, dispatchPhase, qaId, err, ownAcquire());
|
|
10583
|
-
}
|
|
10584
|
-
else if (err instanceof EnsureSessionError && err.partial.handled) {
|
|
10585
|
-
}
|
|
10586
|
-
else {
|
|
10587
|
-
const rolledBack = await rollback(installedPass);
|
|
10588
|
-
if (!opts.continuation) {
|
|
10589
|
-
await releaseOwnAcquire('session dispatch threw');
|
|
10590
|
-
}
|
|
10591
|
-
if (rolledBack)
|
|
10592
|
-
await rearmConsumedSignal();
|
|
10593
|
-
}
|
|
10594
|
-
throw err;
|
|
10595
|
-
}
|
|
10596
|
-
if (!started) {
|
|
10597
|
-
this.stopPhaseSignalWatcher(taskId);
|
|
10598
|
-
const rolledBack = await rollback(installedPass);
|
|
10599
|
-
if (!opts.continuation) {
|
|
10600
|
-
await releaseOwnAcquire('session did not start');
|
|
10601
|
-
}
|
|
10602
|
-
if (rolledBack)
|
|
10603
|
-
await rearmConsumedSignal();
|
|
10604
|
-
if (!opts.callerOwnsConsumedSignalFailure) {
|
|
10605
|
-
await this.emitIntervention(projectId, qaId, taskId, {
|
|
10606
|
-
phase: 'server-review-start-failed',
|
|
10607
|
-
qaAgentId: qaId,
|
|
10608
|
-
});
|
|
10609
|
-
}
|
|
10610
|
-
return null;
|
|
10611
|
-
}
|
|
10612
|
-
// Persist the checkout mode so base-mode recovery can re-arm read-file.
|
|
10613
|
-
if (opts.phase !== 'spec' && !opts.continuation) {
|
|
10614
|
-
try {
|
|
10615
|
-
await this.updateTask(taskId, { reviewCheckoutMode: dispatchedCheckoutMode });
|
|
10616
|
-
}
|
|
10617
|
-
catch (err) {
|
|
10618
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
10619
|
-
console.warn(`[AgentManager] failed to persist review checkout mode for ${taskId}: ${message}`);
|
|
10620
|
-
await this.emitIntervention(projectId, qaId, taskId, {
|
|
10621
|
-
phase: 'review-checkout-mode-persist-failed',
|
|
10622
|
-
error: message,
|
|
10623
|
-
note: 'The review prompt is running, but restart recovery cannot safely infer its checkout mode.',
|
|
10624
|
-
});
|
|
10625
|
-
}
|
|
10626
|
-
}
|
|
10627
|
-
return await this.taskStore.get(taskId);
|
|
10628
|
-
}
|
|
10629
|
-
async dispatchServerFixToDev(taskId, _findingsJson, opts = {}) {
|
|
10630
|
-
const claim = await this.withTaskLock(async () => {
|
|
10631
|
-
const task = await this.taskStore.get(taskId);
|
|
10632
|
-
if (!task)
|
|
10633
|
-
throw new Error(`dispatchServerFixToDev: task ${taskId} not found`);
|
|
10634
|
-
if (task.reviewMode !== 'server' && !isSpecStagePhase(task.phase)) {
|
|
10635
|
-
throw new Error(`dispatchServerFixToDev: task ${taskId} is not in server review mode`);
|
|
10636
|
-
}
|
|
10637
|
-
if (!task.agentId)
|
|
10638
|
-
throw new Error(`dispatchServerFixToDev: task ${taskId} has no dev agent`);
|
|
10639
|
-
return {
|
|
10640
|
-
devAgentId: task.agentId,
|
|
10641
|
-
qaAgentId: task.qaAgentId,
|
|
10642
|
-
projectId: task.projectId,
|
|
10643
|
-
newToken: createSignalToken(),
|
|
10644
|
-
taskPhase: task.phase,
|
|
10645
|
-
currentReviewRound: task.reviewRound,
|
|
10646
|
-
currentSpecRound: task.specReviewRound,
|
|
10647
|
-
originalStatus: task.status,
|
|
10648
|
-
originalToken: task.signalToken,
|
|
10649
|
-
};
|
|
10650
|
-
});
|
|
10651
|
-
if (!claim)
|
|
10652
|
-
return null;
|
|
10653
|
-
const { devAgentId, qaAgentId, projectId, newToken, taskPhase, currentSpecRound } = claim;
|
|
10654
|
-
const rollbackToEntry = async () => {
|
|
10655
|
-
await this.transitionTaskStatus(taskId, claim.originalStatus, { fromStatus: ['fixing'] }, { signalToken: claim.originalToken }).catch(() => undefined);
|
|
10656
|
-
};
|
|
10657
|
-
const expectedKind = taskPhase === 'spec' ? 'spec-fixed' : 'code-fixed';
|
|
10658
|
-
const rearmReviewedSignal = async () => {
|
|
10659
|
-
if (opts.callerOwnsConsumedSignalFailure)
|
|
10660
|
-
return;
|
|
10661
|
-
if (!qaAgentId)
|
|
10662
|
-
return;
|
|
10663
|
-
const reviewedKind = taskPhase === 'spec' ? 'spec-reviewed' : 'code-reviewed';
|
|
10664
|
-
await this.setupPhaseSignal(taskId, qaAgentId, reviewedKind, { skipSnapshot: true });
|
|
10665
|
-
};
|
|
10666
|
-
const devBoundBefore = (await this.agentStore.get(devAgentId))?.taskId === taskId;
|
|
10667
|
-
const releaseFreshAcquire = async () => {
|
|
10668
|
-
if (devBoundBefore)
|
|
10669
|
-
return;
|
|
10670
|
-
const released = await this.releaseAgentForTask(devAgentId, taskId, 'idle')
|
|
10671
|
-
.catch(() => false);
|
|
10672
|
-
if (!released) {
|
|
10673
|
-
console.error(`[AgentManager] dispatchServerFixToDev: releasing freshly acquired dev ${devAgentId} for ${taskId} failed`);
|
|
10674
|
-
}
|
|
10675
|
-
};
|
|
10676
|
-
const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-feedback');
|
|
10677
|
-
if (!acquired) {
|
|
10678
|
-
await rearmReviewedSignal();
|
|
10679
|
-
if (!opts.callerOwnsConsumedSignalFailure) {
|
|
10680
|
-
await this.emitIntervention(projectId, devAgentId, taskId, {
|
|
10681
|
-
phase: 'server-fix-dev-acquire-failed',
|
|
10682
|
-
devAgentId,
|
|
10683
|
-
});
|
|
10684
|
-
}
|
|
10685
|
-
return null;
|
|
10686
|
-
}
|
|
10687
|
-
if (qaAgentId) {
|
|
10688
|
-
const released = await this.releaseAgentIfBound(qaAgentId, taskId)
|
|
10689
|
-
.catch(() => false);
|
|
10690
|
-
if (!released) {
|
|
10691
|
-
await releaseFreshAcquire();
|
|
10692
|
-
await rearmReviewedSignal();
|
|
10693
|
-
if (!opts.callerOwnsConsumedSignalFailure) {
|
|
10694
|
-
await this.emitIntervention(projectId, qaAgentId, taskId, {
|
|
10695
|
-
phase: 'server-fix-qa-release-failed',
|
|
10696
|
-
qaAgentId,
|
|
10697
|
-
});
|
|
10698
|
-
}
|
|
10699
|
-
return null;
|
|
10700
|
-
}
|
|
10701
|
-
}
|
|
10702
|
-
const transition = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review', 'max_rounds', 'spec-ready', 'ready'] }, { signalToken: newToken, fixDispatchedAt: new Date().toISOString() });
|
|
10703
|
-
if (!transition) {
|
|
10704
|
-
await releaseFreshAcquire();
|
|
10705
|
-
if (!opts.callerOwnsConsumedSignalFailure) {
|
|
10706
|
-
await this.emitIntervention(projectId, devAgentId, taskId, {
|
|
10707
|
-
phase: 'server-fix-transition-failed',
|
|
10708
|
-
devAgentId,
|
|
10709
|
-
});
|
|
10710
|
-
}
|
|
10711
|
-
return null;
|
|
10712
|
-
}
|
|
10713
|
-
let resumed = false;
|
|
10714
|
-
try {
|
|
10715
|
-
const feedbackPhase = taskPhase === 'spec' ? 'spec' : 'code';
|
|
10716
|
-
const feedbackRound = feedbackPhase === 'spec'
|
|
10717
|
-
? (currentSpecRound ?? 1)
|
|
10718
|
-
: Math.max(claim.currentReviewRound, 1);
|
|
10719
|
-
const stored = await this.reviewStore?.getRound(taskId, feedbackPhase, feedbackRound);
|
|
10720
|
-
if (!stored?.findings) {
|
|
10721
|
-
throw new Error(`dispatchServerFixToDev: persisted aggregate findings missing for ${taskId}/${feedbackPhase}/${feedbackRound}`);
|
|
10722
|
-
}
|
|
10723
|
-
const findingsJson = JSON.stringify(stored.findings);
|
|
10724
|
-
const findingsDigest = reviewFindingsDigest(stored.findings);
|
|
10725
|
-
resumed = await this.continueSession(taskId, devAgentId, 'server-feedback', {
|
|
10726
|
-
bypassTaskStatusGate: true,
|
|
10727
|
-
signalToken: newToken,
|
|
10728
|
-
serverPriorFindings: findingsJson,
|
|
10729
|
-
serverFindingsDigest: findingsDigest,
|
|
10730
|
-
...(taskPhase === 'spec' && currentSpecRound !== undefined
|
|
10731
|
-
? { currentSpecRound }
|
|
10732
|
-
: {}),
|
|
10733
|
-
});
|
|
10734
|
-
}
|
|
10735
|
-
catch (err) {
|
|
10736
|
-
if (err instanceof DispatchTerminalError) {
|
|
10737
|
-
await this.failTaskForDispatchError(taskId, 'server-feedback', devAgentId, err);
|
|
10738
|
-
}
|
|
10739
|
-
else if (err instanceof EnsureSessionError && err.partial.handled) {
|
|
10740
|
-
}
|
|
10741
|
-
else {
|
|
10742
|
-
await rollbackToEntry();
|
|
10743
|
-
await this.releaseAgentForTask(devAgentId, taskId, 'idle').catch(() => undefined);
|
|
10744
|
-
await rearmReviewedSignal();
|
|
10745
|
-
}
|
|
10746
|
-
throw err;
|
|
10747
|
-
}
|
|
10748
|
-
if (!resumed) {
|
|
10749
|
-
await rollbackToEntry();
|
|
10750
|
-
await this.releaseAgentForTask(devAgentId, taskId, 'idle').catch(() => undefined);
|
|
10751
|
-
await rearmReviewedSignal();
|
|
10752
|
-
if (!opts.callerOwnsConsumedSignalFailure) {
|
|
10753
|
-
await this.emitIntervention(projectId, devAgentId, taskId, {
|
|
10754
|
-
phase: 'server-fix-resume-failed',
|
|
10755
|
-
devAgentId,
|
|
10756
|
-
});
|
|
10757
|
-
}
|
|
10758
|
-
return null;
|
|
10759
|
-
}
|
|
10760
|
-
await this.armPostDispatchSignalOrHold(taskId, devAgentId, expectedKind, newToken);
|
|
10761
|
-
return await this.taskStore.get(taskId);
|
|
10762
|
-
}
|
|
10763
|
-
projectRepoKey(projectId) {
|
|
10764
|
-
const repo = this.getProjectConfig(projectId)?.repo;
|
|
10765
|
-
if (!repo)
|
|
10766
|
-
return null;
|
|
10767
|
-
if (isGitHubRepo(repo))
|
|
10768
|
-
return `gh:${repoSlug(repo).toLowerCase()}`;
|
|
10769
|
-
const parsed = parseGitRemote(repo);
|
|
10770
|
-
if (parsed)
|
|
10771
|
-
return `git:${parsed.host.toLowerCase()}/${parsed.path}`;
|
|
10772
|
-
return `raw:${repo.trim()}`;
|
|
10773
|
-
}
|
|
10774
|
-
async computeCodeInterdiff(taskId, round) {
|
|
10775
|
-
const store = this.getReviewStore();
|
|
10776
|
-
if (!store || !Number.isInteger(round) || round < 2)
|
|
10777
|
-
return { ok: false, reason: 'no-anchor' };
|
|
10778
|
-
const [cur, prev] = await Promise.all([
|
|
10779
|
-
store.getRound(taskId, 'code', round),
|
|
10780
|
-
store.getRound(taskId, 'code', round - 1),
|
|
10781
|
-
]);
|
|
10782
|
-
const curSha = cur?.headSha;
|
|
10783
|
-
const prevSha = prev?.headSha;
|
|
10784
|
-
if (!curSha || !prevSha)
|
|
10785
|
-
return { ok: false, reason: 'no-anchor' };
|
|
10786
|
-
const task = await this.taskStore.get(taskId);
|
|
10787
|
-
if (!task?.agentId)
|
|
10788
|
-
return { ok: false, reason: 'released' };
|
|
10789
|
-
const agent = this.getAgentConfig(task.agentId);
|
|
10790
|
-
const agentState = await this.agentStore.get(task.agentId);
|
|
10791
|
-
// A rebound agent's Workdir checkout belongs to its new task — its git state describes
|
|
10792
|
-
// another branch, so the interdiff would be meaningless. Same skip rule as
|
|
10793
|
-
// findLineageViolation: the Workdir must still be bound to THIS task.
|
|
10794
|
-
if (!agent || agentState?.taskId !== taskId || !agentState.workdir) {
|
|
10795
|
-
return { ok: false, reason: 'released' };
|
|
10796
|
-
}
|
|
10797
|
-
await this.refreshWorkdirCacheFor(task.agentId);
|
|
10798
|
-
const diff = await this.getReviewTransport().readInterdiff(agent, prevSha, curSha);
|
|
10799
|
-
return { ok: true, diff };
|
|
10800
|
-
}
|
|
10801
|
-
async findLineageViolation(taskId, baseSha) {
|
|
10802
|
-
const task = await this.taskStore.get(taskId);
|
|
10803
|
-
if (!task?.agentId)
|
|
10804
|
-
return null;
|
|
10805
|
-
const agent = this.getAgentConfig(task.agentId);
|
|
10806
|
-
const agentState = await this.agentStore.get(task.agentId);
|
|
10807
|
-
// A rebound agent's Workdir checkout belongs to its new task — checking it would
|
|
10808
|
-
// produce verdicts about the wrong branch. Skip; the dispatch path's own
|
|
10809
|
-
// binding guards (acquire) surface the real failure.
|
|
10810
|
-
if (agentState?.taskId !== taskId)
|
|
10811
|
-
return null;
|
|
10812
|
-
const workdir = agentState.workdir;
|
|
10813
|
-
if (!agent || !workdir)
|
|
10814
|
-
return null;
|
|
10815
|
-
const runner = this.createRunnerFor(agent);
|
|
10816
|
-
let base = baseSha;
|
|
10817
|
-
if (!base) {
|
|
10818
|
-
// Callers without a baseSha (publish) run long after the review diff was
|
|
10819
|
-
// read; refresh origin/HEAD first or upstream commits merged since then
|
|
10820
|
-
// would linger in base..HEAD and flag tasks that leak nothing.
|
|
10821
|
-
const fetch = await execNetwork(runner, `${canonicalSelfGuard(workdir)} && ${GIT_NET_ENV} git -C ${shellQuote(workdir)} fetch origin --quiet`);
|
|
10822
|
-
if (fetch.exitCode !== 0) {
|
|
10823
|
-
throw new Error(`lineage fetch failed in ${workdir}: ${fetch.stderr.trim()}`);
|
|
10824
|
-
}
|
|
10825
|
-
const mb = await runner.exec(`git -C ${shellQuote(workdir)} merge-base origin/HEAD HEAD`);
|
|
10826
|
-
if (mb.exitCode !== 0) {
|
|
10827
|
-
throw new Error(`lineage merge-base failed in ${workdir}: ${mb.stderr.trim()}`);
|
|
10828
|
-
}
|
|
10829
|
-
base = mb.stdout.trim();
|
|
10830
|
-
}
|
|
10831
|
-
// Projects pointing at the same repo share one store and branch namespace,
|
|
10832
|
-
// so candidates are scoped by repo identity, not by projectId.
|
|
10833
|
-
const selfRepoKey = this.projectRepoKey(task.projectId);
|
|
10834
|
-
if (!selfRepoKey)
|
|
10835
|
-
return null;
|
|
10836
|
-
const tasks = await this.taskStore.list();
|
|
10837
|
-
const candidates = tasks
|
|
10838
|
-
.filter(t => t.id !== taskId
|
|
10839
|
-
&& !TERMINAL_STATUSES.includes(t.status)
|
|
10840
|
-
&& this.projectRepoKey(t.projectId) === selfRepoKey)
|
|
10841
|
-
.map(t => ({ taskId: t.id, branch: t.branch ?? BRANCH_PREFIX + t.id }));
|
|
10842
|
-
if (candidates.length === 0)
|
|
10843
|
-
return null;
|
|
10844
|
-
return findForeignTaskTip((cmd) => runner.exec(cmd), workdir, base, candidates);
|
|
10845
|
-
}
|
|
10846
|
-
async dispatchServerAfterDone(taskId, kind) {
|
|
10847
|
-
const task = await this.taskStore.get(taskId);
|
|
10848
|
-
if (!task)
|
|
10849
|
-
throw new Error(`dispatchServerAfterDone: task ${taskId} not found`);
|
|
10850
|
-
const devAgentId = task.agentId;
|
|
10851
|
-
if (!devAgentId)
|
|
10852
|
-
throw new Error(`dispatchServerAfterDone: task ${taskId} has no dev agent`);
|
|
10853
|
-
// 幂等兜底:主路径已在 approve 前经 commitServerAfterDone 落盘 afterDone+binding;
|
|
10854
|
-
// 重启/重试直连本函数时,这里补齐(binding 不可变,已在则短路)。
|
|
10855
|
-
await this.commitServerAfterDone(taskId);
|
|
10856
|
-
let violation;
|
|
10857
|
-
try {
|
|
10858
|
-
violation = await this.findLineageViolation(taskId);
|
|
10859
|
-
}
|
|
10860
|
-
catch (err) {
|
|
10861
|
-
await this.emitIntervention(task.projectId, devAgentId, taskId, {
|
|
10862
|
-
phase: 'server-after-done-lineage-check-failed',
|
|
10863
|
-
error: err instanceof Error ? err.message : String(err),
|
|
10864
|
-
note: 'Publish was not dispatched; mark-complete retries it once the repo state is fixed.',
|
|
10865
|
-
});
|
|
10866
|
-
return null;
|
|
10867
|
-
}
|
|
10868
|
-
if (violation) {
|
|
10869
|
-
await this.emitIntervention(task.projectId, devAgentId, taskId, {
|
|
10870
|
-
phase: 'server-after-done-lineage-violation',
|
|
10871
|
-
offendingTaskId: violation.taskId,
|
|
10872
|
-
offendingBranch: violation.branch,
|
|
10873
|
-
offendingSha: violation.sha,
|
|
10874
|
-
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.',
|
|
10875
|
-
});
|
|
10876
|
-
return null;
|
|
10877
|
-
}
|
|
10878
|
-
const branch = task.branch ?? BRANCH_PREFIX + taskId;
|
|
10879
|
-
const originalToken = task.signalToken;
|
|
10880
|
-
const newToken = createSignalToken();
|
|
10881
|
-
await this.updateTask(taskId, { signalToken: newToken });
|
|
10882
|
-
const rollbackToken = async () => {
|
|
10883
|
-
await this.updateTask(taskId, { signalToken: originalToken, publishDispatchedAt: undefined })
|
|
10884
|
-
.catch(() => undefined);
|
|
10885
|
-
};
|
|
10886
|
-
const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-after-done');
|
|
10887
|
-
if (!acquired) {
|
|
10888
|
-
await rollbackToken();
|
|
10889
|
-
await this.emitIntervention(task.projectId, devAgentId, taskId, { phase: 'server-after-done-dev-acquire-failed', devAgentId });
|
|
10890
|
-
return null;
|
|
10891
|
-
}
|
|
10892
|
-
await this.updateTask(taskId, { publishDispatchedAt: new Date().toISOString() });
|
|
10893
|
-
let resumed = false;
|
|
10894
|
-
try {
|
|
10895
|
-
resumed = await this.continueSession(taskId, devAgentId, 'server-after-done', {
|
|
10896
|
-
bypassTaskStatusGate: true,
|
|
10897
|
-
signalToken: newToken,
|
|
10898
|
-
serverAfterDone: { kind, branch },
|
|
10899
|
-
});
|
|
10900
|
-
}
|
|
10901
|
-
catch (err) {
|
|
10902
|
-
if (err instanceof DispatchTerminalError) {
|
|
10903
|
-
await this.failTaskForDispatchError(taskId, 'server-after-done', devAgentId, err);
|
|
10904
|
-
}
|
|
10905
|
-
else if (!(err instanceof EnsureSessionError && err.partial.handled)) {
|
|
10906
|
-
await rollbackToken();
|
|
10907
|
-
}
|
|
10908
|
-
throw err;
|
|
10909
|
-
}
|
|
10910
|
-
if (!resumed) {
|
|
10911
|
-
await rollbackToken();
|
|
10912
|
-
await this.emitIntervention(task.projectId, devAgentId, taskId, {
|
|
10913
|
-
phase: 'server-after-done-resume-failed',
|
|
10914
|
-
devAgentId,
|
|
10915
|
-
note: 'Publish prompt was not delivered; mark-complete retries the publish dispatch.',
|
|
10916
|
-
});
|
|
10917
|
-
return null;
|
|
10918
|
-
}
|
|
10919
|
-
await this.armPostDispatchSignalOrHold(taskId, devAgentId, 'code-ready', newToken);
|
|
9912
|
+
await this.finalizeReplayedBootstrap(devAgentId, taskId, 'code', acquiredLockToken);
|
|
10920
9913
|
return await this.taskStore.get(taskId);
|
|
10921
9914
|
}
|
|
10922
|
-
async handleReadFileRequest(taskId, qaAgentId, req) {
|
|
10923
|
-
const task = await this.taskStore.get(taskId);
|
|
10924
|
-
if (!task)
|
|
10925
|
-
return;
|
|
10926
|
-
const dev = this.getAgentConfig(task.agentId);
|
|
10927
|
-
if (!dev)
|
|
10928
|
-
return;
|
|
10929
|
-
await this.refreshWorkdirCacheFor(task.agentId);
|
|
10930
|
-
let body;
|
|
10931
|
-
try {
|
|
10932
|
-
const text = await this.getReviewTransport().readFileRange(dev, req.file, req.startLine, req.endLine);
|
|
10933
|
-
body = `=== baxian read-file ${req.file}:${req.startLine}-${req.endLine} ===\n${text}\n=== end read-file ===`;
|
|
10934
|
-
}
|
|
10935
|
-
catch (err) {
|
|
10936
|
-
const reason = err instanceof Error ? err.message : String(err);
|
|
10937
|
-
body = `=== baxian read-file ${req.file}:${req.startLine}-${req.endLine} REFUSED: ${reason} ===`;
|
|
10938
|
-
}
|
|
10939
|
-
const qaState = await this.agentStore.get(qaAgentId);
|
|
10940
|
-
if (qaState?.taskId !== taskId) {
|
|
10941
|
-
console.warn(`[AgentManager] read-file response dropped: qa=${qaAgentId} no longer bound to ${taskId} (got ${qaState?.taskId})`);
|
|
10942
|
-
return;
|
|
10943
|
-
}
|
|
10944
|
-
try {
|
|
10945
|
-
await this.injectTextToAgent(qaAgentId, body, { expectedTaskId: taskId });
|
|
10946
|
-
}
|
|
10947
|
-
catch (err) {
|
|
10948
|
-
console.warn(`[AgentManager] read-file injection to ${qaAgentId} failed:`, err);
|
|
10949
|
-
}
|
|
10950
|
-
}
|
|
10951
9915
|
async injectTextToAgent(agentId, text, opts = {}) {
|
|
10952
9916
|
const cfg = this.getAgentConfig(agentId);
|
|
10953
9917
|
if (!cfg)
|
|
@@ -11006,35 +9970,13 @@ export class AgentManager {
|
|
|
11006
9970
|
}
|
|
11007
9971
|
}
|
|
11008
9972
|
async confirmHumanGate(taskId) {
|
|
11009
|
-
const task = await this.claimCompleteGate(taskId, ['
|
|
9973
|
+
const task = await this.claimCompleteGate(taskId, ['merge-ready']);
|
|
11010
9974
|
try {
|
|
11011
9975
|
const project = this.getProjectConfig(task.projectId);
|
|
11012
9976
|
const mergeAuto = project?.merge === 'auto';
|
|
11013
|
-
|
|
11014
|
-
if (task.status === 'merge-ready') {
|
|
11015
|
-
if (mergeAuto && task.prNumber) {
|
|
11016
|
-
if (!task.latestHeadSha) {
|
|
11017
|
-
throw new ApiError(409, `Task ${taskId} has no approved head recorded; cannot safely merge`);
|
|
11018
|
-
}
|
|
11019
|
-
await this.executeConfirmMerge(task, () => this.mergePr(taskId, {
|
|
11020
|
-
matchHeadSha: task.latestHeadSha,
|
|
11021
|
-
}));
|
|
11022
|
-
await this.eventBus.emit({
|
|
11023
|
-
id: '',
|
|
11024
|
-
type: 'pr.merged',
|
|
11025
|
-
timestamp: new Date().toISOString(),
|
|
11026
|
-
projectId: task.projectId,
|
|
11027
|
-
agentId: task.agentId,
|
|
11028
|
-
taskId: task.id,
|
|
11029
|
-
data: { prNumber: task.prNumber, ...(task.prUrl ? { prUrl: task.prUrl } : {}) },
|
|
11030
|
-
});
|
|
11031
|
-
return (await this.taskStore.get(taskId));
|
|
11032
|
-
}
|
|
11033
|
-
return this.finishTaskAsDone(taskId);
|
|
11034
|
-
}
|
|
11035
|
-
if (afterDone === 'pr' && mergeAuto && task.prNumber) {
|
|
9977
|
+
if (mergeAuto && task.prNumber) {
|
|
11036
9978
|
if (!task.latestHeadSha) {
|
|
11037
|
-
throw new ApiError(409, `Task ${taskId} has no
|
|
9979
|
+
throw new ApiError(409, `Task ${taskId} has no approved head recorded; cannot safely merge`);
|
|
11038
9980
|
}
|
|
11039
9981
|
await this.executeConfirmMerge(task, () => this.mergePr(taskId, {
|
|
11040
9982
|
matchHeadSha: task.latestHeadSha,
|
|
@@ -11050,13 +9992,6 @@ export class AgentManager {
|
|
|
11050
9992
|
});
|
|
11051
9993
|
return (await this.taskStore.get(taskId));
|
|
11052
9994
|
}
|
|
11053
|
-
if (afterDone === 'branch' && mergeAuto && task.branch) {
|
|
11054
|
-
await this.executeConfirmMerge(task, () => this.ffMergeBranch(task));
|
|
11055
|
-
const merged = await this.transitionTaskStatus(taskId, 'merged', { fromStatus: ['ready'] });
|
|
11056
|
-
if (merged)
|
|
11057
|
-
await this.releaseTaskAgents(taskId);
|
|
11058
|
-
return (await this.taskStore.get(taskId));
|
|
11059
|
-
}
|
|
11060
9995
|
return this.finishTaskAsDone(taskId);
|
|
11061
9996
|
}
|
|
11062
9997
|
finally {
|
|
@@ -11074,9 +10009,6 @@ export class AgentManager {
|
|
|
11074
10009
|
if (this.markCompleteInFlight.has(taskId)) {
|
|
11075
10010
|
throw new ApiError(409, `Task ${taskId} is already being completed`);
|
|
11076
10011
|
}
|
|
11077
|
-
if (this.codeVerdictInFlight.has(taskId)) {
|
|
11078
|
-
throw new ApiError(409, `Task ${taskId} code verdict is already being processed`);
|
|
11079
|
-
}
|
|
11080
10012
|
this.markCompleteInFlight.add(taskId);
|
|
11081
10013
|
return fresh;
|
|
11082
10014
|
});
|
|
@@ -11087,7 +10019,7 @@ export class AgentManager {
|
|
|
11087
10019
|
}
|
|
11088
10020
|
catch (err) {
|
|
11089
10021
|
const message = err instanceof Error ? err.message : String(err);
|
|
11090
|
-
if (err instanceof PlatformMergeRecheckError && task.
|
|
10022
|
+
if (err instanceof PlatformMergeRecheckError && task.status === 'merge-ready') {
|
|
11091
10023
|
let retreated = false;
|
|
11092
10024
|
if (err.kind === 'pending-feedback') {
|
|
11093
10025
|
retreated = await this.withTaskLock(async () => {
|
|
@@ -11161,7 +10093,7 @@ export class AgentManager {
|
|
|
11161
10093
|
}
|
|
11162
10094
|
}
|
|
11163
10095
|
async finishTaskAsDone(taskId) {
|
|
11164
|
-
const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['
|
|
10096
|
+
const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['merge-ready'] });
|
|
11165
10097
|
if (!done)
|
|
11166
10098
|
throw new ApiError(409, `Task ${taskId} changed status during confirm; aborted`);
|
|
11167
10099
|
await this.releaseTaskAgents(taskId);
|
|
@@ -11201,49 +10133,6 @@ export class AgentManager {
|
|
|
11201
10133
|
})().catch(err => console.warn(`[AgentManager] releaseTaskAgents compaction(${agentId}) failed:`, err));
|
|
11202
10134
|
return true;
|
|
11203
10135
|
}
|
|
11204
|
-
repoMergeQueue = new Map();
|
|
11205
|
-
async ffMergeBranch(task) {
|
|
11206
|
-
const dev = this.getAgentConfig(task.agentId);
|
|
11207
|
-
if (!dev)
|
|
11208
|
-
throw new Error(`ffMergeBranch: no dev agent for task ${task.id}`);
|
|
11209
|
-
const state = await this.agentStore.get(task.agentId);
|
|
11210
|
-
const workdir = state?.workdir;
|
|
11211
|
-
if (!workdir)
|
|
11212
|
-
throw new Error(`ffMergeBranch: no Workdir for agent ${task.agentId}`);
|
|
11213
|
-
const branch = task.branch ?? BRANCH_PREFIX + task.id;
|
|
11214
|
-
const runner = this.createRunnerFor(dev);
|
|
11215
|
-
const prev = this.repoMergeQueue.get(task.projectId) ?? Promise.resolve();
|
|
11216
|
-
const run = prev.then(async () => {
|
|
11217
|
-
// 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.
|
|
11218
|
-
const guard = `${canonicalSelfGuard(workdir)} && `;
|
|
11219
|
-
const cd = `cd ${shellQuote(workdir)} && `;
|
|
11220
|
-
const db = await runner.exec(`${cd}git symbolic-ref --short refs/remotes/origin/HEAD`);
|
|
11221
|
-
const defaultBranch = db.stdout.trim().replace(/^origin\//, '');
|
|
11222
|
-
if (db.exitCode !== 0 || defaultBranch === '') {
|
|
11223
|
-
throw new Error(`ffMergeBranch: cannot resolve default branch: ${db.stderr.trim() || 'empty origin/HEAD'}`);
|
|
11224
|
-
}
|
|
11225
|
-
const fetch = await execNetwork(runner, `${guard}${cd}${GIT_NET_ENV} git fetch origin`);
|
|
11226
|
-
if (fetch.exitCode !== 0) {
|
|
11227
|
-
throw new Error(`ffMergeBranch [git fetch] failed: ${fetch.stderr.trim()}`);
|
|
11228
|
-
}
|
|
11229
|
-
if (task.latestHeadSha) {
|
|
11230
|
-
const remoteHead = await runner.exec(`${cd}git rev-parse ${shellQuote(`origin/${branch}`)}`);
|
|
11231
|
-
if (remoteHead.exitCode !== 0 || remoteHead.stdout.trim() !== task.latestHeadSha) {
|
|
11232
|
-
throw new Error(`ffMergeBranch: origin/${branch} head ${remoteHead.stdout.trim() || '<unresolved>'} ` +
|
|
11233
|
-
`!= reviewed head ${task.latestHeadSha}; refusing to merge un-reviewed commits`);
|
|
11234
|
-
}
|
|
11235
|
-
}
|
|
11236
|
-
else {
|
|
11237
|
-
throw new Error(`ffMergeBranch: no reviewed head recorded for task ${task.id}; cannot safely merge`);
|
|
11238
|
-
}
|
|
11239
|
-
const push = await execNetwork(runner, `${guard}${cd}${GIT_NET_ENV} git push origin ${shellQuote(`origin/${branch}`)}:${shellQuote(defaultBranch)}`);
|
|
11240
|
-
if (push.exitCode !== 0) {
|
|
11241
|
-
throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
|
|
11242
|
-
}
|
|
11243
|
-
});
|
|
11244
|
-
this.repoMergeQueue.set(task.projectId, run.catch(() => undefined));
|
|
11245
|
-
await run;
|
|
11246
|
-
}
|
|
11247
10136
|
}
|
|
11248
10137
|
function buildAgentIndex(config) {
|
|
11249
10138
|
const index = new Map();
|