@team-agent/installer 0.4.7 → 0.4.9

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.7"
569
+ version = "0.4.9"
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.7"
12
+ version = "0.4.9"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -226,6 +226,33 @@ pub fn cmd_status_for_team(args: &StatusArgs, team: Option<&str>) -> Result<CmdR
226
226
  "status --summary does not accept an agent argument".to_string(),
227
227
  ));
228
228
  }
229
+ // S4QR-001 (0.4.8): selected-team ambiguity gate. status is a selected-team
230
+ // command: when the workspace has 2+ alive teams and no --team was passed,
231
+ // refuse instead of silently defaulting to the active team. Uses the same
232
+ // CommandScope::resolve helper as refuse_if_multi_alive_team_missing_scope
233
+ // (cli/emit.rs:964-979) so the destructive-command ambiguity gate and the
234
+ // selected-team-read ambiguity gate share a single source of truth.
235
+ if team.is_none() {
236
+ let scope = crate::state::paths::CommandScope::resolve(&args.workspace, None);
237
+ if scope.is_ambiguous() {
238
+ let candidates: Vec<String> = scope.candidates().to_vec();
239
+ let message = format!(
240
+ "status: workspace has multiple alive teams ({}); pass `--team <key>` to choose one",
241
+ candidates.join(", ")
242
+ );
243
+ if args.json {
244
+ let payload = serde_json::json!({
245
+ "ok": false,
246
+ "status": "refused",
247
+ "reason": "team_target_ambiguous",
248
+ "candidates": candidates,
249
+ "message": message,
250
+ });
251
+ return Ok(CmdResult::from_json(payload, args.json));
252
+ }
253
+ return Err(CliError::Usage(message));
254
+ }
255
+ }
229
256
  let selected = match crate::state::selector::resolve_active_team(
230
257
  &args.workspace,
231
258
  team,
@@ -33,6 +33,39 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
33
33
  "issue": "leader_not_attached",
34
34
  "action": "attach or claim a leader receiver before sending work",
35
35
  }));
36
+ } else {
37
+ // 0.4.x (CR R2 P0): leader provider health reconciliation. The
38
+ // leader_receiver may be marked `attached` (pane addressable) but the
39
+ // provider process has exited — pane fell back to shell with the exit
40
+ // marker. Distinguish `leader_provider_exited` from `attached` so
41
+ // status/diagnose surfaces the real state.
42
+ if let Some((pane_id, provider_label)) = leader_pane_and_provider(state) {
43
+ let health = crate::leader::leader_provider_health(
44
+ backend,
45
+ &crate::transport::PaneId::new(pane_id),
46
+ &provider_label,
47
+ );
48
+ match health {
49
+ crate::leader::LeaderProviderHealth::ProviderExited => {
50
+ issues.push(json!("leader_provider_exited"));
51
+ repairs.push(json!({
52
+ "issue": "leader_provider_exited",
53
+ "action": format!(
54
+ "leader pane fell back to shell — provider `{provider_label}` exited; \
55
+ relaunch with `team-agent {provider_label}` to restart the provider"
56
+ ),
57
+ }));
58
+ }
59
+ crate::leader::LeaderProviderHealth::Unreachable => {
60
+ issues.push(json!("leader_provider_unreachable"));
61
+ repairs.push(json!({
62
+ "issue": "leader_provider_unreachable",
63
+ "action": "leader pane is dead — relaunch the leader",
64
+ }));
65
+ }
66
+ crate::leader::LeaderProviderHealth::Alive => {}
67
+ }
68
+ }
36
69
  }
37
70
 
38
71
  if let Some(session_name) = state.get("session_name").and_then(Value::as_str).filter(|s| !s.is_empty()) {
@@ -62,6 +95,25 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
62
95
  (Value::Array(issues), Value::Array(repairs))
63
96
  }
64
97
 
