@team-agent/installer 0.5.14 → 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.14"
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.14"
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
  }
@@ -255,6 +255,7 @@ pub(crate) fn start_agent_at_paths(
255
255
  Some(&safety),
256
256
  layout_placement.as_ref(),
257
257
  None,
258
+ None,
258
259
  Some(resolved_team_key.as_str()),
259
260
  )?;
260
261
  verify_spawned_pane_matches_target(
@@ -26,6 +26,7 @@ pub(super) fn spawn_agent_window(
26
26
  safety: Option<&DangerousApproval>,
27
27
  layout_placement: Option<&crate::lifecycle::launch::LayoutPlacement>,
28
28
  spawn_cwd_override: Option<&Path>,
29
+ tmux_endpoint_source: Option<&str>,
29
30
  // Issue 2 (Round 3b gate review §6): explicit owner_team_id override.
30
31
  // When `Some`, callers (restart/rebuild.rs, restart/agent.rs) thread the
31
32
  // resolved `selected.team_key` through here so the worker's MCP env /
@@ -205,6 +206,7 @@ pub(super) fn spawn_agent_window(
205
206
  let tmux_start_mode_pre_spawn =
206
207
  predict_tmux_start_mode(layout_placement, into_existing_session);
207
208
  let spawn_epoch = state_spawn_epoch_for_agent(workspace, agent_id);
209
+ let tmux_endpoint = transport.tmux_endpoint();
208
210
  let event_log = crate::event_log::EventLog::new(workspace);
209
211
  let _ = event_log.write(
210
212
  "provider.worker.spawn_argv",
@@ -220,6 +222,8 @@ pub(super) fn spawn_agent_window(
220
222
  "tmux_start_mode": tmux_start_mode_pre_spawn,
221
223
  "spawn_epoch": spawn_epoch,
222
224
  "source": "restart",
225
+ "tmux_endpoint": tmux_endpoint,
226
+ "tmux_endpoint_source": tmux_endpoint_source.unwrap_or("transport"),
223
227
  }),
224
228
  );
225
229
  }
@@ -564,6 +568,27 @@ pub(crate) fn lifecycle_worker_transport_for_selected_state(
564
568
  .map_err(|e| LifecycleError::TeamSelect(e.to_string()))
565
569
  }
566
570
 
571
+ pub(super) fn lifecycle_worker_tmux_backend_selection_for_state(
572
+ run_workspace: &Path,
573
+ state: &serde_json::Value,
574
+ ) -> Result<crate::tmux_backend::RuntimeTmuxBackendSelection, LifecycleError> {
575
+ if let Some(kind) = state.pointer("/transport/kind").and_then(|v| v.as_str()) {
576
+ if kind.eq_ignore_ascii_case("conpty") {
577
+ return Err(LifecycleError::TeamSelect(format!(
578
+ "backend_kind_mismatch: state.transport.kind={kind:?} but the legacy \
579
+ tmux-typed lifecycle resolver was invoked; caller must migrate to \
580
+ `lifecycle_worker_transport_for_selected_state` (Phase 1d Batch 2/3)"
581
+ )));
582
+ }
583
+ }
584
+ Ok(
585
+ crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
586
+ run_workspace,
587
+ Some(state),
588
+ ),
589
+ )
590
+ }
591
+
567
592
  pub(super) fn lifecycle_worker_tmux_backend_for_state(
568
593
  run_workspace: &Path,
569
594
  state: &serde_json::Value,
@@ -23,16 +23,51 @@ pub fn restart_with_session_convergence_deadline(
23
23
  team: Option<&str>,
24
24
  session_converge_deadline_ms: Option<u64>,
25
25
  ) -> Result<RestartReport, LifecycleError> {
26
- let paths = lifecycle_paths(workspace, team)?;
27
- let transport = lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
28
- restart_with_transport_with_session_convergence_deadline(
29
- workspace,
26
+ let context = resolve_restart_context(workspace, team)?;
27
+ restart_with_selected_team_and_transport(
28
+ context.selected,
30
29
  allow_fresh,
31
- team,
32
- &transport,
30
+ &context.transport,
33
31
  session_converge_deadline_ms,
34
32
  None,
33
+ Some(context.tmux_endpoint_source),
34
+ )
35
+ }
36
+
37
+ struct ResolvedRestartContext {
38
+ selected: crate::state::selector::SelectedTeam,
39
+ transport: crate::tmux_backend::TmuxBackend,
40
+ tmux_endpoint_source: &'static str,
41
+ }
42
+
43
+ fn resolve_restart_context(
44
+ workspace: &Path,
45
+ team: Option<&str>,
46
+ ) -> Result<ResolvedRestartContext, LifecycleError> {
47
+ let resolved_ws = crate::model::paths::canonical_run_workspace(workspace)
48
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
49
+ if crate::lifecycle::restart::input_has_no_local_team_context(&resolved_ws) {
50
+ return Err(LifecycleError::TeamSelect(format!(
51
+ "active team spec not found: input_workspace={} expected_runtime_dir={}",
52
+ workspace.display(),
53
+ crate::model::paths::runtime_dir(&resolved_ws).display()
54
+ )));
55
+ }
56
+ let selected = crate::state::selector::resolve_active_team(
57
+ workspace,
58
+ team,
59
+ crate::state::selector::SelectorMode::RequireSpec,
35
60
  )
61
+ .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
62
+ let transport_selection = lifecycle_worker_tmux_backend_selection_for_state(
63
+ &selected.run_workspace,
64
+ &selected.state,
65
+ )?;
66
+ Ok(ResolvedRestartContext {
67
+ selected,
68
+ transport: transport_selection.backend,
69
+ tmux_endpoint_source: transport_selection.tmux_endpoint_source.as_str(),
70
+ })
36
71
  }
37
72
 
38
73
  /// `restart` with an injected transport (tests: recording mock; prod: real TmuxBackend). The Route-B
@@ -129,6 +164,24 @@ pub fn restart_with_transport_with_session_convergence_deadline(
129
164
  expected.display()
130
165
  )));
