@team-agent/installer 0.5.44 → 0.5.46

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 (28) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +44 -33
  4. package/crates/team-agent/src/cli/mod.rs +6 -0
  5. package/crates/team-agent/src/cli/named_address.rs +206 -4
  6. package/crates/team-agent/src/cli/send.rs +105 -2
  7. package/crates/team-agent/src/cli/spec.rs +1 -1
  8. package/crates/team-agent/src/cli/status_port.rs +70 -2
  9. package/crates/team-agent/src/cli/tests/run_delegation.rs +7 -4
  10. package/crates/team-agent/src/coordinator/steps/abnormal.rs +14 -22
  11. package/crates/team-agent/src/lifecycle/restart/agent.rs +103 -27
  12. package/crates/team-agent/src/lifecycle/restart/common.rs +136 -0
  13. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +168 -0
  14. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -7
  15. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +1 -1
  16. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +3 -0
  17. package/crates/team-agent/src/lifecycle/types.rs +3 -0
  18. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +10 -0
  19. package/crates/team-agent/src/mcp_server/tools.rs +67 -8
  20. package/crates/team-agent/src/model/mod.rs +6 -0
  21. package/crates/team-agent/src/model/name_similarity.rs +266 -0
  22. package/crates/team-agent/src/provider/session/capture.rs +113 -22
  23. package/crates/team-agent/src/provider/session_scan/codex.rs +51 -4
  24. package/crates/team-agent/src/tmux_backend/tests.rs +116 -10
  25. package/crates/team-agent/src/tmux_backend.rs +5 -16
  26. package/crates/team-agent/src/transport/tests/wire.rs +6 -0
  27. package/crates/team-agent/src/transport.rs +8 -2
  28. package/package.json +4 -4
@@ -3,6 +3,7 @@ use super::*;
3
3
  use crate::state::projection::OwnerTeamResolution;
4
4
  use crate::transport::Transport;
5
5
  use rusqlite::params;
6
+ use std::path::PathBuf;
6
7
 
7
8
  /// `status.status(workspace, as_json, compact)`(`queries.py:33`,**有副作用**:capture→refresh→save)。
