@team-agent/installer 0.3.26 → 0.3.28

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.
@@ -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(
@@ -3292,6 +3267,13 @@ fn dangerous_leader_flags() -> &'static [(&'static str, &'static str)] {
3292
3267
  ("claude", "--dangerously-skip-permissions"),
3293
3268
  ("claude", "--dangerously-skip-permission"),
3294
3269
  ("codex", "--dangerously-bypass-approvals-and-sandbox"),
3270
+ // 0.3.27 P1 (E54 symptom 2): copilot's full-permission flags. Without
3271
+ // these entries, a copilot leader running with --allow-all / --yolo would
3272
+ // NOT propagate bypass to claude/codex workers because
3273
+ // detect_dangerous_approval scans the coordinator's ancestor argv and
3274
+ // only matches entries in THIS table.
3275
+ ("copilot", "--allow-all"),
3276
+ ("copilot", "--yolo"),
3295
3277
  ]
3296
3278
  }
3297
3279
 
@@ -3299,6 +3281,8 @@ fn binary_matches_provider(provider: &str, binary: Option<&str>) -> bool {
3299
3281
  match (provider, binary) {
3300
3282
  ("codex", Some("codex")) => true,
3301
3283
  ("claude", Some("claude" | "claude-code" | "claude_code")) => true,
3284
+ // 0.3.27 P1 (E54): copilot binary detection for dangerous-approval scan.
3285
+ ("copilot", Some("copilot" | "github-copilot" | "gh-copilot")) => true,
3302
3286
  _ => false,
3303
3287
  }
3304
3288
  }
@@ -4778,6 +4762,13 @@ fn spec_session_name(spec: &Value) -> SessionName {
4778
4762
  SessionName::new(format!("team-{team_name}"))
4779
4763
  }
4780
4764
 
4765
+ /// 0.3.28 layout step 1: pub re-export of `spec_session_name` for the new
4766
+ /// `layout::sessions::worker_session_name` to delegate to. Single underlying
4767
+ /// impl; this just widens visibility without duplicating logic.
4768
+ pub fn worker_session_name_pub(spec: &Value) -> SessionName {
4769
+ spec_session_name(spec)
4770
+ }
4771
+
4781
4772
  fn spec_agents(spec: &Value) -> Vec<AgentId> {
4782
4773
  spec_agent_values(spec)
4783
4774
  .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]
@@ -2403,6 +2354,9 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
2403
2354
  .keys()
2404
2355
  .cloned()
2405
2356
  .collect::<Vec<_>>();
2357
+ // 0.3.28 Step 4b: adaptive layout fields (layout_window, layout_index,
2358
+ // pane_index, display) removed from running agent state — 1-window-per-agent
2359
+ // means no layout bookkeeping needed.
2406
2360
  assert_eq!(
2407
2361
  keys,
2408
2362
  vec![
@@ -2421,15 +2375,11 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
2421
2375
  "captured_at",
2422
2376
  "captured_via",
2423
2377
  "attribution_confidence",
2424
- "layout_window",
2425
- "layout_index",
2426
- "pane_index",
2427
- "display",
2428
2378
  "spawn_cwd",
2429
2379
  "spawned_at",
2430
2380
  "pane_id",
2431
2381
  ],
2432
- "running agent state key order must include adaptive layout fields; raw={raw}"
2382
+ "0.3.28 running agent state: no adaptive layout bookkeeping; raw={raw}"
2433
2383
  );
2434
2384
  assert_eq!(agent["status"], json!("running"));
2435
2385
  assert_eq!(agent["provider"], json!("codex"));
@@ -2437,7 +2387,7 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
2437
2387
  assert_eq!(agent["model"], json!("gpt-5.5"));
2438
2388
  assert_eq!(agent["auth_mode"], json!("subscription"));
2439
2389
  assert!(agent["profile"].is_null());
