@team-agent/installer 0.5.50 → 0.5.52
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/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +5 -3
- package/crates/team-agent/src/cli/emit.rs +2 -1
- package/crates/team-agent/src/cli/mod.rs +32 -14
- package/crates/team-agent/src/cli/named_address.rs +46 -115
- package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
- package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
- package/crates/team-agent/src/cli/send/persist.rs +154 -0
- package/crates/team-agent/src/cli/send/presentation.rs +333 -0
- package/crates/team-agent/src/cli/send/resolve.rs +361 -0
- package/crates/team-agent/src/cli/send.rs +44 -1169
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
- package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
- package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
- package/crates/team-agent/src/cli/status_port/format.rs +145 -0
- package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
- package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
- package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
- package/crates/team-agent/src/cli/status_port/store.rs +412 -0
- package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
- package/crates/team-agent/src/cli/status_port.rs +47 -1548
- package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +1 -0
- package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
- package/crates/team-agent/src/coordinator/tick.rs +37 -0
- package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
- package/crates/team-agent/src/db/message_store.rs +154 -44
- package/crates/team-agent/src/event_log.rs +73 -0
- package/crates/team-agent/src/leader/lease.rs +202 -16
- package/crates/team-agent/src/leader/start.rs +28 -4
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +303 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
- package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
- package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
- package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
- package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
- package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
- package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
- package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
- package/crates/team-agent/src/lifecycle/restart/agent.rs +63 -29
- package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +167 -27
- package/crates/team-agent/src/lifecycle/restart/remove.rs +503 -77
- package/crates/team-agent/src/lifecycle/restart.rs +26 -2
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +906 -17
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +67 -2
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
- package/crates/team-agent/src/mcp_server/mod.rs +1 -1
- package/crates/team-agent/src/mcp_server/tests/send.rs +6 -10
- package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
- package/crates/team-agent/src/mcp_server/tests.rs +11 -4
- package/crates/team-agent/src/mcp_server/tools.rs +14 -5
- package/crates/team-agent/src/messaging/activity.rs +4 -2
- package/crates/team-agent/src/messaging/address.rs +86 -0
- package/crates/team-agent/src/messaging/delivery.rs +288 -183
- package/crates/team-agent/src/messaging/helpers.rs +17 -13
- package/crates/team-agent/src/messaging/leader_channel.rs +171 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
- package/crates/team-agent/src/messaging/mod.rs +15 -2
- package/crates/team-agent/src/messaging/persist.rs +309 -0
- package/crates/team-agent/src/messaging/results.rs +16 -24
- package/crates/team-agent/src/messaging/scheduler.rs +4 -2
- package/crates/team-agent/src/messaging/selftest.rs +19 -12
- package/crates/team-agent/src/messaging/send.rs +101 -49
- package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +2 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
- package/crates/team-agent/src/messaging/watchers.rs +13 -3
- package/crates/team-agent/src/redaction.rs +72 -2
- package/crates/team-agent/src/state/persist.rs +284 -21
- package/crates/team-agent/src/state/projection.rs +245 -30
- package/crates/team-agent/src/state/repository/intent.rs +13 -0
- package/crates/team-agent/src/state/repository/tests.rs +47 -0
- package/crates/team-agent/src/state/repository.rs +94 -27
- package/crates/team-agent/src/state/selector.rs +9 -18
- package/package.json +4 -4
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
use super::agent::{
|
|
2
|
-
resolve_team_scoped_state_or_refuse, start_agent_at_paths, stop_agent_at_paths,
|
|
3
|
-
};
|
|
1
|
+
use super::agent::{resolve_team_scoped_state_or_refuse, start_agent_at_paths};
|
|
4
2
|
use super::common::*;
|
|
5
3
|
use super::team_state::write_team_state;
|
|
6
4
|
use super::*;
|
|
@@ -17,13 +15,15 @@ pub fn remove_agent(
|
|
|
17
15
|
team: Option<&str>,
|
|
18
16
|
) -> Result<RemoveAgentOutcome, LifecycleError> {
|
|
19
17
|
let paths = lifecycle_paths(workspace, team)?;
|
|
18
|
+
let canonical_team = paths.canonical_team(team).map(str::to_string);
|
|
19
|
+
let team = canonical_team.as_deref();
|
|
20
20
|
let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
|
|
21
21
|
workspace: &paths.run_workspace,
|
|
22
22
|
operation: "remove-agent",
|
|
23
23
|
team,
|
|
24
24
|
agent_id: Some(agent_id),
|
|
25
25
|
})?;
|
|
26
|
-
let transport =
|
|
26
|
+
let transport = paths.tmux_backend()?;
|
|
27
27
|
remove_agent_at_paths(
|
|
28
28
|
&paths.run_workspace,
|
|
29
29
|
&paths.spec_workspace,
|
|
@@ -44,6 +44,8 @@ pub fn remove_agent_with_transport(
|
|
|
44
44
|
transport: &dyn crate::transport::Transport,
|
|
45
45
|
) -> Result<RemoveAgentOutcome, LifecycleError> {
|
|
46
46
|
let paths = lifecycle_paths(workspace, team)?;
|
|
47
|
+
let canonical_team = paths.canonical_team(team).map(str::to_string);
|
|
48
|
+
let team = canonical_team.as_deref();
|
|
47
49
|
let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
|
|
48
50
|
workspace: &paths.run_workspace,
|
|
49
51
|
operation: "remove-agent",
|
|
@@ -61,13 +63,169 @@ pub fn remove_agent_with_transport(
|
|
|
61
63
|
)
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
pub(crate) fn remove_agent_with_transport_locked(
|
|
67
|
+
workspace: &Path,
|
|
68
|
+
agent_id: &AgentId,
|
|
69
|
+
from_spec: bool,
|
|
70
|
+
force: bool,
|
|
71
|
+
team: Option<&str>,
|
|
72
|
+
transport: &dyn crate::transport::Transport,
|
|
73
|
+
) -> Result<RemoveAgentOutcome, LifecycleError> {
|
|
74
|
+
let paths = lifecycle_paths(workspace, team)?;
|
|
75
|
+
let canonical_team = paths.canonical_team(team).map(str::to_string);
|
|
76
|
+
let team = canonical_team.as_deref();
|
|
77
|
+
remove_agent_at_paths(
|
|
78
|
+
&paths.run_workspace,
|
|
79
|
+
&paths.spec_workspace,
|
|
80
|
+
agent_id,
|
|
81
|
+
from_spec,
|
|
82
|
+
force,
|
|
83
|
+
team,
|
|
84
|
+
transport,
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
pub(crate) struct ForceRecreateSnapshot {
|
|
89
|
+
rollback: RemoveRollback,
|
|
90
|
+
run_workspace: std::path::PathBuf,
|
|
91
|
+
spec_workspace: std::path::PathBuf,
|
|
92
|
+
before_physical: Option<crate::transport::PaneInfo>,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
impl ForceRecreateSnapshot {
|
|
96
|
+
pub(crate) fn capture(
|
|
97
|
+
workspace: &Path,
|
|
98
|
+
agent_id: &AgentId,
|
|
99
|
+
team: Option<&str>,
|
|
100
|
+
transport: &dyn crate::transport::Transport,
|
|
101
|
+
) -> Result<Self, LifecycleError> {
|
|
102
|
+
let paths = lifecycle_paths(workspace, team)?;
|
|
103
|
+
let canonical_team = paths.canonical_team(team).map(str::to_string);
|
|
104
|
+
let team = canonical_team.as_deref();
|
|
105
|
+
let seat = resolve_seat(
|
|
106
|
+
&paths.run_workspace,
|
|
107
|
+
&paths.spec_workspace,
|
|
108
|
+
agent_id,
|
|
109
|
+
team,
|
|
110
|
+
transport,
|
|
111
|
+
)?;
|
|
112
|
+
let mut rollback = RemoveRollback::capture(
|
|
113
|
+
&paths.run_workspace,
|
|
114
|
+
&paths.spec_workspace,
|
|
115
|
+
&seat.spec,
|
|
116
|
+
&seat.state,
|
|
117
|
+
&seat.team_key,
|
|
118
|
+
agent_id,
|
|
119
|
+
)?;
|
|
120
|
+
rollback.restore_running = seat.physical.is_some();
|
|
121
|
+
Ok(Self {
|
|
122
|
+
rollback,
|
|
123
|
+
run_workspace: paths.run_workspace,
|
|
124
|
+
spec_workspace: paths.spec_workspace,
|
|
125
|
+
before_physical: seat.physical,
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
pub(crate) fn restore(
|
|
130
|
+
&self,
|
|
131
|
+
team: Option<&str>,
|
|
132
|
+
transport: &dyn crate::transport::Transport,
|
|
133
|
+
) -> Vec<String> {
|
|
134
|
+
self.rollback
|
|
135
|
+
.restore(&self.run_workspace, &self.spec_workspace, team, transport)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// The old pane has already been consumed. Any exact pane now resolved for
|
|
139
|
+
/// this seat belongs to this force-recreate transaction and must be removed
|
|
140
|
+
/// before the logical snapshot is restored, otherwise rollback can leave a
|
|
141
|
+
/// duplicate worker behind.
|
|
142
|
+
pub(crate) fn restore_after_consumption(
|
|
143
|
+
&self,
|
|
144
|
+
transport: &dyn crate::transport::Transport,
|
|
145
|
+
) -> Vec<String> {
|
|
146
|
+
let mut errors = Vec::new();
|
|
147
|
+
match resolve_seat(
|
|
148
|
+
&self.run_workspace,
|
|
149
|
+
&self.spec_workspace,
|
|
150
|
+
&self.rollback.agent_id,
|
|
151
|
+
Some(self.rollback.team_key.as_str()),
|
|
152
|
+
transport,
|
|
153
|
+
) {
|
|
154
|
+
Ok(after) => {
|
|
155
|
+
if let Some(pane) = after.physical {
|
|
156
|
+
if let Err(error) = transport.kill_pane(&pane.pane_id) {
|
|
157
|
+
errors.push(format!(
|
|
158
|
+
"transaction_pane:{}:{error}",
|
|
159
|
+
pane.pane_id.as_str()
|
|
160
|
+
));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
Err(error) => errors.push(format!("transaction_resolve:{error}")),
|
|
165
|
+
}
|
|
166
|
+
errors.extend(self.rollback.restore(
|
|
167
|
+
&self.run_workspace,
|
|
168
|
+
&self.spec_workspace,
|
|
169
|
+
Some(self.rollback.team_key.as_str()),
|
|
170
|
+
transport,
|
|
171
|
+
));
|
|
172
|
+
if errors.is_empty() {
|
|
173
|
+
if let Some(before) = &self.before_physical {
|
|
174
|
+
match resolve_seat(
|
|
175
|
+
&self.run_workspace,
|
|
176
|
+
&self.spec_workspace,
|
|
177
|
+
&self.rollback.agent_id,
|
|
178
|
+
Some(self.rollback.team_key.as_str()),
|
|
179
|
+
transport,
|
|
180
|
+
) {
|
|
181
|
+
Ok(after)
|
|
182
|
+
if after.physical.as_ref().is_some_and(|pane| {
|
|
183
|
+
pane.session == before.session && pane.window_name == before.window_name
|
|
184
|
+
}) => {}
|
|
185
|
+
Ok(after) => errors.push(format!(
|
|
186
|
+
"worker_restore:before physical tuple not restored: {:?}",
|
|
187
|
+
after.consistency
|
|
188
|
+
)),
|
|
189
|
+
Err(error) => errors.push(format!("worker_restore_resolve:{error}")),
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
errors
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
pub(crate) fn require_coherent(
|
|
197
|
+
&self,
|
|
198
|
+
agent_id: &AgentId,
|
|
199
|
+
team: Option<&str>,
|
|
200
|
+
transport: &dyn crate::transport::Transport,
|
|
201
|
+
) -> Result<(), LifecycleError> {
|
|
202
|
+
let after = resolve_seat(
|
|
203
|
+
&self.run_workspace,
|
|
204
|
+
&self.spec_workspace,
|
|
205
|
+
agent_id,
|
|
206
|
+
team,
|
|
207
|
+
transport,
|
|
208
|
+
)?;
|
|
209
|
+
if after.consistency == SeatConsistency::Coherent {
|
|
210
|
+
Ok(())
|
|
211
|
+
} else {
|
|
212
|
+
Err(LifecycleError::StatePersist(format!(
|
|
213
|
+
"force-recreate post-resolve for {agent_id} is {:?}",
|
|
214
|
+
after.consistency
|
|
215
|
+
)))
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
64
220
|
pub fn remove_agent_flag_requirements(
|
|
65
221
|
workspace: &Path,
|
|
66
222
|
agent_id: &AgentId,
|
|
67
223
|
team: Option<&str>,
|
|
68
224
|
) -> Result<RemoveAgentFlagRequirements, LifecycleError> {
|
|
69
225
|
let paths = lifecycle_paths(workspace, team)?;
|
|
70
|
-
let
|
|
226
|
+
let canonical_team = paths.canonical_team(team).map(str::to_string);
|
|
227
|
+
let team = canonical_team.as_deref();
|
|
228
|
+
let transport = paths.tmux_backend()?;
|
|
71
229
|
Ok(remove_agent_preflight(
|
|
72
230
|
&paths.run_workspace,
|
|
73
231
|
&paths.spec_workspace,
|
|
@@ -79,11 +237,146 @@ pub fn remove_agent_flag_requirements(
|
|
|
79
237
|
}
|
|
80
238
|
|
|
81
239
|
struct RemoveAgentPreflight {
|
|
82
|
-
|
|
83
|
-
spec: YamlValue,
|
|
240
|
+
seat: ResolvedSeat,
|
|
84
241
|
requirements: RemoveAgentFlagRequirements,
|
|
85
242
|
}
|
|
86
243
|
|
|
244
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
245
|
+
pub(super) enum SeatConsistency {
|
|
246
|
+
Absent,
|
|
247
|
+
Coherent,
|
|
248
|
+
StateOnly,
|
|
249
|
+
SpecOnly,
|
|
250
|
+
PhysicalOnly,
|
|
251
|
+
Mixed,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
pub(super) struct ResolvedSeat {
|
|
255
|
+
pub(super) state: serde_json::Value,
|
|
256
|
+
pub(super) spec: YamlValue,
|
|
257
|
+
pub(super) team_key: String,
|
|
258
|
+
pub(super) session: crate::transport::SessionName,
|
|
259
|
+
pub(super) window: String,
|
|
260
|
+
pub(super) physical: Option<crate::transport::PaneInfo>,
|
|
261
|
+
pub(super) state_present: bool,
|
|
262
|
+
pub(super) spec_present: bool,
|
|
263
|
+
pub(super) consistency: SeatConsistency,
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/// Resolve one seat from the selected team's desired, persisted and physical
|
|
267
|
+
/// sources. The transport is already bound to the selected team's endpoint;
|
|
268
|
+
/// physical identity is then narrowed to exactly one `(session, window, pane)`
|
|
269
|
+
/// tuple. A global window-name match is never accepted as identity.
|
|
270
|
+
pub(super) fn resolve_seat(
|
|
271
|
+
workspace: &Path,
|
|
272
|
+
spec_workspace: &Path,
|
|
273
|
+
agent_id: &AgentId,
|
|
274
|
+
team: Option<&str>,
|
|
275
|
+
transport: &dyn crate::transport::Transport,
|
|
276
|
+
) -> Result<ResolvedSeat, LifecycleError> {
|
|
277
|
+
let state = resolve_team_scoped_state_or_refuse(workspace, team)?;
|
|
278
|
+
crate::lifecycle::launch::ensure_owner_allowed_for_state(&state, Some(agent_id))?;
|
|
279
|
+
let spec = load_team_spec(spec_workspace)?;
|
|
280
|
+
let team_key = state
|
|
281
|
+
.get("active_team_key")
|
|
282
|
+
.and_then(serde_json::Value::as_str)
|
|
283
|
+
.filter(|key| !key.is_empty())
|
|
284
|
+
.map(str::to_string)
|
|
285
|
+
.unwrap_or_else(|| crate::state::projection::team_state_key(&state));
|
|
286
|
+
let state_present = state
|
|
287
|
+
.get("agents")
|
|
288
|
+
.and_then(|agents| agents.get(agent_id.as_str()))
|
|
289
|
+
.is_some();
|
|
290
|
+
let spec_present = find_spec_agent(&spec, agent_id).is_some();
|
|
291
|
+
let session = state_session_name_from_spec(&state, &spec);
|
|
292
|
+
let window = state
|
|
293
|
+
.get("agents")
|
|
294
|
+
.and_then(|agents| agents.get(agent_id.as_str()))
|
|
295
|
+
.and_then(|agent| agent.get("window"))
|
|
296
|
+
.and_then(serde_json::Value::as_str)
|
|
297
|
+
.filter(|window| !window.is_empty())
|
|
298
|
+
.unwrap_or_else(|| agent_id.as_str())
|
|
299
|
+
.to_string();
|
|
300
|
+
let stored_pane_id = state
|
|
301
|
+
.get("agents")
|
|
302
|
+
.and_then(|agents| agents.get(agent_id.as_str()))
|
|
303
|
+
.and_then(|agent| agent.get("pane_id"))
|
|
304
|
+
.and_then(serde_json::Value::as_str)
|
|
305
|
+
.filter(|pane| !pane.is_empty())
|
|
306
|
+
.map(crate::transport::PaneId::new);
|
|
307
|
+
let mut physical = transport
|
|
308
|
+
.list_targets()
|
|
309
|
+
.map_err(|error| LifecycleError::Transport(format!("resolve seat targets: {error}")))?
|
|
310
|
+
.into_iter()
|
|
311
|
+
.filter(|pane| {
|
|
312
|
+
pane.session == session
|
|
313
|
+
&& pane
|
|
314
|
+
.window_name
|
|
315
|
+
.as_ref()
|
|
316
|
+
.is_some_and(|name| name.as_str() == window)
|
|
317
|
+
&& stored_pane_id
|
|
318
|
+
.as_ref()
|
|
319
|
+
.is_none_or(|stored| pane.pane_id == *stored)
|
|
320
|
+
});
|
|
321
|
+
let mut first = physical.next();
|
|
322
|
+
if physical.next().is_some() {
|
|
323
|
+
return Err(LifecycleError::RequirementUnmet(format!(
|
|
324
|
+
"seat identity ambiguous for {agent_id}: session={} window={window}",
|
|
325
|
+
session.as_str()
|
|
326
|
+
)));
|
|
327
|
+
}
|
|
328
|
+
// Some backends can positively probe an exact pane id even when their
|
|
329
|
+
// global target snapshot is temporarily empty (for example an attached
|
|
330
|
+
// explicit tmux socket during reset). The persisted tuple is scoped by the
|
|
331
|
+
// selected transport endpoint and session; a positive exact-pane probe is
|
|
332
|
+
// therefore safe, unlike a reverse window-name scan.
|
|
333
|
+
if first.is_none() {
|
|
334
|
+
if let Some(pane_id) = stored_pane_id
|
|
335
|
+
.as_ref()
|
|
336
|
+
.filter(|pane| transport.has_pane(pane).ok().flatten() == Some(true))
|
|
337
|
+
{
|
|
338
|
+
first = Some(crate::transport::PaneInfo {
|
|
339
|
+
pane_id: pane_id.clone(),
|
|
340
|
+
session: session.clone(),
|
|
341
|
+
window_index: None,
|
|
342
|
+
window_name: Some(crate::transport::WindowName::new(&window)),
|
|
343
|
+
pane_index: None,
|
|
344
|
+
tty: None,
|
|
345
|
+
current_command: None,
|
|
346
|
+
current_path: None,
|
|
347
|
+
active: true,
|
|
348
|
+
pane_pid: state
|
|
349
|
+
.get("agents")
|
|
350
|
+
.and_then(|agents| agents.get(agent_id.as_str()))
|
|
351
|
+
.and_then(|agent| agent.get("pane_pid"))
|
|
352
|
+
.and_then(serde_json::Value::as_u64)
|
|
353
|
+
.and_then(|pid| u32::try_from(pid).ok()),
|
|
354
|
+
leader_env: std::collections::BTreeMap::new(),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
let physical_present = first.is_some();
|
|
359
|
+
let consistency = match (spec_present, state_present, physical_present) {
|
|
360
|
+
(false, false, false) => SeatConsistency::Absent,
|
|
361
|
+
(true, true, true) => SeatConsistency::Coherent,
|
|
362
|
+
(false, true, false) => SeatConsistency::StateOnly,
|
|
363
|
+
(true, false, false) => SeatConsistency::SpecOnly,
|
|
364
|
+
(false, false, true) => SeatConsistency::PhysicalOnly,
|
|
365
|
+
_ => SeatConsistency::Mixed,
|
|
366
|
+
};
|
|
367
|
+
Ok(ResolvedSeat {
|
|
368
|
+
state,
|
|
369
|
+
spec,
|
|
370
|
+
team_key,
|
|
371
|
+
session,
|
|
372
|
+
window,
|
|
373
|
+
physical: first,
|
|
374
|
+
state_present,
|
|
375
|
+
spec_present,
|
|
376
|
+
consistency,
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
|
|
87
380
|
fn remove_agent_preflight(
|
|
88
381
|
workspace: &Path,
|
|
89
382
|
spec_workspace: &Path,
|
|
@@ -95,25 +388,25 @@ fn remove_agent_preflight(
|
|
|
95
388
|
// team_target_unresolved refusal before the owner gate), THEN the owner gate, THEN load_spec +
|
|
96
389
|
// _find_worker (unknown-worker raise). Mirror the stop/reset wiring so remove is byte-symmetric:
|
|
97
390
|
// the team-scoped projection (not a raw load) drives the dynamic/running/from_spec decisions.
|
|
98
|
-
let
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
let
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
let
|
|
106
|
-
|
|
391
|
+
let seat = resolve_seat(workspace, spec_workspace, agent_id, team, transport)?;
|
|
392
|
+
let spec_agent = find_spec_agent(&seat.spec, agent_id);
|
|
393
|
+
// A persisted-only seat is still known and force-removable.
|
|
394
|
+
let dynamic_agent =
|
|
395
|
+
spec_agent.is_none_or(|agent| is_dynamic_agent(&seat.state, agent, agent_id));
|
|
396
|
+
let force_required =
|
|
397
|
+
seat.physical.is_some() || agent_is_running(&seat.state, agent_id, transport);
|
|
398
|
+
let has_session = seat
|
|
399
|
+
.state
|
|
107
400
|
.get("session_name")
|
|
108
401
|
.and_then(|v| v.as_str())
|
|
109
402
|
.is_some_and(|s| !s.is_empty())
|
|
110
|
-
||
|
|
403
|
+
|| seat
|
|
404
|
+
.state
|
|
111
405
|
.get("agents")
|
|
112
406
|
.and_then(|v| v.get(agent_id.as_str()))
|
|
113
407
|
.is_some_and(agent_has_session);
|
|
114
408
|
Ok(RemoveAgentPreflight {
|
|
115
|
-
|
|
116
|
-
spec,
|
|
409
|
+
seat,
|
|
117
410
|
requirements: RemoveAgentFlagRequirements {
|
|
118
411
|
agent_id: agent_id.clone(),
|
|
119
412
|
from_spec_required: !dynamic_agent,
|
|
@@ -144,6 +437,9 @@ fn remove_agent_at_paths(
|
|
|
144
437
|
transport: &dyn crate::transport::Transport,
|
|
145
438
|
) -> Result<RemoveAgentOutcome, LifecycleError> {
|
|
146
439
|
let preflight = remove_agent_preflight(workspace, spec_workspace, agent_id, team, transport)?;
|
|
440
|
+
if preflight.seat.consistency == SeatConsistency::Absent && !force {
|
|
441
|
+
return Err(unknown_worker(agent_id));
|
|
442
|
+
}
|
|
147
443
|
let missing_from_spec = preflight.requirements.from_spec_required && !from_spec;
|
|
148
444
|
let missing_force = preflight.requirements.force_required && !force;
|
|
149
445
|
if missing_from_spec || missing_force {
|
|
@@ -170,20 +466,33 @@ fn remove_agent_at_paths(
|
|
|
170
466
|
let mut rollback = RemoveRollback::capture(
|
|
171
467
|
paths.run_workspace,
|
|
172
468
|
paths.spec_workspace,
|
|
173
|
-
&preflight.spec,
|
|
174
|
-
&preflight.state,
|
|
469
|
+
&preflight.seat.spec,
|
|
470
|
+
&preflight.seat.state,
|
|
471
|
+
&preflight.seat.team_key,
|
|
175
472
|
agent_id,
|
|
176
473
|
)?;
|
|
177
|
-
rollback.restore_running = force && preflight.
|
|
474
|
+
rollback.restore_running = force && preflight.seat.physical.is_some();
|
|
178
475
|
let result = remove_agent_inner(
|
|
179
476
|
&paths,
|
|
180
477
|
agent_id,
|
|
181
|
-
&preflight.spec,
|
|
182
|
-
preflight.state,
|
|
478
|
+
&preflight.seat.spec,
|
|
479
|
+
preflight.seat.state,
|
|
480
|
+
preflight.seat.physical,
|
|
481
|
+
&preflight.seat.team_key,
|
|
183
482
|
force,
|
|
184
483
|
team,
|
|
185
484
|
transport,
|
|
186
|
-
)
|
|
485
|
+
)
|
|
486
|
+
.and_then(|success| {
|
|
487
|
+
let after = resolve_seat(workspace, spec_workspace, agent_id, team, transport)?;
|
|
488
|
+
if after.consistency != SeatConsistency::Absent {
|
|
489
|
+
return Err(LifecycleError::StatePersist(format!(
|
|
490
|
+
"remove-agent post-resolve for {agent_id} is {:?}",
|
|
491
|
+
after.consistency
|
|
492
|
+
)));
|
|
493
|
+
}
|
|
494
|
+
Ok(success)
|
|
495
|
+
});
|
|
187
496
|
match result {
|
|
188
497
|
Ok(success) => {
|
|
189
498
|
// Foundation-0 F0-2: the historical dual-write to the legacy
|
|
@@ -233,46 +542,48 @@ fn remove_agent_inner(
|
|
|
233
542
|
agent_id: &AgentId,
|
|
234
543
|
spec: &YamlValue,
|
|
235
544
|
state: serde_json::Value,
|
|
545
|
+
physical: Option<crate::transport::PaneInfo>,
|
|
546
|
+
team_key: &str,
|
|
236
547
|
force: bool,
|
|
237
|
-
|
|
548
|
+
_team: Option<&str>,
|
|
238
549
|
transport: &dyn crate::transport::Transport,
|
|
239
550
|
) -> Result<RemoveSuccess, LifecycleError> {
|
|
240
551
|
// golden agents.py:75-79: when force-stopping a running worker, RE-RESOLVE the team-scoped state
|
|
241
552
|
// after the stop (stop_agent persisted it); otherwise the originally-resolved projection drives the
|
|
242
553
|
// removal. Either way we operate on the PROJECTION, never a raw load_runtime_state.
|
|
243
|
-
let
|
|
554
|
+
let working_state = state;
|
|
244
555
|
let mut stopped = false;
|
|
245
556
|
let mut cleared_locations = Vec::new();
|
|
246
|
-
if force
|
|
247
|
-
let
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
paths.run_workspace,
|
|
257
|
-
|
|
258
|
-
"stop",
|
|
259
|
-
&stop.target,
|
|
260
|
-
Some(stop.stopped),
|
|
261
|
-
)?;
|
|
262
|
-
working_state = resolve_team_scoped_state_or_refuse(paths.run_workspace, team)?;
|
|
557
|
+
if force {
|
|
558
|
+
if let Some(pane) = physical {
|
|
559
|
+
transport.kill_pane(&pane.pane_id).map_err(|error| {
|
|
560
|
+
LifecycleError::Transport(format!(
|
|
561
|
+
"failed to stop exact seat pane {} for {agent_id}: {error}",
|
|
562
|
+
pane.pane_id.as_str()
|
|
563
|
+
))
|
|
564
|
+
})?;
|
|
565
|
+
stopped = true;
|
|
566
|
+
let target = pane.pane_id.as_str().to_string();
|
|
567
|
+
write_remove_step_event(paths.run_workspace, agent_id, "stop", &target, Some(true))?;
|
|
568
|
+
}
|
|
263
569
|
}
|
|
264
|
-
let dynamic_role_path =
|
|
570
|
+
let dynamic_role_path =
|
|
571
|
+
managed_dynamic_role_file_path(paths.run_workspace, &working_state, agent_id)?;
|
|
265
572
|
let dynamic_role_required = has_recorded_dynamic_role_file(&working_state, agent_id);
|
|
266
573
|
// golden agents.py:81-83: removed_state = deepcopy(state); pop the agent; save_team_scoped_state
|
|
267
574
|
// (team projection) — NOT a raw save, so other teams in a multi-team workspace are preserved.
|
|
268
575
|
let mut removed_state = working_state;
|
|
269
576
|
remove_agent_from_state(&mut removed_state, agent_id)?;
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
577
|
+
mark_agent_retired_in_state(&mut removed_state, agent_id)?;
|
|
578
|
+
crate::state::repository::StateRepository::new(paths.run_workspace)
|
|
579
|
+
.save(
|
|
580
|
+
crate::state::repository::StateWriteIntent::RemoveAgent {
|
|
581
|
+
team_key,
|
|
582
|
+
agent_id: agent_id.as_str(),
|
|
583
|
+
},
|
|
584
|
+
&removed_state,
|
|
585
|
+
)
|
|
586
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
276
587
|
cleared_locations.push(serde_json::json!("state.json:agents"));
|
|
277
588
|
write_remove_step_event(
|
|
278
589
|
paths.run_workspace,
|
|
@@ -312,8 +623,12 @@ fn remove_agent_inner(
|
|
|
312
623
|
"team.spec.yaml",
|
|
313
624
|
None,
|
|
314
625
|
)?;
|
|
315
|
-
let role_file_removed =
|
|
626
|
+
let role_file_removed = match dynamic_role_path.as_deref() {
|
|
627
|
+
Some(path) => remove_dynamic_role_file(path, dynamic_role_required)?,
|
|
628
|
+
None => false,
|
|
629
|
+
};
|
|
316
630
|
if role_file_removed {
|
|
631
|
+
let dynamic_role_path = dynamic_role_path.as_deref().expect("managed role path");
|
|
317
632
|
let resource = dynamic_role_path.to_string_lossy().to_string();
|
|
318
633
|
cleared_locations.push(serde_json::json!(resource));
|
|
319
634
|
write_remove_step_event(
|
|
@@ -324,7 +639,7 @@ fn remove_agent_inner(
|
|
|
324
639
|
None,
|
|
325
640
|
)?;
|
|
326
641
|
}
|
|
327
|
-
let agent_health_deleted = delete_agent_health(paths.run_workspace, agent_id)?;
|
|
642
|
+
let agent_health_deleted = delete_agent_health(paths.run_workspace, team_key, agent_id)?;
|
|
328
643
|
cleared_locations.push(serde_json::json!("agent_health"));
|
|
329
644
|
write_remove_step_event(
|
|
330
645
|
paths.run_workspace,
|
|
@@ -480,9 +795,60 @@ fn remove_agent_from_state(
|
|
|
480
795
|
}
|
|
481
796
|
}
|
|
482
797
|
|
|
798
|
+
pub(crate) fn mark_agent_retired_in_state(
|
|
799
|
+
state: &mut serde_json::Value,
|
|
800
|
+
agent_id: &AgentId,
|
|
801
|
+
) -> Result<(), LifecycleError> {
|
|
802
|
+
let root = state.as_object_mut().ok_or_else(|| {
|
|
803
|
+
LifecycleError::StatePersist("runtime state root is not an object".to_string())
|
|
804
|
+
})?;
|
|
805
|
+
let lifecycle = root
|
|
806
|
+
.entry("agent_lifecycle".to_string())
|
|
807
|
+
.or_insert_with(|| serde_json::json!({}));
|
|
808
|
+
let lifecycle = lifecycle.as_object_mut().ok_or_else(|| {
|
|
809
|
+
LifecycleError::StatePersist("runtime state agent_lifecycle is not an object".to_string())
|
|
810
|
+
})?;
|
|
811
|
+
let entry = lifecycle
|
|
812
|
+
.entry(agent_id.as_str().to_string())
|
|
813
|
+
.or_insert_with(|| serde_json::json!({}));
|
|
814
|
+
if entry.get("state").and_then(serde_json::Value::as_str) == Some("retired") {
|
|
815
|
+
return Ok(());
|
|
816
|
+
}
|
|
817
|
+
let entry = entry.as_object_mut().ok_or_else(|| {
|
|
818
|
+
LifecycleError::StatePersist(format!(
|
|
819
|
+
"runtime state agent_lifecycle.{} is not an object",
|
|
820
|
+
agent_id.as_str()
|
|
821
|
+
))
|
|
822
|
+
})?;
|
|
823
|
+
entry.insert("state".to_string(), serde_json::json!("retired"));
|
|
824
|
+
entry.insert(
|
|
825
|
+
"changed_at".to_string(),
|
|
826
|
+
serde_json::json!(chrono::Utc::now().to_rfc3339()),
|
|
827
|
+
);
|
|
828
|
+
entry.insert("reason".to_string(), serde_json::json!("remove-agent"));
|
|
829
|
+
Ok(())
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
pub(crate) fn clear_agent_retirement_in_state(state: &mut serde_json::Value, agent_id: &AgentId) {
|
|
833
|
+
let Some(lifecycle) = state
|
|
834
|
+
.get_mut("agent_lifecycle")
|
|
835
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
836
|
+
else {
|
|
837
|
+
return;
|
|
838
|
+
};
|
|
839
|
+
let is_retired = lifecycle
|
|
840
|
+
.get(agent_id.as_str())
|
|
841
|
+
.and_then(|entry| entry.get("state"))
|
|
842
|
+
.and_then(serde_json::Value::as_str)
|
|
843
|
+
== Some("retired");
|
|
844
|
+
if is_retired {
|
|
845
|
+
lifecycle.remove(agent_id.as_str());
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
483
849
|
/// Build the persisted spec after removing one worker. Besides deleting the worker and startup entry,
|
|
484
850
|
/// prune routing references that would otherwise point at the removed worker.
|
|
485
|
-
fn spec_without_agent(spec: &YamlValue, agent_id: &AgentId) -> YamlValue {
|
|
851
|
+
pub(crate) fn spec_without_agent(spec: &YamlValue, agent_id: &AgentId) -> YamlValue {
|
|
486
852
|
let YamlValue::Map(pairs) = spec else {
|
|
487
853
|
return spec.clone();
|
|
488
854
|
};
|
|
@@ -648,6 +1014,45 @@ fn dynamic_role_file_path(
|
|
|
648
1014
|
.join(format!("{}.md", agent_id.as_str()))
|
|
649
1015
|
}
|
|
650
1016
|
|
|
1017
|
+
/// Resolve a deletable role artifact. `dynamic_role_file` may point at an
|
|
1018
|
+
/// external `--role-file`; only canonical children of the runtime-managed
|
|
1019
|
+
/// directory belong to remove/rollback. A symlink escape is external.
|
|
1020
|
+
fn managed_dynamic_role_file_path(
|
|
1021
|
+
workspace: &Path,
|
|
1022
|
+
state: &serde_json::Value,
|
|
1023
|
+
agent_id: &AgentId,
|
|
1024
|
+
) -> Result<Option<std::path::PathBuf>, LifecycleError> {
|
|
1025
|
+
let path = dynamic_role_file_path(workspace, state, agent_id);
|
|
1026
|
+
let managed_root = workspace.join(".team").join("dynamic-role-files");
|
|
1027
|
+
let canonical_root = match std::fs::canonicalize(&managed_root) {
|
|
1028
|
+
Ok(path) => path,
|
|
1029
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
1030
|
+
return Ok(path.starts_with(&managed_root).then_some(path));
|
|
1031
|
+
}
|
|
1032
|
+
Err(error) => {
|
|
1033
|
+
return Err(LifecycleError::StatePersist(format!(
|
|
1034
|
+
"resolve managed role root {}: {error}",
|
|
1035
|
+
managed_root.display()
|
|
1036
|
+
)))
|
|
1037
|
+
}
|
|
1038
|
+
};
|
|
1039
|
+
let canonical_path = match std::fs::canonicalize(&path) {
|
|
1040
|
+
Ok(path) => path,
|
|
1041
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
1042
|
+
return Ok(path.starts_with(&managed_root).then_some(path));
|
|
1043
|
+
}
|
|
1044
|
+
Err(error) => {
|
|
1045
|
+
return Err(LifecycleError::StatePersist(format!(
|
|
1046
|
+
"resolve role file {}: {error}",
|
|
1047
|
+
path.display()
|
|
1048
|
+
)))
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
Ok(canonical_path
|
|
1052
|
+
.starts_with(&canonical_root)
|
|
1053
|
+
.then_some(canonical_path))
|
|
1054
|
+
}
|
|
1055
|
+
|
|
651
1056
|
fn has_recorded_dynamic_role_file(state: &serde_json::Value, agent_id: &AgentId) -> bool {
|
|
652
1057
|
state
|
|
653
1058
|
.get("agents")
|
|
@@ -657,15 +1062,19 @@ fn has_recorded_dynamic_role_file(state: &serde_json::Value, agent_id: &AgentId)
|
|
|
657
1062
|
.is_some_and(|s| !s.is_empty())
|
|
658
1063
|
}
|
|
659
1064
|
|
|
660
|
-
fn delete_agent_health(
|
|
1065
|
+
fn delete_agent_health(
|
|
1066
|
+
workspace: &Path,
|
|
1067
|
+
owner_team_id: &str,
|
|
1068
|
+
agent_id: &AgentId,
|
|
1069
|
+
) -> Result<bool, LifecycleError> {
|
|
661
1070
|
let store = crate::message_store::MessageStore::open(workspace)
|
|
662
1071
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
663
1072
|
let conn = crate::db::schema::open_db(store.db_path())
|
|
664
1073
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
665
1074
|
let changed = conn
|
|
666
1075
|
.execute(
|
|
667
|
-
"delete from agent_health where agent_id = ?
|
|
668
|
-
[agent_id.as_str()],
|
|
1076
|
+
"delete from agent_health where owner_team_id = ?1 and agent_id = ?2",
|
|
1077
|
+
rusqlite::params![owner_team_id, agent_id.as_str()],
|
|
669
1078
|
)
|
|
670
1079
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
671
1080
|
Ok(changed > 0)
|
|
@@ -682,18 +1091,20 @@ use crate::db::agent_health_capture::{
|
|
|
682
1091
|
|
|
683
1092
|
fn select_agent_health(
|
|
684
1093
|
workspace: &Path,
|
|
1094
|
+
owner_team_id: &str,
|
|
685
1095
|
agent_id: &AgentId,
|
|
686
1096
|
) -> Result<Option<CapturedHealth>, LifecycleError> {
|
|
687
|
-
capture_select_agent_health(workspace, agent_id)
|
|
1097
|
+
capture_select_agent_health(workspace, owner_team_id, agent_id)
|
|
688
1098
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))
|
|
689
1099
|
}
|
|
690
1100
|
|
|
691
1101
|
fn restore_agent_health(
|
|
692
1102
|
workspace: &Path,
|
|
1103
|
+
owner_team_id: &str,
|
|
693
1104
|
agent_id: &AgentId,
|
|
694
1105
|
row: &Option<CapturedHealth>,
|
|
695
1106
|
) -> Result<(), LifecycleError> {
|
|
696
|
-
capture_restore_agent_health(workspace, agent_id, row)
|
|
1107
|
+
capture_restore_agent_health(workspace, owner_team_id, agent_id, row)
|
|
697
1108
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))
|
|
698
1109
|
}
|
|
699
1110
|
|
|
@@ -711,12 +1122,13 @@ fn maybe_fail_remove_after_agent_health_delete() -> Result<(), LifecycleError> {
|
|
|
711
1122
|
|
|
712
1123
|
struct RemoveRollback {
|
|
713
1124
|
agent_id: AgentId,
|
|
1125
|
+
team_key: String,
|
|
714
1126
|
spec_text: Option<String>,
|
|
715
1127
|
state: serde_json::Value,
|
|
716
1128
|
team_state_text: Option<String>,
|
|
717
1129
|
team_state_path: std::path::PathBuf,
|
|
718
1130
|
dynamic_role_bytes: Option<Vec<u8>>,
|
|
719
|
-
dynamic_role_path: std::path::PathBuf
|
|
1131
|
+
dynamic_role_path: Option<std::path::PathBuf>,
|
|
720
1132
|
/// golden agents.py:185: the agent_health row captured BEFORE delete, re-upserted on rollback.
|
|
721
1133
|
health: Option<CapturedHealth>,
|
|
722
1134
|
restore_running: bool,
|
|
@@ -728,6 +1140,7 @@ impl RemoveRollback {
|
|
|
728
1140
|
spec_workspace: &Path,
|
|
729
1141
|
spec: &YamlValue,
|
|
730
1142
|
state: &serde_json::Value,
|
|
1143
|
+
team_key: &str,
|
|
731
1144
|
agent_id: &AgentId,
|
|
732
1145
|
) -> Result<Self, LifecycleError> {
|
|
733
1146
|
let spec_path = spec_workspace.join("team.spec.yaml");
|
|
@@ -751,15 +1164,19 @@ impl RemoveRollback {
|
|
|
751
1164
|
)))
|
|
752
1165
|
}
|
|
753
1166
|
};
|
|
754
|
-
let dynamic_role_path =
|
|
755
|
-
let dynamic_role_bytes = match
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
1167
|
+
let dynamic_role_path = managed_dynamic_role_file_path(workspace, state, agent_id)?;
|
|
1168
|
+
let dynamic_role_bytes = match dynamic_role_path.as_deref() {
|
|
1169
|
+
Some(path) => match std::fs::read(path) {
|
|
1170
|
+
Ok(bytes) => Some(bytes),
|
|
1171
|
+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
|
1172
|
+
Err(e) => return Err(LifecycleError::StatePersist(format!("read role file: {e}"))),
|
|
1173
|
+
},
|
|
1174
|
+
None => None,
|
|
759
1175
|
};
|
|
760
|
-
let health = select_agent_health(workspace, agent_id)?;
|
|
1176
|
+
let health = select_agent_health(workspace, team_key, agent_id)?;
|
|
761
1177
|
Ok(Self {
|
|
762
1178
|
agent_id: agent_id.clone(),
|
|
1179
|
+
team_key: team_key.to_string(),
|
|
763
1180
|
spec_text,
|
|
764
1181
|
state: state.clone(),
|
|
765
1182
|
team_state_text,
|
|
@@ -793,7 +1210,13 @@ impl RemoveRollback {
|
|
|
793
1210
|
}
|
|
794
1211
|
}
|
|
795
1212
|
// workspace_state
|
|
796
|
-
if let Err(e) = crate::state::
|
|
1213
|
+
if let Err(e) = crate::state::repository::StateRepository::new(workspace).save(
|
|
1214
|
+
crate::state::repository::StateWriteIntent::ForceRecreateRollback {
|
|
1215
|
+
team_key: &self.team_key,
|
|
1216
|
+
agent_id: self.agent_id.as_str(),
|
|
1217
|
+
},
|
|
1218
|
+
&self.state,
|
|
1219
|
+
) {
|
|
797
1220
|
errors.push(format!("workspace_state:{e}"));
|
|
798
1221
|
}
|
|
799
1222
|
// team_state
|
|
@@ -814,27 +1237,23 @@ impl RemoveRollback {
|
|
|
814
1237
|
errors.push(format!("team_state:{e}"));
|
|
815
1238
|
}
|
|
816
1239
|
// role_file
|
|
817
|
-
let role_file_result = match &self.dynamic_role_bytes {
|
|
818
|
-
Some(bytes) => {
|
|
819
|
-
if let Some(parent) =
|
|
1240
|
+
let role_file_result = match (&self.dynamic_role_path, &self.dynamic_role_bytes) {
|
|
1241
|
+
(Some(path), Some(bytes)) => {
|
|
1242
|
+
if let Some(parent) = path.parent() {
|
|
820
1243
|
let _ = std::fs::create_dir_all(parent);
|
|
821
1244
|
}
|
|
822
|
-
std::fs::write(
|
|
1245
|
+
std::fs::write(path, bytes)
|
|
823
1246
|
}
|
|
824
|
-
None => match std::fs::remove_file(
|
|
1247
|
+
(Some(path), None) => match std::fs::remove_file(path) {
|
|
825
1248
|
Ok(()) => Ok(()),
|
|
826
1249
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
827
1250
|
Err(e) => Err(e),
|
|
828
1251
|
},
|
|
1252
|
+
(None, _) => Ok(()),
|
|
829
1253
|
};
|
|
830
1254
|
if let Err(e) = role_file_result {
|
|
831
1255
|
errors.push(format!("role_file:{e}"));
|
|
832
1256
|
}
|
|
833
|
-
// agent_health
|
|
834
|
-
if let Err(e) = restore_agent_health(workspace, &self.agent_id, &self.health) {
|
|
835
|
-
errors.push(format!("agent_health:{e}"));
|
|
836
|
-
}
|
|
837
|
-
|
|
838
1257
|
if self.restore_running && errors.is_empty() {
|
|
839
1258
|
if let Err(e) = start_agent_at_paths(
|
|
840
1259
|
workspace,
|
|
@@ -849,6 +1268,13 @@ impl RemoveRollback {
|
|
|
849
1268
|
errors.push(format!("worker_restore:{e}"));
|
|
850
1269
|
}
|
|
851
1270
|
}
|
|
1271
|
+
// Starting a replacement cohort intentionally clears stale health, so
|
|
1272
|
+
// rollback must restore the captured row after the old seat is back.
|
|
1273
|
+
if let Err(e) =
|
|
1274
|
+
restore_agent_health(workspace, &self.team_key, &self.agent_id, &self.health)
|
|
1275
|
+
{
|
|
1276
|
+
errors.push(format!("agent_health:{e}"));
|
|
1277
|
+
}
|
|
852
1278
|
errors
|
|
853
1279
|
}
|
|
854
1280
|
}
|