@team-agent/installer 0.5.45 → 0.5.47

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.
package/Cargo.lock CHANGED
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.45"
578
+ version = "0.5.47"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.45"
12
+ version = "0.5.47"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -11,16 +11,22 @@ use std::io::Write as _;
11
11
  pub fn emit(output: &CmdOutput, as_json: bool) -> Option<String> {
12
12
  match output {
13
13
  CmdOutput::None => None,
14
- CmdOutput::Human(text) => Some(text.clone()),
15
- CmdOutput::Json(value) if as_json => serde_json::to_string_pretty(&sort_json(value)).ok(),
16
- CmdOutput::Json(Value::Object(obj)) => {
17
- let lines: Vec<String> = obj
18
- .iter()
19
- .map(|(key, value)| format!("{key}: {}", human_value(value)))
20
- .collect();
21
- Some(lines.join("\n"))
14
+ CmdOutput::Human(text) => Some(crate::redaction::redact_external_text(text)),
15
+ CmdOutput::Json(value) => {
16
+ let value = crate::redaction::redact_external_value(value);
17
+ if as_json {
18
+ return serde_json::to_string_pretty(&sort_json(&value)).ok();
19
+ }
20
+ if let Value::Object(obj) = value {
21
+ let lines: Vec<String> = obj
22
+ .iter()
23
+ .map(|(key, value)| format!("{key}: {}", human_value(value)))
24
+ .collect();
25
+ Some(lines.join("\n"))
26
+ } else {
27
+ Some(human_value(&value))
28
+ }
22
29
  }
23
- CmdOutput::Json(value) => Some(human_value(value)),
24
30
  }
25
31
  }
26
32
 
@@ -620,8 +626,24 @@ fn emit_cli_error(command: &str, args: &[String], cwd: &Path, error: &CliError)
620
626
  }
621
627
  let normalized = normalize_cli_error(error);
622
628
  let payload_error = normalized.as_ref().unwrap_or(error);
