@team-agent/installer 0.3.39 → 0.4.0

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 (61) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +7 -0
  4. package/crates/team-agent/src/cli/adapters.rs +8 -3
  5. package/crates/team-agent/src/cli/diagnose.rs +12 -3
  6. package/crates/team-agent/src/cli/emit.rs +1 -0
  7. package/crates/team-agent/src/cli/mod.rs +250 -9
  8. package/crates/team-agent/src/cli/send.rs +11 -2
  9. package/crates/team-agent/src/cli/status_port.rs +39 -4
  10. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +189 -6
  11. package/crates/team-agent/src/cli/tests/status_send.rs +189 -0
  12. package/crates/team-agent/src/cli/tests/verb_settle.rs +2 -0
  13. package/crates/team-agent/src/cli/types.rs +5 -0
  14. package/crates/team-agent/src/coordinator/mod.rs +1 -0
  15. package/crates/team-agent/src/coordinator/steps/abnormal.rs +4 -0
  16. package/crates/team-agent/src/coordinator/steps/delivery.rs +4 -0
  17. package/crates/team-agent/src/coordinator/steps/health_sync.rs +4 -0
  18. package/crates/team-agent/src/coordinator/steps/mod.rs +83 -0
  19. package/crates/team-agent/src/coordinator/steps/persist.rs +4 -0
  20. package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +4 -0
  21. package/crates/team-agent/src/coordinator/steps/session_gate.rs +5 -0
  22. package/crates/team-agent/src/coordinator/tests/health_sync.rs +156 -0
  23. package/crates/team-agent/src/coordinator/tick.rs +126 -30
  24. package/crates/team-agent/src/{message_store.rs → db/message_store.rs} +6 -0
  25. package/crates/team-agent/src/db/mod.rs +1 -0
  26. package/crates/team-agent/src/layout/mod.rs +7 -0
  27. package/crates/team-agent/src/layout/runtime_sessions.rs +312 -0
  28. package/crates/team-agent/src/layout/tmux_endpoint.rs +162 -0
  29. package/crates/team-agent/src/leader/start.rs +27 -1
  30. package/crates/team-agent/src/leader/tests/identity.rs +50 -0
  31. package/crates/team-agent/src/lib.rs +6 -2
  32. package/crates/team-agent/src/lifecycle/launch/readiness.rs +32 -0
  33. package/crates/team-agent/src/lifecycle/launch/spawn.rs +32 -0
  34. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +51 -0
  35. package/crates/team-agent/src/lifecycle/launch.rs +34 -0
  36. package/crates/team-agent/src/lifecycle/restart/agent.rs +17 -0
  37. package/crates/team-agent/src/lifecycle/restart/common.rs +143 -23
  38. package/crates/team-agent/src/lifecycle/restart/preflight.rs +87 -0
  39. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +308 -0
  40. package/crates/team-agent/src/lifecycle/restart/selection.rs +87 -22
  41. package/crates/team-agent/src/lifecycle/restart.rs +6 -0
  42. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +6 -1
  43. package/crates/team-agent/src/lifecycle/tests/restart.rs +187 -0
  44. package/crates/team-agent/src/lifecycle/tests.rs +1 -0
  45. package/crates/team-agent/src/lifecycle/types.rs +20 -1
  46. package/crates/team-agent/src/mcp_server/helpers.rs +72 -1
  47. package/crates/team-agent/src/mcp_server/tools.rs +29 -4
  48. package/crates/team-agent/src/provider/adapter.rs +31 -1
  49. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +34 -0
  50. package/crates/team-agent/src/provider/classify.rs +138 -0
  51. package/crates/team-agent/src/provider/command.rs +71 -0
  52. package/crates/team-agent/src/provider/mod.rs +3 -0
  53. package/crates/team-agent/src/{session_capture.rs → provider/session/capture.rs} +139 -6
  54. package/crates/team-agent/src/provider/session/mod.rs +13 -0
  55. package/crates/team-agent/src/provider/session/resume.rs +417 -0
  56. package/crates/team-agent/src/provider/session_scan.rs +63 -0
  57. package/crates/team-agent/src/provider/types.rs +5 -0
  58. package/crates/team-agent/src/state/persist.rs +238 -0
  59. package/crates/team-agent/src/tmux_backend.rs +25 -0
  60. package/npm/install.mjs +27 -1
  61. package/package.json +4 -4
