baxian 1.0.0 → 1.0.1

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.
Files changed (97) hide show
  1. package/dist/agent/bootstrap-poller.d.ts.map +1 -1
  2. package/dist/agent/bootstrap-poller.js +2 -4
  3. package/dist/agent/bootstrap-poller.js.map +1 -1
  4. package/dist/agent/bootstrap.d.ts +3 -2
  5. package/dist/agent/bootstrap.d.ts.map +1 -1
  6. package/dist/agent/bootstrap.js +12 -15
  7. package/dist/agent/bootstrap.js.map +1 -1
  8. package/dist/agent/liveness.d.ts +10 -0
  9. package/dist/agent/liveness.d.ts.map +1 -0
  10. package/dist/agent/liveness.js +11 -0
  11. package/dist/agent/liveness.js.map +1 -0
  12. package/dist/agent/manager.d.ts +5 -2
  13. package/dist/agent/manager.d.ts.map +1 -1
  14. package/dist/agent/manager.js +250 -21
  15. package/dist/agent/manager.js.map +1 -1
  16. package/dist/agent/pane-streamer-manager.d.ts +3 -1
  17. package/dist/agent/pane-streamer-manager.d.ts.map +1 -1
  18. package/dist/agent/pane-streamer-manager.js +8 -1
  19. package/dist/agent/pane-streamer-manager.js.map +1 -1
  20. package/dist/agent/pane-streamer.d.ts +3 -2
  21. package/dist/agent/pane-streamer.d.ts.map +1 -1
  22. package/dist/agent/pane-streamer.js +13 -3
  23. package/dist/agent/pane-streamer.js.map +1 -1
  24. package/dist/agent/preflight.d.ts +2 -2
  25. package/dist/agent/preflight.d.ts.map +1 -1
  26. package/dist/agent/preflight.js +10 -8
  27. package/dist/agent/preflight.js.map +1 -1
  28. package/dist/agent/repo-store.js +5 -5
  29. package/dist/agent/repo-store.js.map +1 -1
  30. package/dist/agent/runner.d.ts +13 -2
  31. package/dist/agent/runner.d.ts.map +1 -1
  32. package/dist/agent/runner.js +129 -28
  33. package/dist/agent/runner.js.map +1 -1
  34. package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
  35. package/dist/agent/tmux-probe-poller.js +5 -3
  36. package/dist/agent/tmux-probe-poller.js.map +1 -1
  37. package/dist/agent/tmux.d.ts +8 -5
  38. package/dist/agent/tmux.d.ts.map +1 -1
  39. package/dist/agent/tmux.js +68 -7
  40. package/dist/agent/tmux.js.map +1 -1
  41. package/dist/api/config.d.ts +3 -1
  42. package/dist/api/config.d.ts.map +1 -1
  43. package/dist/api/config.js +66 -0
  44. package/dist/api/config.js.map +1 -1
  45. package/dist/api/hosts.d.ts +7 -0
  46. package/dist/api/hosts.d.ts.map +1 -0
  47. package/dist/api/hosts.js +321 -0
  48. package/dist/api/hosts.js.map +1 -0
  49. package/dist/api/probe.d.ts.map +1 -1
  50. package/dist/api/probe.js +32 -8
  51. package/dist/api/probe.js.map +1 -1
  52. package/dist/api/projects.d.ts.map +1 -1
  53. package/dist/api/projects.js +32 -13
  54. package/dist/api/projects.js.map +1 -1
  55. package/dist/api/tasks.d.ts.map +1 -1
  56. package/dist/api/tasks.js +10 -0
  57. package/dist/api/tasks.js.map +1 -1
  58. package/dist/app.d.ts.map +1 -1
  59. package/dist/app.js +2 -0
  60. package/dist/app.js.map +1 -1
  61. package/dist/cli.d.ts +2 -1
  62. package/dist/cli.d.ts.map +1 -1
  63. package/dist/cli.js +18 -8
  64. package/dist/cli.js.map +1 -1
  65. package/dist/config/loader.d.ts.map +1 -1
  66. package/dist/config/loader.js +28 -0
  67. package/dist/config/loader.js.map +1 -1
  68. package/dist/config/normalizer.d.ts.map +1 -1
  69. package/dist/config/normalizer.js +1 -0
  70. package/dist/config/normalizer.js.map +1 -1
  71. package/dist/config/validator.d.ts.map +1 -1
  72. package/dist/config/validator.js +70 -10
  73. package/dist/config/validator.js.map +1 -1
  74. package/dist/event/handlers.d.ts.map +1 -1
  75. package/dist/event/handlers.js +54 -11
  76. package/dist/event/handlers.js.map +1 -1
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/index.js +7 -2
  79. package/dist/index.js.map +1 -1
  80. package/dist/shared/constants.d.ts.map +1 -1
  81. package/dist/shared/constants.js +5 -2
  82. package/dist/shared/constants.js.map +1 -1
  83. package/dist/shared/types.d.ts +6 -1
  84. package/dist/shared/types.d.ts.map +1 -1
  85. package/dist/state/snapshot.d.ts.map +1 -1
  86. package/dist/state/snapshot.js +3 -2
  87. package/dist/state/snapshot.js.map +1 -1
  88. package/dist/terminal/attach.d.ts +3 -2
  89. package/dist/terminal/attach.d.ts.map +1 -1
  90. package/dist/terminal/attach.js +10 -7
  91. package/dist/terminal/attach.js.map +1 -1
  92. package/dist/web/assets/index-CuCB0XN0.css +1 -0
  93. package/dist/web/assets/index-DrggXuSi.js +4 -0
  94. package/dist/web/index.html +2 -2
  95. package/package.json +1 -1
  96. package/dist/web/assets/index-BfF2mK4D.css +0 -1
  97. package/dist/web/assets/index-DCAoAnPR.js +0 -4
