@team-agent/installer 0.5.118 → 0.5.120

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 (72) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +46 -1
  4. package/README.zh.md +46 -1
  5. package/crates/team-agent/src/cli/adapters.rs +11 -0
  6. package/crates/team-agent/src/cli/arguments.rs +401 -0
  7. package/crates/team-agent/src/cli/emit.rs +241 -142
  8. package/crates/team-agent/src/cli/intent/runtime.rs +1139 -0
  9. package/crates/team-agent/src/cli/intent/runtime_tests.rs +584 -0
  10. package/crates/team-agent/src/cli/intent/tests.rs +654 -0
  11. package/crates/team-agent/src/cli/intent.rs +642 -0
  12. package/crates/team-agent/src/cli/mod.rs +49 -5
  13. package/crates/team-agent/src/cli/named_address.rs +10 -1
  14. package/crates/team-agent/src/cli/spec.rs +22 -10
  15. package/crates/team-agent/src/cli/status_port/brief.rs +117 -16
  16. package/crates/team-agent/src/cli/tests/base.rs +6 -1
  17. package/crates/team-agent/src/cli/tests/parser_safety.rs +383 -0
  18. package/crates/team-agent/src/cli/tests/run_delegation.rs +63 -19
  19. package/crates/team-agent/src/cli/tests/verb_install_skill.rs +3 -2
  20. package/crates/team-agent/src/cli/tests/verb_profile.rs +3 -1
  21. package/crates/team-agent/src/contract_runtime/backend.rs +1 -1
  22. package/crates/team-agent/src/db/message_store.rs +10 -3
  23. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +166 -77
  24. package/crates/team-agent/src/lifecycle/launch.rs +28 -0
  25. package/crates/team-agent/src/lifecycle/restart.rs +17 -0
  26. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +1 -3
  27. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +5 -15
  28. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +2 -3
  29. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +2 -3
  30. package/crates/team-agent/src/lifecycle/tests/cli_argv_routing_red.rs +10 -2
  31. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +8 -1
  32. package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +3 -2
  33. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +6 -9
  34. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +7 -5
  35. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +4 -2
  36. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +5 -2
  37. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +4 -3
  38. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +2 -4
  39. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +3 -4
  40. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +5 -4
  41. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +2 -4
  42. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +78 -36
  43. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +8 -4
  44. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +6 -12
  45. package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +4 -0
  46. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +1 -3
  47. package/crates/team-agent/src/lifecycle/tests/status_credential_redaction_contract.rs +7 -0
  48. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +1 -3
  49. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +2 -1
  50. package/crates/team-agent/src/lifecycle/types.rs +10 -0
  51. package/crates/team-agent/src/messaging/delivery.rs +132 -39
  52. package/crates/team-agent/src/messaging/delivery_consumption_tests.rs +223 -0
  53. package/crates/team-agent/src/messaging/enter_rescue.rs +182 -154
  54. package/crates/team-agent/src/messaging/enter_rescue_tests.rs +344 -277
  55. package/crates/team-agent/src/messaging/mod.rs +1 -1
  56. package/crates/team-agent/src/tmux_backend/enter_rescue.rs +325 -0
  57. package/crates/team-agent/src/tmux_backend/enter_rescue_tests.rs +432 -0
  58. package/crates/team-agent/src/tmux_backend/native_input.rs +19 -0
  59. package/crates/team-agent/src/tmux_backend/tests.rs +37 -0
  60. package/crates/team-agent/src/tmux_backend.rs +35 -1
  61. package/crates/team-agent/src/transport/enter_rescue.rs +166 -0
  62. package/crates/team-agent/src/transport/native_input.rs +5 -1
  63. package/crates/team-agent/src/transport/test_support.rs +25 -0
  64. package/crates/team-agent/src/transport.rs +34 -0
  65. package/package.json +4 -4
  66. package/skills/team-agent/SKILL.md +71 -54
  67. package/skills/team-agent/command-coverage.json +283 -13
  68. package/skills/team-agent/docs/reference/team-agent-operator.md +220 -0
  69. package/skills/team-agent/references/bug-as-artifact-flow.md +1 -1
  70. package/skills/team-agent/references/recovery-runbook.md +61 -212
  71. package/skills/team-agent/references/team-agent-operator.md +181 -48
  72. package/skills/team-agent/references/team-in-team.md +47 -80
