@team-agent/installer 0.5.115 → 0.5.116

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.
@@ -45,7 +45,7 @@ mod contract_route_tests {
45
45
  fn mixed_team_retains_legacy_classification_without_native_codex_fallback() {
46
46
  let state = serde_json::json!({"agents": {
47
47
  "native": {"provider":"cursor","status":"running","session_id":"native-id"},
48
- "legacy": {"provider":"pi","status":"stopped"},
48
+ "legacy": {"provider":"pi","status":"running"},
49
49
  }});
50
50
  let plan = classify_restart_plan(&state, false).unwrap();
51
51
  assert_eq!(plan.decisions.len(), 1);
@@ -54,6 +54,31 @@ mod contract_route_tests {
54
54
  }
55
55
  }
56
56
 
57
+ #[cfg(test)]
58
+ mod stopped_restart_tests {
59
+ use super::*;
60
+
61
+ #[test]
62
+ fn stopped_rows_never_enter_validation_or_fresh_spawn_even_with_allow_fresh() {
63
+ let state = serde_json::json!({"agents": {
64
+ "stopped": {"provider":"pi","status":"stopped","explicit_stop":true,
65
+ "session_id":"preserved","generation":7,"first_send_at":false},
66
+ "legacy_stopped": {"provider":"pi","status":"stopped"},
67
+ "marker": {"provider":"pi","status":"running","explicit_stop":true},
68
+ "active": {"provider":"pi","status":"running"}
69
+ }});
70
+ let before = state.clone();
71
+ for allow_fresh in [false, true] {
72
+ let plan = classify_restart_plan(&state, allow_fresh).unwrap();
73
+ assert_eq!(plan.decisions.len(), 1);
74
+ assert_eq!(plan.decisions[0].agent_id.as_str(), "active");
75
+ assert!(plan.corrupt_entries.is_empty());
76
+ assert!(plan.unresumable.is_empty());
77
+ assert_eq!(state, before);
78
+ }
79
+ }
80
+ }
81
+
57
82
  /// ---
58
83
  /// purpose: 定出该席位这次的启动模式
59
84
  /// params:
@@ -266,13 +291,13 @@ pub(crate) fn python_type_name(value: &serde_json::Value) -> &'static str {
266
291
  /// purpose: 纯计算出整队的 restart 计划
267
292
  /// params:
268
293
  /// allow_fresh: 允许无会话席位改跑全新会话
269
- /// returns: 每个非 paused 席位的 resume 决策、损坏的 first_send_at 条目与不可 resume 名单
294
+ /// returns: 每个非 paused/非 stopped 席位的 resume 决策、损坏的 first_send_at 条目与不可 resume 名单
270
295
  /// errors: 计算过程中的错误以 LifecycleError 返回
271
296
  /// contract_id: lifecycle.selection.classify_restart_plan
272
297
  /// ---
273
298
  /// Route B 全量验证(纯计算,**无破坏性副作用**;`_emit_resume_decisions` +
274
299
  /// `_collect_corrupt_first_send_at`,`orchestration.py:430/467`)。读 fixture state 的
275
- /// `agents.<id>`,对每非 paused worker:
300
+ /// `agents.<id>`,对每非 paused/非 stopped worker(explicit_stop 也跳过):
276
301
  /// (1) corrupt first_send_at → 收进 `corrupt_entries`(carry python type-name);
277
302
  /// (2) 算 resume 决策(`session_id→Resume` / `null session && allow_fresh→FreshStart` /
278
303
  /// 否则 `Refuse`;E6 层2:null session 不再因 first_send_at=null 静默 fresh);
@@ -317,11 +342,8 @@ pub(crate) fn classify_restart_plan_with_resume_validation(
317
342
  // not this legacy enum's unknown-provider Codex default.
318
343
  #[cfg(unix)]
319
344
  if crate::contract_runtime::framework::is_contract(agent) { continue; }
320
- if agent
321
- .get("status")
322
- .and_then(|v| v.as_str())
323
- .map(|s| s == "paused")
324
- .unwrap_or(false)
345
+ if crate::state::projection::agent_stopped_for_restart(agent)
346
+ || agent.get("status").and_then(|v| v.as_str()) == Some("paused")
325
347
  {
326
348
  continue;
327
349
  }
@@ -868,7 +868,8 @@ fn incomplete_resumable_provider<F>(agent: &Value, adapter_for: &mut F) -> Optio
868
868
  where
869
869
  F: FnMut(Provider) -> Box<dyn ProviderAdapter>,
870
870
  {
871
- if agent
871
+ if crate::state::projection::agent_explicit_stop(agent)
872
+ || agent
872
873
  .get("status")
873
874
  .and_then(Value::as_str)
874
875
  .is_some_and(|status| status != "running")
@@ -892,6 +893,28 @@ where
892
893
  Some(provider)
893
894
  }
894
895
 
896
+ #[cfg(test)]
897
+ mod stopped_capture_tests {
898
+ use super::*;
899
+
900
+ #[test]
901
+ fn explicit_stop_is_checked_before_adapter_or_backing_probes() {
902
+ for row in [
903
+ serde_json::json!({"status":"stopped","provider":"pi","spawn_cwd":"/controlled"}),
904
+ serde_json::json!({"status":"running","explicit_stop":true,"provider":"pi","spawn_cwd":"/controlled"}),
905
+ serde_json::json!({"status":"running","explicit_stop":"false","provider":"pi","spawn_cwd":"/controlled"}),
906
+ ] {
907
+ assert!(incomplete_resumable_provider(&row, &mut |_| panic!("held seat reached adapter lookup")).is_none());
908
+ }
909
+ let mut calls = 0;
910
+ let _ = incomplete_resumable_provider(
911
+ &serde_json::json!({"status":"running","explicit_stop":false,"provider":"pi","spawn_cwd":"/controlled"}),
912
+ &mut |provider| { calls += 1; crate::provider::get_adapter(provider) },
913
+ );
914
+ assert_eq!(calls, 1, "active control must still be eligible for capture");
915
+ }
916
+ }
917
+
895
918
  fn agent_session_complete(agent: &Value) -> bool {
896
919
  // RM-039-SESS-001 step 1 (architect verdict 2026-06-22): historically
897
920
  // this returned true the moment `rollout_path` was non-empty — even when
@@ -48,7 +48,7 @@ const SESSION_STATE_FIELDS: [&str; 6] = [
48
48
  ];
49
49
  const LIVE_TOPOLOGY_FIELDS: [&str; 5] =
50
50
  ["pane_id", "pane_pid", "window", "spawned_at", "spawn_epoch"];
51
- const ROSTER_STUB_ALLOWLIST: [&str; 16] = [
51
+ const ROSTER_STUB_ALLOWLIST: [&str; 17] = [
52
52
  "agent_id",
53
53
  "provider",
54
54
  "auth_mode",
@@ -65,6 +65,7 @@ const ROSTER_STUB_ALLOWLIST: [&str; 16] = [
65
65
  "claude_config_dir",
66
66
  "claude_projects_root",
67
67
  "profile_launch",
68
+ "explicit_stop",
68
69
  ];
69
70
 
70
71
  /// `state.py:_RUNTIME_STATE_CACHE`:进程级 path→state 缓存(deep-equal 早返回)。
@@ -626,6 +627,16 @@ fn persist_runtime_state_with_merge_options_and_expected(
626
627
  pub(super) fn update_runtime_state(
627
628
  workspace: &Path,
628
629
  update: impl FnOnce(&Value) -> Result<Value, StateError>,
630
+ ) -> Result<Value, StateError> {
631
+ update_runtime_state_with_lifecycle_target(workspace, None, update)
632
+ }
633
+
634
+ /// Keep bounded StartAgent's exact team/seat authority inside the same state-save
635
+ /// transaction. Ordinary deltas retain every existing stale-writer guard.
636
+ pub(super) fn update_runtime_state_with_lifecycle_target(
637
+ workspace: &Path,
638
+ target: Option<(&str, &str)>,
639
+ update: impl FnOnce(&Value) -> Result<Value, StateError>,
629
640
  ) -> Result<Value, StateError> {
630
641
  let path = runtime_state_path(workspace);
631
642
  let _lock = RuntimeLock::acquire(workspace, "state-save", 2.0)?;
@@ -639,7 +650,12 @@ pub(super) fn update_runtime_state(
639
650
  migrate_state_identity(&mut latest, &SystemEnv, workspace)?;
640
651
  let _ = migrate_active_team_key(&mut latest);
641
652
  let mut committed = update(&latest)?;
642
- merge_ordinary_state(&mut committed, &latest)?;
653
+ let topology_updates = target.into_iter()
654
+ .map(|(team, agent)| (team.to_owned(), agent.to_owned())).collect();
655
+ apply_persist_merge_contract(
656
+ &mut committed, &latest, &BTreeSet::new(), None, &BTreeSet::new(),
657
+ &topology_updates, &BTreeSet::new(), None, None, None,
658
+ )?;
643
659
  migrate_state_identity(&mut committed, &SystemEnv, workspace)?;
644
660
  crate::state::ownership::strip_top_level_ownership_if_canonical_present(&mut committed);
645
661
  if committed == latest {
@@ -1264,6 +1280,13 @@ fn merge_agent_projection(
1264
1280
  .is_some_and(|source| !source.is_empty());
1265
1281
  match incoming_map.entry(agent_id.clone()) {
1266
1282
  serde_json::map::Entry::Vacant(slot) => {
1283
+ // Omission by an ordinary/stale writer cannot retire a stopped
1284
+ // seat or discard its resume metadata. Explicit RemoveAgent
1285
+ // has already been admitted through deleted_agent_ids above.
1286
+ if crate::state::projection::agent_explicit_stop(latest_agent) {
1287
+ slot.insert(latest_agent.clone());
1288
+ continue;
1289
+ }
1267
1290
  if fork_target {
1268
1291
  slot.insert(latest_agent.clone());
1269
1292
  continue;
@@ -1295,6 +1318,17 @@ fn merge_agent_projection(
1295
1318
  }
1296
1319
  }
1297
1320
  serde_json::map::Entry::Occupied(mut existing) => {
1321
+ if !topology_update_agent_ids.contains(agent_id) {
1322
+ if crate::state::projection::agent_explicit_stop(latest_agent) {
1323
+ *existing.get_mut() = latest_agent.clone();
1324
+ continue;
1325
+ }
1326
+ if let Some(marker) = latest_agent.get("explicit_stop") {
1327
+ if let Some(row) = existing.get_mut().as_object_mut() {
1328
+ row.insert("explicit_stop".into(), marker.clone());
1329
+ }
1330
+ }
1331
+ }
1298
1332
  if !tombstoned_for_projection && !topology_update_agent_ids.contains(agent_id) {
1299
1333
  let fields = topology_conflict_fields(existing.get(), latest_agent, team_alive);
1300
1334
  if !fields.is_empty() {
@@ -2453,6 +2487,42 @@ mod tests {
2453
2487
  );
2454
2488
  }
2455
2489
 
2490
+ #[test]
2491
+ fn stopped_intent_and_resume_metadata_survive_stale_writers_until_targeted_start() {
2492
+ let ws = temp_ws();
2493
+ let held = json!({"agent_id":"held","provider":"codex","status":"stopped","explicit_stop":true,
2494
+ "session_id":"same-session","rollout_path":"/controlled/rollout","captured_at":"2026-10-12T00:00:00Z",
2495
+ "captured_via":"native","generation":7});
2496
+ let mut latest = json!({"session_name":"team-a","active_team_key":"team-a","agents":{"held":held},
2497
+ "teams":{"team-a":{"session_name":"team-a","agents":{"held":held}}}});
2498
+ normalize_agent_session_state(&mut latest);
2499
+ for omit in [false, true] {
2500
+ write_state(&ws, &latest);
2501
+ let before = read_state(&ws);
2502
+ let held_root = before.pointer("/agents/held").unwrap().clone();
2503
+ let held_team = before.pointer("/teams/team-a/agents/held").unwrap().clone();
2504
+ let rows = if omit { json!({}) } else { json!({"held":{"agent_id":"held","provider":"codex","status":"running","explicit_stop":false,"session_id":"stale"}}) };
2505
+ let stale = json!({"session_name":"team-a","active_team_key":"team-a","agents":rows,
2506
+ "teams":{"team-a":{"session_name":"team-a","agents":rows}},"delivery":{"message":"observed"}});
2507
+ save_runtime_state(&ws, &stale).unwrap();
2508
+ let saved = read_state(&ws);
2509
+ assert_eq!(saved.pointer("/agents/held"), Some(&held_root));
2510
+ assert_eq!(saved.pointer("/teams/team-a/agents/held"), Some(&held_team));
2511
+ assert_eq!(saved.pointer("/delivery/message"), Some(&json!("observed")));
2512
+ }
2513
+ let mut started = latest;
2514
+ for pointer in ["/agents/held", "/teams/team-a/agents/held"] {
2515
+ let row = started.pointer_mut(pointer).unwrap();
2516
+ row["status"] = json!("running");
2517
+ row["explicit_stop"] = json!(false);
2518
+ }
2519
+ save_runtime_state_with_lifecycle_topology_authority(&ws, &started, "team-a", &["held"]).unwrap();
2520
+ let saved = read_state(&ws);
2521
+ assert_eq!(saved.pointer("/agents/held/explicit_stop"), Some(&json!(false)));
2522
+ assert_eq!(saved.pointer("/agents/held/session_id"), Some(&json!("same-session")));
2523
+ assert_eq!(saved.pointer("/teams/team-a/agents/held/explicit_stop"), Some(&json!(false)));
2524
+ }
2525
+
2456
2526
  #[test]
2457
2527
  fn lifecycle_topology_authority_only_applies_to_target_agent() {
2458
2528
  let ws = temp_ws();
@@ -363,6 +363,20 @@ fn all_present_agents_terminal(team: &Value) -> bool {
363
363
  })
364
364
  }
365
365
 
366
+ /// Team restart never consumes an explicit stop or legacy stopped row.
367
+ /// A malformed persisted marker is held closed, not coerced to false.
368
+ pub(crate) fn agent_stopped_for_restart(agent: &Value) -> bool {
369
+ agent.get("status").and_then(Value::as_str)
370
+ .is_some_and(|status| status.eq_ignore_ascii_case("stopped"))
371
+ || agent_explicit_stop(agent)
372
+ }
373
+
374
+ /// Only an explicit persisted stop freezes metadata against ordinary writers.
375
+ /// Historical stopped rows still follow existing roster-pruning semantics.
376
+ pub(crate) fn agent_explicit_stop(agent: &Value) -> bool {
377
+ !matches!(agent.get("explicit_stop"), None | Some(Value::Bool(false)))
378
+ }
379
+
366
380
  pub(crate) fn agent_status_is_terminal(status: &str) -> bool {
367
381
  matches!(
368
382
  status.to_ascii_lowercase().as_str(),
@@ -1149,6 +1163,21 @@ mod tests {
1149
1163
  ws
1150
1164
  }
1151
1165
 
1166
+ #[test]
1167
+ fn restart_stop_marker_is_conservative_without_blocking_crash_states() {
1168
+ for row in [
1169
+ json!({"status":"stopped"}),
1170
+ json!({"status":"stopped","explicit_stop":false}),
1171
+ json!({"status":"running","explicit_stop":true}),
1172
+ json!({"status":"running","explicit_stop":"false"}),
1173
+ ] {
1174
+ assert!(agent_stopped_for_restart(&row));
1175
+ }
1176
+ for status in ["running", "dead", "restart_failed"] {
1177
+ assert!(!agent_stopped_for_restart(&json!({"status":status,"explicit_stop":false})));
1178
+ }
1179
+ }
1180
+
1152
1181
  #[test]
1153
1182
  fn team_state_key_cases() {
1154
1183
  assert_eq!(
@@ -112,3 +112,73 @@ fn coordinator_observations_keep_legacy_identity_and_shape()
112
112
  std::fs::remove_dir_all(workspace)?;
113
113
  Ok(())
114
114
  }
115
+
116
+ #[test]
117
+ fn stopped_start_commit_updates_exact_target_and_both_mirrors()
118
+ -> Result<(), Box<dyn std::error::Error>> {
119
+ use serde_json::json;
120
+ for target in ["team-a", "team-b"] {
121
+ let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_nanos();
122
+ let workspace = std::env::temp_dir().join(format!("state-start-stopped-{target}-{nonce}"));
123
+ let held = json!({"agent_id":"worker","provider":"grok_build","runtime_family":"contract",
124
+ "status":"stopped","explicit_stop":true,"generation":1,"contract_instance":"instance-one",
125
+ "session_id":"same-session","native_endpoint":"old-endpoint"});
126
+ let peer = json!({"agent_id":"peer","provider":"pi","status":"stopped","explicit_stop":true,
127
+ "session_id":"peer-session","generation":1});
128
+ let control = json!({"agent_id":"control","provider":"pi","status":"running","session_id":"control-session"});
129
+ let agents = json!({"worker":held,"peer":peer,"control":control});
130
+ let initial = json!({"session_name":"team-a","active_team_key":"team-a","agents":agents,
131
+ "teams":{"team-a":{"session_name":"team-a","status":"alive","agents":agents},
132
+ "team-b":{"session_name":"team-b","status":"alive","agents":agents}}});
133
+ let path = super::helper_workspace_path(&workspace);
134
+ std::fs::create_dir_all(path.parent().unwrap())?;
135
+ std::fs::write(&path, serde_json::to_vec(&initial)?)?;
136
+ let repository = super::StateRepository::new(&workspace);
137
+ let before = repository.load_workspace()?;
138
+ let other = if target == "team-a" { "team-b" } else { "team-a" };
139
+ let committed = repository.commit(StateWriteIntent::StartAgent { team_key: target, agent_id: "worker" }, |selected| {
140
+ let row = &mut selected["agents"]["worker"];
141
+ row["status"] = json!("running");
142
+ row["explicit_stop"] = json!(false);
143
+ row["generation"] = json!(2);
144
+ row["contract_instance"] = json!("instance-two");
145
+ row["native_endpoint"] = json!("new-endpoint");
146
+ // Start authority must not leak to an unrelated stopped peer.
147
+ selected["agents"]["peer"]["status"] = json!("running");
148
+ selected["agents"]["peer"]["explicit_stop"] = json!(false);
149
+ })?;
150
+ let saved = repository.load_workspace()?;
151
+ let team = &saved["teams"][target];
152
+ assert_eq!(team["agents"]["worker"]["status"], "running");
153
+ assert_eq!(team["agents"]["worker"]["generation"], 2);
154
+ assert_eq!(team["agents"]["worker"]["explicit_stop"], false);
155
+ assert_eq!(team["agents"]["worker"]["session_id"], "same-session");
156
+ assert_eq!(committed["agents"]["worker"], team["agents"]["worker"]);
157
+ assert!(!crate::state::projection::agent_stopped_for_restart(&team["agents"]["worker"]));
158
+ assert_eq!(team["agents"]["peer"], before["teams"][target]["agents"]["peer"]);
159
+ assert_eq!(team["agents"]["control"], before["teams"][target]["agents"]["control"]);
160
+ assert_eq!(saved["teams"][other], before["teams"][other]);
161
+ // Root normalization retains null session fields that compact team
162
+ // rows omit. Build an independent root oracle, not the compact schema.
163
+ let mut expected_root = before["agents"].clone();
164
+ if target == "team-a" {
165
+ let row = &mut expected_root["worker"];
166
+ row["status"] = json!("running");
167
+ row["explicit_stop"] = json!(false);
168
+ row["generation"] = json!(2);
169
+ row["contract_instance"] = json!("instance-two");
170
+ row["native_endpoint"] = json!("new-endpoint");
171
+ }
172
+ assert_eq!(saved["agents"], expected_root);
173
+ // An ordinary bounded delta still cannot wake a held peer.
174
+ repository.commit(StateWriteIntent::ResultCollection { owner_team_id: Some(target) }, |selected| {
175
+ selected["agents"]["peer"]["status"] = json!("running");
176
+ selected["agents"]["peer"]["explicit_stop"] = json!(false);
177
+ })?;
178
+ let ordinary = repository.load_workspace()?;
179
+ assert_eq!(ordinary["teams"][target]["agents"], team["agents"]);
180
+ assert_eq!(ordinary["agents"], saved["agents"]);
181
+ std::fs::remove_dir_all(workspace)?;
182
+ }
183
+ Ok(())
184
+ }
@@ -160,6 +160,10 @@ impl<'a> StateRepository<'a> {
160
160
  {
161
161
  #[cfg(test)]
162
162
  test_support::before_commit();
163
+ let lifecycle_target = match &intent {
164
+ StateWriteIntent::StartAgent { team_key, agent_id } => Some((*team_key, *agent_id)),
165
+ _ => None,
166
+ };
163
167
  let team_key = match intent {
164
168
  StateWriteIntent::ResultCollection { owner_team_id } => owner_team_id,
165
169
  StateWriteIntent::ForkAgent { team_key, .. }
@@ -170,7 +174,7 @@ impl<'a> StateRepository<'a> {
170
174
  ))
171
175
  }
172
176
  };
173
- let committed = super::persist::update_runtime_state(self.workspace, |latest| {
177
+ let committed = super::persist::update_runtime_state_with_lifecycle_target(self.workspace, lifecycle_target, |latest| {
174
178
  let mut selected = bounded_team_view(latest, team_key)?;
175
179
  update(&mut selected);
176
180
  Ok(match team_key {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.115",
3
+ "version": "0.5.116",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.5.115",
24
- "@team-agent/cli-darwin-x64": "0.5.115",
25
- "@team-agent/cli-linux-x64": "0.5.115"
23
+ "@team-agent/cli-darwin-arm64": "0.5.116",
24
+ "@team-agent/cli-darwin-x64": "0.5.116",
25
+ "@team-agent/cli-linux-x64": "0.5.116"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",