131
166
  }
167
+ restart_with_selected_team_and_transport(
168
+ selected,
169
+ allow_fresh,
170
+ transport,
171
+ session_converge_deadline_ms,
172
+ readiness_deadline_ms,
173
+ None,
174
+ )
175
+ }
176
+
177
+ fn restart_with_selected_team_and_transport(
178
+ selected: crate::state::selector::SelectedTeam,
179
+ allow_fresh: bool,
180
+ transport: &dyn crate::transport::Transport,
181
+ session_converge_deadline_ms: Option<u64>,
182
+ readiness_deadline_ms: Option<u64>,
183
+ tmux_endpoint_source: Option<&str>,
184
+ ) -> Result<RestartReport, LifecycleError> {
132
185
  let lifecycle_lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
133
186
  workspace: &selected.run_workspace,
134
187
  operation: "restart",
@@ -381,6 +434,13 @@ pub fn restart_with_transport_with_session_convergence_deadline(
381
434
  &raw_agent,
382
435
  );
383
436
  if has_endpoint_convergence_marker(&state) && is_fake_model_harness_agent(&agent) {
437
+ write_fake_harness_spawn_argv_event(
438
+ &selected.run_workspace,
439
+ decision,
440
+ &agent,
441
+ transport,
442
+ tmux_endpoint_source,
443
+ );
384
444
  mark_fake_harness_agent_respawned(
385
445
  &mut state,
386
446
  &decision.agent_id,
@@ -438,6 +498,7 @@ pub fn restart_with_transport_with_session_convergence_deadline(
438
498
  Some(&safety),
439
499
  layout_placement.as_ref(),
440
500
  None,
501
+ tmux_endpoint_source,
441
502
  // Issue 2 (Round 3b gate review §6): thread the resolved
442
503
  // selected.team_key so the worker MCP env carries the right
443
504
  // owner_team_id even when top-level active_team_key is stale.
@@ -783,7 +844,14 @@ pub fn restart_with_transport_with_session_convergence_deadline(
783
844
  restart_readiness_poll_interval(),
784
845
  )?;
785
846
  let attach_commands =
786
- crate::tmux_backend::attach_command_for_session(&selected.run_workspace, &session_name)
847
+ crate::tmux_backend::attach_command_for_transport_session(transport, &session_name)
848
+ .or_else(|| {
849
+ crate::tmux_backend::attach_command_for_runtime_state_session_or_workspace(
850
+ &selected.run_workspace,
851
+ Some(&state),
852
+ &session_name,
853
+ )
854
+ })
787
855
  .into_iter()
788
856
  .collect::<Vec<_>>();
789
857
  let mut next_actions = Vec::new();
@@ -1719,6 +1787,29 @@ fn mark_fake_harness_agent_respawned(
1719
1787
  agent.insert("owner_team_id".to_string(), serde_json::json!(team_key));
1720
1788
  }
1721
1789
 
1790
+ fn write_fake_harness_spawn_argv_event(
1791
+ workspace: &Path,
1792
+ decision: &RestartedAgent,
1793
+ agent: &serde_json::Value,
1794
+ transport: &dyn crate::transport::Transport,
1795
+ tmux_endpoint_source: Option<&str>,
1796
+ ) {
1797
+ let _ = crate::event_log::EventLog::new(workspace).write(
1798
+ "provider.worker.spawn_argv",
1799
+ serde_json::json!({
1800
+ "agent_id": decision.agent_id.as_str(),
1801
+ "provider": agent_provider(agent),
1802
+ "argv": [],
1803
+ "session_id_in_argv": null,
1804
+ "expected_session_id": decision.session_id.as_ref().map(|s| s.as_str()),
1805
+ "tmux_start_mode": "fake_harness",
1806
+ "source": "restart",
1807
+ "tmux_endpoint": transport.tmux_endpoint(),
1808
+ "tmux_endpoint_source": tmux_endpoint_source.unwrap_or("transport"),
1809
+ }),
1810
+ );
1811
+ }
1812
+
1722
1813
  fn restart_failed_agent(
1723
1814
  decision: &RestartedAgent,
1724
1815
  phase: impl Into<String>,
@@ -524,7 +524,7 @@ fn normalize_string(text: String, ctx: &mut NormalizeCtx, key: Option<&str>) ->
524
524
  if key_is_timestamp(key) {
525
525
  return json!("<TS>");
526
526
  }
527
- if key.contains("endpoint") {
527
+ if key.contains("endpoint") && value_looks_like_endpoint_path(&text) {
528
528
  return json!("<SOCKET>");
529
529
  }
530
530
  if key_is_id(key, &text) {
@@ -557,6 +557,13 @@ fn normalize_path_string(text: &str, ctx: &NormalizeCtx) -> String {
557
557
  normalize_socket_token(&out)
558
558
  }
559
559
 
560
+ fn value_looks_like_endpoint_path(text: &str) -> bool {
561
+ text.starts_with('/')
562
+ || text.starts_with("ta-")
563
+ || text.contains("/tmux-")
564
+ || text.contains("tmux-")
565
+ }
566
+
560
567
  fn normalize_team_agent_binary_path(text: &str) -> String {
561
568
  let mut out = text.to_string();
562
569
  // 0.5.0 hermetic 教训「环境路径类 token 化」的直接延伸
@@ -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
 
@@ -566,6 +566,14 @@ pub(crate) fn attach_command_for_session(
566
566
  ))
567
567
  }
568
568
 
569
+ pub(crate) fn attach_command_for_transport_session(
570
+ transport: &dyn crate::transport::Transport,
571
+ session_name: &SessionName,
572
+ ) -> Option<String> {
573
+ let endpoint = transport.tmux_endpoint()?;
574
+ Some(attach_command_for_endpoint_session(&endpoint, session_name))
575
+ }
576
+
569
577
  /// Bug #7 (prerelease 0.4.0 gate review §6): when the runtime state carries a
570
578
  /// persisted `tmux_endpoint` / `tmux_socket` (e.g. `/private/tmp/tmux-501/default`),
571
579
  /// the attach command MUST point at THAT endpoint, not the workspace-hash
@@ -591,6 +599,26 @@ pub(crate) fn attach_command_for_runtime_state_or_workspace(
591
599
  attach_command_for_workspace(workspace, session_name, window_name)
592
600
  }
593
601
 
602
+ pub(crate) fn attach_command_for_runtime_state_session_or_workspace(
603
+ workspace: &Path,
604
+ state: Option<&serde_json::Value>,
605
+ session_name: &SessionName,
606
+ ) -> Option<String> {
607
+ if let Some((endpoint, _source)) = runtime_tmux_endpoint_from_state(state) {
608
+ return Some(attach_command_for_endpoint_session(endpoint, session_name));
609
+ }
610
+ attach_command_for_session(workspace, session_name)
611
+ }
612
+
613
+ fn attach_command_for_endpoint_session(endpoint: &str, session_name: &SessionName) -> String {
614
+ let flag = if Path::new(endpoint).is_absolute() {
615
+ "-S"
616
+ } else {
617
+ "-L"
618
+ };
619
+ format!("tmux {flag} {endpoint} attach -t {}", session_name.as_str())
620
+ }
621
+
594
622
  pub(crate) fn attach_commands_for_windows<'a>(
595
623
  workspace: &Path,
596
624
  session_name: &SessionName,
@@ -1563,6 +1591,12 @@ fn shell_quote(raw: &str) -> String {
1563
1591
  quoted
1564
1592
  }
1565
1593
 
1594
+ fn notify_submit_observer(observer: Option<&dyn SubmitObserver>) {
1595
+ if let Some(observer) = observer {
1596
+ observer.after_physical_submit();
1597
+ }
1598
+ }
1599
+
1566
1600
  impl Transport for TmuxBackend {
1567
1601
  fn kind(&self) -> BackendKind {
1568
1602
  BackendKind::Tmux
@@ -1674,6 +1708,17 @@ impl Transport for TmuxBackend {
1674
1708
  payload: &InjectPayload,
1675
1709
  submit: Key,
1676
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>,
1677
1722
  ) -> Result<InjectReport, TransportError> {
1678
1723
  let pane = pane_from_target(target);
1679
1724
  // U1 #7: pane readback signal for the non-pasted-prompt text path.
@@ -1682,6 +1727,7 @@ impl Transport for TmuxBackend {
1682
1727
  InjectPayload::Empty => {
1683
1728
  let argv = tmux_empty_inject_argv(&pane, submit);
1684
1729
  self.run_ok(&argv)?;
1730
+ notify_submit_observer(observer);
1685
1731
  }
1686
1732
  InjectPayload::Text(text) | InjectPayload::TextSkipConsumptionPoll(text) => {
1687
1733
  let buffer = buffer_name_for_text(text);
@@ -1751,6 +1797,7 @@ impl Transport for TmuxBackend {
1751
1797
  // check, KeySentAfterVisibleToken verification.
1752
1798
  if !matches!(submit, Key::Enter) {
1753
1799
  self.run_inject_stage(&submit_argv, InjectStage::Submit)?;
1800
+ notify_submit_observer(observer);
1754
1801
  let total_elapsed_ms = inject_start.elapsed().as_millis() as u64;
1755
1802
  return Ok(InjectReport {
1756
1803
  stage_reached: InjectStage::Submit,
@@ -1780,6 +1827,7 @@ impl Transport for TmuxBackend {
1780
1827
  let poll_consumption = !payload.skip_consumption_poll();
1781
1828
  if !poll_consumption {
1782
1829
  if self.run_inject_stage(&submit_argv, InjectStage::Submit).is_ok() {
1830
+ notify_submit_observer(observer);
1783
1831
  consumption_attempts = 1;
1784
1832
  }
1785
1833
  }
@@ -1830,6 +1878,7 @@ impl Transport for TmuxBackend {
1830
1878
  consumed = None;
1831
1879
  break;
1832
1880
  }
1881
+ notify_submit_observer(observer);
1833
1882
  consumption_attempts = attempt + 1;
1834
1883
 
1835
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.14",
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.14",
24
- "@team-agent/cli-darwin-x64": "0.5.14",
25
- "@team-agent/cli-linux-x64": "0.5.14"
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",