@team-agent/installer 0.5.60 → 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.
Files changed (54) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +44 -1
  4. package/crates/team-agent/src/cli/diagnose.rs +183 -0
  5. package/crates/team-agent/src/cli/emit.rs +63 -14
  6. package/crates/team-agent/src/cli/leader.rs +8 -1
  7. package/crates/team-agent/src/cli/mod.rs +263 -5
  8. package/crates/team-agent/src/cli/named_address.rs +18 -1
  9. package/crates/team-agent/src/cli/send/presentation.rs +2 -0
  10. package/crates/team-agent/src/cli/spec.rs +4 -1
  11. package/crates/team-agent/src/cli/status_port/store.rs +7 -3
  12. package/crates/team-agent/src/cli/tests/named_address.rs +65 -0
  13. package/crates/team-agent/src/cli/types.rs +33 -0
  14. package/crates/team-agent/src/communication_mode/mod.rs +53 -0
  15. package/crates/team-agent/src/compiler/tests.rs +5 -5
  16. package/crates/team-agent/src/compiler.rs +53 -1
  17. package/crates/team-agent/src/db/message_store.rs +45 -1
  18. package/crates/team-agent/src/fake_worker.rs +21 -1
  19. package/crates/team-agent/src/leader/start.rs +732 -94
  20. package/crates/team-agent/src/leader/tests/identity.rs +64 -57
  21. package/crates/team-agent/src/leader/tests/identity_session_names.rs +42 -0
  22. package/crates/team-agent/src/lib.rs +4 -0
  23. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +1 -1
  24. package/crates/team-agent/src/lifecycle/launch/spawn.rs +1 -1
  25. package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
  26. package/crates/team-agent/src/lifecycle/restart/common.rs +1 -1
  27. package/crates/team-agent/src/lifecycle/worker_command_context.rs +29 -11
  28. package/crates/team-agent/src/mcp_server/normalize.rs +1 -0
  29. package/crates/team-agent/src/mcp_server/tests/send.rs +26 -0
  30. package/crates/team-agent/src/mcp_server/tests/wire.rs +1 -1
  31. package/crates/team-agent/src/mcp_server/tools.rs +100 -66
  32. package/crates/team-agent/src/mcp_server/types.rs +5 -0
  33. package/crates/team-agent/src/mcp_server/wire.rs +27 -21
  34. package/crates/team-agent/src/messaging/delivery.rs +105 -38
  35. package/crates/team-agent/src/messaging/leader_channel.rs +33 -10
  36. package/crates/team-agent/src/messaging/leader_receiver.rs +40 -23
  37. package/crates/team-agent/src/messaging/presentation.rs +109 -0
  38. package/crates/team-agent/src/messaging/results.rs +165 -17
  39. package/crates/team-agent/src/messaging/send.rs +94 -81
  40. package/crates/team-agent/src/messaging/tests/leader_channel.rs +6 -6
  41. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +187 -0
  42. package/crates/team-agent/src/messaging/tests/runtime.rs +13 -6
  43. package/crates/team-agent/src/messaging/types.rs +5 -0
  44. package/crates/team-agent/src/messaging/watchers.rs +5 -0
  45. package/crates/team-agent/src/model/mod.rs +1 -0
  46. package/crates/team-agent/src/model/pane_authority_refusal.rs +358 -0
  47. package/crates/team-agent/src/model/spec.rs +16 -0
  48. package/crates/team-agent/src/provider/session/capture.rs +40 -18
  49. package/crates/team-agent/src/provider/session_scan/claude.rs +92 -3
  50. package/crates/team-agent/src/provider/session_scan/common.rs +23 -7
  51. package/crates/team-agent/src/provider/session_scan.rs +5 -0
  52. package/crates/team-agent/src/topology.rs +3 -0
  53. package/package.json +4 -4
  54. package/skills/team-agent/command-coverage.json +379 -0
@@ -3,6 +3,7 @@ use std::path::Path;
3
3
  use serde_json::Value;
4
4
 
5
5
  use crate::codex_app_server::AppServerBinding;
6
+ use crate::model::pane_authority_refusal::PaneWorkspaceMismatchFacts;
6
7
  use crate::transport::{PaneInfo, Transport};
7
8
 
8
9
  #[derive(Debug, Clone, PartialEq, Eq)]
