@team-agent/installer 0.5.112 → 0.5.114

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 (77) 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 +119 -50
  5. package/crates/team-agent/src/cli/diagnose.rs +36 -13
  6. package/crates/team-agent/src/cli/emit.rs +173 -109
  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/leaders.rs +32 -32
  10. package/crates/team-agent/src/cli/mod.rs +158 -14
  11. package/crates/team-agent/src/cli/models.rs +68 -18
  12. package/crates/team-agent/src/cli/named_address.rs +17 -16
  13. package/crates/team-agent/src/cli/route.rs +4 -4
  14. package/crates/team-agent/src/cli/send/presentation.rs +19 -18
  15. package/crates/team-agent/src/cli/send/resolve.rs +11 -11
  16. package/crates/team-agent/src/cli/send.rs +4 -4
  17. package/crates/team-agent/src/cli/spec.rs +35 -29
  18. package/crates/team-agent/src/cli/status.rs +7 -7
  19. package/crates/team-agent/src/cli/status_port/brief.rs +268 -101
  20. package/crates/team-agent/src/cli/tests/base.rs +94 -6
  21. package/crates/team-agent/src/cli/tests/divergence.rs +3 -2
  22. package/crates/team-agent/src/cli/tests/lane_c.rs +1 -1
  23. package/crates/team-agent/src/cli/tests/named_address.rs +3 -3
  24. package/crates/team-agent/src/cli/tests/status_send.rs +9 -7
  25. package/crates/team-agent/src/cli/triage.rs +53 -2
  26. package/crates/team-agent/src/cli/types.rs +8 -8
  27. package/crates/team-agent/src/compiler/tests.rs +12 -0
  28. package/crates/team-agent/src/compiler.rs +65 -26
  29. package/crates/team-agent/src/contract_runtime/backend.rs +646 -0
  30. package/crates/team-agent/src/contract_runtime/config.rs +309 -0
  31. package/crates/team-agent/src/contract_runtime/discovery.rs +237 -0
  32. package/crates/team-agent/src/contract_runtime/forward.rs +229 -0
  33. package/crates/team-agent/src/contract_runtime/framework.rs +261 -0
  34. package/crates/team-agent/src/contract_runtime/mcp.rs +396 -0
  35. package/crates/team-agent/src/contract_runtime/mod.rs +14 -0
  36. package/crates/team-agent/src/contract_runtime/prompt.rs +216 -0
  37. package/crates/team-agent/src/contract_runtime/pump.rs +207 -0
  38. package/crates/team-agent/src/contract_runtime/registry.rs +30 -0
  39. package/crates/team-agent/src/coordinator/tick.rs +26 -8
  40. package/crates/team-agent/src/leader/mod.rs +1 -0
  41. package/crates/team-agent/src/leader/prompt.rs +906 -0
  42. package/crates/team-agent/src/leader/start.rs +265 -16
  43. package/crates/team-agent/src/leader/tests/identity.rs +21 -0
  44. package/crates/team-agent/src/leader/tests/mod.rs +2 -0
  45. package/crates/team-agent/src/leader/tests/prompt_contract.rs +268 -0
  46. package/crates/team-agent/src/leader/types.rs +6 -0
  47. package/crates/team-agent/src/lib.rs +4 -0
  48. package/crates/team-agent/src/lifecycle/launch/identity.rs +172 -13
  49. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +760 -365
  50. package/crates/team-agent/src/lifecycle/launch/spawn.rs +38 -9
  51. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +17 -0
  52. package/crates/team-agent/src/lifecycle/launch.rs +39 -28
  53. package/crates/team-agent/src/lifecycle/profile_launch.rs +58 -46
  54. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +97 -52
  55. package/crates/team-agent/src/lifecycle/restart.rs +1 -1
  56. package/crates/team-agent/src/lifecycle/tests/stale_team_saveconflict_contract.rs +3 -2
  57. package/crates/team-agent/src/lifecycle/types.rs +4 -1
  58. package/crates/team-agent/src/lifecycle/worker_command_context.rs +37 -27
  59. package/crates/team-agent/src/main.rs +6 -0
  60. package/crates/team-agent/src/messaging/delivery.rs +50 -1
  61. package/crates/team-agent/src/messaging/send.rs +10 -0
  62. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +131 -0
  63. package/crates/team-agent/src/model/spec.rs +24 -12
  64. package/crates/team-agent/src/provider/adapter.rs +8 -59
  65. package/crates/team-agent/src/provider/adapters/claude.rs +6 -14
  66. package/crates/team-agent/src/provider/adapters/codex.rs +8 -18
  67. package/crates/team-agent/src/provider/adapters/copilot.rs +5 -22
  68. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +8 -45
  69. package/crates/team-agent/src/provider/adapters/grok.rs +7 -31
  70. package/crates/team-agent/src/provider/adapters/pi.rs +7 -18
  71. package/crates/team-agent/src/provider/command_helpers.rs +45 -0
  72. package/crates/team-agent/src/provider/mod.rs +1 -0
  73. package/crates/team-agent/src/provider/model_catalog.rs +21 -28
  74. package/crates/team-agent/src/provider/tests/adapter.rs +70 -0
  75. package/crates/team-agent/src/provider/tests/command_helpers.rs +55 -0
  76. package/crates/team-agent/src/state/persist.rs +73 -0
  77. package/package.json +4 -4
