baxian 2.0.32 → 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.
@@ -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) {
@@ -1880,14 +1881,14 @@ export class AgentManager {
1880
1881
  }
1881
1882
  }
1882
1883
  if (cfg.role === 'dev' && boundTask) {
1883
- const branches = new BranchManager(runner);
1884
+ const branches = this.taskBranchManager(runner, agentId, expectedTaskId);
1884
1885
  const cleanup = await branches.cleanupTaskBranch(releaseWorkdir, this.cleanupIdentityFor(boundTask), () => this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir).then(() => undefined));
1885
1886
  await this.persistBranchCleanupOutcome(boundTask, agentId, cleanup);
1886
1887
  if (cleanup.status !== 'deleted')
1887
1888
  await branches.parkOnDefaultDetached(releaseWorkdir);
1888
1889
  }
1889
1890
  else {
1890
- await new BranchManager(runner).parkOnDefaultDetached(releaseWorkdir);
1891
+ await this.taskBranchManager(runner, agentId, expectedTaskId).parkOnDefaultDetached(releaseWorkdir);
1891
1892
  }
1892
1893
  await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
1893
1894
  }
@@ -2090,7 +2091,7 @@ export class AgentManager {
2090
2091
  if (!(await this.lockManager.isOwner(cfg.id, owner, token)))
2091
2092
  continue;
2092
2093
  for (const task of targets) {
2093
- 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));
2094
2095
  cleanupResults.push({ task, cleanup });
2095
2096
  }
2096
2097
  }
@@ -6060,6 +6061,204 @@ export class AgentManager {
6060
6061
  return guarded.ran ? guarded.value : null;
6061
6062
  });
6062
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
+ }
6063
6262
  createRunnerFor(agent) {
6064
6263
  if (this.runnerFactory) {
6065
6264
  return this.runnerFactory(agent);
@@ -7125,7 +7324,7 @@ export class AgentManager {
7125
7324
  const { paneId, pane, workdir } = ensure;
7126
7325
  const runner = this.createRunnerFor(agent);
7127
7326
  const tmux = new TmuxManager(runner);
7128
- const branchManager = new BranchManager(runner);
7327
+ const branchManager = this.taskBranchManager(runner, agentId, taskId);
7129
7328
  const lockState = await this.agentStore.get(agentId);
7130
7329
  if (!lockState || lockState.taskId !== taskId || lockState.lockToken !== lockToken)
7131
7330
  return false;
@@ -7733,7 +7932,7 @@ export class AgentManager {
7733
7932
  return false;
7734
7933
  }
7735
7934
  if (agent.role === 'dev' && task.branch) {
7736
- const branches = new BranchManager(runner);
7935
+ const branches = this.taskBranchManager(runner, agentId, taskId);
7737
7936
  if (!opts.allowDirtyWorkdir)
7738
7937
  await branches.assertClean(verifiedWorkdir);
7739
7938
  const actualRef = await branches.currentRef(verifiedWorkdir);