baxian 1.2.57 → 1.2.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/branch.d.ts +6 -1
- package/dist/agent/branch.d.ts.map +1 -1
- package/dist/agent/branch.js +45 -9
- package/dist/agent/branch.js.map +1 -1
- package/dist/agent/manager.d.ts +182 -5
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +2235 -147
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/phase-signal-watcher.d.ts +8 -1
- package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
- package/dist/agent/phase-signal-watcher.js +66 -15
- package/dist/agent/phase-signal-watcher.js.map +1 -1
- package/dist/agent/phase-signal.d.ts +3 -1
- package/dist/agent/phase-signal.d.ts.map +1 -1
- package/dist/agent/phase-signal.js +27 -4
- package/dist/agent/phase-signal.js.map +1 -1
- package/dist/agent/tmux.d.ts +8 -1
- package/dist/agent/tmux.d.ts.map +1 -1
- package/dist/agent/tmux.js +61 -1
- package/dist/agent/tmux.js.map +1 -1
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +16 -4
- package/dist/api/config.js.map +1 -1
- package/dist/api/platform-guard.d.ts +7 -0
- package/dist/api/platform-guard.d.ts.map +1 -0
- package/dist/api/platform-guard.js +32 -0
- package/dist/api/platform-guard.js.map +1 -0
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +34 -6
- package/dist/api/projects.js.map +1 -1
- package/dist/config/normalizer.d.ts.map +1 -1
- package/dist/config/normalizer.js +1 -0
- package/dist/config/normalizer.js.map +1 -1
- package/dist/config/validator.d.ts +3 -1
- package/dist/config/validator.d.ts.map +1 -1
- package/dist/config/validator.js +104 -7
- package/dist/config/validator.js.map +1 -1
- package/dist/event/handlers.d.ts +1 -0
- package/dist/event/handlers.d.ts.map +1 -1
- package/dist/event/handlers.js +661 -89
- package/dist/event/handlers.js.map +1 -1
- package/dist/github/mapper.d.ts +1 -6
- package/dist/github/mapper.d.ts.map +1 -1
- package/dist/github/mapper.js.map +1 -1
- package/dist/github/poller.d.ts +3 -3
- package/dist/github/poller.d.ts.map +1 -1
- package/dist/github/poller.js +2 -11
- package/dist/github/poller.js.map +1 -1
- package/dist/github/resolver.js +1 -1
- package/dist/github/resolver.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +33 -2
- package/dist/index.js.map +1 -1
- package/dist/platform/body-digest.d.ts +4 -0
- package/dist/platform/body-digest.d.ts.map +1 -0
- package/dist/platform/body-digest.js +13 -0
- package/dist/platform/body-digest.js.map +1 -0
- package/dist/platform/command-renderer.d.ts +22 -0
- package/dist/platform/command-renderer.d.ts.map +1 -0
- package/dist/platform/command-renderer.js +82 -0
- package/dist/platform/command-renderer.js.map +1 -0
- package/dist/platform/comment-cursor.d.ts +36 -0
- package/dist/platform/comment-cursor.d.ts.map +1 -0
- package/dist/platform/comment-cursor.js +235 -0
- package/dist/platform/comment-cursor.js.map +1 -0
- package/dist/platform/driver-host.d.ts +7 -0
- package/dist/platform/driver-host.d.ts.map +1 -0
- package/dist/platform/driver-host.js +18 -0
- package/dist/platform/driver-host.js.map +1 -0
- package/dist/platform/driver-spec.d.ts +7 -0
- package/dist/platform/driver-spec.d.ts.map +1 -0
- package/dist/platform/driver-spec.js +475 -0
- package/dist/platform/driver-spec.js.map +1 -0
- package/dist/platform/feedback.d.ts +31 -0
- package/dist/platform/feedback.d.ts.map +1 -0
- package/dist/platform/feedback.js +85 -0
- package/dist/platform/feedback.js.map +1 -0
- package/dist/platform/field-mapper.d.ts +7 -0
- package/dist/platform/field-mapper.d.ts.map +1 -0
- package/dist/platform/field-mapper.js +117 -0
- package/dist/platform/field-mapper.js.map +1 -0
- package/dist/platform/git-driver.d.ts +55 -0
- package/dist/platform/git-driver.d.ts.map +1 -0
- package/dist/platform/git-driver.js +171 -0
- package/dist/platform/git-driver.js.map +1 -0
- package/dist/platform/manifest.d.ts +12 -0
- package/dist/platform/manifest.d.ts.map +1 -0
- package/dist/platform/manifest.js +57 -0
- package/dist/platform/manifest.js.map +1 -0
- package/dist/platform/markers.d.ts +46 -0
- package/dist/platform/markers.d.ts.map +1 -0
- package/dist/platform/markers.js +121 -0
- package/dist/platform/markers.js.map +1 -0
- package/dist/platform/platform-poller.d.ts +68 -0
- package/dist/platform/platform-poller.d.ts.map +1 -0
- package/dist/platform/platform-poller.js +605 -0
- package/dist/platform/platform-poller.js.map +1 -0
- package/dist/platform/plugin-registry.d.ts +31 -0
- package/dist/platform/plugin-registry.d.ts.map +1 -0
- package/dist/platform/plugin-registry.js +238 -0
- package/dist/platform/plugin-registry.js.map +1 -0
- package/dist/platform/plugin-roots.d.ts +3 -0
- package/dist/platform/plugin-roots.d.ts.map +1 -0
- package/dist/platform/plugin-roots.js +11 -0
- package/dist/platform/plugin-roots.js.map +1 -0
- package/dist/platform/plugins/github/baxian-plugin.json +8 -0
- package/dist/platform/plugins/github/driver.json +134 -0
- package/dist/platform/plugins/github/skills/baxian-cli-gh/SKILL.md +99 -0
- package/dist/platform/poller-health.d.ts +3 -0
- package/dist/platform/poller-health.d.ts.map +1 -0
- package/dist/platform/poller-health.js +12 -0
- package/dist/platform/poller-health.js.map +1 -0
- package/dist/platform/pr-binding.d.ts +22 -0
- package/dist/platform/pr-binding.d.ts.map +1 -0
- package/dist/platform/pr-binding.js +28 -0
- package/dist/platform/pr-binding.js.map +1 -0
- package/dist/platform/response-parser.d.ts +6 -0
- package/dist/platform/response-parser.d.ts.map +1 -0
- package/dist/platform/response-parser.js +92 -0
- package/dist/platform/response-parser.js.map +1 -0
- package/dist/platform/row-schema.d.ts +12 -0
- package/dist/platform/row-schema.d.ts.map +1 -0
- package/dist/platform/row-schema.js +127 -0
- package/dist/platform/row-schema.js.map +1 -0
- package/dist/platform/startup.d.ts +16 -0
- package/dist/platform/startup.d.ts.map +1 -0
- package/dist/platform/startup.js +52 -0
- package/dist/platform/startup.js.map +1 -0
- package/dist/platform/types.d.ts +62 -0
- package/dist/platform/types.d.ts.map +1 -0
- package/dist/platform/types.js +32 -0
- package/dist/platform/types.js.map +1 -0
- package/dist/platform/verdict-engine.d.ts +61 -0
- package/dist/platform/verdict-engine.d.ts.map +1 -0
- package/dist/platform/verdict-engine.js +134 -0
- package/dist/platform/verdict-engine.js.map +1 -0
- package/dist/shared/constants.d.ts +2 -0
- package/dist/shared/constants.d.ts.map +1 -1
- package/dist/shared/constants.js +3 -0
- package/dist/shared/constants.js.map +1 -1
- package/dist/shared/git-url.d.ts +8 -0
- package/dist/shared/git-url.d.ts.map +1 -1
- package/dist/shared/git-url.js +38 -0
- package/dist/shared/git-url.js.map +1 -1
- package/dist/shared/types.d.ts +52 -1
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js.map +1 -1
- package/dist/state/agent-store.js +2 -0
- package/dist/state/agent-store.js.map +1 -1
- package/dist/state/task-store.d.ts.map +1 -1
- package/dist/state/task-store.js +91 -4
- package/dist/state/task-store.js.map +1 -1
- package/dist/web/assets/index-DskVWe7e.js +13 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-3Ypk8Gfo.js +0 -13
package/dist/agent/manager.js
CHANGED
|
@@ -4,7 +4,12 @@ import { join, normalize } from 'node:path';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { createSignalToken } from './phase-signal.js';
|
|
6
6
|
import { tmuxInstallHint } from './preflight.js';
|
|
7
|
-
import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, TASK_OWNER_ROLES, isSpecStagePhase, isGitHubRepo, parseGitRemote, repoSlug, } from '../shared/index.js';
|
|
7
|
+
import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, TASK_OWNER_ROLES, isSpecStagePhase, isGitHubRepo, parseGitRemote, repoIdentityKey, repoSlug, } from '../shared/index.js';
|
|
8
|
+
import { buildProjectDriver, makeDriverExec } from '../platform/driver-host.js';
|
|
9
|
+
import { DriverOpError } from '../platform/git-driver.js';
|
|
10
|
+
import { checkOpenPrBinding } from '../platform/pr-binding.js';
|
|
11
|
+
import { collectPendingFeedback, scanCommentSourcesOnce } from '../platform/feedback.js';
|
|
12
|
+
import { recheckPassProvenance } from '../platform/verdict-engine.js';
|
|
8
13
|
import { AGENT_STORE_NOOP } from '../state/agent-store.js';
|
|
9
14
|
import { PostApproveStore } from '../state/post-approve-store.js';
|
|
10
15
|
import { SkillRegistry } from '../skill/registry.js';
|
|
@@ -13,13 +18,15 @@ import { GH_EXEC_TIMEOUT_MS, GIT_NET_ENV, execNetwork } from './net-exec.js';
|
|
|
13
18
|
import { findForeignTaskTip } from './lineage.js';
|
|
14
19
|
import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
|
|
15
20
|
import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, hasOscTitleWorking, hasOscTitleIdle, screenAllowsTitleIdle, } from './tmux.js';
|
|
16
|
-
import { BranchManager, DirtyWorkdirError, ReviewHeadMismatchError } from './branch.js';
|
|
21
|
+
import { BranchManager, DirtyWorkdirError, ReviewHeadMismatchError, isAutoDeletableTaskBranch } from './branch.js';
|
|
17
22
|
import { RepoStore, createRepoStoreCache, moveFileIntoPlace, stageFileGuarded } from './repo-store.js';
|
|
18
23
|
import { PhaseSignalWatcher } from './phase-signal-watcher.js';
|
|
19
24
|
import { ReviewTransport, resolveServerPayloads } from './review-transport.js';
|
|
20
25
|
import { buildPromptInline, buildGreetingPrompt, PromptSizeError, RequiredSkillsMissingError, MAX_PROMPT_BYTES_ROUTE_LIMIT, } from './prompt.js';
|
|
21
26
|
import { ApiError } from '../errors.js';
|
|
22
27
|
import { prepareConfig } from '../config/loader.js';
|
|
28
|
+
import { resolveProjectTool } from '../config/validator.js';
|
|
29
|
+
import { SHA_HEX_SOURCE } from '../platform/types.js';
|
|
23
30
|
export class EnsureSessionError extends Error {
|
|
24
31
|
partial;
|
|
25
32
|
constructor(partial, message) {
|
|
@@ -141,12 +148,40 @@ export function skillSubdirFor(runtime) {
|
|
|
141
148
|
const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
|
|
142
149
|
const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
|
|
143
150
|
const GH_NET = { timeout: GH_EXEC_TIMEOUT_MS, retries: 1 };
|
|
151
|
+
const PLATFORM_HEAD_SHA_RE = new RegExp(`^${SHA_HEX_SOURCE}$`);
|
|
152
|
+
// 复核类失败(provenance/ack)与平台/瞬态失败分型:前者调用方退回 approved 重入闭环(计划 Task 8)
|
|
153
|
+
export class PlatformCloseError extends Error {
|
|
154
|
+
step;
|
|
155
|
+
constructor(step, cause) {
|
|
156
|
+
super(`platform ${step} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
157
|
+
this.step = step;
|
|
158
|
+
this.name = 'PlatformCloseError';
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
export class PlatformMergeRecheckError extends Error {
|
|
162
|
+
reason;
|
|
163
|
+
constructor(reason) {
|
|
164
|
+
super(`merge blocked: ${reason}`);
|
|
165
|
+
this.reason = reason;
|
|
166
|
+
this.name = 'PlatformMergeRecheckError';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
144
169
|
const MANUAL_SERVER_REVIEW_STATUSES = ['in_progress', 'review', 'fixing'];
|
|
145
170
|
const IMAGE_DISPATCH_PHASES = new Set(['develop', 'research', 'code', 'fix', 'server-feedback']);
|
|
146
171
|
const RUNTIME_LIVENESS_SAMPLES = 3;
|
|
147
172
|
export function canDispatchWithBinding(binding) {
|
|
148
173
|
return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
|
|
149
174
|
}
|
|
175
|
+
// 派发期 hold:prompt 从未注入、pane 无在途回合,重派安全;ack_unknown 一类不在此列
|
|
176
|
+
export const RECOVERABLE_QA_DISPATCH_HOLD_PHASES = new Set([
|
|
177
|
+
'checkout-preparation-failed',
|
|
178
|
+
'dirty-workdir',
|
|
179
|
+
]);
|
|
180
|
+
export function isRecoverableQaDispatchHold(binding) {
|
|
181
|
+
return binding?.status === 'awaiting_human'
|
|
182
|
+
&& binding.awaitingPhase != null
|
|
183
|
+
&& RECOVERABLE_QA_DISPATCH_HOLD_PHASES.has(binding.awaitingPhase);
|
|
184
|
+
}
|
|
150
185
|
const TURN_COMPLETED_AWAITING_PHASES = new Set();
|
|
151
186
|
const CANCEL_CLEANUP_HOLD_PHASES = new Set(['cancel-clearing', 'cancel-interrupt-failed']);
|
|
152
187
|
function isCancelCleanupHold(binding) {
|
|
@@ -163,12 +198,24 @@ function cancelPhaseDowngrades(prev, next) {
|
|
|
163
198
|
return cancelPhaseRank(next) < cancelPhaseRank(prev);
|
|
164
199
|
}
|
|
165
200
|
const REGREET_REQUIRED_HOLD_PHASES = new Set(['greeting_failed']);
|
|
201
|
+
const CHECKOUT_HOLD_PHASES = new Set([
|
|
202
|
+
'dirty-workdir',
|
|
203
|
+
'checkout-preparation-failed',
|
|
204
|
+
'branch-cleanup-pending',
|
|
205
|
+
]);
|
|
166
206
|
function shouldReleaseHeldBinding(state, boundTask) {
|
|
167
207
|
if (state.awaitingPhase != null && REGREET_REQUIRED_HOLD_PHASES.has(state.awaitingPhase))
|
|
168
208
|
return false;
|
|
169
209
|
const taskIsTerminal = !!boundTask && TERMINAL_STATUSES.includes(boundTask.status);
|
|
170
210
|
const turnCompleted = state.awaitingPhase != null && TURN_COMPLETED_AWAITING_PHASES.has(state.awaitingPhase);
|
|
171
|
-
|
|
211
|
+
// 任务已退回 verdict 门禁且当前持有者不是本 agent:这次绑定是转码失败的残留
|
|
212
|
+
// (fresh-acquire 释放被脏树挡下),Resume 时补完释放,否则 dev 被永久占住
|
|
213
|
+
const staleCodeHandoff = state.awaitingPhase != null
|
|
214
|
+
&& CHECKOUT_HOLD_PHASES.has(state.awaitingPhase)
|
|
215
|
+
&& !!boundTask
|
|
216
|
+
&& (boundTask.status === 'spec-ready' || boundTask.status === 'max_rounds')
|
|
217
|
+
&& boundTask.agentId !== state.id;
|
|
218
|
+
return !boundTask || taskIsTerminal || turnCompleted || staleCodeHandoff;
|
|
172
219
|
}
|
|
173
220
|
export class AgentManager {
|
|
174
221
|
config;
|
|
@@ -182,6 +229,8 @@ export class AgentManager {
|
|
|
182
229
|
repoCache;
|
|
183
230
|
paneStreamerManager;
|
|
184
231
|
postApproveStore;
|
|
232
|
+
pluginRegistry;
|
|
233
|
+
platformDriverExec;
|
|
185
234
|
phaseSignalWatcher;
|
|
186
235
|
errorRecordStore;
|
|
187
236
|
reviewStore;
|
|
@@ -224,6 +273,7 @@ export class AgentManager {
|
|
|
224
273
|
this.repoStoreFactory = deps.repoStoreFactory;
|
|
225
274
|
this.paneStreamerManager = deps.paneStreamerManager;
|
|
226
275
|
this.postApproveStore = deps.postApproveStore ?? new PostApproveStore();
|
|
276
|
+
this.pluginRegistry = deps.pluginRegistry;
|
|
227
277
|
this.errorRecordStore = deps.errorRecordStore;
|
|
228
278
|
this.phaseSignalWatcher = deps.phaseSignalWatcher
|
|
229
279
|
?? (deps.paneStreamerManager
|
|
@@ -239,6 +289,7 @@ export class AgentManager {
|
|
|
239
289
|
this.cancelInterruptGuardWaitMs = this.dispatchAckTimeoutMs + 5_000;
|
|
240
290
|
this.agentIndex = buildAgentIndex(config);
|
|
241
291
|
this.platformRunner = deps.platformRunner ?? new LocalRunner();
|
|
292
|
+
this.platformDriverExec = makeDriverExec(this.platformRunner);
|
|
242
293
|
this.imageStagingRoot = deps.imageStagingRoot ?? join(tmpdir(), 'baxian-task-images');
|
|
243
294
|
this.repoCache = createRepoStoreCache();
|
|
244
295
|
this.bootstrapTimeoutsMs = {
|
|
@@ -1493,17 +1544,43 @@ export class AgentManager {
|
|
|
1493
1544
|
`(expected ${expectedTaskId}, got ${state.taskId}); skipping`);
|
|
1494
1545
|
return false;
|
|
1495
1546
|
}
|
|
1547
|
+
if (opts.expectedLockToken !== undefined && state.lockToken !== opts.expectedLockToken) {
|
|
1548
|
+
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} lock generation changed ` +
|
|
1549
|
+
`(binding re-acquired by a successor); skipping release`);
|
|
1550
|
+
return false;
|
|
1551
|
+
}
|
|
1496
1552
|
if (isCancelCleanupHold(state) && !opts.fromCancelCleanup) {
|
|
1497
1553
|
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} ${state.awaitingPhase} (cancel-cleanup hold); refusing auto-release`);
|
|
1498
1554
|
return false;
|
|
1499
1555
|
}
|
|
1500
1556
|
const boundTask = await this.taskStore.get(expectedTaskId);
|
|
1557
|
+
if (opts.expectedTask
|
|
1558
|
+
&& boundTask
|
|
1559
|
+
&& (boundTask.status !== opts.expectedTask.status
|
|
1560
|
+
|| boundTask.phase !== opts.expectedTask.phase
|
|
1561
|
+
|| boundTask.signalToken !== opts.expectedTask.signalToken)) {
|
|
1562
|
+
console.warn(`[AgentManager] releaseAgentForTask: task ${expectedTaskId} moved past the captured pass; skipping ${mode} transition`);
|
|
1563
|
+
return false;
|
|
1564
|
+
}
|
|
1501
1565
|
if (state.status === 'awaiting_human' && !opts.allowAwaitingHuman) {
|
|
1502
1566
|
if (!shouldReleaseHeldBinding(state, boundTask)) {
|
|
1503
1567
|
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} is awaiting_human (${state.awaitingPhase}); refusing to release`);
|
|
1504
1568
|
return false;
|
|
1505
1569
|
}
|
|
1506
1570
|
}
|
|
1571
|
+
// expectedHold 是 hold 世代快照的严格 CAS(全 undefined 表示“期望无 hold”):
|
|
1572
|
+
// hold 消失(被并发 Resume 清掉)或换代同样意味着接管,不得继续释放
|
|
1573
|
+
const matchesExpectedHold = (binding) => {
|
|
1574
|
+
const held = binding.status === 'awaiting_human';
|
|
1575
|
+
return (held ? binding.awaitingPhase : undefined) === opts.expectedHold.phase
|
|
1576
|
+
&& (held ? binding.awaitingSince : undefined) === opts.expectedHold.since
|
|
1577
|
+
&& (held ? binding.awaitingNonce : undefined) === opts.expectedHold.nonce;
|
|
1578
|
+
};
|
|
1579
|
+
// 世代门前置:hold 换代时任何 pane/workdir 副作用都不得发生(末尾 CAS 只兜最后窗口)
|
|
1580
|
+
if (opts.expectedHold && !matchesExpectedHold(state)) {
|
|
1581
|
+
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} hold generation changed or cleared before release; refusing`);
|
|
1582
|
+
return false;
|
|
1583
|
+
}
|
|
1507
1584
|
if (mode === 'waiting' && (!boundTask || !ACTIVE_TASK_STATUSES.has(boundTask.status))) {
|
|
1508
1585
|
console.warn(`[AgentManager] releaseAgentForTask: task ${expectedTaskId} is not active; skipping waiting transition`);
|
|
1509
1586
|
return false;
|
|
@@ -1518,10 +1595,19 @@ export class AgentManager {
|
|
|
1518
1595
|
}
|
|
1519
1596
|
const now = new Date().toISOString();
|
|
1520
1597
|
if (mode === 'waiting') {
|
|
1598
|
+
let preservedNewerHold = false;
|
|
1521
1599
|
await this.agentStore.update(agentId, (latest) => {
|
|
1522
1600
|
if (!latest)
|
|
1523
1601
|
return AGENT_STORE_NOOP;
|
|
1524
1602
|
if (opts.clearAwaitingHuman && latest.status === 'awaiting_human') {
|
|
1603
|
+
// The hold-generation CAS lives inside the atomic update: a hold written after any pre-read survives.
|
|
1604
|
+
if (opts.expectedHold
|
|
1605
|
+
&& (latest.awaitingPhase !== opts.expectedHold.phase
|
|
1606
|
+
|| latest.awaitingSince !== opts.expectedHold.since
|
|
1607
|
+
|| latest.awaitingNonce !== opts.expectedHold.nonce)) {
|
|
1608
|
+
preservedNewerHold = true;
|
|
1609
|
+
return AGENT_STORE_NOOP;
|
|
1610
|
+
}
|
|
1525
1611
|
const cleared = {
|
|
1526
1612
|
id: latest.id,
|
|
1527
1613
|
projectId: latest.projectId,
|
|
@@ -1540,6 +1626,10 @@ export class AgentManager {
|
|
|
1540
1626
|
updatedAt: now,
|
|
1541
1627
|
};
|
|
1542
1628
|
});
|
|
1629
|
+
if (preservedNewerHold) {
|
|
1630
|
+
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} holds a newer generation than the captured one; keeping the hold`);
|
|
1631
|
+
return false;
|
|
1632
|
+
}
|
|
1543
1633
|
return true;
|
|
1544
1634
|
}
|
|
1545
1635
|
let sessionConfirmedAbsent = false;
|
|
@@ -1547,6 +1637,7 @@ export class AgentManager {
|
|
|
1547
1637
|
const releaseWorkdir = state.workdir;
|
|
1548
1638
|
const runner = this.createRunnerFor(cfg);
|
|
1549
1639
|
const tmux = new TmuxManager(runner);
|
|
1640
|
+
const holdMatchesExpected = (binding) => !opts.expectedHold || matchesExpectedHold(binding);
|
|
1550
1641
|
const hold = async (reason) => {
|
|
1551
1642
|
const latest = await this.agentStore.get(agentId);
|
|
1552
1643
|
if (!latest
|
|
@@ -1555,18 +1646,27 @@ export class AgentManager {
|
|
|
1555
1646
|
|| !await this.lockManager.isOwner(agentId, expectedTaskId, lockToken)) {
|
|
1556
1647
|
return false;
|
|
1557
1648
|
}
|
|
1649
|
+
// 释放中被写入的新 hold(如 ack_unknown)比 cleanup 失败信息更关键,不得覆盖
|
|
1650
|
+
if (!holdMatchesExpected(latest)) {
|
|
1651
|
+
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} hold generation changed during release; not overwriting with branch-cleanup-pending`);
|
|
1652
|
+
return false;
|
|
1653
|
+
}
|
|
1558
1654
|
const pendingAt = new Date().toISOString();
|
|
1559
1655
|
if (boundTask && cfg.role === 'dev') {
|
|
1560
1656
|
boundTask.branchCleanupPending = { agentId, reason, updatedAt: pendingAt };
|
|
1561
1657
|
boundTask.updatedAt = pendingAt;
|
|
1562
1658
|
await this.taskStore.set(boundTask);
|
|
1563
1659
|
}
|
|
1564
|
-
|
|
1565
|
-
|
|
1660
|
+
let wrote = false;
|
|
1661
|
+
await this.agentStore.update(agentId, (latest2) => {
|
|
1662
|
+
if (!latest2 || latest2.taskId !== expectedTaskId || latest2.lockToken !== lockToken) {
|
|
1566
1663
|
return AGENT_STORE_NOOP;
|
|
1567
1664
|
}
|
|
1665
|
+
if (!holdMatchesExpected(latest2))
|
|
1666
|
+
return AGENT_STORE_NOOP;
|
|
1667
|
+
wrote = true;
|
|
1568
1668
|
return {
|
|
1569
|
-
...
|
|
1669
|
+
...latest2,
|
|
1570
1670
|
status: 'awaiting_human',
|
|
1571
1671
|
awaitingPhase: 'branch-cleanup-pending',
|
|
1572
1672
|
awaitingReason: reason,
|
|
@@ -1574,6 +1674,10 @@ export class AgentManager {
|
|
|
1574
1674
|
updatedAt: pendingAt,
|
|
1575
1675
|
};
|
|
1576
1676
|
});
|
|
1677
|
+
if (!wrote) {
|
|
1678
|
+
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} branch-cleanup-pending hold not written (binding or hold changed)`);
|
|
1679
|
+
return false;
|
|
1680
|
+
}
|
|
1577
1681
|
await this.emitIntervention(state.projectId, agentId, expectedTaskId, {
|
|
1578
1682
|
phase: 'branch-cleanup-pending',
|
|
1579
1683
|
reason,
|
|
@@ -1608,6 +1712,14 @@ export class AgentManager {
|
|
|
1608
1712
|
sessionConfirmedAbsent = true;
|
|
1609
1713
|
}
|
|
1610
1714
|
await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
|
|
1715
|
+
// 探测/等待期间 markAwaitingHuman 不受本锁串行;进入 checkout 变更前按当前盘面重验世代
|
|
1716
|
+
if (opts.expectedHold) {
|
|
1717
|
+
const beforeBranchOps = await this.agentStore.get(agentId);
|
|
1718
|
+
if (!beforeBranchOps || !matchesExpectedHold(beforeBranchOps)) {
|
|
1719
|
+
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} hold generation changed before checkout cleanup; aborting release`);
|
|
1720
|
+
return false;
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1611
1723
|
if (cfg.role === 'dev' && boundTask) {
|
|
1612
1724
|
const branches = new BranchManager(runner);
|
|
1613
1725
|
const cleanup = await branches.cleanupTaskBranch(releaseWorkdir, {
|
|
@@ -1651,6 +1763,13 @@ export class AgentManager {
|
|
|
1651
1763
|
delete boundTask.branchCleanupSkipped;
|
|
1652
1764
|
cleanupStateChanged = true;
|
|
1653
1765
|
}
|
|
1766
|
+
if (cleanup.remoteTipSha) {
|
|
1767
|
+
boundTask.branchLocalCleaned = {
|
|
1768
|
+
remoteTipSha: cleanup.remoteTipSha,
|
|
1769
|
+
updatedAt: new Date().toISOString(),
|
|
1770
|
+
};
|
|
1771
|
+
cleanupStateChanged = true;
|
|
1772
|
+
}
|
|
1654
1773
|
}
|
|
1655
1774
|
if (cleanupStateChanged) {
|
|
1656
1775
|
boundTask.updatedAt = new Date().toISOString();
|
|
@@ -1677,6 +1796,7 @@ export class AgentManager {
|
|
|
1677
1796
|
else {
|
|
1678
1797
|
await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
|
|
1679
1798
|
}
|
|
1799
|
+
let holdGenerationChanged = false;
|
|
1680
1800
|
await this.agentStore.update(agentId, (existing) => {
|
|
1681
1801
|
if (!existing)
|
|
1682
1802
|
return AGENT_STORE_NOOP;
|
|
@@ -1685,6 +1805,11 @@ export class AgentManager {
|
|
|
1685
1805
|
|| (state.workdir !== undefined && existing.workdir !== state.workdir)) {
|
|
1686
1806
|
return AGENT_STORE_NOOP;
|
|
1687
1807
|
}
|
|
1808
|
+
// 世代 CAS 在原子写入内判定:预读后被重写或清除的 hold 都不得被这次释放清掉
|
|
1809
|
+
if (opts.expectedHold && !matchesExpectedHold(existing)) {
|
|
1810
|
+
holdGenerationChanged = true;
|
|
1811
|
+
return AGENT_STORE_NOOP;
|
|
1812
|
+
}
|
|
1688
1813
|
return {
|
|
1689
1814
|
id: existing.id,
|
|
1690
1815
|
projectId: existing.projectId,
|
|
@@ -1694,9 +1819,25 @@ export class AgentManager {
|
|
|
1694
1819
|
updatedAt: now,
|
|
1695
1820
|
};
|
|
1696
1821
|
});
|
|
1822
|
+
if (holdGenerationChanged) {
|
|
1823
|
+
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} hold generation changed during release; keeping the hold`);
|
|
1824
|
+
return false;
|
|
1825
|
+
}
|
|
1697
1826
|
return this.lockManager.releaseIfOwner(agentId, expectedTaskId, lockToken);
|
|
1698
1827
|
});
|
|
1699
1828
|
}
|
|
1829
|
+
async clearBranchLocalCleaned(taskId) {
|
|
1830
|
+
await this.withTaskLock(async () => {
|
|
1831
|
+
const fresh = await this.taskStore.get(taskId);
|
|
1832
|
+
if (!fresh?.branchLocalCleaned)
|
|
1833
|
+
return;
|
|
1834
|
+
delete fresh.branchLocalCleaned;
|
|
1835
|
+
fresh.updatedAt = new Date().toISOString();
|
|
1836
|
+
await this.taskStore.set(fresh);
|
|
1837
|
+
}).catch(err => {
|
|
1838
|
+
console.warn(`[AgentManager] clearing branchLocalCleaned(${taskId}) failed:`, err);
|
|
1839
|
+
});
|
|
1840
|
+
}
|
|
1700
1841
|
async releaseAgentIfBound(agentId, expectedTaskId, opts) {
|
|
1701
1842
|
const state = await this.agentStore.get(agentId);
|
|
1702
1843
|
if (state?.taskId !== expectedTaskId)
|
|
@@ -1908,14 +2049,20 @@ export class AgentManager {
|
|
|
1908
2049
|
this.phaseSignalWatcher?.rearmNeedInput(agentId);
|
|
1909
2050
|
await this.setAgentNeedInput(agentId, false);
|
|
1910
2051
|
}
|
|
1911
|
-
async clearAwaitingHuman(agentId) {
|
|
2052
|
+
async clearAwaitingHuman(agentId, opts = {}) {
|
|
1912
2053
|
const now = new Date().toISOString();
|
|
1913
2054
|
let cleared = false;
|
|
1914
2055
|
await this.agentStore.update(agentId, (latest) => {
|
|
1915
2056
|
if (!latest || latest.status !== 'awaiting_human')
|
|
1916
2057
|
return AGENT_STORE_NOOP;
|
|
2058
|
+
if (opts.expectedHold
|
|
2059
|
+
&& (latest.awaitingPhase !== opts.expectedHold.phase
|
|
2060
|
+
|| latest.awaitingSince !== opts.expectedHold.since
|
|
2061
|
+
|| latest.awaitingNonce !== opts.expectedHold.nonce)) {
|
|
2062
|
+
return AGENT_STORE_NOOP;
|
|
2063
|
+
}
|
|
1917
2064
|
cleared = true;
|
|
1918
|
-
const { status: _status, awaitingPhase: _awaitingPhase, awaitingReason: _awaitingReason, awaitingSince: _awaitingSince, ...readyState } = latest;
|
|
2065
|
+
const { status: _status, awaitingPhase: _awaitingPhase, awaitingReason: _awaitingReason, awaitingSince: _awaitingSince, awaitingNonce: _awaitingNonce, ...readyState } = latest;
|
|
1919
2066
|
return {
|
|
1920
2067
|
...readyState,
|
|
1921
2068
|
updatedAt: now,
|
|
@@ -1931,6 +2078,16 @@ export class AgentManager {
|
|
|
1931
2078
|
await this.agentStore.update(agentId, (existing) => {
|
|
1932
2079
|
if (!existing)
|
|
1933
2080
|
return AGENT_STORE_NOOP;
|
|
2081
|
+
if (opts.expectedHold) {
|
|
2082
|
+
const heldPhase = existing.status === 'awaiting_human' ? existing.awaitingPhase : undefined;
|
|
2083
|
+
const heldSince = existing.status === 'awaiting_human' ? existing.awaitingSince : undefined;
|
|
2084
|
+
const heldNonce = existing.status === 'awaiting_human' ? existing.awaitingNonce : undefined;
|
|
2085
|
+
if (heldPhase !== opts.expectedHold.phase
|
|
2086
|
+
|| heldSince !== opts.expectedHold.since
|
|
2087
|
+
|| heldNonce !== opts.expectedHold.nonce) {
|
|
2088
|
+
return AGENT_STORE_NOOP;
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
1934
2091
|
if (opts.expectedCreationToken !== undefined) {
|
|
1935
2092
|
const expected = opts.expectedCreationToken;
|
|
1936
2093
|
const actual = existing.creationToken ?? null;
|
|
@@ -1943,6 +2100,10 @@ export class AgentManager {
|
|
|
1943
2100
|
if (actualTask !== expectedTask)
|
|
1944
2101
|
return AGENT_STORE_NOOP;
|
|
1945
2102
|
}
|
|
2103
|
+
// 锁代 CAS:绑定被 successor re-acquire 后不得把工作中的 agent 挂回旧 hold
|
|
2104
|
+
if (opts.expectedLockToken !== undefined && existing.lockToken !== opts.expectedLockToken) {
|
|
2105
|
+
return AGENT_STORE_NOOP;
|
|
2106
|
+
}
|
|
1946
2107
|
if (isCancelCleanupHold(existing)) {
|
|
1947
2108
|
if (!CANCEL_CLEANUP_HOLD_PHASES.has(phase))
|
|
1948
2109
|
return AGENT_STORE_NOOP;
|
|
@@ -1959,15 +2120,17 @@ export class AgentManager {
|
|
|
1959
2120
|
awaitingPhase: phase,
|
|
1960
2121
|
awaitingReason: reason,
|
|
1961
2122
|
awaitingSince: now,
|
|
2123
|
+
awaitingNonce: randomBytes(8).toString('hex'),
|
|
1962
2124
|
updatedAt: now,
|
|
1963
2125
|
};
|
|
1964
2126
|
});
|
|
1965
2127
|
if (!wrote)
|
|
1966
|
-
return;
|
|
2128
|
+
return false;
|
|
1967
2129
|
await this.emitIntervention(projectId, agentId, taskId, {
|
|
1968
2130
|
phase,
|
|
1969
2131
|
reason,
|
|
1970
2132
|
});
|
|
2133
|
+
return true;
|
|
1971
2134
|
}
|
|
1972
2135
|
async markAwaitingIfAckUnknown(agentId, err, expectedTaskId) {
|
|
1973
2136
|
if (err instanceof DispatchTerminalError && err.reason === 'ack_unknown') {
|
|
@@ -2018,7 +2181,69 @@ export class AgentManager {
|
|
|
2018
2181
|
return { resumed: false, releasedBinding: false, reason };
|
|
2019
2182
|
}
|
|
2020
2183
|
if ((state.awaitingPhase === 'dirty-workdir' || state.awaitingPhase === 'checkout-preparation-failed')
|
|
2021
|
-
&& boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)
|
|
2184
|
+
&& boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)
|
|
2185
|
+
// verdict 门禁状态没有在途派发:清掉 hold 后由 spec verdict 重新走完整转码,Resume 放行
|
|
2186
|
+
&& boundTask.status !== 'spec-ready' && boundTask.status !== 'max_rounds') {
|
|
2187
|
+
if (state.taskId && boundTask.qaAgentId === agentId) {
|
|
2188
|
+
// 停驻的 review pass 已死(任务离开 review):重派会伪造新 pass,只清 hold 并释放绑定;
|
|
2189
|
+
// 快照 hold/task 世代供释放路径 CAS,且不得借用 cancel-cleanup 的绕行凭据
|
|
2190
|
+
if (boundTask.status !== 'review') {
|
|
2191
|
+
return {
|
|
2192
|
+
resumed: true,
|
|
2193
|
+
releasedBinding: false,
|
|
2194
|
+
releaseHeldQa: {
|
|
2195
|
+
taskId: state.taskId,
|
|
2196
|
+
expectedHold: {
|
|
2197
|
+
phase: state.awaitingPhase,
|
|
2198
|
+
since: state.awaitingSince,
|
|
2199
|
+
nonce: state.awaitingNonce,
|
|
2200
|
+
},
|
|
2201
|
+
expectedTask: {
|
|
2202
|
+
status: boundTask.status,
|
|
2203
|
+
phase: boundTask.phase,
|
|
2204
|
+
signalToken: boundTask.signalToken,
|
|
2205
|
+
},
|
|
2206
|
+
},
|
|
2207
|
+
projectId: state.projectId,
|
|
2208
|
+
previousPhase: state.awaitingPhase,
|
|
2209
|
+
};
|
|
2210
|
+
}
|
|
2211
|
+
const nowQa = new Date().toISOString();
|
|
2212
|
+
let clearedHold = false;
|
|
2213
|
+
await this.agentStore.update(agentId, (existing) => {
|
|
2214
|
+
if (!existing || existing.taskId !== state.taskId)
|
|
2215
|
+
return AGENT_STORE_NOOP;
|
|
2216
|
+
if (existing.awaitingPhase !== state.awaitingPhase
|
|
2217
|
+
|| existing.awaitingSince !== state.awaitingSince
|
|
2218
|
+
|| existing.awaitingNonce !== state.awaitingNonce) {
|
|
2219
|
+
return AGENT_STORE_NOOP;
|
|
2220
|
+
}
|
|
2221
|
+
clearedHold = true;
|
|
2222
|
+
return {
|
|
2223
|
+
...existing,
|
|
2224
|
+
status: 'ok',
|
|
2225
|
+
awaitingPhase: undefined,
|
|
2226
|
+
awaitingReason: undefined,
|
|
2227
|
+
awaitingSince: undefined,
|
|
2228
|
+
awaitingNonce: undefined,
|
|
2229
|
+
updatedAt: nowQa,
|
|
2230
|
+
};
|
|
2231
|
+
});
|
|
2232
|
+
if (!clearedHold) {
|
|
2233
|
+
const reason = `Agent ${agentId} hold changed while resuming; re-check the agent state and Resume again.`;
|
|
2234
|
+
console.warn(`[AgentManager] resumeAgent: ${reason}`);
|
|
2235
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
2236
|
+
}
|
|
2237
|
+
return {
|
|
2238
|
+
resumed: true,
|
|
2239
|
+
releasedBinding: false,
|
|
2240
|
+
redispatchReviewTaskId: state.taskId,
|
|
2241
|
+
reviewPassToken: boundTask.signalToken,
|
|
2242
|
+
heldLockToken: state.lockToken,
|
|
2243
|
+
projectId: state.projectId,
|
|
2244
|
+
previousPhase: state.awaitingPhase,
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
2022
2247
|
const reason = `Task ${state.taskId} was not dispatched because its checkout could not be prepared. ` +
|
|
2023
2248
|
'Fix the Workdir, then cancel and redispatch the task; Resume cannot reconstruct the original dispatch safely.';
|
|
2024
2249
|
console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${state.awaitingPhase} — ${reason}`);
|
|
@@ -2117,7 +2342,7 @@ export class AgentManager {
|
|
|
2117
2342
|
if (result.redispatchCodeTaskId) {
|
|
2118
2343
|
try {
|
|
2119
2344
|
const task = await this.taskStore.get(result.redispatchCodeTaskId);
|
|
2120
|
-
const round = task
|
|
2345
|
+
const round = task ? (task.specReviewRound ?? 1) : undefined;
|
|
2121
2346
|
const stored = round === undefined
|
|
2122
2347
|
? null
|
|
2123
2348
|
: await this.reviewStore?.getRound(result.redispatchCodeTaskId, 'spec', round);
|
|
@@ -2131,6 +2356,12 @@ export class AgentManager {
|
|
|
2131
2356
|
await this.markAwaitingHuman(agentId, 'code-dispatch-failed', reason, { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
|
|
2132
2357
|
return { resumed: false, releasedBinding: false, reason };
|
|
2133
2358
|
}
|
|
2359
|
+
// 重派与首派同样不经 startSession 的 post-ack finalize:残留的 bootstrap 标记
|
|
2360
|
+
// 会让下一次 recover 把已送达的 code prompt 再判成未送达
|
|
2361
|
+
if (!this.isResearchHandoff(task, agentId)) {
|
|
2362
|
+
const lockToken = (await this.agentStore.get(agentId))?.lockToken;
|
|
2363
|
+
await this.finalizeReplayedBootstrap(agentId, result.redispatchCodeTaskId, 'code', lockToken);
|
|
2364
|
+
}
|
|
2134
2365
|
}
|
|
2135
2366
|
catch (err) {
|
|
2136
2367
|
console.error(`[AgentManager] resumeAgent code redispatch failed for ${agentId}:`, err);
|
|
@@ -2139,6 +2370,126 @@ export class AgentManager {
|
|
|
2139
2370
|
return { resumed: false, releasedBinding: false, reason };
|
|
2140
2371
|
}
|
|
2141
2372
|
}
|
|
2373
|
+
if (result.releaseHeldQa) {
|
|
2374
|
+
const { taskId: heldTaskId, expectedHold, expectedTask } = result.releaseHeldQa;
|
|
2375
|
+
const released = await this.releaseAgentForTask(agentId, heldTaskId, 'idle', {
|
|
2376
|
+
allowAwaitingHuman: true,
|
|
2377
|
+
expectedHold,
|
|
2378
|
+
expectedTask,
|
|
2379
|
+
});
|
|
2380
|
+
if (!released) {
|
|
2381
|
+
const refreshed = await this.agentStore.get(agentId).catch((readErr) => {
|
|
2382
|
+
console.warn(`[AgentManager] resumeAgent post-release read failed for ${agentId}:`, readErr);
|
|
2383
|
+
return null;
|
|
2384
|
+
});
|
|
2385
|
+
return {
|
|
2386
|
+
resumed: false,
|
|
2387
|
+
releasedBinding: false,
|
|
2388
|
+
reason: refreshed?.awaitingReason
|
|
2389
|
+
?? `Agent ${agentId} could not safely release task ${heldTaskId}; its pass or hold moved on — re-check the agent state and Resume again.`,
|
|
2390
|
+
};
|
|
2391
|
+
}
|
|
2392
|
+
await this.emitIntervention(result.projectId, agentId, heldTaskId, {
|
|
2393
|
+
phase: 'resumed',
|
|
2394
|
+
previousPhase: result.previousPhase,
|
|
2395
|
+
releasedBinding: true,
|
|
2396
|
+
});
|
|
2397
|
+
return { resumed: true, releasedBinding: true };
|
|
2398
|
+
}
|
|
2399
|
+
if (result.redispatchReviewTaskId) {
|
|
2400
|
+
try {
|
|
2401
|
+
await this.dispatchReviewToQa(result.redispatchReviewTaskId, {
|
|
2402
|
+
bumpRound: false,
|
|
2403
|
+
fromStatus: ['review'],
|
|
2404
|
+
...(result.reviewPassToken !== undefined ? { expectSignalToken: result.reviewPassToken } : {}),
|
|
2405
|
+
});
|
|
2406
|
+
}
|
|
2407
|
+
catch (err) {
|
|
2408
|
+
console.error(`[AgentManager] resumeAgent review redispatch failed for ${agentId}:`, err);
|
|
2409
|
+
const reason = `QA review redispatch on Resume failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
2410
|
+
const reviewTaskId = result.redispatchReviewTaskId;
|
|
2411
|
+
// 锁代未变(未被 successor re-acquire)才允许补挂;markAwaitingHuman 内的 CAS 原子复核同一条件
|
|
2412
|
+
const restoreHoldIfStillOurs = async (phase, holdReason) => {
|
|
2413
|
+
const after = await this.agentStore.get(agentId);
|
|
2414
|
+
if (after?.taskId === reviewTaskId
|
|
2415
|
+
&& after.status !== 'awaiting_human'
|
|
2416
|
+
&& after.lockToken === result.heldLockToken) {
|
|
2417
|
+
await this.markAwaitingHuman(agentId, phase, holdReason, {
|
|
2418
|
+
expectedTaskId: reviewTaskId,
|
|
2419
|
+
...(result.heldLockToken !== undefined ? { expectedLockToken: result.heldLockToken } : {}),
|
|
2420
|
+
});
|
|
2421
|
+
}
|
|
2422
|
+
};
|
|
2423
|
+
// handled 只代表 startSession 已尝试落 hold;落库可能已失败,必须核实并按需补挂
|
|
2424
|
+
if (err instanceof EnsureSessionError && err.partial.handled) {
|
|
2425
|
+
try {
|
|
2426
|
+
await restoreHoldIfStillOurs(result.previousPhase ?? 'checkout-preparation-failed', reason);
|
|
2427
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
2428
|
+
}
|
|
2429
|
+
catch (holdErr) {
|
|
2430
|
+
const holdMsg = holdErr instanceof Error ? holdErr.message : String(holdErr);
|
|
2431
|
+
console.error(`[AgentManager] resumeAgent could not verify/restore the handled hold for ${agentId}:`, holdErr);
|
|
2432
|
+
return {
|
|
2433
|
+
resumed: false,
|
|
2434
|
+
releasedBinding: false,
|
|
2435
|
+
reason: `${reason}; verifying/restoring the hold also failed: ${holdMsg} — the agent may be missing its awaiting_human flag, inspect it manually`,
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
// ack_unknown:prompt 可能已在跑,必须恢复 ack hold 的可见性,而非按普通失败补挂 checkout hold
|
|
2440
|
+
if (err instanceof DispatchTerminalError && err.reason === 'ack_unknown') {
|
|
2441
|
+
try {
|
|
2442
|
+
await restoreHoldIfStillOurs('dispatch-failed:ack_unknown', `${err.message}. Prompt may still be running in the pane; verify before resuming.`);
|
|
2443
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
2444
|
+
}
|
|
2445
|
+
catch (holdErr) {
|
|
2446
|
+
const holdMsg = holdErr instanceof Error ? holdErr.message : String(holdErr);
|
|
2447
|
+
console.error(`[AgentManager] resumeAgent could not restore the ack_unknown hold for ${agentId}:`, holdErr);
|
|
2448
|
+
return {
|
|
2449
|
+
resumed: false,
|
|
2450
|
+
releasedBinding: false,
|
|
2451
|
+
reason: `${reason}; restoring the dispatch-failed:ack_unknown hold also failed: ${holdMsg} — prompt state is unknown and the agent is NOT flagged awaiting_human, inspect it manually`,
|
|
2452
|
+
};
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
// pass 被接管(token 轮换或任务离开 review)说明该 pass 已死,补挂会把新 pass 的 QA 打成 hold
|
|
2456
|
+
let passSuperseded = false;
|
|
2457
|
+
let recoveryReadFailed = false;
|
|
2458
|
+
try {
|
|
2459
|
+
const taskNow = await this.taskStore.get(reviewTaskId);
|
|
2460
|
+
passSuperseded = taskNow?.status !== 'review'
|
|
2461
|
+
|| (result.reviewPassToken !== undefined && taskNow.signalToken !== result.reviewPassToken);
|
|
2462
|
+
}
|
|
2463
|
+
catch (readErr) {
|
|
2464
|
+
recoveryReadFailed = true;
|
|
2465
|
+
console.error(`[AgentManager] resumeAgent recovery task read failed for ${agentId}:`, readErr);
|
|
2466
|
+
}
|
|
2467
|
+
if (passSuperseded)
|
|
2468
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
2469
|
+
try {
|
|
2470
|
+
if (recoveryReadFailed) {
|
|
2471
|
+
// 读失败时无法证明接管,优先恢复可见性:锁代/绑定 CAS 全部由 markAwaitingHuman 原子判定
|
|
2472
|
+
await this.markAwaitingHuman(agentId, result.previousPhase ?? 'checkout-preparation-failed', reason, {
|
|
2473
|
+
expectedTaskId: reviewTaskId,
|
|
2474
|
+
...(result.heldLockToken !== undefined ? { expectedLockToken: result.heldLockToken } : {}),
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
else {
|
|
2478
|
+
await restoreHoldIfStillOurs(result.previousPhase ?? 'checkout-preparation-failed', reason);
|
|
2479
|
+
}
|
|
2480
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
2481
|
+
}
|
|
2482
|
+
catch (holdErr) {
|
|
2483
|
+
const holdMsg = holdErr instanceof Error ? holdErr.message : String(holdErr);
|
|
2484
|
+
console.error(`[AgentManager] resumeAgent could not restore the QA hold for ${agentId}:`, holdErr);
|
|
2485
|
+
return {
|
|
2486
|
+
resumed: false,
|
|
2487
|
+
releasedBinding: false,
|
|
2488
|
+
reason: `${reason}; restoring the hold also failed: ${holdMsg} — the agent is NOT flagged awaiting_human, inspect it manually`,
|
|
2489
|
+
};
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2142
2493
|
return {
|
|
2143
2494
|
resumed: result.resumed,
|
|
2144
2495
|
releasedBinding: result.releasedBinding,
|
|
@@ -2495,41 +2846,360 @@ export class AgentManager {
|
|
|
2495
2846
|
};
|
|
2496
2847
|
});
|
|
2497
2848
|
}
|
|
2498
|
-
|
|
2849
|
+
// Holds only the pass captured in the task tuple: a successor pass (rotated token/phase/status) is never overwritten.
|
|
2850
|
+
async holdReplayFailureIfCurrent(agentId, taskAtEntry, holdPhase, reason, entryHold) {
|
|
2851
|
+
return this.withTaskLock(async () => {
|
|
2852
|
+
const fresh = await this.taskStore.get(taskAtEntry.id);
|
|
2853
|
+
if (fresh?.agentId !== agentId
|
|
2854
|
+
|| fresh.status !== taskAtEntry.status
|
|
2855
|
+
|| fresh.phase !== taskAtEntry.phase
|
|
2856
|
+
|| fresh.signalToken !== taskAtEntry.signalToken)
|
|
2857
|
+
return false;
|
|
2858
|
+
if (await this.markAwaitingHuman(agentId, holdPhase, reason, {
|
|
2859
|
+
expectedTaskId: taskAtEntry.id,
|
|
2860
|
+
expectedHold: entryHold,
|
|
2861
|
+
}))
|
|
2862
|
+
return true;
|
|
2863
|
+
// Entry hold gone (Resume raced): write over the empty generation, never over a foreign hold.
|
|
2864
|
+
if (await this.markAwaitingHuman(agentId, holdPhase, reason, {
|
|
2865
|
+
expectedTaskId: taskAtEntry.id,
|
|
2866
|
+
expectedHold: { phase: undefined, since: undefined, nonce: undefined },
|
|
2867
|
+
}))
|
|
2868
|
+
return true;
|
|
2869
|
+
const now = await this.agentStore.get(agentId);
|
|
2870
|
+
return now?.status === 'awaiting_human';
|
|
2871
|
+
});
|
|
2872
|
+
}
|
|
2873
|
+
async redispatchTaskPromptAfterReplRestart(agentId, taskId) {
|
|
2499
2874
|
const agent = this.getAgentConfig(agentId);
|
|
2500
2875
|
const task = await this.taskStore.get(taskId);
|
|
2501
|
-
if (!agent ||
|
|
2876
|
+
if (!agent || !task || !TASK_OWNER_ROLES.has(agent.role))
|
|
2877
|
+
return false;
|
|
2878
|
+
if (task.agentId !== agentId)
|
|
2502
2879
|
return false;
|
|
2503
|
-
|
|
2880
|
+
const participantId = agent.role === 'research' ? task.researchAgentId : task.devAgentId;
|
|
2881
|
+
if (participantId !== agentId)
|
|
2882
|
+
return false;
|
|
2883
|
+
const bindingAtEntry = await this.agentStore.get(agentId);
|
|
2884
|
+
const entryHold = {
|
|
2885
|
+
phase: bindingAtEntry?.status === 'awaiting_human' ? bindingAtEntry.awaitingPhase : undefined,
|
|
2886
|
+
since: bindingAtEntry?.status === 'awaiting_human' ? bindingAtEntry.awaitingSince : undefined,
|
|
2887
|
+
nonce: bindingAtEntry?.status === 'awaiting_human' ? bindingAtEntry.awaitingNonce : undefined,
|
|
2888
|
+
};
|
|
2889
|
+
// Concurrent passes can advance the task mid-replay; every replay gate re-checks this snapshot.
|
|
2890
|
+
const taskStillCurrent = async () => {
|
|
2891
|
+
const fresh = await this.taskStore.get(taskId);
|
|
2892
|
+
return fresh?.agentId === agentId
|
|
2893
|
+
&& fresh.status === task.status
|
|
2894
|
+
&& fresh.phase === task.phase
|
|
2895
|
+
&& fresh.signalToken === task.signalToken;
|
|
2896
|
+
};
|
|
2897
|
+
// Hold ops share one task-mutation slot with a tuple re-check, and CAS on the entry hold generation.
|
|
2898
|
+
const clearEntryHold = async () => this.withTaskLock(async () => {
|
|
2899
|
+
if (!(await taskStillCurrent()))
|
|
2900
|
+
return false;
|
|
2901
|
+
if (entryHold.phase === undefined) {
|
|
2902
|
+
const now = await this.agentStore.get(agentId);
|
|
2903
|
+
return now?.status !== 'awaiting_human';
|
|
2904
|
+
}
|
|
2905
|
+
return this.clearAwaitingHuman(agentId, { expectedHold: entryHold });
|
|
2906
|
+
});
|
|
2907
|
+
const holdReplayFailure = (phase, reason) => this.holdReplayFailureIfCurrent(agentId, task, phase, reason, entryHold);
|
|
2908
|
+
// skipSnapshot: stale scrollback literals belong to the aborted runtime; onlyReplaceOwnToken keeps a successor's watcher out of reach.
|
|
2909
|
+
const fencedReplay = (expectedKinds, token, stillCurrent) => {
|
|
2910
|
+
const abortStaleReplay = async () => {
|
|
2911
|
+
if (await stillCurrent())
|
|
2912
|
+
return true;
|
|
2913
|
+
this.phaseSignalWatcher?.stopIfToken(taskId, token);
|
|
2914
|
+
return false;
|
|
2915
|
+
};
|
|
2916
|
+
return {
|
|
2917
|
+
armBeforeInject: async () => {
|
|
2918
|
+
if (!(await stillCurrent()))
|
|
2919
|
+
return false;
|
|
2920
|
+
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, {
|
|
2921
|
+
skipSnapshot: true,
|
|
2922
|
+
onlyReplaceOwnToken: true,
|
|
2923
|
+
// git 双 watcher 下 fence 只应比较自己的 (taskId, agentId) 条目:task 域会拿
|
|
2924
|
+
// sibling(reconciliation)的不同 token 否决本应合法的重布防
|
|
2925
|
+
...(task.reviewMode === 'git' ? { replaceScope: 'agent' } : {}),
|
|
2926
|
+
});
|
|
2927
|
+
if (!armed) {
|
|
2928
|
+
if (!(await stillCurrent()))
|
|
2929
|
+
return false;
|
|
2930
|
+
// Own-generation arm failure: throwing routes the caller into the resumable hold.
|
|
2931
|
+
throw new Error(`replay signal watcher failed to arm for task ${taskId}; Restart REPL again or cancel the task`);
|
|
2932
|
+
}
|
|
2933
|
+
return abortStaleReplay();
|
|
2934
|
+
},
|
|
2935
|
+
guardBeforeInject: abortStaleReplay,
|
|
2936
|
+
};
|
|
2937
|
+
};
|
|
2938
|
+
try {
|
|
2939
|
+
if (task.status === 'approved' && agent.role === 'dev') {
|
|
2940
|
+
return await this.replayApprovedHolderAfterReplRestart(agentId, task, taskStillCurrent, fencedReplay, { clearEntryHold, holdReplayFailure });
|
|
2941
|
+
}
|
|
2942
|
+
// reconciliation 条目不是 replay 目标:它没有对应的可重放 prompt,误选会以 QA 轮换 token
|
|
2943
|
+
// 重注 dev 的 publish 提示(spec §5.3 ④ 只允许一次 actor 归位)
|
|
2944
|
+
const mapped = this.mapTaskStateToExpectedWatcher(task);
|
|
2945
|
+
if (!mapped || mapped.agentId !== agentId)
|
|
2946
|
+
return false;
|
|
2947
|
+
if (!task.signalToken) {
|
|
2948
|
+
return await holdReplayFailure('restart-redispatch-failed', 'REPL restarted mid-pass but the task has no signal token to replay the prompt with; cancel the task or re-dispatch it.');
|
|
2949
|
+
}
|
|
2950
|
+
const signalToken = task.signalToken;
|
|
2951
|
+
const replayOpts = {
|
|
2952
|
+
signalToken,
|
|
2953
|
+
preserveDispatchOutputs: true,
|
|
2954
|
+
expectedHold: { phase: undefined, since: undefined, nonce: undefined },
|
|
2955
|
+
...fencedReplay(mapped.expectedKinds, signalToken, taskStillCurrent),
|
|
2956
|
+
};
|
|
2957
|
+
const replayInitialBootstrap = async (phase, opts) => {
|
|
2958
|
+
// This hold means the prompt WAS delivered and only the marker clear failed; replaying would run it twice.
|
|
2959
|
+
if (entryHold.phase === 'bootstrap-marker-clear-failed') {
|
|
2960
|
+
return holdReplayFailure('restart-redispatch-failed', 'REPL restarted while the initial prompt was already delivered (its bootstrap marker clear had failed); replaying would dispatch the task twice. Resume and verify the Workdir, or cancel the task.');
|
|
2961
|
+
}
|
|
2962
|
+
const bindingBefore = await this.agentStore.get(agentId);
|
|
2963
|
+
if (!(await clearEntryHold()))
|
|
2964
|
+
return false;
|
|
2965
|
+
const resumed = await this.continueSession(taskId, agentId, phase, opts);
|
|
2966
|
+
if (resumed && bindingBefore?.bootstrappingTaskId === taskId) {
|
|
2967
|
+
await this.finalizeReplayedBootstrap(agentId, taskId, phase, bindingBefore.lockToken);
|
|
2968
|
+
}
|
|
2969
|
+
return resumed;
|
|
2970
|
+
};
|
|
2971
|
+
if (task.status === 'in_progress' && task.phase === 'research' && agent.role === 'research') {
|
|
2972
|
+
return await replayInitialBootstrap('research', replayOpts);
|
|
2973
|
+
}
|
|
2974
|
+
if (task.status === 'in_progress' && task.phase === undefined && agent.role === 'dev') {
|
|
2975
|
+
// An undelivered bootstrap never started work, so the fresh-dispatch clean gate still applies.
|
|
2976
|
+
const delivered = (await this.agentStore.get(agentId))?.bootstrappingTaskId !== taskId;
|
|
2977
|
+
return await replayInitialBootstrap('develop', {
|
|
2978
|
+
...replayOpts,
|
|
2979
|
+
...(delivered ? { allowDirtyWorkdir: true } : {}),
|
|
2980
|
+
});
|
|
2981
|
+
}
|
|
2982
|
+
if (task.status === 'in_progress' && task.phase === 'code' && agent.role === 'dev') {
|
|
2983
|
+
const state = await this.agentStore.get(agentId);
|
|
2984
|
+
// A no-QA auto-approved spec never writes specReviewRound back; the code dispatch defaults to round 1.
|
|
2985
|
+
const round = task.specReviewRound ?? 1;
|
|
2986
|
+
const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
|
|
2987
|
+
if (state?.bootstrappingTaskId === taskId || !stored || stored.phase !== 'spec') {
|
|
2988
|
+
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.');
|
|
2989
|
+
}
|
|
2990
|
+
if (!(await clearEntryHold()))
|
|
2991
|
+
return false;
|
|
2992
|
+
return await this.continueSession(taskId, agentId, 'code', {
|
|
2993
|
+
...replayOpts,
|
|
2994
|
+
allowDirtyWorkdir: true,
|
|
2995
|
+
specDocuments: stored.documents,
|
|
2996
|
+
...(task.specReviewRound !== undefined ? { currentSpecRound: task.specReviewRound } : {}),
|
|
2997
|
+
});
|
|
2998
|
+
}
|
|
2999
|
+
if (task.status === 'fixing' && task.phase === 'spec') {
|
|
3000
|
+
const round = task.specReviewRound ?? 1;
|
|
3001
|
+
const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
|
|
3002
|
+
if (!stored?.findings) {
|
|
3003
|
+
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.`);
|
|
3004
|
+
}
|
|
3005
|
+
if (!(await clearEntryHold()))
|
|
3006
|
+
return false;
|
|
3007
|
+
return await this.continueSession(taskId, agentId, 'server-feedback', {
|
|
3008
|
+
...replayOpts,
|
|
3009
|
+
...(agent.role === 'dev' ? { allowDirtyWorkdir: true } : {}),
|
|
3010
|
+
serverPriorFindings: JSON.stringify(stored.findings),
|
|
3011
|
+
...(task.specReviewRound !== undefined ? { currentSpecRound: task.specReviewRound } : {}),
|
|
3012
|
+
});
|
|
3013
|
+
}
|
|
3014
|
+
// A direct (no-SDD) pass never persists a phase, so fixing + undefined is the plain code-feedback round.
|
|
3015
|
+
if (task.status === 'fixing'
|
|
3016
|
+
&& (task.phase === 'code' || task.phase === undefined)
|
|
3017
|
+
&& agent.role === 'dev') {
|
|
3018
|
+
if (task.reviewMode === 'server') {
|
|
3019
|
+
const round = Math.max(task.reviewRound, 1);
|
|
3020
|
+
const stored = await this.reviewStore?.getRound(taskId, 'code', round);
|
|
3021
|
+
if (!stored?.findings) {
|
|
3022
|
+
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.`);
|
|
3023
|
+
}
|
|
3024
|
+
if (!(await clearEntryHold()))
|
|
3025
|
+
return false;
|
|
3026
|
+
return await this.continueSession(taskId, agentId, 'server-feedback', {
|
|
3027
|
+
...replayOpts,
|
|
3028
|
+
allowDirtyWorkdir: true,
|
|
3029
|
+
serverPriorFindings: JSON.stringify(stored.findings),
|
|
3030
|
+
});
|
|
3031
|
+
}
|
|
3032
|
+
if (!(await clearEntryHold()))
|
|
3033
|
+
return false;
|
|
3034
|
+
return await this.continueSession(taskId, agentId, 'fix', {
|
|
3035
|
+
...replayOpts,
|
|
3036
|
+
allowDirtyWorkdir: true,
|
|
3037
|
+
});
|
|
3038
|
+
}
|
|
2504
3039
|
return false;
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
3040
|
+
}
|
|
3041
|
+
catch (err) {
|
|
3042
|
+
// A post-entry throw may land after clearEntryHold; only a tuple-checked hold on the live generation is safe.
|
|
3043
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3044
|
+
if (await holdReplayFailure('restart-redispatch-failed', `REPL restarted but replaying the task prompt failed: ${message}. Resume to retry, or cancel the task.`)) {
|
|
3045
|
+
return true;
|
|
3046
|
+
}
|
|
3047
|
+
throw err;
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
// approved hosts two live holder passes — GitHub post-approve (PostApproveStore) and server after-done (task fields); a parked pre-publish holder stays on the waiting path.
|
|
3051
|
+
async replayApprovedHolderAfterReplRestart(agentId, task, taskStillCurrent, fencedReplay, holdGen) {
|
|
3052
|
+
const taskId = task.id;
|
|
3053
|
+
const { clearEntryHold, holdReplayFailure } = holdGen;
|
|
3054
|
+
if (task.reviewMode === 'server') {
|
|
3055
|
+
if (!task.publishDispatchedAt)
|
|
3056
|
+
return false;
|
|
3057
|
+
if (!task.signalToken) {
|
|
3058
|
+
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.');
|
|
3059
|
+
}
|
|
3060
|
+
const token = task.signalToken;
|
|
3061
|
+
const afterDone = this.resolveAfterDone(task);
|
|
3062
|
+
if (!afterDone) {
|
|
3063
|
+
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.');
|
|
3064
|
+
}
|
|
3065
|
+
let violation;
|
|
3066
|
+
try {
|
|
3067
|
+
violation = await this.findLineageViolation(taskId);
|
|
3068
|
+
}
|
|
3069
|
+
catch (err) {
|
|
3070
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3071
|
+
return holdReplayFailure('restart-redispatch-failed', `REPL restarted mid-publish but the branch lineage re-check failed: ${message}. Resume then Restart REPL to retry, or cancel the task.`);
|
|
3072
|
+
}
|
|
3073
|
+
if (violation) {
|
|
3074
|
+
return holdReplayFailure('restart-redispatch-failed', `The task branch embeds another active task's commits (${violation.branch}); rebase onto origin/HEAD, then mark-complete to retry the publish.`);
|
|
3075
|
+
}
|
|
3076
|
+
if (!(await clearEntryHold()))
|
|
2516
3077
|
return false;
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
3078
|
+
return this.continueSession(taskId, agentId, 'server-after-done', {
|
|
3079
|
+
signalToken: token,
|
|
3080
|
+
preserveDispatchOutputs: true,
|
|
3081
|
+
expectedHold: { phase: undefined, since: undefined, nonce: undefined },
|
|
3082
|
+
allowDirtyWorkdir: true,
|
|
3083
|
+
serverAfterDone: { kind: afterDone, branch: task.branch ?? BRANCH_PREFIX + taskId },
|
|
3084
|
+
...fencedReplay(['code-ready'], token, taskStillCurrent),
|
|
3085
|
+
});
|
|
3086
|
+
}
|
|
3087
|
+
if (task.postApproveRevoked) {
|
|
3088
|
+
return holdReplayFailure('restart-redispatch-failed', `The post-approve completion was deliberately revoked (${task.postApproveRevoked.reason}); ` +
|
|
3089
|
+
'the block stays effective, so the pass is not replayed. Re-request changes to restart the review round, or cancel the task.');
|
|
3090
|
+
}
|
|
3091
|
+
const stored = await this.getPostApproveCompletion(taskId);
|
|
3092
|
+
let completion;
|
|
3093
|
+
if (stored) {
|
|
3094
|
+
if (!task.postApproveHeadSha) {
|
|
3095
|
+
return holdReplayFailure('restart-redispatch-failed', 'REPL restarted during the post-approve pass but the stored completion cannot be tied to a persisted approved head; re-request the review verdict or cancel the task.');
|
|
3096
|
+
}
|
|
3097
|
+
if (stored.approvedHeadSha !== task.postApproveHeadSha) {
|
|
3098
|
+
// Provably a dead episode's residue: retire it by token, then rebuild for the current head.
|
|
3099
|
+
await this.clearPostApproveCompletionIfMatches(taskId, stored.token);
|
|
3100
|
+
completion = await this.rebuildPostApproveCompletion(agentId, task, holdReplayFailure);
|
|
3101
|
+
}
|
|
3102
|
+
else {
|
|
3103
|
+
completion = { token: stored.token, rebuilt: false };
|
|
3104
|
+
}
|
|
2520
3105
|
}
|
|
2521
3106
|
else {
|
|
3107
|
+
completion = await this.rebuildPostApproveCompletion(agentId, task, holdReplayFailure);
|
|
3108
|
+
}
|
|
3109
|
+
if (completion === 'held')
|
|
3110
|
+
return true;
|
|
3111
|
+
if (completion === 'stale')
|
|
2522
3112
|
return false;
|
|
3113
|
+
const resolvedCompletion = completion;
|
|
3114
|
+
const completionStillCurrent = async () => {
|
|
3115
|
+
const fresh = await this.taskStore.get(taskId);
|
|
3116
|
+
return fresh?.agentId === agentId
|
|
3117
|
+
&& fresh.status === task.status
|
|
3118
|
+
&& fresh.phase === task.phase
|
|
3119
|
+
&& fresh.signalToken === task.signalToken
|
|
3120
|
+
&& fresh.postApproveRevoked === undefined
|
|
3121
|
+
&& fresh.postApproveHeadSha === task.postApproveHeadSha
|
|
3122
|
+
&& (await this.getPostApproveCompletion(taskId))?.token === resolvedCompletion.token;
|
|
3123
|
+
};
|
|
3124
|
+
if (!(await completionStillCurrent()))
|
|
3125
|
+
return false;
|
|
3126
|
+
if (!(await clearEntryHold()))
|
|
3127
|
+
return false;
|
|
3128
|
+
let resumed = false;
|
|
3129
|
+
try {
|
|
3130
|
+
resumed = await this.continueSession(taskId, agentId, 'post-approve', {
|
|
3131
|
+
signalToken: completion.token,
|
|
3132
|
+
preserveDispatchOutputs: true,
|
|
3133
|
+
expectedHold: { phase: undefined, since: undefined, nonce: undefined },
|
|
3134
|
+
allowDirtyWorkdir: true,
|
|
3135
|
+
...fencedReplay(['pr-merge-ready'], completion.token, completionStillCurrent),
|
|
3136
|
+
});
|
|
2523
3137
|
}
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
3138
|
+
catch (err) {
|
|
3139
|
+
if (completion.rebuilt)
|
|
3140
|
+
await this.clearPostApproveCompletionIfMatches(taskId, completion.token);
|
|
3141
|
+
throw err;
|
|
3142
|
+
}
|
|
3143
|
+
if (resumed && task.reviewMode === 'git') {
|
|
3144
|
+
await this.confirmPostApprovePromptDelivered(taskId, completion.token);
|
|
3145
|
+
}
|
|
3146
|
+
if (!resumed && completion.rebuilt) {
|
|
3147
|
+
await this.clearPostApproveCompletionIfMatches(taskId, completion.token);
|
|
3148
|
+
const fresh = await this.taskStore.get(taskId);
|
|
3149
|
+
const episodeIntact = fresh?.agentId === agentId
|
|
3150
|
+
&& fresh.status === task.status
|
|
3151
|
+
&& fresh.phase === task.phase
|
|
3152
|
+
&& fresh.signalToken === task.signalToken
|
|
3153
|
+
&& fresh.postApproveRevoked === undefined
|
|
3154
|
+
&& fresh.postApproveHeadSha === task.postApproveHeadSha;
|
|
3155
|
+
if (!episodeIntact)
|
|
3156
|
+
return false;
|
|
3157
|
+
return holdReplayFailure('restart-redispatch-failed', 'REPL restarted during the post-approve pass and the rebuilt prompt could not be delivered; Resume then Restart REPL to retry, or cancel the task.');
|
|
3158
|
+
}
|
|
3159
|
+
return resumed;
|
|
3160
|
+
}
|
|
3161
|
+
// A bare hold would loop (Resume clears it, the next restart misses the record again), so rebuild from the persisted approved head.
|
|
3162
|
+
async rebuildPostApproveCompletion(agentId, task, holdReplayFailure) {
|
|
3163
|
+
// latestHeadSha drifts with pushes/mismatch probes; only the head persisted at dispatch is provably approved.
|
|
3164
|
+
const approvedHeadSha = task.postApproveHeadSha;
|
|
3165
|
+
if (!approvedHeadSha) {
|
|
3166
|
+
const held = await holdReplayFailure('restart-redispatch-failed', 'REPL restarted during the post-approve pass but neither its completion context nor a provably approved head are persisted; cancel the task or re-request the review verdict.');
|
|
3167
|
+
return held ? 'held' : 'stale';
|
|
3168
|
+
}
|
|
3169
|
+
const token = createSignalToken();
|
|
3170
|
+
// Conditional create: the in-lock re-read rejects any revoke/dispatch landed since the snapshot.
|
|
3171
|
+
const created = await this.withTaskLock(async () => {
|
|
3172
|
+
const fresh = await this.taskStore.get(task.id);
|
|
3173
|
+
if (!fresh
|
|
3174
|
+
|| fresh.agentId !== agentId
|
|
3175
|
+
|| fresh.status !== 'approved'
|
|
3176
|
+
|| fresh.phase !== task.phase
|
|
3177
|
+
|| fresh.signalToken !== task.signalToken
|
|
3178
|
+
|| fresh.postApproveRevoked !== undefined
|
|
3179
|
+
|| fresh.postApproveHeadSha !== approvedHeadSha) {
|
|
3180
|
+
return false;
|
|
3181
|
+
}
|
|
3182
|
+
if (fresh.reviewMode === 'git') {
|
|
3183
|
+
if (fresh.postApproveToken !== undefined)
|
|
3184
|
+
return false;
|
|
3185
|
+
// rebuild 与常规安装同一 installed→delivered 协议:写后崩溃由 sweep 按 pending 补派
|
|
3186
|
+
await this.taskStore.set({
|
|
3187
|
+
...fresh,
|
|
3188
|
+
postApproveToken: token,
|
|
3189
|
+
postApprovePhase: 'installed',
|
|
3190
|
+
pendingRedispatch: true,
|
|
3191
|
+
updatedAt: new Date().toISOString(),
|
|
3192
|
+
});
|
|
3193
|
+
return true;
|
|
3194
|
+
}
|
|
3195
|
+
if (await this.postApproveStore.get(task.id))
|
|
3196
|
+
return false;
|
|
3197
|
+
await this.postApproveStore.set(task.id, { token, approvedHeadSha });
|
|
3198
|
+
return true;
|
|
2532
3199
|
});
|
|
3200
|
+
if (!created)
|
|
3201
|
+
return 'stale';
|
|
3202
|
+
return { token, rebuilt: true };
|
|
2533
3203
|
}
|
|
2534
3204
|
prepareRemoveTargets(agentId) {
|
|
2535
3205
|
const cfg = this.getAgentConfig(agentId);
|
|
@@ -2672,6 +3342,17 @@ export class AgentManager {
|
|
|
2672
3342
|
getProjectByRepo(repo) {
|
|
2673
3343
|
return this.config.project.find(p => repoSlug(p.repo) === repo);
|
|
2674
3344
|
}
|
|
3345
|
+
getProjectByRepoIdentity(repoKey) {
|
|
3346
|
+
return this.config.project.find(p => repoIdentityKey(p.repo) === repoKey);
|
|
3347
|
+
}
|
|
3348
|
+
platformDriverFor(projectId) {
|
|
3349
|
+
if (!this.pluginRegistry)
|
|
3350
|
+
return undefined;
|
|
3351
|
+
const project = this.getProjectConfig(projectId);
|
|
3352
|
+
if (!project)
|
|
3353
|
+
return undefined;
|
|
3354
|
+
return buildProjectDriver(project, this.pluginRegistry, this.platformDriverExec);
|
|
3355
|
+
}
|
|
2675
3356
|
findAgentGroup(anchorAgentId) {
|
|
2676
3357
|
for (const project of this.config.project) {
|
|
2677
3358
|
for (const group of project.agent) {
|
|
@@ -2707,47 +3388,598 @@ export class AgentManager {
|
|
|
2707
3388
|
return this.agentStore.get(agentId);
|
|
2708
3389
|
}
|
|
2709
3390
|
async getPostApproveCompletion(taskId) {
|
|
3391
|
+
const task = await this.taskStore.get(taskId);
|
|
3392
|
+
if (task?.reviewMode === 'git')
|
|
3393
|
+
return this.gitCompletionOf(task);
|
|
2710
3394
|
return this.postApproveStore.get(taskId);
|
|
2711
3395
|
}
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
expectedKinds: 'pr-merge-ready',
|
|
2725
|
-
token: value.token,
|
|
2726
|
-
});
|
|
2727
|
-
});
|
|
3396
|
+
// 'git' 的 completion 与效果字段同居 TaskState:consumedFeedback/pendingRedispatch 与
|
|
3397
|
+
// token CAS 必须同一次持久化(spec §6 双写协议);legacy github 沿 PostApproveStore 至 M3c。
|
|
3398
|
+
gitCompletionOf(task) {
|
|
3399
|
+
if (task.postApproveToken === undefined || task.postApproveHeadSha === undefined)
|
|
3400
|
+
return null;
|
|
3401
|
+
return {
|
|
3402
|
+
token: task.postApproveToken,
|
|
3403
|
+
approvedHeadSha: task.postApproveHeadSha,
|
|
3404
|
+
updatedAt: task.updatedAt,
|
|
3405
|
+
...(task.redispatchCount !== undefined ? { redispatchCount: task.redispatchCount } : {}),
|
|
3406
|
+
...(task.pendingRedispatch !== undefined ? { pendingRedispatch: task.pendingRedispatch } : {}),
|
|
3407
|
+
};
|
|
2728
3408
|
}
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
this.phaseSignalWatcher?.stop(taskId);
|
|
2733
|
-
});
|
|
3409
|
+
stripGitCompletionFields(task) {
|
|
3410
|
+
const { postApproveToken: _t, pendingRedispatch: _p, redispatchCount: _r, postApprovePhase: _ph, ...rest } = task;
|
|
3411
|
+
return rest;
|
|
2734
3412
|
}
|
|
2735
|
-
|
|
3413
|
+
// Install CASes on the current approved episode; only a fresh human verdict (clearRevocation) unlocks a standing marker.
|
|
3414
|
+
async setPostApproveCompletion(taskId, value, opts = {}) {
|
|
2736
3415
|
return this.withTaskLock(async () => {
|
|
2737
|
-
const
|
|
3416
|
+
const task = await this.taskStore.get(taskId);
|
|
3417
|
+
if (task) {
|
|
3418
|
+
if (task.status !== 'approved')
|
|
3419
|
+
return false;
|
|
3420
|
+
if (task.postApproveRevoked && !opts.clearRevocation)
|
|
3421
|
+
return false;
|
|
3422
|
+
if (task.postApproveHeadSha !== undefined && task.postApproveHeadSha !== value.approvedHeadSha)
|
|
3423
|
+
return false;
|
|
3424
|
+
}
|
|
3425
|
+
if (task?.reviewMode === 'git') {
|
|
3426
|
+
const { postApproveRevoked: _revoked, ...rest } = task;
|
|
3427
|
+
// 全量替换语义与 PostApproveStore.set 一致:未携带的 pending/redispatch 字段清空
|
|
3428
|
+
await this.taskStore.set({
|
|
3429
|
+
...this.stripGitCompletionFields(opts.clearRevocation ? rest : task),
|
|
3430
|
+
postApproveToken: value.token,
|
|
3431
|
+
postApprovePhase: 'installed',
|
|
3432
|
+
...(value.redispatchCount !== undefined ? { redispatchCount: value.redispatchCount } : {}),
|
|
3433
|
+
...(value.pendingRedispatch !== undefined ? { pendingRedispatch: value.pendingRedispatch } : {}),
|
|
3434
|
+
updatedAt: new Date().toISOString(),
|
|
3435
|
+
});
|
|
3436
|
+
}
|
|
3437
|
+
else {
|
|
3438
|
+
await this.postApproveStore.set(taskId, value);
|
|
3439
|
+
}
|
|
3440
|
+
if (!task)
|
|
3441
|
+
return true;
|
|
3442
|
+
if (task.postApproveRevoked && task.reviewMode !== 'git') {
|
|
3443
|
+
const { postApproveRevoked: _revoked, ...rest } = task;
|
|
3444
|
+
await this.taskStore.set({ ...rest, updatedAt: new Date().toISOString() });
|
|
3445
|
+
}
|
|
3446
|
+
if (!this.phaseSignalWatcher)
|
|
3447
|
+
return true;
|
|
3448
|
+
const armed = await this.startPhaseSignalWatch({
|
|
3449
|
+
taskId,
|
|
3450
|
+
projectId: task.projectId,
|
|
3451
|
+
agentId: task.agentId,
|
|
3452
|
+
expectedKinds: 'pr-merge-ready',
|
|
3453
|
+
token: value.token,
|
|
3454
|
+
});
|
|
3455
|
+
if (!armed) {
|
|
3456
|
+
// completion 没有 consumer 就不算安装成功:撤销本次写,durable pending(若有)随原状保留
|
|
3457
|
+
if (task.reviewMode === 'git') {
|
|
3458
|
+
await this.taskStore.set({ ...task, updatedAt: new Date().toISOString() });
|
|
3459
|
+
}
|
|
3460
|
+
else {
|
|
3461
|
+
await this.postApproveStore.clear(taskId);
|
|
3462
|
+
}
|
|
3463
|
+
return false;
|
|
3464
|
+
}
|
|
3465
|
+
return true;
|
|
3466
|
+
});
|
|
3467
|
+
}
|
|
3468
|
+
// post-approve prompt 的至少一次投递(spec §6):pendingRedispatch 随 completion 安装保持 true,
|
|
3469
|
+
// 仅在 continueSession 确认送达后清除——安装与投递之间的崩溃由恢复扫描按 pending 补派。
|
|
3470
|
+
async confirmPostApprovePromptDelivered(taskId, token) {
|
|
3471
|
+
await this.withTaskLock(async () => {
|
|
3472
|
+
const task = await this.taskStore.get(taskId);
|
|
3473
|
+
if (!task || task.reviewMode !== 'git' || task.postApproveToken !== token)
|
|
3474
|
+
return;
|
|
3475
|
+
await this.taskStore.set({
|
|
3476
|
+
...task,
|
|
3477
|
+
pendingRedispatch: false,
|
|
3478
|
+
postApprovePhase: 'delivered',
|
|
3479
|
+
updatedAt: new Date().toISOString(),
|
|
3480
|
+
});
|
|
3481
|
+
});
|
|
3482
|
+
}
|
|
3483
|
+
async markPostApproveSignalReceived(taskId, token) {
|
|
3484
|
+
await this.withTaskLock(async () => {
|
|
3485
|
+
const task = await this.taskStore.get(taskId);
|
|
3486
|
+
if (!task || task.reviewMode !== 'git' || task.postApproveToken !== token)
|
|
3487
|
+
return;
|
|
3488
|
+
if (task.postApprovePhase === 'signaled')
|
|
3489
|
+
return;
|
|
3490
|
+
await this.taskStore.set({ ...task, postApprovePhase: 'signaled', updatedAt: new Date().toISOString() });
|
|
3491
|
+
});
|
|
3492
|
+
}
|
|
3493
|
+
async clearPostApproveCompletion(taskId) {
|
|
3494
|
+
await this.withTaskLock(async () => {
|
|
3495
|
+
const task = await this.taskStore.get(taskId);
|
|
3496
|
+
if (task?.reviewMode === 'git') {
|
|
3497
|
+
if (task.postApproveToken !== undefined || task.pendingRedispatch !== undefined
|
|
3498
|
+
|| task.redispatchCount !== undefined) {
|
|
3499
|
+
await this.taskStore.set({
|
|
3500
|
+
...this.stripGitCompletionFields(task),
|
|
3501
|
+
updatedAt: new Date().toISOString(),
|
|
3502
|
+
});
|
|
3503
|
+
}
|
|
3504
|
+
}
|
|
3505
|
+
else {
|
|
3506
|
+
await this.postApproveStore.clear(taskId);
|
|
3507
|
+
}
|
|
3508
|
+
this.phaseSignalWatcher?.stop(taskId);
|
|
3509
|
+
});
|
|
3510
|
+
}
|
|
3511
|
+
// CAS for an existing pass: never creates a record nor touches the marker, so it cannot resurrect a revoked completion.
|
|
3512
|
+
async updatePostApproveCompletionIfToken(taskId, expectedToken, patch, opts = {}) {
|
|
3513
|
+
return this.withTaskLock(async () => {
|
|
3514
|
+
const task = await this.taskStore.get(taskId);
|
|
3515
|
+
if (task?.postApproveRevoked)
|
|
3516
|
+
return false;
|
|
3517
|
+
if (task?.reviewMode === 'git') {
|
|
3518
|
+
if (task.postApproveToken !== expectedToken)
|
|
3519
|
+
return false;
|
|
3520
|
+
await this.taskStore.set({
|
|
3521
|
+
...task,
|
|
3522
|
+
...patch,
|
|
3523
|
+
...(opts.consumeRevision
|
|
3524
|
+
? { consumedFeedback: { ...task.consumedFeedback, [opts.consumeRevision.key]: opts.consumeRevision.versionTime } }
|
|
3525
|
+
: {}),
|
|
3526
|
+
updatedAt: new Date().toISOString(),
|
|
3527
|
+
});
|
|
3528
|
+
return true;
|
|
3529
|
+
}
|
|
3530
|
+
const current = await this.postApproveStore.get(taskId);
|
|
3531
|
+
if (!current || current.token !== expectedToken)
|
|
3532
|
+
return false;
|
|
3533
|
+
await this.postApproveStore.set(taskId, { ...current, ...patch });
|
|
3534
|
+
return true;
|
|
3535
|
+
});
|
|
3536
|
+
}
|
|
3537
|
+
// Deliberate clear: the persisted marker keeps a restart replay from rebuilding the completion and bypassing the block.
|
|
3538
|
+
async revokePostApproveCompletion(taskId, reason, opts = {}) {
|
|
3539
|
+
return this.withTaskLock(async () => {
|
|
3540
|
+
const task = await this.taskStore.get(taskId);
|
|
3541
|
+
if (!task || task.status !== 'approved')
|
|
3542
|
+
return false;
|
|
3543
|
+
// Head binds the revoke to one approved episode; an unpersisted head cannot disprove it, so blocking stays fail closed.
|
|
3544
|
+
if (opts.expectedHeadSha !== undefined
|
|
3545
|
+
&& task.postApproveHeadSha !== undefined
|
|
3546
|
+
&& task.postApproveHeadSha !== opts.expectedHeadSha)
|
|
3547
|
+
return false;
|
|
3548
|
+
if (opts.expectedToken !== undefined) {
|
|
3549
|
+
if (task.reviewMode === 'git') {
|
|
3550
|
+
if (task.postApproveToken !== opts.expectedToken)
|
|
3551
|
+
return false;
|
|
3552
|
+
}
|
|
3553
|
+
else {
|
|
3554
|
+
const current = await this.postApproveStore.get(taskId);
|
|
3555
|
+
if (current?.token !== opts.expectedToken)
|
|
3556
|
+
return false;
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
if (task.reviewMode === 'git') {
|
|
3560
|
+
await this.taskStore.set({
|
|
3561
|
+
...this.stripGitCompletionFields(task),
|
|
3562
|
+
postApproveRevoked: { reason, at: new Date().toISOString() },
|
|
3563
|
+
updatedAt: new Date().toISOString(),
|
|
3564
|
+
});
|
|
3565
|
+
this.phaseSignalWatcher?.stop(taskId);
|
|
3566
|
+
return true;
|
|
3567
|
+
}
|
|
3568
|
+
// Marker lands first: a crash before the clear leaves marker+completion, which every consumer fences on.
|
|
3569
|
+
await this.taskStore.set({
|
|
3570
|
+
...task,
|
|
3571
|
+
postApproveRevoked: { reason, at: new Date().toISOString() },
|
|
3572
|
+
updatedAt: new Date().toISOString(),
|
|
3573
|
+
});
|
|
3574
|
+
await this.postApproveStore.clear(taskId);
|
|
3575
|
+
this.phaseSignalWatcher?.stop(taskId);
|
|
3576
|
+
return true;
|
|
3577
|
+
});
|
|
3578
|
+
}
|
|
3579
|
+
async clearPostApproveCompletionIfMatches(taskId, token) {
|
|
3580
|
+
return this.withTaskLock(async () => {
|
|
3581
|
+
const task = await this.taskStore.get(taskId);
|
|
3582
|
+
if (task?.reviewMode === 'git') {
|
|
3583
|
+
if (task.postApproveToken !== token)
|
|
3584
|
+
return false;
|
|
3585
|
+
await this.taskStore.set({
|
|
3586
|
+
...this.stripGitCompletionFields(task),
|
|
3587
|
+
updatedAt: new Date().toISOString(),
|
|
3588
|
+
});
|
|
3589
|
+
this.phaseSignalWatcher?.stop(taskId);
|
|
3590
|
+
return true;
|
|
3591
|
+
}
|
|
3592
|
+
const cleared = await this.postApproveStore.clearIfMatches(taskId, token);
|
|
2738
3593
|
if (cleared)
|
|
2739
3594
|
this.phaseSignalWatcher?.stop(taskId);
|
|
2740
3595
|
return cleared;
|
|
2741
3596
|
});
|
|
2742
3597
|
}
|
|
2743
|
-
|
|
2744
|
-
|
|
3598
|
+
// Marker/token/head/pending are re-checked inside the committing critical section, so a concurrent revoke never loses.
|
|
3599
|
+
async completeApprovedPassToMergeReady(taskId, expectedToken) {
|
|
3600
|
+
return this.withTaskLock(async () => {
|
|
3601
|
+
const task = await this.taskStore.get(taskId);
|
|
3602
|
+
if (!task || task.status !== 'approved' || task.postApproveRevoked) {
|
|
3603
|
+
return { refused: 'stale' };
|
|
3604
|
+
}
|
|
3605
|
+
const completion = task.reviewMode === 'git'
|
|
3606
|
+
? this.gitCompletionOf(task)
|
|
3607
|
+
: await this.postApproveStore.get(taskId);
|
|
3608
|
+
if (!completion
|
|
3609
|
+
|| completion.token !== expectedToken
|
|
3610
|
+
|| task.postApproveHeadSha === undefined
|
|
3611
|
+
|| completion.approvedHeadSha !== task.postApproveHeadSha) {
|
|
3612
|
+
return { refused: 'stale' };
|
|
3613
|
+
}
|
|
3614
|
+
if (completion.pendingRedispatch)
|
|
3615
|
+
return { refused: 'pending' };
|
|
3616
|
+
const next = task.reviewMode === 'git' ? this.stripGitCompletionFields(task) : task;
|
|
3617
|
+
delete next.postApproveHeadSha;
|
|
3618
|
+
Object.assign(next, {
|
|
3619
|
+
status: 'merge-ready',
|
|
3620
|
+
latestHeadSha: completion.approvedHeadSha,
|
|
3621
|
+
updatedAt: new Date().toISOString(),
|
|
3622
|
+
});
|
|
3623
|
+
await this.taskStore.set(next);
|
|
3624
|
+
if (task.reviewMode !== 'git')
|
|
3625
|
+
await this.postApproveStore.clear(taskId);
|
|
3626
|
+
this.phaseSignalWatcher?.stop(taskId);
|
|
3627
|
+
return { task: next };
|
|
3628
|
+
});
|
|
3629
|
+
}
|
|
3630
|
+
// 修剪顺序单向(spec §6):仅在对应源 cursor durable 落盘后调用;水位停滞的源不修剪,
|
|
3631
|
+
// 有界性由水位前进保证,禁止按容量淘汰未覆盖键。
|
|
3632
|
+
async pruneConsumedFeedback(taskId, sourceKey, watermarkTime) {
|
|
3633
|
+
await this.withTaskLock(async () => {
|
|
3634
|
+
const task = await this.taskStore.get(taskId);
|
|
3635
|
+
if (!task?.consumedFeedback)
|
|
3636
|
+
return;
|
|
3637
|
+
const prefix = `${sourceKey}:`;
|
|
3638
|
+
const kept = Object.entries(task.consumedFeedback)
|
|
3639
|
+
.filter(([key, time]) => !key.startsWith(prefix) || time >= watermarkTime);
|
|
3640
|
+
if (kept.length === Object.keys(task.consumedFeedback).length)
|
|
3641
|
+
return;
|
|
3642
|
+
await this.taskStore.set({
|
|
3643
|
+
...task,
|
|
3644
|
+
consumedFeedback: Object.fromEntries(kept),
|
|
3645
|
+
updatedAt: new Date().toISOString(),
|
|
3646
|
+
});
|
|
3647
|
+
});
|
|
3648
|
+
}
|
|
3649
|
+
// TaskState 效果+outbox 先 durable、投递成功再清(spec §6 双写协议):崩溃于任一侧,
|
|
3650
|
+
// 重启从未清 outbox 重投,事件携确定性 key 供展示端去重——效果恰一次、通知至少一次。
|
|
3651
|
+
async recordClosedUnmergedAnchor(taskId, prNumber) {
|
|
3652
|
+
return this.withTaskLock(async () => {
|
|
3653
|
+
const task = await this.taskStore.get(taskId);
|
|
3654
|
+
if (!task || task.reviewMode !== 'git' || TERMINAL_STATUSES.includes(task.status))
|
|
3655
|
+
return false;
|
|
3656
|
+
const anchor = task.closedUnmergedAnchor;
|
|
3657
|
+
if (anchor?.prNumber === prNumber && anchor.cleared !== true)
|
|
3658
|
+
return false;
|
|
3659
|
+
const generation = (anchor?.generation ?? 0) + 1;
|
|
3660
|
+
const eventKey = `${taskId}:${prNumber}:mr-closed-unmerged:${generation}`;
|
|
3661
|
+
await this.taskStore.set({
|
|
3662
|
+
...task,
|
|
3663
|
+
closedUnmergedAnchor: { prNumber, generation },
|
|
3664
|
+
outbox: [...(task.outbox ?? []), {
|
|
3665
|
+
key: eventKey,
|
|
3666
|
+
type: 'human.intervention',
|
|
3667
|
+
data: { phase: 'mr-closed-unmerged', prNumber, eventKey },
|
|
3668
|
+
}],
|
|
3669
|
+
updatedAt: new Date().toISOString(),
|
|
3670
|
+
});
|
|
3671
|
+
return true;
|
|
3672
|
+
});
|
|
3673
|
+
}
|
|
3674
|
+
async clearClosedUnmergedAnchor(taskId, prNumber) {
|
|
3675
|
+
return this.withTaskLock(async () => {
|
|
3676
|
+
const task = await this.taskStore.get(taskId);
|
|
3677
|
+
const anchor = task?.closedUnmergedAnchor;
|
|
3678
|
+
if (!task || anchor?.prNumber !== prNumber || anchor.cleared === true)
|
|
3679
|
+
return false;
|
|
3680
|
+
const generation = anchor.generation;
|
|
3681
|
+
const eventKey = `${taskId}:${prNumber}:mr-reopened:${generation}`;
|
|
3682
|
+
await this.taskStore.set({
|
|
3683
|
+
...task,
|
|
3684
|
+
closedUnmergedAnchor: { ...anchor, cleared: true },
|
|
3685
|
+
outbox: [...(task.outbox ?? []), {
|
|
3686
|
+
key: eventKey,
|
|
3687
|
+
type: 'human.intervention',
|
|
3688
|
+
data: { phase: 'mr-reopened', prNumber, eventKey },
|
|
3689
|
+
}],
|
|
3690
|
+
updatedAt: new Date().toISOString(),
|
|
3691
|
+
});
|
|
3692
|
+
return true;
|
|
3693
|
+
});
|
|
3694
|
+
}
|
|
3695
|
+
async deliverTaskOutbox(taskId) {
|
|
3696
|
+
const task = await this.taskStore.get(taskId);
|
|
3697
|
+
if (!task?.outbox?.length)
|
|
2745
3698
|
return;
|
|
3699
|
+
for (const entry of task.outbox) {
|
|
3700
|
+
try {
|
|
3701
|
+
await this.eventBus.emit({
|
|
3702
|
+
// 确定性 id(spec §6 outbox):重投/并发 flush 的重复条目同 id,消费/展示端可去重
|
|
3703
|
+
id: `outbox:${entry.key}`,
|
|
3704
|
+
type: entry.type,
|
|
3705
|
+
timestamp: new Date().toISOString(),
|
|
3706
|
+
projectId: task.projectId,
|
|
3707
|
+
agentId: task.agentId,
|
|
3708
|
+
taskId: task.id,
|
|
3709
|
+
data: entry.data,
|
|
3710
|
+
});
|
|
3711
|
+
}
|
|
3712
|
+
catch (err) {
|
|
3713
|
+
console.warn(`[AgentManager] outbox delivery failed for task=${taskId} key=${entry.key}:`, err);
|
|
3714
|
+
continue;
|
|
3715
|
+
}
|
|
3716
|
+
await this.withTaskLock(async () => {
|
|
3717
|
+
const fresh = await this.taskStore.get(taskId);
|
|
3718
|
+
if (!fresh?.outbox?.some(e => e.key === entry.key))
|
|
3719
|
+
return;
|
|
3720
|
+
const remaining = fresh.outbox.filter(e => e.key !== entry.key);
|
|
3721
|
+
const { outbox: _outbox, ...rest } = fresh;
|
|
3722
|
+
await this.taskStore.set({
|
|
3723
|
+
...rest,
|
|
3724
|
+
...(remaining.length > 0 ? { outbox: remaining } : {}),
|
|
3725
|
+
updatedAt: new Date().toISOString(),
|
|
3726
|
+
});
|
|
3727
|
+
});
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3730
|
+
// merge-ready 信号是单发消费:git 分支在消费点引入网络复核,失败时必须重 arm(skipSnapshot
|
|
3731
|
+
// false 让仍在面板上的信号立即重放),否则一次瞬态失败吞掉整轮完成信号。
|
|
3732
|
+
async rearmPostApproveSignal(taskId, opts = {}) {
|
|
3733
|
+
const task = await this.taskStore.get(taskId);
|
|
3734
|
+
if (!task || task.status !== 'approved')
|
|
3735
|
+
return false;
|
|
3736
|
+
const completion = task.reviewMode === 'git'
|
|
3737
|
+
? this.gitCompletionOf(task)
|
|
3738
|
+
: await this.postApproveStore.get(taskId);
|
|
3739
|
+
if (!completion)
|
|
3740
|
+
return false;
|
|
3741
|
+
if (!this.phaseSignalWatcher)
|
|
3742
|
+
return true;
|
|
3743
|
+
return this.startPhaseSignalWatch({
|
|
3744
|
+
taskId,
|
|
3745
|
+
projectId: task.projectId,
|
|
3746
|
+
agentId: task.agentId,
|
|
3747
|
+
expectedKinds: 'pr-merge-ready',
|
|
3748
|
+
token: completion.token,
|
|
3749
|
+
// 刚消费的信号仍在滚动区:扫描失败路径必须 live-only,否则与持续失败叠加成无退避热循环
|
|
3750
|
+
...(opts.skipSnapshot ? { skipSnapshot: true } : {}),
|
|
3751
|
+
});
|
|
3752
|
+
}
|
|
3753
|
+
// 配置身份切换与任务创建共栅栏(TOCTOU):blocker 扫描与提交在 withTaskLock 内执行,
|
|
3754
|
+
// createTask 的「读配置快照+落任务」同经该锁,扫描时不可见的并发新任务不存在。
|
|
3755
|
+
async guardGitConfigCommit(current, next, scan, commit) {
|
|
3756
|
+
return this.withTaskLock(async () => {
|
|
3757
|
+
const blockers = await scan(this, current, next);
|
|
3758
|
+
if (blockers.length > 0)
|
|
3759
|
+
return { ok: false, blockers };
|
|
3760
|
+
await commit();
|
|
3761
|
+
return { ok: true };
|
|
3762
|
+
});
|
|
3763
|
+
}
|
|
3764
|
+
async flushGitOutbox() {
|
|
3765
|
+
const tasks = await this.taskStore.list();
|
|
3766
|
+
for (const task of tasks) {
|
|
3767
|
+
if (task.reviewMode !== 'git' || !task.outbox?.length)
|
|
3768
|
+
continue;
|
|
3769
|
+
try {
|
|
3770
|
+
await this.deliverTaskOutbox(task.id);
|
|
3771
|
+
}
|
|
3772
|
+
catch (err) {
|
|
3773
|
+
// 单任务失败不得饿死其后的 durable outbox(至少一次的重启保证)
|
|
3774
|
+
console.warn(`[AgentManager] flushGitOutbox: task=${task.id} failed, continuing:`, err);
|
|
3775
|
+
}
|
|
3776
|
+
}
|
|
3777
|
+
}
|
|
3778
|
+
// cancel 清理的部分成功可重试(spec §6 close 条 + 破坏性清理不静默失败):close 已成而
|
|
3779
|
+
// deleteBranch 瞬态失败时任务已终态,重启在守卫成立的前提下幂等补删(REF_NOT_FOUND 即成功)。
|
|
3780
|
+
// 只清本代 pending:A 的 start 晚于 B 的失败返回时,无条件清除会吞掉 B 留下的重试锚
|
|
3781
|
+
async clearReviewDispatchPending(taskId, expectedToken) {
|
|
3782
|
+
await this.withTaskLock(async () => {
|
|
3783
|
+
const task = await this.taskStore.get(taskId);
|
|
3784
|
+
if (!task || task.reviewDispatchPending !== true)
|
|
3785
|
+
return;
|
|
3786
|
+
if (task.status !== 'review' || task.signalToken !== expectedToken)
|
|
3787
|
+
return;
|
|
3788
|
+
const { reviewDispatchPending: _p, ...rest } = task;
|
|
3789
|
+
await this.taskStore.set({ ...rest, updatedAt: new Date().toISOString() });
|
|
3790
|
+
});
|
|
3791
|
+
}
|
|
3792
|
+
// 评审派发的至少一次(sweep 消费):transition 落 pending、QA session 启动成功才清——
|
|
3793
|
+
// QA 缺失/忙/arm/start 失败的每条早退路径都由此在 60s 内自愈,无需依赖事件重放。
|
|
3794
|
+
async retryPendingGitReviewDispatches() {
|
|
3795
|
+
const tasks = await this.listActiveGitTasks();
|
|
3796
|
+
for (const task of tasks) {
|
|
3797
|
+
if (task.reviewDispatchPending !== true)
|
|
3798
|
+
continue;
|
|
3799
|
+
// arm/start 失败回滚会退到 in_progress/fixing 且无第二触发源(poller 已记 pushSha、
|
|
3800
|
+
// watcher 已消费):pending 在场即重新拉入 review
|
|
3801
|
+
if (!['review', 'in_progress', 'fixing'].includes(task.status))
|
|
3802
|
+
continue;
|
|
3803
|
+
try {
|
|
3804
|
+
await this.dispatchReviewToQa(task.id, { bumpRound: false });
|
|
3805
|
+
}
|
|
3806
|
+
catch (err) {
|
|
3807
|
+
console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${task.id} still failing:`, err);
|
|
3808
|
+
}
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
async retryGitRemoteBranchCleanup() {
|
|
3812
|
+
const tasks = await this.taskStore.list({ status: 'cancelled' });
|
|
3813
|
+
for (const task of tasks) {
|
|
3814
|
+
const reason = task.branchCleanupPending?.reason;
|
|
3815
|
+
if (task.reviewMode !== 'git'
|
|
3816
|
+
|| (reason !== 'remote-delete-failed' && reason !== 'remote-close-failed'))
|
|
3817
|
+
continue;
|
|
3818
|
+
try {
|
|
3819
|
+
const driver = this.platformDriverFor(task.projectId);
|
|
3820
|
+
const branch = task.branch;
|
|
3821
|
+
if (!driver || branch === undefined)
|
|
3822
|
+
continue;
|
|
3823
|
+
// close 先补(幂等:closed PR 再 PATCH state=closed 为 no-op),再按守卫补删分支
|
|
3824
|
+
if (reason === 'remote-close-failed' && task.prNumber !== undefined) {
|
|
3825
|
+
await driver.runOp('close', { prNumber: task.prNumber });
|
|
3826
|
+
}
|
|
3827
|
+
if (!isAutoDeletableTaskBranch({
|
|
3828
|
+
taskId: task.id,
|
|
3829
|
+
taskBranch: branch,
|
|
3830
|
+
branchCreatedByBaxian: task.branchCreatedByBaxian,
|
|
3831
|
+
actualRef: `refs/heads/${branch}`,
|
|
3832
|
+
})) {
|
|
3833
|
+
if (reason !== 'remote-close-failed')
|
|
3834
|
+
continue;
|
|
3835
|
+
}
|
|
3836
|
+
else {
|
|
3837
|
+
await driver.runOp('deleteBranch', { branch });
|
|
3838
|
+
}
|
|
3839
|
+
await this.withTaskLock(async () => {
|
|
3840
|
+
const fresh = await this.taskStore.get(task.id);
|
|
3841
|
+
const freshReason = fresh?.branchCleanupPending?.reason;
|
|
3842
|
+
if (freshReason !== 'remote-delete-failed' && freshReason !== 'remote-close-failed')
|
|
3843
|
+
return;
|
|
3844
|
+
const { branchCleanupPending: _pending, ...rest } = fresh;
|
|
3845
|
+
await this.taskStore.set({ ...rest, updatedAt: new Date().toISOString() });
|
|
3846
|
+
});
|
|
3847
|
+
}
|
|
3848
|
+
catch (err) {
|
|
3849
|
+
console.warn(`[AgentManager] retryGitRemoteBranchCleanup: task=${task.id} still failing:`, err);
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
}
|
|
3853
|
+
// 反馈消费的唯一状态机(spec §6 双写协议 + merge 条):锁内按实时状态决定效果,效果字段
|
|
3854
|
+
// 与消费键同一次持久化——入口快照与提交之间的并发状态迁移不再能让 revision 落空。
|
|
3855
|
+
async consumeGitFeedbackRevision(taskId, consume, opts = {}) {
|
|
3856
|
+
return this.withTaskLock(async () => {
|
|
3857
|
+
const task = await this.taskStore.get(taskId);
|
|
3858
|
+
if (!task || task.reviewMode !== 'git')
|
|
3859
|
+
return { kind: 'gone' };
|
|
3860
|
+
if (task.consumedFeedback?.[consume.key] !== undefined)
|
|
3861
|
+
return { kind: 'duplicate' };
|
|
3862
|
+
const consumed = { ...task.consumedFeedback, [consume.key]: consume.versionTime };
|
|
3863
|
+
const stamp = { updatedAt: new Date().toISOString() };
|
|
3864
|
+
if (TERMINAL_STATUSES.includes(task.status)) {
|
|
3865
|
+
await this.taskStore.set({ ...task, consumedFeedback: consumed, ...stamp });
|
|
3866
|
+
return { kind: 'consumed' };
|
|
3867
|
+
}
|
|
3868
|
+
if (task.status === 'merge-ready') {
|
|
3869
|
+
const approvedHead = typeof task.latestHeadSha === 'string' && PLATFORM_HEAD_SHA_RE.test(task.latestHeadSha)
|
|
3870
|
+
? task.latestHeadSha
|
|
3871
|
+
: undefined;
|
|
3872
|
+
if (approvedHead !== undefined) {
|
|
3873
|
+
const next = {
|
|
3874
|
+
...task,
|
|
3875
|
+
status: 'approved',
|
|
3876
|
+
postApproveHeadSha: approvedHead,
|
|
3877
|
+
pendingRedispatch: true,
|
|
3878
|
+
consumedFeedback: consumed,
|
|
3879
|
+
...(opts.feedbackAt !== undefined ? { prFeedbackReceivedAt: opts.feedbackAt } : {}),
|
|
3880
|
+
...stamp,
|
|
3881
|
+
};
|
|
3882
|
+
await this.taskStore.set(next);
|
|
3883
|
+
return { kind: 'returned', task: next };
|
|
3884
|
+
}
|
|
3885
|
+
}
|
|
3886
|
+
if (task.status === 'approved' && task.postApproveRevoked === undefined) {
|
|
3887
|
+
const next = {
|
|
3888
|
+
...task,
|
|
3889
|
+
pendingRedispatch: true,
|
|
3890
|
+
consumedFeedback: consumed,
|
|
3891
|
+
...(opts.feedbackAt !== undefined ? { prFeedbackReceivedAt: opts.feedbackAt } : {}),
|
|
3892
|
+
...stamp,
|
|
3893
|
+
};
|
|
3894
|
+
await this.taskStore.set(next);
|
|
3895
|
+
return { kind: 'pending', task: next };
|
|
3896
|
+
}
|
|
3897
|
+
await this.taskStore.set({
|
|
3898
|
+
...task,
|
|
3899
|
+
consumedFeedback: consumed,
|
|
3900
|
+
...(opts.feedbackAt !== undefined ? { prFeedbackReceivedAt: opts.feedbackAt } : {}),
|
|
3901
|
+
...stamp,
|
|
3902
|
+
});
|
|
3903
|
+
return { kind: 'consumed' };
|
|
3904
|
+
});
|
|
3905
|
+
}
|
|
3906
|
+
// merge-ready 不是评论面的冻结点(spec §6 merge 条):新反馈单写退回 approved + 消费键,
|
|
3907
|
+
// 与 push 使 merge-ready 失效的既有语义对称。
|
|
3908
|
+
async returnMergeReadyToApproved(taskId, consume) {
|
|
3909
|
+
return this.withTaskLock(async () => {
|
|
3910
|
+
const task = await this.taskStore.get(taskId);
|
|
3911
|
+
if (!task || task.reviewMode !== 'git' || task.status !== 'merge-ready')
|
|
3912
|
+
return null;
|
|
3913
|
+
const approvedHead = typeof task.latestHeadSha === 'string' && PLATFORM_HEAD_SHA_RE.test(task.latestHeadSha)
|
|
3914
|
+
? task.latestHeadSha
|
|
3915
|
+
: undefined;
|
|
3916
|
+
if (!approvedHead)
|
|
3917
|
+
return null;
|
|
3918
|
+
// pendingRedispatch 与状态翻转/消费键同写:崩溃于派发前,恢复扫描凭它补派(spec §6 至少一次)
|
|
3919
|
+
const next = {
|
|
3920
|
+
...task,
|
|
3921
|
+
status: 'approved',
|
|
3922
|
+
postApproveHeadSha: approvedHead,
|
|
3923
|
+
pendingRedispatch: true,
|
|
3924
|
+
consumedFeedback: { ...task.consumedFeedback, [consume.key]: consume.versionTime },
|
|
3925
|
+
updatedAt: new Date().toISOString(),
|
|
3926
|
+
};
|
|
3927
|
+
await this.taskStore.set(next);
|
|
3928
|
+
return next;
|
|
3929
|
+
});
|
|
3930
|
+
}
|
|
3931
|
+
async setupRecoveredPostApproveSignals() {
|
|
2746
3932
|
const tasks = await this.taskStore.list({ status: 'approved' });
|
|
2747
3933
|
for (const task of tasks) {
|
|
2748
|
-
|
|
3934
|
+
if (task.postApproveRevoked) {
|
|
3935
|
+
// Finish an interrupted revoke: the marker is authoritative, the leftover record must not re-arm.
|
|
3936
|
+
try {
|
|
3937
|
+
if (task.reviewMode === 'git') {
|
|
3938
|
+
if (task.postApproveToken !== undefined) {
|
|
3939
|
+
await this.taskStore.set({
|
|
3940
|
+
...this.stripGitCompletionFields(task),
|
|
3941
|
+
updatedAt: new Date().toISOString(),
|
|
3942
|
+
});
|
|
3943
|
+
}
|
|
3944
|
+
}
|
|
3945
|
+
else {
|
|
3946
|
+
await this.postApproveStore.clear(task.id);
|
|
3947
|
+
}
|
|
3948
|
+
}
|
|
3949
|
+
catch (err) {
|
|
3950
|
+
console.warn(`[AgentManager] setupRecoveredPostApproveSignals: revoked completion cleanup failed for task=${task.id}:`, err);
|
|
3951
|
+
}
|
|
3952
|
+
continue;
|
|
3953
|
+
}
|
|
3954
|
+
const completion = task.reviewMode === 'git'
|
|
3955
|
+
? this.gitCompletionOf(task)
|
|
3956
|
+
: await this.postApproveStore.get(task.id);
|
|
2749
3957
|
if (!completion)
|
|
2750
3958
|
continue;
|
|
3959
|
+
if (task.postApproveHeadSha === undefined) {
|
|
3960
|
+
// A completion the task head cannot vouch for is another episode's residue until proven otherwise.
|
|
3961
|
+
console.warn(`[AgentManager] setupRecoveredPostApproveSignals: task=${task.id} has a stored completion but no persisted approved head; not re-arming`);
|
|
3962
|
+
continue;
|
|
3963
|
+
}
|
|
3964
|
+
if (completion.approvedHeadSha !== task.postApproveHeadSha) {
|
|
3965
|
+
try {
|
|
3966
|
+
if (task.reviewMode === 'git') {
|
|
3967
|
+
await this.taskStore.set({
|
|
3968
|
+
...this.stripGitCompletionFields(task),
|
|
3969
|
+
updatedAt: new Date().toISOString(),
|
|
3970
|
+
});
|
|
3971
|
+
}
|
|
3972
|
+
else
|
|
3973
|
+
await this.postApproveStore.clear(task.id);
|
|
3974
|
+
console.warn(`[AgentManager] setupRecoveredPostApproveSignals: task=${task.id} retired a completion bound to head ${completion.approvedHeadSha} (persisted approved head ${task.postApproveHeadSha})`);
|
|
3975
|
+
}
|
|
3976
|
+
catch (err) {
|
|
3977
|
+
console.warn(`[AgentManager] setupRecoveredPostApproveSignals: stale completion cleanup failed for task=${task.id}:`, err);
|
|
3978
|
+
}
|
|
3979
|
+
continue;
|
|
3980
|
+
}
|
|
3981
|
+
if (!this.phaseSignalWatcher)
|
|
3982
|
+
continue;
|
|
2751
3983
|
try {
|
|
2752
3984
|
await this.startPhaseSignalWatch({
|
|
2753
3985
|
taskId: task.id,
|
|
@@ -2771,9 +4003,16 @@ export class AgentManager {
|
|
|
2771
4003
|
for (const task of tasks) {
|
|
2772
4004
|
if (!task.signalToken)
|
|
2773
4005
|
continue;
|
|
2774
|
-
const
|
|
2775
|
-
if (
|
|
4006
|
+
const mappings = this.mapTaskStateToExpectedWatchers(task);
|
|
4007
|
+
if (mappings.length === 0)
|
|
2776
4008
|
continue;
|
|
4009
|
+
for (const mapped of mappings) {
|
|
4010
|
+
await this.setupRecoveredWatcherForMapping(task, mapped.token ?? task.signalToken, mapped, mappings.length > 1);
|
|
4011
|
+
}
|
|
4012
|
+
}
|
|
4013
|
+
}
|
|
4014
|
+
async setupRecoveredWatcherForMapping(task, signalToken, mapped, hasSiblings) {
|
|
4015
|
+
{
|
|
2777
4016
|
const { expectedKinds, agentId } = mapped;
|
|
2778
4017
|
const specStage = isSpecStagePhase(task.phase);
|
|
2779
4018
|
const interventionKindLabel = specStage && task.status === 'review' ? 'spec-reviewed'
|
|
@@ -2795,9 +4034,10 @@ export class AgentManager {
|
|
|
2795
4034
|
projectId: task.projectId,
|
|
2796
4035
|
agentId,
|
|
2797
4036
|
expectedKinds,
|
|
2798
|
-
token:
|
|
4037
|
+
token: signalToken,
|
|
2799
4038
|
skipSnapshot: !scanSnapshotOnRecover,
|
|
2800
4039
|
recovered: true,
|
|
4040
|
+
...(hasSiblings ? { replaceScope: 'agent' } : {}),
|
|
2801
4041
|
...(allowRecoveredReadFile
|
|
2802
4042
|
? { onReadFile: (req) => { void this.handleReadFileRequest(task.id, agentId, req); } }
|
|
2803
4043
|
: {}),
|
|
@@ -2891,16 +4131,259 @@ export class AgentManager {
|
|
|
2891
4131
|
return undefined;
|
|
2892
4132
|
throw new Error(`gh pr view exited ${result.exitCode} for PR #${prNumber}: ${stderr.slice(0, 200)}`);
|
|
2893
4133
|
}
|
|
2894
|
-
const parts = result.stdout.trim().split('\t');
|
|
2895
|
-
const [headRefName, headSha, state, isCross] = parts;
|
|
2896
|
-
const body = parts.slice(4).join('\t');
|
|
2897
|
-
if (!headRefName || !/^[0-9a-f]{40}$/i.test(headSha))
|
|
2898
|
-
return undefined;
|
|
2899
|
-
if (state !== 'OPEN')
|
|
2900
|
-
return undefined;
|
|
2901
|
-
if (isCross === 'true')
|
|
2902
|
-
return undefined;
|
|
2903
|
-
return { headRefName, headSha, body };
|
|
4134
|
+
const parts = result.stdout.trim().split('\t');
|
|
4135
|
+
const [headRefName, headSha, state, isCross] = parts;
|
|
4136
|
+
const body = parts.slice(4).join('\t');
|
|
4137
|
+
if (!headRefName || !/^[0-9a-f]{40}$/i.test(headSha))
|
|
4138
|
+
return undefined;
|
|
4139
|
+
if (state !== 'OPEN')
|
|
4140
|
+
return undefined;
|
|
4141
|
+
if (isCross === 'true')
|
|
4142
|
+
return undefined;
|
|
4143
|
+
return { headRefName, headSha, body };
|
|
4144
|
+
}
|
|
4145
|
+
platformBindingFields(projectId) {
|
|
4146
|
+
if (this.effectiveReviewMode(projectId) !== 'git')
|
|
4147
|
+
return {};
|
|
4148
|
+
const project = this.getProjectConfig(projectId);
|
|
4149
|
+
if (!project)
|
|
4150
|
+
return {};
|
|
4151
|
+
const tool = resolveProjectTool(project);
|
|
4152
|
+
if (tool === undefined)
|
|
4153
|
+
return {};
|
|
4154
|
+
return { platformBinding: { mode: 'git', repoKey: repoIdentityKey(project.repo), tool } };
|
|
4155
|
+
}
|
|
4156
|
+
// 指纹为纯身份三元组(spec §4):binary/env/skill 文本 live 解析可演进,身份失配即拒绝
|
|
4157
|
+
assertPlatformBinding(task) {
|
|
4158
|
+
const binding = task.platformBinding;
|
|
4159
|
+
if (!binding) {
|
|
4160
|
+
if (task.reviewMode !== 'git')
|
|
4161
|
+
return;
|
|
4162
|
+
// D11 无兼容负担:不存在合法的无快照 git 任务,缺失即损坏/手工恢复,按失配同级拒绝
|
|
4163
|
+
void this.emitIntervention(task.projectId, task.agentId || undefined, task.id, {
|
|
4164
|
+
phase: 'platform-binding-mismatch',
|
|
4165
|
+
reason: 'missing-binding-snapshot',
|
|
4166
|
+
});
|
|
4167
|
+
throw new Error(`platform binding missing for git task ${task.id}; refusing platform operations`);
|
|
4168
|
+
}
|
|
4169
|
+
const project = this.getProjectConfig(task.projectId);
|
|
4170
|
+
const live = project === undefined ? undefined : {
|
|
4171
|
+
mode: this.effectiveReviewMode(task.projectId),
|
|
4172
|
+
repoKey: repoIdentityKey(project.repo),
|
|
4173
|
+
tool: resolveProjectTool(project),
|
|
4174
|
+
};
|
|
4175
|
+
if (live !== undefined && live.mode === binding.mode
|
|
4176
|
+
&& live.repoKey === binding.repoKey && live.tool === binding.tool)
|
|
4177
|
+
return;
|
|
4178
|
+
void this.emitIntervention(task.projectId, task.agentId || undefined, task.id, {
|
|
4179
|
+
phase: 'platform-binding-mismatch',
|
|
4180
|
+
binding,
|
|
4181
|
+
...(live !== undefined ? { live: { mode: live.mode, repoKey: live.repoKey, tool: live.tool ?? '' } } : {}),
|
|
4182
|
+
});
|
|
4183
|
+
throw new Error(`platform binding mismatch for task ${task.id}: snapshot ${binding.mode}/${binding.repoKey}/${binding.tool} no longer matches the live config`);
|
|
4184
|
+
}
|
|
4185
|
+
async platformTaskAndDriver(taskId) {
|
|
4186
|
+
const task = await this.taskStore.get(taskId);
|
|
4187
|
+
if (!task)
|
|
4188
|
+
throw new Error(`task ${taskId} not found`);
|
|
4189
|
+
this.assertPlatformBinding(task);
|
|
4190
|
+
const driver = this.platformDriverFor(task.projectId);
|
|
4191
|
+
if (!driver)
|
|
4192
|
+
throw new Error(`no git driver resolvable for project ${task.projectId}`);
|
|
4193
|
+
return { task, driver };
|
|
4194
|
+
}
|
|
4195
|
+
async listActiveGitTasks(projectId) {
|
|
4196
|
+
const tasks = await this.taskStore.list(projectId !== undefined ? { projectId } : undefined);
|
|
4197
|
+
return tasks.filter(t => t.reviewMode === 'git' && !TERMINAL_STATUSES.includes(t.status));
|
|
4198
|
+
}
|
|
4199
|
+
async platformFetchPrView(taskId) {
|
|
4200
|
+
const { task, driver } = await this.platformTaskAndDriver(taskId);
|
|
4201
|
+
if (task.prNumber === undefined)
|
|
4202
|
+
throw new Error(`task ${taskId} has no bound PR`);
|
|
4203
|
+
const [row] = await driver.runOp('prView', { prNumber: task.prNumber });
|
|
4204
|
+
return row;
|
|
4205
|
+
}
|
|
4206
|
+
async platformVerifyPrBinding(taskId, prNumber, opts = {}) {
|
|
4207
|
+
const { task, driver } = await this.platformTaskAndDriver(taskId);
|
|
4208
|
+
const [row] = await driver.runOp('prView', { prNumber });
|
|
4209
|
+
let expectedBase = task.baseBranch;
|
|
4210
|
+
if (expectedBase === undefined) {
|
|
4211
|
+
try {
|
|
4212
|
+
const [project] = await driver.runOp('projectView');
|
|
4213
|
+
if (typeof project?.defaultBranch === 'string')
|
|
4214
|
+
expectedBase = project.defaultBranch;
|
|
4215
|
+
}
|
|
4216
|
+
catch {
|
|
4217
|
+
// 快照与实时默认分支都拿不到 → unverifiable:核验失败不是核验通过(fail closed)
|
|
4218
|
+
}
|
|
4219
|
+
}
|
|
4220
|
+
const check = checkOpenPrBinding(row, { branch: opts.branchOverride ?? task.branch, expectedBase });
|
|
4221
|
+
if (!check.ok) {
|
|
4222
|
+
return {
|
|
4223
|
+
...check,
|
|
4224
|
+
...(check.reason === 'branch' && typeof row.branch === 'string' ? { prBranch: row.branch } : {}),
|
|
4225
|
+
};
|
|
4226
|
+
}
|
|
4227
|
+
return {
|
|
4228
|
+
ok: true,
|
|
4229
|
+
headSha: String(row.headSha),
|
|
4230
|
+
branch: String(row.branch),
|
|
4231
|
+
targetBranch: String(row.targetBranch),
|
|
4232
|
+
};
|
|
4233
|
+
}
|
|
4234
|
+
stopPhaseSignalWatcherAgent(taskId, agentId) {
|
|
4235
|
+
this.phaseSignalWatcher?.stopAgent(taskId, agentId);
|
|
4236
|
+
}
|
|
4237
|
+
async platformClosePr(taskId, opts) {
|
|
4238
|
+
const { task, driver } = await this.platformTaskAndDriver(taskId);
|
|
4239
|
+
if (task.prNumber === undefined)
|
|
4240
|
+
throw new Error(`task ${taskId} has no bound PR`);
|
|
4241
|
+
try {
|
|
4242
|
+
await driver.runOp('close', { prNumber: task.prNumber });
|
|
4243
|
+
}
|
|
4244
|
+
catch (err) {
|
|
4245
|
+
throw new PlatformCloseError('close', err);
|
|
4246
|
+
}
|
|
4247
|
+
const branch = task.branch;
|
|
4248
|
+
const deletable = opts.deleteBranch && branch !== undefined && isAutoDeletableTaskBranch({
|
|
4249
|
+
taskId: task.id,
|
|
4250
|
+
taskBranch: branch,
|
|
4251
|
+
branchCreatedByBaxian: task.branchCreatedByBaxian,
|
|
4252
|
+
actualRef: `refs/heads/${branch}`,
|
|
4253
|
+
});
|
|
4254
|
+
if (deletable) {
|
|
4255
|
+
try {
|
|
4256
|
+
await driver.runOp('deleteBranch', { branch });
|
|
4257
|
+
}
|
|
4258
|
+
catch (err) {
|
|
4259
|
+
throw new PlatformCloseError('deleteBranch', err);
|
|
4260
|
+
}
|
|
4261
|
+
}
|
|
4262
|
+
}
|
|
4263
|
+
async platformScanComments(driver, prNumber) {
|
|
4264
|
+
return scanCommentSourcesOnce(driver, prNumber, () => Date.now());
|
|
4265
|
+
}
|
|
4266
|
+
// merge-ready 迁移与 merge confirm 的 provenance/ack 双复核共用一次全源扫描(spec §6/§10)
|
|
4267
|
+
async platformVerifyAcceptedPass(taskId) {
|
|
4268
|
+
const { task, driver } = await this.platformTaskAndDriver(taskId);
|
|
4269
|
+
if (task.prNumber === undefined)
|
|
4270
|
+
return { ok: false, reason: 'no bound PR' };
|
|
4271
|
+
const provenance = task.passProvenance;
|
|
4272
|
+
if (!provenance)
|
|
4273
|
+
return { ok: false, reason: 'no accepted pass provenance' };
|
|
4274
|
+
const scans = await this.platformScanComments(driver, task.prNumber);
|
|
4275
|
+
const recheck = recheckPassProvenance({
|
|
4276
|
+
token: provenance.token,
|
|
4277
|
+
failToken: provenance.failToken,
|
|
4278
|
+
anchorSha: provenance.anchorSha,
|
|
4279
|
+
carrier: { sourceKey: provenance.sourceKey, id: provenance.id, bodyDigest: provenance.bodyDigest },
|
|
4280
|
+
}, scans);
|
|
4281
|
+
if (!recheck.ok)
|
|
4282
|
+
return { ok: false, reason: `provenance ${recheck.reason}` };
|
|
4283
|
+
const pending = collectPendingFeedback(scans, {
|
|
4284
|
+
replyActorId: task.replyActorId,
|
|
4285
|
+
replyActorStatus: task.replyActorStatus,
|
|
4286
|
+
});
|
|
4287
|
+
if (!pending.allSourcesOk)
|
|
4288
|
+
return { ok: false, reason: 'source scan incomplete' };
|
|
4289
|
+
return { ok: true, pendingCount: pending.pending.size };
|
|
4290
|
+
}
|
|
4291
|
+
async platformPendingFeedback(taskId) {
|
|
4292
|
+
const { task, driver } = await this.platformTaskAndDriver(taskId);
|
|
4293
|
+
if (task.prNumber === undefined)
|
|
4294
|
+
throw new Error(`task ${taskId} has no bound PR`);
|
|
4295
|
+
const scans = await this.platformScanComments(driver, task.prNumber);
|
|
4296
|
+
const failed = scans.filter(s => !s.ok).map(s => s.key);
|
|
4297
|
+
if (failed.length > 0) {
|
|
4298
|
+
throw new Error(`pending-feedback scan incomplete; failed sources: ${failed.join(', ')}`);
|
|
4299
|
+
}
|
|
4300
|
+
return collectPendingFeedback(scans, {
|
|
4301
|
+
replyActorId: task.replyActorId,
|
|
4302
|
+
replyActorStatus: task.replyActorStatus,
|
|
4303
|
+
});
|
|
4304
|
+
}
|
|
4305
|
+
async platformConfirmMerge(taskId, opts) {
|
|
4306
|
+
const { task, driver } = await this.platformTaskAndDriver(taskId);
|
|
4307
|
+
if (task.prNumber === undefined)
|
|
4308
|
+
throw new Error(`task ${taskId} has no bound PR`);
|
|
4309
|
+
const prNumber = task.prNumber;
|
|
4310
|
+
const [row] = await driver.runOp('prView', { prNumber });
|
|
4311
|
+
const binding = checkOpenPrBinding(row, { branch: task.branch, expectedBase: task.baseBranch });
|
|
4312
|
+
if (!binding.ok)
|
|
4313
|
+
throw new Error(`merge blocked: binding ${binding.reason}`);
|
|
4314
|
+
if (String(row.headSha) !== opts.expectedHeadSha) {
|
|
4315
|
+
throw new Error(`merge blocked: head moved (expected ${opts.expectedHeadSha}, saw ${String(row.headSha)})`);
|
|
4316
|
+
}
|
|
4317
|
+
// 人工兜底(max_rounds Mark complete)绕过 QA 授权复核(该路径本就无 pass),
|
|
4318
|
+
// binding/head/REST sha 的安全检查保留(spec §7 威胁模型:merge 有人工 confirm)
|
|
4319
|
+
if (opts.humanOverride !== true) {
|
|
4320
|
+
const provenance = task.passProvenance;
|
|
4321
|
+
if (!provenance)
|
|
4322
|
+
throw new PlatformMergeRecheckError('no accepted pass provenance on task');
|
|
4323
|
+
if (provenance.anchorSha.toLowerCase() !== opts.expectedHeadSha.toLowerCase()) {
|
|
4324
|
+
throw new PlatformMergeRecheckError(`pass provenance anchors ${provenance.anchorSha}, not the merge head ${opts.expectedHeadSha}`);
|
|
4325
|
+
}
|
|
4326
|
+
const scans = await this.platformScanComments(driver, prNumber);
|
|
4327
|
+
const recheck = recheckPassProvenance({
|
|
4328
|
+
token: provenance.token,
|
|
4329
|
+
failToken: provenance.failToken,
|
|
4330
|
+
anchorSha: provenance.anchorSha,
|
|
4331
|
+
carrier: { sourceKey: provenance.sourceKey, id: provenance.id, bodyDigest: provenance.bodyDigest },
|
|
4332
|
+
}, scans);
|
|
4333
|
+
if (!recheck.ok) {
|
|
4334
|
+
// 瞬态扫描不完整留在 gate 可重试;只有确定性失效(撤销/编辑/缺失)才退回 approved
|
|
4335
|
+
if (recheck.reason === 'source-scan-incomplete') {
|
|
4336
|
+
throw new Error('merge blocked: provenance source-scan-incomplete');
|
|
4337
|
+
}
|
|
4338
|
+
throw new PlatformMergeRecheckError(`provenance ${recheck.reason}`);
|
|
4339
|
+
}
|
|
4340
|
+
const pending = collectPendingFeedback(scans, {
|
|
4341
|
+
replyActorId: task.replyActorId,
|
|
4342
|
+
replyActorStatus: task.replyActorStatus,
|
|
4343
|
+
});
|
|
4344
|
+
if (!pending.allSourcesOk)
|
|
4345
|
+
throw new Error('merge blocked: pending-feedback scan incomplete');
|
|
4346
|
+
if (pending.pending.size > 0) {
|
|
4347
|
+
throw new PlatformMergeRecheckError(`${pending.pending.size} pending feedback revision(s) without ack`);
|
|
4348
|
+
}
|
|
4349
|
+
}
|
|
4350
|
+
// 临门 CAS:扫描期间 durable 退回(approved/pendingRedispatch/fixing)必须使本次 Confirm 失效;
|
|
4351
|
+
// REST sha 只保护 source head,保护不了评论面与 TaskState(残余网络窗口按 spec §6 如实声明)
|
|
4352
|
+
const fresh = await this.taskStore.get(taskId);
|
|
4353
|
+
if (!fresh
|
|
4354
|
+
|| (opts.humanOverride === true
|
|
4355
|
+
// override 只绕过 provenance/ack,不绕过本次认领的 gate 代际:并发 RC 转 fixing 即失效
|
|
4356
|
+
? fresh.status !== 'merge-ready'
|
|
4357
|
+
: (fresh.status !== 'merge-ready' && fresh.status !== 'ready'))
|
|
4358
|
+
|| fresh.latestHeadSha !== opts.expectedHeadSha
|
|
4359
|
+
|| fresh.pendingRedispatch === true) {
|
|
4360
|
+
throw new Error(`merge blocked: task left its merge gate (status=${fresh?.status ?? 'gone'})`);
|
|
4361
|
+
}
|
|
4362
|
+
try {
|
|
4363
|
+
await driver.runOp('merge', { prNumber, expectedHeadSha: opts.expectedHeadSha });
|
|
4364
|
+
}
|
|
4365
|
+
catch (err) {
|
|
4366
|
+
if (err instanceof DriverOpError && err.info.errorClass === 'MERGE_BLOCKED') {
|
|
4367
|
+
let mergeStatus;
|
|
4368
|
+
try {
|
|
4369
|
+
const [fresh] = await driver.runOp('prView', { prNumber });
|
|
4370
|
+
if (typeof fresh?.detailedMergeStatus === 'string')
|
|
4371
|
+
mergeStatus = fresh.detailedMergeStatus;
|
|
4372
|
+
}
|
|
4373
|
+
catch {
|
|
4374
|
+
// 归因查询失败不遮蔽原始错误
|
|
4375
|
+
}
|
|
4376
|
+
throw new Error(`merge blocked by platform${mergeStatus ? ` (${mergeStatus})` : ''}: ${err.message}`);
|
|
4377
|
+
}
|
|
4378
|
+
throw err;
|
|
4379
|
+
}
|
|
4380
|
+
}
|
|
4381
|
+
mintReviewTokenPair() {
|
|
4382
|
+
const passToken = createSignalToken();
|
|
4383
|
+
let failToken = createSignalToken();
|
|
4384
|
+
while (failToken === passToken)
|
|
4385
|
+
failToken = createSignalToken();
|
|
4386
|
+
return { passToken, failToken };
|
|
2904
4387
|
}
|
|
2905
4388
|
async listTasksByProject(projectId) {
|
|
2906
4389
|
return this.taskStore.list({ projectId });
|
|
@@ -2924,6 +4407,20 @@ export class AgentManager {
|
|
|
2924
4407
|
task.updatedAt = new Date().toISOString();
|
|
2925
4408
|
await this.taskStore.set(task);
|
|
2926
4409
|
}
|
|
4410
|
+
async updateTaskIfStatus(taskId, expectedStatus, updates, alsoExpect = {}) {
|
|
4411
|
+
return this.withTaskLock(async () => {
|
|
4412
|
+
const task = await this.taskStore.get(taskId);
|
|
4413
|
+
if (!task || task.status !== expectedStatus)
|
|
4414
|
+
return false;
|
|
4415
|
+
if ('signalToken' in alsoExpect && task.signalToken !== alsoExpect.signalToken)
|
|
4416
|
+
return false;
|
|
4417
|
+
if ('reviewRound' in alsoExpect && task.reviewRound !== alsoExpect.reviewRound)
|
|
4418
|
+
return false;
|
|
4419
|
+
Object.assign(task, updates, { updatedAt: new Date().toISOString() });
|
|
4420
|
+
await this.taskStore.set(task);
|
|
4421
|
+
return true;
|
|
4422
|
+
});
|
|
4423
|
+
}
|
|
2927
4424
|
async updateTask(taskId, updates) {
|
|
2928
4425
|
await this.withTaskLock(async () => {
|
|
2929
4426
|
const task = await this.taskStore.get(taskId);
|
|
@@ -2943,11 +4440,19 @@ export class AgentManager {
|
|
|
2943
4440
|
return null;
|
|
2944
4441
|
if (!guard.fromStatus.includes(previousStatus))
|
|
2945
4442
|
return null;
|
|
4443
|
+
// 代际 CAS(spec §7):校验后提交前的并发换代(push/重派轮换 token+pair)使旧 verdict 失效
|
|
4444
|
+
if (guard.expectSignalToken !== undefined && task.signalToken !== guard.expectSignalToken)
|
|
4445
|
+
return null;
|
|
2946
4446
|
if (patch?.branch && patch.branch !== task.branch) {
|
|
2947
4447
|
const existing = await this.findTaskByBranch(patch.branch, task.projectId);
|
|
2948
4448
|
if (existing && existing.id !== taskId)
|
|
2949
4449
|
return null;
|
|
2950
4450
|
}
|
|
4451
|
+
// The revocation marker and approved head scope to the approved episode they were created in.
|
|
4452
|
+
if (previousStatus === 'approved' && toStatus !== 'approved') {
|
|
4453
|
+
delete task.postApproveRevoked;
|
|
4454
|
+
delete task.postApproveHeadSha;
|
|
4455
|
+
}
|
|
2951
4456
|
Object.assign(task, patch ?? {}, {
|
|
2952
4457
|
status: toStatus,
|
|
2953
4458
|
updatedAt: new Date().toISOString(),
|
|
@@ -3123,6 +4628,7 @@ export class AgentManager {
|
|
|
3123
4628
|
branch: taskBranch,
|
|
3124
4629
|
branchCreatedByBaxian,
|
|
3125
4630
|
reviewMode: this.effectiveReviewMode(projectId),
|
|
4631
|
+
...this.platformBindingFields(projectId),
|
|
3126
4632
|
createdAt: now,
|
|
3127
4633
|
updatedAt: now,
|
|
3128
4634
|
...(imageFilenames ? { images: imageFilenames } : {}),
|
|
@@ -3198,7 +4704,7 @@ export class AgentManager {
|
|
|
3198
4704
|
const task = await this.createTask(projectId, input);
|
|
3199
4705
|
if (task.status === 'in_progress' && task.agentId) {
|
|
3200
4706
|
const signalToken = createSignalToken();
|
|
3201
|
-
await this.updateTask(task.id,
|
|
4707
|
+
await this.updateTask(task.id, this.dispatchTokenFields(task, signalToken));
|
|
3202
4708
|
const dispatchLockToken = (await this.agentStore.get(task.agentId))?.lockToken;
|
|
3203
4709
|
const start = this.startCreatedTaskSession(task.id, task.agentId, signalToken, dispatchLockToken);
|
|
3204
4710
|
if (opts.background) {
|
|
@@ -3558,7 +5064,7 @@ export class AgentManager {
|
|
|
3558
5064
|
return claim;
|
|
3559
5065
|
const claimed = claim.task;
|
|
3560
5066
|
const signalToken = createSignalToken();
|
|
3561
|
-
await this.updateTask(claimed.id,
|
|
5067
|
+
await this.updateTask(claimed.id, this.dispatchTokenFields(claimed, signalToken));
|
|
3562
5068
|
let started = false;
|
|
3563
5069
|
let dispatchErr = null;
|
|
3564
5070
|
const initialDispatch = this.resolveInitialDispatch(claimed);
|
|
@@ -3752,7 +5258,11 @@ export class AgentManager {
|
|
|
3752
5258
|
else {
|
|
3753
5259
|
if (!task.branch)
|
|
3754
5260
|
throw new Error(`Task ${taskId} has no task branch`);
|
|
3755
|
-
await branchManager.switchToTaskBranch(workdir, taskId, task.branch, task.branchCreatedByBaxian === true
|
|
5261
|
+
await branchManager.switchToTaskBranch(workdir, taskId, task.branch, task.branchCreatedByBaxian === true, task.branchLocalCleaned
|
|
5262
|
+
? { restorableRemoteTip: task.branchLocalCleaned.remoteTipSha }
|
|
5263
|
+
: {});
|
|
5264
|
+
if (task.branchLocalCleaned)
|
|
5265
|
+
await this.clearBranchLocalCleaned(taskId);
|
|
3756
5266
|
}
|
|
3757
5267
|
await assertOwner();
|
|
3758
5268
|
if (!ensure.freshRuntime) {
|
|
@@ -3997,7 +5507,7 @@ export class AgentManager {
|
|
|
3997
5507
|
throw err;
|
|
3998
5508
|
}
|
|
3999
5509
|
}
|
|
4000
|
-
async injectAndAwaitAck(tmux, paneId, prompt, agentId, runtime) {
|
|
5510
|
+
async injectAndAwaitAck(tmux, paneId, prompt, agentId, runtime, guardBeforePaste) {
|
|
4001
5511
|
const before = await this.agentStore.get(agentId);
|
|
4002
5512
|
const beforeLockToken = before?.taskId
|
|
4003
5513
|
? await this.resolveTaskLockToken(before, before.taskId)
|
|
@@ -4046,13 +5556,16 @@ export class AgentManager {
|
|
|
4046
5556
|
}
|
|
4047
5557
|
: undefined;
|
|
4048
5558
|
await revalidate?.();
|
|
4049
|
-
|
|
5559
|
+
if (guardBeforePaste && !(await guardBeforePaste())) {
|
|
5560
|
+
return { acked: false, composerDelivered: false, aborted: true };
|
|
5561
|
+
}
|
|
5562
|
+
return await this.injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, runtime, revalidate, guardBeforePaste);
|
|
4050
5563
|
}
|
|
4051
5564
|
finally {
|
|
4052
5565
|
this.compactInFlight.delete(agentId);
|
|
4053
5566
|
}
|
|
4054
5567
|
}
|
|
4055
|
-
async injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, runtime, revalidate) {
|
|
5568
|
+
async injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, runtime, revalidate, guardBeforePaste) {
|
|
4056
5569
|
// 静态忙信号(文本忙样式/working title)可能是残稿或 stale title 冒充,一律由帧活性仲裁;ready 视图覆盖 stale title。
|
|
4057
5570
|
const preTitle = await tmux.readPaneTitle(paneId);
|
|
4058
5571
|
const preFrame = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
|
|
@@ -4062,15 +5575,87 @@ export class AgentManager {
|
|
|
4062
5575
|
if (staticBusy && await this.paneHasLiveTurn(tmux, paneId, runtime)) {
|
|
4063
5576
|
throw new Error(`pre-inject busy check: pane ${paneId} is still running a turn; dispatch aborted`);
|
|
4064
5577
|
}
|
|
4065
|
-
await tmux.clearComposerDraft(paneId);
|
|
4066
5578
|
await revalidate?.();
|
|
4067
|
-
|
|
5579
|
+
if (guardBeforePaste) {
|
|
5580
|
+
// A stale replay must not touch the pane at all: the guard runs before the first composer scrub.
|
|
5581
|
+
if (!(await guardBeforePaste())) {
|
|
5582
|
+
return { acked: false, composerDelivered: false, aborted: true };
|
|
5583
|
+
}
|
|
5584
|
+
const staged = await tmux.stagePromptBuffer(paneId, prompt, agentId);
|
|
5585
|
+
// Fence + scrub + paste share one task-mutation-queue slot, so a rotation lands before or after, never between.
|
|
5586
|
+
let pasted = false;
|
|
5587
|
+
try {
|
|
5588
|
+
pasted = await this.withTaskLock(async () => {
|
|
5589
|
+
if (!(await guardBeforePaste()))
|
|
5590
|
+
return false;
|
|
5591
|
+
await tmux.clearComposerDraft(paneId);
|
|
5592
|
+
await tmux.pasteStagedBuffer(paneId, staged.buf);
|
|
5593
|
+
return true;
|
|
5594
|
+
});
|
|
5595
|
+
}
|
|
5596
|
+
catch (pasteErr) {
|
|
5597
|
+
// Buffer still present proves the paste never landed; missing/unprobeable fails closed to a composer scrub.
|
|
5598
|
+
let bufferConsumed = true;
|
|
5599
|
+
try {
|
|
5600
|
+
await tmux.dropStagedBuffer(staged.buf);
|
|
5601
|
+
bufferConsumed = false;
|
|
5602
|
+
}
|
|
5603
|
+
catch (dropErr) {
|
|
5604
|
+
console.warn(`[AgentManager] staged buffer ${staged.buf} not confirmed dropped after failed paste (missing or unprobeable); failing closed to a composer scrub:`, dropErr);
|
|
5605
|
+
}
|
|
5606
|
+
if (bufferConsumed) {
|
|
5607
|
+
try {
|
|
5608
|
+
await tmux.clearComposerDraft(paneId);
|
|
5609
|
+
}
|
|
5610
|
+
catch (clearErr) {
|
|
5611
|
+
console.warn(`[AgentManager] composer scrub after unknown paste outcome failed for pane ${paneId}:`, clearErr);
|
|
5612
|
+
}
|
|
5613
|
+
}
|
|
5614
|
+
throw pasteErr;
|
|
5615
|
+
}
|
|
5616
|
+
if (!pasted) {
|
|
5617
|
+
try {
|
|
5618
|
+
await tmux.dropStagedBuffer(staged.buf);
|
|
5619
|
+
}
|
|
5620
|
+
catch (err) {
|
|
5621
|
+
console.warn(`[AgentManager] staged buffer ${staged.buf} cleanup failed:`, err);
|
|
5622
|
+
}
|
|
5623
|
+
return { acked: false, composerDelivered: false, aborted: true };
|
|
5624
|
+
}
|
|
5625
|
+
}
|
|
5626
|
+
else {
|
|
5627
|
+
await tmux.clearComposerDraft(paneId);
|
|
5628
|
+
await revalidate?.();
|
|
5629
|
+
await tmux.injectPrompt(paneId, prompt, agentId);
|
|
5630
|
+
}
|
|
4068
5631
|
let baseline;
|
|
4069
5632
|
let baselineTitle = '';
|
|
4070
5633
|
try {
|
|
4071
5634
|
baseline = await tmux.captureSettledSnapshot(paneId, { timeoutMs: this.dispatchSettleTimeoutMs });
|
|
4072
5635
|
baselineTitle = await tmux.readPaneTitle(paneId);
|
|
4073
|
-
|
|
5636
|
+
if (guardBeforePaste) {
|
|
5637
|
+
// The Enter is the actual submission; it takes the same fenced queue slot.
|
|
5638
|
+
const submitted = await this.withTaskLock(async () => {
|
|
5639
|
+
if (!(await guardBeforePaste()))
|
|
5640
|
+
return false;
|
|
5641
|
+
await tmux.sendEnter(paneId);
|
|
5642
|
+
return true;
|
|
5643
|
+
});
|
|
5644
|
+
if (!submitted) {
|
|
5645
|
+
try {
|
|
5646
|
+
await tmux.clearComposerDraft(paneId);
|
|
5647
|
+
}
|
|
5648
|
+
catch (err) {
|
|
5649
|
+
// Fail closed: a submit-ready stale prompt could not be confirmed gone.
|
|
5650
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5651
|
+
throw new Error(`fence-rejected prompt could not be scrubbed from the composer of pane ${paneId}; verify the pane before reuse: ${message}`);
|
|
5652
|
+
}
|
|
5653
|
+
return { acked: false, composerDelivered: false, aborted: true };
|
|
5654
|
+
}
|
|
5655
|
+
}
|
|
5656
|
+
else {
|
|
5657
|
+
await tmux.sendEnter(paneId);
|
|
5658
|
+
}
|
|
4074
5659
|
}
|
|
4075
5660
|
catch (preAckErr) {
|
|
4076
5661
|
if (await this.clearComposerForReuse(tmux, paneId, agentId))
|
|
@@ -4078,11 +5663,24 @@ export class AgentManager {
|
|
|
4078
5663
|
const message = preAckErr instanceof Error ? preAckErr.message : String(preAckErr);
|
|
4079
5664
|
throw new DispatchTerminalError('ack_unknown', `pre-ack failure left an unconfirmed composer on live pane ${paneId}: ${message}`);
|
|
4080
5665
|
}
|
|
5666
|
+
let staleDuringResend = false;
|
|
4081
5667
|
try {
|
|
4082
5668
|
await tmux.waitSubmitAck(paneId, baseline, runtime, {
|
|
4083
5669
|
timeoutMs: this.dispatchAckTimeoutMs,
|
|
4084
5670
|
baselineTitle,
|
|
4085
|
-
resend:
|
|
5671
|
+
resend: guardBeforePaste
|
|
5672
|
+
? async () => {
|
|
5673
|
+
// A resend is a fresh submission of whatever sits in the composer; it takes the same fence.
|
|
5674
|
+
const resent = await this.withTaskLock(async () => {
|
|
5675
|
+
if (!(await guardBeforePaste()))
|
|
5676
|
+
return false;
|
|
5677
|
+
await tmux.sendEnter(paneId);
|
|
5678
|
+
return true;
|
|
5679
|
+
});
|
|
5680
|
+
if (!resent)
|
|
5681
|
+
staleDuringResend = true;
|
|
5682
|
+
}
|
|
5683
|
+
: () => tmux.sendEnter(paneId),
|
|
4086
5684
|
resendIntervalMs: this.dispatchAckResendIntervalMs,
|
|
4087
5685
|
});
|
|
4088
5686
|
return { acked: true, composerDelivered: true };
|
|
@@ -4092,6 +5690,16 @@ export class AgentManager {
|
|
|
4092
5690
|
if (!(err instanceof Error && /runtime ack timeout/.test(err.message))) {
|
|
4093
5691
|
throw new DispatchTerminalError('ack_unknown', `ack_unknown for pane ${paneId}: ${message}`);
|
|
4094
5692
|
}
|
|
5693
|
+
if (staleDuringResend) {
|
|
5694
|
+
try {
|
|
5695
|
+
await tmux.clearComposerDraft(paneId);
|
|
5696
|
+
}
|
|
5697
|
+
catch (scrubErr) {
|
|
5698
|
+
const scrubMessage = scrubErr instanceof Error ? scrubErr.message : String(scrubErr);
|
|
5699
|
+
throw new Error(`fence-rejected resend left an unverified composer on pane ${paneId}; verify the pane before reuse: ${scrubMessage}`);
|
|
5700
|
+
}
|
|
5701
|
+
return { acked: false, composerDelivered: false, aborted: true };
|
|
5702
|
+
}
|
|
4095
5703
|
console.warn(`[AgentManager] dispatch ack timeout for pane ${paneId} agent ${agentId}: ${message}`);
|
|
4096
5704
|
const state = await this.agentStore.get(agentId).catch(() => null);
|
|
4097
5705
|
await this.emitIntervention(state?.projectId ?? '', agentId, state?.taskId, {
|
|
@@ -4201,7 +5809,7 @@ export class AgentManager {
|
|
|
4201
5809
|
const reason = `Workdir changed during continueSession: ` +
|
|
4202
5810
|
`before=${taskWorkdir}, ensure=${ensure.workdir}, state=${verifiedWorkdir ?? 'missing'}`;
|
|
4203
5811
|
console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
|
|
4204
|
-
await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
|
|
5812
|
+
await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId, ...(opts.expectedHold ? { expectedHold: opts.expectedHold } : {}) });
|
|
4205
5813
|
return false;
|
|
4206
5814
|
}
|
|
4207
5815
|
const expectedStatuses = PHASE_EXPECTED_STATUS[phase] ?? [];
|
|
@@ -4217,11 +5825,22 @@ export class AgentManager {
|
|
|
4217
5825
|
}
|
|
4218
5826
|
if (agent.role === 'dev' && task.branch) {
|
|
4219
5827
|
const branches = new BranchManager(runner);
|
|
4220
|
-
|
|
5828
|
+
if (!opts.allowDirtyWorkdir)
|
|
5829
|
+
await branches.assertClean(verifiedWorkdir);
|
|
4221
5830
|
const actualRef = await branches.currentRef(verifiedWorkdir);
|
|
4222
5831
|
if (actualRef !== `refs/heads/${task.branch}`) {
|
|
4223
|
-
|
|
4224
|
-
|
|
5832
|
+
// dev 可能已被释放过(如 spec max_rounds 停驻默认分支);switchToTaskBranch 自带
|
|
5833
|
+
// assertClean,脏树时抛 DirtyWorkdirError fail-closed,不会覆盖未提交的改动
|
|
5834
|
+
await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
|
|
5835
|
+
await branches.switchToTaskBranch(verifiedWorkdir, taskId, task.branch, task.branchCreatedByBaxian === true, {
|
|
5836
|
+
requireExistingWork: true,
|
|
5837
|
+
...(task.branchLocalCleaned
|
|
5838
|
+
? { restorableRemoteTip: task.branchLocalCleaned.remoteTipSha }
|
|
5839
|
+
: {}),
|
|
5840
|
+
});
|
|
5841
|
+
await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
|
|
5842
|
+
if (task.branchLocalCleaned)
|
|
5843
|
+
await this.clearBranchLocalCleaned(taskId);
|
|
4225
5844
|
}
|
|
4226
5845
|
}
|
|
4227
5846
|
else if (agent.role === 'research') {
|
|
@@ -4264,6 +5883,7 @@ export class AgentManager {
|
|
|
4264
5883
|
agent,
|
|
4265
5884
|
workdir: verifiedWorkdir,
|
|
4266
5885
|
skillRegistry: this.skillRegistry,
|
|
5886
|
+
hasQaPartner: task.qaAgentId !== undefined,
|
|
4267
5887
|
...(signalToken ? { signalToken } : {}),
|
|
4268
5888
|
...(useIncrementalNudge
|
|
4269
5889
|
? { postApproveRedispatchCount: opts.postApproveRedispatchCount }
|
|
@@ -4315,17 +5935,29 @@ export class AgentManager {
|
|
|
4315
5935
|
const reason = `Workdir changed before prompt injection: ` +
|
|
4316
5936
|
`verified=${verifiedWorkdir}, current=${agentFresh.workdir ?? 'missing'}`;
|
|
4317
5937
|
console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
|
|
4318
|
-
await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
|
|
5938
|
+
await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId, ...(opts.expectedHold ? { expectedHold: opts.expectedHold } : {}) });
|
|
4319
5939
|
return false;
|
|
4320
5940
|
}
|
|
4321
5941
|
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
5942
|
+
// A revoke marks before clearing and a push transitions before clearing; every half-window must abort the paste.
|
|
5943
|
+
const postApprovePassStillLive = async () => {
|
|
5944
|
+
const [taskFresh, completionFresh] = await Promise.all([
|
|
5945
|
+
this.taskStore.get(taskId),
|
|
5946
|
+
this.getPostApproveCompletion(taskId),
|
|
5947
|
+
]);
|
|
5948
|
+
return taskFresh?.agentId === agentId
|
|
5949
|
+
&& taskFresh.status === 'approved'
|
|
5950
|
+
&& taskFresh.postApproveRevoked === undefined
|
|
5951
|
+
&& taskFresh.postApproveHeadSha !== undefined
|
|
5952
|
+
&& completionFresh !== null
|
|
5953
|
+
&& completionFresh.token === signalToken
|
|
5954
|
+
&& completionFresh.approvedHeadSha === taskFresh.postApproveHeadSha;
|
|
5955
|
+
};
|
|
5956
|
+
if (phase === 'post-approve' && !(await postApprovePassStillLive())) {
|
|
5957
|
+
console.warn(`[AgentManager] continueSession[post-approve]: pass revoked or rotated before paste for task ${taskId}; skipping`);
|
|
5958
|
+
return false;
|
|
4328
5959
|
}
|
|
5960
|
+
const guardBeforeInject = opts.guardBeforeInject ?? (phase === 'post-approve' ? postApprovePassStillLive : undefined);
|
|
4329
5961
|
if (opts.armBeforeInject && !(await opts.armBeforeInject({}))) {
|
|
4330
5962
|
return false;
|
|
4331
5963
|
}
|
|
@@ -4356,11 +5988,19 @@ export class AgentManager {
|
|
|
4356
5988
|
const reason = `Workdir changed before prompt injection: ` +
|
|
4357
5989
|
`verified=${verifiedWorkdir}, current=${stateBeforeInject.workdir ?? 'missing'}`;
|
|
4358
5990
|
console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
|
|
4359
|
-
await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
|
|
5991
|
+
await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId, ...(opts.expectedHold ? { expectedHold: opts.expectedHold } : {}) });
|
|
4360
5992
|
return false;
|
|
4361
5993
|
}
|
|
4362
5994
|
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
4363
|
-
|
|
5995
|
+
if (guardBeforeInject && !(await guardBeforeInject())) {
|
|
5996
|
+
console.warn(`[AgentManager] continueSession[${phase}]: pre-inject guard rejected the paste for task ${taskId}; skipping`);
|
|
5997
|
+
return false;
|
|
5998
|
+
}
|
|
5999
|
+
const delivery = await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime, guardBeforeInject);
|
|
6000
|
+
if (delivery.aborted) {
|
|
6001
|
+
console.warn(`[AgentManager] continueSession[${phase}]: pre-paste guard rejected the inject for task ${taskId}; skipping`);
|
|
6002
|
+
return false;
|
|
6003
|
+
}
|
|
4364
6004
|
return true;
|
|
4365
6005
|
}
|
|
4366
6006
|
async rollbackUndeliveredBootstrap(state) {
|
|
@@ -4445,6 +6085,49 @@ export class AgentManager {
|
|
|
4445
6085
|
return { ...rest, updatedAt: new Date().toISOString() };
|
|
4446
6086
|
});
|
|
4447
6087
|
}
|
|
6088
|
+
// continueSession never runs startSession's post-ack finalization; a surviving marker would make recover() roll the running task back to pending.
|
|
6089
|
+
async finalizeReplayedBootstrap(agentId, taskId, phase, expectedLockToken) {
|
|
6090
|
+
let cleared = false;
|
|
6091
|
+
let deliveredWorkdir;
|
|
6092
|
+
try {
|
|
6093
|
+
await this.agentStore.update(agentId, (latest) => {
|
|
6094
|
+
if (!latest
|
|
6095
|
+
|| latest.taskId !== taskId
|
|
6096
|
+
|| latest.bootstrappingTaskId !== taskId
|
|
6097
|
+
|| latest.lockToken !== expectedLockToken) {
|
|
6098
|
+
return AGENT_STORE_NOOP;
|
|
6099
|
+
}
|
|
6100
|
+
cleared = true;
|
|
6101
|
+
deliveredWorkdir = latest.workdir;
|
|
6102
|
+
const { bootstrappingTaskId: _delivered, ...rest } = latest;
|
|
6103
|
+
return { ...rest, updatedAt: new Date().toISOString() };
|
|
6104
|
+
});
|
|
6105
|
+
}
|
|
6106
|
+
catch (err) {
|
|
6107
|
+
console.warn(`[AgentManager] replay: clearing bootstrap marker for task=${taskId} failed:`, err);
|
|
6108
|
+
await this.markAwaitingHuman(agentId, 'bootstrap-marker-clear-failed', 'Prompt was replayed but clearing the in-flight bootstrap marker failed; held so recovery does ' +
|
|
6109
|
+
'not roll back an already-running task. Verify the agent via web terminal, then Resume.', { expectedTaskId: taskId }).catch((holdErr) => {
|
|
6110
|
+
console.warn(`[AgentManager] replay: hold after marker-clear failure for task=${taskId} failed:`, holdErr);
|
|
6111
|
+
});
|
|
6112
|
+
return;
|
|
6113
|
+
}
|
|
6114
|
+
if (!cleared)
|
|
6115
|
+
return;
|
|
6116
|
+
try {
|
|
6117
|
+
await this.eventBus.emit({
|
|
6118
|
+
id: '',
|
|
6119
|
+
type: 'session.started',
|
|
6120
|
+
timestamp: new Date().toISOString(),
|
|
6121
|
+
projectId: this.getAgentConfig(agentId)?.projectId ?? '',
|
|
6122
|
+
agentId,
|
|
6123
|
+
taskId,
|
|
6124
|
+
data: { phase, ...(deliveredWorkdir !== undefined ? { workdir: deliveredWorkdir } : {}) },
|
|
6125
|
+
});
|
|
6126
|
+
}
|
|
6127
|
+
catch (err) {
|
|
6128
|
+
console.warn(`[AgentManager] replay: session.started emit failed for task=${taskId}:`, err);
|
|
6129
|
+
}
|
|
6130
|
+
}
|
|
4448
6131
|
async releaseOrphanedLocks(states) {
|
|
4449
6132
|
const bindings = new Map(states.map(state => [state.id, state]));
|
|
4450
6133
|
const claims = await this.lockManager.listClaims();
|
|
@@ -4460,6 +6143,13 @@ export class AgentManager {
|
|
|
4460
6143
|
}
|
|
4461
6144
|
}
|
|
4462
6145
|
async recover() {
|
|
6146
|
+
// 'git' outbox 重投先行:崩溃于「效果已写、通知未达」的窗口由此闭合(spec §6),legacy 任务零涉
|
|
6147
|
+
await this.flushGitOutbox().catch(err => {
|
|
6148
|
+
console.warn('[AgentManager] recover: git outbox flush failed:', err);
|
|
6149
|
+
});
|
|
6150
|
+
await this.retryGitRemoteBranchCleanup().catch(err => {
|
|
6151
|
+
console.warn('[AgentManager] recover: git remote branch cleanup retry failed:', err);
|
|
6152
|
+
});
|
|
4463
6153
|
const states = await this.agentStore.list();
|
|
4464
6154
|
await this.releaseOrphanedLocks(states);
|
|
4465
6155
|
const deferredCleanups = [];
|
|
@@ -4850,7 +6540,11 @@ export class AgentManager {
|
|
|
4850
6540
|
}
|
|
4851
6541
|
const project = this.getProjectConfig(result.projectId);
|
|
4852
6542
|
try {
|
|
4853
|
-
if (project) {
|
|
6543
|
+
if (project && result.reviewMode === 'git') {
|
|
6544
|
+
// close 不附评论(spec §10 有意差异);deleteBranch 由 isAutoDeletableTaskBranch 守卫
|
|
6545
|
+
await this.platformClosePr(taskId, { deleteBranch: true });
|
|
6546
|
+
}
|
|
6547
|
+
else if (project) {
|
|
4854
6548
|
const close = await execNetwork(this.platformRunner, `gh pr close ${publishedCleanup.prNumber} --repo ${shellQuote(repoSlug(project.repo))} ` +
|
|
4855
6549
|
`--comment ${shellQuote('Task cancelled in baxian; closing the published PR.')}`, GH_NET);
|
|
4856
6550
|
if (close.exitCode !== 0)
|
|
@@ -4859,6 +6553,26 @@ export class AgentManager {
|
|
|
4859
6553
|
}
|
|
4860
6554
|
catch (err) {
|
|
4861
6555
|
console.warn(`[AgentManager] cancelTask remote retirement failed for ${taskId}:`, err);
|
|
6556
|
+
if (result.reviewMode === 'git') {
|
|
6557
|
+
await this.withTaskLock(async () => {
|
|
6558
|
+
const fresh = await this.taskStore.get(taskId);
|
|
6559
|
+
if (!fresh)
|
|
6560
|
+
return;
|
|
6561
|
+
await this.taskStore.set({
|
|
6562
|
+
...fresh,
|
|
6563
|
+
branchCleanupPending: {
|
|
6564
|
+
agentId: fresh.devAgentId,
|
|
6565
|
+
reason: err instanceof PlatformCloseError && err.step === 'close'
|
|
6566
|
+
? 'remote-close-failed'
|
|
6567
|
+
: 'remote-delete-failed',
|
|
6568
|
+
updatedAt: new Date().toISOString(),
|
|
6569
|
+
},
|
|
6570
|
+
updatedAt: new Date().toISOString(),
|
|
6571
|
+
});
|
|
6572
|
+
}).catch(persistErr => {
|
|
6573
|
+
console.warn(`[AgentManager] cancelTask cleanup marker persist failed for ${taskId}:`, persistErr);
|
|
6574
|
+
});
|
|
6575
|
+
}
|
|
4862
6576
|
await this.emitIntervention(result.projectId, undefined, taskId, {
|
|
4863
6577
|
phase: 'cancel-published-artifact-cleanup-failed',
|
|
4864
6578
|
afterDone: publishedCleanup.afterDone,
|
|
@@ -4966,13 +6680,38 @@ export class AgentManager {
|
|
|
4966
6680
|
? 'review'
|
|
4967
6681
|
: 'recheck';
|
|
4968
6682
|
const prevQa = await this.agentStore.get(qaId);
|
|
6683
|
+
const resumingHeldDispatch = prevQa?.taskId === taskId && prevQa.status === 'awaiting_human';
|
|
4969
6684
|
if (prevQa?.taskId === taskId) {
|
|
4970
|
-
|
|
6685
|
+
if (prevQa.status === 'awaiting_human' && !isRecoverableQaDispatchHold(prevQa)) {
|
|
6686
|
+
throw new ApiError(409, `QA agent ${qaId} is awaiting human (${prevQa.awaitingPhase ?? 'unknown phase'}); resolve that hold before redispatching review`);
|
|
6687
|
+
}
|
|
6688
|
+
const released = await this.releaseAgentIfBound(qaId, taskId, prevQa.status === 'awaiting_human'
|
|
6689
|
+
? {
|
|
6690
|
+
allowAwaitingHuman: true,
|
|
6691
|
+
expectedHold: {
|
|
6692
|
+
phase: prevQa.awaitingPhase,
|
|
6693
|
+
since: prevQa.awaitingSince,
|
|
6694
|
+
nonce: prevQa.awaitingNonce,
|
|
6695
|
+
},
|
|
6696
|
+
}
|
|
6697
|
+
: undefined);
|
|
6698
|
+
if (!released) {
|
|
6699
|
+
const after = await this.agentStore.get(qaId);
|
|
6700
|
+
const holdNote = after?.status === 'awaiting_human'
|
|
6701
|
+
? ` (awaiting_human: ${after.awaitingPhase ?? 'unknown phase'})`
|
|
6702
|
+
: '';
|
|
6703
|
+
throw new ApiError(409, `QA agent ${qaId} could not be released from task ${taskId}${holdNote}; review dispatch aborted`);
|
|
6704
|
+
}
|
|
4971
6705
|
}
|
|
4972
6706
|
const acquired = await this.acquireAgentForTask(qaId, taskId, qaPhase);
|
|
4973
6707
|
if (!acquired) {
|
|
4974
6708
|
throw new ApiError(409, `QA agent ${qaId} is busy or unavailable`);
|
|
4975
6709
|
}
|
|
6710
|
+
const acquiredClaim = await this.lockManager.claimOf(qaId);
|
|
6711
|
+
const acquiredLockToken = acquiredClaim?.taskId === taskId ? acquiredClaim.token : undefined;
|
|
6712
|
+
const releaseOwnAcquire = acquiredLockToken !== undefined
|
|
6713
|
+
? { expectedLockToken: acquiredLockToken }
|
|
6714
|
+
: {};
|
|
4976
6715
|
let devParked = false;
|
|
4977
6716
|
if (!isTerminal && devAgentId) {
|
|
4978
6717
|
const devState = await this.agentStore.get(devAgentId);
|
|
@@ -4983,7 +6722,7 @@ export class AgentManager {
|
|
|
4983
6722
|
return false;
|
|
4984
6723
|
});
|
|
4985
6724
|
if (!devOk) {
|
|
4986
|
-
await this.releaseAgentForTask(qaId, taskId, 'idle')
|
|
6725
|
+
await this.releaseAgentForTask(qaId, taskId, 'idle', releaseOwnAcquire)
|
|
4987
6726
|
.catch(() => undefined);
|
|
4988
6727
|
throw new ApiError(500, `Cannot park dev ${devAgentId} into waiting for manual QA review (task status=${taskStatusAtClaim}); QA released`);
|
|
4989
6728
|
}
|
|
@@ -4996,9 +6735,12 @@ export class AgentManager {
|
|
|
4996
6735
|
signalToken: pre?.signalToken,
|
|
4997
6736
|
reviewHeadAnchorSha: pre?.reviewHeadAnchorSha,
|
|
4998
6737
|
reviewDispatchedAt: pre?.reviewDispatchedAt,
|
|
6738
|
+
passToken: pre?.passToken,
|
|
6739
|
+
failToken: pre?.failToken,
|
|
4999
6740
|
};
|
|
5000
6741
|
const isTerminalAtClaim = TERMINAL_STATUSES.includes(taskStatusAtClaim);
|
|
5001
|
-
|
|
6742
|
+
// 仅当恢复的是任务当前 review pass 时沿用轮次;任务已离开 review 的手工调用是新 pass,保留加轮
|
|
6743
|
+
const bumpRound = opts.bumpRound !== false && !(resumingHeldDispatch && taskStatusAtClaim === 'review');
|
|
5002
6744
|
// 回滚在锁内判定 pass 是否已被并发接管并返回;仅未被接管时才释放本次 acquire 的 QA,
|
|
5003
6745
|
// 否则会误清接管方 re-acquire 的同一 QA 绑定(同 task 同 agent,绕过 release 的 mismatch 保护)
|
|
5004
6746
|
const abortDispatch = async (dispatchToken) => {
|
|
@@ -5009,15 +6751,59 @@ export class AgentManager {
|
|
|
5009
6751
|
await this.emitManualReviewDevParkedQaFailedIntervention(devAgentId, taskId);
|
|
5010
6752
|
};
|
|
5011
6753
|
let transitionToken;
|
|
6754
|
+
let reviewAnchor;
|
|
5012
6755
|
if (!isTerminalAtClaim) {
|
|
5013
|
-
|
|
5014
|
-
const
|
|
6756
|
+
let gitDispatchBlocked;
|
|
6757
|
+
const anchorViaDriver = async () => {
|
|
6758
|
+
const preTask = await this.taskStore.get(taskId);
|
|
6759
|
+
if (preTask?.prNumber === undefined) {
|
|
6760
|
+
gitDispatchBlocked = 'no bound PR';
|
|
6761
|
+
return undefined;
|
|
6762
|
+
}
|
|
6763
|
+
// 与收编/pr-fixed/merge 同一完整谓词:closed/draft/retarget 的 PR 派出去只会造出
|
|
6764
|
+
// 不可裁决的评审轮(spec §6 统一谓词)
|
|
6765
|
+
const verify = await this.platformVerifyPrBinding(taskId, preTask.prNumber);
|
|
6766
|
+
if (!verify.ok) {
|
|
6767
|
+
gitDispatchBlocked = `binding ${verify.reason}`;
|
|
6768
|
+
return undefined;
|
|
6769
|
+
}
|
|
6770
|
+
return PLATFORM_HEAD_SHA_RE.test(verify.headSha) ? verify.headSha : undefined;
|
|
6771
|
+
};
|
|
6772
|
+
const isGitDispatch = (await this.taskStore.get(taskId))?.reviewMode === 'git';
|
|
6773
|
+
reviewAnchor = await (isGitDispatch ? anchorViaDriver() : this.fetchPrHeadSha(taskId))
|
|
6774
|
+
.catch(() => undefined);
|
|
6775
|
+
if (isGitDispatch && reviewAnchor === undefined) {
|
|
6776
|
+
// 无锚评审轮不可裁决(verdictEligible 要求 anchorSha 且 pane verdict 已退役):
|
|
6777
|
+
// 派出去只会卡死在 review,fail loud 让调用方看到确定性原因或重试瞬态失败
|
|
6778
|
+
const released = await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => false);
|
|
6779
|
+
if (!released) {
|
|
6780
|
+
// 预检失败叠加释放失败:QA 仍绑定会让下一次派发报 busy,必须显式暴露
|
|
6781
|
+
const blockedTask = await this.taskStore.get(taskId);
|
|
6782
|
+
await this.emitIntervention(blockedTask?.projectId ?? '', qaId, taskId, {
|
|
6783
|
+
phase: 'qa-release-failed-after-precheck',
|
|
6784
|
+
qaAgentId: qaId,
|
|
6785
|
+
}).catch(() => undefined);
|
|
6786
|
+
}
|
|
6787
|
+
if (devParked)
|
|
6788
|
+
await this.emitManualReviewDevParkedQaFailedIntervention(devAgentId, taskId);
|
|
6789
|
+
throw gitDispatchBlocked !== undefined
|
|
6790
|
+
? new ApiError(409, `Cannot dispatch git review for ${taskId}: ${gitDispatchBlocked}`)
|
|
6791
|
+
: new ApiError(502, `Cannot anchor the review round for ${taskId}: platform prView failed`);
|
|
6792
|
+
}
|
|
6793
|
+
const preDispatched = await this.transitionTaskStatus(taskId, 'review', {
|
|
6794
|
+
fromStatus: [taskStatusAtClaim],
|
|
6795
|
+
// 入口 claim 到此处之间 pass 可能被并发轮换;expect token 必须与写入同一原子段复核
|
|
6796
|
+
...(opts.expectSignalToken !== undefined ? { expectSignalToken: opts.expectSignalToken } : {}),
|
|
6797
|
+
}, {
|
|
5015
6798
|
reviewHeadAnchorSha: reviewAnchor,
|
|
5016
6799
|
reviewDispatchedAt: new Date().toISOString(),
|
|
5017
6800
|
signalToken: createSignalToken(),
|
|
6801
|
+
// pair/pending 与新轮锚点同一原子写可见(spec §7):两写之间崩溃不得留下无 pending 的新轮
|
|
6802
|
+
...(isGitDispatch ? { ...this.mintReviewTokenPair(), reviewDispatchPending: true } : {}),
|
|
5018
6803
|
});
|
|
5019
6804
|
if (!preDispatched) {
|
|
5020
|
-
|
|
6805
|
+
// fence 拒绝可能源于 successor 已换代并 re-acquire 同一 QA;释放必须钉在本次 acquire 的锁代上
|
|
6806
|
+
await this.releaseAgentForTask(qaId, taskId, 'idle', releaseOwnAcquire).catch(() => undefined);
|
|
5021
6807
|
if (devParked)
|
|
5022
6808
|
await this.emitManualReviewDevParkedQaFailedIntervention(devAgentId, taskId);
|
|
5023
6809
|
throw new ApiError(409, `Task ${taskId} status changed during dispatch; cannot enter review`);
|
|
@@ -5034,10 +6820,16 @@ export class AgentManager {
|
|
|
5034
6820
|
if (!isTerminalAtClaim && (fresh.status !== 'review' || fresh.signalToken !== transitionToken)) {
|
|
5035
6821
|
return { ok: false, reason: fresh.status !== 'review' ? `status=${fresh.status}` : 'pass superseded' };
|
|
5036
6822
|
}
|
|
6823
|
+
// 并发 push 在 prView 读取与本提交之间前进 head 时,本次手动锚点已陈旧(TOCTOU)
|
|
6824
|
+
if (fresh.reviewMode === 'git' && fresh.latestHeadSha !== undefined
|
|
6825
|
+
&& reviewAnchor !== undefined && fresh.latestHeadSha !== reviewAnchor) {
|
|
6826
|
+
return { ok: false, reason: 'head advanced past the manual anchor' };
|
|
6827
|
+
}
|
|
5037
6828
|
await this.taskStore.set({
|
|
5038
6829
|
...fresh,
|
|
5039
6830
|
reviewRound: bumpRound ? fresh.reviewRound + 1 : fresh.reviewRound,
|
|
5040
6831
|
signalToken: armedToken,
|
|
6832
|
+
...(fresh.reviewMode === 'git' ? { reviewDispatchPending: true } : {}),
|
|
5041
6833
|
updatedAt: new Date().toISOString(),
|
|
5042
6834
|
});
|
|
5043
6835
|
return { ok: true };
|
|
@@ -5047,7 +6839,11 @@ export class AgentManager {
|
|
|
5047
6839
|
await abortDispatch(transitionToken);
|
|
5048
6840
|
throw new ApiError(409, `Task ${taskId} left its review pass during dispatch (${claim2.reason})`);
|
|
5049
6841
|
}
|
|
5050
|
-
const
|
|
6842
|
+
const dispatchTaskAfterClaim = await this.taskStore.get(taskId);
|
|
6843
|
+
const armed = await this.setupPhaseSignalWatcher(taskId, qaId, ['pr-approved', 'pr-changes-requested'], armedToken, dispatchTaskAfterClaim?.reviewMode === 'git' ? { replaceScope: 'agent' } : {});
|
|
6844
|
+
if (armed && dispatchTaskAfterClaim?.reviewMode === 'git') {
|
|
6845
|
+
await this.rearmGitReconciliationWatcher(taskId);
|
|
6846
|
+
}
|
|
5051
6847
|
if (!armed) {
|
|
5052
6848
|
await abortDispatch(armedToken);
|
|
5053
6849
|
throw new ApiError(500, `Failed to arm review verdict watcher for ${taskId}`);
|
|
@@ -5068,11 +6864,17 @@ export class AgentManager {
|
|
|
5068
6864
|
});
|
|
5069
6865
|
}
|
|
5070
6866
|
catch (err) {
|
|
5071
|
-
if (
|
|
5072
|
-
|
|
5073
|
-
|
|
6867
|
+
if (err instanceof DispatchTerminalError && err.reason === 'ack_unknown') {
|
|
6868
|
+
// prompt 可能已注入:不回滚 armed pass、不释放 QA;hold 持久化失败也不得吞掉 ack_unknown 分类
|
|
6869
|
+
try {
|
|
6870
|
+
await this.markAwaitingIfAckUnknown(qaId, err, taskId);
|
|
6871
|
+
}
|
|
6872
|
+
catch (holdErr) {
|
|
6873
|
+
console.error(`[AgentManager] dispatchReviewToQa could not persist the ack_unknown hold for ${qaId}:`, holdErr);
|
|
6874
|
+
}
|
|
6875
|
+
throw err;
|
|
5074
6876
|
}
|
|
5075
|
-
|
|
6877
|
+
if (!(err instanceof EnsureSessionError && err.partial.handled)) {
|
|
5076
6878
|
await abortDispatch(armedToken);
|
|
5077
6879
|
}
|
|
5078
6880
|
throw err;
|
|
@@ -5081,6 +6883,7 @@ export class AgentManager {
|
|
|
5081
6883
|
await abortDispatch(armedToken);
|
|
5082
6884
|
throw new ApiError(500, `Failed to start QA review session for ${taskId}`);
|
|
5083
6885
|
}
|
|
6886
|
+
await this.clearReviewDispatchPending(taskId, armedToken);
|
|
5084
6887
|
const final = await this.taskStore.get(taskId);
|
|
5085
6888
|
return final;
|
|
5086
6889
|
}
|
|
@@ -5245,6 +7048,9 @@ export class AgentManager {
|
|
|
5245
7048
|
qaAgentId: snapshot.qaAgentId,
|
|
5246
7049
|
signalToken: snapshot.signalToken,
|
|
5247
7050
|
reviewDispatchedAt: snapshot.reviewDispatchedAt,
|
|
7051
|
+
// 本代新铸的 pair 从未交付 QA:不随快照恢复会让旧 QA 的在途 verdict 被拒、新 pair 无人知
|
|
7052
|
+
passToken: snapshot.passToken,
|
|
7053
|
+
failToken: snapshot.failToken,
|
|
5248
7054
|
updatedAt: new Date().toISOString(),
|
|
5249
7055
|
};
|
|
5250
7056
|
if (!isTerminalAtClaim && !drifted) {
|
|
@@ -5262,8 +7068,10 @@ export class AgentManager {
|
|
|
5262
7068
|
return qaTakenOverInReview;
|
|
5263
7069
|
}
|
|
5264
7070
|
if (afterRollback?.status === 'approved') {
|
|
5265
|
-
const completion =
|
|
5266
|
-
|
|
7071
|
+
const completion = afterRollback.reviewMode === 'git'
|
|
7072
|
+
? this.gitCompletionOf(afterRollback)
|
|
7073
|
+
: await this.postApproveStore.get(taskId);
|
|
7074
|
+
const task = afterRollback;
|
|
5267
7075
|
if (completion && task) {
|
|
5268
7076
|
try {
|
|
5269
7077
|
await this.startPhaseSignalWatch({
|
|
@@ -5284,21 +7092,30 @@ export class AgentManager {
|
|
|
5284
7092
|
const restored = await this.taskStore.get(taskId);
|
|
5285
7093
|
if (!restored || !restored.signalToken)
|
|
5286
7094
|
return qaTakenOverInReview;
|
|
5287
|
-
const
|
|
5288
|
-
if (
|
|
7095
|
+
const mappings = this.mapTaskStateToExpectedWatchers(restored);
|
|
7096
|
+
if (mappings.length === 0)
|
|
5289
7097
|
return qaTakenOverInReview;
|
|
7098
|
+
for (const mapped of mappings) {
|
|
7099
|
+
try {
|
|
7100
|
+
await this.startPhaseSignalWatch({
|
|
7101
|
+
taskId,
|
|
7102
|
+
projectId: restored.projectId,
|
|
7103
|
+
agentId: mapped.agentId,
|
|
7104
|
+
expectedKinds: mapped.expectedKinds,
|
|
7105
|
+
token: mapped.token ?? restored.signalToken,
|
|
7106
|
+
...(mappings.length > 1 ? { replaceScope: 'agent' } : {}),
|
|
7107
|
+
});
|
|
7108
|
+
}
|
|
7109
|
+
catch (err) {
|
|
7110
|
+
console.warn(`[AgentManager] rollback: re-establish ${mapped.expectedKinds.join(',')} failed for task=${taskId}:`, err);
|
|
7111
|
+
}
|
|
7112
|
+
}
|
|
7113
|
+
// 回滚路径不得因 tearDown 探测失败上抛:那会顶替调用方本应抛出的 ApiError 并跳过 QA 释放
|
|
5290
7114
|
try {
|
|
5291
|
-
await this.startPhaseSignalWatch({
|
|
5292
|
-
taskId,
|
|
5293
|
-
projectId: restored.projectId,
|
|
5294
|
-
agentId: mapped.agentId,
|
|
5295
|
-
expectedKinds: mapped.expectedKinds,
|
|
5296
|
-
token: restored.signalToken,
|
|
5297
|
-
});
|
|
5298
7115
|
await this.tearDownWatcherIfTaskTerminal(taskId);
|
|
5299
7116
|
}
|
|
5300
7117
|
catch (err) {
|
|
5301
|
-
console.warn(`[AgentManager] rollback:
|
|
7118
|
+
console.warn(`[AgentManager] rollback: terminal watcher teardown failed for task=${taskId}:`, err);
|
|
5302
7119
|
}
|
|
5303
7120
|
return qaTakenOverInReview;
|
|
5304
7121
|
}
|
|
@@ -5320,6 +7137,22 @@ export class AgentManager {
|
|
|
5320
7137
|
return { phase: 'research', kinds: ['spec-done'] };
|
|
5321
7138
|
return { phase: 'develop', kinds: this.devInitialSignalKinds(task.reviewMode) };
|
|
5322
7139
|
}
|
|
7140
|
+
mapTaskStateToExpectedWatchers(task) {
|
|
7141
|
+
const single = this.mapTaskStateToExpectedWatcher(task);
|
|
7142
|
+
const mappings = single ? [single] : [];
|
|
7143
|
+
// review 窗口内 dev/QA 双条目并存:review 之外 dev 侧另有 pr-fixed/pr-merge-ready 同 agent
|
|
7144
|
+
// 期望,(taskId, agentId) 基数无法同 agent 双 token 并存——错过窗口按 fail-closed 交人工(spec §5.3 ④)
|
|
7145
|
+
if (task.reviewMode === 'git' && task.status === 'review'
|
|
7146
|
+
&& task.replyActorStatus !== 'verified' && task.pendingPrSignalToken !== undefined
|
|
7147
|
+
&& task.devAgentId && task.prNumber !== undefined) {
|
|
7148
|
+
mappings.push({
|
|
7149
|
+
expectedKinds: ['pr-created'],
|
|
7150
|
+
agentId: task.devAgentId,
|
|
7151
|
+
token: task.pendingPrSignalToken,
|
|
7152
|
+
});
|
|
7153
|
+
}
|
|
7154
|
+
return mappings;
|
|
7155
|
+
}
|
|
5323
7156
|
mapTaskStateToExpectedWatcher(task) {
|
|
5324
7157
|
if (task.reviewMode === 'server')
|
|
5325
7158
|
return this.mapServerTaskToExpectedWatcher(task);
|
|
@@ -5372,7 +7205,10 @@ export class AgentManager {
|
|
|
5372
7205
|
const task = await this.taskStore.get(taskId);
|
|
5373
7206
|
if (!task?.signalToken)
|
|
5374
7207
|
return false;
|
|
5375
|
-
return this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, task.signalToken,
|
|
7208
|
+
return this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, opts.tokenOverride ?? task.signalToken, {
|
|
7209
|
+
skipSnapshot: opts.skipSnapshot ?? false,
|
|
7210
|
+
...(opts.replaceScope ? { replaceScope: opts.replaceScope } : {}),
|
|
7211
|
+
});
|
|
5376
7212
|
}
|
|
5377
7213
|
async emitManualReviewDevParkedQaFailedIntervention(agentId, expectedTaskId) {
|
|
5378
7214
|
if (!agentId)
|
|
@@ -5479,6 +7315,17 @@ export class AgentManager {
|
|
|
5479
7315
|
if (!project) {
|
|
5480
7316
|
throw new Error(`mergePr: unknown project ${task.projectId}`);
|
|
5481
7317
|
}
|
|
7318
|
+
if (task.reviewMode === 'git') {
|
|
7319
|
+
const expectedHeadSha = opts.matchHeadSha ?? task.latestHeadSha;
|
|
7320
|
+
if (!expectedHeadSha)
|
|
7321
|
+
throw new Error(`mergePr: no expected head sha for git task ${taskId}`);
|
|
7322
|
+
// max_rounds 的 Mark complete 在调用前已被转成 merge-ready:override 必须显式随调用链传入
|
|
7323
|
+
await this.platformConfirmMerge(taskId, {
|
|
7324
|
+
expectedHeadSha,
|
|
7325
|
+
...(opts.humanOverride === true || task.status === 'max_rounds' ? { humanOverride: true } : {}),
|
|
7326
|
+
});
|
|
7327
|
+
return;
|
|
7328
|
+
}
|
|
5482
7329
|
const matchHead = opts.matchHeadSha
|
|
5483
7330
|
? ` --match-head-commit ${shellQuote(opts.matchHeadSha)}`
|
|
5484
7331
|
: '';
|
|
@@ -5553,7 +7400,8 @@ export class AgentManager {
|
|
|
5553
7400
|
throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
|
|
5554
7401
|
}
|
|
5555
7402
|
try {
|
|
5556
|
-
|
|
7403
|
+
// 从 max_rounds 认领而来:人工兜底 override 必须显式随调用链传入(此刻 status 已是 merge-ready)
|
|
7404
|
+
await this.mergePr(taskId, { humanOverride: true });
|
|
5557
7405
|
}
|
|
5558
7406
|
catch (err) {
|
|
5559
7407
|
await this.transitionTaskStatus(taskId, 'max_rounds', { fromStatus: ['merge-ready'] })
|
|
@@ -5868,7 +7716,7 @@ export class AgentManager {
|
|
|
5868
7716
|
}
|
|
5869
7717
|
return publishDispatchedAt ? 'delivered' : false;
|
|
5870
7718
|
}
|
|
5871
|
-
async setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token,
|
|
7719
|
+
async setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, opts = {}) {
|
|
5872
7720
|
if (!this.phaseSignalWatcher)
|
|
5873
7721
|
return true;
|
|
5874
7722
|
const task = await this.taskStore.get(taskId);
|
|
@@ -5881,8 +7729,10 @@ export class AgentManager {
|
|
|
5881
7729
|
agentId,
|
|
5882
7730
|
expectedKinds,
|
|
5883
7731
|
token,
|
|
5884
|
-
skipSnapshot,
|
|
5885
|
-
...(
|
|
7732
|
+
skipSnapshot: opts.skipSnapshot ?? false,
|
|
7733
|
+
...(opts.onlyReplaceOwnToken ? { onlyReplaceOwnToken: true } : {}),
|
|
7734
|
+
...(opts.replaceScope ? { replaceScope: opts.replaceScope } : {}),
|
|
7735
|
+
...(opts.onReadFile ? { onReadFile: opts.onReadFile } : {}),
|
|
5886
7736
|
});
|
|
5887
7737
|
}
|
|
5888
7738
|
catch (err) {
|
|
@@ -5891,7 +7741,10 @@ export class AgentManager {
|
|
|
5891
7741
|
}
|
|
5892
7742
|
}
|
|
5893
7743
|
async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false, onReadFile) {
|
|
5894
|
-
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token,
|
|
7744
|
+
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, {
|
|
7745
|
+
skipSnapshot,
|
|
7746
|
+
...(onReadFile ? { onReadFile } : {}),
|
|
7747
|
+
});
|
|
5895
7748
|
if (!armed)
|
|
5896
7749
|
await this.holdAgentForUnarmedSignal(taskId, agentId, expectedKinds);
|
|
5897
7750
|
}
|
|
@@ -5899,10 +7752,40 @@ export class AgentManager {
|
|
|
5899
7752
|
const label = (Array.isArray(expectedKinds) ? [...expectedKinds] : [expectedKinds]).join(',');
|
|
5900
7753
|
await this.markAwaitingHuman(agentId, `signal-arm-failed:${label}`, 'Pane-signal watcher failed to arm after dispatch; the prompt expects a signal with no consumer. Cancel the task or delete the agent to retry.', { expectedTaskId: taskId });
|
|
5901
7754
|
}
|
|
7755
|
+
// git 任务的初始 dev 派发把 pr-created 期望 token 独立持久化:评审轮轮换不得使 late
|
|
7756
|
+
// publish 信号失去 reconciliation 凭据(spec §5.3 ④)。
|
|
7757
|
+
dispatchTokenFields(task, signalToken) {
|
|
7758
|
+
const wantsPrCreated = task.reviewMode === 'git'
|
|
7759
|
+
&& this.devInitialSignalKinds(task.reviewMode).includes('pr-created');
|
|
7760
|
+
return { signalToken, ...(wantsPrCreated ? { pendingPrSignalToken: signalToken } : {}) };
|
|
7761
|
+
}
|
|
7762
|
+
// review 窗口内 QA verdict arm 之后重建 dev reconciliation 条目(spec §5.3 ④):approval 路径的
|
|
7763
|
+
// stop(taskId) 与 fix 轮的同键替换都会拆掉它,重入 review 时在此恢复;skipSnapshot=false 让仍在
|
|
7764
|
+
// 面板滚动区的迟到信号立即重放。
|
|
7765
|
+
async rearmGitReconciliationWatcher(taskId, opts = {}) {
|
|
7766
|
+
const task = await this.taskStore.get(taskId);
|
|
7767
|
+
if (!task || task.reviewMode !== 'git' || task.status !== 'review')
|
|
7768
|
+
return;
|
|
7769
|
+
if (task.replyActorStatus === 'verified' || task.pendingPrSignalToken === undefined)
|
|
7770
|
+
return;
|
|
7771
|
+
if (!task.devAgentId || task.prNumber === undefined)
|
|
7772
|
+
return;
|
|
7773
|
+
await this.setupPhaseSignalWatcher(taskId, task.devAgentId, ['pr-created'], task.pendingPrSignalToken, {
|
|
7774
|
+
replaceScope: 'agent',
|
|
7775
|
+
...(opts.skipSnapshot ? { skipSnapshot: true } : {}),
|
|
7776
|
+
}).catch(() => false);
|
|
7777
|
+
}
|
|
5902
7778
|
async rotateAndSetupPhaseSignal(taskId, agentId, expectedKinds) {
|
|
5903
7779
|
const newToken = createSignalToken();
|
|
7780
|
+
const task = await this.taskStore.get(taskId);
|
|
7781
|
+
const kinds = typeof expectedKinds === 'string' ? [expectedKinds] : [...expectedKinds];
|
|
7782
|
+
const isGit = task?.reviewMode === 'git';
|
|
7783
|
+
// pair 的重铸已随「进入 review 的 transition」单写落盘(spec §7 与新轮可见性原子);
|
|
7784
|
+
// 这里只轮换 signal token 并布防
|
|
5904
7785
|
await this.updateTask(taskId, { signalToken: newToken });
|
|
5905
|
-
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, newToken);
|
|
7786
|
+
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, newToken, isGit ? { replaceScope: 'agent' } : {});
|
|
7787
|
+
if (armed && isGit && kinds.includes('pr-approved'))
|
|
7788
|
+
await this.rearmGitReconciliationWatcher(taskId);
|
|
5906
7789
|
return { token: newToken, armed };
|
|
5907
7790
|
}
|
|
5908
7791
|
async rollbackVerdictArmFailure(taskId, restore, opts = {}) {
|
|
@@ -5922,6 +7805,9 @@ export class AgentManager {
|
|
|
5922
7805
|
signalToken: restore.signalToken,
|
|
5923
7806
|
reviewHeadAnchorSha: restore.reviewHeadAnchorSha,
|
|
5924
7807
|
reviewDispatchedAt: restore.reviewDispatchedAt,
|
|
7808
|
+
...(restore.restorePair === true
|
|
7809
|
+
? { passToken: restore.passToken, failToken: restore.failToken }
|
|
7810
|
+
: {}),
|
|
5925
7811
|
updatedAt: new Date().toISOString(),
|
|
5926
7812
|
});
|
|
5927
7813
|
return true;
|
|
@@ -5939,10 +7825,16 @@ export class AgentManager {
|
|
|
5939
7825
|
return;
|
|
5940
7826
|
if (TERMINAL_STATUSES.includes(task.status))
|
|
5941
7827
|
return;
|
|
5942
|
-
const
|
|
5943
|
-
if (
|
|
7828
|
+
const mappings = this.mapTaskStateToExpectedWatchers(task);
|
|
7829
|
+
if (mappings.length === 0)
|
|
5944
7830
|
return;
|
|
5945
|
-
|
|
7831
|
+
for (const mapped of mappings) {
|
|
7832
|
+
await this.setupPhaseSignal(taskId, mapped.agentId, mapped.expectedKinds, {
|
|
7833
|
+
...(opts.skipSnapshot ? { skipSnapshot: true } : {}),
|
|
7834
|
+
...(mappings.length > 1 ? { replaceScope: 'agent' } : {}),
|
|
7835
|
+
...(mapped.token !== undefined ? { tokenOverride: mapped.token } : {}),
|
|
7836
|
+
});
|
|
7837
|
+
}
|
|
5946
7838
|
await this.tearDownWatcherIfTaskTerminal(taskId);
|
|
5947
7839
|
}
|
|
5948
7840
|
async parkTaskAtSpecReady(taskId, opts = {}) {
|
|
@@ -5980,8 +7872,9 @@ export class AgentManager {
|
|
|
5980
7872
|
const fresh = await this.taskStore.get(taskId);
|
|
5981
7873
|
if (!fresh)
|
|
5982
7874
|
throw new ApiError(404, `Task ${taskId} not found`);
|
|
5983
|
-
|
|
5984
|
-
|
|
7875
|
+
const specMaxRounds = fresh.status === 'max_rounds' && isSpecStagePhase(fresh.phase);
|
|
7876
|
+
if (fresh.status !== 'spec-ready' && !specMaxRounds) {
|
|
7877
|
+
throw new ApiError(409, `Task ${taskId} is ${fresh.status}; spec verdict requires spec-ready or spec-phase max_rounds`);
|
|
5985
7878
|
}
|
|
5986
7879
|
if (verdict === 'archive' && !fresh.researchAgentId) {
|
|
5987
7880
|
throw new ApiError(409, `Task ${taskId} is not a Research task; only Research specs can be archived`);
|
|
@@ -6016,7 +7909,7 @@ export class AgentManager {
|
|
|
6016
7909
|
...base,
|
|
6017
7910
|
userDecision: { verdict, ...(trimmed ? { comments: trimmed } : {}), at },
|
|
6018
7911
|
});
|
|
6019
|
-
const transition = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['spec-ready'] });
|
|
7912
|
+
const transition = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['spec-ready', 'max_rounds'] });
|
|
6020
7913
|
if (!transition)
|
|
6021
7914
|
throw new ApiError(409, `Task ${taskId} changed while archiving`);
|
|
6022
7915
|
this.stopPhaseSignalWatcher(taskId);
|
|
@@ -6042,18 +7935,40 @@ export class AgentManager {
|
|
|
6042
7935
|
}
|
|
6043
7936
|
if (verdict === 'approve') {
|
|
6044
7937
|
await store.putRound(taskId, 'spec', { ...base, userDecision: { verdict, at } });
|
|
6045
|
-
|
|
7938
|
+
let result;
|
|
7939
|
+
try {
|
|
7940
|
+
result = await this.transitionToCodePhase(taskId);
|
|
7941
|
+
}
|
|
7942
|
+
catch (err) {
|
|
7943
|
+
if (err instanceof ApiError)
|
|
7944
|
+
throw err;
|
|
7945
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
7946
|
+
const after = await this.taskStore.get(taskId);
|
|
7947
|
+
if (after?.status === 'failed') {
|
|
7948
|
+
const reason = err instanceof DispatchTerminalError ? ` (${err.reason})` : '';
|
|
7949
|
+
throw new ApiError(500, `Code-phase dispatch failed terminally for task ${taskId}${reason}: ${message}. `
|
|
7950
|
+
+ 'The task has been marked failed; use Retry to run it as a fresh task.');
|
|
7951
|
+
}
|
|
7952
|
+
if (after?.phase === 'code' && after.status === 'in_progress') {
|
|
7953
|
+
throw new ApiError(500, `Code-phase dispatch failed for task ${taskId}: ${message}. `
|
|
7954
|
+
+ 'The task already moved to the code phase and the dev is held; '
|
|
7955
|
+
+ 'Resume the dev agent to redeliver the prompt.');
|
|
7956
|
+
}
|
|
7957
|
+
throw new ApiError(500, `Code-phase dispatch failed for task ${taskId}: ${message}. `
|
|
7958
|
+
+ 'The task was rolled back to await the verdict; fix the dev Workdir '
|
|
7959
|
+
+ '(Resume the dev first if it is still held), then retry the verdict.');
|
|
7960
|
+
}
|
|
6046
7961
|
if (!result) {
|
|
7962
|
+
const after = await this.taskStore.get(taskId);
|
|
7963
|
+
if (after?.phase === 'code' && after.status === 'in_progress') {
|
|
7964
|
+
throw new ApiError(500, `Code-phase prompt was not delivered for task ${taskId}; the task already moved to the `
|
|
7965
|
+
+ 'code phase and the dev is held. Resume the dev agent to redeliver the prompt '
|
|
7966
|
+
+ '(retrying the verdict will be rejected).');
|
|
7967
|
+
}
|
|
6047
7968
|
throw new ApiError(409, `Dev is unavailable for task ${taskId}; approval was recorded and can be retried`);
|
|
6048
7969
|
}
|
|
6049
7970
|
return result;
|
|
6050
7971
|
}
|
|
6051
|
-
// 打回消耗一轮修订;到达上限时拒绝而非派发注定进 max_rounds 的修订,让用户当场决策
|
|
6052
|
-
const cap = this.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
|
|
6053
|
-
if (round >= cap) {
|
|
6054
|
-
throw new ApiError(409, `Task ${taskId} has reached the spec review round cap (${cap}); `
|
|
6055
|
-
+ 'approve the spec, or cancel the task and retry with a refined description');
|
|
6056
|
-
}
|
|
6057
7972
|
if (!trimmed)
|
|
6058
7973
|
throw new ApiError(400, 'comments is required for request-changes');
|
|
6059
7974
|
const priorFindings = base.findings?.findings ?? [];
|
|
@@ -6063,16 +7978,106 @@ export class AgentManager {
|
|
|
6063
7978
|
verdict: 'request-changes',
|
|
6064
7979
|
findings: [...priorFindings, { id: nextUserId, severity: 'major', message: trimmed }],
|
|
6065
7980
|
};
|
|
6066
|
-
|
|
7981
|
+
const rejectedRound = {
|
|
6067
7982
|
...base,
|
|
6068
7983
|
findings: mergedFindings,
|
|
6069
7984
|
userDecision: { verdict, comments: trimmed, at },
|
|
6070
|
-
}
|
|
6071
|
-
|
|
7985
|
+
};
|
|
7986
|
+
// 到达上限后用户仍显式打回 = 对「再评一轮」的当场决策:扩 cap 而非拒绝。
|
|
7987
|
+
// holder 恢复与扩 cap 正交:max_rounds 入态时 holder 已被释放清空,即便 cap
|
|
7988
|
+
// 因配置热更新出现余量,打回前也必须先恢复 holder
|
|
7989
|
+
const cap = this.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
|
|
7990
|
+
const claim = task.status === 'max_rounds' || round >= cap
|
|
7991
|
+
? await this.claimSpecFixDispatch(taskId, { extendCapToFit: round >= cap ? round : null })
|
|
7992
|
+
: null;
|
|
7993
|
+
let dispatched = null;
|
|
7994
|
+
try {
|
|
7995
|
+
await store.putRound(taskId, 'spec', rejectedRound);
|
|
7996
|
+
dispatched = await this.dispatchServerFixToDev(taskId, JSON.stringify(mergedFindings));
|
|
7997
|
+
}
|
|
7998
|
+
catch (err) {
|
|
7999
|
+
if (err instanceof ApiError)
|
|
8000
|
+
throw err;
|
|
8001
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
8002
|
+
const after = await this.taskStore.get(taskId);
|
|
8003
|
+
if (after?.status === 'failed') {
|
|
8004
|
+
const reason = err instanceof DispatchTerminalError ? ` (${err.reason})` : '';
|
|
8005
|
+
throw new ApiError(500, `Spec fix dispatch failed terminally for task ${taskId}${reason}: ${message}. `
|
|
8006
|
+
+ 'The task has been marked failed with the rejection kept on record; '
|
|
8007
|
+
+ 'use Retry to run it as a fresh task.');
|
|
8008
|
+
}
|
|
8009
|
+
if (after?.status === 'fixing') {
|
|
8010
|
+
throw new ApiError(500, `Spec fix dispatch was interrupted for task ${taskId}: ${message}. `
|
|
8011
|
+
+ 'The task moved to fixing with the rejection recorded; resume the holder agent to redeliver it.');
|
|
8012
|
+
}
|
|
8013
|
+
throw new ApiError(500, `Spec fix dispatch failed for task ${taskId}: ${message}. `
|
|
8014
|
+
+ 'The rejection was rolled back; fix the agent runtime or configuration, then retry.');
|
|
8015
|
+
}
|
|
8016
|
+
finally {
|
|
8017
|
+
if (!dispatched)
|
|
8018
|
+
await this.recoverSpecRejectionState(taskId, claim, base);
|
|
8019
|
+
}
|
|
6072
8020
|
if (!dispatched)
|
|
6073
8021
|
throw new ApiError(500, `Failed to dispatch spec fix for task ${taskId}`);
|
|
6074
8022
|
return dispatched;
|
|
6075
8023
|
}
|
|
8024
|
+
async claimSpecFixDispatch(taskId, opts) {
|
|
8025
|
+
return await this.withTaskLock(async () => {
|
|
8026
|
+
const fresh = await this.taskStore.get(taskId);
|
|
8027
|
+
if (!fresh)
|
|
8028
|
+
throw new ApiError(404, `Task ${taskId} not found`);
|
|
8029
|
+
if (fresh.status !== 'spec-ready' && fresh.status !== 'max_rounds') {
|
|
8030
|
+
throw new ApiError(409, `Task ${taskId} changed status during the spec verdict; aborted`);
|
|
8031
|
+
}
|
|
8032
|
+
const holder = fresh.agentId || fresh.researchAgentId || fresh.devAgentId;
|
|
8033
|
+
if (!holder || !this.getAgentConfig(holder)) {
|
|
8034
|
+
throw new ApiError(409, `Task ${taskId} spec holder agent ${holder || '(none)'} is not in the current config; `
|
|
8035
|
+
+ 'restore the agent config or cancel the task');
|
|
8036
|
+
}
|
|
8037
|
+
const originalAgentId = fresh.agentId;
|
|
8038
|
+
const originalCounter = fresh.maxRoundsContinues ?? 0;
|
|
8039
|
+
let counterAfter = originalCounter;
|
|
8040
|
+
if (opts.extendCapToFit !== null) {
|
|
8041
|
+
// cap 必须容下下一轮(round+1);rounds 被调低时一次 +1 可能不够
|
|
8042
|
+
counterAfter = Math.max(originalCounter + 1, opts.extendCapToFit + 1 - this.getConfig().review.rounds);
|
|
8043
|
+
fresh.maxRoundsContinues = counterAfter;
|
|
8044
|
+
}
|
|
8045
|
+
fresh.agentId = holder;
|
|
8046
|
+
fresh.updatedAt = new Date().toISOString();
|
|
8047
|
+
await this.taskStore.set(fresh);
|
|
8048
|
+
return { originalAgentId, originalCounter, holderWritten: holder, counterAfter };
|
|
8049
|
+
});
|
|
8050
|
+
}
|
|
8051
|
+
// 补偿以 claim 时的 status+counter+holder 为代际证明:任务已被推进(fixing/cancelled/新扩轮)
|
|
8052
|
+
// 就放弃恢复——尤其派发把任务带进 held fixing 时,打回 round 必须保留给后续 Resume 重派
|
|
8053
|
+
async recoverSpecRejectionState(taskId, claim, baseRound) {
|
|
8054
|
+
const store = this.getReviewStore();
|
|
8055
|
+
await this.withTaskLock(async () => {
|
|
8056
|
+
const fresh = await this.taskStore.get(taskId);
|
|
8057
|
+
if (!fresh)
|
|
8058
|
+
return;
|
|
8059
|
+
if (fresh.status !== 'spec-ready' && fresh.status !== 'max_rounds') {
|
|
8060
|
+
console.warn(`[AgentManager] spec rejection recovery(${taskId}) skipped: task moved to ${fresh.status}`);
|
|
8061
|
+
return;
|
|
8062
|
+
}
|
|
8063
|
+
if (claim) {
|
|
8064
|
+
if ((fresh.maxRoundsContinues ?? 0) !== claim.counterAfter
|
|
8065
|
+
|| fresh.agentId !== claim.holderWritten) {
|
|
8066
|
+
console.warn(`[AgentManager] spec rejection recovery(${taskId}) skipped: `
|
|
8067
|
+
+ `continues=${fresh.maxRoundsContinues ?? 0} agentId=${fresh.agentId} `
|
|
8068
|
+
+ `(expected ${claim.counterAfter}/${claim.holderWritten})`);
|
|
8069
|
+
return;
|
|
8070
|
+
}
|
|
8071
|
+
fresh.maxRoundsContinues = claim.originalCounter;
|
|
8072
|
+
fresh.agentId = claim.originalAgentId;
|
|
8073
|
+
fresh.updatedAt = new Date().toISOString();
|
|
8074
|
+
await this.taskStore.set(fresh);
|
|
8075
|
+
}
|
|
8076
|
+
await store?.putRound(taskId, 'spec', baseRound);
|
|
8077
|
+
}).catch(err => {
|
|
8078
|
+
console.error(`[AgentManager] spec rejection recovery(${taskId}) failed:`, err);
|
|
8079
|
+
});
|
|
8080
|
+
}
|
|
6076
8081
|
async submitCodeVerdict(taskId, comments) {
|
|
6077
8082
|
const trimmed = comments?.trim();
|
|
6078
8083
|
if (!trimmed) {
|
|
@@ -6235,6 +8240,7 @@ export class AgentManager {
|
|
|
6235
8240
|
if (task.qaAgentId && !await releaseParticipant(task.qaAgentId, 'code-phase-qa-release-failed')) {
|
|
6236
8241
|
return null;
|
|
6237
8242
|
}
|
|
8243
|
+
const devBoundBefore = (await this.agentStore.get(devAgentId))?.taskId === taskId;
|
|
6238
8244
|
const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'code');
|
|
6239
8245
|
if (!acquired) {
|
|
6240
8246
|
await this.emitIntervention(task.projectId, devAgentId, taskId, {
|
|
@@ -6243,8 +8249,10 @@ export class AgentManager {
|
|
|
6243
8249
|
});
|
|
6244
8250
|
return null;
|
|
6245
8251
|
}
|
|
8252
|
+
const acquiredLockToken = (await this.agentStore.get(devAgentId))?.lockToken;
|
|
6246
8253
|
const newToken = createSignalToken();
|
|
6247
|
-
|
|
8254
|
+
// spec 阶段的扩轮决策不延续到 code 评审:cap 共享字段,进入编码时归零
|
|
8255
|
+
const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready', 'max_rounds'] }, { agentId: devAgentId, phase: 'code', maxRoundsContinues: 0, ...this.dispatchTokenFields({ ...task, phase: 'code' }, newToken) });
|
|
6248
8256
|
if (!transition) {
|
|
6249
8257
|
await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
|
|
6250
8258
|
.catch(() => undefined);
|
|
@@ -6259,6 +8267,33 @@ export class AgentManager {
|
|
|
6259
8267
|
if (err instanceof DispatchTerminalError) {
|
|
6260
8268
|
await this.failTaskForDispatchError(taskId, 'code', devAgentId, err);
|
|
6261
8269
|
}
|
|
8270
|
+
else if (err instanceof EnsureSessionError && err.partial.handled
|
|
8271
|
+
&& (task.status === 'spec-ready' || task.status === 'max_rounds')) {
|
|
8272
|
+
// dev 已被 checkout/dialog hold 且 prompt 未送达;resumeAgent 不重派这些 hold,
|
|
8273
|
+
// 任务停在 in_progress 会同时封死 Resume 与 verdict——退回 verdict 门禁状态
|
|
8274
|
+
const rolledBack = await this.transitionTaskStatus(taskId, task.status, { fromStatus: ['in_progress'] }, {
|
|
8275
|
+
...(task.phase !== undefined ? { phase: task.phase } : {}),
|
|
8276
|
+
agentId: task.agentId,
|
|
8277
|
+
...(task.signalToken ? { signalToken: task.signalToken } : {}),
|
|
8278
|
+
maxRoundsContinues: task.maxRoundsContinues ?? 0,
|
|
8279
|
+
});
|
|
8280
|
+
if (!rolledBack) {
|
|
8281
|
+
console.warn(`[AgentManager] transitionToCodePhase(${taskId}): handled dispatch failure but the task `
|
|
8282
|
+
+ 'moved on; leaving its status as-is');
|
|
8283
|
+
}
|
|
8284
|
+
else if (!devBoundBefore) {
|
|
8285
|
+
// 转码前 dev 未持有本任务(如 research handoff / max_rounds 重占用):任务已退回
|
|
8286
|
+
// verdict 门禁,释放本次新占用,否则 dev 会被回退后的任务永久占住
|
|
8287
|
+
const released = await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
|
|
8288
|
+
.catch(() => false);
|
|
8289
|
+
if (!released) {
|
|
8290
|
+
await this.emitIntervention(task.projectId, devAgentId, taskId, {
|
|
8291
|
+
phase: 'code-rollback-dev-release-failed',
|
|
8292
|
+
devAgentId,
|
|
8293
|
+
});
|
|
8294
|
+
}
|
|
8295
|
+
}
|
|
8296
|
+
}
|
|
6262
8297
|
else if (!(err instanceof EnsureSessionError && err.partial.handled)) {
|
|
6263
8298
|
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);
|
|
6264
8299
|
}
|
|
@@ -6273,6 +8308,11 @@ export class AgentManager {
|
|
|
6273
8308
|
});
|
|
6274
8309
|
return null;
|
|
6275
8310
|
}
|
|
8311
|
+
// 重占用(released dev)的 acquire 会写 bootstrappingTaskId,而 continueSession 不做
|
|
8312
|
+
// startSession 的 post-ack finalize;残留标记会让 recover 把已运行的任务按未送达回滚
|
|
8313
|
+
if (!researchHandoff) {
|
|
8314
|
+
await this.finalizeReplayedBootstrap(devAgentId, taskId, 'code', acquiredLockToken);
|
|
8315
|
+
}
|
|
6276
8316
|
return await this.taskStore.get(taskId);
|
|
6277
8317
|
}
|
|
6278
8318
|
async dispatchServerReviewToQa(taskId, opts) {
|
|
@@ -6415,7 +8455,11 @@ export class AgentManager {
|
|
|
6415
8455
|
armBeforeInject: (ctx) => {
|
|
6416
8456
|
dispatchedCheckoutMode = ctx.serverReviewCheckout;
|
|
6417
8457
|
const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.serverReviewCheckout === 'base';
|
|
6418
|
-
return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken,
|
|
8458
|
+
return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, {
|
|
8459
|
+
...(allowReadFile
|
|
8460
|
+
? { onReadFile: (req) => { void this.handleReadFileRequest(taskId, qaId, req); } }
|
|
8461
|
+
: {}),
|
|
8462
|
+
});
|
|
6419
8463
|
},
|
|
6420
8464
|
};
|
|
6421
8465
|
const rearmConsumedSignal = async () => {
|
|
@@ -6509,6 +8553,16 @@ export class AgentManager {
|
|
|
6509
8553
|
const reviewedKind = taskPhase === 'spec' ? 'spec-reviewed' : 'code-reviewed';
|
|
6510
8554
|
await this.setupPhaseSignal(taskId, qaAgentId, reviewedKind, { skipSnapshot: true });
|
|
6511
8555
|
};
|
|
8556
|
+
const devBoundBefore = (await this.agentStore.get(devAgentId))?.taskId === taskId;
|
|
8557
|
+
const releaseFreshAcquire = async () => {
|
|
8558
|
+
if (devBoundBefore)
|
|
8559
|
+
return;
|
|
8560
|
+
const released = await this.releaseAgentForTask(devAgentId, taskId, 'idle')
|
|
8561
|
+
.catch(() => false);
|
|
8562
|
+
if (!released) {
|
|
8563
|
+
console.error(`[AgentManager] dispatchServerFixToDev: releasing freshly acquired dev ${devAgentId} for ${taskId} failed`);
|
|
8564
|
+
}
|
|
8565
|
+
};
|
|
6512
8566
|
const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-feedback');
|
|
6513
8567
|
if (!acquired) {
|
|
6514
8568
|
await rearmReviewedSignal();
|
|
@@ -6519,6 +8573,7 @@ export class AgentManager {
|
|
|
6519
8573
|
const released = await this.releaseAgentIfBound(qaAgentId, taskId)
|
|
6520
8574
|
.catch(() => false);
|
|
6521
8575
|
if (!released) {
|
|
8576
|
+
await releaseFreshAcquire();
|
|
6522
8577
|
await rearmReviewedSignal();
|
|
6523
8578
|
await this.emitIntervention(projectId, qaAgentId, taskId, { phase: 'server-fix-qa-release-failed', qaAgentId });
|
|
6524
8579
|
return null;
|
|
@@ -6526,6 +8581,7 @@ export class AgentManager {
|
|
|
6526
8581
|
}
|
|
6527
8582
|
const transition = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review', 'max_rounds', 'spec-ready', 'ready'] }, { signalToken: newToken, fixDispatchedAt: new Date().toISOString() });
|
|
6528
8583
|
if (!transition) {
|
|
8584
|
+
await releaseFreshAcquire();
|
|
6529
8585
|
await this.emitIntervention(projectId, devAgentId, taskId, { phase: 'server-fix-transition-failed', devAgentId });
|
|
6530
8586
|
return null;
|
|
6531
8587
|
}
|
|
@@ -6875,6 +8931,38 @@ export class AgentManager {
|
|
|
6875
8931
|
}
|
|
6876
8932
|
catch (err) {
|
|
6877
8933
|
const message = err instanceof Error ? err.message : String(err);
|
|
8934
|
+
if (err instanceof PlatformMergeRecheckError && task.reviewMode === 'git' && task.status === 'merge-ready') {
|
|
8935
|
+
// 复核类失效(provenance 撤销/未 ack 反馈)没有「再 Confirm」的出路:退回 approved 置
|
|
8936
|
+
// durable pending 重入 post-approve 闭环(计划 Task 8),把失效授权留在 gate 只剩 Cancel
|
|
8937
|
+
const retreated = await this.withTaskLock(async () => {
|
|
8938
|
+
const fresh = await this.taskStore.get(task.id);
|
|
8939
|
+
if (!fresh || fresh.status !== 'merge-ready')
|
|
8940
|
+
return false;
|
|
8941
|
+
const approvedHead = typeof fresh.latestHeadSha === 'string' && PLATFORM_HEAD_SHA_RE.test(fresh.latestHeadSha)
|
|
8942
|
+
? fresh.latestHeadSha
|
|
8943
|
+
: undefined;
|
|
8944
|
+
if (approvedHead === undefined)
|
|
8945
|
+
return false;
|
|
8946
|
+
await this.taskStore.set({
|
|
8947
|
+
...fresh,
|
|
8948
|
+
status: 'approved',
|
|
8949
|
+
postApproveHeadSha: approvedHead,
|
|
8950
|
+
pendingRedispatch: true,
|
|
8951
|
+
updatedAt: new Date().toISOString(),
|
|
8952
|
+
});
|
|
8953
|
+
return true;
|
|
8954
|
+
});
|
|
8955
|
+
await this.emitIntervention(task.projectId, undefined, task.id, {
|
|
8956
|
+
phase: 'confirm-merge-recheck-failed',
|
|
8957
|
+
gate: task.status,
|
|
8958
|
+
error: message,
|
|
8959
|
+
retreated,
|
|
8960
|
+
note: retreated
|
|
8961
|
+
? 'Accepted pass no longer verifies; task returned to approved for the post-approve loop to re-settle.'
|
|
8962
|
+
: 'Accepted pass no longer verifies and the task could not be returned automatically; inspect and re-dispatch.',
|
|
8963
|
+
});
|
|
8964
|
+
throw new ApiError(409, `Merge blocked for task ${task.id}: ${message}`);
|
|
8965
|
+
}
|
|
6878
8966
|
await this.emitIntervention(task.projectId, undefined, task.id, {
|
|
6879
8967
|
phase: 'confirm-merge-failed',
|
|
6880
8968
|
gate: task.status,
|