@@ -26,7 +27,7 @@ pub enum LeaderChannelResolution {
26
27
  ProbeFailed(String),
27
28
  }
28
29
 
29
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
30
+ #[derive(Debug, Clone, PartialEq, Eq)]
30
31
  pub enum LeaderChannelUnbound {
31
32
  ReceiverNotAttached,
32
33
  TransportConflict,
@@ -34,10 +35,25 @@ pub enum LeaderChannelUnbound {
34
35
  NonCanonicalTmuxSocket,
35
36
  EndpointMismatch,
36
37
  PaneNotLive,
37
- PaneWorkspaceMismatch,
38
+ PaneWorkspaceMismatch(PaneWorkspaceMismatchFacts),
38
39
  AppServerBindingInvalid,
39
40
  }
40
41
 
42
+ impl LeaderChannelUnbound {
43
+ pub const fn reason_code(&self) -> &'static str {
44
+ match self {
45
+ Self::ReceiverNotAttached => "ReceiverNotAttached",
46
+ Self::TransportConflict => "TransportConflict",
47
+ Self::MissingPaneId => "MissingPaneId",
48
+ Self::NonCanonicalTmuxSocket => "NonCanonicalTmuxSocket",
49
+ Self::EndpointMismatch => "EndpointMismatch",
50
+ Self::PaneNotLive => "PaneNotLive",
51
+ Self::PaneWorkspaceMismatch(_) => "PaneWorkspaceMismatch",
52
+ Self::AppServerBindingInvalid => "AppServerBindingInvalid",
53
+ }
54
+ }
55
+ }
56
+
41
57
  /// Resolve the canonical receiver's live physical channel without mutating
42
58
  /// ownership. For direct tmux, `(absolute socket, pane id)` plus an available
43
59
  /// pane cwd inside the target workspace is the authority. Session/window/tty/