@@ -1,64 +1,58 @@
1
1
  #![allow(clippy::unwrap_used, clippy::panic)]
2
2
  use super::*;
3
3
  use crate::model::enums::Provider;
4
- use crate::transport::native_input::{NativeInputBinding, NativeInputRequest};
4
+ use crate::transport::enter_rescue::{EnterRescueProof, EnterRescueTarget};
5
+ use std::cell::Cell;
5
6
  use std::sync::atomic::{AtomicUsize, Ordering};
6
7
 
7
8
  struct Fixture {
8
9
  workspace: std::path::PathBuf,
9
10
  store: MessageStore,
10
11
  log: EventLog,
11
- evidence: NativeInputEvidence,
12
+ context: EnterRescueContext,
12
13
  }
13
14
  impl Fixture {
14
15
  fn new() -> Self {
15
16
  static NEXT: AtomicUsize = AtomicUsize::new(0);
16
17
  let workspace = std::env::temp_dir().join(format!(
17
- "ta-enter-rescue-{}-{}",
18
+ "ta-independent-rescue-{}-{}",
18
19
  std::process::id(),
19
20
  NEXT.fetch_add(1, Ordering::SeqCst)
20
21
  ));
21
22
  std::fs::create_dir(&workspace).unwrap();
22
23
  let store = MessageStore::open(&workspace).unwrap();
23
24
  let log = EventLog::new(&workspace);
24
- let request = NativeInputRequest {
25
+ let context = EnterRescueContext {
25
26
  message_id: "msg_one".into(),
26
27
  owner_team_id: "team".into(),
27
28
  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(),
29
+ payload_sha256: digest(&render_message("leader", None, "body", "msg_one")),
30
+ target: Some(EnterRescueTarget {
31
+ provider: Provider::Pi,
32
+ endpoint: "/tmp/owned.sock".into(),
38
33
  pane_id: "%1".into(),
39
34
  pane_pid: 2,
40
35
  pane_height: 120,
41
- carrier_birth: "test-birth".into(),
42
- binding_nonce: "test-nonce".into(),
36
+ carrier_birth: "original-birth".into(),
37
+ binding_nonce: "original-nonce".into(),
43
38
  session: "session".into(),
44
39
  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,
40
+ }),
41
+ proof: Some(EnterRescueProof {
42
+ ui_identity: "pi-model".into(),
43
+ footer_sha256: digest("footer"),
44
+ visible_marker: true,
45
+ transcript_marker_absent: true,
46
+ owned_capsule: None,
47
+ }),
53
48
  };
54
49
  let fixture = Self {
55
50
  workspace,
56
51
  store,
57
52
  log,
58
- evidence,
53
+ context,
59
54
  };
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();
55
+ fixture.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','submitted_unverified','old','old',1)",[]).unwrap();
62
56
  fixture.state("running", false);
63
57
  fixture
64
58
  }
@@ -73,23 +67,21 @@ impl Fixture {
73
67
  )
74
68
  .unwrap();
75
69
  }
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();
70
+ fn schedule(&self, age: i64, bound: bool) {
71
+ let mut conn = self.conn();
72
+ let tx = conn.transaction().unwrap();
73
+ schedule_in_tx(&tx, "msg_one", Utc::now() - Duration::seconds(age)).unwrap();
74
+ tx.commit().unwrap();
75
+ if bound {
76
+ assert!(bind_pending_context(&self.store, &self.context).unwrap());
77
+ }
86
78
  }
87
79
  fn job(&self) -> (i64, String, String) {
88
80
  self.conn()
89
81
  .query_row(
90
82
  "select id,payload_json,status from scheduled_events",
91
83
  [],
92
- |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
84
+ |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
93
85
  )
94
86
  .unwrap()
95
87
  }
@@ -98,10 +90,22 @@ impl Fixture {
98
90
  .query_row(
99
91
  "select status from messages where message_id='msg_one'",
100
92
  [],
101
- |row| row.get(0),
93
+ |r| r.get(0),
102
94
  )
103
95
  .unwrap()
104
96
  }
97
+ fn fire(&self, execute: &Executor<'_>) -> bool {
98
+ let (id, payload, _) = self.job();
99
+ fire_with(
100
+ &self.workspace,
101
+ &self.store,
102
+ &self.log,
103
+ id,
104
+ &payload,
105
+ execute,
106
+ )
107
+ .unwrap()
108
+ }
105
109
  }
