@team-agent/installer 0.3.12 → 0.3.14

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 (35) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +23 -0
  4. package/crates/team-agent/src/cli/leader.rs +2 -5
  5. package/crates/team-agent/src/cli/mod.rs +258 -79
  6. package/crates/team-agent/src/cli/tests/compile.rs +69 -0
  7. package/crates/team-agent/src/cli/tests/main_preserved.rs +68 -0
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +160 -2
  9. package/crates/team-agent/src/compiler.rs +30 -0
  10. package/crates/team-agent/src/coordinator/tick.rs +16 -83
  11. package/crates/team-agent/src/leader/lease.rs +4 -20
  12. package/crates/team-agent/src/leader/mod.rs +3 -1
  13. package/crates/team-agent/src/leader/owner_bind.rs +46 -48
  14. package/crates/team-agent/src/leader/provider_attribution.rs +214 -0
  15. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -3
  16. package/crates/team-agent/src/leader/rediscover.rs +34 -17
  17. package/crates/team-agent/src/leader/start.rs +279 -4
  18. package/crates/team-agent/src/lifecycle/launch.rs +279 -24
  19. package/crates/team-agent/src/lifecycle/restart/common.rs +50 -40
  20. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +343 -49
  21. package/crates/team-agent/src/lifecycle/restart/selection.rs +9 -6
  22. package/crates/team-agent/src/lifecycle/tests/core.rs +181 -45
  23. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +390 -61
  24. package/crates/team-agent/src/lifecycle/types.rs +28 -0
  25. package/crates/team-agent/src/messaging/delivery.rs +5 -1
  26. package/crates/team-agent/src/messaging/helpers.rs +1 -1
  27. package/crates/team-agent/src/messaging/tests/runtime.rs +14 -0
  28. package/crates/team-agent/src/provider/adapter.rs +6 -3
  29. package/crates/team-agent/src/provider/startup_prompt.rs +173 -0
  30. package/crates/team-agent/src/provider/testdata/copilot-ready-marker.txt +5 -0
  31. package/crates/team-agent/src/provider/testdata/copilot-trust-prompt.txt +19 -0
  32. package/crates/team-agent/src/tmux_backend.rs +5 -0
  33. package/crates/team-agent/src/transport/test_support.rs +22 -7
  34. package/crates/team-agent/src/transport.rs +4 -0
  35. package/package.json +4 -4
