baxian 2.0.3 → 2.0.4

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 (72) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +3 -3
  3. package/dist/agent/bootstrap.js +4 -4
  4. package/dist/agent/dispatch-reconciler.d.ts +2 -0
  5. package/dist/agent/dispatch-reconciler.d.ts.map +1 -1
  6. package/dist/agent/dispatch-reconciler.js +48 -4
  7. package/dist/agent/dispatch-reconciler.js.map +1 -1
  8. package/dist/agent/manager.d.ts +41 -4
  9. package/dist/agent/manager.d.ts.map +1 -1
  10. package/dist/agent/manager.js +707 -77
  11. package/dist/agent/manager.js.map +1 -1
  12. package/dist/agent/tmux-probe-poller.js +4 -4
  13. package/dist/api/agents.d.ts.map +1 -1
  14. package/dist/api/agents.js +2 -7
  15. package/dist/api/agents.js.map +1 -1
  16. package/dist/api/config.js +7 -7
  17. package/dist/api/hosts.js +6 -6
  18. package/dist/api/platform-guard.js +2 -2
  19. package/dist/api/projects.js +14 -14
  20. package/dist/api/projects.js.map +1 -1
  21. package/dist/api/tasks.d.ts.map +1 -1
  22. package/dist/api/tasks.js +94 -57
  23. package/dist/api/tasks.js.map +1 -1
  24. package/dist/app.d.ts +2 -0
  25. package/dist/app.d.ts.map +1 -1
  26. package/dist/app.js.map +1 -1
  27. package/dist/cli.js +2 -2
  28. package/dist/config/loader.js +5 -5
  29. package/dist/config/loader.js.map +1 -1
  30. package/dist/config/validator.js +29 -29
  31. package/dist/config/validator.js.map +1 -1
  32. package/dist/event/handlers.d.ts.map +1 -1
  33. package/dist/event/handlers.js +3 -0
  34. package/dist/event/handlers.js.map +1 -1
  35. package/dist/event/ws.d.ts.map +1 -1
  36. package/dist/event/ws.js +3 -3
  37. package/dist/event/ws.js.map +1 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +5 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/platform/feedback.d.ts +5 -1
  42. package/dist/platform/feedback.d.ts.map +1 -1
  43. package/dist/platform/feedback.js +7 -1
  44. package/dist/platform/feedback.js.map +1 -1
  45. package/dist/platform/platform-poller.d.ts +5 -1
  46. package/dist/platform/platform-poller.d.ts.map +1 -1
  47. package/dist/platform/platform-poller.js +8 -6
  48. package/dist/platform/platform-poller.js.map +1 -1
  49. package/dist/platform/pr-conversation-cache.d.ts +12 -7
  50. package/dist/platform/pr-conversation-cache.d.ts.map +1 -1
  51. package/dist/platform/pr-conversation-cache.js +60 -23
  52. package/dist/platform/pr-conversation-cache.js.map +1 -1
  53. package/dist/platform/review-timeline.d.ts +17 -2
  54. package/dist/platform/review-timeline.d.ts.map +1 -1
  55. package/dist/platform/review-timeline.js +93 -84
  56. package/dist/platform/review-timeline.js.map +1 -1
  57. package/dist/shared/types.d.ts +31 -1
  58. package/dist/shared/types.d.ts.map +1 -1
  59. package/dist/shared/types.js +14 -0
  60. package/dist/shared/types.js.map +1 -1
  61. package/dist/state/snapshot.d.ts +0 -1
  62. package/dist/state/snapshot.d.ts.map +1 -1
  63. package/dist/state/snapshot.js +0 -13
  64. package/dist/state/snapshot.js.map +1 -1
  65. package/dist/state/task-store.d.ts.map +1 -1
  66. package/dist/state/task-store.js +96 -8
  67. package/dist/state/task-store.js.map +1 -1
  68. package/dist/web/assets/index-CzVzThuT.js +15 -0
  69. package/dist/web/assets/{index-yqxawAPJ.css → index-Rmp4wT_5.css} +1 -1
  70. package/dist/web/index.html +2 -2
  71. package/package.json +1 -1
  72. package/dist/web/assets/index-CiAnjs1_.js +0 -15
@@ -1,15 +1,18 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { randomBytes } from 'node:crypto';
3
+ import { AsyncLocalStorage } from 'node:async_hooks';
3
4
  import { join, normalize } from 'node:path';
4
5
  import { tmpdir } from 'node:os';
5
6
  import { createSignalToken, PASSIVE_VERDICT_WATCH } from './phase-signal.js';
6
7
  import { tmuxInstallHint } from './preflight.js';
7
- import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, TASK_OWNER_ROLES, effectiveTaskReviewRound, isSpecStagePhase, isTaskOpen, repoIdentityKey, taskGenerationGuard, taskReviewRound, taskMatchesGeneration, } from '../shared/index.js';
8
+ 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, effectiveTaskReviewRound, isSpecStagePhase, isTaskOpen, repoIdentityKey, taskAttentionGeneration, taskGenerationGuard, taskReviewRound, taskMatchesGeneration, } from '../shared/index.js';
8
9
  import { buildProjectDriver, makeDriverExec } from '../platform/driver-host.js';
9
10
  import { DriverOpError, buildDriverRunContext, GitDriver, safeDriverErrorText } from '../platform/git-driver.js';
11
+ import { versionTimeOf } from '../platform/row-schema.js';
10
12
  import { checkOpenPrBinding, checkPrBinding } from '../platform/pr-binding.js';
11
13
  import { collectPendingFeedback, scanCommentSourcesOnce } from '../platform/feedback.js';
12
- import { recheckPassProvenance } from '../platform/verdict-engine.js';
14
+ import { prReviewCacheRevision } from '../platform/pr-conversation-cache.js';
15
+ import { deadTokens, recheckPassProvenance } from '../platform/verdict-engine.js';
13
16
  import { platformBindingMismatch, taskNeedsPlatformBindingAudit, } from '../platform/startup.js';
14
17
  import { AGENT_STORE_NOOP } from '../state/agent-store.js';
15
18
  import { SkillRegistry } from '../skill/registry.js';
@@ -27,6 +30,7 @@ import { resolveProjectTool } from '../config/validator.js';
27
30
  import { SHA_HEX_SOURCE } from '../platform/types.js';
28
31
  import { bodyDigest } from '../platform/body-digest.js';
29
32
  import { validateCommentBody } from '../platform/command-renderer.js';
33
+ import { buildReviewTokenLine, extractReviewTokens, rowTokens } from '../platform/markers.js';
30
34
  const BENIGN_DISPATCH_CONFLICT_CODES = new Set(['dispatch-superseded', 'dispatch-in-flight']);
