@team-agent/installer 0.5.50 → 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 (84) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +5 -3
  4. package/crates/team-agent/src/cli/emit.rs +2 -1
  5. package/crates/team-agent/src/cli/mod.rs +32 -14
  6. package/crates/team-agent/src/cli/named_address.rs +11 -48
  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 +44 -1169
  13. package/crates/team-agent/src/cli/spec.rs +1 -1
  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/run_delegation.rs +1 -0
  25. package/crates/team-agent/src/cli/types.rs +1 -0
  26. package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
  27. package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
  28. package/crates/team-agent/src/coordinator/tick.rs +37 -0
  29. package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
  30. package/crates/team-agent/src/db/message_store.rs +154 -44
  31. package/crates/team-agent/src/event_log.rs +73 -0
  32. package/crates/team-agent/src/leader/start.rs +28 -4
  33. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
  34. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +297 -0
  35. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
  36. package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
  37. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
  38. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
  39. package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
  40. package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
  41. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
  42. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
  43. package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
  44. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
  45. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
  46. package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
  47. package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
  48. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
  49. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
  50. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
  51. package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
  52. package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -26
  53. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
  54. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +67 -26
  55. package/crates/team-agent/src/lifecycle/restart/remove.rs +435 -72
  56. package/crates/team-agent/src/lifecycle/restart.rs +1 -1
  57. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +575 -17
  58. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +55 -2
  59. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
  60. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
  61. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
  62. package/crates/team-agent/src/mcp_server/mod.rs +1 -1
  63. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -10
  64. package/crates/team-agent/src/mcp_server/tools.rs +14 -5
  65. package/crates/team-agent/src/messaging/activity.rs +4 -2
  66. package/crates/team-agent/src/messaging/address.rs +86 -0
  67. package/crates/team-agent/src/messaging/delivery.rs +165 -39
  68. package/crates/team-agent/src/messaging/helpers.rs +17 -13
  69. package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
  70. package/crates/team-agent/src/messaging/mod.rs +10 -2
  71. package/crates/team-agent/src/messaging/persist.rs +309 -0
  72. package/crates/team-agent/src/messaging/results.rs +16 -24
  73. package/crates/team-agent/src/messaging/scheduler.rs +4 -2
  74. package/crates/team-agent/src/messaging/selftest.rs +19 -12
  75. package/crates/team-agent/src/messaging/send.rs +101 -49
  76. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
  77. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  78. package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
  79. package/crates/team-agent/src/messaging/watchers.rs +13 -3
  80. package/crates/team-agent/src/redaction.rs +72 -2
  81. package/crates/team-agent/src/state/persist.rs +2 -1
  82. package/crates/team-agent/src/state/repository/tests.rs +47 -0
  83. package/crates/team-agent/src/state/repository.rs +59 -16
  84. package/package.json +4 -4
