@team-agent/installer 0.5.49 → 0.5.51

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 (90) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +6 -4
  4. package/crates/team-agent/src/cli/emit.rs +91 -39
  5. package/crates/team-agent/src/cli/mod.rs +33 -15
  6. package/crates/team-agent/src/cli/named_address.rs +82 -53
  7. package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
  8. package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
  9. package/crates/team-agent/src/cli/send/persist.rs +154 -0
  10. package/crates/team-agent/src/cli/send/presentation.rs +333 -0
  11. package/crates/team-agent/src/cli/send/resolve.rs +361 -0
  12. package/crates/team-agent/src/cli/send.rs +103 -1308
  13. package/crates/team-agent/src/cli/spec.rs +2 -2
  14. package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
  15. package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
  16. package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
  17. package/crates/team-agent/src/cli/status_port/format.rs +145 -0
  18. package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
  19. package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
  20. package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
  21. package/crates/team-agent/src/cli/status_port/store.rs +412 -0
  22. package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
  23. package/crates/team-agent/src/cli/status_port.rs +47 -1548
  24. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -1
  25. package/crates/team-agent/src/cli/tests/named_address.rs +9 -7
  26. package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -3
  27. package/crates/team-agent/src/cli/tests/status_send.rs +17 -33
  28. package/crates/team-agent/src/cli/types.rs +5 -8
  29. package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
  30. package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
  31. package/crates/team-agent/src/coordinator/tick.rs +37 -0
  32. package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
  33. package/crates/team-agent/src/db/message_store.rs +154 -44
  34. package/crates/team-agent/src/event_log.rs +73 -0
  35. package/crates/team-agent/src/leader/start.rs +28 -4
  36. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
  37. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +297 -0
  38. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
  39. package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
  40. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
  41. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
  42. package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
  43. package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
  44. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
  45. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
  46. package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
  47. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
  48. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
  49. package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
  50. package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
  51. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
  52. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
  53. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
  54. package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
  55. package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -26
  56. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
  57. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +67 -26
  58. package/crates/team-agent/src/lifecycle/restart/remove.rs +435 -72
  59. package/crates/team-agent/src/lifecycle/restart.rs +1 -1
  60. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +575 -17
  61. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +55 -2
  62. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
  63. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -1
  64. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
  65. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
  66. package/crates/team-agent/src/mcp_server/mod.rs +2 -2
  67. package/crates/team-agent/src/mcp_server/tests/send.rs +22 -15
  68. package/crates/team-agent/src/mcp_server/tests/wire.rs +6 -0
  69. package/crates/team-agent/src/mcp_server/tools.rs +26 -15
  70. package/crates/team-agent/src/mcp_server/wire.rs +2 -18
  71. package/crates/team-agent/src/messaging/activity.rs +4 -2
  72. package/crates/team-agent/src/messaging/address.rs +86 -0
  73. package/crates/team-agent/src/messaging/delivery.rs +165 -39
  74. package/crates/team-agent/src/messaging/helpers.rs +17 -13
  75. package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
  76. package/crates/team-agent/src/messaging/mod.rs +11 -2
  77. package/crates/team-agent/src/messaging/persist.rs +309 -0
  78. package/crates/team-agent/src/messaging/results.rs +16 -24
  79. package/crates/team-agent/src/messaging/scheduler.rs +4 -2
  80. package/crates/team-agent/src/messaging/selftest.rs +19 -12
  81. package/crates/team-agent/src/messaging/send.rs +133 -58
  82. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
  83. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  84. package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
  85. package/crates/team-agent/src/messaging/watchers.rs +13 -3
  86. package/crates/team-agent/src/redaction.rs +72 -2
  87. package/crates/team-agent/src/state/persist.rs +2 -1
  88. package/crates/team-agent/src/state/repository/tests.rs +47 -0
  89. package/crates/team-agent/src/state/repository.rs +59 -16
  90. package/package.json +4 -4
@@ -3,6 +3,29 @@ use crate::transport::test_support::OfflineTransport;
3
3
  use serde_json::json;
4
4
  use serial_test::serial;
5
5
 
