@team-agent/installer 0.5.113 → 0.5.115

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 (52) hide show
  1. package/Cargo.lock +252 -84
  2. package/Cargo.toml +3 -1
  3. package/crates/team-agent/Cargo.toml +3 -0
  4. package/crates/team-agent/src/cli/adapters.rs +28 -15
  5. package/crates/team-agent/src/cli/diagnose.rs +36 -13
  6. package/crates/team-agent/src/cli/emit.rs +57 -22
  7. package/crates/team-agent/src/cli/leader.rs +16 -0
  8. package/crates/team-agent/src/cli/leader_prompt.rs +261 -0
  9. package/crates/team-agent/src/cli/mod.rs +157 -8
  10. package/crates/team-agent/src/cli/models.rs +119 -18
  11. package/crates/team-agent/src/cli/spec.rs +7 -1
  12. package/crates/team-agent/src/cli/status_port/brief.rs +268 -101
  13. package/crates/team-agent/src/cli/tests/base.rs +88 -0
  14. package/crates/team-agent/src/cli/tests/status_send.rs +2 -0
  15. package/crates/team-agent/src/compiler.rs +66 -18
  16. package/crates/team-agent/src/contract_runtime/backend.rs +959 -0
  17. package/crates/team-agent/src/contract_runtime/commands.rs +245 -0
  18. package/crates/team-agent/src/contract_runtime/config.rs +340 -0
  19. package/crates/team-agent/src/contract_runtime/config_tests.rs +56 -0
  20. package/crates/team-agent/src/contract_runtime/discovery.rs +383 -0
  21. package/crates/team-agent/src/contract_runtime/forward.rs +229 -0
  22. package/crates/team-agent/src/contract_runtime/framework.rs +294 -0
  23. package/crates/team-agent/src/contract_runtime/mcp.rs +396 -0
  24. package/crates/team-agent/src/contract_runtime/mod.rs +16 -0
  25. package/crates/team-agent/src/contract_runtime/prompt.rs +216 -0
  26. package/crates/team-agent/src/contract_runtime/pump.rs +269 -0
  27. package/crates/team-agent/src/contract_runtime/registry.rs +197 -0
  28. package/crates/team-agent/src/contract_runtime/workspace.rs +160 -0
  29. package/crates/team-agent/src/coordinator/tick.rs +26 -8
  30. package/crates/team-agent/src/leader/mod.rs +1 -0
  31. package/crates/team-agent/src/leader/prompt.rs +906 -0
  32. package/crates/team-agent/src/leader/start.rs +353 -21
  33. package/crates/team-agent/src/leader/tests/mod.rs +2 -0
  34. package/crates/team-agent/src/leader/tests/prompt_contract.rs +268 -0
  35. package/crates/team-agent/src/leader/types.rs +6 -0
  36. package/crates/team-agent/src/lib.rs +4 -0
  37. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +571 -389
  38. package/crates/team-agent/src/lifecycle/launch/spawn.rs +38 -9
  39. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +15 -0
  40. package/crates/team-agent/src/lifecycle/launch.rs +45 -28
  41. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +23 -1
  42. package/crates/team-agent/src/lifecycle/restart/selection.rs +34 -0
  43. package/crates/team-agent/src/lifecycle/role_config.rs +17 -1
  44. package/crates/team-agent/src/lifecycle/types.rs +4 -1
  45. package/crates/team-agent/src/lifecycle/worker_command_context.rs +37 -27
  46. package/crates/team-agent/src/main.rs +6 -0
  47. package/crates/team-agent/src/messaging/delivery.rs +50 -1
  48. package/crates/team-agent/src/messaging/send.rs +10 -0
  49. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +131 -0
  50. package/crates/team-agent/src/model/spec.rs +24 -12
  51. package/crates/team-agent/src/state/persist.rs +73 -0
  52. package/package.json +4 -4
