@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
@@ -2455,17 +2455,7 @@ fn spec_display_backend(spec: &Value) -> DisplayBackend {
2455
2455
  crate::lifecycle::display::resolve_display_backend(requested, None).backend
2456
2456
  }
2457
2457
 
2458
- fn parse_provider(raw: &str) -> Option<Provider> {
2459
- match raw {
2460
- "claude" => Some(Provider::Claude),
2461
- "claude_code" => Some(Provider::ClaudeCode),
2462
- "codex" => Some(Provider::Codex),
2463
- "copilot" => Some(Provider::Copilot),
2464
- "gemini_cli" => Some(Provider::GeminiCli),
2465
- "fake" => Some(Provider::Fake),
2466
- _ => None,
2467
- }
2468
- }
2458
+ use crate::provider::wire::parse_provider;
2469
2459
 
2470
2460
  fn parse_auth_mode(raw: &str) -> Option<AuthMode> {
2471
2461
  match raw {
@@ -3956,18 +3946,45 @@ pub fn fork_agent_with_transport(
3956
3946
  let source_agent = find_spec_agent(&spec, source_agent_id).ok_or_else(|| {
3957
3947
  LifecycleError::RequirementUnmet(format!("unknown worker agent id: {source_agent_id}"))
3958
3948
  })?;
3959
- let session_id = state
3949
+ // 0.4.6 tuple-atomic contract (audit §Fork 修改清单, line 201): fork
3950
+ // must require the COMPLETE source tuple (session_id + rollout_path +
3951
+ // captured_at + captured_via) before treating the scalar session_id
3952
+ // as resumable truth. A row carrying only `session_id` is a partial
3953
+ // tuple (pre-0.4.6 bug source), and the native fork would attach to
3954
+ // a session that has no confirmed backing.
3955
+ let source_agent_state = state
3960
3956
  .get("agents")
3961
3957
  .and_then(|v| v.get(source_agent_id.as_str()))
3962
- .and_then(|v| v.get("session_id"))
3963
- .and_then(|v| v.as_str())
3964
- .filter(|s| !s.is_empty())
3965
- .map(crate::provider::SessionId::new)
3966
3958
  .ok_or_else(|| {
3967
3959
  LifecycleError::Provider(format!(
3968
- "cannot fork {source_agent_id}: source session_id is missing"
3960
+ "cannot fork {source_agent_id}: source agent row not in state"
3969
3961
  ))
3970
3962
  })?;
3963
+ let tuple_field_ok = |field: &str| -> bool {
3964
+ source_agent_state
3965
+ .get(field)
3966
+ .and_then(|v| v.as_str())
3967
+ .is_some_and(|s| !s.is_empty())
3968
+ };
3969
+ let session_id_str = source_agent_state
3970
+ .get("session_id")
3971
+ .and_then(|v| v.as_str())
3972
+ .filter(|s| !s.is_empty());
3973
+ let rollout_path_str = source_agent_state
3974
+ .get("rollout_path")
3975
+ .and_then(|v| v.as_str())
3976
+ .filter(|s| !s.is_empty());
3977
+ if session_id_str.is_none()
3978
+ || rollout_path_str.is_none()
3979
+ || !tuple_field_ok("captured_at")
3980
+ || !tuple_field_ok("captured_via")
3981
+ {
3982
+ return Err(LifecycleError::Provider(format!(
3983
+ "cannot fork {source_agent_id}: source session backing is missing or incomplete \
3984
+ (session_id+rollout_path+captured_at+captured_via required)"
3985
+ )));
3986
+ }
3987
+ let session_id = crate::provider::SessionId::new(session_id_str.unwrap().to_string());
3971
3988
  let session_name = state
3972
3989
  .get("session_name")
3973
3990
  .and_then(|v| v.as_str())
@@ -894,17 +894,7 @@ fn effective_profile_or_agent_model<'a>(
894
894
  agent.model.as_deref().or_else(|| profile_model(values))
895
895
  }
896
896
 
897
- pub(crate) fn parse_provider(raw: &str) -> Option<Provider> {
898
- match raw {
899
- "claude" => Some(Provider::Claude),
900
- "claude_code" => Some(Provider::ClaudeCode),
901
- "codex" => Some(Provider::Codex),
902
- "copilot" => Some(Provider::Copilot),
903
- "gemini_cli" => Some(Provider::GeminiCli),
904
- "fake" => Some(Provider::Fake),
905
- _ => None,
906
- }
907
- }
897
+ pub(crate) use crate::provider::wire::parse_provider;
908
898
 
909
899
  pub(crate) fn parse_auth_mode(raw: &str) -> Option<AuthMode> {
910
900
  match raw {
@@ -441,6 +441,99 @@ fn tmux_start_mode_for_spawn(
441
441
  }
442
442
  }
443
443
 
444
+ // ═══════════════════════════════════════════════════════════════════════
445
+ // 0.4.6 Stage 1: reset/start clean-boundary helpers
446
+ // ═══════════════════════════════════════════════════════════════════════
447
+
448
+ /// Bounded polling: wait for the old pane to become unreachable AND, when
449
+ /// possible, for the old pane_pid to exit. Returns a JSON evidence blob
450
+ /// recording the observed transition for events.
451
+ pub(super) fn drain_old_pane_and_pid(
452
+ transport: &dyn crate::transport::Transport,
453
+ old_pane: Option<&crate::transport::PaneId>,
454
+ old_pid: Option<u32>,
455
+ ) -> serde_json::Value {
456
+ const DRAIN_MAX_MS: u64 = 1500;
457
+ const DRAIN_POLL_MS: u64 = 50;
458
+ let start = std::time::Instant::now();
459
+ let mut pane_dead = old_pane.is_none();
460
+ let mut pid_dead = old_pid.is_none();
461
+ while start.elapsed().as_millis() < DRAIN_MAX_MS as u128 {
462
+ if !pane_dead {
463
+ if let Some(pane) = old_pane {
464
+ if !agent_pane_live_by_id(transport, pane) {
465
+ pane_dead = true;
466
+ }
467
+ }
468
+ }
469
+ if !pid_dead {
470
+ if let Some(pid) = old_pid {
471
+ if !pid_is_alive(pid) {
472
+ pid_dead = true;
473
+ }
474
+ }
475
+ }
476
+ if pane_dead && pid_dead {
477
+ break;
478
+ }
479
+ std::thread::sleep(std::time::Duration::from_millis(DRAIN_POLL_MS));
480
+ }
481
+ serde_json::json!({
482
+ "old_pane_id": old_pane.map(|p| p.as_str()),
483
+ "old_pane_pid": old_pid,
484
+ "old_pane_dead": pane_dead,
485
+ "old_pid_dead": pid_dead,
486
+ "drain_elapsed_ms": start.elapsed().as_millis() as u64,
487
+ })
488
+ }
489
+
490
+ /// Best-effort liveness probe for a pid. Returns true if the pid exists
491
+ /// on Unix (signal 0). On other platforms, returns true conservatively.
492
+ pub(super) fn pid_is_alive(pid: u32) -> bool {
493
+ #[cfg(unix)]
494
+ {
495
+ // SAFETY: kill(pid, 0) checks if pid exists without sending a signal.
496
+ let ret = unsafe { libc::kill(pid as i32, 0) };
497
+ if ret == 0 {
498
+ return true;
499
+ }
500
+ // EPERM also means the process exists (we just can't signal it).
501
+ let err = std::io::Error::last_os_error();
502
+ matches!(err.raw_os_error(), Some(libc::EPERM))
503
+ }
504
+ #[cfg(not(unix))]
505
+ {
506
+ let _ = pid;
507
+ true
508
+ }
509
+ }
510
+
511
+ /// Read state.agents[agent_id].pane_pid (u32) from the runtime state.
512
+ pub(super) fn state_pane_pid(state: &serde_json::Value, agent_id: &AgentId) -> Option<u32> {
513
+ state
514
+ .get("agents")
515
+ .and_then(|v| v.get(agent_id.as_str()))
516
+ .and_then(|v| v.get("pane_pid"))
517
+ .and_then(serde_json::Value::as_u64)
518
+ .map(|p| p as u32)
519
+ }
520
+
521
+ /// Read + increment state.agents[agent_id].spawn_epoch. The epoch is a
522
+ /// monotonic counter persisted with the agent row that uniquely tags each
523
+ /// fresh-start / reset / restart cycle. Subsequent capture / event /
524
+ /// status logic dispatches on `(team_key, agent_id, spawn_epoch,
525
+ /// pane_pid, expected_session_id)` to avoid attributing a stale prior
526
+ /// fresh attempt to the current process.
527
+ pub(super) fn next_spawn_epoch(state: &serde_json::Value, agent_id: &AgentId) -> u64 {
528
+ let current = state
529
+ .get("agents")
530
+ .and_then(|v| v.get(agent_id.as_str()))
531
+ .and_then(|v| v.get("spawn_epoch"))
532
+ .and_then(serde_json::Value::as_u64)
533
+ .unwrap_or(0);
534
+ current.saturating_add(1)
535
+ }
536
+
444
537
  /// `stop_agent(workspace, agent_id, team)`(`lifecycle/operations.py:62`)。
445
538
  /// owner-gate → kill window → **同时关显示** → 写 state。
446
539
  pub fn stop_agent(
@@ -533,6 +626,22 @@ pub(super) fn stop_agent_at_paths(
533
626
  let _ = write_stop_window_failed_event(workspace, agent_id, &target_str, &stderr);
534
627
  return Err(LifecycleError::Transport(format!("failed to stop agent {agent_id}: {stderr}")));
535
628
  }
629
+ // 0.4.6 Stage 1: drain-and-prove. The pre-fix path trusted `kill_pane`'s
630
+ // success return without waiting for the pane / pid to actually exit.
631
+ // This created a window where reset/start spawned a new worker while
632
+ // the old Claude process + tty + provider state were still alive,
633
+ // leading to the macmini "running but not capturable" failure mode.
634
+ //
635
+ // Poll bounded time for old pane to become unreachable. If it stays
636
+ // reachable after the budget, emit an event but do NOT block — the
637
+ // subsequent spawn boundary check (new pane_id != old pane_id) is
638
+ // the final safety net.
639
+ let drain_evidence = drain_old_pane_and_pid(
640
+ transport,
641
+ pane_id.as_ref(),
642
+ state_pane_pid(&state, agent_id),
643
+ );
644
+ let _ = write_stop_drain_event(workspace, agent_id, &target_str, &drain_evidence);
536
645
  }
537
646
  close_agent_display(&mut state, agent_id);
538
647
  mark_agent_stopped(&mut state, agent_id, agent, &window)?;
@@ -631,6 +740,18 @@ fn mark_agent_started(
631
740
  "spawn_cwd".to_string(),
632
741
  serde_json::json!(spawn.spawn_cwd.to_string_lossy().to_string()),
633
742
  );
743
+ // 0.4.6 Stage 1+2: ensure spawn_epoch is present after every start.
744
+ // reset_agent_at_paths bumps it before start; non-reset starts
745
+ // initialise to 1 (or preserve existing) so capture/event paths can
746
+ // always dispatch on (team_key, agent_id, spawn_epoch, ...).
747
+ let preserved_epoch = agent
748
+ .get("spawn_epoch")
749
+ .and_then(serde_json::Value::as_u64)
750
+ .filter(|n| *n > 0);
751
+ agent.insert(
752
+ "spawn_epoch".to_string(),
753
+ serde_json::json!(preserved_epoch.unwrap_or(1)),
754
+ );
634
755
  // Issue 2 (Round 3b gate review §6): persist the resolved owner_team_id
635
756
  // so future restart/start cycles read it directly from the agent row.
636
757
  if let Some(ref team_id) = spawn.owner_team_id {
@@ -763,6 +884,19 @@ fn reset_agent_at_paths(
763
884
  let mut state = resolve_team_scoped_state_or_refuse(workspace, team)?;
764
885
  let spec = load_team_spec(spec_workspace)?;
765
886
  discard_agent_session_fields(&mut state, agent_id)?;
887
+ // 0.4.6 Stage 1: bump spawn_epoch on every reset. The new epoch is
888
+ // visible to subsequent capture/event/status paths so they can
889
+ // attribute observations to the current process cohort (not a stale
890
+ // prior fresh attempt).
891
+ let new_epoch = next_spawn_epoch(&state, agent_id);
892
+ if let Some(obj) = state
893
+ .get_mut("agents")
894
+ .and_then(serde_json::Value::as_object_mut)
895
+ .and_then(|agents| agents.get_mut(agent_id.as_str()))
896
+ .and_then(serde_json::Value::as_object_mut)
897
+ {
898
+ obj.insert("spawn_epoch".to_string(), serde_json::json!(new_epoch));
899
+ }
766
900
  let team_key = restart_projection_team_key(&state, team);
767
901
  sync_restart_team_projections(&mut state, &team_key);
768
902
  // golden operations.py (reset): save_team_scoped_state on the team projection — same multi-team
@@ -966,6 +1100,32 @@ fn write_stop_complete_event(
966
1100
  Ok(())
967
1101
  }
968
1102
 
1103
+ /// 0.4.6 Stage 1: drain evidence for the old pane+pid after stop. Fires
1104
+ /// during reset/stop so operators can see whether the prior worker really
1105
+ /// went away or stuck around inside the resp budget.
1106
+ pub(super) fn write_stop_drain_event(
1107
+ workspace: &Path,
1108
+ agent_id: &AgentId,
1109
+ target: &str,
1110
+ drain: &serde_json::Value,
1111
+ ) -> Result<(), LifecycleError> {
1112
+ let mut payload = serde_json::Map::new();
1113
+ payload.insert("agent_id".to_string(), serde_json::json!(agent_id.as_str()));
1114
+ payload.insert("target".to_string(), serde_json::json!(target));
1115
+ if let Some(obj) = drain.as_object() {
1116
+ for (k, v) in obj {
1117
+ payload.insert(k.clone(), v.clone());
1118
+ }
1119
+ }
1120
+ crate::event_log::EventLog::new(workspace)
1121
+ .write(
1122
+ "stop_agent.drain",
1123
+ serde_json::Value::Object(payload),
1124
+ )
1125
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
1126
+ Ok(())
1127
+ }
1128
+
969
1129
  fn write_stop_window_failed_event(
970
1130
  workspace: &Path,
971
1131
  agent_id: &AgentId,
@@ -168,6 +168,46 @@ pub(super) fn spawn_agent_window(
168
168
  // > workspace; state-based override is silently dropped.
169
169
  let spawn_cwd = spawn_cwd_override.unwrap_or(workspace);
170
170
  let env_unset: Vec<String> = profile_launch.env_unset.iter().cloned().collect();
171
+
172
+ // 0.4.6 Stage 2: write actual spawn plan event BEFORE invoking the
173
+ // transport spawn. Mirrors `launch.rs:359-380` (the reference impl)
174
+ // so reset/start/restart fresh produces the same truth source as
175
+ // quick-start. Any "argv had --session-id but didn't take effect"
176
+ // failure can now be diagnosed from events.jsonl — the recorded
177
+ // expected_session_id == state._pending_session_id (after
178
+ // mark_agent_started persists the same plan tuple).
179
+ {
180
+ let session_id_in_argv = plan
181
+ .argv
182
+ .iter()
183
+ .position(|a| a == "--session-id")
184
+ .and_then(|i| plan.argv.get(i + 1))
185
+ .cloned();
186
+ let env_overlay_keys: Vec<&String> = env.keys().collect();
187
+ let tmux_start_mode_pre_spawn = predict_tmux_start_mode(
188
+ layout_placement,
189
+ into_existing_session,
190
+ );
191
+ let spawn_epoch = state_spawn_epoch_for_agent(workspace, agent_id);
192
+ let event_log = crate::event_log::EventLog::new(workspace);
193
+ let _ = event_log.write(
194
+ "provider.worker.spawn_argv",
195
+ serde_json::json!({
196
+ "agent_id": agent_id.as_str(),
197
+ "provider": provider,
198
+ "argv": plan.argv,
199
+ "session_id_in_argv": session_id_in_argv,
200
+ "expected_session_id": plan.expected_session_id.as_ref().map(|s| s.as_str()),
201
+ "spawn_cwd": spawn_cwd.to_string_lossy(),
202
+ "env_overlay_keys": env_overlay_keys,
203
+ "env_unset": env_unset,
204
+ "tmux_start_mode": tmux_start_mode_pre_spawn,
205
+ "spawn_epoch": spawn_epoch,
206
+ "source": "restart",
207
+ }),
208
+ );
209
+ }
210
+
171
211
  let result = if let Some(placement) = layout_placement {
172
212
  if placement.starts_window {
173
213
  if into_existing_session {
@@ -873,28 +913,7 @@ pub(super) fn agent_window(agent: &serde_json::Value, agent_id: &AgentId) -> Str
873
913
  .to_string()
874
914
  }
875
915
 
876
- pub(super) fn parse_provider(raw: &str) -> Option<Provider> {
877
- match raw {
878
- "claude" => Some(Provider::Claude),
879
- "claude_code" => Some(Provider::ClaudeCode),
880
- "codex" => Some(Provider::Codex),
881
- "copilot" => Some(Provider::Copilot),
882
- "gemini_cli" => Some(Provider::GeminiCli),
883
- "fake" => Some(Provider::Fake),
884
- _ => None,
885
- }
886
- }
887
-
888
- pub(super) fn provider_wire(provider: Provider) -> &'static str {
889
- match provider {
890
- Provider::Claude => "claude",
891
- Provider::ClaudeCode => "claude_code",
892
- Provider::Codex => "codex",
893
- Provider::Copilot => "copilot",
894
- Provider::GeminiCli => "gemini_cli",
895
- Provider::Fake => "fake",
896
- }
897
- }
916
+ pub(super) use crate::provider::wire::{parse_provider, provider_wire};
898
917
 
899
918
  pub(super) fn parse_auth_mode(raw: &str) -> Option<AuthMode> {
900
919
  match raw {
@@ -1218,6 +1237,47 @@ pub(super) fn is_dynamic_agent(
1218
1237
  .is_some_and(|s| !s.is_empty())
1219
1238
  }
1220
1239
 
1240
+ /// 0.4.6 Stage 2: predict the tmux start mode BEFORE the spawn call, so
1241
+ /// the `provider.worker.spawn_argv` event can record what the spawn will
1242
+ /// actually do. Same logic as `tmux_start_mode_for_spawn` in agent.rs
1243
+ /// but driven by `(layout_placement, into_existing_session)` only — no
1244
+ /// dependency on SpawnedAgentWindow.
1245
+ pub(super) fn predict_tmux_start_mode(
1246
+ layout_placement: Option<&crate::lifecycle::launch::LayoutPlacement>,
1247
+ into_existing_session: bool,
1248
+ ) -> &'static str {
1249
+ if let Some(placement) = layout_placement {
1250
+ if placement.starts_window {
1251
+ if into_existing_session {
1252
+ "new-window"
1253
+ } else {
1254
+ "new-session"
1255
+ }
1256
+ } else {
1257
+ "split-window"
1258
+ }
1259
+ } else if into_existing_session {
1260
+ "new-window"
1261
+ } else {
1262
+ "new-session"
1263
+ }
1264
+ }
1265
+
1266
+ /// 0.4.6 Stage 2: read state.agents[agent_id].spawn_epoch from disk to
1267
+ /// stamp the spawn_argv event with the current cohort identifier. Returns
1268
+ /// 0 if the agent row / field is missing.
1269
+ pub(super) fn state_spawn_epoch_for_agent(workspace: &Path, agent_id: &AgentId) -> u64 {
1270
+ let Ok(state) = crate::state::persist::load_runtime_state(workspace) else {
1271
+ return 0;
1272
+ };
1273
+ state
1274
+ .get("agents")
1275
+ .and_then(|v| v.get(agent_id.as_str()))
1276
+ .and_then(|v| v.get("spawn_epoch"))
1277
+ .and_then(serde_json::Value::as_u64)
1278
+ .unwrap_or(0)
1279
+ }
1280
+
1221
1281
  #[cfg(test)]
1222
1282
  mod e36_transcript_backing_tests {
1223
1283
  use super::*;
@@ -538,9 +538,14 @@ pub fn restart_with_transport_with_session_convergence_deadline(
538
538
  survivors.push(decision);
539
539
  continue;
540
540
  }
541
- // Backing went missing between preflight and post-spawn.
542
- // Clear stale capture fields, keep session_id, mark
543
- // _pending_session_id, run a bounded convergence pass.
541
+ // 0.4.6 tuple-atomic contract: backing went missing between
542
+ // preflight and post-spawn. Clear the FULL authoritative tuple
543
+ // (including session_id) and persist the old provider id only
544
+ // in `_pending_session_id` as a capture hint. Pre-0.4.6 kept
545
+ // session_id alive while removing siblings — that left a
546
+ // partial tuple that persist-layer backfill could later
547
+ // resurrect into "looks captured" or that classify_restart
548
+ // would refuse with `session_backing_store_missing`.
544
549
  if let Some(agents) = state
545
550
  .pointer_mut("/agents")
546
551
  .and_then(serde_json::Value::as_object_mut)
@@ -549,10 +554,15 @@ pub fn restart_with_transport_with_session_convergence_deadline(
549
554
  .get_mut(decision.agent_id.as_str())
550
555
  .and_then(serde_json::Value::as_object_mut)
551
556
  {
552
- agent_obj.remove("rollout_path");
553
- agent_obj.remove("captured_at");
554
- agent_obj.remove("captured_via");
555
- agent_obj.remove("attribution_confidence");
557
+ for field in [
558
+ "session_id",
559
+ "rollout_path",
560
+ "captured_at",
561
+ "captured_via",
562
+ "attribution_confidence",
563
+ ] {
564
+ agent_obj.remove(field);
565
+ }
556
566
  agent_obj.insert(
557
567
  "_pending_session_id".to_string(),
558
568
  serde_json::json!(session_id.as_str()),
@@ -764,6 +774,35 @@ fn repair_resume_sessions_from_event_log(
764
774
  .and_then(serde_json::Value::as_str)
765
775
  .filter(|path| !path.is_empty())
766
776
  .map(str::to_string);
777
+ // 0.4.6 tuple-atomic contract (audit §Restart 修改清单, line 175):
778
+ // capture-domain repair must yield a COMPLETE authoritative tuple
779
+ // (session_id + rollout_path + captured_at + captured_via) before
780
+ // restart writes it back into agent state. A partial repair would
781
+ // be persist-domain truth synthesis through the restart hook,
782
+ // exactly what the contract forbids. Reject incomplete repairs;
783
+ // capture stays Stage-1 pending on next coordinator tick.
784
+ let captured_at_ok = repaired
785
+ .get("captured_at")
786
+ .is_some_and(|v| !v.is_null() && v.as_str().is_some_and(|s| !s.is_empty()));
787
+ let captured_via_ok = repaired
788
+ .get("captured_via")
789
+ .is_some_and(|v| !v.is_null() && v.as_str().is_some_and(|s| !s.is_empty()));
790
+ if session_id.is_none() || rollout_path.is_none() || !captured_at_ok || !captured_via_ok {
791
+ crate::event_log::EventLog::new(workspace)
792
+ .write(
793
+ "resume.session_repair_refused_incomplete_tuple",
794
+ serde_json::json!({
795
+ "agent_id": agent_id,
796
+ "provider": provider_wire(provider),
797
+ "session_id": session_id,
798
+ "rollout_path": rollout_path,
799
+ "captured_at_ok": captured_at_ok,
800
+ "captured_via_ok": captured_via_ok,
801
+ }),
802
+ )
803
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
804
+ continue;
805
+ }
767
806
  if let Some(agent) = state
768
807
  .get_mut("agents")
769
808
  .and_then(serde_json::Value::as_object_mut)
@@ -794,7 +833,7 @@ fn claimed_session_ids_except(
794
833
  state: &serde_json::Value,
795
834
  current_agent_id: &str,
796
835
  ) -> std::collections::BTreeSet<String> {
797
- state
836
+ let mut keys: std::collections::BTreeSet<String> = state
798
837
  .get("agents")
799
838
  .and_then(serde_json::Value::as_object)
800
839
  .map(|agents| {
@@ -810,7 +849,42 @@ fn claimed_session_ids_except(
810
849
  })
811
850
  .collect()
812
851
  })
813
- .unwrap_or_default()
852
+ .unwrap_or_default();
853
+ // E57 (lane-046-capture-gap): restart's resume_session_repair postflight
854
+ // path must NOT reassign the leader's own provider session to a worker.
855
+ // The capture-layer allocator (session_capture::claimed_provider_session_keys)
856
+ // already excludes leader_receiver/team_owner; mirror that here for the
857
+ // event-log recovery path, otherwise a fresh restart can pull the leader
858
+ // session_id out of events.jsonl and write it onto a worker
859
+ // (release-engineer / any worker with no captured session). Scan
860
+ // state.leader_receiver, state.team_owner, and the same fields under
861
+ // state.teams.<key>.
862
+ push_leader_session_ids(&mut keys, state);
863
+ if let Some(teams) = state.get("teams").and_then(serde_json::Value::as_object) {
864
+ for team_state in teams.values() {
865
+ push_leader_session_ids(&mut keys, team_state);
866
+ }
867
+ }
868
+ keys
869
+ }
870
+
871
+ fn push_leader_session_ids(
872
+ keys: &mut std::collections::BTreeSet<String>,
873
+ scope: &serde_json::Value,
874
+ ) {
875
+ for anchor in ["leader_receiver", "team_owner"] {
876
+ if let Some(node) = scope.get(anchor) {
877
+ for field in ["session_id", "provider_session_id"] {
878
+ if let Some(session_id) = node
879
+ .get(field)
880
+ .and_then(serde_json::Value::as_str)
881
+ .filter(|s| !s.is_empty())
882
+ {
883
+ keys.insert(session_id.to_string());
884
+ }
885
+ }
886
+ }
887
+ }
814
888
  }
815
889
 
816
890
  fn session_convergence_deadline(requested_ms: Option<u64>) -> std::time::Duration {
@@ -1109,13 +1183,17 @@ fn try_autobind_leader_after_restart(
1109
1183
  .pointer("/leader_receiver/provider")
1110
1184
  .and_then(serde_json::Value::as_str)
1111
1185
  })
1112
- .and_then(|s| match s {
1113
- "codex" => Some(crate::model::enums::Provider::Codex),
1114
- "claude" | "claude_code" | "claude-code" => {
1115
- Some(crate::model::enums::Provider::ClaudeCode)
1116
- }
1117
- "copilot" => Some(crate::model::enums::Provider::Copilot),
1118
- _ => None,
1186
+ .and_then(|s| {
1187
+ // Legacy auto-attach: collapse any claude variant to ClaudeCode
1188
+ // (this site historically treated `Claude` and `ClaudeCode` as
1189
+ // the same attach target). Wire-format `parse_provider` keeps
1190
+ // them distinct everywhere else.
1191
+ crate::provider::wire::parse_provider(s).map(|p| match p {
1192
+ crate::model::enums::Provider::Claude => {
1193
+ crate::model::enums::Provider::ClaudeCode
1194
+ }
1195
+ other => other,
1196
+ })
1119
1197
  })
1120
1198
  .unwrap_or(crate::model::enums::Provider::ClaudeCode);
1121
1199
  let team_str = team;
@@ -1270,44 +1348,24 @@ fn mark_agent_respawned(
1270
1348
  // restart probes correctly refuse with `session_backing_store_missing`
1271
1349
  // (macmini bug-044 truth source for Claude/ClaudeCode).
1272
1350
  //
1273
- // Provider policy (per architect §6 residual risk):
1274
- // * Claude / ClaudeCode: do NOT promote. Persist pending only via
1275
- // `persist_command_plan_state` below (_pending_session_id field).
1276
- // Authoritative `session_id` is written ONLY by session capture when
1277
- // a real backing transcript is found on disk. Stale `session_id` from
1278
- // a previous session is cleared here (fresh restart means old backing
1279
- // is gone or never existed).
1280
- // * Copilot: KEEP promotion. Copilot has stronger expected-id semantics
1281
- // via its SQLite store, and the test
1282
- // `restart_allow_fresh_copilot_persists_expected_session_id` pins the
1283
- // existing behaviour. (Future: gate behind a provider-policy backing
1284
- // assertion when Copilot store probe is implemented.)
1285
- // * Codex: never reaches here with `expected_session_id=Some` — Codex
1286
- // command planning returns `None` (provider/adapter.rs:477-495,
1287
- // 0.3.31 correction).
1351
+ // 0.4.6 tuple-atomic contract (restart-persist-capture-contract-audit.md):
1352
+ // ALL providers restart never promotes a planned id into authoritative
1353
+ // `session_id`. Capture (provider scanner) is the only authoritative
1354
+ // writer. On Fresh / FreshAfterMissingRollout, clear the full tuple;
1355
+ // `persist_command_plan_state` below writes `_pending_session_id` as a
1356
+ // capture hint, and the provider scanner promotes that hint into the
1357
+ // tuple ONLY after it confirms backing (sqlite/transcript/.codex).
1358
+ //
1359
+ // This deletes the Copilot promotion that previously violated the
1360
+ // capture-only-writer rule (audit §Restart 当前违规 #1) and uses the
1361
+ // Claude family clear path uniformly. The Copilot scanner
1362
+ // (provider/adapter.rs:1217-1228) already gates expected-id with a
1363
+ // sqlite point-check, so no truth is lost.
1288
1364
  if matches!(
1289
1365
  restart_mode,
1290
1366
  StartMode::Fresh | StartMode::FreshAfterMissingRollout
1291
1367
  ) {
1292
- let provider = agent
1293
- .get("provider")
1294
- .and_then(serde_json::Value::as_str)
1295
- .map(str::to_ascii_lowercase);
1296
- let is_claude_family = matches!(
1297
- provider.as_deref(),
1298
- Some("claude") | Some("claude_code") | Some("claude-code") | Some("claudecode")
1299
- );
1300
- if is_claude_family {
1301
- // Claude family: do not promote. Clear stale session_id.
1302
- agent.insert("session_id".to_string(), serde_json::Value::Null);
1303
- } else if let Some(session_id) = spawn.plan.expected_session_id.as_ref() {
1304
- // Copilot (and any future provider with provider-store backing
1305
- // semantics): promote as before.
1306
- agent.insert(
1307
- "session_id".to_string(),
1308
- serde_json::json!(session_id.as_str()),
1309
- );
1310
- }
1368
+ agent.insert("session_id".to_string(), serde_json::Value::Null);
1311
1369
  }
1312
1370
  crate::lifecycle::launch::persist_command_plan_state(agent, &spawn.plan, &spawn.profile_launch);
1313
1371
  persist_effective_approval_policy_for_restart(agent, safety);
@@ -221,6 +221,25 @@ pub(crate) fn classify_restart_plan_with_resume_validation(
221
221
  }
222
222
  _ => (true, Vec::new()),
223
223
  };
224
+ // 0.4.7 partial-resume: when a worker has NEVER been captured (no
225
+ // session_id AND first_send_at is Absent), it is structurally
226
+ // non-resumable — there is no context to lose, so auto-fresh is
227
+ // safe even without --allow-fresh. This prevents a single
228
+ // never-captured role (e.g. MCP-only worker that the leader never
229
+ // messaged) from blocking restart of the other 6 roles that DO
230
+ // have complete resume tuples.
231
+ //
232
+ // The "never_captured" predicate is the conjunction:
233
+ // * session_id is None (no provider session bound), AND
234
+ // * first_send_at is Absent (leader has never injected a
235
+ // message — so we never armed the capture path either).
236
+ //
237
+ // If session_id is None but first_send_at is Valid, that's the
238
+ // "received message but session not captured" bug state — keep
239
+ // the Refuse so we never silently drop context (architect rule:
240
+ // "绝不静默 fresh").
241
+ let never_captured =
242
+ session_id.is_none() && matches!(first_send_at_state, FirstSendAtState::Absent);
224
243
  let decision = if session_id.is_some() && provider_can_resume && resume_backing_exists {
225
244
  ResumeDecision::Resume
226
245
  } else if session_id.is_some() && allow_fresh {
@@ -229,6 +248,10 @@ pub(crate) fn classify_restart_plan_with_resume_validation(
229
248
  ResumeDecision::Refuse
230
249
  } else if allow_fresh {
231
250
  ResumeDecision::FreshStart
251
+ } else if never_captured {
252
+ // No session_id + never captured → safe to auto-fresh without
253
+ // --allow-fresh. No context to lose.
254
+ ResumeDecision::FreshStart
232
255
  } else {
233
256
  ResumeDecision::Refuse
234
257
  };