@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
|
@@ -1089,7 +1089,6 @@ fn materialize_pi_plan_with_session(
|
|
|
1089
1089
|
})?;
|
|
1090
1090
|
let (argv, expected_session_id) = if let Some((_session_id, exact_path)) = resume {
|
|
1091
1091
|
let argv = build_pi_command_argv(PiCommandRequest {
|
|
1092
|
-
executable: &chain.launch_executable,
|
|
1093
1092
|
extension: &wrapper,
|
|
1094
1093
|
model: model.as_deref(),
|
|
1095
1094
|
effort: request.effort,
|
|
@@ -1103,7 +1102,6 @@ fn materialize_pi_plan_with_session(
|
|
|
1103
1102
|
} else {
|
|
1104
1103
|
let session_id = new_pi_session_id();
|
|
1105
1104
|
let argv = build_pi_command_argv(PiCommandRequest {
|
|
1106
|
-
executable: &chain.launch_executable,
|
|
1107
1105
|
extension: &wrapper,
|
|
1108
1106
|
model: model.as_deref(),
|
|
1109
1107
|
effort: request.effort,
|
|
@@ -112,7 +112,13 @@ fn t06_selected_templates_project_the_two_signed_communication_boundaries() {
|
|
|
112
112
|
};
|
|
113
113
|
|
|
114
114
|
let leader = prompt(CommunicationMode::LeaderCentric);
|
|
115
|
-
for marker in [
|
|
115
|
+
for marker in [
|
|
116
|
+
"Progress",
|
|
117
|
+
"blocker",
|
|
118
|
+
"question",
|
|
119
|
+
"actionable requests or questions",
|
|
120
|
+
"Do not reply to pure ACKs, greetings, or unchanged status notices",
|
|
121
|
+
] {
|
|
116
122
|
assert!(
|
|
117
123
|
leader.contains(marker),
|
|
118
124
|
"T06/leader_centric: official template must retain {marker:?} guidance; prompt={leader:?}"
|
|
@@ -120,7 +126,11 @@ fn t06_selected_templates_project_the_two_signed_communication_boundaries() {
|
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
let orchestrated = prompt(CommunicationMode::Orchestrated);
|
|
123
|
-
for marker in [
|
|
129
|
+
for marker in [
|
|
130
|
+
"declared channel",
|
|
131
|
+
"Respond to task-related messages through Team Agent MCP tools.",
|
|
132
|
+
"A pure ACK, unrelated status, or non-task message does not require a response.",
|
|
133
|
+
] {
|
|
124
134
|
assert!(
|
|
125
135
|
orchestrated.contains(marker),
|
|
126
136
|
"T06/orchestrated: official template must state the on-demand channel/response boundary via {marker:?}; prompt={orchestrated:?}"
|
|
@@ -159,7 +169,7 @@ fn t07_final_spawn_prompt_does_not_leak_leader_centric_obligations_into_orchestr
|
|
|
159
169
|
|
|
160
170
|
for required in [
|
|
161
171
|
"All communication must go through Team Agent MCP tools.",
|
|
162
|
-
"
|
|
172
|
+
"Do not reply to pure ACKs, greetings, or unchanged status notices",
|
|
163
173
|
"report_result exactly once",
|
|
164
174
|
] {
|
|
165
175
|
assert!(
|
|
@@ -186,7 +196,8 @@ fn t07_final_spawn_prompt_does_not_leak_leader_centric_obligations_into_orchestr
|
|
|
186
196
|
.expect("T07/leader_centric positive control: missing recorded default spawn");
|
|
187
197
|
for required in [
|
|
188
198
|
"Progress, blockers, questions:",
|
|
189
|
-
"
|
|
199
|
+
"actionable requests or questions",
|
|
200
|
+
"Do not reply to pure ACKs, greetings, or unchanged status notices",
|
|
190
201
|
] {
|
|
191
202
|
assert!(
|
|
192
203
|
default_prompt.contains(required),
|
|
@@ -87,7 +87,6 @@ fn argv_has_pair(argv: &[String], flag: &str, value: &str) -> bool {
|
|
|
87
87
|
|
|
88
88
|
fn command_for_model(model: &str) -> Vec<String> {
|
|
89
89
|
build_pi_command_argv(PiCommandRequest {
|
|
90
|
-
executable: Path::new("/verified/pi"),
|
|
91
90
|
extension: Path::new("/workspace/.team/runtime/pi/t1/w1/team-mcp.ts"),
|
|
92
91
|
model: Some(model),
|
|
93
92
|
effort: Some(ProviderEffort::Medium),
|
|
@@ -6,7 +6,6 @@ use crate::provider::adapters::pi::{build_pi_command_argv, PiCommandRequest, PiS
|
|
|
6
6
|
|
|
7
7
|
fn request(session: PiSessionSelector<'static>) -> PiCommandRequest<'static> {
|
|
8
8
|
PiCommandRequest {
|
|
9
|
-
executable: Path::new("/verified/pi"),
|
|
10
9
|
extension: Path::new("/workspace/.team/runtime/pi/team-a/worker-a/team-mcp.ts"),
|
|
11
10
|
model: Some("team-agent/qwen3.8-27b"),
|
|
12
11
|
effort: Some(ProviderEffort::High),
|
|
@@ -35,7 +34,6 @@ fn pi_command_omits_unset_model_and_thinking_flags() {
|
|
|
35
34
|
|
|
36
35
|
fn leader_request() -> PiCommandRequest<'static> {
|
|
37
36
|
PiCommandRequest {
|
|
38
|
-
executable: Path::new("/verified/pi"),
|
|
39
37
|
extension: Path::new("/workspace/.team/runtime/pi/current/leader/team-mcp.ts"),
|
|
40
38
|
model: None,
|
|
41
39
|
effort: None,
|
|
@@ -56,7 +54,7 @@ fn pi_interactive_leader_argv_preserves_native_session_discovery() {
|
|
|
56
54
|
assert_eq!(
|
|
57
55
|
argv,
|
|
58
56
|
[
|
|
59
|
-
"
|
|
57
|
+
"pi",
|
|
60
58
|
"-e",
|
|
61
59
|
"/workspace/.team/runtime/pi/current/leader/team-mcp.ts",
|
|
62
60
|
"--append-system-prompt",
|
|
@@ -109,7 +107,7 @@ fn pi_command_preserves_direct_pi_defaults_with_only_team_runtime_additions() {
|
|
|
109
107
|
assert_eq!(
|
|
110
108
|
fresh,
|
|
111
109
|
[
|
|
112
|
-
"
|
|
110
|
+
"pi",
|
|
113
111
|
"-e",
|
|
114
112
|
"/workspace/.team/runtime/pi/team-a/worker-a/team-mcp.ts",
|
|
115
113
|
"--model",
|
|
@@ -143,6 +141,7 @@ fn pi_command_preserves_direct_pi_defaults_with_only_team_runtime_additions() {
|
|
|
143
141
|
assert!(!resume.iter().any(|arg| arg == "--session-id"));
|
|
144
142
|
|
|
145
143
|
for argv in [&fresh, &resume] {
|
|
144
|
+
assert_eq!(argv.first().map(String::as_str), Some("pi"));
|
|
146
145
|
for forbidden in [
|
|
147
146
|
"rpc",
|
|
148
147
|
"--mode",
|
|
@@ -53,7 +53,6 @@ fn compile_error(result: Result<CompiledRole, ModelError>) -> ModelError {
|
|
|
53
53
|
|
|
54
54
|
fn command(tools: &[&str], effort: ProviderEffort, model: &str) -> Result<Vec<String>, String> {
|
|
55
55
|
build_pi_command_argv(PiCommandRequest {
|
|
56
|
-
executable: Path::new("/verified/pi"),
|
|
57
56
|
extension: Path::new("/workspace/.team/runtime/pi/t1/pi-worker/team-mcp.ts"),
|
|
58
57
|
model: Some(model),
|
|
59
58
|
effort: Some(effort),
|
|
@@ -586,6 +586,7 @@ fn pi_materializer_and_worker_routes_body(hermetic: &HermeticTestEnv) {
|
|
|
586
586
|
!native_paths.sessions.exists(),
|
|
587
587
|
"NativeDefault must not create the Team Agent seat session root"
|
|
588
588
|
);
|
|
589
|
+
assert_eq!(native.argv.first().map(String::as_str), Some("pi"));
|
|
589
590
|
for forbidden in ["--session-dir", "--cwd", "--workspace", "--session"] {
|
|
590
591
|
assert!(
|
|
591
592
|
!native.argv.iter().any(|arg| arg == forbidden),
|
|
@@ -614,6 +615,7 @@ fn pi_materializer_and_worker_routes_body(hermetic: &HermeticTestEnv) {
|
|
|
614
615
|
isolated.provider_projects_root.as_deref(),
|
|
615
616
|
Some(isolated_paths.sessions.as_path())
|
|
616
617
|
);
|
|
618
|
+
assert_eq!(isolated.argv.first().map(String::as_str), Some("pi"));
|
|
617
619
|
assert_session_pair(&isolated.argv, &isolated_paths.sessions);
|
|
618
620
|
assert!(!isolated.argv.iter().any(|arg| arg == "--cwd"));
|
|
619
621
|
assert!(!isolated.argv.iter().any(|arg| arg == "--workspace"));
|
|
@@ -645,6 +647,7 @@ fn pi_materializer_and_worker_routes_body(hermetic: &HermeticTestEnv) {
|
|
|
645
647
|
resumed.provider_projects_root.as_deref(),
|
|
646
648
|
Some(isolated_paths.sessions.as_path())
|
|
647
649
|
);
|
|
650
|
+
assert_eq!(resumed.argv.first().map(String::as_str), Some("pi"));
|
|
648
651
|
assert_session_pair(&resumed.argv, &isolated_paths.sessions);
|
|
649
652
|
assert!(
|
|
650
653
|
resumed
|
|
@@ -757,6 +760,7 @@ fn pi_materializer_and_worker_routes_body(hermetic: &HermeticTestEnv) {
|
|
|
757
760
|
.any(|arg| arg.as_str() == paths.wrapper.to_string_lossy().as_ref())
|
|
758
761
|
})
|
|
759
762
|
.expect("transport spawn record for worker seat");
|
|
763
|
+
assert_eq!(record.1.first().map(String::as_str), Some("pi"));
|
|
760
764
|
assert_session_pair(&record.1, &paths.sessions);
|
|
761
765
|
assert!(
|
|
762
766
|
!record
|
|
@@ -861,6 +865,7 @@ fn pi_materializer_and_worker_routes_body(hermetic: &HermeticTestEnv) {
|
|
|
861
865
|
.into_iter()
|
|
862
866
|
.next()
|
|
863
867
|
.expect("restart must reach the worker spawn construction");
|
|
868
|
+
assert_eq!(restart_record.1.first().map(String::as_str), Some("pi"));
|
|
864
869
|
assert_session_pair(&restart_record.1, &restart_paths.sessions);
|
|
865
870
|
assert!(
|
|
866
871
|
restart_record
|
|
@@ -54,7 +54,6 @@ fn scan_context(cwd: &Path, root: &Path, id: &str, generation: &str) -> CaptureS
|
|
|
54
54
|
|
|
55
55
|
fn resume_argv(path: &Path) -> Result<Vec<String>, ProviderError> {
|
|
56
56
|
build_pi_command_argv(PiCommandRequest {
|
|
57
|
-
executable: Path::new("/verified/pi"),
|
|
58
57
|
extension: Path::new("/workspace/.team/runtime/pi/team-a/worker-a/team-mcp.ts"),
|
|
59
58
|
model: Some("team-agent/qwen3.8-27b"),
|
|
60
59
|
effort: Some(ProviderEffort::Medium),
|
|
@@ -28,20 +28,20 @@ use crate::model::permissions::{resolve_permissions, AgentPermissionInput};
|
|
|
28
28
|
|
|
29
29
|
const RUNTIME_CONTRACT_SECTION: &str = r#"# Team Agent Teammate Runtime Contract
|
|
30
30
|
|
|
31
|
-
You are a
|
|
32
|
-
|
|
31
|
+
You are a Team Agent teammate; leader cannot see terminal output.
|
|
32
|
+
All communication must go through Team Agent MCP tools.
|
|
33
33
|
|
|
34
|
-
## Communication
|
|
34
|
+
## Communication:
|
|
35
35
|
|
|
36
|
-
-
|
|
37
|
-
- Broadcast
|
|
38
|
-
-
|
|
36
|
+
- Teammate: {send_message}(to='<agent_id>', content='...')
|
|
37
|
+
- Broadcast: {send_message}(to='*', content='...')
|
|
38
|
+
- Complete: {report_result}(summary='...') — call exactly once
|
|
39
39
|
|
|
40
|
-
## Rules
|
|
40
|
+
## Rules:
|
|
41
41
|
|
|
42
|
-
- Do not pass sender, task_id, or schema_version —
|
|
43
|
-
-
|
|
44
|
-
- On 500/529/rate
|
|
42
|
+
- Do not pass sender, task_id, or schema_version — MCP fills them.
|
|
43
|
+
- Do not reply to pure ACKs, greetings, or unchanged status notices (such as "paused" or "waiting"); after reporting a blocker once or completing a task, remain silent until a new actionable instruction arrives.
|
|
44
|
+
- On 500/529/rate limits, retry only after 1-2 minutes."#;
|
|
45
45
|
|
|
46
46
|
// 0.4.11 trimmed: the runtime contract section above already covers
|
|
47
47
|
// send_message signatures and report_result exactly-once. The output
|
|
@@ -363,9 +363,7 @@ fn pi_communication_contract(mode: CommunicationMode) -> String {
|
|
|
363
363
|
|
|
364
364
|
- Progress, blockers, questions: mcp({tool:"team_orchestrator_send_message", args:{to:"leader", content:"..."}})
|
|
365
365
|
|
|
366
|
-
|
|
367
|
-
through MCP tools. Writing a reply in your terminal does nothing — the sender
|
|
368
|
-
will never see it."#
|
|
366
|
+
Respond through Team Agent MCP tools only to actionable requests or questions; writing in your terminal does not deliver it."#
|
|
369
367
|
.to_string(),
|
|
370
368
|
CommunicationMode::Orchestrated => mode.runtime_contract("mcp"),
|
|
371
369
|
}
|
|
@@ -598,4 +596,40 @@ mod tests {
|
|
|
598
596
|
assert!(prompt.contains(&slowdown_phrase));
|
|
599
597
|
assert!(prompt.contains("Runtime Developer"));
|
|
600
598
|
}
|
|
599
|
+
|
|
600
|
+
#[test]
|
|
601
|
+
fn all_provider_mode_prompts_share_silence_contract_without_unconditional_replies() {
|
|
602
|
+
for provider in [Provider::Codex, Provider::Pi] {
|
|
603
|
+
for mode in CommunicationMode::ALL.iter().copied() {
|
|
604
|
+
let agent = WorkerCommandAgent {
|
|
605
|
+
id: Some("worker".to_string()),
|
|
606
|
+
provider,
|
|
607
|
+
role: Some("developer".to_string()),
|
|
608
|
+
declared_tools: Some(vec!["mcp_team".to_string()]),
|
|
609
|
+
system_prompt_inline: Some("worker body".to_string()),
|
|
610
|
+
system_prompt_file: None,
|
|
611
|
+
output_contract_format: Some("result_envelope_v1".to_string()),
|
|
612
|
+
communication_mode: mode,
|
|
613
|
+
dangerously_skip_permissions: false,
|
|
614
|
+
};
|
|
615
|
+
let prompt = compile_worker_system_prompt(&agent).unwrap();
|
|
616
|
+
assert!(prompt.contains(
|
|
617
|
+
"Do not reply to pure ACKs, greetings, or unchanged status notices (such as \"paused\" or \"waiting\"); after reporting a blocker once or completing a task, remain silent until a new actionable instruction arrives."
|
|
618
|
+
));
|
|
619
|
+
assert!(!prompt.contains("Silence and resumption (mandatory)"));
|
|
620
|
+
assert!(!prompt.contains("When you receive a message from the leader or a teammate, you MUST respond"));
|
|
621
|
+
match mode {
|
|
622
|
+
CommunicationMode::LeaderCentric => {
|
|
623
|
+
assert!(prompt.contains(
|
|
624
|
+
"Respond through Team Agent MCP tools only to actionable requests or questions; writing in your terminal does not deliver it."
|
|
625
|
+
));
|
|
626
|
+
}
|
|
627
|
+
CommunicationMode::Orchestrated => {
|
|
628
|
+
assert!(prompt.contains("Respond to task-related messages through Team Agent MCP tools."));
|
|
629
|
+
assert!(prompt.contains("A pure ACK, unrelated status, or non-task message does not require a response."));
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
601
635
|
}
|
|
@@ -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);
|
|
@@ -58,7 +58,6 @@ pub(crate) enum PiSessionSelector<'a> {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
pub(crate) struct PiCommandRequest<'a> {
|
|
61
|
-
pub executable: &'a Path,
|
|
62
61
|
pub extension: &'a Path,
|
|
63
62
|
pub model: Option<&'a str>,
|
|
64
63
|
pub effort: Option<ProviderEffort>,
|
|
@@ -72,14 +71,13 @@ pub(crate) struct PiCommandRequest<'a> {
|
|
|
72
71
|
|
|
73
72
|
/// ---
|
|
74
73
|
/// purpose: 从已验证 semantic request 构造仅追加 Team Agent MCP/提示/session 的 Pi argv
|
|
75
|
-
/// returns: exact ordered fresh/resume argv
|
|
74
|
+
/// returns: exact ordered fresh/resume argv,argv[0] 固定为裸 `pi`
|
|
76
75
|
/// errors: 必需字段、mcp_team 或工具 category 非法时返回 ProviderError
|
|
77
76
|
/// ---
|
|
78
77
|
pub(crate) fn build_pi_command_argv(
|
|
79
78
|
request: PiCommandRequest<'_>,
|
|
80
79
|
) -> Result<Vec<String>, ProviderError> {
|
|
81
|
-
if request.
|
|
82
|
-
|| request.extension.as_os_str().is_empty()
|
|
80
|
+
if request.extension.as_os_str().is_empty()
|
|
83
81
|
|| request
|
|
84
82
|
.session_dir
|
|
85
83
|
.is_some_and(|session_dir| session_dir.as_os_str().is_empty())
|
|
@@ -88,7 +86,7 @@ pub(crate) fn build_pi_command_argv(
|
|
|
88
86
|
|| request.agent_id.trim().is_empty()
|
|
89
87
|
{
|
|
90
88
|
return Err(ProviderError::Command(
|
|
91
|
-
"Pi command requires
|
|
89
|
+
"Pi command requires extension, prompt, and agent id".to_string(),
|
|
92
90
|
));
|
|
93
91
|
}
|
|
94
92
|
|
|
@@ -110,8 +108,10 @@ pub(crate) fn build_pi_command_argv(
|
|
|
110
108
|
));
|
|
111
109
|
}
|
|
112
110
|
|
|
111
|
+
// Executable discovery/preflight belongs to the lifecycle materializer;
|
|
112
|
+
// the actual command must remain PATH-resolved at spawn time.
|
|
113
113
|
let mut argv = vec![
|
|
114
|
-
|
|
114
|
+
"pi".to_string(),
|
|
115
115
|
"-e".to_string(),
|
|
116
116
|
request.extension.to_string_lossy().into_owned(),
|
|
117
117
|
];
|
|
@@ -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
|
+
}
|