623
- let _ = std::fs::write(&log_path, format!("{payload_error}\n"));
624
- let payload = payload_error.to_payload(&log_path, command);
629
+ let safe_error = crate::redaction::redact_external_text(&payload_error.to_string());
630
+ let _ = std::fs::write(&log_path, format!("{safe_error}\n"));
631
+ let mut payload = payload_error.to_payload(&log_path, command);
632
+ payload.error = safe_error;
633
+ payload.action = crate::redaction::redact_external_text(&payload.action);
634
+ payload.log = crate::redaction::redact_external_text(&payload.log);
635
+ payload.reason = payload
636
+ .reason
637
+ .map(|value| crate::redaction::redact_external_text(&value));
638
+ payload.session_name = payload
639
+ .session_name
640
+ .map(|value| crate::redaction::redact_external_text(&value));
641
+ payload.next_actions = payload.next_actions.map(|values| {
642
+ values
643
+ .into_iter()
644
+ .map(|value| crate::redaction::redact_external_text(&value))
645
+ .collect()
646
+ });
625
647
  if has_arg(args, "--json") {
626
648
  if let Ok(value) = serde_json::to_value(payload) {
627
649
  println!("{}", python_compact_json(&value));
@@ -2275,6 +2275,9 @@ pub mod lifecycle_port {
2275
2275
  discarded_session_id,
2276
2276
  session_id,
2277
2277
  new_session_id,
2278
+ capture_state,
2279
+ reset_proof,
2280
+ weak_reset_warning,
2278
2281
  }) => Ok(json!({
2279
2282
  "ok": true,
2280
2283
  "agent_id": env.agent_id.as_str(),
@@ -2285,6 +2288,9 @@ pub mod lifecycle_port {
2285
2288
  "discarded_session_id": discarded_session_id.as_ref().map(|id| id.as_str()),
2286
2289
  "session_id": session_id.as_ref().map(|id| id.as_str()),
2287
2290
  "new_session_id": new_session_id.as_ref().map(|id| id.as_str()),
2291
+ "capture_state": capture_state,
2292
+ "reset_proof": reset_proof,
2293
+ "weak_reset_warning": weak_reset_warning,
2288
2294
  })),
2289
2295
  Ok(crate::lifecycle::ResetAgentOutcome::Refused { reason }) => Ok(json!({
2290
2296
  "ok": false,
@@ -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")
@@ -88,7 +89,7 @@ pub fn status_scoped(
88
89
  );
89
90
  }
90
91
  let readiness = crate::cli::diagnose::wait_readiness(&readiness_state);
91
- let full = json!({
92
+ let full = crate::redaction::redact_external_value(&json!({
92
93
  "ok": true,
93
94
  "team": state.pointer("/leader/id").cloned().unwrap_or_else(|| json!("leader")),
94
95
  "session_name": state.get("session_name").cloned().unwrap_or(Value::Null),
@@ -123,7 +124,7 @@ pub fn status_scoped(
123
124
  .tail(10)
124
125
  .map_err(|e| CliError::Runtime(e.to_string()))?,
125
126
  ),
126
- });
127
+ }));
127
128
  if compact {
128
129
  Ok(compact_status(full))
129
130
  } else {
@@ -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")
@@ -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": {
@@ -147,7 +147,7 @@ impl EventLog {
147
147
  obj.insert(k, v);
148
148
  }
149
149
  }
150
- let event = Value::Object(obj);
150
+ let event = crate::redaction::redact_external_value(&Value::Object(obj));
151
151
  self.maybe_rotate()?;
152
152
  // 单次 write_all(line+"\n"):POSIX O_APPEND 对 <PIPE_BUF 写原子,避免并发写者交错(对抗 P1)。
153
153
  let mut bytes = to_python_json(&sort_value(&event)).into_bytes();
@@ -176,11 +176,11 @@ impl EventLog {
176
176
  let mut out = Vec::new();
177
177
  for line in &lines[start..] {
178
178
  match serde_json::from_str::<Value>(line) {
179
- Ok(v) => out.push(v),
179
+ Ok(v) => out.push(crate::redaction::redact_external_value(&v)),
180
180
  Err(_) => {
181
181
  let mut m = serde_json::Map::new();
182
182
  m.insert("raw".to_string(), Value::String((*line).to_string()));
183
- out.push(Value::Object(m));
183
+ out.push(crate::redaction::redact_external_value(&Value::Object(m)));
184
184
  }
185
185
  }
186
186
  }
@@ -56,6 +56,7 @@ pub use crate::db::message_store;
56
56
  // step 8 (provider) — ProviderAdapter trait + typed provider/turn-state/liveness 等(ROUND-0 骨架;
57
57
  // fn body unimplemented!(),P2 porter 落实现)。MUST-NOT-13:provider 调用全走 trait。
58
58
  pub mod provider;
59
+ mod redaction;
59
60
  /// unit-6 (Stage 2) compat shim. Physical home is now
60
61
  /// `crate::provider::session::capture`; this re-export keeps every
61
62
  /// `crate::session_capture::*` caller working without modification.
@@ -485,27 +485,17 @@ fn proxy_scheme(url: &str) -> Option<String> {
485
485
 
486
486
  fn redact_endpoint(raw: &str) -> String {
487
487
  let no_query = raw.split_once('?').map(|(head, _)| head).unwrap_or(raw);
488
- let Some((scheme, rest)) = no_query.split_once("://") else {
489
- return no_query.to_string();
490
- };
491
- let slash = rest.find('/').unwrap_or(rest.len());
492
- let authority = &rest[..slash];
493
- let path = &rest[slash..];
494
- if let Some((_, host)) = authority.rsplit_once('@') {
495
- format!("{scheme}://[redacted]@{host}{path}")
496
- } else {
497
- no_query.to_string()
498
- }
488
+ crate::redaction::redact_external_text(no_query)
499
489
  }
500
490
 
501
491
  fn redact_text(raw: &str, secrets: &[&str]) -> String {
502
492
  let mut out = raw.chars().take(512).collect::<String>();
503
493
  for secret in secrets {
504
494
  if !secret.is_empty() {
505
- out = out.replace(secret, "[redacted]");
495
+ out = out.replace(secret, "[REDACTED]");
506
496
  }
507
497
  }
508
- out
498
+ crate::redaction::redact_external_text(&out)
509
499
  }
510
500
 
511
501
  fn auth_mode_wire(auth_mode: AuthMode) -> &'static str {
@@ -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,