@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.
Files changed (84) 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 +11 -48
  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/run_delegation.rs +1 -0
  25. package/crates/team-agent/src/cli/types.rs +1 -0
  26. package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
  27. package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
  28. package/crates/team-agent/src/coordinator/tick.rs +37 -0
  29. package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
  30. package/crates/team-agent/src/db/message_store.rs +154 -44
  31. package/crates/team-agent/src/event_log.rs +73 -0
  32. package/crates/team-agent/src/leader/start.rs +28 -4
  33. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
  34. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +297 -0
  35. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
  36. package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
  37. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
  38. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
  39. package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
  40. package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
  41. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
  42. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
  43. package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
  44. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
  45. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
  46. package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
  47. package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
  48. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
  49. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
  50. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
  51. package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
  52. package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -26
  53. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
  54. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +67 -26
  55. package/crates/team-agent/src/lifecycle/restart/remove.rs +435 -72
  56. package/crates/team-agent/src/lifecycle/restart.rs +1 -1
  57. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +575 -17
  58. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +55 -2
  59. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
  60. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
  61. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
  62. package/crates/team-agent/src/mcp_server/mod.rs +1 -1
  63. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -10
  64. package/crates/team-agent/src/mcp_server/tools.rs +14 -5
  65. package/crates/team-agent/src/messaging/activity.rs +4 -2
  66. package/crates/team-agent/src/messaging/address.rs +86 -0
  67. package/crates/team-agent/src/messaging/delivery.rs +165 -39
  68. package/crates/team-agent/src/messaging/helpers.rs +17 -13
  69. package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
  70. package/crates/team-agent/src/messaging/mod.rs +10 -2
  71. package/crates/team-agent/src/messaging/persist.rs +309 -0
  72. package/crates/team-agent/src/messaging/results.rs +16 -24
  73. package/crates/team-agent/src/messaging/scheduler.rs +4 -2
  74. package/crates/team-agent/src/messaging/selftest.rs +19 -12
  75. package/crates/team-agent/src/messaging/send.rs +101 -49
  76. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
  77. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  78. package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
  79. package/crates/team-agent/src/messaging/watchers.rs +13 -3
  80. package/crates/team-agent/src/redaction.rs +72 -2
  81. package/crates/team-agent/src/state/persist.rs +2 -1
  82. package/crates/team-agent/src/state/repository/tests.rs +47 -0
  83. package/crates/team-agent/src/state/repository.rs +59 -16
  84. 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
- state: serde_json::Value,
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 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
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
- || state
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
- state,
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.requirements.force_required;
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
- team: Option<&str>,
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 mut working_state = state;
544
+ let working_state = state;
244
545
  let mut stopped = false;
245
546
  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)?;
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 = dynamic_role_file_path(paths.run_workspace, &working_state, agent_id);
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::projection::save_team_scoped_state_with_deleted_agents(
271
- paths.run_workspace,
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 = remove_dynamic_role_file(&dynamic_role_path, dynamic_role_required)?;
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(workspace: &Path, agent_id: &AgentId) -> Result<bool, LifecycleError> {
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 = ?1",
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 = 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}"))),
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::persist::save_runtime_state(workspace, &self.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) = self.dynamic_role_path.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(&self.dynamic_role_path, bytes)
1182
+ std::fs::write(path, bytes)
823
1183
  }
824
- None => match std::fs::remove_file(&self.dynamic_role_path) {
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
  }