@team-agent/installer 0.3.19 → 0.3.20
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/coordinator/backoff.rs +47 -13
- package/crates/team-agent/src/coordinator/tests/daemon.rs +150 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +22 -7
- package/crates/team-agent/src/lifecycle/restart/common.rs +32 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +4 -2
- package/crates/team-agent/src/lifecycle/restart/remove.rs +7 -3
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +222 -0
- package/crates/team-agent/src/tmux_backend.rs +87 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -34,21 +34,42 @@ pub struct DaemonArgs {
|
|
|
34
34
|
/// §10:返 `Result`(顶层 bin 用 anyhow 收;§12 边界)。
|
|
35
35
|
pub fn run_daemon(args: DaemonArgs) -> Result<(), DaemonError> {
|
|
36
36
|
// CP-1: the daemon's whole tick surface (has_session / capture / inject / list_windows / kill)
|
|
37
|
-
// runs through this backend
|
|
38
|
-
//
|
|
37
|
+
// runs through this backend. Prefer the persisted runtime endpoint so attached explicit-socket
|
|
38
|
+
// teams are checked on the same socket as lifecycle worker operations.
|
|
39
|
+
let state = crate::state::persist::load_runtime_state(args.workspace.as_path()).ok();
|
|
40
|
+
let tmux_selection = crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
|
|
41
|
+
args.workspace.as_path(),
|
|
42
|
+
state.as_ref(),
|
|
43
|
+
);
|
|
44
|
+
let tmux_metadata = DaemonTmuxEndpointMetadata {
|
|
45
|
+
tmux_endpoint_used: tmux_selection.tmux_endpoint_used.clone(),
|
|
46
|
+
tmux_endpoint_source: tmux_selection.tmux_endpoint_source.as_str(),
|
|
47
|
+
};
|
|
39
48
|
let coordinator = Coordinator::new(
|
|
40
49
|
args.workspace.clone(),
|
|
41
50
|
Box::new(RealProviderRegistry),
|
|
42
|
-
Box::new(
|
|
43
|
-
args.workspace.as_path(),
|
|
44
|
-
)),
|
|
51
|
+
Box::new(tmux_selection.backend),
|
|
45
52
|
);
|
|
46
|
-
|
|
53
|
+
run_daemon_with_coordinator_and_boot_tmux(&args, &coordinator, Some(tmux_metadata))
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
pub fn run_daemon_with_coordinator(
|
|
50
57
|
args: &DaemonArgs,
|
|
51
58
|
coordinator: &Coordinator,
|
|
59
|
+
) -> Result<(), DaemonError> {
|
|
60
|
+
run_daemon_with_coordinator_and_boot_tmux(args, coordinator, None)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#[derive(Debug, Clone)]
|
|
64
|
+
struct DaemonTmuxEndpointMetadata {
|
|
65
|
+
tmux_endpoint_used: Option<String>,
|
|
66
|
+
tmux_endpoint_source: &'static str,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fn run_daemon_with_coordinator_and_boot_tmux(
|
|
70
|
+
args: &DaemonArgs,
|
|
71
|
+
coordinator: &Coordinator,
|
|
72
|
+
tmux_metadata: Option<DaemonTmuxEndpointMetadata>,
|
|
52
73
|
) -> Result<(), DaemonError> {
|
|
53
74
|
let runtime_dir = crate::model::paths::runtime_dir(args.workspace.as_path());
|
|
54
75
|
std::fs::create_dir_all(&runtime_dir)?;
|
|
@@ -57,13 +78,26 @@ pub fn run_daemon_with_coordinator(
|
|
|
57
78
|
write_coordinator_metadata(&args.workspace, pid, MetadataSource::Boot)?;
|
|
58
79
|
|
|
59
80
|
let event_log = EventLog::new(args.workspace.as_path());
|
|
60
|
-
|
|
61
|
-
"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
81
|
+
let mut boot_event = serde_json::json!({
|
|
82
|
+
"workspace": args.workspace.as_path().to_string_lossy(),
|
|
83
|
+
"once": args.once,
|
|
84
|
+
});
|
|
85
|
+
if let Some(metadata) = tmux_metadata {
|
|
86
|
+
if let Some(object) = boot_event.as_object_mut() {
|
|
87
|
+
object.insert(
|
|
88
|
+
"tmux_endpoint_source".to_string(),
|
|
89
|
+
serde_json::Value::String(metadata.tmux_endpoint_source.to_string()),
|
|
90
|
+
);
|
|
91
|
+
object.insert(
|
|
92
|
+
"tmux_endpoint_used".to_string(),
|
|
93
|
+
metadata
|
|
94
|
+
.tmux_endpoint_used
|
|
95
|
+
.map(serde_json::Value::String)
|
|
96
|
+
.unwrap_or(serde_json::Value::Null),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
event_log.write("coordinator.boot", boot_event)?;
|
|
67
101
|
let tick_interval = match args.tick_interval_sec {
|
|
68
102
|
Some(v) if v > 0.0 => v,
|
|
69
103
|
_ => resolve_tick_interval(&args.workspace)?,
|
|
@@ -209,6 +209,156 @@ fn coord_over_staged_tmux(
|
|
|
209
209
|
(coord, dir, seen)
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
fn coord_over_runtime_state_tmux_endpoint(
|
|
213
|
+
session_name: &str,
|
|
214
|
+
endpoint: &str,
|
|
215
|
+
steps: Vec<RunnerStep>,
|
|
216
|
+
last: RunnerStep,
|
|
217
|
+
) -> (
|
|
218
|
+
Coordinator,
|
|
219
|
+
std::path::PathBuf,
|
|
220
|
+
std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>>,
|
|
221
|
+
Option<String>,
|
|
222
|
+
&'static str,
|
|
223
|
+
) {
|
|
224
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
225
|
+
static N: AtomicU64 = AtomicU64::new(0);
|
|
226
|
+
let dir = std::env::temp_dir().join(format!(
|
|
227
|
+
"ta-rs-e27-coord-explicit-{}-{}",
|
|
228
|
+
std::process::id(),
|
|
229
|
+
N.fetch_add(1, Ordering::Relaxed)
|
|
230
|
+
));
|
|
231
|
+
std::fs::create_dir_all(crate::model::paths::runtime_dir(&dir)).unwrap();
|
|
232
|
+
let _ = crate::message_store::MessageStore::open(&dir).unwrap();
|
|
233
|
+
let state = serde_json::json!({
|
|
234
|
+
"session_name": session_name,
|
|
235
|
+
"tmux_endpoint": endpoint,
|
|
236
|
+
"tmux_socket": endpoint,
|
|
237
|
+
"agents": {},
|
|
238
|
+
});
|
|
239
|
+
crate::state::persist::save_runtime_state(&dir, &state).unwrap();
|
|
240
|
+
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
241
|
+
let runner = StagedTmuxRunner {
|
|
242
|
+
steps: std::sync::Mutex::new(steps.into_iter().collect()),
|
|
243
|
+
last,
|
|
244
|
+
seen: std::sync::Arc::clone(&seen),
|
|
245
|
+
};
|
|
246
|
+
let selection = crate::tmux_backend::tmux_backend_with_runner_for_runtime_state_or_workspace(
|
|
247
|
+
Box::new(runner),
|
|
248
|
+
&dir,
|
|
249
|
+
Some(&state),
|
|
250
|
+
);
|
|
251
|
+
let endpoint_used = selection.tmux_endpoint_used.clone();
|
|
252
|
+
let endpoint_source = selection.tmux_endpoint_source.as_str();
|
|
253
|
+
let reg: Box<dyn ProviderRegistry> = Box::new(MockRegistry::new(&[], &[]));
|
|
254
|
+
let coord = Coordinator::for_test(
|
|
255
|
+
WorkspacePath::new(dir.clone()),
|
|
256
|
+
reg,
|
|
257
|
+
Box::new(selection.backend),
|
|
258
|
+
None,
|
|
259
|
+
None,
|
|
260
|
+
);
|
|
261
|
+
(coord, dir, seen, endpoint_used, endpoint_source)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#[test]
|
|
265
|
+
fn e27_coordinator_tick_uses_runtime_explicit_endpoint_for_session_gate() {
|
|
266
|
+
let session = "team-e27-explicit-restart";
|
|
267
|
+
let endpoint = "/tmp/e27-explicit-restart-test.sock";
|
|
268
|
+
let (coord, dir, seen, endpoint_used, endpoint_source) = coord_over_runtime_state_tmux_endpoint(
|
|
269
|
+
session,
|
|
270
|
+
endpoint,
|
|
271
|
+
vec![RunnerStep::Exit(true)],
|
|
272
|
+
RunnerStep::Exit(true),
|
|
273
|
+
);
|
|
274
|
+
assert_eq!(endpoint_used.as_deref(), Some(endpoint));
|
|
275
|
+
assert_eq!(endpoint_source, "state.tmux_endpoint");
|
|
276
|
+
|
|
277
|
+
let report = coord
|
|
278
|
+
.tick()
|
|
279
|
+
.expect("explicit endpoint with present session should tick");
|
|
280
|
+
assert!(
|
|
281
|
+
report.ok,
|
|
282
|
+
"present explicit endpoint session should keep tick ok"
|
|
283
|
+
);
|
|
284
|
+
assert!(
|
|
285
|
+
!report.stop,
|
|
286
|
+
"present explicit endpoint session must not trip the session-missing stop gate"
|
|
287
|
+
);
|
|
288
|
+
let calls = seen.lock().unwrap().clone();
|
|
289
|
+
assert!(
|
|
290
|
+
calls
|
|
291
|
+
.iter()
|
|
292
|
+
.all(|argv| !argv.iter().any(|part| part == "-L")),
|
|
293
|
+
"explicit endpoint coordinator must not fall back to workspace -L socket; got {calls:?}"
|
|
294
|
+
);
|
|
295
|
+
assert_eq!(
|
|
296
|
+
calls.first(),
|
|
297
|
+
Some(&vec![
|
|
298
|
+
"tmux".to_string(),
|
|
299
|
+
"-S".to_string(),
|
|
300
|
+
endpoint.to_string(),
|
|
301
|
+
"has-session".to_string(),
|
|
302
|
+
"-t".to_string(),
|
|
303
|
+
session.to_string(),
|
|
304
|
+
]),
|
|
305
|
+
"session gate must probe the persisted explicit endpoint"
|
|
306
|
+
);
|
|
307
|
+
let events = read_event_log_dir(&dir);
|
|
308
|
+
assert!(
|
|
309
|
+
events
|
|
310
|
+
.iter()
|
|
311
|
+
.all(|event| event.get("event").and_then(|v| v.as_str())
|
|
312
|
+
!= Some("coordinator.session_missing")),
|
|
313
|
+
"present explicit endpoint session must not emit coordinator.session_missing; got {events:?}"
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
#[test]
|
|
318
|
+
fn e27_coordinator_tick_still_stops_when_explicit_endpoint_session_is_missing() {
|
|
319
|
+
let session = "team-e27-explicit-restart";
|
|
320
|
+
let endpoint = "/tmp/e27-explicit-restart-test.sock";
|
|
321
|
+
let (coord, dir, seen, endpoint_used, endpoint_source) = coord_over_runtime_state_tmux_endpoint(
|
|
322
|
+
session,
|
|
323
|
+
endpoint,
|
|
324
|
+
vec![RunnerStep::Exit(false)],
|
|
325
|
+
RunnerStep::Exit(false),
|
|
326
|
+
);
|
|
327
|
+
assert_eq!(endpoint_used.as_deref(), Some(endpoint));
|
|
328
|
+
assert_eq!(endpoint_source, "state.tmux_endpoint");
|
|
329
|
+
|
|
330
|
+
let report = coord
|
|
331
|
+
.tick()
|
|
332
|
+
.expect("definitive missing session is a typed stop report");
|
|
333
|
+
assert!(!report.ok, "missing explicit endpoint session => ok=false");
|
|
334
|
+
assert!(
|
|
335
|
+
report.stop,
|
|
336
|
+
"genuine missing session on the selected explicit endpoint must still stop"
|
|
337
|
+
);
|
|
338
|
+
assert_eq!(report.reason, Some(TickStopReason::TmuxSessionMissing));
|
|
339
|
+
let calls = seen.lock().unwrap().clone();
|
|
340
|
+
assert_eq!(
|
|
341
|
+
calls.first(),
|
|
342
|
+
Some(&vec![
|
|
343
|
+
"tmux".to_string(),
|
|
344
|
+
"-S".to_string(),
|
|
345
|
+
endpoint.to_string(),
|
|
346
|
+
"has-session".to_string(),
|
|
347
|
+
"-t".to_string(),
|
|
348
|
+
session.to_string(),
|
|
349
|
+
]),
|
|
350
|
+
"negative control must also probe the persisted explicit endpoint"
|
|
351
|
+
);
|
|
352
|
+
let events = read_event_log_dir(&dir);
|
|
353
|
+
assert!(
|
|
354
|
+
events
|
|
355
|
+
.iter()
|
|
356
|
+
.any(|event| event.get("event").and_then(|v| v.as_str())
|
|
357
|
+
== Some("coordinator.session_missing")),
|
|
358
|
+
"genuine explicit endpoint miss must still emit coordinator.session_missing; got {events:?}"
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
212
362
|
// ── 2(a) tick TOLERATES a has-session TIMEOUT as Err (NOT a definitive miss) — LOCK ───────────────
|
|
213
363
|
#[test]
|
|
214
364
|
fn tick_tolerates_has_session_timeout_as_transport_err_not_session_missing() {
|
|
@@ -17,6 +17,8 @@ pub fn start_agent(
|
|
|
17
17
|
team: Option<&str>,
|
|
18
18
|
) -> Result<StartAgentOutcome, LifecycleError> {
|
|
19
19
|
let paths = lifecycle_paths(workspace, team)?;
|
|
20
|
+
let transport =
|
|
21
|
+
lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
|
|
20
22
|
start_agent_at_paths(
|
|
21
23
|
&paths.run_workspace,
|
|
22
24
|
&paths.spec_workspace,
|
|
@@ -25,7 +27,7 @@ pub fn start_agent(
|
|
|
25
27
|
open_display,
|
|
26
28
|
allow_fresh,
|
|
27
29
|
team,
|
|
28
|
-
&
|
|
30
|
+
&transport,
|
|
29
31
|
)
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -267,11 +269,15 @@ pub fn stop_agent(
|
|
|
267
269
|
agent_id: &AgentId,
|
|
268
270
|
team: Option<&str>,
|
|
269
271
|
) -> Result<StopAgentReport, LifecycleError> {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
+
let paths = lifecycle_paths(workspace, team)?;
|
|
273
|
+
let transport =
|
|
274
|
+
lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
|
|
275
|
+
stop_agent_at_paths(
|
|
276
|
+
&paths.run_workspace,
|
|
277
|
+
&paths.spec_workspace,
|
|
272
278
|
agent_id,
|
|
273
279
|
team,
|
|
274
|
-
&
|
|
280
|
+
&transport,
|
|
275
281
|
)
|
|
276
282
|
}
|
|
277
283
|
|
|
@@ -473,13 +479,22 @@ pub fn reset_agent(
|
|
|
473
479
|
open_display: bool,
|
|
474
480
|
team: Option<&str>,
|
|
475
481
|
) -> Result<ResetAgentOutcome, LifecycleError> {
|
|
476
|
-
|
|
477
|
-
|
|
482
|
+
if !discard_session {
|
|
483
|
+
return Ok(ResetAgentOutcome::Refused {
|
|
484
|
+
reason: ResetRefusal::DiscardSessionRequired,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
let paths = lifecycle_paths(workspace, team)?;
|
|
488
|
+
let transport =
|
|
489
|
+
lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
|
|
490
|
+
reset_agent_at_paths(
|
|
491
|
+
&paths.run_workspace,
|
|
492
|
+
&paths.spec_workspace,
|
|
478
493
|
agent_id,
|
|
479
494
|
discard_session,
|
|
480
495
|
open_display,
|
|
481
496
|
team,
|
|
482
|
-
&
|
|
497
|
+
&transport,
|
|
483
498
|
)
|
|
484
499
|
}
|
|
485
500
|
|
|
@@ -208,6 +208,38 @@ pub(super) fn start_coordinator_for_workspace(workspace: &Path) -> Result<bool,
|
|
|
208
208
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
pub(super) fn lifecycle_worker_tmux_backend_for_selected_state(
|
|
212
|
+
run_workspace: &Path,
|
|
213
|
+
team: Option<&str>,
|
|
214
|
+
) -> Result<crate::tmux_backend::TmuxBackend, LifecycleError> {
|
|
215
|
+
let (state, refusal) = crate::state::projection::resolve_team_scoped_state(run_workspace, team)
|
|
216
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
217
|
+
if let Some(refusal) = refusal {
|
|
218
|
+
let reason = refusal
|
|
219
|
+
.get("reason")
|
|
220
|
+
.and_then(|v| v.as_str())
|
|
221
|
+
.unwrap_or("team_target_unresolved");
|
|
222
|
+
let detail = refusal
|
|
223
|
+
.get("error")
|
|
224
|
+
.or_else(|| refusal.get("message"))
|
|
225
|
+
.and_then(|v| v.as_str())
|
|
226
|
+
.unwrap_or("");
|
|
227
|
+
return Err(LifecycleError::TeamSelect(format!("{reason}: {detail}")));
|
|
228
|
+
}
|
|
229
|
+
Ok(state
|
|
230
|
+
.as_ref()
|
|
231
|
+
.map(|state| lifecycle_worker_tmux_backend_for_state(run_workspace, state))
|
|
232
|
+
.unwrap_or_else(|| crate::tmux_backend::TmuxBackend::for_workspace(run_workspace)))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
pub(super) fn lifecycle_worker_tmux_backend_for_state(
|
|
236
|
+
run_workspace: &Path,
|
|
237
|
+
state: &serde_json::Value,
|
|
238
|
+
) -> crate::tmux_backend::TmuxBackend {
|
|
239
|
+
crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(run_workspace, Some(state))
|
|
240
|
+
.backend
|
|
241
|
+
}
|
|
242
|
+
|
|
211
243
|
pub(super) fn persist_effective_approval_policy_for_restart(
|
|
212
244
|
agent: &mut serde_json::Map<String, serde_json::Value>,
|
|
213
245
|
safety: &DangerousApproval,
|
|
@@ -22,12 +22,14 @@ pub fn restart_with_session_convergence_deadline(
|
|
|
22
22
|
team: Option<&str>,
|
|
23
23
|
session_converge_deadline_ms: Option<u64>,
|
|
24
24
|
) -> Result<RestartReport, LifecycleError> {
|
|
25
|
-
let
|
|
25
|
+
let paths = lifecycle_paths(workspace, team)?;
|
|
26
|
+
let transport =
|
|
27
|
+
lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
|
|
26
28
|
restart_with_transport_with_session_convergence_deadline(
|
|
27
29
|
workspace,
|
|
28
30
|
allow_fresh,
|
|
29
31
|
team,
|
|
30
|
-
&
|
|
32
|
+
&transport,
|
|
31
33
|
session_converge_deadline_ms,
|
|
32
34
|
None,
|
|
33
35
|
)
|
|
@@ -13,13 +13,17 @@ pub fn remove_agent(
|
|
|
13
13
|
force: bool,
|
|
14
14
|
team: Option<&str>,
|
|
15
15
|
) -> Result<RemoveAgentOutcome, LifecycleError> {
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
let paths = lifecycle_paths(workspace, team)?;
|
|
17
|
+
let transport =
|
|
18
|
+
lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
|
|
19
|
+
remove_agent_at_paths(
|
|
20
|
+
&paths.run_workspace,
|
|
21
|
+
&paths.spec_workspace,
|
|
18
22
|
agent_id,
|
|
19
23
|
from_spec,
|
|
20
24
|
force,
|
|
21
25
|
team,
|
|
22
|
-
&
|
|
26
|
+
&transport,
|
|
23
27
|
)
|
|
24
28
|
}
|
|
25
29
|
|
|
@@ -260,6 +260,228 @@ fn bug_a_stop_agent_team_dir_input_kills_existing_window_real_machine() {
|
|
|
260
260
|
assert!(report.stopped, "existing worker window must be killed, not silently reported absent");
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
struct EnvVarGuard {
|
|
264
|
+
key: &'static str,
|
|
265
|
+
previous: Option<String>,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
impl EnvVarGuard {
|
|
269
|
+
fn set(key: &'static str, value: &str) -> Self {
|
|
270
|
+
let previous = std::env::var(key).ok();
|
|
271
|
+
unsafe {
|
|
272
|
+
std::env::set_var(key, value);
|
|
273
|
+
}
|
|
274
|
+
Self { key, previous }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
impl Drop for EnvVarGuard {
|
|
279
|
+
fn drop(&mut self) {
|
|
280
|
+
unsafe {
|
|
281
|
+
if let Some(value) = self.previous.take() {
|
|
282
|
+
std::env::set_var(self.key, value);
|
|
283
|
+
} else {
|
|
284
|
+
std::env::remove_var(self.key);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
struct TmuxShim {
|
|
291
|
+
log: std::path::PathBuf,
|
|
292
|
+
_path: EnvVarGuard,
|
|
293
|
+
_log: EnvVarGuard,
|
|
294
|
+
_endpoint: EnvVarGuard,
|
|
295
|
+
_session: EnvVarGuard,
|
|
296
|
+
_pane: EnvVarGuard,
|
|
297
|
+
_real_tmux: EnvVarGuard,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
fn install_e27_tmux_shim(expected_endpoint: &str, session_name: &str) -> TmuxShim {
|
|
301
|
+
use std::os::unix::fs::PermissionsExt;
|
|
302
|
+
|
|
303
|
+
let root = temp_ws().join("e27_tmux_shim");
|
|
304
|
+
let bin = root.join("bin");
|
|
305
|
+
std::fs::create_dir_all(&bin).unwrap();
|
|
306
|
+
let log = root.join("tmux-argv.log");
|
|
307
|
+
let tmux = bin.join("tmux");
|
|
308
|
+
let real_tmux = std::process::Command::new("sh")
|
|
309
|
+
.arg("-lc")
|
|
310
|
+
.arg("command -v tmux || true")
|
|
311
|
+
.output()
|
|
312
|
+
.ok()
|
|
313
|
+
.and_then(|out| String::from_utf8(out.stdout).ok())
|
|
314
|
+
.map(|s| s.trim().to_string())
|
|
315
|
+
.filter(|s| !s.is_empty())
|
|
316
|
+
.unwrap_or_default();
|
|
317
|
+
std::fs::write(
|
|
318
|
+
&tmux,
|
|
319
|
+
r#"#!/bin/sh
|
|
320
|
+
set -eu
|
|
321
|
+
log="${TEAM_AGENT_E27_TMUX_LOG}"
|
|
322
|
+
expected="${TEAM_AGENT_E27_EXPECTED_ENDPOINT}"
|
|
323
|
+
session="${TEAM_AGENT_E27_SESSION_NAME}"
|
|
324
|
+
pane="${TEAM_AGENT_E27_PANE_ID}"
|
|
325
|
+
track=0
|
|
326
|
+
case "$*" in
|
|
327
|
+
*"$expected"*|*"$session"*|*"$pane"*) track=1 ;;
|
|
328
|
+
esac
|
|
329
|
+
if [ "$track" != 1 ]; then
|
|
330
|
+
if [ -n "${TEAM_AGENT_E27_REAL_TMUX}" ]; then
|
|
331
|
+
exec "${TEAM_AGENT_E27_REAL_TMUX}" "$@"
|
|
332
|
+
fi
|
|
333
|
+
exit 127
|
|
334
|
+
fi
|
|
335
|
+
printf '%s\n' "$*" >> "$log"
|
|
336
|
+
case "$*" in
|
|
337
|
+
*"-S $expected"*) ;;
|
|
338
|
+
*)
|
|
339
|
+
echo "missing expected socket $expected: $*" >&2
|
|
340
|
+
exit 18
|
|
341
|
+
;;
|
|
342
|
+
esac
|
|
343
|
+
case "$*" in
|
|
344
|
+
*"display-message -p -t $pane #{pane_id}"*)
|
|
345
|
+
printf '%s\n' "$pane"
|
|
346
|
+
exit 0
|
|
347
|
+
;;
|
|
348
|
+
*"display-message -p -t $session:alpha #{pane_id}"*)
|
|
349
|
+
printf '%%9288\n'
|
|
350
|
+
exit 0
|
|
351
|
+
;;
|
|
352
|
+
*"list-windows -t $session -F #{window_name}"*)
|
|
353
|
+
exit 0
|
|
354
|
+
;;
|
|
355
|
+
*"has-session -t $session"*)
|
|
356
|
+
exit 0
|
|
357
|
+
;;
|
|
358
|
+
*)
|
|
359
|
+
exit 0
|
|
360
|
+
;;
|
|
361
|
+
esac
|
|
362
|
+
"#,
|
|
363
|
+
)
|
|
364
|
+
.unwrap();
|
|
365
|
+
let mut perms = std::fs::metadata(&tmux).unwrap().permissions();
|
|
366
|
+
perms.set_mode(0o755);
|
|
367
|
+
std::fs::set_permissions(&tmux, perms).unwrap();
|
|
368
|
+
let old_path = std::env::var("PATH").unwrap_or_default();
|
|
369
|
+
TmuxShim {
|
|
370
|
+
log: log.clone(),
|
|
371
|
+
_path: EnvVarGuard::set("PATH", &format!("{}:{old_path}", bin.display())),
|
|
372
|
+
_log: EnvVarGuard::set("TEAM_AGENT_E27_TMUX_LOG", &log.to_string_lossy()),
|
|
373
|
+
_endpoint: EnvVarGuard::set("TEAM_AGENT_E27_EXPECTED_ENDPOINT", expected_endpoint),
|
|
374
|
+
_session: EnvVarGuard::set("TEAM_AGENT_E27_SESSION_NAME", session_name),
|
|
375
|
+
_pane: EnvVarGuard::set("TEAM_AGENT_E27_PANE_ID", "%9277"),
|
|
376
|
+
_real_tmux: EnvVarGuard::set("TEAM_AGENT_E27_REAL_TMUX", &real_tmux),
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
fn seed_e27_attached_socket_state(ws: &std::path::Path, endpoint: &str, session_name: &str) {
|
|
381
|
+
let mut state = crate::state::persist::load_runtime_state(ws).unwrap();
|
|
382
|
+
let obj = state.as_object_mut().unwrap();
|
|
383
|
+
obj.insert("session_name".to_string(), json!(session_name));
|
|
384
|
+
obj.insert("tmux_endpoint".to_string(), json!(endpoint));
|
|
385
|
+
obj.insert("tmux_socket".to_string(), json!(endpoint));
|
|
386
|
+
let alpha = obj
|
|
387
|
+
.get_mut("agents")
|
|
388
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
389
|
+
.and_then(|agents| agents.get_mut("alpha"))
|
|
390
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
391
|
+
.unwrap();
|
|
392
|
+
alpha.insert("status".to_string(), json!("running"));
|
|
393
|
+
alpha.insert("provider".to_string(), json!("fake"));
|
|
394
|
+
alpha.insert("window".to_string(), json!("alpha"));
|
|
395
|
+
alpha.insert("pane_id".to_string(), json!("%9277"));
|
|
396
|
+
alpha.insert("session_id".to_string(), json!("old-session"));
|
|
397
|
+
crate::state::persist::save_runtime_state(ws, &state).unwrap();
|
|
398
|
+
seed_healthy_coordinator(ws);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
fn assert_only_expected_socket_used(log: &std::path::Path, expected_endpoint: &str) {
|
|
402
|
+
let raw = std::fs::read_to_string(log).unwrap();
|
|
403
|
+
assert!(!raw.is_empty(), "tmux shim was not invoked");
|
|
404
|
+
assert!(
|
|
405
|
+
!raw.contains("-L ta-"),
|
|
406
|
+
"lifecycle worker operation must not use workspace-derived -L ta-* socket; argv log:\n{raw}"
|
|
407
|
+
);
|
|
408
|
+
for line in raw.lines() {
|
|
409
|
+
assert!(
|
|
410
|
+
line.contains(&format!("-S {expected_endpoint}")),
|
|
411
|
+
"tmux argv must use state endpoint {expected_endpoint}; got line {line:?}; full log:\n{raw}"
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
#[test]
|
|
417
|
+
#[serial_test::serial(env)]
|
|
418
|
+
fn e27_stop_agent_uses_attached_explicit_state_socket() {
|
|
419
|
+
let endpoint = "/tmp/loop13-e27-explicit.sock";
|
|
420
|
+
let session_name = "team-e27-explicit-stop";
|
|
421
|
+
let ws = lanea_team_ws("running");
|
|
422
|
+
seed_e27_attached_socket_state(&ws, endpoint, session_name);
|
|
423
|
+
let shim = install_e27_tmux_shim(endpoint, session_name);
|
|
424
|
+
|
|
425
|
+
let report = stop_agent(&ws, &aid("alpha"), None).expect("stop-agent");
|
|
426
|
+
|
|
427
|
+
assert!(report.stopped, "attached explicit-socket worker should be stopped");
|
|
428
|
+
assert_only_expected_socket_used(&shim.log, endpoint);
|
|
429
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
430
|
+
assert_eq!(
|
|
431
|
+
state.pointer("/agents/alpha/status").and_then(serde_json::Value::as_str),
|
|
432
|
+
Some("stopped")
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
#[test]
|
|
437
|
+
#[serial_test::serial(env)]
|
|
438
|
+
fn e27_reset_agent_uses_attached_explicit_state_socket_for_stop_and_spawn() {
|
|
439
|
+
let endpoint = "/tmp/loop13-e27-explicit.sock";
|
|
440
|
+
let session_name = "team-e27-explicit-reset";
|
|
441
|
+
let ws = lanea_team_ws("running");
|
|
442
|
+
seed_e27_attached_socket_state(&ws, endpoint, session_name);
|
|
443
|
+
let shim = install_e27_tmux_shim(endpoint, session_name);
|
|
444
|
+
|
|
445
|
+
let outcome = reset_agent(&ws, &aid("alpha"), true, false, None).expect("reset-agent");
|
|
446
|
+
|
|
447
|
+
assert!(
|
|
448
|
+
matches!(outcome, ResetAgentOutcome::Reset { .. }),
|
|
449
|
+
"reset-agent should complete over attached explicit socket; got {outcome:?}"
|
|
450
|
+
);
|
|
451
|
+
assert_only_expected_socket_used(&shim.log, endpoint);
|
|
452
|
+
let raw = std::fs::read_to_string(&shim.log).unwrap();
|
|
453
|
+
assert!(raw.contains("kill-pane"), "reset must stop the old pane first; argv log:\n{raw}");
|
|
454
|
+
assert!(
|
|
455
|
+
raw.contains("new-window") || raw.contains("new-session"),
|
|
456
|
+
"reset must respawn the worker through the same endpoint; argv log:\n{raw}"
|
|
457
|
+
);
|
|
458
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
459
|
+
assert_ne!(
|
|
460
|
+
state.pointer("/agents/alpha/session_id").and_then(serde_json::Value::as_str),
|
|
461
|
+
Some("old-session"),
|
|
462
|
+
"discard-session must not preserve the old session id"
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
#[test]
|
|
467
|
+
#[serial_test::serial(env)]
|
|
468
|
+
fn e27_stop_agent_expands_short_state_socket_name() {
|
|
469
|
+
let short = "ta-loop13-e27-short";
|
|
470
|
+
let session_name = "team-e27-short-stop";
|
|
471
|
+
let expected = crate::tmux_backend::socket_path_for_name(short)
|
|
472
|
+
.unwrap()
|
|
473
|
+
.to_string_lossy()
|
|
474
|
+
.to_string();
|
|
475
|
+
let ws = lanea_team_ws("running");
|
|
476
|
+
seed_e27_attached_socket_state(&ws, short, session_name);
|
|
477
|
+
let shim = install_e27_tmux_shim(&expected, session_name);
|
|
478
|
+
|
|
479
|
+
let report = stop_agent(&ws, &aid("alpha"), None).expect("stop-agent short endpoint");
|
|
480
|
+
|
|
481
|
+
assert!(report.stopped, "short endpoint worker should be stopped");
|
|
482
|
+
assert_only_expected_socket_used(&shim.log, &expected);
|
|
483
|
+
}
|
|
484
|
+
|
|
263
485
|
// ═════════════════════════════════════════════════════════════════════════
|
|
264
486
|
// WAVE-2 · LANE A v2 — DEEPENED byte-parity REDs (stop / reset / remove / fork).
|
|
265
487
|
//
|
|
@@ -188,6 +188,29 @@ impl TmuxSocketEndpoint {
|
|
|
188
188
|
}
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
192
|
+
pub(crate) enum RuntimeTmuxEndpointSource {
|
|
193
|
+
StateTmuxEndpoint,
|
|
194
|
+
StateTmuxSocket,
|
|
195
|
+
WorkspaceFallback,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
impl RuntimeTmuxEndpointSource {
|
|
199
|
+
pub(crate) fn as_str(self) -> &'static str {
|
|
200
|
+
match self {
|
|
201
|
+
Self::StateTmuxEndpoint => "state.tmux_endpoint",
|
|
202
|
+
Self::StateTmuxSocket => "state.tmux_socket",
|
|
203
|
+
Self::WorkspaceFallback => "workspace_fallback",
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
pub(crate) struct RuntimeTmuxBackendSelection {
|
|
209
|
+
pub(crate) backend: TmuxBackend,
|
|
210
|
+
pub(crate) tmux_endpoint_used: Option<String>,
|
|
211
|
+
pub(crate) tmux_endpoint_source: RuntimeTmuxEndpointSource,
|
|
212
|
+
}
|
|
213
|
+
|
|
191
214
|
impl TmuxBackend {
|
|
192
215
|
/// Backend bound to the real `tmux` subprocess on the SHARED default socket (no `-L`).
|
|
193
216
|
/// Non-team callers + existing argv/unit tests stay unaffected.
|
|
@@ -338,6 +361,70 @@ impl TmuxBackend {
|
|
|
338
361
|
}
|
|
339
362
|
}
|
|
340
363
|
|
|
364
|
+
pub(crate) fn tmux_backend_for_runtime_state_or_workspace(
|
|
365
|
+
workspace: &Path,
|
|
366
|
+
state: Option<&serde_json::Value>,
|
|
367
|
+
) -> RuntimeTmuxBackendSelection {
|
|
368
|
+
let (backend, source) =
|
|
369
|
+
if let Some((endpoint, source)) = runtime_tmux_endpoint_from_state(state) {
|
|
370
|
+
(TmuxBackend::for_tmux_endpoint(endpoint), source)
|
|
371
|
+
} else {
|
|
372
|
+
(
|
|
373
|
+
TmuxBackend::for_workspace(workspace),
|
|
374
|
+
RuntimeTmuxEndpointSource::WorkspaceFallback,
|
|
375
|
+
)
|
|
376
|
+
};
|
|
377
|
+
RuntimeTmuxBackendSelection {
|
|
378
|
+
tmux_endpoint_used: backend.tmux_endpoint(),
|
|
379
|
+
backend,
|
|
380
|
+
tmux_endpoint_source: source,
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#[cfg(test)]
|
|
385
|
+
pub(crate) fn tmux_backend_with_runner_for_runtime_state_or_workspace(
|
|
386
|
+
runner: Box<dyn CommandRunner>,
|
|
387
|
+
workspace: &Path,
|
|
388
|
+
state: Option<&serde_json::Value>,
|
|
389
|
+
) -> RuntimeTmuxBackendSelection {
|
|
390
|
+
let (backend, source) =
|
|
391
|
+
if let Some((endpoint, source)) = runtime_tmux_endpoint_from_state(state) {
|
|
392
|
+
(
|
|
393
|
+
TmuxBackend::with_runner_for_tmux_endpoint(runner, endpoint),
|
|
394
|
+
source,
|
|
395
|
+
)
|
|
396
|
+
} else {
|
|
397
|
+
(
|
|
398
|
+
TmuxBackend::with_runner_for_workspace(runner, workspace),
|
|
399
|
+
RuntimeTmuxEndpointSource::WorkspaceFallback,
|
|
400
|
+
)
|
|
401
|
+
};
|
|
402
|
+
RuntimeTmuxBackendSelection {
|
|
403
|
+
tmux_endpoint_used: backend.tmux_endpoint(),
|
|
404
|
+
backend,
|
|
405
|
+
tmux_endpoint_source: source,
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
fn runtime_tmux_endpoint_from_state(
|
|
410
|
+
state: Option<&serde_json::Value>,
|
|
411
|
+
) -> Option<(&str, RuntimeTmuxEndpointSource)> {
|
|
412
|
+
state.and_then(|state| {
|
|
413
|
+
state
|
|
414
|
+
.get("tmux_endpoint")
|
|
415
|
+
.and_then(|v| v.as_str())
|
|
416
|
+
.filter(|endpoint| !endpoint.is_empty())
|
|
417
|
+
.map(|endpoint| (endpoint, RuntimeTmuxEndpointSource::StateTmuxEndpoint))
|
|
418
|
+
.or_else(|| {
|
|
419
|
+
state
|
|
420
|
+
.get("tmux_socket")
|
|
421
|
+
.and_then(|v| v.as_str())
|
|
422
|
+
.filter(|endpoint| !endpoint.is_empty())
|
|
423
|
+
.map(|endpoint| (endpoint, RuntimeTmuxEndpointSource::StateTmuxSocket))
|
|
424
|
+
})
|
|
425
|
+
})
|
|
426
|
+
}
|
|
427
|
+
|
|
341
428
|
/// CP-1 socket name: SHORT + DETERMINISTIC per canonical workspace path. `ta-` + 12 hex chars of a
|
|
342
429
|
/// stable FNV-1a hash over the canonicalized path. AF_UNIX `sun_path` is ~104 chars and the socket
|
|
343
430
|
/// lives at `/tmp/tmux-<uid>/<name>`, so we must NOT use the (~88-char) session name. §10: a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.20",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.3.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.3.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.3.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.3.20",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.3.20",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.3.20"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|