@team-agent/installer 0.3.27 → 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(
@@ -4787,6 +4762,13 @@ fn spec_session_name(spec: &Value) -> SessionName {
4787
4762
  SessionName::new(format!("team-{team_name}"))
4788
4763
  }
4789
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
+
4790
4772
  fn spec_agents(spec: &Value) -> Vec<AgentId> {
4791
4773
  spec_agent_values(spec)
4792
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));
@@ -595,15 +595,15 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
595
595
  /// report `SubmitConsumptionUnverified`, NOT
596
596
  /// `EnterSentWithoutPlaceholderCheck`. Otherwise delivery emits delivered
597
597
  /// + message.delivered (假阳) even though the worker never consumed it.
598
- /// 0.3.27 amendment: when token IS visible in the pane (Phase 1 confirmed)
599
- /// but NOT consumed from bottom 5 after all retries, the grace fallback
600
- /// treats "paste landed but composer didn't clear" as
601
- /// EnterSentWithoutPlaceholderCheck. This handles bare-shell panes (MCP sim)
602
- /// and busy-agent TUIs (E55) that keep the token visible after submit.
603
- /// SubmitConsumptionUnverified is reserved for the case where the token was
604
- /// NEVER visible (paste truly failed / dropped).
598
+ /// 0.3.28-final E55 truth source: grace fallback DELETED.
599
+ /// `consumed=Some(false)` (token still in bottom 5 after all retries)
600
+ /// MUST report SubmitConsumptionUnverified unconditionally. Pre-final
601
+ /// returned EnterSentWithoutPlaceholderCheck when Phase-1 saw the token
602
+ /// at all (token_visible_for_report=Some(true)) but Phase-1 visibility
603
+ /// only proves the paste LANDED, not that the provider CONSUMED it.
604
+ /// That masked E55 busy-agent false positives as delivered=true.
605
605
  #[test]
606
- fn e46_inject_text_with_token_visible_but_unconsumed_uses_grace_fallback() {
606
+ fn e46_inject_text_with_token_visible_but_unconsumed_reports_unverified() {
607
607
  let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red1]";
608
608
  let (be, _rec) = backend_with(MockResp::Out(ok(token_text)), vec![]);
609
609
  let report = be
@@ -614,15 +614,16 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
614
614
  true,
615
615
  )
616
616
  .expect("inject runs");
617
- // 0.3.27 grace fallback: token visible + not consumed from bottom
618
- // EnterSentWithoutPlaceholderCheck (paste landed, consumption unclear).
617
+ // 0.3.28-final: no grace fallback. Token visible + not consumed →
618
+ // SubmitConsumptionUnverified (delivery treats as not delivered).
619
619
  assert_eq!(
620
620
  report.submit_verification,
621
- SubmitVerification::EnterSentWithoutPlaceholderCheck,
622
- "0.3.27: when token is visible but not consumed from bottom 5 \
623
- after all retries, grace fallback degrades to \
624
- EnterSentWithoutPlaceholderCheck (paste landed, bare-shell/busy \
625
- pane parity). Got {:?}",
621
+ SubmitVerification::SubmitConsumptionUnverified,
622
+ "0.3.28-final: when token still in bottom 5 after all retries, \
623
+ MUST be SubmitConsumptionUnverified grace fallback masking \
624
+ this as EnterSentWithoutPlaceholderCheck caused E55 false \
625
+ positives (paste landed but busy agent never consumed). \
626
+ Got {:?}",
626
627
  report.submit_verification
627
628
  );
628
629
  assert_eq!(report.turn_verification, TurnVerification::NotYetObserved);
@@ -1441,25 +1441,15 @@ impl Transport for TmuxBackend {
1441
1441
  }
1442
1442
  }
1443
1443
 
1444
- // Escape is RETRY-ONLY (attempt > 0). Researcher discovery:
1445
- // Escape on Claude TUI with [Pasted content] visible may
1446
- // CLEAR the composer content instead of exiting paste mode.
1447
- // Python never sends Escape and never has this issue. So:
1448
- // attempt 0 direct Enter (Python parity); attempt 1+
1449
- // Escape+Enter as remediation for stuck bracketed-paste.
1450
- if attempt > 0 {
1451
- if let Some(ref esc) = escape_argv {
1452
- let _ = self.run_inject_stage(esc, InjectStage::Submit);
1453
- for _ in 0..10 {
1454
- match self.capture(target, CaptureRange::Tail(30)) {
1455
- Ok(cap) if !pasted_prompt_in_bottom(&cap.text, 3) => break,
1456
- Err(_) => break,
1457
- _ => {}
1458
- }
1459
- std::thread::sleep(Duration::from_millis(50));
1460
- }
1461
- }
1462
- }
1444
+ // 0.3.28-final (E55 false-positive truth source):
1445
+ // Escape retry is DELETED. The researcher established
1446
+ // Escape on Claude TUI with [Pasted content] visible
1447
+ // may CLEAR the composer content rather than exit paste
1448
+ // mode sending Escape+Enter on retry would submit an
1449
+ // empty message and hide the genuine consumption failure
1450
+ // under a fake-success path. Python parity: ONLY ever
1451
+ // send Enter, never Escape.
1452
+ let _ = escape_argv;
1463
1453
 
1464
1454
  // Enter — send-keys failure is degraded (tmux may not have
1465
1455
  // the pane in sim/test env). Break to consumed=None path
@@ -1512,23 +1502,18 @@ impl Transport for TmuxBackend {
1512
1502
 
1513
1503
  let submit_verification = match consumed {
1514
1504
  Some(true) => SubmitVerification::EnterSentWithoutPlaceholderCheck,
1515
- Some(false) => {
1516
- // Consumption not observed (token still in bottom 5 after
1517
- // all attempts). As a grace fallback, check if the token
1518
- // is visible ANYWHERE in the pane capture. If yes, the
1519
- // paste DID land — the pane just doesn't clear the token
1520
- // from the bottom region (bare shell panes, MCP sim env,
1521
- // busy agent TUI). Treat as "submitted, verification
1522
- // degraded" EnterSentWithoutPlaceholderCheck (generous;
1523
- // matches pre-0.3.27 behaviour for these edge cases).
1524
- // If the token is NOT visible at all, the paste truly
1525
- // failed SubmitConsumptionUnverified.
1526
- if matches!(token_visible_for_report, Some(true)) {
1527
- SubmitVerification::EnterSentWithoutPlaceholderCheck
1528
- } else {
1529
- SubmitVerification::SubmitConsumptionUnverified
1530
- }
1531
- }
1505
+ // 0.3.28-final (E55 truth source): consumed=Some(false)
1506
+ // means we ran ALL retries and the token never left
1507
+ // bottom 5 lines. That is a genuine consumption failure;
1508
+ // it MUST NOT be masked. Pre-final used a grace fallback
1509
+ // that returned EnterSentWithoutPlaceholderCheck whenever
1510
+ // token_visible_for_report=Some(true) but Phase-1 token
1511
+ // visibility only proves the paste landed, NOT that the
1512
+ // provider consumed it. The fallback caused
1513
+ // delivered=true under E55 (busy-agent paste-landed-not-
1514
+ // consumed false positive). Always return Unverified now;
1515
+ // delivery treats it as not delivered.
1516
+ Some(false) => SubmitVerification::SubmitConsumptionUnverified,
1532
1517
  None => submit_verification_for_key(submit),
1533
1518
  };
1534
1519
  let total_elapsed_ms = inject_start.elapsed().as_millis() as u64;