baxian 1.0.2 → 1.1.0
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/diff-split.d.ts +10 -0
- package/dist/agent/diff-split.d.ts.map +1 -0
- package/dist/agent/diff-split.js +83 -0
- package/dist/agent/diff-split.js.map +1 -0
- package/dist/agent/manager.d.ts +66 -4
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +1070 -37
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/phase-signal-watcher.d.ts +11 -2
- package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
- package/dist/agent/phase-signal-watcher.js +53 -8
- package/dist/agent/phase-signal-watcher.js.map +1 -1
- package/dist/agent/phase-signal.d.ts +29 -2
- package/dist/agent/phase-signal.d.ts.map +1 -1
- package/dist/agent/phase-signal.js +34 -1
- package/dist/agent/phase-signal.js.map +1 -1
- package/dist/agent/prompt.d.ts +14 -0
- package/dist/agent/prompt.d.ts.map +1 -1
- package/dist/agent/prompt.js +242 -13
- package/dist/agent/prompt.js.map +1 -1
- package/dist/agent/review-transport.d.ts +36 -0
- package/dist/agent/review-transport.d.ts.map +1 -0
- package/dist/agent/review-transport.js +246 -0
- package/dist/agent/review-transport.js.map +1 -0
- package/dist/agent/runner.js +3 -3
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/worktree.d.ts +1 -0
- package/dist/agent/worktree.d.ts.map +1 -1
- package/dist/agent/worktree.js +12 -0
- package/dist/agent/worktree.js.map +1 -1
- package/dist/api/hosts.d.ts.map +1 -1
- package/dist/api/hosts.js +11 -7
- package/dist/api/hosts.js.map +1 -1
- package/dist/api/tasks.d.ts.map +1 -1
- package/dist/api/tasks.js +8 -0
- package/dist/api/tasks.js.map +1 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +6 -8
- package/dist/config/loader.js.map +1 -1
- package/dist/config/validator.js +23 -0
- package/dist/config/validator.js.map +1 -1
- package/dist/event/handlers.d.ts.map +1 -1
- package/dist/event/handlers.js +23 -21
- package/dist/event/handlers.js.map +1 -1
- package/dist/event/server-handlers.d.ts +4 -0
- package/dist/event/server-handlers.d.ts.map +1 -0
- package/dist/event/server-handlers.js +828 -0
- package/dist/event/server-handlers.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/shared/constants.d.ts +7 -1
- package/dist/shared/constants.d.ts.map +1 -1
- package/dist/shared/constants.js +26 -2
- package/dist/shared/constants.js.map +1 -1
- package/dist/shared/types.d.ts +64 -2
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/skills/server-feedback/SKILL.md +32 -0
- package/dist/skills/server-recheck/SKILL.md +30 -0
- package/dist/skills/server-review/SKILL.md +43 -0
- package/dist/skills/server-spec-review/SKILL.md +31 -0
- package/dist/state/index.d.ts +1 -0
- package/dist/state/index.d.ts.map +1 -1
- package/dist/state/index.js +1 -0
- package/dist/state/index.js.map +1 -1
- package/dist/state/review-store.d.ts +13 -0
- package/dist/state/review-store.d.ts.map +1 -0
- package/dist/state/review-store.js +92 -0
- package/dist/state/review-store.js.map +1 -0
- package/dist/state/snapshot.js +1 -1
- package/dist/state/snapshot.js.map +1 -1
- package/dist/state/task-store.d.ts.map +1 -1
- package/dist/state/task-store.js +1 -0
- package/dist/state/task-store.js.map +1 -1
- package/dist/web/assets/index-DE_xpPQe.js +4 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-BfCCF072.js +0 -4
package/dist/agent/manager.js
CHANGED
|
@@ -12,6 +12,7 @@ import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu,
|
|
|
12
12
|
import { WorktreeManager } from './worktree.js';
|
|
13
13
|
import { RepoStore, createRepoStoreCache } from './repo-store.js';
|
|
14
14
|
import { PhaseSignalWatcher } from './phase-signal-watcher.js';
|
|
15
|
+
import { ReviewTransport } from './review-transport.js';
|
|
15
16
|
import { buildPromptInline, buildPostMergeCleanupPrompt, PromptSizeError, RequiredSkillsMissingError, MAX_PROMPT_BYTES_ROUTE_LIMIT, } from './prompt.js';
|
|
16
17
|
import { ApiError } from '../errors.js';
|
|
17
18
|
export class EnsureSessionError extends Error {
|
|
@@ -115,6 +116,8 @@ export class AgentManager {
|
|
|
115
116
|
postApproveStore;
|
|
116
117
|
phaseSignalWatcher;
|
|
117
118
|
errorRecordStore;
|
|
119
|
+
reviewStore;
|
|
120
|
+
reviewTransportInstance;
|
|
118
121
|
dispatchAckTimeoutMs;
|
|
119
122
|
dispatchSettleTimeoutMs;
|
|
120
123
|
// Re-send Enter after this long of continuous post-paste idle — recovers a swallowed first Enter
|
|
@@ -160,6 +163,7 @@ export class AgentManager {
|
|
|
160
163
|
resolveAgent: (id) => this.getAgentConfig(id),
|
|
161
164
|
})
|
|
162
165
|
: undefined);
|
|
166
|
+
this.reviewStore = deps.reviewStore;
|
|
163
167
|
this.dispatchAckTimeoutMs = deps.dispatchAckTimeoutMs ?? DEFAULT_DISPATCH_ACK_TIMEOUT_MS;
|
|
164
168
|
this.dispatchSettleTimeoutMs = deps.dispatchSettleTimeoutMs ?? DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS;
|
|
165
169
|
this.agentIndex = buildAgentIndex(deps.config);
|
|
@@ -176,6 +180,36 @@ export class AgentManager {
|
|
|
176
180
|
this.taskMutationQueue = next.catch(() => undefined);
|
|
177
181
|
return next;
|
|
178
182
|
}
|
|
183
|
+
getReviewStore() {
|
|
184
|
+
return this.reviewStore;
|
|
185
|
+
}
|
|
186
|
+
// Snapshot-aware afterDone read: an EXPLICIT null snapshot must win over hot
|
|
187
|
+
// config — `??` would swallow it and reroute an already-decided task (PR #288).
|
|
188
|
+
resolveAfterDone(task) {
|
|
189
|
+
if (task.afterDone !== undefined)
|
|
190
|
+
return task.afterDone;
|
|
191
|
+
return this.config.review.afterDone ?? null;
|
|
192
|
+
}
|
|
193
|
+
getReviewTransport() {
|
|
194
|
+
this.reviewTransportInstance ??= new ReviewTransport({
|
|
195
|
+
createRunnerFor: (agent) => this.createRunnerFor(agent),
|
|
196
|
+
resolveWorktree: (agentId) => this.bindingWorktreeCache.get(agentId),
|
|
197
|
+
});
|
|
198
|
+
return this.reviewTransportInstance;
|
|
199
|
+
}
|
|
200
|
+
// ReviewTransport resolves worktrees synchronously; agentStore reads are async.
|
|
201
|
+
// The cache is refreshed by callers (server handlers) before transport use via
|
|
202
|
+
// refreshWorktreeCacheFor — a stale entry only costs one refresh round-trip.
|
|
203
|
+
bindingWorktreeCache = new Map();
|
|
204
|
+
async refreshWorktreeCacheFor(agentId) {
|
|
205
|
+
const state = await this.agentStore.get(agentId);
|
|
206
|
+
if (state?.worktreePath) {
|
|
207
|
+
this.bindingWorktreeCache.set(agentId, state.worktreePath);
|
|
208
|
+
return state.worktreePath;
|
|
209
|
+
}
|
|
210
|
+
this.bindingWorktreeCache.delete(agentId);
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
179
213
|
async safeEmit(event) {
|
|
180
214
|
try {
|
|
181
215
|
await this.eventBus.emit(event);
|
|
@@ -940,7 +974,10 @@ export class AgentManager {
|
|
|
940
974
|
throw new Error(`Unknown agent: ${agentId}`);
|
|
941
975
|
const state = await this.agentStore.get(agentId);
|
|
942
976
|
const sameTaskLocked = state?.taskId === taskId && (await this.lockManager.isLocked(agentId));
|
|
943
|
-
const reentryPhases = new Set([
|
|
977
|
+
const reentryPhases = new Set([
|
|
978
|
+
'fix', 'post-approve', 'spec-fix', 'code',
|
|
979
|
+
'server-feedback', 'server-after-done',
|
|
980
|
+
]);
|
|
944
981
|
const sameTaskReentry = state?.taskId === taskId &&
|
|
945
982
|
!state.creationToken &&
|
|
946
983
|
state.status !== 'awaiting_human' &&
|
|
@@ -1178,6 +1215,28 @@ export class AgentManager {
|
|
|
1178
1215
|
console.warn(`[AgentManager] resumeAgent: agent ${agentId} dialog resolved but bound task ${state.taskId} still active (crash window) — prompt was never injected; refusing Resume. Operator should cancel the task or DELETE the agent.`);
|
|
1179
1216
|
return { resumed: false, releasedBinding: false };
|
|
1180
1217
|
}
|
|
1218
|
+
// code-dispatch-failed: the code-phase prompt never reached the pane (spec
|
|
1219
|
+
// approval already transitioned the task). Resume = clear the hold AND
|
|
1220
|
+
// redispatch the code prompt (outside this lock) — without the redispatch
|
|
1221
|
+
// the task would stay in_progress with nothing running (PR #288).
|
|
1222
|
+
if (state.awaitingPhase === 'code-dispatch-failed'
|
|
1223
|
+
&& boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)
|
|
1224
|
+
&& state.taskId) {
|
|
1225
|
+
const now2 = new Date().toISOString();
|
|
1226
|
+
await this.agentStore.update(agentId, (existing) => {
|
|
1227
|
+
if (!existing)
|
|
1228
|
+
return AGENT_STORE_NOOP;
|
|
1229
|
+
return {
|
|
1230
|
+
...existing,
|
|
1231
|
+
status: 'ok',
|
|
1232
|
+
awaitingPhase: undefined,
|
|
1233
|
+
awaitingReason: undefined,
|
|
1234
|
+
awaitingSince: undefined,
|
|
1235
|
+
updatedAt: now2,
|
|
1236
|
+
};
|
|
1237
|
+
});
|
|
1238
|
+
return { resumed: true, releasedBinding: false, redispatchCodeTaskId: state.taskId };
|
|
1239
|
+
}
|
|
1181
1240
|
// signal-arm-failed: the prompt was already dispatched but its pane-signal watcher never
|
|
1182
1241
|
// armed. Resume here would only flip status→ok WITHOUT rebuilding the watcher (Resume has no
|
|
1183
1242
|
// re-arm path), so the prompt's signal would still have no consumer — silent deadlock again.
|
|
@@ -1243,7 +1302,20 @@ export class AgentManager {
|
|
|
1243
1302
|
});
|
|
1244
1303
|
return { resumed: true, releasedBinding: shouldReleaseBinding };
|
|
1245
1304
|
});
|
|
1246
|
-
|
|
1305
|
+
// Outside the task lock: continueSession takes it internally.
|
|
1306
|
+
if (result.redispatchCodeTaskId) {
|
|
1307
|
+
try {
|
|
1308
|
+
const resumed = await this.continueSession(result.redispatchCodeTaskId, agentId, 'code');
|
|
1309
|
+
if (!resumed) {
|
|
1310
|
+
await this.markAwaitingHuman(agentId, 'code-dispatch-failed', 'Code-phase redispatch on Resume was not delivered; Resume again to retry or cancel the task.', { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
catch (err) {
|
|
1314
|
+
console.error(`[AgentManager] resumeAgent code redispatch failed for ${agentId}:`, err);
|
|
1315
|
+
await this.markAwaitingHuman(agentId, 'code-dispatch-failed', 'Code-phase redispatch on Resume failed; Resume again to retry or cancel the task.', { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
return { resumed: result.resumed, releasedBinding: result.releasedBinding };
|
|
1247
1319
|
}
|
|
1248
1320
|
async interruptPaneAndWaitReady(state, cfg) {
|
|
1249
1321
|
const runner = this.createRunnerFor(cfg);
|
|
@@ -1337,6 +1409,11 @@ export class AgentManager {
|
|
|
1337
1409
|
const out = [];
|
|
1338
1410
|
for (const t of tasks) {
|
|
1339
1411
|
const bound = t.agentId === agentId || t.qaAgentId === agentId;
|
|
1412
|
+
// Human gates are decision states, not running work: an absent agent
|
|
1413
|
+
// session must not terminally fail a task whose published PR/branch
|
|
1414
|
+
// would then be orphaned — Confirm/Cancel remain the only exits (PR #288).
|
|
1415
|
+
if (t.status === 'ready' || t.status === 'merge-ready')
|
|
1416
|
+
continue;
|
|
1340
1417
|
if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
|
|
1341
1418
|
t.status = 'failed';
|
|
1342
1419
|
t.updatedAt = new Date().toISOString();
|
|
@@ -1667,6 +1744,8 @@ export class AgentManager {
|
|
|
1667
1744
|
expectedKinds: 'pr-merge-ready',
|
|
1668
1745
|
token: completion.token,
|
|
1669
1746
|
skipSnapshot,
|
|
1747
|
+
reviewMode: task.reviewMode ?? 'github',
|
|
1748
|
+
recovered: true,
|
|
1670
1749
|
});
|
|
1671
1750
|
}
|
|
1672
1751
|
catch (err) {
|
|
@@ -1703,7 +1782,11 @@ export class AgentManager {
|
|
|
1703
1782
|
// fixing). Both handlers are idempotent under replay (token + status gates),
|
|
1704
1783
|
// and token rotation still rejects stale ones. Other phases keep
|
|
1705
1784
|
// skipSnapshot=true — their handlers aren't as cleanly replay-safe.
|
|
1706
|
-
|
|
1785
|
+
// Server mode scans on EVERY recovered state: the pane signal is the only
|
|
1786
|
+
// verdict channel (no poller backstop) and all server handlers are
|
|
1787
|
+
// replay-safe (token/status gates + stored-data resumption).
|
|
1788
|
+
const scanSnapshotOnRecover = task.reviewMode === 'server'
|
|
1789
|
+
|| (task.phase !== 'spec' && (task.status === 'review' || task.status === 'fixing'));
|
|
1707
1790
|
try {
|
|
1708
1791
|
await this.phaseSignalWatcher.start({
|
|
1709
1792
|
taskId: task.id,
|
|
@@ -1712,6 +1795,11 @@ export class AgentManager {
|
|
|
1712
1795
|
expectedKinds,
|
|
1713
1796
|
token: task.signalToken,
|
|
1714
1797
|
skipSnapshot: !scanSnapshotOnRecover,
|
|
1798
|
+
reviewMode: task.reviewMode ?? 'github',
|
|
1799
|
+
recovered: true,
|
|
1800
|
+
...(task.reviewMode === 'server' && task.status === 'review'
|
|
1801
|
+
? { onReadFile: (req) => { void this.handleReadFileRequest(task.id, agentId, req); } }
|
|
1802
|
+
: {}),
|
|
1715
1803
|
});
|
|
1716
1804
|
if (interventionKindLabel) {
|
|
1717
1805
|
await this.safeEmit({
|
|
@@ -1987,6 +2075,7 @@ export class AgentManager {
|
|
|
1987
2075
|
reviewRound: 0,
|
|
1988
2076
|
status: 'pending',
|
|
1989
2077
|
branch: BRANCH_PREFIX + taskId,
|
|
2078
|
+
reviewMode: this.config.review.mode ?? 'github',
|
|
1990
2079
|
createdAt: now,
|
|
1991
2080
|
updatedAt: now,
|
|
1992
2081
|
...(imageFilenames ? { images: imageFilenames } : {}),
|
|
@@ -2015,6 +2104,7 @@ export class AgentManager {
|
|
|
2015
2104
|
reviewRound: 0,
|
|
2016
2105
|
status: 'pending',
|
|
2017
2106
|
branch: BRANCH_PREFIX + taskId,
|
|
2107
|
+
reviewMode: this.config.review.mode ?? 'github',
|
|
2018
2108
|
createdAt: now,
|
|
2019
2109
|
updatedAt: now,
|
|
2020
2110
|
...(qa ? { qaAgentId: qa.id } : {}),
|
|
@@ -2047,6 +2137,7 @@ export class AgentManager {
|
|
|
2047
2137
|
reviewRound: 0,
|
|
2048
2138
|
status: 'pending',
|
|
2049
2139
|
branch: BRANCH_PREFIX + taskId,
|
|
2140
|
+
reviewMode: this.config.review.mode ?? 'github',
|
|
2050
2141
|
createdAt: now,
|
|
2051
2142
|
updatedAt: now,
|
|
2052
2143
|
...(qa ? { qaAgentId: qa.id } : {}),
|
|
@@ -2078,6 +2169,7 @@ export class AgentManager {
|
|
|
2078
2169
|
reviewRound: 0,
|
|
2079
2170
|
status: 'in_progress',
|
|
2080
2171
|
branch: BRANCH_PREFIX + taskId,
|
|
2172
|
+
reviewMode: this.config.review.mode ?? 'github',
|
|
2081
2173
|
createdAt: now,
|
|
2082
2174
|
updatedAt: now,
|
|
2083
2175
|
...(imageFilenames ? { images: imageFilenames } : {}),
|
|
@@ -2162,12 +2254,15 @@ export class AgentManager {
|
|
|
2162
2254
|
}
|
|
2163
2255
|
// 后台路径吞掉 reject(void start.catch):arm 抛异常时也要显式 hold agent,否则会留下一个没有
|
|
2164
2256
|
// spec-created/pr-created 消费者的常驻任务(同步 caller 仍由上游收到异常)。
|
|
2257
|
+
// Kinds derive from the task's frozen reviewMode — a hot mode flip during the
|
|
2258
|
+
// startSession window must not desync the armed kinds from the sent prompt.
|
|
2259
|
+
const initialKinds = this.devInitialSignalKinds(fresh.reviewMode);
|
|
2165
2260
|
try {
|
|
2166
|
-
await this.armPostDispatchSignalOrHold(taskId, agentId,
|
|
2261
|
+
await this.armPostDispatchSignalOrHold(taskId, agentId, initialKinds, signalToken);
|
|
2167
2262
|
}
|
|
2168
2263
|
catch (armErr) {
|
|
2169
2264
|
console.error(`[AgentManager] createAndStartTask arm failed for task=${taskId}:`, armErr);
|
|
2170
|
-
await this.holdAgentForUnarmedSignal(taskId, agentId,
|
|
2265
|
+
await this.holdAgentForUnarmedSignal(taskId, agentId, initialKinds)
|
|
2171
2266
|
.catch((holdErr) => {
|
|
2172
2267
|
console.error(`[AgentManager] createAndStartTask hold-after-arm-failure failed for task=${taskId}:`, holdErr);
|
|
2173
2268
|
});
|
|
@@ -2365,7 +2460,7 @@ export class AgentManager {
|
|
|
2365
2460
|
console.error(`[AgentManager] dispatchPendingTask startSession hard error for task=${claimed.id}:`, err);
|
|
2366
2461
|
}
|
|
2367
2462
|
if (started) {
|
|
2368
|
-
await this.armPostDispatchSignalOrHold(claimed.id, claimed.agentId,
|
|
2463
|
+
await this.armPostDispatchSignalOrHold(claimed.id, claimed.agentId, this.devInitialSignalKinds(claimed.reviewMode), signalToken);
|
|
2369
2464
|
const refreshed = await this.taskStore.get(claimed.id);
|
|
2370
2465
|
return { task: refreshed ?? claimed };
|
|
2371
2466
|
}
|
|
@@ -2454,9 +2549,12 @@ export class AgentManager {
|
|
|
2454
2549
|
const baseRef = agent.workdir
|
|
2455
2550
|
? undefined
|
|
2456
2551
|
: await this.resolveAutoBaseRef(runner, workdir);
|
|
2457
|
-
const
|
|
2458
|
-
|
|
2459
|
-
|
|
2552
|
+
const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
|
|
2553
|
+
const worktreePath = isServerQaPhase
|
|
2554
|
+
? await worktree.createDetachedAtBase(workdir, taskId)
|
|
2555
|
+
: phase === 'review' || phase === 'recheck' || phase === 'spec-review'
|
|
2556
|
+
? await worktree.createDetached(workdir, taskId, task.branch)
|
|
2557
|
+
: await worktree.create(workdir, taskId, baseRef);
|
|
2460
2558
|
// Persist worktreePath now so a crash before set-running leaves a recoverable trail.
|
|
2461
2559
|
await this.agentStore.update(agentId, (stateNow) => {
|
|
2462
2560
|
if (!stateNow || stateNow.taskId !== taskId)
|
|
@@ -2494,6 +2592,12 @@ export class AgentManager {
|
|
|
2494
2592
|
...(opts.specFindings ? { specFindings: opts.specFindings } : {}),
|
|
2495
2593
|
...(reuseInjectedSkills ? { excludeSkills: reuseInjectedSkills } : {}),
|
|
2496
2594
|
...(imagePaths.length ? { imagePaths } : {}),
|
|
2595
|
+
...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
|
|
2596
|
+
...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
|
|
2597
|
+
...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
|
|
2598
|
+
...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
|
|
2599
|
+
...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
|
|
2600
|
+
...(opts.contentTruncated ? { contentTruncated: true } : {}),
|
|
2497
2601
|
});
|
|
2498
2602
|
}
|
|
2499
2603
|
catch (err) {
|
|
@@ -2847,6 +2951,13 @@ export class AgentManager {
|
|
|
2847
2951
|
...(opts.specFindings ? { specFindings: opts.specFindings } : {}),
|
|
2848
2952
|
...(reuseInjectedSkills ? { excludeSkills: reuseInjectedSkills } : {}),
|
|
2849
2953
|
...(imagePaths.length ? { imagePaths } : {}),
|
|
2954
|
+
...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
|
|
2955
|
+
...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
|
|
2956
|
+
...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
|
|
2957
|
+
...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
|
|
2958
|
+
...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
|
|
2959
|
+
...(opts.serverAfterDone ? { serverAfterDone: opts.serverAfterDone } : {}),
|
|
2960
|
+
...(opts.contentTruncated ? { contentTruncated: opts.contentTruncated } : {}),
|
|
2850
2961
|
});
|
|
2851
2962
|
}
|
|
2852
2963
|
catch (err) {
|
|
@@ -3235,6 +3346,13 @@ export class AgentManager {
|
|
|
3235
3346
|
async cancelTask(taskId) {
|
|
3236
3347
|
let devToRelease;
|
|
3237
3348
|
let qaToRelease;
|
|
3349
|
+
// Server-mode ready gate may have already published remote artifacts
|
|
3350
|
+
// (pushed branch / open PR). Capture before flipping to cancelled so the
|
|
3351
|
+
// post-lock cleanup can retire them instead of orphaning (PR #288).
|
|
3352
|
+
// mayBeInFlight: approved+marker means the publish prompt may STILL be
|
|
3353
|
+
// running — retirement must wait for the dev interrupt or the in-flight
|
|
3354
|
+
// push/pr-create would recreate the artifacts right after cleanup.
|
|
3355
|
+
let publishedCleanup;
|
|
3238
3356
|
this.phaseSignalWatcher?.stop(taskId);
|
|
3239
3357
|
const result = await this.withTaskLock(async () => {
|
|
3240
3358
|
const task = await this.taskStore.get(taskId);
|
|
@@ -3252,6 +3370,37 @@ export class AgentManager {
|
|
|
3252
3370
|
devToRelease = task.agentId;
|
|
3253
3371
|
if (task.qaAgentId)
|
|
3254
3372
|
qaToRelease = task.qaAgentId;
|
|
3373
|
+
// approved + publishDispatchedAt = the publish prompt reached the pane, so
|
|
3374
|
+
// remote artifacts may already exist even though code-ready never landed
|
|
3375
|
+
// (dispatch crash, or the reviewed-head mismatch gate refused ready —
|
|
3376
|
+
// whose documented exit is exactly this Cancel).
|
|
3377
|
+
// Truthy (not !== undefined): sanitizeTask passes hand-edited nulls through.
|
|
3378
|
+
const publishedAtGate = task.status === 'ready'
|
|
3379
|
+
|| (task.status === 'approved' && !!task.publishDispatchedAt);
|
|
3380
|
+
if (task.reviewMode === 'server' && publishedAtGate && task.agentId) {
|
|
3381
|
+
const afterDone = this.resolveAfterDone(task);
|
|
3382
|
+
if (afterDone !== null && task.branch) {
|
|
3383
|
+
publishedCleanup = {
|
|
3384
|
+
afterDone,
|
|
3385
|
+
branch: task.branch,
|
|
3386
|
+
...(task.prNumber !== undefined ? { prNumber: task.prNumber } : {}),
|
|
3387
|
+
devAgentId: task.agentId,
|
|
3388
|
+
// ready = code-ready consumed, publish finished; approved = no
|
|
3389
|
+
// completion signal yet, the publish may still be running.
|
|
3390
|
+
mayBeInFlight: task.status === 'approved',
|
|
3391
|
+
};
|
|
3392
|
+
}
|
|
3393
|
+
}
|
|
3394
|
+
else if (task.status === 'merge-ready' && task.prNumber !== undefined && task.branch && task.agentId) {
|
|
3395
|
+
// GitHub-mode gate cancel leaves the same orphaned PR/branch (PR #288).
|
|
3396
|
+
publishedCleanup = {
|
|
3397
|
+
afterDone: 'pr',
|
|
3398
|
+
branch: task.branch,
|
|
3399
|
+
prNumber: task.prNumber,
|
|
3400
|
+
devAgentId: task.agentId,
|
|
3401
|
+
mayBeInFlight: false,
|
|
3402
|
+
};
|
|
3403
|
+
}
|
|
3255
3404
|
const now = new Date().toISOString();
|
|
3256
3405
|
task.status = 'cancelled';
|
|
3257
3406
|
task.updatedAt = now;
|
|
@@ -3266,7 +3415,13 @@ export class AgentManager {
|
|
|
3266
3415
|
});
|
|
3267
3416
|
return task;
|
|
3268
3417
|
});
|
|
3269
|
-
// 唯一允许打断 agent 会话的入口(用户主动 Cancel)。
|
|
3418
|
+
// 唯一允许打断 agent 会话的入口(用户主动 Cancel)。Interrupt BEFORE remote
|
|
3419
|
+
// retirement: an in-flight publish prompt would re-push the branch / re-open
|
|
3420
|
+
// the PR right after cleanup, and a cancelled task gets no second pass.
|
|
3421
|
+
// Only a successful interrupt PROVES the pane stopped — skipped paths
|
|
3422
|
+
// (config hot-removed: the pane outlives the config; state gone; rebound)
|
|
3423
|
+
// leave an in-flight publish possible.
|
|
3424
|
+
let devStopConfirmed = false;
|
|
3270
3425
|
for (const id of [devToRelease, qaToRelease]) {
|
|
3271
3426
|
if (!id)
|
|
3272
3427
|
continue;
|
|
@@ -3285,6 +3440,8 @@ export class AgentManager {
|
|
|
3285
3440
|
await this.markAwaitingHuman(id, 'cancel-interrupt-failed', 'Task marked cancelled but C-c / REPL ready check failed; agent may still be running. Attach via web terminal to verify, then Resume or Delete.');
|
|
3286
3441
|
continue;
|
|
3287
3442
|
}
|
|
3443
|
+
if (id === publishedCleanup?.devAgentId)
|
|
3444
|
+
devStopConfirmed = true;
|
|
3288
3445
|
try {
|
|
3289
3446
|
// allowAwaitingHuman: cancelTask 是显式回收入口,agent 之前可能因 ack_unknown 等被标 Held,
|
|
3290
3447
|
// 用户主动 Cancel 应允许跨过 awaiting_human gate 清理 binding;release 默认 gate 是为了
|
|
@@ -3295,6 +3452,65 @@ export class AgentManager {
|
|
|
3295
3452
|
console.error(`[AgentManager] cancelTask releaseAgentForTask(${id}) failed:`, err);
|
|
3296
3453
|
}
|
|
3297
3454
|
}
|
|
3455
|
+
// Best-effort remote retirement for a cancelled published gate: close the PR
|
|
3456
|
+
// and delete the pushed branch so they don't outlive the task. Failures only
|
|
3457
|
+
// warn + intervene — cancel must not be blocked by remote faults.
|
|
3458
|
+
if (publishedCleanup) {
|
|
3459
|
+
if (publishedCleanup.mayBeInFlight && !devStopConfirmed) {
|
|
3460
|
+
// No proof the publish prompt stopped; cleaning now would race its
|
|
3461
|
+
// push/pr-create. Leave the artifacts to the operator.
|
|
3462
|
+
await this.safeEmit({
|
|
3463
|
+
id: '',
|
|
3464
|
+
type: 'human.intervention',
|
|
3465
|
+
timestamp: new Date().toISOString(),
|
|
3466
|
+
projectId: result.projectId,
|
|
3467
|
+
taskId,
|
|
3468
|
+
data: {
|
|
3469
|
+
phase: 'cancel-published-artifact-cleanup-skipped',
|
|
3470
|
+
afterDone: publishedCleanup.afterDone,
|
|
3471
|
+
branch: publishedCleanup.branch,
|
|
3472
|
+
...(publishedCleanup.prNumber !== undefined ? { prNumber: publishedCleanup.prNumber } : {}),
|
|
3473
|
+
reason: 'dev pane stop unconfirmed; the publish prompt may still be running and would recreate the remote artifacts',
|
|
3474
|
+
},
|
|
3475
|
+
});
|
|
3476
|
+
return result;
|
|
3477
|
+
}
|
|
3478
|
+
const project = this.getProjectConfig(result.projectId);
|
|
3479
|
+
try {
|
|
3480
|
+
if (publishedCleanup.afterDone === 'pr' && publishedCleanup.prNumber !== undefined && project) {
|
|
3481
|
+
const close = await this.platformRunner.exec(`gh pr close ${publishedCleanup.prNumber} --repo ${shellQuote(project.repo)} ` +
|
|
3482
|
+
`--comment ${shellQuote('Task cancelled in baxian; closing the published PR.')} --delete-branch`);
|
|
3483
|
+
if (close.exitCode !== 0)
|
|
3484
|
+
throw new Error(close.stderr.trim() || close.stdout.trim());
|
|
3485
|
+
}
|
|
3486
|
+
else {
|
|
3487
|
+
const dev = this.getAgentConfig(publishedCleanup.devAgentId);
|
|
3488
|
+
const state = await this.agentStore.get(publishedCleanup.devAgentId);
|
|
3489
|
+
if (dev && state?.repoPath) {
|
|
3490
|
+
const del = await this.createRunnerFor(dev).exec(`cd ${shellQuote(state.repoPath)} && git push origin --delete ${shellQuote(publishedCleanup.branch)}`);
|
|
3491
|
+
if (del.exitCode !== 0)
|
|
3492
|
+
throw new Error(del.stderr.trim() || del.stdout.trim());
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
catch (err) {
|
|
3497
|
+
console.warn(`[AgentManager] cancelTask remote retirement failed for ${taskId}:`, err);
|
|
3498
|
+
await this.safeEmit({
|
|
3499
|
+
id: '',
|
|
3500
|
+
type: 'human.intervention',
|
|
3501
|
+
timestamp: new Date().toISOString(),
|
|
3502
|
+
projectId: result.projectId,
|
|
3503
|
+
taskId,
|
|
3504
|
+
data: {
|
|
3505
|
+
phase: 'cancel-published-artifact-cleanup-failed',
|
|
3506
|
+
afterDone: publishedCleanup.afterDone,
|
|
3507
|
+
branch: publishedCleanup.branch,
|
|
3508
|
+
...(publishedCleanup.prNumber !== undefined ? { prNumber: publishedCleanup.prNumber } : {}),
|
|
3509
|
+
error: err instanceof Error ? err.message : String(err),
|
|
3510
|
+
},
|
|
3511
|
+
});
|
|
3512
|
+
}
|
|
3513
|
+
}
|
|
3298
3514
|
return result;
|
|
3299
3515
|
}
|
|
3300
3516
|
// task-044 重构:create-time 不再校验 awaiting_human / creating / bound —— 这些都是"忙",
|
|
@@ -3342,6 +3558,11 @@ export class AgentManager {
|
|
|
3342
3558
|
const task = await this.taskStore.get(taskId);
|
|
3343
3559
|
if (!task)
|
|
3344
3560
|
throw new ApiError(404, `Task ${taskId} not found`);
|
|
3561
|
+
// Server-mode tasks review via the exchange protocol; routing one into the
|
|
3562
|
+
// legacy GitHub review flow would cross-contaminate the state machines.
|
|
3563
|
+
if (task.reviewMode === 'server') {
|
|
3564
|
+
throw new ApiError(409, `Task ${taskId} uses server review mode; legacy Call review is not applicable`);
|
|
3565
|
+
}
|
|
3345
3566
|
// spec-phase max_rounds escapes via Retry/Cancel only. Call review dispatches the
|
|
3346
3567
|
// CODE-review protocol, but review.submitted early-returns for spec phase — so a direct
|
|
3347
3568
|
// /tasks/:id/review here would transition the task to review + bind QA, yet its verdict
|
|
@@ -3554,6 +3775,55 @@ export class AgentManager {
|
|
|
3554
3775
|
if (task.phase === 'spec') {
|
|
3555
3776
|
throw new ApiError(409, `Continue one round is only supported for code-phase tasks`);
|
|
3556
3777
|
}
|
|
3778
|
+
// Server-mode continue: grant one round past the cap, then re-run the server
|
|
3779
|
+
// fix protocol from the stored findings — no PR exists at this point (PR #288).
|
|
3780
|
+
if (task.reviewMode === 'server') {
|
|
3781
|
+
if (!task.agentId) {
|
|
3782
|
+
throw new ApiError(400, `Task ${taskId} has no dev agent; cannot continue`);
|
|
3783
|
+
}
|
|
3784
|
+
const stored = await this.reviewStore?.getRound(taskId, 'code', Math.max(task.reviewRound, 1));
|
|
3785
|
+
if (!stored?.findings) {
|
|
3786
|
+
throw new ApiError(409, `Task ${taskId} has no stored findings to continue from; cancel instead`);
|
|
3787
|
+
}
|
|
3788
|
+
// Re-check + grant under the task lock: the entry checks above ran lock-free,
|
|
3789
|
+
// so a concurrent mark-complete may have claimed the gate since (the
|
|
3790
|
+
// claimCompleteGate comment promises Continue re-checks under the same lock).
|
|
3791
|
+
await this.withTaskLock(async () => {
|
|
3792
|
+
if (this.markCompleteInFlight.has(taskId)) {
|
|
3793
|
+
throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`);
|
|
3794
|
+
}
|
|
3795
|
+
const fresh = await this.taskStore.get(taskId);
|
|
3796
|
+
if (!fresh || fresh.status !== 'max_rounds') {
|
|
3797
|
+
throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${fresh?.status ?? 'gone'})`);
|
|
3798
|
+
}
|
|
3799
|
+
fresh.maxRoundsContinues = (fresh.maxRoundsContinues ?? 0) + 1;
|
|
3800
|
+
fresh.updatedAt = new Date().toISOString();
|
|
3801
|
+
await this.taskStore.set(fresh);
|
|
3802
|
+
});
|
|
3803
|
+
let dispatched = null;
|
|
3804
|
+
try {
|
|
3805
|
+
dispatched = await this.dispatchServerFixToDev(taskId, JSON.stringify(stored.findings));
|
|
3806
|
+
}
|
|
3807
|
+
finally {
|
|
3808
|
+
// The grant is only spent when the fix prompt actually reached the dev.
|
|
3809
|
+
// Decrement (not restore-snapshot): a snapshot write-back would also
|
|
3810
|
+
// erase a concurrent Continue's grant.
|
|
3811
|
+
if (!dispatched) {
|
|
3812
|
+
await this.withTaskLock(async () => {
|
|
3813
|
+
const fresh = await this.taskStore.get(taskId);
|
|
3814
|
+
if (!fresh)
|
|
3815
|
+
return;
|
|
3816
|
+
fresh.maxRoundsContinues = Math.max(0, (fresh.maxRoundsContinues ?? 0) - 1);
|
|
3817
|
+
fresh.updatedAt = new Date().toISOString();
|
|
3818
|
+
await this.taskStore.set(fresh);
|
|
3819
|
+
}).catch(() => undefined);
|
|
3820
|
+
}
|
|
3821
|
+
}
|
|
3822
|
+
if (!dispatched) {
|
|
3823
|
+
throw new ApiError(500, `Failed to dispatch server fix round for task ${taskId}`);
|
|
3824
|
+
}
|
|
3825
|
+
return dispatched;
|
|
3826
|
+
}
|
|
3557
3827
|
if (!task.prNumber || !task.branch) {
|
|
3558
3828
|
throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot continue`);
|
|
3559
3829
|
}
|
|
@@ -3690,7 +3960,17 @@ export class AgentManager {
|
|
|
3690
3960
|
// Single source of truth for "what watcher should this task have, given its
|
|
3691
3961
|
// current state". Used by both setupRecoveredSpecSignals (restart recovery)
|
|
3692
3962
|
// and rollbackDispatchReviewPhase1 (manual dispatch failure).
|
|
3963
|
+
// Dev's first prompt offers the spec-first or straight-to-code path; the arm
|
|
3964
|
+
// must accept both completion signals for the task's protocol family.
|
|
3965
|
+
devInitialSignalKinds(reviewMode) {
|
|
3966
|
+
const mode = reviewMode ?? this.config.review.mode ?? 'github';
|
|
3967
|
+
return mode === 'server'
|
|
3968
|
+
? ['spec-done', 'code-done']
|
|
3969
|
+
: ['spec-created', 'pr-created'];
|
|
3970
|
+
}
|
|
3693
3971
|
mapTaskStateToExpectedWatcher(task) {
|
|
3972
|
+
if (task.reviewMode === 'server')
|
|
3973
|
+
return this.mapServerTaskToExpectedWatcher(task);
|
|
3694
3974
|
if (task.phase === 'spec' && task.status === 'review' && task.qaAgentId) {
|
|
3695
3975
|
return { expectedKinds: ['spec-approved', 'spec-changes-requested'], agentId: task.qaAgentId };
|
|
3696
3976
|
}
|
|
@@ -3714,14 +3994,35 @@ export class AgentManager {
|
|
|
3714
3994
|
}
|
|
3715
3995
|
return undefined;
|
|
3716
3996
|
}
|
|
3997
|
+
// Recovery mapping for server-mode tasks: the watcher is the ONLY verdict
|
|
3998
|
+
// channel (no poller backstop), so every awaiting state must re-arm on restart.
|
|
3999
|
+
mapServerTaskToExpectedWatcher(task) {
|
|
4000
|
+
const isSpec = task.phase === 'spec';
|
|
4001
|
+
if (task.status === 'review' && task.qaAgentId) {
|
|
4002
|
+
return { expectedKinds: [isSpec ? 'spec-reviewed' : 'code-reviewed'], agentId: task.qaAgentId };
|
|
4003
|
+
}
|
|
4004
|
+
if (task.status === 'fixing' && task.agentId) {
|
|
4005
|
+
return { expectedKinds: [isSpec ? 'spec-fixed' : 'code-fixed'], agentId: task.agentId };
|
|
4006
|
+
}
|
|
4007
|
+
if (task.status === 'in_progress' && task.agentId) {
|
|
4008
|
+
if (task.phase === 'code')
|
|
4009
|
+
return { expectedKinds: ['code-done'], agentId: task.agentId };
|
|
4010
|
+
return { expectedKinds: ['spec-done', 'code-done'], agentId: task.agentId };
|
|
4011
|
+
}
|
|
4012
|
+
if (task.status === 'approved' && task.agentId) {
|
|
4013
|
+
return { expectedKinds: ['code-ready'], agentId: task.agentId };
|
|
4014
|
+
}
|
|
4015
|
+
return undefined;
|
|
4016
|
+
}
|
|
3717
4017
|
// Public re-establish helper for in-band recoveries that don't rotate the token
|
|
3718
4018
|
// (e.g. handler reject path: agent's next emit must still match current
|
|
3719
|
-
// task.signalToken, so rotating would strand it).
|
|
4019
|
+
// task.signalToken, so rotating would strand it). Returns whether a watcher
|
|
4020
|
+
// armed; callers that consumed a signal must hold on false or it has no consumer.
|
|
3720
4021
|
async setupPhaseSignal(taskId, agentId, expectedKinds, opts = {}) {
|
|
3721
4022
|
const task = await this.taskStore.get(taskId);
|
|
3722
4023
|
if (!task?.signalToken)
|
|
3723
|
-
return;
|
|
3724
|
-
|
|
4024
|
+
return false;
|
|
4025
|
+
return this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, task.signalToken, opts.skipSnapshot);
|
|
3725
4026
|
}
|
|
3726
4027
|
async emitManualReviewDevParkedQaFailedIntervention(agentId, expectedTaskId) {
|
|
3727
4028
|
if (!agentId)
|
|
@@ -3838,28 +4139,83 @@ export class AgentManager {
|
|
|
3838
4139
|
// cleanup chain (pr.merged handler → transition merged + post-merge worktree/branch
|
|
3839
4140
|
// cleanup + /compact + release). Same path the poller drives when it detects the merge.
|
|
3840
4141
|
async markTaskComplete(taskId) {
|
|
3841
|
-
const
|
|
3842
|
-
if (!
|
|
4142
|
+
const peek = await this.taskStore.get(taskId);
|
|
4143
|
+
if (!peek)
|
|
3843
4144
|
throw new ApiError(404, `Task ${taskId} not found`);
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
// Claim the task for the whole merge window. markCompleteInFlight blocks Cancel /
|
|
3856
|
-
// Call review / Continue (all re-check it under the task lock) so they can't act on
|
|
3857
|
-
// the same max_rounds snapshot and interleave with the irreversible `gh pr merge`.
|
|
3858
|
-
if (this.markCompleteInFlight.has(taskId)) {
|
|
3859
|
-
throw new ApiError(409, `Task ${taskId} is already being completed`);
|
|
3860
|
-
}
|
|
3861
|
-
this.markCompleteInFlight.add(taskId);
|
|
4145
|
+
// Human gate (spec §10): ready / merge-ready confirm runs its own completion
|
|
4146
|
+
// matrix (with its own lock-claimed gate); the legacy max_rounds path below
|
|
4147
|
+
// is untouched.
|
|
4148
|
+
if (peek.status === 'ready' || peek.status === 'merge-ready') {
|
|
4149
|
+
return this.confirmHumanGate(taskId);
|
|
4150
|
+
}
|
|
4151
|
+
// Claim under the task lock — the whole merge window. markCompleteInFlight
|
|
4152
|
+
// blocks Cancel / Call review / Continue (all re-check it under the same
|
|
4153
|
+
// lock) so they can't act on the same snapshot and interleave with the
|
|
4154
|
+
// irreversible `gh pr merge` (or, server mode, the publish dispatch).
|
|
4155
|
+
const task = await this.claimCompleteGate(taskId, ['max_rounds', 'approved']);
|
|
3862
4156
|
try {
|
|
4157
|
+
// Server-mode publish retry: a failed afterDone dispatch leaves the task
|
|
4158
|
+
// 'approved' with dev released — mark-complete re-runs the publish (PR #288).
|
|
4159
|
+
const serverApprovedRetry = task.status === 'approved' && task.reviewMode === 'server';
|
|
4160
|
+
if (!serverApprovedRetry && task.status !== 'max_rounds') {
|
|
4161
|
+
throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
|
|
4162
|
+
}
|
|
4163
|
+
// spec-phase max_rounds escapes via Retry/Cancel only (the UI hides complete). Guard the
|
|
4164
|
+
// endpoint too so a direct API call / older client can't merge a spec cap through here.
|
|
4165
|
+
if (task.phase === 'spec') {
|
|
4166
|
+
throw new ApiError(409, `Mark complete is only supported for code-phase tasks`);
|
|
4167
|
+
}
|
|
4168
|
+
if (task.reviewMode !== 'server' && (!task.prNumber || !task.branch)) {
|
|
4169
|
+
throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot mark complete`);
|
|
4170
|
+
}
|
|
4171
|
+
if (serverApprovedRetry) {
|
|
4172
|
+
// A live code-ready watcher armed by a real dispatch means the publish
|
|
4173
|
+
// prompt IS running — a retry would inject a second prompt and rotate
|
|
4174
|
+
// the token under it. A RECOVERED watcher is weaker evidence, but
|
|
4175
|
+
// publishDispatchedAt persists delivery across restarts: set = the
|
|
4176
|
+
// prompt reached the pane before the restart (still in flight, 409);
|
|
4177
|
+
// cleared = the dispatch failed and this approved state is retryable —
|
|
4178
|
+
// stop the recovered watch and let the retry own the dispatch (PR #288).
|
|
4179
|
+
if (this.phaseSignalWatcher?.expectedKindsFor(taskId).has('code-ready')) {
|
|
4180
|
+
if (!this.phaseSignalWatcher.isRecovered(taskId) || task.publishDispatchedAt) {
|
|
4181
|
+
throw new ApiError(409, `Task ${taskId} publish is in flight; retry only after it fails`);
|
|
4182
|
+
}
|
|
4183
|
+
this.phaseSignalWatcher.stop(taskId);
|
|
4184
|
+
}
|
|
4185
|
+
else if (task.publishDispatchedAt) {
|
|
4186
|
+
throw new ApiError(409, `Task ${taskId} publish was delivered and is awaiting code-ready; ` +
|
|
4187
|
+
`retry only after it fails (if the publish is verifiably dead, Cancel the task)`);
|
|
4188
|
+
}
|
|
4189
|
+
// task.afterDone was snapshotted when the approve verdict routed it.
|
|
4190
|
+
const afterDone = this.resolveAfterDone(task);
|
|
4191
|
+
if (afterDone === null) {
|
|
4192
|
+
throw new ApiError(409, `Task ${taskId} is approved with no afterDone step; nothing to retry`);
|
|
4193
|
+
}
|
|
4194
|
+
await this.dispatchServerAfterDone(taskId, afterDone);
|
|
4195
|
+
return (await this.taskStore.get(taskId));
|
|
4196
|
+
}
|
|
4197
|
+
// Server-mode capped task, human accepts as-is: no PR exists yet — run the
|
|
4198
|
+
// afterDone flow (or finish directly) instead of the legacy PR merge (PR #288).
|
|
4199
|
+
// Inside the in-flight claim so a concurrent Continue can't act on the same
|
|
4200
|
+
// max_rounds snapshot and release dev mid-publish.
|
|
4201
|
+
if (task.reviewMode === 'server') {
|
|
4202
|
+
// Max_rounds never routed an approve verdict — snapshot afterDone NOW so
|
|
4203
|
+
// the eventual ready-confirm uses this decision, not future hot config.
|
|
4204
|
+
const afterDone = this.config.review.afterDone ?? null;
|
|
4205
|
+
await this.updateTask(taskId, { afterDone });
|
|
4206
|
+
if (afterDone === null) {
|
|
4207
|
+
const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['max_rounds'] });
|
|
4208
|
+
if (!done)
|
|
4209
|
+
throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
|
|
4210
|
+
await this.releaseTaskAgents(taskId);
|
|
4211
|
+
return (await this.taskStore.get(taskId));
|
|
4212
|
+
}
|
|
4213
|
+
const approved = await this.transitionTaskStatus(taskId, 'approved', { fromStatus: ['max_rounds'] });
|
|
4214
|
+
if (!approved)
|
|
4215
|
+
throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
|
|
4216
|
+
await this.dispatchServerAfterDone(taskId, afterDone);
|
|
4217
|
+
return (await this.taskStore.get(taskId));
|
|
4218
|
+
}
|
|
3863
4219
|
// Held-agent check AFTER claiming (the claim blocks a new continueDevRound from starting),
|
|
3864
4220
|
// and re-reading agent state here catches a continue that Held an agent in the window just
|
|
3865
4221
|
// before our claim. dispatchPostMergeCleanup early-returns on awaiting_human, so merging with
|
|
@@ -4143,7 +4499,7 @@ export class AgentManager {
|
|
|
4143
4499
|
// to arm — the dangerous case where a same-identity verdict would have no consumer. When no
|
|
4144
4500
|
// watcher subsystem is configured at all (poller-only deployment) the poller is the verdict
|
|
4145
4501
|
// path, so this returns true and does not block. Best-effort callers ignore the result.
|
|
4146
|
-
async setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, skipSnapshot = false) {
|
|
4502
|
+
async setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, skipSnapshot = false, onReadFile) {
|
|
4147
4503
|
if (!this.phaseSignalWatcher)
|
|
4148
4504
|
return true;
|
|
4149
4505
|
const task = await this.taskStore.get(taskId);
|
|
@@ -4157,6 +4513,10 @@ export class AgentManager {
|
|
|
4157
4513
|
expectedKinds,
|
|
4158
4514
|
token,
|
|
4159
4515
|
skipSnapshot,
|
|
4516
|
+
// Mode routing rides every arm automatically: the snapshot lives on the
|
|
4517
|
+
// task, so github-mode tasks keep their legacy event types untouched.
|
|
4518
|
+
reviewMode: task.reviewMode ?? 'github',
|
|
4519
|
+
...(onReadFile ? { onReadFile } : {}),
|
|
4160
4520
|
});
|
|
4161
4521
|
}
|
|
4162
4522
|
catch (err) {
|
|
@@ -4167,8 +4527,8 @@ export class AgentManager {
|
|
|
4167
4527
|
// Arm a watcher for a signal the just-dispatched prompt will emit, then hold the agent if it
|
|
4168
4528
|
// could not arm. Used by post-dispatch arms (develop/spec/code phases) whose pane only exists
|
|
4169
4529
|
// after dispatch, so they can't gate before sending the prompt the way verdict dispatch does.
|
|
4170
|
-
async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false) {
|
|
4171
|
-
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, skipSnapshot);
|
|
4530
|
+
async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false, onReadFile) {
|
|
4531
|
+
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, skipSnapshot, onReadFile);
|
|
4172
4532
|
if (!armed)
|
|
4173
4533
|
await this.holdAgentForUnarmedSignal(taskId, agentId, expectedKinds);
|
|
4174
4534
|
}
|
|
@@ -4593,7 +4953,14 @@ export class AgentManager {
|
|
|
4593
4953
|
// (acquire + continueSession below), so holding here would block that reentry. And pr-created
|
|
4594
4954
|
// is authoritatively detected by the GitHub poller (PR creation isn't same-identity-gated), so
|
|
4595
4955
|
// a missed pane watcher only costs one poll cycle of latency, never a stuck task.
|
|
4596
|
-
|
|
4956
|
+
// Server mode has NO poller backstop: an unarmed code-done watcher means the
|
|
4957
|
+
// dev's completion signal would have no consumer — fail closed and hold.
|
|
4958
|
+
const codeKind = task.reviewMode === 'server' ? 'code-done' : 'pr-created';
|
|
4959
|
+
const codeArmed = await this.setupPhaseSignalWatcher(taskId, devAgentId, codeKind, newToken);
|
|
4960
|
+
if (!codeArmed && task.reviewMode === 'server') {
|
|
4961
|
+
await this.holdAgentForUnarmedSignal(taskId, devAgentId, codeKind);
|
|
4962
|
+
return null;
|
|
4963
|
+
}
|
|
4597
4964
|
if (task.qaAgentId) {
|
|
4598
4965
|
// release 失败留 stale qa binding → emit intervention 让其可见。
|
|
4599
4966
|
const released = await this.releaseAgentForTask(task.qaAgentId, taskId, 'idle')
|
|
@@ -4612,6 +4979,11 @@ export class AgentManager {
|
|
|
4612
4979
|
}
|
|
4613
4980
|
const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'code');
|
|
4614
4981
|
if (!acquired) {
|
|
4982
|
+
// Task already shows phase='code' in_progress with the code-done watcher
|
|
4983
|
+
// armed, and server mode has no poller backstop — without a hold the dev
|
|
4984
|
+
// never receives the code prompt and the task dead-ends. The
|
|
4985
|
+
// code-dispatch-failed hold gives Resume a redispatch path.
|
|
4986
|
+
await this.markAwaitingHuman(devAgentId, 'code-dispatch-failed', 'Dev could not be acquired for the code phase after spec approval; the task looks in_progress but the code prompt was never dispatched. Resume the agent to redispatch or cancel the task.', { expectedTaskId: taskId }).catch(() => undefined);
|
|
4615
4987
|
await this.safeEmit({
|
|
4616
4988
|
id: '',
|
|
4617
4989
|
type: 'human.intervention',
|
|
@@ -4633,10 +5005,16 @@ export class AgentManager {
|
|
|
4633
5005
|
if (err instanceof DispatchTerminalError) {
|
|
4634
5006
|
await this.failTaskForDispatchError(taskId, 'code', devAgentId, err);
|
|
4635
5007
|
}
|
|
5008
|
+
else if (!(err instanceof EnsureSessionError && err.partial.handled)) {
|
|
5009
|
+
// Task already shows phase='code' in_progress but the prompt never landed
|
|
5010
|
+
// and there is no retry entry — hold explicitly instead of dead-ending.
|
|
5011
|
+
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);
|
|
5012
|
+
}
|
|
4636
5013
|
console.error(`[AgentManager] transitionToCodePhase continueSession(dev=${devAgentId}) failed:`, err);
|
|
4637
5014
|
throw err;
|
|
4638
5015
|
}
|
|
4639
5016
|
if (!resumed) {
|
|
5017
|
+
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);
|
|
4640
5018
|
await this.safeEmit({
|
|
4641
5019
|
id: '',
|
|
4642
5020
|
type: 'human.intervention',
|
|
@@ -4650,6 +5028,661 @@ export class AgentManager {
|
|
|
4650
5028
|
}
|
|
4651
5029
|
return await this.taskStore.get(taskId);
|
|
4652
5030
|
}
|
|
5031
|
+
// ── Server review mode (spec: docs/spec/server-review-mode.md) ──────────────
|
|
5032
|
+
async dispatchServerReviewToQa(taskId, opts) {
|
|
5033
|
+
const dispatchPhase = opts.phase === 'spec'
|
|
5034
|
+
? 'server-spec-review'
|
|
5035
|
+
: (opts.recheck ? 'server-recheck' : 'server-review');
|
|
5036
|
+
const expectedKind = opts.phase === 'spec' ? 'spec-reviewed' : 'code-reviewed';
|
|
5037
|
+
const claim = await this.withTaskLock(async () => {
|
|
5038
|
+
const task = await this.taskStore.get(taskId);
|
|
5039
|
+
if (!task)
|
|
5040
|
+
throw new Error(`dispatchServerReviewToQa: task ${taskId} not found`);
|
|
5041
|
+
if (task.reviewMode !== 'server') {
|
|
5042
|
+
throw new Error(`dispatchServerReviewToQa: task ${taskId} is not in server review mode`);
|
|
5043
|
+
}
|
|
5044
|
+
const qaId = task.qaAgentId ?? this.findQaPartner(task.agentId)?.id;
|
|
5045
|
+
if (!qaId) {
|
|
5046
|
+
// Config validation rejects qa-less server pairs, but a hot-removed QA
|
|
5047
|
+
// can still land here — re-arm the consumed entry signal so the task
|
|
5048
|
+
// is recoverable once a QA is configured again.
|
|
5049
|
+
const entryKind = task.status === 'fixing'
|
|
5050
|
+
? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
|
|
5051
|
+
: (opts.phase === 'spec' ? 'spec-done' : 'code-done');
|
|
5052
|
+
await this.setupPhaseSignal(taskId, task.agentId, entryKind, { skipSnapshot: true });
|
|
5053
|
+
await this.safeEmit({
|
|
5054
|
+
id: '',
|
|
5055
|
+
type: 'human.intervention',
|
|
5056
|
+
timestamp: new Date().toISOString(),
|
|
5057
|
+
projectId: task.projectId,
|
|
5058
|
+
agentId: task.agentId,
|
|
5059
|
+
taskId,
|
|
5060
|
+
data: { phase: 'server-review-no-qa-partner', devAgentId: task.agentId },
|
|
5061
|
+
});
|
|
5062
|
+
return null;
|
|
5063
|
+
}
|
|
5064
|
+
const roundField = opts.phase === 'spec' ? (task.specReviewRound ?? 0) : task.reviewRound;
|
|
5065
|
+
return {
|
|
5066
|
+
qaId,
|
|
5067
|
+
devAgentId: task.agentId,
|
|
5068
|
+
projectId: task.projectId,
|
|
5069
|
+
newToken: createSignalToken(),
|
|
5070
|
+
newRound: opts.continuation ? Math.max(roundField, 1) : roundField + 1,
|
|
5071
|
+
originalStatus: task.status,
|
|
5072
|
+
originalToken: task.signalToken,
|
|
5073
|
+
originalRound: roundField,
|
|
5074
|
+
originalBatchIndex: task.batchIndex,
|
|
5075
|
+
originalBatchTotal: task.batchTotal,
|
|
5076
|
+
};
|
|
5077
|
+
});
|
|
5078
|
+
if (!claim)
|
|
5079
|
+
return null;
|
|
5080
|
+
const { qaId, devAgentId, projectId, newToken, newRound } = claim;
|
|
5081
|
+
// continueSession failure after the transition would otherwise strand the
|
|
5082
|
+
// task in 'review' with a fresh token nobody will ever signal (PR #288).
|
|
5083
|
+
const rollback = async () => {
|
|
5084
|
+
await this.transitionTaskStatus(taskId, claim.originalStatus, { fromStatus: ['review'] }, {
|
|
5085
|
+
signalToken: claim.originalToken,
|
|
5086
|
+
batchIndex: claim.originalBatchIndex,
|
|
5087
|
+
batchTotal: claim.originalBatchTotal,
|
|
5088
|
+
...(opts.phase === 'spec'
|
|
5089
|
+
? { specReviewRound: claim.originalRound }
|
|
5090
|
+
: { reviewRound: claim.originalRound }),
|
|
5091
|
+
}).catch(() => undefined);
|
|
5092
|
+
};
|
|
5093
|
+
// The entry signal (code/spec-done|fixed) was already consumed by the
|
|
5094
|
+
// watcher; a pre-transition failure must re-arm it with the unrotated token
|
|
5095
|
+
// or the agent's re-emit after the operator fixes availability has no
|
|
5096
|
+
// consumer (PR #288).
|
|
5097
|
+
const rearmEntrySignal = async () => {
|
|
5098
|
+
const entryKind = claim.originalStatus === 'fixing'
|
|
5099
|
+
? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
|
|
5100
|
+
: (opts.phase === 'spec' ? 'spec-done' : 'code-done');
|
|
5101
|
+
await this.setupPhaseSignal(taskId, devAgentId, entryKind, { skipSnapshot: true });
|
|
5102
|
+
};
|
|
5103
|
+
if (!opts.continuation) {
|
|
5104
|
+
const acquired = await this.acquireAgentForTask(qaId, taskId, dispatchPhase);
|
|
5105
|
+
if (!acquired) {
|
|
5106
|
+
await rearmEntrySignal();
|
|
5107
|
+
await this.safeEmit({
|
|
5108
|
+
id: '',
|
|
5109
|
+
type: 'human.intervention',
|
|
5110
|
+
timestamp: new Date().toISOString(),
|
|
5111
|
+
projectId,
|
|
5112
|
+
agentId: qaId,
|
|
5113
|
+
taskId,
|
|
5114
|
+
data: { phase: 'server-review-qa-acquire-failed', qaAgentId: qaId },
|
|
5115
|
+
});
|
|
5116
|
+
return null;
|
|
5117
|
+
}
|
|
5118
|
+
if (devAgentId) {
|
|
5119
|
+
const devOk = await this.markAgentWaiting(devAgentId, taskId);
|
|
5120
|
+
if (!devOk) {
|
|
5121
|
+
await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
|
|
5122
|
+
await rearmEntrySignal();
|
|
5123
|
+
await this.safeEmit({
|
|
5124
|
+
id: '',
|
|
5125
|
+
type: 'human.intervention',
|
|
5126
|
+
timestamp: new Date().toISOString(),
|
|
5127
|
+
projectId,
|
|
5128
|
+
agentId: devAgentId,
|
|
5129
|
+
taskId,
|
|
5130
|
+
data: { phase: 'server-review-dev-park-failed', devAgentId },
|
|
5131
|
+
});
|
|
5132
|
+
return null;
|
|
5133
|
+
}
|
|
5134
|
+
}
|
|
5135
|
+
}
|
|
5136
|
+
const roundPatch = opts.phase === 'spec'
|
|
5137
|
+
? { specReviewRound: newRound, phase: 'spec' }
|
|
5138
|
+
: { reviewRound: newRound };
|
|
5139
|
+
const transition = await this.transitionTaskStatus(taskId, 'review', { fromStatus: ['in_progress', 'fixing', 'review'] }, {
|
|
5140
|
+
signalToken: newToken,
|
|
5141
|
+
qaAgentId: qaId,
|
|
5142
|
+
reviewDispatchedAt: new Date().toISOString(),
|
|
5143
|
+
...(opts.reviewHeadAnchorSha ? { reviewHeadAnchorSha: opts.reviewHeadAnchorSha } : {}),
|
|
5144
|
+
...(opts.batch
|
|
5145
|
+
? { batchIndex: opts.batch.index, batchTotal: opts.batch.total }
|
|
5146
|
+
: { batchIndex: undefined, batchTotal: undefined }),
|
|
5147
|
+
...roundPatch,
|
|
5148
|
+
});
|
|
5149
|
+
if (!transition) {
|
|
5150
|
+
if (!opts.continuation) {
|
|
5151
|
+
await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
|
|
5152
|
+
}
|
|
5153
|
+
await this.safeEmit({
|
|
5154
|
+
id: '',
|
|
5155
|
+
type: 'human.intervention',
|
|
5156
|
+
timestamp: new Date().toISOString(),
|
|
5157
|
+
projectId,
|
|
5158
|
+
agentId: qaId,
|
|
5159
|
+
taskId,
|
|
5160
|
+
data: { phase: 'server-review-transition-failed', qaAgentId: qaId },
|
|
5161
|
+
});
|
|
5162
|
+
return null;
|
|
5163
|
+
}
|
|
5164
|
+
// First dispatch creates the QA's base-detached worktree (startSession);
|
|
5165
|
+
// batch continuations reuse the live session + worktree (continueSession).
|
|
5166
|
+
const sessionOpts = {
|
|
5167
|
+
bypassTaskStatusGate: true,
|
|
5168
|
+
signalToken: newToken,
|
|
5169
|
+
serverContent: opts.content,
|
|
5170
|
+
...(opts.diffstat !== undefined ? { serverDiffstat: opts.diffstat } : {}),
|
|
5171
|
+
...(opts.contentTruncated ? { contentTruncated: true } : {}),
|
|
5172
|
+
...(opts.batch ? { serverBatch: opts.batch } : {}),
|
|
5173
|
+
...(opts.priorFindingsJson ? { serverPriorFindings: opts.priorFindingsJson } : {}),
|
|
5174
|
+
...(opts.priorResponseJson ? { serverPriorResponse: opts.priorResponseJson } : {}),
|
|
5175
|
+
...(opts.phase === 'spec' ? { currentSpecRound: newRound } : {}),
|
|
5176
|
+
};
|
|
5177
|
+
// A continuation consumed the QA's reviewed signal (not the dev's entry
|
|
5178
|
+
// signal): rollback restores the prior slice's review/token, so re-arm the
|
|
5179
|
+
// reviewed watcher — the QA's re-emit replays the stored batch findings and
|
|
5180
|
+
// resumes the next-slice dispatch (PR #288).
|
|
5181
|
+
const rearmConsumedSignal = async () => {
|
|
5182
|
+
if (opts.continuation) {
|
|
5183
|
+
await this.setupPhaseSignal(taskId, qaId, expectedKind, { skipSnapshot: true });
|
|
5184
|
+
}
|
|
5185
|
+
else {
|
|
5186
|
+
await rearmEntrySignal();
|
|
5187
|
+
}
|
|
5188
|
+
};
|
|
5189
|
+
let started = false;
|
|
5190
|
+
try {
|
|
5191
|
+
started = opts.continuation
|
|
5192
|
+
? await this.continueSession(taskId, qaId, dispatchPhase, sessionOpts)
|
|
5193
|
+
: await this.startSession(taskId, qaId, dispatchPhase, sessionOpts);
|
|
5194
|
+
}
|
|
5195
|
+
catch (err) {
|
|
5196
|
+
if (err instanceof DispatchTerminalError) {
|
|
5197
|
+
await this.failTaskForDispatchError(taskId, dispatchPhase, qaId, err);
|
|
5198
|
+
}
|
|
5199
|
+
else if (err instanceof EnsureSessionError && err.partial.handled) {
|
|
5200
|
+
// handleDialogPendingFromRuntime already held + failed + released.
|
|
5201
|
+
}
|
|
5202
|
+
else {
|
|
5203
|
+
await rollback();
|
|
5204
|
+
if (!opts.continuation) {
|
|
5205
|
+
await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
|
|
5206
|
+
}
|
|
5207
|
+
await rearmConsumedSignal();
|
|
5208
|
+
}
|
|
5209
|
+
throw err;
|
|
5210
|
+
}
|
|
5211
|
+
if (!started) {
|
|
5212
|
+
await rollback();
|
|
5213
|
+
if (!opts.continuation) {
|
|
5214
|
+
await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
|
|
5215
|
+
}
|
|
5216
|
+
await rearmConsumedSignal();
|
|
5217
|
+
await this.safeEmit({
|
|
5218
|
+
id: '',
|
|
5219
|
+
type: 'human.intervention',
|
|
5220
|
+
timestamp: new Date().toISOString(),
|
|
5221
|
+
projectId,
|
|
5222
|
+
agentId: qaId,
|
|
5223
|
+
taskId,
|
|
5224
|
+
data: { phase: 'server-review-start-failed', qaAgentId: qaId },
|
|
5225
|
+
});
|
|
5226
|
+
return null;
|
|
5227
|
+
}
|
|
5228
|
+
this.stopPhaseSignalWatcher(taskId);
|
|
5229
|
+
await this.armPostDispatchSignalOrHold(taskId, qaId, expectedKind, newToken, false, (req) => { void this.handleReadFileRequest(taskId, qaId, req); });
|
|
5230
|
+
return await this.taskStore.get(taskId);
|
|
5231
|
+
}
|
|
5232
|
+
async dispatchServerFixToDev(taskId, findingsJson) {
|
|
5233
|
+
const claim = await this.withTaskLock(async () => {
|
|
5234
|
+
const task = await this.taskStore.get(taskId);
|
|
5235
|
+
if (!task)
|
|
5236
|
+
throw new Error(`dispatchServerFixToDev: task ${taskId} not found`);
|
|
5237
|
+
if (task.reviewMode !== 'server') {
|
|
5238
|
+
throw new Error(`dispatchServerFixToDev: task ${taskId} is not in server review mode`);
|
|
5239
|
+
}
|
|
5240
|
+
if (!task.agentId)
|
|
5241
|
+
throw new Error(`dispatchServerFixToDev: task ${taskId} has no dev agent`);
|
|
5242
|
+
return {
|
|
5243
|
+
devAgentId: task.agentId,
|
|
5244
|
+
qaAgentId: task.qaAgentId,
|
|
5245
|
+
projectId: task.projectId,
|
|
5246
|
+
newToken: createSignalToken(),
|
|
5247
|
+
taskPhase: (task.phase ?? 'code'),
|
|
5248
|
+
currentSpecRound: task.specReviewRound,
|
|
5249
|
+
// Continue-one-round enters from max_rounds — failure must restore THAT,
|
|
5250
|
+
// not silently demote the human's pause decision to 'review' (PR #288).
|
|
5251
|
+
originalStatus: task.status,
|
|
5252
|
+
originalToken: task.signalToken,
|
|
5253
|
+
};
|
|
5254
|
+
});
|
|
5255
|
+
if (!claim)
|
|
5256
|
+
return null;
|
|
5257
|
+
const { devAgentId, qaAgentId, projectId, newToken, taskPhase, currentSpecRound } = claim;
|
|
5258
|
+
const rollbackToEntry = async () => {
|
|
5259
|
+
await this.transitionTaskStatus(taskId, claim.originalStatus, { fromStatus: ['fixing'] }, { signalToken: claim.originalToken }).catch(() => undefined);
|
|
5260
|
+
};
|
|
5261
|
+
const expectedKind = taskPhase === 'spec' ? 'spec-fixed' : 'code-fixed';
|
|
5262
|
+
// The QA's reviewed signal was consumed before this dispatch; pre-transition
|
|
5263
|
+
// failures must re-arm it (unrotated token) so a later re-emit is consumed.
|
|
5264
|
+
const rearmReviewedSignal = async () => {
|
|
5265
|
+
if (!qaAgentId)
|
|
5266
|
+
return;
|
|
5267
|
+
const reviewedKind = taskPhase === 'spec' ? 'spec-reviewed' : 'code-reviewed';
|
|
5268
|
+
await this.setupPhaseSignal(taskId, qaAgentId, reviewedKind, { skipSnapshot: true });
|
|
5269
|
+
};
|
|
5270
|
+
// Dev BEFORE QA: releasing the QA first is irreversible (binding cleared,
|
|
5271
|
+
// worktree removed, schedulable elsewhere) — a dev acquire failure after it
|
|
5272
|
+
// would leave the review-parked task with no stably-bound agent to retry
|
|
5273
|
+
// from. With the dev secured first, both failure exits keep the QA bound.
|
|
5274
|
+
const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-feedback');
|
|
5275
|
+
if (!acquired) {
|
|
5276
|
+
await rearmReviewedSignal();
|
|
5277
|
+
await this.safeEmit({
|
|
5278
|
+
id: '',
|
|
5279
|
+
type: 'human.intervention',
|
|
5280
|
+
timestamp: new Date().toISOString(),
|
|
5281
|
+
projectId,
|
|
5282
|
+
agentId: devAgentId,
|
|
5283
|
+
taskId,
|
|
5284
|
+
data: { phase: 'server-fix-dev-acquire-failed', devAgentId },
|
|
5285
|
+
});
|
|
5286
|
+
return null;
|
|
5287
|
+
}
|
|
5288
|
+
if (qaAgentId) {
|
|
5289
|
+
const released = await this.releaseAgentForTask(qaAgentId, taskId, 'idle')
|
|
5290
|
+
.catch(() => false);
|
|
5291
|
+
if (!released) {
|
|
5292
|
+
await rearmReviewedSignal();
|
|
5293
|
+
await this.safeEmit({
|
|
5294
|
+
id: '',
|
|
5295
|
+
type: 'human.intervention',
|
|
5296
|
+
timestamp: new Date().toISOString(),
|
|
5297
|
+
projectId,
|
|
5298
|
+
agentId: qaAgentId,
|
|
5299
|
+
taskId,
|
|
5300
|
+
data: { phase: 'server-fix-qa-release-failed', qaAgentId },
|
|
5301
|
+
});
|
|
5302
|
+
return null;
|
|
5303
|
+
}
|
|
5304
|
+
}
|
|
5305
|
+
// max_rounds entry = human "continue one round" via continueDevRound.
|
|
5306
|
+
const transition = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review', 'max_rounds'] }, { signalToken: newToken, fixDispatchedAt: new Date().toISOString() });
|
|
5307
|
+
if (!transition) {
|
|
5308
|
+
// Refusal = the task left review/max_rounds concurrently (cancel / fail /
|
|
5309
|
+
// mark-complete publish). Ownership moved with it — releasing dev here
|
|
5310
|
+
// would strip a binding the winning chain may be actively using (e.g. a
|
|
5311
|
+
// publish prompt running in the pane); its own cleanup releases the dev.
|
|
5312
|
+
await this.safeEmit({
|
|
5313
|
+
id: '',
|
|
5314
|
+
type: 'human.intervention',
|
|
5315
|
+
timestamp: new Date().toISOString(),
|
|
5316
|
+
projectId,
|
|
5317
|
+
agentId: devAgentId,
|
|
5318
|
+
taskId,
|
|
5319
|
+
data: { phase: 'server-fix-transition-failed', devAgentId },
|
|
5320
|
+
});
|
|
5321
|
+
return null;
|
|
5322
|
+
}
|
|
5323
|
+
let resumed = false;
|
|
5324
|
+
try {
|
|
5325
|
+
resumed = await this.continueSession(taskId, devAgentId, 'server-feedback', {
|
|
5326
|
+
bypassTaskStatusGate: true,
|
|
5327
|
+
signalToken: newToken,
|
|
5328
|
+
serverPriorFindings: findingsJson,
|
|
5329
|
+
...(taskPhase === 'spec' && currentSpecRound !== undefined
|
|
5330
|
+
? { currentSpecRound }
|
|
5331
|
+
: {}),
|
|
5332
|
+
});
|
|
5333
|
+
}
|
|
5334
|
+
catch (err) {
|
|
5335
|
+
if (err instanceof DispatchTerminalError) {
|
|
5336
|
+
await this.failTaskForDispatchError(taskId, 'server-feedback', devAgentId, err);
|
|
5337
|
+
}
|
|
5338
|
+
else if (err instanceof EnsureSessionError && err.partial.handled) {
|
|
5339
|
+
// handled upstream
|
|
5340
|
+
}
|
|
5341
|
+
else {
|
|
5342
|
+
await rollbackToEntry();
|
|
5343
|
+
await this.releaseAgentForTask(devAgentId, taskId, 'idle').catch(() => undefined);
|
|
5344
|
+
// Rollback restored review/old-token, but the QA's reviewed signal was
|
|
5345
|
+
// consumed — without a subscriber its re-emit can never retry the fix
|
|
5346
|
+
// dispatch (PR #288).
|
|
5347
|
+
await rearmReviewedSignal();
|
|
5348
|
+
}
|
|
5349
|
+
throw err;
|
|
5350
|
+
}
|
|
5351
|
+
if (!resumed) {
|
|
5352
|
+
await rollbackToEntry();
|
|
5353
|
+
await this.releaseAgentForTask(devAgentId, taskId, 'idle').catch(() => undefined);
|
|
5354
|
+
await rearmReviewedSignal();
|
|
5355
|
+
await this.safeEmit({
|
|
5356
|
+
id: '',
|
|
5357
|
+
type: 'human.intervention',
|
|
5358
|
+
timestamp: new Date().toISOString(),
|
|
5359
|
+
projectId,
|
|
5360
|
+
agentId: devAgentId,
|
|
5361
|
+
taskId,
|
|
5362
|
+
data: { phase: 'server-fix-resume-failed', devAgentId },
|
|
5363
|
+
});
|
|
5364
|
+
return null;
|
|
5365
|
+
}
|
|
5366
|
+
await this.armPostDispatchSignalOrHold(taskId, devAgentId, expectedKind, newToken);
|
|
5367
|
+
return await this.taskStore.get(taskId);
|
|
5368
|
+
}
|
|
5369
|
+
async dispatchServerAfterDone(taskId, kind) {
|
|
5370
|
+
const task = await this.taskStore.get(taskId);
|
|
5371
|
+
if (!task)
|
|
5372
|
+
throw new Error(`dispatchServerAfterDone: task ${taskId} not found`);
|
|
5373
|
+
const devAgentId = task.agentId;
|
|
5374
|
+
if (!devAgentId)
|
|
5375
|
+
throw new Error(`dispatchServerAfterDone: task ${taskId} has no dev agent`);
|
|
5376
|
+
const branch = task.branch ?? BRANCH_PREFIX + taskId;
|
|
5377
|
+
const originalToken = task.signalToken;
|
|
5378
|
+
const newToken = createSignalToken();
|
|
5379
|
+
await this.updateTask(taskId, { signalToken: newToken });
|
|
5380
|
+
// The publish prompt never reached the pane — restore the pre-rotation token
|
|
5381
|
+
// (so recovery still matches the pre-dispatch arm) and clear the delivery
|
|
5382
|
+
// marker so retry knows this approved state is preemptible (PR #288).
|
|
5383
|
+
const rollbackToken = async () => {
|
|
5384
|
+
await this.updateTask(taskId, { signalToken: originalToken, publishDispatchedAt: undefined })
|
|
5385
|
+
.catch(() => undefined);
|
|
5386
|
+
};
|
|
5387
|
+
const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-after-done');
|
|
5388
|
+
if (!acquired) {
|
|
5389
|
+
await rollbackToken();
|
|
5390
|
+
await this.safeEmit({
|
|
5391
|
+
id: '',
|
|
5392
|
+
type: 'human.intervention',
|
|
5393
|
+
timestamp: new Date().toISOString(),
|
|
5394
|
+
projectId: task.projectId,
|
|
5395
|
+
agentId: devAgentId,
|
|
5396
|
+
taskId,
|
|
5397
|
+
data: { phase: 'server-after-done-dev-acquire-failed', devAgentId },
|
|
5398
|
+
});
|
|
5399
|
+
return null;
|
|
5400
|
+
}
|
|
5401
|
+
// Pessimistic delivery marker BEFORE the irreversible paste: every failure
|
|
5402
|
+
// path below clears it. The remaining crash window (marker written, paste
|
|
5403
|
+
// never ran) fails CLOSED — retry 409s on a publish that never started and
|
|
5404
|
+
// the operator escapes via Cancel — instead of the old window's fail-open
|
|
5405
|
+
// double publish (paste ran, marker missing, retry re-pastes) (PR #288).
|
|
5406
|
+
await this.updateTask(taskId, { publishDispatchedAt: new Date().toISOString() });
|
|
5407
|
+
let resumed = false;
|
|
5408
|
+
try {
|
|
5409
|
+
resumed = await this.continueSession(taskId, devAgentId, 'server-after-done', {
|
|
5410
|
+
bypassTaskStatusGate: true,
|
|
5411
|
+
signalToken: newToken,
|
|
5412
|
+
serverAfterDone: { kind, branch },
|
|
5413
|
+
});
|
|
5414
|
+
}
|
|
5415
|
+
catch (err) {
|
|
5416
|
+
if (err instanceof DispatchTerminalError) {
|
|
5417
|
+
await this.failTaskForDispatchError(taskId, 'server-after-done', devAgentId, err);
|
|
5418
|
+
}
|
|
5419
|
+
else if (!(err instanceof EnsureSessionError && err.partial.handled)) {
|
|
5420
|
+
// Keep dev BOUND — its worktree holds the reviewed (unpushed) commits.
|
|
5421
|
+
// mark-complete retries the publish via server-after-done same-task reentry.
|
|
5422
|
+
await rollbackToken();
|
|
5423
|
+
}
|
|
5424
|
+
throw err;
|
|
5425
|
+
}
|
|
5426
|
+
if (!resumed) {
|
|
5427
|
+
await rollbackToken();
|
|
5428
|
+
await this.safeEmit({
|
|
5429
|
+
id: '',
|
|
5430
|
+
type: 'human.intervention',
|
|
5431
|
+
timestamp: new Date().toISOString(),
|
|
5432
|
+
projectId: task.projectId,
|
|
5433
|
+
agentId: devAgentId,
|
|
5434
|
+
taskId,
|
|
5435
|
+
data: {
|
|
5436
|
+
phase: 'server-after-done-resume-failed',
|
|
5437
|
+
devAgentId,
|
|
5438
|
+
note: 'Publish prompt was not delivered; mark-complete retries the publish dispatch.',
|
|
5439
|
+
},
|
|
5440
|
+
});
|
|
5441
|
+
return null;
|
|
5442
|
+
}
|
|
5443
|
+
await this.armPostDispatchSignalOrHold(taskId, devAgentId, 'code-ready', newToken);
|
|
5444
|
+
return await this.taskStore.get(taskId);
|
|
5445
|
+
}
|
|
5446
|
+
// QA asked for file context during a server-mode review. Read from the DEV
|
|
5447
|
+
// worktree (the QA worktree sits on the base branch) and paste into QA's pane.
|
|
5448
|
+
async handleReadFileRequest(taskId, qaAgentId, req) {
|
|
5449
|
+
const task = await this.taskStore.get(taskId);
|
|
5450
|
+
if (!task)
|
|
5451
|
+
return;
|
|
5452
|
+
const dev = this.getAgentConfig(task.agentId);
|
|
5453
|
+
if (!dev)
|
|
5454
|
+
return;
|
|
5455
|
+
await this.refreshWorktreeCacheFor(task.agentId);
|
|
5456
|
+
let body;
|
|
5457
|
+
try {
|
|
5458
|
+
const text = await this.getReviewTransport().readFileRange(dev, req.file, req.startLine, req.endLine);
|
|
5459
|
+
body = `=== baxian read-file ${req.file}:${req.startLine}-${req.endLine} ===\n${text}\n=== end read-file ===`;
|
|
5460
|
+
}
|
|
5461
|
+
catch (err) {
|
|
5462
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
5463
|
+
body = `=== baxian read-file ${req.file}:${req.startLine}-${req.endLine} REFUSED: ${reason} ===`;
|
|
5464
|
+
}
|
|
5465
|
+
// The read ran async — QA may have submitted its verdict and been released
|
|
5466
|
+
// or rebound meanwhile. Never paste old-task content into a new task's pane.
|
|
5467
|
+
const qaState = await this.agentStore.get(qaAgentId);
|
|
5468
|
+
if (qaState?.taskId !== taskId) {
|
|
5469
|
+
console.warn(`[AgentManager] read-file response dropped: qa=${qaAgentId} no longer bound to ${taskId} (got ${qaState?.taskId})`);
|
|
5470
|
+
return;
|
|
5471
|
+
}
|
|
5472
|
+
try {
|
|
5473
|
+
await this.injectTextToAgent(qaAgentId, body);
|
|
5474
|
+
}
|
|
5475
|
+
catch (err) {
|
|
5476
|
+
console.warn(`[AgentManager] read-file injection to ${qaAgentId} failed:`, err);
|
|
5477
|
+
}
|
|
5478
|
+
}
|
|
5479
|
+
// Plain text paste + submit into a live agent pane (no skills, no ack protocol).
|
|
5480
|
+
async injectTextToAgent(agentId, text) {
|
|
5481
|
+
const cfg = this.getAgentConfig(agentId);
|
|
5482
|
+
if (!cfg)
|
|
5483
|
+
throw new Error(`injectTextToAgent: unknown agent ${agentId}`);
|
|
5484
|
+
const state = await this.agentStore.get(agentId);
|
|
5485
|
+
const paneId = state?.paneId;
|
|
5486
|
+
if (!paneId)
|
|
5487
|
+
throw new Error(`injectTextToAgent: agent ${agentId} has no live pane`);
|
|
5488
|
+
const tmux = new TmuxManager(this.createRunnerFor(cfg));
|
|
5489
|
+
await tmux.injectPrompt(paneId, text, agentId);
|
|
5490
|
+
await tmux.sendEnter(paneId);
|
|
5491
|
+
}
|
|
5492
|
+
// Human gate confirm (spec §10): executes the configured completion for
|
|
5493
|
+
// ready (server mode) / merge-ready (github mode) tasks.
|
|
5494
|
+
async confirmHumanGate(taskId) {
|
|
5495
|
+
// Claim under the task lock: a Cancel racing this read can no longer flip the
|
|
5496
|
+
// task to cancelled (and retire its artifacts) while we proceed on a stale
|
|
5497
|
+
// gate snapshot — cancelTask checks the in-flight flag inside the same lock.
|
|
5498
|
+
const task = await this.claimCompleteGate(taskId, ['ready', 'merge-ready']);
|
|
5499
|
+
try {
|
|
5500
|
+
const project = this.getProjectConfig(task.projectId);
|
|
5501
|
+
const mergeAuto = project?.merge === 'auto';
|
|
5502
|
+
// Snapshot from verdict time — a hot config flip between publish and
|
|
5503
|
+
// confirm must not reroute an already-published artifact (PR #288).
|
|
5504
|
+
const afterDone = this.resolveAfterDone(task);
|
|
5505
|
+
if (task.status === 'merge-ready') {
|
|
5506
|
+
if (mergeAuto && task.prNumber) {
|
|
5507
|
+
// Guard on the post-approve head persisted at the merge-ready transition.
|
|
5508
|
+
if (!task.latestHeadSha) {
|
|
5509
|
+
throw new ApiError(409, `Task ${taskId} has no approved head recorded; cannot safely merge`);
|
|
5510
|
+
}
|
|
5511
|
+
await this.executeConfirmMerge(task, () => this.mergePr(taskId, {
|
|
5512
|
+
matchHeadSha: task.latestHeadSha,
|
|
5513
|
+
}));
|
|
5514
|
+
await this.eventBus.emit({
|
|
5515
|
+
id: '',
|
|
5516
|
+
type: 'pr.merged',
|
|
5517
|
+
timestamp: new Date().toISOString(),
|
|
5518
|
+
projectId: task.projectId,
|
|
5519
|
+
agentId: task.agentId,
|
|
5520
|
+
taskId: task.id,
|
|
5521
|
+
data: { prNumber: task.prNumber, ...(task.prUrl ? { prUrl: task.prUrl } : {}) },
|
|
5522
|
+
});
|
|
5523
|
+
return (await this.taskStore.get(taskId));
|
|
5524
|
+
}
|
|
5525
|
+
return this.finishTaskAsDone(taskId);
|
|
5526
|
+
}
|
|
5527
|
+
// status === 'ready' (server mode)
|
|
5528
|
+
if (afterDone === 'pr' && mergeAuto && task.prNumber) {
|
|
5529
|
+
// Reviewed-head guard is mandatory here — publish fail-closes on capture,
|
|
5530
|
+
// so a missing sha means tampered/legacy state, not a soft fallback.
|
|
5531
|
+
if (!task.latestHeadSha) {
|
|
5532
|
+
throw new ApiError(409, `Task ${taskId} has no reviewed head recorded; cannot safely merge`);
|
|
5533
|
+
}
|
|
5534
|
+
await this.executeConfirmMerge(task, () => this.mergePr(taskId, {
|
|
5535
|
+
matchHeadSha: task.latestHeadSha,
|
|
5536
|
+
}));
|
|
5537
|
+
// pr.merged's fromStatus now includes 'ready' — let the handler own the
|
|
5538
|
+
// merged transition + full cleanup chain (branch delete, /compact, release).
|
|
5539
|
+
await this.eventBus.emit({
|
|
5540
|
+
id: '',
|
|
5541
|
+
type: 'pr.merged',
|
|
5542
|
+
timestamp: new Date().toISOString(),
|
|
5543
|
+
projectId: task.projectId,
|
|
5544
|
+
agentId: task.agentId,
|
|
5545
|
+
taskId: task.id,
|
|
5546
|
+
data: { prNumber: task.prNumber, ...(task.prUrl ? { prUrl: task.prUrl } : {}) },
|
|
5547
|
+
});
|
|
5548
|
+
return (await this.taskStore.get(taskId));
|
|
5549
|
+
}
|
|
5550
|
+
if (afterDone === 'branch' && mergeAuto && task.branch) {
|
|
5551
|
+
await this.executeConfirmMerge(task, () => this.ffMergeBranch(task));
|
|
5552
|
+
const merged = await this.transitionTaskStatus(taskId, 'merged', { fromStatus: ['ready'] });
|
|
5553
|
+
if (merged)
|
|
5554
|
+
await this.releaseTaskAgents(taskId);
|
|
5555
|
+
return (await this.taskStore.get(taskId));
|
|
5556
|
+
}
|
|
5557
|
+
return this.finishTaskAsDone(taskId);
|
|
5558
|
+
}
|
|
5559
|
+
finally {
|
|
5560
|
+
this.markCompleteInFlight.delete(taskId);
|
|
5561
|
+
}
|
|
5562
|
+
}
|
|
5563
|
+
// Atomic gate claim: re-read + status check + markCompleteInFlight.add under
|
|
5564
|
+
// the task lock, so confirm and cancel serialize on the same snapshot.
|
|
5565
|
+
async claimCompleteGate(taskId, statuses) {
|
|
5566
|
+
return this.withTaskLock(async () => {
|
|
5567
|
+
const fresh = await this.taskStore.get(taskId);
|
|
5568
|
+
if (!fresh)
|
|
5569
|
+
throw new ApiError(404, `Task ${taskId} not found`);
|
|
5570
|
+
if (!statuses.includes(fresh.status)) {
|
|
5571
|
+
throw new ApiError(409, `Task ${taskId} is not awaiting confirmation (status=${fresh.status})`);
|
|
5572
|
+
}
|
|
5573
|
+
if (this.markCompleteInFlight.has(taskId)) {
|
|
5574
|
+
throw new ApiError(409, `Task ${taskId} is already being completed`);
|
|
5575
|
+
}
|
|
5576
|
+
this.markCompleteInFlight.add(taskId);
|
|
5577
|
+
return fresh;
|
|
5578
|
+
});
|
|
5579
|
+
}
|
|
5580
|
+
// Merge failures keep the gate: transient gh/network errors retry via another
|
|
5581
|
+
// Confirm, a stale head resolves via Cancel or an external decision — terminal
|
|
5582
|
+
// 'failed' would orphan the published PR/branch outside the task flow (PR #288).
|
|
5583
|
+
async executeConfirmMerge(task, merge) {
|
|
5584
|
+
try {
|
|
5585
|
+
await merge();
|
|
5586
|
+
}
|
|
5587
|
+
catch (err) {
|
|
5588
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5589
|
+
await this.safeEmit({
|
|
5590
|
+
id: '',
|
|
5591
|
+
type: 'human.intervention',
|
|
5592
|
+
timestamp: new Date().toISOString(),
|
|
5593
|
+
projectId: task.projectId,
|
|
5594
|
+
taskId: task.id,
|
|
5595
|
+
data: {
|
|
5596
|
+
phase: 'confirm-merge-failed',
|
|
5597
|
+
gate: task.status,
|
|
5598
|
+
error: message,
|
|
5599
|
+
note: 'Task stays at the gate: Confirm again to retry, or Cancel to retire the published artifact.',
|
|
5600
|
+
},
|
|
5601
|
+
});
|
|
5602
|
+
throw new ApiError(409, `Merge failed for task ${task.id}: ${message}`);
|
|
5603
|
+
}
|
|
5604
|
+
}
|
|
5605
|
+
async finishTaskAsDone(taskId) {
|
|
5606
|
+
const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['ready', 'merge-ready'] });
|
|
5607
|
+
if (!done)
|
|
5608
|
+
throw new ApiError(409, `Task ${taskId} changed status during confirm; aborted`);
|
|
5609
|
+
await this.releaseTaskAgents(taskId);
|
|
5610
|
+
return (await this.taskStore.get(taskId));
|
|
5611
|
+
}
|
|
5612
|
+
// Terminal-state resource release shared by done/merged(branch)/failed confirm
|
|
5613
|
+
// paths: stop the watcher, release dev+qa (worktree removal rides releaseAgentForTask).
|
|
5614
|
+
async releaseTaskAgents(taskId) {
|
|
5615
|
+
this.phaseSignalWatcher?.stop(taskId);
|
|
5616
|
+
const task = await this.taskStore.get(taskId);
|
|
5617
|
+
if (!task)
|
|
5618
|
+
return;
|
|
5619
|
+
for (const id of [task.agentId, task.qaAgentId]) {
|
|
5620
|
+
if (!id)
|
|
5621
|
+
continue;
|
|
5622
|
+
const state = await this.agentStore.get(id);
|
|
5623
|
+
if (!state || state.taskId !== taskId)
|
|
5624
|
+
continue;
|
|
5625
|
+
await this.releaseAgentForTask(id, taskId, 'idle', { allowAwaitingHuman: true })
|
|
5626
|
+
.catch(err => {
|
|
5627
|
+
console.warn(`[AgentManager] confirm release ${id} failed:`, err);
|
|
5628
|
+
});
|
|
5629
|
+
}
|
|
5630
|
+
}
|
|
5631
|
+
// afterDone:'branch' + merge:'auto' — fast-forward the remote default branch
|
|
5632
|
+
// to the reviewed branch ref-to-ref (`git push origin origin/bx/X:main`).
|
|
5633
|
+
// Never touches the repo working tree, and a plain push is ff-only by default:
|
|
5634
|
+
// a non-ff base is rejected by the remote and a human must rebase/decide (spec §6).
|
|
5635
|
+
repoMergeQueue = new Map();
|
|
5636
|
+
async ffMergeBranch(task) {
|
|
5637
|
+
const dev = this.getAgentConfig(task.agentId);
|
|
5638
|
+
if (!dev)
|
|
5639
|
+
throw new Error(`ffMergeBranch: no dev agent for task ${task.id}`);
|
|
5640
|
+
const state = await this.agentStore.get(task.agentId);
|
|
5641
|
+
const repoPath = state?.repoPath;
|
|
5642
|
+
if (!repoPath)
|
|
5643
|
+
throw new Error(`ffMergeBranch: no repoPath for agent ${task.agentId}`);
|
|
5644
|
+
const branch = task.branch ?? BRANCH_PREFIX + task.id;
|
|
5645
|
+
const runner = this.createRunnerFor(dev);
|
|
5646
|
+
const prev = this.repoMergeQueue.get(task.projectId) ?? Promise.resolve();
|
|
5647
|
+
const run = prev.then(async () => {
|
|
5648
|
+
const cd = `cd ${shellQuote(repoPath)} && `;
|
|
5649
|
+
const db = await runner.exec(`${cd}git symbolic-ref --short refs/remotes/origin/HEAD`);
|
|
5650
|
+
const defaultBranch = db.stdout.trim().replace(/^origin\//, '');
|
|
5651
|
+
if (db.exitCode !== 0 || defaultBranch === '') {
|
|
5652
|
+
// A silent 'main' fallback would push the reviewed branch onto the wrong
|
|
5653
|
+
// ref for repos whose default branch differs (PR #288).
|
|
5654
|
+
throw new Error(`ffMergeBranch: cannot resolve default branch: ${db.stderr.trim() || 'empty origin/HEAD'}`);
|
|
5655
|
+
}
|
|
5656
|
+
const fetch = await runner.exec(`${cd}git fetch origin`);
|
|
5657
|
+
if (fetch.exitCode !== 0) {
|
|
5658
|
+
throw new Error(`ffMergeBranch [git fetch] failed: ${fetch.stderr.trim()}`);
|
|
5659
|
+
}
|
|
5660
|
+
// Reviewed-head guard (branch path): refuse if origin/<branch> moved after
|
|
5661
|
+
// the gate — symmetric with the pr path's --match-head-commit (PR #288).
|
|
5662
|
+
if (task.latestHeadSha) {
|
|
5663
|
+
const remoteHead = await runner.exec(`${cd}git rev-parse ${shellQuote(`origin/${branch}`)}`);
|
|
5664
|
+
if (remoteHead.exitCode !== 0 || remoteHead.stdout.trim() !== task.latestHeadSha) {
|
|
5665
|
+
throw new Error(`ffMergeBranch: origin/${branch} head ${remoteHead.stdout.trim() || '<unresolved>'} ` +
|
|
5666
|
+
`!= reviewed head ${task.latestHeadSha}; refusing to merge un-reviewed commits`);
|
|
5667
|
+
}
|
|
5668
|
+
}
|
|
5669
|
+
else {
|
|
5670
|
+
throw new Error(`ffMergeBranch: no reviewed head recorded for task ${task.id}; cannot safely merge`);
|
|
5671
|
+
}
|
|
5672
|
+
const push = await runner.exec(`${cd}git push origin ${shellQuote(`origin/${branch}`)}:${shellQuote(defaultBranch)}`);
|
|
5673
|
+
if (push.exitCode !== 0) {
|
|
5674
|
+
throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
|
|
5675
|
+
}
|
|
5676
|
+
// The merge has landed; branch deletion is cleanup — a transient failure
|
|
5677
|
+
// here must not flip an already-merged task to failed (PR #288).
|
|
5678
|
+
const del = await runner.exec(`${cd}git push origin --delete ${shellQuote(branch)}`);
|
|
5679
|
+
if (del.exitCode !== 0) {
|
|
5680
|
+
console.warn(`[AgentManager] ffMergeBranch: post-merge branch delete failed for ${branch}: ${del.stderr.trim() || del.stdout.trim()}`);
|
|
5681
|
+
}
|
|
5682
|
+
});
|
|
5683
|
+
this.repoMergeQueue.set(task.projectId, run.catch(() => undefined));
|
|
5684
|
+
await run;
|
|
5685
|
+
}
|
|
4653
5686
|
}
|
|
4654
5687
|
function buildAgentIndex(config) {
|
|
4655
5688
|
const index = new Map();
|