@team-agent/installer 0.3.39 → 0.4.0
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/Cargo.toml +7 -0
- package/crates/team-agent/src/cli/adapters.rs +8 -3
- package/crates/team-agent/src/cli/diagnose.rs +12 -3
- package/crates/team-agent/src/cli/emit.rs +1 -0
- package/crates/team-agent/src/cli/mod.rs +250 -9
- package/crates/team-agent/src/cli/send.rs +11 -2
- package/crates/team-agent/src/cli/status_port.rs +39 -4
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +189 -6
- package/crates/team-agent/src/cli/tests/status_send.rs +189 -0
- package/crates/team-agent/src/cli/tests/verb_settle.rs +2 -0
- package/crates/team-agent/src/cli/types.rs +5 -0
- package/crates/team-agent/src/coordinator/mod.rs +1 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/delivery.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/health_sync.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/mod.rs +83 -0
- package/crates/team-agent/src/coordinator/steps/persist.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/session_gate.rs +5 -0
- package/crates/team-agent/src/coordinator/tests/health_sync.rs +156 -0
- package/crates/team-agent/src/coordinator/tick.rs +126 -30
- package/crates/team-agent/src/{message_store.rs → db/message_store.rs} +6 -0
- package/crates/team-agent/src/db/mod.rs +1 -0
- package/crates/team-agent/src/layout/mod.rs +7 -0
- package/crates/team-agent/src/layout/runtime_sessions.rs +312 -0
- package/crates/team-agent/src/layout/tmux_endpoint.rs +162 -0
- package/crates/team-agent/src/leader/start.rs +27 -1
- package/crates/team-agent/src/leader/tests/identity.rs +50 -0
- package/crates/team-agent/src/lib.rs +6 -2
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +32 -0
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +32 -0
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +51 -0
- package/crates/team-agent/src/lifecycle/launch.rs +34 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +17 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +143 -23
- package/crates/team-agent/src/lifecycle/restart/preflight.rs +87 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +308 -0
- package/crates/team-agent/src/lifecycle/restart/selection.rs +87 -22
- package/crates/team-agent/src/lifecycle/restart.rs +6 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +6 -1
- package/crates/team-agent/src/lifecycle/tests/restart.rs +187 -0
- package/crates/team-agent/src/lifecycle/tests.rs +1 -0
- package/crates/team-agent/src/lifecycle/types.rs +20 -1
- package/crates/team-agent/src/mcp_server/helpers.rs +72 -1
- package/crates/team-agent/src/mcp_server/tools.rs +29 -4
- package/crates/team-agent/src/provider/adapter.rs +31 -1
- package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +34 -0
- package/crates/team-agent/src/provider/classify.rs +138 -0
- package/crates/team-agent/src/provider/command.rs +71 -0
- package/crates/team-agent/src/provider/mod.rs +3 -0
- package/crates/team-agent/src/{session_capture.rs → provider/session/capture.rs} +139 -6
- package/crates/team-agent/src/provider/session/mod.rs +13 -0
- package/crates/team-agent/src/provider/session/resume.rs +417 -0
- package/crates/team-agent/src/provider/session_scan.rs +63 -0
- package/crates/team-agent/src/provider/types.rs +5 -0
- package/crates/team-agent/src/state/persist.rs +238 -0
- package/crates/team-agent/src/tmux_backend.rs +25 -0
- package/npm/install.mjs +27 -1
- package/package.json +4 -4
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
//! unit-8 (Stage 3) — `lifecycle::launch::spec_state` phase boundary.
|
|
2
|
+
//!
|
|
3
|
+
//! The dedicated home for spec/runtime-path resolution and state-tree
|
|
4
|
+
//! materialization phases of `quick_start`. Lives in the
|
|
5
|
+
//! `lifecycle/launch/` submodule so future commits can migrate the
|
|
6
|
+
//! existing inline phase fns (launch.rs:2781-2906 and 1680-1756 ranges)
|
|
7
|
+
//! here in small, reviewable pieces.
|
|
8
|
+
//!
|
|
9
|
+
//! Established phases (canonical names — keep stable for future
|
|
10
|
+
//! migration):
|
|
11
|
+
//!
|
|
12
|
+
//! * `resolve_spec_paths` — `.team/runtime/<team>/team.spec.yaml`
|
|
13
|
+
//! resolution + workspace canonicalization
|
|
14
|
+
//! * `materialize_state` — T1 layer state.json initialization including
|
|
15
|
+
//! agent capture fields and `spawn_cwd`
|
|
16
|
+
//!
|
|
17
|
+
//! This commit lands the boundary + a marker enum so unit-8's adoption
|
|
18
|
+
//! sites can reference the phases by name. The phase fns themselves
|
|
19
|
+
//! remain in launch.rs until the next batch of relocations.
|
|
20
|
+
|
|
21
|
+
/// Named launch phases under spec_state. Used in phase labels for logs
|
|
22
|
+
/// and (future) for the orchestrator's step dispatcher.
|
|
23
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
24
|
+
pub enum SpecStatePhase {
|
|
25
|
+
ResolveSpecPaths,
|
|
26
|
+
MaterializeState,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
impl SpecStatePhase {
|
|
30
|
+
pub fn as_str(self) -> &'static str {
|
|
31
|
+
match self {
|
|
32
|
+
Self::ResolveSpecPaths => "launch.spec_state.resolve_spec_paths",
|
|
33
|
+
Self::MaterializeState => "launch.spec_state.materialize_state",
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#[cfg(test)]
|
|
39
|
+
mod tests {
|
|
40
|
+
use super::*;
|
|
41
|
+
|
|
42
|
+
#[test]
|
|
43
|
+
fn phase_labels_are_dotted_paths_under_launch_spec_state() {
|
|
44
|
+
assert!(SpecStatePhase::ResolveSpecPaths
|
|
45
|
+
.as_str()
|
|
46
|
+
.starts_with("launch.spec_state."));
|
|
47
|
+
assert!(SpecStatePhase::MaterializeState
|
|
48
|
+
.as_str()
|
|
49
|
+
.starts_with("launch.spec_state."));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -251,6 +251,11 @@ fn spawn_agents(
|
|
|
251
251
|
model: command_model,
|
|
252
252
|
tools: &resolved_tool_refs,
|
|
253
253
|
profile_launch: Some(&profile_launch),
|
|
254
|
+
// Layer 1 self-healing (architect probe 2026-06-22): expose
|
|
255
|
+
// agent_id as a display-name hint so Claude / Copilot
|
|
256
|
+
// adapters can pass `--name <agent_id>`. Codex has no
|
|
257
|
+
// equivalent flag and ignores the hint.
|
|
258
|
+
agent_id_hint: Some(agent_id_raw),
|
|
254
259
|
})
|
|
255
260
|
.map_err(|e| LifecycleError::Provider(e.to_string()))?;
|
|
256
261
|
if !plan.managed_mcp_config && !profile_launch.managed_mcp_config {
|
|
@@ -923,6 +928,26 @@ fn save_launched_team_state_for_key(
|
|
|
923
928
|
.unwrap_or_else(|| crate::state::projection::team_state_key(launched));
|
|
924
929
|
let mut launched = launched.clone();
|
|
925
930
|
if let Some(obj) = launched.as_object_mut() {
|
|
931
|
+
// RM-039-STAT-001 second-round fix (architect verdict
|
|
932
|
+
// 2026-06-22): the canonical runtime team key MUST be written
|
|
933
|
+
// explicitly to `state.team_key`, not only inferred from
|
|
934
|
+
// `team_dir` by `team_state_key`'s cascade. The historical
|
|
935
|
+
// bug: when `team_dir = "./.team/current"` and the runtime team
|
|
936
|
+
// is `rm039-status-working-891`, `team_state_key` cascades to
|
|
937
|
+
// the team_dir basename (`current`), but `active_team_key` is
|
|
938
|
+
// the real team key. Coordinator tick uses `team_state_key`
|
|
939
|
+
// when saving the team-scoped state, so writes land on
|
|
940
|
+
// `teams.current` instead of `teams.<active_team_key>`. Status
|
|
941
|
+
// reads `teams[active_team_key]`, sees stale data.
|
|
942
|
+
//
|
|
943
|
+
// Writing `team_key=launched_key` here pins the first branch of
|
|
944
|
+
// `team_state_key` so the cascade returns the canonical runtime
|
|
945
|
+
// team key everywhere — coordinator tick, save_team_scoped_state,
|
|
946
|
+
// and status selector all agree.
|
|
947
|
+
obj.insert(
|
|
948
|
+
"team_key".to_string(),
|
|
949
|
+
serde_json::Value::String(launched_key.clone()),
|
|
950
|
+
);
|
|
926
951
|
obj.insert(
|
|
927
952
|
"active_team_key".to_string(),
|
|
928
953
|
serde_json::Value::String(launched_key.clone()),
|
|
@@ -4030,6 +4055,7 @@ pub fn fork_agent_with_transport(
|
|
|
4030
4055
|
model: command_model,
|
|
4031
4056
|
tools: &resolved_tool_refs,
|
|
4032
4057
|
profile_launch: Some(&profile_launch),
|
|
4058
|
+
agent_id_hint: Some(as_agent_id.as_str()),
|
|
4033
4059
|
},
|
|
4034
4060
|
)
|
|
4035
4061
|
.map_err(|e| {
|
|
@@ -4927,3 +4953,11 @@ fn team_workspace(team_dir: &Path) -> PathBuf {
|
|
|
4927
4953
|
|
|
4928
4954
|
mod plan;
|
|
4929
4955
|
pub use plan::{handle_report_result, start_plan};
|
|
4956
|
+
|
|
4957
|
+
// unit-8 (Stage 3) phase boundary modules. Established here as named
|
|
4958
|
+
// homes for upcoming relocations from the launch.rs monolith. Each
|
|
4959
|
+
// child carries a `*Phase` enum naming the canonical phase labels used
|
|
4960
|
+
// in event logs and (future) the orchestrator's step dispatcher.
|
|
4961
|
+
pub mod readiness;
|
|
4962
|
+
pub mod spawn;
|
|
4963
|
+
pub mod spec_state;
|
|
@@ -190,6 +190,11 @@ pub(crate) fn start_agent_at_paths(
|
|
|
190
190
|
.as_ref()
|
|
191
191
|
.map(|placement| placement.layout_window.as_str().to_string())
|
|
192
192
|
.unwrap_or_else(|| window.clone());
|
|
193
|
+
// Issue 2 (Round 3b gate review §6): pass the explicit team_key so the
|
|
194
|
+
// worker MCP env carries it through restart-agent path too. The
|
|
195
|
+
// `restart_projection_team_key` helper consolidates the same resolution
|
|
196
|
+
// used for save_restart_projected_state below.
|
|
197
|
+
let resolved_team_key = restart_projection_team_key(&state, team);
|
|
193
198
|
let spawn = spawn_agent_window(
|
|
194
199
|
workspace,
|
|
195
200
|
&session_name,
|
|
@@ -201,6 +206,7 @@ pub(crate) fn start_agent_at_paths(
|
|
|
201
206
|
Some(&safety),
|
|
202
207
|
layout_placement.as_ref(),
|
|
203
208
|
None,
|
|
209
|
+
Some(resolved_team_key.as_str()),
|
|
204
210
|
)?;
|
|
205
211
|
mark_agent_started(&mut state, agent_id, &spawn_window, &spawn, transport, &safety)?;
|
|
206
212
|
// **0.3.24 add-agent socket drift fix**: keep `state.tmux_endpoint` /
|
|
@@ -625,6 +631,16 @@ fn mark_agent_started(
|
|
|
625
631
|
"spawn_cwd".to_string(),
|
|
626
632
|
serde_json::json!(spawn.spawn_cwd.to_string_lossy().to_string()),
|
|
627
633
|
);
|
|
634
|
+
// Issue 2 (Round 3b gate review §6): persist the resolved owner_team_id
|
|
635
|
+
// so future restart/start cycles read it directly from the agent row.
|
|
636
|
+
if let Some(ref team_id) = spawn.owner_team_id {
|
|
637
|
+
if !team_id.is_empty() {
|
|
638
|
+
agent.insert(
|
|
639
|
+
"owner_team_id".to_string(),
|
|
640
|
+
serde_json::json!(team_id),
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
628
644
|
crate::lifecycle::launch::persist_command_plan_state(
|
|
629
645
|
agent,
|
|
630
646
|
&spawn.plan,
|
|
@@ -889,6 +905,7 @@ fn write_start_agent_start_event(
|
|
|
889
905
|
model: command_model,
|
|
890
906
|
tools: &resolved_tool_refs,
|
|
891
907
|
profile_launch: Some(&profile_launch),
|
|
908
|
+
agent_id_hint: Some(agent_id.as_str()),
|
|
892
909
|
};
|
|
893
910
|
let mut plan = match session_id {
|
|
894
911
|
Some(session_id) => adapter
|
|
@@ -6,8 +6,15 @@ pub(super) struct SpawnedAgentWindow {
|
|
|
6
6
|
pub profile_launch: crate::provider::ProviderProfileLaunch,
|
|
7
7
|
pub layout_placement: Option<crate::lifecycle::launch::LayoutPlacement>,
|
|
8
8
|
pub spawn_cwd: std::path::PathBuf,
|
|
9
|
+
/// Issue 2 (Round 3b gate review §6): the resolved `owner_team_id` used
|
|
10
|
+
/// for this spawn's MCP env / command. Callers (`mark_agent_respawned`,
|
|
11
|
+
/// `mark_agent_started`) must persist this back into the agent row so
|
|
12
|
+
/// future restarts read it directly (priority #2 in the resolution
|
|
13
|
+
/// cascade) instead of relying on top-level `active_team_key`.
|
|
14
|
+
pub owner_team_id: Option<String>,
|
|
9
15
|
}
|
|
10
16
|
|
|
17
|
+
#[allow(clippy::too_many_arguments)]
|
|
11
18
|
pub(super) fn spawn_agent_window(
|
|
12
19
|
workspace: &Path,
|
|
13
20
|
session_name: &SessionName,
|
|
@@ -19,6 +26,14 @@ pub(super) fn spawn_agent_window(
|
|
|
19
26
|
safety: Option<&DangerousApproval>,
|
|
20
27
|
layout_placement: Option<&crate::lifecycle::launch::LayoutPlacement>,
|
|
21
28
|
spawn_cwd_override: Option<&Path>,
|
|
29
|
+
// Issue 2 (Round 3b gate review §6): explicit owner_team_id override.
|
|
30
|
+
// When `Some`, callers (restart/rebuild.rs, restart/agent.rs) thread the
|
|
31
|
+
// resolved `selected.team_key` through here so the worker's MCP env /
|
|
32
|
+
// command argv carries `TEAM_AGENT_OWNER_TEAM_ID=<selected team>` —
|
|
33
|
+
// even when the persisted agent row OR the top-level `active_team_key`
|
|
34
|
+
// is stale. When `None`, falls back to the legacy resolution
|
|
35
|
+
// (agent row → active_team_key) for back-compat with non-restart callers.
|
|
36
|
+
owner_team_id_override: Option<&str>,
|
|
22
37
|
) -> Result<SpawnedAgentWindow, LifecycleError> {
|
|
23
38
|
let provider = agent_provider(agent);
|
|
24
39
|
let auth_mode = agent_auth_mode(agent);
|
|
@@ -52,15 +67,26 @@ pub(super) fn spawn_agent_window(
|
|
|
52
67
|
safety,
|
|
53
68
|
)?;
|
|
54
69
|
let resolved_tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
|
|
55
|
-
// owner_team_id resolution
|
|
56
|
-
//
|
|
57
|
-
//
|
|
70
|
+
// owner_team_id resolution priority (Issue 2 fix):
|
|
71
|
+
// 1. caller's explicit override (restart paths pass `selected.team_key`)
|
|
72
|
+
// 2. agent row's persisted `owner_team_id` (set by prior launch/restart)
|
|
73
|
+
// 3. top-level `active_team_key` (legacy fallback for add-agent etc.)
|
|
74
|
+
// The override breaks the dependency on top-level state mutation: even if
|
|
75
|
+
// top-level `active_team_key` is stale (e.g. `ta-probe-ws`), a restart that
|
|
76
|
+
// resolved `selected.team_key=prerelease-040-round3b` propagates THAT team
|
|
77
|
+
// into the worker's MCP env.
|
|
58
78
|
let state_for_team =
|
|
59
79
|
crate::state::persist::load_runtime_state(workspace).unwrap_or(serde_json::json!({}));
|
|
60
|
-
let team_id =
|
|
61
|
-
.
|
|
62
|
-
.and_then(|v| v.as_str())
|
|
80
|
+
let team_id = owner_team_id_override
|
|
81
|
+
.filter(|s| !s.is_empty())
|
|
63
82
|
.map(str::to_string)
|
|
83
|
+
.or_else(|| {
|
|
84
|
+
agent
|
|
85
|
+
.get("owner_team_id")
|
|
86
|
+
.and_then(|v| v.as_str())
|
|
87
|
+
.filter(|s| !s.is_empty())
|
|
88
|
+
.map(str::to_string)
|
|
89
|
+
})
|
|
64
90
|
.or_else(|| {
|
|
65
91
|
let key =
|
|
66
92
|
crate::messaging::leader_receiver::active_team_key(workspace, &state_for_team);
|
|
@@ -95,6 +121,7 @@ pub(super) fn spawn_agent_window(
|
|
|
95
121
|
model: command_model,
|
|
96
122
|
tools: &resolved_tool_refs,
|
|
97
123
|
profile_launch: Some(&profile_launch),
|
|
124
|
+
agent_id_hint: Some(agent_id.as_str()),
|
|
98
125
|
};
|
|
99
126
|
let mut plan = match resume_session_id {
|
|
100
127
|
Some(session_id) => adapter
|
|
@@ -240,6 +267,7 @@ pub(super) fn spawn_agent_window(
|
|
|
240
267
|
profile_launch,
|
|
241
268
|
layout_placement: layout_placement.cloned(),
|
|
242
269
|
spawn_cwd: spawn_cwd.to_path_buf(),
|
|
270
|
+
owner_team_id: team_id,
|
|
243
271
|
})
|
|
244
272
|
}
|
|
245
273
|
|
|
@@ -468,22 +496,95 @@ pub(super) fn resume_backing_exists_for_agent(
|
|
|
468
496
|
session_id: &SessionId,
|
|
469
497
|
rollout_path: Option<&RolloutPath>,
|
|
470
498
|
) -> bool {
|
|
471
|
-
|
|
499
|
+
resume_backing_probe_for_agent(
|
|
500
|
+
workspace,
|
|
501
|
+
agent_id,
|
|
502
|
+
agent,
|
|
503
|
+
provider,
|
|
504
|
+
session_id,
|
|
505
|
+
rollout_path,
|
|
506
|
+
)
|
|
507
|
+
.exists
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/// Layer 2 self-healing (leader follow-up 2026-06-22): result of probing
|
|
511
|
+
/// the provider backing store for a resumable session. `checked_paths`
|
|
512
|
+
/// reports every path the runtime probed so the operator can see WHICH
|
|
513
|
+
/// places we looked — surfaced into the
|
|
514
|
+
/// `ResumeRefusalReason::SessionBackingStoreMissing.checked_paths`
|
|
515
|
+
/// field, the CLI JSON `unresumable[].checked_paths` array, and the
|
|
516
|
+
/// `restart.resume_decision` event payload.
|
|
517
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
518
|
+
pub(super) struct BackingProbeResult {
|
|
519
|
+
pub exists: bool,
|
|
520
|
+
pub checked_paths: Vec<PathBuf>,
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
pub(super) fn resume_backing_probe_for_agent(
|
|
524
|
+
workspace: &Path,
|
|
525
|
+
agent_id: &AgentId,
|
|
526
|
+
agent: &serde_json::Value,
|
|
527
|
+
provider: Provider,
|
|
528
|
+
session_id: &SessionId,
|
|
529
|
+
rollout_path: Option<&RolloutPath>,
|
|
530
|
+
) -> BackingProbeResult {
|
|
531
|
+
let mut checked_paths: Vec<PathBuf> = Vec::new();
|
|
532
|
+
|
|
533
|
+
// Always record the persisted rollout_path even when it does not
|
|
534
|
+
// exist — that "we looked here" tells the operator that state has a
|
|
535
|
+
// pointer but the file is gone.
|
|
536
|
+
if let Some(path) = rollout_path.map(RolloutPath::as_path) {
|
|
537
|
+
checked_paths.push(path.to_path_buf());
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
let exists = match provider {
|
|
472
541
|
provider if !provider_supports_resume(provider) => {
|
|
473
542
|
let _ = (workspace, agent_id, agent, session_id, rollout_path);
|
|
474
543
|
false
|
|
475
544
|
}
|
|
476
545
|
Provider::Codex => {
|
|
477
|
-
rollout_path_exists(rollout_path)
|
|
478
|
-
|
|
546
|
+
let rollout_ok = rollout_path_exists(rollout_path);
|
|
547
|
+
let scan_roots = codex_session_transcript_scan_roots(agent, rollout_path);
|
|
548
|
+
for root in &scan_roots {
|
|
549
|
+
checked_paths.push(root.clone());
|
|
550
|
+
}
|
|
551
|
+
rollout_ok
|
|
552
|
+
|| codex_session_transcript_exists_with_roots(
|
|
553
|
+
session_id.as_str(),
|
|
554
|
+
&scan_roots,
|
|
555
|
+
)
|
|
479
556
|
}
|
|
480
557
|
Provider::Claude | Provider::ClaudeCode => {
|
|
481
|
-
rollout_path_exists(rollout_path)
|
|
558
|
+
let rollout_ok = rollout_path_exists(rollout_path);
|
|
559
|
+
let projects_root = claude_projects_root_for_agent(agent);
|
|
560
|
+
if let Some(root) = projects_root.as_ref() {
|
|
561
|
+
checked_paths.push(root.clone());
|
|
562
|
+
}
|
|
563
|
+
let event_log_path = workspace.join(".team/logs/events.jsonl");
|
|
564
|
+
checked_paths.push(event_log_path);
|
|
565
|
+
rollout_ok
|
|
482
566
|
|| event_log_transcript_exists(workspace, agent_id.as_str(), session_id.as_str())
|
|
483
|
-
||
|
|
567
|
+
|| projects_root.is_some_and(|root| {
|
|
568
|
+
claude_project_transcript_exists_under(&root, session_id.as_str())
|
|
569
|
+
})
|
|
570
|
+
}
|
|
571
|
+
Provider::Copilot => {
|
|
572
|
+
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
|
|
573
|
+
checked_paths.push(home.join(".copilot/session-store.db"));
|
|
574
|
+
}
|
|
575
|
+
copilot_session_store_has_session(session_id.as_str())
|
|
484
576
|
}
|
|
485
|
-
Provider::Copilot => copilot_session_store_has_session(session_id.as_str()),
|
|
486
577
|
Provider::GeminiCli | Provider::Fake => false,
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
// Deduplicate while preserving order (HashSet would lose deterministic
|
|
581
|
+
// ordering needed for stable JSON/event output).
|
|
582
|
+
let mut seen: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
|
|
583
|
+
checked_paths.retain(|p| seen.insert(p.clone()));
|
|
584
|
+
|
|
585
|
+
BackingProbeResult {
|
|
586
|
+
exists,
|
|
587
|
+
checked_paths,
|
|
487
588
|
}
|
|
488
589
|
}
|
|
489
590
|
|
|
@@ -535,25 +636,32 @@ fn event_transcript_path(event: &serde_json::Value) -> Option<PathBuf> {
|
|
|
535
636
|
/// so we avoid recomputing the project-dir slug, which is brittle for non-ASCII
|
|
536
637
|
/// workspace paths).
|
|
537
638
|
fn claude_project_transcript_exists(agent: &serde_json::Value, session_id: &str) -> bool {
|
|
538
|
-
|
|
639
|
+
let Some(root) = claude_projects_root_for_agent(agent) else {
|
|
539
640
|
return false;
|
|
540
|
-
}
|
|
541
|
-
|
|
641
|
+
};
|
|
642
|
+
claude_project_transcript_exists_under(&root, session_id)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
fn claude_projects_root_for_agent(agent: &serde_json::Value) -> Option<PathBuf> {
|
|
646
|
+
agent
|
|
542
647
|
.get("claude_projects_root")
|
|
543
648
|
.and_then(serde_json::Value::as_str)
|
|
544
649
|
.filter(|value| !value.is_empty())
|
|
545
650
|
.map(PathBuf::from)
|
|
546
651
|
.or_else(|| {
|
|
547
652
|
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude").join("projects"))
|
|
548
|
-
})
|
|
549
|
-
|
|
653
|
+
})
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
fn claude_project_transcript_exists_under(projects_root: &Path, session_id: &str) -> bool {
|
|
657
|
+
if session_id.is_empty() {
|
|
550
658
|
return false;
|
|
551
|
-
}
|
|
659
|
+
}
|
|
552
660
|
if !projects_root.is_dir() {
|
|
553
661
|
return false;
|
|
554
662
|
}
|
|
555
663
|
let transcript_name = format!("{session_id}.jsonl");
|
|
556
|
-
let Ok(project_dirs) = std::fs::read_dir(
|
|
664
|
+
let Ok(project_dirs) = std::fs::read_dir(projects_root) else {
|
|
557
665
|
return false;
|
|
558
666
|
};
|
|
559
667
|
project_dirs
|
|
@@ -567,10 +675,15 @@ fn codex_session_transcript_exists(
|
|
|
567
675
|
session_id: &str,
|
|
568
676
|
rollout_path: Option<&RolloutPath>,
|
|
569
677
|
) -> bool {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
678
|
+
let roots = codex_session_transcript_scan_roots(agent, rollout_path);
|
|
679
|
+
codex_session_transcript_exists_with_roots(session_id, &roots)
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
fn codex_session_transcript_scan_roots(
|
|
683
|
+
agent: &serde_json::Value,
|
|
684
|
+
rollout_path: Option<&RolloutPath>,
|
|
685
|
+
) -> Vec<PathBuf> {
|
|
686
|
+
let mut roots: Vec<PathBuf> = Vec::new();
|
|
574
687
|
if let Some(parent) = rollout_path
|
|
575
688
|
.map(RolloutPath::as_path)
|
|
576
689
|
.and_then(Path::parent)
|
|
@@ -596,6 +709,13 @@ fn codex_session_transcript_exists(
|
|
|
596
709
|
}
|
|
597
710
|
roots.sort();
|
|
598
711
|
roots.dedup();
|
|
712
|
+
roots
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
fn codex_session_transcript_exists_with_roots(session_id: &str, roots: &[PathBuf]) -> bool {
|
|
716
|
+
if session_id.is_empty() {
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
599
719
|
roots
|
|
600
720
|
.iter()
|
|
601
721
|
.any(|root| session_transcript_exists_under(root, session_id, 4))
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
//! unit-3 (Stage 1) — restart preflight session-identity guard.
|
|
2
|
+
//!
|
|
3
|
+
//! Single check that runs BEFORE the worker-session teardown in
|
|
4
|
+
//! `rebuild::restart_inner` (and the parallel check in
|
|
5
|
+
//! `common::session_name_present` callers). The guard's purpose is to
|
|
6
|
+
//! REFUSE the kill when the value in `state.session_name` is actually a
|
|
7
|
+
//! leader launcher session name (the 0.3.39 leader-mis-kill bug shape).
|
|
8
|
+
//!
|
|
9
|
+
//! Typed identity comes from unit-1's `RuntimeSessions::from_state`. This
|
|
10
|
+
//! preflight is the first adoption site of that typed reader inside the
|
|
11
|
+
//! restart code path.
|
|
12
|
+
|
|
13
|
+
use crate::layout::{RuntimeSessionAnomaly, RuntimeSessions};
|
|
14
|
+
|
|
15
|
+
/// Outcome of the preflight session-identity check.
|
|
16
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
17
|
+
pub enum SessionPreflight {
|
|
18
|
+
/// State's session-name fields are well-formed. The caller may proceed
|
|
19
|
+
/// with the worker-session teardown.
|
|
20
|
+
Ok,
|
|
21
|
+
/// `state.session_name` matches the leader launcher prefix. Refuse the
|
|
22
|
+
/// kill — proceeding would tear down the leader pane (E49 / 0.3.39).
|
|
23
|
+
WorkerSessionIsLeaderSession {
|
|
24
|
+
session_name: String,
|
|
25
|
+
reason: String,
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
impl SessionPreflight {
|
|
30
|
+
/// True when the preflight passed and the caller may proceed.
|
|
31
|
+
pub fn is_ok(&self) -> bool {
|
|
32
|
+
matches!(self, SessionPreflight::Ok)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Inspect `state.json` and decide whether the upcoming worker-session
|
|
37
|
+
/// kill is safe. Pure: no I/O, no mutation.
|
|
38
|
+
pub fn check_session_preflight(state: &serde_json::Value) -> SessionPreflight {
|
|
39
|
+
let sessions = RuntimeSessions::from_state(state);
|
|
40
|
+
if let Some(anomaly) = sessions
|
|
41
|
+
.anomalies
|
|
42
|
+
.iter()
|
|
43
|
+
.find(|a| matches!(a, RuntimeSessionAnomaly::WorkerSessionNameIsLeaderPrefixed { .. }))
|
|
44
|
+
{
|
|
45
|
+
if let RuntimeSessionAnomaly::WorkerSessionNameIsLeaderPrefixed { value } = anomaly {
|
|
46
|
+
return SessionPreflight::WorkerSessionIsLeaderSession {
|
|
47
|
+
session_name: value.clone(),
|
|
48
|
+
reason: "worker_session_is_leader_session".to_string(),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
SessionPreflight::Ok
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#[cfg(test)]
|
|
56
|
+
mod tests {
|
|
57
|
+
use super::*;
|
|
58
|
+
use serde_json::json;
|
|
59
|
+
|
|
60
|
+
#[test]
|
|
61
|
+
fn preflight_passes_clean_state() {
|
|
62
|
+
let s = json!({ "session_name": "team-foo" });
|
|
63
|
+
assert!(check_session_preflight(&s).is_ok());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#[test]
|
|
67
|
+
fn preflight_passes_empty_state() {
|
|
68
|
+
assert!(check_session_preflight(&json!({})).is_ok());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#[test]
|
|
72
|
+
fn preflight_refuses_leader_prefixed_worker_session_name() {
|
|
73
|
+
let s = json!({ "session_name": "team-agent-leader-claude-abc" });
|
|
74
|
+
let r = check_session_preflight(&s);
|
|
75
|
+
assert!(!r.is_ok());
|
|
76
|
+
match r {
|
|
77
|
+
SessionPreflight::WorkerSessionIsLeaderSession {
|
|
78
|
+
session_name,
|
|
79
|
+
reason,
|
|
80
|
+
} => {
|
|
81
|
+
assert_eq!(session_name, "team-agent-leader-claude-abc");
|
|
82
|
+
assert_eq!(reason, "worker_session_is_leader_session");
|
|
83
|
+
}
|
|
84
|
+
_ => panic!("expected WorkerSessionIsLeaderSession refusal"),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|