@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
@@ -17,17 +17,17 @@ use crate::state::persist::{
17
17
  };
18
18
 
19
19
  // ── REUSE: step 11 messaging delegate surface ───────────────────────────────
20
- use crate::messaging::{self, MessageTarget, SendOptions, TrustedSender};
20
+ use crate::messaging::{self, DeliveryStatus, MessageTarget, SendOptions, TrustedSender};
21
21
 
22
22
  use super::helpers::{
23
23
  current_reportable_message_for, delivery_outcome_value, direct_message_attribution_for,
24
- ensure_object, enum_value, insert_array, is_worker_recipient, json_dumps_default,
25
- latest_task_for_assignee, non_empty_string, normalized_envelope_value, object_fields,
26
- requires_ack_for_target, tool_runtime_error, DirectMessageAttribution,
24
+ ensure_object, enum_value, is_worker_recipient, json_dumps_default, latest_task_for_assignee,
25
+ non_empty_string, object_fields, requires_ack_for_target, tool_runtime_error,
26
+ DirectMessageAttribution,
27
27
  };
28
28
  use super::normalize::{
29
- compact_tool_result, normalize_report_envelope, normalize_result_status_observed,
30
- report_result_integrity_warnings, validate_test_evidence_schema,
29
+ compact_tool_result, normalize_report_envelope, report_result_integrity_warnings,
30
+ validate_test_evidence_schema,
31
31
  };
32
32
  use super::types::{
33
33
  Scope, SendOutcome, ToolError, ToolErrorReason, ToolOk, ToolResult, VisiblePeers,
@@ -116,6 +116,22 @@ impl TeamOrchestratorTools {
116
116
  "ValueError",
117
117
  ));
118
118
  };
119
+ if let Some(route) = task.get("result_route") {
120
+ let Some(route) = route.as_str() else {
121
+ return Err(ToolError::new(
122
+ ToolErrorReason::InvalidToolArguments,
123
+ "assign_task task.result_route must be 'leader' or 'pipeline'",
124
+ "ValueError",
125
+ ));
126
+ };
127
+ if crate::messaging::results::ResultRoute::parse(route).is_none() {
128
+ return Err(ToolError::new(
129
+ ToolErrorReason::InvalidToolArguments,
130
+ format!("assign_task task.result_route has unknown value: {route}"),
131
+ "ValueError",
132
+ ));
133
+ }
134
+ }
119
135
 
120
136
  let task_value = Value::Object(task_obj.clone());
121
137
  let recovery = task_recovery_marker(&task_value);
@@ -185,6 +201,7 @@ impl TeamOrchestratorTools {
185
201
  requires_ack,
186
202
  scope_override,
187
203
  None,
204
+ None,
188
205
  )
189
206
  }
190
207
 
