@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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +27 -0
- package/crates/team-agent/src/cli/diagnose.rs +52 -0
- package/crates/team-agent/src/cli/mod.rs +10 -0
- package/crates/team-agent/src/leader/start.rs +180 -6
- package/crates/team-agent/src/lifecycle/profile_launch.rs +32 -1
- package/crates/team-agent/src/lifecycle/restart/agent.rs +27 -1
- package/crates/team-agent/src/lifecycle/restart/common.rs +19 -4
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +19 -1
- package/crates/team-agent/src/lifecycle/restart/selection.rs +67 -16
- package/crates/team-agent/src/lifecycle/restart.rs +1 -0
- package/crates/team-agent/src/lifecycle/tests/core.rs +99 -0
- package/crates/team-agent/src/messaging/delivery.rs +113 -0
- package/crates/team-agent/src/provider/session/capture.rs +131 -1
- package/crates/team-agent/src/state/paths.rs +8 -4
- package/crates/team-agent/src/state/persist.rs +17 -0
- package/crates/team-agent/src/tmux_backend.rs +116 -1
- package/crates/team-agent/src/transport/test_support.rs +62 -3
- package/crates/team-agent/src/transport.rs +38 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -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
|
-
.
|
|
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
|
-
.
|
|
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(¤t_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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
|
|
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
|
-
|
|
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
|
|
225
|
-
//
|
|
226
|
-
// non-resumable — there is no
|
|
227
|
-
// safe even without --allow-fresh.
|
|
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
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
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
|
|
238
|
-
// "received message but session not captured" bug state —
|
|
239
|
-
// the Refuse so we never silently drop context (architect
|
|
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.
|
|
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.
|