@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
@@ -13,13 +13,14 @@ use std::path::Path;
13
13
 
14
14
  use crate::model::ids::AgentId;
15
15
 
16
- #[derive(Clone, Debug)]
16
+ #[derive(Clone, Debug, PartialEq, Eq)]
17
17
  pub struct CapturedHealth {
18
18
  pub owner_team_id: Option<String>,
19
19
  pub status: Option<String>,
20
20
  pub last_output_at: Option<String>,
21
21
  pub context_usage_pct: Option<i64>,
22
22
  pub current_task_id: Option<String>,
23
+ pub updated_at: String,
23
24
  }
24
25
 
25
26
  /// golden agents.py:185 `copy.deepcopy(store.agent_health().get(agent_id))` — read the row BEFORE
@@ -27,6 +28,7 @@ pub struct CapturedHealth {
27
28
  /// absent.
28
29
  pub fn select_agent_health(
29
30
  workspace: &Path,
31
+ owner_team_id: &str,
30
32
  agent_id: &AgentId,
31
33
  ) -> Result<Option<CapturedHealth>, crate::db::DbError> {
32
34
  let store = crate::message_store::MessageStore::open(workspace)
@@ -34,9 +36,9 @@ pub fn select_agent_health(
34
36
  let conn = crate::db::schema::open_db(store.db_path())?;
35
37
  let row = conn
36
38
  .query_row(
37
- "select owner_team_id, status, last_output_at, context_usage_pct, current_task_id \
38
- from agent_health where agent_id = ?1",
39
- [agent_id.as_str()],
39
+ "select owner_team_id, status, last_output_at, context_usage_pct, current_task_id, updated_at \
40
+ from agent_health where owner_team_id = ?1 and agent_id = ?2",
41
+ rusqlite::params![owner_team_id, agent_id.as_str()],
40
42
  |r| {
41
43
  Ok(CapturedHealth {
42
44
  owner_team_id: r.get::<_, Option<String>>(0)?,
@@ -44,6 +46,7 @@ pub fn select_agent_health(
44
46
  last_output_at: r.get::<_, Option<String>>(2)?,
45
47
  context_usage_pct: r.get::<_, Option<i64>>(3)?,
46
48
  current_task_id: r.get::<_, Option<String>>(4)?,
49
+ updated_at: r.get::<_, String>(5)?,
47
50
  })
48
51
  },
49
52
  )
@@ -55,42 +58,44 @@ pub fn select_agent_health(
55
58
  /// or delete the row when there was nothing to restore.
56
59
  pub fn restore_agent_health(
57
60
  workspace: &Path,
61
+ owner_team_id: &str,
58
62
  agent_id: &AgentId,
59
63
  row: &Option<CapturedHealth>,
60
64
  ) -> Result<(), crate::db::DbError> {
61
65
  let Some(row) = row else {
62
- return delete_agent_health(workspace, agent_id);
66
+ return delete_agent_health(workspace, owner_team_id, agent_id);
63
67
  };
64
68
  let store = crate::message_store::MessageStore::open(workspace)
65
69
  .map_err(|e| crate::db::DbError::Schema(e.to_string()))?;
66
70
  let conn = crate::db::schema::open_db(store.db_path())?;
67
71
  let status = row.status.clone().unwrap_or_else(|| "IDLE".to_string());
68
- let now = chrono::Utc::now()
69
- .format("%Y-%m-%dT%H:%M:%S%.6f+00:00")
70
- .to_string();
71
72
  conn.execute(
72
73
  "insert into agent_health (owner_team_id, agent_id, status, last_output_at, context_usage_pct, current_task_id, updated_at) \
73
74
  values (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
74
75
  rusqlite::params![
75
- row.owner_team_id,
76
+ owner_team_id,
76
77
  agent_id.as_str(),
77
78
  status,
78
79
  row.last_output_at,
79
80
  row.context_usage_pct,
80
81
  row.current_task_id,
81
- now,
82
+ row.updated_at,
82
83
  ],
83
84
  )?;
84
85
  Ok(())
85
86
  }
86
87
 
87
- fn delete_agent_health(workspace: &Path, agent_id: &AgentId) -> Result<(), crate::db::DbError> {
88
+ fn delete_agent_health(
89
+ workspace: &Path,
90
+ owner_team_id: &str,
91
+ agent_id: &AgentId,
92
+ ) -> Result<(), crate::db::DbError> {
88
93
  let store = crate::message_store::MessageStore::open(workspace)
89
94
  .map_err(|e| crate::db::DbError::Schema(e.to_string()))?;
90
95
  let conn = crate::db::schema::open_db(store.db_path())?;
91
96
  conn.execute(
92
- "delete from agent_health where agent_id = ?1",
93
- [agent_id.as_str()],
97
+ "delete from agent_health where owner_team_id = ?1 and agent_id = ?2",
98
+ rusqlite::params![owner_team_id, agent_id.as_str()],
94
99
  )?;
95
100
  Ok(())
96
101
  }
@@ -52,6 +52,8 @@ pub enum MessageStoreError {
52
52
  Sqlite(#[from] rusqlite::Error),
53
53
  #[error("io: {0}")]
54
54
  Io(#[from] std::io::Error),
55
+ #[error("delivery receipt missing for message: {0}")]
56
+ DeliveryReceiptMissing(String),
55
57
  }
56
58
 
57
59
  /// Outcome of [`MessageStore::claim_leader_notification_delivery`]
@@ -78,6 +80,40 @@ pub struct NotificationClaimParams<'a> {
78
80
  pub pane_id: Option<&'a str>,
79
81
  }
80
82
 
83
+ /// Canonical initial message-row statuses shared by persistence, presentation,
84
+ /// claiming and recovery. New durable dispositions must be added here first.
85
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
86
+ pub enum MessageRowStatus {
87
+ Accepted,
88
+ QueuedUntilLeaderAttach,
89
+ QueuedCoordinatorUnavailable,
90
+ }
91
+
92
+ impl MessageRowStatus {
93
+ pub const fn as_str(self) -> &'static str {
94
+ match self {
95
+ Self::Accepted => "accepted",
96
+ Self::QueuedUntilLeaderAttach => "queued_until_leader_attach",
97
+ Self::QueuedCoordinatorUnavailable => "queued_coordinator_unavailable",
98
+ }
99
+ }
100
+ }
101
+
102
+ /// Fully resolved durable-message insert. Grammar, scope and transport data do
103
+ /// not belong here; callers must resolve those before crossing this boundary.
104
+ pub struct PersistMessageInput<'a> {
105
+ pub message_id: Option<&'a str>,
106
+ pub owner_team_id: Option<&'a str>,
107
+ pub task_id: Option<&'a str>,
108
+ pub sender: &'a str,
109
+ pub recipient: &'a str,
110
+ pub reply_to: Option<&'a str>,
111
+ pub requires_ack: bool,
112
+ pub status: MessageRowStatus,
113
+ pub content: &'a str,
114
+ pub error: Option<&'a str>,
115
+ }
116
+
81
117
  /// `leader_notification_log._legacy_epoch_from_uuid` (line 145-147):
82
118
  /// `int(zlib.crc32(str(uuid or "").encode("utf-8")) & 0x7FFFFFFF)`.
83
119
  pub fn legacy_epoch_from_uuid(leader_session_uuid: Option<&str>) -> i64 {
@@ -133,44 +169,67 @@ impl MessageStore {
133
169
  &self.path
134
170
  }
135
171
 
136
- /// `create_message` (`core.py:71-114`). Returns `msg_<uuid4 hex[:12]>`; inserts
137
- /// a row with `status='accepted'`, `requires_ack` as 0/1 int, `artifact_refs`
138
- /// defaulting to `'[]'`, `delivery_attempts=0`, timestamps = now.
139
- #[allow(clippy::too_many_arguments)]
140
- pub fn create_message(
172
+ pub fn persist_message(
141
173
  &self,
142
- task_id: Option<&str>,
143
- sender: &str,
144
- recipient: &str,
145
- content: &str,
146
- reply_to: Option<&str>,
147
- requires_ack: bool,
148
- owner_team_id: Option<&str>,
174
+ input: PersistMessageInput<'_>,
149
175
  ) -> Result<String, MessageStoreError> {
150
176
  let conn = crate::db::schema::open_db(&self.path)?;
151
- let message_id = next_message_id();
177
+ let message_id = input
178
+ .message_id
179
+ .map(ToOwned::to_owned)
180
+ .unwrap_or_else(next_message_id);
152
181
  let now = now_ts();
153
182
  conn.execute(
154
183
  "insert into messages(
155
184
  message_id, owner_team_id, task_id, sender, recipient, reply_to, requires_ack,
156
185
  status, content, artifact_refs, created_at, updated_at, delivered_at,
157
186
  acknowledged_at, error, delivery_attempts
158
- ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'accepted', ?8, '[]', ?9, ?9, null, null, null, 0)",
187
+ ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, '[]', ?10, ?10, null, null, ?11, 0)",
159
188
  params![
160
189
  message_id,
161
- owner_team_id,
162
- task_id,
163
- sender,
164
- recipient,
165
- reply_to,
166
- if requires_ack { 1 } else { 0 },
167
- content,
190
+ input.owner_team_id,
191
+ input.task_id,
192
+ input.sender,
193
+ input.recipient,
194
+ input.reply_to,
195
+ if input.requires_ack { 1 } else { 0 },
196
+ input.status.as_str(),
197
+ input.content,
168
198
  now,
199
+ input.error,
169
200
  ],
170
201
  )?;
171
202
  Ok(message_id)
172
203
  }
173
204
 
205
+ /// `create_message` (`core.py:71-114`). Returns `msg_<uuid4 hex[:12]>`; inserts
206
+ /// a row with `status='accepted'`, `requires_ack` as 0/1 int, `artifact_refs`
207
+ /// defaulting to `'[]'`, `delivery_attempts=0`, timestamps = now.
208
+ #[allow(clippy::too_many_arguments)]
209
+ pub fn create_message(
210
+ &self,
211
+ task_id: Option<&str>,
212
+ sender: &str,
213
+ recipient: &str,
214
+ content: &str,
215
+ reply_to: Option<&str>,
216
+ requires_ack: bool,
217
+ owner_team_id: Option<&str>,
218
+ ) -> Result<String, MessageStoreError> {
219
+ self.persist_message(PersistMessageInput {
220
+ message_id: None,
221
+ owner_team_id,
222
+ task_id,
223
+ sender,
224
+ recipient,
225
+ reply_to,
226
+ requires_ack,
227
+ status: MessageRowStatus::Accepted,
228
+ content,
229
+ error: None,
230
+ })
231
+ }
232
+
174
233
  /// Caller-supplied-id variant of [`create_message`] (CR-015/054 — `--message-id`).
175
234
  /// Inserts exactly the given `message_id` instead of generating one. The store
176
235
  /// PK is `message_id`, so a repeat with the same id is rejected by SQLite; the
@@ -190,27 +249,18 @@ impl MessageStore {
190
249
  requires_ack: bool,
191
250
  owner_team_id: Option<&str>,
192
251
  ) -> Result<String, MessageStoreError> {
193
- let conn = crate::db::schema::open_db(&self.path)?;
194
- let now = now_ts();
195
- conn.execute(
196
- "insert into messages(
197
- message_id, owner_team_id, task_id, sender, recipient, reply_to, requires_ack,
198
- status, content, artifact_refs, created_at, updated_at, delivered_at,
199
- acknowledged_at, error, delivery_attempts
200
- ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'accepted', ?8, '[]', ?9, ?9, null, null, null, 0)",
201
- params![
202
- message_id,
203
- owner_team_id,
204
- task_id,
205
- sender,
206
- recipient,
207
- reply_to,
208
- if requires_ack { 1 } else { 0 },
209
- content,
210
- now,
211
- ],
212
- )?;
213
- Ok(message_id.to_string())
252
+ self.persist_message(PersistMessageInput {
253
+ message_id: Some(message_id),
254
+ owner_team_id,
255
+ task_id,
256
+ sender,
257
+ recipient,
258
+ reply_to,
259
+ requires_ack,
260
+ status: MessageRowStatus::Accepted,
261
+ content,
262
+ error: None,
263
+ })
214
264
  }
215
265
 
216
266
  /// `true` iff a `messages` row with this `message_id` already exists. Used by
@@ -260,6 +310,62 @@ impl MessageStore {
260
310
  Ok(())
261
311
  }
262
312
 
313
+ /// Record that the transport submitted a message without claiming that the
314
+ /// provider accepted it. The stable message id is also the receipt token.
315
+ pub fn record_delivery_submission(
316
+ &self,
317
+ message_id: &str,
318
+ visible: bool,
319
+ ) -> Result<(), MessageStoreError> {
320
+ let conn = crate::db::schema::open_db(&self.path)?;
321
+ let now = now_ts();
322
+ conn.execute(
323
+ "insert into delivery_tokens(
324
+ message_id, unique_token, injected_at, visible_at,
325
+ consumed_at, failed_at, failure_reason
326
+ ) values (?1, ?1, ?2, ?3, null, null, null)
327
+ on conflict(message_id) do update set
328
+ visible_at = coalesce(delivery_tokens.visible_at, excluded.visible_at),
329
+ failed_at = null,
330
+ failure_reason = null",
331
+ params![message_id, now, visible.then_some(now_ts())],
332
+ )?;
333
+ Ok(())
334
+ }
335
+
336
+ /// Atomically persist the provider-side receipt and advance the message to
337
+ /// delivered. A transport-only caller cannot use this without first
338
+ /// recording the submission row above.
339
+ pub fn mark_delivered_with_receipt(&self, message_id: &str) -> Result<(), MessageStoreError> {
340
+ let mut conn = crate::db::schema::open_db(&self.path)?;
341
+ let tx = conn.transaction()?;
342
+ let now = now_ts();
343
+ let receipts = tx.execute(
344
+ "update delivery_tokens
345
+ set consumed_at = coalesce(consumed_at, ?2),
346
+ failed_at = null,
347
+ failure_reason = null
348
+ where message_id = ?1",
349
+ params![message_id, now],
350
+ )?;
351
+ if receipts != 1 {
352
+ return Err(MessageStoreError::DeliveryReceiptMissing(
353
+ message_id.to_string(),
354
+ ));
355
+ }
356
+ tx.execute(
357
+ "update messages
358
+ set status = case when status = 'acknowledged' then status else 'delivered' end,
359
+ updated_at = ?2,
360
+ delivered_at = ?2,
361
+ error = null
362
+ where message_id = ?1",
363
+ params![message_id, now_ts()],
364
+ )?;
365
+ tx.commit()?;
366
+ Ok(())
367
+ }
368
+
263
369
  /// `claim_for_delivery` (`core.py:190-205`): atomic single-winner claim. Flips an
264
370
  /// eligible row (status ∈ pending/accepted/queued_until_idle/queued_until_start/
265
371
  /// queued_stopped/queued_pane_missing) to `target_resolved`, `delivery_attempts +=
@@ -274,9 +380,13 @@ impl MessageStore {
274
380
  where message_id = ?1
275
381
  and status in (
276
382
  'pending', 'accepted', 'queued_until_idle', 'queued_until_start',
277
- 'queued_stopped', 'queued_pane_missing'
383
+ 'queued_stopped', 'queued_pane_missing', ?3
278
384
  )",
279
- params![message_id, now_ts()],
385
+ params![
386
+ message_id,
387
+ now_ts(),
388
+ MessageRowStatus::QueuedCoordinatorUnavailable.as_str()
389
+ ],
280
390
  )?;
281
391
  Ok(rows == 1)
282
392
  }
@@ -35,6 +35,38 @@ use crate::model::paths::logs_dir;
35
35
  pub const EVENT_LOG_ROTATE_BYTES: u64 = 5 * 1024 * 1024;
36
36
  /// `events.py:18`:保留 5 个 archive。
37
37
  pub const EVENT_LOG_ARCHIVE_KEEP: u32 = 5;
38
+ pub(crate) const PROVIDER_SESSION_CONVERGING: &str = "provider.session.converging";
39
+ pub(crate) const PROVIDER_WORKER_SPAWN_ARGV: &str = "provider.worker.spawn_argv";
40
+
41
+ pub(crate) fn provider_worker_spawn_argv_fields(fields: Value) -> Value {
42
+ let object = fields.as_object();
43
+ let field = |key: &str| {
44
+ object
45
+ .and_then(|object| object.get(key))
46
+ .cloned()
47
+ .unwrap_or(Value::Null)
48
+ };
49
+ let array = |key: &str| match field(key) {
50
+ value @ Value::Array(_) => value,
51
+ _ => Value::Array(Vec::new()),
52
+ };
53
+ serde_json::json!({
54
+ "agent_id": field("agent_id"),
55
+ "provider": field("provider"),
56
+ "argv": array("argv"),
57
+ "session_id_in_argv": field("session_id_in_argv"),
58
+ "expected_session_id": field("expected_session_id"),
59
+ "spawn_cwd": field("spawn_cwd"),
60
+ "spawned_at": field("spawned_at"),
61
+ "source": field("source"),
62
+ "spawn_epoch": field("spawn_epoch"),
63
+ "env_overlay_keys": array("env_overlay_keys"),
64
+ "env_unset": array("env_unset"),
65
+ "tmux_start_mode": field("tmux_start_mode"),
66
+ "tmux_endpoint": field("tmux_endpoint"),
67
+ "tmux_endpoint_source": field("tmux_endpoint_source"),
68
+ })
69
+ }
38
70
 
39
71
  #[derive(Debug, Error)]
40
72
  pub enum EventLogError {
@@ -277,6 +309,47 @@ mod tests {
277
309
  assert!(ts.ends_with("+00:00"));
278
310
  }
279
311
 
312
+ #[test]
313
+ fn provider_spawn_fields_have_one_canonical_shape() {
314
+ let fields = provider_worker_spawn_argv_fields(json!({
315
+ "agent_id": "worker",
316
+ "provider": "fake",
317
+ "source": "restart",
318
+ "ignored": "not part of the schema",
319
+ }));
320
+ let object = fields.as_object().unwrap();
321
+ let keys = object
322
+ .keys()
323
+ .map(String::as_str)
324
+ .collect::<std::collections::BTreeSet<_>>();
325
+ assert_eq!(
326
+ keys,
327
+ [
328
+ "agent_id",
329
+ "argv",
330
+ "env_overlay_keys",
331
+ "env_unset",
332
+ "expected_session_id",
333
+ "provider",
334
+ "session_id_in_argv",
335
+ "source",
336
+ "spawn_cwd",
337
+ "spawn_epoch",
338
+ "spawned_at",
339
+ "tmux_endpoint",
340
+ "tmux_endpoint_source",
341
+ "tmux_start_mode",
342
+ ]
343
+ .into_iter()
344
+ .collect::<std::collections::BTreeSet<_>>()
345
+ );
346
+ assert_eq!(fields["argv"], json!([]));
347
+ assert_eq!(fields["env_overlay_keys"], json!([]));
348
+ assert_eq!(fields["env_unset"], json!([]));
349
+ assert!(fields["spawn_cwd"].is_null());
350
+ assert!(fields.get("ignored").is_none());
351
+ }
352
+
280
353
  #[test]
281
354
  fn tail_returns_last_n_and_raw_on_bad_line() {
282
355
  let ws = temp_ws();
@@ -816,7 +816,13 @@ fn persist_managed_leader_binding(
816
816
  .with_team_owner(owner)
817
817
  .with_owner_epoch(owner_epoch);
818
818
  crate::state::ownership::write_owner(&mut state, identity.team_id.as_str(), record);
819
- crate::state::persist::save_runtime_state(workspace, &state)?;
819
+ crate::state::repository::StateRepository::new(workspace).save(
820
+ crate::state::repository::StateWriteIntent::LeaderStartBinding {
821
+ team_key: identity.team_id.as_str(),
822
+ transport_kind: "managed",
823
+ },
824
+ &state,
825
+ )?;
820
826
  Ok(())
821
827
  }
822
828
 
@@ -933,7 +939,13 @@ fn persist_exec_provider_leader_binding(
933
939
  .with_team_owner(owner)
934
940
  .with_owner_epoch(owner_epoch);
935
941
  crate::state::ownership::write_owner(&mut state, identity.team_id.as_str(), record);
936
- crate::state::persist::save_runtime_state(workspace, &state)?;
942
+ crate::state::repository::StateRepository::new(workspace).save(
943
+ crate::state::repository::StateWriteIntent::LeaderStartBinding {
944
+ team_key: identity.team_id.as_str(),
945
+ transport_kind: "exec_provider",
946
+ },
947
+ &state,
948
+ )?;
937
949
  Ok(())
938
950
  }
939
951
 
@@ -1124,7 +1136,13 @@ fn refresh_managed_leader_provider_binding(
1124
1136
  .with_team_owner(owner)
1125
1137
  .with_owner_epoch(existing_epoch);
1126
1138
  crate::state::ownership::write_owner(&mut state, team_key, record);
1127
- crate::state::persist::save_runtime_state(workspace, &state)?;
1139
+ crate::state::repository::StateRepository::new(workspace).save(
1140
+ crate::state::repository::StateWriteIntent::LeaderStartBinding {
1141
+ team_key,
1142
+ transport_kind: "managed_reentry",
1143
+ },
1144
+ &state,
1145
+ )?;
1128
1146
  Ok(())
1129
1147
  }
1130
1148
 
@@ -1169,7 +1187,13 @@ fn persist_external_leader_topology_marker(
1169
1187
  teams.insert(identity.team_id.as_str().to_string(), entry);
1170
1188
  }
1171
1189
  }
1172
- crate::state::persist::save_runtime_state(workspace, &state)?;
1190
+ crate::state::repository::StateRepository::new(workspace).save(
1191
+ crate::state::repository::StateWriteIntent::LeaderStartBinding {
1192
+ team_key: identity.team_id.as_str(),
1193
+ transport_kind: "external",
1194
+ },
1195
+ &state,
1196
+ )?;
1173
1197
  Ok(())
1174
1198
  }
1175
1199