@team-agent/installer 0.5.114 → 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.
Files changed (33) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +2 -2
  4. package/crates/team-agent/src/cli/mod.rs +155 -19
  5. package/crates/team-agent/src/cli/models.rs +52 -1
  6. package/crates/team-agent/src/cli/shutdown_socket.rs +288 -0
  7. package/crates/team-agent/src/cli/spec.rs +1 -1
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +11 -4
  9. package/crates/team-agent/src/contract_runtime/backend.rs +490 -97
  10. package/crates/team-agent/src/contract_runtime/commands.rs +326 -0
  11. package/crates/team-agent/src/contract_runtime/config.rs +39 -8
  12. package/crates/team-agent/src/contract_runtime/config_tests.rs +56 -0
  13. package/crates/team-agent/src/contract_runtime/discovery.rs +218 -40
  14. package/crates/team-agent/src/contract_runtime/framework.rs +38 -5
  15. package/crates/team-agent/src/contract_runtime/mcp.rs +3 -0
  16. package/crates/team-agent/src/contract_runtime/mod.rs +2 -0
  17. package/crates/team-agent/src/contract_runtime/pump.rs +97 -33
  18. package/crates/team-agent/src/contract_runtime/registry.rs +248 -4
  19. package/crates/team-agent/src/contract_runtime/workspace.rs +228 -0
  20. package/crates/team-agent/src/leader/start.rs +93 -5
  21. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +99 -1
  22. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +3 -5
  23. package/crates/team-agent/src/lifecycle/launch.rs +6 -0
  24. package/crates/team-agent/src/lifecycle/restart/agent.rs +4 -0
  25. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +109 -58
  26. package/crates/team-agent/src/lifecycle/restart/selection.rs +63 -7
  27. package/crates/team-agent/src/lifecycle/role_config.rs +17 -1
  28. package/crates/team-agent/src/provider/session/capture.rs +24 -1
  29. package/crates/team-agent/src/state/persist.rs +72 -2
  30. package/crates/team-agent/src/state/projection.rs +29 -0
  31. package/crates/team-agent/src/state/repository/tests.rs +70 -0
  32. package/crates/team-agent/src/state/repository.rs +5 -1
  33. package/package.json +4 -4
