@team-agent/installer 0.5.3 → 0.5.4

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.3"
578
+ version = "0.5.4"
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.3"
12
+ version = "0.5.4"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -1,14 +1,20 @@
1
1
  //! diagnose/preflight/wait-ready CLI helpers.
2
2
  use super::*;
3
- use crate::provider::wire::{command_name, provider_wire};
3
+ use crate::provider::wire::{command_name, parse_provider, provider_wire};
4
4
  use crate::transport::Transport;
5
5
 
6
6
  pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value, Value) {
7
7
  let mut issues = Vec::new();
8
8
  let mut repairs = Vec::new();
9
9
 
10
- if let Some(session_name) = state.get("session_name").and_then(Value::as_str).filter(|s| !s.is_empty()) {
11
- match backend.has_session(&crate::transport::SessionName::new(session_name.to_string())) {
10
+ if let Some(session_name) = state
11
+ .get("session_name")
12
+ .and_then(Value::as_str)
13
+ .filter(|s| !s.is_empty())
14
+ {
15
+ match backend.has_session(&crate::transport::SessionName::new(
16
+ session_name.to_string(),
17
+ )) {
12
18
  Ok(true) => {}
13
19
  Ok(false) => {
14
20
  issues.push(json!("tmux_session_missing"));
@@ -69,13 +75,55 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
69
75
  }
70
76
  }
71
77
 
72
- if let Some(session_name) = state.get("session_name").and_then(Value::as_str).filter(|s| !s.is_empty()) {
73
- if let Ok(windows) = backend.list_windows(&crate::transport::SessionName::new(session_name.to_string())) {
78
+ if let Some(session_name) = state
79
+ .get("session_name")
80
+ .and_then(Value::as_str)
81
+ .filter(|s| !s.is_empty())
82
+ {
83
+ if let Ok(windows) = backend.list_windows(&crate::transport::SessionName::new(
84
+ session_name.to_string(),
85
+ )) {
74
86
  if let Some(agents) = state.get("agents").and_then(Value::as_object) {
75
87
  for (agent_id, agent_state) in agents {
88
+ let provider = agent_state
89
+ .get("provider")
90
+ .and_then(Value::as_str)
91
+ .and_then(parse_provider)
92
+ .unwrap_or(crate::provider::Provider::Codex);
93
+ let rollout_path = agent_state
94
+ .get("rollout_path")
95
+ .and_then(Value::as_str)
96
+ .filter(|path| !path.is_empty())
97
+ .map(crate::provider::RolloutPath::new);
98
+ let identity_probe =
99
+ crate::lifecycle::restart::session_identity_probe_for_agent(
100
+ &crate::model::ids::AgentId::new(agent_id.clone()),
101
+ provider,
102
+ rollout_path.as_ref(),
103
+ );
104
+ if identity_probe.identity_ok == Some(false) {
105
+ let issue = format!("session_identity_mismatch:{agent_id}");
106
+ issues.push(json!(issue));
107
+ repairs.push(json!({
108
+ "issue": format!("session_identity_mismatch:{agent_id}"),
109
+ "action": format!(
110
+ "run `team-agent restart --allow-fresh` to discard the poisoned session tuple for `{agent_id}`"
111
+ ),
112
+ "expected_agent_id": agent_id,
113
+ "embedded_agent_id": identity_probe.embedded_agent_id,
114
+ "rollout_path": identity_probe
115
+ .rollout_path
116
+ .map(|path| path.to_string_lossy().to_string()),
117
+ }));
118
+ }
76
119
  let window = ["window", "window_name"]
77
120
  .iter()
78
- .find_map(|key| agent_state.get(*key).and_then(Value::as_str).filter(|s| !s.is_empty()))
121
+ .find_map(|key| {
122
+ agent_state
123
+ .get(*key)
124
+ .and_then(Value::as_str)
125
+ .filter(|s| !s.is_empty())
126
+ })
79
127
  .unwrap_or(agent_id);
80
128
  if !windows.iter().any(|w| w.as_str() == window) {
81
129
  issues.push(json!(format!("worker_window_missing:{agent_id}")));
@@ -131,6 +179,80 @@ fn leader_receiver_attached(state: &Value) -> bool {
131
179
  mode_direct && status_attached && pane_present
132
180
  }
133
181
 
182
+ #[cfg(test)]
183
+ mod tests {
184
+ use super::*;
185
+
186
+ fn write_codex_identity_rollout(
187
+ workspace: &std::path::Path,
188
+ session_id: &str,
189
+ embedded_agent_id: &str,
190
+ ) -> std::path::PathBuf {
191
+ let path = workspace.join("rollout-poison.jsonl");
192
+ let text = format!(
193
+ "{{\"session_meta\":{{\"payload\":{{\"id\":\"{session_id}\",\"cwd\":\"{}\"}}}}}}\n\
194
+ {{\"type\":\"turn_context\",\"payload\":{{}}}}\n\
195
+ {{\"type\":\"response_item\",\"payload\":{{\"content\":[{{\"type\":\"input_text\",\"text\":\"You are Team Agent worker `{embedded_agent_id}` with role `fixture`.\"}}]}}}}\n",
196
+ workspace.to_string_lossy()
197
+ );
198
+ std::fs::write(&path, text).expect("write rollout");
199
+ path
200
+ }
201
+
202
+ #[test]
203
+ fn diagnose_surfaces_codex_session_identity_mismatch() {
204
+ let workspace =
205
+ std::env::temp_dir().join(format!("ta-diagnose-crossbind-{}", std::process::id()));
206
+ let _ = std::fs::remove_dir_all(&workspace);
207
+ std::fs::create_dir_all(&workspace).unwrap();
208
+ let rollout = write_codex_identity_rollout(
209
+ &workspace,
210
+ "019f3327-c35a-7023-b3cd-1bea93a7a157",
211
+ "ios-dev",
212
+ );
213
+ let state = json!({
214
+ "session_name": "team-fixture",
215
+ "leader_receiver": {
216
+ "mode": "direct_tmux",
217
+ "status": "attached",
218
+ "pane_id": "%leader",
219
+ "provider": "codex"
220
+ },
221
+ "agents": {
222
+ "frontend": {
223
+ "provider": "codex",
224
+ "status": "running",
225
+ "session_id": "019f3327-c35a-7023-b3cd-1bea93a7a157",
226
+ "rollout_path": rollout.to_string_lossy(),
227
+ "captured_at": "2026-07-05T17:04:04Z",
228
+ "captured_via": "fs_watch",
229
+ "spawn_cwd": workspace.to_string_lossy(),
230
+ "window": "frontend"
231
+ }
232
+ }
233
+ });
234
+ let backend = crate::transport::test_support::OfflineTransport::new()
235
+ .with_session_present(true)
236
+ .with_windows(vec![crate::transport::WindowName::new("frontend")]);
237
+ let (issues, repairs) = diagnose_runtime(&state, &backend);
238
+ assert!(
239
+ issues
240
+ .as_array()
241
+ .is_some_and(|items| items.iter().any(|item| item.as_str()
242
+ == Some("session_identity_mismatch:frontend"))),
243
+ "diagnose must surface session_identity_mismatch for poisoned Codex tuples; issues={issues}"
244
+ );
245
+ assert!(
246
+ repairs.as_array().is_some_and(|items| items.iter().any(|item| {
247
+ item.get("issue").and_then(Value::as_str)
248
+ == Some("session_identity_mismatch:frontend")
249
+ })),
250
+ "diagnose should include an explicit repair hint for the mismatched tuple; repairs={repairs}"
251
+ );
252
+ let _ = std::fs::remove_dir_all(&workspace);
253
+ }
254
+ }
255
+
134
256
  pub(crate) fn build_preflight_report(team: &std::path::Path) -> Result<Value, CliError> {
135
257
  let mut checks = Vec::new();
136
258
  let mut next_actions = Vec::new();
@@ -150,7 +272,9 @@ pub(crate) fn build_preflight_report(team: &std::path::Path) -> Result<Value, Cl
150
272
  "ok": false,
151
273
  "error": error.to_string(),
152
274
  }));
153
- next_actions.push(json!("fix TEAM.md and role front matter, then run preflight again"));
275
+ next_actions.push(json!(
276
+ "fix TEAM.md and role front matter, then run preflight again"
277
+ ));
154
278
  None
155
279
  }
156
280
  };
@@ -183,7 +307,11 @@ pub(crate) fn build_preflight_report(team: &std::path::Path) -> Result<Value, Cl
183
307
 
184
308
  let workspace = crate::model::paths::team_workspace(team)
185
309
  .map_err(|error| CliError::Runtime(error.to_string()))?;
186
- let profile_dir_exists = workspace.join(".team").join("current").join("profiles").exists()
310
+ let profile_dir_exists = workspace
311
+ .join(".team")
312
+ .join("current")
313
+ .join("profiles")
314
+ .exists()
187
315
  || team.join("profiles").exists();
188
316
  let profile_dir_check = json!({
189
317
  "name": "profile_dir",
@@ -229,7 +357,10 @@ pub(crate) fn build_preflight_report(team: &std::path::Path) -> Result<Value, Cl
229
357
  let summary = if ok {
230
358
  "preflight passed".to_string()
231
359
  } else {
232
- format!("preflight found blockers: {}", blocker_names(&blockers).join(", "))
360
+ format!(
361
+ "preflight found blockers: {}",
362
+ blocker_names(&blockers).join(", ")
363
+ )
233
364
  };
234
365
  let checks_value = Value::Array(checks);
235
366
  let details_log = write_details_log(
@@ -255,7 +386,9 @@ pub(crate) fn build_preflight_report(team: &std::path::Path) -> Result<Value, Cl
255
386
  Ok(report)
256
387
  }
257
388
 
258
- pub(crate) fn build_profile_smoke_check_for_team(team: &std::path::Path) -> Result<Value, CliError> {
389
+ pub(crate) fn build_profile_smoke_check_for_team(
390
+ team: &std::path::Path,
391
+ ) -> Result<Value, CliError> {
259
392
  let workspace = crate::model::paths::team_workspace(team)
260
393
  .map_err(|error| CliError::Runtime(error.to_string()))?;
261
394
  let spec = match crate::compiler::compile_team(team) {
@@ -376,10 +509,14 @@ pub(crate) fn build_wait_ready_report(
376
509
  }));
377
510
  }
378
511
  };
379
- let timeout = if timeout.is_finite() && timeout > 0.0 { timeout } else { 0.0 };
512
+ let timeout = if timeout.is_finite() && timeout > 0.0 {
513
+ timeout
514
+ } else {
515
+ 0.0
516
+ };
380
517
  let deadline = std::time::Instant::now() + std::time::Duration::from_secs_f64(timeout);
381
518
  let mut readiness;
382
- let mut state_read_error: Option<String> = None;
519
+ let mut state_read_error: Option<String>;
383
520
  loop {
384
521
  let mut state = match crate::state::projection::select_runtime_state(
385
522
  &selected.run_workspace,
@@ -419,7 +556,9 @@ pub(crate) fn build_wait_ready_report(
419
556
  "error",
420
557
  "state_read_error",
421
558
  "runtime state could not be read",
422
- vec![json!("inspect .team/runtime/state.json (corrupt or unreadable) and retry")],
559
+ vec![json!(
560
+ "inspect .team/runtime/state.json (corrupt or unreadable) and retry"
561
+ )],
423
562
  )
424
563
  } else if awaiting_trust {
425
564
  (
@@ -427,11 +566,17 @@ pub(crate) fn build_wait_ready_report(
427
566
  "pending",
428
567
  "awaiting_trust_prompt",
429
568
  "workers are awaiting trust prompt",
430
- vec![json!("answer the provider trust prompt, then run wait-ready again")],
569
+ vec![json!(
570
+ "answer the provider trust prompt, then run wait-ready again"
571
+ )],
431
572
  )
432
573
  } else if ready {
433
574
  (true, "ready", "ready", "workers ready", Vec::new())
434
- } else if readiness.get("session_capture_complete").and_then(Value::as_bool) == Some(false) {
575
+ } else if readiness
576
+ .get("session_capture_complete")
577
+ .and_then(Value::as_bool)
578
+ == Some(false)
579
+ {
435
580
  (
436
581
  false,
437
582
  "pending",
@@ -445,7 +590,9 @@ pub(crate) fn build_wait_ready_report(
445
590
  "timeout",
446
591
  "workers_not_ready",
447
592
  "workers not ready before timeout",
448
- vec![json!("inspect team-agent diagnose output and worker terminals")],
593
+ vec![json!(
594
+ "inspect team-agent diagnose output and worker terminals"
595
+ )],
449
596
  )
450
597
  };
451
598
  let details_log = write_details_log(
@@ -479,7 +626,11 @@ fn inject_tmux_session_present(workspace: &std::path::Path, state: &mut Value) {
479
626
  // runtime actually uses (state.tmux_endpoint / tmux_socket), not the
480
627
  // workspace-hash socket. Otherwise readiness reports `process_started=false`
481
628
  // even though the session is alive on the persisted socket.
482
- let Some(session_name) = state.get("session_name").and_then(Value::as_str).filter(|s| !s.is_empty()) else {
629
+ let Some(session_name) = state
630
+ .get("session_name")
631
+ .and_then(Value::as_str)
632
+ .filter(|s| !s.is_empty())
633
+ else {
483
634
  return;
484
635
  };
485
636
  let session_name_owned = session_name.to_string();
@@ -519,14 +670,11 @@ pub(crate) fn wait_readiness(state: &Value) -> Value {
519
670
  .get("leader_receiver")
520
671
  .and_then(Value::as_object)
521
672
  .is_some_and(|receiver| {
522
- receiver
523
- .get("status")
524
- .and_then(Value::as_str)
525
- == Some("attached")
673
+ receiver.get("status").and_then(Value::as_str) == Some("attached")
526
674
  || receiver
527
- .get("pane_id")
528
- .and_then(Value::as_str)
529
- .is_some_and(|pane| !pane.is_empty() && pane != "__team_agent_unbound__")
675
+ .get("pane_id")
676
+ .and_then(Value::as_str)
677
+ .is_some_and(|pane| !pane.is_empty() && pane != "__team_agent_unbound__")
530
678
  });
531
679
 
532
680
  if let Some(agents) = agents {
@@ -544,8 +692,7 @@ pub(crate) fn wait_readiness(state: &Value) -> Value {
544
692
  Some("running" | "busy" | "ready")
545
693
  )
546
694
  });
547
- mcp_ready = !agents.is_empty()
548
- && agents.values().all(agent_mcp_ready);
695
+ mcp_ready = !agents.is_empty() && agents.values().all(agent_mcp_ready);
549
696
  task_prompt_delivered = !agents.is_empty()
550
697
  && (message_counts_positive(state.get("messages"))
551
698
  || agents.values().all(|agent| {
@@ -560,7 +707,8 @@ pub(crate) fn wait_readiness(state: &Value) -> Value {
560
707
  .and_then(Value::as_str)
561
708
  == Some("awaiting_trust_prompt")
562
709
  });
563
- incomplete_sessions = crate::session_capture::incomplete_interacted_resumable_agent_ids(state);
710
+ incomplete_sessions =
711
+ crate::session_capture::incomplete_interacted_resumable_agent_ids(state);
564
712
  }
565
713
  let all_resumable_have_session = incomplete_sessions.is_empty();
566
714
  let session_capture_incomplete = !all_resumable_have_session;
@@ -591,7 +739,9 @@ fn inject_message_counts(workspace: &std::path::Path, state: &mut Value) -> Resu
591
739
  let mut stmt = conn
592
740
  .prepare("select status, count(*) from messages group by status order by status")
593
741
  .map_err(|e| CliError::Runtime(e.to_string()))?;
594
- let mut rows = stmt.query([]).map_err(|e| CliError::Runtime(e.to_string()))?;
742
+ let mut rows = stmt
743
+ .query([])
744
+ .map_err(|e| CliError::Runtime(e.to_string()))?;
595
745
  let mut messages = Map::new();
596
746
  while let Some(row) = rows.next().map_err(|e| CliError::Runtime(e.to_string()))? {
597
747
  let status: String = row.get(0).map_err(|e| CliError::Runtime(e.to_string()))?;
@@ -619,7 +769,10 @@ fn message_counts_positive(value: Option<&Value>) -> bool {
619
769
  fn legacy_process_started(agents: &serde_json::Map<String, Value>) -> bool {
620
770
  !agents.is_empty()
621
771
  && agents.values().all(|agent| {
622
- agent.get("pane_id").and_then(Value::as_str).is_some_and(|s| !s.is_empty())
772
+ agent
773
+ .get("pane_id")
774
+ .and_then(Value::as_str)
775
+ .is_some_and(|s| !s.is_empty())
623
776
  || agent.get("pid").and_then(Value::as_i64).is_some()
624
777
  || agent.get("process_started").and_then(Value::as_bool) == Some(true)
625
778
  || fake_agent_started(agent)
@@ -717,7 +870,10 @@ fn preflight_blockers(checks: &[Value]) -> Vec<Value> {
717
870
  .iter()
718
871
  .filter(|check| check.get("ok").and_then(Value::as_bool) == Some(false))
719
872
  .map(|check| {
720
- let name = check.get("name").and_then(Value::as_str).unwrap_or("unknown");
873
+ let name = check
874
+ .get("name")
875
+ .and_then(Value::as_str)
876
+ .unwrap_or("unknown");
721
877
  let reason = check
722
878
  .get("error")
723
879
  .or_else(|| check.get("reason"))
@@ -747,7 +903,11 @@ fn yaml_path_str<'a>(value: &'a crate::model::yaml::Value, keys: &[&str]) -> Opt
747
903
  current.as_str()
748
904
  }
749
905
 
750
- fn write_details_log(workspace: &std::path::Path, prefix: &str, value: &Value) -> Result<std::path::PathBuf, CliError> {
906
+ fn write_details_log(
907
+ workspace: &std::path::Path,
908
+ prefix: &str,
909
+ value: &Value,
910
+ ) -> Result<std::path::PathBuf, CliError> {
751
911
  let logs = workspace.join(".team").join("logs");
752
912
  std::fs::create_dir_all(&logs)?;
753
913
  let path = logs.join(format!("{prefix}-{}.json", timestamp_slug()));
@@ -210,14 +210,22 @@ pub mod lifecycle_port {
210
210
  /// `&*box`. Factory refusal falls back to the workspace tmux
211
211
  /// backend byte-equivalent to today so daemon liveness paths
212
212
  /// don't crash.
213
- pub fn shutdown(workspace: &Path, keep_logs: bool, team: Option<&str>) -> Result<Value, CliError> {
213
+ pub fn shutdown(
214
+ workspace: &Path,
215
+ keep_logs: bool,
216
+ team: Option<&str>,
217
+ ) -> Result<Value, CliError> {
214
218
  let run_ws = crate::model::paths::canonical_run_workspace(workspace)
215
219
  .map_err(|e| CliError::Runtime(e.to_string()))?;
216
220
  let state = shutdown_state_for_team(&run_ws, team)?;
217
221
  if let Some(endpoint) = legacy_worker_tmux_endpoint(&state) {
218
222
  let transport = shutdown_transport_for_endpoint(endpoint);
219
223
  return shutdown_with_transport_and_state(
220
- workspace, keep_logs, team, &transport, Some(state),
224
+ workspace,
225
+ keep_logs,
226
+ team,
227
+ &transport,
228
+ Some(state),
221
229
  );
222
230
  }
223
231
  let boxed: Box<dyn crate::transport::Transport> =
@@ -352,11 +360,7 @@ pub mod lifecycle_port {
352
360
  let anchor_sessions = anchor_sessions_from_state(state, &pane_targets, event_log);
353
361
  match sessions_to_kill(&sessions, &anchor_sessions) {
354
362
  KillDecision::KillServerExclusive => {
355
- if state
356
- .get("tmux_socket_source")
357
- .and_then(Value::as_str)
358
- == Some("leader_env")
359
- {
363
+ if state.get("tmux_socket_source").and_then(Value::as_str) == Some("leader_env") {
360
364
  let _ = event_log.write(
361
365
  "shutdown.kill_server_skipped_shared_socket",
362
366
  json!({
@@ -463,10 +467,7 @@ pub mod lifecycle_port {
463
467
  .cloned()
464
468
  .collect::<Vec<_>>();
465
469
  if let Some(anchored) = target_spared_for_anchor {
466
- if !spared
467
- .iter()
468
- .any(|s| s.as_str() == anchored.as_str())
469
- {
470
+ if !spared.iter().any(|s| s.as_str() == anchored.as_str()) {
470
471
  spared.push(anchored);
471
472
  }
472
473
  }
@@ -486,7 +487,10 @@ pub mod lifecycle_port {
486
487
  // (this function) computes its own `to_kill` from `target`. Any
487
488
  // leader-prefixed entry here is a topology violation introduced
488
489
  // somewhere upstream. Log loudly and skip the kill.
489
- if session.as_str().starts_with(crate::leader::LEADER_SESSION_PREFIX) {
490
+ if session
491
+ .as_str()
492
+ .starts_with(crate::leader::LEADER_SESSION_PREFIX)
493
+ {
490
494
  eprintln!(
491
495
  "team_agent::layout shutdown_invariant_violation kind=KillListContainsLeaderSession \
492
496
  session=`{}` action=skipping_kill (post-Step-9 will hard-fail)",
@@ -723,11 +727,7 @@ pub mod lifecycle_port {
723
727
  if crate::state::projection::state_is_managed_leader(&state)
724
728
  && session_has_leader_anchor
725
729
  {
726
- let _ = event_log_write_session_spared(
727
- &run_workspace,
728
- session,
729
- &leader_anchor_ids,
730
- );
730
+ let _ = event_log_write_session_spared(&run_workspace, session, &leader_anchor_ids);
731
731
  push_unique_session(&mut spared_sessions, session.clone());
732
732
  let worker_panes = collect_session_worker_panes(
733
733
  &state,
@@ -742,10 +742,8 @@ pub mod lifecycle_port {
742
742
  }
743
743
  }
744
744
  }
745
- let _ = crate::lifecycle::display::close_team_display_backends(
746
- &run_workspace,
747
- session,
748
- );
745
+ let _ =
746
+ crate::lifecycle::display::close_team_display_backends(&run_workspace, session);
749
747
  } else {
750
748
  push_unique_session(&mut killed_sessions, session.clone());
751
749
  if let Err(error) = transport.kill_session(session) {
@@ -753,10 +751,8 @@ pub mod lifecycle_port {
753
751
  kill_error = Some(error.to_string());
754
752
  }
755
753
  }
756
- let _ = crate::lifecycle::display::close_team_display_backends(
757
- &run_workspace,
758
- session,
759
- );
754
+ let _ =
755
+ crate::lifecycle::display::close_team_display_backends(&run_workspace, session);
760
756
  }
761
757
  }
762
758
  deadline.check("reap_workspace_residuals")?;
@@ -953,7 +949,7 @@ pub mod lifecycle_port {
953
949
  // byte-preserving. The Unix behavior is a no-op (no shim
954
950
  // concept there); adding a placeholder key would just noise
955
951
  // up the fixtures.
956
- let mut response = json!({
952
+ let response = json!({
957
953
  "ok": ok,
958
954
  "status": status,
959
955
  "phase": phase,
@@ -979,6 +975,8 @@ pub mod lifecycle_port {
979
975
  },
980
976
  });
981
977
  #[cfg(windows)]
978
+ let mut response = response;
979
+ #[cfg(windows)]
982
980
  {
983
981
  if let Some(obj) = response.as_object_mut() {
984
982
  obj.insert(
@@ -1291,10 +1289,7 @@ pub mod lifecycle_port {
1291
1289
  // probes when the primary transport is ConPTY; the primary
1292
1290
  // transport check above already covered the honest question
1293
1291
  // ("does the shim still have this session?").
1294
- let primary_is_conpty = matches!(
1295
- transport.kind(),
1296
- crate::transport::BackendKind::ConPty
1297
- );
1292
+ let primary_is_conpty = matches!(transport.kind(), crate::transport::BackendKind::ConPty);
1298
1293
  if !primary_is_conpty {
1299
1294
  let workspace_transport = shutdown_workspace_transport(workspace);
1300
1295
  match crate::transport::Transport::has_session(&workspace_transport, session) {
@@ -2938,14 +2933,38 @@ pub mod lifecycle_port {
2938
2933
  );
2939
2934
  }
2940
2935
  }
2936
+ if let crate::provider::session::ResumeRefusalReason::SessionIdentityMismatch {
2937
+ expected_agent_id,
2938
+ embedded_agent_id,
2939
+ session_id,
2940
+ rollout_path,
2941
+ } = reason
2942
+ {
2943
+ entry.insert(
2944
+ "expected_agent_id".to_string(),
2945
+ json!(expected_agent_id),
2946
+ );
2947
+ entry.insert(
2948
+ "embedded_agent_id".to_string(),
2949
+ json!(embedded_agent_id),
2950
+ );
2951
+ entry.insert(
2952
+ "poisoned_session_id".to_string(),
2953
+ json!(session_id),
2954
+ );
2955
+ if let Some(path) = rollout_path {
2956
+ entry.insert(
2957
+ "rollout_path".to_string(),
2958
+ json!(path.to_string_lossy()),
2959
+ );
2960
+ }
2961
+ }
2941
2962
  }
2942
2963
  Value::Object(entry)
2943
2964
  })
2944
2965
  .collect();
2945
- let unresumable_ids: Vec<&str> = unresumable
2946
- .iter()
2947
- .map(|w| w.agent_id.as_str())
2948
- .collect();
2966
+ let unresumable_ids: Vec<&str> =
2967
+ unresumable.iter().map(|w| w.agent_id.as_str()).collect();
2949
2968
  // Layer 2 self-healing (leader follow-up 2026-06-22): when
2950
2969
  // EVERY unresumable worker shares the same structured
2951
2970
  // refusal_reason wire string, refine the top-level `status`
@@ -3036,6 +3055,46 @@ pub mod lifecycle_port {
3036
3055
  unresumable_ids.join(", "),
3037
3056
  ),
3038
3057
  ),
3058
+ "session_identity_mismatch" => {
3059
+ let mut lines = Vec::new();
3060
+ for w in unresumable.iter() {
3061
+ if let Some(
3062
+ crate::provider::session::ResumeRefusalReason::SessionIdentityMismatch {
3063
+ expected_agent_id,
3064
+ embedded_agent_id,
3065
+ session_id,
3066
+ rollout_path,
3067
+ },
3068
+ ) = w.refusal_reason.as_ref()
3069
+ {
3070
+ let path = rollout_path
3071
+ .as_ref()
3072
+ .map(|p| p.to_string_lossy().into_owned())
3073
+ .unwrap_or_else(|| "<unknown>".to_string());
3074
+ lines.push(format!(
3075
+ " {}: session {} points to transcript identity {} at {} (expected {})",
3076
+ w.agent_id.as_str(),
3077
+ session_id,
3078
+ embedded_agent_id,
3079
+ path,
3080
+ expected_agent_id
3081
+ ));
3082
+ }
3083
+ }
3084
+ let mut msg = format!(
3085
+ "restart refused: provider session identity mismatch for {} worker(s) ({}). Pass --allow-fresh to discard the poisoned tuple and start fresh.",
3086
+ unresumable.len(),
3087
+ unresumable_ids.join(", "),
3088
+ );
3089
+ if !lines.is_empty() {
3090
+ msg.push_str("\nMismatched sessions:");
3091
+ for line in &lines {
3092
+ msg.push('\n');
3093
+ msg.push_str(line);
3094
+ }
3095
+ }
3096
+ ("refused_session_identity_mismatch".to_string(), msg)
3097
+ }
3039
3098
  _ => ("refused_resume_atomicity".to_string(), error.clone()),
3040
3099
  }
3041
3100
  } else {
@@ -553,6 +553,19 @@ impl Coordinator {
553
553
  }),
554
554
  )?;
555
555
  }
556
+ for mismatch in &report.identity_mismatches {
557
+ event_log.write(
558
+ "provider.session.identity_mismatch",
559
+ serde_json::json!({
560
+ "agent_id": mismatch.agent_id,
561
+ "expected_agent_id": mismatch.expected_agent_id,
562
+ "embedded_agent_id": mismatch.embedded_agent_id,
563
+ "session_id": mismatch.session_id,
564
+ "rollout_path": mismatch.rollout_path,
565
+ "spawn_cwd": mismatch.spawn_cwd,
566
+ }),
567
+ )?;
568
+ }
556
569
  for ambiguous in report.ambiguous {
557
570
  let candidate_count = report
558
571
  .candidate_count_by_agent
@@ -374,6 +374,8 @@ fn spawn_agents(
374
374
  );
375
375
  }
376
376
  }
377
+ let spawn_epoch = u64::try_from(started.len()).unwrap_or(u64::MAX);
378
+ let spawned_at = spawn_timestamp_for_agent(u32::try_from(spawn_epoch).unwrap_or(u32::MAX));
377
379
  // E6 层1 实证3 + 诊断留痕:落最终 worker argv(spawn 前的真实形态)。
378
380
  // 任何"--session-id 预定 UUID 没生效"必须能从 events.jsonl 回答:argv 里到底有没有它。
379
381
  // 抽出 --session-id 值单列,方便和盘上 ~/.claude/projects/<cwd> 实际落的 UUID 对账。
@@ -393,6 +395,10 @@ fn spawn_agents(
393
395
  "argv": plan.argv,
394
396
  "session_id_in_argv": session_id_in_argv,
395
397
  "expected_session_id": plan.expected_session_id.as_ref().map(|s| s.as_str()),
398
+ "spawn_cwd": workspace.to_string_lossy(),
399
+ "spawned_at": spawned_at.as_str(),
400
+ "source": "launch",
401
+ "spawn_epoch": spawn_epoch,
396
402
  }),
397
403
  );
398
404
  }
@@ -473,6 +479,7 @@ fn spawn_agents(
473
479
  agent_id,
474
480
  start_mode: StartMode::Fresh,
475
481
  target: spawn.pane_id.as_str().to_string(),
482
+ spawned_at,
476
483
  session_id: None,
477
484
  rollout_path: None,
478
485
  pending_session_id: plan.expected_session_id.clone(),
@@ -872,7 +879,9 @@ fn persist_spawn_agent_state(
872
879
  continue;
873
880
  }
874
881
  let pane_pid = pane_pids_by_agent.get(id).copied();
875
- let spawned_at = spawn_timestamp_for_agent(spawn_index);
882
+ let spawned_at = started_agent
883
+ .map(|started| started.spawned_at.clone())
884
+ .unwrap_or_else(|| spawn_timestamp_for_agent(spawn_index));
876
885
  spawn_index = spawn_index.saturating_add(1);
877
886
  agents.insert(
878
887
  id.to_string(),
@@ -2970,11 +2979,10 @@ pub fn quick_start_in_workspace_with_display_and_backend(
2970
2979
  // `state.transport.shim.pipe_ready = true` marker so its
2971
2980
  // `conpty_pipe_ready` gate opens.
2972
2981
  #[cfg(windows)]
2973
- let state_value = std::fs::read_to_string(crate::state::persist::runtime_state_path(
2974
- &workspace,
2975
- ))
2976
- .ok()
2977
- .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok());
2982
+ let state_value =
2983
+ std::fs::read_to_string(crate::state::persist::runtime_state_path(&workspace))
2984
+ .ok()
2985
+ .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok());
2978
2986
  #[cfg(windows)]
2979
2987
  let input = match state_value.as_ref() {
2980
2988
  Some(v) => input.with_state(Some(v)),