baxian 1.2.55 → 1.2.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/agent/manager.d.ts +26 -27
  2. package/dist/agent/manager.d.ts.map +1 -1
  3. package/dist/agent/manager.js +613 -271
  4. package/dist/agent/manager.js.map +1 -1
  5. package/dist/agent/prompt.d.ts.map +1 -1
  6. package/dist/agent/prompt.js +2 -1
  7. package/dist/agent/prompt.js.map +1 -1
  8. package/dist/agent/repo-store.d.ts.map +1 -1
  9. package/dist/agent/repo-store.js +1 -0
  10. package/dist/agent/repo-store.js.map +1 -1
  11. package/dist/agent/review-transport.d.ts +5 -1
  12. package/dist/agent/review-transport.d.ts.map +1 -1
  13. package/dist/agent/review-transport.js +133 -8
  14. package/dist/agent/review-transport.js.map +1 -1
  15. package/dist/api/config.d.ts.map +1 -1
  16. package/dist/api/config.js +45 -8
  17. package/dist/api/config.js.map +1 -1
  18. package/dist/api/projects.d.ts.map +1 -1
  19. package/dist/api/projects.js +29 -15
  20. package/dist/api/projects.js.map +1 -1
  21. package/dist/api/tasks.js +2 -2
  22. package/dist/api/tasks.js.map +1 -1
  23. package/dist/config/validator.js +23 -11
  24. package/dist/config/validator.js.map +1 -1
  25. package/dist/event/handlers.d.ts.map +1 -1
  26. package/dist/event/handlers.js +36 -26
  27. package/dist/event/handlers.js.map +1 -1
  28. package/dist/event/server-handlers.d.ts.map +1 -1
  29. package/dist/event/server-handlers.js +46 -26
  30. package/dist/event/server-handlers.js.map +1 -1
  31. package/dist/shared/constants.d.ts +3 -1
  32. package/dist/shared/constants.d.ts.map +1 -1
  33. package/dist/shared/constants.js +8 -0
  34. package/dist/shared/constants.js.map +1 -1
  35. package/dist/shared/types.d.ts +21 -5
  36. package/dist/shared/types.d.ts.map +1 -1
  37. package/dist/shared/types.js +8 -0
  38. package/dist/shared/types.js.map +1 -1
  39. package/dist/skills/baxian-research/SKILL.md +28 -0
  40. package/dist/skills/baxian-server-feedback/SKILL.md +3 -3
  41. package/dist/state/review-store.d.ts +4 -4
  42. package/dist/state/review-store.d.ts.map +1 -1
  43. package/dist/state/review-store.js +57 -6
  44. package/dist/state/review-store.js.map +1 -1
  45. package/dist/state/task-store.d.ts.map +1 -1
  46. package/dist/state/task-store.js +115 -20
  47. package/dist/state/task-store.js.map +1 -1
  48. package/dist/web/assets/index-3Ypk8Gfo.js +13 -0
  49. package/dist/web/index.html +1 -1
  50. package/package.json +1 -1
  51. package/dist/web/assets/index-BmnSiyut.js +0 -13
@@ -1,13 +1,13 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
2
+ import { join, normalize } from 'node:path';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { createSignalToken } from './phase-signal.js';
5
5
  import { tmuxInstallHint } from './preflight.js';
6
- import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, isGitHubRepo, parseGitRemote, repoSlug, } from '../shared/index.js';
6
+ import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, TASK_OWNER_ROLES, isSpecStagePhase, isGitHubRepo, parseGitRemote, repoSlug, } from '../shared/index.js';
7
7
  import { AGENT_STORE_NOOP } from '../state/agent-store.js';
8
8
  import { PostApproveStore } from '../state/post-approve-store.js';
9
9
  import { SkillRegistry } from '../skill/registry.js';
10
- import { createRunner, LocalRunner, shellQuote, resolveAgentHost, hostGroupKey } from './runner.js';
10
+ import { createRunner, LocalRunner, shellQuote, resolveAgentHost, hostGroupKey, workdirHostGroupKey, } from './runner.js';
11
11
  import { GH_EXEC_TIMEOUT_MS, GIT_NET_ENV, execNetwork } from './net-exec.js';
12
12
  import { findForeignTaskTip } from './lineage.js';
13
13
  import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
@@ -137,7 +137,7 @@ const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
137
137
  const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
138
138
  const GH_NET = { timeout: GH_EXEC_TIMEOUT_MS, retries: 1 };
139
139
  const MANUAL_SERVER_REVIEW_STATUSES = ['in_progress', 'review', 'fixing'];
140
- const IMAGE_DISPATCH_PHASES = new Set(['develop', 'code', 'fix', 'server-feedback']);
140
+ const IMAGE_DISPATCH_PHASES = new Set(['develop', 'research', 'code', 'fix', 'server-feedback']);
141
141
  const RUNTIME_LIVENESS_SAMPLES = 3;
142
142
  export function canDispatchWithBinding(binding) {
143
143
  return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
@@ -269,6 +269,17 @@ export class AgentManager {
269
269
  throw new Error(`Agent ${agentId} ownership changed for task ${taskId}; operation aborted`);
270
270
  }
271
271
  }
272
+ async assertTaskGeneration(agentId, taskId, lockToken, workdir) {
273
+ const state = await this.agentStore.get(agentId);
274
+ if (!state
275
+ || state.taskId !== taskId
276
+ || state.lockToken !== lockToken
277
+ || state.workdir !== workdir) {
278
+ throw new Error(`Agent ${agentId} task generation changed for ${taskId}; operation aborted`);
279
+ }
280
+ await this.assertTaskLockOwner(agentId, taskId, lockToken);
281
+ return state;
282
+ }
272
283
  getReviewStore() {
273
284
  return this.reviewStore;
274
285
  }
@@ -300,6 +311,14 @@ export class AgentManager {
300
311
  });
301
312
  return this.reviewTransportInstance;
302
313
  }
314
+ async prepareDispatchArtifacts(agent, workdir, phase, opts) {
315
+ const transport = this.getReviewTransport();
316
+ if (!opts.preserveOutputs)
317
+ await transport.clearDispatchOutputs(agent, workdir, phase);
318
+ if (opts.specDocuments) {
319
+ await transport.replaceSpecDocuments(agent, workdir, opts.specDocuments, opts.assertOwner);
320
+ }
321
+ }
303
322
  bindingWorkdirCache = new Map();
304
323
  async refreshWorkdirCacheFor(agentId) {
305
324
  const state = await this.agentStore.get(agentId);
@@ -342,9 +361,28 @@ export class AgentManager {
342
361
  }
343
362
  replaceConfig(validated) {
344
363
  const config = prepareConfig(validated);
364
+ const previousConfig = this.config;
365
+ const previousIndex = this.agentIndex;
366
+ const nextIndex = buildAgentIndex(config);
367
+ for (const [ownerKey, agentId] of this.repoCache.owners) {
368
+ const previous = previousIndex.get(agentId);
369
+ const next = nextIndex.get(agentId);
370
+ const previousHost = previous
371
+ ? resolveAgentHost(previousConfig.host, previous.host)
372
+ : undefined;
373
+ const nextHost = next ? resolveAgentHost(config.host, next.host) : undefined;
374
+ const previousOwnerConfig = previous
375
+ ? `${workdirHostGroupKey(previous.mode, previousHost)}\0${previous.workdir ? normalize(previous.workdir) : ''}`
376
+ : null;
377
+ const nextOwnerConfig = next
378
+ ? `${workdirHostGroupKey(next.mode, nextHost)}\0${next.workdir ? normalize(next.workdir) : ''}`
379
+ : null;
380
+ if (previousOwnerConfig === null || previousOwnerConfig !== nextOwnerConfig) {
381
+ this.repoCache.owners.delete(ownerKey);
382
+ }
383
+ }
345
384
  this.config = config;
346
- this.agentIndex = buildAgentIndex(config);
347
- this.repoCache.owners.clear();
385
+ this.agentIndex = nextIndex;
348
386
  }
349
387
  getConfig() {
350
388
  return this.config;
@@ -1282,6 +1320,7 @@ export class AgentManager {
1282
1320
  projectId: cfg.projectId,
1283
1321
  taskId,
1284
1322
  lockToken: token,
1323
+ ...(phase === 'code' && !sameTaskReentry ? { bootstrappingTaskId: taskId } : {}),
1285
1324
  updatedAt: now,
1286
1325
  }));
1287
1326
  return true;
@@ -1345,10 +1384,19 @@ export class AgentManager {
1345
1384
  });
1346
1385
  return true;
1347
1386
  }
