@team-agent/installer 0.5.115 → 0.5.116

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.
@@ -4,21 +4,33 @@ use std::collections::BTreeMap;
4
4
  use std::path::PathBuf;
5
5
  use team_agent_contract::contract::{
6
6
  descriptor::{PromptCarrier, ProviderDescriptor, ToolNaming},
7
+ types::InstanceIdentity,
7
8
  hooks::{PlanHook, ProviderHooks},
8
9
  plan::{LaunchPlan, ResolvedLaunch},
9
10
  types::{ContractError, Support},
10
11
  };
11
12
  use team_agent_contract::kiro::{self, KiroAdapter, KIRO_DESCRIPTOR};
12
13
  use team_agent_contract::providers::cursor::{self, CursorAdapter, CURSOR_DESCRIPTOR};
13
- use team_agent_contract::runtime::{delivery::ControlDefinition, native_panel::NativePanelPolicy};
14
+ use team_agent_contract::providers::grok_build::{self, GrokAdapter, GROK_BUILD_DESCRIPTOR};
15
+ use team_agent_contract::runtime::{
16
+ delivery::{ControlDefinition, StartupDefinition},
17
+ native_panel::{NativePanelPolicy, SessionPanelPolicy},
18
+ };
19
+
20
+ const DESCRIPTORS: &[&ProviderDescriptor] = &[&KIRO_DESCRIPTOR, &CURSOR_DESCRIPTOR, &GROK_BUILD_DESCRIPTOR];
14
21
 
15
- const DESCRIPTORS: &[&ProviderDescriptor] = &[&KIRO_DESCRIPTOR, &CURSOR_DESCRIPTOR];
22
+ #[derive(Clone)]
23
+ pub enum NativeRuntime {
24
+ Cursor(cursor::native::RuntimeInstallation),
25
+ GrokBuild(grok_build::native::RuntimeInstallation),
26
+ }
16
27
 
17
28
  /// Leaf selection stays here; the framework, lifecycle and outbox remain shared.
18
29
  #[derive(Clone, Copy, Debug, PartialEq, Eq)]
19
30
  pub(super) enum NativeProvider {
20
31
  Kiro,
21
32
  Cursor,
33
+ GrokBuild,
22
34
  }
23
35
 
24
36
  impl NativeProvider {
@@ -26,19 +38,21 @@ impl NativeProvider {
26
38
  match self {
27
39
  Self::Kiro => &KIRO_DESCRIPTOR,
28
40
  Self::Cursor => &CURSOR_DESCRIPTOR,
41
+ Self::GrokBuild => &GROK_BUILD_DESCRIPTOR,
29
42
  }
30
43
  }
31
44
 
32
45
  pub fn action_budget(self) -> std::time::Duration {
33
46
  // Kiro's existing budget is unchanged; Cursor's measured cold-ready
34
47
  // path needs a larger finite bound, not an unbounded readiness wait.
35
- std::time::Duration::from_secs(match self { Self::Kiro => 30, Self::Cursor => 90 })
48
+ std::time::Duration::from_secs(match self { Self::Kiro => 30, Self::Cursor | Self::GrokBuild => 90 })
36
49
  }
37
50
 
38
51
  pub fn controls(self) -> &'static [ControlDefinition] {
39
52
  match self {
40
53
  Self::Kiro => kiro::interaction::CONTROLS,
41
54
  Self::Cursor => cursor::interaction::CONTROLS,
55
+ Self::GrokBuild => grok_build::interaction::CONTROLS,
42
56
  }
43
57
  }
44
58
 
@@ -48,10 +62,24 @@ impl NativeProvider {
48
62
  matches!(self.descriptor().prompt.carrier, Support::Supported(PromptCarrier::RuntimeBridge))
49
63
  }
50
64
 
51
- /// Cursor has no measured neutral TUI tools panel. Its protocol binding
52
- /// must come from the separately fenced native tool-roundtrip ledger.
65
+ /// These providers use the separately fenced native tool-roundtrip ledger,
66
+ /// never a guessed neutral tools panel or another provider's inspector.
53
67
  pub fn uses_tool_roundtrips(self) -> bool {
54
- matches!(self, Self::Cursor)
68
+ matches!(self, Self::Cursor | Self::GrokBuild)
69
+ }
70
+
71
+ pub fn startup(self) -> Option<&'static StartupDefinition> {
72
+ match self {
73
+ Self::GrokBuild => Some(&grok_build::interaction::STARTUP),
74
+ Self::Kiro | Self::Cursor => None,
75
+ }
76
+ }
77
+
78
+ pub fn session_panel(self) -> Option<&'static SessionPanelPolicy> {
79
+ match self {
80
+ Self::GrokBuild => Some(&grok_build::interaction::SESSION_PANEL),
81
+ Self::Kiro | Self::Cursor => None,
82
+ }
55
83
  }