@@ -10,7 +10,11 @@ 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)]
@@ -49,6 +53,7 @@ impl TrustedSender {
49
53
  /// `send_message` 选项 (`send.py:36`:Python 大量默认参数 → typed 选项 struct)。
50
54
  #[derive(Debug, Clone)]
51
55
  pub struct SendOptions {
56
+ pub origin: SendOrigin,
52
57
  pub task_id: Option<TaskId>,
53
58
  /// `route_task_id`(golden `send.py:190`,默认 `True`):仅 **routing**(CLI `send --task`)时把
54
59
  /// `task_id` 当真任务校验/路由。**投递/fanout/internal/coordinator** 路径传 `false`
@@ -73,6 +78,7 @@ impl Default for SendOptions {
73
78
  fn default() -> Self {
74
79
  // 默认值对齐 Python 签名 (`send.py:38-40`)。
75
80
  Self {
81
+ origin: SendOrigin::Internal(super::InternalSendKind::Delivery),
76
82
  task_id: None,
77
83
  route_task_id: true,
78
84
  sender: TrustedSender::leader(),
@@ -207,46 +213,47 @@ pub fn send_message(
207
213
  }
208
214
  let stale_worker_target_missing =
209
215
  stale_worker_target_missing_preflight(workspace, &state, recipient)?;
210
- if !stale_worker_target_missing {
211
- if let Some(outcome) =
212
- coordinator_unavailable_outcome(workspace, recipient, opts, &event_log)?
213
- {
214
- return Ok(outcome);
215
- }
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());
216
226
  }
217
- let store = crate::message_store::MessageStore::open(workspace)?;
218
- let task_id = opts.task_id.as_ref().map(|t| t.as_str());
219
- let owner_team_id = opts.team.as_ref().map(|t| t.as_str());
220
- // CR-015/054 caller-key dedup: if the caller supplied a stable id, an identical
221
- // re-send must NOT create a second row — return a Duplicate refusal instead.
222
- let message_id = if let Some(requested) = opts.message_id.as_deref() {
223
- 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) => {
224
249
  return Ok(refused_outcome_with_id(
225
250
  DeliveryRefusal::Duplicate,
226
- Some(requested.to_string()),
251
+ Some(message_id),
227
252
  ));
228
253
  }
229
- store.create_message_with_id(
230
- requested,
231
- task_id,
232
- opts.sender.as_str(),
233
- recipient,
234
- content,
235
- None,
236
- opts.requires_ack,
237
- owner_team_id,
238
- )?
239
- } else {
240
- store.create_message(
241
- task_id,
242
- opts.sender.as_str(),
243
- recipient,
244
- content,
245
- None,
246
- opts.requires_ack,
247
- owner_team_id,
248
- )?
249
254
  };
255
+ let message_id = persisted.message_id.clone();
256
+ let store = crate::message_store::MessageStore::open(workspace)?;
250
257
  if stale_worker_target_missing {
251
258
  return super::delivery::mark_worker_target_missing(
252
259
  &store,
@@ -256,10 +263,37 @@ pub fn send_message(
256
263
  None,
257
264
  );
258
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
+ }
259
293
  Ok(DeliveryOutcome {
260
294
  ok: true,
261
295
  status: DeliveryStatus::Queued,
262
- message_status: MessageStatusShadow("accepted".to_string()),
296
+ message_status: MessageStatusShadow(persisted.row_status.as_str().to_string()),
263
297
  message_id: Some(message_id),
264
298
  verification: None,
265
299
  stage: None,
@@ -331,12 +365,17 @@ fn refused_outcome_with_verification(
331
365
  }
332
366
  }
333
367
 
368
+ struct CoordinatorUnavailableGate {
369
+ outcome: DeliveryOutcome,
370
+ persist: bool,
371
+ }
372
+
334
373
  fn coordinator_unavailable_outcome(
335
374
  workspace: &Path,
336
375
  recipient: &str,
337
376
  opts: &SendOptions,
338
377
  event_log: &EventLog,
339
- ) -> Result<Option<DeliveryOutcome>, MessagingError> {
378
+ ) -> Result<Option<CoordinatorUnavailableGate>, MessagingError> {
340
379
  let coordinator_workspace = WorkspacePath::new(workspace.to_path_buf());
341
380
  let health = crate::coordinator::coordinator_health(&coordinator_workspace);
342
381
  if health.ok || matches!(health.status, CoordinatorHealthStatus::Missing) {
@@ -366,9 +405,19 @@ fn coordinator_unavailable_outcome(
366
405
  )?;
367
406
  return Ok(None);
368
407
  }
369
- let warning = format!(
370
- "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")
371
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
+ };
372
421
  event_log.write(
373
422
  "send.coordinator_unavailable",
374
423
  serde_json::json!({
@@ -377,22 +426,25 @@ fn coordinator_unavailable_outcome(
377
426
  "coordinator_status": health.status,
378
427
  "coordinator_pid": health.pid.map(|pid| pid.get()),
379
428
  "metadata_mismatch_reason": health.metadata_mismatch_reason,
380
- "message_queued": false,
429
+ "message_queued": persist,
381
430
  "warning": warning,
382
431
  "coordinator_log": crate::coordinator::coordinator_log_path(&coordinator_workspace)
383
432
  .display()
384
433
  .to_string(),
385
434
  }),
386
435
  )?;
387
- Ok(Some(DeliveryOutcome {
388
- ok: false,
389
- status: DeliveryStatus::Degraded,
390
- message_status: MessageStatusShadow("degraded".to_string()),
391
- message_id: None,
392
- verification: Some(warning),
393
- stage: None,
394
- reason: Some(DeliveryRefusal::CoordinatorUnavailable),
395
- 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
+ },
396
448
  }))
397
449
  }
398
450
 
@@ -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;
@@ -18,12 +18,11 @@ fn pane_info(pane_id: &str, session: &str, window: &str) -> PaneInfo {
18
18
  }
19
19
 
20
20
  // ════════════════════════════════════════════════════════════════════════
21
- // GROUP E — _fail_leader_delivery: bug-52 fallback-log semantics. ok=True but
22
- // status=FallbackLog (NOT a real submit). leader.py:394-436.
21
+ // GROUP E — legacy diagnostics may only advance an existing durable row.
23
22
  // ════════════════════════════════════════════════════════════════════════
24
23
 
25
24
  #[test]
26
- fn fail_leader_delivery_returns_fallback_log_ok_true_not_submitted() {
25
+ fn fail_leader_delivery_without_message_id_does_not_invent_a_row() {
27
26
  let ws = tmp_ws("faillead");
28
27
  let payload = json(serde_json::json!({
29
28
  "to": "leader", "content": "hi", "sender": "coordinator"
@@ -35,12 +34,17 @@ fn fail_leader_delivery_returns_fallback_log_ok_true_not_submitted() {
35
34
  Some("No direct leader tmux pane is attached. Run team-agent attach-leader."),
36
35
  )
37
36
  .unwrap();
38
- // leader.py:423-431 — ok True, status fallback_log, channel fallback_inbox.
39
- assert!(out.ok);
37
+ assert!(!out.ok);
40
38
  assert_eq!(out.status, DeliveryStatus::FallbackLog);
41
39
  assert_eq!(out.reason, Some(DeliveryRefusal::LeaderNotAttached));
42
- // The audit must be distinguishable from a real submit (Delivered).
40
+ assert_eq!(out.message_id, None);
43
41
  assert_ne!(out.status, DeliveryStatus::Delivered);
42
+ let store = MessageStore::open(&ws).unwrap();
43
+ let connection = crate::db::schema::open_db(store.db_path()).unwrap();
44
+ let rows: i64 = connection
45
+ .query_row("select count(*) from messages", [], |row| row.get(0))
46
+ .unwrap();
47
+ assert_eq!(rows, 0, "diagnostics cannot create a fallback message row");
44
48
  }
45
49
 
46
50
  // ════════════════════════════════════════════════════════════════════════
@@ -439,28 +443,30 @@ fn message_not_silently_stuck_accepted_when_coordinator_dead() {
439
443
  )
440
444
  .unwrap();
441
445
 
442
- assert!(!out.ok);
443
- assert_eq!(out.status, DeliveryStatus::Degraded);
444
- assert_eq!(out.message_status.0, "degraded");
446
+ assert!(out.ok, "durable persistence is the send success boundary");
447
+ assert_eq!(out.status, DeliveryStatus::Blocked);
448
+ assert_eq!(out.message_status.0, "queued_coordinator_unavailable");
449
+ assert!(out.message_id.as_deref().is_some_and(|id| id.starts_with("msg_")));
445
450
  assert_eq!(out.reason, Some(DeliveryRefusal::CoordinatorUnavailable));
446
451
  assert!(
447
452
  out.verification
448
453
  .as_deref()
449
454
  .is_some_and(|warning| warning.contains("coordinator is not running")),
450
- "N38 warning must explain why the message was not queued; out={out:?}"
455
+ "N38 warning must explain the durable retry blocker; out={out:?}"
451
456
  );
452
457
  let store = MessageStore::open(&ws).unwrap();
453
458
  let conn = crate::db::schema::open_db(store.db_path()).unwrap();
454
- let accepted: i64 = conn
459
+ let blocked: i64 = conn
455
460
  .query_row(
456
- "select count(*) from messages where status = 'accepted'",
461
+ "select count(*) from messages where status = 'queued_coordinator_unavailable' \
462
+ and error = 'coordinator_unavailable' and delivered_at is null",
457
463
  [],
458
464
  |row| row.get(0),
459
465
  )
460
466
  .unwrap();
461
467
  assert_eq!(
462
- accepted, 0,
463
- "dead coordinator send must not strand an accepted row"
468
+ blocked, 1,
469
+ "dead coordinator send must park exactly one durable, non-delivered row"
464
470
  );
465
471
  let events = EventLog::new(&ws).tail(20).unwrap();
466
472
  assert!(
@@ -470,7 +476,7 @@ fn message_not_silently_stuck_accepted_when_coordinator_dead() {
470
476
  && event
471
477
  .get("message_queued")
472
478
  .and_then(serde_json::Value::as_bool)
473
- == Some(false)
479
+ == Some(true)
474
480
  }),
475
481
  "send.coordinator_unavailable event must be durable; events={events:?}"
476
482
  );
@@ -2914,9 +2920,15 @@ fn gate054_rebind_required_status_refuses_leader_delivery_without_cross_server_p
2914
2920
  }
2915
2921
 
2916
2922
  #[test]
2917
- fn gate054_attached_status_still_delivers_when_pane_is_live() {
2923
+ fn gate054_attached_status_still_accepts_when_pane_is_live() {
2918
2924
  // Companion negative: prove the new status gate does NOT over-refuse when
2919
2925
  // `status="attached"` and the workspace-canonical socket has the pane.
2926
+ // Injection-case revision: transport submit success is NOT a provider
2927
+ // receipt, so the old `delivered` pin was a false-positive lock
2928
+ // (MUST-10: physical submit is the framework's fact; acceptance is the
2929
+ // provider's). The un-refused path now parks the SAME row in
2930
+ // submitted_pending_acceptance until a provider receipt or retry
2931
+ // schedule advances it.
2920
2932
  let ws = tmp_ws("gate054ok");
2921
2933
  let store = store_for(&ws);
2922
2934
  let log = EventLog::new(&ws);
@@ -2945,7 +2957,16 @@ fn gate054_attached_status_still_delivers_when_pane_is_live() {
2945
2957
  out.ok,
2946
2958
  "0.5.4 gate054: status='attached' with a live pane must not be over-refused: {out:?}"
2947
2959
  );
2948
- assert_eq!(out.message_status.0, "delivered");
2960
+ assert_eq!(
2961
+ transport.inject_targets().len(),
2962
+ 1,
2963
+ "gate054: exactly one physical inject must have happened (MUST-10 anchor)"
2964
+ );
2965
+ assert_eq!(
2966
+ out.message_status.0, "submitted_pending_acceptance",
2967
+ "gate054 (injection-case revision): a successful physical submit without a provider \
2968
+ receipt must park as submitted_pending_acceptance, never claim delivered: {out:?}"
2969
+ );
2949
2970
  }
2950
2971
 
2951
2972
  // ════════════════════════════════════════════════════════════════════════