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.
- package/dist/agent/manager.d.ts +26 -27
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +613 -271
- package/dist/agent/manager.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.map +1 -1
- package/dist/agent/repo-store.js +1 -0
- 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 +133 -8
- package/dist/agent/review-transport.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 +29 -15
- 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/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/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,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 =
|
|
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.
|
|
1379
|
-
const
|
|
1380
|
-
if (
|
|
1381
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
1455
|
+
const cleanup = await branches.cleanupTaskBranch(releaseWorkdir, {
|
|
1400
1456
|
taskId: boundTask.id,
|
|
1401
1457
|
taskBranch: boundTask.branch,
|
|
1402
1458
|
branchCreatedByBaxian: boundTask.branchCreatedByBaxian,
|
|
1403
|
-
}, () => this.
|
|
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(
|
|
1502
|
+
await branches.parkOnDefaultDetached(releaseWorkdir);
|
|
1447
1503
|
}
|
|
1448
1504
|
else {
|
|
1449
|
-
await new BranchManager(runner).parkOnDefaultDetached(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2384
|
+
if (cfg.role !== 'dev')
|
|
2218
2385
|
return { targets: [agentId] };
|
|
2219
|
-
|
|
2220
|
-
|
|
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
|
-
|
|
2512
|
+
findAgentGroup(anchorAgentId) {
|
|
2341
2513
|
for (const project of this.config.project) {
|
|
2342
|
-
for (const
|
|
2343
|
-
if (
|
|
2344
|
-
return
|
|
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
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
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' ||
|
|
2621
|
+
const isServerProtocol = task.reviewMode === 'server' || specStage;
|
|
2450
2622
|
const scanSnapshotOnRecover = isServerProtocol
|
|
2451
2623
|
|| (task.phase === undefined && task.status === 'in_progress')
|
|
2452
|
-
|| (
|
|
2624
|
+
|| (!specStage && (task.status === 'review' || task.status === 'fixing'));
|
|
2453
2625
|
const allowRecoveredReadFile = task.status === 'review'
|
|
2454
|
-
&& (
|
|
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
|
|
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
|
-
|
|
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(
|
|
2881
|
-
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:
|
|
3027
|
+
agentId: target.id,
|
|
2897
3028
|
taskId,
|
|
2898
|
-
data: { agentId:
|
|
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,
|
|
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.
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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 =
|
|
3657
|
+
const hasQaPartner = task.qaAgentId !== undefined;
|
|
3503
3658
|
let prompt;
|
|
3504
3659
|
try {
|
|
3505
|
-
await this.
|
|
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
|
-
|
|
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 (
|
|
4067
|
+
if (agent.role === 'dev' && task.branch) {
|
|
3899
4068
|
const branches = new BranchManager(runner);
|
|
3900
|
-
await branches.assertClean(
|
|
3901
|
-
const actualRef = await branches.currentRef(
|
|
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.
|
|
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,
|
|
4097
|
+
payloadOpts = await resolveServerPayloads(this.getReviewTransport(), agent, verifiedWorkdir, {
|
|
3918
4098
|
phase,
|
|
3919
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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 (
|
|
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((
|
|
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
|
|
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
|
|
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 =
|
|
4545
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
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
|
|
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
|
-
|
|
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 };
|
|
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.
|
|
4956
|
-
return {
|
|
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.
|
|
4959
|
-
|
|
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
|
|
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 === '
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
5605
|
-
|
|
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(
|
|
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
|
|
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.
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
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
|
-
|
|
5755
|
-
const
|
|
5756
|
-
const
|
|
5757
|
-
if (!
|
|
5758
|
-
await this.
|
|
6066
|
+
}
|
|
6067
|
+
const round = task.specReviewRound ?? 1;
|
|
6068
|
+
const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
|
|
6069
|
+
if (!stored || stored.phase !== 'spec') {
|
|
6070
|
+
await this.emitIntervention(task.projectId, task.agentId, taskId, {
|
|
6071
|
+
phase: 'code-spec-round-missing',
|
|
6072
|
+
round,
|
|
6073
|
+
});
|
|
5759
6074
|
return null;
|
|
5760
6075
|
}
|
|
5761
|
-
|
|
5762
|
-
|
|
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,
|
|
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.
|
|
5771
|
-
|
|
6095
|
+
await this.emitIntervention(task.projectId, devAgentId, taskId, {
|
|
6096
|
+
phase: 'code-dev-acquire-failed',
|
|
6097
|
+
devAgentId,
|
|
6098
|
+
});
|
|
5772
6099
|
return null;
|
|
5773
6100
|
}
|
|
5774
|
-
|
|
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
|
-
|
|
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
|
|
6120
|
+
console.error(`[AgentManager] transitionToCodePhase dispatch(dev=${devAgentId}) failed:`, err);
|
|
5786
6121
|
throw err;
|
|
5787
6122
|
}
|
|
5788
|
-
if (!
|
|
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, {
|
|
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
|
-
|
|
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
|
|
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:
|
|
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.
|
|
6374
|
+
const released = await this.releaseAgentIfBound(qaAgentId, taskId)
|
|
6033
6375
|
.catch(() => false);
|
|
6034
6376
|
if (!released) {
|
|
6035
6377
|
await rearmReviewedSignal();
|