@team-agent/installer 0.5.116 → 0.5.117

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.
@@ -0,0 +1,566 @@
1
+ #![allow(clippy::unwrap_used, clippy::panic)]
2
+ use super::*;
3
+ use crate::model::enums::Provider;
4
+ use crate::transport::native_input::{NativeInputBinding, NativeInputRequest};
5
+ use std::sync::atomic::{AtomicUsize, Ordering};
6
+
7
+ struct Fixture {
8
+ workspace: std::path::PathBuf,
9
+ store: MessageStore,
10
+ log: EventLog,
11
+ evidence: NativeInputEvidence,
12
+ }
13
+ impl Fixture {
14
+ fn new() -> Self {
15
+ static NEXT: AtomicUsize = AtomicUsize::new(0);
16
+ let workspace = std::env::temp_dir().join(format!(
17
+ "ta-enter-rescue-{}-{}",
18
+ std::process::id(),
19
+ NEXT.fetch_add(1, Ordering::SeqCst)
20
+ ));
21
+ std::fs::create_dir(&workspace).unwrap();
22
+ let store = MessageStore::open(&workspace).unwrap();
23
+ let log = EventLog::new(&workspace);
24
+ let request = NativeInputRequest {
25
+ message_id: "msg_one".into(),
26
+ owner_team_id: "team".into(),
27
+ recipient: "worker".into(),
28
+ provider: Provider::Pi,
29
+ pane_id: "%1".into(),
30
+ pane_pid: 2,
31
+ session: "session".into(),
32
+ window: "worker".into(),
33
+ };
34
+ let evidence = NativeInputEvidence {
35
+ request,
36
+ binding: NativeInputBinding {
37
+ endpoint: "/tmp/owned-test.sock".into(),
38
+ pane_id: "%1".into(),
39
+ pane_pid: 2,
40
+ pane_height: 120,
41
+ carrier_birth: "test-birth".into(),
42
+ binding_nonce: "test-nonce".into(),
43
+ session: "session".into(),
44
+ window: "worker".into(),
45
+ },
46
+ payload_sha256: digest(&render_message("leader", None, "body", "msg_one")),
47
+ ui_identity: "gpt-6-luna".into(),
48
+ footer_sha256: digest(""),
49
+ baseline_sha256: digest("baseline"),
50
+ baseline_capsules: vec![],
51
+ phase: NativeInputPhase::Submitted,
52
+ capsule: None,
53
+ };
54
+ let fixture = Self {
55
+ workspace,
56
+ store,
57
+ log,
58
+ evidence,
59
+ };
60
+ let conn = fixture.conn();
61
+ conn.execute("insert into messages(message_id,owner_team_id,sender,recipient,content,status,created_at,updated_at,delivery_attempts) values('msg_one','team','leader','worker','body','target_resolved','old','old',1)", []).unwrap();
62
+ fixture.state("running", false);
63
+ fixture
64
+ }
65
+ fn conn(&self) -> rusqlite::Connection {
66
+ crate::db::schema::open_db(self.store.db_path()).unwrap()
67
+ }
68
+ fn state(&self, status: &str, stopped: bool) {
69
+ let state = serde_json::json!({"team_key":"team","session_name":"session","agents":{"worker":{"status":status,"explicit_stop":stopped,"provider":"pi","pane_id":"%1","pane_pid":2,"window":"worker"}}});
70
+ std::fs::write(
71
+ crate::model::paths::runtime_dir(&self.workspace).join("state.json"),
72
+ state.to_string(),
73
+ )
74
+ .unwrap();
75
+ }
76
+ fn park(&self, age: i64) {
77
+ park_at(
78
+ &self.store,
79
+ &self.evidence,
80
+ 1,
81
+ "uncertain",
82
+ &self.log,
83
+ Utc::now() - Duration::seconds(age),
84
+ )
85
+ .unwrap();
86
+ }
87
+ fn job(&self) -> (i64, String, String) {
88
+ self.conn()
89
+ .query_row(
90
+ "select id,payload_json,status from scheduled_events",
91
+ [],
92
+ |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
93
+ )
94
+ .unwrap()
95
+ }
96
+ fn message_status(&self) -> String {
97
+ self.conn()
98
+ .query_row(
99
+ "select status from messages where message_id='msg_one'",
100
+ [],
101
+ |row| row.get(0),
102
+ )
103
+ .unwrap()
104
+ }
105
+ }
106
+ impl Drop for Fixture {
107
+ fn drop(&mut self) {
108
+ let _ = std::fs::remove_dir_all(&self.workspace);
109
+ }
110
+ }
111
+
112
+ #[test]
113
+ fn enter_rescue_first_successful_key_arms_original_turn_before_acceptance_poll() {
114
+ let f = Fixture::new();
115
+ f.park(95);
116
+ super::super::delivery::with_rescue_submit_observer(
117
+ &f.workspace,
118
+ &f.store,
119
+ &f.evidence,
120
+ &f.log,
121
+ |observer| {
122
+ observer.after_physical_submit();
123
+ let state = crate::state::persist::load_runtime_state(&f.workspace).unwrap();
124
+ assert_eq!(
125
+ state["agents"]["worker"]["current_turn_message_id"],
126
+ "msg_one"
127
+ );
128
+ Ok(EnterRescueOutcome::Entered { accepted: false })
129
+ },
130
+ )
131
+ .unwrap();
132
+ assert_eq!(f.message_status(), "submitted_unverified");
133
+ }
134
+
135
+ #[test]
136
+ fn enter_rescue_post_key_owner_or_receiver_loss_is_unknown_without_cross_team_write() {
137
+ for change in [
138
+ "owner",
139
+ "receiver",
140
+ "pane",
141
+ "pane_pid",
142
+ "missing_pane_pid",
143
+ "session",
144
+ "stopped",
145
+ ] {
146
+ let f = Fixture::new();
147
+ f.park(95);
148
+ let (id, payload, _) = f.job();
149
+ let effects = AtomicUsize::new(0);
150
+ let path = crate::model::paths::runtime_dir(&f.workspace).join("state.json");
151
+ let replacement = std::cell::RefCell::new(String::new());
152
+ // Controlled post-key mutation; this is not a native TUI reproduction.
153
+ fire_with(
154
+ &f.workspace,
155
+ &f.store,
156
+ &f.log,
157
+ id,
158
+ &payload,
159
+ &|evidence, _| {
160
+ super::super::delivery::with_rescue_submit_observer(
161
+ &f.workspace,
162
+ &f.store,
163
+ evidence,
164
+ &f.log,
165
+ |observer| {
166
+ effects.fetch_add(1, Ordering::SeqCst);
167
+ let mut state =
168
+ crate::state::persist::load_runtime_state(&f.workspace).unwrap();
169
+ match change {
170
+ "owner" => {
171
+ state["team_key"] = "other".into();
172
+ state["active_team_key"] = "other".into();
173
+ state.as_object_mut().unwrap().remove("teams");
174
+ }
175
+ "receiver" => {
176
+ state["agents"].as_object_mut().unwrap().remove("worker");
177
+ }
178
+ "pane" => state["agents"]["worker"]["pane_id"] = "%2".into(),
179
+ "pane_pid" => state["agents"]["worker"]["pane_pid"] = 3.into(),
180
+ "missing_pane_pid" => {
181
+ state["agents"]["worker"]
182
+ .as_object_mut()
183
+ .unwrap()
184
+ .remove("pane_pid");
185
+ }
186
+ "session" => state["session_name"] = "other-session".into(),
187
+ "stopped" => state["agents"]["worker"]["explicit_stop"] = true.into(),
188
+ _ => unreachable!(),
189
+ }
190
+ *replacement.borrow_mut() = state.to_string();
191
+ std::fs::write(&path, replacement.borrow().as_bytes()).unwrap();
192
+ observer.after_physical_submit();
193
+ Ok(EnterRescueOutcome::Entered { accepted: true })
194
+ },
195
+ )
196
+ },
197
+ )
198
+ .unwrap();
199
+ // The rejected commit must not change even one byte of the replacement state.
200
+ let expected = replacement.borrow().clone();
201
+ assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
202
+ assert_eq!(f.job().2, "unknown", "{change}");
203
+ assert_eq!(f.message_status(), "submitted_unverified");
204
+ let state: serde_json::Value = serde_json::from_str(&expected).unwrap();
205
+ assert!(
206
+ state["agents"]["worker"]["current_turn_message_id"].is_null(),
207
+ "{change}"
208
+ );
209
+ assert!(!fire_with(
210
+ &f.workspace,
211
+ &f.store,
212
+ &f.log,
213
+ id,
214
+ &payload,
215
+ &|_, _| panic!("post-key error replay")
216
+ )
217
+ .unwrap());
218
+ assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
219
+ assert_eq!(effects.load(Ordering::SeqCst), 1);
220
+ }
221
+ }
222
+
223
+ #[test]
224
+ fn enter_rescue_owner_loss_at_state_save_hook_refuses_without_default_fallback() {
225
+ let f = Fixture::new();
226
+ f.park(95);
227
+ let path = crate::model::paths::runtime_dir(&f.workspace).join("state.json");
228
+ let replacement = serde_json::json!({"team_key":"other","active_team_key":"other","session_name":"session","agents":{"worker":{"provider":"pi","status":"running","pane_id":"%1","pane_pid":2,"window":"worker"}}}).to_string();
229
+ let hook_path = path.clone();
230
+ let hook_state = replacement.clone();
231
+ crate::state::repository::test_support::set_before_commit(move || {
232
+ std::fs::write(hook_path, hook_state).unwrap();
233
+ });
234
+ assert!(super::super::delivery::with_rescue_submit_observer(
235
+ &f.workspace,
236
+ &f.store,
237
+ &f.evidence,
238
+ &f.log,
239
+ |observer| {
240
+ observer.after_physical_submit();
241
+ Ok(EnterRescueOutcome::Entered { accepted: true })
242
+ },
243
+ )
244
+ .is_err());
245
+ assert_eq!(std::fs::read_to_string(path).unwrap(), replacement);
246
+ assert_eq!(f.message_status(), "submitted_unverified");
247
+ }
248
+
249
+ #[test]
250
+ fn enter_rescue_switched_top_projection_updates_only_retained_original_owner() {
251
+ let f = Fixture::new();
252
+ f.park(95);
253
+ super::super::delivery::with_rescue_submit_observer(
254
+ &f.workspace, &f.store, &f.evidence, &f.log, |observer| {
255
+ let original = crate::state::persist::load_runtime_state(&f.workspace).unwrap();
256
+ let mut other = original.clone();
257
+ other["team_key"] = "other".into();
258
+ other["active_team_key"] = "other".into();
259
+ other["session_name"] = "other-session".into();
260
+ other["agents"]["worker"]["pane_id"] = "%2".into();
261
+ other["agents"]["worker"]["pane_pid"] = 3.into();
262
+ let mut state = other.clone();
263
+ state["teams"] = serde_json::json!({"team": crate::state::projection::compact_team_state(&original), "other": crate::state::projection::compact_team_state(&other)});
264
+ std::fs::write(crate::model::paths::runtime_dir(&f.workspace).join("state.json"), state.to_string()).unwrap();
265
+ observer.after_physical_submit();
266
+ Ok(EnterRescueOutcome::Entered { accepted: false })
267
+ },
268
+ ).unwrap();
269
+ let state = crate::state::persist::load_runtime_state(&f.workspace).unwrap();
270
+ assert_eq!(
271
+ state["teams"]["team"]["agents"]["worker"]["current_turn_message_id"],
272
+ "msg_one"
273
+ );
274
+ assert!(state["teams"]["other"]["agents"]["worker"]["current_turn_message_id"].is_null());
275
+ assert!(state["agents"]["worker"]["current_turn_message_id"].is_null());
276
+ assert_eq!(state["agents"]["worker"]["pane_id"], "%2");
277
+ }
278
+
279
+ #[test]
280
+ fn enter_rescue_atomic_park_and_due90_no_early_effect() {
281
+ let f = Fixture::new();
282
+ f.park(0);
283
+ let (id, payload, status) = f.job();
284
+ assert_eq!(status, "pending");
285
+ assert_eq!(f.message_status(), "submitted_unverified");
286
+ let times: (String, String, String) = f
287
+ .conn()
288
+ .query_row(
289
+ "select owner_team_id,created_at,due_at from scheduled_events",
290
+ [],
291
+ |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
292
+ )
293
+ .unwrap();
294
+ assert_eq!(times.0, "team");
295
+ assert_eq!(
296
+ DateTime::parse_from_rfc3339(&times.2).unwrap()
297
+ - DateTime::parse_from_rfc3339(&times.1).unwrap(),
298
+ Duration::seconds(90)
299
+ );
300
+ assert!(!fire_with(
301
+ &f.workspace,
302
+ &f.store,
303
+ &f.log,
304
+ id,
305
+ &payload,
306
+ &|_, _| panic!("not due")
307
+ )
308
+ .unwrap());
309
+ assert!(park_at(&f.store, &f.evidence, 1, "again", &f.log, Utc::now()).is_err());
310
+ assert_eq!(
311
+ f.conn()
312
+ .query_row("select count(*) from scheduled_events", [], |r| r
313
+ .get::<_, i64>(0))
314
+ .unwrap(),
315
+ 1
316
+ );
317
+ }
318
+
319
+ #[test]
320
+ fn enter_rescue_insert_failure_rolls_back_original_uncertainty() {
321
+ let f = Fixture::new();
322
+ f.conn().execute_batch("create trigger refuse_job before insert on scheduled_events begin select raise(abort,'fixture'); end;").unwrap();
323
+ assert!(park_at(&f.store, &f.evidence, 1, "uncertain", &f.log, Utc::now()).is_err());
324
+ assert_eq!(f.message_status(), "target_resolved");
325
+ }
326
+
327
+ #[test]
328
+ fn enter_rescue_no_effect_deferral_releases_only_exact_unspent_claim() {
329
+ let f = Fixture::new();
330
+ release_no_effect(&f.store, "msg_one", 2).unwrap();
331
+ assert_eq!(
332
+ f.conn()
333
+ .query_row("select delivery_attempts from messages", [], |r| r
334
+ .get::<_, i64>(0))
335
+ .unwrap(),
336
+ 1
337
+ );
338
+ release_no_effect(&f.store, "msg_one", 1).unwrap();
339
+ assert_eq!(
340
+ f.conn()
341
+ .query_row("select delivery_attempts from messages", [], |r| r
342
+ .get::<_, i64>(0))
343
+ .unwrap(),
344
+ 0
345
+ );
346
+ assert!(f.store.claim_for_delivery("msg_one").unwrap());
347
+ assert_eq!(
348
+ f.conn()
349
+ .query_row("select delivery_attempts from messages", [], |r| r
350
+ .get::<_, i64>(0))
351
+ .unwrap(),
352
+ 1
353
+ );
354
+ }
355
+
356
+ #[test]
357
+ fn enter_rescue_canonical_stopped_changed_consumed_expired_and_superseded_refuse() {
358
+ for case in [
359
+ "stopped",
360
+ "explicit_stop",
361
+ "content",
362
+ "owner",
363
+ "consumed",
364
+ "expired",
365
+ "superseded",
366
+ "job_scope",
367
+ ] {
368
+ let f = Fixture::new();
369
+ f.park(if case == "expired" { 121 } else { 95 });
370
+ match case {
371
+ "stopped" => f.state("stopped", false),
372
+ "explicit_stop" => f.state("running", true),
373
+ "content" => {
374
+ f.conn()
375
+ .execute("update messages set content='changed'", [])
376
+ .unwrap();
377
+ }
378
+ "owner" => {
379
+ f.conn()
380
+ .execute("update messages set owner_team_id='other'", [])
381
+ .unwrap();
382
+ }
383
+ "consumed" => {
384
+ f.conn()
385
+ .execute("update messages set status='acknowledged'", [])
386
+ .unwrap();
387
+ }
388
+ "superseded" => {
389
+ f.conn().execute("insert into messages(message_id,owner_team_id,recipient,status,created_at,delivery_attempts) values('msg_two','team','worker','submitted_unverified','z',1)",[]).unwrap();
390
+ }
391
+ "job_scope" => {
392
+ f.conn()
393
+ .execute("update scheduled_events set owner_team_id='other'", [])
394
+ .unwrap();
395
+ }
396
+ "expired" => {}
397
+ _ => unreachable!(),
398
+ }
399
+ let (id, payload, _) = f.job();
400
+ assert!(fire_with(
401
+ &f.workspace,
402
+ &f.store,
403
+ &f.log,
404
+ id,
405
+ &payload,
406
+ &|_, _| panic!("{case}: forbidden effect")
407
+ )
408
+ .unwrap());
409
+ assert_eq!(f.job().2, "refused", "{case}");
410
+ }
411
+ }
412
+
413
+ #[test]
414
+ fn enter_rescue_no_effect_can_reobserve_but_unknown_or_claimed_never_replays() {
415
+ let f = Fixture::new();
416
+ f.park(95);
417
+ let (id, payload, _) = f.job();
418
+ assert!(fire_with(
419
+ &f.workspace,
420
+ &f.store,
421
+ &f.log,
422
+ id,
423
+ &payload,
424
+ &|_, authorize| {
425
+ assert!(authorize());
426
+ Ok(EnterRescueOutcome::NoEffect {
427
+ reason: "busy",
428
+ reobserve: true,
429
+ })
430
+ }
431
+ )
432
+ .unwrap());
433
+ assert_eq!(f.job().2, "pending");
434
+ assert!(!fire_with(
435
+ &f.workspace,
436
+ &f.store,
437
+ &f.log,
438
+ id,
439
+ &payload,
440
+ &|_, _| panic!("not yet due")
441
+ )
442
+ .unwrap());
443
+ f.conn()
444
+ .execute("update scheduled_events set due_at='0'", [])
445
+ .unwrap();
446
+ assert!(fire_with(
447
+ &f.workspace,
448
+ &f.store,
449
+ &f.log,
450
+ id,
451
+ &payload,
452
+ &|_, authorize| {
453
+ assert!(authorize());
454
+ Ok(EnterRescueOutcome::Entered { accepted: false })
455
+ }
456
+ )
457
+ .unwrap());
458
+ assert_eq!(f.job().2, "unknown");
459
+ assert_eq!(f.message_status(), "submitted_unverified");
460
+ assert!(!fire_with(
461
+ &f.workspace,
462
+ &f.store,
463
+ &f.log,
464
+ id,
465
+ &payload,
466
+ &|_, _| panic!("unknown replay")
467
+ )
468
+ .unwrap());
469
+ f.conn()
470
+ .execute("update scheduled_events set status='claimed'", [])
471
+ .unwrap();
472
+ assert!(!fire_with(
473
+ &f.workspace,
474
+ &f.store,
475
+ &f.log,
476
+ id,
477
+ &payload,
478
+ &|_, _| panic!("crash replay")
479
+ )
480
+ .unwrap());
481
+ }
482
+
483
+ #[test]
484
+ fn enter_rescue_concurrent_claim_is_one_effect_and_updates_original_only() {
485
+ let f = Fixture::new();
486
+ f.park(95);
487
+ let (id, payload, _) = f.job();
488
+ let effects = AtomicUsize::new(0);
489
+ std::thread::scope(|scope| {
490
+ for _ in 0..2 {
491
+ scope.spawn(|| {
492
+ fire_with(
493
+ &f.workspace,
494
+ &f.store,
495
+ &f.log,
496
+ id,
497
+ &payload,
498
+ &|_, authorize| {
499
+ assert!(authorize());
500
+ effects.fetch_add(1, Ordering::SeqCst);
501
+ std::thread::sleep(std::time::Duration::from_millis(20));
502
+ Ok(EnterRescueOutcome::Entered { accepted: true })
503
+ },
504
+ )
505
+ .unwrap()
506
+ });
507
+ }
508
+ });
509
+ assert_eq!(effects.load(Ordering::SeqCst), 1);
510
+ assert_eq!(f.job().2, "done");
511
+ assert_eq!(f.message_status(), "delivered");
512
+ assert_eq!(
513
+ f.conn()
514
+ .query_row("select count(*) from messages", [], |r| r.get::<_, i64>(0))
515
+ .unwrap(),
516
+ 1
517
+ );
518
+ }
519
+
520
+ #[test]
521
+ fn enter_rescue_revalidates_at_effect_and_keeps_unknown_errors_terminal() {
522
+ let f = Fixture::new();
523
+ f.park(95);
524
+ let (id, payload, _) = f.job();
525
+ fire_with(
526
+ &f.workspace,
527
+ &f.store,
528
+ &f.log,
529
+ id,
530
+ &payload,
531
+ &|_, authorize| {
532
+ f.state("stopped", true);
533
+ assert!(!authorize());
534
+ Ok(EnterRescueOutcome::NoEffect {
535
+ reason: "canonical_changed",
536
+ reobserve: false,
537
+ })
538
+ },
539
+ )
540
+ .unwrap();
541
+ assert_eq!(f.job().2, "refused");
542
+ let other = Fixture::new();
543
+ other.park(95);
544
+ let (id, payload, _) = other.job();
545
+ fire_with(
546
+ &other.workspace,
547
+ &other.store,
548
+ &other.log,
549
+ id,
550
+ &payload,
551
+ &|_, _| {
552
+ Err(crate::transport::TransportError::Io(std::io::Error::other("may have sent")).into())
553
+ },
554
+ )
555
+ .unwrap();
556
+ assert_eq!(other.job().2, "unknown");
557
+ assert!(!fire_with(
558
+ &other.workspace,
559
+ &other.store,
560
+ &other.log,
561
+ id,
562
+ &payload,
563
+ &|_, _| panic!("error replay")
564
+ )
565
+ .unwrap());
566
+ }
@@ -107,6 +107,7 @@ pub(crate) fn parse_scheduled_kind(kind: &str) -> Result<ScheduledKind, Messagin
107
107
  "send" => Ok(ScheduledKind::Send),
