@team-agent/installer 0.5.15 → 0.5.16

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.16"
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.16"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -4167,6 +4167,8 @@ pub mod leader_port {
4167
4167
  "source": source,
4168
4168
  "reason": convergence.get("reason").and_then(Value::as_str).unwrap_or("old_endpoint_dead"),
4169
4169
  "owner_epoch": convergence.get("owner_epoch").cloned().unwrap_or(Value::Null),
4170
+ "persisted": convergence.get("persisted").cloned().unwrap_or(Value::Bool(false)),
4171
+ "checked_paths": convergence.get("checked_paths").cloned().unwrap_or_else(|| json!([])),
4170
4172
  }),
4171
4173
  );
4172
4174
  }
@@ -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
  }
@@ -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.16",
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.16",
24
+ "@team-agent/cli-darwin-x64": "0.5.16",
25
+ "@team-agent/cli-linux-x64": "0.5.16"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",