@team-agent/installer 0.5.80 → 0.5.82
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/status_port/brief.rs +40 -16
- package/crates/team-agent/src/cli/tests/status_brief.rs +199 -0
- package/crates/team-agent/src/communication_mode/mod.rs +3 -3
- 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/lifecycle/launch/pi_mcp.rs +0 -2
- package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +15 -4
- package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +0 -1
- package/crates/team-agent/src/lifecycle/tests/pi_command_red.rs +3 -4
- package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +0 -1
- package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +5 -0
- package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +0 -1
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +47 -13
- 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/results.rs +1 -2
- package/crates/team-agent/src/provider/adapters/pi.rs +6 -6
- package/crates/team-agent/src/state/persist.rs +42 -2
- package/crates/team-agent/src/state/projection.rs +24 -0
- package/crates/team-agent/src/state/repository/tests.rs +81 -0
- package/crates/team-agent/src/state/repository.rs +169 -1
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -294,24 +294,39 @@ fn display_optional(value: &Value) -> String {
|
|
|
294
294
|
}
|
|
295
295
|
|
|
296
296
|
fn registered_nodes(state: &Value, selected: Option<&str>) -> Vec<RegisteredNode> {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
297
|
+
// A projected state can still carry stale top-level agents. Once the
|
|
298
|
+
// selector identified a canonical team entry, that entry is authoritative;
|
|
299
|
+
// an empty/missing nested roster must not fall back to the stale projection.
|
|
300
|
+
let canonical_team = state
|
|
301
|
+
.get("active_team_key")
|
|
302
|
+
.and_then(Value::as_str)
|
|
303
|
+
.and_then(|key| state.get("teams").and_then(|teams| teams.get(key)));
|
|
304
|
+
let (agents, lifecycle, defaults) = match canonical_team {
|
|
305
|
+
Some(team) => (
|
|
306
|
+
team.get("agents").and_then(Value::as_object),
|
|
307
|
+
team.get("agent_lifecycle").and_then(Value::as_object),
|
|
308
|
+
team,
|
|
309
|
+
),
|
|
310
|
+
None => (
|
|
311
|
+
state.get("agents").and_then(Value::as_object),
|
|
312
|
+
state.get("agent_lifecycle").and_then(Value::as_object),
|
|
313
|
+
state,
|
|
314
|
+
),
|
|
315
|
+
};
|
|
308
316
|
let Some(agents) = agents else {
|
|
309
317
|
return Vec::new();
|
|
310
318
|
};
|
|
311
319
|
agents
|
|
312
320
|
.iter()
|
|
313
321
|
.filter(|(name, _)| selected.is_none_or(|selected| selected == name.as_str()))
|
|
314
|
-
.
|
|
322
|
+
.filter(|(name, _)| {
|
|
323
|
+
!lifecycle
|
|
324
|
+
.and_then(|entries| entries.get(*name))
|
|
325
|
+
.and_then(|entry| entry.get("state"))
|
|
326
|
+
.and_then(Value::as_str)
|
|
327
|
+
.is_some_and(|state| state == "retired")
|
|
328
|
+
})
|
|
329
|
+
.map(|(name, value)| registered_node(name, value, defaults))
|
|
315
330
|
.collect()
|
|
316
331
|
}
|
|
317
332
|
|
|
@@ -803,7 +818,7 @@ fn parse_probe(bytes: &[u8], requested_endpoint: &str) -> Result<ProbeResult, ()
|
|
|
803
818
|
.and_then(Value::as_array)
|
|
804
819
|
.ok_or(())?
|
|
805
820
|
.iter()
|
|
806
|
-
.filter_map(parse_probe_node)
|
|
821
|
+
.filter_map(|node| parse_probe_node(node, report_socket))
|
|
807
822
|
.collect();
|
|
808
823
|
Ok(ProbeResult { nodes })
|
|
809
824
|
}
|
|
@@ -818,9 +833,12 @@ fn probe_has_error(value: &Value) -> bool {
|
|
|
818
833
|
}
|
|
819
834
|
}
|
|
820
835
|
|
|
821
|
-
fn parse_probe_node(value: &Value) -> Option<ProbeNode> {
|
|
836
|
+
fn parse_probe_node(value: &Value, report_socket: &str) -> Option<ProbeNode> {
|
|
822
837
|
Some(ProbeNode {
|
|
823
|
-
socket
|
|
838
|
+
// The accepted producer binds the socket once at the report envelope
|
|
839
|
+
// and omits the redundant per-node field. An explicit node socket is
|
|
840
|
+
// still preserved for strict mismatch rejection below.
|
|
841
|
+
socket: non_empty(value, "socket").unwrap_or_else(|| report_socket.to_string()),
|
|
824
842
|
session: non_empty(value, "session")?,
|
|
825
843
|
window: non_empty(value, "window_name")?,
|
|
826
844
|
pane: non_empty(value, "pane_id")?,
|
|
@@ -926,7 +944,13 @@ fn match_result<'a>(
|
|
|
926
944
|
fn matches_registered(node: &RegisteredNode, observed: &ProbeNode) -> bool {
|
|
927
945
|
node.endpoint.as_deref() == Some(observed.socket.as_str())
|
|
928
946
|
&& node.session.as_deref() == Some(observed.session.as_str())
|
|
929
|
-
|
|
947
|
+
// Older registrations may not persist a window. The endpoint,
|
|
948
|
+
// session, and pane tuple remains the narrow compatibility key; a
|
|
949
|
+
// present window is still an exact constraint.
|
|
950
|
+
&& node
|
|
951
|
+
.window
|
|
952
|
+
.as_deref()
|
|
953
|
+
.is_none_or(|window| window == observed.window)
|
|
930
954
|
&& node.pane.as_deref() == Some(observed.pane.as_str())
|
|
931
955
|
}
|
|
932
956
|
|
|
@@ -685,6 +685,205 @@ fn cmd_status_multi_endpoint_uses_total_budget() {
|
|
|
685
685
|
let _ = std::fs::remove_dir_all(&ws);
|
|
686
686
|
}
|
|
687
687
|
|
|
688
|
+
#[cfg(unix)]
|
|
689
|
+
#[test]
|
|
690
|
+
#[serial(status_brief)]
|
|
691
|
+
fn cmd_status_uses_canonical_roster_and_filters_same_team_retired_tombstones() {
|
|
692
|
+
let ws = brief_workspace("canonical-roster");
|
|
693
|
+
let mut stale = serde_json::Map::new();
|
|
694
|
+
for index in 0..220 {
|
|
695
|
+
stale.insert(
|
|
696
|
+
format!("stale-{index}"),
|
|
697
|
+
production_agent(&format!("stale-{index}"), &format!("%{index}")),
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
let mut stopped = production_agent("stopped", "%9");
|
|
701
|
+
stopped["status"] = json!("stopped");
|
|
702
|
+
let state = json!({
|
|
703
|
+
"session_name": "team-demo",
|
|
704
|
+
"tmux_endpoint": "/tmp/ta-status-brief.sock",
|
|
705
|
+
"tmux_socket": "/tmp/ta-status-brief.sock",
|
|
706
|
+
"active_team_key": "demo",
|
|
707
|
+
"agents": stale,
|
|
708
|
+
"teams": {
|
|
709
|
+
"demo": {
|
|
710
|
+
"status": "alive",
|
|
711
|
+
"session_name": "team-demo",
|
|
712
|
+
"tmux_endpoint": "/tmp/ta-status-brief.sock",
|
|
713
|
+
"agents": {
|
|
714
|
+
"live": production_agent("live", "%7"),
|
|
715
|
+
"retired": production_agent("retired", "%8"),
|
|
716
|
+
"stopped": stopped
|
|
717
|
+
},
|
|
718
|
+
"agent_lifecycle": {
|
|
719
|
+
"retired": {"state": "retired"}
|
|
720
|
+
}
|
|
721
|
+
},
|
|
722
|
+
"sibling": {
|
|
723
|
+
"status": "alive",
|
|
724
|
+
"agent_lifecycle": {"live": {"state": "retired"}}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
write_state(&ws, &state);
|
|
729
|
+
let bin = write_nodeprobe(
|
|
730
|
+
&ws.join("probe"),
|
|
731
|
+
"printf '%s\\n' '{\"schema_version\":1,\"socket\":\"/tmp/ta-status-brief.sock\",\"nodes\":[]}'\n",
|
|
732
|
+
);
|
|
733
|
+
let nodes = status_port::with_test_nodeprobe(bin, || {
|
|
734
|
+
json_nodes(cmd_status(&status_args(&ws, true, None, Some("demo"))).expect("status"))
|
|
735
|
+
});
|
|
736
|
+
let mut names = nodes
|
|
737
|
+
.iter()
|
|
738
|
+
.filter_map(|node| node["name"].as_str())
|
|
739
|
+
.collect::<Vec<_>>();
|
|
740
|
+
names.sort_unstable();
|
|
741
|
+
assert_eq!(names, vec!["live", "stopped"]);
|
|
742
|
+
assert_eq!(
|
|
743
|
+
nodes
|
|
744
|
+
.iter()
|
|
745
|
+
.find(|node| node["name"] == "stopped")
|
|
746
|
+
.and_then(|node| node["runtime_status"].as_str()),
|
|
747
|
+
Some("stopped")
|
|
748
|
+
);
|
|
749
|
+
let err = cmd_status(&status_args(&ws, true, Some("retired"), Some("demo"))).unwrap_err();
|
|
750
|
+
assert!(err.to_string().contains("unknown agent id: retired"), "{err}");
|
|
751
|
+
|
|
752
|
+
let empty = brief_workspace("canonical-empty");
|
|
753
|
+
write_state(
|
|
754
|
+
&empty,
|
|
755
|
+
&json!({
|
|
756
|
+
"active_team_key": "demo",
|
|
757
|
+
"agents": {"stale": production_agent("stale", "%1")},
|
|
758
|
+
"teams": {
|
|
759
|
+
"demo": {
|
|
760
|
+
"status": "alive",
|
|
761
|
+
"agents": {},
|
|
762
|
+
"agent_lifecycle": {}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}),
|
|
766
|
+
);
|
|
767
|
+
assert!(json_nodes(cmd_status(&status_args(&empty, true, None, None)).expect("status")).is_empty());
|
|
768
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
769
|
+
let _ = std::fs::remove_dir_all(&empty);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
#[cfg(unix)]
|
|
773
|
+
#[test]
|
|
774
|
+
#[serial(status_brief)]
|
|
775
|
+
fn cmd_status_allows_only_missing_window_compatibility_match() {
|
|
776
|
+
let ws = brief_workspace("missing-window");
|
|
777
|
+
let mut agent = production_agent("worker", "%7");
|
|
778
|
+
agent.as_object_mut().unwrap().remove("window");
|
|
779
|
+
agent.as_object_mut().unwrap().remove("layout_window");
|
|
780
|
+
agent
|
|
781
|
+
.get_mut("display")
|
|
782
|
+
.and_then(Value::as_object_mut)
|
|
783
|
+
.unwrap()
|
|
784
|
+
.remove("window");
|
|
785
|
+
write_state(
|
|
786
|
+
&ws,
|
|
787
|
+
&production_state(json!({"worker": agent})),
|
|
788
|
+
);
|
|
789
|
+
let report = producer_report(
|
|
790
|
+
"/tmp/ta-status-brief.sock",
|
|
791
|
+
"observed-window",
|
|
792
|
+
"%7",
|
|
793
|
+
"pi_activity_channel",
|
|
794
|
+
);
|
|
795
|
+
let bin = write_nodeprobe(&ws.join("probe"), &format!("cat <<'EOF'\n{report}\nEOF\n"));
|
|
796
|
+
let nodes = status_port::with_test_nodeprobe(bin, || {
|
|
797
|
+
json_nodes(cmd_status(&status_args(&ws, true, None, None)).expect("status"))
|
|
798
|
+
});
|
|
799
|
+
assert_eq!(nodes[0]["runtime_status"], "running");
|
|
800
|
+
assert_eq!(nodes[0]["activity"], "idle");
|
|
801
|
+
assert_eq!(
|
|
802
|
+
nodes[0]["tmux_command"],
|
|
803
|
+
"tmux -S '/tmp/ta-status-brief.sock' attach -t 'team-demo:observed-window.%7'"
|
|
804
|
+
);
|
|
805
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
806
|
+
|
|
807
|
+
let conflict = brief_workspace("present-window-conflict");
|
|
808
|
+
write_state(
|
|
809
|
+
&conflict,
|
|
810
|
+
&production_state(json!({"worker": production_agent("worker", "%7")})),
|
|
811
|
+
);
|
|
812
|
+
let report = producer_report(
|
|
813
|
+
"/tmp/ta-status-brief.sock",
|
|
814
|
+
"different-window",
|
|
815
|
+
"%7",
|
|
816
|
+
"pi_activity_channel",
|
|
817
|
+
);
|
|
818
|
+
let bin = write_nodeprobe(&conflict.join("probe"), &format!("cat <<'EOF'\n{report}\nEOF\n"));
|
|
819
|
+
let nodes = status_port::with_test_nodeprobe(bin, || {
|
|
820
|
+
json_nodes(cmd_status(&status_args(&conflict, true, None, None)).expect("status"))
|
|
821
|
+
});
|
|
822
|
+
assert_eq!(nodes[0]["runtime_status"], "unknown");
|
|
823
|
+
assert!(nodes[0]["tmux_command"].is_null());
|
|
824
|
+
let _ = std::fs::remove_dir_all(&conflict);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
#[cfg(unix)]
|
|
828
|
+
#[test]
|
|
829
|
+
#[serial(status_brief)]
|
|
830
|
+
fn cmd_status_accepts_envelope_bound_node_socket_but_rejects_explicit_mismatch() {
|
|
831
|
+
let ws = brief_workspace("envelope-node-socket");
|
|
832
|
+
write_state(
|
|
833
|
+
&ws,
|
|
834
|
+
&production_state(json!({"worker": production_agent("worker", "%7")})),
|
|
835
|
+
);
|
|
836
|
+
let mut report: Value =
|
|
837
|
+
serde_json::from_str(&producer_report(
|
|
838
|
+
"/tmp/ta-status-brief.sock",
|
|
839
|
+
"worker",
|
|
840
|
+
"%7",
|
|
841
|
+
"pi_activity_channel",
|
|
842
|
+
))
|
|
843
|
+
.unwrap();
|
|
844
|
+
report["nodes"][0].as_object_mut().unwrap().remove("socket");
|
|
845
|
+
let report = serde_json::to_string(&report).unwrap();
|
|
846
|
+
let bin = write_nodeprobe(&ws.join("probe"), &format!("cat <<'EOF'\n{report}\nEOF\n"));
|
|
847
|
+
let nodes = status_port::with_test_nodeprobe(bin, || {
|
|
848
|
+
json_nodes(cmd_status(&status_args(&ws, true, None, None)).expect("status"))
|
|
849
|
+
});
|
|
850
|
+
assert_eq!(nodes[0]["runtime_status"], "running");
|
|
851
|
+
assert_eq!(nodes[0]["activity"], "idle");
|
|
852
|
+
assert_eq!(nodes[0]["health"], "normal");
|
|
853
|
+
assert_eq!(nodes[0]["session_name"], "pi-session");
|
|
854
|
+
assert_eq!(
|
|
855
|
+
nodes[0]["tmux_command"],
|
|
856
|
+
"tmux -S '/tmp/ta-status-brief.sock' attach -t 'team-demo:worker.%7'"
|
|
857
|
+
);
|
|
858
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
859
|
+
|
|
860
|
+
let mismatch = brief_workspace("explicit-node-socket-mismatch");
|
|
861
|
+
write_state(
|
|
862
|
+
&mismatch,
|
|
863
|
+
&production_state(json!({"worker": production_agent("worker", "%7")})),
|
|
864
|
+
);
|
|
865
|
+
let mut report: Value =
|
|
866
|
+
serde_json::from_str(&producer_report(
|
|
867
|
+
"/tmp/ta-status-brief.sock",
|
|
868
|
+
"worker",
|
|
869
|
+
"%7",
|
|
870
|
+
"pi_activity_channel",
|
|
871
|
+
))
|
|
872
|
+
.unwrap();
|
|
873
|
+
report["nodes"][0]["socket"] = json!("/tmp/other.sock");
|
|
874
|
+
let report = serde_json::to_string(&report).unwrap();
|
|
875
|
+
let bin = write_nodeprobe(
|
|
876
|
+
&mismatch.join("probe"),
|
|
877
|
+
&format!("cat <<'EOF'\n{report}\nEOF\n"),
|
|
878
|
+
);
|
|
879
|
+
let nodes = status_port::with_test_nodeprobe(bin, || {
|
|
880
|
+
json_nodes(cmd_status(&status_args(&mismatch, true, None, None)).expect("status"))
|
|
881
|
+
});
|
|
882
|
+
assert_eq!(nodes[0]["runtime_status"], "unknown");
|
|
883
|
+
assert!(nodes[0]["tmux_command"].is_null());
|
|
884
|
+
let _ = std::fs::remove_dir_all(&mismatch);
|
|
885
|
+
}
|
|
886
|
+
|
|
688
887
|
#[cfg(unix)]
|
|
689
888
|
fn pid_alive(pid: u32) -> bool {
|
|
690
889
|
unsafe { libc::kill(pid as i32, 0) == 0 }
|
|
@@ -12,9 +12,9 @@ const LEADER_CENTRIC_RUNTIME_CONTRACT: &str = r#"# Team Agent communication cont
|
|
|
12
12
|
|
|
13
13
|
- Progress, blockers, questions: {send_message}(to='leader', content='...')
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
Respond through Team Agent MCP tools only to actionable requests or questions; writing in your terminal does not deliver it.
|
|
16
|
+
|
|
17
|
+
"#;
|
|
18
18
|
|
|
19
19
|
const ORCHESTRATED_RUNTIME_CONTRACT: &str = r#"# Team Agent communication contract: orchestrated
|
|
20
20
|
|
|
@@ -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
|
}
|