@team-agent/installer 0.4.5 → 0.4.7

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 (29) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +6 -1
  4. package/crates/team-agent/src/cli/status_port.rs +119 -66
  5. package/crates/team-agent/src/cli/tests/status_send.rs +74 -48
  6. package/crates/team-agent/src/coordinator/tests/health_sync.rs +6 -1
  7. package/crates/team-agent/src/coordinator/tick.rs +46 -21
  8. package/crates/team-agent/src/leader/helpers.rs +1 -22
  9. package/crates/team-agent/src/lifecycle/launch.rs +34 -17
  10. package/crates/team-agent/src/lifecycle/profile_launch.rs +1 -11
  11. package/crates/team-agent/src/lifecycle/restart/agent.rs +160 -0
  12. package/crates/team-agent/src/lifecycle/restart/common.rs +82 -22
  13. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +108 -50
  14. package/crates/team-agent/src/lifecycle/restart/selection.rs +23 -0
  15. package/crates/team-agent/src/lifecycle/tests/core.rs +19 -11
  16. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +23 -1
  17. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +163 -15
  18. package/crates/team-agent/src/provider/adapter.rs +217 -377
  19. package/crates/team-agent/src/provider/adapters/claude.rs +106 -0
  20. package/crates/team-agent/src/provider/adapters/codex.rs +117 -0
  21. package/crates/team-agent/src/provider/adapters/copilot.rs +124 -0
  22. package/crates/team-agent/src/provider/adapters/fake.rs +17 -0
  23. package/crates/team-agent/src/provider/adapters/mod.rs +27 -0
  24. package/crates/team-agent/src/provider/mod.rs +6 -0
  25. package/crates/team-agent/src/provider/session/capture.rs +593 -55
  26. package/crates/team-agent/src/provider/wire.rs +139 -0
  27. package/crates/team-agent/src/state/persist.rs +225 -10
  28. package/package.json +4 -4
  29. package/skills/team-agent/SKILL.md +6 -0
package/Cargo.lock CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.4.5"
569
+ version = "0.4.7"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.4.5"
12
+ version = "0.4.7"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -240,11 +240,16 @@ pub fn cmd_status_for_team(args: &StatusArgs, team: Option<&str>) -> Result<CmdR
240
240
  }
241
241
  };
