@team-agent/installer 0.5.119 → 0.5.121

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 (73) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +46 -1
  4. package/README.zh.md +46 -1
  5. package/crates/team-agent/src/cli/adapters.rs +11 -0
  6. package/crates/team-agent/src/cli/arguments.rs +401 -0
  7. package/crates/team-agent/src/cli/emit.rs +241 -142
  8. package/crates/team-agent/src/cli/intent/runtime.rs +1175 -0
  9. package/crates/team-agent/src/cli/intent/runtime_tests.rs +698 -0
  10. package/crates/team-agent/src/cli/intent/tests.rs +654 -0
  11. package/crates/team-agent/src/cli/intent.rs +642 -0
  12. package/crates/team-agent/src/cli/mod.rs +53 -6
  13. package/crates/team-agent/src/cli/named_address.rs +10 -1
  14. package/crates/team-agent/src/cli/spec.rs +22 -10
  15. package/crates/team-agent/src/cli/status_port/brief.rs +117 -16
  16. package/crates/team-agent/src/cli/tests/base.rs +6 -1
  17. package/crates/team-agent/src/cli/tests/parser_safety.rs +383 -0
  18. package/crates/team-agent/src/cli/tests/run_delegation.rs +63 -19
  19. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +56 -0
  20. package/crates/team-agent/src/cli/tests/verb_install_skill.rs +3 -2
  21. package/crates/team-agent/src/cli/tests/verb_profile.rs +3 -1
  22. package/crates/team-agent/src/contract_runtime/backend.rs +1 -1
  23. package/crates/team-agent/src/db/message_store.rs +65 -0
  24. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +8 -0
  25. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +111 -4
  26. package/crates/team-agent/src/lifecycle/launch/cursor_mcp_iso.rs +244 -82
  27. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +166 -77
  28. package/crates/team-agent/src/lifecycle/launch.rs +29 -0
  29. package/crates/team-agent/src/lifecycle/restart/common.rs +7 -1
  30. package/crates/team-agent/src/lifecycle/restart.rs +17 -0
  31. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +1 -3
  32. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +5 -15
  33. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +2 -3
  34. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +2 -3
  35. package/crates/team-agent/src/lifecycle/tests/cli_argv_routing_red.rs +10 -2
  36. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +8 -1
  37. package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +3 -2
  38. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +6 -9
  39. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +7 -5
  40. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +64 -19
  41. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +5 -2
  42. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +34 -10
  43. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +2 -4
  44. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +3 -4
  45. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +5 -4
  46. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +2 -4
  47. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +78 -36
  48. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +8 -4
  49. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +6 -12
  50. package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +4 -0
  51. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +1 -3
  52. package/crates/team-agent/src/lifecycle/tests/status_credential_redaction_contract.rs +7 -0
  53. package/crates/team-agent/src/lifecycle/tests/subprep_codex_trust_roundtrip_red.rs +47 -4
  54. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +1 -3
  55. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +2 -1
  56. package/crates/team-agent/src/lifecycle/types.rs +10 -0
  57. package/crates/team-agent/src/mcp_server/mod.rs +2 -0
  58. package/crates/team-agent/src/mcp_server/readiness.rs +432 -0
  59. package/crates/team-agent/src/mcp_server/readiness_tests.rs +242 -0
  60. package/crates/team-agent/src/mcp_server/wire.rs +96 -11
  61. package/crates/team-agent/src/messaging/delivery.rs +62 -0
  62. package/crates/team-agent/src/messaging/tests/dup_inject.rs +41 -1
  63. package/crates/team-agent/src/provider/adapter.rs +7 -0
  64. package/crates/team-agent/src/tmux_backend/tests.rs +37 -0
  65. package/crates/team-agent/src/tmux_backend.rs +8 -1
  66. package/package.json +4 -4
  67. package/skills/team-agent/SKILL.md +71 -54
  68. package/skills/team-agent/command-coverage.json +283 -13
  69. package/skills/team-agent/docs/reference/team-agent-operator.md +220 -0
  70. package/skills/team-agent/references/bug-as-artifact-flow.md +1 -1
  71. package/skills/team-agent/references/recovery-runbook.md +61 -212
  72. package/skills/team-agent/references/team-agent-operator.md +181 -48
  73. package/skills/team-agent/references/team-in-team.md +47 -80
