@team-agent/installer 0.5.104 → 0.5.106

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 (38) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +5 -3
  4. package/README.zh.md +4 -3
  5. package/crates/team-agent/src/cli/adapters.rs +17 -1
  6. package/crates/team-agent/src/cli/emit.rs +135 -18
  7. package/crates/team-agent/src/cli/mod.rs +156 -35
  8. package/crates/team-agent/src/cli/spec.rs +2 -2
  9. package/crates/team-agent/src/cli/status_port/brief.rs +62 -3
  10. package/crates/team-agent/src/cli/tests/run_delegation.rs +5 -2
  11. package/crates/team-agent/src/cli/tests/status_send.rs +2 -2
  12. package/crates/team-agent/src/cli/types.rs +3 -1
  13. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +6 -2
  14. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +6 -4
  15. package/crates/team-agent/src/lifecycle/launch/role_source.rs +1 -1
  16. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +7 -2
  17. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -3
  18. package/crates/team-agent/src/lifecycle/launch.rs +4 -1
  19. package/crates/team-agent/src/lifecycle/mod.rs +1 -0
  20. package/crates/team-agent/src/lifecycle/restart/agent.rs +75 -0
  21. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +24 -17
  22. package/crates/team-agent/src/lifecycle/restart.rs +1 -1
  23. package/crates/team-agent/src/lifecycle/role_config.rs +1123 -0
  24. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +22 -0
  25. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +43 -0
  26. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +71 -0
  27. package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +6 -2
  28. package/crates/team-agent/src/state/projection.rs +15 -0
  29. package/crates/team-agent/src/state/repository.rs +2 -1
  30. package/crates/team-agent/src/tmux_backend/tests.rs +137 -0
  31. package/crates/team-agent/src/tmux_backend.rs +110 -0
  32. package/crates/team-agent/src/transport/test_support.rs +35 -0
  33. package/crates/team-agent/src/transport/tests/wire.rs +32 -3
  34. package/crates/team-agent/src/transport.rs +26 -12
  35. package/package.json +4 -4
  36. package/skills/team-agent/SKILL.md +55 -20
  37. package/skills/team-agent/references/recovery-runbook.md +2 -2
  38. package/skills/team-agent/references/team-agent-operator.md +14 -6
@@ -1,4 +1,4 @@
1
- //! Seven-field, read-only status projection backed by native tmux and process samples.
1
+ //! Read-only status: native runtime samples plus accepted launch model/effort.
2
2
  //!
3
3
  //! This intentionally does not reuse RuntimeSnapshot: the legacy snapshot reads
4
4
  //! coordinator/db/history and carries diagnostic fields that are outside the