98
+ /// 0.4.x (CR R2): pull (pane_id, provider_label) from leader_receiver. Used
99
+ /// by `leader_provider_health` reconcile in diagnose. Returns None when the
100
+ /// leader is not attached or any field is missing.
101
+ fn leader_pane_and_provider(state: &Value) -> Option<(String, String)> {
102
+ let receiver = state.get("leader_receiver")?;
103
+ let pane_id = receiver
104
+ .get("pane_id")
105
+ .and_then(Value::as_str)
106
+ .filter(|s| !s.is_empty())?
107
+ .to_string();
108
+ let provider = receiver
109
+ .get("provider")
110
+ .and_then(Value::as_str)
111
+ .filter(|s| !s.is_empty())
112
+ .unwrap_or("claude")
113
+ .to_string();
114
+ Some((pane_id, provider))
115
+ }
116
+
65
117
  fn leader_receiver_attached(state: &Value) -> bool {
66
118
  let Some(receiver) = state.get("leader_receiver") else {
67
119
  return false;
@@ -1685,6 +1685,16 @@ pub mod lifecycle_port {
1685
1685
  /// matching cannot reap the leader — including when ANOTHER team's bare shutdown
1686
1686
  /// runs, where the leader is never in the invoker's ancestry.
1687
1687
  ///
1688
+ /// 0.4.x (CR R3): leader shell wrapper interaction. The leader pane's
1689
+ /// controlling process is the `sh -lc "...; exec ${SHELL} -l"` that
1690
+ /// runs Claude as a CHILD. When Claude exits and the shell wrapper falls
1691
+ /// back via `exec ${SHELL} -l`, the controlling PID is REPLACED in-place
1692
+ /// by the interactive shell (same pane_pid). Because this function
1693
+ /// protects by `pane.pane_pid` (Source 1 & 2), the fallback interactive
1694
+ /// shell is already covered by the same protection set — shutdown will
1695
+ /// NOT treat the fallback shell as a stray process. Verified by the
1696
+ /// `leader_fallback_shell_protected_when_provider_exited` test.
1697
+ ///
1688
1698
  /// Two leader-pane sources(N39 双来源,真机 grounded):
1689
1699
  /// 1. **Session prefix**: tmux session starts with `team-agent-leader-`(契约 grounded;
1690
1700
  /// 覆盖 LeaderStartMode::NewTmuxSession / AttachExisting).
@@ -448,16 +448,57 @@ fn ensure_managed_leader_pane(
448
448
  });
449
449
  }
450
450
  }
451
+ // 0.4.x (CR C-1 + C-2): leader env_unset reuses the worker
452
+ // provider_env_unsets (single source of truth) + spawn through the
453
+ // leader shell wrapper so provider exit returns to a shell, not
454
+ // `[exited]`.
455
+ let env_unset = leader_env_unset_for_provider(plan.provider);
456
+ let provider_label = provider_command_name(plan.provider);
451
457
  transport
452
- .spawn_into(session, window, &plan.provider_argv, workspace, &plan.leader_env)
458
+ .spawn_into_with_leader_shell_wrapper(
459
+ session,
460
+ window,
461
+ &plan.provider_argv,
462
+ workspace,
463
+ &plan.leader_env,
464
+ &env_unset,
465
+ provider_label,
466
+ )
453
467
  .map_err(|error| LeaderError::Start(error.to_string()))
454
468
  } else {
469
+ let env_unset = leader_env_unset_for_provider(plan.provider);
470
+ let provider_label = provider_command_name(plan.provider);
455
471
  transport
456
- .spawn_first(session, window, &plan.provider_argv, workspace, &plan.leader_env)
472
+ .spawn_first_with_leader_shell_wrapper(
473
+ session,
474
+ window,
475
+ &plan.provider_argv,
476
+ workspace,
477
+ &plan.leader_env,
478
+ &env_unset,
479
+ provider_label,
480
+ )
457
481
  .map_err(|error| LeaderError::Start(error.to_string()))
458
482
  }
459
483
  }
460
484
 