6
+ fn launch_source() -> String {
7
+ let src = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/lifecycle");
8
+ let mut files = vec![src.join("launch.rs")];
9
+ let mut siblings = std::fs::read_dir(src.join("launch"))
10
+ .expect("read launch module directory")
11
+ .map(|entry| entry.expect("read launch module entry").path())
12
+ .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("rs"))
13
+ .collect::<Vec<_>>();
14
+ siblings.sort();
15
+ files.extend(siblings);
16
+
17
+ files
18
+ .into_iter()
19
+ .map(|path| {
20
+ format!(
21
+ "\n// @source {}\n{}",
22
+ path.strip_prefix(&src).unwrap().display(),
23
+ std::fs::read_to_string(&path).expect("read launch source")
24
+ )
25
+ })
26
+ .collect()
27
+ }
28
+
6
29
  #[allow(dead_code)]
7
30
  struct HermeticTestEnv;
8
31
 
@@ -968,7 +991,7 @@ fn e5_add_agent_resolves_team_dir_to_role_dir_when_runtime_spec_exists() {
968
991
  // never write team.spec.yaml into the user team_dir/agents_dir. Pins the spec-demote invariant.
969
992
  #[test]
970
993
  fn e5_guard_g1_writers_use_runtime_spec_path_not_user_dir() {
971
- let source = include_str!("../launch.rs");
994
+ let source = launch_source();
972
995
  let runtime_writes = source.matches("runtime_spec_path(").count();
973
996
  assert!(
974
997
  runtime_writes >= 2,
@@ -989,7 +1012,7 @@ fn e5_guard_g1_writers_use_runtime_spec_path_not_user_dir() {
989
1012
  // Pins the Bug1 fix: no `fs::copy` of a role file + no `materialize_added_role_file` reborn.
990
1013
  #[test]
991
1014
  fn e5_guard_g2_no_copy_role_into_platform_dir() {
992
- let source = include_str!("../launch.rs");
1015
+ let source = launch_source();
993
1016
  assert!(
994
1017
  !source.contains("materialize_added_role_file"),
995
1018
  "G2: materialize_added_role_file (role copy anti-pattern) must stay deleted"
@@ -3117,6 +3140,36 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
3117
3140
  assert_eq!(spawn_event["spawned_at"], json!(FIXED_SPAWNED_AT));
3118
3141
  assert_eq!(spawn_event["source"], json!("launch"));
3119
3142
  assert_eq!(spawn_event["spawn_epoch"], json!(0));
3143
+ let actual_keys = spawn_event
3144
+ .as_object()
3145
+ .expect("spawn event object")
3146
+ .keys()
3147
+ .map(String::as_str)
3148
+ .collect::<std::collections::BTreeSet<_>>();
3149
+ let expected_keys = [
3150
+ "agent_id",
3151
+ "argv",
3152
+ "env_overlay_keys",
3153
+ "env_unset",
3154
+ "event",
3155
+ "expected_session_id",
3156
+ "provider",
3157
+ "session_id_in_argv",
3158
+ "source",
3159
+ "spawn_cwd",
3160
+ "spawn_epoch",
3161
+ "spawned_at",
3162
+ "tmux_endpoint",
3163
+ "tmux_endpoint_source",
3164
+ "tmux_start_mode",
3165
+ "ts",
3166
+ ]
3167
+ .into_iter()
3168
+ .collect::<std::collections::BTreeSet<_>>();
3169
+ assert_eq!(
3170
+ actual_keys, expected_keys,
3171
+ "spawn event schema must be canonical"
3172
+ );
3120
3173
  }
3121
3174
 
3122
3175
  // Stage B2 — golden launch/core.py:171-173 writes paused workers as exactly
@@ -233,7 +233,7 @@ fn r2_lifecycle_lock_exists_precondition() {
233
233
  "remove rollback runs while remove-agent holds the lifecycle lock; it must use lock-free start_agent_at_paths"
234
234
  );
235
235
 
236
- let launch = read_src("lifecycle/launch.rs");
236
+ let launch = launch_source();
237
237
  assert_public_operation(&launch, "add-agent");
238
238
  assert_public_operation(&launch, "fork-agent");
239
239
  assert_body_is_unlocked(&launch, "fn add_agent_with_transport_at_paths");
@@ -279,6 +279,29 @@ fn read_src(path: &str) -> String {
279
279
  std::fs::read_to_string(manifest_src().join(path)).unwrap()
280
280
  }
281
281
 
282
+ fn launch_source() -> String {
283
+ let src = manifest_src().join("lifecycle");
284
+ let mut files = vec![src.join("launch.rs")];
285
+ let mut siblings = std::fs::read_dir(src.join("launch"))
286
+ .expect("read launch module directory")
287
+ .map(|entry| entry.expect("read launch module entry").path())
288
+ .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("rs"))
289
+ .collect::<Vec<_>>();
290
+ siblings.sort();
291
+ files.extend(siblings);
292
+
293
+ files
294
+ .into_iter()
295
+ .map(|path| {
296
+ format!(
297
+ "\n// @source {}\n{}",
298
+ path.strip_prefix(&src).unwrap().display(),
299
+ std::fs::read_to_string(&path).expect("read launch source")
300
+ )
301
+ })
302
+ .collect()
303
+ }
304
+
282
305
  fn source_files(root: &Path) -> Vec<PathBuf> {
283
306
  let mut out = Vec::new();
284
307
  collect_rs_files(root, &mut out);
@@ -259,7 +259,7 @@ fn run_phase_golden(spec: PhaseGolden) -> Value {
259
259
  workspace: workspace.clone(),
260
260
  team: Some(spec.team_key.to_string()),
261
261
  task: None,
262
- sender: "leader".to_string(),
262
+ sender: crate::messaging::TrustedSender::leader(),
263
263
  no_ack: true,
264
264
  no_wait: true,
265
265
  watch_result: false,
@@ -252,12 +252,28 @@ fn state_spec_workspace_from_entry(state: &Value) -> Option<PathBuf> {
252
252
 
253
253
  fn load_local_runtime_state(workspace: &Path) -> Result<Value, super::super::ToolError> {
254
254
  let path = crate::state::persist::runtime_state_path(workspace);
255
- let text = std::fs::read_to_string(&path).map_err(|e| {
256
- tool_runtime_error(format!("read local runtime state {}: {e}", path.display()))
257
- })?;
258
- serde_json::from_str(&text).map_err(|e| {
259
- tool_runtime_error(format!("parse local runtime state {}: {e}", path.display()))
260
- })
255
+ match crate::state::repository::StateRepository::new(workspace)
256
+ .load_workspace_if_exists_without_migrations()
257
+ {
258
+ Ok(Some(state)) => Ok(state),
259
+ Ok(None) => Err(tool_runtime_error(format!(
260
+ "read local runtime state {}: {}",
261
+ path.display(),
262
+ std::io::Error::from(std::io::ErrorKind::NotFound)
263
+ ))),
264
+ Err(crate::state::StateError::Json(error)) => Err(tool_runtime_error(format!(
265
+ "parse local runtime state {}: {error}",
266
+ path.display()
267
+ ))),
268
+ Err(crate::state::StateError::Io(error)) => Err(tool_runtime_error(format!(
269
+ "read local runtime state {}: {error}",
270
+ path.display()
271
+ ))),
272
+ Err(error) => Err(tool_runtime_error(format!(
273
+ "read local runtime state {}: {error}",
274
+ path.display()
275
+ ))),
276
+ }
261
277
  }
262
278
 
263
279
  fn materialize_mcp_lifecycle_spec(
@@ -416,7 +432,14 @@ fn prepare_selected_team_state(
416
432
  }
417
433
  }
418
434
  }
419
- crate::state::persist::save_runtime_state(workspace, state).map_err(|e| {
435
+ crate::state::repository::StateRepository::new(workspace)
436
+ .save(
437
+ crate::state::repository::StateWriteIntent::McpLifecycleAgentOps {
438
+ team_key: Some(team),
439
+ },
440
+ state,
441
+ )
442
+ .map_err(|e| {
420
443
  tool_runtime_error(format!(
421
444
  "save MCP lifecycle scoped state {}: {e}",
422
445
  workspace.display()
@@ -26,8 +26,10 @@ pub(crate) fn update_state(
26
26
  let mut state = selected.state;
27
27
  ensure_object(&mut state);
28
28
  append_note(&mut state, note);
29
- crate::state::projection::save_team_scoped_state_reapplying_after_conflict(
30
- &selected.run_workspace,
29
+ crate::state::repository::StateRepository::new(&selected.run_workspace).save_reapplying(
30
+ crate::state::repository::StateWriteIntent::McpUpdateStateNote {
31
+ team_key: Some(&selected.team_key),
32
+ },
31
33
  &state,
32
34
  |latest| {
33
35
  ensure_object(latest);
@@ -66,8 +68,10 @@ fn update_state_without_spec(
66
68
  ensure_object(&mut state);
67
69
  seed_legacy_team_key(&mut state, &selected.run_workspace, &selected.team_key);
68
70
  append_note(&mut state, note);
69
- crate::state::projection::save_team_scoped_state_reapplying_after_conflict(
70
- &selected.run_workspace,
71
+ crate::state::repository::StateRepository::new(&selected.run_workspace).save_reapplying(
72
+ crate::state::repository::StateWriteIntent::McpUpdateStateNote {
73
+ team_key: Some(&selected.team_key),
74
+ },
71
75
  &state,
72
76
  |latest| {
73
77
  ensure_object(latest);
@@ -85,10 +85,10 @@ use crate::event_log::EventLog;
85
85
  use crate::message_store::MessageStore;
86
86
 
87
87
  // ── REUSE: step 5 state persist / projection ────────────────────────────────
88
- use crate::state::persist::{load_runtime_state, save_runtime_state};
88
+ use crate::state::persist::load_runtime_state;
89
89
 
90
90
  // ── REUSE: step 11 messaging delegate surface ───────────────────────────────
91
- use crate::messaging::{self, DeliveryOutcome, MessageTarget, SendOptions};
91
+ use crate::messaging::{self, DeliveryOutcome, MessageTarget, SendOptions, TrustedSender};
92
92
 
93
93
  pub mod helpers;
94
94
  pub(crate) mod lifecycle_tools;
@@ -155,7 +155,6 @@ fn send_message_worker_recipient_returns_accepted_with_poll_hint() {
155
155
  None,
156
156
  None,
157
157
  None,
158
- None,
159
158
  );
160
159
  match outcome {
161
160
  Ok(SendOutcome::WorkerAccepted {
@@ -185,7 +184,6 @@ fn ordinary_send_assign_shape_has_no_recovery_marker() {
185
184
  None,
186
185
  None,
187
186
  None,
188
- None,
189
187
  )
190
188
  .expect("ordinary send ok")
191
189
  .to_value();
@@ -240,6 +238,22 @@ fn ordinary_send_assign_shape_has_no_recovery_marker() {
240
238
  );
241
239
  }
242
240
 
241
+ #[test]
242
+ fn send_message_without_framework_identity_fails_closed() {
243
+ let ws = seed_current_worker_state("missing-sender-identity");
244
+ let tools = TeamOrchestratorTools::with_identity(&ws, None, Some(TeamKey::new("current")));
245
+ let error = tools
246
+ .send_message(
247
+ &MessageTarget::Single("worker-1".to_string()),
248
+ "must not be attributed to unknown",
249
+ None,
250
+ None,
251
+ None,
252
+ )
253
+ .expect_err("missing framework identity must fail before persistence");
254
+ assert_eq!(error.reason, ToolErrorReason::McpScopeRefused);
255
+ }
256
+
243
257
  #[test]
244
258
  fn recovery_assign_shape_has_structured_marker() {
245
259
  let ws = seed_current_worker_state("recovery-marker");
@@ -304,22 +318,17 @@ fn send_message_worker_recipient_surfaces_dead_coordinator_warning() {
304
318
  None,
305
319
  None,
306
320
  None,
307
- None,
308
321
  )
309
- .expect("send returns degraded warning, not an MCP error");
322
+ .expect("send persists a durable blocker, not an MCP error");
310
323
  let v = outcome.to_value();
311
- assert_eq!(v.get("status"), Some(&json!("degraded")));
312
- assert_eq!(v.get("reason"), Some(&json!("coordinator_unavailable")));
324
+ assert_eq!(v.get("status"), Some(&json!("accepted")));
313
325
  assert!(
314
- v.get("warning")
326
+ v.get("message_id")
315
327
  .and_then(Value::as_str)
316
- .is_some_and(|warning| warning.contains("message was not queued")),
317
- "warning must explain the accepted-row avoidance; value={v}"
318
- );
319
- assert!(
320
- v.get("delivery_pending").is_none(),
321
- "dead coordinator must not return the old accepted async envelope"
328
+ .is_some_and(|id| id.starts_with("msg_")),
329
+ "worker MCP send must expose the durable row id; value={v}"
322
330
  );
331
+ assert_eq!(v.get("delivery_pending"), Some(&json!(true)));
323
332
  }
324
333
 
325
334
  #[test]
@@ -336,7 +345,6 @@ fn send_message_leader_recipient_is_direct_not_accepted() {
336
345
  None,
337
346
  None,
338
347
  None,
339
- None,
340
348
  )
341
349
  .expect("leader send ok");
342
350
  assert!(
@@ -435,7 +443,6 @@ fn send_message_cross_team_peer_surfaces_peer_not_in_scope_error() {
435
443
  None,
436
444
  None,
437
445
  None,
438
- None,
439
446
  )
440
447
  .expect_err("out-of-scope peer must be refused");
441
448
  assert_eq!(err.reason, ToolErrorReason::PeerNotInScope);
@@ -66,6 +66,12 @@
66
66
  );
67
67
  assert_eq!(send["inputSchema"]["additionalProperties"], json!(false));
68
68
  assert_eq!(send["inputSchema"]["required"], json!(["to", "content"]));
69
+ for internal in ["sender", "task_id", "requires_ack"] {
70
+ assert!(
71
+ send["inputSchema"]["properties"].get(internal).is_none(),
72
+ "{internal} is framework-owned, not caller-supplied"
73
+ );
74
+ }
69
75
  }
70
76
 
71
77
  #[test]
@@ -17,7 +17,7 @@ use crate::state::persist::{
17
17
  };
18
18
 
19
19
  // ── REUSE: step 11 messaging delegate surface ───────────────────────────────
20
- use crate::messaging::{self, MessageTarget, SendOptions};
20
+ use crate::messaging::{self, MessageTarget, SendOptions, TrustedSender};
21
21
 
22
22
  use super::helpers::{
23
23
  current_reportable_message_for, delivery_outcome_value, direct_message_attribution_for,
@@ -126,11 +126,19 @@ impl TeamOrchestratorTools {
126
126
  .map(|team| team.as_str().to_string())
127
127
  .or_else(|| assignment_team_key(&state));
128
128
  reconcile_assigned_task(&mut state, team_key.as_deref(), &task_value);
129
- save_runtime_state_reapplying_after_conflict(&self.workspace, &state, |latest| {
130
- ensure_object(latest);
131
- let latest_team_key = team_key.clone().or_else(|| assignment_team_key(latest));
132
- reconcile_assigned_task(latest, latest_team_key.as_deref(), &task_value);
133
- })
129
+ crate::state::repository::StateRepository::new(&self.workspace)
130
+ .save_reapplying(
131
+ crate::state::repository::StateWriteIntent::McpAssignTask {
132
+ team_key: team_key.as_deref(),
133
+ task_id,
134
+ },
135
+ &state,
136
+ |latest| {
137
+ ensure_object(latest);
138
+ let latest_team_key = team_key.clone().or_else(|| assignment_team_key(latest));
139
+ reconcile_assigned_task(latest, latest_team_key.as_deref(), &task_value);
140
+ },
141
+ )
134
142
  .map_err(tool_runtime_error)?;
135
143
 
136
144
  let content = assignment_message(task, message);
@@ -140,7 +148,6 @@ impl TeamOrchestratorTools {
140
148
  Some(task_id),
141
149
  None,
142
150
  None,
143
- None,
144
151
  )?;
145
152
  let mut ok = compact_tool_result(&out.to_value())?;
146
153
  if recovery {
@@ -155,7 +162,7 @@ impl TeamOrchestratorTools {
155
162
  }
156
163
 
157
164
  /// `send_message` (`tools.py:135-183`): C14/C15/C17 scope resolution.
158
- /// - sender = explicit / `TEAM_AGENT_ID` env / `"unknown"` (no candidate scan).
165
+ /// - sender = immutable `TEAM_AGENT_ID` captured when the MCP server starts.
159
166
  /// - `requires_ack` defaults from target (`_requires_ack_for_target`).
160
167
  /// - C23 cross-team pre-refusal ([`Self::refuse_cross_team_peer`]) before any
161
168
  /// runtime call.
@@ -168,7 +175,6 @@ impl TeamOrchestratorTools {
168
175
  to: &MessageTarget,
169
176
  content: &str,
170
177
  task_id: Option<&str>,
171
- sender: Option<&str>,
172
178
  requires_ack: Option<bool>,
173
179
  scope_override: Option<Scope>,
174
180
  ) -> Result<SendOutcome, ToolError> {
@@ -183,10 +189,14 @@ impl TeamOrchestratorTools {
183
189
  if let Some(err) = self.refuse_cross_team_peer(to, None) {
184
190
  return Err(err);
185
191
  }
186
- let sender = sender
187
- .and_then(non_empty_string)
188
- .or_else(|| self.agent_id.as_ref().map(AgentId::as_str))
189
- .unwrap_or("unknown");
192
+ let sender = self.agent_id.clone().ok_or_else(|| {
193
+ ToolError::new(
194
+ ToolErrorReason::McpScopeRefused,
195
+ "send_message requires framework-injected TEAM_AGENT_ID",
196
+ "IdentityError",
197
+ )
198
+ })?;
199
+ let sender = TrustedSender::from_runtime_identity(sender);
190
200
  let ack = requires_ack.unwrap_or_else(|| requires_ack_for_target(to));
191
201
  // C14/C15/C17 scope audit (#230 I-2/I-6 contract): emit mcp.scope_resolved
192
202
  // for every worker-origin send before any routing/delivery — the funnel
@@ -197,7 +207,7 @@ impl TeamOrchestratorTools {
197
207
  "mcp.scope_resolved",
198
208
  serde_json::json!({
199
209
  "tool": "send_message",
200
- "sender": sender,
210
+ "sender": sender.as_str(),
201
211
  "owner_team_id": canonical_owner_team.as_ref().map(TeamKey::as_str),
202
212
  "to": match to {
203
213
  MessageTarget::Single(t) => serde_json::Value::String(t.clone()),
@@ -211,9 +221,10 @@ impl TeamOrchestratorTools {
211
221
  )
212
222
  .map_err(tool_runtime_error)?;
213
223
  let opts = SendOptions {
224
+ origin: crate::messaging::SendOrigin::Mcp,
214
225
  task_id: task_id.map(TaskId::new),
215
226
  route_task_id: true,
216
- sender: sender.to_string(),
227
+ sender,
217
228
  requires_ack: ack,
218
229
  team: canonical_owner_team,
219
230
  ..SendOptions::default()
@@ -417,21 +417,6 @@ fn tool_properties(tool: McpTool) -> serde_json::Map<String, Value> {
417
417
  string_property("Target agent id, 'leader', or '*' for broadcast."),
418
418
  );
419
419
  insert_property(&mut properties, "content", string_property("Message body."));
420
- insert_property(
421
- &mut properties,
422
- "task_id",
423
- string_property("Optional task id to associate with the message."),
424
- );
425
- insert_property(
426
- &mut properties,
427
- "sender",
428
- string_property("Optional sender override."),
429
- );
430
- insert_property(
431
- &mut properties,
432
- "requires_ack",
433
- boolean_property("Whether the recipient should acknowledge delivery."),
434
- );
435
420
  }
436
421
  McpTool::ReportResult => {
437
422
  insert_property(
@@ -609,9 +594,8 @@ pub(crate) fn dispatch_tool(
609
594
  let outcome = tools.send_message(
610
595
  &target,
611
596
  content,
612
- args.get("task_id").and_then(Value::as_str),
613
- args.get("sender").and_then(Value::as_str),
614
- args.get("requires_ack").and_then(Value::as_bool),
597
+ None,
598
+ None,
615
599
  None,
616
600
  )?;
617
601
  match outcome {
@@ -80,8 +80,10 @@ pub fn detect_idle_fallbacks(
80
80
  let suppression_snapshots =
81
81
  idle_fallback_suppression_snapshots(&next_state, store, &team, &idle_workers)?;
82
82
  register_idle_fallback_suppression(&mut next_state, &team, &now, &suppression_snapshots);
83
- crate::state::persist::save_runtime_state_reapplying_after_conflict(
84
- workspace,
83
+ crate::state::repository::StateRepository::new(workspace).save_reapplying(
84
+ crate::state::repository::StateWriteIntent::MessagingTurnArm {
85
+ owner_team_id: Some(&team),
86
+ },
85
87
  &next_state,
86
88
  |latest| {
87
89
  register_idle_fallback_suppression(latest, &team, &now, &suppression_snapshots);
@@ -0,0 +1,86 @@
1
+ //! Pure logical-address grammar shared by public entry-point adapters.
2
+
3
+ use std::path::PathBuf;
4
+
5
+ #[derive(Debug, Clone, PartialEq, Eq)]
6
+ pub struct LogicalAddress {
7
+ pub workspace: Option<PathBuf>,
8
+ pub target: LogicalAddressTarget,
9
+ }
10
+
11
+ #[derive(Debug, Clone, PartialEq, Eq)]
12
+ pub enum LogicalAddressTarget {
13
+ Worker(String),
14
+ TeamEntity { team: String, entity: String },
15
+ SessionWindow { session: String, window: String },
16
+ }
17
+
18
+ pub fn parse_logical_address(raw_name: &str) -> Result<LogicalAddress, &'static str> {
19
+ let raw = raw_name.trim();
20
+ if raw.is_empty() {
21
+ return Err("name is empty");
22
+ }
23
+ let (workspace, name) = if let Some((workspace, rest)) = raw.split_once("::") {
24
+ if workspace.trim().is_empty() || rest.trim().is_empty() {
25
+ return Err("workspace-qualified name must include workspace and target");
26
+ }
27
+ (Some(PathBuf::from(workspace)), rest.trim())
28
+ } else {
29
+ (None, raw)
30
+ };
31
+ if name.contains("//") {
32
+ return Err("name contains an empty path segment");
33
+ }
34
+ let target = if name.contains('/') {
35
+ let parts = name.split('/').collect::<Vec<_>>();
36
+ if parts.len() != 2 || !valid_component(parts[0]) || !valid_component(parts[1]) {
37
+ return Err("expected <team>/<agent> or <team>/leader");
38
+ }
39
+ LogicalAddressTarget::TeamEntity {
40
+ team: parts[0].to_string(),
41
+ entity: parts[1].to_string(),
42
+ }
43
+ } else if name.contains(':') {
44
+ let parts = name.split(':').collect::<Vec<_>>();
45
+ if parts.len() != 2 || parts[0].trim().is_empty() || parts[1].trim().is_empty() {
46
+ return Err("expected <session>:<window>");
47
+ }
48
+ LogicalAddressTarget::SessionWindow {
49
+ session: parts[0].to_string(),
50
+ window: parts[1].to_string(),
51
+ }
52
+ } else {
53
+ if !valid_component(name) {
54
+ return Err("expected a non-empty agent id");
55
+ }
56
+ LogicalAddressTarget::Worker(name.to_string())
57
+ };
58
+ Ok(LogicalAddress { workspace, target })
59
+ }
60
+
61
+ fn valid_component(raw: &str) -> bool {
62
+ !raw.trim().is_empty()
63
+ && !raw.contains(char::is_whitespace)
64
+ && !raw.contains('/')
65
+ && !raw.contains(':')
66
+ }
67
+
68
+ #[cfg(test)]
69
+ mod tests {
70
+ use super::*;
71
+
72
+ #[test]
73
+ fn parses_canonical_send_grammar_without_runtime_state() {
74
+ assert_eq!(
75
+ parse_logical_address("/tmp/ws::team/w1").unwrap(),
76
+ LogicalAddress {
77
+ workspace: Some(PathBuf::from("/tmp/ws")),
78
+ target: LogicalAddressTarget::TeamEntity {
79
+ team: "team".into(),
80
+ entity: "w1".into(),
81
+ },
82
+ }
83
+ );
84
+ assert!(parse_logical_address("team//w1").is_err());
85
+ }
86
+ }