@@ -2,13 +2,13 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { createSignalToken } from './phase-signal.js';
5
- import { BRANCH_PREFIX, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, } from '../shared/index.js';
5
+ import { BRANCH_PREFIX, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, } from '../shared/index.js';
6
6
  import { AGENT_STORE_NOOP } from '../state/agent-store.js';
7
7
  import { PostApproveStore } from '../state/post-approve-store.js';
8
8
  import { SkillRegistry } from '../skill/registry.js';
9
- import { createRunner, LocalRunner, shellQuote } from './runner.js';
9
+ import { createRunner, LocalRunner, shellQuote, resolveAgentHost } from './runner.js';
10
10
  import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
11
- import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, detectReplActiveBusy, hasReplReadyAnchor, hasReplProcTitle, } from './tmux.js';
11
+ import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, detectReplActiveBusy, hasRuntimeReadyView, hasReplProcTitle, } from './tmux.js';
12
12
  import { WorktreeManager } from './worktree.js';
13
13
  import { RepoStore, createRepoStoreCache } from './repo-store.js';
14
14
  import { PhaseSignalWatcher } from './phase-signal-watcher.js';
@@ -72,7 +72,6 @@ function agentRuntimeKindFor(agent) {
72
72
  }
73
73
  const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
74
74
  const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
75
- const ACTIVE_TASK_STATUSES = new Set(['in_progress', 'review', 'fixing', 'approved', 'merge-ready']);
76
75
  // Dev-facing deliverable phases that weave the task's uploaded image paths into the prompt (task-055).
77
76
  const IMAGE_DISPATCH_PHASES = new Set(['develop', 'code', 'fix', 'spec-fix']);
