@team-agent/installer 0.5.104 → 0.5.106
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 +5 -3
- package/README.zh.md +4 -3
- package/crates/team-agent/src/cli/adapters.rs +17 -1
- package/crates/team-agent/src/cli/emit.rs +135 -18
- package/crates/team-agent/src/cli/mod.rs +156 -35
- package/crates/team-agent/src/cli/spec.rs +2 -2
- package/crates/team-agent/src/cli/status_port/brief.rs +62 -3
- package/crates/team-agent/src/cli/tests/run_delegation.rs +5 -2
- package/crates/team-agent/src/cli/tests/status_send.rs +2 -2
- package/crates/team-agent/src/cli/types.rs +3 -1
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +6 -2
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +6 -4
- package/crates/team-agent/src/lifecycle/launch/role_source.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +7 -2
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -3
- package/crates/team-agent/src/lifecycle/launch.rs +4 -1
- package/crates/team-agent/src/lifecycle/mod.rs +1 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +75 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +24 -17
- package/crates/team-agent/src/lifecycle/restart.rs +1 -1
- package/crates/team-agent/src/lifecycle/role_config.rs +1123 -0
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +22 -0
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +43 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +71 -0
- package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +6 -2
- package/crates/team-agent/src/state/projection.rs +15 -0
- package/crates/team-agent/src/state/repository.rs +2 -1
- package/crates/team-agent/src/tmux_backend/tests.rs +137 -0
- package/crates/team-agent/src/tmux_backend.rs +110 -0
- package/crates/team-agent/src/transport/test_support.rs +35 -0
- package/crates/team-agent/src/transport/tests/wire.rs +32 -3
- package/crates/team-agent/src/transport.rs +26 -12
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +55 -20
- package/skills/team-agent/references/recovery-runbook.md +2 -2
- package/skills/team-agent/references/team-agent-operator.md +14 -6
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/README.md
CHANGED
|
@@ -82,9 +82,11 @@ Subagents are one-shot, fire-and-forget, and can't talk to each other. Team Agen
|
|
|
82
82
|
|
|
83
83
|
## Status
|
|
84
84
|
|
|
85
|
-
`team-agent status` prints one concise row per registered node with
|
|
86
|
-
`name`, `provider`, `runtime_status`, `activity`, `health`, `session_name`, and `tmux_command`.
|
|
87
|
-
|
|
85
|
+
`team-agent status` prints one concise row per registered node with nine fields:
|
|
86
|
+
`name`, `provider`, `model`, `effort`, `runtime_status`, `activity`, `health`, `session_name`, and `tmux_command`.
|
|
87
|
+
`model` and `effort` are the last accepted launch settings, or `null` when unspecified;
|
|
88
|
+
pending role-file edits and provider defaults are not inferred.
|
|
89
|
+
`--json` exposes the same projection as structured JSON. Missing or conflicting runtime evidence is
|
|
88
90
|
reported as `unknown`; a non-null `tmux_command` is safe to copy for the observed socket,
|
|
89
91
|
session, window, and pane. `--summary` and `--detail` remain accepted for compatibility and
|
|
90
92
|
do not add diagnostic or history fields. The accepted nodeprobe pairing and
|
package/README.zh.md
CHANGED
|
@@ -82,9 +82,10 @@ subagent 是一次性的、干完即焚、彼此不通信。Team Agent 的队友
|
|
|
82
82
|
|
|
83
83
|
## 状态
|
|
84
84
|
|
|
85
|
-
`team-agent status`
|
|
86
|
-
`name`、`provider`、`runtime_status`、`activity`、`health`、`session_name`、`tmux_command`。
|
|
87
|
-
|
|
85
|
+
`team-agent status` 为每个已登记节点输出九个简要字段:
|
|
86
|
+
`name`、`provider`、`model`、`effort`、`runtime_status`、`activity`、`health`、`session_name`、`tmux_command`。
|
|
87
|
+
`model`、`effort` 来自最近已接受的启动配置,未设置时为 `null`;不会把待启动的角色编辑或猜测的 provider 默认值当作已生效。
|
|
88
|
+
`--json` 与人读输出使用同一信息投影。运行态证据缺失或冲突时显示 `unknown`;非空
|
|
88
89
|
`tmux_command` 可复制到已观测的 socket、session、window 和 pane。`--summary` 与
|
|
89
90
|
`--detail` 保留兼容性,但不会增加诊断或历史字段。已验收 nodeprobe 的配对方式、
|
|
90
91
|
operator-trusted 能力 receipt 与 fail-closed 边界见
|
|
@@ -174,7 +174,7 @@ fn append_reminder(text: String, reminder: &str) -> String {
|
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
/// `cmd_status`(`commands.py:90`)。CLI status
|
|
177
|
+
/// `cmd_status`(`commands.py:90`)。CLI status 统一为只读九字段 brief;
|
|
178
178
|
/// `--json` 与人读路径共享 native tmux/process projection;`--summary`/`--detail` 保留参数兼容性但不暴露诊断。
|
|
179
179
|
#[cfg(test)]
|
|
180
180
|
pub(crate) fn cmd_status(args: &StatusArgs) -> Result<CmdResult, CliError> {
|
|
@@ -1100,6 +1100,7 @@ pub fn cmd_start_agent(args: &StartAgentArgs) -> Result<CmdResult, CliError> {
|
|
|
1100
1100
|
args.force,
|
|
1101
1101
|
args.allow_fresh,
|
|
1102
1102
|
args.team.as_deref(),
|
|
1103
|
+
&args.role_config,
|
|
1103
1104
|
)?;
|
|
1104
1105
|
if value.get("agent_ids").is_none() {
|
|
1105
1106
|
if let Some(object) = value.as_object_mut() {
|
|
@@ -1142,6 +1143,7 @@ pub fn cmd_add_agent(args: &AddAgentArgs) -> Result<CmdResult, CliError> {
|
|
|
1142
1143
|
&args.role_file,
|
|
1143
1144
|
args.team.as_deref(),
|
|
1144
1145
|
args.force,
|
|
1146
|
+
&args.role_config,
|
|
1145
1147
|
)?;
|
|
1146
1148
|
if value.get("agent_ids").is_none() {
|
|
1147
1149
|
if let Some(object) = value.as_object_mut() {
|
|
@@ -1638,6 +1640,20 @@ mod tests {
|
|
|
1638
1640
|
);
|
|
1639
1641
|
}
|
|
1640
1642
|
|
|
1643
|
+
#[test]
|
|
1644
|
+
fn reminders_only_recommend_supported_result_commands() {
|
|
1645
|
+
let reminder = crate::cli::QUICK_START_REMINDER;
|
|
1646
|
+
assert!(reminder.contains("Use team-agent status / inbox instead."));
|
|
1647
|
+
for text in [
|
|
1648
|
+
reminder,
|
|
1649
|
+
crate::cli::STATUS_REMINDER,
|
|
1650
|
+
crate::cli::SEND_REMINDER,
|
|
1651
|
+
&quickstart_human(&json!({"summary": "team started"})),
|
|
1652
|
+
] {
|
|
1653
|
+
assert!(!text.contains("collect"), "removed CLI command leaked: {text}");
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1641
1657
|
#[test]
|
|
1642
1658
|
fn e13_quickstart_human_summary_only_when_no_attach() {
|
|
1643
1659
|
let value = json!({"summary": "quick-start complete"});
|
|
@@ -340,15 +340,21 @@ fn command_help(command: Option<&str>) -> String {
|
|
|
340
340
|
)
|
|
341
341
|
.to_string(),
|
|
342
342
|
Some("allow-peer-talk") => "usage: team-agent allow-peer-talk A B [--workspace WORKSPACE] [--json]".to_string(),
|
|
343
|
-
Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]\n\n
|
|
343
|
+
Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]\n\n输出九字段:name/provider/model/effort/runtime_status/activity/health/session_name/tmux_command;人读与 --json 使用同一投影。model/effort 为已接受启动配置,未设置为 null,不猜 provider 默认值。缺少可靠定位或原生 tmux/process 采样时显示 unknown;tmux_command 可复制到对应目标。--summary/--detail 仅保留兼容性,不增加诊断字段。".to_string(),
|
|
344
344
|
Some("models") => "usage: team-agent models [--provider pi|cursor_agent|codex|claude|claude_code] [QUERY|--search TEXT] [--json]\n\nLists exact provider model ids with case-insensitive multi-word search across provider, vendor, id, display name, and source aliases. Uses each provider's native catalog; Codex uses `codex debug models` and Claude uses SDK stream-json initialize.".to_string(),
|
|
345
345
|
Some("leaders") => "usage: team-agent leaders [QUERY|--search TEXT] [--all|--stale] [--json] | --prune [--dry-run] [--json]\n\nLists LIVE leaders by default. Use --all to include retained STALE entries, --stale to inspect only STALE entries, QUERY or --search TEXT to match workspace/team/name fields, and --prune to remove only entries proven terminal by canonical state. --dry-run is valid only with --prune.".to_string(),
|
|
346
346
|
Some("shutdown") => "usage: team-agent shutdown [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]".to_string(),
|
|
347
347
|
Some("restart") => "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json] [--detail]".to_string(),
|
|
348
348
|
Some("reset-agent") => "usage: team-agent reset-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--discard-session] [--json]".to_string(),
|
|
349
|
-
Some("start-agent"
|
|
349
|
+
Some(name @ ("start-agent" | "add-agent")) => command_spec(name)
|
|
350
|
+
.map(|spec| format!(
|
|
351
|
+
"{}\n\n{}\n\nAfter a successful {}, use the returned `send_commands` with `team-agent send AGENT MESSAGE`.",
|
|
352
|
+
spec.usage,
|
|
353
|
+
spec.summary,
|
|
354
|
+
if name == "start-agent" { "start" } else { "add" },
|
|
355
|
+
))
|
|
356
|
+
.unwrap_or_else(|| format!("usage: team-agent {name} [options]")),
|
|
350
357
|
Some("stop-agent") => "usage: team-agent stop-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
|
|
351
|
-
Some("add-agent") => "usage: team-agent add-agent AGENT --role-file FILE [--force] [--workspace WORKSPACE] [--team TEAM] [--json]\n\nAfter a successful add, use the returned `send_commands` with `team-agent send AGENT MESSAGE`.".to_string(),
|
|
352
358
|
Some("clone-agent") => "usage: team-agent clone-agent SOURCE_AGENT --as AGENT [--label LABEL] [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
|
|
353
359
|
Some("fork-agent") => "usage: team-agent fork-agent SOURCE_AGENT --as AGENT [--label LABEL] [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
|
|
354
360
|
Some("remove-agent") => "usage: team-agent remove-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--from-spec] [--confirm] [--force] [--json]".to_string(),
|
|
@@ -1430,9 +1436,107 @@ fn restart_args(args: &[String], cwd: &Path) -> Result<RestartArgs, CliError> {
|
|
|
1430
1436
|
})
|
|
1431
1437
|
}
|
|
1432
1438
|
|
|
1439
|
+
// Keep strict role CLI parsing separate from the legacy shared parser.
|
|
1440
|
+
fn role_agent_args(args: &[String], add: bool) -> Result<(ParsedArgs, crate::lifecycle::role_config::RoleConfigPatch), CliError> {
|
|
1441
|
+
let mut role = crate::lifecycle::role_config::RoleConfigPatch::default();
|
|
1442
|
+
let mut forwarded = Vec::new();
|
|
1443
|
+
let mut seen = std::collections::BTreeSet::new();
|
|
1444
|
+
let mut index = 0;
|
|
1445
|
+
while index < args.len() {
|
|
1446
|
+
let argument = &args[index];
|
|
1447
|
+
if !argument.starts_with('-') {
|
|
1448
|
+
forwarded.push(argument.clone());
|
|
1449
|
+
index += 1;
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
let (flag, inline) = argument.split_once('=').map_or((argument.as_str(), None), |(key, value)| (key, Some(value)));
|
|
1453
|
+
if !seen.insert(flag.to_string()) { return Err(CliError::Usage(format!("duplicate {flag}"))); }
|
|
1454
|
+
if matches!(flag, "--json" | "--allow-fresh" | "--force") {
|
|
1455
|
+
if inline.is_some() || (add && flag == "--allow-fresh") {
|
|
1456
|
+
return Err(CliError::Usage(format!("unsupported option: {argument}")));
|
|
1457
|
+
}
|
|
1458
|
+
forwarded.push(flag.to_string());
|
|
1459
|
+
index += 1;
|
|
1460
|
+
continue;
|
|
1461
|
+
}
|
|
1462
|
+
if !matches!(flag, "--provider" | "--model" | "--effort" | "--bypass" | "--prompt" | "--profile" | "--workspace" | "--team" | "--role-file")
|
|
1463
|
+
|| (!add && flag == "--role-file") {
|
|
1464
|
+
return Err(CliError::Usage(format!("unknown option: {flag}")));
|
|
1465
|
+
}
|
|
1466
|
+
let value = if let Some(value) = inline { value.to_string() } else {
|
|
1467
|
+
index += 1;
|
|
1468
|
+
args.get(index).filter(|value| !value.starts_with("--"))
|
|
1469
|
+
.ok_or_else(|| CliError::Usage(format!("missing value for {flag}")))?.clone()
|
|
1470
|
+
};
|
|
1471
|
+
if value.trim().is_empty() { return Err(CliError::Usage(format!("empty value for {flag}"))); }
|
|
1472
|
+
match flag {
|
|
1473
|
+
"--provider" => {
|
|
1474
|
+
let provider = crate::provider::wire::parse_provider(&value)
|
|
1475
|
+
.ok_or_else(|| CliError::Usage(format!("unknown provider: {value}")))?;
|
|
1476
|
+
role.provider = Some(crate::provider::wire::provider_wire(provider).to_string());
|
|
1477
|
+
}
|
|
1478
|
+
"--bypass" => role.bypass = Some(match value.as_str() {
|
|
1479
|
+
"true" => true, "false" => false,
|
|
1480
|
+
_ => return Err(CliError::Usage("--bypass requires true or false".into())),
|
|
1481
|
+
}),
|
|
1482
|
+
"--model" => role.model = Some(value),
|
|
1483
|
+
"--effort" => {
|
|
1484
|
+
if crate::model::enums::ProviderEffort::parse(&value).is_none() {
|
|
1485
|
+
return Err(CliError::Usage(format!("unknown effort: {value}")));
|
|
1486
|
+
}
|
|
1487
|
+
role.effort = Some(value);
|
|
1488
|
+
}
|
|
1489
|
+
"--prompt" => role.prompt = Some(value),
|
|
1490
|
+
"--profile" => role.profile = Some(value),
|
|
1491
|
+
_ => { forwarded.push(flag.to_string()); forwarded.push(value); }
|
|
1492
|
+
}
|
|
1493
|
+
index += 1;
|
|
1494
|
+
}
|
|
1495
|
+
let parsed = parse_args(&forwarded);
|
|
1496
|
+
if add && parsed.role_file.is_none() {
|
|
1497
|
+
if role.provider.is_none() { return Err(CliError::Usage("add-agent requires --provider <name>".into())); }
|
|
1498
|
+
if role.bypass.is_none() { return Err(CliError::Usage("add-agent requires --bypass <true|false>".into())); }
|
|
1499
|
+
}
|
|
1500
|
+
if parsed.positionals.len() != 1 { return Err(CliError::Usage("expected exactly one agent id".into())); }
|
|
1501
|
+
Ok((parsed, role))
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
#[cfg(test)]
|
|
1505
|
+
mod role_cli_tests {
|
|
1506
|
+
use super::*;
|
|
1507
|
+
fn strings(values: &[&str]) -> Vec<String> { values.iter().map(|value| (*value).to_string()).collect() }
|
|
1508
|
+
|
|
1509
|
+
#[test]
|
|
1510
|
+
fn add_requires_decisions_without_a_template_but_accepts_file_input() {
|
|
1511
|
+
for args in [vec!["w"], vec!["w", "--provider", "pi"], vec!["w", "--bypass", "false"]] {
|
|
1512
|
+
assert!(role_agent_args(&strings(&args), true).is_err());
|
|
1513
|
+
}
|
|
1514
|
+
assert!(role_agent_args(&strings(&["w", "--role-file", "w.md"]), true).is_ok());
|
|
1515
|
+
let (parsed, _) = role_agent_args(&strings(&["w", "--role-file", "w.md", "--force"]), true).unwrap();
|
|
1516
|
+
assert!(parsed.force);
|
|
1517
|
+
assert!(role_agent_args(&strings(&["w", "--role-file", "w.md", "--force=true"]), true).is_err());
|
|
1518
|
+
assert!(role_agent_args(&strings(&["w", "--role-file", "w.md", "--allow-fresh"]), true).is_err());
|
|
1519
|
+
let (_, patch) = role_agent_args(&strings(&["w", "--provider=pi", "--bypass=false"]), true).unwrap();
|
|
1520
|
+
assert_eq!(patch.provider.as_deref(), Some("pi"));
|
|
1521
|
+
assert_eq!(patch.bypass, Some(false));
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
#[test]
|
|
1525
|
+
fn strict_role_options_reject_ambiguous_values() {
|
|
1526
|
+
for args in [
|
|
1527
|
+
vec!["w", "--bypass"], vec!["w", "--bypass", "yes"],
|
|
1528
|
+
vec!["w", "--no-bypass"], vec!["w", "--bypass", "true", "--bypass=false"],
|
|
1529
|
+
vec!["w", "--unknown", "x"], vec!["w", "extra"],
|
|
1530
|
+
] { assert!(role_agent_args(&strings(&args), false).is_err()); }
|
|
1531
|
+
let (_, patch) = role_agent_args(&strings(&["w"]), false).unwrap();
|
|
1532
|
+
assert_eq!(patch, crate::lifecycle::role_config::RoleConfigPatch::default());
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1433
1536
|
fn start_agent_args(args: &[String], cwd: &Path) -> Result<StartAgentArgs, CliError> {
|
|
1434
|
-
let parsed =
|
|
1537
|
+
let (parsed, role_config) = role_agent_args(args, false)?;
|
|
1435
1538
|
Ok(StartAgentArgs {
|
|
1539
|
+
role_config,
|
|
1436
1540
|
agent: required_pos(&parsed, 0, "agent")?,
|
|
1437
1541
|
workspace: workspace(&parsed, cwd),
|
|
1438
1542
|
team: parsed.team,
|
|
@@ -1466,14 +1570,13 @@ fn reset_agent_args(args: &[String], cwd: &Path) -> Result<ResetAgentArgs, CliEr
|
|
|
1466
1570
|
}
|
|
1467
1571
|
|
|
1468
1572
|
fn add_agent_args(args: &[String], cwd: &Path) -> Result<AddAgentArgs, CliError> {
|
|
1469
|
-
let parsed =
|
|
1573
|
+
let (parsed, role_config) = role_agent_args(args, true)?;
|
|
1470
1574
|
Ok(AddAgentArgs {
|
|
1575
|
+
role_config,
|
|
1471
1576
|
agent: required_pos(&parsed, 0, "agent")?,
|
|
1472
1577
|
workspace: workspace(&parsed, cwd),
|
|
1473
1578
|
team: parsed.team,
|
|
1474
|
-
role_file: parsed
|
|
1475
|
-
.role_file
|
|
1476
|
-
.ok_or_else(|| CliError::Usage("missing --role-file".to_string()))?,
|
|
1579
|
+
role_file: parsed.role_file.unwrap_or_default(),
|
|
1477
1580
|
force: parsed.force,
|
|
1478
1581
|
json: parsed.json,
|
|
1479
1582
|
})
|
|
@@ -2076,11 +2179,8 @@ mod tests {
|
|
|
2076
2179
|
(
|
|
2077
2180
|
"start-agent",
|
|
2078
2181
|
&[
|
|
2079
|
-
"--
|
|
2080
|
-
"--team",
|
|
2081
|
-
"--force",
|
|
2082
|
-
"--allow-fresh",
|
|
2083
|
-
"--json",
|
|
2182
|
+
"--model", "--effort", "--bypass", "--prompt", "--profile", "--provider",
|
|
2183
|
+
"--workspace", "--team", "--allow-fresh", "--json",
|
|
2084
2184
|
][..],
|
|
2085
2185
|
),
|
|
2086
2186
|
(
|
|
@@ -2095,10 +2195,8 @@ mod tests {
|
|
|
2095
2195
|
(
|
|
2096
2196
|
"add-agent",
|
|
2097
2197
|
&[
|
|
2098
|
-
"--role-file",
|
|
2099
|
-
"--workspace",
|
|
2100
|
-
"--team",
|
|
2101
|
-
"--json",
|
|
2198
|
+
"--role-file", "--provider", "--bypass", "--model", "--effort", "--prompt", "--profile",
|
|
2199
|
+
"--force", "--workspace", "--team", "--json",
|
|
2102
2200
|
][..],
|
|
2103
2201
|
),
|
|
2104
2202
|
(
|
|
@@ -2185,11 +2283,30 @@ mod tests {
|
|
|
2185
2283
|
);
|
|
2186
2284
|
}
|
|
2187
2285
|
|
|
2286
|
+
#[test]
|
|
2287
|
+
fn worker_lifecycle_help_uses_authoritative_spec_usage_and_summary() {
|
|
2288
|
+
for name in ["start-agent", "add-agent"] {
|
|
2289
|
+
let spec = command_spec(name).expect("registered worker lifecycle command");
|
|
2290
|
+
let help = command_help(Some(name));
|
|
2291
|
+
assert_eq!(help.lines().next(), Some(spec.usage));
|
|
2292
|
+
assert!(help.contains(spec.summary));
|
|
2293
|
+
for flag in ["--model SLUG", "--effort LEVEL", "--bypass true|false", "--prompt TEXT", "--profile NAME", "--provider NAME"] {
|
|
2294
|
+
assert!(help.contains(flag), "{name} help missing {flag}: {help}");
|
|
2295
|
+
}
|
|
2296
|
+
assert!(help.contains("team-agent send AGENT MESSAGE"));
|
|
2297
|
+
}
|
|
2298
|
+
assert!(!command_help(Some("start-agent")).contains("--force"));
|
|
2299
|
+
let add = command_help(Some("add-agent"));
|
|
2300
|
+
assert!(add.contains("[--role-file FILE]"));
|
|
2301
|
+
assert!(add.contains("provider/bypass required from CLI or role file"));
|
|
2302
|
+
assert!(add.contains("conflicting values rejected"));
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2188
2305
|
#[test]
|
|
2189
2306
|
fn status_help_describes_brief_projection_and_unknown_boundary() {
|
|
2190
2307
|
let help = command_help(Some("status"));
|
|
2191
2308
|
for marker in [
|
|
2192
|
-
"name/provider/runtime_status/activity/health/session_name/tmux_command",
|
|
2309
|
+
"name/provider/model/effort/runtime_status/activity/health/session_name/tmux_command",
|
|
2193
2310
|
"人读与 --json 使用同一投影",
|
|
2194
2311
|
"显示 unknown",
|
|
2195
2312
|
"tmux_command",
|
|
@@ -49,7 +49,7 @@ use crate::messaging::{self, MessageTarget, SendOptions, TrustedSender};
|
|
|
49
49
|
use crate::model::ids::{TaskId, TeamKey};
|
|
50
50
|
|
|
51
51
|
pub(crate) const COMMS_BOUNDARY_TEXT: &str = "validates live pane binding consistency and zero-token comms contracts. Does NOT perform live runtime message round-trip. (zero token, zero pollution)";
|
|
52
|
-
pub(crate) const QUICK_START_REMINDER: &str = "Reminder: Do not inspect raw worker pane output during normal operation. Use team-agent status / inbox
|
|
52
|
+
pub(crate) const QUICK_START_REMINDER: &str = "Reminder: Do not inspect raw worker pane output during normal operation. Use team-agent status / inbox instead. Wait for report_result.";
|
|
53
53
|
pub(crate) const SEND_REMINDER: &str = "Message delivered. Wait for the worker to report_result. Do not poll the worker terminal with capture-pane.";
|
|
54
54
|
pub(crate) const STATUS_REMINDER: &str = "Results are finalized automatically; use --watch-result or team-agent inbox AGENT -n 3 for transport fallback. Do not capture-pane worker terminals.";
|
|
55
55
|
|
|
@@ -707,19 +707,7 @@ pub mod lifecycle_port {
|
|
|
707
707
|
}
|
|
708
708
|
|
|
709
709
|
fn state_generation(state: &Value) -> Option<&str> {
|
|
710
|
-
state
|
|
711
|
-
.get("generation")
|
|
712
|
-
.and_then(Value::as_str)
|
|
713
|
-
.or_else(|| {
|
|
714
|
-
state
|
|
715
|
-
.get("agents")
|
|
716
|
-
.and_then(Value::as_object)
|
|
717
|
-
.and_then(|agents| {
|
|
718
|
-
agents.values().find_map(|agent| {
|
|
719
|
-
agent.get("spawned_at").and_then(Value::as_str)
|
|
720
|
-
})
|
|
721
|
-
})
|
|
722
|
-
})
|
|
710
|
+
crate::state::projection::session_generation(state)
|
|
723
711
|
}
|
|
724
712
|
|
|
725
713
|
fn state_for_session<'a>(state: &'a Value, session: &str) -> Option<&'a Value> {
|
|
@@ -777,12 +765,23 @@ pub mod lifecycle_port {
|
|
|
777
765
|
if owner.generation != expected_generation {
|
|
778
766
|
return SessionOwnership::Foreign;
|
|
779
767
|
}
|
|
780
|
-
|
|
781
|
-
target.current_path.as_ref().
|
|
782
|
-
|
|
783
|
-
})
|
|
784
|
-
|
|
785
|
-
|
|
768
|
+
for target in session_targets {
|
|
769
|
+
if target.current_path.as_ref().is_some_and(|path| {
|
|
770
|
+
path_is_under(path.as_path(), workspace)
|
|
771
|
+
}) {
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
// An exited keepalive has no cwd or live process. Exempt only the
|
|
775
|
+
// internal anchor, with native positive dead-pane proof, AFTER the
|
|
776
|
+
// session workspace/team/generation ownership gates above.
|
|
777
|
+
let dead_anchor = target.current_path.as_ref().is_none_or(|path| {
|
|
778
|
+
path.as_os_str().is_empty()
|
|
779
|
+
}) && target.window_name.as_ref().is_some_and(|window| {
|
|
780
|
+
window.as_str() == crate::tmux_backend::KEEPALIVE_WINDOW_NAME
|
|
781
|
+
}) && matches!(transport.pane_is_dead(&target.pane_id), Ok(Some(true)));
|
|
782
|
+
if !dead_anchor {
|
|
783
|
+
return SessionOwnership::Unknown;
|
|
784
|
+
}
|
|
786
785
|
}
|
|
787
786
|
SessionOwnership::Owned
|
|
788
787
|
}
|
|
@@ -793,7 +792,135 @@ pub mod lifecycle_port {
|
|
|
793
792
|
|
|
794
793
|
use super::*;
|
|
795
794
|
use crate::transport::test_support::OfflineTransport;
|
|
796
|
-
use crate::transport::{PaneId, PaneInfo, SessionName};
|
|
795
|
+
use crate::transport::{PaneId, PaneInfo, SessionName, Transport, WindowName};
|
|
796
|
+
|
|
797
|
+
fn ownership_pane(session: &SessionName, pane: &str, window: &str, cwd: Option<PathBuf>) -> PaneInfo {
|
|
798
|
+
PaneInfo {
|
|
799
|
+
pane_id: PaneId::new(pane),
|
|
800
|
+
session: session.clone(),
|
|
801
|
+
window_index: None,
|
|
802
|
+
window_name: Some(WindowName::new(window)),
|
|
803
|
+
pane_index: None,
|
|
804
|
+
tty: None,
|
|
805
|
+
current_command: None,
|
|
806
|
+
current_path: cwd,
|
|
807
|
+
active: false,
|
|
808
|
+
pane_pid: None,
|
|
809
|
+
leader_env: BTreeMap::new(),
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
#[test]
|
|
814
|
+
fn dead_anchor_ownership_requires_native_dead_proof_and_all_owner_markers() {
|
|
815
|
+
let workspace = std::env::temp_dir().canonicalize().unwrap();
|
|
816
|
+
let session = SessionName::new("owned");
|
|
817
|
+
let state = json!({"team_key": "owned", "generation": "g"});
|
|
818
|
+
for (window, cwd, dead, team, generation, expected) in [
|
|
819
|
+
(crate::tmux_backend::KEEPALIVE_WINDOW_NAME, None, Some(true), "owned", "g", SessionOwnership::Owned),
|
|
820
|
+
(crate::tmux_backend::KEEPALIVE_WINDOW_NAME, None, Some(false), "owned", "g", SessionOwnership::Unknown),
|
|
821
|
+
(crate::tmux_backend::KEEPALIVE_WINDOW_NAME, None, None, "owned", "g", SessionOwnership::Unknown),
|
|
822
|
+
("foreign", None, Some(true), "owned", "g", SessionOwnership::Unknown),
|
|
823
|
+
(crate::tmux_backend::KEEPALIVE_WINDOW_NAME, Some(PathBuf::from("/foreign-workspace")), Some(true), "owned", "g", SessionOwnership::Unknown),
|
|
824
|
+
(crate::tmux_backend::KEEPALIVE_WINDOW_NAME, None, Some(true), "other-team", "g", SessionOwnership::Foreign),
|
|
825
|
+
(crate::tmux_backend::KEEPALIVE_WINDOW_NAME, None, Some(true), "owned", "other-generation", SessionOwnership::Foreign),
|
|
826
|
+
] {
|
|
827
|
+
let mut transport = OfflineTransport::new().with_session_owner(&workspace, team, generation);
|
|
828
|
+
if let Some(dead) = dead { transport = transport.with_pane_dead("%1", dead); }
|
|
829
|
+
let targets = vec![
|
|
830
|
+
ownership_pane(&session, "%2", "assistant", Some(workspace.clone())),
|
|
831
|
+
ownership_pane(&session, "%1", window, cwd),
|
|
832
|
+
];
|
|
833
|
+
assert_eq!(session_ownership(&workspace, &state, &transport, &session, &targets), expected);
|
|
834
|
+
if expected == SessionOwnership::Foreign {
|
|
835
|
+
assert!(!transport.calls().contains(&"pane_is_dead"), "owner gates must precede exemption");
|
|
836
|
+
}
|
|
837
|
+
assert!(!transport.calls().contains(&"kill_session"));
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
#[test]
|
|
842
|
+
#[cfg(unix)]
|
|
843
|
+
#[ignore = "isolated native tmux: explicit Grok execution, no provider subscription"]
|
|
844
|
+
fn dead_anchor_scoped_shutdown_reaps_worker_wrapper_and_spares_other_team() {
|
|
845
|
+
let root = std::env::temp_dir().join(format!(
|
|
846
|
+
"ta-r2-{}-{}", std::process::id(), chrono::Utc::now().timestamp_nanos_opt().unwrap(),
|
|
847
|
+
));
|
|
848
|
+
std::fs::create_dir(&root).unwrap();
|
|
849
|
+
let root = root.canonicalize().unwrap();
|
|
850
|
+
let workspace = root.join("owned");
|
|
851
|
+
let foreign_workspace = root.join("foreign");
|
|
852
|
+
std::fs::create_dir(&workspace).unwrap();
|
|
853
|
+
std::fs::create_dir(&foreign_workspace).unwrap();
|
|
854
|
+
let endpoint = root.join("sock");
|
|
855
|
+
let transport = crate::tmux_backend::TmuxBackend::with_runner_for_tmux_endpoint(
|
|
856
|
+
Box::new(crate::tmux_backend::RealCommandRunner), endpoint.to_str().unwrap(),
|
|
857
|
+
);
|
|
858
|
+
let session = SessionName::new("round2-owned");
|
|
859
|
+
let foreign = SessionName::new("round2-foreign");
|
|
860
|
+
struct Cleanup<'a>(&'a crate::tmux_backend::TmuxBackend, [&'a SessionName; 2], &'a Path);
|
|
861
|
+
impl Drop for Cleanup<'_> {
|
|
862
|
+
fn drop(&mut self) {
|
|
863
|
+
for session in self.1 { let _ = self.0.kill_session(session); }
|
|
864
|
+
let _ = std::fs::remove_dir_all(self.2);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
let _cleanup = Cleanup(&transport, [&session, &foreign], &root);
|
|
868
|
+
let env = BTreeMap::new();
|
|
869
|
+
let argv = vec!["sh".to_string(), "-c".to_string(), "exit 0".to_string()];
|
|
870
|
+
let first = transport.spawn_first_with_worker_shell_wrapper(
|
|
871
|
+
&session, &WindowName::new("assistant"), &argv, &workspace, &env, &[], "pi",
|
|
872
|
+
).unwrap();
|
|
873
|
+
transport.set_session_owner_with_generation(&session, &workspace, "round2-owned", "g").unwrap();
|
|
874
|
+
transport.preserve_session_before_stop(&session, &[crate::transport::Target::Pane(first.pane_id.clone())]).unwrap();
|
|
875
|
+
transport.kill_pane(&first.pane_id).unwrap();
|
|
876
|
+
let worker = transport.spawn_into_with_worker_shell_wrapper(
|
|
877
|
+
&session, &WindowName::new("assistant"), &argv, &workspace, &env, &[], "pi",
|
|
878
|
+
).unwrap();
|
|
879
|
+
let foreign_worker = transport.spawn_first(
|
|
880
|
+
&foreign, &WindowName::new("other"), &["sleep".to_string(), "60".to_string()],
|
|
881
|
+
&foreign_workspace, &env,
|
|
882
|
+
).unwrap();
|
|
883
|
+
transport.set_session_owner_with_generation(&foreign, &foreign_workspace, "round2-foreign", "foreign-g").unwrap();
|
|
884
|
+
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
|
|
885
|
+
let marker = format!("{} 0", crate::tmux_backend::worker_provider_exit_marker("pi"));
|
|
886
|
+
loop {
|
|
887
|
+
// This controlled fixture prints nothing: the worker wrapper
|
|
888
|
+
// and its inert tail supply these two lines. provider_exit_status
|
|
889
|
+
// is a leader-only receipt, not a worker-wrapper observation.
|
|
890
|
+
let captured = transport.capture(
|
|
891
|
+
&crate::transport::Target::Pane(worker.pane_id.clone()),
|
|
892
|
+
crate::transport::CaptureRange::Full,
|
|
893
|
+
).unwrap();
|
|
894
|
+
if captured.text.contains(&marker)
|
|
895
|
+
&& captured.text.contains("Provider exited; this pane no longer accepts input.")
|
|
896
|
+
{
|
|
897
|
+
break;
|
|
898
|
+
}
|
|
899
|
+
assert!(std::time::Instant::now() < deadline, "worker must reach its inert exit wrapper: {}", captured.text);
|
|
900
|
+
std::thread::sleep(std::time::Duration::from_millis(20));
|
|
901
|
+
}
|
|
902
|
+
assert_eq!(transport.pane_is_dead(&worker.pane_id).unwrap(), Some(false));
|
|
903
|
+
let worker_pid = worker.child_pid.unwrap();
|
|
904
|
+
assert!(crate::platform::process::pid_is_alive(worker_pid));
|
|
905
|
+
let state = json!({
|
|
906
|
+
"team_key": "round2-owned", "active_team_key": "round2-owned",
|
|
907
|
+
"session_name": session.as_str(), "tmux_socket": endpoint.to_str().unwrap(),
|
|
908
|
+
"generation": "g", "is_external_leader": true,
|
|
909
|
+
"agents": {"assistant": {
|
|
910
|
+
"status": "running", "provider": "pi", "model": "openai-codex/gpt-6-luna", "effort": "max",
|
|
911
|
+
"window": "assistant", "pane_id": worker.pane_id.as_str(), "pane_pid": worker_pid,
|
|
912
|
+
"spawned_at": "new-worker-birth-after-start"
|
|
913
|
+
}}
|
|
914
|
+
});
|
|
915
|
+
crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
|
|
916
|
+
let result = shutdown_with_transport_and_state(&workspace, true, None, &transport, Some(state)).unwrap();
|
|
917
|
+
assert_eq!(result["ok"], true, "{result}");
|
|
918
|
+
assert_eq!(result["residuals"], json!({"sessions": [], "processes": [], "owned_files": []}), "{result}");
|
|
919
|
+
assert!(!transport.has_session(&session).unwrap());
|
|
920
|
+
assert!(!crate::platform::process::pid_is_alive(worker_pid));
|
|
921
|
+
assert!(transport.has_session(&foreign).unwrap());
|
|
922
|
+
assert!(crate::platform::process::pid_is_alive(foreign_worker.child_pid.unwrap()));
|
|
923
|
+
}
|
|
797
924
|
|
|
798
925
|
#[test]
|
|
799
926
|
fn leader_process_protection_does_not_alias_panes_across_endpoints() {
|
|
@@ -2750,15 +2877,12 @@ pub mod lifecycle_port {
|
|
|
2750
2877
|
force: bool,
|
|
2751
2878
|
allow_fresh: bool,
|
|
2752
2879
|
team: Option<&str>,
|
|
2880
|
+
role_config: &crate::lifecycle::role_config::RoleConfigPatch,
|
|
2753
2881
|
) -> Result<Value, CliError> {
|
|
2882
|
+
let _ = force; // Never grants authority to stop an already-running seat.
|
|
2754
2883
|
let agent_id = crate::model::ids::AgentId::new(agent);
|
|
2755
|
-
match crate::lifecycle::
|
|
2756
|
-
workspace,
|
|
2757
|
-
&agent_id,
|
|
2758
|
-
force,
|
|
2759
|
-
true,
|
|
2760
|
-
allow_fresh,
|
|
2761
|
-
team,
|
|
2884
|
+
match crate::lifecycle::role_config::start_agent_from_role(
|
|
2885
|
+
workspace, &agent_id, role_config, allow_fresh, team,
|
|
2762
2886
|
) {
|
|
2763
2887
|
Ok(crate::lifecycle::StartAgentOutcome::Running {
|
|
2764
2888
|
env,
|
|
@@ -2876,15 +3000,12 @@ pub mod lifecycle_port {
|
|
|
2876
3000
|
role_file: &str,
|
|
2877
3001
|
team: Option<&str>,
|
|
2878
3002
|
force: bool,
|
|
3003
|
+
role_config: &crate::lifecycle::role_config::RoleConfigPatch,
|
|
2879
3004
|
) -> Result<Value, CliError> {
|
|
2880
3005
|
let agent_id = crate::model::ids::AgentId::new(agent);
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
&agent_id,
|
|
2884
|
-
Path::new(role_file),
|
|
2885
|
-
true,
|
|
2886
|
-
team,
|
|
2887
|
-
force,
|
|
3006
|
+
let source = (!role_file.is_empty()).then(|| Path::new(role_file));
|
|
3007
|
+
match crate::lifecycle::role_config::add_agent_from_role(
|
|
3008
|
+
workspace, &agent_id, source, role_config, force, team,
|
|
2888
3009
|
) {
|
|
2889
3010
|
Ok(report) => Ok(agent_action_value(
|
|
2890
3011
|
agent,
|
|
@@ -152,8 +152,8 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
|
|
|
152
152
|
CommandSpec { name: "wait", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Wait), summary: "block until a task result is stored", usage: "usage: team-agent wait --task TASK [--workspace WORKSPACE] [--json]", default_help: false, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, sunset: None, action: None, governance: None },
|
|
153
153
|
CommandSpec { name: "restart", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Restart), summary: "restart the selected team", usage: "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, sunset: None, action: None, governance: None },
|
|
154
154
|
CommandSpec { name: "shutdown", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Shutdown), summary: "stop the selected team", usage: "usage: team-agent shutdown [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, sunset: None, action: None, governance: None },
|
|
155
|
-
CommandSpec { name: "add-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::AddAgent), summary: "
|
|
156
|
-
CommandSpec { name: "start-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::StartAgent), summary: "start
|
|
155
|
+
CommandSpec { name: "add-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::AddAgent), summary: "create and start a new worker", usage: "usage: team-agent add-agent AGENT [--role-file FILE] [--provider NAME] [--bypass true|false] [--model SLUG] [--effort LEVEL] [--prompt TEXT] [--profile NAME] [--force] [--workspace WORKSPACE] [--team TEAM] [--json] (provider/bypass required from CLI or role file; conflicting values rejected; force only recreates a confirmed dead registered pane)", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, sunset: None, action: None, governance: None },
|
|
156
|
+
CommandSpec { name: "start-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::StartAgent), summary: "update and start a stopped worker (stop first if running)", usage: "usage: team-agent start-agent AGENT [--model SLUG] [--effort LEVEL] [--bypass true|false] [--prompt TEXT] [--profile NAME] [--provider NAME] [--workspace WORKSPACE] [--team TEAM] [--allow-fresh] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, sunset: None, action: None, governance: None },
|
|
157
157
|
CommandSpec { name: "stop-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::StopAgent), summary: "stop a worker", usage: "usage: team-agent stop-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, sunset: None, action: None, governance: None },
|
|
158
158
|
CommandSpec { name: "reset-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::ResetAgent), summary: "reset a worker session", usage: "usage: team-agent reset-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--discard-session] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, sunset: None, action: None, governance: None },
|
|
159
159
|
CommandSpec { name: "claim-leader", tier: CommandTier::Guided, category: CommandCategory::GuidedRecovery, kind: CommandKind::Dispatch(DispatchKind::ClaimLeader), summary: "bind this pane when doctor reports rebind_required", usage: "usage: team-agent claim-leader [--workspace WORKSPACE] [--team TEAM] [--confirm] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, sunset: None, action: None, governance: None },
|