@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/emit.rs +2 -2
- package/crates/team-agent/src/cli/mod.rs +155 -19
- package/crates/team-agent/src/cli/models.rs +52 -1
- package/crates/team-agent/src/cli/shutdown_socket.rs +288 -0
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +11 -4
- package/crates/team-agent/src/contract_runtime/backend.rs +490 -97
- package/crates/team-agent/src/contract_runtime/commands.rs +326 -0
- package/crates/team-agent/src/contract_runtime/config.rs +39 -8
- package/crates/team-agent/src/contract_runtime/config_tests.rs +56 -0
- package/crates/team-agent/src/contract_runtime/discovery.rs +218 -40
- package/crates/team-agent/src/contract_runtime/framework.rs +38 -5
- package/crates/team-agent/src/contract_runtime/mcp.rs +3 -0
- package/crates/team-agent/src/contract_runtime/mod.rs +2 -0
- package/crates/team-agent/src/contract_runtime/pump.rs +97 -33
- package/crates/team-agent/src/contract_runtime/registry.rs +248 -4
- package/crates/team-agent/src/contract_runtime/workspace.rs +228 -0
- package/crates/team-agent/src/leader/start.rs +93 -5
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +99 -1
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +3 -5
- package/crates/team-agent/src/lifecycle/launch.rs +6 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +4 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +109 -58
- package/crates/team-agent/src/lifecycle/restart/selection.rs +63 -7
- package/crates/team-agent/src/lifecycle/role_config.rs +17 -1
- package/crates/team-agent/src/provider/session/capture.rs +24 -1
- package/crates/team-agent/src/state/persist.rs +72 -2
- package/crates/team-agent/src/state/projection.rs +29 -0
- package/crates/team-agent/src/state/repository/tests.rs +70 -0
- package/crates/team-agent/src/state/repository.rs +5 -1
- 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
|
|
98
|
-
fn
|
|
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
|
|
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
|
|
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() &&
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
+
}
|