baxian 2.0.0 → 2.0.2

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 (198) hide show
  1. package/README.md +5 -7
  2. package/README.zh-CN.md +5 -7
  3. package/dist/agent/bootstrap.d.ts.map +1 -1
  4. package/dist/agent/bootstrap.js +2 -4
  5. package/dist/agent/bootstrap.js.map +1 -1
  6. package/dist/agent/branch.d.ts +0 -13
  7. package/dist/agent/branch.d.ts.map +1 -1
  8. package/dist/agent/branch.js +1 -71
  9. package/dist/agent/branch.js.map +1 -1
  10. package/dist/agent/dispatch-reconciler.d.ts.map +1 -1
  11. package/dist/agent/dispatch-reconciler.js +19 -14
  12. package/dist/agent/dispatch-reconciler.js.map +1 -1
  13. package/dist/agent/manager.d.ts +90 -134
  14. package/dist/agent/manager.d.ts.map +1 -1
  15. package/dist/agent/manager.js +1154 -2265
  16. package/dist/agent/manager.js.map +1 -1
  17. package/dist/agent/net-exec.d.ts +3 -0
  18. package/dist/agent/net-exec.d.ts.map +1 -1
  19. package/dist/agent/net-exec.js +6 -0
  20. package/dist/agent/net-exec.js.map +1 -1
  21. package/dist/agent/pane-streamer-manager.d.ts +6 -6
  22. package/dist/agent/pane-streamer-manager.d.ts.map +1 -1
  23. package/dist/agent/pane-streamer-manager.js.map +1 -1
  24. package/dist/agent/pane-streamer.d.ts +6 -3
  25. package/dist/agent/pane-streamer.d.ts.map +1 -1
  26. package/dist/agent/pane-streamer.js +70 -40
  27. package/dist/agent/pane-streamer.js.map +1 -1
  28. package/dist/agent/phase-signal-watcher.d.ts +7 -2
  29. package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
  30. package/dist/agent/phase-signal-watcher.js +90 -32
  31. package/dist/agent/phase-signal-watcher.js.map +1 -1
  32. package/dist/agent/phase-signal.d.ts +19 -38
  33. package/dist/agent/phase-signal.d.ts.map +1 -1
  34. package/dist/agent/phase-signal.js +54 -88
  35. package/dist/agent/phase-signal.js.map +1 -1
  36. package/dist/agent/preflight.d.ts +0 -1
  37. package/dist/agent/preflight.d.ts.map +1 -1
  38. package/dist/agent/preflight.js +8 -15
  39. package/dist/agent/preflight.js.map +1 -1
  40. package/dist/agent/prompt.d.ts +1 -35
  41. package/dist/agent/prompt.d.ts.map +1 -1
  42. package/dist/agent/prompt.js +22 -162
  43. package/dist/agent/prompt.js.map +1 -1
  44. package/dist/agent/repo-store.d.ts +4 -1
  45. package/dist/agent/repo-store.d.ts.map +1 -1
  46. package/dist/agent/repo-store.js +33 -11
  47. package/dist/agent/repo-store.js.map +1 -1
  48. package/dist/agent/root-agent-runtime.d.ts +80 -0
  49. package/dist/agent/root-agent-runtime.d.ts.map +1 -0
  50. package/dist/agent/root-agent-runtime.js +637 -0
  51. package/dist/agent/root-agent-runtime.js.map +1 -0
  52. package/dist/agent/root-recovery-coordinator.d.ts +124 -0
  53. package/dist/agent/root-recovery-coordinator.d.ts.map +1 -0
  54. package/dist/agent/root-recovery-coordinator.js +1557 -0
  55. package/dist/agent/root-recovery-coordinator.js.map +1 -0
  56. package/dist/agent/runner.d.ts +1 -0
  57. package/dist/agent/runner.d.ts.map +1 -1
  58. package/dist/agent/runner.js +103 -7
  59. package/dist/agent/runner.js.map +1 -1
  60. package/dist/agent/tmux-probe-poller.d.ts +1 -0
  61. package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
  62. package/dist/agent/tmux-probe-poller.js +16 -10
  63. package/dist/agent/tmux-probe-poller.js.map +1 -1
  64. package/dist/agent/tmux.d.ts +3 -0
  65. package/dist/agent/tmux.d.ts.map +1 -1
  66. package/dist/agent/tmux.js +11 -5
  67. package/dist/agent/tmux.js.map +1 -1
  68. package/dist/agent/vt-visible-text.d.ts +42 -0
  69. package/dist/agent/vt-visible-text.d.ts.map +1 -0
  70. package/dist/agent/vt-visible-text.js +482 -0
  71. package/dist/agent/vt-visible-text.js.map +1 -0
  72. package/dist/api/agents.d.ts.map +1 -1
  73. package/dist/api/agents.js +42 -1
  74. package/dist/api/agents.js.map +1 -1
  75. package/dist/api/config.d.ts.map +1 -1
  76. package/dist/api/config.js +83 -9
  77. package/dist/api/config.js.map +1 -1
  78. package/dist/api/hosts.d.ts.map +1 -1
  79. package/dist/api/hosts.js +68 -13
  80. package/dist/api/hosts.js.map +1 -1
  81. package/dist/api/platform-guard.js +2 -2
  82. package/dist/api/platform-guard.js.map +1 -1
  83. package/dist/api/projects.d.ts.map +1 -1
  84. package/dist/api/projects.js +621 -325
  85. package/dist/api/projects.js.map +1 -1
  86. package/dist/api/tasks.d.ts.map +1 -1
  87. package/dist/api/tasks.js +15 -39
  88. package/dist/api/tasks.js.map +1 -1
  89. package/dist/app.d.ts +2 -0
  90. package/dist/app.d.ts.map +1 -1
  91. package/dist/app.js +6 -0
  92. package/dist/app.js.map +1 -1
  93. package/dist/cli.d.ts +1 -1
  94. package/dist/cli.d.ts.map +1 -1
  95. package/dist/cli.js +49 -7
  96. package/dist/cli.js.map +1 -1
  97. package/dist/config/loader.d.ts +5 -1
  98. package/dist/config/loader.d.ts.map +1 -1
  99. package/dist/config/loader.js +57 -24
  100. package/dist/config/loader.js.map +1 -1
  101. package/dist/config/normalizer.d.ts.map +1 -1
  102. package/dist/config/normalizer.js +18 -0
  103. package/dist/config/normalizer.js.map +1 -1
  104. package/dist/config/validator.d.ts +7 -4
  105. package/dist/config/validator.d.ts.map +1 -1
  106. package/dist/config/validator.js +251 -96
  107. package/dist/config/validator.js.map +1 -1
  108. package/dist/event/handlers.d.ts.map +1 -1
  109. package/dist/event/handlers.js +359 -229
  110. package/dist/event/handlers.js.map +1 -1
  111. package/dist/index.d.ts.map +1 -1
  112. package/dist/index.js +45 -5
  113. package/dist/index.js.map +1 -1
  114. package/dist/platform/command-renderer.d.ts +6 -0
  115. package/dist/platform/command-renderer.d.ts.map +1 -1
  116. package/dist/platform/command-renderer.js +20 -0
  117. package/dist/platform/command-renderer.js.map +1 -1
  118. package/dist/platform/driver-host.d.ts.map +1 -1
  119. package/dist/platform/driver-host.js +3 -1
  120. package/dist/platform/driver-host.js.map +1 -1
  121. package/dist/platform/driver-spec.d.ts.map +1 -1
  122. package/dist/platform/driver-spec.js +32 -4
  123. package/dist/platform/driver-spec.js.map +1 -1
  124. package/dist/platform/git-driver.d.ts +2 -0
  125. package/dist/platform/git-driver.d.ts.map +1 -1
  126. package/dist/platform/git-driver.js +9 -3
  127. package/dist/platform/git-driver.js.map +1 -1
  128. package/dist/platform/platform-poller.d.ts +0 -1
  129. package/dist/platform/platform-poller.d.ts.map +1 -1
  130. package/dist/platform/platform-poller.js +1 -11
  131. package/dist/platform/platform-poller.js.map +1 -1
  132. package/dist/platform/plugins/github/driver.json +5 -0
  133. package/dist/platform/plugins/github/skills/baxian-cli-gh/SKILL.md +20 -14
  134. package/dist/platform/startup.d.ts.map +1 -1
  135. package/dist/platform/startup.js +5 -5
  136. package/dist/platform/startup.js.map +1 -1
  137. package/dist/platform/types.d.ts +1 -0
  138. package/dist/platform/types.d.ts.map +1 -1
  139. package/dist/platform/types.js +3 -3
  140. package/dist/platform/types.js.map +1 -1
  141. package/dist/shared/constants.d.ts +8 -7
  142. package/dist/shared/constants.d.ts.map +1 -1
  143. package/dist/shared/constants.js +6 -28
  144. package/dist/shared/constants.js.map +1 -1
  145. package/dist/shared/types.d.ts +52 -125
  146. package/dist/shared/types.d.ts.map +1 -1
  147. package/dist/shared/types.js +24 -5
  148. package/dist/shared/types.js.map +1 -1
  149. package/dist/skills/baxian-pr-feedback/SKILL.md +6 -6
  150. package/dist/skills/baxian-pr-recheck/SKILL.md +6 -0
  151. package/dist/skills/baxian-pr-review/SKILL.md +6 -0
  152. package/dist/skills/baxian-signals/SKILL.md +17 -28
  153. package/dist/skills/baxian-task-check/SKILL.md +13 -14
  154. package/dist/state/init.d.ts.map +1 -1
  155. package/dist/state/init.js +1 -0
  156. package/dist/state/init.js.map +1 -1
  157. package/dist/state/lock.d.ts +6 -0
  158. package/dist/state/lock.d.ts.map +1 -1
  159. package/dist/state/lock.js +8 -0
  160. package/dist/state/lock.js.map +1 -1
  161. package/dist/state/root-recovery-store.d.ts +107 -0
  162. package/dist/state/root-recovery-store.d.ts.map +1 -0
  163. package/dist/state/root-recovery-store.js +400 -0
  164. package/dist/state/root-recovery-store.js.map +1 -0
  165. package/dist/state/snapshot.d.ts.map +1 -1
  166. package/dist/state/snapshot.js +5 -2
  167. package/dist/state/snapshot.js.map +1 -1
  168. package/dist/state/task-store.d.ts.map +1 -1
  169. package/dist/state/task-store.js +57 -137
  170. package/dist/state/task-store.js.map +1 -1
  171. package/dist/terminal/attach.d.ts +3 -3
  172. package/dist/terminal/attach.d.ts.map +1 -1
  173. package/dist/terminal/attach.js.map +1 -1
  174. package/dist/terminal/stream-ws.d.ts.map +1 -1
  175. package/dist/terminal/stream-ws.js.map +1 -1
  176. package/dist/web/assets/index-BKiM0MSM.js +15 -0
  177. package/dist/web/assets/index-BS2adAfI.css +1 -0
  178. package/dist/web/index.html +2 -2
  179. package/package.json +1 -1
  180. package/dist/agent/review-transport.d.ts +0 -92
  181. package/dist/agent/review-transport.d.ts.map +0 -1
  182. package/dist/agent/review-transport.js +0 -512
  183. package/dist/agent/review-transport.js.map +0 -1
  184. package/dist/event/server-handlers.d.ts +0 -4
  185. package/dist/event/server-handlers.d.ts.map +0 -1
  186. package/dist/event/server-handlers.js +0 -1221
  187. package/dist/event/server-handlers.js.map +0 -1
  188. package/dist/skills/baxian-research/SKILL.md +0 -28
  189. package/dist/skills/baxian-server-feedback/SKILL.md +0 -40
  190. package/dist/skills/baxian-server-feedback/agents/openai.yaml +0 -2
  191. package/dist/skills/baxian-server-review/SKILL.md +0 -58
  192. package/dist/skills/baxian-server-review/agents/openai.yaml +0 -2
  193. package/dist/state/review-store.d.ts +0 -33
  194. package/dist/state/review-store.d.ts.map +0 -1
  195. package/dist/state/review-store.js +0 -272
  196. package/dist/state/review-store.js.map +0 -1
  197. package/dist/web/assets/index-BPv6vv3U.css +0 -1
  198. package/dist/web/assets/index-BXfgaFNm.js +0 -18
