@team-agent/installer 0.5.49 → 0.5.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +6 -4
- package/crates/team-agent/src/cli/emit.rs +91 -39
- package/crates/team-agent/src/cli/mod.rs +33 -15
- package/crates/team-agent/src/cli/named_address.rs +82 -53
- package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
- package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
- package/crates/team-agent/src/cli/send/persist.rs +154 -0
- package/crates/team-agent/src/cli/send/presentation.rs +333 -0
- package/crates/team-agent/src/cli/send/resolve.rs +361 -0
- package/crates/team-agent/src/cli/send.rs +103 -1308
- package/crates/team-agent/src/cli/spec.rs +2 -2
- package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
- package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
- package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
- package/crates/team-agent/src/cli/status_port/format.rs +145 -0
- package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
- package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
- package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
- package/crates/team-agent/src/cli/status_port/store.rs +412 -0
- package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
- package/crates/team-agent/src/cli/status_port.rs +47 -1548
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -1
- package/crates/team-agent/src/cli/tests/named_address.rs +9 -7
- package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -3
- package/crates/team-agent/src/cli/tests/status_send.rs +17 -33
- package/crates/team-agent/src/cli/types.rs +5 -8
- package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
- package/crates/team-agent/src/coordinator/tick.rs +37 -0
- package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
- package/crates/team-agent/src/db/message_store.rs +154 -44
- package/crates/team-agent/src/event_log.rs +73 -0
- package/crates/team-agent/src/leader/start.rs +28 -4
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +297 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
- package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
- package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
- package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
- package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
- package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
- package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
- package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
- package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -26
- package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +67 -26
- package/crates/team-agent/src/lifecycle/restart/remove.rs +435 -72
- package/crates/team-agent/src/lifecycle/restart.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +575 -17
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +55 -2
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -1
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
- package/crates/team-agent/src/mcp_server/mod.rs +2 -2
- package/crates/team-agent/src/mcp_server/tests/send.rs +22 -15
- package/crates/team-agent/src/mcp_server/tests/wire.rs +6 -0
- package/crates/team-agent/src/mcp_server/tools.rs +26 -15
- package/crates/team-agent/src/mcp_server/wire.rs +2 -18
- package/crates/team-agent/src/messaging/activity.rs +4 -2
- package/crates/team-agent/src/messaging/address.rs +86 -0
- package/crates/team-agent/src/messaging/delivery.rs +165 -39
- package/crates/team-agent/src/messaging/helpers.rs +17 -13
- package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
- package/crates/team-agent/src/messaging/mod.rs +11 -2
- package/crates/team-agent/src/messaging/persist.rs +309 -0
- package/crates/team-agent/src/messaging/results.rs +16 -24
- package/crates/team-agent/src/messaging/scheduler.rs +4 -2
- package/crates/team-agent/src/messaging/selftest.rs +19 -12
- package/crates/team-agent/src/messaging/send.rs +133 -58
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
- package/crates/team-agent/src/messaging/watchers.rs +13 -3
- package/crates/team-agent/src/redaction.rs +72 -2
- package/crates/team-agent/src/state/persist.rs +2 -1
- package/crates/team-agent/src/state/repository/tests.rs +47 -0
- package/crates/team-agent/src/state/repository.rs +59 -16
- package/package.json +4 -4
|
@@ -153,12 +153,12 @@ pub(crate) struct CommandSpec {
|
|
|
153
153
|
#[rustfmt::skip]
|
|
154
154
|
pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
|
|
155
155
|
CommandSpec { name: "quick-start", tier: CommandTier::Core, category: CommandCategory::Start, kind: CommandKind::Dispatch(DispatchKind::QuickStart), summary: "start or attach a team from TEAM.md", usage: "usage: team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--name NAME] [--team-id TEAM|--team TEAM] [--yes] [--no-display] [--backend tmux|conpty] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
|
|
156
|
-
CommandSpec { name: "send", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Send), summary: "
|
|
156
|
+
CommandSpec { name: "send", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Send), summary: "persist a message for a logical recipient", usage: "usage: team-agent send TO MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: Some("next compatibility release"), action: Some("use positional logical TO and the returned message id"), governance: None },
|
|
157
157
|
CommandSpec { name: "status", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Status), summary: "show current team status", usage: "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
158
158
|
CommandSpec { name: "collect", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Collect), summary: "collect reported results", usage: "usage: team-agent collect [--workspace WORKSPACE] [--team TEAM] [--result-file FILE] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
159
159
|
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, alias_of: None, sunset: None, action: None, governance: None },
|
|
160
160
|
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, alias_of: None, sunset: None, action: None, governance: None },
|
|
161
|
-
CommandSpec { name: "add-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::AddAgent), summary: "add a worker", usage: "usage: team-agent add-agent AGENT --role-file FILE [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
|
|
161
|
+
CommandSpec { name: "add-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::AddAgent), summary: "add or force-recreate a worker", usage: "usage: team-agent add-agent AGENT --role-file FILE [--force] [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
|
|
162
162
|
CommandSpec { name: "start-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::StartAgent), summary: "start an existing worker", usage: "usage: team-agent start-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--force] [--allow-fresh] [--no-display] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
|
|
163
163
|
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, alias_of: None, sunset: None, action: None, governance: None },
|
|
164
164
|
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] [--no-display] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
pub(super) fn agent_window(agent_id: &str, agent_state: &Value) -> String {
|
|
4
|
+
["window", "window_name"]
|
|
5
|
+
.iter()
|
|
6
|
+
.find_map(|key| {
|
|
7
|
+
agent_state
|
|
8
|
+
.get(*key)
|
|
9
|
+
.and_then(Value::as_str)
|
|
10
|
+
.filter(|s| !s.is_empty())
|
|
11
|
+
})
|
|
12
|
+
.unwrap_or(agent_id)
|
|
13
|
+
.to_string()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
pub(super) fn enrich_agents(
|
|
17
|
+
workspace: &Path,
|
|
18
|
+
state: &Value,
|
|
19
|
+
tmux_session_present: bool,
|
|
20
|
+
freshness: &RuntimeFreshness,
|
|
21
|
+
) -> Value {
|
|
22
|
+
let agents = state.get("agents");
|
|
23
|
+
let Some(Value::Object(input)) = agents else {
|
|
24
|
+
return json!({});
|
|
25
|
+
};
|
|
26
|
+
let team_dir = state
|
|
27
|
+
.get("team_dir")
|
|
28
|
+
.and_then(Value::as_str)
|
|
29
|
+
.filter(|value| !value.is_empty())
|
|
30
|
+
.map(PathBuf::from)
|
|
31
|
+
.unwrap_or_else(|| workspace.to_path_buf());
|
|
32
|
+
let mut out = Map::new();
|
|
33
|
+
for (agent_id, value) in input {
|
|
34
|
+
match value {
|
|
35
|
+
Value::Object(obj) => {
|
|
36
|
+
let mut enriched = obj.clone();
|
|
37
|
+
apply_effective_role_projection(&team_dir, agent_id, &mut enriched);
|
|
38
|
+
enriched.insert(
|
|
39
|
+
"interacted".to_string(),
|
|
40
|
+
Value::String(interacted_marker(obj.get("first_send_at"))),
|
|
41
|
+
);
|
|
42
|
+
// 0.5.41 Slice 3: order stale sources most-authoritative-first.
|
|
43
|
+
// Host boot mismatch wins because it invalidates all cached
|
|
44
|
+
// pane/pid/session facts. Provider-exit marker wins over
|
|
45
|
+
// pane liveness because the wrapper leaves an interactive
|
|
46
|
+
// shell live. Coordinator unavailability without stronger
|
|
47
|
+
// live provider proof means DB agent_health rows are stale.
|
|
48
|
+
// Tmux session missing keeps its existing legacy path so
|
|
49
|
+
// pre-0.5.41 tests remain byte-identical when no new
|
|
50
|
+
// signal fires.
|
|
51
|
+
let has_pane_binding = agent_has_pane_fact(&Value::Object(obj.clone()));
|
|
52
|
+
// 0.5.41 Slice 3 (fault-invisibility-locate.md §5 point 3
|
|
53
|
+
// + §9 RED4 live-provider guard): the wrapper-era pane
|
|
54
|
+
// liveness cannot prove provider liveness — but a state
|
|
55
|
+
// `pane_current_command` that MATCHES the agent's provider
|
|
56
|
+
// IS positive proof (the abnormal.rs classifier writes it
|
|
57
|
+
// when the pane's foreground command is the provider CLI).
|
|
58
|
+
// When that positive proof is present, no stale downgrade
|
|
59
|
+
// fires here so the agent renders as working.
|
|
60
|
+
let provider_command_positive_proof = provider_current_command_matches(obj);
|
|
61
|
+
// 0.5.41 Slice 3 (0.5.35 R4 regression guard): when the
|
|
62
|
+
// runtime classifier has already written canonical
|
|
63
|
+
// `worker_state=UNKNOWN` / `activity.status=uncertain`,
|
|
64
|
+
// that is the authoritative honest observation — do NOT
|
|
65
|
+
// reclassify it as `coordinator_unavailable` stale (which
|
|
66
|
+
// would land it in the Stopped bucket instead of Unknown).
|
|
67
|
+
// Host-boot mismatch and provider-exited marker are
|
|
68
|
+
// stronger, more specific signals and still win.
|
|
69
|
+
let canonical_unknown = agent_canonical_worker_state_is_unknown(obj);
|
|
70
|
+
let new_reason = if freshness.host_boot_stale && has_pane_binding {
|
|
71
|
+
freshness.host_boot_stale_reason()
|
|
72
|
+
} else if freshness.provider_exited_agents.contains(agent_id) {
|
|
73
|
+
Some("worker_provider_exited")
|
|
74
|
+
} else if !freshness.coordinator_service_available
|
|
75
|
+
&& has_pane_binding
|
|
76
|
+
&& !provider_command_positive_proof
|
|
77
|
+
&& !canonical_unknown
|
|
78
|
+
{
|
|
79
|
+
Some("coordinator_unavailable")
|
|
80
|
+
} else {
|
|
81
|
+
None
|
|
82
|
+
};
|
|
83
|
+
let legacy_reason = if provider_command_positive_proof {
|
|
84
|
+
None
|
|
85
|
+
} else {
|
|
86
|
+
stale_reason_for_agent(&Value::Object(obj.clone()), tmux_session_present)
|
|
87
|
+
};
|
|
88
|
+
let reason = new_reason.or(legacy_reason);
|
|
89
|
+
if let Some(reason) = reason {
|
|
90
|
+
enriched.insert("stale".to_string(), Value::Bool(true));
|
|
91
|
+
enriched.insert(
|
|
92
|
+
"stale_reason".to_string(),
|
|
93
|
+
Value::String(reason.to_string()),
|
|
94
|
+
);
|
|
95
|
+
// Downgrade cached BUSY/working when the stale source is
|
|
96
|
+
// one of the new authoritative signals OR the pre-existing
|
|
97
|
+
// session-missing signal. Legacy code only downgraded on
|
|
98
|
+
// !tmux_session_present; that let host_boot / provider-
|
|
99
|
+
// exit / coord-unavailable stale rows keep raw=running.
|
|
100
|
+
let is_new_signal = matches!(
|
|
101
|
+
reason,
|
|
102
|
+
"host_boot_mismatch" | "worker_provider_exited" | "coordinator_unavailable"
|
|
103
|
+
);
|
|
104
|
+
if !tmux_session_present || is_new_signal {
|
|
105
|
+
downgrade_stale_agent(&mut enriched);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
out.insert(agent_id.clone(), Value::Object(enriched));
|
|
109
|
+
}
|
|
110
|
+
_ => {
|
|
111
|
+
out.insert(agent_id.clone(), value.clone());
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
Value::Object(out)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
pub(super) fn apply_effective_role_projection(
|
|
119
|
+
team_dir: &Path,
|
|
120
|
+
agent_id: &str,
|
|
121
|
+
agent: &mut Map<String, Value>,
|
|
122
|
+
) {
|
|
123
|
+
let role_file = agent
|
|
124
|
+
.get("dynamic_role_file")
|
|
125
|
+
.and_then(Value::as_str)
|
|
126
|
+
.filter(|value| !value.is_empty())
|
|
127
|
+
.map(PathBuf::from)
|
|
128
|
+
.unwrap_or_else(|| team_dir.join("agents").join(format!("{agent_id}.md")));
|
|
129
|
+
let Ok((meta, _)) = crate::compiler::read_front_matter(&role_file) else {
|
|
130
|
+
return;
|
|
131
|
+
};
|
|
132
|
+
let Some(meta) = yaml_map(&meta) else {
|
|
133
|
+
return;
|
|
134
|
+
};
|
|
135
|
+
if let Some(provider) = yaml_str(meta, "provider").filter(|value| !value.is_empty()) {
|
|
136
|
+
agent.insert("provider".to_string(), Value::String(provider.to_string()));
|
|
137
|
+
agent.insert(
|
|
138
|
+
"provider_source".to_string(),
|
|
139
|
+
Value::String("role".to_string()),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if let Some(model) = yaml_str(meta, "model").filter(|value| !value.is_empty()) {
|
|
143
|
+
if agent.get("model").and_then(Value::as_str) != Some(model) {
|
|
144
|
+
agent.insert("model_stale".to_string(), Value::Bool(true));
|
|
145
|
+
}
|
|
146
|
+
agent.insert("model".to_string(), Value::String(model.to_string()));
|
|
147
|
+
agent.insert(
|
|
148
|
+
"model_source".to_string(),
|
|
149
|
+
Value::String("role".to_string()),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
pub(super) fn yaml_map(
|
|
155
|
+
value: &crate::model::yaml::Value,
|
|
156
|
+
) -> Option<&Vec<(String, crate::model::yaml::Value)>> {
|
|
157
|
+
match value {
|
|
158
|
+
crate::model::yaml::Value::Map(items) => Some(items),
|
|
159
|
+
_ => None,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
pub(super) fn yaml_str<'a>(
|
|
164
|
+
items: &'a [(String, crate::model::yaml::Value)],
|
|
165
|
+
key: &str,
|
|
166
|
+
) -> Option<&'a str> {
|
|
167
|
+
items.iter().find_map(|(name, value)| {
|
|
168
|
+
if name == key {
|
|
169
|
+
match value {
|
|
170
|
+
crate::model::yaml::Value::Str(value) => Some(value.as_str()),
|
|
171
|
+
_ => None,
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
None
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
pub(super) fn downgrade_stale_agent(agent: &mut Map<String, Value>) {
|
|
180
|
+
let raw = agent
|
|
181
|
+
.get("status")
|
|
182
|
+
.and_then(Value::as_str)
|
|
183
|
+
.unwrap_or("")
|
|
184
|
+
.to_ascii_lowercase();
|
|
185
|
+
if matches!(raw.as_str(), "running" | "busy" | "working" | "idle") {
|
|
186
|
+
agent.insert("status".to_string(), Value::String("stopped".to_string()));
|
|
187
|
+
}
|
|
188
|
+
let worker_state = agent
|
|
189
|
+
.get("worker_state")
|
|
190
|
+
.and_then(Value::as_str)
|
|
191
|
+
.unwrap_or("")
|
|
192
|
+
.to_ascii_uppercase();
|
|
193
|
+
if matches!(worker_state.as_str(), "RUNNING" | "BUSY" | "PROBABLY_IDLE") {
|
|
194
|
+
agent.insert(
|
|
195
|
+
"worker_state".to_string(),
|
|
196
|
+
Value::String("DEAD".to_string()),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
pub(super) fn stale_reason_for_agent(
|
|
202
|
+
agent: &Value,
|
|
203
|
+
tmux_session_present: bool,
|
|
204
|
+
) -> Option<&'static str> {
|
|
205
|
+
let pane_dead = !tmux_session_present && agent_has_pane_fact(agent);
|
|
206
|
+
let process_dead =
|
|
207
|
+
agent_process_dead(agent) || (!tmux_session_present && agent_has_process_fact(agent));
|
|
208
|
+
match (pane_dead, process_dead) {
|
|
209
|
+
(true, true) => Some("both"),
|
|
210
|
+
(false, true) => Some("process_dead"),
|
|
211
|
+
(true, false) => Some("pane_dead"),
|
|
212
|
+
(false, false) => None,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/// 0.5.41 Slice 3 (0.5.35 R4 regression guard): true when the agent
|
|
217
|
+
/// row carries the canonical `worker_state=UNKNOWN` OR
|
|
218
|
+
/// `activity.status=uncertain` observation the runtime classifier
|
|
219
|
+
/// writes. Used to skip the coordinator-unavailable stale mark
|
|
220
|
+
/// (see `enrich_agents`) so the pre-existing R4 rendering (UNKNOWN
|
|
221
|
+
/// beats WORKING) is preserved.
|
|
222
|
+
pub(super) fn agent_canonical_worker_state_is_unknown(
|
|
223
|
+
agent: &serde_json::Map<String, Value>,
|
|
224
|
+
) -> bool {
|
|
225
|
+
let worker_state_unknown = agent
|
|
226
|
+
.get("worker_state")
|
|
227
|
+
.and_then(Value::as_str)
|
|
228
|
+
.is_some_and(|value| value.eq_ignore_ascii_case("UNKNOWN"));
|
|
229
|
+
let activity_uncertain = agent
|
|
230
|
+
.get("activity")
|
|
231
|
+
.and_then(|v| v.get("status"))
|
|
232
|
+
.and_then(Value::as_str)
|
|
233
|
+
.is_some_and(|value| value.eq_ignore_ascii_case("uncertain"));
|
|
234
|
+
worker_state_unknown || activity_uncertain
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/// 0.5.41 Slice 3 (fault-invisibility-locate.md §9 RED4 live-provider
|
|
238
|
+
/// guard): true when the agent row carries a `pane_current_command`
|
|
239
|
+
/// that matches the agent's provider CLI. This is positive proof
|
|
240
|
+
/// the provider is the pane's foreground process — the abnormal.rs
|
|
241
|
+
/// classifier writes this field after the marker/current-command
|
|
242
|
+
/// check clears. When true, stale-downgrade paths in
|
|
243
|
+
/// `enrich_agents` skip so the row keeps its BUSY/working state.
|
|
244
|
+
pub(super) fn provider_current_command_matches(agent: &serde_json::Map<String, Value>) -> bool {
|
|
245
|
+
let Some(command) = agent
|
|
246
|
+
.get("pane_current_command")
|
|
247
|
+
.and_then(Value::as_str)
|
|
248
|
+
.filter(|s| !s.is_empty())
|
|
249
|
+
else {
|
|
250
|
+
return false;
|
|
251
|
+
};
|
|
252
|
+
let Some(provider_wire) = agent.get("provider").and_then(Value::as_str) else {
|
|
253
|
+
return false;
|
|
254
|
+
};
|
|
255
|
+
let Some(provider) = crate::provider::wire::parse_provider(provider_wire) else {
|
|
256
|
+
return false;
|
|
257
|
+
};
|
|
258
|
+
crate::leader::command_matches_provider(provider, command)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
pub(super) fn agent_has_pane_fact(agent: &Value) -> bool {
|
|
262
|
+
["pane_id", "window", "window_name"].iter().any(|key| {
|
|
263
|
+
agent
|
|
264
|
+
.get(*key)
|
|
265
|
+
.and_then(Value::as_str)
|
|
266
|
+
.is_some_and(|value| !value.is_empty())
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
pub(super) fn agent_has_process_fact(agent: &Value) -> bool {
|
|
271
|
+
agent.get("pid").and_then(Value::as_i64).is_some()
|
|
272
|
+
|| agent.get("process_started").and_then(Value::as_bool) == Some(true)
|
|
273
|
+
|| agent
|
|
274
|
+
.get("provider_process_dead")
|
|
275
|
+
.and_then(Value::as_bool)
|
|
276
|
+
.is_some()
|
|
277
|
+
|| agent
|
|
278
|
+
.get("process_liveness")
|
|
279
|
+
.and_then(Value::as_str)
|
|
280
|
+
.is_some()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
pub(super) fn agent_process_dead(agent: &Value) -> bool {
|
|
284
|
+
if agent.get("provider_process_dead").and_then(Value::as_bool) == Some(true) {
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
["process_liveness", "worker_state"].iter().any(|key| {
|
|
288
|
+
agent
|
|
289
|
+
.get(*key)
|
|
290
|
+
.and_then(Value::as_str)
|
|
291
|
+
.is_some_and(is_dead_process_state)
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
pub(super) fn is_dead_process_state(value: &str) -> bool {
|
|
296
|
+
matches!(
|
|
297
|
+
value,
|
|
298
|
+
"dead" | "missing" | "stopped" | "exited" | "terminated"
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
pub(super) fn interacted_marker(value: Option<&Value>) -> String {
|
|
303
|
+
let Some(raw) = value.and_then(Value::as_str) else {
|
|
304
|
+
return "never".to_string();
|
|
305
|
+
};
|
|
306
|
+
if raw.is_empty() {
|
|
307
|
+
return "never".to_string();
|
|
308
|
+
}
|
|
309
|
+
if chrono::DateTime::parse_from_rfc3339(raw).is_ok() {
|
|
310
|
+
raw.to_string()
|
|
311
|
+
} else {
|
|
312
|
+
"never".to_string()
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
pub(super) fn tmux_session_present(
|
|
317
|
+
workspace: &Path,
|
|
318
|
+
state: &Value,
|
|
319
|
+
session_name: Option<&str>,
|
|
320
|
+
) -> bool {
|
|
321
|
+
// Bug #7 (prerelease 0.4.0 gate review §6): probe the SAME endpoint
|
|
322
|
+
// the runtime actually uses (state.tmux_endpoint / tmux_socket), not
|
|
323
|
+
// the workspace-hash socket. When state has no persisted endpoint,
|
|
324
|
+
// fall back to workspace — preserves legacy behavior. wait_readiness
|
|
325
|
+
// formula unchanged per 不可改项; only the input signal is fixed.
|
|
326
|
+
let Some(name) = session_name else {
|
|
327
|
+
return false;
|
|
328
|
+
};
|
|
329
|
+
if name.is_empty() {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
let run_ws = crate::model::paths::canonical_run_workspace(workspace)
|
|
333
|
+
.unwrap_or_else(|_| workspace.to_path_buf());
|
|
334
|
+
// 0.5.x Phase 1d Batch 3: route through the factory so a
|
|
335
|
+
// conpty team does NOT get its `has_session` probe served by a
|
|
336
|
+
// tmux backend (which would always return false and drive the
|
|
337
|
+
// reader into a false `tmux_session_missing` state — design
|
|
338
|
+
// §Batch 3 Verification anchor). Tmux teams see byte-equivalent
|
|
339
|
+
// behavior because factory Layer 3 (legacy tmux endpoint) uses
|
|
340
|
+
// the same `tmux_backend_for_runtime_state_or_workspace` shape.
|
|
341
|
+
let resolved = crate::transport_factory::resolve_read_only_transport(
|
|
342
|
+
&run_ws,
|
|
343
|
+
Some(state),
|
|
344
|
+
crate::transport_factory::TransportPurpose::Status,
|
|
345
|
+
);
|
|
346
|
+
match resolved {
|
|
347
|
+
Ok(r) => r
|
|
348
|
+
.backend
|
|
349
|
+
.has_session(&crate::transport::SessionName::new(name))
|
|
350
|
+
.unwrap_or(false),
|
|
351
|
+
Err(_) => {
|
|
352
|
+
// Factory refused (e.g. explicit conpty without a
|
|
353
|
+
// resolvable team_key). Honest: return false rather
|
|
354
|
+
// than pretend a tmux session exists.
|
|
355
|
+
false
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
pub fn approvals(workspace: &Path, agent: Option<&str>, as_json: bool) -> Result<Value, CliError> {
|
|
4
|
+
let _ = as_json;
|
|
5
|
+
let state = read_runtime_state(workspace);
|
|
6
|
+
approvals_scoped(workspace, &state, agent, as_json)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
pub fn approvals_scoped(
|
|
10
|
+
workspace: &Path,
|
|
11
|
+
state: &Value,
|
|
12
|
+
agent: Option<&str>,
|
|
13
|
+
as_json: bool,
|
|
14
|
+
) -> Result<Value, CliError> {
|
|
15
|
+
let _ = as_json;
|
|
16
|
+
let session = state
|
|
17
|
+
.get("session_name")
|
|
18
|
+
.and_then(Value::as_str)
|
|
19
|
+
.filter(|s| !s.is_empty());
|
|
20
|
+
let mut approvals = Vec::new();
|
|
21
|
+
if let (Some(session), Some(agents)) = (session, state.get("agents").and_then(Value::as_object))
|
|
22
|
+
{
|
|
23
|
+
let run_ws = crate::model::paths::canonical_run_workspace(workspace)
|
|
24
|
+
.unwrap_or_else(|_| workspace.to_path_buf());
|
|
25
|
+
// 0.5.x Phase 1d Batch 3: use the factory-resolved backend
|
|
26
|
+
// so conpty teams get their scrollback from the shim rather
|
|
27
|
+
// than a fake tmux capture that always returns empty. Tmux
|
|
28
|
+
// teams take the same code path as before (byte-equivalent).
|
|
29
|
+
let resolved = crate::transport_factory::resolve_read_only_transport(
|
|
30
|
+
&run_ws,
|
|
31
|
+
Some(state),
|
|
32
|
+
crate::transport_factory::TransportPurpose::Status,
|
|
33
|
+
);
|
|
34
|
+
let backend: Box<dyn crate::transport::Transport> = match resolved {
|
|
35
|
+
Ok(r) => r.backend,
|
|
36
|
+
Err(_) => {
|
|
37
|
+
// Read-path fallback: refused factory means we don't
|
|
38
|
+
// try to inspect approval prompts. Empty vec = no
|
|
39
|
+
// waiting approvals, honest.
|
|
40
|
+
return Ok(json!({
|
|
41
|
+
"ok": true,
|
|
42
|
+
"waiting": false,
|
|
43
|
+
"waiting_count": 0,
|
|
44
|
+
"approvals": [],
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
for (agent_id, agent_state) in agents {
|
|
49
|
+
if agent.is_some_and(|wanted| wanted != agent_id) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
let window = agent_window(agent_id, agent_state);
|
|
53
|
+
let target = crate::transport::Target::SessionWindow {
|
|
54
|
+
session: crate::transport::SessionName::new(session.to_string()),
|
|
55
|
+
window: crate::transport::WindowName::new(window.clone()),
|
|
56
|
+
};
|
|
57
|
+
let Ok(captured) = backend.capture(&target, crate::transport::CaptureRange::Tail(120))
|
|
58
|
+
else {
|
|
59
|
+
continue;
|
|
60
|
+
};
|
|
61
|
+
if let Some(prompt) = crate::provider::extract_approval_prompt(agent_id, &captured.text)
|
|
62
|
+
{
|
|
63
|
+
approvals.push(prompt.to_ordered_value());
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
let waiting_count = approvals.len();
|
|
68
|
+
Ok(json!({
|
|
69
|
+
"ok": true,
|
|
70
|
+
"waiting": waiting_count > 0,
|
|
71
|
+
"waiting_count": waiting_count,
|
|
72
|
+
"approvals": approvals,
|
|
73
|
+
"scan": {
|
|
74
|
+
"mode": "tail",
|
|
75
|
+
"lines": 120,
|
|
76
|
+
"raw_output": false,
|
|
77
|
+
},
|
|
78
|
+
}))
|
|
79
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
pub(super) fn compact_status(full: Value) -> Value {
|
|
4
|
+
let not_ready = compact_not_ready(&full);
|
|
5
|
+
let ready = compact_ready(&full, ¬_ready);
|
|
6
|
+
json!({
|
|
7
|
+
"ok": true,
|
|
8
|
+
"team": full.get("team").cloned().unwrap_or(Value::Null),
|
|
9
|
+
"session_name": full.get("session_name").cloned().unwrap_or(Value::Null),
|
|
10
|
+
"leader_attach_command": full.get("leader_attach_command").cloned().unwrap_or(Value::Null),
|
|
11
|
+
"ready": ready,
|
|
12
|
+
"not_ready": not_ready,
|
|
13
|
+
"agents": compact_agents(full.get("agents")),
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/// Synthesized readiness boolean for the slim payload. Stricter than the
|
|
18
|
+
/// raw `readiness.ready` because it also folds in coordinator + schema +
|
|
19
|
+
/// tmux session presence so operators don't need to read separate booleans.
|
|
20
|
+
pub(super) fn compact_ready(full: &Value, not_ready: &Value) -> bool {
|
|
21
|
+
not_ready.is_null()
|
|
22
|
+
&& full
|
|
23
|
+
.get("readiness")
|
|
24
|
+
.and_then(|r| r.get("ready"))
|
|
25
|
+
.and_then(Value::as_bool)
|
|
26
|
+
.unwrap_or(false)
|
|
27
|
+
&& full
|
|
28
|
+
.get("coordinator")
|
|
29
|
+
.and_then(|c| c.get("status"))
|
|
30
|
+
.and_then(Value::as_str)
|
|
31
|
+
.is_some_and(|s| s == "running" || s == "ok")
|
|
32
|
+
&& full
|
|
33
|
+
.get("coordinator")
|
|
34
|
+
.and_then(|c| c.get("schema_ok"))
|
|
35
|
+
.and_then(Value::as_bool)
|
|
36
|
+
.unwrap_or(true)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Returns `Value::Null` when fully ready, otherwise an object:
|
|
40
|
+
/// `{"reasons": [...], "agents": [...]}` listing every gating issue.
|
|
41
|
+
pub(super) fn compact_not_ready(full: &Value) -> Value {
|
|
42
|
+
let reasons = not_ready_reasons(full);
|
|
43
|
+
if reasons.is_empty() {
|
|
44
|
+
return Value::Null;
|
|
45
|
+
}
|
|
46
|
+
let agents = full
|
|
47
|
+
.get("incomplete_session_capture_agents")
|
|
48
|
+
.and_then(Value::as_array)
|
|
49
|
+
.cloned()
|
|
50
|
+
.or_else(|| {
|
|
51
|
+
full.get("pending_session_agent_ids")
|
|
52
|
+
.and_then(Value::as_array)
|
|
53
|
+
.cloned()
|
|
54
|
+
})
|
|
55
|
+
.unwrap_or_default();
|
|
56
|
+
let mut obj = Map::new();
|
|
57
|
+
obj.insert(
|
|
58
|
+
"reasons".to_string(),
|
|
59
|
+
Value::Array(reasons.into_iter().map(Value::String).collect()),
|
|
60
|
+
);
|
|
61
|
+
obj.insert("agents".to_string(), Value::Array(agents));
|
|
62
|
+
Value::Object(obj)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
pub(super) fn not_ready_reasons(full: &Value) -> Vec<String> {
|
|
66
|
+
let mut reasons = Vec::new();
|
|
67
|
+
let coord = full.get("coordinator");
|
|
68
|
+
let coord_status = coord
|
|
69
|
+
.and_then(|c| c.get("status"))
|
|
70
|
+
.and_then(Value::as_str)
|
|
71
|
+
.unwrap_or("");
|
|
72
|
+
if coord_status != "running" && coord_status != "ok" {
|
|
73
|
+
reasons.push("coordinator_not_running".to_string());
|
|
74
|
+
}
|
|
75
|
+
if coord
|
|
76
|
+
.and_then(|c| c.get("schema_ok"))
|
|
77
|
+
.and_then(Value::as_bool)
|
|
78
|
+
== Some(false)
|
|
79
|
+
{
|
|
80
|
+
reasons.push("coordinator_schema_not_ok".to_string());
|
|
81
|
+
}
|
|
82
|
+
if full.get("tmux_session_present").and_then(Value::as_bool) == Some(false) {
|
|
83
|
+
reasons.push("tmux_session_missing".to_string());
|
|
84
|
+
}
|
|
85
|
+
let readiness = full.get("readiness");
|
|
86
|
+
if readiness
|
|
87
|
+
.and_then(|r| r.get("all_spawned"))
|
|
88
|
+
.and_then(Value::as_bool)
|
|
89
|
+
== Some(false)
|
|
90
|
+
{
|
|
91
|
+
reasons.push("workers_not_spawned".to_string());
|
|
92
|
+
}
|
|
93
|
+
if readiness
|
|
94
|
+
.and_then(|r| r.get("all_attached_receiver"))
|
|
95
|
+
.and_then(Value::as_bool)
|
|
96
|
+
== Some(false)
|
|
97
|
+
{
|
|
98
|
+
reasons.push("leader_receiver_unbound".to_string());
|
|
99
|
+
}
|
|
100
|
+
if readiness
|
|
101
|
+
.and_then(|r| r.get("session_capture_complete"))
|
|
102
|
+
.and_then(Value::as_bool)
|
|
103
|
+
== Some(false)
|
|
104
|
+
{
|
|
105
|
+
reasons.push("session_capture_incomplete".to_string());
|
|
106
|
+
}
|
|
107
|
+
if readiness
|
|
108
|
+
.and_then(|r| r.get("awaiting_trust_prompt"))
|
|
109
|
+
.and_then(Value::as_bool)
|
|
110
|
+
== Some(true)
|
|
111
|
+
{
|
|
112
|
+
reasons.push("awaiting_trust_prompt".to_string());
|
|
113
|
+
}
|
|
114
|
+
reasons
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
pub(super) fn compact_agents(value: Option<&Value>) -> Value {
|
|
118
|
+
let Some(Value::Object(input)) = value else {
|
|
119
|
+
return json!({});
|
|
120
|
+
};
|
|
121
|
+
let mut out = Map::new();
|
|
122
|
+
for (agent_id, agent) in input {
|
|
123
|
+
out.insert(agent_id.clone(), compact_agent_state(agent_id, agent));
|
|
124
|
+
}
|
|
125
|
+
Value::Object(out)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/// 0.4.x: agent rows in the slim payload have exactly 4 fields. agent_id
|
|
129
|
+
/// is no longer copied in — the map key already carries it. Diagnostic
|
|
130
|
+
/// fields (model, tmux_window_present, session_id, captured_via,
|
|
131
|
+
/// attribution_confidence, display, interacted) move to `--detail`.
|
|
132
|
+
/// `activity` + `last_output_at` are preserved (RM-039-STAT-001).
|
|
133
|
+
pub(super) fn compact_agent_state(_agent_id: &str, agent: &Value) -> Value {
|
|
134
|
+
let Some(input) = agent.as_object() else {
|
|
135
|
+
return json!({});
|
|
136
|
+
};
|
|
137
|
+
let mut out = Map::new();
|
|
138
|
+
// 0.4.x Phase 1: add `worker_state` (canonical 5-state product
|
|
139
|
+
// surface). `activity` is preserved alongside as the deprecated
|
|
140
|
+
// legacy classifier output (CR R3 same-source contract).
|
|
141
|
+
for key in [
|
|
142
|
+
"status",
|
|
143
|
+
"provider",
|
|
144
|
+
"worker_state",
|
|
145
|
+
"activity",
|
|
146
|
+
"last_output_at",
|
|
147
|
+
"stale",
|
|
148
|
+
"stale_reason",
|
|
149
|
+
] {
|
|
150
|
+
if let Some(value) = input.get(key) {
|
|
151
|
+
out.insert(key.to_string(), value.clone());
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
Value::Object(out)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
pub(super) fn compact_tasks(value: Option<&Value>) -> Value {
|
|
158
|
+
let Some(Value::Array(tasks)) = value else {
|
|
159
|
+
return json!([]);
|
|
160
|
+
};
|
|
161
|
+
Value::Array(
|
|
162
|
+
tasks
|
|
163
|
+
.iter()
|
|
164
|
+
.map(|task| {
|
|
165
|
+
compact_object(
|
|
166
|
+
Some(task),
|
|
167
|
+
&[
|
|
168
|
+
"id",
|
|
169
|
+
"title",
|
|
170
|
+
"status",
|
|
171
|
+
"assignee",
|
|
172
|
+
"type",
|
|
173
|
+
"accepted_result_id",
|
|
174
|
+
],
|
|
175
|
+
)
|
|
176
|
+
})
|
|
177
|
+
.collect(),
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
pub(super) fn compact_object(value: Option<&Value>, keys: &[&str]) -> Value {
|
|
182
|
+
let Some(Value::Object(input)) = value else {
|
|
183
|
+
return json!({});
|
|
184
|
+
};
|
|
185
|
+
let mut out = Map::new();
|
|
186
|
+
for key in keys {
|
|
187
|
+
if let Some(value) = input.get(*key) {
|
|
188
|
+
out.insert((*key).to_string(), value.clone());
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
Value::Object(out)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
pub(super) fn take_array(value: Option<&Value>, limit: usize) -> Value {
|
|
195
|
+
let Some(Value::Array(items)) = value else {
|
|
196
|
+
return json!([]);
|
|
197
|
+
};
|
|
198
|
+
Value::Array(items.iter().take(limit).cloned().collect())
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
pub(super) fn take_array_tail(value: Option<&Value>, limit: usize) -> Value {
|
|
202
|
+
let Some(Value::Array(items)) = value else {
|
|
203
|
+
return json!([]);
|
|
204
|
+
};
|
|
205
|
+
let start = items.len().saturating_sub(limit);
|
|
206
|
+
Value::Array(items.iter().skip(start).cloned().collect())
|
|
207
|
+
}
|