@@ -82,6 +82,8 @@ static NODEPROBE_RESOLVER_ACTIVE: AtomicBool = AtomicBool::new(false);
82
82
  struct RegisteredNode {
83
83
  name: String,
84
84
  provider: String,
85
+ model: Option<String>,
86
+ effort: Option<String>,
85
87
  endpoint: Option<String>,
86
88
  session: Option<String>,
87
89
  window: Option<String>,
@@ -106,6 +108,8 @@ struct ProbeNode {
106
108
  struct BriefNode {
107
109
  name: String,
108
110
  provider: String,
111
+ model: Option<String>,
112
+ effort: Option<String>,
109
113
  runtime_status: String,
110
114
  activity: String,
111
115
  health: String,
@@ -157,7 +161,7 @@ pub(crate) fn status_brief_scoped(
157
161
  })
158
162
  }
159
163
 
160
- /// Human output is the exact same seven-field projection as JSON, rendered in
164
+ /// Human output is the exact same nine-field projection as JSON, rendered in
161
165
  /// stable field order. Null values remain explicit rather than becoming a
162
166
  /// diagnostic or an inferred value.
163
167
  pub(crate) fn format_status_brief(
@@ -305,9 +309,11 @@ fn write_test_capability_receipt(path: &Path) {
305
309
 
306
310
  fn format_brief_node(value: &Value) -> Option<String> {
307
311
  Some(format!(
308
- "name: {} provider: {} runtime_status: {} activity: {} health: {} session_name: {} tmux_command: {}",
312
+ "name: {} provider: {} model: {} effort: {} runtime_status: {} activity: {} health: {} session_name: {} tmux_command: {}",
309
313
  value.get("name")?.as_str()?,
310
314
  value.get("provider")?.as_str()?,
315
+ display_optional(value.get("model")?),
316
+ display_optional(value.get("effort")?),
311
317
  value.get("runtime_status")?.as_str()?,
312
318
  value.get("activity")?.as_str()?,
313
319
  value.get("health")?.as_str()?,
@@ -369,6 +375,10 @@ fn registered_node(name: &str, value: &Value, state: &Value) -> RegisteredNode {
369
375
  RegisteredNode {
370
376
  name: name.to_string(),
371
377
  provider,
378
+ // Accepted launch settings, not pending role-file edits or guessed
379
+ // provider defaults. Runtime liveness remains a separate signal.
380
+ model: string_at(value, &["model"]),
381
+ effort: string_at(value, &["effort"]),
372
382
  endpoint: endpoint(value).or_else(|| endpoint(state)),
373
383
  session: first_string(value, &["session_name", "session"])
374
384
  .or_else(|| {
@@ -1052,6 +1062,8 @@ fn project_node(node: &RegisteredNode, probe: Option<&Option<ProbeResult>>) -> B
1052
1062
  MatchResult::Missing => BriefNode {
1053
1063
  name: node.name.clone(),
1054
1064
  provider: node.provider.clone(),
1065
+ model: node.model.clone(),
1066
+ effort: node.effort.clone(),
1055
1067
  runtime_status: "stopped".to_string(),
1056
1068
  activity: "unknown".to_string(),
1057
1069
  health: "unknown".to_string(),
@@ -1092,6 +1104,8 @@ fn project_node(node: &RegisteredNode, probe: Option<&Option<ProbeResult>>) -> B
1092
1104
  } else {
1093
1105
  node.provider.clone()
1094
1106
  },
1107
+ model: node.model.clone(),
1108
+ effort: node.effort.clone(),
1095
1109
  runtime_status: "running".to_string(),
1096
1110
  activity: activity.to_string(),
1097
1111
  health: health.to_string(),
@@ -1172,6 +1186,8 @@ impl BriefNode {
1172
1186
  Self {
1173
1187
  name: node.name.clone(),
1174
1188
  provider: node.provider.clone(),
1189
+ model: node.model.clone(),
1190
+ effort: node.effort.clone(),
1175
1191
  runtime_status: "unknown".to_string(),
1176
1192
  activity: "unknown".to_string(),
1177
1193
  health: "unknown".to_string(),
@@ -1184,6 +1200,8 @@ impl BriefNode {
1184
1200
  let mut object = Map::new();
1185
1201
  object.insert("name".to_string(), json!(self.name));
1186
1202
  object.insert("provider".to_string(), json!(self.provider));
1203
+ object.insert("model".to_string(), json!(self.model));
1204
+ object.insert("effort".to_string(), json!(self.effort));
1187
1205
  object.insert("runtime_status".to_string(), json!(self.runtime_status));
1188
1206
  object.insert("activity".to_string(), json!(self.activity));
1189
1207
  object.insert("health".to_string(), json!(self.health));
@@ -1208,6 +1226,8 @@ mod tests {
1208
1226
  RegisteredNode {
1209
1227
  name: "worker".to_string(),
1210
1228
  provider: "pi".to_string(),
1229
+ model: None,
1230
+ effort: None,
1211
1231
  endpoint: Some("/tmp/tmux socket/o'k".to_string()),
1212
1232
  session: Some("sess".to_string()),
1213
1233
  window: Some("win".to_string()),
@@ -1234,6 +1254,45 @@ mod tests {
1234
1254
  br#"{"schema_version":1,"socket":"/tmp/sock","sampled_at":"2026-01-01T00:00:00Z","nodes":[],"error":{"kind":"tmux_inventory","message":"missing"}}"#
1235
1255
  }
1236
1256
 
1257
+ #[test]
1258
+ fn status_model_effort_project_accepted_launch_settings_in_all_runtime_states() {
1259
+ for settings in [(None, None), (Some("openai-codex/gpt-6-luna"), Some("max"))] {
1260
+ let mut node = registered();
1261
+ node.model = settings.0.map(str::to_string);
1262
+ node.effort = settings.1.map(str::to_string);
1263
+ for lifecycle in [None, Some("stopped")] {
1264
+ node.lifecycle = lifecycle.map(str::to_string);
1265
+ for sample in [None, Some(ProbeResult { nodes: vec![probe("pi_activity_channel")] })] {
1266
+ let value = project_node(&node, Some(&sample)).into_json();
1267
+ assert_eq!(value["model"], json!(settings.0));
1268
+ assert_eq!(value["effort"], json!(settings.1));
1269
+ let human = format_brief_node(&value).unwrap();
1270
+ assert!(human.contains(&format!("model: {} effort: {}", settings.0.unwrap_or("null"), settings.1.unwrap_or("null"))));
1271
+ assert_eq!(value.as_object().unwrap().len(), 9);
1272
+ }
1273
+ }
1274
+ }
1275
+ }
1276
+
1277
+ #[test]
1278
+ fn status_model_effort_use_canonical_runtime_roster_not_stale_projection() {
1279
+ let state = json!({
1280
+ "active_team_key": "selected",
1281
+ "agents": {"worker": {"model": "stale-model", "effort": "low"}},
1282
+ "teams": {"selected": {"agents": {
1283
+ "worker": {"model": "accepted-model", "effort": "max"},
1284
+ "plain": {}
1285
+ }}}
1286
+ });
1287
+ let selected = registered_nodes(&state, Some("worker"));
1288
+ assert_eq!(selected.len(), 1);
1289
+ assert_eq!(selected[0].model.as_deref(), Some("accepted-model"));
1290
+ assert_eq!(selected[0].effort.as_deref(), Some("max"));
1291
+ let plain = registered_nodes(&state, Some("plain"));
1292
+ assert!(plain[0].model.is_none());
1293
+ assert!(plain[0].effort.is_none());
1294
+ }
1295
+
1237
1296
  #[test]
1238
1297
  fn pi_pane_title_is_unknown_not_idle() {
1239
1298
  let node = registered();
@@ -317,6 +317,7 @@ fn cli_add_agent_duplicate_id_surfaces_real_error() {
317
317
  let dup_role = team.join("dup-role.md");
318
318
  std::fs::write(&dup_role, DELEG_VALID_ROLE).unwrap(); // role file must exist
319
319
  let args = AddAgentArgs {
320
+ role_config: Default::default(),
320
321
  agent: "implementer".to_string(),
321
322
  workspace: team,
322
323
  team: None,
@@ -332,12 +333,12 @@ fn cli_add_agent_duplicate_id_surfaces_real_error() {
332
333
  );
333
334
  }
334
335
 
335
- // 5 [P1] — cmd_status --json emits the concise seven-field node projection.
336
+ // 5 [P1] — cmd_status --json emits the concise nine-field node projection.
336
337
  // Status must not leak coordinator/db/history diagnostics; missing exact nodeprobe evidence remains
337
338
  // an explicit unknown projection. The fixture still seeds a real store to ensure the status path
338
339
  // does not accidentally depend on coordinator or mailbox reads.
339
340
  #[test]
340
- fn cli_status_emits_seven_field_brief_without_diagnostics() {
341
+ fn cli_status_emits_nine_field_brief_without_diagnostics() {
341
342
  let ws = seed_status_workspace(); // writes .team/runtime/state.json
342
343
  let store = crate::message_store::MessageStore::open(&ws).unwrap();
343
344
  let _ = store
@@ -398,6 +399,8 @@ fn cli_status_emits_seven_field_brief_without_diagnostics() {
398
399
  [
399
400
  "name",
400
401
  "provider",
402
+ "model",
403
+ "effort",
401
404
  "runtime_status",
402
405
  "activity",
403
406
  "health",
@@ -345,7 +345,7 @@ fn status_compact_reports_identity_slot_health_on_grok_identity_slot_ok() {
345
345
  }
346
346
 
347
347
  #[test]
348
- fn cmd_status_human_is_seven_field_brief_without_diagnostics() {
348
+ fn cmd_status_human_is_nine_field_brief_without_diagnostics() {
349
349
  let ws = seed_status_workspace();
350
350
  let args = StatusArgs {
351
351
  agent: None,
@@ -364,7 +364,7 @@ fn cmd_status_human_is_seven_field_brief_without_diagnostics() {
364
364
 
365
365
  assert!(
366
366
  text.contains(
367
- "name: a1 provider: unknown runtime_status: unknown activity: unknown health: unknown session_name: null tmux_command: null"
367
+ "name: a1 provider: unknown model: null effort: null runtime_status: unknown activity: unknown health: unknown session_name: null tmux_command: null"
368
368
  ),
369
369
  "{text}"
370
370
  );
@@ -475,6 +475,7 @@ pub struct RestartArgs {
475
475
  /// `start-agent`(`parser.py:369`)。
476
476
  #[derive(Debug, Clone, PartialEq, Eq)]
477
477
  pub struct StartAgentArgs {
478
+ pub role_config: crate::lifecycle::role_config::RoleConfigPatch,
478
479
  pub agent: String,
479
480
  pub workspace: PathBuf,
480
481
  pub team: Option<String>,
@@ -502,9 +503,10 @@ pub struct ResetAgentArgs {
502
503
  pub json: bool,
503
504
  }
504
505
 
505
- /// `add-agent`(`parser.py:395`)。`--role-file` 必需。
506
+ /// `add-agent`: provider and bypass are explicit CLI or role-file decisions.
506
507
  #[derive(Debug, Clone, PartialEq, Eq)]
507
508
  pub struct AddAgentArgs {
509
+ pub role_config: crate::lifecycle::role_config::RoleConfigPatch,
508
510
  pub agent: String,
509
511
  pub workspace: PathBuf,
510
512
  pub team: Option<String>,
@@ -418,7 +418,7 @@ pub(crate) fn add_agent_with_transport_force(
418
418
  /// returns: 新席报告;成功后还要过快照的一致性校验
419
419
  /// errors: 角色文件不存在先行返回 Compile;摘除、加回或一致性校验失败时按快照恢复并返回错误
420
420
  /// ---
421
- pub(super) fn force_recreate_with_transport_locked(
421
+ pub(crate) fn force_recreate_with_transport_locked(
422
422
  run_workspace: &Path,
423
423
  team_dir: &Path,
424
424
  agent_id: &AgentId,
@@ -546,7 +546,7 @@ pub(super) fn add_agent_with_transport_at_paths(
546
546
  }
547
547
 
548
548
  #[allow(clippy::too_many_arguments)]
549
- fn add_agent_with_transport_at_paths_locked(
549
+ pub(crate) fn add_agent_with_transport_at_paths_locked(
550
550
  run_workspace: &Path,
551
551
  team_dir: &Path,
552
552
  agent_id: &AgentId,
@@ -683,6 +683,7 @@ fn add_agent_with_transport_at_paths_reserved(
683
683
  &spec_path,
684
684
  pre_spec_text.as_deref(),
685
685
  None,
686
+ Some(&canonical_team_key),
686
687
  agent_id,
687
688
  "state_upsert_failed",
688
689
  );
@@ -705,6 +706,7 @@ fn add_agent_with_transport_at_paths_reserved(
705
706
  &spec_path,
706
707
  pre_spec_text.as_deref(),
707
708
  None,
709
+ Some(&canonical_team_key),
708
710
  agent_id,
709
711
  "start_agent_failed",
710
712
  );
@@ -721,6 +723,7 @@ fn add_agent_with_transport_at_paths_reserved(
721
723
  &spec_path,
722
724
  pre_spec_text.as_deref(),
723
725
  None,
726
+ Some(&canonical_team_key),
724
727
  agent_id,
725
728
  "start_agent_noop",
726
729
  );
@@ -734,6 +737,7 @@ fn add_agent_with_transport_at_paths_reserved(
734
737
  &spec_path,
735
738
  pre_spec_text.as_deref(),
736
739
  None,
740
+ Some(&canonical_team_key),
737
741
  agent_id,
738
742
  "added_agent_paused",
739
743
  );
@@ -55,6 +55,7 @@ pub(super) fn rollback_add_agent_atomic(
55
55
  spec_path: &Path,
56
56
  pre_spec_text: Option<&str>,
57
57
  pre_runtime_state: Option<&serde_json::Value>,
58
+ team_key: Option<&str>,
58
59
  agent_id: &AgentId,
59
60
  reason: &str,
60
61
  ) {
@@ -86,7 +87,7 @@ pub(super) fn rollback_add_agent_atomic(
86
87
  crate::state::repository::StateRepository::new(run_workspace)
87
88
  .save(
88
89
  crate::state::repository::StateWriteIntent::AgentRollback {
89
- team_key: None,
90
+ team_key,
90
91
  agent_id: agent_id.as_str(),
91
92
  },
92
93
  state,
@@ -94,7 +95,7 @@ pub(super) fn rollback_add_agent_atomic(
94
95
  .is_ok()
95
96
  } else {
96
97
  // No prior runtime state — drop just the agent we added (load → strip → save).
97
- if let Ok(mut state) = crate::state::persist::load_runtime_state(run_workspace) {
98
+ if let Ok(mut state) = crate::state::projection::select_runtime_state(run_workspace, team_key) {
98
99
  if let Some(agents) = state
99
100
  .get_mut("agents")
100
101
  .and_then(serde_json::Value::as_object_mut)
@@ -105,7 +106,8 @@ pub(super) fn rollback_add_agent_atomic(
105
106
  .get_mut("teams")
106
107
  .and_then(serde_json::Value::as_object_mut)
107
108
  {
108
- for team in teams.values_mut() {
109
+ for (key, team) in teams.iter_mut() {
110
+ if team_key.is_some_and(|selected| selected != key) { continue; }
109
111
  if let Some(agents) = team
110
112
  .get_mut("agents")
111
113
  .and_then(serde_json::Value::as_object_mut)
@@ -117,7 +119,7 @@ pub(super) fn rollback_add_agent_atomic(
117
119
  crate::state::repository::StateRepository::new(run_workspace)
118
120
  .save(
119
121
  crate::state::repository::StateWriteIntent::AgentRollback {
120
- team_key: None,
122
+ team_key,
121
123
  agent_id: agent_id.as_str(),
122
124
  },
123
125
  &state,
@@ -39,7 +39,7 @@ fn set_yaml_map_value(value: &mut Value, key: &str, next: Value) -> Result<(), L
39
39
  Ok(())
40
40
  }
41
41
 
42
- fn dump_role_frontmatter(meta: &Value) -> String {
42
+ pub(crate) fn dump_role_frontmatter(meta: &Value) -> String {
43
43
  let Value::Map(pairs) = meta else {
44
44
  return yaml::dumps(meta);
45
45
  };
@@ -327,8 +327,13 @@ pub(crate) fn write_spec_atomic(spec_path: &Path, spec: &Value) -> Result<(), Li
327
327
  .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", parent.display())))?;
328
328
  }
329
329
  let tmp = spec_path.with_extension(format!("tmp-{}", std::process::id()));
330
- std::fs::write(&tmp, yaml::dumps(spec))
331
- .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", tmp.display())))?;
330
+ if let Err(e) = std::fs::write(&tmp, yaml::dumps(spec)) {
331
+ let _ = std::fs::remove_file(&tmp);
332
+ return Err(LifecycleError::StatePersist(format!(
333
+ "{}: {e}",
334
+ tmp.display()
335
+ )));
336
+ }
332
337
  if let Err(e) = std::fs::rename(&tmp, spec_path) {
333
338
  let _ = std::fs::remove_file(&tmp);
334
339
  return Err(LifecycleError::StatePersist(format!(
@@ -170,12 +170,17 @@ pub(super) fn persist_spawn_agent_state(
170
170
  )?,
171
171
  );
172
172
  }
173
+ let generation = started
174
+ .first()
175
+ .map(|agent| agent.spawned_at.as_str())
176
+ .unwrap_or(session_name.as_str());
177
+ // Use the same birth identity as the native session owner marker. Worker
178
+ // spawned_at may change on start-agent; this session generation must not.
173
179
  if let Some(obj) = state.as_object_mut() {
174
180
  obj.insert("agents".to_string(), serde_json::Value::Object(agents));
181
+ obj.insert("generation".to_string(), serde_json::json!(generation));
175
182
  } else {
176
- let mut obj = serde_json::Map::new();
177
- obj.insert("agents".to_string(), serde_json::Value::Object(agents));
178
- state = serde_json::Value::Object(obj);
183
+ state = serde_json::json!({"agents": agents, "generation": generation});
179
184
  }
180
185
  save_launched_team_state_for_key(workspace, &state, Some(&team_id), None)
181
186
  }
@@ -402,7 +402,10 @@ pub(super) use readiness::*;
402
402
  mod add_agent;
403
403
  pub(super) use add_agent::*;
404
404
  pub use add_agent::{add_agent, add_agent_force};
405
- pub(crate) use add_agent::{add_agent_with_transport, add_agent_with_transport_force};
405
+ pub(crate) use add_agent::{
406
+ add_agent_with_transport, add_agent_with_transport_force,
407
+ add_agent_with_transport_at_paths_locked, force_recreate_with_transport_locked,
408
+ };
406
409
 
407
410
  pub(crate) mod add_agent_state;
408
411
  pub(super) use add_agent_state::*;
@@ -57,6 +57,7 @@ pub(crate) mod pane_input_lock;
57
57
  pub(crate) mod profile_launch;
58
58
  pub(crate) mod profile_smoke;
59
59
  pub mod restart;
60
+ pub mod role_config;
60
61
  pub mod types;
61
62
  pub(crate) mod worker_command_context;
62
63
 
@@ -37,6 +37,37 @@ use super::team_state::write_team_state;
37
37
  use super::*;
38
38
  use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
39
39
 
40
+ /// CLI start is orthogonal to stop: refuse any live target before editing its role.
41
+ /// Transport uncertainty is a refusal, not permission to spawn a duplicate.
42
+ pub(crate) fn ensure_agent_not_running(
43
+ state: &serde_json::Value,
44
+ agent_id: &AgentId,
45
+ transport: &dyn crate::transport::Transport,
46
+ ) -> Result<(), LifecycleError> {
47
+ let agent = state.get("agents").and_then(|agents| agents.get(agent_id.as_str()))
48
+ .ok_or_else(|| LifecycleError::RequirementUnmet(format!("agent {agent_id} not found")))?;
49
+ let session = state_session_name(state);
50
+ let window = agent_window(agent, agent_id);
51
+ let pane = agent.get("pane_id").and_then(serde_json::Value::as_str);
52
+ let targets = transport.list_targets()
53
+ .map_err(|error| LifecycleError::Transport(error.to_string()))?;
54
+ let live = targets.iter().find(|target| {
55
+ target.session == session
56
+ && target.window_name.as_ref().is_some_and(|name| name.as_str() == window)
57
+ && (pane == Some(target.pane_id.as_str())
58
+ || !crate::lifecycle::launch::state_uses_adaptive_layout(state))
59
+ && agent_pane_live_by_id(transport, &target.pane_id)
60
+ });
61
+ if let Some(target) = live {
62
+ Err(LifecycleError::RequirementUnmet(format!(
63
+ "agent {agent_id} is already running (duplicate / active cohort); use stop-agent first; cohort proof: session={} window={window} pane={}",
64
+ session.as_str(), target.pane_id.as_str(),
65
+ )))
66
+ } else {
67
+ Ok(())
68
+ }
69
+ }
70
+
40
71
  /// ---
41
72
  /// purpose: 起或复活一席的对外入口
42
73
  /// params:
@@ -342,6 +373,8 @@ pub(crate) fn start_agent_at_paths(
342
373
  };
343
374
  let into_existing_session =
344
375
  session_live_or_default(transport, &session_name, session_name_present(&state));
376
+ let inherited_generation =
377
+ crate::state::projection::session_generation(&state).map(str::to_string);
345
378
  let safety = crate::lifecycle::launch::effective_runtime_config_for_worker_spawn_json(
346
379
  &agent,
347
380
  provider,
@@ -410,6 +443,24 @@ pub(crate) fn start_agent_at_paths(
410
443
  &safety,
411
444
  start_mode,
412
445
  )?;
446
+ let generation = if into_existing_session {
447
+ inherited_generation.as_deref()
448
+ } else {
449
+ Some(spawn.spawned_at.as_str())
450
+ };
451
+ if let Some(generation) = generation {
452
+ state["generation"] = serde_json::json!(generation);
453
+ if !into_existing_session {
454
+ transport
455
+ .set_session_owner_with_generation(
456
+ &session_name,
457
+ workspace,
458
+ &resolved_team_key,
459
+ generation,
460
+ )
461
+ .map_err(|error| LifecycleError::Transport(error.to_string()))?;
462
+ }
463
+ }
413
464
  // 0.5.66 bypass 单源 §2.7:启动即带 bypass → 审计留痕。
414
465
  if safety.enabled {
415
466
  let state_agent = state
@@ -1125,6 +1176,30 @@ pub(super) fn stop_agent_at_paths(
1125
1176
  || !same_role_panes.is_empty()
1126
1177
  || (pane_id.is_none() && window_exists(transport, &session_name, &window));
1127
1178
  if stopped {
1179
+ let stop_targets =
1180
+ if let Some(pane) = pane_id.as_ref().filter(|_| stored_pane_live) {
1181
+ vec![Target::Pane(pane.clone())]
1182
+ } else if !same_role_panes.is_empty() {
1183
+ same_role_panes
1184
+ .iter()
1185
+ .map(|pane| Target::Pane(pane.pane_id.clone()))
1186
+ .collect()
1187
+ } else {
1188
+ vec![Target::SessionWindow {
1189
+ session: session_name.clone(),
1190
+ window: WindowName::new(&window),
1191
+ }]
1192
+ };
1193
+ // Explicit kill-pane/kill-window ignores remain-on-exit on the worker.
1194
+ // Preserve the last window BEFORE killing it, or the coordinator sees
1195
+ // a missing session and stops while the seat is merely being stopped.
1196
+ transport
1197
+ .preserve_session_before_stop(&session_name, &stop_targets)
1198
+ .map_err(|error| {
1199
+ LifecycleError::Transport(format!(
1200
+ "failed to preserve session before stopping agent {agent_id}: {error}"
1201
+ ))
1202
+ })?;
1128
1203
  // golden operations.py:84-86: a non-zero kill-window raises
1129
1204
  // RuntimeError(f"failed to stop agent {agent_id}: {proc.stderr.strip()}").
1130
1205
  //
@@ -509,6 +509,8 @@ fn restart_with_selected_team_and_transport(
509
509
  let live_worker_session_deferred_teardown =
510
510
  session_live_or_default(transport, &session_name, false)
511
511
  && !collect_live_agents_from_state(&state).is_empty();
512
+ let inherited_generation =
513
+ crate::state::projection::session_generation(&state).map(str::to_string);
512
514
  let same_role_cohort_targets =
513
515
  same_role_cohort_targets_from_decisions(&state, spec_workspace, &plan.decisions);
514
516
  let pre_spawn_pane_ids = if live_worker_session_deferred_teardown {
@@ -998,6 +1000,25 @@ fn restart_with_selected_team_and_transport(
998
1000
  }
999
1001
  }
1000
1002
  }
1003
+ if !fatal_resume_failure && !successful_agents.is_empty() {
1004
+ let generation = if live_worker_session_deferred_teardown {
1005
+ inherited_generation
1006
+ } else {
1007
+ // A recreated session gets the first successful NEW worker's birth,
1008
+ // not an arbitrary old stopped worker still present in the roster.
1009
+ successful_agents.first().and_then(|agent| {
1010
+ state
1011
+ .get("agents")?
1012
+ .get(agent.agent_id.as_str())?
1013
+ .get("spawned_at")?
1014
+ .as_str()
1015
+ .map(str::to_string)
1016
+ })
1017
+ };
1018
+ if let Some(generation) = generation {
1019
+ state["generation"] = serde_json::json!(generation);
1020
+ }
1021
+ }
1001
1022
  save_restart_state_with_lifecycle_topology_authority_and_capture_backfill_skip(
1002
1023
  &selected.run_workspace,
1003
1024
  &mut state,
@@ -1040,23 +1061,9 @@ fn restart_with_selected_team_and_transport(
1040
1061
  attach_commands,
1041
1062
  });
1042
1063
  }
1043
- // Respawn changes the generation used by shutdown's ownership check, even
1044
- // when build-before-destroy keeps the tmux session itself alive. Stamp only
1045
- // the selected session, after its replacement state is durable.
1046
- if let Some(generation) = state
1047
- .get("generation")
1048
- .and_then(serde_json::Value::as_str)
1049
- .or_else(|| {
1050
- state
1051
- .get("agents")
1052
- .and_then(serde_json::Value::as_object)
1053
- .and_then(|agents| {
1054
- agents.values().find_map(|agent| {
1055
- agent.get("spawned_at").and_then(serde_json::Value::as_str)
1056
- })
1057
- })
1058
- })
1059
- {
1064
+ // Stamp the physical session birth after durable state. Replacing workers
1065
+ // in a retained session does not create a new session generation.
1066
+ if let Some(generation) = crate::state::projection::session_generation(&state) {
1060
1067
  transport
1061
1068
  .set_session_owner_with_generation(
1062
1069
  &session_name,
@@ -77,7 +77,7 @@ pub(crate) mod remove;
77
77
  mod selection;
78
78
  mod team_state;
79
79
 
80
- pub(crate) use agent::start_agent_at_paths;
80
+ pub(crate) use agent::{start_agent_at_paths, ensure_agent_not_running};
81
81
  pub use agent::{reset_agent, start_agent, stop_agent};
82
82
  pub(crate) use agent::{
83
83
  reset_agent_with_transport, start_agent_with_transport, stop_agent_with_transport,