242
242
  if args.summary {
243
+ // 0.4.x compact slimming: `--summary` renders the five-line triage
244
+ // text via `format_status_summary`, which needs the full payload
245
+ // fields (coordinator, leader_receiver, agent_health, queued_messages,
246
+ // latest_results). The slim compact projection drops those, so the
247
+ // summary path must request the full payload (compact=false).
243
248
  let value = status_port::status_scoped(
244
249
  &selected.run_workspace,
245
250
  &selected.state,
246
251
  Some(&selected.team_key),
247
- true,
252
+ false,
248
253
  false,
249
254
  )?;
250
255
  return Ok(CmdResult::human(append_reminder(
@@ -601,39 +601,127 @@ use rusqlite::params;
601
601
  Ok(Value::Array(values))
602
602
  }
603
603
 
604
+ /// 0.4.x: slim default compact payload — exactly 7 top-level fields.
605
+ /// Diagnostic detail moves to `--detail`. Plan:
606
+ /// /Users/alauda/Documents/code/team-agent-public/.team/artifacts/status-compact-plan.md
604
607
  fn compact_status(full: Value) -> Value {
608
+ let not_ready = compact_not_ready(&full);
609
+ let ready = compact_ready(&full, &not_ready);
605
610
  json!({
611
+ "ok": true,
606
612
  "team": full.get("team").cloned().unwrap_or(Value::Null),
607
613
  "session_name": full.get("session_name").cloned().unwrap_or(Value::Null),
608
- "leader_topology": full.get("leader_topology").cloned().unwrap_or_else(|| json!("managed")),
609
- "is_external_leader": full.get("is_external_leader").cloned().unwrap_or(Value::Bool(false)),
610
614
  "leader_attach_command": full.get("leader_attach_command").cloned().unwrap_or(Value::Null),
611
- "leader_client": full.get("leader_client").cloned().unwrap_or(Value::Null),
612
- "tmux_session_present": full.get("tmux_session_present").cloned().unwrap_or(Value::Bool(false)),
613
- "all_spawned": full.get("all_spawned").cloned().unwrap_or(Value::Bool(false)),
614
- "all_attached_receiver": full.get("all_attached_receiver").cloned().unwrap_or(Value::Bool(true)),
615
- "all_resumable_have_session": full.get("all_resumable_have_session").cloned().unwrap_or(Value::Bool(true)),
616
- "session_capture_complete": full.get("session_capture_complete").cloned().unwrap_or(Value::Bool(true)),
617
- "session_capture_incomplete": full.get("session_capture_incomplete").cloned().unwrap_or(Value::Bool(false)),
618
- "incomplete_session_capture_agents": full.get("incomplete_session_capture_agents").cloned().unwrap_or_else(|| json!([])),
619
- "pending_session_agent_ids": full.get("pending_session_agent_ids").cloned().unwrap_or_else(|| json!([])),
620
- "leader_receiver": compact_object(full.get("leader_receiver"), &[
621
- "status", "provider", "mode", "session_name", "window_name", "pane_id", "pane_current_command",
622
- ]),
615
+ "ready": ready,
616
+ "not_ready": not_ready,
623
617
  "agents": compact_agents(full.get("agents")),
624
- "agent_health": full.get("agent_health").cloned().unwrap_or_else(|| json!({})),
625
- "tasks": compact_tasks(full.get("tasks")),
626
- "messages": full.get("messages").cloned().unwrap_or_else(|| json!({})),
627
- "queued_messages": take_array(full.get("queued_messages"), 8),
628
- "results": full.get("results").cloned().unwrap_or_else(|| json!({})),
629
- "latest_results": take_array(full.get("latest_results"), 5),
630
- "readiness": full.get("readiness").cloned().unwrap_or_else(|| json!({})),
631
- "coordinator": compact_object(full.get("coordinator"), &["status", "pid", "metadata_ok", "schema_ok"]),
632
- "reminder": full.get("reminder").cloned().unwrap_or_else(|| json!(crate::cli::STATUS_REMINDER)),
633
- "last_events": take_array_tail(full.get("last_events"), 10),
634
618
  })
635
619
  }
636
620
 
621
+ /// Synthesized readiness boolean for the slim payload. Stricter than the
622
+ /// raw `readiness.ready` because it also folds in coordinator + schema +
623
+ /// tmux session presence so operators don't need to read separate booleans.
624
+ fn compact_ready(full: &Value, not_ready: &Value) -> bool {
625
+ not_ready.is_null()
626
+ && full
627
+ .get("readiness")
628
+ .and_then(|r| r.get("ready"))
629
+ .and_then(Value::as_bool)
630
+ .unwrap_or(false)
631
+ && full
632
+ .get("coordinator")
633
+ .and_then(|c| c.get("status"))
634
+ .and_then(Value::as_str)
635
+ .is_some_and(|s| s == "running" || s == "ok")
636
+ && full
637
+ .get("coordinator")
638
+ .and_then(|c| c.get("schema_ok"))
639
+ .and_then(Value::as_bool)
640
+ .unwrap_or(true)
641
+ }
642
+
643
+ /// Returns `Value::Null` when fully ready, otherwise an object:
644
+ /// `{"reasons": [...], "agents": [...]}` listing every gating issue.
645
+ fn compact_not_ready(full: &Value) -> Value {
646
+ let reasons = not_ready_reasons(full);
647
+ if reasons.is_empty() {
648
+ return Value::Null;
649
+ }
650
+ let agents = full
651
+ .get("incomplete_session_capture_agents")
652
+ .and_then(Value::as_array)
653
+ .cloned()
654
+ .or_else(|| {
655
+ full.get("pending_session_agent_ids")
656
+ .and_then(Value::as_array)
657
+ .cloned()
658
+ })
659
+ .unwrap_or_default();
660
+ let mut obj = Map::new();
661
+ obj.insert(
662
+ "reasons".to_string(),
663
+ Value::Array(reasons.into_iter().map(Value::String).collect()),
664
+ );
665
+ obj.insert("agents".to_string(), Value::Array(agents));
666
+ Value::Object(obj)
667
+ }
668
+
669
+ fn not_ready_reasons(full: &Value) -> Vec<String> {
670
+ let mut reasons = Vec::new();
671
+ let coord = full.get("coordinator");
672
+ let coord_status = coord
673
+ .and_then(|c| c.get("status"))
674
+ .and_then(Value::as_str)
675
+ .unwrap_or("");
676
+ if coord_status != "running" && coord_status != "ok" {
677
+ reasons.push("coordinator_not_running".to_string());
678
+ }
679
+ if coord
680
+ .and_then(|c| c.get("schema_ok"))
681
+ .and_then(Value::as_bool)
682
+ == Some(false)
683
+ {
684
+ reasons.push("coordinator_schema_not_ok".to_string());
685
+ }
686
+ if full
687
+ .get("tmux_session_present")
688
+ .and_then(Value::as_bool)
689
+ == Some(false)
690
+ {
691
+ reasons.push("tmux_session_missing".to_string());
692
+ }
693
+ let readiness = full.get("readiness");
694
+ if readiness
695
+ .and_then(|r| r.get("all_spawned"))
696
+ .and_then(Value::as_bool)
697
+ == Some(false)
698
+ {
699
+ reasons.push("workers_not_spawned".to_string());
700
+ }
701
+ if readiness
702
+ .and_then(|r| r.get("all_attached_receiver"))
703
+ .and_then(Value::as_bool)
704
+ == Some(false)
705
+ {
706
+ reasons.push("leader_receiver_unbound".to_string());
707
+ }
708
+ if readiness
709
+ .and_then(|r| r.get("session_capture_complete"))
710
+ .and_then(Value::as_bool)
711
+ == Some(false)
712
+ {
713
+ reasons.push("session_capture_incomplete".to_string());
714
+ }
715
+ if readiness
716
+ .and_then(|r| r.get("awaiting_trust_prompt"))
717
+ .and_then(Value::as_bool)
718
+ == Some(true)
719
+ {
720
+ reasons.push("awaiting_trust_prompt".to_string());
721
+ }
722
+ reasons
723
+ }
724
+
637
725
  fn compact_agents(value: Option<&Value>) -> Value {
638
726
  let Some(Value::Object(input)) = value else {
639
727
  return json!({});
@@ -645,56 +733,21 @@ use rusqlite::params;
645
733
  Value::Object(out)
646
734
  }
647
735
 
648
- fn compact_agent_state(agent_id: &str, agent: &Value) -> Value {
736
+ /// 0.4.x: agent rows in the slim payload have exactly 4 fields. agent_id
737
+ /// is no longer copied in — the map key already carries it. Diagnostic
738
+ /// fields (model, tmux_window_present, session_id, captured_via,
739
+ /// attribution_confidence, display, interacted) move to `--detail`.
740
+ /// `activity` + `last_output_at` are preserved (RM-039-STAT-001).
741
+ fn compact_agent_state(_agent_id: &str, agent: &Value) -> Value {
649
742
  let Some(input) = agent.as_object() else {
650
743
  return json!({});
651
744
  };
652
745
  let mut out = Map::new();
653
- out.insert(
654
- "agent_id".to_string(),
655
- input
656
- .get("agent_id")
657
- .cloned()
658
- .unwrap_or_else(|| Value::String(agent_id.to_string())),
659
- );
660
- for key in [
661
- "status",
662
- "provider",
663
- "model",
664
- "tmux_window_present",
665
- "session_id",
666
- "captured_via",
667
- "attribution_confidence",
668
- // RM-039-STAT-001 fix (real-machine evidence 2026-06-22): the
669
- // coordinator-tick activity classifier writes activity (status,
670
- // confidence, rationale) to the top-level agent state, but the
671
- // status --json compact projection dropped it. Operators saw
672
- // "status: running" with no working/idle signal even when the
673
- // pane was clearly producing output. Add the classifier output
674
- // here so the compact form surfaces it.
675
- // last_output_at is the timestamp the classifier advanced when
676
- // the scrollback digest changed; keeping it adjacent to activity
677
- // gives the operator a one-glance "is something moving" view.
678
- // interacted is enriched in enrich_agents (true|false|never)
679
- // and is the canonical "leader has ever sent this worker a
680
- // message" signal — also needed for status sanity at a glance.
681
- "activity",
682
- "last_output_at",
683
- "interacted",
684
- ] {
746
+ for key in ["status", "provider", "activity", "last_output_at"] {
685
747
  if let Some(value) = input.get(key) {
686
748
  out.insert(key.to_string(), value.clone());
687
749
  }
688
750
  }
689
- if let Some(display) = input.get("display") {
690
- let compact_display = compact_object(
691
- Some(display),
692
- &["backend", "status", "workspace_window", "pane_id", "pid", "pids", "reason"],
693
- );
694
- if compact_display.as_object().is_some_and(|obj| !obj.is_empty()) {
695
- out.insert("display".to_string(), compact_display);
696
- }
697
- }
698
751
  Value::Object(out)
699
752
  }
700
753
 
@@ -36,7 +36,7 @@ use super::*;
36
36
  // projection MUST NOT collapse them.
37
37
  // =========================================================================
38
38
  #[test]
39
- fn rm039_stat001_compact_status_preserves_activity_last_output_and_interacted() {
39
+ fn rm039_stat001_compact_status_preserves_activity_and_last_output() {
40
40
  let ws = seed_status_workspace();
41
41
  let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
42
42
  if let Some(agents) = state
@@ -99,13 +99,11 @@ use super::*;
99
99
  Some("2026-06-22T02:52:30+00:00"),
100
100
  "compact projection must preserve `last_output_at` for the \"is something moving\" view"
101
101
  );
102
- // enrich_agents injects `interacted` from first_send_at; the
103
- // compact path must carry it (operators read it as a one-glance
104
- // \"has the leader ever sent this worker a message?\" signal).
105
- assert_eq!(
106
- agent.get("interacted").and_then(serde_json::Value::as_str),
107
- Some("2026-01-01T00:00:00Z"),
108
- "compact projection must preserve `interacted` from enrich_agents"
102
+ // 0.4.x compact slim: `interacted` moves to --detail; the 4-field
103
+ // compact agent row keeps only status/provider/activity/last_output_at.
104
+ assert!(
105
+ agent.get("interacted").is_none(),
106
+ "0.4.x: compact projection drops `interacted` (moved to --detail)"
109
107
  );
110
108
  let _ = std::fs::remove_dir_all(&ws);
111
109
  }
@@ -215,23 +213,37 @@ use super::*;
215
213
  #[test]
216
214
  fn status_port_status_compact_json_shape_against_seeded_fixture() {
217
215
  // cmd_status json branch (detail=false) delegates status_port::status(compact=true).
218
- // compact_status (status/compact.py:8-37) projects to a STABLE key set; assert the
219
- // load-bearing keys survive and `last_events` is bounded (compact truncates events).
216
+ // 0.4.x compact slim: exactly 7 top-level fields; diagnostics moved
217
+ // to --detail. Plan: .team/artifacts/status-compact-plan.md.
220
218
  let ws = seed_status_workspace();
221
219
  let v = status_port::status(&ws, /*compact=*/ true, /*detail=*/ false)
222
220
  .expect("seeded fixture status should project a value");
223
221
  let obj = v.as_object().expect("--json status is a dict");
224
- // compact_status's exact top-level key set (compact.py:9-37):
225
- for key in [
222
+ // Exactly these 7 keys, no more.
223
+ let expected: std::collections::BTreeSet<&str> = [
224
+ "ok",
226
225
  "team",
227
226
  "session_name",
227
+ "leader_attach_command",
228
+ "ready",
229
+ "not_ready",
230
+ "agents",
231
+ ]
232
+ .iter()
233
+ .copied()
234
+ .collect();
235
+ let actual: std::collections::BTreeSet<&str> = obj.keys().map(String::as_str).collect();
236
+ assert_eq!(
237
+ actual, expected,
238
+ "0.4.x compact must expose exactly 7 keys; got {actual:?}"
239
+ );
240
+ // Diagnostic keys must NOT leak into the default compact payload.
241
+ for forbidden in [
228
242
  "leader_topology",
229
243
  "is_external_leader",
230
- "leader_attach_command",
231
244
  "leader_client",
232
245
  "tmux_session_present",
233
246
  "leader_receiver",
234
- "agents",
235
247
  "agent_health",
236
248
  "tasks",
237
249
  "messages",
@@ -239,23 +251,20 @@ use super::*;
239
251
  "results",
240
252
  "latest_results",
241
253
  "coordinator",
254
+ "readiness",
242
255
  "reminder",
243
256
  "last_events",
244
257
  ] {
245
- assert!(obj.contains_key(key), "compact status missing key `{key}`");
258
+ assert!(
259
+ !obj.contains_key(forbidden),
260
+ "0.4.x: compact must NOT contain diagnostic key `{forbidden}` (--detail only)"
261
+ );
246
262
  }
247
- assert_eq!(
248
- obj.get("reminder").and_then(|v| v.as_str()),
249
- Some(crate::cli::STATUS_REMINDER)
250
- );
251
263
  // seeded agent surfaces through the projection.
252
264
  assert!(
253
265
  obj["agents"].as_object().unwrap().contains_key("a1"),
254
266
  "seeded agent a1 must appear in compact agents projection"
255
267
  );
256
- // compact bounds: queued_messages[:8] and latest_results[:5] -> arrays.
257
- assert!(obj["queued_messages"].is_array());
258
- assert!(obj["latest_results"].as_array().unwrap().len() <= 5);
259
268
  let _ = std::fs::remove_dir_all(&ws);
260
269
  }
261
270
 
@@ -291,14 +300,17 @@ use super::*;
291
300
  }
292
301
  crate::state::persist::save_runtime_state(&ws, &state).unwrap();
293
302
 
294
- let v = status_port::status(&ws, /*compact=*/ true, /*detail=*/ false).expect("status");
295
-
296
- assert_eq!(v["leader_topology"], json!("managed"));
297
- assert_eq!(v["is_external_leader"], json!(false));
298
- let attach = v["leader_attach_command"]
303
+ // 0.4.x: leader_topology / is_external_leader moved to --detail.
304
+ // leader_attach_command stays in the slim compact payload.
305
+ let slim = status_port::status(&ws, /*compact=*/ true, /*detail=*/ false).expect("status");
306
+ let attach = slim["leader_attach_command"]
299
307
  .as_str()
300
- .expect("managed status includes attach command");
308
+ .expect("compact still includes leader_attach_command");
301
309
  assert!(attach.contains("attach -t team-current:leader"), "{attach}");
310
+
311
+ let detail = status_port::status(&ws, /*compact=*/ false, /*detail=*/ true).expect("status detail");
312
+ assert_eq!(detail["leader_topology"], json!("managed"));
313
+ assert_eq!(detail["is_external_leader"], json!(false));
302
314
  let _ = std::fs::remove_dir_all(&ws);
303
315
  }
304
316
 
@@ -365,33 +377,48 @@ use super::*;
365
377
  }
366
378
  crate::state::persist::save_runtime_state(&ws, &state).unwrap();
367
379
 
368
- let v = status_port::status(&ws, /*compact=*/ true, /*detail=*/ false).expect("status");
369
-
370
- assert_eq!(v["leader_topology"], json!("managed"));
371
- assert_eq!(v["is_external_leader"], json!(false));
372
- let attach = v["leader_attach_command"]
380
+ // 0.4.x: topology/external markers moved to --detail; compact keeps attach.
381
+ let slim = status_port::status(&ws, /*compact=*/ true, /*detail=*/ false).expect("status");
382
+ let attach = slim["leader_attach_command"]
373
383
  .as_str()
374
384
  .expect("missing marker defaults to managed attach command");
375
385
  assert!(attach.contains("attach -t team-current:leader"), "{attach}");
386
+
387
+ let detail = status_port::status(&ws, /*compact=*/ false, /*detail=*/ true).expect("status detail");
388
+ assert_eq!(detail["leader_topology"], json!("managed"));
389
+ assert_eq!(detail["is_external_leader"], json!(false));
376
390
  let _ = std::fs::remove_dir_all(&ws);
377
391
  }
378
392
 
379
393
  #[test]
380
394
  fn status_port_status_detail_full_keeps_uncompacted_events() {
381
- // cmd_status --json --detail -> status_port::status(compact=false): the FULL dict
382
- // (queries.py:65-79) is returned WITHOUT compact_status truncation. Distinguishing
383
- // invariant: full result preserves `tasks` rows verbatim (not compact_task-projected),
384
- // so the seeded task's full row (incl. fields compact would drop) survives.
395
+ // 0.4.x: --detail (compact=false) preserves ALL diagnostic fields the
396
+ // compact slim payload drops. Pin the must-keep set so future
397
+ // refactors can't accidentally strip detail diagnostics.
385
398
  let ws = seed_status_workspace();
386
399
  let full = status_port::status(&ws, /*compact=*/ false, /*detail=*/ true)
387
400
  .expect("seeded fixture full status should project a value");
388
401
  let compact = status_port::status(&ws, /*compact=*/ true, /*detail=*/ false)
389
402
  .expect("seeded fixture compact status should project a value");
390
- // The CLI-owned invariant: detail=true => compact=false; the two projections MUST
391
- // differ in shape (full carries `messages`/`results` count maps + verbatim tasks).
392
403
  assert_ne!(full, compact, "detail (full) and default (compact) projections must differ");
393
404
  let full_obj = full.as_object().expect("full status is a dict");
394
- assert!(full_obj.contains_key("last_events"), "full status keeps last_events");
405
+ for key in [
406
+ "coordinator",
407
+ "readiness",
408
+ "leader_receiver",
409
+ "agent_health",
410
+ "tasks",
411
+ "messages",
412
+ "queued_messages",
413
+ "results",
414
+ "latest_results",
415
+ "last_events",
416
+ ] {
417
+ assert!(
418
+ full_obj.contains_key(key),
419
+ "0.4.x: --detail must preserve `{key}` (compact slimming escape hatch)"
420
+ );
421
+ }
395
422
  assert_eq!(full_obj["agents"].as_object().unwrap().len(), 1);
396
423
  let _ = std::fs::remove_dir_all(&ws);
397
424
  }
@@ -565,16 +592,15 @@ fn status_noncompact_coordinator_includes_ok() {
565
592
  }
566
593
 
567
594
  #[test]
568
- fn status_compact_coordinator_omits_ok() {
595
+ fn status_compact_omits_coordinator_entirely() {
596
+ // 0.4.x compact slim: the entire `coordinator` block moved to --detail.
597
+ // Compact folds coordinator health into the top-level `ready`/`not_ready`
598
+ // synthesis (`coordinator_not_running` / `coordinator_schema_not_ok`).
569
599
  let ws = seed_status_workspace();
570
600
  let v = status_port::status(&ws, /*compact=*/ true, /*detail=*/ false).expect("status");
571
- let coord = v.get("coordinator").and_then(|c| c.as_object()).expect("coordinator object");
572
- let keys: std::collections::BTreeSet<&str> = coord.keys().map(String::as_str).collect();
573
- let expected: std::collections::BTreeSet<&str> =
574
- ["metadata_ok", "pid", "schema_ok", "status"].into_iter().collect();
575
- assert_eq!(
576
- keys, expected,
577
- "compact coordinator key set must be EXACTLY {{metadata_ok,pid,schema_ok,status}} (no ok/metadata)"
601
+ assert!(
602
+ v.as_object().unwrap().get("coordinator").is_none(),
603
+ "0.4.x: compact must NOT include `coordinator`; reasons fold into not_ready"
578
604
  );
579
605
  let _ = std::fs::remove_dir_all(&ws);
580
606
  }
@@ -174,9 +174,14 @@ fn spine2_capture_missing_captures_session_id_for_missing_agent() {
174
174
  static N: AtomicU64 = AtomicU64::new(0);
175
175
  let tdir = std::env::temp_dir().join(format!("ta-rs-health-tx-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
176
176
  std::fs::create_dir_all(&tdir).unwrap();
177
+ // lane-046-capture-gap: Claude no-expected_session_id capture now requires
178
+ // positive_agent_id_match (TEAM_AGENT_ID=<id>) OR agent_path_match. Without
179
+ // a worker-identity signal, weak candidates can be leader transcripts and
180
+ // must not be attributed to a worker. The seeded transcript includes the
181
+ // TEAM_AGENT_ID marker for w1.
177
182
  std::fs::write(
178
183
  tdir.join("session.jsonl"),
179
- r#"{"type":"user","sessionId":"sess-found","cwd":"x","message":{"content":"hi"}}"#,
184
+ r#"{"type":"user","sessionId":"sess-found","cwd":"x","message":{"content":"TEAM_AGENT_ID=w1 hi"}}"#,
180
185
  )
181
186
  .unwrap();
182
187
  let agents = serde_json::json!({
@@ -506,6 +506,50 @@ impl Coordinator {
506
506
  // operators unable to tell whether the failure was zero candidates,
507
507
  // multiple same-cwd candidates, stale pre-spawn candidates, or
508
508
  // expected-id miss. Architect §4 fix #4.
509
+ // 0.4.6 Stage 3: emit throttled `provider.session.transcript_missing`
510
+ // events for agents that transitioned into the transcript_missing
511
+ // capture_state on this pass. The capture pass only flags a
512
+ // transition (prev_state != next_state), so the event fires at
513
+ // most once per (agent_id, spawn_epoch) per state change.
514
+ for missing in &report.transcript_missing {
515
+ let agent = state
516
+ .get("agents")
517
+ .and_then(|a| a.get(missing.agent_id.as_str()));
518
+ let pane_id = agent
519
+ .and_then(|a| a.get("pane_id"))
520
+ .and_then(Value::as_str);
521
+ let pane_pid = agent.and_then(|a| a.get("pane_pid")).and_then(Value::as_u64);
522
+ let provider = agent.and_then(|a| a.get("provider")).and_then(Value::as_str);
523
+ let session_id_in_argv = agent
524
+ .and_then(|a| a.get("session_id_in_argv"))
525
+ .and_then(Value::as_str);
526
+ let first_send_at = agent
527
+ .and_then(|a| a.get("first_send_at"))
528
+ .and_then(Value::as_str);
529
+ let last_result_at = agent
530
+ .and_then(|a| a.get("last_result_at"))
531
+ .and_then(Value::as_str);
532
+ let last_pane_output_at = agent
533
+ .and_then(|a| a.get("last_pane_output_at"))
534
+ .and_then(Value::as_str);
535
+ event_log.write(
536
+ "provider.session.transcript_missing",
537
+ serde_json::json!({
538
+ "agent_id": missing.agent_id,
539
+ "provider": provider,
540
+ "pane_id": pane_id,
541
+ "pane_pid": pane_pid,
542
+ "spawn_epoch": missing.spawn_epoch,
543
+ "expected_session_id": missing.expected_session_id,
544
+ "session_id_in_argv": session_id_in_argv,
545
+ "spawn_cwd": missing.spawn_cwd,
546
+ "candidate_count": missing.candidate_count,
547
+ "first_send_at": first_send_at,
548
+ "last_result_at": last_result_at,
549
+ "last_pane_output_at": last_pane_output_at,
550
+ }),
551
+ )?;
552
+ }
509
553
  for ambiguous in report.ambiguous {
510
554
  let candidate_count = report
511
555
  .candidate_count_by_agent
@@ -1612,16 +1656,7 @@ fn turn_state_wire(state: TurnState) -> &'static str {
1612
1656
  }
1613
1657
  }
1614
1658
 
1615
- fn provider_wire(provider: crate::model::enums::Provider) -> &'static str {
1616
- match provider {
1617
- crate::model::enums::Provider::Claude => "claude",
1618
- crate::model::enums::Provider::ClaudeCode => "claude_code",
1619
- crate::model::enums::Provider::Codex => "codex",
1620
- crate::model::enums::Provider::Copilot => "copilot",
1621
- crate::model::enums::Provider::GeminiCli => "gemini_cli",
1622
- crate::model::enums::Provider::Fake => "fake",
1623
- }
1624
- }
1659
+ use crate::provider::wire::provider_wire;
1625
1660
 
1626
1661
  #[derive(Debug, Clone)]
1627
1662
  struct AbnormalWatchAgent {
@@ -2365,17 +2400,7 @@ fn monotonic_seconds() -> f64 {
2365
2400
  }
2366
2401
  }
2367
2402
 
2368
- fn parse_provider(raw: &str) -> Option<crate::model::enums::Provider> {
2369
- match raw {
2370
- "claude" => Some(crate::model::enums::Provider::Claude),
2371
- "claude_code" => Some(crate::model::enums::Provider::ClaudeCode),
2372
- "codex" => Some(crate::model::enums::Provider::Codex),
2373
- "copilot" => Some(crate::model::enums::Provider::Copilot),
2374
- "gemini_cli" => Some(crate::model::enums::Provider::GeminiCli),
2375
- "fake" => Some(crate::model::enums::Provider::Fake),
2376
- _ => None,
2377
- }
2378
- }
2403
+ use crate::provider::wire::parse_provider;
2379
2404
 
2380
2405
  fn capture_window_target(
2381
2406
  agent: &Value,
@@ -9,28 +9,7 @@ use crate::provider::{Provider, RolloutPath, TurnState};
9
9
 
10
10
  use super::LeaderError;
11
11
 
12
- pub(crate) fn provider_wire(provider: Provider) -> &'static str {
13
- match provider {
14
- Provider::Claude => "claude",
15
- Provider::ClaudeCode => "claude_code",
16
- Provider::Codex => "codex",
17
- Provider::Copilot => "copilot",
18
- Provider::GeminiCli => "gemini_cli",
19
- Provider::Fake => "fake",
20
- }
21
- }
22
-
23
- pub(crate) fn parse_provider(s: &str) -> Option<Provider> {
24
- match s {
25
- "claude" => Some(Provider::Claude),
26
- "claude_code" => Some(Provider::ClaudeCode),
27
- "codex" => Some(Provider::Codex),
28
- "copilot" => Some(Provider::Copilot),
29
- "gemini_cli" => Some(Provider::GeminiCli),
30
- "fake" => Some(Provider::Fake),
31
- _ => None,
32
- }
33
- }
12
+ pub(crate) use crate::provider::wire::{parse_provider, provider_wire};
34
13
 
35
14
  pub(crate) fn turn_state_wire(state: TurnState) -> &'static str {
36
15
  match state {