@team-agent/installer 0.3.22 → 0.3.24

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
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.3.22"
569
+ version = "0.3.24"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.3.22"
12
+ version = "0.3.24"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -2,7 +2,7 @@
2
2
  //! + 双写 / 分叉检测。
3
3
 
4
4
  use std::collections::BTreeSet;
5
- use std::path::Path;
5
+ use std::path::{Path, PathBuf};
6
6
 
7
7
  use serde_json::{json, Value};
8
8
 
@@ -1024,14 +1024,27 @@ fn state_owner(state: &Value) -> Option<TeamOwner> {
1024
1024
  pub fn write_lease_dual_state(workspace: &Path, state: &Value) -> Result<(), LeaderError> {
1025
1025
  crate::state::persist::save_runtime_state(workspace, state)?;
1026
1026
  if let Some(session_name) = state.get("session_name").and_then(Value::as_str) {
1027
- let snap_path = crate::model::paths::runtime_dir(workspace)
1028
- .join("teams")
1029
- .join(session_name)
1030
- .join("state.json");
1031
- if let Some(parent) = snap_path.parent() {
1032
- std::fs::create_dir_all(parent)?;
1033
- }
1034
- std::fs::write(snap_path, serde_json::to_string_pretty(state)?)?;
1027
+ write_team_snapshot_atomic(workspace, session_name, state)?;
1028
+ }
1029
+ Ok(())
1030
+ }
1031
+
1032
+ fn write_team_snapshot_atomic(
1033
+ workspace: &Path,
1034
+ session_name: &str,
1035
+ state: &Value,
1036
+ ) -> Result<(), LeaderError> {
1037
+ let snap_path = crate::lifecycle::helpers::team_snapshot_path(workspace, session_name);
1038
+ let parent = snap_path
1039
+ .parent()
1040
+ .ok_or_else(|| LeaderError::Validation("team snapshot path has no parent".to_string()))?;
1041
+ std::fs::create_dir_all(parent)?;
1042
+ let tmp = parent.join(format!("state.json.tmp-{}", std::process::id()));
1043
+ let data = serde_json::to_vec_pretty(state)?;
1044
+ std::fs::write(&tmp, data)?;
1045
+ if let Err(error) = std::fs::rename(&tmp, &snap_path) {
1046
+ let _ = std::fs::remove_file(&tmp);
1047
+ return Err(error.into());
1035
1048
  }
1036
1049
  Ok(())
1037
1050
  }
@@ -1113,10 +1126,7 @@ pub fn detect_dual_state_divergence(
1113
1126
  let Some(session_name) = state.get("session_name").and_then(Value::as_str) else {
1114
1127
  return Ok(None);
1115
1128
  };
1116
- let snap_path = crate::model::paths::runtime_dir(workspace)
1117
- .join("teams")
1118
- .join(session_name)
1119
- .join("state.json");
1129
+ let snap_path = readable_team_snapshot_path(workspace, session_name);
1120
1130
  if !snap_path.exists() {
1121
1131
  return Ok(None);
1122
1132
  }
@@ -1150,6 +1160,17 @@ pub fn detect_dual_state_divergence(
1150
1160
  })))
1151
1161
  }
1152
1162
 
1163
+ fn readable_team_snapshot_path(workspace: &Path, session_name: &str) -> PathBuf {
1164
+ let safe_path = crate::lifecycle::helpers::team_snapshot_path(workspace, session_name);
1165
+ if safe_path.exists() {
1166
+ return safe_path;
1167
+ }
1168
+ crate::model::paths::runtime_dir(workspace)
1169
+ .join("teams")
1170
+ .join(session_name)
1171
+ .join("state.json")
1172
+ }
1173
+
1153
1174
  #[cfg(test)]
1154
1175
  mod tests {
1155
1176
  use super::*;
@@ -145,6 +145,85 @@ use super::*;
145
145
  );
146
146
  }
147
147
 
