baxian 2.0.31 → 2.0.33

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 (63) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/agent/branch.d.ts +2 -1
  4. package/dist/agent/branch.d.ts.map +1 -1
  5. package/dist/agent/branch.js +17 -4
  6. package/dist/agent/branch.js.map +1 -1
  7. package/dist/agent/manager.d.ts +19 -0
  8. package/dist/agent/manager.d.ts.map +1 -1
  9. package/dist/agent/manager.js +298 -41
  10. package/dist/agent/manager.js.map +1 -1
  11. package/dist/agent/tmux.d.ts +4 -1
  12. package/dist/agent/tmux.d.ts.map +1 -1
  13. package/dist/agent/tmux.js +27 -0
  14. package/dist/agent/tmux.js.map +1 -1
  15. package/dist/agent/untracked-files.d.ts +23 -0
  16. package/dist/agent/untracked-files.d.ts.map +1 -0
  17. package/dist/agent/untracked-files.js +218 -0
  18. package/dist/agent/untracked-files.js.map +1 -0
  19. package/dist/api/projects.d.ts.map +1 -1
  20. package/dist/api/projects.js +0 -11
  21. package/dist/api/projects.js.map +1 -1
  22. package/dist/api/tasks.d.ts.map +1 -1
  23. package/dist/api/tasks.js +12 -0
  24. package/dist/api/tasks.js.map +1 -1
  25. package/dist/app.d.ts +0 -2
  26. package/dist/app.d.ts.map +1 -1
  27. package/dist/app.js +0 -2
  28. package/dist/app.js.map +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -5
  31. package/dist/index.js.map +1 -1
  32. package/dist/shared/constants.d.ts +5 -6
  33. package/dist/shared/constants.d.ts.map +1 -1
  34. package/dist/shared/constants.js +6 -6
  35. package/dist/shared/constants.js.map +1 -1
  36. package/dist/shared/types.d.ts +6 -9
  37. package/dist/shared/types.d.ts.map +1 -1
  38. package/dist/shared/types.js.map +1 -1
  39. package/dist/state/snapshot.d.ts +1 -5
  40. package/dist/state/snapshot.d.ts.map +1 -1
  41. package/dist/state/snapshot.js +5 -30
  42. package/dist/state/snapshot.js.map +1 -1
  43. package/dist/state/task-store.d.ts.map +1 -1
  44. package/dist/state/task-store.js +11 -2
  45. package/dist/state/task-store.js.map +1 -1
  46. package/dist/web/assets/index-CqHBL6U6.css +1 -0
  47. package/dist/web/assets/index-DTb6HI9L.js +19 -0
  48. package/dist/web/index.html +2 -2
  49. package/package.json +1 -1
  50. package/dist/agent/pet-input.d.ts +0 -18
  51. package/dist/agent/pet-input.d.ts.map +0 -1
  52. package/dist/agent/pet-input.js +0 -88
  53. package/dist/agent/pet-input.js.map +0 -1
  54. package/dist/api/pets.d.ts +0 -3
  55. package/dist/api/pets.d.ts.map +0 -1
  56. package/dist/api/pets.js +0 -77
  57. package/dist/api/pets.js.map +0 -1
  58. package/dist/state/pet-store.d.ts +0 -35
  59. package/dist/state/pet-store.d.ts.map +0 -1
  60. package/dist/state/pet-store.js +0 -189
  61. package/dist/state/pet-store.js.map +0 -1
  62. package/dist/web/assets/index-B77QkCk7.css +0 -1
  63. package/dist/web/assets/index-CbTk_gj8.js +0 -19
@@ -1,11 +1,11 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
- import { randomBytes } from 'node:crypto';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
3
  import { AsyncLocalStorage } from 'node:async_hooks';
4
4
  import { join, normalize } from 'node:path';
5
- import { tmpdir } from 'node:os';
5
+ import { hostname, tmpdir } from 'node:os';
6
6
  import { createSignalToken, PASSIVE_VERDICT_WATCH } from './phase-signal.js';
7
7
  import { tmuxInstallHint } from './preflight.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, TERMINAL_INTERVENTION_PHASES, effectiveTaskReviewRound, isSpecStagePhase, isTaskOpen, taskAttentionGeneration, taskGenerationGuard, taskReviewRound, taskMatchesGeneration, } from '../shared/index.js';
8
+ import { BRANCH_PREFIX, isValidBranchName, retainedUntrackedFilesWithinLimit, 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, TERMINAL_INTERVENTION_PHASES, effectiveTaskReviewRound, isSpecStagePhase, isTaskOpen, taskAttentionGeneration, taskGenerationGuard, taskReviewRound, taskMatchesGeneration, } from '../shared/index.js';
9
9
  import { buildProjectDriver, buildProjectPromptContext, makeDriverExec, repoIdentityKey, } from '../platform/driver-host.js';
10
10
  import { DriverOpError, safeDriverErrorText, validateCommentBody, } from '../platform/types.js';
11
11
  import { versionTimeOf } from '../platform/row-schema.js';
@@ -22,6 +22,7 @@ import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js
22
22
  import { TmuxManager, TmuxOutcomeUnknownError, ReplNotReadyError, isBlockedOnStartupDialog, hasRuntimeReadyView, hasReplProcTitle, isShellProcTitle, PaneGoneError, } from './tmux.js';
23
23
  import { BranchManager, DirtyWorkdirError, ReviewHeadMismatchError, isAutoDeletableTaskBranch, } from './branch.js';
24
24
  import { RepoStore, createRepoStoreCache } from './repo-store.js';