106
110
  impl Drop for Fixture {
107
111
  fn drop(&mut self) {
@@ -112,11 +116,11 @@ impl Drop for Fixture {
112
116
  #[test]
113
117
  fn enter_rescue_first_successful_key_arms_original_turn_before_acceptance_poll() {
114
118
  let f = Fixture::new();
115
- f.park(95);
119
+ f.schedule(95, true);
116
120
  super::super::delivery::with_rescue_submit_observer(
117
121
  &f.workspace,
118
122
  &f.store,
119
- &f.evidence,
123
+ &f.context,
120
124
  &f.log,
121
125
  |observer| {
122
126
  observer.after_physical_submit();
@@ -144,7 +148,7 @@ fn enter_rescue_post_key_owner_or_receiver_loss_is_unknown_without_cross_team_wr
144
148
  "stopped",
145
149
  ] {
146
150
  let f = Fixture::new();
147
- f.park(95);
151
+ f.schedule(95, true);
148
152
  let (id, payload, _) = f.job();
149
153
  let effects = AtomicUsize::new(0);
150
154
  let path = crate::model::paths::runtime_dir(&f.workspace).join("state.json");
@@ -156,11 +160,11 @@ fn enter_rescue_post_key_owner_or_receiver_loss_is_unknown_without_cross_team_wr
156
160
  &f.log,
157
161
  id,
158
162
  &payload,
159
- &|evidence, _| {
163
+ &|context, _| {
160
164
  super::super::delivery::with_rescue_submit_observer(
161
165
  &f.workspace,
162
166
  &f.store,
163
- evidence,
167
+ context,
164
168
  &f.log,
165
169
  |observer| {
166
170
  effects.fetch_add(1, Ordering::SeqCst);
@@ -196,7 +200,7 @@ fn enter_rescue_post_key_owner_or_receiver_loss_is_unknown_without_cross_team_wr
196
200
  },
197
201
  )
198
202
  .unwrap();
199
- // The rejected commit must not change even one byte of the replacement state.
203
+ // A rejected post-key commit must preserve every byte of replacement state.
200
204
  let expected = replacement.borrow().clone();
201
205
  assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
202
206
  assert_eq!(f.job().2, "unknown", "{change}");
@@ -223,7 +227,7 @@ fn enter_rescue_post_key_owner_or_receiver_loss_is_unknown_without_cross_team_wr
223
227
  #[test]
224
228
  fn enter_rescue_owner_loss_at_state_save_hook_refuses_without_default_fallback() {
225
229
  let f = Fixture::new();
226
- f.park(95);
230
+ f.schedule(95, true);
227
231
  let path = crate::model::paths::runtime_dir(&f.workspace).join("state.json");
228
232
  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
233
  let hook_path = path.clone();
@@ -234,7 +238,7 @@ fn enter_rescue_owner_loss_at_state_save_hook_refuses_without_default_fallback()
234
238
  assert!(super::super::delivery::with_rescue_submit_observer(
235
239
  &f.workspace,
236
240
  &f.store,
237
- &f.evidence,
241
+ &f.context,
238
242
  &f.log,
239
243
  |observer| {
240
244
  observer.after_physical_submit();
@@ -249,9 +253,9 @@ fn enter_rescue_owner_loss_at_state_save_hook_refuses_without_default_fallback()
249
253
  #[test]
250
254
  fn enter_rescue_switched_top_projection_updates_only_retained_original_owner() {
251
255
  let f = Fixture::new();
252
- f.park(95);
256
+ f.schedule(95, true);
253
257
  super::super::delivery::with_rescue_submit_observer(
254
- &f.workspace, &f.store, &f.evidence, &f.log, |observer| {
258
+ &f.workspace, &f.store, &f.context, &f.log, |observer| {
255
259
  let original = crate::state::persist::load_runtime_state(&f.workspace).unwrap();
256
260
  let mut other = original.clone();
257
261
  other["team_key"] = "other".into();
@@ -277,102 +281,177 @@ fn enter_rescue_switched_top_projection_updates_only_retained_original_owner() {
277
281
  }
278
282
 
279
283
  #[test]
280
- fn enter_rescue_atomic_park_and_due90_no_early_effect() {
284
+ fn every_unverified_row_registers_independently_of_native_receipts() {
281
285
  let f = Fixture::new();
282
- f.park(0);
283
- let (id, payload, status) = f.job();
286
+ f.schedule(0, false);
287
+ let (_, payload, status) = f.job();
288
+ let job: RescueJob = serde_json::from_str(&payload).unwrap();
284
289
  assert_eq!(status, "pending");
285
- assert_eq!(f.message_status(), "submitted_unverified");
286
- let times: (String, String, String) = f
290
+ assert!(job.context.target.is_none());
291
+ assert!(job.context.proof.is_none());
292
+ assert!(!payload.contains("evidence"));
293
+ assert!(!payload.contains("NativeInput"));
294
+ let due: String = f
287
295
  .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
- )
296
+ .query_row("select due_at from scheduled_events", [], |r| r.get(0))
293
297
  .unwrap();
294
- assert_eq!(times.0, "team");
295
298
  assert_eq!(
296
- DateTime::parse_from_rfc3339(&times.2).unwrap()
297
- - DateTime::parse_from_rfc3339(&times.1).unwrap(),
299
+ timestamp(&due).unwrap() - timestamp(&job.registered_at).unwrap(),
298
300
  Duration::seconds(90)
299
301
  );
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
302
  assert_eq!(
311
- f.conn()
312
- .query_row("select count(*) from scheduled_events", [], |r| r
313
- .get::<_, i64>(0))
314
- .unwrap(),
315
- 1
303
+ timestamp(&job.expires_at).unwrap() - timestamp(&job.registered_at).unwrap(),
304
+ Duration::seconds(120)
316
305
  );
317
306
  }
318
307
 
319
308
  #[test]
320
- fn enter_rescue_insert_failure_rolls_back_original_uncertainty() {
309
+ fn even_missing_owner_or_recipient_gets_a_job_then_refuses_without_keys() {
321
310
  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");
311
+ f.conn()
312
+ .execute("update messages set owner_team_id=null,recipient=null", [])
313
+ .unwrap();
314
+ f.schedule(95, false);
315
+ assert_eq!(f.job().2, "pending");
316
+ assert!(f.fire(&|_, _| panic!("unowned registration is not key authority")));
317
+ assert_eq!(f.job().2, "refused");
325
318
  }
326
319
 
327
320
  #[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!(
321
+ fn registration_is_durable_and_never_resets_a_claimed_or_unknown_attempt() {
322
+ for status in ["pending", "claimed", "unknown", "done", "refused"] {
323
+ let f = Fixture::new();
324
+ f.schedule(95, false);
340
325
  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!(
326
+ .execute("update scheduled_events set status=?1", [status])
327
+ .unwrap();
328
+ let original = f.job();
329
+ f.schedule(0, false);
330
+ assert_eq!(f.job(), original);
331
+ let count: i64 = f
332
+ .conn()
333
+ .query_row("select count(*) from scheduled_events", [], |r| r.get(0))
334
+ .unwrap();
335
+ assert_eq!(count, 1);
336
+ }
337
+ }
338
+
339
+ #[test]
340
+ fn unbound_job_is_observed_then_refused_without_executor() {
341
+ let f = Fixture::new();
342
+ f.schedule(95, false);
343
+ assert!(f.fire(&|_, _| panic!("unbound job must not reach keys")));
344
+ assert_eq!(f.job().2, "refused");
345
+ assert_eq!(f.message_status(), "submitted_unverified");
346
+ let result: String = f
347
+ .conn()
348
+ .query_row("select result_json from scheduled_events", [], |r| r.get(0))
349
+ .unwrap();
350
+ assert!(result.contains("proof_missing"));
351
+ }
352
+
353
+ #[test]
354
+ fn supplement_cannot_change_original_clock_scope_digest_or_bound_carrier() {
355
+ let f = Fixture::new();
356
+ f.schedule(95, false);
357
+ let before: (String, String) = f
358
+ .conn()
359
+ .query_row("select due_at,created_at from scheduled_events", [], |r| {
360
+ Ok((r.get(0)?, r.get(1)?))
361
+ })
362
+ .unwrap();
363
+ assert!(bind_pending_context(&f.store, &f.context).unwrap());
364
+ let after: (String, String) = f
365
+ .conn()
366
+ .query_row("select due_at,created_at from scheduled_events", [], |r| {
367
+ Ok((r.get(0)?, r.get(1)?))
368
+ })
369
+ .unwrap();
370
+ assert_eq!(before, after);
371
+ for field in [
372
+ "owner",
373
+ "recipient",
374
+ "digest",
375
+ "pid",
376
+ "birth",
377
+ "nonce",
378
+ "endpoint",
379
+ ] {
380
+ let mut other = f.context.clone();
381
+ match field {
382
+ "owner" => other.owner_team_id = "other".into(),
383
+ "recipient" => other.recipient = "other".into(),
384
+ "digest" => other.payload_sha256 = digest("different"),
385
+ "pid" => other.target.as_mut().unwrap().pane_pid = 3,
386
+ "birth" => other.target.as_mut().unwrap().carrier_birth = "reused".into(),
387
+ "nonce" => other.target.as_mut().unwrap().binding_nonce = "replacement".into(),
388
+ _ => other.target.as_mut().unwrap().endpoint = "/tmp/other.sock".into(),
389
+ }
390
+ assert!(!bind_pending_context(&f.store, &other).unwrap(), "{field}");
391
+ }
392
+ }
393
+
394
+ #[test]
395
+ fn early_expired_or_extended_jobs_have_no_effect() {
396
+ for (age, extend) in [(0, false), (121, false), (95, true)] {
397
+ let f = Fixture::new();
398
+ f.schedule(age, true);
399
+ let (id, payload, _) = f.job();
400
+ let payload = if extend {
401
+ let mut j: RescueJob = serde_json::from_str(&payload).unwrap();
402
+ j.expires_at = (Utc::now() + Duration::hours(1)).to_rfc3339();
403
+ let p = serde_json::to_string(&j).unwrap();
404
+ f.conn()
405
+ .execute("update scheduled_events set payload_json=?1", [&p])
406
+ .unwrap();
407
+ p
408
+ } else {
409
+ payload
410
+ };
348
411
  f.conn()
349
- .query_row("select delivery_attempts from messages", [], |r| r
350
- .get::<_, i64>(0))
351
- .unwrap(),
352
- 1
353
- );
412
+ .execute(
413
+ "update scheduled_events set due_at='1970-01-01T00:00:00+00:00'",
414
+ [],
415
+ )
416
+ .unwrap();
417
+ assert!(fire_with(
418
+ &f.workspace,
419
+ &f.store,
420
+ &f.log,
421
+ id,
422
+ &payload,
423
+ &|_, _| panic!("invalid clock")
424
+ )
425
+ .unwrap());
426
+ assert_eq!(f.job().2, "refused");
427
+ }
354
428
  }
355
429
 
356
430
  #[test]
357
- fn enter_rescue_canonical_stopped_changed_consumed_expired_and_superseded_refuse() {
358
- for case in [
359
- "stopped",
360
- "explicit_stop",
431
+ fn current_canonical_message_and_original_owner_recipient_remain_required() {
432
+ for change in [
433
+ "ack",
361
434
  "content",
362
435
  "owner",
363
- "consumed",
364
- "expired",
365
- "superseded",
366
- "job_scope",
436
+ "recipient",
437
+ "stop",
438
+ "pid",
439
+ "provider",
440
+ "session",
441
+ "window",
442
+ "newer",
367
443
  ] {
368
444
  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),
445
+ f.schedule(95, true);
446
+ match change {
447
+ "ack" => {
448
+ f.conn()
449
+ .execute("update messages set status='acknowledged'", [])
450
+ .unwrap();
451
+ }
373
452
  "content" => {
374
453
  f.conn()
375
- .execute("update messages set content='changed'", [])
454
+ .execute("update messages set content='edited'", [])
376
455
  .unwrap();
377
456
  }
378
457
  "owner" => {
@@ -380,187 +459,175 @@ fn enter_rescue_canonical_stopped_changed_consumed_expired_and_superseded_refuse
380
459
  .execute("update messages set owner_team_id='other'", [])
381
460
  .unwrap();
382
461
  }
383
- "consumed" => {
462
+ "recipient" => {
384
463
  f.conn()
385
- .execute("update messages set status='acknowledged'", [])
464
+ .execute("update messages set recipient='other'", [])
386
465
  .unwrap();
387
466
  }
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();
467
+ "stop" => f.state("running", true),
468
+ "newer" => {
469
+ f.conn().execute("insert into messages(message_id,owner_team_id,recipient,created_at,delivery_attempts) values('msg_new','team','worker','z',1)",[]).unwrap();
390
470
  }
391
- "job_scope" => {
392
- f.conn()
393
- .execute("update scheduled_events set owner_team_id='other'", [])
394
- .unwrap();
471
+ field => {
472
+ let p = crate::model::paths::runtime_dir(&f.workspace).join("state.json");
473
+ let mut state: serde_json::Value =
474
+ serde_json::from_str(&std::fs::read_to_string(&p).unwrap()).unwrap();
475
+ match field {
476
+ "pid" => state["agents"]["worker"]["pane_pid"] = 3.into(),
477
+ "provider" => state["agents"]["worker"]["provider"] = "cursor_agent".into(),
478
+ "session" => state["session_name"] = "other".into(),
479
+ _ => state["agents"]["worker"]["window"] = "other".into(),
480
+ };
481
+ std::fs::write(p, state.to_string()).unwrap();
395
482
  }
396
- "expired" => {}
397
- _ => unreachable!(),
398
483
  }
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}");
484
+ assert!(f.fire(&|_, _| panic!("canonical change {change}")));
485
+ assert_eq!(f.job().2, "refused", "{change}");
410
486
  }
411
487
  }
412
488
 
413
489
  #[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| {
490
+ fn one_cas_and_ambiguous_effect_or_error_never_replay() {
491
+ for error in [false, true] {
492
+ let f = Fixture::new();
493
+ f.schedule(95, true);
494
+ let calls = Cell::new(0);
495
+ assert!(f.fire(&|_, authorize| {
496
+ calls.set(calls.get() + 1);
453
497
  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());
498
+ if error {
499
+ Err(MessagingError::Validation("may have sent".into()))
500
+ } else {
501
+ Ok(EnterRescueOutcome::Entered { accepted: false })
502
+ }
503
+ }));
504
+ assert_eq!(f.job().2, "unknown");
505
+ assert!(!f.fire(&|_, _| panic!("unknown replay")));
506
+ assert_eq!(calls.get(), 1);
507
+ assert_eq!(f.message_status(), "submitted_unverified");
508
+ }
509
+ let f = Fixture::new();
510
+ f.schedule(95, true);
469
511
  f.conn()
470
512
  .execute("update scheduled_events set status='claimed'", [])
471
513
  .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());
514
+ assert!(!f.fire(&|_, _| panic!("crash replay")));
481
515
  }
482
516
 
483
517
  #[test]
484
- fn enter_rescue_concurrent_claim_is_one_effect_and_updates_original_only() {
518
+ fn no_effect_busy_reobserves_within_original_window_only() {
485
519
  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!(
520
+ f.schedule(95, true);
521
+ assert!(f.fire(&|_, _| Ok(EnterRescueOutcome::NoEffect {
522
+ reason: "busy",
523
+ reobserve: true
524
+ })));
525
+ assert_eq!(f.job().2, "pending");
526
+ let due: String = f
527
+ .conn()
528
+ .query_row("select due_at from scheduled_events", [], |r| r.get(0))
529
+ .unwrap();
530
+ assert!(timestamp(&due).unwrap() > Utc::now());
531
+ let f = Fixture::new();
532
+ f.schedule(119, true);
533
+ assert!(f.fire(&|_, _| Ok(EnterRescueOutcome::NoEffect {
534
+ reason: "busy",
535
+ reobserve: true
536
+ })));
537
+ assert_eq!(f.job().2, "refused");
538
+ }
539
+
540
+ #[test]
541
+ fn consumed_or_single_accepted_enter_updates_only_original_unverified_row() {
542
+ for consumed in [false, true] {
543
+ let f = Fixture::new();
544
+ f.schedule(95, true);
545
+ assert!(f.fire(&|_, _| Ok(if consumed {
546
+ EnterRescueOutcome::Consumed
547
+ } else {
548
+ EnterRescueOutcome::Entered { accepted: true }
549
+ })));
550
+ assert_eq!(f.job().2, "done");
551
+ assert_eq!(f.message_status(), "delivered");
552
+ assert!(!f.fire(&|_, _| panic!("done replay")));
553
+ }
554
+ let f = Fixture::new();
555
+ f.schedule(95, true);
556
+ assert!(f.fire(&|_, _| {
513
557
  f.conn()
514
- .query_row("select count(*) from messages", [], |r| r.get::<_, i64>(0))
515
- .unwrap(),
516
- 1
517
- );
558
+ .execute("update messages set status='acknowledged'", [])
559
+ .unwrap();
560
+ Ok(EnterRescueOutcome::Entered { accepted: true })
561
+ }));
562
+ assert_eq!(f.message_status(), "acknowledged");
518
563
  }
519
564
 
520
565
  #[test]
521
- fn enter_rescue_revalidates_at_effect_and_keeps_unknown_errors_terminal() {
566
+ fn final_authorizer_detects_canonical_changes_during_transport_checks() {
522
567
  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();
568
+ f.schedule(95, true);
569
+ assert!(f.fire(&|_, authorize| {
570
+ assert!(authorize());
571
+ f.state("stopped", true);
572
+ assert!(!authorize());
573
+ Ok(EnterRescueOutcome::NoEffect {
574
+ reason: "canonical_changed",
575
+ reobserve: false,
576
+ })
577
+ }));
541
578
  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());
579
+ }
580
+
581
+ #[test]
582
+ fn revoked_scheduler_claim_is_not_key_authority() {
583
+ let f = Fixture::new();
584
+ f.schedule(95, true);
585
+ assert!(f.fire(&|_, authorize| {
586
+ assert!(authorize());
587
+ f.conn()
588
+ .execute("update scheduled_events set status='refused'", [])
589
+ .unwrap();
590
+ assert!(!authorize());
591
+ Ok(EnterRescueOutcome::NoEffect {
592
+ reason: "claim_revoked",
593
+ reobserve: false,
594
+ })
595
+ }));
596
+ assert_eq!(f.job().2, "refused");
597
+ }
598
+
599
+ #[test]
600
+ fn tampered_event_scope_or_untrusted_proof_cannot_execute() {
601
+ for change in ["scope", "capsule", "capsule_scope", "missing"] {
602
+ let f = Fixture::new();
603
+ f.schedule(95, false);
604
+ let mut context = f.context.clone();
605
+ if change == "capsule" || change == "capsule_scope" {
606
+ let scope_sha256 = if change == "capsule_scope" {
607
+ digest("foreign receipt")
608
+ } else {
609
+ context.scope_sha256().unwrap()
610
+ };
611
+ let proof = context.proof.as_mut().unwrap();
612
+ proof.visible_marker = false;
613
+ proof.owned_capsule = Some(crate::transport::enter_rescue::OwnedCapsule {
614
+ scope_sha256,
615
+ label: "[paste #1 +30 lines]".into(),
616
+ baseline_sha256: digest("baseline"),
617
+ baseline_capsules: vec![],
618
+ observed_after_owned_paste: change == "capsule_scope",
619
+ });
620
+ }
621
+ if change == "missing" {
622
+ context.proof = None;
623
+ }
624
+ assert!(bind_pending_context(&f.store, &context).unwrap());
625
+ if change == "scope" {
626
+ f.conn()
627
+ .execute("update scheduled_events set owner_team_id='other'", [])
628
+ .unwrap();
629
+ }
630
+ assert!(f.fire(&|_, _| panic!("untrusted job")));
631
+ assert_eq!(f.job().2, "refused");
632
+ }
566
633
  }