108
108
  "health_ping" => Ok(ScheduledKind::HealthPing),
109
109
  "trust_retry" => Ok(ScheduledKind::TrustRetry),
110
+ "enter_rescue" => Ok(ScheduledKind::EnterRescue),
110
111
  other => Err(MessagingError::Validation(format!(
111
112
  "unknown scheduled event kind: {other}"
112
113
  ))),
@@ -64,6 +64,7 @@ use crate::transport::{PaneId, Target, Transport};
64
64
  pub mod activity;
65
65
  pub mod address;
66
66
  pub mod delivery;
67
+ mod enter_rescue;
67
68
  pub mod helpers;
68
69
  pub mod leader_channel;
69
70
  pub mod leader_receiver;
@@ -40,6 +40,13 @@ pub fn fire_due_scheduled_events(
40
40
  };
41
41
  let mut fired = Vec::new();
42
42
  for (id, kind, target, payload_json) in due_events {
43
+ if matches!(parse_scheduled_kind(&kind), Ok(ScheduledKind::EnterRescue)) {
44
+ // This kind owns its pre-effect CAS and terminal/no-effect transitions.
45
+ if super::enter_rescue::fire(workspace, store, transport, event_log, id, &payload_json)? {
46
+ fired.push(id);
47
+ }
48
+ continue;
49
+ }
43
50
  // U1 #5: per-event isolation — one poison event (bad payload / fire error) must not
44
51
  // halt the whole pass. The fire body is confined to a closure; on Err we emit
45
52
  // `scheduler.event_failed`, mark the row terminal 'failed' (no re-fire), and continue.
@@ -80,6 +87,7 @@ pub fn fire_due_scheduled_events(
80
87
  "message_id": outcome.message_id,
81
88
  })
