baxian 2.0.3 → 2.0.5

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 (141) 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/branch.d.ts.map +1 -1
  5. package/dist/agent/branch.js +7 -10
  6. package/dist/agent/branch.js.map +1 -1
  7. package/dist/agent/detect/manifest.d.ts +2 -1
  8. package/dist/agent/detect/manifest.d.ts.map +1 -1
  9. package/dist/agent/dispatch-reconciler.d.ts +2 -0
  10. package/dist/agent/dispatch-reconciler.d.ts.map +1 -1
  11. package/dist/agent/dispatch-reconciler.js +48 -4
  12. package/dist/agent/dispatch-reconciler.js.map +1 -1
  13. package/dist/agent/manager.d.ts +41 -4
  14. package/dist/agent/manager.d.ts.map +1 -1
  15. package/dist/agent/manager.js +755 -105
  16. package/dist/agent/manager.js.map +1 -1
  17. package/dist/agent/net-exec.d.ts +0 -1
  18. package/dist/agent/net-exec.d.ts.map +1 -1
  19. package/dist/agent/net-exec.js +1 -5
  20. package/dist/agent/net-exec.js.map +1 -1
  21. package/dist/agent/preflight.d.ts.map +1 -1
  22. package/dist/agent/preflight.js +2 -3
  23. package/dist/agent/preflight.js.map +1 -1
  24. package/dist/agent/repo-store.d.ts +9 -15
  25. package/dist/agent/repo-store.d.ts.map +1 -1
  26. package/dist/agent/repo-store.js +156 -151
  27. package/dist/agent/repo-store.js.map +1 -1
  28. package/dist/agent/tmux-probe-poller.js +4 -4
  29. package/dist/agent/tmux.d.ts +0 -5
  30. package/dist/agent/tmux.d.ts.map +1 -1
  31. package/dist/agent/tmux.js +1 -1
  32. package/dist/agent/tmux.js.map +1 -1
  33. package/dist/api/agents.d.ts.map +1 -1
  34. package/dist/api/agents.js +2 -7
  35. package/dist/api/agents.js.map +1 -1
  36. package/dist/api/config.js +7 -7
  37. package/dist/api/hosts.js +6 -6
  38. package/dist/api/platform-guard.js +2 -2
  39. package/dist/api/projects.js +14 -14
  40. package/dist/api/projects.js.map +1 -1
  41. package/dist/api/tasks.d.ts.map +1 -1
  42. package/dist/api/tasks.js +94 -57
  43. package/dist/api/tasks.js.map +1 -1
  44. package/dist/app.d.ts +2 -0
  45. package/dist/app.d.ts.map +1 -1
  46. package/dist/app.js.map +1 -1
  47. package/dist/cli.js +2 -2
  48. package/dist/config/backup.d.ts +3 -2
  49. package/dist/config/backup.d.ts.map +1 -1
  50. package/dist/config/backup.js +78 -15
  51. package/dist/config/backup.js.map +1 -1
  52. package/dist/config/loader.d.ts.map +1 -1
  53. package/dist/config/loader.js +7 -6
  54. package/dist/config/loader.js.map +1 -1
  55. package/dist/config/validator.js +29 -29
  56. package/dist/config/validator.js.map +1 -1
  57. package/dist/event/handlers.d.ts.map +1 -1
  58. package/dist/event/handlers.js +3 -0
  59. package/dist/event/handlers.js.map +1 -1
  60. package/dist/event/ws.d.ts.map +1 -1
  61. package/dist/event/ws.js +3 -3
  62. package/dist/event/ws.js.map +1 -1
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +5 -1
  65. package/dist/index.js.map +1 -1
  66. package/dist/lifecycle/restart-sentinel.d.ts.map +1 -1
  67. package/dist/lifecycle/restart-sentinel.js +2 -1
  68. package/dist/lifecycle/restart-sentinel.js.map +1 -1
  69. package/dist/platform/feedback.d.ts +5 -1
  70. package/dist/platform/feedback.d.ts.map +1 -1
  71. package/dist/platform/feedback.js +7 -1
  72. package/dist/platform/feedback.js.map +1 -1
  73. package/dist/platform/git-driver.d.ts +0 -2
  74. package/dist/platform/git-driver.d.ts.map +1 -1
  75. package/dist/platform/git-driver.js +2 -2
  76. package/dist/platform/git-driver.js.map +1 -1
  77. package/dist/platform/manifest.d.ts +0 -1
  78. package/dist/platform/manifest.d.ts.map +1 -1
  79. package/dist/platform/manifest.js +0 -12
  80. package/dist/platform/manifest.js.map +1 -1
  81. package/dist/platform/platform-poller.d.ts +7 -2
  82. package/dist/platform/platform-poller.d.ts.map +1 -1
  83. package/dist/platform/platform-poller.js +8 -6
  84. package/dist/platform/platform-poller.js.map +1 -1
  85. package/dist/platform/plugins/github/skills/baxian-cli-gh/SKILL.md +1 -1
  86. package/dist/platform/pr-binding.d.ts +2 -1
  87. package/dist/platform/pr-binding.d.ts.map +1 -1
  88. package/dist/platform/pr-conversation-cache.d.ts +11 -11
  89. package/dist/platform/pr-conversation-cache.d.ts.map +1 -1
  90. package/dist/platform/pr-conversation-cache.js +64 -27
  91. package/dist/platform/pr-conversation-cache.js.map +1 -1
  92. package/dist/platform/review-timeline.d.ts +17 -2
  93. package/dist/platform/review-timeline.d.ts.map +1 -1
  94. package/dist/platform/review-timeline.js +93 -84
  95. package/dist/platform/review-timeline.js.map +1 -1
  96. package/dist/platform/verdict-engine.d.ts +3 -2
  97. package/dist/platform/verdict-engine.d.ts.map +1 -1
  98. package/dist/shared/constants.d.ts +1 -3
  99. package/dist/shared/constants.d.ts.map +1 -1
  100. package/dist/shared/constants.js +0 -2
  101. package/dist/shared/constants.js.map +1 -1
  102. package/dist/shared/types.d.ts +33 -3
  103. package/dist/shared/types.d.ts.map +1 -1
  104. package/dist/shared/types.js +14 -0
  105. package/dist/shared/types.js.map +1 -1
  106. package/dist/state/agent-store.d.ts.map +1 -1
  107. package/dist/state/agent-store.js +2 -1
  108. package/dist/state/agent-store.js.map +1 -1
  109. package/dist/state/error-record-store.d.ts.map +1 -1
  110. package/dist/state/error-record-store.js +2 -1
  111. package/dist/state/error-record-store.js.map +1 -1
  112. package/dist/state/lock.d.ts.map +1 -1
  113. package/dist/state/lock.js +2 -1
  114. package/dist/state/lock.js.map +1 -1
  115. package/dist/state/managed-path.d.ts +2 -0
  116. package/dist/state/managed-path.d.ts.map +1 -0
  117. package/dist/state/managed-path.js +11 -0
  118. package/dist/state/managed-path.js.map +1 -0
  119. package/dist/state/pet-store.d.ts.map +1 -1
  120. package/dist/state/pet-store.js +2 -1
  121. package/dist/state/pet-store.js.map +1 -1
  122. package/dist/state/process-lock.d.ts.map +1 -1
  123. package/dist/state/process-lock.js +2 -1
  124. package/dist/state/process-lock.js.map +1 -1
  125. package/dist/state/snapshot.d.ts +0 -1
  126. package/dist/state/snapshot.d.ts.map +1 -1
  127. package/dist/state/snapshot.js +0 -13
  128. package/dist/state/snapshot.js.map +1 -1
  129. package/dist/state/task-store.d.ts +0 -1
  130. package/dist/state/task-store.d.ts.map +1 -1
  131. package/dist/state/task-store.js +97 -24
  132. package/dist/state/task-store.js.map +1 -1
  133. package/dist/web/assets/index-CzVzThuT.js +15 -0
  134. package/dist/web/assets/{index-yqxawAPJ.css → index-Rmp4wT_5.css} +1 -1
  135. package/dist/web/index.html +2 -2
  136. package/package.json +1 -1
  137. package/dist/agent/lineage.d.ts +0 -14
  138. package/dist/agent/lineage.d.ts.map +0 -1
  139. package/dist/agent/lineage.js +0 -33
  140. package/dist/agent/lineage.js.map +0 -1
  141. package/dist/web/assets/index-CiAnjs1_.js +0 -15
@@ -1,24 +1,27 @@
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';
16
19
  import { createRunner, LocalRunner, shellQuote, resolveAgentHost, hostGroupKey, workdirHostGroupKey, } from './runner.js';
17
- import { isTransientNetworkFailure } from './net-exec.js';
20
+ import { execOutcomeUnknown, isTransientNetworkFailure } from './net-exec.js';
18
21
  import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
19
22
  import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, hasOscTitleWorking, hasOscTitleIdle, screenAllowsTitleIdle, PaneGoneError, } from './tmux.js';
20
23
  import { BranchManager, DirtyWorkdirError, ReviewHeadMismatchError, isAutoDeletableTaskBranch } from './branch.js';
21
- import { RepoStore, createRepoStoreCache, moveFileIntoPlace, stageFileGuarded, canonicalSelfGuard } from './repo-store.js';
24
+ import { RepoStore, createRepoStoreCache, moveFileIntoPlace, stageFile, trashBatchDirUnder } from './repo-store.js';
22
25
  import { PhaseSignalWatcher, } from './phase-signal-watcher.js';
23
26
  import { buildPromptInline, buildGreetingPrompt, PromptSizeError, RequiredSkillsMissingError, MAX_PROMPT_BYTES_ROUTE_LIMIT, } from './prompt.js';
