@team-agent/installer 0.4.10 → 0.5.0

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 (74) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -0
  4. package/crates/team-agent/src/cli/diagnose.rs +2 -15
  5. package/crates/team-agent/src/cli/emit.rs +62 -4
  6. package/crates/team-agent/src/compiler.rs +11 -20
  7. package/crates/team-agent/src/coordinator/orphan.rs +2 -2
  8. package/crates/team-agent/src/coordinator/runtime_detectors.rs +5 -4
  9. package/crates/team-agent/src/coordinator/steps/abnormal.rs +1776 -2
  10. package/crates/team-agent/src/coordinator/steps/mod.rs +19 -0
  11. package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +78 -7
  12. package/crates/team-agent/src/coordinator/tests/abnormal.rs +195 -0
  13. package/crates/team-agent/src/coordinator/tick.rs +31 -932
  14. package/crates/team-agent/src/diagnose/orphans.rs +66 -8
  15. package/crates/team-agent/src/layout/worker_window_helpers.rs +5 -7
  16. package/crates/team-agent/src/leader/provider_attribution.rs +7 -5
  17. package/crates/team-agent/src/leader/rediscover.rs +1 -11
  18. package/crates/team-agent/src/leader/start.rs +169 -25
  19. package/crates/team-agent/src/lifecycle/launch/plan.rs +19 -8
  20. package/crates/team-agent/src/lifecycle/launch.rs +135 -58
  21. package/crates/team-agent/src/lifecycle/lock.rs +302 -0
  22. package/crates/team-agent/src/lifecycle/mod.rs +1 -0
  23. package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -11
  24. package/crates/team-agent/src/lifecycle/restart/agent.rs +436 -121
  25. package/crates/team-agent/src/lifecycle/restart/common.rs +108 -17
  26. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -32
  27. package/crates/team-agent/src/lifecycle/restart/remove.rs +16 -2
  28. package/crates/team-agent/src/lifecycle/restart/selection.rs +2 -1
  29. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +193 -0
  30. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +3 -3
  31. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +321 -0
  32. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +54 -15
  33. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
  34. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +648 -0
  35. package/crates/team-agent/src/lifecycle/tests.rs +3 -0
  36. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  37. package/crates/team-agent/src/lifecycle/worker_command_context.rs +24 -34
  38. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +24 -9
  39. package/crates/team-agent/src/mcp_server/tools.rs +157 -68
  40. package/crates/team-agent/src/messaging/activity.rs +43 -18
  41. package/crates/team-agent/src/messaging/delivery.rs +161 -97
  42. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  43. package/crates/team-agent/src/messaging/leader_receiver.rs +22 -1
  44. package/crates/team-agent/src/messaging/results.rs +34 -9
  45. package/crates/team-agent/src/messaging/scheduler.rs +52 -9
  46. package/crates/team-agent/src/model/spec.rs +10 -6
  47. package/crates/team-agent/src/provider/adapter.rs +10 -1038
  48. package/crates/team-agent/src/provider/adapters/claude.rs +3 -8
  49. package/crates/team-agent/src/provider/adapters/copilot.rs +2 -5
  50. package/crates/team-agent/src/provider/classify.rs +23 -48
  51. package/crates/team-agent/src/provider/command.rs +16 -7
  52. package/crates/team-agent/src/provider/faults.rs +93 -53
  53. package/crates/team-agent/src/provider/session/capture.rs +4 -15
  54. package/crates/team-agent/src/provider/session_scan/claude.rs +309 -0
  55. package/crates/team-agent/src/provider/session_scan/codex.rs +40 -0
  56. package/crates/team-agent/src/provider/session_scan/common.rs +350 -0
  57. package/crates/team-agent/src/provider/session_scan/copilot.rs +202 -0
  58. package/crates/team-agent/src/provider/session_scan.rs +65 -27
  59. package/crates/team-agent/src/provider/tests/faults.rs +80 -0
  60. package/crates/team-agent/src/provider/types.rs +33 -1
  61. package/crates/team-agent/src/provider/wire.rs +171 -1
  62. package/crates/team-agent/src/state/identity.rs +242 -57
  63. package/crates/team-agent/src/state/identity_keys.rs +9 -12
  64. package/crates/team-agent/src/state/mod.rs +5 -1
  65. package/crates/team-agent/src/state/owner_gate.rs +59 -15
  66. package/crates/team-agent/src/state/ownership.rs +12 -11
  67. package/crates/team-agent/src/state/paths.rs +13 -6
  68. package/crates/team-agent/src/state/persist.rs +671 -128
  69. package/crates/team-agent/src/state/projection.rs +240 -49
  70. package/crates/team-agent/src/state/selector.rs +11 -5
  71. package/crates/team-agent/src/tmux_backend/tests.rs +51 -2
  72. package/crates/team-agent/src/tmux_backend.rs +42 -5
  73. package/crates/team-agent/src/transport/test_support.rs +55 -6
  74. package/package.json +4 -4