56
84
 
57
85
  pub fn tool_panel(self) -> Result<&'static NativePanelPolicy, ContractError> {
@@ -61,9 +89,20 @@ impl NativeProvider {
61
89
  field: "Cursor native Team-tool inspector",
62
90
  reason: cursor::UNVERIFIED,
63
91
  }),
92
+ Self::GrokBuild => Err(ContractError::Unverified {
93
+ field: "Grok Build native Team-tool inspector",
94
+ reason: grok_build::UNVERIFIED,
95
+ }),
64
96
  }
65
97
  }
66
98
 
99
+ pub fn server_key_for(self, identity: &InstanceIdentity) -> Result<String, ContractError> {
100
+ if self == Self::GrokBuild {
101
+ return Ok(grok_build::server_key(identity));
102
+ }
103
+ Ok(self.server_key()?.into())
104
+ }
105
+
67
106
  pub fn server_key(self) -> Result<&'static str, ContractError> {
68
107
  match self.descriptor().tool_names.naming.require("tool naming")? {
69
108
  ToolNaming::StaticServer { key } => Ok(key),
@@ -84,6 +123,9 @@ impl NativeProvider {
84
123
  Self::Cursor => CursorAdapter::new(cursor::McpStdio {
85
124
  executable, arguments, environment,
86
125
  }).map(NativeAdapter::Cursor),
126
+ Self::GrokBuild => GrokAdapter::new(grok_build::McpStdio {
127
+ executable, arguments, environment,
128
+ }).map(NativeAdapter::GrokBuild),
87
129
  }
88
130
  }
89
131
  }
