baxian 1.0.3 → 1.2.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 +75 -12
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +1121 -320
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/phase-signal-watcher.d.ts +7 -1
- package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
- package/dist/agent/phase-signal-watcher.js +37 -11
- package/dist/agent/phase-signal-watcher.js.map +1 -1
- package/dist/agent/phase-signal.d.ts +29 -11
- package/dist/agent/phase-signal.d.ts.map +1 -1
- package/dist/agent/phase-signal.js +38 -8
- package/dist/agent/phase-signal.js.map +1 -1
- package/dist/agent/prompt.d.ts +15 -2
- package/dist/agent/prompt.d.ts.map +1 -1
- package/dist/agent/prompt.js +250 -52
- package/dist/agent/prompt.js.map +1 -1
- package/dist/agent/repo-store.d.ts +0 -1
- package/dist/agent/repo-store.d.ts.map +1 -1
- package/dist/agent/repo-store.js +0 -25
- package/dist/agent/repo-store.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/worktree.d.ts +2 -0
- package/dist/agent/worktree.d.ts.map +1 -1
- package/dist/agent/worktree.js +26 -0
- package/dist/agent/worktree.js.map +1 -1
- package/dist/api/agents.d.ts.map +1 -1
- package/dist/api/agents.js +4 -0
- package/dist/api/agents.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/cli.d.ts.map +1 -1
- package/dist/cli.js +8 -1
- package/dist/cli.js.map +1 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +3 -0
- 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 +33 -451
- 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 +835 -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 -8
- 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 +34 -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/terminal/attach.d.ts.map +1 -1
- package/dist/terminal/attach.js +8 -2
- package/dist/terminal/attach.js.map +1 -1
- package/dist/web/assets/index-OtgjyQI1.js +4 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-ByNjLidI.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 {
|
|
@@ -73,7 +74,7 @@ function agentRuntimeKindFor(agent) {
|
|
|
73
74
|
const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
|
|
74
75
|
const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
|
|
75
76
|
// Dev-facing deliverable phases that weave the task's uploaded image paths into the prompt (task-055).
|
|
76
|
-
const IMAGE_DISPATCH_PHASES = new Set(['develop', 'code', 'fix', '
|
|
77
|
+
const IMAGE_DISPATCH_PHASES = new Set(['develop', 'code', 'fix', 'server-feedback']);
|
|
77
78
|
export function canDispatchWithBinding(binding) {
|
|
78
79
|
return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
|
|
79
80
|
}
|
|
@@ -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
|
|
@@ -129,6 +132,7 @@ export class AgentManager {
|
|
|
129
132
|
runtimeMenuPollIntervalMs = 10_000;
|
|
130
133
|
compactIdleWaitMs = 5 * 60_000;
|
|
131
134
|
compactIdlePollMs = 2_000;
|
|
135
|
+
manualCompactWaitMs = 5_000;
|
|
132
136
|
postMergeFetchTimeoutMs = 60_000;
|
|
133
137
|
postMergeBranchTimeoutMs = 10_000;
|
|
134
138
|
// taskIds with in-flight manual review — second concurrent POST gets 409.
|
|
@@ -140,6 +144,7 @@ export class AgentManager {
|
|
|
140
144
|
// agentIds with in-flight DELETE — 第二个 DELETE 撞 awaiting_human stale-lock takeover 路径会
|
|
141
145
|
// 把第一个 DELETE 持有的占位也当 stale 接管,导致并发 cleanupRemovedAgentRuntime。
|
|
142
146
|
deletionInFlight = new Set();
|
|
147
|
+
compactInFlight = new Set();
|
|
143
148
|
constructor(deps) {
|
|
144
149
|
this.config = deps.config;
|
|
145
150
|
this.agentStore = deps.agentStore;
|
|
@@ -160,6 +165,7 @@ export class AgentManager {
|
|
|
160
165
|
resolveAgent: (id) => this.getAgentConfig(id),
|
|
161
166
|
})
|
|
162
167
|
: undefined);
|
|
168
|
+
this.reviewStore = deps.reviewStore;
|
|
163
169
|
this.dispatchAckTimeoutMs = deps.dispatchAckTimeoutMs ?? DEFAULT_DISPATCH_ACK_TIMEOUT_MS;
|
|
164
170
|
this.dispatchSettleTimeoutMs = deps.dispatchSettleTimeoutMs ?? DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS;
|
|
165
171
|
this.agentIndex = buildAgentIndex(deps.config);
|
|
@@ -176,6 +182,36 @@ export class AgentManager {
|
|
|
176
182
|
this.taskMutationQueue = next.catch(() => undefined);
|
|
177
183
|
return next;
|
|
178
184
|
}
|
|
185
|
+
getReviewStore() {
|
|
186
|
+
return this.reviewStore;
|
|
187
|
+
}
|
|
188
|
+
// Snapshot-aware afterDone read: an EXPLICIT null snapshot must win over hot
|
|
189
|
+
// config — `??` would swallow it and reroute an already-decided task (PR #288).
|
|
190
|
+
resolveAfterDone(task) {
|
|
191
|
+
if (task.afterDone !== undefined)
|
|
192
|
+
return task.afterDone;
|
|
193
|
+
return this.config.review.afterDone ?? null;
|
|
194
|
+
}
|
|
195
|
+
getReviewTransport() {
|
|
196
|
+
this.reviewTransportInstance ??= new ReviewTransport({
|
|
197
|
+
createRunnerFor: (agent) => this.createRunnerFor(agent),
|
|
198
|
+
resolveWorktree: (agentId) => this.bindingWorktreeCache.get(agentId),
|
|
199
|
+
});
|
|
200
|
+
return this.reviewTransportInstance;
|
|
201
|
+
}
|
|
202
|
+
// ReviewTransport resolves worktrees synchronously; agentStore reads are async.
|
|
203
|
+
// The cache is refreshed by callers (server handlers) before transport use via
|
|
204
|
+
// refreshWorktreeCacheFor — a stale entry only costs one refresh round-trip.
|
|
205
|
+
bindingWorktreeCache = new Map();
|
|
206
|
+
async refreshWorktreeCacheFor(agentId) {
|
|
207
|
+
const state = await this.agentStore.get(agentId);
|
|
208
|
+
if (state?.worktreePath) {
|
|
209
|
+
this.bindingWorktreeCache.set(agentId, state.worktreePath);
|
|
210
|
+
return state.worktreePath;
|
|
211
|
+
}
|
|
212
|
+
this.bindingWorktreeCache.delete(agentId);
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
179
215
|
async safeEmit(event) {
|
|
180
216
|
try {
|
|
181
217
|
await this.eventBus.emit(event);
|
|
@@ -940,7 +976,10 @@ export class AgentManager {
|
|
|
940
976
|
throw new Error(`Unknown agent: ${agentId}`);
|
|
941
977
|
const state = await this.agentStore.get(agentId);
|
|
942
978
|
const sameTaskLocked = state?.taskId === taskId && (await this.lockManager.isLocked(agentId));
|
|
943
|
-
const reentryPhases = new Set([
|
|
979
|
+
const reentryPhases = new Set([
|
|
980
|
+
'fix', 'post-approve', 'code',
|
|
981
|
+
'server-feedback', 'server-after-done',
|
|
982
|
+
]);
|
|
944
983
|
const sameTaskReentry = state?.taskId === taskId &&
|
|
945
984
|
!state.creationToken &&
|
|
946
985
|
state.status !== 'awaiting_human' &&
|
|
@@ -1178,6 +1217,28 @@ export class AgentManager {
|
|
|
1178
1217
|
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
1218
|
return { resumed: false, releasedBinding: false };
|
|
1180
1219
|
}
|
|
1220
|
+
// code-dispatch-failed: the code-phase prompt never reached the pane (spec
|
|
1221
|
+
// approval already transitioned the task). Resume = clear the hold AND
|
|
1222
|
+
// redispatch the code prompt (outside this lock) — without the redispatch
|
|
1223
|
+
// the task would stay in_progress with nothing running (PR #288).
|
|
1224
|
+
if (state.awaitingPhase === 'code-dispatch-failed'
|
|
1225
|
+
&& boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)
|
|
1226
|
+
&& state.taskId) {
|
|
1227
|
+
const now2 = new Date().toISOString();
|
|
1228
|
+
await this.agentStore.update(agentId, (existing) => {
|
|
1229
|
+
if (!existing)
|
|
1230
|
+
return AGENT_STORE_NOOP;
|
|
1231
|
+
return {
|
|
1232
|
+
...existing,
|
|
1233
|
+
status: 'ok',
|
|
1234
|
+
awaitingPhase: undefined,
|
|
1235
|
+
awaitingReason: undefined,
|
|
1236
|
+
awaitingSince: undefined,
|
|
1237
|
+
updatedAt: now2,
|
|
1238
|
+
};
|
|
1239
|
+
});
|
|
1240
|
+
return { resumed: true, releasedBinding: false, redispatchCodeTaskId: state.taskId };
|
|
1241
|
+
}
|
|
1181
1242
|
// signal-arm-failed: the prompt was already dispatched but its pane-signal watcher never
|
|
1182
1243
|
// armed. Resume here would only flip status→ok WITHOUT rebuilding the watcher (Resume has no
|
|
1183
1244
|
// re-arm path), so the prompt's signal would still have no consumer — silent deadlock again.
|
|
@@ -1243,7 +1304,20 @@ export class AgentManager {
|
|
|
1243
1304
|
});
|
|
1244
1305
|
return { resumed: true, releasedBinding: shouldReleaseBinding };
|
|
1245
1306
|
});
|
|
1246
|
-
|
|
1307
|
+
// Outside the task lock: continueSession takes it internally.
|
|
1308
|
+
if (result.redispatchCodeTaskId) {
|
|
1309
|
+
try {
|
|
1310
|
+
const resumed = await this.continueSession(result.redispatchCodeTaskId, agentId, 'code');
|
|
1311
|
+
if (!resumed) {
|
|
1312
|
+
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);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
catch (err) {
|
|
1316
|
+
console.error(`[AgentManager] resumeAgent code redispatch failed for ${agentId}:`, err);
|
|
1317
|
+
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);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
return { resumed: result.resumed, releasedBinding: result.releasedBinding };
|
|
1247
1321
|
}
|
|
1248
1322
|
async interruptPaneAndWaitReady(state, cfg) {
|
|
1249
1323
|
const runner = this.createRunnerFor(cfg);
|
|
@@ -1337,6 +1411,11 @@ export class AgentManager {
|
|
|
1337
1411
|
const out = [];
|
|
1338
1412
|
for (const t of tasks) {
|
|
1339
1413
|
const bound = t.agentId === agentId || t.qaAgentId === agentId;
|
|
1414
|
+
// Human gates are decision states, not running work: an absent agent
|
|
1415
|
+
// session must not terminally fail a task whose published PR/branch
|
|
1416
|
+
// would then be orphaned — Confirm/Cancel remain the only exits (PR #288).
|
|
1417
|
+
if (t.status === 'ready' || t.status === 'merge-ready')
|
|
1418
|
+
continue;
|
|
1340
1419
|
if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
|
|
1341
1420
|
t.status = 'failed';
|
|
1342
1421
|
t.updatedAt = new Date().toISOString();
|
|
@@ -1667,6 +1746,7 @@ export class AgentManager {
|
|
|
1667
1746
|
expectedKinds: 'pr-merge-ready',
|
|
1668
1747
|
token: completion.token,
|
|
1669
1748
|
skipSnapshot,
|
|
1749
|
+
recovered: true,
|
|
1670
1750
|
});
|
|
1671
1751
|
}
|
|
1672
1752
|
catch (err) {
|
|
@@ -1674,8 +1754,8 @@ export class AgentManager {
|
|
|
1674
1754
|
}
|
|
1675
1755
|
}
|
|
1676
1756
|
}
|
|
1677
|
-
//
|
|
1678
|
-
// 只对 spec verdict / spec-fixed emit intervention — spec-
|
|
1757
|
+
// snapshot 扫描按协议族决定:server 协议(含全模式 spec 阶段)恢复时必扫,github code 阶段仅 review/fixing 扫。
|
|
1758
|
+
// 只对 spec verdict / spec-fixed emit intervention — spec-done 在 develop
|
|
1679
1759
|
// prompt 里是 optional, 报警会让所有 in_progress task 噪音化。
|
|
1680
1760
|
// expectedKinds 必须覆盖 dispatch 时实际 set up 的 kind 集,否则真信号无法匹配。
|
|
1681
1761
|
async setupRecoveredSpecSignals() {
|
|
@@ -1690,20 +1770,26 @@ export class AgentManager {
|
|
|
1690
1770
|
continue;
|
|
1691
1771
|
const { expectedKinds, agentId } = mapped;
|
|
1692
1772
|
// Only spec verdict / spec-fixed / PR verdict warrant an intervention —
|
|
1693
|
-
// optional kinds (spec-
|
|
1773
|
+
// optional kinds (spec-done, pr-created in develop) would spam every
|
|
1694
1774
|
// in_progress task on restart.
|
|
1695
|
-
const interventionKindLabel = task.phase === 'spec' && task.status === 'review' ? 'spec-
|
|
1775
|
+
const interventionKindLabel = task.phase === 'spec' && task.status === 'review' ? 'spec-reviewed'
|
|
1696
1776
|
: task.phase === 'spec' && task.status === 'fixing' ? 'spec-fixed'
|
|
1697
1777
|
: task.phase !== 'spec' && task.status === 'review' ? 'pr-approved|pr-changes-requested'
|
|
1698
1778
|
: task.phase !== 'spec' && task.status === 'fixing' ? 'pr-fixed'
|
|
1699
1779
|
: undefined;
|
|
1700
|
-
//
|
|
1701
|
-
//
|
|
1702
|
-
//
|
|
1703
|
-
// fixing)
|
|
1704
|
-
//
|
|
1705
|
-
//
|
|
1706
|
-
|
|
1780
|
+
// spec 阶段恒为 server 协议(无 poller 兜底);code 阶段才按 reviewMode 区分。
|
|
1781
|
+
// Scan pane snapshot on recover for signals the agent emitted before the
|
|
1782
|
+
// server consumed them (lost on restart; agent won't re-emit).
|
|
1783
|
+
// github code states (review/fixing): replay-safe handlers — token + status
|
|
1784
|
+
// gates reject duplicates; PR verdict & pr-fixed covered.
|
|
1785
|
+
// github pre-spec (phase undefined, in_progress): spec-done has only the pane
|
|
1786
|
+
// channel (pr-created has a poller backstop, scanning it is idempotent).
|
|
1787
|
+
// server protocol incl. all-mode spec phase: no poller backstop, pane is the
|
|
1788
|
+
// only signal channel; handlers equally replay-safe via same gates.
|
|
1789
|
+
const isServerProtocol = task.reviewMode === 'server' || task.phase === 'spec';
|
|
1790
|
+
const scanSnapshotOnRecover = isServerProtocol
|
|
1791
|
+
|| (task.phase === undefined && task.status === 'in_progress')
|
|
1792
|
+
|| (task.phase !== 'spec' && (task.status === 'review' || task.status === 'fixing'));
|
|
1707
1793
|
try {
|
|
1708
1794
|
await this.phaseSignalWatcher.start({
|
|
1709
1795
|
taskId: task.id,
|
|
@@ -1712,6 +1798,10 @@ export class AgentManager {
|
|
|
1712
1798
|
expectedKinds,
|
|
1713
1799
|
token: task.signalToken,
|
|
1714
1800
|
skipSnapshot: !scanSnapshotOnRecover,
|
|
1801
|
+
recovered: true,
|
|
1802
|
+
...(isServerProtocol && task.status === 'review'
|
|
1803
|
+
? { onReadFile: (req) => { void this.handleReadFileRequest(task.id, agentId, req); } }
|
|
1804
|
+
: {}),
|
|
1715
1805
|
});
|
|
1716
1806
|
if (interventionKindLabel) {
|
|
1717
1807
|
await this.safeEmit({
|
|
@@ -1987,6 +2077,7 @@ export class AgentManager {
|
|
|
1987
2077
|
reviewRound: 0,
|
|
1988
2078
|
status: 'pending',
|
|
1989
2079
|
branch: BRANCH_PREFIX + taskId,
|
|
2080
|
+
reviewMode: this.config.review.mode ?? 'github',
|
|
1990
2081
|
createdAt: now,
|
|
1991
2082
|
updatedAt: now,
|
|
1992
2083
|
...(imageFilenames ? { images: imageFilenames } : {}),
|
|
@@ -2015,6 +2106,7 @@ export class AgentManager {
|
|
|
2015
2106
|
reviewRound: 0,
|
|
2016
2107
|
status: 'pending',
|
|
2017
2108
|
branch: BRANCH_PREFIX + taskId,
|
|
2109
|
+
reviewMode: this.config.review.mode ?? 'github',
|
|
2018
2110
|
createdAt: now,
|
|
2019
2111
|
updatedAt: now,
|
|
2020
2112
|
...(qa ? { qaAgentId: qa.id } : {}),
|
|
@@ -2047,6 +2139,7 @@ export class AgentManager {
|
|
|
2047
2139
|
reviewRound: 0,
|
|
2048
2140
|
status: 'pending',
|
|
2049
2141
|
branch: BRANCH_PREFIX + taskId,
|
|
2142
|
+
reviewMode: this.config.review.mode ?? 'github',
|
|
2050
2143
|
createdAt: now,
|
|
2051
2144
|
updatedAt: now,
|
|
2052
2145
|
...(qa ? { qaAgentId: qa.id } : {}),
|
|
@@ -2078,6 +2171,7 @@ export class AgentManager {
|
|
|
2078
2171
|
reviewRound: 0,
|
|
2079
2172
|
status: 'in_progress',
|
|
2080
2173
|
branch: BRANCH_PREFIX + taskId,
|
|
2174
|
+
reviewMode: this.config.review.mode ?? 'github',
|
|
2081
2175
|
createdAt: now,
|
|
2082
2176
|
updatedAt: now,
|
|
2083
2177
|
...(imageFilenames ? { images: imageFilenames } : {}),
|
|
@@ -2161,13 +2255,16 @@ export class AgentManager {
|
|
|
2161
2255
|
return null;
|
|
2162
2256
|
}
|
|
2163
2257
|
// 后台路径吞掉 reject(void start.catch):arm 抛异常时也要显式 hold agent,否则会留下一个没有
|
|
2164
|
-
// spec-
|
|
2258
|
+
// spec-done/pr-created 消费者的常驻任务(同步 caller 仍由上游收到异常)。
|
|
2259
|
+
// Kinds derive from the task's frozen reviewMode — a hot mode flip during the
|
|
2260
|
+
// startSession window must not desync the armed kinds from the sent prompt.
|
|
2261
|
+
const initialKinds = this.devInitialSignalKinds(fresh.reviewMode);
|
|
2165
2262
|
try {
|
|
2166
|
-
await this.armPostDispatchSignalOrHold(taskId, agentId,
|
|
2263
|
+
await this.armPostDispatchSignalOrHold(taskId, agentId, initialKinds, signalToken);
|
|
2167
2264
|
}
|
|
2168
2265
|
catch (armErr) {
|
|
2169
2266
|
console.error(`[AgentManager] createAndStartTask arm failed for task=${taskId}:`, armErr);
|
|
2170
|
-
await this.holdAgentForUnarmedSignal(taskId, agentId,
|
|
2267
|
+
await this.holdAgentForUnarmedSignal(taskId, agentId, initialKinds)
|
|
2171
2268
|
.catch((holdErr) => {
|
|
2172
2269
|
console.error(`[AgentManager] createAndStartTask hold-after-arm-failure failed for task=${taskId}:`, holdErr);
|
|
2173
2270
|
});
|
|
@@ -2206,12 +2303,83 @@ export class AgentManager {
|
|
|
2206
2303
|
const paneId = state?.paneId;
|
|
2207
2304
|
if (!paneId)
|
|
2208
2305
|
throw new ApiError(409, `Agent ${agentId} has no live session`);
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2306
|
+
// 写文件→粘贴全程持有 pane 互斥:写文件可能卡住,恢复后的粘贴若落进
|
|
2307
|
+
// compact 的 C-c→/compact 窗口会把路径拼进指令提交。
|
|
2308
|
+
if (!this.tryAcquireCompactGuard(agentId)) {
|
|
2309
|
+
throw new ApiError(409, `Agent ${agentId} compact or upload in progress; retry shortly`);
|
|
2310
|
+
}
|
|
2311
|
+
try {
|
|
2312
|
+
const runner = this.createRunnerFor(cfg);
|
|
2313
|
+
const path = agentHostPath(agentId, imageFilename(ext));
|
|
2314
|
+
await writeImageToHost(runner, path, bytes);
|
|
2315
|
+
const tmux = new TmuxManager(runner);
|
|
2316
|
+
await tmux.injectPrompt(paneId, `${path} `, agentId);
|
|
2317
|
+
return { path };
|
|
2318
|
+
}
|
|
2319
|
+
finally {
|
|
2320
|
+
this.compactInFlight.delete(agentId);
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
// busy 时注入会把指令拼进正在运行的回合,宁可 409。
|
|
2324
|
+
async compactAgent(agentId) {
|
|
2325
|
+
const cfg = this.getAgentConfig(agentId);
|
|
2326
|
+
if (!cfg)
|
|
2327
|
+
throw new ApiError(404, `Unknown agent: ${agentId}`);
|
|
2328
|
+
if (!this.tryAcquireCompactGuard(agentId)) {
|
|
2329
|
+
throw new ApiError(409, `Agent ${agentId} compact or upload already in progress`);
|
|
2330
|
+
}
|
|
2331
|
+
let guardHandedOff = false;
|
|
2332
|
+
try {
|
|
2333
|
+
const state = await this.agentStore.get(agentId);
|
|
2334
|
+
const paneId = state?.paneId;
|
|
2335
|
+
if (!paneId)
|
|
2336
|
+
throw new ApiError(409, `Agent ${agentId} has no live session`);
|
|
2337
|
+
const taskIdAtStart = state.taskId;
|
|
2338
|
+
const updatedAtAtStart = state.updatedAt;
|
|
2339
|
+
// updatedAt 拦同任务 phase 派发(paneId/taskId 均不变,派发 paste 前必写 state);
|
|
2340
|
+
// 快照变了决不注入——C-c 会打断刚注入的 prompt。
|
|
2341
|
+
const assertSessionUnchanged = async () => {
|
|
2342
|
+
const now = await this.agentStore.get(agentId);
|
|
2343
|
+
if (!now
|
|
2344
|
+
|| now.paneId !== paneId
|
|
2345
|
+
|| now.taskId !== taskIdAtStart
|
|
2346
|
+
|| now.updatedAt !== updatedAtAtStart) {
|
|
2347
|
+
throw new ApiError(409, `Agent ${agentId} session changed while waiting; compact aborted`);
|
|
2348
|
+
}
|
|
2349
|
+
};
|
|
2350
|
+
const tmux = new TmuxManager(this.createRunnerFor(cfg));
|
|
2351
|
+
const waitReady = async () => {
|
|
2352
|
+
try {
|
|
2353
|
+
await this.waitForReplPromptReady(tmux, paneId, cfg.runtime, this.manualCompactWaitMs);
|
|
2354
|
+
}
|
|
2355
|
+
catch (err) {
|
|
2356
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
2357
|
+
throw new ApiError(409, `Agent ${agentId} runtime is not at an idle REPL prompt: ${detail}`);
|
|
2358
|
+
}
|
|
2359
|
+
};
|
|
2360
|
+
await waitReady();
|
|
2361
|
+
await assertSessionUnchanged();
|
|
2362
|
+
// 残留草稿会被「草稿/compact」连带提交;C-c 清线后再发。
|
|
2363
|
+
await tmux.sendKeysToPane(paneId, 'C-c');
|
|
2364
|
+
await waitReady();
|
|
2365
|
+
await assertSessionUnchanged();
|
|
2366
|
+
await tmux.sendKeysLiteral(paneId, '/compact');
|
|
2367
|
+
await tmux.sendEnter(paneId);
|
|
2368
|
+
// 压缩仍在运行:guard 交给后台尾随等待,runtime 回到 idle 才释放,
|
|
2369
|
+
// 否则紧随的上传/派发会粘进压缩中的 pane。
|
|
2370
|
+
guardHandedOff = true;
|
|
2371
|
+
void this.waitForReplPromptReady(tmux, paneId, cfg.runtime, this.compactIdleWaitMs)
|
|
2372
|
+
.catch(err => {
|
|
2373
|
+
console.warn(`[AgentManager] compactAgent(${agentId}) post-/compact idle wait failed:`, err);
|
|
2374
|
+
})
|
|
2375
|
+
.finally(() => {
|
|
2376
|
+
this.compactInFlight.delete(agentId);
|
|
2377
|
+
});
|
|
2378
|
+
}
|
|
2379
|
+
finally {
|
|
2380
|
+
if (!guardHandedOff)
|
|
2381
|
+
this.compactInFlight.delete(agentId);
|
|
2382
|
+
}
|
|
2215
2383
|
}
|
|
2216
2384
|
async persistTaskImages(taskId, images) {
|
|
2217
2385
|
const dir = join(this.imageStagingRoot, taskId);
|
|
@@ -2263,12 +2431,10 @@ export class AgentManager {
|
|
|
2263
2431
|
}
|
|
2264
2432
|
return hostPaths;
|
|
2265
2433
|
}
|
|
2266
|
-
// Dev-facing deliverable phases
|
|
2267
|
-
//
|
|
2268
|
-
//
|
|
2269
|
-
//
|
|
2270
|
-
// QA phases (review/recheck/spec-review) and post-approve (feedback-processing; if it needs
|
|
2271
|
-
// changes baxian routes to `fix`, which carries the image) (task-055).
|
|
2434
|
+
// Dev-facing deliverable phases all carry the task's uploaded images, since the image is a
|
|
2435
|
+
// persistent task input the dev needs while producing or revising the spec/code — and a fresh
|
|
2436
|
+
// runtime (restart/recovery) loses the original context. IMAGE_DISPATCH_PHASES 中的后续阶段经
|
|
2437
|
+
// continueSession 触发此方法;QA 阶段和 post-approve 不传图(task-055)。
|
|
2272
2438
|
async imagePathsForDispatch(runner, task, phase) {
|
|
2273
2439
|
if (!IMAGE_DISPATCH_PHASES.has(phase))
|
|
2274
2440
|
return [];
|
|
@@ -2365,7 +2531,7 @@ export class AgentManager {
|
|
|
2365
2531
|
console.error(`[AgentManager] dispatchPendingTask startSession hard error for task=${claimed.id}:`, err);
|
|
2366
2532
|
}
|
|
2367
2533
|
if (started) {
|
|
2368
|
-
await this.armPostDispatchSignalOrHold(claimed.id, claimed.agentId,
|
|
2534
|
+
await this.armPostDispatchSignalOrHold(claimed.id, claimed.agentId, this.devInitialSignalKinds(claimed.reviewMode), signalToken);
|
|
2369
2535
|
const refreshed = await this.taskStore.get(claimed.id);
|
|
2370
2536
|
return { task: refreshed ?? claimed };
|
|
2371
2537
|
}
|
|
@@ -2454,9 +2620,12 @@ export class AgentManager {
|
|
|
2454
2620
|
const baseRef = agent.workdir
|
|
2455
2621
|
? undefined
|
|
2456
2622
|
: await this.resolveAutoBaseRef(runner, workdir);
|
|
2457
|
-
const
|
|
2458
|
-
|
|
2459
|
-
|
|
2623
|
+
const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
|
|
2624
|
+
const worktreePath = isServerQaPhase
|
|
2625
|
+
? await worktree.createDetachedAtBase(workdir, taskId)
|
|
2626
|
+
: phase === 'review' || phase === 'recheck'
|
|
2627
|
+
? await worktree.createDetached(workdir, taskId, task.branch)
|
|
2628
|
+
: await worktree.create(workdir, taskId, baseRef);
|
|
2460
2629
|
// Persist worktreePath now so a crash before set-running leaves a recoverable trail.
|
|
2461
2630
|
await this.agentStore.update(agentId, (stateNow) => {
|
|
2462
2631
|
if (!stateNow || stateNow.taskId !== taskId)
|
|
@@ -2480,6 +2649,8 @@ export class AgentManager {
|
|
|
2480
2649
|
const reuseInjectedSkills = ensure.freshRuntime
|
|
2481
2650
|
? null
|
|
2482
2651
|
: reuseSkillsIfContextValid(beforeInjectAgent, taskId, paneId);
|
|
2652
|
+
// develop prompt 按 QA 有无裁剪 spec 路线(qaAgentId 快照优先,与 review 派发同一解析)。
|
|
2653
|
+
const hasQaPartner = !!(task.qaAgentId ?? this.findQaPartner(agentId)?.id);
|
|
2483
2654
|
let prompt;
|
|
2484
2655
|
try {
|
|
2485
2656
|
const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
|
|
@@ -2489,11 +2660,17 @@ export class AgentManager {
|
|
|
2489
2660
|
agent,
|
|
2490
2661
|
worktreePath,
|
|
2491
2662
|
skillRegistry: this.skillRegistry,
|
|
2663
|
+
hasQaPartner,
|
|
2492
2664
|
...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
|
|
2493
2665
|
...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
|
|
2494
|
-
...(opts.specFindings ? { specFindings: opts.specFindings } : {}),
|
|
2495
2666
|
...(reuseInjectedSkills ? { excludeSkills: reuseInjectedSkills } : {}),
|
|
2496
2667
|
...(imagePaths.length ? { imagePaths } : {}),
|
|
2668
|
+
...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
|
|
2669
|
+
...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
|
|
2670
|
+
...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
|
|
2671
|
+
...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
|
|
2672
|
+
...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
|
|
2673
|
+
...(opts.contentTruncated ? { contentTruncated: true } : {}),
|
|
2497
2674
|
});
|
|
2498
2675
|
}
|
|
2499
2676
|
catch (err) {
|
|
@@ -2658,7 +2835,27 @@ export class AgentManager {
|
|
|
2658
2835
|
throw err;
|
|
2659
2836
|
}
|
|
2660
2837
|
}
|
|
2661
|
-
|
|
2838
|
+
// 注入方必须持有 pane 互斥:compact 侧的快照校验关不死「校验→按键」
|
|
2839
|
+
// 之间的 async 边界,竞态只能在这里关死。
|
|
2840
|
+
async injectAndAwaitAck(tmux, paneId, prompt, agentId, runtime) {
|
|
2841
|
+
const before = await this.agentStore.get(agentId);
|
|
2842
|
+
await this.acquireCompactGuard(agentId);
|
|
2843
|
+
try {
|
|
2844
|
+
// guard 等待期间任务可能被 Cancel(释放绑定)或会话重建;过期派发
|
|
2845
|
+
// 决不落 pane。无快照(direct 调用)时跳过——真实派发必有绑定。
|
|
2846
|
+
if (before) {
|
|
2847
|
+
const now = await this.agentStore.get(agentId);
|
|
2848
|
+
if (!now || now.paneId !== before.paneId || now.taskId !== before.taskId) {
|
|
2849
|
+
throw new Error(`dispatch aborted: agent ${agentId} binding changed while waiting for pane mutex`);
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
return await this.injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, runtime);
|
|
2853
|
+
}
|
|
2854
|
+
finally {
|
|
2855
|
+
this.compactInFlight.delete(agentId);
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
async injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, _runtime) {
|
|
2662
2859
|
await tmux.injectPrompt(paneId, prompt, agentId);
|
|
2663
2860
|
let baseline;
|
|
2664
2861
|
try {
|
|
@@ -2844,9 +3041,15 @@ export class AgentManager {
|
|
|
2844
3041
|
? { postApproveRedispatchCount: opts.postApproveRedispatchCount }
|
|
2845
3042
|
: {}),
|
|
2846
3043
|
...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
|
|
2847
|
-
...(opts.specFindings ? { specFindings: opts.specFindings } : {}),
|
|
2848
3044
|
...(reuseInjectedSkills ? { excludeSkills: reuseInjectedSkills } : {}),
|
|
2849
3045
|
...(imagePaths.length ? { imagePaths } : {}),
|
|
3046
|
+
...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
|
|
3047
|
+
...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
|
|
3048
|
+
...(opts.serverBatch ? { serverBatch: opts.serverBatch } : {}),
|
|
3049
|
+
...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
|
|
3050
|
+
...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
|
|
3051
|
+
...(opts.serverAfterDone ? { serverAfterDone: opts.serverAfterDone } : {}),
|
|
3052
|
+
...(opts.contentTruncated ? { contentTruncated: opts.contentTruncated } : {}),
|
|
2850
3053
|
});
|
|
2851
3054
|
}
|
|
2852
3055
|
catch (err) {
|
|
@@ -3235,6 +3438,13 @@ export class AgentManager {
|
|
|
3235
3438
|
async cancelTask(taskId) {
|
|
3236
3439
|
let devToRelease;
|
|
3237
3440
|
let qaToRelease;
|
|
3441
|
+
// Server-mode ready gate may have already published remote artifacts
|
|
3442
|
+
// (pushed branch / open PR). Capture before flipping to cancelled so the
|
|
3443
|
+
// post-lock cleanup can retire them instead of orphaning (PR #288).
|
|
3444
|
+
// mayBeInFlight: approved+marker means the publish prompt may STILL be
|
|
3445
|
+
// running — retirement must wait for the dev interrupt or the in-flight
|
|
3446
|
+
// push/pr-create would recreate the artifacts right after cleanup.
|
|
3447
|
+
let publishedCleanup;
|
|
3238
3448
|
this.phaseSignalWatcher?.stop(taskId);
|
|
3239
3449
|
const result = await this.withTaskLock(async () => {
|
|
3240
3450
|
const task = await this.taskStore.get(taskId);
|
|
@@ -3252,6 +3462,37 @@ export class AgentManager {
|
|
|
3252
3462
|
devToRelease = task.agentId;
|
|
3253
3463
|
if (task.qaAgentId)
|
|
3254
3464
|
qaToRelease = task.qaAgentId;
|
|
3465
|
+
// approved + publishDispatchedAt = the publish prompt reached the pane, so
|
|
3466
|
+
// remote artifacts may already exist even though code-ready never landed
|
|
3467
|
+
// (dispatch crash, or the reviewed-head mismatch gate refused ready —
|
|
3468
|
+
// whose documented exit is exactly this Cancel).
|
|
3469
|
+
// Truthy (not !== undefined): sanitizeTask passes hand-edited nulls through.
|
|
3470
|
+
const publishedAtGate = task.status === 'ready'
|
|
3471
|
+
|| (task.status === 'approved' && !!task.publishDispatchedAt);
|
|
3472
|
+
if (task.reviewMode === 'server' && publishedAtGate && task.agentId) {
|
|
3473
|
+
const afterDone = this.resolveAfterDone(task);
|
|
3474
|
+
if (afterDone !== null && task.branch) {
|
|
3475
|
+
publishedCleanup = {
|
|
3476
|
+
afterDone,
|
|
3477
|
+
branch: task.branch,
|
|
3478
|
+
...(task.prNumber !== undefined ? { prNumber: task.prNumber } : {}),
|
|
3479
|
+
devAgentId: task.agentId,
|
|
3480
|
+
// ready = code-ready consumed, publish finished; approved = no
|
|
3481
|
+
// completion signal yet, the publish may still be running.
|
|
3482
|
+
mayBeInFlight: task.status === 'approved',
|
|
3483
|
+
};
|
|
3484
|
+
}
|
|
3485
|
+
}
|
|
3486
|
+
else if (task.status === 'merge-ready' && task.prNumber !== undefined && task.branch && task.agentId) {
|
|
3487
|
+
// GitHub-mode gate cancel leaves the same orphaned PR/branch (PR #288).
|
|
3488
|
+
publishedCleanup = {
|
|
3489
|
+
afterDone: 'pr',
|
|
3490
|
+
branch: task.branch,
|
|
3491
|
+
prNumber: task.prNumber,
|
|
3492
|
+
devAgentId: task.agentId,
|
|
3493
|
+
mayBeInFlight: false,
|
|
3494
|
+
};
|
|
3495
|
+
}
|
|
3255
3496
|
const now = new Date().toISOString();
|
|
3256
3497
|
task.status = 'cancelled';
|
|
3257
3498
|
task.updatedAt = now;
|
|
@@ -3266,7 +3507,13 @@ export class AgentManager {
|
|
|
3266
3507
|
});
|
|
3267
3508
|
return task;
|
|
3268
3509
|
});
|
|
3269
|
-
// 唯一允许打断 agent 会话的入口(用户主动 Cancel)。
|
|
3510
|
+
// 唯一允许打断 agent 会话的入口(用户主动 Cancel)。Interrupt BEFORE remote
|
|
3511
|
+
// retirement: an in-flight publish prompt would re-push the branch / re-open
|
|
3512
|
+
// the PR right after cleanup, and a cancelled task gets no second pass.
|
|
3513
|
+
// Only a successful interrupt PROVES the pane stopped — skipped paths
|
|
3514
|
+
// (config hot-removed: the pane outlives the config; state gone; rebound)
|
|
3515
|
+
// leave an in-flight publish possible.
|
|
3516
|
+
let devStopConfirmed = false;
|
|
3270
3517
|
for (const id of [devToRelease, qaToRelease]) {
|
|
3271
3518
|
if (!id)
|
|
3272
3519
|
continue;
|
|
@@ -3285,6 +3532,8 @@ export class AgentManager {
|
|
|
3285
3532
|
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
3533
|
continue;
|
|
3287
3534
|
}
|
|
3535
|
+
if (id === publishedCleanup?.devAgentId)
|
|
3536
|
+
devStopConfirmed = true;
|
|
3288
3537
|
try {
|
|
3289
3538
|
// allowAwaitingHuman: cancelTask 是显式回收入口,agent 之前可能因 ack_unknown 等被标 Held,
|
|
3290
3539
|
// 用户主动 Cancel 应允许跨过 awaiting_human gate 清理 binding;release 默认 gate 是为了
|
|
@@ -3295,6 +3544,65 @@ export class AgentManager {
|
|
|
3295
3544
|
console.error(`[AgentManager] cancelTask releaseAgentForTask(${id}) failed:`, err);
|
|
3296
3545
|
}
|
|
3297
3546
|
}
|
|
3547
|
+
// Best-effort remote retirement for a cancelled published gate: close the PR
|
|
3548
|
+
// and delete the pushed branch so they don't outlive the task. Failures only
|
|
3549
|
+
// warn + intervene — cancel must not be blocked by remote faults.
|
|
3550
|
+
if (publishedCleanup) {
|
|
3551
|
+
if (publishedCleanup.mayBeInFlight && !devStopConfirmed) {
|
|
3552
|
+
// No proof the publish prompt stopped; cleaning now would race its
|
|
3553
|
+
// push/pr-create. Leave the artifacts to the operator.
|
|
3554
|
+
await this.safeEmit({
|
|
3555
|
+
id: '',
|
|
3556
|
+
type: 'human.intervention',
|
|
3557
|
+
timestamp: new Date().toISOString(),
|
|
3558
|
+
projectId: result.projectId,
|
|
3559
|
+
taskId,
|
|
3560
|
+
data: {
|
|
3561
|
+
phase: 'cancel-published-artifact-cleanup-skipped',
|
|
3562
|
+
afterDone: publishedCleanup.afterDone,
|
|
3563
|
+
branch: publishedCleanup.branch,
|
|
3564
|
+
...(publishedCleanup.prNumber !== undefined ? { prNumber: publishedCleanup.prNumber } : {}),
|
|
3565
|
+
reason: 'dev pane stop unconfirmed; the publish prompt may still be running and would recreate the remote artifacts',
|
|
3566
|
+
},
|
|
3567
|
+
});
|
|
3568
|
+
return result;
|
|
3569
|
+
}
|
|
3570
|
+
const project = this.getProjectConfig(result.projectId);
|
|
3571
|
+
try {
|
|
3572
|
+
if (publishedCleanup.afterDone === 'pr' && publishedCleanup.prNumber !== undefined && project) {
|
|
3573
|
+
const close = await this.platformRunner.exec(`gh pr close ${publishedCleanup.prNumber} --repo ${shellQuote(project.repo)} ` +
|
|
3574
|
+
`--comment ${shellQuote('Task cancelled in baxian; closing the published PR.')} --delete-branch`);
|
|
3575
|
+
if (close.exitCode !== 0)
|
|
3576
|
+
throw new Error(close.stderr.trim() || close.stdout.trim());
|
|
3577
|
+
}
|
|
3578
|
+
else {
|
|
3579
|
+
const dev = this.getAgentConfig(publishedCleanup.devAgentId);
|
|
3580
|
+
const state = await this.agentStore.get(publishedCleanup.devAgentId);
|
|
3581
|
+
if (dev && state?.repoPath) {
|
|
3582
|
+
const del = await this.createRunnerFor(dev).exec(`cd ${shellQuote(state.repoPath)} && git push origin --delete ${shellQuote(publishedCleanup.branch)}`);
|
|
3583
|
+
if (del.exitCode !== 0)
|
|
3584
|
+
throw new Error(del.stderr.trim() || del.stdout.trim());
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3588
|
+
catch (err) {
|
|
3589
|
+
console.warn(`[AgentManager] cancelTask remote retirement failed for ${taskId}:`, err);
|
|
3590
|
+
await this.safeEmit({
|
|
3591
|
+
id: '',
|
|
3592
|
+
type: 'human.intervention',
|
|
3593
|
+
timestamp: new Date().toISOString(),
|
|
3594
|
+
projectId: result.projectId,
|
|
3595
|
+
taskId,
|
|
3596
|
+
data: {
|
|
3597
|
+
phase: 'cancel-published-artifact-cleanup-failed',
|
|
3598
|
+
afterDone: publishedCleanup.afterDone,
|
|
3599
|
+
branch: publishedCleanup.branch,
|
|
3600
|
+
...(publishedCleanup.prNumber !== undefined ? { prNumber: publishedCleanup.prNumber } : {}),
|
|
3601
|
+
error: err instanceof Error ? err.message : String(err),
|
|
3602
|
+
},
|
|
3603
|
+
});
|
|
3604
|
+
}
|
|
3605
|
+
}
|
|
3298
3606
|
return result;
|
|
3299
3607
|
}
|
|
3300
3608
|
// task-044 重构:create-time 不再校验 awaiting_human / creating / bound —— 这些都是"忙",
|
|
@@ -3342,6 +3650,11 @@ export class AgentManager {
|
|
|
3342
3650
|
const task = await this.taskStore.get(taskId);
|
|
3343
3651
|
if (!task)
|
|
3344
3652
|
throw new ApiError(404, `Task ${taskId} not found`);
|
|
3653
|
+
// Server-mode tasks review via the exchange protocol; routing one into the
|
|
3654
|
+
// legacy GitHub review flow would cross-contaminate the state machines.
|
|
3655
|
+
if (task.reviewMode === 'server') {
|
|
3656
|
+
throw new ApiError(409, `Task ${taskId} uses server review mode; legacy Call review is not applicable`);
|
|
3657
|
+
}
|
|
3345
3658
|
// spec-phase max_rounds escapes via Retry/Cancel only. Call review dispatches the
|
|
3346
3659
|
// CODE-review protocol, but review.submitted early-returns for spec phase — so a direct
|
|
3347
3660
|
// /tasks/:id/review here would transition the task to review + bind QA, yet its verdict
|
|
@@ -3554,6 +3867,55 @@ export class AgentManager {
|
|
|
3554
3867
|
if (task.phase === 'spec') {
|
|
3555
3868
|
throw new ApiError(409, `Continue one round is only supported for code-phase tasks`);
|
|
3556
3869
|
}
|
|
3870
|
+
// Server-mode continue: grant one round past the cap, then re-run the server
|
|
3871
|
+
// fix protocol from the stored findings — no PR exists at this point (PR #288).
|
|
3872
|
+
if (task.reviewMode === 'server') {
|
|
3873
|
+
if (!task.agentId) {
|
|
3874
|
+
throw new ApiError(400, `Task ${taskId} has no dev agent; cannot continue`);
|
|
3875
|
+
}
|
|
3876
|
+
const stored = await this.reviewStore?.getRound(taskId, 'code', Math.max(task.reviewRound, 1));
|
|
3877
|
+
if (!stored?.findings) {
|
|
3878
|
+
throw new ApiError(409, `Task ${taskId} has no stored findings to continue from; cancel instead`);
|
|
3879
|
+
}
|
|
3880
|
+
// Re-check + grant under the task lock: the entry checks above ran lock-free,
|
|
3881
|
+
// so a concurrent mark-complete may have claimed the gate since (the
|
|
3882
|
+
// claimCompleteGate comment promises Continue re-checks under the same lock).
|
|
3883
|
+
await this.withTaskLock(async () => {
|
|
3884
|
+
if (this.markCompleteInFlight.has(taskId)) {
|
|
3885
|
+
throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`);
|
|
3886
|
+
}
|
|
3887
|
+
const fresh = await this.taskStore.get(taskId);
|
|
3888
|
+
if (!fresh || fresh.status !== 'max_rounds') {
|
|
3889
|
+
throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${fresh?.status ?? 'gone'})`);
|
|
3890
|
+
}
|
|
3891
|
+
fresh.maxRoundsContinues = (fresh.maxRoundsContinues ?? 0) + 1;
|
|
3892
|
+
fresh.updatedAt = new Date().toISOString();
|
|
3893
|
+
await this.taskStore.set(fresh);
|
|
3894
|
+
});
|
|
3895
|
+
let dispatched = null;
|
|
3896
|
+
try {
|
|
3897
|
+
dispatched = await this.dispatchServerFixToDev(taskId, JSON.stringify(stored.findings));
|
|
3898
|
+
}
|
|
3899
|
+
finally {
|
|
3900
|
+
// The grant is only spent when the fix prompt actually reached the dev.
|
|
3901
|
+
// Decrement (not restore-snapshot): a snapshot write-back would also
|
|
3902
|
+
// erase a concurrent Continue's grant.
|
|
3903
|
+
if (!dispatched) {
|
|
3904
|
+
await this.withTaskLock(async () => {
|
|
3905
|
+
const fresh = await this.taskStore.get(taskId);
|
|
3906
|
+
if (!fresh)
|
|
3907
|
+
return;
|
|
3908
|
+
fresh.maxRoundsContinues = Math.max(0, (fresh.maxRoundsContinues ?? 0) - 1);
|
|
3909
|
+
fresh.updatedAt = new Date().toISOString();
|
|
3910
|
+
await this.taskStore.set(fresh);
|
|
3911
|
+
}).catch(() => undefined);
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
if (!dispatched) {
|
|
3915
|
+
throw new ApiError(500, `Failed to dispatch server fix round for task ${taskId}`);
|
|
3916
|
+
}
|
|
3917
|
+
return dispatched;
|
|
3918
|
+
}
|
|
3557
3919
|
if (!task.prNumber || !task.branch) {
|
|
3558
3920
|
throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot continue`);
|
|
3559
3921
|
}
|
|
@@ -3667,7 +4029,7 @@ export class AgentManager {
|
|
|
3667
4029
|
}
|
|
3668
4030
|
// General case: rolled-back task may still want a watcher matching its
|
|
3669
4031
|
// current (restored) state — develop dispatch still waiting on
|
|
3670
|
-
// spec-
|
|
4032
|
+
// spec-done/pr-created, recheck still waiting on verdict, etc.
|
|
3671
4033
|
const restored = await this.taskStore.get(taskId);
|
|
3672
4034
|
if (!restored || !restored.signalToken)
|
|
3673
4035
|
return;
|
|
@@ -3690,9 +4052,19 @@ export class AgentManager {
|
|
|
3690
4052
|
// Single source of truth for "what watcher should this task have, given its
|
|
3691
4053
|
// current state". Used by both setupRecoveredSpecSignals (restart recovery)
|
|
3692
4054
|
// and rollbackDispatchReviewPhase1 (manual dispatch failure).
|
|
4055
|
+
// Dev's first prompt offers the spec-first or straight-to-code path; the arm
|
|
4056
|
+
// must accept both completion signals for the task's protocol family.
|
|
4057
|
+
devInitialSignalKinds(reviewMode) {
|
|
4058
|
+
const mode = reviewMode ?? this.config.review.mode ?? 'github';
|
|
4059
|
+
return mode === 'server'
|
|
4060
|
+
? ['spec-done', 'code-done']
|
|
4061
|
+
: ['spec-done', 'pr-created'];
|
|
4062
|
+
}
|
|
3693
4063
|
mapTaskStateToExpectedWatcher(task) {
|
|
4064
|
+
if (task.reviewMode === 'server')
|
|
4065
|
+
return this.mapServerTaskToExpectedWatcher(task);
|
|
3694
4066
|
if (task.phase === 'spec' && task.status === 'review' && task.qaAgentId) {
|
|
3695
|
-
return { expectedKinds: ['spec-
|
|
4067
|
+
return { expectedKinds: ['spec-reviewed'], agentId: task.qaAgentId };
|
|
3696
4068
|
}
|
|
3697
4069
|
if (task.phase === 'spec' && task.status === 'fixing' && task.agentId) {
|
|
3698
4070
|
return { expectedKinds: ['spec-fixed'], agentId: task.agentId };
|
|
@@ -3702,7 +4074,7 @@ export class AgentManager {
|
|
|
3702
4074
|
return { expectedKinds: ['pr-fixed'], agentId: task.agentId };
|
|
3703
4075
|
}
|
|
3704
4076
|
if (task.phase === undefined && task.status === 'in_progress' && task.agentId) {
|
|
3705
|
-
return { expectedKinds: ['spec-
|
|
4077
|
+
return { expectedKinds: ['spec-done', 'pr-created'], agentId: task.agentId };
|
|
3706
4078
|
}
|
|
3707
4079
|
if (task.phase === 'code' && task.status === 'in_progress' && task.agentId) {
|
|
3708
4080
|
return { expectedKinds: ['pr-created'], agentId: task.agentId };
|
|
@@ -3714,14 +4086,35 @@ export class AgentManager {
|
|
|
3714
4086
|
}
|
|
3715
4087
|
return undefined;
|
|
3716
4088
|
}
|
|
4089
|
+
// Recovery mapping for server-mode tasks: the watcher is the ONLY verdict
|
|
4090
|
+
// channel (no poller backstop), so every awaiting state must re-arm on restart.
|
|
4091
|
+
mapServerTaskToExpectedWatcher(task) {
|
|
4092
|
+
const isSpec = task.phase === 'spec';
|
|
4093
|
+
if (task.status === 'review' && task.qaAgentId) {
|
|
4094
|
+
return { expectedKinds: [isSpec ? 'spec-reviewed' : 'code-reviewed'], agentId: task.qaAgentId };
|
|
4095
|
+
}
|
|
4096
|
+
if (task.status === 'fixing' && task.agentId) {
|
|
4097
|
+
return { expectedKinds: [isSpec ? 'spec-fixed' : 'code-fixed'], agentId: task.agentId };
|
|
4098
|
+
}
|
|
4099
|
+
if (task.status === 'in_progress' && task.agentId) {
|
|
4100
|
+
if (task.phase === 'code')
|
|
4101
|
+
return { expectedKinds: ['code-done'], agentId: task.agentId };
|
|
4102
|
+
return { expectedKinds: ['spec-done', 'code-done'], agentId: task.agentId };
|
|
4103
|
+
}
|
|
4104
|
+
if (task.status === 'approved' && task.agentId) {
|
|
4105
|
+
return { expectedKinds: ['code-ready'], agentId: task.agentId };
|
|
4106
|
+
}
|
|
4107
|
+
return undefined;
|
|
4108
|
+
}
|
|
3717
4109
|
// Public re-establish helper for in-band recoveries that don't rotate the token
|
|
3718
4110
|
// (e.g. handler reject path: agent's next emit must still match current
|
|
3719
|
-
// task.signalToken, so rotating would strand it).
|
|
4111
|
+
// task.signalToken, so rotating would strand it). Returns whether a watcher
|
|
4112
|
+
// armed; callers that consumed a signal must hold on false or it has no consumer.
|
|
3720
4113
|
async setupPhaseSignal(taskId, agentId, expectedKinds, opts = {}) {
|
|
3721
4114
|
const task = await this.taskStore.get(taskId);
|
|
3722
4115
|
if (!task?.signalToken)
|
|
3723
|
-
return;
|
|
3724
|
-
|
|
4116
|
+
return false;
|
|
4117
|
+
return this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, task.signalToken, opts.skipSnapshot);
|
|
3725
4118
|
}
|
|
3726
4119
|
async emitManualReviewDevParkedQaFailedIntervention(agentId, expectedTaskId) {
|
|
3727
4120
|
if (!agentId)
|
|
@@ -3838,28 +4231,83 @@ export class AgentManager {
|
|
|
3838
4231
|
// cleanup chain (pr.merged handler → transition merged + post-merge worktree/branch
|
|
3839
4232
|
// cleanup + /compact + release). Same path the poller drives when it detects the merge.
|
|
3840
4233
|
async markTaskComplete(taskId) {
|
|
3841
|
-
const
|
|
3842
|
-
if (!
|
|
4234
|
+
const peek = await this.taskStore.get(taskId);
|
|
4235
|
+
if (!peek)
|
|
3843
4236
|
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);
|
|
4237
|
+
// Human gate (spec §10): ready / merge-ready confirm runs its own completion
|
|
4238
|
+
// matrix (with its own lock-claimed gate); the legacy max_rounds path below
|
|
4239
|
+
// is untouched.
|
|
4240
|
+
if (peek.status === 'ready' || peek.status === 'merge-ready') {
|
|
4241
|
+
return this.confirmHumanGate(taskId);
|
|
4242
|
+
}
|
|
4243
|
+
// Claim under the task lock — the whole merge window. markCompleteInFlight
|
|
4244
|
+
// blocks Cancel / Call review / Continue (all re-check it under the same
|
|
4245
|
+
// lock) so they can't act on the same snapshot and interleave with the
|
|
4246
|
+
// irreversible `gh pr merge` (or, server mode, the publish dispatch).
|
|
4247
|
+
const task = await this.claimCompleteGate(taskId, ['max_rounds', 'approved']);
|
|
3862
4248
|
try {
|
|
4249
|
+
// Server-mode publish retry: a failed afterDone dispatch leaves the task
|
|
4250
|
+
// 'approved' with dev released — mark-complete re-runs the publish (PR #288).
|
|
4251
|
+
const serverApprovedRetry = task.status === 'approved' && task.reviewMode === 'server';
|
|
4252
|
+
if (!serverApprovedRetry && task.status !== 'max_rounds') {
|
|
4253
|
+
throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
|
|
4254
|
+
}
|
|
4255
|
+
// spec-phase max_rounds escapes via Retry/Cancel only (the UI hides complete). Guard the
|
|
4256
|
+
// endpoint too so a direct API call / older client can't merge a spec cap through here.
|
|
4257
|
+
if (task.phase === 'spec') {
|
|
4258
|
+
throw new ApiError(409, `Mark complete is only supported for code-phase tasks`);
|
|
4259
|
+
}
|
|
4260
|
+
if (task.reviewMode !== 'server' && (!task.prNumber || !task.branch)) {
|
|
4261
|
+
throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot mark complete`);
|
|
4262
|
+
}
|
|
4263
|
+
if (serverApprovedRetry) {
|
|
4264
|
+
// A live code-ready watcher armed by a real dispatch means the publish
|
|
4265
|
+
// prompt IS running — a retry would inject a second prompt and rotate
|
|
4266
|
+
// the token under it. A RECOVERED watcher is weaker evidence, but
|
|
4267
|
+
// publishDispatchedAt persists delivery across restarts: set = the
|
|
4268
|
+
// prompt reached the pane before the restart (still in flight, 409);
|
|
4269
|
+
// cleared = the dispatch failed and this approved state is retryable —
|
|
4270
|
+
// stop the recovered watch and let the retry own the dispatch (PR #288).
|
|
4271
|
+
if (this.phaseSignalWatcher?.expectedKindsFor(taskId).has('code-ready')) {
|
|
4272
|
+
if (!this.phaseSignalWatcher.isRecovered(taskId) || task.publishDispatchedAt) {
|
|
4273
|
+
throw new ApiError(409, `Task ${taskId} publish is in flight; retry only after it fails`);
|
|
4274
|
+
}
|
|
4275
|
+
this.phaseSignalWatcher.stop(taskId);
|
|
4276
|
+
}
|
|
4277
|
+
else if (task.publishDispatchedAt) {
|
|
4278
|
+
throw new ApiError(409, `Task ${taskId} publish was delivered and is awaiting code-ready; ` +
|
|
4279
|
+
`retry only after it fails (if the publish is verifiably dead, Cancel the task)`);
|
|
4280
|
+
}
|
|
4281
|
+
// task.afterDone was snapshotted when the approve verdict routed it.
|
|
4282
|
+
const afterDone = this.resolveAfterDone(task);
|
|
4283
|
+
if (afterDone === null) {
|
|
4284
|
+
throw new ApiError(409, `Task ${taskId} is approved with no afterDone step; nothing to retry`);
|
|
4285
|
+
}
|
|
4286
|
+
await this.dispatchServerAfterDone(taskId, afterDone);
|
|
4287
|
+
return (await this.taskStore.get(taskId));
|
|
4288
|
+
}
|
|
4289
|
+
// Server-mode capped task, human accepts as-is: no PR exists yet — run the
|
|
4290
|
+
// afterDone flow (or finish directly) instead of the legacy PR merge (PR #288).
|
|
4291
|
+
// Inside the in-flight claim so a concurrent Continue can't act on the same
|
|
4292
|
+
// max_rounds snapshot and release dev mid-publish.
|
|
4293
|
+
if (task.reviewMode === 'server') {
|
|
4294
|
+
// Max_rounds never routed an approve verdict — snapshot afterDone NOW so
|
|
4295
|
+
// the eventual ready-confirm uses this decision, not future hot config.
|
|
4296
|
+
const afterDone = this.config.review.afterDone ?? null;
|
|
4297
|
+
await this.updateTask(taskId, { afterDone });
|
|
4298
|
+
if (afterDone === null) {
|
|
4299
|
+
const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['max_rounds'] });
|
|
4300
|
+
if (!done)
|
|
4301
|
+
throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
|
|
4302
|
+
await this.releaseTaskAgents(taskId);
|
|
4303
|
+
return (await this.taskStore.get(taskId));
|
|
4304
|
+
}
|
|
4305
|
+
const approved = await this.transitionTaskStatus(taskId, 'approved', { fromStatus: ['max_rounds'] });
|
|
4306
|
+
if (!approved)
|
|
4307
|
+
throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
|
|
4308
|
+
await this.dispatchServerAfterDone(taskId, afterDone);
|
|
4309
|
+
return (await this.taskStore.get(taskId));
|
|
4310
|
+
}
|
|
3863
4311
|
// Held-agent check AFTER claiming (the claim blocks a new continueDevRound from starting),
|
|
3864
4312
|
// and re-reading agent state here catches a continue that Held an agent in the window just
|
|
3865
4313
|
// before our claim. dispatchPostMergeCleanup early-returns on awaiting_human, so merging with
|
|
@@ -4060,6 +4508,28 @@ export class AgentManager {
|
|
|
4060
4508
|
}
|
|
4061
4509
|
}
|
|
4062
4510
|
async runPostMergeCompaction(tmux, paneId, agentId, originalTaskId, runtime, prompt) {
|
|
4511
|
+
// 等待获取(而非无条件 add):手动 compact 持锁时直接进入会并发注入,
|
|
4512
|
+
// 且 finally 会误删对方的 guard 放穿后续请求。
|
|
4513
|
+
await this.acquireCompactGuard(agentId);
|
|
4514
|
+
try {
|
|
4515
|
+
await this.runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime, prompt);
|
|
4516
|
+
}
|
|
4517
|
+
finally {
|
|
4518
|
+
this.compactInFlight.delete(agentId);
|
|
4519
|
+
}
|
|
4520
|
+
}
|
|
4521
|
+
async acquireCompactGuard(agentId) {
|
|
4522
|
+
while (!this.tryAcquireCompactGuard(agentId)) {
|
|
4523
|
+
await new Promise(r => setTimeout(r, this.compactIdlePollMs));
|
|
4524
|
+
}
|
|
4525
|
+
}
|
|
4526
|
+
tryAcquireCompactGuard(agentId) {
|
|
4527
|
+
if (this.compactInFlight.has(agentId))
|
|
4528
|
+
return false;
|
|
4529
|
+
this.compactInFlight.add(agentId);
|
|
4530
|
+
return true;
|
|
4531
|
+
}
|
|
4532
|
+
async runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime, prompt) {
|
|
4063
4533
|
const bindingStillOurs = async () => {
|
|
4064
4534
|
const s = await this.agentStore.get(agentId);
|
|
4065
4535
|
return !!s && s.taskId === originalTaskId && s.paneId === paneId;
|
|
@@ -4132,18 +4602,12 @@ export class AgentManager {
|
|
|
4132
4602
|
stopPhaseSignalWatcher(taskId) {
|
|
4133
4603
|
this.phaseSignalWatcher?.stop(taskId);
|
|
4134
4604
|
}
|
|
4135
|
-
// Backwards-compat alias for spec-only call sites (recovery, transitions
|
|
4136
|
-
// that already named the kind). New callers should use setupPhaseSignalWatcher
|
|
4137
|
-
// directly with the right expectedKinds.
|
|
4138
|
-
stopSpecSignalWatcher(taskId) {
|
|
4139
|
-
this.stopPhaseSignalWatcher(taskId);
|
|
4140
|
-
}
|
|
4141
4605
|
// Prompt build (via task.signalToken) and watcher must share the same token.
|
|
4142
4606
|
// Returns whether dispatch may safely proceed. False ONLY when a configured watcher failed
|
|
4143
4607
|
// to arm — the dangerous case where a same-identity verdict would have no consumer. When no
|
|
4144
4608
|
// watcher subsystem is configured at all (poller-only deployment) the poller is the verdict
|
|
4145
4609
|
// path, so this returns true and does not block. Best-effort callers ignore the result.
|
|
4146
|
-
async setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, skipSnapshot = false) {
|
|
4610
|
+
async setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, skipSnapshot = false, onReadFile) {
|
|
4147
4611
|
if (!this.phaseSignalWatcher)
|
|
4148
4612
|
return true;
|
|
4149
4613
|
const task = await this.taskStore.get(taskId);
|
|
@@ -4157,6 +4621,7 @@ export class AgentManager {
|
|
|
4157
4621
|
expectedKinds,
|
|
4158
4622
|
token,
|
|
4159
4623
|
skipSnapshot,
|
|
4624
|
+
...(onReadFile ? { onReadFile } : {}),
|
|
4160
4625
|
});
|
|
4161
4626
|
}
|
|
4162
4627
|
catch (err) {
|
|
@@ -4167,8 +4632,8 @@ export class AgentManager {
|
|
|
4167
4632
|
// Arm a watcher for a signal the just-dispatched prompt will emit, then hold the agent if it
|
|
4168
4633
|
// could not arm. Used by post-dispatch arms (develop/spec/code phases) whose pane only exists
|
|
4169
4634
|
// 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);
|
|
4635
|
+
async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false, onReadFile) {
|
|
4636
|
+
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, skipSnapshot, onReadFile);
|
|
4172
4637
|
if (!armed)
|
|
4173
4638
|
await this.holdAgentForUnarmedSignal(taskId, agentId, expectedKinds);
|
|
4174
4639
|
}
|
|
@@ -4220,56 +4685,125 @@ export class AgentManager {
|
|
|
4220
4685
|
if (mapped)
|
|
4221
4686
|
await this.setupPhaseSignal(taskId, mapped.agentId, mapped.expectedKinds);
|
|
4222
4687
|
}
|
|
4223
|
-
async
|
|
4688
|
+
async transitionToCodePhase(taskId) {
|
|
4224
4689
|
const task = await this.taskStore.get(taskId);
|
|
4225
4690
|
if (!task)
|
|
4226
4691
|
return null;
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4692
|
+
const devAgentId = task.agentId;
|
|
4693
|
+
if (!devAgentId)
|
|
4694
|
+
return null;
|
|
4695
|
+
// Rotate token for the code phase so dev's pr-created signal is fresh; old
|
|
4696
|
+
// spec token must not survive into a different expected-kind set.
|
|
4697
|
+
const newToken = createSignalToken();
|
|
4698
|
+
// Atomic transition + persist: 旧版先 transition 再 updateTask, 中间崩溃 task 卡在
|
|
4699
|
+
// (phase='spec', status='in_progress') — setupRecoveredSpecSignals 三个 case 都不匹配,
|
|
4700
|
+
// freshness gate 也拒所有 spec.* event, 任务 stranded 无 auto-recovery。
|
|
4701
|
+
const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing'] }, { phase: 'code', signalToken: newToken });
|
|
4702
|
+
if (!transition)
|
|
4703
|
+
return null;
|
|
4704
|
+
this.stopPhaseSignalWatcher(taskId);
|
|
4705
|
+
// Best-effort arm (NOT hold-on-failure): this runs before the code prompt is dispatched
|
|
4706
|
+
// (acquire + continueSession below), so holding here would block that reentry. And pr-created
|
|
4707
|
+
// is authoritatively detected by the GitHub poller (PR creation isn't same-identity-gated), so
|
|
4708
|
+
// a missed pane watcher only costs one poll cycle of latency, never a stuck task.
|
|
4709
|
+
// Server mode has NO poller backstop: an unarmed code-done watcher means the
|
|
4710
|
+
// dev's completion signal would have no consumer — fail closed and hold.
|
|
4711
|
+
const codeKind = task.reviewMode === 'server' ? 'code-done' : 'pr-created';
|
|
4712
|
+
const codeArmed = await this.setupPhaseSignalWatcher(taskId, devAgentId, codeKind, newToken);
|
|
4713
|
+
if (!codeArmed && task.reviewMode === 'server') {
|
|
4714
|
+
await this.holdAgentForUnarmedSignal(taskId, devAgentId, codeKind);
|
|
4715
|
+
return null;
|
|
4237
4716
|
}
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
return store.readFileFromBranch(workdir, task.branch, filePath);
|
|
4244
|
-
}
|
|
4245
|
-
async dispatchSpecReviewToQa(taskId) {
|
|
4246
|
-
// Phase 1 (lock): validate + decide qa + compute newToken/newRound (无 mutation, 无 park)。
|
|
4247
|
-
// 关键约束:task 不能在 startSession 之前被改 — startSession 内部调用
|
|
4248
|
-
// buildPromptInline,prompt 必须看到的是新 token 和新 round;这里只 *计算*,
|
|
4249
|
-
// 真正写回 task 放到 Phase 3。
|
|
4250
|
-
const claim = await this.withTaskLock(async () => {
|
|
4251
|
-
const task = await this.taskStore.get(taskId);
|
|
4252
|
-
if (!task)
|
|
4253
|
-
throw new Error(`dispatchSpecReviewToQa: task ${taskId} not found`);
|
|
4254
|
-
if (!task.branch)
|
|
4255
|
-
throw new Error(`dispatchSpecReviewToQa: task ${taskId} has no branch`);
|
|
4256
|
-
// Stale spec-created guard: 一旦 task 离开 pre-spec 阶段 (phase='code' 或其他
|
|
4257
|
-
// 非 'spec'/undefined 值),迟到的 spec-created signal 不应再 dispatch review。
|
|
4258
|
-
// 允许 phase==='spec' 是预留 dev 在 fix-complete 后再 emit spec-created 的扩展点。
|
|
4259
|
-
if (task.phase !== undefined && task.phase !== 'spec') {
|
|
4717
|
+
if (task.qaAgentId) {
|
|
4718
|
+
// release 失败留 stale qa binding → emit intervention 让其可见。
|
|
4719
|
+
const released = await this.releaseAgentForTask(task.qaAgentId, taskId, 'idle')
|
|
4720
|
+
.catch(() => false);
|
|
4721
|
+
if (!released) {
|
|
4260
4722
|
await this.safeEmit({
|
|
4261
4723
|
id: '',
|
|
4262
4724
|
type: 'human.intervention',
|
|
4263
4725
|
timestamp: new Date().toISOString(),
|
|
4264
4726
|
projectId: task.projectId,
|
|
4265
|
-
agentId: task.
|
|
4727
|
+
agentId: task.qaAgentId,
|
|
4266
4728
|
taskId,
|
|
4267
|
-
data: { phase: '
|
|
4729
|
+
data: { phase: 'code-phase-qa-release-failed', qaAgentId: task.qaAgentId },
|
|
4268
4730
|
});
|
|
4269
|
-
return null;
|
|
4270
4731
|
}
|
|
4271
|
-
|
|
4272
|
-
|
|
4732
|
+
}
|
|
4733
|
+
const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'code');
|
|
4734
|
+
if (!acquired) {
|
|
4735
|
+
// Task already shows phase='code' in_progress with the code-done watcher
|
|
4736
|
+
// armed, and server mode has no poller backstop — without a hold the dev
|
|
4737
|
+
// never receives the code prompt and the task dead-ends. The
|
|
4738
|
+
// code-dispatch-failed hold gives Resume a redispatch path.
|
|
4739
|
+
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);
|
|
4740
|
+
await this.safeEmit({
|
|
4741
|
+
id: '',
|
|
4742
|
+
type: 'human.intervention',
|
|
4743
|
+
timestamp: new Date().toISOString(),
|
|
4744
|
+
projectId: task.projectId,
|
|
4745
|
+
agentId: devAgentId,
|
|
4746
|
+
taskId,
|
|
4747
|
+
data: { phase: 'code-dev-acquire-failed', devAgentId },
|
|
4748
|
+
});
|
|
4749
|
+
return null;
|
|
4750
|
+
}
|
|
4751
|
+
let resumed = false;
|
|
4752
|
+
try {
|
|
4753
|
+
resumed = await this.continueSession(taskId, devAgentId, 'code');
|
|
4754
|
+
}
|
|
4755
|
+
catch (err) {
|
|
4756
|
+
// 同 dispatchServerReviewToQa/dispatchServerFixToDev:DispatchTerminalError 委托给 failTaskForDispatchError
|
|
4757
|
+
// (ack_unknown → markAwaitingHuman;其他 reason → release + task failed)。
|
|
4758
|
+
if (err instanceof DispatchTerminalError) {
|
|
4759
|
+
await this.failTaskForDispatchError(taskId, 'code', devAgentId, err);
|
|
4760
|
+
}
|
|
4761
|
+
else if (!(err instanceof EnsureSessionError && err.partial.handled)) {
|
|
4762
|
+
// Task already shows phase='code' in_progress but the prompt never landed
|
|
4763
|
+
// and there is no retry entry — hold explicitly instead of dead-ending.
|
|
4764
|
+
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);
|
|
4765
|
+
}
|
|
4766
|
+
console.error(`[AgentManager] transitionToCodePhase continueSession(dev=${devAgentId}) failed:`, err);
|
|
4767
|
+
throw err;
|
|
4768
|
+
}
|
|
4769
|
+
if (!resumed) {
|
|
4770
|
+
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);
|
|
4771
|
+
await this.safeEmit({
|
|
4772
|
+
id: '',
|
|
4773
|
+
type: 'human.intervention',
|
|
4774
|
+
timestamp: new Date().toISOString(),
|
|
4775
|
+
projectId: task.projectId,
|
|
4776
|
+
agentId: devAgentId,
|
|
4777
|
+
taskId,
|
|
4778
|
+
data: { phase: 'code-resume-failed', devAgentId },
|
|
4779
|
+
});
|
|
4780
|
+
return null;
|
|
4781
|
+
}
|
|
4782
|
+
return await this.taskStore.get(taskId);
|
|
4783
|
+
}
|
|
4784
|
+
// ── Server review mode (spec: docs/spec/server-review-mode.md) ──────────────
|
|
4785
|
+
async dispatchServerReviewToQa(taskId, opts) {
|
|
4786
|
+
const dispatchPhase = opts.phase === 'spec'
|
|
4787
|
+
? 'server-spec-review'
|
|
4788
|
+
: (opts.recheck ? 'server-recheck' : 'server-review');
|
|
4789
|
+
const expectedKind = opts.phase === 'spec' ? 'spec-reviewed' : 'code-reviewed';
|
|
4790
|
+
const claim = await this.withTaskLock(async () => {
|
|
4791
|
+
const task = await this.taskStore.get(taskId);
|
|
4792
|
+
if (!task)
|
|
4793
|
+
throw new Error(`dispatchServerReviewToQa: task ${taskId} not found`);
|
|
4794
|
+
// spec 阶段恒为 server 中转;code 阶段仍 server-only。
|
|
4795
|
+
if (task.reviewMode !== 'server' && opts.phase !== 'spec') {
|
|
4796
|
+
throw new Error(`dispatchServerReviewToQa: task ${taskId} is not in server review mode`);
|
|
4797
|
+
}
|
|
4798
|
+
const qaId = task.qaAgentId ?? this.findQaPartner(task.agentId)?.id;
|
|
4799
|
+
if (!qaId) {
|
|
4800
|
+
// Config validation rejects qa-less server pairs, but a hot-removed QA
|
|
4801
|
+
// can still land here — re-arm the consumed entry signal so the task
|
|
4802
|
+
// is recoverable once a QA is configured again.
|
|
4803
|
+
const entryKind = task.status === 'fixing'
|
|
4804
|
+
? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
|
|
4805
|
+
: (opts.phase === 'spec' ? 'spec-done' : 'code-done');
|
|
4806
|
+
await this.setupPhaseSignal(taskId, task.agentId, entryKind, { skipSnapshot: true });
|
|
4273
4807
|
await this.safeEmit({
|
|
4274
4808
|
id: '',
|
|
4275
4809
|
type: 'human.intervention',
|
|
@@ -4277,83 +4811,103 @@ export class AgentManager {
|
|
|
4277
4811
|
projectId: task.projectId,
|
|
4278
4812
|
agentId: task.agentId,
|
|
4279
4813
|
taskId,
|
|
4280
|
-
data: { phase: '
|
|
4814
|
+
data: { phase: 'server-review-no-qa-partner', devAgentId: task.agentId },
|
|
4281
4815
|
});
|
|
4282
4816
|
return null;
|
|
4283
4817
|
}
|
|
4284
|
-
|
|
4285
|
-
// spawn 失败 rollback 不能无差别回 in_progress;必须回到原 status 以保留 spec phase。
|
|
4286
|
-
// transitionTaskStatus 的 fromStatus 守门已限定为这三种之一; 其他 status 不会走到这里。
|
|
4287
|
-
const isReviewEntry = task.status === 'in_progress'
|
|
4288
|
-
|| task.status === 'fixing'
|
|
4289
|
-
|| task.status === 'pending';
|
|
4290
|
-
if (!isReviewEntry)
|
|
4291
|
-
return null;
|
|
4818
|
+
const roundField = opts.phase === 'spec' ? (task.specReviewRound ?? 0) : task.reviewRound;
|
|
4292
4819
|
return {
|
|
4293
|
-
qaId
|
|
4820
|
+
qaId,
|
|
4294
4821
|
devAgentId: task.agentId,
|
|
4295
4822
|
projectId: task.projectId,
|
|
4296
4823
|
newToken: createSignalToken(),
|
|
4297
|
-
newRound:
|
|
4824
|
+
newRound: opts.continuation ? Math.max(roundField, 1) : roundField + 1,
|
|
4298
4825
|
originalStatus: task.status,
|
|
4299
|
-
// 记录原 spec-created token — pre-spec entry rollback 时 restore,
|
|
4300
|
-
// 让 dev 后续 spec-created signal (with 原 token) 经 handler freshness gate 通过 → auto retry。
|
|
4301
4826
|
originalToken: task.signalToken,
|
|
4302
|
-
|
|
4303
|
-
|
|
4827
|
+
originalRound: roundField,
|
|
4828
|
+
originalBatchIndex: task.batchIndex,
|
|
4829
|
+
originalBatchTotal: task.batchTotal,
|
|
4830
|
+
originalPhase: task.phase,
|
|
4304
4831
|
};
|
|
4305
4832
|
});
|
|
4306
4833
|
if (!claim)
|
|
4307
4834
|
return null;
|
|
4308
|
-
const { qaId, devAgentId, projectId, newToken, newRound
|
|
4309
|
-
//
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
await this.
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
//
|
|
4325
|
-
//
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4835
|
+
const { qaId, devAgentId, projectId, newToken, newRound } = claim;
|
|
4836
|
+
// continueSession failure after the transition would otherwise strand the
|
|
4837
|
+
// task in 'review' with a fresh token nobody will ever signal (PR #288).
|
|
4838
|
+
const rollback = async () => {
|
|
4839
|
+
await this.transitionTaskStatus(taskId, claim.originalStatus, { fromStatus: ['review'] }, {
|
|
4840
|
+
signalToken: claim.originalToken,
|
|
4841
|
+
batchIndex: claim.originalBatchIndex,
|
|
4842
|
+
batchTotal: claim.originalBatchTotal,
|
|
4843
|
+
// spec transition 写入 phase:'spec';github 首轮失败若不还原,dev 直发
|
|
4844
|
+
// pr-created 会被 legacy freshness gate 拒(设计 §2)。
|
|
4845
|
+
phase: claim.originalPhase,
|
|
4846
|
+
...(opts.phase === 'spec'
|
|
4847
|
+
? { specReviewRound: claim.originalRound }
|
|
4848
|
+
: { reviewRound: claim.originalRound }),
|
|
4849
|
+
}).catch(() => undefined);
|
|
4850
|
+
};
|
|
4851
|
+
// The entry signal (code/spec-done|fixed) was already consumed by the
|
|
4852
|
+
// watcher; a pre-transition failure must re-arm it with the unrotated token
|
|
4853
|
+
// or the agent's re-emit after the operator fixes availability has no
|
|
4854
|
+
// consumer (PR #288).
|
|
4855
|
+
const rearmEntrySignal = async () => {
|
|
4856
|
+
const entryKind = claim.originalStatus === 'fixing'
|
|
4857
|
+
? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
|
|
4858
|
+
: (opts.phase === 'spec' ? 'spec-done' : 'code-done');
|
|
4859
|
+
await this.setupPhaseSignal(taskId, devAgentId, entryKind, { skipSnapshot: true });
|
|
4860
|
+
};
|
|
4861
|
+
if (!opts.continuation) {
|
|
4862
|
+
const acquired = await this.acquireAgentForTask(qaId, taskId, dispatchPhase);
|
|
4863
|
+
if (!acquired) {
|
|
4864
|
+
await rearmEntrySignal();
|
|
4331
4865
|
await this.safeEmit({
|
|
4332
4866
|
id: '',
|
|
4333
4867
|
type: 'human.intervention',
|
|
4334
4868
|
timestamp: new Date().toISOString(),
|
|
4335
4869
|
projectId,
|
|
4336
|
-
agentId:
|
|
4870
|
+
agentId: qaId,
|
|
4337
4871
|
taskId,
|
|
4338
|
-
data: { phase: '
|
|
4872
|
+
data: { phase: 'server-review-qa-acquire-failed', qaAgentId: qaId },
|
|
4339
4873
|
});
|
|
4340
4874
|
return null;
|
|
4341
4875
|
}
|
|
4876
|
+
if (devAgentId) {
|
|
4877
|
+
const devOk = await this.markAgentWaiting(devAgentId, taskId);
|
|
4878
|
+
if (!devOk) {
|
|
4879
|
+
await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
|
|
4880
|
+
await rearmEntrySignal();
|
|
4881
|
+
await this.safeEmit({
|
|
4882
|
+
id: '',
|
|
4883
|
+
type: 'human.intervention',
|
|
4884
|
+
timestamp: new Date().toISOString(),
|
|
4885
|
+
projectId,
|
|
4886
|
+
agentId: devAgentId,
|
|
4887
|
+
taskId,
|
|
4888
|
+
data: { phase: 'server-review-dev-park-failed', devAgentId },
|
|
4889
|
+
});
|
|
4890
|
+
return null;
|
|
4891
|
+
}
|
|
4892
|
+
}
|
|
4342
4893
|
}
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
const transition = await this.transitionTaskStatus(taskId, 'review', { fromStatus: ['in_progress', 'fixing', '
|
|
4347
|
-
specReviewRound: newRound,
|
|
4894
|
+
const roundPatch = opts.phase === 'spec'
|
|
4895
|
+
? { specReviewRound: newRound, phase: 'spec' }
|
|
4896
|
+
: { reviewRound: newRound };
|
|
4897
|
+
const transition = await this.transitionTaskStatus(taskId, 'review', { fromStatus: ['in_progress', 'fixing', 'review'] }, {
|
|
4348
4898
|
signalToken: newToken,
|
|
4349
|
-
phase: 'spec',
|
|
4350
4899
|
qaAgentId: qaId,
|
|
4900
|
+
reviewDispatchedAt: new Date().toISOString(),
|
|
4901
|
+
...(opts.reviewHeadAnchorSha ? { reviewHeadAnchorSha: opts.reviewHeadAnchorSha } : {}),
|
|
4902
|
+
...(opts.batch
|
|
4903
|
+
? { batchIndex: opts.batch.index, batchTotal: opts.batch.total }
|
|
4904
|
+
: { batchIndex: undefined, batchTotal: undefined }),
|
|
4905
|
+
...roundPatch,
|
|
4351
4906
|
});
|
|
4352
4907
|
if (!transition) {
|
|
4353
|
-
|
|
4354
|
-
.catch(() => undefined);
|
|
4355
|
-
|
|
4356
|
-
// dev 仍 bound 到 task; develop phase 不在 reentry 集合, 重 acquire 必返回 false (dead code)。
|
|
4908
|
+
if (!opts.continuation) {
|
|
4909
|
+
await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
|
|
4910
|
+
}
|
|
4357
4911
|
await this.safeEmit({
|
|
4358
4912
|
id: '',
|
|
4359
4913
|
type: 'human.intervention',
|
|
@@ -4361,45 +4915,63 @@ export class AgentManager {
|
|
|
4361
4915
|
projectId,
|
|
4362
4916
|
agentId: qaId,
|
|
4363
4917
|
taskId,
|
|
4364
|
-
data: { phase: '
|
|
4918
|
+
data: { phase: 'server-review-transition-failed', qaAgentId: qaId },
|
|
4365
4919
|
});
|
|
4366
4920
|
return null;
|
|
4367
4921
|
}
|
|
4368
|
-
//
|
|
4369
|
-
//
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4922
|
+
// First dispatch creates the QA's base-detached worktree (startSession);
|
|
4923
|
+
// batch continuations reuse the live session + worktree (continueSession).
|
|
4924
|
+
const sessionOpts = {
|
|
4925
|
+
bypassTaskStatusGate: true,
|
|
4926
|
+
signalToken: newToken,
|
|
4927
|
+
serverContent: opts.content,
|
|
4928
|
+
...(opts.diffstat !== undefined ? { serverDiffstat: opts.diffstat } : {}),
|
|
4929
|
+
...(opts.contentTruncated ? { contentTruncated: true } : {}),
|
|
4930
|
+
...(opts.batch ? { serverBatch: opts.batch } : {}),
|
|
4931
|
+
...(opts.priorFindingsJson ? { serverPriorFindings: opts.priorFindingsJson } : {}),
|
|
4932
|
+
...(opts.priorResponseJson ? { serverPriorResponse: opts.priorResponseJson } : {}),
|
|
4933
|
+
...(opts.phase === 'spec' ? { currentSpecRound: newRound } : {}),
|
|
4934
|
+
};
|
|
4935
|
+
// A continuation consumed the QA's reviewed signal (not the dev's entry
|
|
4936
|
+
// signal): rollback restores the prior slice's review/token, so re-arm the
|
|
4937
|
+
// reviewed watcher — the QA's re-emit replays the stored batch findings and
|
|
4938
|
+
// resumes the next-slice dispatch (PR #288).
|
|
4939
|
+
const rearmConsumedSignal = async () => {
|
|
4940
|
+
if (opts.continuation) {
|
|
4941
|
+
await this.setupPhaseSignal(taskId, qaId, expectedKind, { skipSnapshot: true });
|
|
4942
|
+
}
|
|
4943
|
+
else {
|
|
4944
|
+
await rearmEntrySignal();
|
|
4945
|
+
}
|
|
4946
|
+
};
|
|
4373
4947
|
let started = false;
|
|
4374
4948
|
try {
|
|
4375
|
-
started =
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
currentSpecRound: newRound,
|
|
4379
|
-
});
|
|
4949
|
+
started = opts.continuation
|
|
4950
|
+
? await this.continueSession(taskId, qaId, dispatchPhase, sessionOpts)
|
|
4951
|
+
: await this.startSession(taskId, qaId, dispatchPhase, sessionOpts);
|
|
4380
4952
|
}
|
|
4381
4953
|
catch (err) {
|
|
4382
|
-
// DispatchTerminalError 都委托给 failTaskForDispatchError:ack_unknown 会保留绑定走
|
|
4383
|
-
// markAwaitingHuman,其他 reason(prompt_too_large 等非 transient)让 task 进 failed
|
|
4384
|
-
// 而不是 rollback 让 cron 反复 retry。其他异常(瞬时 / 不明)才走 rollback + release。
|
|
4385
4954
|
if (err instanceof DispatchTerminalError) {
|
|
4386
|
-
await this.failTaskForDispatchError(taskId,
|
|
4955
|
+
await this.failTaskForDispatchError(taskId, dispatchPhase, qaId, err);
|
|
4387
4956
|
}
|
|
4388
4957
|
else if (err instanceof EnsureSessionError && err.partial.handled) {
|
|
4389
|
-
// handleDialogPendingFromRuntime
|
|
4390
|
-
// 否则 boundTask terminal 让 release gate 放行清掉仍卡 dialog 的 pane lock。
|
|
4958
|
+
// handleDialogPendingFromRuntime already held + failed + released.
|
|
4391
4959
|
}
|
|
4392
4960
|
else {
|
|
4393
|
-
await
|
|
4394
|
-
|
|
4395
|
-
.catch(() => undefined);
|
|
4961
|
+
await rollback();
|
|
4962
|
+
if (!opts.continuation) {
|
|
4963
|
+
await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
|
|
4964
|
+
}
|
|
4965
|
+
await rearmConsumedSignal();
|
|
4396
4966
|
}
|
|
4397
4967
|
throw err;
|
|
4398
4968
|
}
|
|
4399
4969
|
if (!started) {
|
|
4400
|
-
await
|
|
4401
|
-
|
|
4402
|
-
.catch(() => undefined);
|
|
4970
|
+
await rollback();
|
|
4971
|
+
if (!opts.continuation) {
|
|
4972
|
+
await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
|
|
4973
|
+
}
|
|
4974
|
+
await rearmConsumedSignal();
|
|
4403
4975
|
await this.safeEmit({
|
|
4404
4976
|
id: '',
|
|
4405
4977
|
type: 'human.intervention',
|
|
@@ -4407,75 +4979,75 @@ export class AgentManager {
|
|
|
4407
4979
|
projectId,
|
|
4408
4980
|
agentId: qaId,
|
|
4409
4981
|
taskId,
|
|
4410
|
-
data: { phase: '
|
|
4982
|
+
data: { phase: 'server-review-start-failed', qaAgentId: qaId },
|
|
4411
4983
|
});
|
|
4412
4984
|
return null;
|
|
4413
4985
|
}
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
this.stopSpecSignalWatcher(taskId);
|
|
4417
|
-
await this.armPostDispatchSignalOrHold(taskId, qaId, ['spec-approved', 'spec-changes-requested'], newToken);
|
|
4986
|
+
this.stopPhaseSignalWatcher(taskId);
|
|
4987
|
+
await this.armPostDispatchSignalOrHold(taskId, qaId, expectedKind, newToken, false, (req) => { void this.handleReadFileRequest(taskId, qaId, req); });
|
|
4418
4988
|
return await this.taskStore.get(taskId);
|
|
4419
4989
|
}
|
|
4420
|
-
|
|
4421
|
-
// - pre-spec entry: restore originalToken 让 dev 后续 spec-created signal 经 freshness gate 通过 → auto retry。
|
|
4422
|
-
// - fixing entry: 保留 phase='spec' + qaAgentId(否则 spec.* freshness gate 全 fail),清 token 防 stale。
|
|
4423
|
-
// round 必须 restore — round 是 "已完成轮次" 计数, 累计失败不应吃 round 配额。
|
|
4424
|
-
async rollbackSpecReviewTransition(taskId, originalStatus, originalToken, originalRound) {
|
|
4425
|
-
if (originalStatus === 'fixing') {
|
|
4426
|
-
await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review'] }, { signalToken: undefined, specReviewRound: originalRound });
|
|
4427
|
-
return;
|
|
4428
|
-
}
|
|
4429
|
-
await this.transitionTaskStatus(taskId, originalStatus, { fromStatus: ['review'] }, {
|
|
4430
|
-
signalToken: originalToken,
|
|
4431
|
-
phase: undefined,
|
|
4432
|
-
qaAgentId: undefined,
|
|
4433
|
-
specReviewRound: originalRound,
|
|
4434
|
-
});
|
|
4435
|
-
}
|
|
4436
|
-
async dispatchSpecFixToDev(taskId, findings) {
|
|
4437
|
-
// Phase 1 (lock): validate + phase guard + decide newToken。
|
|
4438
|
-
// fix 是同 round 的 dev 处理 QA findings,round 不递增;只刷新 token 让 prompt + watcher 唯一识别本轮 fix。
|
|
4990
|
+
async dispatchServerFixToDev(taskId, findingsJson) {
|
|
4439
4991
|
const claim = await this.withTaskLock(async () => {
|
|
4440
4992
|
const task = await this.taskStore.get(taskId);
|
|
4441
4993
|
if (!task)
|
|
4442
|
-
throw new Error(`
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
throw new Error(`dispatchSpecFixToDev: task ${taskId} has no dev agent`);
|
|
4446
|
-
}
|
|
4447
|
-
// 离开 spec 阶段的 task 不应再被 spec-fix dispatch 击中 (defense in depth — handler 也 gate)。
|
|
4448
|
-
if (task.phase !== 'spec') {
|
|
4449
|
-
await this.safeEmit({
|
|
4450
|
-
id: '',
|
|
4451
|
-
type: 'human.intervention',
|
|
4452
|
-
timestamp: new Date().toISOString(),
|
|
4453
|
-
projectId: task.projectId,
|
|
4454
|
-
agentId: devAgentId,
|
|
4455
|
-
taskId,
|
|
4456
|
-
data: { phase: 'spec-fix-stale-phase', taskPhase: task.phase },
|
|
4457
|
-
});
|
|
4458
|
-
return null;
|
|
4994
|
+
throw new Error(`dispatchServerFixToDev: task ${taskId} not found`);
|
|
4995
|
+
if (task.reviewMode !== 'server' && task.phase !== 'spec') {
|
|
4996
|
+
throw new Error(`dispatchServerFixToDev: task ${taskId} is not in server review mode`);
|
|
4459
4997
|
}
|
|
4998
|
+
if (!task.agentId)
|
|
4999
|
+
throw new Error(`dispatchServerFixToDev: task ${taskId} has no dev agent`);
|
|
4460
5000
|
return {
|
|
4461
|
-
devAgentId,
|
|
5001
|
+
devAgentId: task.agentId,
|
|
4462
5002
|
qaAgentId: task.qaAgentId,
|
|
4463
5003
|
projectId: task.projectId,
|
|
4464
5004
|
newToken: createSignalToken(),
|
|
4465
|
-
|
|
5005
|
+
taskPhase: (task.phase ?? 'code'),
|
|
5006
|
+
currentSpecRound: task.specReviewRound,
|
|
5007
|
+
// Continue-one-round enters from max_rounds — failure must restore THAT,
|
|
5008
|
+
// not silently demote the human's pause decision to 'review' (PR #288).
|
|
5009
|
+
originalStatus: task.status,
|
|
5010
|
+
originalToken: task.signalToken,
|
|
4466
5011
|
};
|
|
4467
5012
|
});
|
|
4468
5013
|
if (!claim)
|
|
4469
5014
|
return null;
|
|
4470
|
-
const { devAgentId, qaAgentId, projectId, newToken,
|
|
5015
|
+
const { devAgentId, qaAgentId, projectId, newToken, taskPhase, currentSpecRound } = claim;
|
|
5016
|
+
const rollbackToEntry = async () => {
|
|
5017
|
+
await this.transitionTaskStatus(taskId, claim.originalStatus, { fromStatus: ['fixing'] }, { signalToken: claim.originalToken }).catch(() => undefined);
|
|
5018
|
+
};
|
|
5019
|
+
const expectedKind = taskPhase === 'spec' ? 'spec-fixed' : 'code-fixed';
|
|
5020
|
+
// The QA's reviewed signal was consumed before this dispatch; pre-transition
|
|
5021
|
+
// failures must re-arm it (unrotated token) so a later re-emit is consumed.
|
|
5022
|
+
const rearmReviewedSignal = async () => {
|
|
5023
|
+
if (!qaAgentId)
|
|
5024
|
+
return;
|
|
5025
|
+
const reviewedKind = taskPhase === 'spec' ? 'spec-reviewed' : 'code-reviewed';
|
|
5026
|
+
await this.setupPhaseSignal(taskId, qaAgentId, reviewedKind, { skipSnapshot: true });
|
|
5027
|
+
};
|
|
5028
|
+
// Dev BEFORE QA: releasing the QA first is irreversible (binding cleared,
|
|
5029
|
+
// worktree removed, schedulable elsewhere) — a dev acquire failure after it
|
|
5030
|
+
// would leave the review-parked task with no stably-bound agent to retry
|
|
5031
|
+
// from. With the dev secured first, both failure exits keep the QA bound.
|
|
5032
|
+
const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-feedback');
|
|
5033
|
+
if (!acquired) {
|
|
5034
|
+
await rearmReviewedSignal();
|
|
5035
|
+
await this.safeEmit({
|
|
5036
|
+
id: '',
|
|
5037
|
+
type: 'human.intervention',
|
|
5038
|
+
timestamp: new Date().toISOString(),
|
|
5039
|
+
projectId,
|
|
5040
|
+
agentId: devAgentId,
|
|
5041
|
+
taskId,
|
|
5042
|
+
data: { phase: 'server-fix-dev-acquire-failed', devAgentId },
|
|
5043
|
+
});
|
|
5044
|
+
return null;
|
|
5045
|
+
}
|
|
4471
5046
|
if (qaAgentId) {
|
|
4472
|
-
// release 失败留 stale qa binding,下一轮 acquireAgentForTask(qa) 必拒;abort + emit intervention。
|
|
4473
5047
|
const released = await this.releaseAgentForTask(qaAgentId, taskId, 'idle')
|
|
4474
|
-
.catch(
|
|
4475
|
-
console.warn(`[AgentManager] dispatchSpecFixToDev release qa=${qaAgentId} failed:`, err);
|
|
4476
|
-
return false;
|
|
4477
|
-
});
|
|
5048
|
+
.catch(() => false);
|
|
4478
5049
|
if (!released) {
|
|
5050
|
+
await rearmReviewedSignal();
|
|
4479
5051
|
await this.safeEmit({
|
|
4480
5052
|
id: '',
|
|
4481
5053
|
type: 'human.intervention',
|
|
@@ -4483,32 +5055,18 @@ export class AgentManager {
|
|
|
4483
5055
|
projectId,
|
|
4484
5056
|
agentId: qaAgentId,
|
|
4485
5057
|
taskId,
|
|
4486
|
-
data: { phase: '
|
|
5058
|
+
data: { phase: 'server-fix-qa-release-failed', qaAgentId },
|
|
4487
5059
|
});
|
|
4488
5060
|
return null;
|
|
4489
5061
|
}
|
|
4490
5062
|
}
|
|
4491
|
-
//
|
|
4492
|
-
const
|
|
4493
|
-
if (!acquired) {
|
|
4494
|
-
await this.safeEmit({
|
|
4495
|
-
id: '',
|
|
4496
|
-
type: 'human.intervention',
|
|
4497
|
-
timestamp: new Date().toISOString(),
|
|
4498
|
-
projectId,
|
|
4499
|
-
agentId: devAgentId,
|
|
4500
|
-
taskId,
|
|
4501
|
-
data: { phase: 'spec-fix-dev-acquire-failed', devAgentId },
|
|
4502
|
-
});
|
|
4503
|
-
return null;
|
|
4504
|
-
}
|
|
4505
|
-
// Phase 2b (lock): atomic transition + persist newToken/phase。
|
|
4506
|
-
// 必须在 continueSession 之前;否则崩溃后 setupRecoveredSpecSignals 读旧 token,
|
|
4507
|
-
// 与 dev 输出的 newToken signal 不匹配 → 链路死。
|
|
4508
|
-
const transition = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review'] }, { signalToken: newToken, phase: 'spec' });
|
|
5063
|
+
// max_rounds entry = human "continue one round" via continueDevRound.
|
|
5064
|
+
const transition = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review', 'max_rounds'] }, { signalToken: newToken, fixDispatchedAt: new Date().toISOString() });
|
|
4509
5065
|
if (!transition) {
|
|
4510
|
-
|
|
4511
|
-
|
|
5066
|
+
// Refusal = the task left review/max_rounds concurrently (cancel / fail /
|
|
5067
|
+
// mark-complete publish). Ownership moved with it — releasing dev here
|
|
5068
|
+
// would strip a binding the winning chain may be actively using (e.g. a
|
|
5069
|
+
// publish prompt running in the pane); its own cleanup releases the dev.
|
|
4512
5070
|
await this.safeEmit({
|
|
4513
5071
|
id: '',
|
|
4514
5072
|
type: 'human.intervention',
|
|
@@ -4516,42 +5074,42 @@ export class AgentManager {
|
|
|
4516
5074
|
projectId,
|
|
4517
5075
|
agentId: devAgentId,
|
|
4518
5076
|
taskId,
|
|
4519
|
-
data: { phase: '
|
|
5077
|
+
data: { phase: 'server-fix-transition-failed', devAgentId },
|
|
4520
5078
|
});
|
|
4521
5079
|
return null;
|
|
4522
5080
|
}
|
|
4523
|
-
// Phase 2c: continueSession 透传 newToken + currentRound 给 prompt。
|
|
4524
|
-
// 失败时回滚 transition + 清新 token,避免 task 留在 fixing 但 dev 无 spec-fix prompt 的 stuck。
|
|
4525
5081
|
let resumed = false;
|
|
4526
5082
|
try {
|
|
4527
|
-
resumed = await this.continueSession(taskId, devAgentId, '
|
|
4528
|
-
specFindings: findings,
|
|
4529
|
-
signalToken: newToken,
|
|
4530
|
-
currentSpecRound: currentRound,
|
|
5083
|
+
resumed = await this.continueSession(taskId, devAgentId, 'server-feedback', {
|
|
4531
5084
|
bypassTaskStatusGate: true,
|
|
5085
|
+
signalToken: newToken,
|
|
5086
|
+
serverPriorFindings: findingsJson,
|
|
5087
|
+
...(taskPhase === 'spec' && currentSpecRound !== undefined
|
|
5088
|
+
? { currentSpecRound }
|
|
5089
|
+
: {}),
|
|
4532
5090
|
});
|
|
4533
5091
|
}
|
|
4534
5092
|
catch (err) {
|
|
4535
|
-
// 同 spec-review:DispatchTerminalError 走 failTaskForDispatchError 统一处理
|
|
4536
|
-
// (ack_unknown → markAwaitingHuman,其他 reason → release + task failed)。
|
|
4537
5093
|
if (err instanceof DispatchTerminalError) {
|
|
4538
|
-
await this.failTaskForDispatchError(taskId, '
|
|
5094
|
+
await this.failTaskForDispatchError(taskId, 'server-feedback', devAgentId, err);
|
|
4539
5095
|
}
|
|
4540
5096
|
else if (err instanceof EnsureSessionError && err.partial.handled) {
|
|
4541
|
-
//
|
|
5097
|
+
// handled upstream
|
|
4542
5098
|
}
|
|
4543
5099
|
else {
|
|
4544
|
-
await
|
|
4545
|
-
await this.releaseAgentForTask(devAgentId, taskId, 'idle')
|
|
4546
|
-
|
|
5100
|
+
await rollbackToEntry();
|
|
5101
|
+
await this.releaseAgentForTask(devAgentId, taskId, 'idle').catch(() => undefined);
|
|
5102
|
+
// Rollback restored review/old-token, but the QA's reviewed signal was
|
|
5103
|
+
// consumed — without a subscriber its re-emit can never retry the fix
|
|
5104
|
+
// dispatch (PR #288).
|
|
5105
|
+
await rearmReviewedSignal();
|
|
4547
5106
|
}
|
|
4548
|
-
console.error(`[AgentManager] dispatchSpecFixToDev continueSession(dev=${devAgentId}) failed:`, err);
|
|
4549
5107
|
throw err;
|
|
4550
5108
|
}
|
|
4551
5109
|
if (!resumed) {
|
|
4552
|
-
await
|
|
4553
|
-
await this.releaseAgentForTask(devAgentId, taskId, 'idle')
|
|
4554
|
-
|
|
5110
|
+
await rollbackToEntry();
|
|
5111
|
+
await this.releaseAgentForTask(devAgentId, taskId, 'idle').catch(() => undefined);
|
|
5112
|
+
await rearmReviewedSignal();
|
|
4555
5113
|
await this.safeEmit({
|
|
4556
5114
|
id: '',
|
|
4557
5115
|
type: 'human.intervention',
|
|
@@ -4559,59 +5117,34 @@ export class AgentManager {
|
|
|
4559
5117
|
projectId,
|
|
4560
5118
|
agentId: devAgentId,
|
|
4561
5119
|
taskId,
|
|
4562
|
-
data: { phase: '
|
|
5120
|
+
data: { phase: 'server-fix-resume-failed', devAgentId },
|
|
4563
5121
|
});
|
|
4564
5122
|
return null;
|
|
4565
5123
|
}
|
|
4566
|
-
|
|
4567
|
-
await this.armPostDispatchSignalOrHold(taskId, devAgentId, 'spec-fixed', newToken);
|
|
5124
|
+
await this.armPostDispatchSignalOrHold(taskId, devAgentId, expectedKind, newToken);
|
|
4568
5125
|
return await this.taskStore.get(taskId);
|
|
4569
5126
|
}
|
|
4570
|
-
|
|
4571
|
-
// 保留 phase='spec' 与 qaAgentId — 失败后 review 状态需要人工 retry 或重新 dispatch。
|
|
4572
|
-
async rollbackSpecFixTransition(taskId) {
|
|
4573
|
-
await this.transitionTaskStatus(taskId, 'review', { fromStatus: ['fixing'] }, { signalToken: undefined });
|
|
4574
|
-
}
|
|
4575
|
-
async transitionToCodePhase(taskId) {
|
|
5127
|
+
async dispatchServerAfterDone(taskId, kind) {
|
|
4576
5128
|
const task = await this.taskStore.get(taskId);
|
|
4577
5129
|
if (!task)
|
|
4578
|
-
|
|
5130
|
+
throw new Error(`dispatchServerAfterDone: task ${taskId} not found`);
|
|
4579
5131
|
const devAgentId = task.agentId;
|
|
4580
5132
|
if (!devAgentId)
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
5133
|
+
throw new Error(`dispatchServerAfterDone: task ${taskId} has no dev agent`);
|
|
5134
|
+
const branch = task.branch ?? BRANCH_PREFIX + taskId;
|
|
5135
|
+
const originalToken = task.signalToken;
|
|
4584
5136
|
const newToken = createSignalToken();
|
|
4585
|
-
|
|
4586
|
-
//
|
|
4587
|
-
//
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
|
|
4591
|
-
|
|
4592
|
-
|
|
4593
|
-
|
|
4594
|
-
// is authoritatively detected by the GitHub poller (PR creation isn't same-identity-gated), so
|
|
4595
|
-
// a missed pane watcher only costs one poll cycle of latency, never a stuck task.
|
|
4596
|
-
await this.setupPhaseSignalWatcher(taskId, devAgentId, 'pr-created', newToken);
|
|
4597
|
-
if (task.qaAgentId) {
|
|
4598
|
-
// release 失败留 stale qa binding → emit intervention 让其可见。
|
|
4599
|
-
const released = await this.releaseAgentForTask(task.qaAgentId, taskId, 'idle')
|
|
4600
|
-
.catch(() => false);
|
|
4601
|
-
if (!released) {
|
|
4602
|
-
await this.safeEmit({
|
|
4603
|
-
id: '',
|
|
4604
|
-
type: 'human.intervention',
|
|
4605
|
-
timestamp: new Date().toISOString(),
|
|
4606
|
-
projectId: task.projectId,
|
|
4607
|
-
agentId: task.qaAgentId,
|
|
4608
|
-
taskId,
|
|
4609
|
-
data: { phase: 'code-phase-qa-release-failed', qaAgentId: task.qaAgentId },
|
|
4610
|
-
});
|
|
4611
|
-
}
|
|
4612
|
-
}
|
|
4613
|
-
const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'code');
|
|
5137
|
+
await this.updateTask(taskId, { signalToken: newToken });
|
|
5138
|
+
// The publish prompt never reached the pane — restore the pre-rotation token
|
|
5139
|
+
// (so recovery still matches the pre-dispatch arm) and clear the delivery
|
|
5140
|
+
// marker so retry knows this approved state is preemptible (PR #288).
|
|
5141
|
+
const rollbackToken = async () => {
|
|
5142
|
+
await this.updateTask(taskId, { signalToken: originalToken, publishDispatchedAt: undefined })
|
|
5143
|
+
.catch(() => undefined);
|
|
5144
|
+
};
|
|
5145
|
+
const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-after-done');
|
|
4614
5146
|
if (!acquired) {
|
|
5147
|
+
await rollbackToken();
|
|
4615
5148
|
await this.safeEmit({
|
|
4616
5149
|
id: '',
|
|
4617
5150
|
type: 'human.intervention',
|
|
@@ -4619,24 +5152,37 @@ export class AgentManager {
|
|
|
4619
5152
|
projectId: task.projectId,
|
|
4620
5153
|
agentId: devAgentId,
|
|
4621
5154
|
taskId,
|
|
4622
|
-
data: { phase: '
|
|
5155
|
+
data: { phase: 'server-after-done-dev-acquire-failed', devAgentId },
|
|
4623
5156
|
});
|
|
4624
5157
|
return null;
|
|
4625
5158
|
}
|
|
5159
|
+
// Pessimistic delivery marker BEFORE the irreversible paste: every failure
|
|
5160
|
+
// path below clears it. The remaining crash window (marker written, paste
|
|
5161
|
+
// never ran) fails CLOSED — retry 409s on a publish that never started and
|
|
5162
|
+
// the operator escapes via Cancel — instead of the old window's fail-open
|
|
5163
|
+
// double publish (paste ran, marker missing, retry re-pastes) (PR #288).
|
|
5164
|
+
await this.updateTask(taskId, { publishDispatchedAt: new Date().toISOString() });
|
|
4626
5165
|
let resumed = false;
|
|
4627
5166
|
try {
|
|
4628
|
-
resumed = await this.continueSession(taskId, devAgentId, '
|
|
5167
|
+
resumed = await this.continueSession(taskId, devAgentId, 'server-after-done', {
|
|
5168
|
+
bypassTaskStatusGate: true,
|
|
5169
|
+
signalToken: newToken,
|
|
5170
|
+
serverAfterDone: { kind, branch },
|
|
5171
|
+
});
|
|
4629
5172
|
}
|
|
4630
5173
|
catch (err) {
|
|
4631
|
-
// 同 spec-review/spec-fix:DispatchTerminalError 委托给 failTaskForDispatchError
|
|
4632
|
-
// (ack_unknown → markAwaitingHuman;其他 reason → release + task failed)。
|
|
4633
5174
|
if (err instanceof DispatchTerminalError) {
|
|
4634
|
-
await this.failTaskForDispatchError(taskId, '
|
|
5175
|
+
await this.failTaskForDispatchError(taskId, 'server-after-done', devAgentId, err);
|
|
5176
|
+
}
|
|
5177
|
+
else if (!(err instanceof EnsureSessionError && err.partial.handled)) {
|
|
5178
|
+
// Keep dev BOUND — its worktree holds the reviewed (unpushed) commits.
|
|
5179
|
+
// mark-complete retries the publish via server-after-done same-task reentry.
|
|
5180
|
+
await rollbackToken();
|
|
4635
5181
|
}
|
|
4636
|
-
console.error(`[AgentManager] transitionToCodePhase continueSession(dev=${devAgentId}) failed:`, err);
|
|
4637
5182
|
throw err;
|
|
4638
5183
|
}
|
|
4639
5184
|
if (!resumed) {
|
|
5185
|
+
await rollbackToken();
|
|
4640
5186
|
await this.safeEmit({
|
|
4641
5187
|
id: '',
|
|
4642
5188
|
type: 'human.intervention',
|
|
@@ -4644,12 +5190,267 @@ export class AgentManager {
|
|
|
4644
5190
|
projectId: task.projectId,
|
|
4645
5191
|
agentId: devAgentId,
|
|
4646
5192
|
taskId,
|
|
4647
|
-
data: {
|
|
5193
|
+
data: {
|
|
5194
|
+
phase: 'server-after-done-resume-failed',
|
|
5195
|
+
devAgentId,
|
|
5196
|
+
note: 'Publish prompt was not delivered; mark-complete retries the publish dispatch.',
|
|
5197
|
+
},
|
|
4648
5198
|
});
|
|
4649
5199
|
return null;
|
|
4650
5200
|
}
|
|
5201
|
+
await this.armPostDispatchSignalOrHold(taskId, devAgentId, 'code-ready', newToken);
|
|
4651
5202
|
return await this.taskStore.get(taskId);
|
|
4652
5203
|
}
|
|
5204
|
+
// QA asked for file context during a server-mode review. Read from the DEV
|
|
5205
|
+
// worktree (the QA worktree sits on the base branch) and paste into QA's pane.
|
|
5206
|
+
async handleReadFileRequest(taskId, qaAgentId, req) {
|
|
5207
|
+
const task = await this.taskStore.get(taskId);
|
|
5208
|
+
if (!task)
|
|
5209
|
+
return;
|
|
5210
|
+
const dev = this.getAgentConfig(task.agentId);
|
|
5211
|
+
if (!dev)
|
|
5212
|
+
return;
|
|
5213
|
+
await this.refreshWorktreeCacheFor(task.agentId);
|
|
5214
|
+
let body;
|
|
5215
|
+
try {
|
|
5216
|
+
const text = await this.getReviewTransport().readFileRange(dev, req.file, req.startLine, req.endLine);
|
|
5217
|
+
body = `=== baxian read-file ${req.file}:${req.startLine}-${req.endLine} ===\n${text}\n=== end read-file ===`;
|
|
5218
|
+
}
|
|
5219
|
+
catch (err) {
|
|
5220
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
5221
|
+
body = `=== baxian read-file ${req.file}:${req.startLine}-${req.endLine} REFUSED: ${reason} ===`;
|
|
5222
|
+
}
|
|
5223
|
+
// The read ran async — QA may have submitted its verdict and been released
|
|
5224
|
+
// or rebound meanwhile. Never paste old-task content into a new task's pane.
|
|
5225
|
+
const qaState = await this.agentStore.get(qaAgentId);
|
|
5226
|
+
if (qaState?.taskId !== taskId) {
|
|
5227
|
+
console.warn(`[AgentManager] read-file response dropped: qa=${qaAgentId} no longer bound to ${taskId} (got ${qaState?.taskId})`);
|
|
5228
|
+
return;
|
|
5229
|
+
}
|
|
5230
|
+
try {
|
|
5231
|
+
await this.injectTextToAgent(qaAgentId, body, { expectedTaskId: taskId });
|
|
5232
|
+
}
|
|
5233
|
+
catch (err) {
|
|
5234
|
+
console.warn(`[AgentManager] read-file injection to ${qaAgentId} failed:`, err);
|
|
5235
|
+
}
|
|
5236
|
+
}
|
|
5237
|
+
// Plain text paste + submit into a live agent pane (no skills, no ack protocol).
|
|
5238
|
+
async injectTextToAgent(agentId, text, opts = {}) {
|
|
5239
|
+
const cfg = this.getAgentConfig(agentId);
|
|
5240
|
+
if (!cfg)
|
|
5241
|
+
throw new Error(`injectTextToAgent: unknown agent ${agentId}`);
|
|
5242
|
+
await this.acquireCompactGuard(agentId);
|
|
5243
|
+
try {
|
|
5244
|
+
// 锁内重读:guard 等待期间绑定可能已易主,过期文本决不落 pane。
|
|
5245
|
+
const state = await this.agentStore.get(agentId);
|
|
5246
|
+
if (opts.expectedTaskId !== undefined && state?.taskId !== opts.expectedTaskId) {
|
|
5247
|
+
throw new Error(`injectTextToAgent: agent ${agentId} no longer bound to ${opts.expectedTaskId}`);
|
|
5248
|
+
}
|
|
5249
|
+
const paneId = state?.paneId;
|
|
5250
|
+
if (!paneId)
|
|
5251
|
+
throw new Error(`injectTextToAgent: agent ${agentId} has no live pane`);
|
|
5252
|
+
const tmux = new TmuxManager(this.createRunnerFor(cfg));
|
|
5253
|
+
await tmux.injectPrompt(paneId, text, agentId);
|
|
5254
|
+
await tmux.sendEnter(paneId);
|
|
5255
|
+
}
|
|
5256
|
+
finally {
|
|
5257
|
+
this.compactInFlight.delete(agentId);
|
|
5258
|
+
}
|
|
5259
|
+
}
|
|
5260
|
+
// Human gate confirm (spec §10): executes the configured completion for
|
|
5261
|
+
// ready (server mode) / merge-ready (github mode) tasks.
|
|
5262
|
+
async confirmHumanGate(taskId) {
|
|
5263
|
+
// Claim under the task lock: a Cancel racing this read can no longer flip the
|
|
5264
|
+
// task to cancelled (and retire its artifacts) while we proceed on a stale
|
|
5265
|
+
// gate snapshot — cancelTask checks the in-flight flag inside the same lock.
|
|
5266
|
+
const task = await this.claimCompleteGate(taskId, ['ready', 'merge-ready']);
|
|
5267
|
+
try {
|
|
5268
|
+
const project = this.getProjectConfig(task.projectId);
|
|
5269
|
+
const mergeAuto = project?.merge === 'auto';
|
|
5270
|
+
// Snapshot from verdict time — a hot config flip between publish and
|
|
5271
|
+
// confirm must not reroute an already-published artifact (PR #288).
|
|
5272
|
+
const afterDone = this.resolveAfterDone(task);
|
|
5273
|
+
if (task.status === 'merge-ready') {
|
|
5274
|
+
if (mergeAuto && task.prNumber) {
|
|
5275
|
+
// Guard on the post-approve head persisted at the merge-ready transition.
|
|
5276
|
+
if (!task.latestHeadSha) {
|
|
5277
|
+
throw new ApiError(409, `Task ${taskId} has no approved head recorded; cannot safely merge`);
|
|
5278
|
+
}
|
|
5279
|
+
await this.executeConfirmMerge(task, () => this.mergePr(taskId, {
|
|
5280
|
+
matchHeadSha: task.latestHeadSha,
|
|
5281
|
+
}));
|
|
5282
|
+
await this.eventBus.emit({
|
|
5283
|
+
id: '',
|
|
5284
|
+
type: 'pr.merged',
|
|
5285
|
+
timestamp: new Date().toISOString(),
|
|
5286
|
+
projectId: task.projectId,
|
|
5287
|
+
agentId: task.agentId,
|
|
5288
|
+
taskId: task.id,
|
|
5289
|
+
data: { prNumber: task.prNumber, ...(task.prUrl ? { prUrl: task.prUrl } : {}) },
|
|
5290
|
+
});
|
|
5291
|
+
return (await this.taskStore.get(taskId));
|
|
5292
|
+
}
|
|
5293
|
+
return this.finishTaskAsDone(taskId);
|
|
5294
|
+
}
|
|
5295
|
+
// status === 'ready' (server mode)
|
|
5296
|
+
if (afterDone === 'pr' && mergeAuto && task.prNumber) {
|
|
5297
|
+
// Reviewed-head guard is mandatory here — publish fail-closes on capture,
|
|
5298
|
+
// so a missing sha means tampered/legacy state, not a soft fallback.
|
|
5299
|
+
if (!task.latestHeadSha) {
|
|
5300
|
+
throw new ApiError(409, `Task ${taskId} has no reviewed head recorded; cannot safely merge`);
|
|
5301
|
+
}
|
|
5302
|
+
await this.executeConfirmMerge(task, () => this.mergePr(taskId, {
|
|
5303
|
+
matchHeadSha: task.latestHeadSha,
|
|
5304
|
+
}));
|
|
5305
|
+
// pr.merged's fromStatus now includes 'ready' — let the handler own the
|
|
5306
|
+
// merged transition + full cleanup chain (branch delete, /compact, release).
|
|
5307
|
+
await this.eventBus.emit({
|
|
5308
|
+
id: '',
|
|
5309
|
+
type: 'pr.merged',
|
|
5310
|
+
timestamp: new Date().toISOString(),
|
|
5311
|
+
projectId: task.projectId,
|
|
5312
|
+
agentId: task.agentId,
|
|
5313
|
+
taskId: task.id,
|
|
5314
|
+
data: { prNumber: task.prNumber, ...(task.prUrl ? { prUrl: task.prUrl } : {}) },
|
|
5315
|
+
});
|
|
5316
|
+
return (await this.taskStore.get(taskId));
|
|
5317
|
+
}
|
|
5318
|
+
if (afterDone === 'branch' && mergeAuto && task.branch) {
|
|
5319
|
+
await this.executeConfirmMerge(task, () => this.ffMergeBranch(task));
|
|
5320
|
+
const merged = await this.transitionTaskStatus(taskId, 'merged', { fromStatus: ['ready'] });
|
|
5321
|
+
if (merged)
|
|
5322
|
+
await this.releaseTaskAgents(taskId);
|
|
5323
|
+
return (await this.taskStore.get(taskId));
|
|
5324
|
+
}
|
|
5325
|
+
return this.finishTaskAsDone(taskId);
|
|
5326
|
+
}
|
|
5327
|
+
finally {
|
|
5328
|
+
this.markCompleteInFlight.delete(taskId);
|
|
5329
|
+
}
|
|
5330
|
+
}
|
|
5331
|
+
// Atomic gate claim: re-read + status check + markCompleteInFlight.add under
|
|
5332
|
+
// the task lock, so confirm and cancel serialize on the same snapshot.
|
|
5333
|
+
async claimCompleteGate(taskId, statuses) {
|
|
5334
|
+
return this.withTaskLock(async () => {
|
|
5335
|
+
const fresh = await this.taskStore.get(taskId);
|
|
5336
|
+
if (!fresh)
|
|
5337
|
+
throw new ApiError(404, `Task ${taskId} not found`);
|
|
5338
|
+
if (!statuses.includes(fresh.status)) {
|
|
5339
|
+
throw new ApiError(409, `Task ${taskId} is not awaiting confirmation (status=${fresh.status})`);
|
|
5340
|
+
}
|
|
5341
|
+
if (this.markCompleteInFlight.has(taskId)) {
|
|
5342
|
+
throw new ApiError(409, `Task ${taskId} is already being completed`);
|
|
5343
|
+
}
|
|
5344
|
+
this.markCompleteInFlight.add(taskId);
|
|
5345
|
+
return fresh;
|
|
5346
|
+
});
|
|
5347
|
+
}
|
|
5348
|
+
// Merge failures keep the gate: transient gh/network errors retry via another
|
|
5349
|
+
// Confirm, a stale head resolves via Cancel or an external decision — terminal
|
|
5350
|
+
// 'failed' would orphan the published PR/branch outside the task flow (PR #288).
|
|
5351
|
+
async executeConfirmMerge(task, merge) {
|
|
5352
|
+
try {
|
|
5353
|
+
await merge();
|
|
5354
|
+
}
|
|
5355
|
+
catch (err) {
|
|
5356
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5357
|
+
await this.safeEmit({
|
|
5358
|
+
id: '',
|
|
5359
|
+
type: 'human.intervention',
|
|
5360
|
+
timestamp: new Date().toISOString(),
|
|
5361
|
+
projectId: task.projectId,
|
|
5362
|
+
taskId: task.id,
|
|
5363
|
+
data: {
|
|
5364
|
+
phase: 'confirm-merge-failed',
|
|
5365
|
+
gate: task.status,
|
|
5366
|
+
error: message,
|
|
5367
|
+
note: 'Task stays at the gate: Confirm again to retry, or Cancel to retire the published artifact.',
|
|
5368
|
+
},
|
|
5369
|
+
});
|
|
5370
|
+
throw new ApiError(409, `Merge failed for task ${task.id}: ${message}`);
|
|
5371
|
+
}
|
|
5372
|
+
}
|
|
5373
|
+
async finishTaskAsDone(taskId) {
|
|
5374
|
+
const done = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['ready', 'merge-ready'] });
|
|
5375
|
+
if (!done)
|
|
5376
|
+
throw new ApiError(409, `Task ${taskId} changed status during confirm; aborted`);
|
|
5377
|
+
await this.releaseTaskAgents(taskId);
|
|
5378
|
+
return (await this.taskStore.get(taskId));
|
|
5379
|
+
}
|
|
5380
|
+
// Terminal-state resource release shared by done/merged(branch)/failed confirm
|
|
5381
|
+
// paths: stop the watcher, release dev+qa (worktree removal rides releaseAgentForTask).
|
|
5382
|
+
async releaseTaskAgents(taskId) {
|
|
5383
|
+
this.phaseSignalWatcher?.stop(taskId);
|
|
5384
|
+
const task = await this.taskStore.get(taskId);
|
|
5385
|
+
if (!task)
|
|
5386
|
+
return;
|
|
5387
|
+
for (const id of [task.agentId, task.qaAgentId]) {
|
|
5388
|
+
if (!id)
|
|
5389
|
+
continue;
|
|
5390
|
+
const state = await this.agentStore.get(id);
|
|
5391
|
+
if (!state || state.taskId !== taskId)
|
|
5392
|
+
continue;
|
|
5393
|
+
await this.releaseAgentForTask(id, taskId, 'idle', { allowAwaitingHuman: true })
|
|
5394
|
+
.catch(err => {
|
|
5395
|
+
console.warn(`[AgentManager] confirm release ${id} failed:`, err);
|
|
5396
|
+
});
|
|
5397
|
+
}
|
|
5398
|
+
}
|
|
5399
|
+
// afterDone:'branch' + merge:'auto' — fast-forward the remote default branch
|
|
5400
|
+
// to the reviewed branch ref-to-ref (`git push origin origin/bx/X:main`).
|
|
5401
|
+
// Never touches the repo working tree, and a plain push is ff-only by default:
|
|
5402
|
+
// a non-ff base is rejected by the remote and a human must rebase/decide (spec §6).
|
|
5403
|
+
repoMergeQueue = new Map();
|
|
5404
|
+
async ffMergeBranch(task) {
|
|
5405
|
+
const dev = this.getAgentConfig(task.agentId);
|
|
5406
|
+
if (!dev)
|
|
5407
|
+
throw new Error(`ffMergeBranch: no dev agent for task ${task.id}`);
|
|
5408
|
+
const state = await this.agentStore.get(task.agentId);
|
|
5409
|
+
const repoPath = state?.repoPath;
|
|
5410
|
+
if (!repoPath)
|
|
5411
|
+
throw new Error(`ffMergeBranch: no repoPath for agent ${task.agentId}`);
|
|
5412
|
+
const branch = task.branch ?? BRANCH_PREFIX + task.id;
|
|
5413
|
+
const runner = this.createRunnerFor(dev);
|
|
5414
|
+
const prev = this.repoMergeQueue.get(task.projectId) ?? Promise.resolve();
|
|
5415
|
+
const run = prev.then(async () => {
|
|
5416
|
+
const cd = `cd ${shellQuote(repoPath)} && `;
|
|
5417
|
+
const db = await runner.exec(`${cd}git symbolic-ref --short refs/remotes/origin/HEAD`);
|
|
5418
|
+
const defaultBranch = db.stdout.trim().replace(/^origin\//, '');
|
|
5419
|
+
if (db.exitCode !== 0 || defaultBranch === '') {
|
|
5420
|
+
// A silent 'main' fallback would push the reviewed branch onto the wrong
|
|
5421
|
+
// ref for repos whose default branch differs (PR #288).
|
|
5422
|
+
throw new Error(`ffMergeBranch: cannot resolve default branch: ${db.stderr.trim() || 'empty origin/HEAD'}`);
|
|
5423
|
+
}
|
|
5424
|
+
const fetch = await runner.exec(`${cd}git fetch origin`);
|
|
5425
|
+
if (fetch.exitCode !== 0) {
|
|
5426
|
+
throw new Error(`ffMergeBranch [git fetch] failed: ${fetch.stderr.trim()}`);
|
|
5427
|
+
}
|
|
5428
|
+
// Reviewed-head guard (branch path): refuse if origin/<branch> moved after
|
|
5429
|
+
// the gate — symmetric with the pr path's --match-head-commit (PR #288).
|
|
5430
|
+
if (task.latestHeadSha) {
|
|
5431
|
+
const remoteHead = await runner.exec(`${cd}git rev-parse ${shellQuote(`origin/${branch}`)}`);
|
|
5432
|
+
if (remoteHead.exitCode !== 0 || remoteHead.stdout.trim() !== task.latestHeadSha) {
|
|
5433
|
+
throw new Error(`ffMergeBranch: origin/${branch} head ${remoteHead.stdout.trim() || '<unresolved>'} ` +
|
|
5434
|
+
`!= reviewed head ${task.latestHeadSha}; refusing to merge un-reviewed commits`);
|
|
5435
|
+
}
|
|
5436
|
+
}
|
|
5437
|
+
else {
|
|
5438
|
+
throw new Error(`ffMergeBranch: no reviewed head recorded for task ${task.id}; cannot safely merge`);
|
|
5439
|
+
}
|
|
5440
|
+
const push = await runner.exec(`${cd}git push origin ${shellQuote(`origin/${branch}`)}:${shellQuote(defaultBranch)}`);
|
|
5441
|
+
if (push.exitCode !== 0) {
|
|
5442
|
+
throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
|
|
5443
|
+
}
|
|
5444
|
+
// The merge has landed; branch deletion is cleanup — a transient failure
|
|
5445
|
+
// here must not flip an already-merged task to failed (PR #288).
|
|
5446
|
+
const del = await runner.exec(`${cd}git push origin --delete ${shellQuote(branch)}`);
|
|
5447
|
+
if (del.exitCode !== 0) {
|
|
5448
|
+
console.warn(`[AgentManager] ffMergeBranch: post-merge branch delete failed for ${branch}: ${del.stderr.trim() || del.stdout.trim()}`);
|
|
5449
|
+
}
|
|
5450
|
+
});
|
|
5451
|
+
this.repoMergeQueue.set(task.projectId, run.catch(() => undefined));
|
|
5452
|
+
await run;
|
|
5453
|
+
}
|
|
4653
5454
|
}
|
|
4654
5455
|
function buildAgentIndex(config) {
|
|
4655
5456
|
const index = new Map();
|