8
9
  pub fn status(workspace: &Path, compact: bool, detail: bool) -> Result<Value, CliError> {
@@ -48,7 +49,7 @@ pub fn status_scoped(
48
49
  !tmux_present,
49
50
  &freshness,
50
51
  );
51
- let agents = enrich_agents(state.get("agents"), tmux_present, &freshness);
52
+ let agents = enrich_agents(workspace, state, tmux_present, &freshness);
52
53
  let tasks = state.get("tasks").cloned().unwrap_or_else(|| json!([]));
53
54
  let leader_receiver = state
54
55
  .get("leader_receiver")
@@ -548,18 +549,27 @@ pub(crate) fn compute_runtime_freshness(
548
549
  }
549
550
 
550
551
  fn enrich_agents(
551
- agents: Option<&Value>,
552
+ workspace: &Path,
553
+ state: &Value,
552
554
  tmux_session_present: bool,
553
555
  freshness: &RuntimeFreshness,
554
556
  ) -> Value {
557
+ let agents = state.get("agents");
555
558
  let Some(Value::Object(input)) = agents else {
556
559
  return json!({});
557
560
  };
561
+ let team_dir = state
562
+ .get("team_dir")
563
+ .and_then(Value::as_str)
564
+ .filter(|value| !value.is_empty())
565
+ .map(PathBuf::from)
566
+ .unwrap_or_else(|| workspace.to_path_buf());
558
567
  let mut out = Map::new();
559
568
  for (agent_id, value) in input {
560
569
  match value {
561
570
  Value::Object(obj) => {
562
571
  let mut enriched = obj.clone();
572
+ apply_effective_role_projection(&team_dir, agent_id, &mut enriched);
563
573
  enriched.insert(
564
574
  "interacted".to_string(),
565
575
  Value::String(interacted_marker(obj.get("first_send_at"))),
@@ -640,6 +650,64 @@ fn enrich_agents(
640
650
  Value::Object(out)
641
651
  }
642
652
 
653
+ fn apply_effective_role_projection(
654
+ team_dir: &Path,
655
+ agent_id: &str,
656
+ agent: &mut Map<String, Value>,
657
+ ) {
658
+ let role_file = agent
659
+ .get("dynamic_role_file")
660
+ .and_then(Value::as_str)
661
+ .filter(|value| !value.is_empty())
662
+ .map(PathBuf::from)
663
+ .unwrap_or_else(|| team_dir.join("agents").join(format!("{agent_id}.md")));
664
+ let Ok((meta, _)) = crate::compiler::read_front_matter(&role_file) else {
665
+ return;
666
+ };
667
+ let Some(meta) = yaml_map(&meta) else {
668
+ return;
669
+ };
670
+ if let Some(provider) = yaml_str(meta, "provider").filter(|value| !value.is_empty()) {
671
+ agent.insert("provider".to_string(), Value::String(provider.to_string()));
672
+ agent.insert(
673
+ "provider_source".to_string(),
674
+ Value::String("role".to_string()),
675
+ );
676
+ }
677
+ if let Some(model) = yaml_str(meta, "model").filter(|value| !value.is_empty()) {
678
+ if agent.get("model").and_then(Value::as_str) != Some(model) {
679
+ agent.insert("model_stale".to_string(), Value::Bool(true));
680
+ }
681
+ agent.insert("model".to_string(), Value::String(model.to_string()));
682
+ agent.insert(
683
+ "model_source".to_string(),
684
+ Value::String("role".to_string()),
685
+ );
686
+ }
687
+ }
688
+
689
+ fn yaml_map(
690
+ value: &crate::model::yaml::Value,
691
+ ) -> Option<&Vec<(String, crate::model::yaml::Value)>> {
692
+ match value {
693
+ crate::model::yaml::Value::Map(items) => Some(items),
694
+ _ => None,
695
+ }
696
+ }
697
+
698
+ fn yaml_str<'a>(items: &'a [(String, crate::model::yaml::Value)], key: &str) -> Option<&'a str> {
699
+ items.iter().find_map(|(name, value)| {
700
+ if name == key {
701
+ match value {
702
+ crate::model::yaml::Value::Str(value) => Some(value.as_str()),
703
+ _ => None,
704
+ }
705
+ } else {
706
+ None
707
+ }
708
+ })
709
+ }
710
+
643
711
  fn downgrade_stale_agent(agent: &mut Map<String, Value>) {
644
712
  let raw = agent
645
713
  .get("status")
@@ -125,12 +125,15 @@ fn run_dispatches_send_to_handler_returns_ok() {
125
125
  }
126
126
 
127
127
  #[test]
128
- fn run_unknown_subcommand_is_usage() {
129
- // An unknown subcommand routes to argparse-style usage (exit 2), never a handler.
128
+ fn run_unknown_subcommand_is_error_not_usage() {
129
+ // 0.5.45 naming-addressing (RED-6): unknown subcommand exits 1
130
+ // (Error), aligned with the shared refusal shape used by
131
+ // `send`/`--to-name` typos. Pre-0.5.45 argparse-style Usage (2)
132
+ // was internal drift.
130
133
  assert_eq!(
131
134
  run(&["totally-not-a-subcommand".to_string()], Path::new(".")),
132
- ExitCode::Usage,
133
- "an unknown subcommand must map to ExitCode::Usage"
135
+ ExitCode::Error,
136
+ "unknown subcommand must map to ExitCode::Error (aligned with send/--to-name typo family)"
134
137
  );
135
138
  }
136
139
 
@@ -101,7 +101,7 @@ pub(crate) fn detect_abnormal_exits(
101
101
  let fact = crate::provider::latest_explicit_error_fact(agent.provider, &text);
102
102
  let error_observation_key = fact
103
103
  .as_ref()
104
- .map(|fact| abnormal_error_observation_key(&agent, fact, size));
104
+ .map(|fact| abnormal_error_observation_key(&agent, fact));
105
105
  let error_observation_cohort = fact.as_ref().map(|_| abnormal_error_cohort_key(&agent));
106
106
  let error_recency = abnormal_error_recency(
107
107
  &snapshot,
@@ -116,7 +116,6 @@ pub(crate) fn detect_abnormal_exits(
116
116
  fact.as_ref(),
117
117
  error_recency,
118
118
  error_observation_key.as_deref(),
119
- size,
120
119
  );
121
120
  upsert_abnormal_watch(
122
121
  state,
@@ -155,7 +154,7 @@ pub(crate) fn detect_abnormal_exits(
155
154
  let fact = match (decision, fact) {
156
155
  (AbnormalExitDecision::Notify, Some(fact)) => fact,
157
156
  (AbnormalExitDecision::Suppress(reason), _) => {
158
- let suppress_key = abnormal_suppression_key(&agent, &liveness, reason, size);
157
+ let suppress_key = abnormal_suppression_key(&agent, &liveness, reason);
159
158
  if abnormal_last_suppressed_key(state, &agent.agent_id).as_deref()
160
159
  != Some(suppress_key.as_str())
161
160
  {
@@ -167,7 +166,7 @@ pub(crate) fn detect_abnormal_exits(
167
166
  (AbnormalExitDecision::NoSignal, _) => continue,
168
167
  (AbnormalExitDecision::Notify, None) => continue,
169
168
  };
170
- let dedupe_key = abnormal_dedupe_key(&agent, &fact, size);
169
+ let dedupe_key = abnormal_dedupe_key(&agent, &fact);
171
170
  if abnormal_last_notified_key(state, &agent.agent_id).as_deref()
172
171
  == Some(dedupe_key.as_str())
173
172
  {
@@ -555,7 +554,7 @@ fn agent_process_liveness(
555
554
  if let Some(command) = agent.current_command.as_deref() {
556
555
  return command_process_check_with_marker(agent, transport, command);
557
556
  }
558
- if let Some(pane_id) = agent.pane_id.as_deref() {
557
+ if agent.pane_id.is_some() {
559
558
  // Even without pane current_command / matching target, try the
560
559
  // marker probe directly — the wrapper's printf leaves the marker
561
560
  // in the pane's capture tail whether or not the transport
@@ -1135,16 +1134,13 @@ fn write_abnormal_suppressed(
1135
1134
  Ok(())
1136
1135
  }
1137
1136
 
1138
- fn abnormal_dedupe_key(
1139
- agent: &AbnormalWatchAgent,
1140
- fact: &crate::provider::FaultFact,
1141
- size: u64,
1142
- ) -> String {
1137
+ fn abnormal_dedupe_key(agent: &AbnormalWatchAgent, fact: &crate::provider::FaultFact) -> String {
1143
1138
  let bucket = fact
1144
1139
  .turn_id
1145
1140
  .as_ref()
1146
1141
  .map(|id| id.as_str().to_string())
1147
- .unwrap_or_else(|| size.to_string());
1142
+ .or_else(|| abnormal_error_fact_identity(fact))
1143
+ .unwrap_or_else(|| "no_error_identity".to_string());
1148
1144
  format!(
1149
1145
  "worker.abnormal_exit:{}:{}:{}:{}",
1150
1146
  agent.agent_id,
@@ -1174,9 +1170,9 @@ fn abnormal_error_cohort_key(agent: &AbnormalWatchAgent) -> String {
1174
1170
  fn abnormal_error_observation_key(
1175
1171
  agent: &AbnormalWatchAgent,
1176
1172
  fact: &crate::provider::FaultFact,
1177
- size: u64,
1178
1173
  ) -> String {
1179
- let bucket = abnormal_error_fact_identity(fact).unwrap_or_else(|| size.to_string());
1174
+ let bucket =
1175
+ abnormal_error_fact_identity(fact).unwrap_or_else(|| "no_error_identity".to_string());
1180
1176
  format!(
1181
1177
  "worker.abnormal_exit.error:{}:{}:{}:{}",
1182
1178
  agent.agent_id,
@@ -1217,15 +1213,13 @@ fn abnormal_suppression_key(
1217
1213
  agent: &AbnormalWatchAgent,
1218
1214
  liveness: &ProcessCheck,
1219
1215
  reason: &str,
1220
- size: u64,
1221
1216
  ) -> String {
1222
1217
  format!(
1223
- "abnormal_exit.single_signal_suppressed:{}:{}:{}:{}:{}",
1218
+ "abnormal_exit.single_signal_suppressed:{}:{}:{}:{}",
1224
1219
  agent.agent_id,
1225
1220
  agent.rollout_path_display,
1226
1221
  reason,
1227
- process_liveness_wire(liveness.state),
1228
- size
1222
+ process_liveness_wire(liveness.state)
1229
1223
  )
1230
1224
  }
1231
1225
 
@@ -1235,17 +1229,15 @@ fn abnormal_check_key(
1235
1229
  fact: Option<&crate::provider::FaultFact>,
1236
1230
  error_recency: ErrorRecency,
1237
1231
  error_observation_key: Option<&str>,
1238
- size: u64,
1239
1232
  ) -> String {
1240
1233
  format!(
1241
- "worker.abnormal_exit.check:{}:{}:{}:{}:{}:{}:{}",
1234
+ "worker.abnormal_exit.check:{}:{}:{}:{}:{}:{}",
1242
1235
  agent.agent_id,
1243
1236
  agent.rollout_path_display,
1244
1237
  process_liveness_wire(liveness.state),
1245
1238
  fact.map(|fact| fact.signature.as_str()).unwrap_or("-"),
1246
1239
  error_recency.as_str(),
1247
- error_observation_key.unwrap_or("-"),
1248
- size
1240
+ error_observation_key.unwrap_or("-")
1249
1241
  )
1250
1242
  }
1251
1243
 
@@ -2526,7 +2518,7 @@ mod tests {
2526
2518
  "{\"method\":\"turn/completed\",\"params\":{\"turn\":{\"id\":\"t1\",\"status\":\"failed\"}}}\n",
2527
2519
  )
2528
2520
  .unwrap();
2529
- let observation_key = abnormal_error_observation_key(&agent, &fact, 99);
2521
+ let observation_key = abnormal_error_observation_key(&agent, &fact);
2530
2522
  let state = serde_json::json!({
2531
2523
  "coordinator": {
2532
2524
  "abnormal_exit_watch": {
@@ -113,6 +113,23 @@ pub(crate) fn start_agent_at_paths(
113
113
  let window = agent_window(&agent, agent_id);
114
114
  let adaptive_layout =
115
115
  open_display && crate::lifecycle::launch::state_uses_adaptive_layout(&state);
116
+ let fake_provider = raw_agent
117
+ .get("provider")
118
+ .and_then(serde_json::Value::as_str)
119
+ .is_some_and(|provider| provider.eq_ignore_ascii_case("fake"));
120
+ if force && !fake_provider && is_per_agent_window(&window, agent_id) {
121
+ let expected_pane_id = raw_agent
122
+ .get("pane_id")
123
+ .and_then(serde_json::Value::as_str)
124
+ .filter(|value| !value.is_empty());
125
+ let target =
126
+ SameRoleCohortTarget::new(agent_id, &window).with_expected_pane_id(expected_pane_id);
127
+ if let Some(error) =
128
+ same_role_cohort_pre_spawn_error(transport, &session_name, "start-agent", &[target])
129
+ {
130
+ return Err(LifecycleError::RequirementUnmet(error));
131
+ }
132
+ }
116
133
  let agent_live = if adaptive_layout {
117
134
  agent_pane_live(transport, &raw_agent)
118
135
  } else {
@@ -640,7 +657,7 @@ fn list_same_role_panes(
640
657
  /// caller falls back to safer behavior (refuse stop, surface
641
658
  /// RequirementUnmet/transport error) when this returns false.
642
659
  fn is_per_agent_window(window: &str, agent_id: &AgentId) -> bool {
643
- window == agent_id.as_str() && !crate::lifecycle::launch::is_adaptive_layout_window_pub(window)
660
+ is_per_agent_cohort_window(window, agent_id)
644
661
  }
645
662
 
646
663
  fn tmux_start_mode_for_spawn(
@@ -1264,6 +1281,10 @@ fn reset_agent_at_paths(
1264
1281
  .filter(|p| !p.is_empty())
1265
1282
  .map(crate::transport::PaneId::new);
1266
1283
  let old_pane_pid_before = state_pane_pid(&state_before_stop, agent_id);
1284
+ let old_pane_live_before = old_pane_id_before
1285
+ .as_ref()
1286
+ .map(|pane| agent_pane_live_by_id(transport, pane))
1287
+ .unwrap_or(false);
1267
1288
  // CR C-2: take ONE pre-stop snapshot of the team session's panes so
1268
1289
  // the gate below can compute "what survived stop" by set difference,
1269
1290
  // not "what panes exist at all" (which would refuse legitimate
@@ -1286,7 +1307,18 @@ fn reset_agent_at_paths(
1286
1307
  if let Some(session) = pre_stop_session.as_ref() {
1287
1308
  if is_per_agent_window(&pre_stop_window, agent_id) {
1288
1309
  list_same_role_panes(transport, session, &pre_stop_window)
1289
- .iter()
1310
+ .into_iter()
1311
+ .filter(|pane| {
1312
+ if !old_pane_live_before {
1313
+ return true;
1314
+ }
1315
+ let same_old_pane = old_pane_id_before
1316
+ .as_ref()
1317
+ .is_some_and(|old| pane.pane_id.as_str() == old.as_str());
1318
+ let same_old_pid = old_pane_pid_before
1319
+ .is_some_and(|old_pid| pane.pane_pid == Some(old_pid));
1320
+ same_old_pane || same_old_pid
1321
+ })
1290
1322
  .map(|p| p.pane_id.as_str().to_string())
1291
1323
  .collect()
1292
1324
  } else {
@@ -1327,17 +1359,12 @@ fn reset_agent_at_paths(
1327
1359
  // CR C-5: gate is reset-specific; standalone stop-agent path keeps
1328
1360
  // existing "already absent is ok" behavior.
1329
1361
  //
1330
- // Gate scope refinement: when stop.stopped == true, the kill_pane
1331
- // call already succeeded (and drain_old_pane_and_pid polled for
1332
- // the pane to become unreachable). Treat that as the authoritative
1333
- // signal running the gate again post-stop introduces a race
1334
- // window where tmux's has_pane lag can spuriously report Live.
1335
- // Only gate the dangerous case: stop reported stopped == false
1336
- // (state's stale pane_id pointed at nothing kill-able), which is
1337
- // exactly the duplicate-window bug pattern from the macmini
1338
- // evidence: `stop_agent.complete stopped=false` followed by an
1339
- // unconditional `start_agent.agent_start`.
1340
- if !agent_is_paused && !stop.stopped {
1362
+ // P0 cohort proof: every non-paused reset takes a post-stop
1363
+ // same-role snapshot. Refuse only on tmux-visible residue; a
1364
+ // standalone old-pane liveness probe can be stale in mocks and
1365
+ // must not mask the later spawn ownership/window-disappeared
1366
+ // verifier.
1367
+ if !agent_is_paused {
1341
1368
  let spec_for_gate = load_team_spec(spec_workspace)?;
1342
1369
  let gate_state = resolve_team_scoped_state_or_refuse(workspace, team)?;
1343
1370
  let session_name_gate = state_session_name_from_spec(&gate_state, &spec_for_gate);
@@ -1349,10 +1376,6 @@ fn reset_agent_at_paths(
1349
1376
  .filter(|s| !s.is_empty())
1350
1377
  .unwrap_or_else(|| agent_id.as_str())
1351
1378
  .to_string();
1352
- let old_pane_still_live = old_pane_id_before
1353
- .as_ref()
1354
- .map(|p| agent_pane_live_by_id(transport, p))
1355
- .unwrap_or(false);
1356
1379
  // Take a SECOND snapshot post-stop and compute the differential.
1357
1380
  // Only panes present in BOTH snapshots are residue (stop did not
1358
1381
  // remove them).
@@ -1366,16 +1389,10 @@ fn reset_agent_at_paths(
1366
1389
  .into_iter()
1367
1390
  .filter(|p| pre_stop_pane_ids.contains(p.pane_id.as_str()))
1368
1391
  .collect();
1369
- // Pid-alone aliveness is secondary evidence and noisy under fixtures
1370
- // (synthetic pids may by chance be live on the test machine). Block
1371
- // ONLY on tmux-visible residue: old pane still live OR same-role
1372
- // panes survived stop. The pid is still recorded in the event for
1373
- // diagnostics.
1374
- let old_pid_still_live = old_pane_pid_before
1375
- .filter(|_| old_pane_still_live)
1376
- .map(|pid| pid_is_alive(pid))
1377
- .unwrap_or(false);
1378
- if old_pane_still_live || !remaining_panes.is_empty() {
1392
+ // Pid-alone aliveness is secondary evidence and noisy under
1393
+ // fixtures. Block only on tmux-visible same-role residue; record
1394
+ // the old pid in the event for diagnostics.
1395
+ if !remaining_panes.is_empty() {
1379
1396
  let remaining_pane_ids: Vec<String> = remaining_panes
1380
1397
  .iter()
1381
1398
  .map(|p| p.pane_id.as_str().to_string())
@@ -1454,6 +1471,8 @@ fn reset_agent_at_paths(
1454
1471
  )?;
1455
1472
  let started = matches!(start, StartAgentOutcome::Running { .. });
1456
1473
  write_reset_complete_event(workspace, agent_id, stop.stopped, started)?;
1474
+ let (capture_state, reset_proof, weak_reset_warning) =
1475
+ reset_capture_proof(workspace, agent_id, discarded_session_id.as_ref());
1457
1476
  match start {
1458
1477
  StartAgentOutcome::Running {
1459
1478
  env,
@@ -1476,6 +1495,9 @@ fn reset_agent_at_paths(
1476
1495
  discarded_session_id,
1477
1496
  session_id: output_session_id,
1478
1497
  new_session_id,
1498
+ capture_state,
1499
+ reset_proof,
1500
+ weak_reset_warning,
1479
1501
  })
1480
1502
  }
1481
1503
  StartAgentOutcome::Noop { env, .. } => Ok(ResetAgentOutcome::Reset {
@@ -1484,6 +1506,9 @@ fn reset_agent_at_paths(
1484
1506
  discarded_session_id,
1485
1507
  session_id: None,
1486
1508
  new_session_id: None,
1509
+ capture_state,
1510
+ reset_proof,
1511
+ weak_reset_warning,
1487
1512
  }),
1488
1513
  StartAgentOutcome::Paused { .. } => Ok(ResetAgentOutcome::Reset {
1489
1514
  env: AgentActionEnvelope {
@@ -1495,10 +1520,61 @@ fn reset_agent_at_paths(
1495
1520
  discarded_session_id,
1496
1521
  session_id: None,
1497
1522
  new_session_id: None,
1523
+ capture_state,
1524
+ reset_proof,
1525
+ weak_reset_warning,
1498
1526
  }),
1499
1527
  }
1500
1528
  }
1501
1529
 
1530
+ fn reset_capture_proof(
1531
+ workspace: &Path,
1532
+ agent_id: &AgentId,
1533
+ discarded_session_id: Option<&SessionId>,
1534
+ ) -> (String, String, Option<String>) {
1535
+ let state = crate::state::persist::load_runtime_state(workspace)
1536
+ .unwrap_or_else(|_| serde_json::json!({}));
1537
+ let agent = state
1538
+ .get("agents")
1539
+ .and_then(|agents| agents.get(agent_id.as_str()));
1540
+ let capture_state = agent
1541
+ .and_then(|agent| agent.get("capture_state"))
1542
+ .and_then(serde_json::Value::as_str)
1543
+ .filter(|value| !value.is_empty())
1544
+ .or_else(|| {
1545
+ agent
1546
+ .and_then(|agent| agent.get("attribution_ambiguous"))
1547
+ .and_then(serde_json::Value::as_bool)
1548
+ .filter(|ambiguous| *ambiguous)
1549
+ .map(|_| "attribution_ambiguous")
1550
+ })
1551
+ .or_else(|| {
1552
+ let has_session = agent
1553
+ .and_then(|agent| agent.get("session_id"))
1554
+ .and_then(serde_json::Value::as_str)
1555
+ .is_some_and(|value| !value.is_empty());
1556
+ let has_rollout = agent
1557
+ .and_then(|agent| agent.get("rollout_path"))
1558
+ .and_then(serde_json::Value::as_str)
1559
+ .is_some_and(|value| !value.is_empty());
1560
+ (has_session && has_rollout).then_some("captured")
1561
+ })
1562
+ .unwrap_or("transcript_missing")
1563
+ .to_string();
1564
+ let weak = discarded_session_id.is_none()
1565
+ || matches!(
1566
+ capture_state.as_str(),
1567
+ "transcript_missing" | "attribution_ambiguous"
1568
+ );
1569
+ let reset_proof = if weak { "weak" } else { "strong" }.to_string();
1570
+ let weak_reset_warning = weak.then(|| {
1571
+ format!(
1572
+ "weak reset proof: capture_state={capture_state}; lifecycle restarted but attribution did not prove a fresh transcript"
1573
+ )
1574
+ });
1575
+ (capture_state, reset_proof, weak_reset_warning)
1576
+ }
1577
+
1502
1578
  #[allow(clippy::too_many_arguments)]
1503
1579
  fn write_start_agent_start_event(
1504
1580
  workspace: &Path,
@@ -14,6 +14,142 @@ pub(super) struct SpawnedAgentWindow {
14
14
  pub owner_team_id: Option<String>,
15
15
  }
16
16
 
17
+ #[derive(Clone)]
18
+ pub(super) struct SameRoleCohortTarget {
19
+ pub agent_id: String,
20
+ pub window: String,
21
+ pub expected_pane_id: Option<String>,
22
+ }
23
+
24
+ impl SameRoleCohortTarget {
25
+ pub(super) fn new(agent_id: &AgentId, window: &str) -> Self {
26
+ Self {
27
+ agent_id: agent_id.as_str().to_string(),
28
+ window: window.to_string(),
29
+ expected_pane_id: None,
30
+ }
31
+ }
32
+
33
+ pub(super) fn with_expected_pane_id(mut self, pane_id: Option<&str>) -> Self {
34
+ self.expected_pane_id = pane_id.map(ToString::to_string);
35
+ self
36
+ }
37
+ }
38
+
39
+ pub(super) fn is_per_agent_cohort_window(window: &str, agent_id: &AgentId) -> bool {
40
+ window == agent_id.as_str() && !crate::lifecycle::launch::is_adaptive_layout_window_pub(window)
41
+ }
42
+
43
+ pub(super) fn same_role_cohort_pre_spawn_error(
44
+ transport: &dyn crate::transport::Transport,
45
+ session_name: &SessionName,
46
+ operation: &str,
47
+ targets: &[SameRoleCohortTarget],
48
+ ) -> Option<String> {
49
+ same_role_cohort_error(transport, session_name, operation, targets, 0, true)
50
+ }
51
+
52
+ pub(super) fn same_role_cohort_exactly_one_error(
53
+ transport: &dyn crate::transport::Transport,
54
+ session_name: &SessionName,
55
+ operation: &str,
56
+ targets: &[SameRoleCohortTarget],
57
+ ) -> Option<String> {
58
+ same_role_cohort_error(transport, session_name, operation, targets, 1, false)
59
+ }
60
+
61
+ pub(super) fn retire_expected_same_role_cohorts(
62
+ transport: &dyn crate::transport::Transport,
63
+ operation: &str,
64
+ targets: &[SameRoleCohortTarget],
65
+ ) -> Result<(), String> {
66
+ for target in targets {
67
+ let Some(pane_id) = target.expected_pane_id.as_deref() else {
68
+ continue;
69
+ };
70
+ let pane = crate::transport::PaneId::new(pane_id);
71
+ if transport.has_pane(&pane).ok().flatten() == Some(false) {
72
+ continue;
73
+ }
74
+ transport.kill_pane(&pane).map_err(|error| {
75
+ format!(
76
+ "{operation} failed to retire old same-role cohort {}:window={}:pane={pane_id}: {error}",
77
+ target.agent_id, target.window
78
+ )
79
+ })?;
80
+ }
81
+ Ok(())
82
+ }
83
+
84
+ fn same_role_cohort_error(
85
+ transport: &dyn crate::transport::Transport,
86
+ session_name: &SessionName,
87
+ operation: &str,
88
+ targets: &[SameRoleCohortTarget],
89
+ expected_live: usize,
90
+ expected_old_only: bool,
91
+ ) -> Option<String> {
92
+ let panes = transport.list_targets().ok()?;
93
+ let mut cardinality_proofs = Vec::new();
94
+ let mut binding_proofs = Vec::new();
95
+ for target in targets {
96
+ let live_panes = panes
97
+ .iter()
98
+ .filter(|pane| pane.session.as_str() == session_name.as_str())
99
+ .filter(|pane| {
100
+ pane.window_name
101
+ .as_ref()
102
+ .is_some_and(|window| window.as_str() == target.window)
103
+ })
104
+ .filter(|pane| {
105
+ !expected_old_only
106
+ || target
107
+ .expected_pane_id
108
+ .as_deref()
109
+ .is_some_and(|expected| pane.pane_id.as_str() == expected)
110
+ })
111
+ .filter(|pane| {
112
+ transport
113
+ .liveness(&pane.pane_id)
114
+ .is_ok_and(|live| live == crate::transport::PaneLiveness::Live)
115
+ })
116
+ .map(|pane| pane.pane_id.as_str().to_string())
117
+ .collect::<Vec<_>>();
118
+ if live_panes.len() != expected_live {
119
+ cardinality_proofs.push(format!(
120
+ "{}:window={}:live_panes=[{}]",
121
+ target.agent_id,
122
+ target.window,
123
+ live_panes.join(",")
124
+ ));
125
+ } else if !expected_old_only {
126
+ let observed = live_panes.first().map(String::as_str);
127
+ if target.expected_pane_id.as_deref() != observed {
128
+ binding_proofs.push(format!(
129
+ "{}:window={}:expected_pane={}:observed_pane={}",
130
+ target.agent_id,
131
+ target.window,
132
+ target.expected_pane_id.as_deref().unwrap_or("<missing>"),
133
+ observed.unwrap_or("<missing>")
134
+ ));
135
+ }
136
+ }
137
+ }
138
+ if !binding_proofs.is_empty() {
139
+ return Some(format!(
140
+ "{operation} refused: spawn identity/binding mismatch; {}",
141
+ binding_proofs.join("; ")
142
+ ));
143
+ }
144
+ if !cardinality_proofs.is_empty() {
145
+ return Some(format!(
146
+ "{operation} refused: same-role cohort duplicate proof failed; {}",
147
+ cardinality_proofs.join("; ")
148
+ ));
149
+ }
150
+ None
151
+ }
152
+
17
153
  #[allow(clippy::too_many_arguments)]
18
154
  pub(super) fn spawn_agent_window(
19
155
  workspace: &Path,