@team-agent/installer 0.5.47 → 0.5.49
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/named_address.rs +56 -20
- package/crates/team-agent/src/cli/send.rs +4 -3
- package/crates/team-agent/src/cli/tests/named_address.rs +23 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -1
- package/crates/team-agent/src/cli/tests/status_send.rs +42 -0
- package/crates/team-agent/src/lifecycle/launch.rs +86 -17
- package/crates/team-agent/src/lifecycle/restart/agent.rs +11 -7
- package/crates/team-agent/src/lifecycle/restart/common.rs +49 -15
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +20 -5
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +33 -16
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -1
- package/crates/team-agent/src/state/repository.rs +15 -9
- package/crates/team-agent/src/tmux_backend/tests.rs +50 -8
- package/crates/team-agent/src/tmux_backend.rs +65 -35
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -472,7 +472,10 @@ pub(crate) fn parse_leader_target_workspace_and_team(
|
|
|
472
472
|
let target_workspace = resolve_workspace(sender_workspace, parsed.workspace.as_deref())?;
|
|
473
473
|
match parsed.target {
|
|
474
474
|
ParsedTarget::TeamEntity { team, entity } if entity == "leader" => {
|
|
475
|
-
|
|
475
|
+
let state = load_state_checked(&target_workspace)?;
|
|
476
|
+
let canonical = canonical_named_team_key(&target_workspace, &state, &team)
|
|
477
|
+
.unwrap_or(team);
|
|
478
|
+
Ok(Some((target_workspace, canonical)))
|
|
476
479
|
}
|
|
477
480
|
_ => Ok(None),
|
|
478
481
|
}
|
|
@@ -607,23 +610,30 @@ fn resolve_in_workspace(
|
|
|
607
610
|
ParsedTarget::BareAgent(agent) => {
|
|
608
611
|
resolve_bare_agent(sender_workspace, target_workspace, state, agent, transport)
|
|
609
612
|
}
|
|
610
|
-
ParsedTarget::TeamEntity { team, entity }
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
613
|
+
ParsedTarget::TeamEntity { team, entity } => {
|
|
614
|
+
let canonical = canonical_named_team_key(target_workspace, state, team)
|
|
615
|
+
.unwrap_or_else(|| team.clone());
|
|
616
|
+
if entity == "leader" {
|
|
617
|
+
resolve_leader(
|
|
618
|
+
sender_workspace,
|
|
619
|
+
target_workspace,
|
|
620
|
+
state,
|
|
621
|
+
&canonical,
|
|
622
|
+
parsed,
|
|
623
|
+
transport,
|
|
624
|
+
)
|
|
625
|
+
} else {
|
|
626
|
+
resolve_worker(
|
|
627
|
+
sender_workspace,
|
|
628
|
+
target_workspace,
|
|
629
|
+
state,
|
|
630
|
+
&canonical,
|
|
631
|
+
entity,
|
|
632
|
+
parsed,
|
|
633
|
+
transport,
|
|
634
|
+
)
|
|
635
|
+
}
|
|
636
|
+
}
|
|
627
637
|
ParsedTarget::SessionWindow { session, window } => resolve_session_window(
|
|
628
638
|
sender_workspace,
|
|
629
639
|
target_workspace,
|
|
@@ -1149,8 +1159,10 @@ fn transport_for_cli_target(
|
|
|
1149
1159
|
parsed: &ParsedNamedAddress,
|
|
1150
1160
|
) -> Box<dyn Transport> {
|
|
1151
1161
|
if let ParsedTarget::TeamEntity { team, entity } = &parsed.target {
|
|
1162
|
+
let canonical = canonical_named_team_key(target_workspace, state, team)
|
|
1163
|
+
.unwrap_or_else(|| team.clone());
|
|
1152
1164
|
if entity == "leader" {
|
|
1153
|
-
let team_scoped_socket = team_entry(state,
|
|
1165
|
+
let team_scoped_socket = team_entry(state, &canonical)
|
|
1154
1166
|
.and_then(|entry| entry.get("leader_receiver"))
|
|
1155
1167
|
.and_then(|receiver| string_field(receiver, "tmux_socket"));
|
|
1156
1168
|
let socket = team_scoped_socket.map(str::to_string).or_else(|| {
|
|
@@ -1164,7 +1176,7 @@ fn transport_for_cli_target(
|
|
|
1164
1176
|
return Box::new(crate::tmux_backend::TmuxBackend::for_tmux_endpoint(&socket));
|
|
1165
1177
|
}
|
|
1166
1178
|
}
|
|
1167
|
-
if let Some(entry) = team_entry(state,
|
|
1179
|
+
if let Some(entry) = team_entry(state, &canonical) {
|
|
1168
1180
|
let selected = crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
|
|
1169
1181
|
target_workspace,
|
|
1170
1182
|
Some(entry),
|
|
@@ -1247,6 +1259,30 @@ fn team_entry<'a>(state: &'a Value, team: &str) -> Option<&'a Value> {
|
|
|
1247
1259
|
.and_then(|teams| teams.get(team))
|
|
1248
1260
|
}
|
|
1249
1261
|
|
|
1262
|
+
fn canonical_named_team_key(
|
|
1263
|
+
target_workspace: &Path,
|
|
1264
|
+
state: &Value,
|
|
1265
|
+
requested: &str,
|
|
1266
|
+
) -> Option<String> {
|
|
1267
|
+
if team_entry(state, requested).is_some() || is_legacy_single_team_active(state, requested) {
|
|
1268
|
+
return Some(requested.to_string());
|
|
1269
|
+
}
|
|
1270
|
+
let selected = crate::state::projection::select_runtime_state(
|
|
1271
|
+
target_workspace,
|
|
1272
|
+
Some(requested),
|
|
1273
|
+
)
|
|
1274
|
+
.ok()?;
|
|
1275
|
+
let canonical = selected
|
|
1276
|
+
.get("active_team_key")
|
|
1277
|
+
.and_then(Value::as_str)
|
|
1278
|
+
.filter(|team| !team.is_empty())?;
|
|
1279
|
+
state
|
|
1280
|
+
.get("teams")
|
|
1281
|
+
.and_then(Value::as_object)
|
|
1282
|
+
.is_some_and(|teams| teams.contains_key(canonical))
|
|
1283
|
+
.then(|| canonical.to_string())
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1250
1286
|
/// E6 (0.5.9 offline-mailbox-toname-design §3.1): decide which
|
|
1251
1287
|
/// `leader_receiver` object, if any, applies to `<team>/leader` before any
|
|
1252
1288
|
/// downstream code reads it. Wrong runtime keys are rejected here with
|
|
@@ -144,9 +144,10 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
|
144
144
|
)?;
|
|
145
145
|
let target = send_target(args.targets.as_deref(), args.target.as_deref());
|
|
146
146
|
let mut opts = send_options_from_args(args);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
// `args.team` is a selector and may be a legacy session/team-dir alias.
|
|
148
|
+
// All downstream membership and DB scope must use the canonical key that
|
|
149
|
+
// resolve_active_team returned, never the original selector spelling.
|
|
150
|
+
opts.team = Some(TeamKey::new(selected.team_key.clone()));
|
|
150
151
|
let content = args.message.join(" ");
|
|
151
152
|
// CR-061/N27 routing-ambiguous: a single positional with no `--to`/`--targets` and an
|
|
152
153
|
// empty message body is a prompt-only invocation (`team-agent send "fix the build"`).
|
|
@@ -222,6 +222,29 @@ fn resolve_name_team_disambiguated() {
|
|
|
222
222
|
let _ = std::fs::remove_dir_all(&ws);
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
#[test]
|
|
226
|
+
fn resolve_qualified_name_canonicalizes_legacy_session_alias() {
|
|
227
|
+
let ws = named_ws("legacy-session-alias");
|
|
228
|
+
seed_state(
|
|
229
|
+
&ws,
|
|
230
|
+
state_with_teams(json!({
|
|
231
|
+
"teamdir": worker_team("team-displayname", "qa", "%1", "qa")
|
|
232
|
+
})),
|
|
233
|
+
);
|
|
234
|
+
let transport = OfflineTransport::new().with_targets(vec![pane(
|
|
235
|
+
"team-displayname",
|
|
236
|
+
"qa",
|
|
237
|
+
"%1",
|
|
238
|
+
)]);
|
|
239
|
+
|
|
240
|
+
let resolved = resolve_name_with_transport(&ws, "displayname/qa", &transport)
|
|
241
|
+
.expect("qualified legacy alias must resolve through the canonical selector");
|
|
242
|
+
assert_eq!(resolved.team_key.as_deref(), Some("teamdir"));
|
|
243
|
+
assert_eq!(resolved.agent_id.as_deref(), Some("qa"));
|
|
244
|
+
assert_eq!(resolved.pane_id, "%1");
|
|
245
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
246
|
+
}
|
|
247
|
+
|
|
225
248
|
#[test]
|
|
226
249
|
fn resolve_name_stale_pane() {
|
|
227
250
|
let ws = named_ws("stale-pane");
|
|
@@ -230,7 +230,7 @@ fn cli_quick_start_invokes_real_lifecycle_compiles_spec() {
|
|
|
230
230
|
};
|
|
231
231
|
let _ = cmd_quick_start(&args); // real quick_start compiles the spec before any coordinator/launch step
|
|
232
232
|
// E5: spec compiled to .team/runtime/<team_key>/, NOT the user team dir.
|
|
233
|
-
let team_key =
|
|
233
|
+
let team_key = "clidelegteam";
|
|
234
234
|
let workspace = crate::model::paths::team_workspace(&team).unwrap();
|
|
235
235
|
let runtime_spec = crate::model::paths::runtime_spec_path(&workspace, &team_key);
|
|
236
236
|
assert!(
|
|
@@ -706,6 +706,48 @@ fn cmd_send_failed_outcome_yields_error_exit() {
|
|
|
706
706
|
}
|
|
707
707
|
}
|
|
708
708
|
|
|
709
|
+
#[test]
|
|
710
|
+
fn cmd_send_canonicalizes_legacy_session_alias_before_membership_projection() {
|
|
711
|
+
let ws = deleg_uniq_dir("send-team-alias");
|
|
712
|
+
let _ = crate::message_store::MessageStore::open(&ws).unwrap();
|
|
713
|
+
crate::state::persist::save_runtime_state(
|
|
714
|
+
&ws,
|
|
715
|
+
&json!({
|
|
716
|
+
"active_team_key": "teamdir",
|
|
717
|
+
"team_key": "teamdir",
|
|
718
|
+
"teams": {
|
|
719
|
+
"teamdir": {
|
|
720
|
+
"status": "alive",
|
|
721
|
+
"session_name": "team-displayname",
|
|
722
|
+
"agents": {"w1": {"provider": "codex", "status": "running"}}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}),
|
|
726
|
+
)
|
|
727
|
+
.unwrap();
|
|
728
|
+
let args = SendArgs {
|
|
729
|
+
workspace: ws,
|
|
730
|
+
target: Some("w1".into()),
|
|
731
|
+
team: Some("displayname".into()),
|
|
732
|
+
task: None,
|
|
733
|
+
no_wait: true,
|
|
734
|
+
watch_result: false,
|
|
735
|
+
json: true,
|
|
736
|
+
..send_args_fixture()
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
let result = cmd_send(&args).expect("alias selection should reach canonical team send");
|
|
740
|
+
let value = match result.output {
|
|
741
|
+
CmdOutput::Json(value) => value,
|
|
742
|
+
other => panic!("expected JSON send outcome, got {other:?}"),
|
|
743
|
+
};
|
|
744
|
+
assert_ne!(
|
|
745
|
+
value.get("reason").and_then(serde_json::Value::as_str),
|
|
746
|
+
Some("target_not_in_team"),
|
|
747
|
+
"selector accepted the alias, so membership must use its canonical team key: {value}"
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
|
|
709
751
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
710
752
|
// coordinator.ok — non-compact status carries the FULL coordinator_health (incl. `ok`); compact
|
|
711
753
|
// strips to {status,pid,metadata_ok,schema_ok} (golden queries.py:77 + compact.py:35; ok =
|
|
@@ -3167,10 +3167,18 @@ pub fn quick_start_with_transport_in_workspace_with_display(
|
|
|
3167
3167
|
if !open_display {
|
|
3168
3168
|
override_spec_display_backend(&mut spec, "none");
|
|
3169
3169
|
}
|
|
3170
|
-
let requested_team = quick_start_requested_team_key(team_id, name)
|
|
3171
|
-
.map(str::to_string)
|
|
3172
|
-
.or_else(|| spec_team_id(&spec));
|
|
3173
3170
|
let explicit_team_key = quick_start_requested_team_key(team_id, name).map(str::to_string);
|
|
3171
|
+
let canonical_team_key = explicit_team_key
|
|
3172
|
+
.clone()
|
|
3173
|
+
.or_else(|| spec_team_id(&spec).filter(|team| !team.is_empty()))
|
|
3174
|
+
.unwrap_or_else(|| {
|
|
3175
|
+
runtime_team_key_for_spec(
|
|
3176
|
+
&agents_dir.join("team.spec.yaml"),
|
|
3177
|
+
&spec,
|
|
3178
|
+
&spec_session_name(&spec),
|
|
3179
|
+
)
|
|
3180
|
+
});
|
|
3181
|
+
let requested_team = Some(canonical_team_key.clone());
|
|
3174
3182
|
let team_depth = quick_start_depth_guard(
|
|
3175
3183
|
&workspace,
|
|
3176
3184
|
agents_dir,
|
|
@@ -3248,14 +3256,12 @@ pub fn quick_start_with_transport_in_workspace_with_display(
|
|
|
3248
3256
|
// spec's runtime.session_name with one derived from the REQUESTED team identity
|
|
3249
3257
|
// so launch_with_transport (which reads runtime.session_name) spawns into an
|
|
3250
3258
|
// isolated session per requested team.
|
|
3251
|
-
if let Some(requested) =
|
|
3259
|
+
if let Some(requested) = explicit_team_key.as_deref() {
|
|
3252
3260
|
override_spec_session_name(&mut spec, &format!("team-{requested}"));
|
|
3253
3261
|
}
|
|
3254
3262
|
let session_name = spec_session_name(&spec);
|
|
3255
|
-
// team_key
|
|
3256
|
-
let state_team_key =
|
|
3257
|
-
runtime_team_key_for_spec(&agents_dir.join("team.spec.yaml"), &spec, &session_name)
|
|
3258
|
-
});
|
|
3263
|
+
// team_key 已在入口由显式 id/name 或 compiled spec identity 单次确定。
|
|
3264
|
+
let state_team_key = canonical_team_key;
|
|
3259
3265
|
warn_ignored_owner_team_id(workspace.as_path(), agents_dir, &state_team_key);
|
|
3260
3266
|
// E5 spec 迁移:spec 写到 .team/runtime/<team_key>/(中间产物,绝不落用户目录 agents_dir)。
|
|
3261
3267
|
// Bug2:原子写(tmp+rename),避免半截 spec。
|
|
@@ -3265,7 +3271,13 @@ pub fn quick_start_with_transport_in_workspace_with_display(
|
|
|
3265
3271
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
3266
3272
|
let resolved_spec_path =
|
|
3267
3273
|
std::fs::canonicalize(&spec_path).unwrap_or_else(|_| spec_path.clone());
|
|
3268
|
-
let mut state = initial_runtime_state(
|
|
3274
|
+
let mut state = initial_runtime_state(
|
|
3275
|
+
&spec,
|
|
3276
|
+
&resolved_spec_path,
|
|
3277
|
+
&workspace,
|
|
3278
|
+
agents_dir,
|
|
3279
|
+
&state_team_key,
|
|
3280
|
+
);
|
|
3269
3281
|
// 0.5.x Phase 1d hot-path 接线(裁决1 msg_76e1d98202b8): use the
|
|
3270
3282
|
// generic annotator that writes `state.transport = { kind, source }`
|
|
3271
3283
|
// for every backend AND (for tmux) preserves the existing
|
|
@@ -4366,6 +4378,7 @@ pub fn fork_agent_with_transport(
|
|
|
4366
4378
|
// E5 §3:team_dir(角色定义+profiles)恒用户目录。spec 读用 selector 解析的 spec_path
|
|
4367
4379
|
// (读序 B:runtime 优先、legacy 回落),写恒走 runtime_spec_path(canonical 落点)。
|
|
4368
4380
|
let fork_team_dir = selected.team_dir.clone();
|
|
4381
|
+
let fork_team = selected.team_key.clone();
|
|
4369
4382
|
let read_spec_path = selected
|
|
4370
4383
|
.spec_path
|
|
4371
4384
|
.clone()
|
|
@@ -4373,7 +4386,7 @@ pub fn fork_agent_with_transport(
|
|
|
4373
4386
|
let workspace = selected.run_workspace;
|
|
4374
4387
|
let state = selected.state;
|
|
4375
4388
|
ensure_owner_allowed_for_state(&state, Some(source_agent_id))?;
|
|
4376
|
-
let spec_path = crate::model::paths::runtime_spec_path(&workspace, &
|
|
4389
|
+
let spec_path = crate::model::paths::runtime_spec_path(&workspace, &fork_team);
|
|
4377
4390
|
let text = std::fs::read_to_string(&read_spec_path)
|
|
4378
4391
|
.map_err(|e| LifecycleError::Compile(format!("{}: {e}", read_spec_path.display())))?;
|
|
4379
4392
|
let spec = yaml::loads(&text).map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
@@ -4487,7 +4500,6 @@ pub fn fork_agent_with_transport(
|
|
|
4487
4500
|
&safety,
|
|
4488
4501
|
)?;
|
|
4489
4502
|
let resolved_tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
|
|
4490
|
-
let fork_team = crate::messaging::leader_receiver::active_team_key(&workspace, &state);
|
|
4491
4503
|
let mcp_config = adapter.mcp_config(auth_mode).map_err(|e| {
|
|
4492
4504
|
let _ = std::fs::write(&spec_path, text.as_bytes());
|
|
4493
4505
|
LifecycleError::Provider(e.to_string())
|
|
@@ -4625,13 +4637,16 @@ pub fn fork_agent_with_transport(
|
|
|
4625
4637
|
&mcp_config_path,
|
|
4626
4638
|
as_agent_id,
|
|
4627
4639
|
&profile_launch,
|
|
4640
|
+
&fork_team,
|
|
4628
4641
|
);
|
|
4629
4642
|
return Err(e);
|
|
4630
4643
|
}
|
|
4631
|
-
if let Err(e) = crate::state::
|
|
4632
|
-
|
|
4644
|
+
if let Err(e) = crate::state::repository::StateRepository::new(&workspace).save(
|
|
4645
|
+
crate::state::repository::StateWriteIntent::ForkAgent {
|
|
4646
|
+
team_key: &fork_team,
|
|
4647
|
+
agent_id: as_agent_id.as_str(),
|
|
4648
|
+
},
|
|
4633
4649
|
&next_state,
|
|
4634
|
-
&[as_agent_id.as_str()],
|
|
4635
4650
|
) {
|
|
4636
4651
|
rollback_fork_after_spawn(
|
|
4637
4652
|
&workspace,
|
|
@@ -4644,9 +4659,56 @@ pub fn fork_agent_with_transport(
|
|
|
4644
4659
|
&mcp_config_path,
|
|
4645
4660
|
as_agent_id,
|
|
4646
4661
|
&profile_launch,
|
|
4662
|
+
&fork_team,
|
|
4647
4663
|
);
|
|
4648
4664
|
return Err(LifecycleError::StatePersist(e.to_string()));
|
|
4649
4665
|
}
|
|
4666
|
+
let registration =
|
|
4667
|
+
crate::state::projection::select_runtime_state(&workspace, Some(fork_team.as_str()))
|
|
4668
|
+
.map_err(|e| e.to_string())
|
|
4669
|
+
.and_then(|saved| {
|
|
4670
|
+
let agent = saved
|
|
4671
|
+
.get("agents")
|
|
4672
|
+
.and_then(|agents| agents.get(as_agent_id.as_str()))
|
|
4673
|
+
.ok_or_else(|| "canonical team row is missing".to_string())?;
|
|
4674
|
+
if agent.get("pane_id").and_then(serde_json::Value::as_str)
|
|
4675
|
+
!= Some(spawn.pane_id.as_str())
|
|
4676
|
+
{
|
|
4677
|
+
return Err("canonical team pane_id does not match spawned pane".to_string());
|
|
4678
|
+
}
|
|
4679
|
+
if agent.get("window").and_then(serde_json::Value::as_str) != Some(window.as_str())
|
|
4680
|
+
{
|
|
4681
|
+
return Err("canonical team window does not match spawned window".to_string());
|
|
4682
|
+
}
|
|
4683
|
+
if let Some(pid) = spawn.child_pid {
|
|
4684
|
+
if agent.get("pane_pid").and_then(serde_json::Value::as_u64)
|
|
4685
|
+
!= Some(u64::from(pid))
|
|
4686
|
+
{
|
|
4687
|
+
return Err(
|
|
4688
|
+
"canonical team pane_pid does not match spawned process".to_string()
|
|
4689
|
+
);
|
|
4690
|
+
}
|
|
4691
|
+
}
|
|
4692
|
+
Ok(())
|
|
4693
|
+
});
|
|
4694
|
+
if let Err(reason) = registration {
|
|
4695
|
+
rollback_fork_after_spawn(
|
|
4696
|
+
&workspace,
|
|
4697
|
+
&spec_path,
|
|
4698
|
+
&text,
|
|
4699
|
+
&old_state,
|
|
4700
|
+
transport,
|
|
4701
|
+
&session_name,
|
|
4702
|
+
&window,
|
|
4703
|
+
&mcp_config_path,
|
|
4704
|
+
as_agent_id,
|
|
4705
|
+
&profile_launch,
|
|
4706
|
+
&fork_team,
|
|
4707
|
+
);
|
|
4708
|
+
return Err(LifecycleError::StatePersist(format!(
|
|
4709
|
+
"fork spawned but team registration readback failed: {reason}"
|
|
4710
|
+
)));
|
|
4711
|
+
}
|
|
4650
4712
|
if let Err(e) = maybe_fail_fork_after_spawn("start_coordinator") {
|
|
4651
4713
|
rollback_fork_after_spawn(
|
|
4652
4714
|
&workspace,
|
|
@@ -4659,6 +4721,7 @@ pub fn fork_agent_with_transport(
|
|
|
4659
4721
|
&mcp_config_path,
|
|
4660
4722
|
as_agent_id,
|
|
4661
4723
|
&profile_launch,
|
|
4724
|
+
&fork_team,
|
|
4662
4725
|
);
|
|
4663
4726
|
return Err(e);
|
|
4664
4727
|
}
|
|
@@ -4678,6 +4741,7 @@ pub fn fork_agent_with_transport(
|
|
|
4678
4741
|
&mcp_config_path,
|
|
4679
4742
|
as_agent_id,
|
|
4680
4743
|
&profile_launch,
|
|
4744
|
+
&fork_team,
|
|
4681
4745
|
);
|
|
4682
4746
|
LifecycleError::StatePersist(e.to_string())
|
|
4683
4747
|
})?;
|
|
@@ -4704,16 +4768,19 @@ fn rollback_fork_after_spawn(
|
|
|
4704
4768
|
mcp_config_path: &Path,
|
|
4705
4769
|
agent_id: &AgentId,
|
|
4706
4770
|
profile_launch: &crate::provider::ProviderProfileLaunch,
|
|
4771
|
+
team_key: &str,
|
|
4707
4772
|
) {
|
|
4708
4773
|
let _ = transport.kill_window(&Target::SessionWindow {
|
|
4709
4774
|
session: session_name.clone(),
|
|
4710
4775
|
window: window.clone(),
|
|
4711
4776
|
});
|
|
4712
4777
|
let _ = std::fs::write(spec_path, spec_text.as_bytes());
|
|
4713
|
-
let _ = crate::state::
|
|
4714
|
-
|
|
4778
|
+
let _ = crate::state::repository::StateRepository::new(workspace).save(
|
|
4779
|
+
crate::state::repository::StateWriteIntent::AgentRollback {
|
|
4780
|
+
team_key: Some(team_key),
|
|
4781
|
+
agent_id: agent_id.as_str(),
|
|
4782
|
+
},
|
|
4715
4783
|
old_state,
|
|
4716
|
-
&[agent_id.as_str()],
|
|
4717
4784
|
);
|
|
4718
4785
|
cleanup_fork_mcp_artifacts(workspace, agent_id, mcp_config_path, profile_launch);
|
|
4719
4786
|
}
|
|
@@ -5049,6 +5116,7 @@ fn initial_runtime_state(
|
|
|
5049
5116
|
spec_path: &Path,
|
|
5050
5117
|
workspace: &Path,
|
|
5051
5118
|
team_dir: &Path,
|
|
5119
|
+
team_key: &str,
|
|
5052
5120
|
) -> serde_json::Value {
|
|
5053
5121
|
let mut agents = serde_json::Map::new();
|
|
5054
5122
|
for agent in spec_agent_values(spec) {
|
|
@@ -5090,6 +5158,7 @@ fn initial_runtime_state(
|
|
|
5090
5158
|
"team_dir".to_string(),
|
|
5091
5159
|
serde_json::json!(team_dir.to_string_lossy().to_string()),
|
|
5092
5160
|
);
|
|
5161
|
+
state.insert("team_key".to_string(), serde_json::json!(team_key));
|
|
5093
5162
|
state.insert(
|
|
5094
5163
|
"session_name".to_string(),
|
|
5095
5164
|
serde_json::json!(spec_session_name(spec).as_str()),
|
|
@@ -113,11 +113,7 @@ pub(crate) fn start_agent_at_paths(
|
|
|
113
113
|
let window = agent_window(&agent, agent_id);
|
|
114
114
|
let adaptive_layout =
|
|
115
115
|
open_display && crate::lifecycle::launch::state_uses_adaptive_layout(&state);
|
|
116
|
-
|
|
117
|
-
.get("provider")
|
|
118
|
-
.and_then(serde_json::Value::as_str)
|
|
119
|
-
.is_some_and(|provider| provider.eq_ignore_ascii_case("fake"));
|
|
120
|
-
if force && !fake_provider && is_per_agent_window(&window, agent_id) {
|
|
116
|
+
if force && is_per_agent_window(&window, agent_id) {
|
|
121
117
|
let expected_pane_id = raw_agent
|
|
122
118
|
.get("pane_id")
|
|
123
119
|
.and_then(serde_json::Value::as_str)
|
|
@@ -276,12 +272,20 @@ pub(crate) fn start_agent_at_paths(
|
|
|
276
272
|
None,
|
|
277
273
|
Some(resolved_team_key.as_str()),
|
|
278
274
|
)?;
|
|
279
|
-
verify_spawned_pane_matches_target(
|
|
275
|
+
if let Err(error) = verify_spawned_pane_matches_target(
|
|
280
276
|
transport,
|
|
281
277
|
&spawn.spawn.pane_id,
|
|
282
278
|
&session_name,
|
|
283
279
|
&spawn.spawn.window,
|
|
284
|
-
)
|
|
280
|
+
) {
|
|
281
|
+
if let Err(rollback_error) = transport.kill_pane(&spawn.spawn.pane_id) {
|
|
282
|
+
return Err(LifecycleError::RequirementUnmet(format!(
|
|
283
|
+
"{error}; failed to roll back spawned pane {}: {rollback_error}",
|
|
284
|
+
spawn.spawn.pane_id.as_str()
|
|
285
|
+
)));
|
|
286
|
+
}
|
|
287
|
+
return Err(error);
|
|
288
|
+
}
|
|
285
289
|
let actual_spawn_window = spawn.spawn.window.as_str().to_string();
|
|
286
290
|
mark_agent_started(
|
|
287
291
|
&mut state,
|
|
@@ -89,11 +89,19 @@ fn same_role_cohort_error(
|
|
|
89
89
|
expected_live: usize,
|
|
90
90
|
expected_old_only: bool,
|
|
91
91
|
) -> Option<String> {
|
|
92
|
-
let panes = transport.list_targets()
|
|
92
|
+
let panes = match transport.list_targets() {
|
|
93
|
+
Ok(panes) => panes,
|
|
94
|
+
Err(error) => {
|
|
95
|
+
return Some(format!(
|
|
96
|
+
"{operation} refused: same-role cohort observation failed: {error}"
|
|
97
|
+
));
|
|
98
|
+
}
|
|
99
|
+
};
|
|
93
100
|
let mut cardinality_proofs = Vec::new();
|
|
94
101
|
let mut binding_proofs = Vec::new();
|
|
102
|
+
let mut observation_proofs = Vec::new();
|
|
95
103
|
for target in targets {
|
|
96
|
-
let
|
|
104
|
+
let candidate_panes = panes
|
|
97
105
|
.iter()
|
|
98
106
|
.filter(|pane| pane.session.as_str() == session_name.as_str())
|
|
99
107
|
.filter(|pane| {
|
|
@@ -101,20 +109,30 @@ fn same_role_cohort_error(
|
|
|
101
109
|
.as_ref()
|
|
102
110
|
.is_some_and(|window| window.as_str() == target.window)
|
|
103
111
|
})
|
|
104
|
-
.filter(|pane| {
|
|
105
|
-
!expected_old_only
|
|
106
|
-
|| target
|
|
107
|
-
.expected_pane_id
|
|
108
|
-
.as_deref()
|
|
109
|
-
.is_some_and(|expected| pane.pane_id.as_str() == expected)
|
|
110
|
-
})
|
|
111
|
-
.filter(|pane| {
|
|
112
|
-
transport
|
|
113
|
-
.liveness(&pane.pane_id)
|
|
114
|
-
.is_ok_and(|live| live == crate::transport::PaneLiveness::Live)
|
|
115
|
-
})
|
|
116
|
-
.map(|pane| pane.pane_id.as_str().to_string())
|
|
117
112
|
.collect::<Vec<_>>();
|
|
113
|
+
let mut live_panes = Vec::new();
|
|
114
|
+
for pane in candidate_panes {
|
|
115
|
+
match transport.liveness(&pane.pane_id) {
|
|
116
|
+
Ok(crate::transport::PaneLiveness::Live) => {
|
|
117
|
+
live_panes.push(pane.pane_id.as_str().to_string());
|
|
118
|
+
}
|
|
119
|
+
Ok(crate::transport::PaneLiveness::Dead) => {}
|
|
120
|
+
Ok(crate::transport::PaneLiveness::Unknown) => {
|
|
121
|
+
observation_proofs.push(format!(
|
|
122
|
+
"{}:window={}:pane={}:liveness=unknown",
|
|
123
|
+
target.agent_id,
|
|
124
|
+
target.window,
|
|
125
|
+
pane.pane_id.as_str()
|
|
126
|
+
));
|
|
127
|
+
}
|
|
128
|
+
Err(error) => observation_proofs.push(format!(
|
|
129
|
+
"{}:window={}:pane={}:liveness_error={error}",
|
|
130
|
+
target.agent_id,
|
|
131
|
+
target.window,
|
|
132
|
+
pane.pane_id.as_str()
|
|
133
|
+
)),
|
|
134
|
+
}
|
|
135
|
+
}
|
|
118
136
|
if live_panes.len() != expected_live {
|
|
119
137
|
cardinality_proofs.push(format!(
|
|
120
138
|
"{}:window={}:live_panes=[{}]",
|
|
@@ -135,6 +153,12 @@ fn same_role_cohort_error(
|
|
|
135
153
|
}
|
|
136
154
|
}
|
|
137
155
|
}
|
|
156
|
+
if !observation_proofs.is_empty() {
|
|
157
|
+
return Some(format!(
|
|
158
|
+
"{operation} refused: same-role cohort observation failed; {}",
|
|
159
|
+
observation_proofs.join("; ")
|
|
160
|
+
));
|
|
161
|
+
}
|
|
138
162
|
if !binding_proofs.is_empty() {
|
|
139
163
|
return Some(format!(
|
|
140
164
|
"{operation} refused: spawn identity/binding mismatch; {}",
|
|
@@ -476,6 +500,16 @@ pub(super) fn spawn_agent_window(
|
|
|
476
500
|
);
|
|
477
501
|
if let Some(error) = startup_prompt.capture_error.as_deref() {
|
|
478
502
|
if is_structural_startup_prompt_error(error) {
|
|
503
|
+
if let Err(rollback_error) = transport.kill_pane(&spawn.pane_id) {
|
|
504
|
+
return Err(LifecycleError::Transport(format!(
|
|
505
|
+
"startup prompt structural failure for {}:{} pane {}: {}; failed to roll back spawned pane: {}",
|
|
506
|
+
session_name.as_str(),
|
|
507
|
+
window.as_str(),
|
|
508
|
+
spawn.pane_id.as_str(),
|
|
509
|
+
error,
|
|
510
|
+
rollback_error
|
|
511
|
+
)));
|
|
512
|
+
}
|
|
479
513
|
return Err(LifecycleError::Transport(format!(
|
|
480
514
|
"startup prompt structural failure for {}:{} pane {}: {}",
|
|
481
515
|
session_name.as_str(),
|
|
@@ -14,7 +14,7 @@ pub(super) type LaneSpawns = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<St
|
|
|
14
14
|
/// end-to-end in-process.
|
|
15
15
|
pub(super) struct LaneTransport {
|
|
16
16
|
session: String,
|
|
17
|
-
windows: Vec<String
|
|
17
|
+
windows: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
|
|
18
18
|
killed: LaneKills,
|
|
19
19
|
spawns: LaneSpawns,
|
|
20
20
|
}
|
|
@@ -22,7 +22,9 @@ impl LaneTransport {
|
|
|
22
22
|
pub(super) fn new(session: &str, windows: &[&str]) -> Self {
|
|
23
23
|
Self {
|
|
24
24
|
session: session.to_string(),
|
|
25
|
-
windows:
|
|
25
|
+
windows: std::sync::Arc::new(std::sync::Mutex::new(
|
|
26
|
+
windows.iter().map(|w| (*w).to_string()).collect(),
|
|
27
|
+
)),
|
|
26
28
|
killed: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
|
27
29
|
spawns: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
|
28
30
|
}
|
|
@@ -120,6 +122,8 @@ impl crate::transport::Transport for LaneTransport {
|
|
|
120
122
|
) -> Result<Vec<crate::transport::PaneInfo>, crate::transport::TransportError> {
|
|
121
123
|
Ok(self
|
|
122
124
|
.windows
|
|
125
|
+
.lock()
|
|
126
|
+
.unwrap()
|
|
123
127
|
.iter()
|
|
124
128
|
.map(|w| crate::transport::PaneInfo {
|
|
125
129
|
pane_id: crate::transport::PaneId::new(format!("%{w}")),
|
|
@@ -149,6 +153,8 @@ impl crate::transport::Transport for LaneTransport {
|
|
|
149
153
|
if s.as_str() == self.session {
|
|
150
154
|
Ok(self
|
|
151
155
|
.windows
|
|
156
|
+
.lock()
|
|
157
|
+
.unwrap()
|
|
152
158
|
.iter()
|
|
153
159
|
.map(|w| crate::transport::WindowName::new(w.as_str()))
|
|
154
160
|
.collect())
|
|
@@ -174,13 +180,22 @@ impl crate::transport::Transport for LaneTransport {
|
|
|
174
180
|
&self,
|
|
175
181
|
t: &crate::transport::Target,
|
|
176
182
|
) -> Result<(), crate::transport::TransportError> {
|
|
177
|
-
let name = match t {
|
|
178
|
-
crate::transport::Target::Pane(p) => p.as_str().to_string(),
|
|
183
|
+
let (name, window) = match t {
|
|
184
|
+
crate::transport::Target::Pane(p) => (p.as_str().to_string(), None),
|
|
179
185
|
crate::transport::Target::SessionWindow { session, window } => {
|
|
180
|
-
|
|
186
|
+
(
|
|
187
|
+
format!("{}:{}", session.as_str(), window.as_str()),
|
|
188
|
+
Some(window.as_str()),
|
|
189
|
+
)
|
|
181
190
|
}
|
|
182
191
|
};
|
|
183
192
|
self.killed.lock().unwrap().push(name);
|
|
193
|
+
if let Some(window) = window {
|
|
194
|
+
self.windows
|
|
195
|
+
.lock()
|
|
196
|
+
.unwrap()
|
|
197
|
+
.retain(|candidate| candidate != window);
|
|
198
|
+
}
|
|
184
199
|
Ok(())
|
|
185
200
|
}
|
|
186
201
|
fn attach_session(
|
|
@@ -80,11 +80,10 @@ fn layout_pane(
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/// E5 spec 迁移:spec 不再落 team_dir,而在 <team_workspace>/.team/runtime/<team_key>/。
|
|
83
|
-
///
|
|
83
|
+
/// quick-start 未显式指定 id 时,team_key 来自 compiled TEAM.md name(`quickteam`)。
|
|
84
84
|
pub(super) fn quick_start_runtime_spec_path(team: &std::path::Path) -> PathBuf {
|
|
85
85
|
let workspace = crate::model::paths::team_workspace(team).unwrap();
|
|
86
|
-
|
|
87
|
-
crate::model::paths::runtime_spec_path(&workspace, &team_key)
|
|
86
|
+
crate::model::paths::runtime_spec_path(&workspace, "quickteam")
|
|
88
87
|
}
|
|
89
88
|
|
|
90
89
|
/// 在 team 自身或其 team_workspace 父下找 .team/runtime/*/team.spec.yaml。run_workspace 解析
|
|
@@ -172,6 +171,26 @@ fn quick_start_compiles_real_spec_to_team_spec_yaml() {
|
|
|
172
171
|
}
|
|
173
172
|
}
|
|
174
173
|
|
|
174
|
+
#[test]
|
|
175
|
+
fn quick_start_without_team_id_uses_compiled_team_name_as_canonical_key() {
|
|
176
|
+
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
177
|
+
let workspace = crate::model::paths::team_workspace(&team).unwrap();
|
|
178
|
+
let transport = OfflineTransport::new();
|
|
179
|
+
|
|
180
|
+
let result = quick_start_with_transport(&team, None, true, None, &transport);
|
|
181
|
+
assert!(result.is_ok(), "quick-start fixture must run: {result:?}");
|
|
182
|
+
|
|
183
|
+
let state = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
184
|
+
assert_eq!(state["active_team_key"], json!("quickteam"));
|
|
185
|
+
assert_eq!(state["team_key"], json!("quickteam"));
|
|
186
|
+
assert!(state["teams"].get("quickteam").is_some(), "{state}");
|
|
187
|
+
assert!(state["teams"].get("teamdir").is_none(), "{state}");
|
|
188
|
+
assert!(
|
|
189
|
+
crate::model::paths::runtime_spec_path(&workspace, "quickteam").exists(),
|
|
190
|
+
"runtime spec must share the canonical key"
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
175
194
|
#[test]
|
|
176
195
|
fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_collect() {
|
|
177
196
|
let workspace = temp_ws();
|
|
@@ -215,8 +234,8 @@ fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_coll
|
|
|
215
234
|
"input={}",
|
|
216
235
|
input.display()
|
|
217
236
|
);
|
|
218
|
-
// E5: selector resolves spec_path to .team/runtime/<
|
|
219
|
-
let expected_spec = crate::model::paths::runtime_spec_path(&workspace, "
|
|
237
|
+
// E5: selector resolves spec_path to .team/runtime/<canonical team_key>/.
|
|
238
|
+
let expected_spec = crate::model::paths::runtime_spec_path(&workspace, "quickteam");
|
|
220
239
|
assert_eq!(
|
|
221
240
|
selected
|
|
222
241
|
.spec_path
|
|
@@ -1319,7 +1338,7 @@ fn quick_start_persists_selected_tmux_endpoint_and_attach_commands() {
|
|
|
1319
1338
|
assert_eq!(state["tmux_socket"], json!(endpoint));
|
|
1320
1339
|
assert_eq!(state["is_external_leader"], json!(false));
|
|
1321
1340
|
assert_eq!(
|
|
1322
|
-
state["teams"]["
|
|
1341
|
+
state["teams"]["quickteam"]["is_external_leader"],
|
|
1323
1342
|
json!(false)
|
|
1324
1343
|
);
|
|
1325
1344
|
}
|
|
@@ -2876,7 +2895,7 @@ fn quick_start_seeds_tasks_key_from_compiled_spec() {
|
|
|
2876
2895
|
}
|
|
2877
2896
|
|
|
2878
2897
|
// Stage A — golden launch/core.py:62-71 seeded top-level runtime state in insertion order:
|
|
2879
|
-
// spec_path, workspace, team_dir, session_name, leader, agents, tasks, display_backend.
|
|
2898
|
+
// spec_path, workspace, team_dir, team_key, session_name, leader, agents, tasks, display_backend.
|
|
2880
2899
|
//
|
|
2881
2900
|
// OLD (Python parity): the top-level shape ended at `display_backend`.
|
|
2882
2901
|
// NEW (Bug 1/2 — team-in-team state scope, see tests/team_in_team_state_scope_red.rs):
|
|
@@ -2889,9 +2908,9 @@ fn quick_start_seeds_tasks_key_from_compiled_spec() {
|
|
|
2889
2908
|
// The post-launch hook drops the top-level owner triple when the launched pane is
|
|
2890
2909
|
// unbound (empty pane_id), and only the per-team entry retains the binding.
|
|
2891
2910
|
// R1: topology is explicit; fresh managed teams carry `is_external_leader=false`
|
|
2892
|
-
// before the team-in-team suffix.
|
|
2893
|
-
//
|
|
2894
|
-
// teams). display_backend stays the resolved backend from display/backend.py:12-29
|
|
2911
|
+
// before the team-in-team suffix. Canonical `team_key` is seeded before owner
|
|
2912
|
+
// binding; the remaining order stays the golden prefix + topology marker + new
|
|
2913
|
+
// suffix (active_team_key, teams). display_backend stays the resolved backend from display/backend.py:12-29
|
|
2895
2914
|
// (default adaptive), not the raw optional spec field.
|
|
2896
2915
|
#[test]
|
|
2897
2916
|
#[serial(env)]
|
|
@@ -2924,6 +2943,9 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
|
|
|
2924
2943
|
"spec_path",
|
|
2925
2944
|
"workspace",
|
|
2926
2945
|
"team_dir",
|
|
2946
|
+
// Seed canonical identity before owner binding so the initial
|
|
2947
|
+
// receiver is written under the same team key as the saved state.
|
|
2948
|
+
"team_key",
|
|
2927
2949
|
"session_name",
|
|
2928
2950
|
"leader",
|
|
2929
2951
|
"agents",
|
|
@@ -2937,17 +2959,12 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
|
|
|
2937
2959
|
// field); source is `"unknown"` until Phase 2 threads
|
|
2938
2960
|
// `ResolvedTransport.source` through the launch call site.
|
|
2939
2961
|
"transport",
|
|
2940
|
-
// 0.4.0 refactor: `team_key` added as top-level topology marker
|
|
2941
|
-
// alongside active_team_key (refactor-modular-architecture). The
|
|
2942
|
-
// owner / leader_receiver / owner_epoch invariants below still
|
|
2943
|
-
// hold — those live only under teams[<active_team_key>].
|
|
2944
|
-
"team_key",
|
|
2945
2962
|
"active_team_key",
|
|
2946
2963
|
"teams",
|
|
2947
2964
|
],
|
|
2948
2965
|
"state.json top-level key order must match golden launch/core.py:62-71 \
|
|
2949
2966
|
plus R1 topology marker and Bug 1/2 team-in-team suffix \
|
|
2950
|
-
(is_external_leader,
|
|
2967
|
+
(is_external_leader, active_team_key, teams). \
|
|
2951
2968
|
Bug 2 owner team-scope (N1/N12/N18/N29) deliberately keeps owner / \
|
|
2952
2969
|
leader_receiver / owner_epoch OFF the top level — they live ONLY under \
|
|
2953
2970
|
teams[<active_team_key>] so per-team isolation has a single source of \
|
|
@@ -451,7 +451,7 @@ fn team_dir_with_roles(role_docs: &[(&str, &str)]) -> PathBuf {
|
|
|
451
451
|
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
452
452
|
std::fs::write(
|
|
453
453
|
team.join("TEAM.md"),
|
|
454
|
-
"---\nname:
|
|
454
|
+
"---\nname: teamdir\nobjective: Phase B.\nprovider: codex\nsession_name: team-phaseb\n---\n\nPhase B team.\n",
|
|
455
455
|
)
|
|
456
456
|
.unwrap();
|
|
457
457
|
for (file, role_doc) in role_docs {
|
|
@@ -536,7 +536,7 @@ fn add_fixture() -> (PathBuf, PathBuf, PathBuf, OfflineTransport) {
|
|
|
536
536
|
quick_start_with_transport_in_workspace_with_display(
|
|
537
537
|
&workspace,
|
|
538
538
|
&team,
|
|
539
|
-
|
|
539
|
+
Some("teamdir"),
|
|
540
540
|
true,
|
|
541
541
|
None,
|
|
542
542
|
&launch_transport,
|
|
@@ -367,7 +367,7 @@ fn two_worker_team_dir(hermetic: &HermeticTestEnv) -> PathBuf {
|
|
|
367
367
|
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
368
368
|
std::fs::write(
|
|
369
369
|
team.join("TEAM.md"),
|
|
370
|
-
"---\nname:
|
|
370
|
+
"---\nname: teamdir\nobjective: Phase golden.\nprovider: codex\nsession_name: team-phasegolden\n---\n\nPhase golden team.\n",
|
|
371
371
|
)
|
|
372
372
|
.unwrap();
|
|
373
373
|
for id in ["w1", "w2"] {
|
|
@@ -315,16 +315,22 @@ fn route_direct(
|
|
|
315
315
|
StateWriteIntent::RemoveAgent { agent_id, .. } => {
|
|
316
316
|
helper_write_team_scoped_with_deleted_agents(workspace, state, &[agent_id])
|
|
317
317
|
}
|
|
318
|
-
// ForkAgent
|
|
319
|
-
// lifecycle
|
|
320
|
-
StateWriteIntent::ForkAgent { .. } =>
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
helper_write_root_with_deleted_agents(workspace, state, &[agent_id])
|
|
318
|
+
// ForkAgent mutates a selected team projection, so persist it back to
|
|
319
|
+
// that projection with the forked row as lifecycle topology authority.
|
|
320
|
+
StateWriteIntent::ForkAgent { agent_id, .. } => {
|
|
321
|
+
helper_write_team_scoped_with_lifecycle_topology_authority(
|
|
322
|
+
workspace,
|
|
323
|
+
state,
|
|
324
|
+
&[agent_id],
|
|
325
|
+
)
|
|
327
326
|
}
|
|
327
|
+
// Team-scoped rollback must restore only the selected projection and
|
|
328
|
+
// preserve sibling teams. Legacy callers without a team keep the root
|
|
329
|
+
// deleted-agents writer.
|
|
330
|
+
StateWriteIntent::AgentRollback { team_key, agent_id } => match team_key {
|
|
331
|
+
Some(_) => helper_write_team_scoped_with_deleted_agents(workspace, state, &[agent_id]),
|
|
332
|
+
None => helper_write_root_with_deleted_agents(workspace, state, &[agent_id]),
|
|
333
|
+
},
|
|
328
334
|
// ClaimLeader -> leader/lease.rs:1625 uses the root helper, and the
|
|
329
335
|
// scoped preserve-claim-fields variant at :1702 uses the team-tombstoned
|
|
330
336
|
// agents helper.
|
|
@@ -514,9 +514,9 @@ fn spawn_into_same_named_replacement_returns_identity_created_by_spawn_command()
|
|
|
514
514
|
}
|
|
515
515
|
|
|
516
516
|
#[test]
|
|
517
|
-
fn
|
|
517
|
+
fn spawn_with_command_refuses_and_rolls_back_spawn_pane_owned_by_other_window() {
|
|
518
518
|
let pane_inventory = "%5\tteamsess\t1\tw2\t0\t/dev/ttys005\tnode\t1\t/work/dir\t1\t0\t125\n";
|
|
519
|
-
let (be,
|
|
519
|
+
let (be, rec) = backend_with(
|
|
520
520
|
MockResp::Out(ok("")),
|
|
521
521
|
vec![
|
|
522
522
|
MockResp::Out(ok("%5\n")),
|
|
@@ -539,6 +539,48 @@ fn spawn_with_command_refuses_spawn_pane_owned_by_other_window() {
|
|
|
539
539
|
&& msg.contains("observed=teamsess:w2"),
|
|
540
540
|
"error must include requested/observed ownership evidence, got {msg}"
|
|
541
541
|
);
|
|
542
|
+
let calls = rec.lock().unwrap().clone();
|
|
543
|
+
assert!(
|
|
544
|
+
calls
|
|
545
|
+
.iter()
|
|
546
|
+
.any(|call| call == &svec(&["tmux", "kill-pane", "-t", "%5"])),
|
|
547
|
+
"identity mismatch must roll back the exact pane created by this spawn; calls={calls:?}"
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
#[test]
|
|
552
|
+
fn spawn_with_command_retries_until_spawn_pane_is_visible() {
|
|
553
|
+
let pane_inventory = "%6\tteamsess\t1\tw1\t0\t/dev/ttys006\tnode\t1\t/work/dir\t1\t0\t126\n";
|
|
554
|
+
let (be, rec) = backend_with(
|
|
555
|
+
MockResp::Out(ok(pane_inventory)),
|
|
556
|
+
vec![MockResp::Out(ok("%6\n")), MockResp::Out(ok(""))],
|
|
557
|
+
);
|
|
558
|
+
let result = be
|
|
559
|
+
.spawn_into(
|
|
560
|
+
&SessionName::new("teamsess"),
|
|
561
|
+
&WindowName::new("w1"),
|
|
562
|
+
&svec(&["provider-bin"]),
|
|
563
|
+
Path::new("/work/dir"),
|
|
564
|
+
&BTreeMap::new(),
|
|
565
|
+
)
|
|
566
|
+
.expect("spawn pane must tolerate bounded tmux inventory lag");
|
|
567
|
+
let calls = rec.lock().unwrap().clone();
|
|
568
|
+
|
|
569
|
+
assert_eq!(result.pane_id.as_str(), "%6");
|
|
570
|
+
assert_eq!(
|
|
571
|
+
calls
|
|
572
|
+
.iter()
|
|
573
|
+
.filter(|call| call.get(1).is_some_and(|arg| arg == "list-panes"))
|
|
574
|
+
.count(),
|
|
575
|
+
2,
|
|
576
|
+
"spawn identity must retry the same pane id until it becomes visible; calls={calls:?}"
|
|
577
|
+
);
|
|
578
|
+
assert!(
|
|
579
|
+
calls
|
|
580
|
+
.iter()
|
|
581
|
+
.all(|call| call.get(1).is_none_or(|arg| arg != "kill-pane")),
|
|
582
|
+
"a delayed but valid identity must not be rolled back; calls={calls:?}"
|
|
583
|
+
);
|
|
542
584
|
}
|
|
543
585
|
|
|
544
586
|
#[test]
|
|
@@ -1742,16 +1784,16 @@ fn query_single_field_argv_and_nonzero_maps_to_none() {
|
|
|
1742
1784
|
}
|
|
1743
1785
|
|
|
1744
1786
|
// ── 11. list_targets (TRANSPORT TRIO) — `list-panes -a -F TMUX_PANE_FORMAT` + per-line parse ────
|
|
1745
|
-
// Golden _legacy_pane_discovery.py:29-33 _tmux_list_panes: `tmux list-panes -a -F <TMUX_PANE_FORMAT
|
|
1746
|
-
//
|
|
1747
|
-
//
|
|
1787
|
+
// Golden _legacy_pane_discovery.py:29-33 _tmux_list_panes: `tmux list-panes -a -F <TMUX_PANE_FORMAT>`.
|
|
1788
|
+
// The Rust backend uses the printable separator locked below so the 12-field frame stays explicit
|
|
1789
|
+
// in argv/log evidence, while retaining the golden nonzero -> empty inventory behavior. P5 (C-P5-3) appends
|
|
1748
1790
|
// `#{pane_pid}` as field 12 so pane pids ride the single list-panes call (the per-pane
|
|
1749
1791
|
// display-message N+1 fallback is gone). leader_env stays the reverse-env real-machine bit.
|
|
1750
1792
|
#[test]
|
|
1751
1793
|
fn list_targets_argv_and_parses_tmux_pane_format() {
|
|
1752
|
-
const FMT: &str = "#{pane_id}
|
|
1753
|
-
let stdout = "%
|
|
1754
|
-
%
|
|
1794
|
+
const FMT: &str = "#{pane_id}__TA_FIELD__#{session_name}__TA_FIELD__#{window_index}__TA_FIELD__#{window_name}__TA_FIELD__#{pane_index}__TA_FIELD__#{pane_tty}__TA_FIELD__#{pane_current_command}__TA_FIELD__#{pane_active}__TA_FIELD__#{pane_current_path}__TA_FIELD__#{session_attached}__TA_FIELD__#{pane_in_mode}__TA_FIELD__#{pane_pid}";
|
|
1795
|
+
let stdout = "%7__TA_FIELD__team-x__TA_FIELD__0__TA_FIELD__win0__TA_FIELD__0__TA_FIELD__/dev/ttys003__TA_FIELD__codex__TA_FIELD__1__TA_FIELD__/Users/me/work__TA_FIELD__1__TA_FIELD__0__TA_FIELD__41001\n\
|
|
1796
|
+
%8__TA_FIELD__team-x__TA_FIELD__1__TA_FIELD__win1__TA_FIELD__0__TA_FIELD__/dev/ttys004__TA_FIELD__node__TA_FIELD__0__TA_FIELD__/Users/me/other__TA_FIELD__0__TA_FIELD__0__TA_FIELD__41002\n";
|
|
1755
1797
|
let (be, rec) = backend_with(MockResp::Out(ok(stdout)), vec![]);
|
|
1756
1798
|
let panes = be.list_targets().expect("list_targets ok");
|
|
1757
1799
|
assert_eq!(
|
|
@@ -71,6 +71,8 @@ pub trait CommandRunner: Send + Sync {
|
|
|
71
71
|
pub struct RealCommandRunner;
|
|
72
72
|
|
|
73
73
|
const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
|
|
74
|
+
const SPAWN_IDENTITY_TIMEOUT: Duration = Duration::from_millis(500);
|
|
75
|
+
const SPAWN_IDENTITY_POLL_INTERVAL: Duration = Duration::from_millis(25);
|
|
74
76
|
|
|
75
77
|
impl CommandRunner for RealCommandRunner {
|
|
76
78
|
fn run(&self, argv: &[String]) -> Result<CommandOutput, std::io::Error> {
|
|
@@ -779,46 +781,68 @@ impl TmuxBackend {
|
|
|
779
781
|
});
|
|
780
782
|
}
|
|
781
783
|
let pane_id = PaneId::new(pane);
|
|
782
|
-
let
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
784
|
+
let deadline = Instant::now() + SPAWN_IDENTITY_TIMEOUT;
|
|
785
|
+
let observed = loop {
|
|
786
|
+
match self.list_targets() {
|
|
787
|
+
Ok(targets) => {
|
|
788
|
+
if let Some(target) = targets.iter().find(|target| {
|
|
789
|
+
target.pane_id == pane_id
|
|
790
|
+
&& target.session.as_str() == session.as_str()
|
|
791
|
+
&& target
|
|
792
|
+
.window_name
|
|
793
|
+
.as_ref()
|
|
794
|
+
.is_some_and(|name| name.as_str() == window.as_str())
|
|
795
|
+
}) {
|
|
796
|
+
return Ok(SpawnResult {
|
|
797
|
+
pane_id,
|
|
798
|
+
session: session.clone(),
|
|
799
|
+
window: window.clone(),
|
|
800
|
+
child_pid: target.pane_pid,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
if let Some(target) = targets.iter().find(|target| target.pane_id == pane_id) {
|
|
804
|
+
break format!(
|
|
805
|
+
"{}:{}",
|
|
806
|
+
target.session.as_str(),
|
|
807
|
+
target
|
|
808
|
+
.window_name
|
|
809
|
+
.as_ref()
|
|
810
|
+
.map(WindowName::as_str)
|
|
811
|
+
.unwrap_or("<unknown>")
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
if Instant::now() >= deadline {
|
|
815
|
+
break "<missing-from-list-targets>".to_string();
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
Err(error) => {
|
|
819
|
+
if Instant::now() >= deadline {
|
|
820
|
+
break format!("<list-targets-error:{error}>");
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
std::thread::sleep(SPAWN_IDENTITY_POLL_INTERVAL);
|
|
825
|
+
};
|
|
826
|
+
let rollback_argv = vec![
|
|
827
|
+
"tmux".to_string(),
|
|
828
|
+
"kill-pane".to_string(),
|
|
829
|
+
"-t".to_string(),
|
|
830
|
+
pane_id.as_str().to_string(),
|
|
831
|
+
];
|
|
832
|
+
let rollback_error = self.run_ok(&rollback_argv).err();
|
|
833
|
+
let rollback_suffix = rollback_error
|
|
834
|
+
.map(|error| format!("; failed to roll back spawned pane: {error}"))
|
|
835
|
+
.unwrap_or_default();
|
|
813
836
|
Err(TransportError::Subprocess {
|
|
814
837
|
argv: spawn_argv,
|
|
815
838
|
code: output.code,
|
|
816
839
|
stderr: format!(
|
|
817
|
-
"tmux spawn pane identity mismatch: requested={}:{} observed_pane={} observed={}",
|
|
840
|
+
"tmux spawn pane identity mismatch: requested={}:{} observed_pane={} observed={}{}",
|
|
818
841
|
session.as_str(),
|
|
819
842
|
window.as_str(),
|
|
820
843
|
pane_id.as_str(),
|
|
821
|
-
observed
|
|
844
|
+
observed,
|
|
845
|
+
rollback_suffix
|
|
822
846
|
),
|
|
823
847
|
})
|
|
824
848
|
}
|
|
@@ -2314,7 +2338,9 @@ impl Transport for TmuxBackend {
|
|
|
2314
2338
|
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
2315
2339
|
// P5 (C-P5-3): `#{pane_pid}` rides the single list-panes call (field index 11),
|
|
2316
2340
|
// killing the per-pane display-message N+1 fallback.
|
|
2317
|
-
|
|
2341
|
+
// Use a printable sentinel so the 12-field frame stays explicit in argv/log evidence;
|
|
2342
|
+
// `parse_pane_info_line` retains compatibility with legacy tab-delimited output.
|
|
2343
|
+
const TMUX_PANE_FORMAT: &str = "#{pane_id}__TA_FIELD__#{session_name}__TA_FIELD__#{window_index}__TA_FIELD__#{window_name}__TA_FIELD__#{pane_index}__TA_FIELD__#{pane_tty}__TA_FIELD__#{pane_current_command}__TA_FIELD__#{pane_active}__TA_FIELD__#{pane_current_path}__TA_FIELD__#{session_attached}__TA_FIELD__#{pane_in_mode}__TA_FIELD__#{pane_pid}";
|
|
2318
2344
|
let argv = self.tmux_argv(&[
|
|
2319
2345
|
"tmux".to_string(),
|
|
2320
2346
|
"list-panes".to_string(),
|
|
@@ -2510,7 +2536,11 @@ fn query_pane_pid(backend: &TmuxBackend, pane: &PaneId) -> Result<Option<u32>, T
|
|
|
2510
2536
|
}
|
|
2511
2537
|
|
|
2512
2538
|
fn parse_pane_info_line(line: &str) -> Option<PaneInfo> {
|
|
2513
|
-
let fields = line.
|
|
2539
|
+
let fields = if line.contains("__TA_FIELD__") {
|
|
2540
|
+
line.split("__TA_FIELD__").collect::<Vec<_>>()
|
|
2541
|
+
} else {
|
|
2542
|
+
line.split('\t').collect::<Vec<_>>()
|
|
2543
|
+
};
|
|
2514
2544
|
if fields.len() < 11 {
|
|
2515
2545
|
return None;
|
|
2516
2546
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.49",
|
|
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.49",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.5.49",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.5.49"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|