@@ -1,9 +1,9 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { isAbsolute, normalize } from 'node:path';
3
- import { TASK_ACTIVE_STATUS_SET, TASK_OWNER_ROLES, TASK_TERMINAL_STATUS_SET } from '../shared/index.js';
3
+ import { ROOT_AGENT_ID, TASK_ACTIVE_STATUS_SET, TASK_OWNER_ROLES, TASK_TERMINAL_STATUS_SET, } from '../shared/index.js';
4
4
  import { probeTmux, runPreflight } from '../agent/preflight.js';
5
5
  import { installTmux } from '../agent/tmux-install.js';
6
- import { createRunner, hostGroupKey, resolveAgentHost, workdirHostGroupKey, } from '../agent/runner.js';
6
+ import { createRunner, hostGroupKey, mayShareHostAccount, resolveAgentHost, } from '../agent/runner.js';
7
7
  import { saveConfig, prepareConfig, ConfigValidationError } from '../config/loader.js';
8
8
  import { withConfigLock } from '../config/mutex.js';
9
9
  import { redactProjects } from './config.js';
@@ -12,7 +12,7 @@ import { AGENT_STORE_NOOP } from '../state/agent-store.js';
12
12
  import { applyConfigHotReload, prepareConfigHotReload } from '../config/hot-reload.js';
13
13
  import { gitBindingBlockerDetails, gitBindingBlockers } from './platform-guard.js';
14
14
  import { safeDriverErrorText } from '../platform/git-driver.js';
15
- import { projectAfterDone, projectNeedsPlatformEntry, projectReviewMode } from '../config/validator.js';
15
+ import { projectNeedsPlatformEntry } from '../config/validator.js';
16
16
  async function fixMissingTmux(projectId, entries, agents) {
17
17
  const runsById = new Map(agents.map(run => [run.agentId, run]));
18
18
  const groups = new Map();
@@ -102,6 +102,267 @@ function omitAgentsFromProject(config, projectId, removed) {
102
102
  }),
103
103
  };
104
104
  }
