@team-agent/installer 0.4.5 → 0.4.6

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.
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.6"
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.6"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -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
@@ -3956,18 +3956,45 @@ pub fn fork_agent_with_transport(
3956
3956
  let source_agent = find_spec_agent(&spec, source_agent_id).ok_or_else(|| {
3957
3957
  LifecycleError::RequirementUnmet(format!("unknown worker agent id: {source_agent_id}"))
3958
3958
  })?;
3959
- let session_id = state
3959
+ // 0.4.6 tuple-atomic contract (audit §Fork 修改清单, line 201): fork
3960
+ // must require the COMPLETE source tuple (session_id + rollout_path +
3961
+ // captured_at + captured_via) before treating the scalar session_id
3962
+ // as resumable truth. A row carrying only `session_id` is a partial
3963
+ // tuple (pre-0.4.6 bug source), and the native fork would attach to
3964
+ // a session that has no confirmed backing.
3965
+ let source_agent_state = state
3960
3966
  .get("agents")
3961
3967
  .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
3968
  .ok_or_else(|| {
3967
3969
  LifecycleError::Provider(format!(
3968
- "cannot fork {source_agent_id}: source session_id is missing"
3970
+ "cannot fork {source_agent_id}: source agent row not in state"
3969
3971
  ))
3970
3972
  })?;
3973
+ let tuple_field_ok = |field: &str| -> bool {
3974
+ source_agent_state
3975
+ .get(field)
3976
+ .and_then(|v| v.as_str())
3977
+ .is_some_and(|s| !s.is_empty())
3978
+ };
3979
+ let session_id_str = source_agent_state
3980
+ .get("session_id")
3981
+ .and_then(|v| v.as_str())
3982
+ .filter(|s| !s.is_empty());
3983
+ let rollout_path_str = source_agent_state
3984
+ .get("rollout_path")
3985
+ .and_then(|v| v.as_str())
3986
+ .filter(|s| !s.is_empty());
3987
+ if session_id_str.is_none()
3988
+ || rollout_path_str.is_none()
3989
+ || !tuple_field_ok("captured_at")
3990
+ || !tuple_field_ok("captured_via")
3991
+ {
3992
+ return Err(LifecycleError::Provider(format!(
3993
+ "cannot fork {source_agent_id}: source session backing is missing or incomplete \
3994
+ (session_id+rollout_path+captured_at+captured_via required)"
3995
+ )));
3996
+ }
3997
+ let session_id = crate::provider::SessionId::new(session_id_str.unwrap().to_string());
3971
3998
  let session_name = state
3972
3999
  .get("session_name")
3973
4000
  .and_then(|v| v.as_str())
@@ -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 {
@@ -1218,6 +1258,47 @@ pub(super) fn is_dynamic_agent(
1218
1258
  .is_some_and(|s| !s.is_empty())
1219
1259
  }
1220
1260
 
1261
+ /// 0.4.6 Stage 2: predict the tmux start mode BEFORE the spawn call, so
1262
+ /// the `provider.worker.spawn_argv` event can record what the spawn will
1263
+ /// actually do. Same logic as `tmux_start_mode_for_spawn` in agent.rs
1264
+ /// but driven by `(layout_placement, into_existing_session)` only — no
1265
+ /// dependency on SpawnedAgentWindow.
1266
+ pub(super) fn predict_tmux_start_mode(
1267
+ layout_placement: Option<&crate::lifecycle::launch::LayoutPlacement>,
1268
+ into_existing_session: bool,
1269
+ ) -> &'static str {
1270
+ if let Some(placement) = layout_placement {
1271
+ if placement.starts_window {
1272
+ if into_existing_session {
1273
+ "new-window"
1274
+ } else {
1275
+ "new-session"
1276
+ }
1277
+ } else {
1278
+ "split-window"
1279
+ }
1280
+ } else if into_existing_session {
1281
+ "new-window"
1282
+ } else {
1283
+ "new-session"
1284
+ }
1285
+ }
1286
+
1287
+ /// 0.4.6 Stage 2: read state.agents[agent_id].spawn_epoch from disk to
1288
+ /// stamp the spawn_argv event with the current cohort identifier. Returns
1289
+ /// 0 if the agent row / field is missing.
1290
+ pub(super) fn state_spawn_epoch_for_agent(workspace: &Path, agent_id: &AgentId) -> u64 {
1291
+ let Ok(state) = crate::state::persist::load_runtime_state(workspace) else {
1292
+ return 0;
1293
+ };
1294
+ state
1295
+ .get("agents")
1296
+ .and_then(|v| v.get(agent_id.as_str()))
1297
+ .and_then(|v| v.get("spawn_epoch"))
1298
+ .and_then(serde_json::Value::as_u64)
1299
+ .unwrap_or(0)
1300
+ }
1301
+
1221
1302
  #[cfg(test)]
1222
1303
  mod e36_transcript_backing_tests {
1223
1304
  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 {
@@ -1270,44 +1344,24 @@ fn mark_agent_respawned(
1270
1344
  // restart probes correctly refuse with `session_backing_store_missing`
1271
1345
  // (macmini bug-044 truth source for Claude/ClaudeCode).
1272
1346
  //
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).
1347
+ // 0.4.6 tuple-atomic contract (restart-persist-capture-contract-audit.md):
1348
+ // ALL providers restart never promotes a planned id into authoritative
1349
+ // `session_id`. Capture (provider scanner) is the only authoritative
1350
+ // writer. On Fresh / FreshAfterMissingRollout, clear the full tuple;
1351
+ // `persist_command_plan_state` below writes `_pending_session_id` as a
1352
+ // capture hint, and the provider scanner promotes that hint into the
1353
+ // tuple ONLY after it confirms backing (sqlite/transcript/.codex).
1354
+ //
1355
+ // This deletes the Copilot promotion that previously violated the
1356
+ // capture-only-writer rule (audit §Restart 当前违规 #1) and uses the
1357
+ // Claude family clear path uniformly. The Copilot scanner
1358
+ // (provider/adapter.rs:1217-1228) already gates expected-id with a
1359
+ // sqlite point-check, so no truth is lost.
1288
1360
  if matches!(
1289
1361
  restart_mode,
1290
1362
  StartMode::Fresh | StartMode::FreshAfterMissingRollout
1291
1363
  ) {
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
- }
1364
+ agent.insert("session_id".to_string(), serde_json::Value::Null);
1311
1365
  }
1312
1366
  crate::lifecycle::launch::persist_command_plan_state(agent, &spawn.plan, &spawn.profile_launch);
1313
1367
  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
  };