485
+ /// 0.4.x (CR C-1): leader provider env-unset list — SINGLE SOURCE OF TRUTH
486
+ /// reused from worker spawn (`profile_launch::provider_env_unsets`).
487
+ /// Audit grep guard: this function MUST be the only place in
488
+ /// `crates/team-agent/src/leader/` that constructs a Claude/Codex/Copilot
489
+ /// env-unset list. Any new code path that needs it must call this function
490
+ /// or the underlying `provider_env_unsets`. Use `AuthMode::Subscription` —
491
+ /// the leader is the user's interactive provider, never CompatibleApi/
492
+ /// OfficialApi which are worker-only auth modes today.
493
+ pub fn leader_env_unset_for_provider(provider: Provider) -> Vec<String> {
494
+ crate::lifecycle::profile_launch::provider_env_unsets(
495
+ provider,
496
+ crate::model::enums::AuthMode::Subscription,
497
+ )
498
+ .into_iter()
499
+ .collect()
500
+ }
501
+
461
502
  fn ensure_managed_provider_live_after_attach(
462
503
  transport: &dyn Transport,
463
504
  spawned: &SpawnResult,
@@ -478,6 +519,117 @@ fn ensure_managed_provider_live_after_attach(
478
519
  )))
479
520
  }
480
521
 