@@ -0,0 +1,229 @@
1
+ //! New-runtime egress into the existing shared framework business APIs. A
2
+ //! forwarding intent is not a legacy physical queue, nor proof of presentation.
3
+ use serde_json::{json, Value};
4
+ use team_agent_contract::contract::{session::CwdIdentity, types::ScopeId};
5
+ use team_agent_contract::host::{digest, digest_hex, process::resolve_cwd};
6
+ use team_agent_contract::orchestration::{forward::*, store::ContractStore, Error};
7
+
8
+ use crate::messaging::{self, MessageTarget, SendOptions, SendOrigin, TrustedSender};
9
+ use crate::model::ids::{AgentId, TaskId, TeamKey};
10
+
11
+ /// Created from the selected framework Team, never from MCP tool arguments.
12
+ pub struct FrameworkContext {
13
+ pub workspace: CwdIdentity,
14
+ pub team: String,
15
+ pub native_scope: ScopeId,
16
+ }
17
+ impl FrameworkContext {
18
+ pub fn route(&self) -> Result<String, Error> {
19
+ Ok(format!(
20
+ "framework-{}",
21
+ digest_hex(digest(&serde_json::to_vec(&(
22
+ &self.workspace,
23
+ &self.team,
24
+ &self.native_scope
25
+ ))?))
26
+ ))
27
+ }
28
+ fn validate(&self, store: &ContractStore) -> Result<(), Error> {
29
+ if self.team.trim().is_empty()
30
+ || self.team.chars().any(char::is_control)
31
+ || store.scope() != &self.native_scope
32
+ || resolve_cwd(&self.workspace.path).map_err(|_| Error::Fence)? != self.workspace
33
+ {
34
+ return Err(Error::Fence);
35
+ }
36
+ let selected = crate::state::selector::resolve_active_team_readonly(
37
+ &self.workspace.path,
38
+ Some(&self.team),
39
+ crate::state::selector::SelectorMode::RuntimeOnly,
40
+ )
41
+ .map_err(|_| Error::Fence)?;
42
+ if selected.team_key != self.team
43
+ || resolve_cwd(&selected.run_workspace).map_err(|_| Error::Fence)? != self.workspace
44
+ {
45
+ return Err(Error::Fence);
46
+ }
47
+ Ok(())
48
+ }
49
+ }
50
+
51
+ fn framework_result(result_id: &str, source: &str, envelope: &Value) -> Result<Value, Error> {
52
+ if envelope.get("agent_id").and_then(Value::as_str) != Some(source)
53
+ || envelope
54
+ .get("task_id")
55
+ .and_then(Value::as_str)
56
+ .is_none_or(|task| task.trim().is_empty())
57
+ {
58
+ return Err(Error::Fence);
59
+ }
60
+ // K3's compact wire (schema 1 / completed) is not the root business envelope.
61
+ // Reuse the shared normalizer rather than maintaining a second alias/schema
62
+ // implementation or claiming that a persisted K3 result was forwarded.
63
+ let normalized = crate::mcp_server::normalize::normalize_report_envelope(envelope);
64
+ if normalized.presentation_error.is_some() {
65
+ return Err(Error::Invalid("result presentation"));
66
+ }
67
+ let mut value = serde_json::to_value(normalized)?;
68
+ value["result_id"] = json!(result_id);
69
+ Ok(value)
70
+ }
71
+
72
+ /// Reuses the common root send/result operations. No provider implementation is
73
+ /// selected here; the common per-recipient dispatcher remains authoritative.
74
+ fn forward_one(context: &FrameworkContext, intent: &ForwardIntent) -> (ForwardState, Value) {
75
+ match &intent.payload {
76
+ ForwardPayload::Message {
77
+ message,
78
+ task,
79
+ sender,
80
+ recipient,
81
+ content,
82
+ mailbox,
83
+ } => {
84
+ if sender != intent.source.seat.as_str() {
85
+ return (
86
+ ForwardState::Refused,
87
+ json!({"code":"sender_binding_mismatch"}),
88
+ );
89
+ }
90
+ let mut options = SendOptions {
91
+ origin: SendOrigin::Mcp,
92
+ task_id: task.as_ref().map(|task| TaskId::new(task.clone())),
93
+ route_task_id: false,
94
+ sender: TrustedSender::from_runtime_identity(AgentId::new(sender)),
95
+ team: Some(TeamKey::new(&context.team)),
96
+ message_id: Some(message.as_str().into()),
97
+ wait_visible: false,
98
+ block_until_delivered: false,
99
+ timeout: 2.0,
100
+ ..Default::default()
101
+ };
102
+ if *mailbox {
103
+ options.presentation.sink = messaging::presentation::PresentationSink::Casefile;
104
+ }
105
+ match messaging::send_message(
106
+ &context.workspace.path,
107
+ &MessageTarget::Single(recipient.clone()),
108
+ content,
109
+ &options,
110
+ ) {
111
+ Ok(outcome) => {
112
+ let accepted = outcome.message_id.as_deref() == Some(message.as_str())
113
+ && matches!(
114
+ outcome.status,
115
+ messaging::DeliveryStatus::Queued
116
+ | messaging::DeliveryStatus::StoredOnly
117
+ | messaging::DeliveryStatus::Delivered
118
+ | messaging::DeliveryStatus::AlreadyDelivered
119
+ | messaging::DeliveryStatus::RetryScheduled
120
+ | messaging::DeliveryStatus::FallbackLog
121
+ );
122
+ let state = if accepted {
123
+ ForwardState::Accepted
124
+ } else if outcome.message_id.is_none()
125
+ && matches!(outcome.status, messaging::DeliveryStatus::Refused)
126
+ {
127
+ ForwardState::Refused
128
+ } else {
129
+ ForwardState::Unknown
130
+ };
131
+ (
132
+ state,
133
+ json!({"ok":outcome.ok,"message_id":outcome.message_id,
134
+ "status":outcome.status,"message_status":outcome.message_status.0,
135
+ "reason":outcome.reason,"verification":outcome.verification}),
136
+ )
137
+ }
138
+ // Error alone is not proof that no destination row was written.
139
+ Err(_) => (
140
+ ForwardState::Unknown,
141
+ json!({"code":"framework_send_outcome_unknown"}),
142
+ ),
143
+ }
144
+ }
145
+ ForwardPayload::Result {
146
+ result_id,
147
+ envelope,
148
+ } => {
149
+ let envelope = match framework_result(result_id, intent.source.seat.as_str(), envelope)
150
+ {
151
+ Ok(envelope) => envelope,
152
+ Err(_) => {
153
+ return (
154
+ ForwardState::Refused,
155
+ json!({"code":"invalid_result_envelope"}),
156
+ )
157
+ }
158
+ };
159
+ match messaging::results::report_result_for_owner_team(
160
+ &context.workspace.path,
161
+ &envelope,
162
+ Some(&context.team),
163
+ ) {
164
+ Ok(receipt) => (ForwardState::Accepted, receipt),
165
+ Err(_) => (
166
+ ForwardState::Unknown,
167
+ json!({"code":"framework_result_outcome_unknown"}),
168
+ ),
169
+ }
170
+ }
171
+ }
172
+ }
173
+
174
+ /// The root coordinator owns this consumer. Claim commits before the delegate
175
+ /// is called, so no K3 transaction is held across root framework locks/writes.
176
+ /// InFlight/Unknown items require explicit exact-ID reconciliation, not replay.
177
+ pub fn drain(
178
+ context: &FrameworkContext,
179
+ store: &mut ContractStore,
180
+ limit: usize,
181
+ ) -> Result<usize, Error> {
182
+ context.validate(store)?;
183
+ let route = context.route()?;
184
+ let pending = store.pending_forwards(limit)?;
185
+ let mut completed = 0;
186
+ for next in pending {
187
+ if next.source.scope != context.native_scope {
188
+ return Err(Error::Fence);
189
+ }
190
+ let intent = match store.claim_forward(&next.id) {
191
+ Ok(intent) => intent,
192
+ Err(Error::Conflict) => continue,
193
+ Err(error) => return Err(error),
194
+ };
195
+ let (state, receipt) = if intent.route != route {
196
+ (
197
+ ForwardState::Refused,
198
+ json!({"code":"framework_route_changed"}),
199
+ )
200
+ } else {
201
+ // Recheck the selected directory before every external operation.
202
+ context.validate(store)?;
203
+ forward_one(context, &intent)
204
+ };
205
+ store.finish_forward(&intent.id, state, receipt)?;
206
+ completed += 1;
207
+ }
208
+ Ok(completed)
209
+ }
210
+
211
+ #[cfg(test)]
212
+ mod tests {
213
+ use super::*;
214
+ #[test]
215
+ fn compact_native_result_becomes_the_existing_framework_business_schema() {
216
+ let compact = json!({"schema_version":1,"agent_id":"worker","task_id":"msg-bound","status":"completed","summary":"5535"});
217
+ let result = framework_result("result-fixed", "worker", &compact).unwrap();
218
+ assert_eq!(result["schema_version"], "result_envelope_v1");
219
+ assert_eq!(result["status"], "success");
220
+ assert_eq!(result["task_id"], "msg-bound");
221
+ assert_eq!(result["summary"], "5535");
222
+ assert_eq!(result["result_id"], "result-fixed");
223
+ for field in ["changes", "tests", "risks", "artifacts", "next_actions"] {
224
+ assert!(result[field].is_array());
225
+ }
226
+ crate::messaging::helpers::validate_result_envelope(&result).unwrap();
227
+ assert!(framework_result("result-fixed", "other", &compact).is_err());
228
+ }
229
+ }
@@ -0,0 +1,261 @@
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
+ pub fn has_contract(state: &Value) -> bool {
23
+ state
24
+ .get("agents")
25
+ .and_then(Value::as_object)
26
+ .is_some_and(|agents| agents.values().any(is_contract))
27
+ }
28
+ pub fn only_contract(state: &Value) -> bool {
29
+ state
30
+ .get("agents")
31
+ .and_then(Value::as_object)
32
+ .is_some_and(|agents| !agents.is_empty() && agents.values().all(is_contract))
33
+ }
34
+ /// Unsupported lifecycle operations must stop before the legacy provider enum
35
+ /// can turn an unknown native seat into its historical Codex fallback.
36
+ pub fn require_legacy_lifecycle(
37
+ workspace: &Path,
38
+ team: Option<&str>,
39
+ agent: Option<&str>,
40
+ operation: &str,
41
+ ) -> Result<(), crate::cli::CliError> {
42
+ let Ok(selected) = crate::state::selector::resolve_active_team_readonly(
43
+ workspace,
44
+ team,
45
+ crate::state::selector::SelectorMode::RuntimeOnly,
46
+ ) else {
47
+ return Ok(());
48
+ };
49
+ let selected_native = match agent {
50
+ Some(id) => selected
51
+ .state
52
+ .get("agents")
53
+ .and_then(|agents| agents.get(id))
54
+ .is_some_and(is_contract),
55
+ None => has_contract(&selected.state),
56
+ };
57
+ let configured_native = selected
58
+ .state
59
+ .get("team_dir")
60
+ .and_then(Value::as_str)
61
+ .and_then(|directory| {
62
+ team_config(
63
+ &selected.run_workspace,
64
+ Path::new(directory),
65
+ &selected.team_key,
66
+ )
67
+ .ok()
68
+ .flatten()
69
+ })
70
+ .is_some_and(|config| match agent {
71
+ Some(id) => config.roles.iter().any(|role| role.id.as_str() == id),
72
+ None => !config.roles.is_empty(),
73
+ });
74
+ if selected_native || configured_native {
75
+ return Err(crate::cli::CliError::Runtime(format!("contract_capability_unverified: {operation} is not admitted for Kiro; no legacy fallback or fresh replacement was performed")));
76
+ }
77
+ Ok(())
78
+ }
79
+ pub fn require_legacy_role(
80
+ path: Option<&Path>,
81
+ provider: Option<&str>,
82
+ ) -> Result<(), crate::cli::CliError> {
83
+ let declared = path
84
+ .and_then(|path| std::fs::read_to_string(path).ok())
85
+ .and_then(|raw| {
86
+ crate::compiler::split_front_matter(&raw)
87
+ .0
88
+ .get("provider")
89
+ .and_then(crate::model::yaml::Value::as_str)
90
+ .map(str::to_owned)
91
+ });
92
+ if provider
93
+ .or(declared.as_deref())
94
+ .is_some_and(super::registry::recognizes)
95
+ {
96
+ return Err(crate::cli::CliError::Runtime("contract_capability_unverified: mutable Kiro role lifecycle is not admitted; use initial quick-start; no role file was changed".into()));
97
+ }
98
+ Ok(())
99
+ }
100
+ pub fn team_config(
101
+ workspace: &Path,
102
+ directory: &Path,
103
+ team: &str,
104
+ ) -> Result<Option<config::TeamConfig>, config::ConfigError> {
105
+ config::read_team(&crate::cli::QuickStartArgs {
106
+ workspace: workspace.into(),
107
+ agents_dir: directory.into(),
108
+ name: None,
109
+ team_id: Some(team.into()),
110
+ yes: false,
111
+ json: false,
112
+ detail: false,
113
+ backend: Some("tmux".into()),
114
+ })
115
+ }
116
+ pub fn start_role(
117
+ workspace: &Path,
118
+ directory: &Path,
119
+ team: &str,
120
+ id: &str,
121
+ ) -> Result<(), crate::lifecycle::LifecycleError> {
122
+ let error = |error: String| crate::lifecycle::LifecycleError::Provider(error);
123
+ let config = team_config(workspace, directory, team)
124
+ .map_err(|e| error(e.to_string()))?
125
+ .ok_or_else(|| error("contract role no longer configured".into()))?;
126
+ let role = config
127
+ .roles
128
+ .iter()
129
+ .find(|role| role.id.as_str() == id)
130
+ .ok_or_else(|| error("contract role not found".into()))?;
131
+ let (_, record) =
132
+ backend::start(workspace, team, role, &config.members).map_err(|e| error(e.to_string()))?;
133
+ if record.outcome != Outcome::Committed {
134
+ return Err(error(format!(
135
+ "native startup {:?}: {}",
136
+ record.outcome,
137
+ record.failure.unwrap_or_default()
138
+ )));
139
+ }
140
+ Ok(())
141
+ }
142
+
143
+ pub fn project_seat(workspace: &Path, team: &str, id: &str) -> Result<Value, BackendError> {
144
+ let backend = Backend::open_readonly(workspace, team)?;
145
+ let seat = backend
146
+ .store
147
+ .seat(&SeatId::new(id)?)?
148
+ .ok_or(BackendError::Metadata)?;
149
+ let role = backend::read_role(&backend.binding, &seat.identity.seat)?;
150
+ let alive = seat
151
+ .physical
152
+ .as_ref()
153
+ .is_some_and(|target| sample_process(&target.process) == ProcessState::Alive);
154
+ let status = if seat.status == SeatStatus::Stopped {
155
+ "stopped"
156
+ } else if seat.status != SeatStatus::Unknown && alive {
157
+ "running"
158
+ } else {
159
+ "unknown"
160
+ };
161
+ Ok(
162
+ json!({"agent_id":id,"provider":seat.provider,"status":status,"model":role.model,"effort":role.effort,
163
+ "contract_prompt_sha256":team_agent_contract::host::digest_hex(role.prompt.sha256),
164
+ "runtime_family":"contract","contract_scope":seat.identity.scope,"contract_instance":seat.identity.instance,
165
+ "generation":seat.identity.generation,"owner_team_id":team,"team_key":team,
166
+ "session_id":seat.session.map(|session| session.native_session),
167
+ "native_endpoint":seat.endpoint,"native_pane":seat.pane,
168
+ "pane_id":null,"window":null,"session_name":null,
169
+ "mcp_binding":"unverified","bootstrap_used":seat.bootstrap_used}),
170
+ )
171
+ }
172
+
173
+ pub fn enqueue(
174
+ workspace: &Path,
175
+ state: &Value,
176
+ recipient: &str,
177
+ content: &str,
178
+ options: &crate::messaging::SendOptions,
179
+ ) -> Result<crate::messaging::DeliveryOutcome, crate::messaging::MessagingError> {
180
+ use crate::messaging::{DeliveryOutcome, DeliveryStatus, MessagingError};
181
+ let error = |error: String| MessagingError::Validation(error);
182
+ let team = crate::state::projection::team_state_key(state);
183
+ let mut backend = Backend::open(workspace, &team).map_err(|e| error(e.to_string()))?;
184
+ let request = OperatorSend {
185
+ recipient: SeatId::new(recipient).map_err(|e| error(e.to_string()))?,
186
+ content: content.into(),
187
+ task: options.task_id.as_ref().map(|task| task.as_str().into()),
188
+ message: options
189
+ .message_id
190
+ .clone()
191
+ .map(team_agent_contract::contract::types::MessageId::new)
192
+ .transpose()
193
+ .map_err(|e| error(e.to_string()))?,
194
+ mailbox: options.presentation.sink
195
+ == crate::messaging::presentation::PresentationSink::Casefile,
196
+ };
197
+ let sender = SeatId::new(options.sender.as_str()).map_err(|e| error(e.to_string()))?;
198
+ let receipt = backend
199
+ .store
200
+ .send_from_framework(&request, &sender)
201
+ .map_err(|e| error(e.to_string()))?;
202
+ // Queued is durable admission only. The coordinator consumes this outbox;
203
+ // CLI send never performs its own paste or repeats an uncertain attempt.
204
+ Ok(DeliveryOutcome {
205
+ ok: true,
206
+ status: if receipt.mailbox {
207
+ DeliveryStatus::StoredOnly
208
+ } else {
209
+ DeliveryStatus::Queued
210
+ },
211
+ message_status: crate::messaging::helpers::MessageStatusShadow(
212
+ if receipt.mailbox {
213
+ "stored_only"
214
+ } else {
215
+ "accepted"
216
+ }
217
+ .into(),
218
+ ),
219
+ message_id: Some(receipt.message.as_str().into()),
220
+ verification: Some(
221
+ json!({"runtime":"contract","task_id":receipt.task,
222
+ "target":receipt.target,"native_submission":"not_observed"})
223
+ .to_string(),
224
+ ),
225
+ stage: None,
226
+ reason: None,
227
+ channel: None,
228
+ ack_forced_off: false,
229
+ turn_verification: None,
230
+ })
231
+ }
232
+
233
+ pub fn stop_selected(workspace: &Path, state: &Value) -> Result<Vec<Value>, BackendError> {
234
+ let team = crate::state::projection::team_state_key(state);
235
+ let mut receipts = Vec::new();
236
+ if let Some(agents) = state.get("agents").and_then(Value::as_object) {
237
+ for (id, agent) in agents.iter().filter(|(_, agent)| is_contract(agent)) {
238
+ let backend = Backend::open(workspace, &team)?;
239
+ let seat = backend
240
+ .store
241
+ .seat(&SeatId::new(id)?)?
242
+ .ok_or(BackendError::Metadata)?;
243
+ if seat.status == SeatStatus::Stopped {
244
+ receipts.push(json!({"agent_id":id,"status":"already_stopped"}));
245
+ continue;
246
+ }
247
+ let _ = agent;
248
+ let receipt = backend::stop(workspace, &team, &SeatId::new(id)?)?;
249
+ if receipt.outcome != Outcome::Committed {
250
+ return Err(BackendError::Shutdown {
251
+ operation: receipt.id,
252
+ phase: receipt.phase,
253
+ outcome: receipt.outcome,
254
+ failure: receipt.failure,
255
+ });
256
+ }
257
+ receipts.push(json!({"agent_id":id,"status":"stopped","operation":receipt.id}));
258
+ }
259
+ }
260
+ Ok(receipts)
261
+ }