@team-agent/installer 0.5.15 → 0.5.17

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.15"
578
+ version = "0.5.17"
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.15"
12
+ version = "0.5.17"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -4060,79 +4060,15 @@ pub mod leader_port {
4060
4060
  source: &str,
4061
4061
  response: &mut Value,
4062
4062
  ) {
4063
- let Ok(state) = crate::state::persist::load_runtime_state(workspace) else {
4064
- return;
4065
- };
4066
- let team_key = match team.filter(|t| !t.is_empty()) {
4067
- Some(t) => t.to_string(),
4068
- None => crate::state::projection::team_state_key(&state),
4069
- };
4070
- let receiver = state
4071
- .get("teams")
4072
- .and_then(|v| v.as_object())
4073
- .and_then(|teams| teams.get(&team_key))
4074
- .and_then(|t| t.get("leader_receiver"))
4075
- .or_else(|| state.get("leader_receiver"))
4076
- .cloned();
4077
- let Some(receiver) = receiver else {
4078
- return;
4079
- };
4080
- let transport_kind = receiver
4081
- .get("transport_kind")
4082
- .and_then(Value::as_str)
4083
- .unwrap_or("direct_tmux")
4084
- .to_string();
4085
- let owner_epoch = receiver
4086
- .get("owner_epoch")
4087
- .and_then(Value::as_u64)
4088
- .or_else(|| {
4089
- state
4090
- .get("teams")
4091
- .and_then(|v| v.as_object())
4092
- .and_then(|teams| teams.get(&team_key))
4093
- .and_then(|t| t.get("owner_epoch"))
4094
- .and_then(Value::as_u64)
4095
- })
4096
- .unwrap_or(0);
4097
- let entry = crate::leader::registry::build_entry(
4063
+ let Some(outcome) = crate::leader::registry::register_binding_from_state_best_effort(
4098
4064
  workspace,
4099
- &team_key,
4100
- &transport_kind,
4101
- receiver,
4102
- owner_epoch,
4065
+ team,
4103
4066
  source,
4104
- chrono::Utc::now().to_rfc3339(),
4105
- );
4106
- let event_log = crate::event_log::EventLog::new(workspace);
4107
- let write_result = crate::leader::registry::write_entry_best_effort(&entry);
4108
- let registry_status = match &write_result {
4109
- Some(path) => {
4110
- let _ = event_log.write(
4111
- crate::leader::registry::EVENT_REGISTERED,
4112
- json!({
4113
- "path": path.display().to_string(),
4114
- "team_key": team_key,
4115
- "workspace_hash": entry.workspace_hash,
4116
- "source": source,
4117
- "owner_epoch": entry.owner_epoch,
4118
- }),
4119
- );
4120
- json!({"status": "registered", "path": path.display().to_string()})
4121
- }
4122
- None => {
4123
- let _ = event_log.write(
4124
- crate::leader::registry::EVENT_WRITE_FAILED,
4125
- json!({
4126
- "team_key": team_key,
4127
- "workspace_hash": entry.workspace_hash,
4128
- "source": source,
4129
- }),
4130
- );
4131
- json!({"status": "write_failed"})
4132
- }
4067
+ ) else {
4068
+ return;
4133
4069
  };
4134
4070
  if let Some(obj) = response.as_object_mut() {
4135
- obj.insert("leader_registry".to_string(), registry_status);
4071
+ obj.insert("leader_registry".to_string(), outcome.response_json());
4136
4072
  }
4137
4073
  }
4138
4074
 
@@ -4167,6 +4103,8 @@ pub mod leader_port {
4167
4103
  "source": source,
4168
4104
  "reason": convergence.get("reason").and_then(Value::as_str).unwrap_or("old_endpoint_dead"),
4169
4105
  "owner_epoch": convergence.get("owner_epoch").cloned().unwrap_or(Value::Null),
4106
+ "persisted": convergence.get("persisted").cloned().unwrap_or(Value::Bool(false)),
4107
+ "checked_paths": convergence.get("checked_paths").cloned().unwrap_or_else(|| json!([])),
4170
4108
  }),
4171
4109
  );
4172
4110
  }
