baxian 2.0.1 → 2.0.3
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 -12
- package/dist/agent/branch.d.ts.map +1 -1
- package/dist/agent/branch.js +1 -78
- package/dist/agent/branch.js.map +1 -1
- package/dist/agent/detect/region.d.ts.map +1 -1
- package/dist/agent/detect/region.js +0 -3
- package/dist/agent/detect/region.js.map +1 -1
- package/dist/agent/dispatch-reconciler.d.ts.map +1 -1
- package/dist/agent/dispatch-reconciler.js +8 -59
- package/dist/agent/dispatch-reconciler.js.map +1 -1
- package/dist/agent/lineage.d.ts.map +1 -1
- package/dist/agent/lineage.js +0 -9
- package/dist/agent/lineage.js.map +1 -1
- package/dist/agent/manager.d.ts +66 -142
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +936 -2738
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/net-exec.d.ts.map +1 -1
- package/dist/agent/net-exec.js +0 -5
- package/dist/agent/net-exec.js.map +1 -1
- package/dist/agent/pane-streamer-manager.d.ts.map +1 -1
- package/dist/agent/pane-streamer-manager.js +0 -36
- package/dist/agent/pane-streamer-manager.js.map +1 -1
- package/dist/agent/pane-streamer.d.ts +10 -1
- package/dist/agent/pane-streamer.d.ts.map +1 -1
- package/dist/agent/pane-streamer.js +142 -79
- package/dist/agent/pane-streamer.js.map +1 -1
- package/dist/agent/phase-signal-watcher.d.ts +2 -11
- package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
- package/dist/agent/phase-signal-watcher.js +36 -160
- package/dist/agent/phase-signal-watcher.js.map +1 -1
- package/dist/agent/phase-signal.d.ts +13 -40
- package/dist/agent/phase-signal.d.ts.map +1 -1
- package/dist/agent/phase-signal.js +38 -98
- 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 +6 -31
- package/dist/agent/preflight.js.map +1 -1
- package/dist/agent/prompt.d.ts +1 -30
- package/dist/agent/prompt.d.ts.map +1 -1
- package/dist/agent/prompt.js +20 -148
- package/dist/agent/prompt.js.map +1 -1
- package/dist/agent/repo-store.d.ts.map +1 -1
- package/dist/agent/repo-store.js +0 -42
- package/dist/agent/repo-store.js.map +1 -1
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +0 -2
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/tmux-install.js +0 -1
- package/dist/agent/tmux-install.js.map +1 -1
- package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
- package/dist/agent/tmux-probe-poller.js +0 -7
- package/dist/agent/tmux-probe-poller.js.map +1 -1
- package/dist/agent/tmux.d.ts.map +1 -1
- package/dist/agent/tmux.js +0 -86
- package/dist/agent/tmux.js.map +1 -1
- package/dist/agent/vt-visible-text.d.ts +87 -0
- package/dist/agent/vt-visible-text.d.ts.map +1 -0
- package/dist/agent/vt-visible-text.js +1058 -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 +1 -42
- package/dist/api/agents.js.map +1 -1
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +8 -53
- package/dist/api/config.js.map +1 -1
- package/dist/api/hosts.d.ts.map +1 -1
- package/dist/api/hosts.js +2 -57
- package/dist/api/hosts.js.map +1 -1
- package/dist/api/platform-guard.d.ts.map +1 -1
- package/dist/api/platform-guard.js +2 -3
- package/dist/api/platform-guard.js.map +1 -1
- package/dist/api/probe.d.ts.map +1 -1
- package/dist/api/probe.js +0 -1
- package/dist/api/probe.js.map +1 -1
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +599 -332
- package/dist/api/projects.js.map +1 -1
- package/dist/api/tasks.d.ts.map +1 -1
- package/dist/api/tasks.js +15 -40
- package/dist/api/tasks.js.map +1 -1
- package/dist/app.d.ts +0 -2
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +0 -6
- package/dist/app.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +4 -29
- package/dist/cli.js.map +1 -1
- package/dist/config/hot-reload.d.ts.map +1 -1
- package/dist/config/hot-reload.js +0 -4
- package/dist/config/hot-reload.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 +36 -47
- package/dist/config/loader.js.map +1 -1
- package/dist/config/normalizer.d.ts.map +1 -1
- package/dist/config/normalizer.js +0 -19
- 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 +56 -270
- package/dist/config/validator.js.map +1 -1
- package/dist/event/handlers.d.ts.map +1 -1
- package/dist/event/handlers.js +356 -234
- package/dist/event/handlers.js.map +1 -1
- package/dist/event/log.js +0 -2
- package/dist/event/log.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -50
- package/dist/index.js.map +1 -1
- package/dist/platform/body-digest.d.ts.map +1 -1
- package/dist/platform/body-digest.js +0 -4
- package/dist/platform/body-digest.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 -3
- package/dist/platform/command-renderer.js.map +1 -1
- package/dist/platform/comment-cursor.d.ts.map +1 -1
- package/dist/platform/comment-cursor.js +0 -21
- package/dist/platform/comment-cursor.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 +33 -46
- package/dist/platform/driver-spec.js.map +1 -1
- package/dist/platform/field-mapper.d.ts.map +1 -1
- package/dist/platform/field-mapper.js +3 -11
- package/dist/platform/field-mapper.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 -23
- package/dist/platform/git-driver.js.map +1 -1
- package/dist/platform/manifest.d.ts.map +1 -1
- package/dist/platform/manifest.js +0 -3
- package/dist/platform/manifest.js.map +1 -1
- package/dist/platform/markers.d.ts.map +1 -1
- package/dist/platform/markers.js +0 -19
- package/dist/platform/markers.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 +0 -49
- package/dist/platform/platform-poller.js.map +1 -1
- package/dist/platform/plugin-registry.d.ts.map +1 -1
- package/dist/platform/plugin-registry.js +1 -12
- package/dist/platform/plugin-registry.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/pr-conversation-cache.d.ts.map +1 -1
- package/dist/platform/pr-conversation-cache.js +0 -17
- package/dist/platform/pr-conversation-cache.js.map +1 -1
- package/dist/platform/preflight-exec.d.ts.map +1 -1
- package/dist/platform/preflight-exec.js +0 -9
- package/dist/platform/preflight-exec.js.map +1 -1
- package/dist/platform/response-parser.d.ts.map +1 -1
- package/dist/platform/response-parser.js +0 -4
- package/dist/platform/response-parser.js.map +1 -1
- package/dist/platform/review-timeline.d.ts.map +1 -1
- package/dist/platform/review-timeline.js +0 -22
- package/dist/platform/review-timeline.js.map +1 -1
- package/dist/platform/row-schema.d.ts.map +1 -1
- package/dist/platform/row-schema.js +0 -11
- package/dist/platform/row-schema.js.map +1 -1
- package/dist/platform/startup.d.ts.map +1 -1
- package/dist/platform/startup.js +6 -17
- 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 -8
- package/dist/platform/types.js.map +1 -1
- package/dist/platform/verdict-engine.d.ts.map +1 -1
- package/dist/platform/verdict-engine.js +0 -15
- package/dist/platform/verdict-engine.js.map +1 -1
- package/dist/shared/concurrency.d.ts.map +1 -1
- package/dist/shared/concurrency.js +0 -2
- package/dist/shared/concurrency.js.map +1 -1
- package/dist/shared/constants.d.ts +1 -8
- package/dist/shared/constants.d.ts.map +1 -1
- package/dist/shared/constants.js +2 -32
- package/dist/shared/constants.js.map +1 -1
- package/dist/shared/git-url.d.ts.map +1 -1
- package/dist/shared/git-url.js +0 -9
- package/dist/shared/git-url.js.map +1 -1
- package/dist/shared/types.d.ts +28 -135
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js +7 -6
- package/dist/shared/types.js.map +1 -1
- package/dist/skill/registry.d.ts.map +1 -1
- package/dist/skill/registry.js +0 -5
- package/dist/skill/registry.js.map +1 -1
- package/dist/skills/baxian-pr-feedback/SKILL.md +5 -3
- package/dist/skills/baxian-pr-recheck/SKILL.md +4 -0
- package/dist/skills/baxian-pr-review/SKILL.md +4 -0
- package/dist/skills/baxian-signals/SKILL.md +15 -38
- package/dist/skills/baxian-task-check/SKILL.md +13 -14
- package/dist/state/agent-store.d.ts.map +1 -1
- package/dist/state/agent-store.js +0 -6
- package/dist/state/agent-store.js.map +1 -1
- package/dist/state/error-record-store.d.ts.map +1 -1
- package/dist/state/error-record-store.js +0 -5
- package/dist/state/error-record-store.js.map +1 -1
- package/dist/state/init.d.ts.map +1 -1
- package/dist/state/init.js +0 -1
- package/dist/state/init.js.map +1 -1
- package/dist/state/lock.d.ts.map +1 -1
- package/dist/state/lock.js +0 -1
- package/dist/state/lock.js.map +1 -1
- 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 +56 -143
- package/dist/state/task-store.js.map +1 -1
- package/dist/terminal/attach.d.ts.map +1 -1
- package/dist/terminal/attach.js +0 -4
- package/dist/terminal/attach.js.map +1 -1
- package/dist/terminal/stream-ws.js +0 -5
- package/dist/terminal/stream-ws.js.map +1 -1
- package/dist/web/assets/index-CiAnjs1_.js +15 -0
- package/dist/web/assets/index-yqxawAPJ.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/agent/root-agent-runtime.d.ts +0 -80
- package/dist/agent/root-agent-runtime.d.ts.map +0 -1
- package/dist/agent/root-agent-runtime.js +0 -635
- package/dist/agent/root-agent-runtime.js.map +0 -1
- package/dist/agent/root-recovery-coordinator.d.ts +0 -125
- package/dist/agent/root-recovery-coordinator.d.ts.map +0 -1
- package/dist/agent/root-recovery-coordinator.js +0 -1579
- package/dist/agent/root-recovery-coordinator.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 -1294
- 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/state/root-recovery-store.d.ts +0 -107
- package/dist/state/root-recovery-store.d.ts.map +0 -1
- package/dist/state/root-recovery-store.js +0 -400
- package/dist/state/root-recovery-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,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { isAbsolute, normalize } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { 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
6
|
import { createRunner, hostGroupKey, mayShareHostAccount, resolveAgentHost, } from '../agent/runner.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,
|
|
@@ -160,7 +412,6 @@ export async function projectRoutes(app) {
|
|
|
160
412
|
}
|
|
161
413
|
}
|
|
162
414
|
const agents = await Promise.all(checks);
|
|
163
|
-
// server 执行面手动诊断组:poller 未装配/零 agent 的部署也要能自证 merge/close 通道。
|
|
164
415
|
let server;
|
|
165
416
|
if (projectNeedsPlatformEntry(app.ctx.config, project)) {
|
|
166
417
|
server = { results: await runServerHostChecks(app.ctx.agentManager, project.id) };
|
|
@@ -228,7 +479,7 @@ export async function projectRoutes(app) {
|
|
|
228
479
|
return reply.status(500).send({ error: 'No config path configured' });
|
|
229
480
|
}
|
|
230
481
|
return withConfigLock(async () => {
|
|
231
|
-
const { id, repo, merge, specApproval,
|
|
482
|
+
const { id, repo, merge, specApproval, gitCli } = request.body ?? {};
|
|
232
483
|
if (!id || typeof id !== 'string') {
|
|
233
484
|
return reply.status(400).send({ error: 'id must be a non-empty string' });
|
|
234
485
|
}
|
|
@@ -243,7 +494,6 @@ export async function projectRoutes(app) {
|
|
|
243
494
|
repo,
|
|
244
495
|
merge: merge ?? null,
|
|
245
496
|
...(specApproval !== undefined ? { specApproval } : {}),
|
|
246
|
-
...(review !== undefined ? { review } : {}),
|
|
247
497
|
...(gitCli !== undefined ? { gitCli } : {}),
|
|
248
498
|
agent: [],
|
|
249
499
|
};
|
|
@@ -292,74 +542,61 @@ export async function projectRoutes(app) {
|
|
|
292
542
|
return reply.status(500).send({ error: 'No config path configured' });
|
|
293
543
|
}
|
|
294
544
|
const { projectId } = request.params;
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
545
|
+
const agents = request.body?.agents;
|
|
546
|
+
if (!Array.isArray(agents) || agents.length !== 2) {
|
|
547
|
+
return reply.status(400).send({ error: 'agents must contain exactly one dev and one qa agent' });
|
|
548
|
+
}
|
|
549
|
+
const dev = agents.find(agent => agent?.role === 'dev');
|
|
550
|
+
const qa = agents.find(agent => agent?.role === 'qa');
|
|
551
|
+
if (!dev || !qa || agents.some(agent => agent?.role !== 'dev' && agent?.role !== 'qa')) {
|
|
552
|
+
return reply.status(400).send({ error: 'agents must contain exactly one dev and one qa agent' });
|
|
299
553
|
}
|
|
300
|
-
if (typeof
|
|
301
|
-
|
|
554
|
+
if (typeof dev.id !== 'string' || dev.id.trim() === ''
|
|
555
|
+
|| typeof qa.id !== 'string' || qa.id.trim() === '') {
|
|
556
|
+
return reply.status(400).send({ error: 'both agent ids are required' });
|
|
302
557
|
}
|
|
303
|
-
|
|
558
|
+
if (dev.id === qa.id) {
|
|
559
|
+
return reply.status(409).send({ error: `Agent id "${dev.id}" is duplicated within the group` });
|
|
560
|
+
}
|
|
561
|
+
const creationTokens = new Map();
|
|
562
|
+
const warnings = [];
|
|
563
|
+
let restartRequired = false;
|
|
304
564
|
const phase1Result = await withConfigLock(async () => {
|
|
305
565
|
const project = app.ctx.config.project.find(p => p.id === projectId);
|
|
306
566
|
if (!project) {
|
|
307
567
|
return reply.status(404).send({ error: `Project "${projectId}" not found` });
|
|
308
568
|
}
|
|
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}` });
|
|
569
|
+
let workdirConfig = app.ctx.config;
|
|
570
|
+
for (const agentInput of [dev, qa]) {
|
|
571
|
+
const existsGlobally = app.ctx.config.project.some(p => p.agent.some(group => group.some(agent => agent.id === agentInput.id)));
|
|
572
|
+
if (existsGlobally) {
|
|
573
|
+
return reply.status(409).send({ error: `Agent id "${agentInput.id}" already exists` });
|
|
330
574
|
}
|
|
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`,
|
|
575
|
+
if (app.ctx.agentManager.isDeletionInFlight(agentInput.id)) {
|
|
576
|
+
return reply.status(409).send({
|
|
577
|
+
error: `Agent id "${agentInput.id}" is being deleted (or its deletion diverged); retry later or restart the server`,
|
|
336
578
|
});
|
|
337
579
|
}
|
|
338
|
-
const
|
|
339
|
-
if (
|
|
580
|
+
const workdirConflict = findConfiguredWorkdirConflict(workdirConfig, agentInput);
|
|
581
|
+
if (workdirConflict) {
|
|
340
582
|
return reply.status(409).send({
|
|
341
|
-
error: `
|
|
583
|
+
error: `Workdir "${normalize(agentInput.workdir)}" is already used by agent ` +
|
|
584
|
+
`"${workdirConflict}" on the same host; different agents must not share a directory`,
|
|
342
585
|
});
|
|
343
586
|
}
|
|
587
|
+
workdirConfig = {
|
|
588
|
+
...workdirConfig,
|
|
589
|
+
project: workdirConfig.project.map(p => p.id === projectId
|
|
590
|
+
? { ...p, agent: [...p.agent, [agentInput]] }
|
|
591
|
+
: p),
|
|
592
|
+
};
|
|
344
593
|
}
|
|
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 };
|
|
594
|
+
const merged = {
|
|
595
|
+
...app.ctx.config,
|
|
596
|
+
project: app.ctx.config.project.map(p => p.id === projectId
|
|
597
|
+
? { ...p, agent: [...p.agent, [dev, qa]] }
|
|
598
|
+
: p),
|
|
599
|
+
};
|
|
363
600
|
let validated;
|
|
364
601
|
try {
|
|
365
602
|
validated = prepareConfig(merged);
|
|
@@ -370,104 +607,278 @@ export async function projectRoutes(app) {
|
|
|
370
607
|
}
|
|
371
608
|
throw err;
|
|
372
609
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
app.ctx.bootstrapPoller?.replaceConfig(validated);
|
|
378
|
-
app.ctx.dispatchReconciler?.replaceConfig(validated);
|
|
379
|
-
creationToken = randomUUID();
|
|
610
|
+
const stateSnapshots = new Map();
|
|
611
|
+
for (const agent of [dev, qa]) {
|
|
612
|
+
stateSnapshots.set(agent.id, await app.ctx.agentStore.get(agent.id));
|
|
613
|
+
}
|
|
380
614
|
const now = new Date().toISOString();
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
615
|
+
try {
|
|
616
|
+
for (const agent of [dev, qa]) {
|
|
617
|
+
const creationToken = randomUUID();
|
|
618
|
+
await app.ctx.agentStore.set({
|
|
619
|
+
id: agent.id,
|
|
620
|
+
projectId,
|
|
621
|
+
creationToken,
|
|
622
|
+
updatedAt: now,
|
|
623
|
+
});
|
|
624
|
+
creationTokens.set(agent.id, creationToken);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
catch (err) {
|
|
628
|
+
creationTokens.clear();
|
|
629
|
+
const rollbackError = await restoreAgentStateSnapshots(app, stateSnapshots);
|
|
630
|
+
if (rollbackError) {
|
|
631
|
+
app.log.error({ err: rollbackError, agentIds: [dev.id, qa.id] }, 'POST /agents state initialization rollback failed');
|
|
632
|
+
return reply.status(500).send({
|
|
633
|
+
error: `agent state initialization failed and staged state could not be restored safely: ${rollbackError.message}`,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
return reply.status(500).send({
|
|
637
|
+
error: `agent state initialization failed; the group was not committed: ${err instanceof Error ? err.message : String(err)}`,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
try {
|
|
641
|
+
await saveConfig(app.ctx.configPath, validated);
|
|
642
|
+
}
|
|
643
|
+
catch (err) {
|
|
644
|
+
creationTokens.clear();
|
|
645
|
+
const rollbackError = await restoreAgentStateSnapshots(app, stateSnapshots);
|
|
646
|
+
if (rollbackError) {
|
|
647
|
+
app.log.error({ err: rollbackError, agentIds: [dev.id, qa.id] }, 'POST /agents config save state rollback failed');
|
|
648
|
+
return reply.status(500).send({
|
|
649
|
+
error: `config save failed and staged agent state could not be restored safely: ${rollbackError.message}`,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
return reply.status(500).send({
|
|
653
|
+
error: `failed to persist agent group config; staged agent state was restored: ${err instanceof Error ? err.message : String(err)}`,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
const switchResult = switchCommittedAgentConfig(app, validated, [dev.id, qa.id], 'POST');
|
|
657
|
+
restartRequired = switchResult.restartRequired;
|
|
658
|
+
if (switchResult.warning) {
|
|
659
|
+
creationTokens.clear();
|
|
660
|
+
warnings.push(switchResult.warning);
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
387
663
|
return null;
|
|
388
664
|
});
|
|
389
665
|
if (phase1Result)
|
|
390
666
|
return phase1Result;
|
|
391
667
|
if (reply.sent)
|
|
392
668
|
return reply;
|
|
393
|
-
|
|
394
|
-
.startBootstrapAsync(
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
}
|
|
669
|
+
for (const [agentId, creationToken] of creationTokens) {
|
|
670
|
+
void app.ctx.agentManager.startBootstrapAsync(agentId, creationToken).catch((err) => {
|
|
671
|
+
app.log.error({ err, agentId }, 'POST /agents startBootstrapAsync threw — should be impossible');
|
|
672
|
+
});
|
|
673
|
+
}
|
|
398
674
|
const storedProject = app.ctx.config.project.find(p => p.id === projectId);
|
|
399
|
-
const
|
|
675
|
+
const storedGroup = storedProject.agent.find(group => group.some(agent => agent.id === dev.id));
|
|
400
676
|
return reply.status(201).send({
|
|
401
|
-
|
|
677
|
+
agents: storedGroup,
|
|
402
678
|
runtimeStatus: 'pending',
|
|
403
|
-
restartRequired
|
|
679
|
+
restartRequired,
|
|
680
|
+
...(warnings.length ? { warnings } : {}),
|
|
404
681
|
});
|
|
405
682
|
});
|
|
406
|
-
app.
|
|
683
|
+
app.put('/projects/:projectId/agents/:agentId', async (request, reply) => {
|
|
407
684
|
if (!app.ctx.configPath) {
|
|
408
685
|
return reply.status(500).send({ error: 'No config path configured' });
|
|
409
686
|
}
|
|
410
687
|
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
|
-
}
|
|
688
|
+
const replacement = request.body;
|
|
689
|
+
if (!replacement || (replacement.role !== 'dev' && replacement.role !== 'qa')) {
|
|
690
|
+
return reply.status(400).send({ error: 'replacement role must be "dev" or "qa"' });
|
|
691
|
+
}
|
|
692
|
+
if (typeof replacement.id !== 'string' || replacement.id.trim() === '') {
|
|
693
|
+
return reply.status(400).send({ error: 'replacement agent id is required' });
|
|
694
|
+
}
|
|
695
|
+
if (replacement.id === agentId) {
|
|
696
|
+
return reply.status(400).send({ error: 'replacement agent id must differ from the current agent id' });
|
|
697
|
+
}
|
|
698
|
+
let bootstrap = null;
|
|
699
|
+
let replacementResult = null;
|
|
700
|
+
const phaseResult = await withConfigLock(async () => {
|
|
701
|
+
const project = app.ctx.config.project.find(p => p.id === projectId);
|
|
702
|
+
if (!project) {
|
|
703
|
+
return reply.status(404).send({ error: `Project "${projectId}" not found` });
|
|
440
704
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
throw new AggregateError(failures.map(f => new Error(f)), `lock rollback failed for ${failures.length} target(s)`);
|
|
705
|
+
const group = project.agent.find(candidate => candidate.some(agent => agent.id === agentId));
|
|
706
|
+
const current = group?.find(agent => agent.id === agentId);
|
|
707
|
+
if (!group || !current) {
|
|
708
|
+
return reply.status(404).send({ error: `Agent "${agentId}" not found in project "${projectId}"` });
|
|
446
709
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
710
|
+
if (replacement.role !== current.role) {
|
|
711
|
+
return reply.status(400).send({
|
|
712
|
+
error: `replacement role must remain "${current.role}" so the group keeps one dev and one qa`,
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
const existsGlobally = app.ctx.config.project.some(p => p.agent.some(candidate => candidate.some(agent => agent.id === replacement.id)));
|
|
716
|
+
if (existsGlobally) {
|
|
717
|
+
return reply.status(409).send({ error: `Agent id "${replacement.id}" already exists` });
|
|
718
|
+
}
|
|
719
|
+
if (app.ctx.agentManager.isDeletionInFlight(replacement.id)) {
|
|
720
|
+
return reply.status(409).send({
|
|
721
|
+
error: `Agent id "${replacement.id}" is being deleted (or its deletion diverged); retry later or restart the server`,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
const workdirConflict = findConfiguredWorkdirConflict(app.ctx.config, replacement, new Set([agentId]));
|
|
725
|
+
if (workdirConflict) {
|
|
726
|
+
return reply.status(409).send({
|
|
727
|
+
error: `Workdir "${normalize(replacement.workdir)}" is already used by agent ` +
|
|
728
|
+
`"${workdirConflict}" on the same host; different agents must not share a directory`,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
const nextConfig = {
|
|
732
|
+
...app.ctx.config,
|
|
733
|
+
project: app.ctx.config.project.map(p => p.id === projectId
|
|
734
|
+
? {
|
|
735
|
+
...p,
|
|
736
|
+
agent: p.agent.map(candidate => candidate.some(agent => agent.id === agentId)
|
|
737
|
+
? candidate.map(agent => agent.id === agentId ? replacement : agent)
|
|
738
|
+
: candidate),
|
|
739
|
+
}
|
|
740
|
+
: p),
|
|
741
|
+
};
|
|
742
|
+
let validated;
|
|
451
743
|
try {
|
|
452
|
-
|
|
744
|
+
validated = prepareConfig(nextConfig);
|
|
453
745
|
}
|
|
454
746
|
catch (err) {
|
|
455
|
-
|
|
747
|
+
if (err instanceof ConfigValidationError) {
|
|
748
|
+
return reply.status(400).send({ error: 'Invalid config', details: err.errors });
|
|
749
|
+
}
|
|
750
|
+
throw err;
|
|
456
751
|
}
|
|
752
|
+
const storedReplacement = validated.project
|
|
753
|
+
.find(candidate => candidate.id === projectId)
|
|
754
|
+
.agent.flat()
|
|
755
|
+
.find(agent => agent.id === replacement.id);
|
|
756
|
+
const removal = new AgentRemovalTransaction(app, [agentId], 'PUT');
|
|
457
757
|
try {
|
|
458
|
-
await
|
|
758
|
+
const deletion = await removal.claim();
|
|
759
|
+
if (!deletion.ok) {
|
|
760
|
+
switch (deletion.code) {
|
|
761
|
+
case 'active':
|
|
762
|
+
return reply.status(409).send({
|
|
763
|
+
error: `Agent "${deletion.agentId}" is still active on task ${deletion.taskId}; finish or cancel it before replacement.`,
|
|
764
|
+
});
|
|
765
|
+
case 'bootstrapping':
|
|
766
|
+
return reply.status(409).send({
|
|
767
|
+
error: `Agent "${deletion.agentId}" is bootstrapping; retry after bootstrap settles.`,
|
|
768
|
+
});
|
|
769
|
+
case 'referencing':
|
|
770
|
+
return reply.status(409).send({
|
|
771
|
+
error: `Agent "${agentId}" is referenced by open task ${deletion.taskId}; finish or cancel it before replacement.`,
|
|
772
|
+
});
|
|
773
|
+
case 'already-deleting':
|
|
774
|
+
return reply.status(409).send({
|
|
775
|
+
error: `Agent "${deletion.agentId}" deletion or replacement is already in progress.`,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
const rotation = await removal.rotateClaims();
|
|
780
|
+
if (!rotation.ok) {
|
|
781
|
+
switch (rotation.code) {
|
|
782
|
+
case 'locked':
|
|
783
|
+
case 'stale-binding':
|
|
784
|
+
return reply.status(409).send({
|
|
785
|
+
error: `Agent "${rotation.agentId}" is locked by another operation; retry replacement later.`,
|
|
786
|
+
});
|
|
787
|
+
case 'claim-changed':
|
|
788
|
+
return reply.status(409).send({
|
|
789
|
+
error: `Agent "${rotation.agentId}" lock changed; retry replacement.`,
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
try {
|
|
794
|
+
await app.ctx.agentManager.cleanupRemovedAgentRuntime([agentId]);
|
|
795
|
+
}
|
|
796
|
+
catch (err) {
|
|
797
|
+
const rollbackError = await removal.rollbackStateAndClaims();
|
|
798
|
+
if (rollbackError) {
|
|
799
|
+
app.log.error({ err: rollbackError, agentId }, 'PUT /agents replacement rollback failed');
|
|
800
|
+
return reply.status(500).send({
|
|
801
|
+
error: `runtime cleanup failed and the original agent state could not be restored safely: ${rollbackError.message}`,
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
return reply.status(502).send({
|
|
805
|
+
error: `runtime cleanup failed for ${agentId}; the original config, state, and lock were restored`,
|
|
806
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
try {
|
|
810
|
+
await saveConfig(app.ctx.configPath, validated);
|
|
811
|
+
}
|
|
812
|
+
catch (err) {
|
|
813
|
+
const rollbackError = await removal.rollbackStateAndClaims();
|
|
814
|
+
if (rollbackError) {
|
|
815
|
+
app.log.error({ err: rollbackError, agentId }, 'PUT /agents save rollback failed');
|
|
816
|
+
return reply.status(500).send({
|
|
817
|
+
error: `config save failed and the original agent state could not be restored safely: ${rollbackError.message}`,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
return reply.status(500).send({
|
|
821
|
+
error: `failed to persist replacement config; the original agent state and lock were restored: ${err instanceof Error ? err.message : String(err)}`,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
const commitResult = await removal.finalizeCommit(validated, {
|
|
825
|
+
divergenceTargets: [agentId, replacement.id],
|
|
826
|
+
beforeConfigSwitch: async (warnings) => {
|
|
827
|
+
const creationToken = randomUUID();
|
|
828
|
+
try {
|
|
829
|
+
await app.ctx.agentStore.set({
|
|
830
|
+
id: replacement.id,
|
|
831
|
+
projectId,
|
|
832
|
+
creationToken,
|
|
833
|
+
updatedAt: new Date().toISOString(),
|
|
834
|
+
});
|
|
835
|
+
bootstrap = { agentId: replacement.id, creationToken };
|
|
836
|
+
}
|
|
837
|
+
catch (err) {
|
|
838
|
+
const warning = `replacement agent ${replacement.id} state initialization failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
839
|
+
warnings.push(warning);
|
|
840
|
+
app.log.error({ err, agentId: replacement.id }, `PUT /agents ${warning}`);
|
|
841
|
+
}
|
|
842
|
+
},
|
|
843
|
+
});
|
|
844
|
+
if (commitResult.restartRequired)
|
|
845
|
+
bootstrap = null;
|
|
846
|
+
replacementResult = {
|
|
847
|
+
agent: storedReplacement,
|
|
848
|
+
replaced: agentId,
|
|
849
|
+
runtimeStatus: 'pending',
|
|
850
|
+
restartRequired: commitResult.restartRequired,
|
|
851
|
+
...(commitResult.warnings.length ? { warnings: commitResult.warnings } : {}),
|
|
852
|
+
};
|
|
853
|
+
return null;
|
|
459
854
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
: restoreError;
|
|
855
|
+
finally {
|
|
856
|
+
await removal.finish().catch((err) => {
|
|
857
|
+
app.log.error({ err, agentId }, 'PUT /agents leftover replacement cleanup failed');
|
|
858
|
+
});
|
|
465
859
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
860
|
+
});
|
|
861
|
+
if (phaseResult)
|
|
862
|
+
return phaseResult;
|
|
863
|
+
if (reply.sent)
|
|
864
|
+
return reply;
|
|
865
|
+
if (bootstrap) {
|
|
866
|
+
const { agentId: replacementId, creationToken } = bootstrap;
|
|
867
|
+
void app.ctx.agentManager.startBootstrapAsync(replacementId, creationToken).catch((err) => {
|
|
868
|
+
app.log.error({ err, agentId: replacementId }, 'PUT /agents startBootstrapAsync threw — should be impossible');
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
return reply.send(replacementResult);
|
|
872
|
+
});
|
|
873
|
+
app.delete('/projects/:projectId/agents/:agentId', async (request, reply) => {
|
|
874
|
+
if (!app.ctx.configPath) {
|
|
875
|
+
return reply.status(500).send({ error: 'No config path configured' });
|
|
876
|
+
}
|
|
877
|
+
const { projectId, agentId } = request.params;
|
|
878
|
+
let targets = [];
|
|
879
|
+
let originalConfigHash = '';
|
|
880
|
+
let validatedAfterRemove = null;
|
|
881
|
+
const removalRef = { current: null };
|
|
471
882
|
try {
|
|
472
883
|
const phase1Result = await withConfigLock(async () => {
|
|
473
884
|
const project = app.ctx.config.project.find(p => p.id === projectId);
|
|
@@ -479,8 +890,9 @@ export async function projectRoutes(app) {
|
|
|
479
890
|
return reply.status(404).send({ error: `Agent "${agentId}" not found in project "${projectId}"` });
|
|
480
891
|
}
|
|
481
892
|
targets = app.ctx.agentManager.prepareRemoveTargets(agentId).targets.slice().sort();
|
|
482
|
-
|
|
483
|
-
|
|
893
|
+
const transaction = new AgentRemovalTransaction(app, targets, 'DELETE');
|
|
894
|
+
removalRef.current = transaction;
|
|
895
|
+
const claim = await transaction.claim();
|
|
484
896
|
if (!claim.ok) {
|
|
485
897
|
switch (claim.code) {
|
|
486
898
|
case 'active':
|
|
@@ -494,7 +906,7 @@ export async function projectRoutes(app) {
|
|
|
494
906
|
});
|
|
495
907
|
case 'referencing':
|
|
496
908
|
return reply.status(409).send({
|
|
497
|
-
error: `Agent "${agentId}" is referenced by
|
|
909
|
+
error: `Agent "${agentId}" is referenced by open task ${claim.taskId}; ` +
|
|
498
910
|
`cancel or finish that task before deleting (would dangle its dev/QA/retry reference).`,
|
|
499
911
|
});
|
|
500
912
|
case 'already-deleting':
|
|
@@ -503,49 +915,23 @@ export async function projectRoutes(app) {
|
|
|
503
915
|
});
|
|
504
916
|
}
|
|
505
917
|
}
|
|
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();
|
|
918
|
+
const rotation = await transaction.rotateClaims();
|
|
919
|
+
if (!rotation.ok) {
|
|
920
|
+
switch (rotation.code) {
|
|
921
|
+
case 'locked':
|
|
516
922
|
return reply.status(409).send({
|
|
517
|
-
error: `Agent "${
|
|
923
|
+
error: `Agent "${rotation.agentId}" is currently locked by another op; please retry later`,
|
|
924
|
+
});
|
|
925
|
+
case 'stale-binding':
|
|
926
|
+
return reply.status(409).send({
|
|
927
|
+
error: `Agent "${rotation.agentId}" has a stale task binding whose exclusive lock is owned by another ` +
|
|
928
|
+
`operation; retry after it finishes.`,
|
|
929
|
+
});
|
|
930
|
+
case 'claim-changed':
|
|
931
|
+
return reply.status(409).send({
|
|
932
|
+
error: `Agent "${rotation.agentId}" lock changed while claiming it for deletion; please retry.`,
|
|
518
933
|
});
|
|
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
934
|
}
|
|
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
935
|
}
|
|
550
936
|
originalConfigHash = configHash(app.ctx.config);
|
|
551
937
|
const removeRequest = omitAgentsFromProject(app.ctx.config, projectId, targets);
|
|
@@ -553,7 +939,9 @@ export async function projectRoutes(app) {
|
|
|
553
939
|
validatedAfterRemove = prepareConfig(removeRequest);
|
|
554
940
|
}
|
|
555
941
|
catch (err) {
|
|
556
|
-
await
|
|
942
|
+
const rollbackError = await transaction.rollbackClaimsForRetry();
|
|
943
|
+
if (rollbackError)
|
|
944
|
+
throw rollbackError;
|
|
557
945
|
if (err instanceof ConfigValidationError) {
|
|
558
946
|
return reply.status(400).send({ error: 'Invalid config', details: err.errors });
|
|
559
947
|
}
|
|
@@ -571,9 +959,9 @@ export async function projectRoutes(app) {
|
|
|
571
959
|
catch (err) {
|
|
572
960
|
const failures = err instanceof CleanupFailedError
|
|
573
961
|
? err.failures
|
|
574
|
-
: [{ agentId
|
|
962
|
+
: [{ agentId, step: 'runtime.cleanup', error: err }];
|
|
575
963
|
app.log.error({ failures, targets }, 'DELETE /agents cleanupRemovedAgentRuntime failed; rolling back state and locks');
|
|
576
|
-
const rollbackError = await withConfigLock(
|
|
964
|
+
const rollbackError = await withConfigLock(() => removalRef.current.rollbackStateAndClaims());
|
|
577
965
|
if (rollbackError) {
|
|
578
966
|
app.log.error({ err: rollbackError, targets }, 'DELETE /agents rollback failed');
|
|
579
967
|
return reply.status(500).send({
|
|
@@ -583,10 +971,14 @@ export async function projectRoutes(app) {
|
|
|
583
971
|
return reply.status(502).send({
|
|
584
972
|
error: `runtime cleanup failed for ${targets.join(',')}; agent state and exact task locks were restored. ` +
|
|
585
973
|
`Fix the underlying SSH / tmux / Workdir issue and retry DELETE.`,
|
|
586
|
-
failures: failures.map(f => ({
|
|
587
|
-
|
|
974
|
+
failures: failures.map(f => ({
|
|
975
|
+
agentId: f.agentId,
|
|
976
|
+
step: f.step,
|
|
977
|
+
error: f.error instanceof Error ? f.error.message : String(f.error),
|
|
978
|
+
})),
|
|
588
979
|
});
|
|
589
980
|
}
|
|
981
|
+
const transaction = removalRef.current;
|
|
590
982
|
const phase3Result = await withConfigLock(async () => {
|
|
591
983
|
const currentHash = configHash(app.ctx.config);
|
|
592
984
|
const rollbackBase = currentHash === originalConfigHash
|
|
@@ -597,7 +989,7 @@ export async function projectRoutes(app) {
|
|
|
597
989
|
validated = prepareConfig(rollbackBase);
|
|
598
990
|
}
|
|
599
991
|
catch (err) {
|
|
600
|
-
const rollbackError = await
|
|
992
|
+
const rollbackError = await transaction.rollbackStateAndClaims();
|
|
601
993
|
if (rollbackError) {
|
|
602
994
|
app.log.error({ err: rollbackError, targets }, 'DELETE /agents rollback after config validation failed');
|
|
603
995
|
return reply.status(500).send({
|
|
@@ -609,106 +1001,36 @@ export async function projectRoutes(app) {
|
|
|
609
1001
|
}
|
|
610
1002
|
throw err;
|
|
611
1003
|
}
|
|
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
1004
|
try {
|
|
625
1005
|
await saveConfig(app.ctx.configPath, validated);
|
|
626
1006
|
}
|
|
627
1007
|
catch (err) {
|
|
628
1008
|
app.log.error({ err, targets }, 'DELETE /agents Phase 3 saveConfig failed; releasing acquired locks (no state was deleted yet)');
|
|
629
|
-
const
|
|
630
|
-
if (
|
|
1009
|
+
const rollbackError = await transaction.rollbackClaimsForRetry();
|
|
1010
|
+
if (rollbackError) {
|
|
631
1011
|
return reply.status(500).send({
|
|
632
|
-
error: `failed to persist config and roll the deletion locks back safely: ${
|
|
1012
|
+
error: `failed to persist config and roll the deletion locks back safely: ${rollbackError.message}`,
|
|
633
1013
|
});
|
|
634
1014
|
}
|
|
635
1015
|
return reply.status(500).send({
|
|
636
1016
|
error: 'failed to persist config after runtime cleanup; this attempt\'s locks were released and agent state left intact',
|
|
637
1017
|
});
|
|
638
1018
|
}
|
|
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 } : {}) });
|
|
1019
|
+
const commitResult = await transaction.finalizeCommit(validated);
|
|
1020
|
+
return reply.send({
|
|
1021
|
+
removed: targets,
|
|
1022
|
+
restartRequired: commitResult.restartRequired,
|
|
1023
|
+
...(commitResult.warnings.length ? { warnings: commitResult.warnings } : {}),
|
|
1024
|
+
});
|
|
698
1025
|
});
|
|
699
1026
|
return phase3Result;
|
|
700
1027
|
}
|
|
701
1028
|
finally {
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
await cleanup.catch((err) => {
|
|
706
|
-
app.log.error({ err, committed }, 'DELETE /agents: leftover deletion-claim cleanup failed');
|
|
1029
|
+
if (removalRef.current) {
|
|
1030
|
+
await removalRef.current.finish().catch((err) => {
|
|
1031
|
+
app.log.error({ err }, 'DELETE /agents leftover deletion-claim cleanup failed');
|
|
707
1032
|
});
|
|
708
1033
|
}
|
|
709
|
-
if (claimedTargets.length > 0) {
|
|
710
|
-
app.ctx.agentManager.releaseDeletionClaim(claimedTargets);
|
|
711
|
-
}
|
|
712
1034
|
}
|
|
713
1035
|
});
|
|
714
1036
|
app.post('/projects/:projectId/agents/:agentId/resume', async (request, reply) => {
|
|
@@ -833,7 +1155,6 @@ export async function projectRoutes(app) {
|
|
|
833
1155
|
try {
|
|
834
1156
|
const result = await app.ctx.agentManager.ensureSession(agentId, 'runtime', { expectedGeneration: genAtEntry });
|
|
835
1157
|
const now = new Date().toISOString();
|
|
836
|
-
// Generation-gated: a DELETE→recreate during ensureSession leaves a NEW state, so an existing-only check would bind the stale paneId to the wrong incarnation.
|
|
837
1158
|
const paneCommit = await app.ctx.agentStore.update(agentId, (existing) => {
|
|
838
1159
|
if (!existing
|
|
839
1160
|
|| app.ctx.agentManager.isDeletionInFlight(agentId)
|
|
@@ -842,7 +1163,6 @@ export async function projectRoutes(app) {
|
|
|
842
1163
|
}
|
|
843
1164
|
return { ...existing, paneId: result.paneId, updatedAt: now };
|
|
844
1165
|
});
|
|
845
|
-
// No-op ⇒ a DELETE→recreate raced ensureSession; fail closed rather than run stale retry cleanup against the new incarnation.
|
|
846
1166
|
if (paneCommit !== 'committed') {
|
|
847
1167
|
if (maintenanceToken && acquiredMaintenanceLock) {
|
|
848
1168
|
await app.ctx.lockManager.releaseIfOwner(agentId, maintenanceLockOwner, maintenanceToken);
|
|
@@ -878,7 +1198,7 @@ export async function projectRoutes(app) {
|
|
|
878
1198
|
return reply.send({ ok: true, agentId });
|
|
879
1199
|
});
|
|
880
1200
|
}
|
|
881
|
-
function findConfiguredWorkdirConflict(config, candidate) {
|
|
1201
|
+
function findConfiguredWorkdirConflict(config, candidate, ignoredAgentIds = new Set()) {
|
|
882
1202
|
if (typeof candidate.workdir !== 'string'
|
|
883
1203
|
|| candidate.workdir.trim() === ''
|
|
884
1204
|
|| !isAbsolute(candidate.workdir)
|
|
@@ -890,14 +1210,6 @@ function findConfiguredWorkdirConflict(config, candidate) {
|
|
|
890
1210
|
return null;
|
|
891
1211
|
const candidatePath = normalize(candidate.workdir);
|
|
892
1212
|
const configured = [];
|
|
893
|
-
if (config.root) {
|
|
894
|
-
configured.push({
|
|
895
|
-
id: ROOT_AGENT_ID,
|
|
896
|
-
mode: config.root.mode,
|
|
897
|
-
host: resolveAgentHost(config.host, config.root.host),
|
|
898
|
-
workdir: config.root.workdir,
|
|
899
|
-
});
|
|
900
|
-
}
|
|
901
1213
|
for (const project of config.project) {
|
|
902
1214
|
for (const existing of project.agent.flat()) {
|
|
903
1215
|
if (!existing.workdir)
|
|
@@ -911,6 +1223,8 @@ function findConfiguredWorkdirConflict(config, candidate) {
|
|
|
911
1223
|
}
|
|
912
1224
|
}
|
|
913
1225
|
for (const existing of configured) {
|
|
1226
|
+
if (ignoredAgentIds.has(existing.id))
|
|
1227
|
+
continue;
|
|
914
1228
|
if (normalize(existing.workdir) === candidatePath
|
|
915
1229
|
&& mayShareHostAccount(existing.mode, existing.host, candidate.mode, resolvedCandidateHost)) {
|
|
916
1230
|
return existing.id;
|
|
@@ -1000,7 +1314,6 @@ async function pendingCleanupAfterReplReady(app, agentId, takeover, maintenance)
|
|
|
1000
1314
|
}
|
|
1001
1315
|
catch (err) {
|
|
1002
1316
|
app.log.error({ err, agentId, taskId }, 'restart/retry task prompt redispatch failed');
|
|
1003
|
-
// Bound to the pre-restart task tuple: a successor pass rotated mid-replay must not be held.
|
|
1004
1317
|
const held = await app.ctx.agentManager.holdReplayFailureIfCurrent(agentId, task, 'restart-redispatch-failed', `REPL restarted but the active task prompt could not be restored: ${err instanceof Error ? err.message : String(err)}`, {
|
|
1005
1318
|
phase: state?.status === 'awaiting_human' ? state.awaitingPhase : undefined,
|
|
1006
1319
|
since: state?.status === 'awaiting_human' ? state.awaitingSince : undefined,
|
|
@@ -1012,7 +1325,6 @@ async function pendingCleanupAfterReplReady(app, agentId, takeover, maintenance)
|
|
|
1012
1325
|
return;
|
|
1013
1326
|
}
|
|
1014
1327
|
}
|
|
1015
|
-
// Both fences ride inside the release itself: a hold or pass rotation landing after any pre-read survives.
|
|
1016
1328
|
await app.ctx.agentManager.markAgentWaiting(agentId, taskId, {
|
|
1017
1329
|
allowAwaitingHuman: true,
|
|
1018
1330
|
clearAwaitingHuman: true,
|
|
@@ -1024,49 +1336,4 @@ async function pendingCleanupAfterReplReady(app, agentId, takeover, maintenance)
|
|
|
1024
1336
|
},
|
|
1025
1337
|
});
|
|
1026
1338
|
}
|
|
1027
|
-
async function rollbackPerTargetState(app, targets, originalStates) {
|
|
1028
|
-
const failures = [];
|
|
1029
|
-
for (const id of targets) {
|
|
1030
|
-
const original = originalStates.get(id) ?? null;
|
|
1031
|
-
if (!original) {
|
|
1032
|
-
try {
|
|
1033
|
-
await app.ctx.agentStore.delete(id);
|
|
1034
|
-
}
|
|
1035
|
-
catch (err) {
|
|
1036
|
-
failures.push(new Error(`delete transient state for ${id}: ${err instanceof Error ? err.message : String(err)}`));
|
|
1037
|
-
}
|
|
1038
|
-
continue;
|
|
1039
|
-
}
|
|
1040
|
-
try {
|
|
1041
|
-
let lockToken = original.lockToken;
|
|
1042
|
-
if (original.taskId) {
|
|
1043
|
-
const claim = await app.ctx.lockManager.claimOf(id);
|
|
1044
|
-
if (claim?.taskId === original.taskId) {
|
|
1045
|
-
lockToken = claim.token;
|
|
1046
|
-
}
|
|
1047
|
-
else if (claim === null) {
|
|
1048
|
-
lockToken = await app.ctx.lockManager.acquire(id, original.taskId) ?? undefined;
|
|
1049
|
-
}
|
|
1050
|
-
else {
|
|
1051
|
-
throw new Error(`lock is owned by ${claim.taskId}, expected ${original.taskId}`);
|
|
1052
|
-
}
|
|
1053
|
-
if (!lockToken || !(await app.ctx.lockManager.isOwner(id, original.taskId, lockToken))) {
|
|
1054
|
-
throw new Error(`could not restore exact task lock for ${original.taskId}`);
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
await app.ctx.agentStore.set({
|
|
1058
|
-
...original,
|
|
1059
|
-
...(original.taskId ? { lockToken } : { lockToken: undefined }),
|
|
1060
|
-
paneId: undefined,
|
|
1061
|
-
updatedAt: new Date().toISOString(),
|
|
1062
|
-
});
|
|
1063
|
-
}
|
|
1064
|
-
catch (err) {
|
|
1065
|
-
failures.push(new Error(`restore state for ${id}: ${err instanceof Error ? err.message : String(err)}`));
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
if (failures.length > 0) {
|
|
1069
|
-
throw new AggregateError(failures, `Failed to roll back ${failures.length} agent deletion state(s)`);
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
1339
|
//# sourceMappingURL=projects.js.map
|