@@ -79,11 +95,12 @@ pub fn resolve_live_leader_channel(
79
95
  .get("tmux_socket")
80
96
  .and_then(Value::as_str)
81
97
  .filter(|socket| !socket.is_empty());
98
+ let transport_endpoint = transport.tmux_endpoint();
82
99
  if tmux_socket.is_some_and(|socket| !std::path::Path::new(socket).is_absolute()) {
83
100
  return LeaderChannelResolution::Unbound(LeaderChannelUnbound::NonCanonicalTmuxSocket);
84
101
  }
85
102
  if let Some(expected) = tmux_socket {
86
- if transport.tmux_endpoint().as_deref() != Some(expected) {
103
+ if transport_endpoint.as_deref() != Some(expected) {
87
104
  return LeaderChannelResolution::Unbound(LeaderChannelUnbound::EndpointMismatch);
88
105
  }
89
106
  }
@@ -97,13 +114,19 @@ pub fn resolve_live_leader_channel(
97
114
  else {
98
115
  return LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneNotLive);
99
116
  };
100
- if observed
101
- .current_path
102
- .as_deref()
103
- .is_some_and(|path| !path_is_in_workspace(path, workspace))
104
- && !explicit_claim_authority_matches(workspace, receiver, &observed)
105
- {
106
- return LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch);
117
+ if let Some(observed_pane_workspace) = observed.current_path.clone() {
118
+ if !path_is_in_workspace(&observed_pane_workspace, workspace)
119
+ && !explicit_claim_authority_matches(workspace, receiver, &observed)
120
+ {
121
+ return LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch(
122
+ PaneWorkspaceMismatchFacts {
123
+ requested_workspace: canonical_path(workspace),
124
+ observed_pane_id: observed.pane_id.as_str().to_string(),
125
+ observed_pane_workspace,
126
+ endpoint: transport_endpoint.unwrap_or_else(|| "default".to_string()),
127
+ },
128
+ ));
129
+ }
107
130
  }
108
131
  let metadata_drift = receiver_metadata_drift(receiver, &observed);
109
132
  LeaderChannelResolution::Live(LiveLeaderChannel::DirectTmux(DirectTmuxLeaderChannel {
@@ -397,29 +397,46 @@ pub fn deliver_to_leader_fallback_pane(
397
397
  let readback_ok = super::delivery::pane_readback_verified(&report);
398
398
  if submit_ok {
399
399
  store.record_delivery_submission(message_id, readback_ok)?;
400
- if !super::delivery::leader_transcript_has_token(state, message_id) {
401
- store.mark(message_id, "submitted_pending_acceptance", None)?;
402
- event_log.write(
403
- "leader_receiver.acceptance_pending",
404
- serde_json::json!({
405
- "message_id": message_id,
406
- "result_id": result_id,
407
- "reason": "provider_receipt_not_observed",
408
- "channel": "fallback_pane",
409
- }),
410
- )?;
411
- return Ok(DeliveryOutcome {
412
- ok: true,
413
- status: DeliveryStatus::RetryScheduled,
414
- message_status: MessageStatusShadow(
415
- "submitted_pending_acceptance".to_string(),
416
- ),
417
- message_id: Some(message_id.to_string()),
418
- verification: Some("provider_receipt_not_observed".to_string()),
419
- stage: Some(DeliveryStage::Submit),
420
- reason: None,
421
- channel: Some("leader_acceptance_pending".to_string()),
422
- });
400
+ match super::delivery::observe_leader_receipt(state, message_id) {
401
+ super::delivery::LeaderReceiptObservation::SourceUnavailable => {
402
+ store.mark_injected_awaiting_receipt(message_id)?;
403
+ event_log.write(
404
+ "leader_receiver.receipt_source_unavailable",
405
+ serde_json::json!({
406
+ "message_id": message_id,
407
+ "result_id": result_id,
408
+ "channel": "fallback_pane",
409
+ }),
410
+ )?;
411
+ return Ok(super::delivery::leader_receipt_source_unavailable_outcome(
412
+ message_id,
413
+ ));
414
+ }
415
+ super::delivery::LeaderReceiptObservation::TokenAbsent => {
416
+ store.mark(message_id, "submitted_pending_acceptance", None)?;
417
+ event_log.write(
418
+ "leader_receiver.acceptance_pending",
419
+ serde_json::json!({
420
+ "message_id": message_id,
421
+ "result_id": result_id,
422
+ "reason": "provider_receipt_not_observed",
423
+ "channel": "fallback_pane",
424
+ }),
425
+ )?;
426
+ return Ok(DeliveryOutcome {
427
+ ok: true,
428
+ status: DeliveryStatus::RetryScheduled,
429
+ message_status: MessageStatusShadow(
430
+ "submitted_pending_acceptance".to_string(),
431
+ ),
432
+ message_id: Some(message_id.to_string()),
433
+ verification: Some("provider_receipt_not_observed".to_string()),
434
+ stage: Some(DeliveryStage::Submit),
435
+ reason: None,
436
+ channel: Some("leader_acceptance_pending".to_string()),
437
+ });
438
+ }
439
+ super::delivery::LeaderReceiptObservation::TokenObserved => {}
423
440
  }
424
441
  store.mark_delivered_with_receipt(message_id)?;
425
442
  event_log.write(
@@ -98,6 +98,12 @@ pub struct PresentationDecision {
98
98
  pub policy_version: String,
99
99
  }
100
100
 
101
+ #[derive(Debug, Clone, PartialEq, Eq)]
102
+ pub struct SendPresentationInput {
103
+ pub request: PresentationRequest,
104
+ pub deprecation: Option<String>,
105
+ }
106
+
101
107
  impl Default for PresentationRequest {
102
108
  fn default() -> Self {
103
109
  Self {
@@ -177,6 +183,79 @@ pub fn normalize_report_presentation(
177
183
  (request, None)
178
184
  }
179
185
 
186
+ /// Normalize the one-bit send surface and the temporary S-011 compatibility
187
+ /// input into the existing presentation disposition primitive.
188
+ ///
189
+ /// S-011 sensitive: if the compatibility period is ended by a hard-cut
190
+ /// decision, replace this table with one uniform legacy-input refusal.
191
+ pub fn normalize_send_presentation(
192
+ mailbox: Option<&Value>,
193
+ legacy_presentation: Option<&Value>,
194
+ ) -> Result<SendPresentationInput, String> {
195
+ if mailbox.is_some() && legacy_presentation.is_some() {
196
+ return Err("mailbox_conflicts_with_deprecated_presentation".to_string());
197
+ }
198
+ if let Some(mailbox) = mailbox {
199
+ let Some(mailbox) = mailbox.as_bool() else {
200
+ return Err("mailbox_must_be_boolean".to_string());
201
+ };
202
+ return Ok(SendPresentationInput {
203
+ request: PresentationRequest {
204
+ sink: if mailbox {
205
+ PresentationSink::Casefile
206
+ } else {
207
+ PresentationSink::Leader
208
+ },
209
+ class: PresentationClass::Message,
210
+ case_id: None,
211
+ },
212
+ deprecation: None,
213
+ });
214
+ }
215
+ let Some(legacy) = legacy_presentation else {
216
+ return Ok(SendPresentationInput {
217
+ request: PresentationRequest::default(),
218
+ deprecation: None,
219
+ });
220
+ };
221
+ let Some(object) = legacy.as_object() else {
222
+ return Err("malformed_presentation".to_string());
223
+ };
224
+ let Some(class) = object.get("class").and_then(Value::as_str) else {
225
+ return Err("missing_class".to_string());
226
+ };
227
+ let Some(class) = PresentationClass::parse(class) else {
228
+ return Err(format!("unknown_class:{class}"));
229
+ };
230
+ let sink = match class {
231
+ PresentationClass::Message | PresentationClass::Blocking | PresentationClass::Timeout => {
232
+ PresentationSink::Leader
233
+ }
234
+ PresentationClass::Progress => PresentationSink::Casefile,
235
+ PresentationClass::StageResult
236
+ | PresentationClass::StagePass
237
+ | PresentationClass::Bounce
238
+ | PresentationClass::FinalReview => {
239
+ return Err(format!(
240
+ "deprecated_message_class:{}_requires_result_route",
241
+ class.as_str()
242
+ ));
243
+ }
244
+ };
245
+ Ok(SendPresentationInput {
246
+ request: PresentationRequest {
247
+ sink,
248
+ class: PresentationClass::Message,
249
+ case_id: None,
250
+ },
251
+ deprecation: Some(format!(
252
+ "message-class={} is deprecated; use mailbox={} instead",
253
+ class.as_str(),
254
+ sink != PresentationSink::Leader
255
+ )),
256
+ })
257
+ }
258
+
180
259
  pub fn normalize_presentation(value: Option<&Value>) -> (PresentationRequest, Option<String>) {
181
260
  let Some(value) = value else {
182
261
  return (PresentationRequest::default(), None);
@@ -304,4 +383,34 @@ mod tests {
304
383
  "send normalization remains unchanged"
305
384
  );
306
385
  }
386
+
387
+ #[test]
388
+ fn send_mailbox_and_s011_compatibility_map_to_one_bit() {
389
+ let mailbox = normalize_send_presentation(Some(&json!(true)), None).unwrap();
390
+ assert_eq!(mailbox.request.sink, PresentationSink::Casefile);
391
+ assert_eq!(mailbox.deprecation, None);
392
+
393
+ for (class, sink) in [
394
+ ("message", PresentationSink::Leader),
395
+ ("progress", PresentationSink::Casefile),
396
+ ("blocking", PresentationSink::Leader),
397
+ ("timeout", PresentationSink::Leader),
398
+ ] {
399
+ let legacy = json!({"sink": "silent", "class": class});
400
+ let mapped = normalize_send_presentation(None, Some(&legacy)).unwrap();
401
+ assert_eq!(mapped.request.sink, sink, "{class}");
402
+ assert!(mapped.deprecation.is_some(), "{class}");
403
+ }
404
+ }
405
+
406
+ #[test]
407
+ fn s011_stage_classes_require_result_route() {
408
+ for class in ["stage_result", "stage_pass", "bounce", "final_review"] {
409
+ let legacy = json!({"sink": "leader", "class": class});
410
+ assert_eq!(
411
+ normalize_send_presentation(None, Some(&legacy)).unwrap_err(),
412
+ format!("deprecated_message_class:{class}_requires_result_route")
413
+ );
414
+ }
415
+ }
307
416
  }
@@ -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
+ &current_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
- validate_result_envelope(envelope)?;
631
- let (presentation_request, presentation_error) =
632
- super::presentation::normalize_report_presentation(envelope.get("presentation"));
633
- if let Some(error) = presentation_error {
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
- let presentation = super::presentation::decide_presentation(
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>,