@team-agent/installer 0.3.3 → 0.3.5

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.
Files changed (63) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +8 -0
  4. package/crates/team-agent/src/cli/diagnose.rs +52 -11
  5. package/crates/team-agent/src/cli/emit.rs +3 -2
  6. package/crates/team-agent/src/cli/mod.rs +225 -80
  7. package/crates/team-agent/src/cli/send.rs +1 -0
  8. package/crates/team-agent/src/cli/status_port.rs +135 -7
  9. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +8 -1
  10. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  11. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +39 -0
  12. package/crates/team-agent/src/cli/types.rs +5 -1
  13. package/crates/team-agent/src/compiler/tests.rs +2 -2
  14. package/crates/team-agent/src/compiler.rs +1 -1
  15. package/crates/team-agent/src/coordinator/backoff.rs +57 -9
  16. package/crates/team-agent/src/coordinator/health.rs +65 -2
  17. package/crates/team-agent/src/coordinator/runtime_detectors.rs +28 -16
  18. package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +87 -0
  19. package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
  20. package/crates/team-agent/src/coordinator/tests/watch.rs +4 -2
  21. package/crates/team-agent/src/coordinator/tick.rs +195 -43
  22. package/crates/team-agent/src/leader/helpers.rs +2 -0
  23. package/crates/team-agent/src/leader/rediscover.rs +1 -0
  24. package/crates/team-agent/src/leader/start.rs +9 -1
  25. package/crates/team-agent/src/leader/takeover.rs +18 -1
  26. package/crates/team-agent/src/lifecycle/display.rs +3 -3
  27. package/crates/team-agent/src/lifecycle/launch.rs +772 -285
  28. package/crates/team-agent/src/lifecycle/mod.rs +1 -0
  29. package/crates/team-agent/src/lifecycle/profile_launch.rs +110 -4
  30. package/crates/team-agent/src/lifecycle/profile_smoke.rs +4 -1
  31. package/crates/team-agent/src/lifecycle/restart/agent.rs +16 -5
  32. package/crates/team-agent/src/lifecycle/restart/common.rs +35 -25
  33. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +31 -25
  34. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -2
  35. package/crates/team-agent/src/lifecycle/tests/core.rs +5 -5
  36. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +4 -4
  37. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +5 -3
  38. package/crates/team-agent/src/lifecycle/types.rs +4 -0
  39. package/crates/team-agent/src/lifecycle/worker_command_context.rs +361 -0
  40. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +2 -1
  41. package/crates/team-agent/src/mcp_server/tests/scoped.rs +14 -1
  42. package/crates/team-agent/src/mcp_server/tests/send.rs +15 -1
  43. package/crates/team-agent/src/mcp_server/tools.rs +65 -9
  44. package/crates/team-agent/src/mcp_server/wire.rs +2 -1
  45. package/crates/team-agent/src/message_store.rs +80 -0
  46. package/crates/team-agent/src/messaging/results.rs +76 -5
  47. package/crates/team-agent/src/messaging/send.rs +3 -1
  48. package/crates/team-agent/src/messaging/types.rs +15 -1
  49. package/crates/team-agent/src/messaging/watchers.rs +68 -30
  50. package/crates/team-agent/src/model/enums.rs +7 -1
  51. package/crates/team-agent/src/model/permissions.rs +7 -0
  52. package/crates/team-agent/src/model/spec.rs +3 -1
  53. package/crates/team-agent/src/provider/adapter.rs +472 -7
  54. package/crates/team-agent/src/provider/classify.rs +6 -2
  55. package/crates/team-agent/src/provider/faults.rs +3 -2
  56. package/crates/team-agent/src/provider/startup_prompt.rs +25 -7
  57. package/crates/team-agent/src/provider/types.rs +11 -0
  58. package/crates/team-agent/src/session_capture.rs +1 -0
  59. package/crates/team-agent/src/state/persist.rs +95 -19
  60. package/crates/team-agent/src/tmux_backend/tests.rs +8 -7
  61. package/crates/team-agent/src/tmux_backend.rs +134 -6
  62. package/crates/team-agent/src/transport.rs +32 -0
  63. package/package.json +4 -4