148
+ #[test]
149
+ fn lease_divergence_reads_restart_snapshot_for_special_session_names() {
150
+ let ws = std::env::temp_dir().join(format!("ta_rs_dual_special_{}", std::process::id()));
151
+ std::fs::create_dir_all(&ws).unwrap();
152
+ let state = serde_json::json!({
153
+ "session_name": "team:proj-中文",
154
+ "team_owner": {"pane_id": "%1", "owner_epoch": 2, "leader_session_uuid": "uuuu"},
155
+ "leader_receiver": {"pane_id": "%1", "owner_epoch": 2},
156
+ });
157
+ write_lease_dual_state(&ws, &state).unwrap();
158
+ let safe_path = crate::lifecycle::helpers::team_snapshot_path(&ws, "team:proj-中文");
159
+ let raw_path = crate::model::paths::runtime_dir(&ws)
160
+ .join("teams")
161
+ .join("team:proj-中文")
162
+ .join("state.json");
163
+ assert!(
164
+ safe_path.exists(),
165
+ "lease snapshot must use the restart-safe path: {}",
166
+ safe_path.display()
167
+ );
168
+ assert!(
169
+ !raw_path.exists(),
170
+ "new lease writes must not create the legacy raw session path: {}",
171
+ raw_path.display()
172
+ );
173
+
174
+ let restart_state = serde_json::json!({
175
+ "session_name": "team:proj-中文",
176
+ "team_owner": {"pane_id": "%9", "owner_epoch": 2, "leader_session_uuid": "uuuu"},
177
+ "leader_receiver": {"pane_id": "%9", "owner_epoch": 2},
178
+ });
179
+ let restart_path = crate::lifecycle::save_team_runtime_snapshot(&ws, &restart_state).unwrap();
180
+ assert_eq!(restart_path, safe_path);
181
+ assert!(
182
+ restart_path.exists(),
183
+ "restart snapshot must be written to a real file: {}",
184
+ restart_path.display()
185
+ );
186
+
187
+ let d = detect_dual_state_divergence(&ws, &state)
188
+ .unwrap()
189
+ .expect("divergence detector must read the restart-written snapshot");
190
+ assert_eq!(d["workspace_owner_pane"], serde_json::json!("%1"));
191
+ assert_eq!(d["team_owner_pane"], serde_json::json!("%9"));
192
+ assert_eq!(d["workspace_receiver_pane"], serde_json::json!("%1"));
193
+ assert_eq!(d["team_receiver_pane"], serde_json::json!("%9"));
194
+ }
195
+
196
+ #[test]
197
+ fn detect_dual_state_divergence_reads_legacy_raw_snapshot_when_safe_absent() {
198
+ let ws = std::env::temp_dir().join(format!("ta_rs_dual_legacy_{}", std::process::id()));
199
+ std::fs::create_dir_all(&ws).unwrap();
200
+ let state = serde_json::json!({
201
+ "session_name": "team:legacy-中文",
202
+ "team_owner": {"pane_id": "%1", "leader_session_uuid": "uuuu", "owner_epoch": 2},
203
+ "leader_receiver": {"pane_id": "%1", "owner_epoch": 2},
204
+ });
205
+ let safe_path = crate::lifecycle::helpers::team_snapshot_path(&ws, "team:legacy-中文");
206
+ assert!(!safe_path.exists(), "test setup requires safe path absent");
207
+ let raw_dir = crate::model::paths::runtime_dir(&ws)
208
+ .join("teams")
209
+ .join("team:legacy-中文");
210
+ std::fs::create_dir_all(&raw_dir).unwrap();
211
+ let snap = serde_json::json!({
212
+ "session_name": "team:legacy-中文",
213
+ "team_owner": {"pane_id": "%8", "leader_session_uuid": "uuuu", "owner_epoch": 2},
214
+ "leader_receiver": {"pane_id": "%8", "owner_epoch": 2},
215
+ });
216
+ std::fs::write(raw_dir.join("state.json"), serde_json::to_string(&snap).unwrap()).unwrap();
217
+
218
+ let d = detect_dual_state_divergence(&ws, &state)
219
+ .unwrap()
220
+ .expect("legacy raw snapshot fallback must preserve old teams");
221
+ assert_eq!(d["workspace_owner_pane"], serde_json::json!("%1"));
222
+ assert_eq!(d["team_owner_pane"], serde_json::json!("%8"));
223
+ assert_eq!(d["workspace_receiver_pane"], serde_json::json!("%1"));
224
+ assert_eq!(d["team_receiver_pane"], serde_json::json!("%8"));
225
+ }
226
+
148
227
  // detect_dual_state_divergence:无 session_name → None(__init__.py:560-561);unimplemented → RED。
