@team-agent/installer 0.5.119 → 0.5.120

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 (59) 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 +1139 -0
  9. package/crates/team-agent/src/cli/intent/runtime_tests.rs +584 -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 +49 -5
  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/verb_install_skill.rs +3 -2
  20. package/crates/team-agent/src/cli/tests/verb_profile.rs +3 -1
  21. package/crates/team-agent/src/contract_runtime/backend.rs +1 -1
  22. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +166 -77
  23. package/crates/team-agent/src/lifecycle/launch.rs +28 -0
  24. package/crates/team-agent/src/lifecycle/restart.rs +17 -0
  25. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +1 -3
  26. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +5 -15
  27. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +2 -3
  28. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +2 -3
  29. package/crates/team-agent/src/lifecycle/tests/cli_argv_routing_red.rs +10 -2
  30. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +8 -1
  31. package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +3 -2
  32. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +6 -9
  33. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +7 -5
  34. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +4 -2
  35. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +5 -2
  36. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +4 -3
  37. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +2 -4
  38. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +3 -4
  39. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +5 -4
  40. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +2 -4
  41. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +78 -36
  42. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +8 -4
  43. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +6 -12
  44. package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +4 -0
  45. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +1 -3
  46. package/crates/team-agent/src/lifecycle/tests/status_credential_redaction_contract.rs +7 -0
  47. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +1 -3
  48. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +2 -1
  49. package/crates/team-agent/src/lifecycle/types.rs +10 -0
  50. package/crates/team-agent/src/tmux_backend/tests.rs +37 -0
  51. package/crates/team-agent/src/tmux_backend.rs +8 -1
  52. package/package.json +4 -4
  53. package/skills/team-agent/SKILL.md +71 -54
  54. package/skills/team-agent/command-coverage.json +283 -13
  55. package/skills/team-agent/docs/reference/team-agent-operator.md +220 -0
  56. package/skills/team-agent/references/bug-as-artifact-flow.md +1 -1
  57. package/skills/team-agent/references/recovery-runbook.md +61 -212
  58. package/skills/team-agent/references/team-agent-operator.md +181 -48
  59. package/skills/team-agent/references/team-in-team.md +47 -80
