baxian 1.2.55 → 1.2.57
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/manager.d.ts +43 -29
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +873 -386
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/net-exec.d.ts +1 -0
- package/dist/agent/net-exec.d.ts.map +1 -1
- package/dist/agent/net-exec.js +6 -0
- package/dist/agent/net-exec.js.map +1 -1
- package/dist/agent/prompt.d.ts.map +1 -1
- package/dist/agent/prompt.js +2 -1
- package/dist/agent/prompt.js.map +1 -1
- package/dist/agent/repo-store.d.ts +15 -1
- package/dist/agent/repo-store.d.ts.map +1 -1
- package/dist/agent/repo-store.js +207 -22
- package/dist/agent/repo-store.js.map +1 -1
- package/dist/agent/review-transport.d.ts +5 -1
- package/dist/agent/review-transport.d.ts.map +1 -1
- package/dist/agent/review-transport.js +140 -18
- package/dist/agent/review-transport.js.map +1 -1
- package/dist/agent/tmux.d.ts +21 -2
- package/dist/agent/tmux.d.ts.map +1 -1
- package/dist/agent/tmux.js +200 -16
- package/dist/agent/tmux.js.map +1 -1
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +45 -8
- package/dist/api/config.js.map +1 -1
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +31 -26
- package/dist/api/projects.js.map +1 -1
- package/dist/api/tasks.js +2 -2
- package/dist/api/tasks.js.map +1 -1
- package/dist/config/validator.js +23 -11
- package/dist/config/validator.js.map +1 -1
- package/dist/event/handlers.d.ts.map +1 -1
- package/dist/event/handlers.js +36 -26
- package/dist/event/handlers.js.map +1 -1
- package/dist/event/server-handlers.d.ts.map +1 -1
- package/dist/event/server-handlers.js +46 -26
- package/dist/event/server-handlers.js.map +1 -1
- package/dist/lifecycle/restart-sentinel.d.ts.map +1 -1
- package/dist/lifecycle/restart-sentinel.js +10 -3
- package/dist/lifecycle/restart-sentinel.js.map +1 -1
- package/dist/shared/constants.d.ts +3 -1
- package/dist/shared/constants.d.ts.map +1 -1
- package/dist/shared/constants.js +8 -0
- package/dist/shared/constants.js.map +1 -1
- package/dist/shared/types.d.ts +21 -5
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js +8 -0
- package/dist/shared/types.js.map +1 -1
- package/dist/skills/baxian-research/SKILL.md +28 -0
- package/dist/skills/baxian-server-feedback/SKILL.md +3 -3
- package/dist/state/error-record-store.d.ts.map +1 -1
- package/dist/state/error-record-store.js +12 -3
- package/dist/state/error-record-store.js.map +1 -1
- package/dist/state/process-lock.d.ts.map +1 -1
- package/dist/state/process-lock.js +11 -2
- package/dist/state/process-lock.js.map +1 -1
- package/dist/state/review-store.d.ts +4 -4
- package/dist/state/review-store.d.ts.map +1 -1
- package/dist/state/review-store.js +57 -6
- package/dist/state/review-store.js.map +1 -1
- package/dist/state/task-store.d.ts.map +1 -1
- package/dist/state/task-store.js +115 -20
- package/dist/state/task-store.js.map +1 -1
- package/dist/web/assets/index-3Ypk8Gfo.js +13 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-BmnSiyut.js +0 -13
package/dist/agent/manager.js
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
-
import {
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { join, normalize } from 'node:path';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import { createSignalToken } from './phase-signal.js';
|
|
5
6
|
import { tmuxInstallHint } from './preflight.js';
|
|
6
|
-
import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, isGitHubRepo, parseGitRemote, repoSlug, } from '../shared/index.js';
|
|
7
|
+
import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, TASK_OWNER_ROLES, isSpecStagePhase, isGitHubRepo, parseGitRemote, repoSlug, } from '../shared/index.js';
|
|
7
8
|
import { AGENT_STORE_NOOP } from '../state/agent-store.js';
|
|
8
9
|
import { PostApproveStore } from '../state/post-approve-store.js';
|
|
9
10
|
import { SkillRegistry } from '../skill/registry.js';
|
|
10
|
-
import { createRunner, LocalRunner, shellQuote, resolveAgentHost, hostGroupKey } from './runner.js';
|
|
11
|
+
import { createRunner, LocalRunner, shellQuote, resolveAgentHost, hostGroupKey, workdirHostGroupKey, } from './runner.js';
|
|
11
12
|
import { GH_EXEC_TIMEOUT_MS, GIT_NET_ENV, execNetwork } from './net-exec.js';
|
|
12
13
|
import { findForeignTaskTip } from './lineage.js';
|
|
13
14
|
import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
|
|
14
15
|
import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, hasOscTitleWorking, hasOscTitleIdle, screenAllowsTitleIdle, } from './tmux.js';
|
|
15
16
|
import { BranchManager, DirtyWorkdirError, ReviewHeadMismatchError } from './branch.js';
|
|
16
|
-
import { RepoStore, createRepoStoreCache } from './repo-store.js';
|
|
17
|
+
import { RepoStore, createRepoStoreCache, moveFileIntoPlace, stageFileGuarded } from './repo-store.js';
|
|
17
18
|
import { PhaseSignalWatcher } from './phase-signal-watcher.js';
|
|
18
19
|
import { ReviewTransport, resolveServerPayloads } from './review-transport.js';
|
|
19
20
|
import { buildPromptInline, buildGreetingPrompt, PromptSizeError, RequiredSkillsMissingError, MAX_PROMPT_BYTES_ROUTE_LIMIT, } from './prompt.js';
|
|
@@ -85,6 +86,10 @@ export function buildLaunchCommand(agent) {
|
|
|
85
86
|
}
|
|
86
87
|
return segments.join(' ');
|
|
87
88
|
}
|
|
89
|
+
// cd re-resolves by pathname: a reused pane shell may sit on a deleted-and-recreated cwd inode.
|
|
90
|
+
export function launchCommandIn(dir, agent) {
|
|
91
|
+
return `cd ${shellQuote(dir)} && ${buildLaunchCommand(agent)}`;
|
|
92
|
+
}
|
|
88
93
|
function agentRuntimeKindFor(agent) {
|
|
89
94
|
return agent.runtime;
|
|
90
95
|
}
|
|
@@ -137,7 +142,7 @@ const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
|
|
|
137
142
|
const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
|
|
138
143
|
const GH_NET = { timeout: GH_EXEC_TIMEOUT_MS, retries: 1 };
|
|
139
144
|
const MANUAL_SERVER_REVIEW_STATUSES = ['in_progress', 'review', 'fixing'];
|
|
140
|
-
const IMAGE_DISPATCH_PHASES = new Set(['develop', 'code', 'fix', 'server-feedback']);
|
|
145
|
+
const IMAGE_DISPATCH_PHASES = new Set(['develop', 'research', 'code', 'fix', 'server-feedback']);
|
|
141
146
|
const RUNTIME_LIVENESS_SAMPLES = 3;
|
|
142
147
|
export function canDispatchWithBinding(binding) {
|
|
143
148
|
return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
|
|
@@ -269,6 +274,17 @@ export class AgentManager {
|
|
|
269
274
|
throw new Error(`Agent ${agentId} ownership changed for task ${taskId}; operation aborted`);
|
|
270
275
|
}
|
|
271
276
|
}
|
|
277
|
+
async assertTaskGeneration(agentId, taskId, lockToken, workdir) {
|
|
278
|
+
const state = await this.agentStore.get(agentId);
|
|
279
|
+
if (!state
|
|
280
|
+
|| state.taskId !== taskId
|
|
281
|
+
|| state.lockToken !== lockToken
|
|
282
|
+
|| state.workdir !== workdir) {
|
|
283
|
+
throw new Error(`Agent ${agentId} task generation changed for ${taskId}; operation aborted`);
|
|
284
|
+
}
|
|
285
|
+
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
286
|
+
return state;
|
|
287
|
+
}
|
|
272
288
|
getReviewStore() {
|
|
273
289
|
return this.reviewStore;
|
|
274
290
|
}
|
|
@@ -300,6 +316,14 @@ export class AgentManager {
|
|
|
300
316
|
});
|
|
301
317
|
return this.reviewTransportInstance;
|
|
302
318
|
}
|
|
319
|
+
async prepareDispatchArtifacts(agent, workdir, phase, opts) {
|
|
320
|
+
const transport = this.getReviewTransport();
|
|
321
|
+
if (!opts.preserveOutputs)
|
|
322
|
+
await transport.clearDispatchOutputs(agent, workdir, phase);
|
|
323
|
+
if (opts.specDocuments) {
|
|
324
|
+
await transport.replaceSpecDocuments(agent, workdir, opts.specDocuments, opts.assertOwner);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
303
327
|
bindingWorkdirCache = new Map();
|
|
304
328
|
async refreshWorkdirCacheFor(agentId) {
|
|
305
329
|
const state = await this.agentStore.get(agentId);
|
|
@@ -342,9 +366,28 @@ export class AgentManager {
|
|
|
342
366
|
}
|
|
343
367
|
replaceConfig(validated) {
|
|
344
368
|
const config = prepareConfig(validated);
|
|
369
|
+
const previousConfig = this.config;
|
|
370
|
+
const previousIndex = this.agentIndex;
|
|
371
|
+
const nextIndex = buildAgentIndex(config);
|
|
372
|
+
for (const [ownerKey, agentId] of this.repoCache.owners) {
|
|
373
|
+
const previous = previousIndex.get(agentId);
|
|
374
|
+
const next = nextIndex.get(agentId);
|
|
375
|
+
const previousHost = previous
|
|
376
|
+
? resolveAgentHost(previousConfig.host, previous.host)
|
|
377
|
+
: undefined;
|
|
378
|
+
const nextHost = next ? resolveAgentHost(config.host, next.host) : undefined;
|
|
379
|
+
const previousOwnerConfig = previous
|
|
380
|
+
? `${workdirHostGroupKey(previous.mode, previousHost)}\0${previous.workdir ? normalize(previous.workdir) : ''}`
|
|
381
|
+
: null;
|
|
382
|
+
const nextOwnerConfig = next
|
|
383
|
+
? `${workdirHostGroupKey(next.mode, nextHost)}\0${next.workdir ? normalize(next.workdir) : ''}`
|
|
384
|
+
: null;
|
|
385
|
+
if (previousOwnerConfig === null || previousOwnerConfig !== nextOwnerConfig) {
|
|
386
|
+
this.repoCache.owners.delete(ownerKey);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
345
389
|
this.config = config;
|
|
346
|
-
this.agentIndex =
|
|
347
|
-
this.repoCache.owners.clear();
|
|
390
|
+
this.agentIndex = nextIndex;
|
|
348
391
|
}
|
|
349
392
|
getConfig() {
|
|
350
393
|
return this.config;
|
|
@@ -411,8 +454,11 @@ export class AgentManager {
|
|
|
411
454
|
`if tmux is missing, ${tmuxInstallHint(agent.projectId)}`);
|
|
412
455
|
}
|
|
413
456
|
if (mode === 'create' && alive) {
|
|
414
|
-
|
|
415
|
-
|
|
457
|
+
const reclaimed = await this.runUnderSessionLifecycle(agentId, () => this.reclaimHalfCreatedSession(tmux, agentId));
|
|
458
|
+
if (!reclaimed) {
|
|
459
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" already exists on host; baxian only manages sessions ` +
|
|
460
|
+
`it creates itself — kill it manually or pick a different agent id`);
|
|
461
|
+
}
|
|
416
462
|
}
|
|
417
463
|
if (alive && (mode === 'runtime' || mode === 'recover')) {
|
|
418
464
|
return this.adoptOrRestartSession(tmux, runner, agent, agentId, workdir, workdir);
|
|
@@ -432,8 +478,77 @@ export class AgentManager {
|
|
|
432
478
|
// before the skill runs. Message-body payloads are not expanded, so this path is injection-safe.
|
|
433
479
|
await this.runUnderSkillDirLock(this.skillDirLockKey(agent, workdir), async () => {
|
|
434
480
|
await this.ensureSkillDirSafe(runner, workdir, subdir);
|
|
435
|
-
await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, path, content), destRoot);
|
|
481
|
+
await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, workdir, path, content), destRoot);
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
// A rollback only kills if no adoption bumped the generation since its create.
|
|
485
|
+
sessionLifecycleChain = new Map();
|
|
486
|
+
adoptGeneration = new Map();
|
|
487
|
+
runUnderSessionLifecycle(agentId, fn) {
|
|
488
|
+
const prev = this.sessionLifecycleChain.get(agentId) ?? Promise.resolve();
|
|
489
|
+
const cur = prev.then(fn, fn);
|
|
490
|
+
const settled = cur.then(() => undefined, () => undefined);
|
|
491
|
+
this.sessionLifecycleChain.set(agentId, settled);
|
|
492
|
+
void settled.finally(() => {
|
|
493
|
+
if (this.sessionLifecycleChain.get(agentId) === settled)
|
|
494
|
+
this.sessionLifecycleChain.delete(agentId);
|
|
436
495
|
});
|
|
496
|
+
return cur;
|
|
497
|
+
}
|
|
498
|
+
// Inside the lifecycle lock, a generation bump since create means handoff — stand down.
|
|
499
|
+
async rollbackCreatedSession(agentId, partial, reason, opts = {}) {
|
|
500
|
+
await this.runUnderSessionLifecycle(agentId, async () => {
|
|
501
|
+
try {
|
|
502
|
+
const ref = partial.sessionRef;
|
|
503
|
+
if (!ref) {
|
|
504
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: no session ref recorded`);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if ((this.adoptGeneration.get(agentId) ?? 0) !== (partial.genAtCreate ?? 0)) {
|
|
508
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: session adopted since create — leaving it to its successor`);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (opts.expectCreationToken !== undefined) {
|
|
512
|
+
let fresh;
|
|
513
|
+
try {
|
|
514
|
+
fresh = await this.agentStore.get(agentId);
|
|
515
|
+
}
|
|
516
|
+
catch (storeErr) {
|
|
517
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: agent store read failed — session left in place:`, storeErr);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (!fresh) {
|
|
521
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: agent record gone`);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (fresh.creationToken !== opts.expectCreationToken) {
|
|
525
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: creationToken rotated — leaving the session to its successor`);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const cfg = this.getAgentConfig(agentId);
|
|
530
|
+
if (!cfg) {
|
|
531
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: agent no longer in config`);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const runner = this.createRunnerFor(cfg);
|
|
535
|
+
const outcome = await new TmuxManager(runner).killSessionRef(ref);
|
|
536
|
+
if (outcome === 'stale-server') {
|
|
537
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}): tmux server generation changed — leaving session ${ref.sessionId} untouched`);
|
|
538
|
+
}
|
|
539
|
+
else if (outcome === 'killed') {
|
|
540
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}): killed created session ${ref.sessionId}`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
catch (cleanupErr) {
|
|
544
|
+
console.warn(`[AgentManager] created-session rollback (${reason}) failed for ${agentId}:`, cleanupErr);
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
async rollbackEnsureSessionFailure(agentId, err, reason) {
|
|
549
|
+
if (!(err instanceof EnsureSessionError) || !err.partial.createdSession)
|
|
550
|
+
return;
|
|
551
|
+
await this.rollbackCreatedSession(agentId, err.partial, reason);
|
|
437
552
|
}
|
|
438
553
|
skillDirChain = new Map();
|
|
439
554
|
skillDirLockKey(agent, workdir) {
|
|
@@ -453,19 +568,21 @@ export class AgentManager {
|
|
|
453
568
|
});
|
|
454
569
|
return run;
|
|
455
570
|
}
|
|
456
|
-
async atomicWriteFile(runner, finalPath, content) {
|
|
457
|
-
const tmp = `${finalPath}.baxian-tmp`;
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
571
|
+
async atomicWriteFile(runner, workdir, finalPath, content) {
|
|
572
|
+
const tmp = `${finalPath}.baxian-tmp-${randomBytes(6).toString('hex')}`;
|
|
573
|
+
try {
|
|
574
|
+
await stageFileGuarded(runner, workdir, tmp, content);
|
|
575
|
+
await moveFileIntoPlace(runner, tmp, finalPath, { guardRoot: workdir });
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
throw new Error(`atomic skill write failed (${finalPath}): ${err instanceof Error ? err.message : String(err)}`);
|
|
463
579
|
}
|
|
464
580
|
}
|
|
465
581
|
async ensureSkillDirSafe(runner, workdir, subdir) {
|
|
466
582
|
const top = subdir.split('/')[0];
|
|
467
583
|
const keep = this.skillRegistry.names().map((n) => `! -name ${shellQuote(n)}`).join(' ');
|
|
468
|
-
const inner = `
|
|
584
|
+
const inner = `[ ! -L ${shellQuote(workdir)} ] && [ -d ${shellQuote(workdir)} ] && ` +
|
|
585
|
+
`cd ${shellQuote(workdir)} && ` +
|
|
469
586
|
`for d in ${top} ${subdir}; do if [ -L "$d" ]; then ` +
|
|
470
587
|
`printf 'baxian: %s is a symlink -> %s; replace it with a real directory\\n' "$d" "$(readlink "$d")" >&2; ` +
|
|
471
588
|
`exit 3; fi; done && ` +
|
|
@@ -478,10 +595,12 @@ export class AgentManager {
|
|
|
478
595
|
throw new Error(`failed to prepare a symlink-safe ${subdir} in ${workdir}: ${res.stderr || 'unknown error'}`);
|
|
479
596
|
}
|
|
480
597
|
}
|
|
481
|
-
async tagSessionSkillsVersion(tmux, agentId) {
|
|
598
|
+
async tagSessionSkillsVersion(tmux, agentId, ref) {
|
|
482
599
|
if (this.skillRegistry.names().length === 0)
|
|
483
600
|
return;
|
|
484
|
-
await
|
|
601
|
+
await this.setSessionOptions(tmux, agentId, ref, [
|
|
602
|
+
['@baxian-skills-version', this.skillRegistry.contentHash()],
|
|
603
|
+
]);
|
|
485
604
|
}
|
|
486
605
|
async replSkillsStale(tmux, agentId) {
|
|
487
606
|
if (this.skillRegistry.names().length === 0)
|
|
@@ -490,7 +609,8 @@ export class AgentManager {
|
|
|
490
609
|
return tagged !== this.skillRegistry.contentHash();
|
|
491
610
|
}
|
|
492
611
|
async excludeInjectedSkills(runner, workdir, subdir) {
|
|
493
|
-
const inner = `
|
|
612
|
+
const inner = `[ ! -L ${shellQuote(workdir)} ] && [ -d ${shellQuote(workdir)} ] && ` +
|
|
613
|
+
`cd ${shellQuote(workdir)} && ` +
|
|
494
614
|
`if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo BX_SKILLS_NON_GIT; exit 0; fi; ` +
|
|
495
615
|
`tracked="$(git ls-files -- ${shellQuote(`${subdir}/baxian-*`)} 2>/dev/null | head -5)"; ` +
|
|
496
616
|
`if [ -n "$tracked" ]; then printf 'BX_SKILLS_TRACKED %s\\n' "$tracked" | head -1; exit 0; fi; ` +
|
|
@@ -513,35 +633,46 @@ export class AgentManager {
|
|
|
513
633
|
}
|
|
514
634
|
throw new Error(`cannot make injected skills invisible to Git in ${workdir}: ${verdict || 'no probe output'}`);
|
|
515
635
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
636
|
+
runtimePinEntries() {
|
|
637
|
+
return [
|
|
638
|
+
['prefix', 'C-b'],
|
|
639
|
+
['prefix2', 'None'],
|
|
640
|
+
['mouse', 'on'],
|
|
641
|
+
];
|
|
520
642
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
643
|
+
// A name (or even a bare $id) can rebind to a same-name successor mid-flight;
|
|
644
|
+
// every option write goes through the identity-checked by-ref batch.
|
|
645
|
+
async setSessionOptions(tmux, agentId, ref, entries) {
|
|
646
|
+
const outcome = await tmux.setSessionOptionsIfAlive(ref, entries);
|
|
647
|
+
if (outcome === 'gone') {
|
|
648
|
+
throw new Error(`tmux session ${ref.sessionId} (${agentId}) vanished before its options could be applied`);
|
|
649
|
+
}
|
|
524
650
|
}
|
|
525
651
|
async buildFreshSession(tmux, agent, agentId, workdir, sessionDir = workdir) {
|
|
526
652
|
return this.runUnderSkillDirLock(this.skillDirLockKey(agent, sessionDir), () => this.buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir));
|
|
527
653
|
}
|
|
528
654
|
async buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir) {
|
|
529
655
|
let createdSession = false;
|
|
656
|
+
let createdRef;
|
|
657
|
+
const genAtCreate = this.adoptGeneration.get(agentId) ?? 0;
|
|
530
658
|
const runtime = agentRuntimeKindFor(agent);
|
|
531
659
|
try {
|
|
532
|
-
await tmux.createSession(agentId, sessionDir);
|
|
660
|
+
createdRef = await tmux.createSession(agentId, sessionDir);
|
|
533
661
|
createdSession = true;
|
|
534
|
-
await
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
662
|
+
await this.setSessionOptions(tmux, agentId, createdRef, [
|
|
663
|
+
['@baxian-agent-id', agentId],
|
|
664
|
+
['@baxian-runtime', agent.runtime],
|
|
665
|
+
[WORKDIR_SESSION_OPTION, sessionDir],
|
|
666
|
+
['allow-passthrough', 'on'],
|
|
667
|
+
['set-titles', 'on'],
|
|
668
|
+
['window-size', 'latest'],
|
|
669
|
+
...this.runtimePinEntries(),
|
|
670
|
+
['status-right', ''],
|
|
671
|
+
]);
|
|
541
672
|
await tmux.setServerOption('extended-keys', 'on');
|
|
542
673
|
await tmux.appendServerOptionIfMissing('terminal-features', 'xterm*:extkeys');
|
|
543
|
-
const paneId = await tmux.
|
|
544
|
-
await tmux.sendKeysToPane(paneId, `${
|
|
674
|
+
const paneId = await tmux.getSinglePaneIdByRef(createdRef);
|
|
675
|
+
await tmux.sendKeysToPane(paneId, `${launchCommandIn(sessionDir, agent)}\n`);
|
|
545
676
|
await tmux.handleTrustDialog(paneId, runtime, {
|
|
546
677
|
timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
|
|
547
678
|
});
|
|
@@ -549,11 +680,13 @@ export class AgentManager {
|
|
|
549
680
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
550
681
|
scrollback: 0,
|
|
551
682
|
});
|
|
552
|
-
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
553
|
-
return { ok: true, createdSession: true, freshRuntime: true, paneId, workdir };
|
|
683
|
+
await this.tagSessionSkillsVersion(tmux, agentId, createdRef);
|
|
684
|
+
return { ok: true, createdSession: true, freshRuntime: true, paneId, workdir, sessionRef: createdRef };
|
|
554
685
|
}
|
|
555
686
|
catch (err) {
|
|
556
|
-
const partial = { createdSession, agentId };
|
|
687
|
+
const partial = { createdSession, agentId, genAtCreate };
|
|
688
|
+
if (createdRef)
|
|
689
|
+
partial.sessionRef = createdRef;
|
|
557
690
|
if (err instanceof ReplNotReadyError) {
|
|
558
691
|
partial.lastScreen = err.lastScreen;
|
|
559
692
|
if (createdSession && detectStartupDialog(err.lastScreen, runtime)) {
|
|
@@ -569,22 +702,12 @@ export class AgentManager {
|
|
|
569
702
|
console.warn(`[bootstrap] ${agentId} not in config at bootstrap start — aborting`);
|
|
570
703
|
return;
|
|
571
704
|
}
|
|
572
|
-
const tryKillOrphanSession = async (reason) => {
|
|
573
|
-
try {
|
|
574
|
-
const runner = this.createRunnerFor(cfgAtStart);
|
|
575
|
-
await new TmuxManager(runner).killSession(agentId);
|
|
576
|
-
}
|
|
577
|
-
catch (cleanupErr) {
|
|
578
|
-
console.warn(`[bootstrap] orphan killSession (${reason}) failed for ${agentId}:`, cleanupErr);
|
|
579
|
-
}
|
|
580
|
-
};
|
|
581
705
|
try {
|
|
582
706
|
const result = await this.ensureSession(agentId, 'create');
|
|
583
707
|
if (!(await this.runGreetingHandshake(agentId, cfgAtStart, result.paneId))) {
|
|
584
708
|
const current = await this.agentStore.get(agentId);
|
|
585
709
|
if (current && current.creationToken !== creationToken) {
|
|
586
|
-
console.warn(`[bootstrap] ${agentId} creationToken changed during greeting —
|
|
587
|
-
await tryKillOrphanSession('greeting-failure token mismatch');
|
|
710
|
+
console.warn(`[bootstrap] ${agentId} creationToken changed during greeting — leaving the session to its successor`);
|
|
588
711
|
return;
|
|
589
712
|
}
|
|
590
713
|
await this.markGreetingFailed(agentId, creationToken);
|
|
@@ -605,8 +728,7 @@ export class AgentManager {
|
|
|
605
728
|
};
|
|
606
729
|
});
|
|
607
730
|
if (!resolvedExisting) {
|
|
608
|
-
console.warn(`[bootstrap] ${agentId} creationToken mismatch on success —
|
|
609
|
-
await tryKillOrphanSession('post-success token mismatch');
|
|
731
|
+
console.warn(`[bootstrap] ${agentId} creationToken mismatch on success — leaving the session to its successor`);
|
|
610
732
|
return;
|
|
611
733
|
}
|
|
612
734
|
await this.safeEmit({
|
|
@@ -624,8 +746,7 @@ export class AgentManager {
|
|
|
624
746
|
if (err.partial.createdSession) {
|
|
625
747
|
const fresh = await this.agentStore.get(agentId);
|
|
626
748
|
if (!fresh || fresh.creationToken !== creationToken) {
|
|
627
|
-
console.warn(`[bootstrap] ${agentId} dialog-path token mismatch —
|
|
628
|
-
await tryKillOrphanSession('dialog-path token mismatch');
|
|
749
|
+
console.warn(`[bootstrap] ${agentId} dialog-path token mismatch — leaving the session to its successor`);
|
|
629
750
|
return;
|
|
630
751
|
}
|
|
631
752
|
}
|
|
@@ -636,7 +757,9 @@ export class AgentManager {
|
|
|
636
757
|
return;
|
|
637
758
|
}
|
|
638
759
|
if (err instanceof EnsureSessionError && err.partial.createdSession) {
|
|
639
|
-
await
|
|
760
|
+
await this.rollbackCreatedSession(agentId, err.partial, 'hard-failure rollback', {
|
|
761
|
+
expectCreationToken: creationToken,
|
|
762
|
+
});
|
|
640
763
|
}
|
|
641
764
|
const message = err instanceof Error ? err.message : String(err);
|
|
642
765
|
await this.markBootstrapFailed(agentId, creationToken, message);
|
|
@@ -1105,27 +1228,39 @@ export class AgentManager {
|
|
|
1105
1228
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
1106
1229
|
});
|
|
1107
1230
|
}
|
|
1231
|
+
// Claim snapshot and adoption declaration share one critical section with rollbacks.
|
|
1108
1232
|
async adoptOrRestartSession(tmux, runner, agent, agentId, workdir, sessionDir) {
|
|
1109
|
-
|
|
1233
|
+
return this.runUnderSessionLifecycle(agentId, () => this.adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir));
|
|
1234
|
+
}
|
|
1235
|
+
async adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir) {
|
|
1236
|
+
let snapshot;
|
|
1110
1237
|
try {
|
|
1111
|
-
|
|
1238
|
+
snapshot = await tmux.getSessionSnapshot(agentId);
|
|
1112
1239
|
}
|
|
1113
1240
|
catch (err) {
|
|
1114
|
-
throw new EnsureSessionError({ createdSession: false, agentId }, `
|
|
1241
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `session snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1115
1242
|
}
|
|
1116
|
-
if (
|
|
1243
|
+
if (!snapshot) {
|
|
1244
|
+
return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
|
|
1245
|
+
}
|
|
1246
|
+
const sessionRef = snapshot.ref;
|
|
1247
|
+
if (snapshot.claim !== agentId) {
|
|
1248
|
+
if (snapshot.claim === null && await this.reclaimHalfCreatedSession(tmux, agentId, snapshot)) {
|
|
1249
|
+
return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
|
|
1250
|
+
}
|
|
1117
1251
|
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" exists but @baxian-agent-id session claim mismatch ` +
|
|
1118
|
-
`(got "${claim ?? 'null'}"); baxian will not adopt foreign session — operator must intervene`);
|
|
1252
|
+
`(got "${snapshot.claim ?? 'null'}"); baxian will not adopt foreign session — operator must intervene`);
|
|
1119
1253
|
}
|
|
1254
|
+
this.adoptGeneration.set(agentId, (this.adoptGeneration.get(agentId) ?? 0) + 1);
|
|
1120
1255
|
try {
|
|
1121
|
-
await this.
|
|
1256
|
+
await this.setSessionOptions(tmux, agentId, sessionRef, this.runtimePinEntries());
|
|
1122
1257
|
}
|
|
1123
1258
|
catch (err) {
|
|
1124
|
-
throw new EnsureSessionError({ createdSession: false, agentId }, `
|
|
1259
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `pinning runtime session options failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1125
1260
|
}
|
|
1126
1261
|
let paneId;
|
|
1127
1262
|
try {
|
|
1128
|
-
paneId = await tmux.
|
|
1263
|
+
paneId = await tmux.getSinglePaneIdByRef(sessionRef);
|
|
1129
1264
|
}
|
|
1130
1265
|
catch (err) {
|
|
1131
1266
|
throw new EnsureSessionError({ createdSession: false, agentId }, `getSinglePaneId failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1164,10 +1299,10 @@ export class AgentManager {
|
|
|
1164
1299
|
else if (state.kind !== 'shell') {
|
|
1165
1300
|
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux pane path ${currentPath} differs from Workdir ${sessionDir}, but runtime is not safely idle`);
|
|
1166
1301
|
}
|
|
1167
|
-
await
|
|
1302
|
+
await this.killAdoptTarget(tmux, agentId, sessionRef, 'workdir change');
|
|
1168
1303
|
return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
|
|
1169
1304
|
}
|
|
1170
|
-
await
|
|
1305
|
+
await this.setSessionOptions(tmux, agentId, sessionRef, [[WORKDIR_SESSION_OPTION, sessionDir]]);
|
|
1171
1306
|
switch (state.kind) {
|
|
1172
1307
|
case 'live-runtime': {
|
|
1173
1308
|
let stale;
|
|
@@ -1185,9 +1320,10 @@ export class AgentManager {
|
|
|
1185
1320
|
skillsStale: true,
|
|
1186
1321
|
paneId,
|
|
1187
1322
|
workdir,
|
|
1323
|
+
sessionRef,
|
|
1188
1324
|
};
|
|
1189
1325
|
}
|
|
1190
|
-
return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir };
|
|
1326
|
+
return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir, sessionRef };
|
|
1191
1327
|
}
|
|
1192
1328
|
case 'startup-dialog':
|
|
1193
1329
|
throw new EnsureSessionError({
|
|
@@ -1209,8 +1345,8 @@ export class AgentManager {
|
|
|
1209
1345
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
1210
1346
|
scrollback: 0,
|
|
1211
1347
|
});
|
|
1212
|
-
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
1213
|
-
return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir };
|
|
1348
|
+
await this.tagSessionSkillsVersion(tmux, agentId, sessionRef);
|
|
1349
|
+
return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir, sessionRef };
|
|
1214
1350
|
}
|
|
1215
1351
|
catch (trustErr) {
|
|
1216
1352
|
if (trustErr instanceof ReplNotReadyError && detectStartupDialog(trustErr.lastScreen, runtime)) {
|
|
@@ -1228,7 +1364,7 @@ export class AgentManager {
|
|
|
1228
1364
|
break;
|
|
1229
1365
|
}
|
|
1230
1366
|
try {
|
|
1231
|
-
await tmux.sendKeysToPane(paneId, `${
|
|
1367
|
+
await tmux.sendKeysToPane(paneId, `${launchCommandIn(sessionDir, agent)}\n`);
|
|
1232
1368
|
await tmux.handleTrustDialog(paneId, runtime, {
|
|
1233
1369
|
timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
|
|
1234
1370
|
});
|
|
@@ -1236,8 +1372,8 @@ export class AgentManager {
|
|
|
1236
1372
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
1237
1373
|
scrollback: 0,
|
|
1238
1374
|
});
|
|
1239
|
-
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
1240
|
-
return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir };
|
|
1375
|
+
await this.tagSessionSkillsVersion(tmux, agentId, sessionRef);
|
|
1376
|
+
return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir, sessionRef };
|
|
1241
1377
|
}
|
|
1242
1378
|
catch (relErr) {
|
|
1243
1379
|
if (relErr instanceof ReplNotReadyError && detectStartupDialog(relErr.lastScreen, runtime)) {
|
|
@@ -1251,6 +1387,66 @@ export class AgentManager {
|
|
|
1251
1387
|
throw new EnsureSessionError({ createdSession: false, agentId }, `REPL relaunch failed: ${relErr instanceof Error ? relErr.message : String(relErr)}`);
|
|
1252
1388
|
}
|
|
1253
1389
|
}
|
|
1390
|
+
// Frees a bootstrap blocked on its predecessor's half-created session (nonce
|
|
1391
|
+
// env present but claim never written); anything else is left alone.
|
|
1392
|
+
async reclaimHalfCreatedSession(tmux, agentId, knownSnapshot) {
|
|
1393
|
+
let snapshot;
|
|
1394
|
+
try {
|
|
1395
|
+
snapshot = knownSnapshot ?? await tmux.getSessionSnapshot(agentId);
|
|
1396
|
+
}
|
|
1397
|
+
catch (err) {
|
|
1398
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `session snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1399
|
+
}
|
|
1400
|
+
if (!snapshot)
|
|
1401
|
+
return true;
|
|
1402
|
+
if (snapshot.claim !== null)
|
|
1403
|
+
return false;
|
|
1404
|
+
let halfCreated;
|
|
1405
|
+
try {
|
|
1406
|
+
halfCreated = await tmux.hasCreationNonce(agentId);
|
|
1407
|
+
}
|
|
1408
|
+
catch (err) {
|
|
1409
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `creation-nonce probe failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1410
|
+
}
|
|
1411
|
+
if (!halfCreated)
|
|
1412
|
+
return false;
|
|
1413
|
+
let outcome;
|
|
1414
|
+
try {
|
|
1415
|
+
// claim-still-empty rides the kill itself — a claim racing the probes makes the server refuse.
|
|
1416
|
+
outcome = await tmux.killSessionRef(snapshot.ref, { onlyIfUnclaimed: true });
|
|
1417
|
+
}
|
|
1418
|
+
catch (err) {
|
|
1419
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `killSessionRef (half-created leftover) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1420
|
+
}
|
|
1421
|
+
if (outcome === 'refused' || outcome === 'stale-server') {
|
|
1422
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `session ${snapshot.ref.sessionId} was claimed (or its server changed) while reclaiming the half-created leftover — retry to observe the new state`);
|
|
1423
|
+
}
|
|
1424
|
+
if (outcome === 'killed') {
|
|
1425
|
+
console.warn(`[AgentManager] ${agentId}: killed half-created leftover session ${snapshot.ref.sessionId} (creation nonce present, claim never written)`);
|
|
1426
|
+
}
|
|
1427
|
+
else {
|
|
1428
|
+
console.warn(`[AgentManager] ${agentId}: half-created leftover session ${snapshot.ref.sessionId} already gone`);
|
|
1429
|
+
}
|
|
1430
|
+
return true;
|
|
1431
|
+
}
|
|
1432
|
+
async killAdoptTarget(tmux, agentId, sessionRef, reason) {
|
|
1433
|
+
let outcome;
|
|
1434
|
+
try {
|
|
1435
|
+
outcome = await tmux.killSessionRef(sessionRef);
|
|
1436
|
+
}
|
|
1437
|
+
catch (err) {
|
|
1438
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `killSessionRef (${reason}) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1439
|
+
}
|
|
1440
|
+
if (outcome === 'stale-server') {
|
|
1441
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux server generation changed while replacing session (${reason}); retry to observe the new state`);
|
|
1442
|
+
}
|
|
1443
|
+
if (outcome === 'killed') {
|
|
1444
|
+
console.warn(`[AgentManager] ${agentId}: killed session ${sessionRef.sessionId} (${reason})`);
|
|
1445
|
+
}
|
|
1446
|
+
else {
|
|
1447
|
+
console.warn(`[AgentManager] ${agentId}: session ${sessionRef.sessionId} already gone (${reason})`);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1254
1450
|
async acquireAgentForTask(agentId, taskId, phase) {
|
|
1255
1451
|
const cfg = this.getAgentConfig(agentId);
|
|
1256
1452
|
if (!cfg)
|
|
@@ -1282,6 +1478,7 @@ export class AgentManager {
|
|
|
1282
1478
|
projectId: cfg.projectId,
|
|
1283
1479
|
taskId,
|
|
1284
1480
|
lockToken: token,
|
|
1481
|
+
...(phase === 'code' && !sameTaskReentry ? { bootstrappingTaskId: taskId } : {}),
|
|
1285
1482
|
updatedAt: now,
|
|
1286
1483
|
}));
|
|
1287
1484
|
return true;
|
|
@@ -1345,10 +1542,19 @@ export class AgentManager {
|
|
|
1345
1542
|
});
|
|
1346
1543
|
return true;
|
|
1347
1544
|
}
|
|
1545
|
+
let sessionConfirmedAbsent = false;
|
|
1348
1546
|
if (state.workdir) {
|
|
1547
|
+
const releaseWorkdir = state.workdir;
|
|
1349
1548
|
const runner = this.createRunnerFor(cfg);
|
|
1350
1549
|
const tmux = new TmuxManager(runner);
|
|
1351
1550
|
const hold = async (reason) => {
|
|
1551
|
+
const latest = await this.agentStore.get(agentId);
|
|
1552
|
+
if (!latest
|
|
1553
|
+
|| latest.taskId !== expectedTaskId
|
|
1554
|
+
|| latest.lockToken !== lockToken
|
|
1555
|
+
|| !await this.lockManager.isOwner(agentId, expectedTaskId, lockToken)) {
|
|
1556
|
+
return false;
|
|
1557
|
+
}
|
|
1352
1558
|
const pendingAt = new Date().toISOString();
|
|
1353
1559
|
if (boundTask && cfg.role === 'dev') {
|
|
1354
1560
|
boundTask.branchCleanupPending = { agentId, reason, updatedAt: pendingAt };
|
|
@@ -1375,32 +1581,40 @@ export class AgentManager {
|
|
|
1375
1581
|
return false;
|
|
1376
1582
|
};
|
|
1377
1583
|
try {
|
|
1378
|
-
await this.
|
|
1379
|
-
const
|
|
1380
|
-
if (
|
|
1381
|
-
|
|
1584
|
+
await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
|
|
1585
|
+
const runtime = await this.inspectReleaseRuntime(tmux, agentId);
|
|
1586
|
+
if (runtime.kind === 'hold')
|
|
1587
|
+
return hold(runtime.reason);
|
|
1588
|
+
await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
|
|
1589
|
+
if (runtime.kind === 'pane') {
|
|
1590
|
+
if (state.paneId !== runtime.paneId) {
|
|
1591
|
+
await this.agentStore.update(agentId, (latest) => {
|
|
1592
|
+
if (!latest
|
|
1593
|
+
|| latest.taskId !== expectedTaskId
|
|
1594
|
+
|| latest.lockToken !== lockToken
|
|
1595
|
+
|| latest.workdir !== releaseWorkdir) {
|
|
1596
|
+
return AGENT_STORE_NOOP;
|
|
1597
|
+
}
|
|
1598
|
+
return { ...latest, paneId: runtime.paneId, updatedAt: new Date().toISOString() };
|
|
1599
|
+
});
|
|
1600
|
+
const refreshed = await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
|
|
1601
|
+
if (refreshed.paneId !== runtime.paneId) {
|
|
1602
|
+
throw new Error(`Agent ${agentId} runtime pane changed during release; operation aborted`);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
await this.waitForReplPromptReady(tmux, runtime.paneId, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
|
|
1382
1606
|
}
|
|
1383
1607
|
else {
|
|
1384
|
-
|
|
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
|
-
}
|
|
1608
|
+
sessionConfirmedAbsent = true;
|
|
1395
1609
|
}
|
|
1396
|
-
await this.
|
|
1610
|
+
await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
|
|
1397
1611
|
if (cfg.role === 'dev' && boundTask) {
|
|
1398
1612
|
const branches = new BranchManager(runner);
|
|
1399
|
-
const cleanup = await branches.cleanupTaskBranch(
|
|
1613
|
+
const cleanup = await branches.cleanupTaskBranch(releaseWorkdir, {
|
|
1400
1614
|
taskId: boundTask.id,
|
|
1401
1615
|
taskBranch: boundTask.branch,
|
|
1402
1616
|
branchCreatedByBaxian: boundTask.branchCreatedByBaxian,
|
|
1403
|
-
}, () => this.
|
|
1617
|
+
}, () => this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir).then(() => undefined));
|
|
1404
1618
|
let cleanupStateChanged = false;
|
|
1405
1619
|
if (cleanup.status === 'pending') {
|
|
1406
1620
|
if (boundTask.branchCleanupPending?.agentId !== agentId
|
|
@@ -1443,11 +1657,12 @@ export class AgentManager {
|
|
|
1443
1657
|
await this.taskStore.set(boundTask);
|
|
1444
1658
|
}
|
|
1445
1659
|
if (cleanup.status !== 'deleted')
|
|
1446
|
-
await branches.parkOnDefaultDetached(
|
|
1660
|
+
await branches.parkOnDefaultDetached(releaseWorkdir);
|
|
1447
1661
|
}
|
|
1448
1662
|
else {
|
|
1449
|
-
await new BranchManager(runner).parkOnDefaultDetached(
|
|
1663
|
+
await new BranchManager(runner).parkOnDefaultDetached(releaseWorkdir);
|
|
1450
1664
|
}
|
|
1665
|
+
await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
|
|
1451
1666
|
}
|
|
1452
1667
|
catch (err) {
|
|
1453
1668
|
const reason = err instanceof DirtyWorkdirError
|
|
@@ -1456,17 +1671,25 @@ export class AgentManager {
|
|
|
1456
1671
|
return hold(reason);
|
|
1457
1672
|
}
|
|
1458
1673
|
}
|
|
1459
|
-
|
|
1674
|
+
if (state.workdir) {
|
|
1675
|
+
await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, state.workdir);
|
|
1676
|
+
}
|
|
1677
|
+
else {
|
|
1678
|
+
await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
|
|
1679
|
+
}
|
|
1460
1680
|
await this.agentStore.update(agentId, (existing) => {
|
|
1461
1681
|
if (!existing)
|
|
1462
1682
|
return AGENT_STORE_NOOP;
|
|
1463
|
-
if (existing.taskId !== expectedTaskId
|
|
1683
|
+
if (existing.taskId !== expectedTaskId
|
|
1684
|
+
|| existing.lockToken !== lockToken
|
|
1685
|
+
|| (state.workdir !== undefined && existing.workdir !== state.workdir)) {
|
|
1464
1686
|
return AGENT_STORE_NOOP;
|
|
1687
|
+
}
|
|
1465
1688
|
return {
|
|
1466
1689
|
id: existing.id,
|
|
1467
1690
|
projectId: existing.projectId,
|
|
1468
1691
|
...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
1469
|
-
...(existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
1692
|
+
...(!sessionConfirmedAbsent && existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
1470
1693
|
...(existing.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
1471
1694
|
updatedAt: now,
|
|
1472
1695
|
};
|
|
@@ -1474,6 +1697,17 @@ export class AgentManager {
|
|
|
1474
1697
|
return this.lockManager.releaseIfOwner(agentId, expectedTaskId, lockToken);
|
|
1475
1698
|
});
|
|
1476
1699
|
}
|
|
1700
|
+
async releaseAgentIfBound(agentId, expectedTaskId, opts) {
|
|
1701
|
+
const state = await this.agentStore.get(agentId);
|
|
1702
|
+
if (state?.taskId !== expectedTaskId)
|
|
1703
|
+
return true;
|
|
1704
|
+
const released = opts
|
|
1705
|
+
? await this.releaseAgentForTask(agentId, expectedTaskId, 'idle', opts)
|
|
1706
|
+
: await this.releaseAgentForTask(agentId, expectedTaskId, 'idle');
|
|
1707
|
+
if (released)
|
|
1708
|
+
return true;
|
|
1709
|
+
return (await this.agentStore.get(agentId))?.taskId !== expectedTaskId;
|
|
1710
|
+
}
|
|
1477
1711
|
async reconcileTaskBranches() {
|
|
1478
1712
|
const tasks = await this.taskStore.list();
|
|
1479
1713
|
const terminalByBranch = new Map(tasks
|
|
@@ -1882,14 +2116,27 @@ export class AgentManager {
|
|
|
1882
2116
|
}
|
|
1883
2117
|
if (result.redispatchCodeTaskId) {
|
|
1884
2118
|
try {
|
|
1885
|
-
const
|
|
2119
|
+
const task = await this.taskStore.get(result.redispatchCodeTaskId);
|
|
2120
|
+
const round = task?.specReviewRound;
|
|
2121
|
+
const stored = round === undefined
|
|
2122
|
+
? null
|
|
2123
|
+
: await this.reviewStore?.getRound(result.redispatchCodeTaskId, 'spec', round);
|
|
2124
|
+
if (!task || task.phase !== 'code' || !task.signalToken || !stored || stored.phase !== 'spec') {
|
|
2125
|
+
await this.markAwaitingHuman(agentId, 'code-dispatch-failed', 'Code-phase redispatch cannot continue because the persisted Spec handoff is missing or invalid. Restore the review record or cancel the task.', { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
|
|
2126
|
+
return { resumed: false, releasedBinding: false, reason: 'Persisted Spec handoff is missing or invalid.' };
|
|
2127
|
+
}
|
|
2128
|
+
const resumed = await this.dispatchCodePhasePrompt(task, agentId, task.signalToken, stored.documents, round);
|
|
1886
2129
|
if (!resumed) {
|
|
1887
|
-
|
|
2130
|
+
const reason = 'Code-phase redispatch on Resume was not delivered; Resume again to retry or cancel the task.';
|
|
2131
|
+
await this.markAwaitingHuman(agentId, 'code-dispatch-failed', reason, { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
|
|
2132
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
1888
2133
|
}
|
|
1889
2134
|
}
|
|
1890
2135
|
catch (err) {
|
|
1891
2136
|
console.error(`[AgentManager] resumeAgent code redispatch failed for ${agentId}:`, err);
|
|
1892
|
-
|
|
2137
|
+
const reason = `Code-phase redispatch on Resume failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
2138
|
+
await this.markAwaitingHuman(agentId, 'code-dispatch-failed', reason, { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
|
|
2139
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
1893
2140
|
}
|
|
1894
2141
|
}
|
|
1895
2142
|
return {
|
|
@@ -1909,6 +2156,48 @@ export class AgentManager {
|
|
|
1909
2156
|
return null;
|
|
1910
2157
|
}
|
|
1911
2158
|
}
|
|
2159
|
+
async inspectReleaseRuntime(tmux, agentId) {
|
|
2160
|
+
let sessionAlive;
|
|
2161
|
+
try {
|
|
2162
|
+
sessionAlive = await tmux.hasSession(agentId);
|
|
2163
|
+
}
|
|
2164
|
+
catch (err) {
|
|
2165
|
+
return {
|
|
2166
|
+
kind: 'hold',
|
|
2167
|
+
reason: `Runtime availability probe failed; refusing checkout cleanup: ` +
|
|
2168
|
+
`${err instanceof Error ? err.message : String(err)}`,
|
|
2169
|
+
};
|
|
2170
|
+
}
|
|
2171
|
+
if (!sessionAlive)
|
|
2172
|
+
return { kind: 'absent' };
|
|
2173
|
+
let claim;
|
|
2174
|
+
try {
|
|
2175
|
+
claim = await tmux.getOption(agentId, '@baxian-agent-id');
|
|
2176
|
+
}
|
|
2177
|
+
catch (err) {
|
|
2178
|
+
return {
|
|
2179
|
+
kind: 'hold',
|
|
2180
|
+
reason: `Runtime session claim probe failed; refusing checkout cleanup: ` +
|
|
2181
|
+
`${err instanceof Error ? err.message : String(err)}`,
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
if (claim !== agentId) {
|
|
2185
|
+
return {
|
|
2186
|
+
kind: 'hold',
|
|
2187
|
+
reason: `Runtime session claim mismatch (got "${claim ?? 'null'}"); refusing checkout cleanup`,
|
|
2188
|
+
};
|
|
2189
|
+
}
|
|
2190
|
+
try {
|
|
2191
|
+
return { kind: 'pane', paneId: await tmux.getSinglePaneId(agentId) };
|
|
2192
|
+
}
|
|
2193
|
+
catch (err) {
|
|
2194
|
+
return {
|
|
2195
|
+
kind: 'hold',
|
|
2196
|
+
reason: `Runtime pane probe failed; refusing checkout cleanup: ` +
|
|
2197
|
+
`${err instanceof Error ? err.message : String(err)}`,
|
|
2198
|
+
};
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
1912
2201
|
async interruptPaneAndWaitReady(state, cfg) {
|
|
1913
2202
|
const paneId = await this.resolvePaneId(state, cfg);
|
|
1914
2203
|
if (!paneId)
|
|
@@ -2153,15 +2442,14 @@ export class AgentManager {
|
|
|
2153
2442
|
throw new Error(`Unknown agent: ${agentId}`);
|
|
2154
2443
|
const runner = this.createRunnerFor(cfg);
|
|
2155
2444
|
const tmux = new TmuxManager(runner);
|
|
2156
|
-
const
|
|
2157
|
-
if (!
|
|
2445
|
+
const snapshot = await tmux.getSessionSnapshot(agentId);
|
|
2446
|
+
if (!snapshot) {
|
|
2158
2447
|
throw new Error(`restart-repl: tmux session ${agentId} does not exist; use retry to rebuild`);
|
|
2159
2448
|
}
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
throw new Error(`restart-repl: session claim mismatch (got "${claim ?? 'null'}"); refusing to touch foreign session`);
|
|
2449
|
+
if (snapshot.claim !== agentId) {
|
|
2450
|
+
throw new Error(`restart-repl: session claim mismatch (got "${snapshot.claim ?? 'null'}"); refusing to touch foreign session`);
|
|
2163
2451
|
}
|
|
2164
|
-
const paneId = await tmux.
|
|
2452
|
+
const paneId = await tmux.getSinglePaneIdByRef(snapshot.ref);
|
|
2165
2453
|
await tmux.sendKeysToPane(paneId, 'C-c');
|
|
2166
2454
|
const cmd = await this.pollPaneCommandStable(tmux, paneId, { timeoutMs: 2_000 });
|
|
2167
2455
|
const RUNTIME = /^(?:claude|codex|node|opencode|qodercli(?:-[\d.]+)?|\d+\.\d+\.\d+)$/;
|
|
@@ -2183,10 +2471,10 @@ export class AgentManager {
|
|
|
2183
2471
|
'use retry to rebuild the session safely');
|
|
2184
2472
|
}
|
|
2185
2473
|
await this.provisionRepoSkills(runner, cfg, workdir);
|
|
2186
|
-
await
|
|
2474
|
+
await this.setSessionOptions(tmux, agentId, snapshot.ref, [[WORKDIR_SESSION_OPTION, workdir]]);
|
|
2187
2475
|
const runtime = agentRuntimeKindFor(cfg);
|
|
2188
2476
|
const relaunch = async () => {
|
|
2189
|
-
await tmux.sendKeysToPane(paneId, `${
|
|
2477
|
+
await tmux.sendKeysToPane(paneId, `${launchCommandIn(workdir, cfg)}\n`);
|
|
2190
2478
|
await tmux.handleTrustDialog(paneId, runtime, {
|
|
2191
2479
|
timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
|
|
2192
2480
|
});
|
|
@@ -2194,7 +2482,7 @@ export class AgentManager {
|
|
|
2194
2482
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
2195
2483
|
scrollback: 0,
|
|
2196
2484
|
});
|
|
2197
|
-
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
2485
|
+
await this.tagSessionSkillsVersion(tmux, agentId, snapshot.ref);
|
|
2198
2486
|
};
|
|
2199
2487
|
await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
|
|
2200
2488
|
await this.agentStore.update(agentId, (state) => {
|
|
@@ -2207,6 +2495,42 @@ export class AgentManager {
|
|
|
2207
2495
|
};
|
|
2208
2496
|
});
|
|
2209
2497
|
}
|
|
2498
|
+
async redispatchResearchAfterReplRestart(agentId, taskId) {
|
|
2499
|
+
const agent = this.getAgentConfig(agentId);
|
|
2500
|
+
const task = await this.taskStore.get(taskId);
|
|
2501
|
+
if (!agent || agent.role !== 'research' || !task)
|
|
2502
|
+
return false;
|
|
2503
|
+
if (task.researchAgentId !== agentId || task.agentId !== agentId || !task.signalToken)
|
|
2504
|
+
return false;
|
|
2505
|
+
let phase;
|
|
2506
|
+
let expectedKind;
|
|
2507
|
+
let findings;
|
|
2508
|
+
if (task.phase === 'research' && task.status === 'in_progress') {
|
|
2509
|
+
phase = 'research';
|
|
2510
|
+
expectedKind = 'spec-done';
|
|
2511
|
+
}
|
|
2512
|
+
else if (task.phase === 'spec' && task.status === 'fixing') {
|
|
2513
|
+
const round = task.specReviewRound ?? 1;
|
|
2514
|
+
const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
|
|
2515
|
+
if (!stored?.findings)
|
|
2516
|
+
return false;
|
|
2517
|
+
phase = 'server-feedback';
|
|
2518
|
+
expectedKind = 'spec-fixed';
|
|
2519
|
+
findings = JSON.stringify(stored.findings);
|
|
2520
|
+
}
|
|
2521
|
+
else {
|
|
2522
|
+
return false;
|
|
2523
|
+
}
|
|
2524
|
+
await this.clearAwaitingHuman(agentId);
|
|
2525
|
+
return this.continueSession(taskId, agentId, phase, {
|
|
2526
|
+
bypassTaskStatusGate: true,
|
|
2527
|
+
signalToken: task.signalToken,
|
|
2528
|
+
preserveDispatchOutputs: true,
|
|
2529
|
+
...(findings ? { serverPriorFindings: findings } : {}),
|
|
2530
|
+
...(task.specReviewRound !== undefined ? { currentSpecRound: task.specReviewRound } : {}),
|
|
2531
|
+
armBeforeInject: () => this.setupPhaseSignalWatcher(taskId, agentId, expectedKind, task.signalToken),
|
|
2532
|
+
});
|
|
2533
|
+
}
|
|
2210
2534
|
prepareRemoveTargets(agentId) {
|
|
2211
2535
|
const cfg = this.getAgentConfig(agentId);
|
|
2212
2536
|
if (!cfg)
|
|
@@ -2214,15 +2538,10 @@ export class AgentManager {
|
|
|
2214
2538
|
const project = this.getProjectConfig(cfg.projectId);
|
|
2215
2539
|
if (!project)
|
|
2216
2540
|
throw new Error(`Unknown project: ${cfg.projectId}`);
|
|
2217
|
-
if (cfg.role
|
|
2541
|
+
if (cfg.role !== 'dev')
|
|
2218
2542
|
return { targets: [agentId] };
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
const qa = pair[1];
|
|
2222
|
-
return { targets: qa ? [agentId, qa.id] : [agentId] };
|
|
2223
|
-
}
|
|
2224
|
-
}
|
|
2225
|
-
return { targets: [agentId] };
|
|
2543
|
+
const group = this.findAgentGroup(agentId);
|
|
2544
|
+
return { targets: group?.map(agent => agent.id) ?? [agentId] };
|
|
2226
2545
|
}
|
|
2227
2546
|
async cleanupRemovedAgentRuntime(targets) {
|
|
2228
2547
|
const failures = [];
|
|
@@ -2257,17 +2576,23 @@ export class AgentManager {
|
|
|
2257
2576
|
}
|
|
2258
2577
|
}
|
|
2259
2578
|
try {
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
}
|
|
2266
|
-
else {
|
|
2579
|
+
await this.runUnderSessionLifecycle(id, async () => {
|
|
2580
|
+
const snapshot = await tmux.getSessionSnapshot(id);
|
|
2581
|
+
if (!snapshot)
|
|
2582
|
+
return;
|
|
2583
|
+
if (snapshot.claim !== id) {
|
|
2267
2584
|
console.warn(`[AgentManager] cleanupRemovedAgentRuntime: skipping kill for ${id} ` +
|
|
2268
|
-
`(claim=${claim ?? 'null'}; not baxian-managed)`);
|
|
2585
|
+
`(claim=${snapshot.claim ?? 'null'}; not baxian-managed)`);
|
|
2586
|
+
return;
|
|
2269
2587
|
}
|
|
2270
|
-
|
|
2588
|
+
const outcome = await tmux.killSessionRef(snapshot.ref);
|
|
2589
|
+
if (outcome === 'stale-server') {
|
|
2590
|
+
console.warn(`[AgentManager] cleanupRemovedAgentRuntime: tmux server generation changed for ${id} — leaving session untouched`);
|
|
2591
|
+
}
|
|
2592
|
+
else if (outcome === 'killed') {
|
|
2593
|
+
console.warn(`[AgentManager] cleanupRemovedAgentRuntime: killed session ${snapshot.ref.sessionId} for removed agent ${id}`);
|
|
2594
|
+
}
|
|
2595
|
+
});
|
|
2271
2596
|
}
|
|
2272
2597
|
catch (err) {
|
|
2273
2598
|
failures.push({ agentId: id, step: 'tmux', error: err });
|
|
@@ -2305,6 +2630,12 @@ export class AgentManager {
|
|
|
2305
2630
|
}
|
|
2306
2631
|
const workdirGuess = workdirForEstimate ?? '/'.padEnd(64, 'x');
|
|
2307
2632
|
const now = new Date().toISOString();
|
|
2633
|
+
const isResearch = cfg.role === 'research';
|
|
2634
|
+
const group = this.findAgentGroup(cfg.id);
|
|
2635
|
+
const devAgentId = isResearch ? group?.find(agent => agent.role === 'dev')?.id : cfg.id;
|
|
2636
|
+
if (!devAgentId)
|
|
2637
|
+
throw new Error(`Agent ${cfg.id} has no dev agent in its group`);
|
|
2638
|
+
const qaAgentId = group?.find(agent => agent.role === 'qa')?.id;
|
|
2308
2639
|
const fakeTask = {
|
|
2309
2640
|
id: 'task-9999999999',
|
|
2310
2641
|
projectId,
|
|
@@ -2312,6 +2643,9 @@ export class AgentManager {
|
|
|
2312
2643
|
description: input.description,
|
|
2313
2644
|
preferredAgentId: input.preferredAgentId,
|
|
2314
2645
|
agentId: cfg.id,
|
|
2646
|
+
devAgentId,
|
|
2647
|
+
...(qaAgentId ? { qaAgentId } : {}),
|
|
2648
|
+
...(isResearch ? { researchAgentId: cfg.id, phase: 'research' } : {}),
|
|
2315
2649
|
branch: `${BRANCH_PREFIX}task-9999999999`,
|
|
2316
2650
|
reviewRound: 0,
|
|
2317
2651
|
status: 'in_progress',
|
|
@@ -2320,11 +2654,12 @@ export class AgentManager {
|
|
|
2320
2654
|
};
|
|
2321
2655
|
const fullPrompt = buildPromptInline({
|
|
2322
2656
|
task: fakeTask,
|
|
2323
|
-
phase: 'develop',
|
|
2657
|
+
phase: isResearch ? 'research' : 'develop',
|
|
2324
2658
|
agent: cfg,
|
|
2325
2659
|
workdir: workdirGuess,
|
|
2326
2660
|
skillRegistry: this.skillRegistry,
|
|
2327
2661
|
signalToken: 'preview-signal-token',
|
|
2662
|
+
hasQaPartner: qaAgentId !== undefined,
|
|
2328
2663
|
});
|
|
2329
2664
|
return Buffer.byteLength(fullPrompt, 'utf8');
|
|
2330
2665
|
}
|
|
@@ -2337,12 +2672,11 @@ export class AgentManager {
|
|
|
2337
2672
|
getProjectByRepo(repo) {
|
|
2338
2673
|
return this.config.project.find(p => repoSlug(p.repo) === repo);
|
|
2339
2674
|
}
|
|
2340
|
-
|
|
2675
|
+
findAgentGroup(anchorAgentId) {
|
|
2341
2676
|
for (const project of this.config.project) {
|
|
2342
|
-
for (const
|
|
2343
|
-
if (
|
|
2344
|
-
return
|
|
2345
|
-
}
|
|
2677
|
+
for (const group of project.agent) {
|
|
2678
|
+
if (group.some(agent => agent.id === anchorAgentId))
|
|
2679
|
+
return group;
|
|
2346
2680
|
}
|
|
2347
2681
|
}
|
|
2348
2682
|
return undefined;
|
|
@@ -2441,17 +2775,18 @@ export class AgentManager {
|
|
|
2441
2775
|
if (!mapped)
|
|
2442
2776
|
continue;
|
|
2443
2777
|
const { expectedKinds, agentId } = mapped;
|
|
2444
|
-
const
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2778
|
+
const specStage = isSpecStagePhase(task.phase);
|
|
2779
|
+
const interventionKindLabel = specStage && task.status === 'review' ? 'spec-reviewed'
|
|
2780
|
+
: specStage && task.status === 'fixing' ? 'spec-fixed'
|
|
2781
|
+
: !specStage && task.status === 'review' ? 'pr-approved|pr-changes-requested'
|
|
2782
|
+
: !specStage && task.status === 'fixing' ? 'pr-fixed'
|
|
2448
2783
|
: undefined;
|
|
2449
|
-
const isServerProtocol = task.reviewMode === 'server' ||
|
|
2784
|
+
const isServerProtocol = task.reviewMode === 'server' || specStage;
|
|
2450
2785
|
const scanSnapshotOnRecover = isServerProtocol
|
|
2451
2786
|
|| (task.phase === undefined && task.status === 'in_progress')
|
|
2452
|
-
|| (
|
|
2787
|
+
|| (!specStage && (task.status === 'review' || task.status === 'fixing'));
|
|
2453
2788
|
const allowRecoveredReadFile = task.status === 'review'
|
|
2454
|
-
&& (
|
|
2789
|
+
&& (specStage
|
|
2455
2790
|
|| (task.reviewMode === 'server' && task.batchTotal !== undefined)
|
|
2456
2791
|
|| (task.reviewMode === 'server' && task.reviewCheckoutMode === 'base'));
|
|
2457
2792
|
try {
|
|
@@ -2730,8 +3065,8 @@ export class AgentManager {
|
|
|
2730
3065
|
if (cfg.projectId !== projectId) {
|
|
2731
3066
|
throw new ApiError(400, `Agent ${preferredAgentId} not in project ${projectId}`);
|
|
2732
3067
|
}
|
|
2733
|
-
if (cfg.role
|
|
2734
|
-
throw new ApiError(400, `Agent ${preferredAgentId} is not dev role`);
|
|
3068
|
+
if (!TASK_OWNER_ROLES.has(cfg.role)) {
|
|
3069
|
+
throw new ApiError(400, `Agent ${preferredAgentId} is not dev or research role`);
|
|
2735
3070
|
}
|
|
2736
3071
|
const state = await this.agentStore.get(preferredAgentId);
|
|
2737
3072
|
if (!canDispatchWithBinding(state))
|
|
@@ -2739,6 +3074,22 @@ export class AgentManager {
|
|
|
2739
3074
|
const { projectId: _projectId, ...rest } = cfg;
|
|
2740
3075
|
return rest;
|
|
2741
3076
|
}
|
|
3077
|
+
async persistQueuedTask(task, queueReason, agentId) {
|
|
3078
|
+
await this.taskStore.set(task);
|
|
3079
|
+
await this.safeEmit({
|
|
3080
|
+
id: '',
|
|
3081
|
+
type: 'task.created',
|
|
3082
|
+
timestamp: task.createdAt,
|
|
3083
|
+
projectId: task.projectId,
|
|
3084
|
+
taskId: task.id,
|
|
3085
|
+
data: {
|
|
3086
|
+
queued: true,
|
|
3087
|
+
queueReason,
|
|
3088
|
+
...(agentId ? { agentId } : {}),
|
|
3089
|
+
},
|
|
3090
|
+
});
|
|
3091
|
+
return task;
|
|
3092
|
+
}
|
|
2742
3093
|
async createTask(projectId, input) {
|
|
2743
3094
|
return this.withTaskLock(async () => {
|
|
2744
3095
|
const taskId = await this.taskStore.nextId();
|
|
@@ -2762,113 +3113,13 @@ export class AgentManager {
|
|
|
2762
3113
|
const imageFilenames = input.images?.length
|
|
2763
3114
|
? await this.persistTaskImages(taskId, input.images)
|
|
2764
3115
|
: undefined;
|
|
2765
|
-
|
|
2766
|
-
const unassigned = {
|
|
2767
|
-
id: taskId,
|
|
2768
|
-
projectId,
|
|
2769
|
-
title: input.title,
|
|
2770
|
-
description: input.description,
|
|
2771
|
-
preferredAgentId: '',
|
|
2772
|
-
agentId: '',
|
|
2773
|
-
reviewRound: 0,
|
|
2774
|
-
status: 'pending',
|
|
2775
|
-
branch: taskBranch,
|
|
2776
|
-
branchCreatedByBaxian,
|
|
2777
|
-
reviewMode: this.effectiveReviewMode(projectId),
|
|
2778
|
-
createdAt: now,
|
|
2779
|
-
updatedAt: now,
|
|
2780
|
-
...(imageFilenames ? { images: imageFilenames } : {}),
|
|
2781
|
-
};
|
|
2782
|
-
await this.taskStore.set(unassigned);
|
|
2783
|
-
await this.safeEmit({
|
|
2784
|
-
id: '',
|
|
2785
|
-
type: 'task.created',
|
|
2786
|
-
timestamp: now,
|
|
2787
|
-
projectId,
|
|
2788
|
-
taskId,
|
|
2789
|
-
data: { queued: true, queueReason: 'unassigned' },
|
|
2790
|
-
});
|
|
2791
|
-
return unassigned;
|
|
2792
|
-
}
|
|
2793
|
-
const dev = await this.pickAgent(projectId, input.preferredAgentId);
|
|
2794
|
-
const qa = this.findQaPartner(input.preferredAgentId);
|
|
2795
|
-
if (!dev) {
|
|
2796
|
-
const queued = {
|
|
2797
|
-
id: taskId,
|
|
2798
|
-
projectId,
|
|
2799
|
-
title: input.title,
|
|
2800
|
-
description: input.description,
|
|
2801
|
-
preferredAgentId: input.preferredAgentId,
|
|
2802
|
-
agentId: '',
|
|
2803
|
-
reviewRound: 0,
|
|
2804
|
-
status: 'pending',
|
|
2805
|
-
branch: taskBranch,
|
|
2806
|
-
branchCreatedByBaxian,
|
|
2807
|
-
reviewMode: this.effectiveReviewMode(projectId),
|
|
2808
|
-
createdAt: now,
|
|
2809
|
-
updatedAt: now,
|
|
2810
|
-
...(qa ? { qaAgentId: qa.id } : {}),
|
|
2811
|
-
...(imageFilenames ? { images: imageFilenames } : {}),
|
|
2812
|
-
};
|
|
2813
|
-
await this.taskStore.set(queued);
|
|
2814
|
-
await this.safeEmit({
|
|
2815
|
-
id: '',
|
|
2816
|
-
type: 'task.created',
|
|
2817
|
-
timestamp: now,
|
|
2818
|
-
projectId,
|
|
2819
|
-
taskId,
|
|
2820
|
-
data: {
|
|
2821
|
-
queued: true,
|
|
2822
|
-
queueReason: 'preferred_agent_busy',
|
|
2823
|
-
agentId: input.preferredAgentId,
|
|
2824
|
-
},
|
|
2825
|
-
});
|
|
2826
|
-
return queued;
|
|
2827
|
-
}
|
|
2828
|
-
const lockToken = await this.lockManager.acquire(dev.id, taskId);
|
|
2829
|
-
if (!lockToken) {
|
|
2830
|
-
const queued = {
|
|
2831
|
-
id: taskId,
|
|
2832
|
-
projectId,
|
|
2833
|
-
title: input.title,
|
|
2834
|
-
description: input.description,
|
|
2835
|
-
preferredAgentId: input.preferredAgentId,
|
|
2836
|
-
agentId: '',
|
|
2837
|
-
reviewRound: 0,
|
|
2838
|
-
status: 'pending',
|
|
2839
|
-
branch: taskBranch,
|
|
2840
|
-
branchCreatedByBaxian,
|
|
2841
|
-
reviewMode: this.effectiveReviewMode(projectId),
|
|
2842
|
-
createdAt: now,
|
|
2843
|
-
updatedAt: now,
|
|
2844
|
-
...(qa ? { qaAgentId: qa.id } : {}),
|
|
2845
|
-
...(imageFilenames ? { images: imageFilenames } : {}),
|
|
2846
|
-
};
|
|
2847
|
-
await this.taskStore.set(queued);
|
|
2848
|
-
await this.safeEmit({
|
|
2849
|
-
id: '',
|
|
2850
|
-
type: 'task.created',
|
|
2851
|
-
timestamp: now,
|
|
2852
|
-
projectId,
|
|
2853
|
-
taskId,
|
|
2854
|
-
data: {
|
|
2855
|
-
queued: true,
|
|
2856
|
-
queueReason: 'agent_locked',
|
|
2857
|
-
agentId: input.preferredAgentId,
|
|
2858
|
-
},
|
|
2859
|
-
});
|
|
2860
|
-
return queued;
|
|
2861
|
-
}
|
|
2862
|
-
const task = {
|
|
3116
|
+
const taskBase = {
|
|
2863
3117
|
id: taskId,
|
|
2864
3118
|
projectId,
|
|
2865
3119
|
title: input.title,
|
|
2866
3120
|
description: input.description,
|
|
2867
3121
|
preferredAgentId: input.preferredAgentId,
|
|
2868
|
-
agentId: dev.id,
|
|
2869
|
-
...(qa ? { qaAgentId: qa.id } : {}),
|
|
2870
3122
|
reviewRound: 0,
|
|
2871
|
-
status: 'in_progress',
|
|
2872
3123
|
branch: taskBranch,
|
|
2873
3124
|
branchCreatedByBaxian,
|
|
2874
3125
|
reviewMode: this.effectiveReviewMode(projectId),
|
|
@@ -2876,9 +3127,52 @@ export class AgentManager {
|
|
|
2876
3127
|
updatedAt: now,
|
|
2877
3128
|
...(imageFilenames ? { images: imageFilenames } : {}),
|
|
2878
3129
|
};
|
|
3130
|
+
if (input.preferredAgentId === '') {
|
|
3131
|
+
const unassigned = {
|
|
3132
|
+
...taskBase,
|
|
3133
|
+
agentId: '',
|
|
3134
|
+
devAgentId: '',
|
|
3135
|
+
status: 'pending',
|
|
3136
|
+
};
|
|
3137
|
+
return this.persistQueuedTask(unassigned, 'unassigned');
|
|
3138
|
+
}
|
|
3139
|
+
const target = await this.pickAgent(projectId, input.preferredAgentId);
|
|
3140
|
+
const targetConfig = this.getAgentConfig(input.preferredAgentId);
|
|
3141
|
+
const group = this.findAgentGroup(input.preferredAgentId);
|
|
3142
|
+
const dev = group?.find(agent => agent.role === 'dev');
|
|
3143
|
+
if (!dev)
|
|
3144
|
+
throw new ApiError(409, `Agent ${input.preferredAgentId} has no dev agent in its group`);
|
|
3145
|
+
const qa = group?.find(agent => agent.role === 'qa');
|
|
3146
|
+
const research = targetConfig.role === 'research' ? targetConfig : undefined;
|
|
3147
|
+
const researchFields = research
|
|
3148
|
+
? { researchAgentId: research.id, phase: 'research' }
|
|
3149
|
+
: {};
|
|
3150
|
+
const queued = {
|
|
3151
|
+
...taskBase,
|
|
3152
|
+
agentId: '',
|
|
3153
|
+
devAgentId: dev.id,
|
|
3154
|
+
...researchFields,
|
|
3155
|
+
status: 'pending',
|
|
3156
|
+
...(qa ? { qaAgentId: qa.id } : {}),
|
|
3157
|
+
};
|
|
3158
|
+
if (!target) {
|
|
3159
|
+
return this.persistQueuedTask(queued, 'preferred_agent_busy', input.preferredAgentId);
|
|
3160
|
+
}
|
|
3161
|
+
const lockToken = await this.lockManager.acquire(target.id, taskId);
|
|
3162
|
+
if (!lockToken) {
|
|
3163
|
+
return this.persistQueuedTask(queued, 'agent_locked', input.preferredAgentId);
|
|
3164
|
+
}
|
|
3165
|
+
const task = {
|
|
3166
|
+
...taskBase,
|
|
3167
|
+
agentId: target.id,
|
|
3168
|
+
devAgentId: dev.id,
|
|
3169
|
+
...(qa ? { qaAgentId: qa.id } : {}),
|
|
3170
|
+
...researchFields,
|
|
3171
|
+
status: 'in_progress',
|
|
3172
|
+
};
|
|
2879
3173
|
await this.taskStore.set(task);
|
|
2880
|
-
await this.agentStore.update(
|
|
2881
|
-
id:
|
|
3174
|
+
await this.agentStore.update(target.id, (existing) => ({
|
|
3175
|
+
id: target.id,
|
|
2882
3176
|
projectId,
|
|
2883
3177
|
taskId,
|
|
2884
3178
|
lockToken,
|
|
@@ -2893,9 +3187,9 @@ export class AgentManager {
|
|
|
2893
3187
|
type: 'task.assigned',
|
|
2894
3188
|
timestamp: now,
|
|
2895
3189
|
projectId,
|
|
2896
|
-
agentId:
|
|
3190
|
+
agentId: target.id,
|
|
2897
3191
|
taskId,
|
|
2898
|
-
data: { agentId:
|
|
3192
|
+
data: { agentId: target.id },
|
|
2899
3193
|
});
|
|
2900
3194
|
return task;
|
|
2901
3195
|
});
|
|
@@ -2921,10 +3215,15 @@ export class AgentManager {
|
|
|
2921
3215
|
return task;
|
|
2922
3216
|
}
|
|
2923
3217
|
async startCreatedTaskSession(taskId, agentId, signalToken, dispatchLockToken) {
|
|
3218
|
+
const initialTask = await this.taskStore.get(taskId);
|
|
3219
|
+
if (!initialTask)
|
|
3220
|
+
return null;
|
|
3221
|
+
const initialDispatch = this.resolveInitialDispatch(initialTask);
|
|
3222
|
+
const dispatchPhase = initialDispatch.phase;
|
|
2924
3223
|
let started = false;
|
|
2925
3224
|
let dispatchErr = null;
|
|
2926
3225
|
try {
|
|
2927
|
-
started = await this.startSession(taskId, agentId,
|
|
3226
|
+
started = await this.startSession(taskId, agentId, dispatchPhase);
|
|
2928
3227
|
}
|
|
2929
3228
|
catch (err) {
|
|
2930
3229
|
dispatchErr = err;
|
|
@@ -2954,7 +3253,7 @@ export class AgentManager {
|
|
|
2954
3253
|
await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true });
|
|
2955
3254
|
return null;
|
|
2956
3255
|
}
|
|
2957
|
-
const initialKinds = this.
|
|
3256
|
+
const initialKinds = this.resolveInitialDispatch(fresh).kinds;
|
|
2958
3257
|
try {
|
|
2959
3258
|
await this.armPostDispatchSignalOrHold(taskId, agentId, initialKinds, signalToken);
|
|
2960
3259
|
}
|
|
@@ -2968,7 +3267,7 @@ export class AgentManager {
|
|
|
2968
3267
|
return null;
|
|
2969
3268
|
}
|
|
2970
3269
|
if (dispatchErr instanceof DispatchTerminalError) {
|
|
2971
|
-
await this.failTaskForDispatchError(taskId,
|
|
3270
|
+
await this.failTaskForDispatchError(taskId, dispatchPhase, agentId, dispatchErr);
|
|
2972
3271
|
}
|
|
2973
3272
|
else if (dispatchErr instanceof EnsureSessionError && dispatchErr.partial.handled) {
|
|
2974
3273
|
}
|
|
@@ -3187,8 +3486,8 @@ export class AgentManager {
|
|
|
3187
3486
|
if (cfg.projectId !== fresh.projectId) {
|
|
3188
3487
|
return { task: fresh, errorCode: 400, error: `Agent ${agentId} not in project ${fresh.projectId}` };
|
|
3189
3488
|
}
|
|
3190
|
-
if (cfg.role
|
|
3191
|
-
return { task: fresh, errorCode: 400, error: `Agent ${agentId} is not dev role` };
|
|
3489
|
+
if (!TASK_OWNER_ROLES.has(cfg.role)) {
|
|
3490
|
+
return { task: fresh, errorCode: 400, error: `Agent ${agentId} is not dev or research role` };
|
|
3192
3491
|
}
|
|
3193
3492
|
if (fresh.preferredAgentId !== '' && fresh.preferredAgentId !== agentId) {
|
|
3194
3493
|
return {
|
|
@@ -3206,14 +3505,29 @@ export class AgentManager {
|
|
|
3206
3505
|
return { task: fresh, errorCode: 409, error: `Agent ${agentId} lock acquisition failed` };
|
|
3207
3506
|
}
|
|
3208
3507
|
const now = new Date().toISOString();
|
|
3209
|
-
const
|
|
3508
|
+
const initiallyUnassigned = fresh.preferredAgentId === '';
|
|
3509
|
+
const group = initiallyUnassigned ? this.findAgentGroup(agentId) : undefined;
|
|
3510
|
+
const devId = initiallyUnassigned
|
|
3511
|
+
? group?.find(agent => agent.role === 'dev')?.id
|
|
3512
|
+
: fresh.devAgentId;
|
|
3513
|
+
if (!devId) {
|
|
3514
|
+
await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
|
|
3515
|
+
return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no dev agent in its group` };
|
|
3516
|
+
}
|
|
3517
|
+
const qaId = initiallyUnassigned
|
|
3518
|
+
? group?.find(agent => agent.role === 'qa')?.id
|
|
3519
|
+
: fresh.qaAgentId;
|
|
3520
|
+
const researchId = initiallyUnassigned && cfg.role === 'research' ? cfg.id : fresh.researchAgentId;
|
|
3210
3521
|
const claimedTask = {
|
|
3211
3522
|
...fresh,
|
|
3212
3523
|
preferredAgentId: agentId,
|
|
3213
3524
|
agentId,
|
|
3525
|
+
devAgentId: devId,
|
|
3526
|
+
researchAgentId: researchId,
|
|
3527
|
+
qaAgentId: qaId,
|
|
3528
|
+
phase: researchId ? 'research' : undefined,
|
|
3214
3529
|
status: 'in_progress',
|
|
3215
3530
|
updatedAt: now,
|
|
3216
|
-
...(qaId ? { qaAgentId: qaId } : {}),
|
|
3217
3531
|
};
|
|
3218
3532
|
await this.taskStore.set(claimedTask);
|
|
3219
3533
|
await this.agentStore.update(agentId, (existing) => ({
|
|
@@ -3247,20 +3561,21 @@ export class AgentManager {
|
|
|
3247
3561
|
await this.updateTask(claimed.id, { signalToken });
|
|
3248
3562
|
let started = false;
|
|
3249
3563
|
let dispatchErr = null;
|
|
3564
|
+
const initialDispatch = this.resolveInitialDispatch(claimed);
|
|
3250
3565
|
try {
|
|
3251
|
-
started = await this.startSession(claimed.id, claimed.agentId,
|
|
3566
|
+
started = await this.startSession(claimed.id, claimed.agentId, initialDispatch.phase);
|
|
3252
3567
|
}
|
|
3253
3568
|
catch (err) {
|
|
3254
3569
|
dispatchErr = err;
|
|
3255
3570
|
console.error(`[AgentManager] dispatchPendingTask startSession hard error for task=${claimed.id}:`, err);
|
|
3256
3571
|
}
|
|
3257
3572
|
if (started) {
|
|
3258
|
-
await this.armPostDispatchSignalOrHold(claimed.id, claimed.agentId,
|
|
3573
|
+
await this.armPostDispatchSignalOrHold(claimed.id, claimed.agentId, initialDispatch.kinds, signalToken);
|
|
3259
3574
|
const refreshed = await this.taskStore.get(claimed.id);
|
|
3260
3575
|
return { task: refreshed ?? claimed };
|
|
3261
3576
|
}
|
|
3262
3577
|
if (dispatchErr instanceof DispatchTerminalError) {
|
|
3263
|
-
await this.failTaskForDispatchError(claimed.id,
|
|
3578
|
+
await this.failTaskForDispatchError(claimed.id, initialDispatch.phase, claimed.agentId, dispatchErr);
|
|
3264
3579
|
}
|
|
3265
3580
|
else if (dispatchErr instanceof EnsureSessionError && dispatchErr.partial.handled) {
|
|
3266
3581
|
}
|
|
@@ -3391,18 +3706,12 @@ export class AgentManager {
|
|
|
3391
3706
|
const stillOwner = stateNow?.taskId === taskId
|
|
3392
3707
|
&& stateNow.lockToken === lockToken
|
|
3393
3708
|
&& await this.lockManager.isOwner(agentId, taskId, lockToken);
|
|
3394
|
-
|
|
3395
|
-
|
|
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
|
-
}
|
|
3709
|
+
if (stillOwner) {
|
|
3710
|
+
await this.rollbackCreatedSession(agentId, err.partial, 'startSession ensure rollback');
|
|
3403
3711
|
}
|
|
3404
|
-
|
|
3405
|
-
console.warn(`[AgentManager] startSession ensureSession rollback
|
|
3712
|
+
else {
|
|
3713
|
+
console.warn(`[AgentManager] startSession ensureSession rollback skipped for stale owner ` +
|
|
3714
|
+
`${agentId}/${taskId}`);
|
|
3406
3715
|
}
|
|
3407
3716
|
}
|
|
3408
3717
|
throw err;
|
|
@@ -3437,6 +3746,9 @@ export class AgentManager {
|
|
|
3437
3746
|
throw new Error(`Task ${taskId} has no review branch`);
|
|
3438
3747
|
await this.switchToVerifiedReviewHead(branchManager, workdir, task, assertOwner);
|
|
3439
3748
|
}
|
|
3749
|
+
else if (phase === 'research') {
|
|
3750
|
+
await branchManager.switchToDefaultDetached(workdir);
|
|
3751
|
+
}
|
|
3440
3752
|
else {
|
|
3441
3753
|
if (!task.branch)
|
|
3442
3754
|
throw new Error(`Task ${taskId} has no task branch`);
|
|
@@ -3446,7 +3758,7 @@ export class AgentManager {
|
|
|
3446
3758
|
if (!ensure.freshRuntime) {
|
|
3447
3759
|
await this.clearRuntimeForTaskBoundary(tmux, paneId, agentId, agent.runtime, assertOwner);
|
|
3448
3760
|
if (ensure.skillsStale)
|
|
3449
|
-
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
3761
|
+
await this.tagSessionSkillsVersion(tmux, agentId, ensure.sessionRef);
|
|
3450
3762
|
}
|
|
3451
3763
|
}
|
|
3452
3764
|
catch (err) {
|
|
@@ -3499,16 +3811,20 @@ export class AgentManager {
|
|
|
3499
3811
|
});
|
|
3500
3812
|
const promptSignalToken = opts.signalToken ?? task.signalToken;
|
|
3501
3813
|
const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
|
|
3502
|
-
const hasQaPartner =
|
|
3814
|
+
const hasQaPartner = task.qaAgentId !== undefined;
|
|
3503
3815
|
let prompt;
|
|
3504
3816
|
try {
|
|
3505
|
-
await this.
|
|
3817
|
+
await this.prepareDispatchArtifacts(agent, dispatchWorkdir, phase, {
|
|
3818
|
+
specDocuments: opts.specDocuments,
|
|
3819
|
+
preserveOutputs: false,
|
|
3820
|
+
assertOwner,
|
|
3821
|
+
});
|
|
3506
3822
|
const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
|
|
3507
3823
|
let payloadOpts = {};
|
|
3508
3824
|
if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
|
|
3509
3825
|
payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, dispatchWorkdir, {
|
|
3510
3826
|
phase,
|
|
3511
|
-
|
|
3827
|
+
taskPhase: task.phase,
|
|
3512
3828
|
...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
|
|
3513
3829
|
reviewRound: task.reviewRound,
|
|
3514
3830
|
...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
|
|
@@ -3642,7 +3958,7 @@ export class AgentManager {
|
|
|
3642
3958
|
const isAckUnknown = err instanceof DispatchTerminalError && err.reason === 'ack_unknown';
|
|
3643
3959
|
if (!isAckUnknown) {
|
|
3644
3960
|
await cleanupCheckoutOrHold();
|
|
3645
|
-
if (agentMarkedRunning) {
|
|
3961
|
+
if (agentMarkedRunning && !opts.preserveBindingOnFailure) {
|
|
3646
3962
|
try {
|
|
3647
3963
|
let released = false;
|
|
3648
3964
|
let releaseToken;
|
|
@@ -3859,18 +4175,12 @@ export class AgentManager {
|
|
|
3859
4175
|
const stillOwner = stateNow?.taskId === taskId
|
|
3860
4176
|
&& stateNow.lockToken === lockToken
|
|
3861
4177
|
&& await this.lockManager.isOwner(agentId, taskId, lockToken);
|
|
3862
|
-
|
|
3863
|
-
|
|
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
|
-
}
|
|
4178
|
+
if (stillOwner) {
|
|
4179
|
+
await this.rollbackCreatedSession(agentId, err.partial, 'continueSession ensure rollback');
|
|
3871
4180
|
}
|
|
3872
|
-
|
|
3873
|
-
console.warn(`[AgentManager] continueSession ensureSession rollback
|
|
4181
|
+
else {
|
|
4182
|
+
console.warn(`[AgentManager] continueSession ensureSession rollback skipped for stale owner ` +
|
|
4183
|
+
`${agentId}/${taskId}`);
|
|
3874
4184
|
}
|
|
3875
4185
|
}
|
|
3876
4186
|
throw err;
|
|
@@ -3884,6 +4194,16 @@ export class AgentManager {
|
|
|
3884
4194
|
return false;
|
|
3885
4195
|
}
|
|
3886
4196
|
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
4197
|
+
const verifiedWorkdir = stateAfterEnsure.workdir;
|
|
4198
|
+
if (!verifiedWorkdir
|
|
4199
|
+
|| verifiedWorkdir !== ensure.workdir
|
|
4200
|
+
|| verifiedWorkdir !== taskWorkdir) {
|
|
4201
|
+
const reason = `Workdir changed during continueSession: ` +
|
|
4202
|
+
`before=${taskWorkdir}, ensure=${ensure.workdir}, state=${verifiedWorkdir ?? 'missing'}`;
|
|
4203
|
+
console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
|
|
4204
|
+
await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
|
|
4205
|
+
return false;
|
|
4206
|
+
}
|
|
3887
4207
|
const expectedStatuses = PHASE_EXPECTED_STATUS[phase] ?? [];
|
|
3888
4208
|
const taskAfterEnsure = await this.taskStore.get(taskId);
|
|
3889
4209
|
if (!taskAfterEnsure || TERMINAL_STATUSES.includes(taskAfterEnsure.status)) {
|
|
@@ -3895,28 +4215,39 @@ export class AgentManager {
|
|
|
3895
4215
|
`expected ${expectedStatuses.join('/')} for phase=${phase} after ensure; skipping`);
|
|
3896
4216
|
return false;
|
|
3897
4217
|
}
|
|
3898
|
-
if (
|
|
4218
|
+
if (agent.role === 'dev' && task.branch) {
|
|
3899
4219
|
const branches = new BranchManager(runner);
|
|
3900
|
-
await branches.assertClean(
|
|
3901
|
-
const actualRef = await branches.currentRef(
|
|
4220
|
+
await branches.assertClean(verifiedWorkdir);
|
|
4221
|
+
const actualRef = await branches.currentRef(verifiedWorkdir);
|
|
3902
4222
|
if (actualRef !== `refs/heads/${task.branch}`) {
|
|
3903
4223
|
throw new Error(`Agent ${agentId} checkout mismatch for task ${taskId}: ` +
|
|
3904
4224
|
`expected refs/heads/${task.branch}, got ${actualRef ?? 'detached HEAD'}`);
|
|
3905
4225
|
}
|
|
3906
4226
|
}
|
|
4227
|
+
else if (agent.role === 'research') {
|
|
4228
|
+
await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
|
|
4229
|
+
await new BranchManager(runner).switchToDefaultDetached(verifiedWorkdir);
|
|
4230
|
+
await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
|
|
4231
|
+
}
|
|
3907
4232
|
const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
|
|
3908
4233
|
let prompt;
|
|
3909
4234
|
try {
|
|
3910
|
-
await this.
|
|
4235
|
+
await this.prepareDispatchArtifacts(agent, verifiedWorkdir, phase, {
|
|
4236
|
+
specDocuments: opts.specDocuments,
|
|
4237
|
+
preserveOutputs: opts.preserveDispatchOutputs ?? false,
|
|
4238
|
+
assertOwner: async () => {
|
|
4239
|
+
await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
|
|
4240
|
+
},
|
|
4241
|
+
});
|
|
3911
4242
|
const useIncrementalNudge = typeof opts.postApproveRedispatchCount === 'number'
|
|
3912
4243
|
&& opts.postApproveRedispatchCount > 0
|
|
3913
4244
|
&& !ensure.freshRuntime;
|
|
3914
4245
|
const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
|
|
3915
4246
|
let payloadOpts = {};
|
|
3916
4247
|
if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
|
|
3917
|
-
payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent,
|
|
4248
|
+
payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, verifiedWorkdir, {
|
|
3918
4249
|
phase,
|
|
3919
|
-
|
|
4250
|
+
taskPhase: task.phase,
|
|
3920
4251
|
...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
|
|
3921
4252
|
reviewRound: task.reviewRound,
|
|
3922
4253
|
...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
|
|
@@ -3931,7 +4262,7 @@ export class AgentManager {
|
|
|
3931
4262
|
task,
|
|
3932
4263
|
phase,
|
|
3933
4264
|
agent,
|
|
3934
|
-
workdir:
|
|
4265
|
+
workdir: verifiedWorkdir,
|
|
3935
4266
|
skillRegistry: this.skillRegistry,
|
|
3936
4267
|
...(signalToken ? { signalToken } : {}),
|
|
3937
4268
|
...(useIncrementalNudge
|
|
@@ -3980,6 +4311,13 @@ export class AgentManager {
|
|
|
3980
4311
|
console.warn(`[AgentManager] continueSession[${phase}]: ownership token changed; skipping`);
|
|
3981
4312
|
return false;
|
|
3982
4313
|
}
|
|
4314
|
+
if (agentFresh.workdir !== verifiedWorkdir) {
|
|
4315
|
+
const reason = `Workdir changed before prompt injection: ` +
|
|
4316
|
+
`verified=${verifiedWorkdir}, current=${agentFresh.workdir ?? 'missing'}`;
|
|
4317
|
+
console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
|
|
4318
|
+
await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
|
|
4319
|
+
return false;
|
|
4320
|
+
}
|
|
3983
4321
|
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
3984
4322
|
if (phase === 'post-approve') {
|
|
3985
4323
|
const completionFresh = await this.getPostApproveCompletion(taskId);
|
|
@@ -3993,18 +4331,35 @@ export class AgentManager {
|
|
|
3993
4331
|
}
|
|
3994
4332
|
const now = new Date().toISOString();
|
|
3995
4333
|
await this.agentStore.update(agentId, (latest) => {
|
|
3996
|
-
if (!latest
|
|
4334
|
+
if (!latest
|
|
4335
|
+
|| latest.taskId !== taskId
|
|
4336
|
+
|| latest.lockToken !== lockToken
|
|
4337
|
+
|| latest.workdir !== verifiedWorkdir) {
|
|
3997
4338
|
return AGENT_STORE_NOOP;
|
|
4339
|
+
}
|
|
3998
4340
|
// The continuation prompt supersedes any question asked under the previous one.
|
|
3999
4341
|
const { needInputAt: _needInputAt, ...rest } = latest;
|
|
4000
4342
|
return {
|
|
4001
4343
|
...rest,
|
|
4002
4344
|
paneId,
|
|
4003
|
-
workdir:
|
|
4345
|
+
workdir: verifiedWorkdir,
|
|
4004
4346
|
lockToken,
|
|
4005
4347
|
updatedAt: now,
|
|
4006
4348
|
};
|
|
4007
4349
|
});
|
|
4350
|
+
const stateBeforeInject = await this.agentStore.get(agentId);
|
|
4351
|
+
if (stateBeforeInject?.taskId !== taskId || stateBeforeInject.lockToken !== lockToken) {
|
|
4352
|
+
console.warn(`[AgentManager] continueSession[${phase}]: ownership changed before prompt injection; skipping`);
|
|
4353
|
+
return false;
|
|
4354
|
+
}
|
|
4355
|
+
if (stateBeforeInject.workdir !== verifiedWorkdir) {
|
|
4356
|
+
const reason = `Workdir changed before prompt injection: ` +
|
|
4357
|
+
`verified=${verifiedWorkdir}, current=${stateBeforeInject.workdir ?? 'missing'}`;
|
|
4358
|
+
console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
|
|
4359
|
+
await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
|
|
4360
|
+
return false;
|
|
4361
|
+
}
|
|
4362
|
+
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
4008
4363
|
await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime);
|
|
4009
4364
|
return true;
|
|
4010
4365
|
}
|
|
@@ -4015,13 +4370,47 @@ export class AgentManager {
|
|
|
4015
4370
|
return false;
|
|
4016
4371
|
}
|
|
4017
4372
|
const boundTask = await this.taskStore.get(state.taskId);
|
|
4018
|
-
if (!boundTask
|
|
4373
|
+
if (!boundTask)
|
|
4374
|
+
return false;
|
|
4375
|
+
if (boundTask.status === 'spec-ready' && isSpecStagePhase(boundTask.phase)) {
|
|
4376
|
+
if (state.id === boundTask.devAgentId && state.id !== boundTask.agentId) {
|
|
4377
|
+
const lockToken = state.lockToken;
|
|
4378
|
+
await this.agentStore.update(state.id, (latest) => {
|
|
4379
|
+
if (!latest
|
|
4380
|
+
|| latest.taskId !== state.taskId
|
|
4381
|
+
|| latest.bootstrappingTaskId !== state.taskId
|
|
4382
|
+
|| latest.lockToken !== lockToken) {
|
|
4383
|
+
return AGENT_STORE_NOOP;
|
|
4384
|
+
}
|
|
4385
|
+
return {
|
|
4386
|
+
id: latest.id,
|
|
4387
|
+
projectId: latest.projectId,
|
|
4388
|
+
...(latest.paneId !== undefined ? { paneId: latest.paneId } : {}),
|
|
4389
|
+
...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
|
|
4390
|
+
...(latest.creationToken !== undefined ? { creationToken: latest.creationToken } : {}),
|
|
4391
|
+
updatedAt: new Date().toISOString(),
|
|
4392
|
+
};
|
|
4393
|
+
});
|
|
4394
|
+
if (lockToken)
|
|
4395
|
+
await this.lockManager.releaseIfOwner(state.id, state.taskId, lockToken);
|
|
4396
|
+
return true;
|
|
4397
|
+
}
|
|
4398
|
+
await this.clearBootstrapMarker(state.id, state.taskId);
|
|
4019
4399
|
return false;
|
|
4020
4400
|
}
|
|
4021
|
-
if (
|
|
4401
|
+
if (boundTask.status !== 'in_progress' || boundTask.agentId !== state.id)
|
|
4402
|
+
return false;
|
|
4403
|
+
const dispatchPhase = boundTask.phase === 'research'
|
|
4404
|
+
? 'research'
|
|
4405
|
+
: boundTask.specReviewRound !== undefined ? 'code' : 'develop';
|
|
4406
|
+
if (await this.bootstrapPromptWasDelivered(state.taskId, boundTask.createdAt, state.id, dispatchPhase)) {
|
|
4022
4407
|
await this.clearBootstrapMarker(state.id, state.taskId);
|
|
4023
4408
|
return false;
|
|
4024
4409
|
}
|
|
4410
|
+
if (dispatchPhase === 'code') {
|
|
4411
|
+
await this.markAwaitingHuman(state.id, 'code-dispatch-failed', 'Code-phase handoff was interrupted before the prompt was delivered. Resume to replay the persisted Spec documents.', { expectedTaskId: state.taskId });
|
|
4412
|
+
return true;
|
|
4413
|
+
}
|
|
4025
4414
|
console.warn(`[recover] agent ${state.id} was mid-bootstrap for in_progress task ${state.taskId} ` +
|
|
4026
4415
|
`(prompt never ack'd); rolling the task back to pending`);
|
|
4027
4416
|
await this.rollbackFailedDispatch(state.taskId, state.id, undefined, state.lockToken);
|
|
@@ -4033,12 +4422,15 @@ export class AgentManager {
|
|
|
4033
4422
|
});
|
|
4034
4423
|
return true;
|
|
4035
4424
|
}
|
|
4036
|
-
async bootstrapPromptWasDelivered(taskId, createdAtIso) {
|
|
4425
|
+
async bootstrapPromptWasDelivered(taskId, createdAtIso, agentId, phase) {
|
|
4037
4426
|
const today = new Date().toISOString().slice(0, 10);
|
|
4038
4427
|
const from = createdAtIso.slice(0, 10);
|
|
4039
4428
|
try {
|
|
4040
4429
|
const events = await this.eventBus.readRange(from, today);
|
|
4041
|
-
return events.some((
|
|
4430
|
+
return events.some((event) => event.type === 'session.started'
|
|
4431
|
+
&& event.taskId === taskId
|
|
4432
|
+
&& event.agentId === agentId
|
|
4433
|
+
&& event.data.phase === phase);
|
|
4042
4434
|
}
|
|
4043
4435
|
catch (err) {
|
|
4044
4436
|
console.warn(`[recover] bootstrapPromptWasDelivered read failed for task=${taskId}:`, err);
|
|
@@ -4222,13 +4614,7 @@ export class AgentManager {
|
|
|
4222
4614
|
continue;
|
|
4223
4615
|
}
|
|
4224
4616
|
if (err instanceof EnsureSessionError && err.partial.createdSession) {
|
|
4225
|
-
|
|
4226
|
-
const runner = this.createRunnerFor(agentConfig);
|
|
4227
|
-
await new TmuxManager(runner).killSession(state.id);
|
|
4228
|
-
}
|
|
4229
|
-
catch (cleanupErr) {
|
|
4230
|
-
console.warn(`[recover] killSession rollback failed for agent=${state.id}:`, cleanupErr);
|
|
4231
|
-
}
|
|
4617
|
+
await this.rollbackCreatedSession(state.id, err.partial, 'recover ensure rollback');
|
|
4232
4618
|
}
|
|
4233
4619
|
const message = err instanceof Error ? err.message : String(err);
|
|
4234
4620
|
console.warn(`[recover] ensureSession failed for agent=${state.id}: ${message}`);
|
|
@@ -4493,8 +4879,8 @@ export class AgentManager {
|
|
|
4493
4879
|
if (cfg.projectId !== projectId) {
|
|
4494
4880
|
throw new ApiError(400, `Agent ${input.preferredAgentId} not in project ${projectId}`);
|
|
4495
4881
|
}
|
|
4496
|
-
if (cfg.role
|
|
4497
|
-
throw new ApiError(400, `Agent ${input.preferredAgentId} is not dev role`);
|
|
4882
|
+
if (!TASK_OWNER_ROLES.has(cfg.role)) {
|
|
4883
|
+
throw new ApiError(400, `Agent ${input.preferredAgentId} is not dev or research role`);
|
|
4498
4884
|
}
|
|
4499
4885
|
}
|
|
4500
4886
|
let previewBytes;
|
|
@@ -4529,24 +4915,23 @@ export class AgentManager {
|
|
|
4529
4915
|
if (opts.expectSignalToken !== undefined && task.signalToken !== opts.expectSignalToken) {
|
|
4530
4916
|
throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (signalToken rotated); aborting`);
|
|
4531
4917
|
}
|
|
4532
|
-
if (task.reviewMode === 'server' || task.phase
|
|
4918
|
+
if (task.reviewMode === 'server' || isSpecStagePhase(task.phase)) {
|
|
4533
4919
|
if (!MANUAL_SERVER_REVIEW_STATUSES.includes(task.status)) {
|
|
4534
4920
|
throw new ApiError(409, `Task ${taskId} status is ${task.status}; manual server-side review requires ${MANUAL_SERVER_REVIEW_STATUSES.join('/')}`);
|
|
4535
4921
|
}
|
|
4536
4922
|
if (!this.serverReviewDriver) {
|
|
4537
4923
|
throw new ApiError(409, `Server review pipeline is not configured; cannot dispatch review for ${taskId}`);
|
|
4538
4924
|
}
|
|
4539
|
-
// in_progress 且 phase 未定:dev 既可能交 spec-done 也可能交 code-done,评审对象无从判定
|
|
4540
4925
|
if (task.status === 'in_progress' && task.phase === undefined) {
|
|
4541
4926
|
throw new ApiError(409, `Task ${taskId} has no phase yet (the dev has not delivered spec-done/code-done); wait for the dev signal or use Cancel/Retry`);
|
|
4542
4927
|
}
|
|
4543
4928
|
// 手工发起=明确要求跑一次 QA 评审;无 QA 时必须拒绝,不得落入自动通过/停驻兜底
|
|
4544
|
-
const qaAvailable =
|
|
4545
|
-
|
|
4929
|
+
const qaAvailable = task.qaAgentId !== undefined
|
|
4930
|
+
&& this.getAgentConfig(task.qaAgentId)?.role === 'qa';
|
|
4546
4931
|
if (!qaAvailable) {
|
|
4547
4932
|
throw new ApiError(400, `Task ${taskId} has no QA partner configured; manual review requires a QA agent`);
|
|
4548
4933
|
}
|
|
4549
|
-
const isSpec = task.phase
|
|
4934
|
+
const isSpec = isSpecStagePhase(task.phase);
|
|
4550
4935
|
const cap = this.config.review.rounds + (task.maxRoundsContinues ?? 0);
|
|
4551
4936
|
const round = isSpec ? (task.specReviewRound ?? 0) : task.reviewRound;
|
|
4552
4937
|
if (round + 1 > cap) {
|
|
@@ -4561,18 +4946,12 @@ export class AgentManager {
|
|
|
4561
4946
|
if (!task.branch) {
|
|
4562
4947
|
throw new ApiError(400, `Task ${taskId} has no branch; cannot dispatch review`);
|
|
4563
4948
|
}
|
|
4564
|
-
|
|
4565
|
-
if (qaId && !this.getAgentConfig(qaId)) {
|
|
4566
|
-
console.warn(`[dispatchReviewToQa] task ${taskId}.qaAgentId="${qaId}" no longer in config; ` +
|
|
4567
|
-
`falling back to findQaPartner(${task.agentId})`);
|
|
4568
|
-
qaId = undefined;
|
|
4569
|
-
}
|
|
4949
|
+
const qaId = task.qaAgentId;
|
|
4570
4950
|
if (!qaId) {
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
}
|
|
4575
|
-
qaId = qa.id;
|
|
4951
|
+
throw new ApiError(400, `Task ${taskId} has no QA participant; cannot dispatch review`);
|
|
4952
|
+
}
|
|
4953
|
+
if (this.getAgentConfig(qaId)?.role !== 'qa') {
|
|
4954
|
+
throw new ApiError(409, `Task ${taskId} QA participant ${qaId} is unavailable`);
|
|
4576
4955
|
}
|
|
4577
4956
|
this.manualReviewInFlight.add(taskId);
|
|
4578
4957
|
return { mode: 'github', qaId, devAgentId: task.agentId, taskStatusAtClaim: task.status };
|
|
@@ -4658,7 +5037,6 @@ export class AgentManager {
|
|
|
4658
5037
|
await this.taskStore.set({
|
|
4659
5038
|
...fresh,
|
|
4660
5039
|
reviewRound: bumpRound ? fresh.reviewRound + 1 : fresh.reviewRound,
|
|
4661
|
-
qaAgentId: qaId,
|
|
4662
5040
|
signalToken: armedToken,
|
|
4663
5041
|
updatedAt: new Date().toISOString(),
|
|
4664
5042
|
});
|
|
@@ -4741,7 +5119,7 @@ export class AgentManager {
|
|
|
4741
5119
|
if (task.status !== 'max_rounds') {
|
|
4742
5120
|
throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
|
|
4743
5121
|
}
|
|
4744
|
-
if (task.phase
|
|
5122
|
+
if (isSpecStagePhase(task.phase)) {
|
|
4745
5123
|
throw new ApiError(409, `Continue one round is only supported for code-phase tasks`);
|
|
4746
5124
|
}
|
|
4747
5125
|
if (task.reviewMode === 'server') {
|
|
@@ -4937,31 +5315,40 @@ export class AgentManager {
|
|
|
4937
5315
|
? ['spec-done', 'code-done']
|
|
4938
5316
|
: ['spec-done', 'pr-created'];
|
|
4939
5317
|
}
|
|
5318
|
+
resolveInitialDispatch(task) {
|
|
5319
|
+
if (task.phase === 'research')
|
|
5320
|
+
return { phase: 'research', kinds: ['spec-done'] };
|
|
5321
|
+
return { phase: 'develop', kinds: this.devInitialSignalKinds(task.reviewMode) };
|
|
5322
|
+
}
|
|
4940
5323
|
mapTaskStateToExpectedWatcher(task) {
|
|
4941
5324
|
if (task.reviewMode === 'server')
|
|
4942
5325
|
return this.mapServerTaskToExpectedWatcher(task);
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
if (task.phase !== 'spec' && task.status === 'fixing' && task.agentId) {
|
|
4950
|
-
return { expectedKinds: ['pr-fixed'], agentId: task.agentId };
|
|
4951
|
-
}
|
|
4952
|
-
if (task.phase === undefined && task.status === 'in_progress' && task.agentId) {
|
|
4953
|
-
return { expectedKinds: ['spec-done', 'pr-created'], agentId: task.agentId };
|
|
5326
|
+
const specStage = isSpecStagePhase(task.phase);
|
|
5327
|
+
if (task.status === 'review' && task.qaAgentId) {
|
|
5328
|
+
return {
|
|
5329
|
+
expectedKinds: specStage ? ['spec-reviewed'] : ['pr-approved', 'pr-changes-requested'],
|
|
5330
|
+
agentId: task.qaAgentId,
|
|
5331
|
+
};
|
|
4954
5332
|
}
|
|
4955
|
-
if (task.
|
|
4956
|
-
return {
|
|
5333
|
+
if (task.status === 'fixing' && task.agentId) {
|
|
5334
|
+
return {
|
|
5335
|
+
expectedKinds: [specStage ? 'spec-fixed' : 'pr-fixed'],
|
|
5336
|
+
agentId: task.agentId,
|
|
5337
|
+
};
|
|
4957
5338
|
}
|
|
4958
|
-
if (task.
|
|
4959
|
-
|
|
5339
|
+
if (task.status === 'in_progress' && task.agentId) {
|
|
5340
|
+
if (task.phase === 'research')
|
|
5341
|
+
return { expectedKinds: ['spec-done'], agentId: task.agentId };
|
|
5342
|
+
if (task.phase === 'code')
|
|
5343
|
+
return { expectedKinds: ['pr-created'], agentId: task.agentId };
|
|
5344
|
+
if (task.phase === undefined) {
|
|
5345
|
+
return { expectedKinds: ['spec-done', 'pr-created'], agentId: task.agentId };
|
|
5346
|
+
}
|
|
4960
5347
|
}
|
|
4961
5348
|
return undefined;
|
|
4962
5349
|
}
|
|
4963
5350
|
mapServerTaskToExpectedWatcher(task) {
|
|
4964
|
-
const isSpec = task.phase
|
|
5351
|
+
const isSpec = isSpecStagePhase(task.phase);
|
|
4965
5352
|
if (task.status === 'review' && task.qaAgentId) {
|
|
4966
5353
|
return { expectedKinds: [isSpec ? 'spec-reviewed' : 'code-reviewed'], agentId: task.qaAgentId };
|
|
4967
5354
|
}
|
|
@@ -4969,8 +5356,11 @@ export class AgentManager {
|
|
|
4969
5356
|
return { expectedKinds: [isSpec ? 'spec-fixed' : 'code-fixed'], agentId: task.agentId };
|
|
4970
5357
|
}
|
|
4971
5358
|
if (task.status === 'in_progress' && task.agentId) {
|
|
4972
|
-
if (task.phase === '
|
|
5359
|
+
if (task.phase === 'research')
|
|
5360
|
+
return { expectedKinds: ['spec-done'], agentId: task.agentId };
|
|
5361
|
+
if (task.phase === 'code') {
|
|
4973
5362
|
return { expectedKinds: ['code-done'], agentId: task.agentId };
|
|
5363
|
+
}
|
|
4974
5364
|
return { expectedKinds: ['spec-done', 'code-done'], agentId: task.agentId };
|
|
4975
5365
|
}
|
|
4976
5366
|
if (task.status === 'approved' && task.agentId) {
|
|
@@ -5002,7 +5392,7 @@ export class AgentManager {
|
|
|
5002
5392
|
if (!t)
|
|
5003
5393
|
throw new ApiError(404, 'Task not found');
|
|
5004
5394
|
const retryable = TERMINAL_STATUSES.includes(t.status)
|
|
5005
|
-
|| (t.status === 'max_rounds' && t.phase
|
|
5395
|
+
|| (t.status === 'max_rounds' && isSpecStagePhase(t.phase));
|
|
5006
5396
|
if (!retryable) {
|
|
5007
5397
|
throw new ApiError(409, `Task ${taskId} cannot be retried in status "${t.status}"; cancel it first or wait for completion`);
|
|
5008
5398
|
}
|
|
@@ -5037,7 +5427,11 @@ export class AgentManager {
|
|
|
5037
5427
|
if (patch.preferredAgentId !== undefined && patch.preferredAgentId !== task.preferredAgentId) {
|
|
5038
5428
|
if (patch.preferredAgentId === '') {
|
|
5039
5429
|
task.preferredAgentId = '';
|
|
5430
|
+
task.agentId = '';
|
|
5431
|
+
task.devAgentId = '';
|
|
5432
|
+
delete task.phase;
|
|
5040
5433
|
delete task.qaAgentId;
|
|
5434
|
+
delete task.researchAgentId;
|
|
5041
5435
|
}
|
|
5042
5436
|
else {
|
|
5043
5437
|
const cfg = this.getAgentConfig(patch.preferredAgentId);
|
|
@@ -5046,10 +5440,23 @@ export class AgentManager {
|
|
|
5046
5440
|
if (cfg.projectId !== task.projectId) {
|
|
5047
5441
|
throw new ApiError(400, `Agent not in project ${task.projectId}`);
|
|
5048
5442
|
}
|
|
5049
|
-
if (cfg.role
|
|
5050
|
-
throw new ApiError(400, `Agent is not dev role`);
|
|
5443
|
+
if (!TASK_OWNER_ROLES.has(cfg.role))
|
|
5444
|
+
throw new ApiError(400, `Agent is not dev or research role`);
|
|
5445
|
+
const group = this.findAgentGroup(cfg.id);
|
|
5446
|
+
const dev = group?.find(agent => agent.role === 'dev');
|
|
5447
|
+
if (!dev)
|
|
5448
|
+
throw new ApiError(409, `Agent ${cfg.id} has no dev agent in its group`);
|
|
5051
5449
|
task.preferredAgentId = patch.preferredAgentId;
|
|
5052
|
-
|
|
5450
|
+
task.devAgentId = dev.id;
|
|
5451
|
+
if (cfg.role === 'research') {
|
|
5452
|
+
task.phase = 'research';
|
|
5453
|
+
task.researchAgentId = cfg.id;
|
|
5454
|
+
}
|
|
5455
|
+
else {
|
|
5456
|
+
delete task.phase;
|
|
5457
|
+
delete task.researchAgentId;
|
|
5458
|
+
}
|
|
5459
|
+
const qaId = group?.find(agent => agent.role === 'qa')?.id;
|
|
5053
5460
|
if (qaId) {
|
|
5054
5461
|
task.qaAgentId = qaId;
|
|
5055
5462
|
}
|
|
@@ -5095,7 +5502,7 @@ export class AgentManager {
|
|
|
5095
5502
|
if (!serverApprovedRetry && task.status !== 'max_rounds') {
|
|
5096
5503
|
throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
|
|
5097
5504
|
}
|
|
5098
|
-
if (task.phase
|
|
5505
|
+
if (isSpecStagePhase(task.phase)) {
|
|
5099
5506
|
throw new ApiError(409, `Mark complete is only supported for code-phase tasks`);
|
|
5100
5507
|
}
|
|
5101
5508
|
if (task.reviewMode !== 'server' && (!task.prNumber || !task.branch)) {
|
|
@@ -5515,7 +5922,6 @@ export class AgentManager {
|
|
|
5515
5922
|
signalToken: restore.signalToken,
|
|
5516
5923
|
reviewHeadAnchorSha: restore.reviewHeadAnchorSha,
|
|
5517
5924
|
reviewDispatchedAt: restore.reviewDispatchedAt,
|
|
5518
|
-
qaAgentId: undefined,
|
|
5519
5925
|
updatedAt: new Date().toISOString(),
|
|
5520
5926
|
});
|
|
5521
5927
|
return true;
|
|
@@ -5543,11 +5949,8 @@ export class AgentManager {
|
|
|
5543
5949
|
const task = await this.taskStore.get(taskId);
|
|
5544
5950
|
if (!task)
|
|
5545
5951
|
return null;
|
|
5546
|
-
const transition = await this.transitionTaskStatus(taskId, 'spec-ready', { fromStatus: ['review', 'in_progress', 'fixing'] },
|
|
5547
|
-
// QA 在停驻期解绑,残留 qaAgentId 会让后续 approve/打回误发释放;复审经 findQaPartner 回绑
|
|
5548
|
-
{
|
|
5952
|
+
const transition = await this.transitionTaskStatus(taskId, 'spec-ready', { fromStatus: ['review', 'in_progress', 'fixing'] }, {
|
|
5549
5953
|
phase: 'spec',
|
|
5550
|
-
qaAgentId: undefined,
|
|
5551
5954
|
...(opts.specReviewRound !== undefined ? { specReviewRound: opts.specReviewRound } : {}),
|
|
5552
5955
|
});
|
|
5553
5956
|
if (!transition)
|
|
@@ -5580,6 +5983,9 @@ export class AgentManager {
|
|
|
5580
5983
|
if (fresh.status !== 'spec-ready') {
|
|
5581
5984
|
throw new ApiError(409, `Task ${taskId} is ${fresh.status}; spec verdict requires spec-ready`);
|
|
5582
5985
|
}
|
|
5986
|
+
if (verdict === 'archive' && !fresh.researchAgentId) {
|
|
5987
|
+
throw new ApiError(409, `Task ${taskId} is not a Research task; only Research specs can be archived`);
|
|
5988
|
+
}
|
|
5583
5989
|
if (this.specVerdictInFlight.has(taskId)) {
|
|
5584
5990
|
throw new ApiError(409, `Task ${taskId} spec verdict is already being processed`);
|
|
5585
5991
|
}
|
|
@@ -5601,13 +6007,45 @@ export class AgentManager {
|
|
|
5601
6007
|
const round = task.specReviewRound ?? 1;
|
|
5602
6008
|
const at = new Date().toISOString();
|
|
5603
6009
|
const roundData = await store.getRound(taskId, 'spec', round);
|
|
5604
|
-
|
|
5605
|
-
|
|
6010
|
+
if (!roundData || roundData.phase !== 'spec') {
|
|
6011
|
+
throw new ApiError(409, `Task ${taskId} has no persisted spec review round ${round}`);
|
|
6012
|
+
}
|
|
6013
|
+
const base = roundData;
|
|
6014
|
+
if (verdict === 'archive') {
|
|
6015
|
+
await store.putRound(taskId, 'spec', {
|
|
6016
|
+
...base,
|
|
6017
|
+
userDecision: { verdict, ...(trimmed ? { comments: trimmed } : {}), at },
|
|
6018
|
+
});
|
|
6019
|
+
const transition = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['spec-ready'] });
|
|
6020
|
+
if (!transition)
|
|
6021
|
+
throw new ApiError(409, `Task ${taskId} changed while archiving`);
|
|
6022
|
+
this.stopPhaseSignalWatcher(taskId);
|
|
6023
|
+
const participants = new Set([
|
|
6024
|
+
task.agentId,
|
|
6025
|
+
task.devAgentId,
|
|
6026
|
+
task.qaAgentId,
|
|
6027
|
+
task.researchAgentId,
|
|
6028
|
+
].filter((id) => typeof id === 'string' && id !== ''));
|
|
6029
|
+
for (const agentId of participants) {
|
|
6030
|
+
const state = await this.agentStore.get(agentId);
|
|
6031
|
+
if (state?.taskId !== taskId)
|
|
6032
|
+
continue;
|
|
6033
|
+
const released = await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true }).catch(() => false);
|
|
6034
|
+
if (!released) {
|
|
6035
|
+
await this.emitIntervention(task.projectId, agentId, taskId, {
|
|
6036
|
+
phase: 'spec-archive-agent-release-failed',
|
|
6037
|
+
agentId,
|
|
6038
|
+
});
|
|
6039
|
+
}
|
|
6040
|
+
}
|
|
6041
|
+
return (await this.taskStore.get(taskId));
|
|
6042
|
+
}
|
|
5606
6043
|
if (verdict === 'approve') {
|
|
5607
6044
|
await store.putRound(taskId, 'spec', { ...base, userDecision: { verdict, at } });
|
|
5608
6045
|
const result = await this.transitionToCodePhase(taskId);
|
|
5609
|
-
if (!result)
|
|
5610
|
-
throw new ApiError(
|
|
6046
|
+
if (!result) {
|
|
6047
|
+
throw new ApiError(409, `Dev is unavailable for task ${taskId}; approval was recorded and can be retried`);
|
|
6048
|
+
}
|
|
5611
6049
|
return result;
|
|
5612
6050
|
}
|
|
5613
6051
|
// 打回消耗一轮修订;到达上限时拒绝而非派发注定进 max_rounds 的修订,让用户当场决策
|
|
@@ -5647,7 +6085,7 @@ export class AgentManager {
|
|
|
5647
6085
|
if (fresh.status !== 'ready') {
|
|
5648
6086
|
throw new ApiError(409, `Task ${taskId} is ${fresh.status}; code verdict requires ready`);
|
|
5649
6087
|
}
|
|
5650
|
-
if (fresh.phase
|
|
6088
|
+
if (isSpecStagePhase(fresh.phase)) {
|
|
5651
6089
|
throw new ApiError(409, `Task ${taskId} is in the spec phase; reject the spec via the spec verdict instead`);
|
|
5652
6090
|
}
|
|
5653
6091
|
if (fresh.reviewMode !== 'server') {
|
|
@@ -5740,40 +6178,82 @@ export class AgentManager {
|
|
|
5740
6178
|
return empty;
|
|
5741
6179
|
}
|
|
5742
6180
|
}
|
|
6181
|
+
isResearchHandoff(task, devAgentId) {
|
|
6182
|
+
return task.researchAgentId !== undefined && task.researchAgentId !== devAgentId;
|
|
6183
|
+
}
|
|
6184
|
+
dispatchCodePhasePrompt(task, devAgentId, signalToken, specDocuments, currentSpecRound) {
|
|
6185
|
+
const expectedKind = task.reviewMode === 'server' ? 'code-done' : 'pr-created';
|
|
6186
|
+
const dispatchOpts = {
|
|
6187
|
+
signalToken,
|
|
6188
|
+
specDocuments,
|
|
6189
|
+
...(currentSpecRound !== undefined ? { currentSpecRound } : {}),
|
|
6190
|
+
armBeforeInject: () => this.setupPhaseSignalWatcher(task.id, devAgentId, expectedKind, signalToken),
|
|
6191
|
+
};
|
|
6192
|
+
return this.isResearchHandoff(task, devAgentId)
|
|
6193
|
+
? this.startSession(task.id, devAgentId, 'code', {
|
|
6194
|
+
...dispatchOpts,
|
|
6195
|
+
preserveBindingOnFailure: true,
|
|
6196
|
+
})
|
|
6197
|
+
: this.continueSession(task.id, devAgentId, 'code', dispatchOpts);
|
|
6198
|
+
}
|
|
5743
6199
|
async transitionToCodePhase(taskId) {
|
|
5744
6200
|
const task = await this.taskStore.get(taskId);
|
|
5745
6201
|
if (!task)
|
|
5746
6202
|
return null;
|
|
5747
|
-
const devAgentId = task.
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
6203
|
+
const devAgentId = task.devAgentId;
|
|
6204
|
+
const dev = devAgentId ? this.getAgentConfig(devAgentId) : undefined;
|
|
6205
|
+
if (!devAgentId || dev?.role !== 'dev') {
|
|
6206
|
+
await this.emitIntervention(task.projectId, task.agentId, taskId, {
|
|
6207
|
+
phase: 'code-dev-missing',
|
|
6208
|
+
devAgentId,
|
|
6209
|
+
});
|
|
5753
6210
|
return null;
|
|
5754
|
-
|
|
5755
|
-
const
|
|
5756
|
-
const
|
|
5757
|
-
if (!
|
|
5758
|
-
await this.
|
|
6211
|
+
}
|
|
6212
|
+
const round = task.specReviewRound ?? 1;
|
|
6213
|
+
const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
|
|
6214
|
+
if (!stored || stored.phase !== 'spec') {
|
|
6215
|
+
await this.emitIntervention(task.projectId, task.agentId, taskId, {
|
|
6216
|
+
phase: 'code-spec-round-missing',
|
|
6217
|
+
round,
|
|
6218
|
+
});
|
|
5759
6219
|
return null;
|
|
5760
6220
|
}
|
|
5761
|
-
|
|
5762
|
-
|
|
6221
|
+
const researchAgentId = task.researchAgentId;
|
|
6222
|
+
const researchHandoff = this.isResearchHandoff(task, devAgentId);
|
|
6223
|
+
const releaseParticipant = async (agentId, failurePhase) => {
|
|
6224
|
+
const released = await this.releaseAgentIfBound(agentId, taskId, { allowAwaitingHuman: true })
|
|
5763
6225
|
.catch(() => false);
|
|
5764
6226
|
if (!released) {
|
|
5765
|
-
await this.emitIntervention(task.projectId,
|
|
6227
|
+
await this.emitIntervention(task.projectId, agentId, taskId, { phase: failurePhase, agentId });
|
|
5766
6228
|
}
|
|
6229
|
+
return released;
|
|
6230
|
+
};
|
|
6231
|
+
if (researchHandoff && researchAgentId) {
|
|
6232
|
+
if (!await releaseParticipant(researchAgentId, 'code-phase-research-release-failed'))
|
|
6233
|
+
return null;
|
|
6234
|
+
}
|
|
6235
|
+
if (task.qaAgentId && !await releaseParticipant(task.qaAgentId, 'code-phase-qa-release-failed')) {
|
|
6236
|
+
return null;
|
|
5767
6237
|
}
|
|
5768
6238
|
const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'code');
|
|
5769
6239
|
if (!acquired) {
|
|
5770
|
-
await this.
|
|
5771
|
-
|
|
6240
|
+
await this.emitIntervention(task.projectId, devAgentId, taskId, {
|
|
6241
|
+
phase: 'code-dev-acquire-failed',
|
|
6242
|
+
devAgentId,
|
|
6243
|
+
});
|
|
5772
6244
|
return null;
|
|
5773
6245
|
}
|
|
5774
|
-
|
|
6246
|
+
const newToken = createSignalToken();
|
|
6247
|
+
const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready'] }, { agentId: devAgentId, phase: 'code', signalToken: newToken });
|
|
6248
|
+
if (!transition) {
|
|
6249
|
+
await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
|
|
6250
|
+
.catch(() => undefined);
|
|
6251
|
+
return null;
|
|
6252
|
+
}
|
|
6253
|
+
this.stopPhaseSignalWatcher(taskId);
|
|
6254
|
+
let started = false;
|
|
5775
6255
|
try {
|
|
5776
|
-
|
|
6256
|
+
started = await this.dispatchCodePhasePrompt(task, devAgentId, newToken, stored.documents);
|
|
5777
6257
|
}
|
|
5778
6258
|
catch (err) {
|
|
5779
6259
|
if (err instanceof DispatchTerminalError) {
|
|
@@ -5782,12 +6262,15 @@ export class AgentManager {
|
|
|
5782
6262
|
else if (!(err instanceof EnsureSessionError && err.partial.handled)) {
|
|
5783
6263
|
await this.markAwaitingHuman(devAgentId, 'code-dispatch-failed', 'Code-phase prompt was not delivered after spec approval; the task looks in_progress but the dev never received it. Resume/restart the agent or cancel the task.', { expectedTaskId: taskId }).catch(() => undefined);
|
|
5784
6264
|
}
|
|
5785
|
-
console.error(`[AgentManager] transitionToCodePhase
|
|
6265
|
+
console.error(`[AgentManager] transitionToCodePhase dispatch(dev=${devAgentId}) failed:`, err);
|
|
5786
6266
|
throw err;
|
|
5787
6267
|
}
|
|
5788
|
-
if (!
|
|
6268
|
+
if (!started) {
|
|
5789
6269
|
await this.markAwaitingHuman(devAgentId, 'code-dispatch-failed', 'Code-phase prompt was not delivered after spec approval; the task looks in_progress but the dev never received it. Resume/restart the agent or cancel the task.', { expectedTaskId: taskId }).catch(() => undefined);
|
|
5790
|
-
await this.emitIntervention(task.projectId, devAgentId, taskId, {
|
|
6270
|
+
await this.emitIntervention(task.projectId, devAgentId, taskId, {
|
|
6271
|
+
phase: researchHandoff ? 'code-start-failed' : 'code-resume-failed',
|
|
6272
|
+
devAgentId,
|
|
6273
|
+
});
|
|
5791
6274
|
return null;
|
|
5792
6275
|
}
|
|
5793
6276
|
return await this.taskStore.get(taskId);
|
|
@@ -5804,13 +6287,7 @@ export class AgentManager {
|
|
|
5804
6287
|
if (task.reviewMode !== 'server' && opts.phase !== 'spec') {
|
|
5805
6288
|
throw new Error(`dispatchServerReviewToQa: task ${taskId} is not in server review mode`);
|
|
5806
6289
|
}
|
|
5807
|
-
|
|
5808
|
-
if (recordedQaId && !this.getAgentConfig(recordedQaId)) {
|
|
5809
|
-
console.warn(`[dispatchServerReviewToQa] task ${taskId}.qaAgentId="${recordedQaId}" no longer in config; ` +
|
|
5810
|
-
`falling back to findQaPartner(${task.agentId})`);
|
|
5811
|
-
recordedQaId = undefined;
|
|
5812
|
-
}
|
|
5813
|
-
const qaId = recordedQaId ?? this.findQaPartner(task.agentId)?.id;
|
|
6290
|
+
const qaId = task.qaAgentId;
|
|
5814
6291
|
if (!qaId) {
|
|
5815
6292
|
const entryKind = task.status === 'fixing'
|
|
5816
6293
|
? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
|
|
@@ -5819,6 +6296,17 @@ export class AgentManager {
|
|
|
5819
6296
|
await this.emitIntervention(task.projectId, task.agentId, taskId, { phase: 'server-review-no-qa-partner', devAgentId: task.agentId });
|
|
5820
6297
|
return null;
|
|
5821
6298
|
}
|
|
6299
|
+
if (this.getAgentConfig(qaId)?.role !== 'qa') {
|
|
6300
|
+
const entryKind = task.status === 'fixing'
|
|
6301
|
+
? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
|
|
6302
|
+
: (opts.phase === 'spec' ? 'spec-done' : 'code-done');
|
|
6303
|
+
await this.setupPhaseSignal(taskId, task.agentId, entryKind, { skipSnapshot: true });
|
|
6304
|
+
await this.emitIntervention(task.projectId, task.agentId, taskId, {
|
|
6305
|
+
phase: 'server-review-qa-unavailable',
|
|
6306
|
+
qaAgentId: qaId,
|
|
6307
|
+
});
|
|
6308
|
+
return null;
|
|
6309
|
+
}
|
|
5822
6310
|
const roundField = opts.phase === 'spec' ? (task.specReviewRound ?? 0) : task.reviewRound;
|
|
5823
6311
|
return {
|
|
5824
6312
|
qaId,
|
|
@@ -5892,7 +6380,6 @@ export class AgentManager {
|
|
|
5892
6380
|
: { reviewRound: newRound };
|
|
5893
6381
|
const transition = await this.transitionTaskStatus(taskId, 'review', { fromStatus: ['in_progress', 'fixing', 'review'] }, {
|
|
5894
6382
|
signalToken: newToken,
|
|
5895
|
-
qaAgentId: qaId,
|
|
5896
6383
|
reviewDispatchedAt: new Date().toISOString(),
|
|
5897
6384
|
...(opts.reviewHeadAnchorSha ? { reviewHeadAnchorSha: opts.reviewHeadAnchorSha } : {}),
|
|
5898
6385
|
...(opts.batch
|
|
@@ -5993,7 +6480,7 @@ export class AgentManager {
|
|
|
5993
6480
|
const task = await this.taskStore.get(taskId);
|
|
5994
6481
|
if (!task)
|
|
5995
6482
|
throw new Error(`dispatchServerFixToDev: task ${taskId} not found`);
|
|
5996
|
-
if (task.reviewMode !== 'server' && task.phase
|
|
6483
|
+
if (task.reviewMode !== 'server' && !isSpecStagePhase(task.phase)) {
|
|
5997
6484
|
throw new Error(`dispatchServerFixToDev: task ${taskId} is not in server review mode`);
|
|
5998
6485
|
}
|
|
5999
6486
|
if (!task.agentId)
|
|
@@ -6003,7 +6490,7 @@ export class AgentManager {
|
|
|
6003
6490
|
qaAgentId: task.qaAgentId,
|
|
6004
6491
|
projectId: task.projectId,
|
|
6005
6492
|
newToken: createSignalToken(),
|
|
6006
|
-
taskPhase:
|
|
6493
|
+
taskPhase: task.phase,
|
|
6007
6494
|
currentSpecRound: task.specReviewRound,
|
|
6008
6495
|
originalStatus: task.status,
|
|
6009
6496
|
originalToken: task.signalToken,
|
|
@@ -6029,7 +6516,7 @@ export class AgentManager {
|
|
|
6029
6516
|
return null;
|
|
6030
6517
|
}
|
|
6031
6518
|
if (qaAgentId) {
|
|
6032
|
-
const released = await this.
|
|
6519
|
+
const released = await this.releaseAgentIfBound(qaAgentId, taskId)
|
|
6033
6520
|
.catch(() => false);
|
|
6034
6521
|
if (!released) {
|
|
6035
6522
|
await rearmReviewedSignal();
|