522
+ /// 0.4.x (CR C-3 P0): leader provider health reconciliation. The default
523
+ /// `liveness()` check only proves the pane is ADDRESSABLE via tmux — it
524
+ /// returns `Live` even when the provider has exited and the wrapper shell
525
+ /// fell back to an interactive shell. This function distinguishes
526
+ /// `provider_alive` from `provider_exited` by:
527
+ /// 1. Reading `pane_current_command` (tmux `#{pane_current_command}`).
528
+ /// 2. If the current command matches the expected provider binary (or
529
+ /// one of its known aliases), report `Alive`.
530
+ /// 3. If the current command is an interactive shell AND the pane
531
+ /// content contains the exit marker `[team-agent] <provider> exited`
532
+ /// (emitted by `leader_shell_wrapper_command`), report
533
+ /// `ProviderExited`.
534
+ /// 4. Otherwise (Unknown shell, no marker), report `Alive` as the
535
+ /// conservative default — avoid false-positive exit alarms.
536
+ ///
537
+ /// Note: when the leader shell wrapper is used (CR C-2), a provider exit
538
+ /// leaves the pane as `<SHELL:-/bin/zsh>` with the exit marker in
539
+ /// scrollback. Pre-wrapper code that hit `exec claude` would have left the
540
+ /// pane as `[exited]` and `liveness()` would have returned `Dead`. The new
541
+ /// failure mode requires this richer health check to surface
542
+ /// `leader_provider_exited` as a distinct status.
543
+ pub fn leader_provider_health(
544
+ transport: &dyn Transport,
545
+ pane_id: &PaneId,
546
+ expected_provider_label: &str,
547
+ ) -> LeaderProviderHealth {
548
+ use crate::transport::{CaptureRange, PaneField, Target};
549
+ let liveness = transport.liveness(pane_id).ok();
550
+ if matches!(liveness, Some(PaneLiveness::Dead)) {
551
+ return LeaderProviderHealth::Unreachable;
552
+ }
553
+ let target = Target::Pane(pane_id.clone());
554
+ let current_command = transport
555
+ .query(&target, PaneField::PaneCurrentCommand)
556
+ .ok()
557
+ .flatten()
558
+ .map(|s| s.trim().to_lowercase())
559
+ .unwrap_or_default();
560
+ if !current_command.is_empty()
561
+ && (current_command == expected_provider_label
562
+ || current_command.contains(expected_provider_label))
563
+ {
564
+ return LeaderProviderHealth::Alive;
565
+ }
566
+ // Current command is NOT the provider — likely fell back to shell.
567
+ let is_shell = is_interactive_shell_basename(&current_command);
568
+ if is_shell {
569
+ // CR R6: marker text from single-source `leader_provider_exit_marker`
570
+ // so the wrapper printf and the health-check substring cannot drift.
571
+ let exit_marker_substr =
572
+ crate::tmux_backend::leader_provider_exit_marker(expected_provider_label);
573
+ if let Ok(cap) = transport.capture(&target, CaptureRange::Tail(200)) {
574
+ if cap.text.contains(&exit_marker_substr) {
575
+ return LeaderProviderHealth::ProviderExited;
576
+ }
577
+ }
578
+ }
579
+ // Conservative default — pane addressable, but couldn't positively
580
+ // confirm provider exit. Treat as Alive.
581
+ LeaderProviderHealth::Alive
582
+ }
583
+
584
+ /// Health status reported by [`leader_provider_health`].
585
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
586
+ pub enum LeaderProviderHealth {
587
+ /// Provider process appears to be the pane's current command.
588
+ Alive,
589
+ /// Pane has fallen back to a shell with the exit marker present —
590
+ /// provider has exited.
591
+ ProviderExited,
592
+ /// Pane is dead / unaddressable.
593
+ Unreachable,
594
+ }
595
+
596
+ /// 0.4.x (CR R6 + R3): single-source interactive-shell detection.
597
+ /// Used by:
598
+ /// - `leader_provider_health` to decide "pane fell back to shell"
599
+ /// - shutdown logic to recognise a leader pane in fallback-shell mode
600
+ /// as still owned by the leader (not stray).
601
+ ///
602
+ /// Matches by basename (case-insensitive) — `pane_current_command` returns
603
+ /// the basename of the running binary. Conservative whitelist of POSIX +
604
+ /// common interactive shells; missing entries here are false negatives
605
+ /// (shell looks like provider absent → health says Alive) which is the
606
+ /// safe default per the CR R6 conservative-Alive rule.
607
+ pub fn is_interactive_shell_basename(name: &str) -> bool {
608
+ let trimmed = name.trim().to_ascii_lowercase();
609
+ let basename = std::path::Path::new(&trimmed)
610
+ .file_name()
611
+ .and_then(|s| s.to_str())
612
+ .unwrap_or(&trimmed);
613
+ matches!(
614
+ basename,
615
+ "zsh"
616
+ | "bash"
617
+ | "sh"
618
+ | "fish"
619
+ | "dash"
620
+ | "ksh"
621
+ | "tcsh"
622
+ | "csh"
623
+ | "ash"
624
+ | "mksh"
625
+ | "yash"
626
+ | "elvish"
627
+ | "nu"
628
+ | "nushell"
629
+ | "xonsh"
630
+ )
631
+ }
632
+
481
633
  fn managed_spawned_pane_in_targets(transport: &dyn Transport, spawned: &SpawnResult) -> bool {
482
634
  transport
483
635
  .list_targets()
@@ -785,13 +937,35 @@ fn run_leader_argv(
785
937
  "leader launch argv is empty".to_string(),
786
938
  ));
787
939
  };
788
- let mut child = Command::new(program)
940
+ // 0.4.x regression fix (env-leak scenario 1, in-tmux ExecProvider path):
941
+ // the managed-tmux path got the provider env-unset block via the shell
942
+ // wrapper (cb9c217), but the ExecProvider in-tmux path here is a direct
943
+ // Command::spawn().wait() — it inherits the parent process's full env
944
+ // including any CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_CHILD_SESSION /
945
+ // CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS / CLAUDE_CODE_ENTRYPOINT /
946
+ // CLAUDE_CODE_EXECPATH / CLAUDECODE that the launching shell carries.
947
+ // Apply the SAME env-unset list used by the managed path (CR R6 single
948
+ // source: profile_launch::provider_env_unsets via
949
+ // leader_env_unset_for_provider).
950
+ let env_unset = leader_env_unset_for_provider(plan.provider);
951
+ let mut command = Command::new(program);
952
+ command
789
953
  .args(argv.iter().skip(1))
