baxian 1.0.0 → 1.0.2
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/bootstrap-poller.d.ts.map +1 -1
- package/dist/agent/bootstrap-poller.js +2 -4
- package/dist/agent/bootstrap-poller.js.map +1 -1
- package/dist/agent/bootstrap.d.ts +3 -2
- package/dist/agent/bootstrap.d.ts.map +1 -1
- package/dist/agent/bootstrap.js +12 -15
- package/dist/agent/bootstrap.js.map +1 -1
- package/dist/agent/liveness.d.ts +10 -0
- package/dist/agent/liveness.d.ts.map +1 -0
- package/dist/agent/liveness.js +11 -0
- package/dist/agent/liveness.js.map +1 -0
- package/dist/agent/manager.d.ts +5 -2
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +250 -21
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/pane-streamer-manager.d.ts +3 -1
- package/dist/agent/pane-streamer-manager.d.ts.map +1 -1
- package/dist/agent/pane-streamer-manager.js +8 -1
- package/dist/agent/pane-streamer-manager.js.map +1 -1
- package/dist/agent/pane-streamer.d.ts +3 -2
- package/dist/agent/pane-streamer.d.ts.map +1 -1
- package/dist/agent/pane-streamer.js +13 -3
- package/dist/agent/pane-streamer.js.map +1 -1
- package/dist/agent/preflight.d.ts +2 -2
- package/dist/agent/preflight.d.ts.map +1 -1
- package/dist/agent/preflight.js +10 -8
- package/dist/agent/preflight.js.map +1 -1
- package/dist/agent/repo-store.js +5 -5
- package/dist/agent/repo-store.js.map +1 -1
- package/dist/agent/runner.d.ts +13 -2
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +129 -28
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
- package/dist/agent/tmux-probe-poller.js +5 -3
- package/dist/agent/tmux-probe-poller.js.map +1 -1
- package/dist/agent/tmux.d.ts +8 -5
- package/dist/agent/tmux.d.ts.map +1 -1
- package/dist/agent/tmux.js +68 -7
- package/dist/agent/tmux.js.map +1 -1
- package/dist/api/config.d.ts +3 -1
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +66 -0
- package/dist/api/config.js.map +1 -1
- package/dist/api/hosts.d.ts +7 -0
- package/dist/api/hosts.d.ts.map +1 -0
- package/dist/api/hosts.js +321 -0
- package/dist/api/hosts.js.map +1 -0
- package/dist/api/probe.d.ts.map +1 -1
- package/dist/api/probe.js +32 -8
- package/dist/api/probe.js.map +1 -1
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +32 -13
- package/dist/api/projects.js.map +1 -1
- package/dist/api/tasks.d.ts.map +1 -1
- package/dist/api/tasks.js +10 -0
- package/dist/api/tasks.js.map +1 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +2 -0
- package/dist/app.js.map +1 -1
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +18 -8
- package/dist/cli.js.map +1 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +28 -0
- package/dist/config/loader.js.map +1 -1
- package/dist/config/normalizer.d.ts.map +1 -1
- package/dist/config/normalizer.js +1 -0
- package/dist/config/normalizer.js.map +1 -1
- package/dist/config/validator.d.ts.map +1 -1
- package/dist/config/validator.js +70 -10
- package/dist/config/validator.js.map +1 -1
- package/dist/event/handlers.d.ts.map +1 -1
- package/dist/event/handlers.js +54 -11
- package/dist/event/handlers.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/shared/constants.d.ts.map +1 -1
- package/dist/shared/constants.js +5 -2
- package/dist/shared/constants.js.map +1 -1
- package/dist/shared/types.d.ts +6 -1
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/state/snapshot.d.ts.map +1 -1
- package/dist/state/snapshot.js +3 -2
- package/dist/state/snapshot.js.map +1 -1
- package/dist/terminal/attach.d.ts +3 -2
- package/dist/terminal/attach.d.ts.map +1 -1
- package/dist/terminal/attach.js +10 -7
- package/dist/terminal/attach.js.map +1 -1
- package/dist/web/assets/index-BfCCF072.js +4 -0
- package/dist/web/assets/index-CuCB0XN0.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BfF2mK4D.css +0 -1
- package/dist/web/assets/index-DCAoAnPR.js +0 -4
package/dist/agent/manager.js
CHANGED
|
@@ -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,
|
|
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 (
|
|
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,
|
|
1892
|
+
return this.repoStoreFactory(runner, project.repo, agent.mode, host, this.repoCache);
|
|
1890
1893
|
}
|
|
1891
|
-
return new RepoStore(runner, project.repo, agent.mode,
|
|
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
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|