@team-agent/installer 0.5.78 → 0.5.81
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/models.rs +31 -0
- package/crates/team-agent/src/cli/status_port/brief.rs +40 -16
- package/crates/team-agent/src/cli/status_port/runtime.rs +3 -1
- package/crates/team-agent/src/cli/status_port/tests.rs +37 -1
- package/crates/team-agent/src/cli/tests/status_brief.rs +199 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +122 -2
- package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/s3_bounded.rs +239 -0
- package/crates/team-agent/src/coordinator/tick.rs +21 -13
- package/crates/team-agent/src/leader/start.rs +46 -74
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +21 -5
- package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt +9 -0
- package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +71 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +2 -11
- package/crates/team-agent/src/mcp_server/tools.rs +1 -3
- package/crates/team-agent/src/messaging/delivery.rs +154 -72
- package/crates/team-agent/src/messaging/results.rs +1 -2
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +127 -0
- package/crates/team-agent/src/provider/session_scan/common/tests.rs +204 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +24 -2
- package/crates/team-agent/src/state/abnormal_watch/tests/mod.rs +248 -0
- package/crates/team-agent/src/state/abnormal_watch.rs +144 -0
- package/crates/team-agent/src/state/mod.rs +1 -0
- package/crates/team-agent/src/state/persist.rs +47 -5
- package/crates/team-agent/src/state/projection.rs +31 -1
- package/crates/team-agent/src/state/repository/tests.rs +81 -0
- package/crates/team-agent/src/state/repository.rs +169 -1
- package/crates/team-agent/src/tmux_backend/tests.rs +28 -0
- package/crates/team-agent/src/tmux_backend.rs +31 -9
- package/crates/team-agent/src/transport.rs +6 -0
- package/package.json +4 -4
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
use serde_json::json;
|
|
3
|
+
use std::io::{BufRead, Write};
|
|
4
|
+
use std::path::{Path, PathBuf};
|
|
5
|
+
use std::process::{Child, Command, Stdio};
|
|
6
|
+
use crate::state::repository::{StateRepository, StateWriteIntent};
|
|
7
|
+
|
|
8
|
+
const WORKER: &str = "coordinator::tests::s3_bounded::s3_process_worker";
|
|
9
|
+
|
|
10
|
+
fn isolated(name: &str, check: impl FnOnce(&Path)) {
|
|
11
|
+
if std::env::var("TA_S3_CASE").as_deref() == Ok(name) {
|
|
12
|
+
check(&std::env::current_dir().unwrap());
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
let dir = std::env::temp_dir().join(format!("ta-s3-{name}-{}", std::process::id()));
|
|
16
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
17
|
+
let output = Command::new(std::env::current_exe().unwrap())
|
|
18
|
+
.args(["--exact", &format!("coordinator::tests::s3_bounded::{name}"), "--nocapture"])
|
|
19
|
+
.env_clear().env("HOME", dir.join("home")).env("TA_S3_CASE", name)
|
|
20
|
+
.current_dir(&dir).output().unwrap();
|
|
21
|
+
print!("{}", String::from_utf8_lossy(&output.stdout));
|
|
22
|
+
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
|
23
|
+
std::fs::remove_dir_all(dir).unwrap();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
fn disk(dir: &Path) -> Value {
|
|
27
|
+
serde_json::from_str(&std::fs::read_to_string(crate::state::persist::runtime_state_path(dir)).unwrap()).unwrap()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
fn seed(dir: &Path, legacy: bool) {
|
|
31
|
+
std::fs::create_dir_all(dir).unwrap();
|
|
32
|
+
let team = |key: &str| {
|
|
33
|
+
let spec = dir.join(".team/runtime").join(key).join("team.spec.yaml");
|
|
34
|
+
std::fs::create_dir_all(spec.parent().unwrap()).unwrap();
|
|
35
|
+
std::fs::write(&spec, format!("name: {key}\nagents: []\ntasks: []\n")).unwrap();
|
|
36
|
+
json!({
|
|
37
|
+
"session_name": key, "team_dir": dir.join(".team").join(key), "spec_path": spec,
|
|
38
|
+
"agents": {"w1": {"agent_id": "w1", "provider": "fake", "status": "running",
|
|
39
|
+
"window": "w1", "pane_id": "%11", "spawn_epoch": 1,
|
|
40
|
+
"session_id": "fixture-session", "startup_prompts": "handled"}},
|
|
41
|
+
"tasks": [{"id": "original", "title": "Original task", "assignee": "w1", "status": "pending"}],
|
|
42
|
+
"notes": ["original note"]
|
|
43
|
+
})
|
|
44
|
+
};
|
|
45
|
+
let mut state = team("T1");
|
|
46
|
+
state["active_team_key"] = json!("T1");
|
|
47
|
+
if !legacy { state["teams"] = json!({"T1": state.clone(), "T2": team("T2")}); }
|
|
48
|
+
crate::state::persist::save_runtime_state(dir, &state).unwrap();
|
|
49
|
+
MessageStore::open(dir).unwrap();
|
|
50
|
+
std::fs::write(dir.join("result.json"), json!({
|
|
51
|
+
"schema_version": "result_envelope_v1", "result_id": "result-original",
|
|
52
|
+
"task_id": "original", "agent_id": "w1", "status": "success", "summary": "done",
|
|
53
|
+
"artifacts": [], "changes": [], "tests": [], "risks": [], "next_actions": []
|
|
54
|
+
}).to_string()).unwrap();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
fn task(id: &str) -> Value { json!({"id": id, "title": id, "assignee": "w1"}) }
|
|
58
|
+
|
|
59
|
+
fn start_writer(dir: &Path, action: &str, team: Option<&str>, payload: Value, pause: bool) -> Child {
|
|
60
|
+
let mut child = Command::new(std::env::current_exe().unwrap())
|
|
61
|
+
.args(["--exact", WORKER, "--ignored", "--nocapture"])
|
|
62
|
+
.env_clear().env("HOME", dir.join("home"))
|
|
63
|
+
.env("TA_S3_ACTION", action).env("TA_S3_TEAM", team.unwrap_or(""))
|
|
64
|
+
.env("TA_S3_PAYLOAD", payload.to_string()).env("TA_S3_PAUSE", if pause { "1" } else { "0" })
|
|
65
|
+
.current_dir(dir).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped())
|
|
66
|
+
.spawn().unwrap();
|
|
67
|
+
if pause {
|
|
68
|
+
let mut reader = std::io::BufReader::new(child.stdout.take().unwrap());
|
|
69
|
+
loop {
|
|
70
|
+
let mut line = String::new();
|
|
71
|
+
assert!(reader.read_line(&mut line).unwrap() > 0, "writer exited before barrier");
|
|
72
|
+
print!("{line}");
|
|
73
|
+
if line.contains("S3_READY") { break; }
|
|
74
|
+
}
|
|
75
|
+
child.stdout = Some(reader.into_inner());
|
|
76
|
+
}
|
|
77
|
+
child
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fn finish(mut child: Child, paused: bool) {
|
|
81
|
+
if paused { child.stdin.take().unwrap().write_all(b"commit\n").unwrap(); }
|
|
82
|
+
let output = child.wait_with_output().unwrap();
|
|
83
|
+
print!("{}", String::from_utf8_lossy(&output.stdout));
|
|
84
|
+
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
fn write(dir: &Path, action: &str, team: Option<&str>, payload: Value) {
|
|
88
|
+
finish(start_writer(dir, action, team, payload, false), false);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#[test]
|
|
92
|
+
#[ignore = "hermetic subprocess entry; parent contracts invoke this exact test"]
|
|
93
|
+
fn s3_process_worker() {
|
|
94
|
+
let dir = std::env::current_dir().unwrap();
|
|
95
|
+
let action = std::env::var("TA_S3_ACTION").unwrap();
|
|
96
|
+
let team = std::env::var("TA_S3_TEAM").unwrap();
|
|
97
|
+
let team = (!team.is_empty()).then_some(team.as_str());
|
|
98
|
+
let payload: Value = serde_json::from_str(&std::env::var("TA_S3_PAYLOAD").unwrap()).unwrap();
|
|
99
|
+
if std::env::var("TA_S3_PAUSE").as_deref() == Ok("1") {
|
|
100
|
+
crate::state::repository::test_support::set_before_commit(|| {
|
|
101
|
+
println!("S3_READY");
|
|
102
|
+
std::io::stdout().flush().unwrap();
|
|
103
|
+
let mut line = String::new();
|
|
104
|
+
std::io::stdin().read_line(&mut line).unwrap();
|
|
105
|
+
assert_eq!(line.trim(), "commit");
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
let tools = crate::mcp_server::TeamOrchestratorTools::with_identity(
|
|
109
|
+
&dir, Some(crate::model::ids::AgentId::new("leader")), team.map(crate::model::ids::TeamKey::new),
|
|
110
|
+
);
|
|
111
|
+
match action.as_str() {
|
|
112
|
+
"assign" => { tools.assign_task(&payload, Some("bounded contract")).unwrap(); }
|
|
113
|
+
"note" => { println!("S3 note={:?}", tools.update_state(payload.as_str().unwrap()).unwrap()); }
|
|
114
|
+
"collect" => {
|
|
115
|
+
let output = crate::messaging::results::collect_for_team(&dir, Some(&dir.join("result.json")), false, team).unwrap();
|
|
116
|
+
assert_eq!(output["ok"], true);
|
|
117
|
+
println!("S3 collect={output}");
|
|
118
|
+
}
|
|
119
|
+
"sibling" => {
|
|
120
|
+
let mut state = StateRepository::new(&dir).load_workspace().unwrap();
|
|
121
|
+
state["teams"]["T3"] = json!({"session_name": "T3", "tasks": [task("third")], "notes": ["third"]});
|
|
122
|
+
StateRepository::new(&dir).save(StateWriteIntent::LaunchTeam { team_key: "T3" }, &state).unwrap();
|
|
123
|
+
}
|
|
124
|
+
"topology" => {
|
|
125
|
+
let mut state = StateRepository::new(&dir).load_team(Some("T1")).unwrap();
|
|
126
|
+
state["agents"]["w1"]["pane_id"] = json!("%22");
|
|
127
|
+
state["agents"]["w1"]["spawn_epoch"] = json!(2);
|
|
128
|
+
// A concurrent restart owns topology for this agent. StartAgent's
|
|
129
|
+
// add-only tail intentionally carries no existing-agent authority.
|
|
130
|
+
StateRepository::new(&dir).save(StateWriteIntent::RestartTeam {
|
|
131
|
+
team_key: "T1", topology_authority_agent_ids: &["w1"],
|
|
132
|
+
skip_capture_backfill_agent_ids: &[],
|
|
133
|
+
}, &state).unwrap();
|
|
134
|
+
assert_eq!(disk(&dir)["teams"]["T1"]["agents"]["w1"]["pane_id"], "%22");
|
|
135
|
+
}
|
|
136
|
+
_ => panic!("unexpected test action"),
|
|
137
|
+
}
|
|
138
|
+
println!("S3_COMMITTED {action}");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
fn coordinator(dir: &Path, hook: SaveHook) -> Coordinator {
|
|
142
|
+
let transport = OfflineTransport::new().with_session_present(true)
|
|
143
|
+
.with_windows(vec![WindowName::new("live-peer")]);
|
|
144
|
+
Coordinator::for_test(WorkspacePath::new(dir.to_path_buf()),
|
|
145
|
+
Box::new(MockRegistry::new(&[], &[])), Box::new(transport), None, None)
|
|
146
|
+
.with_before_save_hook(hook)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#[test]
|
|
150
|
+
fn s3_tick_preserves_tasks_notes_and_siblings() {
|
|
151
|
+
isolated("s3_tick_preserves_tasks_notes_and_siblings", |dir| {
|
|
152
|
+
seed(dir, false);
|
|
153
|
+
let hook: SaveHook = Box::new(|ws, observed| {
|
|
154
|
+
assert_eq!(observed["agents"]["w1"]["worker_state"], "DEAD", "tick must produce a real observation delta");
|
|
155
|
+
write(ws.as_path(), "assign", Some("T1"), task("new-task"));
|
|
156
|
+
write(ws.as_path(), "note", Some("T1"), json!("concurrent note"));
|
|
157
|
+
write(ws.as_path(), "collect", Some("T1"), Value::Null);
|
|
158
|
+
write(ws.as_path(), "note", Some("T2"), json!("sibling note"));
|
|
159
|
+
write(ws.as_path(), "sibling", None, Value::Null);
|
|
160
|
+
let latest = disk(ws.as_path());
|
|
161
|
+
assert_eq!(latest["teams"]["T1"]["agents"]["w1"]["pane_id"], observed["agents"]["w1"]["pane_id"]);
|
|
162
|
+
println!("S3 before tick save topology unchanged; committed business={}", latest["teams"]);
|
|
163
|
+
Ok(())
|
|
164
|
+
});
|
|
165
|
+
let report = coordinator(dir, hook).tick().unwrap();
|
|
166
|
+
assert!(report.ok, "tick must save its observation: {report:?}");
|
|
167
|
+
let state = disk(dir);
|
|
168
|
+
let team = &state["teams"]["T1"];
|
|
169
|
+
assert_eq!(team["tasks"][0]["accepted_result_id"], "result-original");
|
|
170
|
+
assert!(team["tasks"].as_array().unwrap().iter().any(|row| row["id"] == "new-task"));
|
|
171
|
+
assert_eq!(team["notes"], json!(["original note", "concurrent note"]));
|
|
172
|
+
assert_eq!(team["agents"]["w1"]["worker_state"], "DEAD");
|
|
173
|
+
assert_eq!(state["teams"]["T2"]["notes"], json!(["original note", "sibling note"]));
|
|
174
|
+
assert_eq!(state["teams"]["T3"]["notes"], json!(["third"]));
|
|
175
|
+
assert_eq!(state["tasks"], team["tasks"]);
|
|
176
|
+
assert_eq!(StateRepository::new(dir).load_team(Some("T1")).unwrap()["tasks"], team["tasks"]);
|
|
177
|
+
println!("S3 after tick save={state}");
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#[test]
|
|
182
|
+
fn s3_business_writers_commit_once_on_latest() {
|
|
183
|
+
isolated("s3_business_writers_commit_once_on_latest", |root| {
|
|
184
|
+
for (i, (left, right)) in [
|
|
185
|
+
(("assign", task("left")), ("assign", task("right"))),
|
|
186
|
+
(("note", json!("same note")), ("note", json!("same note"))),
|
|
187
|
+
(("note", json!("same note")), ("assign", task("right"))),
|
|
188
|
+
(("collect", Value::Null), ("assign", task("right"))),
|
|
189
|
+
(("collect", Value::Null), ("note", json!("same note"))),
|
|
190
|
+
].into_iter().enumerate() {
|
|
191
|
+
let dir = root.join(i.to_string());
|
|
192
|
+
seed(&dir, false);
|
|
193
|
+
let waiting = start_writer(&dir, left.0, Some("T1"), left.1, true);
|
|
194
|
+
write(&dir, right.0, Some("T1"), right.1);
|
|
195
|
+
finish(waiting, true);
|
|
196
|
+
let state = disk(&dir);
|
|
197
|
+
let team = &state["teams"]["T1"];
|
|
198
|
+
if left.0 == "assign" { assert!(team["tasks"].as_array().unwrap().iter().any(|task| task["id"] == "left")); }
|
|
199
|
+
if right.0 == "assign" { assert!(team["tasks"].as_array().unwrap().iter().any(|task| task["id"] == "right")); }
|
|
200
|
+
if left.0 == "collect" { assert_eq!(team["tasks"][0]["accepted_result_id"], "result-original"); }
|
|
201
|
+
let notes = usize::from(left.0 == "note") + usize::from(right.0 == "note");
|
|
202
|
+
assert_eq!(team["notes"].as_array().unwrap().len(), 1 + notes);
|
|
203
|
+
if left.0 == "note" && right.0 == "assign" {
|
|
204
|
+
let markdown = std::fs::read_to_string(dir.join(".team/runtime/T1/team_state.md")).unwrap();
|
|
205
|
+
assert!(markdown.contains("right"), "note must render the committed task view: {markdown}");
|
|
206
|
+
}
|
|
207
|
+
if left.0 == "collect" {
|
|
208
|
+
write(&dir, "assign", Some("T1"), json!({"id": "original", "assignee": "w1", "title": "edited"}));
|
|
209
|
+
assert_eq!(disk(&dir)["teams"]["T1"]["tasks"][0]["accepted_result_id"], "result-original");
|
|
210
|
+
}
|
|
211
|
+
println!("S3 pair {i} canonical={team}");
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#[test]
|
|
217
|
+
fn s3_legacy_note_and_collection_keep_independent_updates() {
|
|
218
|
+
isolated("s3_legacy_note_and_collection_keep_independent_updates", |dir| {
|
|
219
|
+
seed(dir, true);
|
|
220
|
+
let waiting = start_writer(dir, "collect", None, Value::Null, true);
|
|
221
|
+
write(dir, "note", None, json!("legacy note"));
|
|
222
|
+
finish(waiting, true);
|
|
223
|
+
let state = disk(dir);
|
|
224
|
+
assert_eq!(state["tasks"][0]["accepted_result_id"], "result-original");
|
|
225
|
+
assert_eq!(state["notes"], json!(["original note", "legacy note"]));
|
|
226
|
+
assert!(state.get("teams").is_none(), "legacy single Team shape remains valid");
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#[test]
|
|
231
|
+
fn s3_observation_keeps_live_topology_conflict() {
|
|
232
|
+
isolated("s3_observation_keeps_live_topology_conflict", |dir| {
|
|
233
|
+
seed(dir, false);
|
|
234
|
+
let hook: SaveHook = Box::new(|ws, _| { write(ws.as_path(), "topology", Some("T1"), Value::Null); Ok(()) });
|
|
235
|
+
let report = coordinator(dir, hook).tick().unwrap();
|
|
236
|
+
assert_eq!(report.reason, Some(TickStopReason::PersistenceDegraded));
|
|
237
|
+
assert_eq!(disk(dir)["teams"]["T1"]["agents"]["w1"]["pane_id"], "%22");
|
|
238
|
+
});
|
|
239
|
+
}
|
|
@@ -193,6 +193,8 @@ pub struct Coordinator {
|
|
|
193
193
|
/// bug-084 save 注入钩。`None` ⇔ 真实 `state::save_runtime_state`。
|
|
194
194
|
#[allow(dead_code)]
|
|
195
195
|
save_hook: Option<SaveHook>,
|
|
196
|
+
#[cfg(test)]
|
|
197
|
+
before_save_hook: Option<SaveHook>,
|
|
196
198
|
/// tick 副作用 ORDER 探针。`None` ⇔ 不记录(生产)。
|
|
197
199
|
#[allow(dead_code)]
|
|
198
200
|
order_recorder: Option<OrderRecorder>,
|
|
@@ -219,6 +221,8 @@ impl Coordinator {
|
|
|
219
221
|
transport,
|
|
220
222
|
daemon_team_key: None,
|
|
221
223
|
save_hook: None,
|
|
224
|
+
#[cfg(test)]
|
|
225
|
+
before_save_hook: None,
|
|
222
226
|
order_recorder: None,
|
|
223
227
|
}
|
|
224
228
|
}
|
|
@@ -263,10 +267,18 @@ impl Coordinator {
|
|
|
263
267
|
transport,
|
|
264
268
|
daemon_team_key: None,
|
|
265
269
|
save_hook,
|
|
270
|
+
before_save_hook: None,
|
|
266
271
|
order_recorder,
|
|
267
272
|
}
|
|
268
273
|
}
|
|
269
274
|
|
|
275
|
+
/// A test barrier before the real repository save; never replaces the save.
|
|
276
|
+
#[cfg(test)]
|
|
277
|
+
pub(crate) fn with_before_save_hook(mut self, hook: SaveHook) -> Self {
|
|
278
|
+
self.before_save_hook = Some(hook);
|
|
279
|
+
self
|
|
280
|
+
}
|
|
281
|
+
|
|
270
282
|
// ── tick 编排(lifecycle.py:250-385)──────────────────────────────────────
|
|
271
283
|
|
|
272
284
|
/// 单次 tick(`coordinator_tick`,`lifecycle.py:250`)。固定顺序串 step 8-11 原子:
|
|
@@ -308,6 +320,7 @@ impl Coordinator {
|
|
|
308
320
|
self.daemon_team_key.as_deref(),
|
|
309
321
|
)
|
|
310
322
|
.unwrap_or(raw_state);
|
|
323
|
+
let before_observation = state.clone();
|
|
311
324
|
let store = crate::message_store::MessageStore::open(self.workspace.as_path())?;
|
|
312
325
|
let event_log = EventLog::new(self.workspace.as_path());
|
|
313
326
|
increment_coordinator_tick_iteration_count(&self.workspace);
|
|
@@ -487,26 +500,21 @@ impl Coordinator {
|
|
|
487
500
|
};
|
|
488
501
|
|
|
489
502
|
self.record_step(TickStepGroup::Persist, "atomic_save");
|
|
503
|
+
#[cfg(test)]
|
|
504
|
+
if let Some(hook) = &self.before_save_hook {
|
|
505
|
+
hook(&self.workspace, &state)?;
|
|
506
|
+
}
|
|
490
507
|
let saved = match &self.save_hook {
|
|
491
508
|
Some(hook) => hook(&self.workspace, &state),
|
|
492
509
|
None => {
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
// routes through `StateRepository` with the
|
|
496
|
-
// `CoordinatorTick` intent. Repository dispatch is
|
|
497
|
-
// byte-identical to the old `save_team_scoped_state`
|
|
498
|
-
// helper (see `state/repository.rs:CoordinatorTick =>
|
|
499
|
-
// helper_write_team_scoped`), so persist/merge/lock/
|
|
500
|
-
// atomic-rename semantics and degraded-mapping stay
|
|
501
|
-
// unchanged. No cached repository, no extra load, no
|
|
502
|
-
// retry — the caller still owns the `Value` and
|
|
503
|
-
// failure still returns `TickReport{PersistenceDegraded}`
|
|
504
|
-
// via the `saved.is_err()` branch below.
|
|
510
|
+
// Keep the sampled topology guard, but commit only this tick's
|
|
511
|
+
// observations onto latest so task/note writers cannot be lost.
|
|
505
512
|
let team_key = crate::state::projection::team_state_key(&state);
|
|
506
|
-
crate::state::repository::StateRepository::new(self.workspace.as_path()).
|
|
513
|
+
crate::state::repository::StateRepository::new(self.workspace.as_path()).commit_observations(
|
|
507
514
|
crate::state::repository::StateWriteIntent::CoordinatorTick {
|
|
508
515
|
team_key: team_key.as_str(),
|
|
509
516
|
},
|
|
517
|
+
&before_observation,
|
|
510
518
|
&state,
|
|
511
519
|
)
|
|
512
520
|
}
|
|
@@ -1412,13 +1412,6 @@ extern "C" fn managed_term_handler(_: libc::c_int) {
|
|
|
1412
1412
|
}
|
|
1413
1413
|
|
|
1414
1414
|
fn managed_attach_should_teardown(watch: &ManagedAttachWatch<'_>) -> bool {
|
|
1415
|
-
let marker = crate::tmux_backend::leader_provider_exit_marker(watch.provider_label);
|
|
1416
|
-
let target = Target::Pane(watch.spawned.pane_id.clone());
|
|
1417
|
-
if let Ok(cap) = watch.transport.capture(&target, CaptureRange::Tail(80)) {
|
|
1418
|
-
if cap.text.contains(&marker) || cap.text.contains("[team-agent] Provider exited") {
|
|
1419
|
-
return true;
|
|
1420
|
-
}
|
|
1421
|
-
}
|
|
1422
1415
|
matches!(
|
|
1423
1416
|
leader_provider_health(
|
|
1424
1417
|
watch.transport,
|
|
@@ -1674,65 +1667,21 @@ fn ensure_managed_provider_live_after_attach(
|
|
|
1674
1667
|
)))
|
|
1675
1668
|
}
|
|
1676
1669
|
|
|
1677
|
-
///
|
|
1678
|
-
///
|
|
1679
|
-
///
|
|
1680
|
-
/// remains at its inert tail. This function distinguishes
|
|
1681
|
-
/// `provider_alive` from `provider_exited` by:
|
|
1682
|
-
/// 1. Reading `pane_current_command` (tmux `#{pane_current_command}`).
|
|
1683
|
-
/// 2. If the current command matches the expected provider binary (or
|
|
1684
|
-
/// one of its known aliases), report `Alive`.
|
|
1685
|
-
/// 3. If the current command is a shell tail process AND the pane
|
|
1686
|
-
/// content contains the exit marker `[team-agent] <provider> exited`
|
|
1687
|
-
/// (emitted by `leader_shell_wrapper_command`), report
|
|
1688
|
-
/// `ProviderExited`.
|
|
1689
|
-
/// 4. Otherwise (Unknown shell, no marker), report `Alive` as the
|
|
1690
|
-
/// conservative default — avoid false-positive exit alarms.
|
|
1691
|
-
///
|
|
1692
|
-
/// Note: when the leader shell wrapper is used (CR C-2), a provider exit
|
|
1693
|
-
/// leaves the pane as an inert `/bin/sh -c` tail with the exit marker in
|
|
1694
|
-
/// scrollback. Pre-wrapper code that hit `exec claude` would have left the
|
|
1695
|
-
/// pane as `[exited]` and `liveness()` would have returned `Dead`. The new
|
|
1696
|
-
/// failure mode requires this richer health check to surface
|
|
1697
|
-
/// `leader_provider_exited` as a distinct status.
|
|
1670
|
+
/// Reconcile real pane liveness with the supervising wrapper's exit receipt.
|
|
1671
|
+
/// Restored provider output may contain any exit-marker text; it is not process
|
|
1672
|
+
/// state and must never authorize destructive managed-window cleanup.
|
|
1698
1673
|
pub fn leader_provider_health(
|
|
1699
1674
|
transport: &dyn Transport,
|
|
1700
1675
|
pane_id: &PaneId,
|
|
1701
|
-
|
|
1676
|
+
_expected_provider_label: &str,
|
|
1702
1677
|
) -> LeaderProviderHealth {
|
|
1703
|
-
|
|
1704
|
-
let liveness = transport.liveness(pane_id).ok();
|
|
1705
|
-
if matches!(liveness, Some(PaneLiveness::Dead)) {
|
|
1678
|
+
if matches!(transport.liveness(pane_id), Ok(PaneLiveness::Dead)) {
|
|
1706
1679
|
return LeaderProviderHealth::Unreachable;
|
|
1707
1680
|
}
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
.query(&target, PaneField::PaneCurrentCommand)
|
|
1711
|
-
.ok()
|
|
1712
|
-
.flatten()
|
|
1713
|
-
.map(|s| s.trim().to_lowercase())
|
|
1714
|
-
.unwrap_or_default();
|
|
1715
|
-
if !current_command.is_empty()
|
|
1716
|
-
&& (current_command == expected_provider_label
|
|
1717
|
-
|| current_command.contains(expected_provider_label))
|
|
1718
|
-
{
|
|
1719
|
-
return LeaderProviderHealth::Alive;
|
|
1681
|
+
if matches!(transport.provider_exit_status(pane_id), Ok(Some(_))) {
|
|
1682
|
+
return LeaderProviderHealth::ProviderExited;
|
|
1720
1683
|
}
|
|
1721
|
-
//
|
|
1722
|
-
let is_shell = is_interactive_shell_basename(¤t_command);
|
|
1723
|
-
if is_shell {
|
|
1724
|
-
// CR R6: marker text from single-source `leader_provider_exit_marker`
|
|
1725
|
-
// so the wrapper printf and the health-check substring cannot drift.
|
|
1726
|
-
let exit_marker_substr =
|
|
1727
|
-
crate::tmux_backend::leader_provider_exit_marker(expected_provider_label);
|
|
1728
|
-
if let Ok(cap) = transport.capture(&target, CaptureRange::Tail(200)) {
|
|
1729
|
-
if cap.text.contains(&exit_marker_substr) {
|
|
1730
|
-
return LeaderProviderHealth::ProviderExited;
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
|
-
// Conservative default — pane addressable, but couldn't positively
|
|
1735
|
-
// confirm provider exit. Treat as Alive.
|
|
1684
|
+
// Missing/unsupported receipts (including old wrappers) are not proof of exit.
|
|
1736
1685
|
LeaderProviderHealth::Alive
|
|
1737
1686
|
}
|
|
1738
1687
|
|
|
@@ -1750,7 +1699,6 @@ pub enum LeaderProviderHealth {
|
|
|
1750
1699
|
|
|
1751
1700
|
/// 0.4.x (CR R6 + R3): single-source shell-tail detection.
|
|
1752
1701
|
/// Used by:
|
|
1753
|
-
/// - `leader_provider_health` to decide "pane is at the shell tail"
|
|
1754
1702
|
/// - shutdown logic to recognise a leader pane in shell-tail mode
|
|
1755
1703
|
/// as still owned by the leader (not stray).
|
|
1756
1704
|
///
|
|
@@ -3001,6 +2949,7 @@ mod tests {
|
|
|
3001
2949
|
screens: Mutex<Vec<String>>,
|
|
3002
2950
|
sent: Mutex<Vec<(Target, Vec<Key>)>>,
|
|
3003
2951
|
liveness: PaneLiveness,
|
|
2952
|
+
exit_status: Option<u8>,
|
|
3004
2953
|
targets: Vec<PaneInfo>,
|
|
3005
2954
|
window_options: Mutex<Vec<(String, String)>>,
|
|
3006
2955
|
window_option_error: Mutex<Option<String>>,
|
|
@@ -3014,6 +2963,7 @@ mod tests {
|
|
|
3014
2963
|
screens: Mutex::new(screens),
|
|
3015
2964
|
sent: Mutex::new(Vec::new()),
|
|
3016
2965
|
liveness: PaneLiveness::Unknown,
|
|
2966
|
+
exit_status: None,
|
|
3017
2967
|
targets: Vec::new(),
|
|
3018
2968
|
window_options: Mutex::new(Vec::new()),
|
|
3019
2969
|
window_option_error: Mutex::new(None),
|
|
@@ -3027,6 +2977,7 @@ mod tests {
|
|
|
3027
2977
|
screens: Mutex::new(Vec::new()),
|
|
3028
2978
|
sent: Mutex::new(Vec::new()),
|
|
3029
2979
|
liveness,
|
|
2980
|
+
exit_status: None,
|
|
3030
2981
|
targets: Vec::new(),
|
|
3031
2982
|
window_options: Mutex::new(Vec::new()),
|
|
3032
2983
|
window_option_error: Mutex::new(None),
|
|
@@ -3040,6 +2991,7 @@ mod tests {
|
|
|
3040
2991
|
screens: Mutex::new(Vec::new()),
|
|
3041
2992
|
sent: Mutex::new(Vec::new()),
|
|
3042
2993
|
liveness,
|
|
2994
|
+
exit_status: None,
|
|
3043
2995
|
targets,
|
|
3044
2996
|
window_options: Mutex::new(Vec::new()),
|
|
3045
2997
|
window_option_error: Mutex::new(None),
|
|
@@ -3055,6 +3007,7 @@ mod tests {
|
|
|
3055
3007
|
screens: Mutex::new(Vec::new()),
|
|
3056
3008
|
sent: Mutex::new(Vec::new()),
|
|
3057
3009
|
liveness: PaneLiveness::Unknown,
|
|
3010
|
+
exit_status: None,
|
|
3058
3011
|
targets: Vec::new(),
|
|
3059
3012
|
window_options: Mutex::new(Vec::new()),
|
|
3060
3013
|
window_option_error: Mutex::new(Some(error.to_string())),
|
|
@@ -3186,6 +3139,10 @@ mod tests {
|
|
|
3186
3139
|
Ok(self.liveness)
|
|
3187
3140
|
}
|
|
3188
3141
|
|
|
3142
|
+
fn provider_exit_status(&self, _pane: &PaneId) -> Result<Option<u8>, TransportError> {
|
|
3143
|
+
Ok(self.exit_status)
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3189
3146
|
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
3190
3147
|
Ok(self.targets.clone())
|
|
3191
3148
|
}
|
|
@@ -3859,22 +3816,37 @@ mod tests {
|
|
|
3859
3816
|
}
|
|
3860
3817
|
|
|
3861
3818
|
#[test]
|
|
3862
|
-
fn
|
|
3819
|
+
fn managed_attach_teardown_requires_exit_receipt_not_replayed_markers() {
|
|
3863
3820
|
let spawned = managed_spawn_result();
|
|
3864
|
-
let transport = ScriptedTransport::new(vec![
|
|
3865
|
-
"[team-agent]
|
|
3821
|
+
let mut transport = ScriptedTransport::new(vec![
|
|
3822
|
+
"[team-agent] pi exited with 1\n[team-agent] Provider exited".to_string(),
|
|
3866
3823
|
]);
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3824
|
+
transport.liveness = PaneLiveness::Live;
|
|
3825
|
+
for exit_status in [None, Some(0), Some(17)] {
|
|
3826
|
+
transport.exit_status = exit_status;
|
|
3827
|
+
let watch = super::ManagedAttachWatch {
|
|
3828
|
+
transport: &transport,
|
|
3829
|
+
spawned: &spawned,
|
|
3830
|
+
session: &spawned.session,
|
|
3831
|
+
session_existed_before: false,
|
|
3832
|
+
workspace: Path::new("/tmp"),
|
|
3833
|
+
provider_label: "pi",
|
|
3834
|
+
};
|
|
3835
|
+
assert_eq!(
|
|
3836
|
+
super::managed_attach_should_teardown(&watch),
|
|
3837
|
+
exit_status.is_some()
|
|
3838
|
+
);
|
|
3839
|
+
}
|
|
3840
|
+
assert_eq!(
|
|
3841
|
+
transport.screens.lock().unwrap().len(),
|
|
3842
|
+
1,
|
|
3843
|
+
"health must not read history"
|
|
3844
|
+
);
|
|
3845
|
+
transport.liveness = PaneLiveness::Dead;
|
|
3846
|
+
transport.exit_status = None;
|
|
3847
|
+
assert_eq!(
|
|
3848
|
+
super::leader_provider_health(&transport, &spawned.pane_id, "pi"),
|
|
3849
|
+
super::LeaderProviderHealth::Unreachable
|
|
3878
3850
|
);
|
|
3879
3851
|
}
|
|
3880
3852
|
|
|
@@ -151,14 +151,30 @@ pub(crate) fn parse_pi_list_models_table(bytes: &[u8]) -> Result<Vec<String>, Pr
|
|
|
151
151
|
if line.trim().is_empty() {
|
|
152
152
|
continue;
|
|
153
153
|
}
|
|
154
|
-
let
|
|
155
|
-
|
|
156
|
-
return Err(ProviderError::Command(format!(
|
|
154
|
+
let malformed = || {
|
|
155
|
+
ProviderError::Command(format!(
|
|
157
156
|
"Pi model catalog row {} is malformed",
|
|
158
157
|
index + 2
|
|
159
|
-
))
|
|
158
|
+
))
|
|
159
|
+
};
|
|
160
|
+
let (provider, model) = line
|
|
161
|
+
.trim()
|
|
162
|
+
.split_once(char::is_whitespace)
|
|
163
|
+
.ok_or_else(malformed)?;
|
|
164
|
+
let mut model = model.trim();
|
|
165
|
+
// Pi prints the raw model id, which may contain spaces. Only the
|
|
166
|
+
// capability columns following it are single whitespace-free values.
|
|
167
|
+
for _ in 2..header_columns.len() {
|
|
168
|
+
model = model
|
|
169
|
+
.rsplit_once(char::is_whitespace)
|
|
170
|
+
.ok_or_else(malformed)?
|
|
171
|
+
.0
|
|
172
|
+
.trim_end();
|
|
173
|
+
}
|
|
174
|
+
if model.is_empty() {
|
|
175
|
+
return Err(malformed());
|
|
160
176
|
}
|
|
161
|
-
let exact = format!("{}/{}"
|
|
177
|
+
let exact = format!("{provider}/{model}");
|
|
162
178
|
if !seen.insert(exact.clone()) {
|
|
163
179
|
return Err(ProviderError::Command(format!(
|
|
164
180
|
"Pi model catalog contains duplicate exact id {exact:?}"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
provider model context max-out thinking images
|
|
2
|
+
cursor claude-opus-4-7@1m:fast 1M 16.4K yes yes
|
|
3
|
+
cursor claude-opus-4-7@1m:slow 1M 16.4K yes yes
|
|
4
|
+
openai-codex gpt-5.6-luna 272K 128K yes yes
|
|
5
|
+
team-agent Claude Opus 4.6 (Thinking) 200K 32.8K yes yes
|
|
6
|
+
team-agent Claude Sonnet 4.6 (Thinking) 200K 32.8K yes yes
|
|
7
|
+
team-agent Gemini 3.8 Flash 200K 32.8K yes yes
|
|
8
|
+
team-agent gpt-5.6-luna 200K 32.8K yes yes
|
|
9
|
+
team-agent qwen3.8-27b 262.1K 32.8K yes yes
|
|
@@ -8,6 +8,77 @@ use crate::model::enums::ProviderEffort;
|
|
|
8
8
|
use crate::provider::adapters::pi::{build_pi_command_argv, PiCommandRequest, PiSessionSelector};
|
|
9
9
|
|
|
10
10
|
const CATALOG: &[u8] = include_bytes!("fixtures/pi_list_models_g0.stdout.txt");
|
|
11
|
+
// Public pi --list-models rows captured for issue #186, preserving column widths.
|
|
12
|
+
const SPACED_IDS_CATALOG: &[u8] = include_bytes!("fixtures/pi_list_models_spaced_ids.stdout.txt");
|
|
13
|
+
|
|
14
|
+
#[test]
|
|
15
|
+
fn pi_catalog_preserves_spaced_ids_and_qualified_slug_variants() {
|
|
16
|
+
let models = parse_pi_list_models_table(SPACED_IDS_CATALOG).expect("public catalog rows");
|
|
17
|
+
assert_eq!(
|
|
18
|
+
models,
|
|
19
|
+
vec![
|
|
20
|
+
"cursor/claude-opus-4-7@1m:fast",
|
|
21
|
+
"cursor/claude-opus-4-7@1m:slow",
|
|
22
|
+
"openai-codex/gpt-5.6-luna",
|
|
23
|
+
"team-agent/Claude Opus 4.6 (Thinking)",
|
|
24
|
+
"team-agent/Claude Sonnet 4.6 (Thinking)",
|
|
25
|
+
"team-agent/Gemini 3.8 Flash",
|
|
26
|
+
"team-agent/gpt-5.6-luna",
|
|
27
|
+
"team-agent/qwen3.8-27b",
|
|
28
|
+
]
|
|
29
|
+
);
|
|
30
|
+
for exact in &models {
|
|
31
|
+
assert_eq!(select_exact_pi_model(&models, exact).unwrap(), *exact);
|
|
32
|
+
assert!(argv_has_pair(&command_for_model(exact), "--model", exact));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#[test]
|
|
37
|
+
fn pi_catalog_still_rejects_duplicate_complete_spaced_ids() {
|
|
38
|
+
let text = std::str::from_utf8(SPACED_IDS_CATALOG).unwrap();
|
|
39
|
+
let repeated = text
|
|
40
|
+
.lines()
|
|
41
|
+
.find(|line| line.contains("Claude Opus 4.6 (Thinking)"))
|
|
42
|
+
.unwrap();
|
|
43
|
+
let duplicate = format!("{text}{repeated}\n");
|
|
44
|
+
let error = parse_pi_list_models_table(duplicate.as_bytes()).unwrap_err();
|
|
45
|
+
assert!(error
|
|
46
|
+
.to_string()
|
|
47
|
+
.contains("duplicate exact id \"team-agent/Claude Opus 4.6 (Thinking)\""));
|
|
48
|
+
let exact = "team-agent/Claude Opus 4.6 (Thinking)";
|
|
49
|
+
assert!(select_exact_pi_model(&[exact.into(), exact.into()], exact).is_err());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#[test]
|
|
53
|
+
fn pi_catalog_preserves_internal_whitespace_with_compact_and_full_headers() {
|
|
54
|
+
for catalog in [
|
|
55
|
+
"provider model\r\nteam-agent\tClaude Opus 4.6 (Thinking) \r\n",
|
|
56
|
+
"provider model context max-out thinking images\r\nteam-agent\tClaude Opus 4.6 (Thinking)\t200K 32.8K\tyes yes \r\n",
|
|
57
|
+
] {
|
|
58
|
+
assert_eq!(
|
|
59
|
+
parse_pi_list_models_table(catalog.as_bytes()).unwrap(),
|
|
60
|
+
vec!["team-agent/Claude Opus 4.6 (Thinking)"]
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#[test]
|
|
66
|
+
fn pi_catalog_rejects_rows_without_model_or_required_trailing_fields() {
|
|
67
|
+
for row in [
|
|
68
|
+
"team-agent",
|
|
69
|
+
"team-agent 200K 32.8K yes yes",
|
|
70
|
+
"team-agent gpt-5.6-luna 200K 32.8K yes",
|
|
71
|
+
] {
|
|
72
|
+
let catalog = format!("provider model context max-out thinking images\n{row}\n");
|
|
73
|
+
assert!(
|
|
74
|
+
parse_pi_list_models_table(catalog.as_bytes())
|
|
75
|
+
.unwrap_err()
|
|
76
|
+
.to_string()
|
|
77
|
+
.contains("row 2 is malformed"),
|
|
78
|
+
"missing model or capability fields must refuse: {row}"
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
11
82
|
|
|
12
83
|
fn argv_has_pair(argv: &[String], flag: &str, value: &str) -> bool {
|
|
13
84
|
argv.windows(2)
|