82
89
  }
90
+ ScheduledKind::EnterRescue => return Err(MessagingError::Validation("enter rescue requires atomic dispatcher".into())),
83
91
  ScheduledKind::HealthPing => {
84
92
  event_log.write(
85
93
  "scheduled.health_ping",
@@ -117,6 +117,8 @@ fn scheduled_kind_serde_byte_locked() {
117
117
  serde_json::to_string(&ScheduledKind::TrustRetry).unwrap(),
118
118
  "\"trust_retry\""
119
119
  );
120
+ assert_eq!(serde_json::to_string(&ScheduledKind::EnterRescue).unwrap(), "\"enter_rescue\"");
121
+ assert_eq!(super::super::helpers::parse_scheduled_kind("enter_rescue").unwrap(), ScheduledKind::EnterRescue);
120
122
  }
121
123
 
122
124
  #[test]
@@ -108,6 +108,7 @@ pub enum ScheduledKind {
108
108
  Send,
109
109
  HealthPing,
110
110
  TrustRetry,
111
+ EnterRescue,
111
112
  }
112
113
 
113
114
  /// classifier 产物 status (card §48;消费 step 8 classifier)。
@@ -185,6 +185,53 @@ impl<'a> StateRepository<'a> {
185
185
  bounded_team_view(&committed, team_key)
186
186
  }
187
187
 
188
+ /// Commit a messaging delta against its exact canonical owner under state-save.
189
+ /// Unlike ordinary compatibility writes, owner loss cannot select raw/default
190
+ /// state, and a failed receiver check cannot be reapplied to another team.
191
+ pub(crate) fn commit_messaging_checked<F>(
192
+ &self,
193
+ intent: StateWriteIntent<'_>,
194
+ update: F,
195
+ ) -> Result<(), StateError>
196
+ where
197
+ F: FnOnce(&mut Value) -> Result<(), StateError>,
198
+ {
199
+ let StateWriteIntent::MessagingDeliveryState { owner_team_id: Some(team) } = intent else {
200
+ return Err(StateError::SaveFailed("checked messaging requires an owner".into()));
201
+ };
202
+ #[cfg(test)]
203
+ test_support::before_commit();
204
+ super::persist::update_runtime_state(self.workspace, |latest| {
205
+ if team.is_empty() || !matches!(
206
+ super::projection::resolve_owner_team_id(latest, team),
207
+ super::projection::OwnerTeamResolution::Canonical(key) if key == team
208
+ ) {
209
+ return Err(StateError::SaveConflict("messaging owner changed before commit".into()));
210
+ }
211
+ let nested = latest.get("teams").and_then(Value::as_object)
212
+ .is_some_and(|teams| teams.contains_key(team));
213
+ let mut selected = if nested {
214
+ super::projection::project_top_level_view(latest, team)
215
+ } else if legacy_single_team_state(latest)
216
+ && (latest.get("active_team_key").and_then(Value::as_str) == Some(team)
217
+ || super::projection::team_state_key(latest) == team
218
+ || latest.get("session_name").and_then(Value::as_str)
219
+ .and_then(|session| session.strip_prefix("team-")) == Some(team))
220
+ {
221
+ latest.clone()
222
+ } else {
223
+ return Err(StateError::SaveConflict("messaging owner no longer exists".into()));
224
+ };
225
+ update(&mut selected)?;
226
+ Ok(if nested {
227
+ super::projection::merge_committed_team(latest, &selected, team)
228
+ } else {
229
+ selected
230
+ })
231
+ })?;
232
+ Ok(())
233
+ }
234
+
188
235
  /// Atomically replace only the fork target row in the selected team.
189
236
  pub(crate) fn commit_fork_agent(
190
237
  &self,