@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.
Files changed (93) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +5 -3
  4. package/crates/team-agent/src/cli/emit.rs +2 -1
  5. package/crates/team-agent/src/cli/mod.rs +32 -14
  6. package/crates/team-agent/src/cli/named_address.rs +46 -115
  7. package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
  8. package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
  9. package/crates/team-agent/src/cli/send/persist.rs +154 -0
  10. package/crates/team-agent/src/cli/send/presentation.rs +333 -0
  11. package/crates/team-agent/src/cli/send/resolve.rs +361 -0
  12. package/crates/team-agent/src/cli/send.rs +44 -1169
  13. package/crates/team-agent/src/cli/spec.rs +1 -1
  14. package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
  15. package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
  16. package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
  17. package/crates/team-agent/src/cli/status_port/format.rs +145 -0
  18. package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
  19. package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
  20. package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
  21. package/crates/team-agent/src/cli/status_port/store.rs +412 -0
  22. package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
  23. package/crates/team-agent/src/cli/status_port.rs +47 -1548
  24. package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
  25. package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -0
  26. package/crates/team-agent/src/cli/types.rs +1 -0
  27. package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
  28. package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
  29. package/crates/team-agent/src/coordinator/tick.rs +37 -0
  30. package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
  31. package/crates/team-agent/src/db/message_store.rs +154 -44
  32. package/crates/team-agent/src/event_log.rs +73 -0
  33. package/crates/team-agent/src/leader/lease.rs +202 -16
  34. package/crates/team-agent/src/leader/start.rs +28 -4
  35. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
  36. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +303 -0
  37. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
  38. package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
  39. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
  40. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
  41. package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
  42. package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
  43. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
  44. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
  45. package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
  46. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
  47. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
  48. package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
  49. package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
  50. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
  51. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
  52. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
  53. package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
  54. package/crates/team-agent/src/lifecycle/restart/agent.rs +63 -29
  55. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
  56. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +167 -27
  57. package/crates/team-agent/src/lifecycle/restart/remove.rs +503 -77
  58. package/crates/team-agent/src/lifecycle/restart.rs +26 -2
  59. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +906 -17
  60. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +67 -2
  61. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
  62. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
  63. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
  64. package/crates/team-agent/src/mcp_server/mod.rs +1 -1
  65. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -10
  66. package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
  67. package/crates/team-agent/src/mcp_server/tests.rs +11 -4
  68. package/crates/team-agent/src/mcp_server/tools.rs +14 -5
  69. package/crates/team-agent/src/messaging/activity.rs +4 -2
  70. package/crates/team-agent/src/messaging/address.rs +86 -0
  71. package/crates/team-agent/src/messaging/delivery.rs +288 -183
  72. package/crates/team-agent/src/messaging/helpers.rs +17 -13
  73. package/crates/team-agent/src/messaging/leader_channel.rs +171 -0
  74. package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
  75. package/crates/team-agent/src/messaging/mod.rs +15 -2
  76. package/crates/team-agent/src/messaging/persist.rs +309 -0
  77. package/crates/team-agent/src/messaging/results.rs +16 -24
  78. package/crates/team-agent/src/messaging/scheduler.rs +4 -2
  79. package/crates/team-agent/src/messaging/selftest.rs +19 -12
  80. package/crates/team-agent/src/messaging/send.rs +101 -49
  81. package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
  82. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
  83. package/crates/team-agent/src/messaging/tests/mod.rs +2 -0
  84. package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
  85. package/crates/team-agent/src/messaging/watchers.rs +13 -3
  86. package/crates/team-agent/src/redaction.rs +72 -2
  87. package/crates/team-agent/src/state/persist.rs +284 -21
  88. package/crates/team-agent/src/state/projection.rs +245 -30
  89. package/crates/team-agent/src/state/repository/intent.rs +13 -0
  90. package/crates/team-agent/src/state/repository/tests.rs +47 -0
  91. package/crates/team-agent/src/state/repository.rs +94 -27
  92. package/crates/team-agent/src/state/selector.rs +9 -18
  93. 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 = lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
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 transport = lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
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
- state: serde_json::Value,
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 state = resolve_team_scoped_state_or_refuse(workspace, team)?;
99
- crate::lifecycle::launch::ensure_owner_allowed_for_state(&state, Some(agent_id))?;
100
- let spec = load_team_spec(spec_workspace)?;
101
- let Some(spec_agent) = find_spec_agent(&spec, agent_id) else {
102
- return Err(unknown_worker(agent_id));
103
- };
104
- let dynamic_agent = is_dynamic_agent(&state, spec_agent, agent_id);
105
- let force_required = agent_is_running(&state, agent_id, transport);
106
- let has_session = state
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
- || state
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
- state,
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.requirements.force_required;
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
- team: Option<&str>,
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 mut working_state = state;
554
+ let working_state = state;
244
555
  let mut stopped = false;
245
556
  let mut cleared_locations = Vec::new();
246
- if force && agent_is_running(&working_state, agent_id, transport) {
247
- let stop = stop_agent_at_paths(
248
- paths.run_workspace,
249
- paths.spec_workspace,
250
- agent_id,
251
- team,
252
- transport,
253
- )?;
254
- stopped = stop.stopped;
255
- write_remove_step_event(
256
- paths.run_workspace,
257
- agent_id,
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 = dynamic_role_file_path(paths.run_workspace, &working_state, agent_id);
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
- crate::state::projection::save_team_scoped_state_with_deleted_agents(
271
- paths.run_workspace,
272
- &removed_state,
273
- &[agent_id.as_str()],
274
- )
275
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
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 = remove_dynamic_role_file(&dynamic_role_path, dynamic_role_required)?;
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(workspace: &Path, agent_id: &AgentId) -> Result<bool, LifecycleError> {
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 = ?1",
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 = dynamic_role_file_path(workspace, state, agent_id);
755
- let dynamic_role_bytes = match std::fs::read(&dynamic_role_path) {
756
- Ok(bytes) => Some(bytes),
757
- Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
758
- Err(e) => return Err(LifecycleError::StatePersist(format!("read role file: {e}"))),
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::persist::save_runtime_state(workspace, &self.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) = self.dynamic_role_path.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(&self.dynamic_role_path, bytes)
1245
+ std::fs::write(path, bytes)
823
1246
  }
824
- None => match std::fs::remove_file(&self.dynamic_role_path) {
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
  }