@team-agent/installer 0.5.59 → 0.5.61
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/adapters.rs +44 -1
- package/crates/team-agent/src/cli/diagnose.rs +183 -0
- package/crates/team-agent/src/cli/emit.rs +63 -14
- package/crates/team-agent/src/cli/leader.rs +8 -1
- package/crates/team-agent/src/cli/mod.rs +263 -5
- package/crates/team-agent/src/cli/named_address.rs +18 -1
- package/crates/team-agent/src/cli/send/presentation.rs +2 -0
- package/crates/team-agent/src/cli/spec.rs +4 -1
- package/crates/team-agent/src/cli/status_port/store.rs +7 -3
- package/crates/team-agent/src/cli/tests/named_address.rs +65 -0
- package/crates/team-agent/src/cli/types.rs +33 -0
- package/crates/team-agent/src/communication_mode/mod.rs +53 -0
- package/crates/team-agent/src/compiler/tests.rs +5 -5
- package/crates/team-agent/src/compiler.rs +53 -1
- package/crates/team-agent/src/coordinator/tick.rs +21 -8
- package/crates/team-agent/src/db/message_store.rs +45 -1
- package/crates/team-agent/src/fake_worker.rs +21 -1
- package/crates/team-agent/src/leader/start.rs +732 -94
- package/crates/team-agent/src/leader/tests/identity.rs +64 -57
- package/crates/team-agent/src/leader/tests/identity_session_names.rs +42 -0
- package/crates/team-agent/src/lib.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +58 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +57 -51
- package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +148 -8
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +5 -0
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch.rs +1 -1
- package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
- package/crates/team-agent/src/lifecycle/restart/common.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +35 -1
- package/crates/team-agent/src/lifecycle/types.rs +1 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +29 -11
- package/crates/team-agent/src/mcp_server/normalize.rs +1 -0
- package/crates/team-agent/src/mcp_server/tests/send.rs +26 -0
- package/crates/team-agent/src/mcp_server/tests/wire.rs +1 -1
- package/crates/team-agent/src/mcp_server/tools.rs +100 -66
- package/crates/team-agent/src/mcp_server/types.rs +5 -0
- package/crates/team-agent/src/mcp_server/wire.rs +27 -21
- package/crates/team-agent/src/messaging/delivery.rs +105 -38
- package/crates/team-agent/src/messaging/leader_channel.rs +33 -10
- package/crates/team-agent/src/messaging/leader_receiver.rs +40 -23
- package/crates/team-agent/src/messaging/presentation.rs +109 -0
- package/crates/team-agent/src/messaging/results.rs +165 -17
- package/crates/team-agent/src/messaging/send.rs +94 -81
- package/crates/team-agent/src/messaging/tests/leader_channel.rs +6 -6
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +187 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +13 -6
- package/crates/team-agent/src/messaging/types.rs +5 -0
- package/crates/team-agent/src/messaging/watchers.rs +5 -0
- package/crates/team-agent/src/model/mod.rs +1 -0
- package/crates/team-agent/src/model/pane_authority_refusal.rs +358 -0
- package/crates/team-agent/src/model/spec.rs +16 -0
- package/crates/team-agent/src/provider/adapter.rs +57 -0
- package/crates/team-agent/src/provider/adapters/claude_fork.rs +10 -2
- package/crates/team-agent/src/provider/session/capture.rs +69 -34
- package/crates/team-agent/src/provider/session/context_fork/codex.rs +386 -9
- package/crates/team-agent/src/provider/session/context_fork/outcome.rs +3 -14
- package/crates/team-agent/src/provider/session/context_fork.rs +7 -2
- package/crates/team-agent/src/provider/session/mod.rs +3 -2
- package/crates/team-agent/src/provider/session_scan/claude.rs +92 -3
- package/crates/team-agent/src/provider/session_scan/codex.rs +28 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +34 -8
- package/crates/team-agent/src/provider/session_scan.rs +8 -0
- package/crates/team-agent/src/topology.rs +3 -0
- package/package.json +4 -4
- package/skills/team-agent/command-coverage.json +379 -0
|
@@ -14,6 +14,95 @@ use super::MessagingError;
|
|
|
14
14
|
use crate::model::ids::TaskId;
|
|
15
15
|
use crate::state::projection::OwnerTeamResolution;
|
|
16
16
|
|
|
17
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
18
|
+
pub enum ResultRoute {
|
|
19
|
+
Leader,
|
|
20
|
+
Pipeline,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
impl ResultRoute {
|
|
24
|
+
pub const ALL: [Self; 2] = [Self::Leader, Self::Pipeline];
|
|
25
|
+
|
|
26
|
+
pub const fn as_str(self) -> &'static str {
|
|
27
|
+
match self {
|
|
28
|
+
Self::Leader => "leader",
|
|
29
|
+
Self::Pipeline => "pipeline",
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
pub fn parse(value: &str) -> Option<Self> {
|
|
34
|
+
match value {
|
|
35
|
+
"leader" => Some(Self::Leader),
|
|
36
|
+
"pipeline" => Some(Self::Pipeline),
|
|
37
|
+
_ => None,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
impl Default for ResultRoute {
|
|
43
|
+
fn default() -> Self {
|
|
44
|
+
Self::Leader
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
pub fn results_for_case(
|
|
49
|
+
workspace: &Path,
|
|
50
|
+
case_id: &str,
|
|
51
|
+
owner_team_id: Option<&str>,
|
|
52
|
+
requested_owner_team_id: Option<&str>,
|
|
53
|
+
) -> Result<Vec<serde_json::Value>, MessagingError> {
|
|
54
|
+
let current_db = workspace.join(".team/runtime/team.db");
|
|
55
|
+
let legacy_db = workspace.join(".team/team.db");
|
|
56
|
+
if current_db.exists() {
|
|
57
|
+
return results_for_case_in_db(
|
|
58
|
+
¤t_db,
|
|
59
|
+
case_id,
|
|
60
|
+
owner_team_id,
|
|
61
|
+
requested_owner_team_id,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if legacy_db.exists() {
|
|
65
|
+
return results_for_case_in_db(&legacy_db, case_id, owner_team_id, requested_owner_team_id);
|
|
66
|
+
}
|
|
67
|
+
Ok(Vec::new())
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fn results_for_case_in_db(
|
|
71
|
+
db_path: &Path,
|
|
72
|
+
case_id: &str,
|
|
73
|
+
owner_team_id: Option<&str>,
|
|
74
|
+
requested_owner_team_id: Option<&str>,
|
|
75
|
+
) -> Result<Vec<serde_json::Value>, MessagingError> {
|
|
76
|
+
let conn = crate::db::schema::open_db(db_path)?;
|
|
77
|
+
// The second predicate works around
|
|
78
|
+
// `.team/bugs/BUG-caseid-writable-when-route-undeclared-20260730.md`;
|
|
79
|
+
// it is not design intent.
|
|
80
|
+
let case_predicate = "task_id = ?1 or json_extract(envelope,'$.presentation.case_id') = ?1";
|
|
81
|
+
let sql = match owner_team_id {
|
|
82
|
+
Some(_) => format!(
|
|
83
|
+
"select envelope from results where ({case_predicate}) \
|
|
84
|
+
and (owner_team_id = ?2 or owner_team_id = ?3) \
|
|
85
|
+
order by created_at, result_id"
|
|
86
|
+
),
|
|
87
|
+
None => format!(
|
|
88
|
+
"select envelope from results where {case_predicate} order by created_at, result_id"
|
|
89
|
+
),
|
|
90
|
+
};
|
|
91
|
+
let mut stmt = conn.prepare(&sql)?;
|
|
92
|
+
let row_mapper = |row: &rusqlite::Row<'_>| row.get::<_, String>(0);
|
|
93
|
+
let rows = match (owner_team_id, requested_owner_team_id) {
|
|
94
|
+
(Some(team), Some(requested_team)) => {
|
|
95
|
+
stmt.query_map(params![case_id, team, requested_team], row_mapper)
|
|
96
|
+
}
|
|
97
|
+
(Some(team), None) => stmt.query_map(params![case_id, team, team], row_mapper),
|
|
98
|
+
(None, _) => stmt.query_map(params![case_id], row_mapper),
|
|
99
|
+
}?
|
|
100
|
+
.collect::<Result<Vec<_>, _>>()?;
|
|
101
|
+
rows.into_iter()
|
|
102
|
+
.map(|envelope| serde_json::from_str(&envelope).map_err(MessagingError::from))
|
|
103
|
+
.collect()
|
|
104
|
+
}
|
|
105
|
+
|
|
17
106
|
/// `collect` (`results.py:45`):投递 pending、捞 uncollected results、校验 envelope、更新任务态、
|
|
18
107
|
/// 写 team_state、ensure coordinator。CLI `collect` + coordinator tick 调。
|
|
19
108
|
pub fn collect(
|
|
@@ -627,18 +716,12 @@ fn report_result_for_owner_team_inner(
|
|
|
627
716
|
explicit_owner_team: Option<&str>,
|
|
628
717
|
fallback_primary_error: Option<&str>,
|
|
629
718
|
) -> Result<serde_json::Value, MessagingError> {
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
return Err(MessagingError::Validation(format!(
|
|
635
|
-
"invalid presentation: {error}"
|
|
636
|
-
)));
|
|
719
|
+
if envelope.get("result_route").is_some() {
|
|
720
|
+
return Err(MessagingError::Validation(
|
|
721
|
+
"report_result result_route is task-owned and cannot be set by a worker".to_string(),
|
|
722
|
+
));
|
|
637
723
|
}
|
|
638
|
-
|
|
639
|
-
&presentation_request,
|
|
640
|
-
super::presentation::PresentationSource::ReportResult,
|
|
641
|
-
);
|
|
724
|
+
validate_result_envelope(envelope)?;
|
|
642
725
|
let store = MessageStore::open(workspace)?;
|
|
643
726
|
let result_id = envelope
|
|
644
727
|
.get("result_id")
|
|
@@ -656,12 +739,6 @@ fn report_result_for_owner_team_inner(
|
|
|
656
739
|
);
|
|
657
740
|
}
|
|
658
741
|
}
|
|
659
|
-
if let Some(obj) = stored.as_object_mut() {
|
|
660
|
-
obj.insert(
|
|
661
|
-
"presentation".to_string(),
|
|
662
|
-
serde_json::to_value(&presentation)?,
|
|
663
|
-
);
|
|
664
|
-
}
|
|
665
742
|
let conn = crate::db::schema::open_db(store.db_path())?;
|
|
666
743
|
let state_for_owner =
|
|
667
744
|
crate::state::persist::load_runtime_state(workspace).unwrap_or(serde_json::json!({}));
|
|
@@ -669,6 +746,42 @@ fn report_result_for_owner_team_inner(
|
|
|
669
746
|
.filter(|team| !team.is_empty())
|
|
670
747
|
.map(str::to_string)
|
|
671
748
|
.unwrap_or_else(|| super::leader_receiver::active_team_key(workspace, &state_for_owner));
|
|
749
|
+
let (result_route, route_declared) = task_result_route(&state_for_owner, &owner_team, task_id)?;
|
|
750
|
+
let presentation = if route_declared {
|
|
751
|
+
let request = super::presentation::PresentationRequest {
|
|
752
|
+
sink: match result_route {
|
|
753
|
+
ResultRoute::Leader => super::presentation::PresentationSink::Leader,
|
|
754
|
+
ResultRoute::Pipeline => super::presentation::PresentationSink::Casefile,
|
|
755
|
+
},
|
|
756
|
+
class: match result_route {
|
|
757
|
+
ResultRoute::Leader => super::presentation::PresentationClass::Message,
|
|
758
|
+
ResultRoute::Pipeline => super::presentation::PresentationClass::StageResult,
|
|
759
|
+
},
|
|
760
|
+
case_id: (result_route == ResultRoute::Pipeline).then(|| task_id.to_string()),
|
|
761
|
+
};
|
|
762
|
+
super::presentation::decide_presentation(
|
|
763
|
+
&request,
|
|
764
|
+
super::presentation::PresentationSource::ReportResult,
|
|
765
|
+
)
|
|
766
|
+
} else {
|
|
767
|
+
let (request, error) =
|
|
768
|
+
super::presentation::normalize_report_presentation(envelope.get("presentation"));
|
|
769
|
+
if let Some(error) = error {
|
|
770
|
+
return Err(MessagingError::Validation(format!(
|
|
771
|
+
"invalid presentation: {error}"
|
|
772
|
+
)));
|
|
773
|
+
}
|
|
774
|
+
super::presentation::decide_presentation(
|
|
775
|
+
&request,
|
|
776
|
+
super::presentation::PresentationSource::ReportResult,
|
|
777
|
+
)
|
|
778
|
+
};
|
|
779
|
+
if let Some(obj) = stored.as_object_mut() {
|
|
780
|
+
obj.insert(
|
|
781
|
+
"presentation".to_string(),
|
|
782
|
+
serde_json::to_value(&presentation)?,
|
|
783
|
+
);
|
|
784
|
+
}
|
|
672
785
|
let inserted = insert_result_if_absent(
|
|
673
786
|
&conn,
|
|
674
787
|
&result_id,
|
|
@@ -968,6 +1081,8 @@ fn report_result_for_owner_team_inner(
|
|
|
968
1081
|
let leader_notified = matches!(outcome.status, crate::messaging::DeliveryStatus::Delivered);
|
|
969
1082
|
let notification_status_wire = if leader_notified {
|
|
970
1083
|
"delivered"
|
|
1084
|
+
} else if outcome.channel.as_deref() == Some("leader_receipt_source_unavailable") {
|
|
1085
|
+
"injected_awaiting_receipt"
|
|
971
1086
|
} else if outcome.channel.as_deref() == Some("leader_acceptance_pending") {
|
|
972
1087
|
"submitted_pending_acceptance"
|
|
973
1088
|
} else if outcome.channel.as_deref() == Some("rebind_required")
|
|
@@ -1045,6 +1160,39 @@ fn report_result_for_owner_team_inner(
|
|
|
1045
1160
|
Ok(serde_json::Value::Object(out))
|
|
1046
1161
|
}
|
|
1047
1162
|
|
|
1163
|
+
fn task_result_route(
|
|
1164
|
+
state: &serde_json::Value,
|
|
1165
|
+
owner_team: &str,
|
|
1166
|
+
task_id: &str,
|
|
1167
|
+
) -> Result<(ResultRoute, bool), MessagingError> {
|
|
1168
|
+
let scoped_tasks = state
|
|
1169
|
+
.get("teams")
|
|
1170
|
+
.and_then(|teams| teams.get(owner_team))
|
|
1171
|
+
.and_then(|team| team.get("tasks"))
|
|
1172
|
+
.and_then(serde_json::Value::as_array);
|
|
1173
|
+
let top_level_tasks = state.get("tasks").and_then(serde_json::Value::as_array);
|
|
1174
|
+
let task = scoped_tasks
|
|
1175
|
+
.into_iter()
|
|
1176
|
+
.flatten()
|
|
1177
|
+
.chain(top_level_tasks.into_iter().flatten())
|
|
1178
|
+
.find(|task| task.get("id").and_then(serde_json::Value::as_str) == Some(task_id));
|
|
1179
|
+
let Some(route) = task.and_then(|task| task.get("result_route")) else {
|
|
1180
|
+
return Ok((ResultRoute::default(), false));
|
|
1181
|
+
};
|
|
1182
|
+
let route = route.as_str().ok_or_else(|| {
|
|
1183
|
+
MessagingError::Validation(format!(
|
|
1184
|
+
"task {task_id} has non-string result_route and cannot accept results"
|
|
1185
|
+
))
|
|
1186
|
+
})?;
|
|
1187
|
+
ResultRoute::parse(route)
|
|
1188
|
+
.map(|route| (route, true))
|
|
1189
|
+
.ok_or_else(|| {
|
|
1190
|
+
MessagingError::Validation(format!(
|
|
1191
|
+
"task {task_id} has unknown result_route {route:?} and cannot accept results"
|
|
1192
|
+
))
|
|
1193
|
+
})
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1048
1196
|
fn copy_report_attribution_fields(
|
|
1049
1197
|
envelope: &serde_json::Value,
|
|
1050
1198
|
out: &mut serde_json::Map<String, serde_json::Value>,
|
|
@@ -9,9 +9,10 @@ use crate::model::ids::{AgentId, TaskId, TeamKey};
|
|
|
9
9
|
use crate::transport::{PaneId, Transport};
|
|
10
10
|
|
|
11
11
|
use super::helpers::{status_wire, MessageStatusShadow};
|
|
12
|
-
use super::leader_receiver::
|
|
12
|
+
use super::leader_receiver::send_to_leader_receiver_with_presentation;
|
|
13
13
|
use super::presentation::{
|
|
14
|
-
decide_presentation, PresentationRequest, PresentationSink,
|
|
14
|
+
decide_presentation, PresentationDecision, PresentationRequest, PresentationSink,
|
|
15
|
+
PresentationSource,
|
|
15
16
|
};
|
|
16
17
|
use super::{
|
|
17
18
|
persist_resolved_send, DeliveryBlocker, DeliveryOutcome, DeliveryRefusal, DeliveryStatus,
|
|
@@ -125,49 +126,14 @@ pub fn send_message(
|
|
|
125
126
|
MessageTarget::Single(target) if target == "leader" => {
|
|
126
127
|
let presentation = decide_presentation(&opts.presentation, PresentationSource::Send);
|
|
127
128
|
if presentation.effective_sink != PresentationSink::Leader {
|
|
128
|
-
|
|
129
|
-
opts.origin,
|
|
129
|
+
return persist_stored_only_send(
|
|
130
130
|
workspace,
|
|
131
|
-
opts.team.clone(),
|
|
132
131
|
LogicalRecipient::Leader,
|
|
133
|
-
opts.sender.clone(),
|
|
134
|
-
opts.task_id.clone(),
|
|
135
132
|
content,
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
133
|
+
opts,
|
|
134
|
+
&presentation,
|
|
135
|
+
&event_log,
|
|
139
136
|
);
|
|
140
|
-
intent.initial_disposition = InitialDisposition::StoredOnly;
|
|
141
|
-
intent.presentation = presentation.clone();
|
|
142
|
-
let persisted = match persist_resolved_send(&intent)? {
|
|
143
|
-
PersistResolution::Persisted(persisted) => persisted,
|
|
144
|
-
PersistResolution::Duplicate(message_id) => {
|
|
145
|
-
return Ok(refused_outcome_with_id(
|
|
146
|
-
DeliveryRefusal::Duplicate,
|
|
147
|
-
Some(message_id),
|
|
148
|
-
));
|
|
149
|
-
}
|
|
150
|
-
};
|
|
151
|
-
event_log.write(
|
|
152
|
-
"presentation.stored_without_live_inject",
|
|
153
|
-
serde_json::json!({
|
|
154
|
-
"message_id": persisted.message_id,
|
|
155
|
-
"requested_sink": presentation.requested_sink,
|
|
156
|
-
"effective_sink": presentation.effective_sink,
|
|
157
|
-
"class": presentation.class,
|
|
158
|
-
"policy_reason": presentation.policy_reason,
|
|
159
|
-
}),
|
|
160
|
-
)?;
|
|
161
|
-
return Ok(DeliveryOutcome {
|
|
162
|
-
ok: true,
|
|
163
|
-
status: DeliveryStatus::StoredOnly,
|
|
164
|
-
message_status: MessageStatusShadow("stored_only".to_string()),
|
|
165
|
-
message_id: Some(persisted.message_id),
|
|
166
|
-
verification: Some("durable_without_live_inject".to_string()),
|
|
167
|
-
stage: None,
|
|
168
|
-
reason: None,
|
|
169
|
-
channel: Some(presentation.effective_sink.as_str().to_string()),
|
|
170
|
-
});
|
|
171
137
|
}
|
|
172
138
|
let outcome = send_to_leader_receiver_with_presentation(
|
|
173
139
|
workspace,
|
|
@@ -201,15 +167,7 @@ pub fn send_message(
|
|
|
201
167
|
MessageTarget::Single(target) => target,
|
|
202
168
|
MessageTarget::Broadcast => {
|
|
203
169
|
let recipients = broadcast_recipients(&state, opts.sender.as_str(), opts.team.as_ref());
|
|
204
|
-
return fanout_send(
|
|
205
|
-
workspace,
|
|
206
|
-
&state,
|
|
207
|
-
&recipients,
|
|
208
|
-
content,
|
|
209
|
-
opts,
|
|
210
|
-
&event_log,
|
|
211
|
-
"*",
|
|
212
|
-
);
|
|
170
|
+
return fanout_send(workspace, &recipients, content, opts, "*");
|
|
213
171
|
}
|
|
214
172
|
MessageTarget::Fanout(recipients) if recipients.is_empty() => {
|
|
215
173
|
// swallow batch 3 ②: a failed send carries its reason (Python send error
|
|
@@ -226,9 +184,7 @@ pub fn send_message(
|
|
|
226
184
|
});
|
|
227
185
|
}
|
|
228
186
|
MessageTarget::Fanout(recipients) => {
|
|
229
|
-
return fanout_send(
|
|
230
|
-
workspace, &state, recipients, content, opts, &event_log, "fanout",
|
|
231
|
-
);
|
|
187
|
+
return fanout_send(workspace, recipients, content, opts, "fanout");
|
|
232
188
|
}
|
|
233
189
|
};
|
|
234
190
|
// send.py:259-261 — a non-leader target that is NOT a known team agent is refused
|
|
@@ -240,6 +196,17 @@ pub fn send_message(
|
|
|
240
196
|
if !in_team {
|
|
241
197
|
return Ok(refused_outcome(DeliveryRefusal::TargetNotInTeam));
|
|
242
198
|
}
|
|
199
|
+
let presentation = decide_presentation(&opts.presentation, PresentationSource::Send);
|
|
200
|
+
if presentation.effective_sink != PresentationSink::Leader {
|
|
201
|
+
return persist_stored_only_send(
|
|
202
|
+
workspace,
|
|
203
|
+
LogicalRecipient::from_resolved(recipient),
|
|
204
|
+
content,
|
|
205
|
+
opts,
|
|
206
|
+
&presentation,
|
|
207
|
+
&event_log,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
243
210
|
if let Some(outcome) = session_drift_refusal(
|
|
244
211
|
&state,
|
|
245
212
|
recipient,
|
|
@@ -354,6 +321,59 @@ pub fn send_message(
|
|
|
354
321
|
})
|
|
355
322
|
}
|
|
356
323
|
|
|
324
|
+
fn persist_stored_only_send(
|
|
325
|
+
workspace: &Path,
|
|
326
|
+
recipient: LogicalRecipient,
|
|
327
|
+
content: &str,
|
|
328
|
+
opts: &SendOptions,
|
|
329
|
+
presentation: &PresentationDecision,
|
|
330
|
+
event_log: &EventLog,
|
|
331
|
+
) -> Result<DeliveryOutcome, MessagingError> {
|
|
332
|
+
let mut intent = ResolvedSendIntent::accepted(
|
|
333
|
+
opts.origin,
|
|
334
|
+
workspace,
|
|
335
|
+
opts.team.clone(),
|
|
336
|
+
recipient,
|
|
337
|
+
opts.sender.clone(),
|
|
338
|
+
opts.task_id.clone(),
|
|
339
|
+
content,
|
|
340
|
+
None,
|
|
341
|
+
opts.requires_ack,
|
|
342
|
+
opts.message_id.clone(),
|
|
343
|
+
);
|
|
344
|
+
intent.initial_disposition = InitialDisposition::StoredOnly;
|
|
345
|
+
intent.presentation = presentation.clone();
|
|
346
|
+
let persisted = match persist_resolved_send(&intent)? {
|
|
347
|
+
PersistResolution::Persisted(persisted) => persisted,
|
|
348
|
+
PersistResolution::Duplicate(message_id) => {
|
|
349
|
+
return Ok(refused_outcome_with_id(
|
|
350
|
+
DeliveryRefusal::Duplicate,
|
|
351
|
+
Some(message_id),
|
|
352
|
+
));
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
event_log.write(
|
|
356
|
+
"presentation.stored_without_live_inject",
|
|
357
|
+
serde_json::json!({
|
|
358
|
+
"message_id": persisted.message_id,
|
|
359
|
+
"requested_sink": presentation.requested_sink,
|
|
360
|
+
"effective_sink": presentation.effective_sink,
|
|
361
|
+
"class": presentation.class,
|
|
362
|
+
"policy_reason": presentation.policy_reason,
|
|
363
|
+
}),
|
|
364
|
+
)?;
|
|
365
|
+
Ok(DeliveryOutcome {
|
|
366
|
+
ok: true,
|
|
367
|
+
status: DeliveryStatus::StoredOnly,
|
|
368
|
+
message_status: MessageStatusShadow("stored_only".to_string()),
|
|
369
|
+
message_id: Some(persisted.message_id),
|
|
370
|
+
verification: Some("durable_without_live_inject".to_string()),
|
|
371
|
+
stage: None,
|
|
372
|
+
reason: None,
|
|
373
|
+
channel: Some(presentation.effective_sink.as_str().to_string()),
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
|
|
357
377
|
fn stale_worker_target_missing_preflight(
|
|
358
378
|
workspace: &Path,
|
|
359
379
|
state: &serde_json::Value,
|
|
@@ -804,11 +824,9 @@ fn broadcast_recipients(
|
|
|
804
824
|
|
|
805
825
|
fn fanout_send(
|
|
806
826
|
workspace: &Path,
|
|
807
|
-
state: &serde_json::Value,
|
|
808
827
|
recipients: &[String],
|
|
809
828
|
content: &str,
|
|
810
829
|
opts: &SendOptions,
|
|
811
|
-
event_log: &EventLog,
|
|
812
830
|
channel_label: &str,
|
|
813
831
|
) -> Result<DeliveryOutcome, MessagingError> {
|
|
814
832
|
let mut last_message_id: Option<String> = None;
|
|
@@ -821,30 +839,17 @@ fn fanout_send(
|
|
|
821
839
|
continue;
|
|
822
840
|
}
|
|
823
841
|
attempted_count = attempted_count.saturating_add(1);
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
)?
|
|
836
|
-
} else {
|
|
837
|
-
// single-recipient re-entry — strip fanout metadata to avoid recursion + ensure
|
|
838
|
-
// each row gets its own caller-supplied message_id (none) so SQLite PK doesn't clash.
|
|
839
|
-
let mut inner_opts = opts.clone();
|
|
840
|
-
inner_opts.message_id = None;
|
|
841
|
-
super::send::send_message(
|
|
842
|
-
workspace,
|
|
843
|
-
&MessageTarget::Single(recipient.clone()),
|
|
844
|
-
content,
|
|
845
|
-
&inner_opts,
|
|
846
|
-
)?
|
|
847
|
-
};
|
|
842
|
+
// Every expanded recipient re-enters the same presentation-aware send
|
|
843
|
+
// funnel. Strip the caller idempotency key so each durable fanout row
|
|
844
|
+
// receives its own stable message id.
|
|
845
|
+
let mut inner_opts = opts.clone();
|
|
846
|
+
inner_opts.message_id = None;
|
|
847
|
+
let outcome = super::send::send_message(
|
|
848
|
+
workspace,
|
|
849
|
+
&MessageTarget::Single(recipient.clone()),
|
|
850
|
+
content,
|
|
851
|
+
&inner_opts,
|
|
852
|
+
)?;
|
|
848
853
|
if outcome.ok {
|
|
849
854
|
delivered_count = delivered_count.saturating_add(1);
|
|
850
855
|
if let Some(mid) = outcome.message_id.clone() {
|
|
@@ -862,8 +867,12 @@ fn fanout_send(
|
|
|
862
867
|
return Ok(outcome);
|
|
863
868
|
}
|
|
864
869
|
}
|
|
870
|
+
let presentation = decide_presentation(&opts.presentation, PresentationSource::Send);
|
|
871
|
+
let stored_only = presentation.effective_sink != PresentationSink::Leader;
|
|
865
872
|
let status = if any_failure {
|
|
866
873
|
DeliveryStatus::FanoutPartial
|
|
874
|
+
} else if delivered_count > 0 && stored_only {
|
|
875
|
+
DeliveryStatus::StoredOnly
|
|
867
876
|
} else if delivered_count > 0 {
|
|
868
877
|
DeliveryStatus::FanoutDelivered
|
|
869
878
|
} else {
|
|
@@ -874,9 +883,13 @@ fn fanout_send(
|
|
|
874
883
|
status,
|
|
875
884
|
message_status: MessageStatusShadow(status_wire(status).to_string()),
|
|
876
885
|
message_id: last_message_id,
|
|
877
|
-
verification:
|
|
886
|
+
verification: stored_only.then(|| "durable_without_live_inject".to_string()),
|
|
878
887
|
stage: None,
|
|
879
888
|
reason: None,
|
|
880
|
-
channel: Some(
|
|
889
|
+
channel: Some(if stored_only {
|
|
890
|
+
presentation.effective_sink.as_str().to_string()
|
|
891
|
+
} else {
|
|
892
|
+
channel_label.to_string()
|
|
893
|
+
}),
|
|
881
894
|
})
|
|
882
895
|
}
|
|
@@ -101,10 +101,10 @@ fn legacy_receiver_without_status_still_rejects_a_foreign_workspace() {
|
|
|
101
101
|
.with_tmux_endpoint(socket)
|
|
102
102
|
.with_targets(vec![observed]);
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
assert!(matches!(
|
|
105
105
|
resolve_live_leader_channel(workspace, &receiver, &transport),
|
|
106
|
-
LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch)
|
|
107
|
-
);
|
|
106
|
+
LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch(_))
|
|
107
|
+
));
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
#[test]
|
|
@@ -162,8 +162,8 @@ fn same_socket_and_pane_in_a_foreign_workspace_is_not_a_live_leader() {
|
|
|
162
162
|
.with_tmux_endpoint(socket)
|
|
163
163
|
.with_targets(vec![observed]);
|
|
164
164
|
|
|
165
|
-
|
|
165
|
+
assert!(matches!(
|
|
166
166
|
resolve_live_leader_channel(workspace, &receiver(socket), &transport),
|
|
167
|
-
LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch)
|
|
168
|
-
);
|
|
167
|
+
LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch(_))
|
|
168
|
+
));
|
|
169
169
|
}
|