25
+ import { inspectUntrackedFiles } from './untracked-files.js';
25
26
  import { PhaseSignalWatcher, } from './phase-signal-watcher.js';
26
27
  import { buildPromptInline, buildGreetingPrompt, PromptSizeError, MAX_PROMPT_BYTES_ROUTE_LIMIT, } from './prompt.js';
27
28
  import { ApiError } from '../errors.js';
@@ -182,9 +183,9 @@ const RUNTIME_LIVENESS_SAMPLES = 3;
182
183
  export function canDispatchWithBinding(binding) {
183
184
  return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
184
185
  }
186
+ const CHECKOUT_PREPARATION_HOLD_PHASES = new Set(['checkout-preparation-failed', 'dirty-workdir']);
185
187
  const RECOVERABLE_QA_DISPATCH_HOLD_PHASES = new Set([
186
- 'checkout-preparation-failed',
187
- 'dirty-workdir',
188
+ ...CHECKOUT_PREPARATION_HOLD_PHASES,
188
189
  'branch-cleanup-pending',
189
190
  ]);
190
191
  export function isRecoverableQaDispatchHold(binding) {
@@ -211,6 +212,30 @@ function cancelPhaseDowngrades(prev, next) {
211
212
  return cancelPhaseRank(next) < cancelPhaseRank(prev);
212
213
  }
213
214
  const REGREET_REQUIRED_HOLD_PHASES = new Set(['greeting_failed']);
215
+ const TASK_REPLAY_FAILED_RUNBOOK = 'Resolve the cause, then retry the current step from the task page. '
216
+ + 'If the task branch was never created, cancel the task and run it again. '
217
+ + 'Resume does not resend the task prompt.';
218
+ const DELIVERED_BOOTSTRAP_RUNBOOK = 'The initial prompt was already delivered; replaying would dispatch the task twice. '
219
+ + 'Verify the task outcome in the terminal, or cancel the task.';
220
+ const PROMPT_MAYBE_RUNNING_PHASES = new Set([
221
+ 'dispatch-failed:ack_unknown',
222
+ 'dev-wait-gate-failed-after-qa-started',
223
+ ]);
224
+ function taskReplayFailure(error) {
225
+ const dirty = error instanceof DirtyWorkdirError;
226
+ const message = error instanceof Error ? error.message : String(error);
227
+ if (error instanceof DispatchTerminalError && error.reason === 'ack_unknown') {
228
+ return {
229
+ phase: 'dispatch-failed:ack_unknown',
230
+ reason: `Task prompt delivery requires verification: ${message}. The prompt may already be running. Verify the task outcome in the terminal, or cancel the task.`,
231
+ };
232
+ }
233
+ const preparation = dirty ? 'Save the Workdir changes by committing or stashing them first. ' : '';
234
+ return {
235
+ phase: dirty ? 'dirty-workdir' : 'restart-redispatch-failed',
236
+ reason: `Task prompt replay failed: ${message}. ${preparation}${TASK_REPLAY_FAILED_RUNBOOK}`,
237
+ };
238
+ }
214
239
  const RECOVERY_FAILED_AGENT_RUNBOOK = 'Fix the agent Workdir (or wait for the transient failure to clear), then Resume the agent.';
215
240
  const RECOVERY_FAILED_TASK_RUNBOOK = 'Fix the agent Workdir (or wait for the transient failure to clear), '
216
241
  + 'then Resume the agent and use the recommended action on the task page.';
@@ -1856,14 +1881,14 @@ export class AgentManager {
1856
1881
  }
1857
1882
  }
1858
1883
  if (cfg.role === 'dev' && boundTask) {
1859
- const branches = new BranchManager(runner);
1884
+ const branches = this.taskBranchManager(runner, agentId, expectedTaskId);
1860
1885
  const cleanup = await branches.cleanupTaskBranch(releaseWorkdir, this.cleanupIdentityFor(boundTask), () => this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir).then(() => undefined));
1861
1886
  await this.persistBranchCleanupOutcome(boundTask, agentId, cleanup);
1862
1887
  if (cleanup.status !== 'deleted')
1863
1888
  await branches.parkOnDefaultDetached(releaseWorkdir);
1864
1889
  }
1865
1890
  else {
1866
- await new BranchManager(runner).parkOnDefaultDetached(releaseWorkdir);
1891
+ await this.taskBranchManager(runner, agentId, expectedTaskId).parkOnDefaultDetached(releaseWorkdir);
1867
1892
  }
1868
1893
  await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1869
1894
  }
@@ -2066,7 +2091,7 @@ export class AgentManager {
2066
2091
  if (!(await this.lockManager.isOwner(cfg.id, owner, token)))
2067
2092
  continue;
2068
2093
  for (const task of targets) {
2069
- const cleanup = await branches.cleanupTaskBranch(workdir, this.cleanupIdentityFor(task), () => this.assertTaskLockOwner(cfg.id, owner, token));
2094
+ const cleanup = await this.taskBranchManager(runner, cfg.id, task.id).cleanupTaskBranch(workdir, this.cleanupIdentityFor(task), () => this.assertTaskLockOwner(cfg.id, owner, token));
2070
2095
  cleanupResults.push({ task, cleanup });
2071
2096
  }
2072
2097
  }
@@ -2711,16 +2736,25 @@ export class AgentManager {
2711
2736
  return { resumed: false, releasedBinding: false, reason };
2712
2737
  }
2713
2738
  const boundTask = state.taskId ? await this.taskStore.get(state.taskId) : null;
