@team-agent/installer 0.5.50 → 0.5.51
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 +11 -48
- 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/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/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 +297 -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 +44 -26
- package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +67 -26
- package/crates/team-agent/src/lifecycle/restart/remove.rs +435 -72
- package/crates/team-agent/src/lifecycle/restart.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +575 -17
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +55 -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/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 +165 -39
- package/crates/team-agent/src/messaging/helpers.rs +17 -13
- package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
- package/crates/team-agent/src/messaging/mod.rs +10 -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_inject_acceptance.rs +305 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +1 -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 +2 -1
- package/crates/team-agent/src/state/repository/tests.rs +47 -0
- package/crates/team-agent/src/state/repository.rs +59 -16
- 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::*;
|
|
@@ -61,6 +59,156 @@ pub fn remove_agent_with_transport(
|
|
|
61
59
|
)
|
|
62
60
|
}
|
|
63
61
|
|
|
62
|
+
pub(crate) fn remove_agent_with_transport_locked(
|
|
63
|
+
workspace: &Path,
|
|
64
|
+
agent_id: &AgentId,
|
|
65
|
+
from_spec: bool,
|
|
66
|
+
force: bool,
|
|
67
|
+
team: Option<&str>,
|
|
68
|
+
transport: &dyn crate::transport::Transport,
|
|
69
|
+
) -> Result<RemoveAgentOutcome, LifecycleError> {
|
|
70
|
+
let paths = lifecycle_paths(workspace, team)?;
|
|
71
|
+
remove_agent_at_paths(
|
|
72
|
+
&paths.run_workspace,
|
|
73
|
+
&paths.spec_workspace,
|
|
74
|
+
agent_id,
|
|
75
|
+
from_spec,
|
|
76
|
+
force,
|
|
77
|
+
team,
|
|
78
|
+
transport,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
pub(crate) struct ForceRecreateSnapshot {
|
|
83
|
+
rollback: RemoveRollback,
|
|
84
|
+
run_workspace: std::path::PathBuf,
|
|
85
|
+
spec_workspace: std::path::PathBuf,
|
|
86
|
+
before_physical: Option<crate::transport::PaneInfo>,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
impl ForceRecreateSnapshot {
|
|
90
|
+
pub(crate) fn capture(
|
|
91
|
+
workspace: &Path,
|
|
92
|
+
agent_id: &AgentId,
|
|
93
|
+
team: Option<&str>,
|
|
94
|
+
transport: &dyn crate::transport::Transport,
|
|
95
|
+
) -> Result<Self, LifecycleError> {
|
|
96
|
+
let paths = lifecycle_paths(workspace, team)?;
|
|
97
|
+
let seat = resolve_seat(
|
|
98
|
+
&paths.run_workspace,
|
|
99
|
+
&paths.spec_workspace,
|
|
100
|
+
agent_id,
|
|
101
|
+
team,
|
|
102
|
+
transport,
|
|
103
|
+
)?;
|
|
104
|
+
let mut rollback = RemoveRollback::capture(
|
|
105
|
+
&paths.run_workspace,
|
|
106
|
+
&paths.spec_workspace,
|
|
107
|
+
&seat.spec,
|
|
108
|
+
&seat.state,
|
|
109
|
+
&seat.team_key,
|
|
110
|
+
agent_id,
|
|
111
|
+
)?;
|
|
112
|
+
rollback.restore_running = seat.physical.is_some();
|
|
113
|
+
Ok(Self {
|
|
114
|
+
rollback,
|
|
115
|
+
run_workspace: paths.run_workspace,
|
|
116
|
+
spec_workspace: paths.spec_workspace,
|
|
117
|
+
before_physical: seat.physical,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
pub(crate) fn restore(
|
|
122
|
+
&self,
|
|
123
|
+
team: Option<&str>,
|
|
124
|
+
transport: &dyn crate::transport::Transport,
|
|
125
|
+
) -> Vec<String> {
|
|
126
|
+
self.rollback
|
|
127
|
+
.restore(&self.run_workspace, &self.spec_workspace, team, transport)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// The old pane has already been consumed. Any exact pane now resolved for
|
|
131
|
+
/// this seat belongs to this force-recreate transaction and must be removed
|
|
132
|
+
/// before the logical snapshot is restored, otherwise rollback can leave a
|
|
133
|
+
/// duplicate worker behind.
|
|
134
|
+
pub(crate) fn restore_after_consumption(
|
|
135
|
+
&self,
|
|
136
|
+
transport: &dyn crate::transport::Transport,
|
|
137
|
+
) -> Vec<String> {
|
|
138
|
+
let mut errors = Vec::new();
|
|
139
|
+
match resolve_seat(
|
|
140
|
+
&self.run_workspace,
|
|
141
|
+
&self.spec_workspace,
|
|
142
|
+
&self.rollback.agent_id,
|
|
143
|
+
Some(self.rollback.team_key.as_str()),
|
|
144
|
+
transport,
|
|
145
|
+
) {
|
|
146
|
+
Ok(after) => {
|
|
147
|
+
if let Some(pane) = after.physical {
|
|
148
|
+
if let Err(error) = transport.kill_pane(&pane.pane_id) {
|
|
149
|
+
errors.push(format!(
|
|
150
|
+
"transaction_pane:{}:{error}",
|
|
151
|
+
pane.pane_id.as_str()
|
|
152
|
+
));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
Err(error) => errors.push(format!("transaction_resolve:{error}")),
|
|
157
|
+
}
|
|
158
|
+
errors.extend(self.rollback.restore(
|
|
159
|
+
&self.run_workspace,
|
|
160
|
+
&self.spec_workspace,
|
|
161
|
+
Some(self.rollback.team_key.as_str()),
|
|
162
|
+
transport,
|
|
163
|
+
));
|
|
164
|
+
if errors.is_empty() {
|
|
165
|
+
if let Some(before) = &self.before_physical {
|
|
166
|
+
match resolve_seat(
|
|
167
|
+
&self.run_workspace,
|
|
168
|
+
&self.spec_workspace,
|
|
169
|
+
&self.rollback.agent_id,
|
|
170
|
+
Some(self.rollback.team_key.as_str()),
|
|
171
|
+
transport,
|
|
172
|
+
) {
|
|
173
|
+
Ok(after)
|
|
174
|
+
if after.physical.as_ref().is_some_and(|pane| {
|
|
175
|
+
pane.session == before.session && pane.window_name == before.window_name
|
|
176
|
+
}) => {}
|
|
177
|
+
Ok(after) => errors.push(format!(
|
|
178
|
+
"worker_restore:before physical tuple not restored: {:?}",
|
|
179
|
+
after.consistency
|
|
180
|
+
)),
|
|
181
|
+
Err(error) => errors.push(format!("worker_restore_resolve:{error}")),
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
errors
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
pub(crate) fn require_coherent(
|
|
189
|
+
&self,
|
|
190
|
+
agent_id: &AgentId,
|
|
191
|
+
team: Option<&str>,
|
|
192
|
+
transport: &dyn crate::transport::Transport,
|
|
193
|
+
) -> Result<(), LifecycleError> {
|
|
194
|
+
let after = resolve_seat(
|
|
195
|
+
&self.run_workspace,
|
|
196
|
+
&self.spec_workspace,
|
|
197
|
+
agent_id,
|
|
198
|
+
team,
|
|
199
|
+
transport,
|
|
200
|
+
)?;
|
|
201
|
+
if after.consistency == SeatConsistency::Coherent {
|
|
202
|
+
Ok(())
|
|
203
|
+
} else {
|
|
204
|
+
Err(LifecycleError::StatePersist(format!(
|
|
205
|
+
"force-recreate post-resolve for {agent_id} is {:?}",
|
|
206
|
+
after.consistency
|
|
207
|
+
)))
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
64
212
|
pub fn remove_agent_flag_requirements(
|
|
65
213
|
workspace: &Path,
|
|
66
214
|
agent_id: &AgentId,
|
|
@@ -79,11 +227,146 @@ pub fn remove_agent_flag_requirements(
|
|
|
79
227
|
}
|
|
80
228
|
|
|
81
229
|
struct RemoveAgentPreflight {
|
|
82
|
-
|
|
83
|
-
spec: YamlValue,
|
|
230
|
+
seat: ResolvedSeat,
|
|
84
231
|
requirements: RemoveAgentFlagRequirements,
|
|
85
232
|
}
|
|
86
233
|
|
|
234
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
235
|
+
pub(super) enum SeatConsistency {
|
|
236
|
+
Absent,
|
|
237
|
+
Coherent,
|
|
238
|
+
StateOnly,
|
|
239
|
+
SpecOnly,
|
|
240
|
+
PhysicalOnly,
|
|
241
|
+
Mixed,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
pub(super) struct ResolvedSeat {
|
|
245
|
+
pub(super) state: serde_json::Value,
|
|
246
|
+
pub(super) spec: YamlValue,
|
|
247
|
+
pub(super) team_key: String,
|
|
248
|
+
pub(super) session: crate::transport::SessionName,
|
|
249
|
+
pub(super) window: String,
|
|
250
|
+
pub(super) physical: Option<crate::transport::PaneInfo>,
|
|
251
|
+
pub(super) state_present: bool,
|
|
252
|
+
pub(super) spec_present: bool,
|
|
253
|
+
pub(super) consistency: SeatConsistency,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/// Resolve one seat from the selected team's desired, persisted and physical
|
|
257
|
+
/// sources. The transport is already bound to the selected team's endpoint;
|
|
258
|
+
/// physical identity is then narrowed to exactly one `(session, window, pane)`
|
|
259
|
+
/// tuple. A global window-name match is never accepted as identity.
|
|
260
|
+
pub(super) fn resolve_seat(
|
|
261
|
+
workspace: &Path,
|
|
262
|
+
spec_workspace: &Path,
|
|
263
|
+
agent_id: &AgentId,
|
|
264
|
+
team: Option<&str>,
|
|
265
|
+
transport: &dyn crate::transport::Transport,
|
|
266
|
+
) -> Result<ResolvedSeat, LifecycleError> {
|
|
267
|
+
let state = resolve_team_scoped_state_or_refuse(workspace, team)?;
|
|
268
|
+
crate::lifecycle::launch::ensure_owner_allowed_for_state(&state, Some(agent_id))?;
|
|
269
|
+
let spec = load_team_spec(spec_workspace)?;
|
|
270
|
+
let team_key = state
|
|
271
|
+
.get("active_team_key")
|
|
272
|
+
.and_then(serde_json::Value::as_str)
|
|
273
|
+
.filter(|key| !key.is_empty())
|
|
274
|
+
.map(str::to_string)
|
|
275
|
+
.unwrap_or_else(|| crate::state::projection::team_state_key(&state));
|
|
276
|
+
let state_present = state
|
|
277
|
+
.get("agents")
|
|
278
|
+
.and_then(|agents| agents.get(agent_id.as_str()))
|
|
279
|
+
.is_some();
|
|
280
|
+
let spec_present = find_spec_agent(&spec, agent_id).is_some();
|
|
281
|
+
let session = state_session_name_from_spec(&state, &spec);
|
|
282
|
+
let window = state
|
|
283
|
+
.get("agents")
|
|
284
|
+
.and_then(|agents| agents.get(agent_id.as_str()))
|
|
285
|
+
.and_then(|agent| agent.get("window"))
|
|
286
|
+
.and_then(serde_json::Value::as_str)
|
|
287
|
+
.filter(|window| !window.is_empty())
|
|
288
|
+
.unwrap_or_else(|| agent_id.as_str())
|
|
289
|
+
.to_string();
|
|
290
|
+
let stored_pane_id = state
|
|
291
|
+
.get("agents")
|
|
292
|
+
.and_then(|agents| agents.get(agent_id.as_str()))
|
|
293
|
+
.and_then(|agent| agent.get("pane_id"))
|
|
294
|
+
.and_then(serde_json::Value::as_str)
|
|
295
|
+
.filter(|pane| !pane.is_empty())
|
|
296
|
+
.map(crate::transport::PaneId::new);
|
|
297
|
+
let mut physical = transport
|
|
298
|
+
.list_targets()
|
|
299
|
+
.map_err(|error| LifecycleError::Transport(format!("resolve seat targets: {error}")))?
|
|
300
|
+
.into_iter()
|
|
301
|
+
.filter(|pane| {
|
|
302
|
+
pane.session == session
|
|
303
|
+
&& pane
|
|
304
|
+
.window_name
|
|
305
|
+
.as_ref()
|
|
306
|
+
.is_some_and(|name| name.as_str() == window)
|
|
307
|
+
&& stored_pane_id
|
|
308
|
+
.as_ref()
|
|
309
|
+
.is_none_or(|stored| pane.pane_id == *stored)
|
|
310
|
+
});
|
|
311
|
+
let mut first = physical.next();
|
|
312
|
+
if physical.next().is_some() {
|
|
313
|
+
return Err(LifecycleError::RequirementUnmet(format!(
|
|
314
|
+
"seat identity ambiguous for {agent_id}: session={} window={window}",
|
|
315
|
+
session.as_str()
|
|
316
|
+
)));
|
|
317
|
+
}
|
|
318
|
+
// Some backends can positively probe an exact pane id even when their
|
|
319
|
+
// global target snapshot is temporarily empty (for example an attached
|
|
320
|
+
// explicit tmux socket during reset). The persisted tuple is scoped by the
|
|
321
|
+
// selected transport endpoint and session; a positive exact-pane probe is
|
|
322
|
+
// therefore safe, unlike a reverse window-name scan.
|
|
323
|
+
if first.is_none() {
|
|
324
|
+
if let Some(pane_id) = stored_pane_id
|
|
325
|
+
.as_ref()
|
|
326
|
+
.filter(|pane| transport.has_pane(pane).ok().flatten() == Some(true))
|
|
327
|
+
{
|
|
328
|
+
first = Some(crate::transport::PaneInfo {
|
|
329
|
+
pane_id: pane_id.clone(),
|
|
330
|
+
session: session.clone(),
|
|
331
|
+
window_index: None,
|
|
332
|
+
window_name: Some(crate::transport::WindowName::new(&window)),
|
|
333
|
+
pane_index: None,
|
|
334
|
+
tty: None,
|
|
335
|
+
current_command: None,
|
|
336
|
+
current_path: None,
|
|
337
|
+
active: true,
|
|
338
|
+
pane_pid: state
|
|
339
|
+
.get("agents")
|
|
340
|
+
.and_then(|agents| agents.get(agent_id.as_str()))
|
|
341
|
+
.and_then(|agent| agent.get("pane_pid"))
|
|
342
|
+
.and_then(serde_json::Value::as_u64)
|
|
343
|
+
.and_then(|pid| u32::try_from(pid).ok()),
|
|
344
|
+
leader_env: std::collections::BTreeMap::new(),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
let physical_present = first.is_some();
|
|
349
|
+
let consistency = match (spec_present, state_present, physical_present) {
|
|
350
|
+
(false, false, false) => SeatConsistency::Absent,
|
|
351
|
+
(true, true, true) => SeatConsistency::Coherent,
|
|
352
|
+
(false, true, false) => SeatConsistency::StateOnly,
|
|
353
|
+
(true, false, false) => SeatConsistency::SpecOnly,
|
|
354
|
+
(false, false, true) => SeatConsistency::PhysicalOnly,
|
|
355
|
+
_ => SeatConsistency::Mixed,
|
|
356
|
+
};
|
|
357
|
+
Ok(ResolvedSeat {
|
|
358
|
+
state,
|
|
359
|
+
spec,
|
|
360
|
+
team_key,
|
|
361
|
+
session,
|
|
362
|
+
window,
|
|
363
|
+
physical: first,
|
|
364
|
+
state_present,
|
|
365
|
+
spec_present,
|
|
366
|
+
consistency,
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
|
|
87
370
|
fn remove_agent_preflight(
|
|
88
371
|
workspace: &Path,
|
|
89
372
|
spec_workspace: &Path,
|
|
@@ -95,25 +378,25 @@ fn remove_agent_preflight(
|
|
|
95
378
|
// team_target_unresolved refusal before the owner gate), THEN the owner gate, THEN load_spec +
|
|
96
379
|
// _find_worker (unknown-worker raise). Mirror the stop/reset wiring so remove is byte-symmetric:
|
|
97
380
|
// 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
|
-
|
|
381
|
+
let seat = resolve_seat(workspace, spec_workspace, agent_id, team, transport)?;
|
|
382
|
+
let spec_agent = find_spec_agent(&seat.spec, agent_id);
|
|
383
|
+
// A persisted-only seat is still known and force-removable.
|
|
384
|
+
let dynamic_agent =
|
|
385
|
+
spec_agent.is_none_or(|agent| is_dynamic_agent(&seat.state, agent, agent_id));
|
|
386
|
+
let force_required =
|
|
387
|
+
seat.physical.is_some() || agent_is_running(&seat.state, agent_id, transport);
|
|
388
|
+
let has_session = seat
|
|
389
|
+
.state
|
|
107
390
|
.get("session_name")
|
|
108
391
|
.and_then(|v| v.as_str())
|
|
109
392
|
.is_some_and(|s| !s.is_empty())
|
|
110
|
-
||
|
|
393
|
+
|| seat
|
|
394
|
+
.state
|
|
111
395
|
.get("agents")
|
|
112
396
|
.and_then(|v| v.get(agent_id.as_str()))
|
|
113
397
|
.is_some_and(agent_has_session);
|
|
114
398
|
Ok(RemoveAgentPreflight {
|
|
115
|
-
|
|
116
|
-
spec,
|
|
399
|
+
seat,
|
|
117
400
|
requirements: RemoveAgentFlagRequirements {
|
|
118
401
|
agent_id: agent_id.clone(),
|
|
119
402
|
from_spec_required: !dynamic_agent,
|
|
@@ -144,6 +427,9 @@ fn remove_agent_at_paths(
|
|
|
144
427
|
transport: &dyn crate::transport::Transport,
|
|
145
428
|
) -> Result<RemoveAgentOutcome, LifecycleError> {
|
|
146
429
|
let preflight = remove_agent_preflight(workspace, spec_workspace, agent_id, team, transport)?;
|
|
430
|
+
if preflight.seat.consistency == SeatConsistency::Absent && !force {
|
|
431
|
+
return Err(unknown_worker(agent_id));
|
|
432
|
+
}
|
|
147
433
|
let missing_from_spec = preflight.requirements.from_spec_required && !from_spec;
|
|
148
434
|
let missing_force = preflight.requirements.force_required && !force;
|
|
149
435
|
if missing_from_spec || missing_force {
|
|
@@ -170,20 +456,33 @@ fn remove_agent_at_paths(
|
|
|
170
456
|
let mut rollback = RemoveRollback::capture(
|
|
171
457
|
paths.run_workspace,
|
|
172
458
|
paths.spec_workspace,
|
|
173
|
-
&preflight.spec,
|
|
174
|
-
&preflight.state,
|
|
459
|
+
&preflight.seat.spec,
|
|
460
|
+
&preflight.seat.state,
|
|
461
|
+
&preflight.seat.team_key,
|
|
175
462
|
agent_id,
|
|
176
463
|
)?;
|
|
177
|
-
rollback.restore_running = force && preflight.
|
|
464
|
+
rollback.restore_running = force && preflight.seat.physical.is_some();
|
|
178
465
|
let result = remove_agent_inner(
|
|
179
466
|
&paths,
|
|
180
467
|
agent_id,
|
|
181
|
-
&preflight.spec,
|
|
182
|
-
preflight.state,
|
|
468
|
+
&preflight.seat.spec,
|
|
469
|
+
preflight.seat.state,
|
|
470
|
+
preflight.seat.physical,
|
|
471
|
+
&preflight.seat.team_key,
|
|
183
472
|
force,
|
|
184
473
|
team,
|
|
185
474
|
transport,
|
|
186
|
-
)
|
|
475
|
+
)
|
|
476
|
+
.and_then(|success| {
|
|
477
|
+
let after = resolve_seat(workspace, spec_workspace, agent_id, team, transport)?;
|
|
478
|
+
if after.consistency != SeatConsistency::Absent {
|
|
479
|
+
return Err(LifecycleError::StatePersist(format!(
|
|
480
|
+
"remove-agent post-resolve for {agent_id} is {:?}",
|
|
481
|
+
after.consistency
|
|
482
|
+
)));
|
|
483
|
+
}
|
|
484
|
+
Ok(success)
|
|
485
|
+
});
|
|
187
486
|
match result {
|
|
188
487
|
Ok(success) => {
|
|
189
488
|
// Foundation-0 F0-2: the historical dual-write to the legacy
|
|
@@ -233,44 +532,44 @@ fn remove_agent_inner(
|
|
|
233
532
|
agent_id: &AgentId,
|
|
234
533
|
spec: &YamlValue,
|
|
235
534
|
state: serde_json::Value,
|
|
535
|
+
physical: Option<crate::transport::PaneInfo>,
|
|
536
|
+
team_key: &str,
|
|
236
537
|
force: bool,
|
|
237
|
-
|
|
538
|
+
_team: Option<&str>,
|
|
238
539
|
transport: &dyn crate::transport::Transport,
|
|
239
540
|
) -> Result<RemoveSuccess, LifecycleError> {
|
|
240
541
|
// golden agents.py:75-79: when force-stopping a running worker, RE-RESOLVE the team-scoped state
|
|
241
542
|
// after the stop (stop_agent persisted it); otherwise the originally-resolved projection drives the
|
|
242
543
|
// removal. Either way we operate on the PROJECTION, never a raw load_runtime_state.
|
|
243
|
-
let
|
|
544
|
+
let working_state = state;
|
|
244
545
|
let mut stopped = false;
|
|
245
546
|
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)?;
|
|
547
|
+
if force {
|
|
548
|
+
if let Some(pane) = physical {
|
|
549
|
+
transport.kill_pane(&pane.pane_id).map_err(|error| {
|
|
550
|
+
LifecycleError::Transport(format!(
|
|
551
|
+
"failed to stop exact seat pane {} for {agent_id}: {error}",
|
|
552
|
+
pane.pane_id.as_str()
|
|
553
|
+
))
|
|
554
|
+
})?;
|
|
555
|
+
stopped = true;
|
|
556
|
+
let target = pane.pane_id.as_str().to_string();
|
|
557
|
+
write_remove_step_event(paths.run_workspace, agent_id, "stop", &target, Some(true))?;
|
|
558
|
+
}
|
|
263
559
|
}
|
|
264
|
-
let dynamic_role_path =
|
|
560
|
+
let dynamic_role_path =
|
|
561
|
+
managed_dynamic_role_file_path(paths.run_workspace, &working_state, agent_id)?;
|
|
265
562
|
let dynamic_role_required = has_recorded_dynamic_role_file(&working_state, agent_id);
|
|
266
563
|
// golden agents.py:81-83: removed_state = deepcopy(state); pop the agent; save_team_scoped_state
|
|
267
564
|
// (team projection) — NOT a raw save, so other teams in a multi-team workspace are preserved.
|
|
268
565
|
let mut removed_state = working_state;
|
|
269
566
|
remove_agent_from_state(&mut removed_state, agent_id)?;
|
|
270
|
-
crate::state::
|
|
271
|
-
|
|
567
|
+
crate::state::repository::StateRepository::new(paths.run_workspace).save(
|
|
568
|
+
crate::state::repository::StateWriteIntent::RemoveAgent {
|
|
569
|
+
team_key,
|
|
570
|
+
agent_id: agent_id.as_str(),
|
|
571
|
+
},
|
|
272
572
|
&removed_state,
|
|
273
|
-
&[agent_id.as_str()],
|
|
274
573
|
)
|
|
275
574
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
276
575
|
cleared_locations.push(serde_json::json!("state.json:agents"));
|
|
@@ -312,8 +611,12 @@ fn remove_agent_inner(
|
|
|
312
611
|
"team.spec.yaml",
|
|
313
612
|
None,
|
|
314
613
|
)?;
|
|
315
|
-
let role_file_removed =
|
|
614
|
+
let role_file_removed = match dynamic_role_path.as_deref() {
|
|
615
|
+
Some(path) => remove_dynamic_role_file(path, dynamic_role_required)?,
|
|
616
|
+
None => false,
|
|
617
|
+
};
|
|
316
618
|
if role_file_removed {
|
|
619
|
+
let dynamic_role_path = dynamic_role_path.as_deref().expect("managed role path");
|
|
317
620
|
let resource = dynamic_role_path.to_string_lossy().to_string();
|
|
318
621
|
cleared_locations.push(serde_json::json!(resource));
|
|
319
622
|
write_remove_step_event(
|
|
@@ -324,7 +627,7 @@ fn remove_agent_inner(
|
|
|
324
627
|
None,
|
|
325
628
|
)?;
|
|
326
629
|
}
|
|
327
|
-
let agent_health_deleted = delete_agent_health(paths.run_workspace, agent_id)?;
|
|
630
|
+
let agent_health_deleted = delete_agent_health(paths.run_workspace, team_key, agent_id)?;
|
|
328
631
|
cleared_locations.push(serde_json::json!("agent_health"));
|
|
329
632
|
write_remove_step_event(
|
|
330
633
|
paths.run_workspace,
|
|
@@ -482,7 +785,7 @@ fn remove_agent_from_state(
|
|
|
482
785
|
|
|
483
786
|
/// Build the persisted spec after removing one worker. Besides deleting the worker and startup entry,
|
|
484
787
|
/// prune routing references that would otherwise point at the removed worker.
|
|
485
|
-
fn spec_without_agent(spec: &YamlValue, agent_id: &AgentId) -> YamlValue {
|
|
788
|
+
pub(crate) fn spec_without_agent(spec: &YamlValue, agent_id: &AgentId) -> YamlValue {
|
|
486
789
|
let YamlValue::Map(pairs) = spec else {
|
|
487
790
|
return spec.clone();
|
|
488
791
|
};
|
|
@@ -648,6 +951,45 @@ fn dynamic_role_file_path(
|
|
|
648
951
|
.join(format!("{}.md", agent_id.as_str()))
|
|
649
952
|
}
|
|
650
953
|
|
|
954
|
+
/// Resolve a deletable role artifact. `dynamic_role_file` may point at an
|
|
955
|
+
/// external `--role-file`; only canonical children of the runtime-managed
|
|
956
|
+
/// directory belong to remove/rollback. A symlink escape is external.
|
|
957
|
+
fn managed_dynamic_role_file_path(
|
|
958
|
+
workspace: &Path,
|
|
959
|
+
state: &serde_json::Value,
|
|
960
|
+
agent_id: &AgentId,
|
|
961
|
+
) -> Result<Option<std::path::PathBuf>, LifecycleError> {
|
|
962
|
+
let path = dynamic_role_file_path(workspace, state, agent_id);
|
|
963
|
+
let managed_root = workspace.join(".team").join("dynamic-role-files");
|
|
964
|
+
let canonical_root = match std::fs::canonicalize(&managed_root) {
|
|
965
|
+
Ok(path) => path,
|
|
966
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
967
|
+
return Ok(path.starts_with(&managed_root).then_some(path));
|
|
968
|
+
}
|
|
969
|
+
Err(error) => {
|
|
970
|
+
return Err(LifecycleError::StatePersist(format!(
|
|
971
|
+
"resolve managed role root {}: {error}",
|
|
972
|
+
managed_root.display()
|
|
973
|
+
)))
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
let canonical_path = match std::fs::canonicalize(&path) {
|
|
977
|
+
Ok(path) => path,
|
|
978
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
979
|
+
return Ok(path.starts_with(&managed_root).then_some(path));
|
|
980
|
+
}
|
|
981
|
+
Err(error) => {
|
|
982
|
+
return Err(LifecycleError::StatePersist(format!(
|
|
983
|
+
"resolve role file {}: {error}",
|
|
984
|
+
path.display()
|
|
985
|
+
)))
|
|
986
|
+
}
|
|
987
|
+
};
|
|
988
|
+
Ok(canonical_path
|
|
989
|
+
.starts_with(&canonical_root)
|
|
990
|
+
.then_some(canonical_path))
|
|
991
|
+
}
|
|
992
|
+
|
|
651
993
|
fn has_recorded_dynamic_role_file(state: &serde_json::Value, agent_id: &AgentId) -> bool {
|
|
652
994
|
state
|
|
653
995
|
.get("agents")
|
|
@@ -657,15 +999,19 @@ fn has_recorded_dynamic_role_file(state: &serde_json::Value, agent_id: &AgentId)
|
|
|
657
999
|
.is_some_and(|s| !s.is_empty())
|
|
658
1000
|
}
|
|
659
1001
|
|
|
660
|
-
fn delete_agent_health(
|
|
1002
|
+
fn delete_agent_health(
|
|
1003
|
+
workspace: &Path,
|
|
1004
|
+
owner_team_id: &str,
|
|
1005
|
+
agent_id: &AgentId,
|
|
1006
|
+
) -> Result<bool, LifecycleError> {
|
|
661
1007
|
let store = crate::message_store::MessageStore::open(workspace)
|
|
662
1008
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
663
1009
|
let conn = crate::db::schema::open_db(store.db_path())
|
|
664
1010
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
665
1011
|
let changed = conn
|
|
666
1012
|
.execute(
|
|
667
|
-
"delete from agent_health where agent_id = ?
|
|
668
|
-
[agent_id.as_str()],
|
|
1013
|
+
"delete from agent_health where owner_team_id = ?1 and agent_id = ?2",
|
|
1014
|
+
rusqlite::params![owner_team_id, agent_id.as_str()],
|
|
669
1015
|
)
|
|
670
1016
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
671
1017
|
Ok(changed > 0)
|
|
@@ -682,18 +1028,20 @@ use crate::db::agent_health_capture::{
|
|
|
682
1028
|
|
|
683
1029
|
fn select_agent_health(
|
|
684
1030
|
workspace: &Path,
|
|
1031
|
+
owner_team_id: &str,
|
|
685
1032
|
agent_id: &AgentId,
|
|
686
1033
|
) -> Result<Option<CapturedHealth>, LifecycleError> {
|
|
687
|
-
capture_select_agent_health(workspace, agent_id)
|
|
1034
|
+
capture_select_agent_health(workspace, owner_team_id, agent_id)
|
|
688
1035
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))
|
|
689
1036
|
}
|
|
690
1037
|
|
|
691
1038
|
fn restore_agent_health(
|
|
692
1039
|
workspace: &Path,
|
|
1040
|
+
owner_team_id: &str,
|
|
693
1041
|
agent_id: &AgentId,
|
|
694
1042
|
row: &Option<CapturedHealth>,
|
|
695
1043
|
) -> Result<(), LifecycleError> {
|
|
696
|
-
capture_restore_agent_health(workspace, agent_id, row)
|
|
1044
|
+
capture_restore_agent_health(workspace, owner_team_id, agent_id, row)
|
|
697
1045
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))
|
|
698
1046
|
}
|
|
699
1047
|
|
|
@@ -711,12 +1059,13 @@ fn maybe_fail_remove_after_agent_health_delete() -> Result<(), LifecycleError> {
|
|
|
711
1059
|
|
|
712
1060
|
struct RemoveRollback {
|
|
713
1061
|
agent_id: AgentId,
|
|
1062
|
+
team_key: String,
|
|
714
1063
|
spec_text: Option<String>,
|
|
715
1064
|
state: serde_json::Value,
|
|
716
1065
|
team_state_text: Option<String>,
|
|
717
1066
|
team_state_path: std::path::PathBuf,
|
|
718
1067
|
dynamic_role_bytes: Option<Vec<u8>>,
|
|
719
|
-
dynamic_role_path: std::path::PathBuf
|
|
1068
|
+
dynamic_role_path: Option<std::path::PathBuf>,
|
|
720
1069
|
/// golden agents.py:185: the agent_health row captured BEFORE delete, re-upserted on rollback.
|
|
721
1070
|
health: Option<CapturedHealth>,
|
|
722
1071
|
restore_running: bool,
|
|
@@ -728,6 +1077,7 @@ impl RemoveRollback {
|
|
|
728
1077
|
spec_workspace: &Path,
|
|
729
1078
|
spec: &YamlValue,
|
|
730
1079
|
state: &serde_json::Value,
|
|
1080
|
+
team_key: &str,
|
|
731
1081
|
agent_id: &AgentId,
|
|
732
1082
|
) -> Result<Self, LifecycleError> {
|
|
733
1083
|
let spec_path = spec_workspace.join("team.spec.yaml");
|
|
@@ -751,15 +1101,19 @@ impl RemoveRollback {
|
|
|
751
1101
|
)))
|
|
752
1102
|
}
|
|
753
1103
|
};
|
|
754
|
-
let dynamic_role_path =
|
|
755
|
-
let dynamic_role_bytes = match
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
1104
|
+
let dynamic_role_path = managed_dynamic_role_file_path(workspace, state, agent_id)?;
|
|
1105
|
+
let dynamic_role_bytes = match dynamic_role_path.as_deref() {
|
|
1106
|
+
Some(path) => match std::fs::read(path) {
|
|
1107
|
+
Ok(bytes) => Some(bytes),
|
|
1108
|
+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
|
1109
|
+
Err(e) => return Err(LifecycleError::StatePersist(format!("read role file: {e}"))),
|
|
1110
|
+
},
|
|
1111
|
+
None => None,
|
|
759
1112
|
};
|
|
760
|
-
let health = select_agent_health(workspace, agent_id)?;
|
|
1113
|
+
let health = select_agent_health(workspace, team_key, agent_id)?;
|
|
761
1114
|
Ok(Self {
|
|
762
1115
|
agent_id: agent_id.clone(),
|
|
1116
|
+
team_key: team_key.to_string(),
|
|
763
1117
|
spec_text,
|
|
764
1118
|
state: state.clone(),
|
|
765
1119
|
team_state_text,
|
|
@@ -793,7 +1147,13 @@ impl RemoveRollback {
|
|
|
793
1147
|
}
|
|
794
1148
|
}
|
|
795
1149
|
// workspace_state
|
|
796
|
-
if let Err(e) = crate::state::
|
|
1150
|
+
if let Err(e) = crate::state::repository::StateRepository::new(workspace).save(
|
|
1151
|
+
crate::state::repository::StateWriteIntent::ForceRecreateRollback {
|
|
1152
|
+
team_key: &self.team_key,
|
|
1153
|
+
agent_id: self.agent_id.as_str(),
|
|
1154
|
+
},
|
|
1155
|
+
&self.state,
|
|
1156
|
+
) {
|
|
797
1157
|
errors.push(format!("workspace_state:{e}"));
|
|
798
1158
|
}
|
|
799
1159
|
// team_state
|
|
@@ -814,27 +1174,23 @@ impl RemoveRollback {
|
|
|
814
1174
|
errors.push(format!("team_state:{e}"));
|
|
815
1175
|
}
|
|
816
1176
|
// role_file
|
|
817
|
-
let role_file_result = match &self.dynamic_role_bytes {
|
|
818
|
-
Some(bytes) => {
|
|
819
|
-
if let Some(parent) =
|
|
1177
|
+
let role_file_result = match (&self.dynamic_role_path, &self.dynamic_role_bytes) {
|
|
1178
|
+
(Some(path), Some(bytes)) => {
|
|
1179
|
+
if let Some(parent) = path.parent() {
|
|
820
1180
|
let _ = std::fs::create_dir_all(parent);
|
|
821
1181
|
}
|
|
822
|
-
std::fs::write(
|
|
1182
|
+
std::fs::write(path, bytes)
|
|
823
1183
|
}
|
|
824
|
-
None => match std::fs::remove_file(
|
|
1184
|
+
(Some(path), None) => match std::fs::remove_file(path) {
|
|
825
1185
|
Ok(()) => Ok(()),
|
|
826
1186
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
827
1187
|
Err(e) => Err(e),
|
|
828
1188
|
},
|
|
1189
|
+
(None, _) => Ok(()),
|
|
829
1190
|
};
|
|
830
1191
|
if let Err(e) = role_file_result {
|
|
831
1192
|
errors.push(format!("role_file:{e}"));
|
|
832
1193
|
}
|
|
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
1194
|
if self.restore_running && errors.is_empty() {
|
|
839
1195
|
if let Err(e) = start_agent_at_paths(
|
|
840
1196
|
workspace,
|
|
@@ -849,6 +1205,13 @@ impl RemoveRollback {
|
|
|
849
1205
|
errors.push(format!("worker_restore:{e}"));
|
|
850
1206
|
}
|
|
851
1207
|
}
|
|
1208
|
+
// Starting a replacement cohort intentionally clears stale health, so
|
|
1209
|
+
// rollback must restore the captured row after the old seat is back.
|
|
1210
|
+
if let Err(e) =
|
|
1211
|
+
restore_agent_health(workspace, &self.team_key, &self.agent_id, &self.health)
|
|
1212
|
+
{
|
|
1213
|
+
errors.push(format!("agent_health:{e}"));
|
|
1214
|
+
}
|
|
852
1215
|
errors
|
|
853
1216
|
}
|
|
854
1217
|
}
|