@team-agent/installer 0.5.73 → 0.5.75
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +19 -13
- package/crates/team-agent/src/cli/emit.rs +6 -3
- package/crates/team-agent/src/cli/mod.rs +151 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +28 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +3 -0
- package/crates/team-agent/src/cli/types.rs +6 -0
- package/crates/team-agent/src/compiler.rs +14 -1
- package/crates/team-agent/src/layout/worker_env.rs +366 -0
- package/crates/team-agent/src/leader/lease.rs +334 -53
- package/crates/team-agent/src/leader/registry.rs +177 -108
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +12 -1
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +2272 -128
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +16 -6
- 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/launch/state_projection.rs +5 -3
- package/crates/team-agent/src/lifecycle/restart/common.rs +3 -11
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +60 -0
- package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +31 -0
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +25 -23
- package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +110 -31
- package/crates/team-agent/src/messaging/delivery.rs +226 -37
- package/crates/team-agent/src/messaging/leader_channel.rs +12 -3
- package/crates/team-agent/src/messaging/tests/runtime.rs +234 -0
- package/crates/team-agent/src/model/spec.rs +81 -7
- package/crates/team-agent/src/provider/adapters/pi.rs +14 -0
- package/crates/team-agent/src/state/persist.rs +433 -4
- package/crates/team-agent/src/state/repository.rs +45 -2
- package/crates/team-agent/src/tmux_backend/tests.rs +414 -7
- package/crates/team-agent/src/tmux_backend.rs +274 -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
- package/skills/team-agent/SKILL.md +3 -0
- package/skills/team-agent/command-coverage.json +21 -0
- package/skills/team-agent/references/team-agent-operator.md +9 -2
|
@@ -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,10 +42,12 @@ 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>;
|
|
44
|
-
fn live_binding_elsewhere(&mut self, pane: &PaneId, workspace: &Path, team_key: &str) -> bool;
|
|
45
51
|
fn attach(
|
|
46
52
|
&mut self,
|
|
47
53
|
workspace: &Path,
|
|
@@ -49,12 +55,36 @@ trait FreshQuickStartLeaderBindingOps {
|
|
|
49
55
|
pane: &PaneId,
|
|
50
56
|
provider: crate::provider::Provider,
|
|
51
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
|
+
}
|
|
52
70
|
fn register(&mut self, workspace: &Path, team_key: &str) -> bool;
|
|
71
|
+
fn register_failure_reason(&self) -> Option<&'static str> {
|
|
72
|
+
None
|
|
73
|
+
}
|
|
53
74
|
fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool;
|
|
75
|
+
fn readback_failure_reason(&self) -> Option<&'static str> {
|
|
76
|
+
None
|
|
77
|
+
}
|
|
54
78
|
}
|
|
55
79
|
|
|
56
80
|
struct RuntimeFreshQuickStartLeaderBindingOps<'a> {
|
|
57
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>>,
|
|
58
88
|
}
|
|
59
89
|
|
|
60
90
|
impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<'_> {
|
|
@@ -64,6 +94,18 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
64
94
|
.filter(|pane| !pane.is_empty())
|
|
65
95
|
}
|
|
66
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
|
+
|
|
67
109
|
fn explicit_provider(&mut self) -> Option<String> {
|
|
68
110
|
std::env::var("TEAM_AGENT_LEADER_PROVIDER")
|
|
69
111
|
.ok()
|
|
@@ -82,14 +124,6 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
82
124
|
.filter(|command| !command.trim().is_empty())
|
|
83
125
|
}
|
|
84
126
|
|
|
85
|
-
fn live_binding_elsewhere(&mut self, pane: &PaneId, workspace: &Path, team_key: &str) -> bool {
|
|
86
|
-
crate::leader::registry::live_same_pane_binding_elsewhere(
|
|
87
|
-
pane.as_str(),
|
|
88
|
-
workspace,
|
|
89
|
-
team_key,
|
|
90
|
-
)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
127
|
fn attach(
|
|
94
128
|
&mut self,
|
|
95
129
|
workspace: &Path,
|
|
@@ -97,8 +131,36 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
97
131
|
pane: &PaneId,
|
|
98
132
|
provider: crate::provider::Provider,
|
|
99
133
|
) -> bool {
|
|
134
|
+
self.cas_conflict = false;
|
|
135
|
+
self.applied_grant = None;
|
|
136
|
+
self.registry_receipt = None;
|
|
100
137
|
let event_log = crate::event_log::EventLog::new(workspace);
|
|
101
|
-
|
|
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(
|
|
102
164
|
workspace,
|
|
103
165
|
state,
|
|
104
166
|
Some(pane),
|
|
@@ -106,24 +168,159 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
106
168
|
&event_log,
|
|
107
169
|
crate::leader::LeaseSource::QuickStart,
|
|
108
170
|
true,
|
|
109
|
-
|
|
110
|
-
|
|
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()
|
|
111
225
|
}
|
|
112
226
|
|
|
113
227
|
fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
114
|
-
crate::leader::registry::register_binding_from_state_best_effort(
|
|
228
|
+
match crate::leader::registry::register_binding_from_state_best_effort(
|
|
115
229
|
workspace,
|
|
116
230
|
Some(team_key),
|
|
117
231
|
"quick-start",
|
|
118
|
-
)
|
|
119
|
-
|
|
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
|
|
120
255
|
}
|
|
121
256
|
|
|
122
257
|
fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
123
|
-
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
|
|
124
290
|
}
|
|
125
291
|
}
|
|
126
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
|
+
|
|
127
324
|
struct BindingFileSnapshot {
|
|
128
325
|
path: PathBuf,
|
|
129
326
|
bytes: Option<Vec<u8>>,
|
|
@@ -153,6 +350,7 @@ impl BindingFileSnapshot {
|
|
|
153
350
|
}
|
|
154
351
|
}
|
|
155
352
|
}
|
|
353
|
+
|
|
156
354
|
}
|
|
157
355
|
|
|
158
356
|
fn binding_snapshots(workspace: &Path, team_key: &str) -> Vec<BindingFileSnapshot> {
|
|
@@ -242,45 +440,82 @@ fn persisted_binding_matches_verified_pane(
|
|
|
242
440
|
fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
|
|
243
441
|
workspace: &Path,
|
|
244
442
|
team_key: &str,
|
|
443
|
+
seeded_owner: Option<&serde_json::Value>,
|
|
245
444
|
ops: &mut O,
|
|
246
|
-
) -> bool {
|
|
445
|
+
) -> Result<bool, LifecycleError> {
|
|
247
446
|
let Ok(resolved) = crate::state::projection::resolve_runtime_team_scope(
|
|
248
447
|
workspace,
|
|
249
448
|
Some(team_key),
|
|
250
449
|
) else {
|
|
251
|
-
return
|
|
450
|
+
return refuse_fresh_bind(workspace, team_key, "scope", "scope_unresolved");
|
|
252
451
|
};
|
|
253
452
|
if resolved.canonical_team_key != team_key {
|
|
254
|
-
return
|
|
453
|
+
return refuse_fresh_bind(workspace, team_key, "scope", "scope_key_mismatch");
|
|
255
454
|
}
|
|
256
455
|
let mut state = resolved.state;
|
|
257
456
|
let Some(pane) = ops.caller_pane().filter(|pane| !pane.is_empty()) else {
|
|
258
|
-
return
|
|
457
|
+
return refuse_fresh_bind(workspace, team_key, "caller_pane", "caller_pane_missing");
|
|
259
458
|
};
|
|
260
459
|
let pane = PaneId::new(pane);
|
|
261
460
|
let Some(command) = ops
|
|
262
461
|
.observe_command(&pane)
|
|
263
462
|
.filter(|command| !command.trim().is_empty())
|
|
264
463
|
else {
|
|
265
|
-
return
|
|
464
|
+
return refuse_fresh_bind(
|
|
465
|
+
workspace,
|
|
466
|
+
team_key,
|
|
467
|
+
"command_observation",
|
|
468
|
+
"command_unobservable",
|
|
469
|
+
);
|
|
266
470
|
};
|
|
267
|
-
let
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
+
}
|
|
273
497
|
};
|
|
274
|
-
if ops.live_binding_elsewhere(&pane, workspace, team_key) {
|
|
275
|
-
return false;
|
|
276
|
-
}
|
|
277
498
|
let endpoint = ops.tmux_endpoint();
|
|
278
|
-
// initial_runtime_state seeds the verified caller into the launched team's
|
|
279
|
-
// dual state before spawn. That is the transaction's candidate, not an
|
|
280
|
-
// already-owned-team refusal: it still needs the canonical registry commit.
|
|
281
499
|
let has_persisted_binding = ["team_owner", "leader_receiver"]
|
|
282
500
|
.iter()
|
|
283
501
|
.any(|key| state.get(*key).is_some_and(|value| !value.is_null()));
|
|
502
|
+
// `claimed_via` is historical state, not proof that this invocation seeded
|
|
503
|
+
// the owner. Cleanup is allowed only when the caller supplies the exact
|
|
504
|
+
// in-memory seed created immediately before this bind attempt and the
|
|
505
|
+
// projected state still contains that seed.
|
|
506
|
+
let fresh_seeded_binding = seeded_owner.is_some_and(|seed| {
|
|
507
|
+
state
|
|
508
|
+
.get("team_owner")
|
|
509
|
+
.is_some_and(|current| current == seed)
|
|
510
|
+
});
|
|
511
|
+
let seeded_receiver = if fresh_seeded_binding {
|
|
512
|
+
state.get("leader_receiver").cloned()
|
|
513
|
+
} else {
|
|
514
|
+
None
|
|
515
|
+
};
|
|
516
|
+
// Same-Team owner collision remains fail-closed. Cross-Team registry rows
|
|
517
|
+
// are intentionally irrelevant: pane ids are scoped to their tmux server,
|
|
518
|
+
// and independent endpoints may reuse the same numeric id.
|
|
284
519
|
if has_persisted_binding
|
|
285
520
|
&& !persisted_binding_matches_verified_pane(
|
|
286
521
|
&state,
|
|
@@ -290,21 +525,123 @@ fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
|
|
|
290
525
|
endpoint.as_deref(),
|
|
291
526
|
)
|
|
292
527
|
{
|
|
293
|
-
return
|
|
528
|
+
return refuse_fresh_bind(
|
|
529
|
+
workspace,
|
|
530
|
+
team_key,
|
|
531
|
+
"persisted_binding",
|
|
532
|
+
"persisted_binding_mismatch",
|
|
533
|
+
);
|
|
294
534
|
}
|
|
295
|
-
// Registry publication is the commit point. Restore
|
|
296
|
-
//
|
|
535
|
+
// Registry publication is the commit point. Restore persisted surfaces on
|
|
536
|
+
// failure, except for the caller-seeded owner that must be cleared.
|
|
297
537
|
let snapshots = binding_snapshots(workspace, team_key);
|
|
298
|
-
let attached =
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
+
};
|
|
302
583
|
if !committed {
|
|
303
|
-
|
|
304
|
-
|
|
584
|
+
if ops.attach_cas_conflict() {
|
|
585
|
+
return Ok(false);
|
|
305
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
|
|
604
|
+
.get("team_owner")
|
|
605
|
+
.is_some_and(|current| current == *seed)
|
|
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);
|
|
611
|
+
}
|
|
612
|
+
} else {
|
|
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() {
|
|
620
|
+
snapshot.restore();
|
|
621
|
+
}
|
|
622
|
+
if let Some(error) = registry_rollback_error {
|
|
623
|
+
return Err(error);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
Ok(committed)
|
|
628
|
+
}
|
|
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
|
+
))),
|
|
306
644
|
}
|
|
307
|
-
committed
|
|
308
645
|
}
|
|
309
646
|
|
|
310
647
|
fn should_emit_workspace_socket_missing_hint(
|
|
@@ -314,15 +651,64 @@ fn should_emit_workspace_socket_missing_hint(
|
|
|
314
651
|
workspace_socket_missing && selected_source != Some("leader_env")
|
|
315
652
|
}
|
|
316
653
|
|
|
654
|
+
fn clear_fresh_binding_on_refusal(
|
|
655
|
+
workspace: &Path,
|
|
656
|
+
state: &mut serde_json::Value,
|
|
657
|
+
team_key: &str,
|
|
658
|
+
seed: &serde_json::Value,
|
|
659
|
+
expected_grant: Option<(&serde_json::Value, &serde_json::Value)>,
|
|
660
|
+
) -> Result<(), LifecycleError> {
|
|
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.
|
|
664
|
+
if let Some(obj) = state.as_object_mut() {
|
|
665
|
+
obj.remove("leader_receiver");
|
|
666
|
+
obj.remove("team_owner");
|
|
667
|
+
obj.remove("owner_epoch");
|
|
668
|
+
}
|
|
669
|
+
if let Some(team) = state
|
|
670
|
+
.get_mut("teams")
|
|
671
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
672
|
+
.and_then(|teams| teams.get_mut(team_key))
|
|
673
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
674
|
+
{
|
|
675
|
+
team.insert("leader_receiver".to_string(), serde_json::Value::Null);
|
|
676
|
+
team.insert("team_owner".to_string(), serde_json::Value::Null);
|
|
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
|
+
};
|
|
687
|
+
crate::state::repository::StateRepository::new(workspace)
|
|
688
|
+
.save(intent, state)
|
|
689
|
+
.map_err(|error| LifecycleError::StatePersist(format!(
|
|
690
|
+
"quick-start binding cleanup failed: {error}"
|
|
691
|
+
)))
|
|
692
|
+
}
|
|
693
|
+
|
|
317
694
|
fn bind_fresh_quick_start_leader(
|
|
318
695
|
workspace: &Path,
|
|
319
696
|
team_key: &str,
|
|
697
|
+
seeded_owner: Option<&serde_json::Value>,
|
|
320
698
|
transport: &dyn Transport,
|
|
321
|
-
) -> bool {
|
|
699
|
+
) -> Result<bool, LifecycleError> {
|
|
322
700
|
bind_fresh_quick_start_leader_with(
|
|
323
701
|
workspace,
|
|
324
702
|
team_key,
|
|
325
|
-
|
|
703
|
+
seeded_owner,
|
|
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
|
+
},
|
|
326
712
|
)
|
|
327
713
|
}
|
|
328
714
|
|
|
@@ -735,13 +1121,29 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
|
|
|
735
1121
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
736
1122
|
let resolved_spec_path =
|
|
737
1123
|
std::fs::canonicalize(&spec_path).unwrap_or_else(|_| spec_path.clone());
|
|
1124
|
+
let scoped_endpoint = transport.tmux_endpoint();
|
|
738
1125
|
let mut state = initial_runtime_state(
|
|
739
1126
|
&spec,
|
|
740
1127
|
&resolved_spec_path,
|
|
741
1128
|
&workspace,
|
|
742
1129
|
agents_dir,
|
|
743
1130
|
&state_team_key,
|
|
1131
|
+
scoped_endpoint.as_deref(),
|
|
744
1132
|
);
|
|
1133
|
+
// Keep this attempt-local seed separate from persisted `claimed_via`;
|
|
1134
|
+
// historical quick-start rows are never sufficient cleanup authority.
|
|
1135
|
+
let seeded_owner = state
|
|
1136
|
+
.get("teams")
|
|
1137
|
+
.and_then(serde_json::Value::as_object)
|
|
1138
|
+
.and_then(|teams| teams.get(&state_team_key))
|
|
1139
|
+
.and_then(|team| team.get("team_owner"))
|
|
1140
|
+
.filter(|owner| {
|
|
1141
|
+
owner
|
|
1142
|
+
.get("claimed_via")
|
|
1143
|
+
.and_then(serde_json::Value::as_str)
|
|
1144
|
+
== Some("quick-start")
|
|
1145
|
+
})
|
|
1146
|
+
.cloned();
|
|
745
1147
|
// 0.5.x Phase 1d hot-path 接线(裁决1 msg_76e1d98202b8): use the
|
|
746
1148
|
// generic annotator that writes `state.transport = { kind, source }`
|
|
747
1149
|
// for every backend AND (for tmux) preserves the existing
|
|
@@ -778,8 +1180,12 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
|
|
|
778
1180
|
// Fresh initialization owns this one fail-closed bind attempt. It is
|
|
779
1181
|
// independent of display layout, so --no-display never suppresses receiver
|
|
780
1182
|
// binding. Readiness receives true only after canonical registry readback.
|
|
781
|
-
launch.leader_receiver_attached =
|
|
782
|
-
|
|
1183
|
+
launch.leader_receiver_attached = bind_fresh_quick_start_leader(
|
|
1184
|
+
&workspace,
|
|
1185
|
+
&state_team_key,
|
|
1186
|
+
seeded_owner.as_ref(),
|
|
1187
|
+
transport,
|
|
1188
|
+
)?;
|
|
783
1189
|
launch.session_capture_incomplete_agents =
|
|
784
1190
|
quick_start_session_capture_incomplete_agents(&workspace, &state_team_key);
|
|
785
1191
|
let coordinator_workspace = crate::coordinator::WorkspacePath::new(workspace.clone());
|
|
@@ -858,9 +1264,15 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
|
|
|
858
1264
|
#[cfg(test)]
|
|
859
1265
|
mod fresh_quick_start_leader_binding_tests {
|
|
860
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};
|
|
861
1270
|
use serde_json::json;
|
|
1271
|
+
use std::collections::BTreeMap;
|
|
862
1272
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
863
1273
|
|
|
1274
|
+
use super::hermetic::HermeticTestEnv;
|
|
1275
|
+
|
|
864
1276
|
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
|
865
1277
|
|
|
866
1278
|
fn workspace(tag: &str) -> PathBuf {
|
|
@@ -888,7 +1300,6 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
888
1300
|
explicit_provider: Option<String>,
|
|
889
1301
|
endpoint: Option<String>,
|
|
890
1302
|
command: Option<String>,
|
|
891
|
-
live_elsewhere: bool,
|
|
892
1303
|
attach_ok: bool,
|
|
893
1304
|
register_ok: bool,
|
|
894
1305
|
readback_ok: bool,
|
|
@@ -896,6 +1307,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
896
1307
|
register_calls: usize,
|
|
897
1308
|
readback_calls: usize,
|
|
898
1309
|
attached_provider: Option<crate::provider::Provider>,
|
|
1310
|
+
applied_grant: Option<(serde_json::Value, serde_json::Value)>,
|
|
899
1311
|
}
|
|
900
1312
|
|
|
901
1313
|
impl Default for MockOps {
|
|
@@ -905,7 +1317,6 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
905
1317
|
explicit_provider: Some("pi".to_string()),
|
|
906
1318
|
endpoint: Some("/private/tmp/tmux-test/default".to_string()),
|
|
907
1319
|
command: Some("pi".to_string()),
|
|
908
|
-
live_elsewhere: false,
|
|
909
1320
|
attach_ok: true,
|
|
910
1321
|
register_ok: true,
|
|
911
1322
|
readback_ok: true,
|
|
@@ -913,10 +1324,58 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
913
1324
|
register_calls: 0,
|
|
914
1325
|
readback_calls: 0,
|
|
915
1326
|
attached_provider: None,
|
|
1327
|
+
applied_grant: None,
|
|
916
1328
|
}
|
|
917
1329
|
}
|
|
918
1330
|
}
|
|
919
1331
|
|
|
1332
|
+
#[test]
|
|
1333
|
+
fn invalid_pi_tool_category_is_rejected_before_runtime_persistence() {
|
|
1334
|
+
let root = std::env::temp_dir().join(format!(
|
|
1335
|
+
"ta-quick-start-pi-tools-{}-{}",
|
|
1336
|
+
std::process::id(),
|
|
1337
|
+
SEQUENCE.fetch_add(1, Ordering::Relaxed)
|
|
1338
|
+
));
|
|
1339
|
+
let team = root.join(".team/current");
|
|
1340
|
+
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
1341
|
+
std::fs::write(
|
|
1342
|
+
team.join("TEAM.md"),
|
|
1343
|
+
"---\nname: pi-tools\nprovider: pi\n---\n\nPi team.\n",
|
|
1344
|
+
)
|
|
1345
|
+
.unwrap();
|
|
1346
|
+
std::fs::write(
|
|
1347
|
+
team.join("agents/worker.md"),
|
|
1348
|
+
"---\nname: worker\nrole: Worker\nprovider: pi\nauth_mode: subscription\ndangerously_skip_permissions: false\ntools:\n - mcp_team\n - fs_read\n - provider_builtin\n---\n\nWorker.\n",
|
|
1349
|
+
)
|
|
1350
|
+
.unwrap();
|
|
1351
|
+
|
|
1352
|
+
let transport = crate::transport::test_support::OfflineTransport::new();
|
|
1353
|
+
let mut discover = |_requested: &str| -> Result<Vec<String>, ()> { Err(()) };
|
|
1354
|
+
let error = quick_start_with_transport_in_workspace_with_display_pi_preflight(
|
|
1355
|
+
&root,
|
|
1356
|
+
&team,
|
|
1357
|
+
None,
|
|
1358
|
+
true,
|
|
1359
|
+
None,
|
|
1360
|
+
&transport,
|
|
1361
|
+
false,
|
|
1362
|
+
&mut discover,
|
|
1363
|
+
)
|
|
1364
|
+
.expect_err("invalid Pi category must fail before quick-start persistence");
|
|
1365
|
+
let error = error.to_string();
|
|
1366
|
+
assert!(error.contains("Pi does not support Team Agent tool category \"provider_builtin\""));
|
|
1367
|
+
assert!(error.contains("remove it from the role's tools"));
|
|
1368
|
+
assert!(
|
|
1369
|
+
!crate::state::persist::runtime_state_path(&root).exists(),
|
|
1370
|
+
"quick-start validation failure must not persist runtime state"
|
|
1371
|
+
);
|
|
1372
|
+
assert!(
|
|
1373
|
+
!root.join(".team/runtime").exists(),
|
|
1374
|
+
"quick-start validation failure must not create runtime artifacts"
|
|
1375
|
+
);
|
|
1376
|
+
let _ = std::fs::remove_dir_all(root);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
920
1379
|
impl FreshQuickStartLeaderBindingOps for MockOps {
|
|
921
1380
|
fn caller_pane(&mut self) -> Option<String> {
|
|
922
1381
|
self.pane.clone()
|
|
@@ -934,15 +1393,6 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
934
1393
|
self.command.clone()
|
|
935
1394
|
}
|
|
936
1395
|
|
|
937
|
-
fn live_binding_elsewhere(
|
|
938
|
-
&mut self,
|
|
939
|
-
_pane: &PaneId,
|
|
940
|
-
_workspace: &Path,
|
|
941
|
-
_team_key: &str,
|
|
942
|
-
) -> bool {
|
|
943
|
-
self.live_elsewhere
|
|
944
|
-
}
|
|
945
|
-
|
|
946
1396
|
fn attach(
|
|
947
1397
|
&mut self,
|
|
948
1398
|
workspace: &Path,
|
|
@@ -950,6 +1400,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
950
1400
|
pane: &PaneId,
|
|
951
1401
|
provider: crate::provider::Provider,
|
|
952
1402
|
) -> bool {
|
|
1403
|
+
self.applied_grant = None;
|
|
953
1404
|
self.attach_calls += 1;
|
|
954
1405
|
self.attached_provider = Some(provider);
|
|
955
1406
|
if !self.attach_ok {
|
|
@@ -957,30 +1408,79 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
957
1408
|
let _ = crate::state::persist::save_runtime_state(workspace, state);
|
|
958
1409
|
return false;
|
|
959
1410
|
}
|
|
960
|
-
|
|
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!({
|
|
961
1431
|
"mode": "direct_tmux",
|
|
962
1432
|
"pane_id": pane.as_str(),
|
|
963
1433
|
"status": "attached",
|
|
964
1434
|
"provider": "pi",
|
|
965
1435
|
"leader_session_uuid": "uuid-fresh",
|
|
966
|
-
"owner_epoch":
|
|
1436
|
+
"owner_epoch": owner_epoch,
|
|
967
1437
|
"tmux_socket": self.endpoint
|
|
968
1438
|
});
|
|
969
|
-
state["
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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
|
+
}
|
|
976
1468
|
}
|
|
977
1469
|
|
|
978
|
-
fn
|
|
1470
|
+
fn applied_grant(&self) -> Option<(serde_json::Value, serde_json::Value)> {
|
|
1471
|
+
self.applied_grant.clone()
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
979
1475
|
self.register_calls += 1;
|
|
980
1476
|
let mut persisted = crate::state::persist::load_runtime_state(workspace).unwrap();
|
|
981
1477
|
assert_eq!(
|
|
982
1478
|
persisted
|
|
983
|
-
.
|
|
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"))
|
|
984
1484
|
.and_then(serde_json::Value::as_str),
|
|
985
1485
|
Some("%42")
|
|
986
1486
|
);
|
|
@@ -991,7 +1491,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
991
1491
|
self.register_ok
|
|
992
1492
|
}
|
|
993
1493
|
|
|
994
|
-
fn canonical_readback(&mut self, workspace: &Path,
|
|
1494
|
+
fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
995
1495
|
self.readback_calls += 1;
|
|
996
1496
|
let mut persisted = crate::state::persist::load_runtime_state(workspace).unwrap();
|
|
997
1497
|
if !self.readback_ok {
|
|
@@ -1000,7 +1500,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1000
1500
|
}
|
|
1001
1501
|
self.readback_ok
|
|
1002
1502
|
&& persisted
|
|
1003
|
-
.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"))
|
|
1004
1507
|
.is_some_and(|receiver| !receiver.is_null())
|
|
1005
1508
|
}
|
|
1006
1509
|
}
|
|
@@ -1009,11 +1512,8 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1009
1512
|
fn fresh_binding_persists_then_registers_then_requires_canonical_readback() {
|
|
1010
1513
|
let workspace = workspace("positive");
|
|
1011
1514
|
let mut ops = MockOps::default();
|
|
1012
|
-
assert!(bind_fresh_quick_start_leader_with(
|
|
1013
|
-
|
|
1014
|
-
"fresh",
|
|
1015
|
-
&mut ops
|
|
1016
|
-
));
|
|
1515
|
+
assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
|
|
1516
|
+
.unwrap());
|
|
1017
1517
|
assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Pi));
|
|
1018
1518
|
assert_eq!(ops.attach_calls, 1);
|
|
1019
1519
|
assert_eq!(ops.register_calls, 1);
|
|
@@ -1021,10 +1521,110 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1021
1521
|
let state = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
1022
1522
|
assert_eq!(
|
|
1023
1523
|
state
|
|
1024
|
-
.
|
|
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"))
|
|
1025
1529
|
.and_then(serde_json::Value::as_str),
|
|
1026
1530
|
Some("%42")
|
|
1027
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"));
|
|
1028
1628
|
}
|
|
1029
1629
|
|
|
1030
1630
|
#[test]
|
|
@@ -1042,7 +1642,6 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1042
1642
|
"socket_mismatch",
|
|
1043
1643
|
"workspace_mismatch",
|
|
1044
1644
|
"dual_state_mismatch",
|
|
1045
|
-
"live_other_scope",
|
|
1046
1645
|
] {
|
|
1047
1646
|
let workspace = workspace(case);
|
|
1048
1647
|
let mut state = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
@@ -1088,7 +1687,6 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1088
1687
|
_ => unreachable!(),
|
|
1089
1688
|
}
|
|
1090
1689
|
}
|
|
1091
|
-
"live_other_scope" => ops.live_elsewhere = true,
|
|
1092
1690
|
"team_mismatch" => {}
|
|
1093
1691
|
_ => unreachable!(),
|
|
1094
1692
|
}
|
|
@@ -1096,7 +1694,8 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1096
1694
|
let before = std::fs::read(crate::state::persist::runtime_state_path(&workspace))
|
|
1097
1695
|
.unwrap();
|
|
1098
1696
|
assert!(
|
|
1099
|
-
!bind_fresh_quick_start_leader_with(&workspace, team_key, &mut ops)
|
|
1697
|
+
!bind_fresh_quick_start_leader_with(&workspace, team_key, None, &mut ops)
|
|
1698
|
+
.unwrap(),
|
|
1100
1699
|
"{case} must refuse"
|
|
1101
1700
|
);
|
|
1102
1701
|
assert_eq!(
|
|
@@ -1179,6 +1778,7 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1179
1778
|
struct AmbientRegistryOps {
|
|
1180
1779
|
readback_ok: bool,
|
|
1181
1780
|
attach_calls: usize,
|
|
1781
|
+
registry_receipt: Option<crate::leader::registry::RegistryWriteReceipt>,
|
|
1182
1782
|
}
|
|
1183
1783
|
|
|
1184
1784
|
impl FreshQuickStartLeaderBindingOps for AmbientRegistryOps {
|
|
@@ -1198,15 +1798,6 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1198
1798
|
Some("pi".to_string())
|
|
1199
1799
|
}
|
|
1200
1800
|
|
|
1201
|
-
fn live_binding_elsewhere(
|
|
1202
|
-
&mut self,
|
|
1203
|
-
_pane: &PaneId,
|
|
1204
|
-
_workspace: &Path,
|
|
1205
|
-
_team_key: &str,
|
|
1206
|
-
) -> bool {
|
|
1207
|
-
false
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
1801
|
fn attach(
|
|
1211
1802
|
&mut self,
|
|
1212
1803
|
_workspace: &Path,
|
|
@@ -1219,12 +1810,19 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1219
1810
|
}
|
|
1220
1811
|
|
|
1221
1812
|
fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
1222
|
-
crate::leader::registry::register_binding_from_state_best_effort(
|
|
1813
|
+
let Some(outcome) = crate::leader::registry::register_binding_from_state_best_effort(
|
|
1223
1814
|
workspace,
|
|
1224
1815
|
Some(team_key),
|
|
1225
1816
|
"quick-start",
|
|
1226
|
-
)
|
|
1227
|
-
|
|
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()
|
|
1228
1826
|
}
|
|
1229
1827
|
|
|
1230
1828
|
fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
@@ -1245,12 +1843,10 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1245
1843
|
let mut ops = AmbientRegistryOps {
|
|
1246
1844
|
readback_ok: true,
|
|
1247
1845
|
attach_calls: 0,
|
|
1846
|
+
registry_receipt: None,
|
|
1248
1847
|
};
|
|
1249
|
-
assert!(bind_fresh_quick_start_leader_with(
|
|
1250
|
-
|
|
1251
|
-
"fresh",
|
|
1252
|
-
&mut ops
|
|
1253
|
-
));
|
|
1848
|
+
assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
|
|
1849
|
+
.unwrap());
|
|
1254
1850
|
assert_eq!(ops.attach_calls, 0, "preseeded dual state must not reattach");
|
|
1255
1851
|
let path = crate::leader::registry::registry_dir().unwrap().join(format!(
|
|
1256
1852
|
"{}__fresh.json",
|
|
@@ -1265,38 +1861,359 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1265
1861
|
}
|
|
1266
1862
|
|
|
1267
1863
|
#[test]
|
|
1268
|
-
fn
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1864
|
+
fn historical_quick_start_owner_is_not_cleanup_authority() {
|
|
1865
|
+
let workspace = workspace("historical-quick-start-owner");
|
|
1866
|
+
let mut state = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
1867
|
+
state["workspace"] = json!(workspace);
|
|
1868
|
+
state["team_owner"] = json!({
|
|
1869
|
+
"pane_id": "%old",
|
|
1870
|
+
"provider": "pi",
|
|
1871
|
+
"leader_session_uuid": "uuid-old",
|
|
1872
|
+
"owner_epoch": 4,
|
|
1873
|
+
"claimed_via": "quick-start"
|
|
1874
|
+
});
|
|
1875
|
+
state["leader_receiver"] = json!({
|
|
1876
|
+
"mode": "direct_tmux",
|
|
1877
|
+
"status": "attached",
|
|
1878
|
+
"pane_id": "%old",
|
|
1879
|
+
"provider": "pi",
|
|
1880
|
+
"leader_session_uuid": "uuid-old",
|
|
1881
|
+
"owner_epoch": 4,
|
|
1882
|
+
"tmux_socket": "/private/tmp/tmux-test/old"
|
|
1883
|
+
});
|
|
1884
|
+
crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
|
|
1885
|
+
let before = std::fs::read(crate::state::persist::runtime_state_path(&workspace)).unwrap();
|
|
1886
|
+
let mut ops = MockOps::default();
|
|
1887
|
+
|
|
1888
|
+
assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
|
|
1889
|
+
.unwrap());
|
|
1890
|
+
assert_eq!(
|
|
1891
|
+
before,
|
|
1892
|
+
std::fs::read(crate::state::persist::runtime_state_path(&workspace)).unwrap(),
|
|
1893
|
+
"historical claimed_via alone must not authorize cleanup"
|
|
1894
|
+
);
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
fn matching_seed() -> serde_json::Value {
|
|
1898
|
+
json!({
|
|
1899
|
+
"pane_id": "%42",
|
|
1900
|
+
"provider": "pi",
|
|
1901
|
+
"leader_session_uuid": "uuid-fresh",
|
|
1902
|
+
"owner_epoch": 1,
|
|
1903
|
+
"claimed_via": "quick-start"
|
|
1904
|
+
})
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
fn matching_receiver() -> serde_json::Value {
|
|
1908
|
+
json!({
|
|
1909
|
+
"mode": "direct_tmux",
|
|
1910
|
+
"status": "attached",
|
|
1911
|
+
"pane_id": "%42",
|
|
1912
|
+
"provider": "pi",
|
|
1913
|
+
"leader_session_uuid": "uuid-fresh",
|
|
1914
|
+
"owner_epoch": 1,
|
|
1915
|
+
"tmux_socket": "/private/tmp/tmux-test/default"
|
|
1916
|
+
})
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
fn workspace_with_matching_seed(tag: &str) -> (PathBuf, serde_json::Value) {
|
|
1920
|
+
let workspace = workspace(tag);
|
|
1921
|
+
let seed = matching_seed();
|
|
1922
|
+
crate::state::persist::save_runtime_state(
|
|
1923
|
+
&workspace,
|
|
1924
|
+
&json!({
|
|
1925
|
+
"workspace": workspace,
|
|
1926
|
+
"active_team_key": "fresh",
|
|
1927
|
+
"team_key": "fresh",
|
|
1928
|
+
"session_name": "team-fresh",
|
|
1929
|
+
"agents": {"sol": {"status": "running", "provider": "pi"}},
|
|
1930
|
+
"teams": {
|
|
1931
|
+
"fresh": {
|
|
1932
|
+
"workspace": workspace,
|
|
1933
|
+
"team_key": "fresh",
|
|
1934
|
+
"session_name": "team-fresh",
|
|
1935
|
+
"agents": {"sol": {"status": "running", "provider": "pi"}},
|
|
1936
|
+
"team_owner": seed,
|
|
1937
|
+
"leader_receiver": matching_receiver(),
|
|
1938
|
+
"owner_epoch": 1
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
}),
|
|
1942
|
+
)
|
|
1943
|
+
.unwrap();
|
|
1944
|
+
let seed = crate::state::persist::load_runtime_state(&workspace).unwrap()["teams"]["fresh"]
|
|
1945
|
+
["team_owner"]
|
|
1946
|
+
.clone();
|
|
1947
|
+
(workspace, seed)
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
struct ConcurrentTakeover {
|
|
1951
|
+
owner: serde_json::Value,
|
|
1952
|
+
receiver: serde_json::Value,
|
|
1953
|
+
epoch: u64,
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
fn takeover_binding(epoch: u64) -> ConcurrentTakeover {
|
|
1957
|
+
ConcurrentTakeover {
|
|
1958
|
+
owner: json!({
|
|
1959
|
+
"pane_id": "%99",
|
|
1960
|
+
"provider": "codex",
|
|
1961
|
+
"leader_session_uuid": "uuid-other",
|
|
1962
|
+
"owner_epoch": epoch,
|
|
1963
|
+
"claimed_via": "claim-leader"
|
|
1964
|
+
}),
|
|
1965
|
+
receiver: json!({
|
|
1966
|
+
"mode": "direct_tmux",
|
|
1967
|
+
"status": "attached",
|
|
1968
|
+
"pane_id": "%99",
|
|
1969
|
+
"provider": "codex",
|
|
1970
|
+
"leader_session_uuid": "uuid-other",
|
|
1971
|
+
"owner_epoch": epoch,
|
|
1972
|
+
"tmux_socket": "/private/tmp/tmux-test/other"
|
|
1973
|
+
}),
|
|
1974
|
+
epoch,
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
struct SeedCleanupOps {
|
|
1979
|
+
inner: MockOps,
|
|
1980
|
+
takeover: Option<ConcurrentTakeover>,
|
|
1981
|
+
poison_state_file: bool,
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
impl FreshQuickStartLeaderBindingOps for SeedCleanupOps {
|
|
1985
|
+
fn caller_pane(&mut self) -> Option<String> {
|
|
1986
|
+
self.inner.caller_pane()
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
fn explicit_provider(&mut self) -> Option<String> {
|
|
1990
|
+
self.inner.explicit_provider()
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
fn tmux_endpoint(&mut self) -> Option<String> {
|
|
1994
|
+
self.inner.tmux_endpoint()
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
fn observe_command(&mut self, pane: &PaneId) -> Option<String> {
|
|
1998
|
+
self.inner.observe_command(pane)
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
fn attach(
|
|
2002
|
+
&mut self,
|
|
2003
|
+
workspace: &Path,
|
|
2004
|
+
state: &mut serde_json::Value,
|
|
2005
|
+
pane: &PaneId,
|
|
2006
|
+
provider: crate::provider::Provider,
|
|
2007
|
+
) -> bool {
|
|
2008
|
+
self.inner.attach(workspace, state, pane, provider)
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
fn applied_grant(&self) -> Option<(serde_json::Value, serde_json::Value)> {
|
|
2012
|
+
self.inner.applied_grant()
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
2016
|
+
self.inner.register_calls += 1;
|
|
2017
|
+
if let Some(takeover) = &self.takeover {
|
|
2018
|
+
let mut persisted = crate::state::persist::load_runtime_state(workspace).unwrap();
|
|
2019
|
+
persisted["teams"][team_key]["team_owner"] = takeover.owner.clone();
|
|
2020
|
+
persisted["teams"][team_key]["leader_receiver"] = takeover.receiver.clone();
|
|
2021
|
+
persisted["teams"][team_key]["owner_epoch"] = json!(takeover.epoch);
|
|
2022
|
+
crate::state::persist::save_runtime_state_with_receiver_authority(
|
|
2023
|
+
workspace,
|
|
2024
|
+
&persisted,
|
|
2025
|
+
team_key,
|
|
2026
|
+
None,
|
|
2027
|
+
)
|
|
2028
|
+
.unwrap();
|
|
2029
|
+
assert_fresh_binding_kept(
|
|
2030
|
+
&crate::state::persist::load_runtime_state(workspace).unwrap(),
|
|
2031
|
+
takeover,
|
|
2032
|
+
);
|
|
2033
|
+
}
|
|
2034
|
+
if self.poison_state_file {
|
|
2035
|
+
let path = crate::state::persist::runtime_state_path(workspace);
|
|
2036
|
+
let _ = std::fs::remove_file(&path);
|
|
2037
|
+
std::fs::create_dir_all(&path).unwrap();
|
|
2038
|
+
}
|
|
2039
|
+
false
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
2043
|
+
self.inner.canonical_readback(workspace, team_key)
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
fn failed_register_ops(takeover: Option<ConcurrentTakeover>, poison: bool) -> SeedCleanupOps {
|
|
2048
|
+
SeedCleanupOps {
|
|
2049
|
+
inner: MockOps {
|
|
2050
|
+
register_ok: false,
|
|
2051
|
+
..MockOps::default()
|
|
2052
|
+
},
|
|
2053
|
+
takeover,
|
|
2054
|
+
poison_state_file: poison,
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
fn assert_fresh_binding_kept(
|
|
2059
|
+
persisted: &serde_json::Value,
|
|
2060
|
+
takeover: &ConcurrentTakeover,
|
|
2061
|
+
) {
|
|
2062
|
+
assert_eq!(
|
|
2063
|
+
persisted["teams"]["fresh"]["team_owner"],
|
|
2064
|
+
takeover.owner,
|
|
2065
|
+
"concurrent team_owner must survive seeded cleanup"
|
|
2066
|
+
);
|
|
2067
|
+
assert_eq!(
|
|
2068
|
+
persisted["teams"]["fresh"]["leader_receiver"],
|
|
2069
|
+
takeover.receiver,
|
|
2070
|
+
"concurrent leader_receiver must survive seeded cleanup"
|
|
2071
|
+
);
|
|
2072
|
+
assert_eq!(
|
|
2073
|
+
persisted["teams"]["fresh"]["owner_epoch"],
|
|
2074
|
+
json!(takeover.epoch),
|
|
2075
|
+
"concurrent owner_epoch must survive seeded cleanup"
|
|
2076
|
+
);
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
#[test]
|
|
2080
|
+
fn seeded_cleanup_clears_exact_seed_through_bind_repository_entry() {
|
|
2081
|
+
let (workspace, seed) = workspace_with_matching_seed("seed-cleanup-success");
|
|
2082
|
+
let mut ops = failed_register_ops(None, false);
|
|
2083
|
+
assert!(!bind_fresh_quick_start_leader_with(
|
|
2084
|
+
&workspace,
|
|
2085
|
+
"fresh",
|
|
2086
|
+
Some(&seed),
|
|
2087
|
+
&mut ops
|
|
2088
|
+
)
|
|
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
|
+
}));
|
|
2099
|
+
let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
2100
|
+
assert!(
|
|
2101
|
+
persisted
|
|
2102
|
+
.pointer("/teams/fresh/team_owner")
|
|
2103
|
+
.is_some_and(serde_json::Value::is_null),
|
|
2104
|
+
"matching seed must be cleared via bind/repository; got {}",
|
|
2105
|
+
persisted["teams"]["fresh"]["team_owner"]
|
|
2106
|
+
);
|
|
2107
|
+
assert!(
|
|
2108
|
+
persisted
|
|
2109
|
+
.pointer("/teams/fresh/leader_receiver")
|
|
2110
|
+
.is_some_and(serde_json::Value::is_null)
|
|
2111
|
+
);
|
|
2112
|
+
assert_eq!(
|
|
2113
|
+
persisted["teams"]["fresh"]["owner_epoch"],
|
|
2114
|
+
json!(1),
|
|
2115
|
+
"seed cleanup must not bump owner_epoch"
|
|
2116
|
+
);
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
#[test]
|
|
2120
|
+
fn seeded_cleanup_preserves_equal_epoch_persisted_concurrent_owner() {
|
|
2121
|
+
let (workspace, seed) = workspace_with_matching_seed("seed-cleanup-concurrent");
|
|
2122
|
+
let takeover = takeover_binding(1);
|
|
2123
|
+
let mut ops = failed_register_ops(Some(takeover_binding(1)), false);
|
|
2124
|
+
assert!(!bind_fresh_quick_start_leader_with(
|
|
2125
|
+
&workspace,
|
|
2126
|
+
"fresh",
|
|
2127
|
+
Some(&seed),
|
|
2128
|
+
&mut ops
|
|
2129
|
+
)
|
|
2130
|
+
.unwrap());
|
|
2131
|
+
assert_fresh_binding_kept(
|
|
2132
|
+
&crate::state::persist::load_runtime_state(&workspace).unwrap(),
|
|
2133
|
+
&takeover,
|
|
2134
|
+
);
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
#[test]
|
|
2138
|
+
fn seeded_cleanup_preserves_epoch2_takeover_owner_receiver_epoch() {
|
|
2139
|
+
let (workspace, seed) = workspace_with_matching_seed("seed-cleanup-epoch2");
|
|
2140
|
+
let takeover = takeover_binding(2);
|
|
2141
|
+
let mut ops = failed_register_ops(Some(takeover_binding(2)), false);
|
|
2142
|
+
assert!(!bind_fresh_quick_start_leader_with(
|
|
2143
|
+
&workspace,
|
|
2144
|
+
"fresh",
|
|
2145
|
+
Some(&seed),
|
|
2146
|
+
&mut ops
|
|
2147
|
+
)
|
|
2148
|
+
.unwrap());
|
|
2149
|
+
assert_fresh_binding_kept(
|
|
2150
|
+
&crate::state::persist::load_runtime_state(&workspace).unwrap(),
|
|
2151
|
+
&takeover,
|
|
2152
|
+
);
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
#[test]
|
|
2156
|
+
fn seeded_cleanup_propagates_persist_error() {
|
|
2157
|
+
let (workspace, seed) = workspace_with_matching_seed("seed-cleanup-persist-err");
|
|
2158
|
+
let mut ops = failed_register_ops(None, true);
|
|
2159
|
+
let error = bind_fresh_quick_start_leader_with(
|
|
2160
|
+
&workspace,
|
|
2161
|
+
"fresh",
|
|
2162
|
+
Some(&seed),
|
|
2163
|
+
&mut ops,
|
|
2164
|
+
)
|
|
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
|
+
}));
|
|
2175
|
+
match error {
|
|
2176
|
+
LifecycleError::StatePersist(text) => {
|
|
2177
|
+
assert!(
|
|
2178
|
+
text.contains("quick-start binding cleanup failed"),
|
|
2179
|
+
"unexpected persist error: {text}"
|
|
2180
|
+
);
|
|
2181
|
+
}
|
|
2182
|
+
other => panic!("expected StatePersist, got {other:?}"),
|
|
2183
|
+
}
|
|
2184
|
+
let _ = std::fs::remove_dir_all(crate::state::persist::runtime_state_path(&workspace));
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
#[test]
|
|
2188
|
+
fn attach_registry_or_readback_failure_rolls_back_exact_state_bytes() {
|
|
2189
|
+
for (case, mut ops) in [
|
|
2190
|
+
(
|
|
2191
|
+
"attach-failure",
|
|
2192
|
+
MockOps {
|
|
2193
|
+
attach_ok: false,
|
|
2194
|
+
..MockOps::default()
|
|
2195
|
+
},
|
|
2196
|
+
),
|
|
2197
|
+
(
|
|
2198
|
+
"registry-failure",
|
|
2199
|
+
MockOps {
|
|
2200
|
+
register_ok: false,
|
|
2201
|
+
..MockOps::default()
|
|
2202
|
+
},
|
|
2203
|
+
),
|
|
2204
|
+
(
|
|
2205
|
+
"readback-failure",
|
|
2206
|
+
MockOps {
|
|
2207
|
+
readback_ok: false,
|
|
2208
|
+
..MockOps::default()
|
|
2209
|
+
},
|
|
1290
2210
|
),
|
|
1291
2211
|
] {
|
|
1292
2212
|
let workspace = workspace(case);
|
|
1293
2213
|
let state_path = crate::state::persist::runtime_state_path(&workspace);
|
|
1294
2214
|
let before = std::fs::read(&state_path).unwrap();
|
|
1295
|
-
assert!(!bind_fresh_quick_start_leader_with(
|
|
1296
|
-
|
|
1297
|
-
"fresh",
|
|
1298
|
-
&mut ops
|
|
1299
|
-
));
|
|
2215
|
+
assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
|
|
2216
|
+
.unwrap());
|
|
1300
2217
|
assert_eq!(
|
|
1301
2218
|
std::fs::read(&state_path).unwrap(),
|
|
1302
2219
|
before,
|
|
@@ -1356,13 +2273,1240 @@ mod fresh_quick_start_leader_binding_tests {
|
|
|
1356
2273
|
let mut ops = AmbientRegistryOps {
|
|
1357
2274
|
readback_ok: false,
|
|
1358
2275
|
attach_calls: 0,
|
|
2276
|
+
registry_receipt: None,
|
|
1359
2277
|
};
|
|
1360
|
-
assert!(!bind_fresh_quick_start_leader_with(
|
|
2278
|
+
assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
|
|
2279
|
+
.unwrap());
|
|
2280
|
+
assert_eq!(std::fs::read(state_path).unwrap(), before);
|
|
2281
|
+
assert!(!registry_path.exists(), "failed readback left registry bytes");
|
|
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(
|
|
1361
2478
|
&workspace,
|
|
1362
2479
|
"fresh",
|
|
2480
|
+
Some(&seed),
|
|
1363
2481
|
&mut ops
|
|
1364
|
-
)
|
|
1365
|
-
|
|
1366
|
-
|
|
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
|
+
);
|
|
1367
3511
|
}
|
|
1368
3512
|
}
|