149
228
  #[test]
150
229
  fn detect_dual_state_divergence_none_without_session_name() {
@@ -16,14 +16,15 @@ pub fn save_team_runtime_snapshot(
16
16
  .get("session_name")
17
17
  .and_then(|v| v.as_str())
18
18
  .ok_or_else(|| LifecycleError::StatePersist("session_name is required".to_string()))?;
19
- let safe = safe_snapshot_name(session_name);
20
19
  // golden restart/snapshot.py:46-47 — team_runtime_snapshot_dir = runtime_dir(workspace)/teams/<safe>,
21
20
  // and paths.py:25-26 runtime_dir = workspace/.team/runtime. Use the crate path helper so the snapshot
22
21
  // lands at <ws>/.team/runtime/teams/<safe>, matching golden AND the rest of the crate (not the
23
22
  // ".team"-less <ws>/runtime/teams that the original port wrote).
24
- let dir = crate::model::paths::runtime_dir(workspace).join("teams").join(safe);
23
+ let path = team_snapshot_path(workspace, session_name);
24
+ let dir = path
25
+ .parent()
26
+ .ok_or_else(|| LifecycleError::StatePersist("snapshot path has no parent".to_string()))?;
25
27
  fs::create_dir_all(&dir).map_err(|e| persist_err("create snapshot dir", &e))?;
26
- let path = dir.join("state.json");
27
28
  let tmp = dir.join("state.json.tmp");
28
29
  let data = serde_json::to_vec_pretty(state)
29
30
  .map_err(|e| LifecycleError::StatePersist(format!("serialize snapshot: {e}")))?;
@@ -32,6 +33,13 @@ pub fn save_team_runtime_snapshot(
32
33
  Ok(path)
33
34
  }
34
35
 
36
+ pub fn team_snapshot_path(workspace: &Path, session_name: &str) -> PathBuf {
37
+ crate::model::paths::runtime_dir(workspace)
38
+ .join("teams")
39
+ .join(safe_snapshot_name(session_name))
40
+ .join("state.json")
41
+ }
42
+
35
43
  fn persist_err(action: &str, err: &io::Error) -> LifecycleError {
36
44
  LifecycleError::StatePersist(format!("{action}: {err}"))
37
45
  }
@@ -213,8 +213,7 @@ fn spawn_agents(
213
213
  safety,
214
214
  )?;
215
215
  let resolved_tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
216
- let mcp_team_id =
217
- runtime_active_team_key_for_spawn(workspace, spec_path, spec, session_name);
216
+ let mcp_team_id = runtime_team_key_for_spec(spec_path, spec, session_name);
218
217
  let mcp_config = adapter
219
218
  .mcp_config(auth_mode)
220
219
  .map_err(|e| LifecycleError::Provider(e.to_string()))?;
@@ -724,8 +723,7 @@ fn persist_spawn_agent_state(
724
723
  } else {
725
724
  serde_json::json!({"agents": {}})
726
725
  };
727
- let team_id = explicit_active_team_key(&state)
728
- .unwrap_or_else(|| runtime_team_key_for_spec(spec_path, spec, session_name));
726
+ let team_id = runtime_team_key_for_spec(spec_path, spec, session_name);
729
727
  let worker_tmux_socket = launched_worker_tmux_socket(transport, workspace);
730
728
  drop_worker_pane_seeded_owner(&mut state, &team_id, started, worker_tmux_socket.as_deref());
731
729
  // Only persist running state for agents whose spawn still has a live target.
@@ -2330,18 +2328,6 @@ fn spec_team_id(spec: &Value) -> Option<String> {
2330
2328
  .or_else(|| spec.get("name").and_then(Value::as_str).map(str::to_string))
2331
2329
  }
2332
2330
 
2333
- fn runtime_active_team_key_for_spawn(
2334
- workspace: &Path,
2335
- spec_path: &Path,
2336
- spec: &Value,
2337
- session_name: &SessionName,
2338
- ) -> String {
2339
- load_runtime_state(workspace)
2340
- .ok()
2341
- .and_then(|state| explicit_active_team_key(&state))
2342
- .unwrap_or_else(|| runtime_team_key_for_spec(spec_path, spec, session_name))
2343
- }
2344
-
2345
2331
  fn explicit_active_team_key(state: &serde_json::Value) -> Option<String> {
2346
2332
  state
2347
2333
  .get("active_team_key")
@@ -403,6 +403,7 @@ pub(super) fn resume_backing_exists_for_agent(
403
403
  Provider::Claude | Provider::ClaudeCode => {
404
404
  rollout_path_exists(rollout_path)
405
405
  || event_log_transcript_exists(workspace, agent_id.as_str(), session_id.as_str())
406
+ || claude_project_transcript_exists(agent, session_id.as_str())
406
407
  }
407
408
  Provider::Copilot => copilot_session_store_has_session(session_id.as_str()),
408
409
  Provider::GeminiCli | Provider::Fake => false,
@@ -448,6 +449,42 @@ fn event_transcript_path(event: &serde_json::Value) -> Option<PathBuf> {
448
449
  .map(PathBuf::from)
449
450
  }
450
451
 
452
+ /// E36 fix-B: a real Claude worker writes its session transcript to
453
+ /// `<claude_projects_root>/<workspace-slug>/<session_id>.jsonl` even when neither
454
+ /// `rollout_path` was persisted to state nor a `session.captured` event was logged.
455
+ /// That landed transcript is itself a valid resume backing — restart was wrongly
456
+ /// refusing resumable workers because it only checked the two paths above. Scan the
457
+ /// projects root recursively for `<session_id>.jsonl` (session_id is a unique UUID,
458
+ /// so we avoid recomputing the project-dir slug, which is brittle for non-ASCII
459
+ /// workspace paths).
460
+ fn claude_project_transcript_exists(agent: &serde_json::Value, session_id: &str) -> bool {
461
+ if session_id.is_empty() {
462
+ return false;
463
+ }
464
+ let projects_root = agent
465
+ .get("claude_projects_root")
466
+ .and_then(serde_json::Value::as_str)
467
+ .filter(|value| !value.is_empty())
468
+ .map(PathBuf::from)
469
+ .or_else(|| {
470
+ std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude").join("projects"))
471
+ });
472
+ let Some(projects_root) = projects_root else {
473
+ return false;
474
+ };
475
+ if !projects_root.is_dir() {
476
+ return false;
477
+ }
478
+ let transcript_name = format!("{session_id}.jsonl");
479
+ let Ok(project_dirs) = std::fs::read_dir(&projects_root) else {
480
+ return false;
481
+ };
482
+ project_dirs
483
+ .flatten()
484
+ .filter(|entry| entry.path().is_dir())
485
+ .any(|entry| entry.path().join(&transcript_name).is_file())
486
+ }
487
+
451
488
  fn copilot_session_store_has_session(session_id: &str) -> bool {
452
489
  let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
453
490
  return false;
@@ -908,3 +945,69 @@ pub(super) fn is_dynamic_agent(
908
945
  .and_then(YamlValue::as_str)
909
946
  .is_some_and(|s| !s.is_empty())
910
947
  }
948
+
949
+ #[cfg(test)]
950
+ mod e36_transcript_backing_tests {
951
+ use super::*;
952
+
953
+ struct ScratchDir(PathBuf);
954
+ impl ScratchDir {
955
+ fn new(tag: &str) -> Self {
956
+ let pid = std::process::id();
957
+ let base = std::env::temp_dir().join(format!("ta-e36-{tag}-{pid}"));
958
+ std::fs::create_dir_all(&base).expect("scratch dir");
959
+ ScratchDir(base)
960
+ }
961
+ fn path(&self) -> &Path {
962
+ &self.0
963
+ }
964
+ }
965
+ impl Drop for ScratchDir {
966
+ fn drop(&mut self) {
967
+ let _ = std::fs::remove_dir_all(&self.0);
968
+ }
969
+ }
970
+
971
+ // E36 fix-B RED→GREEN: a real Claude worker that sent a message has its session
972
+ // transcript landed at <projects_root>/<slug>/<session_id>.jsonl, but neither
973
+ // rollout_path was persisted to state nor a session.captured event was logged.
974
+ // Before the fix, claude_project_transcript_exists did not exist and restart
975
+ // refused such a worker. This asserts the landed transcript is recognized.
976
+ #[test]
977
+ fn claude_project_transcript_is_recognized_without_rollout_or_capture_event() {
978
+ let scratch = ScratchDir::new("recognized");
979
+ let projects_root = scratch.path().join("projects");
980
+ let slug_dir = projects_root.join("-Users-alauda-Documents-code---rust---9");
981
+ std::fs::create_dir_all(&slug_dir).expect("mkdir slug");
982
+ let session_id = "87742d3f-0b4e-4fc1-ad35-447ac2340b65";
983
+ std::fs::write(slug_dir.join(format!("{session_id}.jsonl")), b"{}\n").expect("transcript");
984
+
985
+ let agent = serde_json::json!({
986
+ "claude_projects_root": projects_root.to_string_lossy(),
987
+ });
988
+ assert!(
989
+ claude_project_transcript_exists(&agent, session_id),
990
+ "landed claude transcript must count as resume backing (E36 fix-B)"
991
+ );
992
+ }
993
+
994
+ #[test]
995
+ fn missing_claude_transcript_is_not_backing() {
996
+ let scratch = ScratchDir::new("missing");
997
+ let projects_root = scratch.path().join("projects");
998
+ std::fs::create_dir_all(&projects_root).expect("mkdir");
999
+ let agent = serde_json::json!({
1000
+ "claude_projects_root": projects_root.to_string_lossy(),
1001
+ });
1002
+ assert!(
1003
+ !claude_project_transcript_exists(&agent, "deadbeef-0000-0000-0000-000000000000"),
1004
+ "no transcript file => no backing"
1005
+ );
1006
+ }
1007
+
1008
+ #[test]
1009
+ fn empty_session_id_is_not_backing() {
1010
+ let agent = serde_json::json!({});
1011
+ assert!(!claude_project_transcript_exists(&agent, ""));
1012
+ }
1013
+ }
@@ -203,8 +203,42 @@ pub fn deliver_pending_message(
203
203
  let delivery_transport =
204
204
  delivery_transport_for_recipient(workspace, transport, state, &message.recipient);
205
205
  let transport = delivery_transport.as_transport();
206
+ // U1-B: probe `list_targets()` ONCE per delivery and DEFER on Err. The prior
207
+ // `.unwrap_or_default()` coerced server jitter (Err = subprocess fork failed)
208
+ // to an empty vec, and the downstream cache-reuse branch
209
+ // (`live_targets.is_empty() && !known_dead`) would then inject into a
210
+ // never-validated cached pane on every tmux hiccup. Err is OBSERVED here as
211
+ // jitter and the delivery is deferred (status stays `target_resolved`, no
212
+ // inject attempted) so the next tick gets a fresh probe.
213
+ let live_targets = match transport.list_targets() {
214
+ Ok(targets) => targets,
215
+ Err(err) => {
216
+ let reason = format!("list_targets_server_jitter:{err}");
217
+ event_log.write(
218
+ "delivery.deferred_list_targets_jitter",
219
+ serde_json::json!({
220
+ "message_id": message_id,
221
+ "recipient": message.recipient,
222
+ "reason": reason,
223
+ }),
224
+ )?;
225
+ store.mark(message_id, "target_resolved", Some(&reason))?;
226
+ return Ok(DeliveryOutcome {
227
+ ok: false,
228
+ status: DeliveryStatus::Degraded,
229
+ message_status: MessageStatusShadow("target_resolved".to_string()),
230
+ message_id: Some(message_id.to_string()),
231
+ verification: Some(reason),
232
+ stage: None,
233
+ reason: None,
234
+ channel: None,
235
+ });
236
+ }
237
+ };
206
238
  // Do not inject queued leader messages into a synthetic "leader" window.
207
- if message.recipient == "leader" && !leader_receiver_pane_is_usable(transport, state) {
239
+ if message.recipient == "leader"
240
+ && !leader_receiver_pane_is_usable(transport, state, &live_targets)
241
+ {
208
242
  store.mark(message_id, "failed", Some("leader_not_attached"))?;
209
243
  event_log.write(
210
244
  "leader_receiver.delivery_blocked",
@@ -227,7 +261,7 @@ pub fn deliver_pending_message(
227
261
  channel: Some("rebind_required".to_string()),
228
262
  });
229
263
  }
230
- let target = resolve_inject_target(state, &message.recipient, transport);
264
+ let target = resolve_inject_target(state, &message.recipient, transport, &live_targets);
231
265
  // Contract B / MUST-10 / N31/N32: physical paste+Enter into a startup trust/update
232
266
  // menu is NOT provider delivery — the menu consumes the Enter and the task text
233
267
  // is lost (PROBE-2 root-cause). Before injection, peek at the recipient's pane for
@@ -473,12 +507,24 @@ fn render_message(sender: &str, task_id: Option<&str>, content: &str, message_id
473
507
  ///
474
508
  /// Leader delivery uses the bound leader receiver pane. The leader is not a worker agent and
475
509
  /// must not fall through to a synthetic `SessionWindow{window="leader"}` target.
510
+ ///
511
+ /// `live_targets` is passed in BY THE CALLER (probed ONCE per delivery — see U1-B); this fn is
512
+ /// pure w.r.t. transport state. The caller must DEFER on `list_targets()` Err and never coerce
513
+ /// to an empty vec.
514
+ ///
515
+ /// **0.3.24 excision (U1-A real-machine RED v2, macmini fixture res_e4b40473d36f)**:
516
+ /// the wave-2 LEADER drift fallback chain (session+window probe) was removed — see
517
+ /// `.team/artifacts/u1-a-realmachine-v2-fix-or-excise.md` for the root-cause analysis.
518
+ /// Drift now fails loudly as `leader_not_attached` rather than silently selecting a stale
519
+ /// pane. U1-A real-machine fix is deferred to v0.3.25 (writer-shape + projection +
520
+ /// rediscover-writer triad). U1-B jitter defer at try_deliver_message:213-237 and
521
+ /// U1-C-Tail at the startup-prompt peek site are unchanged.
476
522
  fn resolve_inject_target(
477
523
  state: &serde_json::Value,
478
524
  recipient: &str,
479
525
  transport: &dyn Transport,
526
+ live_targets: &[PaneInfo],
480
527
  ) -> Target {
481
- let live_targets = transport.list_targets().unwrap_or_default();
482
528
  if recipient == "leader" {
483
529
  if let Some(pane_id) = leader_receiver_pane_id(state) {
484
530
  return Target::Pane(PaneId::new(pane_id));
@@ -507,7 +553,7 @@ fn resolve_inject_target(
507
553
  return Target::Pane(pane.clone());
508
554
  }
509
555
  }
510
- if let Some(live_pane) = live_pane_for_session_window(&live_targets, session, window) {
556
+ if let Some(live_pane) = live_pane_for_session_window(live_targets, session, window) {
511
557
  return Target::Pane(live_pane);
512
558
  }
513
559
  if let Some(pane) = cached_pane {
@@ -552,16 +598,21 @@ fn leader_receiver_pane_id(state: &serde_json::Value) -> Option<&str> {
552
598
  .or_else(|| only_team_entry(state).and_then(leader_receiver_pane_id_in_state))
553
599
  }
554
600
 
555
- fn leader_receiver_pane_is_usable(transport: &dyn Transport, state: &serde_json::Value) -> bool {
601
+ /// `state` is "usable" for leader delivery when the bound `leader_receiver.pane_id`
602
+ /// is present and alive (in `live_targets` or `liveness` probe returns not-Dead).
603
+ ///
604
+ /// **0.3.24 excision (U1-A real-machine RED v2)**: the wave-2 session+window drift
605
+ /// fallback was removed. Drift now fails loudly as `leader_not_attached` —
606
+ /// see resolve_inject_target and `.team/artifacts/u1-a-realmachine-v2-fix-or-excise.md`.
607
+ fn leader_receiver_pane_is_usable(
608
+ transport: &dyn Transport,
609
+ state: &serde_json::Value,
610
+ live_targets: &[PaneInfo],
611
+ ) -> bool {
556
612
  let Some(pane_id) = leader_receiver_pane_id(state) else {
557
613
  return false;
558
614
  };
559
- if transport
560
- .list_targets()
561
- .unwrap_or_default()
562
- .iter()
563
- .any(|target| target.pane_id.as_str() == pane_id)
564
- {
615
+ if live_targets.iter().any(|target| target.pane_id.as_str() == pane_id) {
565
616
  return true;
566
617
  }
567
618
  !matches!(
@@ -956,8 +1007,16 @@ fn recipient_pane_has_actionable_startup_prompt(
956
1007
  if matches!(startup_prompts, Some("handled" | "complete")) {
957
1008
  return false;
958
1009
  }
1010
+ // U1-C wave-2: Tail(80) instead of Full. The peek is the DELIVERY-SITE pre-check
1011
+ // (NOT the startup-prompts dismissal phase, which legitimately needs the full
1012
+ // scrollback to anchor recency). Limiting to the visible screen avoids matching
1013
+ // residual already-answered trust modals that scrolled off — while a live trust
1014
+ // modal would still appear in the last 80 lines because Codex pins it to the
1015
+ // visible region until dismissed. Pair this with the U1-C recency guard in
1016
+ // `has_actionable_trust_shape`: either fix alone covers the symptom; both
1017
+ // together defend against both scrollback-residue AND pre-render pathologies.
959
1018
  let captured = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
960
- transport.capture(target, crate::transport::CaptureRange::Full)
1019
+ transport.capture(target, crate::transport::CaptureRange::Tail(80))
961
1020
  })) {
962
1021
  Ok(Ok(captured)) => captured.text,
963
1022
  _ => return false,
@@ -1351,27 +1410,15 @@ pub(crate) fn normalize_owner_team_id_rows(
1351
1410
  if requested == canonical {
1352
1411
  return Ok(());
1353
1412
  }
1354
- let store = MessageStore::open(workspace)?;
1355
- let conn = crate::db::schema::open_db(store.db_path())?;
1356
- for table in [
1357
- "messages",
1358
- "results",
1359
- "scheduled_events",
1360
- "agent_health",
1361
- "result_watchers",
1362
- "leader_notification_log",
1363
- ] {
1364
- let sql =
1365
- format!("update or ignore {table} set owner_team_id = ?1 where owner_team_id = ?2");
1366
- conn.execute(&sql, params![canonical, requested])?;
1367
- }
1368
1413
  if let Some(event_log) = event_log {
1369
1414
  event_log.write(
1370
- "owner_team_id.compatibility_alias_migrated",
1415
+ "owner_team_id.compatibility_alias_detected",
1371
1416
  serde_json::json!({
1372
1417
  "requested_owner_team_id": requested,
1373
1418
  "canonical_owner_team_id": canonical,
1374
1419
  "message_id": message_id,
1420
+ "action": "read_only_no_db_update",
1421
+ "workspace": workspace.to_string_lossy().to_string(),
1375
1422
  }),
1376
1423
  )?;
1377
1424
  }
@@ -295,3 +295,4 @@ mod e23;
295
295
  mod main_preserved;
296
296
  mod runtime;
297
297
  mod spine;
298
+ mod wave2;