@team-agent/installer 0.3.17 → 0.3.19
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/emit.rs +4 -3
- package/crates/team-agent/src/cli/leader.rs +15 -1
- package/crates/team-agent/src/cli/mod.rs +26 -7
- package/crates/team-agent/src/cli/status_port.rs +3 -6
- package/crates/team-agent/src/cli/tests/base.rs +15 -14
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +41 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +63 -0
- package/crates/team-agent/src/cli/types.rs +6 -0
- package/crates/team-agent/src/leader/lease.rs +3 -4
- package/crates/team-agent/src/leader/start.rs +70 -7
- package/crates/team-agent/src/leader/tests/identity.rs +10 -2
- package/crates/team-agent/src/lifecycle/launch.rs +6 -6
- package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -6
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +42 -4
- package/crates/team-agent/src/lifecycle/types.rs +4 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +10 -1
- package/crates/team-agent/src/state/persist.rs +85 -8
- package/crates/team-agent/src/state/projection.rs +48 -3
- package/crates/team-agent/src/tmux_backend/tests.rs +21 -0
- package/crates/team-agent/src/tmux_backend.rs +31 -9
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -32,9 +32,10 @@ pub fn run(argv: &[String], cwd: &Path) -> ExitCode {
|
|
|
32
32
|
return emit_missing_subcommand_usage();
|
|
33
33
|
};
|
|
34
34
|
if command == "codex" || command == "claude" || command == "copilot" {
|
|
35
|
-
return cmd_leader_passthrough(command, &argv[1..], cwd)
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
return match cmd_leader_passthrough(command, &argv[1..], cwd) {
|
|
36
|
+
Ok(result) => emit_result(result),
|
|
37
|
+
Err(error) => emit_cli_error(command, &argv[1..], cwd, &error),
|
|
38
|
+
};
|
|
38
39
|
}
|
|
39
40
|
if matches!(command, "-h" | "--help" | "help") {
|
|
40
41
|
println!("{}", command_help(None));
|
|
@@ -23,7 +23,14 @@ pub fn leader_launcher_args(values: &[String]) -> Result<LeaderLauncherArgs, Cli
|
|
|
23
23
|
while idx < values.len() {
|
|
24
24
|
let token = &values[idx];
|
|
25
25
|
if token == "--" {
|
|
26
|
-
|
|
26
|
+
for value in values.iter().skip(idx + 1) {
|
|
27
|
+
if is_leader_launcher_flag(value) {
|
|
28
|
+
return Err(CliError::Runtime(format!(
|
|
29
|
+
"Team Agent launcher flag {value} must appear before --"
|
|
30
|
+
)));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
out.provider_args.extend(values.iter().skip(idx + 1).cloned());
|
|
27
34
|
break;
|
|
28
35
|
} else if token == "--attach" || token == "--attach-existing" {
|
|
29
36
|
out.attach_existing = true;
|
|
@@ -49,6 +56,13 @@ pub fn leader_launcher_args(values: &[String]) -> Result<LeaderLauncherArgs, Cli
|
|
|
49
56
|
Ok(out)
|
|
50
57
|
}
|
|
51
58
|
|
|
59
|
+
fn is_leader_launcher_flag(value: &str) -> bool {
|
|
60
|
+
matches!(
|
|
61
|
+
value,
|
|
62
|
+
"--external-leader" | "--attach" | "--attach-existing" | "--confirm" | "--attach-session"
|
|
63
|
+
) || value.starts_with("--attach-session=")
|
|
64
|
+
}
|
|
65
|
+
|
|
52
66
|
fn leader_launcher_json(values: &[String]) -> bool {
|
|
53
67
|
values
|
|
54
68
|
.iter()
|
|
@@ -363,10 +363,7 @@ pub mod lifecycle_port {
|
|
|
363
363
|
}
|
|
364
364
|
|
|
365
365
|
fn state_uses_external_leader(state: &Value) -> bool {
|
|
366
|
-
state
|
|
367
|
-
.get("is_external_leader")
|
|
368
|
-
.and_then(Value::as_bool)
|
|
369
|
-
.unwrap_or(true)
|
|
366
|
+
crate::state::projection::state_is_external_leader(state)
|
|
370
367
|
}
|
|
371
368
|
|
|
372
369
|
fn managed_leader_socket_cleanup(
|
|
@@ -1722,9 +1719,31 @@ pub mod lifecycle_port {
|
|
|
1722
1719
|
open_display,
|
|
1723
1720
|
team,
|
|
1724
1721
|
) {
|
|
1725
|
-
Ok(
|
|
1726
|
-
|
|
1727
|
-
|
|
1722
|
+
Ok(crate::lifecycle::ResetAgentOutcome::Reset {
|
|
1723
|
+
env,
|
|
1724
|
+
start_mode,
|
|
1725
|
+
discarded_session_id,
|
|
1726
|
+
session_id,
|
|
1727
|
+
new_session_id,
|
|
1728
|
+
}) => Ok(json!({
|
|
1729
|
+
"ok": true,
|
|
1730
|
+
"agent_id": env.agent_id.as_str(),
|
|
1731
|
+
"status": "reset",
|
|
1732
|
+
"state_file": env.state_file.to_string_lossy().to_string(),
|
|
1733
|
+
"coordinator_started": env.coordinator_started,
|
|
1734
|
+
"start_mode": start_mode,
|
|
1735
|
+
"discarded_session_id": discarded_session_id.as_ref().map(|id| id.as_str()),
|
|
1736
|
+
"session_id": session_id.as_ref().map(|id| id.as_str()),
|
|
1737
|
+
"new_session_id": new_session_id.as_ref().map(|id| id.as_str()),
|
|
1738
|
+
})),
|
|
1739
|
+
Ok(crate::lifecycle::ResetAgentOutcome::Refused { reason }) => Ok(json!({
|
|
1740
|
+
"ok": false,
|
|
1741
|
+
"agent_id": agent,
|
|
1742
|
+
"status": "refused",
|
|
1743
|
+
"reason": match reason {
|
|
1744
|
+
crate::lifecycle::ResetRefusal::DiscardSessionRequired => "discard_session_required",
|
|
1745
|
+
},
|
|
1746
|
+
})),
|
|
1728
1747
|
Err(e) => Ok(error_value(e)),
|
|
1729
1748
|
}
|
|
1730
1749
|
}
|
|
@@ -48,10 +48,7 @@ use rusqlite::params;
|
|
|
48
48
|
.cloned()
|
|
49
49
|
.unwrap_or_else(|| json!({}));
|
|
50
50
|
let session_name = state.get("session_name").cloned().unwrap_or(Value::Null);
|
|
51
|
-
let is_external_leader = state
|
|
52
|
-
.get("is_external_leader")
|
|
53
|
-
.and_then(Value::as_bool)
|
|
54
|
-
.unwrap_or(true);
|
|
51
|
+
let is_external_leader = crate::state::projection::state_is_external_leader(state);
|
|
55
52
|
let leader_topology = if is_external_leader { "external" } else { "managed" };
|
|
56
53
|
let leader_attach_command = if is_external_leader {
|
|
57
54
|
None
|
|
@@ -573,8 +570,8 @@ use rusqlite::params;
|
|
|
573
570
|
json!({
|
|
574
571
|
"team": full.get("team").cloned().unwrap_or(Value::Null),
|
|
575
572
|
"session_name": full.get("session_name").cloned().unwrap_or(Value::Null),
|
|
576
|
-
"leader_topology": full.get("leader_topology").cloned().unwrap_or_else(|| json!("
|
|
577
|
-
"is_external_leader": full.get("is_external_leader").cloned().unwrap_or(Value::Bool(
|
|
573
|
+
"leader_topology": full.get("leader_topology").cloned().unwrap_or_else(|| json!("managed")),
|
|
574
|
+
"is_external_leader": full.get("is_external_leader").cloned().unwrap_or(Value::Bool(false)),
|
|
578
575
|
"leader_attach_command": full.get("leader_attach_command").cloned().unwrap_or(Value::Null),
|
|
579
576
|
"leader_client": full.get("leader_client").cloned().unwrap_or(Value::Null),
|
|
580
577
|
"tmux_session_present": full.get("tmux_session_present").cloned().unwrap_or(Value::Bool(false)),
|
|
@@ -86,17 +86,18 @@ use super::*;
|
|
|
86
86
|
assert!(!got.attach_existing);
|
|
87
87
|
assert_eq!(
|
|
88
88
|
got.provider_args,
|
|
89
|
-
vec!["--
|
|
89
|
+
vec!["--model".to_string(), "opus".to_string()]
|
|
90
90
|
);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
#[test]
|
|
94
|
-
fn
|
|
95
|
-
let
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
94
|
+
fn leader_launcher_args_external_leader_after_dashdash_errors() {
|
|
95
|
+
let err = leader_launcher_args(&["--".into(), "--external-leader".into()])
|
|
96
|
+
.expect_err("Team Agent flags after -- must not be silently passed to provider");
|
|
97
|
+
assert!(
|
|
98
|
+
err.to_string()
|
|
99
|
+
.contains("Team Agent launcher flag --external-leader must appear before --"),
|
|
100
|
+
"unexpected error: {err}"
|
|
100
101
|
);
|
|
101
102
|
}
|
|
102
103
|
|
|
@@ -116,22 +117,22 @@ use super::*;
|
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
#[test]
|
|
119
|
-
fn
|
|
120
|
-
//
|
|
121
|
-
// provider_args=["
|
|
122
|
-
//
|
|
120
|
+
fn leader_launcher_args_dashdash_passthrough_strips_separator() {
|
|
121
|
+
// ["--attach","--","-x","--provider-confirm"] ->
|
|
122
|
+
// provider_args=["-x","--provider-confirm"], attach_existing=True, confirm_attach=False
|
|
123
|
+
// Known Team Agent launcher flags after `--` are rejected by a separate guard.
|
|
123
124
|
let got = leader_launcher_args(&[
|
|
124
125
|
"--attach".into(),
|
|
125
126
|
"--".into(),
|
|
126
127
|
"-x".into(),
|
|
127
|
-
"--confirm".into(),
|
|
128
|
+
"--provider-confirm".into(),
|
|
128
129
|
])
|
|
129
130
|
.unwrap();
|
|
130
131
|
assert!(got.attach_existing);
|
|
131
|
-
assert!(!got.confirm_attach
|
|
132
|
+
assert!(!got.confirm_attach);
|
|
132
133
|
assert_eq!(
|
|
133
134
|
got.provider_args,
|
|
134
|
-
vec!["
|
|
135
|
+
vec!["-x".to_string(), "--provider-confirm".to_string()]
|
|
135
136
|
);
|
|
136
137
|
}
|
|
137
138
|
|
|
@@ -309,6 +309,7 @@ fn lsof_cwd_timeout_is_diagnostic_not_shutdown_partial() {
|
|
|
309
309
|
&ws,
|
|
310
310
|
&json!({
|
|
311
311
|
"session_name": "team-lsof-cwd-timeout",
|
|
312
|
+
"is_external_leader": true,
|
|
312
313
|
"agents": {
|
|
313
314
|
"fake_impl": {
|
|
314
315
|
"status": "running",
|
|
@@ -467,6 +468,7 @@ fn leader_env_tmux_socket_never_kills_server_even_when_sessions_look_exclusive()
|
|
|
467
468
|
crate::state::persist::save_runtime_state(
|
|
468
469
|
&ws,
|
|
469
470
|
&json!({
|
|
471
|
+
"is_external_leader": true,
|
|
470
472
|
"tmux_socket_source": "leader_env",
|
|
471
473
|
"agents": {
|
|
472
474
|
"fake_impl": {
|
|
@@ -543,3 +545,42 @@ fn managed_leader_shutdown_never_kills_server_even_when_socket_looks_exclusive()
|
|
|
543
545
|
"managed topology must clear the team session/window without kill-server"
|
|
544
546
|
);
|
|
545
547
|
}
|
|
548
|
+
|
|
549
|
+
#[test]
|
|
550
|
+
fn shutdown_missing_topology_marker_defaults_to_managed_cleanup() {
|
|
551
|
+
let ws = tmp_shutdown_workspace("missing-topology-marker-managed-cleanup");
|
|
552
|
+
crate::state::persist::save_runtime_state(
|
|
553
|
+
&ws,
|
|
554
|
+
&json!({
|
|
555
|
+
"session_name": "team-current",
|
|
556
|
+
"agents": {}
|
|
557
|
+
}),
|
|
558
|
+
)
|
|
559
|
+
.unwrap();
|
|
560
|
+
let transport = CleanShutdownTransport::new()
|
|
561
|
+
.with_targets(vec![PaneInfo {
|
|
562
|
+
pane_id: PaneId::new("%1"),
|
|
563
|
+
session: SessionName::new("team-current"),
|
|
564
|
+
window_index: Some(0),
|
|
565
|
+
window_name: Some(WindowName::new("leader")),
|
|
566
|
+
pane_index: Some(0),
|
|
567
|
+
tty: None,
|
|
568
|
+
current_command: Some("codex".to_string()),
|
|
569
|
+
current_path: None,
|
|
570
|
+
active: true,
|
|
571
|
+
pane_pid: None,
|
|
572
|
+
leader_env: BTreeMap::new(),
|
|
573
|
+
}])
|
|
574
|
+
.with_targets_persist_after_kill();
|
|
575
|
+
|
|
576
|
+
let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
|
|
577
|
+
.expect("shutdown should complete");
|
|
578
|
+
|
|
579
|
+
assert_eq!(out["ok"], json!(true));
|
|
580
|
+
assert_eq!(out["killed_sessions"], json!(["team-current"]));
|
|
581
|
+
assert_eq!(out["spared_sessions"], json!([]));
|
|
582
|
+
assert!(
|
|
583
|
+
!transport.kill_server_called(),
|
|
584
|
+
"missing topology marker must default to managed cleanup, not external kill-server"
|
|
585
|
+
);
|
|
586
|
+
}
|
|
@@ -86,6 +86,27 @@ use super::*;
|
|
|
86
86
|
let _ = std::fs::remove_dir_all(&ws);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
#[test]
|
|
90
|
+
fn status_port_missing_topology_marker_defaults_to_managed() {
|
|
91
|
+
let ws = seed_status_workspace();
|
|
92
|
+
let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
93
|
+
if let Some(obj) = state.as_object_mut() {
|
|
94
|
+
obj.remove("is_external_leader");
|
|
95
|
+
obj.insert("session_name".to_string(), json!("team-current"));
|
|
96
|
+
}
|
|
97
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
98
|
+
|
|
99
|
+
let v = status_port::status(&ws, /*compact=*/ true, /*detail=*/ false).expect("status");
|
|
100
|
+
|
|
101
|
+
assert_eq!(v["leader_topology"], json!("managed"));
|
|
102
|
+
assert_eq!(v["is_external_leader"], json!(false));
|
|
103
|
+
let attach = v["leader_attach_command"]
|
|
104
|
+
.as_str()
|
|
105
|
+
.expect("missing marker defaults to managed attach command");
|
|
106
|
+
assert!(attach.contains("attach -t team-current:leader"), "{attach}");
|
|
107
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
108
|
+
}
|
|
109
|
+
|
|
89
110
|
#[test]
|
|
90
111
|
fn status_port_status_detail_full_keeps_uncompacted_events() {
|
|
91
112
|
// cmd_status --json --detail -> status_port::status(compact=false): the FULL dict
|
|
@@ -394,6 +415,48 @@ fn run_send_unknown_task_renders_error_not_silent_swallow() {
|
|
|
394
415
|
let _ = std::fs::remove_dir_all(&ws);
|
|
395
416
|
}
|
|
396
417
|
|
|
418
|
+
#[test]
|
|
419
|
+
fn run_leader_passthrough_flag_after_dashdash_renders_error_not_silent_swallow() {
|
|
420
|
+
let ws = std::env::temp_dir().join(format!(
|
|
421
|
+
"ta-run-leaderflag-{}-{}",
|
|
422
|
+
std::process::id(),
|
|
423
|
+
std::time::SystemTime::now()
|
|
424
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
425
|
+
.unwrap()
|
|
426
|
+
.as_nanos()
|
|
427
|
+
));
|
|
428
|
+
std::fs::create_dir_all(&ws).unwrap();
|
|
429
|
+
let argv: Vec<String> = ["codex", "--", "--external-leader"]
|
|
430
|
+
.iter()
|
|
431
|
+
.map(ToString::to_string)
|
|
432
|
+
.collect();
|
|
433
|
+
|
|
434
|
+
let code = run(&argv, &ws);
|
|
435
|
+
|
|
436
|
+
assert_eq!(
|
|
437
|
+
code,
|
|
438
|
+
ExitCode::Error,
|
|
439
|
+
"misplaced leader flag must fail before provider exec"
|
|
440
|
+
);
|
|
441
|
+
let logs_dir = ws.join(".team").join("logs");
|
|
442
|
+
let mut found = String::new();
|
|
443
|
+
if let Ok(entries) = std::fs::read_dir(&logs_dir) {
|
|
444
|
+
for entry in entries.flatten() {
|
|
445
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
446
|
+
if name.starts_with("cli-error-") {
|
|
447
|
+
found = std::fs::read_to_string(entry.path()).unwrap_or_default();
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
assert!(
|
|
453
|
+
found.contains("Team Agent launcher flag --external-leader must appear before --"),
|
|
454
|
+
"leader passthrough run() must render the misplaced flag error; a silent swallow leaves no \
|
|
455
|
+
cli-error log. got log body: {found:?}"
|
|
456
|
+
);
|
|
457
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
458
|
+
}
|
|
459
|
+
|
|
397
460
|
// R8 D6 (c-lite offline byte-lock): the CLI requeued_exhausted_watchers return projection, extracted
|
|
398
461
|
// into a pure helper, must project the golden event's watcher_ids STRING list (leader/__init__.py:56) —
|
|
399
462
|
// NOT the Rust `requeued` Vec<WatcherNotice> objects.
|
|
@@ -101,6 +101,12 @@ impl CliError {
|
|
|
101
101
|
.to_string(),
|
|
102
102
|
]);
|
|
103
103
|
}
|
|
104
|
+
} else if error.contains("Team Agent launcher flag")
|
|
105
|
+
&& error.contains("must appear before --")
|
|
106
|
+
{
|
|
107
|
+
payload.action = String::from(
|
|
108
|
+
"move the Team Agent launcher flag before `--`; only provider flags belong after `--`",
|
|
109
|
+
);
|
|
104
110
|
}
|
|
105
111
|
payload
|
|
106
112
|
}
|
|
@@ -144,6 +144,7 @@ fn attach_leader_targets(workspace: &Path, state: &Value) -> Vec<AttachLeaderTar
|
|
|
144
144
|
let mut targets = Vec::new();
|
|
145
145
|
for endpoint in state_recorded_tmux_endpoints(state) {
|
|
146
146
|
let backend = tmux_backend_for_endpoint(&endpoint);
|
|
147
|
+
let resolved_endpoint = backend.tmux_endpoint();
|
|
147
148
|
targets.extend(
|
|
148
149
|
backend
|
|
149
150
|
.list_targets()
|
|
@@ -151,7 +152,7 @@ fn attach_leader_targets(workspace: &Path, state: &Value) -> Vec<AttachLeaderTar
|
|
|
151
152
|
.into_iter()
|
|
152
153
|
.map(|info| AttachLeaderTarget {
|
|
153
154
|
info,
|
|
154
|
-
endpoint:
|
|
155
|
+
endpoint: resolved_endpoint.clone(),
|
|
155
156
|
}),
|
|
156
157
|
);
|
|
157
158
|
}
|
|
@@ -168,10 +169,8 @@ fn attach_leader_targets(workspace: &Path, state: &Value) -> Vec<AttachLeaderTar
|
|
|
168
169
|
fn tmux_backend_for_endpoint(endpoint: &str) -> crate::tmux_backend::TmuxBackend {
|
|
169
170
|
if endpoint.is_empty() || endpoint == "default" {
|
|
170
171
|
crate::tmux_backend::TmuxBackend::new()
|
|
171
|
-
} else if Path::new(endpoint).is_absolute() {
|
|
172
|
-
crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
|
|
173
172
|
} else {
|
|
174
|
-
crate::tmux_backend::TmuxBackend::
|
|
173
|
+
crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
|
|
175
174
|
}
|
|
176
175
|
}
|
|
177
176
|
|
|
@@ -196,6 +196,9 @@ pub fn execute_leader_plan(
|
|
|
196
196
|
let detached = plan.mode == LeaderStartMode::NewTmuxSession
|
|
197
197
|
&& !std::io::stdin().is_terminal()
|
|
198
198
|
&& insert_detach_flag(&mut argv);
|
|
199
|
+
if plan.is_external_leader {
|
|
200
|
+
persist_external_leader_topology_marker(plan, workspace)?;
|
|
201
|
+
}
|
|
199
202
|
let status = run_leader_argv(&argv, &plan.leader_env, plan, workspace)?;
|
|
200
203
|
let code = status.code();
|
|
201
204
|
if !status.success() {
|
|
@@ -205,9 +208,6 @@ pub fn execute_leader_plan(
|
|
|
205
208
|
.unwrap_or_else(|| "signal".to_string())
|
|
206
209
|
)));
|
|
207
210
|
}
|
|
208
|
-
if plan.is_external_leader {
|
|
209
|
-
persist_external_leader_topology_marker(plan, workspace)?;
|
|
210
|
-
}
|
|
211
211
|
if detached {
|
|
212
212
|
Ok(LeaderLaunchOutcome {
|
|
213
213
|
status: LeaderLaunchStatus::Detached,
|
|
@@ -258,7 +258,7 @@ fn start_argv(
|
|
|
258
258
|
match mode {
|
|
259
259
|
LeaderStartMode::ExecProvider => {
|
|
260
260
|
let mut argv = vec![provider_cmd];
|
|
261
|
-
argv.extend(provider_args
|
|
261
|
+
argv.extend(normalized_provider_args(provider_args));
|
|
262
262
|
Ok(argv)
|
|
263
263
|
}
|
|
264
264
|
LeaderStartMode::ManagedTmuxClient => {
|
|
@@ -289,7 +289,7 @@ fn start_argv(
|
|
|
289
289
|
exports.push(shlex_quote(&format!("PATH={path}")));
|
|
290
290
|
}
|
|
291
291
|
let mut provider_argv = vec![provider_cmd];
|
|
292
|
-
provider_argv.extend(provider_args
|
|
292
|
+
provider_argv.extend(normalized_provider_args(provider_args));
|
|
293
293
|
let shell = format!(
|
|
294
294
|
"cd {} && export {} && exec {}",
|
|
295
295
|
shlex_quote(&resolved_workspace.to_string_lossy()),
|
|
@@ -316,10 +316,17 @@ fn start_argv(
|
|
|
316
316
|
|
|
317
317
|
fn provider_command_argv(provider: Provider, provider_args: &[String]) -> Vec<String> {
|
|
318
318
|
let mut argv = vec![provider_command_name(provider).to_string()];
|
|
319
|
-
argv.extend(provider_args
|
|
319
|
+
argv.extend(normalized_provider_args(provider_args));
|
|
320
320
|
argv
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
+
fn normalized_provider_args(provider_args: &[String]) -> impl Iterator<Item = String> + '_ {
|
|
324
|
+
provider_args
|
|
325
|
+
.iter()
|
|
326
|
+
.skip(usize::from(provider_args.first().is_some_and(|arg| arg == "--")))
|
|
327
|
+
.cloned()
|
|
328
|
+
}
|
|
329
|
+
|
|
323
330
|
fn managed_team_session_name(identity: &LeaderIdentity) -> SessionName {
|
|
324
331
|
let team = identity.team_id.as_str();
|
|
325
332
|
if team.starts_with("team-") {
|
|
@@ -777,7 +784,12 @@ mod tests {
|
|
|
777
784
|
use std::path::Path;
|
|
778
785
|
use std::sync::Mutex;
|
|
779
786
|
|
|
787
|
+
use crate::leader::{
|
|
788
|
+
LeaderIdentity, LeaderLaunchSocket, LeaderSessionUuidSource, LeaderStartMode,
|
|
789
|
+
LeaderStartPlan,
|
|
790
|
+
};
|
|
780
791
|
use crate::model::enums::PaneLiveness;
|
|
792
|
+
use crate::model::ids::{LeaderSessionUuid, TeamKey};
|
|
781
793
|
use crate::provider::{Provider, COPILOT_READY_MARKER, COPILOT_TRUST_PROMPT_MARKER};
|
|
782
794
|
use crate::transport::{
|
|
783
795
|
AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport,
|
|
@@ -786,7 +798,7 @@ mod tests {
|
|
|
786
798
|
TurnVerification, WindowName,
|
|
787
799
|
};
|
|
788
800
|
|
|
789
|
-
use super::handle_exec_provider_startup_prompts;
|
|
801
|
+
use super::{execute_leader_plan, handle_exec_provider_startup_prompts, shlex_quote};
|
|
790
802
|
|
|
791
803
|
struct ScriptedTransport {
|
|
792
804
|
screens: Mutex<Vec<String>>,
|
|
@@ -937,6 +949,57 @@ mod tests {
|
|
|
937
949
|
}
|
|
938
950
|
}
|
|
939
951
|
|
|
952
|
+
#[test]
|
|
953
|
+
fn external_exec_provider_persists_topology_before_provider_exec() {
|
|
954
|
+
let workspace = std::env::temp_dir().join(format!(
|
|
955
|
+
"ta-external-pre-exec-{}",
|
|
956
|
+
std::process::id()
|
|
957
|
+
));
|
|
958
|
+
std::fs::create_dir_all(&workspace).unwrap();
|
|
959
|
+
let state_path = crate::state::persist::runtime_state_path(&workspace);
|
|
960
|
+
let command = format!(
|
|
961
|
+
"test -f {path} && grep -q is_external_leader {path}",
|
|
962
|
+
path = shlex_quote(&state_path.to_string_lossy())
|
|
963
|
+
);
|
|
964
|
+
let identity = LeaderIdentity {
|
|
965
|
+
leader_session_uuid: LeaderSessionUuid::derive(
|
|
966
|
+
"fp",
|
|
967
|
+
&workspace.to_string_lossy(),
|
|
968
|
+
"tester",
|
|
969
|
+
"current",
|
|
970
|
+
)
|
|
971
|
+
.unwrap(),
|
|
972
|
+
leader_session_uuid_source: LeaderSessionUuidSource::Derived,
|
|
973
|
+
machine_fingerprint: "fp".to_string(),
|
|
974
|
+
workspace_abspath: workspace.clone(),
|
|
975
|
+
os_user: "tester".to_string(),
|
|
976
|
+
team_id: TeamKey::new("current"),
|
|
977
|
+
};
|
|
978
|
+
let plan = LeaderStartPlan {
|
|
979
|
+
mode: LeaderStartMode::ExecProvider,
|
|
980
|
+
provider: Provider::Codex,
|
|
981
|
+
workspace: workspace.clone(),
|
|
982
|
+
socket: LeaderLaunchSocket::Workspace,
|
|
983
|
+
session_name: None,
|
|
984
|
+
argv: vec!["sh".to_string(), "-c".to_string(), command],
|
|
985
|
+
provider_argv: vec!["codex".to_string()],
|
|
986
|
+
leader_window: None,
|
|
987
|
+
is_external_leader: true,
|
|
988
|
+
leader_env: BTreeMap::new(),
|
|
989
|
+
identity: Some(identity),
|
|
990
|
+
detached: false,
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
let outcome = execute_leader_plan(&plan, &workspace)
|
|
994
|
+
.expect("external marker must be present before provider argv runs");
|
|
995
|
+
|
|
996
|
+
assert_eq!(outcome.status, crate::leader::LeaderLaunchStatus::Exited);
|
|
997
|
+
let state = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
998
|
+
assert_eq!(state["is_external_leader"], serde_json::json!(true));
|
|
999
|
+
assert_eq!(state["teams"]["current"]["is_external_leader"], serde_json::json!(true));
|
|
1000
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
940
1003
|
#[test]
|
|
941
1004
|
fn exec_provider_leader_startup_prompt_handler_reuses_copilot_adapter() {
|
|
942
1005
|
let transport = ScriptedTransport::new(vec![
|
|
@@ -108,12 +108,20 @@ use super::*;
|
|
|
108
108
|
let ws = std::env::temp_dir().join(format!("ta_rs_lsp_external_{}", std::process::id()));
|
|
109
109
|
std::fs::create_dir_all(&ws).unwrap();
|
|
110
110
|
|
|
111
|
-
let
|
|
111
|
+
let provider_args = vec!["--".to_string(), "--model".to_string(), "opus".to_string()];
|
|
112
|
+
let plan = leader_start_plan(Provider::Fake, &provider_args, &ws, false, false, None, true).unwrap();
|
|
112
113
|
|
|
113
114
|
assert_eq!(plan.mode, LeaderStartMode::ExecProvider);
|
|
114
115
|
assert!(plan.is_external_leader);
|
|
115
116
|
assert_eq!(plan.leader_window, None);
|
|
116
|
-
assert_eq!(
|
|
117
|
+
assert_eq!(
|
|
118
|
+
plan.argv,
|
|
119
|
+
vec!["fake".to_string(), "--model".to_string(), "opus".to_string()]
|
|
120
|
+
);
|
|
121
|
+
assert_eq!(
|
|
122
|
+
plan.provider_argv,
|
|
123
|
+
vec!["fake".to_string(), "--model".to_string(), "opus".to_string()]
|
|
124
|
+
);
|
|
117
125
|
}
|
|
118
126
|
|
|
119
127
|
#[test]
|
|
@@ -856,6 +856,8 @@ fn save_launched_team_state_for_key(
|
|
|
856
856
|
"active_team_key".to_string(),
|
|
857
857
|
serde_json::Value::String(launched_key.clone()),
|
|
858
858
|
);
|
|
859
|
+
obj.entry("is_external_leader".to_string())
|
|
860
|
+
.or_insert(serde_json::Value::Bool(false));
|
|
859
861
|
}
|
|
860
862
|
promote_launched_binding_from_team_entry(&mut launched, &launched_key);
|
|
861
863
|
preserve_existing_leader_topology(&existing, &launched_key, &mut launched);
|
|
@@ -3021,10 +3023,7 @@ fn attach_window_names_with_managed_leader(
|
|
|
3021
3023
|
}
|
|
3022
3024
|
|
|
3023
3025
|
fn state_uses_managed_leader(state: &serde_json::Value) -> bool {
|
|
3024
|
-
state
|
|
3025
|
-
.get("is_external_leader")
|
|
3026
|
-
.and_then(serde_json::Value::as_bool)
|
|
3027
|
-
.is_some_and(|external| !external)
|
|
3026
|
+
crate::state::projection::state_is_managed_leader(state)
|
|
3028
3027
|
}
|
|
3029
3028
|
|
|
3030
3029
|
/// BUG-7 helper: derive a [`QuickStartReadiness`] verdict from the just-written
|
|
@@ -3087,7 +3086,7 @@ fn quick_start_session_capture_incomplete_agents(workspace: &Path, team_key: &st
|
|
|
3087
3086
|
crate::session_capture::incomplete_interacted_resumable_agent_ids(team_state)
|
|
3088
3087
|
}
|
|
3089
3088
|
|
|
3090
|
-
fn launched_team_receiver_is_attached(workspace: &Path, team_key: &str) -> bool {
|
|
3089
|
+
pub(crate) fn launched_team_receiver_is_attached(workspace: &Path, team_key: &str) -> bool {
|
|
3091
3090
|
let Ok(state) = load_runtime_state(workspace) else {
|
|
3092
3091
|
return true;
|
|
3093
3092
|
};
|
|
@@ -3097,7 +3096,7 @@ fn launched_team_receiver_is_attached(workspace: &Path, team_key: &str) -> bool
|
|
|
3097
3096
|
.and_then(|teams| teams.get(team_key))
|
|
3098
3097
|
.unwrap_or(&state);
|
|
3099
3098
|
if team_state.get("leader_receiver").is_none() {
|
|
3100
|
-
return
|
|
3099
|
+
return crate::state::projection::state_is_external_leader(team_state);
|
|
3101
3100
|
}
|
|
3102
3101
|
if team_uses_fake_model_harness(team_state) {
|
|
3103
3102
|
return true;
|
|
@@ -4366,6 +4365,7 @@ fn initial_runtime_state(
|
|
|
4366
4365
|
"display_backend".to_string(),
|
|
4367
4366
|
serde_json::json!(display_backend),
|
|
4368
4367
|
);
|
|
4368
|
+
state.insert("is_external_leader".to_string(), serde_json::json!(false));
|
|
4369
4369
|
let mut state = serde_json::Value::Object(state);
|
|
4370
4370
|
if !seed_launched_owner_from_env(&mut state) {
|
|
4371
4371
|
let team_id = crate::state::projection::team_state_key(&state);
|
|
@@ -206,6 +206,7 @@ pub(crate) fn start_agent_at_paths(
|
|
|
206
206
|
start_mode,
|
|
207
207
|
target: spawn.spawn.pane_id.as_str().to_string(),
|
|
208
208
|
session_id,
|
|
209
|
+
new_session_id: spawn.plan.expected_session_id.clone(),
|
|
209
210
|
rollout_path,
|
|
210
211
|
})
|
|
211
212
|
}
|
|
@@ -528,8 +529,8 @@ fn reset_agent_at_paths(
|
|
|
528
529
|
.and_then(|v| v.get(agent_id.as_str()))
|
|
529
530
|
.and_then(|v| v.get("session_id"))
|
|
530
531
|
.and_then(|v| v.as_str())
|
|
531
|
-
.
|
|
532
|
-
.
|
|
532
|
+
.filter(|session| !session.is_empty())
|
|
533
|
+
.map(SessionId::new);
|
|
533
534
|
crate::lifecycle::launch::ensure_owner_allowed_for_state(&state_before_stop, Some(agent_id))?;
|
|
534
535
|
let stop = stop_agent_at_paths(workspace, spec_workspace, agent_id, team, transport)?;
|
|
535
536
|
let mut state = resolve_team_scoped_state_or_refuse(workspace, team)?;
|
|
@@ -537,11 +538,22 @@ fn reset_agent_at_paths(
|
|
|
537
538
|
discard_agent_session_fields(&mut state, agent_id)?;
|
|
538
539
|
// golden operations.py (reset): save_team_scoped_state on the team projection — same multi-team
|
|
539
540
|
// preservation as stop, not a raw save_runtime_state.
|
|
540
|
-
crate::state::projection::
|
|
541
|
+
crate::state::projection::save_team_scoped_state_with_tombstoned_agents(
|
|
542
|
+
workspace,
|
|
543
|
+
&state,
|
|
544
|
+
&[agent_id.as_str()],
|
|
545
|
+
)
|
|
541
546
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
542
547
|
// golden operations.py:125: write_team_state after the discard-save (the intermediate stopped snapshot).
|
|
543
548
|
write_team_state(spec_workspace, &spec, &state)?;
|
|
544
|
-
write_reset_tombstone_event(
|
|
549
|
+
write_reset_tombstone_event(
|
|
550
|
+
workspace,
|
|
551
|
+
agent_id,
|
|
552
|
+
discarded_session_id
|
|
553
|
+
.as_ref()
|
|
554
|
+
.map(SessionId::as_str)
|
|
555
|
+
.unwrap_or(""),
|
|
556
|
+
)?;
|
|
545
557
|
let start = start_agent_at_paths(
|
|
546
558
|
workspace,
|
|
547
559
|
spec_workspace,
|
|
@@ -556,11 +568,34 @@ fn reset_agent_at_paths(
|
|
|
556
568
|
write_reset_complete_event(workspace, agent_id, stop.stopped, started)?;
|
|
557
569
|
match start {
|
|
558
570
|
StartAgentOutcome::Running {
|
|
559
|
-
env,
|
|
560
|
-
|
|
571
|
+
env,
|
|
572
|
+
start_mode,
|
|
573
|
+
session_id,
|
|
574
|
+
new_session_id,
|
|
575
|
+
..
|
|
576
|
+
} => {
|
|
577
|
+
let output_session_id = if matches!(
|
|
578
|
+
start_mode,
|
|
579
|
+
StartMode::Fresh | StartMode::FreshAfterMissingRollout
|
|
580
|
+
) {
|
|
581
|
+
new_session_id.clone().or(session_id)
|
|
582
|
+
} else {
|
|
583
|
+
session_id
|
|
584
|
+
};
|
|
585
|
+
Ok(ResetAgentOutcome::Reset {
|
|
586
|
+
env,
|
|
587
|
+
start_mode,
|
|
588
|
+
discarded_session_id,
|
|
589
|
+
session_id: output_session_id,
|
|
590
|
+
new_session_id,
|
|
591
|
+
})
|
|
592
|
+
}
|
|
561
593
|
StartAgentOutcome::Noop { env, .. } => Ok(ResetAgentOutcome::Reset {
|
|
562
594
|
env,
|
|
563
595
|
start_mode: StartMode::Noop,
|
|
596
|
+
discarded_session_id,
|
|
597
|
+
session_id: None,
|
|
598
|
+
new_session_id: None,
|
|
564
599
|
}),
|
|
565
600
|
StartAgentOutcome::Paused { .. } => Ok(ResetAgentOutcome::Reset {
|
|
566
601
|
env: AgentActionEnvelope {
|
|
@@ -569,6 +604,9 @@ fn reset_agent_at_paths(
|
|
|
569
604
|
coordinator_started: false,
|
|
570
605
|
},
|
|
571
606
|
start_mode: StartMode::Noop,
|
|
607
|
+
discarded_session_id,
|
|
608
|
+
session_id: None,
|
|
609
|
+
new_session_id: None,
|
|
572
610
|
}),
|
|
573
611
|
}
|
|
574
612
|
}
|
|
@@ -1200,9 +1200,15 @@ fn quick_start_persists_selected_tmux_endpoint_and_attach_commands() {
|
|
|
1200
1200
|
.any(|cmd| cmd.contains(&format!("tmux -S {endpoint} attach -t "))),
|
|
1201
1201
|
"attach commands must use the selected socket endpoint: {attach_commands:?}"
|
|
1202
1202
|
);
|
|
1203
|
+
assert!(
|
|
1204
|
+
attach_commands.iter().any(|cmd| cmd.contains(":leader")),
|
|
1205
|
+
"fresh managed quick-start must include the leader window attach command: {attach_commands:?}"
|
|
1206
|
+
);
|
|
1203
1207
|
let (_raw, state) = raw_runtime_state(workspace);
|
|
1204
1208
|
assert_eq!(state["tmux_endpoint"], json!(endpoint));
|
|
1205
1209
|
assert_eq!(state["tmux_socket"], json!(endpoint));
|
|
1210
|
+
assert_eq!(state["is_external_leader"], json!(false));
|
|
1211
|
+
assert_eq!(state["teams"]["teamdir"]["is_external_leader"], json!(false));
|
|
1206
1212
|
}
|
|
1207
1213
|
|
|
1208
1214
|
#[test]
|
|
@@ -1249,6 +1255,33 @@ fn quick_start_preserves_managed_leader_topology_and_emits_leader_attach_command
|
|
|
1249
1255
|
assert_eq!(state["leader_client"]["diagnostic_only"], json!(true));
|
|
1250
1256
|
}
|
|
1251
1257
|
|
|
1258
|
+
#[test]
|
|
1259
|
+
fn fresh_managed_state_without_receiver_is_not_attached() {
|
|
1260
|
+
let workspace = temp_ws();
|
|
1261
|
+
crate::state::persist::save_runtime_state(
|
|
1262
|
+
&workspace,
|
|
1263
|
+
&json!({
|
|
1264
|
+
"active_team_key": "teamdir",
|
|
1265
|
+
"session_name": "team-teamdir",
|
|
1266
|
+
"is_external_leader": false,
|
|
1267
|
+
"agents": {"implementer": {"status": "running"}},
|
|
1268
|
+
"teams": {
|
|
1269
|
+
"teamdir": {
|
|
1270
|
+
"session_name": "team-teamdir",
|
|
1271
|
+
"is_external_leader": false,
|
|
1272
|
+
"agents": {"implementer": {"status": "running"}}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}),
|
|
1276
|
+
)
|
|
1277
|
+
.unwrap();
|
|
1278
|
+
|
|
1279
|
+
assert!(
|
|
1280
|
+
!crate::lifecycle::launch::launched_team_receiver_is_attached(&workspace, "teamdir"),
|
|
1281
|
+
"managed topology with missing leader_receiver must not be reported attached"
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1252
1285
|
#[test]
|
|
1253
1286
|
fn attach_window_names_for_state_agents_include_managed_leader_and_layout_windows() {
|
|
1254
1287
|
let state = json!({
|
|
@@ -2225,9 +2258,11 @@ fn quick_start_seeds_tasks_key_from_compiled_spec() {
|
|
|
2225
2258
|
// cross-team reads cannot accidentally pick up another team's binding from the root.
|
|
2226
2259
|
// The post-launch hook drops the top-level owner triple when the launched pane is
|
|
2227
2260
|
// unbound (empty pane_id), and only the per-team entry retains the binding.
|
|
2228
|
-
//
|
|
2229
|
-
//
|
|
2230
|
-
//
|
|
2261
|
+
// R1: topology is explicit; fresh managed teams carry `is_external_leader=false`
|
|
2262
|
+
// before the team-in-team suffix. Order remains the golden prefix
|
|
2263
|
+
// (spec_path … display_backend) + topology marker + new suffix (active_team_key,
|
|
2264
|
+
// teams). display_backend stays the resolved backend from display/backend.py:12-29
|
|
2265
|
+
// (default adaptive), not the raw optional spec field.
|
|
2231
2266
|
#[test]
|
|
2232
2267
|
#[serial(env)]
|
|
2233
2268
|
fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
|
|
@@ -2264,11 +2299,13 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
|
|
|
2264
2299
|
"agents",
|
|
2265
2300
|
"tasks",
|
|
2266
2301
|
"display_backend",
|
|
2302
|
+
"is_external_leader",
|
|
2267
2303
|
"active_team_key",
|
|
2268
2304
|
"teams",
|
|
2269
2305
|
],
|
|
2270
2306
|
"state.json top-level key order must match golden launch/core.py:62-71 \
|
|
2271
|
-
plus Bug 1/2 team-in-team suffix
|
|
2307
|
+
plus R1 topology marker and Bug 1/2 team-in-team suffix \
|
|
2308
|
+
(is_external_leader, active_team_key, teams). \
|
|
2272
2309
|
Bug 2 owner team-scope (N1/N12/N18/N29) deliberately keeps owner / \
|
|
2273
2310
|
leader_receiver / owner_epoch OFF the top level — they live ONLY under \
|
|
2274
2311
|
teams[<active_team_key>] so per-team isolation has a single source of \
|
|
@@ -2308,6 +2345,7 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
|
|
|
2308
2345
|
json!("adaptive"),
|
|
2309
2346
|
"adaptive layout directive: resolve_display_backend(None, source='launch') defaults to adaptive"
|
|
2310
2347
|
);
|
|
2348
|
+
assert_eq!(state["is_external_leader"], json!(false));
|
|
2311
2349
|
}
|
|
2312
2350
|
|
|
2313
2351
|
// Stage B1 — golden launch/core.py:238-255 writes the running agent state only after
|
|
@@ -535,6 +535,7 @@ pub enum StartAgentOutcome {
|
|
|
535
535
|
start_mode: StartMode,
|
|
536
536
|
target: String,
|
|
537
537
|
session_id: Option<SessionId>,
|
|
538
|
+
new_session_id: Option<SessionId>,
|
|
538
539
|
rollout_path: Option<RolloutPath>,
|
|
539
540
|
},
|
|
540
541
|
/// 窗口已存在且非 force → noop(`start.py:134`)。
|
|
@@ -565,6 +566,9 @@ pub enum ResetAgentOutcome {
|
|
|
565
566
|
Reset {
|
|
566
567
|
env: AgentActionEnvelope,
|
|
567
568
|
start_mode: StartMode,
|
|
569
|
+
discarded_session_id: Option<SessionId>,
|
|
570
|
+
session_id: Option<SessionId>,
|
|
571
|
+
new_session_id: Option<SessionId>,
|
|
568
572
|
},
|
|
569
573
|
/// 未传 discard_session → 拒绝(不丢上下文的误用保护)。
|
|
570
574
|
Refused { reason: ResetRefusal },
|
|
@@ -60,7 +60,13 @@ pub(crate) fn reset_agent(
|
|
|
60
60
|
)
|
|
61
61
|
.map_err(tool_runtime_error)?
|
|
62
62
|
{
|
|
63
|
-
ResetAgentOutcome::Reset {
|
|
63
|
+
ResetAgentOutcome::Reset {
|
|
64
|
+
env,
|
|
65
|
+
start_mode,
|
|
66
|
+
discarded_session_id,
|
|
67
|
+
session_id,
|
|
68
|
+
new_session_id,
|
|
69
|
+
} => Ok(ToolOk {
|
|
64
70
|
fields: object_fields(serde_json::json!({
|
|
65
71
|
"ok": true,
|
|
66
72
|
"agent_id": env.agent_id.as_str(),
|
|
@@ -68,6 +74,9 @@ pub(crate) fn reset_agent(
|
|
|
68
74
|
"state_file": env.state_file.to_string_lossy().to_string(),
|
|
69
75
|
"coordinator_started": env.coordinator_started,
|
|
70
76
|
"start_mode": enum_value(start_mode),
|
|
77
|
+
"discarded_session_id": discarded_session_id.as_ref().map(|id| id.as_str()),
|
|
78
|
+
"session_id": session_id.as_ref().map(|id| id.as_str()),
|
|
79
|
+
"new_session_id": new_session_id.as_ref().map(|id| id.as_str()),
|
|
71
80
|
})),
|
|
72
81
|
}),
|
|
73
82
|
ResetAgentOutcome::Refused { reason } => Ok(ToolOk {
|
|
@@ -170,13 +170,38 @@ impl Drop for RuntimeLock {
|
|
|
170
170
|
/// `save_runtime_state`(bug-084)。`state` 是 state.json 的内存 Value(插入序保留)。
|
|
171
171
|
/// 注:Python 在此还调 `_migrate_state_identity`(identity slice 落地后接入;本 slice 不改 state 内容)。
|
|
172
172
|
pub fn save_runtime_state(workspace: &Path, state: &Value) -> Result<(), StateError> {
|
|
173
|
-
|
|
173
|
+
save_runtime_state_with_merge_exceptions(workspace, state, &[], None, &[])
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
pub(crate) fn save_runtime_state_with_deleted_agents(
|
|
177
177
|
workspace: &Path,
|
|
178
178
|
state: &Value,
|
|
179
179
|
deleted_agent_ids: &[&str],
|
|
180
|
+
) -> Result<(), StateError> {
|
|
181
|
+
save_runtime_state_with_merge_exceptions(workspace, state, deleted_agent_ids, None, &[])
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
pub(crate) fn save_runtime_state_with_team_tombstoned_agents(
|
|
185
|
+
workspace: &Path,
|
|
186
|
+
state: &Value,
|
|
187
|
+
tombstoned_team_key: &str,
|
|
188
|
+
tombstoned_agent_ids: &[&str],
|
|
189
|
+
) -> Result<(), StateError> {
|
|
190
|
+
save_runtime_state_with_merge_exceptions(
|
|
191
|
+
workspace,
|
|
192
|
+
state,
|
|
193
|
+
&[],
|
|
194
|
+
Some(tombstoned_team_key),
|
|
195
|
+
tombstoned_agent_ids,
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
fn save_runtime_state_with_merge_exceptions(
|
|
200
|
+
workspace: &Path,
|
|
201
|
+
state: &Value,
|
|
202
|
+
deleted_agent_ids: &[&str],
|
|
203
|
+
skip_capture_backfill_team_key: Option<&str>,
|
|
204
|
+
skip_capture_backfill_agent_ids: &[&str],
|
|
180
205
|
) -> Result<(), StateError> {
|
|
181
206
|
let path = runtime_state_path(workspace);
|
|
182
207
|
if cache_equals(&path, state) {
|
|
@@ -218,7 +243,19 @@ pub(crate) fn save_runtime_state_with_deleted_agents(
|
|
|
218
243
|
.filter(|id| !id.is_empty())
|
|
219
244
|
.map(str::to_string)
|
|
220
245
|
.collect::<BTreeSet<_>>();
|
|
221
|
-
|
|
246
|
+
let skip_capture_backfill = skip_capture_backfill_agent_ids
|
|
247
|
+
.iter()
|
|
248
|
+
.copied()
|
|
249
|
+
.filter(|id| !id.is_empty())
|
|
250
|
+
.map(str::to_string)
|
|
251
|
+
.collect::<BTreeSet<_>>();
|
|
252
|
+
preserve_latest_roster_entries(
|
|
253
|
+
&mut migrated,
|
|
254
|
+
&latest,
|
|
255
|
+
&deleted,
|
|
256
|
+
skip_capture_backfill_team_key,
|
|
257
|
+
&skip_capture_backfill,
|
|
258
|
+
);
|
|
222
259
|
}
|
|
223
260
|
// 字节对拍 Python json.dumps(indent=2, ensure_ascii=False)(无尾换行)。
|
|
224
261
|
let payload = serde_json::to_string_pretty(&migrated)?;
|
|
@@ -269,19 +306,34 @@ fn read_latest_state_under_lock(workspace: &Path, path: &Path) -> Option<Value>
|
|
|
269
306
|
Some(latest)
|
|
270
307
|
}
|
|
271
308
|
|
|
272
|
-
fn preserve_latest_roster_entries(
|
|
309
|
+
fn preserve_latest_roster_entries(
|
|
310
|
+
incoming: &mut Value,
|
|
311
|
+
latest: &Value,
|
|
312
|
+
deleted_agent_ids: &BTreeSet<String>,
|
|
313
|
+
skip_capture_backfill_team_key: Option<&str>,
|
|
314
|
+
skip_capture_backfill_agent_ids: &BTreeSet<String>,
|
|
315
|
+
) {
|
|
273
316
|
// A0/R1: the projection gate only guards the TOP-LEVEL passes (top-level agents and
|
|
274
317
|
// the top-level<->active-team cross projections depend on which team is active); the
|
|
275
318
|
// per-team `teams.<k>.agents` merge below is team-key self-identifying and must run
|
|
276
319
|
// even when another process flipped session_name/active_team_key between this
|
|
277
320
|
// writer's load and save.
|
|
278
321
|
let projection_matches = same_runtime_projection(incoming, latest);
|
|
322
|
+
let active_team = active_team_key(incoming).or_else(|| active_team_key(latest));
|
|
323
|
+
let top_level_team = Some(team_state_key(incoming)).or_else(|| Some(team_state_key(latest)));
|
|
324
|
+
let skip_top_level_capture_backfill =
|
|
325
|
+
should_skip_capture_backfill(top_level_team.as_deref(), skip_capture_backfill_team_key);
|
|
279
326
|
if projection_matches {
|
|
280
|
-
preserve_missing_agents(
|
|
327
|
+
preserve_missing_agents(
|
|
328
|
+
incoming.get_mut("agents"),
|
|
329
|
+
latest.get("agents"),
|
|
330
|
+
deleted_agent_ids,
|
|
331
|
+
skip_top_level_capture_backfill,
|
|
332
|
+
skip_capture_backfill_agent_ids,
|
|
333
|
+
);
|
|
281
334
|
preserve_latest_ownership_fields(incoming, latest);
|
|
282
335
|
}
|
|
283
336
|
|
|
284
|
-
let active_team = active_team_key(incoming).or_else(|| active_team_key(latest));
|
|
285
337
|
if projection_matches {
|
|
286
338
|
if let Some(active_team) = active_team.as_deref() {
|
|
287
339
|
let latest_active_agents = latest
|
|
@@ -289,7 +341,13 @@ fn preserve_latest_roster_entries(incoming: &mut Value, latest: &Value, deleted_
|
|
|
289
341
|
.and_then(Value::as_object)
|
|
290
342
|
.and_then(|teams| teams.get(active_team))
|
|
291
343
|
.and_then(|entry| entry.get("agents"));
|
|
292
|
-
preserve_missing_agents(
|
|
344
|
+
preserve_missing_agents(
|
|
345
|
+
incoming.get_mut("agents"),
|
|
346
|
+
latest_active_agents,
|
|
347
|
+
deleted_agent_ids,
|
|
348
|
+
skip_top_level_capture_backfill,
|
|
349
|
+
skip_capture_backfill_agent_ids,
|
|
350
|
+
);
|
|
293
351
|
}
|
|
294
352
|
}
|
|
295
353
|
|
|
@@ -306,6 +364,8 @@ fn preserve_latest_roster_entries(incoming: &mut Value, latest: &Value, deleted_
|
|
|
306
364
|
incoming_entry.get_mut("agents"),
|
|
307
365
|
latest_entry.get("agents"),
|
|
308
366
|
deleted_agent_ids,
|
|
367
|
+
should_skip_capture_backfill(Some(team), skip_capture_backfill_team_key),
|
|
368
|
+
skip_capture_backfill_agent_ids,
|
|
309
369
|
);
|
|
310
370
|
preserve_latest_ownership_fields(incoming_entry, latest_entry);
|
|
311
371
|
}
|
|
@@ -314,13 +374,26 @@ fn preserve_latest_roster_entries(incoming: &mut Value, latest: &Value, deleted_
|
|
|
314
374
|
if let Some(active_team) = active_team.as_deref() {
|
|
315
375
|
let latest_top_agents = latest.get("agents");
|
|
316
376
|
if let Some(incoming_entry) = incoming_teams.get_mut(active_team) {
|
|
317
|
-
preserve_missing_agents(
|
|
377
|
+
preserve_missing_agents(
|
|
378
|
+
incoming_entry.get_mut("agents"),
|
|
379
|
+
latest_top_agents,
|
|
380
|
+
deleted_agent_ids,
|
|
381
|
+
should_skip_capture_backfill(Some(active_team), skip_capture_backfill_team_key),
|
|
382
|
+
skip_capture_backfill_agent_ids,
|
|
383
|
+
);
|
|
318
384
|
preserve_latest_ownership_fields(incoming_entry, latest);
|
|
319
385
|
}
|
|
320
386
|
}
|
|
321
387
|
}
|
|
322
388
|
}
|
|
323
389
|
|
|
390
|
+
fn should_skip_capture_backfill(current_team_key: Option<&str>, skip_team_key: Option<&str>) -> bool {
|
|
391
|
+
match skip_team_key {
|
|
392
|
+
Some(skip_team_key) => current_team_key == Some(skip_team_key),
|
|
393
|
+
None => true,
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
324
397
|
fn preserve_latest_ownership_fields(incoming: &mut Value, latest: &Value) {
|
|
325
398
|
if !latest_has_preferable_ownership(incoming, latest) {
|
|
326
399
|
return;
|
|
@@ -379,6 +452,8 @@ fn preserve_missing_agents(
|
|
|
379
452
|
incoming_agents: Option<&mut Value>,
|
|
380
453
|
latest_agents: Option<&Value>,
|
|
381
454
|
deleted_agent_ids: &BTreeSet<String>,
|
|
455
|
+
skip_capture_backfill: bool,
|
|
456
|
+
skip_capture_backfill_agent_ids: &BTreeSet<String>,
|
|
382
457
|
) {
|
|
383
458
|
let Some(incoming_agents) = incoming_agents else {
|
|
384
459
|
return;
|
|
@@ -398,7 +473,9 @@ fn preserve_missing_agents(
|
|
|
398
473
|
slot.insert(latest_agent.clone());
|
|
399
474
|
}
|
|
400
475
|
serde_json::map::Entry::Occupied(mut existing) => {
|
|
401
|
-
|
|
476
|
+
if !skip_capture_backfill || !skip_capture_backfill_agent_ids.contains(agent_id) {
|
|
477
|
+
backfill_capture_fields(existing.get_mut(), latest_agent);
|
|
478
|
+
}
|
|
402
479
|
}
|
|
403
480
|
}
|
|
404
481
|
}
|
|
@@ -14,7 +14,7 @@ use std::path::Path;
|
|
|
14
14
|
use serde_json::{json, Map, Value};
|
|
15
15
|
|
|
16
16
|
use super::StateError;
|
|
17
|
-
use crate::state::persist::{load_runtime_state, save_runtime_state_with_deleted_agents};
|
|
17
|
+
use crate::state::persist::{load_runtime_state, save_runtime_state_with_deleted_agents, save_runtime_state_with_team_tombstoned_agents};
|
|
18
18
|
|
|
19
19
|
/// `team_state_key`(`state.py:93`):从 team_dir(.name)/spec_path(.parent.name)派生 team key,
|
|
20
20
|
/// 跳过 `.team`/`runtime`;兜底 `session_name` 或 `"current"`。
|
|
@@ -162,6 +162,17 @@ pub fn compact_team_state(state: &Value) -> Value {
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
pub fn state_is_external_leader(state: &Value) -> bool {
|
|
166
|
+
state
|
|
167
|
+
.get("is_external_leader")
|
|
168
|
+
.and_then(Value::as_bool)
|
|
169
|
+
.unwrap_or(false)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
pub fn state_is_managed_leader(state: &Value) -> bool {
|
|
173
|
+
!state_is_external_leader(state)
|
|
174
|
+
}
|
|
175
|
+
|
|
165
176
|
/// `merge_workspace_team_state`(`state.py:111`):把新启动的 team 并入既有 workspace state。
|
|
166
177
|
pub fn merge_workspace_team_state(existing: &Value, launched: &Value) -> Value {
|
|
167
178
|
let launched_key = team_state_key(launched);
|
|
@@ -471,6 +482,23 @@ pub(crate) fn save_team_scoped_state_with_deleted_agents(
|
|
|
471
482
|
workspace: &Path,
|
|
472
483
|
team_state: &Value,
|
|
473
484
|
deleted_agent_ids: &[&str],
|
|
485
|
+
) -> Result<(), StateError> {
|
|
486
|
+
save_team_scoped_state_with_merge_exceptions(workspace, team_state, deleted_agent_ids, &[])
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
pub(crate) fn save_team_scoped_state_with_tombstoned_agents(
|
|
490
|
+
workspace: &Path,
|
|
491
|
+
team_state: &Value,
|
|
492
|
+
tombstoned_agent_ids: &[&str],
|
|
493
|
+
) -> Result<(), StateError> {
|
|
494
|
+
save_team_scoped_state_with_merge_exceptions(workspace, team_state, &[], tombstoned_agent_ids)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
fn save_team_scoped_state_with_merge_exceptions(
|
|
498
|
+
workspace: &Path,
|
|
499
|
+
team_state: &Value,
|
|
500
|
+
deleted_agent_ids: &[&str],
|
|
501
|
+
tombstoned_agent_ids: &[&str],
|
|
474
502
|
) -> Result<(), StateError> {
|
|
475
503
|
let target_key = team_state_key(team_state);
|
|
476
504
|
let existing = load_runtime_state(workspace)?;
|
|
@@ -500,7 +528,15 @@ pub(crate) fn save_team_scoped_state_with_deleted_agents(
|
|
|
500
528
|
// not existing_teams and existing_primary_key == target_key → 纯 save(剔 teams)。
|
|
501
529
|
if existing_teams.is_empty() && existing_primary_key.as_deref() == Some(target_key.as_str()) {
|
|
502
530
|
let merged = compact_team_state(team_state);
|
|
503
|
-
|
|
531
|
+
if tombstoned_agent_ids.is_empty() {
|
|
532
|
+
return save_runtime_state_with_deleted_agents(workspace, &merged, deleted_agent_ids);
|
|
533
|
+
}
|
|
534
|
+
return save_runtime_state_with_team_tombstoned_agents(
|
|
535
|
+
workspace,
|
|
536
|
+
&merged,
|
|
537
|
+
&target_key,
|
|
538
|
+
tombstoned_agent_ids,
|
|
539
|
+
);
|
|
504
540
|
}
|
|
505
541
|
// teams = deepcopy(incoming_teams or existing_teams)
|
|
506
542
|
let mut teams = match incoming_teams {
|
|
@@ -520,7 +556,16 @@ pub(crate) fn save_team_scoped_state_with_deleted_agents(
|
|
|
520
556
|
if merged.get("teams").and_then(Value::as_object).is_some_and(Map::is_empty) {
|
|
521
557
|
merged.remove("teams");
|
|
522
558
|
}
|
|
523
|
-
|
|
559
|
+
if tombstoned_agent_ids.is_empty() {
|
|
560
|
+
save_runtime_state_with_deleted_agents(workspace, &Value::Object(merged), deleted_agent_ids)
|
|
561
|
+
} else {
|
|
562
|
+
save_runtime_state_with_team_tombstoned_agents(
|
|
563
|
+
workspace,
|
|
564
|
+
&Value::Object(merged),
|
|
565
|
+
&target_key,
|
|
566
|
+
tombstoned_agent_ids,
|
|
567
|
+
)
|
|
568
|
+
}
|
|
524
569
|
}
|
|
525
570
|
|
|
526
571
|
// ---- helpers ----
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
9
9
|
|
|
10
10
|
use std::collections::{BTreeMap, VecDeque};
|
|
11
|
+
use std::os::unix::net::UnixListener;
|
|
11
12
|
use std::path::Path;
|
|
12
13
|
use std::sync::{Arc, Mutex};
|
|
13
14
|
|
|
@@ -251,8 +252,20 @@
|
|
|
251
252
|
}
|
|
252
253
|
|
|
253
254
|
#[test]
|
|
255
|
+
#[serial_test::serial(env)]
|
|
254
256
|
fn leader_receiver_short_endpoint_must_not_reconstruct_tmux_l_socket() {
|
|
255
257
|
let endpoint = "dl9aa40c88";
|
|
258
|
+
let uid = unsafe { libc::geteuid() };
|
|
259
|
+
let tmp = std::env::temp_dir().join(format!(
|
|
260
|
+
"ta-tmux-short-endpoint-{}",
|
|
261
|
+
std::process::id()
|
|
262
|
+
));
|
|
263
|
+
let root = tmp.join(format!("tmux-{uid}"));
|
|
264
|
+
std::fs::create_dir_all(&root).unwrap();
|
|
265
|
+
let socket_path = root.join(endpoint);
|
|
266
|
+
let _listener = UnixListener::bind(&socket_path).unwrap();
|
|
267
|
+
let socket_path = socket_path.canonicalize().unwrap();
|
|
268
|
+
let _env = EnvGuard::apply(&[("TMPDIR", Some(tmp.to_str().unwrap()))]);
|
|
256
269
|
let (be, rec) = {
|
|
257
270
|
let recorded = Arc::new(Mutex::new(Vec::new()));
|
|
258
271
|
let runner = MockCommandRunner {
|
|
@@ -275,6 +288,14 @@
|
|
|
275
288
|
"non-canonical leader endpoints must be rejected or left unbound, never reconstructed as \
|
|
276
289
|
tmux -L <short> under the coordinator socket root; calls={calls:?}"
|
|
277
290
|
);
|
|
291
|
+
assert!(
|
|
292
|
+
calls.iter().all(|call| call.starts_with(&[
|
|
293
|
+
"tmux".to_string(),
|
|
294
|
+
"-S".to_string(),
|
|
295
|
+
socket_path.to_string_lossy().to_string()
|
|
296
|
+
])),
|
|
297
|
+
"short leader endpoints must resolve to the existing physical socket path with -S; calls={calls:?}"
|
|
298
|
+
);
|
|
278
299
|
}
|
|
279
300
|
|
|
280
301
|
// ── 1. has_session: exit 0 -> true, exit 1 -> false; argv = `tmux has-session -t <s>` ──────────
|
|
@@ -233,6 +233,12 @@ impl TmuxBackend {
|
|
|
233
233
|
socket: Some(TmuxSocketEndpoint::Path(endpoint.to_string())),
|
|
234
234
|
event_workspace: None,
|
|
235
235
|
}
|
|
236
|
+
} else if let Some(path) = socket_path_for_name(endpoint) {
|
|
237
|
+
Self {
|
|
238
|
+
runner: Box::new(RealCommandRunner),
|
|
239
|
+
socket: Some(TmuxSocketEndpoint::Path(path.to_string_lossy().into_owned())),
|
|
240
|
+
event_workspace: None,
|
|
241
|
+
}
|
|
236
242
|
} else {
|
|
237
243
|
Self::new()
|
|
238
244
|
}
|
|
@@ -275,6 +281,12 @@ impl TmuxBackend {
|
|
|
275
281
|
socket: None,
|
|
276
282
|
event_workspace: None,
|
|
277
283
|
}
|
|
284
|
+
} else if let Some(path) = socket_path_for_name(endpoint) {
|
|
285
|
+
Self {
|
|
286
|
+
runner,
|
|
287
|
+
socket: Some(TmuxSocketEndpoint::Path(path.to_string_lossy().into_owned())),
|
|
288
|
+
event_workspace: None,
|
|
289
|
+
}
|
|
278
290
|
} else {
|
|
279
291
|
Self {
|
|
280
292
|
runner,
|
|
@@ -340,13 +352,7 @@ pub(crate) fn socket_name_for_workspace(workspace: &Path) -> String {
|
|
|
340
352
|
}
|
|
341
353
|
|
|
342
354
|
pub(crate) fn socket_path_for_workspace(workspace: &Path) -> Option<PathBuf> {
|
|
343
|
-
|
|
344
|
-
return Some(existing);
|
|
345
|
-
}
|
|
346
|
-
let uid = unsafe { libc::geteuid() };
|
|
347
|
-
let default_root = PathBuf::from(format!("/tmp/tmux-{uid}"));
|
|
348
|
-
let default_root = default_root.canonicalize().unwrap_or(default_root);
|
|
349
|
-
Some(default_root.join(socket_name_for_workspace(workspace)))
|
|
355
|
+
socket_path_for_name(&socket_name_for_workspace(workspace))
|
|
350
356
|
}
|
|
351
357
|
|
|
352
358
|
pub(crate) fn socket_probe_missing_for_workspace(workspace: &Path) -> bool {
|
|
@@ -354,11 +360,27 @@ pub(crate) fn socket_probe_missing_for_workspace(workspace: &Path) -> bool {
|
|
|
354
360
|
}
|
|
355
361
|
|
|
356
362
|
fn existing_socket_path_for_workspace(workspace: &Path) -> Option<PathBuf> {
|
|
357
|
-
|
|
363
|
+
existing_socket_path_for_name(&socket_name_for_workspace(workspace))
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
pub(crate) fn socket_path_for_name(socket_name: &str) -> Option<PathBuf> {
|
|
367
|
+
if socket_name.is_empty() || socket_name == "default" || Path::new(socket_name).is_absolute() {
|
|
368
|
+
return None;
|
|
369
|
+
}
|
|
370
|
+
if let Some(existing) = existing_socket_path_for_name(socket_name) {
|
|
371
|
+
return Some(existing);
|
|
372
|
+
}
|
|
373
|
+
let uid = unsafe { libc::geteuid() };
|
|
374
|
+
let default_root = PathBuf::from(format!("/tmp/tmux-{uid}"));
|
|
375
|
+
let default_root = default_root.canonicalize().unwrap_or(default_root);
|
|
376
|
+
Some(default_root.join(socket_name))
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
fn existing_socket_path_for_name(socket_name: &str) -> Option<PathBuf> {
|
|
358
380
|
let roots = tmux_socket_roots();
|
|
359
381
|
for root in &roots {
|
|
360
382
|
let root = root.canonicalize().unwrap_or_else(|_| root.clone());
|
|
361
|
-
let candidate = root.join(
|
|
383
|
+
let candidate = root.join(socket_name);
|
|
362
384
|
if candidate.exists() {
|
|
363
385
|
return Some(candidate.canonicalize().unwrap_or(candidate));
|
|
364
386
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.19",
|
|
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.19",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.3.19",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.3.19"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|