@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/leader/lease.rs +1 -18
- package/crates/team-agent/src/lifecycle/pane_input_lock.rs +86 -8
- package/crates/team-agent/src/messaging/delivery.rs +132 -27
- package/crates/team-agent/src/messaging/enter_rescue.rs +334 -0
- package/crates/team-agent/src/messaging/enter_rescue_tests.rs +566 -0
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/mod.rs +1 -0
- package/crates/team-agent/src/messaging/scheduler.rs +8 -0
- package/crates/team-agent/src/messaging/tests/basic.rs +2 -0
- package/crates/team-agent/src/messaging/types.rs +1 -0
- package/crates/team-agent/src/state/repository.rs +47 -0
- package/crates/team-agent/src/tmux_backend/native_input.rs +494 -0
- package/crates/team-agent/src/tmux_backend/native_input_tests.rs +781 -0
- package/crates/team-agent/src/tmux_backend.rs +49 -6
- package/crates/team-agent/src/transport/native_input.rs +594 -0
- package/crates/team-agent/src/transport.rs +19 -0
- 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(×.2).unwrap()
|
|
297
|
+
- DateTime::parse_from_rfc3339(×.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
|
))),
|
|
@@ -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]
|
|
@@ -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,
|