@team-agent/installer 0.5.49 → 0.5.51

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 (90) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +6 -4
  4. package/crates/team-agent/src/cli/emit.rs +91 -39
  5. package/crates/team-agent/src/cli/mod.rs +33 -15
  6. package/crates/team-agent/src/cli/named_address.rs +82 -53
  7. package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
  8. package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
  9. package/crates/team-agent/src/cli/send/persist.rs +154 -0
  10. package/crates/team-agent/src/cli/send/presentation.rs +333 -0
  11. package/crates/team-agent/src/cli/send/resolve.rs +361 -0
  12. package/crates/team-agent/src/cli/send.rs +103 -1308
  13. package/crates/team-agent/src/cli/spec.rs +2 -2
  14. package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
  15. package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
  16. package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
  17. package/crates/team-agent/src/cli/status_port/format.rs +145 -0
  18. package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
  19. package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
  20. package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
  21. package/crates/team-agent/src/cli/status_port/store.rs +412 -0
  22. package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
  23. package/crates/team-agent/src/cli/status_port.rs +47 -1548
  24. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -1
  25. package/crates/team-agent/src/cli/tests/named_address.rs +9 -7
  26. package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -3
  27. package/crates/team-agent/src/cli/tests/status_send.rs +17 -33
  28. package/crates/team-agent/src/cli/types.rs +5 -8
  29. package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
  30. package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
  31. package/crates/team-agent/src/coordinator/tick.rs +37 -0
  32. package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
  33. package/crates/team-agent/src/db/message_store.rs +154 -44
  34. package/crates/team-agent/src/event_log.rs +73 -0
  35. package/crates/team-agent/src/leader/start.rs +28 -4
  36. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
  37. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +297 -0
  38. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
  39. package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
  40. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
  41. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
  42. package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
  43. package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
  44. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
  45. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
  46. package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
  47. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
  48. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
  49. package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
  50. package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
  51. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
  52. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
  53. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
  54. package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
  55. package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -26
  56. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
  57. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +67 -26
  58. package/crates/team-agent/src/lifecycle/restart/remove.rs +435 -72
  59. package/crates/team-agent/src/lifecycle/restart.rs +1 -1
  60. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +575 -17
  61. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +55 -2
  62. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
  63. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -1
  64. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
  65. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
  66. package/crates/team-agent/src/mcp_server/mod.rs +2 -2
  67. package/crates/team-agent/src/mcp_server/tests/send.rs +22 -15
  68. package/crates/team-agent/src/mcp_server/tests/wire.rs +6 -0
  69. package/crates/team-agent/src/mcp_server/tools.rs +26 -15
  70. package/crates/team-agent/src/mcp_server/wire.rs +2 -18
  71. package/crates/team-agent/src/messaging/activity.rs +4 -2
  72. package/crates/team-agent/src/messaging/address.rs +86 -0
  73. package/crates/team-agent/src/messaging/delivery.rs +165 -39
  74. package/crates/team-agent/src/messaging/helpers.rs +17 -13
  75. package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
  76. package/crates/team-agent/src/messaging/mod.rs +11 -2
  77. package/crates/team-agent/src/messaging/persist.rs +309 -0
  78. package/crates/team-agent/src/messaging/results.rs +16 -24
  79. package/crates/team-agent/src/messaging/scheduler.rs +4 -2
  80. package/crates/team-agent/src/messaging/selftest.rs +19 -12
  81. package/crates/team-agent/src/messaging/send.rs +133 -58
  82. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
  83. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  84. package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
  85. package/crates/team-agent/src/messaging/watchers.rs +13 -3
  86. package/crates/team-agent/src/redaction.rs +72 -2
  87. package/crates/team-agent/src/state/persist.rs +2 -1
  88. package/crates/team-agent/src/state/repository/tests.rs +47 -0
  89. package/crates/team-agent/src/state/repository.rs +59 -16
  90. package/package.json +4 -4
@@ -5,12 +5,16 @@ use std::path::Path;
5
5
  use crate::coordinator::{CoordinatorHealthStatus, WorkspacePath};
6
6
  use crate::event_log::EventLog;
7
7
  use crate::model::enums::PaneLiveness;
