@team-agent/installer 0.5.115 → 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.
Files changed (36) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/mod.rs +143 -12
  4. package/crates/team-agent/src/cli/shutdown_socket.rs +288 -0
  5. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +11 -4
  6. package/crates/team-agent/src/contract_runtime/backend.rs +88 -8
  7. package/crates/team-agent/src/contract_runtime/commands.rs +88 -7
  8. package/crates/team-agent/src/contract_runtime/discovery.rs +42 -10
  9. package/crates/team-agent/src/contract_runtime/mcp.rs +3 -0
  10. package/crates/team-agent/src/contract_runtime/pump.rs +2 -0
  11. package/crates/team-agent/src/contract_runtime/registry.rs +86 -9
  12. package/crates/team-agent/src/contract_runtime/workspace.rs +79 -11
  13. package/crates/team-agent/src/leader/lease.rs +1 -18
  14. package/crates/team-agent/src/lifecycle/pane_input_lock.rs +86 -8
  15. package/crates/team-agent/src/lifecycle/restart/agent.rs +4 -0
  16. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +101 -72
  17. package/crates/team-agent/src/lifecycle/restart/selection.rs +30 -8
  18. package/crates/team-agent/src/messaging/delivery.rs +132 -27
  19. package/crates/team-agent/src/messaging/enter_rescue.rs +334 -0
  20. package/crates/team-agent/src/messaging/enter_rescue_tests.rs +566 -0
  21. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  22. package/crates/team-agent/src/messaging/mod.rs +1 -0
  23. package/crates/team-agent/src/messaging/scheduler.rs +8 -0
  24. package/crates/team-agent/src/messaging/tests/basic.rs +2 -0
  25. package/crates/team-agent/src/messaging/types.rs +1 -0
  26. package/crates/team-agent/src/provider/session/capture.rs +24 -1
  27. package/crates/team-agent/src/state/persist.rs +72 -2
  28. package/crates/team-agent/src/state/projection.rs +29 -0
  29. package/crates/team-agent/src/state/repository/tests.rs +70 -0
  30. package/crates/team-agent/src/state/repository.rs +52 -1
  31. package/crates/team-agent/src/tmux_backend/native_input.rs +494 -0
  32. package/crates/team-agent/src/tmux_backend/native_input_tests.rs +781 -0
  33. package/crates/team-agent/src/tmux_backend.rs +49 -6
  34. package/crates/team-agent/src/transport/native_input.rs +594 -0
  35. package/crates/team-agent/src/transport.rs +19 -0
  36. package/package.json +4 -4
@@ -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)。
@@ -868,7 +868,8 @@ fn incomplete_resumable_provider<F>(agent: &Value, adapter_for: &mut F) -> Optio
868
868
  where
869
869
  F: FnMut(Provider) -> Box<dyn ProviderAdapter>,
870
870
  {
871
- if agent
871
+ if crate::state::projection::agent_explicit_stop(agent)
872
+ || agent
872
873
  .get("status")
873
874
  .and_then(Value::as_str)
874
875
  .is_some_and(|status| status != "running")
@@ -892,6 +893,28 @@ where
892
893
  Some(provider)
893
894
  }
894
895
 
896
+ #[cfg(test)]
897
+ mod stopped_capture_tests {
898
+ use super::*;
899
+
900
+ #[test]
901
+ fn explicit_stop_is_checked_before_adapter_or_backing_probes() {
902
+ for row in [
903
+ serde_json::json!({"status":"stopped","provider":"pi","spawn_cwd":"/controlled"}),
904
+ serde_json::json!({"status":"running","explicit_stop":true,"provider":"pi","spawn_cwd":"/controlled"}),
905
+ serde_json::json!({"status":"running","explicit_stop":"false","provider":"pi","spawn_cwd":"/controlled"}),
906
+ ] {
907
+ assert!(incomplete_resumable_provider(&row, &mut |_| panic!("held seat reached adapter lookup")).is_none());
908
+ }
909
+ let mut calls = 0;
910
+ let _ = incomplete_resumable_provider(
911
+ &serde_json::json!({"status":"running","explicit_stop":false,"provider":"pi","spawn_cwd":"/controlled"}),
912
+ &mut |provider| { calls += 1; crate::provider::get_adapter(provider) },
913
+ );
914
+ assert_eq!(calls, 1, "active control must still be eligible for capture");
915
+ }
916
+ }
917
+
895
918
  fn agent_session_complete(agent: &Value) -> bool {
896
919
  // RM-039-SESS-001 step 1 (architect verdict 2026-06-22): historically
897
920
  // this returned true the moment `rollout_path` was non-empty — even when