@@ -0,0 +1,361 @@
1
+ use std::path::Path;
2
+
3
+ use crate::lifecycle::types::{DangerousApproval, LifecycleError};
4
+ use crate::model::enums::{Enforcement, Provider};
5
+ use crate::model::ids::AgentId;
6
+ use crate::model::permissions::{resolve_permissions, AgentPermissionInput};
7
+
8
+ const RUNTIME_CONTRACT_SECTION: &str = r#"# Team Agent Teammate Runtime Contract
9
+
10
+ You are a teammate in a Team Agent runtime, not the user's primary assistant.
11
+ The user normally talks to the team lead. Plain text you write in this worker
12
+ session is local to this session and is not a team message.
13
+
14
+ Use Team Agent MCP tools for team-visible coordination:
15
+ - Send progress, blockers, permission needs, tool failures, scope changes, and
16
+ long-running status updates with team_orchestrator.send_message(to='leader',
17
+ content='<short message>').
18
+ - Send to another teammate by agent id when coordination is useful, or use
19
+ to='*' to notify every other team member. The runtime resolves only this team
20
+ and excludes your own worker.
21
+ - When the task is complete, call team_orchestrator.report_result exactly once.
22
+ - Do not pass sender, task_id, agent_id, schema_version, or ack fields unless
23
+ doing a low-level compatibility diagnostic. The MCP runtime fills protocol
24
+ fields from the current worker and task state.
25
+
26
+ If you are blocked or cannot continue, message the leader promptly instead of
27
+ waiting silently. If work takes several minutes, send a short progress update.
28
+
29
+ When any Team Agent worker hits a 500/529/rate-limit/overloaded API error,
30
+ slow the team down before retrying: wait 1-2 minutes, keep active workers low,
31
+ and avoid blind immediate retries."#;
32
+
33
+ const RESULT_ENVELOPE_OUTPUT_CONTRACT: &str =
34
+ "For progress or blockers, call team_orchestrator.send_message(to='leader', content='<short message>'); \
35
+ for teammate coordination, send to another agent id or to='*' for every other team member. \
36
+ do not pass sender, task_id, or requires_ack because the MCP runtime fills protocol fields. \
37
+ the runtime injects it into the attached Codex leader pane when the leader has run attach-leader. \
38
+ If no leader is attached, the tool returns a fallback/failed result instead of completion. \
39
+ Final completion must call team_orchestrator.report_result exactly once with a short summary \
40
+ and optional status/changes/tests; MCP fills schema_version, task_id, and agent_id.";
41
+
42
+ pub(crate) struct WorkerCommandAgent {
43
+ id: Option<String>,
44
+ provider: Provider,
45
+ role: Option<String>,
46
+ declared_tools: Option<Vec<String>>,
47
+ system_prompt_inline: Option<String>,
48
+ system_prompt_file: Option<String>,
49
+ output_contract_format: Option<String>,
50
+ }
51
+
52
+ impl WorkerCommandAgent {
53
+ pub(crate) fn from_yaml(
54
+ agent: &crate::model::yaml::Value,
55
+ fallback_id: Option<&str>,
56
+ provider: Provider,
57
+ ) -> Self {
58
+ let system_prompt = agent.get("system_prompt");
59
+ Self {
60
+ id: agent
61
+ .get("id")
62
+ .and_then(crate::model::yaml::Value::as_str)
63
+ .or(fallback_id)
64
+ .map(str::to_string),
65
+ provider,
66
+ role: agent
67
+ .get("role")
68
+ .and_then(crate::model::yaml::Value::as_str)
69
+ .map(str::to_string),
70
+ declared_tools: agent
71
+ .get("tools")
72
+ .and_then(crate::model::yaml::Value::as_list)
73
+ .map(|items| {
74
+ items
75
+ .iter()
76
+ .filter_map(crate::model::yaml::Value::as_str)
77
+ .map(str::to_string)
78
+ .collect()
79
+ }),
80
+ system_prompt_inline: system_prompt
81
+ .and_then(|prompt| prompt.get("inline"))
82
+ .and_then(crate::model::yaml::Value::as_str)
83
+ .filter(|value| !value.is_empty())
84
+ .map(str::to_string),
85
+ system_prompt_file: system_prompt
86
+ .and_then(|prompt| prompt.get("file"))
87
+ .filter(|value| value.is_truthy())
88
+ .and_then(crate::model::yaml::Value::as_str)
89
+ .map(str::to_string),
90
+ output_contract_format: agent
91
+ .get("output_contract")
92
+ .and_then(|contract| contract.get("format"))
93
+ .and_then(crate::model::yaml::Value::as_str)
94
+ .map(str::to_string),
95
+ }
96
+ }
97
+
98
+ pub(crate) fn from_json(
99
+ agent: &serde_json::Value,
100
+ fallback_id: Option<&str>,
101
+ provider: Provider,
102
+ ) -> Self {
103
+ let system_prompt = agent.get("system_prompt");
104
+ Self {
105
+ id: agent
106
+ .get("id")
107
+ .and_then(serde_json::Value::as_str)
108
+ .or(fallback_id)
109
+ .map(str::to_string),
110
+ provider,
111
+ role: agent
112
+ .get("role")
113
+ .and_then(serde_json::Value::as_str)
114
+ .map(str::to_string),
115
+ declared_tools: agent
116
+ .get("tools")
117
+ .and_then(serde_json::Value::as_array)
118
+ .map(|items| {
119
+ items
120
+ .iter()
121
+ .filter_map(serde_json::Value::as_str)
122
+ .map(str::to_string)
123
+ .collect()
124
+ }),
125
+ system_prompt_inline: system_prompt
126
+ .and_then(|prompt| prompt.get("inline"))
127
+ .and_then(serde_json::Value::as_str)
128
+ .filter(|value| !value.is_empty())
129
+ .map(str::to_string),
130
+ system_prompt_file: system_prompt
131
+ .and_then(|prompt| prompt.get("file"))
132
+ .and_then(serde_json::Value::as_str)
133
+ .filter(|value| !value.is_empty())
134
+ .map(str::to_string),
135
+ output_contract_format: agent
136
+ .get("output_contract")
137
+ .and_then(|contract| contract.get("format"))
138
+ .and_then(serde_json::Value::as_str)
139
+ .map(str::to_string),
140
+ }
141
+ }
142
+ }
143
+
144
+ pub(crate) fn compile_worker_system_prompt(
145
+ agent: &WorkerCommandAgent,
146
+ ) -> Result<String, LifecycleError> {
147
+ // Python prompt.py:39 — chunks = [identity, TEAMMATE_SYSTEM_PROMPT, ...]: the worker
148
+ // identity line anchors the very first section (live Python worker argv confirms).
149
+ // C-1 cr verdict / B2 灵魂件 — identity 必须 FIRST(MUST-4 行为层守:空白上下文问
150
+ // "你是谁"必须先答 Team Agent worker 身份)。runtime contract 跟后。
151
+ let mut chunks = vec![
152
+ identity_section(agent),
153
+ runtime_contract_section(),
154
+ role_body(agent)?,
155
+ ];
156
+ if let Some(contract) = output_contract(agent) {
157
+ chunks.push(contract);
158
+ }
159
+ if let Some(notes) = permission_notes(agent)? {
160
+ chunks.push(notes);
161
+ }
162
+ Ok(chunks
163
+ .into_iter()
164
+ .filter(|chunk| !chunk.is_empty())
165
+ .collect::<Vec<_>>()
166
+ .join("\n\n"))
167
+ }
168
+
169
+ pub(crate) fn resolved_tool_strings_for_command(
170
+ agent: &WorkerCommandAgent,
171
+ provider: Provider,
172
+ safety: &DangerousApproval,
173
+ ) -> Result<Vec<String>, LifecycleError> {
174
+ let mut tools: Vec<String> = resolve_agent_permissions(agent, provider)?
175
+ .sorted_tool_strings()
176
+ .into_iter()
177
+ .map(str::to_string)
178
+ .collect();
179
+ if safety.enabled && !tools.iter().any(|tool| tool == "dangerous_auto_approve") {
180
+ tools.push("dangerous_auto_approve".to_string());
181
+ }
182
+ Ok(tools)
183
+ }
184
+
185
+ fn resolve_agent_permissions(
186
+ agent: &WorkerCommandAgent,
187
+ provider: Provider,
188
+ ) -> Result<crate::model::permissions::ResolvedPermissions, LifecycleError> {
189
+ resolve_permissions(&AgentPermissionInput {
190
+ id: agent.id.as_deref().map(AgentId::new),
191
+ provider,
192
+ role: agent.role.clone(),
193
+ tools: agent.declared_tools.clone(),
194
+ })
195
+ .map_err(|e| LifecycleError::Compile(e.to_string()))
196
+ }
197
+
198
+ fn runtime_contract_section() -> String {
199
+ RUNTIME_CONTRACT_SECTION.to_string()
200
+ }
201
+
202
+ fn identity_section(agent: &WorkerCommandAgent) -> String {
203
+ format!(
204
+ "You are Team Agent worker `{}` with role `{}`. When asked about your role or identity, answer with this Team Agent worker identity first, not only the generic provider product identity.",
205
+ agent.id.as_deref().unwrap_or("unknown"),
206
+ agent.role.as_deref().unwrap_or("developer")
207
+ )
208
+ }
209
+
210
+ fn role_body(agent: &WorkerCommandAgent) -> Result<String, LifecycleError> {
211
+ let mut chunks = Vec::new();
212
+ if let Some(inline) = &agent.system_prompt_inline {
213
+ chunks.push(inline.clone());
214
+ }
215
+ if let Some(path) = &agent.system_prompt_file {
216
+ let body = std::fs::read_to_string(Path::new(path))
217
+ .map_err(|e| LifecycleError::Compile(format!("read system_prompt.file {path}: {e}")))?;
218
+ if !body.is_empty() {
219
+ chunks.push(body);
220
+ }
221
+ }
222
+ Ok(chunks.join("\n\n"))
223
+ }
224
+
225
+ fn output_contract(agent: &WorkerCommandAgent) -> Option<String> {
226
+ (agent.output_contract_format.as_deref() == Some("result_envelope_v1"))
227
+ .then(|| RESULT_ENVELOPE_OUTPUT_CONTRACT.to_string())
228
+ }
229
+
230
+ fn permission_notes(agent: &WorkerCommandAgent) -> Result<Option<String>, LifecycleError> {
231
+ let permissions = resolve_agent_permissions(agent, agent.provider)?;
232
+ // C-2-1/C-2-2 cr verdict — Copilot 一期 framework 不替决 fs_read/fs_list/git_diff/
233
+ // provider_builtin(provider prompt 控);为诚实(MUST-NOT-13)在 system prompt 内
234
+ // 总是声明这些 provider-level prompt_only 工具,即便角色未显式声明。
235
+ let provider_prompt_only_extras = provider_default_prompt_only_tools(agent.provider);
236
+ let mut prompt_only: std::collections::BTreeSet<String> = permissions
237
+ .resolved_tools
238
+ .iter()
239
+ .filter(|tool| tool.enforcement == Enforcement::PromptOnly)
240
+ .filter_map(|tool| serde_json::to_value(tool.tool).ok())
241
+ .filter_map(|value| value.as_str().map(str::to_string))
242
+ .collect();
243
+ for tool in provider_prompt_only_extras {
244
+ prompt_only.insert((*tool).to_string());
245
+ }
246
+ if prompt_only.is_empty() {
247
+ return Ok(None);
248
+ }
249
+ let prompt_only: Vec<String> = prompt_only.into_iter().collect();
250
+ Ok(Some(format!(
251
+ "Permission note: these tools are prompt-only for this provider and not hard-enforced: {}",
252
+ prompt_only.join(", ")
253
+ )))
254
+ }
255
+
256
+ /// C-2-1 cr verdict — provider-level prompt_only tools that the framework cannot
257
+ /// hard-enforce. The system prompt declares them so the worker is honest about
258
+ /// where consent gates actually live (provider prompt vs framework).
259
+ fn provider_default_prompt_only_tools(provider: Provider) -> &'static [&'static str] {
260
+ match provider {
261
+ // C-2-1: fs_read / fs_list / git_diff / provider_builtin 由 provider prompt
262
+ // 控制,framework 不替决(prompt_only 诚实声明)。
263
+ Provider::Copilot => &["fs_list", "fs_read", "git_diff", "provider_builtin"],
264
+ _ => &[],
265
+ }
266
+ }
267
+
268
+ #[cfg(test)]
269
+ mod tests {
270
+ use super::*;
271
+ use crate::lifecycle::types::DangerousApprovalSource;
272
+
273
+ fn disabled_safety() -> DangerousApproval {
274
+ DangerousApproval {
275
+ enabled: false,
276
+ source: DangerousApprovalSource::Disabled,
277
+ inherited: false,
278
+ provider: None,
279
+ flag: None,
280
+ worker_capability_above_leader: false,
281
+ ancestry_binary_name: None,
282
+ unexpected_binary: false,
283
+ }
284
+ }
285
+
286
+ #[test]
287
+ fn empty_tools_use_role_defaults_and_aliases_resolve_before_command() {
288
+ let agent = WorkerCommandAgent {
289
+ id: Some("dev".to_string()),
290
+ provider: Provider::ClaudeCode,
291
+ role: Some("developer".to_string()),
292
+ declared_tools: Some(Vec::new()),
293
+ system_prompt_inline: None,
294
+ system_prompt_file: None,
295
+ output_contract_format: None,
296
+ };
297
+ let tools =
298
+ resolved_tool_strings_for_command(&agent, Provider::ClaudeCode, &disabled_safety())
299
+ .unwrap();
300
+ assert_eq!(
301
+ tools,
302
+ [
303
+ "execute_bash",
304
+ "fs_list",
305
+ "fs_read",
306
+ "fs_write",
307
+ "git_diff",
308
+ "mcp_team",
309
+ "provider_builtin"
310
+ ]
311
+ );
312
+
313
+ let agent = WorkerCommandAgent {
314
+ declared_tools: Some(vec!["fs_*".to_string(), "@team-orchestrator".to_string()]),
315
+ ..agent
316
+ };
317
+ let tools =
318
+ resolved_tool_strings_for_command(&agent, Provider::ClaudeCode, &disabled_safety())
319
+ .unwrap();
320
+ assert_eq!(tools, ["fs_list", "fs_read", "fs_write", "mcp_team"]);
321
+ }
322
+
323
+ #[test]
324
+ fn system_prompt_uses_identity_then_runtime_contract_python_order() {
325
+ // #264 D6: Python truth source (prompt.py:39, live ps confirmed) builds
326
+ // chunks = [identity, TEAMMATE_SYSTEM_PROMPT, role_body, output, permissions].
327
+ // The previous assertion locked the inverted contract-first order with no
328
+ // Python evidence; this is the corrected golden.
329
+ // 0.3.5 union (copilot v2 C-1 / B2 MUST-4 行为层守): identity 必须 FIRST —
330
+ // 空白上下文问"你是谁"的第一行答案必须先答 Team Agent worker 身份;
331
+ // Copilot 适配的 C-1-3 行为层要求与其它 provider 同步。
332
+ let agent = WorkerCommandAgent {
333
+ id: Some("coder".to_string()),
334
+ provider: Provider::Codex,
335
+ role: Some("Runtime Developer".to_string()),
336
+ declared_tools: Some(vec!["mcp_team".to_string()]),
337
+ system_prompt_inline: Some("Implement the assigned slice.".to_string()),
338
+ system_prompt_file: None,
339
+ output_contract_format: Some("result_envelope_v1".to_string()),
340
+ };
341
+ let prompt = compile_worker_system_prompt(&agent).unwrap();
342
+ assert!(
343
+ prompt.starts_with("You are Team Agent worker `coder` with role `Runtime Developer`."),
344
+ "compiled prompt must start with the identity section (Python prompt.py:39); head={:?}",
345
+ prompt.chars().take(120).collect::<String>()
346
+ );
347
+ let identity = prompt.find("worker `coder`").unwrap();
348
+ let runtime = prompt
349
+ .find(RUNTIME_CONTRACT_SECTION.lines().next().unwrap_or(""))
350
+ .unwrap();
351
+ let role = prompt.find("Implement the assigned slice.").unwrap();
352
+ let output = prompt
353
+ .find("Final completion must call team_orchestrator.report_result exactly once")
354
+ .unwrap();
355
+ let permissions = prompt.find("Permission note:").unwrap();
356
+ assert!(identity < runtime && runtime < role && role < output && output < permissions);
357
+ let slowdown_phrase = format!("500/{}", 500 + 29);
358
+ assert!(prompt.contains(&slowdown_phrase));
359
+ assert!(prompt.contains("Runtime Developer"));
360
+ }
361
+ }
@@ -88,12 +88,13 @@ pub(crate) fn fork_agent(
88
88
  as_agent_id: &str,
89
89
  label: Option<&str>,
90
90
  ) -> ToolResult {
91
- let _ = label;
92
91
  let lifecycle_workspace = lifecycle_workspace(workspace, owner_team, false)?;
92
+ // operations.py:315 — the label becomes the forked agent's role.
93
93
  let report = crate::lifecycle::launch::fork_agent(
94
94
  &lifecycle_workspace,
95
95
  &AgentId::new(source_agent_id),
96
96
  &AgentId::new(as_agent_id),
97
+ label,
97
98
  false,
98
99
  owner_team.map(TeamKey::as_str),
99
100
  )
@@ -1,7 +1,20 @@
1
1
  #[test]
2
2
  fn dispatch_send_message_worker_accepted_returned_verbatim() {
3
+ // A-7: accepted requires a REAL stored message_id (no fabricated ids), so the
4
+ // workspace seeds a running worker-1 the delivery layer can actually queue for.
5
+ let ws = unique_ws("dispatch-accepted");
6
+ crate::state::persist::save_runtime_state(
7
+ &ws,
8
+ &serde_json::json!({
9
+ "session_name": "team-x",
10
+ "agents": {
11
+ "worker-1": {"status": "running", "agent_id": "worker-1", "window": "worker-1"},
12
+ },
13
+ }),
14
+ )
15
+ .unwrap();
3
16
  let tools = TeamOrchestratorTools::with_identity(
4
- &unique_ws("dispatch-accepted"),
17
+ &ws,
5
18
  Some(AgentId::new("leader")), // legacy single-team bypasses cross-team refusal
6
19
  None,
7
20
  );
@@ -96,8 +96,22 @@
96
96
  // refusal first (worker-1 not in visible peers) -> PeerNotInScope. owner_team_id=None
97
97
  // (legacy single-team) bypasses that, isolating the worker-recipient accepted path.
98
98
  // The cross-team refusal has its own tests (refuse_cross_team_peer_* / send_message_cross_team_*).
99
+ // A-7: the accepted path needs a REAL stored message_id (tools.py:175-181 —
100
+ // fabricated ids are gone), so the workspace seeds a running worker-1 the
101
+ // delivery layer can actually queue for.
102
+ let ws = unique_ws("send-worker");
103
+ crate::state::persist::save_runtime_state(
104
+ &ws,
105
+ &serde_json::json!({
106
+ "session_name": "team-x",
107
+ "agents": {
108
+ "worker-1": {"status": "running", "agent_id": "worker-1", "window": "worker-1"},
109
+ },
110
+ }),
111
+ )
112
+ .unwrap();
99
113
  let tools = TeamOrchestratorTools::with_identity(
100
- &unique_ws("send-worker"),
114
+ &ws,
101
115
  Some(AgentId::new("leader")),
102
116
  None,
103
117
  );
@@ -189,11 +189,12 @@ impl TeamOrchestratorTools {
189
189
  };
190
190
  if is_worker_recipient(to) {
191
191
  let out = messaging::send_message(&self.workspace, to, content, &opts).map_err(tool_runtime_error)?;
192
+ // tools.py:175-181 — accepted+poll_via ONLY for a REAL message_id; any other
193
+ // outcome falls back to the compacted direct result. Never invent an
194
+ // `mcp_<timestamp>` id: it does not exist in the store and makes the
195
+ // advertised `team-agent inbox <id>` poll a dead end.
192
196
  let message_id = match out.message_id {
193
197
  Some(message_id) if out.ok => message_id,
194
- None if self.owner_team_id.is_none() => {
195
- format!("mcp_{}", chrono::Utc::now().timestamp_micros())
196
- }
197
198
  _ => {
198
199
  let value = delivery_outcome_value(&out);
199
200
  let ok = compact_tool_result(&value)?;
@@ -229,7 +230,13 @@ impl TeamOrchestratorTools {
229
230
  let team_widens = match (owner_team.as_ref(), requested_team.as_deref()) {
230
231
  (_, None) => false,
231
232
  (Some(owner), Some(requested)) => {
232
- let state = load_runtime_state(&self.workspace).unwrap_or(serde_json::json!({}));
233
+ // swallow batch 4 C6: an unreadable state cannot verify the requested
234
+ // team — fail closed (structured transient refusal), never compare
235
+ // against an empty substitute.
236
+ let state = match load_runtime_state(&self.workspace) {
237
+ Ok(state) => state,
238
+ Err(error) => return Err(self.scope_unverifiable(&error.to_string())),
239
+ };
233
240
  let requested_canonical = crate::state::projection::resolve_owner_team_id(&state, requested)
234
241
  .canonical_key()
235
242
  .unwrap_or(requested)
@@ -463,7 +470,18 @@ impl TeamOrchestratorTools {
463
470
  "idle_fallback" => Some(messaging::AlertType::IdleFallback),
464
471
  "cross_worker_deadlock" => Some(messaging::AlertType::CrossWorkerDeadlock),
465
472
  "all" => None,
466
- _ => None,
473
+ // scheduler.py:268-273 — an unknown alert_type refuses with the Python
474
+ // literal instead of silently widening to suppressing ALL alert families.
475
+ _ => {
476
+ return Ok(ToolOk {
477
+ fields: object_fields(serde_json::json!({
478
+ "ok": false,
479
+ "status": "refused",
480
+ "reason": "invalid_alert_type",
481
+ "alert_type": alert_type,
482
+ })),
483
+ });
484
+ }
467
485
  };
468
486
  let suppressed_by = self.agent_id.as_ref().map(AgentId::as_str).unwrap_or("leader");
469
487
  messaging::stuck_cancel(&self.workspace, agent_id, alert, suppressed_by)
@@ -571,7 +589,12 @@ impl TeamOrchestratorTools {
571
589
  // Unresolved/ambiguous owner scope emits mcp.scope_refused; never fallback
572
590
  // to active/top-level/sibling teams in a multi-team state.
573
591
  let Some(owner_team_id) = &self.owner_team_id else {
574
- let state = load_runtime_state(&self.workspace).unwrap_or(serde_json::json!({}));
592
+ // swallow batch 4 C5-C8 (MUST-16 fail-closed): the runtime state is the
593
+ // scope truth source — when it cannot be read, the gate REFUSES with a
594
+ // structured transient scope_unverifiable instead of silently treating
595
+ // the workspace as legacy single-team (silent privilege widening).
596
+ let state = load_runtime_state(&self.workspace)
597
+ .map_err(|error| self.scope_unverifiable(&error.to_string()))?;
575
598
  if state
576
599
  .get("teams")
577
600
  .and_then(Value::as_object)
@@ -582,7 +605,7 @@ impl TeamOrchestratorTools {
582
605
  return Ok(None);
583
606
  };
584
607
  let state = load_runtime_state(&self.workspace)
585
- .map_err(|_| self.scope_refused("owner team could not be resolved"))?;
608
+ .map_err(|error| self.scope_unverifiable(&error.to_string()))?;
586
609
  match canonicalize_owner_team_id(&state, owner_team_id.as_str()) {
587
610
  Some(team) => Ok(Some(TeamKey::new(team))),
588
611
  None => Err(self.scope_refused("owner team could not be resolved")),
@@ -591,7 +614,12 @@ impl TeamOrchestratorTools {
591
614
 
592
615
  fn canonical_owner_team_key_for_mcp(&self) -> Result<Option<TeamKey>, ToolError> {
593
616
  let Some(owner_team_id) = &self.owner_team_id else {
594
- let state = load_runtime_state(&self.workspace).unwrap_or(serde_json::json!({}));
617
+ // swallow batch 4 C5-C8 (MUST-16 fail-closed): the runtime state is the
618
+ // scope truth source — when it cannot be read, the gate REFUSES with a
619
+ // structured transient scope_unverifiable instead of silently treating
620
+ // the workspace as legacy single-team (silent privilege widening).
621
+ let state = load_runtime_state(&self.workspace)
622
+ .map_err(|error| self.scope_unverifiable(&error.to_string()))?;
595
623
  if state
596
624
  .get("teams")
597
625
  .and_then(Value::as_object)
@@ -602,13 +630,41 @@ impl TeamOrchestratorTools {
602
630
  return Ok(None);
603
631
  };
604
632
  let state = load_runtime_state(&self.workspace)
605
- .map_err(|_| self.scope_refused("owner team could not be resolved"))?;
633
+ .map_err(|error| self.scope_unverifiable(&error.to_string()))?;
606
634
  match canonicalize_owner_team_id(&state, owner_team_id.as_str()) {
607
635
  Some(team) => Ok(Some(TeamKey::new(team))),
608
636
  None => Err(self.scope_refused("owner team could not be resolved")),
609
637
  }
610
638
  }
611
639
 
640
+ /// swallow batch 4 C5/C6/N38: the structured FAIL-CLOSED refusal for "the scope
641
+ /// could not be verified" (state read failed). Transient by design — the same call
642
+ /// passes once the state is readable again; the caller's self-reported scope is
643
+ /// never trusted as a fallback (MUST-16 ceiling).
644
+ fn scope_unverifiable(&self, io_error: &str) -> ToolError {
645
+ let _ = EventLog::new(&self.workspace).write(
646
+ "mcp.scope_state_read_failed",
647
+ serde_json::json!({
648
+ "reason": "scope_unverifiable",
649
+ "requested_owner_team_id": self.owner_team_id.as_ref().map(TeamKey::as_str),
650
+ "error": io_error,
651
+ }),
652
+ );
653
+ let mut extra = serde_json::Map::new();
654
+ extra.insert("status".to_string(), Value::String("refused".to_string()));
655
+ extra.insert("kind".to_string(), Value::String("scope_unverifiable".to_string()));
656
+ extra.insert(
657
+ "next_action".to_string(),
658
+ Value::String("retry shortly or check the runtime state path".to_string()),
659
+ );
660
+ ToolError {
661
+ reason: ToolErrorReason::McpScopeRefused,
662
+ exc_type: "McpScopeUnverifiable".to_string(),
663
+ message: format!("scope_unverifiable: state read failed: {io_error}"),
664
+ extra,
665
+ }
666
+ }
667
+
612
668
  fn scope_refused(&self, message: &str) -> ToolError {
613
669
  let canonical_owner_team_id = self.canonical_owner_team_key_for_event();
614
670
  let _ = EventLog::new(&self.workspace).write(
@@ -444,7 +444,8 @@ pub(crate) fn dispatch_tool(tools: &TeamOrchestratorTools, tool: McpTool, args:
444
444
  McpTool::StuckList => tools.stuck_list(),
445
445
  McpTool::StuckCancel => tools.stuck_cancel(
446
446
  args.get("agent_id").and_then(Value::as_str).unwrap_or(""),
447
- args.get("alert_type").and_then(Value::as_str).unwrap_or("all"),
447
+ // tools.py:351 — the MCP default alert_type is "stuck", not "all".
448
+ args.get("alert_type").and_then(Value::as_str).unwrap_or("stuck"),
448
449
  ),
449
450
  }
450
451
  }
@@ -320,6 +320,54 @@ impl MessageStore {
320
320
  Ok(rows.into_iter().rev().collect())
321
321
  }
322
322
 
323
+ /// `latest_results` (`core.py:458-471`): newest non-invalid result rows, oldest
324
+ /// first (Python fetches `created_at desc limit ?` then reverses).
325
+ pub fn latest_results(
326
+ &self,
327
+ limit: usize,
328
+ owner_team_id: Option<&str>,
329
+ ) -> Result<Vec<serde_json::Value>, MessageStoreError> {
330
+ let conn = crate::db::schema::open_db(&self.path)?;
331
+ let limit = i64::try_from(limit).unwrap_or(i64::MAX);
332
+ let sql = match owner_team_id {
333
+ Some(_) => {
334
+ "select owner_team_id, result_id, task_id, agent_id, envelope, status, created_at
335
+ from results
336
+ where status != 'invalid' and owner_team_id = ?2
337
+ order by created_at desc
338
+ limit ?1"
339
+ }
340
+ None => {
341
+ "select owner_team_id, result_id, task_id, agent_id, envelope, status, created_at
342
+ from results
343
+ where status != 'invalid'
344
+ order by created_at desc
345
+ limit ?1"
346
+ }
347
+ };
348
+ let mut stmt = conn.prepare(sql)?;
349
+ let map_row = |row: &rusqlite::Row<'_>| {
350
+ Ok(serde_json::json!({
351
+ "owner_team_id": row.get::<_, Option<String>>(0)?,
352
+ "result_id": row.get::<_, String>(1)?,
353
+ "task_id": row.get::<_, Option<String>>(2)?,
354
+ "agent_id": row.get::<_, Option<String>>(3)?,
355
+ "envelope": row.get::<_, Option<String>>(4)?,
356
+ "status": row.get::<_, Option<String>>(5)?,
357
+ "created_at": row.get::<_, Option<String>>(6)?,
358
+ }))
359
+ };
360
+ let rows = match owner_team_id {
361
+ Some(team) => stmt
362
+ .query_map(params![limit, team], map_row)?
363
+ .collect::<Result<Vec<_>, _>>()?,
364
+ None => stmt
365
+ .query_map(params![limit], map_row)?
366
+ .collect::<Result<Vec<_>, _>>()?,
367
+ };
368
+ Ok(rows.into_iter().rev().collect())
369
+ }
370
+
323
371
  /// Allow direct peer messages in both directions. Golden stores `(a,b)` and
324
372
  /// `(b,a)` so either sender/recipient lookup can use a single ordered key.
325
373
  pub fn allow_peer(&self, a: &str, b: &str) -> Result<(), MessageStoreError> {
@@ -410,6 +458,38 @@ fn row_to_message_value(row: &rusqlite::Row<'_>) -> rusqlite::Result<serde_json:
410
458
  }))
411
459
  }
412
460
 
461
+ /// `result_summary_from_row`(`status/queries.py:92-106`):解析 result 行的 envelope,
462
+ /// 产出 status/watch 共用的 result 摘要;envelope 坏/非对象 → `None`。
463
+ pub fn result_summary_from_row(row: &serde_json::Value) -> Option<serde_json::Value> {
464
+ let envelope = match row.get("envelope") {
465
+ Some(serde_json::Value::String(text)) => {
466
+ serde_json::from_str::<serde_json::Value>(text).ok()?
467
+ }
468
+ Some(value @ serde_json::Value::Object(_)) => value.clone(),
469
+ _ => return None,
470
+ };
471
+ if !envelope.is_object() {
472
+ return None;
473
+ }
474
+ // Python `envelope.get(k) or row.get(k)` — falsy (null/empty) falls through to the row.
475
+ let pick = |key: &str| {
476
+ envelope
477
+ .get(key)
478
+ .filter(|v| !v.is_null() && v.as_str() != Some(""))
479
+ .or_else(|| row.get(key))
480
+ .cloned()
481
+ .unwrap_or(serde_json::Value::Null)
482
+ };
483
+ Some(serde_json::json!({
484
+ "result_id": row.get("result_id").cloned().unwrap_or(serde_json::Value::Null),
485
+ "task_id": pick("task_id"),
486
+ "agent_id": pick("agent_id"),
487
+ "status": pick("status"),
488
+ "summary": envelope.get("summary").cloned().unwrap_or(serde_json::Value::Null),
489
+ "created_at": row.get("created_at").cloned().unwrap_or(serde_json::Value::Null),
490
+ }))
491
+ }
492
+
413
493
  fn now_ts() -> String {
414
494
  chrono::Utc::now().to_rfc3339()
415
495
  }