24
27
  import { ApiError } from '../errors.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
  }
@@ -621,12 +656,14 @@ export class AgentManager {
621
656
  const subdir = skillSubdirFor(agent.runtime);
622
657
  const destRoot = `${workdir}/${subdir}`;
623
658
  await this.excludeInjectedSkills(runner, workdir, subdir);
659
+ // Batch inside the swept tree itself: a symlinked skills dir may live on another filesystem, where mv degrades to copy+unlink.
660
+ const trashDir = trashBatchDirUnder(`${destRoot}/.baxian-trash`, 'skills');
624
661
  await this.runUnderSkillDirLock(this.skillDirLockKey(agent, workdir), async () => {
625
662
  await this.ensureSkillDirSafe(runner, workdir, subdir, [
626
663
  ...this.skillRegistry.names(),
627
664
  ...this.skillRegistry.pluginSkillNames(scope),
628
- ]);
629
- await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, workdir, path, content), destRoot, scope);
665
+ ], trashDir);
666
+ await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, path, content, trashDir), destRoot, scope);
630
667
  });
631
668
  }
632
669
  sessionLifecycleChain = new Map();
@@ -714,33 +751,44 @@ export class AgentManager {
714
751
  });
715
752
  return run;
716
753
  }
717
- async atomicWriteFile(runner, workdir, finalPath, content) {
754
+ async atomicWriteFile(runner, finalPath, content, trashDir) {
718
755
  const tmp = `${finalPath}.baxian-tmp-${randomBytes(6).toString('hex')}`;
719
756
  try {
720
- await stageFileGuarded(runner, workdir, tmp, content);
721
- await moveFileIntoPlace(runner, tmp, finalPath, { guardRoot: workdir });
757
+ await stageFile(runner, tmp, content, { trashDir });
758
+ await moveFileIntoPlace(runner, tmp, finalPath, { trashDir });
722
759
  }
723
760
  catch (err) {
724
761
  throw new Error(`atomic skill write failed (${finalPath}): ${err instanceof Error ? err.message : String(err)}`);
725
762
  }
726
763
  }
727
- async ensureSkillDirSafe(runner, workdir, subdir, keepNames) {
728
- const top = subdir.split('/')[0];
764
+ async ensureSkillDirSafe(runner, workdir, subdir, keepNames, trashDir) {
765
+ const SKILLS_SWEPT = 'BX_SKILLS_SWEPT';
729
766
  const keep = keepNames.map((n) => `! -name ${shellQuote(n)}`).join(' ');
730
- const inner =
731
- // Keep the canonical Workdir check and find/rm in one command to fail closed on a rebound ancestor.
732
- `${canonicalSelfGuard(workdir)} && ` +
733
- `cd ${shellQuote(workdir)} && ` +
734
- `for d in ${top} ${subdir}; do if [ -L "$d" ]; then ` +
735
- `printf 'baxian: %s is a symlink -> %s; replace it with a real directory\\n' "$d" "$(readlink "$d")" >&2; ` +
736
- `exit 3; fi; done && ` +
767
+ const batch = shellQuote(trashDir);
768
+ // Stale directories go first so the later link/file passes only see what stayed behind in kept dirs. -H follows a symlinked skills dir given as the start path; -exec … + propagates a mover failure into find's exit code (\; would swallow it); pruning .baxian-trash keeps the never-emptied trash out of the walk (it only grows, and one unreadable old batch would fail every sweep).
769
+ const prune = `-name .baxian-trash -prune -o`;
770
+ const findStaleDirs = `find -H ${subdir} -maxdepth 1 ${prune} -name 'baxian-*' ${keep}`;
771
+ const findStaleLinks = `find -H ${subdir} ${prune} -path '${subdir}/baxian-*' -type l`;
772
+ const findStaleFiles = `find -H ${subdir} ${prune} -path '${subdir}/baxian-*/*' -type f ! -name 'SKILL.md'`;
773
+ const mover = `dest="$0"; for f in "$@"; do ` +
774
+ `mkdir -p "$dest" || exit 1; ` +
775
+ `base=$(basename -- "$f"); t="$dest/$base"; n=0; ` +
776
+ `while [ -e "$t" ] || [ -L "$t" ]; do n=$((n+1)); t="$dest/$base.$n"; done; ` +
777
+ `mv -- "$f" "$t" || exit 1; ` +
778
+ `done`;
779
+ const moveStale = (find) => `${find} -exec sh -c '${mover}' ${batch} {} +`;
780
+ const inner = `cd ${shellQuote(workdir)} && ` +
737
781
  `mkdir -p ${subdir} && ` +
738
- `find ${subdir} -maxdepth 1 -name 'baxian-*' ${keep} -exec rm -rf {} + && ` +
739
- `find ${subdir} -path '${subdir}/baxian-*' -type l -exec rm -f {} + && ` +
740
- `find ${subdir} -path '${subdir}/baxian-*/*' -type f ! -name 'SKILL.md' -exec rm -f {} +`;
782
+ `${moveStale(findStaleDirs)} && ` +
783
+ `${moveStale(findStaleLinks)} && ` +
784
+ `${moveStale(findStaleFiles)} && ` +
785
+ `printf '%s' ${SKILLS_SWEPT}`;
741
786
  const res = await runner.exec(`sh -c ${shellQuote(inner)}`);
742
- if (res.exitCode !== 0) {
743
- throw new Error(`failed to prepare a symlink-safe ${subdir} in ${workdir}: ${res.stderr || 'unknown error'}`);
787
+ if (execOutcomeUnknown(res)) {
788
+ throw new Error(`skill sweep outcome unknown in ${workdir}/${subdir} (exit ${res.exitCode}): ${res.stderr.trim() || res.stdout.trim() || 'no output'}`);
789
+ }
790
+ if (res.exitCode !== 0 || !res.stdout.includes(SKILLS_SWEPT)) {
791
+ throw new Error(`failed to prepare ${subdir} in ${workdir}: ${res.stderr.trim() || `exit ${res.exitCode}, no completion marker`}`);
744
792
  }
745
793
  }
746
794
  async tagSessionSkillsVersion(tmux, agentId, ref) {
@@ -757,18 +805,25 @@ export class AgentManager {
757
805
  return tagged !== this.skillRegistry.contentHash(this.agentSkillScope(agentId));
758
806
  }
759
807
  async excludeInjectedSkills(runner, workdir, subdir) {
760
- const inner = `${canonicalSelfGuard(workdir)} && ` +
761
- `cd ${shellQuote(workdir)} && ` +
808
+ // cd -P first: tracked/exclude/probe all run against the skills dir's PHYSICAL owning repo, so a symlink pointing back inside a worktree keeps the tracked-content protection while one pointing outside degrades to the plain non-git path.
809
+ const inner = `cd ${shellQuote(workdir)} && ` +
810
+ `mkdir -p ${subdir} && cd -P ${subdir} && ` +
762
811
  `if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo BX_SKILLS_NON_GIT; exit 0; fi; ` +
763
- `tracked="$(git ls-files -- ${shellQuote(`${subdir}/baxian-*`)} 2>/dev/null | head -5)"; ` +
812
+ `tracked="$(git ls-files -- 'baxian-*' 2>/dev/null | head -5)"; ` +
764
813
  `if [ -n "$tracked" ]; then printf 'BX_SKILLS_TRACKED %s\\n' "$tracked" | head -1; exit 0; fi; ` +
765
814
  `p="$(git rev-parse --git-path info/exclude)" || { echo BX_SKILLS_EXCLUDE_WRITE_FAILED; exit 0; }; ` +
766
- `pre="$(git rev-parse --show-prefix 2>/dev/null)"; rule="\${pre}${subdir}/baxian-*"; ` +
767
- `{ mkdir -p "$(dirname "$p")" && { grep -qxF "$rule" "$p" 2>/dev/null || printf '%s\\n' "$rule" >> "$p"; }; } ` +
815
+ // show-prefix is a literal path but lands in a gitignore pattern: escape fnmatch metacharacters or a dir like skill[dir] never matches its own rule.
816
+ `pre="$(git rev-parse --show-prefix 2>/dev/null | sed 's/[][\\*?]/\\\\&/g')"; rule="/\${pre}baxian-*"; trashrule="/\${pre}.baxian-trash"; ` +
817
+ `{ mkdir -p "$(dirname "$p")" && { grep -qxF "$rule" "$p" 2>/dev/null || printf '%s\\n' "$rule" >> "$p"; } ` +
818
+ `&& { grep -qxF "$trashrule" "$p" 2>/dev/null || printf '%s\\n' "$trashrule" >> "$p"; }; } ` +
768
819
  `|| { echo BX_SKILLS_EXCLUDE_WRITE_FAILED; exit 0; }; ` +
769
- `if git check-ignore -q -- ${shellQuote(`${subdir}/baxian-__probe__/SKILL.md`)}; then echo BX_SKILLS_OK; ` +
820
+ `if git check-ignore -q -- 'baxian-__probe__/SKILL.md' ` +
821
+ `&& git check-ignore -q -- '.baxian-trash/__probe__'; then echo BX_SKILLS_OK; ` +
770
822
  `else echo BX_SKILLS_EXCLUDE_INEFFECTIVE; fi`;
771
823
  const res = await runner.exec(`sh -c ${shellQuote(inner)}`);
824
+ if (execOutcomeUnknown(res)) {
825
+ throw new Error(`skill exclusion probe outcome unknown in ${workdir} (exit ${res.exitCode}): ${res.stderr.trim() || res.stdout.trim() || 'no output'}`);
826
+ }
772
827
  if (res.exitCode !== 0) {
773
828
  throw new Error(`skill exclusion probe failed in ${workdir}: ${res.stderr.trim() || `exit ${res.exitCode}`}`);
774
829
  }
@@ -911,7 +966,7 @@ export class AgentManager {
911
966
  }
912
967
  }
913
968
  await this.markDialogPending(agentId, creationToken);
914
- void this.slowPollDialogPending(agentId, creationToken).catch((pollErr) => {
969
+ void this.outsideTaskMutationScope(() => this.slowPollDialogPending(agentId, creationToken)).catch((pollErr) => {
915
970
  console.warn(`[bootstrap] slowPoll for ${agentId} crashed:`, pollErr);
916
971
  });
917
972
  return;
@@ -1184,10 +1239,10 @@ export class AgentManager {
1184
1239
  err.partial.handled = true;
1185
1240
  const snapshotPaneId = state.paneId;
1186
1241
  const snapshotTaskId = state.taskId;
1187
- void this.slowPollDialogPending(agentId, state.creationToken, {
1242
+ void this.outsideTaskMutationScope(() => this.slowPollDialogPending(agentId, state.creationToken, {
1188
1243
  ...(snapshotPaneId !== undefined ? { expectedPaneId: snapshotPaneId } : {}),
1189
1244
  expectedTaskId: snapshotTaskId,
1190
- }).catch((pollErr) => {
1245
+ })).catch((pollErr) => {
1191
1246
  console.warn(`[runtime] slowPoll for ${agentId} crashed:`, pollErr);
1192
1247
  });
1193
1248
  return true;
@@ -1255,6 +1310,7 @@ export class AgentManager {
1255
1310
  return;
1256
1311
  const now = new Date().toISOString();
1257
1312
  let projectIdForEmit = '';
1313
+ let taskIdForEmit;
1258
1314
  let wrote = false;
1259
1315
  const isBootstrapPath = creationToken !== undefined;
1260
1316
  if (isBootstrapPath && !(await this.runGreetingHandshake(agentId, cfg, pane))) {
@@ -1270,6 +1326,7 @@ export class AgentManager {
1270
1326
  return AGENT_STORE_NOOP;
1271
1327
  }
1272
1328
  projectIdForEmit = fresh.projectId;
1329
+ taskIdForEmit = fresh.taskId;
1273
1330
  wrote = true;
1274
1331
  if (isBootstrapPath) {
1275
1332
  return {
@@ -1283,11 +1340,15 @@ export class AgentManager {
1283
1340
  updatedAt: now,
1284
1341
  };
1285
1342
  }
1343
+ const awaitingReason = fresh.taskId
1344
+ ? `Runtime dialog resolved; agent REPL ready. Open task ${fresh.taskId}: cancel it if it is still active, ` +
1345
+ 'otherwise Resume can release the binding; Delete the agent if the binding cannot be recovered.'
1346
+ : 'Runtime dialog resolved; agent REPL ready. Click Resume to release the binding and let baxian pick the next task.';
1286
1347
  return {
1287
1348
  ...fresh,
1288
1349
  paneId,
1289
1350
  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.',
1351
+ awaitingReason,
1291
1352
  updatedAt: now,
1292
1353
  };
1293
1354
  });
@@ -1304,9 +1365,13 @@ export class AgentManager {
1304
1365
  });
1305
1366
  }
1306
1367
  else {
1307
- await this.emitIntervention(projectIdForEmit, agentId, undefined, {
1368
+ const note = taskIdForEmit
1369
+ ? `Runtime dialog resolved; agent REPL ready. Open task ${taskIdForEmit}: cancel it if it is still active, ` +
1370
+ 'otherwise Resume can release the binding; Delete the agent if the binding cannot be recovered.'
1371
+ : 'Runtime dialog resolved; agent REPL ready. Click Resume to continue.';
1372
+ await this.emitIntervention(projectIdForEmit, agentId, taskIdForEmit, {
1308
1373
  phase: 'agent_dialog_resolved_runtime',
1309
- note: 'Runtime dialog resolved; agent REPL ready. Click Resume to continue.',
1374
+ note,
1310
1375
  });
1311
1376
  }
1312
1377
  return;
@@ -1317,7 +1382,7 @@ export class AgentManager {
1317
1382
  return;
1318
1383
  const controller = new AbortController();
1319
1384
  this.runtimeMenuWatchers.set(agentId, controller);
1320
- void this.runtimeMenuWatchLoop(agentId, controller.signal)
1385
+ void this.outsideTaskMutationScope(() => this.runtimeMenuWatchLoop(agentId, controller.signal))
1321
1386
  .catch((err) => {
1322
1387
  console.warn(`[runtimeMenuWatch] ${agentId} loop crashed:`, err);
1323
1388
  })
@@ -1378,6 +1443,10 @@ export class AgentManager {
1378
1443
  }
1379
1444
  else if (!onMenu && pendingMenu) {
1380
1445
  pendingMenu = false;
1446
+ await this.emitIntervention(fresh.projectId, agentId, fresh.taskId, {
1447
+ phase: 'agent_runtime_menu_resolved',
1448
+ previousPhase: 'agent_runtime_menu_pending',
1449
+ });
1381
1450
  }
1382
1451
  }
1383
1452
  }
@@ -2272,7 +2341,7 @@ export class AgentManager {
2272
2341
  if (this.needInputRetryTimer)
2273
2342
  return;
2274
2343
  this.needInputRetryTimer = setInterval(() => {
2275
- void this.needInputRetryPass();
2344
+ void this.outsideTaskMutationScope(() => this.needInputRetryPass());
2276
2345
  }, this.needInputRetryIntervalMs);
2277
2346
  this.needInputRetryTimer.unref?.();
2278
2347
  }
@@ -2464,13 +2533,13 @@ export class AgentManager {
2464
2533
  if (wm !== null && bumped && mode === 'restore') {
2465
2534
  const delta = this.collectPredecessorNeedInputDelta(args.agentId, args.taskId, wm.epoch);
2466
2535
  if (delta) {
2467
- void this.commitNeedInputWatermark({
2536
+ void this.outsideTaskMutationScope(() => this.commitNeedInputWatermark({
2468
2537
  agentId: args.agentId,
2469
2538
  taskId: args.taskId,
2470
2539
  epoch: wm.epoch,
2471
2540
  askSeq: Math.max(delta.askSeq, wm.askSeq),
2472
2541
  answeredSeq: Math.max(delta.answeredSeq, wm.answeredSeq),
2473
- });
2542
+ }));
2474
2543
  }
2475
2544
  }
2476
2545
  try {
@@ -3358,7 +3427,7 @@ export class AgentManager {
3358
3427
  if (!this.deletionGateOpen(agentId, genAtEntry)) {
3359
3428
  throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
3360
3429
  }
3361
- const workdir = (await this.ensureWorkdir(cfg, project, runner)).workdir;
3430
+ const { workdir } = await this.ensureWorkdir(cfg, project, runner);
3362
3431
  const paneWorkdir = await tmux.getPaneCurrentPath(pane);
3363
3432
  if (!await sameDirOnHost(runner, paneWorkdir, workdir)) {
3364
3433
  throw new Error(`restart-repl: pane Workdir ${paneWorkdir} does not match agent Workdir ${workdir}; ` +
@@ -3762,8 +3831,8 @@ export class AgentManager {
3762
3831
  const project = this.getProjectConfig(cfg.projectId);
3763
3832
  if (!project)
3764
3833
  throw new Error(`Unknown project: ${cfg.projectId}`);
3765
- const group = this.findAgentGroup(agentId);
3766
- return { targets: group?.map(agent => agent.id) ?? [agentId] };
3834
+ const team = this.findAgentTeam(agentId);
3835
+ return { targets: team?.map(agent => agent.id) ?? [agentId] };
3767
3836
  }
3768
3837
  async cleanupRemovedAgentRuntime(targets) {
3769
3838
  const failures = [];
@@ -3862,13 +3931,13 @@ export class AgentManager {
3862
3931
  const now = new Date().toISOString();
3863
3932
  if (cfg.role !== 'dev')
3864
3933
  throw new Error(`Agent ${cfg.id} is not a dev agent`);
3865
- const group = this.findAgentGroup(cfg.id);
3934
+ const team = this.findAgentTeam(cfg.id);
3866
3935
  const devAgentId = cfg.id;
3867
3936
  const qaAgentId = input.preferredAgentId === ''
3868
3937
  ? '_unassigned_preview_qa'
3869
- : group?.find(agent => agent.role === 'qa')?.id;
3938
+ : team?.find(agent => agent.role === 'qa')?.id;
3870
3939
  if (!qaAgentId)
3871
- throw new Error(`Agent ${cfg.id} has no qa agent in its group`);
3940
+ throw new Error(`Agent ${cfg.id} has no qa agent in its Agent Team`);
3872
3941
  const fakeTask = {
3873
3942
  id: 'task-9999999999',
3874
3943
  projectId,
@@ -3910,11 +3979,11 @@ export class AgentManager {
3910
3979
  return undefined;
3911
3980
  return buildProjectDriver(project, this.pluginRegistry, this.platformDriverExec);
3912
3981
  }
3913
- findAgentGroup(anchorAgentId) {
3982
+ findAgentTeam(anchorAgentId) {
3914
3983
  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;
3984
+ for (const team of project.agent) {
3985
+ if (team.some(agent => agent.id === anchorAgentId))
3986
+ return team;
3918
3987
  }
3919
3988
  }
3920
3989
  return undefined;
@@ -3925,8 +3994,8 @@ export class AgentManager {
3925
3994
  return undefined;
3926
3995
  let longest;
3927
3996
  let longestBytes = -1;
3928
- for (const pair of project.agent) {
3929
- for (const a of pair) {
3997
+ for (const team of project.agent) {
3998
+ for (const a of team) {
3930
3999
  if (a.role !== 'dev' || !a.workdir)
3931
4000
  continue;
3932
4001
  const bytes = Buffer.byteLength(a.workdir, 'utf8');
@@ -3941,6 +4010,99 @@ export class AgentManager {
3941
4010
  async getTask(taskId) {
3942
4011
  return this.taskStore.get(taskId);
3943
4012
  }
4013
+ async recordTaskAttention(event) {
4014
+ if (!event.taskId)
4015
+ return;
4016
+ await this.withTaskLock(async () => {
4017
+ const task = await this.taskStore.get(event.taskId);
4018
+ if (!task)
4019
+ return;
4020
+ const reasonValue = typeof event.data.phase === 'string'
4021
+ ? event.data.phase
4022
+ : typeof event.data.reason === 'string'
4023
+ ? event.data.reason
4024
+ : 'human-intervention';
4025
+ const reason = reasonValue.trim() || 'human-intervention';
4026
+ const previousPhase = typeof event.data.previousPhase === 'string'
4027
+ ? event.data.previousPhase.trim()
4028
+ : '';
4029
+ const clearedReason = reason === 'mr-reopened'
4030
+ ? 'mr-closed-unmerged'
4031
+ : reason === 'resumed'
4032
+ || reason === 'git-review-dispatch-hold-cleared'
4033
+ || reason === 'agent_runtime_menu_resolved'
4034
+ ? previousPhase
4035
+ : '';
4036
+ if (clearedReason) {
4037
+ if (task.attention?.reason !== clearedReason
4038
+ || Date.parse(event.timestamp) < Date.parse(task.attention.occurredAt))
4039
+ return;
4040
+ const next = { ...task, updatedAt: new Date().toISOString() };
4041
+ delete next.attention;
4042
+ await this.taskStore.set(next);
4043
+ return;
4044
+ }
4045
+ if (reason === 'resumed'
4046
+ || reason === 'git-review-dispatch-hold-cleared'
4047
+ || reason === 'agent_runtime_menu_resolved')
4048
+ return;
4049
+ const runbookValue = [
4050
+ event.data.note,
4051
+ event.data.recommendation,
4052
+ event.data.message,
4053
+ event.data.error,
4054
+ ...(typeof event.data.phase === 'string' ? [event.data.reason] : []),
4055
+ ]
4056
+ .find(value => typeof value === 'string' && value.trim() !== '');
4057
+ const runbook = typeof runbookValue === 'string'
4058
+ ? runbookValue.trim()
4059
+ : `Task ${task.id} requires human intervention for ${reason}. Inspect the task and its linked agent before choosing an action.`;
4060
+ const occurredAt = Number.isFinite(Date.parse(event.timestamp))
4061
+ ? event.timestamp
4062
+ : new Date().toISOString();
4063
+ const generation = taskAttentionGeneration(task);
4064
+ let recommendedActions;
4065
+ if (task.status === 'max_rounds') {
4066
+ recommendedActions = ['verdict', 'cancel'];
4067
+ }
4068
+ else if (TERMINAL_STATUSES.includes(task.status)) {
4069
+ recommendedActions = task.remoteCleanup !== undefined
4070
+ && task.remoteCleanup.stage !== 'manual'
4071
+ ? ['cancel']
4072
+ : ['retry'];
4073
+ }
4074
+ else if (task.status === 'review') {
4075
+ recommendedActions = isSpecStagePhase(task.phase)
4076
+ ? ['advance', 'cancel']
4077
+ : ['advance', 'verdict', 'cancel'];
4078
+ }
4079
+ else if (task.status === 'spec-ready' || task.status === 'merge-ready') {
4080
+ recommendedActions = ['verdict', 'cancel'];
4081
+ }
4082
+ else {
4083
+ recommendedActions = ['advance', 'cancel'];
4084
+ }
4085
+ if (task.attention
4086
+ && Date.parse(task.attention.occurredAt) > Date.parse(occurredAt))
4087
+ return;
4088
+ if (task.attention
4089
+ && task.attention.reason === reason
4090
+ && task.attention.runbook === runbook
4091
+ && Date.parse(task.attention.occurredAt) >= Date.parse(occurredAt))
4092
+ return;
4093
+ await this.taskStore.set({
4094
+ ...task,
4095
+ attention: {
4096
+ reason,
4097
+ runbook,
4098
+ occurredAt,
4099
+ recommendedActions,
4100
+ generation,
4101
+ },
4102
+ updatedAt: new Date().toISOString(),
4103
+ });
4104
+ });
4105
+ }
3944
4106
  async getAgentState(agentId) {
3945
4107
  return this.agentStore.get(agentId);
3946
4108
  }
@@ -4027,15 +4189,30 @@ export class AgentManager {
4027
4189
  delete next.reviewDispatch;
4028
4190
  delete next.reviewRoundPending;
4029
4191
  }
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
- }
4192
+ if (nextStatus !== task.status)
4193
+ this.stripGitVerdictOutbox(next);
4037
4194
  return next;
4038
4195
  }
4196
+ stripGitVerdictOutbox(task) {
4197
+ if (!task.outbox)
4198
+ return;
4199
+ const outbox = task.outbox.filter(entry => entry.type !== 'git.spec-verdict' && entry.type !== 'git.code-verdict');
4200
+ if (outbox.length > 0)
4201
+ task.outbox = outbox;
4202
+ else
4203
+ delete task.outbox;
4204
+ }
4205
+ stripCodeVerdictOutboxForAnchor(task, anchorSha) {
4206
+ if (!task.outbox)
4207
+ return;
4208
+ const normalizedAnchor = anchorSha.toLowerCase();
4209
+ const outbox = task.outbox.filter(entry => entry.type !== 'git.code-verdict'
4210
+ || entry.data.anchorSha.toLowerCase() === normalizedAnchor);
4211
+ if (outbox.length > 0)
4212
+ task.outbox = outbox;
4213
+ else
4214
+ delete task.outbox;
4215
+ }
4039
4216
  async confirmPostApprovePromptDelivered(taskId, expected) {
4040
4217
  await this.withTaskLock(async () => {
4041
4218
  const task = await this.taskStore.get(taskId);
@@ -4229,6 +4406,9 @@ export class AgentManager {
4229
4406
  specVerdictOutbox(task) {
4230
4407
  return task.outbox?.find((entry) => entry.type === 'git.spec-verdict');
4231
4408
  }
4409
+ codeVerdictOutbox(task) {
4410
+ return task.outbox?.find((entry) => entry.type === 'git.code-verdict');
4411
+ }
4232
4412
  async mutateTaskOutboxEntry(taskId, key, mutate) {
4233
4413
  return this.withTaskLock(async () => {
4234
4414
  const task = await this.taskStore.get(taskId);
@@ -4258,6 +4438,10 @@ export class AgentManager {
4258
4438
  await this.deliverSpecVerdictOutbox(taskId, entry.key);
4259
4439
  continue;
4260
4440
  }
4441
+ if (entry.type === 'git.code-verdict') {
4442
+ await this.deliverCodeVerdictOutbox(taskId, entry.key);
4443
+ continue;
4444
+ }
4261
4445
  await this.eventBus.emit({
4262
4446
  id: `outbox:${entry.key}`,
4263
4447
  type: 'human.intervention',
@@ -4379,6 +4563,10 @@ export class AgentManager {
4379
4563
  return null;
4380
4564
  if (Object.hasOwn(opts, 'expectPhase') && current.phase !== opts.expectPhase)
4381
4565
  return null;
4566
+ if (current.status === 'review'
4567
+ && current.reviewHeadAnchorSha?.toLowerCase() === opts.headSha.toLowerCase()
4568
+ && this.codeVerdictOutbox(current) !== undefined)
4569
+ return null;
4382
4570
  if (opts.patch?.branch && opts.patch.branch !== current.branch) {
4383
4571
  const existing = await this.findTaskByBranch(opts.patch.branch, current.projectId);
4384
4572
  if (existing && existing.id !== taskId)
@@ -4399,6 +4587,7 @@ export class AgentManager {
4399
4587
  ? 'review'
4400
4588
  : 'recheck');
4401
4589
  const next = this.stripPostApproveEpisodeFields(current);
4590
+ this.stripGitVerdictOutbox(next);
4402
4591
  delete next.passProvenance;
4403
4592
  Object.assign(next, opts.patch ?? {}, {
4404
4593
  status: 'review',
@@ -4468,6 +4657,7 @@ export class AgentManager {
4468
4657
  return null;
4469
4658
  const now = new Date().toISOString();
4470
4659
  const next = this.stripPostApproveEpisodeFields(current);
4660
+ this.stripGitVerdictOutbox(next);
4471
4661
  delete next.reviewDispatch;
4472
4662
  delete next.reviewRoundPending;
4473
4663
  const postApproveGeneration = createSignalToken();
@@ -4567,6 +4757,7 @@ export class AgentManager {
4567
4757
  },
4568
4758
  updatedAt: now,
4569
4759
  };
4760
+ this.stripCodeVerdictOutboxForAnchor(next, verifiedHeadSha);
4570
4761
  await this.taskStore.set(next);
4571
4762
  if (qaHold !== undefined) {
4572
4763
  const cleared = await this.clearAwaitingHuman(qaId, {
@@ -4612,6 +4803,9 @@ export class AgentManager {
4612
4803
  }
4613
4804
  next.reviewDispatchedAt = now;
4614
4805
  delete next.reviewDispatch;
4806
+ if (next.attention?.reason === 'git-review-dispatch-recovery-uncertain') {
4807
+ delete next.attention;
4808
+ }
4615
4809
  await this.taskStore.set(next);
4616
4810
  return true;
4617
4811
  });
@@ -5100,20 +5294,28 @@ export class AgentManager {
5100
5294
  return next;
5101
5295
  });
5102
5296
  }
5103
- async noteReviewConversationRevision(taskId) {
5297
+ async noteReviewConversationRevision(taskId, conversation) {
5104
5298
  await this.withTaskLock(async () => {
5105
5299
  const task = await this.taskStore.get(taskId);
5106
5300
  if (!task || TERMINAL_STATUSES.includes(task.status))
5107
5301
  return;
5108
5302
  const now = new Date().toISOString();
5109
- await this.taskStore.set({ ...task, reviewConversationUpdatedAt: now, updatedAt: now });
5303
+ const next = { ...task, reviewConversationUpdatedAt: now, updatedAt: now };
5304
+ const repoKey = next.platformBinding?.repoKey;
5305
+ if (conversation !== undefined
5306
+ && this.prConversationCache !== undefined
5307
+ && repoKey !== undefined
5308
+ && next.prNumber === conversation.prNumber) {
5309
+ this.prConversationCache.put(next.id, prReviewCacheRevision(next, repoKey), conversation.payload, next.reviewConversationUpdatedAt ?? '');
5310
+ }
5311
+ await this.taskStore.set(next);
5110
5312
  });
5111
5313
  }
5112
5314
  assertPlatformBinding(task) {
5113
5315
  const mismatch = platformBindingMismatch(this.config, task);
5114
5316
  if (mismatch === undefined)
5115
5317
  return;
5116
- void this.emitPlatformBindingIntervention(task, mismatch);
5318
+ void this.outsideTaskMutationScope(() => this.emitPlatformBindingIntervention(task, mismatch));
5117
5319
  if (mismatch.reason === 'missing-binding-snapshot') {
5118
5320
  throw new Error(`platform binding missing for task ${task.id}; refusing platform operations`);
5119
5321
  }
@@ -5650,8 +5852,12 @@ export class AgentManager {
5650
5852
  return null;
5651
5853
  if (task.prNumber !== undefined && task.prNumber !== observation.prNumber)
5652
5854
  return null;
5855
+ const repairsDeliveryConfirmation = confirmation?.source === 'human'
5856
+ && (task.status === 'review' || task.status === 'fixing')
5857
+ && Object.hasOwn(confirmation, 'expectedSignalToken');
5653
5858
  if (confirmation !== undefined) {
5654
5859
  if (task.status !== 'in_progress'
5860
+ && !repairsDeliveryConfirmation
5655
5861
  && (task.phase !== confirmation.phase
5656
5862
  || task.deliveryConfirmation?.phase !== confirmation.phase))
5657
5863
  return null;
@@ -5678,7 +5884,7 @@ export class AgentManager {
5678
5884
  ...(confirmation !== undefined
5679
5885
  ? {
5680
5886
  phase: confirmation.phase,
5681
- deliveryConfirmation: task.status === 'in_progress'
5887
+ deliveryConfirmation: task.status === 'in_progress' || repairsDeliveryConfirmation
5682
5888
  ? { phase: confirmation.phase, source: confirmation.source, at: now }
5683
5889
  : task.deliveryConfirmation,
5684
5890
  ...(confirmation.branch !== undefined ? { branch: confirmation.branch } : {}),
@@ -5930,13 +6136,13 @@ export class AgentManager {
5930
6136
  }
5931
6137
  const genAtEntry = this.deletionGenerationOf(input.preferredAgentId);
5932
6138
  const target = await this.pickAgent(projectId, input.preferredAgentId);
5933
- const group = this.findAgentGroup(input.preferredAgentId);
5934
- const dev = group?.find(agent => agent.role === 'dev');
6139
+ const team = this.findAgentTeam(input.preferredAgentId);
6140
+ const dev = team?.find(agent => agent.role === 'dev');
5935
6141
  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');
6142
+ throw new ApiError(409, `Agent ${input.preferredAgentId} has no dev agent in its Agent Team`);
6143
+ const qa = team?.find(agent => agent.role === 'qa');
5938
6144
  if (!qa)
5939
- throw new ApiError(409, `Agent ${input.preferredAgentId} has no qa agent in its group`);
6145
+ throw new ApiError(409, `Agent ${input.preferredAgentId} has no qa agent in its Agent Team`);
5940
6146
  const participantGens = new Map([dev.id, qa.id]
5941
6147
  .map(id => [id, this.deletionGenerationOf(id)]));
5942
6148
  const participantsFresh = () => this.deletionGenerationOf(input.preferredAgentId) === genAtEntry
@@ -5951,7 +6157,7 @@ export class AgentManager {
5951
6157
  };
5952
6158
  const persistQueuedOrReject = (reason) => {
5953
6159
  if (!participantsFresh()) {
5954
- throw new ApiError(409, `Agent ${input.preferredAgentId} or a group member is being deleted or recreated during task creation; please retry`);
6160
+ throw new ApiError(409, `Agent ${input.preferredAgentId} or an Agent Team member is being deleted or recreated during task creation; please retry`);
5955
6161
  }
5956
6162
  return this.persistQueuedTask(queued, reason, input.preferredAgentId);
5957
6163
  };
@@ -6061,6 +6267,9 @@ export class AgentManager {
6061
6267
  }
6062
6268
  async createAndStartTask(projectId, input, opts = {}) {
6063
6269
  const task = await this.createTask(projectId, input);
6270
+ return this.startCreatedTask(task, opts);
6271
+ }
6272
+ async startCreatedTask(task, opts = {}) {
6064
6273
  if (task.status === 'in_progress' && task.agentId) {
6065
6274
  const signalToken = createSignalToken();
6066
6275
  await this.updateTask(task.id, this.dispatchTokenFields(signalToken));
@@ -6238,7 +6447,7 @@ export class AgentManager {
6238
6447
  await tmux.sendKeysLiteral(pane, command);
6239
6448
  await tmux.sendEnter(pane);
6240
6449
  guardHandedOff = true;
6241
- void this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.compactIdleWaitMs)
6450
+ void this.outsideTaskMutationScope(() => this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.compactIdleWaitMs))
6242
6451
  .catch(err => {
6243
6452
  console.warn(`[AgentManager] sendSlashCommand(${agentId}, ${command}) post idle wait failed:`, err);
6244
6453
  })
@@ -6281,6 +6490,16 @@ export class AgentManager {
6281
6490
  clearPendingDispatchRetry(taskId) {
6282
6491
  this.pendingDispatchRetryByTask.delete(taskId);
6283
6492
  }
6493
+ resetPendingDispatchRetryBudget(taskId) {
6494
+ const entry = this.pendingDispatchRetryByTask.get(taskId);
6495
+ if (!entry)
6496
+ return;
6497
+ this.pendingDispatchRetryByTask.set(taskId, {
6498
+ ...entry,
6499
+ since: Date.now(),
6500
+ budgetAlerted: undefined,
6501
+ });
6502
+ }
6284
6503
  clearPendingDispatchRetryIfMatches(taskId, expected) {
6285
6504
  const entry = this.pendingDispatchRetryByTask.get(taskId);
6286
6505
  if (entry?.agentId === expected.agentId && entry.signalToken === expected.signalToken) {
@@ -6450,20 +6669,20 @@ export class AgentManager {
6450
6669
  }
6451
6670
  const now = new Date().toISOString();
6452
6671
  const initiallyUnassigned = fresh.preferredAgentId === '';
6453
- const group = initiallyUnassigned ? this.findAgentGroup(agentId) : undefined;
6672
+ const team = initiallyUnassigned ? this.findAgentTeam(agentId) : undefined;
6454
6673
  const devId = initiallyUnassigned
6455
- ? group?.find(agent => agent.role === 'dev')?.id
6674
+ ? team?.find(agent => agent.role === 'dev')?.id
6456
6675
  : fresh.devAgentId;
6457
6676
  if (!devId) {
6458
6677
  await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
6459
- return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no dev agent in its group` };
6678
+ return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no dev agent in its Agent Team` };
6460
6679
  }
6461
6680
  const qaId = initiallyUnassigned
6462
- ? group?.find(agent => agent.role === 'qa')?.id
6681
+ ? team?.find(agent => agent.role === 'qa')?.id
6463
6682
  : fresh.qaAgentId;
6464
6683
  if (!qaId) {
6465
6684
  await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
6466
- return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no qa agent in its group` };
6685
+ return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no qa agent in its Agent Team` };
6467
6686
  }
6468
6687
  const participantGens = new Map([devId, qaId]
6469
6688
  .map(pid => [pid, this.deletionGenerationOf(pid)]));
@@ -7780,10 +7999,10 @@ export class AgentManager {
7780
7999
  if (await this.rollbackUndeliveredBootstrap(state))
7781
8000
  continue;
7782
8001
  await this.markDialogPending(state.id, state.creationToken);
7783
- void this.slowPollDialogPending(state.id, state.creationToken, {
8002
+ void this.outsideTaskMutationScope(() => this.slowPollDialogPending(state.id, state.creationToken, {
7784
8003
  ...(state.paneId !== undefined ? { expectedPaneId: state.paneId } : {}),
7785
8004
  expectedTaskId: state.taskId,
7786
- }).catch((pollErr) => {
8005
+ })).catch((pollErr) => {
7787
8006
  console.warn(`[recover] slowPoll for ${state.id} crashed:`, pollErr);
7788
8007
  });
7789
8008
  continue;
@@ -8369,6 +8588,169 @@ export class AgentManager {
8369
8588
  return dispatched;
8370
8589
  }
8371
8590
  }
8591
+ async advanceTask(taskId, opts = {}) {
8592
+ let task = await this.taskStore.get(taskId);
8593
+ if (!task)
8594
+ throw new ApiError(404, `Task ${taskId} not found`);
8595
+ const executor = opts.executor ?? (task.status === 'review' ? 'qa' : 'dev');
8596
+ let updated;
8597
+ if (task.status === 'pending') {
8598
+ if (executor !== 'dev')
8599
+ throw new ApiError(409, 'Pending tasks can only advance to Dev');
8600
+ const result = await this.dispatchPendingTask(taskId, opts.agentId);
8601
+ if (result.errorCode !== undefined)
8602
+ throw new ApiError(result.errorCode, result.error ?? 'Dispatch failed');
8603
+ if (!result.task)
8604
+ throw new ApiError(404, `Task ${taskId} disappeared during dispatch`);
8605
+ updated = result.task;
8606
+ }
8607
+ else if (executor === 'qa') {
8608
+ if (task.status !== 'in_progress' && task.status !== 'fixing' && task.status !== 'review') {
8609
+ throw new ApiError(409, `Task ${taskId} cannot advance to QA from ${task.status}`);
8610
+ }
8611
+ updated = await this.dispatchReviewToQa(taskId, {
8612
+ fromStatus: [task.status],
8613
+ confirmUncertainNotDelivered: true,
8614
+ ...(opts.stage !== undefined ? { stage: opts.stage } : {}),
8615
+ ...(opts.actorId !== undefined ? { actorId: opts.actorId } : {}),
8616
+ ...(opts.prNumber !== undefined ? { prNumber: opts.prNumber } : {}),
8617
+ });
8618
+ }
8619
+ else if (task.status === 'review') {
8620
+ throw new ApiError(409, 'Review cannot advance to Dev without a request-changes verdict');
8621
+ }
8622
+ else if (task.status === 'in_progress' || task.status === 'fixing') {
8623
+ if (!task.devAgentId)
8624
+ throw new ApiError(409, `Task ${taskId} has no Dev participant`);
8625
+ const replayed = await this.redispatchTaskPromptAfterReplRestart(task.devAgentId, taskId);
8626
+ if (!replayed)
8627
+ throw new ApiError(409, `Task ${taskId} changed or its Dev prompt could not be replayed`);
8628
+ updated = (await this.taskStore.get(taskId)) ?? task;
8629
+ }
8630
+ else if (task.status === 'approved') {
8631
+ if (!task.devAgentId)
8632
+ throw new ApiError(409, `Task ${taskId} has no Dev participant`);
8633
+ if (task.postApproveRevoked) {
8634
+ if (opts.confirmRevoked !== true) {
8635
+ throw new ApiError(409, `Task ${taskId} post-approve replay is revoked; confirmRevoked=true is required`);
8636
+ }
8637
+ task = await this.restoreRevokedPostApprove(task);
8638
+ }
8639
+ const replayed = await this.redispatchTaskPromptAfterReplRestart(task.devAgentId, taskId);
8640
+ if (!replayed)
8641
+ throw new ApiError(409, `Task ${taskId} changed or its post-approve prompt could not be replayed`);
8642
+ updated = (await this.taskStore.get(taskId)) ?? task;
8643
+ }
8644
+ else {
8645
+ throw new ApiError(409, `Task ${taskId} cannot be advanced from ${task.status}`);
8646
+ }
8647
+ await this.auditHumanTaskOperation(updated, 'advance', executor, opts.note);
8648
+ return updated;
8649
+ }
8650
+ async restoreRevokedPostApprove(task) {
8651
+ if (!task.postApproveRevoked || task.prNumber === undefined) {
8652
+ throw new ApiError(409, `Task ${task.id} has no revoked post-approve episode`);
8653
+ }
8654
+ const [binding, accepted] = await Promise.all([
8655
+ this.platformVerifyPrBinding(task.id, task.prNumber),
8656
+ this.platformVerifyAcceptedPass(task.id),
8657
+ ]);
8658
+ if (!binding.ok) {
8659
+ throw new ApiError(409, `Cannot restore task ${task.id}: PR binding ${binding.reason}`);
8660
+ }
8661
+ if (accepted.kind !== 'valid') {
8662
+ throw new ApiError(409, `Cannot restore task ${task.id}: ${accepted.reason}`);
8663
+ }
8664
+ if (accepted.pendingCount > 0) {
8665
+ throw new ApiError(409, `Cannot restore task ${task.id}: the accepted pass has pending feedback`);
8666
+ }
8667
+ const reviewedPass = task.passProvenance;
8668
+ if (!reviewedPass || binding.headSha.toLowerCase() !== reviewedPass.anchorSha.toLowerCase()) {
8669
+ throw new ApiError(409, `Cannot restore task ${task.id}: the current PR head is not the accepted head`);
8670
+ }
8671
+ return this.withTaskLock(async () => {
8672
+ const fresh = await this.taskStore.get(task.id);
8673
+ if (!fresh || fresh.status !== 'approved'
8674
+ || fresh.postApproveRevoked?.generation !== task.postApproveRevoked.generation
8675
+ || fresh.prNumber !== task.prNumber
8676
+ || !samePassProvenance(fresh.passProvenance, reviewedPass)) {
8677
+ throw new ApiError(409, `Task ${task.id} changed while restoring post-approve replay`);
8678
+ }
8679
+ const generation = createSignalToken();
8680
+ const token = createSignalTokenExcluding(generation);
8681
+ const next = {
8682
+ ...this.stripPostApproveEpisodeFields(fresh),
8683
+ postApproveGeneration: generation,
8684
+ postApproveHeadSha: binding.headSha,
8685
+ postApproveToken: token,
8686
+ postApprovePhase: 'installed',
8687
+ pendingRedispatch: true,
8688
+ redispatchCount: 0,
8689
+ updatedAt: new Date().toISOString(),
8690
+ };
8691
+ await this.taskStore.set(next);
8692
+ return next;
8693
+ });
8694
+ }
8695
+ async submitTaskVerdict(taskId, action, comments, note) {
8696
+ const task = await this.taskStore.get(taskId);
8697
+ if (!task)
8698
+ throw new ApiError(404, `Task ${taskId} not found`);
8699
+ let updated;
8700
+ if (task.status === 'spec-ready'
8701
+ || (task.status === 'max_rounds' && isSpecStagePhase(task.phase))) {
8702
+ if (action !== 'approve' && action !== 'request-changes') {
8703
+ throw new ApiError(409, `Task ${taskId} requires a spec approve or request-changes verdict`);
8704
+ }
8705
+ updated = await this.submitSpecVerdict(taskId, action, comments);
8706
+ }
8707
+ else if (task.status === 'review' && !isSpecStagePhase(task.phase)) {
8708
+ if (action !== 'pass' && action !== 'request-changes') {
8709
+ throw new ApiError(409, `Task ${taskId} requires a code pass or request-changes verdict`);
8710
+ }
8711
+ updated = await this.submitCodeVerdict(taskId, action, comments);
8712
+ }
8713
+ else if (task.status === 'max_rounds') {
8714
+ if (action === 'continue')
8715
+ updated = await this.continueDevRound(taskId);
8716
+ else if (action === 'complete')
8717
+ updated = await this.markTaskComplete(taskId);
8718
+ else
8719
+ throw new ApiError(409, `Task ${taskId} requires continue or complete at code max_rounds`);
8720
+ }
8721
+ else if (task.status === 'merge-ready') {
8722
+ if (action !== 'confirm-merge') {
8723
+ throw new ApiError(409, `Task ${taskId} requires confirm-merge`);
8724
+ }
8725
+ updated = await this.markTaskComplete(taskId);
8726
+ }
8727
+ else {
8728
+ throw new ApiError(409, `Task ${taskId} cannot accept a verdict from ${task.status}`);
8729
+ }
8730
+ await this.auditHumanTaskOperation(updated, 'verdict', action, note);
8731
+ return updated;
8732
+ }
8733
+ async auditHumanTaskOperation(task, operation, action, note, details = {}) {
8734
+ const emitted = await this.safeEmit({
8735
+ id: '',
8736
+ type: 'task.updated',
8737
+ timestamp: new Date().toISOString(),
8738
+ projectId: task.projectId,
8739
+ ...(task.agentId ? { agentId: task.agentId } : {}),
8740
+ taskId: task.id,
8741
+ data: {
8742
+ ...details,
8743
+ operation,
8744
+ action,
8745
+ actor: 'human',
8746
+ provenance: 'human',
8747
+ note: note?.trim() || `${operation}:${action}`,
8748
+ },
8749
+ });
8750
+ if (!emitted) {
8751
+ throw new ApiError(500, `Task ${task.id} ${operation} completed, but its audit event could not be persisted; verify the task state before retrying`);
8752
+ }
8753
+ }
8372
8754
  async dispatchGitFixToDev(taskId) {
8373
8755
  const task = await this.taskStore.get(taskId);
8374
8756
  if (!task || task.status !== 'fixing')
@@ -8461,7 +8843,7 @@ export class AgentManager {
8461
8843
  const devState = await this.agentStore.get(devAgentId);
8462
8844
  if (devState?.taskId !== taskId || !devState.workdir) {
8463
8845
  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`);
8846
+ `submit a complete verdict to merge the PR as-is, or cancel the task`);
8465
8847
  }
8466
8848
  const prevReviewRound = task.reviewRound;
8467
8849
  const transitioned = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['max_rounds'] }, { reviewRound: prevReviewRound + 1, fixDispatchedAt: new Date().toISOString() });
@@ -8553,26 +8935,53 @@ export class AgentManager {
8553
8935
  const t = await this.taskStore.get(taskId);
8554
8936
  if (!t)
8555
8937
  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) {
8938
+ if (!TERMINAL_STATUSES.includes(t.status)) {
8559
8939
  throw new ApiError(409, `Task ${taskId} cannot be retried in status "${t.status}"; cancel it first or wait for completion`);
8560
8940
  }
8941
+ if (t.replacementTaskId !== undefined) {
8942
+ throw new ApiError(409, `Task ${taskId} was already retried as ${t.replacementTaskId}`);
8943
+ }
8944
+ if (this.retryTaskInFlight.has(taskId)) {
8945
+ throw new ApiError(409, `Task ${taskId} retry is already in progress`);
8946
+ }
8947
+ this.retryTaskInFlight.add(taskId);
8561
8948
  return t;
8562
8949
  });
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);
8950
+ try {
8951
+ const input = {
8952
+ title: old.title,
8953
+ description: old.description,
8954
+ preferredAgentId: this.resolveRetryPreferredAgentId(old),
8955
+ };
8956
+ if (old.images?.length) {
8957
+ input.images = await this.readStagedImages(old.id, old.images);
8958
+ }
8959
+ await this.validateTaskDispatch(old.projectId, input);
8960
+ const replacement = await this.createTask(old.projectId, input);
8961
+ await this.withTaskLock(async () => {
8962
+ const source = await this.taskStore.get(taskId);
8963
+ if (!source)
8964
+ throw new ApiError(404, `Task ${taskId} disappeared while recording its retry`);
8965
+ if (source.replacementTaskId !== undefined && source.replacementTaskId !== replacement.id) {
8966
+ throw new ApiError(409, `Task ${taskId} was already retried as ${source.replacementTaskId}`);
8967
+ }
8968
+ const next = {
8969
+ ...source,
8970
+ replacementTaskId: replacement.id,
8971
+ updatedAt: new Date().toISOString(),
8972
+ };
8973
+ if (old.attention !== undefined
8974
+ && source.attention?.reason === old.attention.reason
8975
+ && source.attention.occurredAt === old.attention.occurredAt) {
8976
+ delete next.attention;
8977
+ }
8978
+ await this.taskStore.set(next);
8979
+ });
8980
+ return this.startCreatedTask(replacement);
8570
8981
  }
8571
- await this.validateTaskDispatch(old.projectId, input);
8572
- if (!TERMINAL_STATUSES.includes(old.status)) {
8573
- await this.cancelTask(old.id);
8982
+ finally {
8983
+ this.retryTaskInFlight.delete(taskId);
8574
8984
  }
8575
- return this.createAndStartTask(old.projectId, input);
8576
8985
  }
8577
8986
  resolveRetryPreferredAgentId(task) {
8578
8987
  const configured = this.getAgentConfig(task.preferredAgentId);
@@ -8582,7 +8991,7 @@ export class AgentManager {
8582
8991
  const anchors = new Set([task.qaAgentId, task.devAgentId, task.agentId]
8583
8992
  .filter((agentId) => typeof agentId === 'string' && agentId !== ''));
8584
8993
  for (const anchor of anchors) {
8585
- const dev = this.findAgentGroup(anchor)?.find(agent => TASK_OWNER_ROLES.has(agent.role));
8994
+ const dev = this.findAgentTeam(anchor)?.find(agent => TASK_OWNER_ROLES.has(agent.role));
8586
8995
  const current = dev ? this.getAgentConfig(dev.id) : undefined;
8587
8996
  if (current?.projectId === task.projectId)
8588
8997
  return current.id;
@@ -8624,13 +9033,13 @@ export class AgentManager {
8624
9033
  }
8625
9034
  if (!TASK_OWNER_ROLES.has(cfg.role))
8626
9035
  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');
9036
+ const team = this.findAgentTeam(cfg.id);
9037
+ const dev = team?.find(agent => agent.role === 'dev');
8629
9038
  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');
9039
+ throw new ApiError(409, `Agent ${cfg.id} has no dev agent in its Agent Team`);
9040
+ const qa = team?.find(agent => agent.role === 'qa');
8632
9041
  if (!qa)
8633
- throw new ApiError(409, `Agent ${cfg.id} has no qa agent in its group`);
9042
+ throw new ApiError(409, `Agent ${cfg.id} has no qa agent in its Agent Team`);
8634
9043
  task.preferredAgentId = patch.preferredAgentId;
8635
9044
  task.devAgentId = dev.id;
8636
9045
  task.qaAgentId = qa.id;
@@ -8668,22 +9077,22 @@ export class AgentManager {
8668
9077
  throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
8669
9078
  }
8670
9079
  if (isSpecStagePhase(task.phase)) {
8671
- throw new ApiError(409, `Mark complete is only supported for code-phase tasks`);
9080
+ throw new ApiError(409, `Complete verdict is only supported for code-phase tasks`);
8672
9081
  }
8673
9082
  if (!task.prNumber || !task.branch) {
8674
- throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot mark complete`);
9083
+ throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot complete`);
8675
9084
  }
8676
9085
  for (const agentId of [task.agentId, task.qaAgentId]) {
8677
9086
  if (!agentId)
8678
9087
  continue;
8679
9088
  const state = await this.agentStore.get(agentId);
8680
9089
  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`);
9090
+ throw new ApiError(409, `Agent ${agentId} is awaiting human intervention on this task; resume/restart/delete it before completing`);
8682
9091
  }
8683
9092
  }
8684
9093
  const claimed = await this.transitionTaskStatus(taskId, 'merge-ready', { fromStatus: ['max_rounds'] });
8685
9094
  if (!claimed) {
8686
- throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
9095
+ throw new ApiError(409, `Task ${taskId} changed status during complete verdict; aborted`);
8687
9096
  }
8688
9097
  try {
8689
9098
  await this.mergePr(taskId, { humanOverride: true });
@@ -9193,6 +9602,247 @@ export class AgentManager {
9193
9602
  }
9194
9603
  return parked;
9195
9604
  }
9605
+ async submitCodeVerdict(taskId, verdict, comments) {
9606
+ const trimmed = comments?.trim() ?? '';
9607
+ if (verdict === 'request-changes' && !trimmed) {
9608
+ throw new ApiError(400, 'comments is required for request-changes');
9609
+ }
9610
+ if (extractReviewTokens(trimmed).length > 0) {
9611
+ throw new ApiError(400, 'comments must not contain baxian review markers');
9612
+ }
9613
+ const task = await this.withTaskLock(async () => {
9614
+ const fresh = await this.taskStore.get(taskId);
9615
+ if (!fresh)
9616
+ throw new ApiError(404, `Task ${taskId} not found`);
9617
+ const pending = this.codeVerdictOutbox(fresh);
9618
+ const kind = verdict === 'pass' ? 'pass' : 'fail';
9619
+ if (pending !== undefined
9620
+ && (pending.data.kind !== kind || pending.data.comments !== trimmed)) {
9621
+ throw new ApiError(409, `Task ${taskId} has a different code verdict already in progress`);
9622
+ }
9623
+ if (pending === undefined && (fresh.status !== 'review' || isSpecStagePhase(fresh.phase))) {
9624
+ throw new ApiError(409, `Task ${taskId} is not awaiting a code review verdict`);
9625
+ }
9626
+ if (this.codeVerdictInFlight.has(taskId)) {
9627
+ throw new ApiError(409, `Task ${taskId} code verdict is already being processed`);
9628
+ }
9629
+ this.codeVerdictInFlight.add(taskId);
9630
+ return fresh;
9631
+ });
9632
+ try {
9633
+ return await this.executeCodeVerdict(task, verdict, trimmed);
9634
+ }
9635
+ finally {
9636
+ this.codeVerdictInFlight.delete(taskId);
9637
+ }
9638
+ }
9639
+ codeVerdictCommentBody(entry) {
9640
+ const lines = [
9641
+ entry.data.kind === 'pass'
9642
+ ? 'Human code verdict: pass'
9643
+ : 'Human code verdict: request changes',
9644
+ ];
9645
+ if (entry.data.comments)
9646
+ lines.push('', entry.data.comments);
9647
+ lines.push('', buildReviewTokenLine({
9648
+ kind: entry.data.kind,
9649
+ anchorSha: entry.data.anchorSha,
9650
+ token: entry.data.token,
9651
+ }));
9652
+ const body = lines.join('\n');
9653
+ try {
9654
+ validateCommentBody(body);
9655
+ }
9656
+ catch (err) {
9657
+ throw new ApiError(400, `Invalid code verdict comment: ${err instanceof Error ? err.message : String(err)}`);
9658
+ }
9659
+ return body;
9660
+ }
9661
+ async scanCodeVerdictMarker(driver, entry, oppositeToken) {
9662
+ const scans = await this.platformScanComments(driver, entry.data.prNumber);
9663
+ if (scans.some(scan => !scan.ok))
9664
+ return 'unknown';
9665
+ const dead = deadTokens(scans);
9666
+ if (dead.has(entry.data.token))
9667
+ return 'dead';
9668
+ let present = false;
9669
+ let opposite = false;
9670
+ for (const scan of scans) {
9671
+ for (const row of scan.rows) {
9672
+ if (row.system === true || versionTimeOf(row) === undefined)
9673
+ continue;
9674
+ for (const marker of rowTokens(row)) {
9675
+ if (dead.has(marker.token))
9676
+ continue;
9677
+ if (marker.anchorSha.toLowerCase() !== entry.data.anchorSha.toLowerCase())
9678
+ continue;
9679
+ if (marker.kind === entry.data.kind && marker.token === entry.data.token)
9680
+ present = true;
9681
+ if (marker.kind !== entry.data.kind && marker.token === oppositeToken)
9682
+ opposite = true;
9683
+ }
9684
+ }
9685
+ }
9686
+ if (opposite)
9687
+ return 'opposite';
9688
+ return present ? 'present' : 'absent';
9689
+ }
9690
+ async reconcileCodeVerdictComment(driver, entry) {
9691
+ const expectedDigest = bodyDigest(this.codeVerdictCommentBody(entry));
9692
+ const scans = await this.platformScanComments(driver, entry.data.prNumber);
9693
+ if (scans.some(scan => !scan.ok))
9694
+ return 'unknown';
9695
+ if (deadTokens(scans).has(entry.data.token))
9696
+ return 'dead';
9697
+ if (scans.some(scan => scan.rows.some(row => row.system !== true
9698
+ && versionTimeOf(row) !== undefined
9699
+ && row.bodyDigest === expectedDigest)))
9700
+ return 'present';
9701
+ return 'absent';
9702
+ }
9703
+ async verifyCodeVerdictHead(taskId, prNumber, anchorSha) {
9704
+ try {
9705
+ const binding = await this.platformVerifyPrBinding(taskId, prNumber);
9706
+ if (!binding.ok)
9707
+ return { ok: false, reason: `the PR binding is ${binding.reason}` };
9708
+ if (binding.headSha.toLowerCase() !== anchorSha.toLowerCase()) {
9709
+ return { ok: false, reason: 'the PR head changed' };
9710
+ }
9711
+ return { ok: true };
9712
+ }
9713
+ catch (err) {
9714
+ throw new ApiError(503, `Cannot verify code verdict head for task ${taskId}: ${safeDriverErrorText(err)}`);
9715
+ }
9716
+ }
9717
+ async deliverCodeVerdictOutbox(taskId, key) {
9718
+ let task = await this.taskStore.get(taskId);
9719
+ let entry = task ? this.codeVerdictOutbox(task) : undefined;
9720
+ if (!task || !entry || entry.key !== key)
9721
+ return;
9722
+ const driver = this.platformDriverFor(task.projectId);
9723
+ if (!driver)
9724
+ throw new ApiError(503, `Git driver unavailable for task ${task.id}`);
9725
+ const binding = await this.verifyCodeVerdictHead(task.id, entry.data.prNumber, entry.data.anchorSha);
9726
+ if (!binding.ok) {
9727
+ await this.mutateTaskOutboxEntry(task.id, key, current => current.type === 'git.code-verdict' ? undefined : null);
9728
+ throw new ApiError(409, `Cannot write code verdict for task ${task.id}: ${binding.reason}`);
9729
+ }
9730
+ if (entry.data.writeAttemptedAt !== undefined) {
9731
+ const reconciled = await this.reconcileCodeVerdictComment(driver, entry);
9732
+ if (reconciled === 'dead') {
9733
+ await this.mutateTaskOutboxEntry(task.id, key, current => current.type === 'git.code-verdict' ? undefined : null);
9734
+ throw new ApiError(409, `Code verdict token for task ${task.id} was dismissed; advance QA to create a new review pass`);
9735
+ }
9736
+ if (reconciled === 'present') {
9737
+ return;
9738
+ }
9739
+ if (reconciled === 'unknown'
9740
+ || Date.now() - Date.parse(entry.data.writeAttemptedAt) < driver.visibilityLagMs) {
9741
+ throw new ApiError(503, `Code verdict comment outcome is still being reconciled for task ${task.id}`);
9742
+ }
9743
+ }
9744
+ const expectedAttempt = entry.data.writeAttemptedAt;
9745
+ const claimed = await this.mutateTaskOutboxEntry(task.id, entry.key, current => current.type === 'git.code-verdict' && current.data.writeAttemptedAt === expectedAttempt
9746
+ ? { ...current, data: { ...current.data, writeAttemptedAt: new Date().toISOString() } }
9747
+ : null);
9748
+ const claimedEntry = claimed ? this.codeVerdictOutbox(claimed) : undefined;
9749
+ if (!claimed || claimedEntry?.key !== entry.key) {
9750
+ throw new ApiError(503, `Code verdict comment write is already claimed for task ${task.id}`);
9751
+ }
9752
+ task = claimed;
9753
+ entry = claimedEntry;
9754
+ let writeFailure;
9755
+ try {
9756
+ await driver.runOp('comment', {
9757
+ prNumber: entry.data.prNumber,
9758
+ body: this.codeVerdictCommentBody(entry),
9759
+ });
9760
+ }
9761
+ catch (error) {
9762
+ writeFailure = { error };
9763
+ }
9764
+ const reconciled = await this.reconcileCodeVerdictComment(driver, entry);
9765
+ if (reconciled === 'dead') {
9766
+ await this.mutateTaskOutboxEntry(task.id, key, current => current.type === 'git.code-verdict' ? undefined : null);
9767
+ throw new ApiError(409, `Code verdict token for task ${task.id} was dismissed; advance QA to create a new review pass`);
9768
+ }
9769
+ if (writeFailure !== undefined && reconciled !== 'present') {
9770
+ const { error } = writeFailure;
9771
+ const uncertain = !(error instanceof DriverOpError)
9772
+ || error.info.exitCode === 255
9773
+ || isTransientNetworkFailure(error.info.stderrTail ?? '')
9774
+ || isTransientNetworkFailure(error.message);
9775
+ if (reconciled === 'unknown' || uncertain) {
9776
+ throw new ApiError(503, `Code verdict comment outcome is unknown for task ${task.id}; the persisted outbox will reconcile it`);
9777
+ }
9778
+ await this.mutateTaskOutboxEntry(task.id, key, current => current.type === 'git.code-verdict' ? undefined : null);
9779
+ throw new ApiError(502, `Code verdict comment was rejected for task ${task.id}: ${safeDriverErrorText(error)}`);
9780
+ }
9781
+ if (reconciled !== 'present') {
9782
+ throw new ApiError(503, `Code verdict comment is not yet visible for task ${task.id}; the persisted outbox will reconcile it`);
9783
+ }
9784
+ }
9785
+ async executeCodeVerdict(task, verdict, comments) {
9786
+ if (task.status !== 'review' || isSpecStagePhase(task.phase)
9787
+ || task.prNumber === undefined || task.reviewHeadAnchorSha === undefined
9788
+ || task.passToken === undefined || task.failToken === undefined) {
9789
+ throw new ApiError(409, `Task ${task.id} has no complete code review generation`);
9790
+ }
9791
+ const kind = verdict === 'pass' ? 'pass' : 'fail';
9792
+ const token = kind === 'pass' ? task.passToken : task.failToken;
9793
+ const existingEntry = this.codeVerdictOutbox(task);
9794
+ const entry = existingEntry ?? {
9795
+ key: token,
9796
+ type: 'git.code-verdict',
9797
+ data: {
9798
+ prNumber: task.prNumber,
9799
+ kind,
9800
+ anchorSha: task.reviewHeadAnchorSha,
9801
+ token,
9802
+ comments,
9803
+ },
9804
+ };
9805
+ this.codeVerdictCommentBody(entry);
9806
+ const driver = this.platformDriverFor(task.projectId);
9807
+ if (!driver)
9808
+ throw new ApiError(503, `Git driver unavailable for task ${task.id}`);
9809
+ const binding = await this.verifyCodeVerdictHead(task.id, task.prNumber, task.reviewHeadAnchorSha);
9810
+ if (!binding.ok) {
9811
+ throw new ApiError(409, `Cannot submit code verdict for task ${task.id}: ${binding.reason}`);
9812
+ }
9813
+ if (existingEntry === undefined) {
9814
+ const marker = await this.scanCodeVerdictMarker(driver, entry, kind === 'pass' ? task.failToken : task.passToken);
9815
+ if (marker === 'present')
9816
+ return task;
9817
+ if (marker === 'opposite') {
9818
+ throw new ApiError(409, `Task ${task.id} already has the opposite verdict on the current review head`);
9819
+ }
9820
+ if (marker === 'dead') {
9821
+ throw new ApiError(409, `Code verdict token for task ${task.id} was dismissed; advance QA to create a new review pass`);
9822
+ }
9823
+ if (marker === 'unknown') {
9824
+ throw new ApiError(503, `Cannot safely check existing code verdict comments for task ${task.id}`);
9825
+ }
9826
+ const created = await this.withTaskLock(async () => {
9827
+ const fresh = await this.taskStore.get(task.id);
9828
+ if (!fresh || !taskMatchesGeneration(fresh, taskGenerationGuard(task))
9829
+ || this.codeVerdictOutbox(fresh) !== undefined)
9830
+ return null;
9831
+ const next = {
9832
+ ...fresh,
9833
+ outbox: [...(fresh.outbox ?? []), entry],
9834
+ updatedAt: new Date().toISOString(),
9835
+ };
9836
+ await this.taskStore.set(next);
9837
+ return next;
9838
+ });
9839
+ if (!created)
9840
+ throw new ApiError(409, `Task ${task.id} changed while recording the code verdict`);
9841
+ task = created;
9842
+ }
9843
+ await this.deliverCodeVerdictOutbox(task.id, entry.key);
9844
+ return (await this.taskStore.get(task.id)) ?? task;
9845
+ }
9196
9846
  async submitSpecVerdict(taskId, verdict, comments) {
9197
9847
  const trimmed = comments?.trim();
9198
9848
  if (verdict === 'request-changes' && !trimmed) {
@@ -9849,8 +10499,8 @@ export class AgentManager {
9849
10499
  function buildAgentIndex(config) {
9850
10500
  const index = new Map();
9851
10501
  for (const project of config.project) {
9852
- for (const pair of project.agent) {
9853
- for (const agent of pair) {
10502
+ for (const team of project.agent) {
10503
+ for (const agent of team) {
9854
10504
  index.set(agent.id, { ...agent, projectId: project.id });
9855
10505
  }
9856
10506
  }