@@ -116,10 +116,9 @@ pub fn restart_with_transport_with_session_convergence_deadline(
116
116
  // 显式存在性门(下移后):selected.spec_path 经读序 B 已定位 runtime/legacy spec。
117
117
  // 缺(空目录 restart 等)→ 报真实期望路径,不误导去用户目录找。
118
118
  if !selected.spec_path.as_ref().is_some_and(|p| p.exists()) {
119
- let expected = selected
120
- .spec_path
121
- .clone()
122
- .unwrap_or_else(|| crate::model::paths::runtime_spec_path(&selected.run_workspace, &selected.team_key));
119
+ let expected = selected.spec_path.clone().unwrap_or_else(|| {
120
+ crate::model::paths::runtime_spec_path(&selected.run_workspace, &selected.team_key)
121
+ });
123
122
  return Err(LifecycleError::TeamSelect(format!(
124
123
  "missing spec for restart: {} (run `team-agent quick-start <teamdir>` first, or restore the team's role docs)",
125
124
  expected.display()
@@ -130,9 +129,11 @@ pub fn restart_with_transport_with_session_convergence_deadline(
130
129
  // E5 task#3 / RC-A6a + E4(leader 裁定:每次 restart 都从角色定义重建 runtime spec,覆盖):
131
130
  // 角色定义=第一真相源。角色齐 → compile_team 重建 + 保留运行期 override(session_name)+
132
131
  // 写 runtime spec。角色缺(TEAM.md/agents 不在)→ 显式拒(列缺哪些),旧 spec 原地保留不删不用。
133
- let spec = rebuild_runtime_spec_from_roles(&selected.run_workspace, &selected.team_key, &state)?;
132
+ let spec =
133
+ rebuild_runtime_spec_from_roles(&selected.run_workspace, &selected.team_key, &state)?;
134
134
  // 重建后 spec_workspace 恒为 runtime spec 的父目录(.team/runtime/<team_key>/)。
135
- let runtime_spec = crate::model::paths::runtime_spec_path(&selected.run_workspace, &selected.team_key);
135
+ let runtime_spec =
136
+ crate::model::paths::runtime_spec_path(&selected.run_workspace, &selected.team_key);
136
137
  let spec_workspace = runtime_spec.parent().ok_or_else(|| {
137
138
  LifecycleError::TeamSelect("active team spec workspace not found".to_string())
138
139
  })?;
@@ -173,13 +174,23 @@ pub fn restart_with_transport_with_session_convergence_deadline(
173
174
  crate::state::projection::save_team_scoped_state(&selected.run_workspace, &state)
174
175
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
175
176
  }
176
- let forced_fresh_missing = if convergence.converged {
177
+ let mut forced_fresh_missing = if convergence.converged {
177
178
  std::collections::BTreeSet::new()
178
179
  } else {
179
180
  convergence.missing.iter().cloned().collect()
180
181
  };
181
182
  let forced_fresh_convergence = (!convergence.converged).then_some(convergence.clone());
182
- let plan = classify_restart_plan_with_resume_validation(Some(&selected.run_workspace), &state, allow_fresh)?;
183
+ let plan = classify_restart_plan_with_resume_validation(
184
+ Some(&selected.run_workspace),
185
+ &state,
186
+ allow_fresh,
187
+ )?;
188
+ for decision in &plan.decisions {
189
+ if matches!(decision.decision, ResumeDecision::FreshStart) && decision.session_id.is_some()
190
+ {
191
+ forced_fresh_missing.insert(decision.agent_id.as_str().to_string());
192
+ }
193
+ }
183
194
  write_restart_resume_decision_events(
184
195
  &selected.run_workspace,
185
196
  &state,
@@ -212,35 +223,65 @@ pub fn restart_with_transport_with_session_convergence_deadline(
212
223
  crate::state::projection::save_team_scoped_state(&selected.run_workspace, &state)
213
224
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
214
225
  }
215
- let mut last_spawned: Option<AgentId> = None;
226
+ let mut successful_agents: Vec<RestartedAgent> = Vec::new();
227
+ let mut failed_agents: Vec<RestartFailedAgent> = Vec::new();
228
+ let mut fatal_resume_failure = false;
229
+ // B5 restart isolation loop: per-agent spawn failures must be recorded and
230
+ // isolated here. G1 resume-integrity failures set a fatal flag and skip later
231
+ // spawns; do not reintroduce `?`, `break`, or `return` inside this loop.
232
+ // BEGIN_B5_RESTART_ISOLATION_LOOP
216
233
  for decision in &plan.decisions {
217
- let agent = state
234
+ if fatal_resume_failure {
235
+ continue;
236
+ }
237
+ let Some(agent) = state
218
238
  .get("agents")
219
239
  .and_then(|v| v.get(decision.agent_id.as_str()))
220
- .ok_or_else(|| {
221
- LifecycleError::RequirementUnmet(format!(
222
- "agent {} not found for restart",
223
- decision.agent_id
224
- ))
225
- })?
226
- .clone();
240
+ .cloned()
241
+ else {
242
+ let error = format!("agent {} not found for restart", decision.agent_id);
243
+ mark_agent_restart_failed(&mut state, decision, &error);
244
+ let _ = write_restart_agent_failed_event(
245
+ &selected.run_workspace,
246
+ decision,
247
+ "spawn",
248
+ &error,
249
+ );
250
+ failed_agents.push(restart_failed_agent(decision, "spawn", error));
251
+ continue;
252
+ };
227
253
  let session_id = if matches!(decision.restart_mode, StartMode::Resumed) {
228
254
  decision.session_id.as_ref()
229
255
  } else {
230
256
  None
231
257
  };
232
- let session_live = session_live_or_default(transport, &session_name, false);
258
+ let mut session_live = session_live_or_default(transport, &session_name, false);
233
259
  if !session_live {
234
- if let Some(previous) = &last_spawned {
235
- return Err(LifecycleError::Transport(format!(
260
+ if let Some(previous) = successful_agents.pop() {
261
+ let error = format!(
236
262
  "session_disappeared_after_spawn: provider_resume_exited for {}; session {} disappeared before spawning {}",
237
- previous,
263
+ previous.agent_id,
238
264
  session_name.as_str(),
239
265
  decision.agent_id
240
- )));
266
+ );
267
+ mark_agent_restart_failed(&mut state, &previous, &error);
268
+ let _ = write_restart_agent_failed_event(
269
+ &selected.run_workspace,
270
+ &previous,
271
+ "resume",
272
+ &error,
273
+ );
274
+ failed_agents.push(restart_failed_agent(&previous, "resume", error));
275
+ if is_resume_integrity_failure(&previous, "resume", "") {
276
+ fatal_resume_failure = true;
277
+ }
278
+ session_live = false;
241
279
  }
242
280
  }
243
- let spawn = spawn_agent_window(
281
+ if fatal_resume_failure {
282
+ continue;
283
+ }
284
+ let spawn = match spawn_agent_window(
244
285
  &selected.run_workspace,
245
286
  &session_name,
246
287
  &decision.agent_id,
@@ -250,10 +291,42 @@ pub fn restart_with_transport_with_session_convergence_deadline(
250
291
  transport,
251
292
  Some(&safety),
252
293
  Some(spec_workspace),
253
- )?;
254
- verify_spawned_agent_live(&decision.agent_id, &spawn, transport)?;
255
- mark_agent_respawned(&mut state, &decision.agent_id, &spawn, transport, &safety)?;
256
- last_spawned = Some(decision.agent_id.clone());
294
+ ) {
295
+ Ok(spawn) => spawn,
296
+ Err(error) => {
297
+ let error = error.to_string();
298
+ mark_agent_restart_failed(&mut state, decision, &error);
299
+ let phase = restart_failure_phase(decision, "spawn", &error);
300
+ let _ = write_restart_agent_failed_event(
301
+ &selected.run_workspace,
302
+ decision,
303
+ phase,
304
+ &error,
305
+ );
306
+ failed_agents.push(restart_failed_agent(decision, phase, error));
307
+ if phase == "resume" {
308
+ fatal_resume_failure = true;
309
+ }
310
+ continue;
311
+ }
312
+ };
313
+ if let Err(error) = verify_spawned_agent_live(&decision.agent_id, &spawn, transport)
314
+ .and_then(|_| {
315
+ mark_agent_respawned(&mut state, &decision.agent_id, &spawn, transport, &safety)
316
+ })
317
+ {
318
+ let error = error.to_string();
319
+ mark_agent_restart_failed(&mut state, decision, &error);
320
+ let phase = restart_failure_phase(decision, "readiness", &error);
321
+ let _ =
322
+ write_restart_agent_failed_event(&selected.run_workspace, decision, phase, &error);
323
+ failed_agents.push(restart_failed_agent(decision, phase, error));
324
+ if phase == "resume" {
325
+ fatal_resume_failure = true;
326
+ }
327
+ continue;
328
+ }
329
+ successful_agents.push(decision.clone());
257
330
  if let Some(agent) = state
258
331
  .get_mut("agents")
259
332
  .and_then(serde_json::Value::as_object_mut)
@@ -263,14 +336,47 @@ pub fn restart_with_transport_with_session_convergence_deadline(
263
336
  persist_effective_approval_policy_for_restart(agent, &safety);
264
337
  }
265
338
  }
339
+ // END_B5_RESTART_ISOLATION_LOOP
266
340
  crate::state::projection::save_team_scoped_state(&selected.run_workspace, &state)
267
341
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
342
+ if fatal_resume_failure {
343
+ let attach_commands = Vec::new();
344
+ let next_actions = restart_failure_next_actions(&failed_agents);
345
+ write_restart_completed_event(
346
+ &selected.run_workspace,
347
+ &successful_agents,
348
+ &failed_agents,
349
+ "fail",
350
+ )?;
351
+ return Ok(RestartReport::Failed {
352
+ session_name,
353
+ failed_agents,
354
+ next_actions,
355
+ attach_commands,
356
+ });
357
+ }
358
+ if successful_agents.is_empty() && !failed_agents.is_empty() {
359
+ let attach_commands = Vec::new();
360
+ let next_actions = restart_failure_next_actions(&failed_agents);
361
+ write_restart_completed_event(
362
+ &selected.run_workspace,
363
+ &successful_agents,
364
+ &failed_agents,
365
+ "fail",
366
+ )?;
367
+ return Ok(RestartReport::Failed {
368
+ session_name,
369
+ failed_agents,
370
+ next_actions,
371
+ attach_commands,
372
+ });
373
+ }
268
374
  let coordinator_started = start_coordinator_for_workspace(&selected.run_workspace)?;
269
375
  wait_restart_readiness_or_timeout(
270
376
  &selected.run_workspace,
271
377
  &state,
272
378
  &session_name,
273
- &plan.decisions,
379
+ &successful_agents,
274
380
  transport,
275
381
  restart_readiness_deadline(readiness_deadline_ms),
276
382
  restart_readiness_poll_interval(),
@@ -278,14 +384,37 @@ pub fn restart_with_transport_with_session_convergence_deadline(
278
384
  let attach_commands = crate::tmux_backend::attach_commands_for_windows(
279
385
  &selected.run_workspace,
280
386
  &session_name,
281
- plan.decisions
387
+ successful_agents
282
388
  .iter()
283
389
  .map(|decision| decision.agent_id.as_str()),
284
390
  );
285
- let next_actions = attach_commands.clone();
391
+ let mut next_actions = attach_commands.clone();
392
+ if !failed_agents.is_empty() {
393
+ next_actions.extend(restart_failure_next_actions(&failed_agents));
394
+ write_restart_completed_event(
395
+ &selected.run_workspace,
396
+ &successful_agents,
397
+ &failed_agents,
398
+ "partial",
399
+ )?;
400
+ return Ok(RestartReport::Partial {
401
+ session_name,
402
+ agents: successful_agents,
403
+ failed_agents,
404
+ coordinator_started,
405
+ next_actions,
406
+ attach_commands,
407
+ });
408
+ }
409
+ write_restart_completed_event(
410
+ &selected.run_workspace,
411
+ &successful_agents,
412
+ &failed_agents,
413
+ "ok",
414
+ )?;
286
415
  Ok(RestartReport::Restarted {
287
416
  session_name,
288
- agents: plan.decisions,
417
+ agents: successful_agents,
289
418
  coordinator_started,
290
419
  next_actions,
291
420
  attach_commands,
@@ -478,9 +607,9 @@ fn parse_duration_value_seconds_ms(value: &str) -> Option<u64> {
478
607
  }
479
608
 
480
609
  fn restart_readiness_deadline(requested_ms: Option<u64>) -> std::time::Duration {
481
- requested_ms.map(std::time::Duration::from_millis).unwrap_or_else(|| {
482
- env_duration_ms(&["TEAM_AGENT_RESTART_READINESS_DEADLINE_MS"], 30_000)
483
- })
610
+ requested_ms
611
+ .map(std::time::Duration::from_millis)
612
+ .unwrap_or_else(|| env_duration_ms(&["TEAM_AGENT_RESTART_READINESS_DEADLINE_MS"], 30_000))
484
613
  }
485
614
 
486
615
  fn restart_readiness_poll_interval() -> std::time::Duration {
@@ -518,11 +647,14 @@ fn wait_restart_readiness_or_timeout(
518
647
  let elapsed = started.elapsed();
519
648
  if elapsed >= deadline {
520
649
  write_restart_readiness_timeout_event(workspace, readiness, deadline, elapsed)?;
521
- return Err(LifecycleError::RequirementUnmet(restart_readiness_timeout_message(
522
- workspace, readiness, deadline,
523
- )));
650
+ return Err(LifecycleError::RequirementUnmet(
651
+ restart_readiness_timeout_message(workspace, readiness, deadline),
652
+ ));
524
653
  }
525
- std::thread::sleep(std::cmp::min(poll_interval, deadline.saturating_sub(elapsed)));
654
+ std::thread::sleep(std::cmp::min(
655
+ poll_interval,
656
+ deadline.saturating_sub(elapsed),
657
+ ));
526
658
  }
527
659
  }
528
660
 
@@ -538,7 +670,11 @@ fn restart_readiness(
538
670
  let coordinator_workspace = crate::coordinator::WorkspacePath::new(workspace.to_path_buf());
539
671
  let coordinator_alive =
540
672
  crate::coordinator::coordinator_health(&coordinator_workspace).ok && session_created;
541
- RestartReadiness { session_created, worker_pane_addressable, coordinator_alive }
673
+ RestartReadiness {
674
+ session_created,
675
+ worker_pane_addressable,
676
+ coordinator_alive,
677
+ }
542
678
  }
543
679
 
544
680
  fn restart_worker_panes_addressable(
@@ -650,7 +786,11 @@ fn restart_readiness_missing_summary(readiness: RestartReadiness) -> String {
650
786
  }
651
787
 
652
788
  fn yes_no(value: bool) -> &'static str {
653
- if value { "yes" } else { "no" }
789
+ if value {
790
+ "yes"
791
+ } else {
792
+ "no"
793
+ }
654
794
  }
655
795
 
656
796
  fn verify_spawned_agent_live(
@@ -749,6 +889,161 @@ fn mark_agent_respawned(
749
889
  Ok(())
750
890
  }
751
891
 
892
+ fn restart_failed_agent(
893
+ decision: &RestartedAgent,
894
+ phase: impl Into<String>,
895
+ error: String,
896
+ ) -> RestartFailedAgent {
897
+ RestartFailedAgent {
898
+ agent_id: decision.agent_id.clone(),
899
+ restart_mode: decision.restart_mode,
900
+ decision: decision.decision,
901
+ session_id: decision.session_id.clone(),
902
+ phase: phase.into(),
903
+ error,
904
+ }
905
+ }
906
+
907
+ fn restart_failure_phase(
908
+ decision: &RestartedAgent,
909
+ phase: &'static str,
910
+ error: &str,
911
+ ) -> &'static str {
912
+ if is_resume_integrity_failure(decision, phase, error) {
913
+ "resume"
914
+ } else {
915
+ phase
916
+ }
917
+ }
918
+
919
+ fn is_resume_integrity_failure(decision: &RestartedAgent, phase: &str, error: &str) -> bool {
920
+ if !matches!(decision.restart_mode, StartMode::Resumed) {
921
+ return false;
922
+ }
923
+ if phase == "resume" || phase == "readiness" {
924
+ return true;
925
+ }
926
+ error.contains("session_disappeared_after_spawn")
927
+ || error.contains("provider_resume_exited")
928
+ || error.contains("resume_not_ready")
929
+ || error.contains("resume_atomicity")
930
+ || error.contains("no live pane")
931
+ || error.contains("no-pane")
932
+ }
933
+
934
+ fn mark_agent_restart_failed(
935
+ state: &mut serde_json::Value,
936
+ decision: &RestartedAgent,
937
+ error: &str,
938
+ ) {
939
+ let Some(agent) = state
940
+ .get_mut("agents")
941
+ .and_then(serde_json::Value::as_object_mut)
942
+ .and_then(|agents| agents.get_mut(decision.agent_id.as_str()))
943
+ .and_then(serde_json::Value::as_object_mut)
944
+ else {
945
+ return;
946
+ };
947
+ agent.insert("status".to_string(), serde_json::json!("failed"));
948
+ agent.insert("restart_error".to_string(), serde_json::json!(error));
949
+ agent.insert(
950
+ "restart_failed_at".to_string(),
951
+ serde_json::json!(chrono::Utc::now().to_rfc3339()),
952
+ );
953
+ agent.remove("pane_id");
954
+ agent.remove("pane_pid");
955
+ }
956
+
957
+ fn write_restart_agent_failed_event(
958
+ workspace: &Path,
959
+ decision: &RestartedAgent,
960
+ phase: &str,
961
+ error: &str,
962
+ ) -> Result<(), LifecycleError> {
963
+ crate::event_log::EventLog::new(workspace)
964
+ .write(
965
+ "restart.agent_failed",
966
+ serde_json::json!({
967
+ "agent_id": decision.agent_id.as_str(),
968
+ "restart_mode": start_mode_wire(decision.restart_mode),
969
+ "decision": resume_decision_wire(decision.decision),
970
+ "session_id": decision.session_id.as_ref().map(|session| session.as_str()),
971
+ "phase": phase,
972
+ "error": error,
973
+ "action": format!(
974
+ "inspect worker {} output, then restart that worker with `team-agent restart-agent {}` or rerun `team-agent restart --allow-fresh`",
975
+ decision.agent_id,
976
+ decision.agent_id
977
+ ),
978
+ "log": format!(
979
+ ".team/logs/coordinator.log and .team/runtime/state.json agent={}",
980
+ decision.agent_id
981
+ ),
982
+ }),
983
+ )
984
+ .map(|_| ())
985
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))
986
+ }
987
+
988
+ fn write_restart_completed_event(
989
+ workspace: &Path,
990
+ successful_agents: &[RestartedAgent],
991
+ failed_agents: &[RestartFailedAgent],
992
+ rc: &str,
993
+ ) -> Result<(), LifecycleError> {
994
+ crate::event_log::EventLog::new(workspace)
995
+ .write(
996
+ "restart.completed",
997
+ serde_json::json!({
998
+ "rc": rc,
999
+ "status": rc,
1000
+ "successful_agents": successful_agents
1001
+ .iter()
1002
+ .map(|agent| agent.agent_id.as_str())
1003
+ .collect::<Vec<_>>(),
1004
+ "failed_agents": failed_agents
1005
+ .iter()
1006
+ .map(|failure| serde_json::json!({
1007
+ "agent_id": failure.agent_id.as_str(),
1008
+ "phase": failure.phase,
1009
+ "error": failure.error,
1010
+ }))
1011
+ .collect::<Vec<_>>(),
1012
+ }),
1013
+ )
1014
+ .map(|_| ())
1015
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))
1016
+ }
1017
+
1018
+ fn restart_failure_next_actions(failed_agents: &[RestartFailedAgent]) -> Vec<String> {
1019
+ failed_agents
1020
+ .iter()
1021
+ .map(|failure| {
1022
+ format!(
1023
+ "inspect worker {} output, then restart that worker with `team-agent restart-agent {}` or rerun `team-agent restart --allow-fresh`",
1024
+ failure.agent_id, failure.agent_id
1025
+ )
1026
+ })
1027
+ .collect()
1028
+ }
1029
+
1030
+ fn start_mode_wire(mode: StartMode) -> &'static str {
1031
+ match mode {
1032
+ StartMode::Resumed => "resumed",
1033
+ StartMode::Fresh => "fresh",
1034
+ StartMode::FreshAfterMissingRollout => "fresh_after_missing_rollout",
1035
+ StartMode::Noop => "noop",
1036
+ }
1037
+ }
1038
+
1039
+ fn resume_decision_wire(decision: ResumeDecision) -> &'static str {
1040
+ match decision {
1041
+ ResumeDecision::Resume => "resume",
1042
+ ResumeDecision::FreshStart => "fresh_start",
1043
+ ResumeDecision::Refuse => "refuse",
1044
+ }
1045
+ }
1046
+
752
1047
  fn write_restart_resume_decision_events(
753
1048
  workspace: &Path,
754
1049
  state: &serde_json::Value,
@@ -768,11 +1063,7 @@ fn write_restart_resume_decision_events(
768
1063
  .filter(|s| !s.is_empty())
769
1064
  .map(|s| s.to_string());
770
1065
  let session_id = decision.session_id.as_ref().map(|s| s.as_str().to_string());
771
- let decision_wire = match decision.decision {
772
- ResumeDecision::Resume => "resume",
773
- ResumeDecision::FreshStart => "fresh_start",
774
- ResumeDecision::Refuse => "refuse",
775
- };
1066
+ let decision_wire = resume_decision_wire(decision.decision);
776
1067
  write_restart_resume_decision_event(
777
1068
  workspace,
778
1069
  decision.agent_id.as_str(),
@@ -956,13 +1247,16 @@ fn rebuild_runtime_spec_from_roles(
956
1247
  let agents_dir = team_dir.join("agents");
957
1248
  let has_role_doc = std::fs::read_dir(&agents_dir)
958
1249
  .map(|entries| {
959
- entries.flatten().any(|e| {
960
- e.path().extension().and_then(|x| x.to_str()) == Some("md")
961
- })
1250
+ entries
1251
+ .flatten()
1252
+ .any(|e| e.path().extension().and_then(|x| x.to_str()) == Some("md"))
962
1253
  })
963
1254
  .unwrap_or(false);
964
1255
  if !has_role_doc {
965
- missing.push(format!("{}/*.md (at least one role doc)", agents_dir.display()));
1256
+ missing.push(format!(
1257
+ "{}/*.md (at least one role doc)",
1258
+ agents_dir.display()
1259
+ ));
966
1260
  }
967
1261
  if !missing.is_empty() {
968
1262
  // N38 三行式:error / action / log。旧 runtime spec 原地保留(不删不用)。
@@ -1,5 +1,5 @@
1
- use super::*;
2
1
  use super::common::*;
2
+ use super::*;
3
3
 
4
4
  /// bug-085 四象限 `start_mode` 决策(`start.py:179-188` + `_resume_rollout_missing` `start.py:66-69`),
5
5
  /// **从 start_agent 的整条 lock+spawn 路径里分离出的纯函数**(gate gap:porter 需要单元级 RED
@@ -19,7 +19,8 @@ pub fn decide_start_mode(
19
19
  match session_id {
20
20
  None => StartMode::Fresh,
21
21
  Some(_) => {
22
- let missing_resume_backing = resumable_provider_requires_backing(provider) && !rollout_exists;
22
+ let missing_resume_backing = !provider_wire_supports_resume(provider)
23
+ || (resumable_provider_requires_backing(provider) && !rollout_exists);
23
24
  match (missing_resume_backing, allow_fresh) {
24
25
  (true, true) => StartMode::FreshAfterMissingRollout,
25
26
  (true, false) => StartMode::Noop,
@@ -188,8 +189,10 @@ pub(crate) fn classify_restart_plan_with_resume_validation(
188
189
  // (leader 从未发消息 → first_send_at=null → interacted=false)会被它静默 fresh 丢上下文。
189
190
  let provider = agent_provider(agent);
190
191
  let provider_wire = provider_wire(provider);
191
- let resume_backing_exists = match (workspace, session_id.as_ref()) {
192
- (Some(workspace), Some(session)) => resume_backing_exists_for_agent(
192
+ let provider_can_resume = provider_supports_resume(provider);
193
+ let resume_backing_exists = match (workspace, session_id.as_ref(), provider_can_resume) {
194
+ (_, Some(_), false) => false,
195
+ (Some(workspace), Some(session), true) => resume_backing_exists_for_agent(
193
196
  workspace,
194
197
  &agent_id,
195
198
  agent,
@@ -197,14 +200,14 @@ pub(crate) fn classify_restart_plan_with_resume_validation(
197
200
  session,
198
201
  agent_rollout_path(agent).as_ref(),
199
202
  ),
200
- (None, Some(_)) if resumable_provider_requires_backing(provider_wire) => {
203
+ (None, Some(_), true) if resumable_provider_requires_backing(provider_wire) => {
201
204
  agent_rollout_path(agent)
202
205
  .as_ref()
203
206
  .is_some_and(|path| path.as_path().exists())
204
207
  }
205
208
  _ => true,
206
209
  };
207
- let decision = if session_id.is_some() && resume_backing_exists {
210
+ let decision = if session_id.is_some() && provider_can_resume && resume_backing_exists {
208
211
  ResumeDecision::Resume
209
212
  } else if session_id.is_some() && allow_fresh {
210
213
  ResumeDecision::FreshStart