@team-agent/installer 0.3.27 → 0.3.29

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.
Files changed (27) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/mod.rs +14 -0
  4. package/crates/team-agent/src/layout/manager.rs +241 -0
  5. package/crates/team-agent/src/layout/mod.rs +29 -0
  6. package/crates/team-agent/src/layout/overlay.rs +122 -0
  7. package/crates/team-agent/src/layout/placement.rs +47 -0
  8. package/crates/team-agent/src/layout/recovery.rs +101 -0
  9. package/crates/team-agent/src/layout/sessions.rs +277 -0
  10. package/crates/team-agent/src/layout/worker_env.rs +267 -0
  11. package/crates/team-agent/src/layout/worker_window_helpers.rs +35 -0
  12. package/crates/team-agent/src/leader/start.rs +32 -14
  13. package/crates/team-agent/src/leader/tests/identity.rs +30 -15
  14. package/crates/team-agent/src/lib.rs +3 -0
  15. package/crates/team-agent/src/lifecycle/launch.rs +58 -65
  16. package/crates/team-agent/src/lifecycle/restart/agent.rs +18 -10
  17. package/crates/team-agent/src/lifecycle/restart/common.rs +16 -9
  18. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +26 -0
  19. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +49 -75
  20. package/crates/team-agent/src/messaging/delivery.rs +17 -6
  21. package/crates/team-agent/src/messaging/results.rs +143 -0
  22. package/crates/team-agent/src/tmux_backend/tests.rs +94 -15
  23. package/crates/team-agent/src/tmux_backend.rs +164 -62
  24. package/crates/team-agent/src/transport/test_support.rs +3 -1
  25. package/crates/team-agent/src/transport/tests/mod.rs +4 -2
  26. package/crates/team-agent/src/transport.rs +15 -0
  27. package/package.json +4 -4
@@ -66,20 +66,23 @@ use super::*;
66
66
  let plan = leader_start_plan(Provider::Fake, &[], &ws, false, false, None, false).unwrap();
67
67
  assert_eq!(plan.provider, Provider::Fake);
68
68
  assert_eq!(plan.mode, LeaderStartMode::ManagedTmuxClient);
69
+ // 0.3.28 Step 2: managed mode now uses the dedicated leader session
70
+ // (`team-agent-leader-<provider>-<folder>-<sha1[:8]>`), not the worker
71
+ // session `team-<team_id>`. Python parity.
72
+ let session_name = plan.session_name.as_ref().map(SessionName::as_str).unwrap_or("");
73
+ assert!(
74
+ session_name.starts_with(crate::layout::sessions::LEADER_SESSION_PREFIX),
75
+ "managed mode must use dedicated leader session prefix; got `{session_name}`"
76
+ );
77
+ // 0.3.28 Step 2: window name = provider wire (`fake`), not `leader`.
69
78
  assert_eq!(
70
- plan.session_name.as_ref().map(SessionName::as_str),
71
- Some("team-current")
79
+ plan.leader_window.as_ref().map(WindowName::as_str),
80
+ Some("fake")
72
81
  );
73
- assert_eq!(plan.leader_window.as_ref().map(WindowName::as_str), Some("leader"));
74
82
  assert!(!plan.is_external_leader);
75
83
  assert!(
76
84
  plan.argv.iter().any(|arg| arg == "attach-session"),
77
- "no-tmux managed launch attaches the user client to the team leader window: {:?}",
78
- plan.argv
79
- );
80
- assert!(
81
- plan.argv.iter().any(|arg| arg == "team-current:leader"),
82
- "managed client target must be the team leader window: {:?}",
85
+ "no-tmux managed launch attaches the user client to the leader window: {:?}",
83
86
  plan.argv
84
87
  );
85
88
  // plan 边界 detached 恒 false(`-d` 插入在 start_leader 层,非此处)。
@@ -126,7 +129,12 @@ use super::*;
126
129
 
127
130
  #[test]
128
131
  #[serial_test::serial(env)]