@@ -91,13 +133,14 @@ impl NativeProvider {
91
133
  pub(super) enum NativeAdapter {
92
134
  Kiro(KiroAdapter),
93
135
  Cursor(CursorAdapter),
136
+ GrokBuild(GrokAdapter),
94
137
  }
95
138
 
96
139
  impl NativeAdapter {
97
140
  pub fn with_launch_context(
98
141
  self,
99
142
  project: PathBuf,
100
- runtime: Option<cursor::native::RuntimeInstallation>,
143
+ runtime: Option<NativeRuntime>,
101
144
  ) -> Result<Self, ContractError> {
102
145
  match self {
103
146
  Self::Kiro(adapter) if runtime.is_none() => Ok(Self::Kiro(adapter)),
@@ -105,7 +148,16 @@ impl NativeAdapter {
105
148
  Self::Cursor(adapter) => {
106
149
  let adapter = adapter.with_project(project)?;
107
150
  Ok(Self::Cursor(match runtime {
108
- Some(runtime) => adapter.with_runtime(runtime),
151
+ Some(NativeRuntime::Cursor(runtime)) => adapter.with_runtime(runtime),
152
+ Some(_) => return Err(ContractError::Mismatch("native runtime provider")),
153
+ None => adapter,
154
+ }))
155
+ }
156
+ Self::GrokBuild(adapter) => {
157
+ let adapter = adapter.with_project(project)?;
158
+ Ok(Self::GrokBuild(match runtime {
159
+ Some(NativeRuntime::GrokBuild(runtime)) => adapter.with_runtime(runtime),
160
+ Some(_) => return Err(ContractError::Mismatch("native runtime provider")),
109
161
  None => adapter,
110
162
  }))
111
163
  }
@@ -116,6 +168,7 @@ impl NativeAdapter {
116
168
  match self {
117
169
  Self::Kiro(adapter) => adapter.hooks(),
118
170
  Self::Cursor(adapter) => adapter.hooks(),
171
+ Self::GrokBuild(adapter) => adapter.hooks(),
119
172
  }
120
173
  }
121
174
 
@@ -123,6 +176,7 @@ impl NativeAdapter {
123
176
  match self {
124
177
  Self::Kiro(adapter) => adapter.plan(request),
125
178
  Self::Cursor(adapter) => adapter.plan(request),
179
+ Self::GrokBuild(adapter) => adapter.plan(request),
126
180
  }
127
181
  }
128
182
  }
@@ -132,6 +186,7 @@ pub(super) fn provider(key: &str) -> Result<NativeProvider, ContractError> {
132
186
  match descriptor.identity.id {
133
187
  "kiro" => Ok(NativeProvider::Kiro),
134
188
  "cursor" => Ok(NativeProvider::Cursor),
189
+ "grok_build" => Ok(NativeProvider::GrokBuild),
135
190
  _ => Err(ContractError::Invalid("contract provider dispatcher")),
136
191
  }
137
192
  }
@@ -168,11 +223,15 @@ mod tests {
168
223
  fn canonical_cursor_does_not_reclassify_legacy_names() {
169
224
  assert_eq!(provider("cursor").unwrap(), NativeProvider::Cursor);
170
225
  assert_eq!(provider("kiro").unwrap(), NativeProvider::Kiro);
171
- for legacy in ["cursor_agent", "agent", "pi", "codex"] {
226
+ for legacy in ["cursor_agent", "agent", "pi", "codex", "grok"] {
172
227
  assert!(descriptor(legacy).is_none());
173
228
  assert!(!recognizes(legacy));
174
229
  assert!(provider(legacy).is_err());
175
230
  }
231
+ assert_eq!(provider("grok_build").unwrap(), NativeProvider::GrokBuild);
232
+ assert!(recognizes(" Grok_Build "));
233
+ assert!(descriptor("Grok_Build").is_none());
234
+ assert!(GROK_BUILD_DESCRIPTOR.identity.aliases.is_empty());
176
235
  assert!(recognizes(" Cursor "));
177
236
  assert!(descriptor("Cursor").is_none());
178
237
  for descriptor in descriptors() {
@@ -180,6 +239,24 @@ mod tests {
180
239
  }
181
240
  }
182
241
 
242
+ #[test]
243
+ fn grok_server_key_is_generation_bound_and_has_no_inspector_fallback() {
244
+ use team_agent_contract::contract::types::{Generation, InstanceId, ScopeId, SeatId};
245
+ let mut identity = InstanceIdentity {
246
+ scope: ScopeId::new("scope").unwrap(), seat: SeatId::new("worker").unwrap(),
247
+ instance: InstanceId::new("instance").unwrap(), generation: Generation(1),
248
+ };
249
+ let first = NativeProvider::GrokBuild.server_key_for(&identity).unwrap();
250
+ assert_eq!(first, grok_build::server_key(&identity));
251
+ identity.generation = Generation(2);
252
+ assert_ne!(first, NativeProvider::GrokBuild.server_key_for(&identity).unwrap());
253
+ assert!(NativeProvider::GrokBuild.server_key().is_err());
254
+ assert!(NativeProvider::GrokBuild.tool_panel().is_err());
255
+ assert!(NativeProvider::GrokBuild.startup().is_some());
256
+ assert!(NativeProvider::GrokBuild.session_panel().is_some());
257
+ assert!(NativeProvider::GrokBuild.uses_tool_roundtrips());
258
+ }
259
+
183
260
  #[test]
184
261
  fn cursor_has_no_kiro_control_or_tool_inspector_fallback() {
185
262
  assert!(NativeProvider::Cursor.controls().is_empty());
@@ -1,5 +1,6 @@
1
- //! Stable per-seat primary cwd. The project stays an additional directory;
2
- //! private MCP files never overwrite a shared project or another seat's config.
1
+ //! Stable per-seat primary cwd outside project Git ancestry. Provider-specific
2
+ //! project access remains explicit; private MCP files never overwrite a shared
3
+ //! project or another seat's config, and old cwd incarnations are not migrated.
3
4
  use std::io::{Read, Write};
4
5
  use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};
5
6
  use std::path::Path;
@@ -23,7 +24,7 @@ struct CwdRecord {
23
24
  pub(super) fn prepare(binding: &Binding, seat: &SeatId) -> Result<CwdIdentity, BackendError> {
24
25
  let root = root_for(&binding.workspace.path, &binding.scope);
25
26
  validate_directory(&root)?;
26
- let parent = root.join("workspaces");
27
+ let parent = binding.transport_root.join("workspaces");
27
28
  let path = parent.join(seat.as_str());
28
29
  let record_path = root.join(format!("cwd-{}.json", seat.as_str()));
29
30
  match std::fs::symlink_metadata(&record_path) {
@@ -43,12 +44,23 @@ pub(super) fn prepare(binding: &Binding, seat: &SeatId) -> Result<CwdIdentity, B
43
44
  return Err(BackendError::Metadata);
44
45
  }
45
46
  let record: CwdRecord = serde_json::from_slice(&bytes).map_err(|_| BackendError::Metadata)?;
46
- if record.version != 1 || record.seat != *seat || record.cwd.path != path {
47
+ // Old sessions stay pinned to their original project-local cwd.
48
+ // Only new allocations move outside Git; never relocate a resume.
49
+ let expected = match record.version {
50
+ 1 => root.join("workspaces").join(seat.as_str()),
51
+ 2 => {
52
+ validate_directory(&binding.transport_root)?;
53
+ reject_git_ancestors(&binding.transport_root)?;
54
+ path.clone()
55
+ }
56
+ _ => return Err(BackendError::Metadata),
57
+ };
58
+ if record.seat != *seat || record.cwd.path != expected {
47
59
  return Err(BackendError::Metadata);
48
60
  }
49
- validate_directory(&parent)?;
50
- validate_directory(&path)?;
51
- if resolve_cwd(&path)? != record.cwd {
61
+ validate_directory(expected.parent().ok_or(BackendError::Metadata)?)?;
62
+ validate_directory(&expected)?;
63
+ if resolve_cwd(&expected)? != record.cwd {
52
64
  return Err(BackendError::Metadata);
53
65
  }
54
66
  return Ok(record.cwd);
@@ -56,6 +68,8 @@ pub(super) fn prepare(binding: &Binding, seat: &SeatId) -> Result<CwdIdentity, B
56
68
  Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
57
69
  Err(error) => return Err(error.into()),
58
70
  }
71
+ validate_directory(&binding.transport_root)?;
72
+ reject_git_ancestors(&binding.transport_root)?;
59
73
  match std::fs::symlink_metadata(&parent) {
60
74
  Ok(_) => validate_directory(&parent)?,
61
75
  Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
@@ -68,7 +82,7 @@ pub(super) fn prepare(binding: &Binding, seat: &SeatId) -> Result<CwdIdentity, B
68
82
  std::fs::DirBuilder::new().mode(0o700).create(&path)?;
69
83
  validate_directory(&path)?;
70
84
  let cwd = resolve_cwd(&path)?;
71
- let record = CwdRecord { version: 1, seat: seat.clone(), cwd: cwd.clone() };
85
+ let record = CwdRecord { version: 2, seat: seat.clone(), cwd: cwd.clone() };
72
86
  let bytes = serde_json::to_vec(&record).map_err(|_| BackendError::Metadata)?;
73
87
  let mut file = std::fs::OpenOptions::new().write(true).create_new(true)
74
88
  .mode(0o600).custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC).open(record_path)?;
@@ -77,6 +91,19 @@ pub(super) fn prepare(binding: &Binding, seat: &SeatId) -> Result<CwdIdentity, B
77
91
  Ok(cwd)
78
92
  }
79
93
 
94
+ // Metadata only: handle both ordinary .git directories and worktree .git files;
95
+ // never read Git config, credentials, execute hooks, or assume /tmp is non-Git.
96
+ fn reject_git_ancestors(path: &Path) -> Result<(), BackendError> {
97
+ for ancestor in path.ancestors() {
98
+ match std::fs::symlink_metadata(ancestor.join(".git")) {
99
+ Ok(_) => return Err(BackendError::Metadata),
100
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
101
+ Err(error) => return Err(error.into()),
102
+ }
103
+ }
104
+ Ok(())
105
+ }
106
+
80
107
  fn validate_directory(path: &Path) -> Result<(), BackendError> {
81
108
  let metadata = std::fs::symlink_metadata(path)?;
82
109
  if !metadata.is_dir() || metadata.file_type().is_symlink() || path.canonicalize()? != path
@@ -108,8 +135,9 @@ mod tests {
108
135
  version: 1, workspace: resolve_cwd(&path).unwrap(), team: "test".into(),
109
136
  scope: ScopeId::new("cwd-test").unwrap(), endpoint: "framework:cwd-test".into(),
110
137
  candidate: "/controlled/candidate".into(), candidate_sha256: Digest([1; 32]),
111
- tmux: "/controlled/tmux".into(), transport_root: path.join("transport"),
138
+ tmux: "/controlled/tmux".into(), transport_root: path.with_extension("transport"),
112
139
  };
140
+ std::fs::DirBuilder::new().mode(0o700).create(&binding.transport_root).unwrap();
113
141
  let root = root_for(&path, &binding.scope);
114
142
  std::fs::create_dir_all(root.parent().unwrap()).unwrap();
115
143
  std::fs::DirBuilder::new().mode(0o700).create(&root).unwrap();
@@ -117,7 +145,10 @@ mod tests {
117
145
  }
118
146
  }
119
147
  impl Drop for Fixture {
120
- fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.path); }
148
+ fn drop(&mut self) {
149
+ let _ = std::fs::remove_dir_all(&self.path);
150
+ let _ = std::fs::remove_dir_all(&self.binding.transport_root);
151
+ }
121
152
  }
122
153
 
123
154
  #[test]
@@ -151,10 +182,47 @@ mod tests {
151
182
  fn unrecorded_existing_directory_is_not_claimed() {
152
183
  let f = Fixture::new();
153
184
  let root = root_for(&f.path, &f.binding.scope);
154
- let parent = root.join("workspaces");
185
+ let parent = f.binding.transport_root.join("workspaces");
155
186
  std::fs::DirBuilder::new().mode(0o700).create(&parent).unwrap();
156
187
  std::fs::DirBuilder::new().mode(0o700).create(parent.join("worker")).unwrap();
157
188
  assert!(prepare(&f.binding, &SeatId::new("worker").unwrap()).is_err());
158
189
  assert!(!root.join("cwd-worker.json").exists());
159
190
  }
191
+
192
+ #[test]
193
+ fn new_primary_is_outside_project_git_ancestors() {
194
+ let f = Fixture::new();
195
+ std::fs::create_dir(f.path.join(".git")).unwrap();
196
+ let cwd = prepare(&f.binding, &SeatId::new("worker").unwrap()).unwrap();
197
+ assert!(cwd.path.starts_with(&f.binding.transport_root));
198
+ assert!(!cwd.path.starts_with(&f.path));
199
+ reject_git_ancestors(&cwd.path).unwrap();
200
+ }
201
+
202
+ #[test]
203
+ fn existing_version_one_cwd_is_not_migrated() {
204
+ let f = Fixture::new();
205
+ let seat = SeatId::new("worker").unwrap();
206
+ let root = root_for(&f.path, &f.binding.scope);
207
+ let parent = root.join("workspaces");
208
+ let path = parent.join(seat.as_str());
209
+ std::fs::DirBuilder::new().mode(0o700).create(&parent).unwrap();
210
+ std::fs::DirBuilder::new().mode(0o700).create(&path).unwrap();
211
+ let cwd = resolve_cwd(&path).unwrap();
212
+ let record = CwdRecord { version: 1, seat: seat.clone(), cwd: cwd.clone() };
213
+ std::fs::write(root.join("cwd-worker.json"), serde_json::to_vec(&record).unwrap()).unwrap();
214
+ assert_eq!(prepare(&f.binding, &seat).unwrap(), cwd);
215
+ assert!(!f.binding.transport_root.join("workspaces").exists());
216
+ }
217
+
218
+ #[test]
219
+ fn git_directory_or_worktree_file_on_transport_ancestor_is_refused() {
220
+ let f = Fixture::new();
221
+ let marker = f.binding.transport_root.join(".git");
222
+ std::fs::write(&marker, b"gitdir: controlled-marker").unwrap();
223
+ assert!(prepare(&f.binding, &SeatId::new("worker").unwrap()).is_err());
224
+ std::fs::remove_file(&marker).unwrap();
225
+ std::fs::create_dir(&marker).unwrap();
226
+ assert!(prepare(&f.binding, &SeatId::new("worker").unwrap()).is_err());
227
+ }
160
228
  }
@@ -307,6 +307,7 @@ pub(crate) fn start_agent_at_paths(
307
307
  &window,
308
308
  noop_pane.as_ref(),
309
309
  )?;
310
+ state["agents"][agent_id.as_str()]["explicit_stop"] = serde_json::Value::Bool(false);
310
311
  let team_key = restart_projection_team_key(&state, team);
311
312
  save_restart_projected_state(workspace, &mut state, &team_key, &[agent_id.as_str()])?;
312
313
  if let Ok(spec) = load_team_spec(spec_workspace) {
@@ -1258,6 +1259,7 @@ pub(super) fn stop_agent_at_paths(
1258
1259
  let _ = write_stop_drain_event(workspace, agent_id, &target_str, &drain_evidence);
1259
1260
  }
1260
1261
  mark_agent_stopped(&mut state, agent_id, agent, &window)?;
1262
+ state["agents"][agent_id.as_str()]["explicit_stop"] = serde_json::Value::Bool(true);
1261
1263
  let team_key = restart_projection_team_key(&state, team);
1262
1264
  // golden operations.py:95: save_team_scoped_state (team projection) — NOT a raw save, so a
1263
1265
  // multi-team workspace keeps the other teams' persisted runtime state instead of being clobbered.
@@ -1380,6 +1382,7 @@ pub(super) fn mark_agent_started(
1380
1382
  }
1381
1383
  }
1382
1384
  agent.insert("status".to_string(), serde_json::json!("running"));
1385
+ agent.insert("explicit_stop".to_string(), serde_json::Value::Bool(false));
1383
1386
  agent.insert("agent_id".to_string(), serde_json::json!(agent_id.as_str()));
1384
1387
  agent.insert("window".to_string(), serde_json::json!(window));
1385
1388
  agent.insert(
@@ -1741,6 +1744,7 @@ fn reset_agent_at_paths(
1741
1744
  .and_then(serde_json::Value::as_object_mut)
1742
1745
  {
1743
1746
  obj.insert("spawn_epoch".to_string(), serde_json::json!(new_epoch));
1747
+ obj.insert("explicit_stop".to_string(), serde_json::Value::Bool(false));
1744
1748
  }
1745
1749
  let team_key = restart_projection_team_key(&state, team);
1746
1750
  sync_restart_team_projections(&mut state, &team_key);
@@ -371,7 +371,15 @@ fn restart_with_selected_team_and_transport(
371
371
  team: Some(selected.team_key.as_str()),
372
372
  agent_id: None,
373
373
  })?;
374
- let mut state = selected.state;
374
+ // Selection happened before the lifecycle lock. A stop-agent that won the
375
+ // lock must not be undone by the old running snapshot used by restart.
376
+ let mut state = crate::state::selector::resolve_active_team(
377
+ &selected.run_workspace,
378
+ Some(selected.team_key.as_str()),
379
+ crate::state::selector::SelectorMode::RequireSpec,
380
+ )
381
+ .map_err(|error| LifecycleError::TeamSelect(error.to_string()))?
382
+ .state;
375
383
  crate::lifecycle::launch::ensure_owner_allowed_for_state(&state, None)?;
376
384
  let topology_issue_ids = crate::topology::restart_dirty_topology_issue_ids(&state);
377
385
  if !topology_issue_ids.is_empty() {
@@ -1092,50 +1100,23 @@ fn restart_with_selected_team_and_transport(
1092
1100
  )?;
1093
1101
  phase_timer.emit(&selected.run_workspace, "restart.phase", LifecyclePhase::SaveState);
1094
1102
  if fatal_resume_failure {
1095
- let attach_commands = Vec::new();
1096
- let next_actions = restart_failure_next_actions(&failed_agents);
1097
- write_restart_completed_event(
1098
- &selected.run_workspace,
1099
- &successful_agents,
1100
- &failed_agents,
1101
- "fail",
1102
- None,
1103
- )?;
1104
- return Ok(RestartReport::Failed {
1105
- session_name,
1106
- failed_agents,
1107
- next_actions,
1108
- attach_commands,
1109
- });
1103
+ // The legacy cohort did not commit; its snapshot was restored above.
1104
+ // Do not promote tentative survivors, but still attempt the separately
1105
+ // admitted contract cohort before reporting the combined outcome.
1106
+ successful_agents.clear();
1110
1107
  }
1111
- if successful_agents.is_empty() && !failed_agents.is_empty() {
1112
- let attach_commands = Vec::new();
1113
- let next_actions = restart_failure_next_actions(&failed_agents);
1114
- write_restart_completed_event(
1115
- &selected.run_workspace,
1116
- &successful_agents,
1117
- &failed_agents,
1118
- "fail",
1119
- None,
1120
- )?;
1121
- return Ok(RestartReport::Failed {
1122
- session_name,
1123
- failed_agents,
1124
- next_actions,
1125
- attach_commands,
1126
- });
1127
- }
1128
- // Stamp the physical session birth after durable state. Replacing workers
1129
- // in a retained session does not create a new session generation.
1130
- if let Some(generation) = crate::state::projection::session_generation(&state) {
1131
- transport
1132
- .set_session_owner_with_generation(
1133
- &session_name,
1134
- &selected.run_workspace,
1135
- &selected.team_key,
1136
- generation,
1137
- )
1138
- .map_err(|error| LifecycleError::Transport(error.to_string()))?;
1108
+ // A native-only active cohort has no legacy replacement session to stamp.
1109
+ if !successful_agents.is_empty() {
1110
+ if let Some(generation) = crate::state::projection::session_generation(&state) {
1111
+ transport
1112
+ .set_session_owner_with_generation(
1113
+ &session_name,
1114
+ &selected.run_workspace,
1115
+ &selected.team_key,
1116
+ generation,
1117
+ )
1118
+ .map_err(|error| LifecycleError::Transport(error.to_string()))?;
1119
+ }
1139
1120
  }
1140
1121
  // RM-039-SESS-001 step 2 (architect verdict 2026-06-22): post-respawn
1141
1122
  // resume backing validation.
@@ -1344,8 +1325,16 @@ fn restart_with_selected_team_and_transport(
1344
1325
  )?;
1345
1326
  }
1346
1327
  }
1328
+ // Only legacy survivors enter legacy readiness; contract results are
1329
+ // aggregated afterwards and must never enter backing scans/Codex fallback.
1330
+ let legacy_successful_agents = successful_agents.clone();
1331
+ #[cfg(unix)]
1332
+ append_native_restart_results(&mut successful_agents, &mut failed_agents, || {
1333
+ crate::contract_runtime::commands::restart_prepared(
1334
+ &selected.run_workspace, &selected.team_key, &mut state, native_restart,
1335
+ )
1336
+ });
1347
1337
  if successful_agents.is_empty() && !failed_agents.is_empty() {
1348
- // Postflight demoted every survivor — emit Failed before coordinator start.
1349
1338
  let attach_commands = Vec::new();
1350
1339
  let next_actions = restart_failure_next_actions(&failed_agents);
1351
1340
  write_restart_completed_event(
@@ -1362,20 +1351,6 @@ fn restart_with_selected_team_and_transport(
1362
1351
  attach_commands,
1363
1352
  });
1364
1353
  }
1365
- let legacy_successful_agents = successful_agents.clone();
1366
- #[cfg(unix)]
1367
- for result in crate::contract_runtime::commands::restart_prepared(
1368
- &selected.run_workspace, &selected.team_key, &mut state, native_restart,
1369
- ) {
1370
- let decision = RestartedAgent {
1371
- agent_id: AgentId::new(result.agent_id), restart_mode: StartMode::Resumed,
1372
- decision: ResumeDecision::Resume, session_id: result.session_id.map(SessionId::new),
1373
- };
1374
- match result.outcome {
1375
- Ok(()) => successful_agents.push(decision),
1376
- Err(error) => failed_agents.push(restart_failed_agent(&decision, "native_resume", error)),
1377
- }
1378
- }
1379
1354
  drop(lifecycle_lock);
1380
1355
  let coordinator =
1381
1356
  start_coordinator_for_workspace(&selected.run_workspace, Some(&selected.team_key))?;
@@ -1386,15 +1361,17 @@ fn restart_with_selected_team_and_transport(
1386
1361
  );
1387
1362
  let coordinator_started = coordinator.ok;
1388
1363
  phase_timer.emit(&selected.run_workspace, "restart.phase", LifecyclePhase::ReadinessWait);
1389
- wait_restart_readiness_or_timeout(
1390
- &selected.run_workspace,
1391
- &state,
1392
- &session_name,
1393
- &legacy_successful_agents,
1394
- transport,
1395
- restart_readiness_deadline(readiness_deadline_ms),
1396
- restart_readiness_poll_interval(),
1397
- )?;
1364
+ if !legacy_successful_agents.is_empty() {
1365
+ wait_restart_readiness_or_timeout(
1366
+ &selected.run_workspace,
1367
+ &state,
1368
+ &session_name,
1369
+ &legacy_successful_agents,
1370
+ transport,
1371
+ restart_readiness_deadline(readiness_deadline_ms),
1372
+ restart_readiness_poll_interval(),
1373
+ )?;
1374
+ }
1398
1375
  let attach_commands =
1399
1376
  crate::tmux_backend::attach_command_for_transport_session(transport, &session_name)
1400
1377
  .or_else(|| {
@@ -1491,10 +1468,9 @@ fn repair_resume_sessions_from_event_log(
1491
1468
  .and_then(|agents| agents.get(&agent_id))
1492
1469
  .cloned()
1493
1470
  .unwrap_or(serde_json::Value::Null);
1494
- if previous
1495
- .get("session_id")
1496
- .and_then(serde_json::Value::as_str)
1497
- .is_some_and(|session| !session.is_empty())
1471
+ if crate::state::projection::agent_stopped_for_restart(&previous)
1472
+ || previous.get("session_id").and_then(serde_json::Value::as_str)
1473
+ .is_some_and(|session| !session.is_empty())
1498
1474
  {
1499
1475
  continue;
1500
1476
  }
@@ -1724,12 +1700,65 @@ fn parse_duration_value_seconds_ms(value: &str) -> Option<u64> {
1724
1700
  }
1725
1701
  }
1726
1702
 
1703
+ /// Run the admitted native cohort before classifying any legacy failure. Keeping
1704
+ /// the execution inside this seam makes the mixed-failure ordering testable.
1705
+ #[cfg(unix)]
1706
+ fn append_native_restart_results(
1707
+ successful: &mut Vec<RestartedAgent>,
1708
+ failed: &mut Vec<RestartFailedAgent>,
1709
+ restart: impl FnOnce() -> Vec<crate::contract_runtime::commands::NativeRestartResult>,
1710
+ ) {
1711
+ for result in restart() {
1712
+ let decision = RestartedAgent {
1713
+ agent_id: AgentId::new(result.agent_id), restart_mode: StartMode::Resumed,
1714
+ decision: ResumeDecision::Resume, session_id: result.session_id.map(SessionId::new),
1715
+ };
1716
+ match result.outcome {
1717
+ Ok(()) => successful.push(decision),
1718
+ Err(error) => failed.push(restart_failed_agent(&decision, "native_resume", error)),
1719
+ }
1720
+ }
1721
+ }
1722
+
1727
1723
  fn restart_readiness_deadline(requested_ms: Option<u64>) -> std::time::Duration {
1728
1724
  requested_ms
1729
1725
  .map(std::time::Duration::from_millis)
1730
1726
  .unwrap_or_else(|| env_duration_ms(&["TEAM_AGENT_RESTART_READINESS_DEADLINE_MS"], 30_000))
1731
1727
  }
1732
1728
 
1729
+ #[cfg(all(test, unix))]
1730
+ mod mixed_native_restart_tests {
1731
+ use super::*;
1732
+ use crate::contract_runtime::commands::NativeRestartResult;
1733
+
1734
+ #[test]
1735
+ fn failed_legacy_cohort_does_not_skip_native_resume_or_hide_its_result() {
1736
+ for legacy_failed in [false, true] {
1737
+ for native_failed in [false, true] {
1738
+ let mut successful = Vec::new();
1739
+ let legacy = RestartedAgent { agent_id: AgentId::new("legacy"),
1740
+ restart_mode: StartMode::Fresh, decision: ResumeDecision::FreshStart, session_id: None };
1741
+ let mut failed = if legacy_failed { vec![restart_failed_agent(&legacy, "spawn", "failed".into())] } else { vec![] };
1742
+ let mut attempts = 0;
1743
+ append_native_restart_results(&mut successful, &mut failed, || {
1744
+ attempts += 1;
1745
+ vec![NativeRestartResult { agent_id: "native".into(), session_id: Some("same-session".into()),
1746
+ outcome: if native_failed { Err("native failed".into()) } else { Ok(()) } }]
1747
+ });
1748
+ assert_eq!(attempts, 1, "no legacy survivor must not short-circuit native execution");
1749
+ assert_eq!(successful.len(), usize::from(!native_failed));
1750
+ assert_eq!(failed.len(), usize::from(legacy_failed) + usize::from(native_failed));
1751
+ if !native_failed {
1752
+ assert_eq!(successful[0].agent_id.as_str(), "native");
1753
+ assert_eq!(successful[0].session_id.as_ref().unwrap().as_str(), "same-session");
1754
+ } else {
1755
+ assert_eq!(failed.last().unwrap().phase, "native_resume");
1756
+ }
1757
+ }
1758
+ }
1759
+ }
1760
+ }
1761
+
1733
1762
  fn restart_readiness_poll_interval() -> std::time::Duration {
1734
1763
  env_duration_ms(&["TEAM_AGENT_RESTART_READINESS_POLL_MS"], 200)
1735
1764
  }