@@ -72,6 +72,9 @@ pub fn restart_with_transport_with_readiness_deadline(
72
72
  .map(|agent_id| UnresumableWorker {
73
73
  agent_id,
74
74
  reason: "session_capture_incomplete".to_string(),
75
+ refusal_reason: Some(
76
+ crate::provider::session::ResumeRefusalReason::SessionCaptureIncomplete,
77
+ ),
75
78
  session_id: None,
76
79
  first_send_at: None,
77
80
  })
@@ -190,6 +193,7 @@ pub fn restart_with_transport_with_session_convergence_deadline(
190
193
  &plan.decisions,
191
194
  &forced_fresh_missing,
192
195
  forced_fresh_convergence.as_ref(),
196
+ &plan.unresumable,
193
197
  )?;
194
198
  if !plan.corrupt_entries.is_empty() {
195
199
  return Ok(RestartReport::RefusedInvalidFirstSendAt {
@@ -205,6 +209,29 @@ pub fn restart_with_transport_with_session_convergence_deadline(
205
209
  error: "restart requires resumable workers before live spawn; rerun with --allow-fresh to start fresh".to_string(),
206
210
  });
207
211
  }
212
+ // unit-3 (Stage 1) session-identity preflight: before any kill, reject
213
+ // the case where `state.session_name` actually holds a leader launcher
214
+ // session name (`team-agent-leader-*`). Proceeding would tear down the
215
+ // leader pane (E49 / 0.3.39 leader mis-kill). Nothing is created or
216
+ // killed — the caller gets a structured refusal that distinguishes this
217
+ // dirty-state from a normal resume/atomicity refusal.
218
+ match crate::lifecycle::restart::preflight::check_session_preflight(&state) {
219
+ crate::lifecycle::restart::preflight::SessionPreflight::Ok => {}
220
+ crate::lifecycle::restart::preflight::SessionPreflight::WorkerSessionIsLeaderSession {
221
+ session_name,
222
+ reason,
223
+ } => {
224
+ return Ok(RestartReport::RefusedDirtyTopology {
225
+ session_name: session_name.clone(),
226
+ reason: reason.clone(),
227
+ error: format!(
228
+ "restart refused: state.session_name `{session_name}` is a leader \
229
+ launcher session ({reason}); aborting before any tmux kill. Repair \
230
+ state.session_name to the worker session and re-run."
231
+ ),
232
+ });
233
+ }
234
+ }
208
235
  let session_name = state_session_name(&state);
209
236
  if session_live_or_default(transport, &session_name, false) {
210
237
  // 0.3.28 Step 5 (warn-only): per architecture, restart should REFUSE
@@ -310,6 +337,10 @@ pub fn restart_with_transport_with_session_convergence_deadline(
310
337
  Some(&safety),
311
338
  layout_placement.as_ref(),
312
339
  None,
340
+ // Issue 2 (Round 3b gate review §6): thread the resolved
341
+ // selected.team_key so the worker MCP env carries the right
342
+ // owner_team_id even when top-level active_team_key is stale.
343
+ Some(selected.team_key.as_str()),
313
344
  ) {
314
345
  Ok(spawn) => spawn,
315
346
  Err(error) => {
@@ -396,6 +427,175 @@ pub fn restart_with_transport_with_session_convergence_deadline(
396
427
  attach_commands,
397
428
  });
398
429
  }
430
+ // RM-039-SESS-001 step 2 (architect verdict 2026-06-22): post-respawn
431
+ // resume backing validation.
432
+ //
433
+ // Pre-kill preflight already proves backing exists, but killing the
434
+ // provider process can cause the provider to rotate / clean up its
435
+ // transient backing file (the Claude case in the evidence: the
436
+ // sessions/<pid>.json file is the process-tracking record and goes away
437
+ // when the worker dies). Resumed agents preserve the OLD
438
+ // capture tuple (mark_agent_respawned only clears it on Fresh/
439
+ // FreshAfterMissingRollout), so without this re-probe the runtime
440
+ // reports `restart.completed rc:"ok"` while L2 provider backing truth
441
+ // is missing — a false-green restart.
442
+ //
443
+ // Strategy:
444
+ // 1. For every Resumed worker in `successful_agents`, re-run the
445
+ // same `resume_backing_probe_for_agent` used at preflight.
446
+ // 2. Emit `restart.resume_postflight` carrying agent_id, session_id,
447
+ // exists, checked_paths.
448
+ // 3. If exists=false: clear stale capture fields (rollout_path,
449
+ // captured_at, captured_via, attribution_confidence) but
450
+ // preserve session_id; set `_pending_session_id` to the same
451
+ // session_id so convergence searches for the resumed session
452
+ // explicitly. Then run one bounded convergence pass.
453
+ // 4. Re-probe. If still missing and !allow_fresh, demote the agent
454
+ // from successful to failed with phase `resume_postflight` and
455
+ // `session_backing_store_missing_after_restart`. The outer
456
+ // branches below will then return Failed/Partial instead of OK.
457
+ {
458
+ let mut postflight_failed: Vec<crate::lifecycle::types::RestartFailedAgent> = Vec::new();
459
+ let mut survivors: Vec<crate::lifecycle::types::RestartedAgent> =
460
+ Vec::with_capacity(successful_agents.len());
461
+ let convergence_deadline = session_convergence_deadline(session_converge_deadline_ms);
462
+ let convergence_poll = session_convergence_poll_interval();
463
+ for decision in successful_agents.drain(..) {
464
+ if !matches!(decision.restart_mode, crate::lifecycle::types::StartMode::Resumed) {
465
+ survivors.push(decision);
466
+ continue;
467
+ }
468
+ let Some(agent) = state
469
+ .get("agents")
470
+ .and_then(|v| v.get(decision.agent_id.as_str()))
471
+ .cloned()
472
+ else {
473
+ survivors.push(decision);
474
+ continue;
475
+ };
476
+ let provider = agent_provider(&agent);
477
+ let session_id = match decision.session_id.as_ref() {
478
+ Some(sid) => sid.clone(),
479
+ None => {
480
+ // No session id on a Resumed decision shouldn't happen,
481
+ // but if it does there is nothing to revalidate.
482
+ survivors.push(decision);
483
+ continue;
484
+ }
485
+ };
486
+ let probe = resume_backing_probe_for_agent(
487
+ &selected.run_workspace,
488
+ &decision.agent_id,
489
+ &agent,
490
+ provider,
491
+ &session_id,
492
+ agent_rollout_path(&agent).as_ref(),
493
+ );
494
+ write_restart_resume_postflight_event(
495
+ &selected.run_workspace,
496
+ &decision.agent_id,
497
+ Some(&session_id),
498
+ probe.exists,
499
+ &probe.checked_paths,
500
+ /* recaptured = */ false,
501
+ )?;
502
+ if probe.exists {
503
+ survivors.push(decision);
504
+ continue;
505
+ }
506
+ // Backing went missing between preflight and post-spawn.
507
+ // Clear stale capture fields, keep session_id, mark
508
+ // _pending_session_id, run a bounded convergence pass.
509
+ if let Some(agents) = state
510
+ .pointer_mut("/agents")
511
+ .and_then(serde_json::Value::as_object_mut)
512
+ {
513
+ if let Some(agent_obj) = agents
514
+ .get_mut(decision.agent_id.as_str())
515
+ .and_then(serde_json::Value::as_object_mut)
516
+ {
517
+ agent_obj.remove("rollout_path");
518
+ agent_obj.remove("captured_at");
519
+ agent_obj.remove("captured_via");
520
+ agent_obj.remove("attribution_confidence");
521
+ agent_obj.insert(
522
+ "_pending_session_id".to_string(),
523
+ serde_json::json!(session_id.as_str()),
524
+ );
525
+ }
526
+ }
527
+ // Persist state before convergence so the helper sees the
528
+ // cleared tuple.
529
+ save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
530
+ let _converge = converge_missing_provider_sessions(
531
+ &mut state,
532
+ convergence_deadline,
533
+ convergence_poll,
534
+ &selected.run_workspace,
535
+ allow_fresh,
536
+ )?;
537
+ save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
538
+ // Re-probe with the freshest agent state.
539
+ let agent_after = state
540
+ .get("agents")
541
+ .and_then(|v| v.get(decision.agent_id.as_str()))
542
+ .cloned()
543
+ .unwrap_or(serde_json::Value::Null);
544
+ let probe_after = resume_backing_probe_for_agent(
545
+ &selected.run_workspace,
546
+ &decision.agent_id,
547
+ &agent_after,
548
+ provider,
549
+ &session_id,
550
+ agent_rollout_path(&agent_after).as_ref(),
551
+ );
552
+ write_restart_resume_postflight_event(
553
+ &selected.run_workspace,
554
+ &decision.agent_id,
555
+ Some(&session_id),
556
+ probe_after.exists,
557
+ &probe_after.checked_paths,
558
+ /* recaptured = */ true,
559
+ )?;
560
+ if probe_after.exists {
561
+ survivors.push(decision);
562
+ continue;
563
+ }
564
+ // Still missing. Demote the agent.
565
+ let phase = "resume_postflight";
566
+ let error = "session_backing_store_missing_after_restart".to_string();
567
+ mark_agent_restart_failed(&mut state, &decision, &error);
568
+ let _ = write_restart_agent_failed_event(
569
+ &selected.run_workspace,
570
+ &decision,
571
+ phase,
572
+ &error,
573
+ );
574
+ postflight_failed.push(restart_failed_agent(&decision, phase, error));
575
+ }
576
+ successful_agents = survivors;
577
+ failed_agents.extend(postflight_failed);
578
+ if !failed_agents.is_empty() {
579
+ save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
580
+ }
581
+ }
582
+ if successful_agents.is_empty() && !failed_agents.is_empty() {
583
+ // Postflight demoted every survivor — emit Failed before coordinator start.
584
+ let attach_commands = Vec::new();
585
+ let next_actions = restart_failure_next_actions(&failed_agents);
586
+ write_restart_completed_event(
587
+ &selected.run_workspace,
588
+ &successful_agents,
589
+ &failed_agents,
590
+ "fail",
591
+ )?;
592
+ return Ok(RestartReport::Failed {
593
+ session_name,
594
+ failed_agents,
595
+ next_actions,
596
+ attach_commands,
597
+ });
598
+ }
399
599
  let coordinator_started = start_coordinator_for_workspace(&selected.run_workspace)?;
400
600
  wait_restart_readiness_or_timeout(
401
601
  &selected.run_workspace,
@@ -939,6 +1139,18 @@ fn mark_agent_respawned(
939
1139
  "spawn_cwd".to_string(),
940
1140
  serde_json::json!(spawn.spawn_cwd.to_string_lossy().to_string()),
941
1141
  );
1142
+ // Issue 2 (Round 3b gate review §6): persist the resolved owner_team_id
1143
+ // back into the agent row so future restarts read it directly from the
1144
+ // agent row (cascade priority 2) instead of relying on top-level
1145
+ // active_team_key (priority 3).
1146
+ if let Some(ref team_id) = spawn.owner_team_id {
1147
+ if !team_id.is_empty() {
1148
+ agent.insert(
1149
+ "owner_team_id".to_string(),
1150
+ serde_json::json!(team_id),
1151
+ );
1152
+ }
1153
+ }
942
1154
  if matches!(
943
1155
  restart_mode,
944
1156
  StartMode::Fresh | StartMode::FreshAfterMissingRollout
@@ -1099,6 +1311,39 @@ fn write_restart_agent_failed_event(
1099
1311
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))
1100
1312
  }
1101
1313
 
1314
+ /// RM-039-SESS-001 step 2 (architect verdict 2026-06-22): emit
1315
+ /// `restart.resume_postflight` for each Resumed worker we re-probed after
1316
+ /// spawn. `recaptured=true` means this event is the second probe after
1317
+ /// one bounded `converge_missing_provider_sessions` pass; `false` means
1318
+ /// it's the first probe right after spawn. `exists` reflects whether the
1319
+ /// provider backing file actually exists on disk; `checked_paths` lists
1320
+ /// every path the runtime probed.
1321
+ fn write_restart_resume_postflight_event(
1322
+ workspace: &Path,
1323
+ agent_id: &AgentId,
1324
+ session_id: Option<&SessionId>,
1325
+ exists: bool,
1326
+ checked_paths: &[std::path::PathBuf],
1327
+ recaptured: bool,
1328
+ ) -> Result<(), LifecycleError> {
1329
+ crate::event_log::EventLog::new(workspace)
1330
+ .write(
1331
+ "restart.resume_postflight",
1332
+ serde_json::json!({
1333
+ "agent_id": agent_id.as_str(),
1334
+ "session_id": session_id.map(SessionId::as_str),
1335
+ "exists": exists,
1336
+ "checked_paths": checked_paths
1337
+ .iter()
1338
+ .map(|p| p.to_string_lossy().into_owned())
1339
+ .collect::<Vec<_>>(),
1340
+ "recaptured": recaptured,
1341
+ }),
1342
+ )
1343
+ .map(|_| ())
1344
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))
1345
+ }
1346
+
1102
1347
  fn write_restart_completed_event(
1103
1348
  workspace: &Path,
1104
1349
  successful_agents: &[RestartedAgent],
@@ -1165,7 +1410,13 @@ fn write_restart_resume_decision_events(
1165
1410
  decisions: &[RestartedAgent],
1166
1411
  forced_fresh_missing: &std::collections::BTreeSet<String>,
1167
1412
  forced_fresh_convergence: Option<&crate::session_capture::SessionConvergence>,
1413
+ unresumable: &[crate::lifecycle::types::UnresumableWorker],
1168
1414
  ) -> Result<(), LifecycleError> {
1415
+ // Layer 2 (leader directive 2026-06-22): every restart.resume_decision
1416
+ // event for a Refuse decision must carry the structured refusal_reason
1417
+ // wire string so operators can see WHY without crawling state.json.
1418
+ let refusal_index: std::collections::BTreeMap<&str, &crate::lifecycle::types::UnresumableWorker> =
1419
+ unresumable.iter().map(|u| (u.agent_id.as_str(), u)).collect();
1169
1420
  for decision in decisions {
1170
1421
  let agent = state
1171
1422
  .get("agents")
@@ -1187,6 +1438,7 @@ fn write_restart_resume_decision_events(
1187
1438
  decision_wire,
1188
1439
  forced_fresh_missing.contains(decision.agent_id.as_str()),
1189
1440
  forced_fresh_convergence,
1441
+ refusal_index.get(decision.agent_id.as_str()).copied(),
1190
1442
  )?;
1191
1443
  }
1192
1444
  Ok(())
@@ -1201,6 +1453,7 @@ fn write_restart_resume_decision_event(
1201
1453
  decision: &str,
1202
1454
  forced_fresh: bool,
1203
1455
  forced_fresh_convergence: Option<&crate::session_capture::SessionConvergence>,
1456
+ unresumable: Option<&crate::lifecycle::types::UnresumableWorker>,
1204
1457
  ) -> Result<(), LifecycleError> {
1205
1458
  use std::io::Write as _;
1206
1459
 
@@ -1219,6 +1472,60 @@ fn write_restart_resume_decision_event(
1219
1472
  "first_send_at": first_send_at,
1220
1473
  "session_id": session_id,
1221
1474
  });
1475
+ // Layer 2 (leader directive 2026-06-22): when this is a Refuse decision
1476
+ // and we have a structured ResumeRefusalReason, emit the wire string +
1477
+ // optional recovery hint so the event-log audit is actionable. Falls
1478
+ // back to the legacy free-form `reason` string when no structured
1479
+ // refusal_reason is set.
1480
+ if decision == "refuse" {
1481
+ if let Some(u) = unresumable {
1482
+ if let Some(obj) = event.as_object_mut() {
1483
+ obj.insert("refusal_reason".to_string(), serde_json::json!(u.reason));
1484
+ if let Some(structured) = &u.refusal_reason {
1485
+ obj.insert(
1486
+ "refusal_reason_wire".to_string(),
1487
+ serde_json::json!(structured.wire()),
1488
+ );
1489
+ if let crate::provider::session::ResumeRefusalReason::SessionBackingStoreMissing {
1490
+ checked_paths,
1491
+ recovery_hint,
1492
+ } = structured
1493
+ {
1494
+ if !checked_paths.is_empty() {
1495
+ obj.insert(
1496
+ "checked_paths".to_string(),
1497
+ serde_json::json!(checked_paths
1498
+ .iter()
1499
+ .map(|p| p.to_string_lossy().into_owned())
1500
+ .collect::<Vec<_>>()),
1501
+ );
1502
+ }
1503
+ if let Some(hint) = recovery_hint {
1504
+ let mut h = serde_json::Map::new();
1505
+ h.insert("provider".to_string(), serde_json::json!(hint.provider));
1506
+ if let Some(name) = &hint.provider_session_name_hint {
1507
+ h.insert("name".to_string(), serde_json::json!(name));
1508
+ }
1509
+ if let Some(cwd) = &hint.spawn_cwd {
1510
+ h.insert(
1511
+ "spawn_cwd".to_string(),
1512
+ serde_json::json!(cwd.to_string_lossy()),
1513
+ );
1514
+ }
1515
+ h.insert(
1516
+ "picker_hint".to_string(),
1517
+ serde_json::json!(hint.picker_hint()),
1518
+ );
1519
+ obj.insert(
1520
+ "recovery_hint".to_string(),
1521
+ serde_json::Value::Object(h),
1522
+ );
1523
+ }
1524
+ }
1525
+ }
1526
+ }
1527
+ }
1528
+ }
1222
1529
  if forced_fresh {
1223
1530
  if let Some(event) = event.as_object_mut() {
1224
1531
  event.insert("forced_fresh".to_string(), serde_json::json!(true));
@@ -1595,6 +1902,7 @@ mod tests {
1595
1902
  profile_launch: crate::provider::ProviderProfileLaunch::default(),
1596
1903
  layout_placement: None,
1597
1904
  spawn_cwd: std::path::PathBuf::from("/tmp/team-epoch"),
1905
+ owner_team_id: None,
1598
1906
  };
1599
1907
  let before = chrono::Utc::now();
1600
1908
 
@@ -190,23 +190,37 @@ pub(crate) fn classify_restart_plan_with_resume_validation(
190
190
  let provider = agent_provider(agent);
191
191
  let provider_wire = provider_wire(provider);
192
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(
196
- workspace,
197
- &agent_id,
198
- agent,
199
- provider,
200
- session,
201
- agent_rollout_path(agent).as_ref(),
202
- ),
203
- (None, Some(_), true) if resumable_provider_requires_backing(provider_wire) => {
204
- agent_rollout_path(agent)
205
- .as_ref()
206
- .is_some_and(|path| path.as_path().exists())
207
- }
208
- _ => true,
209
- };
193
+ // Layer 2 self-healing (leader follow-up 2026-06-22): use the
194
+ // structured probe so we can carry the list of paths the runtime
195
+ // actually checked into the refusal — operators need to see WHICH
196
+ // places we looked, not just "missing".
197
+ let (resume_backing_exists, backing_checked_paths) =
198
+ match (workspace, session_id.as_ref(), provider_can_resume) {
199
+ (_, Some(_), false) => (false, Vec::new()),
200
+ (Some(workspace), Some(session), true) => {
201
+ let probe = resume_backing_probe_for_agent(
202
+ workspace,
203
+ &agent_id,
204
+ agent,
205
+ provider,
206
+ session,
207
+ agent_rollout_path(agent).as_ref(),
208
+ );
209
+ (probe.exists, probe.checked_paths)
210
+ }
211
+ (None, Some(_), true) if resumable_provider_requires_backing(provider_wire) => {
212
+ let path_opt = agent_rollout_path(agent);
213
+ let exists = path_opt
214
+ .as_ref()
215
+ .is_some_and(|path| path.as_path().exists());
216
+ let checked = path_opt
217
+ .as_ref()
218
+ .map(|p| vec![p.as_path().to_path_buf()])
219
+ .unwrap_or_default();
220
+ (exists, checked)
221
+ }
222
+ _ => (true, Vec::new()),
223
+ };
210
224
  let decision = if session_id.is_some() && provider_can_resume && resume_backing_exists {
211
225
  ResumeDecision::Resume
212
226
  } else if session_id.is_some() && allow_fresh {
@@ -219,13 +233,64 @@ pub(crate) fn classify_restart_plan_with_resume_validation(
219
233
  ResumeDecision::Refuse
220
234
  };
221
235
  if matches!(decision, ResumeDecision::Refuse) {
236
+ // unit-5: surface structured ResumeRefusalReason alongside the
237
+ // legacy free-form string. The string wire is preserved exactly
238
+ // (round-tripped through ResumeRefusalReason::wire) so the
239
+ // CLI/JSON contract does not change.
240
+ let (reason_str, structured) = if session_id.is_some() {
241
+ if !provider_can_resume {
242
+ (
243
+ "session_unresumable".to_string(),
244
+ crate::provider::session::ResumeRefusalReason::ProviderResumeUnsupported {
245
+ provider: provider_wire.to_string(),
246
+ },
247
+ )
248
+ } else if !resume_backing_exists {
249
+ // Today the legacy wire collapses backing-missing under
250
+ // the catch-all `session_unresumable` — keep that wire,
251
+ // but record the structured reason so the new shape is
252
+ // available to callers that want it.
253
+ //
254
+ // Layer 2 self-healing (architect probe 2026-06-22): attach
255
+ // a recovery hint pointing at the agent_id (used as
256
+ // launch-time `--name`) and spawn_cwd. Operator-facing
257
+ // diagnostic only — no auto-resume off the hint.
258
+ let recovery_hint = Some(
259
+ crate::provider::session::RecoveryHint {
260
+ provider_session_name_hint: Some(agent_id.as_str().to_string()),
261
+ spawn_cwd: agent
262
+ .get("spawn_cwd")
263
+ .and_then(|v| v.as_str())
264
+ .filter(|s| !s.is_empty())
265
+ .map(std::path::PathBuf::from),
266
+ provider: provider_wire.to_string(),
267
+ },
268
+ );
269
+ (
270
+ "session_unresumable".to_string(),
271
+ crate::provider::session::ResumeRefusalReason::SessionBackingStoreMissing {
272
+ checked_paths: backing_checked_paths.clone(),
273
+ recovery_hint,
274
+ },
275
+ )
276
+ } else {
277
+ (
278
+ "session_unresumable".to_string(),
279
+ crate::provider::session::ResumeRefusalReason::Other {
280
+ legacy_reason: "session_unresumable".to_string(),
281
+ },
282
+ )
283
+ }
284
+ } else {
285
+ (
286
+ "no_persisted_session_id".to_string(),
287
+ crate::provider::session::ResumeRefusalReason::NoSessionId,
288
+ )
289
+ };
222
290
  unresumable.push(UnresumableWorker {
223
291
  agent_id: agent_id.clone(),
224
- reason: if session_id.is_some() {
225
- "session_unresumable".to_string()
226
- } else {
227
- "no_persisted_session_id".to_string()
228
- },
292
+ reason: reason_str,
293
+ refusal_reason: Some(structured),
229
294
  session_id: session_id.clone(),
230
295
  first_send_at: first_send_at_raw.as_str().map(|s| s.to_string()),
231
296
  });
@@ -26,6 +26,7 @@ struct LifecyclePathRefs<'a> {
26
26
  mod agent;
27
27
  mod common;
28
28
  mod orchestrator;
29
+ pub mod preflight;
29
30
  mod rebuild;
30
31
  mod remove;
31
32
  mod selection;
@@ -46,6 +47,11 @@ pub use rebuild::{
46
47
  pub(crate) use rebuild::restart_with_transport_with_session_convergence_deadline;
47
48
  pub use remove::{remove_agent, remove_agent_with_transport};
48
49
  pub use selection::{classify_first_send_at, classify_restart_plan, decide_start_mode, python_type_name};
50
+ // Layer 2 (leader follow-up 2026-06-22): test-visible workspace-aware
51
+ // classification so lifecycle/tests/restart.rs can exercise the
52
+ // SessionBackingStoreMissing + checked_paths + RecoveryHint path
53
+ // end-to-end without spinning up a full restart.
54
+ pub(crate) use selection::classify_restart_plan_with_resume_validation;
49
55
  pub(crate) use team_state::write_team_state;
50
56
 
51
57
  pub(crate) fn lifecycle_run_workspace(workspace: &Path) -> Result<std::path::PathBuf, LifecycleError> {
@@ -2544,12 +2544,17 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
2544
2544
  "tasks",
2545
2545
  "display_backend",
2546
2546
  "is_external_leader",
2547
+ // 0.4.0 refactor: `team_key` added as top-level topology marker
2548
+ // alongside active_team_key (refactor-modular-architecture). The
2549
+ // owner / leader_receiver / owner_epoch invariants below still
2550
+ // hold — those live only under teams[<active_team_key>].
2551
+ "team_key",
2547
2552
  "active_team_key",
2548
2553
  "teams",
2549
2554
  ],
2550
2555
  "state.json top-level key order must match golden launch/core.py:62-71 \
2551
2556
  plus R1 topology marker and Bug 1/2 team-in-team suffix \
2552
- (is_external_leader, active_team_key, teams). \
2557
+ (is_external_leader, team_key, active_team_key, teams). \
2553
2558
  Bug 2 owner team-scope (N1/N12/N18/N29) deliberately keeps owner / \
2554
2559
  leader_receiver / owner_epoch OFF the top level — they live ONLY under \
2555
2560
  teams[<active_team_key>] so per-team isolation has a single source of \