baxian 1.2.54 → 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/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 +12 -9
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +1028 -423
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/preflight.d.ts.map +1 -1
- package/dist/agent/preflight.js +19 -15
- package/dist/agent/preflight.js.map +1 -1
- package/dist/agent/prompt.d.ts +2 -2
- package/dist/agent/prompt.d.ts.map +1 -1
- package/dist/agent/prompt.js +7 -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.d.ts +1 -0
- package/dist/agent/tmux.d.ts.map +1 -1
- package/dist/agent/tmux.js +21 -9
- package/dist/agent/tmux.js.map +1 -1
- package/dist/api/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 +52 -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 +14 -3
- 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 +1 -1
- package/dist/skills/baxian-task-check/SKILL.md +4 -4
- package/dist/state/agent-store.js +4 -4
- package/dist/state/agent-store.js.map +1 -1
- package/dist/state/lock.d.ts +15 -2
- package/dist/state/lock.d.ts.map +1 -1
- package/dist/state/lock.js +104 -21
- package/dist/state/lock.js.map +1 -1
- package/dist/state/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-Cx-wa7zd.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,23 +46,32 @@ export class DispatchTerminalError extends Error {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
export function buildLaunchCommand(agent) {
|
|
49
|
+
const yolo = agent.yolo !== false;
|
|
49
50
|
const segments = [];
|
|
50
51
|
switch (agent.runtime) {
|
|
51
52
|
case 'claude-code':
|
|
52
|
-
segments.push('env CLAUDE_CODE_NO_FLICKER=1 CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY=1 claude
|
|
53
|
+
segments.push('env CLAUDE_CODE_NO_FLICKER=1 CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY=1 claude');
|
|
54
|
+
if (yolo)
|
|
55
|
+
segments.push('--permission-mode bypassPermissions');
|
|
53
56
|
break;
|
|
54
57
|
case 'codex':
|
|
55
|
-
segments.push('codex
|
|
58
|
+
segments.push('codex');
|
|
59
|
+
if (yolo)
|
|
60
|
+
segments.push('--dangerously-bypass-approvals-and-sandbox');
|
|
56
61
|
break;
|
|
57
62
|
case 'opencode':
|
|
58
63
|
// opencode materializes skills into .agents/skills (shared with codex; see skillSubdirFor).
|
|
59
64
|
// Its .agents/.claude compatibility scans can't both be disabled by env, so also turn off
|
|
60
65
|
// the .claude/skills scan — that closes the last collision source: a claude-code agent's
|
|
61
66
|
// .claude/skills/baxian-* in a shared base repo.
|
|
62
|
-
segments.push('env OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 opencode
|
|
67
|
+
segments.push('env OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 opencode');
|
|
68
|
+
if (yolo)
|
|
69
|
+
segments.push('--auto');
|
|
63
70
|
break;
|
|
64
71
|
case 'qodercli':
|
|
65
|
-
segments.push('qodercli
|
|
72
|
+
segments.push('qodercli');
|
|
73
|
+
if (yolo)
|
|
74
|
+
segments.push('--dangerously-skip-permissions');
|
|
66
75
|
break;
|
|
67
76
|
}
|
|
68
77
|
if (agent.model) {
|
|
@@ -87,6 +96,27 @@ const REPL_EXIT_COMMAND = {
|
|
|
87
96
|
opencode: '/exit',
|
|
88
97
|
qodercli: '/quit',
|
|
89
98
|
};
|
|
99
|
+
const WORKDIR_SESSION_OPTION = '@baxian-workdir';
|
|
100
|
+
function normalizedDir(left) {
|
|
101
|
+
if (!left)
|
|
102
|
+
return null;
|
|
103
|
+
return left.replace(/\/+$/, '') || '/';
|
|
104
|
+
}
|
|
105
|
+
async function sameDirOnHost(runner, left, right) {
|
|
106
|
+
if (!left || !right)
|
|
107
|
+
return false;
|
|
108
|
+
if (normalizedDir(left) === normalizedDir(right))
|
|
109
|
+
return true;
|
|
110
|
+
const physical = async (path) => {
|
|
111
|
+
const result = await runner.exec(`cd -P ${shellQuote(path)} && pwd -P`);
|
|
112
|
+
const resolved = normalizedDir(result.stdout.trim());
|
|
113
|
+
if (result.exitCode !== 0 || !resolved) {
|
|
114
|
+
throw new Error(`Cannot resolve physical path for ${path}: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
|
|
115
|
+
}
|
|
116
|
+
return resolved;
|
|
117
|
+
};
|
|
118
|
+
return await physical(left) === await physical(right);
|
|
119
|
+
}
|
|
90
120
|
export function skillSubdirFor(runtime) {
|
|
91
121
|
switch (runtime) {
|
|
92
122
|
case 'codex':
|
|
@@ -113,15 +143,13 @@ export function canDispatchWithBinding(binding) {
|
|
|
113
143
|
return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
|
|
114
144
|
}
|
|
115
145
|
const TURN_COMPLETED_AWAITING_PHASES = new Set();
|
|
116
|
-
|
|
117
|
-
const CANCEL_CLEANUP_HOLD_PHASES = new Set(['cancel-clearing', 'cancel-clear-failed', 'cancel-interrupt-failed']);
|
|
146
|
+
const CANCEL_CLEANUP_HOLD_PHASES = new Set(['cancel-clearing', 'cancel-interrupt-failed']);
|
|
118
147
|
function isCancelCleanupHold(binding) {
|
|
119
148
|
return binding?.awaitingPhase != null && CANCEL_CLEANUP_HOLD_PHASES.has(binding.awaitingPhase);
|
|
120
149
|
}
|
|
121
150
|
const CANCEL_CLEANUP_PHASE_RANK = {
|
|
122
151
|
'cancel-clearing': 1,
|
|
123
152
|
'cancel-interrupt-failed': 2,
|
|
124
|
-
'cancel-clear-failed': 3,
|
|
125
153
|
};
|
|
126
154
|
function cancelPhaseRank(phase) {
|
|
127
155
|
return phase ? (CANCEL_CLEANUP_PHASE_RANK[phase] ?? 0) : 0;
|
|
@@ -171,8 +199,6 @@ export class AgentManager {
|
|
|
171
199
|
runtimeLivenessProbeMs = 700;
|
|
172
200
|
cleanComposerWaitMs = 5_000;
|
|
173
201
|
cancelInterruptGuardWaitMs = DEFAULT_DISPATCH_ACK_TIMEOUT_MS + 5_000;
|
|
174
|
-
postMergeFetchTimeoutMs = 60_000;
|
|
175
|
-
postMergeBranchTimeoutMs = 10_000;
|
|
176
202
|
manualReviewInFlight = new Set();
|
|
177
203
|
markCompleteInFlight = new Set();
|
|
178
204
|
specVerdictInFlight = new Set();
|
|
@@ -221,6 +247,28 @@ export class AgentManager {
|
|
|
221
247
|
this.taskMutationQueue = next.catch(() => undefined);
|
|
222
248
|
return next;
|
|
223
249
|
}
|
|
250
|
+
async resolveTaskLockToken(state, taskId) {
|
|
251
|
+
if (state.lockToken && await this.lockManager.isOwner(state.id, taskId, state.lockToken)) {
|
|
252
|
+
return state.lockToken;
|
|
253
|
+
}
|
|
254
|
+
if (state.lockToken)
|
|
255
|
+
return null;
|
|
256
|
+
const claim = await this.lockManager.claimOf(state.id);
|
|
257
|
+
const token = claim?.taskId === taskId ? claim.token : null;
|
|
258
|
+
if (!token)
|
|
259
|
+
return null;
|
|
260
|
+
await this.agentStore.update(state.id, (latest) => {
|
|
261
|
+
if (!latest || latest.taskId !== taskId || latest.lockToken)
|
|
262
|
+
return AGENT_STORE_NOOP;
|
|
263
|
+
return { ...latest, lockToken: token, updatedAt: new Date().toISOString() };
|
|
264
|
+
});
|
|
265
|
+
return token;
|
|
266
|
+
}
|
|
267
|
+
async assertTaskLockOwner(agentId, taskId, token) {
|
|
268
|
+
if (!(await this.lockManager.isOwner(agentId, taskId, token))) {
|
|
269
|
+
throw new Error(`Agent ${agentId} ownership changed for task ${taskId}; operation aborted`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
224
272
|
getReviewStore() {
|
|
225
273
|
return this.reviewStore;
|
|
226
274
|
}
|
|
@@ -248,18 +296,19 @@ export class AgentManager {
|
|
|
248
296
|
getReviewTransport() {
|
|
249
297
|
this.reviewTransportInstance ??= new ReviewTransport({
|
|
250
298
|
createRunnerFor: (agent) => this.createRunnerFor(agent),
|
|
251
|
-
|
|
299
|
+
resolveWorkdir: (agentId) => this.bindingWorkdirCache.get(agentId),
|
|
252
300
|
});
|
|
253
301
|
return this.reviewTransportInstance;
|
|
254
302
|
}
|
|
255
|
-
|
|
256
|
-
async
|
|
303
|
+
bindingWorkdirCache = new Map();
|
|
304
|
+
async refreshWorkdirCacheFor(agentId) {
|
|
257
305
|
const state = await this.agentStore.get(agentId);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
306
|
+
const workdir = state?.workdir;
|
|
307
|
+
if (workdir) {
|
|
308
|
+
this.bindingWorkdirCache.set(agentId, workdir);
|
|
309
|
+
return workdir;
|
|
261
310
|
}
|
|
262
|
-
this.
|
|
311
|
+
this.bindingWorkdirCache.delete(agentId);
|
|
263
312
|
return undefined;
|
|
264
313
|
}
|
|
265
314
|
async safeEmit(event) {
|
|
@@ -295,6 +344,7 @@ export class AgentManager {
|
|
|
295
344
|
const config = prepareConfig(validated);
|
|
296
345
|
this.config = config;
|
|
297
346
|
this.agentIndex = buildAgentIndex(config);
|
|
347
|
+
this.repoCache.owners.clear();
|
|
298
348
|
}
|
|
299
349
|
getConfig() {
|
|
300
350
|
return this.config;
|
|
@@ -333,17 +383,15 @@ export class AgentManager {
|
|
|
333
383
|
try {
|
|
334
384
|
const resolved = await this.ensureWorkdir(agent, project, runner);
|
|
335
385
|
workdir = resolved.workdir;
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
});
|
|
346
|
-
}
|
|
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
|
+
});
|
|
347
395
|
}
|
|
348
396
|
catch (err) {
|
|
349
397
|
throw new EnsureSessionError({ createdSession: false, agentId }, `ensureWorkdir failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -367,9 +415,9 @@ export class AgentManager {
|
|
|
367
415
|
`it creates itself — kill it manually or pick a different agent id`);
|
|
368
416
|
}
|
|
369
417
|
if (alive && (mode === 'runtime' || mode === 'recover')) {
|
|
370
|
-
return this.adoptOrRestartSession(tmux, agent, agentId, workdir);
|
|
418
|
+
return this.adoptOrRestartSession(tmux, runner, agent, agentId, workdir, workdir);
|
|
371
419
|
}
|
|
372
|
-
return this.buildFreshSession(tmux, agent, agentId, workdir);
|
|
420
|
+
return this.buildFreshSession(tmux, agent, agentId, workdir, workdir);
|
|
373
421
|
}
|
|
374
422
|
async provisionRepoSkills(runner, agent, workdir) {
|
|
375
423
|
if (this.skillRegistry.names().length === 0)
|
|
@@ -397,7 +445,12 @@ export class AgentManager {
|
|
|
397
445
|
runUnderSkillDirLock(key, fn) {
|
|
398
446
|
const prev = this.skillDirChain.get(key) ?? Promise.resolve();
|
|
399
447
|
const run = prev.then(fn, fn);
|
|
400
|
-
|
|
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
|
+
});
|
|
401
454
|
return run;
|
|
402
455
|
}
|
|
403
456
|
async atomicWriteFile(runner, finalPath, content) {
|
|
@@ -437,14 +490,28 @@ export class AgentManager {
|
|
|
437
490
|
return tagged !== this.skillRegistry.contentHash();
|
|
438
491
|
}
|
|
439
492
|
async excludeInjectedSkills(runner, workdir, subdir) {
|
|
440
|
-
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; }; ` +
|
|
441
498
|
`pre="$(git rev-parse --show-prefix 2>/dev/null)"; rule="\${pre}${subdir}/baxian-*"; ` +
|
|
442
|
-
`mkdir -p "$(dirname "$p")" && { grep -qxF "$rule" "$p" 2>/dev/null || printf '%s\\n' "$rule" >> "$p"; };
|
|
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`;
|
|
443
503
|
const res = await runner.exec(`sh -c ${shellQuote(inner)}`);
|
|
444
504
|
if (res.exitCode !== 0) {
|
|
445
|
-
|
|
446
|
-
|
|
505
|
+
throw new Error(`skill exclusion probe failed in ${workdir}: ${res.stderr.trim() || `exit ${res.exitCode}`}`);
|
|
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 ', '')}`);
|
|
447
513
|
}
|
|
514
|
+
throw new Error(`cannot make injected skills invisible to Git in ${workdir}: ${verdict || 'no probe output'}`);
|
|
448
515
|
}
|
|
449
516
|
async pinRuntimeSessionOptions(tmux, agentId) {
|
|
450
517
|
await tmux.setOption(agentId, 'prefix', 'C-b');
|
|
@@ -455,17 +522,18 @@ export class AgentManager {
|
|
|
455
522
|
await tmux.setOption(agentId, 'window-size', 'latest');
|
|
456
523
|
await this.pinRuntimeSessionOptions(tmux, agentId);
|
|
457
524
|
}
|
|
458
|
-
async buildFreshSession(tmux, agent, agentId, workdir) {
|
|
459
|
-
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));
|
|
460
527
|
}
|
|
461
|
-
async buildFreshSessionLocked(tmux, agent, agentId, workdir) {
|
|
528
|
+
async buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir) {
|
|
462
529
|
let createdSession = false;
|
|
463
530
|
const runtime = agentRuntimeKindFor(agent);
|
|
464
531
|
try {
|
|
465
|
-
await tmux.createSession(agentId,
|
|
532
|
+
await tmux.createSession(agentId, sessionDir);
|
|
466
533
|
createdSession = true;
|
|
467
534
|
await tmux.setOption(agentId, '@baxian-agent-id', agentId);
|
|
468
535
|
await tmux.setOption(agentId, '@baxian-runtime', agent.runtime);
|
|
536
|
+
await tmux.setOption(agentId, WORKDIR_SESSION_OPTION, sessionDir);
|
|
469
537
|
await tmux.setOption(agentId, 'allow-passthrough', 'on');
|
|
470
538
|
await tmux.setOption(agentId, 'set-titles', 'on');
|
|
471
539
|
await this.pinFreshSessionOptions(tmux, agentId);
|
|
@@ -1037,7 +1105,7 @@ export class AgentManager {
|
|
|
1037
1105
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
1038
1106
|
});
|
|
1039
1107
|
}
|
|
1040
|
-
async adoptOrRestartSession(tmux, agent, agentId, workdir) {
|
|
1108
|
+
async adoptOrRestartSession(tmux, runner, agent, agentId, workdir, sessionDir) {
|
|
1041
1109
|
let claim;
|
|
1042
1110
|
try {
|
|
1043
1111
|
claim = await tmux.getOption(agentId, '@baxian-agent-id');
|
|
@@ -1070,6 +1138,36 @@ export class AgentManager {
|
|
|
1070
1138
|
catch (err) {
|
|
1071
1139
|
throw new EnsureSessionError({ createdSession: false, agentId }, `classifyPaneForAdopt failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1072
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);
|
|
1073
1171
|
switch (state.kind) {
|
|
1074
1172
|
case 'live-runtime': {
|
|
1075
1173
|
let stale;
|
|
@@ -1080,8 +1178,14 @@ export class AgentManager {
|
|
|
1080
1178
|
throw new EnsureSessionError({ createdSession: false, agentId }, `skills-version probe failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1081
1179
|
}
|
|
1082
1180
|
if (stale) {
|
|
1083
|
-
|
|
1084
|
-
|
|
1181
|
+
return {
|
|
1182
|
+
ok: true,
|
|
1183
|
+
createdSession: false,
|
|
1184
|
+
freshRuntime: false,
|
|
1185
|
+
skillsStale: true,
|
|
1186
|
+
paneId,
|
|
1187
|
+
workdir,
|
|
1188
|
+
};
|
|
1085
1189
|
}
|
|
1086
1190
|
return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir };
|
|
1087
1191
|
}
|
|
@@ -1152,7 +1256,10 @@ export class AgentManager {
|
|
|
1152
1256
|
if (!cfg)
|
|
1153
1257
|
throw new Error(`Unknown agent: ${agentId}`);
|
|
1154
1258
|
const state = await this.agentStore.get(agentId);
|
|
1155
|
-
const
|
|
1259
|
+
const existingToken = state?.taskId === taskId && state
|
|
1260
|
+
? await this.resolveTaskLockToken(state, taskId)
|
|
1261
|
+
: null;
|
|
1262
|
+
const sameTaskLocked = existingToken !== null;
|
|
1156
1263
|
const reentryPhases = new Set([
|
|
1157
1264
|
'fix', 'post-approve', 'code',
|
|
1158
1265
|
'server-feedback', 'server-after-done',
|
|
@@ -1165,17 +1272,16 @@ export class AgentManager {
|
|
|
1165
1272
|
if (!sameTaskReentry && !canDispatchWithBinding(state)) {
|
|
1166
1273
|
return false;
|
|
1167
1274
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
return false;
|
|
1172
|
-
}
|
|
1275
|
+
const token = reuseLock ? existingToken : await this.lockManager.acquire(agentId, taskId);
|
|
1276
|
+
if (!token)
|
|
1277
|
+
return false;
|
|
1173
1278
|
const now = new Date().toISOString();
|
|
1174
1279
|
await this.agentStore.update(agentId, (existing) => ({
|
|
1175
1280
|
...(existing ?? { id: agentId, projectId: cfg.projectId, updatedAt: now }),
|
|
1176
1281
|
id: agentId,
|
|
1177
1282
|
projectId: cfg.projectId,
|
|
1178
1283
|
taskId,
|
|
1284
|
+
lockToken: token,
|
|
1179
1285
|
updatedAt: now,
|
|
1180
1286
|
}));
|
|
1181
1287
|
return true;
|
|
@@ -1208,6 +1314,11 @@ export class AgentManager {
|
|
|
1208
1314
|
const cfg = this.getAgentConfig(agentId);
|
|
1209
1315
|
if (!cfg)
|
|
1210
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
|
+
}
|
|
1211
1322
|
const now = new Date().toISOString();
|
|
1212
1323
|
if (mode === 'waiting') {
|
|
1213
1324
|
await this.agentStore.update(agentId, (latest) => {
|
|
@@ -1219,9 +1330,9 @@ export class AgentManager {
|
|
|
1219
1330
|
projectId: latest.projectId,
|
|
1220
1331
|
updatedAt: now,
|
|
1221
1332
|
...(latest.taskId !== undefined ? { taskId: latest.taskId } : {}),
|
|
1333
|
+
...(latest.lockToken !== undefined ? { lockToken: latest.lockToken } : {}),
|
|
1222
1334
|
...(latest.paneId !== undefined ? { paneId: latest.paneId } : {}),
|
|
1223
|
-
...(latest.
|
|
1224
|
-
...(latest.worktreePath !== undefined ? { worktreePath: latest.worktreePath } : {}),
|
|
1335
|
+
...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
|
|
1225
1336
|
...(latest.startedAt !== undefined ? { startedAt: latest.startedAt } : {}),
|
|
1226
1337
|
...(latest.creationToken !== undefined ? { creationToken: latest.creationToken } : {}),
|
|
1227
1338
|
};
|
|
@@ -1234,19 +1345,118 @@ export class AgentManager {
|
|
|
1234
1345
|
});
|
|
1235
1346
|
return true;
|
|
1236
1347
|
}
|
|
1237
|
-
if (state.
|
|
1238
|
-
const
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
const
|
|
1242
|
-
|
|
1243
|
-
|
|
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);
|
|
1244
1447
|
}
|
|
1245
|
-
|
|
1246
|
-
|
|
1448
|
+
else {
|
|
1449
|
+
await new BranchManager(runner).parkOnDefaultDetached(state.workdir);
|
|
1247
1450
|
}
|
|
1248
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
|
+
}
|
|
1249
1458
|
}
|
|
1459
|
+
await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
|
|
1250
1460
|
await this.agentStore.update(agentId, (existing) => {
|
|
1251
1461
|
if (!existing)
|
|
1252
1462
|
return AGENT_STORE_NOOP;
|
|
@@ -1255,16 +1465,172 @@ export class AgentManager {
|
|
|
1255
1465
|
return {
|
|
1256
1466
|
id: existing.id,
|
|
1257
1467
|
projectId: existing.projectId,
|
|
1258
|
-
...(existing.
|
|
1468
|
+
...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
1259
1469
|
...(existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
1260
1470
|
...(existing.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
1261
1471
|
updatedAt: now,
|
|
1262
1472
|
};
|
|
1263
1473
|
});
|
|
1264
|
-
|
|
1265
|
-
return true;
|
|
1474
|
+
return this.lockManager.releaseIfOwner(agentId, expectedTaskId, lockToken);
|
|
1266
1475
|
});
|
|
1267
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
|
+
}
|
|
1268
1634
|
async setAgentNeedInput(agentId, pending, opts = {}) {
|
|
1269
1635
|
const now = new Date().toISOString();
|
|
1270
1636
|
try {
|
|
@@ -1417,6 +1783,13 @@ export class AgentManager {
|
|
|
1417
1783
|
console.warn(`[AgentManager] resumeAgent: agent ${agentId} — ${reason}`);
|
|
1418
1784
|
return { resumed: false, releasedBinding: false, reason };
|
|
1419
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
|
+
}
|
|
1420
1793
|
if (state.awaitingPhase === 'code-dispatch-failed'
|
|
1421
1794
|
&& boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)
|
|
1422
1795
|
&& state.taskId) {
|
|
@@ -1448,19 +1821,14 @@ export class AgentManager {
|
|
|
1448
1821
|
}
|
|
1449
1822
|
const now = new Date().toISOString();
|
|
1450
1823
|
const shouldReleaseBinding = shouldReleaseHeldBinding(state, boundTask);
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
}
|
|
1460
|
-
catch (err) {
|
|
1461
|
-
console.warn(`[AgentManager] resumeAgent worktree.remove failed for ${state.worktreePath}:`, err);
|
|
1462
|
-
}
|
|
1463
|
-
}
|
|
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
|
+
};
|
|
1464
1832
|
}
|
|
1465
1833
|
await this.agentStore.update(agentId, (existing) => {
|
|
1466
1834
|
if (!existing)
|
|
@@ -1469,7 +1837,7 @@ export class AgentManager {
|
|
|
1469
1837
|
id: existing.id,
|
|
1470
1838
|
projectId: existing.projectId,
|
|
1471
1839
|
updatedAt: now,
|
|
1472
|
-
...(existing.
|
|
1840
|
+
...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
1473
1841
|
...(existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
1474
1842
|
...(existing.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
1475
1843
|
status: 'ok',
|
|
@@ -1477,23 +1845,41 @@ export class AgentManager {
|
|
|
1477
1845
|
if (!shouldReleaseBinding) {
|
|
1478
1846
|
if (existing.taskId !== undefined)
|
|
1479
1847
|
next.taskId = existing.taskId;
|
|
1480
|
-
if (existing.
|
|
1481
|
-
next.
|
|
1848
|
+
if (existing.lockToken !== undefined)
|
|
1849
|
+
next.lockToken = existing.lockToken;
|
|
1482
1850
|
if (existing.startedAt !== undefined)
|
|
1483
1851
|
next.startedAt = existing.startedAt;
|
|
1484
1852
|
}
|
|
1485
1853
|
return next;
|
|
1486
1854
|
});
|
|
1487
|
-
if (shouldReleaseBinding) {
|
|
1488
|
-
await this.lockManager.release(agentId);
|
|
1489
|
-
}
|
|
1490
1855
|
await this.emitIntervention(state.projectId, agentId, state.taskId, {
|
|
1491
1856
|
phase: 'resumed',
|
|
1492
1857
|
previousPhase: state.awaitingPhase,
|
|
1493
|
-
releasedBinding:
|
|
1858
|
+
releasedBinding: false,
|
|
1494
1859
|
});
|
|
1495
|
-
return { resumed: true, releasedBinding:
|
|
1860
|
+
return { resumed: true, releasedBinding: false };
|
|
1496
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
|
+
}
|
|
1497
1883
|
if (result.redispatchCodeTaskId) {
|
|
1498
1884
|
try {
|
|
1499
1885
|
const resumed = await this.continueSession(result.redispatchCodeTaskId, agentId, 'code');
|
|
@@ -1698,7 +2084,7 @@ export class AgentManager {
|
|
|
1698
2084
|
const out = [];
|
|
1699
2085
|
for (const t of tasks) {
|
|
1700
2086
|
const bound = t.agentId === agentId || t.qaAgentId === agentId;
|
|
1701
|
-
// spec-ready 不豁免:approve/打回都依赖 dev
|
|
2087
|
+
// spec-ready 不豁免:approve/打回都依赖 dev Workdir,dev 丢失后停驻只是假活
|
|
1702
2088
|
if (t.status === 'ready' || t.status === 'merge-ready')
|
|
1703
2089
|
continue;
|
|
1704
2090
|
if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
|
|
@@ -1788,18 +2174,16 @@ export class AgentManager {
|
|
|
1788
2174
|
throw new Error(`restart-repl precondition failed: unexpected pane state "${cmd}"`);
|
|
1789
2175
|
}
|
|
1790
2176
|
const project = this.getProjectConfig(cfg.projectId);
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
}
|
|
1802
|
-
}
|
|
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);
|
|
1803
2187
|
const runtime = agentRuntimeKindFor(cfg);
|
|
1804
2188
|
const relaunch = async () => {
|
|
1805
2189
|
await tmux.sendKeysToPane(paneId, `${buildLaunchCommand(cfg)}\n`);
|
|
@@ -1810,16 +2194,9 @@ export class AgentManager {
|
|
|
1810
2194
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
1811
2195
|
scrollback: 0,
|
|
1812
2196
|
});
|
|
1813
|
-
|
|
1814
|
-
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
1815
|
-
}
|
|
2197
|
+
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
1816
2198
|
};
|
|
1817
|
-
|
|
1818
|
-
await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
|
|
1819
|
-
}
|
|
1820
|
-
else {
|
|
1821
|
-
await relaunch();
|
|
1822
|
-
}
|
|
2199
|
+
await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
|
|
1823
2200
|
await this.agentStore.update(agentId, (state) => {
|
|
1824
2201
|
if (!state)
|
|
1825
2202
|
return AGENT_STORE_NOOP;
|
|
@@ -1855,7 +2232,21 @@ export class AgentManager {
|
|
|
1855
2232
|
continue;
|
|
1856
2233
|
const runner = this.createRunnerFor(cfg);
|
|
1857
2234
|
const tmux = new TmuxManager(runner);
|
|
1858
|
-
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
|
+
}
|
|
1859
2250
|
this.stopRuntimeMenuWatch(id);
|
|
1860
2251
|
if (this.paneStreamerManager) {
|
|
1861
2252
|
try {
|
|
@@ -1881,18 +2272,6 @@ export class AgentManager {
|
|
|
1881
2272
|
catch (err) {
|
|
1882
2273
|
failures.push({ agentId: id, step: 'tmux', error: err });
|
|
1883
2274
|
}
|
|
1884
|
-
const state = await this.agentStore.get(id);
|
|
1885
|
-
if (state?.worktreePath) {
|
|
1886
|
-
const cleanupDir = this.resolveWorkdir(cfg, state);
|
|
1887
|
-
if (cleanupDir) {
|
|
1888
|
-
try {
|
|
1889
|
-
await worktree.remove(cleanupDir, state.worktreePath);
|
|
1890
|
-
}
|
|
1891
|
-
catch (err) {
|
|
1892
|
-
failures.push({ agentId: id, step: 'worktree.remove', error: err });
|
|
1893
|
-
}
|
|
1894
|
-
}
|
|
1895
|
-
}
|
|
1896
2275
|
}
|
|
1897
2276
|
if (failures.length > 0) {
|
|
1898
2277
|
const summary = failures
|
|
@@ -1925,7 +2304,6 @@ export class AgentManager {
|
|
|
1925
2304
|
workdirForEstimate = cfg.workdir;
|
|
1926
2305
|
}
|
|
1927
2306
|
const workdirGuess = workdirForEstimate ?? '/'.padEnd(64, 'x');
|
|
1928
|
-
const worktreePathBound = `${workdirGuess}/.baxian-worktrees/task-9999999999_ffffffffffffffff`;
|
|
1929
2307
|
const now = new Date().toISOString();
|
|
1930
2308
|
const fakeTask = {
|
|
1931
2309
|
id: 'task-9999999999',
|
|
@@ -1944,7 +2322,7 @@ export class AgentManager {
|
|
|
1944
2322
|
task: fakeTask,
|
|
1945
2323
|
phase: 'develop',
|
|
1946
2324
|
agent: cfg,
|
|
1947
|
-
|
|
2325
|
+
workdir: workdirGuess,
|
|
1948
2326
|
skillRegistry: this.skillRegistry,
|
|
1949
2327
|
signalToken: 'preview-signal-token',
|
|
1950
2328
|
});
|
|
@@ -2075,7 +2453,7 @@ export class AgentManager {
|
|
|
2075
2453
|
const allowRecoveredReadFile = task.status === 'review'
|
|
2076
2454
|
&& (task.phase === 'spec'
|
|
2077
2455
|
|| (task.reviewMode === 'server' && task.batchTotal !== undefined)
|
|
2078
|
-
|| (task.reviewMode === 'server' && task.
|
|
2456
|
+
|| (task.reviewMode === 'server' && task.reviewCheckoutMode === 'base'));
|
|
2079
2457
|
try {
|
|
2080
2458
|
await this.startPhaseSignalWatch({
|
|
2081
2459
|
taskId: task.id,
|
|
@@ -2193,8 +2571,11 @@ export class AgentManager {
|
|
|
2193
2571
|
return this.taskStore.list({ projectId });
|
|
2194
2572
|
}
|
|
2195
2573
|
async findTaskByBranch(branch, projectId) {
|
|
2196
|
-
const
|
|
2197
|
-
|
|
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);
|
|
2198
2579
|
}
|
|
2199
2580
|
async listTasksByPrNumber(prNumber, projectId) {
|
|
2200
2581
|
const all = await this.taskStore.list({ projectId });
|
|
@@ -2261,13 +2642,11 @@ export class AgentManager {
|
|
|
2261
2642
|
createRepoStore(agent, project, runner) {
|
|
2262
2643
|
const host = resolveAgentHost(this.config.host, agent.host);
|
|
2263
2644
|
if (this.repoStoreFactory) {
|
|
2264
|
-
return this.repoStoreFactory(runner,
|
|
2645
|
+
return this.repoStoreFactory(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir);
|
|
2265
2646
|
}
|
|
2266
|
-
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);
|
|
2267
2648
|
}
|
|
2268
2649
|
async ensureWorkdir(agent, project, runner) {
|
|
2269
|
-
if (agent.workdir)
|
|
2270
|
-
return { workdir: agent.workdir, repoStore: null };
|
|
2271
2650
|
const repoStore = this.createRepoStore(agent, project, runner);
|
|
2272
2651
|
const workdir = await repoStore.ensure();
|
|
2273
2652
|
return { workdir, repoStore };
|
|
@@ -2275,22 +2654,29 @@ export class AgentManager {
|
|
|
2275
2654
|
resolveWorkdir(agent, agentState) {
|
|
2276
2655
|
if (agent.workdir)
|
|
2277
2656
|
return agent.workdir;
|
|
2278
|
-
return agentState?.
|
|
2279
|
-
}
|
|
2280
|
-
async resolveAutoBaseRef(runner, workdir) {
|
|
2281
|
-
const result = await runner.exec(`git -C ${shellQuote(workdir)} rev-parse --verify --quiet origin/HEAD`);
|
|
2282
|
-
if (result.exitCode !== 0) {
|
|
2283
|
-
// Falling back to the shared clone's HEAD would seed the worktree with whatever
|
|
2284
|
-
// another agent left checked out — refuse instead of silently cross-contaminating.
|
|
2285
|
-
throw new Error(`origin/HEAD is unresolvable in ${workdir}; ` +
|
|
2286
|
-
`run "git -C ${workdir} remote set-head origin --auto" and redispatch`);
|
|
2287
|
-
}
|
|
2288
|
-
return 'origin/HEAD';
|
|
2657
|
+
return agentState?.workdir ?? null;
|
|
2289
2658
|
}
|
|
2290
2659
|
getRepoCache() {
|
|
2291
2660
|
return this.repoCache;
|
|
2292
2661
|
}
|
|
2293
|
-
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
|
+
}
|
|
2294
2680
|
let rolledBack = null;
|
|
2295
2681
|
await this.withTaskLock(async () => {
|
|
2296
2682
|
const task = await this.taskStore.get(taskId);
|
|
@@ -2311,17 +2697,7 @@ export class AgentManager {
|
|
|
2311
2697
|
note: 'Dispatch failed; the task is back in pending — re-dispatch it once the cause is resolved.',
|
|
2312
2698
|
});
|
|
2313
2699
|
}
|
|
2314
|
-
const
|
|
2315
|
-
if (existing && existing.taskId !== taskId) {
|
|
2316
|
-
console.warn(`[AgentManager] rollback: agent ${agentId} taskId mismatch (expected ${taskId}, got ${existing.taskId}); ` +
|
|
2317
|
-
`skipping agent cleanup — agent already reassigned`);
|
|
2318
|
-
return;
|
|
2319
|
-
}
|
|
2320
|
-
if (isCancelCleanupHold(existing)) {
|
|
2321
|
-
console.warn(`[AgentManager] rollback: agent ${agentId} held by cancel cleanup (${existing?.awaitingPhase}); ` +
|
|
2322
|
-
`leaving binding to the owner`);
|
|
2323
|
-
return;
|
|
2324
|
-
}
|
|
2700
|
+
const lockToken = expectedLockToken;
|
|
2325
2701
|
const projectId = existing?.projectId ?? this.getAgentConfig(agentId)?.projectId;
|
|
2326
2702
|
if (!projectId) {
|
|
2327
2703
|
console.error(`[AgentManager] CRITICAL: cannot resolve projectId for agent ${agentId} during rollback; deleting agent state.`);
|
|
@@ -2329,16 +2705,23 @@ export class AgentManager {
|
|
|
2329
2705
|
}
|
|
2330
2706
|
else {
|
|
2331
2707
|
const now = new Date().toISOString();
|
|
2332
|
-
await this.agentStore.update(agentId, (latest) =>
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
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
|
+
});
|
|
2340
2722
|
}
|
|
2341
|
-
|
|
2723
|
+
if (lockToken)
|
|
2724
|
+
await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
|
|
2342
2725
|
}
|
|
2343
2726
|
async pickAgent(projectId, preferredAgentId) {
|
|
2344
2727
|
const cfg = this.getAgentConfig(preferredAgentId);
|
|
@@ -2369,6 +2752,7 @@ export class AgentManager {
|
|
|
2369
2752
|
}
|
|
2370
2753
|
}
|
|
2371
2754
|
const taskBranch = input.branch ?? BRANCH_PREFIX + taskId;
|
|
2755
|
+
const branchCreatedByBaxian = input.branch === undefined;
|
|
2372
2756
|
if (input.branch) {
|
|
2373
2757
|
const existing = await this.findTaskByBranch(input.branch, projectId);
|
|
2374
2758
|
if (existing) {
|
|
@@ -2389,6 +2773,7 @@ export class AgentManager {
|
|
|
2389
2773
|
reviewRound: 0,
|
|
2390
2774
|
status: 'pending',
|
|
2391
2775
|
branch: taskBranch,
|
|
2776
|
+
branchCreatedByBaxian,
|
|
2392
2777
|
reviewMode: this.effectiveReviewMode(projectId),
|
|
2393
2778
|
createdAt: now,
|
|
2394
2779
|
updatedAt: now,
|
|
@@ -2418,6 +2803,7 @@ export class AgentManager {
|
|
|
2418
2803
|
reviewRound: 0,
|
|
2419
2804
|
status: 'pending',
|
|
2420
2805
|
branch: taskBranch,
|
|
2806
|
+
branchCreatedByBaxian,
|
|
2421
2807
|
reviewMode: this.effectiveReviewMode(projectId),
|
|
2422
2808
|
createdAt: now,
|
|
2423
2809
|
updatedAt: now,
|
|
@@ -2439,8 +2825,8 @@ export class AgentManager {
|
|
|
2439
2825
|
});
|
|
2440
2826
|
return queued;
|
|
2441
2827
|
}
|
|
2442
|
-
const
|
|
2443
|
-
if (!
|
|
2828
|
+
const lockToken = await this.lockManager.acquire(dev.id, taskId);
|
|
2829
|
+
if (!lockToken) {
|
|
2444
2830
|
const queued = {
|
|
2445
2831
|
id: taskId,
|
|
2446
2832
|
projectId,
|
|
@@ -2451,6 +2837,7 @@ export class AgentManager {
|
|
|
2451
2837
|
reviewRound: 0,
|
|
2452
2838
|
status: 'pending',
|
|
2453
2839
|
branch: taskBranch,
|
|
2840
|
+
branchCreatedByBaxian,
|
|
2454
2841
|
reviewMode: this.effectiveReviewMode(projectId),
|
|
2455
2842
|
createdAt: now,
|
|
2456
2843
|
updatedAt: now,
|
|
@@ -2483,6 +2870,7 @@ export class AgentManager {
|
|
|
2483
2870
|
reviewRound: 0,
|
|
2484
2871
|
status: 'in_progress',
|
|
2485
2872
|
branch: taskBranch,
|
|
2873
|
+
branchCreatedByBaxian,
|
|
2486
2874
|
reviewMode: this.effectiveReviewMode(projectId),
|
|
2487
2875
|
createdAt: now,
|
|
2488
2876
|
updatedAt: now,
|
|
@@ -2493,10 +2881,11 @@ export class AgentManager {
|
|
|
2493
2881
|
id: dev.id,
|
|
2494
2882
|
projectId,
|
|
2495
2883
|
taskId,
|
|
2884
|
+
lockToken,
|
|
2496
2885
|
bootstrappingTaskId: taskId,
|
|
2497
2886
|
updatedAt: now,
|
|
2498
2887
|
...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
2499
|
-
...(existing?.
|
|
2888
|
+
...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
2500
2889
|
...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
2501
2890
|
}));
|
|
2502
2891
|
await this.safeEmit({
|
|
@@ -2516,7 +2905,8 @@ export class AgentManager {
|
|
|
2516
2905
|
if (task.status === 'in_progress' && task.agentId) {
|
|
2517
2906
|
const signalToken = createSignalToken();
|
|
2518
2907
|
await this.updateTask(task.id, { signalToken });
|
|
2519
|
-
const
|
|
2908
|
+
const dispatchLockToken = (await this.agentStore.get(task.agentId))?.lockToken;
|
|
2909
|
+
const start = this.startCreatedTaskSession(task.id, task.agentId, signalToken, dispatchLockToken);
|
|
2520
2910
|
if (opts.background) {
|
|
2521
2911
|
void start.catch((err) => {
|
|
2522
2912
|
console.error(`[AgentManager] createAndStartTask background start failed for task=${task.id}:`, err);
|
|
@@ -2530,7 +2920,7 @@ export class AgentManager {
|
|
|
2530
2920
|
}
|
|
2531
2921
|
return task;
|
|
2532
2922
|
}
|
|
2533
|
-
async startCreatedTaskSession(taskId, agentId, signalToken) {
|
|
2923
|
+
async startCreatedTaskSession(taskId, agentId, signalToken, dispatchLockToken) {
|
|
2534
2924
|
let started = false;
|
|
2535
2925
|
let dispatchErr = null;
|
|
2536
2926
|
try {
|
|
@@ -2591,7 +2981,7 @@ export class AgentManager {
|
|
|
2591
2981
|
await this.rollbackFailedDispatch(taskId, agentId, dispatchErr ? {
|
|
2592
2982
|
phase: 'dispatch-rollback',
|
|
2593
2983
|
message: dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr),
|
|
2594
|
-
} : undefined);
|
|
2984
|
+
} : undefined, dispatchLockToken);
|
|
2595
2985
|
}
|
|
2596
2986
|
}
|
|
2597
2987
|
return (await this.taskStore.get(taskId)) ?? null;
|
|
@@ -2696,6 +3086,33 @@ export class AgentManager {
|
|
|
2696
3086
|
async clearAgent(agentId) {
|
|
2697
3087
|
return this.sendSlashCommand(agentId, '/clear');
|
|
2698
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
|
+
}
|
|
2699
3116
|
async persistTaskImages(taskId, images) {
|
|
2700
3117
|
const dir = join(this.imageStagingRoot, taskId);
|
|
2701
3118
|
await mkdir(dir, { recursive: true });
|
|
@@ -2784,8 +3201,8 @@ export class AgentManager {
|
|
|
2784
3201
|
if (!canDispatchWithBinding(state)) {
|
|
2785
3202
|
return { task: fresh, errorCode: 409, error: `Agent ${agentId} is busy or awaiting human` };
|
|
2786
3203
|
}
|
|
2787
|
-
const
|
|
2788
|
-
if (!
|
|
3204
|
+
const lockToken = await this.lockManager.acquire(agentId, taskId);
|
|
3205
|
+
if (!lockToken) {
|
|
2789
3206
|
return { task: fresh, errorCode: 409, error: `Agent ${agentId} lock acquisition failed` };
|
|
2790
3207
|
}
|
|
2791
3208
|
const now = new Date().toISOString();
|
|
@@ -2803,10 +3220,11 @@ export class AgentManager {
|
|
|
2803
3220
|
id: agentId,
|
|
2804
3221
|
projectId: cfg.projectId,
|
|
2805
3222
|
taskId,
|
|
3223
|
+
lockToken,
|
|
2806
3224
|
bootstrappingTaskId: taskId,
|
|
2807
3225
|
updatedAt: now,
|
|
2808
3226
|
...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
2809
|
-
...(existing?.
|
|
3227
|
+
...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
2810
3228
|
...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
2811
3229
|
}));
|
|
2812
3230
|
await this.safeEmit({
|
|
@@ -2818,7 +3236,7 @@ export class AgentManager {
|
|
|
2818
3236
|
taskId,
|
|
2819
3237
|
data: { agentId, manuallyDispatched: true },
|
|
2820
3238
|
});
|
|
2821
|
-
return { task: claimedTask };
|
|
3239
|
+
return { task: claimedTask, lockToken };
|
|
2822
3240
|
});
|
|
2823
3241
|
if (claim.errorCode !== undefined)
|
|
2824
3242
|
return claim;
|
|
@@ -2850,7 +3268,7 @@ export class AgentManager {
|
|
|
2850
3268
|
await this.rollbackFailedDispatch(claimed.id, claimed.agentId, dispatchErr ? {
|
|
2851
3269
|
phase: 'dispatch-rollback',
|
|
2852
3270
|
message: dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr),
|
|
2853
|
-
} : undefined);
|
|
3271
|
+
} : undefined, claim.lockToken);
|
|
2854
3272
|
}
|
|
2855
3273
|
const refreshed = await this.taskStore.get(claimed.id);
|
|
2856
3274
|
if (dispatchErr === null) {
|
|
@@ -2859,6 +3277,50 @@ export class AgentManager {
|
|
|
2859
3277
|
const err = dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr);
|
|
2860
3278
|
return { task: refreshed, errorCode: 500, error: err };
|
|
2861
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
|
+
}
|
|
2862
3324
|
async startSession(taskId, agentId, phase, opts = {}) {
|
|
2863
3325
|
const agent = this.getAgentConfig(agentId);
|
|
2864
3326
|
if (!agent)
|
|
@@ -2898,6 +3360,23 @@ export class AgentManager {
|
|
|
2898
3360
|
`${preAgent.taskId} (request ${taskId}); aborting`);
|
|
2899
3361
|
return false;
|
|
2900
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();
|
|
2901
3380
|
const dialogFailFromStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
|
|
2902
3381
|
let ensure;
|
|
2903
3382
|
try {
|
|
@@ -2908,9 +3387,19 @@ export class AgentManager {
|
|
|
2908
3387
|
throw err;
|
|
2909
3388
|
}
|
|
2910
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);
|
|
2911
3394
|
try {
|
|
2912
|
-
|
|
2913
|
-
|
|
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
|
+
}
|
|
2914
3403
|
}
|
|
2915
3404
|
catch (cleanupErr) {
|
|
2916
3405
|
console.warn(`[AgentManager] startSession ensureSession rollback killSession failed:`, cleanupErr);
|
|
@@ -2920,36 +3409,91 @@ export class AgentManager {
|
|
|
2920
3409
|
}
|
|
2921
3410
|
const { paneId, workdir } = ensure;
|
|
2922
3411
|
const runner = this.createRunnerFor(agent);
|
|
2923
|
-
const worktree = new WorktreeManager(runner);
|
|
2924
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);
|
|
2925
3419
|
const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
|
|
2926
3420
|
const isServerCodeReviewPhase = phase === 'server-review' || phase === 'server-recheck';
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
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
|
+
};
|
|
2943
3489
|
await this.agentStore.update(agentId, (stateNow) => {
|
|
2944
3490
|
if (!stateNow || stateNow.taskId !== taskId)
|
|
2945
3491
|
return AGENT_STORE_NOOP;
|
|
2946
|
-
// A fresh dispatch supersedes any question asked under the previous prompt.
|
|
2947
3492
|
const { needInputAt: _needInputAt, ...rest } = stateNow;
|
|
2948
3493
|
return {
|
|
2949
3494
|
...rest,
|
|
2950
3495
|
paneId,
|
|
2951
|
-
|
|
2952
|
-
repoPath: workdir,
|
|
3496
|
+
workdir,
|
|
2953
3497
|
updatedAt: new Date().toISOString(),
|
|
2954
3498
|
};
|
|
2955
3499
|
});
|
|
@@ -2958,17 +3502,18 @@ export class AgentManager {
|
|
|
2958
3502
|
const hasQaPartner = !!(task.qaAgentId ?? this.findQaPartner(agentId)?.id);
|
|
2959
3503
|
let prompt;
|
|
2960
3504
|
try {
|
|
3505
|
+
await this.getReviewTransport().clearDispatchOutputs(agent, dispatchWorkdir, phase);
|
|
2961
3506
|
const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
|
|
2962
3507
|
let payloadOpts = {};
|
|
2963
3508
|
if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
|
|
2964
|
-
payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent,
|
|
3509
|
+
payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, dispatchWorkdir, {
|
|
2965
3510
|
phase,
|
|
2966
3511
|
...(task.phase ? { taskPhase: task.phase } : {}),
|
|
2967
3512
|
...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
|
|
2968
3513
|
reviewRound: task.reviewRound,
|
|
2969
3514
|
...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
|
|
2970
3515
|
...(opts.serverContent !== undefined ? { serverContent: opts.serverContent } : {}),
|
|
2971
|
-
...(
|
|
3516
|
+
...(reviewCheckout?.mode === 'head' ? { forceContentFile: true } : {}),
|
|
2972
3517
|
...(opts.serverDiffstat !== undefined ? { serverDiffstat: opts.serverDiffstat } : {}),
|
|
2973
3518
|
...(opts.serverInterdiff !== undefined ? { serverInterdiff: opts.serverInterdiff } : {}),
|
|
2974
3519
|
...(opts.serverPriorFindings ? { serverPriorFindings: opts.serverPriorFindings } : {}),
|
|
@@ -2979,14 +3524,14 @@ export class AgentManager {
|
|
|
2979
3524
|
task,
|
|
2980
3525
|
phase,
|
|
2981
3526
|
agent,
|
|
2982
|
-
|
|
3527
|
+
workdir: dispatchWorkdir,
|
|
2983
3528
|
skillRegistry: this.skillRegistry,
|
|
2984
3529
|
hasQaPartner,
|
|
2985
3530
|
...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
|
|
2986
3531
|
...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
|
|
2987
3532
|
...(imagePaths.length ? { imagePaths } : {}),
|
|
2988
|
-
...(
|
|
2989
|
-
...(
|
|
3533
|
+
...(reviewCheckout ? { serverReviewCheckout: reviewCheckout.mode } : {}),
|
|
3534
|
+
...(reviewCheckout?.fallbackReason ? { serverReviewFallbackReason: reviewCheckout.fallbackReason } : {}),
|
|
2990
3535
|
...(opts.serverBaseSha !== undefined ? { serverBaseSha: opts.serverBaseSha } : {}),
|
|
2991
3536
|
...(opts.serverHeadSha !== undefined ? { serverHeadSha: opts.serverHeadSha } : {}),
|
|
2992
3537
|
...(opts.serverHeadTree !== undefined ? { serverHeadTree: opts.serverHeadTree } : {}),
|
|
@@ -2995,10 +3540,7 @@ export class AgentManager {
|
|
|
2995
3540
|
});
|
|
2996
3541
|
}
|
|
2997
3542
|
catch (err) {
|
|
2998
|
-
|
|
2999
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3000
|
-
}
|
|
3001
|
-
catch { }
|
|
3543
|
+
await cleanupCheckoutOrHold();
|
|
3002
3544
|
if (err instanceof PromptSizeError) {
|
|
3003
3545
|
throw new DispatchTerminalError('prompt_too_large', err.message);
|
|
3004
3546
|
}
|
|
@@ -3009,57 +3551,39 @@ export class AgentManager {
|
|
|
3009
3551
|
}
|
|
3010
3552
|
const taskFresh = await this.taskStore.get(taskId);
|
|
3011
3553
|
if (!taskFresh) {
|
|
3012
|
-
console.warn(`[AgentManager] startSession: task ${taskId} disappeared mid-dispatch;
|
|
3013
|
-
|
|
3014
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3015
|
-
}
|
|
3016
|
-
catch { }
|
|
3554
|
+
console.warn(`[AgentManager] startSession: task ${taskId} disappeared mid-dispatch; parking checkout before paste`);
|
|
3555
|
+
await cleanupCheckoutOrHold();
|
|
3017
3556
|
return false;
|
|
3018
3557
|
}
|
|
3019
3558
|
if (TERMINAL_STATUSES.includes(taskFresh.status)) {
|
|
3020
3559
|
console.warn(`[AgentManager] startSession: task ${taskId} status=${taskFresh.status} is terminal ` +
|
|
3021
|
-
`for phase=${phase};
|
|
3022
|
-
|
|
3023
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3024
|
-
}
|
|
3025
|
-
catch { }
|
|
3560
|
+
`for phase=${phase}; parking checkout before paste`);
|
|
3561
|
+
await cleanupCheckoutOrHold();
|
|
3026
3562
|
return false;
|
|
3027
3563
|
}
|
|
3028
3564
|
if (!opts.bypassTaskStatusGate && !expectedStatuses.includes(taskFresh.status)) {
|
|
3029
3565
|
console.warn(`[AgentManager] startSession: task ${taskId} status=${taskFresh.status} not in ` +
|
|
3030
|
-
`expected ${expectedStatuses.join('/')} for phase=${phase};
|
|
3031
|
-
|
|
3032
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3033
|
-
}
|
|
3034
|
-
catch { }
|
|
3566
|
+
`expected ${expectedStatuses.join('/')} for phase=${phase}; parking checkout before paste`);
|
|
3567
|
+
await cleanupCheckoutOrHold();
|
|
3035
3568
|
return false;
|
|
3036
3569
|
}
|
|
3037
3570
|
const agentFresh = await this.agentStore.get(agentId);
|
|
3038
3571
|
if (PHASE_REQUIRES_AGENT_BOUND_TO_TASK[phase]) {
|
|
3039
3572
|
if (!agentFresh || agentFresh.taskId !== taskId) {
|
|
3040
3573
|
console.warn(`[AgentManager] startSession[${phase}]: agent ${agentId} not bound to ${taskId} ` +
|
|
3041
|
-
`(got taskId=${agentFresh?.taskId});
|
|
3042
|
-
|
|
3043
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3044
|
-
}
|
|
3045
|
-
catch { }
|
|
3574
|
+
`(got taskId=${agentFresh?.taskId}); parking checkout before paste`);
|
|
3575
|
+
await cleanupCheckoutOrHold();
|
|
3046
3576
|
return false;
|
|
3047
3577
|
}
|
|
3048
3578
|
}
|
|
3049
3579
|
else if (agentFresh && agentFresh.taskId && agentFresh.taskId !== taskId) {
|
|
3050
3580
|
console.warn(`[AgentManager] startSession[${phase}]: agent ${agentId} reassigned to ${agentFresh.taskId} ` +
|
|
3051
|
-
`(was ${taskId});
|
|
3052
|
-
|
|
3053
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3054
|
-
}
|
|
3055
|
-
catch { }
|
|
3581
|
+
`(was ${taskId}); parking checkout before paste`);
|
|
3582
|
+
await cleanupCheckoutOrHold();
|
|
3056
3583
|
return false;
|
|
3057
3584
|
}
|
|
3058
|
-
if (opts.armBeforeInject && !(await opts.armBeforeInject({
|
|
3059
|
-
|
|
3060
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3061
|
-
}
|
|
3062
|
-
catch { }
|
|
3585
|
+
if (opts.armBeforeInject && !(await opts.armBeforeInject({ serverReviewCheckout: reviewCheckout?.mode }))) {
|
|
3586
|
+
await cleanupCheckoutOrHold();
|
|
3063
3587
|
return false;
|
|
3064
3588
|
}
|
|
3065
3589
|
const now = new Date().toISOString();
|
|
@@ -3076,8 +3600,8 @@ export class AgentManager {
|
|
|
3076
3600
|
projectId: agent.projectId,
|
|
3077
3601
|
paneId,
|
|
3078
3602
|
taskId,
|
|
3079
|
-
|
|
3080
|
-
|
|
3603
|
+
workdir,
|
|
3604
|
+
lockToken,
|
|
3081
3605
|
startedAt: now,
|
|
3082
3606
|
bootstrappingTaskId: taskId,
|
|
3083
3607
|
updatedAt: now,
|
|
@@ -3087,10 +3611,7 @@ export class AgentManager {
|
|
|
3087
3611
|
if (cancelHoldWon) {
|
|
3088
3612
|
console.warn(`[AgentManager] startSession[${phase}]: agent ${agentId} entered a cancel-cleanup hold during dispatch; ` +
|
|
3089
3613
|
`aborting so cancel can finish interrupt + /clear`);
|
|
3090
|
-
|
|
3091
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3092
|
-
}
|
|
3093
|
-
catch { }
|
|
3614
|
+
await cleanupCheckoutOrHold();
|
|
3094
3615
|
return false;
|
|
3095
3616
|
}
|
|
3096
3617
|
agentMarkedRunning = true;
|
|
@@ -3112,7 +3633,7 @@ export class AgentManager {
|
|
|
3112
3633
|
projectId: agent.projectId,
|
|
3113
3634
|
agentId,
|
|
3114
3635
|
taskId,
|
|
3115
|
-
data: { phase,
|
|
3636
|
+
data: { phase, workdir: dispatchWorkdir },
|
|
3116
3637
|
});
|
|
3117
3638
|
this.startRuntimeMenuWatch(agentId);
|
|
3118
3639
|
return true;
|
|
@@ -3120,13 +3641,11 @@ export class AgentManager {
|
|
|
3120
3641
|
catch (err) {
|
|
3121
3642
|
const isAckUnknown = err instanceof DispatchTerminalError && err.reason === 'ack_unknown';
|
|
3122
3643
|
if (!isAckUnknown) {
|
|
3123
|
-
|
|
3124
|
-
await worktree.removeWithBranch(workdir, worktreePath, customBranch);
|
|
3125
|
-
}
|
|
3126
|
-
catch { }
|
|
3644
|
+
await cleanupCheckoutOrHold();
|
|
3127
3645
|
if (agentMarkedRunning) {
|
|
3128
3646
|
try {
|
|
3129
3647
|
let released = false;
|
|
3648
|
+
let releaseToken;
|
|
3130
3649
|
await this.agentStore.update(agentId, (agentNow) => {
|
|
3131
3650
|
if (!agentNow || agentNow.taskId !== taskId) {
|
|
3132
3651
|
console.warn(`[AgentManager] startSession cleanup agentStore: agent ${agentId} already reassigned ` +
|
|
@@ -3139,18 +3658,19 @@ export class AgentManager {
|
|
|
3139
3658
|
return AGENT_STORE_NOOP;
|
|
3140
3659
|
}
|
|
3141
3660
|
released = true;
|
|
3661
|
+
releaseToken = agentNow.lockToken;
|
|
3142
3662
|
void err;
|
|
3143
3663
|
return {
|
|
3144
3664
|
id: agentId,
|
|
3145
3665
|
projectId: agent.projectId,
|
|
3146
3666
|
paneId,
|
|
3147
|
-
|
|
3667
|
+
workdir,
|
|
3148
3668
|
updatedAt: new Date().toISOString(),
|
|
3149
3669
|
...(agentNow.creationToken !== undefined ? { creationToken: agentNow.creationToken } : {}),
|
|
3150
3670
|
};
|
|
3151
3671
|
});
|
|
3152
|
-
if (released) {
|
|
3153
|
-
await this.lockManager.
|
|
3672
|
+
if (released && releaseToken) {
|
|
3673
|
+
await this.lockManager.releaseIfOwner(agentId, taskId, releaseToken);
|
|
3154
3674
|
}
|
|
3155
3675
|
}
|
|
3156
3676
|
catch (cleanupErr) {
|
|
@@ -3163,13 +3683,25 @@ export class AgentManager {
|
|
|
3163
3683
|
}
|
|
3164
3684
|
async injectAndAwaitAck(tmux, paneId, prompt, agentId, runtime) {
|
|
3165
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
|
+
}
|
|
3166
3692
|
await this.acquireCompactGuard(agentId);
|
|
3167
3693
|
try {
|
|
3168
3694
|
if (before) {
|
|
3169
3695
|
const now = await this.agentStore.get(agentId);
|
|
3170
|
-
if (!now
|
|
3696
|
+
if (!now
|
|
3697
|
+
|| now.paneId !== before.paneId
|
|
3698
|
+
|| now.taskId !== before.taskId
|
|
3699
|
+
|| (beforeLockToken !== null && now.lockToken !== beforeLockToken)) {
|
|
3171
3700
|
throw new Error(`dispatch aborted: agent ${agentId} binding changed while waiting for pane mutex`);
|
|
3172
3701
|
}
|
|
3702
|
+
if (before.taskId && beforeLockToken) {
|
|
3703
|
+
await this.assertTaskLockOwner(agentId, before.taskId, beforeLockToken);
|
|
3704
|
+
}
|
|
3173
3705
|
if (isCancelCleanupHold(now)) {
|
|
3174
3706
|
throw new Error(`dispatch aborted: agent ${agentId} taken over by cancel (${now.awaitingPhase}) while waiting for pane mutex`);
|
|
3175
3707
|
}
|
|
@@ -3181,9 +3713,16 @@ export class AgentManager {
|
|
|
3181
3713
|
const revalidate = before
|
|
3182
3714
|
? async () => {
|
|
3183
3715
|
const fresh = await this.agentStore.get(agentId);
|
|
3184
|
-
if (!fresh
|
|
3716
|
+
if (!fresh
|
|
3717
|
+
|| fresh.paneId !== before.paneId
|
|
3718
|
+
|| fresh.taskId !== before.taskId
|
|
3719
|
+
|| (beforeLockToken !== null && fresh.lockToken !== beforeLockToken)
|
|
3720
|
+
|| isCancelCleanupHold(fresh)) {
|
|
3185
3721
|
throw new Error(`dispatch aborted: agent ${agentId} taken over by cancel before paste`);
|
|
3186
3722
|
}
|
|
3723
|
+
if (before.taskId && beforeLockToken) {
|
|
3724
|
+
await this.assertTaskLockOwner(agentId, before.taskId, beforeLockToken);
|
|
3725
|
+
}
|
|
3187
3726
|
const boundTask = fresh.taskId ? await this.taskStore.get(fresh.taskId) : null;
|
|
3188
3727
|
if (boundTask && TERMINAL_STATUSES.includes(boundTask.status)) {
|
|
3189
3728
|
throw new Error(`dispatch aborted: task ${fresh.taskId} for agent ${agentId} went terminal before paste`);
|
|
@@ -3300,7 +3839,12 @@ export class AgentManager {
|
|
|
3300
3839
|
`(pre-paste taskId=${agentState.taskId} !== ${taskId}); skipping`);
|
|
3301
3840
|
return false;
|
|
3302
3841
|
}
|
|
3303
|
-
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}`);
|
|
3304
3848
|
const dialogFailFromStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
|
|
3305
3849
|
let ensure;
|
|
3306
3850
|
try {
|
|
@@ -3311,9 +3855,19 @@ export class AgentManager {
|
|
|
3311
3855
|
throw err;
|
|
3312
3856
|
}
|
|
3313
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);
|
|
3314
3862
|
try {
|
|
3315
|
-
|
|
3316
|
-
|
|
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
|
+
}
|
|
3317
3871
|
}
|
|
3318
3872
|
catch (cleanupErr) {
|
|
3319
3873
|
console.warn(`[AgentManager] continueSession ensureSession rollback killSession failed:`, cleanupErr);
|
|
@@ -3324,16 +3878,43 @@ export class AgentManager {
|
|
|
3324
3878
|
const { paneId } = ensure;
|
|
3325
3879
|
const runner = this.createRunnerFor(agent);
|
|
3326
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
|
+
}
|
|
3327
3907
|
const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
|
|
3328
3908
|
let prompt;
|
|
3329
3909
|
try {
|
|
3910
|
+
await this.getReviewTransport().clearDispatchOutputs(agent, taskWorkdir, phase);
|
|
3330
3911
|
const useIncrementalNudge = typeof opts.postApproveRedispatchCount === 'number'
|
|
3331
3912
|
&& opts.postApproveRedispatchCount > 0
|
|
3332
3913
|
&& !ensure.freshRuntime;
|
|
3333
3914
|
const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
|
|
3334
3915
|
let payloadOpts = {};
|
|
3335
3916
|
if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
|
|
3336
|
-
payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent,
|
|
3917
|
+
payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, taskWorkdir, {
|
|
3337
3918
|
phase,
|
|
3338
3919
|
...(task.phase ? { taskPhase: task.phase } : {}),
|
|
3339
3920
|
...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
|
|
@@ -3350,7 +3931,7 @@ export class AgentManager {
|
|
|
3350
3931
|
task,
|
|
3351
3932
|
phase,
|
|
3352
3933
|
agent,
|
|
3353
|
-
|
|
3934
|
+
workdir: taskWorkdir,
|
|
3354
3935
|
skillRegistry: this.skillRegistry,
|
|
3355
3936
|
...(signalToken ? { signalToken } : {}),
|
|
3356
3937
|
...(useIncrementalNudge
|
|
@@ -3372,7 +3953,6 @@ export class AgentManager {
|
|
|
3372
3953
|
}
|
|
3373
3954
|
throw err;
|
|
3374
3955
|
}
|
|
3375
|
-
const expectedStatuses = PHASE_EXPECTED_STATUS[phase] ?? [];
|
|
3376
3956
|
const taskFresh = await this.taskStore.get(taskId);
|
|
3377
3957
|
if (!taskFresh || TERMINAL_STATUSES.includes(taskFresh.status)) {
|
|
3378
3958
|
console.warn(`[AgentManager] continueSession: task ${taskId} status=${taskFresh?.status} terminal/missing; skipping paste`);
|
|
@@ -3396,6 +3976,11 @@ export class AgentManager {
|
|
|
3396
3976
|
`(taskId=${agentFresh.taskId} !== ${taskId}); skipping`);
|
|
3397
3977
|
return false;
|
|
3398
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);
|
|
3399
3984
|
if (phase === 'post-approve') {
|
|
3400
3985
|
const completionFresh = await this.getPostApproveCompletion(taskId);
|
|
3401
3986
|
if (!completionFresh || completionFresh.token !== signalToken) {
|
|
@@ -3415,14 +4000,15 @@ export class AgentManager {
|
|
|
3415
4000
|
return {
|
|
3416
4001
|
...rest,
|
|
3417
4002
|
paneId,
|
|
3418
|
-
|
|
4003
|
+
workdir: taskWorkdir,
|
|
4004
|
+
lockToken,
|
|
3419
4005
|
updatedAt: now,
|
|
3420
4006
|
};
|
|
3421
4007
|
});
|
|
3422
4008
|
await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime);
|
|
3423
4009
|
return true;
|
|
3424
4010
|
}
|
|
3425
|
-
async rollbackUndeliveredBootstrap(state
|
|
4011
|
+
async rollbackUndeliveredBootstrap(state) {
|
|
3426
4012
|
if (!state.taskId
|
|
3427
4013
|
|| state.bootstrappingTaskId !== state.taskId
|
|
3428
4014
|
|| state.awaitingPhase === 'bootstrap-marker-clear-failed') {
|
|
@@ -3438,18 +4024,7 @@ export class AgentManager {
|
|
|
3438
4024
|
}
|
|
3439
4025
|
console.warn(`[recover] agent ${state.id} was mid-bootstrap for in_progress task ${state.taskId} ` +
|
|
3440
4026
|
`(prompt never ack'd); rolling the task back to pending`);
|
|
3441
|
-
|
|
3442
|
-
const cleanupDir = this.resolveWorkdir(agentConfig, state);
|
|
3443
|
-
if (cleanupDir) {
|
|
3444
|
-
try {
|
|
3445
|
-
await new WorktreeManager(this.createRunnerFor(agentConfig)).remove(cleanupDir, state.worktreePath);
|
|
3446
|
-
}
|
|
3447
|
-
catch (worktreeErr) {
|
|
3448
|
-
console.warn(`[recover] mid-bootstrap rollback: worktree.remove failed for ${state.worktreePath}:`, worktreeErr);
|
|
3449
|
-
}
|
|
3450
|
-
}
|
|
3451
|
-
}
|
|
3452
|
-
await this.rollbackFailedDispatch(state.taskId, state.id);
|
|
4027
|
+
await this.rollbackFailedDispatch(state.taskId, state.id, undefined, state.lockToken);
|
|
3453
4028
|
await this.agentStore.update(state.id, (latest) => {
|
|
3454
4029
|
if (!latest || latest.status !== 'awaiting_human')
|
|
3455
4030
|
return AGENT_STORE_NOOP;
|
|
@@ -3478,16 +4053,56 @@ export class AgentManager {
|
|
|
3478
4053
|
return { ...rest, updatedAt: new Date().toISOString() };
|
|
3479
4054
|
});
|
|
3480
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
|
+
}
|
|
3481
4070
|
async recover() {
|
|
3482
4071
|
const states = await this.agentStore.list();
|
|
4072
|
+
await this.releaseOrphanedLocks(states);
|
|
3483
4073
|
const deferredCleanups = [];
|
|
3484
4074
|
for (const state of states) {
|
|
3485
4075
|
const agentConfig = this.getAgentConfig(state.id);
|
|
3486
4076
|
if (!agentConfig)
|
|
3487
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
|
+
}
|
|
3488
4103
|
try {
|
|
3489
4104
|
const result = await this.ensureSession(state.id, 'recover');
|
|
3490
|
-
if (await this.rollbackUndeliveredBootstrap(state
|
|
4105
|
+
if (await this.rollbackUndeliveredBootstrap(state))
|
|
3491
4106
|
continue;
|
|
3492
4107
|
if (state.creationToken && !state.taskId) {
|
|
3493
4108
|
const ct = state.creationToken;
|
|
@@ -3546,18 +4161,16 @@ export class AgentManager {
|
|
|
3546
4161
|
}
|
|
3547
4162
|
const cancelHold = isCancelCleanupHold(state);
|
|
3548
4163
|
const shouldReleaseBinding = shouldReleaseHeldBinding(state, boundTask) && !cancelHold;
|
|
3549
|
-
if (shouldReleaseBinding && state.
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
}
|
|
3560
|
-
}
|
|
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;
|
|
3561
4174
|
}
|
|
3562
4175
|
const preserveHeld = !shouldReleaseBinding
|
|
3563
4176
|
&& state.status === 'awaiting_human';
|
|
@@ -3569,7 +4182,7 @@ export class AgentManager {
|
|
|
3569
4182
|
projectId: latest.projectId,
|
|
3570
4183
|
paneId: result.paneId,
|
|
3571
4184
|
updatedAt: new Date().toISOString(),
|
|
3572
|
-
...(latest.
|
|
4185
|
+
...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
|
|
3573
4186
|
status: 'ok',
|
|
3574
4187
|
};
|
|
3575
4188
|
if (shouldReleaseBinding) {
|
|
@@ -3578,7 +4191,7 @@ export class AgentManager {
|
|
|
3578
4191
|
const withBinding = {
|
|
3579
4192
|
...base,
|
|
3580
4193
|
...(latest.taskId !== undefined ? { taskId: latest.taskId } : {}),
|
|
3581
|
-
...(latest.
|
|
4194
|
+
...(latest.lockToken !== undefined ? { lockToken: latest.lockToken } : {}),
|
|
3582
4195
|
...(latest.startedAt !== undefined ? { startedAt: latest.startedAt } : {}),
|
|
3583
4196
|
};
|
|
3584
4197
|
if (!preserveHeld)
|
|
@@ -3591,16 +4204,13 @@ export class AgentManager {
|
|
|
3591
4204
|
...(latest.awaitingSince !== undefined ? { awaitingSince: latest.awaitingSince } : {}),
|
|
3592
4205
|
};
|
|
3593
4206
|
});
|
|
3594
|
-
if (shouldReleaseBinding) {
|
|
3595
|
-
await this.lockManager.release(state.id);
|
|
3596
|
-
}
|
|
3597
4207
|
if (state.taskId && !shouldReleaseBinding && !cancelHold) {
|
|
3598
4208
|
this.startRuntimeMenuWatch(state.id);
|
|
3599
4209
|
}
|
|
3600
4210
|
}
|
|
3601
4211
|
catch (err) {
|
|
3602
4212
|
if (err instanceof EnsureSessionError && err.partial.dialogPending) {
|
|
3603
|
-
if (await this.rollbackUndeliveredBootstrap(state
|
|
4213
|
+
if (await this.rollbackUndeliveredBootstrap(state))
|
|
3604
4214
|
continue;
|
|
3605
4215
|
await this.markDialogPending(state.id, state.creationToken);
|
|
3606
4216
|
void this.slowPollDialogPending(state.id, state.creationToken, {
|
|
@@ -3629,10 +4239,15 @@ export class AgentManager {
|
|
|
3629
4239
|
...latest,
|
|
3630
4240
|
paneId: undefined,
|
|
3631
4241
|
creationToken: undefined,
|
|
4242
|
+
...(latest.taskId ? {
|
|
4243
|
+
status: 'awaiting_human',
|
|
4244
|
+
awaitingPhase: 'recovery-failed',
|
|
4245
|
+
awaitingReason: message,
|
|
4246
|
+
awaitingSince: new Date().toISOString(),
|
|
4247
|
+
} : {}),
|
|
3632
4248
|
updatedAt: new Date().toISOString(),
|
|
3633
4249
|
};
|
|
3634
4250
|
});
|
|
3635
|
-
await this.lockManager.release(state.id);
|
|
3636
4251
|
const cleanup = await this.failTasksForAgent(state.id, `recovery: ${message}`, { deferPartnerCleanup: true });
|
|
3637
4252
|
deferredCleanups.push({
|
|
3638
4253
|
failingAgentId: state.id,
|
|
@@ -3676,23 +4291,32 @@ export class AgentManager {
|
|
|
3676
4291
|
hadBinding = !!existing.taskId;
|
|
3677
4292
|
taskId = existing.taskId;
|
|
3678
4293
|
if (!existing.taskId
|
|
3679
|
-
&& !existing.worktreePath
|
|
3680
4294
|
&& !existing.startedAt
|
|
3681
4295
|
&& !existing.paneId
|
|
3682
4296
|
&& !existing.creationToken) {
|
|
3683
4297
|
return AGENT_STORE_NOOP;
|
|
3684
4298
|
}
|
|
3685
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
|
+
}
|
|
3686
4311
|
return {
|
|
3687
4312
|
id: existing.id,
|
|
3688
4313
|
projectId: existing.projectId,
|
|
3689
|
-
...(existing.
|
|
4314
|
+
...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
3690
4315
|
updatedAt: timestamp,
|
|
3691
4316
|
};
|
|
3692
4317
|
});
|
|
3693
4318
|
if (!projectId || !changed)
|
|
3694
4319
|
return null;
|
|
3695
|
-
await this.lockManager.release(agentId);
|
|
3696
4320
|
return { projectId, timestamp, hadBinding, taskId };
|
|
3697
4321
|
});
|
|
3698
4322
|
if (!reconciled)
|
|
@@ -3747,18 +4371,18 @@ export class AgentManager {
|
|
|
3747
4371
|
|| (task.status === 'approved' && !!task.publishDispatchedAt);
|
|
3748
4372
|
if (task.reviewMode === 'server' && publishedAtGate && task.agentId) {
|
|
3749
4373
|
const afterDone = this.resolveAfterDone(task);
|
|
3750
|
-
if (afterDone
|
|
4374
|
+
if (afterDone === 'pr' && task.branch && task.prNumber !== undefined) {
|
|
3751
4375
|
publishedCleanup = {
|
|
3752
4376
|
afterDone,
|
|
3753
4377
|
branch: task.branch,
|
|
3754
|
-
|
|
4378
|
+
prNumber: task.prNumber,
|
|
3755
4379
|
devAgentId: task.agentId,
|
|
3756
4380
|
mayBeInFlight: task.status === 'approved',
|
|
3757
4381
|
};
|
|
3758
4382
|
}
|
|
3759
4383
|
}
|
|
3760
4384
|
else if (!alreadyTerminal && task.reviewMode !== 'server' && task.prNumber !== undefined && task.branch) {
|
|
3761
|
-
//
|
|
4385
|
+
// GitHub 模式取消时只关闭开放 PR;远程分支归用户所有,baxian 不自动删除。
|
|
3762
4386
|
// server 模式仍只信 gate+marker——非 gate 状态上的孤立 prNumber 不足以证明远端有待回收的工件
|
|
3763
4387
|
publishedCleanup = {
|
|
3764
4388
|
afterDone: 'pr',
|
|
@@ -3833,40 +4457,18 @@ export class AgentManager {
|
|
|
3833
4457
|
phase: 'cancel-published-artifact-cleanup-skipped',
|
|
3834
4458
|
afterDone: publishedCleanup.afterDone,
|
|
3835
4459
|
branch: publishedCleanup.branch,
|
|
3836
|
-
|
|
4460
|
+
prNumber: publishedCleanup.prNumber,
|
|
3837
4461
|
reason: 'dev pane stop unconfirmed; the publish prompt may still be running and would recreate the remote artifacts',
|
|
3838
4462
|
});
|
|
3839
4463
|
return result;
|
|
3840
4464
|
}
|
|
3841
4465
|
const project = this.getProjectConfig(result.projectId);
|
|
3842
4466
|
try {
|
|
3843
|
-
if (
|
|
4467
|
+
if (project) {
|
|
3844
4468
|
const close = await execNetwork(this.platformRunner, `gh pr close ${publishedCleanup.prNumber} --repo ${shellQuote(repoSlug(project.repo))} ` +
|
|
3845
4469
|
`--comment ${shellQuote('Task cancelled in baxian; closing the published PR.')}`, GH_NET);
|
|
3846
4470
|
if (close.exitCode !== 0)
|
|
3847
4471
|
throw new Error(close.stderr.trim() || close.stdout.trim());
|
|
3848
|
-
// Branch deletion is a separate idempotent step, not `--delete-branch`:
|
|
3849
|
-
// a retried close that finds the PR already closed exits 0 before gh's
|
|
3850
|
-
// branch-deletion block, which would report success while leaking the
|
|
3851
|
-
// published branch.
|
|
3852
|
-
const refPath = `repos/${repoSlug(project.repo)}/git/refs/heads/` +
|
|
3853
|
-
encodeURIComponent(publishedCleanup.branch).replace(/%2F/gi, '/');
|
|
3854
|
-
const del = await execNetwork(this.platformRunner, `gh api -X DELETE ${shellQuote(refPath)}`, GH_NET);
|
|
3855
|
-
if (del.exitCode !== 0 && !del.stderr.includes('Reference does not exist')) {
|
|
3856
|
-
throw new Error(del.stderr.trim() || del.stdout.trim());
|
|
3857
|
-
}
|
|
3858
|
-
}
|
|
3859
|
-
else if (publishedCleanup.devAgentId) {
|
|
3860
|
-
const dev = this.getAgentConfig(publishedCleanup.devAgentId);
|
|
3861
|
-
const state = await this.agentStore.get(publishedCleanup.devAgentId);
|
|
3862
|
-
if (dev && state?.repoPath) {
|
|
3863
|
-
const del = await execNetwork(this.createRunnerFor(dev), `cd ${shellQuote(state.repoPath)} && ${GIT_NET_ENV} git push origin --delete ${shellQuote(publishedCleanup.branch)}`);
|
|
3864
|
-
// The goal is "branch absent": a retried delete whose first round
|
|
3865
|
-
// landed reports a missing remote ref, which is success, not failure.
|
|
3866
|
-
if (del.exitCode !== 0 && !del.stderr.includes('remote ref does not exist')) {
|
|
3867
|
-
throw new Error(del.stderr.trim() || del.stdout.trim());
|
|
3868
|
-
}
|
|
3869
|
-
}
|
|
3870
4472
|
}
|
|
3871
4473
|
}
|
|
3872
4474
|
catch (err) {
|
|
@@ -3875,7 +4477,7 @@ export class AgentManager {
|
|
|
3875
4477
|
phase: 'cancel-published-artifact-cleanup-failed',
|
|
3876
4478
|
afterDone: publishedCleanup.afterDone,
|
|
3877
4479
|
branch: publishedCleanup.branch,
|
|
3878
|
-
|
|
4480
|
+
prNumber: publishedCleanup.prNumber,
|
|
3879
4481
|
error: err instanceof Error ? err.message : String(err),
|
|
3880
4482
|
});
|
|
3881
4483
|
}
|
|
@@ -4191,8 +4793,8 @@ export class AgentManager {
|
|
|
4191
4793
|
}
|
|
4192
4794
|
const devAgentId = task.agentId;
|
|
4193
4795
|
const devState = await this.agentStore.get(devAgentId);
|
|
4194
|
-
if (devState?.taskId !== taskId || !devState.
|
|
4195
|
-
throw new ApiError(409, `Dev ${devAgentId} no longer holds task ${taskId}'s
|
|
4796
|
+
if (devState?.taskId !== taskId || !devState.workdir) {
|
|
4797
|
+
throw new ApiError(409, `Dev ${devAgentId} no longer holds task ${taskId}'s Workdir (cannot continue); ` +
|
|
4196
4798
|
`use mark-complete to merge the PR as-is, or cancel the task`);
|
|
4197
4799
|
}
|
|
4198
4800
|
const prevReviewRound = task.reviewRound;
|
|
@@ -4475,7 +5077,7 @@ export class AgentManager {
|
|
|
4475
5077
|
: '';
|
|
4476
5078
|
// A merge is not idempotent from the caller's view (user-triggered, retryable
|
|
4477
5079
|
// in the UI), so it gets a timeout but no automatic retry.
|
|
4478
|
-
const result = await execNetwork(this.platformRunner, `gh pr merge ${task.prNumber} --repo ${shellQuote(repoSlug(project.repo))}${matchHead} --squash
|
|
5080
|
+
const result = await execNetwork(this.platformRunner, `gh pr merge ${task.prNumber} --repo ${shellQuote(repoSlug(project.repo))}${matchHead} --squash`, { retries: 0 });
|
|
4479
5081
|
if (result.exitCode !== 0) {
|
|
4480
5082
|
throw new Error(`gh pr merge failed for PR #${task.prNumber}: ${result.stderr || result.stdout}`);
|
|
4481
5083
|
}
|
|
@@ -4608,6 +5210,9 @@ export class AgentManager {
|
|
|
4608
5210
|
return;
|
|
4609
5211
|
}
|
|
4610
5212
|
if (!state.taskId) {
|
|
5213
|
+
const lockToken = await this.lockManager.acquire(agentId, ctx.taskId);
|
|
5214
|
+
if (!lockToken)
|
|
5215
|
+
return;
|
|
4611
5216
|
await this.agentStore.update(agentId, (existing) => {
|
|
4612
5217
|
if (!existing)
|
|
4613
5218
|
return AGENT_STORE_NOOP;
|
|
@@ -4619,21 +5224,20 @@ export class AgentManager {
|
|
|
4619
5224
|
return AGENT_STORE_NOOP;
|
|
4620
5225
|
if (existing.paneId !== state.paneId)
|
|
4621
5226
|
return AGENT_STORE_NOOP;
|
|
4622
|
-
return {
|
|
5227
|
+
return {
|
|
5228
|
+
...existing,
|
|
5229
|
+
taskId: ctx.taskId,
|
|
5230
|
+
lockToken,
|
|
5231
|
+
updatedAt: new Date().toISOString(),
|
|
5232
|
+
};
|
|
4623
5233
|
});
|
|
4624
5234
|
const fresh = await this.agentStore.get(agentId);
|
|
4625
|
-
if (fresh?.taskId !== ctx.taskId)
|
|
5235
|
+
if (fresh?.taskId !== ctx.taskId || fresh.lockToken !== lockToken) {
|
|
5236
|
+
await this.lockManager.releaseIfOwner(agentId, ctx.taskId, lockToken);
|
|
4626
5237
|
return;
|
|
5238
|
+
}
|
|
4627
5239
|
}
|
|
4628
|
-
await this.removeMergedWorktree(agent, agentId, ctx.taskId);
|
|
4629
5240
|
const runner = this.createRunnerFor(agent);
|
|
4630
|
-
if (state.repoPath) {
|
|
4631
|
-
await this.deleteLocalBranchInRepo(runner, state.repoPath, ctx.branch, agentId);
|
|
4632
|
-
}
|
|
4633
|
-
else {
|
|
4634
|
-
console.warn(`[AgentManager] dispatchPostMergeCleanup(${agentId}): no repoPath on binding; skipping local ` +
|
|
4635
|
-
`branch delete for ${ctx.branch} (it may linger locally)`);
|
|
4636
|
-
}
|
|
4637
5241
|
const tmux = new TmuxManager(runner);
|
|
4638
5242
|
const runtime = agentRuntimeKindFor(agent);
|
|
4639
5243
|
void this.runPostMergeCompaction(tmux, state.paneId, agentId, ctx.taskId, runtime).catch(err => console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) failed:`, err));
|
|
@@ -4649,56 +5253,6 @@ export class AgentManager {
|
|
|
4649
5253
|
console.warn(`[AgentManager] releasePostMergeAgent: releaseAgentForTask(${agentId}, ${taskId}) failed:`, err);
|
|
4650
5254
|
}
|
|
4651
5255
|
}
|
|
4652
|
-
async removeMergedWorktree(cfg, agentId, expectedTaskId) {
|
|
4653
|
-
await this.withTaskLock(async () => {
|
|
4654
|
-
const state = await this.agentStore.get(agentId);
|
|
4655
|
-
if (!state || state.taskId !== expectedTaskId || !state.worktreePath)
|
|
4656
|
-
return;
|
|
4657
|
-
const cleanupDir = this.resolveWorkdir(cfg, state);
|
|
4658
|
-
if (cleanupDir) {
|
|
4659
|
-
const runner = this.createRunnerFor(cfg);
|
|
4660
|
-
const worktree = new WorktreeManager(runner);
|
|
4661
|
-
try {
|
|
4662
|
-
await worktree.remove(cleanupDir, state.worktreePath);
|
|
4663
|
-
}
|
|
4664
|
-
catch (err) {
|
|
4665
|
-
console.warn(`[AgentManager] removeMergedWorktree worktree.remove failed for ${state.worktreePath}:`, err);
|
|
4666
|
-
}
|
|
4667
|
-
}
|
|
4668
|
-
const now = new Date().toISOString();
|
|
4669
|
-
await this.agentStore.update(agentId, (existing) => {
|
|
4670
|
-
if (!existing || existing.taskId !== expectedTaskId)
|
|
4671
|
-
return AGENT_STORE_NOOP;
|
|
4672
|
-
const next = { ...existing, updatedAt: now };
|
|
4673
|
-
delete next.worktreePath;
|
|
4674
|
-
return next;
|
|
4675
|
-
});
|
|
4676
|
-
});
|
|
4677
|
-
}
|
|
4678
|
-
async deleteLocalBranchInRepo(runner, repoPath, branch, agentId) {
|
|
4679
|
-
const fetchCmd = `cd ${shellQuote(repoPath)} && git fetch --prune origin && git worktree prune --expire=now`;
|
|
4680
|
-
try {
|
|
4681
|
-
const fetchResult = await runner.exec(fetchCmd, { timeout: this.postMergeFetchTimeoutMs });
|
|
4682
|
-
if (fetchResult.exitCode !== 0) {
|
|
4683
|
-
console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}): fetch/prune exit=${fetchResult.exitCode} ` +
|
|
4684
|
-
`stderr=${fetchResult.stderr.trim()}`);
|
|
4685
|
-
}
|
|
4686
|
-
}
|
|
4687
|
-
catch (err) {
|
|
4688
|
-
console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}) fetch/prune threw:`, err);
|
|
4689
|
-
}
|
|
4690
|
-
const delCmd = `cd ${shellQuote(repoPath)} && git branch -D ${shellQuote(branch)}`;
|
|
4691
|
-
try {
|
|
4692
|
-
const delResult = await runner.exec(delCmd, { timeout: this.postMergeBranchTimeoutMs });
|
|
4693
|
-
if (delResult.exitCode !== 0 && !/not found|not a valid|no such branch/i.test(delResult.stderr)) {
|
|
4694
|
-
console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}): branch -D exit=${delResult.exitCode} ` +
|
|
4695
|
-
`stderr=${delResult.stderr.trim()}`);
|
|
4696
|
-
}
|
|
4697
|
-
}
|
|
4698
|
-
catch (err) {
|
|
4699
|
-
console.warn(`[AgentManager] deleteLocalBranchInRepo(${agentId}, ${branch}) branch -D threw:`, err);
|
|
4700
|
-
}
|
|
4701
|
-
}
|
|
4702
5256
|
async runPostMergeCompaction(tmux, paneId, agentId, originalTaskId, runtime) {
|
|
4703
5257
|
await this.acquireCompactGuard(agentId);
|
|
4704
5258
|
try {
|
|
@@ -4729,33 +5283,59 @@ export class AgentManager {
|
|
|
4729
5283
|
return true;
|
|
4730
5284
|
}
|
|
4731
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;
|
|
4732
5292
|
const bindingStillOurs = async () => {
|
|
4733
5293
|
const s = await this.agentStore.get(agentId);
|
|
4734
|
-
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);
|
|
4735
5299
|
};
|
|
4736
5300
|
if (!await bindingStillOurs())
|
|
4737
5301
|
return;
|
|
4738
5302
|
let cleared = false;
|
|
5303
|
+
let clearError;
|
|
4739
5304
|
try {
|
|
4740
5305
|
cleared = await this.sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, bindingStillOurs);
|
|
4741
5306
|
}
|
|
4742
5307
|
catch (err) {
|
|
4743
|
-
|
|
5308
|
+
clearError = err;
|
|
4744
5309
|
}
|
|
4745
5310
|
if (!cleared) {
|
|
4746
|
-
if (await this.recoverPostMergeExitedRuntime(tmux, paneId, agentId, originalTaskId, runtime)) {
|
|
5311
|
+
if (await this.recoverPostMergeExitedRuntime(tmux, paneId, agentId, originalTaskId, lockToken, runtime)) {
|
|
4747
5312
|
cleared = true;
|
|
4748
5313
|
}
|
|
4749
5314
|
else if (!await bindingStillOurs()) {
|
|
4750
5315
|
return;
|
|
4751
5316
|
}
|
|
4752
|
-
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 });
|
|
4753
5333
|
return;
|
|
4754
5334
|
}
|
|
4755
5335
|
}
|
|
4756
5336
|
await this.releasePostMergeAgent(agentId, originalTaskId);
|
|
4757
5337
|
}
|
|
4758
|
-
async recoverPostMergeExitedRuntime(tmux, paneId, agentId, taskId, runtime) {
|
|
5338
|
+
async recoverPostMergeExitedRuntime(tmux, paneId, agentId, taskId, lockToken, runtime) {
|
|
4759
5339
|
let paneExists = true;
|
|
4760
5340
|
try {
|
|
4761
5341
|
if (hasReplProcTitle(await tmux.displayMessage(paneId, '#{pane_current_command}'), runtime))
|
|
@@ -4769,7 +5349,11 @@ export class AgentManager {
|
|
|
4769
5349
|
paneExists = false;
|
|
4770
5350
|
}
|
|
4771
5351
|
const state = await this.agentStore.get(agentId);
|
|
4772
|
-
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)))
|
|
4773
5357
|
return false;
|
|
4774
5358
|
try {
|
|
4775
5359
|
if (paneExists)
|
|
@@ -4778,20 +5362,24 @@ export class AgentManager {
|
|
|
4778
5362
|
if (!ensure.freshRuntime)
|
|
4779
5363
|
return false;
|
|
4780
5364
|
await this.agentStore.update(agentId, (existing) => {
|
|
4781
|
-
if (!existing || existing.taskId !== taskId)
|
|
5365
|
+
if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken) {
|
|
4782
5366
|
return AGENT_STORE_NOOP;
|
|
4783
|
-
|
|
5367
|
+
}
|
|
5368
|
+
if (existing.paneId === ensure.paneId && existing.workdir === ensure.workdir) {
|
|
4784
5369
|
return AGENT_STORE_NOOP;
|
|
4785
5370
|
}
|
|
4786
5371
|
return {
|
|
4787
5372
|
...existing,
|
|
4788
5373
|
paneId: ensure.paneId,
|
|
4789
|
-
|
|
5374
|
+
workdir: ensure.workdir,
|
|
4790
5375
|
updatedAt: new Date().toISOString(),
|
|
4791
5376
|
};
|
|
4792
5377
|
});
|
|
4793
5378
|
const current = await this.agentStore.get(agentId);
|
|
4794
|
-
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);
|
|
4795
5383
|
}
|
|
4796
5384
|
catch (err) {
|
|
4797
5385
|
console.warn(`[AgentManager] recoverPostMergeExitedRuntime(${agentId}, ${taskId}) failed:`, err);
|
|
@@ -5136,7 +5724,7 @@ export class AgentManager {
|
|
|
5136
5724
|
return empty;
|
|
5137
5725
|
// 读取失败不阻断打回:留痕与 fix 闭环优先于 diff 展示
|
|
5138
5726
|
try {
|
|
5139
|
-
await this.
|
|
5727
|
+
await this.refreshWorkdirCacheFor(task.agentId);
|
|
5140
5728
|
const content = await this.getReviewTransport().readContent(task, dev, 'code');
|
|
5141
5729
|
return {
|
|
5142
5730
|
round,
|
|
@@ -5310,10 +5898,10 @@ export class AgentManager {
|
|
|
5310
5898
|
...(opts.batch
|
|
5311
5899
|
? { batchIndex: opts.batch.index, batchTotal: opts.batch.total }
|
|
5312
5900
|
: { batchIndex: undefined, batchTotal: undefined }),
|
|
5313
|
-
// Fresh dispatch: clear any stale mode until startSession materializes the
|
|
5901
|
+
// Fresh dispatch: clear any stale mode until startSession materializes the review checkout,
|
|
5314
5902
|
// so a crash in this window recovers conservatively (no read-file) rather than
|
|
5315
5903
|
// re-arming it for what might be a head-mode review.
|
|
5316
|
-
...(opts.continuation ? {} : {
|
|
5904
|
+
...(opts.continuation ? {} : { reviewCheckoutMode: undefined }),
|
|
5317
5905
|
...roundPatch,
|
|
5318
5906
|
});
|
|
5319
5907
|
if (!transition) {
|
|
@@ -5323,7 +5911,7 @@ export class AgentManager {
|
|
|
5323
5911
|
await this.emitIntervention(projectId, qaId, taskId, { phase: 'server-review-transition-failed', qaAgentId: qaId });
|
|
5324
5912
|
return null;
|
|
5325
5913
|
}
|
|
5326
|
-
let
|
|
5914
|
+
let dispatchedCheckoutMode;
|
|
5327
5915
|
const sessionOpts = {
|
|
5328
5916
|
bypassTaskStatusGate: true,
|
|
5329
5917
|
signalToken: newToken,
|
|
@@ -5338,8 +5926,8 @@ export class AgentManager {
|
|
|
5338
5926
|
...(opts.priorResponseJson ? { serverPriorResponse: opts.priorResponseJson } : {}),
|
|
5339
5927
|
...(opts.phase === 'spec' ? { currentSpecRound: newRound } : {}),
|
|
5340
5928
|
armBeforeInject: (ctx) => {
|
|
5341
|
-
|
|
5342
|
-
const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.
|
|
5929
|
+
dispatchedCheckoutMode = ctx.serverReviewCheckout;
|
|
5930
|
+
const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.serverReviewCheckout === 'base';
|
|
5343
5931
|
return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, false, allowReadFile ? (req) => { void this.handleReadFileRequest(taskId, qaId, req); } : undefined);
|
|
5344
5932
|
},
|
|
5345
5933
|
};
|
|
@@ -5383,10 +5971,20 @@ export class AgentManager {
|
|
|
5383
5971
|
await this.emitIntervention(projectId, qaId, taskId, { phase: 'server-review-start-failed', qaAgentId: qaId });
|
|
5384
5972
|
return null;
|
|
5385
5973
|
}
|
|
5386
|
-
// Persist the
|
|
5387
|
-
// 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.
|
|
5388
5975
|
if (opts.phase !== 'spec' && !opts.continuation) {
|
|
5389
|
-
|
|
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
|
+
}
|
|
5390
5988
|
}
|
|
5391
5989
|
return await this.taskStore.get(taskId);
|
|
5392
5990
|
}
|
|
@@ -5506,13 +6104,13 @@ export class AgentManager {
|
|
|
5506
6104
|
return { ok: false, reason: 'released' };
|
|
5507
6105
|
const agent = this.getAgentConfig(task.agentId);
|
|
5508
6106
|
const agentState = await this.agentStore.get(task.agentId);
|
|
5509
|
-
// A rebound agent's
|
|
6107
|
+
// A rebound agent's Workdir checkout belongs to its new task — its git state describes
|
|
5510
6108
|
// another branch, so the interdiff would be meaningless. Same skip rule as
|
|
5511
|
-
// findLineageViolation:
|
|
5512
|
-
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) {
|
|
5513
6111
|
return { ok: false, reason: 'released' };
|
|
5514
6112
|
}
|
|
5515
|
-
await this.
|
|
6113
|
+
await this.refreshWorkdirCacheFor(task.agentId);
|
|
5516
6114
|
const diff = await this.getReviewTransport().readInterdiff(agent, prevSha, curSha);
|
|
5517
6115
|
return { ok: true, diff };
|
|
5518
6116
|
}
|
|
@@ -5522,13 +6120,13 @@ export class AgentManager {
|
|
|
5522
6120
|
return null;
|
|
5523
6121
|
const agent = this.getAgentConfig(task.agentId);
|
|
5524
6122
|
const agentState = await this.agentStore.get(task.agentId);
|
|
5525
|
-
// A rebound agent's
|
|
6123
|
+
// A rebound agent's Workdir checkout belongs to its new task — checking it would
|
|
5526
6124
|
// produce verdicts about the wrong branch. Skip; the dispatch path's own
|
|
5527
6125
|
// binding guards (acquire) surface the real failure.
|
|
5528
6126
|
if (agentState?.taskId !== taskId)
|
|
5529
6127
|
return null;
|
|
5530
|
-
const
|
|
5531
|
-
if (!agent || !
|
|
6128
|
+
const workdir = agentState.workdir;
|
|
6129
|
+
if (!agent || !workdir)
|
|
5532
6130
|
return null;
|
|
5533
6131
|
const runner = this.createRunnerFor(agent);
|
|
5534
6132
|
let base = baseSha;
|
|
@@ -5536,13 +6134,13 @@ export class AgentManager {
|
|
|
5536
6134
|
// Callers without a baseSha (publish) run long after the review diff was
|
|
5537
6135
|
// read; refresh origin/HEAD first or upstream commits merged since then
|
|
5538
6136
|
// would linger in base..HEAD and flag tasks that leak nothing.
|
|
5539
|
-
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`);
|
|
5540
6138
|
if (fetch.exitCode !== 0) {
|
|
5541
|
-
throw new Error(`lineage fetch failed in ${
|
|
6139
|
+
throw new Error(`lineage fetch failed in ${workdir}: ${fetch.stderr.trim()}`);
|
|
5542
6140
|
}
|
|
5543
|
-
const mb = await runner.exec(`git -C ${shellQuote(
|
|
6141
|
+
const mb = await runner.exec(`git -C ${shellQuote(workdir)} merge-base origin/HEAD HEAD`);
|
|
5544
6142
|
if (mb.exitCode !== 0) {
|
|
5545
|
-
throw new Error(`lineage merge-base failed in ${
|
|
6143
|
+
throw new Error(`lineage merge-base failed in ${workdir}: ${mb.stderr.trim()}`);
|
|
5546
6144
|
}
|
|
5547
6145
|
base = mb.stdout.trim();
|
|
5548
6146
|
}
|
|
@@ -5559,7 +6157,7 @@ export class AgentManager {
|
|
|
5559
6157
|
.map(t => ({ taskId: t.id, branch: t.branch ?? BRANCH_PREFIX + t.id }));
|
|
5560
6158
|
if (candidates.length === 0)
|
|
5561
6159
|
return null;
|
|
5562
|
-
return findForeignTaskTip((cmd) => runner.exec(cmd),
|
|
6160
|
+
return findForeignTaskTip((cmd) => runner.exec(cmd), workdir, base, candidates);
|
|
5563
6161
|
}
|
|
5564
6162
|
async dispatchServerAfterDone(taskId, kind) {
|
|
5565
6163
|
const task = await this.taskStore.get(taskId);
|
|
@@ -5641,7 +6239,7 @@ export class AgentManager {
|
|
|
5641
6239
|
const dev = this.getAgentConfig(task.agentId);
|
|
5642
6240
|
if (!dev)
|
|
5643
6241
|
return;
|
|
5644
|
-
await this.
|
|
6242
|
+
await this.refreshWorkdirCacheFor(task.agentId);
|
|
5645
6243
|
let body;
|
|
5646
6244
|
try {
|
|
5647
6245
|
const text = await this.getReviewTransport().readFileRange(dev, req.file, req.startLine, req.endLine);
|
|
@@ -5676,6 +6274,11 @@ export class AgentManager {
|
|
|
5676
6274
|
if (isCancelCleanupHold(state)) {
|
|
5677
6275
|
throw new Error(`injectTextToAgent: agent ${agentId} taken over by cancel (${state?.awaitingPhase}); refusing injection`);
|
|
5678
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
|
+
}
|
|
5679
6282
|
const boundTask = state?.taskId ? await this.taskStore.get(state.taskId) : null;
|
|
5680
6283
|
if (boundTask && TERMINAL_STATUSES.includes(boundTask.status)) {
|
|
5681
6284
|
throw new Error(`injectTextToAgent: task ${state?.taskId} for agent ${agentId} is terminal; refusing injection`);
|
|
@@ -5685,10 +6288,16 @@ export class AgentManager {
|
|
|
5685
6288
|
throw new Error(`injectTextToAgent: agent ${agentId} has no live pane`);
|
|
5686
6289
|
const fresh = await this.agentStore.get(agentId);
|
|
5687
6290
|
if (!fresh || fresh.paneId !== paneId
|
|
5688
|
-
||
|
|
5689
|
-
||
|
|
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)) {
|
|
5690
6296
|
throw new Error(`injectTextToAgent: agent ${agentId} taken over by cancel before paste`);
|
|
5691
6297
|
}
|
|
6298
|
+
if (taskId && lockToken) {
|
|
6299
|
+
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
6300
|
+
}
|
|
5692
6301
|
const tmux = new TmuxManager(this.createRunnerFor(cfg));
|
|
5693
6302
|
await tmux.injectPrompt(paneId, text, agentId);
|
|
5694
6303
|
await tmux.sendEnter(paneId);
|
|
@@ -5832,14 +6441,14 @@ export class AgentManager {
|
|
|
5832
6441
|
if (!dev)
|
|
5833
6442
|
throw new Error(`ffMergeBranch: no dev agent for task ${task.id}`);
|
|
5834
6443
|
const state = await this.agentStore.get(task.agentId);
|
|
5835
|
-
const
|
|
5836
|
-
if (!
|
|
5837
|
-
throw new Error(`ffMergeBranch: no
|
|
6444
|
+
const workdir = state?.workdir;
|
|
6445
|
+
if (!workdir)
|
|
6446
|
+
throw new Error(`ffMergeBranch: no Workdir for agent ${task.agentId}`);
|
|
5838
6447
|
const branch = task.branch ?? BRANCH_PREFIX + task.id;
|
|
5839
6448
|
const runner = this.createRunnerFor(dev);
|
|
5840
6449
|
const prev = this.repoMergeQueue.get(task.projectId) ?? Promise.resolve();
|
|
5841
6450
|
const run = prev.then(async () => {
|
|
5842
|
-
const cd = `cd ${shellQuote(
|
|
6451
|
+
const cd = `cd ${shellQuote(workdir)} && `;
|
|
5843
6452
|
const db = await runner.exec(`${cd}git symbolic-ref --short refs/remotes/origin/HEAD`);
|
|
5844
6453
|
const defaultBranch = db.stdout.trim().replace(/^origin\//, '');
|
|
5845
6454
|
if (db.exitCode !== 0 || defaultBranch === '') {
|
|
@@ -5863,10 +6472,6 @@ export class AgentManager {
|
|
|
5863
6472
|
if (push.exitCode !== 0) {
|
|
5864
6473
|
throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
|
|
5865
6474
|
}
|
|
5866
|
-
const del = await execNetwork(runner, `${cd}${GIT_NET_ENV} git push origin --delete ${shellQuote(branch)}`);
|
|
5867
|
-
if (del.exitCode !== 0 && !del.stderr.includes('remote ref does not exist')) {
|
|
5868
|
-
console.warn(`[AgentManager] ffMergeBranch: post-merge branch delete failed for ${branch}: ${del.stderr.trim() || del.stdout.trim()}`);
|
|
5869
|
-
}
|
|
5870
6475
|
});
|
|
5871
6476
|
this.repoMergeQueue.set(task.projectId, run.catch(() => undefined));
|
|
5872
6477
|
await run;
|