8
- use crate::model::ids::{TaskId, TeamKey};
8
+ 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
12
  use super::leader_receiver::{send_to_leader_receiver, send_to_leader_receiver_with_message_id};
13
- use super::{DeliveryOutcome, DeliveryRefusal, DeliveryStatus, MessagingError};
13
+ use super::{
14
+ persist_resolved_send, DeliveryBlocker, DeliveryOutcome, DeliveryRefusal, DeliveryStatus,
15
+ InitialDisposition, LogicalRecipient, MessagingError, PersistResolution, ResolvedSendIntent,
16
+ SendOrigin,
17
+ };
14
18
 
15
19
  /// 发件目标:单 target / 广播 `*` / 扇出 list (`send.py:36` `target: str|list[str]|None`)。
16
20
  #[derive(Debug, Clone, PartialEq, Eq)]
@@ -23,15 +27,39 @@ pub enum MessageTarget {
23
27
  Fanout(Vec<String>),
24
28
  }
25
29
 
30
+ /// Sender identity captured from a framework-owned runtime context.
31
+ ///
32
+ /// Public CLI/MCP inputs never construct this value from a caller-supplied
33
+ /// string. The wrapper keeps that trust boundary visible throughout delivery
34
+ /// instead of degrading the identity back to an untyped option field.
35
+ #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
36
+ #[serde(transparent)]
37
+ pub struct TrustedSender(AgentId);
38
+
39
+ impl TrustedSender {
40
+ pub fn from_runtime_identity(agent_id: AgentId) -> Self {
41
+ Self(agent_id)
42
+ }
43
+
44
+ pub fn leader() -> Self {
45
+ Self(AgentId::new("leader"))
46
+ }
47
+
48
+ pub fn as_str(&self) -> &str {
49
+ self.0.as_str()
50
+ }
51
+ }
52
+
26
53
  /// `send_message` 选项 (`send.py:36`:Python 大量默认参数 → typed 选项 struct)。
27
54
  #[derive(Debug, Clone)]
