baxian 1.1.0 → 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/manager.d.ts +10 -9
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +181 -413
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/phase-signal-watcher.d.ts +1 -4
- package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
- package/dist/agent/phase-signal-watcher.js +3 -22
- package/dist/agent/phase-signal-watcher.js.map +1 -1
- package/dist/agent/phase-signal.d.ts +1 -10
- package/dist/agent/phase-signal.d.ts.map +1 -1
- package/dist/agent/phase-signal.js +5 -8
- package/dist/agent/phase-signal.js.map +1 -1
- package/dist/agent/prompt.d.ts +1 -2
- package/dist/agent/prompt.d.ts.map +1 -1
- package/dist/agent/prompt.js +32 -63
- 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/worktree.d.ts +1 -0
- package/dist/agent/worktree.d.ts.map +1 -1
- package/dist/agent/worktree.js +14 -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.js +1 -1
- 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/event/handlers.d.ts.map +1 -1
- package/dist/event/handlers.js +10 -430
- package/dist/event/handlers.js.map +1 -1
- package/dist/event/server-handlers.d.ts.map +1 -1
- package/dist/event/server-handlers.js +21 -14
- package/dist/event/server-handlers.js.map +1 -1
- package/dist/shared/constants.d.ts +1 -1
- package/dist/shared/constants.d.ts.map +1 -1
- package/dist/shared/constants.js +0 -6
- package/dist/shared/constants.js.map +1 -1
- package/dist/shared/types.d.ts +1 -1
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/skills/server-feedback/SKILL.md +4 -2
- 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-DE_xpPQe.js +0 -4
package/dist/agent/manager.js
CHANGED
|
@@ -74,7 +74,7 @@ function agentRuntimeKindFor(agent) {
|
|
|
74
74
|
const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
|
|
75
75
|
const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
|
|
76
76
|
// Dev-facing deliverable phases that weave the task's uploaded image paths into the prompt (task-055).
|
|
77
|
-
const IMAGE_DISPATCH_PHASES = new Set(['develop', 'code', 'fix', '
|
|
77
|
+
const IMAGE_DISPATCH_PHASES = new Set(['develop', 'code', 'fix', 'server-feedback']);
|
|
78
78
|
export function canDispatchWithBinding(binding) {
|
|
79
79
|
return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
|
|
80
80
|
}
|
|
@@ -132,6 +132,7 @@ export class AgentManager {
|
|
|
132
132
|
runtimeMenuPollIntervalMs = 10_000;
|
|
133
133
|
compactIdleWaitMs = 5 * 60_000;
|
|
134
134
|
compactIdlePollMs = 2_000;
|
|
135
|
+
manualCompactWaitMs = 5_000;
|
|
135
136
|
postMergeFetchTimeoutMs = 60_000;
|
|
136
137
|
postMergeBranchTimeoutMs = 10_000;
|
|
137
138
|
// taskIds with in-flight manual review — second concurrent POST gets 409.
|
|
@@ -143,6 +144,7 @@ export class AgentManager {
|
|
|
143
144
|
// agentIds with in-flight DELETE — 第二个 DELETE 撞 awaiting_human stale-lock takeover 路径会
|
|
144
145
|
// 把第一个 DELETE 持有的占位也当 stale 接管,导致并发 cleanupRemovedAgentRuntime。
|
|
145
146
|
deletionInFlight = new Set();
|
|
147
|
+
compactInFlight = new Set();
|
|
146
148
|
constructor(deps) {
|
|
147
149
|
this.config = deps.config;
|
|
148
150
|
this.agentStore = deps.agentStore;
|
|
@@ -975,7 +977,7 @@ export class AgentManager {
|
|
|
975
977
|
const state = await this.agentStore.get(agentId);
|
|
976
978
|
const sameTaskLocked = state?.taskId === taskId && (await this.lockManager.isLocked(agentId));
|
|
977
979
|
const reentryPhases = new Set([
|
|
978
|
-
'fix', 'post-approve', '
|
|
980
|
+
'fix', 'post-approve', 'code',
|
|
979
981
|
'server-feedback', 'server-after-done',
|
|
980
982
|
]);
|
|
981
983
|
const sameTaskReentry = state?.taskId === taskId &&
|
|
@@ -1744,7 +1746,6 @@ export class AgentManager {
|
|
|
1744
1746
|
expectedKinds: 'pr-merge-ready',
|
|
1745
1747
|
token: completion.token,
|
|
1746
1748
|
skipSnapshot,
|
|
1747
|
-
reviewMode: task.reviewMode ?? 'github',
|
|
1748
1749
|
recovered: true,
|
|
1749
1750
|
});
|
|
1750
1751
|
}
|
|
@@ -1753,8 +1754,8 @@ export class AgentManager {
|
|
|
1753
1754
|
}
|
|
1754
1755
|
}
|
|
1755
1756
|
}
|
|
1756
|
-
//
|
|
1757
|
-
// 只对 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
|
|
1758
1759
|
// prompt 里是 optional, 报警会让所有 in_progress task 噪音化。
|
|
1759
1760
|
// expectedKinds 必须覆盖 dispatch 时实际 set up 的 kind 集,否则真信号无法匹配。
|
|
1760
1761
|
async setupRecoveredSpecSignals() {
|
|
@@ -1769,23 +1770,25 @@ export class AgentManager {
|
|
|
1769
1770
|
continue;
|
|
1770
1771
|
const { expectedKinds, agentId } = mapped;
|
|
1771
1772
|
// Only spec verdict / spec-fixed / PR verdict warrant an intervention —
|
|
1772
|
-
// optional kinds (spec-
|
|
1773
|
+
// optional kinds (spec-done, pr-created in develop) would spam every
|
|
1773
1774
|
// in_progress task on restart.
|
|
1774
|
-
const interventionKindLabel = task.phase === 'spec' && task.status === 'review' ? 'spec-
|
|
1775
|
+
const interventionKindLabel = task.phase === 'spec' && task.status === 'review' ? 'spec-reviewed'
|
|
1775
1776
|
: task.phase === 'spec' && task.status === 'fixing' ? 'spec-fixed'
|
|
1776
1777
|
: task.phase !== 'spec' && task.status === 'review' ? 'pr-approved|pr-changes-requested'
|
|
1777
1778
|
: task.phase !== 'spec' && task.status === 'fixing' ? 'pr-fixed'
|
|
1778
1779
|
: undefined;
|
|
1779
|
-
//
|
|
1780
|
-
//
|
|
1781
|
-
//
|
|
1782
|
-
// fixing)
|
|
1783
|
-
//
|
|
1784
|
-
//
|
|
1785
|
-
//
|
|
1786
|
-
//
|
|
1787
|
-
// replay-safe
|
|
1788
|
-
const
|
|
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')
|
|
1789
1792
|
|| (task.phase !== 'spec' && (task.status === 'review' || task.status === 'fixing'));
|
|
1790
1793
|
try {
|
|
1791
1794
|
await this.phaseSignalWatcher.start({
|
|
@@ -1795,9 +1798,8 @@ export class AgentManager {
|
|
|
1795
1798
|
expectedKinds,
|
|
1796
1799
|
token: task.signalToken,
|
|
1797
1800
|
skipSnapshot: !scanSnapshotOnRecover,
|
|
1798
|
-
reviewMode: task.reviewMode ?? 'github',
|
|
1799
1801
|
recovered: true,
|
|
1800
|
-
...(
|
|
1802
|
+
...(isServerProtocol && task.status === 'review'
|
|
1801
1803
|
? { onReadFile: (req) => { void this.handleReadFileRequest(task.id, agentId, req); } }
|
|
1802
1804
|
: {}),
|
|
1803
1805
|
});
|
|
@@ -2253,7 +2255,7 @@ export class AgentManager {
|
|
|
2253
2255
|
return null;
|
|
2254
2256
|
}
|
|
2255
2257
|
// 后台路径吞掉 reject(void start.catch):arm 抛异常时也要显式 hold agent,否则会留下一个没有
|
|
2256
|
-
// spec-
|
|
2258
|
+
// spec-done/pr-created 消费者的常驻任务(同步 caller 仍由上游收到异常)。
|
|
2257
2259
|
// Kinds derive from the task's frozen reviewMode — a hot mode flip during the
|
|
2258
2260
|
// startSession window must not desync the armed kinds from the sent prompt.
|
|
2259
2261
|
const initialKinds = this.devInitialSignalKinds(fresh.reviewMode);
|
|
@@ -2301,12 +2303,83 @@ export class AgentManager {
|
|
|
2301
2303
|
const paneId = state?.paneId;
|
|
2302
2304
|
if (!paneId)
|
|
2303
2305
|
throw new ApiError(409, `Agent ${agentId} has no live session`);
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
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
|
+
}
|
|
2310
2383
|
}
|
|
2311
2384
|
async persistTaskImages(taskId, images) {
|
|
2312
2385
|
const dir = join(this.imageStagingRoot, taskId);
|
|
@@ -2358,12 +2431,10 @@ export class AgentManager {
|
|
|
2358
2431
|
}
|
|
2359
2432
|
return hostPaths;
|
|
2360
2433
|
}
|
|
2361
|
-
// Dev-facing deliverable phases
|
|
2362
|
-
//
|
|
2363
|
-
//
|
|
2364
|
-
//
|
|
2365
|
-
// QA phases (review/recheck/spec-review) and post-approve (feedback-processing; if it needs
|
|
2366
|
-
// 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)。
|
|
2367
2438
|
async imagePathsForDispatch(runner, task, phase) {
|
|
2368
2439
|
if (!IMAGE_DISPATCH_PHASES.has(phase))
|
|
2369
2440
|
return [];
|
|
@@ -2552,7 +2623,7 @@ export class AgentManager {
|
|
|
2552
2623
|
const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
|
|
2553
2624
|
const worktreePath = isServerQaPhase
|
|
2554
2625
|
? await worktree.createDetachedAtBase(workdir, taskId)
|
|
2555
|
-
: phase === 'review' || phase === 'recheck'
|
|
2626
|
+
: phase === 'review' || phase === 'recheck'
|
|
2556
2627
|
? await worktree.createDetached(workdir, taskId, task.branch)
|
|
2557
2628
|
: await worktree.create(workdir, taskId, baseRef);
|
|
2558
2629
|
// Persist worktreePath now so a crash before set-running leaves a recoverable trail.
|
|
@@ -2578,6 +2649,8 @@ export class AgentManager {
|
|
|
2578
2649
|
const reuseInjectedSkills = ensure.freshRuntime
|
|
2579
2650
|
? null
|
|
2580
2651
|
: reuseSkillsIfContextValid(beforeInjectAgent, taskId, paneId);
|
|
2652
|
+
// develop prompt 按 QA 有无裁剪 spec 路线(qaAgentId 快照优先,与 review 派发同一解析)。
|
|
2653
|
+
const hasQaPartner = !!(task.qaAgentId ?? this.findQaPartner(agentId)?.id);
|
|
2581
2654
|
let prompt;
|
|
2582
2655
|
try {
|
|
2583
2656
|
const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
|
|
@@ -2587,9 +2660,9 @@ export class AgentManager {
|
|
|
2587
2660
|
agent,
|
|
2588
2661
|
worktreePath,
|
|
2589
2662
|
skillRegistry: this.skillRegistry,
|
|
2663
|
+
hasQaPartner,
|
|
2590
2664
|
...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
|
|
2591
2665
|
...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
|
|
2592
|
-
...(opts.specFindings ? { specFindings: opts.specFindings } : {}),
|
|
2593
2666
|
...(reuseInjectedSkills ? { excludeSkills: reuseInjectedSkills } : {}),
|
|
2594
2667
|
...(imagePaths.length ? { imagePaths } : {}),
|
|
2595
2668
|
...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
|
|
@@ -2762,7 +2835,27 @@ export class AgentManager {
|
|
|
2762
2835
|
throw err;
|
|
2763
2836
|
}
|
|
2764
2837
|
}
|
|
2765
|
-
|
|
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) {
|
|
2766
2859
|
await tmux.injectPrompt(paneId, prompt, agentId);
|
|
2767
2860
|
let baseline;
|
|
2768
2861
|
try {
|
|
@@ -2948,7 +3041,6 @@ export class AgentManager {
|
|
|
2948
3041
|
? { postApproveRedispatchCount: opts.postApproveRedispatchCount }
|
|
2949
3042
|
: {}),
|
|
2950
3043
|
...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
|
|
2951
|
-
...(opts.specFindings ? { specFindings: opts.specFindings } : {}),
|
|
2952
3044
|
...(reuseInjectedSkills ? { excludeSkills: reuseInjectedSkills } : {}),
|
|
2953
3045
|
...(imagePaths.length ? { imagePaths } : {}),
|
|
2954
3046
|
...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
|
|
@@ -3937,7 +4029,7 @@ export class AgentManager {
|
|
|
3937
4029
|
}
|
|
3938
4030
|
// General case: rolled-back task may still want a watcher matching its
|
|
3939
4031
|
// current (restored) state — develop dispatch still waiting on
|
|
3940
|
-
// spec-
|
|
4032
|
+
// spec-done/pr-created, recheck still waiting on verdict, etc.
|
|
3941
4033
|
const restored = await this.taskStore.get(taskId);
|
|
3942
4034
|
if (!restored || !restored.signalToken)
|
|
3943
4035
|
return;
|
|
@@ -3966,13 +4058,13 @@ export class AgentManager {
|
|
|
3966
4058
|
const mode = reviewMode ?? this.config.review.mode ?? 'github';
|
|
3967
4059
|
return mode === 'server'
|
|
3968
4060
|
? ['spec-done', 'code-done']
|
|
3969
|
-
: ['spec-
|
|
4061
|
+
: ['spec-done', 'pr-created'];
|
|
3970
4062
|
}
|
|
3971
4063
|
mapTaskStateToExpectedWatcher(task) {
|
|
3972
4064
|
if (task.reviewMode === 'server')
|
|
3973
4065
|
return this.mapServerTaskToExpectedWatcher(task);
|
|
3974
4066
|
if (task.phase === 'spec' && task.status === 'review' && task.qaAgentId) {
|
|
3975
|
-
return { expectedKinds: ['spec-
|
|
4067
|
+
return { expectedKinds: ['spec-reviewed'], agentId: task.qaAgentId };
|
|
3976
4068
|
}
|
|
3977
4069
|
if (task.phase === 'spec' && task.status === 'fixing' && task.agentId) {
|
|
3978
4070
|
return { expectedKinds: ['spec-fixed'], agentId: task.agentId };
|
|
@@ -3982,7 +4074,7 @@ export class AgentManager {
|
|
|
3982
4074
|
return { expectedKinds: ['pr-fixed'], agentId: task.agentId };
|
|
3983
4075
|
}
|
|
3984
4076
|
if (task.phase === undefined && task.status === 'in_progress' && task.agentId) {
|
|
3985
|
-
return { expectedKinds: ['spec-
|
|
4077
|
+
return { expectedKinds: ['spec-done', 'pr-created'], agentId: task.agentId };
|
|
3986
4078
|
}
|
|
3987
4079
|
if (task.phase === 'code' && task.status === 'in_progress' && task.agentId) {
|
|
3988
4080
|
return { expectedKinds: ['pr-created'], agentId: task.agentId };
|
|
@@ -4416,6 +4508,28 @@ export class AgentManager {
|
|
|
4416
4508
|
}
|
|
4417
4509
|
}
|
|
4418
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) {
|
|
4419
4533
|
const bindingStillOurs = async () => {
|
|
4420
4534
|
const s = await this.agentStore.get(agentId);
|
|
4421
4535
|
return !!s && s.taskId === originalTaskId && s.paneId === paneId;
|
|
@@ -4488,12 +4602,6 @@ export class AgentManager {
|
|
|
4488
4602
|
stopPhaseSignalWatcher(taskId) {
|
|
4489
4603
|
this.phaseSignalWatcher?.stop(taskId);
|
|
4490
4604
|
}
|
|
4491
|
-
// Backwards-compat alias for spec-only call sites (recovery, transitions
|
|
4492
|
-
// that already named the kind). New callers should use setupPhaseSignalWatcher
|
|
4493
|
-
// directly with the right expectedKinds.
|
|
4494
|
-
stopSpecSignalWatcher(taskId) {
|
|
4495
|
-
this.stopPhaseSignalWatcher(taskId);
|
|
4496
|
-
}
|
|
4497
4605
|
// Prompt build (via task.signalToken) and watcher must share the same token.
|
|
4498
4606
|
// Returns whether dispatch may safely proceed. False ONLY when a configured watcher failed
|
|
4499
4607
|
// to arm — the dangerous case where a same-identity verdict would have no consumer. When no
|
|
@@ -4513,9 +4621,6 @@ export class AgentManager {
|
|
|
4513
4621
|
expectedKinds,
|
|
4514
4622
|
token,
|
|
4515
4623
|
skipSnapshot,
|
|
4516
|
-
// Mode routing rides every arm automatically: the snapshot lives on the
|
|
4517
|
-
// task, so github-mode tasks keep their legacy event types untouched.
|
|
4518
|
-
reviewMode: task.reviewMode ?? 'github',
|
|
4519
4624
|
...(onReadFile ? { onReadFile } : {}),
|
|
4520
4625
|
});
|
|
4521
4626
|
}
|
|
@@ -4580,358 +4685,6 @@ export class AgentManager {
|
|
|
4580
4685
|
if (mapped)
|
|
4581
4686
|
await this.setupPhaseSignal(taskId, mapped.agentId, mapped.expectedKinds);
|
|
4582
4687
|
}
|
|
4583
|
-
async readSpecReviewFile(taskId, fileName) {
|
|
4584
|
-
const task = await this.taskStore.get(taskId);
|
|
4585
|
-
if (!task)
|
|
4586
|
-
return null;
|
|
4587
|
-
if (!task.branch) {
|
|
4588
|
-
throw new Error(`readSpecReviewFile: task ${taskId} has no branch`);
|
|
4589
|
-
}
|
|
4590
|
-
const project = this.getProjectConfig(task.projectId);
|
|
4591
|
-
if (!project) {
|
|
4592
|
-
throw new Error(`readSpecReviewFile: unknown project ${task.projectId}`);
|
|
4593
|
-
}
|
|
4594
|
-
const dev = this.getAgentConfig(task.agentId);
|
|
4595
|
-
if (!dev) {
|
|
4596
|
-
throw new Error(`readSpecReviewFile: task ${taskId} has no dev agent bound`);
|
|
4597
|
-
}
|
|
4598
|
-
const runner = this.createRunnerFor(dev);
|
|
4599
|
-
const store = this.createRepoStore(dev, project, runner);
|
|
4600
|
-
const workdir = await this.resolveWorkdir(dev, await this.agentStore.get(dev.id))
|
|
4601
|
-
?? await store.ensure();
|
|
4602
|
-
const filePath = `.baxian/spec-review/${fileName}`;
|
|
4603
|
-
return store.readFileFromBranch(workdir, task.branch, filePath);
|
|
4604
|
-
}
|
|
4605
|
-
async dispatchSpecReviewToQa(taskId) {
|
|
4606
|
-
// Phase 1 (lock): validate + decide qa + compute newToken/newRound (无 mutation, 无 park)。
|
|
4607
|
-
// 关键约束:task 不能在 startSession 之前被改 — startSession 内部调用
|
|
4608
|
-
// buildPromptInline,prompt 必须看到的是新 token 和新 round;这里只 *计算*,
|
|
4609
|
-
// 真正写回 task 放到 Phase 3。
|
|
4610
|
-
const claim = await this.withTaskLock(async () => {
|
|
4611
|
-
const task = await this.taskStore.get(taskId);
|
|
4612
|
-
if (!task)
|
|
4613
|
-
throw new Error(`dispatchSpecReviewToQa: task ${taskId} not found`);
|
|
4614
|
-
if (!task.branch)
|
|
4615
|
-
throw new Error(`dispatchSpecReviewToQa: task ${taskId} has no branch`);
|
|
4616
|
-
// Stale spec-created guard: 一旦 task 离开 pre-spec 阶段 (phase='code' 或其他
|
|
4617
|
-
// 非 'spec'/undefined 值),迟到的 spec-created signal 不应再 dispatch review。
|
|
4618
|
-
// 允许 phase==='spec' 是预留 dev 在 fix-complete 后再 emit spec-created 的扩展点。
|
|
4619
|
-
if (task.phase !== undefined && task.phase !== 'spec') {
|
|
4620
|
-
await this.safeEmit({
|
|
4621
|
-
id: '',
|
|
4622
|
-
type: 'human.intervention',
|
|
4623
|
-
timestamp: new Date().toISOString(),
|
|
4624
|
-
projectId: task.projectId,
|
|
4625
|
-
agentId: task.agentId,
|
|
4626
|
-
taskId,
|
|
4627
|
-
data: { phase: 'spec-created-stale-after-code', taskPhase: task.phase },
|
|
4628
|
-
});
|
|
4629
|
-
return null;
|
|
4630
|
-
}
|
|
4631
|
-
const qa = this.findQaPartner(task.agentId);
|
|
4632
|
-
if (!qa) {
|
|
4633
|
-
await this.safeEmit({
|
|
4634
|
-
id: '',
|
|
4635
|
-
type: 'human.intervention',
|
|
4636
|
-
timestamp: new Date().toISOString(),
|
|
4637
|
-
projectId: task.projectId,
|
|
4638
|
-
agentId: task.agentId,
|
|
4639
|
-
taskId,
|
|
4640
|
-
data: { phase: 'spec-review-no-qa-partner', devAgentId: task.agentId },
|
|
4641
|
-
});
|
|
4642
|
-
return null;
|
|
4643
|
-
}
|
|
4644
|
-
// 记录入口 status — fix-then-review 重派 (fromStatus 含 'fixing') 时,
|
|
4645
|
-
// spawn 失败 rollback 不能无差别回 in_progress;必须回到原 status 以保留 spec phase。
|
|
4646
|
-
// transitionTaskStatus 的 fromStatus 守门已限定为这三种之一; 其他 status 不会走到这里。
|
|
4647
|
-
const isReviewEntry = task.status === 'in_progress'
|
|
4648
|
-
|| task.status === 'fixing'
|
|
4649
|
-
|| task.status === 'pending';
|
|
4650
|
-
if (!isReviewEntry)
|
|
4651
|
-
return null;
|
|
4652
|
-
return {
|
|
4653
|
-
qaId: qa.id,
|
|
4654
|
-
devAgentId: task.agentId,
|
|
4655
|
-
projectId: task.projectId,
|
|
4656
|
-
newToken: createSignalToken(),
|
|
4657
|
-
newRound: (task.specReviewRound ?? 0) + 1,
|
|
4658
|
-
originalStatus: task.status,
|
|
4659
|
-
// 记录原 spec-created token — pre-spec entry rollback 时 restore,
|
|
4660
|
-
// 让 dev 后续 spec-created signal (with 原 token) 经 handler freshness gate 通过 → auto retry。
|
|
4661
|
-
originalToken: task.signalToken,
|
|
4662
|
-
// 回滚时 restore — round 是 "已完成轮次" 计数, 累计失败不应吃 round 配额。
|
|
4663
|
-
originalRound: task.specReviewRound,
|
|
4664
|
-
};
|
|
4665
|
-
});
|
|
4666
|
-
if (!claim)
|
|
4667
|
-
return null;
|
|
4668
|
-
const { qaId, devAgentId, projectId, newToken, newRound, originalStatus, originalToken, originalRound } = claim;
|
|
4669
|
-
// Phase 2a: 先 acquire QA — 失败时 dev 还未 park,直接 return 即可。
|
|
4670
|
-
const acquired = await this.acquireAgentForTask(qaId, taskId, 'spec-review');
|
|
4671
|
-
if (!acquired) {
|
|
4672
|
-
await this.safeEmit({
|
|
4673
|
-
id: '',
|
|
4674
|
-
type: 'human.intervention',
|
|
4675
|
-
timestamp: new Date().toISOString(),
|
|
4676
|
-
projectId,
|
|
4677
|
-
agentId: qaId,
|
|
4678
|
-
taskId,
|
|
4679
|
-
data: { phase: 'spec-review-qa-acquire-failed', qaAgentId: qaId },
|
|
4680
|
-
});
|
|
4681
|
-
return null;
|
|
4682
|
-
}
|
|
4683
|
-
// Phase 2b: dev gate — park dev so it stops editing the spec while QA reviews。
|
|
4684
|
-
// 顺序在 acquireQA 之后:避免 QA 失败时 dev 已 parked 但 task 仍 in_progress,
|
|
4685
|
-
// 无任何后续 dispatch 把 dev 拉出 waiting (即 dev 永久挂起)。
|
|
4686
|
-
if (devAgentId) {
|
|
4687
|
-
const devOk = await this.markAgentWaiting(devAgentId, taskId);
|
|
4688
|
-
if (!devOk) {
|
|
4689
|
-
await this.releaseAgentForTask(qaId, taskId, 'idle')
|
|
4690
|
-
.catch(() => undefined);
|
|
4691
|
-
await this.safeEmit({
|
|
4692
|
-
id: '',
|
|
4693
|
-
type: 'human.intervention',
|
|
4694
|
-
timestamp: new Date().toISOString(),
|
|
4695
|
-
projectId,
|
|
4696
|
-
agentId: devAgentId,
|
|
4697
|
-
taskId,
|
|
4698
|
-
data: { phase: 'spec-review-dev-park-failed', devAgentId },
|
|
4699
|
-
});
|
|
4700
|
-
return null;
|
|
4701
|
-
}
|
|
4702
|
-
}
|
|
4703
|
-
// Phase 2c (lock): atomic transition + persist newToken/newRound/phase/qaAgentId.
|
|
4704
|
-
// 必须在 startSession 之前;若顺序反过来,startSession 之后崩溃但 transition 没做时,
|
|
4705
|
-
// setupRecoveredSpecSignals 会读旧 status/token 推断错 kind/token,新 signal 无法匹配 → 链路死。
|
|
4706
|
-
const transition = await this.transitionTaskStatus(taskId, 'review', { fromStatus: ['in_progress', 'fixing', 'pending'] }, {
|
|
4707
|
-
specReviewRound: newRound,
|
|
4708
|
-
signalToken: newToken,
|
|
4709
|
-
phase: 'spec',
|
|
4710
|
-
qaAgentId: qaId,
|
|
4711
|
-
});
|
|
4712
|
-
if (!transition) {
|
|
4713
|
-
await this.releaseAgentForTask(qaId, taskId, 'idle')
|
|
4714
|
-
.catch(() => undefined);
|
|
4715
|
-
// 不 re-acquire dev: markAgentWaiting (mode='waiting') 仅 bump updatedAt,
|
|
4716
|
-
// dev 仍 bound 到 task; develop phase 不在 reentry 集合, 重 acquire 必返回 false (dead code)。
|
|
4717
|
-
await this.safeEmit({
|
|
4718
|
-
id: '',
|
|
4719
|
-
type: 'human.intervention',
|
|
4720
|
-
timestamp: new Date().toISOString(),
|
|
4721
|
-
projectId,
|
|
4722
|
-
agentId: qaId,
|
|
4723
|
-
taskId,
|
|
4724
|
-
data: { phase: 'spec-review-transition-failed', qaAgentId: qaId },
|
|
4725
|
-
});
|
|
4726
|
-
return null;
|
|
4727
|
-
}
|
|
4728
|
-
// Phase 2d: startSession 用显式 newToken/newRound 透传到 prompt。
|
|
4729
|
-
// 失败时回滚 transition + 清新 persist 字段,避免 task 留在 review 但 qa 无 session 的 stuck。
|
|
4730
|
-
// 不调 acquireAgentForTask(dev, 'develop'):markAgentWaiting 走 mode='waiting' 仅 bump updatedAt
|
|
4731
|
-
// (不清 binding 也不真正 park REPL),dev 仍 bound 到 task;且 develop phase 不在
|
|
4732
|
-
// canDispatchWithBinding 的 reentry 集合,重 acquire 必返回 false — 是 dead code。
|
|
4733
|
-
let started = false;
|
|
4734
|
-
try {
|
|
4735
|
-
started = await this.startSession(taskId, qaId, 'spec-review', {
|
|
4736
|
-
bypassTaskStatusGate: true,
|
|
4737
|
-
signalToken: newToken,
|
|
4738
|
-
currentSpecRound: newRound,
|
|
4739
|
-
});
|
|
4740
|
-
}
|
|
4741
|
-
catch (err) {
|
|
4742
|
-
// DispatchTerminalError 都委托给 failTaskForDispatchError:ack_unknown 会保留绑定走
|
|
4743
|
-
// markAwaitingHuman,其他 reason(prompt_too_large 等非 transient)让 task 进 failed
|
|
4744
|
-
// 而不是 rollback 让 cron 反复 retry。其他异常(瞬时 / 不明)才走 rollback + release。
|
|
4745
|
-
if (err instanceof DispatchTerminalError) {
|
|
4746
|
-
await this.failTaskForDispatchError(taskId, 'spec-review', qaId, err);
|
|
4747
|
-
}
|
|
4748
|
-
else if (err instanceof EnsureSessionError && err.partial.handled) {
|
|
4749
|
-
// handleDialogPendingFromRuntime 已 Held + fail task + release partners;跳过 rollback + release,
|
|
4750
|
-
// 否则 boundTask terminal 让 release gate 放行清掉仍卡 dialog 的 pane lock。
|
|
4751
|
-
}
|
|
4752
|
-
else {
|
|
4753
|
-
await this.rollbackSpecReviewTransition(taskId, originalStatus, originalToken, originalRound);
|
|
4754
|
-
await this.releaseAgentForTask(qaId, taskId, 'idle')
|
|
4755
|
-
.catch(() => undefined);
|
|
4756
|
-
}
|
|
4757
|
-
throw err;
|
|
4758
|
-
}
|
|
4759
|
-
if (!started) {
|
|
4760
|
-
await this.rollbackSpecReviewTransition(taskId, originalStatus, originalToken, originalRound);
|
|
4761
|
-
await this.releaseAgentForTask(qaId, taskId, 'idle')
|
|
4762
|
-
.catch(() => undefined);
|
|
4763
|
-
await this.safeEmit({
|
|
4764
|
-
id: '',
|
|
4765
|
-
type: 'human.intervention',
|
|
4766
|
-
timestamp: new Date().toISOString(),
|
|
4767
|
-
projectId,
|
|
4768
|
-
agentId: qaId,
|
|
4769
|
-
taskId,
|
|
4770
|
-
data: { phase: 'spec-review-start-failed', qaAgentId: qaId },
|
|
4771
|
-
});
|
|
4772
|
-
return null;
|
|
4773
|
-
}
|
|
4774
|
-
// Phase 3: set up watcher。spec-created 已被消费,先 tear down 防止 dev 之后无关 signal 误触发。
|
|
4775
|
-
// QA echoes exactly one verdict signal — set up both kinds, the first match wins.
|
|
4776
|
-
this.stopSpecSignalWatcher(taskId);
|
|
4777
|
-
await this.armPostDispatchSignalOrHold(taskId, qaId, ['spec-approved', 'spec-changes-requested'], newToken);
|
|
4778
|
-
return await this.taskStore.get(taskId);
|
|
4779
|
-
}
|
|
4780
|
-
// startSession 失败回滚:
|
|
4781
|
-
// - pre-spec entry: restore originalToken 让 dev 后续 spec-created signal 经 freshness gate 通过 → auto retry。
|
|
4782
|
-
// - fixing entry: 保留 phase='spec' + qaAgentId(否则 spec.* freshness gate 全 fail),清 token 防 stale。
|
|
4783
|
-
// round 必须 restore — round 是 "已完成轮次" 计数, 累计失败不应吃 round 配额。
|
|
4784
|
-
async rollbackSpecReviewTransition(taskId, originalStatus, originalToken, originalRound) {
|
|
4785
|
-
if (originalStatus === 'fixing') {
|
|
4786
|
-
await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review'] }, { signalToken: undefined, specReviewRound: originalRound });
|
|
4787
|
-
return;
|
|
4788
|
-
}
|
|
4789
|
-
await this.transitionTaskStatus(taskId, originalStatus, { fromStatus: ['review'] }, {
|
|
4790
|
-
signalToken: originalToken,
|
|
4791
|
-
phase: undefined,
|
|
4792
|
-
qaAgentId: undefined,
|
|
4793
|
-
specReviewRound: originalRound,
|
|
4794
|
-
});
|
|
4795
|
-
}
|
|
4796
|
-
async dispatchSpecFixToDev(taskId, findings) {
|
|
4797
|
-
// Phase 1 (lock): validate + phase guard + decide newToken。
|
|
4798
|
-
// fix 是同 round 的 dev 处理 QA findings,round 不递增;只刷新 token 让 prompt + watcher 唯一识别本轮 fix。
|
|
4799
|
-
const claim = await this.withTaskLock(async () => {
|
|
4800
|
-
const task = await this.taskStore.get(taskId);
|
|
4801
|
-
if (!task)
|
|
4802
|
-
throw new Error(`dispatchSpecFixToDev: task ${taskId} not found`);
|
|
4803
|
-
const devAgentId = task.agentId;
|
|
4804
|
-
if (!devAgentId) {
|
|
4805
|
-
throw new Error(`dispatchSpecFixToDev: task ${taskId} has no dev agent`);
|
|
4806
|
-
}
|
|
4807
|
-
// 离开 spec 阶段的 task 不应再被 spec-fix dispatch 击中 (defense in depth — handler 也 gate)。
|
|
4808
|
-
if (task.phase !== 'spec') {
|
|
4809
|
-
await this.safeEmit({
|
|
4810
|
-
id: '',
|
|
4811
|
-
type: 'human.intervention',
|
|
4812
|
-
timestamp: new Date().toISOString(),
|
|
4813
|
-
projectId: task.projectId,
|
|
4814
|
-
agentId: devAgentId,
|
|
4815
|
-
taskId,
|
|
4816
|
-
data: { phase: 'spec-fix-stale-phase', taskPhase: task.phase },
|
|
4817
|
-
});
|
|
4818
|
-
return null;
|
|
4819
|
-
}
|
|
4820
|
-
return {
|
|
4821
|
-
devAgentId,
|
|
4822
|
-
qaAgentId: task.qaAgentId,
|
|
4823
|
-
projectId: task.projectId,
|
|
4824
|
-
newToken: createSignalToken(),
|
|
4825
|
-
currentRound: task.specReviewRound ?? 1,
|
|
4826
|
-
};
|
|
4827
|
-
});
|
|
4828
|
-
if (!claim)
|
|
4829
|
-
return null;
|
|
4830
|
-
const { devAgentId, qaAgentId, projectId, newToken, currentRound } = claim;
|
|
4831
|
-
if (qaAgentId) {
|
|
4832
|
-
// release 失败留 stale qa binding,下一轮 acquireAgentForTask(qa) 必拒;abort + emit intervention。
|
|
4833
|
-
const released = await this.releaseAgentForTask(qaAgentId, taskId, 'idle')
|
|
4834
|
-
.catch(err => {
|
|
4835
|
-
console.warn(`[AgentManager] dispatchSpecFixToDev release qa=${qaAgentId} failed:`, err);
|
|
4836
|
-
return false;
|
|
4837
|
-
});
|
|
4838
|
-
if (!released) {
|
|
4839
|
-
await this.safeEmit({
|
|
4840
|
-
id: '',
|
|
4841
|
-
type: 'human.intervention',
|
|
4842
|
-
timestamp: new Date().toISOString(),
|
|
4843
|
-
projectId,
|
|
4844
|
-
agentId: qaAgentId,
|
|
4845
|
-
taskId,
|
|
4846
|
-
data: { phase: 'spec-fix-qa-release-failed', qaAgentId },
|
|
4847
|
-
});
|
|
4848
|
-
return null;
|
|
4849
|
-
}
|
|
4850
|
-
}
|
|
4851
|
-
// Phase 2a: acquire dev。
|
|
4852
|
-
const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'spec-fix');
|
|
4853
|
-
if (!acquired) {
|
|
4854
|
-
await this.safeEmit({
|
|
4855
|
-
id: '',
|
|
4856
|
-
type: 'human.intervention',
|
|
4857
|
-
timestamp: new Date().toISOString(),
|
|
4858
|
-
projectId,
|
|
4859
|
-
agentId: devAgentId,
|
|
4860
|
-
taskId,
|
|
4861
|
-
data: { phase: 'spec-fix-dev-acquire-failed', devAgentId },
|
|
4862
|
-
});
|
|
4863
|
-
return null;
|
|
4864
|
-
}
|
|
4865
|
-
// Phase 2b (lock): atomic transition + persist newToken/phase。
|
|
4866
|
-
// 必须在 continueSession 之前;否则崩溃后 setupRecoveredSpecSignals 读旧 token,
|
|
4867
|
-
// 与 dev 输出的 newToken signal 不匹配 → 链路死。
|
|
4868
|
-
const transition = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review'] }, { signalToken: newToken, phase: 'spec' });
|
|
4869
|
-
if (!transition) {
|
|
4870
|
-
await this.releaseAgentForTask(devAgentId, taskId, 'idle')
|
|
4871
|
-
.catch(() => undefined);
|
|
4872
|
-
await this.safeEmit({
|
|
4873
|
-
id: '',
|
|
4874
|
-
type: 'human.intervention',
|
|
4875
|
-
timestamp: new Date().toISOString(),
|
|
4876
|
-
projectId,
|
|
4877
|
-
agentId: devAgentId,
|
|
4878
|
-
taskId,
|
|
4879
|
-
data: { phase: 'spec-fix-transition-failed', devAgentId },
|
|
4880
|
-
});
|
|
4881
|
-
return null;
|
|
4882
|
-
}
|
|
4883
|
-
// Phase 2c: continueSession 透传 newToken + currentRound 给 prompt。
|
|
4884
|
-
// 失败时回滚 transition + 清新 token,避免 task 留在 fixing 但 dev 无 spec-fix prompt 的 stuck。
|
|
4885
|
-
let resumed = false;
|
|
4886
|
-
try {
|
|
4887
|
-
resumed = await this.continueSession(taskId, devAgentId, 'spec-fix', {
|
|
4888
|
-
specFindings: findings,
|
|
4889
|
-
signalToken: newToken,
|
|
4890
|
-
currentSpecRound: currentRound,
|
|
4891
|
-
bypassTaskStatusGate: true,
|
|
4892
|
-
});
|
|
4893
|
-
}
|
|
4894
|
-
catch (err) {
|
|
4895
|
-
// 同 spec-review:DispatchTerminalError 走 failTaskForDispatchError 统一处理
|
|
4896
|
-
// (ack_unknown → markAwaitingHuman,其他 reason → release + task failed)。
|
|
4897
|
-
if (err instanceof DispatchTerminalError) {
|
|
4898
|
-
await this.failTaskForDispatchError(taskId, 'spec-fix', devAgentId, err);
|
|
4899
|
-
}
|
|
4900
|
-
else if (err instanceof EnsureSessionError && err.partial.handled) {
|
|
4901
|
-
// handleDialogPendingFromRuntime 已 Held + fail task + release partners;跳过 rollback + release。
|
|
4902
|
-
}
|
|
4903
|
-
else {
|
|
4904
|
-
await this.rollbackSpecFixTransition(taskId);
|
|
4905
|
-
await this.releaseAgentForTask(devAgentId, taskId, 'idle')
|
|
4906
|
-
.catch(() => undefined);
|
|
4907
|
-
}
|
|
4908
|
-
console.error(`[AgentManager] dispatchSpecFixToDev continueSession(dev=${devAgentId}) failed:`, err);
|
|
4909
|
-
throw err;
|
|
4910
|
-
}
|
|
4911
|
-
if (!resumed) {
|
|
4912
|
-
await this.rollbackSpecFixTransition(taskId);
|
|
4913
|
-
await this.releaseAgentForTask(devAgentId, taskId, 'idle')
|
|
4914
|
-
.catch(() => undefined);
|
|
4915
|
-
await this.safeEmit({
|
|
4916
|
-
id: '',
|
|
4917
|
-
type: 'human.intervention',
|
|
4918
|
-
timestamp: new Date().toISOString(),
|
|
4919
|
-
projectId,
|
|
4920
|
-
agentId: devAgentId,
|
|
4921
|
-
taskId,
|
|
4922
|
-
data: { phase: 'spec-fix-resume-failed', devAgentId },
|
|
4923
|
-
});
|
|
4924
|
-
return null;
|
|
4925
|
-
}
|
|
4926
|
-
// Phase 3: set up watcher。
|
|
4927
|
-
await this.armPostDispatchSignalOrHold(taskId, devAgentId, 'spec-fixed', newToken);
|
|
4928
|
-
return await this.taskStore.get(taskId);
|
|
4929
|
-
}
|
|
4930
|
-
// continueSession 失败回滚:fixing → review + 清新 token。
|
|
4931
|
-
// 保留 phase='spec' 与 qaAgentId — 失败后 review 状态需要人工 retry 或重新 dispatch。
|
|
4932
|
-
async rollbackSpecFixTransition(taskId) {
|
|
4933
|
-
await this.transitionTaskStatus(taskId, 'review', { fromStatus: ['fixing'] }, { signalToken: undefined });
|
|
4934
|
-
}
|
|
4935
4688
|
async transitionToCodePhase(taskId) {
|
|
4936
4689
|
const task = await this.taskStore.get(taskId);
|
|
4937
4690
|
if (!task)
|
|
@@ -5000,7 +4753,7 @@ export class AgentManager {
|
|
|
5000
4753
|
resumed = await this.continueSession(taskId, devAgentId, 'code');
|
|
5001
4754
|
}
|
|
5002
4755
|
catch (err) {
|
|
5003
|
-
// 同
|
|
4756
|
+
// 同 dispatchServerReviewToQa/dispatchServerFixToDev:DispatchTerminalError 委托给 failTaskForDispatchError
|
|
5004
4757
|
// (ack_unknown → markAwaitingHuman;其他 reason → release + task failed)。
|
|
5005
4758
|
if (err instanceof DispatchTerminalError) {
|
|
5006
4759
|
await this.failTaskForDispatchError(taskId, 'code', devAgentId, err);
|
|
@@ -5038,7 +4791,8 @@ export class AgentManager {
|
|
|
5038
4791
|
const task = await this.taskStore.get(taskId);
|
|
5039
4792
|
if (!task)
|
|
5040
4793
|
throw new Error(`dispatchServerReviewToQa: task ${taskId} not found`);
|
|
5041
|
-
|
|
4794
|
+
// spec 阶段恒为 server 中转;code 阶段仍 server-only。
|
|
4795
|
+
if (task.reviewMode !== 'server' && opts.phase !== 'spec') {
|
|
5042
4796
|
throw new Error(`dispatchServerReviewToQa: task ${taskId} is not in server review mode`);
|
|
5043
4797
|
}
|
|
5044
4798
|
const qaId = task.qaAgentId ?? this.findQaPartner(task.agentId)?.id;
|
|
@@ -5073,6 +4827,7 @@ export class AgentManager {
|
|
|
5073
4827
|
originalRound: roundField,
|
|
5074
4828
|
originalBatchIndex: task.batchIndex,
|
|
5075
4829
|
originalBatchTotal: task.batchTotal,
|
|
4830
|
+
originalPhase: task.phase,
|
|
5076
4831
|
};
|
|
5077
4832
|
});
|
|
5078
4833
|
if (!claim)
|
|
@@ -5085,6 +4840,9 @@ export class AgentManager {
|
|
|
5085
4840
|
signalToken: claim.originalToken,
|
|
5086
4841
|
batchIndex: claim.originalBatchIndex,
|
|
5087
4842
|
batchTotal: claim.originalBatchTotal,
|
|
4843
|
+
// spec transition 写入 phase:'spec';github 首轮失败若不还原,dev 直发
|
|
4844
|
+
// pr-created 会被 legacy freshness gate 拒(设计 §2)。
|
|
4845
|
+
phase: claim.originalPhase,
|
|
5088
4846
|
...(opts.phase === 'spec'
|
|
5089
4847
|
? { specReviewRound: claim.originalRound }
|
|
5090
4848
|
: { reviewRound: claim.originalRound }),
|
|
@@ -5234,7 +4992,7 @@ export class AgentManager {
|
|
|
5234
4992
|
const task = await this.taskStore.get(taskId);
|
|
5235
4993
|
if (!task)
|
|
5236
4994
|
throw new Error(`dispatchServerFixToDev: task ${taskId} not found`);
|
|
5237
|
-
if (task.reviewMode !== 'server') {
|
|
4995
|
+
if (task.reviewMode !== 'server' && task.phase !== 'spec') {
|
|
5238
4996
|
throw new Error(`dispatchServerFixToDev: task ${taskId} is not in server review mode`);
|
|
5239
4997
|
}
|
|
5240
4998
|
if (!task.agentId)
|
|
@@ -5470,24 +5228,34 @@ export class AgentManager {
|
|
|
5470
5228
|
return;
|
|
5471
5229
|
}
|
|
5472
5230
|
try {
|
|
5473
|
-
await this.injectTextToAgent(qaAgentId, body);
|
|
5231
|
+
await this.injectTextToAgent(qaAgentId, body, { expectedTaskId: taskId });
|
|
5474
5232
|
}
|
|
5475
5233
|
catch (err) {
|
|
5476
5234
|
console.warn(`[AgentManager] read-file injection to ${qaAgentId} failed:`, err);
|
|
5477
5235
|
}
|
|
5478
5236
|
}
|
|
5479
5237
|
// Plain text paste + submit into a live agent pane (no skills, no ack protocol).
|
|
5480
|
-
async injectTextToAgent(agentId, text) {
|
|
5238
|
+
async injectTextToAgent(agentId, text, opts = {}) {
|
|
5481
5239
|
const cfg = this.getAgentConfig(agentId);
|
|
5482
5240
|
if (!cfg)
|
|
5483
5241
|
throw new Error(`injectTextToAgent: unknown agent ${agentId}`);
|
|
5484
|
-
|
|
5485
|
-
|
|
5486
|
-
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
|
|
5490
|
-
|
|
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
|
+
}
|
|
5491
5259
|
}
|
|
5492
5260
|
// Human gate confirm (spec §10): executes the configured completion for
|
|
5493
5261
|
// ready (server mode) / merge-ready (github mode) tasks.
|