@team-agent/installer 0.5.119 → 0.5.121
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/README.md +46 -1
- package/README.zh.md +46 -1
- package/crates/team-agent/src/cli/adapters.rs +11 -0
- package/crates/team-agent/src/cli/arguments.rs +401 -0
- package/crates/team-agent/src/cli/emit.rs +241 -142
- package/crates/team-agent/src/cli/intent/runtime.rs +1175 -0
- package/crates/team-agent/src/cli/intent/runtime_tests.rs +698 -0
- package/crates/team-agent/src/cli/intent/tests.rs +654 -0
- package/crates/team-agent/src/cli/intent.rs +642 -0
- package/crates/team-agent/src/cli/mod.rs +53 -6
- package/crates/team-agent/src/cli/named_address.rs +10 -1
- package/crates/team-agent/src/cli/spec.rs +22 -10
- package/crates/team-agent/src/cli/status_port/brief.rs +117 -16
- package/crates/team-agent/src/cli/tests/base.rs +6 -1
- package/crates/team-agent/src/cli/tests/parser_safety.rs +383 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +63 -19
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +56 -0
- package/crates/team-agent/src/cli/tests/verb_install_skill.rs +3 -2
- package/crates/team-agent/src/cli/tests/verb_profile.rs +3 -1
- package/crates/team-agent/src/contract_runtime/backend.rs +1 -1
- package/crates/team-agent/src/db/message_store.rs +65 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +8 -0
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +111 -4
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp_iso.rs +244 -82
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +166 -77
- package/crates/team-agent/src/lifecycle/launch.rs +29 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +7 -1
- package/crates/team-agent/src/lifecycle/restart.rs +17 -0
- package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +1 -3
- package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +5 -15
- package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +2 -3
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +2 -3
- package/crates/team-agent/src/lifecycle/tests/cli_argv_routing_red.rs +10 -2
- package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +8 -1
- package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +3 -2
- package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +6 -9
- package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +7 -5
- package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +64 -19
- package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +5 -2
- package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +34 -10
- package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +2 -4
- package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +3 -4
- package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +5 -4
- package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +2 -4
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +78 -36
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +8 -4
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +6 -12
- package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +4 -0
- package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +1 -3
- package/crates/team-agent/src/lifecycle/tests/status_credential_redaction_contract.rs +7 -0
- package/crates/team-agent/src/lifecycle/tests/subprep_codex_trust_roundtrip_red.rs +47 -4
- package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +1 -3
- package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +2 -1
- package/crates/team-agent/src/lifecycle/types.rs +10 -0
- package/crates/team-agent/src/mcp_server/mod.rs +2 -0
- package/crates/team-agent/src/mcp_server/readiness.rs +432 -0
- package/crates/team-agent/src/mcp_server/readiness_tests.rs +242 -0
- package/crates/team-agent/src/mcp_server/wire.rs +96 -11
- package/crates/team-agent/src/messaging/delivery.rs +62 -0
- package/crates/team-agent/src/messaging/tests/dup_inject.rs +41 -1
- package/crates/team-agent/src/provider/adapter.rs +7 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +37 -0
- package/crates/team-agent/src/tmux_backend.rs +8 -1
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +71 -54
- package/skills/team-agent/command-coverage.json +283 -13
- package/skills/team-agent/docs/reference/team-agent-operator.md +220 -0
- package/skills/team-agent/references/bug-as-artifact-flow.md +1 -1
- package/skills/team-agent/references/recovery-runbook.md +61 -212
- package/skills/team-agent/references/team-agent-operator.md +181 -48
- package/skills/team-agent/references/team-in-team.md +47 -80
|
@@ -0,0 +1,1175 @@
|
|
|
1
|
+
//! Read-only planning; execution remains in the original typed lifecycle ports.
|
|
2
|
+
use super::*;
|
|
3
|
+
use crate::model::yaml::{self, Value as Yaml};
|
|
4
|
+
use crate::state::selector::{resolve_active_team_readonly, SelectedTeam, SelectorMode};
|
|
5
|
+
use crate::transport::{PaneId, Target, Transport};
|
|
6
|
+
use std::collections::BTreeSet;
|
|
7
|
+
#[cfg(test)]
|
|
8
|
+
#[path = "runtime_tests.rs"]
|
|
9
|
+
mod tests;
|
|
10
|
+
|
|
11
|
+
fn checked<T, E: std::fmt::Display>(result: Result<T, E>) -> Result<T, CliError> {
|
|
12
|
+
result.map_err(|e| refusal(e.to_string()))
|
|
13
|
+
}
|
|
14
|
+
fn complete(path: &Path) -> bool {
|
|
15
|
+
path.join("TEAM.md").is_file() && path.join("agents").is_dir()
|
|
16
|
+
}
|
|
17
|
+
fn discover(
|
|
18
|
+
workspace: &Path,
|
|
19
|
+
explicit: Option<&Path>,
|
|
20
|
+
selected: Option<&SelectedTeam>,
|
|
21
|
+
) -> Result<PathBuf, CliError> {
|
|
22
|
+
if let Some(path) = explicit {
|
|
23
|
+
return if complete(path) {
|
|
24
|
+
Ok(path.to_owned())
|
|
25
|
+
} else {
|
|
26
|
+
Err(usage("source requires TEAM.md and agents/"))
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if let Some(selected) = selected {
|
|
30
|
+
return if complete(&selected.team_dir) {
|
|
31
|
+
Ok(selected.team_dir.clone())
|
|
32
|
+
} else {
|
|
33
|
+
Err(refusal(
|
|
34
|
+
"selected role source is incomplete; repair it before start",
|
|
35
|
+
))
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if complete(workspace) {
|
|
39
|
+
return Ok(workspace.to_owned());
|
|
40
|
+
}
|
|
41
|
+
let dir = workspace.join(".team");
|
|
42
|
+
let mut candidates = BTreeSet::new();
|
|
43
|
+
if dir.is_dir() {
|
|
44
|
+
for entry in std::fs::read_dir(dir)? {
|
|
45
|
+
let path = entry?.path();
|
|
46
|
+
if path.is_dir() && complete(&path) {
|
|
47
|
+
candidates.insert(path.canonicalize()?);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if candidates.is_empty() {
|
|
52
|
+
return Err(usage(format!(
|
|
53
|
+
"action_required: prepare {}/TEAM.md and {}/agents/<id>.md, then use --from TEAMDIR. Nothing was written or started. Role template (fill your decisions): provider: <choose>; dangerously_skip_permissions: <true|false>; model/effort: <optional>.",
|
|
54
|
+
workspace.display(), workspace.display()
|
|
55
|
+
)));
|
|
56
|
+
}
|
|
57
|
+
if candidates.len() != 1 {
|
|
58
|
+
return Err(usage(format!(
|
|
59
|
+
"action_required: multiple complete team sources {candidates:?}; supply --from TEAMDIR"
|
|
60
|
+
)));
|
|
61
|
+
}
|
|
62
|
+
candidates
|
|
63
|
+
.into_iter()
|
|
64
|
+
.next()
|
|
65
|
+
.ok_or_else(|| usage("missing team source"))
|
|
66
|
+
}
|
|
67
|
+
fn validate_roster(spec: &Yaml, directory: &Path) -> Result<(), CliError> {
|
|
68
|
+
checked(crate::model::spec::validate_spec(spec, directory))?;
|
|
69
|
+
if let Some(agents) = spec.get("agents").and_then(Yaml::as_list) {
|
|
70
|
+
for agent in agents {
|
|
71
|
+
if let Some(id) = agent.get("id").and_then(Yaml::as_str) {
|
|
72
|
+
logical_agent_id(id)
|
|
73
|
+
.map_err(|_| refusal("invalid agent identity in selected roster"))?;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
Ok(())
|
|
78
|
+
}
|
|
79
|
+
fn remember(plan: &mut Plan, path: &Path) -> Result<(), CliError> {
|
|
80
|
+
if !plan.files.iter().any(|(p, _)| p == path) {
|
|
81
|
+
plan.files.push((path.to_owned(), std::fs::read(path)?));
|
|
82
|
+
}
|
|
83
|
+
Ok(())
|
|
84
|
+
}
|
|
85
|
+
fn json_yaml(value: &Yaml) -> Value {
|
|
86
|
+
match value {
|
|
87
|
+
Yaml::Null => Value::Null,
|
|
88
|
+
Yaml::Bool(b) => json!(b),
|
|
89
|
+
Yaml::Int(n) => json!(n),
|
|
90
|
+
Yaml::Float(n) => json!(n),
|
|
91
|
+
Yaml::Str(s) => json!(s),
|
|
92
|
+
Yaml::List(values) => Value::Array(values.iter().map(json_yaml).collect()),
|
|
93
|
+
Yaml::Map(values) => Value::Object(
|
|
94
|
+
values
|
|
95
|
+
.iter()
|
|
96
|
+
.map(|(k, v)| (k.clone(), json_yaml(v)))
|
|
97
|
+
.collect(),
|
|
98
|
+
),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
fn role(
|
|
102
|
+
plan: &mut Plan,
|
|
103
|
+
selected: &SelectedTeam,
|
|
104
|
+
id: &str,
|
|
105
|
+
source: Option<&Path>,
|
|
106
|
+
) -> Result<(PathBuf, Value), CliError> {
|
|
107
|
+
let path = match source {
|
|
108
|
+
Some(path) => path.to_owned(),
|
|
109
|
+
None => checked(crate::lifecycle::launch::role_source::resolve_role_source(
|
|
110
|
+
&selected.run_workspace,
|
|
111
|
+
&selected.team_dir,
|
|
112
|
+
&selected.state,
|
|
113
|
+
&AgentId::new(id),
|
|
114
|
+
))?,
|
|
115
|
+
};
|
|
116
|
+
let team_path = selected.team_dir.join("TEAM.md");
|
|
117
|
+
remember(plan, &team_path)?;
|
|
118
|
+
remember(plan, &path)?;
|
|
119
|
+
let (team, _) = checked(crate::compiler::read_front_matter(&team_path))?;
|
|
120
|
+
let compiled = checked(crate::compiler::compile_role_agent(
|
|
121
|
+
&path,
|
|
122
|
+
&team,
|
|
123
|
+
&selected.run_workspace.to_string_lossy(),
|
|
124
|
+
))?;
|
|
125
|
+
if compiled.id != id {
|
|
126
|
+
return Err(refusal(format!("role identity mismatch for {id}")));
|
|
127
|
+
}
|
|
128
|
+
Ok((path, json_yaml(&compiled.agent)))
|
|
129
|
+
}
|
|
130
|
+
fn provider(row: &Value) -> &str {
|
|
131
|
+
row.get("provider").and_then(Value::as_str).unwrap_or("pi")
|
|
132
|
+
}
|
|
133
|
+
fn topology(state: &Value) -> Value {
|
|
134
|
+
let mut owner = serde_json::Map::new();
|
|
135
|
+
for key in ["pane_id", "provider", "owner_epoch", "machine_fingerprint"] {
|
|
136
|
+
owner.insert(
|
|
137
|
+
key.into(),
|
|
138
|
+
state
|
|
139
|
+
.get("team_owner")
|
|
140
|
+
.and_then(|o| o.get(key))
|
|
141
|
+
.cloned()
|
|
142
|
+
.unwrap_or(Value::Null),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
json!({"tmux_socket": state.get("tmux_socket"), "transport": state.get("transport"), "owner": owner, "owner_epoch": state.get("owner_epoch")})
|
|
146
|
+
}
|
|
147
|
+
fn relevant(row: &Value) -> Value {
|
|
148
|
+
let mut object = serde_json::Map::new();
|
|
149
|
+
for key in [
|
|
150
|
+
"provider",
|
|
151
|
+
"status",
|
|
152
|
+
"explicit_stop",
|
|
153
|
+
"pane_id",
|
|
154
|
+
"pane_pid",
|
|
155
|
+
"window",
|
|
156
|
+
"session_id",
|
|
157
|
+
"contract_instance",
|
|
158
|
+
"generation",
|
|
159
|
+
"dynamic_role_file",
|
|
160
|
+
"owner_team_id",
|
|
161
|
+
"spawn_epoch",
|
|
162
|
+
"spawned_at",
|
|
163
|
+
] {
|
|
164
|
+
object.insert(key.into(), row.get(key).cloned().unwrap_or(Value::Null));
|
|
165
|
+
}
|
|
166
|
+
Value::Object(object)
|
|
167
|
+
}
|
|
168
|
+
fn same_provider(left: &str, right: &str) -> bool {
|
|
169
|
+
match (
|
|
170
|
+
crate::provider::wire::parse_provider(left),
|
|
171
|
+
crate::provider::wire::parse_provider(right),
|
|
172
|
+
) {
|
|
173
|
+
(Some(left), Some(right)) => left == right,
|
|
174
|
+
_ => left == right,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
fn drift(row: &Value, compiled: &Value) -> bool {
|
|
178
|
+
!same_provider(provider(row), provider(compiled))
|
|
179
|
+
|| row
|
|
180
|
+
.get("as_launched_dangerously_skip_permissions")
|
|
181
|
+
.and_then(Value::as_bool)
|
|
182
|
+
.zip(
|
|
183
|
+
compiled
|
|
184
|
+
.get("dangerously_skip_permissions")
|
|
185
|
+
.and_then(Value::as_bool),
|
|
186
|
+
)
|
|
187
|
+
.is_some_and(|(old, new)| old != new)
|
|
188
|
+
|| row
|
|
189
|
+
.get("model")
|
|
190
|
+
.filter(|v| !v.is_null())
|
|
191
|
+
.zip(compiled.get("model").filter(|v| !v.is_null()))
|
|
192
|
+
.is_some_and(|(old, new)| old != new)
|
|
193
|
+
}
|
|
194
|
+
#[cfg(unix)]
|
|
195
|
+
fn native_settings_changed(
|
|
196
|
+
old: &crate::contract_runtime::config::RoleConfig,
|
|
197
|
+
current: &crate::contract_runtime::config::RoleConfig,
|
|
198
|
+
) -> bool {
|
|
199
|
+
old.provider != current.provider
|
|
200
|
+
|| old.model != current.model
|
|
201
|
+
|| old.effort != current.effort
|
|
202
|
+
|| old.bypass != current.bypass
|
|
203
|
+
|| old.paused != current.paused
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
fn check_native_noop_settings(
|
|
207
|
+
selected: &SelectedTeam,
|
|
208
|
+
id: &str,
|
|
209
|
+
row: &Value,
|
|
210
|
+
) -> Result<(), CliError> {
|
|
211
|
+
#[cfg(unix)]
|
|
212
|
+
if crate::contract_runtime::framework::is_contract(row) {
|
|
213
|
+
use team_agent_contract::contract::types::SeatId;
|
|
214
|
+
let backend = checked(crate::contract_runtime::backend::Backend::open_readonly(
|
|
215
|
+
&selected.run_workspace,
|
|
216
|
+
&selected.team_key,
|
|
217
|
+
))?;
|
|
218
|
+
let seat = checked(backend.store.seat(&checked(SeatId::new(id))?))?
|
|
219
|
+
.ok_or_else(|| refusal("missing native snapshot identity"))?;
|
|
220
|
+
if row.get("contract_instance").and_then(Value::as_str)
|
|
221
|
+
!= Some(seat.identity.instance.as_str())
|
|
222
|
+
|| row.get("generation").and_then(Value::as_u64) != Some(seat.identity.generation.0)
|
|
223
|
+
{
|
|
224
|
+
return Err(refusal("native incarnation changed during no-op planning"));
|
|
225
|
+
}
|
|
226
|
+
let old = checked(crate::contract_runtime::backend::read_role(
|
|
227
|
+
&backend.binding,
|
|
228
|
+
&seat.identity,
|
|
229
|
+
))?;
|
|
230
|
+
let config = checked(crate::contract_runtime::framework::team_config(
|
|
231
|
+
&selected.run_workspace,
|
|
232
|
+
&selected.team_dir,
|
|
233
|
+
&selected.team_key,
|
|
234
|
+
))?
|
|
235
|
+
.ok_or_else(|| refusal("missing native configuration"))?;
|
|
236
|
+
let current = config
|
|
237
|
+
.roles
|
|
238
|
+
.iter()
|
|
239
|
+
.find(|role| role.id.as_str() == id)
|
|
240
|
+
.ok_or_else(|| refusal("native role no longer configured"))?;
|
|
241
|
+
if native_settings_changed(&old, current) {
|
|
242
|
+
return Err(refusal(format!(
|
|
243
|
+
"native launch configuration drift for {id}; stop before applying settings"
|
|
244
|
+
)));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
Ok(())
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
fn paused(row: &Value) -> bool {
|
|
251
|
+
row.get("status")
|
|
252
|
+
.and_then(Value::as_str)
|
|
253
|
+
.is_some_and(|status| status.eq_ignore_ascii_case("paused"))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#[cfg(unix)]
|
|
257
|
+
fn native_observation(
|
|
258
|
+
status: team_agent_contract::orchestration::store::SeatStatus,
|
|
259
|
+
process: team_agent_contract::host::process::ProcessState,
|
|
260
|
+
root_status: Option<&str>,
|
|
261
|
+
) -> Observation {
|
|
262
|
+
use team_agent_contract::{host::process::ProcessState, orchestration::store::SeatStatus};
|
|
263
|
+
match (status, process) {
|
|
264
|
+
(SeatStatus::Ready, ProcessState::Alive) if root_status == Some("running") => {
|
|
265
|
+
Observation::Live
|
|
266
|
+
}
|
|
267
|
+
(SeatStatus::Ready, ProcessState::Exited) => Observation::StoppedVerified,
|
|
268
|
+
(SeatStatus::Stopped, ProcessState::Exited) if root_status == Some("stopped") => {
|
|
269
|
+
Observation::StoppedVerified
|
|
270
|
+
}
|
|
271
|
+
_ => Observation::Unknown,
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
fn shell_carrier(command: &str) -> bool {
|
|
276
|
+
matches!(command, "sh" | "bash" | "zsh" | "fish")
|
|
277
|
+
}
|
|
278
|
+
fn surface_liveness(command: &str, native_frame: bool, live_child: bool) -> bool {
|
|
279
|
+
!command.is_empty() && native_frame && (!shell_carrier(command) || live_child)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/// Positive native/store or exact carrier+pane+provider observations only.
|
|
283
|
+
fn observe(selected: &SelectedTeam, id: &str, row: &Value) -> Result<Observation, CliError> {
|
|
284
|
+
if !row.is_object()
|
|
285
|
+
|| row
|
|
286
|
+
.get("provider")
|
|
287
|
+
.is_some_and(|value| !value.is_null() && !value.is_string())
|
|
288
|
+
{
|
|
289
|
+
return Err(refusal("invalid persisted provider metadata"));
|
|
290
|
+
}
|
|
291
|
+
#[cfg(unix)]
|
|
292
|
+
if crate::contract_runtime::framework::is_contract(row) {
|
|
293
|
+
use team_agent_contract::{contract::types::SeatId, host::process::sample_process};
|
|
294
|
+
if crate::contract_runtime::framework::is_paused_unstarted(row) {
|
|
295
|
+
return Ok(Observation::Unstarted);
|
|
296
|
+
}
|
|
297
|
+
let backend = checked(crate::contract_runtime::backend::Backend::open_readonly(
|
|
298
|
+
&selected.run_workspace,
|
|
299
|
+
&selected.team_key,
|
|
300
|
+
))?;
|
|
301
|
+
let seat = checked(backend.store.seat(&checked(SeatId::new(id))?))?
|
|
302
|
+
.ok_or_else(|| refusal("missing canonical native seat"))?;
|
|
303
|
+
if provider(row) != seat.provider.as_str()
|
|
304
|
+
|| row.get("contract_scope").and_then(Value::as_str)
|
|
305
|
+
!= Some(seat.identity.scope.as_str())
|
|
306
|
+
|| row.get("contract_instance").and_then(Value::as_str)
|
|
307
|
+
!= Some(seat.identity.instance.as_str())
|
|
308
|
+
|| row.get("generation").and_then(Value::as_u64) != Some(seat.identity.generation.0)
|
|
309
|
+
{
|
|
310
|
+
return Ok(Observation::Unknown);
|
|
311
|
+
}
|
|
312
|
+
let Some(target) = seat.physical.as_ref() else {
|
|
313
|
+
return Ok(Observation::Unknown);
|
|
314
|
+
};
|
|
315
|
+
return Ok(native_observation(
|
|
316
|
+
seat.status,
|
|
317
|
+
sample_process(&target.process),
|
|
318
|
+
row.get("status").and_then(Value::as_str),
|
|
319
|
+
));
|
|
320
|
+
}
|
|
321
|
+
let native = crate::provider::wire::parse_provider(provider(row))
|
|
322
|
+
.ok_or_else(|| refusal("unknown provider"))?;
|
|
323
|
+
let status = row.get("status").and_then(Value::as_str).unwrap_or("");
|
|
324
|
+
if !["running", "stopped", "dead"]
|
|
325
|
+
.iter()
|
|
326
|
+
.any(|known| status.eq_ignore_ascii_case(known))
|
|
327
|
+
{
|
|
328
|
+
return Ok(Observation::Unknown);
|
|
329
|
+
}
|
|
330
|
+
let Some(pid) = row
|
|
331
|
+
.get("pane_pid")
|
|
332
|
+
.and_then(Value::as_u64)
|
|
333
|
+
.and_then(|p| u32::try_from(p).ok())
|
|
334
|
+
.filter(|p| *p > 1)
|
|
335
|
+
else {
|
|
336
|
+
return Ok(Observation::Unknown);
|
|
337
|
+
};
|
|
338
|
+
match checked(crate::platform::process::pid_liveness(pid))? {
|
|
339
|
+
crate::platform::process::ProcessLiveness::Dead => return Ok(Observation::StoppedVerified),
|
|
340
|
+
crate::platform::process::ProcessLiveness::Unknown { .. } => {
|
|
341
|
+
return Ok(Observation::Unknown)
|
|
342
|
+
}
|
|
343
|
+
crate::platform::process::ProcessLiveness::Live => (),
|
|
344
|
+
}
|
|
345
|
+
if !status.eq_ignore_ascii_case("running") {
|
|
346
|
+
return Ok(Observation::Unknown);
|
|
347
|
+
}
|
|
348
|
+
#[cfg(unix)]
|
|
349
|
+
let carrier_birth = checked(team_agent_contract::host::process::process_birth_identity(
|
|
350
|
+
pid,
|
|
351
|
+
))?;
|
|
352
|
+
let backend = checked(
|
|
353
|
+
crate::lifecycle::restart::lifecycle_worker_tmux_backend_selection_for_state(
|
|
354
|
+
&selected.run_workspace,
|
|
355
|
+
&selected.state,
|
|
356
|
+
),
|
|
357
|
+
)?
|
|
358
|
+
.backend;
|
|
359
|
+
let panes = checked(backend.list_targets())?;
|
|
360
|
+
let pane = row.get("pane_id").and_then(Value::as_str);
|
|
361
|
+
let session = selected.state.get("session_name").and_then(Value::as_str);
|
|
362
|
+
let window = row.get("window").and_then(Value::as_str);
|
|
363
|
+
let matches: Vec<_> = panes
|
|
364
|
+
.iter()
|
|
365
|
+
.filter(|p| {
|
|
366
|
+
Some(p.pane_id.as_str()) == pane
|
|
367
|
+
&& p.pane_pid == Some(pid)
|
|
368
|
+
&& Some(p.session.as_str()) == session
|
|
369
|
+
&& p.window_name.as_ref().map(|w| w.as_str()) == window
|
|
370
|
+
})
|
|
371
|
+
.collect();
|
|
372
|
+
if matches.len() != 1 {
|
|
373
|
+
return Ok(Observation::Unknown);
|
|
374
|
+
}
|
|
375
|
+
let Some(session) = session else {
|
|
376
|
+
return Ok(Observation::Unknown);
|
|
377
|
+
};
|
|
378
|
+
let owner = checked(backend.session_owner(&crate::transport::SessionName::new(session)))?;
|
|
379
|
+
let generation = crate::state::projection::session_generation(&selected.state);
|
|
380
|
+
if owner.as_ref().is_none_or(|owner| {
|
|
381
|
+
Path::new(&owner.workspace) != selected.run_workspace
|
|
382
|
+
|| owner.team != selected.team_key
|
|
383
|
+
|| Some(owner.generation.as_str()) != generation
|
|
384
|
+
}) {
|
|
385
|
+
return Ok(Observation::Unknown);
|
|
386
|
+
}
|
|
387
|
+
let command = matches[0].current_command.as_deref().unwrap_or("");
|
|
388
|
+
let positive_command = command == crate::provider::wire::command_name(native)
|
|
389
|
+
|| crate::leader::provider_attribution::command_matches_provider(native, command);
|
|
390
|
+
let positive_surface = if !positive_command && !command.is_empty() {
|
|
391
|
+
let frame = checked(
|
|
392
|
+
backend.observe_native_input(&Target::Pane(PaneId::new(pane.unwrap_or(""))), native),
|
|
393
|
+
)?;
|
|
394
|
+
// Original start/restart supervises the native child with a shell carrier.
|
|
395
|
+
// A shell alone (including its inert post-exit tail) is never Live.
|
|
396
|
+
let mut live_child = false;
|
|
397
|
+
if frame.is_some() && shell_carrier(command) {
|
|
398
|
+
for child in checked(crate::platform::process::process_tree(pid))? {
|
|
399
|
+
if child != pid
|
|
400
|
+
&& matches!(
|
|
401
|
+
checked(crate::platform::process::pid_liveness(child))?,
|
|
402
|
+
crate::platform::process::ProcessLiveness::Live
|
|
403
|
+
)
|
|
404
|
+
{
|
|
405
|
+
live_child = true;
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
surface_liveness(command, frame.is_some(), live_child)
|
|
411
|
+
} else {
|
|
412
|
+
false
|
|
413
|
+
};
|
|
414
|
+
if !positive_command && !positive_surface {
|
|
415
|
+
return Ok(Observation::Unknown);
|
|
416
|
+
}
|
|
417
|
+
#[cfg(unix)]
|
|
418
|
+
if checked(team_agent_contract::host::process::process_birth_identity(
|
|
419
|
+
pid,
|
|
420
|
+
))? != carrier_birth
|
|
421
|
+
{
|
|
422
|
+
return Ok(Observation::Unknown);
|
|
423
|
+
}
|
|
424
|
+
Ok(Observation::Live)
|
|
425
|
+
}
|
|
426
|
+
fn start_args(plan: &Plan, id: &str, patch: RoleConfigPatch) -> StartAgentArgs {
|
|
427
|
+
StartAgentArgs {
|
|
428
|
+
agent: id.into(),
|
|
429
|
+
workspace: plan.scope.workspace.clone(),
|
|
430
|
+
team: plan.scope.team.clone(),
|
|
431
|
+
role_config: patch,
|
|
432
|
+
force: false,
|
|
433
|
+
allow_fresh: plan.intent.session == SessionPolicy::Auto,
|
|
434
|
+
json: true,
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
fn stop_args(plan: &Plan, id: &str) -> StopAgentArgs {
|
|
438
|
+
StopAgentArgs {
|
|
439
|
+
agent: id.into(),
|
|
440
|
+
workspace: plan.scope.workspace.clone(),
|
|
441
|
+
team: plan.scope.team.clone(),
|
|
442
|
+
json: true,
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
fn check_incarnation_cwd(
|
|
446
|
+
selected: &SelectedTeam,
|
|
447
|
+
id: &str,
|
|
448
|
+
row: &Value,
|
|
449
|
+
provider: crate::provider::Provider,
|
|
450
|
+
) -> Result<(), CliError> {
|
|
451
|
+
if let Some(cwd) = row
|
|
452
|
+
.get("spawn_cwd")
|
|
453
|
+
.and_then(Value::as_str)
|
|
454
|
+
.filter(|cwd| !cwd.is_empty())
|
|
455
|
+
{
|
|
456
|
+
let declared_cwd = Path::new(cwd);
|
|
457
|
+
let cwd = checked(declared_cwd.canonicalize())?;
|
|
458
|
+
let inside = cwd.starts_with(&selected.run_workspace);
|
|
459
|
+
#[cfg(unix)]
|
|
460
|
+
let owned_cursor_primary = !inside
|
|
461
|
+
&& provider == crate::provider::Provider::CursorAgent
|
|
462
|
+
&& checked(crate::lifecycle::launch::cursor_mcp_primary_matches_scope(
|
|
463
|
+
&selected.run_workspace,
|
|
464
|
+
&selected.team_key,
|
|
465
|
+
id,
|
|
466
|
+
declared_cwd,
|
|
467
|
+
))?;
|
|
468
|
+
#[cfg(not(unix))]
|
|
469
|
+
let owned_cursor_primary = false;
|
|
470
|
+
if !cwd.is_dir() || !(inside || owned_cursor_primary) {
|
|
471
|
+
return Err(refusal("legacy incarnation cwd is outside the selected workspace; no context or scope fallback"));
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
Ok(())
|
|
475
|
+
}
|
|
476
|
+
fn preflight_start(
|
|
477
|
+
selected: &SelectedTeam,
|
|
478
|
+
id: &str,
|
|
479
|
+
row: &Value,
|
|
480
|
+
session_policy: SessionPolicy,
|
|
481
|
+
) -> Result<(), CliError> {
|
|
482
|
+
#[cfg(unix)]
|
|
483
|
+
if crate::contract_runtime::framework::is_contract(row) {
|
|
484
|
+
use team_agent_contract::contract::types::SeatId;
|
|
485
|
+
let config = checked(crate::contract_runtime::framework::team_config(
|
|
486
|
+
&selected.run_workspace,
|
|
487
|
+
&selected.team_dir,
|
|
488
|
+
&selected.team_key,
|
|
489
|
+
))?
|
|
490
|
+
.ok_or_else(|| refusal("missing native role config"))?;
|
|
491
|
+
let role = config
|
|
492
|
+
.roles
|
|
493
|
+
.iter()
|
|
494
|
+
.find(|r| r.id.as_str() == id)
|
|
495
|
+
.ok_or_else(|| refusal("missing native role"))?;
|
|
496
|
+
checked(crate::contract_runtime::backend::prepare_resume(
|
|
497
|
+
&selected.run_workspace,
|
|
498
|
+
&selected.team_key,
|
|
499
|
+
&checked(SeatId::new(id))?,
|
|
500
|
+
Some(role),
|
|
501
|
+
))?;
|
|
502
|
+
return Ok(());
|
|
503
|
+
}
|
|
504
|
+
let provider = crate::provider::wire::parse_provider(provider(row))
|
|
505
|
+
.ok_or_else(|| refusal("unknown provider before restart"))?;
|
|
506
|
+
for field in ["session_id", "rollout_path", "last_result_at", "spawn_cwd"] {
|
|
507
|
+
if row
|
|
508
|
+
.get(field)
|
|
509
|
+
.is_some_and(|value| !value.is_null() && !value.is_string())
|
|
510
|
+
{
|
|
511
|
+
return Err(refusal(format!("invalid resume metadata: {field}")));
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if crate::lifecycle::restart::classify_first_send_at(
|
|
515
|
+
row.get("first_send_at").unwrap_or(&Value::Null),
|
|
516
|
+
) == crate::lifecycle::FirstSendAtState::Corrupt
|
|
517
|
+
{
|
|
518
|
+
return Err(refusal("invalid first_send_at metadata before stop"));
|
|
519
|
+
}
|
|
520
|
+
let session = row
|
|
521
|
+
.get("session_id")
|
|
522
|
+
.and_then(Value::as_str)
|
|
523
|
+
.filter(|s| !s.is_empty())
|
|
524
|
+
.map(crate::provider::SessionId::new);
|
|
525
|
+
if session_policy == SessionPolicy::Preserve
|
|
526
|
+
&& !crate::lifecycle::restart::restart_agent_never_captured(
|
|
527
|
+
row,
|
|
528
|
+
session.as_ref().map(|sid| sid.as_str()),
|
|
529
|
+
)
|
|
530
|
+
&& session.is_none()
|
|
531
|
+
{
|
|
532
|
+
return Err(refusal(
|
|
533
|
+
"resume_not_ready before stop: captured session is missing for existing task context",
|
|
534
|
+
));
|
|
535
|
+
}
|
|
536
|
+
let rollout = row
|
|
537
|
+
.get("rollout_path")
|
|
538
|
+
.and_then(Value::as_str)
|
|
539
|
+
.filter(|s| !s.is_empty())
|
|
540
|
+
.map(crate::provider::RolloutPath::new);
|
|
541
|
+
if session.is_some()
|
|
542
|
+
&& row
|
|
543
|
+
.get("spawn_cwd")
|
|
544
|
+
.and_then(Value::as_str)
|
|
545
|
+
.is_none_or(|cwd| cwd.is_empty())
|
|
546
|
+
{
|
|
547
|
+
return Err(refusal("resume metadata lacks the scoped spawn cwd"));
|
|
548
|
+
}
|
|
549
|
+
check_incarnation_cwd(selected, id, row, provider)?;
|
|
550
|
+
if row
|
|
551
|
+
.get("task_prompt_delivered")
|
|
552
|
+
.is_some_and(|value| !value.is_null() && !value.is_boolean())
|
|
553
|
+
{
|
|
554
|
+
return Err(refusal("invalid task context metadata before stop"));
|
|
555
|
+
}
|
|
556
|
+
let (exists, checked_paths) = session
|
|
557
|
+
.as_ref()
|
|
558
|
+
.map(|session| {
|
|
559
|
+
crate::lifecycle::restart::surface_resume_backing_probe(
|
|
560
|
+
&selected.run_workspace,
|
|
561
|
+
&AgentId::new(id),
|
|
562
|
+
row,
|
|
563
|
+
provider,
|
|
564
|
+
session,
|
|
565
|
+
rollout.as_ref(),
|
|
566
|
+
)
|
|
567
|
+
})
|
|
568
|
+
.unwrap_or_default();
|
|
569
|
+
let mode = crate::lifecycle::restart::decide_start_mode(
|
|
570
|
+
crate::provider::wire::provider_wire(provider),
|
|
571
|
+
session.as_ref(),
|
|
572
|
+
rollout.as_ref(),
|
|
573
|
+
exists,
|
|
574
|
+
session_policy == SessionPolicy::Auto,
|
|
575
|
+
);
|
|
576
|
+
if mode == crate::lifecycle::StartMode::Noop {
|
|
577
|
+
return Err(refusal(format!("resume_not_ready before stop: {id}; backing_checked={checked_paths:?}; choose --session auto only if fresh fallback is acceptable")));
|
|
578
|
+
}
|
|
579
|
+
Ok(())
|
|
580
|
+
}
|
|
581
|
+
fn add(
|
|
582
|
+
plan: &mut Plan,
|
|
583
|
+
selected: &SelectedTeam,
|
|
584
|
+
id: &str,
|
|
585
|
+
source: Option<PathBuf>,
|
|
586
|
+
) -> Result<(), CliError> {
|
|
587
|
+
if selected
|
|
588
|
+
.state
|
|
589
|
+
.get("agent_lifecycle")
|
|
590
|
+
.and_then(|lifecycle| lifecycle.get(id))
|
|
591
|
+
.is_some()
|
|
592
|
+
{
|
|
593
|
+
return Err(refusal(
|
|
594
|
+
"agent lifecycle history is not an absent role; use explicit recovery/add",
|
|
595
|
+
));
|
|
596
|
+
}
|
|
597
|
+
let patch = plan.intent.patch.clone();
|
|
598
|
+
let role_file = match source {
|
|
599
|
+
Some(path) => {
|
|
600
|
+
let (meta, _) = checked(crate::compiler::read_front_matter(&path))?;
|
|
601
|
+
if (meta.get("provider").and_then(Yaml::as_str).is_none() && patch.provider.is_none())
|
|
602
|
+
|| (!matches!(
|
|
603
|
+
meta.get("dangerously_skip_permissions"),
|
|
604
|
+
Some(Yaml::Bool(_))
|
|
605
|
+
) && patch.bypass.is_none())
|
|
606
|
+
{
|
|
607
|
+
return Err(usage("role creation requires explicit provider and dangerously_skip_permissions decisions"));
|
|
608
|
+
}
|
|
609
|
+
let (_, compiled) = role(plan, selected, id, Some(&path))?;
|
|
610
|
+
#[cfg(unix)]
|
|
611
|
+
if crate::contract_runtime::registry::descriptor(provider(&compiled)).is_some() {
|
|
612
|
+
return Err(refusal(
|
|
613
|
+
"native dynamic add is not admitted; no legacy fallback",
|
|
614
|
+
));
|
|
615
|
+
}
|
|
616
|
+
path.to_string_lossy().into_owned()
|
|
617
|
+
}
|
|
618
|
+
None => {
|
|
619
|
+
if patch.provider.is_none() || patch.bypass.is_none() {
|
|
620
|
+
return Err(usage(
|
|
621
|
+
"unknown agent: supply a complete --role-file or --provider and --bypass",
|
|
622
|
+
));
|
|
623
|
+
}
|
|
624
|
+
#[cfg(unix)]
|
|
625
|
+
if patch
|
|
626
|
+
.provider
|
|
627
|
+
.as_deref()
|
|
628
|
+
.is_some_and(|p| crate::contract_runtime::registry::descriptor(p).is_some())
|
|
629
|
+
{
|
|
630
|
+
return Err(refusal(
|
|
631
|
+
"native dynamic add is not admitted; no legacy fallback",
|
|
632
|
+
));
|
|
633
|
+
}
|
|
634
|
+
String::new()
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
plan.steps.push(Step::AddAgent(AddAgentArgs {
|
|
638
|
+
agent: id.into(),
|
|
639
|
+
workspace: plan.scope.workspace.clone(),
|
|
640
|
+
team: plan.scope.team.clone(),
|
|
641
|
+
role_config: patch,
|
|
642
|
+
role_file,
|
|
643
|
+
force: false,
|
|
644
|
+
json: true,
|
|
645
|
+
}));
|
|
646
|
+
Ok(())
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
pub(super) fn plan(intent: Intent) -> Result<Plan, CliError> {
|
|
650
|
+
plan_with_observer(intent, observe)
|
|
651
|
+
}
|
|
652
|
+
fn no_eligible(mut plan: Plan, reason: &'static str) -> Plan {
|
|
653
|
+
plan.steps.clear();
|
|
654
|
+
plan.no_eligible = Some(reason);
|
|
655
|
+
plan
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
fn plan_with_observer(
|
|
659
|
+
intent: Intent,
|
|
660
|
+
observe: impl Fn(&SelectedTeam, &str, &Value) -> Result<Observation, CliError>,
|
|
661
|
+
) -> Result<Plan, CliError> {
|
|
662
|
+
let mut plan = Plan {
|
|
663
|
+
scope: ResolvedScope {
|
|
664
|
+
workspace: intent.workspace.clone(),
|
|
665
|
+
team: intent.team.clone(),
|
|
666
|
+
},
|
|
667
|
+
intent,
|
|
668
|
+
steps: vec![],
|
|
669
|
+
skipped: vec![],
|
|
670
|
+
no_eligible: None,
|
|
671
|
+
files: vec![],
|
|
672
|
+
expected: BTreeMap::new(),
|
|
673
|
+
topology: None,
|
|
674
|
+
};
|
|
675
|
+
let runtime_workspace = if plan.intent.pinned_workspace {
|
|
676
|
+
plan.scope.workspace.clone()
|
|
677
|
+
} else {
|
|
678
|
+
checked(crate::model::paths::canonical_run_workspace(
|
|
679
|
+
&plan.scope.workspace,
|
|
680
|
+
))?
|
|
681
|
+
};
|
|
682
|
+
let has_state = crate::state::persist::runtime_state_path(&runtime_workspace).is_file();
|
|
683
|
+
if has_state {
|
|
684
|
+
plan.scope.workspace = runtime_workspace;
|
|
685
|
+
}
|
|
686
|
+
if !has_state {
|
|
687
|
+
let runtime = plan.scope.workspace.join(".team/runtime");
|
|
688
|
+
let mut spec_only = plan.scope.workspace.join("team.spec.yaml").is_file();
|
|
689
|
+
if runtime.is_dir() {
|
|
690
|
+
for entry in std::fs::read_dir(runtime)? {
|
|
691
|
+
spec_only |= entry?.path().join("team.spec.yaml").is_file();
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
if spec_only {
|
|
695
|
+
return Err(refusal("runtime spec exists without its state; select the owning workspace or repair it explicitly"));
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
let raw = if has_state {
|
|
699
|
+
Some(crate::state::persist::load_runtime_state_without_migrations(&plan.scope.workspace)?)
|
|
700
|
+
} else {
|
|
701
|
+
None
|
|
702
|
+
};
|
|
703
|
+
if raw.as_ref().is_some_and(|state| {
|
|
704
|
+
!state.is_object() || state.get("teams").is_some_and(|teams| !teams.is_object())
|
|
705
|
+
}) {
|
|
706
|
+
return Err(refusal(
|
|
707
|
+
"invalid runtime state; creation is not a repair fallback",
|
|
708
|
+
));
|
|
709
|
+
}
|
|
710
|
+
let new_named_team =
|
|
711
|
+
raw.as_ref()
|
|
712
|
+
.zip(plan.intent.team.as_deref())
|
|
713
|
+
.is_some_and(|(raw, team)| {
|
|
714
|
+
team != "current"
|
|
715
|
+
&& !crate::lifecycle::launch::surface_has_runtime_team(raw, team)
|
|
716
|
+
&& !(crate::state::projection::team_state_key(raw) == team
|
|
717
|
+
&& crate::lifecycle::launch::surface_is_leader_bootstrap(raw))
|
|
718
|
+
&& !raw
|
|
719
|
+
.get("teams")
|
|
720
|
+
.and_then(|t| t.get(team))
|
|
721
|
+
.is_some_and(crate::lifecycle::launch::surface_is_leader_bootstrap)
|
|
722
|
+
});
|
|
723
|
+
let selected = if has_state && !new_named_team {
|
|
724
|
+
Some(resolve_active_team_readonly(
|
|
725
|
+
&plan.scope.workspace,
|
|
726
|
+
plan.intent.team.as_deref(),
|
|
727
|
+
SelectorMode::RuntimeOnly,
|
|
728
|
+
)?)
|
|
729
|
+
} else {
|
|
730
|
+
None
|
|
731
|
+
};
|
|
732
|
+
let initialized = selected
|
|
733
|
+
.as_ref()
|
|
734
|
+
.is_some_and(|s| crate::lifecycle::launch::surface_has_runtime_team(&s.state, &s.team_key));
|
|
735
|
+
if !initialized {
|
|
736
|
+
if plan.intent.verb != Verb::Start || !matches!(plan.intent.subject, Subject::Team { .. }) {
|
|
737
|
+
return Err(refusal("no initialized team; use start --from TEAMDIR"));
|
|
738
|
+
}
|
|
739
|
+
let explicit = match &plan.intent.subject {
|
|
740
|
+
Subject::Team { source } => source.as_deref(),
|
|
741
|
+
_ => None,
|
|
742
|
+
};
|
|
743
|
+
if new_named_team && explicit.is_none() {
|
|
744
|
+
return Err(usage(
|
|
745
|
+
"new --team requires explicit --from; existing runtime is not a creation source",
|
|
746
|
+
));
|
|
747
|
+
}
|
|
748
|
+
let source = discover(&plan.scope.workspace, explicit, None)?;
|
|
749
|
+
let compiled = checked(crate::compiler::compile_team(&source))?;
|
|
750
|
+
validate_roster(&compiled, &source)?;
|
|
751
|
+
let create = QuickStartArgs {
|
|
752
|
+
workspace: plan.scope.workspace.clone(),
|
|
753
|
+
agents_dir: source.clone(),
|
|
754
|
+
name: None,
|
|
755
|
+
team_id: plan.intent.team.clone(),
|
|
756
|
+
yes: false,
|
|
757
|
+
json: true,
|
|
758
|
+
detail: true,
|
|
759
|
+
backend: None,
|
|
760
|
+
};
|
|
761
|
+
#[cfg(unix)]
|
|
762
|
+
if let Some(config) = checked(crate::contract_runtime::config::read_team(&create))? {
|
|
763
|
+
if config.roles.len() == config.members.len()
|
|
764
|
+
&& config.roles.iter().all(|role| role.paused)
|
|
765
|
+
{
|
|
766
|
+
plan.skipped.extend(
|
|
767
|
+
config
|
|
768
|
+
.roles
|
|
769
|
+
.iter()
|
|
770
|
+
.map(|role| json!({"agent":role.id.as_str(),"reason":"paused"})),
|
|
771
|
+
);
|
|
772
|
+
return Ok(no_eligible(plan, "all configured native roles are paused"));
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
remember(&mut plan, &source.join("TEAM.md"))?;
|
|
776
|
+
for entry in std::fs::read_dir(source.join("agents"))? {
|
|
777
|
+
let path = entry?.path();
|
|
778
|
+
if path.extension().is_some_and(|s| s == "md") {
|
|
779
|
+
remember(&mut plan, &path)?;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
plan.steps.push(Step::CreateTeam(create));
|
|
783
|
+
return Ok(plan);
|
|
784
|
+
}
|
|
785
|
+
let selected = selected.ok_or_else(|| refusal("missing selected runtime"))?;
|
|
786
|
+
plan.scope = ResolvedScope {
|
|
787
|
+
workspace: selected.run_workspace.clone(),
|
|
788
|
+
team: Some(selected.team_key.clone()),
|
|
789
|
+
};
|
|
790
|
+
plan.topology = Some(topology(&selected.state));
|
|
791
|
+
if let Subject::Team {
|
|
792
|
+
source: Some(source),
|
|
793
|
+
} = &plan.intent.subject
|
|
794
|
+
{
|
|
795
|
+
if source.canonicalize()? != selected.team_dir.canonicalize()? {
|
|
796
|
+
return Err(refusal("source differs from selected team's role source"));
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
let target = match &plan.intent.subject {
|
|
800
|
+
Subject::Agent(id) => Some(id.clone()),
|
|
801
|
+
_ => None,
|
|
802
|
+
};
|
|
803
|
+
checked(crate::lifecycle::launch::ensure_owner_allowed_for_state(
|
|
804
|
+
&selected.state,
|
|
805
|
+
target.as_ref(),
|
|
806
|
+
))?;
|
|
807
|
+
if plan.intent.verb == Verb::Stop && target.is_none() {
|
|
808
|
+
plan.steps.push(Step::Shutdown(ShutdownArgs {
|
|
809
|
+
workspace: plan.scope.workspace.clone(),
|
|
810
|
+
team: plan.scope.team.clone(),
|
|
811
|
+
keep_logs: true,
|
|
812
|
+
json: true,
|
|
813
|
+
}));
|
|
814
|
+
return Ok(plan);
|
|
815
|
+
}
|
|
816
|
+
let spec = selected.spec_path.as_ref().filter(|p| p.is_file());
|
|
817
|
+
if spec.is_none() {
|
|
818
|
+
if plan.intent.verb == Verb::Restart && target.is_none() {
|
|
819
|
+
// Original active restart owns spec reconstruction and pre-destroy validation.
|
|
820
|
+
let rows = selected
|
|
821
|
+
.state
|
|
822
|
+
.get("agents")
|
|
823
|
+
.and_then(Value::as_object)
|
|
824
|
+
.ok_or_else(|| refusal("missing agents"))?;
|
|
825
|
+
let mut active = false;
|
|
826
|
+
for (id, row) in rows {
|
|
827
|
+
if !paused(row)
|
|
828
|
+
&& !crate::state::projection::agent_explicit_stop(row)
|
|
829
|
+
&& !crate::state::projection::agent_stopped_for_restart(row)
|
|
830
|
+
{
|
|
831
|
+
let obs = observe(&selected, id, row)?;
|
|
832
|
+
if obs == Observation::Unknown {
|
|
833
|
+
return Err(refusal("unknown physical identity"));
|
|
834
|
+
}
|
|
835
|
+
active = true;
|
|
836
|
+
} else {
|
|
837
|
+
plan.skipped.push(json!({"agent":id,"reason":if paused(row){"paused"}else if crate::state::projection::agent_explicit_stop(row){"explicit_stop"}else{"stopped_preserved_by_original_restart"}}));
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if !active {
|
|
841
|
+
return Ok(no_eligible(
|
|
842
|
+
plan,
|
|
843
|
+
"missing spec cannot be rebuilt without an active roster",
|
|
844
|
+
));
|
|
845
|
+
}
|
|
846
|
+
plan.steps.push(Step::RestartTeam(restart_args(&plan)));
|
|
847
|
+
return Ok(plan);
|
|
848
|
+
}
|
|
849
|
+
return Err(refusal(
|
|
850
|
+
"runtime spec missing; use explicit team restart to request supported reconstruction",
|
|
851
|
+
));
|
|
852
|
+
}
|
|
853
|
+
let spec = spec.ok_or_else(|| refusal("missing spec"))?;
|
|
854
|
+
let spec_value = checked(yaml::loads(&std::fs::read_to_string(spec)?))?;
|
|
855
|
+
validate_roster(
|
|
856
|
+
&spec_value,
|
|
857
|
+
spec.parent().unwrap_or(&selected.run_workspace),
|
|
858
|
+
)?;
|
|
859
|
+
let spec_ids: BTreeSet<String> = spec_value
|
|
860
|
+
.get("agents")
|
|
861
|
+
.and_then(Yaml::as_list)
|
|
862
|
+
.ok_or_else(|| refusal("invalid runtime roster"))?
|
|
863
|
+
.iter()
|
|
864
|
+
.map(|a| {
|
|
865
|
+
a.get("id")
|
|
866
|
+
.and_then(Yaml::as_str)
|
|
867
|
+
.map(str::to_string)
|
|
868
|
+
.ok_or_else(|| refusal("invalid runtime role ID"))
|
|
869
|
+
})
|
|
870
|
+
.collect::<Result<_, _>>()?;
|
|
871
|
+
let rows = selected
|
|
872
|
+
.state
|
|
873
|
+
.get("agents")
|
|
874
|
+
.and_then(Value::as_object)
|
|
875
|
+
.ok_or_else(|| refusal("missing runtime agents"))?;
|
|
876
|
+
let ids = if let Some(id) = target.as_ref() {
|
|
877
|
+
vec![id.as_str().to_owned()]
|
|
878
|
+
} else {
|
|
879
|
+
let source = discover(&plan.scope.workspace, None, Some(&selected))?;
|
|
880
|
+
let compiled = checked(crate::compiler::compile_team(&source))?;
|
|
881
|
+
validate_roster(&compiled, &source)?;
|
|
882
|
+
let mut ids: BTreeSet<String> = rows.keys().cloned().collect();
|
|
883
|
+
if let Some(agents) = compiled.get("agents").and_then(Yaml::as_list) {
|
|
884
|
+
for agent in agents {
|
|
885
|
+
if let Some(id) = agent.get("id").and_then(Yaml::as_str) {
|
|
886
|
+
ids.insert(id.into());
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
for id in &spec_ids {
|
|
891
|
+
if !rows.contains_key(id) {
|
|
892
|
+
return Err(refusal(format!("SpecOnly {id}: state/spec mismatch")));
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
ids.into_iter().collect()
|
|
896
|
+
};
|
|
897
|
+
let team = target.is_none();
|
|
898
|
+
let mut observations = Vec::new();
|
|
899
|
+
for id in ids {
|
|
900
|
+
let row = rows.get(&id);
|
|
901
|
+
if row.is_some() != spec_ids.contains(&id) {
|
|
902
|
+
return Err(refusal(format!("state/spec mismatch for {id}")));
|
|
903
|
+
}
|
|
904
|
+
let Some(row) = row else {
|
|
905
|
+
if team
|
|
906
|
+
&& selected
|
|
907
|
+
.state
|
|
908
|
+
.pointer(&format!("/agent_lifecycle/{id}/state"))
|
|
909
|
+
.and_then(Value::as_str)
|
|
910
|
+
== Some("retired")
|
|
911
|
+
{
|
|
912
|
+
plan.skipped.push(json!({"agent":id,"reason":"retired"}));
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
if plan.intent.verb != Verb::Start {
|
|
916
|
+
return Err(refusal("restart/stop never implicitly add agents"));
|
|
917
|
+
}
|
|
918
|
+
let source = plan.intent.role_file.clone().or_else(|| {
|
|
919
|
+
let p = selected.team_dir.join("agents").join(format!("{id}.md"));
|
|
920
|
+
p.is_file().then_some(p)
|
|
921
|
+
});
|
|
922
|
+
add(&mut plan, &selected, &id, source)?;
|
|
923
|
+
continue;
|
|
924
|
+
};
|
|
925
|
+
if let Some(source) = plan.intent.role_file.as_ref() {
|
|
926
|
+
let expected = checked(crate::lifecycle::launch::role_source::resolve_role_source(
|
|
927
|
+
&selected.run_workspace,
|
|
928
|
+
&selected.team_dir,
|
|
929
|
+
&selected.state,
|
|
930
|
+
&AgentId::new(&id),
|
|
931
|
+
))?;
|
|
932
|
+
if source.canonicalize()? != expected.canonicalize()? {
|
|
933
|
+
return Err(refusal("registered agent cannot replace its role source"));
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
let explicit = crate::state::projection::agent_explicit_stop(row);
|
|
937
|
+
let obs = if paused(row) || (team && plan.intent.verb == Verb::Restart && explicit) {
|
|
938
|
+
Observation::Unstarted
|
|
939
|
+
} else {
|
|
940
|
+
observe(&selected, &id, row)?
|
|
941
|
+
};
|
|
942
|
+
let actions = seat_actions(
|
|
943
|
+
plan.intent.verb,
|
|
944
|
+
obs,
|
|
945
|
+
paused(row),
|
|
946
|
+
explicit,
|
|
947
|
+
plan.intent.patch != RoleConfigPatch::default() || plan.intent.role_file.is_some(),
|
|
948
|
+
team,
|
|
949
|
+
)?;
|
|
950
|
+
if actions.is_empty() {
|
|
951
|
+
if obs == Observation::Live && plan.intent.verb == Verb::Start {
|
|
952
|
+
let (_, compiled) = role(&mut plan, &selected, &id, None)?;
|
|
953
|
+
check_native_noop_settings(&selected, &id, row)?;
|
|
954
|
+
if drift(row, &compiled) {
|
|
955
|
+
return Err(refusal(format!(
|
|
956
|
+
"known launch configuration drift for running {id}; stop before applying"
|
|
957
|
+
)));
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
plan.skipped.push(json!({"agent":id,"reason":if paused(row){"paused"}else if team&&plan.intent.verb==Verb::Restart&&explicit{"explicit_stop"}else if obs==Observation::Live{"already_running"}else{"already_stopped"},"observation":obs}));
|
|
961
|
+
} else {
|
|
962
|
+
if plan.intent.verb != Verb::Stop {
|
|
963
|
+
let (_, compiled) = role(&mut plan, &selected, &id, None)?;
|
|
964
|
+
if !same_provider(provider(row), provider(&compiled)) {
|
|
965
|
+
return Err(refusal("registered provider cannot change"));
|
|
966
|
+
}
|
|
967
|
+
preflight_start(&selected, &id, row, plan.intent.session)?;
|
|
968
|
+
}
|
|
969
|
+
for action in actions {
|
|
970
|
+
match action {
|
|
971
|
+
"stop" => plan.steps.push(Step::StopAgent(stop_args(&plan, &id))),
|
|
972
|
+
_ => plan.steps.push(Step::StartAgent(start_args(
|
|
973
|
+
&plan,
|
|
974
|
+
&id,
|
|
975
|
+
plan.intent.patch.clone(),
|
|
976
|
+
))),
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
let mut expected = relevant(row);
|
|
981
|
+
#[cfg(unix)]
|
|
982
|
+
if obs == Observation::Live {
|
|
983
|
+
if let Some(pid) = row
|
|
984
|
+
.get("pane_pid")
|
|
985
|
+
.and_then(Value::as_u64)
|
|
986
|
+
.and_then(|p| u32::try_from(p).ok())
|
|
987
|
+
{
|
|
988
|
+
expected["_birth"] = json!(checked(
|
|
989
|
+
team_agent_contract::host::process::process_birth_identity(pid)
|
|
990
|
+
)?);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
plan.expected.insert(id.clone(), expected);
|
|
994
|
+
observations.push((id, obs, explicit, paused(row)));
|
|
995
|
+
}
|
|
996
|
+
if team && plan.intent.verb == Verb::Restart {
|
|
997
|
+
let eligible: Vec<_> = observations
|
|
998
|
+
.iter()
|
|
999
|
+
.filter(|(_, _, explicit, paused)| !explicit && !paused)
|
|
1000
|
+
.collect();
|
|
1001
|
+
if eligible.is_empty() {
|
|
1002
|
+
return Ok(no_eligible(
|
|
1003
|
+
plan,
|
|
1004
|
+
"use team start to explicitly activate a nonpaused roster",
|
|
1005
|
+
));
|
|
1006
|
+
}
|
|
1007
|
+
let shutdown = selected.state.get("status").and_then(Value::as_str) == Some("shutdown");
|
|
1008
|
+
if !shutdown
|
|
1009
|
+
&& !eligible.iter().any(|(id, _, _, _)| {
|
|
1010
|
+
rows.get(id.as_str())
|
|
1011
|
+
.is_some_and(|row| !crate::state::projection::agent_stopped_for_restart(row))
|
|
1012
|
+
})
|
|
1013
|
+
{
|
|
1014
|
+
plan.skipped.extend(eligible.iter().map(|(id,obs,_,_)| json!({"agent":id,"observation":obs,"reason":"stopped_preserved_by_original_restart"})));
|
|
1015
|
+
return Ok(no_eligible(
|
|
1016
|
+
plan,
|
|
1017
|
+
"stopped roster requires explicit team start",
|
|
1018
|
+
));
|
|
1019
|
+
}
|
|
1020
|
+
if !shutdown
|
|
1021
|
+
|| eligible
|
|
1022
|
+
.iter()
|
|
1023
|
+
.any(|(_, obs, _, _)| *obs == Observation::Live)
|
|
1024
|
+
{
|
|
1025
|
+
for (id, obs, _, _) in &eligible {
|
|
1026
|
+
if rows
|
|
1027
|
+
.get(id.as_str())
|
|
1028
|
+
.is_some_and(crate::state::projection::agent_stopped_for_restart)
|
|
1029
|
+
{
|
|
1030
|
+
plan.skipped.push(json!({"agent":id,"observation":obs,"reason":"stopped_preserved_by_original_restart"}));
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
plan.steps.clear();
|
|
1034
|
+
plan.steps.push(Step::RestartTeam(restart_args(&plan)));
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
if team
|
|
1038
|
+
&& plan.steps.is_empty()
|
|
1039
|
+
&& !plan
|
|
1040
|
+
.skipped
|
|
1041
|
+
.iter()
|
|
1042
|
+
.any(|s| s.get("reason").and_then(Value::as_str) == Some("already_running"))
|
|
1043
|
+
{
|
|
1044
|
+
return Ok(no_eligible(plan, "no eligible nonpaused roster"));
|
|
1045
|
+
}
|
|
1046
|
+
Ok(plan)
|
|
1047
|
+
}
|
|
1048
|
+
fn restart_args(plan: &Plan) -> RestartArgs {
|
|
1049
|
+
RestartArgs {
|
|
1050
|
+
workspace: plan.scope.workspace.clone(),
|
|
1051
|
+
team: plan.scope.team.clone(),
|
|
1052
|
+
allow_fresh: plan.intent.session == SessionPolicy::Auto,
|
|
1053
|
+
session_converge_deadline_ms: plan.intent.session_converge_deadline_ms,
|
|
1054
|
+
json: true,
|
|
1055
|
+
detail: true,
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
pub(super) fn recheck(plan: &Plan, step: &Step, after_stop: bool) -> Result<(), CliError> {
|
|
1060
|
+
for (path, bytes) in &plan.files {
|
|
1061
|
+
if std::fs::read(path)? != *bytes {
|
|
1062
|
+
return Err(refusal(format!(
|
|
1063
|
+
"role/spec changed since planning: {}",
|
|
1064
|
+
path.display()
|
|
1065
|
+
)));
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
if let Step::CreateTeam(args) = step {
|
|
1069
|
+
let role_dir = args.agents_dir.join("agents");
|
|
1070
|
+
let current: BTreeSet<_> = std::fs::read_dir(&role_dir)?
|
|
1071
|
+
.map(|entry| entry.map(|e| e.path()))
|
|
1072
|
+
.collect::<Result<Vec<_>, _>>()?
|
|
1073
|
+
.into_iter()
|
|
1074
|
+
.filter(|path| path.extension().is_some_and(|s| s == "md"))
|
|
1075
|
+
.collect();
|
|
1076
|
+
let planned: BTreeSet<_> = plan
|
|
1077
|
+
.files
|
|
1078
|
+
.iter()
|
|
1079
|
+
.map(|(path, _)| path)
|
|
1080
|
+
.filter(|path| path.parent() == Some(role_dir.as_path()))
|
|
1081
|
+
.cloned()
|
|
1082
|
+
.collect();
|
|
1083
|
+
if current != planned {
|
|
1084
|
+
return Err(refusal("creation roster changed since planning"));
|
|
1085
|
+
}
|
|
1086
|
+
return Ok(());
|
|
1087
|
+
}
|
|
1088
|
+
let selected = resolve_active_team_readonly(
|
|
1089
|
+
&plan.scope.workspace,
|
|
1090
|
+
plan.scope.team.as_deref(),
|
|
1091
|
+
SelectorMode::RuntimeOnly,
|
|
1092
|
+
)?;
|
|
1093
|
+
if Some(selected.team_key.as_str()) != plan.scope.team.as_deref()
|
|
1094
|
+
|| selected.run_workspace != plan.scope.workspace
|
|
1095
|
+
{
|
|
1096
|
+
return Err(refusal("canonical scope changed"));
|
|
1097
|
+
}
|
|
1098
|
+
if plan
|
|
1099
|
+
.topology
|
|
1100
|
+
.as_ref()
|
|
1101
|
+
.is_some_and(|expected| topology(&selected.state) != *expected)
|
|
1102
|
+
{
|
|
1103
|
+
return Err(refusal("owner or transport binding changed since planning"));
|
|
1104
|
+
}
|
|
1105
|
+
let id = step.agent().map(AgentId::new);
|
|
1106
|
+
checked(crate::lifecycle::launch::ensure_owner_allowed_for_state(
|
|
1107
|
+
&selected.state,
|
|
1108
|
+
id.as_ref(),
|
|
1109
|
+
))?;
|
|
1110
|
+
let targets: Vec<_> = if let Some(id) = step.agent() {
|
|
1111
|
+
vec![id]
|
|
1112
|
+
} else {
|
|
1113
|
+
plan.expected.keys().map(String::as_str).collect()
|
|
1114
|
+
};
|
|
1115
|
+
for id in targets {
|
|
1116
|
+
let row = selected.state.get("agents").and_then(|a| a.get(id));
|
|
1117
|
+
if matches!(step, Step::AddAgent(_)) {
|
|
1118
|
+
if row.is_some() {
|
|
1119
|
+
return Err(refusal("agent was registered after planning"));
|
|
1120
|
+
}
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
let row = row.ok_or_else(|| refusal("planned agent disappeared"))?;
|
|
1124
|
+
let expected = plan
|
|
1125
|
+
.expected
|
|
1126
|
+
.get(id)
|
|
1127
|
+
.ok_or_else(|| refusal("missing original incarnation"))?;
|
|
1128
|
+
if after_stop && plan.intent.verb == Verb::Restart && matches!(step, Step::StartAgent(_)) {
|
|
1129
|
+
if !same_provider(provider(row), provider(expected))
|
|
1130
|
+
|| row.get("generation").unwrap_or(&Value::Null)
|
|
1131
|
+
!= expected.get("generation").unwrap_or(&Value::Null)
|
|
1132
|
+
|| observe(&selected, id, row)? != Observation::StoppedVerified
|
|
1133
|
+
{
|
|
1134
|
+
return Err(refusal(
|
|
1135
|
+
"post-stop incarnation is not the planned stopped agent",
|
|
1136
|
+
));
|
|
1137
|
+
}
|
|
1138
|
+
} else {
|
|
1139
|
+
let mut expected = expected.clone();
|
|
1140
|
+
let birth = expected.as_object_mut().and_then(|o| o.remove("_birth"));
|
|
1141
|
+
if relevant(row) != expected {
|
|
1142
|
+
return Err(refusal("planned agent incarnation changed"));
|
|
1143
|
+
}
|
|
1144
|
+
#[cfg(unix)]
|
|
1145
|
+
if let Some(birth) = birth {
|
|
1146
|
+
let pid = row
|
|
1147
|
+
.get("pane_pid")
|
|
1148
|
+
.and_then(Value::as_u64)
|
|
1149
|
+
.and_then(|p| u32::try_from(p).ok())
|
|
1150
|
+
.ok_or_else(|| refusal("missing original carrier"))?;
|
|
1151
|
+
if json!(checked(
|
|
1152
|
+
team_agent_contract::host::process::process_birth_identity(pid)
|
|
1153
|
+
)?) != birth
|
|
1154
|
+
{
|
|
1155
|
+
return Err(refusal("carrier replaced since planning"));
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
#[cfg(unix)]
|
|
1160
|
+
if !crate::contract_runtime::framework::is_contract(row)
|
|
1161
|
+
&& crate::provider::wire::parse_provider(provider(row))
|
|
1162
|
+
== Some(crate::provider::Provider::CursorAgent)
|
|
1163
|
+
&& (matches!(step, Step::StartAgent(_))
|
|
1164
|
+
|| (plan.intent.verb == Verb::Restart && matches!(step, Step::StopAgent(_)))
|
|
1165
|
+
|| (matches!(step, Step::RestartTeam(_))
|
|
1166
|
+
&& row.get("status").and_then(Value::as_str) == Some("running")
|
|
1167
|
+
&& !crate::state::projection::agent_explicit_stop(row)))
|
|
1168
|
+
{
|
|
1169
|
+
// Reopen the same ownership proof at the existing execution recheck,
|
|
1170
|
+
// before effects; a planning-time receipt is not a filesystem lease.
|
|
1171
|
+
check_incarnation_cwd(&selected, id, row, crate::provider::Provider::CursorAgent)?;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
Ok(())
|
|
1175
|
+
}
|