baxian 1.2.54 → 1.2.56
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 +2 -1
- package/dist/agent/bootstrap-poller.d.ts.map +1 -1
- package/dist/agent/bootstrap-poller.js +3 -1
- package/dist/agent/bootstrap-poller.js.map +1 -1
- package/dist/agent/bootstrap.d.ts +1 -1
- package/dist/agent/bootstrap.d.ts.map +1 -1
- package/dist/agent/bootstrap.js +12 -19
- package/dist/agent/bootstrap.js.map +1 -1
- package/dist/agent/branch.d.ts +64 -0
- package/dist/agent/branch.d.ts.map +1 -0
- package/dist/agent/branch.js +386 -0
- package/dist/agent/branch.js.map +1 -0
- package/dist/agent/detect/manifests/claude-code.json +2 -2
- package/dist/agent/detect/region.d.ts +1 -0
- package/dist/agent/detect/region.d.ts.map +1 -1
- package/dist/agent/detect/region.js +27 -19
- package/dist/agent/detect/region.js.map +1 -1
- package/dist/agent/lineage.d.ts +1 -1
- package/dist/agent/lineage.d.ts.map +1 -1
- package/dist/agent/lineage.js +6 -7
- package/dist/agent/lineage.js.map +1 -1
- package/dist/agent/manager.d.ts +38 -36
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +1616 -669
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/preflight.d.ts.map +1 -1
- package/dist/agent/preflight.js +19 -15
- package/dist/agent/preflight.js.map +1 -1
- package/dist/agent/prompt.d.ts +2 -2
- package/dist/agent/prompt.d.ts.map +1 -1
- package/dist/agent/prompt.js +9 -8
- package/dist/agent/prompt.js.map +1 -1
- package/dist/agent/repo-store.d.ts +8 -4
- package/dist/agent/repo-store.d.ts.map +1 -1
- package/dist/agent/repo-store.js +143 -54
- package/dist/agent/repo-store.js.map +1 -1
- package/dist/agent/review-transport.d.ts +11 -5
- package/dist/agent/review-transport.d.ts.map +1 -1
- package/dist/agent/review-transport.js +170 -19
- package/dist/agent/review-transport.js.map +1 -1
- package/dist/agent/runner.d.ts +1 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +5 -0
- package/dist/agent/runner.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 +21 -9
- package/dist/agent/tmux.js.map +1 -1
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +45 -8
- package/dist/api/config.js.map +1 -1
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +257 -71
- package/dist/api/projects.js.map +1 -1
- package/dist/api/tasks.js +2 -2
- package/dist/api/tasks.js.map +1 -1
- package/dist/config/validator.d.ts.map +1 -1
- package/dist/config/validator.js +75 -19
- package/dist/config/validator.js.map +1 -1
- package/dist/event/handlers.d.ts.map +1 -1
- package/dist/event/handlers.js +36 -26
- package/dist/event/handlers.js.map +1 -1
- package/dist/event/server-handlers.d.ts.map +1 -1
- package/dist/event/server-handlers.js +54 -34
- package/dist/event/server-handlers.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/shared/constants.d.ts +3 -2
- package/dist/shared/constants.d.ts.map +1 -1
- package/dist/shared/constants.js +8 -1
- package/dist/shared/constants.js.map +1 -1
- package/dist/shared/types.d.ts +35 -8
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js +8 -0
- package/dist/shared/types.js.map +1 -1
- package/dist/skills/baxian-pr-feedback/SKILL.md +1 -1
- package/dist/skills/baxian-pr-recheck/SKILL.md +1 -1
- package/dist/skills/baxian-pr-review/SKILL.md +1 -1
- package/dist/skills/baxian-research/SKILL.md +28 -0
- package/dist/skills/baxian-server-feedback/SKILL.md +6 -6
- package/dist/skills/baxian-server-review/SKILL.md +7 -7
- package/dist/skills/baxian-signals/SKILL.md +1 -1
- package/dist/skills/baxian-task-check/SKILL.md +4 -4
- package/dist/state/agent-store.js +4 -4
- package/dist/state/agent-store.js.map +1 -1
- package/dist/state/lock.d.ts +15 -2
- package/dist/state/lock.d.ts.map +1 -1
- package/dist/state/lock.js +104 -21
- package/dist/state/lock.js.map +1 -1
- package/dist/state/review-store.d.ts +4 -4
- package/dist/state/review-store.d.ts.map +1 -1
- package/dist/state/review-store.js +57 -6
- package/dist/state/review-store.js.map +1 -1
- package/dist/state/task-store.d.ts.map +1 -1
- package/dist/state/task-store.js +116 -18
- package/dist/state/task-store.js.map +1 -1
- package/dist/web/assets/index-3Ypk8Gfo.js +13 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/agent/worktree.d.ts +0 -32
- package/dist/agent/worktree.d.ts.map +0 -1
- package/dist/agent/worktree.js +0 -291
- package/dist/agent/worktree.js.map +0 -1
- package/dist/web/assets/index-Cx-wa7zd.js +0 -13
package/dist/agent/manager.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { join, normalize } from 'node:path';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { createSignalToken } from './phase-signal.js';
|
|
5
5
|
import { tmuxInstallHint } from './preflight.js';
|
|
6
|
-
import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, isGitHubRepo, parseGitRemote, repoSlug, } from '../shared/index.js';
|
|
6
|
+
import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, TASK_OWNER_ROLES, isSpecStagePhase, isGitHubRepo, parseGitRemote, repoSlug, } from '../shared/index.js';
|
|
7
7
|
import { AGENT_STORE_NOOP } from '../state/agent-store.js';
|
|
8
8
|
import { PostApproveStore } from '../state/post-approve-store.js';
|
|
9
9
|
import { SkillRegistry } from '../skill/registry.js';
|
|
10
|
-
import { createRunner, LocalRunner, shellQuote, resolveAgentHost, hostGroupKey } from './runner.js';
|
|
10
|
+
import { createRunner, LocalRunner, shellQuote, resolveAgentHost, hostGroupKey, workdirHostGroupKey, } from './runner.js';
|
|
11
11
|
import { GH_EXEC_TIMEOUT_MS, GIT_NET_ENV, execNetwork } from './net-exec.js';
|
|
12
12
|
import { findForeignTaskTip } from './lineage.js';
|
|
13
13
|
import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
|
|
14
14
|
import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, hasOscTitleWorking, hasOscTitleIdle, screenAllowsTitleIdle, } from './tmux.js';
|
|
15
|
-
import {
|
|
15
|
+
import { BranchManager, DirtyWorkdirError, ReviewHeadMismatchError } from './branch.js';
|
|
16
16
|
import { RepoStore, createRepoStoreCache } from './repo-store.js';
|
|
17
17
|
import { PhaseSignalWatcher } from './phase-signal-watcher.js';
|
|
18
18
|
import { ReviewTransport, resolveServerPayloads } from './review-transport.js';
|
|
@@ -46,23 +46,32 @@ export class DispatchTerminalError extends Error {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
export function buildLaunchCommand(agent) {
|
|
49
|
+
const yolo = agent.yolo !== false;
|
|
49
50
|
const segments = [];
|
|
50
51
|
switch (agent.runtime) {
|
|
51
52
|
case 'claude-code':
|
|
52
|
-
segments.push('env CLAUDE_CODE_NO_FLICKER=1 CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY=1 claude
|
|
53
|
+
segments.push('env CLAUDE_CODE_NO_FLICKER=1 CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY=1 claude');
|
|
54
|
+
if (yolo)
|
|
55
|
+
segments.push('--permission-mode bypassPermissions');
|
|
53
56
|
break;
|
|
54
57
|
case 'codex':
|
|
55
|
-
segments.push('codex
|
|
58
|
+
segments.push('codex');
|
|
59
|
+
if (yolo)
|
|
60
|
+
segments.push('--dangerously-bypass-approvals-and-sandbox');
|
|
56
61
|
break;
|
|
57
62
|
case 'opencode':
|
|
58
63
|
// opencode materializes skills into .agents/skills (shared with codex; see skillSubdirFor).
|
|
59
64
|
// Its .agents/.claude compatibility scans can't both be disabled by env, so also turn off
|
|
60
65
|
// the .claude/skills scan — that closes the last collision source: a claude-code agent's
|
|
61
66
|
// .claude/skills/baxian-* in a shared base repo.
|
|
62
|
-
segments.push('env OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 opencode
|
|
67
|
+
segments.push('env OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 opencode');
|
|
68
|
+
if (yolo)
|
|
69
|
+
segments.push('--auto');
|
|
63
70
|
break;
|
|
64
71
|
case 'qodercli':
|
|
65
|
-
segments.push('qodercli
|
|
72
|
+
segments.push('qodercli');
|
|
73
|
+
if (yolo)
|
|
74
|
+
segments.push('--dangerously-skip-permissions');
|
|
66
75
|
break;
|
|
67
76
|
}
|
|
68
77
|
if (agent.model) {
|
|
@@ -87,6 +96,27 @@ const REPL_EXIT_COMMAND = {
|
|
|
87
96
|
opencode: '/exit',
|
|
88
97
|
qodercli: '/quit',
|
|
89
98
|
};
|
|
99
|
+
const WORKDIR_SESSION_OPTION = '@baxian-workdir';
|
|
100
|
+
function normalizedDir(left) {
|
|
101
|
+
if (!left)
|
|
102
|
+
return null;
|
|
103
|
+
return left.replace(/\/+$/, '') || '/';
|
|
104
|
+
}
|
|
105
|
+
async function sameDirOnHost(runner, left, right) {
|
|
106
|
+
if (!left || !right)
|
|
107
|
+
return false;
|
|
108
|
+
if (normalizedDir(left) === normalizedDir(right))
|
|
109
|
+
return true;
|
|
110
|
+
const physical = async (path) => {
|
|
111
|
+
const result = await runner.exec(`cd -P ${shellQuote(path)} && pwd -P`);
|
|
112
|
+
const resolved = normalizedDir(result.stdout.trim());
|
|
113
|
+
if (result.exitCode !== 0 || !resolved) {
|
|
114
|
+
throw new Error(`Cannot resolve physical path for ${path}: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
|
|
115
|
+
}
|
|
116
|
+
return resolved;
|
|
117
|
+
};
|
|
118
|
+
return await physical(left) === await physical(right);
|
|
119
|
+
}
|
|
90
120
|
export function skillSubdirFor(runtime) {
|
|
91
121
|
switch (runtime) {
|
|
92
122
|
case 'codex':
|
|
@@ -107,21 +137,19 @@ const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
|
|
|
107
137
|
const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
|
|
108
138
|
const GH_NET = { timeout: GH_EXEC_TIMEOUT_MS, retries: 1 };
|
|
109
139
|
const MANUAL_SERVER_REVIEW_STATUSES = ['in_progress', 'review', 'fixing'];
|
|
110
|
-
const IMAGE_DISPATCH_PHASES = new Set(['develop', 'code', 'fix', 'server-feedback']);
|
|
140
|
+
const IMAGE_DISPATCH_PHASES = new Set(['develop', 'research', 'code', 'fix', 'server-feedback']);
|
|
111
141
|
const RUNTIME_LIVENESS_SAMPLES = 3;
|
|
112
142
|
export function canDispatchWithBinding(binding) {
|
|
113
143
|
return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
|
|
114
144
|
}
|
|
115
145
|
const TURN_COMPLETED_AWAITING_PHASES = new Set();
|
|
116
|
-
|
|
117
|
-
const CANCEL_CLEANUP_HOLD_PHASES = new Set(['cancel-clearing', 'cancel-clear-failed', 'cancel-interrupt-failed']);
|
|
146
|
+
const CANCEL_CLEANUP_HOLD_PHASES = new Set(['cancel-clearing', 'cancel-interrupt-failed']);
|
|
118
147
|
function isCancelCleanupHold(binding) {
|
|
119
148
|
return binding?.awaitingPhase != null && CANCEL_CLEANUP_HOLD_PHASES.has(binding.awaitingPhase);
|
|
120
149
|
}
|
|
121
150
|
const CANCEL_CLEANUP_PHASE_RANK = {
|
|
122
151
|
'cancel-clearing': 1,
|
|
123
152
|
'cancel-interrupt-failed': 2,
|
|
124
|
-
'cancel-clear-failed': 3,
|
|
125
153
|
};
|
|
126
154
|
function cancelPhaseRank(phase) {
|
|
127
155
|
return phase ? (CANCEL_CLEANUP_PHASE_RANK[phase] ?? 0) : 0;
|
|
@@ -171,8 +199,6 @@ export class AgentManager {
|
|
|
171
199
|
runtimeLivenessProbeMs = 700;
|
|
172
200
|
cleanComposerWaitMs = 5_000;
|
|
173
201
|
cancelInterruptGuardWaitMs = DEFAULT_DISPATCH_ACK_TIMEOUT_MS + 5_000;
|
|
174
|
-
postMergeFetchTimeoutMs = 60_000;
|
|
175
|
-
postMergeBranchTimeoutMs = 10_000;
|
|
176
202
|
manualReviewInFlight = new Set();
|
|
177
203
|
markCompleteInFlight = new Set();
|
|
178
204
|
specVerdictInFlight = new Set();
|
|
@@ -221,6 +247,39 @@ export class AgentManager {
|
|
|
221
247
|
this.taskMutationQueue = next.catch(() => undefined);
|
|
222
248
|
return next;
|
|
223
249
|
}
|
|
250
|
+
async resolveTaskLockToken(state, taskId) {
|
|
251
|
+
if (state.lockToken && await this.lockManager.isOwner(state.id, taskId, state.lockToken)) {
|
|
252
|
+
return state.lockToken;
|
|
253
|
+
}
|
|
254
|
+
if (state.lockToken)
|
|
255
|
+
return null;
|
|
256
|
+
const claim = await this.lockManager.claimOf(state.id);
|
|
257
|
+
const token = claim?.taskId === taskId ? claim.token : null;
|
|
258
|
+
if (!token)
|
|
259
|
+
return null;
|
|
260
|
+
await this.agentStore.update(state.id, (latest) => {
|
|
261
|
+
if (!latest || latest.taskId !== taskId || latest.lockToken)
|
|
262
|
+
return AGENT_STORE_NOOP;
|
|
263
|
+
return { ...latest, lockToken: token, updatedAt: new Date().toISOString() };
|
|
264
|
+
});
|
|
265
|
+
return token;
|
|
266
|
+
}
|
|
267
|
+
async assertTaskLockOwner(agentId, taskId, token) {
|
|
268
|
+
if (!(await this.lockManager.isOwner(agentId, taskId, token))) {
|
|
269
|
+
throw new Error(`Agent ${agentId} ownership changed for task ${taskId}; operation aborted`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
async assertTaskGeneration(agentId, taskId, lockToken, workdir) {
|
|
273
|
+
const state = await this.agentStore.get(agentId);
|
|
274
|
+
if (!state
|
|
275
|
+
|| state.taskId !== taskId
|
|
276
|
+
|| state.lockToken !== lockToken
|
|
277
|
+
|| state.workdir !== workdir) {
|
|
278
|
+
throw new Error(`Agent ${agentId} task generation changed for ${taskId}; operation aborted`);
|
|
279
|
+
}
|
|
280
|
+
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
281
|
+
return state;
|
|
282
|
+
}
|
|
224
283
|
getReviewStore() {
|
|
225
284
|
return this.reviewStore;
|
|
226
285
|
}
|
|
@@ -248,18 +307,27 @@ export class AgentManager {
|
|
|
248
307
|
getReviewTransport() {
|
|
249
308
|
this.reviewTransportInstance ??= new ReviewTransport({
|
|
250
309
|
createRunnerFor: (agent) => this.createRunnerFor(agent),
|
|
251
|
-
|
|
310
|
+
resolveWorkdir: (agentId) => this.bindingWorkdirCache.get(agentId),
|
|
252
311
|
});
|
|
253
312
|
return this.reviewTransportInstance;
|
|
254
313
|
}
|
|
255
|
-
|
|
256
|
-
|
|
314
|
+
async prepareDispatchArtifacts(agent, workdir, phase, opts) {
|
|
315
|
+
const transport = this.getReviewTransport();
|
|
316
|
+
if (!opts.preserveOutputs)
|
|
317
|
+
await transport.clearDispatchOutputs(agent, workdir, phase);
|
|
318
|
+
if (opts.specDocuments) {
|
|
319
|
+
await transport.replaceSpecDocuments(agent, workdir, opts.specDocuments, opts.assertOwner);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
bindingWorkdirCache = new Map();
|
|
323
|
+
async refreshWorkdirCacheFor(agentId) {
|
|
257
324
|
const state = await this.agentStore.get(agentId);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
325
|
+
const workdir = state?.workdir;
|
|
326
|
+
if (workdir) {
|
|
327
|
+
this.bindingWorkdirCache.set(agentId, workdir);
|
|
328
|
+
return workdir;
|
|
261
329
|
}
|
|
262
|
-
this.
|
|
330
|
+
this.bindingWorkdirCache.delete(agentId);
|
|
263
331
|
return undefined;
|
|
264
332
|
}
|
|
265
333
|
async safeEmit(event) {
|
|
@@ -293,8 +361,28 @@ export class AgentManager {
|
|
|
293
361
|
}
|
|
294
362
|
replaceConfig(validated) {
|
|
295
363
|
const config = prepareConfig(validated);
|
|
364
|
+
const previousConfig = this.config;
|
|
365
|
+
const previousIndex = this.agentIndex;
|
|
366
|
+
const nextIndex = buildAgentIndex(config);
|
|
367
|
+
for (const [ownerKey, agentId] of this.repoCache.owners) {
|
|
368
|
+
const previous = previousIndex.get(agentId);
|
|
369
|
+
const next = nextIndex.get(agentId);
|
|
370
|
+
const previousHost = previous
|
|
371
|
+
? resolveAgentHost(previousConfig.host, previous.host)
|
|
372
|
+
: undefined;
|
|
373
|
+
const nextHost = next ? resolveAgentHost(config.host, next.host) : undefined;
|
|
374
|
+
const previousOwnerConfig = previous
|
|
375
|
+
? `${workdirHostGroupKey(previous.mode, previousHost)}\0${previous.workdir ? normalize(previous.workdir) : ''}`
|
|
376
|
+
: null;
|
|
377
|
+
const nextOwnerConfig = next
|
|
378
|
+
? `${workdirHostGroupKey(next.mode, nextHost)}\0${next.workdir ? normalize(next.workdir) : ''}`
|
|
379
|
+
: null;
|
|
380
|
+
if (previousOwnerConfig === null || previousOwnerConfig !== nextOwnerConfig) {
|
|
381
|
+
this.repoCache.owners.delete(ownerKey);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
296
384
|
this.config = config;
|
|
297
|
-
this.agentIndex =
|
|
385
|
+
this.agentIndex = nextIndex;
|
|
298
386
|
}
|
|
299
387
|
getConfig() {
|
|
300
388
|
return this.config;
|
|
@@ -333,17 +421,15 @@ export class AgentManager {
|
|
|
333
421
|
try {
|
|
334
422
|
const resolved = await this.ensureWorkdir(agent, project, runner);
|
|
335
423
|
workdir = resolved.workdir;
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
});
|
|
346
|
-
}
|
|
424
|
+
await this.agentStore.update(agentId, (existing) => {
|
|
425
|
+
if (!existing)
|
|
426
|
+
return AGENT_STORE_NOOP;
|
|
427
|
+
return {
|
|
428
|
+
...existing,
|
|
429
|
+
workdir,
|
|
430
|
+
updatedAt: new Date().toISOString(),
|
|
431
|
+
};
|
|
432
|
+
});
|
|
347
433
|
}
|
|
348
434
|
catch (err) {
|
|
349
435
|
throw new EnsureSessionError({ createdSession: false, agentId }, `ensureWorkdir failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -367,9 +453,9 @@ export class AgentManager {
|
|
|
367
453
|
`it creates itself — kill it manually or pick a different agent id`);
|
|
368
454
|
}
|
|
369
455
|
if (alive && (mode === 'runtime' || mode === 'recover')) {
|
|
370
|
-
return this.adoptOrRestartSession(tmux, agent, agentId, workdir);
|
|
456
|
+
return this.adoptOrRestartSession(tmux, runner, agent, agentId, workdir, workdir);
|
|
371
457
|
}
|
|
372
|
-
return this.buildFreshSession(tmux, agent, agentId, workdir);
|
|
458
|
+
return this.buildFreshSession(tmux, agent, agentId, workdir, workdir);
|
|
373
459
|
}
|
|
374
460
|
async provisionRepoSkills(runner, agent, workdir) {
|
|
375
461
|
if (this.skillRegistry.names().length === 0)
|
|
@@ -397,7 +483,12 @@ export class AgentManager {
|
|
|
397
483
|
runUnderSkillDirLock(key, fn) {
|
|
398
484
|
const prev = this.skillDirChain.get(key) ?? Promise.resolve();
|
|
399
485
|
const run = prev.then(fn, fn);
|
|
400
|
-
|
|
486
|
+
const settled = run.then(() => undefined, () => undefined);
|
|
487
|
+
this.skillDirChain.set(key, settled);
|
|
488
|
+
void settled.finally(() => {
|
|
489
|
+
if (this.skillDirChain.get(key) === settled)
|
|
490
|
+
this.skillDirChain.delete(key);
|
|
491
|
+
});
|
|
401
492
|
return run;
|
|
402
493
|
}
|
|
403
494
|
async atomicWriteFile(runner, finalPath, content) {
|
|
@@ -437,14 +528,28 @@ export class AgentManager {
|
|
|
437
528
|
return tagged !== this.skillRegistry.contentHash();
|
|
438
529
|
}
|
|
439
530
|
async excludeInjectedSkills(runner, workdir, subdir) {
|
|
440
|
-
const inner = `cd ${shellQuote(workdir)} &&
|
|
531
|
+
const inner = `cd ${shellQuote(workdir)} && ` +
|
|
532
|
+
`if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo BX_SKILLS_NON_GIT; exit 0; fi; ` +
|
|
533
|
+
`tracked="$(git ls-files -- ${shellQuote(`${subdir}/baxian-*`)} 2>/dev/null | head -5)"; ` +
|
|
534
|
+
`if [ -n "$tracked" ]; then printf 'BX_SKILLS_TRACKED %s\\n' "$tracked" | head -1; exit 0; fi; ` +
|
|
535
|
+
`p="$(git rev-parse --git-path info/exclude)" || { echo BX_SKILLS_EXCLUDE_WRITE_FAILED; exit 0; }; ` +
|
|
441
536
|
`pre="$(git rev-parse --show-prefix 2>/dev/null)"; rule="\${pre}${subdir}/baxian-*"; ` +
|
|
442
|
-
`mkdir -p "$(dirname "$p")" && { grep -qxF "$rule" "$p" 2>/dev/null || printf '%s\\n' "$rule" >> "$p"; };
|
|
537
|
+
`{ mkdir -p "$(dirname "$p")" && { grep -qxF "$rule" "$p" 2>/dev/null || printf '%s\\n' "$rule" >> "$p"; }; } ` +
|
|
538
|
+
`|| { echo BX_SKILLS_EXCLUDE_WRITE_FAILED; exit 0; }; ` +
|
|
539
|
+
`if git check-ignore -q -- ${shellQuote(`${subdir}/baxian-__probe__/SKILL.md`)}; then echo BX_SKILLS_OK; ` +
|
|
540
|
+
`else echo BX_SKILLS_EXCLUDE_INEFFECTIVE; fi`;
|
|
443
541
|
const res = await runner.exec(`sh -c ${shellQuote(inner)}`);
|
|
444
542
|
if (res.exitCode !== 0) {
|
|
445
|
-
|
|
446
|
-
|
|
543
|
+
throw new Error(`skill exclusion probe failed in ${workdir}: ${res.stderr.trim() || `exit ${res.exitCode}`}`);
|
|
544
|
+
}
|
|
545
|
+
const verdict = res.stdout.trim().split('\n').pop() ?? '';
|
|
546
|
+
if (verdict === 'BX_SKILLS_NON_GIT' || verdict === 'BX_SKILLS_OK')
|
|
547
|
+
return;
|
|
548
|
+
if (verdict.startsWith('BX_SKILLS_TRACKED')) {
|
|
549
|
+
throw new Error(`refusing to inject baxian skills into ${workdir}: project tracks ` +
|
|
550
|
+
`${verdict.replace('BX_SKILLS_TRACKED ', '')}`);
|
|
447
551
|
}
|
|
552
|
+
throw new Error(`cannot make injected skills invisible to Git in ${workdir}: ${verdict || 'no probe output'}`);
|
|
448
553
|
}
|
|
449
554
|
async pinRuntimeSessionOptions(tmux, agentId) {
|
|
450
555
|
await tmux.setOption(agentId, 'prefix', 'C-b');
|
|
@@ -455,17 +560,18 @@ export class AgentManager {
|
|
|
455
560
|
await tmux.setOption(agentId, 'window-size', 'latest');
|
|
456
561
|
await this.pinRuntimeSessionOptions(tmux, agentId);
|
|
457
562
|
}
|
|
458
|
-
async buildFreshSession(tmux, agent, agentId, workdir) {
|
|
459
|
-
return this.runUnderSkillDirLock(this.skillDirLockKey(agent,
|
|
563
|
+
async buildFreshSession(tmux, agent, agentId, workdir, sessionDir = workdir) {
|
|
564
|
+
return this.runUnderSkillDirLock(this.skillDirLockKey(agent, sessionDir), () => this.buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir));
|
|
460
565
|
}
|
|
461
|
-
async buildFreshSessionLocked(tmux, agent, agentId, workdir) {
|
|
566
|
+
async buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir) {
|
|
462
567
|
let createdSession = false;
|
|
463
568
|
const runtime = agentRuntimeKindFor(agent);
|
|
464
569
|
try {
|
|
465
|
-
await tmux.createSession(agentId,
|
|
570
|
+
await tmux.createSession(agentId, sessionDir);
|
|
466
571
|
createdSession = true;
|
|
467
572
|
await tmux.setOption(agentId, '@baxian-agent-id', agentId);
|
|
468
573
|
await tmux.setOption(agentId, '@baxian-runtime', agent.runtime);
|
|
574
|
+
await tmux.setOption(agentId, WORKDIR_SESSION_OPTION, sessionDir);
|
|
469
575
|
await tmux.setOption(agentId, 'allow-passthrough', 'on');
|
|
470
576
|
await tmux.setOption(agentId, 'set-titles', 'on');
|
|
471
577
|
await this.pinFreshSessionOptions(tmux, agentId);
|
|
@@ -1037,7 +1143,7 @@ export class AgentManager {
|
|
|
1037
1143
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
1038
1144
|
});
|
|
1039
1145
|
}
|
|
1040
|
-
async adoptOrRestartSession(tmux, agent, agentId, workdir) {
|
|
1146
|
+
async adoptOrRestartSession(tmux, runner, agent, agentId, workdir, sessionDir) {
|
|
1041
1147
|
let claim;
|
|
1042
1148
|
try {
|
|
1043
1149
|
claim = await tmux.getOption(agentId, '@baxian-agent-id');
|
|
@@ -1070,6 +1176,36 @@ export class AgentManager {
|
|
|
1070
1176
|
catch (err) {
|
|
1071
1177
|
throw new EnsureSessionError({ createdSession: false, agentId }, `classifyPaneForAdopt failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1072
1178
|
}
|
|
1179
|
+
let currentPath;
|
|
1180
|
+
try {
|
|
1181
|
+
currentPath = await tmux.getPaneCurrentPath(paneId);
|
|
1182
|
+
}
|
|
1183
|
+
catch (err) {
|
|
1184
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `pane current path probe failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1185
|
+
}
|
|
1186
|
+
let pathMatches;
|
|
1187
|
+
try {
|
|
1188
|
+
pathMatches = await sameDirOnHost(runner, currentPath, sessionDir);
|
|
1189
|
+
}
|
|
1190
|
+
catch (err) {
|
|
1191
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `pane current path comparison failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1192
|
+
}
|
|
1193
|
+
if (!pathMatches) {
|
|
1194
|
+
if (state.kind === 'live-runtime') {
|
|
1195
|
+
try {
|
|
1196
|
+
await this.waitForReplPromptReady(tmux, paneId, agent.runtime, this.cleanComposerWaitMs);
|
|
1197
|
+
}
|
|
1198
|
+
catch (err) {
|
|
1199
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `Workdir change requires an idle runtime: ${err instanceof Error ? err.message : String(err)}`);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
else if (state.kind !== 'shell') {
|
|
1203
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux pane path ${currentPath} differs from Workdir ${sessionDir}, but runtime is not safely idle`);
|
|
1204
|
+
}
|
|
1205
|
+
await tmux.killSession(agentId);
|
|
1206
|
+
return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
|
|
1207
|
+
}
|
|
1208
|
+
await tmux.setOption(agentId, WORKDIR_SESSION_OPTION, sessionDir);
|
|
1073
1209
|
switch (state.kind) {
|
|
1074
1210
|
case 'live-runtime': {
|
|
1075
1211
|
let stale;
|
|
@@ -1080,8 +1216,14 @@ export class AgentManager {
|
|
|
1080
1216
|
throw new EnsureSessionError({ createdSession: false, agentId }, `skills-version probe failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1081
1217
|
}
|
|
1082
1218
|
if (stale) {
|
|
1083
|
-
|
|
1084
|
-
|
|
1219
|
+
return {
|
|
1220
|
+
ok: true,
|
|
1221
|
+
createdSession: false,
|
|
1222
|
+
freshRuntime: false,
|
|
1223
|
+
skillsStale: true,
|
|
1224
|
+
paneId,
|
|
1225
|
+
workdir,
|
|
1226
|
+
};
|
|
1085
1227
|
}
|
|
1086
1228
|
return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir };
|
|
1087
1229
|
}
|
|
@@ -1152,7 +1294,10 @@ export class AgentManager {
|
|
|
1152
1294
|
if (!cfg)
|
|
1153
1295
|
throw new Error(`Unknown agent: ${agentId}`);
|
|
1154
1296
|
const state = await this.agentStore.get(agentId);
|
|
1155
|
-
const
|
|
1297
|
+
const existingToken = state?.taskId === taskId && state
|
|
1298
|
+
? await this.resolveTaskLockToken(state, taskId)
|
|
1299
|
+
: null;
|
|
1300
|
+
const sameTaskLocked = existingToken !== null;
|
|
1156
1301
|
const reentryPhases = new Set([
|
|
1157
1302
|
'fix', 'post-approve', 'code',
|
|
1158
1303
|
'server-feedback', 'server-after-done',
|
|
@@ -1165,17 +1310,17 @@ export class AgentManager {
|
|
|
1165
1310
|
if (!sameTaskReentry && !canDispatchWithBinding(state)) {
|
|
1166
1311
|
return false;
|
|
1167
1312
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
return false;
|
|
1172
|
-
}
|
|
1313
|
+
const token = reuseLock ? existingToken : await this.lockManager.acquire(agentId, taskId);
|
|
1314
|
+
if (!token)
|
|
1315
|
+
return false;
|
|
1173
1316
|
const now = new Date().toISOString();
|
|
1174
1317
|
await this.agentStore.update(agentId, (existing) => ({
|
|
1175
1318
|
...(existing ?? { id: agentId, projectId: cfg.projectId, updatedAt: now }),
|
|
1176
1319
|
id: agentId,
|
|
1177
1320
|
projectId: cfg.projectId,
|
|
1178
1321
|
taskId,
|
|
1322
|
+
lockToken: token,
|
|
1323
|
+
...(phase === 'code' && !sameTaskReentry ? { bootstrappingTaskId: taskId } : {}),
|
|
1179
1324
|
updatedAt: now,
|
|
1180
1325
|
}));
|
|
1181
1326
|
return true;
|
|
@@ -1208,6 +1353,11 @@ export class AgentManager {
|
|
|
1208
1353
|
const cfg = this.getAgentConfig(agentId);
|
|
1209
1354
|
if (!cfg)
|
|
1210
1355
|
return false;
|
|
1356
|
+
const lockToken = await this.resolveTaskLockToken(state, expectedTaskId);
|
|
1357
|
+
if (!lockToken) {
|
|
1358
|
+
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} no longer owns task ${expectedTaskId}; skipping`);
|
|
1359
|
+
return false;
|
|
1360
|
+
}
|
|
1211
1361
|
const now = new Date().toISOString();
|
|
1212
1362
|
if (mode === 'waiting') {
|
|
1213
1363
|
await this.agentStore.update(agentId, (latest) => {
|
|
@@ -1219,9 +1369,9 @@ export class AgentManager {
|
|
|
1219
1369
|
projectId: latest.projectId,
|
|
1220
1370
|
updatedAt: now,
|
|
1221
1371
|
...(latest.taskId !== undefined ? { taskId: latest.taskId } : {}),
|
|
1372
|
+
...(latest.lockToken !== undefined ? { lockToken: latest.lockToken } : {}),
|
|
1222
1373
|
...(latest.paneId !== undefined ? { paneId: latest.paneId } : {}),
|
|
1223
|
-
...(latest.
|
|
1224
|
-
...(latest.worktreePath !== undefined ? { worktreePath: latest.worktreePath } : {}),
|
|
1374
|
+
...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
|
|
1225
1375
|
...(latest.startedAt !== undefined ? { startedAt: latest.startedAt } : {}),
|
|
1226
1376
|
...(latest.creationToken !== undefined ? { creationToken: latest.creationToken } : {}),
|
|
1227
1377
|
};
|
|
@@ -1234,37 +1384,329 @@ export class AgentManager {
|
|
|
1234
1384
|
});
|
|
1235
1385
|
return true;
|
|
1236
1386
|
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1387
|
+
let sessionConfirmedAbsent = false;
|
|
1388
|
+
if (state.workdir) {
|
|
1389
|
+
const releaseWorkdir = state.workdir;
|
|
1390
|
+
const runner = this.createRunnerFor(cfg);
|
|
1391
|
+
const tmux = new TmuxManager(runner);
|
|
1392
|
+
const hold = async (reason) => {
|
|
1393
|
+
const latest = await this.agentStore.get(agentId);
|
|
1394
|
+
if (!latest
|
|
1395
|
+
|| latest.taskId !== expectedTaskId
|
|
1396
|
+
|| latest.lockToken !== lockToken
|
|
1397
|
+
|| !await this.lockManager.isOwner(agentId, expectedTaskId, lockToken)) {
|
|
1398
|
+
return false;
|
|
1399
|
+
}
|
|
1400
|
+
const pendingAt = new Date().toISOString();
|
|
1401
|
+
if (boundTask && cfg.role === 'dev') {
|
|
1402
|
+
boundTask.branchCleanupPending = { agentId, reason, updatedAt: pendingAt };
|
|
1403
|
+
boundTask.updatedAt = pendingAt;
|
|
1404
|
+
await this.taskStore.set(boundTask);
|
|
1405
|
+
}
|
|
1406
|
+
await this.agentStore.update(agentId, (latest) => {
|
|
1407
|
+
if (!latest || latest.taskId !== expectedTaskId || latest.lockToken !== lockToken) {
|
|
1408
|
+
return AGENT_STORE_NOOP;
|
|
1409
|
+
}
|
|
1410
|
+
return {
|
|
1411
|
+
...latest,
|
|
1412
|
+
status: 'awaiting_human',
|
|
1413
|
+
awaitingPhase: 'branch-cleanup-pending',
|
|
1414
|
+
awaitingReason: reason,
|
|
1415
|
+
awaitingSince: pendingAt,
|
|
1416
|
+
updatedAt: pendingAt,
|
|
1417
|
+
};
|
|
1418
|
+
});
|
|
1419
|
+
await this.emitIntervention(state.projectId, agentId, expectedTaskId, {
|
|
1420
|
+
phase: 'branch-cleanup-pending',
|
|
1421
|
+
reason,
|
|
1422
|
+
});
|
|
1423
|
+
return false;
|
|
1424
|
+
};
|
|
1425
|
+
try {
|
|
1426
|
+
await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
|
|
1427
|
+
const runtime = await this.inspectReleaseRuntime(tmux, agentId);
|
|
1428
|
+
if (runtime.kind === 'hold')
|
|
1429
|
+
return hold(runtime.reason);
|
|
1430
|
+
await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
|
|
1431
|
+
if (runtime.kind === 'pane') {
|
|
1432
|
+
if (state.paneId !== runtime.paneId) {
|
|
1433
|
+
await this.agentStore.update(agentId, (latest) => {
|
|
1434
|
+
if (!latest
|
|
1435
|
+
|| latest.taskId !== expectedTaskId
|
|
1436
|
+
|| latest.lockToken !== lockToken
|
|
1437
|
+
|| latest.workdir !== releaseWorkdir) {
|
|
1438
|
+
return AGENT_STORE_NOOP;
|
|
1439
|
+
}
|
|
1440
|
+
return { ...latest, paneId: runtime.paneId, updatedAt: new Date().toISOString() };
|
|
1441
|
+
});
|
|
1442
|
+
const refreshed = await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
|
|
1443
|
+
if (refreshed.paneId !== runtime.paneId) {
|
|
1444
|
+
throw new Error(`Agent ${agentId} runtime pane changed during release; operation aborted`);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
await this.waitForReplPromptReady(tmux, runtime.paneId, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
|
|
1244
1448
|
}
|
|
1245
|
-
|
|
1246
|
-
|
|
1449
|
+
else {
|
|
1450
|
+
sessionConfirmedAbsent = true;
|
|
1451
|
+
}
|
|
1452
|
+
await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
|
|
1453
|
+
if (cfg.role === 'dev' && boundTask) {
|
|
1454
|
+
const branches = new BranchManager(runner);
|
|
1455
|
+
const cleanup = await branches.cleanupTaskBranch(releaseWorkdir, {
|
|
1456
|
+
taskId: boundTask.id,
|
|
1457
|
+
taskBranch: boundTask.branch,
|
|
1458
|
+
branchCreatedByBaxian: boundTask.branchCreatedByBaxian,
|
|
1459
|
+
}, () => this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir).then(() => undefined));
|
|
1460
|
+
let cleanupStateChanged = false;
|
|
1461
|
+
if (cleanup.status === 'pending') {
|
|
1462
|
+
if (boundTask.branchCleanupPending?.agentId !== agentId
|
|
1463
|
+
|| boundTask.branchCleanupPending.reason !== cleanup.reason
|
|
1464
|
+
|| boundTask.branchCleanupSkipped !== undefined) {
|
|
1465
|
+
boundTask.branchCleanupPending = {
|
|
1466
|
+
agentId,
|
|
1467
|
+
reason: cleanup.reason,
|
|
1468
|
+
updatedAt: new Date().toISOString(),
|
|
1469
|
+
};
|
|
1470
|
+
delete boundTask.branchCleanupSkipped;
|
|
1471
|
+
cleanupStateChanged = true;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
else if (cleanup.status === 'skipped') {
|
|
1475
|
+
if (boundTask.branchCleanupSkipped?.agentId !== agentId
|
|
1476
|
+
|| boundTask.branchCleanupSkipped.reason !== cleanup.reason
|
|
1477
|
+
|| boundTask.branchCleanupPending !== undefined) {
|
|
1478
|
+
boundTask.branchCleanupSkipped = {
|
|
1479
|
+
agentId,
|
|
1480
|
+
reason: cleanup.reason,
|
|
1481
|
+
updatedAt: new Date().toISOString(),
|
|
1482
|
+
};
|
|
1483
|
+
delete boundTask.branchCleanupPending;
|
|
1484
|
+
cleanupStateChanged = true;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
else {
|
|
1488
|
+
if (boundTask.branchCleanupPending?.agentId === agentId) {
|
|
1489
|
+
delete boundTask.branchCleanupPending;
|
|
1490
|
+
cleanupStateChanged = true;
|
|
1491
|
+
}
|
|
1492
|
+
if (boundTask.branchCleanupSkipped?.agentId === agentId) {
|
|
1493
|
+
delete boundTask.branchCleanupSkipped;
|
|
1494
|
+
cleanupStateChanged = true;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
if (cleanupStateChanged) {
|
|
1498
|
+
boundTask.updatedAt = new Date().toISOString();
|
|
1499
|
+
await this.taskStore.set(boundTask);
|
|
1500
|
+
}
|
|
1501
|
+
if (cleanup.status !== 'deleted')
|
|
1502
|
+
await branches.parkOnDefaultDetached(releaseWorkdir);
|
|
1247
1503
|
}
|
|
1504
|
+
else {
|
|
1505
|
+
await new BranchManager(runner).parkOnDefaultDetached(releaseWorkdir);
|
|
1506
|
+
}
|
|
1507
|
+
await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
|
|
1508
|
+
}
|
|
1509
|
+
catch (err) {
|
|
1510
|
+
const reason = err instanceof DirtyWorkdirError
|
|
1511
|
+
? err.message
|
|
1512
|
+
: `checkout cleanup failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
1513
|
+
return hold(reason);
|
|
1248
1514
|
}
|
|
1249
1515
|
}
|
|
1516
|
+
if (state.workdir) {
|
|
1517
|
+
await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, state.workdir);
|
|
1518
|
+
}
|
|
1519
|
+
else {
|
|
1520
|
+
await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
|
|
1521
|
+
}
|
|
1250
1522
|
await this.agentStore.update(agentId, (existing) => {
|
|
1251
1523
|
if (!existing)
|
|
1252
1524
|
return AGENT_STORE_NOOP;
|
|
1253
|
-
if (existing.taskId !== expectedTaskId
|
|
1525
|
+
if (existing.taskId !== expectedTaskId
|
|
1526
|
+
|| existing.lockToken !== lockToken
|
|
1527
|
+
|| (state.workdir !== undefined && existing.workdir !== state.workdir)) {
|
|
1254
1528
|
return AGENT_STORE_NOOP;
|
|
1529
|
+
}
|
|
1255
1530
|
return {
|
|
1256
1531
|
id: existing.id,
|
|
1257
1532
|
projectId: existing.projectId,
|
|
1258
|
-
...(existing.
|
|
1259
|
-
...(existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
1533
|
+
...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
1534
|
+
...(!sessionConfirmedAbsent && existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
1260
1535
|
...(existing.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
1261
1536
|
updatedAt: now,
|
|
1262
1537
|
};
|
|
1263
1538
|
});
|
|
1264
|
-
|
|
1265
|
-
return true;
|
|
1539
|
+
return this.lockManager.releaseIfOwner(agentId, expectedTaskId, lockToken);
|
|
1266
1540
|
});
|
|
1267
1541
|
}
|
|
1542
|
+
async releaseAgentIfBound(agentId, expectedTaskId, opts) {
|
|
1543
|
+
const state = await this.agentStore.get(agentId);
|
|
1544
|
+
if (state?.taskId !== expectedTaskId)
|
|
1545
|
+
return true;
|
|
1546
|
+
const released = opts
|
|
1547
|
+
? await this.releaseAgentForTask(agentId, expectedTaskId, 'idle', opts)
|
|
1548
|
+
: await this.releaseAgentForTask(agentId, expectedTaskId, 'idle');
|
|
1549
|
+
if (released)
|
|
1550
|
+
return true;
|
|
1551
|
+
return (await this.agentStore.get(agentId))?.taskId !== expectedTaskId;
|
|
1552
|
+
}
|
|
1553
|
+
async reconcileTaskBranches() {
|
|
1554
|
+
const tasks = await this.taskStore.list();
|
|
1555
|
+
const terminalByBranch = new Map(tasks
|
|
1556
|
+
.filter(task => task.branchCreatedByBaxian === true
|
|
1557
|
+
&& TERMINAL_STATUSES.includes(task.status)
|
|
1558
|
+
&& task.branch === `${BRANCH_PREFIX}${task.id}`
|
|
1559
|
+
&& task.branchCleanupSkipped === undefined)
|
|
1560
|
+
.map(task => [task.branch, task]));
|
|
1561
|
+
const owner = 'maintenance:branch-reconcile';
|
|
1562
|
+
const persistCleanup = async (task, agentId, cleanup) => {
|
|
1563
|
+
await this.withTaskLock(async () => {
|
|
1564
|
+
const latest = await this.taskStore.get(task.id);
|
|
1565
|
+
if (!latest
|
|
1566
|
+
|| latest.branch !== task.branch
|
|
1567
|
+
|| latest.branchCreatedByBaxian !== true
|
|
1568
|
+
|| !TERMINAL_STATUSES.includes(latest.status))
|
|
1569
|
+
return;
|
|
1570
|
+
const now = new Date().toISOString();
|
|
1571
|
+
let changed = false;
|
|
1572
|
+
if (cleanup.status === 'pending') {
|
|
1573
|
+
if (latest.branchCleanupPending?.agentId !== agentId
|
|
1574
|
+
|| latest.branchCleanupPending.reason !== cleanup.reason
|
|
1575
|
+
|| latest.branchCleanupSkipped !== undefined) {
|
|
1576
|
+
latest.branchCleanupPending = { agentId, reason: cleanup.reason, updatedAt: now };
|
|
1577
|
+
delete latest.branchCleanupSkipped;
|
|
1578
|
+
changed = true;
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
else if (cleanup.status === 'skipped') {
|
|
1582
|
+
if (latest.branchCleanupSkipped?.agentId !== agentId
|
|
1583
|
+
|| latest.branchCleanupSkipped.reason !== cleanup.reason
|
|
1584
|
+
|| latest.branchCleanupPending !== undefined) {
|
|
1585
|
+
latest.branchCleanupSkipped = { agentId, reason: cleanup.reason, updatedAt: now };
|
|
1586
|
+
delete latest.branchCleanupPending;
|
|
1587
|
+
changed = true;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
else {
|
|
1591
|
+
if (latest.branchCleanupPending !== undefined) {
|
|
1592
|
+
delete latest.branchCleanupPending;
|
|
1593
|
+
changed = true;
|
|
1594
|
+
}
|
|
1595
|
+
if (latest.branchCleanupSkipped !== undefined) {
|
|
1596
|
+
delete latest.branchCleanupSkipped;
|
|
1597
|
+
changed = true;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
if (!changed)
|
|
1601
|
+
return;
|
|
1602
|
+
latest.updatedAt = now;
|
|
1603
|
+
await this.taskStore.set(latest);
|
|
1604
|
+
});
|
|
1605
|
+
};
|
|
1606
|
+
for (const cfg of this.agentIndex.values()) {
|
|
1607
|
+
if (cfg.role !== 'dev')
|
|
1608
|
+
continue;
|
|
1609
|
+
const candidateTasks = [...terminalByBranch.values()].filter(task => task.agentId === cfg.id || task.branchCleanupPending?.agentId === cfg.id);
|
|
1610
|
+
if (candidateTasks.length === 0)
|
|
1611
|
+
continue;
|
|
1612
|
+
const candidateByBranch = new Map(candidateTasks.map(task => [task.branch, task]));
|
|
1613
|
+
const state = await this.agentStore.get(cfg.id);
|
|
1614
|
+
if (!state || !canDispatchWithBinding(state))
|
|
1615
|
+
continue;
|
|
1616
|
+
const workdir = state.workdir ?? cfg.workdir;
|
|
1617
|
+
if (!workdir)
|
|
1618
|
+
continue;
|
|
1619
|
+
const runner = this.createRunnerFor(cfg);
|
|
1620
|
+
const branches = new BranchManager(runner);
|
|
1621
|
+
let refs;
|
|
1622
|
+
try {
|
|
1623
|
+
refs = await branches.listLocalTaskRefs(workdir);
|
|
1624
|
+
}
|
|
1625
|
+
catch (err) {
|
|
1626
|
+
console.warn(`[AgentManager] reconcileTaskBranches(${cfg.id}) local ref preflight failed: ` +
|
|
1627
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
1628
|
+
continue;
|
|
1629
|
+
}
|
|
1630
|
+
if (!refs.some(ref => candidateByBranch.has(ref.slice('refs/heads/'.length))))
|
|
1631
|
+
continue;
|
|
1632
|
+
const token = await this.lockManager.acquire(cfg.id, owner);
|
|
1633
|
+
if (!token)
|
|
1634
|
+
continue;
|
|
1635
|
+
const cleanupResults = [];
|
|
1636
|
+
try {
|
|
1637
|
+
const lockedState = await this.agentStore.get(cfg.id);
|
|
1638
|
+
if (!lockedState || !canDispatchWithBinding(lockedState))
|
|
1639
|
+
continue;
|
|
1640
|
+
if (!(await this.lockManager.isOwner(cfg.id, owner, token)))
|
|
1641
|
+
continue;
|
|
1642
|
+
refs = await branches.listLocalTaskRefs(workdir);
|
|
1643
|
+
refs = refs.filter(ref => candidateByBranch.has(ref.slice('refs/heads/'.length)));
|
|
1644
|
+
if (refs.length === 0)
|
|
1645
|
+
continue;
|
|
1646
|
+
const tmux = new TmuxManager(runner);
|
|
1647
|
+
if (await tmux.hasSession(cfg.id)) {
|
|
1648
|
+
const claim = await tmux.getOption(cfg.id, '@baxian-agent-id');
|
|
1649
|
+
if (claim !== cfg.id) {
|
|
1650
|
+
throw new Error(`tmux session claim mismatch for ${cfg.id}: ${claim ?? '<missing>'}`);
|
|
1651
|
+
}
|
|
1652
|
+
const paneId = lockedState.paneId ?? await tmux.getSinglePaneId(cfg.id);
|
|
1653
|
+
await this.waitForReplPromptReady(tmux, paneId, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
|
|
1654
|
+
}
|
|
1655
|
+
if (!(await this.lockManager.isOwner(cfg.id, owner, token)))
|
|
1656
|
+
continue;
|
|
1657
|
+
for (const actualRef of refs) {
|
|
1658
|
+
const branch = actualRef.slice('refs/heads/'.length);
|
|
1659
|
+
const task = candidateByBranch.get(branch);
|
|
1660
|
+
if (!task)
|
|
1661
|
+
continue;
|
|
1662
|
+
const cleanup = await branches.cleanupTaskBranch(workdir, {
|
|
1663
|
+
taskId: task.id,
|
|
1664
|
+
taskBranch: task.branch,
|
|
1665
|
+
branchCreatedByBaxian: task.branchCreatedByBaxian,
|
|
1666
|
+
}, () => this.assertTaskLockOwner(cfg.id, owner, token));
|
|
1667
|
+
cleanupResults.push({ task, cleanup });
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
catch (err) {
|
|
1671
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1672
|
+
if (err instanceof DirtyWorkdirError) {
|
|
1673
|
+
await this.markAwaitingHuman(cfg.id, 'dirty-workdir', message, { expectedTaskId: null });
|
|
1674
|
+
}
|
|
1675
|
+
else if (err instanceof ReplNotReadyError || message.includes('stayed busy past')) {
|
|
1676
|
+
for (const actualRef of refs) {
|
|
1677
|
+
const task = candidateByBranch.get(actualRef.slice('refs/heads/'.length));
|
|
1678
|
+
if (!task)
|
|
1679
|
+
continue;
|
|
1680
|
+
cleanupResults.push({
|
|
1681
|
+
task,
|
|
1682
|
+
cleanup: {
|
|
1683
|
+
status: 'pending',
|
|
1684
|
+
reason: `runtime is not idle; local branch cleanup deferred: ${message}`,
|
|
1685
|
+
},
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
else {
|
|
1690
|
+
console.warn(`[AgentManager] reconcileTaskBranches(${cfg.id}) failed: ${message}`);
|
|
1691
|
+
await this.recordError({
|
|
1692
|
+
agentId: cfg.id,
|
|
1693
|
+
projectId: cfg.projectId,
|
|
1694
|
+
operation: 'branch-reconcile',
|
|
1695
|
+
reason: 'BRANCH_RECONCILE_FAILED',
|
|
1696
|
+
message,
|
|
1697
|
+
observation: { phase: 'branch-reconcile' },
|
|
1698
|
+
recommendation: 'Verify the Workdir and remote, then let the next reconciliation retry.',
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
finally {
|
|
1703
|
+
await this.lockManager.releaseIfOwner(cfg.id, owner, token);
|
|
1704
|
+
}
|
|
1705
|
+
for (const result of cleanupResults) {
|
|
1706
|
+
await persistCleanup(result.task, cfg.id, result.cleanup);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1268
1710
|
async setAgentNeedInput(agentId, pending, opts = {}) {
|
|
1269
1711
|
const now = new Date().toISOString();
|
|
1270
1712
|
try {
|
|
@@ -1417,6 +1859,13 @@ export class AgentManager {
|
|
|
1417
1859
|
console.warn(`[AgentManager] resumeAgent: agent ${agentId} — ${reason}`);
|
|
1418
1860
|
return { resumed: false, releasedBinding: false, reason };
|
|
1419
1861
|
}
|
|
1862
|
+
if ((state.awaitingPhase === 'dirty-workdir' || state.awaitingPhase === 'checkout-preparation-failed')
|
|
1863
|
+
&& boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)) {
|
|
1864
|
+
const reason = `Task ${state.taskId} was not dispatched because its checkout could not be prepared. ` +
|
|
1865
|
+
'Fix the Workdir, then cancel and redispatch the task; Resume cannot reconstruct the original dispatch safely.';
|
|
1866
|
+
console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${state.awaitingPhase} — ${reason}`);
|
|
1867
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
1868
|
+
}
|
|
1420
1869
|
if (state.awaitingPhase === 'code-dispatch-failed'
|
|
1421
1870
|
&& boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)
|
|
1422
1871
|
&& state.taskId) {
|
|
@@ -1448,19 +1897,14 @@ export class AgentManager {
|
|
|
1448
1897
|
}
|
|
1449
1898
|
const now = new Date().toISOString();
|
|
1450
1899
|
const shouldReleaseBinding = shouldReleaseHeldBinding(state, boundTask);
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
}
|
|
1460
|
-
catch (err) {
|
|
1461
|
-
console.warn(`[AgentManager] resumeAgent worktree.remove failed for ${state.worktreePath}:`, err);
|
|
1462
|
-
}
|
|
1463
|
-
}
|
|
1900
|
+
if (shouldReleaseBinding && state.taskId) {
|
|
1901
|
+
return {
|
|
1902
|
+
resumed: true,
|
|
1903
|
+
releasedBinding: false,
|
|
1904
|
+
releaseTaskId: state.taskId,
|
|
1905
|
+
projectId: state.projectId,
|
|
1906
|
+
previousPhase: state.awaitingPhase,
|
|
1907
|
+
};
|
|
1464
1908
|
}
|
|
1465
1909
|
await this.agentStore.update(agentId, (existing) => {
|
|
1466
1910
|
if (!existing)
|
|
@@ -1469,7 +1913,7 @@ export class AgentManager {
|
|
|
1469
1913
|
id: existing.id,
|
|
1470
1914
|
projectId: existing.projectId,
|
|
1471
1915
|
updatedAt: now,
|
|
1472
|
-
...(existing.
|
|
1916
|
+
...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
1473
1917
|
...(existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
1474
1918
|
...(existing.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
1475
1919
|
status: 'ok',
|
|
@@ -1477,33 +1921,64 @@ export class AgentManager {
|
|
|
1477
1921
|
if (!shouldReleaseBinding) {
|
|
1478
1922
|
if (existing.taskId !== undefined)
|
|
1479
1923
|
next.taskId = existing.taskId;
|
|
1480
|
-
if (existing.
|
|
1481
|
-
next.
|
|
1924
|
+
if (existing.lockToken !== undefined)
|
|
1925
|
+
next.lockToken = existing.lockToken;
|
|
1482
1926
|
if (existing.startedAt !== undefined)
|
|
1483
1927
|
next.startedAt = existing.startedAt;
|
|
1484
1928
|
}
|
|
1485
1929
|
return next;
|
|
1486
1930
|
});
|
|
1487
|
-
if (shouldReleaseBinding) {
|
|
1488
|
-
await this.lockManager.release(agentId);
|
|
1489
|
-
}
|
|
1490
1931
|
await this.emitIntervention(state.projectId, agentId, state.taskId, {
|
|
1491
1932
|
phase: 'resumed',
|
|
1492
1933
|
previousPhase: state.awaitingPhase,
|
|
1493
|
-
releasedBinding:
|
|
1934
|
+
releasedBinding: false,
|
|
1494
1935
|
});
|
|
1495
|
-
return { resumed: true, releasedBinding:
|
|
1936
|
+
return { resumed: true, releasedBinding: false };
|
|
1496
1937
|
});
|
|
1938
|
+
if (result.releaseTaskId) {
|
|
1939
|
+
const released = await this.releaseAgentForTask(agentId, result.releaseTaskId, 'idle', {
|
|
1940
|
+
allowAwaitingHuman: true,
|
|
1941
|
+
fromCancelCleanup: true,
|
|
1942
|
+
});
|
|
1943
|
+
if (!released) {
|
|
1944
|
+
const refreshed = await this.agentStore.get(agentId);
|
|
1945
|
+
return {
|
|
1946
|
+
resumed: false,
|
|
1947
|
+
releasedBinding: false,
|
|
1948
|
+
reason: refreshed?.awaitingReason
|
|
1949
|
+
?? `Agent ${agentId} could not safely release task ${result.releaseTaskId}.`,
|
|
1950
|
+
};
|
|
1951
|
+
}
|
|
1952
|
+
await this.emitIntervention(result.projectId, agentId, result.releaseTaskId, {
|
|
1953
|
+
phase: 'resumed',
|
|
1954
|
+
previousPhase: result.previousPhase,
|
|
1955
|
+
releasedBinding: true,
|
|
1956
|
+
});
|
|
1957
|
+
return { resumed: true, releasedBinding: true };
|
|
1958
|
+
}
|
|
1497
1959
|
if (result.redispatchCodeTaskId) {
|
|
1498
1960
|
try {
|
|
1499
|
-
const
|
|
1961
|
+
const task = await this.taskStore.get(result.redispatchCodeTaskId);
|
|
1962
|
+
const round = task?.specReviewRound;
|
|
1963
|
+
const stored = round === undefined
|
|
1964
|
+
? null
|
|
1965
|
+
: await this.reviewStore?.getRound(result.redispatchCodeTaskId, 'spec', round);
|
|
1966
|
+
if (!task || task.phase !== 'code' || !task.signalToken || !stored || stored.phase !== 'spec') {
|
|
1967
|
+
await this.markAwaitingHuman(agentId, 'code-dispatch-failed', 'Code-phase redispatch cannot continue because the persisted Spec handoff is missing or invalid. Restore the review record or cancel the task.', { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
|
|
1968
|
+
return { resumed: false, releasedBinding: false, reason: 'Persisted Spec handoff is missing or invalid.' };
|
|
1969
|
+
}
|
|
1970
|
+
const resumed = await this.dispatchCodePhasePrompt(task, agentId, task.signalToken, stored.documents, round);
|
|
1500
1971
|
if (!resumed) {
|
|
1501
|
-
|
|
1972
|
+
const reason = 'Code-phase redispatch on Resume was not delivered; Resume again to retry or cancel the task.';
|
|
1973
|
+
await this.markAwaitingHuman(agentId, 'code-dispatch-failed', reason, { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
|
|
1974
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
1502
1975
|
}
|
|
1503
1976
|
}
|
|
1504
1977
|
catch (err) {
|
|
1505
1978
|
console.error(`[AgentManager] resumeAgent code redispatch failed for ${agentId}:`, err);
|
|
1506
|
-
|
|
1979
|
+
const reason = `Code-phase redispatch on Resume failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
1980
|
+
await this.markAwaitingHuman(agentId, 'code-dispatch-failed', reason, { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
|
|
1981
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
1507
1982
|
}
|
|
1508
1983
|
}
|
|
1509
1984
|
return {
|
|
@@ -1523,6 +1998,48 @@ export class AgentManager {
|
|
|
1523
1998
|
return null;
|
|
1524
1999
|
}
|
|
1525
2000
|
}
|
|
2001
|
+
async inspectReleaseRuntime(tmux, agentId) {
|
|
2002
|
+
let sessionAlive;
|
|
2003
|
+
try {
|
|
2004
|
+
sessionAlive = await tmux.hasSession(agentId);
|
|
2005
|
+
}
|
|
2006
|
+
catch (err) {
|
|
2007
|
+
return {
|
|
2008
|
+
kind: 'hold',
|
|
2009
|
+
reason: `Runtime availability probe failed; refusing checkout cleanup: ` +
|
|
2010
|
+
`${err instanceof Error ? err.message : String(err)}`,
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
if (!sessionAlive)
|
|
2014
|
+
return { kind: 'absent' };
|
|
2015
|
+
let claim;
|
|
2016
|
+
try {
|
|
2017
|
+
claim = await tmux.getOption(agentId, '@baxian-agent-id');
|
|
2018
|
+
}
|
|
2019
|
+
catch (err) {
|
|
2020
|
+
return {
|
|
2021
|
+
kind: 'hold',
|
|
2022
|
+
reason: `Runtime session claim probe failed; refusing checkout cleanup: ` +
|
|
2023
|
+
`${err instanceof Error ? err.message : String(err)}`,
|
|
2024
|
+
};
|
|
2025
|
+
}
|
|
2026
|
+
if (claim !== agentId) {
|
|
2027
|
+
return {
|
|
2028
|
+
kind: 'hold',
|
|
2029
|
+
reason: `Runtime session claim mismatch (got "${claim ?? 'null'}"); refusing checkout cleanup`,
|
|
2030
|
+
};
|
|
2031
|
+
}
|
|
2032
|
+
try {
|
|
2033
|
+
return { kind: 'pane', paneId: await tmux.getSinglePaneId(agentId) };
|
|
2034
|
+
}
|
|
2035
|
+
catch (err) {
|
|
2036
|
+
return {
|
|
2037
|
+
kind: 'hold',
|
|
2038
|
+
reason: `Runtime pane probe failed; refusing checkout cleanup: ` +
|
|
2039
|
+
`${err instanceof Error ? err.message : String(err)}`,
|
|
2040
|
+
};
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
1526
2043
|
async interruptPaneAndWaitReady(state, cfg) {
|
|
1527
2044
|
const paneId = await this.resolvePaneId(state, cfg);
|
|
1528
2045
|
if (!paneId)
|
|
@@ -1698,7 +2215,7 @@ export class AgentManager {
|
|
|
1698
2215
|
const out = [];
|
|
1699
2216
|
for (const t of tasks) {
|
|
1700
2217
|
const bound = t.agentId === agentId || t.qaAgentId === agentId;
|
|
1701
|
-
// spec-ready 不豁免:approve/打回都依赖 dev
|
|
2218
|
+
// spec-ready 不豁免:approve/打回都依赖 dev Workdir,dev 丢失后停驻只是假活
|
|
1702
2219
|
if (t.status === 'ready' || t.status === 'merge-ready')
|
|
1703
2220
|
continue;
|
|
1704
2221
|
if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
|
|
@@ -1788,18 +2305,16 @@ export class AgentManager {
|
|
|
1788
2305
|
throw new Error(`restart-repl precondition failed: unexpected pane state "${cmd}"`);
|
|
1789
2306
|
}
|
|
1790
2307
|
const project = this.getProjectConfig(cfg.projectId);
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
}
|
|
1802
|
-
}
|
|
2308
|
+
if (!project)
|
|
2309
|
+
throw new Error(`restart-repl: project ${cfg.projectId} does not exist`);
|
|
2310
|
+
const workdir = (await this.ensureWorkdir(cfg, project, runner)).workdir;
|
|
2311
|
+
const paneWorkdir = await tmux.getPaneCurrentPath(paneId);
|
|
2312
|
+
if (!await sameDirOnHost(runner, paneWorkdir, workdir)) {
|
|
2313
|
+
throw new Error(`restart-repl: pane Workdir ${paneWorkdir} does not match agent Workdir ${workdir}; ` +
|
|
2314
|
+
'use retry to rebuild the session safely');
|
|
2315
|
+
}
|
|
2316
|
+
await this.provisionRepoSkills(runner, cfg, workdir);
|
|
2317
|
+
await tmux.setOption(agentId, WORKDIR_SESSION_OPTION, workdir);
|
|
1803
2318
|
const runtime = agentRuntimeKindFor(cfg);
|
|
1804
2319
|
const relaunch = async () => {
|
|
1805
2320
|
await tmux.sendKeysToPane(paneId, `${buildLaunchCommand(cfg)}\n`);
|
|
@@ -1810,16 +2325,9 @@ export class AgentManager {
|
|
|
1810
2325
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
1811
2326
|
scrollback: 0,
|
|
1812
2327
|
});
|
|
1813
|
-
|
|
1814
|
-
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
1815
|
-
}
|
|
2328
|
+
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
1816
2329
|
};
|
|
1817
|
-
|
|
1818
|
-
await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
|
|
1819
|
-
}
|
|
1820
|
-
else {
|
|
1821
|
-
await relaunch();
|
|
1822
|
-
}
|
|
2330
|
+
await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
|
|
1823
2331
|
await this.agentStore.update(agentId, (state) => {
|
|
1824
2332
|
if (!state)
|
|
1825
2333
|
return AGENT_STORE_NOOP;
|
|
@@ -1830,6 +2338,42 @@ export class AgentManager {
|
|
|
1830
2338
|
};
|
|
1831
2339
|
});
|
|
1832
2340
|
}
|
|
2341
|
+
async redispatchResearchAfterReplRestart(agentId, taskId) {
|
|
2342
|
+
const agent = this.getAgentConfig(agentId);
|
|
2343
|
+
const task = await this.taskStore.get(taskId);
|
|
2344
|
+
if (!agent || agent.role !== 'research' || !task)
|
|
2345
|
+
return false;
|
|
2346
|
+
if (task.researchAgentId !== agentId || task.agentId !== agentId || !task.signalToken)
|
|
2347
|
+
return false;
|
|
2348
|
+
let phase;
|
|
2349
|
+
let expectedKind;
|
|
2350
|
+
let findings;
|
|
2351
|
+
if (task.phase === 'research' && task.status === 'in_progress') {
|
|
2352
|
+
phase = 'research';
|
|
2353
|
+
expectedKind = 'spec-done';
|
|
2354
|
+
}
|
|
2355
|
+
else if (task.phase === 'spec' && task.status === 'fixing') {
|
|
2356
|
+
const round = task.specReviewRound ?? 1;
|
|
2357
|
+
const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
|
|
2358
|
+
if (!stored?.findings)
|
|
2359
|
+
return false;
|
|
2360
|
+
phase = 'server-feedback';
|
|
2361
|
+
expectedKind = 'spec-fixed';
|
|
2362
|
+
findings = JSON.stringify(stored.findings);
|
|
2363
|
+
}
|
|
2364
|
+
else {
|
|
2365
|
+
return false;
|
|
2366
|
+
}
|
|
2367
|
+
await this.clearAwaitingHuman(agentId);
|
|
2368
|
+
return this.continueSession(taskId, agentId, phase, {
|
|
2369
|
+
bypassTaskStatusGate: true,
|
|
2370
|
+
signalToken: task.signalToken,
|
|
2371
|
+
preserveDispatchOutputs: true,
|
|
2372
|
+
...(findings ? { serverPriorFindings: findings } : {}),
|
|
2373
|
+
...(task.specReviewRound !== undefined ? { currentSpecRound: task.specReviewRound } : {}),
|
|
2374
|
+
armBeforeInject: () => this.setupPhaseSignalWatcher(taskId, agentId, expectedKind, task.signalToken),
|
|
2375
|
+
});
|
|
2376
|
+
}
|
|
1833
2377
|
prepareRemoveTargets(agentId) {
|
|
1834
2378
|
const cfg = this.getAgentConfig(agentId);
|
|
1835
2379
|
if (!cfg)
|
|
@@ -1837,15 +2381,10 @@ export class AgentManager {
|
|
|
1837
2381
|
const project = this.getProjectConfig(cfg.projectId);
|
|
1838
2382
|
if (!project)
|
|
1839
2383
|
throw new Error(`Unknown project: ${cfg.projectId}`);
|
|
1840
|
-
if (cfg.role
|
|
2384
|
+
if (cfg.role !== 'dev')
|
|
1841
2385
|
return { targets: [agentId] };
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
const qa = pair[1];
|
|
1845
|
-
return { targets: qa ? [agentId, qa.id] : [agentId] };
|
|
1846
|
-
}
|
|
1847
|
-
}
|
|
1848
|
-
return { targets: [agentId] };
|
|
2386
|
+
const group = this.findAgentGroup(agentId);
|
|
2387
|
+
return { targets: group?.map(agent => agent.id) ?? [agentId] };
|
|
1849
2388
|
}
|
|
1850
2389
|
async cleanupRemovedAgentRuntime(targets) {
|
|
1851
2390
|
const failures = [];
|
|
@@ -1855,7 +2394,21 @@ export class AgentManager {
|
|
|
1855
2394
|
continue;
|
|
1856
2395
|
const runner = this.createRunnerFor(cfg);
|
|
1857
2396
|
const tmux = new TmuxManager(runner);
|
|
1858
|
-
const
|
|
2397
|
+
const boundState = await this.agentStore.get(id);
|
|
2398
|
+
if (boundState?.taskId) {
|
|
2399
|
+
try {
|
|
2400
|
+
const released = await this.releaseAgentForTask(id, boundState.taskId, 'idle', {
|
|
2401
|
+
allowAwaitingHuman: true,
|
|
2402
|
+
});
|
|
2403
|
+
if (!released) {
|
|
2404
|
+
throw new Error(`could not safely release task ${boundState.taskId}`);
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
catch (err) {
|
|
2408
|
+
failures.push({ agentId: id, step: 'binding.release', error: err });
|
|
2409
|
+
continue;
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
1859
2412
|
this.stopRuntimeMenuWatch(id);
|
|
1860
2413
|
if (this.paneStreamerManager) {
|
|
1861
2414
|
try {
|
|
@@ -1881,18 +2434,6 @@ export class AgentManager {
|
|
|
1881
2434
|
catch (err) {
|
|
1882
2435
|
failures.push({ agentId: id, step: 'tmux', error: err });
|
|
1883
2436
|
}
|
|
1884
|
-
const state = await this.agentStore.get(id);
|
|
1885
|
-
if (state?.worktreePath) {
|
|
1886
|
-
const cleanupDir = this.resolveWorkdir(cfg, state);
|
|
1887
|
-
if (cleanupDir) {
|
|
1888
|
-
try {
|
|
1889
|
-
await worktree.remove(cleanupDir, state.worktreePath);
|
|
1890
|
-
}
|
|
1891
|
-
catch (err) {
|
|
1892
|
-
failures.push({ agentId: id, step: 'worktree.remove', error: err });
|
|
1893
|
-
}
|
|
1894
|
-
}
|
|
1895
|
-
}
|
|
1896
2437
|
}
|
|
1897
2438
|
if (failures.length > 0) {
|
|
1898
2439
|
const summary = failures
|
|
@@ -1925,8 +2466,13 @@ export class AgentManager {
|
|
|
1925
2466
|
workdirForEstimate = cfg.workdir;
|
|
1926
2467
|
}
|
|
1927
2468
|
const workdirGuess = workdirForEstimate ?? '/'.padEnd(64, 'x');
|
|
1928
|
-
const worktreePathBound = `${workdirGuess}/.baxian-worktrees/task-9999999999_ffffffffffffffff`;
|
|
1929
2469
|
const now = new Date().toISOString();
|
|
2470
|
+
const isResearch = cfg.role === 'research';
|
|
2471
|
+
const group = this.findAgentGroup(cfg.id);
|
|
2472
|
+
const devAgentId = isResearch ? group?.find(agent => agent.role === 'dev')?.id : cfg.id;
|
|
2473
|
+
if (!devAgentId)
|
|
2474
|
+
throw new Error(`Agent ${cfg.id} has no dev agent in its group`);
|
|
2475
|
+
const qaAgentId = group?.find(agent => agent.role === 'qa')?.id;
|
|
1930
2476
|
const fakeTask = {
|
|
1931
2477
|
id: 'task-9999999999',
|
|
1932
2478
|
projectId,
|
|
@@ -1934,6 +2480,9 @@ export class AgentManager {
|
|
|
1934
2480
|
description: input.description,
|
|
1935
2481
|
preferredAgentId: input.preferredAgentId,
|
|
1936
2482
|
agentId: cfg.id,
|
|
2483
|
+
devAgentId,
|
|
2484
|
+
...(qaAgentId ? { qaAgentId } : {}),
|
|
2485
|
+
...(isResearch ? { researchAgentId: cfg.id, phase: 'research' } : {}),
|
|
1937
2486
|
branch: `${BRANCH_PREFIX}task-9999999999`,
|
|
1938
2487
|
reviewRound: 0,
|
|
1939
2488
|
status: 'in_progress',
|
|
@@ -1942,11 +2491,12 @@ export class AgentManager {
|
|
|
1942
2491
|
};
|
|
1943
2492
|
const fullPrompt = buildPromptInline({
|
|
1944
2493
|
task: fakeTask,
|
|
1945
|
-
phase: 'develop',
|
|
2494
|
+
phase: isResearch ? 'research' : 'develop',
|
|
1946
2495
|
agent: cfg,
|
|
1947
|
-
|
|
2496
|
+
workdir: workdirGuess,
|
|
1948
2497
|
skillRegistry: this.skillRegistry,
|
|
1949
2498
|
signalToken: 'preview-signal-token',
|
|
2499
|
+
hasQaPartner: qaAgentId !== undefined,
|
|
1950
2500
|
});
|
|
1951
2501
|
return Buffer.byteLength(fullPrompt, 'utf8');
|
|
1952
2502
|
}
|
|
@@ -1959,12 +2509,11 @@ export class AgentManager {
|
|
|
1959
2509
|
getProjectByRepo(repo) {
|
|
1960
2510
|
return this.config.project.find(p => repoSlug(p.repo) === repo);
|
|
1961
2511
|
}
|
|
1962
|
-
|
|
2512
|
+
findAgentGroup(anchorAgentId) {
|
|
1963
2513
|
for (const project of this.config.project) {
|
|
1964
|
-
for (const
|
|
1965
|
-
if (
|
|
1966
|
-
return
|
|
1967
|
-
}
|
|
2514
|
+
for (const group of project.agent) {
|
|
2515
|
+
if (group.some(agent => agent.id === anchorAgentId))
|
|
2516
|
+
return group;
|
|
1968
2517
|
}
|
|
1969
2518
|
}
|
|
1970
2519
|
return undefined;
|
|
@@ -2063,19 +2612,20 @@ export class AgentManager {
|
|
|
2063
2612
|
if (!mapped)
|
|
2064
2613
|
continue;
|
|
2065
2614
|
const { expectedKinds, agentId } = mapped;
|
|
2066
|
-
const
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2615
|
+
const specStage = isSpecStagePhase(task.phase);
|
|
2616
|
+
const interventionKindLabel = specStage && task.status === 'review' ? 'spec-reviewed'
|
|
2617
|
+
: specStage && task.status === 'fixing' ? 'spec-fixed'
|
|
2618
|
+
: !specStage && task.status === 'review' ? 'pr-approved|pr-changes-requested'
|
|
2619
|
+
: !specStage && task.status === 'fixing' ? 'pr-fixed'
|
|
2070
2620
|
: undefined;
|
|
2071
|
-
const isServerProtocol = task.reviewMode === 'server' ||
|
|
2621
|
+
const isServerProtocol = task.reviewMode === 'server' || specStage;
|
|
2072
2622
|
const scanSnapshotOnRecover = isServerProtocol
|
|
2073
2623
|
|| (task.phase === undefined && task.status === 'in_progress')
|
|
2074
|
-
|| (
|
|
2624
|
+
|| (!specStage && (task.status === 'review' || task.status === 'fixing'));
|
|
2075
2625
|
const allowRecoveredReadFile = task.status === 'review'
|
|
2076
|
-
&& (
|
|
2626
|
+
&& (specStage
|
|
2077
2627
|
|| (task.reviewMode === 'server' && task.batchTotal !== undefined)
|
|
2078
|
-
|| (task.reviewMode === 'server' && task.
|
|
2628
|
+
|| (task.reviewMode === 'server' && task.reviewCheckoutMode === 'base'));
|
|
2079
2629
|
try {
|
|
2080
2630
|
await this.startPhaseSignalWatch({
|
|
2081
2631
|
taskId: task.id,
|
|
@@ -2193,8 +2743,11 @@ export class AgentManager {
|
|
|
2193
2743
|
return this.taskStore.list({ projectId });
|
|
2194
2744
|
}
|
|
2195
2745
|
async findTaskByBranch(branch, projectId) {
|
|
2196
|
-
const
|
|
2197
|
-
|
|
2746
|
+
const repoKey = this.projectRepoKey(projectId);
|
|
2747
|
+
if (!repoKey)
|
|
2748
|
+
return undefined;
|
|
2749
|
+
const all = await this.taskStore.list();
|
|
2750
|
+
return all.find(t => t.branch === branch && this.projectRepoKey(t.projectId) === repoKey);
|
|
2198
2751
|
}
|
|
2199
2752
|
async listTasksByPrNumber(prNumber, projectId) {
|
|
2200
2753
|
const all = await this.taskStore.list({ projectId });
|
|
@@ -2261,13 +2814,11 @@ export class AgentManager {
|
|
|
2261
2814
|
createRepoStore(agent, project, runner) {
|
|
2262
2815
|
const host = resolveAgentHost(this.config.host, agent.host);
|
|
2263
2816
|
if (this.repoStoreFactory) {
|
|
2264
|
-
return this.repoStoreFactory(runner,
|
|
2817
|
+
return this.repoStoreFactory(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir);
|
|
2265
2818
|
}
|
|
2266
|
-
return new RepoStore(runner, project.repo, agent.mode, host, this.repoCache);
|
|
2819
|
+
return new RepoStore(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir);
|
|
2267
2820
|
}
|
|
2268
2821
|
async ensureWorkdir(agent, project, runner) {
|
|
2269
|
-
if (agent.workdir)
|
|
2270
|
-
return { workdir: agent.workdir, repoStore: null };
|
|
2271
2822
|
const repoStore = this.createRepoStore(agent, project, runner);
|
|
2272
2823
|
const workdir = await repoStore.ensure();
|
|
2273
2824
|
return { workdir, repoStore };
|
|
@@ -2275,22 +2826,29 @@ export class AgentManager {
|
|
|
2275
2826
|
resolveWorkdir(agent, agentState) {
|
|
2276
2827
|
if (agent.workdir)
|
|
2277
2828
|
return agent.workdir;
|
|
2278
|
-
return agentState?.
|
|
2279
|
-
}
|
|
2280
|
-
async resolveAutoBaseRef(runner, workdir) {
|
|
2281
|
-
const result = await runner.exec(`git -C ${shellQuote(workdir)} rev-parse --verify --quiet origin/HEAD`);
|
|
2282
|
-
if (result.exitCode !== 0) {
|
|
2283
|
-
// Falling back to the shared clone's HEAD would seed the worktree with whatever
|
|
2284
|
-
// another agent left checked out — refuse instead of silently cross-contaminating.
|
|
2285
|
-
throw new Error(`origin/HEAD is unresolvable in ${workdir}; ` +
|
|
2286
|
-
`run "git -C ${workdir} remote set-head origin --auto" and redispatch`);
|
|
2287
|
-
}
|
|
2288
|
-
return 'origin/HEAD';
|
|
2829
|
+
return agentState?.workdir ?? null;
|
|
2289
2830
|
}
|
|
2290
2831
|
getRepoCache() {
|
|
2291
2832
|
return this.repoCache;
|
|
2292
2833
|
}
|
|
2293
|
-
async rollbackFailedDispatch(taskId, agentId, reason) {
|
|
2834
|
+
async rollbackFailedDispatch(taskId, agentId, reason, expectedLockToken) {
|
|
2835
|
+
const existing = await this.agentStore.get(agentId);
|
|
2836
|
+
if (existing && existing.taskId !== undefined && existing.taskId !== taskId) {
|
|
2837
|
+
console.warn(`[AgentManager] rollback: agent ${agentId} taskId mismatch (expected ${taskId}, got ${existing.taskId}); ` +
|
|
2838
|
+
`skipping rollback — agent already reassigned`);
|
|
2839
|
+
return;
|
|
2840
|
+
}
|
|
2841
|
+
if (isCancelCleanupHold(existing)) {
|
|
2842
|
+
console.warn(`[AgentManager] rollback: agent ${agentId} held by cancel cleanup (${existing?.awaitingPhase}); ` +
|
|
2843
|
+
`leaving binding to the owner`);
|
|
2844
|
+
return;
|
|
2845
|
+
}
|
|
2846
|
+
if (!expectedLockToken
|
|
2847
|
+
|| !(await this.lockManager.isOwner(agentId, taskId, expectedLockToken))
|
|
2848
|
+
|| (existing?.taskId === taskId && existing.lockToken !== expectedLockToken)) {
|
|
2849
|
+
console.warn(`[AgentManager] rollback: generation mismatch for ${agentId}/${taskId}; skipping stale rollback`);
|
|
2850
|
+
return;
|
|
2851
|
+
}
|
|
2294
2852
|
let rolledBack = null;
|
|
2295
2853
|
await this.withTaskLock(async () => {
|
|
2296
2854
|
const task = await this.taskStore.get(taskId);
|
|
@@ -2311,17 +2869,7 @@ export class AgentManager {
|
|
|
2311
2869
|
note: 'Dispatch failed; the task is back in pending — re-dispatch it once the cause is resolved.',
|
|
2312
2870
|
});
|
|
2313
2871
|
}
|
|
2314
|
-
const
|
|
2315
|
-
if (existing && existing.taskId !== taskId) {
|
|
2316
|
-
console.warn(`[AgentManager] rollback: agent ${agentId} taskId mismatch (expected ${taskId}, got ${existing.taskId}); ` +
|
|
2317
|
-
`skipping agent cleanup — agent already reassigned`);
|
|
2318
|
-
return;
|
|
2319
|
-
}
|
|
2320
|
-
if (isCancelCleanupHold(existing)) {
|
|
2321
|
-
console.warn(`[AgentManager] rollback: agent ${agentId} held by cancel cleanup (${existing?.awaitingPhase}); ` +
|
|
2322
|
-
`leaving binding to the owner`);
|
|
2323
|
-
return;
|
|
2324
|
-
}
|
|
2872
|
+
const lockToken = expectedLockToken;
|
|
2325
2873
|
const projectId = existing?.projectId ?? this.getAgentConfig(agentId)?.projectId;
|
|
2326
2874
|
if (!projectId) {
|
|
2327
2875
|
console.error(`[AgentManager] CRITICAL: cannot resolve projectId for agent ${agentId} during rollback; deleting agent state.`);
|
|
@@ -2329,16 +2877,23 @@ export class AgentManager {
|
|
|
2329
2877
|
}
|
|
2330
2878
|
else {
|
|
2331
2879
|
const now = new Date().toISOString();
|
|
2332
|
-
await this.agentStore.update(agentId, (latest) =>
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2880
|
+
await this.agentStore.update(agentId, (latest) => {
|
|
2881
|
+
if (latest?.taskId === taskId && latest.lockToken !== expectedLockToken)
|
|
2882
|
+
return AGENT_STORE_NOOP;
|
|
2883
|
+
if (latest?.taskId !== undefined && latest.taskId !== taskId)
|
|
2884
|
+
return AGENT_STORE_NOOP;
|
|
2885
|
+
return {
|
|
2886
|
+
...(latest ?? existing ?? { id: agentId, projectId, updatedAt: now }),
|
|
2887
|
+
id: agentId,
|
|
2888
|
+
projectId,
|
|
2889
|
+
taskId: undefined,
|
|
2890
|
+
lockToken: undefined,
|
|
2891
|
+
updatedAt: now,
|
|
2892
|
+
};
|
|
2893
|
+
});
|
|
2340
2894
|
}
|
|
2341
|
-
|
|
2895
|
+
if (lockToken)
|
|
2896
|
+
await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
|
|
2342
2897
|
}
|
|
2343
2898
|
async pickAgent(projectId, preferredAgentId) {
|
|
2344
2899
|
const cfg = this.getAgentConfig(preferredAgentId);
|
|
@@ -2347,8 +2902,8 @@ export class AgentManager {
|
|
|
2347
2902
|
if (cfg.projectId !== projectId) {
|
|
2348
2903
|
throw new ApiError(400, `Agent ${preferredAgentId} not in project ${projectId}`);
|
|
2349
2904
|
}
|
|
2350
|
-
if (cfg.role
|
|
2351
|
-
throw new ApiError(400, `Agent ${preferredAgentId} is not dev role`);
|
|
2905
|
+
if (!TASK_OWNER_ROLES.has(cfg.role)) {
|
|
2906
|
+
throw new ApiError(400, `Agent ${preferredAgentId} is not dev or research role`);
|
|
2352
2907
|
}
|
|
2353
2908
|
const state = await this.agentStore.get(preferredAgentId);
|
|
2354
2909
|
if (!canDispatchWithBinding(state))
|
|
@@ -2356,6 +2911,22 @@ export class AgentManager {
|
|
|
2356
2911
|
const { projectId: _projectId, ...rest } = cfg;
|
|
2357
2912
|
return rest;
|
|
2358
2913
|
}
|
|
2914
|
+
async persistQueuedTask(task, queueReason, agentId) {
|
|
2915
|
+
await this.taskStore.set(task);
|
|
2916
|
+
await this.safeEmit({
|
|
2917
|
+
id: '',
|
|
2918
|
+
type: 'task.created',
|
|
2919
|
+
timestamp: task.createdAt,
|
|
2920
|
+
projectId: task.projectId,
|
|
2921
|
+
taskId: task.id,
|
|
2922
|
+
data: {
|
|
2923
|
+
queued: true,
|
|
2924
|
+
queueReason,
|
|
2925
|
+
...(agentId ? { agentId } : {}),
|
|
2926
|
+
},
|
|
2927
|
+
});
|
|
2928
|
+
return task;
|
|
2929
|
+
}
|
|
2359
2930
|
async createTask(projectId, input) {
|
|
2360
2931
|
return this.withTaskLock(async () => {
|
|
2361
2932
|
const taskId = await this.taskStore.nextId();
|
|
@@ -2369,6 +2940,7 @@ export class AgentManager {
|
|
|
2369
2940
|
}
|
|
2370
2941
|
}
|
|
2371
2942
|
const taskBranch = input.branch ?? BRANCH_PREFIX + taskId;
|
|
2943
|
+
const branchCreatedByBaxian = input.branch === undefined;
|
|
2372
2944
|
if (input.branch) {
|
|
2373
2945
|
const existing = await this.findTaskByBranch(input.branch, projectId);
|
|
2374
2946
|
if (existing) {
|
|
@@ -2378,125 +2950,73 @@ export class AgentManager {
|
|
|
2378
2950
|
const imageFilenames = input.images?.length
|
|
2379
2951
|
? await this.persistTaskImages(taskId, input.images)
|
|
2380
2952
|
: undefined;
|
|
2381
|
-
|
|
2382
|
-
const unassigned = {
|
|
2383
|
-
id: taskId,
|
|
2384
|
-
projectId,
|
|
2385
|
-
title: input.title,
|
|
2386
|
-
description: input.description,
|
|
2387
|
-
preferredAgentId: '',
|
|
2388
|
-
agentId: '',
|
|
2389
|
-
reviewRound: 0,
|
|
2390
|
-
status: 'pending',
|
|
2391
|
-
branch: taskBranch,
|
|
2392
|
-
reviewMode: this.effectiveReviewMode(projectId),
|
|
2393
|
-
createdAt: now,
|
|
2394
|
-
updatedAt: now,
|
|
2395
|
-
...(imageFilenames ? { images: imageFilenames } : {}),
|
|
2396
|
-
};
|
|
2397
|
-
await this.taskStore.set(unassigned);
|
|
2398
|
-
await this.safeEmit({
|
|
2399
|
-
id: '',
|
|
2400
|
-
type: 'task.created',
|
|
2401
|
-
timestamp: now,
|
|
2402
|
-
projectId,
|
|
2403
|
-
taskId,
|
|
2404
|
-
data: { queued: true, queueReason: 'unassigned' },
|
|
2405
|
-
});
|
|
2406
|
-
return unassigned;
|
|
2407
|
-
}
|
|
2408
|
-
const dev = await this.pickAgent(projectId, input.preferredAgentId);
|
|
2409
|
-
const qa = this.findQaPartner(input.preferredAgentId);
|
|
2410
|
-
if (!dev) {
|
|
2411
|
-
const queued = {
|
|
2412
|
-
id: taskId,
|
|
2413
|
-
projectId,
|
|
2414
|
-
title: input.title,
|
|
2415
|
-
description: input.description,
|
|
2416
|
-
preferredAgentId: input.preferredAgentId,
|
|
2417
|
-
agentId: '',
|
|
2418
|
-
reviewRound: 0,
|
|
2419
|
-
status: 'pending',
|
|
2420
|
-
branch: taskBranch,
|
|
2421
|
-
reviewMode: this.effectiveReviewMode(projectId),
|
|
2422
|
-
createdAt: now,
|
|
2423
|
-
updatedAt: now,
|
|
2424
|
-
...(qa ? { qaAgentId: qa.id } : {}),
|
|
2425
|
-
...(imageFilenames ? { images: imageFilenames } : {}),
|
|
2426
|
-
};
|
|
2427
|
-
await this.taskStore.set(queued);
|
|
2428
|
-
await this.safeEmit({
|
|
2429
|
-
id: '',
|
|
2430
|
-
type: 'task.created',
|
|
2431
|
-
timestamp: now,
|
|
2432
|
-
projectId,
|
|
2433
|
-
taskId,
|
|
2434
|
-
data: {
|
|
2435
|
-
queued: true,
|
|
2436
|
-
queueReason: 'preferred_agent_busy',
|
|
2437
|
-
agentId: input.preferredAgentId,
|
|
2438
|
-
},
|
|
2439
|
-
});
|
|
2440
|
-
return queued;
|
|
2441
|
-
}
|
|
2442
|
-
const acquired = await this.lockManager.acquire(dev.id, taskId);
|
|
2443
|
-
if (!acquired) {
|
|
2444
|
-
const queued = {
|
|
2445
|
-
id: taskId,
|
|
2446
|
-
projectId,
|
|
2447
|
-
title: input.title,
|
|
2448
|
-
description: input.description,
|
|
2449
|
-
preferredAgentId: input.preferredAgentId,
|
|
2450
|
-
agentId: '',
|
|
2451
|
-
reviewRound: 0,
|
|
2452
|
-
status: 'pending',
|
|
2453
|
-
branch: taskBranch,
|
|
2454
|
-
reviewMode: this.effectiveReviewMode(projectId),
|
|
2455
|
-
createdAt: now,
|
|
2456
|
-
updatedAt: now,
|
|
2457
|
-
...(qa ? { qaAgentId: qa.id } : {}),
|
|
2458
|
-
...(imageFilenames ? { images: imageFilenames } : {}),
|
|
2459
|
-
};
|
|
2460
|
-
await this.taskStore.set(queued);
|
|
2461
|
-
await this.safeEmit({
|
|
2462
|
-
id: '',
|
|
2463
|
-
type: 'task.created',
|
|
2464
|
-
timestamp: now,
|
|
2465
|
-
projectId,
|
|
2466
|
-
taskId,
|
|
2467
|
-
data: {
|
|
2468
|
-
queued: true,
|
|
2469
|
-
queueReason: 'agent_locked',
|
|
2470
|
-
agentId: input.preferredAgentId,
|
|
2471
|
-
},
|
|
2472
|
-
});
|
|
2473
|
-
return queued;
|
|
2474
|
-
}
|
|
2475
|
-
const task = {
|
|
2953
|
+
const taskBase = {
|
|
2476
2954
|
id: taskId,
|
|
2477
2955
|
projectId,
|
|
2478
2956
|
title: input.title,
|
|
2479
2957
|
description: input.description,
|
|
2480
2958
|
preferredAgentId: input.preferredAgentId,
|
|
2481
|
-
agentId: dev.id,
|
|
2482
|
-
...(qa ? { qaAgentId: qa.id } : {}),
|
|
2483
2959
|
reviewRound: 0,
|
|
2484
|
-
status: 'in_progress',
|
|
2485
2960
|
branch: taskBranch,
|
|
2961
|
+
branchCreatedByBaxian,
|
|
2486
2962
|
reviewMode: this.effectiveReviewMode(projectId),
|
|
2487
2963
|
createdAt: now,
|
|
2488
2964
|
updatedAt: now,
|
|
2489
2965
|
...(imageFilenames ? { images: imageFilenames } : {}),
|
|
2490
2966
|
};
|
|
2967
|
+
if (input.preferredAgentId === '') {
|
|
2968
|
+
const unassigned = {
|
|
2969
|
+
...taskBase,
|
|
2970
|
+
agentId: '',
|
|
2971
|
+
devAgentId: '',
|
|
2972
|
+
status: 'pending',
|
|
2973
|
+
};
|
|
2974
|
+
return this.persistQueuedTask(unassigned, 'unassigned');
|
|
2975
|
+
}
|
|
2976
|
+
const target = await this.pickAgent(projectId, input.preferredAgentId);
|
|
2977
|
+
const targetConfig = this.getAgentConfig(input.preferredAgentId);
|
|
2978
|
+
const group = this.findAgentGroup(input.preferredAgentId);
|
|
2979
|
+
const dev = group?.find(agent => agent.role === 'dev');
|
|
2980
|
+
if (!dev)
|
|
2981
|
+
throw new ApiError(409, `Agent ${input.preferredAgentId} has no dev agent in its group`);
|
|
2982
|
+
const qa = group?.find(agent => agent.role === 'qa');
|
|
2983
|
+
const research = targetConfig.role === 'research' ? targetConfig : undefined;
|
|
2984
|
+
const researchFields = research
|
|
2985
|
+
? { researchAgentId: research.id, phase: 'research' }
|
|
2986
|
+
: {};
|
|
2987
|
+
const queued = {
|
|
2988
|
+
...taskBase,
|
|
2989
|
+
agentId: '',
|
|
2990
|
+
devAgentId: dev.id,
|
|
2991
|
+
...researchFields,
|
|
2992
|
+
status: 'pending',
|
|
2993
|
+
...(qa ? { qaAgentId: qa.id } : {}),
|
|
2994
|
+
};
|
|
2995
|
+
if (!target) {
|
|
2996
|
+
return this.persistQueuedTask(queued, 'preferred_agent_busy', input.preferredAgentId);
|
|
2997
|
+
}
|
|
2998
|
+
const lockToken = await this.lockManager.acquire(target.id, taskId);
|
|
2999
|
+
if (!lockToken) {
|
|
3000
|
+
return this.persistQueuedTask(queued, 'agent_locked', input.preferredAgentId);
|
|
3001
|
+
}
|
|
3002
|
+
const task = {
|
|
3003
|
+
...taskBase,
|
|
3004
|
+
agentId: target.id,
|
|
3005
|
+
devAgentId: dev.id,
|
|
3006
|
+
...(qa ? { qaAgentId: qa.id } : {}),
|
|
3007
|
+
...researchFields,
|
|
3008
|
+
status: 'in_progress',
|
|
3009
|
+
};
|
|
2491
3010
|
await this.taskStore.set(task);
|
|
2492
|
-
await this.agentStore.update(
|
|
2493
|
-
id:
|
|
3011
|
+
await this.agentStore.update(target.id, (existing) => ({
|
|
3012
|
+
id: target.id,
|
|
2494
3013
|
projectId,
|
|
2495
3014
|
taskId,
|
|
3015
|
+
lockToken,
|
|
2496
3016
|
bootstrappingTaskId: taskId,
|
|
2497
3017
|
updatedAt: now,
|
|
2498
3018
|
...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
2499
|
-
...(existing?.
|
|
3019
|
+
...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
2500
3020
|
...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
2501
3021
|
}));
|
|
2502
3022
|
await this.safeEmit({
|
|
@@ -2504,9 +3024,9 @@ export class AgentManager {
|
|
|
2504
3024
|
type: 'task.assigned',
|
|
2505
3025
|
timestamp: now,
|
|
2506
3026
|
projectId,
|
|
2507
|
-
agentId:
|
|
3027
|
+
agentId: target.id,
|
|
2508
3028
|
taskId,
|
|
2509
|
-
data: { agentId:
|
|
3029
|
+
data: { agentId: target.id },
|
|
2510
3030
|
});
|
|
2511
3031
|
return task;
|
|
2512
3032
|
});
|
|
@@ -2516,7 +3036,8 @@ export class AgentManager {
|
|
|
2516
3036
|
if (task.status === 'in_progress' && task.agentId) {
|
|
2517
3037
|
const signalToken = createSignalToken();
|
|
2518
3038
|
await this.updateTask(task.id, { signalToken });
|
|
2519
|
-
const
|
|
3039
|
+
const dispatchLockToken = (await this.agentStore.get(task.agentId))?.lockToken;
|
|
3040
|
+
const start = this.startCreatedTaskSession(task.id, task.agentId, signalToken, dispatchLockToken);
|
|
2520
3041
|
if (opts.background) {
|
|
2521
3042
|
void start.catch((err) => {
|
|
2522
3043
|
console.error(`[AgentManager] createAndStartTask background start failed for task=${task.id}:`, err);
|
|
@@ -2530,11 +3051,16 @@ export class AgentManager {
|
|
|
2530
3051
|
}
|
|
2531
3052
|
return task;
|
|
2532
3053
|
}
|
|
2533
|
-
async startCreatedTaskSession(taskId, agentId, signalToken) {
|
|
3054
|
+
async startCreatedTaskSession(taskId, agentId, signalToken, dispatchLockToken) {
|
|
3055
|
+
const initialTask = await this.taskStore.get(taskId);
|
|
3056
|
+
if (!initialTask)
|
|
3057
|
+
return null;
|
|
3058
|
+
const initialDispatch = this.resolveInitialDispatch(initialTask);
|
|
3059
|
+
const dispatchPhase = initialDispatch.phase;
|
|
2534
3060
|
let started = false;
|
|
2535
3061
|
let dispatchErr = null;
|
|
2536
3062
|
try {
|
|
2537
|
-
started = await this.startSession(taskId, agentId,
|
|
3063
|
+
started = await this.startSession(taskId, agentId, dispatchPhase);
|
|
2538
3064
|
}
|
|
2539
3065
|
catch (err) {
|
|
2540
3066
|
dispatchErr = err;
|
|
@@ -2564,7 +3090,7 @@ export class AgentManager {
|
|
|
2564
3090
|
await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true });
|
|
2565
3091
|
return null;
|
|
2566
3092
|
}
|
|
2567
|
-
const initialKinds = this.
|
|
3093
|
+
const initialKinds = this.resolveInitialDispatch(fresh).kinds;
|
|
2568
3094
|
try {
|
|
2569
3095
|
await this.armPostDispatchSignalOrHold(taskId, agentId, initialKinds, signalToken);
|
|
2570
3096
|
}
|
|
@@ -2578,7 +3104,7 @@ export class AgentManager {
|
|
|
2578
3104
|
return null;
|
|
2579
3105
|
}
|
|
2580
3106
|
if (dispatchErr instanceof DispatchTerminalError) {
|
|
2581
|
-
await this.failTaskForDispatchError(taskId,
|
|
3107
|
+
await this.failTaskForDispatchError(taskId, dispatchPhase, agentId, dispatchErr);
|
|
2582
3108
|
}
|
|
2583
3109
|
else if (dispatchErr instanceof EnsureSessionError && dispatchErr.partial.handled) {
|
|
2584
3110
|
}
|
|
@@ -2591,7 +3117,7 @@ export class AgentManager {
|
|
|
2591
3117
|
await this.rollbackFailedDispatch(taskId, agentId, dispatchErr ? {
|
|
2592
3118
|
phase: 'dispatch-rollback',
|
|
2593
3119
|
message: dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr),
|
|
2594
|
-
} : undefined);
|
|
3120
|
+
} : undefined, dispatchLockToken);
|
|
2595
3121
|
}
|
|
2596
3122
|
}
|
|
2597
3123
|
return (await this.taskStore.get(taskId)) ?? null;
|
|
@@ -2696,6 +3222,33 @@ export class AgentManager {
|
|
|
2696
3222
|
async clearAgent(agentId) {
|
|
2697
3223
|
return this.sendSlashCommand(agentId, '/clear');
|
|
2698
3224
|
}
|
|
3225
|
+
async clearRuntimeForTaskBoundary(tmux, paneId, agentId, runtime, revalidate) {
|
|
3226
|
+
await this.acquireCompactGuard(agentId);
|
|
3227
|
+
try {
|
|
3228
|
+
await this.waitForReplPromptReady(tmux, paneId, runtime, this.cleanComposerWaitMs);
|
|
3229
|
+
await revalidate();
|
|
3230
|
+
await tmux.clearComposerDraft(paneId);
|
|
3231
|
+
const baseline = await tmux.captureSettledSnapshot(paneId, {
|
|
3232
|
+
timeoutMs: this.dispatchSettleTimeoutMs,
|
|
3233
|
+
});
|
|
3234
|
+
const baselineTitle = await tmux.readPaneTitle(paneId);
|
|
3235
|
+
await tmux.sendKeysLiteral(paneId, '/clear');
|
|
3236
|
+
await tmux.sendEnter(paneId);
|
|
3237
|
+
await tmux.waitSubmitAck(paneId, baseline, runtime, {
|
|
3238
|
+
timeoutMs: this.dispatchAckTimeoutMs,
|
|
3239
|
+
baselineTitle,
|
|
3240
|
+
acceptComposerChange: true,
|
|
3241
|
+
});
|
|
3242
|
+
await this.waitForReplPromptReady(tmux, paneId, runtime, this.dispatchAckTimeoutMs);
|
|
3243
|
+
if (await this.hasRuntimeSlashCommandRejection(tmux, paneId, '/clear')) {
|
|
3244
|
+
throw new Error('Runtime rejected /clear at the task boundary; refusing to reuse prior task context');
|
|
3245
|
+
}
|
|
3246
|
+
await revalidate();
|
|
3247
|
+
}
|
|
3248
|
+
finally {
|
|
3249
|
+
this.compactInFlight.delete(agentId);
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
2699
3252
|
async persistTaskImages(taskId, images) {
|
|
2700
3253
|
const dir = join(this.imageStagingRoot, taskId);
|
|
2701
3254
|
await mkdir(dir, { recursive: true });
|
|
@@ -2770,8 +3323,8 @@ export class AgentManager {
|
|
|
2770
3323
|
if (cfg.projectId !== fresh.projectId) {
|
|
2771
3324
|
return { task: fresh, errorCode: 400, error: `Agent ${agentId} not in project ${fresh.projectId}` };
|
|
2772
3325
|
}
|
|
2773
|
-
if (cfg.role
|
|
2774
|
-
return { task: fresh, errorCode: 400, error: `Agent ${agentId} is not dev role` };
|
|
3326
|
+
if (!TASK_OWNER_ROLES.has(cfg.role)) {
|
|
3327
|
+
return { task: fresh, errorCode: 400, error: `Agent ${agentId} is not dev or research role` };
|
|
2775
3328
|
}
|
|
2776
3329
|
if (fresh.preferredAgentId !== '' && fresh.preferredAgentId !== agentId) {
|
|
2777
3330
|
return {
|
|
@@ -2784,29 +3337,45 @@ export class AgentManager {
|
|
|
2784
3337
|
if (!canDispatchWithBinding(state)) {
|
|
2785
3338
|
return { task: fresh, errorCode: 409, error: `Agent ${agentId} is busy or awaiting human` };
|
|
2786
3339
|
}
|
|
2787
|
-
const
|
|
2788
|
-
if (!
|
|
3340
|
+
const lockToken = await this.lockManager.acquire(agentId, taskId);
|
|
3341
|
+
if (!lockToken) {
|
|
2789
3342
|
return { task: fresh, errorCode: 409, error: `Agent ${agentId} lock acquisition failed` };
|
|
2790
3343
|
}
|
|
2791
3344
|
const now = new Date().toISOString();
|
|
2792
|
-
const
|
|
3345
|
+
const initiallyUnassigned = fresh.preferredAgentId === '';
|
|
3346
|
+
const group = initiallyUnassigned ? this.findAgentGroup(agentId) : undefined;
|
|
3347
|
+
const devId = initiallyUnassigned
|
|
3348
|
+
? group?.find(agent => agent.role === 'dev')?.id
|
|
3349
|
+
: fresh.devAgentId;
|
|
3350
|
+
if (!devId) {
|
|
3351
|
+
await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
|
|
3352
|
+
return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no dev agent in its group` };
|
|
3353
|
+
}
|
|
3354
|
+
const qaId = initiallyUnassigned
|
|
3355
|
+
? group?.find(agent => agent.role === 'qa')?.id
|
|
3356
|
+
: fresh.qaAgentId;
|
|
3357
|
+
const researchId = initiallyUnassigned && cfg.role === 'research' ? cfg.id : fresh.researchAgentId;
|
|
2793
3358
|
const claimedTask = {
|
|
2794
3359
|
...fresh,
|
|
2795
3360
|
preferredAgentId: agentId,
|
|
2796
3361
|
agentId,
|
|
3362
|
+
devAgentId: devId,
|
|
3363
|
+
researchAgentId: researchId,
|
|
3364
|
+
qaAgentId: qaId,
|
|
3365
|
+
phase: researchId ? 'research' : undefined,
|
|
2797
3366
|
status: 'in_progress',
|
|
2798
3367
|
updatedAt: now,
|
|
2799
|
-
...(qaId ? { qaAgentId: qaId } : {}),
|
|
2800
3368
|
};
|
|
2801
3369
|
await this.taskStore.set(claimedTask);
|
|
2802
3370
|
await this.agentStore.update(agentId, (existing) => ({
|
|
2803
3371
|
id: agentId,
|
|
2804
3372
|
projectId: cfg.projectId,
|
|
2805
3373
|
taskId,
|
|
3374
|
+
lockToken,
|
|
2806
3375
|
bootstrappingTaskId: taskId,
|
|
2807
3376
|
updatedAt: now,
|
|
2808
3377
|
...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
2809
|
-
...(existing?.
|
|
3378
|
+
...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
2810
3379
|
...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
2811
3380
|
}));
|
|
2812
3381
|
await this.safeEmit({
|
|
@@ -2818,7 +3387,7 @@ export class AgentManager {
|
|
|
2818
3387
|
taskId,
|
|
2819
3388
|
data: { agentId, manuallyDispatched: true },
|
|
2820
3389
|
});
|
|
2821
|
-
return { task: claimedTask };
|
|
3390
|
+
return { task: claimedTask, lockToken };
|
|
2822
3391
|
});
|
|
2823
3392
|
if (claim.errorCode !== undefined)
|
|
2824
3393
|
return claim;
|
|
@@ -2829,20 +3398,21 @@ export class AgentManager {
|
|
|
2829
3398
|
await this.updateTask(claimed.id, { signalToken });
|
|
2830
3399
|
let started = false;
|
|
2831
3400
|
let dispatchErr = null;
|
|
3401
|
+
const initialDispatch = this.resolveInitialDispatch(claimed);
|
|
2832
3402
|
try {
|
|
2833
|
-
started = await this.startSession(claimed.id, claimed.agentId,
|
|
3403
|
+
started = await this.startSession(claimed.id, claimed.agentId, initialDispatch.phase);
|
|
2834
3404
|
}
|
|
2835
3405
|
catch (err) {
|
|
2836
3406
|
dispatchErr = err;
|
|
2837
3407
|
console.error(`[AgentManager] dispatchPendingTask startSession hard error for task=${claimed.id}:`, err);
|
|
2838
3408
|
}
|
|
2839
3409
|
if (started) {
|
|
2840
|
-
await this.armPostDispatchSignalOrHold(claimed.id, claimed.agentId,
|
|
3410
|
+
await this.armPostDispatchSignalOrHold(claimed.id, claimed.agentId, initialDispatch.kinds, signalToken);
|
|
2841
3411
|
const refreshed = await this.taskStore.get(claimed.id);
|
|
2842
3412
|
return { task: refreshed ?? claimed };
|
|
2843
3413
|
}
|
|
2844
3414
|
if (dispatchErr instanceof DispatchTerminalError) {
|
|
2845
|
-
await this.failTaskForDispatchError(claimed.id,
|
|
3415
|
+
await this.failTaskForDispatchError(claimed.id, initialDispatch.phase, claimed.agentId, dispatchErr);
|
|
2846
3416
|
}
|
|
2847
3417
|
else if (dispatchErr instanceof EnsureSessionError && dispatchErr.partial.handled) {
|
|
2848
3418
|
}
|
|
@@ -2850,7 +3420,7 @@ export class AgentManager {
|
|
|
2850
3420
|
await this.rollbackFailedDispatch(claimed.id, claimed.agentId, dispatchErr ? {
|
|
2851
3421
|
phase: 'dispatch-rollback',
|
|
2852
3422
|
message: dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr),
|
|
2853
|
-
} : undefined);
|
|
3423
|
+
} : undefined, claim.lockToken);
|
|
2854
3424
|
}
|
|
2855
3425
|
const refreshed = await this.taskStore.get(claimed.id);
|
|
2856
3426
|
if (dispatchErr === null) {
|
|
@@ -2859,6 +3429,50 @@ export class AgentManager {
|
|
|
2859
3429
|
const err = dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr);
|
|
2860
3430
|
return { task: refreshed, errorCode: 500, error: err };
|
|
2861
3431
|
}
|
|
3432
|
+
async switchToVerifiedReviewHead(branchManager, workdir, task, assertOwner) {
|
|
3433
|
+
if (!task.branch)
|
|
3434
|
+
throw new Error(`Task ${task.id} has no review branch`);
|
|
3435
|
+
const current = await this.taskStore.get(task.id);
|
|
3436
|
+
let expectedHeadSha = current?.latestHeadSha ?? task.latestHeadSha;
|
|
3437
|
+
if (!expectedHeadSha) {
|
|
3438
|
+
const fetched = await this.fetchPrHeadSha(task.id);
|
|
3439
|
+
expectedHeadSha = await this.persistReviewHeadIfCurrent(task.id, undefined, fetched);
|
|
3440
|
+
}
|
|
3441
|
+
await assertOwner();
|
|
3442
|
+
try {
|
|
3443
|
+
await branchManager.switchToRemoteBranchDetached(workdir, task.branch, expectedHeadSha);
|
|
3444
|
+
return;
|
|
3445
|
+
}
|
|
3446
|
+
catch (err) {
|
|
3447
|
+
if (!(err instanceof ReviewHeadMismatchError))
|
|
3448
|
+
throw err;
|
|
3449
|
+
const latest = await this.taskStore.get(task.id);
|
|
3450
|
+
let replacement = latest?.latestHeadSha;
|
|
3451
|
+
if (!replacement || replacement === expectedHeadSha) {
|
|
3452
|
+
const fetched = await this.fetchPrHeadSha(task.id);
|
|
3453
|
+
replacement = await this.persistReviewHeadIfCurrent(task.id, expectedHeadSha, fetched);
|
|
3454
|
+
}
|
|
3455
|
+
if (replacement === expectedHeadSha)
|
|
3456
|
+
throw err;
|
|
3457
|
+
await assertOwner();
|
|
3458
|
+
await branchManager.switchToRemoteBranchDetached(workdir, task.branch, replacement);
|
|
3459
|
+
}
|
|
3460
|
+
}
|
|
3461
|
+
persistReviewHeadIfCurrent(taskId, expected, replacement) {
|
|
3462
|
+
return this.withTaskLock(async () => {
|
|
3463
|
+
const latest = await this.taskStore.get(taskId);
|
|
3464
|
+
if (!latest || TERMINAL_STATUSES.includes(latest.status)) {
|
|
3465
|
+
throw new Error(`Task ${taskId} became terminal while refreshing its review head`);
|
|
3466
|
+
}
|
|
3467
|
+
if (latest.latestHeadSha && latest.latestHeadSha !== expected) {
|
|
3468
|
+
return latest.latestHeadSha;
|
|
3469
|
+
}
|
|
3470
|
+
latest.latestHeadSha = replacement;
|
|
3471
|
+
latest.updatedAt = new Date().toISOString();
|
|
3472
|
+
await this.taskStore.set(latest);
|
|
3473
|
+
return replacement;
|
|
3474
|
+
});
|
|
3475
|
+
}
|
|
2862
3476
|
async startSession(taskId, agentId, phase, opts = {}) {
|
|
2863
3477
|
const agent = this.getAgentConfig(agentId);
|
|
2864
3478
|
if (!agent)
|
|
@@ -2898,6 +3512,23 @@ export class AgentManager {
|
|
|
2898
3512
|
`${preAgent.taskId} (request ${taskId}); aborting`);
|
|
2899
3513
|
return false;
|
|
2900
3514
|
}
|
|
3515
|
+
if (!preAgent || preAgent.taskId !== taskId) {
|
|
3516
|
+
console.warn(`[AgentManager] startSession[${phase}]: agent=${agentId} has no exact task binding for lock validation`);
|
|
3517
|
+
return false;
|
|
3518
|
+
}
|
|
3519
|
+
const lockToken = await this.resolveTaskLockToken(preAgent, taskId);
|
|
3520
|
+
if (!lockToken) {
|
|
3521
|
+
console.warn(`[AgentManager] startSession[${phase}]: agent=${agentId} does not own the exclusive lock for ${taskId}`);
|
|
3522
|
+
return false;
|
|
3523
|
+
}
|
|
3524
|
+
const assertOwner = async () => {
|
|
3525
|
+
const stateNow = await this.agentStore.get(agentId);
|
|
3526
|
+
if (stateNow?.taskId !== taskId || stateNow.lockToken !== lockToken) {
|
|
3527
|
+
throw new Error(`Agent ${agentId} binding changed for task ${taskId}; operation aborted`);
|
|
3528
|
+
}
|
|
3529
|
+
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
3530
|
+
};
|
|
3531
|
+
await assertOwner();
|
|
2901
3532
|
const dialogFailFromStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
|
|
2902
3533
|
let ensure;
|
|
2903
3534
|
try {
|
|
@@ -2907,68 +3538,141 @@ export class AgentManager {
|
|
|
2907
3538
|
if (await this.handleDialogPendingFromRuntime(agentId, err, { expectedFromStatuses: dialogFailFromStatuses })) {
|
|
2908
3539
|
throw err;
|
|
2909
3540
|
}
|
|
2910
|
-
if (err instanceof EnsureSessionError && err.partial.createdSession) {
|
|
3541
|
+
if (err instanceof EnsureSessionError && err.partial.createdSession) {
|
|
3542
|
+
const stateNow = await this.agentStore.get(agentId);
|
|
3543
|
+
const stillOwner = stateNow?.taskId === taskId
|
|
3544
|
+
&& stateNow.lockToken === lockToken
|
|
3545
|
+
&& await this.lockManager.isOwner(agentId, taskId, lockToken);
|
|
3546
|
+
try {
|
|
3547
|
+
if (stillOwner) {
|
|
3548
|
+
const runner = this.createRunnerFor(agent);
|
|
3549
|
+
await new TmuxManager(runner).killSession(agentId);
|
|
3550
|
+
}
|
|
3551
|
+
else {
|
|
3552
|
+
console.warn(`[AgentManager] startSession ensureSession rollback skipped killSession for stale owner ` +
|
|
3553
|
+
`${agentId}/${taskId}`);
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3556
|
+
catch (cleanupErr) {
|
|
3557
|
+
console.warn(`[AgentManager] startSession ensureSession rollback killSession failed:`, cleanupErr);
|
|
3558
|
+
}
|
|
3559
|
+
}
|
|
3560
|
+
throw err;
|
|
3561
|
+
}
|
|
3562
|
+
const { paneId, workdir } = ensure;
|
|
3563
|
+
const runner = this.createRunnerFor(agent);
|
|
3564
|
+
const tmux = new TmuxManager(runner);
|
|
3565
|
+
const branchManager = new BranchManager(runner);
|
|
3566
|
+
const lockState = await this.agentStore.get(agentId);
|
|
3567
|
+
if (!lockState || lockState.taskId !== taskId || lockState.lockToken !== lockToken)
|
|
3568
|
+
return false;
|
|
3569
|
+
await assertOwner();
|
|
3570
|
+
await this.waitForReplPromptReady(tmux, paneId, agent.runtime, this.cleanComposerWaitMs);
|
|
3571
|
+
const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
|
|
3572
|
+
const isServerCodeReviewPhase = phase === 'server-review' || phase === 'server-recheck';
|
|
3573
|
+
let reviewCheckout;
|
|
3574
|
+
const dispatchWorkdir = workdir;
|
|
3575
|
+
try {
|
|
3576
|
+
if (isServerCodeReviewPhase && opts.serverContent !== undefined) {
|
|
3577
|
+
reviewCheckout = await branchManager.materializeReviewHead(workdir, {
|
|
3578
|
+
patch: opts.serverContent,
|
|
3579
|
+
baseSha: opts.serverBaseSha,
|
|
3580
|
+
headSha: opts.serverHeadSha,
|
|
3581
|
+
headTree: opts.serverHeadTree,
|
|
3582
|
+
});
|
|
3583
|
+
}
|
|
3584
|
+
else if (isServerQaPhase) {
|
|
3585
|
+
await branchManager.switchToDefaultDetached(workdir);
|
|
3586
|
+
}
|
|
3587
|
+
else if (phase === 'review' || phase === 'recheck') {
|
|
3588
|
+
if (!task.branch)
|
|
3589
|
+
throw new Error(`Task ${taskId} has no review branch`);
|
|
3590
|
+
await this.switchToVerifiedReviewHead(branchManager, workdir, task, assertOwner);
|
|
3591
|
+
}
|
|
3592
|
+
else if (phase === 'research') {
|
|
3593
|
+
await branchManager.switchToDefaultDetached(workdir);
|
|
3594
|
+
}
|
|
3595
|
+
else {
|
|
3596
|
+
if (!task.branch)
|
|
3597
|
+
throw new Error(`Task ${taskId} has no task branch`);
|
|
3598
|
+
await branchManager.switchToTaskBranch(workdir, taskId, task.branch, task.branchCreatedByBaxian === true);
|
|
3599
|
+
}
|
|
3600
|
+
await assertOwner();
|
|
3601
|
+
if (!ensure.freshRuntime) {
|
|
3602
|
+
await this.clearRuntimeForTaskBoundary(tmux, paneId, agentId, agent.runtime, assertOwner);
|
|
3603
|
+
if (ensure.skillsStale)
|
|
3604
|
+
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
3607
|
+
catch (err) {
|
|
3608
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3609
|
+
const holdPhase = err instanceof DirtyWorkdirError
|
|
3610
|
+
? 'dirty-workdir'
|
|
3611
|
+
: 'checkout-preparation-failed';
|
|
3612
|
+
await this.markAwaitingHuman(agentId, holdPhase, message, { expectedTaskId: taskId })
|
|
3613
|
+
.catch((holdErr) => {
|
|
3614
|
+
console.error(`[AgentManager] startSession could not persist ${holdPhase} hold for ${agentId}:`, holdErr);
|
|
3615
|
+
});
|
|
3616
|
+
throw new EnsureSessionError({ createdSession: ensure.createdSession, agentId, handled: true }, `checkout preparation failed for task ${taskId}: ${message}`);
|
|
3617
|
+
}
|
|
3618
|
+
const cleanupCheckout = async () => {
|
|
3619
|
+
try {
|
|
3620
|
+
await assertOwner();
|
|
3621
|
+
}
|
|
3622
|
+
catch {
|
|
3623
|
+
return;
|
|
3624
|
+
}
|
|
3625
|
+
await branchManager.parkOnDefaultDetached(workdir);
|
|
3626
|
+
await assertOwner();
|
|
3627
|
+
};
|
|
3628
|
+
const cleanupCheckoutOrHold = async () => {
|
|
3629
|
+
try {
|
|
3630
|
+
await cleanupCheckout();
|
|
3631
|
+
}
|
|
3632
|
+
catch (err) {
|
|
3633
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3634
|
+
let holdFailure = '';
|
|
2911
3635
|
try {
|
|
2912
|
-
|
|
2913
|
-
await new TmuxManager(runner).killSession(agentId);
|
|
3636
|
+
await this.markAwaitingHuman(agentId, 'checkout-cleanup-failed', `Task checkout could not be parked safely: ${message}`, { expectedTaskId: taskId });
|
|
2914
3637
|
}
|
|
2915
|
-
catch (
|
|
2916
|
-
|
|
3638
|
+
catch (holdErr) {
|
|
3639
|
+
holdFailure = `; persisting the hold also failed: ${holdErr instanceof Error ? holdErr.message : String(holdErr)}`;
|
|
2917
3640
|
}
|
|
3641
|
+
throw new EnsureSessionError({ createdSession: ensure.createdSession, agentId, handled: true }, `checkout cleanup failed for task ${taskId}: ${message}${holdFailure}`);
|
|
2918
3642
|
}
|
|
2919
|
-
|
|
2920
|
-
}
|
|
2921
|
-
const { paneId, workdir } = ensure;
|
|
2922
|
-
const runner = this.createRunnerFor(agent);
|
|
2923
|
-
const worktree = new WorktreeManager(runner);
|
|
2924
|
-
const tmux = new TmuxManager(runner);
|
|
2925
|
-
const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
|
|
2926
|
-
const isServerCodeReviewPhase = phase === 'server-review' || phase === 'server-recheck';
|
|
2927
|
-
const customBranch = task.branch && !task.branch.startsWith(BRANCH_PREFIX) ? task.branch : undefined;
|
|
2928
|
-
// Server code reviews materialize the captured head; other QA modes stay off origin/HEAD.
|
|
2929
|
-
// The fail-fast base resolution only runs where the default base is used.
|
|
2930
|
-
const reviewWorktree = isServerCodeReviewPhase && opts.serverContent !== undefined
|
|
2931
|
-
? await worktree.createDetachedForReviewHead(workdir, taskId, {
|
|
2932
|
-
patch: opts.serverContent,
|
|
2933
|
-
baseSha: opts.serverBaseSha,
|
|
2934
|
-
headSha: opts.serverHeadSha,
|
|
2935
|
-
headTree: opts.serverHeadTree,
|
|
2936
|
-
})
|
|
2937
|
-
: undefined;
|
|
2938
|
-
const worktreePath = reviewWorktree?.path ?? (isServerQaPhase
|
|
2939
|
-
? await worktree.createDetachedAtBase(workdir, taskId)
|
|
2940
|
-
: phase === 'review' || phase === 'recheck'
|
|
2941
|
-
? await worktree.createDetached(workdir, taskId, task.branch)
|
|
2942
|
-
: await worktree.create(workdir, taskId, agent.workdir ? undefined : await this.resolveAutoBaseRef(runner, workdir), customBranch));
|
|
3643
|
+
};
|
|
2943
3644
|
await this.agentStore.update(agentId, (stateNow) => {
|
|
2944
3645
|
if (!stateNow || stateNow.taskId !== taskId)
|
|
2945
3646
|
return AGENT_STORE_NOOP;
|
|
2946
|
-
// A fresh dispatch supersedes any question asked under the previous prompt.
|
|
2947
3647
|
const { needInputAt: _needInputAt, ...rest } = stateNow;
|
|
2948
3648
|
return {
|
|
2949
3649
|
...rest,
|
|
2950
3650
|
paneId,
|
|
2951
|
-
|
|
2952
|
-
repoPath: workdir,
|
|
3651
|
+
workdir,
|
|
2953
3652
|
updatedAt: new Date().toISOString(),
|
|
2954
3653
|
};
|
|
2955
3654
|
});
|
|
2956
3655
|
const promptSignalToken = opts.signalToken ?? task.signalToken;
|
|
2957
3656
|
const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
|
|
2958
|
-
const hasQaPartner =
|
|
3657
|
+
const hasQaPartner = task.qaAgentId !== undefined;
|
|
2959
3658
|
let prompt;
|
|
2960
3659
|
try {
|
|
3660
|
+
await this.prepareDispatchArtifacts(agent, dispatchWorkdir, phase, {
|
|
3661
|
+
specDocuments: opts.specDocuments,
|
|
3662
|
+
preserveOutputs: false,
|
|
3663
|
+
assertOwner,
|
|
3664
|
+
});
|
|
2961
3665
|
const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
|
|
2962
3666
|
let payloadOpts = {};
|
|
2963
3667
|
if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
|
|
2964
|
-
payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent,
|
|
3668
|
+
payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, dispatchWorkdir, {
|
|
2965
3669
|
phase,
|
|
2966
|
-
|
|
3670
|
+
taskPhase: task.phase,
|
|
2967
3671
|
...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
|
|
2968
3672
|
reviewRound: task.reviewRound,
|
|
2969
3673
|
...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
|
|
2970
3674
|
...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
|
|
2971
|
-
...(
|
|
3675
|
+
...(reviewCheckout?.mode === 'head' ? { forceContentFile: true } : {}),
|
|
2972
3676
|
...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
|
|
2973
3677
|
...(opts.serverInterdiff !== undefined ? { serverInterdiff: opts.serverInterdiff } : {}),
|
|
2974
3678
|
...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
|
|
@@ -2979,14 +3683,14 @@ export class AgentManager {
|
|
|
2979
3683
|
task,
|
|
2980
3684
|
phase,
|
|
2981
3685
|
agent,
|
|
2982
|
-
|
|
3686
|
+
workdir: dispatchWorkdir,
|
|
2983
3687
|
skillRegistry: this.skillRegistry,
|
|
2984
3688
|
hasQaPartner,
|
|
2985
3689
|
...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
|
|
2986
3690
|
...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
|
|
2987
3691
|
...(imagePaths.length ? { imagePaths } : {}),
|
|
2988
|
-
...(
|
|
2989
|
-
...(
|
|
3692
|
+
...(reviewCheckout ? { serverReviewCheckout: reviewCheckout.mode } : {}),
|
|
3693
|
+
...(reviewCheckout?.fallbackReason ? { serverReviewFallbackReason: reviewCheckout.fallbackReason } : {}),
|
|
2990
3694
|
...(opts.serverBaseSha !== undefined ? { serverBaseSha: opts.serverBaseSha } : {}),
|
|
2991
3695
|
...(opts.serverHeadSha !== undefined ? { serverHeadSha: opts.serverHeadSha } : {}),
|
|
2992
3696
|
...(opts.serverHeadTree !== undefined ? { serverHeadTree: opts.serverHeadTree } : {}),
|
|
@@ -2995,10 +3699,7 @@ export class AgentManager {
|
|
|
2995
3699
|
});
|
|
2996
3700
|
}
|
|
2997
3701
|
catch (err) {
|
|
2998
|
-
|
|
2999
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3000
|
-
}
|
|
3001
|
-
catch { }
|
|
3702
|
+
await cleanupCheckoutOrHold();
|
|
3002
3703
|
if (err instanceof PromptSizeError) {
|
|
3003
3704
|
throw new DispatchTerminalError('prompt_too_large', err.message);
|
|
3004
3705
|
}
|
|
@@ -3009,57 +3710,39 @@ export class AgentManager {
|
|
|
3009
3710
|
}
|
|
3010
3711
|
const taskFresh = await this.taskStore.get(taskId);
|
|
3011
3712
|
if (!taskFresh) {
|
|
3012
|
-
console.warn(`[AgentManager] startSession: task ${taskId} disappeared mid-dispatch;
|
|
3013
|
-
|
|
3014
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3015
|
-
}
|
|
3016
|
-
catch { }
|
|
3713
|
+
console.warn(`[AgentManager] startSession: task ${taskId} disappeared mid-dispatch; parking checkout before paste`);
|
|
3714
|
+
await cleanupCheckoutOrHold();
|
|
3017
3715
|
return false;
|
|
3018
3716
|
}
|
|
3019
3717
|
if (TERMINAL_STATUSES.includes(taskFresh.status)) {
|
|
3020
3718
|
console.warn(`[AgentManager] startSession: task ${taskId} status=${taskFresh.status} is terminal ` +
|
|
3021
|
-
`for phase=${phase};
|
|
3022
|
-
|
|
3023
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3024
|
-
}
|
|
3025
|
-
catch { }
|
|
3719
|
+
`for phase=${phase}; parking checkout before paste`);
|
|
3720
|
+
await cleanupCheckoutOrHold();
|
|
3026
3721
|
return false;
|
|
3027
3722
|
}
|
|
3028
3723
|
if (!opts.bypassTaskStatusGate && !expectedStatuses.includes(taskFresh.status)) {
|
|
3029
3724
|
console.warn(`[AgentManager] startSession: task ${taskId} status=${taskFresh.status} not in ` +
|
|
3030
|
-
`expected ${expectedStatuses.join('/')} for phase=${phase};
|
|
3031
|
-
|
|
3032
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3033
|
-
}
|
|
3034
|
-
catch { }
|
|
3725
|
+
`expected ${expectedStatuses.join('/')} for phase=${phase}; parking checkout before paste`);
|
|
3726
|
+
await cleanupCheckoutOrHold();
|
|
3035
3727
|
return false;
|
|
3036
3728
|
}
|
|
3037
3729
|
const agentFresh = await this.agentStore.get(agentId);
|
|
3038
3730
|
if (PHASE_REQUIRES_AGENT_BOUND_TO_TASK[phase]) {
|
|
3039
3731
|
if (!agentFresh || agentFresh.taskId !== taskId) {
|
|
3040
3732
|
console.warn(`[AgentManager] startSession[${phase}]: agent ${agentId} not bound to ${taskId} ` +
|
|
3041
|
-
`(got taskId=${agentFresh?.taskId});
|
|
3042
|
-
|
|
3043
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3044
|
-
}
|
|
3045
|
-
catch { }
|
|
3733
|
+
`(got taskId=${agentFresh?.taskId}); parking checkout before paste`);
|
|
3734
|
+
await cleanupCheckoutOrHold();
|
|
3046
3735
|
return false;
|
|
3047
3736
|
}
|
|
3048
3737
|
}
|
|
3049
3738
|
else if (agentFresh && agentFresh.taskId && agentFresh.taskId !== taskId) {
|
|
3050
3739
|
console.warn(`[AgentManager] startSession[${phase}]: agent ${agentId} reassigned to ${agentFresh.taskId} ` +
|
|
3051
|
-
`(was ${taskId});
|
|
3052
|
-
|
|
3053
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3054
|
-
}
|
|
3055
|
-
catch { }
|
|
3740
|
+
`(was ${taskId}); parking checkout before paste`);
|
|
3741
|
+
await cleanupCheckoutOrHold();
|
|
3056
3742
|
return false;
|
|
3057
3743
|
}
|
|
3058
|
-
if (opts.armBeforeInject && !(await opts.armBeforeInject({
|
|
3059
|
-
|
|
3060
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3061
|
-
}
|
|
3062
|
-
catch { }
|
|
3744
|
+
if (opts.armBeforeInject && !(await opts.armBeforeInject({ serverReviewCheckout: reviewCheckout?.mode }))) {
|
|
3745
|
+
await cleanupCheckoutOrHold();
|
|
3063
3746
|
return false;
|
|
3064
3747
|
}
|
|
3065
3748
|
const now = new Date().toISOString();
|
|
@@ -3076,8 +3759,8 @@ export class AgentManager {
|
|
|
3076
3759
|
projectId: agent.projectId,
|
|
3077
3760
|
paneId,
|
|
3078
3761
|
taskId,
|
|
3079
|
-
|
|
3080
|
-
|
|
3762
|
+
workdir,
|
|
3763
|
+
lockToken,
|
|
3081
3764
|
startedAt: now,
|
|
3082
3765
|
bootstrappingTaskId: taskId,
|
|
3083
3766
|
updatedAt: now,
|
|
@@ -3087,10 +3770,7 @@ export class AgentManager {
|
|
|
3087
3770
|
if (cancelHoldWon) {
|
|
3088
3771
|
console.warn(`[AgentManager] startSession[${phase}]: agent ${agentId} entered a cancel-cleanup hold during dispatch; ` +
|
|
3089
3772
|
`aborting so cancel can finish interrupt + /clear`);
|
|
3090
|
-
|
|
3091
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3092
|
-
}
|
|
3093
|
-
catch { }
|
|
3773
|
+
await cleanupCheckoutOrHold();
|
|
3094
3774
|
return false;
|
|
3095
3775
|
}
|
|
3096
3776
|
agentMarkedRunning = true;
|
|
@@ -3112,7 +3792,7 @@ export class AgentManager {
|
|
|
3112
3792
|
projectId: agent.projectId,
|
|
3113
3793
|
agentId,
|
|
3114
3794
|
taskId,
|
|
3115
|
-
data: { phase,
|
|
3795
|
+
data: { phase, workdir: dispatchWorkdir },
|
|
3116
3796
|
});
|
|
3117
3797
|
this.startRuntimeMenuWatch(agentId);
|
|
3118
3798
|
return true;
|
|
@@ -3120,13 +3800,11 @@ export class AgentManager {
|
|
|
3120
3800
|
catch (err) {
|
|
3121
3801
|
const isAckUnknown = err instanceof DispatchTerminalError && err.reason === 'ack_unknown';
|
|
3122
3802
|
if (!isAckUnknown) {
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
}
|
|
3126
|
-
catch { }
|
|
3127
|
-
if (agentMarkedRunning) {
|
|
3803
|
+
await cleanupCheckoutOrHold();
|
|
3804
|
+
if (agentMarkedRunning && !opts.preserveBindingOnFailure) {
|
|
3128
3805
|
try {
|
|
3129
3806
|
let released = false;
|
|
3807
|
+
let releaseToken;
|
|
3130
3808
|
await this.agentStore.update(agentId, (agentNow) => {
|
|
3131
3809
|
if (!agentNow || agentNow.taskId !== taskId) {
|
|
3132
3810
|
console.warn(`[AgentManager] startSession cleanup agentStore: agent ${agentId} already reassigned ` +
|
|
@@ -3139,18 +3817,19 @@ export class AgentManager {
|
|
|
3139
3817
|
return AGENT_STORE_NOOP;
|
|
3140
3818
|
}
|
|
3141
3819
|
released = true;
|
|
3820
|
+
releaseToken = agentNow.lockToken;
|
|
3142
3821
|
void err;
|
|
3143
3822
|
return {
|
|
3144
3823
|
id: agentId,
|
|
3145
3824
|
projectId: agent.projectId,
|
|
3146
3825
|
paneId,
|
|
3147
|
-
|
|
3826
|
+
workdir,
|
|
3148
3827
|
updatedAt: new Date().toISOString(),
|
|
3149
3828
|
...(agentNow.creationToken !== undefined ? { creationToken: agentNow.creationToken } : {}),
|
|
3150
3829
|
};
|
|
3151
3830
|
});
|
|
3152
|
-
if (released) {
|
|
3153
|
-
await this.lockManager.
|
|
3831
|
+
if (released && releaseToken) {
|
|
3832
|
+
await this.lockManager.releaseIfOwner(agentId, taskId, releaseToken);
|
|
3154
3833
|
}
|
|
3155
3834
|
}
|
|
3156
3835
|
catch (cleanupErr) {
|
|
@@ -3163,13 +3842,25 @@ export class AgentManager {
|
|
|
3163
3842
|
}
|
|
3164
3843
|
async injectAndAwaitAck(tmux, paneId, prompt, agentId, runtime) {
|
|
3165
3844
|
const before = await this.agentStore.get(agentId);
|
|
3845
|
+
const beforeLockToken = before?.taskId
|
|
3846
|
+
? await this.resolveTaskLockToken(before, before.taskId)
|
|
3847
|
+
: null;
|
|
3848
|
+
if (before?.taskId && !beforeLockToken) {
|
|
3849
|
+
throw new Error(`dispatch aborted: agent ${agentId} no longer owns task ${before.taskId}`);
|
|
3850
|
+
}
|
|
3166
3851
|
await this.acquireCompactGuard(agentId);
|
|
3167
3852
|
try {
|
|
3168
3853
|
if (before) {
|
|
3169
3854
|
const now = await this.agentStore.get(agentId);
|
|
3170
|
-
if (!now
|
|
3855
|
+
if (!now
|
|
3856
|
+
|| now.paneId !== before.paneId
|
|
3857
|
+
|| now.taskId !== before.taskId
|
|
3858
|
+
|| (beforeLockToken !== null && now.lockToken !== beforeLockToken)) {
|
|
3171
3859
|
throw new Error(`dispatch aborted: agent ${agentId} binding changed while waiting for pane mutex`);
|
|
3172
3860
|
}
|
|
3861
|
+
if (before.taskId && beforeLockToken) {
|
|
3862
|
+
await this.assertTaskLockOwner(agentId, before.taskId, beforeLockToken);
|
|
3863
|
+
}
|
|
3173
3864
|
if (isCancelCleanupHold(now)) {
|
|
3174
3865
|
throw new Error(`dispatch aborted: agent ${agentId} taken over by cancel (${now.awaitingPhase}) while waiting for pane mutex`);
|
|
3175
3866
|
}
|
|
@@ -3181,9 +3872,16 @@ export class AgentManager {
|
|
|
3181
3872
|
const revalidate = before
|
|
3182
3873
|
? async () => {
|
|
3183
3874
|
const fresh = await this.agentStore.get(agentId);
|
|
3184
|
-
if (!fresh
|
|
3875
|
+
if (!fresh
|
|
3876
|
+
|| fresh.paneId !== before.paneId
|
|
3877
|
+
|| fresh.taskId !== before.taskId
|
|
3878
|
+
|| (beforeLockToken !== null && fresh.lockToken !== beforeLockToken)
|
|
3879
|
+
|| isCancelCleanupHold(fresh)) {
|
|
3185
3880
|
throw new Error(`dispatch aborted: agent ${agentId} taken over by cancel before paste`);
|
|
3186
3881
|
}
|
|
3882
|
+
if (before.taskId && beforeLockToken) {
|
|
3883
|
+
await this.assertTaskLockOwner(agentId, before.taskId, beforeLockToken);
|
|
3884
|
+
}
|
|
3187
3885
|
const boundTask = fresh.taskId ? await this.taskStore.get(fresh.taskId) : null;
|
|
3188
3886
|
if (boundTask && TERMINAL_STATUSES.includes(boundTask.status)) {
|
|
3189
3887
|
throw new Error(`dispatch aborted: task ${fresh.taskId} for agent ${agentId} went terminal before paste`);
|
|
@@ -3300,7 +3998,12 @@ export class AgentManager {
|
|
|
3300
3998
|
`(pre-paste taskId=${agentState.taskId} !== ${taskId}); skipping`);
|
|
3301
3999
|
return false;
|
|
3302
4000
|
}
|
|
3303
|
-
const
|
|
4001
|
+
const taskWorkdir = agentState.workdir;
|
|
4002
|
+
if (!taskWorkdir)
|
|
4003
|
+
throw new Error(`Agent ${agentId} has no Workdir`);
|
|
4004
|
+
const lockToken = await this.resolveTaskLockToken(agentState, taskId);
|
|
4005
|
+
if (!lockToken)
|
|
4006
|
+
throw new Error(`Agent ${agentId} no longer owns task ${taskId}`);
|
|
3304
4007
|
const dialogFailFromStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
|
|
3305
4008
|
let ensure;
|
|
3306
4009
|
try {
|
|
@@ -3311,9 +4014,19 @@ export class AgentManager {
|
|
|
3311
4014
|
throw err;
|
|
3312
4015
|
}
|
|
3313
4016
|
if (err instanceof EnsureSessionError && err.partial.createdSession) {
|
|
4017
|
+
const stateNow = await this.agentStore.get(agentId);
|
|
4018
|
+
const stillOwner = stateNow?.taskId === taskId
|
|
4019
|
+
&& stateNow.lockToken === lockToken
|
|
4020
|
+
&& await this.lockManager.isOwner(agentId, taskId, lockToken);
|
|
3314
4021
|
try {
|
|
3315
|
-
|
|
3316
|
-
|
|
4022
|
+
if (stillOwner) {
|
|
4023
|
+
const runner = this.createRunnerFor(agent);
|
|
4024
|
+
await new TmuxManager(runner).killSession(agentId);
|
|
4025
|
+
}
|
|
4026
|
+
else {
|
|
4027
|
+
console.warn(`[AgentManager] continueSession ensureSession rollback skipped killSession for stale owner ` +
|
|
4028
|
+
`${agentId}/${taskId}`);
|
|
4029
|
+
}
|
|
3317
4030
|
}
|
|
3318
4031
|
catch (cleanupErr) {
|
|
3319
4032
|
console.warn(`[AgentManager] continueSession ensureSession rollback killSession failed:`, cleanupErr);
|
|
@@ -3324,18 +4037,66 @@ export class AgentManager {
|
|
|
3324
4037
|
const { paneId } = ensure;
|
|
3325
4038
|
const runner = this.createRunnerFor(agent);
|
|
3326
4039
|
const tmux = new TmuxManager(runner);
|
|
4040
|
+
const stateAfterEnsure = await this.agentStore.get(agentId);
|
|
4041
|
+
if (stateAfterEnsure?.taskId !== taskId || stateAfterEnsure.lockToken !== lockToken) {
|
|
4042
|
+
console.warn(`[AgentManager] continueSession[${phase}]: ownership changed during ensureSession; skipping`);
|
|
4043
|
+
return false;
|
|
4044
|
+
}
|
|
4045
|
+
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
4046
|
+
const verifiedWorkdir = stateAfterEnsure.workdir;
|
|
4047
|
+
if (!verifiedWorkdir
|
|
4048
|
+
|| verifiedWorkdir !== ensure.workdir
|
|
4049
|
+
|| verifiedWorkdir !== taskWorkdir) {
|
|
4050
|
+
const reason = `Workdir changed during continueSession: ` +
|
|
4051
|
+
`before=${taskWorkdir}, ensure=${ensure.workdir}, state=${verifiedWorkdir ?? 'missing'}`;
|
|
4052
|
+
console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
|
|
4053
|
+
await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
|
|
4054
|
+
return false;
|
|
4055
|
+
}
|
|
4056
|
+
const expectedStatuses = PHASE_EXPECTED_STATUS[phase] ?? [];
|
|
4057
|
+
const taskAfterEnsure = await this.taskStore.get(taskId);
|
|
4058
|
+
if (!taskAfterEnsure || TERMINAL_STATUSES.includes(taskAfterEnsure.status)) {
|
|
4059
|
+
console.warn(`[AgentManager] continueSession: task ${taskId} status=${taskAfterEnsure?.status} terminal/missing after ensure; skipping`);
|
|
4060
|
+
return false;
|
|
4061
|
+
}
|
|
4062
|
+
if (!opts.bypassTaskStatusGate && !expectedStatuses.includes(taskAfterEnsure.status)) {
|
|
4063
|
+
console.warn(`[AgentManager] continueSession: task ${taskId} status=${taskAfterEnsure.status} not in ` +
|
|
4064
|
+
`expected ${expectedStatuses.join('/')} for phase=${phase} after ensure; skipping`);
|
|
4065
|
+
return false;
|
|
4066
|
+
}
|
|
4067
|
+
if (agent.role === 'dev' && task.branch) {
|
|
4068
|
+
const branches = new BranchManager(runner);
|
|
4069
|
+
await branches.assertClean(verifiedWorkdir);
|
|
4070
|
+
const actualRef = await branches.currentRef(verifiedWorkdir);
|
|
4071
|
+
if (actualRef !== `refs/heads/${task.branch}`) {
|
|
4072
|
+
throw new Error(`Agent ${agentId} checkout mismatch for task ${taskId}: ` +
|
|
4073
|
+
`expected refs/heads/${task.branch}, got ${actualRef ?? 'detached HEAD'}`);
|
|
4074
|
+
}
|
|
4075
|
+
}
|
|
4076
|
+
else if (agent.role === 'research') {
|
|
4077
|
+
await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
|
|
4078
|
+
await new BranchManager(runner).switchToDefaultDetached(verifiedWorkdir);
|
|
4079
|
+
await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
|
|
4080
|
+
}
|
|
3327
4081
|
const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
|
|
3328
4082
|
let prompt;
|
|
3329
4083
|
try {
|
|
4084
|
+
await this.prepareDispatchArtifacts(agent, verifiedWorkdir, phase, {
|
|
4085
|
+
specDocuments: opts.specDocuments,
|
|
4086
|
+
preserveOutputs: opts.preserveDispatchOutputs ?? false,
|
|
4087
|
+
assertOwner: async () => {
|
|
4088
|
+
await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
|
|
4089
|
+
},
|
|
4090
|
+
});
|
|
3330
4091
|
const useIncrementalNudge = typeof opts.postApproveRedispatchCount === 'number'
|
|
3331
4092
|
&& opts.postApproveRedispatchCount > 0
|
|
3332
4093
|
&& !ensure.freshRuntime;
|
|
3333
4094
|
const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
|
|
3334
4095
|
let payloadOpts = {};
|
|
3335
4096
|
if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
|
|
3336
|
-
payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent,
|
|
4097
|
+
payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, verifiedWorkdir, {
|
|
3337
4098
|
phase,
|
|
3338
|
-
|
|
4099
|
+
taskPhase: task.phase,
|
|
3339
4100
|
...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
|
|
3340
4101
|
reviewRound: task.reviewRound,
|
|
3341
4102
|
...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
|
|
@@ -3350,7 +4111,7 @@ export class AgentManager {
|
|
|
3350
4111
|
task,
|
|
3351
4112
|
phase,
|
|
3352
4113
|
agent,
|
|
3353
|
-
|
|
4114
|
+
workdir: verifiedWorkdir,
|
|
3354
4115
|
skillRegistry: this.skillRegistry,
|
|
3355
4116
|
...(signalToken ? { signalToken } : {}),
|
|
3356
4117
|
...(useIncrementalNudge
|
|
@@ -3372,7 +4133,6 @@ export class AgentManager {
|
|
|
3372
4133
|
}
|
|
3373
4134
|
throw err;
|
|
3374
4135
|
}
|
|
3375
|
-
const expectedStatuses = PHASE_EXPECTED_STATUS[phase] ?? [];
|
|
3376
4136
|
const taskFresh = await this.taskStore.get(taskId);
|
|
3377
4137
|
if (!taskFresh || TERMINAL_STATUSES.includes(taskFresh.status)) {
|
|
3378
4138
|
console.warn(`[AgentManager] continueSession: task ${taskId} status=${taskFresh?.status} terminal/missing; skipping paste`);
|
|
@@ -3396,6 +4156,18 @@ export class AgentManager {
|
|
|
3396
4156
|
`(taskId=${agentFresh.taskId} !== ${taskId}); skipping`);
|
|
3397
4157
|
return false;
|
|
3398
4158
|
}
|
|
4159
|
+
if (!agentFresh || agentFresh.lockToken !== lockToken) {
|
|
4160
|
+
console.warn(`[AgentManager] continueSession[${phase}]: ownership token changed; skipping`);
|
|
4161
|
+
return false;
|
|
4162
|
+
}
|
|
4163
|
+
if (agentFresh.workdir !== verifiedWorkdir) {
|
|
4164
|
+
const reason = `Workdir changed before prompt injection: ` +
|
|
4165
|
+
`verified=${verifiedWorkdir}, current=${agentFresh.workdir ?? 'missing'}`;
|
|
4166
|
+
console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
|
|
4167
|
+
await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
|
|
4168
|
+
return false;
|
|
4169
|
+
}
|
|
4170
|
+
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
3399
4171
|
if (phase === 'post-approve') {
|
|
3400
4172
|
const completionFresh = await this.getPostApproveCompletion(taskId);
|
|
3401
4173
|
if (!completionFresh || completionFresh.token !== signalToken) {
|
|
@@ -3408,48 +4180,89 @@ export class AgentManager {
|
|
|
3408
4180
|
}
|
|
3409
4181
|
const now = new Date().toISOString();
|
|
3410
4182
|
await this.agentStore.update(agentId, (latest) => {
|
|
3411
|
-
if (!latest
|
|
4183
|
+
if (!latest
|
|
4184
|
+
|| latest.taskId !== taskId
|
|
4185
|
+
|| latest.lockToken !== lockToken
|
|
4186
|
+
|| latest.workdir !== verifiedWorkdir) {
|
|
3412
4187
|
return AGENT_STORE_NOOP;
|
|
4188
|
+
}
|
|
3413
4189
|
// The continuation prompt supersedes any question asked under the previous one.
|
|
3414
4190
|
const { needInputAt: _needInputAt, ...rest } = latest;
|
|
3415
4191
|
return {
|
|
3416
4192
|
...rest,
|
|
3417
4193
|
paneId,
|
|
3418
|
-
|
|
4194
|
+
workdir: verifiedWorkdir,
|
|
4195
|
+
lockToken,
|
|
3419
4196
|
updatedAt: now,
|
|
3420
4197
|
};
|
|
3421
4198
|
});
|
|
4199
|
+
const stateBeforeInject = await this.agentStore.get(agentId);
|
|
4200
|
+
if (stateBeforeInject?.taskId !== taskId || stateBeforeInject.lockToken !== lockToken) {
|
|
4201
|
+
console.warn(`[AgentManager] continueSession[${phase}]: ownership changed before prompt injection; skipping`);
|
|
4202
|
+
return false;
|
|
4203
|
+
}
|
|
4204
|
+
if (stateBeforeInject.workdir !== verifiedWorkdir) {
|
|
4205
|
+
const reason = `Workdir changed before prompt injection: ` +
|
|
4206
|
+
`verified=${verifiedWorkdir}, current=${stateBeforeInject.workdir ?? 'missing'}`;
|
|
4207
|
+
console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
|
|
4208
|
+
await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
|
|
4209
|
+
return false;
|
|
4210
|
+
}
|
|
4211
|
+
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
3422
4212
|
await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime);
|
|
3423
4213
|
return true;
|
|
3424
4214
|
}
|
|
3425
|
-
async rollbackUndeliveredBootstrap(state
|
|
4215
|
+
async rollbackUndeliveredBootstrap(state) {
|
|
3426
4216
|
if (!state.taskId
|
|
3427
4217
|
|| state.bootstrappingTaskId !== state.taskId
|
|
3428
4218
|
|| state.awaitingPhase === 'bootstrap-marker-clear-failed') {
|
|
3429
4219
|
return false;
|
|
3430
4220
|
}
|
|
3431
4221
|
const boundTask = await this.taskStore.get(state.taskId);
|
|
3432
|
-
if (!boundTask
|
|
4222
|
+
if (!boundTask)
|
|
4223
|
+
return false;
|
|
4224
|
+
if (boundTask.status === 'spec-ready' && isSpecStagePhase(boundTask.phase)) {
|
|
4225
|
+
if (state.id === boundTask.devAgentId && state.id !== boundTask.agentId) {
|
|
4226
|
+
const lockToken = state.lockToken;
|
|
4227
|
+
await this.agentStore.update(state.id, (latest) => {
|
|
4228
|
+
if (!latest
|
|
4229
|
+
|| latest.taskId !== state.taskId
|
|
4230
|
+
|| latest.bootstrappingTaskId !== state.taskId
|
|
4231
|
+
|| latest.lockToken !== lockToken) {
|
|
4232
|
+
return AGENT_STORE_NOOP;
|
|
4233
|
+
}
|
|
4234
|
+
return {
|
|
4235
|
+
id: latest.id,
|
|
4236
|
+
projectId: latest.projectId,
|
|
4237
|
+
...(latest.paneId !== undefined ? { paneId: latest.paneId } : {}),
|
|
4238
|
+
...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
|
|
4239
|
+
...(latest.creationToken !== undefined ? { creationToken: latest.creationToken } : {}),
|
|
4240
|
+
updatedAt: new Date().toISOString(),
|
|
4241
|
+
};
|
|
4242
|
+
});
|
|
4243
|
+
if (lockToken)
|
|
4244
|
+
await this.lockManager.releaseIfOwner(state.id, state.taskId, lockToken);
|
|
4245
|
+
return true;
|
|
4246
|
+
}
|
|
4247
|
+
await this.clearBootstrapMarker(state.id, state.taskId);
|
|
3433
4248
|
return false;
|
|
3434
4249
|
}
|
|
3435
|
-
if (
|
|
4250
|
+
if (boundTask.status !== 'in_progress' || boundTask.agentId !== state.id)
|
|
4251
|
+
return false;
|
|
4252
|
+
const dispatchPhase = boundTask.phase === 'research'
|
|
4253
|
+
? 'research'
|
|
4254
|
+
: boundTask.specReviewRound !== undefined ? 'code' : 'develop';
|
|
4255
|
+
if (await this.bootstrapPromptWasDelivered(state.taskId, boundTask.createdAt, state.id, dispatchPhase)) {
|
|
3436
4256
|
await this.clearBootstrapMarker(state.id, state.taskId);
|
|
3437
4257
|
return false;
|
|
3438
4258
|
}
|
|
4259
|
+
if (dispatchPhase === 'code') {
|
|
4260
|
+
await this.markAwaitingHuman(state.id, 'code-dispatch-failed', 'Code-phase handoff was interrupted before the prompt was delivered. Resume to replay the persisted Spec documents.', { expectedTaskId: state.taskId });
|
|
4261
|
+
return true;
|
|
4262
|
+
}
|
|
3439
4263
|
console.warn(`[recover] agent ${state.id} was mid-bootstrap for in_progress task ${state.taskId} ` +
|
|
3440
4264
|
`(prompt never ack'd); rolling the task back to pending`);
|
|
3441
|
-
|
|
3442
|
-
const cleanupDir = this.resolveWorkdir(agentConfig, state);
|
|
3443
|
-
if (cleanupDir) {
|
|
3444
|
-
try {
|
|
3445
|
-
await new WorktreeManager(this.createRunnerFor(agentConfig)).remove(cleanupDir, state.worktreePath);
|
|
3446
|
-
}
|
|
3447
|
-
catch (worktreeErr) {
|
|
3448
|
-
console.warn(`[recover] mid-bootstrap rollback: worktree.remove failed for ${state.worktreePath}:`, worktreeErr);
|
|
3449
|
-
}
|
|
3450
|
-
}
|
|
3451
|
-
}
|
|
3452
|
-
await this.rollbackFailedDispatch(state.taskId, state.id);
|
|
4265
|
+
await this.rollbackFailedDispatch(state.taskId, state.id, undefined, state.lockToken);
|
|
3453
4266
|
await this.agentStore.update(state.id, (latest) => {
|
|
3454
4267
|
if (!latest || latest.status !== 'awaiting_human')
|
|
3455
4268
|
return AGENT_STORE_NOOP;
|
|
@@ -3458,12 +4271,15 @@ export class AgentManager {
|
|
|
3458
4271
|
});
|
|
3459
4272
|
return true;
|
|
3460
4273
|
}
|
|
3461
|
-
async bootstrapPromptWasDelivered(taskId, createdAtIso) {
|
|
4274
|
+
async bootstrapPromptWasDelivered(taskId, createdAtIso, agentId, phase) {
|
|
3462
4275
|
const today = new Date().toISOString().slice(0, 10);
|
|
3463
4276
|
const from = createdAtIso.slice(0, 10);
|
|
3464
4277
|
try {
|
|
3465
4278
|
const events = await this.eventBus.readRange(from, today);
|
|
3466
|
-
return events.some((
|
|
4279
|
+
return events.some((event) => event.type === 'session.started'
|
|
4280
|
+
&& event.taskId === taskId
|
|
4281
|
+
&& event.agentId === agentId
|
|
4282
|
+
&& event.data.phase === phase);
|
|
3467
4283
|
}
|
|
3468
4284
|
catch (err) {
|
|
3469
4285
|
console.warn(`[recover] bootstrapPromptWasDelivered read failed for task=${taskId}:`, err);
|
|
@@ -3478,16 +4294,56 @@ export class AgentManager {
|
|
|
3478
4294
|
return { ...rest, updatedAt: new Date().toISOString() };
|
|
3479
4295
|
});
|
|
3480
4296
|
}
|
|
4297
|
+
async releaseOrphanedLocks(states) {
|
|
4298
|
+
const bindings = new Map(states.map(state => [state.id, state]));
|
|
4299
|
+
const claims = await this.lockManager.listClaims();
|
|
4300
|
+
for (const claim of claims) {
|
|
4301
|
+
const binding = bindings.get(claim.agentId);
|
|
4302
|
+
const exactBinding = binding?.taskId === claim.taskId && binding.lockToken === claim.token;
|
|
4303
|
+
if (!claim.taskId.startsWith('maintenance:') && exactBinding)
|
|
4304
|
+
continue;
|
|
4305
|
+
const released = await this.lockManager.releaseIfOwner(claim.agentId, claim.taskId, claim.token);
|
|
4306
|
+
if (!released) {
|
|
4307
|
+
console.warn(`[recover] lock changed while reclaiming ${claim.agentId}/${claim.taskId}; preserving the newer claim`);
|
|
4308
|
+
}
|
|
4309
|
+
}
|
|
4310
|
+
}
|
|
3481
4311
|
async recover() {
|
|
3482
4312
|
const states = await this.agentStore.list();
|
|
4313
|
+
await this.releaseOrphanedLocks(states);
|
|
3483
4314
|
const deferredCleanups = [];
|
|
3484
4315
|
for (const state of states) {
|
|
3485
4316
|
const agentConfig = this.getAgentConfig(state.id);
|
|
3486
4317
|
if (!agentConfig)
|
|
3487
4318
|
continue;
|
|
4319
|
+
if (state.taskId) {
|
|
4320
|
+
let token = null;
|
|
4321
|
+
try {
|
|
4322
|
+
token = await this.resolveTaskLockToken(state, state.taskId);
|
|
4323
|
+
}
|
|
4324
|
+
catch (err) {
|
|
4325
|
+
console.warn(`[recover] lock validation failed for ${state.id}/${state.taskId}:`, err);
|
|
4326
|
+
}
|
|
4327
|
+
if (!token) {
|
|
4328
|
+
const reason = `Exclusive lock ownership cannot be proven for task ${state.taskId}; runtime recovery was not attempted.`;
|
|
4329
|
+
await this.markAwaitingHuman(state.id, 'recovery-lock-invalid', reason, { expectedTaskId: state.taskId });
|
|
4330
|
+
await this.recordError({
|
|
4331
|
+
agentId: state.id,
|
|
4332
|
+
projectId: state.projectId,
|
|
4333
|
+
taskId: state.taskId,
|
|
4334
|
+
operation: 'recovery',
|
|
4335
|
+
reason: 'LOCK_OWNERSHIP_INVALID',
|
|
4336
|
+
message: reason,
|
|
4337
|
+
observation: { phase: 'pre-runtime-recovery' },
|
|
4338
|
+
recommendation: 'Inspect the persisted lock and task binding, then cancel or delete the Agent if ownership cannot be restored.',
|
|
4339
|
+
});
|
|
4340
|
+
continue;
|
|
4341
|
+
}
|
|
4342
|
+
state.lockToken = token;
|
|
4343
|
+
}
|
|
3488
4344
|
try {
|
|
3489
4345
|
const result = await this.ensureSession(state.id, 'recover');
|
|
3490
|
-
if (await this.rollbackUndeliveredBootstrap(state
|
|
4346
|
+
if (await this.rollbackUndeliveredBootstrap(state))
|
|
3491
4347
|
continue;
|
|
3492
4348
|
if (state.creationToken && !state.taskId) {
|
|
3493
4349
|
const ct = state.creationToken;
|
|
@@ -3546,18 +4402,16 @@ export class AgentManager {
|
|
|
3546
4402
|
}
|
|
3547
4403
|
const cancelHold = isCancelCleanupHold(state);
|
|
3548
4404
|
const shouldReleaseBinding = shouldReleaseHeldBinding(state, boundTask) && !cancelHold;
|
|
3549
|
-
if (shouldReleaseBinding && state.
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
}
|
|
3560
|
-
}
|
|
4405
|
+
if (shouldReleaseBinding && state.taskId) {
|
|
4406
|
+
await this.agentStore.update(state.id, (latest) => {
|
|
4407
|
+
if (!latest || latest.taskId !== state.taskId)
|
|
4408
|
+
return AGENT_STORE_NOOP;
|
|
4409
|
+
return { ...latest, paneId: result.paneId, updatedAt: new Date().toISOString() };
|
|
4410
|
+
});
|
|
4411
|
+
await this.releaseAgentForTask(state.id, state.taskId, 'idle', {
|
|
4412
|
+
allowAwaitingHuman: true,
|
|
4413
|
+
});
|
|
4414
|
+
continue;
|
|
3561
4415
|
}
|
|
3562
4416
|
const preserveHeld = !shouldReleaseBinding
|
|
3563
4417
|
&& state.status === 'awaiting_human';
|
|
@@ -3569,7 +4423,7 @@ export class AgentManager {
|
|
|
3569
4423
|
projectId: latest.projectId,
|
|
3570
4424
|
paneId: result.paneId,
|
|
3571
4425
|
updatedAt: new Date().toISOString(),
|
|
3572
|
-
...(latest.
|
|
4426
|
+
...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
|
|
3573
4427
|
status: 'ok',
|
|
3574
4428
|
};
|
|
3575
4429
|
if (shouldReleaseBinding) {
|
|
@@ -3578,7 +4432,7 @@ export class AgentManager {
|
|
|
3578
4432
|
const withBinding = {
|
|
3579
4433
|
...base,
|
|
3580
4434
|
...(latest.taskId !== undefined ? { taskId: latest.taskId } : {}),
|
|
3581
|
-
...(latest.
|
|
4435
|
+
...(latest.lockToken !== undefined ? { lockToken: latest.lockToken } : {}),
|
|
3582
4436
|
...(latest.startedAt !== undefined ? { startedAt: latest.startedAt } : {}),
|
|
3583
4437
|
};
|
|
3584
4438
|
if (!preserveHeld)
|
|
@@ -3591,16 +4445,13 @@ export class AgentManager {
|
|
|
3591
4445
|
...(latest.awaitingSince !== undefined ? { awaitingSince: latest.awaitingSince } : {}),
|
|
3592
4446
|
};
|
|
3593
4447
|
});
|
|
3594
|
-
if (shouldReleaseBinding) {
|
|
3595
|
-
await this.lockManager.release(state.id);
|
|
3596
|
-
}
|
|
3597
4448
|
if (state.taskId && !shouldReleaseBinding && !cancelHold) {
|
|
3598
4449
|
this.startRuntimeMenuWatch(state.id);
|
|
3599
4450
|
}
|
|
3600
4451
|
}
|
|
3601
4452
|
catch (err) {
|
|
3602
4453
|
if (err instanceof EnsureSessionError && err.partial.dialogPending) {
|
|
3603
|
-
if (await this.rollbackUndeliveredBootstrap(state
|
|
4454
|
+
if (await this.rollbackUndeliveredBootstrap(state))
|
|
3604
4455
|
continue;
|
|
3605
4456
|
await this.markDialogPending(state.id, state.creationToken);
|
|
3606
4457
|
void this.slowPollDialogPending(state.id, state.creationToken, {
|
|
@@ -3629,10 +4480,15 @@ export class AgentManager {
|
|
|
3629
4480
|
...latest,
|
|
3630
4481
|
paneId: undefined,
|
|
3631
4482
|
creationToken: undefined,
|
|
4483
|
+
...(latest.taskId ? {
|
|
4484
|
+
status: 'awaiting_human',
|
|
4485
|
+
awaitingPhase: 'recovery-failed',
|
|
4486
|
+
awaitingReason: message,
|
|
4487
|
+
awaitingSince: new Date().toISOString(),
|
|
4488
|
+
} : {}),
|
|
3632
4489
|
updatedAt: new Date().toISOString(),
|
|
3633
4490
|
};
|
|
3634
4491
|
});
|
|
3635
|
-
await this.lockManager.release(state.id);
|
|
3636
4492
|
const cleanup = await this.failTasksForAgent(state.id, `recovery: ${message}`, { deferPartnerCleanup: true });
|
|
3637
4493
|
deferredCleanups.push({
|
|
3638
4494
|
failingAgentId: state.id,
|
|
@@ -3676,23 +4532,32 @@ export class AgentManager {
|
|
|
3676
4532
|
hadBinding = !!existing.taskId;
|
|
3677
4533
|
taskId = existing.taskId;
|
|
3678
4534
|
if (!existing.taskId
|
|
3679
|
-
&& !existing.worktreePath
|
|
3680
4535
|
&& !existing.startedAt
|
|
3681
4536
|
&& !existing.paneId
|
|
3682
4537
|
&& !existing.creationToken) {
|
|
3683
4538
|
return AGENT_STORE_NOOP;
|
|
3684
4539
|
}
|
|
3685
4540
|
changed = true;
|
|
4541
|
+
if (existing.taskId) {
|
|
4542
|
+
return {
|
|
4543
|
+
...existing,
|
|
4544
|
+
paneId: undefined,
|
|
4545
|
+
status: 'awaiting_human',
|
|
4546
|
+
awaitingPhase: 'runtime-missing',
|
|
4547
|
+
awaitingReason: 'The tmux session is missing; restart the REPL before releasing this task binding.',
|
|
4548
|
+
awaitingSince: timestamp,
|
|
4549
|
+
updatedAt: timestamp,
|
|
4550
|
+
};
|
|
4551
|
+
}
|
|
3686
4552
|
return {
|
|
3687
4553
|
id: existing.id,
|
|
3688
4554
|
projectId: existing.projectId,
|
|
3689
|
-
...(existing.
|
|
4555
|
+
...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
3690
4556
|
updatedAt: timestamp,
|
|
3691
4557
|
};
|
|
3692
4558
|
});
|
|
3693
4559
|
if (!projectId || !changed)
|
|
3694
4560
|
return null;
|
|
3695
|
-
await this.lockManager.release(agentId);
|
|
3696
4561
|
return { projectId, timestamp, hadBinding, taskId };
|
|
3697
4562
|
});
|
|
3698
4563
|
if (!reconciled)
|
|
@@ -3747,18 +4612,18 @@ export class AgentManager {
|
|
|
3747
4612
|
|| (task.status === 'approved' && !!task.publishDispatchedAt);
|
|
3748
4613
|
if (task.reviewMode === 'server' && publishedAtGate && task.agentId) {
|
|
3749
4614
|
const afterDone = this.resolveAfterDone(task);
|
|
3750
|
-
if (afterDone
|
|
4615
|
+
if (afterDone === 'pr' && task.branch && task.prNumber !== undefined) {
|
|
3751
4616
|
publishedCleanup = {
|
|
3752
4617
|
afterDone,
|
|
3753
4618
|
branch: task.branch,
|
|
3754
|
-
|
|
4619
|
+
prNumber: task.prNumber,
|
|
3755
4620
|
devAgentId: task.agentId,
|
|
3756
4621
|
mayBeInFlight: task.status === 'approved',
|
|
3757
4622
|
};
|
|
3758
4623
|
}
|
|
3759
4624
|
}
|
|
3760
4625
|
else if (!alreadyTerminal && task.reviewMode !== 'server' && task.prNumber !== undefined && task.branch) {
|
|
3761
|
-
//
|
|
4626
|
+
// GitHub 模式取消时只关闭开放 PR;远程分支归用户所有,baxian 不自动删除。
|
|
3762
4627
|
// server 模式仍只信 gate+marker——非 gate 状态上的孤立 prNumber 不足以证明远端有待回收的工件
|
|
3763
4628
|
publishedCleanup = {
|
|
3764
4629
|
afterDone: 'pr',
|
|
@@ -3833,40 +4698,18 @@ export class AgentManager {
|
|
|
3833
4698
|
phase: 'cancel-published-artifact-cleanup-skipped',
|
|
3834
4699
|
afterDone: publishedCleanup.afterDone,
|
|
3835
4700
|
branch: publishedCleanup.branch,
|
|
3836
|
-
|
|
4701
|
+
prNumber: publishedCleanup.prNumber,
|
|
3837
4702
|
reason: 'dev pane stop unconfirmed; the publish prompt may still be running and would recreate the remote artifacts',
|
|
3838
4703
|
});
|
|
3839
4704
|
return result;
|
|
3840
4705
|
}
|
|
3841
4706
|
const project = this.getProjectConfig(result.projectId);
|
|
3842
4707
|
try {
|
|
3843
|
-
if (
|
|
4708
|
+
if (project) {
|
|
3844
4709
|
const close = await execNetwork(this.platformRunner, `gh pr close ${publishedCleanup.prNumber} --repo ${shellQuote(repoSlug(project.repo))} ` +
|
|
3845
4710
|
`--comment ${shellQuote('Task cancelled in baxian; closing the published PR.')}`, GH_NET);
|
|
3846
4711
|
if (close.exitCode !== 0)
|
|
3847
4712
|
throw new Error(close.stderr.trim() || close.stdout.trim());
|
|
3848
|
-
// Branch deletion is a separate idempotent step, not `--delete-branch`:
|
|
3849
|
-
// a retried close that finds the PR already closed exits 0 before gh's
|
|
3850
|
-
// branch-deletion block, which would report success while leaking the
|
|
3851
|
-
// published branch.
|
|
3852
|
-
const refPath = `repos/${repoSlug(project.repo)}/git/refs/heads/` +
|
|
3853
|
-
encodeURIComponent(publishedCleanup.branch).replace(/%2F/gi, '/');
|
|
3854
|
-
const del = await execNetwork(this.platformRunner, `gh api -X DELETE ${shellQuote(refPath)}`, GH_NET);
|
|
3855
|
-
if (del.exitCode !== 0 && !del.stderr.includes('Reference does not exist')) {
|
|
3856
|
-
throw new Error(del.stderr.trim() || del.stdout.trim());
|
|
3857
|
-
}
|
|
3858
|
-
}
|
|
3859
|
-
else if (publishedCleanup.devAgentId) {
|
|
3860
|
-
const dev = this.getAgentConfig(publishedCleanup.devAgentId);
|
|
3861
|
-
const state = await this.agentStore.get(publishedCleanup.devAgentId);
|
|
3862
|
-
if (dev && state?.repoPath) {
|
|
3863
|
-
const del = await execNetwork(this.createRunnerFor(dev), `cd ${shellQuote(state.repoPath)} && ${GIT_NET_ENV} git push origin --delete ${shellQuote(publishedCleanup.branch)}`);
|
|
3864
|
-
// The goal is "branch absent": a retried delete whose first round
|
|
3865
|
-
// landed reports a missing remote ref, which is success, not failure.
|
|
3866
|
-
if (del.exitCode !== 0 && !del.stderr.includes('remote ref does not exist')) {
|
|
3867
|
-
throw new Error(del.stderr.trim() || del.stdout.trim());
|
|
3868
|
-
}
|
|
3869
|
-
}
|
|
3870
4713
|
}
|
|
3871
4714
|
}
|
|
3872
4715
|
catch (err) {
|
|
@@ -3875,7 +4718,7 @@ export class AgentManager {
|
|
|
3875
4718
|
phase: 'cancel-published-artifact-cleanup-failed',
|
|
3876
4719
|
afterDone: publishedCleanup.afterDone,
|
|
3877
4720
|
branch: publishedCleanup.branch,
|
|
3878
|
-
|
|
4721
|
+
prNumber: publishedCleanup.prNumber,
|
|
3879
4722
|
error: err instanceof Error ? err.message : String(err),
|
|
3880
4723
|
});
|
|
3881
4724
|
}
|
|
@@ -3891,8 +4734,8 @@ export class AgentManager {
|
|
|
3891
4734
|
if (cfg.projectId !== projectId) {
|
|
3892
4735
|
throw new ApiError(400, `Agent ${input.preferredAgentId} not in project ${projectId}`);
|
|
3893
4736
|
}
|
|
3894
|
-
if (cfg.role
|
|
3895
|
-
throw new ApiError(400, `Agent ${input.preferredAgentId} is not dev role`);
|
|
4737
|
+
if (!TASK_OWNER_ROLES.has(cfg.role)) {
|
|
4738
|
+
throw new ApiError(400, `Agent ${input.preferredAgentId} is not dev or research role`);
|
|
3896
4739
|
}
|
|
3897
4740
|
}
|
|
3898
4741
|
let previewBytes;
|
|
@@ -3927,24 +4770,23 @@ export class AgentManager {
|
|
|
3927
4770
|
if (opts.expectSignalToken !== undefined && task.signalToken !== opts.expectSignalToken) {
|
|
3928
4771
|
throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (signalToken rotated); aborting`);
|
|
3929
4772
|
}
|
|
3930
|
-
if (task.reviewMode === 'server' || task.phase
|
|
4773
|
+
if (task.reviewMode === 'server' || isSpecStagePhase(task.phase)) {
|
|
3931
4774
|
if (!MANUAL_SERVER_REVIEW_STATUSES.includes(task.status)) {
|
|
3932
4775
|
throw new ApiError(409, `Task ${taskId} status is ${task.status}; manual server-side review requires ${MANUAL_SERVER_REVIEW_STATUSES.join('/')}`);
|
|
3933
4776
|
}
|
|
3934
4777
|
if (!this.serverReviewDriver) {
|
|
3935
4778
|
throw new ApiError(409, `Server review pipeline is not configured; cannot dispatch review for ${taskId}`);
|
|
3936
4779
|
}
|
|
3937
|
-
// in_progress 且 phase 未定:dev 既可能交 spec-done 也可能交 code-done,评审对象无从判定
|
|
3938
4780
|
if (task.status === 'in_progress' && task.phase === undefined) {
|
|
3939
4781
|
throw new ApiError(409, `Task ${taskId} has no phase yet (the dev has not delivered spec-done/code-done); wait for the dev signal or use Cancel/Retry`);
|
|
3940
4782
|
}
|
|
3941
4783
|
// 手工发起=明确要求跑一次 QA 评审;无 QA 时必须拒绝,不得落入自动通过/停驻兜底
|
|
3942
|
-
const qaAvailable =
|
|
3943
|
-
|
|
4784
|
+
const qaAvailable = task.qaAgentId !== undefined
|
|
4785
|
+
&& this.getAgentConfig(task.qaAgentId)?.role === 'qa';
|
|
3944
4786
|
if (!qaAvailable) {
|
|
3945
4787
|
throw new ApiError(400, `Task ${taskId} has no QA partner configured; manual review requires a QA agent`);
|
|
3946
4788
|
}
|
|
3947
|
-
const isSpec = task.phase
|
|
4789
|
+
const isSpec = isSpecStagePhase(task.phase);
|
|
3948
4790
|
const cap = this.config.review.rounds + (task.maxRoundsContinues ?? 0);
|
|
3949
4791
|
const round = isSpec ? (task.specReviewRound ?? 0) : task.reviewRound;
|
|
3950
4792
|
if (round + 1 > cap) {
|
|
@@ -3959,18 +4801,12 @@ export class AgentManager {
|
|
|
3959
4801
|
if (!task.branch) {
|
|
3960
4802
|
throw new ApiError(400, `Task ${taskId} has no branch; cannot dispatch review`);
|
|
3961
4803
|
}
|
|
3962
|
-
|
|
3963
|
-
if (qaId && !this.getAgentConfig(qaId)) {
|
|
3964
|
-
console.warn(`[dispatchReviewToQa] task ${taskId}.qaAgentId="${qaId}" no longer in config; ` +
|
|
3965
|
-
`falling back to findQaPartner(${task.agentId})`);
|
|
3966
|
-
qaId = undefined;
|
|
3967
|
-
}
|
|
4804
|
+
const qaId = task.qaAgentId;
|
|
3968
4805
|
if (!qaId) {
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
}
|
|
3973
|
-
qaId = qa.id;
|
|
4806
|
+
throw new ApiError(400, `Task ${taskId} has no QA participant; cannot dispatch review`);
|
|
4807
|
+
}
|
|
4808
|
+
if (this.getAgentConfig(qaId)?.role !== 'qa') {
|
|
4809
|
+
throw new ApiError(409, `Task ${taskId} QA participant ${qaId} is unavailable`);
|
|
3974
4810
|
}
|
|
3975
4811
|
this.manualReviewInFlight.add(taskId);
|
|
3976
4812
|
return { mode: 'github', qaId, devAgentId: task.agentId, taskStatusAtClaim: task.status };
|
|
@@ -4056,7 +4892,6 @@ export class AgentManager {
|
|
|
4056
4892
|
await this.taskStore.set({
|
|
4057
4893
|
...fresh,
|
|
4058
4894
|
reviewRound: bumpRound ? fresh.reviewRound + 1 : fresh.reviewRound,
|
|
4059
|
-
qaAgentId: qaId,
|
|
4060
4895
|
signalToken: armedToken,
|
|
4061
4896
|
updatedAt: new Date().toISOString(),
|
|
4062
4897
|
});
|
|
@@ -4139,7 +4974,7 @@ export class AgentManager {
|
|
|
4139
4974
|
if (task.status !== 'max_rounds') {
|
|
4140
4975
|
throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
|
|
4141
4976
|
}
|
|
4142
|
-
if (task.phase
|
|
4977
|
+
if (isSpecStagePhase(task.phase)) {
|
|
4143
4978
|
throw new ApiError(409, `Continue one round is only supported for code-phase tasks`);
|
|
4144
4979
|
}
|
|
4145
4980
|
if (task.reviewMode === 'server') {
|
|
@@ -4191,8 +5026,8 @@ export class AgentManager {
|
|
|
4191
5026
|
}
|
|
4192
5027
|
const devAgentId = task.agentId;
|
|
4193
5028
|
const devState = await this.agentStore.get(devAgentId);
|
|
4194
|
-
if (devState?.taskId !== taskId || !devState.
|
|
4195
|
-
throw new ApiError(409, `Dev ${devAgentId} no longer holds task ${taskId}'s
|
|
5029
|
+
if (devState?.taskId !== taskId || !devState.workdir) {
|
|
5030
|
+
throw new ApiError(409, `Dev ${devAgentId} no longer holds task ${taskId}'s Workdir (cannot continue); ` +
|
|
4196
5031
|
`use mark-complete to merge the PR as-is, or cancel the task`);
|
|
4197
5032
|
}
|
|
4198
5033
|
const prevReviewRound = task.reviewRound;
|
|
@@ -4335,31 +5170,40 @@ export class AgentManager {
|
|
|
4335
5170
|
? ['spec-done', 'code-done']
|
|
4336
5171
|
: ['spec-done', 'pr-created'];
|
|
4337
5172
|
}
|
|
5173
|
+
resolveInitialDispatch(task) {
|
|
5174
|
+
if (task.phase === 'research')
|
|
5175
|
+
return { phase: 'research', kinds: ['spec-done'] };
|
|
5176
|
+
return { phase: 'develop', kinds: this.devInitialSignalKinds(task.reviewMode) };
|
|
5177
|
+
}
|
|
4338
5178
|
mapTaskStateToExpectedWatcher(task) {
|
|
4339
5179
|
if (task.reviewMode === 'server')
|
|
4340
5180
|
return this.mapServerTaskToExpectedWatcher(task);
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
if (task.phase !== 'spec' && task.status === 'fixing' && task.agentId) {
|
|
4348
|
-
return { expectedKinds: ['pr-fixed'], agentId: task.agentId };
|
|
4349
|
-
}
|
|
4350
|
-
if (task.phase === undefined && task.status === 'in_progress' && task.agentId) {
|
|
4351
|
-
return { expectedKinds: ['spec-done', 'pr-created'], agentId: task.agentId };
|
|
5181
|
+
const specStage = isSpecStagePhase(task.phase);
|
|
5182
|
+
if (task.status === 'review' && task.qaAgentId) {
|
|
5183
|
+
return {
|
|
5184
|
+
expectedKinds: specStage ? ['spec-reviewed'] : ['pr-approved', 'pr-changes-requested'],
|
|
5185
|
+
agentId: task.qaAgentId,
|
|
5186
|
+
};
|
|
4352
5187
|
}
|
|
4353
|
-
if (task.
|
|
4354
|
-
return {
|
|
5188
|
+
if (task.status === 'fixing' && task.agentId) {
|
|
5189
|
+
return {
|
|
5190
|
+
expectedKinds: [specStage ? 'spec-fixed' : 'pr-fixed'],
|
|
5191
|
+
agentId: task.agentId,
|
|
5192
|
+
};
|
|
4355
5193
|
}
|
|
4356
|
-
if (task.
|
|
4357
|
-
|
|
5194
|
+
if (task.status === 'in_progress' && task.agentId) {
|
|
5195
|
+
if (task.phase === 'research')
|
|
5196
|
+
return { expectedKinds: ['spec-done'], agentId: task.agentId };
|
|
5197
|
+
if (task.phase === 'code')
|
|
5198
|
+
return { expectedKinds: ['pr-created'], agentId: task.agentId };
|
|
5199
|
+
if (task.phase === undefined) {
|
|
5200
|
+
return { expectedKinds: ['spec-done', 'pr-created'], agentId: task.agentId };
|
|
5201
|
+
}
|
|
4358
5202
|
}
|
|
4359
5203
|
return undefined;
|
|
4360
5204
|
}
|
|
4361
5205
|
mapServerTaskToExpectedWatcher(task) {
|
|
4362
|
-
const isSpec = task.phase
|
|
5206
|
+
const isSpec = isSpecStagePhase(task.phase);
|
|
4363
5207
|
if (task.status === 'review' && task.qaAgentId) {
|
|
4364
5208
|
return { expectedKinds: [isSpec ? 'spec-reviewed' : 'code-reviewed'], agentId: task.qaAgentId };
|
|
4365
5209
|
}
|
|
@@ -4367,8 +5211,11 @@ export class AgentManager {
|
|
|
4367
5211
|
return { expectedKinds: [isSpec ? 'spec-fixed' : 'code-fixed'], agentId: task.agentId };
|
|
4368
5212
|
}
|
|
4369
5213
|
if (task.status === 'in_progress' && task.agentId) {
|
|
4370
|
-
if (task.phase === '
|
|
5214
|
+
if (task.phase === 'research')
|
|
5215
|
+
return { expectedKinds: ['spec-done'], agentId: task.agentId };
|
|
5216
|
+
if (task.phase === 'code') {
|
|
4371
5217
|
return { expectedKinds: ['code-done'], agentId: task.agentId };
|
|
5218
|
+
}
|
|
4372
5219
|
return { expectedKinds: ['spec-done', 'code-done'], agentId: task.agentId };
|
|
4373
5220
|
}
|
|
4374
5221
|
if (task.status === 'approved' && task.agentId) {
|
|
@@ -4400,7 +5247,7 @@ export class AgentManager {
|
|
|
4400
5247
|
if (!t)
|
|
4401
5248
|
throw new ApiError(404, 'Task not found');
|
|
4402
5249
|
const retryable = TERMINAL_STATUSES.includes(t.status)
|
|
4403
|
-
|| (t.status === 'max_rounds' && t.phase
|
|
5250
|
+
|| (t.status === 'max_rounds' && isSpecStagePhase(t.phase));
|
|
4404
5251
|
if (!retryable) {
|
|
4405
5252
|
throw new ApiError(409, `Task ${taskId} cannot be retried in status "${t.status}"; cancel it first or wait for completion`);
|
|
4406
5253
|
}
|
|
@@ -4435,7 +5282,11 @@ export class AgentManager {
|
|
|
4435
5282
|
if (patch.preferredAgentId !== undefined && patch.preferredAgentId !== task.preferredAgentId) {
|
|
4436
5283
|
if (patch.preferredAgentId === '') {
|
|
4437
5284
|
task.preferredAgentId = '';
|
|
5285
|
+
task.agentId = '';
|
|
5286
|
+
task.devAgentId = '';
|
|
5287
|
+
delete task.phase;
|
|
4438
5288
|
delete task.qaAgentId;
|
|
5289
|
+
delete task.researchAgentId;
|
|
4439
5290
|
}
|
|
4440
5291
|
else {
|
|
4441
5292
|
const cfg = this.getAgentConfig(patch.preferredAgentId);
|
|
@@ -4444,10 +5295,23 @@ export class AgentManager {
|
|
|
4444
5295
|
if (cfg.projectId !== task.projectId) {
|
|
4445
5296
|
throw new ApiError(400, `Agent not in project ${task.projectId}`);
|
|
4446
5297
|
}
|
|
4447
|
-
if (cfg.role
|
|
4448
|
-
throw new ApiError(400, `Agent is not dev role`);
|
|
5298
|
+
if (!TASK_OWNER_ROLES.has(cfg.role))
|
|
5299
|
+
throw new ApiError(400, `Agent is not dev or research role`);
|
|
5300
|
+
const group = this.findAgentGroup(cfg.id);
|
|
5301
|
+
const dev = group?.find(agent => agent.role === 'dev');
|
|
5302
|
+
if (!dev)
|
|
5303
|
+
throw new ApiError(409, `Agent ${cfg.id} has no dev agent in its group`);
|
|
4449
5304
|
task.preferredAgentId = patch.preferredAgentId;
|
|
4450
|
-
|
|
5305
|
+
task.devAgentId = dev.id;
|
|
5306
|
+
if (cfg.role === 'research') {
|
|
5307
|
+
task.phase = 'research';
|
|
5308
|
+
task.researchAgentId = cfg.id;
|
|
5309
|
+
}
|
|
5310
|
+
else {
|
|
5311
|
+
delete task.phase;
|
|
5312
|
+
delete task.researchAgentId;
|
|
5313
|
+
}
|
|
5314
|
+
const qaId = group?.find(agent => agent.role === 'qa')?.id;
|
|
4451
5315
|
if (qaId) {
|
|
4452
5316
|
task.qaAgentId = qaId;
|
|
4453
5317
|
}
|
|
@@ -4475,7 +5339,7 @@ export class AgentManager {
|
|
|
4475
5339
|
: '';
|
|
4476
5340
|
// A merge is not idempotent from the caller's view (user-triggered, retryable
|
|
4477
5341
|
// in the UI), so it gets a timeout but no automatic retry.
|
|
4478
|
-
const result = await execNetwork(this.platformRunner, `gh pr merge ${task.prNumber} --repo ${shellQuote(repoSlug(project.repo))}${matchHead} --squash
|
|
5342
|
+
const result = await execNetwork(this.platformRunner, `gh pr merge ${task.prNumber} --repo ${shellQuote(repoSlug(project.repo))}${matchHead} --squash`, { retries: 0 });
|
|
4479
5343
|
if (result.exitCode !== 0) {
|
|
4480
5344
|
throw new Error(`gh pr merge failed for PR #${task.prNumber}: ${result.stderr || result.stdout}`);
|
|
4481
5345
|
}
|
|
@@ -4493,7 +5357,7 @@ export class AgentManager {
|
|
|
4493
5357
|
if (!serverApprovedRetry && task.status !== 'max_rounds') {
|
|
4494
5358
|
throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
|
|
4495
5359
|
}
|
|
4496
|
-
if (task.phase
|
|
5360
|
+
if (isSpecStagePhase(task.phase)) {
|
|
4497
5361
|
throw new ApiError(409, `Mark complete is only supported for code-phase tasks`);
|
|
4498
5362
|
}
|
|
4499
5363
|
if (task.reviewMode !== 'server' && (!task.prNumber || !task.branch)) {
|
|
@@ -4608,6 +5472,9 @@ export class AgentManager {
|
|
|
4608
5472
|
return;
|
|
4609
5473
|
}
|
|
4610
5474
|
if (!state.taskId) {
|
|
5475
|
+
const lockToken = await this.lockManager.acquire(agentId, ctx.taskId);
|
|
5476
|
+
if (!lockToken)
|
|
5477
|
+
return;
|
|
4611
5478
|
await this.agentStore.update(agentId, (existing) => {
|
|
4612
5479
|
if (!existing)
|
|
4613
5480
|
return AGENT_STORE_NOOP;
|
|
@@ -4619,21 +5486,20 @@ export class AgentManager {
|
|
|
4619
5486
|
return AGENT_STORE_NOOP;
|
|
4620
5487
|
if (existing.paneId !== state.paneId)
|
|
4621
5488
|
return AGENT_STORE_NOOP;
|
|
4622
|
-
return {
|
|
5489
|
+
return {
|
|
5490
|
+
...existing,
|
|
5491
|
+
taskId: ctx.taskId,
|
|
5492
|
+
lockToken,
|
|
5493
|
+
updatedAt: new Date().toISOString(),
|
|
5494
|
+
};
|
|
4623
5495
|
});
|
|
4624
5496
|
const fresh = await this.agentStore.get(agentId);
|
|
4625
|
-
if (fresh?.taskId !== ctx.taskId)
|
|
5497
|
+
if (fresh?.taskId !== ctx.taskId || fresh.lockToken !== lockToken) {
|
|
5498
|
+
await this.lockManager.releaseIfOwner(agentId, ctx.taskId, lockToken);
|
|
4626
5499
|
return;
|
|
5500
|
+
}
|
|
4627
5501
|
}
|
|
4628
|
-
await this.removeMergedWorktree(agent, agentId, ctx.taskId);
|
|
4629
5502
|
const runner = this.createRunnerFor(agent);
|
|
4630
|
-
if (state.repoPath) {
|
|
4631
|
-
await this.deleteLocalBranchInRepo(runner, state.repoPath, ctx.branch, agentId);
|
|
4632
|
-
}
|
|
4633
|
-
else {
|
|
4634
|
-
console.warn(`[AgentManager] dispatchPostMergeCleanup(${agentId}): no repoPath on binding; skipping local ` +
|
|
4635
|
-
`branch delete for ${ctx.branch} (it may linger locally)`);
|
|
4636
|
-
}
|
|
4637
5503
|
const tmux = new TmuxManager(runner);
|
|
4638
5504
|
const runtime = agentRuntimeKindFor(agent);
|
|
4639
5505
|
void this.runPostMergeCompaction(tmux, state.paneId, agentId, ctx.taskId, runtime).catch(err => console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) failed:`, err));
|
|
@@ -4649,56 +5515,6 @@ export class AgentManager {
|
|
|
4649
5515
|
console.warn(`[AgentManager] releasePostMergeAgent: releaseAgentForTask(${agentId}, ${taskId}) failed:`, err);
|
|
4650
5516
|
}
|
|
4651
5517
|
}
|
|
4652
|
-
async removeMergedWorktree(cfg, agentId, expectedTaskId) {
|
|
4653
|
-
await this.withTaskLock(async () => {
|
|
4654
|
-
const state = await this.agentStore.get(agentId);
|
|
4655
|
-
if (!state || state.taskId !== expectedTaskId || !state.worktreePath)
|
|
4656
|
-
return;
|
|
4657
|
-
const cleanupDir = this.resolveWorkdir(cfg, state);
|
|
4658
|
-
if (cleanupDir) {
|
|
4659
|
-
const runner = this.createRunnerFor(cfg);
|
|
4660
|
-
const worktree = new WorktreeManager(runner);
|
|
4661
|
-
try {
|
|
4662
|
-
await worktree.remove(cleanupDir, state.worktreePath);
|
|
4663
|
-
}
|
|
4664
|
-
catch (err) {
|
|
4665
|
-
console.warn(`[AgentManager] removeMergedWorktree worktree.remove failed for ${state.worktreePath}:`, err);
|
|
4666
|
-
}
|
|
4667
|
-
}
|
|
4668
|
-
const now = new Date().toISOString();
|
|
4669
|
-
await this.agentStore.update(agentId, (existing) => {
|
|
4670
|
-
if (!existing || existing.taskId !== expectedTaskId)
|
|
4671
|
-
return AGENT_STORE_NOOP;
|
|
4672
|
-
const next = { ...existing, updatedAt: now };
|
|
4673
|
-
delete next.worktreePath;
|
|
4674
|
-
return next;
|
|
4675
|
-
});
|
|
4676
|
-
});
|
|
4677
|
-
}
|
|
4678
|
-
async deleteLocalBranchInRepo(runner, repoPath, branch, agentId) {
|
|
4679
|
-
const fetchCmd = `cd ${shellQuote(repoPath)} && git fetch --prune origin && git worktree prune --expire=now`;
|
|
4680
|
-
try {
|
|
4681
|
-
const fetchResult = await runner.exec(fetchCmd, { timeout: this.postMergeFetchTimeoutMs });
|
|
4682
|
-
if (fetchResult.exitCode !== 0) {
|
|
4683
|
-
console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}): fetch/prune exit=${fetchResult.exitCode} ` +
|
|
4684
|
-
`stderr=${fetchResult.stderr.trim()}`);
|
|
4685
|
-
}
|
|
4686
|
-
}
|
|
4687
|
-
catch (err) {
|
|
4688
|
-
console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}) fetch/prune threw:`, err);
|
|
4689
|
-
}
|
|
4690
|
-
const delCmd = `cd ${shellQuote(repoPath)} && git branch -D ${shellQuote(branch)}`;
|
|
4691
|
-
try {
|
|
4692
|
-
const delResult = await runner.exec(delCmd, { timeout: this.postMergeBranchTimeoutMs });
|
|
4693
|
-
if (delResult.exitCode !== 0 && !/not found|not a valid|no such branch/i.test(delResult.stderr)) {
|
|
4694
|
-
console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}): branch -D exit=${delResult.exitCode} ` +
|
|
4695
|
-
`stderr=${delResult.stderr.trim()}`);
|
|
4696
|
-
}
|
|
4697
|
-
}
|
|
4698
|
-
catch (err) {
|
|
4699
|
-
console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}) branch -D threw:`, err);
|
|
4700
|
-
}
|
|
4701
|
-
}
|
|
4702
5518
|
async runPostMergeCompaction(tmux, paneId, agentId, originalTaskId, runtime) {
|
|
4703
5519
|
await this.acquireCompactGuard(agentId);
|
|
4704
5520
|
try {
|
|
@@ -4729,33 +5545,59 @@ export class AgentManager {
|
|
|
4729
5545
|
return true;
|
|
4730
5546
|
}
|
|
4731
5547
|
async runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime) {
|
|
5548
|
+
const initial = await this.agentStore.get(agentId);
|
|
5549
|
+
if (!initial || initial.taskId !== originalTaskId || initial.paneId !== paneId)
|
|
5550
|
+
return;
|
|
5551
|
+
const lockToken = await this.resolveTaskLockToken(initial, originalTaskId);
|
|
5552
|
+
if (!lockToken)
|
|
5553
|
+
return;
|
|
4732
5554
|
const bindingStillOurs = async () => {
|
|
4733
5555
|
const s = await this.agentStore.get(agentId);
|
|
4734
|
-
return !!s
|
|
5556
|
+
return !!s
|
|
5557
|
+
&& s.taskId === originalTaskId
|
|
5558
|
+
&& s.lockToken === lockToken
|
|
5559
|
+
&& s.paneId === paneId
|
|
5560
|
+
&& await this.lockManager.isOwner(agentId, originalTaskId, lockToken);
|
|
4735
5561
|
};
|
|
4736
5562
|
if (!await bindingStillOurs())
|
|
4737
5563
|
return;
|
|
4738
5564
|
let cleared = false;
|
|
5565
|
+
let clearError;
|
|
4739
5566
|
try {
|
|
4740
5567
|
cleared = await this.sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, bindingStillOurs);
|
|
4741
5568
|
}
|
|
4742
5569
|
catch (err) {
|
|
4743
|
-
|
|
5570
|
+
clearError = err;
|
|
4744
5571
|
}
|
|
4745
5572
|
if (!cleared) {
|
|
4746
|
-
if (await this.recoverPostMergeExitedRuntime(tmux, paneId, agentId, originalTaskId, runtime)) {
|
|
5573
|
+
if (await this.recoverPostMergeExitedRuntime(tmux, paneId, agentId, originalTaskId, lockToken, runtime)) {
|
|
4747
5574
|
cleared = true;
|
|
4748
5575
|
}
|
|
4749
5576
|
else if (!await bindingStillOurs()) {
|
|
4750
5577
|
return;
|
|
4751
5578
|
}
|
|
4752
|
-
else if (
|
|
5579
|
+
else if (clearError instanceof Error
|
|
5580
|
+
&& clearError.message.includes('runtime rejected /clear')) {
|
|
5581
|
+
try {
|
|
5582
|
+
await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
|
|
5583
|
+
}
|
|
5584
|
+
catch (err) {
|
|
5585
|
+
clearError = err;
|
|
5586
|
+
}
|
|
5587
|
+
if (clearError instanceof Error && !clearError.message.includes('runtime rejected /clear')) {
|
|
5588
|
+
await this.markAwaitingHuman(agentId, 'post-merge-cleanup-not-idle', `Task finished, but the runtime could not be proven idle after /clear: ${clearError.message}`, { expectedTaskId: originalTaskId });
|
|
5589
|
+
return;
|
|
5590
|
+
}
|
|
5591
|
+
}
|
|
5592
|
+
else {
|
|
5593
|
+
const reason = clearError instanceof Error ? clearError.message : String(clearError ?? 'unknown error');
|
|
5594
|
+
await this.markAwaitingHuman(agentId, 'post-merge-cleanup-not-idle', `Task finished, but /clear and the runtime idle check did not complete safely: ${reason}`, { expectedTaskId: originalTaskId });
|
|
4753
5595
|
return;
|
|
4754
5596
|
}
|
|
4755
5597
|
}
|
|
4756
5598
|
await this.releasePostMergeAgent(agentId, originalTaskId);
|
|
4757
5599
|
}
|
|
4758
|
-
async recoverPostMergeExitedRuntime(tmux, paneId, agentId, taskId, runtime) {
|
|
5600
|
+
async recoverPostMergeExitedRuntime(tmux, paneId, agentId, taskId, lockToken, runtime) {
|
|
4759
5601
|
let paneExists = true;
|
|
4760
5602
|
try {
|
|
4761
5603
|
if (hasReplProcTitle(await tmux.displayMessage(paneId, '#{pane_current_command}'), runtime))
|
|
@@ -4769,7 +5611,11 @@ export class AgentManager {
|
|
|
4769
5611
|
paneExists = false;
|
|
4770
5612
|
}
|
|
4771
5613
|
const state = await this.agentStore.get(agentId);
|
|
4772
|
-
if (!state
|
|
5614
|
+
if (!state
|
|
5615
|
+
|| state.taskId !== taskId
|
|
5616
|
+
|| state.lockToken !== lockToken
|
|
5617
|
+
|| state.paneId !== paneId
|
|
5618
|
+
|| !(await this.lockManager.isOwner(agentId, taskId, lockToken)))
|
|
4773
5619
|
return false;
|
|
4774
5620
|
try {
|
|
4775
5621
|
if (paneExists)
|
|
@@ -4778,20 +5624,24 @@ export class AgentManager {
|
|
|
4778
5624
|
if (!ensure.freshRuntime)
|
|
4779
5625
|
return false;
|
|
4780
5626
|
await this.agentStore.update(agentId, (existing) => {
|
|
4781
|
-
if (!existing || existing.taskId !== taskId)
|
|
5627
|
+
if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken) {
|
|
4782
5628
|
return AGENT_STORE_NOOP;
|
|
4783
|
-
|
|
5629
|
+
}
|
|
5630
|
+
if (existing.paneId === ensure.paneId && existing.workdir === ensure.workdir) {
|
|
4784
5631
|
return AGENT_STORE_NOOP;
|
|
4785
5632
|
}
|
|
4786
5633
|
return {
|
|
4787
5634
|
...existing,
|
|
4788
5635
|
paneId: ensure.paneId,
|
|
4789
|
-
|
|
5636
|
+
workdir: ensure.workdir,
|
|
4790
5637
|
updatedAt: new Date().toISOString(),
|
|
4791
5638
|
};
|
|
4792
5639
|
});
|
|
4793
5640
|
const current = await this.agentStore.get(agentId);
|
|
4794
|
-
return current?.taskId === taskId
|
|
5641
|
+
return current?.taskId === taskId
|
|
5642
|
+
&& current.lockToken === lockToken
|
|
5643
|
+
&& current.paneId === ensure.paneId
|
|
5644
|
+
&& await this.lockManager.isOwner(agentId, taskId, lockToken);
|
|
4795
5645
|
}
|
|
4796
5646
|
catch (err) {
|
|
4797
5647
|
console.warn(`[AgentManager] recoverPostMergeExitedRuntime(${agentId}, ${taskId}) failed:`, err);
|
|
@@ -4927,7 +5777,6 @@ export class AgentManager {
|
|
|
4927
5777
|
signalToken: restore.signalToken,
|
|
4928
5778
|
reviewHeadAnchorSha: restore.reviewHeadAnchorSha,
|
|
4929
5779
|
reviewDispatchedAt: restore.reviewDispatchedAt,
|
|
4930
|
-
qaAgentId: undefined,
|
|
4931
5780
|
updatedAt: new Date().toISOString(),
|
|
4932
5781
|
});
|
|
4933
5782
|
return true;
|
|
@@ -4955,11 +5804,8 @@ export class AgentManager {
|
|
|
4955
5804
|
const task = await this.taskStore.get(taskId);
|
|
4956
5805
|
if (!task)
|
|
4957
5806
|
return null;
|
|
4958
|
-
const transition = await this.transitionTaskStatus(taskId, 'spec-ready', { fromStatus: ['review', 'in_progress', 'fixing'] },
|
|
4959
|
-
// QA 在停驻期解绑,残留 qaAgentId 会让后续 approve/打回误发释放;复审经 findQaPartner 回绑
|
|
4960
|
-
{
|
|
5807
|
+
const transition = await this.transitionTaskStatus(taskId, 'spec-ready', { fromStatus: ['review', 'in_progress', 'fixing'] }, {
|
|
4961
5808
|
phase: 'spec',
|
|
4962
|
-
qaAgentId: undefined,
|
|
4963
5809
|
...(opts.specReviewRound !== undefined ? { specReviewRound: opts.specReviewRound } : {}),
|
|
4964
5810
|
});
|
|
4965
5811
|
if (!transition)
|
|
@@ -4992,6 +5838,9 @@ export class AgentManager {
|
|
|
4992
5838
|
if (fresh.status !== 'spec-ready') {
|
|
4993
5839
|
throw new ApiError(409, `Task ${taskId} is ${fresh.status}; spec verdict requires spec-ready`);
|
|
4994
5840
|
}
|
|
5841
|
+
if (verdict === 'archive' && !fresh.researchAgentId) {
|
|
5842
|
+
throw new ApiError(409, `Task ${taskId} is not a Research task; only Research specs can be archived`);
|
|
5843
|
+
}
|
|
4995
5844
|
if (this.specVerdictInFlight.has(taskId)) {
|
|
4996
5845
|
throw new ApiError(409, `Task ${taskId} spec verdict is already being processed`);
|
|
4997
5846
|
}
|
|
@@ -5013,13 +5862,45 @@ export class AgentManager {
|
|
|
5013
5862
|
const round = task.specReviewRound ?? 1;
|
|
5014
5863
|
const at = new Date().toISOString();
|
|
5015
5864
|
const roundData = await store.getRound(taskId, 'spec', round);
|
|
5016
|
-
|
|
5017
|
-
|
|
5865
|
+
if (!roundData || roundData.phase !== 'spec') {
|
|
5866
|
+
throw new ApiError(409, `Task ${taskId} has no persisted spec review round ${round}`);
|
|
5867
|
+
}
|
|
5868
|
+
const base = roundData;
|
|
5869
|
+
if (verdict === 'archive') {
|
|
5870
|
+
await store.putRound(taskId, 'spec', {
|
|
5871
|
+
...base,
|
|
5872
|
+
userDecision: { verdict, ...(trimmed ? { comments: trimmed } : {}), at },
|
|
5873
|
+
});
|
|
5874
|
+
const transition = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['spec-ready'] });
|
|
5875
|
+
if (!transition)
|
|
5876
|
+
throw new ApiError(409, `Task ${taskId} changed while archiving`);
|
|
5877
|
+
this.stopPhaseSignalWatcher(taskId);
|
|
5878
|
+
const participants = new Set([
|
|
5879
|
+
task.agentId,
|
|
5880
|
+
task.devAgentId,
|
|
5881
|
+
task.qaAgentId,
|
|
5882
|
+
task.researchAgentId,
|
|
5883
|
+
].filter((id) => typeof id === 'string' && id !== ''));
|
|
5884
|
+
for (const agentId of participants) {
|
|
5885
|
+
const state = await this.agentStore.get(agentId);
|
|
5886
|
+
if (state?.taskId !== taskId)
|
|
5887
|
+
continue;
|
|
5888
|
+
const released = await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true }).catch(() => false);
|
|
5889
|
+
if (!released) {
|
|
5890
|
+
await this.emitIntervention(task.projectId, agentId, taskId, {
|
|
5891
|
+
phase: 'spec-archive-agent-release-failed',
|
|
5892
|
+
agentId,
|
|
5893
|
+
});
|
|
5894
|
+
}
|
|
5895
|
+
}
|
|
5896
|
+
return (await this.taskStore.get(taskId));
|
|
5897
|
+
}
|
|
5018
5898
|
if (verdict === 'approve') {
|
|
5019
5899
|
await store.putRound(taskId, 'spec', { ...base, userDecision: { verdict, at } });
|
|
5020
5900
|
const result = await this.transitionToCodePhase(taskId);
|
|
5021
|
-
if (!result)
|
|
5022
|
-
throw new ApiError(
|
|
5901
|
+
if (!result) {
|
|
5902
|
+
throw new ApiError(409, `Dev is unavailable for task ${taskId}; approval was recorded and can be retried`);
|
|
5903
|
+
}
|
|
5023
5904
|
return result;
|
|
5024
5905
|
}
|
|
5025
5906
|
// 打回消耗一轮修订;到达上限时拒绝而非派发注定进 max_rounds 的修订,让用户当场决策
|
|
@@ -5059,7 +5940,7 @@ export class AgentManager {
|
|
|
5059
5940
|
if (fresh.status !== 'ready') {
|
|
5060
5941
|
throw new ApiError(409, `Task ${taskId} is ${fresh.status}; code verdict requires ready`);
|
|
5061
5942
|
}
|
|
5062
|
-
if (fresh.phase
|
|
5943
|
+
if (isSpecStagePhase(fresh.phase)) {
|
|
5063
5944
|
throw new ApiError(409, `Task ${taskId} is in the spec phase; reject the spec via the spec verdict instead`);
|
|
5064
5945
|
}
|
|
5065
5946
|
if (fresh.reviewMode !== 'server') {
|
|
@@ -5136,7 +6017,7 @@ export class AgentManager {
|
|
|
5136
6017
|
return empty;
|
|
5137
6018
|
// 读取失败不阻断打回:留痕与 fix 闭环优先于 diff 展示
|
|
5138
6019
|
try {
|
|
5139
|
-
await this.
|
|
6020
|
+
await this.refreshWorkdirCacheFor(task.agentId);
|
|
5140
6021
|
const content = await this.getReviewTransport().readContent(task, dev, 'code');
|
|
5141
6022
|
return {
|
|
5142
6023
|
round,
|
|
@@ -5152,40 +6033,82 @@ export class AgentManager {
|
|
|
5152
6033
|
return empty;
|
|
5153
6034
|
}
|
|
5154
6035
|
}
|
|
6036
|
+
isResearchHandoff(task, devAgentId) {
|
|
6037
|
+
return task.researchAgentId !== undefined && task.researchAgentId !== devAgentId;
|
|
6038
|
+
}
|
|
6039
|
+
dispatchCodePhasePrompt(task, devAgentId, signalToken, specDocuments, currentSpecRound) {
|
|
6040
|
+
const expectedKind = task.reviewMode === 'server' ? 'code-done' : 'pr-created';
|
|
6041
|
+
const dispatchOpts = {
|
|
6042
|
+
signalToken,
|
|
6043
|
+
specDocuments,
|
|
6044
|
+
...(currentSpecRound !== undefined ? { currentSpecRound } : {}),
|
|
6045
|
+
armBeforeInject: () => this.setupPhaseSignalWatcher(task.id, devAgentId, expectedKind, signalToken),
|
|
6046
|
+
};
|
|
6047
|
+
return this.isResearchHandoff(task, devAgentId)
|
|
6048
|
+
? this.startSession(task.id, devAgentId, 'code', {
|
|
6049
|
+
...dispatchOpts,
|
|
6050
|
+
preserveBindingOnFailure: true,
|
|
6051
|
+
})
|
|
6052
|
+
: this.continueSession(task.id, devAgentId, 'code', dispatchOpts);
|
|
6053
|
+
}
|
|
5155
6054
|
async transitionToCodePhase(taskId) {
|
|
5156
6055
|
const task = await this.taskStore.get(taskId);
|
|
5157
6056
|
if (!task)
|
|
5158
6057
|
return null;
|
|
5159
|
-
const devAgentId = task.
|
|
5160
|
-
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
6058
|
+
const devAgentId = task.devAgentId;
|
|
6059
|
+
const dev = devAgentId ? this.getAgentConfig(devAgentId) : undefined;
|
|
6060
|
+
if (!devAgentId || dev?.role !== 'dev') {
|
|
6061
|
+
await this.emitIntervention(task.projectId, task.agentId, taskId, {
|
|
6062
|
+
phase: 'code-dev-missing',
|
|
6063
|
+
devAgentId,
|
|
6064
|
+
});
|
|
5165
6065
|
return null;
|
|
5166
|
-
|
|
5167
|
-
const
|
|
5168
|
-
const
|
|
5169
|
-
if (!
|
|
5170
|
-
await this.
|
|
6066
|
+
}
|
|
6067
|
+
const round = task.specReviewRound ?? 1;
|
|
6068
|
+
const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
|
|
6069
|
+
if (!stored || stored.phase !== 'spec') {
|
|
6070
|
+
await this.emitIntervention(task.projectId, task.agentId, taskId, {
|
|
6071
|
+
phase: 'code-spec-round-missing',
|
|
6072
|
+
round,
|
|
6073
|
+
});
|
|
5171
6074
|
return null;
|
|
5172
6075
|
}
|
|
5173
|
-
|
|
5174
|
-
|
|
6076
|
+
const researchAgentId = task.researchAgentId;
|
|
6077
|
+
const researchHandoff = this.isResearchHandoff(task, devAgentId);
|
|
6078
|
+
const releaseParticipant = async (agentId, failurePhase) => {
|
|
6079
|
+
const released = await this.releaseAgentIfBound(agentId, taskId, { allowAwaitingHuman: true })
|
|
5175
6080
|
.catch(() => false);
|
|
5176
6081
|
if (!released) {
|
|
5177
|
-
await this.emitIntervention(task.projectId,
|
|
6082
|
+
await this.emitIntervention(task.projectId, agentId, taskId, { phase: failurePhase, agentId });
|
|
5178
6083
|
}
|
|
6084
|
+
return released;
|
|
6085
|
+
};
|
|
6086
|
+
if (researchHandoff && researchAgentId) {
|
|
6087
|
+
if (!await releaseParticipant(researchAgentId, 'code-phase-research-release-failed'))
|
|
6088
|
+
return null;
|
|
6089
|
+
}
|
|
6090
|
+
if (task.qaAgentId && !await releaseParticipant(task.qaAgentId, 'code-phase-qa-release-failed')) {
|
|
6091
|
+
return null;
|
|
5179
6092
|
}
|
|
5180
6093
|
const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'code');
|
|
5181
6094
|
if (!acquired) {
|
|
5182
|
-
await this.
|
|
5183
|
-
|
|
6095
|
+
await this.emitIntervention(task.projectId, devAgentId, taskId, {
|
|
6096
|
+
phase: 'code-dev-acquire-failed',
|
|
6097
|
+
devAgentId,
|
|
6098
|
+
});
|
|
5184
6099
|
return null;
|
|
5185
6100
|
}
|
|
5186
|
-
|
|
6101
|
+
const newToken = createSignalToken();
|
|
6102
|
+
const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready'] }, { agentId: devAgentId, phase: 'code', signalToken: newToken });
|
|
6103
|
+
if (!transition) {
|
|
6104
|
+
await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
|
|
6105
|
+
.catch(() => undefined);
|
|
6106
|
+
return null;
|
|
6107
|
+
}
|
|
6108
|
+
this.stopPhaseSignalWatcher(taskId);
|
|
6109
|
+
let started = false;
|
|
5187
6110
|
try {
|
|
5188
|
-
|
|
6111
|
+
started = await this.dispatchCodePhasePrompt(task, devAgentId, newToken, stored.documents);
|
|
5189
6112
|
}
|
|
5190
6113
|
catch (err) {
|
|
5191
6114
|
if (err instanceof DispatchTerminalError) {
|
|
@@ -5194,12 +6117,15 @@ export class AgentManager {
|
|
|
5194
6117
|
else if (!(err instanceof EnsureSessionError && err.partial.handled)) {
|
|
5195
6118
|
await this.markAwaitingHuman(devAgentId, 'code-dispatch-failed', 'Code-phase prompt was not delivered after spec approval; the task looks in_progress but the dev never received it. Resume/restart the agent or cancel the task.', { expectedTaskId: taskId }).catch(() => undefined);
|
|
5196
6119
|
}
|
|
5197
|
-
console.error(`[AgentManager] transitionToCodePhase
|
|
6120
|
+
console.error(`[AgentManager] transitionToCodePhase dispatch(dev=${devAgentId}) failed:`, err);
|
|
5198
6121
|
throw err;
|
|
5199
6122
|
}
|
|
5200
|
-
if (!
|
|
6123
|
+
if (!started) {
|
|
5201
6124
|
await this.markAwaitingHuman(devAgentId, 'code-dispatch-failed', 'Code-phase prompt was not delivered after spec approval; the task looks in_progress but the dev never received it. Resume/restart the agent or cancel the task.', { expectedTaskId: taskId }).catch(() => undefined);
|
|
5202
|
-
await this.emitIntervention(task.projectId, devAgentId, taskId, {
|
|
6125
|
+
await this.emitIntervention(task.projectId, devAgentId, taskId, {
|
|
6126
|
+
phase: researchHandoff ? 'code-start-failed' : 'code-resume-failed',
|
|
6127
|
+
devAgentId,
|
|
6128
|
+
});
|
|
5203
6129
|
return null;
|
|
5204
6130
|
}
|
|
5205
6131
|
return await this.taskStore.get(taskId);
|
|
@@ -5216,13 +6142,7 @@ export class AgentManager {
|
|
|
5216
6142
|
if (task.reviewMode !== 'server' && opts.phase !== 'spec') {
|
|
5217
6143
|
throw new Error(`dispatchServerReviewToQa: task ${taskId} is not in server review mode`);
|
|
5218
6144
|
}
|
|
5219
|
-
|
|
5220
|
-
if (recordedQaId && !this.getAgentConfig(recordedQaId)) {
|
|
5221
|
-
console.warn(`[dispatchServerReviewToQa] task ${taskId}.qaAgentId="${recordedQaId}" no longer in config; ` +
|
|
5222
|
-
`falling back to findQaPartner(${task.agentId})`);
|
|
5223
|
-
recordedQaId = undefined;
|
|
5224
|
-
}
|
|
5225
|
-
const qaId = recordedQaId ?? this.findQaPartner(task.agentId)?.id;
|
|
6145
|
+
const qaId = task.qaAgentId;
|
|
5226
6146
|
if (!qaId) {
|
|
5227
6147
|
const entryKind = task.status === 'fixing'
|
|
5228
6148
|
? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
|
|
@@ -5231,6 +6151,17 @@ export class AgentManager {
|
|
|
5231
6151
|
await this.emitIntervention(task.projectId, task.agentId, taskId, { phase: 'server-review-no-qa-partner', devAgentId: task.agentId });
|
|
5232
6152
|
return null;
|
|
5233
6153
|
}
|
|
6154
|
+
if (this.getAgentConfig(qaId)?.role !== 'qa') {
|
|
6155
|
+
const entryKind = task.status === 'fixing'
|
|
6156
|
+
? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
|
|
6157
|
+
: (opts.phase === 'spec' ? 'spec-done' : 'code-done');
|
|
6158
|
+
await this.setupPhaseSignal(taskId, task.agentId, entryKind, { skipSnapshot: true });
|
|
6159
|
+
await this.emitIntervention(task.projectId, task.agentId, taskId, {
|
|
6160
|
+
phase: 'server-review-qa-unavailable',
|
|
6161
|
+
qaAgentId: qaId,
|
|
6162
|
+
});
|
|
6163
|
+
return null;
|
|
6164
|
+
}
|
|
5234
6165
|
const roundField = opts.phase === 'spec' ? (task.specReviewRound ?? 0) : task.reviewRound;
|
|
5235
6166
|
return {
|
|
5236
6167
|
qaId,
|
|
@@ -5304,16 +6235,15 @@ export class AgentManager {
|
|
|
5304
6235
|
: { reviewRound: newRound };
|
|
5305
6236
|
const transition = await this.transitionTaskStatus(taskId, 'review', { fromStatus: ['in_progress', 'fixing', 'review'] }, {
|
|
5306
6237
|
signalToken: newToken,
|
|
5307
|
-
qaAgentId: qaId,
|
|
5308
6238
|
reviewDispatchedAt: new Date().toISOString(),
|
|
5309
6239
|
...(opts.reviewHeadAnchorSha ? { reviewHeadAnchorSha: opts.reviewHeadAnchorSha } : {}),
|
|
5310
6240
|
...(opts.batch
|
|
5311
6241
|
? { batchIndex: opts.batch.index, batchTotal: opts.batch.total }
|
|
5312
6242
|
: { batchIndex: undefined, batchTotal: undefined }),
|
|
5313
|
-
// Fresh dispatch: clear any stale mode until startSession materializes the
|
|
6243
|
+
// Fresh dispatch: clear any stale mode until startSession materializes the review checkout,
|
|
5314
6244
|
// so a crash in this window recovers conservatively (no read-file) rather than
|
|
5315
6245
|
// re-arming it for what might be a head-mode review.
|
|
5316
|
-
...(opts.continuation ? {} : {
|
|
6246
|
+
...(opts.continuation ? {} : { reviewCheckoutMode: undefined }),
|
|
5317
6247
|
...roundPatch,
|
|
5318
6248
|
});
|
|
5319
6249
|
if (!transition) {
|
|
@@ -5323,7 +6253,7 @@ export class AgentManager {
|
|
|
5323
6253
|
await this.emitIntervention(projectId, qaId, taskId, { phase: 'server-review-transition-failed', qaAgentId: qaId });
|
|
5324
6254
|
return null;
|
|
5325
6255
|
}
|
|
5326
|
-
let
|
|
6256
|
+
let dispatchedCheckoutMode;
|
|
5327
6257
|
const sessionOpts = {
|
|
5328
6258
|
bypassTaskStatusGate: true,
|
|
5329
6259
|
signalToken: newToken,
|
|
@@ -5338,8 +6268,8 @@ export class AgentManager {
|
|
|
5338
6268
|
...(opts.priorResponseJson ? { serverPriorResponse: opts.priorResponseJson } : {}),
|
|
5339
6269
|
...(opts.phase === 'spec' ? { currentSpecRound: newRound } : {}),
|
|
5340
6270
|
armBeforeInject: (ctx) => {
|
|
5341
|
-
|
|
5342
|
-
const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.
|
|
6271
|
+
dispatchedCheckoutMode = ctx.serverReviewCheckout;
|
|
6272
|
+
const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.serverReviewCheckout === 'base';
|
|
5343
6273
|
return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, false, allowReadFile ? (req) => { void this.handleReadFileRequest(taskId, qaId, req); } : undefined);
|
|
5344
6274
|
},
|
|
5345
6275
|
};
|
|
@@ -5383,10 +6313,20 @@ export class AgentManager {
|
|
|
5383
6313
|
await this.emitIntervention(projectId, qaId, taskId, { phase: 'server-review-start-failed', qaAgentId: qaId });
|
|
5384
6314
|
return null;
|
|
5385
6315
|
}
|
|
5386
|
-
// Persist the
|
|
5387
|
-
// fallback review (whose prompt depends on it) but not for a head-mode one.
|
|
6316
|
+
// Persist the checkout mode so base-mode recovery can re-arm read-file.
|
|
5388
6317
|
if (opts.phase !== 'spec' && !opts.continuation) {
|
|
5389
|
-
|
|
6318
|
+
try {
|
|
6319
|
+
await this.updateTask(taskId, { reviewCheckoutMode: dispatchedCheckoutMode });
|
|
6320
|
+
}
|
|
6321
|
+
catch (err) {
|
|
6322
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
6323
|
+
console.warn(`[AgentManager] failed to persist review checkout mode for ${taskId}: ${message}`);
|
|
6324
|
+
await this.emitIntervention(projectId, qaId, taskId, {
|
|
6325
|
+
phase: 'review-checkout-mode-persist-failed',
|
|
6326
|
+
error: message,
|
|
6327
|
+
note: 'The review prompt is running, but restart recovery cannot safely infer its checkout mode.',
|
|
6328
|
+
});
|
|
6329
|
+
}
|
|
5390
6330
|
}
|
|
5391
6331
|
return await this.taskStore.get(taskId);
|
|
5392
6332
|
}
|
|
@@ -5395,7 +6335,7 @@ export class AgentManager {
|
|
|
5395
6335
|
const task = await this.taskStore.get(taskId);
|
|
5396
6336
|
if (!task)
|
|
5397
6337
|
throw new Error(`dispatchServerFixToDev: task ${taskId} not found`);
|
|
5398
|
-
if (task.reviewMode !== 'server' && task.phase
|
|
6338
|
+
if (task.reviewMode !== 'server' && !isSpecStagePhase(task.phase)) {
|
|
5399
6339
|
throw new Error(`dispatchServerFixToDev: task ${taskId} is not in server review mode`);
|
|
5400
6340
|
}
|
|
5401
6341
|
if (!task.agentId)
|
|
@@ -5405,7 +6345,7 @@ export class AgentManager {
|
|
|
5405
6345
|
qaAgentId: task.qaAgentId,
|
|
5406
6346
|
projectId: task.projectId,
|
|
5407
6347
|
newToken: createSignalToken(),
|
|
5408
|
-
taskPhase:
|
|
6348
|
+
taskPhase: task.phase,
|
|
5409
6349
|
currentSpecRound: task.specReviewRound,
|
|
5410
6350
|
originalStatus: task.status,
|
|
5411
6351
|
originalToken: task.signalToken,
|
|
@@ -5431,7 +6371,7 @@ export class AgentManager {
|
|
|
5431
6371
|
return null;
|
|
5432
6372
|
}
|
|
5433
6373
|
if (qaAgentId) {
|
|
5434
|
-
const released = await this.
|
|
6374
|
+
const released = await this.releaseAgentIfBound(qaAgentId, taskId)
|
|
5435
6375
|
.catch(() => false);
|
|
5436
6376
|
if (!released) {
|
|
5437
6377
|
await rearmReviewedSignal();
|
|
@@ -5506,13 +6446,13 @@ export class AgentManager {
|
|
|
5506
6446
|
return { ok: false, reason: 'released' };
|
|
5507
6447
|
const agent = this.getAgentConfig(task.agentId);
|
|
5508
6448
|
const agentState = await this.agentStore.get(task.agentId);
|
|
5509
|
-
// A rebound agent's
|
|
6449
|
+
// A rebound agent's Workdir checkout belongs to its new task — its git state describes
|
|
5510
6450
|
// another branch, so the interdiff would be meaningless. Same skip rule as
|
|
5511
|
-
// findLineageViolation:
|
|
5512
|
-
if (!agent || agentState?.taskId !== taskId || !agentState.
|
|
6451
|
+
// findLineageViolation: the Workdir must still be bound to THIS task.
|
|
6452
|
+
if (!agent || agentState?.taskId !== taskId || !agentState.workdir) {
|
|
5513
6453
|
return { ok: false, reason: 'released' };
|
|
5514
6454
|
}
|
|
5515
|
-
await this.
|
|
6455
|
+
await this.refreshWorkdirCacheFor(task.agentId);
|
|
5516
6456
|
const diff = await this.getReviewTransport().readInterdiff(agent, prevSha, curSha);
|
|
5517
6457
|
return { ok: true, diff };
|
|
5518
6458
|
}
|
|
@@ -5522,13 +6462,13 @@ export class AgentManager {
|
|
|
5522
6462
|
return null;
|
|
5523
6463
|
const agent = this.getAgentConfig(task.agentId);
|
|
5524
6464
|
const agentState = await this.agentStore.get(task.agentId);
|
|
5525
|
-
// A rebound agent's
|
|
6465
|
+
// A rebound agent's Workdir checkout belongs to its new task — checking it would
|
|
5526
6466
|
// produce verdicts about the wrong branch. Skip; the dispatch path's own
|
|
5527
6467
|
// binding guards (acquire) surface the real failure.
|
|
5528
6468
|
if (agentState?.taskId !== taskId)
|
|
5529
6469
|
return null;
|
|
5530
|
-
const
|
|
5531
|
-
if (!agent || !
|
|
6470
|
+
const workdir = agentState.workdir;
|
|
6471
|
+
if (!agent || !workdir)
|
|
5532
6472
|
return null;
|
|
5533
6473
|
const runner = this.createRunnerFor(agent);
|
|
5534
6474
|
let base = baseSha;
|
|
@@ -5536,13 +6476,13 @@ export class AgentManager {
|
|
|
5536
6476
|
// Callers without a baseSha (publish) run long after the review diff was
|
|
5537
6477
|
// read; refresh origin/HEAD first or upstream commits merged since then
|
|
5538
6478
|
// would linger in base..HEAD and flag tasks that leak nothing.
|
|
5539
|
-
const fetch = await execNetwork(runner, `${GIT_NET_ENV} git -C ${shellQuote(
|
|
6479
|
+
const fetch = await execNetwork(runner, `${GIT_NET_ENV} git -C ${shellQuote(workdir)} fetch origin --quiet`);
|
|
5540
6480
|
if (fetch.exitCode !== 0) {
|
|
5541
|
-
throw new Error(`lineage fetch failed in ${
|
|
6481
|
+
throw new Error(`lineage fetch failed in ${workdir}: ${fetch.stderr.trim()}`);
|
|
5542
6482
|
}
|
|
5543
|
-
const mb = await runner.exec(`git -C ${shellQuote(
|
|
6483
|
+
const mb = await runner.exec(`git -C ${shellQuote(workdir)} merge-base origin/HEAD HEAD`);
|
|
5544
6484
|
if (mb.exitCode !== 0) {
|
|
5545
|
-
throw new Error(`lineage merge-base failed in ${
|
|
6485
|
+
throw new Error(`lineage merge-base failed in ${workdir}: ${mb.stderr.trim()}`);
|
|
5546
6486
|
}
|
|
5547
6487
|
base = mb.stdout.trim();
|
|
5548
6488
|
}
|
|
@@ -5559,7 +6499,7 @@ export class AgentManager {
|
|
|
5559
6499
|
.map(t => ({ taskId: t.id, branch: t.branch ?? BRANCH_PREFIX + t.id }));
|
|
5560
6500
|
if (candidates.length === 0)
|
|
5561
6501
|
return null;
|
|
5562
|
-
return findForeignTaskTip((cmd) => runner.exec(cmd),
|
|
6502
|
+
return findForeignTaskTip((cmd) => runner.exec(cmd), workdir, base, candidates);
|
|
5563
6503
|
}
|
|
5564
6504
|
async dispatchServerAfterDone(taskId, kind) {
|
|
5565
6505
|
const task = await this.taskStore.get(taskId);
|
|
@@ -5641,7 +6581,7 @@ export class AgentManager {
|
|
|
5641
6581
|
const dev = this.getAgentConfig(task.agentId);
|
|
5642
6582
|
if (!dev)
|
|
5643
6583
|
return;
|
|
5644
|
-
await this.
|
|
6584
|
+
await this.refreshWorkdirCacheFor(task.agentId);
|
|
5645
6585
|
let body;
|
|
5646
6586
|
try {
|
|
5647
6587
|
const text = await this.getReviewTransport().readFileRange(dev, req.file, req.startLine, req.endLine);
|
|
@@ -5676,6 +6616,11 @@ export class AgentManager {
|
|
|
5676
6616
|
if (isCancelCleanupHold(state)) {
|
|
5677
6617
|
throw new Error(`injectTextToAgent: agent ${agentId} taken over by cancel (${state?.awaitingPhase}); refusing injection`);
|
|
5678
6618
|
}
|
|
6619
|
+
const taskId = state?.taskId;
|
|
6620
|
+
const lockToken = taskId ? await this.resolveTaskLockToken(state, taskId) : null;
|
|
6621
|
+
if (taskId && !lockToken) {
|
|
6622
|
+
throw new Error(`injectTextToAgent: agent ${agentId} no longer owns the exclusive lock for task ${taskId}`);
|
|
6623
|
+
}
|
|
5679
6624
|
const boundTask = state?.taskId ? await this.taskStore.get(state.taskId) : null;
|
|
5680
6625
|
if (boundTask && TERMINAL_STATUSES.includes(boundTask.status)) {
|
|
5681
6626
|
throw new Error(`injectTextToAgent: task ${state?.taskId} for agent ${agentId} is terminal; refusing injection`);
|
|
@@ -5685,10 +6630,16 @@ export class AgentManager {
|
|
|
5685
6630
|
throw new Error(`injectTextToAgent: agent ${agentId} has no live pane`);
|
|
5686
6631
|
const fresh = await this.agentStore.get(agentId);
|
|
5687
6632
|
if (!fresh || fresh.paneId !== paneId
|
|
5688
|
-
||
|
|
5689
|
-
||
|
|
6633
|
+
|| fresh.taskId !== taskId
|
|
6634
|
+
|| (taskId !== undefined && fresh.lockToken !== lockToken)) {
|
|
6635
|
+
throw new Error(`injectTextToAgent: agent ${agentId} binding or exclusive lock changed before paste`);
|
|
6636
|
+
}
|
|
6637
|
+
if (isCancelCleanupHold(fresh)) {
|
|
5690
6638
|
throw new Error(`injectTextToAgent: agent ${agentId} taken over by cancel before paste`);
|
|
5691
6639
|
}
|
|
6640
|
+
if (taskId && lockToken) {
|
|
6641
|
+
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
6642
|
+
}
|
|
5692
6643
|
const tmux = new TmuxManager(this.createRunnerFor(cfg));
|
|
5693
6644
|
await tmux.injectPrompt(paneId, text, agentId);
|
|
5694
6645
|
await tmux.sendEnter(paneId);
|
|
@@ -5832,14 +6783,14 @@ export class AgentManager {
|
|
|
5832
6783
|
if (!dev)
|
|
5833
6784
|
throw new Error(`ffMergeBranch: no dev agent for task ${task.id}`);
|
|
5834
6785
|
const state = await this.agentStore.get(task.agentId);
|
|
5835
|
-
const
|
|
5836
|
-
if (!
|
|
5837
|
-
throw new Error(`ffMergeBranch: no
|
|
6786
|
+
const workdir = state?.workdir;
|
|
6787
|
+
if (!workdir)
|
|
6788
|
+
throw new Error(`ffMergeBranch: no Workdir for agent ${task.agentId}`);
|
|
5838
6789
|
const branch = task.branch ?? BRANCH_PREFIX + task.id;
|
|
5839
6790
|
const runner = this.createRunnerFor(dev);
|
|
5840
6791
|
const prev = this.repoMergeQueue.get(task.projectId) ?? Promise.resolve();
|
|
5841
6792
|
const run = prev.then(async () => {
|
|
5842
|
-
const cd = `cd ${shellQuote(
|
|
6793
|
+
const cd = `cd ${shellQuote(workdir)} && `;
|
|
5843
6794
|
const db = await runner.exec(`${cd}git symbolic-ref --short refs/remotes/origin/HEAD`);
|
|
5844
6795
|
const defaultBranch = db.stdout.trim().replace(/^origin\//, '');
|
|
5845
6796
|
if (db.exitCode !== 0 || defaultBranch === '') {
|
|
@@ -5863,10 +6814,6 @@ export class AgentManager {
|
|
|
5863
6814
|
if (push.exitCode !== 0) {
|
|
5864
6815
|
throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
|
|
5865
6816
|
}
|
|
5866
|
-
const del = await execNetwork(runner, `${cd}${GIT_NET_ENV} git push origin --delete ${shellQuote(branch)}`);
|
|
5867
|
-
if (del.exitCode !== 0 && !del.stderr.includes('remote ref does not exist')) {
|
|
5868
|
-
console.warn(`[AgentManager] ffMergeBranch: post-merge branch delete failed for ${branch}: ${del.stderr.trim() || del.stdout.trim()}`);
|
|
5869
|
-
}
|
|
5870
6817
|
});
|
|
5871
6818
|
this.repoMergeQueue.set(task.projectId, run.catch(() => undefined));
|
|
5872
6819
|
await run;
|