baxian 1.2.53 → 1.2.55
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/manifests/opencode.json +44 -0
- package/dist/agent/detect/manifests/qodercli.json +49 -0
- 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 +13 -9
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +1071 -426
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/preflight.d.ts.map +1 -1
- package/dist/agent/preflight.js +21 -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 -7
- 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 +142 -54
- package/dist/agent/repo-store.js.map +1 -1
- package/dist/agent/review-transport.d.ts +6 -4
- package/dist/agent/review-transport.d.ts.map +1 -1
- package/dist/agent/review-transport.js +39 -13
- 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-probe-poller.d.ts.map +1 -1
- package/dist/agent/tmux-probe-poller.js +2 -0
- package/dist/agent/tmux-probe-poller.js.map +1 -1
- package/dist/agent/tmux.d.ts +2 -1
- package/dist/agent/tmux.d.ts.map +1 -1
- package/dist/agent/tmux.js +73 -7
- package/dist/agent/tmux.js.map +1 -1
- package/dist/api/probe.d.ts.map +1 -1
- package/dist/api/probe.js +9 -1
- package/dist/api/probe.js.map +1 -1
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +228 -56
- package/dist/api/projects.js.map +1 -1
- package/dist/config/validator.d.ts.map +1 -1
- package/dist/config/validator.js +58 -8
- package/dist/config/validator.js.map +1 -1
- package/dist/event/server-handlers.js +8 -8
- 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 +0 -1
- package/dist/shared/constants.d.ts.map +1 -1
- package/dist/shared/constants.js +0 -1
- package/dist/shared/constants.js.map +1 -1
- package/dist/shared/types.d.ts +15 -4
- package/dist/shared/types.d.ts.map +1 -1
- 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-server-feedback/SKILL.md +4 -4
- package/dist/skills/baxian-server-review/SKILL.md +7 -7
- package/dist/skills/baxian-signals/SKILL.md +3 -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/task-store.d.ts.map +1 -1
- package/dist/state/task-store.js +5 -2
- package/dist/state/task-store.js.map +1 -1
- package/dist/web/assets/index-BmnSiyut.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-DAPd4Nv_.js +0 -13
package/dist/agent/manager.js
CHANGED
|
@@ -12,7 +12,7 @@ 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,19 +46,39 @@ 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');
|
|
61
|
+
break;
|
|
62
|
+
case 'opencode':
|
|
63
|
+
// opencode materializes skills into .agents/skills (shared with codex; see skillSubdirFor).
|
|
64
|
+
// Its .agents/.claude compatibility scans can't both be disabled by env, so also turn off
|
|
65
|
+
// the .claude/skills scan — that closes the last collision source: a claude-code agent's
|
|
66
|
+
// .claude/skills/baxian-* in a shared base repo.
|
|
67
|
+
segments.push('env OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 opencode');
|
|
68
|
+
if (yolo)
|
|
69
|
+
segments.push('--auto');
|
|
70
|
+
break;
|
|
71
|
+
case 'qodercli':
|
|
72
|
+
segments.push('qodercli');
|
|
73
|
+
if (yolo)
|
|
74
|
+
segments.push('--dangerously-skip-permissions');
|
|
56
75
|
break;
|
|
57
76
|
}
|
|
58
77
|
if (agent.model) {
|
|
59
78
|
segments.push(`--model ${shellQuote(agent.model)}`);
|
|
60
79
|
}
|
|
61
|
-
|
|
80
|
+
// opencode has no --add-dir; extra roots go through its permission model instead (validator rejects the pairing).
|
|
81
|
+
if (agent.addDirs && agent.addDirs.length > 0 && agent.runtime !== 'opencode') {
|
|
62
82
|
for (const dir of agent.addDirs) {
|
|
63
83
|
segments.push(`--add-dir ${shellQuote(dir)}`);
|
|
64
84
|
}
|
|
@@ -68,6 +88,51 @@ export function buildLaunchCommand(agent) {
|
|
|
68
88
|
function agentRuntimeKindFor(agent) {
|
|
69
89
|
return agent.runtime;
|
|
70
90
|
}
|
|
91
|
+
// restart-repl drives the REPL back to a shell before relaunch. Each runtime exits
|
|
92
|
+
// via its own slash command (a bare "exit" would be sent to the model as chat text).
|
|
93
|
+
const REPL_EXIT_COMMAND = {
|
|
94
|
+
'claude-code': '/exit',
|
|
95
|
+
codex: '/quit',
|
|
96
|
+
opencode: '/exit',
|
|
97
|
+
qodercli: '/quit',
|
|
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
|
+
}
|
|
120
|
+
export function skillSubdirFor(runtime) {
|
|
121
|
+
switch (runtime) {
|
|
122
|
+
case 'codex':
|
|
123
|
+
case 'opencode':
|
|
124
|
+
// opencode has no way to disable its .agents/skills compatibility scan (only .claude via
|
|
125
|
+
// env), and when it shares a base repo with a codex agent it loads the codex copy of a
|
|
126
|
+
// same-named baxian-* skill (verified: the .agents copy wins over .opencode). So opencode
|
|
127
|
+
// materializes into .agents/skills too — both runtimes share one runtime-agnostic copy
|
|
128
|
+
// (serialized by the same skill-dir lock) instead of colliding on duplicate names.
|
|
129
|
+
return '.agents/skills';
|
|
130
|
+
case 'qodercli':
|
|
131
|
+
return '.qoder/skills';
|
|
132
|
+
default:
|
|
133
|
+
return '.claude/skills';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
71
136
|
const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
|
|
72
137
|
const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
|
|
73
138
|
const GH_NET = { timeout: GH_EXEC_TIMEOUT_MS, retries: 1 };
|
|
@@ -78,15 +143,13 @@ export function canDispatchWithBinding(binding) {
|
|
|
78
143
|
return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
|
|
79
144
|
}
|
|
80
145
|
const TURN_COMPLETED_AWAITING_PHASES = new Set();
|
|
81
|
-
|
|
82
|
-
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']);
|
|
83
147
|
function isCancelCleanupHold(binding) {
|
|
84
148
|
return binding?.awaitingPhase != null && CANCEL_CLEANUP_HOLD_PHASES.has(binding.awaitingPhase);
|
|
85
149
|
}
|
|
86
150
|
const CANCEL_CLEANUP_PHASE_RANK = {
|
|
87
151
|
'cancel-clearing': 1,
|
|
88
152
|
'cancel-interrupt-failed': 2,
|
|
89
|
-
'cancel-clear-failed': 3,
|
|
90
153
|
};
|
|
91
154
|
function cancelPhaseRank(phase) {
|
|
92
155
|
return phase ? (CANCEL_CLEANUP_PHASE_RANK[phase] ?? 0) : 0;
|
|
@@ -136,8 +199,6 @@ export class AgentManager {
|
|
|
136
199
|
runtimeLivenessProbeMs = 700;
|
|
137
200
|
cleanComposerWaitMs = 5_000;
|
|
138
201
|
cancelInterruptGuardWaitMs = DEFAULT_DISPATCH_ACK_TIMEOUT_MS + 5_000;
|
|
139
|
-
postMergeFetchTimeoutMs = 60_000;
|
|
140
|
-
postMergeBranchTimeoutMs = 10_000;
|
|
141
202
|
manualReviewInFlight = new Set();
|
|
142
203
|
markCompleteInFlight = new Set();
|
|
143
204
|
specVerdictInFlight = new Set();
|
|
@@ -186,6 +247,28 @@ export class AgentManager {
|
|
|
186
247
|
this.taskMutationQueue = next.catch(() => undefined);
|
|
187
248
|
return next;
|
|
188
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
|
+
}
|
|
189
272
|
getReviewStore() {
|
|
190
273
|
return this.reviewStore;
|
|
191
274
|
}
|
|
@@ -213,18 +296,19 @@ export class AgentManager {
|
|
|
213
296
|
getReviewTransport() {
|
|
214
297
|
this.reviewTransportInstance ??= new ReviewTransport({
|
|
215
298
|
createRunnerFor: (agent) => this.createRunnerFor(agent),
|
|
216
|
-
|
|
299
|
+
resolveWorkdir: (agentId) => this.bindingWorkdirCache.get(agentId),
|
|
217
300
|
});
|
|
218
301
|
return this.reviewTransportInstance;
|
|
219
302
|
}
|
|
220
|
-
|
|
221
|
-
async
|
|
303
|
+
bindingWorkdirCache = new Map();
|
|
304
|
+
async refreshWorkdirCacheFor(agentId) {
|
|
222
305
|
const state = await this.agentStore.get(agentId);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
306
|
+
const workdir = state?.workdir;
|
|
307
|
+
if (workdir) {
|
|
308
|
+
this.bindingWorkdirCache.set(agentId, workdir);
|
|
309
|
+
return workdir;
|
|
226
310
|
}
|
|
227
|
-
this.
|
|
311
|
+
this.bindingWorkdirCache.delete(agentId);
|
|
228
312
|
return undefined;
|
|
229
313
|
}
|
|
230
314
|
async safeEmit(event) {
|
|
@@ -260,6 +344,7 @@ export class AgentManager {
|
|
|
260
344
|
const config = prepareConfig(validated);
|
|
261
345
|
this.config = config;
|
|
262
346
|
this.agentIndex = buildAgentIndex(config);
|
|
347
|
+
this.repoCache.owners.clear();
|
|
263
348
|
}
|
|
264
349
|
getConfig() {
|
|
265
350
|
return this.config;
|
|
@@ -298,17 +383,15 @@ export class AgentManager {
|
|
|
298
383
|
try {
|
|
299
384
|
const resolved = await this.ensureWorkdir(agent, project, runner);
|
|
300
385
|
workdir = resolved.workdir;
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
});
|
|
311
|
-
}
|
|
386
|
+
await this.agentStore.update(agentId, (existing) => {
|
|
387
|
+
if (!existing)
|
|
388
|
+
return AGENT_STORE_NOOP;
|
|
389
|
+
return {
|
|
390
|
+
...existing,
|
|
391
|
+
workdir,
|
|
392
|
+
updatedAt: new Date().toISOString(),
|
|
393
|
+
};
|
|
394
|
+
});
|
|
312
395
|
}
|
|
313
396
|
catch (err) {
|
|
314
397
|
throw new EnsureSessionError({ createdSession: false, agentId }, `ensureWorkdir failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -332,16 +415,21 @@ export class AgentManager {
|
|
|
332
415
|
`it creates itself — kill it manually or pick a different agent id`);
|
|
333
416
|
}
|
|
334
417
|
if (alive && (mode === 'runtime' || mode === 'recover')) {
|
|
335
|
-
return this.adoptOrRestartSession(tmux, agent, agentId, workdir);
|
|
418
|
+
return this.adoptOrRestartSession(tmux, runner, agent, agentId, workdir, workdir);
|
|
336
419
|
}
|
|
337
|
-
return this.buildFreshSession(tmux, agent, agentId, workdir);
|
|
420
|
+
return this.buildFreshSession(tmux, agent, agentId, workdir, workdir);
|
|
338
421
|
}
|
|
339
422
|
async provisionRepoSkills(runner, agent, workdir) {
|
|
340
423
|
if (this.skillRegistry.names().length === 0)
|
|
341
424
|
return;
|
|
342
|
-
const subdir = agent.runtime
|
|
425
|
+
const subdir = skillSubdirFor(agent.runtime);
|
|
343
426
|
const destRoot = `${workdir}/${subdir}`;
|
|
344
427
|
await this.excludeInjectedSkills(runner, workdir, subdir);
|
|
428
|
+
// opencode's skills are model-invoked, not slash commands; the dispatched /<skill> sigil
|
|
429
|
+
// reaches the model as message text and it loads the skill from .opencode/skills. We do NOT
|
|
430
|
+
// wrap skills as .opencode/commands: opencode expands $ARGUMENTS in a command template and
|
|
431
|
+
// would execute a `!`cmd`` / inline a @path embedded in an untrusted task title/description
|
|
432
|
+
// before the skill runs. Message-body payloads are not expanded, so this path is injection-safe.
|
|
345
433
|
await this.runUnderSkillDirLock(this.skillDirLockKey(agent, workdir), async () => {
|
|
346
434
|
await this.ensureSkillDirSafe(runner, workdir, subdir);
|
|
347
435
|
await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, path, content), destRoot);
|
|
@@ -350,14 +438,19 @@ export class AgentManager {
|
|
|
350
438
|
skillDirChain = new Map();
|
|
351
439
|
skillDirLockKey(agent, workdir) {
|
|
352
440
|
const host = hostGroupKey(agent.mode, resolveAgentHost(this.config.host, agent.host));
|
|
353
|
-
const subdir = agent.runtime
|
|
441
|
+
const subdir = skillSubdirFor(agent.runtime);
|
|
354
442
|
const dir = workdir.replace(/\/+$/, '');
|
|
355
443
|
return `${host}:${dir}:${subdir}`;
|
|
356
444
|
}
|
|
357
445
|
runUnderSkillDirLock(key, fn) {
|
|
358
446
|
const prev = this.skillDirChain.get(key) ?? Promise.resolve();
|
|
359
447
|
const run = prev.then(fn, fn);
|
|
360
|
-
|
|
448
|
+
const settled = run.then(() => undefined, () => undefined);
|
|
449
|
+
this.skillDirChain.set(key, settled);
|
|
450
|
+
void settled.finally(() => {
|
|
451
|
+
if (this.skillDirChain.get(key) === settled)
|
|
452
|
+
this.skillDirChain.delete(key);
|
|
453
|
+
});
|
|
361
454
|
return run;
|
|
362
455
|
}
|
|
363
456
|
async atomicWriteFile(runner, finalPath, content) {
|
|
@@ -397,14 +490,28 @@ export class AgentManager {
|
|
|
397
490
|
return tagged !== this.skillRegistry.contentHash();
|
|
398
491
|
}
|
|
399
492
|
async excludeInjectedSkills(runner, workdir, subdir) {
|
|
400
|
-
const inner = `cd ${shellQuote(workdir)} &&
|
|
493
|
+
const inner = `cd ${shellQuote(workdir)} && ` +
|
|
494
|
+
`if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo BX_SKILLS_NON_GIT; exit 0; fi; ` +
|
|
495
|
+
`tracked="$(git ls-files -- ${shellQuote(`${subdir}/baxian-*`)} 2>/dev/null | head -5)"; ` +
|
|
496
|
+
`if [ -n "$tracked" ]; then printf 'BX_SKILLS_TRACKED %s\\n' "$tracked" | head -1; exit 0; fi; ` +
|
|
497
|
+
`p="$(git rev-parse --git-path info/exclude)" || { echo BX_SKILLS_EXCLUDE_WRITE_FAILED; exit 0; }; ` +
|
|
401
498
|
`pre="$(git rev-parse --show-prefix 2>/dev/null)"; rule="\${pre}${subdir}/baxian-*"; ` +
|
|
402
|
-
`mkdir -p "$(dirname "$p")" && { grep -qxF "$rule" "$p" 2>/dev/null || printf '%s\\n' "$rule" >> "$p"; };
|
|
499
|
+
`{ mkdir -p "$(dirname "$p")" && { grep -qxF "$rule" "$p" 2>/dev/null || printf '%s\\n' "$rule" >> "$p"; }; } ` +
|
|
500
|
+
`|| { echo BX_SKILLS_EXCLUDE_WRITE_FAILED; exit 0; }; ` +
|
|
501
|
+
`if git check-ignore -q -- ${shellQuote(`${subdir}/baxian-__probe__/SKILL.md`)}; then echo BX_SKILLS_OK; ` +
|
|
502
|
+
`else echo BX_SKILLS_EXCLUDE_INEFFECTIVE; fi`;
|
|
403
503
|
const res = await runner.exec(`sh -c ${shellQuote(inner)}`);
|
|
404
504
|
if (res.exitCode !== 0) {
|
|
405
|
-
|
|
406
|
-
`(skills still materialized): ${res.stderr || 'unknown error'}`);
|
|
505
|
+
throw new Error(`skill exclusion probe failed in ${workdir}: ${res.stderr.trim() || `exit ${res.exitCode}`}`);
|
|
407
506
|
}
|
|
507
|
+
const verdict = res.stdout.trim().split('\n').pop() ?? '';
|
|
508
|
+
if (verdict === 'BX_SKILLS_NON_GIT' || verdict === 'BX_SKILLS_OK')
|
|
509
|
+
return;
|
|
510
|
+
if (verdict.startsWith('BX_SKILLS_TRACKED')) {
|
|
511
|
+
throw new Error(`refusing to inject baxian skills into ${workdir}: project tracks ` +
|
|
512
|
+
`${verdict.replace('BX_SKILLS_TRACKED ', '')}`);
|
|
513
|
+
}
|
|
514
|
+
throw new Error(`cannot make injected skills invisible to Git in ${workdir}: ${verdict || 'no probe output'}`);
|
|
408
515
|
}
|
|
409
516
|
async pinRuntimeSessionOptions(tmux, agentId) {
|
|
410
517
|
await tmux.setOption(agentId, 'prefix', 'C-b');
|
|
@@ -415,17 +522,18 @@ export class AgentManager {
|
|
|
415
522
|
await tmux.setOption(agentId, 'window-size', 'latest');
|
|
416
523
|
await this.pinRuntimeSessionOptions(tmux, agentId);
|
|
417
524
|
}
|
|
418
|
-
async buildFreshSession(tmux, agent, agentId, workdir) {
|
|
419
|
-
return this.runUnderSkillDirLock(this.skillDirLockKey(agent,
|
|
525
|
+
async buildFreshSession(tmux, agent, agentId, workdir, sessionDir = workdir) {
|
|
526
|
+
return this.runUnderSkillDirLock(this.skillDirLockKey(agent, sessionDir), () => this.buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir));
|
|
420
527
|
}
|
|
421
|
-
async buildFreshSessionLocked(tmux, agent, agentId, workdir) {
|
|
528
|
+
async buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir) {
|
|
422
529
|
let createdSession = false;
|
|
423
530
|
const runtime = agentRuntimeKindFor(agent);
|
|
424
531
|
try {
|
|
425
|
-
await tmux.createSession(agentId,
|
|
532
|
+
await tmux.createSession(agentId, sessionDir);
|
|
426
533
|
createdSession = true;
|
|
427
534
|
await tmux.setOption(agentId, '@baxian-agent-id', agentId);
|
|
428
535
|
await tmux.setOption(agentId, '@baxian-runtime', agent.runtime);
|
|
536
|
+
await tmux.setOption(agentId, WORKDIR_SESSION_OPTION, sessionDir);
|
|
429
537
|
await tmux.setOption(agentId, 'allow-passthrough', 'on');
|
|
430
538
|
await tmux.setOption(agentId, 'set-titles', 'on');
|
|
431
539
|
await this.pinFreshSessionOptions(tmux, agentId);
|
|
@@ -997,7 +1105,7 @@ export class AgentManager {
|
|
|
997
1105
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
998
1106
|
});
|
|
999
1107
|
}
|
|
1000
|
-
async adoptOrRestartSession(tmux, agent, agentId, workdir) {
|
|
1108
|
+
async adoptOrRestartSession(tmux, runner, agent, agentId, workdir, sessionDir) {
|
|
1001
1109
|
let claim;
|
|
1002
1110
|
try {
|
|
1003
1111
|
claim = await tmux.getOption(agentId, '@baxian-agent-id');
|
|
@@ -1030,6 +1138,36 @@ export class AgentManager {
|
|
|
1030
1138
|
catch (err) {
|
|
1031
1139
|
throw new EnsureSessionError({ createdSession: false, agentId }, `classifyPaneForAdopt failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1032
1140
|
}
|
|
1141
|
+
let currentPath;
|
|
1142
|
+
try {
|
|
1143
|
+
currentPath = await tmux.getPaneCurrentPath(paneId);
|
|
1144
|
+
}
|
|
1145
|
+
catch (err) {
|
|
1146
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `pane current path probe failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1147
|
+
}
|
|
1148
|
+
let pathMatches;
|
|
1149
|
+
try {
|
|
1150
|
+
pathMatches = await sameDirOnHost(runner, currentPath, sessionDir);
|
|
1151
|
+
}
|
|
1152
|
+
catch (err) {
|
|
1153
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `pane current path comparison failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1154
|
+
}
|
|
1155
|
+
if (!pathMatches) {
|
|
1156
|
+
if (state.kind === 'live-runtime') {
|
|
1157
|
+
try {
|
|
1158
|
+
await this.waitForReplPromptReady(tmux, paneId, agent.runtime, this.cleanComposerWaitMs);
|
|
1159
|
+
}
|
|
1160
|
+
catch (err) {
|
|
1161
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `Workdir change requires an idle runtime: ${err instanceof Error ? err.message : String(err)}`);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
else if (state.kind !== 'shell') {
|
|
1165
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux pane path ${currentPath} differs from Workdir ${sessionDir}, but runtime is not safely idle`);
|
|
1166
|
+
}
|
|
1167
|
+
await tmux.killSession(agentId);
|
|
1168
|
+
return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
|
|
1169
|
+
}
|
|
1170
|
+
await tmux.setOption(agentId, WORKDIR_SESSION_OPTION, sessionDir);
|
|
1033
1171
|
switch (state.kind) {
|
|
1034
1172
|
case 'live-runtime': {
|
|
1035
1173
|
let stale;
|
|
@@ -1040,8 +1178,14 @@ export class AgentManager {
|
|
|
1040
1178
|
throw new EnsureSessionError({ createdSession: false, agentId }, `skills-version probe failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1041
1179
|
}
|
|
1042
1180
|
if (stale) {
|
|
1043
|
-
|
|
1044
|
-
|
|
1181
|
+
return {
|
|
1182
|
+
ok: true,
|
|
1183
|
+
createdSession: false,
|
|
1184
|
+
freshRuntime: false,
|
|
1185
|
+
skillsStale: true,
|
|
1186
|
+
paneId,
|
|
1187
|
+
workdir,
|
|
1188
|
+
};
|
|
1045
1189
|
}
|
|
1046
1190
|
return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir };
|
|
1047
1191
|
}
|
|
@@ -1112,7 +1256,10 @@ export class AgentManager {
|
|
|
1112
1256
|
if (!cfg)
|
|
1113
1257
|
throw new Error(`Unknown agent: ${agentId}`);
|
|
1114
1258
|
const state = await this.agentStore.get(agentId);
|
|
1115
|
-
const
|
|
1259
|
+
const existingToken = state?.taskId === taskId && state
|
|
1260
|
+
? await this.resolveTaskLockToken(state, taskId)
|
|
1261
|
+
: null;
|
|
1262
|
+
const sameTaskLocked = existingToken !== null;
|
|
1116
1263
|
const reentryPhases = new Set([
|
|
1117
1264
|
'fix', 'post-approve', 'code',
|
|
1118
1265
|
'server-feedback', 'server-after-done',
|
|
@@ -1125,17 +1272,16 @@ export class AgentManager {
|
|
|
1125
1272
|
if (!sameTaskReentry && !canDispatchWithBinding(state)) {
|
|
1126
1273
|
return false;
|
|
1127
1274
|
}
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
return false;
|
|
1132
|
-
}
|
|
1275
|
+
const token = reuseLock ? existingToken : await this.lockManager.acquire(agentId, taskId);
|
|
1276
|
+
if (!token)
|
|
1277
|
+
return false;
|
|
1133
1278
|
const now = new Date().toISOString();
|
|
1134
1279
|
await this.agentStore.update(agentId, (existing) => ({
|
|
1135
1280
|
...(existing ?? { id: agentId, projectId: cfg.projectId, updatedAt: now }),
|
|
1136
1281
|
id: agentId,
|
|
1137
1282
|
projectId: cfg.projectId,
|
|
1138
1283
|
taskId,
|
|
1284
|
+
lockToken: token,
|
|
1139
1285
|
updatedAt: now,
|
|
1140
1286
|
}));
|
|
1141
1287
|
return true;
|
|
@@ -1168,6 +1314,11 @@ export class AgentManager {
|
|
|
1168
1314
|
const cfg = this.getAgentConfig(agentId);
|
|
1169
1315
|
if (!cfg)
|
|
1170
1316
|
return false;
|
|
1317
|
+
const lockToken = await this.resolveTaskLockToken(state, expectedTaskId);
|
|
1318
|
+
if (!lockToken) {
|
|
1319
|
+
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} no longer owns task ${expectedTaskId}; skipping`);
|
|
1320
|
+
return false;
|
|
1321
|
+
}
|
|
1171
1322
|
const now = new Date().toISOString();
|
|
1172
1323
|
if (mode === 'waiting') {
|
|
1173
1324
|
await this.agentStore.update(agentId, (latest) => {
|
|
@@ -1179,9 +1330,9 @@ export class AgentManager {
|
|
|
1179
1330
|
projectId: latest.projectId,
|
|
1180
1331
|
updatedAt: now,
|
|
1181
1332
|
...(latest.taskId !== undefined ? { taskId: latest.taskId } : {}),
|
|
1333
|
+
...(latest.lockToken !== undefined ? { lockToken: latest.lockToken } : {}),
|
|
1182
1334
|
...(latest.paneId !== undefined ? { paneId: latest.paneId } : {}),
|
|
1183
|
-
...(latest.
|
|
1184
|
-
...(latest.worktreePath !== undefined ? { worktreePath: latest.worktreePath } : {}),
|
|
1335
|
+
...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
|
|
1185
1336
|
...(latest.startedAt !== undefined ? { startedAt: latest.startedAt } : {}),
|
|
1186
1337
|
...(latest.creationToken !== undefined ? { creationToken: latest.creationToken } : {}),
|
|
1187
1338
|
};
|
|
@@ -1194,19 +1345,118 @@ export class AgentManager {
|
|
|
1194
1345
|
});
|
|
1195
1346
|
return true;
|
|
1196
1347
|
}
|
|
1197
|
-
if (state.
|
|
1198
|
-
const
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
const
|
|
1202
|
-
|
|
1203
|
-
|
|
1348
|
+
if (state.workdir) {
|
|
1349
|
+
const runner = this.createRunnerFor(cfg);
|
|
1350
|
+
const tmux = new TmuxManager(runner);
|
|
1351
|
+
const hold = async (reason) => {
|
|
1352
|
+
const pendingAt = new Date().toISOString();
|
|
1353
|
+
if (boundTask && cfg.role === 'dev') {
|
|
1354
|
+
boundTask.branchCleanupPending = { agentId, reason, updatedAt: pendingAt };
|
|
1355
|
+
boundTask.updatedAt = pendingAt;
|
|
1356
|
+
await this.taskStore.set(boundTask);
|
|
1357
|
+
}
|
|
1358
|
+
await this.agentStore.update(agentId, (latest) => {
|
|
1359
|
+
if (!latest || latest.taskId !== expectedTaskId || latest.lockToken !== lockToken) {
|
|
1360
|
+
return AGENT_STORE_NOOP;
|
|
1361
|
+
}
|
|
1362
|
+
return {
|
|
1363
|
+
...latest,
|
|
1364
|
+
status: 'awaiting_human',
|
|
1365
|
+
awaitingPhase: 'branch-cleanup-pending',
|
|
1366
|
+
awaitingReason: reason,
|
|
1367
|
+
awaitingSince: pendingAt,
|
|
1368
|
+
updatedAt: pendingAt,
|
|
1369
|
+
};
|
|
1370
|
+
});
|
|
1371
|
+
await this.emitIntervention(state.projectId, agentId, expectedTaskId, {
|
|
1372
|
+
phase: 'branch-cleanup-pending',
|
|
1373
|
+
reason,
|
|
1374
|
+
});
|
|
1375
|
+
return false;
|
|
1376
|
+
};
|
|
1377
|
+
try {
|
|
1378
|
+
await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
|
|
1379
|
+
const paneId = await this.resolvePaneId(state, cfg);
|
|
1380
|
+
if (paneId) {
|
|
1381
|
+
await this.waitForReplPromptReady(tmux, paneId, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
|
|
1382
|
+
}
|
|
1383
|
+
else {
|
|
1384
|
+
let sessionAlive;
|
|
1385
|
+
try {
|
|
1386
|
+
sessionAlive = await tmux.hasSession(agentId);
|
|
1387
|
+
}
|
|
1388
|
+
catch (err) {
|
|
1389
|
+
return hold(`Runtime availability probe failed; refusing checkout cleanup: ` +
|
|
1390
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
1391
|
+
}
|
|
1392
|
+
if (sessionAlive) {
|
|
1393
|
+
return hold('Runtime session still exists but its pane is unavailable; refusing checkout cleanup');
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
|
|
1397
|
+
if (cfg.role === 'dev' && boundTask) {
|
|
1398
|
+
const branches = new BranchManager(runner);
|
|
1399
|
+
const cleanup = await branches.cleanupTaskBranch(state.workdir, {
|
|
1400
|
+
taskId: boundTask.id,
|
|
1401
|
+
taskBranch: boundTask.branch,
|
|
1402
|
+
branchCreatedByBaxian: boundTask.branchCreatedByBaxian,
|
|
1403
|
+
}, () => this.assertTaskLockOwner(agentId, expectedTaskId, lockToken));
|
|
1404
|
+
let cleanupStateChanged = false;
|
|
1405
|
+
if (cleanup.status === 'pending') {
|
|
1406
|
+
if (boundTask.branchCleanupPending?.agentId !== agentId
|
|
1407
|
+
|| boundTask.branchCleanupPending.reason !== cleanup.reason
|
|
1408
|
+
|| boundTask.branchCleanupSkipped !== undefined) {
|
|
1409
|
+
boundTask.branchCleanupPending = {
|
|
1410
|
+
agentId,
|
|
1411
|
+
reason: cleanup.reason,
|
|
1412
|
+
updatedAt: new Date().toISOString(),
|
|
1413
|
+
};
|
|
1414
|
+
delete boundTask.branchCleanupSkipped;
|
|
1415
|
+
cleanupStateChanged = true;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
else if (cleanup.status === 'skipped') {
|
|
1419
|
+
if (boundTask.branchCleanupSkipped?.agentId !== agentId
|
|
1420
|
+
|| boundTask.branchCleanupSkipped.reason !== cleanup.reason
|
|
1421
|
+
|| boundTask.branchCleanupPending !== undefined) {
|
|
1422
|
+
boundTask.branchCleanupSkipped = {
|
|
1423
|
+
agentId,
|
|
1424
|
+
reason: cleanup.reason,
|
|
1425
|
+
updatedAt: new Date().toISOString(),
|
|
1426
|
+
};
|
|
1427
|
+
delete boundTask.branchCleanupPending;
|
|
1428
|
+
cleanupStateChanged = true;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
else {
|
|
1432
|
+
if (boundTask.branchCleanupPending?.agentId === agentId) {
|
|
1433
|
+
delete boundTask.branchCleanupPending;
|
|
1434
|
+
cleanupStateChanged = true;
|
|
1435
|
+
}
|
|
1436
|
+
if (boundTask.branchCleanupSkipped?.agentId === agentId) {
|
|
1437
|
+
delete boundTask.branchCleanupSkipped;
|
|
1438
|
+
cleanupStateChanged = true;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
if (cleanupStateChanged) {
|
|
1442
|
+
boundTask.updatedAt = new Date().toISOString();
|
|
1443
|
+
await this.taskStore.set(boundTask);
|
|
1444
|
+
}
|
|
1445
|
+
if (cleanup.status !== 'deleted')
|
|
1446
|
+
await branches.parkOnDefaultDetached(state.workdir);
|
|
1204
1447
|
}
|
|
1205
|
-
|
|
1206
|
-
|
|
1448
|
+
else {
|
|
1449
|
+
await new BranchManager(runner).parkOnDefaultDetached(state.workdir);
|
|
1207
1450
|
}
|
|
1208
1451
|
}
|
|
1452
|
+
catch (err) {
|
|
1453
|
+
const reason = err instanceof DirtyWorkdirError
|
|
1454
|
+
? err.message
|
|
1455
|
+
: `checkout cleanup failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
1456
|
+
return hold(reason);
|
|
1457
|
+
}
|
|
1209
1458
|
}
|
|
1459
|
+
await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
|
|
1210
1460
|
await this.agentStore.update(agentId, (existing) => {
|
|
1211
1461
|
if (!existing)
|
|
1212
1462
|
return AGENT_STORE_NOOP;
|
|
@@ -1215,16 +1465,172 @@ export class AgentManager {
|
|
|
1215
1465
|
return {
|
|
1216
1466
|
id: existing.id,
|
|
1217
1467
|
projectId: existing.projectId,
|
|
1218
|
-
...(existing.
|
|
1468
|
+
...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
1219
1469
|
...(existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
1220
1470
|
...(existing.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
1221
1471
|
updatedAt: now,
|
|
1222
1472
|
};
|
|
1223
1473
|
});
|
|
1224
|
-
|
|
1225
|
-
return true;
|
|
1474
|
+
return this.lockManager.releaseIfOwner(agentId, expectedTaskId, lockToken);
|
|
1226
1475
|
});
|
|
1227
1476
|
}
|
|
1477
|
+
async reconcileTaskBranches() {
|
|
1478
|
+
const tasks = await this.taskStore.list();
|
|
1479
|
+
const terminalByBranch = new Map(tasks
|
|
1480
|
+
.filter(task => task.branchCreatedByBaxian === true
|
|
1481
|
+
&& TERMINAL_STATUSES.includes(task.status)
|
|
1482
|
+
&& task.branch === `${BRANCH_PREFIX}${task.id}`
|
|
1483
|
+
&& task.branchCleanupSkipped === undefined)
|
|
1484
|
+
.map(task => [task.branch, task]));
|
|
1485
|
+
const owner = 'maintenance:branch-reconcile';
|
|
1486
|
+
const persistCleanup = async (task, agentId, cleanup) => {
|
|
1487
|
+
await this.withTaskLock(async () => {
|
|
1488
|
+
const latest = await this.taskStore.get(task.id);
|
|
1489
|
+
if (!latest
|
|
1490
|
+
|| latest.branch !== task.branch
|
|
1491
|
+
|| latest.branchCreatedByBaxian !== true
|
|
1492
|
+
|| !TERMINAL_STATUSES.includes(latest.status))
|
|
1493
|
+
return;
|
|
1494
|
+
const now = new Date().toISOString();
|
|
1495
|
+
let changed = false;
|
|
1496
|
+
if (cleanup.status === 'pending') {
|
|
1497
|
+
if (latest.branchCleanupPending?.agentId !== agentId
|
|
1498
|
+
|| latest.branchCleanupPending.reason !== cleanup.reason
|
|
1499
|
+
|| latest.branchCleanupSkipped !== undefined) {
|
|
1500
|
+
latest.branchCleanupPending = { agentId, reason: cleanup.reason, updatedAt: now };
|
|
1501
|
+
delete latest.branchCleanupSkipped;
|
|
1502
|
+
changed = true;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
else if (cleanup.status === 'skipped') {
|
|
1506
|
+
if (latest.branchCleanupSkipped?.agentId !== agentId
|
|
1507
|
+
|| latest.branchCleanupSkipped.reason !== cleanup.reason
|
|
1508
|
+
|| latest.branchCleanupPending !== undefined) {
|
|
1509
|
+
latest.branchCleanupSkipped = { agentId, reason: cleanup.reason, updatedAt: now };
|
|
1510
|
+
delete latest.branchCleanupPending;
|
|
1511
|
+
changed = true;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
else {
|
|
1515
|
+
if (latest.branchCleanupPending !== undefined) {
|
|
1516
|
+
delete latest.branchCleanupPending;
|
|
1517
|
+
changed = true;
|
|
1518
|
+
}
|
|
1519
|
+
if (latest.branchCleanupSkipped !== undefined) {
|
|
1520
|
+
delete latest.branchCleanupSkipped;
|
|
1521
|
+
changed = true;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
if (!changed)
|
|
1525
|
+
return;
|
|
1526
|
+
latest.updatedAt = now;
|
|
1527
|
+
await this.taskStore.set(latest);
|
|
1528
|
+
});
|
|
1529
|
+
};
|
|
1530
|
+
for (const cfg of this.agentIndex.values()) {
|
|
1531
|
+
if (cfg.role !== 'dev')
|
|
1532
|
+
continue;
|
|
1533
|
+
const candidateTasks = [...terminalByBranch.values()].filter(task => task.agentId === cfg.id || task.branchCleanupPending?.agentId === cfg.id);
|
|
1534
|
+
if (candidateTasks.length === 0)
|
|
1535
|
+
continue;
|
|
1536
|
+
const candidateByBranch = new Map(candidateTasks.map(task => [task.branch, task]));
|
|
1537
|
+
const state = await this.agentStore.get(cfg.id);
|
|
1538
|
+
if (!state || !canDispatchWithBinding(state))
|
|
1539
|
+
continue;
|
|
1540
|
+
const workdir = state.workdir ?? cfg.workdir;
|
|
1541
|
+
if (!workdir)
|
|
1542
|
+
continue;
|
|
1543
|
+
const runner = this.createRunnerFor(cfg);
|
|
1544
|
+
const branches = new BranchManager(runner);
|
|
1545
|
+
let refs;
|
|
1546
|
+
try {
|
|
1547
|
+
refs = await branches.listLocalTaskRefs(workdir);
|
|
1548
|
+
}
|
|
1549
|
+
catch (err) {
|
|
1550
|
+
console.warn(`[AgentManager] reconcileTaskBranches(${cfg.id}) local ref preflight failed: ` +
|
|
1551
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
1552
|
+
continue;
|
|
1553
|
+
}
|
|
1554
|
+
if (!refs.some(ref => candidateByBranch.has(ref.slice('refs/heads/'.length))))
|
|
1555
|
+
continue;
|
|
1556
|
+
const token = await this.lockManager.acquire(cfg.id, owner);
|
|
1557
|
+
if (!token)
|
|
1558
|
+
continue;
|
|
1559
|
+
const cleanupResults = [];
|
|
1560
|
+
try {
|
|
1561
|
+
const lockedState = await this.agentStore.get(cfg.id);
|
|
1562
|
+
if (!lockedState || !canDispatchWithBinding(lockedState))
|
|
1563
|
+
continue;
|
|
1564
|
+
if (!(await this.lockManager.isOwner(cfg.id, owner, token)))
|
|
1565
|
+
continue;
|
|
1566
|
+
refs = await branches.listLocalTaskRefs(workdir);
|
|
1567
|
+
refs = refs.filter(ref => candidateByBranch.has(ref.slice('refs/heads/'.length)));
|
|
1568
|
+
if (refs.length === 0)
|
|
1569
|
+
continue;
|
|
1570
|
+
const tmux = new TmuxManager(runner);
|
|
1571
|
+
if (await tmux.hasSession(cfg.id)) {
|
|
1572
|
+
const claim = await tmux.getOption(cfg.id, '@baxian-agent-id');
|
|
1573
|
+
if (claim !== cfg.id) {
|
|
1574
|
+
throw new Error(`tmux session claim mismatch for ${cfg.id}: ${claim ?? '<missing>'}`);
|
|
1575
|
+
}
|
|
1576
|
+
const paneId = lockedState.paneId ?? await tmux.getSinglePaneId(cfg.id);
|
|
1577
|
+
await this.waitForReplPromptReady(tmux, paneId, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
|
|
1578
|
+
}
|
|
1579
|
+
if (!(await this.lockManager.isOwner(cfg.id, owner, token)))
|
|
1580
|
+
continue;
|
|
1581
|
+
for (const actualRef of refs) {
|
|
1582
|
+
const branch = actualRef.slice('refs/heads/'.length);
|
|
1583
|
+
const task = candidateByBranch.get(branch);
|
|
1584
|
+
if (!task)
|
|
1585
|
+
continue;
|
|
1586
|
+
const cleanup = await branches.cleanupTaskBranch(workdir, {
|
|
1587
|
+
taskId: task.id,
|
|
1588
|
+
taskBranch: task.branch,
|
|
1589
|
+
branchCreatedByBaxian: task.branchCreatedByBaxian,
|
|
1590
|
+
}, () => this.assertTaskLockOwner(cfg.id, owner, token));
|
|
1591
|
+
cleanupResults.push({ task, cleanup });
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
catch (err) {
|
|
1595
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1596
|
+
if (err instanceof DirtyWorkdirError) {
|
|
1597
|
+
await this.markAwaitingHuman(cfg.id, 'dirty-workdir', message, { expectedTaskId: null });
|
|
1598
|
+
}
|
|
1599
|
+
else if (err instanceof ReplNotReadyError || message.includes('stayed busy past')) {
|
|
1600
|
+
for (const actualRef of refs) {
|
|
1601
|
+
const task = candidateByBranch.get(actualRef.slice('refs/heads/'.length));
|
|
1602
|
+
if (!task)
|
|
1603
|
+
continue;
|
|
1604
|
+
cleanupResults.push({
|
|
1605
|
+
task,
|
|
1606
|
+
cleanup: {
|
|
1607
|
+
status: 'pending',
|
|
1608
|
+
reason: `runtime is not idle; local branch cleanup deferred: ${message}`,
|
|
1609
|
+
},
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
else {
|
|
1614
|
+
console.warn(`[AgentManager] reconcileTaskBranches(${cfg.id}) failed: ${message}`);
|
|
1615
|
+
await this.recordError({
|
|
1616
|
+
agentId: cfg.id,
|
|
1617
|
+
projectId: cfg.projectId,
|
|
1618
|
+
operation: 'branch-reconcile',
|
|
1619
|
+
reason: 'BRANCH_RECONCILE_FAILED',
|
|
1620
|
+
message,
|
|
1621
|
+
observation: { phase: 'branch-reconcile' },
|
|
1622
|
+
recommendation: 'Verify the Workdir and remote, then let the next reconciliation retry.',
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
finally {
|
|
1627
|
+
await this.lockManager.releaseIfOwner(cfg.id, owner, token);
|
|
1628
|
+
}
|
|
1629
|
+
for (const result of cleanupResults) {
|
|
1630
|
+
await persistCleanup(result.task, cfg.id, result.cleanup);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1228
1634
|
async setAgentNeedInput(agentId, pending, opts = {}) {
|
|
1229
1635
|
const now = new Date().toISOString();
|
|
1230
1636
|
try {
|
|
@@ -1377,6 +1783,13 @@ export class AgentManager {
|
|
|
1377
1783
|
console.warn(`[AgentManager] resumeAgent: agent ${agentId} — ${reason}`);
|
|
1378
1784
|
return { resumed: false, releasedBinding: false, reason };
|
|
1379
1785
|
}
|
|
1786
|
+
if ((state.awaitingPhase === 'dirty-workdir' || state.awaitingPhase === 'checkout-preparation-failed')
|
|
1787
|
+
&& boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)) {
|
|
1788
|
+
const reason = `Task ${state.taskId} was not dispatched because its checkout could not be prepared. ` +
|
|
1789
|
+
'Fix the Workdir, then cancel and redispatch the task; Resume cannot reconstruct the original dispatch safely.';
|
|
1790
|
+
console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${state.awaitingPhase} — ${reason}`);
|
|
1791
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
1792
|
+
}
|
|
1380
1793
|
if (state.awaitingPhase === 'code-dispatch-failed'
|
|
1381
1794
|
&& boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)
|
|
1382
1795
|
&& state.taskId) {
|
|
@@ -1408,19 +1821,14 @@ export class AgentManager {
|
|
|
1408
1821
|
}
|
|
1409
1822
|
const now = new Date().toISOString();
|
|
1410
1823
|
const shouldReleaseBinding = shouldReleaseHeldBinding(state, boundTask);
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
}
|
|
1420
|
-
catch (err) {
|
|
1421
|
-
console.warn(`[AgentManager] resumeAgent worktree.remove failed for ${state.worktreePath}:`, err);
|
|
1422
|
-
}
|
|
1423
|
-
}
|
|
1824
|
+
if (shouldReleaseBinding && state.taskId) {
|
|
1825
|
+
return {
|
|
1826
|
+
resumed: true,
|
|
1827
|
+
releasedBinding: false,
|
|
1828
|
+
releaseTaskId: state.taskId,
|
|
1829
|
+
projectId: state.projectId,
|
|
1830
|
+
previousPhase: state.awaitingPhase,
|
|
1831
|
+
};
|
|
1424
1832
|
}
|
|
1425
1833
|
await this.agentStore.update(agentId, (existing) => {
|
|
1426
1834
|
if (!existing)
|
|
@@ -1429,7 +1837,7 @@ export class AgentManager {
|
|
|
1429
1837
|
id: existing.id,
|
|
1430
1838
|
projectId: existing.projectId,
|
|
1431
1839
|
updatedAt: now,
|
|
1432
|
-
...(existing.
|
|
1840
|
+
...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
1433
1841
|
...(existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
1434
1842
|
...(existing.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
1435
1843
|
status: 'ok',
|
|
@@ -1437,23 +1845,41 @@ export class AgentManager {
|
|
|
1437
1845
|
if (!shouldReleaseBinding) {
|
|
1438
1846
|
if (existing.taskId !== undefined)
|
|
1439
1847
|
next.taskId = existing.taskId;
|
|
1440
|
-
if (existing.
|
|
1441
|
-
next.
|
|
1848
|
+
if (existing.lockToken !== undefined)
|
|
1849
|
+
next.lockToken = existing.lockToken;
|
|
1442
1850
|
if (existing.startedAt !== undefined)
|
|
1443
1851
|
next.startedAt = existing.startedAt;
|
|
1444
1852
|
}
|
|
1445
1853
|
return next;
|
|
1446
1854
|
});
|
|
1447
|
-
if (shouldReleaseBinding) {
|
|
1448
|
-
await this.lockManager.release(agentId);
|
|
1449
|
-
}
|
|
1450
1855
|
await this.emitIntervention(state.projectId, agentId, state.taskId, {
|
|
1451
1856
|
phase: 'resumed',
|
|
1452
1857
|
previousPhase: state.awaitingPhase,
|
|
1453
|
-
releasedBinding:
|
|
1858
|
+
releasedBinding: false,
|
|
1454
1859
|
});
|
|
1455
|
-
return { resumed: true, releasedBinding:
|
|
1860
|
+
return { resumed: true, releasedBinding: false };
|
|
1456
1861
|
});
|
|
1862
|
+
if (result.releaseTaskId) {
|
|
1863
|
+
const released = await this.releaseAgentForTask(agentId, result.releaseTaskId, 'idle', {
|
|
1864
|
+
allowAwaitingHuman: true,
|
|
1865
|
+
fromCancelCleanup: true,
|
|
1866
|
+
});
|
|
1867
|
+
if (!released) {
|
|
1868
|
+
const refreshed = await this.agentStore.get(agentId);
|
|
1869
|
+
return {
|
|
1870
|
+
resumed: false,
|
|
1871
|
+
releasedBinding: false,
|
|
1872
|
+
reason: refreshed?.awaitingReason
|
|
1873
|
+
?? `Agent ${agentId} could not safely release task ${result.releaseTaskId}.`,
|
|
1874
|
+
};
|
|
1875
|
+
}
|
|
1876
|
+
await this.emitIntervention(result.projectId, agentId, result.releaseTaskId, {
|
|
1877
|
+
phase: 'resumed',
|
|
1878
|
+
previousPhase: result.previousPhase,
|
|
1879
|
+
releasedBinding: true,
|
|
1880
|
+
});
|
|
1881
|
+
return { resumed: true, releasedBinding: true };
|
|
1882
|
+
}
|
|
1457
1883
|
if (result.redispatchCodeTaskId) {
|
|
1458
1884
|
try {
|
|
1459
1885
|
const resumed = await this.continueSession(result.redispatchCodeTaskId, agentId, 'code');
|
|
@@ -1658,7 +2084,7 @@ export class AgentManager {
|
|
|
1658
2084
|
const out = [];
|
|
1659
2085
|
for (const t of tasks) {
|
|
1660
2086
|
const bound = t.agentId === agentId || t.qaAgentId === agentId;
|
|
1661
|
-
// spec-ready 不豁免:approve/打回都依赖 dev
|
|
2087
|
+
// spec-ready 不豁免:approve/打回都依赖 dev Workdir,dev 丢失后停驻只是假活
|
|
1662
2088
|
if (t.status === 'ready' || t.status === 'merge-ready')
|
|
1663
2089
|
continue;
|
|
1664
2090
|
if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
|
|
@@ -1738,28 +2164,26 @@ export class AgentManager {
|
|
|
1738
2164
|
const paneId = await tmux.getSinglePaneId(agentId);
|
|
1739
2165
|
await tmux.sendKeysToPane(paneId, 'C-c');
|
|
1740
2166
|
const cmd = await this.pollPaneCommandStable(tmux, paneId, { timeoutMs: 2_000 });
|
|
1741
|
-
const RUNTIME = /^(?:claude|codex|node
|
|
2167
|
+
const RUNTIME = /^(?:claude|codex|node|opencode|qodercli(?:-[\d.]+)?|\d+\.\d+\.\d+)$/;
|
|
1742
2168
|
const SHELL = /^(?:zsh|bash|sh|fish)$/;
|
|
1743
2169
|
if (RUNTIME.test(cmd)) {
|
|
1744
|
-
await tmux.sendKeysToPane(paneId,
|
|
2170
|
+
await tmux.sendKeysToPane(paneId, REPL_EXIT_COMMAND[cfg.runtime], 'Enter');
|
|
1745
2171
|
await this.pollPaneCommandStable(tmux, paneId, { timeoutMs: 2_000, expectShell: true });
|
|
1746
2172
|
}
|
|
1747
2173
|
else if (!SHELL.test(cmd)) {
|
|
1748
2174
|
throw new Error(`restart-repl precondition failed: unexpected pane state "${cmd}"`);
|
|
1749
2175
|
}
|
|
1750
2176
|
const project = this.getProjectConfig(cfg.projectId);
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
}
|
|
1762
|
-
}
|
|
2177
|
+
if (!project)
|
|
2178
|
+
throw new Error(`restart-repl: project ${cfg.projectId} does not exist`);
|
|
2179
|
+
const workdir = (await this.ensureWorkdir(cfg, project, runner)).workdir;
|
|
2180
|
+
const paneWorkdir = await tmux.getPaneCurrentPath(paneId);
|
|
2181
|
+
if (!await sameDirOnHost(runner, paneWorkdir, workdir)) {
|
|
2182
|
+
throw new Error(`restart-repl: pane Workdir ${paneWorkdir} does not match agent Workdir ${workdir}; ` +
|
|
2183
|
+
'use retry to rebuild the session safely');
|
|
2184
|
+
}
|
|
2185
|
+
await this.provisionRepoSkills(runner, cfg, workdir);
|
|
2186
|
+
await tmux.setOption(agentId, WORKDIR_SESSION_OPTION, workdir);
|
|
1763
2187
|
const runtime = agentRuntimeKindFor(cfg);
|
|
1764
2188
|
const relaunch = async () => {
|
|
1765
2189
|
await tmux.sendKeysToPane(paneId, `${buildLaunchCommand(cfg)}\n`);
|
|
@@ -1770,16 +2194,9 @@ export class AgentManager {
|
|
|
1770
2194
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
1771
2195
|
scrollback: 0,
|
|
1772
2196
|
});
|
|
1773
|
-
|
|
1774
|
-
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
1775
|
-
}
|
|
2197
|
+
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
1776
2198
|
};
|
|
1777
|
-
|
|
1778
|
-
await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
|
|
1779
|
-
}
|
|
1780
|
-
else {
|
|
1781
|
-
await relaunch();
|
|
1782
|
-
}
|
|
2199
|
+
await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
|
|
1783
2200
|
await this.agentStore.update(agentId, (state) => {
|
|
1784
2201
|
if (!state)
|
|
1785
2202
|
return AGENT_STORE_NOOP;
|
|
@@ -1815,7 +2232,21 @@ export class AgentManager {
|
|
|
1815
2232
|
continue;
|
|
1816
2233
|
const runner = this.createRunnerFor(cfg);
|
|
1817
2234
|
const tmux = new TmuxManager(runner);
|
|
1818
|
-
const
|
|
2235
|
+
const boundState = await this.agentStore.get(id);
|
|
2236
|
+
if (boundState?.taskId) {
|
|
2237
|
+
try {
|
|
2238
|
+
const released = await this.releaseAgentForTask(id, boundState.taskId, 'idle', {
|
|
2239
|
+
allowAwaitingHuman: true,
|
|
2240
|
+
});
|
|
2241
|
+
if (!released) {
|
|
2242
|
+
throw new Error(`could not safely release task ${boundState.taskId}`);
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
catch (err) {
|
|
2246
|
+
failures.push({ agentId: id, step: 'binding.release', error: err });
|
|
2247
|
+
continue;
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
1819
2250
|
this.stopRuntimeMenuWatch(id);
|
|
1820
2251
|
if (this.paneStreamerManager) {
|
|
1821
2252
|
try {
|
|
@@ -1841,18 +2272,6 @@ export class AgentManager {
|
|
|
1841
2272
|
catch (err) {
|
|
1842
2273
|
failures.push({ agentId: id, step: 'tmux', error: err });
|
|
1843
2274
|
}
|
|
1844
|
-
const state = await this.agentStore.get(id);
|
|
1845
|
-
if (state?.worktreePath) {
|
|
1846
|
-
const cleanupDir = this.resolveWorkdir(cfg, state);
|
|
1847
|
-
if (cleanupDir) {
|
|
1848
|
-
try {
|
|
1849
|
-
await worktree.remove(cleanupDir, state.worktreePath);
|
|
1850
|
-
}
|
|
1851
|
-
catch (err) {
|
|
1852
|
-
failures.push({ agentId: id, step: 'worktree.remove', error: err });
|
|
1853
|
-
}
|
|
1854
|
-
}
|
|
1855
|
-
}
|
|
1856
2275
|
}
|
|
1857
2276
|
if (failures.length > 0) {
|
|
1858
2277
|
const summary = failures
|
|
@@ -1885,7 +2304,6 @@ export class AgentManager {
|
|
|
1885
2304
|
workdirForEstimate = cfg.workdir;
|
|
1886
2305
|
}
|
|
1887
2306
|
const workdirGuess = workdirForEstimate ?? '/'.padEnd(64, 'x');
|
|
1888
|
-
const worktreePathBound = `${workdirGuess}/.baxian-worktrees/task-9999999999_ffffffffffffffff`;
|
|
1889
2307
|
const now = new Date().toISOString();
|
|
1890
2308
|
const fakeTask = {
|
|
1891
2309
|
id: 'task-9999999999',
|
|
@@ -1904,7 +2322,7 @@ export class AgentManager {
|
|
|
1904
2322
|
task: fakeTask,
|
|
1905
2323
|
phase: 'develop',
|
|
1906
2324
|
agent: cfg,
|
|
1907
|
-
|
|
2325
|
+
workdir: workdirGuess,
|
|
1908
2326
|
skillRegistry: this.skillRegistry,
|
|
1909
2327
|
signalToken: 'preview-signal-token',
|
|
1910
2328
|
});
|
|
@@ -2035,7 +2453,7 @@ export class AgentManager {
|
|
|
2035
2453
|
const allowRecoveredReadFile = task.status === 'review'
|
|
2036
2454
|
&& (task.phase === 'spec'
|
|
2037
2455
|
|| (task.reviewMode === 'server' && task.batchTotal !== undefined)
|
|
2038
|
-
|| (task.reviewMode === 'server' && task.
|
|
2456
|
+
|| (task.reviewMode === 'server' && task.reviewCheckoutMode === 'base'));
|
|
2039
2457
|
try {
|
|
2040
2458
|
await this.startPhaseSignalWatch({
|
|
2041
2459
|
taskId: task.id,
|
|
@@ -2153,8 +2571,11 @@ export class AgentManager {
|
|
|
2153
2571
|
return this.taskStore.list({ projectId });
|
|
2154
2572
|
}
|
|
2155
2573
|
async findTaskByBranch(branch, projectId) {
|
|
2156
|
-
const
|
|
2157
|
-
|
|
2574
|
+
const repoKey = this.projectRepoKey(projectId);
|
|
2575
|
+
if (!repoKey)
|
|
2576
|
+
return undefined;
|
|
2577
|
+
const all = await this.taskStore.list();
|
|
2578
|
+
return all.find(t => t.branch === branch && this.projectRepoKey(t.projectId) === repoKey);
|
|
2158
2579
|
}
|
|
2159
2580
|
async listTasksByPrNumber(prNumber, projectId) {
|
|
2160
2581
|
const all = await this.taskStore.list({ projectId });
|
|
@@ -2221,13 +2642,11 @@ export class AgentManager {
|
|
|
2221
2642
|
createRepoStore(agent, project, runner) {
|
|
2222
2643
|
const host = resolveAgentHost(this.config.host, agent.host);
|
|
2223
2644
|
if (this.repoStoreFactory) {
|
|
2224
|
-
return this.repoStoreFactory(runner,
|
|
2645
|
+
return this.repoStoreFactory(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir);
|
|
2225
2646
|
}
|
|
2226
|
-
return new RepoStore(runner, project.repo, agent.mode, host, this.repoCache);
|
|
2647
|
+
return new RepoStore(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir);
|
|
2227
2648
|
}
|
|
2228
2649
|
async ensureWorkdir(agent, project, runner) {
|
|
2229
|
-
if (agent.workdir)
|
|
2230
|
-
return { workdir: agent.workdir, repoStore: null };
|
|
2231
2650
|
const repoStore = this.createRepoStore(agent, project, runner);
|
|
2232
2651
|
const workdir = await repoStore.ensure();
|
|
2233
2652
|
return { workdir, repoStore };
|
|
@@ -2235,22 +2654,29 @@ export class AgentManager {
|
|
|
2235
2654
|
resolveWorkdir(agent, agentState) {
|
|
2236
2655
|
if (agent.workdir)
|
|
2237
2656
|
return agent.workdir;
|
|
2238
|
-
return agentState?.
|
|
2239
|
-
}
|
|
2240
|
-
async resolveAutoBaseRef(runner, workdir) {
|
|
2241
|
-
const result = await runner.exec(`git -C ${shellQuote(workdir)} rev-parse --verify --quiet origin/HEAD`);
|
|
2242
|
-
if (result.exitCode !== 0) {
|
|
2243
|
-
// Falling back to the shared clone's HEAD would seed the worktree with whatever
|
|
2244
|
-
// another agent left checked out — refuse instead of silently cross-contaminating.
|
|
2245
|
-
throw new Error(`origin/HEAD is unresolvable in ${workdir}; ` +
|
|
2246
|
-
`run "git -C ${workdir} remote set-head origin --auto" and redispatch`);
|
|
2247
|
-
}
|
|
2248
|
-
return 'origin/HEAD';
|
|
2657
|
+
return agentState?.workdir ?? null;
|
|
2249
2658
|
}
|
|
2250
2659
|
getRepoCache() {
|
|
2251
2660
|
return this.repoCache;
|
|
2252
2661
|
}
|
|
2253
|
-
async rollbackFailedDispatch(taskId, agentId, reason) {
|
|
2662
|
+
async rollbackFailedDispatch(taskId, agentId, reason, expectedLockToken) {
|
|
2663
|
+
const existing = await this.agentStore.get(agentId);
|
|
2664
|
+
if (existing && existing.taskId !== undefined && existing.taskId !== taskId) {
|
|
2665
|
+
console.warn(`[AgentManager] rollback: agent ${agentId} taskId mismatch (expected ${taskId}, got ${existing.taskId}); ` +
|
|
2666
|
+
`skipping rollback — agent already reassigned`);
|
|
2667
|
+
return;
|
|
2668
|
+
}
|
|
2669
|
+
if (isCancelCleanupHold(existing)) {
|
|
2670
|
+
console.warn(`[AgentManager] rollback: agent ${agentId} held by cancel cleanup (${existing?.awaitingPhase}); ` +
|
|
2671
|
+
`leaving binding to the owner`);
|
|
2672
|
+
return;
|
|
2673
|
+
}
|
|
2674
|
+
if (!expectedLockToken
|
|
2675
|
+
|| !(await this.lockManager.isOwner(agentId, taskId, expectedLockToken))
|
|
2676
|
+
|| (existing?.taskId === taskId && existing.lockToken !== expectedLockToken)) {
|
|
2677
|
+
console.warn(`[AgentManager] rollback: generation mismatch for ${agentId}/${taskId}; skipping stale rollback`);
|
|
2678
|
+
return;
|
|
2679
|
+
}
|
|
2254
2680
|
let rolledBack = null;
|
|
2255
2681
|
await this.withTaskLock(async () => {
|
|
2256
2682
|
const task = await this.taskStore.get(taskId);
|
|
@@ -2271,17 +2697,7 @@ export class AgentManager {
|
|
|
2271
2697
|
note: 'Dispatch failed; the task is back in pending — re-dispatch it once the cause is resolved.',
|
|
2272
2698
|
});
|
|
2273
2699
|
}
|
|
2274
|
-
const
|
|
2275
|
-
if (existing && existing.taskId !== taskId) {
|
|
2276
|
-
console.warn(`[AgentManager] rollback: agent ${agentId} taskId mismatch (expected ${taskId}, got ${existing.taskId}); ` +
|
|
2277
|
-
`skipping agent cleanup — agent already reassigned`);
|
|
2278
|
-
return;
|
|
2279
|
-
}
|
|
2280
|
-
if (isCancelCleanupHold(existing)) {
|
|
2281
|
-
console.warn(`[AgentManager] rollback: agent ${agentId} held by cancel cleanup (${existing?.awaitingPhase}); ` +
|
|
2282
|
-
`leaving binding to the owner`);
|
|
2283
|
-
return;
|
|
2284
|
-
}
|
|
2700
|
+
const lockToken = expectedLockToken;
|
|
2285
2701
|
const projectId = existing?.projectId ?? this.getAgentConfig(agentId)?.projectId;
|
|
2286
2702
|
if (!projectId) {
|
|
2287
2703
|
console.error(`[AgentManager] CRITICAL: cannot resolve projectId for agent ${agentId} during rollback; deleting agent state.`);
|
|
@@ -2289,16 +2705,23 @@ export class AgentManager {
|
|
|
2289
2705
|
}
|
|
2290
2706
|
else {
|
|
2291
2707
|
const now = new Date().toISOString();
|
|
2292
|
-
await this.agentStore.update(agentId, (latest) =>
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2708
|
+
await this.agentStore.update(agentId, (latest) => {
|
|
2709
|
+
if (latest?.taskId === taskId && latest.lockToken !== expectedLockToken)
|
|
2710
|
+
return AGENT_STORE_NOOP;
|
|
2711
|
+
if (latest?.taskId !== undefined && latest.taskId !== taskId)
|
|
2712
|
+
return AGENT_STORE_NOOP;
|
|
2713
|
+
return {
|
|
2714
|
+
...(latest ?? existing ?? { id: agentId, projectId, updatedAt: now }),
|
|
2715
|
+
id: agentId,
|
|
2716
|
+
projectId,
|
|
2717
|
+
taskId: undefined,
|
|
2718
|
+
lockToken: undefined,
|
|
2719
|
+
updatedAt: now,
|
|
2720
|
+
};
|
|
2721
|
+
});
|
|
2300
2722
|
}
|
|
2301
|
-
|
|
2723
|
+
if (lockToken)
|
|
2724
|
+
await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
|
|
2302
2725
|
}
|
|
2303
2726
|
async pickAgent(projectId, preferredAgentId) {
|
|
2304
2727
|
const cfg = this.getAgentConfig(preferredAgentId);
|
|
@@ -2329,6 +2752,7 @@ export class AgentManager {
|
|
|
2329
2752
|
}
|
|
2330
2753
|
}
|
|
2331
2754
|
const taskBranch = input.branch ?? BRANCH_PREFIX + taskId;
|
|
2755
|
+
const branchCreatedByBaxian = input.branch === undefined;
|
|
2332
2756
|
if (input.branch) {
|
|
2333
2757
|
const existing = await this.findTaskByBranch(input.branch, projectId);
|
|
2334
2758
|
if (existing) {
|
|
@@ -2349,6 +2773,7 @@ export class AgentManager {
|
|
|
2349
2773
|
reviewRound: 0,
|
|
2350
2774
|
status: 'pending',
|
|
2351
2775
|
branch: taskBranch,
|
|
2776
|
+
branchCreatedByBaxian,
|
|
2352
2777
|
reviewMode: this.effectiveReviewMode(projectId),
|
|
2353
2778
|
createdAt: now,
|
|
2354
2779
|
updatedAt: now,
|
|
@@ -2378,6 +2803,7 @@ export class AgentManager {
|
|
|
2378
2803
|
reviewRound: 0,
|
|
2379
2804
|
status: 'pending',
|
|
2380
2805
|
branch: taskBranch,
|
|
2806
|
+
branchCreatedByBaxian,
|
|
2381
2807
|
reviewMode: this.effectiveReviewMode(projectId),
|
|
2382
2808
|
createdAt: now,
|
|
2383
2809
|
updatedAt: now,
|
|
@@ -2399,8 +2825,8 @@ export class AgentManager {
|
|
|
2399
2825
|
});
|
|
2400
2826
|
return queued;
|
|
2401
2827
|
}
|
|
2402
|
-
const
|
|
2403
|
-
if (!
|
|
2828
|
+
const lockToken = await this.lockManager.acquire(dev.id, taskId);
|
|
2829
|
+
if (!lockToken) {
|
|
2404
2830
|
const queued = {
|
|
2405
2831
|
id: taskId,
|
|
2406
2832
|
projectId,
|
|
@@ -2411,6 +2837,7 @@ export class AgentManager {
|
|
|
2411
2837
|
reviewRound: 0,
|
|
2412
2838
|
status: 'pending',
|
|
2413
2839
|
branch: taskBranch,
|
|
2840
|
+
branchCreatedByBaxian,
|
|
2414
2841
|
reviewMode: this.effectiveReviewMode(projectId),
|
|
2415
2842
|
createdAt: now,
|
|
2416
2843
|
updatedAt: now,
|
|
@@ -2443,6 +2870,7 @@ export class AgentManager {
|
|
|
2443
2870
|
reviewRound: 0,
|
|
2444
2871
|
status: 'in_progress',
|
|
2445
2872
|
branch: taskBranch,
|
|
2873
|
+
branchCreatedByBaxian,
|
|
2446
2874
|
reviewMode: this.effectiveReviewMode(projectId),
|
|
2447
2875
|
createdAt: now,
|
|
2448
2876
|
updatedAt: now,
|
|
@@ -2453,10 +2881,11 @@ export class AgentManager {
|
|
|
2453
2881
|
id: dev.id,
|
|
2454
2882
|
projectId,
|
|
2455
2883
|
taskId,
|
|
2884
|
+
lockToken,
|
|
2456
2885
|
bootstrappingTaskId: taskId,
|
|
2457
2886
|
updatedAt: now,
|
|
2458
2887
|
...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
2459
|
-
...(existing?.
|
|
2888
|
+
...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
2460
2889
|
...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
2461
2890
|
}));
|
|
2462
2891
|
await this.safeEmit({
|
|
@@ -2476,7 +2905,8 @@ export class AgentManager {
|
|
|
2476
2905
|
if (task.status === 'in_progress' && task.agentId) {
|
|
2477
2906
|
const signalToken = createSignalToken();
|
|
2478
2907
|
await this.updateTask(task.id, { signalToken });
|
|
2479
|
-
const
|
|
2908
|
+
const dispatchLockToken = (await this.agentStore.get(task.agentId))?.lockToken;
|
|
2909
|
+
const start = this.startCreatedTaskSession(task.id, task.agentId, signalToken, dispatchLockToken);
|
|
2480
2910
|
if (opts.background) {
|
|
2481
2911
|
void start.catch((err) => {
|
|
2482
2912
|
console.error(`[AgentManager] createAndStartTask background start failed for task=${task.id}:`, err);
|
|
@@ -2490,7 +2920,7 @@ export class AgentManager {
|
|
|
2490
2920
|
}
|
|
2491
2921
|
return task;
|
|
2492
2922
|
}
|
|
2493
|
-
async startCreatedTaskSession(taskId, agentId, signalToken) {
|
|
2923
|
+
async startCreatedTaskSession(taskId, agentId, signalToken, dispatchLockToken) {
|
|
2494
2924
|
let started = false;
|
|
2495
2925
|
let dispatchErr = null;
|
|
2496
2926
|
try {
|
|
@@ -2551,7 +2981,7 @@ export class AgentManager {
|
|
|
2551
2981
|
await this.rollbackFailedDispatch(taskId, agentId, dispatchErr ? {
|
|
2552
2982
|
phase: 'dispatch-rollback',
|
|
2553
2983
|
message: dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr),
|
|
2554
|
-
} : undefined);
|
|
2984
|
+
} : undefined, dispatchLockToken);
|
|
2555
2985
|
}
|
|
2556
2986
|
}
|
|
2557
2987
|
return (await this.taskStore.get(taskId)) ?? null;
|
|
@@ -2656,6 +3086,33 @@ export class AgentManager {
|
|
|
2656
3086
|
async clearAgent(agentId) {
|
|
2657
3087
|
return this.sendSlashCommand(agentId, '/clear');
|
|
2658
3088
|
}
|
|
3089
|
+
async clearRuntimeForTaskBoundary(tmux, paneId, agentId, runtime, revalidate) {
|
|
3090
|
+
await this.acquireCompactGuard(agentId);
|
|
3091
|
+
try {
|
|
3092
|
+
await this.waitForReplPromptReady(tmux, paneId, runtime, this.cleanComposerWaitMs);
|
|
3093
|
+
await revalidate();
|
|
3094
|
+
await tmux.clearComposerDraft(paneId);
|
|
3095
|
+
const baseline = await tmux.captureSettledSnapshot(paneId, {
|
|
3096
|
+
timeoutMs: this.dispatchSettleTimeoutMs,
|
|
3097
|
+
});
|
|
3098
|
+
const baselineTitle = await tmux.readPaneTitle(paneId);
|
|
3099
|
+
await tmux.sendKeysLiteral(paneId, '/clear');
|
|
3100
|
+
await tmux.sendEnter(paneId);
|
|
3101
|
+
await tmux.waitSubmitAck(paneId, baseline, runtime, {
|
|
3102
|
+
timeoutMs: this.dispatchAckTimeoutMs,
|
|
3103
|
+
baselineTitle,
|
|
3104
|
+
acceptComposerChange: true,
|
|
3105
|
+
});
|
|
3106
|
+
await this.waitForReplPromptReady(tmux, paneId, runtime, this.dispatchAckTimeoutMs);
|
|
3107
|
+
if (await this.hasRuntimeSlashCommandRejection(tmux, paneId, '/clear')) {
|
|
3108
|
+
throw new Error('Runtime rejected /clear at the task boundary; refusing to reuse prior task context');
|
|
3109
|
+
}
|
|
3110
|
+
await revalidate();
|
|
3111
|
+
}
|
|
3112
|
+
finally {
|
|
3113
|
+
this.compactInFlight.delete(agentId);
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
2659
3116
|
async persistTaskImages(taskId, images) {
|
|
2660
3117
|
const dir = join(this.imageStagingRoot, taskId);
|
|
2661
3118
|
await mkdir(dir, { recursive: true });
|
|
@@ -2744,8 +3201,8 @@ export class AgentManager {
|
|
|
2744
3201
|
if (!canDispatchWithBinding(state)) {
|
|
2745
3202
|
return { task: fresh, errorCode: 409, error: `Agent ${agentId} is busy or awaiting human` };
|
|
2746
3203
|
}
|
|
2747
|
-
const
|
|
2748
|
-
if (!
|
|
3204
|
+
const lockToken = await this.lockManager.acquire(agentId, taskId);
|
|
3205
|
+
if (!lockToken) {
|
|
2749
3206
|
return { task: fresh, errorCode: 409, error: `Agent ${agentId} lock acquisition failed` };
|
|
2750
3207
|
}
|
|
2751
3208
|
const now = new Date().toISOString();
|
|
@@ -2763,10 +3220,11 @@ export class AgentManager {
|
|
|
2763
3220
|
id: agentId,
|
|
2764
3221
|
projectId: cfg.projectId,
|
|
2765
3222
|
taskId,
|
|
3223
|
+
lockToken,
|
|
2766
3224
|
bootstrappingTaskId: taskId,
|
|
2767
3225
|
updatedAt: now,
|
|
2768
3226
|
...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
2769
|
-
...(existing?.
|
|
3227
|
+
...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
2770
3228
|
...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
2771
3229
|
}));
|
|
2772
3230
|
await this.safeEmit({
|
|
@@ -2778,7 +3236,7 @@ export class AgentManager {
|
|
|
2778
3236
|
taskId,
|
|
2779
3237
|
data: { agentId, manuallyDispatched: true },
|
|
2780
3238
|
});
|
|
2781
|
-
return { task: claimedTask };
|
|
3239
|
+
return { task: claimedTask, lockToken };
|
|
2782
3240
|
});
|
|
2783
3241
|
if (claim.errorCode !== undefined)
|
|
2784
3242
|
return claim;
|
|
@@ -2810,7 +3268,7 @@ export class AgentManager {
|
|
|
2810
3268
|
await this.rollbackFailedDispatch(claimed.id, claimed.agentId, dispatchErr ? {
|
|
2811
3269
|
phase: 'dispatch-rollback',
|
|
2812
3270
|
message: dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr),
|
|
2813
|
-
} : undefined);
|
|
3271
|
+
} : undefined, claim.lockToken);
|
|
2814
3272
|
}
|
|
2815
3273
|
const refreshed = await this.taskStore.get(claimed.id);
|
|
2816
3274
|
if (dispatchErr === null) {
|
|
@@ -2819,6 +3277,50 @@ export class AgentManager {
|
|
|
2819
3277
|
const err = dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr);
|
|
2820
3278
|
return { task: refreshed, errorCode: 500, error: err };
|
|
2821
3279
|
}
|
|
3280
|
+
async switchToVerifiedReviewHead(branchManager, workdir, task, assertOwner) {
|
|
3281
|
+
if (!task.branch)
|
|
3282
|
+
throw new Error(`Task ${task.id} has no review branch`);
|
|
3283
|
+
const current = await this.taskStore.get(task.id);
|
|
3284
|
+
let expectedHeadSha = current?.latestHeadSha ?? task.latestHeadSha;
|
|
3285
|
+
if (!expectedHeadSha) {
|
|
3286
|
+
const fetched = await this.fetchPrHeadSha(task.id);
|
|
3287
|
+
expectedHeadSha = await this.persistReviewHeadIfCurrent(task.id, undefined, fetched);
|
|
3288
|
+
}
|
|
3289
|
+
await assertOwner();
|
|
3290
|
+
try {
|
|
3291
|
+
await branchManager.switchToRemoteBranchDetached(workdir, task.branch, expectedHeadSha);
|
|
3292
|
+
return;
|
|
3293
|
+
}
|
|
3294
|
+
catch (err) {
|
|
3295
|
+
if (!(err instanceof ReviewHeadMismatchError))
|
|
3296
|
+
throw err;
|
|
3297
|
+
const latest = await this.taskStore.get(task.id);
|
|
3298
|
+
let replacement = latest?.latestHeadSha;
|
|
3299
|
+
if (!replacement || replacement === expectedHeadSha) {
|
|
3300
|
+
const fetched = await this.fetchPrHeadSha(task.id);
|
|
3301
|
+
replacement = await this.persistReviewHeadIfCurrent(task.id, expectedHeadSha, fetched);
|
|
3302
|
+
}
|
|
3303
|
+
if (replacement === expectedHeadSha)
|
|
3304
|
+
throw err;
|
|
3305
|
+
await assertOwner();
|
|
3306
|
+
await branchManager.switchToRemoteBranchDetached(workdir, task.branch, replacement);
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
persistReviewHeadIfCurrent(taskId, expected, replacement) {
|
|
3310
|
+
return this.withTaskLock(async () => {
|
|
3311
|
+
const latest = await this.taskStore.get(taskId);
|
|
3312
|
+
if (!latest || TERMINAL_STATUSES.includes(latest.status)) {
|
|
3313
|
+
throw new Error(`Task ${taskId} became terminal while refreshing its review head`);
|
|
3314
|
+
}
|
|
3315
|
+
if (latest.latestHeadSha && latest.latestHeadSha !== expected) {
|
|
3316
|
+
return latest.latestHeadSha;
|
|
3317
|
+
}
|
|
3318
|
+
latest.latestHeadSha = replacement;
|
|
3319
|
+
latest.updatedAt = new Date().toISOString();
|
|
3320
|
+
await this.taskStore.set(latest);
|
|
3321
|
+
return replacement;
|
|
3322
|
+
});
|
|
3323
|
+
}
|
|
2822
3324
|
async startSession(taskId, agentId, phase, opts = {}) {
|
|
2823
3325
|
const agent = this.getAgentConfig(agentId);
|
|
2824
3326
|
if (!agent)
|
|
@@ -2858,6 +3360,23 @@ export class AgentManager {
|
|
|
2858
3360
|
`${preAgent.taskId} (request ${taskId}); aborting`);
|
|
2859
3361
|
return false;
|
|
2860
3362
|
}
|
|
3363
|
+
if (!preAgent || preAgent.taskId !== taskId) {
|
|
3364
|
+
console.warn(`[AgentManager] startSession[${phase}]: agent=${agentId} has no exact task binding for lock validation`);
|
|
3365
|
+
return false;
|
|
3366
|
+
}
|
|
3367
|
+
const lockToken = await this.resolveTaskLockToken(preAgent, taskId);
|
|
3368
|
+
if (!lockToken) {
|
|
3369
|
+
console.warn(`[AgentManager] startSession[${phase}]: agent=${agentId} does not own the exclusive lock for ${taskId}`);
|
|
3370
|
+
return false;
|
|
3371
|
+
}
|
|
3372
|
+
const assertOwner = async () => {
|
|
3373
|
+
const stateNow = await this.agentStore.get(agentId);
|
|
3374
|
+
if (stateNow?.taskId !== taskId || stateNow.lockToken !== lockToken) {
|
|
3375
|
+
throw new Error(`Agent ${agentId} binding changed for task ${taskId}; operation aborted`);
|
|
3376
|
+
}
|
|
3377
|
+
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
3378
|
+
};
|
|
3379
|
+
await assertOwner();
|
|
2861
3380
|
const dialogFailFromStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
|
|
2862
3381
|
let ensure;
|
|
2863
3382
|
try {
|
|
@@ -2868,9 +3387,19 @@ export class AgentManager {
|
|
|
2868
3387
|
throw err;
|
|
2869
3388
|
}
|
|
2870
3389
|
if (err instanceof EnsureSessionError && err.partial.createdSession) {
|
|
3390
|
+
const stateNow = await this.agentStore.get(agentId);
|
|
3391
|
+
const stillOwner = stateNow?.taskId === taskId
|
|
3392
|
+
&& stateNow.lockToken === lockToken
|
|
3393
|
+
&& await this.lockManager.isOwner(agentId, taskId, lockToken);
|
|
2871
3394
|
try {
|
|
2872
|
-
|
|
2873
|
-
|
|
3395
|
+
if (stillOwner) {
|
|
3396
|
+
const runner = this.createRunnerFor(agent);
|
|
3397
|
+
await new TmuxManager(runner).killSession(agentId);
|
|
3398
|
+
}
|
|
3399
|
+
else {
|
|
3400
|
+
console.warn(`[AgentManager] startSession ensureSession rollback skipped killSession for stale owner ` +
|
|
3401
|
+
`${agentId}/${taskId}`);
|
|
3402
|
+
}
|
|
2874
3403
|
}
|
|
2875
3404
|
catch (cleanupErr) {
|
|
2876
3405
|
console.warn(`[AgentManager] startSession ensureSession rollback killSession failed:`, cleanupErr);
|
|
@@ -2880,36 +3409,91 @@ export class AgentManager {
|
|
|
2880
3409
|
}
|
|
2881
3410
|
const { paneId, workdir } = ensure;
|
|
2882
3411
|
const runner = this.createRunnerFor(agent);
|
|
2883
|
-
const worktree = new WorktreeManager(runner);
|
|
2884
3412
|
const tmux = new TmuxManager(runner);
|
|
3413
|
+
const branchManager = new BranchManager(runner);
|
|
3414
|
+
const lockState = await this.agentStore.get(agentId);
|
|
3415
|
+
if (!lockState || lockState.taskId !== taskId || lockState.lockToken !== lockToken)
|
|
3416
|
+
return false;
|
|
3417
|
+
await assertOwner();
|
|
3418
|
+
await this.waitForReplPromptReady(tmux, paneId, agent.runtime, this.cleanComposerWaitMs);
|
|
2885
3419
|
const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
|
|
2886
3420
|
const isServerCodeReviewPhase = phase === 'server-review' || phase === 'server-recheck';
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
3421
|
+
let reviewCheckout;
|
|
3422
|
+
const dispatchWorkdir = workdir;
|
|
3423
|
+
try {
|
|
3424
|
+
if (isServerCodeReviewPhase && opts.serverContent !== undefined) {
|
|
3425
|
+
reviewCheckout = await branchManager.materializeReviewHead(workdir, {
|
|
3426
|
+
patch: opts.serverContent,
|
|
3427
|
+
baseSha: opts.serverBaseSha,
|
|
3428
|
+
headSha: opts.serverHeadSha,
|
|
3429
|
+
headTree: opts.serverHeadTree,
|
|
3430
|
+
});
|
|
3431
|
+
}
|
|
3432
|
+
else if (isServerQaPhase) {
|
|
3433
|
+
await branchManager.switchToDefaultDetached(workdir);
|
|
3434
|
+
}
|
|
3435
|
+
else if (phase === 'review' || phase === 'recheck') {
|
|
3436
|
+
if (!task.branch)
|
|
3437
|
+
throw new Error(`Task ${taskId} has no review branch`);
|
|
3438
|
+
await this.switchToVerifiedReviewHead(branchManager, workdir, task, assertOwner);
|
|
3439
|
+
}
|
|
3440
|
+
else {
|
|
3441
|
+
if (!task.branch)
|
|
3442
|
+
throw new Error(`Task ${taskId} has no task branch`);
|
|
3443
|
+
await branchManager.switchToTaskBranch(workdir, taskId, task.branch, task.branchCreatedByBaxian === true);
|
|
3444
|
+
}
|
|
3445
|
+
await assertOwner();
|
|
3446
|
+
if (!ensure.freshRuntime) {
|
|
3447
|
+
await this.clearRuntimeForTaskBoundary(tmux, paneId, agentId, agent.runtime, assertOwner);
|
|
3448
|
+
if (ensure.skillsStale)
|
|
3449
|
+
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
3450
|
+
}
|
|
3451
|
+
}
|
|
3452
|
+
catch (err) {
|
|
3453
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3454
|
+
const holdPhase = err instanceof DirtyWorkdirError
|
|
3455
|
+
? 'dirty-workdir'
|
|
3456
|
+
: 'checkout-preparation-failed';
|
|
3457
|
+
await this.markAwaitingHuman(agentId, holdPhase, message, { expectedTaskId: taskId })
|
|
3458
|
+
.catch((holdErr) => {
|
|
3459
|
+
console.error(`[AgentManager] startSession could not persist ${holdPhase} hold for ${agentId}:`, holdErr);
|
|
3460
|
+
});
|
|
3461
|
+
throw new EnsureSessionError({ createdSession: ensure.createdSession, agentId, handled: true }, `checkout preparation failed for task ${taskId}: ${message}`);
|
|
3462
|
+
}
|
|
3463
|
+
const cleanupCheckout = async () => {
|
|
3464
|
+
try {
|
|
3465
|
+
await assertOwner();
|
|
3466
|
+
}
|
|
3467
|
+
catch {
|
|
3468
|
+
return;
|
|
3469
|
+
}
|
|
3470
|
+
await branchManager.parkOnDefaultDetached(workdir);
|
|
3471
|
+
await assertOwner();
|
|
3472
|
+
};
|
|
3473
|
+
const cleanupCheckoutOrHold = async () => {
|
|
3474
|
+
try {
|
|
3475
|
+
await cleanupCheckout();
|
|
3476
|
+
}
|
|
3477
|
+
catch (err) {
|
|
3478
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3479
|
+
let holdFailure = '';
|
|
3480
|
+
try {
|
|
3481
|
+
await this.markAwaitingHuman(agentId, 'checkout-cleanup-failed', `Task checkout could not be parked safely: ${message}`, { expectedTaskId: taskId });
|
|
3482
|
+
}
|
|
3483
|
+
catch (holdErr) {
|
|
3484
|
+
holdFailure = `; persisting the hold also failed: ${holdErr instanceof Error ? holdErr.message : String(holdErr)}`;
|
|
3485
|
+
}
|
|
3486
|
+
throw new EnsureSessionError({ createdSession: ensure.createdSession, agentId, handled: true }, `checkout cleanup failed for task ${taskId}: ${message}${holdFailure}`);
|
|
3487
|
+
}
|
|
3488
|
+
};
|
|
2903
3489
|
await this.agentStore.update(agentId, (stateNow) => {
|
|
2904
3490
|
if (!stateNow || stateNow.taskId !== taskId)
|
|
2905
3491
|
return AGENT_STORE_NOOP;
|
|
2906
|
-
// A fresh dispatch supersedes any question asked under the previous prompt.
|
|
2907
3492
|
const { needInputAt: _needInputAt, ...rest } = stateNow;
|
|
2908
3493
|
return {
|
|
2909
3494
|
...rest,
|
|
2910
3495
|
paneId,
|
|
2911
|
-
|
|
2912
|
-
repoPath: workdir,
|
|
3496
|
+
workdir,
|
|
2913
3497
|
updatedAt: new Date().toISOString(),
|
|
2914
3498
|
};
|
|
2915
3499
|
});
|
|
@@ -2918,17 +3502,18 @@ export class AgentManager {
|
|
|
2918
3502
|
const hasQaPartner = !!(task.qaAgentId ?? this.findQaPartner(agentId)?.id);
|
|
2919
3503
|
let prompt;
|
|
2920
3504
|
try {
|
|
3505
|
+
await this.getReviewTransport().clearDispatchOutputs(agent, dispatchWorkdir, phase);
|
|
2921
3506
|
const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
|
|
2922
3507
|
let payloadOpts = {};
|
|
2923
3508
|
if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
|
|
2924
|
-
payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent,
|
|
3509
|
+
payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, dispatchWorkdir, {
|
|
2925
3510
|
phase,
|
|
2926
3511
|
...(task.phase ? { taskPhase: task.phase } : {}),
|
|
2927
3512
|
...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
|
|
2928
3513
|
reviewRound: task.reviewRound,
|
|
2929
3514
|
...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
|
|
2930
3515
|
...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
|
|
2931
|
-
...(
|
|
3516
|
+
...(reviewCheckout?.mode === 'head' ? { forceContentFile: true } : {}),
|
|
2932
3517
|
...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
|
|
2933
3518
|
...(opts.serverInterdiff !== undefined ? { serverInterdiff: opts.serverInterdiff } : {}),
|
|
2934
3519
|
...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
|
|
@@ -2939,14 +3524,14 @@ export class AgentManager {
|
|
|
2939
3524
|
task,
|
|
2940
3525
|
phase,
|
|
2941
3526
|
agent,
|
|
2942
|
-
|
|
3527
|
+
workdir: dispatchWorkdir,
|
|
2943
3528
|
skillRegistry: this.skillRegistry,
|
|
2944
3529
|
hasQaPartner,
|
|
2945
3530
|
...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
|
|
2946
3531
|
...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
|
|
2947
3532
|
...(imagePaths.length ? { imagePaths } : {}),
|
|
2948
|
-
...(
|
|
2949
|
-
...(
|
|
3533
|
+
...(reviewCheckout ? { serverReviewCheckout: reviewCheckout.mode } : {}),
|
|
3534
|
+
...(reviewCheckout?.fallbackReason ? { serverReviewFallbackReason: reviewCheckout.fallbackReason } : {}),
|
|
2950
3535
|
...(opts.serverBaseSha !== undefined ? { serverBaseSha: opts.serverBaseSha } : {}),
|
|
2951
3536
|
...(opts.serverHeadSha !== undefined ? { serverHeadSha: opts.serverHeadSha } : {}),
|
|
2952
3537
|
...(opts.serverHeadTree !== undefined ? { serverHeadTree: opts.serverHeadTree } : {}),
|
|
@@ -2955,10 +3540,7 @@ export class AgentManager {
|
|
|
2955
3540
|
});
|
|
2956
3541
|
}
|
|
2957
3542
|
catch (err) {
|
|
2958
|
-
|
|
2959
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
2960
|
-
}
|
|
2961
|
-
catch { }
|
|
3543
|
+
await cleanupCheckoutOrHold();
|
|
2962
3544
|
if (err instanceof PromptSizeError) {
|
|
2963
3545
|
throw new DispatchTerminalError('prompt_too_large', err.message);
|
|
2964
3546
|
}
|
|
@@ -2969,57 +3551,39 @@ export class AgentManager {
|
|
|
2969
3551
|
}
|
|
2970
3552
|
const taskFresh = await this.taskStore.get(taskId);
|
|
2971
3553
|
if (!taskFresh) {
|
|
2972
|
-
console.warn(`[AgentManager] startSession: task ${taskId} disappeared mid-dispatch;
|
|
2973
|
-
|
|
2974
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
2975
|
-
}
|
|
2976
|
-
catch { }
|
|
3554
|
+
console.warn(`[AgentManager] startSession: task ${taskId} disappeared mid-dispatch; parking checkout before paste`);
|
|
3555
|
+
await cleanupCheckoutOrHold();
|
|
2977
3556
|
return false;
|
|
2978
3557
|
}
|
|
2979
3558
|
if (TERMINAL_STATUSES.includes(taskFresh.status)) {
|
|
2980
3559
|
console.warn(`[AgentManager] startSession: task ${taskId} status=${taskFresh.status} is terminal ` +
|
|
2981
|
-
`for phase=${phase};
|
|
2982
|
-
|
|
2983
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
2984
|
-
}
|
|
2985
|
-
catch { }
|
|
3560
|
+
`for phase=${phase}; parking checkout before paste`);
|
|
3561
|
+
await cleanupCheckoutOrHold();
|
|
2986
3562
|
return false;
|
|
2987
3563
|
}
|
|
2988
3564
|
if (!opts.bypassTaskStatusGate && !expectedStatuses.includes(taskFresh.status)) {
|
|
2989
3565
|
console.warn(`[AgentManager] startSession: task ${taskId} status=${taskFresh.status} not in ` +
|
|
2990
|
-
`expected ${expectedStatuses.join('/')} for phase=${phase};
|
|
2991
|
-
|
|
2992
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
2993
|
-
}
|
|
2994
|
-
catch { }
|
|
3566
|
+
`expected ${expectedStatuses.join('/')} for phase=${phase}; parking checkout before paste`);
|
|
3567
|
+
await cleanupCheckoutOrHold();
|
|
2995
3568
|
return false;
|
|
2996
3569
|
}
|
|
2997
3570
|
const agentFresh = await this.agentStore.get(agentId);
|
|
2998
3571
|
if (PHASE_REQUIRES_AGENT_BOUND_TO_TASK[phase]) {
|
|
2999
3572
|
if (!agentFresh || agentFresh.taskId !== taskId) {
|
|
3000
3573
|
console.warn(`[AgentManager] startSession[${phase}]: agent ${agentId} not bound to ${taskId} ` +
|
|
3001
|
-
`(got taskId=${agentFresh?.taskId});
|
|
3002
|
-
|
|
3003
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3004
|
-
}
|
|
3005
|
-
catch { }
|
|
3574
|
+
`(got taskId=${agentFresh?.taskId}); parking checkout before paste`);
|
|
3575
|
+
await cleanupCheckoutOrHold();
|
|
3006
3576
|
return false;
|
|
3007
3577
|
}
|
|
3008
3578
|
}
|
|
3009
3579
|
else if (agentFresh && agentFresh.taskId && agentFresh.taskId !== taskId) {
|
|
3010
3580
|
console.warn(`[AgentManager] startSession[${phase}]: agent ${agentId} reassigned to ${agentFresh.taskId} ` +
|
|
3011
|
-
`(was ${taskId});
|
|
3012
|
-
|
|
3013
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3014
|
-
}
|
|
3015
|
-
catch { }
|
|
3581
|
+
`(was ${taskId}); parking checkout before paste`);
|
|
3582
|
+
await cleanupCheckoutOrHold();
|
|
3016
3583
|
return false;
|
|
3017
3584
|
}
|
|
3018
|
-
if (opts.armBeforeInject && !(await opts.armBeforeInject({
|
|
3019
|
-
|
|
3020
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3021
|
-
}
|
|
3022
|
-
catch { }
|
|
3585
|
+
if (opts.armBeforeInject && !(await opts.armBeforeInject({ serverReviewCheckout: reviewCheckout?.mode }))) {
|
|
3586
|
+
await cleanupCheckoutOrHold();
|
|
3023
3587
|
return false;
|
|
3024
3588
|
}
|
|
3025
3589
|
const now = new Date().toISOString();
|
|
@@ -3036,8 +3600,8 @@ export class AgentManager {
|
|
|
3036
3600
|
projectId: agent.projectId,
|
|
3037
3601
|
paneId,
|
|
3038
3602
|
taskId,
|
|
3039
|
-
|
|
3040
|
-
|
|
3603
|
+
workdir,
|
|
3604
|
+
lockToken,
|
|
3041
3605
|
startedAt: now,
|
|
3042
3606
|
bootstrappingTaskId: taskId,
|
|
3043
3607
|
updatedAt: now,
|
|
@@ -3047,10 +3611,7 @@ export class AgentManager {
|
|
|
3047
3611
|
if (cancelHoldWon) {
|
|
3048
3612
|
console.warn(`[AgentManager] startSession[${phase}]: agent ${agentId} entered a cancel-cleanup hold during dispatch; ` +
|
|
3049
3613
|
`aborting so cancel can finish interrupt + /clear`);
|
|
3050
|
-
|
|
3051
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3052
|
-
}
|
|
3053
|
-
catch { }
|
|
3614
|
+
await cleanupCheckoutOrHold();
|
|
3054
3615
|
return false;
|
|
3055
3616
|
}
|
|
3056
3617
|
agentMarkedRunning = true;
|
|
@@ -3072,7 +3633,7 @@ export class AgentManager {
|
|
|
3072
3633
|
projectId: agent.projectId,
|
|
3073
3634
|
agentId,
|
|
3074
3635
|
taskId,
|
|
3075
|
-
data: { phase,
|
|
3636
|
+
data: { phase, workdir: dispatchWorkdir },
|
|
3076
3637
|
});
|
|
3077
3638
|
this.startRuntimeMenuWatch(agentId);
|
|
3078
3639
|
return true;
|
|
@@ -3080,13 +3641,11 @@ export class AgentManager {
|
|
|
3080
3641
|
catch (err) {
|
|
3081
3642
|
const isAckUnknown = err instanceof DispatchTerminalError && err.reason === 'ack_unknown';
|
|
3082
3643
|
if (!isAckUnknown) {
|
|
3083
|
-
|
|
3084
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3085
|
-
}
|
|
3086
|
-
catch { }
|
|
3644
|
+
await cleanupCheckoutOrHold();
|
|
3087
3645
|
if (agentMarkedRunning) {
|
|
3088
3646
|
try {
|
|
3089
3647
|
let released = false;
|
|
3648
|
+
let releaseToken;
|
|
3090
3649
|
await this.agentStore.update(agentId, (agentNow) => {
|
|
3091
3650
|
if (!agentNow || agentNow.taskId !== taskId) {
|
|
3092
3651
|
console.warn(`[AgentManager] startSession cleanup agentStore: agent ${agentId} already reassigned ` +
|
|
@@ -3099,18 +3658,19 @@ export class AgentManager {
|
|
|
3099
3658
|
return AGENT_STORE_NOOP;
|
|
3100
3659
|
}
|
|
3101
3660
|
released = true;
|
|
3661
|
+
releaseToken = agentNow.lockToken;
|
|
3102
3662
|
void err;
|
|
3103
3663
|
return {
|
|
3104
3664
|
id: agentId,
|
|
3105
3665
|
projectId: agent.projectId,
|
|
3106
3666
|
paneId,
|
|
3107
|
-
|
|
3667
|
+
workdir,
|
|
3108
3668
|
updatedAt: new Date().toISOString(),
|
|
3109
3669
|
...(agentNow.creationToken !== undefined ? { creationToken: agentNow.creationToken } : {}),
|
|
3110
3670
|
};
|
|
3111
3671
|
});
|
|
3112
|
-
if (released) {
|
|
3113
|
-
await this.lockManager.
|
|
3672
|
+
if (released && releaseToken) {
|
|
3673
|
+
await this.lockManager.releaseIfOwner(agentId, taskId, releaseToken);
|
|
3114
3674
|
}
|
|
3115
3675
|
}
|
|
3116
3676
|
catch (cleanupErr) {
|
|
@@ -3123,13 +3683,25 @@ export class AgentManager {
|
|
|
3123
3683
|
}
|
|
3124
3684
|
async injectAndAwaitAck(tmux, paneId, prompt, agentId, runtime) {
|
|
3125
3685
|
const before = await this.agentStore.get(agentId);
|
|
3686
|
+
const beforeLockToken = before?.taskId
|
|
3687
|
+
? await this.resolveTaskLockToken(before, before.taskId)
|
|
3688
|
+
: null;
|
|
3689
|
+
if (before?.taskId && !beforeLockToken) {
|
|
3690
|
+
throw new Error(`dispatch aborted: agent ${agentId} no longer owns task ${before.taskId}`);
|
|
3691
|
+
}
|
|
3126
3692
|
await this.acquireCompactGuard(agentId);
|
|
3127
3693
|
try {
|
|
3128
3694
|
if (before) {
|
|
3129
3695
|
const now = await this.agentStore.get(agentId);
|
|
3130
|
-
if (!now
|
|
3696
|
+
if (!now
|
|
3697
|
+
|| now.paneId !== before.paneId
|
|
3698
|
+
|| now.taskId !== before.taskId
|
|
3699
|
+
|| (beforeLockToken !== null && now.lockToken !== beforeLockToken)) {
|
|
3131
3700
|
throw new Error(`dispatch aborted: agent ${agentId} binding changed while waiting for pane mutex`);
|
|
3132
3701
|
}
|
|
3702
|
+
if (before.taskId && beforeLockToken) {
|
|
3703
|
+
await this.assertTaskLockOwner(agentId, before.taskId, beforeLockToken);
|
|
3704
|
+
}
|
|
3133
3705
|
if (isCancelCleanupHold(now)) {
|
|
3134
3706
|
throw new Error(`dispatch aborted: agent ${agentId} taken over by cancel (${now.awaitingPhase}) while waiting for pane mutex`);
|
|
3135
3707
|
}
|
|
@@ -3141,9 +3713,16 @@ export class AgentManager {
|
|
|
3141
3713
|
const revalidate = before
|
|
3142
3714
|
? async () => {
|
|
3143
3715
|
const fresh = await this.agentStore.get(agentId);
|
|
3144
|
-
if (!fresh
|
|
3716
|
+
if (!fresh
|
|
3717
|
+
|| fresh.paneId !== before.paneId
|
|
3718
|
+
|| fresh.taskId !== before.taskId
|
|
3719
|
+
|| (beforeLockToken !== null && fresh.lockToken !== beforeLockToken)
|
|
3720
|
+
|| isCancelCleanupHold(fresh)) {
|
|
3145
3721
|
throw new Error(`dispatch aborted: agent ${agentId} taken over by cancel before paste`);
|
|
3146
3722
|
}
|
|
3723
|
+
if (before.taskId && beforeLockToken) {
|
|
3724
|
+
await this.assertTaskLockOwner(agentId, before.taskId, beforeLockToken);
|
|
3725
|
+
}
|
|
3147
3726
|
const boundTask = fresh.taskId ? await this.taskStore.get(fresh.taskId) : null;
|
|
3148
3727
|
if (boundTask && TERMINAL_STATUSES.includes(boundTask.status)) {
|
|
3149
3728
|
throw new Error(`dispatch aborted: task ${fresh.taskId} for agent ${agentId} went terminal before paste`);
|
|
@@ -3260,7 +3839,12 @@ export class AgentManager {
|
|
|
3260
3839
|
`(pre-paste taskId=${agentState.taskId} !== ${taskId}); skipping`);
|
|
3261
3840
|
return false;
|
|
3262
3841
|
}
|
|
3263
|
-
const
|
|
3842
|
+
const taskWorkdir = agentState.workdir;
|
|
3843
|
+
if (!taskWorkdir)
|
|
3844
|
+
throw new Error(`Agent ${agentId} has no Workdir`);
|
|
3845
|
+
const lockToken = await this.resolveTaskLockToken(agentState, taskId);
|
|
3846
|
+
if (!lockToken)
|
|
3847
|
+
throw new Error(`Agent ${agentId} no longer owns task ${taskId}`);
|
|
3264
3848
|
const dialogFailFromStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
|
|
3265
3849
|
let ensure;
|
|
3266
3850
|
try {
|
|
@@ -3271,9 +3855,19 @@ export class AgentManager {
|
|
|
3271
3855
|
throw err;
|
|
3272
3856
|
}
|
|
3273
3857
|
if (err instanceof EnsureSessionError && err.partial.createdSession) {
|
|
3858
|
+
const stateNow = await this.agentStore.get(agentId);
|
|
3859
|
+
const stillOwner = stateNow?.taskId === taskId
|
|
3860
|
+
&& stateNow.lockToken === lockToken
|
|
3861
|
+
&& await this.lockManager.isOwner(agentId, taskId, lockToken);
|
|
3274
3862
|
try {
|
|
3275
|
-
|
|
3276
|
-
|
|
3863
|
+
if (stillOwner) {
|
|
3864
|
+
const runner = this.createRunnerFor(agent);
|
|
3865
|
+
await new TmuxManager(runner).killSession(agentId);
|
|
3866
|
+
}
|
|
3867
|
+
else {
|
|
3868
|
+
console.warn(`[AgentManager] continueSession ensureSession rollback skipped killSession for stale owner ` +
|
|
3869
|
+
`${agentId}/${taskId}`);
|
|
3870
|
+
}
|
|
3277
3871
|
}
|
|
3278
3872
|
catch (cleanupErr) {
|
|
3279
3873
|
console.warn(`[AgentManager] continueSession ensureSession rollback killSession failed:`, cleanupErr);
|
|
@@ -3284,16 +3878,43 @@ export class AgentManager {
|
|
|
3284
3878
|
const { paneId } = ensure;
|
|
3285
3879
|
const runner = this.createRunnerFor(agent);
|
|
3286
3880
|
const tmux = new TmuxManager(runner);
|
|
3881
|
+
const stateAfterEnsure = await this.agentStore.get(agentId);
|
|
3882
|
+
if (stateAfterEnsure?.taskId !== taskId || stateAfterEnsure.lockToken !== lockToken) {
|
|
3883
|
+
console.warn(`[AgentManager] continueSession[${phase}]: ownership changed during ensureSession; skipping`);
|
|
3884
|
+
return false;
|
|
3885
|
+
}
|
|
3886
|
+
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
3887
|
+
const expectedStatuses = PHASE_EXPECTED_STATUS[phase] ?? [];
|
|
3888
|
+
const taskAfterEnsure = await this.taskStore.get(taskId);
|
|
3889
|
+
if (!taskAfterEnsure || TERMINAL_STATUSES.includes(taskAfterEnsure.status)) {
|
|
3890
|
+
console.warn(`[AgentManager] continueSession: task ${taskId} status=${taskAfterEnsure?.status} terminal/missing after ensure; skipping`);
|
|
3891
|
+
return false;
|
|
3892
|
+
}
|
|
3893
|
+
if (!opts.bypassTaskStatusGate && !expectedStatuses.includes(taskAfterEnsure.status)) {
|
|
3894
|
+
console.warn(`[AgentManager] continueSession: task ${taskId} status=${taskAfterEnsure.status} not in ` +
|
|
3895
|
+
`expected ${expectedStatuses.join('/')} for phase=${phase} after ensure; skipping`);
|
|
3896
|
+
return false;
|
|
3897
|
+
}
|
|
3898
|
+
if (agentState.workdir && agent.role === 'dev' && task.branch) {
|
|
3899
|
+
const branches = new BranchManager(runner);
|
|
3900
|
+
await branches.assertClean(taskWorkdir);
|
|
3901
|
+
const actualRef = await branches.currentRef(taskWorkdir);
|
|
3902
|
+
if (actualRef !== `refs/heads/${task.branch}`) {
|
|
3903
|
+
throw new Error(`Agent ${agentId} checkout mismatch for task ${taskId}: ` +
|
|
3904
|
+
`expected refs/heads/${task.branch}, got ${actualRef ?? 'detached HEAD'}`);
|
|
3905
|
+
}
|
|
3906
|
+
}
|
|
3287
3907
|
const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
|
|
3288
3908
|
let prompt;
|
|
3289
3909
|
try {
|
|
3910
|
+
await this.getReviewTransport().clearDispatchOutputs(agent, taskWorkdir, phase);
|
|
3290
3911
|
const useIncrementalNudge = typeof opts.postApproveRedispatchCount === 'number'
|
|
3291
3912
|
&& opts.postApproveRedispatchCount > 0
|
|
3292
3913
|
&& !ensure.freshRuntime;
|
|
3293
3914
|
const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
|
|
3294
3915
|
let payloadOpts = {};
|
|
3295
3916
|
if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
|
|
3296
|
-
payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent,
|
|
3917
|
+
payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, taskWorkdir, {
|
|
3297
3918
|
phase,
|
|
3298
3919
|
...(task.phase ? { taskPhase: task.phase } : {}),
|
|
3299
3920
|
...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
|
|
@@ -3310,7 +3931,7 @@ export class AgentManager {
|
|
|
3310
3931
|
task,
|
|
3311
3932
|
phase,
|
|
3312
3933
|
agent,
|
|
3313
|
-
|
|
3934
|
+
workdir: taskWorkdir,
|
|
3314
3935
|
skillRegistry: this.skillRegistry,
|
|
3315
3936
|
...(signalToken ? { signalToken } : {}),
|
|
3316
3937
|
...(useIncrementalNudge
|
|
@@ -3332,7 +3953,6 @@ export class AgentManager {
|
|
|
3332
3953
|
}
|
|
3333
3954
|
throw err;
|
|
3334
3955
|
}
|
|
3335
|
-
const expectedStatuses = PHASE_EXPECTED_STATUS[phase] ?? [];
|
|
3336
3956
|
const taskFresh = await this.taskStore.get(taskId);
|
|
3337
3957
|
if (!taskFresh || TERMINAL_STATUSES.includes(taskFresh.status)) {
|
|
3338
3958
|
console.warn(`[AgentManager] continueSession: task ${taskId} status=${taskFresh?.status} terminal/missing; skipping paste`);
|
|
@@ -3356,6 +3976,11 @@ export class AgentManager {
|
|
|
3356
3976
|
`(taskId=${agentFresh.taskId} !== ${taskId}); skipping`);
|
|
3357
3977
|
return false;
|
|
3358
3978
|
}
|
|
3979
|
+
if (!agentFresh || agentFresh.lockToken !== lockToken) {
|
|
3980
|
+
console.warn(`[AgentManager] continueSession[${phase}]: ownership token changed; skipping`);
|
|
3981
|
+
return false;
|
|
3982
|
+
}
|
|
3983
|
+
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
3359
3984
|
if (phase === 'post-approve') {
|
|
3360
3985
|
const completionFresh = await this.getPostApproveCompletion(taskId);
|
|
3361
3986
|
if (!completionFresh || completionFresh.token !== signalToken) {
|
|
@@ -3375,14 +4000,15 @@ export class AgentManager {
|
|
|
3375
4000
|
return {
|
|
3376
4001
|
...rest,
|
|
3377
4002
|
paneId,
|
|
3378
|
-
|
|
4003
|
+
workdir: taskWorkdir,
|
|
4004
|
+
lockToken,
|
|
3379
4005
|
updatedAt: now,
|
|
3380
4006
|
};
|
|
3381
4007
|
});
|
|
3382
4008
|
await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime);
|
|
3383
4009
|
return true;
|
|
3384
4010
|
}
|
|
3385
|
-
async rollbackUndeliveredBootstrap(state
|
|
4011
|
+
async rollbackUndeliveredBootstrap(state) {
|
|
3386
4012
|
if (!state.taskId
|
|
3387
4013
|
|| state.bootstrappingTaskId !== state.taskId
|
|
3388
4014
|
|| state.awaitingPhase === 'bootstrap-marker-clear-failed') {
|
|
@@ -3398,18 +4024,7 @@ export class AgentManager {
|
|
|
3398
4024
|
}
|
|
3399
4025
|
console.warn(`[recover] agent ${state.id} was mid-bootstrap for in_progress task ${state.taskId} ` +
|
|
3400
4026
|
`(prompt never ack'd); rolling the task back to pending`);
|
|
3401
|
-
|
|
3402
|
-
const cleanupDir = this.resolveWorkdir(agentConfig, state);
|
|
3403
|
-
if (cleanupDir) {
|
|
3404
|
-
try {
|
|
3405
|
-
await new WorktreeManager(this.createRunnerFor(agentConfig)).remove(cleanupDir, state.worktreePath);
|
|
3406
|
-
}
|
|
3407
|
-
catch (worktreeErr) {
|
|
3408
|
-
console.warn(`[recover] mid-bootstrap rollback: worktree.remove failed for ${state.worktreePath}:`, worktreeErr);
|
|
3409
|
-
}
|
|
3410
|
-
}
|
|
3411
|
-
}
|
|
3412
|
-
await this.rollbackFailedDispatch(state.taskId, state.id);
|
|
4027
|
+
await this.rollbackFailedDispatch(state.taskId, state.id, undefined, state.lockToken);
|
|
3413
4028
|
await this.agentStore.update(state.id, (latest) => {
|
|
3414
4029
|
if (!latest || latest.status !== 'awaiting_human')
|
|
3415
4030
|
return AGENT_STORE_NOOP;
|
|
@@ -3438,16 +4053,56 @@ export class AgentManager {
|
|
|
3438
4053
|
return { ...rest, updatedAt: new Date().toISOString() };
|
|
3439
4054
|
});
|
|
3440
4055
|
}
|
|
4056
|
+
async releaseOrphanedLocks(states) {
|
|
4057
|
+
const bindings = new Map(states.map(state => [state.id, state]));
|
|
4058
|
+
const claims = await this.lockManager.listClaims();
|
|
4059
|
+
for (const claim of claims) {
|
|
4060
|
+
const binding = bindings.get(claim.agentId);
|
|
4061
|
+
const exactBinding = binding?.taskId === claim.taskId && binding.lockToken === claim.token;
|
|
4062
|
+
if (!claim.taskId.startsWith('maintenance:') && exactBinding)
|
|
4063
|
+
continue;
|
|
4064
|
+
const released = await this.lockManager.releaseIfOwner(claim.agentId, claim.taskId, claim.token);
|
|
4065
|
+
if (!released) {
|
|
4066
|
+
console.warn(`[recover] lock changed while reclaiming ${claim.agentId}/${claim.taskId}; preserving the newer claim`);
|
|
4067
|
+
}
|
|
4068
|
+
}
|
|
4069
|
+
}
|
|
3441
4070
|
async recover() {
|
|
3442
4071
|
const states = await this.agentStore.list();
|
|
4072
|
+
await this.releaseOrphanedLocks(states);
|
|
3443
4073
|
const deferredCleanups = [];
|
|
3444
4074
|
for (const state of states) {
|
|
3445
4075
|
const agentConfig = this.getAgentConfig(state.id);
|
|
3446
4076
|
if (!agentConfig)
|
|
3447
4077
|
continue;
|
|
4078
|
+
if (state.taskId) {
|
|
4079
|
+
let token = null;
|
|
4080
|
+
try {
|
|
4081
|
+
token = await this.resolveTaskLockToken(state, state.taskId);
|
|
4082
|
+
}
|
|
4083
|
+
catch (err) {
|
|
4084
|
+
console.warn(`[recover] lock validation failed for ${state.id}/${state.taskId}:`, err);
|
|
4085
|
+
}
|
|
4086
|
+
if (!token) {
|
|
4087
|
+
const reason = `Exclusive lock ownership cannot be proven for task ${state.taskId}; runtime recovery was not attempted.`;
|
|
4088
|
+
await this.markAwaitingHuman(state.id, 'recovery-lock-invalid', reason, { expectedTaskId: state.taskId });
|
|
4089
|
+
await this.recordError({
|
|
4090
|
+
agentId: state.id,
|
|
4091
|
+
projectId: state.projectId,
|
|
4092
|
+
taskId: state.taskId,
|
|
4093
|
+
operation: 'recovery',
|
|
4094
|
+
reason: 'LOCK_OWNERSHIP_INVALID',
|
|
4095
|
+
message: reason,
|
|
4096
|
+
observation: { phase: 'pre-runtime-recovery' },
|
|
4097
|
+
recommendation: 'Inspect the persisted lock and task binding, then cancel or delete the Agent if ownership cannot be restored.',
|
|
4098
|
+
});
|
|
4099
|
+
continue;
|
|
4100
|
+
}
|
|
4101
|
+
state.lockToken = token;
|
|
4102
|
+
}
|
|
3448
4103
|
try {
|
|
3449
4104
|
const result = await this.ensureSession(state.id, 'recover');
|
|
3450
|
-
if (await this.rollbackUndeliveredBootstrap(state
|
|
4105
|
+
if (await this.rollbackUndeliveredBootstrap(state))
|
|
3451
4106
|
continue;
|
|
3452
4107
|
if (state.creationToken && !state.taskId) {
|
|
3453
4108
|
const ct = state.creationToken;
|
|
@@ -3506,18 +4161,16 @@ export class AgentManager {
|
|
|
3506
4161
|
}
|
|
3507
4162
|
const cancelHold = isCancelCleanupHold(state);
|
|
3508
4163
|
const shouldReleaseBinding = shouldReleaseHeldBinding(state, boundTask) && !cancelHold;
|
|
3509
|
-
if (shouldReleaseBinding && state.
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
}
|
|
3520
|
-
}
|
|
4164
|
+
if (shouldReleaseBinding && state.taskId) {
|
|
4165
|
+
await this.agentStore.update(state.id, (latest) => {
|
|
4166
|
+
if (!latest || latest.taskId !== state.taskId)
|
|
4167
|
+
return AGENT_STORE_NOOP;
|
|
4168
|
+
return { ...latest, paneId: result.paneId, updatedAt: new Date().toISOString() };
|
|
4169
|
+
});
|
|
4170
|
+
await this.releaseAgentForTask(state.id, state.taskId, 'idle', {
|
|
4171
|
+
allowAwaitingHuman: true,
|
|
4172
|
+
});
|
|
4173
|
+
continue;
|
|
3521
4174
|
}
|
|
3522
4175
|
const preserveHeld = !shouldReleaseBinding
|
|
3523
4176
|
&& state.status === 'awaiting_human';
|
|
@@ -3529,7 +4182,7 @@ export class AgentManager {
|
|
|
3529
4182
|
projectId: latest.projectId,
|
|
3530
4183
|
paneId: result.paneId,
|
|
3531
4184
|
updatedAt: new Date().toISOString(),
|
|
3532
|
-
...(latest.
|
|
4185
|
+
...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
|
|
3533
4186
|
status: 'ok',
|
|
3534
4187
|
};
|
|
3535
4188
|
if (shouldReleaseBinding) {
|
|
@@ -3538,7 +4191,7 @@ export class AgentManager {
|
|
|
3538
4191
|
const withBinding = {
|
|
3539
4192
|
...base,
|
|
3540
4193
|
...(latest.taskId !== undefined ? { taskId: latest.taskId } : {}),
|
|
3541
|
-
...(latest.
|
|
4194
|
+
...(latest.lockToken !== undefined ? { lockToken: latest.lockToken } : {}),
|
|
3542
4195
|
...(latest.startedAt !== undefined ? { startedAt: latest.startedAt } : {}),
|
|
3543
4196
|
};
|
|
3544
4197
|
if (!preserveHeld)
|
|
@@ -3551,16 +4204,13 @@ export class AgentManager {
|
|
|
3551
4204
|
...(latest.awaitingSince !== undefined ? { awaitingSince: latest.awaitingSince } : {}),
|
|
3552
4205
|
};
|
|
3553
4206
|
});
|
|
3554
|
-
if (shouldReleaseBinding) {
|
|
3555
|
-
await this.lockManager.release(state.id);
|
|
3556
|
-
}
|
|
3557
4207
|
if (state.taskId && !shouldReleaseBinding && !cancelHold) {
|
|
3558
4208
|
this.startRuntimeMenuWatch(state.id);
|
|
3559
4209
|
}
|
|
3560
4210
|
}
|
|
3561
4211
|
catch (err) {
|
|
3562
4212
|
if (err instanceof EnsureSessionError && err.partial.dialogPending) {
|
|
3563
|
-
if (await this.rollbackUndeliveredBootstrap(state
|
|
4213
|
+
if (await this.rollbackUndeliveredBootstrap(state))
|
|
3564
4214
|
continue;
|
|
3565
4215
|
await this.markDialogPending(state.id, state.creationToken);
|
|
3566
4216
|
void this.slowPollDialogPending(state.id, state.creationToken, {
|
|
@@ -3589,10 +4239,15 @@ export class AgentManager {
|
|
|
3589
4239
|
...latest,
|
|
3590
4240
|
paneId: undefined,
|
|
3591
4241
|
creationToken: undefined,
|
|
4242
|
+
...(latest.taskId ? {
|
|
4243
|
+
status: 'awaiting_human',
|
|
4244
|
+
awaitingPhase: 'recovery-failed',
|
|
4245
|
+
awaitingReason: message,
|
|
4246
|
+
awaitingSince: new Date().toISOString(),
|
|
4247
|
+
} : {}),
|
|
3592
4248
|
updatedAt: new Date().toISOString(),
|
|
3593
4249
|
};
|
|
3594
4250
|
});
|
|
3595
|
-
await this.lockManager.release(state.id);
|
|
3596
4251
|
const cleanup = await this.failTasksForAgent(state.id, `recovery: ${message}`, { deferPartnerCleanup: true });
|
|
3597
4252
|
deferredCleanups.push({
|
|
3598
4253
|
failingAgentId: state.id,
|
|
@@ -3636,23 +4291,32 @@ export class AgentManager {
|
|
|
3636
4291
|
hadBinding = !!existing.taskId;
|
|
3637
4292
|
taskId = existing.taskId;
|
|
3638
4293
|
if (!existing.taskId
|
|
3639
|
-
&& !existing.worktreePath
|
|
3640
4294
|
&& !existing.startedAt
|
|
3641
4295
|
&& !existing.paneId
|
|
3642
4296
|
&& !existing.creationToken) {
|
|
3643
4297
|
return AGENT_STORE_NOOP;
|
|
3644
4298
|
}
|
|
3645
4299
|
changed = true;
|
|
4300
|
+
if (existing.taskId) {
|
|
4301
|
+
return {
|
|
4302
|
+
...existing,
|
|
4303
|
+
paneId: undefined,
|
|
4304
|
+
status: 'awaiting_human',
|
|
4305
|
+
awaitingPhase: 'runtime-missing',
|
|
4306
|
+
awaitingReason: 'The tmux session is missing; restart the REPL before releasing this task binding.',
|
|
4307
|
+
awaitingSince: timestamp,
|
|
4308
|
+
updatedAt: timestamp,
|
|
4309
|
+
};
|
|
4310
|
+
}
|
|
3646
4311
|
return {
|
|
3647
4312
|
id: existing.id,
|
|
3648
4313
|
projectId: existing.projectId,
|
|
3649
|
-
...(existing.
|
|
4314
|
+
...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
3650
4315
|
updatedAt: timestamp,
|
|
3651
4316
|
};
|
|
3652
4317
|
});
|
|
3653
4318
|
if (!projectId || !changed)
|
|
3654
4319
|
return null;
|
|
3655
|
-
await this.lockManager.release(agentId);
|
|
3656
4320
|
return { projectId, timestamp, hadBinding, taskId };
|
|
3657
4321
|
});
|
|
3658
4322
|
if (!reconciled)
|
|
@@ -3707,18 +4371,18 @@ export class AgentManager {
|
|
|
3707
4371
|
|| (task.status === 'approved' && !!task.publishDispatchedAt);
|
|
3708
4372
|
if (task.reviewMode === 'server' && publishedAtGate && task.agentId) {
|
|
3709
4373
|
const afterDone = this.resolveAfterDone(task);
|
|
3710
|
-
if (afterDone
|
|
4374
|
+
if (afterDone === 'pr' && task.branch && task.prNumber !== undefined) {
|
|
3711
4375
|
publishedCleanup = {
|
|
3712
4376
|
afterDone,
|
|
3713
4377
|
branch: task.branch,
|
|
3714
|
-
|
|
4378
|
+
prNumber: task.prNumber,
|
|
3715
4379
|
devAgentId: task.agentId,
|
|
3716
4380
|
mayBeInFlight: task.status === 'approved',
|
|
3717
4381
|
};
|
|
3718
4382
|
}
|
|
3719
4383
|
}
|
|
3720
4384
|
else if (!alreadyTerminal && task.reviewMode !== 'server' && task.prNumber !== undefined && task.branch) {
|
|
3721
|
-
//
|
|
4385
|
+
// GitHub 模式取消时只关闭开放 PR;远程分支归用户所有,baxian 不自动删除。
|
|
3722
4386
|
// server 模式仍只信 gate+marker——非 gate 状态上的孤立 prNumber 不足以证明远端有待回收的工件
|
|
3723
4387
|
publishedCleanup = {
|
|
3724
4388
|
afterDone: 'pr',
|
|
@@ -3793,40 +4457,18 @@ export class AgentManager {
|
|
|
3793
4457
|
phase: 'cancel-published-artifact-cleanup-skipped',
|
|
3794
4458
|
afterDone: publishedCleanup.afterDone,
|
|
3795
4459
|
branch: publishedCleanup.branch,
|
|
3796
|
-
|
|
4460
|
+
prNumber: publishedCleanup.prNumber,
|
|
3797
4461
|
reason: 'dev pane stop unconfirmed; the publish prompt may still be running and would recreate the remote artifacts',
|
|
3798
4462
|
});
|
|
3799
4463
|
return result;
|
|
3800
4464
|
}
|
|
3801
4465
|
const project = this.getProjectConfig(result.projectId);
|
|
3802
4466
|
try {
|
|
3803
|
-
if (
|
|
4467
|
+
if (project) {
|
|
3804
4468
|
const close = await execNetwork(this.platformRunner, `gh pr close ${publishedCleanup.prNumber} --repo ${shellQuote(repoSlug(project.repo))} ` +
|
|
3805
4469
|
`--comment ${shellQuote('Task cancelled in baxian; closing the published PR.')}`, GH_NET);
|
|
3806
4470
|
if (close.exitCode !== 0)
|
|
3807
4471
|
throw new Error(close.stderr.trim() || close.stdout.trim());
|
|
3808
|
-
// Branch deletion is a separate idempotent step, not `--delete-branch`:
|
|
3809
|
-
// a retried close that finds the PR already closed exits 0 before gh's
|
|
3810
|
-
// branch-deletion block, which would report success while leaking the
|
|
3811
|
-
// published branch.
|
|
3812
|
-
const refPath = `repos/${repoSlug(project.repo)}/git/refs/heads/` +
|
|
3813
|
-
encodeURIComponent(publishedCleanup.branch).replace(/%2F/gi, '/');
|
|
3814
|
-
const del = await execNetwork(this.platformRunner, `gh api -X DELETE ${shellQuote(refPath)}`, GH_NET);
|
|
3815
|
-
if (del.exitCode !== 0 && !del.stderr.includes('Reference does not exist')) {
|
|
3816
|
-
throw new Error(del.stderr.trim() || del.stdout.trim());
|
|
3817
|
-
}
|
|
3818
|
-
}
|
|
3819
|
-
else if (publishedCleanup.devAgentId) {
|
|
3820
|
-
const dev = this.getAgentConfig(publishedCleanup.devAgentId);
|
|
3821
|
-
const state = await this.agentStore.get(publishedCleanup.devAgentId);
|
|
3822
|
-
if (dev && state?.repoPath) {
|
|
3823
|
-
const del = await execNetwork(this.createRunnerFor(dev), `cd ${shellQuote(state.repoPath)} && ${GIT_NET_ENV} git push origin --delete ${shellQuote(publishedCleanup.branch)}`);
|
|
3824
|
-
// The goal is "branch absent": a retried delete whose first round
|
|
3825
|
-
// landed reports a missing remote ref, which is success, not failure.
|
|
3826
|
-
if (del.exitCode !== 0 && !del.stderr.includes('remote ref does not exist')) {
|
|
3827
|
-
throw new Error(del.stderr.trim() || del.stdout.trim());
|
|
3828
|
-
}
|
|
3829
|
-
}
|
|
3830
4472
|
}
|
|
3831
4473
|
}
|
|
3832
4474
|
catch (err) {
|
|
@@ -3835,7 +4477,7 @@ export class AgentManager {
|
|
|
3835
4477
|
phase: 'cancel-published-artifact-cleanup-failed',
|
|
3836
4478
|
afterDone: publishedCleanup.afterDone,
|
|
3837
4479
|
branch: publishedCleanup.branch,
|
|
3838
|
-
|
|
4480
|
+
prNumber: publishedCleanup.prNumber,
|
|
3839
4481
|
error: err instanceof Error ? err.message : String(err),
|
|
3840
4482
|
});
|
|
3841
4483
|
}
|
|
@@ -4151,8 +4793,8 @@ export class AgentManager {
|
|
|
4151
4793
|
}
|
|
4152
4794
|
const devAgentId = task.agentId;
|
|
4153
4795
|
const devState = await this.agentStore.get(devAgentId);
|
|
4154
|
-
if (devState?.taskId !== taskId || !devState.
|
|
4155
|
-
throw new ApiError(409, `Dev ${devAgentId} no longer holds task ${taskId}'s
|
|
4796
|
+
if (devState?.taskId !== taskId || !devState.workdir) {
|
|
4797
|
+
throw new ApiError(409, `Dev ${devAgentId} no longer holds task ${taskId}'s Workdir (cannot continue); ` +
|
|
4156
4798
|
`use mark-complete to merge the PR as-is, or cancel the task`);
|
|
4157
4799
|
}
|
|
4158
4800
|
const prevReviewRound = task.reviewRound;
|
|
@@ -4435,7 +5077,7 @@ export class AgentManager {
|
|
|
4435
5077
|
: '';
|
|
4436
5078
|
// A merge is not idempotent from the caller's view (user-triggered, retryable
|
|
4437
5079
|
// in the UI), so it gets a timeout but no automatic retry.
|
|
4438
|
-
const result = await execNetwork(this.platformRunner, `gh pr merge ${task.prNumber} --repo ${shellQuote(repoSlug(project.repo))}${matchHead} --squash
|
|
5080
|
+
const result = await execNetwork(this.platformRunner, `gh pr merge ${task.prNumber} --repo ${shellQuote(repoSlug(project.repo))}${matchHead} --squash`, { retries: 0 });
|
|
4439
5081
|
if (result.exitCode !== 0) {
|
|
4440
5082
|
throw new Error(`gh pr merge failed for PR #${task.prNumber}: ${result.stderr || result.stdout}`);
|
|
4441
5083
|
}
|
|
@@ -4568,6 +5210,9 @@ export class AgentManager {
|
|
|
4568
5210
|
return;
|
|
4569
5211
|
}
|
|
4570
5212
|
if (!state.taskId) {
|
|
5213
|
+
const lockToken = await this.lockManager.acquire(agentId, ctx.taskId);
|
|
5214
|
+
if (!lockToken)
|
|
5215
|
+
return;
|
|
4571
5216
|
await this.agentStore.update(agentId, (existing) => {
|
|
4572
5217
|
if (!existing)
|
|
4573
5218
|
return AGENT_STORE_NOOP;
|
|
@@ -4579,21 +5224,20 @@ export class AgentManager {
|
|
|
4579
5224
|
return AGENT_STORE_NOOP;
|
|
4580
5225
|
if (existing.paneId !== state.paneId)
|
|
4581
5226
|
return AGENT_STORE_NOOP;
|
|
4582
|
-
return {
|
|
5227
|
+
return {
|
|
5228
|
+
...existing,
|
|
5229
|
+
taskId: ctx.taskId,
|
|
5230
|
+
lockToken,
|
|
5231
|
+
updatedAt: new Date().toISOString(),
|
|
5232
|
+
};
|
|
4583
5233
|
});
|
|
4584
5234
|
const fresh = await this.agentStore.get(agentId);
|
|
4585
|
-
if (fresh?.taskId !== ctx.taskId)
|
|
5235
|
+
if (fresh?.taskId !== ctx.taskId || fresh.lockToken !== lockToken) {
|
|
5236
|
+
await this.lockManager.releaseIfOwner(agentId, ctx.taskId, lockToken);
|
|
4586
5237
|
return;
|
|
5238
|
+
}
|
|
4587
5239
|
}
|
|
4588
|
-
await this.removeMergedWorktree(agent, agentId, ctx.taskId);
|
|
4589
5240
|
const runner = this.createRunnerFor(agent);
|
|
4590
|
-
if (state.repoPath) {
|
|
4591
|
-
await this.deleteLocalBranchInRepo(runner, state.repoPath, ctx.branch, agentId);
|
|
4592
|
-
}
|
|
4593
|
-
else {
|
|
4594
|
-
console.warn(`[AgentManager] dispatchPostMergeCleanup(${agentId}): no repoPath on binding; skipping local ` +
|
|
4595
|
-
`branch delete for ${ctx.branch} (it may linger locally)`);
|
|
4596
|
-
}
|
|
4597
5241
|
const tmux = new TmuxManager(runner);
|
|
4598
5242
|
const runtime = agentRuntimeKindFor(agent);
|
|
4599
5243
|
void this.runPostMergeCompaction(tmux, state.paneId, agentId, ctx.taskId, runtime).catch(err => console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) failed:`, err));
|
|
@@ -4609,56 +5253,6 @@ export class AgentManager {
|
|
|
4609
5253
|
console.warn(`[AgentManager] releasePostMergeAgent: releaseAgentForTask(${agentId}, ${taskId}) failed:`, err);
|
|
4610
5254
|
}
|
|
4611
5255
|
}
|
|
4612
|
-
async removeMergedWorktree(cfg, agentId, expectedTaskId) {
|
|
4613
|
-
await this.withTaskLock(async () => {
|
|
4614
|
-
const state = await this.agentStore.get(agentId);
|
|
4615
|
-
if (!state || state.taskId !== expectedTaskId || !state.worktreePath)
|
|
4616
|
-
return;
|
|
4617
|
-
const cleanupDir = this.resolveWorkdir(cfg, state);
|
|
4618
|
-
if (cleanupDir) {
|
|
4619
|
-
const runner = this.createRunnerFor(cfg);
|
|
4620
|
-
const worktree = new WorktreeManager(runner);
|
|
4621
|
-
try {
|
|
4622
|
-
await worktree.remove(cleanupDir, state.worktreePath);
|
|
4623
|
-
}
|
|
4624
|
-
catch (err) {
|
|
4625
|
-
console.warn(`[AgentManager] removeMergedWorktree worktree.remove failed for ${state.worktreePath}:`, err);
|
|
4626
|
-
}
|
|
4627
|
-
}
|
|
4628
|
-
const now = new Date().toISOString();
|
|
4629
|
-
await this.agentStore.update(agentId, (existing) => {
|
|
4630
|
-
if (!existing || existing.taskId !== expectedTaskId)
|
|
4631
|
-
return AGENT_STORE_NOOP;
|
|
4632
|
-
const next = { ...existing, updatedAt: now };
|
|
4633
|
-
delete next.worktreePath;
|
|
4634
|
-
return next;
|
|
4635
|
-
});
|
|
4636
|
-
});
|
|
4637
|
-
}
|
|
4638
|
-
async deleteLocalBranchInRepo(runner, repoPath, branch, agentId) {
|
|
4639
|
-
const fetchCmd = `cd ${shellQuote(repoPath)} && git fetch --prune origin && git worktree prune --expire=now`;
|
|
4640
|
-
try {
|
|
4641
|
-
const fetchResult = await runner.exec(fetchCmd, { timeout: this.postMergeFetchTimeoutMs });
|
|
4642
|
-
if (fetchResult.exitCode !== 0) {
|
|
4643
|
-
console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}): fetch/prune exit=${fetchResult.exitCode} ` +
|
|
4644
|
-
`stderr=${fetchResult.stderr.trim()}`);
|
|
4645
|
-
}
|
|
4646
|
-
}
|
|
4647
|
-
catch (err) {
|
|
4648
|
-
console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}) fetch/prune threw:`, err);
|
|
4649
|
-
}
|
|
4650
|
-
const delCmd = `cd ${shellQuote(repoPath)} && git branch -D ${shellQuote(branch)}`;
|
|
4651
|
-
try {
|
|
4652
|
-
const delResult = await runner.exec(delCmd, { timeout: this.postMergeBranchTimeoutMs });
|
|
4653
|
-
if (delResult.exitCode !== 0 && !/not found|not a valid|no such branch/i.test(delResult.stderr)) {
|
|
4654
|
-
console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}): branch -D exit=${delResult.exitCode} ` +
|
|
4655
|
-
`stderr=${delResult.stderr.trim()}`);
|
|
4656
|
-
}
|
|
4657
|
-
}
|
|
4658
|
-
catch (err) {
|
|
4659
|
-
console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}) branch -D threw:`, err);
|
|
4660
|
-
}
|
|
4661
|
-
}
|
|
4662
5256
|
async runPostMergeCompaction(tmux, paneId, agentId, originalTaskId, runtime) {
|
|
4663
5257
|
await this.acquireCompactGuard(agentId);
|
|
4664
5258
|
try {
|
|
@@ -4689,33 +5283,59 @@ export class AgentManager {
|
|
|
4689
5283
|
return true;
|
|
4690
5284
|
}
|
|
4691
5285
|
async runPostMergeCompactionSteps(tmux, paneId, agentId, originalTaskId, runtime) {
|
|
5286
|
+
const initial = await this.agentStore.get(agentId);
|
|
5287
|
+
if (!initial || initial.taskId !== originalTaskId || initial.paneId !== paneId)
|
|
5288
|
+
return;
|
|
5289
|
+
const lockToken = await this.resolveTaskLockToken(initial, originalTaskId);
|
|
5290
|
+
if (!lockToken)
|
|
5291
|
+
return;
|
|
4692
5292
|
const bindingStillOurs = async () => {
|
|
4693
5293
|
const s = await this.agentStore.get(agentId);
|
|
4694
|
-
return !!s
|
|
5294
|
+
return !!s
|
|
5295
|
+
&& s.taskId === originalTaskId
|
|
5296
|
+
&& s.lockToken === lockToken
|
|
5297
|
+
&& s.paneId === paneId
|
|
5298
|
+
&& await this.lockManager.isOwner(agentId, originalTaskId, lockToken);
|
|
4695
5299
|
};
|
|
4696
5300
|
if (!await bindingStillOurs())
|
|
4697
5301
|
return;
|
|
4698
5302
|
let cleared = false;
|
|
5303
|
+
let clearError;
|
|
4699
5304
|
try {
|
|
4700
5305
|
cleared = await this.sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, bindingStillOurs);
|
|
4701
5306
|
}
|
|
4702
5307
|
catch (err) {
|
|
4703
|
-
|
|
5308
|
+
clearError = err;
|
|
4704
5309
|
}
|
|
4705
5310
|
if (!cleared) {
|
|
4706
|
-
if (await this.recoverPostMergeExitedRuntime(tmux, paneId, agentId, originalTaskId, runtime)) {
|
|
5311
|
+
if (await this.recoverPostMergeExitedRuntime(tmux, paneId, agentId, originalTaskId, lockToken, runtime)) {
|
|
4707
5312
|
cleared = true;
|
|
4708
5313
|
}
|
|
4709
5314
|
else if (!await bindingStillOurs()) {
|
|
4710
5315
|
return;
|
|
4711
5316
|
}
|
|
4712
|
-
else if (
|
|
5317
|
+
else if (clearError instanceof Error
|
|
5318
|
+
&& clearError.message.includes('runtime rejected /clear')) {
|
|
5319
|
+
try {
|
|
5320
|
+
await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
|
|
5321
|
+
}
|
|
5322
|
+
catch (err) {
|
|
5323
|
+
clearError = err;
|
|
5324
|
+
}
|
|
5325
|
+
if (clearError instanceof Error && !clearError.message.includes('runtime rejected /clear')) {
|
|
5326
|
+
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 });
|
|
5327
|
+
return;
|
|
5328
|
+
}
|
|
5329
|
+
}
|
|
5330
|
+
else {
|
|
5331
|
+
const reason = clearError instanceof Error ? clearError.message : String(clearError ?? 'unknown error');
|
|
5332
|
+
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 });
|
|
4713
5333
|
return;
|
|
4714
5334
|
}
|
|
4715
5335
|
}
|
|
4716
5336
|
await this.releasePostMergeAgent(agentId, originalTaskId);
|
|
4717
5337
|
}
|
|
4718
|
-
async recoverPostMergeExitedRuntime(tmux, paneId, agentId, taskId, runtime) {
|
|
5338
|
+
async recoverPostMergeExitedRuntime(tmux, paneId, agentId, taskId, lockToken, runtime) {
|
|
4719
5339
|
let paneExists = true;
|
|
4720
5340
|
try {
|
|
4721
5341
|
if (hasReplProcTitle(await tmux.displayMessage(paneId, '#{pane_current_command}'), runtime))
|
|
@@ -4729,7 +5349,11 @@ export class AgentManager {
|
|
|
4729
5349
|
paneExists = false;
|
|
4730
5350
|
}
|
|
4731
5351
|
const state = await this.agentStore.get(agentId);
|
|
4732
|
-
if (!state
|
|
5352
|
+
if (!state
|
|
5353
|
+
|| state.taskId !== taskId
|
|
5354
|
+
|| state.lockToken !== lockToken
|
|
5355
|
+
|| state.paneId !== paneId
|
|
5356
|
+
|| !(await this.lockManager.isOwner(agentId, taskId, lockToken)))
|
|
4733
5357
|
return false;
|
|
4734
5358
|
try {
|
|
4735
5359
|
if (paneExists)
|
|
@@ -4738,20 +5362,24 @@ export class AgentManager {
|
|
|
4738
5362
|
if (!ensure.freshRuntime)
|
|
4739
5363
|
return false;
|
|
4740
5364
|
await this.agentStore.update(agentId, (existing) => {
|
|
4741
|
-
if (!existing || existing.taskId !== taskId)
|
|
5365
|
+
if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken) {
|
|
4742
5366
|
return AGENT_STORE_NOOP;
|
|
4743
|
-
|
|
5367
|
+
}
|
|
5368
|
+
if (existing.paneId === ensure.paneId && existing.workdir === ensure.workdir) {
|
|
4744
5369
|
return AGENT_STORE_NOOP;
|
|
4745
5370
|
}
|
|
4746
5371
|
return {
|
|
4747
5372
|
...existing,
|
|
4748
5373
|
paneId: ensure.paneId,
|
|
4749
|
-
|
|
5374
|
+
workdir: ensure.workdir,
|
|
4750
5375
|
updatedAt: new Date().toISOString(),
|
|
4751
5376
|
};
|
|
4752
5377
|
});
|
|
4753
5378
|
const current = await this.agentStore.get(agentId);
|
|
4754
|
-
return current?.taskId === taskId
|
|
5379
|
+
return current?.taskId === taskId
|
|
5380
|
+
&& current.lockToken === lockToken
|
|
5381
|
+
&& current.paneId === ensure.paneId
|
|
5382
|
+
&& await this.lockManager.isOwner(agentId, taskId, lockToken);
|
|
4755
5383
|
}
|
|
4756
5384
|
catch (err) {
|
|
4757
5385
|
console.warn(`[AgentManager] recoverPostMergeExitedRuntime(${agentId}, ${taskId}) failed:`, err);
|
|
@@ -5096,7 +5724,7 @@ export class AgentManager {
|
|
|
5096
5724
|
return empty;
|
|
5097
5725
|
// 读取失败不阻断打回:留痕与 fix 闭环优先于 diff 展示
|
|
5098
5726
|
try {
|
|
5099
|
-
await this.
|
|
5727
|
+
await this.refreshWorkdirCacheFor(task.agentId);
|
|
5100
5728
|
const content = await this.getReviewTransport().readContent(task, dev, 'code');
|
|
5101
5729
|
return {
|
|
5102
5730
|
round,
|
|
@@ -5270,10 +5898,10 @@ export class AgentManager {
|
|
|
5270
5898
|
...(opts.batch
|
|
5271
5899
|
? { batchIndex: opts.batch.index, batchTotal: opts.batch.total }
|
|
5272
5900
|
: { batchIndex: undefined, batchTotal: undefined }),
|
|
5273
|
-
// Fresh dispatch: clear any stale mode until startSession materializes the
|
|
5901
|
+
// Fresh dispatch: clear any stale mode until startSession materializes the review checkout,
|
|
5274
5902
|
// so a crash in this window recovers conservatively (no read-file) rather than
|
|
5275
5903
|
// re-arming it for what might be a head-mode review.
|
|
5276
|
-
...(opts.continuation ? {} : {
|
|
5904
|
+
...(opts.continuation ? {} : { reviewCheckoutMode: undefined }),
|
|
5277
5905
|
...roundPatch,
|
|
5278
5906
|
});
|
|
5279
5907
|
if (!transition) {
|
|
@@ -5283,7 +5911,7 @@ export class AgentManager {
|
|
|
5283
5911
|
await this.emitIntervention(projectId, qaId, taskId, { phase: 'server-review-transition-failed', qaAgentId: qaId });
|
|
5284
5912
|
return null;
|
|
5285
5913
|
}
|
|
5286
|
-
let
|
|
5914
|
+
let dispatchedCheckoutMode;
|
|
5287
5915
|
const sessionOpts = {
|
|
5288
5916
|
bypassTaskStatusGate: true,
|
|
5289
5917
|
signalToken: newToken,
|
|
@@ -5298,8 +5926,8 @@ export class AgentManager {
|
|
|
5298
5926
|
...(opts.priorResponseJson ? { serverPriorResponse: opts.priorResponseJson } : {}),
|
|
5299
5927
|
...(opts.phase === 'spec' ? { currentSpecRound: newRound } : {}),
|
|
5300
5928
|
armBeforeInject: (ctx) => {
|
|
5301
|
-
|
|
5302
|
-
const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.
|
|
5929
|
+
dispatchedCheckoutMode = ctx.serverReviewCheckout;
|
|
5930
|
+
const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.serverReviewCheckout === 'base';
|
|
5303
5931
|
return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, false, allowReadFile ? (req) => { void this.handleReadFileRequest(taskId, qaId, req); } : undefined);
|
|
5304
5932
|
},
|
|
5305
5933
|
};
|
|
@@ -5343,10 +5971,20 @@ export class AgentManager {
|
|
|
5343
5971
|
await this.emitIntervention(projectId, qaId, taskId, { phase: 'server-review-start-failed', qaAgentId: qaId });
|
|
5344
5972
|
return null;
|
|
5345
5973
|
}
|
|
5346
|
-
// Persist the
|
|
5347
|
-
// fallback review (whose prompt depends on it) but not for a head-mode one.
|
|
5974
|
+
// Persist the checkout mode so base-mode recovery can re-arm read-file.
|
|
5348
5975
|
if (opts.phase !== 'spec' && !opts.continuation) {
|
|
5349
|
-
|
|
5976
|
+
try {
|
|
5977
|
+
await this.updateTask(taskId, { reviewCheckoutMode: dispatchedCheckoutMode });
|
|
5978
|
+
}
|
|
5979
|
+
catch (err) {
|
|
5980
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5981
|
+
console.warn(`[AgentManager] failed to persist review checkout mode for ${taskId}: ${message}`);
|
|
5982
|
+
await this.emitIntervention(projectId, qaId, taskId, {
|
|
5983
|
+
phase: 'review-checkout-mode-persist-failed',
|
|
5984
|
+
error: message,
|
|
5985
|
+
note: 'The review prompt is running, but restart recovery cannot safely infer its checkout mode.',
|
|
5986
|
+
});
|
|
5987
|
+
}
|
|
5350
5988
|
}
|
|
5351
5989
|
return await this.taskStore.get(taskId);
|
|
5352
5990
|
}
|
|
@@ -5466,13 +6104,13 @@ export class AgentManager {
|
|
|
5466
6104
|
return { ok: false, reason: 'released' };
|
|
5467
6105
|
const agent = this.getAgentConfig(task.agentId);
|
|
5468
6106
|
const agentState = await this.agentStore.get(task.agentId);
|
|
5469
|
-
// A rebound agent's
|
|
6107
|
+
// A rebound agent's Workdir checkout belongs to its new task — its git state describes
|
|
5470
6108
|
// another branch, so the interdiff would be meaningless. Same skip rule as
|
|
5471
|
-
// findLineageViolation:
|
|
5472
|
-
if (!agent || agentState?.taskId !== taskId || !agentState.
|
|
6109
|
+
// findLineageViolation: the Workdir must still be bound to THIS task.
|
|
6110
|
+
if (!agent || agentState?.taskId !== taskId || !agentState.workdir) {
|
|
5473
6111
|
return { ok: false, reason: 'released' };
|
|
5474
6112
|
}
|
|
5475
|
-
await this.
|
|
6113
|
+
await this.refreshWorkdirCacheFor(task.agentId);
|
|
5476
6114
|
const diff = await this.getReviewTransport().readInterdiff(agent, prevSha, curSha);
|
|
5477
6115
|
return { ok: true, diff };
|
|
5478
6116
|
}
|
|
@@ -5482,13 +6120,13 @@ export class AgentManager {
|
|
|
5482
6120
|
return null;
|
|
5483
6121
|
const agent = this.getAgentConfig(task.agentId);
|
|
5484
6122
|
const agentState = await this.agentStore.get(task.agentId);
|
|
5485
|
-
// A rebound agent's
|
|
6123
|
+
// A rebound agent's Workdir checkout belongs to its new task — checking it would
|
|
5486
6124
|
// produce verdicts about the wrong branch. Skip; the dispatch path's own
|
|
5487
6125
|
// binding guards (acquire) surface the real failure.
|
|
5488
6126
|
if (agentState?.taskId !== taskId)
|
|
5489
6127
|
return null;
|
|
5490
|
-
const
|
|
5491
|
-
if (!agent || !
|
|
6128
|
+
const workdir = agentState.workdir;
|
|
6129
|
+
if (!agent || !workdir)
|
|
5492
6130
|
return null;
|
|
5493
6131
|
const runner = this.createRunnerFor(agent);
|
|
5494
6132
|
let base = baseSha;
|
|
@@ -5496,13 +6134,13 @@ export class AgentManager {
|
|
|
5496
6134
|
// Callers without a baseSha (publish) run long after the review diff was
|
|
5497
6135
|
// read; refresh origin/HEAD first or upstream commits merged since then
|
|
5498
6136
|
// would linger in base..HEAD and flag tasks that leak nothing.
|
|
5499
|
-
const fetch = await execNetwork(runner, `${GIT_NET_ENV} git -C ${shellQuote(
|
|
6137
|
+
const fetch = await execNetwork(runner, `${GIT_NET_ENV} git -C ${shellQuote(workdir)} fetch origin --quiet`);
|
|
5500
6138
|
if (fetch.exitCode !== 0) {
|
|
5501
|
-
throw new Error(`lineage fetch failed in ${
|
|
6139
|
+
throw new Error(`lineage fetch failed in ${workdir}: ${fetch.stderr.trim()}`);
|
|
5502
6140
|
}
|
|
5503
|
-
const mb = await runner.exec(`git -C ${shellQuote(
|
|
6141
|
+
const mb = await runner.exec(`git -C ${shellQuote(workdir)} merge-base origin/HEAD HEAD`);
|
|
5504
6142
|
if (mb.exitCode !== 0) {
|
|
5505
|
-
throw new Error(`lineage merge-base failed in ${
|
|
6143
|
+
throw new Error(`lineage merge-base failed in ${workdir}: ${mb.stderr.trim()}`);
|
|
5506
6144
|
}
|
|
5507
6145
|
base = mb.stdout.trim();
|
|
5508
6146
|
}
|
|
@@ -5519,7 +6157,7 @@ export class AgentManager {
|
|
|
5519
6157
|
.map(t => ({ taskId: t.id, branch: t.branch ?? BRANCH_PREFIX + t.id }));
|
|
5520
6158
|
if (candidates.length === 0)
|
|
5521
6159
|
return null;
|
|
5522
|
-
return findForeignTaskTip((cmd) => runner.exec(cmd),
|
|
6160
|
+
return findForeignTaskTip((cmd) => runner.exec(cmd), workdir, base, candidates);
|
|
5523
6161
|
}
|
|
5524
6162
|
async dispatchServerAfterDone(taskId, kind) {
|
|
5525
6163
|
const task = await this.taskStore.get(taskId);
|
|
@@ -5601,7 +6239,7 @@ export class AgentManager {
|
|
|
5601
6239
|
const dev = this.getAgentConfig(task.agentId);
|
|
5602
6240
|
if (!dev)
|
|
5603
6241
|
return;
|
|
5604
|
-
await this.
|
|
6242
|
+
await this.refreshWorkdirCacheFor(task.agentId);
|
|
5605
6243
|
let body;
|
|
5606
6244
|
try {
|
|
5607
6245
|
const text = await this.getReviewTransport().readFileRange(dev, req.file, req.startLine, req.endLine);
|
|
@@ -5636,6 +6274,11 @@ export class AgentManager {
|
|
|
5636
6274
|
if (isCancelCleanupHold(state)) {
|
|
5637
6275
|
throw new Error(`injectTextToAgent: agent ${agentId} taken over by cancel (${state?.awaitingPhase}); refusing injection`);
|
|
5638
6276
|
}
|
|
6277
|
+
const taskId = state?.taskId;
|
|
6278
|
+
const lockToken = taskId ? await this.resolveTaskLockToken(state, taskId) : null;
|
|
6279
|
+
if (taskId && !lockToken) {
|
|
6280
|
+
throw new Error(`injectTextToAgent: agent ${agentId} no longer owns the exclusive lock for task ${taskId}`);
|
|
6281
|
+
}
|
|
5639
6282
|
const boundTask = state?.taskId ? await this.taskStore.get(state.taskId) : null;
|
|
5640
6283
|
if (boundTask && TERMINAL_STATUSES.includes(boundTask.status)) {
|
|
5641
6284
|
throw new Error(`injectTextToAgent: task ${state?.taskId} for agent ${agentId} is terminal; refusing injection`);
|
|
@@ -5645,10 +6288,16 @@ export class AgentManager {
|
|
|
5645
6288
|
throw new Error(`injectTextToAgent: agent ${agentId} has no live pane`);
|
|
5646
6289
|
const fresh = await this.agentStore.get(agentId);
|
|
5647
6290
|
if (!fresh || fresh.paneId !== paneId
|
|
5648
|
-
||
|
|
5649
|
-
||
|
|
6291
|
+
|| fresh.taskId !== taskId
|
|
6292
|
+
|| (taskId !== undefined && fresh.lockToken !== lockToken)) {
|
|
6293
|
+
throw new Error(`injectTextToAgent: agent ${agentId} binding or exclusive lock changed before paste`);
|
|
6294
|
+
}
|
|
6295
|
+
if (isCancelCleanupHold(fresh)) {
|
|
5650
6296
|
throw new Error(`injectTextToAgent: agent ${agentId} taken over by cancel before paste`);
|
|
5651
6297
|
}
|
|
6298
|
+
if (taskId && lockToken) {
|
|
6299
|
+
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
6300
|
+
}
|
|
5652
6301
|
const tmux = new TmuxManager(this.createRunnerFor(cfg));
|
|
5653
6302
|
await tmux.injectPrompt(paneId, text, agentId);
|
|
5654
6303
|
await tmux.sendEnter(paneId);
|
|
@@ -5792,14 +6441,14 @@ export class AgentManager {
|
|
|
5792
6441
|
if (!dev)
|
|
5793
6442
|
throw new Error(`ffMergeBranch: no dev agent for task ${task.id}`);
|
|
5794
6443
|
const state = await this.agentStore.get(task.agentId);
|
|
5795
|
-
const
|
|
5796
|
-
if (!
|
|
5797
|
-
throw new Error(`ffMergeBranch: no
|
|
6444
|
+
const workdir = state?.workdir;
|
|
6445
|
+
if (!workdir)
|
|
6446
|
+
throw new Error(`ffMergeBranch: no Workdir for agent ${task.agentId}`);
|
|
5798
6447
|
const branch = task.branch ?? BRANCH_PREFIX + task.id;
|
|
5799
6448
|
const runner = this.createRunnerFor(dev);
|
|
5800
6449
|
const prev = this.repoMergeQueue.get(task.projectId) ?? Promise.resolve();
|
|
5801
6450
|
const run = prev.then(async () => {
|
|
5802
|
-
const cd = `cd ${shellQuote(
|
|
6451
|
+
const cd = `cd ${shellQuote(workdir)} && `;
|
|
5803
6452
|
const db = await runner.exec(`${cd}git symbolic-ref --short refs/remotes/origin/HEAD`);
|
|
5804
6453
|
const defaultBranch = db.stdout.trim().replace(/^origin\//, '');
|
|
5805
6454
|
if (db.exitCode !== 0 || defaultBranch === '') {
|
|
@@ -5823,10 +6472,6 @@ export class AgentManager {
|
|
|
5823
6472
|
if (push.exitCode !== 0) {
|
|
5824
6473
|
throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
|
|
5825
6474
|
}
|
|
5826
|
-
const del = await execNetwork(runner, `${cd}${GIT_NET_ENV} git push origin --delete ${shellQuote(branch)}`);
|
|
5827
|
-
if (del.exitCode !== 0 && !del.stderr.includes('remote ref does not exist')) {
|
|
5828
|
-
console.warn(`[AgentManager] ffMergeBranch: post-merge branch delete failed for ${branch}: ${del.stderr.trim() || del.stdout.trim()}`);
|
|
5829
|
-
}
|
|
5830
6475
|
});
|
|
5831
6476
|
this.repoMergeQueue.set(task.projectId, run.catch(() => undefined));
|
|
5832
6477
|
await run;
|