2714
- const PROMPT_MAYBE_RUNNING_PHASES = new Set([
2715
- 'dispatch-failed:ack_unknown',
2716
- 'dev-wait-gate-failed-after-qa-started',
2717
- ]);
2739
+ if (boundTask && ((state.awaitingPhase === 'restart-redispatch-failed'
2740
+ && (boundTask.status === 'in_progress' || boundTask.status === 'fixing' || boundTask.status === 'approved'))
2741
+ || (state.awaitingPhase === 'bootstrap-marker-clear-failed' && boundTask.status === 'in_progress'))) {
2742
+ const runbook = state.awaitingPhase === 'bootstrap-marker-clear-failed'
2743
+ ? DELIVERED_BOOTSTRAP_RUNBOOK : TASK_REPLAY_FAILED_RUNBOOK;
2744
+ const reason = `Resume is blocked for active task ${boundTask.id} (${state.awaitingPhase}). ${runbook}`;
2745
+ console.warn(`[AgentManager] resumeAgent: agent ${agentId} — ${reason}`);
2746
+ return { resumed: false, releasedBinding: false, reason };
2747
+ }
2718
2748
  if (state.awaitingPhase != null
2719
2749
  && PROMPT_MAYBE_RUNNING_PHASES.has(state.awaitingPhase)
2720
- && boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)) {
2750
+ && boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)
2751
+ && !(state.awaitingPhase === 'dispatch-failed:ack_unknown'
2752
+ && boundTask.devAgentId === agentId && boundTask.qaAgentId !== agentId
2753
+ && ['spec-ready', 'review', 'merge-ready', 'max_rounds'].includes(boundTask.status))) {
2721
2754
  const uncertainRecovery = state.awaitingPhase === 'dispatch-failed:ack_unknown'
2755
+ && boundTask.status === 'review' && boundTask.qaAgentId === agentId
2722
2756
  ? ' Confirm the uncertain review dispatch as not delivered, cancel the task,'
2723
- : ` Cancel task ${state.taskId}`;
2757
+ : ` Verify the task outcome in the terminal, cancel task ${state.taskId},`;
2724
2758
  const reason = `Prompt may still be running (${state.awaitingPhase}); Resume is blocked until the task outcome arrives.${uncertainRecovery} or DELETE the agent to recover.`;
2725
2759
  console.warn(`[AgentManager] resumeAgent: agent ${agentId} — ${reason}`);
2726
2760
  return { resumed: false, releasedBinding: false, reason };
@@ -3430,7 +3464,7 @@ export class AgentManager {
3430
3464
  await tmux.clearComposerDraft(pane, runtime);
3431
3465
  // 退出文本与 Enter 分两次守卫写:前台在两者之间换了进程,回车就不发,/exit 留在草稿里等下一次清稿,而不会打进 shell 或别的进程
3432
3466
  try {
3433
- if (!await this.reachedRuntime(() => tmux.submitToRuntime(pane, runtime, REPL_EXIT_COMMAND[cfg.runtime])))
3467
+ if (!await this.reachedRuntime(() => tmux.submitToRuntime(pane, runtime, REPL_EXIT_COMMAND[cfg.runtime], { expectRuntimeExit: true })))
3434
3468
  return;
3435
3469
  }
3436
3470
  catch (err) {
@@ -3643,7 +3677,8 @@ export class AgentManager {
3643
3677
  retireOldOnce();
3644
3678
  if (!(await args.stillCurrent(newToken)))
3645
3679
  return false;
3646
- const held = await args.holdFailure(newToken, `REPL restarted but replaying the task prompt failed: ${err.message}. Resume to retry, or cancel the task.`);
3680
+ const failure = taskReplayFailure(err);
3681
+ const held = await args.holdFailure(newToken, failure.phase, failure.reason);
3647
3682
  if (held)
3648
3683
  return true;
3649
3684
  if (!(await args.stillCurrent(newToken)))
@@ -3703,7 +3738,13 @@ export class AgentManager {
3703
3738
  };
3704
3739
  const resumed = await args.continueWith(newToken, fence);
3705
3740
  if (resumed) {
3706
- await args.afterDelivered?.(newToken);
3741
+ try {
3742
+ await args.afterDelivered?.(newToken);
3743
+ }
3744
+ catch (err) {
3745
+ const message = err instanceof Error ? err.message : String(err);
3746
+ throw new DispatchTerminalError('ack_unknown', `The prompt for task ${taskId} was already delivered, but saving its delivery status failed: ${message}`);
3747
+ }
3707
3748
  return true;
3708
3749
  }
3709
3750
  releaseClaimOnce();
@@ -3758,14 +3799,17 @@ export class AgentManager {
3758
3799
  });
3759
3800
  const holdReplayFailure = (phase, reason) => this.holdReplayFailureIfCurrent(agentId, task, phase, reason, entryHold);
3760
3801
  try {
3802
+ const mapped = this.mapTaskStateToExpectedWatcher(task);
3803
+ if (entryHold.phase && PROMPT_MAYBE_RUNNING_PHASES.has(entryHold.phase)
3804
+ && ((task.status === 'approved' && agent.role === 'dev') || mapped?.agentId === agentId))
3805
+ return true;
3761
3806
  if (task.status === 'approved' && agent.role === 'dev') {
3762
3807
  return await this.replayApprovedHolderAfterReplRestart(agentId, task, entryHold, { clearEntryHold, holdReplayFailure });
3763
3808
  }
3764
- const mapped = this.mapTaskStateToExpectedWatcher(task);
3765
3809
  if (!mapped || mapped.agentId !== agentId)
3766
3810
  return false;
3767
3811
  if (!task.signalToken) {
3768
- return await holdReplayFailure('restart-redispatch-failed', 'REPL restarted mid-pass but the task has no signal token to replay the prompt with; cancel the task or re-dispatch it.');
3812
+ return await holdReplayFailure('restart-redispatch-failed', 'The task has no signal token to replay its prompt with; cancel the task and run it again.');
3769
3813
  }
3770
3814
  const signalToken = task.signalToken;
3771
3815
  const replayTask = (phase, opts = {}, afterDelivered) => this.runRotatedReplay({
@@ -3788,7 +3832,7 @@ export class AgentManager {
3788
3832
  return true;
3789
3833
  },
3790
3834
  stillCurrent: taskStillCurrent,
3791
- holdFailure: (newToken, reason) => this.holdReplayFailureIfCurrent(agentId, { ...task, signalToken: newToken }, 'restart-redispatch-failed', reason, entryHold),
3835
+ holdFailure: (newToken, phase, reason) => this.holdReplayFailureIfCurrent(agentId, { ...task, signalToken: newToken }, phase, reason, entryHold),
3792
3836
  continueWith: (newToken, fence) => this.continueSession(taskId, agentId, phase, {
3793
3837
  ...opts,
3794
3838
  signalToken: newToken,
@@ -3799,7 +3843,7 @@ export class AgentManager {
3799
3843
  });
3800
3844
  const replayInitialBootstrap = async (phase, opts) => {
3801
3845
  if (entryHold.phase === 'bootstrap-marker-clear-failed') {
3802
- return holdReplayFailure('restart-redispatch-failed', 'REPL restarted while the initial prompt was already delivered (its bootstrap marker clear had failed); replaying would dispatch the task twice. Resume and verify the Workdir, or cancel the task.');
3846
+ return holdReplayFailure('bootstrap-marker-clear-failed', DELIVERED_BOOTSTRAP_RUNBOOK);
3803
3847
  }
3804
3848
  const bindingBefore = await this.agentStore.get(agentId);
3805
3849
  if (!(await clearEntryHold()))
@@ -3845,8 +3889,8 @@ export class AgentManager {
3845
3889
  return false;
3846
3890
  }
3847
3891
  catch (err) {
3848
- const message = err instanceof Error ? err.message : String(err);
3849
- if (await holdReplayFailure('restart-redispatch-failed', `REPL restarted but replaying the task prompt failed: ${message}. Resume to retry, or cancel the task.`)) {
3892
+ const failure = taskReplayFailure(err);
3893
+ if (await holdReplayFailure(failure.phase, failure.reason)) {
3850
3894
  return true;
3851
3895
  }
3852
3896
  throw err;
@@ -3915,7 +3959,7 @@ export class AgentManager {
3915
3959
  return true;
3916
3960
  },
3917
3961
  stillCurrent: completionStillCurrent,
3918
- holdFailure: (newToken, reason) => this.holdReplayFailureIfCurrent(agentId, task, 'restart-redispatch-failed', reason, entryHold, async (fresh) => episodeMatches(fresh) && completionTokenMatches(fresh, newToken)),
3962
+ holdFailure: (newToken, phase, reason) => this.holdReplayFailureIfCurrent(agentId, task, phase, reason, entryHold, async (fresh) => episodeMatches(fresh) && completionTokenMatches(fresh, newToken)),
3919
3963
  continueWith: (newToken, fence) => this.continueSession(taskId, agentId, 'post-approve', {
3920
3964
  signalToken: newToken,
3921
3965
  postApproveEpisode: {
@@ -4212,6 +4256,10 @@ export class AgentManager {
4212
4256
  else {
4213
4257
  recommendedActions = ['advance', 'cancel'];
4214
4258
  }
4259
+ if (reason === 'bootstrap-marker-clear-failed'
4260
+ || (reason === 'dispatch-failed:ack_unknown' && task.status !== 'review')) {
4261
+ recommendedActions = recommendedActions.filter(action => action !== 'advance');
4262
+ }
4215
4263
  if (task.attention
4216
4264
  && Date.parse(task.attention.occurredAt) > Date.parse(occurredAt))
4217
4265
  return;
@@ -6013,6 +6061,204 @@ export class AgentManager {
6013
6061
  return guarded.ran ? guarded.value : null;
6014
6062
  });
6015
6063
  }
6064
+ untrackedFilesHost(agent) {
6065
+ return hostGroupKey(agent.mode, resolveAgentHost(this.config.host, agent.host));
6066
+ }
6067
+ taskBranchManager(runner, agentId, taskId) {
6068
+ return new BranchManager(runner, async (workdir) => {
6069
+ const agent = this.getAgentConfig(agentId);
6070
+ const task = await this.taskStore.get(taskId);
6071
+ return task?.retainedUntrackedFiles?.find(entry => entry.agentId === agentId
6072
+ && entry.workdir === workdir && agent && entry.host === this.untrackedFilesHost(agent))?.pathsBase64 ?? [];
6073
+ });
6074
+ }
6075
+ supportsUntrackedFiles(binding, agent) {
6076
+ return binding?.status === 'awaiting_human' && !!agent
6077
+ && (RECOVERABLE_QA_DISPATCH_HOLD_PHASES.has(binding.awaitingPhase ?? '')
6078
+ || (agent.role === 'dev' && binding.awaitingPhase === 'restart-redispatch-failed'));
6079
+ }
6080
+ async untrackedFilesContext(taskId, agentId) {
6081
+ const task = await this.taskStore.get(taskId);
6082
+ const binding = await this.agentStore.get(agentId);
6083
+ const agent = this.getAgentConfig(agentId);
6084
+ if (!task || !agent || binding?.taskId !== taskId || !binding.workdir || agent.projectId !== task.projectId) {
6085
+ throw new ApiError(409, 'Task or agent binding changed; refresh the task');
6086
+ }
6087
+ if (this.isDeletionInFlight(agentId) || this.cancelCleanupInFlight.has(taskId)) {
6088
+ throw new ApiError(409, 'Task cancellation or agent removal is in progress; wait for it to finish');
6089
+ }
6090
+ if (this.compactInFlight.has(agentId) || this.maintenanceInFlight.has(agentId)) {
6091
+ throw new ApiError(409, 'Agent maintenance is in progress; refresh the file list when it finishes');
6092
+ }
6093
+ if (this.resolveWorkdir(agent, binding) !== binding.workdir)
6094
+ throw new ApiError(409, 'Agent working directory changed; check its configuration');
6095
+ if (task.reviewDispatch?.phase === 'uncertain' || task.reviewDispatch?.phase === 'claimed') {
6096
+ throw new ApiError(409, 'Review delivery is pending or uncertain; verify its outcome before handling files');
6097
+ }
6098
+ if (!this.supportsUntrackedFiles(binding, agent))
6099
+ throw new ApiError(409, 'This agent hold cannot be resumed by handling files');
6100
+ const claim = await this.lockManager.claimOf(agentId);
6101
+ if (claim?.taskId !== taskId || (binding.lockToken && binding.lockToken !== claim.token)) {
6102
+ throw new ApiError(409, 'Agent ownership changed; refresh the task');
6103
+ }
6104
+ const host = this.untrackedFilesHost(agent);
6105
+ const retained = task.retainedUntrackedFiles?.find(entry => entry.agentId === agentId
6106
+ && entry.workdir === binding.workdir && entry.host === host)?.pathsBase64 ?? [];
6107
+ let recovery;
6108
+ if (agent.role === 'qa' && task.status === 'review') {
6109
+ if (task.qaAgentId !== agentId || !task.phase || task.deliveryConfirmation?.phase !== task.phase || !task.prNumber) {
6110
+ throw new ApiError(409, 'Review delivery must be verified in the task before handling files');
6111
+ }
6112
+ recovery = 'qa';
6113
+ }
6114
+ else if (binding.awaitingPhase === 'branch-cleanup-pending' || agent.role === 'qa'
6115
+ || task.status === 'review' || TERMINAL_STATUSES.includes(task.status)) {
6116
+ recovery = 'release';
6117
+ }
6118
+ else if (['in_progress', 'fixing', 'approved'].includes(task.status)) {
6119
+ if (task.status === 'approved' && task.postApproveRevoked)
6120
+ throw new ApiError(409, 'Post-approval work was revoked; confirm recovery in the task before handling files');
6121
+ recovery = 'dev';
6122
+ }
6123
+ else {
6124
+ throw new ApiError(409, `Task cannot continue from ${task.status}; use the task actions before handling files`);
6125
+ }
6126
+ const devFallback = task.branchLocalCleaned?.remoteTipSha
6127
+ ?? (binding.bootstrappingTaskId === taskId && CHECKOUT_PREPARATION_HOLD_PHASES.has(binding.awaitingPhase ?? '') ? 'origin/HEAD' : undefined);
6128
+ const targetRefs = recovery === 'release' ? ['origin/HEAD'] : recovery === 'qa'
6129
+ ? ['origin/HEAD', task.reviewHeadAnchorSha ?? task.latestHeadSha ?? `refs/remotes/origin/${task.branch}`]
6130
+ : [[`refs/heads/${task.branch}`, ...(devFallback ? [devFallback] : [])]];
6131
+ return { task, binding: { ...binding, lockToken: claim.token }, agent, host, retained, recovery, targetRefs,
6132
+ workdir: binding.workdir, runner: this.createRunnerFor(agent) };
6133
+ }
6134
+ untrackedFilesToken(context, fingerprint) {
6135
+ return createHash('sha256').update(JSON.stringify({
6136
+ taskId: context.task.id, generation: taskAttentionGeneration(context.task),
6137
+ head: context.task.reviewHeadAnchorSha, latestHead: context.task.latestHeadSha,
6138
+ branch: context.task.branch, agentId: context.agent.id, host: context.host,
6139
+ workdir: context.workdir, lockToken: context.binding.lockToken,
6140
+ hold: context.binding.awaitingPhase, nonce: context.binding.awaitingNonce,
6141
+ pane: context.binding.paneId, creation: context.binding.creationToken,
6142
+ bootstrap: context.binding.bootstrappingTaskId, reviewDispatch: context.task.reviewDispatch,
6143
+ branchLocalCleaned: context.task.branchLocalCleaned, recovery: context.recovery,
6144
+ runtime: context.agent.runtime, fingerprint, retained: context.retained,
6145
+ })).digest('hex');
6146
+ }
6147
+ retainedUntrackedSelection(context, pathsBase64) {
6148
+ return [
6149
+ ...(context.task.retainedUntrackedFiles ?? []).filter(entry => entry.agentId !== context.agent.id
6150
+ || entry.workdir !== context.workdir || entry.host !== context.host),
6151
+ { agentId: context.agent.id, host: context.host, workdir: context.workdir, pathsBase64: [...new Set([...context.retained, ...pathsBase64])] },
6152
+ ];
6153
+ }
6154
+ async getUntrackedFiles(taskId, agentId) {
6155
+ if (!this.supportsUntrackedFiles(await this.agentStore.get(agentId), this.getAgentConfig(agentId)))
6156
+ return null;
6157
+ const context = await this.untrackedFilesContext(taskId, agentId);
6158
+ const snapshot = await inspectUntrackedFiles(context.runner, context.workdir, undefined, context.targetRefs);
6159
+ return this.withTaskLock(async () => {
6160
+ const fresh = await this.untrackedFilesContext(taskId, agentId);
6161
+ if (this.untrackedFilesToken(context, snapshot.fingerprint) !== this.untrackedFilesToken(fresh, snapshot.fingerprint)) {
6162
+ throw new ApiError(409, 'Task changed during file inspection; refresh the file list');
6163
+ }
6164
+ const files = snapshot.files.filter(file => !fresh.retained.includes(file.pathBase64));
6165
+ const pending = new Set(files.map(file => file.pathBase64));
6166
+ return {
6167
+ agentId, host: fresh.agent.mode === 'remote' ? fresh.agent.host : hostname(),
6168
+ workdir: fresh.workdir, token: this.untrackedFilesToken(fresh, snapshot.fingerprint), files,
6169
+ trackedChanges: snapshot.trackedChanges, conflicts: snapshot.conflicts,
6170
+ manualCleanupRequired: snapshot.conflicts.some(file => !pending.has(file.pathBase64)),
6171
+ keepLimitExceeded: !retainedUntrackedFilesWithinLimit(this.retainedUntrackedSelection(fresh, [...pending])),
6172
+ };
6173
+ });
6174
+ }
6175
+ async resolveUntrackedFiles(taskId, agentId, action, token) {
6176
+ let filesHandled = false;
6177
+ try {
6178
+ const current = await this.untrackedFilesContext(taskId, agentId);
6179
+ const tmux = new TmuxManager(current.runner);
6180
+ const runtime = await this.inspectReleaseRuntime(tmux, agentId, agentRuntimeKindFor(current.agent));
6181
+ if (runtime.kind === 'hold')
6182
+ throw new ApiError(409, runtime.reason);
6183
+ if (runtime.kind === 'pane') {
6184
+ await this.waitForReplPromptReady(tmux, runtime.pane, current.agent.runtime, this.cleanComposerWaitMs, { stableIdle: true });
6185
+ }
6186
+ const snapshot = await inspectUntrackedFiles(current.runner, current.workdir, undefined, current.targetRefs);
6187
+ const prepared = await this.withTaskLock(async () => {
6188
+ let context = await this.untrackedFilesContext(taskId, agentId);
6189
+ if (this.untrackedFilesToken(context, snapshot.fingerprint) !== token) {
6190
+ throw new ApiError(409, 'Files or task changed; refresh the list and choose again');
6191
+ }
6192
+ if (this.agentOperationQueues.has(agentId))
6193
+ throw new ApiError(409, 'Agent operation is in progress; refresh the list when it finishes');
6194
+ const pathsBase64 = snapshot.files.filter(file => !context.retained.includes(file.pathBase64)).map(file => file.pathBase64);
6195
+ if (snapshot.conflicts.some(file => action === 'keep' || !pathsBase64.includes(file.pathBase64))) {
6196
+ throw new ApiError(409, 'Checkout would overwrite local files; move, rename or remove the conflicting files in the terminal');
6197
+ }
6198
+ if (action === 'continue' && pathsBase64.length > 0)
6199
+ throw new ApiError(409, 'Untracked files need a decision; refresh the list and choose Keep or Discard');
6200
+ if (action !== 'continue' && pathsBase64.length === 0)
6201
+ throw new ApiError(409, 'No untracked files remain to handle; refresh the list and continue the task');
6202
+ if (snapshot.trackedChanges)
6203
+ throw new ApiError(409, 'Tracked changes must be saved before continuing');
6204
+ if (action === 'keep') {
6205
+ const retainedUntrackedFiles = this.retainedUntrackedSelection(context, pathsBase64);
6206
+ if (!retainedUntrackedFilesWithinLimit(retainedUntrackedFiles))
6207
+ throw new ApiError(409, 'Retained file limit exceeded (1000 paths or 64 KiB per task); reduce the list in the terminal or discard the listed files');
6208
+ await this.taskStore.set({ ...context.task, retainedUntrackedFiles, updatedAt: new Date().toISOString() });
6209
+ filesHandled = true;
6210
+ context = await this.untrackedFilesContext(taskId, agentId);
6211
+ }
6212
+ // Register the agent lease before releasing the task lock; never reacquire the task lock inside it.
6213
+ const processing = this.outsideTaskMutationScope(() => this.withAgentOperationLease(agentId, async () => {
6214
+ if (action !== 'discard')
6215
+ return;
6216
+ await inspectUntrackedFiles(context.runner, context.workdir, { fingerprint: snapshot.fingerprint, pathsBase64 }, context.targetRefs);
6217
+ filesHandled = true;
6218
+ }));
6219
+ return { context, pathsBase64, processing };
6220
+ });
6221
+ await prepared.processing;
6222
+ return await this.withTaskLock(async () => {
6223
+ const context = await this.withAgentOperationLease(agentId, async () => {
6224
+ const refreshed = await this.untrackedFilesContext(taskId, agentId);
6225
+ if (this.untrackedFilesToken(refreshed, snapshot.fingerprint) !== this.untrackedFilesToken(prepared.context, snapshot.fingerprint)) {
6226
+ throw new ApiError(409, 'Task changed during file handling; refresh the task before continuing');
6227
+ }
6228
+ await this.auditHumanTaskOperation(refreshed.task, 'advance', `untracked-files:${action}`, undefined, { agentId, pathsBase64: prepared.pathsBase64 });
6229
+ if (refreshed.recovery === 'dev' && refreshed.binding.bootstrappingTaskId === taskId
6230
+ && refreshed.task.branch && CHECKOUT_PREPARATION_HOLD_PHASES.has(refreshed.binding.awaitingPhase ?? '')) {
6231
+ await this.taskBranchManager(refreshed.runner, agentId, taskId).switchToTaskBranch(refreshed.workdir, taskId, refreshed.task.branch, refreshed.task.branchCreatedByBaxian === true, refreshed.task.branchLocalCleaned ? { restorableRemoteTip: refreshed.task.branchLocalCleaned.remoteTipSha } : {});
6232
+ if (refreshed.task.branchLocalCleaned)
6233
+ await this.clearBranchLocalCleaned(taskId);
6234
+ }
6235
+ return refreshed;
6236
+ });
6237
+ if (context.recovery === 'qa') {
6238
+ return this.dispatchReviewToQa(taskId, { fromStatus: ['review'], expectedTask: taskGenerationGuard(context.task) });
6239
+ }
6240
+ if (context.recovery === 'release') {
6241
+ const released = await this.releaseAgentForTask(agentId, taskId, 'idle', {
6242
+ allowAwaitingHuman: true, expectedTask: taskGenerationGuard(context.task),
6243
+ expectedLockToken: context.binding.lockToken,
6244
+ expectedHold: { phase: context.binding.awaitingPhase, since: context.binding.awaitingSince, nonce: context.binding.awaitingNonce },
6245
+ });
6246
+ if (!released)
6247
+ throw new ApiError(409, 'Workdir release is still blocked; inspect the task');
6248
+ await this.emitIntervention(context.task.projectId, agentId, taskId, { phase: 'resumed', previousPhase: context.binding.awaitingPhase });
6249
+ }
6250
+ else {
6251
+ await this.advanceTask(taskId, { executor: 'dev', agentId });
6252
+ }
6253
+ return (await this.taskStore.get(taskId));
6254
+ });
6255
+ }
6256
+ catch (error) {
6257
+ if (!filesHandled)
6258
+ throw error;
6259
+ throw new ApiError(error instanceof ApiError ? error.status : 500, `${action === 'keep' ? 'Files were kept' : 'Listed files were discarded'}, but continuing the task failed: ${error instanceof Error ? error.message : String(error)}`);
6260
+ }
6261
+ }
6016
6262
  createRunnerFor(agent) {
6017
6263
  if (this.runnerFactory) {
6018
6264
  return this.runnerFactory(agent);
@@ -7078,7 +7324,7 @@ export class AgentManager {
7078
7324
  const { paneId, pane, workdir } = ensure;
7079
7325
  const runner = this.createRunnerFor(agent);
7080
7326
  const tmux = new TmuxManager(runner);
7081
- const branchManager = new BranchManager(runner);
7327
+ const branchManager = this.taskBranchManager(runner, agentId, taskId);
7082
7328
  const lockState = await this.agentStore.get(agentId);
7083
7329
  if (!lockState || lockState.taskId !== taskId || lockState.lockToken !== lockToken)
7084
7330
  return false;
@@ -7294,8 +7540,7 @@ export class AgentManager {
7294
7540
  }
7295
7541
  catch (clearErr) {
7296
7542
  console.warn(`[AgentManager] startSession: clearing bootstrap marker for task=${taskId} failed:`, clearErr);
7297
- await this.markAwaitingHuman(agentId, 'bootstrap-marker-clear-failed', 'Prompt was delivered but clearing the in-flight bootstrap marker failed; held so recovery does ' +
7298
- 'not re-dispatch an already-running task. Verify the agent via web terminal, then Resume.', { expectedTaskId: taskId }).catch((holdErr) => {
7543
+ await this.markAwaitingHuman(agentId, 'bootstrap-marker-clear-failed', `Clearing the in-flight bootstrap marker failed. ${DELIVERED_BOOTSTRAP_RUNBOOK}`, { expectedTaskId: taskId }).catch((holdErr) => {
7299
7544
  console.warn(`[AgentManager] startSession: hold after marker-clear failure for task=${taskId} failed:`, holdErr);
7300
7545
  });
7301
7546
  }
@@ -7687,7 +7932,7 @@ export class AgentManager {
7687
7932
  return false;
7688
7933
  }
7689
7934
  if (agent.role === 'dev' && task.branch) {
7690
- const branches = new BranchManager(runner);
7935
+ const branches = this.taskBranchManager(runner, agentId, taskId);
7691
7936
  if (!opts.allowDirtyWorkdir)
7692
7937
  await branches.assertClean(verifiedWorkdir);
7693
7938
  const actualRef = await branches.currentRef(verifiedWorkdir);
@@ -7862,7 +8107,8 @@ export class AgentManager {
7862
8107
  async rollbackUndeliveredBootstrap(state) {
7863
8108
  if (!state.taskId
7864
8109
  || state.bootstrappingTaskId !== state.taskId
7865
- || state.awaitingPhase === 'bootstrap-marker-clear-failed') {
8110
+ || state.awaitingPhase === 'bootstrap-marker-clear-failed'
8111
+ || (state.awaitingPhase !== undefined && PROMPT_MAYBE_RUNNING_PHASES.has(state.awaitingPhase))) {
7866
8112
  return false;
7867
8113
  }
7868
8114
  const boundTask = await this.taskStore.get(state.taskId);
@@ -7973,8 +8219,7 @@ export class AgentManager {
7973
8219
  }
7974
8220
  catch (err) {
7975
8221
  console.warn(`[AgentManager] replay: clearing bootstrap marker for task=${taskId} failed:`, err);
7976
- await this.markAwaitingHuman(agentId, 'bootstrap-marker-clear-failed', 'Prompt was replayed but clearing the in-flight bootstrap marker failed; held so recovery does ' +
7977
- 'not roll back an already-running task. Verify the agent via web terminal, then Resume.', { expectedTaskId: taskId }).catch((holdErr) => {
8222
+ await this.markAwaitingHuman(agentId, 'bootstrap-marker-clear-failed', `Clearing the in-flight bootstrap marker failed. ${DELIVERED_BOOTSTRAP_RUNBOOK}`, { expectedTaskId: taskId }).catch((holdErr) => {
7978
8223
  console.warn(`[AgentManager] replay: hold after marker-clear failure for task=${taskId} failed:`, holdErr);
7979
8224
  });
7980
8225
  return;
@@ -8141,9 +8386,11 @@ export class AgentManager {
8141
8386
  return {
8142
8387
  ...withBinding,
8143
8388
  status: 'awaiting_human',
8389
+ ...(latest.bootstrappingTaskId !== undefined ? { bootstrappingTaskId: latest.bootstrappingTaskId } : {}),
8144
8390
  ...(latest.awaitingPhase !== undefined ? { awaitingPhase: latest.awaitingPhase } : {}),
8145
8391
  ...(latest.awaitingReason !== undefined ? { awaitingReason: latest.awaitingReason } : {}),
8146
8392
  ...(latest.awaitingSince !== undefined ? { awaitingSince: latest.awaitingSince } : {}),
8393
+ ...(latest.awaitingNonce !== undefined ? { awaitingNonce: latest.awaitingNonce } : {}),
8147
8394
  };
8148
8395
  });
8149
8396
  if (state.taskId && !shouldReleaseBinding && !cancelHold) {
@@ -8786,6 +9033,24 @@ export class AgentManager {
8786
9033
  return dispatched;
8787
9034
  }
8788
9035
  }
9036
+ async replayDevTaskForAdvance(task) {
9037
+ const taskId = task.id;
9038
+ if (!task.devAgentId)
9039
+ throw new ApiError(409, `Task ${taskId} has no Dev participant`);
9040
+ const entryBinding = await this.agentStore.get(task.devAgentId);
9041
+ if (entryBinding?.taskId === taskId && entryBinding.status === 'awaiting_human'
9042
+ && entryBinding.awaitingPhase && PROMPT_MAYBE_RUNNING_PHASES.has(entryBinding.awaitingPhase)) {
9043
+ throw new ApiError(409, `Task ${taskId}: Prompt may still be running (${entryBinding.awaitingPhase}). Verify the outcome in the terminal, or cancel the task.`);
9044
+ }
9045
+ const replayed = await this.redispatchTaskPromptAfterReplRestart(task.devAgentId, taskId);
9046
+ if (!replayed)
9047
+ throw new ApiError(409, `Task ${taskId} changed or its Dev prompt could not be replayed`);
9048
+ const binding = await this.agentStore.get(task.devAgentId);
9049
+ if (binding?.taskId === taskId && binding.status === 'awaiting_human') {
9050
+ throw new ApiError(409, `Task ${taskId} is blocked (${binding.awaitingPhase ?? 'awaiting_human'}): ${binding.awaitingReason ?? 'Inspect the task and its agent before retrying.'}`);
9051
+ }
9052
+ return (await this.taskStore.get(taskId)) ?? task;
9053
+ }
8789
9054
  async advanceTask(taskId, opts = {}) {
8790
9055
  let task = await this.taskStore.get(taskId);
8791
9056
  if (!task)
@@ -8817,12 +9082,7 @@ export class AgentManager {
8817
9082
  throw new ApiError(409, 'Review cannot advance to Dev without a request-changes verdict');
8818
9083
  }
8819
9084
  else if (task.status === 'in_progress' || task.status === 'fixing') {
8820
- if (!task.devAgentId)
8821
- throw new ApiError(409, `Task ${taskId} has no Dev participant`);
8822
- const replayed = await this.redispatchTaskPromptAfterReplRestart(task.devAgentId, taskId);
8823
- if (!replayed)
8824
- throw new ApiError(409, `Task ${taskId} changed or its Dev prompt could not be replayed`);
8825
- updated = (await this.taskStore.get(taskId)) ?? task;
9085
+ updated = await this.replayDevTaskForAdvance(task);
8826
9086
  }
8827
9087
  else if (task.status === 'approved') {
8828
9088
  if (!task.devAgentId)
@@ -8833,10 +9093,7 @@ export class AgentManager {
8833
9093
  }
8834
9094
  task = await this.restoreRevokedPostApprove(task);
8835
9095
  }
8836
- const replayed = await this.redispatchTaskPromptAfterReplRestart(task.devAgentId, taskId);
8837
- if (!replayed)
8838
- throw new ApiError(409, `Task ${taskId} changed or its post-approve prompt could not be replayed`);
8839
- updated = (await this.taskStore.get(taskId)) ?? task;
9096
+ updated = await this.replayDevTaskForAdvance(task);
8840
9097
  }
8841
9098
  else {
8842
9099
  throw new ApiError(409, `Task ${taskId} cannot be advanced from ${task.status}`);