@team-agent/installer 0.3.12 → 0.3.14
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 +23 -0
- package/crates/team-agent/src/cli/leader.rs +2 -5
- package/crates/team-agent/src/cli/mod.rs +258 -79
- package/crates/team-agent/src/cli/tests/compile.rs +69 -0
- package/crates/team-agent/src/cli/tests/main_preserved.rs +68 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +160 -2
- package/crates/team-agent/src/compiler.rs +30 -0
- package/crates/team-agent/src/coordinator/tick.rs +16 -83
- package/crates/team-agent/src/leader/lease.rs +4 -20
- package/crates/team-agent/src/leader/mod.rs +3 -1
- package/crates/team-agent/src/leader/owner_bind.rs +46 -48
- package/crates/team-agent/src/leader/provider_attribution.rs +214 -0
- package/crates/team-agent/src/leader/rediscover/tests.rs +3 -3
- package/crates/team-agent/src/leader/rediscover.rs +34 -17
- package/crates/team-agent/src/leader/start.rs +279 -4
- package/crates/team-agent/src/lifecycle/launch.rs +279 -24
- package/crates/team-agent/src/lifecycle/restart/common.rs +50 -40
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +343 -49
- package/crates/team-agent/src/lifecycle/restart/selection.rs +9 -6
- package/crates/team-agent/src/lifecycle/tests/core.rs +181 -45
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +390 -61
- package/crates/team-agent/src/lifecycle/types.rs +28 -0
- package/crates/team-agent/src/messaging/delivery.rs +5 -1
- package/crates/team-agent/src/messaging/helpers.rs +1 -1
- package/crates/team-agent/src/messaging/tests/runtime.rs +14 -0
- package/crates/team-agent/src/provider/adapter.rs +6 -3
- package/crates/team-agent/src/provider/startup_prompt.rs +173 -0
- package/crates/team-agent/src/provider/testdata/copilot-ready-marker.txt +5 -0
- package/crates/team-agent/src/provider/testdata/copilot-trust-prompt.txt +19 -0
- package/crates/team-agent/src/tmux_backend.rs +5 -0
- package/crates/team-agent/src/transport/test_support.rs +22 -7
- package/crates/team-agent/src/transport.rs +4 -0
- package/package.json +4 -4
|
@@ -472,6 +472,74 @@ fn seed_team_spec(ws: &std::path::Path) {
|
|
|
472
472
|
other => panic!("expected JSON output, got {other:?}"),
|
|
473
473
|
}
|
|
474
474
|
}
|
|
475
|
+
fn seed_remove_agent_workspace(ws: &std::path::Path, status: &str) {
|
|
476
|
+
seed_team_spec(ws);
|
|
477
|
+
crate::state::persist::save_runtime_state(
|
|
478
|
+
ws,
|
|
479
|
+
&json!({
|
|
480
|
+
"session_name": "team-agent-fake-e2e",
|
|
481
|
+
"agents": {
|
|
482
|
+
"fake_impl": {
|
|
483
|
+
"status": status,
|
|
484
|
+
"provider": "fake",
|
|
485
|
+
"window": "fake_impl"
|
|
486
|
+
}
|
|
487
|
+
},
|
|
488
|
+
"spec_path": ws.join("team.spec.yaml").to_string_lossy()
|
|
489
|
+
}),
|
|
490
|
+
)
|
|
491
|
+
.unwrap();
|
|
492
|
+
}
|
|
493
|
+
#[test]
|
|
494
|
+
fn remove_agent_running_refusal_is_not_success_envelope() {
|
|
495
|
+
let ws = tmp_workspace();
|
|
496
|
+
seed_remove_agent_workspace(&ws, "running");
|
|
497
|
+
let out = json_output(
|
|
498
|
+
cmd_remove_agent(&RemoveAgentArgs {
|
|
499
|
+
agent: "fake_impl".to_string(),
|
|
500
|
+
workspace: ws.clone(),
|
|
501
|
+
team: None,
|
|
502
|
+
from_spec: true,
|
|
503
|
+
confirm: true,
|
|
504
|
+
force: false,
|
|
505
|
+
json: true,
|
|
506
|
+
})
|
|
507
|
+
.unwrap(),
|
|
508
|
+
);
|
|
509
|
+
assert_eq!(out["ok"], json!(false));
|
|
510
|
+
assert_eq!(out["status"], json!("refused"));
|
|
511
|
+
assert_eq!(out["reason"], json!("force_required"));
|
|
512
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
513
|
+
assert!(
|
|
514
|
+
state["agents"].get("fake_impl").is_some(),
|
|
515
|
+
"refused remove-agent must not delete the running agent"
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
#[test]
|
|
519
|
+
fn remove_agent_from_spec_refusal_is_not_success_envelope() {
|
|
520
|
+
let ws = tmp_workspace();
|
|
521
|
+
seed_remove_agent_workspace(&ws, "stopped");
|
|
522
|
+
let out = json_output(
|
|
523
|
+
cmd_remove_agent(&RemoveAgentArgs {
|
|
524
|
+
agent: "fake_impl".to_string(),
|
|
525
|
+
workspace: ws.clone(),
|
|
526
|
+
team: None,
|
|
527
|
+
from_spec: false,
|
|
528
|
+
confirm: true,
|
|
529
|
+
force: false,
|
|
530
|
+
json: true,
|
|
531
|
+
})
|
|
532
|
+
.unwrap(),
|
|
533
|
+
);
|
|
534
|
+
assert_eq!(out["ok"], json!(false));
|
|
535
|
+
assert_eq!(out["status"], json!("refused"));
|
|
536
|
+
assert_eq!(out["reason"], json!("from_spec_confirm_required"));
|
|
537
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
538
|
+
assert!(
|
|
539
|
+
state["agents"].get("fake_impl").is_some(),
|
|
540
|
+
"refused remove-agent must not delete the spec-defined agent"
|
|
541
|
+
);
|
|
542
|
+
}
|
|
475
543
|
#[test]
|
|
476
544
|
fn validate_result_file_good_and_inline_garbage_are_distinct() {
|
|
477
545
|
let ws = tmp_workspace();
|
|
@@ -5,8 +5,15 @@
|
|
|
5
5
|
//! 集成面由 tests/b5_leader_terminal_kill_red.rs 的真 tmux 契约覆盖,此处锁纯决策 + 4 反向 case。
|
|
6
6
|
|
|
7
7
|
use crate::cli::lifecycle_port::{sessions_to_kill, KillDecision};
|
|
8
|
-
use crate::transport::
|
|
9
|
-
|
|
8
|
+
use crate::transport::{
|
|
9
|
+
AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport,
|
|
10
|
+
Key, PaneField, PaneId, PaneInfo, SessionName, SetEnvOutcome, SpawnResult, Target, Transport,
|
|
11
|
+
TransportError, WindowName,
|
|
12
|
+
};
|
|
13
|
+
use serde_json::json;
|
|
14
|
+
use std::collections::{BTreeMap, BTreeSet};
|
|
15
|
+
use std::path::{Path, PathBuf};
|
|
16
|
+
use std::sync::Mutex;
|
|
10
17
|
|
|
11
18
|
fn names(raw: &[&str]) -> Vec<SessionName> {
|
|
12
19
|
raw.iter().map(|name| SessionName::new(*name)).collect()
|
|
@@ -102,3 +109,154 @@ fn union_prefix_and_anchor_no_double_count() {
|
|
|
102
109
|
KillDecision::KillIndividually { to_kill: vec![], spared: names(&["team-agent-leader-claude-ws-beef"]) }
|
|
103
110
|
);
|
|
104
111
|
}
|
|
112
|
+
|
|
113
|
+
#[test]
|
|
114
|
+
fn missing_coordinator_is_ok_when_shutdown_cleaned_session() {
|
|
115
|
+
let ws = tmp_shutdown_workspace("missing-coordinator-clean");
|
|
116
|
+
crate::state::persist::save_runtime_state(
|
|
117
|
+
&ws,
|
|
118
|
+
&json!({
|
|
119
|
+
"session_name": "team-lane-l1-clean-shutdown",
|
|
120
|
+
"agents": {
|
|
121
|
+
"fake_impl": {
|
|
122
|
+
"status": "running",
|
|
123
|
+
"provider": "fake",
|
|
124
|
+
"window": "fake_impl"
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}),
|
|
128
|
+
)
|
|
129
|
+
.unwrap();
|
|
130
|
+
let out = crate::cli::lifecycle_port::shutdown_with_transport(
|
|
131
|
+
&ws,
|
|
132
|
+
true,
|
|
133
|
+
None,
|
|
134
|
+
&CleanShutdownTransport::new(),
|
|
135
|
+
)
|
|
136
|
+
.expect("shutdown should complete");
|
|
137
|
+
assert_eq!(out["coordinator"]["status"], json!("missing"));
|
|
138
|
+
assert_eq!(
|
|
139
|
+
out["ok"], json!(true),
|
|
140
|
+
"coordinator.status=missing alone must not make a fully cleaned shutdown partial: {out}"
|
|
141
|
+
);
|
|
142
|
+
assert_eq!(out["status"], json!("ok"));
|
|
143
|
+
assert_eq!(out["residuals"]["sessions"], json!([]));
|
|
144
|
+
assert_eq!(out["residuals"]["processes"], json!([]));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
fn tmp_shutdown_workspace(tag: &str) -> PathBuf {
|
|
148
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
149
|
+
static N: AtomicU64 = AtomicU64::new(0);
|
|
150
|
+
let dir = std::env::temp_dir().join(format!(
|
|
151
|
+
"ta-shutdown-{tag}-{}-{}",
|
|
152
|
+
std::process::id(),
|
|
153
|
+
N.fetch_add(1, Ordering::Relaxed)
|
|
154
|
+
));
|
|
155
|
+
std::fs::create_dir_all(dir.join(".team").join("runtime")).unwrap();
|
|
156
|
+
dir
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
struct CleanShutdownTransport {
|
|
160
|
+
session_present: Mutex<bool>,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
impl CleanShutdownTransport {
|
|
164
|
+
fn new() -> Self {
|
|
165
|
+
Self { session_present: Mutex::new(true) }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
impl Transport for CleanShutdownTransport {
|
|
170
|
+
fn kind(&self) -> BackendKind {
|
|
171
|
+
BackendKind::Tmux
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
fn spawn_first(
|
|
175
|
+
&self,
|
|
176
|
+
_session: &SessionName,
|
|
177
|
+
_window: &WindowName,
|
|
178
|
+
_argv: &[String],
|
|
179
|
+
_cwd: &Path,
|
|
180
|
+
_env: &BTreeMap<String, String>,
|
|
181
|
+
) -> Result<SpawnResult, TransportError> {
|
|
182
|
+
unimplemented!("shutdown test does not spawn")
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
fn spawn_into(
|
|
186
|
+
&self,
|
|
187
|
+
_session: &SessionName,
|
|
188
|
+
_window: &WindowName,
|
|
189
|
+
_argv: &[String],
|
|
190
|
+
_cwd: &Path,
|
|
191
|
+
_env: &BTreeMap<String, String>,
|
|
192
|
+
) -> Result<SpawnResult, TransportError> {
|
|
193
|
+
unimplemented!("shutdown test does not spawn")
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
fn inject(
|
|
197
|
+
&self,
|
|
198
|
+
_target: &Target,
|
|
199
|
+
_payload: &InjectPayload,
|
|
200
|
+
_submit: Key,
|
|
201
|
+
_bracketed: bool,
|
|
202
|
+
) -> Result<InjectReport, TransportError> {
|
|
203
|
+
unimplemented!("shutdown test does not inject")
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
fn send_keys(&self, _target: &Target, _keys: &[Key]) -> Result<(), TransportError> {
|
|
207
|
+
Ok(())
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
fn capture(
|
|
211
|
+
&self,
|
|
212
|
+
_target: &Target,
|
|
213
|
+
range: CaptureRange,
|
|
214
|
+
) -> Result<CapturedText, TransportError> {
|
|
215
|
+
Ok(CapturedText { text: String::new(), range })
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
fn query(&self, _target: &Target, _field: PaneField) -> Result<Option<String>, TransportError> {
|
|
219
|
+
Ok(None)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
fn liveness(
|
|
223
|
+
&self,
|
|
224
|
+
_pane: &PaneId,
|
|
225
|
+
) -> Result<crate::model::enums::PaneLiveness, TransportError> {
|
|
226
|
+
Ok(crate::model::enums::PaneLiveness::Live)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
230
|
+
Ok(Vec::new())
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
fn has_session(&self, _session: &SessionName) -> Result<bool, TransportError> {
|
|
234
|
+
Ok(*self.session_present.lock().unwrap())
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
|
|
238
|
+
Ok(Vec::new())
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
fn set_session_env(
|
|
242
|
+
&self,
|
|
243
|
+
_session: &SessionName,
|
|
244
|
+
_key: &str,
|
|
245
|
+
_value: &str,
|
|
246
|
+
) -> Result<SetEnvOutcome, TransportError> {
|
|
247
|
+
Ok(SetEnvOutcome::Applied)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
fn kill_session(&self, _session: &SessionName) -> Result<(), TransportError> {
|
|
251
|
+
*self.session_present.lock().unwrap() = false;
|
|
252
|
+
Ok(())
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
fn kill_window(&self, _target: &Target) -> Result<(), TransportError> {
|
|
256
|
+
Ok(())
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
fn attach_session(&self, _session: &SessionName) -> Result<AttachOutcome, TransportError> {
|
|
260
|
+
Ok(AttachOutcome::Attached)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -29,6 +29,14 @@ use std::path::Path;
|
|
|
29
29
|
use crate::model::yaml::Value;
|
|
30
30
|
use crate::model::{paths, spec, yaml, ModelError};
|
|
31
31
|
|
|
32
|
+
pub const IGNORED_OWNER_TEAM_ID_FIELD: &str = "owner_team_id";
|
|
33
|
+
|
|
34
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
35
|
+
pub struct IgnoredTeamField {
|
|
36
|
+
pub field: &'static str,
|
|
37
|
+
pub value: String,
|
|
38
|
+
}
|
|
39
|
+
|
|
32
40
|
/// `compiler._read_front_matter` (compiler.py:173-185).
|
|
33
41
|
///
|
|
34
42
|
/// Reads `path` (UTF-8). If the text does not start with `"---\n"`, returns
|
|
@@ -76,6 +84,21 @@ pub fn read_front_matter(path: &Path) -> Result<(Value, String), ModelError> {
|
|
|
76
84
|
Ok((meta, after_marker.trim_start_matches('\n').to_string()))
|
|
77
85
|
}
|
|
78
86
|
|
|
87
|
+
pub fn ignored_owner_team_id_from_team_md(team_dir: &Path) -> Result<Option<IgnoredTeamField>, ModelError> {
|
|
88
|
+
let team_md = team_dir.join("TEAM.md");
|
|
89
|
+
if !team_md.exists() {
|
|
90
|
+
return Ok(None);
|
|
91
|
+
}
|
|
92
|
+
let (team_meta, _) = read_front_matter(&team_md)?;
|
|
93
|
+
let Some(value) = team_meta.get(IGNORED_OWNER_TEAM_ID_FIELD) else {
|
|
94
|
+
return Ok(None);
|
|
95
|
+
};
|
|
96
|
+
Ok(Some(IgnoredTeamField {
|
|
97
|
+
field: IGNORED_OWNER_TEAM_ID_FIELD,
|
|
98
|
+
value: front_matter_value_label(value),
|
|
99
|
+
}))
|
|
100
|
+
}
|
|
101
|
+
|
|
79
102
|
/// `compiler.compile_team` (compiler.py:23-135) — returns the compiled spec dict.
|
|
80
103
|
///
|
|
81
104
|
/// `TEAM.md` + sorted `agents/*.md` → the canonical spec `Value::Map` with the
|
|
@@ -518,5 +541,12 @@ fn max_active_agents(count: usize) -> i64 {
|
|
|
518
541
|
}
|
|
519
542
|
}
|
|
520
543
|
|
|
544
|
+
fn front_matter_value_label(value: &Value) -> String {
|
|
545
|
+
value
|
|
546
|
+
.as_str()
|
|
547
|
+
.map(ToString::to_string)
|
|
548
|
+
.unwrap_or_else(|| yaml::dumps(value).trim().to_string())
|
|
549
|
+
}
|
|
550
|
+
|
|
521
551
|
#[cfg(test)]
|
|
522
552
|
mod tests;
|
|
@@ -264,55 +264,6 @@ impl Coordinator {
|
|
|
264
264
|
BTreeMap::new()
|
|
265
265
|
}
|
|
266
266
|
};
|
|
267
|
-
// C-3-4 cr verdict — copilot 一期 classify→None(Unknown);为防 silent,
|
|
268
|
-
// tick 每次发现 copilot agent(从 state.agents 直接扫,不依赖 captures —
|
|
269
|
-
// 离线/未起 tmux 场景仍能写)就发 `provider.classify.unsupported` 事件
|
|
270
|
-
// (字面 reason=`phase1_unknown_pending_sample`,含 provider="copilot" + "classify"
|
|
271
|
-
// 串)。二期接 sqlite turns 表后这条删/降级,届时改 reason 区分。
|
|
272
|
-
//
|
|
273
|
-
// B-4 P4 / 036b 防 dedup-flood:同 (provider, agent_id, reason) 状态跨 tick
|
|
274
|
-
// 只发一次(check_key 范式,同 abnormal_check_key tick.rs:603 同精神);状态
|
|
275
|
-
// 变了才再发。check_key 落 state.agents.<id>.classify_unsupported.last_key,
|
|
276
|
-
// tick-only metadata,不进 #235 owner / receiver 等持久态。
|
|
277
|
-
let agents_snapshot: Vec<(String, Option<String>)> =
|
|
278
|
-
if let Some(agents) = state.get("agents").and_then(Value::as_object) {
|
|
279
|
-
agents
|
|
280
|
-
.iter()
|
|
281
|
-
.filter_map(|(agent_id, agent)| {
|
|
282
|
-
let is_copilot = agent
|
|
283
|
-
.get("provider")
|
|
284
|
-
.and_then(Value::as_str)
|
|
285
|
-
.and_then(parse_provider)
|
|
286
|
-
.is_some_and(|p| matches!(p, crate::model::enums::Provider::Copilot));
|
|
287
|
-
if !is_copilot {
|
|
288
|
-
return None;
|
|
289
|
-
}
|
|
290
|
-
let last_key = agent
|
|
291
|
-
.get("classify_unsupported")
|
|
292
|
-
.and_then(|v| v.get("last_key"))
|
|
293
|
-
.and_then(Value::as_str)
|
|
294
|
-
.map(str::to_string);
|
|
295
|
-
Some((agent_id.clone(), last_key))
|
|
296
|
-
})
|
|
297
|
-
.collect()
|
|
298
|
-
} else {
|
|
299
|
-
Vec::new()
|
|
300
|
-
};
|
|
301
|
-
for (agent_id, last_key) in agents_snapshot {
|
|
302
|
-
let check_key = format!("copilot|{agent_id}|phase1_unknown_pending_sample");
|
|
303
|
-
if last_key.as_deref() == Some(check_key.as_str()) {
|
|
304
|
-
continue;
|
|
305
|
-
}
|
|
306
|
-
let _ = event_log.write(
|
|
307
|
-
"provider.classify.unsupported",
|
|
308
|
-
serde_json::json!({
|
|
309
|
-
"provider": "copilot",
|
|
310
|
-
"agent_id": agent_id,
|
|
311
|
-
"reason": "phase1_unknown_pending_sample",
|
|
312
|
-
}),
|
|
313
|
-
);
|
|
314
|
-
mark_classify_unsupported(&mut state, &agent_id, &check_key);
|
|
315
|
-
}
|
|
316
267
|
if let Err(error) = self.detect_abnormal_exits(&mut state, &event_log, &pane_snapshot) {
|
|
317
268
|
let _ = event_log.write(
|
|
318
269
|
"coordinator.tick.detect_abnormal_failed",
|
|
@@ -1644,7 +1595,7 @@ fn agent_process_liveness(
|
|
|
1644
1595
|
}
|
|
1645
1596
|
if let Some(target) = matching_agent_target(agent, session_name, targets) {
|
|
1646
1597
|
if let Some(command) = target.current_command.as_deref() {
|
|
1647
|
-
return
|
|
1598
|
+
return pane_command_process_check(agent.provider, target, command);
|
|
1648
1599
|
}
|
|
1649
1600
|
if let Some(pid) = target.pane_pid.map(Pid::new) {
|
|
1650
1601
|
return pid_process_check("pane_pid", pid);
|
|
@@ -1731,7 +1682,7 @@ fn pid_process_check(label: &str, pid: Pid) -> ProcessCheck {
|
|
|
1731
1682
|
}
|
|
1732
1683
|
|
|
1733
1684
|
fn command_process_check(provider: crate::model::enums::Provider, command: &str) -> ProcessCheck {
|
|
1734
|
-
if
|
|
1685
|
+
if crate::leader::command_matches_provider(provider, command) {
|
|
1735
1686
|
process_check(ProcessLiveness::Alive, format!("current_command:{command}"))
|
|
1736
1687
|
} else {
|
|
1737
1688
|
process_check(
|
|
@@ -1741,16 +1692,20 @@ fn command_process_check(provider: crate::model::enums::Provider, command: &str)
|
|
|
1741
1692
|
}
|
|
1742
1693
|
}
|
|
1743
1694
|
|
|
1744
|
-
fn
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
crate::
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1695
|
+
fn pane_command_process_check(
|
|
1696
|
+
provider: crate::model::enums::Provider,
|
|
1697
|
+
pane: &crate::transport::PaneInfo,
|
|
1698
|
+
command: &str,
|
|
1699
|
+
) -> ProcessCheck {
|
|
1700
|
+
if crate::leader::attribute_pane_provider(pane)
|
|
1701
|
+
.is_some_and(|candidate| crate::leader::provider_matches(candidate, provider))
|
|
1702
|
+
{
|
|
1703
|
+
process_check(ProcessLiveness::Alive, format!("current_command:{command}"))
|
|
1704
|
+
} else {
|
|
1705
|
+
process_check(
|
|
1706
|
+
ProcessLiveness::Dead,
|
|
1707
|
+
format!("provider_not_foreground:{command}"),
|
|
1708
|
+
)
|
|
1754
1709
|
}
|
|
1755
1710
|
}
|
|
1756
1711
|
|
|
@@ -1962,28 +1917,6 @@ fn mark_abnormal_suppressed(state: &mut Value, agent_id: &str, key: &str) {
|
|
|
1962
1917
|
}
|
|
1963
1918
|
}
|
|
1964
1919
|
|
|
1965
|
-
/// B-4 P4 dedup marker — 把 classify.unsupported check_key 写到
|
|
1966
|
-
/// state.agents.<id>.classify_unsupported.last_key,只在状态变了才再发事件。
|
|
1967
|
-
/// 同 abnormal_check_key (line 603) 同精神,但落 agents 子 obj(per-agent locality,
|
|
1968
|
-
/// 不污染 abnormal_exit_watch 命名空间)。
|
|
1969
|
-
fn mark_classify_unsupported(state: &mut Value, agent_id: &str, key: &str) {
|
|
1970
|
-
let Some(agents) = state.get_mut("agents").and_then(Value::as_object_mut) else {
|
|
1971
|
-
return;
|
|
1972
|
-
};
|
|
1973
|
-
let Some(agent) = agents.get_mut(agent_id).and_then(Value::as_object_mut) else {
|
|
1974
|
-
return;
|
|
1975
|
-
};
|
|
1976
|
-
let entry = agent
|
|
1977
|
-
.entry("classify_unsupported".to_string())
|
|
1978
|
-
.or_insert_with(|| serde_json::json!({}));
|
|
1979
|
-
if !entry.is_object() {
|
|
1980
|
-
*entry = serde_json::json!({});
|
|
1981
|
-
}
|
|
1982
|
-
if let Some(obj) = entry.as_object_mut() {
|
|
1983
|
-
obj.insert("last_key".to_string(), serde_json::json!(key));
|
|
1984
|
-
}
|
|
1985
|
-
}
|
|
1986
|
-
|
|
1987
1920
|
fn mark_abnormal_checked(state: &mut Value, agent_id: &str, key: &str) {
|
|
1988
1921
|
if let Some(watch) = coordinator_child_object(state, "abnormal_exit_watch") {
|
|
1989
1922
|
let entry = watch
|
|
@@ -634,8 +634,7 @@ fn claim_target_from_pane_info(workspace: &Path, target: &PaneInfo) -> Option<Le
|
|
|
634
634
|
if !target.active {
|
|
635
635
|
return None;
|
|
636
636
|
}
|
|
637
|
-
let
|
|
638
|
-
let provider = leader_command_provider(command)?;
|
|
637
|
+
let provider = super::attribute_pane_provider(target)?;
|
|
639
638
|
let current_path = target.current_path.as_deref()?;
|
|
640
639
|
if !crate::state::owner_gate::workspace_paths_match(current_path, workspace) {
|
|
641
640
|
return None;
|
|
@@ -648,21 +647,6 @@ fn claim_target_from_pane_info(workspace: &Path, target: &PaneInfo) -> Option<Le
|
|
|
648
647
|
})
|
|
649
648
|
}
|
|
650
649
|
|
|
651
|
-
fn leader_command_provider(command: &str) -> Option<Provider> {
|
|
652
|
-
let lower = command.to_ascii_lowercase();
|
|
653
|
-
if lower.contains("claude") {
|
|
654
|
-
Some(Provider::ClaudeCode)
|
|
655
|
-
} else if lower.contains("codex") {
|
|
656
|
-
Some(Provider::Codex)
|
|
657
|
-
} else if lower.contains("copilot") {
|
|
658
|
-
Some(Provider::Copilot)
|
|
659
|
-
} else if lower.contains("fake") {
|
|
660
|
-
Some(Provider::Fake)
|
|
661
|
-
} else {
|
|
662
|
-
None
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
|
|
666
650
|
fn target_leader_session_uuid(target: &PaneInfo) -> Option<crate::model::ids::LeaderSessionUuid> {
|
|
667
651
|
target
|
|
668
652
|
.leader_env
|
|
@@ -1090,13 +1074,13 @@ mod tests {
|
|
|
1090
1074
|
use super::*;
|
|
1091
1075
|
|
|
1092
1076
|
#[test]
|
|
1093
|
-
fn
|
|
1077
|
+
fn attribute_command_provider_recognizes_copilot() {
|
|
1094
1078
|
assert_eq!(
|
|
1095
|
-
|
|
1079
|
+
super::super::attribute_command_provider("copilot --allow-all-tools"),
|
|
1096
1080
|
Some(Provider::Copilot)
|
|
1097
1081
|
);
|
|
1098
1082
|
assert_eq!(
|
|
1099
|
-
|
|
1083
|
+
super::super::attribute_command_provider("/usr/local/bin/copilot"),
|
|
1100
1084
|
Some(Provider::Copilot)
|
|
1101
1085
|
);
|
|
1102
1086
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
//! (`build_idle_nodes` / `push_idle_reminder`) / `wake.py`
|
|
14
14
|
//! (`should_reread` / `on_file_changed` / `take_pending`).
|
|
15
15
|
//! - `messaging/leader_panes.py` (`_leader_command_looks_usable` /
|
|
16
|
-
//!
|
|
16
|
+
//! provider attribution / `_target_leader_session_uuid`).
|
|
17
17
|
//!
|
|
18
18
|
//! 职责(card §职责):拥有「谁是 leader、消息投到哪个 pane」这一身份事实,当成租约(lease)
|
|
19
19
|
//! 管理。pane id 即权威路由/授权身份,`owner_epoch` 做 CAS/去重,确定派生的
|
|
@@ -79,6 +79,7 @@ mod helpers;
|
|
|
79
79
|
pub mod inject;
|
|
80
80
|
pub mod lease;
|
|
81
81
|
pub mod owner_bind;
|
|
82
|
+
pub mod provider_attribution;
|
|
82
83
|
pub mod rediscover;
|
|
83
84
|
pub mod start;
|
|
84
85
|
pub mod takeover;
|
|
@@ -88,6 +89,7 @@ pub mod types;
|
|
|
88
89
|
pub use inject::*;
|
|
89
90
|
pub use lease::*;
|
|
90
91
|
pub use owner_bind::*;
|
|
92
|
+
pub(crate) use provider_attribution::*;
|
|
91
93
|
pub use rediscover::*;
|
|
92
94
|
pub use start::*;
|
|
93
95
|
pub use takeover::*;
|
|
@@ -8,7 +8,7 @@ use serde_json::{json, Value};
|
|
|
8
8
|
use crate::model::ids::{LeaderSessionUuid, OwnerEpoch, TeamKey};
|
|
9
9
|
use crate::provider::Provider;
|
|
10
10
|
use crate::tmux_backend::TmuxBackend;
|
|
11
|
-
use crate::transport::{PaneField, PaneId, Target, Transport};
|
|
11
|
+
use crate::transport::{PaneField, PaneId, PaneInfo, SessionName, Target, Transport};
|
|
12
12
|
|
|
13
13
|
use super::helpers::{get_path_str, now_ts, prefix, resolve_workspace_for_hash};
|
|
14
14
|
use super::{
|
|
@@ -134,8 +134,12 @@ pub fn bind_owner_from_caller_pane(
|
|
|
134
134
|
hint: Some(hint.to_string()),
|
|
135
135
|
});
|
|
136
136
|
};
|
|
137
|
-
let
|
|
138
|
-
let
|
|
137
|
+
let caller_info = tmux_pane_info(workspace, &pane);
|
|
138
|
+
let caller_current_command = caller_info
|
|
139
|
+
.as_ref()
|
|
140
|
+
.and_then(|info| info.current_command.clone())
|
|
141
|
+
.unwrap_or_else(|| tmux_pane_current_command(workspace, &pane).unwrap_or_default());
|
|
142
|
+
let provider = bind_provider_from_env_or_pane(&caller_current_command, caller_info);
|
|
139
143
|
let machine_fingerprint = std::env::var("TEAM_AGENT_MACHINE_FINGERPRINT").unwrap_or_default();
|
|
140
144
|
let os_user = std::env::var("USER")
|
|
141
145
|
.or_else(|_| std::env::var("USERNAME"))
|
|
@@ -185,47 +189,19 @@ pub fn emit_owner_bound_event(
|
|
|
185
189
|
Ok(())
|
|
186
190
|
}
|
|
187
191
|
|
|
188
|
-
fn
|
|
189
|
-
std::env::var("TEAM_AGENT_LEADER_PROVIDER")
|
|
192
|
+
fn bind_provider_from_env_or_pane(command: &str, pane: Option<PaneInfo>) -> Provider {
|
|
193
|
+
let env_provider = std::env::var("TEAM_AGENT_LEADER_PROVIDER")
|
|
190
194
|
.ok()
|
|
191
|
-
.and_then(|raw| super::helpers::parse_provider(&raw))
|
|
192
|
-
|
|
195
|
+
.and_then(|raw| super::helpers::parse_provider(&raw));
|
|
196
|
+
env_provider
|
|
197
|
+
.or_else(|| pane.as_ref().and_then(super::attribute_pane_provider))
|
|
198
|
+
.or_else(|| super::attribute_command_provider(command))
|
|
193
199
|
// E11 层2:未知命令不再静默默认 codex(会误绑任意 provider + 喂错分类器)。
|
|
194
|
-
// 无法识别时回落 Codex
|
|
200
|
+
// 无法识别时回落 Codex 仅作最末兜底,且该路径已被统一归因入口的显式 None 收窄
|
|
195
201
|
// (调用方理应只在已知 leader 命令上 bind);保留以不改 fn 签名/上游 panic 面。
|
|
196
202
|
.unwrap_or(Provider::Codex)
|
|
197
203
|
}
|
|
198
204
|
|
|
199
|
-
/// E11 层2 + N39:command 名 → wire 串 → `parse_provider`(**单一映射源**,与
|
|
200
|
-
/// `owner_bind_provider_wire` 共用 [`command_provider_wire`])。未知命令 → `None`
|
|
201
|
-
/// (危险的 `_ => Codex` 默认已删:不静默把任意 provider 误绑成 codex)。
|
|
202
|
-
fn provider_from_command(command: &str) -> Option<Provider> {
|
|
203
|
-
command_provider_wire(command).and_then(super::helpers::parse_provider)
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/// command 名 → provider wire 串(单一真相;copilot/claude/codex/fake)。未知 → `None`。
|
|
207
|
-
/// `claude.exe` 归一为 `claude`。
|
|
208
|
-
fn command_provider_wire(command: &str) -> Option<&'static str> {
|
|
209
|
-
match exact_command_name(command).as_deref() {
|
|
210
|
-
Some("claude") | Some("claude.exe") => Some("claude"),
|
|
211
|
-
Some("codex") => Some("codex"),
|
|
212
|
-
Some("copilot") => Some("copilot"),
|
|
213
|
-
Some("fake") => Some("fake"),
|
|
214
|
-
_ => None,
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
fn exact_command_name(command: &str) -> Option<String> {
|
|
219
|
-
let last = command
|
|
220
|
-
.split_whitespace()
|
|
221
|
-
.next()
|
|
222
|
-
.unwrap_or(command)
|
|
223
|
-
.rsplit(['/', '\\'])
|
|
224
|
-
.next()?;
|
|
225
|
-
let lower = last.to_ascii_lowercase();
|
|
226
|
-
if lower.is_empty() { None } else { Some(lower) }
|
|
227
|
-
}
|
|
228
|
-
|
|
229
205
|
pub fn owner_bind_provider_wire(command: &str) -> &'static str {
|
|
230
206
|
if let Ok(raw) = std::env::var("TEAM_AGENT_LEADER_PROVIDER") {
|
|
231
207
|
// env 显式 provider:经 parse_provider(单一表,知 copilot)校验后透传其 wire 串;
|
|
@@ -234,9 +210,9 @@ pub fn owner_bind_provider_wire(command: &str) -> &'static str {
|
|
|
234
210
|
.map(super::helpers::provider_wire)
|
|
235
211
|
.unwrap_or("");
|
|
236
212
|
}
|
|
237
|
-
// E11 层2 + N39
|
|
213
|
+
// E11 层2 + N39:与统一 provider attribution 共用单一映射(含 copilot);
|
|
238
214
|
// 未知命令 → ""(不绑),不再静默当 codex。
|
|
239
|
-
|
|
215
|
+
super::provider_wire_from_command(command).unwrap_or("")
|
|
240
216
|
}
|
|
241
217
|
|
|
242
218
|
fn family_a_identity(
|
|
@@ -295,6 +271,28 @@ fn tmux_pane_current_command(workspace: &Path, pane: &str) -> Result<String, Lea
|
|
|
295
271
|
.map_err(|e| LeaderError::Tmux(e.to_string()))
|
|
296
272
|
}
|
|
297
273
|
|
|
274
|
+
fn tmux_pane_info(workspace: &Path, pane: &str) -> Option<PaneInfo> {
|
|
275
|
+
let target = PaneId::new(pane);
|
|
276
|
+
TmuxBackend::for_workspace(workspace)
|
|
277
|
+
.list_targets()
|
|
278
|
+
.ok()?
|
|
279
|
+
.into_iter()
|
|
280
|
+
.find(|info| info.pane_id == target)
|
|
281
|
+
.or_else(|| tmux_pane_current_command(workspace, pane).ok().map(|command| PaneInfo {
|
|
282
|
+
pane_id: target,
|
|
283
|
+
session: SessionName::new(""),
|
|
284
|
+
window_index: None,
|
|
285
|
+
window_name: None,
|
|
286
|
+
pane_index: None,
|
|
287
|
+
tty: None,
|
|
288
|
+
current_command: (!command.is_empty()).then_some(command),
|
|
289
|
+
current_path: None,
|
|
290
|
+
active: true,
|
|
291
|
+
pane_pid: None,
|
|
292
|
+
leader_env: Default::default(),
|
|
293
|
+
}))
|
|
294
|
+
}
|
|
295
|
+
|
|
298
296
|
// NOTE: `derive_leader_session_uuid`(`leader_binding.py:146`)已由
|
|
299
297
|
// `model::ids::LeaderSessionUuid::derive` 字节对齐实现(含 NUL 拒绝 + golden 测试)——
|
|
300
298
|
// 此 lane REUSE 之,不重声明。
|
|
@@ -307,25 +305,25 @@ mod e11_provider_bind_tests {
|
|
|
307
305
|
// E11 层2:copilot leader 命令必须绑成 Provider::Copilot(此前缺臂 → _ => Codex 误绑)。
|
|
308
306
|
#[test]
|
|
309
307
|
fn copilot_command_binds_copilot_not_codex() {
|
|
310
|
-
assert_eq!(
|
|
311
|
-
assert_eq!(
|
|
308
|
+
assert_eq!(super::super::attribute_command_provider("copilot --banner -C /ws"), Some(Provider::Copilot));
|
|
309
|
+
assert_eq!(super::super::attribute_command_provider("/opt/homebrew/bin/copilot"), Some(Provider::Copilot));
|
|
312
310
|
assert_eq!(owner_bind_provider_wire("copilot --banner"), "copilot");
|
|
313
311
|
}
|
|
314
312
|
|
|
315
313
|
#[test]
|
|
316
314
|
fn known_commands_map_via_single_source() {
|
|
317
|
-
assert_eq!(
|
|
318
|
-
assert_eq!(
|
|
319
|
-
assert_eq!(
|
|
315
|
+
assert_eq!(super::super::attribute_command_provider("claude"), Some(Provider::ClaudeCode));
|
|
316
|
+
assert_eq!(super::super::attribute_command_provider("codex"), Some(Provider::Codex));
|
|
317
|
+
assert_eq!(super::super::attribute_command_provider("fake"), Some(Provider::Fake));
|
|
320
318
|
assert_eq!(owner_bind_provider_wire("claude"), "claude");
|
|
321
319
|
assert_eq!(owner_bind_provider_wire("codex"), "codex");
|
|
322
320
|
}
|
|
323
321
|
|
|
324
|
-
// E11 层2:未知命令不再静默默认 codex ——
|
|
322
|
+
// E11 层2:未知命令不再静默默认 codex —— attribution → None,wire → ""。
|
|
325
323
|
#[test]
|
|
326
324
|
fn unknown_command_is_none_not_silent_codex() {
|
|
327
|
-
assert_eq!(
|
|
328
|
-
assert_eq!(
|
|
325
|
+
assert_eq!(super::super::attribute_command_provider("node /some/thing.js"), None);
|
|
326
|
+
assert_eq!(super::super::attribute_command_provider("totally-unknown"), None);
|
|
329
327
|
assert_eq!(owner_bind_provider_wire("totally-unknown"), "");
|
|
330
328
|
}
|
|
331
329
|
}
|