@team-agent/installer 0.5.105 → 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 +15 -1
- package/crates/team-agent/src/cli/emit.rs +33 -13
- package/crates/team-agent/src/cli/mod.rs +148 -21
- package/crates/team-agent/src/cli/status_port/brief.rs +62 -3
- package/crates/team-agent/src/cli/tests/run_delegation.rs +4 -2
- package/crates/team-agent/src/cli/tests/status_send.rs +2 -2
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -3
- package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +24 -17
- 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/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/references/recovery-runbook.md +2 -2
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> {
|
|
@@ -1640,6 +1640,20 @@ mod tests {
|
|
|
1640
1640
|
);
|
|
1641
1641
|
}
|
|
1642
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
|
+
|
|
1643
1657
|
#[test]
|
|
1644
1658
|
fn e13_quickstart_human_summary_only_when_no_attach() {
|
|
1645
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(),
|
|
@@ -2173,11 +2179,8 @@ mod tests {
|
|
|
2173
2179
|
(
|
|
2174
2180
|
"start-agent",
|
|
2175
2181
|
&[
|
|
2176
|
-
"--
|
|
2177
|
-
"--team",
|
|
2178
|
-
"--force",
|
|
2179
|
-
"--allow-fresh",
|
|
2180
|
-
"--json",
|
|
2182
|
+
"--model", "--effort", "--bypass", "--prompt", "--profile", "--provider",
|
|
2183
|
+
"--workspace", "--team", "--allow-fresh", "--json",
|
|
2181
2184
|
][..],
|
|
2182
2185
|
),
|
|
2183
2186
|
(
|
|
@@ -2192,10 +2195,8 @@ mod tests {
|
|
|
2192
2195
|
(
|
|
2193
2196
|
"add-agent",
|
|
2194
2197
|
&[
|
|
2195
|
-
"--role-file",
|
|
2196
|
-
"--workspace",
|
|
2197
|
-
"--team",
|
|
2198
|
-
"--json",
|
|
2198
|
+
"--role-file", "--provider", "--bypass", "--model", "--effort", "--prompt", "--profile",
|
|
2199
|
+
"--force", "--workspace", "--team", "--json",
|
|
2199
2200
|
][..],
|
|
2200
2201
|
),
|
|
2201
2202
|
(
|
|
@@ -2282,11 +2283,30 @@ mod tests {
|
|
|
2282
2283
|
);
|
|
2283
2284
|
}
|
|
2284
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
|
+
|
|
2285
2305
|
#[test]
|
|
2286
2306
|
fn status_help_describes_brief_projection_and_unknown_boundary() {
|
|
2287
2307
|
let help = command_help(Some("status"));
|
|
2288
2308
|
for marker in [
|
|
2289
|
-
"name/provider/runtime_status/activity/health/session_name/tmux_command",
|
|
2309
|
+
"name/provider/model/effort/runtime_status/activity/health/session_name/tmux_command",
|
|
2290
2310
|
"人读与 --json 使用同一投影",
|
|
2291
2311
|
"显示 unknown",
|
|
2292
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() {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//!
|
|
1
|
+
//! Read-only status: native runtime samples plus accepted launch model/effort.
|
|
2
2
|
//!
|
|
3
3
|
//! This intentionally does not reuse RuntimeSnapshot: the legacy snapshot reads
|
|
4
4
|
//! coordinator/db/history and carries diagnostic fields that are outside the
|
|
@@ -82,6 +82,8 @@ static NODEPROBE_RESOLVER_ACTIVE: AtomicBool = AtomicBool::new(false);
|
|
|
82
82
|
struct RegisteredNode {
|
|
83
83
|
name: String,
|
|
84
84
|
provider: String,
|
|
85
|
+
model: Option<String>,
|
|
86
|
+
effort: Option<String>,
|
|
85
87
|
endpoint: Option<String>,
|
|
86
88
|
session: Option<String>,
|
|
87
89
|
window: Option<String>,
|
|
@@ -106,6 +108,8 @@ struct ProbeNode {
|
|
|
106
108
|
struct BriefNode {
|
|
107
109
|
name: String,
|
|
108
110
|
provider: String,
|
|
111
|
+
model: Option<String>,
|
|
112
|
+
effort: Option<String>,
|
|
109
113
|
runtime_status: String,
|
|
110
114
|
activity: String,
|
|
111
115
|
health: String,
|
|
@@ -157,7 +161,7 @@ pub(crate) fn status_brief_scoped(
|
|
|
157
161
|
})
|
|
158
162
|
}
|
|
159
163
|
|
|
160
|
-
/// Human output is the exact same
|
|
164
|
+
/// Human output is the exact same nine-field projection as JSON, rendered in
|
|
161
165
|
/// stable field order. Null values remain explicit rather than becoming a
|
|
162
166
|
/// diagnostic or an inferred value.
|
|
163
167
|
pub(crate) fn format_status_brief(
|
|
@@ -305,9 +309,11 @@ fn write_test_capability_receipt(path: &Path) {
|
|
|
305
309
|
|
|
306
310
|
fn format_brief_node(value: &Value) -> Option<String> {
|
|
307
311
|
Some(format!(
|
|
308
|
-
"name: {} provider: {} runtime_status: {} activity: {} health: {} session_name: {} tmux_command: {}",
|
|
312
|
+
"name: {} provider: {} model: {} effort: {} runtime_status: {} activity: {} health: {} session_name: {} tmux_command: {}",
|
|
309
313
|
value.get("name")?.as_str()?,
|
|
310
314
|
value.get("provider")?.as_str()?,
|
|
315
|
+
display_optional(value.get("model")?),
|
|
316
|
+
display_optional(value.get("effort")?),
|
|
311
317
|
value.get("runtime_status")?.as_str()?,
|
|
312
318
|
value.get("activity")?.as_str()?,
|
|
313
319
|
value.get("health")?.as_str()?,
|
|
@@ -369,6 +375,10 @@ fn registered_node(name: &str, value: &Value, state: &Value) -> RegisteredNode {
|
|
|
369
375
|
RegisteredNode {
|
|
370
376
|
name: name.to_string(),
|
|
371
377
|
provider,
|
|
378
|
+
// Accepted launch settings, not pending role-file edits or guessed
|
|
379
|
+
// provider defaults. Runtime liveness remains a separate signal.
|
|
380
|
+
model: string_at(value, &["model"]),
|
|
381
|
+
effort: string_at(value, &["effort"]),
|
|
372
382
|
endpoint: endpoint(value).or_else(|| endpoint(state)),
|
|
373
383
|
session: first_string(value, &["session_name", "session"])
|
|
374
384
|
.or_else(|| {
|
|
@@ -1052,6 +1062,8 @@ fn project_node(node: &RegisteredNode, probe: Option<&Option<ProbeResult>>) -> B
|
|
|
1052
1062
|
MatchResult::Missing => BriefNode {
|
|
1053
1063
|
name: node.name.clone(),
|
|
1054
1064
|
provider: node.provider.clone(),
|
|
1065
|
+
model: node.model.clone(),
|
|
1066
|
+
effort: node.effort.clone(),
|
|
1055
1067
|
runtime_status: "stopped".to_string(),
|
|
1056
1068
|
activity: "unknown".to_string(),
|
|
1057
1069
|
health: "unknown".to_string(),
|
|
@@ -1092,6 +1104,8 @@ fn project_node(node: &RegisteredNode, probe: Option<&Option<ProbeResult>>) -> B
|
|
|
1092
1104
|
} else {
|
|
1093
1105
|
node.provider.clone()
|
|
1094
1106
|
},
|
|
1107
|
+
model: node.model.clone(),
|
|
1108
|
+
effort: node.effort.clone(),
|
|
1095
1109
|
runtime_status: "running".to_string(),
|
|
1096
1110
|
activity: activity.to_string(),
|
|
1097
1111
|
health: health.to_string(),
|
|
@@ -1172,6 +1186,8 @@ impl BriefNode {
|
|
|
1172
1186
|
Self {
|
|
1173
1187
|
name: node.name.clone(),
|
|
1174
1188
|
provider: node.provider.clone(),
|
|
1189
|
+
model: node.model.clone(),
|
|
1190
|
+
effort: node.effort.clone(),
|
|
1175
1191
|
runtime_status: "unknown".to_string(),
|
|
1176
1192
|
activity: "unknown".to_string(),
|
|
1177
1193
|
health: "unknown".to_string(),
|
|
@@ -1184,6 +1200,8 @@ impl BriefNode {
|
|
|
1184
1200
|
let mut object = Map::new();
|
|
1185
1201
|
object.insert("name".to_string(), json!(self.name));
|
|
1186
1202
|
object.insert("provider".to_string(), json!(self.provider));
|
|
1203
|
+
object.insert("model".to_string(), json!(self.model));
|
|
1204
|
+
object.insert("effort".to_string(), json!(self.effort));
|
|
1187
1205
|
object.insert("runtime_status".to_string(), json!(self.runtime_status));
|
|
1188
1206
|
object.insert("activity".to_string(), json!(self.activity));
|
|
1189
1207
|
object.insert("health".to_string(), json!(self.health));
|
|
@@ -1208,6 +1226,8 @@ mod tests {
|
|
|
1208
1226
|
RegisteredNode {
|
|
1209
1227
|
name: "worker".to_string(),
|
|
1210
1228
|
provider: "pi".to_string(),
|
|
1229
|
+
model: None,
|
|
1230
|
+
effort: None,
|
|
1211
1231
|
endpoint: Some("/tmp/tmux socket/o'k".to_string()),
|
|
1212
1232
|
session: Some("sess".to_string()),
|
|
1213
1233
|
window: Some("win".to_string()),
|
|
@@ -1234,6 +1254,45 @@ mod tests {
|
|
|
1234
1254
|
br#"{"schema_version":1,"socket":"/tmp/sock","sampled_at":"2026-01-01T00:00:00Z","nodes":[],"error":{"kind":"tmux_inventory","message":"missing"}}"#
|
|
1235
1255
|
}
|
|
1236
1256
|
|
|
1257
|
+
#[test]
|
|
1258
|
+
fn status_model_effort_project_accepted_launch_settings_in_all_runtime_states() {
|
|
1259
|
+
for settings in [(None, None), (Some("openai-codex/gpt-6-luna"), Some("max"))] {
|
|
1260
|
+
let mut node = registered();
|
|
1261
|
+
node.model = settings.0.map(str::to_string);
|
|
1262
|
+
node.effort = settings.1.map(str::to_string);
|
|
1263
|
+
for lifecycle in [None, Some("stopped")] {
|
|
1264
|
+
node.lifecycle = lifecycle.map(str::to_string);
|
|
1265
|
+
for sample in [None, Some(ProbeResult { nodes: vec![probe("pi_activity_channel")] })] {
|
|
1266
|
+
let value = project_node(&node, Some(&sample)).into_json();
|
|
1267
|
+
assert_eq!(value["model"], json!(settings.0));
|
|
1268
|
+
assert_eq!(value["effort"], json!(settings.1));
|
|
1269
|
+
let human = format_brief_node(&value).unwrap();
|
|
1270
|
+
assert!(human.contains(&format!("model: {} effort: {}", settings.0.unwrap_or("null"), settings.1.unwrap_or("null"))));
|
|
1271
|
+
assert_eq!(value.as_object().unwrap().len(), 9);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
#[test]
|
|
1278
|
+
fn status_model_effort_use_canonical_runtime_roster_not_stale_projection() {
|
|
1279
|
+
let state = json!({
|
|
1280
|
+
"active_team_key": "selected",
|
|
1281
|
+
"agents": {"worker": {"model": "stale-model", "effort": "low"}},
|
|
1282
|
+
"teams": {"selected": {"agents": {
|
|
1283
|
+
"worker": {"model": "accepted-model", "effort": "max"},
|
|
1284
|
+
"plain": {}
|
|
1285
|
+
}}}
|
|
1286
|
+
});
|
|
1287
|
+
let selected = registered_nodes(&state, Some("worker"));
|
|
1288
|
+
assert_eq!(selected.len(), 1);
|
|
1289
|
+
assert_eq!(selected[0].model.as_deref(), Some("accepted-model"));
|
|
1290
|
+
assert_eq!(selected[0].effort.as_deref(), Some("max"));
|
|
1291
|
+
let plain = registered_nodes(&state, Some("plain"));
|
|
1292
|
+
assert!(plain[0].model.is_none());
|
|
1293
|
+
assert!(plain[0].effort.is_none());
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1237
1296
|
#[test]
|
|
1238
1297
|
fn pi_pane_title_is_unknown_not_idle() {
|
|
1239
1298
|
let node = registered();
|
|
@@ -333,12 +333,12 @@ fn cli_add_agent_duplicate_id_surfaces_real_error() {
|
|
|
333
333
|
);
|
|
334
334
|
}
|
|
335
335
|
|
|
336
|
-
// 5 [P1] — cmd_status --json emits the concise
|
|
336
|
+
// 5 [P1] — cmd_status --json emits the concise nine-field node projection.
|
|
337
337
|
// Status must not leak coordinator/db/history diagnostics; missing exact nodeprobe evidence remains
|
|
338
338
|
// an explicit unknown projection. The fixture still seeds a real store to ensure the status path
|
|
339
339
|
// does not accidentally depend on coordinator or mailbox reads.
|
|
340
340
|
#[test]
|
|
341
|
-
fn
|
|
341
|
+
fn cli_status_emits_nine_field_brief_without_diagnostics() {
|
|
342
342
|
let ws = seed_status_workspace(); // writes .team/runtime/state.json
|
|
343
343
|
let store = crate::message_store::MessageStore::open(&ws).unwrap();
|
|
344
344
|
let _ = store
|
|
@@ -399,6 +399,8 @@ fn cli_status_emits_seven_field_brief_without_diagnostics() {
|
|
|
399
399
|
[
|
|
400
400
|
"name",
|
|
401
401
|
"provider",
|
|
402
|
+
"model",
|
|
403
|
+
"effort",
|
|
402
404
|
"runtime_status",
|
|
403
405
|
"activity",
|
|
404
406
|
"health",
|
|
@@ -345,7 +345,7 @@ fn status_compact_reports_identity_slot_health_on_grok_identity_slot_ok() {
|
|
|
345
345
|
}
|
|
346
346
|
|
|
347
347
|
#[test]
|
|
348
|
-
fn
|
|
348
|
+
fn cmd_status_human_is_nine_field_brief_without_diagnostics() {
|
|
349
349
|
let ws = seed_status_workspace();
|
|
350
350
|
let args = StatusArgs {
|
|
351
351
|
agent: None,
|
|
@@ -364,7 +364,7 @@ fn cmd_status_human_is_seven_field_brief_without_diagnostics() {
|
|
|
364
364
|
|
|
365
365
|
assert!(
|
|
366
366
|
text.contains(
|
|
367
|
-
"name: a1 provider: unknown runtime_status: unknown activity: unknown health: unknown session_name: null tmux_command: null"
|
|
367
|
+
"name: a1 provider: unknown model: null effort: null runtime_status: unknown activity: unknown health: unknown session_name: null tmux_command: null"
|
|
368
368
|
),
|
|
369
369
|
"{text}"
|
|
370
370
|
);
|
|
@@ -170,12 +170,17 @@ pub(super) fn persist_spawn_agent_state(
|
|
|
170
170
|
)?,
|
|
171
171
|
);
|
|
172
172
|
}
|
|
173
|
+
let generation = started
|
|
174
|
+
.first()
|
|
175
|
+
.map(|agent| agent.spawned_at.as_str())
|
|
176
|
+
.unwrap_or(session_name.as_str());
|
|
177
|
+
// Use the same birth identity as the native session owner marker. Worker
|
|
178
|
+
// spawned_at may change on start-agent; this session generation must not.
|
|
173
179
|
if let Some(obj) = state.as_object_mut() {
|
|
174
180
|
obj.insert("agents".to_string(), serde_json::Value::Object(agents));
|
|
181
|
+
obj.insert("generation".to_string(), serde_json::json!(generation));
|
|
175
182
|
} else {
|
|
176
|
-
|
|
177
|
-
obj.insert("agents".to_string(), serde_json::Value::Object(agents));
|
|
178
|
-
state = serde_json::Value::Object(obj);
|
|
183
|
+
state = serde_json::json!({"agents": agents, "generation": generation});
|
|
179
184
|
}
|
|
180
185
|
save_launched_team_state_for_key(workspace, &state, Some(&team_id), None)
|
|
181
186
|
}
|
|
@@ -373,6 +373,8 @@ pub(crate) fn start_agent_at_paths(
|
|
|
373
373
|
};
|
|
374
374
|
let into_existing_session =
|
|
375
375
|
session_live_or_default(transport, &session_name, session_name_present(&state));
|
|
376
|
+
let inherited_generation =
|
|
377
|
+
crate::state::projection::session_generation(&state).map(str::to_string);
|
|
376
378
|
let safety = crate::lifecycle::launch::effective_runtime_config_for_worker_spawn_json(
|
|
377
379
|
&agent,
|
|
378
380
|
provider,
|
|
@@ -441,6 +443,24 @@ pub(crate) fn start_agent_at_paths(
|
|
|
441
443
|
&safety,
|
|
442
444
|
start_mode,
|
|
443
445
|
)?;
|
|
446
|
+
let generation = if into_existing_session {
|
|
447
|
+
inherited_generation.as_deref()
|
|
448
|
+
} else {
|
|
449
|
+
Some(spawn.spawned_at.as_str())
|
|
450
|
+
};
|
|
451
|
+
if let Some(generation) = generation {
|
|
452
|
+
state["generation"] = serde_json::json!(generation);
|
|
453
|
+
if !into_existing_session {
|
|
454
|
+
transport
|
|
455
|
+
.set_session_owner_with_generation(
|
|
456
|
+
&session_name,
|
|
457
|
+
workspace,
|
|
458
|
+
&resolved_team_key,
|
|
459
|
+
generation,
|
|
460
|
+
)
|
|
461
|
+
.map_err(|error| LifecycleError::Transport(error.to_string()))?;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
444
464
|
// 0.5.66 bypass 单源 §2.7:启动即带 bypass → 审计留痕。
|
|
445
465
|
if safety.enabled {
|
|
446
466
|
let state_agent = state
|
|
@@ -1156,6 +1176,30 @@ pub(super) fn stop_agent_at_paths(
|
|
|
1156
1176
|
|| !same_role_panes.is_empty()
|
|
1157
1177
|
|| (pane_id.is_none() && window_exists(transport, &session_name, &window));
|
|
1158
1178
|
if stopped {
|
|
1179
|
+
let stop_targets =
|
|
1180
|
+
if let Some(pane) = pane_id.as_ref().filter(|_| stored_pane_live) {
|
|
1181
|
+
vec![Target::Pane(pane.clone())]
|
|
1182
|
+
} else if !same_role_panes.is_empty() {
|
|
1183
|
+
same_role_panes
|
|
1184
|
+
.iter()
|
|
1185
|
+
.map(|pane| Target::Pane(pane.pane_id.clone()))
|
|
1186
|
+
.collect()
|
|
1187
|
+
} else {
|
|
1188
|
+
vec![Target::SessionWindow {
|
|
1189
|
+
session: session_name.clone(),
|
|
1190
|
+
window: WindowName::new(&window),
|
|
1191
|
+
}]
|
|
1192
|
+
};
|
|
1193
|
+
// Explicit kill-pane/kill-window ignores remain-on-exit on the worker.
|
|
1194
|
+
// Preserve the last window BEFORE killing it, or the coordinator sees
|
|
1195
|
+
// a missing session and stops while the seat is merely being stopped.
|
|
1196
|
+
transport
|
|
1197
|
+
.preserve_session_before_stop(&session_name, &stop_targets)
|
|
1198
|
+
.map_err(|error| {
|
|
1199
|
+
LifecycleError::Transport(format!(
|
|
1200
|
+
"failed to preserve session before stopping agent {agent_id}: {error}"
|
|
1201
|
+
))
|
|
1202
|
+
})?;
|
|
1159
1203
|
// golden operations.py:84-86: a non-zero kill-window raises
|
|
1160
1204
|
// RuntimeError(f"failed to stop agent {agent_id}: {proc.stderr.strip()}").
|
|
1161
1205
|
//
|