2440
- assert_eq!(agent["window"], json!("team-w1"));
2390
+ assert_eq!(agent["window"], json!("implementer"));
2441
2391
  assert_eq!(
2442
2392
  agent["mcp_config"],
2443
2393
  json!(workspace
@@ -2459,12 +2409,13 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
2459
2409
  assert!(agent["captured_at"].is_null());
2460
2410
  assert!(agent["captured_via"].is_null());
2461
2411
  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")));
2412
+ // 0.3.28 Step 4b: layout_window/layout_index/pane_index/display removed.
2413
+ // No adaptive bookkeeping needed — each worker has its own window named
2414
+ // after the agent_id.
2415
+ assert!(agent.get("layout_window").is_none());
2416
+ assert!(agent.get("layout_index").is_none());
2417
+ assert!(agent.get("pane_index").is_none());
2418
+ assert!(agent.get("display").is_none());
2468
2419
  // D5 (#264) / Python launch/core.py:253 — fresh launch persists spawn_cwd=workspace.
2469
2420
  assert_eq!(agent["spawn_cwd"], json!(workspace.to_string_lossy()));
2470
2421
  assert_eq!(agent["spawned_at"], json!(FIXED_SPAWNED_AT));
@@ -473,7 +473,8 @@ pub fn deliver_pending_message(
473
473
  Ok(outcome)
474
474
  }
475
475
 
476
- fn inject_submit_verified(report: &InjectReport) -> bool {
476
+ /// 0.3.27: promoted to pub(crate) for leader_receiver.rs verification gate.
477
+ pub(crate) fn inject_submit_verified(report: &InjectReport) -> bool {
477
478
  match report.submit_verification {
478
479
  SubmitVerification::SendKeysFailed => false,
479
480
  SubmitVerification::PastedContentPromptStillPresentAfterSubmit => false,
@@ -502,7 +503,8 @@ fn inject_submit_verified(report: &InjectReport) -> bool {
502
503
  /// `CaptureMissingToken` → readback failed → not delivered (degraded/unverified). All
503
504
  /// other inject_verification variants (incl. token-less payloads, empty sends, new
504
505
  /// pasted-content prompt) are not readback-negative and pass this gate.
505
- fn pane_readback_verified(report: &InjectReport) -> bool {
506
+ /// 0.3.27: promoted to pub(crate) for leader_receiver.rs verification gate.
507
+ pub(crate) fn pane_readback_verified(report: &InjectReport) -> bool {
506
508
  !matches!(
507
509
  report.inject_verification,
508
510
  InjectVerification::CaptureMissingToken
@@ -11,7 +11,7 @@ use crate::model::ids::{OwnerEpoch, TaskId};
11
11
  use crate::transport::{InjectPayload, Key, PaneId, Target, Transport};
12
12
 
13
13
  use super::helpers::MessageStatusShadow;
14
- use super::{DeliveryOutcome, DeliveryRefusal, DeliveryStatus, MessagingError};
14
+ use super::{DeliveryOutcome, DeliveryRefusal, DeliveryStage, DeliveryStatus, MessagingError};
15
15
 
16
16
  /// `_send_to_leader_receiver` (`leader.py:69`) — **N31/N32 funnel primitive**:所有 leader-bound
17
17
  /// caller(send_message(to=leader) / report_result / request_human / idle reminder /
@@ -393,29 +393,66 @@ pub fn deliver_to_leader_fallback_pane(
393
393
 
394
394
  match inject_result {
395
395
  Ok(report) => {
396
- store.mark(message_id, "delivered", None)?;
397
- event_log.write(
398
- "leader_receiver.fallback_pane_submitted",
399
- serde_json::json!({
400
- "message_id": message_id,
401
- "result_id": result_id,
402
- "owner_team_id": owner_team_id,
403
- "pane_id": pane_id,
404
- "primary_error": primary_error,
405
- "delivered_via": "fallback_pane",
406
- "verification": format!("{:?}", report.submit_verification),
407
- }),
408
- )?;
409
- Ok(DeliveryOutcome {
410
- ok: true,
411
- status: DeliveryStatus::Delivered,
412
- message_status: MessageStatusShadow("delivered".to_string()),
413
- message_id: Some(message_id.to_string()),
414
- verification: Some("delivered_via=fallback_pane".to_string()),
415
- stage: None,
416
- reason: None,
417
- channel: Some("fallback_pane".to_string()),
418
- })
396
+ // 0.3.27 P0: leader_receiver verification gate. The pre-fix path
397
+ // marked every inject Ok as delivered regardless of whether the
398
+ // submit was actually verified. Apply the same two-gate check that
399
+ // deliver_pending_message uses (delivery.rs:376-381):
400
+ // (a) inject_submit_verified — Enter was accepted by the TUI
401
+ // (b) pane_readback_verified — token was visible in the pane
402
+ // Both must pass to mark delivered; otherwise degrade to
403
+ // submitted_unverified (loud, not silent).
404
+ let submit_ok = super::delivery::inject_submit_verified(&report);
405
+ let readback_ok = super::delivery::pane_readback_verified(&report);
406
+ if submit_ok && readback_ok {
407
+ store.mark(message_id, "delivered", None)?;
408
+ event_log.write(
409
+ "leader_receiver.fallback_pane_submitted",
410
+ serde_json::json!({
411
+ "message_id": message_id,
412
+ "result_id": result_id,
413
+ "owner_team_id": owner_team_id,
414
+ "pane_id": pane_id,
415
+ "primary_error": primary_error,
416
+ "delivered_via": "fallback_pane",
417
+ "verification": format!("{:?}", report.submit_verification),
418
+ }),
419
+ )?;
420
+ Ok(DeliveryOutcome {
421
+ ok: true,
422
+ status: DeliveryStatus::Delivered,
423
+ message_status: MessageStatusShadow("delivered".to_string()),
424
+ message_id: Some(message_id.to_string()),
425
+ verification: Some("delivered_via=fallback_pane".to_string()),
426
+ stage: None,
427
+ reason: None,
428
+ channel: Some("fallback_pane".to_string()),
429
+ })
430
+ } else {
431
+ let reason = format!(
432
+ "fallback_pane_unverified:submit={submit_ok},readback={readback_ok},sv={:?}",
433
+ report.submit_verification
434
+ );
435
+ store.mark(message_id, "submitted_unverified", Some(&reason))?;
436
+ event_log.write(
437
+ "leader_receiver.fallback_pane_unverified",
438
+ serde_json::json!({
439
+ "message_id": message_id,
440
+ "pane_id": pane_id,
441
+ "reason": reason,
442
+ "primary_error": primary_error,
443
+ }),
444
+ )?;
445
+ Ok(DeliveryOutcome {
446
+ ok: false,
447
+ status: DeliveryStatus::Failed,
448
+ message_status: MessageStatusShadow("submitted_unverified".to_string()),
449
+ message_id: Some(message_id.to_string()),
450
+ verification: Some(reason),
451
+ stage: Some(DeliveryStage::Submit),
452
+ reason: None,
453
+ channel: Some("fallback_pane".to_string()),
454
+ })
455
+ }
419
456
  }
420
457
  Err(error) => {
421
458
  event_log.write(