@team-agent/installer 0.5.38 → 0.5.40
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/diagnose.rs +28 -3
- package/crates/team-agent/src/cli/mod.rs +24 -0
- package/crates/team-agent/src/lifecycle/display.rs +58 -121
- package/crates/team-agent/src/lifecycle/restart/common.rs +18 -5
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +146 -21
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +11 -1
- package/crates/team-agent/src/lifecycle/types.rs +21 -0
- package/crates/team-agent/src/tmux_backend.rs +175 -0
- package/crates/team-agent/src/transport.rs +38 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -3,6 +3,22 @@ use super::*;
|
|
|
3
3
|
use crate::provider::wire::{command_name, parse_provider, provider_wire};
|
|
4
4
|
use crate::transport::Transport;
|
|
5
5
|
|
|
6
|
+
/// 0.5.39 Slice 1 (tmux-server-death-locate §11.1 B): classify a tmux
|
|
7
|
+
/// transport error string into a diagnose issue id. When the underlying
|
|
8
|
+
/// tmux subprocess stderr contains `server exited unexpectedly`, the
|
|
9
|
+
/// tmux server itself crashed (upstream tmux 3.6a control-mode +
|
|
10
|
+
/// broadcast attach/detach jitter is one known trigger) — the physical
|
|
11
|
+
/// layer that disappeared is the whole server, not just this team's
|
|
12
|
+
/// session. Otherwise, callers keep the legacy `tmux_session_missing`
|
|
13
|
+
/// classification.
|
|
14
|
+
pub(crate) fn classify_tmux_server_error(error_text: &str) -> &'static str {
|
|
15
|
+
if error_text.contains("server exited unexpectedly") {
|
|
16
|
+
"tmux_server_crashed"
|
|
17
|
+
} else {
|
|
18
|
+
"tmux_session_missing"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
6
22
|
pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value, Value) {
|
|
7
23
|
let mut issues = Vec::new();
|
|
8
24
|
let mut repairs = Vec::new();
|
|
@@ -31,11 +47,20 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
|
|
|
31
47
|
));
|
|
32
48
|
}
|
|
33
49
|
Err(error) => {
|
|
34
|
-
|
|
50
|
+
// 0.5.39 Slice 1 (tmux-server-death-locate §11.1 B):
|
|
51
|
+
// when the transport error stderr matches "server exited
|
|
52
|
+
// unexpectedly", the physical layer that disappeared is
|
|
53
|
+
// the tmux server itself — not just this team's session.
|
|
54
|
+
// Surface `tmux_server_crashed` so the user's next
|
|
55
|
+
// action is a coordinator/host-level recovery, not a
|
|
56
|
+
// per-agent `restart <agent>`.
|
|
57
|
+
let error_str = error.to_string();
|
|
58
|
+
let issue_id = classify_tmux_server_error(&error_str);
|
|
59
|
+
issues.push(json!(issue_id));
|
|
35
60
|
let mut hint =
|
|
36
|
-
recovery_hint(session_name,
|
|
61
|
+
recovery_hint(session_name, issue_id, "team-agent diagnose");
|
|
37
62
|
if let Some(obj) = hint.as_object_mut() {
|
|
38
|
-
obj.insert("reason".to_string(), Value::String(
|
|
63
|
+
obj.insert("reason".to_string(), Value::String(error_str));
|
|
39
64
|
}
|
|
40
65
|
repairs.push(hint);
|
|
41
66
|
}
|
|
@@ -3339,6 +3339,30 @@ pub mod lifecycle_port {
|
|
|
3339
3339
|
"invalid": invalid.iter().map(|w| w.worker_id.as_str()).collect::<Vec<_>>(),
|
|
3340
3340
|
"reminder": crate::cli::QUICK_START_REMINDER,
|
|
3341
3341
|
}),
|
|
3342
|
+
crate::lifecycle::RestartReport::RefusedBuildBeforeDestroyRequired {
|
|
3343
|
+
session_name,
|
|
3344
|
+
live_agents,
|
|
3345
|
+
error,
|
|
3346
|
+
} => {
|
|
3347
|
+
let repair_team = team
|
|
3348
|
+
.filter(|team| !team.is_empty())
|
|
3349
|
+
.unwrap_or(session_name.as_str());
|
|
3350
|
+
let shutdown_scoped =
|
|
3351
|
+
format!("team-agent shutdown --team {repair_team}");
|
|
3352
|
+
json!({
|
|
3353
|
+
"ok": false,
|
|
3354
|
+
"status": "refused_build_before_destroy",
|
|
3355
|
+
"reason": "build_before_destroy_required",
|
|
3356
|
+
"session_name": session_name,
|
|
3357
|
+
"live_agents": live_agents,
|
|
3358
|
+
"error": error,
|
|
3359
|
+
"next_actions": [
|
|
3360
|
+
shutdown_scoped,
|
|
3361
|
+
"team-agent restart",
|
|
3362
|
+
],
|
|
3363
|
+
"reminder": crate::cli::QUICK_START_REMINDER,
|
|
3364
|
+
})
|
|
3365
|
+
}
|
|
3342
3366
|
crate::lifecycle::RestartReport::RefusedDirtyTopology {
|
|
3343
3367
|
session_name,
|
|
3344
3368
|
reason,
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
//! lifecycle::display —— 能力门 / 后端解析 / 开关 / rebind 后重建。
|
|
2
|
+
//!
|
|
3
|
+
//! 0.5.39 Slice 1 (tmux-server-death-locate §7 Slice 1): all tmux
|
|
4
|
+
//! session/window/pane operations here MUST go through the workspace's
|
|
5
|
+
//! scoped `TmuxBackend` — raw `Command::new("tmux")` / ambient
|
|
6
|
+
//! `run_tmux(...)` helpers in this file inherit ambient `$TMUX` and can
|
|
7
|
+
//! kill sessions on the wrong socket. Ambient leader-pane env probes
|
|
8
|
+
//! (`display-message` reading $TMUX to discover which session invoked
|
|
9
|
+
//! us) live in `tmux_backend::probe_ambient_leader_pane_info` — that
|
|
10
|
+
//! probe is definitionally ambient (its whole job is "which session am I
|
|
11
|
+
//! in") and stays outside this module.
|
|
2
12
|
|
|
3
13
|
use std::collections::BTreeMap;
|
|
4
14
|
use std::path::Path;
|
|
5
|
-
use std::process::Command;
|
|
6
15
|
|
|
7
|
-
use crate::transport::{PaneId, SessionName, WindowName};
|
|
16
|
+
use crate::transport::{PaneId, SessionName, Target, Transport, WindowName};
|
|
8
17
|
|
|
9
18
|
use super::*;
|
|
10
19
|
|
|
@@ -239,6 +248,12 @@ fn close_adaptive_displays(
|
|
|
239
248
|
orphans_cleaned: Vec::new(),
|
|
240
249
|
});
|
|
241
250
|
};
|
|
251
|
+
// 0.5.39 Slice 1: build workspace-scoped tmux transport once and
|
|
252
|
+
// route every destructive tmux op below through it (no ambient
|
|
253
|
+
// $TMUX). The kill_* Transport methods carry the workspace socket
|
|
254
|
+
// (`tmux -L <socket>`) so display cleanup cannot cross-kill a session
|
|
255
|
+
// on the user's default tmux server.
|
|
256
|
+
let transport = crate::transport_factory::tmux_workspace_transport(workspace);
|
|
242
257
|
let mut closed = Vec::new();
|
|
243
258
|
let mut orphans_cleaned = Vec::new();
|
|
244
259
|
let mut seen = std::collections::BTreeSet::new();
|
|
@@ -258,13 +273,13 @@ fn close_adaptive_displays(
|
|
|
258
273
|
}
|
|
259
274
|
for target in display_identifiers(display, agent, agent_id, session_name, &state) {
|
|
260
275
|
if seen.insert(target.clone()) {
|
|
261
|
-
kill_display_target(&target);
|
|
276
|
+
kill_display_target(&transport, &target);
|
|
262
277
|
closed.push(target);
|
|
263
278
|
}
|
|
264
279
|
}
|
|
265
280
|
}
|
|
266
281
|
if let Some(leader_session) = adaptive_leader_session(&state) {
|
|
267
|
-
for target in close_adaptive_windows(leader_session.as_str(), session_name.as_str()) {
|
|
282
|
+
for target in close_adaptive_windows(&transport, leader_session.as_str(), session_name.as_str()) {
|
|
268
283
|
orphans_cleaned.push(target.clone());
|
|
269
284
|
if seen.insert(target.clone()) {
|
|
270
285
|
closed.push(target);
|
|
@@ -351,13 +366,6 @@ struct LeaderTmuxInfo {
|
|
|
351
366
|
pane: Option<String>,
|
|
352
367
|
}
|
|
353
368
|
|
|
354
|
-
#[derive(Debug)]
|
|
355
|
-
struct TmuxOutput {
|
|
356
|
-
ok: bool,
|
|
357
|
-
stdout: String,
|
|
358
|
-
stderr: String,
|
|
359
|
-
}
|
|
360
|
-
|
|
361
369
|
fn in_wsl() -> bool {
|
|
362
370
|
std::env::var("WSL_DISTRO_NAME").is_ok_and(|value| !value.is_empty())
|
|
363
371
|
|| std::env::var("WSL_INTEROP").is_ok_and(|value| !value.is_empty())
|
|
@@ -369,52 +377,11 @@ fn running_inside_tmux() -> bool {
|
|
|
369
377
|
}
|
|
370
378
|
|
|
371
379
|
fn current_leader_tmux_info() -> Option<LeaderTmuxInfo> {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
commands.push(vec![
|
|
378
|
-
"display-message".to_string(),
|
|
379
|
-
"-p".to_string(),
|
|
380
|
-
"-t".to_string(),
|
|
381
|
-
pane.to_string(),
|
|
382
|
-
"-F".to_string(),
|
|
383
|
-
"#{session_name}\t#{pane_id}".to_string(),
|
|
384
|
-
]);
|
|
385
|
-
commands.push(vec![
|
|
386
|
-
"display-message".to_string(),
|
|
387
|
-
"-p".to_string(),
|
|
388
|
-
"-t".to_string(),
|
|
389
|
-
pane.to_string(),
|
|
390
|
-
"-F".to_string(),
|
|
391
|
-
"#{session_name}".to_string(),
|
|
392
|
-
]);
|
|
393
|
-
}
|
|
394
|
-
if std::env::var("TMUX").is_ok_and(|value| !value.is_empty()) {
|
|
395
|
-
commands.push(vec![
|
|
396
|
-
"display-message".to_string(),
|
|
397
|
-
"-p".to_string(),
|
|
398
|
-
"-F".to_string(),
|
|
399
|
-
"#{session_name}\t#{pane_id}".to_string(),
|
|
400
|
-
]);
|
|
401
|
-
commands.push(vec![
|
|
402
|
-
"display-message".to_string(),
|
|
403
|
-
"-p".to_string(),
|
|
404
|
-
"-F".to_string(),
|
|
405
|
-
"#{session_name}".to_string(),
|
|
406
|
-
]);
|
|
407
|
-
}
|
|
408
|
-
for command in commands {
|
|
409
|
-
let args = command.iter().map(String::as_str).collect::<Vec<_>>();
|
|
410
|
-
if let Some(parsed) = run_tmux(&args)
|
|
411
|
-
.ok()
|
|
412
|
-
.and_then(|out| parse_tmux_info(&out.stdout))
|
|
413
|
-
{
|
|
414
|
-
return Some(parsed);
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
None
|
|
380
|
+
// 0.5.39 Slice 1: ambient probe lives in `tmux_backend` — the single
|
|
381
|
+
// controlled exception to the "all tmux ops through socket-scoped
|
|
382
|
+
// transport" rule (its whole job is "which session am I in?").
|
|
383
|
+
crate::tmux_backend::probe_ambient_leader_pane_info()
|
|
384
|
+
.map(|(session, pane)| LeaderTmuxInfo { session, pane })
|
|
418
385
|
}
|
|
419
386
|
|
|
420
387
|
fn env_leader_tmux_info() -> Option<LeaderTmuxInfo> {
|
|
@@ -428,44 +395,29 @@ fn env_leader_tmux_info() -> Option<LeaderTmuxInfo> {
|
|
|
428
395
|
Some(LeaderTmuxInfo { session, pane })
|
|
429
396
|
}
|
|
430
397
|
|
|
431
|
-
fn
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
Some(LeaderTmuxInfo {
|
|
437
|
-
session: (*session).to_string(),
|
|
438
|
-
pane: (!pane.is_empty()).then(|| (*pane).to_string()),
|
|
439
|
-
})
|
|
440
|
-
}
|
|
441
|
-
[pane, session, ..] if pane.starts_with('%') && !session.is_empty() => {
|
|
442
|
-
Some(LeaderTmuxInfo {
|
|
443
|
-
session: (*session).to_string(),
|
|
444
|
-
pane: Some((*pane).to_string()),
|
|
445
|
-
})
|
|
446
|
-
}
|
|
447
|
-
[session] if !session.is_empty() && !session.starts_with('%') => Some(LeaderTmuxInfo {
|
|
448
|
-
session: (*session).to_string(),
|
|
449
|
-
pane: None,
|
|
450
|
-
}),
|
|
451
|
-
_ => None,
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
fn close_adaptive_windows(leader_session: &str, session_name: &str) -> Vec<String> {
|
|
398
|
+
fn close_adaptive_windows(
|
|
399
|
+
transport: &dyn Transport,
|
|
400
|
+
leader_session: &str,
|
|
401
|
+
session_name: &str,
|
|
402
|
+
) -> Vec<String> {
|
|
456
403
|
let prefix = format!("team-agent:{session_name}:overview");
|
|
457
|
-
|
|
458
|
-
|
|
404
|
+
// 0.5.39 Slice 1: `Transport::list_windows` uses the workspace socket
|
|
405
|
+
// — if `leader_session` doesn't exist on that socket, list_windows
|
|
406
|
+
// returns Err/empty and we bail without touching another server.
|
|
407
|
+
let Ok(windows) = transport.list_windows(&SessionName::new(leader_session.to_string())) else {
|
|
459
408
|
return Vec::new();
|
|
460
409
|
};
|
|
461
|
-
|
|
462
|
-
.
|
|
463
|
-
.
|
|
464
|
-
|
|
465
|
-
let window = line.trim();
|
|
410
|
+
windows
|
|
411
|
+
.into_iter()
|
|
412
|
+
.filter_map(|window| {
|
|
413
|
+
let window = window.as_str().to_string();
|
|
466
414
|
if window == prefix || window.starts_with(&format!("{prefix}-")) {
|
|
467
|
-
let
|
|
468
|
-
|
|
415
|
+
let target_string = format!("{leader_session}:{window}");
|
|
416
|
+
let target = Target::SessionWindow {
|
|
417
|
+
session: SessionName::new(leader_session.to_string()),
|
|
418
|
+
window: WindowName::new(window),
|
|
419
|
+
};
|
|
420
|
+
transport.kill_window(&target).ok().map(|_| target_string)
|
|
469
421
|
} else {
|
|
470
422
|
None
|
|
471
423
|
}
|
|
@@ -473,18 +425,23 @@ fn close_adaptive_windows(leader_session: &str, session_name: &str) -> Vec<Strin
|
|
|
473
425
|
.collect()
|
|
474
426
|
}
|
|
475
427
|
|
|
476
|
-
fn
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
428
|
+
fn kill_display_target(transport: &dyn Transport, target: &str) {
|
|
429
|
+
// 0.5.39 Slice 1: only kill window/pane — never fall back to
|
|
430
|
+
// kill-session on an ambiguous string shape. Locate §7 Slice 1:
|
|
431
|
+
// "Remove the string-shape `kill-session` fallback unless target is a
|
|
432
|
+
// proven display-only session." The old code parsed any bare
|
|
433
|
+
// non-`%`/non-`:` target as a session and issued `kill-session`,
|
|
434
|
+
// which is exactly the unbounded blast radius we're closing.
|
|
435
|
+
if let Some((session, window)) = target.split_once(':') {
|
|
436
|
+
let _ = transport.kill_window(&Target::SessionWindow {
|
|
437
|
+
session: SessionName::new(session.to_string()),
|
|
438
|
+
window: WindowName::new(window.to_string()),
|
|
439
|
+
});
|
|
483
440
|
} else if target.starts_with('%') {
|
|
484
|
-
let _ =
|
|
485
|
-
} else {
|
|
486
|
-
let _ = run_tmux(&["kill-session", "-t", target]);
|
|
441
|
+
let _ = transport.kill_pane(&PaneId::new(target.to_string()));
|
|
487
442
|
}
|
|
443
|
+
// Bare session-name form is intentionally a no-op: display cleanup
|
|
444
|
+
// must not kill a whole session on shape guesswork.
|
|
488
445
|
}
|
|
489
446
|
|
|
490
447
|
fn adaptive_leader_session(state: &serde_json::Value) -> Option<String> {
|
|
@@ -525,23 +482,3 @@ fn string_field(display: &serde_json::Map<String, serde_json::Value>, key: &str)
|
|
|
525
482
|
.map(str::to_string)
|
|
526
483
|
}
|
|
527
484
|
|
|
528
|
-
fn run_tmux(args: &[&str]) -> Result<TmuxOutput, LifecycleError> {
|
|
529
|
-
let output = Command::new("tmux")
|
|
530
|
-
.args(args)
|
|
531
|
-
.output()
|
|
532
|
-
.map_err(|e| LifecycleError::StatePersist(format!("tmux {}: {e}", args.join(" "))))?;
|
|
533
|
-
let result = TmuxOutput {
|
|
534
|
-
ok: output.status.success(),
|
|
535
|
-
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
|
536
|
-
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
|
537
|
-
};
|
|
538
|
-
if result.ok {
|
|
539
|
-
Ok(result)
|
|
540
|
-
} else {
|
|
541
|
-
Err(LifecycleError::StatePersist(format!(
|
|
542
|
-
"tmux {}: {}",
|
|
543
|
-
args.join(" "),
|
|
544
|
-
result.stderr.trim()
|
|
545
|
-
)))
|
|
546
|
-
}
|
|
547
|
-
}
|
|
@@ -228,25 +228,35 @@ pub(super) fn spawn_agent_window(
|
|
|
228
228
|
);
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
// 0.5.39 Slice 2 (tmux-server-death-locate §7 Slice 2): route the
|
|
232
|
+
// primary worker spawn through the worker shell wrapper so provider
|
|
233
|
+
// exit leaves the pane at an interactive shell with an explicit exit
|
|
234
|
+
// marker instead of collapsing the pane into `[exited]`. This mirrors
|
|
235
|
+
// the leader wrapper's manual-tmux equivalence. The split path stays
|
|
236
|
+
// on plain spawn_split — split panes are display overlays, not the
|
|
237
|
+
// primary worker process.
|
|
238
|
+
let provider_label = crate::provider::wire::command_name(provider);
|
|
231
239
|
let result = if let Some(placement) = layout_placement {
|
|
232
240
|
if placement.starts_window {
|
|
233
241
|
if into_existing_session {
|
|
234
|
-
transport.
|
|
242
|
+
transport.spawn_into_with_worker_shell_wrapper(
|
|
235
243
|
session_name,
|
|
236
244
|
&window,
|
|
237
245
|
&plan.argv,
|
|
238
246
|
spawn_cwd,
|
|
239
247
|
&env,
|
|
240
248
|
&env_unset,
|
|
249
|
+
provider_label,
|
|
241
250
|
)
|
|
242
251
|
} else {
|
|
243
|
-
transport.
|
|
252
|
+
transport.spawn_first_with_worker_shell_wrapper(
|
|
244
253
|
session_name,
|
|
245
254
|
&window,
|
|
246
255
|
&plan.argv,
|
|
247
256
|
spawn_cwd,
|
|
248
257
|
&env,
|
|
249
258
|
&env_unset,
|
|
259
|
+
provider_label,
|
|
250
260
|
)
|
|
251
261
|
}
|
|
252
262
|
} else if !window_present_in_live(transport, session_name, &window)
|
|
@@ -264,13 +274,14 @@ pub(super) fn spawn_agent_window(
|
|
|
264
274
|
// hijack the developer worker's pane. Downgrade to spawn_into
|
|
265
275
|
// (new window named after agent_id) — canonical per-agent
|
|
266
276
|
// fallback the existing 7 workers use.
|
|
267
|
-
transport.
|
|
277
|
+
transport.spawn_into_with_worker_shell_wrapper(
|
|
268
278
|
session_name,
|
|
269
279
|
&WindowName::new(agent_id.as_str()),
|
|
270
280
|
&plan.argv,
|
|
271
281
|
spawn_cwd,
|
|
272
282
|
&env,
|
|
273
283
|
&env_unset,
|
|
284
|
+
provider_label,
|
|
274
285
|
)
|
|
275
286
|
} else {
|
|
276
287
|
// 0.3.28 Step 8: spawn_split must only fire from the display
|
|
@@ -286,22 +297,24 @@ pub(super) fn spawn_agent_window(
|
|
|
286
297
|
)
|
|
287
298
|
}
|
|
288
299
|
} else if into_existing_session {
|
|
289
|
-
transport.
|
|
300
|
+
transport.spawn_into_with_worker_shell_wrapper(
|
|
290
301
|
session_name,
|
|
291
302
|
&window,
|
|
292
303
|
&plan.argv,
|
|
293
304
|
spawn_cwd,
|
|
294
305
|
&env,
|
|
295
306
|
&env_unset,
|
|
307
|
+
provider_label,
|
|
296
308
|
)
|
|
297
309
|
} else {
|
|
298
|
-
transport.
|
|
310
|
+
transport.spawn_first_with_worker_shell_wrapper(
|
|
299
311
|
session_name,
|
|
300
312
|
&window,
|
|
301
313
|
&plan.argv,
|
|
302
314
|
spawn_cwd,
|
|
303
315
|
&env,
|
|
304
316
|
&env_unset,
|
|
317
|
+
provider_label,
|
|
305
318
|
)
|
|
306
319
|
};
|
|
307
320
|
let spawn = result.map_err(|e| LifecycleError::Transport(e.to_string()))?;
|
|
@@ -410,22 +410,27 @@ fn restart_with_selected_team_and_transport(
|
|
|
410
410
|
}
|
|
411
411
|
}
|
|
412
412
|
let session_name = state_session_name(&state);
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
413
|
+
// 0.5.40 Slice 3 (tmux-server-death-locate §7 Slice 3, first-version
|
|
414
|
+
// build-before-destroy). Pre-0.5.40 code UNCONDITIONALLY killed the
|
|
415
|
+
// live worker session here, then ran the spawn loop against a fresh
|
|
416
|
+
// session — which loses provider session state (Case B) and, under
|
|
417
|
+
// upstream tmux 3.6a private-server bugs, can cascade into whole-
|
|
418
|
+
// server death (Case A). Locate §7 Slice 3 requires: "restart must
|
|
419
|
+
// build/prove the replacement before retiring a currently live
|
|
420
|
+
// worker session." First-version narrow shape: defer the kill until
|
|
421
|
+
// AFTER the spawn loop proves the replacement is minimally viable,
|
|
422
|
+
// and refuse the kill on failure so the original session/state stay
|
|
423
|
+
// authoritative. Discriminator = `collect_live_agents_from_state`
|
|
424
|
+
// (state marks agents running with real pane_ids). When state has
|
|
425
|
+
// no authoritative live agents, the previous behavior (pre-spawn
|
|
426
|
+
// teardown of a stale empty session) is preserved so tests that
|
|
427
|
+
// seed a fake-live session without live agent rows still work.
|
|
428
|
+
let live_worker_session_deferred_teardown =
|
|
429
|
+
session_live_or_default(transport, &session_name, false)
|
|
430
|
+
&& !collect_live_agents_from_state(&state).is_empty();
|
|
431
|
+
if session_live_or_default(transport, &session_name, false)
|
|
432
|
+
&& !live_worker_session_deferred_teardown
|
|
433
|
+
{
|
|
429
434
|
eprintln!(
|
|
430
435
|
"team_agent::layout restart_precondition_warning worker_session=`{}` action=killing \
|
|
431
436
|
(post-Step-7 will refuse and direct user to recover; safe today because Step 2 \
|
|
@@ -451,6 +456,20 @@ fn restart_with_selected_team_and_transport(
|
|
|
451
456
|
}
|
|
452
457
|
phase_timer.emit(&selected.run_workspace, "restart.phase", "teardown");
|
|
453
458
|
phase_timer.emit(&selected.run_workspace, "restart.phase", "spawn_all");
|
|
459
|
+
// 0.5.40 Slice 3 (tmux-server-death-locate §7 Slice 3): when the
|
|
460
|
+
// pre-spawn teardown was DEFERRED (live worker session with
|
|
461
|
+
// authoritative running agents), snapshot the pre-spawn agent rows
|
|
462
|
+
// so we can restore them if the replacement build fails. This
|
|
463
|
+
// enforces the "old session/state stay authoritative on failure"
|
|
464
|
+
// invariant without threading a buffer through every state
|
|
465
|
+
// mutation. On success (all spawns viable) the mutations already
|
|
466
|
+
// written win; on failure the snapshot restores the original rows.
|
|
467
|
+
let build_before_destroy_agents_snapshot: Option<serde_json::Value> =
|
|
468
|
+
if live_worker_session_deferred_teardown {
|
|
469
|
+
state.get("agents").cloned()
|
|
470
|
+
} else {
|
|
471
|
+
None
|
|
472
|
+
};
|
|
454
473
|
let mut successful_agents: Vec<RestartedAgent> = Vec::new();
|
|
455
474
|
let mut failed_agents: Vec<RestartFailedAgent> = Vec::new();
|
|
456
475
|
let mut fatal_resume_failure = false;
|
|
@@ -599,6 +618,37 @@ fn restart_with_selected_team_and_transport(
|
|
|
599
618
|
};
|
|
600
619
|
let mut session_live = session_live_or_default(transport, &session_name, false);
|
|
601
620
|
if !session_live {
|
|
621
|
+
// 0.5.40 Slice 3: when we deferred the pre-spawn teardown
|
|
622
|
+
// because the original session was live with authoritative
|
|
623
|
+
// running agents, a mid-flight session disappearance
|
|
624
|
+
// classifies as tmux_server_crashed (0539 §11.1 B) — the
|
|
625
|
+
// whole server died out from under us. Do NOT cascade-pop
|
|
626
|
+
// previously-successful agents into `session_disappeared_after_spawn`:
|
|
627
|
+
// that path (a) rewrites state.agents.<popped>.status to
|
|
628
|
+
// restart_failed (losing the authoritative old row R3
|
|
629
|
+
// requires stays untouched), and (b) emits the exact
|
|
630
|
+
// cascade error string R3 forbids. Instead, refuse the
|
|
631
|
+
// build-before-destroy cohort: leave `successful_agents`
|
|
632
|
+
// alone (their live spawn already happened but the server
|
|
633
|
+
// died before we could prove them viable), stop the loop,
|
|
634
|
+
// and let the terminal Failed report attribute the failure
|
|
635
|
+
// to tmux_server_crashed via `restart_failure_phase`. The
|
|
636
|
+
// legacy path still runs when the pre-spawn teardown was
|
|
637
|
+
// taken (empty session case), because
|
|
638
|
+
// `live_worker_session_deferred_teardown` is false there.
|
|
639
|
+
if live_worker_session_deferred_teardown {
|
|
640
|
+
fatal_resume_failure = true;
|
|
641
|
+
let error = format!(
|
|
642
|
+
"tmux_server_crashed: session {} disappeared during replacement build; refusing to touch original agent state",
|
|
643
|
+
session_name.as_str()
|
|
644
|
+
);
|
|
645
|
+
failed_agents.push(restart_failed_agent(
|
|
646
|
+
decision,
|
|
647
|
+
"tmux_server_crashed",
|
|
648
|
+
error,
|
|
649
|
+
));
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
602
652
|
if let Some(previous) = successful_agents.pop() {
|
|
603
653
|
let error = format!(
|
|
604
654
|
"session_disappeared_after_spawn: provider_resume_exited for {}; session {} disappeared before spawning {}",
|
|
@@ -750,6 +800,38 @@ fn restart_with_selected_team_and_transport(
|
|
|
750
800
|
})
|
|
751
801
|
.map(|agent| agent.agent_id.as_str().to_string())
|
|
752
802
|
.collect::<Vec<_>>();
|
|
803
|
+
// 0.5.40 Slice 3 (tmux-server-death-locate §7 Slice 3): restore the
|
|
804
|
+
// pre-spawn agents snapshot when we deferred the teardown AND the
|
|
805
|
+
// replacement build failed. This is the "old session/state stay
|
|
806
|
+
// authoritative on failure" invariant made concrete — the mid-loop
|
|
807
|
+
// mark_agent_respawned/mark_agent_restart_failed calls already
|
|
808
|
+
// mutated `state.agents` in memory; restoring the snapshot rewinds
|
|
809
|
+
// those mutations so `save_restart_state_...` below persists rows
|
|
810
|
+
// byte-identical to what was loaded. Only rewind when
|
|
811
|
+
// `fatal_resume_failure` (whole cohort refused) OR when NO agent
|
|
812
|
+
// survived (`successful_agents.is_empty()` with failures) — on
|
|
813
|
+
// partial success the surviving replacements are legitimately new
|
|
814
|
+
// and the pre-restart rows are stale.
|
|
815
|
+
if let Some(snapshot) = build_before_destroy_agents_snapshot.as_ref() {
|
|
816
|
+
let replacement_failed =
|
|
817
|
+
fatal_resume_failure || (!failed_agents.is_empty() && successful_agents.is_empty());
|
|
818
|
+
if replacement_failed {
|
|
819
|
+
if let Some(state_obj) = state.as_object_mut() {
|
|
820
|
+
state_obj.insert("agents".to_string(), snapshot.clone());
|
|
821
|
+
if let Some(teams) = state_obj
|
|
822
|
+
.get_mut("teams")
|
|
823
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
824
|
+
{
|
|
825
|
+
if let Some(team_entry) = teams
|
|
826
|
+
.get_mut(selected.team_key.as_str())
|
|
827
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
828
|
+
{
|
|
829
|
+
team_entry.insert("agents".to_string(), snapshot.clone());
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
753
835
|
save_restart_state_with_lifecycle_topology_authority_and_capture_backfill_skip(
|
|
754
836
|
&selected.run_workspace,
|
|
755
837
|
&mut state,
|
|
@@ -2049,6 +2131,16 @@ fn restart_failure_phase(
|
|
|
2049
2131
|
phase: &'static str,
|
|
2050
2132
|
error: &str,
|
|
2051
2133
|
) -> &'static str {
|
|
2134
|
+
// 0.5.39 Slice 1 (tmux-server-death-locate §11.1 B): promote
|
|
2135
|
+
// spawn/readiness errors whose stderr contains "server exited
|
|
2136
|
+
// unexpectedly" to `tmux_server_crashed`, so downstream event
|
|
2137
|
+
// classification and diagnose next_actions ("team-agent diagnose")
|
|
2138
|
+
// don't misattribute a whole-server death to a per-agent provider
|
|
2139
|
+
// failure. Restart itself does not recover from server crashes in
|
|
2140
|
+
// 0.5.39 (§11.1 C is Slice 3, later car); it just classifies clearly.
|
|
2141
|
+
if is_tmux_server_crashed(error) {
|
|
2142
|
+
return "tmux_server_crashed";
|
|
2143
|
+
}
|
|
2052
2144
|
if is_resume_integrity_failure(decision, phase, error) {
|
|
2053
2145
|
"resume"
|
|
2054
2146
|
} else {
|
|
@@ -2056,6 +2148,10 @@ fn restart_failure_phase(
|
|
|
2056
2148
|
}
|
|
2057
2149
|
}
|
|
2058
2150
|
|
|
2151
|
+
fn is_tmux_server_crashed(error: &str) -> bool {
|
|
2152
|
+
error.contains("server exited unexpectedly")
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2059
2155
|
fn is_resume_integrity_failure(decision: &RestartedAgent, phase: &str, error: &str) -> bool {
|
|
2060
2156
|
if !matches!(decision.restart_mode, StartMode::Resumed) {
|
|
2061
2157
|
return false;
|
|
@@ -2349,7 +2445,7 @@ fn run_bounded_parallel_worker_spawns(
|
|
|
2349
2445
|
}
|
|
2350
2446
|
// Advance the gate so the next plan-order thread can
|
|
2351
2447
|
// start its own `spawn_agent_window` in parallel; a
|
|
2352
|
-
// deterministic
|
|
2448
|
+
// deterministic 10ms stagger between plan slots keeps
|
|
2353
2449
|
// pane_id assignment (which happens inside the
|
|
2354
2450
|
// transport at the tail of its call) ordered by plan
|
|
2355
2451
|
// even when each individual call sleeps for tens of
|
|
@@ -2362,11 +2458,14 @@ fn run_bounded_parallel_worker_spawns(
|
|
|
2362
2458
|
// per-slot stagger so pane_id assignment inside the
|
|
2363
2459
|
// transport happens in plan order even though the
|
|
2364
2460
|
// subsequent per-call sleeps overlap across threads.
|
|
2365
|
-
// 10ms per slot is enough headroom over OS scheduler
|
|
2366
|
-
//
|
|
2367
|
-
//
|
|
2461
|
+
// 10ms per slot is enough headroom over OS scheduler jitter
|
|
2462
|
+
// (previous 3/5ms values flaked on shared macOS gate
|
|
2463
|
+
// machines) while remaining well under real tmux
|
|
2368
2464
|
// new-window latency (~50-120ms) so the overlap
|
|
2369
|
-
// property required by R1 still holds.
|
|
2465
|
+
// property required by R1 still holds. Reverse guard for
|
|
2466
|
+
// this stagger lives in
|
|
2467
|
+
// `tests/parallel_spawn_stagger_reverse_guard.rs`
|
|
2468
|
+
// (0.5.38 cr observation D-k).
|
|
2370
2469
|
std::thread::sleep(std::time::Duration::from_millis(slot as u64 * 10));
|
|
2371
2470
|
let spawn_start = std::time::Instant::now();
|
|
2372
2471
|
let outcome = match spawn_agent_window(
|
|
@@ -3056,6 +3155,32 @@ fn load_endpoint_convergence_runtime_spec(
|
|
|
3056
3155
|
Ok(Some(spec))
|
|
3057
3156
|
}
|
|
3058
3157
|
|
|
3158
|
+
/// 0.5.40 Slice 3: collect the set of agent ids that state marks as
|
|
3159
|
+
/// running with a real pane_id. "Running with pane_id" is the
|
|
3160
|
+
/// discriminator between "state has an authoritative live team the
|
|
3161
|
+
/// user would lose on teardown" and "state is fresh / all stopped and
|
|
3162
|
+
/// the live session is just leftover empty topology". Sorted for
|
|
3163
|
+
/// deterministic report output.
|
|
3164
|
+
fn collect_live_agents_from_state(state: &serde_json::Value) -> Vec<String> {
|
|
3165
|
+
let Some(agents) = state.get("agents").and_then(serde_json::Value::as_object) else {
|
|
3166
|
+
return Vec::new();
|
|
3167
|
+
};
|
|
3168
|
+
let mut live: Vec<String> = agents
|
|
3169
|
+
.iter()
|
|
3170
|
+
.filter_map(|(agent_id, agent)| {
|
|
3171
|
+
let status = agent.get("status").and_then(serde_json::Value::as_str)?;
|
|
3172
|
+
let pane_id = agent
|
|
3173
|
+
.get("pane_id")
|
|
3174
|
+
.and_then(serde_json::Value::as_str)
|
|
3175
|
+
.filter(|s| !s.is_empty())?;
|
|
3176
|
+
let _ = pane_id;
|
|
3177
|
+
(status == "running").then(|| agent_id.clone())
|
|
3178
|
+
})
|
|
3179
|
+
.collect();
|
|
3180
|
+
live.sort();
|
|
3181
|
+
live
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3059
3184
|
fn has_endpoint_convergence_marker(state: &serde_json::Value) -> bool {
|
|
3060
3185
|
state
|
|
3061
3186
|
.get("topology_convergence")
|
|
@@ -336,7 +336,17 @@ if [ "$track" != 1 ]; then
|
|
|
336
336
|
fi
|
|
337
337
|
exit 127
|
|
338
338
|
fi
|
|
339
|
-
|
|
339
|
+
# 0.5.39 Slice 2: worker spawn now goes through the worker shell
|
|
340
|
+
# wrapper (tmux-server-death-locate §7 Slice 2), which embeds a
|
|
341
|
+
# printf format literal containing "\n" bytes so the pane returns to
|
|
342
|
+
# an interactive shell with an explicit exit marker instead of
|
|
343
|
+
# collapsing to `[exited]`. Those embedded newlines land inside the
|
|
344
|
+
# tmux argv payload, so a naive `printf '%s\n' "$*"` writes multiple
|
|
345
|
+
# lines per tmux invocation and downstream `raw.lines()` scans see
|
|
346
|
+
# fake "argv" lines that carry only the marker text. Escape newlines
|
|
347
|
+
# to a literal `\n` marker so one shim call = one log line = one
|
|
348
|
+
# assertion target.
|
|
349
|
+
printf '%s\n' "$*" | awk 'BEGIN{ORS=""} {print sep $0; sep="\\n"} END{print "\n"}' >> "$log"
|
|
340
350
|
case "$*" in
|
|
341
351
|
*"-S $expected"*) ;;
|
|
342
352
|
*)
|
|
@@ -745,6 +745,27 @@ pub enum RestartReport {
|
|
|
745
745
|
allow_fresh: bool,
|
|
746
746
|
error: String,
|
|
747
747
|
},
|
|
748
|
+
/// 0.5.40 Slice 3 (tmux-server-death-locate §7 Slice 3, first-version
|
|
749
|
+
/// all-or-refuse): the previous worker session is still live AND state
|
|
750
|
+
/// carries running agents with real pane_ids. Restart today can only
|
|
751
|
+
/// proceed by killing the live session first — which loses provider
|
|
752
|
+
/// session state in the worker panes and, worse, can cascade into
|
|
753
|
+
/// server death under Case B. Until the build-before-destroy path
|
|
754
|
+
/// (later car: temporary session + minimal-viability proof + swap)
|
|
755
|
+
/// lands, restart refuses this shape *before* touching tmux or state.
|
|
756
|
+
/// **nothing created or killed; state.agents rows are byte-identical
|
|
757
|
+
/// to what was seeded before the call.**
|
|
758
|
+
RefusedBuildBeforeDestroyRequired {
|
|
759
|
+
/// Live worker session name from state that would need destructive
|
|
760
|
+
/// rebuild.
|
|
761
|
+
session_name: String,
|
|
762
|
+
/// Snapshot of agent ids currently running with real pane_ids —
|
|
763
|
+
/// this is the "authoritative old state" the refusal preserves.
|
|
764
|
+
live_agents: Vec<String>,
|
|
765
|
+
/// Human-readable error pointing the user at the canonical recovery
|
|
766
|
+
/// path (bare shutdown then restart).
|
|
767
|
+
error: String,
|
|
768
|
+
},
|
|
748
769
|
/// unit-3 (Stage 1): session-identity preflight refused — `state.session_name`
|
|
749
770
|
/// is a leader launcher session (`team-agent-leader-*`). Proceeding would
|
|
750
771
|
/// tear down the leader pane (E49 / 0.3.39). **nothing created or killed**.
|
|
@@ -1509,6 +1509,149 @@ pub fn leader_provider_exit_marker(provider_label: &str) -> String {
|
|
|
1509
1509
|
)
|
|
1510
1510
|
}
|
|
1511
1511
|
|
|
1512
|
+
/// 0.5.39 Slice 1 (tmux-server-death-locate §7 Slice 1): ambient-tmux
|
|
1513
|
+
/// leader-pane probe. Kept inside `tmux_backend` because it is
|
|
1514
|
+
/// definitionally ambient — its job is to discover *which* session/pane
|
|
1515
|
+
/// the leader process is currently inside via $TMUX/$TMUX_PANE +
|
|
1516
|
+
/// `tmux display-message`. Everywhere else in the codebase, tmux ops
|
|
1517
|
+
/// must go through a socket-scoped `TmuxBackend` (that constraint is
|
|
1518
|
+
/// enforced by `n16_tmux_socket_invariant_red.rs` +
|
|
1519
|
+
/// `tmux_server_death_0539_contract.rs::display_cleanup_...`); this
|
|
1520
|
+
/// helper is the single controlled exception.
|
|
1521
|
+
///
|
|
1522
|
+
/// Returns `(session_name, Some(pane_id))` when the ambient tmux
|
|
1523
|
+
/// responds, or `None` if `$TMUX` is unset / `display-message` fails.
|
|
1524
|
+
pub fn probe_ambient_leader_pane_info() -> Option<(String, Option<String>)> {
|
|
1525
|
+
let pane = std::env::var("TMUX_PANE")
|
|
1526
|
+
.ok()
|
|
1527
|
+
.filter(|value| !value.is_empty());
|
|
1528
|
+
let mut commands: Vec<Vec<String>> = Vec::new();
|
|
1529
|
+
if let Some(pane) = pane.as_deref() {
|
|
1530
|
+
commands.push(vec![
|
|
1531
|
+
"display-message".to_string(),
|
|
1532
|
+
"-p".to_string(),
|
|
1533
|
+
"-t".to_string(),
|
|
1534
|
+
pane.to_string(),
|
|
1535
|
+
"-F".to_string(),
|
|
1536
|
+
"#{session_name}\t#{pane_id}".to_string(),
|
|
1537
|
+
]);
|
|
1538
|
+
commands.push(vec![
|
|
1539
|
+
"display-message".to_string(),
|
|
1540
|
+
"-p".to_string(),
|
|
1541
|
+
"-t".to_string(),
|
|
1542
|
+
pane.to_string(),
|
|
1543
|
+
"-F".to_string(),
|
|
1544
|
+
"#{session_name}".to_string(),
|
|
1545
|
+
]);
|
|
1546
|
+
}
|
|
1547
|
+
if std::env::var("TMUX").is_ok_and(|value| !value.is_empty()) {
|
|
1548
|
+
commands.push(vec![
|
|
1549
|
+
"display-message".to_string(),
|
|
1550
|
+
"-p".to_string(),
|
|
1551
|
+
"-F".to_string(),
|
|
1552
|
+
"#{session_name}\t#{pane_id}".to_string(),
|
|
1553
|
+
]);
|
|
1554
|
+
commands.push(vec![
|
|
1555
|
+
"display-message".to_string(),
|
|
1556
|
+
"-p".to_string(),
|
|
1557
|
+
"-F".to_string(),
|
|
1558
|
+
"#{session_name}".to_string(),
|
|
1559
|
+
]);
|
|
1560
|
+
}
|
|
1561
|
+
for command in commands {
|
|
1562
|
+
let output = match std::process::Command::new("tmux").args(&command).output() {
|
|
1563
|
+
Ok(output) if output.status.success() => output,
|
|
1564
|
+
_ => continue,
|
|
1565
|
+
};
|
|
1566
|
+
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
1567
|
+
let Some(line) = stdout.lines().find(|line| !line.trim().is_empty()) else {
|
|
1568
|
+
continue;
|
|
1569
|
+
};
|
|
1570
|
+
let line = line.trim();
|
|
1571
|
+
let parts: Vec<&str> = line.split('\t').collect();
|
|
1572
|
+
let parsed = match parts.as_slice() {
|
|
1573
|
+
[session, pane_str, ..] if !session.is_empty() && !session.starts_with('%') => Some((
|
|
1574
|
+
(*session).to_string(),
|
|
1575
|
+
(!pane_str.is_empty()).then(|| (*pane_str).to_string()),
|
|
1576
|
+
)),
|
|
1577
|
+
[pane_str, session, ..] if pane_str.starts_with('%') && !session.is_empty() => {
|
|
1578
|
+
Some(((*session).to_string(), Some((*pane_str).to_string())))
|
|
1579
|
+
}
|
|
1580
|
+
[session] if !session.is_empty() && !session.starts_with('%') => {
|
|
1581
|
+
Some(((*session).to_string(), None))
|
|
1582
|
+
}
|
|
1583
|
+
_ => None,
|
|
1584
|
+
};
|
|
1585
|
+
if parsed.is_some() {
|
|
1586
|
+
return parsed;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
None
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/// 0.5.39 Slice 2 (tmux-server-death-locate §11.2): single-source worker
|
|
1593
|
+
/// exit marker prefix. Same envelope shape as `LEADER_PROVIDER_EXIT_MARKER_*`
|
|
1594
|
+
/// but distinct so status/classifier code can tell "leader pane fell back
|
|
1595
|
+
/// to shell" from "worker pane fell back to shell". Format:
|
|
1596
|
+
/// `"[team-agent worker] {provider_label} exited with {rc}"`.
|
|
1597
|
+
pub const WORKER_PROVIDER_EXIT_MARKER_PREFIX: &str = "[team-agent worker]";
|
|
1598
|
+
pub const WORKER_PROVIDER_EXIT_MARKER_SUFFIX: &str = "exited with";
|
|
1599
|
+
|
|
1600
|
+
/// 0.5.39 Slice 2: build the worker exit marker text for `provider_label`.
|
|
1601
|
+
/// Used by both the worker shell wrapper (printf source) and future
|
|
1602
|
+
/// status/classifier code (capture substring) so they cannot drift.
|
|
1603
|
+
pub fn worker_provider_exit_marker(provider_label: &str) -> String {
|
|
1604
|
+
format!(
|
|
1605
|
+
"{WORKER_PROVIDER_EXIT_MARKER_PREFIX} {provider_label} {WORKER_PROVIDER_EXIT_MARKER_SUFFIX}"
|
|
1606
|
+
)
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
/// 0.5.39 Slice 2 (tmux-server-death-locate §7 Slice 2): worker shell
|
|
1610
|
+
/// wrapper. Same shape as `leader_shell_wrapper_command` — provider runs
|
|
1611
|
+
/// as a CHILD of a long-lived shell so provider exit does NOT collapse the
|
|
1612
|
+
/// worker pane (which under upstream tmux 3.6a private-server bugs can
|
|
1613
|
+
/// cascade into whole-server death). When the provider exits, the worker
|
|
1614
|
+
/// pane returns to an interactive shell with an explicit worker exit
|
|
1615
|
+
/// marker, matching manual `tmux new-window` then `<provider>` behaviour.
|
|
1616
|
+
pub fn worker_shell_wrapper_command(
|
|
1617
|
+
argv: &[String],
|
|
1618
|
+
cwd: &Path,
|
|
1619
|
+
env: &BTreeMap<String, String>,
|
|
1620
|
+
env_unset: &[String],
|
|
1621
|
+
provider_label: &str,
|
|
1622
|
+
) -> String {
|
|
1623
|
+
let unset_set: std::collections::BTreeSet<&str> =
|
|
1624
|
+
env_unset.iter().map(String::as_str).collect();
|
|
1625
|
+
let mut parts = Vec::new();
|
|
1626
|
+
parts.push("cd".to_string());
|
|
1627
|
+
parts.push(shell_quote(&cwd.to_string_lossy()));
|
|
1628
|
+
parts.push("&&".to_string());
|
|
1629
|
+
for key in env_unset {
|
|
1630
|
+
parts.push("unset".to_string());
|
|
1631
|
+
parts.push(key.clone());
|
|
1632
|
+
parts.push("&&".to_string());
|
|
1633
|
+
}
|
|
1634
|
+
for (key, value) in env {
|
|
1635
|
+
if unset_set.contains(key.as_str()) {
|
|
1636
|
+
continue;
|
|
1637
|
+
}
|
|
1638
|
+
parts.push(format!("{key}={}", shell_quote(value)));
|
|
1639
|
+
}
|
|
1640
|
+
parts.extend(argv.iter().map(|arg| shell_quote(arg)));
|
|
1641
|
+
parts.push(";".to_string());
|
|
1642
|
+
parts.push("rc=$?;".to_string());
|
|
1643
|
+
parts.push("printf".to_string());
|
|
1644
|
+
parts.push(shell_quote(&format!(
|
|
1645
|
+
"\n{} %s\n",
|
|
1646
|
+
worker_provider_exit_marker(provider_label)
|
|
1647
|
+
)));
|
|
1648
|
+
parts.push("\"$rc\";".to_string());
|
|
1649
|
+
parts.push("exec".to_string());
|
|
1650
|
+
parts.push("\"${SHELL:-/bin/zsh}\"".to_string());
|
|
1651
|
+
parts.push("-l".to_string());
|
|
1652
|
+
parts.join(" ")
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1512
1655
|
/// 0.4.x (CR C-2): leader shell wrapper — provider runs as a CHILD of a
|
|
1513
1656
|
/// long-lived shell, not as the pane's primary process. When the provider
|
|
1514
1657
|
/// exits, the pane returns to an interactive shell with an explicit exit
|
|
@@ -1670,6 +1813,38 @@ impl Transport for TmuxBackend {
|
|
|
1670
1813
|
self.spawn_split(session, window, argv, cwd, env, env_unset)
|
|
1671
1814
|
}
|
|
1672
1815
|
|
|
1816
|
+
/// 0.5.39 Slice 2: TmuxBackend override of the worker-shell-wrapper
|
|
1817
|
+
/// variant. Same mechanism as the leader wrapper (child provider under
|
|
1818
|
+
/// long-lived shell), but the marker text is distinct so downstream
|
|
1819
|
+
/// classifiers can tell leader vs worker provider exit apart.
|
|
1820
|
+
fn spawn_first_with_worker_shell_wrapper(
|
|
1821
|
+
&self,
|
|
1822
|
+
session: &SessionName,
|
|
1823
|
+
window: &WindowName,
|
|
1824
|
+
argv: &[String],
|
|
1825
|
+
cwd: &Path,
|
|
1826
|
+
env: &BTreeMap<String, String>,
|
|
1827
|
+
env_unset: &[String],
|
|
1828
|
+
provider_label: &str,
|
|
1829
|
+
) -> Result<SpawnResult, TransportError> {
|
|
1830
|
+
let command = worker_shell_wrapper_command(argv, cwd, env, env_unset, provider_label);
|
|
1831
|
+
self.spawn_with_command(session, window, &command, true)
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
fn spawn_into_with_worker_shell_wrapper(
|
|
1835
|
+
&self,
|
|
1836
|
+
session: &SessionName,
|
|
1837
|
+
window: &WindowName,
|
|
1838
|
+
argv: &[String],
|
|
1839
|
+
cwd: &Path,
|
|
1840
|
+
env: &BTreeMap<String, String>,
|
|
1841
|
+
env_unset: &[String],
|
|
1842
|
+
provider_label: &str,
|
|
1843
|
+
) -> Result<SpawnResult, TransportError> {
|
|
1844
|
+
let command = worker_shell_wrapper_command(argv, cwd, env, env_unset, provider_label);
|
|
1845
|
+
self.spawn_with_command(session, window, &command, false)
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1673
1848
|
/// 0.4.x (CR C-2): TmuxBackend override of the leader-shell-wrapper
|
|
1674
1849
|
/// variant. Builds the wrapper shell line via
|
|
1675
1850
|
/// `leader_shell_wrapper_command` and runs it through
|
|
@@ -589,6 +589,44 @@ pub trait Transport: Send + Sync {
|
|
|
589
589
|
self.spawn_into(session, window, argv, cwd, env)
|
|
590
590
|
}
|
|
591
591
|
|
|
592
|
+
/// 0.5.39 Slice 2 (tmux-server-death-locate §7 Slice 2): worker spawn
|
|
593
|
+
/// variant that reuses the leader-wrapper mechanism so worker provider
|
|
594
|
+
/// exit does not collapse the pane into `[exited]` (which under
|
|
595
|
+
/// upstream tmux 3.6a private-server bugs can cascade into server
|
|
596
|
+
/// death). When the provider exits, the worker pane returns to an
|
|
597
|
+
/// interactive shell with an explicit worker exit marker — matching
|
|
598
|
+
/// manual `tmux new-window` then `<provider>` behaviour. Default falls
|
|
599
|
+
/// back to plain `spawn_first_with_env_unset` for backends that have
|
|
600
|
+
/// no shell layer (test-only `OfflineTransport`).
|
|
601
|
+
fn spawn_first_with_worker_shell_wrapper(
|
|
602
|
+
&self,
|
|
603
|
+
session: &SessionName,
|
|
604
|
+
window: &WindowName,
|
|
605
|
+
argv: &[String],
|
|
606
|
+
cwd: &Path,
|
|
607
|
+
env: &BTreeMap<String, String>,
|
|
608
|
+
env_unset: &[String],
|
|
609
|
+
provider_label: &str,
|
|
610
|
+
) -> Result<SpawnResult, TransportError> {
|
|
611
|
+
let _ = provider_label;
|
|
612
|
+
self.spawn_first_with_env_unset(session, window, argv, cwd, env, env_unset)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/// 同 [`Transport::spawn_first_with_worker_shell_wrapper`],对应 `spawn_into`。
|
|
616
|
+
fn spawn_into_with_worker_shell_wrapper(
|
|
617
|
+
&self,
|
|
618
|
+
session: &SessionName,
|
|
619
|
+
window: &WindowName,
|
|
620
|
+
argv: &[String],
|
|
621
|
+
cwd: &Path,
|
|
622
|
+
env: &BTreeMap<String, String>,
|
|
623
|
+
env_unset: &[String],
|
|
624
|
+
provider_label: &str,
|
|
625
|
+
) -> Result<SpawnResult, TransportError> {
|
|
626
|
+
let _ = provider_label;
|
|
627
|
+
self.spawn_into_with_env_unset(session, window, argv, cwd, env, env_unset)
|
|
628
|
+
}
|
|
629
|
+
|
|
592
630
|
/// 0.4.x (CR C-2): leader-specific spawn variant. Instead of `exec <cmd>`
|
|
593
631
|
/// (which makes the provider the pane's primary process and turns the
|
|
594
632
|
/// pane into `[exited]` when the provider exits), build a shell line
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.40",
|
|
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.5.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.5.40",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.5.40",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.5.40"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|