105
+ class AgentRemovalTransaction {
106
+ app;
107
+ targets;
108
+ operation;
109
+ owner;
110
+ ownerLabel;
111
+ originalStates = new Map();
112
+ rotatedClaims = new Map();
113
+ claimed = false;
114
+ committed = false;
115
+ constructor(app, targets, operation) {
116
+ this.app = app;
117
+ this.targets = targets;
118
+ this.operation = operation;
119
+ const ownerPrefix = operation === 'DELETE' ? 'deletion' : 'replacement';
120
+ this.owner = `${ownerPrefix}:${randomUUID()}`;
121
+ this.ownerLabel = ownerPrefix === 'deletion' ? 'deletion-owner' : 'replacement owner';
122
+ }
123
+ async claim() {
124
+ const outcome = await this.app.ctx.agentManager.scanOpenThenClaimDeletion(this.targets);
125
+ if (outcome.ok)
126
+ this.claimed = true;
127
+ return outcome;
128
+ }
129
+ async rotateClaims() {
130
+ for (const agentId of this.targets) {
131
+ const state = await this.app.ctx.agentStore.get(agentId);
132
+ this.originalStates.set(agentId, state);
133
+ const claim = await this.app.ctx.lockManager.claimOf(agentId);
134
+ if (claim && !state?.taskId) {
135
+ await this.rollbackClaims();
136
+ return { ok: false, code: 'locked', agentId };
137
+ }
138
+ if (claim && claim.taskId !== state.taskId) {
139
+ await this.rollbackClaims();
140
+ return { ok: false, code: 'stale-binding', agentId };
141
+ }
142
+ const deletionToken = randomUUID();
143
+ const original = claim ? { taskId: claim.taskId, token: claim.token } : null;
144
+ const acquired = await this.app.ctx.lockManager.rotateClaim(agentId, original ?? { unbound: true }, { taskId: this.owner, token: deletionToken });
145
+ if (!acquired) {
146
+ await this.rollbackClaims();
147
+ const code = original || this.operation === 'PUT' ? 'claim-changed' : 'locked';
148
+ return { ok: false, code, agentId };
149
+ }
150
+ this.rotatedClaims.set(agentId, { deletionToken, original });
151
+ }
152
+ return { ok: true };
153
+ }
154
+ async rollbackStateAndClaims() {
155
+ const failures = [];
156
+ const claimError = await this.rollbackClaimsForRetry();
157
+ if (claimError)
158
+ failures.push(claimError);
159
+ try {
160
+ await this.restoreStates();
161
+ }
162
+ catch (err) {
163
+ failures.push(err instanceof Error ? err : new Error(String(err)));
164
+ }
165
+ if (failures.length === 0)
166
+ return null;
167
+ return failures.length === 1
168
+ ? failures[0]
169
+ : new AggregateError(failures, 'Agent removal rollback failed');
170
+ }
171
+ async rollbackClaimsForRetry() {
172
+ try {
173
+ await this.rollbackClaims();
174
+ return null;
175
+ }
176
+ catch (err) {
177
+ return err instanceof Error ? err : new Error(String(err));
178
+ }
179
+ }
180
+ async finalizeCommit(config, opts = {}) {
181
+ this.committed = true;
182
+ for (const agentId of this.targets)
183
+ this.app.ctx.agentManager.bumpDeletionGeneration(agentId);
184
+ const warnings = [];
185
+ for (const agentId of this.targets) {
186
+ try {
187
+ await this.app.ctx.agentStore.delete(agentId);
188
+ }
189
+ catch (err) {
190
+ const warning = this.operation === 'PUT'
191
+ ? `old agent ${agentId} state delete failed post-commit: ${err instanceof Error ? err.message : String(err)}`
192
+ : `agent ${agentId} state delete failed post-commit (reclaimable orphan state): ${err instanceof Error ? err.message : String(err)}`;
193
+ warnings.push(warning);
194
+ this.app.log.error({ err, agentId, targets: this.targets }, `${this.operation} /agents${this.operation === 'DELETE' ? ' Phase 3' : ''} ${warning}`);
195
+ }
196
+ }
197
+ await opts.beforeConfigSwitch?.(warnings);
198
+ const switchResult = switchCommittedAgentConfig(this.app, config, opts.divergenceTargets ?? this.targets, this.operation);
199
+ if (switchResult.warning)
200
+ warnings.push(switchResult.warning);
201
+ for (const agentId of this.targets) {
202
+ try {
203
+ await this.releaseClaim(agentId);
204
+ }
205
+ catch (err) {
206
+ const warning = this.operation === 'PUT'
207
+ ? `replacement lock release for ${agentId} failed: ${err instanceof Error ? err.message : String(err)}`
208
+ : `lock release for ${agentId} failed: ${err instanceof Error ? err.message : String(err)}`;
209
+ warnings.push(warning);
210
+ this.app.log.warn({ err, agentId }, `${this.operation} /agents ${this.operation === 'DELETE' ? 'Phase 3 lock release failed (post-commit)' : warning}`);
211
+ }
212
+ if (this.app.ctx.errorRecordStore) {
213
+ try {
214
+ await this.app.ctx.errorRecordStore.purgeAgent(agentId);
215
+ }
216
+ catch (err) {
217
+ const warning = `error history cleanup for ${agentId} failed: ${err instanceof Error ? err.message : String(err)}`;
218
+ if (this.operation === 'PUT')
219
+ warnings.push(warning);
220
+ this.app.log.warn({ err, agentId }, this.operation === 'PUT' ? `PUT /agents ${warning}` : 'DELETE /agents errorRecordStore.purgeAgent failed');
221
+ }
222
+ }
223
+ if (this.app.ctx.petStore) {
224
+ try {
225
+ await this.app.ctx.petStore.setAssignment(agentId, null);
226
+ }
227
+ catch (err) {
228
+ const warning = `pet assignment cleanup for ${agentId} failed: ${err instanceof Error ? err.message : String(err)}`;
229
+ if (this.operation === 'PUT')
230
+ warnings.push(warning);
231
+ this.app.log.warn({ err, agentId }, this.operation === 'PUT' ? `PUT /agents ${warning}` : 'DELETE /agents petStore.setAssignment(null) failed');
232
+ }
233
+ }
234
+ }
235
+ return { restartRequired: switchResult.restartRequired, warnings };
236
+ }
237
+ async releaseClaim(agentId) {
238
+ if (!(await this.settleClaim(agentId, false))) {
239
+ throw new Error(`${this.ownerLabel} claim changed before release`);
240
+ }
241
+ }
242
+ async finish() {
243
+ try {
244
+ if (this.rotatedClaims.size > 0) {
245
+ await this.settleClaims(!this.committed);
246
+ }
247
+ }
248
+ finally {
249
+ if (this.claimed) {
250
+ this.app.ctx.agentManager.releaseDeletionClaim(this.targets);
251
+ this.claimed = false;
252
+ }
253
+ }
254
+ }
255
+ async rollbackClaims() {
256
+ await this.settleClaims(true);
257
+ }
258
+ async settleClaims(restoreOriginal) {
259
+ const failures = [];
260
+ for (const agentId of [...this.rotatedClaims.keys()]) {
261
+ try {
262
+ if (!(await this.settleClaim(agentId, restoreOriginal))) {
263
+ failures.push(new Error(`${agentId}: ${this.ownerLabel} claim no longer held; ` +
264
+ (restoreOriginal ? 'original owner not restored' : 'claim not released')));
265
+ }
266
+ }
267
+ catch (err) {
268
+ failures.push(new Error(`${agentId}: ${err instanceof Error ? err.message : String(err)}`));
269
+ }
270
+ }
271
+ if (failures.length > 0) {
272
+ throw new AggregateError(failures, `lock ${restoreOriginal ? 'rollback' : 'release'} failed for ${failures.length} target(s)`);
273
+ }
274
+ }
275
+ async settleClaim(agentId, restoreOriginal) {
276
+ const rotated = this.rotatedClaims.get(agentId);
277
+ if (!rotated)
278
+ return true;
279
+ const settled = restoreOriginal && rotated.original
280
+ ? await this.app.ctx.lockManager.rotateClaim(agentId, { taskId: this.owner, token: rotated.deletionToken }, rotated.original)
281
+ : await this.app.ctx.lockManager.releaseIfOwner(agentId, this.owner, rotated.deletionToken);
282
+ if (settled)
283
+ this.rotatedClaims.delete(agentId);
284
+ return settled;
285
+ }
286
+ async restoreStates() {
287
+ const failures = [];
288
+ for (const agentId of this.targets) {
289
+ try {
290
+ const original = this.originalStates.get(agentId) ?? null;
291
+ if (!original) {
292
+ await this.app.ctx.agentStore.delete(agentId);
293
+ continue;
294
+ }
295
+ let lockToken = original.lockToken;
296
+ if (original.taskId) {
297
+ const claim = await this.app.ctx.lockManager.claimOf(agentId);
298
+ if (claim?.taskId === original.taskId) {
299
+ lockToken = claim.token;
300
+ }
301
+ else if (claim === null) {
302
+ lockToken = await this.app.ctx.lockManager.acquire(agentId, original.taskId) ?? undefined;
303
+ }
304
+ else {
305
+ throw new Error(`lock is owned by ${claim.taskId}, expected ${original.taskId}`);
306
+ }
307
+ if (!lockToken || !(await this.app.ctx.lockManager.isOwner(agentId, original.taskId, lockToken))) {
308
+ throw new Error(`could not restore exact task lock for ${original.taskId}`);
309
+ }
310
+ }
311
+ await this.app.ctx.agentStore.set({
312
+ ...original,
313
+ ...(original.taskId ? { lockToken } : { lockToken: undefined }),
314
+ paneId: undefined,
315
+ updatedAt: new Date().toISOString(),
316
+ });
317
+ }
318
+ catch (err) {
319
+ failures.push(new Error(`${agentId}: ${err instanceof Error ? err.message : String(err)}`));
320
+ }
321
+ }
322
+ if (failures.length > 0) {
323
+ throw new AggregateError(failures, `Failed to roll back ${failures.length} agent removal state(s)`);
324
+ }
325
+ }
326
+ }
327
+ async function restoreAgentStateSnapshots(app, snapshots) {
328
+ const failures = [];
329
+ for (const [agentId, snapshot] of [...snapshots].reverse()) {
330
+ try {
331
+ if (snapshot)
332
+ await app.ctx.agentStore.set(snapshot);
333
+ else
334
+ await app.ctx.agentStore.delete(agentId);
335
+ }
336
+ catch (err) {
337
+ failures.push(new Error(`${agentId}: ${err instanceof Error ? err.message : String(err)}`));
338
+ }
339
+ }
340
+ if (failures.length === 0)
341
+ return null;
342
+ return failures.length === 1
343
+ ? failures[0]
344
+ : new AggregateError(failures, `Failed to restore ${failures.length} agent state snapshot(s)`);
345
+ }
346
+ function switchCommittedAgentConfig(app, config, affectedAgentIds, operation) {
347
+ app.ctx.config = config;
348
+ try {
349
+ app.ctx.agentManager.replaceConfig(config);
350
+ app.ctx.tmuxProbePoller?.replaceConfig(config);
351
+ app.ctx.bootstrapPoller?.replaceConfig(config);
352
+ app.ctx.dispatchReconciler?.replaceConfig(config);
353
+ return { restartRequired: false };
354
+ }
355
+ catch (err) {
356
+ for (const agentId of affectedAgentIds) {
357
+ app.ctx.agentManager.retainTombstoneForDivergence(agentId);
358
+ }
359
+ app.log.fatal({ err, agentIds: affectedAgentIds }, `${operation} /agents in-memory config switch failed after disk commit; restart required`);
360
+ return {
361
+ restartRequired: true,
362
+ warning: 'in-memory config switch failed after disk commit; restart the server',
363
+ };
364
+ }
365
+ }
105
366
  export async function projectRoutes(app) {
106
367
  app.get('/projects', async () => {
107
368
  return redactProjects(app.ctx.config.project);
@@ -136,22 +397,13 @@ export async function projectRoutes(app) {
136
397
  const entries = [];
137
398
  const checks = [];
138
399
  const gitPlatform = app.ctx.agentManager.agentGitPreflightContext(project.id);
139
- const reviewMode = projectReviewMode(app.ctx.config, project);
140
- const afterDone = projectAfterDone(app.ctx.config, project);
141
400
  for (const pair of project.agent) {
142
401
  for (const agent of pair) {
143
402
  const host = resolveAgentHost(app.ctx.config.host, agent.host);
144
403
  const runner = createRunner(agent.mode, host);
145
- const agentGitPlatform = agent.role === 'research' && agent.workdir !== undefined
146
- ? undefined
147
- : gitPlatform;
148
404
  entries.push({ agent, hostGroup: hostGroupKey(agent.mode, host), runner });
149
- checks.push(runPreflight(runner, agent, project.repo, host, project.id, agentGitPlatform, {
150
- requireGitHubCli: reviewMode === 'server'
151
- && (agent.workdir === undefined
152
- || (agent.role === 'dev' && afterDone === 'pr')),
153
- requireGitPush: agent.role === 'dev'
154
- && (reviewMode === 'git' || afterDone !== null),
405
+ checks.push(runPreflight(runner, agent, project.repo, host, project.id, gitPlatform, {
406
+ requireGitPush: agent.role === 'dev',
155
407
  }).then(results => ({
156
408
  agentId: agent.id,
157
409
  mode: agent.mode,
@@ -228,7 +480,7 @@ export async function projectRoutes(app) {
228
480
  return reply.status(500).send({ error: 'No config path configured' });
229
481
  }
230
482
  return withConfigLock(async () => {
231
- const { id, repo, merge, specApproval, review, gitCli } = request.body ?? {};
483
+ const { id, repo, merge, specApproval, gitCli } = request.body ?? {};
232
484
  if (!id || typeof id !== 'string') {
233
485
  return reply.status(400).send({ error: 'id must be a non-empty string' });
234
486
  }
@@ -243,7 +495,6 @@ export async function projectRoutes(app) {
243
495
  repo,
244
496
  merge: merge ?? null,
245
497
  ...(specApproval !== undefined ? { specApproval } : {}),
246
- ...(review !== undefined ? { review } : {}),
247
498
  ...(gitCli !== undefined ? { gitCli } : {}),
248
499
  agent: [],
249
500
  };
@@ -292,74 +543,61 @@ export async function projectRoutes(app) {
292
543
  return reply.status(500).send({ error: 'No config path configured' });
293
544
  }
294
545
  const { projectId } = request.params;
295
- const body = request.body ?? {};
296
- const { pairWith, ...agentInput } = body;
297
- if (agentInput.role !== 'dev' && agentInput.role !== 'qa' && agentInput.role !== 'research') {
298
- return reply.status(400).send({ error: 'role must be "dev", "qa", or "research"' });
546
+ const agents = request.body?.agents;
547
+ if (!Array.isArray(agents) || agents.length !== 2) {
548
+ return reply.status(400).send({ error: 'agents must contain exactly one dev and one qa agent' });
549
+ }
550
+ const dev = agents.find(agent => agent?.role === 'dev');
551
+ const qa = agents.find(agent => agent?.role === 'qa');
552
+ if (!dev || !qa || agents.some(agent => agent?.role !== 'dev' && agent?.role !== 'qa')) {
553
+ return reply.status(400).send({ error: 'agents must contain exactly one dev and one qa agent' });
554
+ }
555
+ if (typeof dev.id !== 'string' || dev.id.trim() === ''
556
+ || typeof qa.id !== 'string' || qa.id.trim() === '') {
557
+ return reply.status(400).send({ error: 'both agent ids are required' });
299
558
  }
300
- if (typeof agentInput.id !== 'string' || agentInput.id.trim().length === 0) {
301
- return reply.status(400).send({ error: 'agent id is required' });
559
+ if (dev.id === qa.id) {
560
+ return reply.status(409).send({ error: `Agent id "${dev.id}" is duplicated within the group` });
302
561
  }
303
- let creationToken = '';
562
+ const creationTokens = new Map();
563
+ const warnings = [];
564
+ let restartRequired = false;
304
565
  const phase1Result = await withConfigLock(async () => {
305
566
  const project = app.ctx.config.project.find(p => p.id === projectId);
306
567
  if (!project) {
307
568
  return reply.status(404).send({ error: `Project "${projectId}" not found` });
308
569
  }
309
- const existsGlobally = app.ctx.config.project.some(p => p.agent.some(pair => pair.some(a => a.id === agentInput.id)));
310
- if (existsGlobally) {
311
- return reply.status(409).send({ error: `Agent id "${agentInput.id}" already exists` });
312
- }
313
- // A tombstone in flight (or retained after a DELETE memory-switch divergence) must block a
314
- // same-id rebuild, else the fail-stop could be silently bypassed.
315
- if (app.ctx.agentManager.isDeletionInFlight(agentInput.id)) {
316
- return reply.status(409).send({
317
- error: `Agent id "${agentInput.id}" is being deleted (or its deletion diverged); retry later or restart the server`,
318
- });
319
- }
320
- const workdirConflict = findConfiguredWorkdirConflict(app.ctx.config, agentInput);
321
- if (workdirConflict) {
322
- return reply.status(409).send({
323
- error: `Workdir "${normalize(agentInput.workdir)}" is already used by agent ` +
324
- `"${workdirConflict}" on the same host; different agents must not share a directory`,
325
- });
326
- }
327
- if (agentInput.role !== 'dev') {
328
- if (!pairWith) {
329
- return reply.status(400).send({ error: `pairWith required when role=${agentInput.role}` });
570
+ let workdirConfig = app.ctx.config;
571
+ for (const agentInput of [dev, qa]) {
572
+ const existsGlobally = app.ctx.config.project.some(p => p.agent.some(group => group.some(agent => agent.id === agentInput.id)));
573
+ if (existsGlobally) {
574
+ return reply.status(409).send({ error: `Agent id "${agentInput.id}" already exists` });
330
575
  }
331
- const targetGroup = project.agent.find(group => group.some(agent => agent.id === pairWith && agent.role === 'dev')
332
- && !group.some(agent => agent.role === agentInput.role));
333
- if (!targetGroup) {
334
- return reply.status(400).send({
335
- error: `pairWith "${pairWith}" must reference a dev group in this project that has no ${agentInput.role} yet`,
576
+ if (app.ctx.agentManager.isDeletionInFlight(agentInput.id)) {
577
+ return reply.status(409).send({
578
+ error: `Agent id "${agentInput.id}" is being deleted (or its deletion diverged); retry later or restart the server`,
336
579
  });
337
580
  }
338
- const devState = await app.ctx.agentStore.get(pairWith);
339
- if (devState?.creationToken) {
581
+ const workdirConflict = findConfiguredWorkdirConflict(workdirConfig, agentInput);
582
+ if (workdirConflict) {
340
583
  return reply.status(409).send({
341
- error: `dev agent "${pairWith}" is being created; please retry later`,
584
+ error: `Workdir "${normalize(agentInput.workdir)}" is already used by agent ` +
585
+ `"${workdirConflict}" on the same host; different agents must not share a directory`,
342
586
  });
343
587
  }
588
+ workdirConfig = {
589
+ ...workdirConfig,
590
+ project: workdirConfig.project.map(p => p.id === projectId
591
+ ? { ...p, agent: [...p.agent, [agentInput]] }
592
+ : p),
593
+ };
344
594
  }
345
- if (agentInput.role === 'dev' && pairWith) {
346
- return reply.status(400).send({ error: 'pairWith is only valid for qa or research agents' });
347
- }
348
- const newProjects = app.ctx.config.project.map((p) => {
349
- if (p.id !== projectId)
350
- return p;
351
- if (agentInput.role === 'dev') {
352
- return { ...p, agent: [...p.agent, [agentInput]] };
353
- }
354
- const newAgent = p.agent.map(group => {
355
- if (group.some(agent => agent.id === pairWith && agent.role === 'dev')) {
356
- return [...group, agentInput];
357
- }
358
- return group;
359
- });
360
- return { ...p, agent: newAgent };
361
- });
362
- const merged = { ...app.ctx.config, project: newProjects };
595
+ const merged = {
596
+ ...app.ctx.config,
597
+ project: app.ctx.config.project.map(p => p.id === projectId
598
+ ? { ...p, agent: [...p.agent, [dev, qa]] }
599
+ : p),
600
+ };
363
601
  let validated;
364
602
  try {
365
603
  validated = prepareConfig(merged);
@@ -370,104 +608,278 @@ export async function projectRoutes(app) {
370
608
  }
371
609
  throw err;
372
610
  }
373
- await saveConfig(app.ctx.configPath, validated);
374
- app.ctx.config = validated;
375
- app.ctx.agentManager.replaceConfig(validated);
376
- app.ctx.tmuxProbePoller?.replaceConfig(validated);
377
- app.ctx.bootstrapPoller?.replaceConfig(validated);
378
- app.ctx.dispatchReconciler?.replaceConfig(validated);
379
- creationToken = randomUUID();
611
+ const stateSnapshots = new Map();
612
+ for (const agent of [dev, qa]) {
613
+ stateSnapshots.set(agent.id, await app.ctx.agentStore.get(agent.id));
614
+ }
380
615
  const now = new Date().toISOString();
381
- await app.ctx.agentStore.set({
382
- id: agentInput.id,
383
- projectId,
384
- creationToken,
385
- updatedAt: now,
386
- });
616
+ try {
617
+ for (const agent of [dev, qa]) {
618
+ const creationToken = randomUUID();
619
+ await app.ctx.agentStore.set({
620
+ id: agent.id,
621
+ projectId,
622
+ creationToken,
623
+ updatedAt: now,
624
+ });
625
+ creationTokens.set(agent.id, creationToken);
626
+ }
627
+ }
628
+ catch (err) {
629
+ creationTokens.clear();
630
+ const rollbackError = await restoreAgentStateSnapshots(app, stateSnapshots);
631
+ if (rollbackError) {
632
+ app.log.error({ err: rollbackError, agentIds: [dev.id, qa.id] }, 'POST /agents state initialization rollback failed');
633
+ return reply.status(500).send({
634
+ error: `agent state initialization failed and staged state could not be restored safely: ${rollbackError.message}`,
635
+ });
636
+ }
637
+ return reply.status(500).send({
638
+ error: `agent state initialization failed; the group was not committed: ${err instanceof Error ? err.message : String(err)}`,
639
+ });
640
+ }
641
+ try {
642
+ await saveConfig(app.ctx.configPath, validated);
643
+ }
644
+ catch (err) {
645
+ creationTokens.clear();
646
+ const rollbackError = await restoreAgentStateSnapshots(app, stateSnapshots);
647
+ if (rollbackError) {
648
+ app.log.error({ err: rollbackError, agentIds: [dev.id, qa.id] }, 'POST /agents config save state rollback failed');
649
+ return reply.status(500).send({
650
+ error: `config save failed and staged agent state could not be restored safely: ${rollbackError.message}`,
651
+ });
652
+ }
653
+ return reply.status(500).send({
654
+ error: `failed to persist agent group config; staged agent state was restored: ${err instanceof Error ? err.message : String(err)}`,
655
+ });
656
+ }
657
+ const switchResult = switchCommittedAgentConfig(app, validated, [dev.id, qa.id], 'POST');
658
+ restartRequired = switchResult.restartRequired;
659
+ if (switchResult.warning) {
660
+ creationTokens.clear();
661
+ warnings.push(switchResult.warning);
662
+ return null;
663
+ }
387
664
  return null;
388
665
  });
389
666
  if (phase1Result)
390
667
  return phase1Result;
391
668
  if (reply.sent)
392
669
  return reply;
393
- void app.ctx.agentManager
394
- .startBootstrapAsync(agentInput.id, creationToken)
395
- .catch((err) => {
396
- app.log.error({ err, agentId: agentInput.id }, 'POST /agents startBootstrapAsync threw — should be impossible (it never throws)');
397
- });
670
+ for (const [agentId, creationToken] of creationTokens) {
671
+ void app.ctx.agentManager.startBootstrapAsync(agentId, creationToken).catch((err) => {
672
+ app.log.error({ err, agentId }, 'POST /agents startBootstrapAsync threw — should be impossible');
673
+ });
674
+ }
398
675
  const storedProject = app.ctx.config.project.find(p => p.id === projectId);
399
- const storedAgent = storedProject.agent.flat().find(a => a.id === agentInput.id);
676
+ const storedGroup = storedProject.agent.find(group => group.some(agent => agent.id === dev.id));
400
677
  return reply.status(201).send({
401
- agent: storedAgent,
678
+ agents: storedGroup,
402
679
  runtimeStatus: 'pending',
403
- restartRequired: false,
680
+ restartRequired,
681
+ ...(warnings.length ? { warnings } : {}),
404
682
  });
405
683
  });
406
- app.delete('/projects/:projectId/agents/:agentId', async (request, reply) => {
684
+ app.put('/projects/:projectId/agents/:agentId', async (request, reply) => {
407
685
  if (!app.ctx.configPath) {
408
686
  return reply.status(500).send({ error: 'No config path configured' });
409
687
  }
410
688
  const { projectId, agentId } = request.params;
411
- let targets = [];
412
- const originalStates = new Map();
413
- let originalConfigHash = '';
414
- let validatedAfterRemove = null;
415
- // Per-attempt deletion owner: phase 1 rotates every claim onto it (so a concurrent maintenance release with the old shared token fails owner-scoped); phase 3 releases it, rollback rotates back to the original.
416
- const deletionOwner = `deletion:${randomUUID()}`;
417
- const rotatedClaims = new Map();
418
- const releaseDeletionClaims = async () => {
419
- for (const [id, r] of rotatedClaims) {
420
- await app.ctx.lockManager.releaseIfOwner(id, deletionOwner, r.deletionToken);
421
- }
422
- rotatedClaims.clear();
423
- };
424
- const rollbackClaims = async () => {
425
- const failures = [];
426
- const restored = [];
427
- for (const [id, r] of rotatedClaims) {
428
- try {
429
- const ok = r.original
430
- ? await app.ctx.lockManager.rotateClaim(id, { taskId: deletionOwner, token: r.deletionToken }, r.original)
431
- : await app.ctx.lockManager.releaseIfOwner(id, deletionOwner, r.deletionToken);
432
- if (ok)
433
- restored.push(id);
434
- else
435
- failures.push(`${id}: deletion-owner claim no longer held; original owner not restored`);
436
- }
437
- catch (err) {
438
- failures.push(`${id}: ${err instanceof Error ? err.message : String(err)}`);
439
- }
689
+ const replacement = request.body;
690
+ if (!replacement || (replacement.role !== 'dev' && replacement.role !== 'qa')) {
691
+ return reply.status(400).send({ error: 'replacement role must be "dev" or "qa"' });
692
+ }
693
+ if (typeof replacement.id !== 'string' || replacement.id.trim() === '') {
694
+ return reply.status(400).send({ error: 'replacement agent id is required' });
695
+ }
696
+ if (replacement.id === agentId) {
697
+ return reply.status(400).send({ error: 'replacement agent id must differ from the current agent id' });
698
+ }
699
+ let bootstrap = null;
700
+ let replacementResult = null;
701
+ const phaseResult = await withConfigLock(async () => {
702
+ const project = app.ctx.config.project.find(p => p.id === projectId);
703
+ if (!project) {
704
+ return reply.status(404).send({ error: `Project "${projectId}" not found` });
440
705
  }
441
- // Clear only successfully-restored entries; keep failed ones as evidence for the caller/finally.
442
- for (const id of restored)
443
- rotatedClaims.delete(id);
444
- if (failures.length > 0) {
445
- throw new AggregateError(failures.map(f => new Error(f)), `lock rollback failed for ${failures.length} target(s)`);
706
+ const group = project.agent.find(candidate => candidate.some(agent => agent.id === agentId));
707
+ const current = group?.find(agent => agent.id === agentId);
708
+ if (!group || !current) {
709
+ return reply.status(404).send({ error: `Agent "${agentId}" not found in project "${projectId}"` });
446
710
  }
447
- };
448
- const rollbackAndRelease = async () => {
449
- let rollbackError = null;
450
- // Rotate claims back to their original owners FIRST, so state restore sees the original claim.
711
+ if (replacement.role !== current.role) {
712
+ return reply.status(400).send({
713
+ error: `replacement role must remain "${current.role}" so the group keeps one dev and one qa`,
714
+ });
715
+ }
716
+ const existsGlobally = app.ctx.config.project.some(p => p.agent.some(candidate => candidate.some(agent => agent.id === replacement.id)));
717
+ if (existsGlobally) {
718
+ return reply.status(409).send({ error: `Agent id "${replacement.id}" already exists` });
719
+ }
720
+ if (app.ctx.agentManager.isDeletionInFlight(replacement.id)) {
721
+ return reply.status(409).send({
722
+ error: `Agent id "${replacement.id}" is being deleted (or its deletion diverged); retry later or restart the server`,
723
+ });
724
+ }
725
+ const workdirConflict = findConfiguredWorkdirConflict(app.ctx.config, replacement, new Set([agentId]));
726
+ if (workdirConflict) {
727
+ return reply.status(409).send({
728
+ error: `Workdir "${normalize(replacement.workdir)}" is already used by agent ` +
729
+ `"${workdirConflict}" on the same host; different agents must not share a directory`,
730
+ });
731
+ }
732
+ const nextConfig = {
733
+ ...app.ctx.config,
734
+ project: app.ctx.config.project.map(p => p.id === projectId
735
+ ? {
736
+ ...p,
737
+ agent: p.agent.map(candidate => candidate.some(agent => agent.id === agentId)
738
+ ? candidate.map(agent => agent.id === agentId ? replacement : agent)
739
+ : candidate),
740
+ }
741
+ : p),
742
+ };
743
+ let validated;
451
744
  try {
452
- await rollbackClaims();
745
+ validated = prepareConfig(nextConfig);
453
746
  }
454
747
  catch (err) {
455
- rollbackError = err instanceof Error ? err : new Error(String(err));
748
+ if (err instanceof ConfigValidationError) {
749
+ return reply.status(400).send({ error: 'Invalid config', details: err.errors });
750
+ }
751
+ throw err;
456
752
  }
753
+ const storedReplacement = validated.project
754
+ .find(candidate => candidate.id === projectId)
755
+ .agent.flat()
756
+ .find(agent => agent.id === replacement.id);
757
+ const removal = new AgentRemovalTransaction(app, [agentId], 'PUT');
457
758
  try {
458
- await rollbackPerTargetState(app, targets, originalStates);
759
+ const deletion = await removal.claim();
760
+ if (!deletion.ok) {
761
+ switch (deletion.code) {
762
+ case 'active':
763
+ return reply.status(409).send({
764
+ error: `Agent "${deletion.agentId}" is still active on task ${deletion.taskId}; finish or cancel it before replacement.`,
765
+ });
766
+ case 'bootstrapping':
767
+ return reply.status(409).send({
768
+ error: `Agent "${deletion.agentId}" is bootstrapping; retry after bootstrap settles.`,
769
+ });
770
+ case 'referencing':
771
+ return reply.status(409).send({
772
+ error: `Agent "${agentId}" is referenced by open task ${deletion.taskId}; finish or cancel it before replacement.`,
773
+ });
774
+ case 'already-deleting':
775
+ return reply.status(409).send({
776
+ error: `Agent "${deletion.agentId}" deletion or replacement is already in progress.`,
777
+ });
778
+ }
779
+ }
780
+ const rotation = await removal.rotateClaims();
781
+ if (!rotation.ok) {
782
+ switch (rotation.code) {
783
+ case 'locked':
784
+ case 'stale-binding':
785
+ return reply.status(409).send({
786
+ error: `Agent "${rotation.agentId}" is locked by another operation; retry replacement later.`,
787
+ });
788
+ case 'claim-changed':
789
+ return reply.status(409).send({
790
+ error: `Agent "${rotation.agentId}" lock changed; retry replacement.`,
791
+ });
792
+ }
793
+ }
794
+ try {
795
+ await app.ctx.agentManager.cleanupRemovedAgentRuntime([agentId]);
796
+ }
797
+ catch (err) {
798
+ const rollbackError = await removal.rollbackStateAndClaims();
799
+ if (rollbackError) {
800
+ app.log.error({ err: rollbackError, agentId }, 'PUT /agents replacement rollback failed');
801
+ return reply.status(500).send({
802
+ error: `runtime cleanup failed and the original agent state could not be restored safely: ${rollbackError.message}`,
803
+ });
804
+ }
805
+ return reply.status(502).send({
806
+ error: `runtime cleanup failed for ${agentId}; the original config, state, and lock were restored`,
807
+ detail: err instanceof Error ? err.message : String(err),
808
+ });
809
+ }
810
+ try {
811
+ await saveConfig(app.ctx.configPath, validated);
812
+ }
813
+ catch (err) {
814
+ const rollbackError = await removal.rollbackStateAndClaims();
815
+ if (rollbackError) {
816
+ app.log.error({ err: rollbackError, agentId }, 'PUT /agents save rollback failed');
817
+ return reply.status(500).send({
818
+ error: `config save failed and the original agent state could not be restored safely: ${rollbackError.message}`,
819
+ });
820
+ }
821
+ return reply.status(500).send({
822
+ error: `failed to persist replacement config; the original agent state and lock were restored: ${err instanceof Error ? err.message : String(err)}`,
823
+ });
824
+ }
825
+ const commitResult = await removal.finalizeCommit(validated, {
826
+ divergenceTargets: [agentId, replacement.id],
827
+ beforeConfigSwitch: async (warnings) => {
828
+ const creationToken = randomUUID();
829
+ try {
830
+ await app.ctx.agentStore.set({
831
+ id: replacement.id,
832
+ projectId,
833
+ creationToken,
834
+ updatedAt: new Date().toISOString(),
835
+ });
836
+ bootstrap = { agentId: replacement.id, creationToken };
837
+ }
838
+ catch (err) {
839
+ const warning = `replacement agent ${replacement.id} state initialization failed: ${err instanceof Error ? err.message : String(err)}`;
840
+ warnings.push(warning);
841
+ app.log.error({ err, agentId: replacement.id }, `PUT /agents ${warning}`);
842
+ }
843
+ },
844
+ });
845
+ if (commitResult.restartRequired)
846
+ bootstrap = null;
847
+ replacementResult = {
848
+ agent: storedReplacement,
849
+ replaced: agentId,
850
+ runtimeStatus: 'pending',
851
+ restartRequired: commitResult.restartRequired,
852
+ ...(commitResult.warnings.length ? { warnings: commitResult.warnings } : {}),
853
+ };
854
+ return null;
459
855
  }
460
- catch (err) {
461
- const restoreError = err instanceof Error ? err : new Error(String(err));
462
- rollbackError = rollbackError
463
- ? new AggregateError([rollbackError, restoreError], 'Agent deletion rollback and lock release failed')
464
- : restoreError;
856
+ finally {
857
+ await removal.finish().catch((err) => {
858
+ app.log.error({ err, agentId }, 'PUT /agents leftover replacement cleanup failed');
859
+ });
465
860
  }
466
- return rollbackError;
467
- };
468
- let claimedTargets = [];
469
- // True only after saveConfig commits: pre-commit escapes must rollback to the original owners; post-commit just releases.
470
- let committed = false;
861
+ });
862
+ if (phaseResult)
863
+ return phaseResult;
864
+ if (reply.sent)
865
+ return reply;
866
+ if (bootstrap) {
867
+ const { agentId: replacementId, creationToken } = bootstrap;
868
+ void app.ctx.agentManager.startBootstrapAsync(replacementId, creationToken).catch((err) => {
869
+ app.log.error({ err, agentId: replacementId }, 'PUT /agents startBootstrapAsync threw — should be impossible');
870
+ });
871
+ }
872
+ return reply.send(replacementResult);
873
+ });
874
+ app.delete('/projects/:projectId/agents/:agentId', async (request, reply) => {
875
+ if (!app.ctx.configPath) {
876
+ return reply.status(500).send({ error: 'No config path configured' });
877
+ }
878
+ const { projectId, agentId } = request.params;
879
+ let targets = [];
880
+ let originalConfigHash = '';
881
+ let validatedAfterRemove = null;
882
+ const removalRef = { current: null };
471
883
  try {
472
884
  const phase1Result = await withConfigLock(async () => {
473
885
  const project = app.ctx.config.project.find(p => p.id === projectId);
@@ -479,8 +891,9 @@ export async function projectRoutes(app) {
479
891
  return reply.status(404).send({ error: `Agent "${agentId}" not found in project "${projectId}"` });
480
892
  }
481
893
  targets = app.ctx.agentManager.prepareRemoveTargets(agentId).targets.slice().sort();
482
- // Scan + tombstone-claim run atomically under the manager's task lock, blocking a concurrent createTask/dispatchPendingTask from committing onto a target mid-delete.
483
- const claim = await app.ctx.agentManager.scanActiveThenClaimDeletion(targets);
894
+ const transaction = new AgentRemovalTransaction(app, targets, 'DELETE');
895
+ removalRef.current = transaction;
896
+ const claim = await transaction.claim();
484
897
  if (!claim.ok) {
485
898
  switch (claim.code) {
486
899
  case 'active':
@@ -494,7 +907,7 @@ export async function projectRoutes(app) {
494
907
  });
495
908
  case 'referencing':
496
909
  return reply.status(409).send({
497
- error: `Agent "${agentId}" is referenced by active task ${claim.taskId}; ` +
910
+ error: `Agent "${agentId}" is referenced by open task ${claim.taskId}; ` +
498
911
  `cancel or finish that task before deleting (would dangle its dev/QA/retry reference).`,
499
912
  });
500
913
  case 'already-deleting':
@@ -503,49 +916,23 @@ export async function projectRoutes(app) {
503
916
  });
504
917
  }
505
918
  }
506
- claimedTargets = targets.slice();
507
- for (const id of targets) {
508
- const state = await app.ctx.agentStore.get(id);
509
- const claim = await app.ctx.lockManager.claimOf(id);
510
- const deletionToken = randomUUID();
511
- if (claim === null) {
512
- // No lock file (unbound, or a stale binding whose lock is gone): rotate from unbound == acquire.
513
- const ok = await app.ctx.lockManager.rotateClaim(id, { unbound: true }, { taskId: deletionOwner, token: deletionToken });
514
- if (!ok) {
515
- await rollbackClaims();
919
+ const rotation = await transaction.rotateClaims();
920
+ if (!rotation.ok) {
921
+ switch (rotation.code) {
922
+ case 'locked':
516
923
  return reply.status(409).send({
517
- error: `Agent "${id}" is currently locked by another op; please retry later`,
924
+ error: `Agent "${rotation.agentId}" is currently locked by another op; please retry later`,
925
+ });
926
+ case 'stale-binding':
927
+ return reply.status(409).send({
928
+ error: `Agent "${rotation.agentId}" has a stale task binding whose exclusive lock is owned by another ` +
929
+ `operation; retry after it finishes.`,
930
+ });
931
+ case 'claim-changed':
932
+ return reply.status(409).send({
933
+ error: `Agent "${rotation.agentId}" lock changed while claiming it for deletion; please retry.`,
518
934
  });
519
- }
520
- rotatedClaims.set(id, { deletionToken, original: null });
521
- continue;
522
- }
523
- // A lock exists — only rotate it if it belongs to THIS agent's (now-terminal) task binding;
524
- // a lock owned by an unrelated live op must not be stolen.
525
- if (state?.taskId && claim.taskId !== state.taskId) {
526
- await rollbackClaims();
527
- return reply.status(409).send({
528
- error: `Agent "${id}" has a stale task binding whose exclusive lock is owned by another operation; retry after it finishes.`,
529
- });
530
- }
531
- if (!state?.taskId) {
532
- await rollbackClaims();
533
- return reply.status(409).send({
534
- error: `Agent "${id}" is currently locked by another op; please retry later`,
535
- });
536
- }
537
- const ok = await app.ctx.lockManager.rotateClaim(id, { taskId: claim.taskId, token: claim.token }, { taskId: deletionOwner, token: deletionToken });
538
- if (!ok) {
539
- await rollbackClaims();
540
- return reply.status(409).send({
541
- error: `Agent "${id}" lock changed while claiming it for deletion; please retry.`,
542
- });
543
935
  }
544
- rotatedClaims.set(id, { deletionToken, original: { taskId: claim.taskId, token: claim.token } });
545
- }
546
- for (const id of targets) {
547
- const existing = await app.ctx.agentStore.get(id);
548
- originalStates.set(id, existing);
549
936
  }
550
937
  originalConfigHash = configHash(app.ctx.config);
551
938
  const removeRequest = omitAgentsFromProject(app.ctx.config, projectId, targets);
@@ -553,7 +940,9 @@ export async function projectRoutes(app) {
553
940
  validatedAfterRemove = prepareConfig(removeRequest);
554
941
  }
555
942
  catch (err) {
556
- await rollbackClaims();
943
+ const rollbackError = await transaction.rollbackClaimsForRetry();
944
+ if (rollbackError)
945
+ throw rollbackError;
557
946
  if (err instanceof ConfigValidationError) {
558
947
  return reply.status(400).send({ error: 'Invalid config', details: err.errors });
559
948
  }
@@ -571,9 +960,9 @@ export async function projectRoutes(app) {
571
960
  catch (err) {
572
961
  const failures = err instanceof CleanupFailedError
573
962
  ? err.failures
574
- : [{ agentId: agentId, step: 'runtime.cleanup', error: err }];
963
+ : [{ agentId, step: 'runtime.cleanup', error: err }];
575
964
  app.log.error({ failures, targets }, 'DELETE /agents cleanupRemovedAgentRuntime failed; rolling back state and locks');
576
- const rollbackError = await withConfigLock(rollbackAndRelease);
965
+ const rollbackError = await withConfigLock(() => removalRef.current.rollbackStateAndClaims());
577
966
  if (rollbackError) {
578
967
  app.log.error({ err: rollbackError, targets }, 'DELETE /agents rollback failed');
579
968
  return reply.status(500).send({
@@ -583,10 +972,14 @@ export async function projectRoutes(app) {
583
972
  return reply.status(502).send({
584
973
  error: `runtime cleanup failed for ${targets.join(',')}; agent state and exact task locks were restored. ` +
585
974
  `Fix the underlying SSH / tmux / Workdir issue and retry DELETE.`,
586
- failures: failures.map(f => ({ agentId: f.agentId, step: f.step,
587
- error: f.error instanceof Error ? f.error.message : String(f.error) })),
975
+ failures: failures.map(f => ({
976
+ agentId: f.agentId,
977
+ step: f.step,
978
+ error: f.error instanceof Error ? f.error.message : String(f.error),
979
+ })),
588
980
  });
589
981
  }
982
+ const transaction = removalRef.current;
590
983
  const phase3Result = await withConfigLock(async () => {
591
984
  const currentHash = configHash(app.ctx.config);
592
985
  const rollbackBase = currentHash === originalConfigHash
@@ -597,7 +990,7 @@ export async function projectRoutes(app) {
597
990
  validated = prepareConfig(rollbackBase);
598
991
  }
599
992
  catch (err) {
600
- const rollbackError = await rollbackAndRelease();
993
+ const rollbackError = await transaction.rollbackStateAndClaims();
601
994
  if (rollbackError) {
602
995
  app.log.error({ err: rollbackError, targets }, 'DELETE /agents rollback after config validation failed');
603
996
  return reply.status(500).send({
@@ -609,106 +1002,36 @@ export async function projectRoutes(app) {
609
1002
  }
610
1003
  throw err;
611
1004
  }
612
- // Its only caller (saveConfig failure) runs before any state delete, so recovery is just rotating each claim back to its original owner.
613
- const restoreForRetry = async () => {
614
- try {
615
- await rollbackClaims();
616
- return null;
617
- }
618
- catch (e) {
619
- return e instanceof Error ? e : new Error(String(e));
620
- }
621
- };
622
- // Step 1 — commit config (atomic saveConfig) BEFORE any state delete: a crash between the two would else leave config referencing an agent whose state is gone, behind a stuck deletion:<uuid> lock (unrecoverable wedge).
623
- const warnings = [];
624
1005
  try {
625
1006
  await saveConfig(app.ctx.configPath, validated);
626
1007
  }
627
1008
  catch (err) {
628
1009
  app.log.error({ err, targets }, 'DELETE /agents Phase 3 saveConfig failed; releasing acquired locks (no state was deleted yet)');
629
- const restoreError = await restoreForRetry();
630
- if (restoreError) {
1010
+ const rollbackError = await transaction.rollbackClaimsForRetry();
1011
+ if (rollbackError) {
631
1012
  return reply.status(500).send({
632
- error: `failed to persist config and roll the deletion locks back safely: ${restoreError.message}`,
1013
+ error: `failed to persist config and roll the deletion locks back safely: ${rollbackError.message}`,
633
1014
  });
634
1015
  }
635
1016
  return reply.status(500).send({
636
1017
  error: 'failed to persist config after runtime cleanup; this attempt\'s locks were released and agent state left intact',
637
1018
  });
638
1019
  }
639
- // Disk committed point of no return. Bump the generation before any memory switch that could throw.
640
- committed = true;
641
- for (const id of targets)
642
- app.ctx.agentManager.bumpDeletionGeneration(id);
643
- // Step 2 delete agent state POST-commit (best-effort): config already dropped these agents, so a failure here leaves only reclaimable orphan state.
644
- for (const id of targets) {
645
- try {
646
- await app.ctx.agentStore.delete(id);
647
- }
648
- catch (delErr) {
649
- const msg = `agent ${id} state delete failed post-commit (reclaimable orphan state): ${delErr instanceof Error ? delErr.message : String(delErr)}`;
650
- app.log.error({ err: delErr, agentId: id, targets }, `DELETE /agents Phase 3 ${msg}`);
651
- warnings.push(msg);
652
- }
653
- }
654
- // Step 3 — in-memory switch (never reverts; pollers cleared once). A throw here is a program bug: fail-stop by retaining the tombstone so nothing re-creates the id.
655
- try {
656
- app.ctx.config = validated;
657
- app.ctx.agentManager.replaceConfig(validated);
658
- app.ctx.tmuxProbePoller?.replaceConfig(validated);
659
- app.ctx.bootstrapPoller?.replaceConfig(validated);
660
- app.ctx.dispatchReconciler?.replaceConfig(validated);
661
- }
662
- catch (err) {
663
- for (const id of targets)
664
- app.ctx.agentManager.retainTombstoneForDivergence(id);
665
- app.log.fatal({ err, targets }, 'DELETE /agents Phase 3 replaceConfig threw AFTER disk commit; tombstone retained, restart advised');
666
- }
667
- // Step 4 — owner-scoped release of the deletion-owner claims + best-effort side cleanup; past the commit point, failures are surfaced as warnings, never rolled back.
668
- for (const id of targets) {
669
- const rotated = rotatedClaims.get(id);
670
- if (rotated) {
671
- try {
672
- await app.ctx.lockManager.releaseIfOwner(id, deletionOwner, rotated.deletionToken);
673
- rotatedClaims.delete(id);
674
- }
675
- catch (releaseErr) {
676
- warnings.push(`lock release for ${id} failed: ${releaseErr instanceof Error ? releaseErr.message : String(releaseErr)}`);
677
- app.log.warn({ err: releaseErr, agentId: id }, 'DELETE /agents Phase 3 lock release failed (post-commit)');
678
- }
679
- }
680
- if (app.ctx.errorRecordStore) {
681
- try {
682
- await app.ctx.errorRecordStore.purgeAgent(id);
683
- }
684
- catch (purgeErr) {
685
- app.log.warn({ err: purgeErr, agentId: id }, 'DELETE /agents errorRecordStore.purgeAgent failed');
686
- }
687
- }
688
- if (app.ctx.petStore) {
689
- try {
690
- await app.ctx.petStore.setAssignment(id, null);
691
- }
692
- catch (petErr) {
693
- app.log.warn({ err: petErr, agentId: id }, 'DELETE /agents petStore.setAssignment(null) failed');
694
- }
695
- }
696
- }
697
- return reply.send({ removed: targets, restartRequired: false, ...(warnings.length ? { warnings } : {}) });
1020
+ const commitResult = await transaction.finalizeCommit(validated);
1021
+ return reply.send({
1022
+ removed: targets,
1023
+ restartRequired: commitResult.restartRequired,
1024
+ ...(commitResult.warnings.length ? { warnings: commitResult.warnings } : {}),
1025
+ });
698
1026
  });
699
1027
  return phase3Result;
700
1028
  }
701
1029
  finally {
702
- // Leftover rotated claims (normal paths clear them): pre-commit escapes roll back to the original owners; releasing pre-commit would strand a lockless binding. Post-commit the agents are gone, so just release.
703
- if (rotatedClaims.size > 0) {
704
- const cleanup = committed ? releaseDeletionClaims() : rollbackClaims();
705
- await cleanup.catch((err) => {
706
- app.log.error({ err, committed }, 'DELETE /agents: leftover deletion-claim cleanup failed');
1030
+ if (removalRef.current) {
1031
+ await removalRef.current.finish().catch((err) => {
1032
+ app.log.error({ err }, 'DELETE /agents leftover deletion-claim cleanup failed');
707
1033
  });
708
1034
  }
709
- if (claimedTargets.length > 0) {
710
- app.ctx.agentManager.releaseDeletionClaim(claimedTargets);
711
- }
712
1035
  }
713
1036
  });
714
1037
  app.post('/projects/:projectId/agents/:agentId/resume', async (request, reply) => {
@@ -878,7 +1201,7 @@ export async function projectRoutes(app) {
878
1201
  return reply.send({ ok: true, agentId });
879
1202
  });
880
1203
  }
881
- function findConfiguredWorkdirConflict(config, candidate) {
1204
+ function findConfiguredWorkdirConflict(config, candidate, ignoredAgentIds = new Set()) {
882
1205
  if (typeof candidate.workdir !== 'string'
883
1206
  || candidate.workdir.trim() === ''
884
1207
  || !isAbsolute(candidate.workdir)
@@ -888,16 +1211,34 @@ function findConfiguredWorkdirConflict(config, candidate) {
888
1211
  if (candidate.mode === 'remote'
889
1212
  && (!resolvedCandidateHost || typeof resolvedCandidateHost.hostname !== 'string'))
890
1213
  return null;
891
- const candidateHost = workdirHostGroupKey(candidate.mode, resolvedCandidateHost);
892
1214
  const candidatePath = normalize(candidate.workdir);
1215
+ const configured = [];
1216
+ if (config.root) {
1217
+ configured.push({
1218
+ id: ROOT_AGENT_ID,
1219
+ mode: config.root.mode,
1220
+ host: resolveAgentHost(config.host, config.root.host),
1221
+ workdir: config.root.workdir,
1222
+ });
1223
+ }
893
1224
  for (const project of config.project) {
894
1225
  for (const existing of project.agent.flat()) {
895
1226
  if (!existing.workdir)
896
1227
  continue;
897
- const existingHost = workdirHostGroupKey(existing.mode, resolveAgentHost(config.host, existing.host));
898
- if (existingHost === candidateHost && normalize(existing.workdir) === candidatePath) {
899
- return existing.id;
900
- }
1228
+ configured.push({
1229
+ id: existing.id,
1230
+ mode: existing.mode,
1231
+ host: resolveAgentHost(config.host, existing.host),
1232
+ workdir: existing.workdir,
1233
+ });
1234
+ }
1235
+ }
1236
+ for (const existing of configured) {
1237
+ if (ignoredAgentIds.has(existing.id))
1238
+ continue;
1239
+ if (normalize(existing.workdir) === candidatePath
1240
+ && mayShareHostAccount(existing.mode, existing.host, candidate.mode, resolvedCandidateHost)) {
1241
+ return existing.id;
901
1242
  }
902
1243
  }
903
1244
  return null;
@@ -1008,49 +1349,4 @@ async function pendingCleanupAfterReplReady(app, agentId, takeover, maintenance)
1008
1349
  },
1009
1350
  });
1010
1351
  }
1011
- async function rollbackPerTargetState(app, targets, originalStates) {
1012
- const failures = [];
1013
- for (const id of targets) {
1014
- const original = originalStates.get(id) ?? null;
1015
- if (!original) {
1016
- try {
1017
- await app.ctx.agentStore.delete(id);
1018
- }
1019
- catch (err) {
1020
- failures.push(new Error(`delete transient state for ${id}: ${err instanceof Error ? err.message : String(err)}`));
1021
- }
1022
- continue;
1023
- }
1024
- try {
1025
- let lockToken = original.lockToken;
1026
- if (original.taskId) {
1027
- const claim = await app.ctx.lockManager.claimOf(id);
1028
- if (claim?.taskId === original.taskId) {
1029
- lockToken = claim.token;
1030
- }
1031
- else if (claim === null) {
1032
- lockToken = await app.ctx.lockManager.acquire(id, original.taskId) ?? undefined;
1033
- }
1034
- else {
1035
- throw new Error(`lock is owned by ${claim.taskId}, expected ${original.taskId}`);
1036
- }
1037
- if (!lockToken || !(await app.ctx.lockManager.isOwner(id, original.taskId, lockToken))) {
1038
- throw new Error(`could not restore exact task lock for ${original.taskId}`);
1039
- }
1040
- }
1041
- await app.ctx.agentStore.set({
1042
- ...original,
1043
- ...(original.taskId ? { lockToken } : { lockToken: undefined }),
1044
- paneId: undefined,
1045
- updatedAt: new Date().toISOString(),
1046
- });
1047
- }
1048
- catch (err) {
1049
- failures.push(new Error(`restore state for ${id}: ${err instanceof Error ? err.message : String(err)}`));
1050
- }
1051
- }
1052
- if (failures.length > 0) {
1053
- throw new AggregateError(failures, `Failed to roll back ${failures.length} agent deletion state(s)`);
1054
- }
1055
- }
1056
1352
  //# sourceMappingURL=projects.js.map