@team-agent/installer 0.5.80 → 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/status_port/brief.rs +40 -16
- package/crates/team-agent/src/cli/tests/status_brief.rs +199 -0
- 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/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/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 }
|
|
@@ -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
|
}
|
|
@@ -24,14 +24,10 @@ pub(crate) fn update_state(
|
|
|
24
24
|
}
|
|
25
25
|
Err(err) => return Err(tool_runtime_error(err)),
|
|
26
26
|
};
|
|
27
|
-
let
|
|
28
|
-
ensure_object(&mut state);
|
|
29
|
-
append_note(&mut state, note);
|
|
30
|
-
crate::state::repository::StateRepository::new(&selected.run_workspace).save_reapplying(
|
|
27
|
+
let state = crate::state::repository::StateRepository::new(&selected.run_workspace).commit(
|
|
31
28
|
crate::state::repository::StateWriteIntent::McpUpdateStateNote {
|
|
32
29
|
team_key: Some(&selected.team_key),
|
|
33
30
|
},
|
|
34
|
-
&state,
|
|
35
31
|
|latest| {
|
|
36
32
|
ensure_object(latest);
|
|
37
33
|
append_note(latest, note);
|
|
@@ -65,15 +61,10 @@ fn update_state_without_spec(
|
|
|
65
61
|
crate::state::selector::SelectorMode::RuntimeOnly,
|
|
66
62
|
)
|
|
67
63
|
.map_err(tool_runtime_error)?;
|
|
68
|
-
let
|
|
69
|
-
ensure_object(&mut state);
|
|
70
|
-
seed_legacy_team_key(&mut state, &selected.run_workspace, &selected.team_key);
|
|
71
|
-
append_note(&mut state, note);
|
|
72
|
-
crate::state::repository::StateRepository::new(&selected.run_workspace).save_reapplying(
|
|
64
|
+
let state = crate::state::repository::StateRepository::new(&selected.run_workspace).commit(
|
|
73
65
|
crate::state::repository::StateWriteIntent::McpUpdateStateNote {
|
|
74
66
|
team_key: Some(&selected.team_key),
|
|
75
67
|
},
|
|
76
|
-
&state,
|
|
77
68
|
|latest| {
|
|
78
69
|
ensure_object(latest);
|
|
79
70
|
seed_legacy_team_key(latest, &selected.run_workspace, &selected.team_key);
|
|
@@ -142,14 +142,12 @@ impl TeamOrchestratorTools {
|
|
|
142
142
|
.canonical_owner_team_key()?
|
|
143
143
|
.map(|team| team.as_str().to_string())
|
|
144
144
|
.or_else(|| assignment_team_key(&state));
|
|
145
|
-
reconcile_assigned_task(&mut state, team_key.as_deref(), &task_value);
|
|
146
145
|
crate::state::repository::StateRepository::new(&self.workspace)
|
|
147
|
-
.
|
|
146
|
+
.commit(
|
|
148
147
|
crate::state::repository::StateWriteIntent::McpAssignTask {
|
|
149
148
|
team_key: team_key.as_deref(),
|
|
150
149
|
task_id,
|
|
151
150
|
},
|
|
152
|
-
&state,
|
|
153
151
|
|latest| {
|
|
154
152
|
ensure_object(latest);
|
|
155
153
|
let latest_team_key = team_key.clone().or_else(|| assignment_team_key(latest));
|
|
@@ -287,9 +287,8 @@ fn collect_scoped(
|
|
|
287
287
|
collected_results.push(summary);
|
|
288
288
|
}
|
|
289
289
|
if state_dirty {
|
|
290
|
-
crate::state::repository::StateRepository::new(&paths.run_workspace).
|
|
290
|
+
state = crate::state::repository::StateRepository::new(&paths.run_workspace).commit(
|
|
291
291
|
crate::state::repository::StateWriteIntent::ResultCollection { owner_team_id },
|
|
292
|
-
&state,
|
|
293
292
|
|latest| {
|
|
294
293
|
for (task_id, result_id) in &task_updates {
|
|
295
294
|
mark_task_done(latest, task_id, result_id);
|
|
@@ -591,8 +591,48 @@ fn persist_runtime_state_with_merge_options_and_expected(
|
|
|
591
591
|
// on-disk latest. Re-strip here so the serialized payload is always
|
|
592
592
|
// canonical-only when a canonical teams.<key>.team_owner is present.
|
|
593
593
|
crate::state::ownership::strip_top_level_ownership_if_canonical_present(&mut migrated);
|
|
594
|
-
|
|
595
|
-
|
|
594
|
+
write_state_under_lock(workspace, &path, &migrated)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/// Apply a bounded writer to disk under state-save, without a cache shortcut.
|
|
598
|
+
pub(super) fn update_runtime_state(
|
|
599
|
+
workspace: &Path,
|
|
600
|
+
update: impl FnOnce(&Value) -> Result<Value, StateError>,
|
|
601
|
+
) -> Result<Value, StateError> {
|
|
602
|
+
let path = runtime_state_path(workspace);
|
|
603
|
+
let _lock = RuntimeLock::acquire(workspace, "state-save", 2.0)?;
|
|
604
|
+
// Malformed/unreadable state is an error, never an empty store.
|
|
605
|
+
let mut latest = match std::fs::read_to_string(&path) {
|
|
606
|
+
Ok(text) => serde_json::from_str(&text)?,
|
|
607
|
+
Err(error) if error.kind() == io::ErrorKind::NotFound => json!({}),
|
|
608
|
+
Err(error) => return Err(error.into()),
|
|
609
|
+
};
|
|
610
|
+
normalize_agent_session_state(&mut latest);
|
|
611
|
+
migrate_state_identity(&mut latest, &SystemEnv, workspace)?;
|
|
612
|
+
let _ = migrate_active_team_key(&mut latest);
|
|
613
|
+
let mut committed = update(&latest)?;
|
|
614
|
+
merge_ordinary_state(&mut committed, &latest)?;
|
|
615
|
+
migrate_state_identity(&mut committed, &SystemEnv, workspace)?;
|
|
616
|
+
crate::state::ownership::strip_top_level_ownership_if_canonical_present(&mut committed);
|
|
617
|
+
if committed == latest {
|
|
618
|
+
cache_set(&path, &committed);
|
|
619
|
+
return Ok(committed);
|
|
620
|
+
}
|
|
621
|
+
write_state_under_lock(workspace, &path, &committed)?;
|
|
622
|
+
Ok(committed)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/// Reuse ordinary roster, topology, ownership, capture and endpoint guards.
|
|
626
|
+
pub(super) fn merge_ordinary_state(incoming: &mut Value, latest: &Value) -> Result<(), StateError> {
|
|
627
|
+
apply_persist_merge_contract(
|
|
628
|
+
incoming, latest, &BTreeSet::new(), None, &BTreeSet::new(),
|
|
629
|
+
&BTreeSet::new(), &BTreeSet::new(), None, None, None,
|
|
630
|
+
)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
fn write_state_under_lock(workspace: &Path, path: &Path, migrated: &Value) -> Result<(), StateError> {
|
|
634
|
+
// Shared retry/self-heal/atomic replacement path; caller owns state-save.
|
|
635
|
+
let payload = serde_json::to_string_pretty(migrated)?;
|
|
596
636
|
let delays = [0.05_f64, 0.2, 0.5];
|
|
597
637
|
|
|
598
638
|
for attempt in 0..=delays.len() {
|
|
@@ -775,6 +775,30 @@ pub fn save_team_scoped_state(workspace: &Path, team_state: &Value) -> Result<()
|
|
|
775
775
|
save_team_scoped_state_with_deleted_agents(workspace, team_state, &[])
|
|
776
776
|
}
|
|
777
777
|
|
|
778
|
+
/// Materialize a bounded Team update from the lock-held workspace. Siblings
|
|
779
|
+
/// come only from latest; retain the existing primary/legacy projection rules.
|
|
780
|
+
pub(super) fn merge_committed_team(existing: &Value, team_state: &Value, target_key: &str) -> Value {
|
|
781
|
+
let mut primary = existing.get("session_name").and_then(Value::as_str)
|
|
782
|
+
.filter(|name| !name.is_empty()).map(|_| team_state_key(existing));
|
|
783
|
+
if primary.as_deref().is_some_and(|key| key != target_key)
|
|
784
|
+
&& existing.get("session_name") == team_state.get("session_name")
|
|
785
|
+
{
|
|
786
|
+
primary = Some(target_key.to_string());
|
|
787
|
+
}
|
|
788
|
+
let mut teams = take_object(existing.get("teams"));
|
|
789
|
+
if teams.is_empty() && primary.as_deref() == Some(target_key) {
|
|
790
|
+
return compact_team_state(team_state);
|
|
791
|
+
}
|
|
792
|
+
teams.insert(target_key.to_string(), compact_team_state(team_state));
|
|
793
|
+
let mut merged = if primary.is_none() || primary.as_deref() == Some(target_key) {
|
|
794
|
+
to_object(team_state)
|
|
795
|
+
} else {
|
|
796
|
+
to_object(existing)
|
|
797
|
+
};
|
|
798
|
+
merged.insert("teams".to_string(), Value::Object(teams));
|
|
799
|
+
Value::Object(merged)
|
|
800
|
+
}
|
|
801
|
+
|
|
778
802
|
pub(crate) fn save_team_scoped_state_reapplying_after_conflict<F>(
|
|
779
803
|
workspace: &Path,
|
|
780
804
|
team_state: &Value,
|
|
@@ -46,3 +46,84 @@ fn optional_unmigrated_read_preserves_missing_corrupt_and_valid_shapes(
|
|
|
46
46
|
std::fs::remove_dir_all(workspace)?;
|
|
47
47
|
Ok(())
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
#[test]
|
|
51
|
+
fn coordinator_observations_keep_legacy_identity_and_shape()
|
|
52
|
+
-> Result<(), Box<dyn std::error::Error>> {
|
|
53
|
+
let nonce = std::time::SystemTime::now()
|
|
54
|
+
.duration_since(std::time::UNIX_EPOCH)?
|
|
55
|
+
.as_nanos();
|
|
56
|
+
let workspace =
|
|
57
|
+
std::env::temp_dir().join(format!("state-repository-legacy-observation-{nonce}"));
|
|
58
|
+
let initial = serde_json::json!({
|
|
59
|
+
"active_team_key": "team",
|
|
60
|
+
"agents": {"w1": {"agent_id": "w1", "provider": "codex", "status": "running"}}
|
|
61
|
+
});
|
|
62
|
+
let path = super::helper_workspace_path(&workspace);
|
|
63
|
+
if let Some(parent) = path.parent() {
|
|
64
|
+
std::fs::create_dir_all(parent)?;
|
|
65
|
+
}
|
|
66
|
+
std::fs::write(&path, serde_json::to_vec(&initial)?)?;
|
|
67
|
+
let repository = super::StateRepository::new(&workspace);
|
|
68
|
+
let before = repository.load_workspace()?;
|
|
69
|
+
|
|
70
|
+
let mut observed = before.clone();
|
|
71
|
+
observed["coordinator"] = serde_json::json!({
|
|
72
|
+
"abnormal_exit_watch": {"w1": {"last_error_observation_key": "error-1"}}
|
|
73
|
+
});
|
|
74
|
+
repository.commit_observations(
|
|
75
|
+
StateWriteIntent::CoordinatorTick {
|
|
76
|
+
team_key: "current",
|
|
77
|
+
},
|
|
78
|
+
&before,
|
|
79
|
+
&observed,
|
|
80
|
+
)?;
|
|
81
|
+
let first = repository.load_workspace()?;
|
|
82
|
+
assert!(
|
|
83
|
+
first.get("teams").is_none(),
|
|
84
|
+
"legacy observation must not synthesize teams.current"
|
|
85
|
+
);
|
|
86
|
+
assert_eq!(first["active_team_key"], serde_json::json!("team"));
|
|
87
|
+
assert_eq!(
|
|
88
|
+
first["coordinator"]["abnormal_exit_watch"]["w1"]["last_error_observation_key"],
|
|
89
|
+
serde_json::json!("error-1")
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
let mut observed = first.clone();
|
|
93
|
+
observed["coordinator"]["abnormal_exit_watch"]["w1"]["last_error_observation_key"] =
|
|
94
|
+
serde_json::json!("error-2");
|
|
95
|
+
repository.commit_observations(
|
|
96
|
+
StateWriteIntent::CoordinatorTick {
|
|
97
|
+
team_key: "current",
|
|
98
|
+
},
|
|
99
|
+
&first,
|
|
100
|
+
&observed,
|
|
101
|
+
)?;
|
|
102
|
+
let second = repository.load_workspace()?;
|
|
103
|
+
assert!(
|
|
104
|
+
second.get("teams").is_none(),
|
|
105
|
+
"legacy shape must remain root-only"
|
|
106
|
+
);
|
|
107
|
+
assert_eq!(second["active_team_key"], serde_json::json!("team"));
|
|
108
|
+
assert_eq!(
|
|
109
|
+
second["coordinator"]["abnormal_exit_watch"]["w1"]["last_error_observation_key"],
|
|
110
|
+
serde_json::json!("error-2")
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
let mut changed_latest = second.clone();
|
|
114
|
+
changed_latest["active_team_key"] = serde_json::json!("other");
|
|
115
|
+
std::fs::write(&path, serde_json::to_vec(&changed_latest)?)?;
|
|
116
|
+
let err = repository
|
|
117
|
+
.commit_observations(
|
|
118
|
+
StateWriteIntent::CoordinatorTick {
|
|
119
|
+
team_key: "current",
|
|
120
|
+
},
|
|
121
|
+
&second,
|
|
122
|
+
&observed,
|
|
123
|
+
)
|
|
124
|
+
.expect_err("identity drift must refuse the stale legacy observation");
|
|
125
|
+
assert!(matches!(err, super::StateError::SaveConflict(_)));
|
|
126
|
+
|
|
127
|
+
std::fs::remove_dir_all(workspace)?;
|
|
128
|
+
Ok(())
|
|
129
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
//!
|
|
2
|
-
//! StateRepository facade.
|
|
2
|
+
//! StateRepository facade. Task/note commits apply bounded mutations under the
|
|
3
|
+
//! existing state-save lock; coordinator commits carry only observation deltas.
|
|
4
|
+
//! Other writer clusters retain their legacy dispatch below.
|
|
3
5
|
//!
|
|
4
6
|
//! Ref:
|
|
5
7
|
//! - `.team/artifacts/s1a-state-repository-design.md` (skeleton)
|
|
@@ -142,8 +144,152 @@ impl<'a> StateRepository<'a> {
|
|
|
142
144
|
where
|
|
143
145
|
F: FnOnce(&mut Value),
|
|
144
146
|
{
|
|
147
|
+
#[cfg(test)]
|
|
148
|
+
test_support::before_commit();
|
|
145
149
|
route_reapply(self.workspace, intent, state, reapply)
|
|
146
150
|
}
|
|
151
|
+
|
|
152
|
+
/// Commit a task/note delta once against latest under state-save. Return
|
|
153
|
+
/// the committed Team view for callers that produce Markdown afterwards.
|
|
154
|
+
pub fn commit<F>(&self, intent: StateWriteIntent<'_>, update: F) -> Result<Value, StateError>
|
|
155
|
+
where
|
|
156
|
+
F: FnOnce(&mut Value),
|
|
157
|
+
{
|
|
158
|
+
#[cfg(test)]
|
|
159
|
+
test_support::before_commit();
|
|
160
|
+
let team_key = match intent {
|
|
161
|
+
// Assignment's existing reconcile helper updates the root task
|
|
162
|
+
// array and the explicit Team array independently (legacy parity).
|
|
163
|
+
StateWriteIntent::McpAssignTask { .. } => None,
|
|
164
|
+
StateWriteIntent::McpUpdateStateNote { team_key } => team_key,
|
|
165
|
+
StateWriteIntent::ResultCollection { owner_team_id } => owner_team_id,
|
|
166
|
+
_ => return Err(StateError::SaveFailed("intent does not accept a task/note delta".into())),
|
|
167
|
+
};
|
|
168
|
+
let committed = super::persist::update_runtime_state(self.workspace, |latest| {
|
|
169
|
+
let mut selected = bounded_team_view(latest, team_key)?;
|
|
170
|
+
update(&mut selected);
|
|
171
|
+
Ok(match team_key {
|
|
172
|
+
Some(key) => super::projection::merge_committed_team(latest, &selected, key),
|
|
173
|
+
None => selected,
|
|
174
|
+
})
|
|
175
|
+
})?;
|
|
176
|
+
bounded_team_view(&committed, team_key)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/// Fence the sampled incarnation with the existing persist guards, then
|
|
180
|
+
/// commit only this tick's agent observations and coordinator bookkeeping.
|
|
181
|
+
pub fn commit_observations(
|
|
182
|
+
&self,
|
|
183
|
+
intent: StateWriteIntent<'_>,
|
|
184
|
+
before: &Value,
|
|
185
|
+
observed: &Value,
|
|
186
|
+
) -> Result<(), StateError> {
|
|
187
|
+
let StateWriteIntent::CoordinatorTick { team_key } = intent else {
|
|
188
|
+
return Err(StateError::SaveFailed("intent does not accept observations".into()));
|
|
189
|
+
};
|
|
190
|
+
let sampled_legacy =
|
|
191
|
+
legacy_single_team_state(before) && legacy_single_team_state(observed);
|
|
192
|
+
let sampled_identity = legacy_team_identity(before);
|
|
193
|
+
super::persist::update_runtime_state(self.workspace, |latest| {
|
|
194
|
+
if sampled_legacy {
|
|
195
|
+
if sampled_identity != legacy_team_identity(observed)
|
|
196
|
+
|| !legacy_single_team_state(latest)
|
|
197
|
+
|| sampled_identity != legacy_team_identity(latest)
|
|
198
|
+
{
|
|
199
|
+
return Err(StateError::SaveConflict(
|
|
200
|
+
"legacy coordinator observation scope changed before commit".to_string(),
|
|
201
|
+
));
|
|
202
|
+
}
|
|
203
|
+
let mut checked = observed.clone();
|
|
204
|
+
super::persist::merge_ordinary_state(&mut checked, latest)?;
|
|
205
|
+
if !legacy_single_team_state(&checked)
|
|
206
|
+
|| sampled_identity != legacy_team_identity(&checked)
|
|
207
|
+
{
|
|
208
|
+
return Err(StateError::SaveConflict(
|
|
209
|
+
"legacy coordinator observation scope changed during validation"
|
|
210
|
+
.to_string(),
|
|
211
|
+
));
|
|
212
|
+
}
|
|
213
|
+
let mut selected = latest.clone();
|
|
214
|
+
for field in ["agents", "coordinator"] {
|
|
215
|
+
if before[field] != checked[field] {
|
|
216
|
+
apply_observation_delta(
|
|
217
|
+
&before[field],
|
|
218
|
+
&checked[field],
|
|
219
|
+
&mut selected[field],
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return Ok(selected);
|
|
224
|
+
}
|
|
225
|
+
let mut checked = super::projection::merge_committed_team(latest, observed, team_key);
|
|
226
|
+
super::persist::merge_ordinary_state(&mut checked, latest)?;
|
|
227
|
+
let checked = bounded_team_view(&checked, Some(team_key))?;
|
|
228
|
+
let mut selected = bounded_team_view(latest, Some(team_key))?;
|
|
229
|
+
for field in ["agents", "coordinator"] {
|
|
230
|
+
if before[field] != checked[field] {
|
|
231
|
+
apply_observation_delta(&before[field], &checked[field], &mut selected[field]);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
Ok(super::projection::merge_committed_team(latest, &selected, team_key))
|
|
235
|
+
})?;
|
|
236
|
+
Ok(())
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
fn legacy_single_team_state(state: &Value) -> bool {
|
|
241
|
+
match state.get("teams") {
|
|
242
|
+
None => true,
|
|
243
|
+
Some(Value::Object(teams)) => teams.is_empty(),
|
|
244
|
+
Some(_) => false,
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
fn legacy_team_identity(state: &Value) -> [Option<String>; 5] {
|
|
249
|
+
[
|
|
250
|
+
"active_team_key",
|
|
251
|
+
"team_key",
|
|
252
|
+
"session_name",
|
|
253
|
+
"team_dir",
|
|
254
|
+
"spec_path",
|
|
255
|
+
]
|
|
256
|
+
.map(|field| {
|
|
257
|
+
state
|
|
258
|
+
.get(field)
|
|
259
|
+
.and_then(Value::as_str)
|
|
260
|
+
.filter(|value| !value.is_empty())
|
|
261
|
+
.map(str::to_string)
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
fn bounded_team_view(state: &Value, team_key: Option<&str>) -> Result<Value, StateError> {
|
|
266
|
+
let Some(key) = team_key else { return Ok(state.clone()); };
|
|
267
|
+
if state.get("teams").and_then(Value::as_object).is_some_and(|teams| teams.contains_key(key)) {
|
|
268
|
+
return Ok(super::projection::project_top_level_view(state, key));
|
|
269
|
+
}
|
|
270
|
+
if state.get("teams").and_then(Value::as_object).is_some_and(|teams| !teams.is_empty()) {
|
|
271
|
+
return Err(StateError::TeamSelect(format!("team no longer exists: {key}")));
|
|
272
|
+
}
|
|
273
|
+
Ok(state.clone())
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Diffing is limited to the two observation blocks above, never a whole-state
|
|
277
|
+
// merge. Equal fields retain a concurrent writer's value; removed fields clear.
|
|
278
|
+
fn apply_observation_delta(before: &Value, observed: &Value, latest: &mut Value) {
|
|
279
|
+
if before == observed { return; }
|
|
280
|
+
if let Some(observed) = observed.as_object() {
|
|
281
|
+
if !latest.is_object() { *latest = serde_json::json!({}); }
|
|
282
|
+
if let Some(before) = before.as_object() {
|
|
283
|
+
for key in before.keys().filter(|key| !observed.contains_key(*key)) {
|
|
284
|
+
if let Some(latest) = latest.as_object_mut() { latest.remove(key); }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
for (key, value) in observed {
|
|
288
|
+
apply_observation_delta(&before[key], value, &mut latest[key]);
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
*latest = observed.clone();
|
|
292
|
+
}
|
|
147
293
|
}
|
|
148
294
|
|
|
149
295
|
/// Closed semantic catalog of workspace state writes. Every allowlisted
|
|
@@ -555,6 +701,11 @@ fn route_reapply<F>(
|
|
|
555
701
|
where
|
|
556
702
|
F: FnOnce(&mut Value),
|
|
557
703
|
{
|
|
704
|
+
if matches!(&intent, StateWriteIntent::McpAssignTask { .. }
|
|
705
|
+
| StateWriteIntent::McpUpdateStateNote { .. } | StateWriteIntent::ResultCollection { .. })
|
|
706
|
+
{
|
|
707
|
+
return StateRepository::new(workspace).commit(intent, reapply).map(|_| ());
|
|
708
|
+
}
|
|
558
709
|
if reapply_scope(&intent) == ReapplyScope::Team {
|
|
559
710
|
helper_write_team_scoped_reapply(workspace, state, reapply)
|
|
560
711
|
} else {
|
|
@@ -564,3 +715,20 @@ where
|
|
|
564
715
|
|
|
565
716
|
#[cfg(test)]
|
|
566
717
|
mod tests;
|
|
718
|
+
|
|
719
|
+
#[cfg(test)]
|
|
720
|
+
pub(crate) mod test_support {
|
|
721
|
+
thread_local! {
|
|
722
|
+
static BEFORE_COMMIT: std::cell::RefCell<Option<Box<dyn FnOnce()>>> = const {
|
|
723
|
+
std::cell::RefCell::new(None)
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
pub(crate) fn set_before_commit(hook: impl FnOnce() + 'static) {
|
|
728
|
+
BEFORE_COMMIT.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
pub(super) fn before_commit() {
|
|
732
|
+
if let Some(hook) = BEFORE_COMMIT.with(|slot| slot.borrow_mut().take()) { hook(); }
|
|
733
|
+
}
|
|
734
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.81",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.5.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.5.81",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.5.81",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.5.81"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|