28
55
  pub struct SendOptions {
56
+ pub origin: SendOrigin,
29
57
  pub task_id: Option<TaskId>,
30
58
  /// `route_task_id`(golden `send.py:190`,默认 `True`):仅 **routing**(CLI `send --task`)时把
31
59
  /// `task_id` 当真任务校验/路由。**投递/fanout/internal/coordinator** 路径传 `false`
32
60
  /// (`internal_delivery.py:44`、`send.py:412/481`),此时 `task_id` 只是标签,**不校验 state.tasks**。
33
61
  pub route_task_id: bool,
34
- pub sender: String,
62
+ pub sender: TrustedSender,
35
63
  pub requires_ack: bool,
36
64
  pub confirm_human: bool,
37
65
  pub wait_visible: bool,
@@ -50,9 +78,10 @@ impl Default for SendOptions {
50
78
  fn default() -> Self {
51
79
  // 默认值对齐 Python 签名 (`send.py:38-40`)。
52
80
  Self {
81
+ origin: SendOrigin::Internal(super::InternalSendKind::Delivery),
53
82
  task_id: None,
54
83
  route_task_id: true,
55
- sender: "leader".to_string(),
84
+ sender: TrustedSender::leader(),
56
85
  requires_ack: true,
57
86
  confirm_human: false,
58
87
  wait_visible: true,
@@ -95,7 +124,7 @@ pub fn send_message(
95
124
  "leader",
96
125
  content,
97
126
  opts.task_id.as_ref(),
98
- &opts.sender,
127
+ opts.sender.as_str(),
99
128
  opts.requires_ack,
100
129
  None,
101
130
  opts.message_id.as_deref(),
@@ -119,7 +148,7 @@ pub fn send_message(
119
148
  }
120
149
  MessageTarget::Single(target) => target,
121
150
  MessageTarget::Broadcast => {
122
- let recipients = broadcast_recipients(&state, &opts.sender, opts.team.as_ref());
151
+ let recipients = broadcast_recipients(&state, opts.sender.as_str(), opts.team.as_ref());
123
152
  return fanout_send(
124
153
  workspace,
125
154
  &state,
@@ -163,13 +192,13 @@ pub fn send_message(
163
192
  &state,
164
193
  recipient,
165
194
  "leader",
166
- &opts.sender,
195
+ opts.sender.as_str(),
167
196
  opts.task_id.as_ref(),
168
197
  &event_log,
169
198
  )? {
170
199
  return Ok(outcome);
171
200
  }
172
- if let Some(outcome) = send_owner_gate_refusal(workspace, &state, &opts.sender)? {
201
+ if let Some(outcome) = send_owner_gate_refusal(workspace, &state, opts.sender.as_str())? {
173
202
  return Ok(outcome);
174
203
  }
175
204
  if opts.route_task_id {
@@ -184,46 +213,47 @@ pub fn send_message(
184
213
  }
185
214
  let stale_worker_target_missing =
186
215
  stale_worker_target_missing_preflight(workspace, &state, recipient)?;
187
- if !stale_worker_target_missing {
188
- if let Some(outcome) =
189
- coordinator_unavailable_outcome(workspace, recipient, opts, &event_log)?
190
- {
191
- return Ok(outcome);
192
- }
216
+ let coordinator_unavailable = if stale_worker_target_missing {
217
+ None
218
+ } else {
219
+ coordinator_unavailable_outcome(workspace, recipient, opts, &event_log)?
220
+ };
221
+ if let Some(gate) = coordinator_unavailable
222
+ .as_ref()
223
+ .filter(|gate| !gate.persist)
224
+ {
225
+ return Ok(gate.outcome.clone());
193
226
  }
194
- let store = crate::message_store::MessageStore::open(workspace)?;
195
- let task_id = opts.task_id.as_ref().map(|t| t.as_str());
196
- let owner_team_id = opts.team.as_ref().map(|t| t.as_str());
197
- // CR-015/054 caller-key dedup: if the caller supplied a stable id, an identical
198
- // re-send must NOT create a second row — return a Duplicate refusal instead.
199
- let message_id = if let Some(requested) = opts.message_id.as_deref() {
200
- if store.message_exists(requested)? {
227
+ let mut intent = ResolvedSendIntent::accepted(
228
+ opts.origin,
229
+ workspace,
230
+ opts.team.clone(),
231
+ LogicalRecipient::from_resolved(recipient),
232
+ opts.sender.clone(),
233
+ opts.task_id.clone(),
234
+ content,
235
+ None,
236
+ opts.requires_ack,
237
+ opts.message_id.clone(),
238
+ );
239
+ if coordinator_unavailable
240
+ .as_ref()
241
+ .is_some_and(|gate| gate.persist)
242
+ {
243
+ intent.initial_disposition =
244
+ InitialDisposition::Blocked(DeliveryBlocker::CoordinatorUnavailable);
245
+ }
246
+ let persisted = match persist_resolved_send(&intent)? {
247
+ PersistResolution::Persisted(persisted) => persisted,
248
+ PersistResolution::Duplicate(message_id) => {
201
249
  return Ok(refused_outcome_with_id(
202
250
  DeliveryRefusal::Duplicate,
203
- Some(requested.to_string()),
251
+ Some(message_id),
204
252
  ));
205
253
  }
206
- store.create_message_with_id(
207
- requested,
208
- task_id,
209
- &opts.sender,
210
- recipient,
211
- content,
212
- None,
213
- opts.requires_ack,
214
- owner_team_id,
215
- )?
216
- } else {
217
- store.create_message(
218
- task_id,
219
- &opts.sender,
220
- recipient,
221
- content,
222
- None,
223
- opts.requires_ack,
224
- owner_team_id,
225
- )?
226
254
  };
255
+ let message_id = persisted.message_id.clone();
256
+ let store = crate::message_store::MessageStore::open(workspace)?;
227
257
  if stale_worker_target_missing {
228
258
  return super::delivery::mark_worker_target_missing(
229
259
  &store,
@@ -233,10 +263,37 @@ pub fn send_message(
233
263
  None,
234
264
  );
235
265
  }
266
+ if let Some(CoordinatorUnavailableGate {
267
+ mut outcome,
268
+ persist: true,
269
+ }) = coordinator_unavailable
270
+ {
271
+ let Some(blocker) = persisted.blocker else {
272
+ return Err(MessagingError::Validation(
273
+ "coordinator-blocked persistence returned no blocker".to_string(),
274
+ ));
275
+ };
276
+ let message_status = persisted.row_status.as_str();
277
+ event_log.write(
278
+ "send.message_queued",
279
+ serde_json::json!({
280
+ "message_id": message_id,
281
+ "recipient": recipient,
282
+ "sender": opts.sender,
283
+ "message_status": message_status,
284
+ "blocker": blocker.as_str(),
285
+ }),
286
+ )?;
287
+ outcome.ok = true;
288
+ outcome.status = DeliveryStatus::Blocked;
289
+ outcome.message_status = MessageStatusShadow(message_status.to_string());
290
+ outcome.message_id = Some(message_id);
291
+ return Ok(outcome);
292
+ }
236
293
  Ok(DeliveryOutcome {
237
294
  ok: true,
238
295
  status: DeliveryStatus::Queued,
239
- message_status: MessageStatusShadow("accepted".to_string()),
296
+ message_status: MessageStatusShadow(persisted.row_status.as_str().to_string()),
240
297
  message_id: Some(message_id),
241
298
  verification: None,
242
299
  stage: None,
@@ -308,12 +365,17 @@ fn refused_outcome_with_verification(
308
365
  }
309
366
  }
310
367
 
368
+ struct CoordinatorUnavailableGate {
369
+ outcome: DeliveryOutcome,
370
+ persist: bool,
371
+ }
372
+
311
373
  fn coordinator_unavailable_outcome(
312
374
  workspace: &Path,
313
375
  recipient: &str,
314
376
  opts: &SendOptions,
315
377
  event_log: &EventLog,
316
- ) -> Result<Option<DeliveryOutcome>, MessagingError> {
378
+ ) -> Result<Option<CoordinatorUnavailableGate>, MessagingError> {
317
379
  let coordinator_workspace = WorkspacePath::new(workspace.to_path_buf());
318
380
  let health = crate::coordinator::coordinator_health(&coordinator_workspace);
319
381
  if health.ok || matches!(health.status, CoordinatorHealthStatus::Missing) {
@@ -343,9 +405,19 @@ fn coordinator_unavailable_outcome(
343
405
  )?;
344
406
  return Ok(None);
345
407
  }
346
- let warning = format!(
347
- "coordinator is not running; message was not queued for {recipient}. Run `team-agent diagnose` or restart the team before sending again."
408
+ let persist = !matches!(
409
+ health.metadata_mismatch_reason.as_deref(),
410
+ Some("protocol_version_mismatch" | "message_store_schema_version_mismatch")
348
411
  );
412
+ let warning = if persist {
413
+ format!(
414
+ "coordinator is not running; message was queued for {recipient} and will retry. Run `team-agent diagnose` or restart the team."
415
+ )
416
+ } else {
417
+ format!(
418
+ "coordinator protocol or schema is incompatible; message was not queued for {recipient}. Run `team-agent diagnose` or upgrade the runtime."
419
+ )
420
+ };
349
421
  event_log.write(
350
422
  "send.coordinator_unavailable",
351
423
  serde_json::json!({
@@ -354,22 +426,25 @@ fn coordinator_unavailable_outcome(
354
426
  "coordinator_status": health.status,
355
427
  "coordinator_pid": health.pid.map(|pid| pid.get()),
356
428
  "metadata_mismatch_reason": health.metadata_mismatch_reason,
357
- "message_queued": false,
429
+ "message_queued": persist,
358
430
  "warning": warning,
359
431
  "coordinator_log": crate::coordinator::coordinator_log_path(&coordinator_workspace)
360
432
  .display()
361
433
  .to_string(),
362
434
  }),
363
435
  )?;
364
- Ok(Some(DeliveryOutcome {
365
- ok: false,
366
- status: DeliveryStatus::Degraded,
367
- message_status: MessageStatusShadow("degraded".to_string()),
368
- message_id: None,
369
- verification: Some(warning),
370
- stage: None,
371
- reason: Some(DeliveryRefusal::CoordinatorUnavailable),
372
- channel: Some("coordinator_unavailable".to_string()),
436
+ Ok(Some(CoordinatorUnavailableGate {
437
+ persist,
438
+ outcome: DeliveryOutcome {
439
+ ok: false,
440
+ status: DeliveryStatus::Degraded,
441
+ message_status: MessageStatusShadow("degraded".to_string()),
442
+ message_id: None,
443
+ verification: Some(warning),
444
+ stage: None,
445
+ reason: Some(DeliveryRefusal::CoordinatorUnavailable),
446
+ channel: Some("coordinator_unavailable".to_string()),
447
+ },
373
448
  }))
374
449
  }
375
450
 
@@ -690,7 +765,7 @@ fn fanout_send(
690
765
  let mut delivered_count = 0usize;
691
766
  let mut attempted_count = 0usize;
692
767
  for recipient in recipients {
693
- if recipient.is_empty() || recipient == &opts.sender {
768
+ if recipient.is_empty() || recipient == opts.sender.as_str() {
694
769
  continue;
695
770
  }
696
771
  attempted_count = attempted_count.saturating_add(1);
@@ -701,7 +776,7 @@ fn fanout_send(
701
776
  recipient,
702
777
  content,
703
778
  opts.task_id.as_ref(),
704
- &opts.sender,
779
+ opts.sender.as_str(),
705
780
  opts.requires_ack,
706
781
  None,
707
782
  event_log,
@@ -0,0 +1,305 @@
1
+ //! Unit regressions for leader injection receipt acceptance.
2
+ //!
3
+ //! These fixtures intentionally seed DB/state/transcript internals. Per MUST-15,
4
+ //! that synthetic setup belongs only in src/**/tests and is not acceptance proof.
5
+
6
+ use super::*;
7
+ use std::collections::BTreeMap;
8
+ use std::path::{Path, PathBuf};
9
+ use std::sync::atomic::{AtomicUsize, Ordering};
10
+ use std::sync::{Arc, Mutex};
11
+
12
+ use crate::event_log::EventLog;
13
+ use crate::message_store::MessageStore;
14
+ use crate::transport::{
15
+ AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport,
16
+ InjectStage, InjectVerification, Key, PaneField, PaneId, PaneInfo, PaneLiveness, SessionName,
17
+ SetEnvOutcome, SpawnResult, SubmitVerification, Target, Transport, TransportError,
18
+ TurnVerification, WindowName,
19
+ };
20
+ use rusqlite::params;
21
+
22
+ #[test]
23
+ fn tmux_leader_submit_without_provider_receipt_stays_pending_then_accepts_same_row() {
24
+ let case = Case::new("receipt");
25
+ let transport = RecordingTransport::new("");
26
+ let message_id = case.seed_message("receipt canary");
27
+
28
+ let first = deliver_pending_message(
29
+ &case.workspace,
30
+ &case.store,
31
+ &transport,
32
+ &message_id,
33
+ &case.event_log,
34
+ &case.state,
35
+ )
36
+ .expect("first delivery attempt");
37
+
38
+ assert!(
39
+ first.ok,
40
+ "verified transport submission is accepted even while provider receipt is pending: {first:?}"
41
+ );
42
+ assert_eq!(
43
+ case.message_status(&message_id),
44
+ "submitted_pending_acceptance"
45
+ );
46
+ assert_eq!(transport.inject_count(), 1);
47
+ assert!(
48
+ case.events().contains("leader_receiver.acceptance_pending"),
49
+ "missing receipt must be observable"
50
+ );
51
+
52
+ std::fs::write(
53
+ &case.rollout,
54
+ format!(r#"{{"type":"user","message":"[team-agent-token:{message_id}]"}}"#),
55
+ )
56
+ .unwrap();
57
+ let second = deliver_pending_message(
58
+ &case.workspace,
59
+ &case.store,
60
+ &transport,
61
+ &message_id,
62
+ &case.event_log,
63
+ &case.state,
64
+ )
65
+ .expect("receipt observation");
66
+
67
+ assert!(
68
+ second.ok,
69
+ "provider receipt advances the same row: {second:?}"
70
+ );
71
+ assert_eq!(case.message_status(&message_id), "delivered");
72
+ assert_eq!(
73
+ transport.inject_count(),
74
+ 1,
75
+ "receipt observation must not inject the message a second time"
76
+ );
77
+ assert!(case.delivery_consumed_at(&message_id).is_some());
78
+ }
79
+
80
+ #[test]
81
+ fn attached_leader_mailbox_is_rechecked_by_the_normal_delivery_tick() {
82
+ let case = Case::new("mailbox");
83
+ let transport = RecordingTransport::new("");
84
+ let message_id = case.seed_message("mailbox canary");
85
+ case.store
86
+ .mark(&message_id, "queued_until_leader_attach", None)
87
+ .unwrap();
88
+
89
+ let delivered =
90
+ deliver_pending_messages(&case.workspace, &case.state, &transport, &case.event_log)
91
+ .expect("normal coordinator delivery pass");
92
+
93
+ assert!(delivered.is_empty(), "provider receipt is still pending");
94
+ assert_eq!(
95
+ case.message_status(&message_id),
96
+ "submitted_pending_acceptance"
97
+ );
98
+ assert_eq!(transport.inject_count(), 1);
99
+ assert!(case.events().contains("leader_receiver.mailbox_requeued"));
100
+ }
101
+
102
+ struct Case {
103
+ workspace: PathBuf,
104
+ rollout: PathBuf,
105
+ store: MessageStore,
106
+ event_log: EventLog,
107
+ state: serde_json::Value,
108
+ }
109
+
110
+ impl Case {
111
+ fn new(tag: &str) -> Self {
112
+ let workspace = tmp_ws(&format!("leader-inject-acceptance-{tag}"));
113
+ let rollout = workspace.join("leader.jsonl");
114
+ std::fs::write(&rollout, "").unwrap();
115
+ let state = serde_json::json!({
116
+ "active_team_key": "acceptance-team",
117
+ "session_name": "team-acceptance",
118
+ "leader_receiver": {
119
+ "pane_id": "%leader",
120
+ "status": "attached",
121
+ "provider": "claude",
122
+ "rollout_path": rollout,
123
+ }
124
+ });
125
+ crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
126
+ let store = MessageStore::open(&workspace).unwrap();
127
+ let event_log = EventLog::new(&workspace);
128
+ Self {
129
+ workspace,
130
+ rollout,
131
+ store,
132
+ event_log,
133
+ state,
134
+ }
135
+ }
136
+
137
+ fn seed_message(&self, content: &str) -> String {
138
+ self.store
139
+ .create_message(None, "worker", "leader", content, None, false, None)
140
+ .unwrap()
141
+ }
142
+
143
+ fn message_status(&self, message_id: &str) -> String {
144
+ let conn = crate::db::schema::open_db(self.store.db_path()).unwrap();
145
+ conn.query_row(
146
+ "select status from messages where message_id = ?1",
147
+ [message_id],
148
+ |row| row.get(0),
149
+ )
150
+ .unwrap()
151
+ }
152
+
153
+ fn delivery_consumed_at(&self, message_id: &str) -> Option<String> {
154
+ let conn = crate::db::schema::open_db(self.store.db_path()).unwrap();
155
+ conn.query_row(
156
+ "select consumed_at from delivery_tokens where message_id = ?1",
157
+ params![message_id],
158
+ |row| row.get(0),
159
+ )
160
+ .unwrap()
161
+ }
162
+
163
+ fn events(&self) -> String {
164
+ self.event_log
165
+ .tail(0)
166
+ .unwrap()
167
+ .into_iter()
168
+ .map(|event| event.to_string())
169
+ .collect::<Vec<_>>()
170
+ .join("\n")
171
+ }
172
+ }
173
+
174
+ #[derive(Clone)]
175
+ struct RecordingTransport {
176
+ injects: Arc<AtomicUsize>,
177
+ capture: Arc<Mutex<String>>,
178
+ }
179
+
180
+ impl RecordingTransport {
181
+ fn new(capture: &str) -> Self {
182
+ Self {
183
+ injects: Arc::new(AtomicUsize::new(0)),
184
+ capture: Arc::new(Mutex::new(capture.to_string())),
185
+ }
186
+ }
187
+
188
+ fn inject_count(&self) -> usize {
189
+ self.injects.load(Ordering::Relaxed)
190
+ }
191
+ }
192
+
193
+ impl Transport for RecordingTransport {
194
+ fn kind(&self) -> BackendKind {
195
+ BackendKind::Tmux
196
+ }
197
+
198
+ fn spawn_first(
199
+ &self,
200
+ _session: &SessionName,
201
+ _window: &WindowName,
202
+ _argv: &[String],
203
+ _cwd: &Path,
204
+ _env: &BTreeMap<String, String>,
205
+ ) -> Result<SpawnResult, TransportError> {
206
+ unreachable!("delivery contract does not spawn")
207
+ }
208
+
209
+ fn spawn_into(
210
+ &self,
211
+ _session: &SessionName,
212
+ _window: &WindowName,
213
+ _argv: &[String],
214
+ _cwd: &Path,
215
+ _env: &BTreeMap<String, String>,
216
+ ) -> Result<SpawnResult, TransportError> {
217
+ unreachable!("delivery contract does not spawn")
218
+ }
219
+
220
+ fn inject(
221
+ &self,
222
+ _target: &Target,
223
+ _payload: &InjectPayload,
224
+ _submit: Key,
225
+ _bracketed_paste: bool,
226
+ ) -> Result<InjectReport, TransportError> {
227
+ self.injects.fetch_add(1, Ordering::Relaxed);
228
+ Ok(InjectReport {
229
+ stage_reached: InjectStage::Submit,
230
+ inject_verification: InjectVerification::CaptureContainsToken,
231
+ submit_verification: SubmitVerification::EnterSentWithoutPlaceholderCheck,
232
+ turn_verification: TurnVerification::NotYetObserved,
233
+ attempts: 1,
234
+ submit_diagnostics: None,
235
+ })
236
+ }
237
+
238
+ fn send_keys(&self, _target: &Target, _keys: &[Key]) -> Result<(), TransportError> {
239
+ Ok(())
240
+ }
241
+
242
+ fn capture(
243
+ &self,
244
+ _target: &Target,
245
+ range: CaptureRange,
246
+ ) -> Result<CapturedText, TransportError> {
247
+ Ok(CapturedText {
248
+ text: self.capture.lock().unwrap().clone(),
249
+ range,
250
+ })
251
+ }
252
+
253
+ fn query(&self, _target: &Target, _field: PaneField) -> Result<Option<String>, TransportError> {
254
+ Ok(None)
255
+ }
256
+
257
+ fn liveness(&self, _pane: &PaneId) -> Result<PaneLiveness, TransportError> {
258
+ Ok(PaneLiveness::Live)
259
+ }
260
+
261
+ fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
262
+ Ok(vec![PaneInfo {
263
+ pane_id: PaneId::new("%leader"),
264
+ session: SessionName::new("team-acceptance"),
265
+ window_index: None,
266
+ window_name: Some(WindowName::new("leader")),
267
+ pane_index: None,
268
+ tty: None,
269
+ current_command: Some("claude".to_string()),
270
+ current_path: None,
271
+ active: true,
272
+ pane_pid: None,
273
+ leader_env: BTreeMap::new(),
274
+ }])
275
+ }
276
+
277
+ fn has_session(&self, _session: &SessionName) -> Result<bool, TransportError> {
278
+ Ok(true)
279
+ }
280
+
281
+ fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
282
+ Ok(vec![WindowName::new("leader")])
283
+ }
284
+
285
+ fn set_session_env(
286
+ &self,
287
+ _session: &SessionName,
288
+ _key: &str,
289
+ _value: &str,
290
+ ) -> Result<SetEnvOutcome, TransportError> {
291
+ Ok(SetEnvOutcome::Applied)
292
+ }
293
+
294
+ fn kill_session(&self, _session: &SessionName) -> Result<(), TransportError> {
295
+ Ok(())
296
+ }
297
+
298
+ fn kill_window(&self, _target: &Target) -> Result<(), TransportError> {
299
+ Ok(())
300
+ }
301
+
302
+ fn attach_session(&self, _session: &SessionName) -> Result<AttachOutcome, TransportError> {
303
+ Ok(AttachOutcome::Attached)
304
+ }
305
+ }
@@ -292,6 +292,7 @@ impl Drop for EnvGuardMsg {
292
292
 
293
293
  mod basic;
294
294
  mod e23;
295
+ mod leader_inject_acceptance;
295
296
  mod main_preserved;
296
297
  mod runtime;
297
298
  mod spine;