1387
+ let sessionConfirmedAbsent = false;
1348
1388
  if (state.workdir) {
1389
+ const releaseWorkdir = state.workdir;
1349
1390
  const runner = this.createRunnerFor(cfg);
1350
1391
  const tmux = new TmuxManager(runner);
1351
1392
  const hold = async (reason) => {
1393
+ const latest = await this.agentStore.get(agentId);
1394
+ if (!latest
1395
+ || latest.taskId !== expectedTaskId
1396
+ || latest.lockToken !== lockToken
1397
+ || !await this.lockManager.isOwner(agentId, expectedTaskId, lockToken)) {
1398
+ return false;
1399
+ }
1352
1400
  const pendingAt = new Date().toISOString();
1353
1401
  if (boundTask && cfg.role === 'dev') {
1354
1402
  boundTask.branchCleanupPending = { agentId, reason, updatedAt: pendingAt };
@@ -1375,32 +1423,40 @@ export class AgentManager {
1375
1423
  return false;
1376
1424
  };
1377
1425
  try {
1378
- await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
1379
- const paneId = await this.resolvePaneId(state, cfg);
1380
- if (paneId) {
1381
- await this.waitForReplPromptReady(tmux, paneId, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
1426
+ await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1427
+ const runtime = await this.inspectReleaseRuntime(tmux, agentId);
1428
+ if (runtime.kind === 'hold')
1429
+ return hold(runtime.reason);
1430
+ await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1431
+ if (runtime.kind === 'pane') {
1432
+ if (state.paneId !== runtime.paneId) {
1433
+ await this.agentStore.update(agentId, (latest) => {
1434
+ if (!latest
1435
+ || latest.taskId !== expectedTaskId
1436
+ || latest.lockToken !== lockToken
1437
+ || latest.workdir !== releaseWorkdir) {
1438
+ return AGENT_STORE_NOOP;
1439
+ }
1440
+ return { ...latest, paneId: runtime.paneId, updatedAt: new Date().toISOString() };
1441
+ });
1442
+ const refreshed = await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1443
+ if (refreshed.paneId !== runtime.paneId) {
1444
+ throw new Error(`Agent ${agentId} runtime pane changed during release; operation aborted`);
1445
+ }
1446
+ }
1447
+ await this.waitForReplPromptReady(tmux, runtime.paneId, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
1382
1448
  }
1383
1449
  else {
1384
- let sessionAlive;
1385
- try {
1386
- sessionAlive = await tmux.hasSession(agentId);
1387
- }
1388
- catch (err) {
1389
- return hold(`Runtime availability probe failed; refusing checkout cleanup: ` +
1390
- `${err instanceof Error ? err.message : String(err)}`);
1391
- }
1392
- if (sessionAlive) {
1393
- return hold('Runtime session still exists but its pane is unavailable; refusing checkout cleanup');
1394
- }
1450
+ sessionConfirmedAbsent = true;
1395
1451
  }
1396
- await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
1452
+ await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1397
1453
  if (cfg.role === 'dev' && boundTask) {
1398
1454
  const branches = new BranchManager(runner);
1399
- const cleanup = await branches.cleanupTaskBranch(state.workdir, {
1455
+ const cleanup = await branches.cleanupTaskBranch(releaseWorkdir, {
1400
1456
  taskId: boundTask.id,
1401
1457
  taskBranch: boundTask.branch,
1402
1458
  branchCreatedByBaxian: boundTask.branchCreatedByBaxian,
1403
- }, () => this.assertTaskLockOwner(agentId, expectedTaskId, lockToken));
1459
+ }, () => this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir).then(() => undefined));
1404
1460
  let cleanupStateChanged = false;
1405
1461
  if (cleanup.status === 'pending') {
1406
1462
  if (boundTask.branchCleanupPending?.agentId !== agentId
@@ -1443,11 +1499,12 @@ export class AgentManager {
1443
1499
  await this.taskStore.set(boundTask);
1444
1500
  }
1445
1501
  if (cleanup.status !== 'deleted')
1446
- await branches.parkOnDefaultDetached(state.workdir);
1502
+ await branches.parkOnDefaultDetached(releaseWorkdir);
1447
1503
  }
1448
1504
  else {
1449
- await new BranchManager(runner).parkOnDefaultDetached(state.workdir);
1505
+ await new BranchManager(runner).parkOnDefaultDetached(releaseWorkdir);
1450
1506
  }
1507
+ await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1451
1508
  }
1452
1509
  catch (err) {
1453
1510
  const reason = err instanceof DirtyWorkdirError
@@ -1456,17 +1513,25 @@ export class AgentManager {
1456
1513
  return hold(reason);
1457
1514
  }
1458
1515
  }
1459
- await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
1516
+ if (state.workdir) {
1517
+ await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, state.workdir);
1518
+ }
1519
+ else {
1520
+ await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
1521
+ }
1460
1522
  await this.agentStore.update(agentId, (existing) => {
1461
1523
  if (!existing)
1462
1524
  return AGENT_STORE_NOOP;
1463
- if (existing.taskId !== expectedTaskId)
1525
+ if (existing.taskId !== expectedTaskId
1526
+ || existing.lockToken !== lockToken
1527
+ || (state.workdir !== undefined && existing.workdir !== state.workdir)) {
1464
1528
  return AGENT_STORE_NOOP;
1529
+ }
1465
1530
  return {
1466
1531
  id: existing.id,
1467
1532
  projectId: existing.projectId,
1468
1533
  ...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
1469
- ...(existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
1534
+ ...(!sessionConfirmedAbsent && existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
1470
1535
  ...(existing.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
1471
1536
  updatedAt: now,
1472
1537
  };
@@ -1474,6 +1539,17 @@ export class AgentManager {
1474
1539
  return this.lockManager.releaseIfOwner(agentId, expectedTaskId, lockToken);
1475
1540
  });
1476
1541
  }
1542
+ async releaseAgentIfBound(agentId, expectedTaskId, opts) {
1543
+ const state = await this.agentStore.get(agentId);
1544
+ if (state?.taskId !== expectedTaskId)
1545
+ return true;
1546
+ const released = opts
1547
+ ? await this.releaseAgentForTask(agentId, expectedTaskId, 'idle', opts)
1548
+ : await this.releaseAgentForTask(agentId, expectedTaskId, 'idle');
1549
+ if (released)
1550
+ return true;
1551
+ return (await this.agentStore.get(agentId))?.taskId !== expectedTaskId;
1552
+ }
1477
1553
  async reconcileTaskBranches() {
1478
1554
  const tasks = await this.taskStore.list();
1479
1555
  const terminalByBranch = new Map(tasks
@@ -1882,14 +1958,27 @@ export class AgentManager {
1882
1958
  }
1883
1959
  if (result.redispatchCodeTaskId) {
1884
1960
  try {
1885
- const resumed = await this.continueSession(result.redispatchCodeTaskId, agentId, 'code');
1961
+ const task = await this.taskStore.get(result.redispatchCodeTaskId);
1962
+ const round = task?.specReviewRound;
1963
+ const stored = round === undefined
1964
+ ? null
1965
+ : await this.reviewStore?.getRound(result.redispatchCodeTaskId, 'spec', round);
1966
+ if (!task || task.phase !== 'code' || !task.signalToken || !stored || stored.phase !== 'spec') {
1967
+ await this.markAwaitingHuman(agentId, 'code-dispatch-failed', 'Code-phase redispatch cannot continue because the persisted Spec handoff is missing or invalid. Restore the review record or cancel the task.', { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
1968
+ return { resumed: false, releasedBinding: false, reason: 'Persisted Spec handoff is missing or invalid.' };
1969
+ }
1970
+ const resumed = await this.dispatchCodePhasePrompt(task, agentId, task.signalToken, stored.documents, round);
1886
1971
  if (!resumed) {
1887
- await this.markAwaitingHuman(agentId, 'code-dispatch-failed', 'Code-phase redispatch on Resume was not delivered; Resume again to retry or cancel the task.', { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
1972
+ const reason = 'Code-phase redispatch on Resume was not delivered; Resume again to retry or cancel the task.';
1973
+ await this.markAwaitingHuman(agentId, 'code-dispatch-failed', reason, { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
1974
+ return { resumed: false, releasedBinding: false, reason };
1888
1975
  }
1889
1976
  }
1890
1977
  catch (err) {
1891
1978
  console.error(`[AgentManager] resumeAgent code redispatch failed for ${agentId}:`, err);
1892
- await this.markAwaitingHuman(agentId, 'code-dispatch-failed', 'Code-phase redispatch on Resume failed; Resume again to retry or cancel the task.', { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
1979
+ const reason = `Code-phase redispatch on Resume failed: ${err instanceof Error ? err.message : String(err)}`;
1980
+ await this.markAwaitingHuman(agentId, 'code-dispatch-failed', reason, { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
1981
+ return { resumed: false, releasedBinding: false, reason };
1893
1982
  }
1894
1983
  }
1895
1984
  return {
@@ -1909,6 +1998,48 @@ export class AgentManager {
1909
1998
  return null;
1910
1999
  }
1911
2000
  }
2001
+ async inspectReleaseRuntime(tmux, agentId) {
2002
+ let sessionAlive;
2003
+ try {
2004
+ sessionAlive = await tmux.hasSession(agentId);
2005
+ }
2006
+ catch (err) {
2007
+ return {
2008
+ kind: 'hold',
2009
+ reason: `Runtime availability probe failed; refusing checkout cleanup: ` +
2010
+ `${err instanceof Error ? err.message : String(err)}`,
2011
+ };
2012
+ }
2013
+ if (!sessionAlive)
2014
+ return { kind: 'absent' };
2015
+ let claim;
2016
+ try {
2017
+ claim = await tmux.getOption(agentId, '@baxian-agent-id');
2018
+ }
2019
+ catch (err) {
2020
+ return {
2021
+ kind: 'hold',
2022
+ reason: `Runtime session claim probe failed; refusing checkout cleanup: ` +
2023
+ `${err instanceof Error ? err.message : String(err)}`,
2024
+ };
2025
+ }
2026
+ if (claim !== agentId) {
2027
+ return {
2028
+ kind: 'hold',
2029
+ reason: `Runtime session claim mismatch (got "${claim ?? 'null'}"); refusing checkout cleanup`,
2030
+ };
2031
+ }
2032
+ try {
2033
+ return { kind: 'pane', paneId: await tmux.getSinglePaneId(agentId) };
2034
+ }
2035
+ catch (err) {
2036
+ return {
2037
+ kind: 'hold',
2038
+ reason: `Runtime pane probe failed; refusing checkout cleanup: ` +
2039
+ `${err instanceof Error ? err.message : String(err)}`,
2040
+ };
2041
+ }
2042
+ }
1912
2043
  async interruptPaneAndWaitReady(state, cfg) {
1913
2044
  const paneId = await this.resolvePaneId(state, cfg);
1914
2045
  if (!paneId)
@@ -2207,6 +2338,42 @@ export class AgentManager {
2207
2338
  };
2208
2339
  });
2209
2340
  }
2341
+ async redispatchResearchAfterReplRestart(agentId, taskId) {
2342
+ const agent = this.getAgentConfig(agentId);
2343
+ const task = await this.taskStore.get(taskId);
2344
+ if (!agent || agent.role !== 'research' || !task)
2345
+ return false;
2346
+ if (task.researchAgentId !== agentId || task.agentId !== agentId || !task.signalToken)
2347
+ return false;
2348
+ let phase;
2349
+ let expectedKind;
2350
+ let findings;
2351
+ if (task.phase === 'research' && task.status === 'in_progress') {
2352
+ phase = 'research';
2353
+ expectedKind = 'spec-done';
2354
+ }
2355
+ else if (task.phase === 'spec' && task.status === 'fixing') {
2356
+ const round = task.specReviewRound ?? 1;
2357
+ const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
2358
+ if (!stored?.findings)
2359
+ return false;
2360
+ phase = 'server-feedback';
2361
+ expectedKind = 'spec-fixed';
2362
+ findings = JSON.stringify(stored.findings);
2363
+ }
2364
+ else {
2365
+ return false;
2366
+ }
2367
+ await this.clearAwaitingHuman(agentId);
2368
+ return this.continueSession(taskId, agentId, phase, {
2369
+ bypassTaskStatusGate: true,
2370
+ signalToken: task.signalToken,
2371
+ preserveDispatchOutputs: true,
2372
+ ...(findings ? { serverPriorFindings: findings } : {}),
2373
+ ...(task.specReviewRound !== undefined ? { currentSpecRound: task.specReviewRound } : {}),
2374
+ armBeforeInject: () => this.setupPhaseSignalWatcher(taskId, agentId, expectedKind, task.signalToken),
2375
+ });
2376
+ }
2210
2377
  prepareRemoveTargets(agentId) {
2211
2378
  const cfg = this.getAgentConfig(agentId);
2212
2379
  if (!cfg)
@@ -2214,15 +2381,10 @@ export class AgentManager {
2214
2381
  const project = this.getProjectConfig(cfg.projectId);
2215
2382
  if (!project)
2216
2383
  throw new Error(`Unknown project: ${cfg.projectId}`);
2217
- if (cfg.role === 'qa')
2384
+ if (cfg.role !== 'dev')
2218
2385
  return { targets: [agentId] };
2219
- for (const pair of project.agent) {
2220
- if (pair[0]?.id === agentId) {
2221
- const qa = pair[1];
2222
- return { targets: qa ? [agentId, qa.id] : [agentId] };
2223
- }
2224
- }
2225
- return { targets: [agentId] };
2386
+ const group = this.findAgentGroup(agentId);
2387
+ return { targets: group?.map(agent => agent.id) ?? [agentId] };
2226
2388
  }
2227
2389
  async cleanupRemovedAgentRuntime(targets) {
2228
2390
  const failures = [];
@@ -2305,6 +2467,12 @@ export class AgentManager {
2305
2467
  }
2306
2468
  const workdirGuess = workdirForEstimate ?? '/'.padEnd(64, 'x');
2307
2469
  const now = new Date().toISOString();
2470
+ const isResearch = cfg.role === 'research';
2471
+ const group = this.findAgentGroup(cfg.id);
2472
+ const devAgentId = isResearch ? group?.find(agent => agent.role === 'dev')?.id : cfg.id;
2473
+ if (!devAgentId)
2474
+ throw new Error(`Agent ${cfg.id} has no dev agent in its group`);
2475
+ const qaAgentId = group?.find(agent => agent.role === 'qa')?.id;
2308
2476
  const fakeTask = {
2309
2477
  id: 'task-9999999999',
2310
2478
  projectId,
@@ -2312,6 +2480,9 @@ export class AgentManager {
2312
2480
  description: input.description,
2313
2481
  preferredAgentId: input.preferredAgentId,
2314
2482
  agentId: cfg.id,
2483
+ devAgentId,
2484
+ ...(qaAgentId ? { qaAgentId } : {}),
2485
+ ...(isResearch ? { researchAgentId: cfg.id, phase: 'research' } : {}),
2315
2486
  branch: `${BRANCH_PREFIX}task-9999999999`,
2316
2487
  reviewRound: 0,
2317
2488
  status: 'in_progress',
@@ -2320,11 +2491,12 @@ export class AgentManager {
2320
2491
  };
2321
2492
  const fullPrompt = buildPromptInline({
2322
2493
  task: fakeTask,
2323
- phase: 'develop',
2494
+ phase: isResearch ? 'research' : 'develop',
2324
2495
  agent: cfg,
2325
2496
  workdir: workdirGuess,
2326
2497
  skillRegistry: this.skillRegistry,
2327
2498
  signalToken: 'preview-signal-token',
2499
+ hasQaPartner: qaAgentId !== undefined,
2328
2500
  });
2329
2501
  return Buffer.byteLength(fullPrompt, 'utf8');
2330
2502
  }
@@ -2337,12 +2509,11 @@ export class AgentManager {
2337
2509
  getProjectByRepo(repo) {
2338
2510
  return this.config.project.find(p => repoSlug(p.repo) === repo);
2339
2511
  }
2340
- findQaPartner(devAgentId) {
2512
+ findAgentGroup(anchorAgentId) {
2341
2513
  for (const project of this.config.project) {
2342
- for (const pair of project.agent) {
2343
- if (pair[0]?.id === devAgentId) {
2344
- return pair[1];
2345
- }
2514
+ for (const group of project.agent) {
2515
+ if (group.some(agent => agent.id === anchorAgentId))
2516
+ return group;
2346
2517
  }
2347
2518
  }
2348
2519
  return undefined;
@@ -2441,17 +2612,18 @@ export class AgentManager {
2441
2612
  if (!mapped)
2442
2613
  continue;
2443
2614
  const { expectedKinds, agentId } = mapped;
2444
- const interventionKindLabel = task.phase === 'spec' && task.status === 'review' ? 'spec-reviewed'
2445
- : task.phase === 'spec' && task.status === 'fixing' ? 'spec-fixed'
2446
- : task.phase !== 'spec' && task.status === 'review' ? 'pr-approved|pr-changes-requested'
2447
- : task.phase !== 'spec' && task.status === 'fixing' ? 'pr-fixed'
2615
+ const specStage = isSpecStagePhase(task.phase);
2616
+ const interventionKindLabel = specStage && task.status === 'review' ? 'spec-reviewed'
2617
+ : specStage && task.status === 'fixing' ? 'spec-fixed'
2618
+ : !specStage && task.status === 'review' ? 'pr-approved|pr-changes-requested'
2619
+ : !specStage && task.status === 'fixing' ? 'pr-fixed'
2448
2620
  : undefined;
2449
- const isServerProtocol = task.reviewMode === 'server' || task.phase === 'spec';
2621
+ const isServerProtocol = task.reviewMode === 'server' || specStage;
2450
2622
  const scanSnapshotOnRecover = isServerProtocol
2451
2623
  || (task.phase === undefined && task.status === 'in_progress')
2452
- || (task.phase !== 'spec' && (task.status === 'review' || task.status === 'fixing'));
2624
+ || (!specStage && (task.status === 'review' || task.status === 'fixing'));
2453
2625
  const allowRecoveredReadFile = task.status === 'review'
2454
- && (task.phase === 'spec'
2626
+ && (specStage
2455
2627
  || (task.reviewMode === 'server' && task.batchTotal !== undefined)
2456
2628
  || (task.reviewMode === 'server' && task.reviewCheckoutMode === 'base'));
2457
2629
  try {
@@ -2730,8 +2902,8 @@ export class AgentManager {
2730
2902
  if (cfg.projectId !== projectId) {
2731
2903
  throw new ApiError(400, `Agent ${preferredAgentId} not in project ${projectId}`);
2732
2904
  }
2733
- if (cfg.role !== 'dev') {
2734
- throw new ApiError(400, `Agent ${preferredAgentId} is not dev role`);
2905
+ if (!TASK_OWNER_ROLES.has(cfg.role)) {
2906
+ throw new ApiError(400, `Agent ${preferredAgentId} is not dev or research role`);
2735
2907
  }
2736
2908
  const state = await this.agentStore.get(preferredAgentId);
2737
2909
  if (!canDispatchWithBinding(state))
@@ -2739,6 +2911,22 @@ export class AgentManager {
2739
2911
  const { projectId: _projectId, ...rest } = cfg;
2740
2912
  return rest;
2741
2913
  }
2914
+ async persistQueuedTask(task, queueReason, agentId) {
2915
+ await this.taskStore.set(task);
2916
+ await this.safeEmit({
2917
+ id: '',
2918
+ type: 'task.created',
2919
+ timestamp: task.createdAt,
2920
+ projectId: task.projectId,
2921
+ taskId: task.id,
2922
+ data: {
2923
+ queued: true,
2924
+ queueReason,
2925
+ ...(agentId ? { agentId } : {}),
2926
+ },
2927
+ });
2928
+ return task;
2929
+ }
2742
2930
  async createTask(projectId, input) {
2743
2931
  return this.withTaskLock(async () => {
2744
2932
  const taskId = await this.taskStore.nextId();
@@ -2762,113 +2950,13 @@ export class AgentManager {
2762
2950
  const imageFilenames = input.images?.length
2763
2951
  ? await this.persistTaskImages(taskId, input.images)
2764
2952
  : undefined;
2765
- if (input.preferredAgentId === '') {
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 = {
2953
+ const taskBase = {
2863
2954
  id: taskId,
2864
2955
  projectId,
2865
2956
  title: input.title,
2866
2957
  description: input.description,
2867
2958
  preferredAgentId: input.preferredAgentId,
2868
- agentId: dev.id,
2869
- ...(qa ? { qaAgentId: qa.id } : {}),
2870
2959
  reviewRound: 0,
2871
- status: 'in_progress',
2872
2960
  branch: taskBranch,
2873
2961
  branchCreatedByBaxian,
2874
2962
  reviewMode: this.effectiveReviewMode(projectId),
@@ -2876,9 +2964,52 @@ export class AgentManager {
2876
2964
  updatedAt: now,
2877
2965
  ...(imageFilenames ? { images: imageFilenames } : {}),
2878
2966
  };
2967
+ if (input.preferredAgentId === '') {
2968
+ const unassigned = {
2969
+ ...taskBase,
2970
+ agentId: '',
2971
+ devAgentId: '',
2972
+ status: 'pending',
2973
+ };
2974
+ return this.persistQueuedTask(unassigned, 'unassigned');
2975
+ }
2976
+ const target = await this.pickAgent(projectId, input.preferredAgentId);
2977
+ const targetConfig = this.getAgentConfig(input.preferredAgentId);
2978
+ const group = this.findAgentGroup(input.preferredAgentId);
2979
+ const dev = group?.find(agent => agent.role === 'dev');
2980
+ if (!dev)
2981
+ throw new ApiError(409, `Agent ${input.preferredAgentId} has no dev agent in its group`);
2982
+ const qa = group?.find(agent => agent.role === 'qa');
2983
+ const research = targetConfig.role === 'research' ? targetConfig : undefined;
2984
+ const researchFields = research
2985
+ ? { researchAgentId: research.id, phase: 'research' }
2986
+ : {};
2987
+ const queued = {
2988
+ ...taskBase,
2989
+ agentId: '',
2990
+ devAgentId: dev.id,
2991
+ ...researchFields,
2992
+ status: 'pending',
2993
+ ...(qa ? { qaAgentId: qa.id } : {}),
2994
+ };
2995
+ if (!target) {
2996
+ return this.persistQueuedTask(queued, 'preferred_agent_busy', input.preferredAgentId);
2997
+ }
2998
+ const lockToken = await this.lockManager.acquire(target.id, taskId);
2999
+ if (!lockToken) {
3000
+ return this.persistQueuedTask(queued, 'agent_locked', input.preferredAgentId);
3001
+ }
3002
+ const task = {
3003
+ ...taskBase,
3004
+ agentId: target.id,
3005
+ devAgentId: dev.id,
3006
+ ...(qa ? { qaAgentId: qa.id } : {}),
3007
+ ...researchFields,
3008
+ status: 'in_progress',
3009
+ };
2879
3010
  await this.taskStore.set(task);
2880
- await this.agentStore.update(dev.id, (existing) => ({
2881
- id: dev.id,
3011
+ await this.agentStore.update(target.id, (existing) => ({
3012
+ id: target.id,
2882
3013
  projectId,
2883
3014
  taskId,
2884
3015
  lockToken,
@@ -2893,9 +3024,9 @@ export class AgentManager {
2893
3024
  type: 'task.assigned',
2894
3025
  timestamp: now,
2895
3026
  projectId,
2896
- agentId: dev.id,
3027
+ agentId: target.id,
2897
3028
  taskId,
2898
- data: { agentId: dev.id },
3029
+ data: { agentId: target.id },
2899
3030
  });
2900
3031
  return task;
2901
3032
  });
@@ -2921,10 +3052,15 @@ export class AgentManager {
2921
3052
  return task;
2922
3053
  }
2923
3054
  async startCreatedTaskSession(taskId, agentId, signalToken, dispatchLockToken) {
3055
+ const initialTask = await this.taskStore.get(taskId);
3056
+ if (!initialTask)
3057
+ return null;
3058
+ const initialDispatch = this.resolveInitialDispatch(initialTask);
3059
+ const dispatchPhase = initialDispatch.phase;
2924
3060
  let started = false;
2925
3061
  let dispatchErr = null;
2926
3062
  try {
2927
- started = await this.startSession(taskId, agentId, 'develop');
3063
+ started = await this.startSession(taskId, agentId, dispatchPhase);
2928
3064
  }
2929
3065
  catch (err) {
2930
3066
  dispatchErr = err;
@@ -2954,7 +3090,7 @@ export class AgentManager {
2954
3090
  await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true });
2955
3091
  return null;
2956
3092
  }
2957
- const initialKinds = this.devInitialSignalKinds(fresh.reviewMode);
3093
+ const initialKinds = this.resolveInitialDispatch(fresh).kinds;
2958
3094
  try {
2959
3095
  await this.armPostDispatchSignalOrHold(taskId, agentId, initialKinds, signalToken);
2960
3096
  }
@@ -2968,7 +3104,7 @@ export class AgentManager {
2968
3104
  return null;
2969
3105
  }
2970
3106
  if (dispatchErr instanceof DispatchTerminalError) {
2971
- await this.failTaskForDispatchError(taskId, 'develop', agentId, dispatchErr);
3107
+ await this.failTaskForDispatchError(taskId, dispatchPhase, agentId, dispatchErr);
2972
3108
  }
2973
3109
  else if (dispatchErr instanceof EnsureSessionError && dispatchErr.partial.handled) {
2974
3110
  }
@@ -3187,8 +3323,8 @@ export class AgentManager {
3187
3323
  if (cfg.projectId !== fresh.projectId) {
3188
3324
  return { task: fresh, errorCode: 400, error: `Agent ${agentId} not in project ${fresh.projectId}` };
3189
3325
  }
3190
- if (cfg.role !== 'dev') {
3191
- return { task: fresh, errorCode: 400, error: `Agent ${agentId} is not dev role` };
3326
+ if (!TASK_OWNER_ROLES.has(cfg.role)) {
3327
+ return { task: fresh, errorCode: 400, error: `Agent ${agentId} is not dev or research role` };
3192
3328
  }
3193
3329
  if (fresh.preferredAgentId !== '' && fresh.preferredAgentId !== agentId) {
3194
3330
  return {
@@ -3206,14 +3342,29 @@ export class AgentManager {
3206
3342
  return { task: fresh, errorCode: 409, error: `Agent ${agentId} lock acquisition failed` };
3207
3343
  }
3208
3344
  const now = new Date().toISOString();
3209
- const qaId = fresh.qaAgentId ?? this.findQaPartner(agentId)?.id;
3345
+ const initiallyUnassigned = fresh.preferredAgentId === '';
3346
+ const group = initiallyUnassigned ? this.findAgentGroup(agentId) : undefined;
3347
+ const devId = initiallyUnassigned
3348
+ ? group?.find(agent => agent.role === 'dev')?.id
3349
+ : fresh.devAgentId;
3350
+ if (!devId) {
3351
+ await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
3352
+ return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no dev agent in its group` };
3353
+ }
3354
+ const qaId = initiallyUnassigned
3355
+ ? group?.find(agent => agent.role === 'qa')?.id
3356
+ : fresh.qaAgentId;
3357
+ const researchId = initiallyUnassigned && cfg.role === 'research' ? cfg.id : fresh.researchAgentId;
3210
3358
  const claimedTask = {
3211
3359
  ...fresh,
3212
3360
  preferredAgentId: agentId,
3213
3361
  agentId,
3362
+ devAgentId: devId,
3363
+ researchAgentId: researchId,
3364
+ qaAgentId: qaId,
3365
+ phase: researchId ? 'research' : undefined,
3214
3366
  status: 'in_progress',
3215
3367
  updatedAt: now,
3216
- ...(qaId ? { qaAgentId: qaId } : {}),
3217
3368
  };
3218
3369
  await this.taskStore.set(claimedTask);
3219
3370
  await this.agentStore.update(agentId, (existing) => ({
@@ -3247,20 +3398,21 @@ export class AgentManager {
3247
3398
  await this.updateTask(claimed.id, { signalToken });
3248
3399
  let started = false;
3249
3400
  let dispatchErr = null;
3401
+ const initialDispatch = this.resolveInitialDispatch(claimed);
3250
3402
  try {
3251
- started = await this.startSession(claimed.id, claimed.agentId, 'develop');
3403
+ started = await this.startSession(claimed.id, claimed.agentId, initialDispatch.phase);
3252
3404
  }
3253
3405
  catch (err) {
3254
3406
  dispatchErr = err;
3255
3407
  console.error(`[AgentManager] dispatchPendingTask startSession hard error for task=${claimed.id}:`, err);
3256
3408
  }
3257
3409
  if (started) {
3258
- await this.armPostDispatchSignalOrHold(claimed.id, claimed.agentId, this.devInitialSignalKinds(claimed.reviewMode), signalToken);
3410
+ await this.armPostDispatchSignalOrHold(claimed.id, claimed.agentId, initialDispatch.kinds, signalToken);
3259
3411
  const refreshed = await this.taskStore.get(claimed.id);
3260
3412
  return { task: refreshed ?? claimed };
3261
3413
  }
3262
3414
  if (dispatchErr instanceof DispatchTerminalError) {
3263
- await this.failTaskForDispatchError(claimed.id, 'develop', claimed.agentId, dispatchErr);
3415
+ await this.failTaskForDispatchError(claimed.id, initialDispatch.phase, claimed.agentId, dispatchErr);
3264
3416
  }
3265
3417
  else if (dispatchErr instanceof EnsureSessionError && dispatchErr.partial.handled) {
3266
3418
  }
@@ -3437,6 +3589,9 @@ export class AgentManager {
3437
3589
  throw new Error(`Task ${taskId} has no review branch`);
3438
3590
  await this.switchToVerifiedReviewHead(branchManager, workdir, task, assertOwner);
3439
3591
  }
3592
+ else if (phase === 'research') {
3593
+ await branchManager.switchToDefaultDetached(workdir);
3594
+ }
3440
3595
  else {
3441
3596
  if (!task.branch)
3442
3597
  throw new Error(`Task ${taskId} has no task branch`);
@@ -3499,16 +3654,20 @@ export class AgentManager {
3499
3654
  });
3500
3655
  const promptSignalToken = opts.signalToken ?? task.signalToken;
3501
3656
  const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
3502
- const hasQaPartner = !!(task.qaAgentId ?? this.findQaPartner(agentId)?.id);
3657
+ const hasQaPartner = task.qaAgentId !== undefined;
3503
3658
  let prompt;
3504
3659
  try {
3505
- await this.getReviewTransport().clearDispatchOutputs(agent, dispatchWorkdir, phase);
3660
+ await this.prepareDispatchArtifacts(agent, dispatchWorkdir, phase, {
3661
+ specDocuments: opts.specDocuments,
3662
+ preserveOutputs: false,
3663
+ assertOwner,
3664
+ });
3506
3665
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
3507
3666
  let payloadOpts = {};
3508
3667
  if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
3509
3668
  payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, dispatchWorkdir, {
3510
3669
  phase,
3511
- ...(task.phase ? { taskPhase: task.phase } : {}),
3670
+ taskPhase: task.phase,
3512
3671
  ...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
3513
3672
  reviewRound: task.reviewRound,
3514
3673
  ...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
@@ -3642,7 +3801,7 @@ export class AgentManager {
3642
3801
  const isAckUnknown = err instanceof DispatchTerminalError && err.reason === 'ack_unknown';
3643
3802
  if (!isAckUnknown) {
3644
3803
  await cleanupCheckoutOrHold();
3645
- if (agentMarkedRunning) {
3804
+ if (agentMarkedRunning && !opts.preserveBindingOnFailure) {
3646
3805
  try {
3647
3806
  let released = false;
3648
3807
  let releaseToken;
@@ -3884,6 +4043,16 @@ export class AgentManager {
3884
4043
  return false;
3885
4044
  }
3886
4045
  await this.assertTaskLockOwner(agentId, taskId, lockToken);
4046
+ const verifiedWorkdir = stateAfterEnsure.workdir;
4047
+ if (!verifiedWorkdir
4048
+ || verifiedWorkdir !== ensure.workdir
4049
+ || verifiedWorkdir !== taskWorkdir) {
4050
+ const reason = `Workdir changed during continueSession: ` +
4051
+ `before=${taskWorkdir}, ensure=${ensure.workdir}, state=${verifiedWorkdir ?? 'missing'}`;
4052
+ console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
4053
+ await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
4054
+ return false;
4055
+ }
3887
4056
  const expectedStatuses = PHASE_EXPECTED_STATUS[phase] ?? [];
3888
4057
  const taskAfterEnsure = await this.taskStore.get(taskId);
3889
4058
  if (!taskAfterEnsure || TERMINAL_STATUSES.includes(taskAfterEnsure.status)) {
@@ -3895,28 +4064,39 @@ export class AgentManager {
3895
4064
  `expected ${expectedStatuses.join('/')} for phase=${phase} after ensure; skipping`);
3896
4065
  return false;
3897
4066
  }
3898
- if (agentState.workdir && agent.role === 'dev' && task.branch) {
4067
+ if (agent.role === 'dev' && task.branch) {
3899
4068
  const branches = new BranchManager(runner);
3900
- await branches.assertClean(taskWorkdir);
3901
- const actualRef = await branches.currentRef(taskWorkdir);
4069
+ await branches.assertClean(verifiedWorkdir);
4070
+ const actualRef = await branches.currentRef(verifiedWorkdir);
3902
4071
  if (actualRef !== `refs/heads/${task.branch}`) {
3903
4072
  throw new Error(`Agent ${agentId} checkout mismatch for task ${taskId}: ` +
3904
4073
  `expected refs/heads/${task.branch}, got ${actualRef ?? 'detached HEAD'}`);
3905
4074
  }
3906
4075
  }
4076
+ else if (agent.role === 'research') {
4077
+ await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
4078
+ await new BranchManager(runner).switchToDefaultDetached(verifiedWorkdir);
4079
+ await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
4080
+ }
3907
4081
  const promptSpecRound = opts.currentSpecRound ?? task.specReviewRound;
3908
4082
  let prompt;
3909
4083
  try {
3910
- await this.getReviewTransport().clearDispatchOutputs(agent, taskWorkdir, phase);
4084
+ await this.prepareDispatchArtifacts(agent, verifiedWorkdir, phase, {
4085
+ specDocuments: opts.specDocuments,
4086
+ preserveOutputs: opts.preserveDispatchOutputs ?? false,
4087
+ assertOwner: async () => {
4088
+ await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
4089
+ },
4090
+ });
3911
4091
  const useIncrementalNudge = typeof opts.postApproveRedispatchCount === 'number'
3912
4092
  && opts.postApproveRedispatchCount > 0
3913
4093
  && !ensure.freshRuntime;
3914
4094
  const imagePaths = await this.imagePathsForDispatch(runner, task, phase);
3915
4095
  let payloadOpts = {};
3916
4096
  if (opts.serverContent !== undefined || opts.serverDiffstat !== undefined || opts.serverInterdiff !== undefined || opts.serverPriorFindings || opts.serverPriorResponse) {
3917
- payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, taskWorkdir, {
4097
+ payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, verifiedWorkdir, {
3918
4098
  phase,
3919
- ...(task.phase ? { taskPhase: task.phase } : {}),
4099
+ taskPhase: task.phase,
3920
4100
  ...(promptSpecRound !== undefined ? { specRound: promptSpecRound } : {}),
3921
4101
  reviewRound: task.reviewRound,
3922
4102
  ...(opts.serverBatch ? { batch: opts.serverBatch } : {}),
@@ -3931,7 +4111,7 @@ export class AgentManager {
3931
4111
  task,
3932
4112
  phase,
3933
4113
  agent,
3934
- workdir: taskWorkdir,
4114
+ workdir: verifiedWorkdir,
3935
4115
  skillRegistry: this.skillRegistry,
3936
4116
  ...(signalToken ? { signalToken } : {}),
3937
4117
  ...(useIncrementalNudge
@@ -3980,6 +4160,13 @@ export class AgentManager {
3980
4160
  console.warn(`[AgentManager] continueSession[${phase}]: ownership token changed; skipping`);
3981
4161
  return false;
3982
4162
  }
4163
+ if (agentFresh.workdir !== verifiedWorkdir) {
4164
+ const reason = `Workdir changed before prompt injection: ` +
4165
+ `verified=${verifiedWorkdir}, current=${agentFresh.workdir ?? 'missing'}`;
4166
+ console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
4167
+ await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
4168
+ return false;
4169
+ }
3983
4170
  await this.assertTaskLockOwner(agentId, taskId, lockToken);
3984
4171
  if (phase === 'post-approve') {
3985
4172
  const completionFresh = await this.getPostApproveCompletion(taskId);
@@ -3993,18 +4180,35 @@ export class AgentManager {
3993
4180
  }
3994
4181
  const now = new Date().toISOString();
3995
4182
  await this.agentStore.update(agentId, (latest) => {
3996
- if (!latest)
4183
+ if (!latest
4184
+ || latest.taskId !== taskId
4185
+ || latest.lockToken !== lockToken
4186
+ || latest.workdir !== verifiedWorkdir) {
3997
4187
  return AGENT_STORE_NOOP;
4188
+ }
3998
4189
  // The continuation prompt supersedes any question asked under the previous one.
3999
4190
  const { needInputAt: _needInputAt, ...rest } = latest;
4000
4191
  return {
4001
4192
  ...rest,
4002
4193
  paneId,
4003
- workdir: taskWorkdir,
4194
+ workdir: verifiedWorkdir,
4004
4195
  lockToken,
4005
4196
  updatedAt: now,
4006
4197
  };
4007
4198
  });
4199
+ const stateBeforeInject = await this.agentStore.get(agentId);
4200
+ if (stateBeforeInject?.taskId !== taskId || stateBeforeInject.lockToken !== lockToken) {
4201
+ console.warn(`[AgentManager] continueSession[${phase}]: ownership changed before prompt injection; skipping`);
4202
+ return false;
4203
+ }
4204
+ if (stateBeforeInject.workdir !== verifiedWorkdir) {
4205
+ const reason = `Workdir changed before prompt injection: ` +
4206
+ `verified=${verifiedWorkdir}, current=${stateBeforeInject.workdir ?? 'missing'}`;
4207
+ console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
4208
+ await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
4209
+ return false;
4210
+ }
4211
+ await this.assertTaskLockOwner(agentId, taskId, lockToken);
4008
4212
  await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime);
4009
4213
  return true;
4010
4214
  }
@@ -4015,13 +4219,47 @@ export class AgentManager {
4015
4219
  return false;
4016
4220
  }
4017
4221
  const boundTask = await this.taskStore.get(state.taskId);
4018
- if (!boundTask || boundTask.phase || boundTask.status !== 'in_progress' || boundTask.agentId !== state.id) {
4222
+ if (!boundTask)
4223
+ return false;
4224
+ if (boundTask.status === 'spec-ready' && isSpecStagePhase(boundTask.phase)) {
4225
+ if (state.id === boundTask.devAgentId && state.id !== boundTask.agentId) {
4226
+ const lockToken = state.lockToken;
4227
+ await this.agentStore.update(state.id, (latest) => {
4228
+ if (!latest
4229
+ || latest.taskId !== state.taskId
4230
+ || latest.bootstrappingTaskId !== state.taskId
4231
+ || latest.lockToken !== lockToken) {
4232
+ return AGENT_STORE_NOOP;
4233
+ }
4234
+ return {
4235
+ id: latest.id,
4236
+ projectId: latest.projectId,
4237
+ ...(latest.paneId !== undefined ? { paneId: latest.paneId } : {}),
4238
+ ...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
4239
+ ...(latest.creationToken !== undefined ? { creationToken: latest.creationToken } : {}),
4240
+ updatedAt: new Date().toISOString(),
4241
+ };
4242
+ });
4243
+ if (lockToken)
4244
+ await this.lockManager.releaseIfOwner(state.id, state.taskId, lockToken);
4245
+ return true;
4246
+ }
4247
+ await this.clearBootstrapMarker(state.id, state.taskId);
4019
4248
  return false;
4020
4249
  }
4021
- if (await this.bootstrapPromptWasDelivered(state.taskId, boundTask.createdAt)) {
4250
+ if (boundTask.status !== 'in_progress' || boundTask.agentId !== state.id)
4251
+ return false;
4252
+ const dispatchPhase = boundTask.phase === 'research'
4253
+ ? 'research'
4254
+ : boundTask.specReviewRound !== undefined ? 'code' : 'develop';
4255
+ if (await this.bootstrapPromptWasDelivered(state.taskId, boundTask.createdAt, state.id, dispatchPhase)) {
4022
4256
  await this.clearBootstrapMarker(state.id, state.taskId);
4023
4257
  return false;
4024
4258
  }
4259
+ if (dispatchPhase === 'code') {
4260
+ await this.markAwaitingHuman(state.id, 'code-dispatch-failed', 'Code-phase handoff was interrupted before the prompt was delivered. Resume to replay the persisted Spec documents.', { expectedTaskId: state.taskId });
4261
+ return true;
4262
+ }
4025
4263
  console.warn(`[recover] agent ${state.id} was mid-bootstrap for in_progress task ${state.taskId} ` +
4026
4264
  `(prompt never ack'd); rolling the task back to pending`);
4027
4265
  await this.rollbackFailedDispatch(state.taskId, state.id, undefined, state.lockToken);
@@ -4033,12 +4271,15 @@ export class AgentManager {
4033
4271
  });
4034
4272
  return true;
4035
4273
  }
4036
- async bootstrapPromptWasDelivered(taskId, createdAtIso) {
4274
+ async bootstrapPromptWasDelivered(taskId, createdAtIso, agentId, phase) {
4037
4275
  const today = new Date().toISOString().slice(0, 10);
4038
4276
  const from = createdAtIso.slice(0, 10);
4039
4277
  try {
4040
4278
  const events = await this.eventBus.readRange(from, today);
4041
- return events.some((e) => e.type === 'session.started' && e.taskId === taskId);
4279
+ return events.some((event) => event.type === 'session.started'
4280
+ && event.taskId === taskId
4281
+ && event.agentId === agentId
4282
+ && event.data.phase === phase);
4042
4283
  }
4043
4284
  catch (err) {
4044
4285
  console.warn(`[recover] bootstrapPromptWasDelivered read failed for task=${taskId}:`, err);
@@ -4493,8 +4734,8 @@ export class AgentManager {
4493
4734
  if (cfg.projectId !== projectId) {
4494
4735
  throw new ApiError(400, `Agent ${input.preferredAgentId} not in project ${projectId}`);
4495
4736
  }
4496
- if (cfg.role !== 'dev') {
4497
- throw new ApiError(400, `Agent ${input.preferredAgentId} is not dev role`);
4737
+ if (!TASK_OWNER_ROLES.has(cfg.role)) {
4738
+ throw new ApiError(400, `Agent ${input.preferredAgentId} is not dev or research role`);
4498
4739
  }
4499
4740
  }
4500
4741
  let previewBytes;
@@ -4529,24 +4770,23 @@ export class AgentManager {
4529
4770
  if (opts.expectSignalToken !== undefined && task.signalToken !== opts.expectSignalToken) {
4530
4771
  throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (signalToken rotated); aborting`);
4531
4772
  }
4532
- if (task.reviewMode === 'server' || task.phase === 'spec') {
4773
+ if (task.reviewMode === 'server' || isSpecStagePhase(task.phase)) {
4533
4774
  if (!MANUAL_SERVER_REVIEW_STATUSES.includes(task.status)) {
4534
4775
  throw new ApiError(409, `Task ${taskId} status is ${task.status}; manual server-side review requires ${MANUAL_SERVER_REVIEW_STATUSES.join('/')}`);
4535
4776
  }
4536
4777
  if (!this.serverReviewDriver) {
4537
4778
  throw new ApiError(409, `Server review pipeline is not configured; cannot dispatch review for ${taskId}`);
4538
4779
  }
4539
- // in_progress 且 phase 未定:dev 既可能交 spec-done 也可能交 code-done,评审对象无从判定
4540
4780
  if (task.status === 'in_progress' && task.phase === undefined) {
4541
4781
  throw new ApiError(409, `Task ${taskId} has no phase yet (the dev has not delivered spec-done/code-done); wait for the dev signal or use Cancel/Retry`);
4542
4782
  }
4543
4783
  // 手工发起=明确要求跑一次 QA 评审;无 QA 时必须拒绝,不得落入自动通过/停驻兜底
4544
- const qaAvailable = (task.qaAgentId !== undefined && !!this.getAgentConfig(task.qaAgentId))
4545
- || !!this.findQaPartner(task.agentId);
4784
+ const qaAvailable = task.qaAgentId !== undefined
4785
+ && this.getAgentConfig(task.qaAgentId)?.role === 'qa';
4546
4786
  if (!qaAvailable) {
4547
4787
  throw new ApiError(400, `Task ${taskId} has no QA partner configured; manual review requires a QA agent`);
4548
4788
  }
4549
- const isSpec = task.phase === 'spec';
4789
+ const isSpec = isSpecStagePhase(task.phase);
4550
4790
  const cap = this.config.review.rounds + (task.maxRoundsContinues ?? 0);
4551
4791
  const round = isSpec ? (task.specReviewRound ?? 0) : task.reviewRound;
4552
4792
  if (round + 1 > cap) {
@@ -4561,18 +4801,12 @@ export class AgentManager {
4561
4801
  if (!task.branch) {
4562
4802
  throw new ApiError(400, `Task ${taskId} has no branch; cannot dispatch review`);
4563
4803
  }
4564
- let qaId = task.qaAgentId;
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
- }
4804
+ const qaId = task.qaAgentId;
4570
4805
  if (!qaId) {
4571
- const qa = this.findQaPartner(task.agentId);
4572
- if (!qa) {
4573
- throw new ApiError(400, `Dev ${task.agentId} has no QA partner configured; cannot dispatch review`);
4574
- }
4575
- qaId = qa.id;
4806
+ throw new ApiError(400, `Task ${taskId} has no QA participant; cannot dispatch review`);
4807
+ }
4808
+ if (this.getAgentConfig(qaId)?.role !== 'qa') {
4809
+ throw new ApiError(409, `Task ${taskId} QA participant ${qaId} is unavailable`);
4576
4810
  }
4577
4811
  this.manualReviewInFlight.add(taskId);
4578
4812
  return { mode: 'github', qaId, devAgentId: task.agentId, taskStatusAtClaim: task.status };
@@ -4658,7 +4892,6 @@ export class AgentManager {
4658
4892
  await this.taskStore.set({
4659
4893
  ...fresh,
4660
4894
  reviewRound: bumpRound ? fresh.reviewRound + 1 : fresh.reviewRound,
4661
- qaAgentId: qaId,
4662
4895
  signalToken: armedToken,
4663
4896
  updatedAt: new Date().toISOString(),
4664
4897
  });
@@ -4741,7 +4974,7 @@ export class AgentManager {
4741
4974
  if (task.status !== 'max_rounds') {
4742
4975
  throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
4743
4976
  }
4744
- if (task.phase === 'spec') {
4977
+ if (isSpecStagePhase(task.phase)) {
4745
4978
  throw new ApiError(409, `Continue one round is only supported for code-phase tasks`);
4746
4979
  }
4747
4980
  if (task.reviewMode === 'server') {
@@ -4937,31 +5170,40 @@ export class AgentManager {
4937
5170
  ? ['spec-done', 'code-done']
4938
5171
  : ['spec-done', 'pr-created'];
4939
5172
  }
5173
+ resolveInitialDispatch(task) {
5174
+ if (task.phase === 'research')
5175
+ return { phase: 'research', kinds: ['spec-done'] };
5176
+ return { phase: 'develop', kinds: this.devInitialSignalKinds(task.reviewMode) };
5177
+ }
4940
5178
  mapTaskStateToExpectedWatcher(task) {
4941
5179
  if (task.reviewMode === 'server')
4942
5180
  return this.mapServerTaskToExpectedWatcher(task);
4943
- if (task.phase === 'spec' && task.status === 'review' && task.qaAgentId) {
4944
- return { expectedKinds: ['spec-reviewed'], agentId: task.qaAgentId };
4945
- }
4946
- if (task.phase === 'spec' && task.status === 'fixing' && task.agentId) {
4947
- return { expectedKinds: ['spec-fixed'], agentId: task.agentId };
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 };
5181
+ const specStage = isSpecStagePhase(task.phase);
5182
+ if (task.status === 'review' && task.qaAgentId) {
5183
+ return {
5184
+ expectedKinds: specStage ? ['spec-reviewed'] : ['pr-approved', 'pr-changes-requested'],
5185
+ agentId: task.qaAgentId,
5186
+ };
4954
5187
  }
4955
- if (task.phase === 'code' && task.status === 'in_progress' && task.agentId) {
4956
- return { expectedKinds: ['pr-created'], agentId: task.agentId };
5188
+ if (task.status === 'fixing' && task.agentId) {
5189
+ return {
5190
+ expectedKinds: [specStage ? 'spec-fixed' : 'pr-fixed'],
5191
+ agentId: task.agentId,
5192
+ };
4957
5193
  }
4958
- if (task.phase !== 'spec' && task.status === 'review' && task.qaAgentId) {
4959
- return { expectedKinds: ['pr-approved', 'pr-changes-requested'], agentId: task.qaAgentId };
5194
+ if (task.status === 'in_progress' && task.agentId) {
5195
+ if (task.phase === 'research')
5196
+ return { expectedKinds: ['spec-done'], agentId: task.agentId };
5197
+ if (task.phase === 'code')
5198
+ return { expectedKinds: ['pr-created'], agentId: task.agentId };
5199
+ if (task.phase === undefined) {
5200
+ return { expectedKinds: ['spec-done', 'pr-created'], agentId: task.agentId };
5201
+ }
4960
5202
  }
4961
5203
  return undefined;
4962
5204
  }
4963
5205
  mapServerTaskToExpectedWatcher(task) {
4964
- const isSpec = task.phase === 'spec';
5206
+ const isSpec = isSpecStagePhase(task.phase);
4965
5207
  if (task.status === 'review' && task.qaAgentId) {
4966
5208
  return { expectedKinds: [isSpec ? 'spec-reviewed' : 'code-reviewed'], agentId: task.qaAgentId };
4967
5209
  }
@@ -4969,8 +5211,11 @@ export class AgentManager {
4969
5211
  return { expectedKinds: [isSpec ? 'spec-fixed' : 'code-fixed'], agentId: task.agentId };
4970
5212
  }
4971
5213
  if (task.status === 'in_progress' && task.agentId) {
4972
- if (task.phase === 'code')
5214
+ if (task.phase === 'research')
5215
+ return { expectedKinds: ['spec-done'], agentId: task.agentId };
5216
+ if (task.phase === 'code') {
4973
5217
  return { expectedKinds: ['code-done'], agentId: task.agentId };
5218
+ }
4974
5219
  return { expectedKinds: ['spec-done', 'code-done'], agentId: task.agentId };
4975
5220
  }
4976
5221
  if (task.status === 'approved' && task.agentId) {
@@ -5002,7 +5247,7 @@ export class AgentManager {
5002
5247
  if (!t)
5003
5248
  throw new ApiError(404, 'Task not found');
5004
5249
  const retryable = TERMINAL_STATUSES.includes(t.status)
5005
- || (t.status === 'max_rounds' && t.phase === 'spec');
5250
+ || (t.status === 'max_rounds' && isSpecStagePhase(t.phase));
5006
5251
  if (!retryable) {
5007
5252
  throw new ApiError(409, `Task ${taskId} cannot be retried in status "${t.status}"; cancel it first or wait for completion`);
5008
5253
  }
@@ -5037,7 +5282,11 @@ export class AgentManager {
5037
5282
  if (patch.preferredAgentId !== undefined && patch.preferredAgentId !== task.preferredAgentId) {
5038
5283
  if (patch.preferredAgentId === '') {
5039
5284
  task.preferredAgentId = '';
5285
+ task.agentId = '';
5286
+ task.devAgentId = '';
5287
+ delete task.phase;
5040
5288
  delete task.qaAgentId;
5289
+ delete task.researchAgentId;
5041
5290
  }
5042
5291
  else {
5043
5292
  const cfg = this.getAgentConfig(patch.preferredAgentId);
@@ -5046,10 +5295,23 @@ export class AgentManager {
5046
5295
  if (cfg.projectId !== task.projectId) {
5047
5296
  throw new ApiError(400, `Agent not in project ${task.projectId}`);
5048
5297
  }
5049
- if (cfg.role !== 'dev')
5050
- throw new ApiError(400, `Agent is not dev role`);
5298
+ if (!TASK_OWNER_ROLES.has(cfg.role))
5299
+ throw new ApiError(400, `Agent is not dev or research role`);
5300
+ const group = this.findAgentGroup(cfg.id);
5301
+ const dev = group?.find(agent => agent.role === 'dev');
5302
+ if (!dev)
5303
+ throw new ApiError(409, `Agent ${cfg.id} has no dev agent in its group`);
5051
5304
  task.preferredAgentId = patch.preferredAgentId;
5052
- const qaId = this.findQaPartner(patch.preferredAgentId)?.id;
5305
+ task.devAgentId = dev.id;
5306
+ if (cfg.role === 'research') {
5307
+ task.phase = 'research';
5308
+ task.researchAgentId = cfg.id;
5309
+ }
5310
+ else {
5311
+ delete task.phase;
5312
+ delete task.researchAgentId;
5313
+ }
5314
+ const qaId = group?.find(agent => agent.role === 'qa')?.id;
5053
5315
  if (qaId) {
5054
5316
  task.qaAgentId = qaId;
5055
5317
  }
@@ -5095,7 +5357,7 @@ export class AgentManager {
5095
5357
  if (!serverApprovedRetry && task.status !== 'max_rounds') {
5096
5358
  throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
5097
5359
  }
5098
- if (task.phase === 'spec') {
5360
+ if (isSpecStagePhase(task.phase)) {
5099
5361
  throw new ApiError(409, `Mark complete is only supported for code-phase tasks`);
5100
5362
  }
5101
5363
  if (task.reviewMode !== 'server' && (!task.prNumber || !task.branch)) {
@@ -5515,7 +5777,6 @@ export class AgentManager {
5515
5777
  signalToken: restore.signalToken,
5516
5778
  reviewHeadAnchorSha: restore.reviewHeadAnchorSha,
5517
5779
  reviewDispatchedAt: restore.reviewDispatchedAt,
5518
- qaAgentId: undefined,
5519
5780
  updatedAt: new Date().toISOString(),
5520
5781
  });
5521
5782
  return true;
@@ -5543,11 +5804,8 @@ export class AgentManager {
5543
5804
  const task = await this.taskStore.get(taskId);
5544
5805
  if (!task)
5545
5806
  return null;
5546
- const transition = await this.transitionTaskStatus(taskId, 'spec-ready', { fromStatus: ['review', 'in_progress', 'fixing'] },
5547
- // QA 在停驻期解绑,残留 qaAgentId 会让后续 approve/打回误发释放;复审经 findQaPartner 回绑
5548
- {
5807
+ const transition = await this.transitionTaskStatus(taskId, 'spec-ready', { fromStatus: ['review', 'in_progress', 'fixing'] }, {
5549
5808
  phase: 'spec',
5550
- qaAgentId: undefined,
5551
5809
  ...(opts.specReviewRound !== undefined ? { specReviewRound: opts.specReviewRound } : {}),
5552
5810
  });
5553
5811
  if (!transition)
@@ -5580,6 +5838,9 @@ export class AgentManager {
5580
5838
  if (fresh.status !== 'spec-ready') {
5581
5839
  throw new ApiError(409, `Task ${taskId} is ${fresh.status}; spec verdict requires spec-ready`);
5582
5840
  }
5841
+ if (verdict === 'archive' && !fresh.researchAgentId) {
5842
+ throw new ApiError(409, `Task ${taskId} is not a Research task; only Research specs can be archived`);
5843
+ }
5583
5844
  if (this.specVerdictInFlight.has(taskId)) {
5584
5845
  throw new ApiError(409, `Task ${taskId} spec verdict is already being processed`);
5585
5846
  }
@@ -5601,13 +5862,45 @@ export class AgentManager {
5601
5862
  const round = task.specReviewRound ?? 1;
5602
5863
  const at = new Date().toISOString();
5603
5864
  const roundData = await store.getRound(taskId, 'spec', round);
5604
- // 停驻路径必然已存轮次;缺失时补一条空轮次,保证 userDecision 留痕与 fix 闭环
5605
- const base = roundData ?? { round, phase: 'spec', content: '', startedAt: at };
5865
+ if (!roundData || roundData.phase !== 'spec') {
5866
+ throw new ApiError(409, `Task ${taskId} has no persisted spec review round ${round}`);
5867
+ }
5868
+ const base = roundData;
5869
+ if (verdict === 'archive') {
5870
+ await store.putRound(taskId, 'spec', {
5871
+ ...base,
5872
+ userDecision: { verdict, ...(trimmed ? { comments: trimmed } : {}), at },
5873
+ });
5874
+ const transition = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['spec-ready'] });
5875
+ if (!transition)
5876
+ throw new ApiError(409, `Task ${taskId} changed while archiving`);
5877
+ this.stopPhaseSignalWatcher(taskId);
5878
+ const participants = new Set([
5879
+ task.agentId,
5880
+ task.devAgentId,
5881
+ task.qaAgentId,
5882
+ task.researchAgentId,
5883
+ ].filter((id) => typeof id === 'string' && id !== ''));
5884
+ for (const agentId of participants) {
5885
+ const state = await this.agentStore.get(agentId);
5886
+ if (state?.taskId !== taskId)
5887
+ continue;
5888
+ const released = await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true }).catch(() => false);
5889
+ if (!released) {
5890
+ await this.emitIntervention(task.projectId, agentId, taskId, {
5891
+ phase: 'spec-archive-agent-release-failed',
5892
+ agentId,
5893
+ });
5894
+ }
5895
+ }
5896
+ return (await this.taskStore.get(taskId));
5897
+ }
5606
5898
  if (verdict === 'approve') {
5607
5899
  await store.putRound(taskId, 'spec', { ...base, userDecision: { verdict, at } });
5608
5900
  const result = await this.transitionToCodePhase(taskId);
5609
- if (!result)
5610
- throw new ApiError(500, `Failed to dispatch code phase for task ${taskId}`);
5901
+ if (!result) {
5902
+ throw new ApiError(409, `Dev is unavailable for task ${taskId}; approval was recorded and can be retried`);
5903
+ }
5611
5904
  return result;
5612
5905
  }
5613
5906
  // 打回消耗一轮修订;到达上限时拒绝而非派发注定进 max_rounds 的修订,让用户当场决策
@@ -5647,7 +5940,7 @@ export class AgentManager {
5647
5940
  if (fresh.status !== 'ready') {
5648
5941
  throw new ApiError(409, `Task ${taskId} is ${fresh.status}; code verdict requires ready`);
5649
5942
  }
5650
- if (fresh.phase === 'spec') {
5943
+ if (isSpecStagePhase(fresh.phase)) {
5651
5944
  throw new ApiError(409, `Task ${taskId} is in the spec phase; reject the spec via the spec verdict instead`);
5652
5945
  }
5653
5946
  if (fresh.reviewMode !== 'server') {
@@ -5740,40 +6033,82 @@ export class AgentManager {
5740
6033
  return empty;
5741
6034
  }
5742
6035
  }
6036
+ isResearchHandoff(task, devAgentId) {
6037
+ return task.researchAgentId !== undefined && task.researchAgentId !== devAgentId;
6038
+ }
6039
+ dispatchCodePhasePrompt(task, devAgentId, signalToken, specDocuments, currentSpecRound) {
6040
+ const expectedKind = task.reviewMode === 'server' ? 'code-done' : 'pr-created';
6041
+ const dispatchOpts = {
6042
+ signalToken,
6043
+ specDocuments,
6044
+ ...(currentSpecRound !== undefined ? { currentSpecRound } : {}),
6045
+ armBeforeInject: () => this.setupPhaseSignalWatcher(task.id, devAgentId, expectedKind, signalToken),
6046
+ };
6047
+ return this.isResearchHandoff(task, devAgentId)
6048
+ ? this.startSession(task.id, devAgentId, 'code', {
6049
+ ...dispatchOpts,
6050
+ preserveBindingOnFailure: true,
6051
+ })
6052
+ : this.continueSession(task.id, devAgentId, 'code', dispatchOpts);
6053
+ }
5743
6054
  async transitionToCodePhase(taskId) {
5744
6055
  const task = await this.taskStore.get(taskId);
5745
6056
  if (!task)
5746
6057
  return null;
5747
- const devAgentId = task.agentId;
5748
- if (!devAgentId)
5749
- return null;
5750
- const newToken = createSignalToken();
5751
- const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready'] }, { phase: 'code', signalToken: newToken });
5752
- if (!transition)
6058
+ const devAgentId = task.devAgentId;
6059
+ const dev = devAgentId ? this.getAgentConfig(devAgentId) : undefined;
6060
+ if (!devAgentId || dev?.role !== 'dev') {
6061
+ await this.emitIntervention(task.projectId, task.agentId, taskId, {
6062
+ phase: 'code-dev-missing',
6063
+ devAgentId,
6064
+ });
5753
6065
  return null;
5754
- this.stopPhaseSignalWatcher(taskId);
5755
- const codeKind = task.reviewMode === 'server' ? 'code-done' : 'pr-created';
5756
- const codeArmed = await this.setupPhaseSignalWatcher(taskId, devAgentId, codeKind, newToken);
5757
- if (!codeArmed && task.reviewMode === 'server') {
5758
- await this.holdAgentForUnarmedSignal(taskId, devAgentId, codeKind);
6066
+ }
6067
+ const round = task.specReviewRound ?? 1;
6068
+ const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
6069
+ if (!stored || stored.phase !== 'spec') {
6070
+ await this.emitIntervention(task.projectId, task.agentId, taskId, {
6071
+ phase: 'code-spec-round-missing',
6072
+ round,
6073
+ });
5759
6074
  return null;
5760
6075
  }
5761
- if (task.qaAgentId) {
5762
- const released = await this.releaseAgentForTask(task.qaAgentId, taskId, 'idle')
6076
+ const researchAgentId = task.researchAgentId;
6077
+ const researchHandoff = this.isResearchHandoff(task, devAgentId);
6078
+ const releaseParticipant = async (agentId, failurePhase) => {
6079
+ const released = await this.releaseAgentIfBound(agentId, taskId, { allowAwaitingHuman: true })
5763
6080
  .catch(() => false);
5764
6081
  if (!released) {
5765
- await this.emitIntervention(task.projectId, task.qaAgentId, taskId, { phase: 'code-phase-qa-release-failed', qaAgentId: task.qaAgentId });
6082
+ await this.emitIntervention(task.projectId, agentId, taskId, { phase: failurePhase, agentId });
5766
6083
  }
6084
+ return released;
6085
+ };
6086
+ if (researchHandoff && researchAgentId) {
6087
+ if (!await releaseParticipant(researchAgentId, 'code-phase-research-release-failed'))
6088
+ return null;
6089
+ }
6090
+ if (task.qaAgentId && !await releaseParticipant(task.qaAgentId, 'code-phase-qa-release-failed')) {
6091
+ return null;
5767
6092
  }
5768
6093
  const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'code');
5769
6094
  if (!acquired) {
5770
- await this.markAwaitingHuman(devAgentId, 'code-dispatch-failed', 'Dev could not be acquired for the code phase after spec approval; the task looks in_progress but the code prompt was never dispatched. Resume the agent to redispatch or cancel the task.', { expectedTaskId: taskId }).catch(() => undefined);
5771
- await this.emitIntervention(task.projectId, devAgentId, taskId, { phase: 'code-dev-acquire-failed', devAgentId });
6095
+ await this.emitIntervention(task.projectId, devAgentId, taskId, {
6096
+ phase: 'code-dev-acquire-failed',
6097
+ devAgentId,
6098
+ });
5772
6099
  return null;
5773
6100
  }
5774
- let resumed = false;
6101
+ const newToken = createSignalToken();
6102
+ const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready'] }, { agentId: devAgentId, phase: 'code', signalToken: newToken });
6103
+ if (!transition) {
6104
+ await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
6105
+ .catch(() => undefined);
6106
+ return null;
6107
+ }
6108
+ this.stopPhaseSignalWatcher(taskId);
6109
+ let started = false;
5775
6110
  try {
5776
- resumed = await this.continueSession(taskId, devAgentId, 'code');
6111
+ started = await this.dispatchCodePhasePrompt(task, devAgentId, newToken, stored.documents);
5777
6112
  }
5778
6113
  catch (err) {
5779
6114
  if (err instanceof DispatchTerminalError) {
@@ -5782,12 +6117,15 @@ export class AgentManager {
5782
6117
  else if (!(err instanceof EnsureSessionError && err.partial.handled)) {
5783
6118
  await this.markAwaitingHuman(devAgentId, 'code-dispatch-failed', 'Code-phase prompt was not delivered after spec approval; the task looks in_progress but the dev never received it. Resume/restart the agent or cancel the task.', { expectedTaskId: taskId }).catch(() => undefined);
5784
6119
  }
5785
- console.error(`[AgentManager] transitionToCodePhase continueSession(dev=${devAgentId}) failed:`, err);
6120
+ console.error(`[AgentManager] transitionToCodePhase dispatch(dev=${devAgentId}) failed:`, err);
5786
6121
  throw err;
5787
6122
  }
5788
- if (!resumed) {
6123
+ if (!started) {
5789
6124
  await this.markAwaitingHuman(devAgentId, 'code-dispatch-failed', 'Code-phase prompt was not delivered after spec approval; the task looks in_progress but the dev never received it. Resume/restart the agent or cancel the task.', { expectedTaskId: taskId }).catch(() => undefined);
5790
- await this.emitIntervention(task.projectId, devAgentId, taskId, { phase: 'code-resume-failed', devAgentId });
6125
+ await this.emitIntervention(task.projectId, devAgentId, taskId, {
6126
+ phase: researchHandoff ? 'code-start-failed' : 'code-resume-failed',
6127
+ devAgentId,
6128
+ });
5791
6129
  return null;
5792
6130
  }
5793
6131
  return await this.taskStore.get(taskId);
@@ -5804,13 +6142,7 @@ export class AgentManager {
5804
6142
  if (task.reviewMode !== 'server' && opts.phase !== 'spec') {
5805
6143
  throw new Error(`dispatchServerReviewToQa: task ${taskId} is not in server review mode`);
5806
6144
  }
5807
- let recordedQaId = task.qaAgentId;
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;
6145
+ const qaId = task.qaAgentId;
5814
6146
  if (!qaId) {
5815
6147
  const entryKind = task.status === 'fixing'
5816
6148
  ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
@@ -5819,6 +6151,17 @@ export class AgentManager {
5819
6151
  await this.emitIntervention(task.projectId, task.agentId, taskId, { phase: 'server-review-no-qa-partner', devAgentId: task.agentId });
5820
6152
  return null;
5821
6153
  }
6154
+ if (this.getAgentConfig(qaId)?.role !== 'qa') {
6155
+ const entryKind = task.status === 'fixing'
6156
+ ? (opts.phase === 'spec' ? 'spec-fixed' : 'code-fixed')
6157
+ : (opts.phase === 'spec' ? 'spec-done' : 'code-done');
6158
+ await this.setupPhaseSignal(taskId, task.agentId, entryKind, { skipSnapshot: true });
6159
+ await this.emitIntervention(task.projectId, task.agentId, taskId, {
6160
+ phase: 'server-review-qa-unavailable',
6161
+ qaAgentId: qaId,
6162
+ });
6163
+ return null;
6164
+ }
5822
6165
  const roundField = opts.phase === 'spec' ? (task.specReviewRound ?? 0) : task.reviewRound;
5823
6166
  return {
5824
6167
  qaId,
@@ -5892,7 +6235,6 @@ export class AgentManager {
5892
6235
  : { reviewRound: newRound };
5893
6236
  const transition = await this.transitionTaskStatus(taskId, 'review', { fromStatus: ['in_progress', 'fixing', 'review'] }, {
5894
6237
  signalToken: newToken,
5895
- qaAgentId: qaId,
5896
6238
  reviewDispatchedAt: new Date().toISOString(),
5897
6239
  ...(opts.reviewHeadAnchorSha ? { reviewHeadAnchorSha: opts.reviewHeadAnchorSha } : {}),
5898
6240
  ...(opts.batch
@@ -5993,7 +6335,7 @@ export class AgentManager {
5993
6335
  const task = await this.taskStore.get(taskId);
5994
6336
  if (!task)
5995
6337
  throw new Error(`dispatchServerFixToDev: task ${taskId} not found`);
5996
- if (task.reviewMode !== 'server' && task.phase !== 'spec') {
6338
+ if (task.reviewMode !== 'server' && !isSpecStagePhase(task.phase)) {
5997
6339
  throw new Error(`dispatchServerFixToDev: task ${taskId} is not in server review mode`);
5998
6340
  }
5999
6341
  if (!task.agentId)
@@ -6003,7 +6345,7 @@ export class AgentManager {
6003
6345
  qaAgentId: task.qaAgentId,
6004
6346
  projectId: task.projectId,
6005
6347
  newToken: createSignalToken(),
6006
- taskPhase: (task.phase ?? 'code'),
6348
+ taskPhase: task.phase,
6007
6349
  currentSpecRound: task.specReviewRound,
6008
6350
  originalStatus: task.status,
6009
6351
  originalToken: task.signalToken,
@@ -6029,7 +6371,7 @@ export class AgentManager {
6029
6371
  return null;
6030
6372
  }
6031
6373
  if (qaAgentId) {
6032
- const released = await this.releaseAgentForTask(qaAgentId, taskId, 'idle')
6374
+ const released = await this.releaseAgentIfBound(qaAgentId, taskId)
6033
6375
  .catch(() => false);
6034
6376
  if (!released) {
6035
6377
  await rearmReviewedSignal();