@team-agent/installer 0.5.43 → 0.5.45
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/emit.rs +44 -33
- package/crates/team-agent/src/cli/mod.rs +99 -9
- package/crates/team-agent/src/cli/named_address.rs +206 -4
- package/crates/team-agent/src/cli/send.rs +105 -2
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/tests/run_delegation.rs +7 -4
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +4 -4
- package/crates/team-agent/src/layout/worker_env.rs +35 -0
- package/crates/team-agent/src/leader/lease.rs +40 -16
- package/crates/team-agent/src/leader/provider_attribution.rs +25 -6
- package/crates/team-agent/src/leader/registry.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch.rs +49 -14
- package/crates/team-agent/src/lifecycle/restart/common.rs +6 -2
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +3 -0
- package/crates/team-agent/src/mcp_server/tools.rs +67 -8
- package/crates/team-agent/src/model/mod.rs +6 -0
- package/crates/team-agent/src/model/name_similarity.rs +266 -0
- package/crates/team-agent/src/platform/process.rs +33 -2
- package/crates/team-agent/src/state/persist.rs +28 -0
- package/crates/team-agent/src/state/repository.rs +7 -0
- package/crates/team-agent/src/topology.rs +13 -0
- package/package.json +4 -4
|
@@ -737,24 +737,83 @@ impl TeamOrchestratorTools {
|
|
|
737
737
|
{
|
|
738
738
|
return None;
|
|
739
739
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
740
|
+
// 0.5.45 naming-addressing (design §3.6, RED-2 MCP): compute
|
|
741
|
+
// the visible peers ONCE and reuse for both exact-match check
|
|
742
|
+
// AND scope-safe candidate ranking. Peers come from
|
|
743
|
+
// `get_visible_peers()` (owner-team only) so sibling teams,
|
|
744
|
+
// host registry, and workspace reverse scans cannot leak.
|
|
745
|
+
let visible_peers: Vec<String> = self
|
|
746
|
+
.get_visible_peers()
|
|
747
|
+
.map(|visible| {
|
|
748
|
+
visible
|
|
749
|
+
.peers
|
|
750
|
+
.iter()
|
|
751
|
+
.map(|peer| peer.as_str().to_string())
|
|
752
|
+
.collect()
|
|
753
|
+
})
|
|
754
|
+
.unwrap_or_default();
|
|
755
|
+
if visible_peers.iter().any(|peer| peer == target) {
|
|
756
|
+
return None;
|
|
744
757
|
}
|
|
745
|
-
let
|
|
758
|
+
let owner_team_key = owner_team
|
|
759
|
+
.as_ref()
|
|
760
|
+
.map(TeamKey::as_str)
|
|
761
|
+
.unwrap_or("")
|
|
762
|
+
.to_string();
|
|
763
|
+
// Rank the typo against owner-scope peers.
|
|
764
|
+
let ranked = {
|
|
765
|
+
use crate::model::name_similarity::{rank, Candidate};
|
|
766
|
+
let candidates: Vec<Candidate<String>> = visible_peers
|
|
767
|
+
.iter()
|
|
768
|
+
.map(|peer| Candidate {
|
|
769
|
+
match_key: peer.clone(),
|
|
770
|
+
stable_key: peer.clone(),
|
|
771
|
+
payload: peer.clone(),
|
|
772
|
+
})
|
|
773
|
+
.collect();
|
|
774
|
+
rank(target, &candidates)
|
|
775
|
+
};
|
|
776
|
+
let candidate_values: Vec<Value> = ranked
|
|
777
|
+
.iter()
|
|
778
|
+
.map(|peer| {
|
|
779
|
+
serde_json::json!({
|
|
780
|
+
"name": peer,
|
|
781
|
+
"team_key": owner_team_key,
|
|
782
|
+
"agent_id": peer,
|
|
783
|
+
"advisory": true,
|
|
784
|
+
})
|
|
785
|
+
})
|
|
786
|
+
.collect();
|
|
787
|
+
let best = ranked.first().cloned();
|
|
788
|
+
let hint = if let Some(best) = best.as_deref() {
|
|
789
|
+
format!(
|
|
790
|
+
"the requested peer is not in your owner team; did you mean `{best}`?"
|
|
791
|
+
)
|
|
792
|
+
} else {
|
|
793
|
+
"the requested peer is not part of your team; worker-origin MCP cannot widen team scope.".to_string()
|
|
794
|
+
};
|
|
746
795
|
let _ = EventLog::new(&self.workspace).write(
|
|
747
796
|
"mcp.send_message_refused",
|
|
748
797
|
serde_json::json!({
|
|
749
798
|
"reason": "peer_not_in_scope",
|
|
750
|
-
"sender_team_id":
|
|
799
|
+
"sender_team_id": owner_team_key,
|
|
751
800
|
"scope": "team",
|
|
752
|
-
"hint": hint
|
|
801
|
+
"hint": hint,
|
|
753
802
|
}),
|
|
754
803
|
);
|
|
755
804
|
let mut extra = serde_json::Map::new();
|
|
756
805
|
extra.insert("status".to_string(), Value::String("refused".to_string()));
|
|
757
|
-
extra.insert("hint".to_string(), Value::String(hint.
|
|
806
|
+
extra.insert("hint".to_string(), Value::String(hint.clone()));
|
|
807
|
+
// 0.5.45 naming-addressing (design §3.6, RED-2 MCP): additive
|
|
808
|
+
// scope-safe suggestions inside the existing ToolError.extra
|
|
809
|
+
// JSON envelope — no struct/schema change, no event log
|
|
810
|
+
// widening. Advisory only: `isError=true` and
|
|
811
|
+
// reason=peer_not_in_scope stay unchanged.
|
|
812
|
+
extra.insert("requested_name".to_string(), Value::String(target.clone()));
|
|
813
|
+
if let Some(best) = best {
|
|
814
|
+
extra.insert("suggested_name".to_string(), Value::String(best));
|
|
815
|
+
}
|
|
816
|
+
extra.insert("candidates".to_string(), Value::Array(candidate_values));
|
|
758
817
|
Some(ToolError {
|
|
759
818
|
reason: ToolErrorReason::PeerNotInScope,
|
|
760
819
|
exc_type: "PeerNotInScope".to_string(),
|
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
pub mod enums;
|
|
13
13
|
pub mod errors;
|
|
14
14
|
pub mod ids;
|
|
15
|
+
// 0.5.45 naming-addressing (design §3.2/§4.1): shared pure name
|
|
16
|
+
// similarity ranking helper. crate-private consumer set = cli/emit.rs
|
|
17
|
+
// (subcommand hint), cli/named_address.rs (typo candidates),
|
|
18
|
+
// cli/send.rs (positional adapter), mcp_server/tools.rs (owner-scoped
|
|
19
|
+
// peer suggestions). Never a routing authority — advisory only.
|
|
20
|
+
pub(crate) mod name_similarity;
|
|
15
21
|
pub mod paths;
|
|
16
22
|
pub mod permissions;
|
|
17
23
|
pub mod routing;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
//! 0.5.45 naming-addressing (design §3.2/§4.1) — shared pure name
|
|
2
|
+
//! similarity ranking helper.
|
|
3
|
+
//!
|
|
4
|
+
//! **Pure**: no filesystem reads, no state loads, no terminal
|
|
5
|
+
//! multiplexer calls, no message dispatch. The RED-6 governance guard
|
|
6
|
+
//! scans this file for the forbidden import tokens and rejects any
|
|
7
|
+
//! occurrence.
|
|
8
|
+
//!
|
|
9
|
+
//! **Single source of truth for name-similarity ranking**: consumed by
|
|
10
|
+
//! - `cli/emit.rs::nearest_subcommand` (unknown top-level subcommand hint),
|
|
11
|
+
//! - `cli/named_address.rs` (bare/`--to-name` typo candidates),
|
|
12
|
+
//! - `cli/send.rs` positional adapter (unknown short id in selected team),
|
|
13
|
+
//! - `mcp_server/tools.rs` owner-scoped peer suggestions.
|
|
14
|
+
//!
|
|
15
|
+
//! **Never a routing authority**: the returned ranking is *advisory
|
|
16
|
+
//! only*. Callers must still exit 1 / `isError=true` / refuse the
|
|
17
|
+
//! request on their original reason; the ranked payload only
|
|
18
|
+
//! populates the additive `requested_name` / `suggested_name` /
|
|
19
|
+
//! `candidates` fields that get echoed back to the user.
|
|
20
|
+
//!
|
|
21
|
+
//! Ranking rules (design §3.2, byte-locked by RED-6):
|
|
22
|
+
//! 1. Comparison keys are trimmed and ASCII-lowercased.
|
|
23
|
+
//! 2. Candidates whose `match_key` prefixes the request outrank
|
|
24
|
+
//! lower-distance non-prefix candidates.
|
|
25
|
+
//! 3. Otherwise sort by Levenshtein distance.
|
|
26
|
+
//! 4. Threshold widens with request length: `0..=3 → 1`, `4..=6 →
|
|
27
|
+
//! 2`, longer → `3`. Nothing beyond the threshold is returned —
|
|
28
|
+
//! no "best guess for a far name".
|
|
29
|
+
//! 5. Sort order: `prefix_miss` (bool), `distance`, `stable_key`.
|
|
30
|
+
//! 6. Deduplicate on `stable_key`, then cap at 3 results.
|
|
31
|
+
//! 7. Canonical spelling comes from the candidate `name` payload
|
|
32
|
+
//! (not the lowercased comparison key) so case is preserved.
|
|
33
|
+
|
|
34
|
+
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
35
|
+
|
|
36
|
+
use std::collections::BTreeSet;
|
|
37
|
+
|
|
38
|
+
/// A candidate to rank. `T` is a caller-owned payload returned
|
|
39
|
+
/// verbatim in the ranked output — callers use it to keep whatever
|
|
40
|
+
/// side data (team_key, workspace, etc.) they need to reconstruct
|
|
41
|
+
/// their advisory JSON envelope.
|
|
42
|
+
#[derive(Debug, Clone)]
|
|
43
|
+
pub struct Candidate<T> {
|
|
44
|
+
/// Comparison key. Callers pass the raw canonical name; this
|
|
45
|
+
/// function trims + lowercases before comparing.
|
|
46
|
+
pub match_key: String,
|
|
47
|
+
/// Stable tie-break key. Typically the same as `match_key`, but
|
|
48
|
+
/// callers with cross-team or cross-workspace candidates use a
|
|
49
|
+
/// composite (`team/agent`) so ordering is deterministic across
|
|
50
|
+
/// otherwise-tied entries.
|
|
51
|
+
pub stable_key: String,
|
|
52
|
+
/// Caller-owned payload returned verbatim.
|
|
53
|
+
pub payload: T,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// Threshold for accepting a candidate given the request length.
|
|
57
|
+
/// Byte-locked by RED-6: short requests get 1, medium 2, longer 3.
|
|
58
|
+
fn threshold_for_len(len: usize) -> usize {
|
|
59
|
+
match len {
|
|
60
|
+
0..=3 => 1,
|
|
61
|
+
4..=6 => 2,
|
|
62
|
+
_ => 3,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Rank `candidates` against `request`. Returns up to 3 payloads,
|
|
67
|
+
/// most-similar-first. `<request>` casing does not affect matching,
|
|
68
|
+
/// only comparison; the caller-supplied payload (which carries the
|
|
69
|
+
/// canonical `name`) is what surfaces to the user.
|
|
70
|
+
///
|
|
71
|
+
/// Empty return means "no acceptable candidate" — do NOT re-rank or
|
|
72
|
+
/// synthesize a best-guess elsewhere.
|
|
73
|
+
pub fn rank<T: Clone>(request: &str, candidates: &[Candidate<T>]) -> Vec<T> {
|
|
74
|
+
let request_key = normalize(request);
|
|
75
|
+
if request_key.is_empty() {
|
|
76
|
+
return Vec::new();
|
|
77
|
+
}
|
|
78
|
+
let threshold = threshold_for_len(request_key.chars().count());
|
|
79
|
+
let mut seen: BTreeSet<String> = BTreeSet::new();
|
|
80
|
+
let mut scored: Vec<(bool, usize, String, T)> = Vec::new();
|
|
81
|
+
for candidate in candidates {
|
|
82
|
+
if !seen.insert(candidate.stable_key.clone()) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
let match_key = normalize(&candidate.match_key);
|
|
86
|
+
if match_key.is_empty() {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
let distance = levenshtein(&request_key, &match_key);
|
|
90
|
+
// Prefix hit means "request is a prefix of candidate" OR
|
|
91
|
+
// "candidate is a prefix of request" — either direction
|
|
92
|
+
// beats a same-distance edit anywhere in the middle.
|
|
93
|
+
let is_prefix = match_key.starts_with(&request_key) || request_key.starts_with(&match_key);
|
|
94
|
+
// Prefix candidates skip the distance threshold (a 1-char
|
|
95
|
+
// prefix miss with distance 5 is still a legitimate
|
|
96
|
+
// completion hint); non-prefix candidates must clear the
|
|
97
|
+
// threshold.
|
|
98
|
+
if !is_prefix && distance > threshold {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// Prefix-miss is the primary sort key; sorting bools puts
|
|
102
|
+
// false (prefix hit) before true (prefix miss).
|
|
103
|
+
scored.push((
|
|
104
|
+
!is_prefix,
|
|
105
|
+
distance,
|
|
106
|
+
candidate.stable_key.clone(),
|
|
107
|
+
candidate.payload.clone(),
|
|
108
|
+
));
|
|
109
|
+
}
|
|
110
|
+
scored.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2)));
|
|
111
|
+
scored.into_iter().take(3).map(|(_, _, _, p)| p).collect()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// Convenience wrapper for the common case where callers only need
|
|
115
|
+
/// the single best canonical name (a `String`), not full payloads.
|
|
116
|
+
/// Returns `None` when no candidate clears the threshold.
|
|
117
|
+
pub fn best_name<'a, S: AsRef<str>>(request: &str, candidates: &'a [S]) -> Option<&'a str> {
|
|
118
|
+
let payloads: Vec<Candidate<&'a str>> = candidates
|
|
119
|
+
.iter()
|
|
120
|
+
.map(|c| {
|
|
121
|
+
let name = c.as_ref();
|
|
122
|
+
Candidate {
|
|
123
|
+
match_key: name.to_string(),
|
|
124
|
+
stable_key: name.to_string(),
|
|
125
|
+
payload: name,
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
.collect();
|
|
129
|
+
rank(request, &payloads).into_iter().next()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fn normalize(input: &str) -> String {
|
|
133
|
+
input.trim().to_ascii_lowercase()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// Standard Levenshtein edit distance. Pure, no dependencies.
|
|
137
|
+
/// Compared strings must already be normalized (trim + lowercase) by
|
|
138
|
+
/// the caller (`rank` does this internally).
|
|
139
|
+
pub fn levenshtein(a: &str, b: &str) -> usize {
|
|
140
|
+
let a: Vec<char> = a.chars().collect();
|
|
141
|
+
let b: Vec<char> = b.chars().collect();
|
|
142
|
+
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
|
143
|
+
let mut curr = vec![0usize; b.len() + 1];
|
|
144
|
+
for (i, &ca) in a.iter().enumerate() {
|
|
145
|
+
curr[0] = i + 1;
|
|
146
|
+
for (j, &cb) in b.iter().enumerate() {
|
|
147
|
+
let cost = if ca == cb { 0 } else { 1 };
|
|
148
|
+
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
|
|
149
|
+
}
|
|
150
|
+
std::mem::swap(&mut prev, &mut curr);
|
|
151
|
+
}
|
|
152
|
+
prev[b.len()]
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#[cfg(test)]
|
|
156
|
+
mod tests {
|
|
157
|
+
use super::*;
|
|
158
|
+
|
|
159
|
+
fn c(name: &str) -> Candidate<String> {
|
|
160
|
+
Candidate {
|
|
161
|
+
match_key: name.to_string(),
|
|
162
|
+
stable_key: name.to_string(),
|
|
163
|
+
payload: name.to_string(),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#[test]
|
|
168
|
+
fn levenshtein_basic() {
|
|
169
|
+
assert_eq!(levenshtein("kitten", "sitting"), 3);
|
|
170
|
+
assert_eq!(levenshtein("", ""), 0);
|
|
171
|
+
assert_eq!(levenshtein("abc", "abc"), 0);
|
|
172
|
+
assert_eq!(levenshtein("abc", "abd"), 1);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#[test]
|
|
176
|
+
fn prefix_hit_beats_lower_distance_non_prefix() {
|
|
177
|
+
// "stat" is a prefix of "status" (distance 2) but "slat" is
|
|
178
|
+
// distance 1 (mid-word substitution). Prefix wins.
|
|
179
|
+
let out = rank("stat", &[c("slat"), c("status")]);
|
|
180
|
+
assert_eq!(out, vec!["status".to_string(), "slat".to_string()]);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#[test]
|
|
184
|
+
fn distance_orders_non_prefix() {
|
|
185
|
+
let out = rank("btea", &[c("bota"), c("beta")]);
|
|
186
|
+
assert_eq!(out[0], "beta"); // 1 edit
|
|
187
|
+
assert_eq!(out[1], "bota"); // 2 edits
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#[test]
|
|
191
|
+
fn stable_key_decides_tie() {
|
|
192
|
+
// Both "beta" and "bota" are distance 2 from "bata".
|
|
193
|
+
let out = rank("bata", &[c("bota"), c("beta")]);
|
|
194
|
+
assert_eq!(out, vec!["beta".to_string(), "bota".to_string()]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#[test]
|
|
198
|
+
fn caps_at_three() {
|
|
199
|
+
let out = rank("aaaa", &[c("aaab"), c("aaac"), c("aaad"), c("aaae")]);
|
|
200
|
+
assert_eq!(out.len(), 3);
|
|
201
|
+
assert_eq!(out, vec!["aaab", "aaac", "aaad"]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#[test]
|
|
205
|
+
fn case_preserved_in_payload() {
|
|
206
|
+
// "BTEA" normalized matches "Beta" (distance 1); the
|
|
207
|
+
// canonical payload preserves original casing.
|
|
208
|
+
let out = rank("BTEA", &[c("Beta")]);
|
|
209
|
+
assert_eq!(out, vec!["Beta".to_string()]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#[test]
|
|
213
|
+
fn far_request_returns_empty() {
|
|
214
|
+
let out = rank("zzzzzz", &[c("beta")]);
|
|
215
|
+
assert!(out.is_empty());
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#[test]
|
|
219
|
+
fn threshold_widens_with_length() {
|
|
220
|
+
assert_eq!(threshold_for_len(0), 1);
|
|
221
|
+
assert_eq!(threshold_for_len(3), 1);
|
|
222
|
+
assert_eq!(threshold_for_len(4), 2);
|
|
223
|
+
assert_eq!(threshold_for_len(6), 2);
|
|
224
|
+
assert_eq!(threshold_for_len(7), 3);
|
|
225
|
+
assert_eq!(threshold_for_len(20), 3);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#[test]
|
|
229
|
+
fn empty_request_returns_empty() {
|
|
230
|
+
let out = rank("", &[c("beta")]);
|
|
231
|
+
assert!(out.is_empty());
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#[test]
|
|
235
|
+
fn dedup_on_stable_key() {
|
|
236
|
+
let out = rank(
|
|
237
|
+
"beta",
|
|
238
|
+
&[
|
|
239
|
+
c("beta"),
|
|
240
|
+
Candidate {
|
|
241
|
+
match_key: "beta".to_string(),
|
|
242
|
+
stable_key: "beta".to_string(),
|
|
243
|
+
payload: "beta-dup".to_string(),
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
);
|
|
247
|
+
assert_eq!(out, vec!["beta".to_string()]);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#[test]
|
|
251
|
+
fn best_name_convenience() {
|
|
252
|
+
assert_eq!(best_name("btea", &["beta", "bota"]), Some("beta"));
|
|
253
|
+
assert_eq!(best_name("zzzzzz", &["beta"]), None);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#[test]
|
|
257
|
+
fn statu_still_maps_to_status() {
|
|
258
|
+
// Byte-lock the existing emit.rs behavior: `statu` → `status`
|
|
259
|
+
// with the shared helper.
|
|
260
|
+
let out = rank(
|
|
261
|
+
"statu",
|
|
262
|
+
&[c("status"), c("send"), c("start"), c("restart")],
|
|
263
|
+
);
|
|
264
|
+
assert_eq!(out[0], "status");
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -99,6 +99,7 @@ mod unix_impl {
|
|
|
99
99
|
//! `cli/mod.rs`, `coordinator/backoff.rs`, `mcp_server/wire.rs`,
|
|
100
100
|
//! `lifecycle/restart/agent.rs`) with zero behavioral drift.
|
|
101
101
|
use super::*;
|
|
102
|
+
use std::process::Command;
|
|
102
103
|
|
|
103
104
|
pub fn current_parent_pid() -> Option<u32> {
|
|
104
105
|
// Byte-equivalent to `mcp_server/wire.rs:319` and
|
|
@@ -150,8 +151,38 @@ mod unix_impl {
|
|
|
150
151
|
Ok(Vec::new())
|
|
151
152
|
}
|
|
152
153
|
|
|
153
|
-
pub fn process_tree(
|
|
154
|
-
|
|
154
|
+
pub fn process_tree(root: u32) -> Result<Vec<u32>, io::Error> {
|
|
155
|
+
let output = Command::new("ps").args(["-axo", "pid=,ppid="]).output()?;
|
|
156
|
+
if !output.status.success() {
|
|
157
|
+
return Err(io::Error::new(
|
|
158
|
+
io::ErrorKind::Other,
|
|
159
|
+
"ps_parent exited unsuccessfully",
|
|
160
|
+
));
|
|
161
|
+
}
|
|
162
|
+
let pairs = String::from_utf8_lossy(&output.stdout)
|
|
163
|
+
.lines()
|
|
164
|
+
.filter_map(|line| {
|
|
165
|
+
let mut parts = line.split_whitespace();
|
|
166
|
+
let pid = parts.next()?.parse::<u32>().ok()?;
|
|
167
|
+
let ppid = parts.next()?.parse::<u32>().ok()?;
|
|
168
|
+
Some((pid, ppid))
|
|
169
|
+
})
|
|
170
|
+
.collect::<Vec<_>>();
|
|
171
|
+
let mut out = Vec::new();
|
|
172
|
+
collect_child_pids(root, &pairs, &mut out);
|
|
173
|
+
out.push(root);
|
|
174
|
+
out.sort_unstable();
|
|
175
|
+
out.dedup();
|
|
176
|
+
Ok(out)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
fn collect_child_pids(parent: u32, pairs: &[(u32, u32)], out: &mut Vec<u32>) {
|
|
180
|
+
for (pid, ppid) in pairs {
|
|
181
|
+
if *ppid == parent && !out.contains(pid) {
|
|
182
|
+
out.push(*pid);
|
|
183
|
+
collect_child_pids(*pid, pairs, out);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
155
186
|
}
|
|
156
187
|
|
|
157
188
|
/// Send a SIGTERM (`TerminateGraceful`) or SIGKILL
|
|
@@ -1130,6 +1130,34 @@ pub fn load_runtime_state(workspace: &Path) -> Result<Value, StateError> {
|
|
|
1130
1130
|
Ok(state)
|
|
1131
1131
|
}
|
|
1132
1132
|
|
|
1133
|
+
pub(crate) fn load_runtime_state_without_migrations(workspace: &Path) -> Result<Value, StateError> {
|
|
1134
|
+
let path = runtime_state_path(workspace);
|
|
1135
|
+
if !path.exists() {
|
|
1136
|
+
return Ok(
|
|
1137
|
+
json!({"agents": {}, "tasks": [], "session_name": null, "active_team_key": null}),
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
let text = std::fs::read_to_string(&path)?;
|
|
1141
|
+
let mut state: Value = serde_json::from_str(&text)?;
|
|
1142
|
+
normalize_agent_session_state(&mut state);
|
|
1143
|
+
Ok(state)
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
pub(crate) fn save_runtime_state_without_migrations(
|
|
1147
|
+
workspace: &Path,
|
|
1148
|
+
state: &Value,
|
|
1149
|
+
) -> Result<(), StateError> {
|
|
1150
|
+
let path = runtime_state_path(workspace);
|
|
1151
|
+
let _lock = RuntimeLock::acquire(workspace, "state-save-raw", 2.0)?;
|
|
1152
|
+
if let Some(parent) = path.parent() {
|
|
1153
|
+
std::fs::create_dir_all(parent)?;
|
|
1154
|
+
}
|
|
1155
|
+
let payload = serde_json::to_string_pretty(state)?;
|
|
1156
|
+
std::fs::write(&path, payload.as_bytes())?;
|
|
1157
|
+
cache_set(&path, state);
|
|
1158
|
+
Ok(())
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1133
1161
|
#[cfg(test)]
|
|
1134
1162
|
mod tests {
|
|
1135
1163
|
#![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
|
|
@@ -47,6 +47,7 @@ use super::persist::{
|
|
|
47
47
|
save_runtime_state_with_lifecycle_topology_authority_and_capture_backfill_skip as helper_write_root_with_lifecycle_topology_authority_and_capture_backfill_skip,
|
|
48
48
|
save_runtime_state_with_team_tombstone_lifecycle_topology_authority as helper_write_root_with_team_tombstone_lifecycle_topology_authority,
|
|
49
49
|
save_runtime_state_with_team_tombstoned_agents as helper_write_root_with_team_tombstoned_agents,
|
|
50
|
+
save_runtime_state_without_migrations as helper_write_root_without_migrations,
|
|
50
51
|
};
|
|
51
52
|
use super::projection::{
|
|
52
53
|
resolve_team_scoped_state as helper_resolve_team_scoped,
|
|
@@ -173,6 +174,9 @@ pub enum StateWriteIntent<'a> {
|
|
|
173
174
|
ClaimLeader {
|
|
174
175
|
team_key: &'a str,
|
|
175
176
|
},
|
|
177
|
+
LeaderBindingRestoreNonTargetTeams {
|
|
178
|
+
target_team_key: &'a str,
|
|
179
|
+
},
|
|
176
180
|
LeaderStartBinding {
|
|
177
181
|
team_key: &'a str,
|
|
178
182
|
transport_kind: &'a str,
|
|
@@ -325,6 +329,9 @@ fn route_direct(
|
|
|
325
329
|
// scoped preserve-claim-fields variant at :1702 uses the team-tombstoned
|
|
326
330
|
// agents helper.
|
|
327
331
|
StateWriteIntent::ClaimLeader { .. } => helper_write_root(workspace, state),
|
|
332
|
+
StateWriteIntent::LeaderBindingRestoreNonTargetTeams { .. } => {
|
|
333
|
+
helper_write_root_without_migrations(workspace, state)
|
|
334
|
+
}
|
|
328
335
|
// LeaderStartBinding -> managed/exec/external all root-save at
|
|
329
336
|
// leader/start.rs:795/903/946.
|
|
330
337
|
StateWriteIntent::LeaderStartBinding { .. } => helper_write_root(workspace, state),
|
|
@@ -457,6 +457,19 @@ fn session_exists_on_endpoint(endpoint: &str, session: &str) -> bool {
|
|
|
457
457
|
session_exists_on_endpoint_checked(endpoint, session).unwrap_or(false)
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
+
pub(crate) fn team_session_ready_on_endpoint(endpoint: &str, session: &str) -> Option<bool> {
|
|
461
|
+
if endpoint.is_empty() || session.is_empty() {
|
|
462
|
+
return None;
|
|
463
|
+
}
|
|
464
|
+
let backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint);
|
|
465
|
+
let targets = backend.list_targets().ok()?;
|
|
466
|
+
Some(
|
|
467
|
+
targets
|
|
468
|
+
.iter()
|
|
469
|
+
.any(|target| target.session.as_str() == session),
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
|
|
460
473
|
fn session_exists_on_endpoint_checked(endpoint: &str, session: &str) -> Option<bool> {
|
|
461
474
|
crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
|
|
462
475
|
.has_session(&SessionName::new(session.to_string()))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.45",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.5.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.5.45",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.5.45",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.5.45"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|