@@ -634,10 +634,28 @@ fn claim_lease_no_incident_with_target(
634
634
  let bound_endpoint_matches_caller = bound_endpoint_matches_current_process(state);
635
635
  if bound_pane_id.as_deref() == Some(caller_pane.as_str()) && bound_endpoint_matches_caller {
636
636
  let current_endpoint = crate::tmux_backend::socket_name_from_tmux_env();
637
- let (topology_convergence, converged) =
637
+ let (mut topology_convergence, converged) =
638
638
  apply_endpoint_convergence(state, team_id, current_endpoint.as_deref(), pre_epoch);
639
639
  if converged {
640
640
  write_claim_state(workspace, state, scoped_team, team)?;
641
+ topology_convergence = verify_persisted_topology_convergence(
642
+ workspace,
643
+ team_id.as_str(),
644
+ topology_convergence,
645
+ pre_epoch,
646
+ )?;
647
+ if topology_convergence
648
+ .as_ref()
649
+ .and_then(|metadata| metadata.get("status"))
650
+ .and_then(Value::as_str)
651
+ != Some("converged")
652
+ {
653
+ return Ok(convergence_persistence_refusal(
654
+ topology_convergence,
655
+ pre_epoch,
656
+ Some(caller_pane.clone()),
657
+ ));
658
+ }
641
659
  }
642
660
  return Ok(LeaseResult {
643
661
  ok: true,
@@ -788,9 +806,29 @@ fn claim_lease_no_incident_with_target(
788
806
  );
789
807
  let owner = make_owner(provider, &non_empty_caller_pane, &identity, next_epoch);
790
808
  write_binding_to_state(state, &receiver, &owner)?;
791
- let (topology_convergence, _) =
809
+ let (mut topology_convergence, converged) =
792
810
  apply_endpoint_convergence(state, team_id, receiver.tmux_socket.as_deref(), next_epoch);
793
811
  write_claim_state(workspace, state, scoped_team, team)?;
812
+ if converged {
813
+ topology_convergence = verify_persisted_topology_convergence(
814
+ workspace,
815
+ team_id.as_str(),
816
+ topology_convergence,
817
+ next_epoch,
818
+ )?;
819
+ if topology_convergence
820
+ .as_ref()
821
+ .and_then(|metadata| metadata.get("status"))
822
+ .and_then(Value::as_str)
823
+ != Some("converged")
824
+ {
825
+ return Ok(convergence_persistence_refusal(
826
+ topology_convergence,
827
+ next_epoch,
828
+ Some(caller_pane.clone()),
829
+ ));
830
+ }
831
+ }
794
832
  let uuid_prefix = identity.leader_session_uuid.as_str().chars().take(8).collect::<String>();
795
833
  if reason == LeaseReason::PreviousOwnerPaneDead {
796
834
  event_log.write(
@@ -962,6 +1000,124 @@ fn write_convergence_marker(state: &mut Value, team_id: &str, metadata: &Value)
962
1000
  }
963
1001
  }
964
1002
 
1003
+ fn verify_persisted_topology_convergence(
1004
+ workspace: &Path,
1005
+ team_id: &str,
1006
+ metadata: Option<Value>,
1007
+ owner_epoch: OwnerEpoch,
1008
+ ) -> Result<Option<Value>, LeaderError> {
1009
+ let Some(mut metadata) = metadata else {
1010
+ return Ok(None);
1011
+ };
1012
+ if metadata.get("status").and_then(Value::as_str) != Some("converged") {
1013
+ return Ok(Some(metadata));
1014
+ }
1015
+ let Some(new_endpoint) = metadata
1016
+ .get("new_tmux_endpoint")
1017
+ .and_then(Value::as_str)
1018
+ .map(str::to_string)
1019
+ else {
1020
+ mark_convergence_persistence_conflict(&mut metadata, team_id);
1021
+ return Ok(Some(metadata));
1022
+ };
1023
+ let state = crate::state::persist::load_runtime_state(workspace)?;
1024
+ if endpoint_convergence_persisted(&state, team_id, &new_endpoint, owner_epoch.0) {
1025
+ mark_convergence_persisted(&mut metadata, team_id);
1026
+ } else {
1027
+ mark_convergence_persistence_conflict(&mut metadata, team_id);
1028
+ }
1029
+ Ok(Some(metadata))
1030
+ }
1031
+
1032
+ fn endpoint_convergence_persisted(
1033
+ state: &Value,
1034
+ team_id: &str,
1035
+ endpoint: &str,
1036
+ owner_epoch: u64,
1037
+ ) -> bool {
1038
+ if !endpoint_convergence_node_matches(state, endpoint, owner_epoch) {
1039
+ return false;
1040
+ }
1041
+ state
1042
+ .get("teams")
1043
+ .and_then(Value::as_object)
1044
+ .and_then(|teams| teams.get(team_id))
1045
+ .is_some_and(|team| endpoint_convergence_node_matches(team, endpoint, owner_epoch))
1046
+ }
1047
+
1048
+ fn endpoint_convergence_node_matches(node: &Value, endpoint: &str, owner_epoch: u64) -> bool {
1049
+ node.get("tmux_endpoint").and_then(Value::as_str) == Some(endpoint)
1050
+ && node.get("tmux_socket").and_then(Value::as_str) == Some(endpoint)
1051
+ && node.get("tmux_socket_source").and_then(Value::as_str) == Some("leader_env")
1052
+ && node
1053
+ .get("topology_convergence")
1054
+ .and_then(|marker| marker.get("status"))
1055
+ .and_then(Value::as_str)
1056
+ == Some("converged")
1057
+ && node
1058
+ .get("topology_convergence")
1059
+ .and_then(|marker| marker.get("owner_epoch"))
1060
+ .and_then(Value::as_u64)
1061
+ == Some(owner_epoch)
1062
+ }
1063
+
1064
+ fn mark_convergence_persisted(metadata: &mut Value, team_id: &str) {
1065
+ let Some(obj) = metadata.as_object_mut() else {
1066
+ return;
1067
+ };
1068
+ obj.insert("persisted".to_string(), json!(true));
1069
+ obj.insert("checked_paths".to_string(), json!(convergence_checked_paths(team_id)));
1070
+ }
1071
+
1072
+ fn mark_convergence_persistence_conflict(metadata: &mut Value, team_id: &str) {
1073
+ let Some(obj) = metadata.as_object_mut() else {
1074
+ return;
1075
+ };
1076
+ obj.insert("status".to_string(), json!("persistence_conflict"));
1077
+ obj.insert("persisted".to_string(), json!(false));
1078
+ obj.insert("checked_paths".to_string(), json!(convergence_checked_paths(team_id)));
1079
+ obj.insert(
1080
+ "action".to_string(),
1081
+ json!("endpoint convergence was not durably persisted; retry claim-leader or takeover"),
1082
+ );
1083
+ }
1084
+
1085
+ fn convergence_checked_paths(team_id: &str) -> Vec<String> {
1086
+ [
1087
+ "/tmux_endpoint".to_string(),
1088
+ "/tmux_socket".to_string(),
1089
+ "/tmux_socket_source".to_string(),
1090
+ "/topology_convergence".to_string(),
1091
+ format!("/teams/{team_id}/tmux_endpoint"),
1092
+ format!("/teams/{team_id}/tmux_socket"),
1093
+ format!("/teams/{team_id}/tmux_socket_source"),
1094
+ format!("/teams/{team_id}/topology_convergence"),
1095
+ ]
1096
+ .into_iter()
1097
+ .collect()
1098
+ }
1099
+
1100
+ fn convergence_persistence_refusal(
1101
+ topology_convergence: Option<Value>,
1102
+ owner_epoch: OwnerEpoch,
1103
+ bound_pane_id: Option<PaneId>,
1104
+ ) -> LeaseResult {
1105
+ LeaseResult {
1106
+ ok: false,
1107
+ status: LeaseStatus::Refused,
1108
+ receiver: None,
1109
+ owner: None,
1110
+ owner_epoch: Some(owner_epoch),
1111
+ reason: Some(LeaseReason::OwnerEpochAdvanced),
1112
+ action: Some(
1113
+ "endpoint convergence was not durably persisted; retry claim-leader or takeover"
1114
+ .to_string(),
1115
+ ),
1116
+ bound_pane_id,
1117
+ topology_convergence,
1118
+ }
1119
+ }
1120
+
965
1121
  fn refused(
966
1122
  reason: LeaseReason,
967
1123
  action: &str,
@@ -1531,7 +1687,7 @@ fn save_claim_team_scoped_state(workspace: &Path, state: &Value, target_key: &st
1531
1687
  // preserving helper so the canonical fields survive the compaction.
1532
1688
  teams.insert(
1533
1689
  target_key.to_string(),
1534
- compact_team_state_preserving_ownership(state, target_key),
1690
+ compact_team_state_preserving_claim_fields(state, target_key),
1535
1691
  );
1536
1692
  let existing_primary_key = existing
1537
1693
  .get("session_name")
@@ -1584,7 +1740,7 @@ fn value_object(value: &Value) -> serde_json::Map<String, Value> {
1584
1740
  /// and copy each present field into the compacted entry.
1585
1741
  /// - Top-level owner fields stay UNTOUCHED — Stage 3d's canonical-only
1586
1742
  /// shape is preserved on disk.
1587
- fn compact_team_state_preserving_ownership(state: &Value, target_key: &str) -> Value {
1743
+ fn compact_team_state_preserving_claim_fields(state: &Value, target_key: &str) -> Value {
1588
1744
  let mut entry = crate::state::projection::compact_team_state(state);
1589
1745
  let Some(entry_obj) = entry.as_object_mut() else {
1590
1746
  return entry;
@@ -1597,7 +1753,15 @@ fn compact_team_state_preserving_ownership(state: &Value, target_key: &str) -> V
1597
1753
  else {
1598
1754
  return entry;
1599
1755
  };
1600
- for key in ["team_owner", "leader_receiver", "owner_epoch"] {
1756
+ for key in [
1757
+ "team_owner",
1758
+ "leader_receiver",
1759
+ "owner_epoch",
1760
+ "tmux_endpoint",
1761
+ "tmux_socket",
1762
+ "tmux_socket_source",
1763
+ "topology_convergence",
1764
+ ] {
1601
1765
  if let Some(value) = owner_obj.get(key) {
1602
1766
  entry_obj.insert(key.to_string(), value.clone());
1603
1767
  }
@@ -22,6 +22,7 @@
22
22
  use std::path::{Path, PathBuf};
23
23
 
24
24
  use serde::{Deserialize, Serialize};
25
+ use serde_json::{json, Value};
25
26
  use sha2::{Digest, Sha256};
26
27
 
27
28
  use crate::transport::Transport;
@@ -98,6 +99,26 @@ pub const AMBIGUOUS_NAMES_FIELD: &str = "ambiguous_names";
98
99
  /// a fully-qualified form.
99
100
  pub const REASON_AMBIGUOUS: &str = "name_ambiguous";
100
101
 
102
+ #[derive(Debug, Clone, PartialEq, Eq)]
103
+ pub struct RegistryWriteOutcome {
104
+ pub status: &'static str,
105
+ pub path: Option<PathBuf>,
106
+ pub team_key: String,
107
+ pub workspace_hash: String,
108
+ pub source: String,
109
+ pub owner_epoch: u64,
110
+ }
111
+
112
+ impl RegistryWriteOutcome {
113
+ #[must_use]
114
+ pub fn response_json(&self) -> Value {
115
+ match &self.path {
116
+ Some(path) => json!({"status": self.status, "path": path.display().to_string()}),
117
+ None => json!({"status": self.status}),
118
+ }
119
+ }
120
+ }
121
+
101
122
  /// Absolute path to `~/.team-agent/leaders`. Returns `None` when the
102
123
  /// caller has no HOME (e.g. some CI shells) — callers should treat this as
103
124
  /// "no registry" rather than an error, since registry is derived.
@@ -170,6 +191,89 @@ pub fn build_entry(
170
191
  }
171
192
  }
172
193
 
194
+ /// After a canonical leader binding succeeds, write the derived host
195
+ /// discovery entry from the just-persisted state. Registry failure is
196
+ /// non-fatal: callers get a write_failed outcome and the binding remains
197
+ /// successful.
198
+ pub fn register_binding_from_state_best_effort(
199
+ workspace: &Path,
200
+ team: Option<&str>,
201
+ source: &str,
202
+ ) -> Option<RegistryWriteOutcome> {
203
+ let Ok(state) = crate::state::persist::load_runtime_state(workspace) else {
204
+ return None;
205
+ };
206
+ let team_key = match team.filter(|team| !team.is_empty()) {
207
+ Some(team) => team.to_string(),
208
+ None => crate::state::projection::team_state_key(&state),
209
+ };
210
+ let receiver = state
211
+ .get("teams")
212
+ .and_then(|value| value.as_object())
213
+ .and_then(|teams| teams.get(&team_key))
214
+ .and_then(|team| team.get("leader_receiver"))
215
+ .or_else(|| state.get("leader_receiver"))
216
+ .cloned()?;
217
+ let transport_kind = receiver
218
+ .get("transport_kind")
219
+ .and_then(Value::as_str)
220
+ .unwrap_or("direct_tmux")
221
+ .to_string();
222
+ let owner_epoch = receiver
223
+ .get("owner_epoch")
224
+ .and_then(Value::as_u64)
225
+ .or_else(|| {
226
+ state
227
+ .get("teams")
228
+ .and_then(|value| value.as_object())
229
+ .and_then(|teams| teams.get(&team_key))
230
+ .and_then(|team| team.get("owner_epoch"))
231
+ .and_then(Value::as_u64)
232
+ })
233
+ .unwrap_or(0);
234
+ let entry = build_entry(
235
+ workspace,
236
+ &team_key,
237
+ &transport_kind,
238
+ receiver,
239
+ owner_epoch,
240
+ source,
241
+ chrono::Utc::now().to_rfc3339(),
242
+ );
243
+ let event_log = crate::event_log::EventLog::new(workspace);
244
+ let write_result = write_entry_best_effort(&entry);
245
+ let status = if let Some(path) = &write_result {
246
+ let _ = event_log.write(
247
+ EVENT_REGISTERED,
248
+ json!({
249
+ "path": path.display().to_string(),
250
+ "team_key": team_key,
251
+ "workspace_hash": entry.workspace_hash,
252
+ "source": source,
253
+ }),
254
+ );
255
+ "registered"
256
+ } else {
257
+ let _ = event_log.write(
258
+ EVENT_WRITE_FAILED,
259
+ json!({
260
+ "team_key": team_key,
261
+ "workspace_hash": entry.workspace_hash,
262
+ "source": source,
263
+ }),
264
+ );
265
+ "write_failed"
266
+ };
267
+ Some(RegistryWriteOutcome {
268
+ status,
269
+ path: write_result,
270
+ team_key,
271
+ workspace_hash: entry.workspace_hash,
272
+ source: source.to_string(),
273
+ owner_epoch: entry.owner_epoch,
274
+ })
275
+ }
276
+
173
277
  fn entry_filename(entry: &LeaderRegistryEntry) -> String {
174
278
  format!("{}__{}.json", entry.workspace_hash, entry.team_key)
175
279
  }
@@ -1404,6 +1404,11 @@ fn try_autobind_leader_after_restart(
1404
1404
  let team_str = team;
1405
1405
  match crate::leader::attach_leader(workspace, team_str, None, provider) {
1406
1406
  Ok(result) if result.ok => {
1407
+ let _ = crate::leader::registry::register_binding_from_state_best_effort(
1408
+ workspace,
1409
+ team_str,
1410
+ "restart-auto-attach",
1411
+ );
1407
1412
  eprintln!(
1408
1413
  "team_agent::restart auto_attach_leader ok pane={:?} team={:?}",
1409
1414
  result.bound_pane_id.as_ref().map(|p| p.as_str()),
@@ -3,6 +3,9 @@ use crate::transport::test_support::OfflineTransport;
3
3
  use serde_json::json;
4
4
  use serial_test::serial;
5
5
 
6
+ #[allow(dead_code)]
7
+ struct HermeticTestEnv;
8
+
6
9
  const QS_TEAM_MD: &str =
7
10
  "---\nname: quickteam\nobjective: Quick start.\nprovider: codex\n---\n\nQuick-start team.\n";
8
11
  pub(super) const QS_VALID_ROLE: &str = "---\nname: implementer\nrole: Implementation Engineer\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nImplement bounded tasks.\n";
@@ -19,6 +19,9 @@ use serde_json::json;
19
19
  use std::collections::BTreeSet;
20
20
  use std::time::Duration;
21
21
 
22
+ #[allow(dead_code)]
23
+ struct HermeticTestEnv;
24
+
22
25
  #[test]
23
26
  fn concurrent_reset_discard_session_serializes() {
24
27
  let ids = (1..=6).map(|n| format!("w{n}")).collect::<Vec<_>>();
@@ -9,22 +9,89 @@ use crate::transport::WindowName;
9
9
  use serde_json::{json, Map, Value};
10
10
  use std::collections::{BTreeMap, BTreeSet};
11
11
  use std::path::{Path, PathBuf};
12
+ use std::sync::atomic::{AtomicU64, Ordering};
12
13
 
13
14
  const ANCESTRY_ENV: &str = "TEAM_AGENT_TEST_PROCESS_ANCESTRY_ARGV_JSON";
14
- const CALLER_IDENTITY_ENVS: &[&str] = &[
15
- "TMUX",
16
- "TMUX_PANE",
17
- "TEAM_AGENT_LEADER_PANE_ID",
18
- "TEAM_AGENT_LEADER_SESSION_UUID",
19
- "TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE",
20
- "TEAM_AGENT_LEADER_PROVIDER",
21
- "TEAM_AGENT_MACHINE_FINGERPRINT",
22
- "TEAM_AGENT_WORKSPACE",
23
- "TEAM_AGENT_TEAM_ID",
24
- "TEAM_AGENT_OWNER_TEAM_ID",
25
- "TEAM_AGENT_ACTIVE_TEAM",
26
- "TEAM_AGENT_ID",
27
- ];
15
+
16
+ static HERMETIC_COUNTER: AtomicU64 = AtomicU64::new(0);
17
+
18
+ struct HermeticTestEnv {
19
+ root: PathBuf,
20
+ previous: Vec<(&'static str, Option<String>)>,
21
+ }
22
+
23
+ impl HermeticTestEnv {
24
+ fn enter(tag: &str) -> Self {
25
+ let base = std::env::var_os("TEAM_AGENT_TEST_TMP")
26
+ .map(PathBuf::from)
27
+ .unwrap_or_else(std::env::temp_dir);
28
+ std::fs::create_dir_all(&base).expect("create hermetic test tmp root");
29
+ let root = base.join(format!(
30
+ "ta-phase-golden-{tag}-{}-{}",
31
+ std::process::id(),
32
+ HERMETIC_COUNTER.fetch_add(1, Ordering::Relaxed)
33
+ ));
34
+ let _ = std::fs::remove_dir_all(&root);
35
+ std::fs::create_dir_all(root.join("home/.team-agent/leaders"))
36
+ .expect("create phase golden hermetic root");
37
+ let root = std::fs::canonicalize(root).expect("canonicalize phase golden hermetic root");
38
+ let home = root.join("home");
39
+ let mut previous = Vec::new();
40
+ for key in std::iter::once("HOME").chain(hermetic_caller_envs().iter().copied()) {
41
+ previous.push((key, std::env::var(key).ok()));
42
+ }
43
+ unsafe {
44
+ std::env::set_var("HOME", &home);
45
+ for key in hermetic_caller_envs() {
46
+ std::env::remove_var(key);
47
+ }
48
+ }
49
+ Self { root, previous }
50
+ }
51
+
52
+ fn workspace(&self, tag: &str) -> PathBuf {
53
+ let path = self.root.join(format!(
54
+ "workspace-{tag}-{}",
55
+ HERMETIC_COUNTER.fetch_add(1, Ordering::Relaxed)
56
+ ));
57
+ std::fs::create_dir_all(&path).expect("create phase golden workspace");
58
+ std::fs::canonicalize(path).expect("canonicalize phase golden workspace")
59
+ }
60
+ }
61
+
62
+ impl Drop for HermeticTestEnv {
63
+ fn drop(&mut self) {
64
+ for (key, value) in self.previous.drain(..).rev() {
65
+ unsafe {
66
+ if let Some(value) = value {
67
+ std::env::set_var(key, value);
68
+ } else {
69
+ std::env::remove_var(key);
70
+ }
71
+ }
72
+ }
73
+ if std::env::var("TEAM_AGENT_KEEP_TEST_TMP").as_deref() != Ok("1") {
74
+ let _ = std::fs::remove_dir_all(&self.root);
75
+ }
76
+ }
77
+ }
78
+
79
+ fn hermetic_caller_envs() -> &'static [&'static str] {
80
+ &[
81
+ "TMUX",
82
+ "TMUX_PANE",
83
+ "TEAM_AGENT_LEADER_PANE_ID",
84
+ "TEAM_AGENT_LEADER_SESSION_UUID",
85
+ "TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE",
86
+ "TEAM_AGENT_LEADER_PROVIDER",
87
+ "TEAM_AGENT_MACHINE_FINGERPRINT",
88
+ "TEAM_AGENT_WORKSPACE",
89
+ "TEAM_AGENT_TEAM_ID",
90
+ "TEAM_AGENT_OWNER_TEAM_ID",
91
+ "TEAM_AGENT_ACTIVE_TEAM",
92
+ "TEAM_AGENT_ID",
93
+ ]
94
+ }
28
95
 
29
96
  #[test]
30
97
  #[serial_test::serial(env)]
@@ -154,12 +221,9 @@ struct PhaseGolden {
154
221
  }
155
222
 
156
223
  fn run_phase_golden(spec: PhaseGolden) -> Value {
224
+ let hermetic = HermeticTestEnv::enter(spec.phase);
157
225
  let _permission_mode = EnvVarGuard::set(ANCESTRY_ENV, "[]");
158
- let _caller_identity = CALLER_IDENTITY_ENVS
159
- .iter()
160
- .map(|key| EnvVarGuard::unset(key))
161
- .collect::<Vec<_>>();
162
- let team = two_worker_team_dir();
226
+ let team = two_worker_team_dir(&hermetic);
163
227
  let workspace = team.parent().expect("workspace").to_path_buf();
164
228
  seed_healthy_coordinator(&workspace);
165
229
  let launch_transport = codex_ready_transport();
@@ -298,8 +362,8 @@ fn phase_b_reset_discard_session(
298
362
  }
299
363
  }
300
364
 
301
- fn two_worker_team_dir() -> PathBuf {
302
- let team = temp_ws().join("teamdir");
365
+ fn two_worker_team_dir(hermetic: &HermeticTestEnv) -> PathBuf {
366
+ let team = hermetic.workspace("team").join("teamdir");
303
367
  std::fs::create_dir_all(team.join("agents")).unwrap();
304
368
  std::fs::write(
305
369
  team.join("TEAM.md"),
@@ -415,13 +479,6 @@ impl EnvVarGuard {
415
479
  Self { key, previous }
416
480
  }
417
481
 
418
- fn unset(key: &'static str) -> Self {
419
- let previous = std::env::var(key).ok();
420
- unsafe {
421
- std::env::remove_var(key);
422
- }
423
- Self { key, previous }
424
- }
425
482
  }
426
483
 
427
484
  impl Drop for EnvVarGuard {
@@ -279,12 +279,31 @@ pub(crate) fn latest_reportable_message_for(
279
279
  workspace: &Path,
280
280
  agent_id: &str,
281
281
  owner_team_id: Option<&str>,
282
+ ) -> Option<String> {
283
+ current_reportable_message_for(workspace, agent_id, owner_team_id)
284
+ .or_else(|| latest_delivered_direct_message_for(workspace, agent_id, owner_team_id))
285
+ }
286
+
287
+ pub(crate) fn current_reportable_message_for(
288
+ workspace: &Path,
289
+ agent_id: &str,
290
+ owner_team_id: Option<&str>,
282
291
  ) -> Option<String> {
283
292
  use crate::db::message_store::MessageStore;
284
293
  let store = MessageStore::open(workspace).ok()?;
285
294
  let conn = crate::db::schema::open_db(store.db_path()).ok()?;
286
295
  current_turn_message_for(workspace, &conn, agent_id, owner_team_id)
287
- .or_else(|| latest_reportable_message_from_db(&conn, agent_id, owner_team_id))
296
+ }
297
+
298
+ pub(crate) fn latest_delivered_direct_message_for(
299
+ workspace: &Path,
300
+ agent_id: &str,
301
+ owner_team_id: Option<&str>,
302
+ ) -> Option<String> {
303
+ use crate::db::message_store::MessageStore;
304
+ let store = MessageStore::open(workspace).ok()?;
305
+ let conn = crate::db::schema::open_db(store.db_path()).ok()?;
306
+ latest_reportable_message_from_db(&conn, agent_id, owner_team_id)
288
307
  }
289
308
 
290
309
  fn current_turn_message_for(
@@ -441,6 +460,6 @@ fn latest_reportable_message_from_db(
441
460
  fn reportable_message_status(status: &str, error: Option<&str>) -> bool {
442
461
  matches!(
443
462
  status,
444
- "delivered" | "target_resolved" | "submitted" | "injected" | "visible"
463
+ "delivered" | "submitted" | "injected" | "visible"
445
464
  ) && (status == "delivered" || error.is_none())
446
465
  }
@@ -110,8 +110,16 @@
110
110
  .unwrap();
111
111
  }
112
112
 
113
+ fn seed_report_scope_state(ws: &std::path::Path, state: &Value) {
114
+ let cws = std::fs::canonicalize(ws).unwrap_or_else(|_| ws.to_path_buf());
115
+ let rt = cws.join(".team").join("runtime");
116
+ std::fs::create_dir_all(&rt).unwrap();
117
+ std::fs::write(rt.join("state.json"), serde_json::to_string_pretty(state).unwrap())
118
+ .unwrap();
119
+ }
120
+
113
121
  #[test]
114
- fn report_result_prefers_current_inflight_message_over_old_delivered_fallback() {
122
+ fn report_result_prefers_current_turn_message_over_old_delivered_fallback() {
115
123
  let ws = unique_ws("report-current-message");
116
124
  seed_report_message(
117
125
  &ws,
@@ -134,6 +142,33 @@
134
142
  "target_resolved",
135
143
  "2026-07-06T13:27:35.000000+00:00",
136
144
  );
145
+ // 0.5.16 result-attribution-race-locate.md §4/§7.7:
146
+ // target_resolved alone is not physical-submit proof. Current-turn
147
+ // attribution comes from the state pointer armed at physical submit.
148
+ seed_report_scope_state(
149
+ &ws,
150
+ &json!({
151
+ "active_team_key": "gate055",
152
+ "teams": {
153
+ "gate055": {
154
+ "team_key": "gate055",
155
+ "coordinator": {
156
+ "turn_open": {
157
+ "armed": true,
158
+ "node_id": "probe-worker",
159
+ "turn_id": "msg_new"
160
+ }
161
+ },
162
+ "agents": {
163
+ "probe-worker": {
164
+ "id": "probe-worker",
165
+ "current_turn_message_id": "msg_new"
166
+ }
167
+ }
168
+ }
169
+ }
170
+ }),
171
+ );
137
172
 
138
173
  let tools = TeamOrchestratorTools::with_identity(
139
174
  &ws,
@@ -149,7 +184,62 @@
149
184
  assert_eq!(
150
185
  v.get("task_id"),
151
186
  Some(&json!("msg_new")),
152
- "current in-flight message must beat stale delivered fallback"
187
+ "current-turn message must beat stale delivered fallback"
188
+ );
189
+ }
190
+
191
+ #[test]
192
+ fn report_result_target_resolved_without_current_turn_uses_task_fallback() {
193
+ let ws = unique_ws("report-target-resolved-no-current");
194
+ seed_report_message(
195
+ &ws,
196
+ "msg_target_only",
197
+ "gate055",
198
+ "target_resolved",
199
+ "2026-07-06T13:27:35.000000+00:00",
200
+ );
201
+ seed_report_scope_state(
202
+ &ws,
203
+ &json!({
204
+ "active_team_key": "gate055",
205
+ "teams": {
206
+ "gate055": {
207
+ "team_key": "gate055",
208
+ "tasks": [
209
+ {
210
+ "id": "task_initial",
211
+ "assignee": "probe-worker",
212
+ "status": "pending"
213
+ }
214
+ ],
215
+ "agents": {
216
+ "probe-worker": {"id": "probe-worker"}
217
+ }
218
+ }
219
+ }
220
+ }),
221
+ );
222
+
223
+ let tools = TeamOrchestratorTools::with_identity(
224
+ &ws,
225
+ Some(AgentId::new("probe-worker")),
226
+ Some(TeamKey::new("gate055")),
227
+ );
228
+ let ok = tools.report_result(
229
+ None, Some("target resolved only"), ResultStatus::Success,
230
+ None, None, None, None, None,
231
+ None, None,
232
+ ).expect("report ok");
233
+ let v = serde_json::to_value(&ok).unwrap();
234
+ assert_ne!(
235
+ v.get("task_id"),
236
+ Some(&json!("msg_target_only")),
237
+ "0.5.16 locate §4/§7.7: target_resolved is a delivery claim, not physical-submit proof"
238
+ );
239
+ assert_eq!(
240
+ v.get("task_id"),
241
+ Some(&json!("task_initial")),
242
+ "without a current-turn pointer, no-task report_result falls through to task fallback"
153
243
  );
154
244
  }
155
245
 
@@ -20,9 +20,10 @@ use crate::state::persist::{
20
20
  use crate::messaging::{self, MessageTarget, SendOptions};
21
21
 
22
22
  use super::helpers::{
23
- delivery_outcome_value, ensure_object, enum_value, insert_array, is_worker_recipient,
24
- json_dumps_default, latest_reportable_message_for, latest_task_for_assignee, non_empty_string,
25
- normalized_envelope_value, object_fields, requires_ack_for_target, tool_runtime_error,
23
+ current_reportable_message_for, delivery_outcome_value, ensure_object, enum_value,
24
+ insert_array, is_worker_recipient, json_dumps_default, latest_delivered_direct_message_for,
25
+ latest_task_for_assignee, non_empty_string, normalized_envelope_value, object_fields,
26
+ requires_ack_for_target, tool_runtime_error,
26
27
  };
27
28
  use super::normalize::{
28
29
  compact_tool_result, normalize_report_envelope, normalize_result_status_observed,
@@ -334,18 +335,26 @@ impl TeamOrchestratorTools {
334
335
  // Blocker-1 (prerelease 0.4.0): scoped-team task inference +
335
336
  // message-scoped fallback, before defaulting to "manual".
336
337
  // 1. explicit arg
337
- // 2. latest nonterminal task assigned to this agent in
338
- // teams.<owner>.tasks (scoped) top-level tasks
339
- // 3. current/in-flight reportable direct message to this agent
340
- // with no task id and no result yet (message-scope
341
- // correlation — collect path: is_message_scoped_result)
342
- // 4. "manual" — truly uncorrelated; collect still rejects
338
+ // 2. current physical direct turn for this agent
339
+ // 3. latest delivered direct message with no result yet
340
+ // 4. latest nonterminal assigned task in teams.<owner>.tasks
341
+ // (scoped) top-level tasks
342
+ // 5. "manual" truly uncorrelated; collect still rejects
343
343
  let owner_team_id_str = self.owner_team_id.as_ref().map(|t| t.as_str().to_string());
344
344
  let resolved = task_id
345
345
  .map(ToString::to_string)
346
346
  .or_else(|| {
347
347
  self.agent_id.as_ref().and_then(|agent| {
348
- latest_task_for_assignee(
348
+ current_reportable_message_for(
349
+ &self.workspace,
350
+ agent.as_str(),
351
+ owner_team_id_str.as_deref(),
352
+ )
353
+ })
354
+ })
355
+ .or_else(|| {
356
+ self.agent_id.as_ref().and_then(|agent| {
357
+ latest_delivered_direct_message_for(
349
358
  &self.workspace,
350
359
  agent.as_str(),
351
360
  owner_team_id_str.as_deref(),
@@ -354,7 +363,7 @@ impl TeamOrchestratorTools {
354
363
  })
355
364
  .or_else(|| {
356
365
  self.agent_id.as_ref().and_then(|agent| {
357
- latest_reportable_message_for(
366
+ latest_task_for_assignee(
358
367
  &self.workspace,
359
368
  agent.as_str(),
360
369
  owner_team_id_str.as_deref(),
@@ -1,6 +1,7 @@
1
1
  //! internal_delivery.py + delivery.py — coordinator/调度器侧 thin wrapper + 单条 tmux 注入投递
2
2
  //! + trust 有界重试 + turn-open arm (card §16/§65)。
3
3
 
4
+ use std::cell::{Cell, RefCell};
4
5
  use std::path::Path;
5
6
 
6
7
  use rusqlite::{params, OptionalExtension};
@@ -14,7 +15,7 @@ use crate::provider::wire::{
14
15
  };
15
16
  use crate::transport::{
16
17
  submit_verification_wire, InjectPayload, InjectReport, InjectVerification, Key, PaneId,
17
- PaneInfo, SessionName, SubmitVerification, Target, Transport, WindowName,
18
+ PaneInfo, SessionName, SubmitObserver, SubmitVerification, Target, Transport, WindowName,
18
19
  };
19
20
 
20
21
  use super::helpers::{message_exists, MessageStatusShadow};
@@ -414,7 +415,22 @@ pub fn deliver_pending_message(
414
415
  } else {
415
416
  InjectPayload::Text(rendered)
416
417
  };
417
- let inject_report = match transport.inject(&target, &payload, Key::Enter, true) {
418
+ let submit_observer = CurrentTurnSubmitObserver::new(
419
+ workspace,
420
+ &message.sender,
421
+ &message.recipient,
422
+ message_id,
423
+ event_log,
424
+ canonical_owner_team_id.as_deref(),
425
+ resolved.metadata.as_ref(),
426
+ );
427
+ let inject_report = match transport.inject_with_submit_observer(
428
+ &target,
429
+ &payload,
430
+ Key::Enter,
431
+ true,
432
+ Some(&submit_observer),
433
+ ) {
418
434
  Ok(report) => report,
419
435
  Err(error) => {
420
436
  let reason = format!("inject_failed:{error}");
@@ -500,15 +516,9 @@ pub fn deliver_pending_message(
500
516
  });
501
517
  }
502
518
  };
503
- record_current_turn_after_inject_if_leader_to_worker_scoped(
504
- workspace,
505
- &message.sender,
506
- &message.recipient,
507
- message_id,
508
- event_log,
509
- canonical_owner_team_id.as_deref(),
510
- resolved.metadata.as_ref(),
511
- )?;
519
+ if let Some(error) = submit_observer.take_error() {
520
+ return Err(error);
521
+ }
512
522
  let submit_verified = inject_submit_verified(&inject_report);
513
523
  let readback_verified = pane_readback_verified(&inject_report);
514
524
  // Leader pane: inject success is delivery proof. Worker pane: post-submit
@@ -2109,6 +2119,65 @@ pub fn record_turn_open_if_leader_to_worker(
2109
2119
  )
2110
2120
  }
2111
2121
 
2122
+ struct CurrentTurnSubmitObserver<'a> {
2123
+ workspace: &'a Path,
2124
+ sender: &'a str,
2125
+ recipient: &'a str,
2126
+ message_id: &'a str,
2127
+ event_log: &'a EventLog,
2128
+ owner_team_id: Option<&'a str>,
2129
+ metadata: Option<&'a TargetMetadata>,
2130
+ fired: Cell<bool>,
2131
+ error: RefCell<Option<MessagingError>>,
2132
+ }
2133
+
2134
+ impl<'a> CurrentTurnSubmitObserver<'a> {
2135
+ fn new(
2136
+ workspace: &'a Path,
2137
+ sender: &'a str,
2138
+ recipient: &'a str,
2139
+ message_id: &'a str,
2140
+ event_log: &'a EventLog,
2141
+ owner_team_id: Option<&'a str>,
2142
+ metadata: Option<&'a TargetMetadata>,
2143
+ ) -> Self {
2144
+ Self {
2145
+ workspace,
2146
+ sender,
2147
+ recipient,
2148
+ message_id,
2149
+ event_log,
2150
+ owner_team_id,
2151
+ metadata,
2152
+ fired: Cell::new(false),
2153
+ error: RefCell::new(None),
2154
+ }
2155
+ }
2156
+
2157
+ fn take_error(&self) -> Option<MessagingError> {
2158
+ self.error.borrow_mut().take()
2159
+ }
2160
+ }
2161
+
2162
+ impl SubmitObserver for CurrentTurnSubmitObserver<'_> {
2163
+ fn after_physical_submit(&self) {
2164
+ if self.fired.replace(true) {
2165
+ return;
2166
+ }
2167
+ if let Err(error) = record_current_turn_after_inject_if_leader_to_worker_scoped(
2168
+ self.workspace,
2169
+ self.sender,
2170
+ self.recipient,
2171
+ self.message_id,
2172
+ self.event_log,
2173
+ self.owner_team_id,
2174
+ self.metadata,
2175
+ ) {
2176
+ *self.error.borrow_mut() = Some(error);
2177
+ }
2178
+ }
2179
+ }
2180
+
2112
2181
  fn record_current_turn_after_inject_if_leader_to_worker_scoped(
2113
2182
  workspace: &Path,
2114
2183
  sender: &str,
@@ -482,6 +482,7 @@ fn apply_persist_merge_contract(
482
482
  // clobber broke Batch 6 CHECK 6 (`shutdown` couldn't route to
483
483
  // the shim pid because `state.transport.shim.pid` was gone).
484
484
  preserve_transport_shim(incoming, latest);
485
+ preserve_latest_endpoint_convergence_fields(incoming, latest);
485
486
  }
486
487
 
487
488
  let latest_teams = latest.get("teams").and_then(Value::as_object);
@@ -504,6 +505,7 @@ fn apply_persist_merge_contract(
504
505
  topology_update_agent_ids,
505
506
  )?;
506
507
  preserve_latest_ownership_fields(incoming_entry, latest_entry);
508
+ preserve_latest_endpoint_convergence_fields(incoming_entry, latest_entry);
507
509
  }
508
510
  }
509
511
  Ok(())
@@ -574,6 +576,58 @@ fn preserve_latest_ownership_fields(incoming: &mut Value, latest: &Value) {
574
576
  }
575
577
  }
576
578
 
579
+ fn preserve_latest_endpoint_convergence_fields(incoming: &mut Value, latest: &Value) {
580
+ if !latest_has_preferable_endpoint_convergence(incoming, latest) {
581
+ return;
582
+ }
583
+ let Some(incoming_obj) = incoming.as_object_mut() else {
584
+ return;
585
+ };
586
+ for key in [
587
+ "tmux_endpoint",
588
+ "tmux_socket",
589
+ "tmux_socket_source",
590
+ "topology_convergence",
591
+ ] {
592
+ if let Some(value) = latest.get(key).filter(|value| json_truthy(value)) {
593
+ incoming_obj.insert(key.to_string(), value.clone());
594
+ }
595
+ }
596
+ }
597
+
598
+ fn latest_has_preferable_endpoint_convergence(incoming: &Value, latest: &Value) -> bool {
599
+ if latest
600
+ .get("topology_convergence")
601
+ .and_then(|marker| marker.get("status"))
602
+ .and_then(Value::as_str)
603
+ != Some("converged")
604
+ {
605
+ return false;
606
+ }
607
+ let latest_epoch = endpoint_convergence_epoch(latest).unwrap_or_else(|| ownership_epoch(latest));
608
+ let incoming_epoch =
609
+ endpoint_convergence_epoch(incoming).unwrap_or_else(|| ownership_epoch(incoming));
610
+ if latest_epoch < incoming_epoch {
611
+ return false;
612
+ }
613
+ !incoming
614
+ .get("topology_convergence")
615
+ .is_some_and(|marker| {
616
+ marker.get("status").and_then(Value::as_str) == Some("converged")
617
+ && marker
618
+ .get("owner_epoch")
619
+ .and_then(Value::as_u64)
620
+ .is_some_and(|epoch| epoch >= latest_epoch)
621
+ })
622
+ }
623
+
624
+ fn endpoint_convergence_epoch(state: &Value) -> Option<u64> {
625
+ state
626
+ .get("topology_convergence")
627
+ .and_then(|marker| marker.get("owner_epoch"))
628
+ .and_then(Value::as_u64)
629
+ }
630
+
577
631
  fn latest_has_preferable_ownership(incoming: &Value, latest: &Value) -> bool {
578
632
  let latest_epoch = ownership_epoch(latest);
579
633
  let incoming_epoch = ownership_epoch(incoming);
@@ -36,7 +36,7 @@ use crate::transport::{
36
36
  tmux_query_argv, tmux_send_keys_argv, tmux_spawn_argv, AttachOutcome, BackendKind,
37
37
  CaptureRange, CapturedText, InjectPayload, InjectReport, InjectStage, InjectVerification, Key,
38
38
  PaneField, PaneId, PaneInfo, PaneMode, SessionName, SetEnvOutcome, SpawnResult,
39
- SubmitAttemptObservation, SubmitVerification, Target, Transport, TransportError,
39
+ SubmitAttemptObservation, SubmitObserver, SubmitVerification, Target, Transport, TransportError,
40
40
  TurnVerification, WindowName,
41
41
  };
42
42
 
@@ -1591,6 +1591,12 @@ fn shell_quote(raw: &str) -> String {
1591
1591
  quoted
1592
1592
  }
1593
1593
 
1594
+ fn notify_submit_observer(observer: Option<&dyn SubmitObserver>) {
1595
+ if let Some(observer) = observer {
1596
+ observer.after_physical_submit();
1597
+ }
1598
+ }
1599
+
1594
1600
  impl Transport for TmuxBackend {
1595
1601
  fn kind(&self) -> BackendKind {
1596
1602
  BackendKind::Tmux
@@ -1702,6 +1708,17 @@ impl Transport for TmuxBackend {
1702
1708
  payload: &InjectPayload,
1703
1709
  submit: Key,
1704
1710
  bracketed: bool,
1711
+ ) -> Result<InjectReport, TransportError> {
1712
+ self.inject_with_submit_observer(target, payload, submit, bracketed, None)
1713
+ }
1714
+
1715
+ fn inject_with_submit_observer(
1716
+ &self,
1717
+ target: &Target,
1718
+ payload: &InjectPayload,
1719
+ submit: Key,
1720
+ bracketed: bool,
1721
+ observer: Option<&dyn SubmitObserver>,
1705
1722
  ) -> Result<InjectReport, TransportError> {
1706
1723
  let pane = pane_from_target(target);
1707
1724
  // U1 #7: pane readback signal for the non-pasted-prompt text path.
@@ -1710,6 +1727,7 @@ impl Transport for TmuxBackend {
1710
1727
  InjectPayload::Empty => {
1711
1728
  let argv = tmux_empty_inject_argv(&pane, submit);
1712
1729
  self.run_ok(&argv)?;
1730
+ notify_submit_observer(observer);
1713
1731
  }
1714
1732
  InjectPayload::Text(text) | InjectPayload::TextSkipConsumptionPoll(text) => {
1715
1733
  let buffer = buffer_name_for_text(text);
@@ -1779,6 +1797,7 @@ impl Transport for TmuxBackend {
1779
1797
  // check, KeySentAfterVisibleToken verification.
1780
1798
  if !matches!(submit, Key::Enter) {
1781
1799
  self.run_inject_stage(&submit_argv, InjectStage::Submit)?;
1800
+ notify_submit_observer(observer);
1782
1801
  let total_elapsed_ms = inject_start.elapsed().as_millis() as u64;
1783
1802
  return Ok(InjectReport {
1784
1803
  stage_reached: InjectStage::Submit,
@@ -1808,6 +1827,7 @@ impl Transport for TmuxBackend {
1808
1827
  let poll_consumption = !payload.skip_consumption_poll();
1809
1828
  if !poll_consumption {
1810
1829
  if self.run_inject_stage(&submit_argv, InjectStage::Submit).is_ok() {
1830
+ notify_submit_observer(observer);
1811
1831
  consumption_attempts = 1;
1812
1832
  }
1813
1833
  }
@@ -1858,6 +1878,7 @@ impl Transport for TmuxBackend {
1858
1878
  consumed = None;
1859
1879
  break;
1860
1880
  }
1881
+ notify_submit_observer(observer);
1861
1882
  consumption_attempts = attempt + 1;
1862
1883
 
1863
1884
  // Post-submit token readback (U1 #7 parity: check token
@@ -640,6 +640,21 @@ pub trait Transport: Send + Sync {
640
640
  bracketed: bool,
641
641
  ) -> Result<InjectReport, TransportError>;
642
642
 
643
+ fn inject_with_submit_observer(
644
+ &self,
645
+ target: &Target,
646
+ payload: &InjectPayload,
647
+ submit: Key,
648
+ bracketed: bool,
649
+ observer: Option<&dyn SubmitObserver>,
650
+ ) -> Result<InjectReport, TransportError> {
651
+ let report = self.inject(target, payload, submit, bracketed)?;
652
+ if let Some(observer) = observer {
653
+ observer.after_physical_submit();
654
+ }
655
+ Ok(report)
656
+ }
657
+
643
658
  fn send_keys(&self, target: &Target, keys: &[Key]) -> Result<(), TransportError>;
644
659
 
645
660
  fn capture(
@@ -718,6 +733,10 @@ pub trait Transport: Send + Sync {
718
733
  ) -> Result<AttachOutcome, TransportError>;
719
734
  }
720
735
 
736
+ pub trait SubmitObserver {
737
+ fn after_physical_submit(&self);
738
+ }
739
+
721
740
  // ─────────────────────────────────────────────────────────────────────────────
722
741
  // 命令构造 seam(STEP-9 头号目标:exact tmux command construction).
723
742
  //
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.15",
3
+ "version": "0.5.17",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.5.15",
24
- "@team-agent/cli-darwin-x64": "0.5.15",
25
- "@team-agent/cli-linux-x64": "0.5.15"
23
+ "@team-agent/cli-darwin-arm64": "0.5.17",
24
+ "@team-agent/cli-darwin-x64": "0.5.17",
25
+ "@team-agent/cli-linux-x64": "0.5.17"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",