@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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/mod.rs +2 -0
- package/crates/team-agent/src/leader/lease.rs +169 -5
- package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +25 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +98 -7
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +8 -1
- package/crates/team-agent/src/mcp_server/helpers.rs +21 -2
- package/crates/team-agent/src/mcp_server/tests/tools.rs +92 -2
- package/crates/team-agent/src/mcp_server/tools.rs +20 -11
- package/crates/team-agent/src/messaging/delivery.rs +80 -11
- package/crates/team-agent/src/state/persist.rs +54 -0
- package/crates/team-agent/src/tmux_backend.rs +50 -1
- package/crates/team-agent/src/transport.rs +19 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 [
|
|
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
|
}
|
|
@@ -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
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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::
|
|
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
|
-
|
|
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" | "
|
|
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
|
|
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
|
|
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,
|
|
24
|
-
|
|
25
|
-
|
|
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.
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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.
|
|
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.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
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",
|