@team-agent/installer 0.5.70 → 0.5.72
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 +107 -9
- package/crates/team-agent/src/cli/emit.rs +35 -5
- package/crates/team-agent/src/cli/mod.rs +120 -15
- package/crates/team-agent/src/cli/models.rs +443 -0
- package/crates/team-agent/src/cli/spec.rs +4 -1
- package/crates/team-agent/src/cli/types.rs +8 -0
- package/crates/team-agent/src/compiler.rs +157 -0
- package/crates/team-agent/src/leader/owner_bind.rs +21 -0
- package/crates/team-agent/src/leader/provider_attribution.rs +25 -0
- package/crates/team-agent/src/leader/registry.rs +25 -1
- package/crates/team-agent/src/leader/start.rs +33 -9
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +169 -10
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +116 -15
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +859 -12
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +23 -7
- package/crates/team-agent/src/lifecycle/restart.rs +2 -1
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +168 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +209 -0
- package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +122 -3
- package/crates/team-agent/src/lifecycle/types.rs +8 -0
- package/npm/check-skill-links.mjs +88 -0
- package/package.json +5 -4
- package/skills/team-agent/SKILL.md +15 -49
- package/skills/team-agent/command-coverage.json +8 -1
- package/skills/team-agent/references/team-agent-operator.md +665 -0
|
@@ -30,12 +30,302 @@ use crate::model::ids::AgentId;
|
|
|
30
30
|
use crate::model::permissions::{self, AgentPermissionInput};
|
|
31
31
|
use crate::model::yaml::{self, Value};
|
|
32
32
|
use crate::state::persist::load_runtime_state;
|
|
33
|
-
use crate::transport::{PaneId, SessionName, Target, Transport, WindowName};
|
|
33
|
+
use crate::transport::{PaneField, PaneId, SessionName, Target, Transport, WindowName};
|
|
34
34
|
|
|
35
35
|
use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
|
|
36
36
|
|
|
37
37
|
use super::*;
|
|
38
38
|
|
|
39
|
+
trait FreshQuickStartLeaderBindingOps {
|
|
40
|
+
fn caller_pane(&mut self) -> Option<String>;
|
|
41
|
+
fn explicit_provider(&mut self) -> Option<String>;
|
|
42
|
+
fn tmux_endpoint(&mut self) -> Option<String>;
|
|
43
|
+
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
|
+
fn attach(
|
|
46
|
+
&mut self,
|
|
47
|
+
workspace: &Path,
|
|
48
|
+
state: &mut serde_json::Value,
|
|
49
|
+
pane: &PaneId,
|
|
50
|
+
provider: crate::provider::Provider,
|
|
51
|
+
) -> bool;
|
|
52
|
+
fn register(&mut self, workspace: &Path, team_key: &str) -> bool;
|
|
53
|
+
fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
struct RuntimeFreshQuickStartLeaderBindingOps<'a> {
|
|
57
|
+
transport: &'a dyn Transport,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<'_> {
|
|
61
|
+
fn caller_pane(&mut self) -> Option<String> {
|
|
62
|
+
std::env::var("TMUX_PANE")
|
|
63
|
+
.ok()
|
|
64
|
+
.filter(|pane| !pane.is_empty())
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
fn explicit_provider(&mut self) -> Option<String> {
|
|
68
|
+
std::env::var("TEAM_AGENT_LEADER_PROVIDER")
|
|
69
|
+
.ok()
|
|
70
|
+
.filter(|provider| !provider.is_empty())
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fn tmux_endpoint(&mut self) -> Option<String> {
|
|
74
|
+
self.transport.tmux_endpoint()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fn observe_command(&mut self, pane: &PaneId) -> Option<String> {
|
|
78
|
+
self.transport
|
|
79
|
+
.query(&Target::Pane(pane.clone()), PaneField::PaneCurrentCommand)
|
|
80
|
+
.ok()
|
|
81
|
+
.flatten()
|
|
82
|
+
.filter(|command| !command.trim().is_empty())
|
|
83
|
+
}
|
|
84
|
+
|
|
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
|
+
fn attach(
|
|
94
|
+
&mut self,
|
|
95
|
+
workspace: &Path,
|
|
96
|
+
state: &mut serde_json::Value,
|
|
97
|
+
pane: &PaneId,
|
|
98
|
+
provider: crate::provider::Provider,
|
|
99
|
+
) -> bool {
|
|
100
|
+
let event_log = crate::event_log::EventLog::new(workspace);
|
|
101
|
+
crate::leader::attach_leader_to_state(
|
|
102
|
+
workspace,
|
|
103
|
+
state,
|
|
104
|
+
Some(pane),
|
|
105
|
+
provider,
|
|
106
|
+
&event_log,
|
|
107
|
+
crate::leader::LeaseSource::QuickStart,
|
|
108
|
+
true,
|
|
109
|
+
)
|
|
110
|
+
.is_ok()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
114
|
+
crate::leader::registry::register_binding_from_state_best_effort(
|
|
115
|
+
workspace,
|
|
116
|
+
Some(team_key),
|
|
117
|
+
"quick-start",
|
|
118
|
+
)
|
|
119
|
+
.is_some_and(|outcome| outcome.status == "registered" && outcome.path.is_some())
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
123
|
+
launched_team_receiver_is_attached(workspace, team_key)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
struct BindingFileSnapshot {
|
|
128
|
+
path: PathBuf,
|
|
129
|
+
bytes: Option<Vec<u8>>,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
impl BindingFileSnapshot {
|
|
133
|
+
fn capture(path: PathBuf) -> Self {
|
|
134
|
+
let bytes = std::fs::read(&path).ok();
|
|
135
|
+
Self { path, bytes }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
fn restore(&self) {
|
|
139
|
+
match &self.bytes {
|
|
140
|
+
Some(bytes) => {
|
|
141
|
+
let tmp = self.path.with_extension(format!(
|
|
142
|
+
"rollback-{}",
|
|
143
|
+
std::process::id()
|
|
144
|
+
));
|
|
145
|
+
if std::fs::write(&tmp, bytes).is_ok()
|
|
146
|
+
&& std::fs::rename(&tmp, &self.path).is_err()
|
|
147
|
+
{
|
|
148
|
+
let _ = std::fs::remove_file(tmp);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
None => {
|
|
152
|
+
let _ = std::fs::remove_file(&self.path);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
fn binding_snapshots(workspace: &Path, team_key: &str) -> Vec<BindingFileSnapshot> {
|
|
159
|
+
let mut snapshots = vec![BindingFileSnapshot::capture(
|
|
160
|
+
crate::state::persist::runtime_state_path(workspace),
|
|
161
|
+
)];
|
|
162
|
+
if let Some(dir) = crate::leader::registry::registry_dir() {
|
|
163
|
+
snapshots.push(BindingFileSnapshot::capture(dir.join(format!(
|
|
164
|
+
"{}__{team_key}.json",
|
|
165
|
+
crate::leader::registry::workspace_hash(workspace)
|
|
166
|
+
))));
|
|
167
|
+
}
|
|
168
|
+
snapshots
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
fn same_workspace(left: &Path, right: &Path) -> bool {
|
|
172
|
+
std::fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf())
|
|
173
|
+
== std::fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf())
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
fn persisted_binding_matches_verified_pane(
|
|
177
|
+
state: &serde_json::Value,
|
|
178
|
+
workspace: &Path,
|
|
179
|
+
pane: &PaneId,
|
|
180
|
+
provider: crate::provider::Provider,
|
|
181
|
+
endpoint: Option<&str>,
|
|
182
|
+
) -> bool {
|
|
183
|
+
let Some(endpoint) = endpoint.filter(|value| !value.is_empty()) else {
|
|
184
|
+
return false;
|
|
185
|
+
};
|
|
186
|
+
if state
|
|
187
|
+
.get("workspace")
|
|
188
|
+
.and_then(serde_json::Value::as_str)
|
|
189
|
+
.filter(|value| !value.is_empty())
|
|
190
|
+
.is_some_and(|value| !same_workspace(Path::new(value), workspace))
|
|
191
|
+
{
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
let Some(owner) = state.get("team_owner").and_then(serde_json::Value::as_object) else {
|
|
195
|
+
return false;
|
|
196
|
+
};
|
|
197
|
+
let Some(receiver) = state
|
|
198
|
+
.get("leader_receiver")
|
|
199
|
+
.and_then(serde_json::Value::as_object)
|
|
200
|
+
else {
|
|
201
|
+
return false;
|
|
202
|
+
};
|
|
203
|
+
let provider = serde_json::to_value(provider)
|
|
204
|
+
.ok()
|
|
205
|
+
.and_then(|value| value.as_str().map(str::to_string));
|
|
206
|
+
let Some(provider) = provider.as_deref() else {
|
|
207
|
+
return false;
|
|
208
|
+
};
|
|
209
|
+
let owner_epoch = owner.get("owner_epoch").and_then(serde_json::Value::as_u64);
|
|
210
|
+
let receiver_epoch = receiver
|
|
211
|
+
.get("owner_epoch")
|
|
212
|
+
.and_then(serde_json::Value::as_u64);
|
|
213
|
+
let owner_uuid = owner
|
|
214
|
+
.get("leader_session_uuid")
|
|
215
|
+
.and_then(serde_json::Value::as_str)
|
|
216
|
+
.filter(|value| !value.is_empty());
|
|
217
|
+
let receiver_uuid = receiver
|
|
218
|
+
.get("leader_session_uuid")
|
|
219
|
+
.and_then(serde_json::Value::as_str)
|
|
220
|
+
.filter(|value| !value.is_empty());
|
|
221
|
+
receiver.get("status").and_then(serde_json::Value::as_str) == Some("attached")
|
|
222
|
+
&& receiver.get("mode").and_then(serde_json::Value::as_str) == Some("direct_tmux")
|
|
223
|
+
&& owner.get("pane_id").and_then(serde_json::Value::as_str) == Some(pane.as_str())
|
|
224
|
+
&& receiver.get("pane_id").and_then(serde_json::Value::as_str) == Some(pane.as_str())
|
|
225
|
+
&& owner.get("provider").and_then(serde_json::Value::as_str) == Some(provider)
|
|
226
|
+
&& receiver.get("provider").and_then(serde_json::Value::as_str) == Some(provider)
|
|
227
|
+
&& owner_epoch.is_some_and(|epoch| epoch > 0)
|
|
228
|
+
&& owner_epoch == receiver_epoch
|
|
229
|
+
&& owner_uuid.is_some()
|
|
230
|
+
&& owner_uuid == receiver_uuid
|
|
231
|
+
&& receiver
|
|
232
|
+
.get("tmux_socket")
|
|
233
|
+
.and_then(serde_json::Value::as_str)
|
|
234
|
+
== Some(endpoint)
|
|
235
|
+
&& receiver
|
|
236
|
+
.get("authorized_team_workspace")
|
|
237
|
+
.and_then(serde_json::Value::as_str)
|
|
238
|
+
.filter(|value| !value.is_empty())
|
|
239
|
+
.is_none_or(|value| same_workspace(Path::new(value), workspace))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
|
|
243
|
+
workspace: &Path,
|
|
244
|
+
team_key: &str,
|
|
245
|
+
ops: &mut O,
|
|
246
|
+
) -> bool {
|
|
247
|
+
let Ok(resolved) = crate::state::projection::resolve_runtime_team_scope(
|
|
248
|
+
workspace,
|
|
249
|
+
Some(team_key),
|
|
250
|
+
) else {
|
|
251
|
+
return false;
|
|
252
|
+
};
|
|
253
|
+
if resolved.canonical_team_key != team_key {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
let mut state = resolved.state;
|
|
257
|
+
let Some(pane) = ops.caller_pane().filter(|pane| !pane.is_empty()) else {
|
|
258
|
+
return false;
|
|
259
|
+
};
|
|
260
|
+
let pane = PaneId::new(pane);
|
|
261
|
+
let Some(command) = ops
|
|
262
|
+
.observe_command(&pane)
|
|
263
|
+
.filter(|command| !command.trim().is_empty())
|
|
264
|
+
else {
|
|
265
|
+
return false;
|
|
266
|
+
};
|
|
267
|
+
let explicit_provider = ops.explicit_provider();
|
|
268
|
+
let Some(provider) = crate::leader::owner_bind::strict_owner_bind_provider(
|
|
269
|
+
explicit_provider.as_deref(),
|
|
270
|
+
&command,
|
|
271
|
+
) else {
|
|
272
|
+
return false;
|
|
273
|
+
};
|
|
274
|
+
if ops.live_binding_elsewhere(&pane, workspace, team_key) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
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
|
+
let has_persisted_binding = ["team_owner", "leader_receiver"]
|
|
282
|
+
.iter()
|
|
283
|
+
.any(|key| state.get(*key).is_some_and(|value| !value.is_null()));
|
|
284
|
+
if has_persisted_binding
|
|
285
|
+
&& !persisted_binding_matches_verified_pane(
|
|
286
|
+
&state,
|
|
287
|
+
workspace,
|
|
288
|
+
&pane,
|
|
289
|
+
provider,
|
|
290
|
+
endpoint.as_deref(),
|
|
291
|
+
)
|
|
292
|
+
{
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
// Registry publication is the commit point. Restore both persisted surfaces
|
|
296
|
+
// byte for byte when attach, publication, or canonical readback does not finish.
|
|
297
|
+
let snapshots = binding_snapshots(workspace, team_key);
|
|
298
|
+
let attached = has_persisted_binding || ops.attach(workspace, &mut state, &pane, provider);
|
|
299
|
+
let committed = attached
|
|
300
|
+
&& ops.register(workspace, team_key)
|
|
301
|
+
&& ops.canonical_readback(workspace, team_key);
|
|
302
|
+
if !committed {
|
|
303
|
+
for snapshot in snapshots.iter().rev() {
|
|
304
|
+
snapshot.restore();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
committed
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
fn should_emit_workspace_socket_missing_hint(
|
|
311
|
+
selected_source: Option<&str>,
|
|
312
|
+
workspace_socket_missing: bool,
|
|
313
|
+
) -> bool {
|
|
314
|
+
workspace_socket_missing && selected_source != Some("leader_env")
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
fn bind_fresh_quick_start_leader(
|
|
318
|
+
workspace: &Path,
|
|
319
|
+
team_key: &str,
|
|
320
|
+
transport: &dyn Transport,
|
|
321
|
+
) -> bool {
|
|
322
|
+
bind_fresh_quick_start_leader_with(
|
|
323
|
+
workspace,
|
|
324
|
+
team_key,
|
|
325
|
+
&mut RuntimeFreshQuickStartLeaderBindingOps { transport },
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
|
|
39
329
|
/// ---
|
|
40
330
|
/// purpose: 由角色目录推出 workspace 后一键起队
|
|
41
331
|
/// params:
|
|
@@ -281,23 +571,57 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display(
|
|
|
281
571
|
transport: &dyn Transport,
|
|
282
572
|
open_display: bool,
|
|
283
573
|
) -> Result<QuickStartReport, LifecycleError> {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
574
|
+
let mut discover = |requested: &str| {
|
|
575
|
+
crate::lifecycle::launch::pi_mcp::pi_model_candidates(requested).map_err(|_| ())
|
|
576
|
+
};
|
|
577
|
+
quick_start_with_transport_in_workspace_with_display_pi_preflight(
|
|
578
|
+
workspace,
|
|
579
|
+
agents_dir,
|
|
580
|
+
name,
|
|
581
|
+
yes,
|
|
582
|
+
team_id,
|
|
583
|
+
transport,
|
|
584
|
+
open_display,
|
|
585
|
+
&mut discover,
|
|
586
|
+
)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
|
|
590
|
+
workspace: &Path,
|
|
591
|
+
agents_dir: &Path,
|
|
592
|
+
name: Option<&str>,
|
|
593
|
+
yes: bool,
|
|
594
|
+
team_id: Option<&str>,
|
|
595
|
+
transport: &dyn Transport,
|
|
596
|
+
open_display: bool,
|
|
597
|
+
discover: &mut dyn FnMut(&str) -> Result<Vec<String>, ()>,
|
|
598
|
+
) -> Result<QuickStartReport, LifecycleError> {
|
|
292
599
|
if !agents_dir.exists() {
|
|
293
600
|
return Err(LifecycleError::Compile(format!(
|
|
294
601
|
"agents dir not found: {}",
|
|
295
602
|
agents_dir.display()
|
|
296
603
|
)));
|
|
297
604
|
}
|
|
605
|
+
crate::compiler::preflight_pi_models_in_team_with(agents_dir, discover).map_err(|error| {
|
|
606
|
+
LifecycleError::PiModelPreflight {
|
|
607
|
+
requested: error.requested,
|
|
608
|
+
candidates: error.candidates,
|
|
609
|
+
action: error.action,
|
|
610
|
+
not_ready: error.not_ready,
|
|
611
|
+
}
|
|
612
|
+
})?;
|
|
298
613
|
let workspace = workspace.to_path_buf();
|
|
299
614
|
let mut spec = crate::compiler::compile_team(agents_dir)
|
|
300
615
|
.map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
616
|
+
// B-7 / 036b N38 三行 fail-fast — TEAM_AGENT_LEADER_PANE_ID 主动路径在 quick-start
|
|
617
|
+
// 入口验活;死/缺(Dead)的 pane 必须明确报错,不可 silent bind 到 spawner /
|
|
618
|
+
// owner_bind / lease / display 任一消费点。被动路径(display/seed 等)各自走
|
|
619
|
+
// 降级+event,不在这里挡。错误三行式:error(含 pane id 字面)/action(unset
|
|
620
|
+
// 或修 env)/log(env var 名)。 Role/schema and Pi model admission above is pure and
|
|
621
|
+
// must finish before this validation can emit a warning event.
|
|
622
|
+
let team_workspace = team_workspace(agents_dir);
|
|
623
|
+
let warning_workspaces = [workspace.as_path(), team_workspace.as_path()];
|
|
624
|
+
validate_active_leader_pane_env_with_workspaces(transport, &warning_workspaces)?;
|
|
301
625
|
override_spec_workspace(&mut spec, &workspace);
|
|
302
626
|
if !open_display {
|
|
303
627
|
override_spec_display_backend(&mut spec, "none");
|
|
@@ -370,7 +694,12 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display(
|
|
|
370
694
|
.to_string(),
|
|
371
695
|
];
|
|
372
696
|
if session_name.is_some() {
|
|
373
|
-
if
|
|
697
|
+
if should_emit_workspace_socket_missing_hint(
|
|
698
|
+
state
|
|
699
|
+
.get("tmux_socket_source")
|
|
700
|
+
.and_then(serde_json::Value::as_str),
|
|
701
|
+
crate::tmux_backend::socket_probe_missing_for_workspace(&workspace),
|
|
702
|
+
) {
|
|
374
703
|
next_actions.push(crate::tmux_backend::socket_missing_hint_for_workspace(
|
|
375
704
|
&workspace,
|
|
376
705
|
));
|
|
@@ -446,8 +775,11 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display(
|
|
|
446
775
|
team_depth.parent_team_key.as_deref(),
|
|
447
776
|
team_depth.team_depth,
|
|
448
777
|
)?;
|
|
778
|
+
// Fresh initialization owns this one fail-closed bind attempt. It is
|
|
779
|
+
// independent of display layout, so --no-display never suppresses receiver
|
|
780
|
+
// binding. Readiness receives true only after canonical registry readback.
|
|
449
781
|
launch.leader_receiver_attached =
|
|
450
|
-
|
|
782
|
+
bind_fresh_quick_start_leader(&workspace, &state_team_key, transport);
|
|
451
783
|
launch.session_capture_incomplete_agents =
|
|
452
784
|
quick_start_session_capture_incomplete_agents(&workspace, &state_team_key);
|
|
453
785
|
let coordinator_workspace = crate::coordinator::WorkspacePath::new(workspace.clone());
|
|
@@ -498,7 +830,10 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display(
|
|
|
498
830
|
already-initialised teams)"
|
|
499
831
|
.to_string(),
|
|
500
832
|
);
|
|
501
|
-
if
|
|
833
|
+
if should_emit_workspace_socket_missing_hint(
|
|
834
|
+
selected_tmux_socket_source(transport, &workspace),
|
|
835
|
+
crate::tmux_backend::socket_probe_missing_for_workspace(&workspace),
|
|
836
|
+
) {
|
|
502
837
|
next_actions.push(crate::tmux_backend::socket_missing_hint_for_workspace(
|
|
503
838
|
&workspace,
|
|
504
839
|
));
|
|
@@ -519,3 +854,515 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display(
|
|
|
519
854
|
worker_readiness,
|
|
520
855
|
})
|
|
521
856
|
}
|
|
857
|
+
|
|
858
|
+
#[cfg(test)]
|
|
859
|
+
mod fresh_quick_start_leader_binding_tests {
|
|
860
|
+
use super::*;
|
|
861
|
+
use serde_json::json;
|
|
862
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
863
|
+
|
|
864
|
+
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
|
865
|
+
|
|
866
|
+
fn workspace(tag: &str) -> PathBuf {
|
|
867
|
+
let path = std::env::temp_dir().join(format!(
|
|
868
|
+
"ta-quick-start-bind-{tag}-{}-{}",
|
|
869
|
+
std::process::id(),
|
|
870
|
+
SEQUENCE.fetch_add(1, Ordering::Relaxed)
|
|
871
|
+
));
|
|
872
|
+
std::fs::create_dir_all(&path).unwrap();
|
|
873
|
+
crate::state::persist::save_runtime_state(
|
|
874
|
+
&path,
|
|
875
|
+
&json!({
|
|
876
|
+
"active_team_key": "fresh",
|
|
877
|
+
"team_key": "fresh",
|
|
878
|
+
"session_name": "team-fresh",
|
|
879
|
+
"agents": {"sol": {"status": "running", "provider": "pi"}}
|
|
880
|
+
}),
|
|
881
|
+
)
|
|
882
|
+
.unwrap();
|
|
883
|
+
path
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
struct MockOps {
|
|
887
|
+
pane: Option<String>,
|
|
888
|
+
explicit_provider: Option<String>,
|
|
889
|
+
endpoint: Option<String>,
|
|
890
|
+
command: Option<String>,
|
|
891
|
+
live_elsewhere: bool,
|
|
892
|
+
attach_ok: bool,
|
|
893
|
+
register_ok: bool,
|
|
894
|
+
readback_ok: bool,
|
|
895
|
+
attach_calls: usize,
|
|
896
|
+
register_calls: usize,
|
|
897
|
+
readback_calls: usize,
|
|
898
|
+
attached_provider: Option<crate::provider::Provider>,
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
impl Default for MockOps {
|
|
902
|
+
fn default() -> Self {
|
|
903
|
+
Self {
|
|
904
|
+
pane: Some("%42".to_string()),
|
|
905
|
+
explicit_provider: Some("pi".to_string()),
|
|
906
|
+
endpoint: Some("/private/tmp/tmux-test/default".to_string()),
|
|
907
|
+
command: Some("pi".to_string()),
|
|
908
|
+
live_elsewhere: false,
|
|
909
|
+
attach_ok: true,
|
|
910
|
+
register_ok: true,
|
|
911
|
+
readback_ok: true,
|
|
912
|
+
attach_calls: 0,
|
|
913
|
+
register_calls: 0,
|
|
914
|
+
readback_calls: 0,
|
|
915
|
+
attached_provider: None,
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
impl FreshQuickStartLeaderBindingOps for MockOps {
|
|
921
|
+
fn caller_pane(&mut self) -> Option<String> {
|
|
922
|
+
self.pane.clone()
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
fn explicit_provider(&mut self) -> Option<String> {
|
|
926
|
+
self.explicit_provider.clone()
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
fn tmux_endpoint(&mut self) -> Option<String> {
|
|
930
|
+
self.endpoint.clone()
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
fn observe_command(&mut self, _pane: &PaneId) -> Option<String> {
|
|
934
|
+
self.command.clone()
|
|
935
|
+
}
|
|
936
|
+
|
|
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
|
+
fn attach(
|
|
947
|
+
&mut self,
|
|
948
|
+
workspace: &Path,
|
|
949
|
+
state: &mut serde_json::Value,
|
|
950
|
+
pane: &PaneId,
|
|
951
|
+
provider: crate::provider::Provider,
|
|
952
|
+
) -> bool {
|
|
953
|
+
self.attach_calls += 1;
|
|
954
|
+
self.attached_provider = Some(provider);
|
|
955
|
+
if !self.attach_ok {
|
|
956
|
+
state["failed_attach_mutation"] = json!(true);
|
|
957
|
+
let _ = crate::state::persist::save_runtime_state(workspace, state);
|
|
958
|
+
return false;
|
|
959
|
+
}
|
|
960
|
+
state["leader_receiver"] = json!({
|
|
961
|
+
"mode": "direct_tmux",
|
|
962
|
+
"pane_id": pane.as_str(),
|
|
963
|
+
"status": "attached",
|
|
964
|
+
"provider": "pi",
|
|
965
|
+
"leader_session_uuid": "uuid-fresh",
|
|
966
|
+
"owner_epoch": 1,
|
|
967
|
+
"tmux_socket": self.endpoint
|
|
968
|
+
});
|
|
969
|
+
state["team_owner"] = json!({
|
|
970
|
+
"pane_id": pane.as_str(),
|
|
971
|
+
"provider": "pi",
|
|
972
|
+
"leader_session_uuid": "uuid-fresh",
|
|
973
|
+
"owner_epoch": 1
|
|
974
|
+
});
|
|
975
|
+
crate::state::persist::save_runtime_state(workspace, state).is_ok()
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
fn register(&mut self, workspace: &Path, _team_key: &str) -> bool {
|
|
979
|
+
self.register_calls += 1;
|
|
980
|
+
let mut persisted = crate::state::persist::load_runtime_state(workspace).unwrap();
|
|
981
|
+
assert_eq!(
|
|
982
|
+
persisted
|
|
983
|
+
.pointer("/leader_receiver/pane_id")
|
|
984
|
+
.and_then(serde_json::Value::as_str),
|
|
985
|
+
Some("%42")
|
|
986
|
+
);
|
|
987
|
+
if !self.register_ok {
|
|
988
|
+
persisted["failed_registry_mutation"] = json!(true);
|
|
989
|
+
crate::state::persist::save_runtime_state(workspace, &persisted).unwrap();
|
|
990
|
+
}
|
|
991
|
+
self.register_ok
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
fn canonical_readback(&mut self, workspace: &Path, _team_key: &str) -> bool {
|
|
995
|
+
self.readback_calls += 1;
|
|
996
|
+
let mut persisted = crate::state::persist::load_runtime_state(workspace).unwrap();
|
|
997
|
+
if !self.readback_ok {
|
|
998
|
+
persisted["failed_readback_mutation"] = json!(true);
|
|
999
|
+
crate::state::persist::save_runtime_state(workspace, &persisted).unwrap();
|
|
1000
|
+
}
|
|
1001
|
+
self.readback_ok
|
|
1002
|
+
&& persisted
|
|
1003
|
+
.get("leader_receiver")
|
|
1004
|
+
.is_some_and(|receiver| !receiver.is_null())
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
#[test]
|
|
1009
|
+
fn fresh_binding_persists_then_registers_then_requires_canonical_readback() {
|
|
1010
|
+
let workspace = workspace("positive");
|
|
1011
|
+
let mut ops = MockOps::default();
|
|
1012
|
+
assert!(bind_fresh_quick_start_leader_with(
|
|
1013
|
+
&workspace,
|
|
1014
|
+
"fresh",
|
|
1015
|
+
&mut ops
|
|
1016
|
+
));
|
|
1017
|
+
assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Pi));
|
|
1018
|
+
assert_eq!(ops.attach_calls, 1);
|
|
1019
|
+
assert_eq!(ops.register_calls, 1);
|
|
1020
|
+
assert_eq!(ops.readback_calls, 1);
|
|
1021
|
+
let state = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
1022
|
+
assert_eq!(
|
|
1023
|
+
state
|
|
1024
|
+
.pointer("/leader_receiver/pane_id")
|
|
1025
|
+
.and_then(serde_json::Value::as_str),
|
|
1026
|
+
Some("%42")
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
#[test]
|
|
1031
|
+
fn every_pre_attach_refusal_is_byte_preserving_and_never_registers() {
|
|
1032
|
+
for case in [
|
|
1033
|
+
"missing_pane",
|
|
1034
|
+
"unverifiable_pane",
|
|
1035
|
+
"empty_command",
|
|
1036
|
+
"unknown_provider",
|
|
1037
|
+
"unknown_explicit_provider",
|
|
1038
|
+
"team_mismatch",
|
|
1039
|
+
"existing_owner",
|
|
1040
|
+
"existing_receiver",
|
|
1041
|
+
"provider_mismatch",
|
|
1042
|
+
"socket_mismatch",
|
|
1043
|
+
"workspace_mismatch",
|
|
1044
|
+
"dual_state_mismatch",
|
|
1045
|
+
"live_other_scope",
|
|
1046
|
+
] {
|
|
1047
|
+
let workspace = workspace(case);
|
|
1048
|
+
let mut state = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
1049
|
+
let mut ops = MockOps::default();
|
|
1050
|
+
let team_key = if case == "team_mismatch" {
|
|
1051
|
+
"other"
|
|
1052
|
+
} else {
|
|
1053
|
+
"fresh"
|
|
1054
|
+
};
|
|
1055
|
+
match case {
|
|
1056
|
+
"missing_pane" => ops.pane = None,
|
|
1057
|
+
"unverifiable_pane" => ops.command = None,
|
|
1058
|
+
"empty_command" => ops.command = Some(" ".to_string()),
|
|
1059
|
+
"unknown_provider" => {
|
|
1060
|
+
ops.explicit_provider = None;
|
|
1061
|
+
ops.command = Some("node".to_string());
|
|
1062
|
+
}
|
|
1063
|
+
"unknown_explicit_provider" => {
|
|
1064
|
+
ops.explicit_provider = Some("unknown".to_string());
|
|
1065
|
+
ops.command = Some("codex".to_string());
|
|
1066
|
+
}
|
|
1067
|
+
"existing_owner" => state["team_owner"] = json!({"pane_id": "%old"}),
|
|
1068
|
+
"existing_receiver" => {
|
|
1069
|
+
state["leader_receiver"] = json!({"pane_id": "%old"})
|
|
1070
|
+
}
|
|
1071
|
+
"provider_mismatch" | "socket_mismatch" | "workspace_mismatch"
|
|
1072
|
+
| "dual_state_mismatch" => {
|
|
1073
|
+
state["workspace"] = json!(workspace);
|
|
1074
|
+
state["team_owner"] = json!({
|
|
1075
|
+
"pane_id": "%42", "provider": "pi",
|
|
1076
|
+
"leader_session_uuid": "uuid-fresh", "owner_epoch": 1
|
|
1077
|
+
});
|
|
1078
|
+
state["leader_receiver"] = json!({
|
|
1079
|
+
"mode": "direct_tmux", "status": "attached", "pane_id": "%42",
|
|
1080
|
+
"provider": "pi", "leader_session_uuid": "uuid-fresh", "owner_epoch": 1,
|
|
1081
|
+
"tmux_socket": "/private/tmp/tmux-test/default"
|
|
1082
|
+
});
|
|
1083
|
+
match case {
|
|
1084
|
+
"provider_mismatch" => state["leader_receiver"]["provider"] = json!("codex"),
|
|
1085
|
+
"socket_mismatch" => state["leader_receiver"]["tmux_socket"] = json!("/tmp/other"),
|
|
1086
|
+
"workspace_mismatch" => state["workspace"] = json!(workspace.join("other")),
|
|
1087
|
+
"dual_state_mismatch" => state["team_owner"]["owner_epoch"] = json!(2),
|
|
1088
|
+
_ => unreachable!(),
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
"live_other_scope" => ops.live_elsewhere = true,
|
|
1092
|
+
"team_mismatch" => {}
|
|
1093
|
+
_ => unreachable!(),
|
|
1094
|
+
}
|
|
1095
|
+
crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
|
|
1096
|
+
let before = std::fs::read(crate::state::persist::runtime_state_path(&workspace))
|
|
1097
|
+
.unwrap();
|
|
1098
|
+
assert!(
|
|
1099
|
+
!bind_fresh_quick_start_leader_with(&workspace, team_key, &mut ops),
|
|
1100
|
+
"{case} must refuse"
|
|
1101
|
+
);
|
|
1102
|
+
assert_eq!(
|
|
1103
|
+
before,
|
|
1104
|
+
std::fs::read(crate::state::persist::runtime_state_path(&workspace)).unwrap(),
|
|
1105
|
+
"{case} changed state bytes"
|
|
1106
|
+
);
|
|
1107
|
+
assert_eq!(ops.attach_calls, 0, "{case} reached attach");
|
|
1108
|
+
assert_eq!(ops.register_calls, 0, "{case} reached registry write");
|
|
1109
|
+
assert_eq!(ops.readback_calls, 0, "{case} reached readback");
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
struct HomeGuard(Option<std::ffi::OsString>);
|
|
1114
|
+
|
|
1115
|
+
impl HomeGuard {
|
|
1116
|
+
fn set(path: &Path) -> Self {
|
|
1117
|
+
let old = std::env::var_os("HOME");
|
|
1118
|
+
std::env::set_var("HOME", path);
|
|
1119
|
+
Self(old)
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
impl Drop for HomeGuard {
|
|
1124
|
+
fn drop(&mut self) {
|
|
1125
|
+
if let Some(value) = self.0.take() {
|
|
1126
|
+
std::env::set_var("HOME", value);
|
|
1127
|
+
} else {
|
|
1128
|
+
std::env::remove_var("HOME");
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
fn ambient_dual_state_workspace(tag: &str) -> PathBuf {
|
|
1134
|
+
let workspace = workspace(tag);
|
|
1135
|
+
let ambient = "/private/tmp/tmux-test/default";
|
|
1136
|
+
crate::state::persist::save_runtime_state(
|
|
1137
|
+
&workspace,
|
|
1138
|
+
&json!({
|
|
1139
|
+
"workspace": workspace,
|
|
1140
|
+
"active_team_key": "fresh",
|
|
1141
|
+
"team_key": "fresh",
|
|
1142
|
+
"session_name": "team-fresh",
|
|
1143
|
+
"agents": {"sol": {"status": "running", "provider": "pi"}},
|
|
1144
|
+
"teams": {
|
|
1145
|
+
"current": {
|
|
1146
|
+
"team_key": "current",
|
|
1147
|
+
"session_name": "team-parent",
|
|
1148
|
+
"agents": {}
|
|
1149
|
+
},
|
|
1150
|
+
"fresh": {
|
|
1151
|
+
"workspace": workspace,
|
|
1152
|
+
"team_key": "fresh",
|
|
1153
|
+
"session_name": "team-fresh",
|
|
1154
|
+
"agents": {"sol": {"status": "running", "provider": "pi"}},
|
|
1155
|
+
"team_owner": {
|
|
1156
|
+
"pane_id": "%42",
|
|
1157
|
+
"provider": "pi",
|
|
1158
|
+
"leader_session_uuid": "uuid-fresh",
|
|
1159
|
+
"owner_epoch": 1
|
|
1160
|
+
},
|
|
1161
|
+
"leader_receiver": {
|
|
1162
|
+
"mode": "direct_tmux",
|
|
1163
|
+
"status": "attached",
|
|
1164
|
+
"pane_id": "%42",
|
|
1165
|
+
"provider": "pi",
|
|
1166
|
+
"leader_session_uuid": "uuid-fresh",
|
|
1167
|
+
"owner_epoch": 1,
|
|
1168
|
+
"tmux_socket": ambient
|
|
1169
|
+
},
|
|
1170
|
+
"owner_epoch": 1
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}),
|
|
1174
|
+
)
|
|
1175
|
+
.unwrap();
|
|
1176
|
+
workspace
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
struct AmbientRegistryOps {
|
|
1180
|
+
readback_ok: bool,
|
|
1181
|
+
attach_calls: usize,
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
impl FreshQuickStartLeaderBindingOps for AmbientRegistryOps {
|
|
1185
|
+
fn caller_pane(&mut self) -> Option<String> {
|
|
1186
|
+
Some("%42".to_string())
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
fn explicit_provider(&mut self) -> Option<String> {
|
|
1190
|
+
Some("pi".to_string())
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
fn tmux_endpoint(&mut self) -> Option<String> {
|
|
1194
|
+
Some("/private/tmp/tmux-test/default".to_string())
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
fn observe_command(&mut self, _pane: &PaneId) -> Option<String> {
|
|
1198
|
+
Some("pi".to_string())
|
|
1199
|
+
}
|
|
1200
|
+
|
|
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
|
+
fn attach(
|
|
1211
|
+
&mut self,
|
|
1212
|
+
_workspace: &Path,
|
|
1213
|
+
_state: &mut serde_json::Value,
|
|
1214
|
+
_pane: &PaneId,
|
|
1215
|
+
_provider: crate::provider::Provider,
|
|
1216
|
+
) -> bool {
|
|
1217
|
+
self.attach_calls += 1;
|
|
1218
|
+
false
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
1222
|
+
crate::leader::registry::register_binding_from_state_best_effort(
|
|
1223
|
+
workspace,
|
|
1224
|
+
Some(team_key),
|
|
1225
|
+
"quick-start",
|
|
1226
|
+
)
|
|
1227
|
+
.is_some_and(|outcome| outcome.status == "registered" && outcome.path.is_some())
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
|
|
1231
|
+
self.readback_ok && launched_team_receiver_is_attached(workspace, team_key)
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
#[test]
|
|
1236
|
+
#[serial_test::serial(env)]
|
|
1237
|
+
fn ambient_dual_state_registers_and_reads_back_when_workspace_socket_is_missing() {
|
|
1238
|
+
let workspace = ambient_dual_state_workspace("ambient-dual-state");
|
|
1239
|
+
let home = workspace.join("home");
|
|
1240
|
+
std::fs::create_dir_all(&home).unwrap();
|
|
1241
|
+
let _home = HomeGuard::set(&home);
|
|
1242
|
+
assert!(crate::tmux_backend::socket_probe_missing_for_workspace(
|
|
1243
|
+
&workspace
|
|
1244
|
+
));
|
|
1245
|
+
let mut ops = AmbientRegistryOps {
|
|
1246
|
+
readback_ok: true,
|
|
1247
|
+
attach_calls: 0,
|
|
1248
|
+
};
|
|
1249
|
+
assert!(bind_fresh_quick_start_leader_with(
|
|
1250
|
+
&workspace,
|
|
1251
|
+
"fresh",
|
|
1252
|
+
&mut ops
|
|
1253
|
+
));
|
|
1254
|
+
assert_eq!(ops.attach_calls, 0, "preseeded dual state must not reattach");
|
|
1255
|
+
let path = crate::leader::registry::registry_dir().unwrap().join(format!(
|
|
1256
|
+
"{}__fresh.json",
|
|
1257
|
+
crate::leader::registry::workspace_hash(&workspace)
|
|
1258
|
+
));
|
|
1259
|
+
let entry: crate::leader::registry::LeaderRegistryEntry =
|
|
1260
|
+
serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
|
1261
|
+
assert_eq!(
|
|
1262
|
+
entry.channel.get("tmux_socket").and_then(serde_json::Value::as_str),
|
|
1263
|
+
Some("/private/tmp/tmux-test/default")
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
#[test]
|
|
1268
|
+
fn attach_registry_or_readback_failure_rolls_back_exact_state_bytes() {
|
|
1269
|
+
for (case, mut ops) in [
|
|
1270
|
+
(
|
|
1271
|
+
"attach-failure",
|
|
1272
|
+
MockOps {
|
|
1273
|
+
attach_ok: false,
|
|
1274
|
+
..MockOps::default()
|
|
1275
|
+
},
|
|
1276
|
+
),
|
|
1277
|
+
(
|
|
1278
|
+
"registry-failure",
|
|
1279
|
+
MockOps {
|
|
1280
|
+
register_ok: false,
|
|
1281
|
+
..MockOps::default()
|
|
1282
|
+
},
|
|
1283
|
+
),
|
|
1284
|
+
(
|
|
1285
|
+
"readback-failure",
|
|
1286
|
+
MockOps {
|
|
1287
|
+
readback_ok: false,
|
|
1288
|
+
..MockOps::default()
|
|
1289
|
+
},
|
|
1290
|
+
),
|
|
1291
|
+
] {
|
|
1292
|
+
let workspace = workspace(case);
|
|
1293
|
+
let state_path = crate::state::persist::runtime_state_path(&workspace);
|
|
1294
|
+
let before = std::fs::read(&state_path).unwrap();
|
|
1295
|
+
assert!(!bind_fresh_quick_start_leader_with(
|
|
1296
|
+
&workspace,
|
|
1297
|
+
"fresh",
|
|
1298
|
+
&mut ops
|
|
1299
|
+
));
|
|
1300
|
+
assert_eq!(
|
|
1301
|
+
std::fs::read(&state_path).unwrap(),
|
|
1302
|
+
before,
|
|
1303
|
+
"{case} left partial state bytes"
|
|
1304
|
+
);
|
|
1305
|
+
match case {
|
|
1306
|
+
"attach-failure" => {
|
|
1307
|
+
assert_eq!(ops.attach_calls, 1);
|
|
1308
|
+
assert_eq!(ops.register_calls, 0);
|
|
1309
|
+
assert_eq!(ops.readback_calls, 0);
|
|
1310
|
+
}
|
|
1311
|
+
"registry-failure" => {
|
|
1312
|
+
assert_eq!(ops.attach_calls, 1);
|
|
1313
|
+
assert_eq!(ops.register_calls, 1);
|
|
1314
|
+
assert_eq!(ops.readback_calls, 0);
|
|
1315
|
+
}
|
|
1316
|
+
"readback-failure" => {
|
|
1317
|
+
assert_eq!(ops.attach_calls, 1);
|
|
1318
|
+
assert_eq!(ops.register_calls, 1);
|
|
1319
|
+
assert_eq!(ops.readback_calls, 1);
|
|
1320
|
+
}
|
|
1321
|
+
_ => unreachable!(),
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
#[test]
|
|
1327
|
+
fn socket_missing_hint_is_suppressed_only_for_selected_leader_env_transport() {
|
|
1328
|
+
assert!(!should_emit_workspace_socket_missing_hint(
|
|
1329
|
+
Some("leader_env"),
|
|
1330
|
+
true
|
|
1331
|
+
));
|
|
1332
|
+
assert!(should_emit_workspace_socket_missing_hint(
|
|
1333
|
+
Some("workspace"),
|
|
1334
|
+
true
|
|
1335
|
+
));
|
|
1336
|
+
assert!(should_emit_workspace_socket_missing_hint(None, true));
|
|
1337
|
+
assert!(!should_emit_workspace_socket_missing_hint(
|
|
1338
|
+
Some("workspace"),
|
|
1339
|
+
false
|
|
1340
|
+
));
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
#[test]
|
|
1344
|
+
#[serial_test::serial(env)]
|
|
1345
|
+
fn failed_canonical_readback_removes_new_registry_bytes_and_preserves_dual_state_bytes() {
|
|
1346
|
+
let workspace = ambient_dual_state_workspace("ambient-readback-rollback");
|
|
1347
|
+
let home = workspace.join("home");
|
|
1348
|
+
std::fs::create_dir_all(&home).unwrap();
|
|
1349
|
+
let _home = HomeGuard::set(&home);
|
|
1350
|
+
let state_path = crate::state::persist::runtime_state_path(&workspace);
|
|
1351
|
+
let before = std::fs::read(&state_path).unwrap();
|
|
1352
|
+
let registry_path = crate::leader::registry::registry_dir().unwrap().join(format!(
|
|
1353
|
+
"{}__fresh.json",
|
|
1354
|
+
crate::leader::registry::workspace_hash(&workspace)
|
|
1355
|
+
));
|
|
1356
|
+
let mut ops = AmbientRegistryOps {
|
|
1357
|
+
readback_ok: false,
|
|
1358
|
+
attach_calls: 0,
|
|
1359
|
+
};
|
|
1360
|
+
assert!(!bind_fresh_quick_start_leader_with(
|
|
1361
|
+
&workspace,
|
|
1362
|
+
"fresh",
|
|
1363
|
+
&mut ops
|
|
1364
|
+
));
|
|
1365
|
+
assert_eq!(std::fs::read(state_path).unwrap(), before);
|
|
1366
|
+
assert!(!registry_path.exists(), "failed readback left registry bytes");
|
|
1367
|
+
}
|
|
1368
|
+
}
|