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.
- package/README.md +5 -7
- package/README.zh-CN.md +5 -7
- package/dist/agent/bootstrap.d.ts.map +1 -1
- package/dist/agent/bootstrap.js +2 -4
- package/dist/agent/bootstrap.js.map +1 -1
- package/dist/agent/branch.d.ts +0 -13
- package/dist/agent/branch.d.ts.map +1 -1
- package/dist/agent/branch.js +1 -71
- package/dist/agent/branch.js.map +1 -1
- package/dist/agent/dispatch-reconciler.d.ts.map +1 -1
- package/dist/agent/dispatch-reconciler.js +19 -14
- package/dist/agent/dispatch-reconciler.js.map +1 -1
- package/dist/agent/manager.d.ts +90 -134
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +1154 -2265
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/net-exec.d.ts +3 -0
- package/dist/agent/net-exec.d.ts.map +1 -1
- package/dist/agent/net-exec.js +6 -0
- package/dist/agent/net-exec.js.map +1 -1
- package/dist/agent/pane-streamer-manager.d.ts +6 -6
- package/dist/agent/pane-streamer-manager.d.ts.map +1 -1
- package/dist/agent/pane-streamer-manager.js.map +1 -1
- package/dist/agent/pane-streamer.d.ts +6 -3
- package/dist/agent/pane-streamer.d.ts.map +1 -1
- package/dist/agent/pane-streamer.js +70 -40
- package/dist/agent/pane-streamer.js.map +1 -1
- package/dist/agent/phase-signal-watcher.d.ts +7 -2
- package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
- package/dist/agent/phase-signal-watcher.js +90 -32
- package/dist/agent/phase-signal-watcher.js.map +1 -1
- package/dist/agent/phase-signal.d.ts +19 -38
- package/dist/agent/phase-signal.d.ts.map +1 -1
- package/dist/agent/phase-signal.js +54 -88
- package/dist/agent/phase-signal.js.map +1 -1
- package/dist/agent/preflight.d.ts +0 -1
- package/dist/agent/preflight.d.ts.map +1 -1
- package/dist/agent/preflight.js +8 -15
- package/dist/agent/preflight.js.map +1 -1
- package/dist/agent/prompt.d.ts +1 -35
- package/dist/agent/prompt.d.ts.map +1 -1
- package/dist/agent/prompt.js +22 -162
- package/dist/agent/prompt.js.map +1 -1
- package/dist/agent/repo-store.d.ts +4 -1
- package/dist/agent/repo-store.d.ts.map +1 -1
- package/dist/agent/repo-store.js +33 -11
- package/dist/agent/repo-store.js.map +1 -1
- package/dist/agent/root-agent-runtime.d.ts +80 -0
- package/dist/agent/root-agent-runtime.d.ts.map +1 -0
- package/dist/agent/root-agent-runtime.js +637 -0
- package/dist/agent/root-agent-runtime.js.map +1 -0
- package/dist/agent/root-recovery-coordinator.d.ts +124 -0
- package/dist/agent/root-recovery-coordinator.d.ts.map +1 -0
- package/dist/agent/root-recovery-coordinator.js +1557 -0
- package/dist/agent/root-recovery-coordinator.js.map +1 -0
- package/dist/agent/runner.d.ts +1 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +103 -7
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/tmux-probe-poller.d.ts +1 -0
- package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
- package/dist/agent/tmux-probe-poller.js +16 -10
- package/dist/agent/tmux-probe-poller.js.map +1 -1
- package/dist/agent/tmux.d.ts +3 -0
- package/dist/agent/tmux.d.ts.map +1 -1
- package/dist/agent/tmux.js +11 -5
- package/dist/agent/tmux.js.map +1 -1
- package/dist/agent/vt-visible-text.d.ts +42 -0
- package/dist/agent/vt-visible-text.d.ts.map +1 -0
- package/dist/agent/vt-visible-text.js +482 -0
- package/dist/agent/vt-visible-text.js.map +1 -0
- package/dist/api/agents.d.ts.map +1 -1
- package/dist/api/agents.js +42 -1
- package/dist/api/agents.js.map +1 -1
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +83 -9
- package/dist/api/config.js.map +1 -1
- package/dist/api/hosts.d.ts.map +1 -1
- package/dist/api/hosts.js +68 -13
- package/dist/api/hosts.js.map +1 -1
- package/dist/api/platform-guard.js +2 -2
- package/dist/api/platform-guard.js.map +1 -1
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +621 -325
- package/dist/api/projects.js.map +1 -1
- package/dist/api/tasks.d.ts.map +1 -1
- package/dist/api/tasks.js +15 -39
- package/dist/api/tasks.js.map +1 -1
- package/dist/app.d.ts +2 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +6 -0
- package/dist/app.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +49 -7
- package/dist/cli.js.map +1 -1
- package/dist/config/loader.d.ts +5 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +57 -24
- package/dist/config/loader.js.map +1 -1
- package/dist/config/normalizer.d.ts.map +1 -1
- package/dist/config/normalizer.js +18 -0
- package/dist/config/normalizer.js.map +1 -1
- package/dist/config/validator.d.ts +7 -4
- package/dist/config/validator.d.ts.map +1 -1
- package/dist/config/validator.js +251 -96
- package/dist/config/validator.js.map +1 -1
- package/dist/event/handlers.d.ts.map +1 -1
- package/dist/event/handlers.js +359 -229
- package/dist/event/handlers.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +45 -5
- package/dist/index.js.map +1 -1
- package/dist/platform/command-renderer.d.ts +6 -0
- package/dist/platform/command-renderer.d.ts.map +1 -1
- package/dist/platform/command-renderer.js +20 -0
- package/dist/platform/command-renderer.js.map +1 -1
- package/dist/platform/driver-host.d.ts.map +1 -1
- package/dist/platform/driver-host.js +3 -1
- package/dist/platform/driver-host.js.map +1 -1
- package/dist/platform/driver-spec.d.ts.map +1 -1
- package/dist/platform/driver-spec.js +32 -4
- package/dist/platform/driver-spec.js.map +1 -1
- package/dist/platform/git-driver.d.ts +2 -0
- package/dist/platform/git-driver.d.ts.map +1 -1
- package/dist/platform/git-driver.js +9 -3
- package/dist/platform/git-driver.js.map +1 -1
- package/dist/platform/platform-poller.d.ts +0 -1
- package/dist/platform/platform-poller.d.ts.map +1 -1
- package/dist/platform/platform-poller.js +1 -11
- package/dist/platform/platform-poller.js.map +1 -1
- package/dist/platform/plugins/github/driver.json +5 -0
- package/dist/platform/plugins/github/skills/baxian-cli-gh/SKILL.md +20 -14
- package/dist/platform/startup.d.ts.map +1 -1
- package/dist/platform/startup.js +5 -5
- package/dist/platform/startup.js.map +1 -1
- package/dist/platform/types.d.ts +1 -0
- package/dist/platform/types.d.ts.map +1 -1
- package/dist/platform/types.js +3 -3
- package/dist/platform/types.js.map +1 -1
- package/dist/shared/constants.d.ts +8 -7
- package/dist/shared/constants.d.ts.map +1 -1
- package/dist/shared/constants.js +6 -28
- package/dist/shared/constants.js.map +1 -1
- package/dist/shared/types.d.ts +52 -125
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js +24 -5
- package/dist/shared/types.js.map +1 -1
- package/dist/skills/baxian-pr-feedback/SKILL.md +6 -6
- package/dist/skills/baxian-pr-recheck/SKILL.md +6 -0
- package/dist/skills/baxian-pr-review/SKILL.md +6 -0
- package/dist/skills/baxian-signals/SKILL.md +17 -28
- package/dist/skills/baxian-task-check/SKILL.md +13 -14
- package/dist/state/init.d.ts.map +1 -1
- package/dist/state/init.js +1 -0
- package/dist/state/init.js.map +1 -1
- package/dist/state/lock.d.ts +6 -0
- package/dist/state/lock.d.ts.map +1 -1
- package/dist/state/lock.js +8 -0
- package/dist/state/lock.js.map +1 -1
- package/dist/state/root-recovery-store.d.ts +107 -0
- package/dist/state/root-recovery-store.d.ts.map +1 -0
- package/dist/state/root-recovery-store.js +400 -0
- package/dist/state/root-recovery-store.js.map +1 -0
- package/dist/state/snapshot.d.ts.map +1 -1
- package/dist/state/snapshot.js +5 -2
- package/dist/state/snapshot.js.map +1 -1
- package/dist/state/task-store.d.ts.map +1 -1
- package/dist/state/task-store.js +57 -137
- package/dist/state/task-store.js.map +1 -1
- package/dist/terminal/attach.d.ts +3 -3
- package/dist/terminal/attach.d.ts.map +1 -1
- package/dist/terminal/attach.js.map +1 -1
- package/dist/terminal/stream-ws.d.ts.map +1 -1
- package/dist/terminal/stream-ws.js.map +1 -1
- package/dist/web/assets/index-BKiM0MSM.js +15 -0
- package/dist/web/assets/index-BS2adAfI.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/agent/review-transport.d.ts +0 -92
- package/dist/agent/review-transport.d.ts.map +0 -1
- package/dist/agent/review-transport.js +0 -512
- package/dist/agent/review-transport.js.map +0 -1
- package/dist/event/server-handlers.d.ts +0 -4
- package/dist/event/server-handlers.d.ts.map +0 -1
- package/dist/event/server-handlers.js +0 -1221
- package/dist/event/server-handlers.js.map +0 -1
- package/dist/skills/baxian-research/SKILL.md +0 -28
- package/dist/skills/baxian-server-feedback/SKILL.md +0 -40
- package/dist/skills/baxian-server-feedback/agents/openai.yaml +0 -2
- package/dist/skills/baxian-server-review/SKILL.md +0 -58
- package/dist/skills/baxian-server-review/agents/openai.yaml +0 -2
- package/dist/state/review-store.d.ts +0 -33
- package/dist/state/review-store.d.ts.map +0 -1
- package/dist/state/review-store.js +0 -272
- package/dist/state/review-store.js.map +0 -1
- package/dist/web/assets/index-BPv6vv3U.css +0 -1
- package/dist/web/assets/index-BXfgaFNm.js +0 -18
package/dist/api/projects.js
CHANGED
|
@@ -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,
|
|
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 {
|
|
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,
|
|
150
|
-
|
|
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,
|
|
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
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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 (
|
|
301
|
-
return reply.status(
|
|
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
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
339
|
-
if (
|
|
581
|
+
const workdirConflict = findConfiguredWorkdirConflict(workdirConfig, agentInput);
|
|
582
|
+
if (workdirConflict) {
|
|
340
583
|
return reply.status(409).send({
|
|
341
|
-
error: `
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
394
|
-
.startBootstrapAsync(
|
|
395
|
-
|
|
396
|
-
|
|
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
|
|
676
|
+
const storedGroup = storedProject.agent.find(group => group.some(agent => agent.id === dev.id));
|
|
400
677
|
return reply.status(201).send({
|
|
401
|
-
|
|
678
|
+
agents: storedGroup,
|
|
402
679
|
runtimeStatus: 'pending',
|
|
403
|
-
restartRequired
|
|
680
|
+
restartRequired,
|
|
681
|
+
...(warnings.length ? { warnings } : {}),
|
|
404
682
|
});
|
|
405
683
|
});
|
|
406
|
-
app.
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
745
|
+
validated = prepareConfig(nextConfig);
|
|
453
746
|
}
|
|
454
747
|
catch (err) {
|
|
455
|
-
|
|
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
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
483
|
-
|
|
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
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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 "${
|
|
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
|
|
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
|
|
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(
|
|
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 => ({
|
|
587
|
-
|
|
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
|
|
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
|
|
630
|
-
if (
|
|
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: ${
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|