78
77
  export function canDispatchWithBinding(binding) {
@@ -134,6 +133,10 @@ export class AgentManager {
134
133
  postMergeBranchTimeoutMs = 10_000;
135
134
  // taskIds with in-flight manual review — second concurrent POST gets 409.
136
135
  manualReviewInFlight = new Set();
136
+ // taskIds with an in-flight mark-complete (slow external `gh pr merge`). While set, the
137
+ // task is being merged — Cancel / Call review / Continue must refuse so they can't act on
138
+ // the same max_rounds snapshot and interleave with the irreversible merge.
139
+ markCompleteInFlight = new Set();
137
140
  // agentIds with in-flight DELETE — 第二个 DELETE 撞 awaiting_human stale-lock takeover 路径会
138
141
  // 把第一个 DELETE 持有的占位也当 stale 接管,导致并发 cleanupRemovedAgentRuntime。
139
142
  deletionInFlight = new Set();
@@ -1333,9 +1336,8 @@ export class AgentManager {
1333
1336
  const tasks = await this.taskStore.list({});
1334
1337
  const out = [];
1335
1338
  for (const t of tasks) {
1336
- const active = ['in_progress', 'review', 'fixing', 'approved', 'merge-ready'];
1337
1339
  const bound = t.agentId === agentId || t.qaAgentId === agentId;
1338
- if (active.includes(t.status) && bound) {
1340
+ if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
1339
1341
  t.status = 'failed';
1340
1342
  t.updatedAt = new Date().toISOString();
1341
1343
  await this.taskStore.set(t);
@@ -1882,13 +1884,14 @@ export class AgentManager {
1882
1884
  if (this.runnerFactory) {
1883
1885
  return this.runnerFactory(agent);
1884
1886
  }
1885
- return createRunner(agent.mode, agent.host);
1887
+ return createRunner(agent.mode, resolveAgentHost(this.config.host, agent.host));
1886
1888
  }
1887
1889
  createRepoStore(agent, project, runner) {
1890
+ const host = resolveAgentHost(this.config.host, agent.host);
1888
1891
  if (this.repoStoreFactory) {
1889
- return this.repoStoreFactory(runner, project.repo, agent.mode, agent.host, this.repoCache);
1892
+ return this.repoStoreFactory(runner, project.repo, agent.mode, host, this.repoCache);
1890
1893
  }
1891
- return new RepoStore(runner, project.repo, agent.mode, agent.host, this.repoCache);
1894
+ return new RepoStore(runner, project.repo, agent.mode, host, this.repoCache);
1892
1895
  }
1893
1896
  async ensureWorkdir(agent, project, runner) {
1894
1897
  if (agent.workdir)
@@ -3001,6 +3004,39 @@ export class AgentManager {
3001
3004
  // 处理 Held:与 resumeAgent 共用 shouldReleaseHeldBinding 规则(task terminal/无 task /
3002
3005
  // turn-completed phase → 同步清 binding;task active 且 phase 不在 completed 集合 → 保留 binding)。
3003
3006
  const boundTask = state.taskId ? await this.taskStore.get(state.taskId) : null;
3007
+ if (state.taskId
3008
+ && boundTask?.id === state.taskId
3009
+ && boundTask.status === 'merged'
3010
+ && boundTask.prNumber != null
3011
+ && boundTask.branch
3012
+ && !state.creationToken
3013
+ && state.status !== 'awaiting_human') {
3014
+ try {
3015
+ await this.agentStore.update(state.id, (latest) => {
3016
+ if (!latest || latest.taskId !== boundTask.id)
3017
+ return AGENT_STORE_NOOP;
3018
+ if (latest.creationToken || latest.status === 'awaiting_human')
3019
+ return AGENT_STORE_NOOP;
3020
+ return { ...latest, paneId: result.paneId, updatedAt: new Date().toISOString() };
3021
+ });
3022
+ const recovered = await this.agentStore.get(state.id);
3023
+ if (recovered?.taskId !== boundTask.id
3024
+ || recovered.paneId !== result.paneId
3025
+ || recovered.creationToken
3026
+ || recovered.status === 'awaiting_human') {
3027
+ continue;
3028
+ }
3029
+ await this.dispatchPostMergeCleanup(state.id, {
3030
+ prNumber: boundTask.prNumber,
3031
+ taskId: boundTask.id,
3032
+ branch: boundTask.branch,
3033
+ });
3034
+ continue;
3035
+ }
3036
+ catch (cleanupErr) {
3037
+ console.warn(`[recover] dispatchPostMergeCleanup(${state.id}, ${boundTask.id}) failed:`, cleanupErr);
3038
+ }
3039
+ }
3004
3040
  const shouldReleaseBinding = shouldReleaseHeldBinding(state, boundTask);
3005
3041
  // 释放 binding 时同步清 worktree(与 resumeAgent 一致)——否则跨重启恢复后
3006
3042
  // worktreePath 在下面 update 中被丢弃,磁盘上的 worktree 永远无人回收。
@@ -3206,6 +3242,12 @@ export class AgentManager {
3206
3242
  throw new ApiError(404, 'Task not found');
3207
3243
  if (TERMINAL_STATUSES.includes(task.status))
3208
3244
  return task;
3245
+ // A mark-complete merge is mid-flight (task is merge-ready, PR being merged) — refuse
3246
+ // to cancel so the merge can't land while the task is flipped to cancelled (which would
3247
+ // make pr.merged a no-op and skip cleanup). Checked under the lock to close the window.
3248
+ if (this.markCompleteInFlight.has(taskId)) {
3249
+ throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`);
3250
+ }
3209
3251
  if (task.agentId)
3210
3252
  devToRelease = task.agentId;
3211
3253
  if (task.qaAgentId)
@@ -3292,9 +3334,22 @@ export class AgentManager {
3292
3334
  if (this.manualReviewInFlight.has(taskId)) {
3293
3335
  throw new ApiError(409, `Manual review already in progress for task ${taskId}`);
3294
3336
  }
3337
+ // A mark-complete merge is mid-flight — refuse so Call review can't flip the
3338
+ // merge-ready task back to review while the PR is being merged.
3339
+ if (this.markCompleteInFlight.has(taskId)) {
3340
+ throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`);
3341
+ }
3295
3342
  const task = await this.taskStore.get(taskId);
3296
3343
  if (!task)
3297
3344
  throw new ApiError(404, `Task ${taskId} not found`);
3345
+ // spec-phase max_rounds escapes via Retry/Cancel only. Call review dispatches the
3346
+ // CODE-review protocol, but review.submitted early-returns for spec phase — so a direct
3347
+ // /tasks/:id/review here would transition the task to review + bind QA, yet its verdict
3348
+ // could never advance it or release the QA. Guard the server entry (UI already hides it),
3349
+ // matching the continue/complete spec guards.
3350
+ if (task.phase === 'spec' && task.status === 'max_rounds') {
3351
+ throw new ApiError(409, `Call review is not supported for spec-phase max_rounds tasks (use Retry or Cancel)`);
3352
+ }
3298
3353
  if (!task.prNumber) {
3299
3354
  throw new ApiError(400, `Task ${taskId} has no PR yet; cannot dispatch review`);
3300
3355
  }
@@ -3338,19 +3393,26 @@ export class AgentManager {
3338
3393
  // emit dev-parked intervention 的旗标。
3339
3394
  // .catch→false: 旧 approved 分支已有此模式,markAgentWaiting reject (store/lock IO 异常) 时
3340
3395
  // 不能直接跳出 try/finally — QA 已 acquire (binding+lock) 必须先 release 清理才能 throw。
3396
+ // Park dev only when it is still bound to THIS task. A paused max_rounds task
3397
+ // released its dev (spec phase) or kept it reserved (code phase); a released
3398
+ // dev has no running session to park, and markAgentWaiting would fail the
3399
+ // taskId-match check (manager.ts releaseAgentForTask) → spurious 500.
3341
3400
  let devParked = false;
3342
3401
  if (!isTerminal && devAgentId) {
3343
- const devOk = await this.markAgentWaiting(devAgentId, taskId)
3344
- .catch(err => {
3345
- console.warn(`[dispatchReviewToQa] markAgentWaiting(dev=${devAgentId}) threw:`, err);
3346
- return false;
3347
- });
3348
- if (!devOk) {
3349
- await this.releaseAgentForTask(qaId, taskId, 'idle')
3350
- .catch(() => undefined);
3351
- throw new ApiError(500, `Cannot park dev ${devAgentId} into waiting for manual QA review (task status=${taskStatusAtClaim}); QA released`);
3402
+ const devState = await this.agentStore.get(devAgentId);
3403
+ if (devState?.taskId === taskId) {
3404
+ const devOk = await this.markAgentWaiting(devAgentId, taskId)
3405
+ .catch(err => {
3406
+ console.warn(`[dispatchReviewToQa] markAgentWaiting(dev=${devAgentId}) threw:`, err);
3407
+ return false;
3408
+ });
3409
+ if (!devOk) {
3410
+ await this.releaseAgentForTask(qaId, taskId, 'idle')
3411
+ .catch(() => undefined);
3412
+ throw new ApiError(500, `Cannot park dev ${devAgentId} into waiting for manual QA review (task status=${taskStatusAtClaim}); QA released`);
3413
+ }
3414
+ devParked = true;
3352
3415
  }
3353
- devParked = true;
3354
3416
  }
3355
3417
  // PHASE 0 — snapshot fields PHASE 1/2 may overwrite, so rollback can
3356
3418
  // restore them exactly (qaAgentId / signalToken / reviewHeadAnchorSha).
@@ -3471,6 +3533,89 @@ export class AgentManager {
3471
3533
  this.manualReviewInFlight.delete(taskId);
3472
3534
  }
3473
3535
  }
3536
+ // Manually push a code-phase max_rounds task through one more dev fix round.
3537
+ // Reuses the fixing dispatch chain (fixing → pr-fixed watcher → continueSession),
3538
+ // bypassing the review cap for this one round; the round still increments and the
3539
+ // task re-pauses at max_rounds if QA requests changes again. The dev is the
3540
+ // reserved one from the pause (§2.1), so its worktree is reused as-is.
3541
+ async continueDevRound(taskId) {
3542
+ // A mark-complete merge may be mid-flight after claiming the task but before the
3543
+ // max_rounds → merge-ready transition lands; refuse so the two can't both act on the
3544
+ // same max_rounds snapshot (the merge-ready status guard covers the post-transition window).
3545
+ if (this.markCompleteInFlight.has(taskId)) {
3546
+ throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`);
3547
+ }
3548
+ const task = await this.taskStore.get(taskId);
3549
+ if (!task)
3550
+ throw new ApiError(404, `Task ${taskId} not found`);
3551
+ if (task.status !== 'max_rounds') {
3552
+ throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
3553
+ }
3554
+ if (task.phase === 'spec') {
3555
+ throw new ApiError(409, `Continue one round is only supported for code-phase tasks`);
3556
+ }
3557
+ if (!task.prNumber || !task.branch) {
3558
+ throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot continue`);
3559
+ }
3560
+ if (!task.agentId) {
3561
+ throw new ApiError(400, `Task ${taskId} has no dev agent; cannot continue`);
3562
+ }
3563
+ // Retained-dev precondition: the paused dev must still hold this task and its
3564
+ // worktree. If broken (cancelled, reassigned, external interference), continueSession
3565
+ // would have no checkout to reuse — steer the user to Retry instead of recreating it.
3566
+ const devAgentId = task.agentId;
3567
+ const devState = await this.agentStore.get(devAgentId);
3568
+ if (devState?.taskId !== taskId || !devState.worktreePath) {
3569
+ // code-phase max_rounds has no Retry (Continue/Complete/Cancel only), so don't
3570
+ // point at Retry: the work is on the PR — merge it (mark-complete) or abandon (cancel).
3571
+ throw new ApiError(409, `Dev ${devAgentId} no longer holds task ${taskId}'s reserved worktree (cannot continue); ` +
3572
+ `use mark-complete to merge the PR as-is, or cancel the task`);
3573
+ }
3574
+ const prevReviewRound = task.reviewRound;
3575
+ const transitioned = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['max_rounds'] }, { reviewRound: prevReviewRound + 1, fixDispatchedAt: new Date().toISOString() });
3576
+ if (!transitioned) {
3577
+ throw new ApiError(409, `Task ${taskId} changed status during continue; aborted`);
3578
+ }
3579
+ const rollback = async () => {
3580
+ await this.transitionTaskStatus(taskId, 'max_rounds', { fromStatus: ['fixing'] }, { reviewRound: prevReviewRound }).catch(() => undefined);
3581
+ };
3582
+ const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'fix');
3583
+ if (!acquired) {
3584
+ await rollback();
3585
+ throw new ApiError(409, `Dev ${devAgentId} is no longer available for task ${taskId}`);
3586
+ }
3587
+ const { armed } = await this.rotateAndSetupPhaseSignal(taskId, devAgentId, 'pr-fixed');
3588
+ if (!armed) {
3589
+ await this.markAwaitingHuman(devAgentId, 'signal-arm-failed:pr-fixed', 'pr-fixed watcher failed to arm; the fix was not dispatched (its completion signal would have no consumer). Cancel the task or delete the agent to retry.', { expectedTaskId: taskId });
3590
+ await rollback();
3591
+ throw new ApiError(500, `Failed to arm pr-fixed watcher for task ${taskId}`);
3592
+ }
3593
+ // rollback returns the task to max_rounds AND re-parks the dev to waiting, keeping
3594
+ // the reserved-dev invariant (bound + 'waiting' + worktree) so a later continue/cancel
3595
+ // sees a consistent state and the snapshot shows 'waiting', not a stale 'working'.
3596
+ const rollbackAndRepark = async () => {
3597
+ await rollback();
3598
+ await this.markAgentWaiting(devAgentId, taskId).catch(() => undefined);
3599
+ };
3600
+ let resumed = false;
3601
+ try {
3602
+ resumed = await this.continueSession(taskId, devAgentId, 'fix');
3603
+ }
3604
+ catch (err) {
3605
+ if (err instanceof DispatchTerminalError) {
3606
+ await this.failTaskForDispatchError(taskId, 'fix', devAgentId, err);
3607
+ throw new ApiError(500, `Continue dispatch failed: ${err.message}`);
3608
+ }
3609
+ await rollbackAndRepark();
3610
+ throw err;
3611
+ }
3612
+ if (!resumed) {
3613
+ await rollbackAndRepark();
3614
+ throw new ApiError(500, `Failed to dispatch fix to dev ${devAgentId} for task ${taskId}`);
3615
+ }
3616
+ const fresh = await this.taskStore.get(taskId);
3617
+ return fresh;
3618
+ }
3474
3619
  // Undo PHASE 1+2 of dispatchReviewToQa when startSession ultimately fails
3475
3620
  // (resolved false, or threw a hard error other than ack_unknown / dialog).
3476
3621
  // Restores task fields to the pre-dispatch snapshot and re-establishes the pane-signal
@@ -3603,7 +3748,11 @@ export class AgentManager {
3603
3748
  const t = await this.taskStore.get(taskId);
3604
3749
  if (!t)
3605
3750
  throw new ApiError(404, 'Task not found');
3606
- if (!TERMINAL_STATUSES.includes(t.status)) {
3751
+ // max_rounds is non-terminal but still retryable for spec-phase tasks (their
3752
+ // only escape this iteration). code-phase max_rounds uses continue/complete/cancel.
3753
+ const retryable = TERMINAL_STATUSES.includes(t.status)
3754
+ || (t.status === 'max_rounds' && t.phase === 'spec');
3755
+ if (!retryable) {
3607
3756
  throw new ApiError(409, `Task ${taskId} cannot be retried in status "${t.status}"; cancel it first or wait for completion`);
3608
3757
  }
3609
3758
  return t;
@@ -3619,6 +3768,12 @@ export class AgentManager {
3619
3768
  input.images = await this.readStagedImages(old.id, old.images);
3620
3769
  }
3621
3770
  await this.validateTaskDispatch(old.projectId, input);
3771
+ // Non-terminal retry (spec-phase max_rounds) must finalize the old paused task so it
3772
+ // leaves the active list instead of lingering beside the fresh run. Terminal tasks
3773
+ // already are their own history record and are left untouched.
3774
+ if (!TERMINAL_STATUSES.includes(old.status)) {
3775
+ await this.cancelTask(old.id);
3776
+ }
3622
3777
  return this.createAndStartTask(old.projectId, input);
3623
3778
  }
3624
3779
  async editTask(taskId, patch) {
@@ -3679,6 +3834,80 @@ export class AgentManager {
3679
3834
  throw new Error(`gh pr merge failed for PR #${task.prNumber}: ${result.stderr || result.stdout}`);
3680
3835
  }
3681
3836
  }
3837
+ // Manually finish a max_rounds task: merge its PR, then reuse the normal merged
3838
+ // cleanup chain (pr.merged handler → transition merged + post-merge worktree/branch
3839
+ // cleanup + /compact + release). Same path the poller drives when it detects the merge.
3840
+ async markTaskComplete(taskId) {
3841
+ const task = await this.taskStore.get(taskId);
3842
+ if (!task)
3843
+ throw new ApiError(404, `Task ${taskId} not found`);
3844
+ if (task.status !== 'max_rounds') {
3845
+ throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
3846
+ }
3847
+ // spec-phase max_rounds escapes via Retry/Cancel only (the UI hides complete). Guard the
3848
+ // endpoint too so a direct API call / older client can't merge a spec cap through here.
3849
+ if (task.phase === 'spec') {
3850
+ throw new ApiError(409, `Mark complete is only supported for code-phase tasks`);
3851
+ }
3852
+ if (!task.prNumber || !task.branch) {
3853
+ throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot mark complete`);
3854
+ }
3855
+ // Claim the task for the whole merge window. markCompleteInFlight blocks Cancel /
3856
+ // Call review / Continue (all re-check it under the task lock) so they can't act on
3857
+ // the same max_rounds snapshot and interleave with the irreversible `gh pr merge`.
3858
+ if (this.markCompleteInFlight.has(taskId)) {
3859
+ throw new ApiError(409, `Task ${taskId} is already being completed`);
3860
+ }
3861
+ this.markCompleteInFlight.add(taskId);
3862
+ try {
3863
+ // Held-agent check AFTER claiming (the claim blocks a new continueDevRound from starting),
3864
+ // and re-reading agent state here catches a continue that Held an agent in the window just
3865
+ // before our claim. dispatchPostMergeCleanup early-returns on awaiting_human, so merging with
3866
+ // a held dev/QA still bound to this task would orphan the merged task on a locked agent.
3867
+ // Bound to *this* task only — a stale id whose agent moved on is harmless (cleanup early-returns).
3868
+ for (const agentId of [task.agentId, task.qaAgentId]) {
3869
+ if (!agentId)
3870
+ continue;
3871
+ const state = await this.agentStore.get(agentId);
3872
+ if (state?.status === 'awaiting_human' && state.taskId === taskId) {
3873
+ throw new ApiError(409, `Agent ${agentId} is awaiting human intervention on this task; resume/restart/delete it before marking complete`);
3874
+ }
3875
+ }
3876
+ // Atomically transition max_rounds → merge-ready under the task lock. merge-ready is
3877
+ // active + already in pr.merged's fromStatus, so the post-merge cleanup chain runs;
3878
+ // combined with the in-flight claim it fully serializes against the other actions.
3879
+ const claimed = await this.transitionTaskStatus(taskId, 'merge-ready', { fromStatus: ['max_rounds'] });
3880
+ if (!claimed) {
3881
+ throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
3882
+ }
3883
+ try {
3884
+ await this.mergePr(taskId);
3885
+ }
3886
+ catch (err) {
3887
+ await this.transitionTaskStatus(taskId, 'max_rounds', { fromStatus: ['merge-ready'] })
3888
+ .catch(() => undefined);
3889
+ const message = err instanceof Error ? err.message : String(err);
3890
+ throw new ApiError(409, `Merge failed for task ${taskId}: ${message}`);
3891
+ }
3892
+ await this.eventBus.emit({
3893
+ id: '',
3894
+ type: 'pr.merged',
3895
+ timestamp: new Date().toISOString(),
3896
+ projectId: task.projectId,
3897
+ agentId: task.agentId,
3898
+ taskId: task.id,
3899
+ data: {
3900
+ prNumber: task.prNumber,
3901
+ ...(task.prUrl ? { prUrl: task.prUrl } : {}),
3902
+ },
3903
+ });
3904
+ const fresh = await this.taskStore.get(taskId);
3905
+ return fresh;
3906
+ }
3907
+ finally {
3908
+ this.markCompleteInFlight.delete(taskId);
3909
+ }
3910
+ }
3682
3911
  async cleanupAfterMerge(taskId) {
3683
3912
  const task = await this.taskStore.get(taskId);
3684
3913
  if (!task || !task.agentId)
@@ -3884,7 +4113,7 @@ export class AgentManager {
3884
4113
  throw new Error(`waitForReplPromptReady: pane ${paneId} pane_current_command=${current.trim()} (not runtime, REPL may have exited)`);
3885
4114
  }
3886
4115
  const cap = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
3887
- const ready = hasReplReadyAnchor(cap, runtime);
4116
+ const ready = hasRuntimeReadyView(cap, runtime);
3888
4117
  if (detectRuntimeMenu(cap) || (!ready && detectStartupDialog(cap, runtime))) {
3889
4118
  throw new Error(`waitForReplPromptReady: pane ${paneId} shows menu/dialog, not a ready REPL prompt`);
3890
4119
  }