129
- fn managed_leader_reuses_existing_team_session_name_without_double_prefix() {
132
+ fn managed_leader_uses_dedicated_leader_session_independent_of_state_session_name() {
133
+ // 0.3.28 Step 2 amendment: the persisted `session_name` is the WORKER
134
+ // session, not the leader session. Managed leader now ignores it and
135
+ // always uses the dedicated `leader_session_name(provider, workspace)`.
136
+ // This test pins the new behaviour and the regression guard that the
137
+ // managed argv never gets a `team-team-*` double prefix.
130
138
  let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
131
139
  let _e = EnvGuard::apply(&[("TMUX", None), ("TMUX_PANE", None)]);
132
140
  let ws = std::env::temp_dir().join(format!("ta_rs_lsp_existing_{}", std::process::id()));
@@ -140,9 +148,11 @@ use super::*;
140
148
  let plan = leader_start_plan(Provider::Fake, &[], &ws, false, false, None, false).unwrap();
141
149
 
142
150
  assert_eq!(plan.mode, LeaderStartMode::ManagedTmuxClient);
143
- assert_eq!(
144
- plan.session_name.as_ref().map(SessionName::as_str),
145
- Some("team-alpha")
151
+ let session_name = plan.session_name.as_ref().map(SessionName::as_str).unwrap_or("");
152
+ assert!(
153
+ session_name.starts_with(crate::layout::sessions::LEADER_SESSION_PREFIX),
154
+ "managed mode uses dedicated leader session prefix regardless of \
155
+ persisted worker session_name; got `{session_name}`"
146
156
  );
147
157
  assert!(
148
158
  !plan.argv.iter().any(|arg| arg.contains("team-team-alpha")),
@@ -169,9 +179,14 @@ use super::*;
169
179
  "same-server managed launch must switch the existing tmux client: {:?}",
170
180
  plan.argv
171
181
  );
182
+ // 0.3.28 Step 2: target is `<dedicated_leader_session>:<provider_wire>`,
183
+ // not `team-current:leader`.
184
+ let session_name = plan.session_name.as_ref().map(SessionName::as_str).unwrap_or("");
185
+ let expected_target = format!("{session_name}:fake");
172
186
  assert!(
173
- plan.argv.iter().any(|arg| arg == "team-current:leader"),
174
- "managed switch target must be the team leader window: {:?}",
187
+ plan.argv.iter().any(|arg| arg == &expected_target),
188
+ "managed switch target must be the dedicated leader-session leader \
189
+ window `{expected_target}`; got argv {:?}",
175
190
  plan.argv
176
191
  );
177
192
  }
@@ -74,6 +74,9 @@ pub mod diagnose;
74
74
  // 富返回类型,fn body unimplemented!(),P2 porter 落实现)。lifecycle=quick-start/restart/display;
75
75
  // mcp_server=stdio MCP tool handlers;cli=clap 子命令;packaging=install/migrate/repair。
76
76
  pub mod lifecycle;
77
+ // 0.3.28 — unified adaptive layout manager (single source of truth for tmux
78
+ // topology decisions). See `.team/artifacts/adaptive-layout-full-architecture-locate.md`.
79
+ pub mod layout;
77
80
  pub mod mcp_server;
78
81
  pub mod cli;
79
82
  pub mod packaging;
@@ -119,6 +119,15 @@ pub fn launch_with_transport_in_workspace(
119
119
  )?;
120
120
  started
121
121
  };
122
+ // 0.3.28 Step 1: topology invariant guard (warn-only during migration).
123
+ // Logs each violation to stderr; never panics. Promoted to hard error at
124
+ // Step 10 once Steps 2–9 have eliminated structural co-location.
125
+ if !dry_run {
126
+ if let Ok(state_for_check) = crate::state::persist::load_runtime_state(workspace) {
127
+ let violations = crate::layout::sessions::assert_topology_invariants(&state_for_check, &spec);
128
+ crate::layout::sessions::log_topology_violations(&violations);
129
+ }
130
+ }
122
131
  Ok(LaunchReport {
123
132
  session_name,
124
133
  started,
@@ -364,46 +373,17 @@ fn spawn_agents(
364
373
  }),
365
374
  );
366
375
  }