@@ -0,0 +1,1175 @@
1
+ //! Read-only planning; execution remains in the original typed lifecycle ports.
2
+ use super::*;
3
+ use crate::model::yaml::{self, Value as Yaml};
4
+ use crate::state::selector::{resolve_active_team_readonly, SelectedTeam, SelectorMode};
5
+ use crate::transport::{PaneId, Target, Transport};
6
+ use std::collections::BTreeSet;
7
+ #[cfg(test)]
8
+ #[path = "runtime_tests.rs"]
9
+ mod tests;
10
+
11
+ fn checked<T, E: std::fmt::Display>(result: Result<T, E>) -> Result<T, CliError> {
12
+ result.map_err(|e| refusal(e.to_string()))
13
+ }
14
+ fn complete(path: &Path) -> bool {
15
+ path.join("TEAM.md").is_file() && path.join("agents").is_dir()
16
+ }
17
+ fn discover(
18
+ workspace: &Path,
19
+ explicit: Option<&Path>,
20
+ selected: Option<&SelectedTeam>,
21
+ ) -> Result<PathBuf, CliError> {
22
+ if let Some(path) = explicit {
23
+ return if complete(path) {
24
+ Ok(path.to_owned())
25
+ } else {
26
+ Err(usage("source requires TEAM.md and agents/"))
27
+ };
28
+ }
29
+ if let Some(selected) = selected {
30
+ return if complete(&selected.team_dir) {
31
+ Ok(selected.team_dir.clone())
32
+ } else {
33
+ Err(refusal(
34
+ "selected role source is incomplete; repair it before start",
35
+ ))
36
+ };
37
+ }
38
+ if complete(workspace) {
39
+ return Ok(workspace.to_owned());
40
+ }
41
+ let dir = workspace.join(".team");
42
+ let mut candidates = BTreeSet::new();
43
+ if dir.is_dir() {
44
+ for entry in std::fs::read_dir(dir)? {
45
+ let path = entry?.path();
46
+ if path.is_dir() && complete(&path) {
47
+ candidates.insert(path.canonicalize()?);
48
+ }
49
+ }
50
+ }
51
+ if candidates.is_empty() {
52
+ return Err(usage(format!(
53
+ "action_required: prepare {}/TEAM.md and {}/agents/<id>.md, then use --from TEAMDIR. Nothing was written or started. Role template (fill your decisions): provider: <choose>; dangerously_skip_permissions: <true|false>; model/effort: <optional>.",
54
+ workspace.display(), workspace.display()
55
+ )));
56
+ }
57
+ if candidates.len() != 1 {
58
+ return Err(usage(format!(
59
+ "action_required: multiple complete team sources {candidates:?}; supply --from TEAMDIR"
60
+ )));
61
+ }
62
+ candidates
63
+ .into_iter()
64
+ .next()
65
+ .ok_or_else(|| usage("missing team source"))
66
+ }
67
+ fn validate_roster(spec: &Yaml, directory: &Path) -> Result<(), CliError> {
68
+ checked(crate::model::spec::validate_spec(spec, directory))?;
69
+ if let Some(agents) = spec.get("agents").and_then(Yaml::as_list) {
70
+ for agent in agents {
71
+ if let Some(id) = agent.get("id").and_then(Yaml::as_str) {
72
+ logical_agent_id(id)
73
+ .map_err(|_| refusal("invalid agent identity in selected roster"))?;
74
+ }
75
+ }
76
+ }
77
+ Ok(())
78
+ }
79
+ fn remember(plan: &mut Plan, path: &Path) -> Result<(), CliError> {
80
+ if !plan.files.iter().any(|(p, _)| p == path) {
81
+ plan.files.push((path.to_owned(), std::fs::read(path)?));
82
+ }
83
+ Ok(())
84
+ }
85
+ fn json_yaml(value: &Yaml) -> Value {
86
+ match value {
87
+ Yaml::Null => Value::Null,
88
+ Yaml::Bool(b) => json!(b),
89
+ Yaml::Int(n) => json!(n),
90
+ Yaml::Float(n) => json!(n),
91
+ Yaml::Str(s) => json!(s),
92
+ Yaml::List(values) => Value::Array(values.iter().map(json_yaml).collect()),
93
+ Yaml::Map(values) => Value::Object(
94
+ values
95
+ .iter()
96
+ .map(|(k, v)| (k.clone(), json_yaml(v)))
97
+ .collect(),
98
+ ),
99
+ }
100
+ }
101
+ fn role(
102
+ plan: &mut Plan,
103
+ selected: &SelectedTeam,
104
+ id: &str,
105
+ source: Option<&Path>,
106
+ ) -> Result<(PathBuf, Value), CliError> {
107
+ let path = match source {
108
+ Some(path) => path.to_owned(),
109
+ None => checked(crate::lifecycle::launch::role_source::resolve_role_source(
110
+ &selected.run_workspace,
111
+ &selected.team_dir,
112
+ &selected.state,
113
+ &AgentId::new(id),
114
+ ))?,
115
+ };
116
+ let team_path = selected.team_dir.join("TEAM.md");
117
+ remember(plan, &team_path)?;
118
+ remember(plan, &path)?;
119
+ let (team, _) = checked(crate::compiler::read_front_matter(&team_path))?;
120
+ let compiled = checked(crate::compiler::compile_role_agent(
121
+ &path,
122
+ &team,
123
+ &selected.run_workspace.to_string_lossy(),
124
+ ))?;
125
+ if compiled.id != id {
126
+ return Err(refusal(format!("role identity mismatch for {id}")));
127
+ }
128
+ Ok((path, json_yaml(&compiled.agent)))
129
+ }
130
+ fn provider(row: &Value) -> &str {
131
+ row.get("provider").and_then(Value::as_str).unwrap_or("pi")
132
+ }
133
+ fn topology(state: &Value) -> Value {
134
+ let mut owner = serde_json::Map::new();
135
+ for key in ["pane_id", "provider", "owner_epoch", "machine_fingerprint"] {
136
+ owner.insert(
137
+ key.into(),
138
+ state
139
+ .get("team_owner")
140
+ .and_then(|o| o.get(key))
141
+ .cloned()
142
+ .unwrap_or(Value::Null),
143
+ );
144
+ }
145
+ json!({"tmux_socket": state.get("tmux_socket"), "transport": state.get("transport"), "owner": owner, "owner_epoch": state.get("owner_epoch")})
146
+ }
147
+ fn relevant(row: &Value) -> Value {
148
+ let mut object = serde_json::Map::new();
149
+ for key in [
150
+ "provider",
151
+ "status",
152
+ "explicit_stop",
153
+ "pane_id",
154
+ "pane_pid",
155
+ "window",
156
+ "session_id",
157
+ "contract_instance",
158
+ "generation",
159
+ "dynamic_role_file",
160
+ "owner_team_id",
161
+ "spawn_epoch",
162
+ "spawned_at",
163
+ ] {
164
+ object.insert(key.into(), row.get(key).cloned().unwrap_or(Value::Null));
165
+ }
166
+ Value::Object(object)
167
+ }
168
+ fn same_provider(left: &str, right: &str) -> bool {
169
+ match (
170
+ crate::provider::wire::parse_provider(left),
171
+ crate::provider::wire::parse_provider(right),
172
+ ) {
173
+ (Some(left), Some(right)) => left == right,
174
+ _ => left == right,
175
+ }
176
+ }
177
+ fn drift(row: &Value, compiled: &Value) -> bool {
178
+ !same_provider(provider(row), provider(compiled))
179
+ || row
180
+ .get("as_launched_dangerously_skip_permissions")
181
+ .and_then(Value::as_bool)
182
+ .zip(
183
+ compiled
184
+ .get("dangerously_skip_permissions")
185
+ .and_then(Value::as_bool),
186
+ )
187
+ .is_some_and(|(old, new)| old != new)
188
+ || row
189
+ .get("model")
190
+ .filter(|v| !v.is_null())
191
+ .zip(compiled.get("model").filter(|v| !v.is_null()))
192
+ .is_some_and(|(old, new)| old != new)
193
+ }
194
+ #[cfg(unix)]
195
+ fn native_settings_changed(
196
+ old: &crate::contract_runtime::config::RoleConfig,
197
+ current: &crate::contract_runtime::config::RoleConfig,
198
+ ) -> bool {
199
+ old.provider != current.provider
200
+ || old.model != current.model
201
+ || old.effort != current.effort
202
+ || old.bypass != current.bypass
203
+ || old.paused != current.paused
204
+ }
205
+
206
+ fn check_native_noop_settings(
207
+ selected: &SelectedTeam,
208
+ id: &str,
209
+ row: &Value,
210
+ ) -> Result<(), CliError> {
211
+ #[cfg(unix)]
212
+ if crate::contract_runtime::framework::is_contract(row) {
213
+ use team_agent_contract::contract::types::SeatId;
214
+ let backend = checked(crate::contract_runtime::backend::Backend::open_readonly(
215
+ &selected.run_workspace,
216
+ &selected.team_key,
217
+ ))?;
218
+ let seat = checked(backend.store.seat(&checked(SeatId::new(id))?))?
219
+ .ok_or_else(|| refusal("missing native snapshot identity"))?;
220
+ if row.get("contract_instance").and_then(Value::as_str)
221
+ != Some(seat.identity.instance.as_str())
222
+ || row.get("generation").and_then(Value::as_u64) != Some(seat.identity.generation.0)
223
+ {
224
+ return Err(refusal("native incarnation changed during no-op planning"));
225
+ }
226
+ let old = checked(crate::contract_runtime::backend::read_role(
227
+ &backend.binding,
228
+ &seat.identity,
229
+ ))?;
230
+ let config = checked(crate::contract_runtime::framework::team_config(
231
+ &selected.run_workspace,
232
+ &selected.team_dir,
233
+ &selected.team_key,
234
+ ))?
235
+ .ok_or_else(|| refusal("missing native configuration"))?;
236
+ let current = config
237
+ .roles
238
+ .iter()
239
+ .find(|role| role.id.as_str() == id)
240
+ .ok_or_else(|| refusal("native role no longer configured"))?;
241
+ if native_settings_changed(&old, current) {
242
+ return Err(refusal(format!(
243
+ "native launch configuration drift for {id}; stop before applying settings"
244
+ )));
245
+ }
246
+ }
247
+ Ok(())
248
+ }
249
+
250
+ fn paused(row: &Value) -> bool {
251
+ row.get("status")
252
+ .and_then(Value::as_str)
253
+ .is_some_and(|status| status.eq_ignore_ascii_case("paused"))
254
+ }
255
+
256
+ #[cfg(unix)]
257
+ fn native_observation(
258
+ status: team_agent_contract::orchestration::store::SeatStatus,
259
+ process: team_agent_contract::host::process::ProcessState,
260
+ root_status: Option<&str>,
261
+ ) -> Observation {
262
+ use team_agent_contract::{host::process::ProcessState, orchestration::store::SeatStatus};
263
+ match (status, process) {
264
+ (SeatStatus::Ready, ProcessState::Alive) if root_status == Some("running") => {
265
+ Observation::Live
266
+ }
267
+ (SeatStatus::Ready, ProcessState::Exited) => Observation::StoppedVerified,
268
+ (SeatStatus::Stopped, ProcessState::Exited) if root_status == Some("stopped") => {
269
+ Observation::StoppedVerified
270
+ }
271
+ _ => Observation::Unknown,
272
+ }
273
+ }
274
+
275
+ fn shell_carrier(command: &str) -> bool {
276
+ matches!(command, "sh" | "bash" | "zsh" | "fish")
277
+ }
278
+ fn surface_liveness(command: &str, native_frame: bool, live_child: bool) -> bool {
279
+ !command.is_empty() && native_frame && (!shell_carrier(command) || live_child)
280
+ }
281
+
282
+ /// Positive native/store or exact carrier+pane+provider observations only.
283
+ fn observe(selected: &SelectedTeam, id: &str, row: &Value) -> Result<Observation, CliError> {
284
+ if !row.is_object()
285
+ || row
286
+ .get("provider")
287
+ .is_some_and(|value| !value.is_null() && !value.is_string())
288
+ {
289
+ return Err(refusal("invalid persisted provider metadata"));
290
+ }
291
+ #[cfg(unix)]
292
+ if crate::contract_runtime::framework::is_contract(row) {
293
+ use team_agent_contract::{contract::types::SeatId, host::process::sample_process};
294
+ if crate::contract_runtime::framework::is_paused_unstarted(row) {
295
+ return Ok(Observation::Unstarted);
296
+ }
297
+ let backend = checked(crate::contract_runtime::backend::Backend::open_readonly(
298
+ &selected.run_workspace,
299
+ &selected.team_key,
300
+ ))?;
301
+ let seat = checked(backend.store.seat(&checked(SeatId::new(id))?))?
302
+ .ok_or_else(|| refusal("missing canonical native seat"))?;
303
+ if provider(row) != seat.provider.as_str()
304
+ || row.get("contract_scope").and_then(Value::as_str)
305
+ != Some(seat.identity.scope.as_str())
306
+ || row.get("contract_instance").and_then(Value::as_str)
307
+ != Some(seat.identity.instance.as_str())
308
+ || row.get("generation").and_then(Value::as_u64) != Some(seat.identity.generation.0)
309
+ {
310
+ return Ok(Observation::Unknown);
311
+ }
312
+ let Some(target) = seat.physical.as_ref() else {
313
+ return Ok(Observation::Unknown);
314
+ };
315
+ return Ok(native_observation(
316
+ seat.status,
317
+ sample_process(&target.process),
318
+ row.get("status").and_then(Value::as_str),
319
+ ));
320
+ }
321
+ let native = crate::provider::wire::parse_provider(provider(row))
322
+ .ok_or_else(|| refusal("unknown provider"))?;
323
+ let status = row.get("status").and_then(Value::as_str).unwrap_or("");
324
+ if !["running", "stopped", "dead"]
325
+ .iter()
326
+ .any(|known| status.eq_ignore_ascii_case(known))
327
+ {
328
+ return Ok(Observation::Unknown);
329
+ }
330
+ let Some(pid) = row
331
+ .get("pane_pid")
332
+ .and_then(Value::as_u64)
333
+ .and_then(|p| u32::try_from(p).ok())
334
+ .filter(|p| *p > 1)
335
+ else {
336
+ return Ok(Observation::Unknown);
337
+ };
338
+ match checked(crate::platform::process::pid_liveness(pid))? {
339
+ crate::platform::process::ProcessLiveness::Dead => return Ok(Observation::StoppedVerified),
340
+ crate::platform::process::ProcessLiveness::Unknown { .. } => {
341
+ return Ok(Observation::Unknown)
342
+ }
343
+ crate::platform::process::ProcessLiveness::Live => (),
344
+ }
345
+ if !status.eq_ignore_ascii_case("running") {
346
+ return Ok(Observation::Unknown);
347
+ }
348
+ #[cfg(unix)]
349
+ let carrier_birth = checked(team_agent_contract::host::process::process_birth_identity(
350
+ pid,
351
+ ))?;
352
+ let backend = checked(
353
+ crate::lifecycle::restart::lifecycle_worker_tmux_backend_selection_for_state(
354
+ &selected.run_workspace,
355
+ &selected.state,
356
+ ),
357
+ )?
358
+ .backend;
359
+ let panes = checked(backend.list_targets())?;
360
+ let pane = row.get("pane_id").and_then(Value::as_str);
361
+ let session = selected.state.get("session_name").and_then(Value::as_str);
362
+ let window = row.get("window").and_then(Value::as_str);
363
+ let matches: Vec<_> = panes
364
+ .iter()
365
+ .filter(|p| {
366
+ Some(p.pane_id.as_str()) == pane
367
+ && p.pane_pid == Some(pid)
368
+ && Some(p.session.as_str()) == session
369
+ && p.window_name.as_ref().map(|w| w.as_str()) == window
370
+ })
371
+ .collect();
372
+ if matches.len() != 1 {
373
+ return Ok(Observation::Unknown);
374
+ }
375
+ let Some(session) = session else {
376
+ return Ok(Observation::Unknown);
377
+ };
378
+ let owner = checked(backend.session_owner(&crate::transport::SessionName::new(session)))?;
379
+ let generation = crate::state::projection::session_generation(&selected.state);
380
+ if owner.as_ref().is_none_or(|owner| {
381
+ Path::new(&owner.workspace) != selected.run_workspace
382
+ || owner.team != selected.team_key
383
+ || Some(owner.generation.as_str()) != generation
384
+ }) {
385
+ return Ok(Observation::Unknown);
386
+ }
387
+ let command = matches[0].current_command.as_deref().unwrap_or("");
388
+ let positive_command = command == crate::provider::wire::command_name(native)
389
+ || crate::leader::provider_attribution::command_matches_provider(native, command);
390
+ let positive_surface = if !positive_command && !command.is_empty() {
391
+ let frame = checked(
392
+ backend.observe_native_input(&Target::Pane(PaneId::new(pane.unwrap_or(""))), native),
393
+ )?;
394
+ // Original start/restart supervises the native child with a shell carrier.
395
+ // A shell alone (including its inert post-exit tail) is never Live.
396
+ let mut live_child = false;
397
+ if frame.is_some() && shell_carrier(command) {
398
+ for child in checked(crate::platform::process::process_tree(pid))? {
399
+ if child != pid
400
+ && matches!(
401
+ checked(crate::platform::process::pid_liveness(child))?,
402
+ crate::platform::process::ProcessLiveness::Live
403
+ )
404
+ {
405
+ live_child = true;
406
+ break;
407
+ }
408
+ }
409
+ }
410
+ surface_liveness(command, frame.is_some(), live_child)
411
+ } else {
412
+ false
413
+ };
414
+ if !positive_command && !positive_surface {
415
+ return Ok(Observation::Unknown);
416
+ }
417
+ #[cfg(unix)]
418
+ if checked(team_agent_contract::host::process::process_birth_identity(
419
+ pid,
420
+ ))? != carrier_birth
421
+ {
422
+ return Ok(Observation::Unknown);
423
+ }
424
+ Ok(Observation::Live)
425
+ }
426
+ fn start_args(plan: &Plan, id: &str, patch: RoleConfigPatch) -> StartAgentArgs {
427
+ StartAgentArgs {
428
+ agent: id.into(),
429
+ workspace: plan.scope.workspace.clone(),
430
+ team: plan.scope.team.clone(),
431
+ role_config: patch,
432
+ force: false,
433
+ allow_fresh: plan.intent.session == SessionPolicy::Auto,
434
+ json: true,
435
+ }
436
+ }
437
+ fn stop_args(plan: &Plan, id: &str) -> StopAgentArgs {
438
+ StopAgentArgs {
439
+ agent: id.into(),
440
+ workspace: plan.scope.workspace.clone(),
441
+ team: plan.scope.team.clone(),
442
+ json: true,
443
+ }
444
+ }
445
+ fn check_incarnation_cwd(
446
+ selected: &SelectedTeam,
447
+ id: &str,
448
+ row: &Value,
449
+ provider: crate::provider::Provider,
450
+ ) -> Result<(), CliError> {
451
+ if let Some(cwd) = row
452
+ .get("spawn_cwd")
453
+ .and_then(Value::as_str)
454
+ .filter(|cwd| !cwd.is_empty())
455
+ {
456
+ let declared_cwd = Path::new(cwd);
457
+ let cwd = checked(declared_cwd.canonicalize())?;
458
+ let inside = cwd.starts_with(&selected.run_workspace);
459
+ #[cfg(unix)]
460
+ let owned_cursor_primary = !inside
461
+ && provider == crate::provider::Provider::CursorAgent
462
+ && checked(crate::lifecycle::launch::cursor_mcp_primary_matches_scope(
463
+ &selected.run_workspace,
464
+ &selected.team_key,
465
+ id,
466
+ declared_cwd,
467
+ ))?;
468
+ #[cfg(not(unix))]
469
+ let owned_cursor_primary = false;
470
+ if !cwd.is_dir() || !(inside || owned_cursor_primary) {
471
+ return Err(refusal("legacy incarnation cwd is outside the selected workspace; no context or scope fallback"));
472
+ }
473
+ }
474
+ Ok(())
475
+ }
476
+ fn preflight_start(
477
+ selected: &SelectedTeam,
478
+ id: &str,
479
+ row: &Value,
480
+ session_policy: SessionPolicy,
481
+ ) -> Result<(), CliError> {
482
+ #[cfg(unix)]
483
+ if crate::contract_runtime::framework::is_contract(row) {
484
+ use team_agent_contract::contract::types::SeatId;
485
+ let config = checked(crate::contract_runtime::framework::team_config(
486
+ &selected.run_workspace,
487
+ &selected.team_dir,
488
+ &selected.team_key,
489
+ ))?
490
+ .ok_or_else(|| refusal("missing native role config"))?;
491
+ let role = config
492
+ .roles
493
+ .iter()
494
+ .find(|r| r.id.as_str() == id)
495
+ .ok_or_else(|| refusal("missing native role"))?;
496
+ checked(crate::contract_runtime::backend::prepare_resume(
497
+ &selected.run_workspace,
498
+ &selected.team_key,
499
+ &checked(SeatId::new(id))?,
500
+ Some(role),
501
+ ))?;
502
+ return Ok(());
503
+ }
504
+ let provider = crate::provider::wire::parse_provider(provider(row))
505
+ .ok_or_else(|| refusal("unknown provider before restart"))?;
506
+ for field in ["session_id", "rollout_path", "last_result_at", "spawn_cwd"] {
507
+ if row
508
+ .get(field)
509
+ .is_some_and(|value| !value.is_null() && !value.is_string())
510
+ {
511
+ return Err(refusal(format!("invalid resume metadata: {field}")));
512
+ }
513
+ }
514
+ if crate::lifecycle::restart::classify_first_send_at(
515
+ row.get("first_send_at").unwrap_or(&Value::Null),
516
+ ) == crate::lifecycle::FirstSendAtState::Corrupt
517
+ {
518
+ return Err(refusal("invalid first_send_at metadata before stop"));
519
+ }
520
+ let session = row
521
+ .get("session_id")
522
+ .and_then(Value::as_str)
523
+ .filter(|s| !s.is_empty())
524
+ .map(crate::provider::SessionId::new);
525
+ if session_policy == SessionPolicy::Preserve
526
+ && !crate::lifecycle::restart::restart_agent_never_captured(
527
+ row,
528
+ session.as_ref().map(|sid| sid.as_str()),
529
+ )
530
+ && session.is_none()
531
+ {
532
+ return Err(refusal(
533
+ "resume_not_ready before stop: captured session is missing for existing task context",
534
+ ));
535
+ }
536
+ let rollout = row
537
+ .get("rollout_path")
538
+ .and_then(Value::as_str)
539
+ .filter(|s| !s.is_empty())
540
+ .map(crate::provider::RolloutPath::new);
541
+ if session.is_some()
542
+ && row
543
+ .get("spawn_cwd")
544
+ .and_then(Value::as_str)
545
+ .is_none_or(|cwd| cwd.is_empty())
546
+ {
547
+ return Err(refusal("resume metadata lacks the scoped spawn cwd"));
548
+ }
549
+ check_incarnation_cwd(selected, id, row, provider)?;
550
+ if row
551
+ .get("task_prompt_delivered")
552
+ .is_some_and(|value| !value.is_null() && !value.is_boolean())
553
+ {
554
+ return Err(refusal("invalid task context metadata before stop"));
555
+ }
556
+ let (exists, checked_paths) = session
557
+ .as_ref()
558
+ .map(|session| {
559
+ crate::lifecycle::restart::surface_resume_backing_probe(
560
+ &selected.run_workspace,
561
+ &AgentId::new(id),
562
+ row,
563
+ provider,
564
+ session,
565
+ rollout.as_ref(),
566
+ )
567
+ })
568
+ .unwrap_or_default();
569
+ let mode = crate::lifecycle::restart::decide_start_mode(
570
+ crate::provider::wire::provider_wire(provider),
571
+ session.as_ref(),
572
+ rollout.as_ref(),
573
+ exists,
574
+ session_policy == SessionPolicy::Auto,
575
+ );
576
+ if mode == crate::lifecycle::StartMode::Noop {
577
+ return Err(refusal(format!("resume_not_ready before stop: {id}; backing_checked={checked_paths:?}; choose --session auto only if fresh fallback is acceptable")));
578
+ }
579
+ Ok(())
580
+ }
581
+ fn add(
582
+ plan: &mut Plan,
583
+ selected: &SelectedTeam,
584
+ id: &str,
585
+ source: Option<PathBuf>,
586
+ ) -> Result<(), CliError> {
587
+ if selected
588
+ .state
589
+ .get("agent_lifecycle")
590
+ .and_then(|lifecycle| lifecycle.get(id))
591
+ .is_some()
592
+ {
593
+ return Err(refusal(
594
+ "agent lifecycle history is not an absent role; use explicit recovery/add",
595
+ ));
596
+ }
597
+ let patch = plan.intent.patch.clone();
598
+ let role_file = match source {
599
+ Some(path) => {
600
+ let (meta, _) = checked(crate::compiler::read_front_matter(&path))?;
601
+ if (meta.get("provider").and_then(Yaml::as_str).is_none() && patch.provider.is_none())
602
+ || (!matches!(
603
+ meta.get("dangerously_skip_permissions"),
604
+ Some(Yaml::Bool(_))
605
+ ) && patch.bypass.is_none())
606
+ {
607
+ return Err(usage("role creation requires explicit provider and dangerously_skip_permissions decisions"));
608
+ }
609
+ let (_, compiled) = role(plan, selected, id, Some(&path))?;
610
+ #[cfg(unix)]
611
+ if crate::contract_runtime::registry::descriptor(provider(&compiled)).is_some() {
612
+ return Err(refusal(
613
+ "native dynamic add is not admitted; no legacy fallback",
614
+ ));
615
+ }
616
+ path.to_string_lossy().into_owned()
617
+ }
618
+ None => {
619
+ if patch.provider.is_none() || patch.bypass.is_none() {
620
+ return Err(usage(
621
+ "unknown agent: supply a complete --role-file or --provider and --bypass",
622
+ ));
623
+ }
624
+ #[cfg(unix)]
625
+ if patch
626
+ .provider
627
+ .as_deref()
628
+ .is_some_and(|p| crate::contract_runtime::registry::descriptor(p).is_some())
629
+ {
630
+ return Err(refusal(
631
+ "native dynamic add is not admitted; no legacy fallback",
632
+ ));
633
+ }
634
+ String::new()
635
+ }
636
+ };
637
+ plan.steps.push(Step::AddAgent(AddAgentArgs {
638
+ agent: id.into(),
639
+ workspace: plan.scope.workspace.clone(),
640
+ team: plan.scope.team.clone(),
641
+ role_config: patch,
642
+ role_file,
643
+ force: false,
644
+ json: true,
645
+ }));
646
+ Ok(())
647
+ }
648
+
649
+ pub(super) fn plan(intent: Intent) -> Result<Plan, CliError> {
650
+ plan_with_observer(intent, observe)
651
+ }
652
+ fn no_eligible(mut plan: Plan, reason: &'static str) -> Plan {
653
+ plan.steps.clear();
654
+ plan.no_eligible = Some(reason);
655
+ plan
656
+ }
657
+
658
+ fn plan_with_observer(
659
+ intent: Intent,
660
+ observe: impl Fn(&SelectedTeam, &str, &Value) -> Result<Observation, CliError>,
661
+ ) -> Result<Plan, CliError> {
662
+ let mut plan = Plan {
663
+ scope: ResolvedScope {
664
+ workspace: intent.workspace.clone(),
665
+ team: intent.team.clone(),
666
+ },
667
+ intent,
668
+ steps: vec![],
669
+ skipped: vec![],
670
+ no_eligible: None,
671
+ files: vec![],
672
+ expected: BTreeMap::new(),
673
+ topology: None,
674
+ };
675
+ let runtime_workspace = if plan.intent.pinned_workspace {
676
+ plan.scope.workspace.clone()
677
+ } else {
678
+ checked(crate::model::paths::canonical_run_workspace(
679
+ &plan.scope.workspace,
680
+ ))?
681
+ };
682
+ let has_state = crate::state::persist::runtime_state_path(&runtime_workspace).is_file();
683
+ if has_state {
684
+ plan.scope.workspace = runtime_workspace;
685
+ }
686
+ if !has_state {
687
+ let runtime = plan.scope.workspace.join(".team/runtime");
688
+ let mut spec_only = plan.scope.workspace.join("team.spec.yaml").is_file();
689
+ if runtime.is_dir() {
690
+ for entry in std::fs::read_dir(runtime)? {
691
+ spec_only |= entry?.path().join("team.spec.yaml").is_file();
692
+ }
693
+ }
694
+ if spec_only {
695
+ return Err(refusal("runtime spec exists without its state; select the owning workspace or repair it explicitly"));
696
+ }
697
+ }
698
+ let raw = if has_state {
699
+ Some(crate::state::persist::load_runtime_state_without_migrations(&plan.scope.workspace)?)
700
+ } else {
701
+ None
702
+ };
703
+ if raw.as_ref().is_some_and(|state| {
704
+ !state.is_object() || state.get("teams").is_some_and(|teams| !teams.is_object())
705
+ }) {
706
+ return Err(refusal(
707
+ "invalid runtime state; creation is not a repair fallback",
708
+ ));
709
+ }
710
+ let new_named_team =
711
+ raw.as_ref()
712
+ .zip(plan.intent.team.as_deref())
713
+ .is_some_and(|(raw, team)| {
714
+ team != "current"
715
+ && !crate::lifecycle::launch::surface_has_runtime_team(raw, team)
716
+ && !(crate::state::projection::team_state_key(raw) == team
717
+ && crate::lifecycle::launch::surface_is_leader_bootstrap(raw))
718
+ && !raw
719
+ .get("teams")
720
+ .and_then(|t| t.get(team))
721
+ .is_some_and(crate::lifecycle::launch::surface_is_leader_bootstrap)
722
+ });
723
+ let selected = if has_state && !new_named_team {
724
+ Some(resolve_active_team_readonly(
725
+ &plan.scope.workspace,
726
+ plan.intent.team.as_deref(),
727
+ SelectorMode::RuntimeOnly,
728
+ )?)
729
+ } else {
730
+ None
731
+ };
732
+ let initialized = selected
733
+ .as_ref()
734
+ .is_some_and(|s| crate::lifecycle::launch::surface_has_runtime_team(&s.state, &s.team_key));
735
+ if !initialized {
736
+ if plan.intent.verb != Verb::Start || !matches!(plan.intent.subject, Subject::Team { .. }) {
737
+ return Err(refusal("no initialized team; use start --from TEAMDIR"));
738
+ }
739
+ let explicit = match &plan.intent.subject {
740
+ Subject::Team { source } => source.as_deref(),
741
+ _ => None,
742
+ };
743
+ if new_named_team && explicit.is_none() {
744
+ return Err(usage(
745
+ "new --team requires explicit --from; existing runtime is not a creation source",
746
+ ));
747
+ }
748
+ let source = discover(&plan.scope.workspace, explicit, None)?;
749
+ let compiled = checked(crate::compiler::compile_team(&source))?;
750
+ validate_roster(&compiled, &source)?;
751
+ let create = QuickStartArgs {
752
+ workspace: plan.scope.workspace.clone(),
753
+ agents_dir: source.clone(),
754
+ name: None,
755
+ team_id: plan.intent.team.clone(),
756
+ yes: false,
757
+ json: true,
758
+ detail: true,
759
+ backend: None,
760
+ };
761
+ #[cfg(unix)]
762
+ if let Some(config) = checked(crate::contract_runtime::config::read_team(&create))? {
763
+ if config.roles.len() == config.members.len()
764
+ && config.roles.iter().all(|role| role.paused)
765
+ {
766
+ plan.skipped.extend(
767
+ config
768
+ .roles
769
+ .iter()
770
+ .map(|role| json!({"agent":role.id.as_str(),"reason":"paused"})),
771
+ );
772
+ return Ok(no_eligible(plan, "all configured native roles are paused"));
773
+ }
774
+ }
775
+ remember(&mut plan, &source.join("TEAM.md"))?;
776
+ for entry in std::fs::read_dir(source.join("agents"))? {
777
+ let path = entry?.path();
778
+ if path.extension().is_some_and(|s| s == "md") {
779
+ remember(&mut plan, &path)?;
780
+ }
781
+ }
782
+ plan.steps.push(Step::CreateTeam(create));
783
+ return Ok(plan);
784
+ }
785
+ let selected = selected.ok_or_else(|| refusal("missing selected runtime"))?;
786
+ plan.scope = ResolvedScope {
787
+ workspace: selected.run_workspace.clone(),
788
+ team: Some(selected.team_key.clone()),
789
+ };
790
+ plan.topology = Some(topology(&selected.state));
791
+ if let Subject::Team {
792
+ source: Some(source),
793
+ } = &plan.intent.subject
794
+ {
795
+ if source.canonicalize()? != selected.team_dir.canonicalize()? {
796
+ return Err(refusal("source differs from selected team's role source"));
797
+ }
798
+ }
799
+ let target = match &plan.intent.subject {
800
+ Subject::Agent(id) => Some(id.clone()),
801
+ _ => None,
802
+ };
803
+ checked(crate::lifecycle::launch::ensure_owner_allowed_for_state(
804
+ &selected.state,
805
+ target.as_ref(),
806
+ ))?;
807
+ if plan.intent.verb == Verb::Stop && target.is_none() {
808
+ plan.steps.push(Step::Shutdown(ShutdownArgs {
809
+ workspace: plan.scope.workspace.clone(),
810
+ team: plan.scope.team.clone(),
811
+ keep_logs: true,
812
+ json: true,
813
+ }));
814
+ return Ok(plan);
815
+ }
816
+ let spec = selected.spec_path.as_ref().filter(|p| p.is_file());
817
+ if spec.is_none() {
818
+ if plan.intent.verb == Verb::Restart && target.is_none() {
819
+ // Original active restart owns spec reconstruction and pre-destroy validation.
820
+ let rows = selected
821
+ .state
822
+ .get("agents")
823
+ .and_then(Value::as_object)
824
+ .ok_or_else(|| refusal("missing agents"))?;
825
+ let mut active = false;
826
+ for (id, row) in rows {
827
+ if !paused(row)
828
+ && !crate::state::projection::agent_explicit_stop(row)
829
+ && !crate::state::projection::agent_stopped_for_restart(row)
830
+ {
831
+ let obs = observe(&selected, id, row)?;
832
+ if obs == Observation::Unknown {
833
+ return Err(refusal("unknown physical identity"));
834
+ }
835
+ active = true;
836
+ } else {
837
+ plan.skipped.push(json!({"agent":id,"reason":if paused(row){"paused"}else if crate::state::projection::agent_explicit_stop(row){"explicit_stop"}else{"stopped_preserved_by_original_restart"}}));
838
+ }
839
+ }
840
+ if !active {
841
+ return Ok(no_eligible(
842
+ plan,
843
+ "missing spec cannot be rebuilt without an active roster",
844
+ ));
845
+ }
846
+ plan.steps.push(Step::RestartTeam(restart_args(&plan)));
847
+ return Ok(plan);
848
+ }
849
+ return Err(refusal(
850
+ "runtime spec missing; use explicit team restart to request supported reconstruction",
851
+ ));
852
+ }
853
+ let spec = spec.ok_or_else(|| refusal("missing spec"))?;
854
+ let spec_value = checked(yaml::loads(&std::fs::read_to_string(spec)?))?;
855
+ validate_roster(
856
+ &spec_value,
857
+ spec.parent().unwrap_or(&selected.run_workspace),
858
+ )?;
859
+ let spec_ids: BTreeSet<String> = spec_value
860
+ .get("agents")
861
+ .and_then(Yaml::as_list)
862
+ .ok_or_else(|| refusal("invalid runtime roster"))?
863
+ .iter()
864
+ .map(|a| {
865
+ a.get("id")
866
+ .and_then(Yaml::as_str)
867
+ .map(str::to_string)
868
+ .ok_or_else(|| refusal("invalid runtime role ID"))
869
+ })
870
+ .collect::<Result<_, _>>()?;
871
+ let rows = selected
872
+ .state
873
+ .get("agents")
874
+ .and_then(Value::as_object)
875
+ .ok_or_else(|| refusal("missing runtime agents"))?;
876
+ let ids = if let Some(id) = target.as_ref() {
877
+ vec![id.as_str().to_owned()]
878
+ } else {
879
+ let source = discover(&plan.scope.workspace, None, Some(&selected))?;
880
+ let compiled = checked(crate::compiler::compile_team(&source))?;
881
+ validate_roster(&compiled, &source)?;
882
+ let mut ids: BTreeSet<String> = rows.keys().cloned().collect();
883
+ if let Some(agents) = compiled.get("agents").and_then(Yaml::as_list) {
884
+ for agent in agents {
885
+ if let Some(id) = agent.get("id").and_then(Yaml::as_str) {
886
+ ids.insert(id.into());
887
+ }
888
+ }
889
+ }
890
+ for id in &spec_ids {
891
+ if !rows.contains_key(id) {
892
+ return Err(refusal(format!("SpecOnly {id}: state/spec mismatch")));
893
+ }
894
+ }
895
+ ids.into_iter().collect()
896
+ };
897
+ let team = target.is_none();
898
+ let mut observations = Vec::new();
899
+ for id in ids {
900
+ let row = rows.get(&id);
901
+ if row.is_some() != spec_ids.contains(&id) {
902
+ return Err(refusal(format!("state/spec mismatch for {id}")));
903
+ }
904
+ let Some(row) = row else {
905
+ if team
906
+ && selected
907
+ .state
908
+ .pointer(&format!("/agent_lifecycle/{id}/state"))
909
+ .and_then(Value::as_str)
910
+ == Some("retired")
911
+ {
912
+ plan.skipped.push(json!({"agent":id,"reason":"retired"}));
913
+ continue;
914
+ }
915
+ if plan.intent.verb != Verb::Start {
916
+ return Err(refusal("restart/stop never implicitly add agents"));
917
+ }
918
+ let source = plan.intent.role_file.clone().or_else(|| {
919
+ let p = selected.team_dir.join("agents").join(format!("{id}.md"));
920
+ p.is_file().then_some(p)
921
+ });
922
+ add(&mut plan, &selected, &id, source)?;
923
+ continue;
924
+ };
925
+ if let Some(source) = plan.intent.role_file.as_ref() {
926
+ let expected = checked(crate::lifecycle::launch::role_source::resolve_role_source(
927
+ &selected.run_workspace,
928
+ &selected.team_dir,
929
+ &selected.state,
930
+ &AgentId::new(&id),
931
+ ))?;
932
+ if source.canonicalize()? != expected.canonicalize()? {
933
+ return Err(refusal("registered agent cannot replace its role source"));
934
+ }
935
+ }
936
+ let explicit = crate::state::projection::agent_explicit_stop(row);
937
+ let obs = if paused(row) || (team && plan.intent.verb == Verb::Restart && explicit) {
938
+ Observation::Unstarted
939
+ } else {
940
+ observe(&selected, &id, row)?
941
+ };
942
+ let actions = seat_actions(
943
+ plan.intent.verb,
944
+ obs,
945
+ paused(row),
946
+ explicit,
947
+ plan.intent.patch != RoleConfigPatch::default() || plan.intent.role_file.is_some(),
948
+ team,
949
+ )?;
950
+ if actions.is_empty() {
951
+ if obs == Observation::Live && plan.intent.verb == Verb::Start {
952
+ let (_, compiled) = role(&mut plan, &selected, &id, None)?;
953
+ check_native_noop_settings(&selected, &id, row)?;
954
+ if drift(row, &compiled) {
955
+ return Err(refusal(format!(
956
+ "known launch configuration drift for running {id}; stop before applying"
957
+ )));
958
+ }
959
+ }
960
+ plan.skipped.push(json!({"agent":id,"reason":if paused(row){"paused"}else if team&&plan.intent.verb==Verb::Restart&&explicit{"explicit_stop"}else if obs==Observation::Live{"already_running"}else{"already_stopped"},"observation":obs}));
961
+ } else {
962
+ if plan.intent.verb != Verb::Stop {
963
+ let (_, compiled) = role(&mut plan, &selected, &id, None)?;
964
+ if !same_provider(provider(row), provider(&compiled)) {
965
+ return Err(refusal("registered provider cannot change"));
966
+ }
967
+ preflight_start(&selected, &id, row, plan.intent.session)?;
968
+ }
969
+ for action in actions {
970
+ match action {
971
+ "stop" => plan.steps.push(Step::StopAgent(stop_args(&plan, &id))),
972
+ _ => plan.steps.push(Step::StartAgent(start_args(
973
+ &plan,
974
+ &id,
975
+ plan.intent.patch.clone(),
976
+ ))),
977
+ }
978
+ }
979
+ }
980
+ let mut expected = relevant(row);
981
+ #[cfg(unix)]
982
+ if obs == Observation::Live {
983
+ if let Some(pid) = row
984
+ .get("pane_pid")
985
+ .and_then(Value::as_u64)
986
+ .and_then(|p| u32::try_from(p).ok())
987
+ {
988
+ expected["_birth"] = json!(checked(
989
+ team_agent_contract::host::process::process_birth_identity(pid)
990
+ )?);
991
+ }
992
+ }
993
+ plan.expected.insert(id.clone(), expected);
994
+ observations.push((id, obs, explicit, paused(row)));
995
+ }
996
+ if team && plan.intent.verb == Verb::Restart {
997
+ let eligible: Vec<_> = observations
998
+ .iter()
999
+ .filter(|(_, _, explicit, paused)| !explicit && !paused)
1000
+ .collect();
1001
+ if eligible.is_empty() {
1002
+ return Ok(no_eligible(
1003
+ plan,
1004
+ "use team start to explicitly activate a nonpaused roster",
1005
+ ));
1006
+ }
1007
+ let shutdown = selected.state.get("status").and_then(Value::as_str) == Some("shutdown");
1008
+ if !shutdown
1009
+ && !eligible.iter().any(|(id, _, _, _)| {
1010
+ rows.get(id.as_str())
1011
+ .is_some_and(|row| !crate::state::projection::agent_stopped_for_restart(row))
1012
+ })
1013
+ {
1014
+ plan.skipped.extend(eligible.iter().map(|(id,obs,_,_)| json!({"agent":id,"observation":obs,"reason":"stopped_preserved_by_original_restart"})));
1015
+ return Ok(no_eligible(
1016
+ plan,
1017
+ "stopped roster requires explicit team start",
1018
+ ));
1019
+ }
1020
+ if !shutdown
1021
+ || eligible
1022
+ .iter()
1023
+ .any(|(_, obs, _, _)| *obs == Observation::Live)
1024
+ {
1025
+ for (id, obs, _, _) in &eligible {
1026
+ if rows
1027
+ .get(id.as_str())
1028
+ .is_some_and(crate::state::projection::agent_stopped_for_restart)
1029
+ {
1030
+ plan.skipped.push(json!({"agent":id,"observation":obs,"reason":"stopped_preserved_by_original_restart"}));
1031
+ }
1032
+ }
1033
+ plan.steps.clear();
1034
+ plan.steps.push(Step::RestartTeam(restart_args(&plan)));
1035
+ }
1036
+ }
1037
+ if team
1038
+ && plan.steps.is_empty()
1039
+ && !plan
1040
+ .skipped
1041
+ .iter()
1042
+ .any(|s| s.get("reason").and_then(Value::as_str) == Some("already_running"))
1043
+ {
1044
+ return Ok(no_eligible(plan, "no eligible nonpaused roster"));
1045
+ }
1046
+ Ok(plan)
1047
+ }
1048
+ fn restart_args(plan: &Plan) -> RestartArgs {
1049
+ RestartArgs {
1050
+ workspace: plan.scope.workspace.clone(),
1051
+ team: plan.scope.team.clone(),
1052
+ allow_fresh: plan.intent.session == SessionPolicy::Auto,
1053
+ session_converge_deadline_ms: plan.intent.session_converge_deadline_ms,
1054
+ json: true,
1055
+ detail: true,
1056
+ }
1057
+ }
1058
+
1059
+ pub(super) fn recheck(plan: &Plan, step: &Step, after_stop: bool) -> Result<(), CliError> {
1060
+ for (path, bytes) in &plan.files {
1061
+ if std::fs::read(path)? != *bytes {
1062
+ return Err(refusal(format!(
1063
+ "role/spec changed since planning: {}",
1064
+ path.display()
1065
+ )));
1066
+ }
1067
+ }
1068
+ if let Step::CreateTeam(args) = step {
1069
+ let role_dir = args.agents_dir.join("agents");
1070
+ let current: BTreeSet<_> = std::fs::read_dir(&role_dir)?
1071
+ .map(|entry| entry.map(|e| e.path()))
1072
+ .collect::<Result<Vec<_>, _>>()?
1073
+ .into_iter()
1074
+ .filter(|path| path.extension().is_some_and(|s| s == "md"))
1075
+ .collect();
1076
+ let planned: BTreeSet<_> = plan
1077
+ .files
1078
+ .iter()
1079
+ .map(|(path, _)| path)
1080
+ .filter(|path| path.parent() == Some(role_dir.as_path()))
1081
+ .cloned()
1082
+ .collect();
1083
+ if current != planned {
1084
+ return Err(refusal("creation roster changed since planning"));
1085
+ }
1086
+ return Ok(());
1087
+ }
1088
+ let selected = resolve_active_team_readonly(
1089
+ &plan.scope.workspace,
1090
+ plan.scope.team.as_deref(),
1091
+ SelectorMode::RuntimeOnly,
1092
+ )?;
1093
+ if Some(selected.team_key.as_str()) != plan.scope.team.as_deref()
1094
+ || selected.run_workspace != plan.scope.workspace
1095
+ {
1096
+ return Err(refusal("canonical scope changed"));
1097
+ }
1098
+ if plan
1099
+ .topology
1100
+ .as_ref()
1101
+ .is_some_and(|expected| topology(&selected.state) != *expected)
1102
+ {
1103
+ return Err(refusal("owner or transport binding changed since planning"));
1104
+ }
1105
+ let id = step.agent().map(AgentId::new);
1106
+ checked(crate::lifecycle::launch::ensure_owner_allowed_for_state(
1107
+ &selected.state,
1108
+ id.as_ref(),
1109
+ ))?;
1110
+ let targets: Vec<_> = if let Some(id) = step.agent() {
1111
+ vec![id]
1112
+ } else {
1113
+ plan.expected.keys().map(String::as_str).collect()
1114
+ };
1115
+ for id in targets {
1116
+ let row = selected.state.get("agents").and_then(|a| a.get(id));
1117
+ if matches!(step, Step::AddAgent(_)) {
1118
+ if row.is_some() {
1119
+ return Err(refusal("agent was registered after planning"));
1120
+ }
1121
+ continue;
1122
+ }
1123
+ let row = row.ok_or_else(|| refusal("planned agent disappeared"))?;
1124
+ let expected = plan
1125
+ .expected
1126
+ .get(id)
1127
+ .ok_or_else(|| refusal("missing original incarnation"))?;
1128
+ if after_stop && plan.intent.verb == Verb::Restart && matches!(step, Step::StartAgent(_)) {
1129
+ if !same_provider(provider(row), provider(expected))
1130
+ || row.get("generation").unwrap_or(&Value::Null)
1131
+ != expected.get("generation").unwrap_or(&Value::Null)
1132
+ || observe(&selected, id, row)? != Observation::StoppedVerified
1133
+ {
1134
+ return Err(refusal(
1135
+ "post-stop incarnation is not the planned stopped agent",
1136
+ ));
1137
+ }
1138
+ } else {
1139
+ let mut expected = expected.clone();
1140
+ let birth = expected.as_object_mut().and_then(|o| o.remove("_birth"));
1141
+ if relevant(row) != expected {
1142
+ return Err(refusal("planned agent incarnation changed"));
1143
+ }
1144
+ #[cfg(unix)]
1145
+ if let Some(birth) = birth {
1146
+ let pid = row
1147
+ .get("pane_pid")
1148
+ .and_then(Value::as_u64)
1149
+ .and_then(|p| u32::try_from(p).ok())
1150
+ .ok_or_else(|| refusal("missing original carrier"))?;
1151
+ if json!(checked(
1152
+ team_agent_contract::host::process::process_birth_identity(pid)
1153
+ )?) != birth
1154
+ {
1155
+ return Err(refusal("carrier replaced since planning"));
1156
+ }
1157
+ }
1158
+ }
1159
+ #[cfg(unix)]
1160
+ if !crate::contract_runtime::framework::is_contract(row)
1161
+ && crate::provider::wire::parse_provider(provider(row))
1162
+ == Some(crate::provider::Provider::CursorAgent)
1163
+ && (matches!(step, Step::StartAgent(_))
1164
+ || (plan.intent.verb == Verb::Restart && matches!(step, Step::StopAgent(_)))
1165
+ || (matches!(step, Step::RestartTeam(_))
1166
+ && row.get("status").and_then(Value::as_str) == Some("running")
1167
+ && !crate::state::projection::agent_explicit_stop(row)))
1168
+ {
1169
+ // Reopen the same ownership proof at the existing execution recheck,
1170
+ // before effects; a planning-time receipt is not a filesystem lease.
1171
+ check_incarnation_cwd(&selected, id, row, crate::provider::Provider::CursorAgent)?;
1172
+ }
1173
+ }
1174
+ Ok(())
1175
+ }