baxian 1.2.22 → 1.2.24
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 +3 -1
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +113 -191
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/prompt.d.ts +0 -7
- package/dist/agent/prompt.d.ts.map +1 -1
- package/dist/agent/prompt.js +5 -25
- package/dist/agent/prompt.js.map +1 -1
- package/dist/agent/tmux.d.ts +1 -0
- package/dist/agent/tmux.d.ts.map +1 -1
- package/dist/agent/tmux.js +42 -10
- package/dist/agent/tmux.js.map +1 -1
- package/dist/api/projects.js +1 -1
- package/dist/api/projects.js.map +1 -1
- package/dist/event/handlers.d.ts.map +1 -1
- package/dist/event/handlers.js +0 -1
- package/dist/event/handlers.js.map +1 -1
- package/dist/shared/types.d.ts +0 -6
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js.map +1 -1
- package/dist/state/agent-store.js +0 -14
- package/dist/state/agent-store.js.map +1 -1
- package/dist/web/assets/index-B3nBJsTG.js +4 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-C9dvXS8C.js +0 -4
package/dist/agent/manager.js
CHANGED
|
@@ -13,7 +13,7 @@ import { WorktreeManager } from './worktree.js';
|
|
|
13
13
|
import { RepoStore, createRepoStoreCache } from './repo-store.js';
|
|
14
14
|
import { PhaseSignalWatcher } from './phase-signal-watcher.js';
|
|
15
15
|
import { ReviewTransport } from './review-transport.js';
|
|
16
|
-
import { buildPromptInline, buildGreetingPrompt,
|
|
16
|
+
import { buildPromptInline, buildGreetingPrompt, PromptSizeError, RequiredSkillsMissingError, MAX_PROMPT_BYTES_ROUTE_LIMIT, } from './prompt.js';
|
|
17
17
|
import { ApiError } from '../errors.js';
|
|
18
18
|
import { prepareConfig } from '../config/loader.js';
|
|
19
19
|
export class EnsureSessionError extends Error {
|
|
@@ -53,7 +53,7 @@ export function buildLaunchCommand(agent) {
|
|
|
53
53
|
const segments = [];
|
|
54
54
|
switch (agent.runtime) {
|
|
55
55
|
case 'claude-code':
|
|
56
|
-
segments.push('env CLAUDE_CODE_NO_FLICKER=1 claude --permission-mode bypassPermissions');
|
|
56
|
+
segments.push('env CLAUDE_CODE_NO_FLICKER=1 CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY=1 claude --permission-mode bypassPermissions');
|
|
57
57
|
break;
|
|
58
58
|
case 'codex':
|
|
59
59
|
segments.push('codex --dangerously-bypass-approvals-and-sandbox');
|
|
@@ -137,16 +137,6 @@ export function shouldReleaseHeldBinding(state, boundTask) {
|
|
|
137
137
|
const turnCompleted = state.awaitingPhase != null && TURN_COMPLETED_AWAITING_PHASES.has(state.awaitingPhase);
|
|
138
138
|
return !boundTask || taskIsTerminal || turnCompleted;
|
|
139
139
|
}
|
|
140
|
-
// null 表示 context 已无效(换 task / 换 pane),调用方应回退到完整注入。
|
|
141
|
-
// 返回数组(含空数组)则代表当前 REPL session 已有的 skill 名集合,可作 excludeSkills 入参。
|
|
142
|
-
function reuseSkillsIfContextValid(state, taskId, paneId) {
|
|
143
|
-
const rec = state?.injectedSkills;
|
|
144
|
-
if (!rec)
|
|
145
|
-
return null;
|
|
146
|
-
if (rec.taskId !== taskId || rec.paneId !== paneId)
|
|
147
|
-
return null;
|
|
148
|
-
return rec.skills;
|
|
149
|
-
}
|
|
150
140
|
export class AgentManager {
|
|
151
141
|
config;
|
|
152
142
|
agentStore;
|
|
@@ -1297,7 +1287,7 @@ export class AgentManager {
|
|
|
1297
1287
|
await tmux.killSession(agentId).catch(() => { });
|
|
1298
1288
|
return this.buildFreshSession(tmux, agent, agentId, workdir);
|
|
1299
1289
|
}
|
|
1300
|
-
// 复用既有 REPL,上下文未中断——
|
|
1290
|
+
// 复用既有 REPL,上下文未中断——freshRuntime=false,post-approve 可走增量 nudge。
|
|
1301
1291
|
return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir };
|
|
1302
1292
|
}
|
|
1303
1293
|
case 'startup-dialog':
|
|
@@ -1598,16 +1588,17 @@ export class AgentManager {
|
|
|
1598
1588
|
const result = await this.withTaskLock(async () => {
|
|
1599
1589
|
const state = await this.agentStore.get(agentId);
|
|
1600
1590
|
if (!state)
|
|
1601
|
-
return { resumed: false, releasedBinding: false };
|
|
1591
|
+
return { resumed: false, releasedBinding: false, reason: 'Agent state not found.' };
|
|
1602
1592
|
if (state.status !== 'awaiting_human') {
|
|
1603
|
-
return { resumed: false, releasedBinding: false };
|
|
1593
|
+
return { resumed: false, releasedBinding: false, reason: 'Agent is not awaiting human; nothing to resume.' };
|
|
1604
1594
|
}
|
|
1605
1595
|
// creationToken 仍 set = bootstrap dialog 仍未解决。Resume 不能让它"继续"——
|
|
1606
1596
|
// dialog 在 pane 里需要 operator 通过 web terminal 处理,slowPoll 解决后自动清状态。
|
|
1607
1597
|
// 如果 operator 想放弃这个 agent,应该走 DELETE 路径。
|
|
1608
1598
|
if (state.creationToken) {
|
|
1609
|
-
|
|
1610
|
-
|
|
1599
|
+
const reason = 'Bootstrap dialog still unresolved; resolve it via the web terminal or DELETE the agent.';
|
|
1600
|
+
console.warn(`[AgentManager] resumeAgent: agent ${agentId} still has creationToken — ${reason}`);
|
|
1601
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
1611
1602
|
}
|
|
1612
1603
|
const boundTask = state.taskId ? await this.taskStore.get(state.taskId) : null;
|
|
1613
1604
|
// "prompt 可能仍在 pane 中跑"类 phase + bound task 仍 active 时 refuse:Resume 让
|
|
@@ -1623,8 +1614,9 @@ export class AgentManager {
|
|
|
1623
1614
|
if (state.awaitingPhase != null
|
|
1624
1615
|
&& PROMPT_MAYBE_RUNNING_PHASES.has(state.awaitingPhase)
|
|
1625
1616
|
&& boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)) {
|
|
1626
|
-
|
|
1627
|
-
|
|
1617
|
+
const reason = `Prompt may still be running (${state.awaitingPhase}); Resume is blocked until the task outcome arrives. Cancel task ${state.taskId} or DELETE the agent to recover.`;
|
|
1618
|
+
console.warn(`[AgentManager] resumeAgent: agent ${agentId} — ${reason}`);
|
|
1619
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
1628
1620
|
}
|
|
1629
1621
|
// agent_dialog_pending: pane 仍卡 startup dialog,REPL 未 ready。Resume 让
|
|
1630
1622
|
// shouldReleaseHeldBinding 看 task terminal/missing 放行后会清 binding/lock,下一次
|
|
@@ -1633,8 +1625,9 @@ export class AgentManager {
|
|
|
1633
1625
|
// agent_dialog_resolved_runtime(Resume 放行)或 bootstrap path 直接清 Held → status='ok',
|
|
1634
1626
|
// 或 DELETE agent。
|
|
1635
1627
|
if (state.awaitingPhase === 'agent_dialog_pending') {
|
|
1636
|
-
|
|
1637
|
-
|
|
1628
|
+
const reason = 'Startup dialog still pending; Resume cannot dismiss it. Dismiss the dialog via the web terminal (baxian will auto-resume) or DELETE the agent.';
|
|
1629
|
+
console.warn(`[AgentManager] resumeAgent: agent ${agentId} — ${reason}`);
|
|
1630
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
1638
1631
|
}
|
|
1639
1632
|
// agent_dialog_resolved_runtime + active task:正常路径下 handleDialogPendingFromRuntime
|
|
1640
1633
|
// 已 fail task → boundTask 应 terminal。bound task 仍 active 表示 crash window
|
|
@@ -1643,8 +1636,9 @@ export class AgentManager {
|
|
|
1643
1636
|
// refuse Resume,提示 operator 显式 cancel task 或 DELETE agent。
|
|
1644
1637
|
if (state.awaitingPhase === 'agent_dialog_resolved_runtime'
|
|
1645
1638
|
&& boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)) {
|
|
1646
|
-
|
|
1647
|
-
|
|
1639
|
+
const reason = `Dialog resolved but task ${state.taskId} is still active and its prompt was never injected; Resume would strand it. Cancel the task or DELETE the agent.`;
|
|
1640
|
+
console.warn(`[AgentManager] resumeAgent: agent ${agentId} — ${reason}`);
|
|
1641
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
1648
1642
|
}
|
|
1649
1643
|
// code-dispatch-failed: the code-phase prompt never reached the pane (spec
|
|
1650
1644
|
// approval already transitioned the task). Resume = clear the hold AND
|
|
@@ -1674,23 +1668,25 @@ export class AgentManager {
|
|
|
1674
1668
|
// Refuse while the task is active; operator must cancel the task or DELETE the agent to retry.
|
|
1675
1669
|
if (state.awaitingPhase?.startsWith('signal-arm-failed')
|
|
1676
1670
|
&& boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)) {
|
|
1677
|
-
|
|
1678
|
-
|
|
1671
|
+
const reason = `The dispatched prompt's pane signal has no consumer and Resume cannot rebuild the watcher; cancel task ${state.taskId} or DELETE the agent to retry.`;
|
|
1672
|
+
console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${state.awaitingPhase} — ${reason}`);
|
|
1673
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
1679
1674
|
}
|
|
1680
1675
|
// Un-cleared pane (cancel mid-clear or /clear unconfirmed): Resume would free + reuse it (terminal
|
|
1681
1676
|
// task → shouldReleaseHeldBinding) and leak the cancelled task's context. Refuse; only DELETE (which
|
|
1682
1677
|
// destroys the pane) is a safe recovery.
|
|
1683
1678
|
if (state.awaitingPhase != null && UNCLEARED_PANE_PHASES.has(state.awaitingPhase)) {
|
|
1684
|
-
|
|
1685
|
-
|
|
1679
|
+
const reason = 'The pane holds un-cleared context from a cancelled task; Resume would leak it into the next task. DELETE the agent to discard it.';
|
|
1680
|
+
console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${state.awaitingPhase} — ${reason}`);
|
|
1681
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
1686
1682
|
}
|
|
1687
1683
|
// A greeting capability failure must be RE-PROVEN, not Resumed away: the default path below
|
|
1688
1684
|
// flips status→'ok' regardless of shouldReleaseHeldBinding, which would put an unverified
|
|
1689
1685
|
// agent back in the dispatch pool. The recovery path is restart-repl / retry (re-greets).
|
|
1690
1686
|
if (state.awaitingPhase != null && REGREET_REQUIRED_HOLD_PHASES.has(state.awaitingPhase)) {
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
return { resumed: false, releasedBinding: false };
|
|
1687
|
+
const reason = 'Greeting capability check failed; the runtime must re-prove it. Resume cannot clear this hold — use Restart REPL to re-run the greeting check.';
|
|
1688
|
+
console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${state.awaitingPhase} — ${reason}`);
|
|
1689
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
1694
1690
|
}
|
|
1695
1691
|
const now = new Date().toISOString();
|
|
1696
1692
|
const shouldReleaseBinding = shouldReleaseHeldBinding(state, boundTask);
|
|
@@ -1761,7 +1757,11 @@ export class AgentManager {
|
|
|
1761
1757
|
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);
|
|
1762
1758
|
}
|
|
1763
1759
|
}
|
|
1764
|
-
return {
|
|
1760
|
+
return {
|
|
1761
|
+
resumed: result.resumed,
|
|
1762
|
+
releasedBinding: result.releasedBinding,
|
|
1763
|
+
...(result.reason ? { reason: result.reason } : {}),
|
|
1764
|
+
};
|
|
1765
1765
|
}
|
|
1766
1766
|
async resolvePaneId(state, cfg) {
|
|
1767
1767
|
if (state.paneId)
|
|
@@ -3108,13 +3108,6 @@ export class AgentManager {
|
|
|
3108
3108
|
await assertSessionUnchanged();
|
|
3109
3109
|
await tmux.sendKeysLiteral(paneId, command);
|
|
3110
3110
|
await tmux.sendEnter(paneId);
|
|
3111
|
-
if (command === '/clear') {
|
|
3112
|
-
await this.agentStore.update(agentId, (s) => {
|
|
3113
|
-
if (!s)
|
|
3114
|
-
return AGENT_STORE_NOOP;
|
|
3115
|
-
return { ...s, injectedSkills: undefined };
|
|
3116
|
-
});
|
|
3117
|
-
}
|
|
3118
3111
|
guardHandedOff = true;
|
|
3119
3112
|
void this.waitForReplPromptReady(tmux, paneId, cfg.runtime, this.compactIdleWaitMs)
|
|
3120
3113
|
.catch(err => {
|
|
@@ -3396,14 +3389,6 @@ export class AgentManager {
|
|
|
3396
3389
|
// Caller-transmitted token/round take precedence — task fields are stale during dispatch.
|
|
3397
3390
|
const promptSignalToken = opts.signalToken ?? task.signalToken;
|
|
3398
3391
|
const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
|
|
3399
|
-
const beforeInjectAgent = await this.agentStore.get(agentId);
|
|
3400
|
-
// freshRuntime=true 覆盖两种场景:(a) buildFreshSession 全新 tmux session;
|
|
3401
|
-
// (b) adoptOrRestartSession 的 shell 重启 / trust-dialog 答完——pane 仍在但 REPL
|
|
3402
|
-
// 是新进程。两种情况下旧上下文都没了,必须重置 dedup baseline,决不能因为
|
|
3403
|
-
// paneId 字符串恰好相同就沿用旧 skill 集。
|
|
3404
|
-
const reuseInjectedSkills = ensure.freshRuntime
|
|
3405
|
-
? null
|
|
3406
|
-
: reuseSkillsIfContextValid(beforeInjectAgent, taskId, paneId);
|
|
3407
3392
|
// develop prompt 按 QA 有无裁剪 spec 路线(qaAgentId 快照优先,与 review 派发同一解析)。
|
|
3408
3393
|
const hasQaPartner = !!(task.qaAgentId ?? this.findQaPartner(agentId)?.id);
|
|
3409
3394
|
let prompt;
|
|
@@ -3418,7 +3403,6 @@ export class AgentManager {
|
|
|
3418
3403
|
hasQaPartner,
|
|
3419
3404
|
...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
|
|
3420
3405
|
...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
|
|
3421
|
-
...(reuseInjectedSkills ? { excludeSkills: reuseInjectedSkills } : {}),
|
|
3422
3406
|
...(imagePaths.length ? { imagePaths } : {}),
|
|
3423
3407
|
...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
|
|
3424
3408
|
...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
|
|
@@ -3491,6 +3475,15 @@ export class AgentManager {
|
|
|
3491
3475
|
catch { }
|
|
3492
3476
|
return false;
|
|
3493
3477
|
}
|
|
3478
|
+
// Pane exists now but the prompt is not out — arm here so a request it triggers is a live chunk,
|
|
3479
|
+
// not snapshot-suppressed scrollback. Abort cleanly (no binding written yet) if it cannot arm.
|
|
3480
|
+
if (opts.armBeforeInject && !(await opts.armBeforeInject())) {
|
|
3481
|
+
try {
|
|
3482
|
+
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3483
|
+
}
|
|
3484
|
+
catch { }
|
|
3485
|
+
return false;
|
|
3486
|
+
}
|
|
3494
3487
|
const now = new Date().toISOString();
|
|
3495
3488
|
let agentMarkedRunning = false;
|
|
3496
3489
|
try {
|
|
@@ -3514,9 +3507,6 @@ export class AgentManager {
|
|
|
3514
3507
|
bootstrappingTaskId: taskId,
|
|
3515
3508
|
updatedAt: now,
|
|
3516
3509
|
...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
3517
|
-
...(reuseInjectedSkills
|
|
3518
|
-
? { injectedSkills: { taskId, paneId, skills: reuseInjectedSkills } }
|
|
3519
|
-
: {}),
|
|
3520
3510
|
};
|
|
3521
3511
|
});
|
|
3522
3512
|
if (cancelHoldWon) {
|
|
@@ -3529,7 +3519,7 @@ export class AgentManager {
|
|
|
3529
3519
|
return false;
|
|
3530
3520
|
}
|
|
3531
3521
|
agentMarkedRunning = true;
|
|
3532
|
-
|
|
3522
|
+
await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime);
|
|
3533
3523
|
// Prompt delivered → clear the mid-bootstrap marker IMMEDIATELY, before the slower persist/emit/watch
|
|
3534
3524
|
// steps: a crash between ack and the clear would otherwise leave recover() seeing a stale marker on
|
|
3535
3525
|
// an already-running prompt and re-dispatching it. The clear is best-effort and NON-destructive — its
|
|
@@ -3545,14 +3535,6 @@ export class AgentManager {
|
|
|
3545
3535
|
console.warn(`[AgentManager] startSession: hold after marker-clear failure for task=${taskId} failed:`, holdErr);
|
|
3546
3536
|
});
|
|
3547
3537
|
}
|
|
3548
|
-
// dedup baseline 记录的是「已 paste 进 idle composer 的 skill 文本」:paste 落入 composer 即进
|
|
3549
|
-
// REPL 上下文,与 submit-ack 无关。ack 超时(首个 Enter 被吞)下 skill 仍在 composer,跳过落盘会让
|
|
3550
|
-
// 下一轮整组重注入——即 SKILLS 重复注入。但 composerDelivered=false(paste 时 pane
|
|
3551
|
-
// 已 busy,文本进了运行中输入流而非 composer)则不能落盘,否则恢复提示会缺必需 skill。freshRuntime
|
|
3552
|
-
// 负责 REPL 真正重启时作废 baseline。
|
|
3553
|
-
if (ack.composerDelivered) {
|
|
3554
|
-
await this.persistInjectedSkills(agentId, taskId, paneId, agent.role, phase, reuseInjectedSkills);
|
|
3555
|
-
}
|
|
3556
3538
|
await this.eventBus.emit({
|
|
3557
3539
|
id: '',
|
|
3558
3540
|
type: 'session.started',
|
|
@@ -3728,29 +3710,6 @@ export class AgentManager {
|
|
|
3728
3710
|
return false;
|
|
3729
3711
|
}
|
|
3730
3712
|
}
|
|
3731
|
-
// Snapshot which skills are now resident in the REPL's context, union of
|
|
3732
|
-
// the pre-dispatch baseline (when context was still valid) and the phase's
|
|
3733
|
-
// declared skills. Guarded by (taskId, paneId) so a concurrent rebind never
|
|
3734
|
-
// overwrites a freshly-bound agent.
|
|
3735
|
-
async persistInjectedSkills(agentId, taskId, paneId, role, phase, reuseInjectedSkills) {
|
|
3736
|
-
const phaseSkills = this.skillRegistry.skillsForPhase(role, phase);
|
|
3737
|
-
const baseList = reuseInjectedSkills ?? [];
|
|
3738
|
-
// 已有有效 context 记录,且本 phase 没有引入新 skill → 写盘无信息增益,short-circuit。
|
|
3739
|
-
// reuseInjectedSkills === null 时缺基线记录,仍需建一份初始档。
|
|
3740
|
-
if (reuseInjectedSkills !== null && phaseSkills.every(s => baseList.includes(s)))
|
|
3741
|
-
return;
|
|
3742
|
-
const merged = Array.from(new Set([...baseList, ...phaseSkills]));
|
|
3743
|
-
const now = new Date().toISOString();
|
|
3744
|
-
await this.agentStore.update(agentId, (latest) => {
|
|
3745
|
-
if (!latest || latest.taskId !== taskId || latest.paneId !== paneId)
|
|
3746
|
-
return AGENT_STORE_NOOP;
|
|
3747
|
-
return {
|
|
3748
|
-
...latest,
|
|
3749
|
-
injectedSkills: { taskId, paneId, skills: merged },
|
|
3750
|
-
updatedAt: now,
|
|
3751
|
-
};
|
|
3752
|
-
});
|
|
3753
|
-
}
|
|
3754
3713
|
// Ready gate prevents mid-paste webhook from flipping to 'waiting' on a busy REPL.
|
|
3755
3714
|
async markAgentWaiting(agentId, expectedTaskId, opts = {}) {
|
|
3756
3715
|
return this.releaseAgentForTask(agentId, expectedTaskId, 'waiting', opts);
|
|
@@ -3810,17 +3769,10 @@ export class AgentManager {
|
|
|
3810
3769
|
const runner = this.createRunnerFor(agent);
|
|
3811
3770
|
const tmux = new TmuxManager(runner);
|
|
3812
3771
|
const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
|
|
3813
|
-
// 与 startSession 同步:任何 REPL 启动 / 重启路径(freshRuntime=true)都视为新上下文,
|
|
3814
|
-
// 强制重新注入完整 skill 集——既覆盖 fresh tmux session,也覆盖同 pane 里的 shell 重启
|
|
3815
|
-
// 与 trust-dialog 完成两种 adopt 场景。
|
|
3816
|
-
const reuseInjectedSkills = ensure.freshRuntime
|
|
3817
|
-
? null
|
|
3818
|
-
: reuseSkillsIfContextValid(agentState, taskId, paneId);
|
|
3819
3772
|
let prompt;
|
|
3820
3773
|
try {
|
|
3821
|
-
// freshRuntime=true 表示 tmux/REPL 刚新建或重启,旧 post-approve prompt
|
|
3822
|
-
//
|
|
3823
|
-
// redispatchCount>0 也必须发完整长段,否则 dev 拿不到 T_self / idempotency / final
|
|
3774
|
+
// freshRuntime=true 表示 tmux/REPL 刚新建或重启,旧 post-approve prompt 上下文已丢失。
|
|
3775
|
+
// 此时即使 redispatchCount>0 也必须发完整长段,否则 dev 拿不到 T_self / idempotency / final
|
|
3824
3776
|
// re-fetch / 禁止 merge 等首轮规则。
|
|
3825
3777
|
const useIncrementalNudge = typeof opts.postApproveRedispatchCount === 'number'
|
|
3826
3778
|
&& opts.postApproveRedispatchCount > 0
|
|
@@ -3839,7 +3791,6 @@ export class AgentManager {
|
|
|
3839
3791
|
? { postApproveRedispatchCount: opts.postApproveRedispatchCount }
|
|
3840
3792
|
: {}),
|
|
3841
3793
|
...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
|
|
3842
|
-
...(reuseInjectedSkills ? { excludeSkills: reuseInjectedSkills } : {}),
|
|
3843
3794
|
...(imagePaths.length ? { imagePaths } : {}),
|
|
3844
3795
|
...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
|
|
3845
3796
|
...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
|
|
@@ -3891,6 +3842,10 @@ export class AgentManager {
|
|
|
3891
3842
|
return false;
|
|
3892
3843
|
}
|
|
3893
3844
|
}
|
|
3845
|
+
// Arm before paste (same reasoning as startSession): pane exists, prompt not out yet.
|
|
3846
|
+
if (opts.armBeforeInject && !(await opts.armBeforeInject())) {
|
|
3847
|
+
return false;
|
|
3848
|
+
}
|
|
3894
3849
|
const now = new Date().toISOString();
|
|
3895
3850
|
await this.agentStore.update(agentId, (latest) => {
|
|
3896
3851
|
if (!latest)
|
|
@@ -3900,16 +3855,9 @@ export class AgentManager {
|
|
|
3900
3855
|
paneId,
|
|
3901
3856
|
worktreePath,
|
|
3902
3857
|
updatedAt: now,
|
|
3903
|
-
...(reuseInjectedSkills
|
|
3904
|
-
? { injectedSkills: { taskId, paneId, skills: reuseInjectedSkills } }
|
|
3905
|
-
: { injectedSkills: undefined }),
|
|
3906
3858
|
};
|
|
3907
3859
|
});
|
|
3908
|
-
|
|
3909
|
-
// 仅 composer 投递成功才落 dedup baseline(理由见 startSession);freshRuntime 负责 REPL 重启时作废。
|
|
3910
|
-
if (ack.composerDelivered) {
|
|
3911
|
-
await this.persistInjectedSkills(agentId, taskId, paneId, agent.role, phase, reuseInjectedSkills);
|
|
3912
|
-
}
|
|
3860
|
+
await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime);
|
|
3913
3861
|
return true;
|
|
3914
3862
|
}
|
|
3915
3863
|
// A bootstrappingTaskId marker means a dispatch began (binding written) but its prompt was never
|
|
@@ -4053,7 +4001,6 @@ export class AgentManager {
|
|
|
4053
4001
|
continue;
|
|
4054
4002
|
}
|
|
4055
4003
|
await this.dispatchPostMergeCleanup(state.id, {
|
|
4056
|
-
prNumber: boundTask.prNumber,
|
|
4057
4004
|
taskId: boundTask.id,
|
|
4058
4005
|
branch: boundTask.branch,
|
|
4059
4006
|
});
|
|
@@ -5214,10 +5161,9 @@ export class AgentManager {
|
|
|
5214
5161
|
this.phaseSignalWatcher?.stop(taskId);
|
|
5215
5162
|
// Keep the agent BOUND (non-dispatchable) until branch cleanup + context reset finish, then
|
|
5216
5163
|
// release. dispatchPostMergeCleanup owns the whole lifecycle: worktree removal → branch
|
|
5217
|
-
// delete → /clear
|
|
5164
|
+
// delete → /clear → release (no agent notification).
|
|
5218
5165
|
if (task.prNumber && task.branch) {
|
|
5219
5166
|
const ctx = {
|
|
5220
|
-
prNumber: task.prNumber,
|
|
5221
5167
|
taskId: task.id,
|
|
5222
5168
|
branch: task.branch,
|
|
5223
5169
|
};
|
|
@@ -5265,14 +5211,22 @@ export class AgentManager {
|
|
|
5265
5211
|
}
|
|
5266
5212
|
await this.removeMergedWorktree(agent, agentId, ctx.taskId);
|
|
5267
5213
|
const runner = this.createRunnerFor(agent);
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
|
|
5214
|
+
if (state.repoPath) {
|
|
5215
|
+
await this.deleteLocalBranchInRepo(runner, state.repoPath, ctx.branch, agentId);
|
|
5216
|
+
}
|
|
5217
|
+
else {
|
|
5218
|
+
// No repoPath on the binding → can't run `git branch -D`. Keep the "can't clean → warn
|
|
5219
|
+
// server-side" contract: surface the skip (the merged branch may linger locally) rather than
|
|
5220
|
+
// dropping it silently now that there is no agent notification carrying `branch-cleanup: skipped`.
|
|
5221
|
+
console.warn(`[AgentManager] dispatchPostMergeCleanup(${agentId}): no repoPath on binding; skipping local ` +
|
|
5222
|
+
`branch delete for ${ctx.branch} (it may linger locally)`);
|
|
5223
|
+
}
|
|
5224
|
+
// No agent dialogue: the merged task is already done and the agent is idle, so baxian just resets
|
|
5225
|
+
// its context with /clear and releases it. Worktree + local branch were cleaned above; a failed
|
|
5226
|
+
// branch delete is logged server-side (deleteLocalBranchInRepo), not surfaced into the pane.
|
|
5271
5227
|
const tmux = new TmuxManager(runner);
|
|
5272
|
-
const prompt = buildPostMergeCleanupPrompt(ctx, cleanupResult);
|
|
5273
5228
|
const runtime = agentRuntimeKindFor(agent);
|
|
5274
|
-
|
|
5275
|
-
void this.runPostMergeCompaction(tmux, state.paneId, agentId, ctx.taskId, runtime, prompt, cleanSlate).catch(err => console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) failed:`, err));
|
|
5229
|
+
void this.runPostMergeCompaction(tmux, state.paneId, agentId, ctx.taskId, runtime).catch(err => console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) failed:`, err));
|
|
5276
5230
|
}
|
|
5277
5231
|
// Release the post-merge binding (clears taskId + frees the lock we held → agent dispatchable
|
|
5278
5232
|
// again). Shared success tail. Skips when the binding has already moved to another task, so it
|
|
@@ -5316,11 +5270,9 @@ export class AgentManager {
|
|
|
5316
5270
|
});
|
|
5317
5271
|
});
|
|
5318
5272
|
}
|
|
5319
|
-
//
|
|
5320
|
-
//
|
|
5321
|
-
//
|
|
5322
|
-
// - absent: branch wasn't there at all (auto-delete-head-branches, never landed locally).
|
|
5323
|
-
// - failed: worktree still occupies the ref, permissions, etc. — agent must NOT be told "cleaned".
|
|
5273
|
+
// Best-effort local cleanup after merge: prune the remote-tracking ref + worktree admin entry, then
|
|
5274
|
+
// delete the local branch. shellQuote prevents injection. Failures are logged server-side only — the
|
|
5275
|
+
// wrap-up resets the pane with /clear regardless, and there is no agent notification to keep honest.
|
|
5324
5276
|
async deleteLocalBranchInRepo(runner, repoPath, branch, agentId) {
|
|
5325
5277
|
// --expire=now: bare `git worktree prune` honors gc.worktreePruneExpire (default 3 months),
|
|
5326
5278
|
// so a worktree that the release just removed could still be tracked as occupying the ref.
|
|
@@ -5338,28 +5290,22 @@ export class AgentManager {
|
|
|
5338
5290
|
const delCmd = `cd ${shellQuote(repoPath)} && git branch -D ${shellQuote(branch)}`;
|
|
5339
5291
|
try {
|
|
5340
5292
|
const delResult = await runner.exec(delCmd, { timeout: this.postMergeBranchTimeoutMs });
|
|
5341
|
-
|
|
5342
|
-
|
|
5293
|
+
// exit 0 → deleted; "not found"/"no such branch" → already absent (auto-delete-head-branches). Both fine.
|
|
5294
|
+
if (delResult.exitCode !== 0 && !/not found|not a valid|no such branch/i.test(delResult.stderr)) {
|
|
5295
|
+
console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}): branch -D exit=${delResult.exitCode} ` +
|
|
5296
|
+
`stderr=${delResult.stderr.trim()}`);
|
|
5343
5297
|
}
|
|
5344
|
-
if (/not found|not a valid|no such branch/i.test(delResult.stderr)) {
|
|
5345
|
-
return { outcome: 'absent', detail: delResult.stderr.trim() };
|
|
5346
|
-
}
|
|
5347
|
-
console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}): branch -D exit=${delResult.exitCode} ` +
|
|
5348
|
-
`stderr=${delResult.stderr.trim()}`);
|
|
5349
|
-
return { outcome: 'failed', detail: delResult.stderr.trim() || `exit ${delResult.exitCode}` };
|
|
5350
5298
|
}
|
|
5351
5299
|
catch (err) {
|
|
5352
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
5353
5300
|
console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}) branch -D threw:`, err);
|
|
5354
|
-
return { outcome: 'failed', detail };
|
|
5355
5301
|
}
|
|
5356
5302
|
}
|
|
5357
|
-
async runPostMergeCompaction(tmux, paneId, agentId, originalTaskId, runtime
|
|
5358
|
-
// 等待获取(而非无条件 add):手动 compact
|
|
5303
|
+
async runPostMergeCompaction(tmux, paneId, agentId, originalTaskId, runtime) {
|
|
5304
|
+
// 等待获取(而非无条件 add):手动 compact 持锁时直接进入会并发,
|
|
5359
5305
|
// 且 finally 会误删对方的 guard 放穿后续请求。
|
|
5360
5306
|
await this.acquireCompactGuard(agentId);
|
|
5361
5307
|
try {
|
|
5362
|
-
await this.runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime
|
|
5308
|
+
await this.runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime);
|
|
5363
5309
|
}
|
|
5364
5310
|
finally {
|
|
5365
5311
|
this.compactInFlight.delete(agentId);
|
|
@@ -5385,64 +5331,36 @@ export class AgentManager {
|
|
|
5385
5331
|
this.compactInFlight.add(agentId);
|
|
5386
5332
|
return true;
|
|
5387
5333
|
}
|
|
5388
|
-
async runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime
|
|
5334
|
+
async runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime) {
|
|
5389
5335
|
const bindingStillOurs = async () => {
|
|
5390
5336
|
const s = await this.agentStore.get(agentId);
|
|
5391
5337
|
return !!s && s.taskId === originalTaskId && s.paneId === paneId;
|
|
5392
5338
|
};
|
|
5393
|
-
|
|
5339
|
+
if (!await bindingStillOurs())
|
|
5340
|
+
return;
|
|
5341
|
+
// No notification, no dialogue: the merged task is done and the agent is idle, so just reset its
|
|
5342
|
+
// context. sendPostMergeSlashCommand interrupts any lingering turn (Esc), waits for idle, sends
|
|
5343
|
+
// /clear, verifies it wasn't rejected, and retries. Returns false if the binding moved mid-way.
|
|
5394
5344
|
let cleared = false;
|
|
5395
|
-
|
|
5396
|
-
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
|
|
5405
|
-
if (!await bindingStillOurs())
|
|
5406
|
-
return;
|
|
5407
|
-
await tmux.injectPrompt(paneId, prompt, agentId);
|
|
5408
|
-
// Resend a swallowed first Enter (bracketed-paste TUI quirk); settle so baseline holds the paste.
|
|
5409
|
-
const baseline = await tmux.captureSettledSnapshot(paneId, { timeoutMs: this.dispatchSettleTimeoutMs });
|
|
5410
|
-
await tmux.sendEnter(paneId);
|
|
5411
|
-
try {
|
|
5412
|
-
await tmux.waitSubmitAck(paneId, baseline, runtime, {
|
|
5413
|
-
timeoutMs: this.dispatchAckTimeoutMs,
|
|
5414
|
-
resend: () => tmux.sendEnter(paneId),
|
|
5415
|
-
resendIntervalMs: this.dispatchAckResendIntervalMs,
|
|
5416
|
-
});
|
|
5417
|
-
}
|
|
5418
|
-
catch (ackErr) {
|
|
5419
|
-
// Only a genuine ack timeout is best-effort; other (infra) errors must reach the outer retry.
|
|
5420
|
-
if (!(ackErr instanceof Error && /runtime ack timeout/.test(ackErr.message)))
|
|
5421
|
-
throw ackErr;
|
|
5422
|
-
console.warn(`[AgentManager] post-merge notification ack timeout (${agentId}, pane ${paneId}):`, ackErr);
|
|
5423
|
-
}
|
|
5424
|
-
await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
|
|
5425
|
-
if (!await bindingStillOurs())
|
|
5426
|
-
return;
|
|
5427
|
-
const command = cleanSlate ? '/clear' : '/compact';
|
|
5428
|
-
if (!await this.sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, command, bindingStillOurs)) {
|
|
5429
|
-
return;
|
|
5430
|
-
}
|
|
5345
|
+
try {
|
|
5346
|
+
cleared = await this.sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, bindingStillOurs);
|
|
5347
|
+
}
|
|
5348
|
+
catch (err) {
|
|
5349
|
+
console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) /clear failed:`, err);
|
|
5350
|
+
}
|
|
5351
|
+
if (!cleared) {
|
|
5352
|
+
// A runtime that exited to a shell can't take /clear; restarting it yields the same clean slate.
|
|
5353
|
+
if (await this.recoverPostMergeExitedRuntime(tmux, paneId, agentId, originalTaskId, runtime)) {
|
|
5431
5354
|
cleared = true;
|
|
5432
|
-
break;
|
|
5433
5355
|
}
|
|
5434
|
-
|
|
5435
|
-
|
|
5436
|
-
console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) attempt ${attempts} failed (${label}):`, err);
|
|
5356
|
+
else if (!await bindingStillOurs()) {
|
|
5357
|
+
return; // binding moved to another flow mid-cleanup → leave its pane alone (no C-c, no release)
|
|
5437
5358
|
}
|
|
5359
|
+
else if (!await this.clearComposerForReuse(tmux, paneId, agentId)) {
|
|
5360
|
+
return; // can't even reach a clean composer → hold (don't release) for a human
|
|
5361
|
+
}
|
|
5362
|
+
// else: composer cleaned → a stuck pane must not strand the agent bound to a merged task; release.
|
|
5438
5363
|
}
|
|
5439
|
-
if (!cleared && await this.recoverPostMergeExitedRuntime(tmux, paneId, agentId, originalTaskId, runtime)) {
|
|
5440
|
-
await this.releasePostMergeAgent(agentId, originalTaskId);
|
|
5441
|
-
return;
|
|
5442
|
-
}
|
|
5443
|
-
// Give-up: clear the injected-but-unsubmitted notification; hold (don't release) if it can't be cleared.
|
|
5444
|
-
if (!cleared && !await this.clearComposerForReuse(tmux, paneId, agentId))
|
|
5445
|
-
return;
|
|
5446
5364
|
await this.releasePostMergeAgent(agentId, originalTaskId);
|
|
5447
5365
|
}
|
|
5448
5366
|
async recoverPostMergeExitedRuntime(tmux, paneId, agentId, taskId, runtime) {
|
|
@@ -5470,16 +5388,13 @@ export class AgentManager {
|
|
|
5470
5388
|
await this.agentStore.update(agentId, (existing) => {
|
|
5471
5389
|
if (!existing || existing.taskId !== taskId)
|
|
5472
5390
|
return AGENT_STORE_NOOP;
|
|
5473
|
-
if (existing.paneId === ensure.paneId
|
|
5474
|
-
&& existing.repoPath === ensure.workdir
|
|
5475
|
-
&& existing.injectedSkills === undefined) {
|
|
5391
|
+
if (existing.paneId === ensure.paneId && existing.repoPath === ensure.workdir) {
|
|
5476
5392
|
return AGENT_STORE_NOOP;
|
|
5477
5393
|
}
|
|
5478
5394
|
return {
|
|
5479
5395
|
...existing,
|
|
5480
5396
|
paneId: ensure.paneId,
|
|
5481
5397
|
repoPath: ensure.workdir,
|
|
5482
|
-
injectedSkills: undefined,
|
|
5483
5398
|
updatedAt: new Date().toISOString(),
|
|
5484
5399
|
};
|
|
5485
5400
|
});
|
|
@@ -5491,33 +5406,33 @@ export class AgentManager {
|
|
|
5491
5406
|
return false;
|
|
5492
5407
|
}
|
|
5493
5408
|
}
|
|
5494
|
-
async sendPostMergeSlashCommand(tmux, paneId, agentId, runtime,
|
|
5409
|
+
async sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, bindingStillOurs) {
|
|
5495
5410
|
let rejection;
|
|
5496
5411
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
5497
|
-
// The runtime rejects /clear
|
|
5498
|
-
//
|
|
5499
|
-
//
|
|
5412
|
+
// The runtime rejects /clear while a turn is in progress, and the agent's last task turn can
|
|
5413
|
+
// still be settling when our idle scrape passes. Esc interrupts it (the same stop the cancel
|
|
5414
|
+
// flow runs before /clear); a genuinely idle pane absorbs it harmlessly.
|
|
5500
5415
|
await tmux.sendKeysToPane(paneId, 'Escape');
|
|
5501
5416
|
await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
|
|
5502
5417
|
if (!await bindingStillOurs())
|
|
5503
5418
|
return false;
|
|
5504
|
-
await tmux.sendKeysLiteral(paneId,
|
|
5419
|
+
await tmux.sendKeysLiteral(paneId, '/clear');
|
|
5505
5420
|
await tmux.sendEnter(paneId);
|
|
5506
5421
|
await new Promise(r => setTimeout(r, this.compactIdlePollMs));
|
|
5507
5422
|
await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
|
|
5508
5423
|
if (!await bindingStillOurs())
|
|
5509
5424
|
return false;
|
|
5510
|
-
if (!await this.hasRuntimeSlashCommandRejection(tmux, paneId,
|
|
5425
|
+
if (!await this.hasRuntimeSlashCommandRejection(tmux, paneId, '/clear'))
|
|
5511
5426
|
return true;
|
|
5512
|
-
rejection = new Error(
|
|
5427
|
+
rejection = new Error('runtime rejected /clear because a task is still in progress');
|
|
5513
5428
|
if (attempt < 2) {
|
|
5514
|
-
console.warn(`[AgentManager] runPostMergeCompaction(${agentId})
|
|
5429
|
+
console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) /clear rejected; retrying`);
|
|
5515
5430
|
await new Promise(r => setTimeout(r, this.compactIdlePollMs));
|
|
5516
5431
|
if (!await bindingStillOurs())
|
|
5517
5432
|
return false;
|
|
5518
5433
|
}
|
|
5519
5434
|
}
|
|
5520
|
-
throw rejection ?? new Error(
|
|
5435
|
+
throw rejection ?? new Error('runtime rejected /clear');
|
|
5521
5436
|
}
|
|
5522
5437
|
async hasRuntimeSlashCommandRejection(tmux, paneId, command) {
|
|
5523
5438
|
const cap = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
|
|
@@ -5903,6 +5818,9 @@ export class AgentManager {
|
|
|
5903
5818
|
...(opts.priorFindingsJson ? { serverPriorFindings: opts.priorFindingsJson } : {}),
|
|
5904
5819
|
...(opts.priorResponseJson ? { serverPriorResponse: opts.priorResponseJson } : {}),
|
|
5905
5820
|
...(opts.phase === 'spec' ? { currentSpecRound: newRound } : {}),
|
|
5821
|
+
// Arm the verdict + read-file watcher in the pane-exists / pre-paste window so a QA
|
|
5822
|
+
// [bx:read-file:...] emitted during the dispatch is a live chunk, not snapshot-suppressed.
|
|
5823
|
+
armBeforeInject: () => this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, false, (req) => { void this.handleReadFileRequest(taskId, qaId, req); }),
|
|
5906
5824
|
};
|
|
5907
5825
|
// A continuation consumed the QA's reviewed signal (not the dev's entry
|
|
5908
5826
|
// signal): rollback restores the prior slice's review/token, so re-arm the
|
|
@@ -5923,6 +5841,9 @@ export class AgentManager {
|
|
|
5923
5841
|
: await this.startSession(taskId, qaId, dispatchPhase, sessionOpts);
|
|
5924
5842
|
}
|
|
5925
5843
|
catch (err) {
|
|
5844
|
+
// armBeforeInject may have armed the watcher before the failing paste — drop it so a stale
|
|
5845
|
+
// entry can't fire on a rolled-back / failed task (no-op if it never armed).
|
|
5846
|
+
this.stopPhaseSignalWatcher(taskId);
|
|
5926
5847
|
if (err instanceof DispatchTerminalError) {
|
|
5927
5848
|
await this.failTaskForDispatchError(taskId, dispatchPhase, qaId, err);
|
|
5928
5849
|
}
|
|
@@ -5939,6 +5860,9 @@ export class AgentManager {
|
|
|
5939
5860
|
throw err;
|
|
5940
5861
|
}
|
|
5941
5862
|
if (!started) {
|
|
5863
|
+
// Covers armBeforeInject returning false (watcher couldn't arm) as well as any other
|
|
5864
|
+
// pre-paste abort; stop is a no-op when nothing armed.
|
|
5865
|
+
this.stopPhaseSignalWatcher(taskId);
|
|
5942
5866
|
await rollback();
|
|
5943
5867
|
if (!opts.continuation) {
|
|
5944
5868
|
await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
|
|
@@ -5955,8 +5879,6 @@ export class AgentManager {
|
|
|
5955
5879
|
});
|
|
5956
5880
|
return null;
|
|
5957
5881
|
}
|
|
5958
|
-
this.stopPhaseSignalWatcher(taskId);
|
|
5959
|
-
await this.armPostDispatchSignalOrHold(taskId, qaId, expectedKind, newToken, false, (req) => { void this.handleReadFileRequest(taskId, qaId, req); });
|
|
5960
5882
|
return await this.taskStore.get(taskId);
|
|
5961
5883
|
}
|
|
5962
5884
|
async dispatchServerFixToDev(taskId, findingsJson) {
|