@@ -0,0 +1,326 @@
1
+ //! Public lifecycle dispatch for contract seats. A private native pane is never
2
+ //! operated through the legacy workspace socket or provider-enum fallback.
3
+ use std::path::Path;
4
+
5
+ use serde_json::{json, Value};
6
+ use team_agent_contract::contract::types::SeatId;
7
+ use team_agent_contract::orchestration::store::{Outcome, SeatStatus};
8
+ use team_agent_contract::host::process::{sample_process, ProcessState};
9
+
10
+ use crate::cli::CliError;
11
+ use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
12
+ use crate::model::ids::AgentId;
13
+ use crate::state::repository::{StateRepository, StateWriteIntent};
14
+ use crate::state::selector::{resolve_active_team_readonly, SelectedTeam, SelectorMode};
15
+
16
+ use super::{backend::{self, Backend, BackendError}, config, framework};
17
+
18
+ fn error(error: impl std::fmt::Display) -> CliError {
19
+ CliError::Runtime(error.to_string())
20
+ }
21
+
22
+ /// None delegates only a non-contract selection. Once selected, any identity,
23
+ /// ownership, state or physical error stays on this route and cannot fall back.
24
+ fn with_agent(
25
+ workspace: &Path,
26
+ team: Option<&str>,
27
+ agent: &str,
28
+ operation: &'static str,
29
+ apply: impl FnOnce(&SelectedTeam, &SeatId) -> Result<Value, CliError>,
30
+ ) -> Result<Option<Value>, CliError> {
31
+ let Ok(selected) = resolve_active_team_readonly(workspace, team, SelectorMode::RuntimeOnly) else {
32
+ return Ok(None);
33
+ };
34
+ if !selected.state.get("agents").and_then(|agents| agents.get(agent)).is_some_and(framework::is_contract) {
35
+ return Ok(None);
36
+ }
37
+ let agent_id = AgentId::new(agent);
38
+ let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
39
+ workspace: &selected.run_workspace,
40
+ operation,
41
+ team: Some(&selected.team_key),
42
+ agent_id: Some(&agent_id),
43
+ }).map_err(error)?;
44
+ let latest = resolve_active_team_readonly(
45
+ &selected.run_workspace, Some(&selected.team_key), SelectorMode::RuntimeOnly,
46
+ ).map_err(error)?;
47
+ crate::lifecycle::launch::ensure_owner_allowed_for_state(&latest.state, Some(&agent_id)).map_err(error)?;
48
+ if !latest.state.get("agents").and_then(|agents| agents.get(agent)).is_some_and(framework::is_contract) {
49
+ return Err(CliError::Runtime("contract seat changed while acquiring lifecycle ownership".into()));
50
+ }
51
+ apply(&latest, &SeatId::new(agent).map_err(error)?).map(Some)
52
+ }
53
+
54
+ fn publish(selected: &SelectedTeam, id: &SeatId, clear_stop: bool) -> Result<Value, CliError> {
55
+ publish_at(&selected.run_workspace, &selected.team_key, id, clear_stop)
56
+ }
57
+ fn publish_at(workspace: &Path, team: &str, id: &SeatId, clear_stop: bool) -> Result<Value, CliError> {
58
+ let projected = framework::project_seat(workspace, team, id.as_str()).map_err(error)?;
59
+ StateRepository::new(workspace).commit(
60
+ StateWriteIntent::StartAgent { team_key: team, agent_id: id.as_str() },
61
+ |state| {
62
+ if let Some(row) = state.get_mut("agents").and_then(|agents| agents.get_mut(id.as_str())).and_then(Value::as_object_mut) {
63
+ for (key, value) in projected.as_object().expect("native projection object") {
64
+ row.insert(key.clone(), value.clone());
65
+ }
66
+ row.insert("paused".into(), Value::Bool(false));
67
+ if clear_stop {
68
+ row.insert("explicit_stop".into(), Value::Bool(false));
69
+ }
70
+ }
71
+ },
72
+ ).map_err(error)?;
73
+ Ok(projected)
74
+ }
75
+
76
+ fn check_record(receipt: &team_agent_contract::orchestration::store::OperationRecord) -> Result<(), CliError> {
77
+ if receipt.outcome != Outcome::Committed {
78
+ return Err(error(BackendError::Shutdown {
79
+ operation: receipt.id.clone(), phase: receipt.phase, outcome: receipt.outcome, failure: receipt.failure.clone(),
80
+ }));
81
+ }
82
+ Ok(())
83
+ }
84
+
85
+ // A partial native operation may not have a session for projection yet. Its
86
+ // original receipt must win over any secondary projection/metadata error.
87
+ fn checked_projection(
88
+ receipt: &team_agent_contract::orchestration::store::OperationRecord,
89
+ project: impl FnOnce() -> Result<Value, CliError>,
90
+ ) -> Result<Value, CliError> {
91
+ check_record(receipt)?;
92
+ project()
93
+ }
94
+
95
+ pub fn start_agent(
96
+ workspace: &Path,
97
+ team: Option<&str>,
98
+ agent: &str,
99
+ patch: &crate::lifecycle::role_config::RoleConfigPatch,
100
+ ) -> Result<Option<Value>, CliError> {
101
+ with_agent(workspace, team, agent, "start-agent", |selected, id| {
102
+ let args = crate::cli::QuickStartArgs {
103
+ workspace: selected.run_workspace.clone(), agents_dir: selected.team_dir.clone(),
104
+ name: None, team_id: Some(selected.team_key.clone()), yes: false, json: false,
105
+ detail: false, backend: Some("tmux".into()),
106
+ };
107
+ // Contract configuration, not a stale legacy dynamic_role_file, is the
108
+ // authoritative source. Never patch an unrelated external role file.
109
+ let original = config::read_team(&args).map_err(error)?.ok_or(BackendError::Metadata).map_err(error)?;
110
+ let source = original.roles.iter().find(|role| &role.id == id)
111
+ .ok_or(BackendError::Metadata).map_err(error)?.source.clone();
112
+ let previous = std::fs::read_to_string(&source).map_err(error)?;
113
+ let next = crate::lifecycle::role_config::patched_role(&previous, &AgentId::new(agent), patch, false).map_err(error)?;
114
+ let config = config::read_team_with_role_override(&args, &source, &next).map_err(error)?.ok_or(BackendError::Metadata).map_err(error)?;
115
+ let role = config.roles.iter().find(|role| &role.id == id).ok_or(BackendError::Metadata).map_err(error)?;
116
+ let row = selected.state.get("agents").and_then(|agents| agents.get(agent)).ok_or(BackendError::Metadata).map_err(error)?;
117
+ if row.get("provider").and_then(Value::as_str) != Some(role.provider.as_str()) {
118
+ return Err(error("provider change requires a new seat identity"));
119
+ }
120
+ if role.paused {
121
+ return Ok(json!({"ok":false,"agent_id":agent,"status":"paused","reason":"agent_paused"}));
122
+ }
123
+ let unstarted = framework::is_paused_unstarted(row);
124
+ let prepared = if unstarted {
125
+ // Full pure team admission precedes native allocation or role writes.
126
+ backend::preflight(&config).map_err(error)?;
127
+ None
128
+ } else {
129
+ let backend = Backend::open_readonly(&selected.run_workspace, &selected.team_key).map_err(error)?;
130
+ let seat = backend.store.seat(id).map_err(error)?.ok_or(BackendError::Metadata).map_err(error)?;
131
+ if seat.status == SeatStatus::Unknown {
132
+ return Err(error("native seat state is unknown; explicit reconciliation required"));
133
+ }
134
+ if seat.status != SeatStatus::Stopped && seat.physical.as_ref().is_some_and(|target| sample_process(&target.process) == ProcessState::Alive) {
135
+ return Err(error("native seat is already running; use stop-agent first"));
136
+ }
137
+ Some(backend::prepare_resume(&selected.run_workspace, &selected.team_key, id, Some(role)).map_err(error)?)
138
+ };
139
+ crate::lifecycle::role_config::commit_contract_role(&source, &previous, &next).map_err(error)?;
140
+ let receipt = match prepared {
141
+ Some(prepared) => backend::resume(prepared).map_err(error)?,
142
+ None => backend::start(&selected.run_workspace, &selected.team_key, role, &config.members).map_err(error)?.1,
143
+ };
144
+ // An uncertain operation never rolls the authoritative native store back
145
+ // to the old generation. The receipt remains available for recovery.
146
+ let projection = checked_projection(&receipt, || publish(selected, id, true))?;
147
+ let coordinator = crate::coordinator::start_coordinator_with_team(
148
+ &crate::coordinator::WorkspacePath::new(selected.run_workspace.clone()), Some(&selected.team_key),
149
+ ).map_err(error)?;
150
+ Ok(json!({"ok":coordinator.ok,"agent_id":agent,"status":"running","runtime":"contract",
151
+ "start_mode":if unstarted {"fresh"} else {"resumed"}, "operation":receipt.id,
152
+ "session_id":projection.get("session_id"),"generation":projection.get("generation"),
153
+ "coordinator":crate::lifecycle::coordinator_start_summary_value(
154
+ &crate::lifecycle::CoordinatorStartSummary::from_start_report(&coordinator))}))
155
+ })
156
+ }
157
+
158
+ pub(crate) struct NativeRestartResult {
159
+ pub agent_id: String,
160
+ pub session_id: Option<String>,
161
+ pub outcome: Result<(), String>,
162
+ }
163
+
164
+ /// Called with the ordinary workspace lifecycle lock already held. Native
165
+ /// panes never enter legacy transport spawn, backing scan or shared teardown.
166
+ pub(crate) fn restart_prepared(
167
+ workspace: &Path,
168
+ team: &str,
169
+ state: &mut Value,
170
+ prepared: Vec<backend::PreparedResume>,
171
+ ) -> Vec<NativeRestartResult> {
172
+ prepared.into_iter().map(|prepared| {
173
+ let id = prepared.seat().clone();
174
+ let session_id = prepared.session().map(str::to_owned);
175
+ let outcome = (|| {
176
+ let receipt = backend::resume(prepared).map_err(error)?;
177
+ let projection = checked_projection(&receipt, || publish_at(workspace, team, &id, false))?;
178
+ if let Some(row) = state.get_mut("agents").and_then(|agents| agents.get_mut(id.as_str())).and_then(Value::as_object_mut) {
179
+ for (key, value) in projection.as_object().expect("native projection object") {
180
+ row.insert(key.clone(), value.clone());
181
+ }
182
+ }
183
+ Ok::<(), CliError>(())
184
+ })().map_err(|error| error.to_string());
185
+ NativeRestartResult { agent_id: id.as_str().into(), session_id, outcome }
186
+ }).collect()
187
+ }
188
+
189
+ pub fn restart(workspace: &Path, team: Option<&str>) -> Result<Option<Value>, CliError> {
190
+ let Ok(selected) = resolve_active_team_readonly(workspace, team, SelectorMode::RuntimeOnly) else {
191
+ return Ok(None);
192
+ };
193
+ if !framework::only_contract(&selected.state) { return Ok(None); }
194
+ let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
195
+ workspace: &selected.run_workspace, operation: "restart",
196
+ team: Some(&selected.team_key), agent_id: None,
197
+ }).map_err(error)?;
198
+ let latest = resolve_active_team_readonly(&selected.run_workspace, Some(&selected.team_key), SelectorMode::RuntimeOnly).map_err(error)?;
199
+ crate::lifecycle::launch::ensure_owner_allowed_for_state(&latest.state, None).map_err(error)?;
200
+ if !framework::only_contract(&latest.state) { return Err(error("native team changed while acquiring lifecycle ownership")); }
201
+ let prepared = backend::prepare_restart(&latest.run_workspace, &latest.team_key, &latest.team_dir, &latest.state).map_err(error)?;
202
+ let mut state = latest.state.clone();
203
+ let results = restart_prepared(&latest.run_workspace, &latest.team_key, &mut state, prepared);
204
+ let succeeded: Vec<_> = results.iter().filter(|result| result.outcome.is_ok()).map(|result| json!({
205
+ "agent_id":result.agent_id,"restart_mode":"resumed","session_id":result.session_id,
206
+ })).collect();
207
+ let failed: Vec<_> = results.iter().filter_map(|result| result.outcome.as_ref().err().map(|error| json!({
208
+ "agent_id":result.agent_id,"phase":"native_resume","error":error,
209
+ }))).collect();
210
+ let coordinator = crate::coordinator::start_coordinator_with_team(
211
+ &crate::coordinator::WorkspacePath::new(latest.run_workspace.clone()), Some(&latest.team_key),
212
+ ).map_err(error)?;
213
+ Ok(Some(json!({"ok":failed.is_empty() && coordinator.ok,"runtime":"contract",
214
+ "status":if failed.is_empty() {"restarted"} else if succeeded.is_empty() {"failed"} else {"partial"},
215
+ "team_id":latest.team_key,"agents":succeeded,"failed_agents":failed,
216
+ "coordinator":crate::lifecycle::coordinator_start_summary_value(
217
+ &crate::lifecycle::CoordinatorStartSummary::from_start_report(&coordinator))})))
218
+ }
219
+
220
+ pub fn stop_agent(
221
+ workspace: &Path,
222
+ team: Option<&str>,
223
+ agent: &str,
224
+ ) -> Result<Option<Value>, CliError> {
225
+ with_agent(workspace, team, agent, "stop-agent", |selected, id| {
226
+ if selected.state.get("agents").and_then(|agents| agents.get(agent)).is_some_and(framework::is_paused_unstarted) {
227
+ return Ok(json!({"ok":true,"agent_id":agent,"stopped":false,"status":"paused","runtime":"contract"}));
228
+ }
229
+ let backend = Backend::open_readonly(&selected.run_workspace, &selected.team_key).map_err(error)?;
230
+ let seat = backend.store.seat(id).map_err(error)?.ok_or(BackendError::Metadata).map_err(error)?;
231
+ let already_stopped = seat.status == SeatStatus::Stopped;
232
+ drop(backend);
233
+ let operation = if already_stopped {
234
+ None
235
+ } else {
236
+ let receipt = backend::stop(&selected.run_workspace, &selected.team_key, id).map_err(error)?;
237
+ if receipt.outcome != Outcome::Committed {
238
+ return Err(error(BackendError::Shutdown {
239
+ operation: receipt.id, phase: receipt.phase, outcome: receipt.outcome, failure: receipt.failure,
240
+ }));
241
+ }
242
+ Some(receipt.id)
243
+ };
244
+ let projected = framework::project_seat(&selected.run_workspace, &selected.team_key, id.as_str()).map_err(error)?;
245
+ let mut state = selected.state.clone();
246
+ let row = state.get_mut("agents").and_then(|agents| agents.get_mut(agent)).and_then(Value::as_object_mut).ok_or(BackendError::Metadata).map_err(error)?;
247
+ for (key, value) in projected.as_object().expect("native projection object") {
248
+ row.insert(key.clone(), value.clone());
249
+ }
250
+ row.insert("explicit_stop".into(), Value::Bool(true));
251
+ StateRepository::new(&selected.run_workspace).save(
252
+ StateWriteIntent::StopAgent { team_key: &selected.team_key, agent_id: agent }, &state,
253
+ ).map_err(error)?;
254
+ Ok(json!({"ok":true,"agent_id":agent,"stopped":!already_stopped,
255
+ "status":if already_stopped {"already_stopped"} else {"stopped"},
256
+ "runtime":"contract","operation":operation}))
257
+ })
258
+ }
259
+
260
+ #[cfg(test)]
261
+ mod tests {
262
+ use super::*;
263
+ use team_agent_contract::contract::{delivery::DeliveryEffect, session::CwdIdentity, types::*};
264
+ use team_agent_contract::orchestration::store::{OperationRecord, Phase, SeatRecord, TransactionKind};
265
+
266
+ fn receipt(outcome: Outcome) -> OperationRecord {
267
+ let identity = InstanceIdentity {
268
+ scope: ScopeId::new("scope").unwrap(), seat: SeatId::new("grok-worker").unwrap(),
269
+ instance: InstanceId::new("instance-two").unwrap(), generation: Generation(2),
270
+ };
271
+ OperationRecord {
272
+ id: OperationId::new("resume-instance-two").unwrap(), kind: TransactionKind::Restart,
273
+ target: SeatRecord {
274
+ identity, provider: ProviderId::new("grok_build").unwrap(),
275
+ native: NativeIdentity { version: "fixture".into(), harness: "fixture".into(), ui: "tui".into(),
276
+ platform: Platform::MacOs, executable_sha256: Digest([0; 32]) },
277
+ cwd: CwdIdentity { path: "/controlled/native-cwd".into(), identity: Digest([0; 32]) },
278
+ endpoint: "fixture".into(), pane: "%0".into(), binding_key: "fixture".into(),
279
+ profile: "fixture".into(), server_key: "fixture".into(), evidence_kind: EvidenceKind::Fixture,
280
+ status: SeatStatus::Starting, process: None, physical: None, session: None,
281
+ resources: vec![], workspace_resources: vec![], bootstrap_used: false,
282
+ },
283
+ parent: None, phase: Phase::F3Spawn, pending: Some("compensating-stop".into()),
284
+ resources: vec![], effect: DeliveryEffect::NoEffect, outcome, preserved: vec![],
285
+ failure: Some("native control SessionInspect guard-deadline".into()),
286
+ }
287
+ }
288
+
289
+ #[test]
290
+ fn uncommitted_receipt_precedes_projection_and_preserves_original_failure() {
291
+ for outcome in [Outcome::Running, Outcome::Compensated, Outcome::NeedsRecovery] {
292
+ let record = receipt(outcome);
293
+ let before = record.clone();
294
+ let mut projected = false;
295
+ let mut state = json!({"status":"stopped","explicit_stop":true,"generation":1});
296
+ let original = state.clone();
297
+ let error = checked_projection(&record, || {
298
+ projected = true;
299
+ state["explicit_stop"] = Value::Bool(false);
300
+ Err(super::error(BackendError::Metadata))
301
+ }).unwrap_err().to_string();
302
+ assert!(!projected);
303
+ assert_eq!(state, original);
304
+ assert_eq!(record, before);
305
+ for value in ["resume-instance-two", "F3Spawn", "SessionInspect", "guard-deadline"] {
306
+ assert!(error.contains(value), "{error}");
307
+ }
308
+ assert!(!error.contains("metadata is invalid"), "{error}");
309
+ }
310
+ }
311
+
312
+ #[test]
313
+ fn committed_receipt_retains_projection_success_and_error() {
314
+ let record = receipt(Outcome::Committed);
315
+ let mut calls = 0;
316
+ let projected = checked_projection(&record, || {
317
+ calls += 1;
318
+ Ok(json!({"status":"running","explicit_stop":false,"generation":2}))
319
+ }).unwrap();
320
+ assert_eq!(calls, 1);
321
+ assert_eq!(projected["explicit_stop"], false);
322
+ let error = checked_projection(&record, || Err(super::error(BackendError::Metadata)))
323
+ .unwrap_err().to_string();
324
+ assert!(error.contains("metadata is invalid"));
325
+ }
326
+ }
@@ -1,5 +1,8 @@
1
1
  //! Read-only per-agent routing/preflight. Contract roles opt in independently;