@@ -0,0 +1,294 @@
1
+ //! Shared-framework seams. A contract seat is never projected as a legacy pane:
2
+ //! its private socket's `%0` must not be interpreted on the workspace tmux server.
3
+ use super::{
4
+ backend::{self, Backend, BackendError},
5
+ config,
6
+ };
7
+ use serde_json::{json, Value};
8
+ use std::path::Path;
9
+ use team_agent_contract::contract::types::SeatId;
10
+ use team_agent_contract::host::process::{sample_process, ProcessState};
11
+ use team_agent_contract::orchestration::{
12
+ operator::OperatorSend,
13
+ store::{Outcome, SeatStatus},
14
+ };
15
+
16
+ pub fn is_contract(agent: &Value) -> bool {
17
+ agent
18
+ .get("provider")
19
+ .and_then(Value::as_str)
20
+ .is_some_and(|provider| super::registry::descriptor(provider).is_some())
21
+ }
22
+ /// Published paused roles without an instance never crossed native startup.
23
+ /// A bound instance is never skipped, even if a stale row says paused.
24
+ pub(crate) fn is_paused_unstarted(agent: &Value) -> bool {
25
+ is_contract(agent)
26
+ && agent.get("status").and_then(Value::as_str) == Some("paused")
27
+ && agent.get("contract_instance").is_none_or(Value::is_null)
28
+ }
29
+
30
+ pub fn has_contract(state: &Value) -> bool {
31
+ state
32
+ .get("agents")
33
+ .and_then(Value::as_object)
34
+ .is_some_and(|agents| agents.values().any(is_contract))
35
+ }
36
+ pub fn only_contract(state: &Value) -> bool {
37
+ state
38
+ .get("agents")
39
+ .and_then(Value::as_object)
40
+ .is_some_and(|agents| !agents.is_empty() && agents.values().all(is_contract))
41
+ }
42
+ /// Unsupported lifecycle operations must stop before the legacy provider enum
43
+ /// can turn an unknown native seat into its historical Codex fallback.
44
+ pub fn require_legacy_lifecycle(
45
+ workspace: &Path,
46
+ team: Option<&str>,
47
+ agent: Option<&str>,
48
+ operation: &str,
49
+ ) -> Result<(), crate::cli::CliError> {
50
+ let Ok(selected) = crate::state::selector::resolve_active_team_readonly(
51
+ workspace,
52
+ team,
53
+ crate::state::selector::SelectorMode::RuntimeOnly,
54
+ ) else {
55
+ return Ok(());
56
+ };
57
+ let selected_native = match agent {
58
+ Some(id) => selected
59
+ .state
60
+ .get("agents")
61
+ .and_then(|agents| agents.get(id))
62
+ .is_some_and(is_contract),
63
+ None => has_contract(&selected.state),
64
+ };
65
+ let configured_native = selected
66
+ .state
67
+ .get("team_dir")
68
+ .and_then(Value::as_str)
69
+ .and_then(|directory| {
70
+ team_config(
71
+ &selected.run_workspace,
72
+ Path::new(directory),
73
+ &selected.team_key,
74
+ )
75
+ .ok()
76
+ .flatten()
77
+ })
78
+ .is_some_and(|config| match agent {
79
+ Some(id) => config.roles.iter().any(|role| role.id.as_str() == id),
80
+ None => !config.roles.is_empty(),
81
+ });
82
+ if selected_native || configured_native {
83
+ return Err(crate::cli::CliError::Runtime(format!("contract_capability_unverified: {operation} is not admitted for contract-native seats; no legacy fallback or fresh replacement was performed")));
84
+ }
85
+ Ok(())
86
+ }
87
+ pub fn require_legacy_role(
88
+ path: Option<&Path>,
89
+ provider: Option<&str>,
90
+ ) -> Result<(), crate::cli::CliError> {
91
+ let declared = path
92
+ .and_then(|path| std::fs::read_to_string(path).ok())
93
+ .and_then(|raw| {
94
+ crate::compiler::split_front_matter(&raw)
95
+ .0
96
+ .get("provider")
97
+ .and_then(crate::model::yaml::Value::as_str)
98
+ .map(str::to_owned)
99
+ });
100
+ if provider
101
+ .or(declared.as_deref())
102
+ .is_some_and(super::registry::recognizes)
103
+ {
104
+ return Err(crate::cli::CliError::Runtime("contract_capability_unverified: mutable contract-native role lifecycle is not admitted; use initial quick-start; no role file was changed".into()));
105
+ }
106
+ Ok(())
107
+ }
108
+ pub fn team_config(
109
+ workspace: &Path,
110
+ directory: &Path,
111
+ team: &str,
112
+ ) -> Result<Option<config::TeamConfig>, config::ConfigError> {
113
+ config::read_team(&crate::cli::QuickStartArgs {
114
+ workspace: workspace.into(),
115
+ agents_dir: directory.into(),
116
+ name: None,
117
+ team_id: Some(team.into()),
118
+ yes: false,
119
+ json: false,
120
+ detail: false,
121
+ backend: Some("tmux".into()),
122
+ })
123
+ }
124
+ pub fn start_role(
125
+ workspace: &Path,
126
+ directory: &Path,
127
+ team: &str,
128
+ id: &str,
129
+ ) -> Result<(), crate::lifecycle::LifecycleError> {
130
+ let error = |error: String| crate::lifecycle::LifecycleError::Provider(error);
131
+ let config = team_config(workspace, directory, team)
132
+ .map_err(|e| error(e.to_string()))?
133
+ .ok_or_else(|| error("contract role no longer configured".into()))?;
134
+ let role = config
135
+ .roles
136
+ .iter()
137
+ .find(|role| role.id.as_str() == id)
138
+ .ok_or_else(|| error("contract role not found".into()))?;
139
+ let (_, record) =
140
+ backend::start(workspace, team, role, &config.members).map_err(|e| error(e.to_string()))?;
141
+ if record.outcome != Outcome::Committed {
142
+ return Err(error(format!(
143
+ "native startup {:?}: {}",
144
+ record.outcome,
145
+ record.failure.unwrap_or_default()
146
+ )));
147
+ }
148
+ Ok(())
149
+ }
150
+
151
+ pub fn project_seat(workspace: &Path, team: &str, id: &str) -> Result<Value, BackendError> {
152
+ let mut backend = Backend::open_readonly(workspace, team)?;
153
+ let seat = backend
154
+ .store
155
+ .seat(&SeatId::new(id)?)?
156
+ .ok_or(BackendError::Metadata)?;
157
+ let role = backend::read_role(&backend.binding, &seat.identity)?;
158
+ if role.provider != seat.provider {
159
+ return Err(BackendError::Metadata);
160
+ }
161
+ let alive = seat
162
+ .physical
163
+ .as_ref()
164
+ .is_some_and(|target| sample_process(&target.process) == ProcessState::Alive);
165
+ // Present only individually proven tools. A successful report_result
166
+ // never turns send_message/get_team_status into invented discoveries.
167
+ let mut observed = Vec::new();
168
+ let mut binding_source = None;
169
+ if super::registry::provider(seat.provider.as_str())?.uses_tool_roundtrips() {
170
+ let protocol = super::pump::protocol(&mut backend, &seat, &team_agent_contract::host::clock::RealClock::new())?;
171
+ if let team_agent_contract::contract::probe::ProbeOutcome::Observed(binding) = protocol.binding.outcome {
172
+ binding_source = Some(format!("{:?}", binding.source));
173
+ observed = binding.tools;
174
+ }
175
+ }
176
+ let tools: serde_json::Map<String, Value> = [
177
+ (team_agent_contract::contract::types::LogicalTool::SendMessage, "send_message"),
178
+ (team_agent_contract::contract::types::LogicalTool::ReportResult, "report_result"),
179
+ (team_agent_contract::contract::types::LogicalTool::GetTeamStatus, "get_team_status"),
180
+ ].into_iter().map(|(tool, name)| (name.into(), json!(if observed.contains(&tool) { "observed" } else { "unknown" }))).collect();
181
+ let binding_status = if observed.len() == 3 { "observed" } else if observed.is_empty() { "unverified" } else { "partial" };
182
+ let status = if seat.status == SeatStatus::Stopped {
183
+ "stopped"
184
+ } else if seat.status != SeatStatus::Unknown && alive {
185
+ "running"
186
+ } else {
187
+ "unknown"
188
+ };
189
+ Ok(
190
+ json!({"agent_id":id,"provider":seat.provider,"status":status,"model":role.model,"effort":role.effort,
191
+ "contract_prompt_sha256":team_agent_contract::host::digest_hex(role.prompt.sha256),
192
+ "runtime_family":"contract","contract_scope":seat.identity.scope,"contract_instance":seat.identity.instance,
193
+ "generation":seat.identity.generation,"owner_team_id":team,"team_key":team,
194
+ "session_id":seat.session.map(|session| session.native_session),
195
+ "native_endpoint":seat.endpoint,"native_pane":seat.pane,
196
+ "pane_id":null,"window":null,"session_name":null,
197
+ "mcp_binding":binding_status,"mcp_binding_source":binding_source,"mcp_tools":tools,
198
+ "bootstrap_used":seat.bootstrap_used}),
199
+ )
200
+ }
201
+
202
+ pub fn enqueue(
203
+ workspace: &Path,
204
+ state: &Value,
205
+ recipient: &str,
206
+ content: &str,
207
+ options: &crate::messaging::SendOptions,
208
+ ) -> Result<crate::messaging::DeliveryOutcome, crate::messaging::MessagingError> {
209
+ use crate::messaging::{DeliveryOutcome, DeliveryStatus, MessagingError};
210
+ let error = |error: String| MessagingError::Validation(error);
211
+ let team = crate::state::projection::team_state_key(state);
212
+ let mut backend = Backend::open(workspace, &team).map_err(|e| error(e.to_string()))?;
213
+ let request = OperatorSend {
214
+ recipient: SeatId::new(recipient).map_err(|e| error(e.to_string()))?,
215
+ content: content.into(),
216
+ task: options.task_id.as_ref().map(|task| task.as_str().into()),
217
+ message: options
218
+ .message_id
219
+ .clone()
220
+ .map(team_agent_contract::contract::types::MessageId::new)
221
+ .transpose()
222
+ .map_err(|e| error(e.to_string()))?,
223
+ mailbox: options.presentation.sink
224
+ == crate::messaging::presentation::PresentationSink::Casefile,
225
+ };
226
+ let sender = SeatId::new(options.sender.as_str()).map_err(|e| error(e.to_string()))?;
227
+ let receipt = backend
228
+ .store
229
+ .send_from_framework(&request, &sender)
230
+ .map_err(|e| error(e.to_string()))?;
231
+ // Queued is durable admission only. The coordinator consumes this outbox;
232
+ // CLI send never performs its own paste or repeats an uncertain attempt.
233
+ Ok(DeliveryOutcome {
234
+ ok: true,
235
+ status: if receipt.mailbox {
236
+ DeliveryStatus::StoredOnly
237
+ } else {
238
+ DeliveryStatus::Queued
239
+ },
240
+ message_status: crate::messaging::helpers::MessageStatusShadow(
241
+ if receipt.mailbox {
242
+ "stored_only"
243
+ } else {
244
+ "accepted"
245
+ }
246
+ .into(),
247
+ ),
248
+ message_id: Some(receipt.message.as_str().into()),
249
+ verification: Some(
250
+ json!({"runtime":"contract","task_id":receipt.task,
251
+ "target":receipt.target,"native_submission":"not_observed"})
252
+ .to_string(),
253
+ ),
254
+ stage: None,
255
+ reason: None,
256
+ channel: None,
257
+ ack_forced_off: false,
258
+ turn_verification: None,
259
+ })
260
+ }
261
+
262
+ pub fn stop_selected(workspace: &Path, state: &Value) -> Result<Vec<Value>, BackendError> {
263
+ let team = crate::state::projection::team_state_key(state);
264
+ let mut receipts = Vec::new();
265
+ if let Some(agents) = state.get("agents").and_then(Value::as_object) {
266
+ for (id, agent) in agents.iter().filter(|(_, agent)| is_contract(agent)) {
267
+ if is_paused_unstarted(agent) {
268
+ receipts.push(json!({"agent_id":id,"status":"paused","stopped":false}));
269
+ continue;
270
+ }
271
+ let backend = Backend::open(workspace, &team)?;
272
+ let seat = backend
273
+ .store
274
+ .seat(&SeatId::new(id)?)?
275
+ .ok_or(BackendError::Metadata)?;
276
+ if seat.status == SeatStatus::Stopped {
277
+ receipts.push(json!({"agent_id":id,"status":"already_stopped"}));
278
+ continue;
279
+ }
280
+ let _ = agent;
281
+ let receipt = backend::stop(workspace, &team, &SeatId::new(id)?)?;
282
+ if receipt.outcome != Outcome::Committed {
283
+ return Err(BackendError::Shutdown {
284
+ operation: receipt.id,
285
+ phase: receipt.phase,
286
+ outcome: receipt.outcome,
287
+ failure: receipt.failure,
288
+ });
289
+ }
290
+ receipts.push(json!({"agent_id":id,"status":"stopped","operation":receipt.id}));
291
+ }
292
+ }
293
+ Ok(receipts)
294
+ }
@@ -0,0 +1,396 @@
1
+ //! Owned native stdio entry. Invocation arguments identify an expected record;
2
+ //! only actual executable/birth/parent and current instance checks authorize it.
3
+ use std::fmt::Display;
4
+ use std::io::Write;
5
+ use std::path::Path;
6
+ use std::time::{Duration, Instant};
7
+
8
+ use serde_json::{json, Value};
9
+ use team_agent_contract::contract::types::*;
10
+ use team_agent_contract::host::process::{
11
+ capture_process, verify_native_ancestry, MAX_NATIVE_PARENT_HOPS,
12
+ };
13
+ use team_agent_contract::orchestration::{
14
+ mcp::*,
15
+ protocol::ConnectionRecord,
16
+ store::{ContractStore, SeatRecord, SeatStatus},
17
+ Error,
18
+ };
19
+
20
+ use super::backend::{Backend, BackendError};
21
+
22
+ // Diagnostics are not protocol facts or admission. Never write them to stdout,
23
+ // include request/argv/env bytes, or let a failed log write change the result.
24
+ struct Diagnostics {
25
+ log: Option<crate::event_log::EventLog>,
26
+ fields: Value,
27
+ }
28
+ impl Diagnostics {
29
+ fn new() -> Self {
30
+ Self {
31
+ log: None,
32
+ fields: json!({
33
+ "pid": std::process::id(),
34
+ "entry_ppid": crate::platform::process::current_parent_pid(),
35
+ }),
36
+ }
37
+ }
38
+
39
+ fn bind_log(&mut self, workspace: &Path) {
40
+ let directory = crate::model::paths::logs_dir(workspace);
41
+ let path = directory.join("events.jsonl");
42
+ // Entry diagnostics must not create an unverified workspace, follow a
43
+ // redirected log path, or depend on successfully opening contract.db.
44
+ let regular_or_absent = std::fs::symlink_metadata(&path).map_or_else(
45
+ |error| error.kind() == std::io::ErrorKind::NotFound,
46
+ |metadata| metadata.is_file() && !metadata.file_type().is_symlink(),
47
+ );
48
+ if workspace.is_absolute()
49
+ && workspace.canonicalize().is_ok_and(|path| path == workspace)
50
+ && directory.canonicalize().is_ok_and(|path| path == directory)
51
+ && regular_or_absent
52
+ {
53
+ self.log = Some(crate::event_log::EventLog::new(workspace));
54
+ }
55
+ }
56
+
57
+ fn record(&self, stage: &str, outcome: &str, error: Option<&dyn Display>) -> Value {
58
+ let mut value = self.fields.clone();
59
+ value["event"] = json!("contract.mcp");
60
+ value["ts"] = json!(chrono::Utc::now().to_rfc3339());
61
+ value["stage"] = json!(stage);
62
+ value["outcome"] = json!(outcome);
63
+ if let Some(error) = error {
64
+ let text = crate::redaction::redact_external_text(&error.to_string());
65
+ value["error"] = json!(text.chars().take(256).collect::<String>());
66
+ value["error_truncated"] = json!(text.chars().count() > 256);
67
+ }
68
+ crate::redaction::redact_external_value(&value)
69
+ }
70
+
71
+ fn emit(&self, stage: &str, outcome: &str, error: Option<&dyn Display>) {
72
+ let value = self.record(stage, outcome, error);
73
+ write_diagnostic(self.log.as_ref(), &value, &mut std::io::stderr().lock());
74
+ }
75
+
76
+ fn step<T, E: Display>(
77
+ &self,
78
+ stage: &str,
79
+ action: impl FnOnce() -> Result<T, E>,
80
+ ) -> Result<T, E> {
81
+ self.emit(stage, "started", None);
82
+ let result = action();
83
+ self.emit(
84
+ stage,
85
+ if result.is_ok() { "ok" } else { "error" },
86
+ result.as_ref().err().map(|error| error as &dyn Display),
87
+ );
88
+ result
89
+ }
90
+ }
91
+
92
+ fn write_diagnostic(
93
+ log: Option<&crate::event_log::EventLog>,
94
+ value: &Value,
95
+ stderr: &mut impl Write,
96
+ ) {
97
+ if log.is_some_and(|log| log.write("contract.mcp", value.clone()).is_ok()) {
98
+ return;
99
+ }
100
+ if let Ok(mut bytes) = serde_json::to_vec(value) {
101
+ // Valid bounded JSON even if future diagnostic fields grow unexpectedly.
102
+ if bytes.len() > 8191 {
103
+ bytes = br#"{"event":"contract.mcp","stage":"diagnostic_size_limit"}"#.to_vec();
104
+ }
105
+ bytes.push(b'\n');
106
+ let _ = stderr.write_all(&bytes);
107
+ }
108
+ }
109
+
110
+ fn arguments(
111
+ args: &[String],
112
+ ) -> Result<(std::path::PathBuf, String, SeatId, InstanceId, Generation), BackendError> {
113
+ let mut values = std::collections::BTreeMap::new();
114
+ if !args.len().is_multiple_of(2) {
115
+ return Err(BackendError::Metadata);
116
+ }
117
+ for pair in args.chunks_exact(2) {
118
+ if !matches!(
119
+ pair[0].as_str(),
120
+ "--workspace" | "--team" | "--seat" | "--instance" | "--generation"
121
+ ) || values.insert(pair[0].as_str(), pair[1].as_str()).is_some()
122
+ {
123
+ return Err(BackendError::Metadata);
124
+ }
125
+ }
126
+ if values.len() != 5 {
127
+ return Err(BackendError::Metadata);
128
+ }
129
+ let get = |key| values.get(key).copied().ok_or(BackendError::Metadata);
130
+ Ok((
131
+ get("--workspace")?.into(),
132
+ get("--team")?.into(),
133
+ SeatId::new(get("--seat")?)?,
134
+ InstanceId::new(get("--instance")?)?,
135
+ Generation(
136
+ get("--generation")?
137
+ .parse()
138
+ .map_err(|_| BackendError::Metadata)?,
139
+ ),
140
+ ))
141
+ }
142
+ fn wait_target(store: &ContractStore, identity: &InstanceIdentity) -> Result<SeatRecord, Error> {
143
+ let deadline = Instant::now() + Duration::from_secs(30);
144
+ loop {
145
+ let seat = store.assert_current(identity)?;
146
+ if matches!(seat.status, SeatStatus::Stopped | SeatStatus::Unknown) {
147
+ return Err(Error::Fence);
148
+ }
149
+ if seat.physical.is_some() {
150
+ return Ok(seat);
151
+ }
152
+ if Instant::now() >= deadline {
153
+ return Err(Error::Host("native target publication timed out"));
154
+ }
155
+ std::thread::sleep(Duration::from_millis(25));
156
+ }
157
+ }
158
+
159
+ pub fn run(args: &[String]) -> Result<(), BackendError> {
160
+ let mut diagnostics = Diagnostics::new();
161
+ // This is the first action in the main.rs contract-mcp dispatch target.
162
+ // Arguments are not trusted as a log destination yet: stderr only.
163
+ diagnostics.emit("entry", "entered", None);
164
+ let result = run_observed(args, &mut diagnostics);
165
+ diagnostics.emit(
166
+ "exit",
167
+ if result.is_ok() { "ok" } else { "error" },
168
+ result.as_ref().err().map(|error| error as &dyn Display),
169
+ );
170
+ result
171
+ }
172
+
173
+ fn run_observed(args: &[String], diagnostics: &mut Diagnostics) -> Result<(), BackendError> {
174
+ let (workspace, team, seat, instance, generation) =
175
+ diagnostics.step("arguments", || arguments(args))?;
176
+ diagnostics.bind_log(&workspace);
177
+ diagnostics.fields["seat"] = json!(seat.as_str().chars().take(96).collect::<String>());
178
+ diagnostics.fields["instance"] = json!(instance.as_str().chars().take(96).collect::<String>());
179
+ diagnostics.fields["generation"] = json!(generation.0);
180
+ diagnostics.emit("entry", "arguments_validated", None);
181
+ let mut backend = diagnostics.step("backend_open", || Backend::open(&workspace, &team))?;
182
+ let identity = InstanceIdentity {
183
+ scope: backend.binding.scope.clone(),
184
+ seat,
185
+ instance,
186
+ generation,
187
+ };
188
+ diagnostics.fields["scope"] = json!(identity.scope.as_str());
189
+ let seat = diagnostics.step("target_wait", || wait_target(&backend.store, &identity))?;
190
+ let target = seat
191
+ .physical
192
+ .as_ref()
193
+ .ok_or(Error::Invalid("native target missing"))?;
194
+ diagnostics.fields["expected_native_pid"] = json!(target.process.identity.pid);
195
+ diagnostics.emit("target_published", "observed", None);
196
+ let process = diagnostics.step("process_capture", || {
197
+ capture_process(
198
+ std::process::id(),
199
+ &backend.binding.candidate,
200
+ backend.binding.candidate_sha256,
201
+ Duration::from_secs(10),
202
+ )
203
+ })?;
204
+ diagnostics.fields["captured_ppid"] = json!(process.parent);
205
+ diagnostics.fields["direct_parent_matches"] =
206
+ json!(process.parent == target.process.identity.pid);
207
+ diagnostics.fields["max_parent_hops"] = json!(MAX_NATIVE_PARENT_HOPS);
208
+ diagnostics.emit("parent_fence", "started", None);
209
+ let ancestry = verify_native_ancestry(&process, &target.process);
210
+ diagnostics.fields["parent_matches"] = json!(ancestry.is_ok());
211
+ if let Ok(chain) = &ancestry {
212
+ diagnostics.fields["ancestor_chain"] = json!(chain);
213
+ }
214
+ diagnostics.emit(
215
+ "parent_fence",
216
+ if ancestry.is_ok() { "ok" } else { "error" },
217
+ ancestry.as_ref().err().map(|error| error as &dyn Display),
218
+ );
219
+ ancestry?;
220
+ let connection = diagnostics
221
+ .step("connection_identity", || {
222
+ backend.store.propose_identity(&identity.seat)
223
+ })?
224
+ .instance;
225
+ let record = ConnectionRecord {
226
+ identity: identity.clone(),
227
+ connection: connection.clone(),
228
+ server_key: seat.server_key.clone(),
229
+ binding_key: seat.binding_key.clone(),
230
+ process,
231
+ native_process: target.process.clone(),
232
+ closed: false,
233
+ };
234
+ diagnostics.step("register_connection", || {
235
+ backend.store.register_connection(&record)
236
+ })?;
237
+ // Registration is not an initialize/tools-list/client-consumption fact.
238
+ // Those continue to be recorded only at the existing protocol boundaries.
239
+ let result = diagnostics.step("serve", || {
240
+ serve_with_diagnostics(
241
+ &mut backend.store,
242
+ |store, request| {
243
+ if verify_native_ancestry(&record.process, &record.native_process).is_err() {
244
+ return Err(Error::Fence);
245
+ }
246
+ let current = store.assert_current(&identity)?;
247
+ if current.binding_key != record.binding_key
248
+ || matches!(current.status, SeatStatus::Stopped | SeatStatus::Unknown)
249
+ || current
250
+ .physical
251
+ .as_ref()
252
+ .is_none_or(|target| target.process != record.native_process)
253
+ {
254
+ return Err(Error::Fence);
255
+ }
256
+ let business =
257
+ request.get("method").and_then(serde_json::Value::as_str) == Some("tools/call");
258
+ let mut task = String::new();
259
+ if business {
260
+ // A native callback can race the physical receipt's DB commit. Wait
261
+ // for that exact generation's submitted turn, never a queued row.
262
+ let deadline = Instant::now() + Duration::from_secs(5);
263
+ loop {
264
+ match store.submitted_task(&identity) {
265
+ Ok(Some(bound)) => {
266
+ task = bound;
267
+ break;
268
+ }
269
+ Ok(None) | Err(Error::Conflict) if Instant::now() < deadline => {
270
+ std::thread::sleep(Duration::from_millis(20))
271
+ }
272
+ Ok(None) => return Err(Error::Invalid("no submitted native turn")),
273
+ Err(error) => return Err(error),
274
+ }
275
+ }
276
+ }
277
+ if verify_native_ancestry(&record.process, &record.native_process).is_err() {
278
+ return Err(Error::Fence);
279
+ }
280
+ Ok(CallContext {
281
+ identity: identity.clone(),
282
+ binding_key: record.binding_key.clone(),
283
+ connection_id: connection.clone(),
284
+ task_id: task,
285
+ })
286
+ },
287
+ std::io::stdin().lock(),
288
+ std::io::stdout().lock(),
289
+ |stage, error| {
290
+ diagnostics.emit(
291
+ match stage {
292
+ RequestRejectionStage::Context => "request_context",
293
+ RequestRejectionStage::Handler => "request_handler",
294
+ },
295
+ "rejected",
296
+ Some(error),
297
+ );
298
+ },
299
+ )
300
+ });
301
+ let closed = diagnostics.step("close_connection", || {
302
+ backend.store.close_connection(&identity, &connection)
303
+ });
304
+ result?;
305
+ closed?;
306
+ Ok(())
307
+ }
308
+
309
+ #[cfg(test)]
310
+ mod tests {
311
+ use super::*;
312
+
313
+ #[test]
314
+ fn diagnostics_preserve_errors_and_distinguish_captured_and_expected_parent() {
315
+ let mut diagnostics = Diagnostics::new();
316
+ diagnostics.fields["captured_ppid"] = json!(202);
317
+ diagnostics.fields["expected_native_pid"] = json!(303);
318
+ diagnostics.fields["parent_matches"] = json!(false);
319
+ let result = diagnostics.step("parent_fence", || Err::<(), _>(Error::Fence));
320
+ assert_eq!(result, Err(Error::Fence));
321
+ let event = diagnostics.record("parent_fence", "error", Some(&Error::Fence));
322
+ assert_eq!(event["captured_ppid"], 202);
323
+ assert_eq!(event["expected_native_pid"], 303);
324
+ assert_eq!(event["parent_matches"], false);
325
+ assert_eq!(event["error"], Error::Fence.to_string());
326
+ assert!(event["entry_ppid"].is_number());
327
+ }
328
+
329
+ #[test]
330
+ fn rejected_requests_keep_the_actual_error_category_and_stage() {
331
+ let diagnostics = Diagnostics::new();
332
+ for (stage, error) in [
333
+ ("request_context", Error::Conflict),
334
+ ("request_context", Error::Fence),
335
+ ("request_context", Error::Database),
336
+ ("request_handler", Error::Invalid("argument type")),
337
+ ] {
338
+ let event = diagnostics.record(stage, "rejected", Some(&error));
339
+ assert_eq!(event["stage"], stage);
340
+ assert_eq!(event["outcome"], "rejected");
341
+ assert_eq!(event["error"], error.to_string());
342
+ assert!(event.get("request").is_none());
343
+ assert!(event.get("arguments").is_none());
344
+ let mut stderr = Vec::new();
345
+ write_diagnostic(None, &event, &mut stderr);
346
+ assert!(stderr.len() <= 8192);
347
+ assert_eq!(serde_json::from_slice::<Value>(&stderr).unwrap(), event);
348
+ }
349
+ }
350
+
351
+ #[test]
352
+ fn diagnostics_redact_and_bound_error_without_argument_or_environment_fields() {
353
+ let diagnostics = Diagnostics::new();
354
+ let error = format!("PASSWORD=do-not-log {}", "x".repeat(20_000));
355
+ let event = diagnostics.record("arguments", "error", Some(&error));
356
+ assert_eq!(event["error_truncated"], true);
357
+ assert!(!event.to_string().contains("do-not-log"));
358
+ for field in ["argv", "args", "env", "prompt", "request", "content"] {
359
+ assert!(event.get(field).is_none());
360
+ }
361
+ let mut stderr = Vec::new();
362
+ write_diagnostic(None, &event, &mut stderr);
363
+ assert!(stderr.len() <= 8192);
364
+ assert_eq!(serde_json::from_slice::<Value>(&stderr).unwrap(), event);
365
+ }
366
+
367
+ #[test]
368
+ fn diagnostic_sink_failures_do_not_change_execution_results() {
369
+ let log = crate::event_log::EventLog::at("/dev/null/contract-mcp-events".into());
370
+ let diagnostics = Diagnostics::new();
371
+ let event = diagnostics.record("backend_open", "error", Some(&Error::Fence));
372
+ let mut stderr = Vec::new();
373
+ write_diagnostic(Some(&log), &event, &mut stderr);
374
+ assert_eq!(serde_json::from_slice::<Value>(&stderr).unwrap(), event);
375
+ struct Broken;
376
+ impl Write for Broken {
377
+ fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
378
+ Err(std::io::ErrorKind::BrokenPipe.into())
379
+ }
380
+ fn flush(&mut self) -> std::io::Result<()> {
381
+ Err(std::io::ErrorKind::BrokenPipe.into())
382
+ }
383
+ }
384
+ write_diagnostic(Some(&log), &event, &mut Broken);
385
+ assert_eq!(diagnostics.step("fixture", || Ok::<_, Error>(17)), Ok(17));
386
+ stderr.clear();
387
+ write_diagnostic(
388
+ None,
389
+ &json!({"unexpected": "x".repeat(20_000)}),
390
+ &mut stderr,
391
+ );
392
+ let bounded: Value = serde_json::from_slice(&stderr).unwrap();
393
+ assert_eq!(bounded["stage"], "diagnostic_size_limit");
394
+ assert!(stderr.len() <= 8192);
395
+ }
396
+ }