@team-agent/installer 0.5.73 → 0.5.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +19 -13
- package/crates/team-agent/src/cli/emit.rs +6 -3
- package/crates/team-agent/src/cli/mod.rs +151 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +28 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +3 -0
- package/crates/team-agent/src/cli/types.rs +6 -0
- package/crates/team-agent/src/compiler.rs +14 -1
- package/crates/team-agent/src/leader/lease.rs +106 -21
- package/crates/team-agent/src/leader/registry.rs +0 -89
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +457 -73
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +16 -6
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +5 -3
- package/crates/team-agent/src/lifecycle/restart/common.rs +0 -11
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +60 -0
- package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +31 -0
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +25 -23
- package/crates/team-agent/src/messaging/delivery.rs +26 -8
- package/crates/team-agent/src/messaging/tests/runtime.rs +234 -0
- package/crates/team-agent/src/model/spec.rs +81 -7
- package/crates/team-agent/src/provider/adapters/pi.rs +14 -0
- package/crates/team-agent/src/state/persist.rs +272 -3
- package/crates/team-agent/src/state/repository.rs +9 -2
- package/crates/team-agent/src/tmux_backend/tests.rs +64 -0
- package/crates/team-agent/src/tmux_backend.rs +57 -0
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +3 -0
- package/skills/team-agent/command-coverage.json +21 -0
- package/skills/team-agent/references/team-agent-operator.md +9 -2
|
@@ -183,13 +183,23 @@ pub(crate) fn annotate_runtime_tmux_endpoint(
|
|
|
183
183
|
transport: &dyn Transport,
|
|
184
184
|
workspace: &Path,
|
|
185
185
|
) {
|
|
186
|
-
let transport_endpoint = transport.tmux_endpoint()
|
|
187
|
-
let endpoint = crate::tmux_backend::owning_tmux_endpoint_from_state(state)
|
|
188
|
-
.map(|(endpoint, _source)| endpoint.to_string())
|
|
189
|
-
.or_else(|| transport_endpoint.clone());
|
|
190
|
-
let Some(endpoint) = endpoint else {
|
|
186
|
+
let Some(transport_endpoint) = transport.tmux_endpoint() else {
|
|
191
187
|
return;
|
|
192
188
|
};
|
|
189
|
+
let persisted_endpoint = ["tmux_endpoint", "tmux_socket"]
|
|
190
|
+
.into_iter()
|
|
191
|
+
.find_map(|key| {
|
|
192
|
+
state
|
|
193
|
+
.get(key)
|
|
194
|
+
.and_then(serde_json::Value::as_str)
|
|
195
|
+
.filter(|endpoint| !endpoint.is_empty())
|
|
196
|
+
});
|
|
197
|
+
// The worker endpoint is its own authority. A restart/add-agent spawn
|
|
198
|
+
// must not let an independently-bound leader receiver rewrite it.
|
|
199
|
+
if persisted_endpoint.is_some_and(|endpoint| endpoint != transport_endpoint.as_str()) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
let endpoint = transport_endpoint.clone();
|
|
193
203
|
let endpoint_for_state = if Path::new(&endpoint).is_absolute() || endpoint == "default" {
|
|
194
204
|
endpoint.clone()
|
|
195
205
|
} else if endpoint == crate::tmux_backend::socket_name_for_workspace(workspace) {
|
|
@@ -212,7 +222,7 @@ pub(crate) fn annotate_runtime_tmux_endpoint(
|
|
|
212
222
|
.cloned()
|
|
213
223
|
.unwrap_or(serde_json::Value::Null),
|
|
214
224
|
);
|
|
215
|
-
if transport_endpoint
|
|
225
|
+
if transport_endpoint == endpoint {
|
|
216
226
|
if let Some(source) = selected_tmux_socket_source(transport, workspace) {
|
|
217
227
|
obj.insert("tmux_socket_source".to_string(), serde_json::json!(source));
|
|
218
228
|
}
|
|
@@ -577,15 +577,17 @@ pub(super) fn seeded_pane_looks_like_worker(pane: &str, started: &[StartedAgent]
|
|
|
577
577
|
}
|
|
578
578
|
|
|
579
579
|
/// ---
|
|
580
|
-
/// purpose: 给出 worker
|
|
581
|
-
/// returns: transport 是 tmux
|
|
580
|
+
/// purpose: 给出 worker 侧实际使用的 tmux endpoint
|
|
581
|
+
/// returns: transport 是 tmux 后端时优先返回 transport endpoint;缺失时按 workspace 推出 socket 名,否则 None
|
|
582
582
|
/// ---
|
|
583
583
|
pub(super) fn launched_worker_tmux_socket(
|
|
584
584
|
transport: &dyn Transport,
|
|
585
585
|
workspace: &Path,
|
|
586
586
|
) -> Option<String> {
|
|
587
587
|
if matches!(transport.kind(), crate::transport::BackendKind::Tmux) {
|
|
588
|
-
|
|
588
|
+
transport
|
|
589
|
+
.tmux_endpoint()
|
|
590
|
+
.or_else(|| Some(crate::tmux_backend::socket_name_for_workspace(workspace)))
|
|
589
591
|
} else {
|
|
590
592
|
None
|
|
591
593
|
}
|
|
@@ -976,14 +976,6 @@ pub(super) fn lifecycle_worker_tmux_backend_selection_for_state(
|
|
|
976
976
|
)));
|
|
977
977
|
}
|
|
978
978
|
}
|
|
979
|
-
if let Some((endpoint, source)) = crate::tmux_backend::owning_tmux_endpoint_from_state(state) {
|
|
980
|
-
let backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint);
|
|
981
|
-
return Ok(crate::tmux_backend::RuntimeTmuxBackendSelection {
|
|
982
|
-
tmux_endpoint_used: backend.tmux_endpoint(),
|
|
983
|
-
backend,
|
|
984
|
-
tmux_endpoint_source: source,
|
|
985
|
-
});
|
|
986
|
-
}
|
|
987
979
|
Ok(
|
|
988
980
|
crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
|
|
989
981
|
run_workspace,
|
|
@@ -1000,9 +992,6 @@ pub(super) fn lifecycle_worker_tmux_backend_for_state(
|
|
|
1000
992
|
run_workspace: &Path,
|
|
1001
993
|
state: &serde_json::Value,
|
|
1002
994
|
) -> crate::tmux_backend::TmuxBackend {
|
|
1003
|
-
if let Some((endpoint, _source)) = crate::tmux_backend::owning_tmux_endpoint_from_state(state) {
|
|
1004
|
-
return crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint);
|
|
1005
|
-
}
|
|
1006
995
|
crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(run_workspace, Some(state))
|
|
1007
996
|
.backend
|
|
1008
997
|
}
|
|
@@ -3496,6 +3496,66 @@ fn add_agent_resolves_to_persisted_endpoint_socket_not_workspace_hash() {
|
|
|
3496
3496
|
);
|
|
3497
3497
|
}
|
|
3498
3498
|
|
|
3499
|
+
/// **Regression guard**: lifecycle worker resolution must ignore the leader
|
|
3500
|
+
/// receiver endpoint. The receiver may live on the caller's default socket
|
|
3501
|
+
/// while workers/coordinator remain on their persisted private socket.
|
|
3502
|
+
#[test]
|
|
3503
|
+
fn lifecycle_worker_resolver_prefers_persisted_worker_over_leader_receiver() {
|
|
3504
|
+
let team = temp_ws().join("addteam-split-leader-worker-endpoints");
|
|
3505
|
+
std::fs::create_dir_all(&team).unwrap();
|
|
3506
|
+
let worker_endpoint = "/private/tmp/tmux-501/ta-worker";
|
|
3507
|
+
let leader_endpoint = "/private/tmp/tmux-501/default";
|
|
3508
|
+
crate::state::persist::save_runtime_state(
|
|
3509
|
+
&team,
|
|
3510
|
+
&json!({
|
|
3511
|
+
"session_name": "team-split",
|
|
3512
|
+
"tmux_endpoint": worker_endpoint,
|
|
3513
|
+
"tmux_socket": worker_endpoint,
|
|
3514
|
+
"leader_receiver": {
|
|
3515
|
+
"pane_id": "%169",
|
|
3516
|
+
"tmux_socket": leader_endpoint,
|
|
3517
|
+
"status": "attached"
|
|
3518
|
+
},
|
|
3519
|
+
"agents": {}
|
|
3520
|
+
}),
|
|
3521
|
+
)
|
|
3522
|
+
.unwrap();
|
|
3523
|
+
|
|
3524
|
+
let resolved = crate::lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state(
|
|
3525
|
+
&team, None,
|
|
3526
|
+
)
|
|
3527
|
+
.expect("resolver must succeed when state has worker and receiver endpoints");
|
|
3528
|
+
let endpoint = <crate::tmux_backend::TmuxBackend as crate::transport::Transport>::tmux_endpoint(
|
|
3529
|
+
&resolved,
|
|
3530
|
+
);
|
|
3531
|
+
assert_eq!(
|
|
3532
|
+
endpoint.as_deref(),
|
|
3533
|
+
Some(worker_endpoint),
|
|
3534
|
+
"worker lifecycle operations must stay on persisted worker endpoint, not leader receiver"
|
|
3535
|
+
);
|
|
3536
|
+
}
|
|
3537
|
+
|
|
3538
|
+
/// **Regression guard**: annotating a worker spawn must not overwrite a
|
|
3539
|
+
/// persisted worker endpoint with the independent leader receiver endpoint.
|
|
3540
|
+
#[test]
|
|
3541
|
+
fn annotate_runtime_tmux_endpoint_does_not_follow_leader_receiver() {
|
|
3542
|
+
let worker_endpoint = "/private/tmp/tmux-501/ta-worker";
|
|
3543
|
+
let leader_endpoint = "/private/tmp/tmux-501/default";
|
|
3544
|
+
let mut state = json!({
|
|
3545
|
+
"tmux_endpoint": worker_endpoint,
|
|
3546
|
+
"tmux_socket": worker_endpoint,
|
|
3547
|
+
"leader_receiver": {"tmux_socket": leader_endpoint}
|
|
3548
|
+
});
|
|
3549
|
+
let transport = OfflineTransport::new().with_tmux_endpoint(worker_endpoint);
|
|
3550
|
+
crate::lifecycle::launch::annotate_runtime_tmux_endpoint(
|
|
3551
|
+
&mut state,
|
|
3552
|
+
&transport,
|
|
3553
|
+
std::path::Path::new("/tmp/annotate-split"),
|
|
3554
|
+
);
|
|
3555
|
+
assert_eq!(state["tmux_endpoint"], json!(worker_endpoint));
|
|
3556
|
+
assert_eq!(state["tmux_socket"], json!(worker_endpoint));
|
|
3557
|
+
}
|
|
3558
|
+
|
|
3499
3559
|
/// **Regression guard**: cold workspace (no persisted endpoint) must safely
|
|
3500
3560
|
/// fall back to `TmuxBackend::for_workspace(workspace)`. Encodes the
|
|
3501
3561
|
/// first-agent / fresh-launch path so a future refactor doesn't accidentally
|
|
@@ -243,3 +243,34 @@ fn pi_role_tools_validate_team_capabilities_without_replacing_direct_pi_tools()
|
|
|
243
243
|
"role tools validate Team Agent capabilities but must not replace direct Pi tools: {argv:?}"
|
|
244
244
|
);
|
|
245
245
|
}
|
|
246
|
+
|
|
247
|
+
#[test]
|
|
248
|
+
fn pi_role_rejects_unsupported_categories_during_compile() {
|
|
249
|
+
compile_pi_role(&valid_role_with("")).expect("supported Pi tools must compile");
|
|
250
|
+
|
|
251
|
+
for unsupported in ["git_diff", "network", "provider_builtin"] {
|
|
252
|
+
let role = valid_role_with("").replace(
|
|
253
|
+
" - execute_bash\n",
|
|
254
|
+
&format!(" - execute_bash\n - {unsupported}\n"),
|
|
255
|
+
);
|
|
256
|
+
let error = compile_error(compile_pi_role(&role)).to_string();
|
|
257
|
+
assert!(
|
|
258
|
+
error.contains(&format!("Pi does not support Team Agent tool category {unsupported:?}")),
|
|
259
|
+
"compile must reject {unsupported} before launch preparation: {error}"
|
|
260
|
+
);
|
|
261
|
+
assert!(
|
|
262
|
+
error.contains("remove it from the role's tools"),
|
|
263
|
+
"compile error must provide an actionable fix: {error}"
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let alias = valid_role_with("").replace(
|
|
268
|
+
" - execute_bash\n",
|
|
269
|
+
" - execute_bash\n - \"@builtin\"\n",
|
|
270
|
+
);
|
|
271
|
+
let error = compile_error(compile_pi_role(&alias)).to_string();
|
|
272
|
+
assert!(
|
|
273
|
+
error.contains("Pi does not support Team Agent tool category \"provider_builtin\""),
|
|
274
|
+
"@builtin must use the same Pi capability contract: {error}"
|
|
275
|
+
);
|
|
276
|
+
}
|
|
@@ -320,26 +320,26 @@ fn pi_leader_and_teammate_body(hermetic: &HermeticTestEnv) {
|
|
|
320
320
|
.get("agents")
|
|
321
321
|
.cloned()
|
|
322
322
|
.expect("fixture agents");
|
|
323
|
-
let
|
|
323
|
+
let receiver_socket = "/private/tmp/tmux-501/ta-leader-receiver";
|
|
324
324
|
let owning_session = "team-agent-leader-pi-owning-team";
|
|
325
|
-
let
|
|
325
|
+
let worker_socket = "/private/tmp/tmux-501/ta-worker-persisted";
|
|
326
326
|
crate::state::persist::save_runtime_state(
|
|
327
327
|
&success_team,
|
|
328
328
|
&json!({
|
|
329
329
|
"active_team_key": "dynamic-success",
|
|
330
|
-
"tmux_endpoint":
|
|
331
|
-
"tmux_socket":
|
|
330
|
+
"tmux_endpoint": worker_socket,
|
|
331
|
+
"tmux_socket": worker_socket,
|
|
332
332
|
"teams": {
|
|
333
333
|
"dynamic-success": {
|
|
334
334
|
"agents": success_agents,
|
|
335
335
|
"team_dir": success_team,
|
|
336
336
|
"session_name": null,
|
|
337
|
-
"tmux_endpoint":
|
|
338
|
-
"tmux_socket":
|
|
337
|
+
"tmux_endpoint": worker_socket,
|
|
338
|
+
"tmux_socket": worker_socket,
|
|
339
339
|
"leader_receiver": {
|
|
340
340
|
"status": "attached",
|
|
341
341
|
"session_name": owning_session,
|
|
342
|
-
"tmux_socket":
|
|
342
|
+
"tmux_socket": receiver_socket
|
|
343
343
|
}
|
|
344
344
|
}
|
|
345
345
|
}
|
|
@@ -351,15 +351,15 @@ fn pi_leader_and_teammate_body(hermetic: &HermeticTestEnv) {
|
|
|
351
351
|
&success_team,
|
|
352
352
|
Some("dynamic-success"),
|
|
353
353
|
)
|
|
354
|
-
.expect("resolve selected
|
|
354
|
+
.expect("resolve selected worker transport");
|
|
355
355
|
assert_eq!(
|
|
356
356
|
selected_transport.tmux_endpoint().as_deref(),
|
|
357
|
-
Some(
|
|
358
|
-
"
|
|
357
|
+
Some(worker_socket),
|
|
358
|
+
"lifecycle worker transport must use persisted worker endpoint, not receiver endpoint"
|
|
359
359
|
);
|
|
360
360
|
let mut annotation_state =
|
|
361
361
|
crate::state::persist::load_runtime_state(&success_team).expect("load annotation fixture");
|
|
362
|
-
let wrong_transport = OfflineTransport::new().with_tmux_endpoint(
|
|
362
|
+
let wrong_transport = OfflineTransport::new().with_tmux_endpoint(worker_socket);
|
|
363
363
|
crate::lifecycle::launch::annotate_runtime_tmux_endpoint(
|
|
364
364
|
&mut annotation_state,
|
|
365
365
|
&wrong_transport,
|
|
@@ -369,18 +369,18 @@ fn pi_leader_and_teammate_body(hermetic: &HermeticTestEnv) {
|
|
|
369
369
|
annotation_state
|
|
370
370
|
.get("tmux_endpoint")
|
|
371
371
|
.and_then(serde_json::Value::as_str),
|
|
372
|
-
Some(
|
|
373
|
-
"worker-derived annotation must
|
|
372
|
+
Some(worker_socket),
|
|
373
|
+
"worker-derived annotation must preserve the persisted worker endpoint"
|
|
374
374
|
);
|
|
375
375
|
assert_eq!(
|
|
376
376
|
annotation_state
|
|
377
377
|
.pointer("/teams/dynamic-success/leader_receiver/tmux_socket")
|
|
378
378
|
.and_then(serde_json::Value::as_str),
|
|
379
|
-
Some(
|
|
379
|
+
Some(receiver_socket)
|
|
380
380
|
);
|
|
381
381
|
let success_transport = OfflineTransport::new()
|
|
382
382
|
.with_session_present(true)
|
|
383
|
-
.with_tmux_endpoint(
|
|
383
|
+
.with_tmux_endpoint(worker_socket);
|
|
384
384
|
crate::lifecycle::add_agent_with_transport(
|
|
385
385
|
&success_team,
|
|
386
386
|
&AgentId::new("mate"),
|
|
@@ -516,8 +516,8 @@ fn pi_leader_and_teammate_body(hermetic: &HermeticTestEnv) {
|
|
|
516
516
|
&live,
|
|
517
517
|
&json!({
|
|
518
518
|
"active_team_key": "current",
|
|
519
|
-
"tmux_endpoint":
|
|
520
|
-
"tmux_socket":
|
|
519
|
+
"tmux_endpoint": worker_socket,
|
|
520
|
+
"tmux_socket": worker_socket,
|
|
521
521
|
"agents": {
|
|
522
522
|
"implementer": {
|
|
523
523
|
"status": "running",
|
|
@@ -532,6 +532,8 @@ fn pi_leader_and_teammate_body(hermetic: &HermeticTestEnv) {
|
|
|
532
532
|
"status": "alive",
|
|
533
533
|
"team_dir": live,
|
|
534
534
|
"session_name": null,
|
|
535
|
+
"tmux_endpoint": worker_socket,
|
|
536
|
+
"tmux_socket": worker_socket,
|
|
535
537
|
"agents": {
|
|
536
538
|
"implementer": {
|
|
537
539
|
"status": "running",
|
|
@@ -655,9 +657,9 @@ int main(int argc, char **argv) {
|
|
|
655
657
|
|
|
656
658
|
let preflight = run_file_h_command_with_deadline(
|
|
657
659
|
std::process::Command::new(&shim)
|
|
658
|
-
.args(["-S",
|
|
660
|
+
.args(["-S", worker_socket, "has-session"])
|
|
659
661
|
.env("TEAM_AGENT_PI_TMUX_LOG", shim_root.join("preflight.log"))
|
|
660
|
-
.env("TEAM_AGENT_PI_OWNING_SOCKET",
|
|
662
|
+
.env("TEAM_AGENT_PI_OWNING_SOCKET", worker_socket)
|
|
661
663
|
.env("TEAM_AGENT_PI_OWNING_SESSION", live_session)
|
|
662
664
|
.env("TEAM_AGENT_PI_RUN_WORKSPACE", &live),
|
|
663
665
|
"File-H native fake has-session preflight",
|
|
@@ -678,7 +680,7 @@ int main(int argc, char **argv) {
|
|
|
678
680
|
.expect("join native shim PATH");
|
|
679
681
|
let _path = EnvVarGuard::set("PATH", joined_path);
|
|
680
682
|
let _log = EnvVarGuard::set("TEAM_AGENT_PI_TMUX_LOG", &shim_log);
|
|
681
|
-
let _socket = EnvVarGuard::set("TEAM_AGENT_PI_OWNING_SOCKET",
|
|
683
|
+
let _socket = EnvVarGuard::set("TEAM_AGENT_PI_OWNING_SOCKET", worker_socket);
|
|
682
684
|
let _session = EnvVarGuard::set("TEAM_AGENT_PI_OWNING_SESSION", live_session);
|
|
683
685
|
let _workspace = EnvVarGuard::set("TEAM_AGENT_PI_RUN_WORKSPACE", &live);
|
|
684
686
|
|
|
@@ -689,15 +691,15 @@ int main(int argc, char **argv) {
|
|
|
689
691
|
);
|
|
690
692
|
let result = tools
|
|
691
693
|
.add_agent("mate", "roles/mate.md")
|
|
692
|
-
.expect("public MCP add must use the
|
|
694
|
+
.expect("public MCP add must use the persisted worker endpoint");
|
|
693
695
|
assert_eq!(
|
|
694
696
|
result.fields.get("ok").and_then(serde_json::Value::as_bool),
|
|
695
697
|
Some(true)
|
|
696
698
|
);
|
|
697
699
|
let tmux_argv = std::fs::read_to_string(&shim_log).expect("read tmux shim log");
|
|
698
700
|
assert!(
|
|
699
|
-
tmux_argv.contains(&format!("-S {
|
|
700
|
-
"public add must address the
|
|
701
|
+
tmux_argv.contains(&format!("-S {worker_socket}")),
|
|
702
|
+
"public add must address the persisted worker socket: {tmux_argv}"
|
|
701
703
|
);
|
|
702
704
|
assert!(
|
|
703
705
|
tmux_argv.contains(&format!(
|
|
@@ -2866,7 +2866,9 @@ fn project_state_for_owner_team(
|
|
|
2866
2866
|
return Ok(OwnerTeamProjection::Refused(outcome));
|
|
2867
2867
|
}
|
|
2868
2868
|
};
|
|
2869
|
-
if top_level_state_matches_owner_team(fallback, &canonical_team)
|
|
2869
|
+
if top_level_state_matches_owner_team(fallback, &canonical_team)
|
|
2870
|
+
&& state_has_no_team_entries(fallback)
|
|
2871
|
+
{
|
|
2870
2872
|
let mut state = fallback.clone();
|
|
2871
2873
|
carry_top_level_leader_binding(&mut state, &raw);
|
|
2872
2874
|
return Ok(OwnerTeamProjection::Projected {
|
|
@@ -2874,7 +2876,8 @@ fn project_state_for_owner_team(
|
|
|
2874
2876
|
canonical_team,
|
|
2875
2877
|
});
|
|
2876
2878
|
}
|
|
2877
|
-
if top_level_state_matches_owner_team(&raw, &canonical_team)
|
|
2879
|
+
if top_level_state_matches_owner_team(&raw, &canonical_team) && state_has_no_team_entries(&raw)
|
|
2880
|
+
{
|
|
2878
2881
|
return Ok(OwnerTeamProjection::Projected {
|
|
2879
2882
|
state: raw,
|
|
2880
2883
|
canonical_team,
|
|
@@ -2999,16 +3002,31 @@ fn project_state_for_owner_team_value(
|
|
|
2999
3002
|
None
|
|
3000
3003
|
}
|
|
3001
3004
|
|
|
3002
|
-
fn top_level_state_matches_owner_team(state: &serde_json::Value, team: &str) -> bool {
|
|
3003
|
-
state
|
|
3005
|
+
pub(crate) fn top_level_state_matches_owner_team(state: &serde_json::Value, team: &str) -> bool {
|
|
3006
|
+
if !state.is_object() {
|
|
3007
|
+
return false;
|
|
3008
|
+
}
|
|
3009
|
+
if state
|
|
3004
3010
|
.get("active_team_key")
|
|
3005
3011
|
.and_then(serde_json::Value::as_str)
|
|
3006
3012
|
.is_some_and(|value| value == team)
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3013
|
+
{
|
|
3014
|
+
return true;
|
|
3015
|
+
}
|
|
3016
|
+
if state
|
|
3017
|
+
.get("session_name")
|
|
3018
|
+
.and_then(serde_json::Value::as_str)
|
|
3019
|
+
.is_some_and(|session| session == team || session.strip_prefix("team-") == Some(team))
|
|
3020
|
+
{
|
|
3021
|
+
return true;
|
|
3022
|
+
}
|
|
3023
|
+
let has_explicit_key = ["team_key", "team_dir", "spec_path"].iter().any(|field| {
|
|
3024
|
+
state
|
|
3025
|
+
.get(*field)
|
|
3010
3026
|
.and_then(serde_json::Value::as_str)
|
|
3011
|
-
.is_some_and(|
|
|
3027
|
+
.is_some_and(|value| !value.is_empty())
|
|
3028
|
+
});
|
|
3029
|
+
has_explicit_key && crate::state::projection::team_state_key(state) == team
|
|
3012
3030
|
}
|
|
3013
3031
|
|
|
3014
3032
|
/// `retry_injection_after_trust_auto_answer` (`trust_auto_answer.py`):leader 路径 trust 应答
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
use super::*;
|
|
2
2
|
use crate::transport::test_support::OfflineTransport;
|
|
3
|
+
use crate::transport::{PaneId, Target};
|
|
3
4
|
|
|
4
5
|
fn pane_info(pane_id: &str, session: &str, window: &str) -> PaneInfo {
|
|
5
6
|
PaneInfo {
|
|
@@ -3421,3 +3422,236 @@ fn gate054_fallback_pane_grep_guard_no_cross_server_chain_when_socket_recorded()
|
|
|
3421
3422
|
"fallback injection must consume the shared resolver's typed channel"
|
|
3422
3423
|
);
|
|
3423
3424
|
}
|
|
3425
|
+
|
|
3426
|
+
fn delivery_blocked_channel_reasons(ws: &std::path::Path) -> Vec<String> {
|
|
3427
|
+
EventLog::new(ws)
|
|
3428
|
+
.tail(80)
|
|
3429
|
+
.unwrap()
|
|
3430
|
+
.into_iter()
|
|
3431
|
+
.filter(|event| event.get("event") == Some(&serde_json::json!("leader_receiver.delivery_blocked")))
|
|
3432
|
+
.filter_map(|event| {
|
|
3433
|
+
event
|
|
3434
|
+
.get("channel_reason")
|
|
3435
|
+
.and_then(serde_json::Value::as_str)
|
|
3436
|
+
.map(str::to_string)
|
|
3437
|
+
})
|
|
3438
|
+
.collect()
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
fn live_leader_pane(ws: &std::path::Path, pane_id: &str) -> PaneInfo {
|
|
3442
|
+
let mut pane = pane_info(pane_id, "team-current", "leader");
|
|
3443
|
+
pane.current_path = Some(ws.to_path_buf());
|
|
3444
|
+
pane
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
#[test]
|
|
3448
|
+
fn owner_projection_null_fallback_projects_nested_current_receiver() {
|
|
3449
|
+
let ws = tmp_ws("proj-null-current");
|
|
3450
|
+
let state = serde_json::json!({
|
|
3451
|
+
"active_team_key": "current",
|
|
3452
|
+
"team_key": "current",
|
|
3453
|
+
"teams": {
|
|
3454
|
+
"current": {
|
|
3455
|
+
"team_key": "current",
|
|
3456
|
+
"leader_receiver": {
|
|
3457
|
+
"status": "attached",
|
|
3458
|
+
"mode": "direct_tmux",
|
|
3459
|
+
"pane_id": "%9"
|
|
3460
|
+
},
|
|
3461
|
+
"agents": {}
|
|
3462
|
+
}
|
|
3463
|
+
},
|
|
3464
|
+
"agents": {}
|
|
3465
|
+
});
|
|
3466
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
3467
|
+
let store = store_for(&ws);
|
|
3468
|
+
let log = EventLog::new(&ws);
|
|
3469
|
+
let message_id = store
|
|
3470
|
+
.create_message(None, "w1", "leader", "nested current", None, false, Some("current"))
|
|
3471
|
+
.unwrap();
|
|
3472
|
+
let transport = OfflineTransport::new().with_targets(vec![live_leader_pane(&ws, "%9")]);
|
|
3473
|
+
|
|
3474
|
+
let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &serde_json::json!({}))
|
|
3475
|
+
.unwrap();
|
|
3476
|
+
|
|
3477
|
+
assert!(
|
|
3478
|
+
!delivery_blocked_channel_reasons(&ws)
|
|
3479
|
+
.iter()
|
|
3480
|
+
.any(|reason| reason == "receiver_missing"),
|
|
3481
|
+
"fresh Null fallback must project teams.current, not return Null; out={out:?} reasons={:?}",
|
|
3482
|
+
delivery_blocked_channel_reasons(&ws)
|
|
3483
|
+
);
|
|
3484
|
+
assert_eq!(
|
|
3485
|
+
transport.inject_targets(),
|
|
3486
|
+
vec![Target::Pane(PaneId::new("%9"))],
|
|
3487
|
+
"nested current receiver must select pane %9; out={out:?}"
|
|
3488
|
+
);
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
#[test]
|
|
3492
|
+
fn owner_projection_non_object_does_not_match_current() {
|
|
3493
|
+
assert!(
|
|
3494
|
+
!crate::messaging::delivery::top_level_state_matches_owner_team(
|
|
3495
|
+
&serde_json::Value::Null,
|
|
3496
|
+
"current"
|
|
3497
|
+
),
|
|
3498
|
+
"Null must not match current through team_state_key default"
|
|
3499
|
+
);
|
|
3500
|
+
assert!(
|
|
3501
|
+
!crate::messaging::delivery::top_level_state_matches_owner_team(
|
|
3502
|
+
&serde_json::json!([]),
|
|
3503
|
+
"current"
|
|
3504
|
+
),
|
|
3505
|
+
"non-object must not match current"
|
|
3506
|
+
);
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
#[test]
|
|
3510
|
+
fn owner_projection_legacy_top_level_receiver_survives_null_fallback() {
|
|
3511
|
+
let ws = tmp_ws("proj-legacy-top");
|
|
3512
|
+
let state = serde_json::json!({
|
|
3513
|
+
"active_team_key": "current",
|
|
3514
|
+
"leader_receiver": {
|
|
3515
|
+
"status": "attached",
|
|
3516
|
+
"mode": "direct_tmux",
|
|
3517
|
+
"pane_id": "%3"
|
|
3518
|
+
},
|
|
3519
|
+
"agents": {}
|
|
3520
|
+
});
|
|
3521
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
3522
|
+
let store = store_for(&ws);
|
|
3523
|
+
let log = EventLog::new(&ws);
|
|
3524
|
+
let message_id = store
|
|
3525
|
+
.create_message(None, "w1", "leader", "legacy top", None, false, Some("current"))
|
|
3526
|
+
.unwrap();
|
|
3527
|
+
let transport = OfflineTransport::new().with_targets(vec![live_leader_pane(&ws, "%3")]);
|
|
3528
|
+
|
|
3529
|
+
let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &serde_json::json!({}))
|
|
3530
|
+
.unwrap();
|
|
3531
|
+
|
|
3532
|
+
assert!(
|
|
3533
|
+
!delivery_blocked_channel_reasons(&ws)
|
|
3534
|
+
.iter()
|
|
3535
|
+
.any(|reason| reason == "receiver_missing"),
|
|
3536
|
+
"legacy top-level receiver must remain usable; out={out:?}"
|
|
3537
|
+
);
|
|
3538
|
+
assert_eq!(
|
|
3539
|
+
transport.inject_targets(),
|
|
3540
|
+
vec![Target::Pane(PaneId::new("%3"))],
|
|
3541
|
+
"legacy top-level receiver must select pane %3; out={out:?}"
|
|
3542
|
+
);
|
|
3543
|
+
}
|
|
3544
|
+
|
|
3545
|
+
#[test]
|
|
3546
|
+
fn owner_projection_missing_selected_does_not_borrow_sibling() {
|
|
3547
|
+
let ws = tmp_ws("proj-no-sibling");
|
|
3548
|
+
let state = serde_json::json!({
|
|
3549
|
+
"active_team_key": "alpha",
|
|
3550
|
+
"team_key": "alpha",
|
|
3551
|
+
"teams": {
|
|
3552
|
+
"alpha": {
|
|
3553
|
+
"team_key": "alpha",
|
|
3554
|
+
"agents": {}
|
|
3555
|
+
},
|
|
3556
|
+
"beta": {
|
|
3557
|
+
"team_key": "beta",
|
|
3558
|
+
"leader_receiver": {
|
|
3559
|
+
"status": "attached",
|
|
3560
|
+
"mode": "direct_tmux",
|
|
3561
|
+
"pane_id": "%beta"
|
|
3562
|
+
},
|
|
3563
|
+
"agents": {}
|
|
3564
|
+
}
|
|
3565
|
+
},
|
|
3566
|
+
"agents": {}
|
|
3567
|
+
});
|
|
3568
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
3569
|
+
let store = store_for(&ws);
|
|
3570
|
+
let log = EventLog::new(&ws);
|
|
3571
|
+
let message_id = store
|
|
3572
|
+
.create_message(None, "w1", "leader", "alpha has no receiver", None, false, Some("alpha"))
|
|
3573
|
+
.unwrap();
|
|
3574
|
+
let transport = OfflineTransport::new().with_targets(vec![live_leader_pane(&ws, "%beta")]);
|
|
3575
|
+
|
|
3576
|
+
let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &serde_json::json!({}))
|
|
3577
|
+
.unwrap();
|
|
3578
|
+
|
|
3579
|
+
assert!(
|
|
3580
|
+
delivery_blocked_channel_reasons(&ws)
|
|
3581
|
+
.iter()
|
|
3582
|
+
.any(|reason| reason == "receiver_missing"),
|
|
3583
|
+
"alpha must not borrow beta's receiver; out={out:?} reasons={:?}",
|
|
3584
|
+
delivery_blocked_channel_reasons(&ws)
|
|
3585
|
+
);
|
|
3586
|
+
assert_eq!(transport.inject_targets().len(), 0, "must not inject into the sibling pane");
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
#[test]
|
|
3590
|
+
fn owner_projection_peer_inject_uses_nested_worker_pane_not_top_level() {
|
|
3591
|
+
let ws = tmp_ws("proj-peer-nested");
|
|
3592
|
+
let state = serde_json::json!({
|
|
3593
|
+
"active_team_key": "teamA",
|
|
3594
|
+
"session_name": "team-top",
|
|
3595
|
+
"agents": {
|
|
3596
|
+
"worker_b": {
|
|
3597
|
+
"provider": "fake",
|
|
3598
|
+
"status": "running",
|
|
3599
|
+
"window": "worker_b",
|
|
3600
|
+
"pane_id": "%top-b"
|
|
3601
|
+
}
|
|
3602
|
+
},
|
|
3603
|
+
"teams": {
|
|
3604
|
+
"teamA": {
|
|
3605
|
+
"session_name": "team-nested",
|
|
3606
|
+
"agents": {
|
|
3607
|
+
"worker_b": {
|
|
3608
|
+
"provider": "fake",
|
|
3609
|
+
"status": "running",
|
|
3610
|
+
"window": "worker_b",
|
|
3611
|
+
"pane_id": "%nested-b"
|
|
3612
|
+
}
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3616
|
+
});
|
|
3617
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
3618
|
+
let store = store_for(&ws);
|
|
3619
|
+
let log = EventLog::new(&ws);
|
|
3620
|
+
let message_id = store
|
|
3621
|
+
.create_message(
|
|
3622
|
+
None,
|
|
3623
|
+
"worker_a",
|
|
3624
|
+
"worker_b",
|
|
3625
|
+
"MCP_SIM_BROADCAST_CANARY",
|
|
3626
|
+
None,
|
|
3627
|
+
false,
|
|
3628
|
+
Some("teamA"),
|
|
3629
|
+
)
|
|
3630
|
+
.unwrap();
|
|
3631
|
+
let transport = OfflineTransport::new().with_targets(vec![
|
|
3632
|
+
pane_info("%top-b", "team-top", "worker_b"),
|
|
3633
|
+
pane_info("%nested-b", "team-nested", "worker_b"),
|
|
3634
|
+
]);
|
|
3635
|
+
|
|
3636
|
+
let out = deliver_pending_message(
|
|
3637
|
+
&ws,
|
|
3638
|
+
&store,
|
|
3639
|
+
&transport,
|
|
3640
|
+
&message_id,
|
|
3641
|
+
&log,
|
|
3642
|
+
&serde_json::json!({}),
|
|
3643
|
+
)
|
|
3644
|
+
.unwrap();
|
|
3645
|
+
|
|
3646
|
+
let targets = transport.inject_targets();
|
|
3647
|
+
assert!(
|
|
3648
|
+
!targets.is_empty(),
|
|
3649
|
+
"owner teamA peer inject must attempt the nested worker pane; out={out:?}"
|
|
3650
|
+
);
|
|
3651
|
+
assert!(
|
|
3652
|
+
targets
|
|
3653
|
+
.iter()
|
|
3654
|
+
.all(|target| target == &Target::Pane(PaneId::new("%nested-b"))),
|
|
3655
|
+
"owner teamA peer inject must select nested worker pane %nested-b, not top-level %top-b; out={out:?} targets={targets:?}"
|
|
3656
|
+
);
|
|
3657
|
+
}
|