31
35
  export function isBenignDispatchConflict(err) {
32
36
  return err instanceof ApiError
@@ -39,6 +43,16 @@ function requireTaskQaAgentId(task, operation) {
39
43
  throw new Error(`${operation}: task ${task.id} has no QA participant`);
40
44
  return task.qaAgentId;
41
45
  }
46
+ function samePassProvenance(left, right) {
47
+ return left !== undefined
48
+ && right !== undefined
49
+ && left.sourceKey === right.sourceKey
50
+ && left.id === right.id
51
+ && left.bodyDigest === right.bodyDigest
52
+ && left.token === right.token
53
+ && left.failToken === right.failToken
54
+ && left.anchorSha === right.anchorSha;
55
+ }
42
56
  export class EnsureSessionError extends Error {
43
57
  partial;
44
58
  constructor(partial, message) {
@@ -226,6 +240,7 @@ export class AgentManager {
226
240
  paneStreamerManager;
227
241
  pluginRegistry;
228
242
  platformDefaultBranchOf;
243
+ prConversationCache;
229
244
  platformDriverExec;
230
245
  phaseSignalWatcher;
231
246
  errorRecordStore;
@@ -234,6 +249,7 @@ export class AgentManager {
234
249
  dispatchSettleTimeoutMs;
235
250
  dispatchAckResendIntervalMs = 3_000;
236
251
  taskMutationQueue = Promise.resolve();
252
+ taskMutationScope = new AsyncLocalStorage();
237
253
  agentOperationQueues = new Map();
238
254
  agentIndex;
239
255
  platformRunner;
@@ -254,6 +270,8 @@ export class AgentManager {
254
270
  cancelInterruptGuardWaitMs = DEFAULT_DISPATCH_ACK_TIMEOUT_MS + 5_000;
255
271
  markCompleteInFlight = new Set();
256
272
  specVerdictInFlight = new Set();
273
+ codeVerdictInFlight = new Set();
274
+ retryTaskInFlight = new Set();
257
275
  cancelCleanupInFlight = new Map();
258
276
  deletionInFlight = new Set();
259
277
  divergedAgents = new Set();
@@ -273,6 +291,7 @@ export class AgentManager {
273
291
  this.paneStreamerManager = deps.paneStreamerManager;
274
292
  this.pluginRegistry = deps.pluginRegistry;
275
293
  this.platformDefaultBranchOf = deps.platformDefaultBranchOf;
294
+ this.prConversationCache = deps.prConversationCache;
276
295
  this.errorRecordStore = deps.errorRecordStore;
277
296
  this.phaseSignalWatcher = deps.phaseSignalWatcher
278
297
  ?? (deps.paneStreamerManager
@@ -299,10 +318,26 @@ export class AgentManager {
299
318
  };
300
319
  }
301
320
  withTaskLock(fn) {
302
- const next = this.taskMutationQueue.then(fn);
321
+ const inherited = this.taskMutationScope.getStore();
322
+ if (inherited?.active)
323
+ return fn();
324
+ const next = this.taskMutationQueue.then(() => {
325
+ const scope = { active: true };
326
+ return this.taskMutationScope.run(scope, async () => {
327
+ try {
328
+ return await fn();
329
+ }
330
+ finally {
331
+ scope.active = false;
332
+ }
333
+ });
334
+ });
303
335
  this.taskMutationQueue = next.catch(() => undefined);
304
336
  return next;
305
337
  }
338
+ outsideTaskMutationScope(fn) {
339
+ return this.taskMutationScope.exit(fn);
340
+ }
306
341
  withAgentOperationLease(agentId, fn) {
307
342
  const previous = this.agentOperationQueues.get(agentId) ?? Promise.resolve();
308
343
  const next = previous.then(fn);
@@ -365,7 +400,7 @@ export class AgentManager {
365
400
  return true;
366
401
  }
367
402
  catch (err) {
368
- console.warn(`[AgentManager] safeEmit ${event.type} failed (audit log loss; state machine unaffected):`, err);
403
+ console.warn(`[AgentManager] safeEmit ${event.type} failed; persistence or handler delivery may be incomplete:`, err);
369
404
  return false;
370
405
  }
371
406
  }
@@ -911,7 +946,7 @@ export class AgentManager {
911
946
  }
912
947
  }
913
948
  await this.markDialogPending(agentId, creationToken);
914
- void this.slowPollDialogPending(agentId, creationToken).catch((pollErr) => {
949
+ void this.outsideTaskMutationScope(() => this.slowPollDialogPending(agentId, creationToken)).catch((pollErr) => {
915
950
  console.warn(`[bootstrap] slowPoll for ${agentId} crashed:`, pollErr);
916
951
  });
917
952
  return;
@@ -1184,10 +1219,10 @@ export class AgentManager {
1184
1219
  err.partial.handled = true;
1185
1220
  const snapshotPaneId = state.paneId;
1186
1221
  const snapshotTaskId = state.taskId;
1187
- void this.slowPollDialogPending(agentId, state.creationToken, {
1222
+ void this.outsideTaskMutationScope(() => this.slowPollDialogPending(agentId, state.creationToken, {
1188
1223
  ...(snapshotPaneId !== undefined ? { expectedPaneId: snapshotPaneId } : {}),
1189
1224
  expectedTaskId: snapshotTaskId,
1190
- }).catch((pollErr) => {
1225
+ })).catch((pollErr) => {
1191
1226
  console.warn(`[runtime] slowPoll for ${agentId} crashed:`, pollErr);
1192
1227
  });
1193
1228
  return true;
@@ -1255,6 +1290,7 @@ export class AgentManager {
1255
1290
  return;
1256
1291
  const now = new Date().toISOString();
1257
1292
  let projectIdForEmit = '';
1293
+ let taskIdForEmit;
1258
1294
  let wrote = false;
1259
1295
  const isBootstrapPath = creationToken !== undefined;
1260
1296
  if (isBootstrapPath && !(await this.runGreetingHandshake(agentId, cfg, pane))) {
@@ -1270,6 +1306,7 @@ export class AgentManager {
1270
1306
  return AGENT_STORE_NOOP;
1271
1307
  }
1272
1308
  projectIdForEmit = fresh.projectId;
1309
+ taskIdForEmit = fresh.taskId;
1273
1310
  wrote = true;
1274
1311
  if (isBootstrapPath) {
1275
1312
  return {
@@ -1283,11 +1320,15 @@ export class AgentManager {
1283
1320
  updatedAt: now,
1284
1321
  };
1285
1322
  }
1323
+ const awaitingReason = fresh.taskId
1324
+ ? `Runtime dialog resolved; agent REPL ready. Open task ${fresh.taskId}: cancel it if it is still active, ` +
1325
+ 'otherwise Resume can release the binding; Delete the agent if the binding cannot be recovered.'
1326
+ : 'Runtime dialog resolved; agent REPL ready. Click Resume to release the binding and let baxian pick the next task.';
1286
1327
  return {
1287
1328
  ...fresh,
1288
1329
  paneId,
1289
1330
  awaitingPhase: 'agent_dialog_resolved_runtime',
1290
- awaitingReason: 'Runtime dialog resolved; agent REPL ready. Click Resume to release the binding and let baxian pick the next task.',
1331
+ awaitingReason,
1291
1332
  updatedAt: now,
1292
1333
  };
1293
1334
  });
@@ -1304,9 +1345,13 @@ export class AgentManager {
1304
1345
  });
1305
1346
  }
1306
1347
  else {
1307
- await this.emitIntervention(projectIdForEmit, agentId, undefined, {
1348
+ const note = taskIdForEmit
1349
+ ? `Runtime dialog resolved; agent REPL ready. Open task ${taskIdForEmit}: cancel it if it is still active, ` +
1350
+ 'otherwise Resume can release the binding; Delete the agent if the binding cannot be recovered.'
1351
+ : 'Runtime dialog resolved; agent REPL ready. Click Resume to continue.';
1352
+ await this.emitIntervention(projectIdForEmit, agentId, taskIdForEmit, {
1308
1353
  phase: 'agent_dialog_resolved_runtime',
1309
- note: 'Runtime dialog resolved; agent REPL ready. Click Resume to continue.',
1354
+ note,
1310
1355
  });
1311
1356
  }
1312
1357
  return;
@@ -1317,7 +1362,7 @@ export class AgentManager {
1317
1362
  return;
1318
1363
  const controller = new AbortController();
1319
1364
  this.runtimeMenuWatchers.set(agentId, controller);
1320
- void this.runtimeMenuWatchLoop(agentId, controller.signal)
1365
+ void this.outsideTaskMutationScope(() => this.runtimeMenuWatchLoop(agentId, controller.signal))
1321
1366
  .catch((err) => {
1322
1367
  console.warn(`[runtimeMenuWatch] ${agentId} loop crashed:`, err);
1323
1368
  })
@@ -1378,6 +1423,10 @@ export class AgentManager {
1378
1423
  }
1379
1424
  else if (!onMenu && pendingMenu) {
1380
1425
  pendingMenu = false;
1426
+ await this.emitIntervention(fresh.projectId, agentId, fresh.taskId, {
1427
+ phase: 'agent_runtime_menu_resolved',
1428
+ previousPhase: 'agent_runtime_menu_pending',
1429
+ });
1381
1430
  }
1382
1431
  }
1383
1432
  }
@@ -2272,7 +2321,7 @@ export class AgentManager {
2272
2321
  if (this.needInputRetryTimer)
2273
2322
  return;
2274
2323
  this.needInputRetryTimer = setInterval(() => {
2275
- void this.needInputRetryPass();
2324
+ void this.outsideTaskMutationScope(() => this.needInputRetryPass());
2276
2325
  }, this.needInputRetryIntervalMs);
2277
2326
  this.needInputRetryTimer.unref?.();
2278
2327
  }
@@ -2464,13 +2513,13 @@ export class AgentManager {
2464
2513
  if (wm !== null && bumped && mode === 'restore') {
2465
2514
  const delta = this.collectPredecessorNeedInputDelta(args.agentId, args.taskId, wm.epoch);
2466
2515
  if (delta) {
2467
- void this.commitNeedInputWatermark({
2516
+ void this.outsideTaskMutationScope(() => this.commitNeedInputWatermark({
2468
2517
  agentId: args.agentId,
2469
2518
  taskId: args.taskId,
2470
2519
  epoch: wm.epoch,
2471
2520
  askSeq: Math.max(delta.askSeq, wm.askSeq),
2472
2521
  answeredSeq: Math.max(delta.answeredSeq, wm.answeredSeq),
2473
- });
2522
+ }));
2474
2523
  }
2475
2524
  }
2476
2525
  try {
@@ -3762,8 +3811,8 @@ export class AgentManager {
3762
3811
  const project = this.getProjectConfig(cfg.projectId);
3763
3812
  if (!project)
3764
3813
  throw new Error(`Unknown project: ${cfg.projectId}`);
3765
- const group = this.findAgentGroup(agentId);
3766
- return { targets: group?.map(agent => agent.id) ?? [agentId] };
3814
+ const team = this.findAgentTeam(agentId);
3815
+ return { targets: team?.map(agent => agent.id) ?? [agentId] };
3767
3816
  }
3768
3817
  async cleanupRemovedAgentRuntime(targets) {
3769
3818
  const failures = [];
@@ -3862,13 +3911,13 @@ export class AgentManager {
3862
3911
  const now = new Date().toISOString();
3863
3912
  if (cfg.role !== 'dev')
3864
3913
  throw new Error(`Agent ${cfg.id} is not a dev agent`);
3865
- const group = this.findAgentGroup(cfg.id);
3914
+ const team = this.findAgentTeam(cfg.id);
3866
3915
  const devAgentId = cfg.id;
3867
3916
  const qaAgentId = input.preferredAgentId === ''
3868
3917
  ? '_unassigned_preview_qa'
3869
- : group?.find(agent => agent.role === 'qa')?.id;
3918
+ : team?.find(agent => agent.role === 'qa')?.id;
3870
3919
  if (!qaAgentId)
3871
- throw new Error(`Agent ${cfg.id} has no qa agent in its group`);
3920
+ throw new Error(`Agent ${cfg.id} has no qa agent in its Agent Team`);
3872
3921
  const fakeTask = {
3873
3922
  id: 'task-9999999999',
3874
3923
  projectId,
@@ -3910,11 +3959,11 @@ export class AgentManager {
3910
3959
  return undefined;
3911
3960
  return buildProjectDriver(project, this.pluginRegistry, this.platformDriverExec);
3912
3961
  }
3913
- findAgentGroup(anchorAgentId) {
3962
+ findAgentTeam(anchorAgentId) {
3914
3963
  for (const project of this.config.project) {
3915
- for (const group of project.agent) {
3916
- if (group.some(agent => agent.id === anchorAgentId))
3917
- return group;
3964
+ for (const team of project.agent) {
3965
+ if (team.some(agent => agent.id === anchorAgentId))
3966
+ return team;
3918
3967
  }
3919
3968
  }
3920
3969
  return undefined;
@@ -3925,8 +3974,8 @@ export class AgentManager {
3925
3974
  return undefined;
3926
3975
  let longest;
3927
3976
  let longestBytes = -1;
3928
- for (const pair of project.agent) {
3929
- for (const a of pair) {
3977
+ for (const team of project.agent) {
3978
+ for (const a of team) {
3930
3979
  if (a.role !== 'dev' || !a.workdir)
3931
3980
  continue;
3932
3981
  const bytes = Buffer.byteLength(a.workdir, 'utf8');
@@ -3941,6 +3990,99 @@ export class AgentManager {
3941
3990
  async getTask(taskId) {
3942
3991
  return this.taskStore.get(taskId);
3943
3992
  }
3993
+ async recordTaskAttention(event) {
3994
+ if (!event.taskId)
3995
+ return;
3996
+ await this.withTaskLock(async () => {
3997
+ const task = await this.taskStore.get(event.taskId);
3998
+ if (!task)
3999
+ return;
4000
+ const reasonValue = typeof event.data.phase === 'string'
4001
+ ? event.data.phase
4002
+ : typeof event.data.reason === 'string'
4003
+ ? event.data.reason
4004
+ : 'human-intervention';
4005
+ const reason = reasonValue.trim() || 'human-intervention';
4006
+ const previousPhase = typeof event.data.previousPhase === 'string'
4007
+ ? event.data.previousPhase.trim()
4008
+ : '';
4009
+ const clearedReason = reason === 'mr-reopened'
4010
+ ? 'mr-closed-unmerged'
4011
+ : reason === 'resumed'
4012
+ || reason === 'git-review-dispatch-hold-cleared'
4013
+ || reason === 'agent_runtime_menu_resolved'
4014
+ ? previousPhase
4015
+ : '';
4016
+ if (clearedReason) {
4017
+ if (task.attention?.reason !== clearedReason
4018
+ || Date.parse(event.timestamp) < Date.parse(task.attention.occurredAt))
4019
+ return;
4020
+ const next = { ...task, updatedAt: new Date().toISOString() };
4021
+ delete next.attention;
4022
+ await this.taskStore.set(next);
4023
+ return;
4024
+ }
4025
+ if (reason === 'resumed'
4026
+ || reason === 'git-review-dispatch-hold-cleared'
4027
+ || reason === 'agent_runtime_menu_resolved')
4028
+ return;
4029
+ const runbookValue = [
4030
+ event.data.note,
4031
+ event.data.recommendation,
4032
+ event.data.message,
4033
+ event.data.error,
4034
+ ...(typeof event.data.phase === 'string' ? [event.data.reason] : []),
4035
+ ]
4036
+ .find(value => typeof value === 'string' && value.trim() !== '');
4037
+ const runbook = typeof runbookValue === 'string'
4038
+ ? runbookValue.trim()
4039
+ : `Task ${task.id} requires human intervention for ${reason}. Inspect the task and its linked agent before choosing an action.`;
4040
+ const occurredAt = Number.isFinite(Date.parse(event.timestamp))
4041
+ ? event.timestamp
4042
+ : new Date().toISOString();
4043
+ const generation = taskAttentionGeneration(task);
4044
+ let recommendedActions;
4045
+ if (task.status === 'max_rounds') {
4046
+ recommendedActions = ['verdict', 'cancel'];
4047
+ }
4048
+ else if (TERMINAL_STATUSES.includes(task.status)) {
4049
+ recommendedActions = task.remoteCleanup !== undefined
4050
+ && task.remoteCleanup.stage !== 'manual'
4051
+ ? ['cancel']
4052
+ : ['retry'];
4053
+ }
4054
+ else if (task.status === 'review') {
4055
+ recommendedActions = isSpecStagePhase(task.phase)
4056
+ ? ['advance', 'cancel']
4057
+ : ['advance', 'verdict', 'cancel'];
4058
+ }
4059
+ else if (task.status === 'spec-ready' || task.status === 'merge-ready') {
4060
+ recommendedActions = ['verdict', 'cancel'];
4061
+ }
4062
+ else {
4063
+ recommendedActions = ['advance', 'cancel'];
4064
+ }
4065
+ if (task.attention
4066
+ && Date.parse(task.attention.occurredAt) > Date.parse(occurredAt))
4067
+ return;
4068
+ if (task.attention
4069
+ && task.attention.reason === reason
4070
+ && task.attention.runbook === runbook
4071
+ && Date.parse(task.attention.occurredAt) >= Date.parse(occurredAt))
4072
+ return;
4073
+ await this.taskStore.set({
4074
+ ...task,
4075
+ attention: {
4076
+ reason,
4077
+ runbook,
4078
+ occurredAt,
4079
+ recommendedActions,
4080
+ generation,
4081
+ },
4082
+ updatedAt: new Date().toISOString(),
4083
+ });
4084
+ });
4085
+ }
3944
4086
  async getAgentState(agentId) {
3945
4087
  return this.agentStore.get(agentId);
3946
4088
  }
@@ -4027,15 +4169,30 @@ export class AgentManager {
4027
4169
  delete next.reviewDispatch;
4028
4170
  delete next.reviewRoundPending;
4029
4171
  }
4030
- if (nextStatus !== task.status && next.outbox) {
4031
- const outbox = next.outbox.filter(entry => entry.type !== 'git.spec-verdict');
4032
- if (outbox.length > 0)
4033
- next.outbox = outbox;
4034
- else
4035
- delete next.outbox;
4036
- }
4172
+ if (nextStatus !== task.status)
4173
+ this.stripGitVerdictOutbox(next);
4037
4174
  return next;
4038
4175
  }
4176
+ stripGitVerdictOutbox(task) {
4177
+ if (!task.outbox)
4178
+ return;
4179
+ const outbox = task.outbox.filter(entry => entry.type !== 'git.spec-verdict' && entry.type !== 'git.code-verdict');
4180
+ if (outbox.length > 0)
4181
+ task.outbox = outbox;
4182
+ else
4183
+ delete task.outbox;
4184
+ }
4185
+ stripCodeVerdictOutboxForAnchor(task, anchorSha) {
4186
+ if (!task.outbox)
4187
+ return;
4188
+ const normalizedAnchor = anchorSha.toLowerCase();
4189
+ const outbox = task.outbox.filter(entry => entry.type !== 'git.code-verdict'
4190
+ || entry.data.anchorSha.toLowerCase() === normalizedAnchor);
4191
+ if (outbox.length > 0)
4192
+ task.outbox = outbox;
4193
+ else
4194
+ delete task.outbox;
4195
+ }
4039
4196
  async confirmPostApprovePromptDelivered(taskId, expected) {
4040
4197
  await this.withTaskLock(async () => {
4041
4198
  const task = await this.taskStore.get(taskId);
@@ -4229,6 +4386,9 @@ export class AgentManager {
4229
4386
  specVerdictOutbox(task) {
4230
4387
  return task.outbox?.find((entry) => entry.type === 'git.spec-verdict');
4231
4388
  }
4389
+ codeVerdictOutbox(task) {
4390
+ return task.outbox?.find((entry) => entry.type === 'git.code-verdict');
4391
+ }
4232
4392
  async mutateTaskOutboxEntry(taskId, key, mutate) {
4233
4393
  return this.withTaskLock(async () => {
4234
4394
  const task = await this.taskStore.get(taskId);
@@ -4258,6 +4418,10 @@ export class AgentManager {
4258
4418
  await this.deliverSpecVerdictOutbox(taskId, entry.key);
4259
4419
  continue;
4260
4420
  }
4421
+ if (entry.type === 'git.code-verdict') {
4422
+ await this.deliverCodeVerdictOutbox(taskId, entry.key);
4423
+ continue;
4424
+ }
4261
4425
  await this.eventBus.emit({
4262
4426
  id: `outbox:${entry.key}`,
4263
4427
  type: 'human.intervention',
@@ -4379,6 +4543,10 @@ export class AgentManager {
4379
4543
  return null;
4380
4544
  if (Object.hasOwn(opts, 'expectPhase') && current.phase !== opts.expectPhase)
4381
4545
  return null;
4546
+ if (current.status === 'review'
4547
+ && current.reviewHeadAnchorSha?.toLowerCase() === opts.headSha.toLowerCase()
4548
+ && this.codeVerdictOutbox(current) !== undefined)
4549
+ return null;
4382
4550
  if (opts.patch?.branch && opts.patch.branch !== current.branch) {
4383
4551
  const existing = await this.findTaskByBranch(opts.patch.branch, current.projectId);
4384
4552
  if (existing && existing.id !== taskId)
@@ -4399,6 +4567,7 @@ export class AgentManager {
4399
4567
  ? 'review'
4400
4568
  : 'recheck');
4401
4569
  const next = this.stripPostApproveEpisodeFields(current);
4570
+ this.stripGitVerdictOutbox(next);
4402
4571
  delete next.passProvenance;
4403
4572
  Object.assign(next, opts.patch ?? {}, {
4404
4573
  status: 'review',
@@ -4468,6 +4637,7 @@ export class AgentManager {
4468
4637
  return null;
4469
4638
  const now = new Date().toISOString();
4470
4639
  const next = this.stripPostApproveEpisodeFields(current);
4640
+ this.stripGitVerdictOutbox(next);
4471
4641
  delete next.reviewDispatch;
4472
4642
  delete next.reviewRoundPending;
4473
4643
  const postApproveGeneration = createSignalToken();
@@ -4567,6 +4737,7 @@ export class AgentManager {
4567
4737
  },
4568
4738
  updatedAt: now,
4569
4739
  };
4740
+ this.stripCodeVerdictOutboxForAnchor(next, verifiedHeadSha);
4570
4741
  await this.taskStore.set(next);
4571
4742
  if (qaHold !== undefined) {
4572
4743
  const cleared = await this.clearAwaitingHuman(qaId, {
@@ -4612,6 +4783,9 @@ export class AgentManager {
4612
4783
  }
4613
4784
  next.reviewDispatchedAt = now;
4614
4785
  delete next.reviewDispatch;
4786
+ if (next.attention?.reason === 'git-review-dispatch-recovery-uncertain') {
4787
+ delete next.attention;
4788
+ }
4615
4789
  await this.taskStore.set(next);
4616
4790
  return true;
4617
4791
  });
@@ -5100,20 +5274,28 @@ export class AgentManager {
5100
5274
  return next;
5101
5275
  });
5102
5276
  }
5103
- async noteReviewConversationRevision(taskId) {
5277
+ async noteReviewConversationRevision(taskId, conversation) {
5104
5278
  await this.withTaskLock(async () => {
5105
5279
  const task = await this.taskStore.get(taskId);
5106
5280
  if (!task || TERMINAL_STATUSES.includes(task.status))
5107
5281
  return;
5108
5282
  const now = new Date().toISOString();
5109
- await this.taskStore.set({ ...task, reviewConversationUpdatedAt: now, updatedAt: now });
5283
+ const next = { ...task, reviewConversationUpdatedAt: now, updatedAt: now };
5284
+ const repoKey = next.platformBinding?.repoKey;
5285
+ if (conversation !== undefined
5286
+ && this.prConversationCache !== undefined
5287
+ && repoKey !== undefined
5288
+ && next.prNumber === conversation.prNumber) {
5289
+ this.prConversationCache.put(next.id, prReviewCacheRevision(next, repoKey), conversation.payload, next.reviewConversationUpdatedAt ?? '');
5290
+ }
5291
+ await this.taskStore.set(next);
5110
5292
  });
5111
5293
  }
5112
5294
  assertPlatformBinding(task) {
5113
5295
  const mismatch = platformBindingMismatch(this.config, task);
5114
5296
  if (mismatch === undefined)
5115
5297
  return;
5116
- void this.emitPlatformBindingIntervention(task, mismatch);
5298
+ void this.outsideTaskMutationScope(() => this.emitPlatformBindingIntervention(task, mismatch));
5117
5299
  if (mismatch.reason === 'missing-binding-snapshot') {
5118
5300
  throw new Error(`platform binding missing for task ${task.id}; refusing platform operations`);
5119
5301
  }
@@ -5650,8 +5832,12 @@ export class AgentManager {
5650
5832
  return null;
5651
5833
  if (task.prNumber !== undefined && task.prNumber !== observation.prNumber)
5652
5834
  return null;
5835
+ const repairsDeliveryConfirmation = confirmation?.source === 'human'
5836
+ && (task.status === 'review' || task.status === 'fixing')
5837
+ && Object.hasOwn(confirmation, 'expectedSignalToken');
5653
5838
  if (confirmation !== undefined) {
5654
5839
  if (task.status !== 'in_progress'
5840
+ && !repairsDeliveryConfirmation
5655
5841
  && (task.phase !== confirmation.phase
5656
5842
  || task.deliveryConfirmation?.phase !== confirmation.phase))
5657
5843
  return null;
@@ -5678,7 +5864,7 @@ export class AgentManager {
5678
5864
  ...(confirmation !== undefined
5679
5865
  ? {
5680
5866
  phase: confirmation.phase,
5681
- deliveryConfirmation: task.status === 'in_progress'
5867
+ deliveryConfirmation: task.status === 'in_progress' || repairsDeliveryConfirmation
5682
5868
  ? { phase: confirmation.phase, source: confirmation.source, at: now }
5683
5869
  : task.deliveryConfirmation,
5684
5870
  ...(confirmation.branch !== undefined ? { branch: confirmation.branch } : {}),
@@ -5930,13 +6116,13 @@ export class AgentManager {
5930
6116
  }
5931
6117
  const genAtEntry = this.deletionGenerationOf(input.preferredAgentId);
5932
6118
  const target = await this.pickAgent(projectId, input.preferredAgentId);
5933
- const group = this.findAgentGroup(input.preferredAgentId);
5934
- const dev = group?.find(agent => agent.role === 'dev');
6119
+ const team = this.findAgentTeam(input.preferredAgentId);
6120
+ const dev = team?.find(agent => agent.role === 'dev');
5935
6121
  if (!dev)
5936
- throw new ApiError(409, `Agent ${input.preferredAgentId} has no dev agent in its group`);
5937
- const qa = group?.find(agent => agent.role === 'qa');
6122
+ throw new ApiError(409, `Agent ${input.preferredAgentId} has no dev agent in its Agent Team`);
6123
+ const qa = team?.find(agent => agent.role === 'qa');
5938
6124
  if (!qa)
5939
- throw new ApiError(409, `Agent ${input.preferredAgentId} has no qa agent in its group`);
6125
+ throw new ApiError(409, `Agent ${input.preferredAgentId} has no qa agent in its Agent Team`);
5940
6126
  const participantGens = new Map([dev.id, qa.id]
5941
6127
  .map(id => [id, this.deletionGenerationOf(id)]));
5942
6128
  const participantsFresh = () => this.deletionGenerationOf(input.preferredAgentId) === genAtEntry
@@ -5951,7 +6137,7 @@ export class AgentManager {
5951
6137
  };
5952
6138
  const persistQueuedOrReject = (reason) => {
5953
6139
  if (!participantsFresh()) {
5954
- throw new ApiError(409, `Agent ${input.preferredAgentId} or a group member is being deleted or recreated during task creation; please retry`);
6140
+ throw new ApiError(409, `Agent ${input.preferredAgentId} or an Agent Team member is being deleted or recreated during task creation; please retry`);
5955
6141
  }
5956
6142
  return this.persistQueuedTask(queued, reason, input.preferredAgentId);
5957
6143
  };
@@ -6061,6 +6247,9 @@ export class AgentManager {
6061
6247
  }
6062
6248
  async createAndStartTask(projectId, input, opts = {}) {
6063
6249
  const task = await this.createTask(projectId, input);
6250
+ return this.startCreatedTask(task, opts);
6251
+ }
6252
+ async startCreatedTask(task, opts = {}) {
6064
6253
  if (task.status === 'in_progress' && task.agentId) {
6065
6254
  const signalToken = createSignalToken();
6066
6255
  await this.updateTask(task.id, this.dispatchTokenFields(signalToken));
@@ -6238,7 +6427,7 @@ export class AgentManager {
6238
6427
  await tmux.sendKeysLiteral(pane, command);
6239
6428
  await tmux.sendEnter(pane);
6240
6429
  guardHandedOff = true;
6241
- void this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.compactIdleWaitMs)
6430
+ void this.outsideTaskMutationScope(() => this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.compactIdleWaitMs))
6242
6431
  .catch(err => {
6243
6432
  console.warn(`[AgentManager] sendSlashCommand(${agentId}, ${command}) post idle wait failed:`, err);
6244
6433
  })
@@ -6281,6 +6470,16 @@ export class AgentManager {
6281
6470
  clearPendingDispatchRetry(taskId) {
6282
6471
  this.pendingDispatchRetryByTask.delete(taskId);
6283
6472
  }
6473
+ resetPendingDispatchRetryBudget(taskId) {
6474
+ const entry = this.pendingDispatchRetryByTask.get(taskId);
6475
+ if (!entry)
6476
+ return;
6477
+ this.pendingDispatchRetryByTask.set(taskId, {
6478
+ ...entry,
6479
+ since: Date.now(),
6480
+ budgetAlerted: undefined,
6481
+ });
6482
+ }
6284
6483
  clearPendingDispatchRetryIfMatches(taskId, expected) {
6285
6484
  const entry = this.pendingDispatchRetryByTask.get(taskId);
6286
6485
  if (entry?.agentId === expected.agentId && entry.signalToken === expected.signalToken) {
@@ -6450,20 +6649,20 @@ export class AgentManager {
6450
6649
  }
6451
6650
  const now = new Date().toISOString();
6452
6651
  const initiallyUnassigned = fresh.preferredAgentId === '';
6453
- const group = initiallyUnassigned ? this.findAgentGroup(agentId) : undefined;
6652
+ const team = initiallyUnassigned ? this.findAgentTeam(agentId) : undefined;
6454
6653
  const devId = initiallyUnassigned
6455
- ? group?.find(agent => agent.role === 'dev')?.id
6654
+ ? team?.find(agent => agent.role === 'dev')?.id
6456
6655
  : fresh.devAgentId;
6457
6656
  if (!devId) {
6458
6657
  await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
6459
- return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no dev agent in its group` };
6658
+ return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no dev agent in its Agent Team` };
6460
6659
  }
6461
6660
  const qaId = initiallyUnassigned
6462
- ? group?.find(agent => agent.role === 'qa')?.id
6661
+ ? team?.find(agent => agent.role === 'qa')?.id
6463
6662
  : fresh.qaAgentId;
6464
6663
  if (!qaId) {
6465
6664
  await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
6466
- return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no qa agent in its group` };
6665
+ return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no qa agent in its Agent Team` };
6467
6666
  }
6468
6667
  const participantGens = new Map([devId, qaId]
6469
6668
  .map(pid => [pid, this.deletionGenerationOf(pid)]));
@@ -7780,10 +7979,10 @@ export class AgentManager {
7780
7979
  if (await this.rollbackUndeliveredBootstrap(state))
7781
7980
  continue;
7782
7981
  await this.markDialogPending(state.id, state.creationToken);
7783
- void this.slowPollDialogPending(state.id, state.creationToken, {
7982
+ void this.outsideTaskMutationScope(() => this.slowPollDialogPending(state.id, state.creationToken, {
7784
7983
  ...(state.paneId !== undefined ? { expectedPaneId: state.paneId } : {}),
7785
7984
  expectedTaskId: state.taskId,
7786
- }).catch((pollErr) => {
7985
+ })).catch((pollErr) => {
7787
7986
  console.warn(`[recover] slowPoll for ${state.id} crashed:`, pollErr);
7788
7987
  });
7789
7988
  continue;
@@ -8369,6 +8568,169 @@ export class AgentManager {
8369
8568
  return dispatched;
8370
8569
  }
8371
8570
  }
8571
+ async advanceTask(taskId, opts = {}) {
8572
+ let task = await this.taskStore.get(taskId);
8573
+ if (!task)
8574
+ throw new ApiError(404, `Task ${taskId} not found`);
8575
+ const executor = opts.executor ?? (task.status === 'review' ? 'qa' : 'dev');
8576
+ let updated;
8577
+ if (task.status === 'pending') {
8578
+ if (executor !== 'dev')
8579
+ throw new ApiError(409, 'Pending tasks can only advance to Dev');
8580
+ const result = await this.dispatchPendingTask(taskId, opts.agentId);
8581
+ if (result.errorCode !== undefined)
8582
+ throw new ApiError(result.errorCode, result.error ?? 'Dispatch failed');
8583
+ if (!result.task)
8584
+ throw new ApiError(404, `Task ${taskId} disappeared during dispatch`);
8585
+ updated = result.task;
8586
+ }
8587
+ else if (executor === 'qa') {
8588
+ if (task.status !== 'in_progress' && task.status !== 'fixing' && task.status !== 'review') {
8589
+ throw new ApiError(409, `Task ${taskId} cannot advance to QA from ${task.status}`);
8590
+ }
8591
+ updated = await this.dispatchReviewToQa(taskId, {
8592
+ fromStatus: [task.status],
8593
+ confirmUncertainNotDelivered: true,
8594
+ ...(opts.stage !== undefined ? { stage: opts.stage } : {}),
8595
+ ...(opts.actorId !== undefined ? { actorId: opts.actorId } : {}),
8596
+ ...(opts.prNumber !== undefined ? { prNumber: opts.prNumber } : {}),
8597
+ });
8598
+ }
8599
+ else if (task.status === 'review') {
8600
+ throw new ApiError(409, 'Review cannot advance to Dev without a request-changes verdict');
8601
+ }
8602
+ else if (task.status === 'in_progress' || task.status === 'fixing') {
8603
+ if (!task.devAgentId)
8604
+ throw new ApiError(409, `Task ${taskId} has no Dev participant`);
8605
+ const replayed = await this.redispatchTaskPromptAfterReplRestart(task.devAgentId, taskId);
8606
+ if (!replayed)
8607
+ throw new ApiError(409, `Task ${taskId} changed or its Dev prompt could not be replayed`);
8608
+ updated = (await this.taskStore.get(taskId)) ?? task;
8609
+ }
8610
+ else if (task.status === 'approved') {
8611
+ if (!task.devAgentId)
8612
+ throw new ApiError(409, `Task ${taskId} has no Dev participant`);
8613
+ if (task.postApproveRevoked) {
8614
+ if (opts.confirmRevoked !== true) {
8615
+ throw new ApiError(409, `Task ${taskId} post-approve replay is revoked; confirmRevoked=true is required`);
8616
+ }
8617
+ task = await this.restoreRevokedPostApprove(task);
8618
+ }
8619
+ const replayed = await this.redispatchTaskPromptAfterReplRestart(task.devAgentId, taskId);
8620
+ if (!replayed)
8621
+ throw new ApiError(409, `Task ${taskId} changed or its post-approve prompt could not be replayed`);
8622
+ updated = (await this.taskStore.get(taskId)) ?? task;
8623
+ }
8624
+ else {
8625
+ throw new ApiError(409, `Task ${taskId} cannot be advanced from ${task.status}`);
8626
+ }
8627
+ await this.auditHumanTaskOperation(updated, 'advance', executor, opts.note);
8628
+ return updated;
8629
+ }
8630
+ async restoreRevokedPostApprove(task) {
8631
+ if (!task.postApproveRevoked || task.prNumber === undefined) {
8632
+ throw new ApiError(409, `Task ${task.id} has no revoked post-approve episode`);
8633
+ }
8634
+ const [binding, accepted] = await Promise.all([
8635
+ this.platformVerifyPrBinding(task.id, task.prNumber),
8636
+ this.platformVerifyAcceptedPass(task.id),
8637
+ ]);
8638
+ if (!binding.ok) {
8639
+ throw new ApiError(409, `Cannot restore task ${task.id}: PR binding ${binding.reason}`);
8640
+ }
8641
+ if (accepted.kind !== 'valid') {
8642
+ throw new ApiError(409, `Cannot restore task ${task.id}: ${accepted.reason}`);
8643
+ }
8644
+ if (accepted.pendingCount > 0) {
8645
+ throw new ApiError(409, `Cannot restore task ${task.id}: the accepted pass has pending feedback`);
8646
+ }
8647
+ const reviewedPass = task.passProvenance;
8648
+ if (!reviewedPass || binding.headSha.toLowerCase() !== reviewedPass.anchorSha.toLowerCase()) {
8649
+ throw new ApiError(409, `Cannot restore task ${task.id}: the current PR head is not the accepted head`);
8650
+ }
8651
+ return this.withTaskLock(async () => {
8652
+ const fresh = await this.taskStore.get(task.id);
8653
+ if (!fresh || fresh.status !== 'approved'
8654
+ || fresh.postApproveRevoked?.generation !== task.postApproveRevoked.generation
8655
+ || fresh.prNumber !== task.prNumber
8656
+ || !samePassProvenance(fresh.passProvenance, reviewedPass)) {
8657
+ throw new ApiError(409, `Task ${task.id} changed while restoring post-approve replay`);
8658
+ }
8659
+ const generation = createSignalToken();
8660
+ const token = createSignalTokenExcluding(generation);
8661
+ const next = {
8662
+ ...this.stripPostApproveEpisodeFields(fresh),
8663
+ postApproveGeneration: generation,
8664
+ postApproveHeadSha: binding.headSha,
8665
+ postApproveToken: token,
8666
+ postApprovePhase: 'installed',
8667
+ pendingRedispatch: true,
8668
+ redispatchCount: 0,
8669
+ updatedAt: new Date().toISOString(),
8670
+ };
8671
+ await this.taskStore.set(next);
8672
+ return next;
8673
+ });
8674
+ }
8675
+ async submitTaskVerdict(taskId, action, comments, note) {
8676
+ const task = await this.taskStore.get(taskId);
8677
+ if (!task)
8678
+ throw new ApiError(404, `Task ${taskId} not found`);
8679
+ let updated;
8680
+ if (task.status === 'spec-ready'
8681
+ || (task.status === 'max_rounds' && isSpecStagePhase(task.phase))) {
8682
+ if (action !== 'approve' && action !== 'request-changes') {
8683
+ throw new ApiError(409, `Task ${taskId} requires a spec approve or request-changes verdict`);
8684
+ }
8685
+ updated = await this.submitSpecVerdict(taskId, action, comments);
8686
+ }
8687
+ else if (task.status === 'review' && !isSpecStagePhase(task.phase)) {
8688
+ if (action !== 'pass' && action !== 'request-changes') {
8689
+ throw new ApiError(409, `Task ${taskId} requires a code pass or request-changes verdict`);
8690
+ }
8691
+ updated = await this.submitCodeVerdict(taskId, action, comments);
8692
+ }
8693
+ else if (task.status === 'max_rounds') {
8694
+ if (action === 'continue')
8695
+ updated = await this.continueDevRound(taskId);
8696
+ else if (action === 'complete')
8697
+ updated = await this.markTaskComplete(taskId);
8698
+ else
8699
+ throw new ApiError(409, `Task ${taskId} requires continue or complete at code max_rounds`);
8700
+ }
8701
+ else if (task.status === 'merge-ready') {
8702
+ if (action !== 'confirm-merge') {
8703
+ throw new ApiError(409, `Task ${taskId} requires confirm-merge`);
8704
+ }
8705
+ updated = await this.markTaskComplete(taskId);
8706
+ }
8707
+ else {
8708
+ throw new ApiError(409, `Task ${taskId} cannot accept a verdict from ${task.status}`);
8709
+ }
8710
+ await this.auditHumanTaskOperation(updated, 'verdict', action, note);
8711
+ return updated;
8712
+ }
8713
+ async auditHumanTaskOperation(task, operation, action, note, details = {}) {
8714
+ const emitted = await this.safeEmit({
8715
+ id: '',
8716
+ type: 'task.updated',
8717
+ timestamp: new Date().toISOString(),
8718
+ projectId: task.projectId,
8719
+ ...(task.agentId ? { agentId: task.agentId } : {}),
8720
+ taskId: task.id,
8721
+ data: {
8722
+ ...details,
8723
+ operation,
8724
+ action,
8725
+ actor: 'human',
8726
+ provenance: 'human',
8727
+ note: note?.trim() || `${operation}:${action}`,
8728
+ },
8729
+ });
8730
+ if (!emitted) {
8731
+ throw new ApiError(500, `Task ${task.id} ${operation} completed, but its audit event could not be persisted; verify the task state before retrying`);
8732
+ }
8733
+ }
8372
8734
  async dispatchGitFixToDev(taskId) {
8373
8735
  const task = await this.taskStore.get(taskId);
8374
8736
  if (!task || task.status !== 'fixing')
@@ -8461,7 +8823,7 @@ export class AgentManager {
8461
8823
  const devState = await this.agentStore.get(devAgentId);
8462
8824
  if (devState?.taskId !== taskId || !devState.workdir) {
8463
8825
  throw new ApiError(409, `Dev ${devAgentId} no longer holds task ${taskId}'s Workdir (cannot continue); ` +
8464
- `use mark-complete to merge the PR as-is, or cancel the task`);
8826
+ `submit a complete verdict to merge the PR as-is, or cancel the task`);
8465
8827
  }
8466
8828
  const prevReviewRound = task.reviewRound;
8467
8829
  const transitioned = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['max_rounds'] }, { reviewRound: prevReviewRound + 1, fixDispatchedAt: new Date().toISOString() });
@@ -8553,26 +8915,53 @@ export class AgentManager {
8553
8915
  const t = await this.taskStore.get(taskId);
8554
8916
  if (!t)
8555
8917
  throw new ApiError(404, 'Task not found');
8556
- const retryable = TERMINAL_STATUSES.includes(t.status)
8557
- || (t.status === 'max_rounds' && isSpecStagePhase(t.phase));
8558
- if (!retryable) {
8918
+ if (!TERMINAL_STATUSES.includes(t.status)) {
8559
8919
  throw new ApiError(409, `Task ${taskId} cannot be retried in status "${t.status}"; cancel it first or wait for completion`);
8560
8920
  }
8921
+ if (t.replacementTaskId !== undefined) {
8922
+ throw new ApiError(409, `Task ${taskId} was already retried as ${t.replacementTaskId}`);
8923
+ }
8924
+ if (this.retryTaskInFlight.has(taskId)) {
8925
+ throw new ApiError(409, `Task ${taskId} retry is already in progress`);
8926
+ }
8927
+ this.retryTaskInFlight.add(taskId);
8561
8928
  return t;
8562
8929
  });
8563
- const input = {
8564
- title: old.title,
8565
- description: old.description,
8566
- preferredAgentId: this.resolveRetryPreferredAgentId(old),
8567
- };
8568
- if (old.images?.length) {
8569
- input.images = await this.readStagedImages(old.id, old.images);
8930
+ try {
8931
+ const input = {
8932
+ title: old.title,
8933
+ description: old.description,
8934
+ preferredAgentId: this.resolveRetryPreferredAgentId(old),
8935
+ };
8936
+ if (old.images?.length) {
8937
+ input.images = await this.readStagedImages(old.id, old.images);
8938
+ }
8939
+ await this.validateTaskDispatch(old.projectId, input);
8940
+ const replacement = await this.createTask(old.projectId, input);
8941
+ await this.withTaskLock(async () => {
8942
+ const source = await this.taskStore.get(taskId);
8943
+ if (!source)
8944
+ throw new ApiError(404, `Task ${taskId} disappeared while recording its retry`);
8945
+ if (source.replacementTaskId !== undefined && source.replacementTaskId !== replacement.id) {
8946
+ throw new ApiError(409, `Task ${taskId} was already retried as ${source.replacementTaskId}`);
8947
+ }
8948
+ const next = {
8949
+ ...source,
8950
+ replacementTaskId: replacement.id,
8951
+ updatedAt: new Date().toISOString(),
8952
+ };
8953
+ if (old.attention !== undefined
8954
+ && source.attention?.reason === old.attention.reason
8955
+ && source.attention.occurredAt === old.attention.occurredAt) {
8956
+ delete next.attention;
8957
+ }
8958
+ await this.taskStore.set(next);
8959
+ });
8960
+ return this.startCreatedTask(replacement);
8570
8961
  }
8571
- await this.validateTaskDispatch(old.projectId, input);
8572
- if (!TERMINAL_STATUSES.includes(old.status)) {
8573
- await this.cancelTask(old.id);
8962
+ finally {
8963
+ this.retryTaskInFlight.delete(taskId);
8574
8964
  }
8575
- return this.createAndStartTask(old.projectId, input);
8576
8965
  }
8577
8966
  resolveRetryPreferredAgentId(task) {
8578
8967
  const configured = this.getAgentConfig(task.preferredAgentId);
@@ -8582,7 +8971,7 @@ export class AgentManager {
8582
8971
  const anchors = new Set([task.qaAgentId, task.devAgentId, task.agentId]
8583
8972
  .filter((agentId) => typeof agentId === 'string' && agentId !== ''));
8584
8973
  for (const anchor of anchors) {
8585
- const dev = this.findAgentGroup(anchor)?.find(agent => TASK_OWNER_ROLES.has(agent.role));
8974
+ const dev = this.findAgentTeam(anchor)?.find(agent => TASK_OWNER_ROLES.has(agent.role));
8586
8975
  const current = dev ? this.getAgentConfig(dev.id) : undefined;
8587
8976
  if (current?.projectId === task.projectId)
8588
8977
  return current.id;
@@ -8624,13 +9013,13 @@ export class AgentManager {
8624
9013
  }
8625
9014
  if (!TASK_OWNER_ROLES.has(cfg.role))
8626
9015
  throw new ApiError(400, 'Agent is not a dev agent');
8627
- const group = this.findAgentGroup(cfg.id);
8628
- const dev = group?.find(agent => agent.role === 'dev');
9016
+ const team = this.findAgentTeam(cfg.id);
9017
+ const dev = team?.find(agent => agent.role === 'dev');
8629
9018
  if (!dev)
8630
- throw new ApiError(409, `Agent ${cfg.id} has no dev agent in its group`);
8631
- const qa = group?.find(agent => agent.role === 'qa');
9019
+ throw new ApiError(409, `Agent ${cfg.id} has no dev agent in its Agent Team`);
9020
+ const qa = team?.find(agent => agent.role === 'qa');
8632
9021
  if (!qa)
8633
- throw new ApiError(409, `Agent ${cfg.id} has no qa agent in its group`);
9022
+ throw new ApiError(409, `Agent ${cfg.id} has no qa agent in its Agent Team`);
8634
9023
  task.preferredAgentId = patch.preferredAgentId;
8635
9024
  task.devAgentId = dev.id;
8636
9025
  task.qaAgentId = qa.id;
@@ -8668,22 +9057,22 @@ export class AgentManager {
8668
9057
  throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
8669
9058
  }
8670
9059
  if (isSpecStagePhase(task.phase)) {
8671
- throw new ApiError(409, `Mark complete is only supported for code-phase tasks`);
9060
+ throw new ApiError(409, `Complete verdict is only supported for code-phase tasks`);
8672
9061
  }
8673
9062
  if (!task.prNumber || !task.branch) {
8674
- throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot mark complete`);
9063
+ throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot complete`);
8675
9064
  }
8676
9065
  for (const agentId of [task.agentId, task.qaAgentId]) {
8677
9066
  if (!agentId)
8678
9067
  continue;
8679
9068
  const state = await this.agentStore.get(agentId);
8680
9069
  if (state?.status === 'awaiting_human' && state.taskId === taskId) {
8681
- throw new ApiError(409, `Agent ${agentId} is awaiting human intervention on this task; resume/restart/delete it before marking complete`);
9070
+ throw new ApiError(409, `Agent ${agentId} is awaiting human intervention on this task; resume/restart/delete it before completing`);
8682
9071
  }
8683
9072
  }
8684
9073
  const claimed = await this.transitionTaskStatus(taskId, 'merge-ready', { fromStatus: ['max_rounds'] });
8685
9074
  if (!claimed) {
8686
- throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
9075
+ throw new ApiError(409, `Task ${taskId} changed status during complete verdict; aborted`);
8687
9076
  }
8688
9077
  try {
8689
9078
  await this.mergePr(taskId, { humanOverride: true });
@@ -9193,6 +9582,247 @@ export class AgentManager {
9193
9582
  }
9194
9583
  return parked;
9195
9584
  }
9585
+ async submitCodeVerdict(taskId, verdict, comments) {
9586
+ const trimmed = comments?.trim() ?? '';
9587
+ if (verdict === 'request-changes' && !trimmed) {
9588
+ throw new ApiError(400, 'comments is required for request-changes');
9589
+ }
9590
+ if (extractReviewTokens(trimmed).length > 0) {
9591
+ throw new ApiError(400, 'comments must not contain baxian review markers');
9592
+ }
9593
+ const task = await this.withTaskLock(async () => {
9594
+ const fresh = await this.taskStore.get(taskId);
9595
+ if (!fresh)
9596
+ throw new ApiError(404, `Task ${taskId} not found`);
9597
+ const pending = this.codeVerdictOutbox(fresh);
9598
+ const kind = verdict === 'pass' ? 'pass' : 'fail';
9599
+ if (pending !== undefined
9600
+ && (pending.data.kind !== kind || pending.data.comments !== trimmed)) {
9601
+ throw new ApiError(409, `Task ${taskId} has a different code verdict already in progress`);
9602
+ }
9603
+ if (pending === undefined && (fresh.status !== 'review' || isSpecStagePhase(fresh.phase))) {
9604
+ throw new ApiError(409, `Task ${taskId} is not awaiting a code review verdict`);
9605
+ }
9606
+ if (this.codeVerdictInFlight.has(taskId)) {
9607
+ throw new ApiError(409, `Task ${taskId} code verdict is already being processed`);
9608
+ }
9609
+ this.codeVerdictInFlight.add(taskId);
9610
+ return fresh;
9611
+ });
9612
+ try {
9613
+ return await this.executeCodeVerdict(task, verdict, trimmed);
9614
+ }
9615
+ finally {
9616
+ this.codeVerdictInFlight.delete(taskId);
9617
+ }
9618
+ }
9619
+ codeVerdictCommentBody(entry) {
9620
+ const lines = [
9621
+ entry.data.kind === 'pass'
9622
+ ? 'Human code verdict: pass'
9623
+ : 'Human code verdict: request changes',
9624
+ ];
9625
+ if (entry.data.comments)
9626
+ lines.push('', entry.data.comments);
9627
+ lines.push('', buildReviewTokenLine({
9628
+ kind: entry.data.kind,
9629
+ anchorSha: entry.data.anchorSha,
9630
+ token: entry.data.token,
9631
+ }));
9632
+ const body = lines.join('\n');
9633
+ try {
9634
+ validateCommentBody(body);
9635
+ }
9636
+ catch (err) {
9637
+ throw new ApiError(400, `Invalid code verdict comment: ${err instanceof Error ? err.message : String(err)}`);
9638
+ }
9639
+ return body;
9640
+ }
9641
+ async scanCodeVerdictMarker(driver, entry, oppositeToken) {
9642
+ const scans = await this.platformScanComments(driver, entry.data.prNumber);
9643
+ if (scans.some(scan => !scan.ok))
9644
+ return 'unknown';
9645
+ const dead = deadTokens(scans);
9646
+ if (dead.has(entry.data.token))
9647
+ return 'dead';
9648
+ let present = false;
9649
+ let opposite = false;
9650
+ for (const scan of scans) {
9651
+ for (const row of scan.rows) {
9652
+ if (row.system === true || versionTimeOf(row) === undefined)
9653
+ continue;
9654
+ for (const marker of rowTokens(row)) {
9655
+ if (dead.has(marker.token))
9656
+ continue;
9657
+ if (marker.anchorSha.toLowerCase() !== entry.data.anchorSha.toLowerCase())
9658
+ continue;
9659
+ if (marker.kind === entry.data.kind && marker.token === entry.data.token)
9660
+ present = true;
9661
+ if (marker.kind !== entry.data.kind && marker.token === oppositeToken)
9662
+ opposite = true;
9663
+ }
9664
+ }
9665
+ }
9666
+ if (opposite)
9667
+ return 'opposite';
9668
+ return present ? 'present' : 'absent';
9669
+ }
9670
+ async reconcileCodeVerdictComment(driver, entry) {
9671
+ const expectedDigest = bodyDigest(this.codeVerdictCommentBody(entry));
9672
+ const scans = await this.platformScanComments(driver, entry.data.prNumber);
9673
+ if (scans.some(scan => !scan.ok))
9674
+ return 'unknown';
9675
+ if (deadTokens(scans).has(entry.data.token))
9676
+ return 'dead';
9677
+ if (scans.some(scan => scan.rows.some(row => row.system !== true
9678
+ && versionTimeOf(row) !== undefined
9679
+ && row.bodyDigest === expectedDigest)))
9680
+ return 'present';
9681
+ return 'absent';
9682
+ }
9683
+ async verifyCodeVerdictHead(taskId, prNumber, anchorSha) {
9684
+ try {
9685
+ const binding = await this.platformVerifyPrBinding(taskId, prNumber);
9686
+ if (!binding.ok)
9687
+ return { ok: false, reason: `the PR binding is ${binding.reason}` };
9688
+ if (binding.headSha.toLowerCase() !== anchorSha.toLowerCase()) {
9689
+ return { ok: false, reason: 'the PR head changed' };
9690
+ }
9691
+ return { ok: true };
9692
+ }
9693
+ catch (err) {
9694
+ throw new ApiError(503, `Cannot verify code verdict head for task ${taskId}: ${safeDriverErrorText(err)}`);
9695
+ }
9696
+ }
9697
+ async deliverCodeVerdictOutbox(taskId, key) {
9698
+ let task = await this.taskStore.get(taskId);
9699
+ let entry = task ? this.codeVerdictOutbox(task) : undefined;
9700
+ if (!task || !entry || entry.key !== key)
9701
+ return;
9702
+ const driver = this.platformDriverFor(task.projectId);
9703
+ if (!driver)
9704
+ throw new ApiError(503, `Git driver unavailable for task ${task.id}`);
9705
+ const binding = await this.verifyCodeVerdictHead(task.id, entry.data.prNumber, entry.data.anchorSha);
9706
+ if (!binding.ok) {
9707
+ await this.mutateTaskOutboxEntry(task.id, key, current => current.type === 'git.code-verdict' ? undefined : null);
9708
+ throw new ApiError(409, `Cannot write code verdict for task ${task.id}: ${binding.reason}`);
9709
+ }
9710
+ if (entry.data.writeAttemptedAt !== undefined) {
9711
+ const reconciled = await this.reconcileCodeVerdictComment(driver, entry);
9712
+ if (reconciled === 'dead') {
9713
+ await this.mutateTaskOutboxEntry(task.id, key, current => current.type === 'git.code-verdict' ? undefined : null);
9714
+ throw new ApiError(409, `Code verdict token for task ${task.id} was dismissed; advance QA to create a new review pass`);
9715
+ }
9716
+ if (reconciled === 'present') {
9717
+ return;
9718
+ }
9719
+ if (reconciled === 'unknown'
9720
+ || Date.now() - Date.parse(entry.data.writeAttemptedAt) < driver.visibilityLagMs) {
9721
+ throw new ApiError(503, `Code verdict comment outcome is still being reconciled for task ${task.id}`);
9722
+ }
9723
+ }
9724
+ const expectedAttempt = entry.data.writeAttemptedAt;
9725
+ const claimed = await this.mutateTaskOutboxEntry(task.id, entry.key, current => current.type === 'git.code-verdict' && current.data.writeAttemptedAt === expectedAttempt
9726
+ ? { ...current, data: { ...current.data, writeAttemptedAt: new Date().toISOString() } }
9727
+ : null);
9728
+ const claimedEntry = claimed ? this.codeVerdictOutbox(claimed) : undefined;
9729
+ if (!claimed || claimedEntry?.key !== entry.key) {
9730
+ throw new ApiError(503, `Code verdict comment write is already claimed for task ${task.id}`);
9731
+ }
9732
+ task = claimed;
9733
+ entry = claimedEntry;
9734
+ let writeFailure;
9735
+ try {
9736
+ await driver.runOp('comment', {
9737
+ prNumber: entry.data.prNumber,
9738
+ body: this.codeVerdictCommentBody(entry),
9739
+ });
9740
+ }
9741
+ catch (error) {
9742
+ writeFailure = { error };
9743
+ }
9744
+ const reconciled = await this.reconcileCodeVerdictComment(driver, entry);
9745
+ if (reconciled === 'dead') {
9746
+ await this.mutateTaskOutboxEntry(task.id, key, current => current.type === 'git.code-verdict' ? undefined : null);
9747
+ throw new ApiError(409, `Code verdict token for task ${task.id} was dismissed; advance QA to create a new review pass`);
9748
+ }
9749
+ if (writeFailure !== undefined && reconciled !== 'present') {
9750
+ const { error } = writeFailure;
9751
+ const uncertain = !(error instanceof DriverOpError)
9752
+ || error.info.exitCode === 255
9753
+ || isTransientNetworkFailure(error.info.stderrTail ?? '')
9754
+ || isTransientNetworkFailure(error.message);
9755
+ if (reconciled === 'unknown' || uncertain) {
9756
+ throw new ApiError(503, `Code verdict comment outcome is unknown for task ${task.id}; the persisted outbox will reconcile it`);
9757
+ }
9758
+ await this.mutateTaskOutboxEntry(task.id, key, current => current.type === 'git.code-verdict' ? undefined : null);
9759
+ throw new ApiError(502, `Code verdict comment was rejected for task ${task.id}: ${safeDriverErrorText(error)}`);
9760
+ }
9761
+ if (reconciled !== 'present') {
9762
+ throw new ApiError(503, `Code verdict comment is not yet visible for task ${task.id}; the persisted outbox will reconcile it`);
9763
+ }
9764
+ }
9765
+ async executeCodeVerdict(task, verdict, comments) {
9766
+ if (task.status !== 'review' || isSpecStagePhase(task.phase)
9767
+ || task.prNumber === undefined || task.reviewHeadAnchorSha === undefined
9768
+ || task.passToken === undefined || task.failToken === undefined) {
9769
+ throw new ApiError(409, `Task ${task.id} has no complete code review generation`);
9770
+ }
9771
+ const kind = verdict === 'pass' ? 'pass' : 'fail';
9772
+ const token = kind === 'pass' ? task.passToken : task.failToken;
9773
+ const existingEntry = this.codeVerdictOutbox(task);
9774
+ const entry = existingEntry ?? {
9775
+ key: token,
9776
+ type: 'git.code-verdict',
9777
+ data: {
9778
+ prNumber: task.prNumber,
9779
+ kind,
9780
+ anchorSha: task.reviewHeadAnchorSha,
9781
+ token,
9782
+ comments,
9783
+ },
9784
+ };
9785
+ this.codeVerdictCommentBody(entry);
9786
+ const driver = this.platformDriverFor(task.projectId);
9787
+ if (!driver)
9788
+ throw new ApiError(503, `Git driver unavailable for task ${task.id}`);
9789
+ const binding = await this.verifyCodeVerdictHead(task.id, task.prNumber, task.reviewHeadAnchorSha);
9790
+ if (!binding.ok) {
9791
+ throw new ApiError(409, `Cannot submit code verdict for task ${task.id}: ${binding.reason}`);
9792
+ }
9793
+ if (existingEntry === undefined) {
9794
+ const marker = await this.scanCodeVerdictMarker(driver, entry, kind === 'pass' ? task.failToken : task.passToken);
9795
+ if (marker === 'present')
9796
+ return task;
9797
+ if (marker === 'opposite') {
9798
+ throw new ApiError(409, `Task ${task.id} already has the opposite verdict on the current review head`);
9799
+ }
9800
+ if (marker === 'dead') {
9801
+ throw new ApiError(409, `Code verdict token for task ${task.id} was dismissed; advance QA to create a new review pass`);
9802
+ }
9803
+ if (marker === 'unknown') {
9804
+ throw new ApiError(503, `Cannot safely check existing code verdict comments for task ${task.id}`);
9805
+ }
9806
+ const created = await this.withTaskLock(async () => {
9807
+ const fresh = await this.taskStore.get(task.id);
9808
+ if (!fresh || !taskMatchesGeneration(fresh, taskGenerationGuard(task))
9809
+ || this.codeVerdictOutbox(fresh) !== undefined)
9810
+ return null;
9811
+ const next = {
9812
+ ...fresh,
9813
+ outbox: [...(fresh.outbox ?? []), entry],
9814
+ updatedAt: new Date().toISOString(),
9815
+ };
9816
+ await this.taskStore.set(next);
9817
+ return next;
9818
+ });
9819
+ if (!created)
9820
+ throw new ApiError(409, `Task ${task.id} changed while recording the code verdict`);
9821
+ task = created;
9822
+ }
9823
+ await this.deliverCodeVerdictOutbox(task.id, entry.key);
9824
+ return (await this.taskStore.get(task.id)) ?? task;
9825
+ }
9196
9826
  async submitSpecVerdict(taskId, verdict, comments) {
9197
9827
  const trimmed = comments?.trim();
9198
9828
  if (verdict === 'request-changes' && !trimmed) {
@@ -9849,8 +10479,8 @@ export class AgentManager {
9849
10479
  function buildAgentIndex(config) {
9850
10480
  const index = new Map();
9851
10481
  for (const project of config.project) {
9852
- for (const pair of project.agent) {
9853
- for (const agent of pair) {
10482
+ for (const team of project.agent) {
10483
+ for (const agent of team) {
9854
10484
  index.set(agent.id, { ...agent, projectId: project.id });
9855
10485
  }
9856
10486
  }