baxian 2.0.28 → 2.0.29

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.
@@ -15,7 +15,7 @@ import { prReviewCacheRevision } from '../platform/pr-conversation-cache.js';
15
15
  import { deadTokens, liveVerdictVeto } from '../platform/verdict-engine.js';
16
16
  import { platformBindingMismatch, taskNeedsPlatformBindingAudit, } from '../platform/startup.js';
17
17
  import { AGENT_STORE_NOOP } from '../state/agent-store.js';
18
- import { createRunner, LocalRunner, shellQuote, resolveAgentHost, workdirHostGroupKey, } from './runner.js';
18
+ import { createRunner, LocalRunner, shellQuote, hostGroupKey, resolveAgentHost, workdirHostGroupKey, } from './runner.js';
19
19
  import { isTransientNetworkFailure } from './net-exec.js';
20
20
  import { classifyScreen, isMenuRule } from './detect/classify.js';
21
21
  import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
@@ -262,6 +262,8 @@ export class AgentManager {
262
262
  compactIdleWaitMs = 5 * 60_000;
263
263
  compactIdlePollMs = 2_000;
264
264
  manualCompactWaitMs = 5_000;
265
+ restartInterruptWaitMs = 10_000;
266
+ replExitWaitMs = 30_000;
265
267
  runtimeLivenessProbeMs = 700;
266
268
  cleanComposerWaitMs = 5_000;
267
269
  platformVerificationRetryDelayMs = 2_000;
@@ -278,6 +280,7 @@ export class AgentManager {
278
280
  divergedAgents = new Set();
279
281
  deletionGeneration = new Map();
280
282
  compactInFlight = new Set();
283
+ maintenanceInFlight = new Set();
281
284
  platformBindingInterventionKeys = new Set();
282
285
  constructor(deps) {
283
286
  const config = prepareConfig(deps.config);
@@ -305,7 +308,17 @@ export class AgentManager {
305
308
  this.needInputRetryIntervalMs = deps.needInputRetryIntervalMs ?? DEFAULT_NEED_INPUT_RETRY_INTERVAL_MS;
306
309
  this.dispatchAckTimeoutMs = deps.dispatchAckTimeoutMs ?? DEFAULT_DISPATCH_ACK_TIMEOUT_MS;
307
310
  this.dispatchSettleTimeoutMs = deps.dispatchSettleTimeoutMs ?? DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS;
308
- this.cancelInterruptGuardWaitMs = this.dispatchAckTimeoutMs + 5_000;
311
+ this.cancelInterruptGuardWaitMs = deps.cancelInterruptGuardWaitMs ?? this.dispatchAckTimeoutMs + 5_000;
312
+ this.restartInterruptWaitMs = deps.restartInterruptWaitMs ?? this.restartInterruptWaitMs;
313
+ this.replExitWaitMs = deps.replExitWaitMs ?? this.replExitWaitMs;
314
+ this.dispatchAckResendIntervalMs = deps.dispatchAckResendIntervalMs ?? this.dispatchAckResendIntervalMs;
315
+ this.compactIdleWaitMs = deps.compactIdleWaitMs ?? this.compactIdleWaitMs;
316
+ this.manualCompactWaitMs = deps.manualCompactWaitMs ?? this.manualCompactWaitMs;
317
+ this.runtimeMenuPollIntervalMs = deps.runtimeMenuPollIntervalMs ?? this.runtimeMenuPollIntervalMs;
318
+ this.runtimeLivenessProbeMs = deps.runtimeLivenessProbeMs ?? this.runtimeLivenessProbeMs;
319
+ this.cleanComposerWaitMs = deps.cleanComposerWaitMs ?? this.cleanComposerWaitMs;
320
+ this.compactIdlePollMs = deps.compactIdlePollMs ?? this.compactIdlePollMs;
321
+ this.readyStableSpacingMs = deps.readyStableSpacingMs ?? this.readyStableSpacingMs;
309
322
  this.agentIndex = buildAgentIndex(config);
310
323
  this.platformRunner = deps.platformRunner ?? new LocalRunner();
311
324
  this.platformDriverExec = makeDriverExec(this.platformRunner);
@@ -897,7 +910,7 @@ export class AgentManager {
897
910
  console.warn(`[bootstrap] greeting attempt ${attempt}/${this.greetingMaxAttempts} for ${agentId}: ${outcome}`);
898
911
  if (outcome === 'no-agent')
899
912
  break;
900
- if (attempt < this.greetingMaxAttempts && !(await this.clearComposerForReuse(tmux, pane, agentId))) {
913
+ if (attempt < this.greetingMaxAttempts && !(await this.clearComposerForReuse(tmux, pane, agentId, agent.runtime))) {
901
914
  break;
902
915
  }
903
916
  }
@@ -3144,7 +3157,7 @@ export class AgentManager {
3144
3157
  }
3145
3158
  async clearComposerAndConfirmReady(tmux, pane, runtime) {
3146
3159
  try {
3147
- await tmux.clearComposerDraft(pane);
3160
+ await tmux.clearComposerDraft(pane, runtime);
3148
3161
  }
3149
3162
  catch (err) {
3150
3163
  console.warn(`[AgentManager] interruptPaneAndWaitReady: composer clear failed for pane ${pane.paneId}:`, err);
@@ -3214,10 +3227,10 @@ export class AgentManager {
3214
3227
  }
3215
3228
  return true;
3216
3229
  }
3217
- async paneReachedReplReady(tmux, pane, runtime, timeoutMs) {
3230
+ async paneReachedReplReady(tmux, pane, runtime, timeoutMs, opts = {}) {
3218
3231
  const paneId = pane.paneId;
3219
3232
  try {
3220
- await tmux.waitReplReady(pane, runtime, { timeoutMs, scrollback: 0, titleIdleFastPath: true });
3233
+ await tmux.waitReplReady(pane, runtime, { timeoutMs, scrollback: 0, titleIdleFastPath: true, ...opts });
3221
3234
  return true;
3222
3235
  }
3223
3236
  catch (err) {
@@ -3323,23 +3336,28 @@ export class AgentManager {
3323
3336
  });
3324
3337
  }
3325
3338
  async failTasksForAgent(agentId, reason) {
3326
- const failed = await this.withTaskLock(async () => {
3327
- const tasks = await this.taskStore.list({});
3328
- const out = [];
3329
- for (const t of tasks) {
3330
- const bound = t.agentId === agentId || t.qaAgentId === agentId;
3331
- if (t.status === 'merge-ready')
3332
- continue;
3333
- if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
3334
- const failedTask = this.stripGitStatusScopedState(t, 'failed');
3335
- failedTask.status = 'failed';
3336
- failedTask.updatedAt = new Date().toISOString();
3337
- await this.taskStore.set(failedTask);
3338
- out.push(failedTask);
3339
- }
3339
+ const failed = await this.withTaskLock(() => this.failBoundTasksLocked(agentId));
3340
+ return this.publishFailedTasks(agentId, reason, failed);
3341
+ }
3342
+ async failBoundTasksLocked(agentId) {
3343
+ const tasks = await this.taskStore.list({});
3344
+ const out = [];
3345
+ for (const t of tasks) {
3346
+ const bound = t.agentId === agentId || t.qaAgentId === agentId;
3347
+ if (t.status === 'merge-ready')
3348
+ continue;
3349
+ if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
3350
+ const failedTask = this.stripGitStatusScopedState(t, 'failed');
3351
+ failedTask.status = 'failed';
3352
+ failedTask.updatedAt = new Date().toISOString();
3353
+ await this.taskStore.set(failedTask);
3354
+ out.push(failedTask);
3340
3355
  }
3341
- return out;
3342
- });
3356
+ }
3357
+ return out;
3358
+ }
3359
+ // 事件与伙伴释放/排空留在 task lock 外:排空可能等待需要 task lock 的操作
3360
+ async publishFailedTasks(agentId, reason, failed) {
3343
3361
  for (const t of failed) {
3344
3362
  await this.safeEmit({
3345
3363
  id: '',
@@ -3378,22 +3396,96 @@ export class AgentManager {
3378
3396
  }
3379
3397
  async pollPaneCommandStable(tmux, pane, opts) {
3380
3398
  const deadline = Date.now() + opts.timeoutMs;
3381
- const SHELL = /^(?:zsh|bash|sh|fish)$/;
3382
3399
  let last = '';
3383
3400
  while (Date.now() < deadline) {
3384
3401
  await new Promise(r => setTimeout(r, 100));
3385
3402
  const raw = await tmux.displayMessage(pane, '#{pane_current_command}');
3386
3403
  last = raw.trim();
3387
- if (opts.expectShell ? SHELL.test(last) : last !== '')
3404
+ if (opts.expectShell ? isShellProcTitle(last) : last !== '')
3388
3405
  return last;
3389
3406
  }
3390
3407
  return last;
3391
3408
  }
3409
+ // 不盲发 C-c:空 composer 上的 C-c 会让 codex 立即退出并花数秒生成 recap,之后的 /quit 与重启命令全都打进正在退出的 TUI
3410
+ async exitReplForRestart(tmux, pane, cfg) {
3411
+ const runtime = agentRuntimeKindFor(cfg);
3412
+ // 已空闲就不发 Escape:codex 上它会武装 backtrack 提示,vim Insert 模式下会切到 Normal;只有 turn 进行中才需要打断
3413
+ let foreground = await this.waitIdleOrShell(tmux, pane, runtime);
3414
+ if (foreground === 'busy') {
3415
+ if (await this.reachedRuntime(() => tmux.sendKeysToRuntime(pane, runtime, 'Escape'))) {
3416
+ await new Promise(r => setTimeout(r, 200));
3417
+ }
3418
+ foreground = await this.waitIdleOrShell(tmux, pane, runtime);
3419
+ if (foreground === 'busy') {
3420
+ throw new Error(`restart-repl: ${runtime} in pane ${pane.paneId} did not return to an idle prompt after Escape; ` +
3421
+ 'interrupt or finish the running turn via the web terminal, then retry');
3422
+ }
3423
+ }
3424
+ if (foreground === 'shell')
3425
+ return;
3426
+ await tmux.clearComposerDraft(pane, runtime);
3427
+ // 退出文本与 Enter 是同一条守卫命令:到达前 runtime 已退到 shell 就整组不发(/exit 不会被 shell 当命令执行),前台是 vim 这类进程也不会只留半行在 composer
3428
+ if (!await this.reachedRuntime(() => tmux.submitToRuntime(pane, runtime, REPL_EXIT_COMMAND[cfg.runtime])))
3429
+ return;
3430
+ const after = await this.pollPaneCommandStable(tmux, pane, { timeoutMs: this.replExitWaitMs, expectShell: true });
3431
+ if (!isShellProcTitle(after)) {
3432
+ throw new Error(`restart-repl: ${runtime} in pane ${pane.paneId} did not exit within ${this.replExitWaitMs}ms ` +
3433
+ `(pane_current_command=${after || 'unknown'}); not relaunching over a live runtime`);
3434
+ }
3435
+ }
3436
+ // 服务端因前台已是 shell 而拒绝的 runtime 写不是失败:runtime 在按键到达前自己退了,交回按 shell 处理的分支;其他前台进程的拒绝照常抛出
3437
+ async reachedRuntime(write) {
3438
+ try {
3439
+ await write();
3440
+ return true;
3441
+ }
3442
+ catch (err) {
3443
+ if (err instanceof ReplNotReadyError && err.shellForeground)
3444
+ return false;
3445
+ throw err;
3446
+ }
3447
+ }
3448
+ // 等待期间 runtime 可能自行退到 shell(例如被误杀后 recap 刚结束):此时 Escape/退出命令都会打进 shell,应直接交回 relaunch
3449
+ async waitIdleOrShell(tmux, pane, runtime) {
3450
+ if (await this.paneReachedReplReady(tmux, pane, runtime, this.restartInterruptWaitMs, { failFastOnShell: true, runtimeSeen: true })) {
3451
+ return 'idle';
3452
+ }
3453
+ const current = (await tmux.displayMessage(pane, '#{pane_current_command}')).trim();
3454
+ return isShellProcTitle(current) ? 'shell' : 'busy';
3455
+ }
3456
+ // restart-repl / retry 从退出/重建到就绪后的清理与任务提示词 replay 是一段维护操作:两段并发会各自对同一个新 runtime replay 一次
3457
+ tryBeginMaintenance(agentId) {
3458
+ if (this.maintenanceInFlight.has(agentId))
3459
+ return false;
3460
+ this.maintenanceInFlight.add(agentId);
3461
+ return true;
3462
+ }
3463
+ endMaintenance(agentId) {
3464
+ this.maintenanceInFlight.delete(agentId);
3465
+ }
3466
+ isMaintenanceInFlight(agentId) {
3467
+ return this.maintenanceInFlight.has(agentId);
3468
+ }
3392
3469
  async restartReplOnly(agentId, opts = {}) {
3393
3470
  const genAtEntry = opts.expectedGeneration ?? this.deletionGenerationOf(agentId);
3394
3471
  if (!this.deletionGateOpen(agentId, genAtEntry)) {
3395
3472
  throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
3396
3473
  }
3474
+ if (!this.getAgentConfig(agentId))
3475
+ throw new Error(`Unknown agent: ${agentId}`);
3476
+ // 退出/重启是一整段状态机,只有最后的 relaunch 在服务端原子:整段与 compact/注入/上传共用 pane 互斥,与 retry/删除共用会话生命周期链
3477
+ if (!this.tryAcquireCompactGuard(agentId)) {
3478
+ throw new ApiError(409, `Agent ${agentId} pane is busy (compact, upload, dispatch or another restart in progress); retry shortly`);
3479
+ }
3480
+ try {
3481
+ await this.runUnderSessionLifecycle(agentId, () => this.restartReplOnlyLocked(agentId, genAtEntry));
3482
+ }
3483
+ finally {
3484
+ this.compactInFlight.delete(agentId);
3485
+ }
3486
+ }
3487
+ async restartReplOnlyLocked(agentId, genAtEntry) {
3488
+ // 配置在拿到生命周期链之后才解析:排队期间的热更新可能改了 runtime/model 等启动参数,入口捕获的旧值会拉起旧 runtime
3397
3489
  const cfg = this.getAgentConfig(agentId);
3398
3490
  if (!cfg)
3399
3491
  throw new Error(`Unknown agent: ${agentId}`);
@@ -3410,23 +3502,22 @@ export class AgentManager {
3410
3502
  throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
3411
3503
  }
3412
3504
  const pane = await tmux.getSinglePaneByRef(snapshot.ref, agentId);
3413
- await tmux.sendKeysToPane(pane, 'C-c');
3414
- const cmd = await this.pollPaneCommandStable(tmux, pane, { timeoutMs: 2_000 });
3415
3505
  const RUNTIME = /^(?:claude|codex|node|opencode|qodercli(?:-[\d.]+)?|\d+\.\d+\.\d+)$/;
3416
- const SHELL = /^(?:zsh|bash|sh|fish)$/;
3417
- if (RUNTIME.test(cmd)) {
3418
- await tmux.sendKeysToPane(pane, REPL_EXIT_COMMAND[cfg.runtime], 'Enter');
3419
- await this.pollPaneCommandStable(tmux, pane, { timeoutMs: 2_000, expectShell: true });
3420
- }
3421
- else if (!SHELL.test(cmd)) {
3422
- throw new Error(`restart-repl precondition failed: unexpected pane state "${cmd}"`);
3423
- }
3506
+ const readForeground = async () => {
3507
+ const cmd = (await tmux.displayMessage(pane, '#{pane_current_command}')).trim();
3508
+ if (!RUNTIME.test(cmd) && !isShellProcTitle(cmd)) {
3509
+ throw new Error(`restart-repl precondition failed: unexpected pane state "${cmd}"`);
3510
+ }
3511
+ return cmd;
3512
+ };
3513
+ await readForeground();
3424
3514
  const project = this.getProjectConfig(cfg.projectId);
3425
3515
  if (!project)
3426
3516
  throw new Error(`restart-repl: project ${cfg.projectId} does not exist`);
3427
3517
  if (!this.deletionGateOpen(agentId, genAtEntry)) {
3428
3518
  throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
3429
3519
  }
3520
+ // 可能耗时或失败的准备(fetch、Workdir 核对)都放在退出 REPL 之前:失败时 REPL 仍活着,pane 停在 shell 的窗口只剩 relaunch 一瞬
3430
3521
  const { workdir } = await this.ensureWorkdir(cfg, project, runner);
3431
3522
  const paneWorkdir = await tmux.getPaneCurrentPath(pane);
3432
3523
  if (!await sameDirOnHost(runner, paneWorkdir, workdir)) {
@@ -3437,10 +3528,25 @@ export class AgentManager {
3437
3528
  throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
3438
3529
  }
3439
3530
  await this.setSessionOptions(tmux, agentId, snapshot.ref, [[WORKDIR_SESSION_OPTION, workdir]]);
3531
+ // 热更新不在这条链上:退出与 relaunch 之间启动参数或连接目标又变了就停下,不能按一套配置退出、按另一套(或在另一台机器上)拉起
3532
+ const launchCommand = launchCommandIn(workdir, cfg);
3533
+ const restartTarget = (agent) => `${hostGroupKey(agent.mode, resolveAgentHost(this.config.host, agent.host))} ${launchCommandIn(workdir, agent)}`;
3534
+ const target = restartTarget(cfg);
3535
+ const assertTargetUnchanged = (stage) => {
3536
+ const now = this.getAgentConfig(agentId);
3537
+ if (!now || restartTarget(now) !== target) {
3538
+ throw new Error(`restart-repl: agent ${agentId} config changed while ${stage}; run Restart REPL again against the current config`);
3539
+ }
3540
+ };
3541
+ assertTargetUnchanged('preparing the restart');
3542
+ if (RUNTIME.test(await readForeground())) {
3543
+ await this.exitReplForRestart(tmux, pane, cfg);
3544
+ }
3545
+ assertTargetUnchanged('exiting the runtime');
3440
3546
  const runtime = agentRuntimeKindFor(cfg);
3441
3547
  const relaunch = async () => {
3442
- await tmux.sendKeysLiteral(pane, launchCommandIn(workdir, cfg));
3443
- await tmux.sendEnter(pane);
3548
+ // C-c 只丢弃 shell readline 里的残留输入(操作员误键、晚到的弄脏键),启动命令不能接在残留文本后面执行;前台不是 shell 则整组按键都不发
3549
+ await tmux.submitCommandOnShell(pane, launchCommand);
3444
3550
  await tmux.handleTrustDialog(pane, runtime, {
3445
3551
  timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
3446
3552
  });
@@ -6339,7 +6445,7 @@ export class AgentManager {
6339
6445
  const tmux = new TmuxManager(runner);
6340
6446
  const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
6341
6447
  await assertUploadStillValid();
6342
- await tmux.injectPrompt(pane, `${path} `, agentId);
6448
+ await tmux.injectPrompt(pane, `${path} `, agentId, cfg.runtime);
6343
6449
  return { path };
6344
6450
  }
6345
6451
  finally {
@@ -6372,25 +6478,35 @@ export class AgentManager {
6372
6478
  };
6373
6479
  const tmux = new TmuxManager(this.createRunnerFor(cfg));
6374
6480
  const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
6375
- const waitReady = async () => {
6481
+ const waitReady = async (stage) => {
6376
6482
  try {
6377
6483
  await this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.manualCompactWaitMs);
6378
6484
  }
6379
6485
  catch (err) {
6486
+ console.warn(`[AgentManager] sendSlashCommand(${agentId}, ${command}) ${stage}: runtime not at an idle prompt:`, err);
6380
6487
  const detail = err instanceof Error ? err.message : String(err);
6381
6488
  throw new ApiError(409, `Agent ${agentId} runtime is not at an idle REPL prompt: ${detail}`);
6382
6489
  }
6383
6490
  };
6384
- await waitReady();
6491
+ await waitReady('before composer clear');
6385
6492
  await assertSessionUnchanged();
6386
- await tmux.clearComposerDraft(pane);
6387
- await waitReady();
6493
+ try {
6494
+ await tmux.clearComposerDraft(pane, cfg.runtime);
6495
+ }
6496
+ catch (err) {
6497
+ console.warn(`[AgentManager] sendSlashCommand(${agentId}, ${command}) composer clear failed:`, err);
6498
+ if (err instanceof ReplNotReadyError || err instanceof PaneGoneError) {
6499
+ throw new ApiError(409, `Agent ${agentId} composer could not be cleared before ${command}: ${err.message}`);
6500
+ }
6501
+ throw err;
6502
+ }
6503
+ await waitReady('after composer clear');
6388
6504
  await assertSessionUnchanged();
6389
6505
  if (command === '/clear') {
6390
6506
  await this.setSessionOptions(tmux, agentId, pane.session, [[TASK_CONTEXT_SESSION_OPTION, '']]);
6391
6507
  }
6392
- await tmux.sendKeysLiteral(pane, command);
6393
- await tmux.sendEnter(pane);
6508
+ await tmux.sendKeysLiteral(pane, command, cfg.runtime);
6509
+ await tmux.sendEnter(pane, cfg.runtime);
6394
6510
  guardHandedOff = true;
6395
6511
  void this.outsideTaskMutationScope(() => this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.compactIdleWaitMs))
6396
6512
  .catch(err => {
@@ -7224,8 +7340,8 @@ export class AgentManager {
7224
7340
  if (!(await guardBeforePaste()))
7225
7341
  return false;
7226
7342
  if (!paneWorking)
7227
- await tmux.clearComposerDraft(pane);
7228
- await tmux.pasteStagedBuffer(paneId, staged.buf);
7343
+ await tmux.clearComposerDraft(pane, runtime);
7344
+ await tmux.pasteStagedBuffer(pane, staged.buf, runtime);
7229
7345
  return true;
7230
7346
  });
7231
7347
  }
@@ -7244,7 +7360,7 @@ export class AgentManager {
7244
7360
  throw new DispatchTerminalError('ack_unknown', `paste outcome unknown on working pane ${paneId}; composer left untouched to keep the live turn intact: ${message}`);
7245
7361
  }
7246
7362
  try {
7247
- await tmux.clearComposerDraft(pane);
7363
+ await tmux.clearComposerDraft(pane, runtime);
7248
7364
  }
7249
7365
  catch (clearErr) {
7250
7366
  console.warn(`[AgentManager] composer scrub after unknown paste outcome failed for pane ${paneId}:`, clearErr);
@@ -7264,9 +7380,9 @@ export class AgentManager {
7264
7380
  }
7265
7381
  else {
7266
7382
  if (!paneWorking)
7267
- await tmux.clearComposerDraft(pane);
7383
+ await tmux.clearComposerDraft(pane, runtime);
7268
7384
  await revalidate?.();
7269
- await tmux.injectPrompt(pane, prompt, agentId);
7385
+ await tmux.injectPrompt(pane, prompt, agentId, runtime);
7270
7386
  }
7271
7387
  let baseline;
7272
7388
  let baselineTitle = '';
@@ -7277,7 +7393,7 @@ export class AgentManager {
7277
7393
  const submitted = await this.withTaskLock(async () => {
7278
7394
  if (!(await guardBeforePaste()))
7279
7395
  return false;
7280
- await tmux.sendEnter(pane);
7396
+ await tmux.sendEnter(pane, runtime);
7281
7397
  return true;
7282
7398
  });
7283
7399
  if (!submitted) {
@@ -7285,7 +7401,7 @@ export class AgentManager {
7285
7401
  throw new Error(`fence-rejected prompt stays in the composer of working pane ${paneId}`);
7286
7402
  }
7287
7403
  try {
7288
- await tmux.clearComposerDraft(pane);
7404
+ await tmux.clearComposerDraft(pane, runtime);
7289
7405
  }
7290
7406
  catch (err) {
7291
7407
  const message = err instanceof Error ? err.message : String(err);
@@ -7295,7 +7411,7 @@ export class AgentManager {
7295
7411
  }
7296
7412
  }
7297
7413
  else {
7298
- await tmux.sendEnter(pane);
7414
+ await tmux.sendEnter(pane, runtime);
7299
7415
  }
7300
7416
  }
7301
7417
  catch (preAckErr) {
@@ -7304,7 +7420,7 @@ export class AgentManager {
7304
7420
  if (paneWorking) {
7305
7421
  throw new DispatchTerminalError('ack_unknown', `pre-ack failure left an unconfirmed composer on working pane ${paneId}: ${message}`);
7306
7422
  }
7307
- if (await this.clearComposerForReuse(tmux, pane, agentId))
7423
+ if (await this.clearComposerForReuse(tmux, pane, agentId, runtime))
7308
7424
  throw preAckErr;
7309
7425
  throw new DispatchTerminalError('ack_unknown', `pre-ack failure left an unconfirmed composer on live pane ${paneId}: ${message}`);
7310
7426
  }
@@ -7321,13 +7437,13 @@ export class AgentManager {
7321
7437
  const resent = await this.withTaskLock(async () => {
7322
7438
  if (!(await guardBeforePaste()))
7323
7439
  return false;
7324
- await tmux.sendEnter(pane);
7440
+ await tmux.sendEnter(pane, runtime);
7325
7441
  return true;
7326
7442
  });
7327
7443
  if (!resent)
7328
7444
  staleDuringResend = true;
7329
7445
  }
7330
- : () => tmux.sendEnter(pane),
7446
+ : () => tmux.sendEnter(pane, runtime),
7331
7447
  resendIntervalMs: this.dispatchAckResendIntervalMs,
7332
7448
  });
7333
7449
  return { acked: true, composerDelivered: true };
@@ -7339,7 +7455,7 @@ export class AgentManager {
7339
7455
  }
7340
7456
  if (staleDuringResend) {
7341
7457
  try {
7342
- await tmux.clearComposerDraft(pane);
7458
+ await tmux.clearComposerDraft(pane, runtime);
7343
7459
  }
7344
7460
  catch (scrubErr) {
7345
7461
  const scrubMessage = scrubErr instanceof Error ? scrubErr.message : String(scrubErr);
@@ -7360,10 +7476,10 @@ export class AgentManager {
7360
7476
  return { acked: false, composerDelivered: true };
7361
7477
  }
7362
7478
  }
7363
- async clearComposerForReuse(tmux, pane, agentId) {
7479
+ async clearComposerForReuse(tmux, pane, agentId, runtime) {
7364
7480
  const paneId = pane.paneId;
7365
7481
  try {
7366
- await tmux.clearComposerDraft(pane);
7482
+ await tmux.clearComposerDraft(pane, runtime);
7367
7483
  return true;
7368
7484
  }
7369
7485
  catch (err) {
@@ -8014,58 +8130,65 @@ export class AgentManager {
8014
8130
  console.warn('[AgentManager] recover: pending git review retry failed:', err);
8015
8131
  });
8016
8132
  }
8017
- async reconcileFailedAgent(agentId) {
8018
- const reconciled = await this.withTaskLock(async () => {
8019
- let projectId = '';
8020
- let timestamp = '';
8021
- let hadBinding = false;
8022
- let changed = false;
8023
- let taskId;
8024
- await this.agentStore.update(agentId, (existing) => {
8025
- if (!existing)
8026
- return AGENT_STORE_NOOP;
8027
- if (existing.creationToken)
8028
- return AGENT_STORE_NOOP;
8029
- if (existing.awaitingPhase != null && REGREET_REQUIRED_HOLD_PHASES.has(existing.awaitingPhase)) {
8030
- return AGENT_STORE_NOOP;
8031
- }
8032
- timestamp = new Date().toISOString();
8033
- projectId = existing.projectId;
8034
- hadBinding = !!existing.taskId;
8035
- taskId = existing.taskId;
8036
- if (!existing.taskId
8037
- && !existing.startedAt
8038
- && !existing.paneId
8039
- && !existing.creationToken) {
8040
- return AGENT_STORE_NOOP;
8041
- }
8042
- changed = true;
8043
- if (existing.taskId) {
8133
+ // absent 结论在等锁期间可能已过期:与 retry/删除的会话重建同处一条生命周期链,链内重探仍 absent、binding 写入时 stillCurrent 仍真,binding 与任务才在同一临界区内一起落下
8134
+ async reconcileFailedAgent(agentId, opts = {}) {
8135
+ const stillCurrent = opts.stillCurrent ?? (() => true);
8136
+ const reconciled = await this.runUnderSessionLifecycle(agentId, async () => {
8137
+ if (!(await this.sessionStillAbsent(agentId)))
8138
+ return null;
8139
+ return this.withTaskLock(async () => {
8140
+ let projectId = '';
8141
+ let timestamp = '';
8142
+ let hadBinding = false;
8143
+ let changed = false;
8144
+ let taskId;
8145
+ await this.agentStore.update(agentId, (existing) => {
8146
+ if (!existing || !stillCurrent())
8147
+ return AGENT_STORE_NOOP;
8148
+ if (existing.creationToken)
8149
+ return AGENT_STORE_NOOP;
8150
+ if (existing.awaitingPhase != null && REGREET_REQUIRED_HOLD_PHASES.has(existing.awaitingPhase)) {
8151
+ return AGENT_STORE_NOOP;
8152
+ }
8153
+ timestamp = new Date().toISOString();
8154
+ projectId = existing.projectId;
8155
+ hadBinding = !!existing.taskId;
8156
+ taskId = existing.taskId;
8157
+ if (!existing.taskId
8158
+ && !existing.startedAt
8159
+ && !existing.paneId
8160
+ && !existing.creationToken) {
8161
+ return AGENT_STORE_NOOP;
8162
+ }
8163
+ changed = true;
8164
+ if (existing.taskId) {
8165
+ return {
8166
+ ...existing,
8167
+ paneId: undefined,
8168
+ status: 'awaiting_human',
8169
+ awaitingPhase: 'runtime-missing',
8170
+ awaitingReason: 'The tmux session is missing; restart the REPL before releasing this task binding.',
8171
+ awaitingSince: timestamp,
8172
+ updatedAt: timestamp,
8173
+ };
8174
+ }
8044
8175
  return {
8045
- ...existing,
8046
- paneId: undefined,
8047
- status: 'awaiting_human',
8048
- awaitingPhase: 'runtime-missing',
8049
- awaitingReason: 'The tmux session is missing; restart the REPL before releasing this task binding.',
8050
- awaitingSince: timestamp,
8176
+ id: existing.id,
8177
+ projectId: existing.projectId,
8178
+ ...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
8051
8179
  updatedAt: timestamp,
8052
8180
  };
8053
- }
8054
- return {
8055
- id: existing.id,
8056
- projectId: existing.projectId,
8057
- ...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
8058
- updatedAt: timestamp,
8059
- };
8181
+ });
8182
+ if (!projectId || !changed)
8183
+ return null;
8184
+ const failed = hadBinding ? await this.failBoundTasksLocked(agentId) : [];
8185
+ return { projectId, timestamp, hadBinding, taskId, failed };
8060
8186
  });
8061
- if (!projectId || !changed)
8062
- return null;
8063
- return { projectId, timestamp, hadBinding, taskId };
8064
8187
  });
8065
8188
  if (!reconciled)
8066
8189
  return false;
8067
8190
  if (reconciled.hadBinding) {
8068
- await this.failTasksForAgent(agentId, 'tmux-probe=absent');
8191
+ await this.publishFailedTasks(agentId, 'tmux-probe=absent', reconciled.failed);
8069
8192
  }
8070
8193
  await this.recordError({
8071
8194
  agentId,
@@ -8088,6 +8211,21 @@ export class AgentManager {
8088
8211
  });
8089
8212
  return true;
8090
8213
  }
8214
+ // 链内重探与探测器同一口径:没有同名会话、或同名会话的 claim 不是本 agent 才算 absent;探不到(含超时)不能按 absent 落 hold,那会把刚重建好的 REPL 上的任务标成失败
8215
+ async sessionStillAbsent(agentId) {
8216
+ const cfg = this.getAgentConfig(agentId);
8217
+ if (!cfg)
8218
+ return true;
8219
+ try {
8220
+ const snapshot = await new TmuxManager(this.createRunnerFor(cfg))
8221
+ .getSessionSnapshot(agentId, { timeout: this.config.server.tmuxProbeTimeoutMs });
8222
+ return snapshot === null || snapshot.claim !== agentId;
8223
+ }
8224
+ catch (err) {
8225
+ console.warn(`[AgentManager] reconcileFailedAgent(${agentId}): session re-probe inconclusive; leaving the binding untouched:`, err);
8226
+ return false;
8227
+ }
8228
+ }
8091
8229
  async cancelTask(taskId) {
8092
8230
  let devToRelease;
8093
8231
  let qaToRelease;
@@ -8536,12 +8674,9 @@ export class AgentManager {
8536
8674
  }
8537
8675
  opts.onSideEffect?.();
8538
8676
  lease = begun.task.reviewDispatch;
8539
- if (opts.expectedTask) {
8540
- dispatchExpectedTask = {
8541
- ...opts.expectedTask,
8542
- signalToken: begun.task.signalToken,
8543
- };
8544
- }
8677
+ // 调用方那一代已在 beginGitReviewPass 内校验过,此后要挡的是 pass 开出去之后的改动
8678
+ if (opts.expectedTask)
8679
+ dispatchExpectedTask = taskGenerationGuard(begun.task);
8545
8680
  }
8546
8681
  const dispatched = await this.dispatchGitReviewLease(taskId, {
8547
8682
  expectedGeneration: lease.generation,
@@ -10060,8 +10195,8 @@ export class AgentManager {
10060
10195
  || (taskId !== undefined && afterResolve.lockToken !== lockToken)) {
10061
10196
  throw new Error(`injectTextToAgent: agent ${agentId} binding changed before paste`);
10062
10197
  }
10063
- await tmux.injectPrompt(pane, text, agentId);
10064
- await tmux.sendEnter(pane);
10198
+ await tmux.injectPrompt(pane, text, agentId, cfg.runtime);
10199
+ await tmux.sendEnter(pane, cfg.runtime);
10065
10200
  }
10066
10201
  finally {
10067
10202
  this.compactInFlight.delete(agentId);