@team-agent/installer 0.5.118 → 0.5.120
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 +1139 -0
- package/crates/team-agent/src/cli/intent/runtime_tests.rs +584 -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 +49 -5
- 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/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 +10 -3
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +166 -77
- package/crates/team-agent/src/lifecycle/launch.rs +28 -0
- 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 +4 -2
- 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 +4 -3
- 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/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/messaging/delivery.rs +132 -39
- package/crates/team-agent/src/messaging/delivery_consumption_tests.rs +223 -0
- package/crates/team-agent/src/messaging/enter_rescue.rs +182 -154
- package/crates/team-agent/src/messaging/enter_rescue_tests.rs +344 -277
- package/crates/team-agent/src/messaging/mod.rs +1 -1
- package/crates/team-agent/src/tmux_backend/enter_rescue.rs +325 -0
- package/crates/team-agent/src/tmux_backend/enter_rescue_tests.rs +432 -0
- package/crates/team-agent/src/tmux_backend/native_input.rs +19 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +37 -0
- package/crates/team-agent/src/tmux_backend.rs +35 -1
- package/crates/team-agent/src/transport/enter_rescue.rs +166 -0
- package/crates/team-agent/src/transport/native_input.rs +5 -1
- package/crates/team-agent/src/transport/test_support.rs +25 -0
- package/crates/team-agent/src/transport.rs +34 -0
- 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,642 @@
|
|
|
1
|
+
//! Canonical lifecycle surface: pure syntax and finite plans over existing handlers.
|
|
2
|
+
//! Legacy entry points keep their own defaults and receipts.
|
|
3
|
+
use super::*;
|
|
4
|
+
use crate::lifecycle::role_config::RoleConfigPatch;
|
|
5
|
+
use crate::model::ids::AgentId;
|
|
6
|
+
use serde::Serialize;
|
|
7
|
+
use std::collections::BTreeMap;
|
|
8
|
+
use std::path::{Path, PathBuf};
|
|
9
|
+
|
|
10
|
+
mod runtime;
|
|
11
|
+
#[cfg(test)]
|
|
12
|
+
mod tests;
|
|
13
|
+
|
|
14
|
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
|
15
|
+
#[serde(rename_all = "snake_case")]
|
|
16
|
+
pub(super) enum Surface {
|
|
17
|
+
Canonical,
|
|
18
|
+
FrozenLegacy,
|
|
19
|
+
}
|
|
20
|
+
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
21
|
+
pub(super) enum Subject {
|
|
22
|
+
Team { source: Option<PathBuf> },
|
|
23
|
+
Agent(AgentId),
|
|
24
|
+
}
|
|
25
|
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
|
26
|
+
#[serde(rename_all = "snake_case")]
|
|
27
|
+
pub(super) enum SessionPolicy {
|
|
28
|
+
Auto,
|
|
29
|
+
Preserve,
|
|
30
|
+
}
|
|
31
|
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
|
32
|
+
#[serde(rename_all = "snake_case")]
|
|
33
|
+
pub(super) enum Verb {
|
|
34
|
+
Start,
|
|
35
|
+
Stop,
|
|
36
|
+
Restart,
|
|
37
|
+
}
|
|
38
|
+
#[derive(Clone, Debug)]
|
|
39
|
+
pub(super) struct Intent {
|
|
40
|
+
surface: Surface,
|
|
41
|
+
verb: Verb,
|
|
42
|
+
subject: Subject,
|
|
43
|
+
workspace: PathBuf,
|
|
44
|
+
pinned_workspace: bool,
|
|
45
|
+
team: Option<String>,
|
|
46
|
+
session: SessionPolicy,
|
|
47
|
+
session_converge_deadline_ms: Option<u64>,
|
|
48
|
+
dry_run: bool,
|
|
49
|
+
json: bool,
|
|
50
|
+
role_file: Option<PathBuf>,
|
|
51
|
+
patch: RoleConfigPatch,
|
|
52
|
+
}
|
|
53
|
+
#[derive(Clone, Debug, Serialize)]
|
|
54
|
+
pub(super) struct ResolvedScope {
|
|
55
|
+
workspace: PathBuf,
|
|
56
|
+
team: Option<String>,
|
|
57
|
+
}
|
|
58
|
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
|
59
|
+
#[serde(rename_all = "snake_case")]
|
|
60
|
+
pub(super) enum Observation {
|
|
61
|
+
Live,
|
|
62
|
+
StoppedVerified,
|
|
63
|
+
Unstarted,
|
|
64
|
+
Unknown,
|
|
65
|
+
}
|
|
66
|
+
#[derive(Clone, Debug)]
|
|
67
|
+
pub(super) enum Step {
|
|
68
|
+
CreateTeam(QuickStartArgs),
|
|
69
|
+
StartAgent(StartAgentArgs),
|
|
70
|
+
AddAgent(AddAgentArgs),
|
|
71
|
+
StopAgent(StopAgentArgs),
|
|
72
|
+
RestartTeam(RestartArgs),
|
|
73
|
+
Shutdown(ShutdownArgs),
|
|
74
|
+
}
|
|
75
|
+
impl Step {
|
|
76
|
+
fn name(&self) -> &'static str {
|
|
77
|
+
match self {
|
|
78
|
+
Self::CreateTeam(_) => "quick_start",
|
|
79
|
+
Self::StartAgent(_) => "start_agent",
|
|
80
|
+
Self::AddAgent(_) => "add_agent",
|
|
81
|
+
Self::StopAgent(_) => "stop_agent",
|
|
82
|
+
Self::RestartTeam(_) => "restart_team",
|
|
83
|
+
Self::Shutdown(_) => "shutdown",
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
fn agent(&self) -> Option<&str> {
|
|
87
|
+
match self {
|
|
88
|
+
Self::StartAgent(a) => Some(&a.agent),
|
|
89
|
+
Self::AddAgent(a) => Some(&a.agent),
|
|
90
|
+
Self::StopAgent(a) => Some(&a.agent),
|
|
91
|
+
_ => None,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
fn execute(&self) -> Result<CmdResult, CliError> {
|
|
95
|
+
match self {
|
|
96
|
+
Self::CreateTeam(a) => cmd_quick_start(a),
|
|
97
|
+
Self::StartAgent(a) => cmd_start_agent(a),
|
|
98
|
+
Self::AddAgent(a) => cmd_add_agent(a),
|
|
99
|
+
Self::StopAgent(a) => cmd_stop_agent(a),
|
|
100
|
+
Self::RestartTeam(a) => cmd_restart(a),
|
|
101
|
+
Self::Shutdown(a) => cmd_shutdown(a),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
#[derive(Debug)]
|
|
106
|
+
pub(super) struct Plan {
|
|
107
|
+
intent: Intent,
|
|
108
|
+
scope: ResolvedScope,
|
|
109
|
+
steps: Vec<Step>,
|
|
110
|
+
skipped: Vec<Value>,
|
|
111
|
+
no_eligible: Option<&'static str>,
|
|
112
|
+
files: Vec<(PathBuf, Vec<u8>)>,
|
|
113
|
+
expected: BTreeMap<String, Value>,
|
|
114
|
+
topology: Option<Value>,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
pub(super) fn help(command: &str) -> String {
|
|
118
|
+
let (purpose, usage, options, examples, next) = match command {
|
|
119
|
+
"start" => (
|
|
120
|
+
"Start a team or one agent without replacing verified running processes.",
|
|
121
|
+
"[AGENT|./TEAMDIR] [--from TEAMDIR] [--workspace PATH] [--team KEY] [--session preserve|auto] [--dry-run] [--json]",
|
|
122
|
+
"--from selects TEAM.md and agents/; all relative paths use the calling directory.\nAgent IDs such as reviewer.v2 stay agent IDs even when a same-named directory exists.\nTeam start activates the non-paused roster, including explicitly stopped seats.\n--role-file or explicit --provider and --bypass true|false provide new-agent decisions.\n--model, --effort, --prompt and --profile update a stopped agent; stop a running agent first.\nSession defaults to preserve; auto only permits the original legal fresh fallback.\n--dry-run performs no lifecycle action; final locked admission remains deferred.\nAlready running does not claim role-file synchronization or business readiness.",
|
|
123
|
+
" team-agent start --from . --dry-run --json\n team-agent start reviewer --dry-run --json",
|
|
124
|
+
"Use team-agent status and team-agent inbox leader -n 3 for actual replies.",
|
|
125
|
+
),
|
|
126
|
+
"stop" => (
|
|
127
|
+
"Stop the selected team or one registered agent while retaining session associations.",
|
|
128
|
+
"[AGENT] [--workspace PATH] [--team KEY] [--json]",
|
|
129
|
+
"Without an agent, use the original team shutdown and its residual-resource checks.\nAn agent target uses the original single-agent stop.\nUse --workspace for another directory; a positional path is rejected.\nNo reset, force, role edits or automatic takeover is performed.",
|
|
130
|
+
" team-agent stop --workspace . --json\n team-agent stop reviewer --workspace . --json",
|
|
131
|
+
"Use team-agent start to explicitly activate the stopped non-paused roster.",
|
|
132
|
+
),
|
|
133
|
+
"team" => (
|
|
134
|
+
"Select a team lifecycle operation explicitly.",
|
|
135
|
+
"<start|stop|restart|status> [OPTIONS]",
|
|
136
|
+
"start [TEAMDIR] or --from TEAMDIR creates once or starts stopped members.\nstop delegates to the original team shutdown; status keeps the original read-only output.\nrestart defaults to --session auto and preserves paused or explicitly stopped seats.\nLegacy bare or explicit-path restart still defaults to preserve and keeps its old receipt.\n--workspace PATH and --team KEY select the team; no ambiguous directory is guessed.\n--dry-run on start/restart is read-only; native refusals never fall back to legacy.\nauto is not reset and cannot invent an unsupported fresh capability.",
|
|
137
|
+
" team-agent team start . --dry-run --json\n team-agent team restart --workspace . --dry-run --json",
|
|
138
|
+
"Use team-agent team status; start, rather than restart, deliberately activates stopped members.",
|
|
139
|
+
),
|
|
140
|
+
"agent" => (
|
|
141
|
+
"Select an agent lifecycle operation explicitly without guessing a team directory.",
|
|
142
|
+
"<start|stop|restart|add|remove|reset|clone|fork> AGENT [OPTIONS]",
|
|
143
|
+
"--workspace PATH and --team KEY select the team.\nstart keeps verified running processes and can add an absent agent with complete decisions.\nrestart defaults to --session auto; --session preserve keeps the original refusal policy.\nSingle-agent restart checks roles and capabilities before stop, then uses original stop/start.\n--dry-run on start/restart is read-only; role patches are not accepted by restart.\nadd/remove/reset/clone/fork retain the original capability and confirmation flags.\nreset requires --discard-session; fork requires --as NEW_ID; --in-place is not supported.",
|
|
144
|
+
" team-agent agent start reviewer --dry-run --json\n team-agent agent restart reviewer --session preserve --dry-run --json",
|
|
145
|
+
"Inspect team-agent status before using agent start after a partial result; an unknown replacement must not be retried blindly.",
|
|
146
|
+
),
|
|
147
|
+
_ => return super::emit::command_help(Some(command)),
|
|
148
|
+
};
|
|
149
|
+
format!("Purpose:\n{purpose}\nUsage: team-agent {command} {usage}\n\nOptions:\n{options}\n\nExamples:\n{examples}\n\nNext Action:\n{next}")
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
fn usage(message: impl Into<String>) -> CliError {
|
|
153
|
+
CliError::Usage(message.into())
|
|
154
|
+
}
|
|
155
|
+
fn refusal(message: impl Into<String>) -> CliError {
|
|
156
|
+
CliError::Runtime(message.into())
|
|
157
|
+
}
|
|
158
|
+
fn path_syntax(value: &str) -> bool {
|
|
159
|
+
super::emit::explicit_path(value)
|
|
160
|
+
}
|
|
161
|
+
fn logical_agent_id(value: &str) -> Result<AgentId, CliError> {
|
|
162
|
+
if value.is_empty()
|
|
163
|
+
|| value.starts_with('-')
|
|
164
|
+
|| path_syntax(value)
|
|
165
|
+
|| !value
|
|
166
|
+
.chars()
|
|
167
|
+
.all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
|
|
168
|
+
{
|
|
169
|
+
return Err(usage("invalid agent ID"));
|
|
170
|
+
}
|
|
171
|
+
Ok(AgentId::new(value))
|
|
172
|
+
}
|
|
173
|
+
fn existing_path(cwd: &Path, value: &str) -> Result<PathBuf, CliError> {
|
|
174
|
+
let path = Path::new(value);
|
|
175
|
+
let path = if path.is_absolute() {
|
|
176
|
+
path.to_owned()
|
|
177
|
+
} else {
|
|
178
|
+
cwd.join(path)
|
|
179
|
+
};
|
|
180
|
+
path.canonicalize()
|
|
181
|
+
.map_err(|e| usage(format!("invalid explicit path {}: {e}", path.display())))
|
|
182
|
+
}
|
|
183
|
+
fn options(tokens: &[String]) -> (Vec<String>, BTreeMap<String, String>) {
|
|
184
|
+
let mut pos = Vec::new();
|
|
185
|
+
let mut flags = BTreeMap::new();
|
|
186
|
+
let mut literal = false;
|
|
187
|
+
for token in tokens {
|
|
188
|
+
if literal {
|
|
189
|
+
pos.push(token.clone());
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if token == "--" {
|
|
193
|
+
literal = true;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if let Some((key, value)) = token
|
|
197
|
+
.split_once('=')
|
|
198
|
+
.filter(|(key, _)| key.starts_with("--"))
|
|
199
|
+
{
|
|
200
|
+
flags.insert(key.to_owned(), value.to_owned());
|
|
201
|
+
} else if token.starts_with("--") {
|
|
202
|
+
flags.insert(token.clone(), String::new());
|
|
203
|
+
} else {
|
|
204
|
+
pos.push(token.clone());
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
(pos, flags)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/// None deliberately leaves frozen legacy parsing, defaults and output untouched.
|
|
211
|
+
pub(super) fn run(
|
|
212
|
+
command: &str,
|
|
213
|
+
args: &[String],
|
|
214
|
+
cwd: &Path,
|
|
215
|
+
) -> Option<Result<CmdResult, CliError>> {
|
|
216
|
+
if !matches!(command, "start" | "stop" | "restart" | "team" | "agent") {
|
|
217
|
+
return None;
|
|
218
|
+
}
|
|
219
|
+
if command == "restart" {
|
|
220
|
+
// Schema parsing is needed to distinguish a positional from a flag VALUE.
|
|
221
|
+
let checked = match super::arguments::parse("intent-restart", args) {
|
|
222
|
+
Ok(c) => c,
|
|
223
|
+
Err(e) => return Some(Err(e)),
|
|
224
|
+
};
|
|
225
|
+
let (pos, flags) = options(&checked.tokens);
|
|
226
|
+
let surface = if !flags.contains_key("--session")
|
|
227
|
+
&& !flags.contains_key("--dry-run")
|
|
228
|
+
&& pos.first().is_none_or(|p| path_syntax(p))
|
|
229
|
+
{
|
|
230
|
+
Surface::FrozenLegacy
|
|
231
|
+
} else {
|
|
232
|
+
Surface::Canonical
|
|
233
|
+
};
|
|
234
|
+
if surface == Surface::FrozenLegacy {
|
|
235
|
+
return None;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
Some(match run_canonical(command, args, cwd) {
|
|
239
|
+
Err(error) if !matches!(error, CliError::Usage(_)) => {
|
|
240
|
+
// New surfaces use one effect-free refusal envelope. In particular,
|
|
241
|
+
// never reparse a --prompt payload as an error-log workspace.
|
|
242
|
+
Ok(CmdResult::from_json(
|
|
243
|
+
json!({"ok":false,"action":command,"scope":null,"phase":"planning_or_selection","error":crate::redaction::redact_external_text(&error.to_string())}),
|
|
244
|
+
super::arguments::has_flag(command, args, "--json"),
|
|
245
|
+
))
|
|
246
|
+
}
|
|
247
|
+
result => result,
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
fn run_canonical(command: &str, args: &[String], cwd: &Path) -> Result<CmdResult, CliError> {
|
|
251
|
+
let (verb, args, explicit_agent, explicit_team) = if matches!(command, "team" | "agent") {
|
|
252
|
+
if args.is_empty()
|
|
253
|
+
|| args
|
|
254
|
+
.first()
|
|
255
|
+
.is_some_and(|a| matches!(a.as_str(), "--help" | "-h"))
|
|
256
|
+
{
|
|
257
|
+
return Ok(CmdResult::human(help(command)));
|
|
258
|
+
}
|
|
259
|
+
let sub = args[0].as_str();
|
|
260
|
+
if command == "team" && sub == "status" {
|
|
261
|
+
return super::emit::intent_alias("status", &args[1..], cwd);
|
|
262
|
+
}
|
|
263
|
+
if command == "agent" && matches!(sub, "add" | "remove" | "reset" | "clone" | "fork") {
|
|
264
|
+
let original = match sub {
|
|
265
|
+
"add" => "add-agent",
|
|
266
|
+
"remove" => "remove-agent",
|
|
267
|
+
"reset" => "reset-agent",
|
|
268
|
+
"clone" => "clone-agent",
|
|
269
|
+
_ => "fork-agent",
|
|
270
|
+
};
|
|
271
|
+
return super::emit::intent_alias(original, &args[1..], cwd);
|
|
272
|
+
}
|
|
273
|
+
(sub, &args[1..], command == "agent", command == "team")
|
|
274
|
+
} else {
|
|
275
|
+
(command, args, false, false)
|
|
276
|
+
};
|
|
277
|
+
let verb = match verb {
|
|
278
|
+
"start" => Verb::Start,
|
|
279
|
+
"stop" => Verb::Stop,
|
|
280
|
+
"restart" => Verb::Restart,
|
|
281
|
+
_ => return Err(usage("unknown team/agent operation; see --help")),
|
|
282
|
+
};
|
|
283
|
+
let schema = match verb {
|
|
284
|
+
Verb::Start => "intent-start",
|
|
285
|
+
Verb::Stop => "intent-stop",
|
|
286
|
+
Verb::Restart => "intent-restart",
|
|
287
|
+
};
|
|
288
|
+
let checked = super::arguments::parse(schema, args)?;
|
|
289
|
+
if checked.help {
|
|
290
|
+
return Ok(CmdResult::human(help(command)));
|
|
291
|
+
}
|
|
292
|
+
let intent = lifecycle_intent(
|
|
293
|
+
command,
|
|
294
|
+
verb,
|
|
295
|
+
&checked.tokens,
|
|
296
|
+
cwd,
|
|
297
|
+
explicit_agent,
|
|
298
|
+
explicit_team,
|
|
299
|
+
)?;
|
|
300
|
+
let plan = runtime::plan(intent)?;
|
|
301
|
+
execute_plan(plan, &mut |step| step.execute(), &mut runtime::recheck)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
fn lifecycle_intent(
|
|
305
|
+
command: &str,
|
|
306
|
+
verb: Verb,
|
|
307
|
+
tokens: &[String],
|
|
308
|
+
cwd: &Path,
|
|
309
|
+
explicit_agent: bool,
|
|
310
|
+
explicit_team: bool,
|
|
311
|
+
) -> Result<Intent, CliError> {
|
|
312
|
+
let (pos, flags) = options(tokens);
|
|
313
|
+
if verb == Verb::Stop && pos.first().is_some_and(|p| path_syntax(p)) {
|
|
314
|
+
return Err(usage(
|
|
315
|
+
"stop accepts an agent ID; select a workspace with --workspace",
|
|
316
|
+
));
|
|
317
|
+
}
|
|
318
|
+
let source = flags.get("--from").map(String::as_str);
|
|
319
|
+
if source.is_some() && !pos.is_empty() {
|
|
320
|
+
return Err(usage("--from conflicts with a positional target"));
|
|
321
|
+
}
|
|
322
|
+
if explicit_agent && (source.is_some() || pos.first().is_some_and(|p| path_syntax(p))) {
|
|
323
|
+
return Err(usage(
|
|
324
|
+
"agent operation requires an agent ID, not a team source",
|
|
325
|
+
));
|
|
326
|
+
}
|
|
327
|
+
if explicit_agent && pos.is_empty() {
|
|
328
|
+
return Err(usage("missing agent"));
|
|
329
|
+
}
|
|
330
|
+
if explicit_team && verb != Verb::Start && !pos.is_empty() {
|
|
331
|
+
return Err(usage(
|
|
332
|
+
"team stop/restart accepts --workspace, not a positional source",
|
|
333
|
+
));
|
|
334
|
+
}
|
|
335
|
+
let restart_path = (verb == Verb::Restart)
|
|
336
|
+
.then(|| pos.first())
|
|
337
|
+
.flatten()
|
|
338
|
+
.filter(|p| path_syntax(p));
|
|
339
|
+
let team_source = source.or_else(|| {
|
|
340
|
+
pos.first()
|
|
341
|
+
.map(String::as_str)
|
|
342
|
+
.filter(|p| explicit_team || (verb == Verb::Start && path_syntax(p)))
|
|
343
|
+
});
|
|
344
|
+
let subject = if let Some(source) = team_source {
|
|
345
|
+
let source = existing_path(cwd, source)?;
|
|
346
|
+
if !source.is_dir() {
|
|
347
|
+
return Err(usage("team source must be an existing directory"));
|
|
348
|
+
}
|
|
349
|
+
Subject::Team {
|
|
350
|
+
source: Some(source),
|
|
351
|
+
}
|
|
352
|
+
} else if restart_path.is_some() {
|
|
353
|
+
Subject::Team { source: None }
|
|
354
|
+
} else if let Some(id) = pos.first() {
|
|
355
|
+
if verb == Verb::Restart && !explicit_agent && cwd.join(id).is_dir() {
|
|
356
|
+
return Err(usage(
|
|
357
|
+
"ambiguous restart target: use agent restart ID or --workspace PATH",
|
|
358
|
+
));
|
|
359
|
+
}
|
|
360
|
+
Subject::Agent(logical_agent_id(id)?)
|
|
361
|
+
} else {
|
|
362
|
+
Subject::Team { source: None }
|
|
363
|
+
};
|
|
364
|
+
let session = match flags.get("--session").map(String::as_str) {
|
|
365
|
+
Some("preserve") if flags.contains_key("--allow-fresh") => {
|
|
366
|
+
return Err(usage("--allow-fresh conflicts with --session preserve"))
|
|
367
|
+
}
|
|
368
|
+
Some("preserve") => SessionPolicy::Preserve,
|
|
369
|
+
Some("auto") => SessionPolicy::Auto,
|
|
370
|
+
_ if flags.contains_key("--allow-fresh")
|
|
371
|
+
|| (verb == Verb::Restart
|
|
372
|
+
&& (command != "restart" || matches!(subject, Subject::Agent(_)))) =>
|
|
373
|
+
{
|
|
374
|
+
SessionPolicy::Auto
|
|
375
|
+
}
|
|
376
|
+
_ => SessionPolicy::Preserve,
|
|
377
|
+
};
|
|
378
|
+
let path_workspace = restart_path.map(|p| existing_path(cwd, p)).transpose()?;
|
|
379
|
+
let workspace = match flags.get("--workspace") {
|
|
380
|
+
Some(path) => existing_path(cwd, path)?,
|
|
381
|
+
None => match &subject {
|
|
382
|
+
Subject::Team {
|
|
383
|
+
source: Some(source),
|
|
384
|
+
} => super::emit::intent_source_workspace(source, cwd)?,
|
|
385
|
+
_ if path_workspace.is_none()
|
|
386
|
+
&& cwd.join("TEAM.md").is_file()
|
|
387
|
+
&& cwd.join("agents").is_dir() =>
|
|
388
|
+
{
|
|
389
|
+
super::emit::intent_source_workspace(cwd, cwd)?
|
|
390
|
+
}
|
|
391
|
+
_ => path_workspace.clone().unwrap_or(cwd.canonicalize()?),
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
let pinned_workspace = flags.contains_key("--workspace")
|
|
395
|
+
|| path_workspace.is_some()
|
|
396
|
+
|| matches!(&subject, Subject::Team { source: Some(_) })
|
|
397
|
+
|| (cwd.join("TEAM.md").is_file() && cwd.join("agents").is_dir());
|
|
398
|
+
if !workspace.is_dir() {
|
|
399
|
+
return Err(usage("workspace must be an existing directory"));
|
|
400
|
+
}
|
|
401
|
+
if path_workspace.is_some_and(|path| path != workspace) {
|
|
402
|
+
return Err(usage("positional workspace conflicts with --workspace"));
|
|
403
|
+
}
|
|
404
|
+
if matches!(subject, Subject::Agent(_)) && flags.contains_key("--session-converge-deadline") {
|
|
405
|
+
return Err(usage(
|
|
406
|
+
"--session-converge-deadline applies only to team restart",
|
|
407
|
+
));
|
|
408
|
+
}
|
|
409
|
+
let session_converge_deadline_ms = flags
|
|
410
|
+
.get("--session-converge-deadline")
|
|
411
|
+
.map(|value| {
|
|
412
|
+
super::emit::parse_seconds_ms(value).ok_or_else(|| usage("invalid session deadline"))
|
|
413
|
+
})
|
|
414
|
+
.transpose()?;
|
|
415
|
+
let role_file = flags
|
|
416
|
+
.get("--role-file")
|
|
417
|
+
.map(|path| existing_path(cwd, path))
|
|
418
|
+
.transpose()?;
|
|
419
|
+
if role_file.as_ref().is_some_and(|path| !path.is_file()) {
|
|
420
|
+
return Err(usage("--role-file must be an existing regular file"));
|
|
421
|
+
}
|
|
422
|
+
let patch = if verb == Verb::Start {
|
|
423
|
+
let mut role_args = vec!["intent-agent".to_string()];
|
|
424
|
+
for token in tokens {
|
|
425
|
+
if token.split_once('=').is_some_and(|(k, _)| {
|
|
426
|
+
matches!(
|
|
427
|
+
k,
|
|
428
|
+
"--provider" | "--model" | "--effort" | "--bypass" | "--prompt" | "--profile"
|
|
429
|
+
)
|
|
430
|
+
}) {
|
|
431
|
+
role_args.push(token.clone());
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
super::emit::intent_role_patch(&role_args)?
|
|
435
|
+
} else {
|
|
436
|
+
RoleConfigPatch::default()
|
|
437
|
+
};
|
|
438
|
+
if matches!(subject, Subject::Team { .. })
|
|
439
|
+
&& (patch != RoleConfigPatch::default() || role_file.is_some())
|
|
440
|
+
{
|
|
441
|
+
return Err(usage(
|
|
442
|
+
"team start does not apply per-agent role patches; use start AGENT",
|
|
443
|
+
));
|
|
444
|
+
}
|
|
445
|
+
Ok(Intent {
|
|
446
|
+
surface: Surface::Canonical,
|
|
447
|
+
verb,
|
|
448
|
+
subject,
|
|
449
|
+
workspace,
|
|
450
|
+
pinned_workspace,
|
|
451
|
+
team: flags.get("--team").cloned(),
|
|
452
|
+
session,
|
|
453
|
+
session_converge_deadline_ms,
|
|
454
|
+
dry_run: flags.contains_key("--dry-run"),
|
|
455
|
+
json: flags.contains_key("--json"),
|
|
456
|
+
role_file,
|
|
457
|
+
patch,
|
|
458
|
+
})
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
fn execute_plan(
|
|
462
|
+
plan: Plan,
|
|
463
|
+
execute: &mut impl FnMut(&Step) -> Result<CmdResult, CliError>,
|
|
464
|
+
recheck: &mut impl FnMut(&Plan, &Step, bool) -> Result<(), CliError>,
|
|
465
|
+
) -> Result<CmdResult, CliError> {
|
|
466
|
+
if let Some(reason) = plan.no_eligible {
|
|
467
|
+
return Ok(CmdResult::from_json(
|
|
468
|
+
json!({"ok":false,"action":plan.intent.verb,"surface":plan.intent.surface,"scope":plan.scope,"session_policy":plan.intent.session,"dry_run":plan.intent.dry_run,"status":"no_eligible_agents","phase":"planning","error":reason,"steps":[],"skipped":plan.skipped}),
|
|
469
|
+
plan.intent.json,
|
|
470
|
+
));
|
|
471
|
+
}
|
|
472
|
+
let descriptions: Vec<_> = plan
|
|
473
|
+
.steps
|
|
474
|
+
.iter()
|
|
475
|
+
.map(|s| json!({"action":s.name(),"agent":s.agent()}))
|
|
476
|
+
.collect();
|
|
477
|
+
if plan.intent.dry_run {
|
|
478
|
+
return Ok(CmdResult::from_json(
|
|
479
|
+
json!({"ok":true,"action":plan.intent.verb,"surface":plan.intent.surface,"scope":plan.scope,"session_policy":plan.intent.session,"dry_run":true,"steps":descriptions,"skipped":plan.skipped,"deferred":["original lock-held owner/cohort admission","native/session admission not performed by the readonly planner"]}),
|
|
480
|
+
plan.intent.json,
|
|
481
|
+
));
|
|
482
|
+
}
|
|
483
|
+
let mut results = Vec::new();
|
|
484
|
+
let mut actual_scope = plan.scope.clone();
|
|
485
|
+
let mut pending = false;
|
|
486
|
+
let mut ok = true;
|
|
487
|
+
let mut stop_succeeded = false;
|
|
488
|
+
let mut effects = Vec::new();
|
|
489
|
+
let mut partial_effect = false;
|
|
490
|
+
let mut handler_effect_confirmed = false;
|
|
491
|
+
let mut known_no_effect = true;
|
|
492
|
+
let mut refusal_hint = None;
|
|
493
|
+
for step in &plan.steps {
|
|
494
|
+
if let Err(error) = recheck(&plan, step, stop_succeeded) {
|
|
495
|
+
ok = false;
|
|
496
|
+
known_no_effect = false;
|
|
497
|
+
results.push(json!({"action":step.name(),"agent":step.agent(),"ok":false,"phase":"recheck","error":crate::redaction::redact_external_text(&error.to_string())}));
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
let result = execute(step);
|
|
501
|
+
let (success, receipt) = match result {
|
|
502
|
+
Ok(result) => (
|
|
503
|
+
result.exit == ExitCode::Ok,
|
|
504
|
+
match result.output {
|
|
505
|
+
CmdOutput::Json(v) => v,
|
|
506
|
+
CmdOutput::Human(text) => json!({"text":text}),
|
|
507
|
+
CmdOutput::None => Value::Null,
|
|
508
|
+
},
|
|
509
|
+
),
|
|
510
|
+
Err(error) => (
|
|
511
|
+
false,
|
|
512
|
+
json!({"ok":false,"error":crate::redaction::redact_external_text(&error.to_string())}),
|
|
513
|
+
),
|
|
514
|
+
};
|
|
515
|
+
partial_effect |= receipt.get("status").and_then(Value::as_str) == Some("partial");
|
|
516
|
+
known_no_effect &= receipt["status"] == "preflight_blocked"
|
|
517
|
+
&& receipt["effects"]["coordinator_start"] == "not_attempted"
|
|
518
|
+
&& receipt["effects"]["worker_panes_started"]
|
|
519
|
+
.as_array()
|
|
520
|
+
.is_some_and(Vec::is_empty);
|
|
521
|
+
if let Some(effect) = receipt.get("effects") {
|
|
522
|
+
let workers = effect
|
|
523
|
+
.get("worker_panes_started")
|
|
524
|
+
.and_then(Value::as_array)
|
|
525
|
+
.is_some_and(|workers| !workers.is_empty());
|
|
526
|
+
let coordinator = effect.get("coordinator_start").and_then(Value::as_str);
|
|
527
|
+
handler_effect_confirmed |= workers || coordinator == Some("running_confirmed");
|
|
528
|
+
partial_effect |=
|
|
529
|
+
workers || matches!(coordinator, Some("running_confirmed" | "attempted_unknown"));
|
|
530
|
+
effects.push(json!({"action": step.name(), "agent": step.agent(), "effects": effect}));
|
|
531
|
+
}
|
|
532
|
+
let stop_unconfirmed = plan.intent.verb == Verb::Restart
|
|
533
|
+
&& matches!(step, Step::StopAgent(_))
|
|
534
|
+
&& success
|
|
535
|
+
&& receipt.get("stopped").and_then(Value::as_bool) != Some(true);
|
|
536
|
+
let success = success && !stop_unconfirmed;
|
|
537
|
+
if !success {
|
|
538
|
+
refusal_hint = receipt
|
|
539
|
+
.get("recovery")
|
|
540
|
+
.and_then(Value::as_str)
|
|
541
|
+
.or_else(|| {
|
|
542
|
+
receipt
|
|
543
|
+
.get("next_actions")
|
|
544
|
+
.and_then(Value::as_array)
|
|
545
|
+
.and_then(|actions| actions.first())
|
|
546
|
+
.and_then(Value::as_str)
|
|
547
|
+
})
|
|
548
|
+
.map(str::to_string);
|
|
549
|
+
}
|
|
550
|
+
if matches!(step, Step::CreateTeam(_)) {
|
|
551
|
+
if let Some(team) = receipt
|
|
552
|
+
.get("team")
|
|
553
|
+
.and_then(Value::as_str)
|
|
554
|
+
.filter(|team| !team.is_empty())
|
|
555
|
+
{
|
|
556
|
+
actual_scope.team = Some(team.to_string());
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
pending |= success
|
|
560
|
+
&& (receipt.get("ready").and_then(Value::as_bool) == Some(false)
|
|
561
|
+
|| receipt.get("status").and_then(Value::as_str) == Some("pending_tool_load"));
|
|
562
|
+
results.push(json!({"action":step.name(),"agent":step.agent(),"ok":success,"phase":if stop_unconfirmed {"stop_not_confirmed"} else {"handler"},"mode":receipt.get("start_mode").or_else(||receipt.get("mode")).cloned().unwrap_or(json!("unknown")),"receipt":receipt}));
|
|
563
|
+
if !success {
|
|
564
|
+
ok = false;
|
|
565
|
+
if plan.intent.verb != Verb::Start {
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if matches!(step, Step::StopAgent(_)) {
|
|
570
|
+
if !success {
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
stop_succeeded = true;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
let partial = !ok
|
|
577
|
+
&& (stop_succeeded
|
|
578
|
+
|| partial_effect
|
|
579
|
+
|| results
|
|
580
|
+
.iter()
|
|
581
|
+
.any(|result| result.get("ok").and_then(Value::as_bool) == Some(true)));
|
|
582
|
+
let recovery = if partial || (!ok && !known_no_effect) {
|
|
583
|
+
let team = actual_scope
|
|
584
|
+
.team
|
|
585
|
+
.as_deref()
|
|
586
|
+
.map(|team| format!(" --team {}", shell_quote(team)))
|
|
587
|
+
.unwrap_or_default();
|
|
588
|
+
Some(format!("team-agent doctor --workspace {}{team} --json; inspect confirmed/unknown effects before retrying; no automatic rollback is claimed", shell_quote(&actual_scope.workspace.to_string_lossy())))
|
|
589
|
+
} else if !ok {
|
|
590
|
+
refusal_hint
|
|
591
|
+
} else {
|
|
592
|
+
None
|
|
593
|
+
};
|
|
594
|
+
Ok(CmdResult::from_json(
|
|
595
|
+
json!({"ok":ok,"action":plan.intent.verb,"surface":plan.intent.surface,"scope":actual_scope,"session_policy":plan.intent.session,"status":if partial {"partial"} else if !ok {"failed"} else if pending {"pending"} else if results.is_empty() {if plan.intent.verb == Verb::Stop {"already_stopped"} else {"already_running"}} else {"actions_completed"},"steps":results,"skipped":plan.skipped,"effects":effects,"configuration_note":"Running processes were not edited; role body synchronization and business readiness are not claimed.","last_confirmed_effect":if stop_succeeded {Some("original_instance_stopped")} else if handler_effect_confirmed {Some("handler_effects_confirmed")} else {None},"current_state":if !ok {Some(if known_no_effect && !partial {"unchanged"} else {"unknown"})} else {None},"recovery":recovery}),
|
|
596
|
+
plan.intent.json,
|
|
597
|
+
))
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/// Pure registered-seat truth table. Caller supplies physical evidence, not status guesses.
|
|
601
|
+
fn seat_actions(
|
|
602
|
+
verb: Verb,
|
|
603
|
+
observation: Observation,
|
|
604
|
+
paused: bool,
|
|
605
|
+
explicit_stop: bool,
|
|
606
|
+
patch: bool,
|
|
607
|
+
team: bool,
|
|
608
|
+
) -> Result<Vec<&'static str>, CliError> {
|
|
609
|
+
if paused {
|
|
610
|
+
return if team {
|
|
611
|
+
Ok(vec![])
|
|
612
|
+
} else {
|
|
613
|
+
Err(refusal("paused agent: start/restart does not unpause"))
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
if team && verb == Verb::Restart && explicit_stop {
|
|
617
|
+
return Ok(vec![]);
|
|
618
|
+
}
|
|
619
|
+
if verb == Verb::Start && observation == Observation::Live && explicit_stop {
|
|
620
|
+
return Err(refusal("live process contradicts the explicit-stop fence; inspect the selected agent before start"));
|
|
621
|
+
}
|
|
622
|
+
if observation == Observation::Unknown {
|
|
623
|
+
return Err(refusal(
|
|
624
|
+
"unknown physical identity; no lifecycle action performed",
|
|
625
|
+
));
|
|
626
|
+
}
|
|
627
|
+
match (verb, observation) {
|
|
628
|
+
(Verb::Start, Observation::Live) if patch => Err(refusal(
|
|
629
|
+
"running agent: stop it before applying role settings",
|
|
630
|
+
)),
|
|
631
|
+
(Verb::Start, Observation::Live) => Ok(vec![]),
|
|
632
|
+
(Verb::Start, _) => Ok(vec!["start"]),
|
|
633
|
+
(Verb::Restart, Observation::Live) => Ok(vec!["stop", "start"]),
|
|
634
|
+
(Verb::Restart, Observation::StoppedVerified) => Ok(vec!["start"]),
|
|
635
|
+
(Verb::Restart, _) => Err(refusal(
|
|
636
|
+
"restart requires a registered, previously started agent",
|
|
637
|
+
)),
|
|
638
|
+
(Verb::Stop, Observation::StoppedVerified | Observation::Unstarted) => Ok(vec![]),
|
|
639
|
+
(Verb::Stop, Observation::Live) => Ok(vec!["stop"]),
|
|
640
|
+
_ => Err(refusal("unsupported lifecycle observation")),
|
|
641
|
+
}
|
|
642
|
+
}
|