790
- .envs(env)
791
954
  .stdin(Stdio::inherit())
792
955
  .stdout(Stdio::inherit())
793
- .stderr(Stdio::inherit())
794
- .spawn()?;
956
+ .stderr(Stdio::inherit());
957
+ // 0.4.x order fix: env_remove MUST run AFTER envs(). The `env` map comes
958
+ // from `merged_exec_env` which seeds with `std::env::vars().collect()` —
959
+ // calling `.envs(env)` re-adds every inherited CLAUDE_CODE_* the launching
960
+ // shell carried, overwriting any prior env_remove. By removing AFTER the
961
+ // bulk envs() call, the final Command env table has the leak keys
962
+ // structurally absent. Verified by the regression grep guard that the
963
+ // env_remove call appears AFTER `command.envs(env)`.
964
+ command.envs(env);
965
+ for key in &env_unset {
966
+ command.env_remove(key);
967
+ }
968
+ let mut child = command.spawn()?;
795
969
  if plan.mode == LeaderStartMode::ExecProvider {
796
970
  spawn_exec_provider_startup_prompt_handler(plan.provider, workspace.to_path_buf());
797
971
  }
@@ -415,10 +415,41 @@ fn provider_env_exports(
415
415
  exports
416
416
  }
417
417
 
418
- fn provider_env_unsets(provider: Provider, auth_mode: AuthMode) -> BTreeSet<String> {
418
+ /// 0.4.x (CR C-1): exposed as `pub(crate)` so the leader launcher
419
+ /// (leader/start.rs) can reuse the SAME Claude/Codex/Copilot env-unset set
420
+ /// used by worker spawn. Single source of truth — adding a new provider env
421
+ /// var to clean up means changing this one function. Audit grep guard in
422
+ /// `crates/team-agent/src/leader/start.rs::leader_env_unset_for_provider`
423
+ /// confirms the leader path imports from here and does not maintain its own
424
+ /// duplicate list.
425
+ pub(crate) fn provider_env_unsets(provider: Provider, auth_mode: AuthMode) -> BTreeSet<String> {
419
426
  let mut unsets = BTreeSet::new();
420
427
  match provider {
421
428
  Provider::Claude | Provider::ClaudeCode => {
429
+ // E57 (0.3.27 P0): unset CLAUDE_CODE_SESSION_ID inherited from the
430
+ // leader's environment. When a Claude leader spawns a Claude worker,
431
+ // the worker inherits the leader's CLAUDE_CODE_SESSION_ID; Claude
432
+ // Code then routes the worker's transcript to the leader's session
433
+ // file (~/.claude/projects/<hash>/<leader-session-uuid>.jsonl),
434
+ // corrupting attribution and transcripts. Always unset so Claude
435
+ // Code generates a fresh session id per worker.
436
+ //
437
+ // S1-CAPTURE (0.4.x P0): also unset the rest of the Claude Code
438
+ // child/team env block. These variables make the spawned process
439
+ // believe it is a CHILD of the parent Claude (CLAUDE_CODE_CHILD_SESSION),
440
+ // a TEAM member (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS), or otherwise
441
+ // inherit the parent's invocation identity (ENTRYPOINT / EXECPATH /
442
+ // CLAUDECODE marker). Without unsetting these, a Claude worker
443
+ // spawned from a Claude leader is not an INDEPENDENT process — it
444
+ // joins the leader's session tree and its transcript / state /
445
+ // billing are attributed to the leader. CLAUDE_EFFORT is preserved
446
+ // (effort preference does not affect session attribution).
447
+ unsets.insert("CLAUDE_CODE_SESSION_ID".to_string());
448
+ unsets.insert("CLAUDE_CODE_CHILD_SESSION".to_string());
449
+ unsets.insert("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS".to_string());
450
+ unsets.insert("CLAUDE_CODE_ENTRYPOINT".to_string());
451
+ unsets.insert("CLAUDE_CODE_EXECPATH".to_string());
452
+ unsets.insert("CLAUDECODE".to_string());
422
453
  if auth_mode == AuthMode::CompatibleApi {
423
454
  unsets.insert("ANTHROPIC_API_KEY".to_string());
424
455
  }
@@ -208,7 +208,7 @@ pub(crate) fn start_agent_at_paths(
208
208
  None,
209
209
  Some(resolved_team_key.as_str()),
210
210
  )?;
211
- mark_agent_started(&mut state, agent_id, &spawn_window, &spawn, transport, &safety)?;
211
+ mark_agent_started(&mut state, agent_id, &spawn_window, &spawn, transport, &safety, start_mode)?;
212
212
  // **0.3.24 add-agent socket drift fix**: keep `state.tmux_endpoint` /
213
213
  // `state.tmux_socket` synchronized with the transport actually used for the
214
214
  // spawn. Without this, add-agent / fork-agent could spawn to a socket that
@@ -700,6 +700,7 @@ fn mark_agent_started(
700
700
  spawn: &SpawnedAgentWindow,
701
701
  transport: &dyn crate::transport::Transport,
702
702
  safety: &DangerousApproval,
703
+ start_mode: StartMode,
703
704
  ) -> Result<(), LifecycleError> {
704
705
  let Some(agent) = state
705
706
  .get_mut("agents")
@@ -712,6 +713,31 @@ fn mark_agent_started(
712
713
  agent_id
713
714
  )));
714
715
  };
716
+ // S1-CAPTURE-001 (0.4.8, CR M3 provider-agnostic): on a Fresh /
717
+ // FreshAfterMissingRollout start, the prior session's authoritative
718
+ // capture tuple MUST be cleared before persist_command_plan_state
719
+ // writes the new _pending_session_id. Otherwise old session_id +
720
+ // rollout_path coexist with new _pending_session_id and
721
+ // agent_session_complete returns true on the stale tuple — capture
722
+ // never re-binds to the new process, and any delivered token lands
723
+ // in the old transcript (the leader/unassigned mis-attribution seen
724
+ // in the gate evidence). This applies to all providers that resume:
725
+ // codex, claude, copilot. Reset_agent --discard-session already does
726
+ // this at common.rs:1144-1188; here we mirror it for start-agent /
727
+ // restart-agent fresh paths so the fresh-tuple invariant is global.
728
+ if matches!(start_mode, StartMode::Fresh | StartMode::FreshAfterMissingRollout) {
729
+ for field in [
730
+ "session_id",
731
+ "rollout_path",
732
+ "captured_at",
733
+ "captured_via",
734
+ "attribution_confidence",
735
+ "capture_state",
736
+ "attribution_ambiguous",
737
+ ] {
738
+ agent.remove(field);
739
+ }
740
+ }
715
741
  agent.insert("status".to_string(), serde_json::json!("running"));
716
742
  agent.insert("agent_id".to_string(), serde_json::json!(agent_id.as_str()));
717
743
  agent.insert("window".to_string(), serde_json::json!(window));
@@ -895,10 +895,25 @@ pub(crate) fn restart_required_missing_session_agent_ids(state: &serde_json::Val
895
895
  .get("status")
896
896
  .and_then(|value| value.as_str())
897
897
  .is_some_and(|status| status == "running");
898
- // E6 层2 (C2): required-missing 谓词只看 session_id 有无 + 是否在跑。
899
- // pane 绑定 / first_send_at gate 时刻天然可空(自启动 worker leader 从未发消息),
900
- // 不能作判据 —— 否则真丢上下文的 null-session worker 被漏判,走静默 fresh。
901
- missing_session_id && is_running
898
+ // E6 层2 (C2) + RESTART-RESUME-001 (0.4.8): required-missing
899
+ // predicate gates on session_id absence + running, but ALSO
900
+ // skips never-captured workers (no session_id AND no context
901
+ // signals at all). A never-captured worker has nothing to
902
+ // lose by fresh-start, so it must not block convergence and
903
+ // burn the capture deadline. This matches the selection-stage
904
+ // partial-resume semantic in
905
+ // selection.rs::classify_resume_decision (never_captured →
906
+ // FreshStart without --allow-fresh).
907
+ //
908
+ // The "has context to preserve" signal is the shared
909
+ // restart_agent_has_context_to_preserve helper: first_send_at
910
+ // (leader→worker delivery), last_result_at (MCP report path
911
+ // that may skip first_send_at), or task_prompt_delivered.
912
+ // Only context-bearing null-session workers continue to
913
+ // require convergence (so we never silently drop context).
914
+ missing_session_id
915
+ && is_running
916
+ && !super::selection::restart_agent_never_captured(agent, None)
902
917
  })
903
918
  .collect::<Vec<_>>();
904
919
  missing.sort();
@@ -1361,11 +1361,29 @@ fn mark_agent_respawned(
1361
1361
  // Claude family clear path uniformly. The Copilot scanner
1362
1362
  // (provider/adapter.rs:1217-1228) already gates expected-id with a
1363
1363
  // sqlite point-check, so no truth is lost.
1364
+ // S1-CAPTURE-001 (0.4.8): on Fresh / FreshAfterMissingRollout, clear the
1365
+ // FULL prior-session authoritative capture tuple — not just session_id.
1366
+ // Mirrors mark_agent_started (restart/agent.rs:728-740) so the rebuild path
1367
+ // (multi-agent restart) and the single-agent start path enforce the same
1368
+ // fresh-tuple invariant. Without this, save_restart_state's persist
1369
+ // backfill can revive the stale rollout_path/captured_at/capture_state
1370
+ // tuple from latest, defeating the fresh-tuple guarantee and leaving
1371
+ // delivered tokens in the old transcript (leader/unassigned mis-attrib).
1364
1372
  if matches!(
1365
1373
  restart_mode,
1366
1374
  StartMode::Fresh | StartMode::FreshAfterMissingRollout
1367
1375
  ) {
1368
- agent.insert("session_id".to_string(), serde_json::Value::Null);
1376
+ for field in [
1377
+ "session_id",
1378
+ "rollout_path",
1379
+ "captured_at",
1380
+ "captured_via",
1381
+ "attribution_confidence",
1382
+ "capture_state",
1383
+ "attribution_ambiguous",
1384
+ ] {
1385
+ agent.remove(field);
1386
+ }
1369
1387
  }
1370
1388
  crate::lifecycle::launch::persist_command_plan_state(agent, &spawn.plan, &spawn.profile_launch);
1371
1389
  persist_effective_approval_policy_for_restart(agent, safety);
@@ -53,6 +53,57 @@ pub fn classify_first_send_at(raw: &serde_json::Value) -> FirstSendAtState {
53
53
  }
54
54
  }
55
55
 
56
+ /// RESTART-RESUME-001 (0.4.8): true if the agent has any signal of prior
57
+ /// interaction whose context would be lost by silent fresh-start. Checks
58
+ /// `first_send_at` (leader→worker delivery), `last_result_at` (worker
59
+ /// report_result), and `task_prompt_delivered` (MCP-only worker first task).
60
+ /// Used by both the selection-stage never-captured decision and the
61
+ /// pre-selection convergence missing-set predicate so the two layers share
62
+ /// a single "needs context preservation" semantic.
63
+ ///
64
+ /// CR M2 callers (must stay in sync):
65
+ /// * lifecycle/restart/selection.rs (never_captured branch, classify_resume_decision)
66
+ /// * lifecycle/restart/common.rs::restart_required_missing_session_agent_ids
67
+ pub(crate) fn restart_agent_has_context_to_preserve(agent: &serde_json::Value) -> bool {
68
+ let has_valid_first_send_at = matches!(
69
+ classify_first_send_at(agent.get("first_send_at").unwrap_or(&serde_json::Value::Null)),
70
+ FirstSendAtState::Valid
71
+ );
72
+ if has_valid_first_send_at {
73
+ return true;
74
+ }
75
+ let has_last_result_at = agent
76
+ .get("last_result_at")
77
+ .and_then(serde_json::Value::as_str)
78
+ .is_some_and(|s| !s.is_empty());
79
+ if has_last_result_at {
80
+ return true;
81
+ }
82
+ agent
83
+ .get("task_prompt_delivered")
84
+ .and_then(serde_json::Value::as_bool)
85
+ .unwrap_or(false)
86
+ }
87
+
88
+ /// RESTART-RESUME-001: an agent is "never-captured" when its session_id is
89
+ /// absent AND there is no context to preserve. Such an agent is safe to
90
+ /// auto-fresh without `--allow-fresh` and should NOT be required to capture
91
+ /// a transcript before restart proceeds.
92
+ pub(crate) fn restart_agent_never_captured(
93
+ agent: &serde_json::Value,
94
+ session_id: Option<&str>,
95
+ ) -> bool {
96
+ let session_present = session_id.is_some_and(|s| !s.is_empty())
97
+ || agent
98
+ .get("session_id")
99
+ .and_then(serde_json::Value::as_str)
100
+ .is_some_and(|s| !s.is_empty());
101
+ if session_present {
102
+ return false;
103
+ }
104
+ !restart_agent_has_context_to_preserve(agent)
105
+ }
106
+
56
107
  fn is_python_fromisoformat_like(raw: &str) -> bool {
57
108
  if raw.is_empty() {
58
109
  return false;
@@ -221,25 +272,25 @@ pub(crate) fn classify_restart_plan_with_resume_validation(
221
272
  }
222
273
  _ => (true, Vec::new()),
223
274
  };
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.
275
+ // 0.4.7 partial-resume + RESTART-RESUME-001 (0.4.8): when a worker
276
+ // has NEVER been captured (no session_id AND no context-bearing
277
+ // signal at all), it is structurally non-resumable — there is no
278
+ // context to lose, so auto-fresh is safe even without --allow-fresh.
231
279
  //
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).
280
+ // The "never_captured" predicate is now the shared
281
+ // restart_agent_never_captured(): session_id absent AND none of
282
+ // first_send_at(Valid) / last_result_at / task_prompt_delivered.
283
+ // This matches the pre-selection convergence semantic in
284
+ // common.rs::restart_required_missing_session_agent_ids so both
285
+ // layers refuse / auto-fresh in unison.
236
286
  //
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").
287
+ // If session_id is None but ANY context signal exists, that's the
288
+ // "received message/result but session not captured" bug state —
289
+ // keep the Refuse so we never silently drop context (architect
290
+ // rule: "绝不静默 fresh").
291
+ let _ = first_send_at_state; // retained for the Corrupt branch above
241
292
  let never_captured =
242
- session_id.is_none() && matches!(first_send_at_state, FirstSendAtState::Absent);
293
+ restart_agent_never_captured(agent, session_id.as_ref().map(|s| s.as_str()));
243
294
  let decision = if session_id.is_some() && provider_can_resume && resume_backing_exists {
244
295
  ResumeDecision::Resume
245
296
  } else if session_id.is_some() && allow_fresh {
@@ -35,6 +35,7 @@ mod team_state;
35
35
  pub use agent::{reset_agent, reset_agent_with_transport, start_agent, start_agent_with_transport, stop_agent, stop_agent_with_transport};
36
36
  pub(crate) use agent::start_agent_at_paths;
37
37
  pub(crate) use common::refresh_missing_provider_sessions;
38
+ pub(crate) use common::restart_required_missing_session_agent_ids;
38
39
  // 0.3.24 add-agent socket drift fix: state-aware tmux resolver shared with
39
40
  // `lifecycle::launch::add_agent` / `fork_agent` so all three (restart / add / fork)
40
41
  // route to the SAME tmux socket the live team uses.