@@ -0,0 +1,1139 @@
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 preflight_start(
446
+ selected: &SelectedTeam,
447
+ id: &str,
448
+ row: &Value,
449
+ session_policy: SessionPolicy,
450
+ ) -> Result<(), CliError> {
451
+ #[cfg(unix)]
452
+ if crate::contract_runtime::framework::is_contract(row) {
453
+ use team_agent_contract::contract::types::SeatId;
454
+ let config = checked(crate::contract_runtime::framework::team_config(
455
+ &selected.run_workspace,
456
+ &selected.team_dir,
457
+ &selected.team_key,
458
+ ))?
459
+ .ok_or_else(|| refusal("missing native role config"))?;
460
+ let role = config
461
+ .roles
462
+ .iter()
463
+ .find(|r| r.id.as_str() == id)
464
+ .ok_or_else(|| refusal("missing native role"))?;
465
+ checked(crate::contract_runtime::backend::prepare_resume(
466
+ &selected.run_workspace,
467
+ &selected.team_key,
468
+ &checked(SeatId::new(id))?,
469
+ Some(role),
470
+ ))?;
471
+ return Ok(());
472
+ }
473
+ let provider = crate::provider::wire::parse_provider(provider(row))
474
+ .ok_or_else(|| refusal("unknown provider before restart"))?;
475
+ for field in ["session_id", "rollout_path", "last_result_at", "spawn_cwd"] {
476
+ if row
477
+ .get(field)
478
+ .is_some_and(|value| !value.is_null() && !value.is_string())
479
+ {
480
+ return Err(refusal(format!("invalid resume metadata: {field}")));
481
+ }
482
+ }
483
+ if crate::lifecycle::restart::classify_first_send_at(
484
+ row.get("first_send_at").unwrap_or(&Value::Null),
485
+ ) == crate::lifecycle::FirstSendAtState::Corrupt
486
+ {
487
+ return Err(refusal("invalid first_send_at metadata before stop"));
488
+ }
489
+ let session = row
490
+ .get("session_id")
491
+ .and_then(Value::as_str)
492
+ .filter(|s| !s.is_empty())
493
+ .map(crate::provider::SessionId::new);
494
+ if session_policy == SessionPolicy::Preserve
495
+ && !crate::lifecycle::restart::restart_agent_never_captured(
496
+ row,
497
+ session.as_ref().map(|sid| sid.as_str()),
498
+ )
499
+ && session.is_none()
500
+ {
501
+ return Err(refusal(
502
+ "resume_not_ready before stop: captured session is missing for existing task context",
503
+ ));
504
+ }
505
+ let rollout = row
506
+ .get("rollout_path")
507
+ .and_then(Value::as_str)
508
+ .filter(|s| !s.is_empty())
509
+ .map(crate::provider::RolloutPath::new);
510
+ if session.is_some()
511
+ && row
512
+ .get("spawn_cwd")
513
+ .and_then(Value::as_str)
514
+ .is_none_or(|cwd| cwd.is_empty())
515
+ {
516
+ return Err(refusal("resume metadata lacks the scoped spawn cwd"));
517
+ }
518
+ if let Some(cwd) = row
519
+ .get("spawn_cwd")
520
+ .and_then(Value::as_str)
521
+ .filter(|cwd| !cwd.is_empty())
522
+ {
523
+ let cwd = checked(Path::new(cwd).canonicalize())?;
524
+ if !cwd.is_dir() || !cwd.starts_with(&selected.run_workspace) {
525
+ return Err(refusal("legacy incarnation cwd is outside the selected workspace; no context or scope fallback"));
526
+ }
527
+ }
528
+ if row
529
+ .get("task_prompt_delivered")
530
+ .is_some_and(|value| !value.is_null() && !value.is_boolean())
531
+ {
532
+ return Err(refusal("invalid task context metadata before stop"));
533
+ }
534
+ let (exists, checked_paths) = session
535
+ .as_ref()
536
+ .map(|session| {
537
+ crate::lifecycle::restart::surface_resume_backing_probe(
538
+ &selected.run_workspace,
539
+ &AgentId::new(id),
540
+ row,
541
+ provider,
542
+ session,
543
+ rollout.as_ref(),
544
+ )
545
+ })
546
+ .unwrap_or_default();
547
+ let mode = crate::lifecycle::restart::decide_start_mode(
548
+ crate::provider::wire::provider_wire(provider),
549
+ session.as_ref(),
550
+ rollout.as_ref(),
551
+ exists,
552
+ session_policy == SessionPolicy::Auto,
553
+ );
554
+ if mode == crate::lifecycle::StartMode::Noop {
555
+ return Err(refusal(format!("resume_not_ready before stop: {id}; backing_checked={checked_paths:?}; choose --session auto only if fresh fallback is acceptable")));
556
+ }
557
+ Ok(())
558
+ }
559
+ fn add(
560
+ plan: &mut Plan,
561
+ selected: &SelectedTeam,
562
+ id: &str,
563
+ source: Option<PathBuf>,
564
+ ) -> Result<(), CliError> {
565
+ if selected
566
+ .state
567
+ .get("agent_lifecycle")
568
+ .and_then(|lifecycle| lifecycle.get(id))
569
+ .is_some()
570
+ {
571
+ return Err(refusal(
572
+ "agent lifecycle history is not an absent role; use explicit recovery/add",
573
+ ));
574
+ }
575
+ let patch = plan.intent.patch.clone();
576
+ let role_file = match source {
577
+ Some(path) => {
578
+ let (meta, _) = checked(crate::compiler::read_front_matter(&path))?;
579
+ if (meta.get("provider").and_then(Yaml::as_str).is_none() && patch.provider.is_none())
580
+ || (!matches!(
581
+ meta.get("dangerously_skip_permissions"),
582
+ Some(Yaml::Bool(_))
583
+ ) && patch.bypass.is_none())
584
+ {
585
+ return Err(usage("role creation requires explicit provider and dangerously_skip_permissions decisions"));
586
+ }
587
+ let (_, compiled) = role(plan, selected, id, Some(&path))?;
588
+ #[cfg(unix)]
589
+ if crate::contract_runtime::registry::descriptor(provider(&compiled)).is_some() {
590
+ return Err(refusal(
591
+ "native dynamic add is not admitted; no legacy fallback",
592
+ ));
593
+ }
594
+ path.to_string_lossy().into_owned()
595
+ }
596
+ None => {
597
+ if patch.provider.is_none() || patch.bypass.is_none() {
598
+ return Err(usage(
599
+ "unknown agent: supply a complete --role-file or --provider and --bypass",
600
+ ));
601
+ }
602
+ #[cfg(unix)]
603
+ if patch
604
+ .provider
605
+ .as_deref()
606
+ .is_some_and(|p| crate::contract_runtime::registry::descriptor(p).is_some())
607
+ {
608
+ return Err(refusal(
609
+ "native dynamic add is not admitted; no legacy fallback",
610
+ ));
611
+ }
612
+ String::new()
613
+ }
614
+ };
615
+ plan.steps.push(Step::AddAgent(AddAgentArgs {
616
+ agent: id.into(),
617
+ workspace: plan.scope.workspace.clone(),
618
+ team: plan.scope.team.clone(),
619
+ role_config: patch,
620
+ role_file,
621
+ force: false,
622
+ json: true,
623
+ }));
624
+ Ok(())
625
+ }
626
+
627
+ pub(super) fn plan(intent: Intent) -> Result<Plan, CliError> {
628
+ plan_with_observer(intent, observe)
629
+ }
630
+ fn no_eligible(mut plan: Plan, reason: &'static str) -> Plan {
631
+ plan.steps.clear();
632
+ plan.no_eligible = Some(reason);
633
+ plan
634
+ }
635
+
636
+ fn plan_with_observer(
637
+ intent: Intent,
638
+ observe: impl Fn(&SelectedTeam, &str, &Value) -> Result<Observation, CliError>,
639
+ ) -> Result<Plan, CliError> {
640
+ let mut plan = Plan {
641
+ scope: ResolvedScope {
642
+ workspace: intent.workspace.clone(),
643
+ team: intent.team.clone(),
644
+ },
645
+ intent,
646
+ steps: vec![],
647
+ skipped: vec![],
648
+ no_eligible: None,
649
+ files: vec![],
650
+ expected: BTreeMap::new(),
651
+ topology: None,
652
+ };
653
+ let runtime_workspace = if plan.intent.pinned_workspace {
654
+ plan.scope.workspace.clone()
655
+ } else {
656
+ checked(crate::model::paths::canonical_run_workspace(
657
+ &plan.scope.workspace,
658
+ ))?
659
+ };
660
+ let has_state = crate::state::persist::runtime_state_path(&runtime_workspace).is_file();
661
+ if has_state {
662
+ plan.scope.workspace = runtime_workspace;
663
+ }
664
+ if !has_state {
665
+ let runtime = plan.scope.workspace.join(".team/runtime");
666
+ let mut spec_only = plan.scope.workspace.join("team.spec.yaml").is_file();
667
+ if runtime.is_dir() {
668
+ for entry in std::fs::read_dir(runtime)? {
669
+ spec_only |= entry?.path().join("team.spec.yaml").is_file();
670
+ }
671
+ }
672
+ if spec_only {
673
+ return Err(refusal("runtime spec exists without its state; select the owning workspace or repair it explicitly"));
674
+ }
675
+ }
676
+ let raw = if has_state {
677
+ Some(crate::state::persist::load_runtime_state_without_migrations(&plan.scope.workspace)?)
678
+ } else {
679
+ None
680
+ };
681
+ if raw.as_ref().is_some_and(|state| {
682
+ !state.is_object() || state.get("teams").is_some_and(|teams| !teams.is_object())
683
+ }) {
684
+ return Err(refusal(
685
+ "invalid runtime state; creation is not a repair fallback",
686
+ ));
687
+ }
688
+ let new_named_team =
689
+ raw.as_ref()
690
+ .zip(plan.intent.team.as_deref())
691
+ .is_some_and(|(raw, team)| {
692
+ team != "current"
693
+ && !crate::lifecycle::launch::surface_has_runtime_team(raw, team)
694
+ && !(crate::state::projection::team_state_key(raw) == team
695
+ && crate::lifecycle::launch::surface_is_leader_bootstrap(raw))
696
+ && !raw
697
+ .get("teams")
698
+ .and_then(|t| t.get(team))
699
+ .is_some_and(crate::lifecycle::launch::surface_is_leader_bootstrap)
700
+ });
701
+ let selected = if has_state && !new_named_team {
702
+ Some(resolve_active_team_readonly(
703
+ &plan.scope.workspace,
704
+ plan.intent.team.as_deref(),
705
+ SelectorMode::RuntimeOnly,
706
+ )?)
707
+ } else {
708
+ None
709
+ };
710
+ let initialized = selected
711
+ .as_ref()
712
+ .is_some_and(|s| crate::lifecycle::launch::surface_has_runtime_team(&s.state, &s.team_key));
713
+ if !initialized {
714
+ if plan.intent.verb != Verb::Start || !matches!(plan.intent.subject, Subject::Team { .. }) {
715
+ return Err(refusal("no initialized team; use start --from TEAMDIR"));
716
+ }
717
+ let explicit = match &plan.intent.subject {
718
+ Subject::Team { source } => source.as_deref(),
719
+ _ => None,
720
+ };
721
+ if new_named_team && explicit.is_none() {
722
+ return Err(usage(
723
+ "new --team requires explicit --from; existing runtime is not a creation source",
724
+ ));
725
+ }
726
+ let source = discover(&plan.scope.workspace, explicit, None)?;
727
+ let compiled = checked(crate::compiler::compile_team(&source))?;
728
+ validate_roster(&compiled, &source)?;
729
+ let create = QuickStartArgs {
730
+ workspace: plan.scope.workspace.clone(),
731
+ agents_dir: source.clone(),
732
+ name: None,
733
+ team_id: plan.intent.team.clone(),
734
+ yes: false,
735
+ json: true,
736
+ detail: true,
737
+ backend: None,
738
+ };
739
+ #[cfg(unix)]
740
+ if let Some(config) = checked(crate::contract_runtime::config::read_team(&create))? {
741
+ if config.roles.len() == config.members.len()
742
+ && config.roles.iter().all(|role| role.paused)
743
+ {
744
+ plan.skipped.extend(
745
+ config
746
+ .roles
747
+ .iter()
748
+ .map(|role| json!({"agent":role.id.as_str(),"reason":"paused"})),
749
+ );
750
+ return Ok(no_eligible(plan, "all configured native roles are paused"));
751
+ }
752
+ }
753
+ remember(&mut plan, &source.join("TEAM.md"))?;
754
+ for entry in std::fs::read_dir(source.join("agents"))? {
755
+ let path = entry?.path();
756
+ if path.extension().is_some_and(|s| s == "md") {
757
+ remember(&mut plan, &path)?;
758
+ }
759
+ }
760
+ plan.steps.push(Step::CreateTeam(create));
761
+ return Ok(plan);
762
+ }
763
+ let selected = selected.ok_or_else(|| refusal("missing selected runtime"))?;
764
+ plan.scope = ResolvedScope {
765
+ workspace: selected.run_workspace.clone(),
766
+ team: Some(selected.team_key.clone()),
767
+ };
768
+ plan.topology = Some(topology(&selected.state));
769
+ if let Subject::Team {
770
+ source: Some(source),
771
+ } = &plan.intent.subject
772
+ {
773
+ if source.canonicalize()? != selected.team_dir.canonicalize()? {
774
+ return Err(refusal("source differs from selected team's role source"));
775
+ }
776
+ }
777
+ let target = match &plan.intent.subject {
778
+ Subject::Agent(id) => Some(id.clone()),
779
+ _ => None,
780
+ };
781
+ checked(crate::lifecycle::launch::ensure_owner_allowed_for_state(
782
+ &selected.state,
783
+ target.as_ref(),
784
+ ))?;
785
+ if plan.intent.verb == Verb::Stop && target.is_none() {
786
+ plan.steps.push(Step::Shutdown(ShutdownArgs {
787
+ workspace: plan.scope.workspace.clone(),
788
+ team: plan.scope.team.clone(),
789
+ keep_logs: true,
790
+ json: true,
791
+ }));
792
+ return Ok(plan);
793
+ }
794
+ let spec = selected.spec_path.as_ref().filter(|p| p.is_file());
795
+ if spec.is_none() {
796
+ if plan.intent.verb == Verb::Restart && target.is_none() {
797
+ // Original active restart owns spec reconstruction and pre-destroy validation.
798
+ let rows = selected
799
+ .state
800
+ .get("agents")
801
+ .and_then(Value::as_object)
802
+ .ok_or_else(|| refusal("missing agents"))?;
803
+ let mut active = false;
804
+ for (id, row) in rows {
805
+ if !paused(row)
806
+ && !crate::state::projection::agent_explicit_stop(row)
807
+ && !crate::state::projection::agent_stopped_for_restart(row)
808
+ {
809
+ let obs = observe(&selected, id, row)?;
810
+ if obs == Observation::Unknown {
811
+ return Err(refusal("unknown physical identity"));
812
+ }
813
+ active = true;
814
+ } else {
815
+ 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"}}));
816
+ }
817
+ }
818
+ if !active {
819
+ return Ok(no_eligible(
820
+ plan,
821
+ "missing spec cannot be rebuilt without an active roster",
822
+ ));
823
+ }
824
+ plan.steps.push(Step::RestartTeam(restart_args(&plan)));
825
+ return Ok(plan);
826
+ }
827
+ return Err(refusal(
828
+ "runtime spec missing; use explicit team restart to request supported reconstruction",
829
+ ));
830
+ }
831
+ let spec = spec.ok_or_else(|| refusal("missing spec"))?;
832
+ let spec_value = checked(yaml::loads(&std::fs::read_to_string(spec)?))?;
833
+ validate_roster(
834
+ &spec_value,
835
+ spec.parent().unwrap_or(&selected.run_workspace),
836
+ )?;
837
+ let spec_ids: BTreeSet<String> = spec_value
838
+ .get("agents")
839
+ .and_then(Yaml::as_list)
840
+ .ok_or_else(|| refusal("invalid runtime roster"))?
841
+ .iter()
842
+ .map(|a| {
843
+ a.get("id")
844
+ .and_then(Yaml::as_str)
845
+ .map(str::to_string)
846
+ .ok_or_else(|| refusal("invalid runtime role ID"))
847
+ })
848
+ .collect::<Result<_, _>>()?;
849
+ let rows = selected
850
+ .state
851
+ .get("agents")
852
+ .and_then(Value::as_object)
853
+ .ok_or_else(|| refusal("missing runtime agents"))?;
854
+ let ids = if let Some(id) = target.as_ref() {
855
+ vec![id.as_str().to_owned()]
856
+ } else {
857
+ let source = discover(&plan.scope.workspace, None, Some(&selected))?;
858
+ let compiled = checked(crate::compiler::compile_team(&source))?;
859
+ validate_roster(&compiled, &source)?;
860
+ let mut ids: BTreeSet<String> = rows.keys().cloned().collect();
861
+ if let Some(agents) = compiled.get("agents").and_then(Yaml::as_list) {
862
+ for agent in agents {
863
+ if let Some(id) = agent.get("id").and_then(Yaml::as_str) {
864
+ ids.insert(id.into());
865
+ }
866
+ }
867
+ }
868
+ for id in &spec_ids {
869
+ if !rows.contains_key(id) {
870
+ return Err(refusal(format!("SpecOnly {id}: state/spec mismatch")));
871
+ }
872
+ }
873
+ ids.into_iter().collect()
874
+ };
875
+ let team = target.is_none();
876
+ let mut observations = Vec::new();
877
+ for id in ids {
878
+ let row = rows.get(&id);
879
+ if row.is_some() != spec_ids.contains(&id) {
880
+ return Err(refusal(format!("state/spec mismatch for {id}")));
881
+ }
882
+ let Some(row) = row else {
883
+ if team
884
+ && selected
885
+ .state
886
+ .pointer(&format!("/agent_lifecycle/{id}/state"))
887
+ .and_then(Value::as_str)
888
+ == Some("retired")
889
+ {
890
+ plan.skipped.push(json!({"agent":id,"reason":"retired"}));
891
+ continue;
892
+ }
893
+ if plan.intent.verb != Verb::Start {
894
+ return Err(refusal("restart/stop never implicitly add agents"));
895
+ }
896
+ let source = plan.intent.role_file.clone().or_else(|| {
897
+ let p = selected.team_dir.join("agents").join(format!("{id}.md"));
898
+ p.is_file().then_some(p)
899
+ });
900
+ add(&mut plan, &selected, &id, source)?;
901
+ continue;
902
+ };
903
+ if let Some(source) = plan.intent.role_file.as_ref() {
904
+ let expected = checked(crate::lifecycle::launch::role_source::resolve_role_source(
905
+ &selected.run_workspace,
906
+ &selected.team_dir,
907
+ &selected.state,
908
+ &AgentId::new(&id),
909
+ ))?;
910
+ if source.canonicalize()? != expected.canonicalize()? {
911
+ return Err(refusal("registered agent cannot replace its role source"));
912
+ }
913
+ }
914
+ let explicit = crate::state::projection::agent_explicit_stop(row);
915
+ let obs = if paused(row) || (team && plan.intent.verb == Verb::Restart && explicit) {
916
+ Observation::Unstarted
917
+ } else {
918
+ observe(&selected, &id, row)?
919
+ };
920
+ let actions = seat_actions(
921
+ plan.intent.verb,
922
+ obs,
923
+ paused(row),
924
+ explicit,
925
+ plan.intent.patch != RoleConfigPatch::default() || plan.intent.role_file.is_some(),
926
+ team,
927
+ )?;
928
+ if actions.is_empty() {
929
+ if obs == Observation::Live && plan.intent.verb == Verb::Start {
930
+ let (_, compiled) = role(&mut plan, &selected, &id, None)?;
931
+ check_native_noop_settings(&selected, &id, row)?;
932
+ if drift(row, &compiled) {
933
+ return Err(refusal(format!(
934
+ "known launch configuration drift for running {id}; stop before applying"
935
+ )));
936
+ }
937
+ }
938
+ 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}));
939
+ } else {
940
+ if plan.intent.verb != Verb::Stop {
941
+ let (_, compiled) = role(&mut plan, &selected, &id, None)?;
942
+ if !same_provider(provider(row), provider(&compiled)) {
943
+ return Err(refusal("registered provider cannot change"));
944
+ }
945
+ preflight_start(&selected, &id, row, plan.intent.session)?;
946
+ }
947
+ for action in actions {
948
+ match action {
949
+ "stop" => plan.steps.push(Step::StopAgent(stop_args(&plan, &id))),
950
+ _ => plan.steps.push(Step::StartAgent(start_args(
951
+ &plan,
952
+ &id,
953
+ plan.intent.patch.clone(),
954
+ ))),
955
+ }
956
+ }
957
+ }
958
+ let mut expected = relevant(row);
959
+ #[cfg(unix)]
960
+ if obs == Observation::Live {
961
+ if let Some(pid) = row
962
+ .get("pane_pid")
963
+ .and_then(Value::as_u64)
964
+ .and_then(|p| u32::try_from(p).ok())
965
+ {
966
+ expected["_birth"] = json!(checked(
967
+ team_agent_contract::host::process::process_birth_identity(pid)
968
+ )?);
969
+ }
970
+ }
971
+ plan.expected.insert(id.clone(), expected);
972
+ observations.push((id, obs, explicit, paused(row)));
973
+ }
974
+ if team && plan.intent.verb == Verb::Restart {
975
+ let eligible: Vec<_> = observations
976
+ .iter()
977
+ .filter(|(_, _, explicit, paused)| !explicit && !paused)
978
+ .collect();
979
+ if eligible.is_empty() {
980
+ return Ok(no_eligible(
981
+ plan,
982
+ "use team start to explicitly activate a nonpaused roster",
983
+ ));
984
+ }
985
+ let shutdown = selected.state.get("status").and_then(Value::as_str) == Some("shutdown");
986
+ if !shutdown
987
+ && !eligible.iter().any(|(id, _, _, _)| {
988
+ rows.get(id.as_str())
989
+ .is_some_and(|row| !crate::state::projection::agent_stopped_for_restart(row))
990
+ })
991
+ {
992
+ plan.skipped.extend(eligible.iter().map(|(id,obs,_,_)| json!({"agent":id,"observation":obs,"reason":"stopped_preserved_by_original_restart"})));
993
+ return Ok(no_eligible(
994
+ plan,
995
+ "stopped roster requires explicit team start",
996
+ ));
997
+ }
998
+ if !shutdown
999
+ || eligible
1000
+ .iter()
1001
+ .any(|(_, obs, _, _)| *obs == Observation::Live)
1002
+ {
1003
+ for (id, obs, _, _) in &eligible {
1004
+ if rows
1005
+ .get(id.as_str())
1006
+ .is_some_and(crate::state::projection::agent_stopped_for_restart)
1007
+ {
1008
+ plan.skipped.push(json!({"agent":id,"observation":obs,"reason":"stopped_preserved_by_original_restart"}));
1009
+ }
1010
+ }
1011
+ plan.steps.clear();
1012
+ plan.steps.push(Step::RestartTeam(restart_args(&plan)));
1013
+ }
1014
+ }
1015
+ if team
1016
+ && plan.steps.is_empty()
1017
+ && !plan
1018
+ .skipped
1019
+ .iter()
1020
+ .any(|s| s.get("reason").and_then(Value::as_str) == Some("already_running"))
1021
+ {
1022
+ return Ok(no_eligible(plan, "no eligible nonpaused roster"));
1023
+ }
1024
+ Ok(plan)
1025
+ }
1026
+ fn restart_args(plan: &Plan) -> RestartArgs {
1027
+ RestartArgs {
1028
+ workspace: plan.scope.workspace.clone(),
1029
+ team: plan.scope.team.clone(),
1030
+ allow_fresh: plan.intent.session == SessionPolicy::Auto,
1031
+ session_converge_deadline_ms: plan.intent.session_converge_deadline_ms,
1032
+ json: true,
1033
+ detail: true,
1034
+ }
1035
+ }
1036
+
1037
+ pub(super) fn recheck(plan: &Plan, step: &Step, after_stop: bool) -> Result<(), CliError> {
1038
+ for (path, bytes) in &plan.files {
1039
+ if std::fs::read(path)? != *bytes {
1040
+ return Err(refusal(format!(
1041
+ "role/spec changed since planning: {}",
1042
+ path.display()
1043
+ )));
1044
+ }
1045
+ }
1046
+ if let Step::CreateTeam(args) = step {
1047
+ let role_dir = args.agents_dir.join("agents");
1048
+ let current: BTreeSet<_> = std::fs::read_dir(&role_dir)?
1049
+ .map(|entry| entry.map(|e| e.path()))
1050
+ .collect::<Result<Vec<_>, _>>()?
1051
+ .into_iter()
1052
+ .filter(|path| path.extension().is_some_and(|s| s == "md"))
1053
+ .collect();
1054
+ let planned: BTreeSet<_> = plan
1055
+ .files
1056
+ .iter()
1057
+ .map(|(path, _)| path)
1058
+ .filter(|path| path.parent() == Some(role_dir.as_path()))
1059
+ .cloned()
1060
+ .collect();
1061
+ if current != planned {
1062
+ return Err(refusal("creation roster changed since planning"));
1063
+ }
1064
+ return Ok(());
1065
+ }
1066
+ let selected = resolve_active_team_readonly(
1067
+ &plan.scope.workspace,
1068
+ plan.scope.team.as_deref(),
1069
+ SelectorMode::RuntimeOnly,
1070
+ )?;
1071
+ if Some(selected.team_key.as_str()) != plan.scope.team.as_deref()
1072
+ || selected.run_workspace != plan.scope.workspace
1073
+ {
1074
+ return Err(refusal("canonical scope changed"));
1075
+ }
1076
+ if plan
1077
+ .topology
1078
+ .as_ref()
1079
+ .is_some_and(|expected| topology(&selected.state) != *expected)
1080
+ {
1081
+ return Err(refusal("owner or transport binding changed since planning"));
1082
+ }
1083
+ let id = step.agent().map(AgentId::new);
1084
+ checked(crate::lifecycle::launch::ensure_owner_allowed_for_state(
1085
+ &selected.state,
1086
+ id.as_ref(),
1087
+ ))?;
1088
+ let targets: Vec<_> = if let Some(id) = step.agent() {
1089
+ vec![id]
1090
+ } else {
1091
+ plan.expected.keys().map(String::as_str).collect()
1092
+ };
1093
+ for id in targets {
1094
+ let row = selected.state.get("agents").and_then(|a| a.get(id));
1095
+ if matches!(step, Step::AddAgent(_)) {
1096
+ if row.is_some() {
1097
+ return Err(refusal("agent was registered after planning"));
1098
+ }
1099
+ continue;
1100
+ }
1101
+ let row = row.ok_or_else(|| refusal("planned agent disappeared"))?;
1102
+ let expected = plan
1103
+ .expected
1104
+ .get(id)
1105
+ .ok_or_else(|| refusal("missing original incarnation"))?;
1106
+ if after_stop && plan.intent.verb == Verb::Restart && matches!(step, Step::StartAgent(_)) {
1107
+ if !same_provider(provider(row), provider(expected))
1108
+ || row.get("generation").unwrap_or(&Value::Null)
1109
+ != expected.get("generation").unwrap_or(&Value::Null)
1110
+ || observe(&selected, id, row)? != Observation::StoppedVerified
1111
+ {
1112
+ return Err(refusal(
1113
+ "post-stop incarnation is not the planned stopped agent",
1114
+ ));
1115
+ }
1116
+ } else {
1117
+ let mut expected = expected.clone();
1118
+ let birth = expected.as_object_mut().and_then(|o| o.remove("_birth"));
1119
+ if relevant(row) != expected {
1120
+ return Err(refusal("planned agent incarnation changed"));
1121
+ }
1122
+ #[cfg(unix)]
1123
+ if let Some(birth) = birth {
1124
+ let pid = row
1125
+ .get("pane_pid")
1126
+ .and_then(Value::as_u64)
1127
+ .and_then(|p| u32::try_from(p).ok())
1128
+ .ok_or_else(|| refusal("missing original carrier"))?;
1129
+ if json!(checked(
1130
+ team_agent_contract::host::process::process_birth_identity(pid)
1131
+ )?) != birth
1132
+ {
1133
+ return Err(refusal("carrier replaced since planning"));
1134
+ }
1135
+ }
1136
+ }
1137
+ }
1138
+ Ok(())
1139
+ }