@@ -59,7 +59,10 @@ pub fn detect_idle_fallbacks(
59
59
  let idle_workers = stmt
60
60
  .query_map(params![team.as_str()], |row| row.get::<_, String>(0))?
61
61
  .collect::<Result<Vec<_>, _>>()?;
62
- let worker_set = workers.iter().cloned().collect::<std::collections::BTreeSet<_>>();
62
+ let worker_set = workers
63
+ .iter()
64
+ .cloned()
65
+ .collect::<std::collections::BTreeSet<_>>();
63
66
  let idle_set = idle_workers
64
67
  .iter()
65
68
  .cloned()
@@ -74,8 +77,16 @@ pub fn detect_idle_fallbacks(
74
77
  }
75
78
  let now = chrono::Utc::now().to_rfc3339();
76
79
  let mut next_state = state.clone();
77
- register_idle_fallback_suppression(&mut next_state, store, &team, &idle_workers, &now)?;
78
- crate::state::persist::save_runtime_state(workspace, &next_state)?;
80
+ let suppression_snapshots =
81
+ idle_fallback_suppression_snapshots(&next_state, store, &team, &idle_workers)?;
82
+ register_idle_fallback_suppression(&mut next_state, &team, &now, &suppression_snapshots);
83
+ crate::state::persist::save_runtime_state_reapplying_after_conflict(
84
+ workspace,
85
+ &next_state,
86
+ |latest| {
87
+ register_idle_fallback_suppression(latest, &team, &now, &suppression_snapshots);
88
+ },
89
+ )?;
79
90
  let alert_count = idle_workers.len();
80
91
  let content = format!(
81
92
  "Idle fallback: all workers idle while {obligation_count} undelivered obligation(s) remain."
@@ -213,7 +224,11 @@ fn active_team_key(workspace: &Path, state: &Value) -> String {
213
224
  .and_then(Value::as_str)
214
225
  .filter(|team| !team.is_empty())
215
226
  .map(ToString::to_string)
216
- .or_else(|| workspace.file_name().map(|name| name.to_string_lossy().to_string()))
227
+ .or_else(|| {
228
+ workspace
229
+ .file_name()
230
+ .map(|name| name.to_string_lossy().to_string())
231
+ })
217
232
  .unwrap_or_else(|| "current".to_string())
218
233
  }
219
234
 
@@ -298,14 +313,13 @@ fn idle_fallback_suppressed(
298
313
  && delivered == delivered_message_ids(store, team, agent_id)?)
299
314
  }
300
315
 
301
- fn register_idle_fallback_suppression(
302
- state: &mut Value,
316
+ fn idle_fallback_suppression_snapshots(
317
+ state: &Value,
303
318
  store: &MessageStore,
304
319
  team: &str,
305
320
  idle_workers: &[String],
306
- now: &str,
307
- ) -> Result<(), MessagingError> {
308
- let snapshots = idle_workers
321
+ ) -> Result<Vec<(String, Vec<String>, Vec<String>)>, MessagingError> {
322
+ idle_workers
309
323
  .iter()
310
324
  .map(|agent_id| {
311
325
  Ok((
@@ -314,23 +328,31 @@ fn register_idle_fallback_suppression(
314
328
  delivered_message_ids(store, team, agent_id)?,
315
329
  ))
316
330
  })
317
- .collect::<Result<Vec<_>, MessagingError>>()?;
331
+ .collect::<Result<Vec<_>, MessagingError>>()
332
+ }
333
+
334
+ fn register_idle_fallback_suppression(
335
+ state: &mut Value,
336
+ team: &str,
337
+ now: &str,
338
+ snapshots: &[(String, Vec<String>, Vec<String>)],
339
+ ) {
318
340
  let Some(root) = state.as_object_mut() else {
319
- return Ok(());
341
+ return;
320
342
  };
321
343
  let Some(coordinator) = root
322
344
  .entry("coordinator")
323
345
  .or_insert_with(|| serde_json::json!({}))
324
346
  .as_object_mut()
325
347
  else {
326
- return Ok(());
348
+ return;
327
349
  };
328
350
  let Some(last) = coordinator
329
351
  .entry("idle_fallback_last_fired_at")
330
352
  .or_insert_with(|| serde_json::json!({}))
331
353
  .as_object_mut()
332
354
  else {
333
- return Ok(());
355
+ return;
334
356
  };
335
357
  last.insert(team.to_string(), serde_json::Value::String(now.to_string()));
336
358
  let Some(all) = coordinator
@@ -338,18 +360,18 @@ fn register_idle_fallback_suppression(
338
360
  .or_insert_with(|| serde_json::json!({}))
339
361
  .as_object_mut()
340
362
  else {
341
- return Ok(());
363
+ return;
342
364
  };
343
365
  let Some(team_map) = all
344
366
  .entry(team.to_string())
345
367
  .or_insert_with(|| serde_json::json!({}))
346
368
  .as_object_mut()
347
369
  else {
348
- return Ok(());
370
+ return;
349
371
  };
350
372
  for (agent_id, assigned, delivered) in snapshots {
351
373
  let Some(agent_map) = team_map
352
- .entry(agent_id)
374
+ .entry(agent_id.clone())
353
375
  .or_insert_with(|| serde_json::json!({}))
354
376
  .as_object_mut()
355
377
  else {
@@ -367,7 +389,6 @@ fn register_idle_fallback_suppression(
367
389
  }),
368
390
  );
369
391
  }
370
- Ok(())
371
392
  }
372
393
 
373
394
  fn timestamp_in_future(ts: &str) -> bool {
@@ -407,7 +428,11 @@ fn assigned_task_ids(state: &Value, agent_id: &str) -> Vec<String> {
407
428
  tasks
408
429
  .iter()
409
430
  .filter(|task| task.get("assignee").and_then(Value::as_str) == Some(agent_id))
410
- .filter_map(|task| task.get("id").and_then(Value::as_str).map(ToString::to_string))
431
+ .filter_map(|task| {
432
+ task.get("id")
433
+ .and_then(Value::as_str)
434
+ .map(ToString::to_string)
435
+ })
411
436
  .collect::<Vec<_>>()
412
437
  })
413
438
  .unwrap_or_default();
@@ -9,6 +9,9 @@ use crate::event_log::EventLog;
9
9
  use crate::message_store::MessageStore;
10
10
  use crate::model::enums::{PaneLiveness, Provider};
11
11
  use crate::model::ids::TeamKey;
12
+ use crate::provider::wire::{
13
+ is_claude_family, parse_canonical_provider, parse_provider, provider_wire,
14
+ };
12
15
  use crate::transport::{
13
16
  submit_verification_wire, InjectPayload, InjectReport, InjectVerification, Key, PaneId,
14
17
  PaneInfo, SessionName, SubmitVerification, Target, Transport, WindowName,
@@ -302,76 +305,63 @@ pub fn deliver_pending_message(
302
305
  } else {
303
306
  InjectPayload::Text(rendered)
304
307
  };
305
- let inject_report =
306
- match transport.inject(&target, &payload, Key::Enter, true) {
307
- Ok(report) => report,
308
- Err(error) => {
309
- let reason = format!("inject_failed:{error}");
310
- if message.recipient == "leader" {
311
- store.mark(message_id, "failed", Some("leader_not_attached"))?;
312
- event_log.write(
313
- "leader_receiver.delivery_blocked",
314
- serde_json::json!({
315
- "message_id": message_id,
316
- "sender": message.sender,
317
- "reason": "leader_not_attached",
318
- "channel": "rebind_required",
319
- "action": "run team-agent claim-leader or team-agent takeover",
320
- "error": error.to_string(),
321
- }),
322
- )?;
323
- return Ok(DeliveryOutcome {
324
- ok: false,
325
- status: DeliveryStatus::Refused,
326
- message_status: MessageStatusShadow("failed".to_string()),
327
- message_id: Some(message_id.to_string()),
328
- verification: Some(
329
- "run team-agent claim-leader or team-agent takeover".to_string(),
330
- ),
331
- stage: None,
332
- reason: Some(DeliveryRefusal::LeaderNotAttached),
333
- channel: Some("rebind_required".to_string()),
334
- });
335
- }
308
+ let inject_report = match transport.inject(&target, &payload, Key::Enter, true) {
309
+ Ok(report) => report,
310
+ Err(error) => {
311
+ let reason = format!("inject_failed:{error}");
312
+ if message.recipient == "leader" {
313
+ store.mark(message_id, "failed", Some("leader_not_attached"))?;
336
314
  event_log.write(
337
- "send.inject_failed",
315
+ "leader_receiver.delivery_blocked",
338
316
  serde_json::json!({
339
317
  "message_id": message_id,
340
- "recipient": message.recipient,
341
- "attempts": attempt,
342
- "max_attempts": SEND_RETRY_MAX_ATTEMPTS,
318
+ "sender": message.sender,
319
+ "reason": "leader_not_attached",
320
+ "channel": "rebind_required",
321
+ "action": "run team-agent claim-leader or team-agent takeover",
343
322
  "error": error.to_string(),
344
323
  }),
345
324
  )?;
346
- if attempt >= u32::from(SEND_RETRY_MAX_ATTEMPTS) {
347
- store.mark(message_id, "failed", Some("send_inject_exhausted"))?;
348
- emit_send_failed_exhausted(
349
- workspace,
350
- state,
351
- event_log,
352
- message_id,
353
- &message.recipient,
354
- attempt,
355
- "send_inject_exhausted",
356
- &reason,
357
- None,
358
- )?;
359
- return Ok(DeliveryOutcome {
360
- ok: false,
361
- status: DeliveryStatus::Failed,
362
- message_status: MessageStatusShadow("failed".to_string()),
363
- message_id: Some(message_id.to_string()),
364
- verification: Some(reason),
365
- stage: Some(DeliveryStage::Inject),
366
- reason: None,
367
- channel: None,
368
- });
369
- }
370
- store.mark(message_id, "target_resolved", Some(&reason))?;
371
325
  return Ok(DeliveryOutcome {
372
326
  ok: false,
373
- status: DeliveryStatus::Degraded,
374
- message_status: MessageStatusShadow("target_resolved".to_string()),
327
+ status: DeliveryStatus::Refused,
328
+ message_status: MessageStatusShadow("failed".to_string()),
329
+ message_id: Some(message_id.to_string()),
330
+ verification: Some(
331
+ "run team-agent claim-leader or team-agent takeover".to_string(),
332
+ ),
333
+ stage: None,
334
+ reason: Some(DeliveryRefusal::LeaderNotAttached),
335
+ channel: Some("rebind_required".to_string()),
336
+ });
337
+ }
338
+ event_log.write(
339
+ "send.inject_failed",
340
+ serde_json::json!({
341
+ "message_id": message_id,
342
+ "recipient": message.recipient,
343
+ "attempts": attempt,
344
+ "max_attempts": SEND_RETRY_MAX_ATTEMPTS,
345
+ "error": error.to_string(),
346
+ }),
347
+ )?;
348
+ if attempt >= u32::from(SEND_RETRY_MAX_ATTEMPTS) {
349
+ store.mark(message_id, "failed", Some("send_inject_exhausted"))?;
350
+ emit_send_failed_exhausted(
351
+ workspace,
352
+ state,
353
+ event_log,
354
+ message_id,
355
+ &message.recipient,
356
+ attempt,
357
+ "send_inject_exhausted",
358
+ &reason,
359
+ None,
360
+ )?;
361
+ return Ok(DeliveryOutcome {
362
+ ok: false,
363
+ status: DeliveryStatus::Failed,
364
+ message_status: MessageStatusShadow("failed".to_string()),
375
365
  message_id: Some(message_id.to_string()),
376
366
  verification: Some(reason),
377
367
  stage: Some(DeliveryStage::Inject),
@@ -379,6 +369,18 @@ pub fn deliver_pending_message(
379
369
  channel: None,
380
370
  });
381
371
  }
372
+ store.mark(message_id, "target_resolved", Some(&reason))?;
373
+ return Ok(DeliveryOutcome {
374
+ ok: false,
375
+ status: DeliveryStatus::Degraded,
376
+ message_status: MessageStatusShadow("target_resolved".to_string()),
377
+ message_id: Some(message_id.to_string()),
378
+ verification: Some(reason),
379
+ stage: Some(DeliveryStage::Inject),
380
+ reason: None,
381
+ channel: None,
382
+ });
383
+ }
382
384
  };
383
385
  let submit_verified = inject_submit_verified(&inject_report);
384
386
  let readback_verified = pane_readback_verified(&inject_report);
@@ -404,8 +406,7 @@ pub fn deliver_pending_message(
404
406
  // legacy keys (`message_id` / `recipient` / `reason` / `attempts`)
405
407
  // are preserved byte-for-byte for grep compatibility; new keys are
406
408
  // ADDITIONAL.
407
- let submit_attempts_detail =
408
- render_submit_diagnostics(&inject_report);
409
+ let submit_attempts_detail = render_submit_diagnostics(&inject_report);
409
410
  event_log.write(
410
411
  "send.unverified",
411
412
  serde_json::json!({
@@ -461,14 +462,15 @@ pub fn deliver_pending_message(
461
462
  // per-delivery check / short grace window. Phase-1 Claude only —
462
463
  // codex/copilot keep the pre-fix behaviour (will be addressed in
463
464
  // phase-2 once Claude phase-1 is field-validated).
464
- if let Some((rollout_path, provider_wire_str)) = claude_recipient_rollout(state, &message.recipient) {
465
+ if let Some((rollout_path, provider_wire_str)) =
466
+ claude_recipient_rollout(state, &message.recipient)
467
+ {
465
468
  let token_marker = format!("[team-agent-token:{message_id}]");
466
469
  let grace = std::time::Duration::from_millis(200);
467
470
  let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
468
471
  let mut transcript_has_token = false;
469
472
  loop {
470
- transcript_has_token =
471
- rollout_tail_contains(&rollout_path, &token_marker, 64 * 1024);
473
+ transcript_has_token = rollout_tail_contains(&rollout_path, &token_marker, 64 * 1024);
472
474
  if transcript_has_token || std::time::Instant::now() >= deadline {
473
475
  break;
474
476
  }
@@ -589,7 +591,12 @@ pub(crate) fn pane_readback_verified(report: &InjectReport) -> bool {
589
591
  /// when it sees this block + extracts the token — the bare content gives WORKING but never a report
590
592
  /// (rt-host-a loop #4). token == message_id (exactly-once correlation).
591
593
  /// F1 (0.3.26): promoted to `pub` for direct pane send in cli/send.rs.
592
- pub fn render_message(sender: &str, task_id: Option<&str>, content: &str, message_id: &str) -> String {
594
+ pub fn render_message(
595
+ sender: &str,
596
+ task_id: Option<&str>,
597
+ content: &str,
598
+ message_id: &str,
599
+ ) -> String {
593
600
  let mut header = format!("Team Agent message from {sender}");
594
601
  if let Some(task_id) = task_id.filter(|t| !t.is_empty()) {
595
602
  header.push_str(&format!(" for {task_id}"));
@@ -717,8 +724,7 @@ fn pane_conflicts_on_same_socket(
717
724
  worker_socket: Option<&str>,
718
725
  leader: PaneSocketBinding<'_>,
719
726
  ) -> bool {
720
- leader.pane_id == pane_id
721
- && !tmux_sockets_known_different(worker_socket, leader.tmux_socket)
727
+ leader.pane_id == pane_id && !tmux_sockets_known_different(worker_socket, leader.tmux_socket)
722
728
  }
723
729
 
724
730
  fn tmux_sockets_known_different(left: Option<&str>, right: Option<&str>) -> bool {
@@ -784,7 +790,10 @@ fn leader_receiver_pane_is_usable(
784
790
  let Some(pane_id) = leader_receiver_pane_id(state) else {
785
791
  return false;
786
792
  };
787
- if live_targets.iter().any(|target| target.pane_id.as_str() == pane_id) {
793
+ if live_targets
794
+ .iter()
795
+ .any(|target| target.pane_id.as_str() == pane_id)
796
+ {
788
797
  return true;
789
798
  }
790
799
  !matches!(
@@ -1228,11 +1237,12 @@ fn recipient_pane_has_actionable_startup_prompt(
1228
1237
  .and_then(|agents| agents.get(recipient));
1229
1238
  let provider = agent
1230
1239
  .and_then(|agent| agent.get("provider"))
1231
- .and_then(serde_json::Value::as_str);
1232
- if !matches!(
1233
- provider,
1234
- Some("codex" | "claude" | "claude_code" | "copilot")
1235
- ) {
1240
+ .and_then(serde_json::Value::as_str)
1241
+ .and_then(parse_canonical_provider);
1242
+ let Some(provider) = provider else {
1243
+ return false;
1244
+ };
1245
+ if matches!(provider, Provider::GeminiCli | Provider::Fake) {
1236
1246
  return false;
1237
1247
  }
1238
1248
  // step2-retry/scrollback root-cause (rt binary 6c9c6c1c): once the agent's
@@ -1264,20 +1274,20 @@ fn recipient_pane_has_actionable_startup_prompt(
1264
1274
  _ => return false,
1265
1275
  };
1266
1276
  match provider {
1267
- Some("codex") => matches!(
1277
+ Provider::Codex => matches!(
1268
1278
  crate::provider::classify_codex_startup_screen(&captured),
1269
1279
  crate::provider::StartupScreenDecision::AnswerWorkspaceTrust
1270
1280
  | crate::provider::StartupScreenDecision::SkipUpdatePrompt
1271
1281
  ),
1272
- Some("claude" | "claude_code") => matches!(
1282
+ Provider::Claude | Provider::ClaudeCode => matches!(
1273
1283
  crate::provider::classify_claude_startup_screen(&captured),
1274
1284
  crate::provider::StartupScreenDecision::AnswerWorkspaceTrust
1275
1285
  ),
1276
- Some("copilot") => matches!(
1286
+ Provider::Copilot => matches!(
1277
1287
  crate::provider::classify_copilot_startup_screen(&captured),
1278
1288
  crate::provider::StartupScreenDecision::AnswerWorkspaceTrust
1279
1289
  ),
1280
- _ => false,
1290
+ Provider::GeminiCli | Provider::Fake => false,
1281
1291
  }
1282
1292
  }
1283
1293
 
@@ -1410,8 +1420,20 @@ fn record_turn_open_if_leader_to_worker_scoped(
1410
1420
  return Ok(());
1411
1421
  }
1412
1422
  let mut state = scoped_state_for_write(workspace, owner_team_id)?;
1423
+ arm_turn_open(&mut state, recipient, &delivered.message_id);
1424
+ save_scoped_state_reapplying_after_conflict(workspace, &state, owner_team_id, |latest| {
1425
+ arm_turn_open(latest, recipient, &delivered.message_id);
1426
+ })?;
1427
+ event_log.write(
1428
+ "turn_open.armed_after_delivery",
1429
+ serde_json::json!({"agent_id": recipient, "message_id": delivered.message_id}),
1430
+ )?;
1431
+ Ok(())
1432
+ }
1433
+
1434
+ fn arm_turn_open(state: &mut serde_json::Value, recipient: &str, message_id: &Option<String>) {
1413
1435
  let Some(root) = state.as_object_mut() else {
1414
- return Ok(());
1436
+ return;
1415
1437
  };
1416
1438
  let coordinator = root
1417
1439
  .entry("coordinator")
@@ -1419,15 +1441,9 @@ fn record_turn_open_if_leader_to_worker_scoped(
1419
1441
  if let Some(obj) = coordinator.as_object_mut() {
1420
1442
  obj.insert(
1421
1443
  "turn_open".to_string(),
1422
- serde_json::json!({"armed": true, "node_id": recipient, "turn_id": delivered.message_id}),
1444
+ serde_json::json!({"armed": true, "node_id": recipient, "turn_id": message_id}),
1423
1445
  );
1424
1446
  }
1425
- save_scoped_state(workspace, &state, owner_team_id)?;
1426
- event_log.write(
1427
- "turn_open.armed_after_delivery",
1428
- serde_json::json!({"agent_id": recipient, "message_id": delivered.message_id}),
1429
- )?;
1430
- Ok(())
1431
1447
  }
1432
1448
 
1433
1449
  /// `_stamp_first_send_at_if_leader_to_worker` (`delivery.py:380`):首次 leader→worker 投递戳
@@ -1453,6 +1469,15 @@ fn stamp_first_send_at_if_leader_to_worker_scoped(
1453
1469
  }
1454
1470
  let mut state = scoped_state_for_write(workspace, owner_team_id)?;
1455
1471
  let now = chrono::Utc::now().to_rfc3339();
1472
+ if stamp_first_send_at(&mut state, recipient, &now) {
1473
+ save_scoped_state_reapplying_after_conflict(workspace, &state, owner_team_id, |latest| {
1474
+ let _ = stamp_first_send_at(latest, recipient, &now);
1475
+ })?;
1476
+ }
1477
+ Ok(())
1478
+ }
1479
+
1480
+ fn stamp_first_send_at(state: &mut serde_json::Value, recipient: &str, now: &str) -> bool {
1456
1481
  if let Some(agent) = state
1457
1482
  .get_mut("agents")
1458
1483
  .and_then(serde_json::Value::as_object_mut)
@@ -1464,11 +1489,14 @@ fn stamp_first_send_at_if_leader_to_worker_scoped(
1464
1489
  .get("first_send_at")
1465
1490
  .is_some_and(serde_json::Value::is_null)
1466
1491
  {
1467
- agent.insert("first_send_at".to_string(), serde_json::Value::String(now));
1468
- save_scoped_state(workspace, &state, owner_team_id)?;
1492
+ agent.insert(
1493
+ "first_send_at".to_string(),
1494
+ serde_json::Value::String(now.to_string()),
1495
+ );
1496
+ return true;
1469
1497
  }
1470
1498
  }
1471
- Ok(())
1499
+ false
1472
1500
  }
1473
1501
 
1474
1502
  fn scoped_state_for_write(
@@ -1516,6 +1544,40 @@ fn save_scoped_state(
1516
1544
  Ok(())
1517
1545
  }
1518
1546
 
1547
+ fn save_scoped_state_reapplying_after_conflict<F>(
1548
+ workspace: &Path,
1549
+ state: &serde_json::Value,
1550
+ owner_team_id: Option<&str>,
1551
+ reapply: F,
1552
+ ) -> Result<(), MessagingError>
1553
+ where
1554
+ F: FnOnce(&mut serde_json::Value),
1555
+ {
1556
+ if owner_team_id.filter(|team| !team.is_empty()).is_some()
1557
+ && state
1558
+ .get("teams")
1559
+ .and_then(serde_json::Value::as_object)
1560
+ .is_some_and(|teams| {
1561
+ owner_team_id
1562
+ .and_then(|team| {
1563
+ crate::state::projection::resolve_owner_team_id(state, team)
1564
+ .canonical_key()
1565
+ .map(str::to_string)
1566
+ })
1567
+ .is_some_and(|team| teams.contains_key(&team))
1568
+ })
1569
+ {
1570
+ crate::state::projection::save_team_scoped_state_reapplying_after_conflict(
1571
+ workspace, state, reapply,
1572
+ )?;
1573
+ } else {
1574
+ crate::state::persist::save_runtime_state_reapplying_after_conflict(
1575
+ workspace, state, reapply,
1576
+ )?;
1577
+ }
1578
+ Ok(())
1579
+ }
1580
+
1519
1581
  enum OwnerTeamProjection {
1520
1582
  Projected {
1521
1583
  state: serde_json::Value,
@@ -1765,12 +1827,14 @@ fn claude_recipient_rollout(
1765
1827
  recipient: &str,
1766
1828
  ) -> Option<(std::path::PathBuf, &'static str)> {
1767
1829
  let agent = state.get("agents")?.get(recipient)?;
1768
- let provider = agent.get("provider").and_then(serde_json::Value::as_str)?;
1769
- let provider_wire_str = match provider {
1770
- "claude" => "claude",
1771
- "claude_code" | "claude-code" => "claude_code",
1772
- _ => return None,
1773
- };
1830
+ let provider = agent
1831
+ .get("provider")
1832
+ .and_then(serde_json::Value::as_str)
1833
+ .and_then(parse_provider)?;
1834
+ if !is_claude_family(provider) {
1835
+ return None;
1836
+ }
1837
+ let provider_wire_str = provider_wire(provider);
1774
1838
  let rollout = agent
1775
1839
  .get("rollout_path")
1776
1840
  .and_then(serde_json::Value::as_str)
@@ -131,6 +131,7 @@ pub(crate) fn working_seconds(scrollback: &str) -> Option<u64> {
131
131
  }
132
132
 
133
133
  pub(crate) fn non_provider_command(command: &str) -> Option<&str> {
134
+ // Activity command grammar, not provider identity parsing.
134
135
  let base = command.rsplit('/').next().unwrap_or(command);
135
136
  let normalized = base.to_ascii_lowercase();
136
137
  match normalized.as_str() {
@@ -269,7 +269,28 @@ pub fn claim_leader_receiver(
269
269
  .with_leader_receiver(receiver_value)
270
270
  .with_owner_epoch(next_epoch);
271
271
  crate::state::ownership::write_owner(state, &team_key, record);
272
- crate::state::persist::save_runtime_state(workspace, state)?;
272
+ crate::state::persist::save_runtime_state_reapplying_after_conflict(
273
+ workspace,
274
+ state,
275
+ |latest| {
276
+ let team_key = crate::state::projection::team_state_key(latest);
277
+ let record = crate::state::ownership::OwnershipWrite::new()
278
+ .with_team_owner(
279
+ state
280
+ .get("team_owner")
281
+ .cloned()
282
+ .unwrap_or_else(|| serde_json::json!({})),
283
+ )
284
+ .with_leader_receiver(
285
+ state
286
+ .get("leader_receiver")
287
+ .cloned()
288
+ .unwrap_or_else(|| serde_json::json!({})),
289
+ )
290
+ .with_owner_epoch(next_epoch);
291
+ crate::state::ownership::write_owner(latest, &team_key, record);
292
+ },
293
+ )?;
273
294
  event_log.write(
274
295
  "leader_receiver.claimed",
275
296
  serde_json::json!({"owner_epoch": next_epoch, "candidate": candidate}),
@@ -108,6 +108,7 @@ fn collect_scoped(
108
108
  let mut invalid_results = Vec::new();
109
109
  let mut fatal_invalid_results = 0usize;
110
110
  let mut state_dirty = false;
111
+ let mut task_updates = Vec::new();
111
112
  for row in rows {
112
113
  let envelope: serde_json::Value = match serde_json::from_str(&row.envelope) {
113
114
  Ok(envelope) => envelope,
@@ -167,6 +168,7 @@ fn collect_scoped(
167
168
  }
168
169
  if scope == "task" {
169
170
  mark_task_done(&mut state, &row.task_id, &row.result_id);
171
+ task_updates.push((row.task_id.clone(), row.result_id.clone()));
170
172
  state_dirty = true;
171
173
  }
172
174
  log.write(
@@ -196,9 +198,25 @@ fn collect_scoped(
196
198
  }
197
199
  if state_dirty {
198
200
  if owner_team_id.is_some() {
199
- crate::state::projection::save_team_scoped_state(&paths.run_workspace, &state)?;
201
+ crate::state::projection::save_team_scoped_state_reapplying_after_conflict(
202
+ &paths.run_workspace,
203
+ &state,
204
+ |latest| {
205
+ for (task_id, result_id) in &task_updates {
206
+ mark_task_done(latest, task_id, result_id);
207
+ }
208
+ },
209
+ )?;
200
210
  } else {
201
- crate::state::persist::save_runtime_state(&paths.run_workspace, &state)?;
211
+ crate::state::persist::save_runtime_state_reapplying_after_conflict(
212
+ &paths.run_workspace,
213
+ &state,
214
+ |latest| {
215
+ for (task_id, result_id) in &task_updates {
216
+ mark_task_done(latest, task_id, result_id);
217
+ }
218
+ },
219
+ )?;
202
220
  }
203
221
  }
204
222
  let counts = result_counts(&conn, owner_team_id)?;
@@ -708,8 +726,10 @@ fn report_result_for_owner_team_inner(
708
726
  Ok(outcome) => outcome,
709
727
  Err(error) => {
710
728
  let message_id = format!("fallback:{result_id}");
711
- let fallback_error =
712
- fallback_primary_error_text(fallback_primary_error, format!("leader_funnel_error:{error}"));
729
+ let fallback_error = fallback_primary_error_text(
730
+ fallback_primary_error,
731
+ format!("leader_funnel_error:{error}"),
732
+ );
713
733
  super::leader_receiver::deliver_to_leader_fallback_pane(
714
734
  workspace,
715
735
  &state,
@@ -1027,9 +1047,11 @@ fn format_report_result_changes(envelope: &serde_json::Value) -> Option<String>
1027
1047
  .filter_map(|change| {
1028
1048
  let path = report_field_any(change, &["path", "file", "filepath", "filename"])?;
1029
1049
  let kind = report_field_any(change, &["kind", "type", "action"]).unwrap_or("changed");
1030
- let description =
1031
- report_field_any(change, &["description", "summary", "detail", "details", "message"])
1032
- .unwrap_or(path);
1050
+ let description = report_field_any(
1051
+ change,
1052
+ &["description", "summary", "detail", "details", "message"],
1053
+ )
1054
+ .unwrap_or(path);
1033
1055
  Some(format!("{kind} {path}: {description}"))
1034
1056
  })
1035
1057
  .collect::<Vec<_>>();
@@ -1078,8 +1100,11 @@ fn format_report_result_next_actions(envelope: &serde_json::Value) -> Option<Str
1078
1100
  let parts = report_result_array(envelope, "next_actions")?
1079
1101
  .iter()
1080
1102
  .filter_map(|action| {
1081
- report_field_any(action, &["description", "summary", "action", "todo", "message"])
1082
- .map(|text| text.to_string())
1103
+ report_field_any(
1104
+ action,
1105
+ &["description", "summary", "action", "todo", "message"],
1106
+ )
1107
+ .map(|text| text.to_string())
1083
1108
  })
1084
1109
  .collect::<Vec<_>>();
1085
1110
  if parts.is_empty() {