@team-agent/installer 0.5.74 → 0.5.77
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/README.md +9 -0
- package/README.zh.md +8 -0
- package/crates/team-agent/src/cli/adapters.rs +23 -96
- package/crates/team-agent/src/cli/emit.rs +35 -7
- package/crates/team-agent/src/cli/mod.rs +63 -42
- package/crates/team-agent/src/cli/models.rs +138 -27
- package/crates/team-agent/src/cli/status_port/brief.rs +1137 -0
- package/crates/team-agent/src/cli/status_port.rs +8 -0
- package/crates/team-agent/src/cli/tests/mod.rs +1 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +29 -24
- package/crates/team-agent/src/cli/tests/status_brief.rs +691 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +8 -2
- package/crates/team-agent/src/layout/worker_env.rs +366 -0
- package/crates/team-agent/src/leader/lease.rs +274 -40
- package/crates/team-agent/src/leader/registry.rs +177 -19
- package/crates/team-agent/src/leader/types.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +93 -5
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1820 -60
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +138 -13
- package/crates/team-agent/src/lifecycle/restart/common.rs +3 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +11 -4
- package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +45 -14
- package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +1 -0
- package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +24 -28
- package/crates/team-agent/src/lifecycle/tests/upgrade_compat_0211_red.rs +22 -10
- package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +110 -31
- package/crates/team-agent/src/lifecycle/types.rs +4 -0
- package/crates/team-agent/src/messaging/delivery.rs +200 -29
- package/crates/team-agent/src/messaging/leader_channel.rs +12 -3
- package/crates/team-agent/src/messaging/watchers.rs +48 -4
- package/crates/team-agent/src/state/paths.rs +17 -2
- package/crates/team-agent/src/state/persist.rs +166 -6
- package/crates/team-agent/src/state/projection.rs +18 -2
- package/crates/team-agent/src/state/repository.rs +36 -0
- package/crates/team-agent/src/state/selector.rs +23 -3
- package/crates/team-agent/src/test_support/models_timeout_fixture.rs +122 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +350 -7
- package/crates/team-agent/src/tmux_backend.rs +217 -26
- package/crates/team-agent/src/transport/test_support.rs +18 -3
- package/crates/team-agent/src/transport.rs +99 -0
- package/package.json +4 -4
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
//! - 团队嵌套超过两层直接拒绝
|
|
21
21
|
//! maturity: wired
|
|
22
22
|
//! ---
|
|
23
|
+
#[cfg(test)]
|
|
24
|
+
#[path = "../../../tests/support/hermetic.rs"]
|
|
25
|
+
mod hermetic;
|
|
26
|
+
|
|
23
27
|
use std::collections::{BTreeMap, BTreeSet};
|
|
24
28
|
use std::path::{Path, PathBuf};
|
|
25
29
|
use std::process::Command;
|
|
@@ -38,6 +42,9 @@ use super::*;
|
|
|
38
42
|
|
|
39
43
|
trait FreshQuickStartLeaderBindingOps {
|
|
40
44
|
fn caller_pane(&mut self) -> Option<String>;
|
|
45
|
+
fn caller_resolution(&mut self) -> crate::layout::worker_env::CallerProviderResolution {
|
|
46
|
+
crate::layout::worker_env::CallerProviderResolution::Absent
|
|
47
|
+
}
|
|
41
48
|
fn explicit_provider(&mut self) -> Option<String>;
|
|
42
49
|
fn tmux_endpoint(&mut self) -> Option<String>;
|
|
43
50
|
fn observe_command(&mut self, pane: &PaneId) -> Option<String>;
|
|
@@ -48,12 +55,36 @@ trait FreshQuickStartLeaderBindingOps {
|
|
|
48
55
|
pane: &PaneId,
|
|
49
56
|
provider: crate::provider::Provider,
|
|
50
57
|
) -> bool;
|
|
58
|
+
fn attach_failure_reason(&self) -> Option<&'static str> {
|
|
59
|
+
None
|
|
60
|
+
}
|
|
61
|
+
fn attach_cas_conflict(&self) -> bool {
|
|
62
|
+
false
|
|
63
|
+
}
|
|
64
|
+
fn applied_grant(&self) -> Option<(serde_json::Value, serde_json::Value)> {
|
|
65
|
+
None
|
|
66
|
+
}
|
|
67
|
+
fn registry_receipt(&self) -> Option<crate::leader::registry::RegistryWriteReceipt> {
|
|
68
|
+
None
|
|
69
|
+
}
|
|
51
70
|
fn register(&mut self, workspace: &Path, team_key: &str) -> bool;
|
|
71
|
+
fn register_failure_reason(&self) -> Option<&'static str> {
|
|
72
|
+
None
|
|
73
|
+
}
|
|
52
74
|
fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool;
|
|
75
|
+
fn readback_failure_reason(&self) -> Option<&'static str> {
|
|
76
|
+
None
|
|
77
|
+
}
|
|
53
78
|
}
|
|
54
79
|
|
|
55
80
|
struct RuntimeFreshQuickStartLeaderBindingOps<'a> {
|
|
56
81
|
transport: &'a dyn Transport,
|
|
82
|
+
last_failure_reason: Option<&'static str>,
|
|
83
|
+
cas_conflict: bool,
|
|
84
|
+
applied_grant: Option<(serde_json::Value, serde_json::Value)>,
|
|
85
|
+
registry_receipt: Option<crate::leader::registry::RegistryWriteReceipt>,
|
|
86
|
+
nonce_writer:
|
|
87
|
+
Option<&'a dyn Fn(&str, &PaneId, &str) -> Result<String, crate::leader::LeaderError>>,
|
|
57
88
|
}
|
|
58
89
|
|
|
59
90
|
impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<'_> {
|
|
@@ -63,6 +94,18 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
63
94
|
.filter(|pane| !pane.is_empty())
|
|
64
95
|
}
|
|
65
96
|
|
|
97
|
+
fn caller_resolution(&mut self) -> crate::layout::worker_env::CallerProviderResolution {
|
|
98
|
+
let current_pane = std::env::var("TMUX_PANE")
|
|
99
|
+
.ok()
|
|
100
|
+
.filter(|pane| !pane.is_empty());
|
|
101
|
+
let current_endpoint = crate::tmux_backend::socket_name_from_tmux_env();
|
|
102
|
+
crate::layout::worker_env::caller_provider_resolution(
|
|
103
|
+
current_pane.as_deref(),
|
|
104
|
+
current_endpoint.as_deref(),
|
|
105
|
+
self.transport.tmux_endpoint().as_deref(),
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
66
109
|
fn explicit_provider(&mut self) -> Option<String> {
|
|
67
110
|
std::env::var("TEAM_AGENT_LEADER_PROVIDER")
|
|
68
111
|
.ok()
|
|
@@ -88,8 +131,36 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
88
131
|
pane: &PaneId,
|
|
89
132
|
provider: crate::provider::Provider,
|
|
90
133
|
) -> bool {
|
|
134
|
+
self.cas_conflict = false;
|
|
135
|
+
self.applied_grant = None;
|
|
136
|
+
self.registry_receipt = None;
|
|
91
137
|
let event_log = crate::event_log::EventLog::new(workspace);
|
|
92
|
-
|
|
138
|
+
let targets = match self.transport.list_targets() {
|
|
139
|
+
Ok(targets) => targets,
|
|
140
|
+
Err(_) => {
|
|
141
|
+
self.last_failure_reason = Some("caller_pane_unobservable");
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
let Some(caller_target) = targets
|
|
146
|
+
.into_iter()
|
|
147
|
+
.find(|target| target.pane_id.as_str() == pane.as_str())
|
|
148
|
+
else {
|
|
149
|
+
self.last_failure_reason = Some("caller_pane_not_live");
|
|
150
|
+
return false;
|
|
151
|
+
};
|
|
152
|
+
if !caller_target.active
|
|
153
|
+
|| caller_target
|
|
154
|
+
.current_path
|
|
155
|
+
.as_ref()
|
|
156
|
+
.is_none_or(|path| path.as_os_str().is_empty())
|
|
157
|
+
{
|
|
158
|
+
self.last_failure_reason = Some("caller_cwd_unobservable");
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
let expected_owner = state.get("team_owner").cloned();
|
|
162
|
+
let expected_receiver = state.get("leader_receiver").cloned();
|
|
163
|
+
match crate::leader::attach_leader_to_state_with_target_and_controls(
|
|
93
164
|
workspace,
|
|
94
165
|
state,
|
|
95
166
|
Some(pane),
|
|
@@ -97,24 +168,159 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
97
168
|
&event_log,
|
|
98
169
|
crate::leader::LeaseSource::QuickStart,
|
|
99
170
|
true,
|
|
100
|
-
|
|
101
|
-
|
|
171
|
+
Some(&caller_target),
|
|
172
|
+
expected_owner.as_ref(),
|
|
173
|
+
expected_receiver.as_ref(),
|
|
174
|
+
self.nonce_writer,
|
|
175
|
+
) {
|
|
176
|
+
Ok((receiver, _)) => {
|
|
177
|
+
let owner = state.get("team_owner").cloned();
|
|
178
|
+
let receiver = serde_json::to_value(receiver).ok();
|
|
179
|
+
self.applied_grant = owner.zip(receiver);
|
|
180
|
+
self.last_failure_reason = None;
|
|
181
|
+
true
|
|
182
|
+
}
|
|
183
|
+
Err(error) => {
|
|
184
|
+
let (error, applied_grant) = error.into_parts();
|
|
185
|
+
self.applied_grant = applied_grant.map(|grant| (grant.owner, grant.receiver));
|
|
186
|
+
self.cas_conflict = match &error {
|
|
187
|
+
crate::leader::LeaderError::State(crate::state::StateError::SaveConflict(_)) => true,
|
|
188
|
+
_ => false,
|
|
189
|
+
};
|
|
190
|
+
self.last_failure_reason = Some(if self.cas_conflict {
|
|
191
|
+
"attach_cas_conflict"
|
|
192
|
+
} else {
|
|
193
|
+
match &error {
|
|
194
|
+
crate::leader::LeaderError::Validation(_) => "attach_validation_failed",
|
|
195
|
+
crate::leader::LeaderError::Tmux(_) => "attach_transport_failed",
|
|
196
|
+
crate::leader::LeaderError::State(_) => "attach_state_failed",
|
|
197
|
+
crate::leader::LeaderError::Identity(_) => "attach_identity_failed",
|
|
198
|
+
crate::leader::LeaderError::Io(_)
|
|
199
|
+
| crate::leader::LeaderError::Json(_)
|
|
200
|
+
| crate::leader::LeaderError::MessageStore(_)
|
|
201
|
+
| crate::leader::LeaderError::Start(_) => "attach_failed",
|
|
202
|
+
crate::leader::LeaderError::EventLog(_) => "attach_event_log_failed",
|
|
203
|
+
crate::leader::LeaderError::Messaging(_) => "attach_watcher_failed",
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
false
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
fn attach_failure_reason(&self) -> Option<&'static str> {
|
|
212
|
+
self.last_failure_reason
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
fn attach_cas_conflict(&self) -> bool {
|
|
216
|
+
self.cas_conflict
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
fn applied_grant(&self) -> Option<(serde_json::Value, serde_json::Value)> {
|
|
220
|
+
self.applied_grant.clone()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
fn registry_receipt(&self) -> Option<crate::leader::registry::RegistryWriteReceipt> {
|
|
224
|
+
self.registry_receipt.clone()
|
|
102
225
|
}
|
|
103
226
|
|
|
104
227
|
fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
105
|
-
crate::leader::registry::register_binding_from_state_best_effort(
|
|
228
|
+
match crate::leader::registry::register_binding_from_state_best_effort(
|
|
106
229
|
workspace,
|
|
107
230
|
Some(team_key),
|
|
108
231
|
"quick-start",
|
|
109
|
-
)
|
|
110
|
-
|
|
232
|
+
) {
|
|
233
|
+
Some(outcome) if outcome.status == "registered" && outcome.path.is_some() => {
|
|
234
|
+
self.registry_receipt = outcome.receipt;
|
|
235
|
+
self.last_failure_reason = None;
|
|
236
|
+
true
|
|
237
|
+
}
|
|
238
|
+
Some(outcome) => {
|
|
239
|
+
self.last_failure_reason = Some(if outcome.status == "write_failed" {
|
|
240
|
+
"registry_write_failed"
|
|
241
|
+
} else {
|
|
242
|
+
"registry_registration_failed"
|
|
243
|
+
});
|
|
244
|
+
false
|
|
245
|
+
}
|
|
246
|
+
None => {
|
|
247
|
+
self.last_failure_reason = Some("registry_state_unavailable");
|
|
248
|
+
false
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
fn register_failure_reason(&self) -> Option<&'static str> {
|
|
254
|
+
self.last_failure_reason
|
|
111
255
|
}
|
|
112
256
|
|
|
113
257
|
fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
114
|
-
launched_team_receiver_is_attached(workspace, team_key)
|
|
258
|
+
if !launched_team_receiver_is_attached(workspace, team_key) {
|
|
259
|
+
self.last_failure_reason = Some("registry_readback_unavailable");
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
let Ok(resolved) = crate::state::projection::resolve_runtime_team_scope(
|
|
263
|
+
workspace,
|
|
264
|
+
Some(team_key),
|
|
265
|
+
) else {
|
|
266
|
+
self.last_failure_reason = Some("receiver_scope_unavailable");
|
|
267
|
+
return false;
|
|
268
|
+
};
|
|
269
|
+
let Some(receiver) = resolved.state.get("leader_receiver") else {
|
|
270
|
+
self.last_failure_reason = Some("receiver_missing");
|
|
271
|
+
return false;
|
|
272
|
+
};
|
|
273
|
+
if !matches!(
|
|
274
|
+
crate::messaging::resolve_live_leader_channel(
|
|
275
|
+
workspace,
|
|
276
|
+
receiver,
|
|
277
|
+
self.transport,
|
|
278
|
+
),
|
|
279
|
+
crate::messaging::LeaderChannelResolution::Live(_)
|
|
280
|
+
) {
|
|
281
|
+
self.last_failure_reason = Some("receiver_live_channel_unavailable");
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
self.last_failure_reason = None;
|
|
285
|
+
true
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
fn readback_failure_reason(&self) -> Option<&'static str> {
|
|
289
|
+
self.last_failure_reason
|
|
115
290
|
}
|
|
116
291
|
}
|
|
117
292
|
|
|
293
|
+
const FRESH_BIND_REFUSAL_EVENT: &str = "quick_start.leader_bind_refused";
|
|
294
|
+
|
|
295
|
+
// The event log is already scoped to `workspace`; `team_key` keeps a
|
|
296
|
+
// multi-team log unambiguous without copying paths or caller details.
|
|
297
|
+
fn record_fresh_bind_refusal(
|
|
298
|
+
workspace: &Path,
|
|
299
|
+
team_key: &str,
|
|
300
|
+
stage: &'static str,
|
|
301
|
+
reason: &'static str,
|
|
302
|
+
) {
|
|
303
|
+
let _ = crate::event_log::EventLog::new(workspace).write(
|
|
304
|
+
FRESH_BIND_REFUSAL_EVENT,
|
|
305
|
+
serde_json::json!({
|
|
306
|
+
"operation": "fresh_quick_start_leader_bind",
|
|
307
|
+
"team_key": team_key,
|
|
308
|
+
"stage": stage,
|
|
309
|
+
"reason": reason,
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
fn refuse_fresh_bind(
|
|
315
|
+
workspace: &Path,
|
|
316
|
+
team_key: &str,
|
|
317
|
+
stage: &'static str,
|
|
318
|
+
reason: &'static str,
|
|
319
|
+
) -> Result<bool, LifecycleError> {
|
|
320
|
+
record_fresh_bind_refusal(workspace, team_key, stage, reason);
|
|
321
|
+
Ok(false)
|
|
322
|
+
}
|
|
323
|
+
|
|
118
324
|
struct BindingFileSnapshot {
|
|
119
325
|
path: PathBuf,
|
|
120
326
|
bytes: Option<Vec<u8>>,
|
|
@@ -144,6 +350,7 @@ impl BindingFileSnapshot {
|
|
|
144
350
|
}
|
|
145
351
|
}
|
|
146
352
|
}
|
|
353
|
+
|
|
147
354
|
}
|
|
148
355
|
|
|
149
356
|
fn binding_snapshots(workspace: &Path, team_key: &str) -> Vec<BindingFileSnapshot> {
|
|
@@ -240,28 +447,53 @@ fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
|
|
|
240
447
|
workspace,
|
|
241
448
|
Some(team_key),
|
|
242
449
|
) else {
|
|
243
|
-
return
|
|
450
|
+
return refuse_fresh_bind(workspace, team_key, "scope", "scope_unresolved");
|
|
244
451
|
};
|
|
245
452
|
if resolved.canonical_team_key != team_key {
|
|
246
|
-
return
|
|
453
|
+
return refuse_fresh_bind(workspace, team_key, "scope", "scope_key_mismatch");
|
|
247
454
|
}
|
|
248
455
|
let mut state = resolved.state;
|
|
249
456
|
let Some(pane) = ops.caller_pane().filter(|pane| !pane.is_empty()) else {
|
|
250
|
-
return
|
|
457
|
+
return refuse_fresh_bind(workspace, team_key, "caller_pane", "caller_pane_missing");
|
|
251
458
|
};
|
|
252
459
|
let pane = PaneId::new(pane);
|
|
253
460
|
let Some(command) = ops
|
|
254
461
|
.observe_command(&pane)
|
|
255
462
|
.filter(|command| !command.trim().is_empty())
|
|
256
463
|
else {
|
|
257
|
-
return
|
|
464
|
+
return refuse_fresh_bind(
|
|
465
|
+
workspace,
|
|
466
|
+
team_key,
|
|
467
|
+
"command_observation",
|
|
468
|
+
"command_unobservable",
|
|
469
|
+
);
|
|
258
470
|
};
|
|
259
|
-
let
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
471
|
+
let provider = match ops.caller_resolution() {
|
|
472
|
+
crate::layout::worker_env::CallerProviderResolution::Valid(provider) => provider,
|
|
473
|
+
crate::layout::worker_env::CallerProviderResolution::Invalid => {
|
|
474
|
+
return refuse_fresh_bind(
|
|
475
|
+
workspace,
|
|
476
|
+
team_key,
|
|
477
|
+
"strict_provider",
|
|
478
|
+
"provider_unresolved",
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
crate::layout::worker_env::CallerProviderResolution::Absent => {
|
|
482
|
+
match crate::leader::owner_bind::strict_owner_bind_provider(
|
|
483
|
+
ops.explicit_provider().as_deref(),
|
|
484
|
+
&command,
|
|
485
|
+
) {
|
|
486
|
+
Some(provider) => provider,
|
|
487
|
+
None => {
|
|
488
|
+
return refuse_fresh_bind(
|
|
489
|
+
workspace,
|
|
490
|
+
team_key,
|
|
491
|
+
"strict_provider",
|
|
492
|
+
"provider_unresolved",
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
265
497
|
};
|
|
266
498
|
let endpoint = ops.tmux_endpoint();
|
|
267
499
|
let has_persisted_binding = ["team_owner", "leader_receiver"]
|
|
@@ -276,6 +508,11 @@ fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
|
|
|
276
508
|
.get("team_owner")
|
|
277
509
|
.is_some_and(|current| current == seed)
|
|
278
510
|
});
|
|
511
|
+
let seeded_receiver = if fresh_seeded_binding {
|
|
512
|
+
state.get("leader_receiver").cloned()
|
|
513
|
+
} else {
|
|
514
|
+
None
|
|
515
|
+
};
|
|
279
516
|
// Same-Team owner collision remains fail-closed. Cross-Team registry rows
|
|
280
517
|
// are intentionally irrelevant: pane ids are scoped to their tmux server,
|
|
281
518
|
// and independent endpoints may reuse the same numeric id.
|
|
@@ -288,40 +525,125 @@ fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
|
|
|
288
525
|
endpoint.as_deref(),
|
|
289
526
|
)
|
|
290
527
|
{
|
|
291
|
-
return
|
|
528
|
+
return refuse_fresh_bind(
|
|
529
|
+
workspace,
|
|
530
|
+
team_key,
|
|
531
|
+
"persisted_binding",
|
|
532
|
+
"persisted_binding_mismatch",
|
|
533
|
+
);
|
|
292
534
|
}
|
|
293
535
|
// Registry publication is the commit point. Restore persisted surfaces on
|
|
294
536
|
// failure, except for the caller-seeded owner that must be cleared.
|
|
295
537
|
let snapshots = binding_snapshots(workspace, team_key);
|
|
296
|
-
let attached =
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
538
|
+
let attached = if fresh_seeded_binding {
|
|
539
|
+
ops.attach(workspace, &mut state, &pane, provider)
|
|
540
|
+
} else {
|
|
541
|
+
has_persisted_binding || ops.attach(workspace, &mut state, &pane, provider)
|
|
542
|
+
};
|
|
543
|
+
let cleanup_grant = ops.applied_grant().or_else(|| {
|
|
544
|
+
seeded_owner
|
|
545
|
+
.filter(|seed| {
|
|
546
|
+
fresh_seeded_binding
|
|
547
|
+
&& state
|
|
548
|
+
.get("team_owner")
|
|
549
|
+
.is_some_and(|current| current == *seed)
|
|
550
|
+
})
|
|
551
|
+
.zip(seeded_receiver.clone())
|
|
552
|
+
.map(|(owner, receiver)| (owner.clone(), receiver))
|
|
553
|
+
});
|
|
554
|
+
let committed = if !attached {
|
|
555
|
+
record_fresh_bind_refusal(
|
|
556
|
+
workspace,
|
|
557
|
+
team_key,
|
|
558
|
+
"attach",
|
|
559
|
+
ops.attach_failure_reason().unwrap_or("attach_failed"),
|
|
560
|
+
);
|
|
561
|
+
false
|
|
562
|
+
} else if !ops.register(workspace, team_key) {
|
|
563
|
+
record_fresh_bind_refusal(
|
|
564
|
+
workspace,
|
|
565
|
+
team_key,
|
|
566
|
+
"registry_register",
|
|
567
|
+
ops.register_failure_reason()
|
|
568
|
+
.unwrap_or("registry_register_failed"),
|
|
569
|
+
);
|
|
570
|
+
false
|
|
571
|
+
} else if !ops.canonical_readback(workspace, team_key) {
|
|
572
|
+
record_fresh_bind_refusal(
|
|
573
|
+
workspace,
|
|
574
|
+
team_key,
|
|
575
|
+
"registry_readback",
|
|
576
|
+
ops.readback_failure_reason()
|
|
577
|
+
.unwrap_or("registry_readback_failed"),
|
|
578
|
+
);
|
|
579
|
+
false
|
|
580
|
+
} else {
|
|
581
|
+
true
|
|
582
|
+
};
|
|
300
583
|
if !committed {
|
|
301
|
-
if
|
|
302
|
-
|
|
303
|
-
|
|
584
|
+
if ops.attach_cas_conflict() {
|
|
585
|
+
return Ok(false);
|
|
586
|
+
}
|
|
587
|
+
if fresh_seeded_binding {
|
|
588
|
+
// The registry receipt is the exact bytes this writer published;
|
|
589
|
+
// conditional restore compares and mutates under the registry
|
|
590
|
+
// owner's lock, so a concurrent winner is never rolled back.
|
|
591
|
+
let registry_rollback_error = ops
|
|
592
|
+
.registry_receipt()
|
|
593
|
+
.and_then(|receipt| restore_registry_receipt(&snapshots, &receipt).err());
|
|
594
|
+
if let Some((expected_owner, expected_receiver)) = cleanup_grant.as_ref() {
|
|
595
|
+
clear_fresh_binding_on_refusal(
|
|
596
|
+
workspace,
|
|
597
|
+
&mut state,
|
|
598
|
+
team_key,
|
|
599
|
+
expected_owner,
|
|
600
|
+
Some((expected_owner, expected_receiver)),
|
|
601
|
+
)?;
|
|
602
|
+
} else if let Some(seed) = seeded_owner.filter(|seed| {
|
|
603
|
+
state
|
|
304
604
|
.get("team_owner")
|
|
305
605
|
.is_some_and(|current| current == *seed)
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
snapshot.restore();
|
|
606
|
+
}) {
|
|
607
|
+
clear_fresh_binding_on_refusal(workspace, &mut state, team_key, seed, None)?;
|
|
608
|
+
}
|
|
609
|
+
if let Some(error) = registry_rollback_error {
|
|
610
|
+
return Err(error);
|
|
312
611
|
}
|
|
313
|
-
clear_fresh_binding_on_refusal(workspace, &mut state, team_key, seed)?;
|
|
314
612
|
} else {
|
|
315
|
-
// No caller-seeded owner was ours to clean up
|
|
316
|
-
//
|
|
317
|
-
|
|
613
|
+
// No caller-seeded owner was ours to clean up. Restore only the
|
|
614
|
+
// runtime snapshot; a registry write is reverted only with its
|
|
615
|
+
// exact receipt under the registry owner's lock.
|
|
616
|
+
let registry_rollback_error = ops
|
|
617
|
+
.registry_receipt()
|
|
618
|
+
.and_then(|receipt| restore_registry_receipt(&snapshots, &receipt).err());
|
|
619
|
+
if let Some(snapshot) = snapshots.first() {
|
|
318
620
|
snapshot.restore();
|
|
319
621
|
}
|
|
622
|
+
if let Some(error) = registry_rollback_error {
|
|
623
|
+
return Err(error);
|
|
624
|
+
}
|
|
320
625
|
}
|
|
321
626
|
}
|
|
322
627
|
Ok(committed)
|
|
323
628
|
}
|
|
324
629
|
|
|
630
|
+
fn restore_registry_receipt(
|
|
631
|
+
snapshots: &[BindingFileSnapshot],
|
|
632
|
+
receipt: &crate::leader::registry::RegistryWriteReceipt,
|
|
633
|
+
) -> Result<(), LifecycleError> {
|
|
634
|
+
let previous = snapshots
|
|
635
|
+
.iter()
|
|
636
|
+
.find(|snapshot| snapshot.path == receipt.path)
|
|
637
|
+
.and_then(|snapshot| snapshot.bytes.as_deref());
|
|
638
|
+
match crate::leader::registry::restore_entry_if_current_matches(receipt, previous) {
|
|
639
|
+
Ok(crate::leader::registry::RegistryRollback::Restored)
|
|
640
|
+
| Ok(crate::leader::registry::RegistryRollback::Superseded) => Ok(()),
|
|
641
|
+
Err(error) => Err(LifecycleError::StatePersist(format!(
|
|
642
|
+
"quick-start registry rollback failed: {error}"
|
|
643
|
+
))),
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
325
647
|
fn should_emit_workspace_socket_missing_hint(
|
|
326
648
|
selected_source: Option<&str>,
|
|
327
649
|
workspace_socket_missing: bool,
|
|
@@ -334,10 +656,11 @@ fn clear_fresh_binding_on_refusal(
|
|
|
334
656
|
state: &mut serde_json::Value,
|
|
335
657
|
team_key: &str,
|
|
336
658
|
seed: &serde_json::Value,
|
|
659
|
+
expected_grant: Option<(&serde_json::Value, &serde_json::Value)>,
|
|
337
660
|
) -> Result<(), LifecycleError> {
|
|
338
|
-
// Tombstone only this attempt's
|
|
339
|
-
// the on-disk owner
|
|
340
|
-
// back and the epoch is not bumped.
|
|
661
|
+
// Tombstone only this attempt's exact grant. The lock-held persist merge
|
|
662
|
+
// compares the on-disk owner and applied receiver; a concurrent owner or
|
|
663
|
+
// receiver update is copied back and the epoch is not bumped.
|
|
341
664
|
if let Some(obj) = state.as_object_mut() {
|
|
342
665
|
obj.remove("leader_receiver");
|
|
343
666
|
obj.remove("team_owner");
|
|
@@ -352,11 +675,17 @@ fn clear_fresh_binding_on_refusal(
|
|
|
352
675
|
team.insert("leader_receiver".to_string(), serde_json::Value::Null);
|
|
353
676
|
team.insert("team_owner".to_string(), serde_json::Value::Null);
|
|
354
677
|
}
|
|
678
|
+
let intent = if let Some((expected_owner, expected_receiver)) = expected_grant {
|
|
679
|
+
crate::state::repository::StateWriteIntent::ClearExactTeamOwnerAndReceiver {
|
|
680
|
+
team_key,
|
|
681
|
+
seed: expected_owner,
|
|
682
|
+
expected_receiver,
|
|
683
|
+
}
|
|
684
|
+
} else {
|
|
685
|
+
crate::state::repository::StateWriteIntent::ClearExactTeamOwner { team_key, seed }
|
|
686
|
+
};
|
|
355
687
|
crate::state::repository::StateRepository::new(workspace)
|
|
356
|
-
.save(
|
|
357
|
-
crate::state::repository::StateWriteIntent::ClearExactTeamOwner { team_key, seed },
|
|
358
|
-
state,
|
|
359
|
-
)
|
|
688
|
+
.save(intent, state)
|
|
360
689
|
.map_err(|error| LifecycleError::StatePersist(format!(
|
|
361
690
|
"quick-start binding cleanup failed: {error}"
|
|
362
691
|
)))
|
|
@@ -372,7 +701,14 @@ fn bind_fresh_quick_start_leader(
|
|
|
372
701
|
workspace,
|
|
373
702
|
team_key,
|
|
374
703
|
seeded_owner,
|
|
375
|
-
&mut RuntimeFreshQuickStartLeaderBindingOps {
|
|
704
|
+
&mut RuntimeFreshQuickStartLeaderBindingOps {
|
|
705
|
+
transport,
|
|
706
|
+
last_failure_reason: None,
|
|
707
|
+
cas_conflict: false,
|
|
708
|
+
applied_grant: None,
|
|
709
|
+
registry_receipt: None,
|
|
710
|
+
nonce_writer: None,
|
|
711
|
+
},
|
|
376
712
|
)
|
|
377
713
|
}
|
|
378
714
|
|
|
@@ -785,12 +1121,14 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
|
|
|
785
1121
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
786
1122
|
let resolved_spec_path =
|
|
787
1123
|
std::fs::canonicalize(&spec_path).unwrap_or_else(|_| spec_path.clone());
|
|
1124
|
+
let scoped_endpoint = transport.tmux_endpoint();
|
|
788
1125
|
let mut state = initial_runtime_state(
|
|
789
1126
|
&spec,
|
|
790
1127
|
&resolved_spec_path,
|
|
791
1128
|
&workspace,
|
|
792
1129
|
agents_dir,
|
|
793
1130
|
&state_team_key,
|
|
1131
|
+
scoped_endpoint.as_deref(),
|
|
794
1132
|
);
|
|
795
1133
|
// Keep this attempt-local seed separate from persisted `claimed_via`;
|
|
796
1134
|
// historical quick-start rows are never sufficient cleanup authority.
|
|
@@ -926,9 +1264,15 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
|
|
|
926
1264
|
#[cfg(test)]
|
|
927
1265
|
mod fresh_quick_start_leader_binding_tests {
|
|
928
1266
|
use super::*;
|
|
1267
|
+
use crate::layout::worker_env::{CALLER_ENDPOINT_ENV, CALLER_PANE_ENV, CALLER_PROVIDER_ENV};
|
|
1268
|
+
use crate::transport::test_support::OfflineTransport;
|
|
1269
|
+
use crate::transport::{PaneInfo, SessionName, WindowName};
|
|
929
1270
|
use serde_json::json;
|
|
1271
|
+
use std::collections::BTreeMap;
|
|
930
1272
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
931
1273
|
|
|
1274
|
+
use super::hermetic::HermeticTestEnv;
|
|
1275
|
+
|
|
932
1276
|
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
|
933
1277
|
|
|
934
1278
|
fn workspace(tag: &str) -> PathBuf {
|
|
@@ -963,6 +1307,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
963
1307
|
register_calls: usize,
|
|
964
1308
|
readback_calls: usize,
|
|
965
1309
|
attached_provider: Option<crate::provider::Provider>,
|
|
1310
|
+
applied_grant: Option<(serde_json::Value, serde_json::Value)>,
|
|
966
1311
|
}
|
|
967
1312
|
|
|
968
1313
|
impl Default for MockOps {
|
|
@@ -979,6 +1324,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
979
1324
|
register_calls: 0,
|
|
980
1325
|
readback_calls: 0,
|
|
981
1326
|
attached_provider: None,
|
|
1327
|
+
applied_grant: None,
|
|
982
1328
|
}
|
|
983
1329
|
}
|
|
984
1330
|
}
|
|
@@ -1054,6 +1400,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1054
1400
|
pane: &PaneId,
|
|
1055
1401
|
provider: crate::provider::Provider,
|
|
1056
1402
|
) -> bool {
|
|
1403
|
+
self.applied_grant = None;
|
|
1057
1404
|
self.attach_calls += 1;
|
|
1058
1405
|
self.attached_provider = Some(provider);
|
|
1059
1406
|
if !self.attach_ok {
|
|
@@ -1061,30 +1408,79 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1061
1408
|
let _ = crate::state::persist::save_runtime_state(workspace, state);
|
|
1062
1409
|
return false;
|
|
1063
1410
|
}
|
|
1064
|
-
|
|
1411
|
+
let team_key = state
|
|
1412
|
+
.get("active_team_key")
|
|
1413
|
+
.and_then(serde_json::Value::as_str)
|
|
1414
|
+
.unwrap_or("fresh")
|
|
1415
|
+
.to_string();
|
|
1416
|
+
let expected_owner = state.get("team_owner").cloned();
|
|
1417
|
+
let expected_receiver = state.get("leader_receiver").cloned();
|
|
1418
|
+
let owner = expected_owner.clone().unwrap_or_else(|| {
|
|
1419
|
+
json!({
|
|
1420
|
+
"pane_id": pane.as_str(),
|
|
1421
|
+
"provider": "pi",
|
|
1422
|
+
"leader_session_uuid": "uuid-fresh",
|
|
1423
|
+
"owner_epoch": 1
|
|
1424
|
+
})
|
|
1425
|
+
});
|
|
1426
|
+
let owner_epoch = owner
|
|
1427
|
+
.get("owner_epoch")
|
|
1428
|
+
.and_then(serde_json::Value::as_u64)
|
|
1429
|
+
.unwrap_or(1);
|
|
1430
|
+
let receiver = json!({
|
|
1065
1431
|
"mode": "direct_tmux",
|
|
1066
1432
|
"pane_id": pane.as_str(),
|
|
1067
1433
|
"status": "attached",
|
|
1068
1434
|
"provider": "pi",
|
|
1069
1435
|
"leader_session_uuid": "uuid-fresh",
|
|
1070
|
-
"owner_epoch":
|
|
1436
|
+
"owner_epoch": owner_epoch,
|
|
1071
1437
|
"tmux_socket": self.endpoint
|
|
1072
1438
|
});
|
|
1073
|
-
state["
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1439
|
+
state["leader_receiver"] = receiver.clone();
|
|
1440
|
+
state["team_owner"] = owner.clone();
|
|
1441
|
+
state["owner_epoch"] = json!(owner_epoch);
|
|
1442
|
+
state["teams"][team_key.as_str()]["leader_receiver"] = receiver.clone();
|
|
1443
|
+
state["teams"][team_key.as_str()]["team_owner"] = owner.clone();
|
|
1444
|
+
state["teams"][team_key.as_str()]["owner_epoch"] = json!(owner_epoch);
|
|
1445
|
+
let saved = match (expected_owner.as_ref(), expected_receiver.as_ref()) {
|
|
1446
|
+
(Some(expected_owner), Some(expected_receiver)) => {
|
|
1447
|
+
crate::state::persist::save_runtime_state_with_receiver_authority_and_expected(
|
|
1448
|
+
workspace,
|
|
1449
|
+
state,
|
|
1450
|
+
&team_key,
|
|
1451
|
+
expected_owner,
|
|
1452
|
+
expected_receiver,
|
|
1453
|
+
)
|
|
1454
|
+
}
|
|
1455
|
+
_ => crate::state::persist::save_runtime_state_with_receiver_authority(
|
|
1456
|
+
workspace,
|
|
1457
|
+
state,
|
|
1458
|
+
&team_key,
|
|
1459
|
+
None,
|
|
1460
|
+
),
|
|
1461
|
+
};
|
|
1462
|
+
if saved.is_ok() {
|
|
1463
|
+
self.applied_grant = Some((owner, receiver));
|
|
1464
|
+
true
|
|
1465
|
+
} else {
|
|
1466
|
+
false
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
fn applied_grant(&self) -> Option<(serde_json::Value, serde_json::Value)> {
|
|
1471
|
+
self.applied_grant.clone()
|
|
1080
1472
|
}
|
|
1081
1473
|
|
|
1082
|
-
fn register(&mut self, workspace: &Path,
|
|
1474
|
+
fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
1083
1475
|
self.register_calls += 1;
|
|
1084
1476
|
let mut persisted = crate::state::persist::load_runtime_state(workspace).unwrap();
|
|
1085
1477
|
assert_eq!(
|
|
1086
1478
|
persisted
|
|
1087
|
-
.
|
|
1479
|
+
.get("teams")
|
|
1480
|
+
.and_then(serde_json::Value::as_object)
|
|
1481
|
+
.and_then(|teams| teams.get(team_key))
|
|
1482
|
+
.and_then(|team| team.get("leader_receiver"))
|
|
1483
|
+
.and_then(|receiver| receiver.get("pane_id"))
|
|
1088
1484
|
.and_then(serde_json::Value::as_str),
|
|
1089
1485
|
Some("%42")
|
|
1090
1486
|
);
|
|
@@ -1095,7 +1491,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1095
1491
|
self.register_ok
|
|
1096
1492
|
}
|
|
1097
1493
|
|
|
1098
|
-
fn canonical_readback(&mut self, workspace: &Path,
|
|
1494
|
+
fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
1099
1495
|
self.readback_calls += 1;
|
|
1100
1496
|
let mut persisted = crate::state::persist::load_runtime_state(workspace).unwrap();
|
|
1101
1497
|
if !self.readback_ok {
|
|
@@ -1104,7 +1500,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1104
1500
|
}
|
|
1105
1501
|
self.readback_ok
|
|
1106
1502
|
&& persisted
|
|
1107
|
-
.get("
|
|
1503
|
+
.get("teams")
|
|
1504
|
+
.and_then(serde_json::Value::as_object)
|
|
1505
|
+
.and_then(|teams| teams.get(team_key))
|
|
1506
|
+
.and_then(|team| team.get("leader_receiver"))
|
|
1108
1507
|
.is_some_and(|receiver| !receiver.is_null())
|
|
1109
1508
|
}
|
|
1110
1509
|
}
|
|
@@ -1122,10 +1521,110 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1122
1521
|
let state = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
1123
1522
|
assert_eq!(
|
|
1124
1523
|
state
|
|
1125
|
-
.
|
|
1524
|
+
.get("teams")
|
|
1525
|
+
.and_then(serde_json::Value::as_object)
|
|
1526
|
+
.and_then(|teams| teams.get("fresh"))
|
|
1527
|
+
.and_then(|team| team.get("leader_receiver"))
|
|
1528
|
+
.and_then(|receiver| receiver.get("pane_id"))
|
|
1126
1529
|
.and_then(serde_json::Value::as_str),
|
|
1127
1530
|
Some("%42")
|
|
1128
1531
|
);
|
|
1532
|
+
assert!(
|
|
1533
|
+
crate::event_log::EventLog::new(&workspace)
|
|
1534
|
+
.tail(0)
|
|
1535
|
+
.unwrap()
|
|
1536
|
+
.is_empty(),
|
|
1537
|
+
"successful bind must not emit a refusal event"
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
#[test]
|
|
1542
|
+
fn fresh_bind_refusal_event_keeps_first_safe_stage_and_reason() {
|
|
1543
|
+
for (case, expected_stage, expected_reason) in [
|
|
1544
|
+
("missing_pane", "caller_pane", "caller_pane_missing"),
|
|
1545
|
+
("empty_command", "command_observation", "command_unobservable"),
|
|
1546
|
+
("unknown_provider", "strict_provider", "provider_unresolved"),
|
|
1547
|
+
(
|
|
1548
|
+
"existing_owner",
|
|
1549
|
+
"persisted_binding",
|
|
1550
|
+
"persisted_binding_mismatch",
|
|
1551
|
+
),
|
|
1552
|
+
("attach_failure", "attach", "attach_failed"),
|
|
1553
|
+
(
|
|
1554
|
+
"registry_failure",
|
|
1555
|
+
"registry_register",
|
|
1556
|
+
"registry_register_failed",
|
|
1557
|
+
),
|
|
1558
|
+
(
|
|
1559
|
+
"readback_failure",
|
|
1560
|
+
"registry_readback",
|
|
1561
|
+
"registry_readback_failed",
|
|
1562
|
+
),
|
|
1563
|
+
] {
|
|
1564
|
+
let workspace = workspace(case);
|
|
1565
|
+
let mut state = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
1566
|
+
let mut ops = MockOps::default();
|
|
1567
|
+
match case {
|
|
1568
|
+
"missing_pane" => ops.pane = None,
|
|
1569
|
+
"empty_command" => ops.command = Some(" ".to_string()),
|
|
1570
|
+
"unknown_provider" => {
|
|
1571
|
+
ops.explicit_provider = None;
|
|
1572
|
+
ops.command = Some("node".to_string());
|
|
1573
|
+
}
|
|
1574
|
+
"existing_owner" => state["team_owner"] = json!({"pane_id": "%old"}),
|
|
1575
|
+
"attach_failure" => ops.attach_ok = false,
|
|
1576
|
+
"registry_failure" => ops.register_ok = false,
|
|
1577
|
+
"readback_failure" => ops.readback_ok = false,
|
|
1578
|
+
_ => unreachable!(),
|
|
1579
|
+
}
|
|
1580
|
+
crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
|
|
1581
|
+
|
|
1582
|
+
assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
|
|
1583
|
+
.unwrap());
|
|
1584
|
+
let events = crate::event_log::EventLog::new(&workspace).tail(0).unwrap();
|
|
1585
|
+
assert_eq!(events.len(), 1, "{case} must emit one first-refusal event");
|
|
1586
|
+
assert_eq!(events[0]["event"], json!(FRESH_BIND_REFUSAL_EVENT));
|
|
1587
|
+
assert_eq!(events[0]["operation"], json!("fresh_quick_start_leader_bind"));
|
|
1588
|
+
assert_eq!(events[0]["team_key"], json!("fresh"));
|
|
1589
|
+
assert_eq!(events[0]["stage"], json!(expected_stage));
|
|
1590
|
+
assert_eq!(events[0]["reason"], json!(expected_reason));
|
|
1591
|
+
assert!(events[0].get("command").is_none());
|
|
1592
|
+
assert!(events[0].get("pane_id").is_none());
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
#[test]
|
|
1597
|
+
fn scope_refusal_event_is_safe_and_targeted() {
|
|
1598
|
+
let empty_workspace = std::env::temp_dir().join(format!(
|
|
1599
|
+
"ta-quick-start-bind-scope-{}-{}",
|
|
1600
|
+
std::process::id(),
|
|
1601
|
+
SEQUENCE.fetch_add(1, Ordering::Relaxed)
|
|
1602
|
+
));
|
|
1603
|
+
std::fs::create_dir_all(&empty_workspace).unwrap();
|
|
1604
|
+
let mut ops = MockOps::default();
|
|
1605
|
+
assert!(!bind_fresh_quick_start_leader_with(&empty_workspace, "fresh", None, &mut ops)
|
|
1606
|
+
.unwrap());
|
|
1607
|
+
let events = crate::event_log::EventLog::new(&empty_workspace).tail(0).unwrap();
|
|
1608
|
+
assert_eq!(events.len(), 1);
|
|
1609
|
+
assert_eq!(events[0]["stage"], json!("scope"));
|
|
1610
|
+
assert_eq!(events[0]["reason"], json!("scope_unresolved"));
|
|
1611
|
+
assert_eq!(events[0]["team_key"], json!("fresh"));
|
|
1612
|
+
assert!(events[0].get("error").is_none());
|
|
1613
|
+
assert!(events[0].get("argv").is_none());
|
|
1614
|
+
let _ = std::fs::remove_dir_all(empty_workspace);
|
|
1615
|
+
|
|
1616
|
+
let workspace = workspace("scope-key-mismatch");
|
|
1617
|
+
let mut state = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
1618
|
+
state["active_team_key"] = json!("fresh");
|
|
1619
|
+
state["teams"] = json!({"fresh": {"team_key": "fresh", "agents": {}}});
|
|
1620
|
+
crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
|
|
1621
|
+
let mut ops = MockOps::default();
|
|
1622
|
+
assert!(!bind_fresh_quick_start_leader_with(&workspace, "current", None, &mut ops)
|
|
1623
|
+
.unwrap());
|
|
1624
|
+
let events = crate::event_log::EventLog::new(&workspace).tail(0).unwrap();
|
|
1625
|
+
assert_eq!(events.len(), 1);
|
|
1626
|
+
assert_eq!(events[0]["stage"], json!("scope"));
|
|
1627
|
+
assert_eq!(events[0]["reason"], json!("scope_key_mismatch"));
|
|
1129
1628
|
}
|
|
1130
1629
|
|
|
1131
1630
|
#[test]
|
|
@@ -1279,6 +1778,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1279
1778
|
struct AmbientRegistryOps {
|
|
1280
1779
|
readback_ok: bool,
|
|
1281
1780
|
attach_calls: usize,
|
|
1781
|
+
registry_receipt: Option<crate::leader::registry::RegistryWriteReceipt>,
|
|
1282
1782
|
}
|
|
1283
1783
|
|
|
1284
1784
|
impl FreshQuickStartLeaderBindingOps for AmbientRegistryOps {
|
|
@@ -1310,12 +1810,19 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1310
1810
|
}
|
|
1311
1811
|
|
|
1312
1812
|
fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
1313
|
-
crate::leader::registry::register_binding_from_state_best_effort(
|
|
1813
|
+
let Some(outcome) = crate::leader::registry::register_binding_from_state_best_effort(
|
|
1314
1814
|
workspace,
|
|
1315
1815
|
Some(team_key),
|
|
1316
1816
|
"quick-start",
|
|
1317
|
-
)
|
|
1318
|
-
|
|
1817
|
+
) else {
|
|
1818
|
+
return false;
|
|
1819
|
+
};
|
|
1820
|
+
self.registry_receipt = outcome.receipt;
|
|
1821
|
+
outcome.status == "registered" && outcome.path.is_some()
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
fn registry_receipt(&self) -> Option<crate::leader::registry::RegistryWriteReceipt> {
|
|
1825
|
+
self.registry_receipt.clone()
|
|
1319
1826
|
}
|
|
1320
1827
|
|
|
1321
1828
|
fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
@@ -1336,6 +1843,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1336
1843
|
let mut ops = AmbientRegistryOps {
|
|
1337
1844
|
readback_ok: true,
|
|
1338
1845
|
attach_calls: 0,
|
|
1846
|
+
registry_receipt: None,
|
|
1339
1847
|
};
|
|
1340
1848
|
assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
|
|
1341
1849
|
.unwrap());
|
|
@@ -1500,6 +2008,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1500
2008
|
self.inner.attach(workspace, state, pane, provider)
|
|
1501
2009
|
}
|
|
1502
2010
|
|
|
2011
|
+
fn applied_grant(&self) -> Option<(serde_json::Value, serde_json::Value)> {
|
|
2012
|
+
self.inner.applied_grant()
|
|
2013
|
+
}
|
|
2014
|
+
|
|
1503
2015
|
fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
1504
2016
|
self.inner.register_calls += 1;
|
|
1505
2017
|
if let Some(takeover) = &self.takeover {
|
|
@@ -1575,6 +2087,15 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1575
2087
|
&mut ops
|
|
1576
2088
|
)
|
|
1577
2089
|
.unwrap());
|
|
2090
|
+
assert_eq!(ops.inner.attach_calls, 1);
|
|
2091
|
+
assert_eq!(ops.inner.register_calls, 1);
|
|
2092
|
+
assert!(ops
|
|
2093
|
+
.inner
|
|
2094
|
+
.applied_grant
|
|
2095
|
+
.as_ref()
|
|
2096
|
+
.is_some_and(|(owner, receiver)| {
|
|
2097
|
+
owner == &seed && receiver == &matching_receiver()
|
|
2098
|
+
}));
|
|
1578
2099
|
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
1579
2100
|
assert!(
|
|
1580
2101
|
persisted
|
|
@@ -1642,6 +2163,15 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1642
2163
|
&mut ops,
|
|
1643
2164
|
)
|
|
1644
2165
|
.expect_err("poisoned state.json must surface cleanup persist failure");
|
|
2166
|
+
assert_eq!(ops.inner.attach_calls, 1);
|
|
2167
|
+
assert_eq!(ops.inner.register_calls, 1);
|
|
2168
|
+
assert!(ops
|
|
2169
|
+
.inner
|
|
2170
|
+
.applied_grant
|
|
2171
|
+
.as_ref()
|
|
2172
|
+
.is_some_and(|(owner, receiver)| {
|
|
2173
|
+
owner == &seed && receiver == &matching_receiver()
|
|
2174
|
+
}));
|
|
1645
2175
|
match error {
|
|
1646
2176
|
LifecycleError::StatePersist(text) => {
|
|
1647
2177
|
assert!(
|
|
@@ -1743,10 +2273,1240 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1743
2273
|
let mut ops = AmbientRegistryOps {
|
|
1744
2274
|
readback_ok: false,
|
|
1745
2275
|
attach_calls: 0,
|
|
2276
|
+
registry_receipt: None,
|
|
1746
2277
|
};
|
|
1747
2278
|
assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
|
|
1748
2279
|
.unwrap());
|
|
1749
2280
|
assert_eq!(std::fs::read(state_path).unwrap(), before);
|
|
1750
2281
|
assert!(!registry_path.exists(), "failed readback left registry bytes");
|
|
1751
2282
|
}
|
|
2283
|
+
|
|
2284
|
+
struct RecordingRuntimeOps<'a> {
|
|
2285
|
+
inner: RuntimeFreshQuickStartLeaderBindingOps<'a>,
|
|
2286
|
+
attached_provider: Option<crate::provider::Provider>,
|
|
2287
|
+
attach_calls: usize,
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
impl<'a> RecordingRuntimeOps<'a> {
|
|
2291
|
+
fn new(transport: &'a dyn Transport) -> Self {
|
|
2292
|
+
Self {
|
|
2293
|
+
inner: RuntimeFreshQuickStartLeaderBindingOps {
|
|
2294
|
+
transport,
|
|
2295
|
+
last_failure_reason: None,
|
|
2296
|
+
cas_conflict: false,
|
|
2297
|
+
applied_grant: None,
|
|
2298
|
+
registry_receipt: None,
|
|
2299
|
+
nonce_writer: None,
|
|
2300
|
+
},
|
|
2301
|
+
attached_provider: None,
|
|
2302
|
+
attach_calls: 0,
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
impl FreshQuickStartLeaderBindingOps for RecordingRuntimeOps<'_> {
|
|
2308
|
+
fn caller_pane(&mut self) -> Option<String> {
|
|
2309
|
+
self.inner.caller_pane()
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
fn caller_resolution(&mut self) -> crate::layout::worker_env::CallerProviderResolution {
|
|
2313
|
+
self.inner.caller_resolution()
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
fn explicit_provider(&mut self) -> Option<String> {
|
|
2317
|
+
self.inner.explicit_provider()
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
fn tmux_endpoint(&mut self) -> Option<String> {
|
|
2321
|
+
self.inner.tmux_endpoint()
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
fn observe_command(&mut self, pane: &PaneId) -> Option<String> {
|
|
2325
|
+
self.inner.observe_command(pane)
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
fn attach(
|
|
2329
|
+
&mut self,
|
|
2330
|
+
workspace: &Path,
|
|
2331
|
+
state: &mut serde_json::Value,
|
|
2332
|
+
pane: &PaneId,
|
|
2333
|
+
provider: crate::provider::Provider,
|
|
2334
|
+
) -> bool {
|
|
2335
|
+
self.attach_calls += 1;
|
|
2336
|
+
self.attached_provider = Some(provider);
|
|
2337
|
+
let wire = crate::provider::wire::provider_wire(provider);
|
|
2338
|
+
state["leader_receiver"] = json!({
|
|
2339
|
+
"mode": "direct_tmux",
|
|
2340
|
+
"pane_id": pane.as_str(),
|
|
2341
|
+
"status": "attached",
|
|
2342
|
+
"provider": wire,
|
|
2343
|
+
"leader_session_uuid": "uuid-fresh",
|
|
2344
|
+
"owner_epoch": 1,
|
|
2345
|
+
"tmux_socket": self.inner.tmux_endpoint()
|
|
2346
|
+
});
|
|
2347
|
+
state["team_owner"] = json!({
|
|
2348
|
+
"pane_id": pane.as_str(),
|
|
2349
|
+
"provider": wire,
|
|
2350
|
+
"leader_session_uuid": "uuid-fresh",
|
|
2351
|
+
"owner_epoch": 1
|
|
2352
|
+
});
|
|
2353
|
+
crate::state::persist::save_runtime_state(workspace, state).is_ok()
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
fn register(&mut self, _workspace: &Path, _team_key: &str) -> bool {
|
|
2357
|
+
true
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
fn canonical_readback(&mut self, _workspace: &Path, _team_key: &str) -> bool {
|
|
2361
|
+
true
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
fn runtime_workspace(hermetic: &HermeticTestEnv, tag: &str) -> PathBuf {
|
|
2366
|
+
let path = hermetic.workspace(tag);
|
|
2367
|
+
crate::state::persist::save_runtime_state(
|
|
2368
|
+
&path,
|
|
2369
|
+
&json!({
|
|
2370
|
+
"active_team_key": "fresh",
|
|
2371
|
+
"team_key": "fresh",
|
|
2372
|
+
"session_name": "team-fresh",
|
|
2373
|
+
"agents": {"sol": {"status": "running", "provider": "pi"}}
|
|
2374
|
+
}),
|
|
2375
|
+
)
|
|
2376
|
+
.unwrap();
|
|
2377
|
+
path
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
fn refusal_reason(workspace: &Path) -> Option<(String, String)> {
|
|
2381
|
+
let events = crate::event_log::EventLog::new(workspace).tail(0).unwrap();
|
|
2382
|
+
let event = events.last()?;
|
|
2383
|
+
Some((
|
|
2384
|
+
event
|
|
2385
|
+
.get("stage")
|
|
2386
|
+
.and_then(serde_json::Value::as_str)?
|
|
2387
|
+
.to_string(),
|
|
2388
|
+
event
|
|
2389
|
+
.get("reason")
|
|
2390
|
+
.and_then(serde_json::Value::as_str)?
|
|
2391
|
+
.to_string(),
|
|
2392
|
+
))
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
fn seeded_runtime_owner(workspace: &Path) -> serde_json::Value {
|
|
2396
|
+
let mut state = crate::state::persist::load_runtime_state(workspace).unwrap();
|
|
2397
|
+
assert!(super::spec_state::seed_launched_owner_from_env(
|
|
2398
|
+
&mut state,
|
|
2399
|
+
Some("/tmp/tmux.sock")
|
|
2400
|
+
));
|
|
2401
|
+
crate::state::persist::save_runtime_state(workspace, &state).unwrap();
|
|
2402
|
+
crate::state::persist::load_runtime_state(workspace)
|
|
2403
|
+
.unwrap()
|
|
2404
|
+
.pointer("/teams/fresh/team_owner")
|
|
2405
|
+
.cloned()
|
|
2406
|
+
.expect("seeded team owner")
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
fn caller_target(path: PathBuf, nonce: Option<&str>) -> PaneInfo {
|
|
2410
|
+
let mut leader_env = BTreeMap::new();
|
|
2411
|
+
if let Some(nonce) = nonce {
|
|
2412
|
+
leader_env.insert(
|
|
2413
|
+
crate::tmux_backend::PANE_BINDING_NONCE_METADATA_KEY.to_string(),
|
|
2414
|
+
nonce.to_string(),
|
|
2415
|
+
);
|
|
2416
|
+
}
|
|
2417
|
+
PaneInfo {
|
|
2418
|
+
pane_id: PaneId::new("%1"),
|
|
2419
|
+
session: SessionName::new("parent"),
|
|
2420
|
+
window_index: Some(0),
|
|
2421
|
+
window_name: Some(WindowName::new("caller")),
|
|
2422
|
+
pane_index: Some(0),
|
|
2423
|
+
tty: Some("/dev/pts/1".to_string()),
|
|
2424
|
+
current_command: Some("bash".to_string()),
|
|
2425
|
+
current_path: Some(path),
|
|
2426
|
+
active: true,
|
|
2427
|
+
pane_pid: Some(101),
|
|
2428
|
+
leader_env,
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
#[test]
|
|
2433
|
+
#[serial_test::serial(env)]
|
|
2434
|
+
fn runtime_valid_caller_binds_typed_pi_through_bash() {
|
|
2435
|
+
let hermetic = HermeticTestEnv::enter("runtime-valid-pi-bash");
|
|
2436
|
+
let workspace = runtime_workspace(&hermetic, "fresh");
|
|
2437
|
+
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
2438
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
2439
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
|
|
2440
|
+
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
2441
|
+
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
2442
|
+
let transport = OfflineTransport::new()
|
|
2443
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
2444
|
+
.with_pane_current_command("%1", "bash");
|
|
2445
|
+
let mut ops = RecordingRuntimeOps::new(&transport);
|
|
2446
|
+
assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
|
|
2447
|
+
assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Pi));
|
|
2448
|
+
assert_eq!(ops.attach_calls, 1);
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
#[test]
|
|
2452
|
+
#[serial_test::serial(env)]
|
|
2453
|
+
fn seeded_runtime_bind_publishes_fresh_caller_authority_for_live_parent_pane() {
|
|
2454
|
+
let hermetic = HermeticTestEnv::enter("runtime-seeded-fresh-authority");
|
|
2455
|
+
let workspace = runtime_workspace(&hermetic, "fresh");
|
|
2456
|
+
let parent = hermetic.root().join("parent");
|
|
2457
|
+
std::fs::create_dir_all(&parent).unwrap();
|
|
2458
|
+
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
2459
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
2460
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
|
|
2461
|
+
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
2462
|
+
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
2463
|
+
let seed = seeded_runtime_owner(&workspace);
|
|
2464
|
+
let target = caller_target(parent, Some("shared-live-nonce"));
|
|
2465
|
+
let transport = OfflineTransport::new()
|
|
2466
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
2467
|
+
.with_pane_current_command("%1", "bash")
|
|
2468
|
+
.with_targets(vec![target.clone()]);
|
|
2469
|
+
let mut ops = RuntimeFreshQuickStartLeaderBindingOps {
|
|
2470
|
+
transport: &transport,
|
|
2471
|
+
last_failure_reason: None,
|
|
2472
|
+
cas_conflict: false,
|
|
2473
|
+
applied_grant: None,
|
|
2474
|
+
registry_receipt: None,
|
|
2475
|
+
nonce_writer: None,
|
|
2476
|
+
};
|
|
2477
|
+
assert!(bind_fresh_quick_start_leader_with(
|
|
2478
|
+
&workspace,
|
|
2479
|
+
"fresh",
|
|
2480
|
+
Some(&seed),
|
|
2481
|
+
&mut ops
|
|
2482
|
+
)
|
|
2483
|
+
.unwrap());
|
|
2484
|
+
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
2485
|
+
let receiver = persisted
|
|
2486
|
+
.pointer("/teams/fresh/leader_receiver")
|
|
2487
|
+
.cloned()
|
|
2488
|
+
.expect("published receiver");
|
|
2489
|
+
assert_eq!(receiver["scope_authority"], json!("fresh_caller"));
|
|
2490
|
+
assert_eq!(receiver["binding_nonce"], json!("shared-live-nonce"));
|
|
2491
|
+
assert_eq!(
|
|
2492
|
+
receiver["authorized_team_workspace"],
|
|
2493
|
+
json!(workspace.to_string_lossy().to_string())
|
|
2494
|
+
);
|
|
2495
|
+
assert_eq!(receiver["owner_epoch"], json!(1));
|
|
2496
|
+
let registry_path = crate::leader::registry::registry_dir()
|
|
2497
|
+
.unwrap()
|
|
2498
|
+
.join(format!(
|
|
2499
|
+
"{}__fresh.json",
|
|
2500
|
+
crate::leader::registry::workspace_hash(&workspace)
|
|
2501
|
+
));
|
|
2502
|
+
let registry: crate::leader::registry::LeaderRegistryEntry =
|
|
2503
|
+
serde_json::from_slice(&std::fs::read(registry_path).unwrap()).unwrap();
|
|
2504
|
+
assert_eq!(registry.channel["scope_authority"], json!("fresh_caller"));
|
|
2505
|
+
assert!(matches!(
|
|
2506
|
+
crate::messaging::resolve_live_leader_channel(&workspace, &receiver, &transport),
|
|
2507
|
+
crate::messaging::LeaderChannelResolution::Live(_)
|
|
2508
|
+
));
|
|
2509
|
+
|
|
2510
|
+
let mut stale_target = target.clone();
|
|
2511
|
+
stale_target.leader_env.insert(
|
|
2512
|
+
crate::tmux_backend::PANE_BINDING_NONCE_METADATA_KEY.to_string(),
|
|
2513
|
+
"stale-live-nonce".to_string(),
|
|
2514
|
+
);
|
|
2515
|
+
let stale_transport = OfflineTransport::new()
|
|
2516
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
2517
|
+
.with_targets(vec![stale_target]);
|
|
2518
|
+
assert!(matches!(
|
|
2519
|
+
crate::messaging::resolve_live_leader_channel(
|
|
2520
|
+
&workspace,
|
|
2521
|
+
&receiver,
|
|
2522
|
+
&stale_transport
|
|
2523
|
+
),
|
|
2524
|
+
crate::messaging::LeaderChannelResolution::Unbound(
|
|
2525
|
+
crate::messaging::LeaderChannelUnbound::PaneWorkspaceMismatch(_)
|
|
2526
|
+
)
|
|
2527
|
+
));
|
|
2528
|
+
|
|
2529
|
+
let missing_target = caller_target(hermetic.root().join("parent"), None);
|
|
2530
|
+
let missing_transport = OfflineTransport::new()
|
|
2531
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
2532
|
+
.with_targets(vec![missing_target]);
|
|
2533
|
+
assert!(matches!(
|
|
2534
|
+
crate::messaging::resolve_live_leader_channel(
|
|
2535
|
+
&workspace,
|
|
2536
|
+
&receiver,
|
|
2537
|
+
&missing_transport
|
|
2538
|
+
),
|
|
2539
|
+
crate::messaging::LeaderChannelResolution::Unbound(
|
|
2540
|
+
crate::messaging::LeaderChannelUnbound::PaneWorkspaceMismatch(_)
|
|
2541
|
+
)
|
|
2542
|
+
));
|
|
2543
|
+
|
|
2544
|
+
let mut missing_authority_receiver = receiver.clone();
|
|
2545
|
+
missing_authority_receiver
|
|
2546
|
+
.as_object_mut()
|
|
2547
|
+
.unwrap()
|
|
2548
|
+
.remove("scope_authority");
|
|
2549
|
+
assert!(matches!(
|
|
2550
|
+
crate::messaging::resolve_live_leader_channel(
|
|
2551
|
+
&workspace,
|
|
2552
|
+
&missing_authority_receiver,
|
|
2553
|
+
&transport
|
|
2554
|
+
),
|
|
2555
|
+
crate::messaging::LeaderChannelResolution::Unbound(
|
|
2556
|
+
crate::messaging::LeaderChannelUnbound::PaneWorkspaceMismatch(_)
|
|
2557
|
+
)
|
|
2558
|
+
));
|
|
2559
|
+
|
|
2560
|
+
let mut foreign_receiver = receiver.clone();
|
|
2561
|
+
foreign_receiver["authorized_team_workspace"] =
|
|
2562
|
+
json!(hermetic.root().join("foreign").to_string_lossy().to_string());
|
|
2563
|
+
assert!(matches!(
|
|
2564
|
+
crate::messaging::resolve_live_leader_channel(&workspace, &foreign_receiver, &transport),
|
|
2565
|
+
crate::messaging::LeaderChannelResolution::Unbound(
|
|
2566
|
+
crate::messaging::LeaderChannelUnbound::PaneWorkspaceMismatch(_)
|
|
2567
|
+
)
|
|
2568
|
+
));
|
|
2569
|
+
|
|
2570
|
+
let wrong_socket_transport = OfflineTransport::new()
|
|
2571
|
+
.with_tmux_endpoint("/tmp/other.sock")
|
|
2572
|
+
.with_targets(vec![target]);
|
|
2573
|
+
assert_eq!(
|
|
2574
|
+
crate::messaging::resolve_live_leader_channel(
|
|
2575
|
+
&workspace,
|
|
2576
|
+
&receiver,
|
|
2577
|
+
&wrong_socket_transport
|
|
2578
|
+
),
|
|
2579
|
+
crate::messaging::LeaderChannelResolution::Unbound(
|
|
2580
|
+
crate::messaging::LeaderChannelUnbound::EndpointMismatch
|
|
2581
|
+
)
|
|
2582
|
+
);
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
#[test]
|
|
2586
|
+
#[serial_test::serial(env)]
|
|
2587
|
+
fn seeded_runtime_children_reuse_live_pane_nonce_without_overwrite() {
|
|
2588
|
+
let hermetic = HermeticTestEnv::enter("runtime-seeded-shared-nonce");
|
|
2589
|
+
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
2590
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
2591
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
|
|
2592
|
+
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
2593
|
+
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
2594
|
+
let parent = hermetic.root().join("parent");
|
|
2595
|
+
std::fs::create_dir_all(&parent).unwrap();
|
|
2596
|
+
let target_without_nonce = caller_target(parent.clone(), None);
|
|
2597
|
+
let target_with_winner = caller_target(parent, Some("writer-winner"));
|
|
2598
|
+
let transport = OfflineTransport::new()
|
|
2599
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
2600
|
+
.with_pane_current_command("%1", "bash")
|
|
2601
|
+
.with_targets(vec![target_with_winner.clone()])
|
|
2602
|
+
.with_target_snapshots(vec![
|
|
2603
|
+
vec![target_without_nonce.clone()],
|
|
2604
|
+
vec![target_with_winner.clone()],
|
|
2605
|
+
vec![target_without_nonce],
|
|
2606
|
+
vec![target_with_winner],
|
|
2607
|
+
]);
|
|
2608
|
+
let first_workspace = runtime_workspace(&hermetic, "child-a");
|
|
2609
|
+
let second_workspace = runtime_workspace(&hermetic, "child-b");
|
|
2610
|
+
let first_seed = seeded_runtime_owner(&first_workspace);
|
|
2611
|
+
let second_seed = seeded_runtime_owner(&second_workspace);
|
|
2612
|
+
let mut first_ops = RuntimeFreshQuickStartLeaderBindingOps {
|
|
2613
|
+
transport: &transport,
|
|
2614
|
+
last_failure_reason: None,
|
|
2615
|
+
cas_conflict: false,
|
|
2616
|
+
applied_grant: None,
|
|
2617
|
+
registry_receipt: None,
|
|
2618
|
+
nonce_writer: Some(&nonce_writer_winner),
|
|
2619
|
+
};
|
|
2620
|
+
assert!(bind_fresh_quick_start_leader_with(
|
|
2621
|
+
&first_workspace,
|
|
2622
|
+
"fresh",
|
|
2623
|
+
Some(&first_seed),
|
|
2624
|
+
&mut first_ops
|
|
2625
|
+
)
|
|
2626
|
+
.unwrap());
|
|
2627
|
+
let mut second_ops = RuntimeFreshQuickStartLeaderBindingOps {
|
|
2628
|
+
transport: &transport,
|
|
2629
|
+
last_failure_reason: None,
|
|
2630
|
+
cas_conflict: false,
|
|
2631
|
+
applied_grant: None,
|
|
2632
|
+
registry_receipt: None,
|
|
2633
|
+
nonce_writer: Some(&nonce_writer_winner),
|
|
2634
|
+
};
|
|
2635
|
+
assert!(bind_fresh_quick_start_leader_with(
|
|
2636
|
+
&second_workspace,
|
|
2637
|
+
"fresh",
|
|
2638
|
+
Some(&second_seed),
|
|
2639
|
+
&mut second_ops
|
|
2640
|
+
)
|
|
2641
|
+
.unwrap());
|
|
2642
|
+
let first_receiver = crate::state::persist::load_runtime_state(&first_workspace).unwrap();
|
|
2643
|
+
let second_receiver = crate::state::persist::load_runtime_state(&second_workspace).unwrap();
|
|
2644
|
+
assert_eq!(
|
|
2645
|
+
first_receiver
|
|
2646
|
+
.pointer("/teams/fresh/leader_receiver/binding_nonce")
|
|
2647
|
+
.and_then(serde_json::Value::as_str),
|
|
2648
|
+
Some("writer-winner")
|
|
2649
|
+
);
|
|
2650
|
+
assert_eq!(
|
|
2651
|
+
second_receiver
|
|
2652
|
+
.pointer("/teams/fresh/leader_receiver/binding_nonce")
|
|
2653
|
+
.and_then(serde_json::Value::as_str),
|
|
2654
|
+
Some("writer-winner")
|
|
2655
|
+
);
|
|
2656
|
+
let first_channel = first_receiver
|
|
2657
|
+
.pointer("/teams/fresh/leader_receiver")
|
|
2658
|
+
.expect("first receiver");
|
|
2659
|
+
let second_channel = second_receiver
|
|
2660
|
+
.pointer("/teams/fresh/leader_receiver")
|
|
2661
|
+
.expect("second receiver");
|
|
2662
|
+
assert!(matches!(
|
|
2663
|
+
crate::messaging::resolve_live_leader_channel(
|
|
2664
|
+
&first_workspace,
|
|
2665
|
+
first_channel,
|
|
2666
|
+
&transport
|
|
2667
|
+
),
|
|
2668
|
+
crate::messaging::LeaderChannelResolution::Live(_)
|
|
2669
|
+
));
|
|
2670
|
+
assert!(matches!(
|
|
2671
|
+
crate::messaging::resolve_live_leader_channel(
|
|
2672
|
+
&second_workspace,
|
|
2673
|
+
second_channel,
|
|
2674
|
+
&transport
|
|
2675
|
+
),
|
|
2676
|
+
crate::messaging::LeaderChannelResolution::Live(_)
|
|
2677
|
+
));
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
struct RuntimeCommitFailureOps<'a> {
|
|
2681
|
+
inner: RuntimeFreshQuickStartLeaderBindingOps<'a>,
|
|
2682
|
+
register_ok: bool,
|
|
2683
|
+
readback_ok: bool,
|
|
2684
|
+
publish_winner_after_register: bool,
|
|
2685
|
+
break_event_log: bool,
|
|
2686
|
+
break_registry_lock: bool,
|
|
2687
|
+
break_registry_read: bool,
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
impl FreshQuickStartLeaderBindingOps for RuntimeCommitFailureOps<'_> {
|
|
2691
|
+
fn caller_pane(&mut self) -> Option<String> {
|
|
2692
|
+
self.inner.caller_pane()
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
fn caller_resolution(&mut self) -> crate::layout::worker_env::CallerProviderResolution {
|
|
2696
|
+
self.inner.caller_resolution()
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
fn explicit_provider(&mut self) -> Option<String> {
|
|
2700
|
+
self.inner.explicit_provider()
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
fn tmux_endpoint(&mut self) -> Option<String> {
|
|
2704
|
+
self.inner.tmux_endpoint()
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
fn observe_command(&mut self, pane: &PaneId) -> Option<String> {
|
|
2708
|
+
self.inner.observe_command(pane)
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
fn attach(
|
|
2712
|
+
&mut self,
|
|
2713
|
+
workspace: &Path,
|
|
2714
|
+
state: &mut serde_json::Value,
|
|
2715
|
+
pane: &PaneId,
|
|
2716
|
+
provider: crate::provider::Provider,
|
|
2717
|
+
) -> bool {
|
|
2718
|
+
if self.break_event_log {
|
|
2719
|
+
let event_path = crate::model::paths::logs_dir(workspace).join("events.jsonl");
|
|
2720
|
+
let _ = std::fs::remove_file(&event_path);
|
|
2721
|
+
std::fs::create_dir_all(&event_path).unwrap();
|
|
2722
|
+
}
|
|
2723
|
+
self.inner.attach(workspace, state, pane, provider)
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
fn attach_cas_conflict(&self) -> bool {
|
|
2727
|
+
self.inner.attach_cas_conflict()
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
fn applied_grant(&self) -> Option<(serde_json::Value, serde_json::Value)> {
|
|
2731
|
+
self.inner.applied_grant()
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2734
|
+
fn registry_receipt(&self) -> Option<crate::leader::registry::RegistryWriteReceipt> {
|
|
2735
|
+
self.inner.registry_receipt()
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
2739
|
+
let registered = self.inner.register(workspace, team_key);
|
|
2740
|
+
if !self.register_ok {
|
|
2741
|
+
return false;
|
|
2742
|
+
}
|
|
2743
|
+
if self.break_registry_lock {
|
|
2744
|
+
let lock_path = crate::leader::registry::registry_dir()
|
|
2745
|
+
.expect("registry dir")
|
|
2746
|
+
.join(".registry.lock");
|
|
2747
|
+
let _ = std::fs::remove_file(&lock_path);
|
|
2748
|
+
std::fs::create_dir_all(&lock_path).unwrap();
|
|
2749
|
+
}
|
|
2750
|
+
if self.break_registry_read {
|
|
2751
|
+
if let Some(receipt) = self.inner.registry_receipt() {
|
|
2752
|
+
let _ = std::fs::remove_file(&receipt.path);
|
|
2753
|
+
std::fs::create_dir_all(&receipt.path).unwrap();
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
if registered && self.publish_winner_after_register {
|
|
2757
|
+
let mut winner = crate::state::persist::load_runtime_state(workspace).unwrap();
|
|
2758
|
+
winner["teams"][team_key]["leader_receiver"]["pane_id"] = json!("%winner");
|
|
2759
|
+
crate::state::persist::save_runtime_state_with_receiver_authority(
|
|
2760
|
+
workspace,
|
|
2761
|
+
&winner,
|
|
2762
|
+
team_key,
|
|
2763
|
+
None,
|
|
2764
|
+
)
|
|
2765
|
+
.unwrap();
|
|
2766
|
+
let _ = crate::leader::registry::register_binding_from_state_best_effort(
|
|
2767
|
+
workspace,
|
|
2768
|
+
Some(team_key),
|
|
2769
|
+
"concurrent",
|
|
2770
|
+
);
|
|
2771
|
+
}
|
|
2772
|
+
registered
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
2776
|
+
self.readback_ok && self.inner.canonical_readback(workspace, team_key)
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
#[test]
|
|
2781
|
+
#[serial_test::serial(env)]
|
|
2782
|
+
fn seeded_runtime_register_failure_rolls_back_child_authority() {
|
|
2783
|
+
let hermetic = HermeticTestEnv::enter("runtime-seeded-rollback");
|
|
2784
|
+
let workspace = runtime_workspace(&hermetic, "fresh");
|
|
2785
|
+
let parent = hermetic.root().join("parent");
|
|
2786
|
+
std::fs::create_dir_all(&parent).unwrap();
|
|
2787
|
+
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
2788
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
2789
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
|
|
2790
|
+
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
2791
|
+
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
2792
|
+
let seed = seeded_runtime_owner(&workspace);
|
|
2793
|
+
let target_without_nonce = caller_target(parent.clone(), None);
|
|
2794
|
+
let target_with_nonce = caller_target(parent, Some("writer-winner"));
|
|
2795
|
+
let transport = OfflineTransport::new()
|
|
2796
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
2797
|
+
.with_pane_current_command("%1", "bash")
|
|
2798
|
+
.with_targets(vec![target_with_nonce.clone()])
|
|
2799
|
+
.with_target_snapshots(vec![
|
|
2800
|
+
vec![target_without_nonce],
|
|
2801
|
+
vec![target_with_nonce],
|
|
2802
|
+
]);
|
|
2803
|
+
let mut ops = RuntimeCommitFailureOps {
|
|
2804
|
+
inner: RuntimeFreshQuickStartLeaderBindingOps {
|
|
2805
|
+
transport: &transport,
|
|
2806
|
+
last_failure_reason: None,
|
|
2807
|
+
cas_conflict: false,
|
|
2808
|
+
applied_grant: None,
|
|
2809
|
+
registry_receipt: None,
|
|
2810
|
+
nonce_writer: Some(&nonce_writer_winner),
|
|
2811
|
+
},
|
|
2812
|
+
register_ok: false,
|
|
2813
|
+
readback_ok: true,
|
|
2814
|
+
publish_winner_after_register: false,
|
|
2815
|
+
break_event_log: false,
|
|
2816
|
+
break_registry_lock: false,
|
|
2817
|
+
break_registry_read: false,
|
|
2818
|
+
};
|
|
2819
|
+
assert!(!bind_fresh_quick_start_leader_with(
|
|
2820
|
+
&workspace,
|
|
2821
|
+
"fresh",
|
|
2822
|
+
Some(&seed),
|
|
2823
|
+
&mut ops
|
|
2824
|
+
)
|
|
2825
|
+
.unwrap());
|
|
2826
|
+
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
2827
|
+
assert!(persisted
|
|
2828
|
+
.pointer("/teams/fresh/team_owner")
|
|
2829
|
+
.is_some_and(serde_json::Value::is_null));
|
|
2830
|
+
assert!(persisted
|
|
2831
|
+
.pointer("/teams/fresh/leader_receiver")
|
|
2832
|
+
.is_some_and(serde_json::Value::is_null));
|
|
2833
|
+
assert!(!persisted.to_string().contains("fresh_caller"));
|
|
2834
|
+
let registry_path = crate::leader::registry::registry_dir()
|
|
2835
|
+
.unwrap()
|
|
2836
|
+
.join(format!(
|
|
2837
|
+
"{}__fresh.json",
|
|
2838
|
+
crate::leader::registry::workspace_hash(&workspace)
|
|
2839
|
+
));
|
|
2840
|
+
assert!(!registry_path.exists(), "failed child grant left registry row");
|
|
2841
|
+
let observed = transport.list_targets().unwrap();
|
|
2842
|
+
assert_eq!(
|
|
2843
|
+
observed[0]
|
|
2844
|
+
.leader_env
|
|
2845
|
+
.get(crate::tmux_backend::PANE_BINDING_NONCE_METADATA_KEY)
|
|
2846
|
+
.map(String::as_str),
|
|
2847
|
+
Some("writer-winner"),
|
|
2848
|
+
"failed child bind must not remove the shared first-writer marker"
|
|
2849
|
+
);
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
#[test]
|
|
2853
|
+
#[serial_test::serial(env)]
|
|
2854
|
+
fn seeded_runtime_post_save_event_error_rolls_back_applied_grant() {
|
|
2855
|
+
let hermetic = HermeticTestEnv::enter("runtime-seeded-post-save-event-error");
|
|
2856
|
+
let workspace = runtime_workspace(&hermetic, "fresh");
|
|
2857
|
+
let parent = hermetic.root().join("parent");
|
|
2858
|
+
std::fs::create_dir_all(&parent).unwrap();
|
|
2859
|
+
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
2860
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
2861
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
|
|
2862
|
+
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
2863
|
+
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
2864
|
+
let seed = seeded_runtime_owner(&workspace);
|
|
2865
|
+
let target = caller_target(parent, Some("post-save-event-nonce"));
|
|
2866
|
+
let transport = OfflineTransport::new()
|
|
2867
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
2868
|
+
.with_pane_current_command("%1", "bash")
|
|
2869
|
+
.with_targets(vec![target]);
|
|
2870
|
+
let mut ops = RuntimeCommitFailureOps {
|
|
2871
|
+
inner: RuntimeFreshQuickStartLeaderBindingOps {
|
|
2872
|
+
transport: &transport,
|
|
2873
|
+
last_failure_reason: None,
|
|
2874
|
+
cas_conflict: false,
|
|
2875
|
+
applied_grant: None,
|
|
2876
|
+
registry_receipt: None,
|
|
2877
|
+
nonce_writer: None,
|
|
2878
|
+
},
|
|
2879
|
+
register_ok: true,
|
|
2880
|
+
readback_ok: true,
|
|
2881
|
+
publish_winner_after_register: false,
|
|
2882
|
+
break_event_log: true,
|
|
2883
|
+
break_registry_lock: false,
|
|
2884
|
+
break_registry_read: false,
|
|
2885
|
+
};
|
|
2886
|
+
assert!(!bind_fresh_quick_start_leader_with(
|
|
2887
|
+
&workspace,
|
|
2888
|
+
"fresh",
|
|
2889
|
+
Some(&seed),
|
|
2890
|
+
&mut ops,
|
|
2891
|
+
)
|
|
2892
|
+
.unwrap());
|
|
2893
|
+
assert_eq!(ops.inner.last_failure_reason, Some("attach_event_log_failed"));
|
|
2894
|
+
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
2895
|
+
assert!(persisted
|
|
2896
|
+
.pointer("/teams/fresh/team_owner")
|
|
2897
|
+
.is_some_and(serde_json::Value::is_null));
|
|
2898
|
+
assert!(persisted
|
|
2899
|
+
.pointer("/teams/fresh/leader_receiver")
|
|
2900
|
+
.is_some_and(serde_json::Value::is_null));
|
|
2901
|
+
assert!(!persisted.to_string().contains("fresh_caller"));
|
|
2902
|
+
let registry_path = crate::leader::registry::registry_dir()
|
|
2903
|
+
.unwrap()
|
|
2904
|
+
.join(format!(
|
|
2905
|
+
"{}__fresh.json",
|
|
2906
|
+
crate::leader::registry::workspace_hash(&workspace)
|
|
2907
|
+
));
|
|
2908
|
+
assert!(!registry_path.exists());
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
#[test]
|
|
2912
|
+
#[serial_test::serial(env)]
|
|
2913
|
+
fn seeded_runtime_nonce_change_before_readback_rolls_back_child_authority() {
|
|
2914
|
+
let hermetic = HermeticTestEnv::enter("runtime-seeded-nonce-drift");
|
|
2915
|
+
let workspace = runtime_workspace(&hermetic, "fresh");
|
|
2916
|
+
let parent = hermetic.root().join("parent");
|
|
2917
|
+
std::fs::create_dir_all(&parent).unwrap();
|
|
2918
|
+
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
2919
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
2920
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
|
|
2921
|
+
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
2922
|
+
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
2923
|
+
let seed = seeded_runtime_owner(&workspace);
|
|
2924
|
+
let first_target = caller_target(parent.clone(), Some("before-readback"));
|
|
2925
|
+
let mut changed_target = first_target.clone();
|
|
2926
|
+
changed_target.leader_env.insert(
|
|
2927
|
+
crate::tmux_backend::PANE_BINDING_NONCE_METADATA_KEY.to_string(),
|
|
2928
|
+
"after-readback".to_string(),
|
|
2929
|
+
);
|
|
2930
|
+
let transport = OfflineTransport::new()
|
|
2931
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
2932
|
+
.with_pane_current_command("%1", "bash")
|
|
2933
|
+
.with_target_snapshots(vec![vec![first_target], vec![changed_target]]);
|
|
2934
|
+
let mut ops = RuntimeFreshQuickStartLeaderBindingOps {
|
|
2935
|
+
transport: &transport,
|
|
2936
|
+
last_failure_reason: None,
|
|
2937
|
+
cas_conflict: false,
|
|
2938
|
+
applied_grant: None,
|
|
2939
|
+
registry_receipt: None,
|
|
2940
|
+
nonce_writer: None,
|
|
2941
|
+
};
|
|
2942
|
+
assert!(!bind_fresh_quick_start_leader_with(
|
|
2943
|
+
&workspace,
|
|
2944
|
+
"fresh",
|
|
2945
|
+
Some(&seed),
|
|
2946
|
+
&mut ops
|
|
2947
|
+
)
|
|
2948
|
+
.unwrap());
|
|
2949
|
+
assert_eq!(ops.last_failure_reason, Some("receiver_live_channel_unavailable"));
|
|
2950
|
+
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
2951
|
+
assert!(persisted
|
|
2952
|
+
.pointer("/teams/fresh/team_owner")
|
|
2953
|
+
.is_some_and(serde_json::Value::is_null));
|
|
2954
|
+
assert!(persisted
|
|
2955
|
+
.pointer("/teams/fresh/leader_receiver")
|
|
2956
|
+
.is_some_and(serde_json::Value::is_null));
|
|
2957
|
+
let registry_path = crate::leader::registry::registry_dir()
|
|
2958
|
+
.unwrap()
|
|
2959
|
+
.join(format!(
|
|
2960
|
+
"{}__fresh.json",
|
|
2961
|
+
crate::leader::registry::workspace_hash(&workspace)
|
|
2962
|
+
));
|
|
2963
|
+
assert!(!registry_path.exists());
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
#[test]
|
|
2967
|
+
#[serial_test::serial(env)]
|
|
2968
|
+
fn seeded_runtime_readback_failure_rolls_back_registry_and_child_authority() {
|
|
2969
|
+
let hermetic = HermeticTestEnv::enter("runtime-seeded-readback-rollback");
|
|
2970
|
+
let workspace = runtime_workspace(&hermetic, "fresh");
|
|
2971
|
+
let parent = hermetic.root().join("parent");
|
|
2972
|
+
std::fs::create_dir_all(&parent).unwrap();
|
|
2973
|
+
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
2974
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
2975
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
|
|
2976
|
+
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
2977
|
+
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
2978
|
+
let seed = seeded_runtime_owner(&workspace);
|
|
2979
|
+
let target = caller_target(parent, Some("readback-nonce"));
|
|
2980
|
+
let transport = OfflineTransport::new()
|
|
2981
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
2982
|
+
.with_pane_current_command("%1", "bash")
|
|
2983
|
+
.with_targets(vec![target]);
|
|
2984
|
+
let mut ops = RuntimeCommitFailureOps {
|
|
2985
|
+
inner: RuntimeFreshQuickStartLeaderBindingOps {
|
|
2986
|
+
transport: &transport,
|
|
2987
|
+
last_failure_reason: None,
|
|
2988
|
+
cas_conflict: false,
|
|
2989
|
+
applied_grant: None,
|
|
2990
|
+
registry_receipt: None,
|
|
2991
|
+
nonce_writer: None,
|
|
2992
|
+
},
|
|
2993
|
+
register_ok: true,
|
|
2994
|
+
readback_ok: false,
|
|
2995
|
+
publish_winner_after_register: false,
|
|
2996
|
+
break_event_log: false,
|
|
2997
|
+
break_registry_lock: false,
|
|
2998
|
+
break_registry_read: false,
|
|
2999
|
+
};
|
|
3000
|
+
assert!(!bind_fresh_quick_start_leader_with(
|
|
3001
|
+
&workspace,
|
|
3002
|
+
"fresh",
|
|
3003
|
+
Some(&seed),
|
|
3004
|
+
&mut ops
|
|
3005
|
+
)
|
|
3006
|
+
.unwrap());
|
|
3007
|
+
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
3008
|
+
assert!(persisted
|
|
3009
|
+
.pointer("/teams/fresh/team_owner")
|
|
3010
|
+
.is_some_and(serde_json::Value::is_null));
|
|
3011
|
+
assert!(persisted
|
|
3012
|
+
.pointer("/teams/fresh/leader_receiver")
|
|
3013
|
+
.is_some_and(serde_json::Value::is_null));
|
|
3014
|
+
let registry_path = crate::leader::registry::registry_dir()
|
|
3015
|
+
.unwrap()
|
|
3016
|
+
.join(format!(
|
|
3017
|
+
"{}__fresh.json",
|
|
3018
|
+
crate::leader::registry::workspace_hash(&workspace)
|
|
3019
|
+
));
|
|
3020
|
+
assert!(!registry_path.exists(), "readback failure left registry row");
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
#[test]
|
|
3024
|
+
#[serial_test::serial(env)]
|
|
3025
|
+
fn seeded_runtime_readback_rollback_preserves_concurrent_registry_winner() {
|
|
3026
|
+
let hermetic = HermeticTestEnv::enter("runtime-seeded-readback-winner");
|
|
3027
|
+
let workspace = runtime_workspace(&hermetic, "fresh");
|
|
3028
|
+
let parent = hermetic.root().join("parent");
|
|
3029
|
+
std::fs::create_dir_all(&parent).unwrap();
|
|
3030
|
+
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
3031
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
3032
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
|
|
3033
|
+
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
3034
|
+
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
3035
|
+
let seed = seeded_runtime_owner(&workspace);
|
|
3036
|
+
let target = caller_target(parent, Some("readback-winner-nonce"));
|
|
3037
|
+
let transport = OfflineTransport::new()
|
|
3038
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
3039
|
+
.with_pane_current_command("%1", "bash")
|
|
3040
|
+
.with_targets(vec![target]);
|
|
3041
|
+
let mut ops = RuntimeCommitFailureOps {
|
|
3042
|
+
inner: RuntimeFreshQuickStartLeaderBindingOps {
|
|
3043
|
+
transport: &transport,
|
|
3044
|
+
last_failure_reason: None,
|
|
3045
|
+
cas_conflict: false,
|
|
3046
|
+
applied_grant: None,
|
|
3047
|
+
registry_receipt: None,
|
|
3048
|
+
nonce_writer: None,
|
|
3049
|
+
},
|
|
3050
|
+
register_ok: true,
|
|
3051
|
+
readback_ok: false,
|
|
3052
|
+
publish_winner_after_register: true,
|
|
3053
|
+
break_event_log: false,
|
|
3054
|
+
break_registry_lock: false,
|
|
3055
|
+
break_registry_read: false,
|
|
3056
|
+
};
|
|
3057
|
+
assert!(!bind_fresh_quick_start_leader_with(
|
|
3058
|
+
&workspace,
|
|
3059
|
+
"fresh",
|
|
3060
|
+
Some(&seed),
|
|
3061
|
+
&mut ops,
|
|
3062
|
+
)
|
|
3063
|
+
.unwrap());
|
|
3064
|
+
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
3065
|
+
assert_eq!(
|
|
3066
|
+
persisted
|
|
3067
|
+
.pointer("/teams/fresh/team_owner/pane_id")
|
|
3068
|
+
.and_then(serde_json::Value::as_str),
|
|
3069
|
+
Some("%1"),
|
|
3070
|
+
"same-owner winner must retain the owner"
|
|
3071
|
+
);
|
|
3072
|
+
assert_eq!(
|
|
3073
|
+
persisted
|
|
3074
|
+
.pointer("/teams/fresh/leader_receiver/pane_id")
|
|
3075
|
+
.and_then(serde_json::Value::as_str),
|
|
3076
|
+
Some("%winner"),
|
|
3077
|
+
"rollback must not clear a newer receiver"
|
|
3078
|
+
);
|
|
3079
|
+
assert_eq!(
|
|
3080
|
+
persisted
|
|
3081
|
+
.pointer("/teams/fresh/leader_receiver/owner_epoch")
|
|
3082
|
+
.and_then(serde_json::Value::as_u64),
|
|
3083
|
+
Some(1),
|
|
3084
|
+
"rollback must preserve the winner epoch"
|
|
3085
|
+
);
|
|
3086
|
+
let registry_path = crate::leader::registry::registry_dir()
|
|
3087
|
+
.unwrap()
|
|
3088
|
+
.join(format!(
|
|
3089
|
+
"{}__fresh.json",
|
|
3090
|
+
crate::leader::registry::workspace_hash(&workspace)
|
|
3091
|
+
));
|
|
3092
|
+
let winner: crate::leader::registry::LeaderRegistryEntry =
|
|
3093
|
+
serde_json::from_slice(&std::fs::read(registry_path).unwrap()).unwrap();
|
|
3094
|
+
assert_eq!(winner.channel["pane_id"], json!("%winner"));
|
|
3095
|
+
assert_eq!(winner.source, "concurrent");
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
#[test]
|
|
3099
|
+
#[serial_test::serial(env)]
|
|
3100
|
+
fn seeded_runtime_registry_rollback_errors_are_visible_after_state_cleanup() {
|
|
3101
|
+
for (tag, break_registry_lock, break_registry_read) in [
|
|
3102
|
+
("lock", true, false),
|
|
3103
|
+
("read", false, true),
|
|
3104
|
+
] {
|
|
3105
|
+
let hermetic = HermeticTestEnv::enter(&format!(
|
|
3106
|
+
"runtime-seeded-registry-rollback-{tag}"
|
|
3107
|
+
));
|
|
3108
|
+
let workspace = runtime_workspace(&hermetic, "fresh");
|
|
3109
|
+
let parent = hermetic.root().join("parent");
|
|
3110
|
+
std::fs::create_dir_all(&parent).unwrap();
|
|
3111
|
+
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
3112
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
3113
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
|
|
3114
|
+
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
3115
|
+
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
3116
|
+
let seed = seeded_runtime_owner(&workspace);
|
|
3117
|
+
let target = caller_target(parent, Some("rollback-error-nonce"));
|
|
3118
|
+
let transport = OfflineTransport::new()
|
|
3119
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
3120
|
+
.with_pane_current_command("%1", "bash")
|
|
3121
|
+
.with_targets(vec![target]);
|
|
3122
|
+
let mut ops = RuntimeCommitFailureOps {
|
|
3123
|
+
inner: RuntimeFreshQuickStartLeaderBindingOps {
|
|
3124
|
+
transport: &transport,
|
|
3125
|
+
last_failure_reason: None,
|
|
3126
|
+
cas_conflict: false,
|
|
3127
|
+
applied_grant: None,
|
|
3128
|
+
registry_receipt: None,
|
|
3129
|
+
nonce_writer: None,
|
|
3130
|
+
},
|
|
3131
|
+
register_ok: true,
|
|
3132
|
+
readback_ok: false,
|
|
3133
|
+
publish_winner_after_register: false,
|
|
3134
|
+
break_event_log: false,
|
|
3135
|
+
break_registry_lock,
|
|
3136
|
+
break_registry_read,
|
|
3137
|
+
};
|
|
3138
|
+
let error = bind_fresh_quick_start_leader_with(
|
|
3139
|
+
&workspace,
|
|
3140
|
+
"fresh",
|
|
3141
|
+
Some(&seed),
|
|
3142
|
+
&mut ops,
|
|
3143
|
+
)
|
|
3144
|
+
.expect_err("registry rollback failure must be visible");
|
|
3145
|
+
assert!(error
|
|
3146
|
+
.to_string()
|
|
3147
|
+
.contains("quick-start registry rollback failed"));
|
|
3148
|
+
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
3149
|
+
assert!(persisted
|
|
3150
|
+
.pointer("/teams/fresh/team_owner")
|
|
3151
|
+
.is_some_and(serde_json::Value::is_null));
|
|
3152
|
+
assert!(persisted
|
|
3153
|
+
.pointer("/teams/fresh/leader_receiver")
|
|
3154
|
+
.is_some_and(serde_json::Value::is_null));
|
|
3155
|
+
assert!(!persisted.to_string().contains("fresh_caller"));
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
fn nonce_writer_winner(
|
|
3160
|
+
_endpoint: &str,
|
|
3161
|
+
_pane: &PaneId,
|
|
3162
|
+
_candidate: &str,
|
|
3163
|
+
) -> Result<String, crate::leader::LeaderError> {
|
|
3164
|
+
Ok("writer-winner".to_string())
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
fn nonce_writer_failure(
|
|
3168
|
+
_endpoint: &str,
|
|
3169
|
+
_pane: &PaneId,
|
|
3170
|
+
_candidate: &str,
|
|
3171
|
+
) -> Result<String, crate::leader::LeaderError> {
|
|
3172
|
+
Err(crate::leader::LeaderError::Tmux(
|
|
3173
|
+
"conditional nonce writer failed".to_string(),
|
|
3174
|
+
))
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
#[test]
|
|
3178
|
+
#[serial_test::serial(env)]
|
|
3179
|
+
fn runtime_missing_verified_pane_observation_refuses_and_cleans_seed() {
|
|
3180
|
+
for (tag, observation, expected_reason) in [
|
|
3181
|
+
("list-error", "list-error", "caller_pane_unobservable"),
|
|
3182
|
+
("pane-absent", "pane-absent", "caller_pane_not_live"),
|
|
3183
|
+
("cwd-missing", "cwd-missing", "caller_cwd_unobservable"),
|
|
3184
|
+
] {
|
|
3185
|
+
let hermetic = HermeticTestEnv::enter(&format!("runtime-observation-{tag}"));
|
|
3186
|
+
let workspace = runtime_workspace(&hermetic, "fresh");
|
|
3187
|
+
let parent = hermetic.root().join("parent");
|
|
3188
|
+
std::fs::create_dir_all(&parent).unwrap();
|
|
3189
|
+
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
3190
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
3191
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
|
|
3192
|
+
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
3193
|
+
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
3194
|
+
let seed = seeded_runtime_owner(&workspace);
|
|
3195
|
+
let mut transport = OfflineTransport::new()
|
|
3196
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
3197
|
+
.with_pane_current_command("%1", "bash");
|
|
3198
|
+
match observation {
|
|
3199
|
+
"list-error" => {
|
|
3200
|
+
transport = transport.with_list_targets_error("pane query failed");
|
|
3201
|
+
}
|
|
3202
|
+
"pane-absent" => {}
|
|
3203
|
+
"cwd-missing" => {
|
|
3204
|
+
let mut target = caller_target(parent, None);
|
|
3205
|
+
target.current_path = None;
|
|
3206
|
+
transport = transport.with_targets(vec![target]);
|
|
3207
|
+
}
|
|
3208
|
+
_ => unreachable!(),
|
|
3209
|
+
}
|
|
3210
|
+
let mut ops = RuntimeFreshQuickStartLeaderBindingOps {
|
|
3211
|
+
transport: &transport,
|
|
3212
|
+
last_failure_reason: None,
|
|
3213
|
+
cas_conflict: false,
|
|
3214
|
+
applied_grant: None,
|
|
3215
|
+
registry_receipt: None,
|
|
3216
|
+
nonce_writer: None,
|
|
3217
|
+
};
|
|
3218
|
+
assert!(!bind_fresh_quick_start_leader_with(
|
|
3219
|
+
&workspace,
|
|
3220
|
+
"fresh",
|
|
3221
|
+
Some(&seed),
|
|
3222
|
+
&mut ops
|
|
3223
|
+
)
|
|
3224
|
+
.unwrap());
|
|
3225
|
+
assert_eq!(ops.last_failure_reason, Some(expected_reason));
|
|
3226
|
+
assert_eq!(refusal_reason(&workspace).map(|(_, reason)| reason), Some(expected_reason.to_string()));
|
|
3227
|
+
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
3228
|
+
assert!(persisted
|
|
3229
|
+
.pointer("/teams/fresh/team_owner")
|
|
3230
|
+
.is_some_and(serde_json::Value::is_null));
|
|
3231
|
+
assert!(persisted
|
|
3232
|
+
.pointer("/teams/fresh/leader_receiver")
|
|
3233
|
+
.is_some_and(serde_json::Value::is_null));
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
#[test]
|
|
3238
|
+
#[serial_test::serial(env)]
|
|
3239
|
+
fn first_nonce_writer_is_product_generated_and_consumer_live() {
|
|
3240
|
+
let hermetic = HermeticTestEnv::enter("runtime-first-nonce-writer");
|
|
3241
|
+
let workspace = runtime_workspace(&hermetic, "fresh");
|
|
3242
|
+
let parent = hermetic.root().join("parent");
|
|
3243
|
+
std::fs::create_dir_all(&parent).unwrap();
|
|
3244
|
+
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
3245
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
3246
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
|
|
3247
|
+
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
3248
|
+
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
3249
|
+
let seed = seeded_runtime_owner(&workspace);
|
|
3250
|
+
let mut state = crate::state::projection::resolve_runtime_team_scope(
|
|
3251
|
+
&workspace,
|
|
3252
|
+
Some("fresh"),
|
|
3253
|
+
)
|
|
3254
|
+
.unwrap()
|
|
3255
|
+
.state;
|
|
3256
|
+
let expected_owner = state["team_owner"].clone();
|
|
3257
|
+
let expected_receiver = state["leader_receiver"].clone();
|
|
3258
|
+
let target = caller_target(parent.clone(), None);
|
|
3259
|
+
let event_log = crate::event_log::EventLog::new(&workspace);
|
|
3260
|
+
let (receiver, _) = crate::leader::attach_leader_to_state_with_target_and_controls(
|
|
3261
|
+
&workspace,
|
|
3262
|
+
&mut state,
|
|
3263
|
+
Some(&PaneId::new("%1")),
|
|
3264
|
+
crate::provider::Provider::Pi,
|
|
3265
|
+
&event_log,
|
|
3266
|
+
crate::leader::LeaseSource::QuickStart,
|
|
3267
|
+
true,
|
|
3268
|
+
Some(&target),
|
|
3269
|
+
Some(&expected_owner),
|
|
3270
|
+
Some(&expected_receiver),
|
|
3271
|
+
Some(&nonce_writer_winner),
|
|
3272
|
+
)
|
|
3273
|
+
.unwrap();
|
|
3274
|
+
let receiver = serde_json::to_value(receiver).unwrap();
|
|
3275
|
+
assert_eq!(receiver["binding_nonce"], json!("writer-winner"));
|
|
3276
|
+
let observed = caller_target(parent, Some("writer-winner"));
|
|
3277
|
+
let transport = OfflineTransport::new()
|
|
3278
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
3279
|
+
.with_targets(vec![observed]);
|
|
3280
|
+
assert!(matches!(
|
|
3281
|
+
crate::messaging::resolve_live_leader_channel(&workspace, &receiver, &transport),
|
|
3282
|
+
crate::messaging::LeaderChannelResolution::Live(_)
|
|
3283
|
+
));
|
|
3284
|
+
|
|
3285
|
+
let workspace = runtime_workspace(&hermetic, "writer-failure");
|
|
3286
|
+
let seed = seeded_runtime_owner(&workspace);
|
|
3287
|
+
let mut state = crate::state::projection::resolve_runtime_team_scope(
|
|
3288
|
+
&workspace,
|
|
3289
|
+
Some("fresh"),
|
|
3290
|
+
)
|
|
3291
|
+
.unwrap()
|
|
3292
|
+
.state;
|
|
3293
|
+
let expected_owner = state["team_owner"].clone();
|
|
3294
|
+
let expected_receiver = state["leader_receiver"].clone();
|
|
3295
|
+
let target = caller_target(hermetic.root().join("parent"), None);
|
|
3296
|
+
let result = crate::leader::attach_leader_to_state_with_target_and_controls(
|
|
3297
|
+
&workspace,
|
|
3298
|
+
&mut state,
|
|
3299
|
+
Some(&PaneId::new("%1")),
|
|
3300
|
+
crate::provider::Provider::Pi,
|
|
3301
|
+
&crate::event_log::EventLog::new(&workspace),
|
|
3302
|
+
crate::leader::LeaseSource::QuickStart,
|
|
3303
|
+
true,
|
|
3304
|
+
Some(&target),
|
|
3305
|
+
Some(&expected_owner),
|
|
3306
|
+
Some(&expected_receiver),
|
|
3307
|
+
Some(&nonce_writer_failure),
|
|
3308
|
+
);
|
|
3309
|
+
assert!(result.is_err());
|
|
3310
|
+
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
3311
|
+
assert_eq!(
|
|
3312
|
+
persisted.pointer("/teams/fresh/leader_receiver/binding_nonce"),
|
|
3313
|
+
None
|
|
3314
|
+
);
|
|
3315
|
+
assert!(!persisted.to_string().contains("fresh_caller"));
|
|
3316
|
+
let _ = seed;
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
#[test]
|
|
3320
|
+
#[serial_test::serial(env)]
|
|
3321
|
+
fn seeded_cas_conflict_preserves_same_owner_receiver_and_registry_through_bind_rollback() {
|
|
3322
|
+
let hermetic = HermeticTestEnv::enter("runtime-seeded-cas-winner");
|
|
3323
|
+
let workspace = runtime_workspace(&hermetic, "fresh");
|
|
3324
|
+
let parent = hermetic.root().join("parent");
|
|
3325
|
+
std::fs::create_dir_all(&parent).unwrap();
|
|
3326
|
+
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
3327
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
3328
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
|
|
3329
|
+
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
3330
|
+
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
3331
|
+
let seed = seeded_runtime_owner(&workspace);
|
|
3332
|
+
let concurrent_writer = |_: &str,
|
|
3333
|
+
_: &PaneId,
|
|
3334
|
+
_: &str|
|
|
3335
|
+
-> Result<String, crate::leader::LeaderError> {
|
|
3336
|
+
let mut winner = crate::state::persist::load_runtime_state(&workspace)
|
|
3337
|
+
.map_err(crate::leader::LeaderError::State)?;
|
|
3338
|
+
winner["teams"]["fresh"]["leader_receiver"]["pane_id"] = json!("%winner");
|
|
3339
|
+
crate::state::persist::save_runtime_state_with_receiver_authority(
|
|
3340
|
+
&workspace,
|
|
3341
|
+
&winner,
|
|
3342
|
+
"fresh",
|
|
3343
|
+
None,
|
|
3344
|
+
)
|
|
3345
|
+
.map_err(crate::leader::LeaderError::State)?;
|
|
3346
|
+
let _ = crate::leader::registry::register_binding_from_state_best_effort(
|
|
3347
|
+
&workspace,
|
|
3348
|
+
Some("fresh"),
|
|
3349
|
+
"concurrent",
|
|
3350
|
+
);
|
|
3351
|
+
Ok("writer-winner".to_string())
|
|
3352
|
+
};
|
|
3353
|
+
let target = caller_target(parent, None);
|
|
3354
|
+
let transport = OfflineTransport::new()
|
|
3355
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
3356
|
+
.with_pane_current_command("%1", "bash")
|
|
3357
|
+
.with_targets(vec![target]);
|
|
3358
|
+
let mut ops = RuntimeFreshQuickStartLeaderBindingOps {
|
|
3359
|
+
transport: &transport,
|
|
3360
|
+
last_failure_reason: None,
|
|
3361
|
+
cas_conflict: false,
|
|
3362
|
+
applied_grant: None,
|
|
3363
|
+
registry_receipt: None,
|
|
3364
|
+
nonce_writer: Some(&concurrent_writer),
|
|
3365
|
+
};
|
|
3366
|
+
assert!(!bind_fresh_quick_start_leader_with(
|
|
3367
|
+
&workspace,
|
|
3368
|
+
"fresh",
|
|
3369
|
+
Some(&seed),
|
|
3370
|
+
&mut ops
|
|
3371
|
+
)
|
|
3372
|
+
.unwrap());
|
|
3373
|
+
assert!(ops.cas_conflict);
|
|
3374
|
+
assert_eq!(ops.last_failure_reason, Some("attach_cas_conflict"));
|
|
3375
|
+
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
3376
|
+
assert_eq!(
|
|
3377
|
+
persisted.pointer("/teams/fresh/team_owner/pane_id").and_then(serde_json::Value::as_str),
|
|
3378
|
+
Some("%1")
|
|
3379
|
+
);
|
|
3380
|
+
assert_eq!(
|
|
3381
|
+
persisted.pointer("/teams/fresh/leader_receiver/pane_id").and_then(serde_json::Value::as_str),
|
|
3382
|
+
Some("%winner")
|
|
3383
|
+
);
|
|
3384
|
+
assert_eq!(
|
|
3385
|
+
persisted.pointer("/teams/fresh/team_owner/owner_epoch").and_then(serde_json::Value::as_u64),
|
|
3386
|
+
Some(1)
|
|
3387
|
+
);
|
|
3388
|
+
assert_eq!(
|
|
3389
|
+
persisted.pointer("/teams/fresh/leader_receiver/owner_epoch").and_then(serde_json::Value::as_u64),
|
|
3390
|
+
Some(1)
|
|
3391
|
+
);
|
|
3392
|
+
let registry_path = crate::leader::registry::registry_dir()
|
|
3393
|
+
.unwrap()
|
|
3394
|
+
.join(format!(
|
|
3395
|
+
"{}__fresh.json",
|
|
3396
|
+
crate::leader::registry::workspace_hash(&workspace)
|
|
3397
|
+
));
|
|
3398
|
+
assert!(registry_path.exists(), "concurrent winner registry was removed");
|
|
3399
|
+
let winner_entry: crate::leader::registry::LeaderRegistryEntry =
|
|
3400
|
+
serde_json::from_slice(&std::fs::read(registry_path).unwrap()).unwrap();
|
|
3401
|
+
assert_eq!(winner_entry.channel["pane_id"], json!("%winner"));
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
#[test]
|
|
3405
|
+
#[serial_test::serial(env)]
|
|
3406
|
+
fn runtime_invalid_caller_refuses_even_when_command_is_pi() {
|
|
3407
|
+
let hermetic = HermeticTestEnv::enter("runtime-invalid-command-pi");
|
|
3408
|
+
let workspace = runtime_workspace(&hermetic, "fresh");
|
|
3409
|
+
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
3410
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
3411
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
|
|
3412
|
+
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%9");
|
|
3413
|
+
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
|
|
3414
|
+
let transport = OfflineTransport::new()
|
|
3415
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
3416
|
+
.with_pane_current_command("%1", "pi");
|
|
3417
|
+
let mut ops = RecordingRuntimeOps::new(&transport);
|
|
3418
|
+
assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
|
|
3419
|
+
assert_eq!(ops.attach_calls, 0);
|
|
3420
|
+
assert_eq!(ops.attached_provider, None);
|
|
3421
|
+
assert_eq!(
|
|
3422
|
+
refusal_reason(&workspace),
|
|
3423
|
+
Some((
|
|
3424
|
+
"strict_provider".to_string(),
|
|
3425
|
+
"provider_unresolved".to_string()
|
|
3426
|
+
))
|
|
3427
|
+
);
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3430
|
+
#[test]
|
|
3431
|
+
#[serial_test::serial(env)]
|
|
3432
|
+
fn runtime_socket_and_explicit_conflicts_refuse_without_command_fallback() {
|
|
3433
|
+
for (tag, endpoint, leader, scoped) in [
|
|
3434
|
+
(
|
|
3435
|
+
"socket",
|
|
3436
|
+
"/tmp/other.sock",
|
|
3437
|
+
None,
|
|
3438
|
+
"/tmp/tmux.sock",
|
|
3439
|
+
),
|
|
3440
|
+
(
|
|
3441
|
+
"scoped",
|
|
3442
|
+
"/tmp/tmux.sock",
|
|
3443
|
+
None,
|
|
3444
|
+
"/tmp/product.sock",
|
|
3445
|
+
),
|
|
3446
|
+
(
|
|
3447
|
+
"leader",
|
|
3448
|
+
"/tmp/tmux.sock",
|
|
3449
|
+
Some("codex"),
|
|
3450
|
+
"/tmp/tmux.sock",
|
|
3451
|
+
),
|
|
3452
|
+
] {
|
|
3453
|
+
let hermetic = HermeticTestEnv::enter(&format!("runtime-conflict-{tag}"));
|
|
3454
|
+
let workspace = runtime_workspace(&hermetic, tag);
|
|
3455
|
+
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
3456
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
3457
|
+
let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
|
|
3458
|
+
let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
|
|
3459
|
+
let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, endpoint);
|
|
3460
|
+
let _leader = leader.map(|value| hermetic.with_env("TEAM_AGENT_LEADER_PROVIDER", value));
|
|
3461
|
+
let transport = OfflineTransport::new()
|
|
3462
|
+
.with_tmux_endpoint(scoped)
|
|
3463
|
+
.with_pane_current_command("%1", "pi");
|
|
3464
|
+
let mut ops = RecordingRuntimeOps::new(&transport);
|
|
3465
|
+
assert!(
|
|
3466
|
+
!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap(),
|
|
3467
|
+
"{tag} must refuse"
|
|
3468
|
+
);
|
|
3469
|
+
assert_eq!(ops.attach_calls, 0, "{tag}");
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3473
|
+
#[test]
|
|
3474
|
+
#[serial_test::serial(env)]
|
|
3475
|
+
fn runtime_absent_keeps_explicit_and_direct_paths() {
|
|
3476
|
+
let hermetic = HermeticTestEnv::enter("runtime-absent-compat");
|
|
3477
|
+
let workspace = runtime_workspace(&hermetic, "explicit");
|
|
3478
|
+
let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
|
|
3479
|
+
let _pane = hermetic.with_env("TMUX_PANE", "%1");
|
|
3480
|
+
let _leader = hermetic.with_env("TEAM_AGENT_LEADER_PROVIDER", "pi");
|
|
3481
|
+
let transport = OfflineTransport::new()
|
|
3482
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
3483
|
+
.with_pane_current_command("%1", "bash");
|
|
3484
|
+
let mut ops = RecordingRuntimeOps::new(&transport);
|
|
3485
|
+
assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
|
|
3486
|
+
assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Pi));
|
|
3487
|
+
|
|
3488
|
+
let workspace = runtime_workspace(&hermetic, "direct");
|
|
3489
|
+
drop(_leader);
|
|
3490
|
+
let transport = OfflineTransport::new()
|
|
3491
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
3492
|
+
.with_pane_current_command("%1", "pi");
|
|
3493
|
+
let mut ops = RecordingRuntimeOps::new(&transport);
|
|
3494
|
+
assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
|
|
3495
|
+
assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Pi));
|
|
3496
|
+
|
|
3497
|
+
let workspace = runtime_workspace(&hermetic, "unknown-shell");
|
|
3498
|
+
let transport = OfflineTransport::new()
|
|
3499
|
+
.with_tmux_endpoint("/tmp/tmux.sock")
|
|
3500
|
+
.with_pane_current_command("%1", "bash");
|
|
3501
|
+
let mut ops = RecordingRuntimeOps::new(&transport);
|
|
3502
|
+
assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
|
|
3503
|
+
assert_eq!(ops.attach_calls, 0);
|
|
3504
|
+
assert_eq!(
|
|
3505
|
+
refusal_reason(&workspace),
|
|
3506
|
+
Some((
|
|
3507
|
+
"strict_provider".to_string(),
|
|
3508
|
+
"provider_unresolved".to_string()
|
|
3509
|
+
))
|
|
3510
|
+
);
|
|
3511
|
+
}
|
|
1752
3512
|
}
|