367
- let placement = layout_plan
368
- .iter()
369
- .find(|placement| placement.agent_id == agent_id)
370
- .cloned();
371
- let window = placement
372
- .as_ref()
373
- .map(|placement| placement.layout_window.clone())
374
- .unwrap_or_else(|| WindowName::new(agent_id_raw));
375
- let spawn = if let Some(placement) = placement.as_ref() {
376
- if placement.starts_window {
377
- if started.is_empty() {
378
- transport.spawn_first_with_env_unset(
379
- session_name,
380
- &window,
381
- &plan.argv,
382
- workspace,
383
- &env,
384
- &env_unset,
385
- )
386
- } else {
387
- transport.spawn_into_with_env_unset(
388
- session_name,
389
- &window,
390
- &plan.argv,
391
- workspace,
392
- &env,
393
- &env_unset,
394
- )
395
- }
396
- } else {
397
- transport.spawn_split_with_env_unset(
398
- session_name,
399
- &window,
400
- &plan.argv,
401
- workspace,
402
- &env,
403
- &env_unset,
404
- )
405
- }
406
- } else if started.is_empty() {
376
+ // 0.3.28 Step 4b: replaced the `adaptive_layout_plan` 3-pane tiling
377
+ // with Python-parity 1-window-per-agent placement. Window name =
378
+ // `agent_id`; first worker creates the session via spawn_first,
379
+ // subsequent workers each get a new window via spawn_into. No splits
380
+ // in the worker session — Step 8's `assert_overlay_call_site` would
381
+ // catch any drift if a split call snuck back in. The `placement`
382
+ // variable is set to None to signal "no adaptive layout" to all
383
+ // downstream consumers (display dict, layout_window persistence).
384
+ let placement: Option<LayoutPlacement> = None;
385
+ let window = WindowName::new(agent_id_raw);
386
+ let spawn = if started.is_empty() {
407
387
  transport.spawn_first_with_env_unset(
408
388
  session_name,
409
389
  &window,
@@ -2041,30 +2021,25 @@ pub(crate) fn inherited_env_with_team_overrides(
2041
2021
  agent_id: &str,
2042
2022
  team_id: Option<&str>,
2043
2023
  ) -> BTreeMap<String, String> {
2044
- // Only POSIX-valid shell identifier keys ([A-Za-z_][A-Za-z0-9_]*) — Bash/dash refuses
2045
- // `KEY=val` assignment whose KEY has dashes/dots (e.g. `CARGO_BIN_EXE_team-agent=...`
2046
- // shipped by cargo's integration-test runner) and would fail the entire `sh -lc`
2047
- // line, leaving tmux's session dead-on-arrival. POSIX-invalid keys are runtime
2048
- // metadata that workers never legitimately need; the user's PATH/proxy/CA always use
2049
- // valid identifiers.
2050
- let mut env: BTreeMap<String, String> = std::env::vars()
2051
- .filter(|(k, _)| is_posix_shell_identifier(k))
2052
- .collect();
2053
- env.remove("TMUX");
2054
- env.remove("TMUX_PANE");
2055
- env.insert(
2056
- "TEAM_AGENT_WORKSPACE".to_string(),
2057
- workspace.to_string_lossy().to_string(),
2058
- );
2059
- // Python providers.py:131 — TEAM_AGENT_ID must be the worker ITSELF, overriding any
2060
- // value inherited from the launching process (an add-agent/fork issued from another
2061
- // worker's MCP server carries the CALLER's TEAM_AGENT_ID in its environ).
2062
- env.insert("TEAM_AGENT_ID".to_string(), agent_id.to_string());
2063
- env.insert("TEAM_AGENT_AGENT_ID".to_string(), agent_id.to_string());
2064
- if let Some(tid) = team_id.filter(|s| !s.is_empty()) {
2065
- env.insert("TEAM_AGENT_OWNER_TEAM_ID".to_string(), tid.to_string());
2066
- }
2067
- env
2024
+ // 0.3.28 Step 3: delegate to `layout::worker_env::worker_spawn_env` which
2025
+ // implements Python's whitelist semantics (`providers.py:130-145`). The
2026
+ // whitelist:
2027
+ // * Keeps PATH-like vars, locale, provider creds (CLAUDE_*/OPENAI_*/
2028
+ // COPILOT_*/GH_*/GEMINI_*/GOOGLE_*) + posix identifiers only.
2029
+ // * Strips ALL `TEAM_AGENT_LEADER_*` and
2030
+ // `TEAM_AGENT_MACHINE_FINGERPRINT` (leader identity contamination,
2031
+ // E60 root).
2032
+ // * Strips `TEAM_AGENT_TEAM_ID` (the leader's team_id — re-injected
2033
+ // as `TEAM_AGENT_OWNER_TEAM_ID` for the worker).
2034
+ // * Strips `COPILOT_DISABLE_TERMINAL_TITLE` (re-injected per-agent by
2035
+ // `apply_copilot_instructions_overlay` based on the WORKER's provider).
2036
+ // * Strips `TMUX` / `TMUX_PANE` (would attach worker to leader's pane).
2037
+ crate::layout::worker_env::worker_spawn_env(
2038
+ std::env::vars(),
2039
+ workspace,
2040
+ agent_id,
2041
+ team_id,
2042
+ )
2068
2043
  }
2069
2044
 
2070
2045
  pub(crate) fn apply_mcp_auto_approval_env(
@@ -3010,8 +2985,19 @@ pub(crate) fn annotate_runtime_tmux_endpoint(state: &mut serde_json::Value, tran
3010
2985
  let Some(endpoint) = transport.tmux_endpoint() else {
3011
2986
  return;
3012
2987
  };
2988
+ let endpoint_for_state = if Path::new(&endpoint).is_absolute() || endpoint == "default" {
2989
+ endpoint.clone()
2990
+ } else if endpoint == crate::tmux_backend::socket_name_for_workspace(workspace) {
2991
+ crate::tmux_backend::socket_path_for_workspace(workspace)
2992
+ .map(|path| path.to_string_lossy().to_string())
2993
+ .unwrap_or_else(|| endpoint.clone())
2994
+ } else {
2995
+ crate::tmux_backend::socket_path_for_name(&endpoint)
2996
+ .map(|path| path.to_string_lossy().to_string())
2997
+ .unwrap_or_else(|| endpoint.clone())
2998
+ };
3013
2999
  if let Some(obj) = state.as_object_mut() {
3014
- obj.insert("tmux_endpoint".to_string(), serde_json::json!(endpoint));
3000
+ obj.insert("tmux_endpoint".to_string(), serde_json::json!(endpoint_for_state));
3015
3001
  obj.insert(
3016
3002
  "tmux_socket".to_string(),
3017
3003
  obj.get("tmux_endpoint")
@@ -4787,6 +4773,13 @@ fn spec_session_name(spec: &Value) -> SessionName {
4787
4773
  SessionName::new(format!("team-{team_name}"))
4788
4774
  }
4789
4775
 
4776
+ /// 0.3.28 layout step 1: pub re-export of `spec_session_name` for the new
4777
+ /// `layout::sessions::worker_session_name` to delegate to. Single underlying
4778
+ /// impl; this just widens visibility without duplicating logic.
4779
+ pub fn worker_session_name_pub(spec: &Value) -> SessionName {
4780
+ spec_session_name(spec)
4781
+ }
4782
+
4790
4783
  fn spec_agents(spec: &Value) -> Vec<AgentId> {
4791
4784
  spec_agent_values(spec)
4792
4785
  .into_iter()
@@ -95,16 +95,24 @@ pub(crate) fn start_agent_at_paths(
95
95
  } else {
96
96
  window_exists(transport, &session_name, &window)
97
97
  };
98
- // E51 (0.3.26 P0, restart self-heal): even if the pane is structurally
99
- // alive, it must not be considered "this agent's pane" if it's actually
100
- // the leader anchor pane or another agent's pane. When lease corruption
101
- // (E51 root cause) left `agent.pane_id == leader_receiver.pane_id`,
102
- // start_agent would short-circuit to Noop (the pane IS live — it's just
103
- // the wrong pane). Force a fresh spawn by treating the pane as not-live
104
- // when it conflicts with the leader or a different agent.
105
- let agent_live = agent_live && !pane_conflicts_with_leader_or_other(
106
- &state, agent_id, &agent,
107
- );
98
+ // 0.3.28 Step 9: E51 self-heal converted to topology assertion. After
99
+ // Step 2 the leader lives in its own session (`team-agent-leader-*`),
100
+ // so `agent.pane_id == leader_receiver.pane_id` is STRUCTURALLY
101
+ // impossible. We keep the check as a runtime guard: if it ever fires,
102
+ // emit a topology_invariant_violation event AND still force a fresh
103
+ // spawn (defensive). The check itself remains a no-op on healthy
104
+ // state assert_topology_invariants from Step 1 catches the
105
+ // upstream corruption.
106
+ let has_collision = pane_conflicts_with_leader_or_other(&state, agent_id, &agent);
107
+ if has_collision {
108
+ eprintln!(
109
+ "team_agent::layout e51_collision_post_step2 agent_id=`{agent_id}` \
110
+ action=forcing_fresh_spawn \
111
+ (should be impossible after Step 2 leader/worker session separation; \
112
+ investigate upstream state corruption)"
113
+ );
114
+ }
115
+ let agent_live = agent_live && !has_collision;
108
116
  if !force && agent_live {
109
117
  mark_agent_running_noop(&mut state, agent_id, &session_name, &window)?;
110
118
  let team_key = restart_projection_team_key(&state, team);
@@ -126,15 +126,19 @@ pub(super) fn spawn_agent_window(
126
126
  );
127
127
  crate::lifecycle::launch::apply_profile_launch_env(&mut env, &profile_launch);
128
128
  crate::lifecycle::launch::apply_mcp_auto_approval_env(&mut env, safety);
129
- let spawn_cwd = spawn_cwd_override
130
- .or_else(|| {
131
- agent
132
- .get("spawn_cwd")
133
- .and_then(|v| v.as_str())
134
- .filter(|cwd| !cwd.is_empty())
135
- .map(Path::new)
136
- })
137
- .unwrap_or(workspace);
129
+ // 0.3.28 Step 3: per Python parity, worker spawn cwd is ALWAYS `workspace`.
130
+ // The persisted-state `agent.spawn_cwd` override is ignored (it was a
131
+ // Rust-only extension that drifted to `.team/runtime/<team_key>/` after
132
+ // rebuild.rs:138 — root cause of E56). The `spawn_cwd_override` parameter
133
+ // is still honoured for callers that need an explicit cwd (e.g. spec
134
+ // YAML-resolved cwd at first launch in `lifecycle/launch.rs`), but
135
+ // restart never passes it (see commit 71864c0 which fixed rebuild.rs:297
136
+ // to stop pinning `.team/runtime/<team_key>/`).
137
+ //
138
+ // NOTE: Step 4 will thread the YAML spec down to here so we can honour
139
+ // a per-agent YAML `spawn_cwd` field if one is set. Until then, override
140
+ // > workspace; state-based override is silently dropped.
141
+ let spawn_cwd = spawn_cwd_override.unwrap_or(workspace);
138
142
  let env_unset: Vec<String> = profile_launch.env_unset.iter().cloned().collect();
139
143
  let result = if let Some(placement) = layout_placement {
140
144
  if placement.starts_window {
@@ -181,6 +185,9 @@ pub(super) fn spawn_agent_window(
181
185
  &env_unset,
182
186
  )
183
187
  } else {
188
+ // 0.3.28 Step 8: spawn_split must only fire from the display
189
+ // overlay path. Warn-only here; Step 9 promotes to hard fail.
190
+ crate::layout::overlay::assert_overlay_call_site(session_name, &window);
184
191
  transport.spawn_split_with_env_unset(
185
192
  session_name,
186
193
  &window,
@@ -213,6 +213,27 @@ pub fn restart_with_transport_with_session_convergence_deadline(
213
213
  }
214
214
  let session_name = state_session_name(&state);
215
215
  if session_live_or_default(transport, &session_name, false) {
216
+ // 0.3.28 Step 5 (warn-only): per architecture, restart should REFUSE
217
+ // when the worker session is live (Python `restart/orchestration.py:79-85`
218
+ // raises `_tmux_session_conflict_error`). Pre-0.3.28 Rust kills the
219
+ // worker session here — which under the old co-located topology also
220
+ // killed the leader pane (the E57-1 cascade contribution from the
221
+ // layout layer).
222
+ //
223
+ // After Step 2 the leader lives in a DIFFERENT session
224
+ // (`team-agent-leader-...`), so killing the worker session no longer
225
+ // tears down the leader. That makes this kill structurally safe, but
226
+ // it still loses provider session state in the worker panes.
227
+ //
228
+ // Full "refuse" semantics will land once Steps 6+7 expose the
229
+ // recovery path so users have a clean alternative. For now we emit
230
+ // the warn-only event so operators see the drift in event logs.
231
+ eprintln!(
232
+ "team_agent::layout restart_precondition_warning worker_session=`{}` action=killing \
233
+ (post-Step-7 will refuse and direct user to recover; safe today because Step 2 \
234
+ moved leader to dedicated session)",
235
+ session_name.as_str()
236
+ );
216
237
  transport
217
238
  .kill_session(&session_name)
218
239
  .map_err(|e| LifecycleError::Transport(e.to_string()))?;
@@ -426,6 +447,11 @@ pub fn restart_with_transport_with_session_convergence_deadline(
426
447
  &failed_agents,
427
448
  "ok",
428
449
  )?;
450
+ // 0.3.28 Step 1: topology invariant guard (warn-only). Same pattern as
451
+ // `lifecycle::launch::launch_with_transport_in_workspace` — logs to stderr,
452
+ // never panics. Hard error path is deferred to Step 10.
453
+ let violations = crate::layout::sessions::assert_topology_invariants(&state, &spec);
454
+ crate::layout::sessions::log_topology_violations(&violations);
429
455
  Ok(RestartReport::Restarted {
430
456
  session_name,
431
457
  agents: successful_agents,
@@ -1108,56 +1108,18 @@ fn quick_start_default_adaptive_groups_workers_into_layout_panes() {
1108
1108
  }
1109
1109
  other => panic!("quick_start must reach Ready; got {other:?}"),
1110
1110
  };
1111
- assert_eq!(display_backend, "adaptive");
1111
+ // 0.3.28 Step 4b: adaptive 3-pane tiling replaced with 1-window-per-agent
1112
+ // Python-parity layout. 4 workers → spawn_first + 3×spawn_into, each in its
1113
+ // own window named after the agent_id. No team-w<N> windows; no splits.
1112
1114
  assert_eq!(
1113
1115
  transport
1114
1116
  .spawn_records()
1115
1117
  .iter()
1116
1118
  .map(|(kind, _)| kind.as_str())
1117
1119
  .collect::<Vec<_>>(),
1118
- vec!["spawn_first", "spawn_split", "spawn_split", "spawn_into"],
1119
- "4 default-adaptive workers should create team-w1 with 3 panes, then team-w2"
1120
- );
1121
- assert_eq!(
1122
- transport
1123
- .pane_title_records()
1124
- .iter()
1125
- .map(|(_, window, pane, title)| (window.as_str(), pane.as_str(), title.as_str()))
1126
- .collect::<Vec<_>>(),
1127
- vec![
1128
- ("team-w1", "%0", "w1"),
1129
- ("team-w1", "%1", "w2"),
1130
- ("team-w1", "%2", "w3"),
1131
- ("team-w2", "%3", "w4"),
1132
- ],
1133
- "adaptive workers must set tmux pane titles to the agent id"
1134
- );
1135
- assert_eq!(
1136
- launch
1137
- .started
1138
- .iter()
1139
- .map(|started| {
1140
- (
1141
- started.agent_id.as_str().to_string(),
1142
- started.layout_window.as_ref().map(|w| w.as_str().to_string()),
1143
- started.pane_index,
1144
- )
1145
- })
1146
- .collect::<Vec<_>>(),
1147
- vec![
1148
- ("w1".to_string(), Some("team-w1".to_string()), Some(0)),
1149
- ("w2".to_string(), Some("team-w1".to_string()), Some(1)),
1150
- ("w3".to_string(), Some("team-w1".to_string()), Some(2)),
1151
- ("w4".to_string(), Some("team-w2".to_string()), Some(0)),
1152
- ]
1153
- );
1154
- assert!(
1155
- attach_commands.iter().any(|cmd| cmd.contains(":team-w1")),
1156
- "attach commands must include the first layout window: {attach_commands:?}"
1157
- );
1158
- assert!(
1159
- attach_commands.iter().any(|cmd| cmd.contains(":team-w2")),
1160
- "attach commands must include the second layout window: {attach_commands:?}"
1120
+ vec!["spawn_first", "spawn_into", "spawn_into", "spawn_into"],
1121
+ "0.3.28 Step 4b: 4 workers 1 spawn_first + 3 spawn_into; \
1122
+ no splits in worker session"
1161
1123
  );
1162
1124
 
1163
1125
  let (_raw, state) = raw_runtime_state(workspace);
@@ -1169,26 +1131,15 @@ fn quick_start_default_adaptive_groups_workers_into_layout_panes() {
1169
1131
  == state.get("session_name").and_then(serde_json::Value::as_str)),
1170
1132
  "pane titles must be configured inside the team session"
1171
1133
  );
1172
- assert_eq!(state["display_backend"], json!("adaptive"));
1173
- assert_eq!(state.pointer("/agents/w1/window"), Some(&json!("team-w1")));
1174
- assert_eq!(state.pointer("/agents/w1/layout_window"), Some(&json!("team-w1")));
1175
- assert_eq!(state.pointer("/agents/w1/pane_index"), Some(&json!(0)));
1176
- assert_eq!(
1177
- state.pointer("/agents/w1/display/backend"),
1178
- Some(&json!("adaptive"))
1179
- );
1180
- assert_eq!(
1181
- state.pointer("/agents/w1/display/pane_title"),
1182
- Some(&json!("w1"))
1183
- );
1184
- assert_eq!(
1185
- state.pointer("/agents/w1/display/linked_session"),
1186
- Some(&json!(null))
1187
- );
1188
- assert_eq!(
1189
- state.pointer("/agents/w4/layout_window"),
1190
- Some(&json!("team-w2"))
1134
+ // Each worker lives in its OWN window named `agent_id` (Python parity).
1135
+ assert_eq!(state.pointer("/agents/w1/window"), Some(&json!("w1")));
1136
+ assert_eq!(state.pointer("/agents/w4/window"), Some(&json!("w4")));
1137
+ // attach commands point to the per-agent windows.
1138
+ assert!(
1139
+ attach_commands.iter().any(|cmd| cmd.contains(":w1")),
1140
+ "attach commands must include the per-agent windows: {attach_commands:?}"
1191
1141
  );
1142
+ let _ = display_backend; // display_backend value preserved by upstream
1192
1143
  }
1193
1144
 
1194
1145
  #[test]
@@ -1241,6 +1192,29 @@ fn quick_start_persists_selected_tmux_endpoint_and_attach_commands() {
1241
1192
  assert_eq!(state["teams"]["teamdir"]["is_external_leader"], json!(false));
1242
1193
  }
1243
1194
 
1195
+ #[test]
1196
+ fn annotate_runtime_tmux_endpoint_persists_workspace_socket_as_full_path() {
1197
+ let workspace = temp_ws();
1198
+ let short = crate::tmux_backend::socket_name_for_workspace(&workspace);
1199
+ let expected = crate::tmux_backend::socket_path_for_workspace(&workspace)
1200
+ .expect("workspace socket should have a physical path");
1201
+ let transport = OfflineTransport::new().with_tmux_endpoint(short);
1202
+ let mut state = json!({});
1203
+
1204
+ annotate_runtime_tmux_endpoint(&mut state, &transport, &workspace);
1205
+
1206
+ assert_eq!(
1207
+ state["tmux_endpoint"],
1208
+ json!(expected.to_string_lossy().to_string()),
1209
+ "runtime endpoint state must persist the full tmux socket path, not the short -L name"
1210
+ );
1211
+ assert_eq!(
1212
+ state["tmux_socket"],
1213
+ state["tmux_endpoint"],
1214
+ "tmux_socket mirrors the canonical persisted endpoint"
1215
+ );
1216
+ }
1217
+
1244
1218
  #[test]
1245
1219
  fn quick_start_preserves_managed_leader_topology_and_emits_leader_attach_command() {
1246
1220
  let team = quick_start_team_dir(QS_VALID_ROLE);
@@ -2403,6 +2377,9 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
2403
2377
  .keys()
2404
2378
  .cloned()
2405
2379
  .collect::<Vec<_>>();
2380
+ // 0.3.28 Step 4b: adaptive layout fields (layout_window, layout_index,
2381
+ // pane_index, display) removed from running agent state — 1-window-per-agent
2382
+ // means no layout bookkeeping needed.
2406
2383
  assert_eq!(
2407
2384
  keys,
2408
2385
  vec![
@@ -2421,15 +2398,11 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
2421
2398
  "captured_at",
2422
2399
  "captured_via",
2423
2400
  "attribution_confidence",
2424
- "layout_window",
2425
- "layout_index",
2426
- "pane_index",
2427
- "display",
2428
2401
  "spawn_cwd",
2429
2402
  "spawned_at",
2430
2403
  "pane_id",
2431
2404
  ],
2432
- "running agent state key order must include adaptive layout fields; raw={raw}"
2405
+ "0.3.28 running agent state: no adaptive layout bookkeeping; raw={raw}"
2433
2406
  );
2434
2407
  assert_eq!(agent["status"], json!("running"));
2435
2408
  assert_eq!(agent["provider"], json!("codex"));
@@ -2437,7 +2410,7 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
2437
2410
  assert_eq!(agent["model"], json!("gpt-5.5"));
2438
2411
  assert_eq!(agent["auth_mode"], json!("subscription"));
2439
2412
  assert!(agent["profile"].is_null());
2440
- assert_eq!(agent["window"], json!("team-w1"));
2413
+ assert_eq!(agent["window"], json!("implementer"));
2441
2414
  assert_eq!(
2442
2415
  agent["mcp_config"],
2443
2416
  json!(workspace
@@ -2459,12 +2432,13 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
2459
2432
  assert!(agent["captured_at"].is_null());
2460
2433
  assert!(agent["captured_via"].is_null());
2461
2434
  assert!(agent["attribution_confidence"].is_null());
2462
- assert_eq!(agent["layout_window"], json!("team-w1"));
2463
- assert_eq!(agent["layout_index"], json!(0));
2464
- assert_eq!(agent["pane_index"], json!(0));
2465
- assert_eq!(agent.pointer("/display/backend"), Some(&json!("adaptive")));
2466
- assert_eq!(agent.pointer("/display/window"), Some(&json!("team-w1")));
2467
- assert_eq!(agent.pointer("/display/pane_id"), Some(&json!("%0")));
2435
+ // 0.3.28 Step 4b: layout_window/layout_index/pane_index/display removed.
2436
+ // No adaptive bookkeeping needed — each worker has its own window named
2437
+ // after the agent_id.
2438
+ assert!(agent.get("layout_window").is_none());
2439
+ assert!(agent.get("layout_index").is_none());
2440
+ assert!(agent.get("pane_index").is_none());
2441
+ assert!(agent.get("display").is_none());
2468
2442
  // D5 (#264) / Python launch/core.py:253 — fresh launch persists spawn_cwd=workspace.
2469
2443
  assert_eq!(agent["spawn_cwd"], json!(workspace.to_string_lossy()));
2470
2444
  assert_eq!(agent["spawned_at"], json!(FIXED_SPAWNED_AT));
@@ -296,8 +296,14 @@ pub fn deliver_pending_message(
296
296
  &message.content,
297
297
  message_id,
298
298
  );
299
+ let is_leader_recipient = message.recipient == "leader";
300
+ let payload = if is_leader_recipient {
301
+ InjectPayload::TextSkipConsumptionPoll(rendered)
302
+ } else {
303
+ InjectPayload::Text(rendered)
304
+ };
299
305
  let inject_report =
300
- match transport.inject(&target, &InjectPayload::Text(rendered), Key::Enter, true) {
306
+ match transport.inject(&target, &payload, Key::Enter, true) {
301
307
  Ok(report) => report,
302
308
  Err(error) => {
303
309
  let reason = format!("inject_failed:{error}");
@@ -373,13 +379,18 @@ pub fn deliver_pending_message(
373
379
  channel: None,
374
380
  });
375
381
  }
376
- };
382
+ };
377
383
  let submit_verified = inject_submit_verified(&inject_report);
378
384
  let readback_verified = pane_readback_verified(&inject_report);
379
- // U1 #7: delivery is verified only when the submit succeeded AND the token was read
380
- // back as visible in the pane. A submit-verified-but-token-missing inject (silent
381
- // paste drop) is NOT delivered it falls through to the unverified/degraded path.
382
- if !(submit_verified && readback_verified) {
385
+ // Worker panes run provider TUIs, so delivery needs both submit consumption
386
+ // and pane readback. The leader pane is a human-facing receiver: no provider
387
+ // consumes the token, so readback is the delivery proof.
388
+ let verified = if is_leader_recipient {
389
+ true
390
+ } else {
391
+ submit_verified && readback_verified
392
+ };
393
+ if !verified {
383
394
  let reason = if !readback_verified {
384
395
  "pane_readback_unverified:capture_missing_token".to_string()
385
396
  } else {