@@ -195,17 +212,20 @@ impl TeamOrchestratorTools {
195
212
  task_id: Option<&str>,
196
213
  requires_ack: Option<bool>,
197
214
  scope_override: Option<Scope>,
215
+ mailbox: Option<&Value>,
198
216
  presentation: Option<&Value>,
199
217
  ) -> Result<SendOutcome, ToolError> {
200
- let (presentation, presentation_error) =
201
- crate::messaging::presentation::normalize_presentation(presentation);
202
- if let Some(error) = presentation_error {
203
- return Err(ToolError::new(
204
- ToolErrorReason::InvalidToolArguments,
205
- format!("invalid presentation: {error}"),
206
- "PresentationError",
207
- ));
208
- }
218
+ let normalized =
219
+ crate::messaging::presentation::normalize_send_presentation(mailbox, presentation)
220
+ .map_err(|error| {
221
+ ToolError::new(
222
+ ToolErrorReason::InvalidToolArguments,
223
+ format!("invalid send routing: {error}"),
224
+ "PresentationError",
225
+ )
226
+ })?;
227
+ let presentation = normalized.request;
228
+ let deprecation = normalized.deprecation;
209
229
  let canonical_owner_team = self.canonical_owner_team_key()?;
210
230
  if matches!(scope_override, Some(Scope::Workspace)) {
211
231
  return Err(self.rpc_scope_refused(
@@ -261,6 +281,21 @@ impl TeamOrchestratorTools {
261
281
  if is_worker_recipient(to) {
262
282
  let out = messaging::send_message(&self.workspace, to, content, &opts)
263
283
  .map_err(tool_runtime_error)?;
284
+ if matches!(out.status, DeliveryStatus::StoredOnly) {
285
+ let value = delivery_outcome_value(&out);
286
+ let mut ok = compact_tool_result(&value)?;
287
+ if let Some(verification) = out.verification.as_deref() {
288
+ ok.fields.insert(
289
+ "verification".to_string(),
290
+ Value::String(verification.to_string()),
291
+ );
292
+ }
293
+ if let Some(deprecation) = deprecation {
294
+ ok.fields
295
+ .insert("warning".to_string(), Value::String(deprecation));
296
+ }
297
+ return Ok(SendOutcome::Direct(ok));
298
+ }
264
299
  // tools.py:175-181 — accepted+poll_via ONLY for a REAL message_id; any other
265
300
  // outcome falls back to the compacted direct result. Never invent an
266
301
  // `mcp_<timestamp>` id: it does not exist in the store and makes the
@@ -276,12 +311,25 @@ impl TeamOrchestratorTools {
276
311
  return Ok(SendOutcome::WorkerAccepted {
277
312
  poll_via: format!("team-agent inbox {message_id}"),
278
313
  message_id,
314
+ warning: deprecation,
279
315
  });
280
316
  }
281
317
  let out = messaging::send_message(&self.workspace, to, content, &opts)
282
318
  .map_err(tool_runtime_error)?;
283
319
  let value = delivery_outcome_value(&out);
284
- let ok = compact_tool_result(&value)?;
320
+ let mut ok = compact_tool_result(&value)?;
321
+ if matches!(out.status, DeliveryStatus::StoredOnly) {
322
+ if let Some(verification) = out.verification.as_deref() {
323
+ ok.fields.insert(
324
+ "verification".to_string(),
325
+ Value::String(verification.to_string()),
326
+ );
327
+ }
328
+ }
329
+ if let Some(deprecation) = deprecation {
330
+ ok.fields
331
+ .insert("warning".to_string(), Value::String(deprecation));
332
+ }
285
333
  Ok(SendOutcome::Direct(ok))
286
334
  }
287
335
 
@@ -352,10 +400,11 @@ impl TeamOrchestratorTools {
352
400
  task_id: Option<&str>,
353
401
  agent_id: Option<&str>,
354
402
  ) -> ToolResult {
403
+ let status = enum_value(status);
355
404
  self.report_result_with_presentation(
356
405
  envelope,
357
406
  summary,
358
- status,
407
+ status.as_str(),
359
408
  changes,
360
409
  tests,
361
410
  risks,
@@ -372,7 +421,7 @@ impl TeamOrchestratorTools {
372
421
  &self,
373
422
  envelope: Option<&Value>,
374
423
  summary: Option<&str>,
375
- status: ResultStatus,
424
+ status: Option<&str>,
376
425
  changes: Option<&[Value]>,
377
426
  tests: Option<&[Value]>,
378
427
  risks: Option<&[Value]>,
@@ -404,7 +453,10 @@ impl TeamOrchestratorTools {
404
453
  );
405
454
  }
406
455
  if !obj.contains_key("status") {
407
- obj.insert("status".to_string(), enum_value(status));
456
+ obj.insert(
457
+ "status".to_string(),
458
+ Value::String(status.unwrap_or("success").to_string()),
459
+ );
408
460
  }
409
461
  if !obj.contains_key("task_id") {
410
462
  // Blocker-1 (prerelease 0.4.0): scoped-team task inference +
@@ -499,29 +551,36 @@ impl TeamOrchestratorTools {
499
551
  obj.insert("agent_id".to_string(), Value::String(resolved));
500
552
  }
501
553
  if !obj.contains_key("changes") {
502
- insert_array(obj, "changes", changes);
554
+ obj.insert(
555
+ "changes".to_string(),
556
+ Value::Array(changes.unwrap_or(&[]).to_vec()),
557
+ );
503
558
  }
504
559
  if !obj.contains_key("tests") {
505
- insert_array(obj, "tests", tests);
560
+ obj.insert(
561
+ "tests".to_string(),
562
+ Value::Array(tests.unwrap_or(&[]).to_vec()),
563
+ );
506
564
  }
507
565
  if !obj.contains_key("risks") {
508
- insert_array(obj, "risks", risks);
566
+ obj.insert(
567
+ "risks".to_string(),
568
+ Value::Array(risks.unwrap_or(&[]).to_vec()),
569
+ );
509
570
  }
510
571
  if !obj.contains_key("artifacts") {
511
- insert_array(obj, "artifacts", artifacts);
572
+ obj.insert(
573
+ "artifacts".to_string(),
574
+ Value::Array(artifacts.unwrap_or(&[]).to_vec()),
575
+ );
512
576
  }
513
577
  if !obj.contains_key("next_actions") {
514
- insert_array(obj, "next_actions", next_actions);
578
+ obj.insert(
579
+ "next_actions".to_string(),
580
+ Value::Array(next_actions.unwrap_or(&[]).to_vec()),
581
+ );
515
582
  }
516
583
  }
517
- // T3-1 cr verdict (refined): an unknown non-empty status literal normalizes to
518
- // Partial and must be OBSERVABLE at this ingestion boundary (the envelope-borne
519
- // path; the wire `status` arg path emits at dispatch). Never a silent swallow.
520
- if let Some(raw) =
521
- normalize_result_status_observed(base.get("status").and_then(Value::as_str)).1
522
- {
523
- self.note_unknown_result_status(&raw);
524
- }
525
584
  if let Err(error) = validate_test_evidence_schema(base.get("tests")) {
526
585
  return Err(ToolError::new(
527
586
  ToolErrorReason::InvalidToolArguments,
@@ -538,8 +597,15 @@ impl TeamOrchestratorTools {
538
597
  ));
539
598
  }
540
599
  let warnings = report_result_integrity_warnings(&base, &normalized);
541
- let mut env_value = normalized_envelope_value(&normalized);
542
- copy_report_attribution_fields(&base, &mut env_value);
600
+ let mut env_value = base;
601
+ if let Some(obj) = env_value.as_object_mut() {
602
+ obj.entry("schema_version")
603
+ .or_insert_with(|| Value::String("result_envelope_v1".to_string()));
604
+ obj.insert(
605
+ "presentation".to_string(),
606
+ serde_json::to_value(&normalized.presentation).unwrap_or(Value::Null),
607
+ );
608
+ }
543
609
  if !warnings.is_empty() {
544
610
  if let Some(obj) = env_value.as_object_mut() {
545
611
  obj.insert("warnings".to_string(), Value::Array(warnings));
@@ -555,20 +621,6 @@ impl TeamOrchestratorTools {
555
621
  .and_then(|value| compact_tool_result(&value))
556
622
  }
557
623
 
558
- /// T3-1 cr verdict: the observable record of an unknown→Partial status
559
- /// normalization (`provider.result.unknown_status_normalized`, raw literal
560
- /// included) — MUST-NOT-13: the swallow is never silent.
561
- pub(crate) fn note_unknown_result_status(&self, raw: &str) {
562
- let _ = EventLog::new(&self.workspace).write(
563
- "provider.result.unknown_status_normalized",
564
- serde_json::json!({
565
- "agent_id": self.agent_id.as_ref().map(AgentId::as_str),
566
- "raw_status": raw,
567
- "normalized": "partial",
568
- }),
569
- );
570
- }
571
-
572
624
  /// `update_state` (`tools.py:316-325`): delegated through the lifecycle tools
573
625
  /// facade. S0 preserves the old placeholder behavior.
574
626
  pub fn update_state(&self, note: &str) -> ToolResult {
@@ -1179,24 +1231,6 @@ fn merge_object_fields(existing: &mut Value, incoming: &Value) {
1179
1231
  }
1180
1232
  }
1181
1233
 
1182
- fn copy_report_attribution_fields(source: &Value, target: &mut Value) {
1183
- let Some(src) = source.as_object() else {
1184
- return;
1185
- };
1186
- let Some(dst) = target.as_object_mut() else {
1187
- return;
1188
- };
1189
- for key in [
1190
- "attributed_message_id",
1191
- "attribution_scope",
1192
- "task_id_source",
1193
- ] {
1194
- if let Some(value) = src.get(key) {
1195
- dst.entry(key.to_string()).or_insert(value.clone());
1196
- }
1197
- }
1198
- }
1199
-
1200
1234
  fn push_report_warning(obj: &mut serde_json::Map<String, Value>, warning: Value) {
1201
1235
  let Some(code) = warning.get("code").and_then(Value::as_str) else {
1202
1236
  return;
@@ -322,6 +322,7 @@ pub enum SendOutcome {
322
322
  message_id: String,
323
323
  /// Byte-stable `"team-agent inbox <message_id>"`.
324
324
  poll_via: String,
325
+ warning: Option<String>,
325
326
  },
326
327
  /// Compacted delegate result (leader / `*` / broadcast / fanout).
327
328
  Direct(ToolOk),
@@ -335,12 +336,16 @@ impl SendOutcome {
335
336
  SendOutcome::WorkerAccepted {
336
337
  message_id,
337
338
  poll_via,
339
+ warning,
338
340
  } => {
339
341
  let mut obj = serde_json::Map::new();
340
342
  obj.insert("status".to_string(), Value::String("accepted".to_string()));
341
343
  obj.insert("delivery_pending".to_string(), Value::Bool(true));
342
344
  obj.insert("poll_via".to_string(), Value::String(poll_via.clone()));
343
345
  obj.insert("message_id".to_string(), Value::String(message_id.clone()));
346
+ if let Some(warning) = warning {
347
+ obj.insert("warning".to_string(), Value::String(warning.clone()));
348
+ }
344
349
  Value::Object(obj)
345
350
  }
346
351
  SendOutcome::Direct(ok) => Value::Object(ok.fields.clone()),
@@ -9,7 +9,6 @@ use crate::event_log::EventLog;
9
9
  use crate::messaging::MessageTarget;
10
10
 
11
11
  use super::helpers::{json_dumps_default, object_fields};
12
- use super::normalize::normalize_result_status;
13
12
  use super::tools::TeamOrchestratorTools;
14
13
  use super::types::{
15
14
  McpError, McpTool, RpcError, RpcId, RpcMethod, RpcResponse, Scope, SendOutcome,
@@ -369,7 +368,7 @@ fn python_repr(value: &str) -> String {
369
368
  fn tool_contract(tool: McpTool) -> Value {
370
369
  let (description, required) = match tool {
371
370
  McpTool::SendMessage => (
372
- "Send a message to a teammate, the leader, or '*' for all other team members. Team Agent fills identity and delivery metadata; optional presentation routing is durable and never drops the message.",
371
+ "Send a message to a teammate, the leader, or '*' for all other team members. mailbox=true stores durably without live injection; the default is live delivery.",
373
372
  vec!["to", "content"],
374
373
  ),
375
374
  McpTool::AssignTask => ("Assign or update a task in the team graph and deliver it to its assignee.", vec!["task"]),
@@ -404,11 +403,7 @@ fn tool_properties(tool: McpTool) -> serde_json::Map<String, Value> {
404
403
  let mut properties = serde_json::Map::new();
405
404
  match tool {
406
405
  McpTool::AssignTask => {
407
- insert_property(
408
- &mut properties,
409
- "task",
410
- object_property("Task object to add or update."),
411
- );
406
+ insert_property(&mut properties, "task", task_property());
412
407
  insert_property(
413
408
  &mut properties,
414
409
  "message",
@@ -424,8 +419,10 @@ fn tool_properties(tool: McpTool) -> serde_json::Map<String, Value> {
424
419
  insert_property(&mut properties, "content", string_property("Message body."));
425
420
  insert_property(
426
421
  &mut properties,
427
- "presentation",
428
- presentation_property("Optional durable presentation routing."),
422
+ "mailbox",
423
+ boolean_property(
424
+ "Set true to store durably without live injection; omit for default live delivery.",
425
+ ),
429
426
  );
430
427
  }
431
428
  McpTool::ReportResult => {
@@ -603,6 +600,25 @@ fn object_property(description: &str) -> Value {
603
600
  serde_json::json!({"type": "object", "description": description, "additionalProperties": true})
604
601
  }
605
602
 
603
+ fn task_property() -> Value {
604
+ serde_json::json!({
605
+ "type": "object",
606
+ "description": "Task object to add or update.",
607
+ "properties": {
608
+ "result_route": {
609
+ "type": "string",
610
+ "enum": crate::messaging::results::ResultRoute::ALL
611
+ .iter()
612
+ .map(|route| route.as_str())
613
+ .collect::<Vec<_>>(),
614
+ "default": crate::messaging::results::ResultRoute::default().as_str(),
615
+ "description": "Result destination chosen by the task assigner."
616
+ }
617
+ },
618
+ "additionalProperties": true
619
+ })
620
+ }
621
+
606
622
  fn presentation_property(description: &str) -> Value {
607
623
  serde_json::json!({
608
624
  "type": "object",
@@ -646,6 +662,7 @@ pub(crate) fn dispatch_tool(
646
662
  None,
647
663
  None,
648
664
  None,
665
+ args.get("mailbox"),
649
666
  args.get("presentation"),
650
667
  )?;
651
668
  match outcome {
@@ -658,18 +675,7 @@ pub(crate) fn dispatch_tool(
658
675
  McpTool::ReportResult => tools.report_result_with_presentation(
659
676
  args.get("envelope"),
660
677
  args.get("summary").and_then(Value::as_str),
661
- // cr verdict (T3-1 refined): an unknown status literal normalizes to
662
- // Partial and is OBSERVABLE at this ingestion boundary, never silent.
663
- {
664
- let (status, unknown) =
665
- crate::mcp_server::normalize::normalize_result_status_observed(
666
- args.get("status").and_then(Value::as_str),
667
- );
668
- if let Some(raw) = unknown {
669
- tools.note_unknown_result_status(&raw);
670
- }
671
- status
672
- },
678
+ args.get("status").and_then(Value::as_str),
673
679
  args.get("changes")
674
680
  .and_then(Value::as_array)
675
681
  .map(Vec::as_slice),
@@ -220,7 +220,12 @@ pub fn deliver_pending_message(
220
220
  }
221
221
  _ => state,
222
222
  };
223
- if message.recipient == "leader" && message.status == "submitted_pending_acceptance" {
223
+ if message.recipient == "leader"
224
+ && matches!(
225
+ message.status.as_str(),
226
+ "submitted_pending_acceptance" | "injected_awaiting_receipt"
227
+ )
228
+ {
224
229
  return observe_pending_leader_acceptance(store, event_log, state, message_id);
225
230
  }
226
231
  if message.recipient == "leader" && message.status == "queued_until_leader_attach" {
@@ -341,6 +346,12 @@ pub fn deliver_pending_message(
341
346
  .get("status")
342
347
  .and_then(serde_json::Value::as_str)
343
348
  .unwrap_or("unbound");
349
+ let refusal = match &reason {
350
+ crate::messaging::LeaderChannelUnbound::PaneWorkspaceMismatch(_) => {
351
+ DeliveryRefusal::PaneWorkspaceMismatch
352
+ }
353
+ _ => DeliveryRefusal::LeaderNotAttached,
354
+ };
344
355
  store.mark(message_id, "failed", Some("leader_not_attached"))?;
345
356
  event_log.write(
346
357
  "leader_receiver.delivery_blocked",
@@ -349,7 +360,7 @@ pub fn deliver_pending_message(
349
360
  "sender": message.sender,
350
361
  "reason": "leader_receiver_not_attached",
351
362
  "error": "leader_not_attached",
352
- "channel_reason": format!("{reason:?}"),
363
+ "channel_reason": reason.reason_code(),
353
364
  "channel": "rebind_required",
354
365
  "action": "run team-agent attach-leader or team-agent takeover",
355
366
  "status": receiver_status,
@@ -364,7 +375,7 @@ pub fn deliver_pending_message(
364
375
  "run team-agent attach-leader or team-agent takeover".to_string(),
365
376
  ),
366
377
  stage: None,
367
- reason: Some(DeliveryRefusal::LeaderNotAttached),
378
+ reason: Some(refusal),
368
379
  channel: Some("rebind_required".to_string()),
369
380
  });
370
381
  }
@@ -711,19 +722,30 @@ pub fn deliver_pending_message(
711
722
  }
712
723
  if is_leader_recipient {
713
724
  store.record_delivery_submission(message_id, readback_verified)?;
714
- if !leader_transcript_has_token(state, message_id) {
715
- store.mark(message_id, "submitted_pending_acceptance", None)?;
716
- event_log.write(
717
- "leader_receiver.acceptance_pending",
718
- serde_json::json!({
719
- "message_id": message_id,
720
- "reason": "provider_receipt_not_observed",
721
- }),
722
- )?;
723
- return Ok(leader_acceptance_pending_outcome(
724
- message_id,
725
- "submitted_pending_acceptance",
726
- ));
725
+ match observe_leader_receipt(state, message_id) {
726
+ LeaderReceiptObservation::SourceUnavailable => {
727
+ store.mark_injected_awaiting_receipt(message_id)?;
728
+ event_log.write(
729
+ "leader_receiver.receipt_source_unavailable",
730
+ serde_json::json!({"message_id": message_id}),
731
+ )?;
732
+ return Ok(leader_receipt_source_unavailable_outcome(message_id));
733
+ }
734
+ LeaderReceiptObservation::TokenAbsent => {
735
+ store.mark(message_id, "submitted_pending_acceptance", None)?;
736
+ event_log.write(
737
+ "leader_receiver.acceptance_pending",
738
+ serde_json::json!({
739
+ "message_id": message_id,
740
+ "reason": "provider_receipt_not_observed",
741
+ }),
742
+ )?;
743
+ return Ok(leader_acceptance_pending_outcome(
744
+ message_id,
745
+ "submitted_pending_acceptance",
746
+ ));
747
+ }
748
+ LeaderReceiptObservation::TokenObserved => {}
727
749
  }
728
750
  store.mark_delivered_with_receipt(message_id)?;
729
751
  } else {
@@ -2041,15 +2063,36 @@ fn leader_rollout_path(state: &serde_json::Value) -> Option<std::path::PathBuf>
2041
2063
  .map(std::path::PathBuf::from)
2042
2064
  }
2043
2065
 
2044
- pub(crate) fn leader_transcript_has_token(state: &serde_json::Value, message_id: &str) -> bool {
2066
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2067
+ pub(crate) enum LeaderReceiptObservation {
2068
+ SourceUnavailable,
2069
+ TokenAbsent,
2070
+ TokenObserved,
2071
+ }
2072
+
2073
+ pub(crate) fn observe_leader_receipt(
2074
+ state: &serde_json::Value,
2075
+ message_id: &str,
2076
+ ) -> LeaderReceiptObservation {
2045
2077
  let Some(path) = leader_rollout_path(state) else {
2046
- return false;
2078
+ return LeaderReceiptObservation::SourceUnavailable;
2047
2079
  };
2048
- rollout_tail_contains(
2080
+ let Ok(observed) = rollout_tail_contains_result(
2049
2081
  &path,
2050
2082
  &format!("[team-agent-token:{message_id}]"),
2051
2083
  64 * 1024,
2052
- )
2084
+ ) else {
2085
+ return LeaderReceiptObservation::SourceUnavailable;
2086
+ };
2087
+ if observed {
2088
+ LeaderReceiptObservation::TokenObserved
2089
+ } else {
2090
+ LeaderReceiptObservation::TokenAbsent
2091
+ }
2092
+ }
2093
+
2094
+ pub(crate) fn leader_transcript_has_token(state: &serde_json::Value, message_id: &str) -> bool {
2095
+ observe_leader_receipt(state, message_id) == LeaderReceiptObservation::TokenObserved
2053
2096
  }
2054
2097
 
2055
2098
  fn leader_acceptance_pending_outcome(message_id: &str, status: &str) -> DeliveryOutcome {
@@ -2065,17 +2108,41 @@ fn leader_acceptance_pending_outcome(message_id: &str, status: &str) -> Delivery
2065
2108
  }
2066
2109
  }
2067
2110
 
2111
+ pub(crate) fn leader_receipt_source_unavailable_outcome(message_id: &str) -> DeliveryOutcome {
2112
+ DeliveryOutcome {
2113
+ ok: true,
2114
+ status: DeliveryStatus::Blocked,
2115
+ message_status: MessageStatusShadow("injected_awaiting_receipt".to_string()),
2116
+ message_id: Some(message_id.to_string()),
2117
+ verification: Some("leader_receipt_source_unavailable".to_string()),
2118
+ stage: Some(DeliveryStage::Submit),
2119
+ reason: None,
2120
+ channel: Some("leader_receipt_source_unavailable".to_string()),
2121
+ }
2122
+ }
2123
+
2068
2124
  fn observe_pending_leader_acceptance(
2069
2125
  store: &MessageStore,
2070
2126
  event_log: &EventLog,
2071
2127
  state: &serde_json::Value,
2072
2128
  message_id: &str,
2073
2129
  ) -> Result<DeliveryOutcome, MessagingError> {
2074
- if !leader_transcript_has_token(state, message_id) {
2075
- return Ok(leader_acceptance_pending_outcome(
2076
- message_id,
2077
- "submitted_pending_acceptance",
2078
- ));
2130
+ match observe_leader_receipt(state, message_id) {
2131
+ LeaderReceiptObservation::SourceUnavailable => {
2132
+ store.mark_injected_awaiting_receipt(message_id)?;
2133
+ event_log.write(
2134
+ "leader_receiver.receipt_source_unavailable",
2135
+ serde_json::json!({"message_id": message_id}),
2136
+ )?;
2137
+ return Ok(leader_receipt_source_unavailable_outcome(message_id));
2138
+ }
2139
+ LeaderReceiptObservation::TokenAbsent => {
2140
+ return Ok(leader_acceptance_pending_outcome(
2141
+ message_id,
2142
+ "submitted_pending_acceptance",
2143
+ ));
2144
+ }
2145
+ LeaderReceiptObservation::TokenObserved => {}
2079
2146
  }
2080
2147
  store.mark_delivered_with_receipt(message_id)?;
2081
2148
  event_log.write(
@@ -2744,22 +2811,22 @@ fn claude_recipient_rollout(
2744
2811
  /// Silent on read errors — callers treat missing/unreadable as "token not
2745
2812
  /// present" which forces the unverified path.
2746
2813
  fn rollout_tail_contains(path: &std::path::Path, needle: &str, tail_bytes: u64) -> bool {
2814
+ rollout_tail_contains_result(path, needle, tail_bytes).unwrap_or(false)
2815
+ }
2816
+
2817
+ fn rollout_tail_contains_result(
2818
+ path: &std::path::Path,
2819
+ needle: &str,
2820
+ tail_bytes: u64,
2821
+ ) -> std::io::Result<bool> {
2747
2822
  use std::io::{Read, Seek, SeekFrom};
2748
- let Ok(mut file) = std::fs::File::open(path) else {
2749
- return false;
2750
- };
2751
- let Ok(metadata) = file.metadata() else {
2752
- return false;
2753
- };
2823
+ let mut file = std::fs::File::open(path)?;
2824
+ let metadata = file.metadata()?;
2754
2825
  let len = metadata.len();
2755
2826
  let start = len.saturating_sub(tail_bytes);
2756
- if file.seek(SeekFrom::Start(start)).is_err() {
2757
- return false;
2758
- }
2827
+ file.seek(SeekFrom::Start(start))?;
2759
2828
  let mut buf = Vec::with_capacity(tail_bytes.min(len) as usize);
2760
- if file.take(tail_bytes).read_to_end(&mut buf).is_err() {
2761
- return false;
2762
- }
2829
+ file.take(tail_bytes).read_to_end(&mut buf)?;
2763
2830
  let haystack = String::from_utf8_lossy(&buf);
2764
- haystack.contains(needle)
2831
+ Ok(haystack.contains(needle))
2765
2832
  }