2
2
  //! other roles keep the legacy engine, including in the same logical team.
3
+ #[cfg(test)]
4
+ #[path = "config_tests.rs"]
5
+ mod tests;
3
6
  use std::collections::BTreeSet;
4
7
  use std::path::{Path, PathBuf};
5
8
 
@@ -37,6 +40,8 @@ pub struct RoleConfig {
37
40
  pub model: String,
38
41
  pub effort: Option<String>,
39
42
  pub bypass: bool,
43
+ #[serde(default)]
44
+ pub paused: bool,
40
45
  pub body: String,
41
46
  pub prompt: Prompt,
42
47
  }
@@ -94,8 +99,8 @@ fn mode(meta: &Value, path: &Path) -> Result<Option<CommunicationMode>, ConfigEr
94
99
  .transpose()
95
100
  }
96
101
 
97
- /// A malformed explicit Kiro header cannot fall back to the default Pi worker.
98
- fn malformed_kiro_header(body: &str) -> bool {
102
+ /// A malformed explicit contract header cannot fall back to the default Pi worker.
103
+ fn malformed_contract_header(body: &str) -> bool {
99
104
  body.strip_prefix("---\n").is_some_and(|header| {
100
105
  header
101
106
  .lines()
@@ -116,7 +121,7 @@ fn malformed_kiro_header(body: &str) -> bool {
116
121
  })
117
122
  }
118
123
 
119
- /// None delegates exactly to the existing no-Kiro/leader-only path. No native
124
+ /// None delegates exactly to the existing legacy/leader-only path. No native
120
125
  /// command, model probe, config write or process is created by this selection.
121
126
  pub fn read_team(args: &QuickStartArgs) -> Result<Option<TeamConfig>, ConfigError> {
122
127
  read_team_with_user_source(args, prompt::user_source)
@@ -127,6 +132,23 @@ pub fn read_team(args: &QuickStartArgs) -> Result<Option<TeamConfig>, ConfigErro
127
132
  pub fn read_team_with_user_source(
128
133
  args: &QuickStartArgs,
129
134
  user_source: impl FnOnce() -> Result<Option<Source>, ConfigError>,
135
+ ) -> Result<Option<TeamConfig>, ConfigError> {
136
+ read_team_inner(args, user_source, None)
137
+ }
138
+
139
+ /// Compile an admitted role-document change without first writing any file.
140
+ pub(crate) fn read_team_with_role_override(
141
+ args: &QuickStartArgs,
142
+ path: &Path,
143
+ contents: &str,
144
+ ) -> Result<Option<TeamConfig>, ConfigError> {
145
+ read_team_inner(args, prompt::user_source, Some((path, contents)))
146
+ }
147
+
148
+ fn read_team_inner(
149
+ args: &QuickStartArgs,
150
+ user_source: impl FnOnce() -> Result<Option<Source>, ConfigError>,
151
+ role_override: Option<(&Path, &str)>,
130
152
  ) -> Result<Option<TeamConfig>, ConfigError> {
131
153
  let agents = args.agents_dir.join("agents");
132
154
  let entries = match std::fs::read_dir(&agents) {
@@ -145,14 +167,17 @@ pub fn read_team_with_user_source(
145
167
  let mut roles = Vec::new();
146
168
  let mut selected = false;
147
169
  for path in paths {
148
- let raw = match std::fs::read_to_string(&path) {
170
+ let raw = match role_override.filter(|(source, _)| path.canonicalize().ok().as_deref() == Some(*source)) {
171
+ Some((_, contents)) => contents.to_owned(),
172
+ None => match std::fs::read_to_string(&path) {
149
173
  Ok(raw) => raw,
150
174
  Err(error) if selected => return Err(error.into()),
151
175
  Err(_) => return Ok(None), // let the legacy compiler report its existing I/O error
176
+ },
152
177
  };
153
178
  let (meta, body) = split_front_matter(&raw);
154
179
  let normalized = raw.replace("\r\n", "\n").replace('\r', "\n");
155
- if meta.get("provider").is_none() && malformed_kiro_header(&normalized) {
180
+ if meta.get("provider").is_none() && malformed_contract_header(&normalized) {
156
181
  return Err(invalid(&path, "front matter"));
157
182
  }
158
183
  if meta
@@ -173,7 +198,7 @@ pub fn read_team_with_user_source(
173
198
  .is_some_and(|backend| backend != "tmux")
174
199
  {
175
200
  return Err(ConfigError::Unsupported(
176
- "Kiro contract transport requires tmux",
201
+ "contract native transport requires tmux",
177
202
  ));
178
203
  }
179
204
  let team_dir = args.agents_dir.canonicalize()?;
@@ -234,7 +259,7 @@ pub fn read_team_with_user_source(
234
259
  };
235
260
  members.push(MemberRoute {
236
261
  id: id.clone(),
237
- provider,
262
+ provider: registration.map_or(provider, |descriptor| descriptor.identity.id.into()),
238
263
  source: source.canonicalize()?,
239
264
  runtime,
240
265
  });
@@ -251,7 +276,7 @@ pub fn read_team_with_user_source(
251
276
  || text(&meta, "profile", &source)?.is_some()
252
277
  {
253
278
  return Err(ConfigError::Unsupported(
254
- "Kiro uses an existing native subscription; API/profile mappings are unavailable",
279
+ "contract native providers use an existing subscription; API/profile mappings are unavailable",
255
280
  ));
256
281
  }
257
282
  let bypass = match meta.get("dangerously_skip_permissions") {
@@ -259,6 +284,11 @@ pub fn read_team_with_user_source(
259
284
  Some(Value::Bool(value)) => *value,
260
285
  _ => return Err(invalid(&source, "dangerously_skip_permissions")),
261
286
  };
287
+ let paused = match meta.get("paused") {
288
+ None => false,
289
+ Some(Value::Bool(value)) => *value,
290
+ _ => return Err(invalid(&source, "paused")),
291
+ };
262
292
  if body.trim().is_empty() || body.contains('\0') {
263
293
  return Err(invalid(&source, "role body"));
264
294
  }
@@ -292,6 +322,7 @@ pub fn read_team_with_user_source(
292
322
  model: required(&meta, "model", &source)?,
293
323
  effort: text(&meta, "effort", &source)?,
294
324
  bypass,
325
+ paused,
295
326
  body,
296
327
  prompt,
297
328
  });
@@ -0,0 +1,56 @@
1
+ use super::*;
2
+ use std::os::unix::fs::DirBuilderExt;
3
+
4
+ struct Fixture { root: PathBuf, args: QuickStartArgs, role: PathBuf }
5
+ impl Fixture {
6
+ fn new() -> Self {
7
+ let root = std::env::temp_dir().join(format!("contract-override-{}-{}", std::process::id(),
8
+ std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()));
9
+ std::fs::DirBuilder::new().mode(0o700).create(&root).unwrap();
10
+ let directory = root.join("roles");
11
+ std::fs::create_dir_all(directory.join("agents")).unwrap();
12
+ std::fs::write(directory.join("TEAM.md"), "---\nname: test\n---\nBounded fixture\n").unwrap();
13
+ let role = directory.join("agents/worker.md");
14
+ std::fs::write(&role, "---\nprovider: cursor\nmodel: old-model\n---\nOld instructions\n").unwrap();
15
+ let args = QuickStartArgs { workspace: root.clone(), agents_dir: directory, name: None,
16
+ team_id: None, yes: false, json: false, detail: false, backend: Some("tmux".into()) };
17
+ Self { root, args, role }
18
+ }
19
+ }
20
+ impl Drop for Fixture { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.root); } }
21
+
22
+ #[test]
23
+ fn virtual_role_change_is_readonly_and_binds_new_instruction_digest() {
24
+ let fixture = Fixture::new();
25
+ let before = std::fs::read(&fixture.role).unwrap();
26
+ let path = fixture.role.canonicalize().unwrap();
27
+ let contents = "---\nprovider: cursor\nmodel: new-model\n---\nNew instructions\n";
28
+ let original = read_team_inner(&fixture.args, || Ok(None), None).unwrap().unwrap();
29
+ let changed = read_team_inner(&fixture.args, || Ok(None), Some((&path, contents))).unwrap().unwrap();
30
+ assert_eq!(changed.roles[0].model, "new-model");
31
+ assert_eq!(changed.roles[0].body, "New instructions\n");
32
+ assert_ne!(changed.roles[0].prompt.sha256, original.roles[0].prompt.sha256);
33
+ assert_eq!(std::fs::read(&fixture.role).unwrap(), before);
34
+ assert!(!fixture.root.join(".team").exists());
35
+ assert!(!fixture.root.join(".cursor").exists());
36
+ }
37
+
38
+ #[test]
39
+ fn virtual_api_profile_is_refused_without_file_effects() {
40
+ let fixture = Fixture::new();
41
+ let before = std::fs::read(&fixture.role).unwrap();
42
+ let path = fixture.role.canonicalize().unwrap();
43
+ let contents = "---\nprovider: cursor\nmodel: new-model\nprofile: unsupported\n---\nInstructions\n";
44
+ assert!(matches!(read_team_inner(&fixture.args, || Ok(None), Some((&path, contents))), Err(ConfigError::Unsupported(_))));
45
+ assert_eq!(std::fs::read(&fixture.role).unwrap(), before);
46
+ assert!(!fixture.root.join(".team").exists());
47
+ }
48
+
49
+ #[test]
50
+ fn virtual_role_override_cannot_match_a_different_source() {
51
+ let fixture = Fixture::new();
52
+ let other = fixture.root.join("other.md");
53
+ let contents = "---\nprovider: cursor\nmodel: wrong-model\n---\nOther\n";
54
+ let config = read_team_inner(&fixture.args, || Ok(None), Some((&other, contents))).unwrap().unwrap();
55
+ assert_eq!(config.roles[0].model, "old-model");
56
+ }