@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,71 @@
|
|
|
1
|
+
//! unit-7 (Stage 3) — provider command-assembly boundary.
|
|
2
|
+
//!
|
|
3
|
+
//! The provider/adapter.rs monolith mixes three concerns:
|
|
4
|
+
//! 1. The `ProviderAdapter` TRAIT and facade dispatch (the public API).
|
|
5
|
+
//! 2. Per-provider COMMAND ASSEMBLY (argv plan, base/resume flags,
|
|
6
|
+
//! --session-id injection — adapter.rs:409-508 region).
|
|
7
|
+
//! 3. SESSION SCANNING (capture polling + transcript scanning —
|
|
8
|
+
//! adapter.rs:511-546, 969-1078).
|
|
9
|
+
//!
|
|
10
|
+
//! This module CREATES the dedicated home for #2 so future work can
|
|
11
|
+
//! migrate command-assembly fns here in small, behavior-neutral commits.
|
|
12
|
+
//! Today it only carries the documented intent and a small typed helper —
|
|
13
|
+
//! the bulk of the assembly fns are still in adapter.rs and reachable
|
|
14
|
+
//! through the existing pub(crate) surface.
|
|
15
|
+
//!
|
|
16
|
+
//! Why incremental: every command-assembly fn touches multiple internal
|
|
17
|
+
//! constants (`--session-id`, `--resume`, MCP-config-path conventions) and
|
|
18
|
+
//! per-provider quirks. Moving them blindly is high-risk. The boundary
|
|
19
|
+
//! exists so the next commit (or any contributor) has a documented target
|
|
20
|
+
//! for relocations.
|
|
21
|
+
|
|
22
|
+
use crate::provider::Provider;
|
|
23
|
+
|
|
24
|
+
/// Lightweight tag for command-plan branches in the existing adapter
|
|
25
|
+
/// (`Base` vs `Resume`). Distinct type so command-assembly code reads
|
|
26
|
+
/// clearly even when it lives next to adapter dispatch.
|
|
27
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
28
|
+
pub enum CommandPlanKind {
|
|
29
|
+
/// Initial spawn — `--session-id <uuid>` predetermined.
|
|
30
|
+
Base,
|
|
31
|
+
/// Resume an existing session — `--resume <sid>`, no fresh
|
|
32
|
+
/// `--session-id` injected.
|
|
33
|
+
Resume,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Returns true when the provider's command plan honors `--session-id`
|
|
37
|
+
/// for fresh spawns. Mirrors the existing adapter logic but lives next to
|
|
38
|
+
/// where future command-assembly fns will land.
|
|
39
|
+
pub fn honors_session_id_for_base_spawn(provider: Provider) -> bool {
|
|
40
|
+
matches!(
|
|
41
|
+
provider,
|
|
42
|
+
Provider::Codex
|
|
43
|
+
| Provider::Claude
|
|
44
|
+
| Provider::ClaudeCode
|
|
45
|
+
| Provider::Copilot
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#[cfg(test)]
|
|
50
|
+
mod tests {
|
|
51
|
+
use super::*;
|
|
52
|
+
|
|
53
|
+
#[test]
|
|
54
|
+
fn fake_provider_does_not_honor_session_id() {
|
|
55
|
+
assert!(!honors_session_id_for_base_spawn(Provider::Fake));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#[test]
|
|
59
|
+
fn real_providers_honor_session_id() {
|
|
60
|
+
for p in [Provider::Codex, Provider::Claude, Provider::Copilot] {
|
|
61
|
+
assert!(honors_session_id_for_base_spawn(p));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#[test]
|
|
66
|
+
fn command_plan_kind_is_copy() {
|
|
67
|
+
let a = CommandPlanKind::Base;
|
|
68
|
+
let b = a;
|
|
69
|
+
assert_eq!(a, b);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -34,7 +34,10 @@ pub use crate::model::enums::{AuthMode, Provider};
|
|
|
34
34
|
pub mod adapter;
|
|
35
35
|
pub mod approvals;
|
|
36
36
|
pub mod classify;
|
|
37
|
+
pub mod command;
|
|
37
38
|
pub mod faults;
|
|
39
|
+
pub mod session;
|
|
40
|
+
pub mod session_scan;
|
|
38
41
|
pub mod startup_prompt;
|
|
39
42
|
pub mod types;
|
|
40
43
|
// helpers 全部原为模块私有(JSONL 解析 / 正则编译),非根可见 → 私有 mod,不参与 re-export。
|
|
@@ -409,14 +409,87 @@ where
|
|
|
409
409
|
}
|
|
410
410
|
|
|
411
411
|
fn agent_session_complete(agent: &Value) -> bool {
|
|
412
|
-
|
|
412
|
+
// RM-039-SESS-001 step 1 (architect verdict 2026-06-22): historically
|
|
413
|
+
// this returned true the moment `rollout_path` was non-empty — even when
|
|
414
|
+
// the file no longer existed on disk. That created a "stale-positive"
|
|
415
|
+
// capture tuple: the worker had a session_id + a stored rollout_path,
|
|
416
|
+
// but the provider had rotated/garbage-collected the actual transcript
|
|
417
|
+
// file. `pending_session_capture` skipped agents whose
|
|
418
|
+
// `agent_session_complete()` was true, so the runtime never recaptured
|
|
419
|
+
// the now-broken backing, and downstream consumers saw the stale tuple
|
|
420
|
+
// as authoritative.
|
|
421
|
+
//
|
|
422
|
+
// Fix: a non-empty `rollout_path` only counts as complete when the path
|
|
423
|
+
// actually exists. session_id remains a required non-empty check
|
|
424
|
+
// (architect directive: "Keep session_id non-empty as required; do not
|
|
425
|
+
// infer context from event log alone unless the referenced transcript
|
|
426
|
+
// path exists.").
|
|
427
|
+
//
|
|
428
|
+
// Blocker-2 Layer-1 (prerelease 0.4.0, architect bugs-prerelease-blockers.md §139):
|
|
429
|
+
// existence alone is not enough — a small Claude session-metadata JSON
|
|
430
|
+
// (~/.claude/sessions/<pid>.json, ~300-400 bytes, sessionId only, no
|
|
431
|
+
// assistant/user records) used to satisfy this and prevent recapture of
|
|
432
|
+
// the real .claude/projects/<cwd>/<sid>.jsonl transcript. For
|
|
433
|
+
// Claude/ClaudeCode rollout paths, additionally require a recognizable
|
|
434
|
+
// transcript lifecycle record (an assistant or user record). Other
|
|
435
|
+
// providers keep existence-only semantics (codex 不可改项).
|
|
436
|
+
let session_id_ok = agent
|
|
413
437
|
.get("session_id")
|
|
414
438
|
.and_then(Value::as_str)
|
|
415
|
-
.is_some_and(|session| !session.is_empty())
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
439
|
+
.is_some_and(|session| !session.is_empty());
|
|
440
|
+
if !session_id_ok {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
let rollout_path = match agent
|
|
444
|
+
.get("rollout_path")
|
|
445
|
+
.and_then(Value::as_str)
|
|
446
|
+
.filter(|path| !path.is_empty())
|
|
447
|
+
{
|
|
448
|
+
Some(path) => path,
|
|
449
|
+
None => return false,
|
|
450
|
+
};
|
|
451
|
+
let path = std::path::Path::new(rollout_path);
|
|
452
|
+
if !path.exists() {
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
let provider_wire = agent.get("provider").and_then(Value::as_str).unwrap_or("");
|
|
456
|
+
if matches!(provider_wire, "claude" | "claude-code" | "claude_code") {
|
|
457
|
+
return claude_rollout_has_lifecycle_records(path);
|
|
458
|
+
}
|
|
459
|
+
true
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/// Blocker-2 Layer-1 (prerelease 0.4.0): a Claude rollout file qualifies as
|
|
463
|
+
/// activity backing only when it contains at least one recognizable transcript
|
|
464
|
+
/// lifecycle record (top-level `type:"assistant"` or `type:"user"`). The
|
|
465
|
+
/// ~/.claude/sessions/<pid>.json metadata file has neither, so this returns
|
|
466
|
+
/// false for it and the runtime recaptures via the .claude/projects/<cwd>/
|
|
467
|
+
/// scan in adapter.rs. Bounded read so a large transcript is not slurped.
|
|
468
|
+
fn claude_rollout_has_lifecycle_records(path: &std::path::Path) -> bool {
|
|
469
|
+
use std::io::Read;
|
|
470
|
+
const MAX_BYTES: u64 = 65_536;
|
|
471
|
+
let Ok(file) = std::fs::File::open(path) else {
|
|
472
|
+
return false;
|
|
473
|
+
};
|
|
474
|
+
let mut bytes = Vec::new();
|
|
475
|
+
if file.take(MAX_BYTES).read_to_end(&mut bytes).is_err() {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
let text = String::from_utf8_lossy(&bytes);
|
|
479
|
+
for line in text.lines() {
|
|
480
|
+
let trimmed = line.trim();
|
|
481
|
+
if trimmed.is_empty() {
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
|
|
485
|
+
continue;
|
|
486
|
+
};
|
|
487
|
+
let kind = value.get("type").and_then(Value::as_str).unwrap_or("");
|
|
488
|
+
if matches!(kind, "assistant" | "user") {
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
false
|
|
420
493
|
}
|
|
421
494
|
|
|
422
495
|
fn allocate_session_candidates(
|
|
@@ -874,4 +947,64 @@ mod u1_tests {
|
|
|
874
947
|
}]
|
|
875
948
|
);
|
|
876
949
|
}
|
|
950
|
+
|
|
951
|
+
/// RM-039-SESS-001 step 1 (architect verdict 2026-06-22): the
|
|
952
|
+
/// `agent_session_complete` predicate must NOT treat a non-empty
|
|
953
|
+
/// `rollout_path` as complete when the file does not exist. The
|
|
954
|
+
/// historical bug was that a stale-positive capture tuple
|
|
955
|
+
/// (`session_id` + `rollout_path` both present, but the path had
|
|
956
|
+
/// been rotated away by the provider) was reported as complete and
|
|
957
|
+
/// blocked `pending_session_capture` from recapturing. Now the
|
|
958
|
+
/// predicate returns false if the path does not exist, so the
|
|
959
|
+
/// session is re-evaluated on the next convergence tick.
|
|
960
|
+
#[test]
|
|
961
|
+
fn rm039_sess001_agent_session_complete_requires_existing_rollout_path() {
|
|
962
|
+
// Case 1: session_id + rollout_path both non-empty, path absent.
|
|
963
|
+
let missing = "/tmp/ta-rm039-sess001-nonexistent-rollout.jsonl";
|
|
964
|
+
// Guard against a previous test run leaving the file behind.
|
|
965
|
+
let _ = std::fs::remove_file(missing);
|
|
966
|
+
let agent_stale = serde_json::json!({
|
|
967
|
+
"session_id": "sess-stale-positive",
|
|
968
|
+
"rollout_path": missing,
|
|
969
|
+
});
|
|
970
|
+
assert!(
|
|
971
|
+
!agent_session_complete(&agent_stale),
|
|
972
|
+
"RM-039-SESS-001: stale-positive tuple (session_id + missing rollout_path) \
|
|
973
|
+
must NOT be reported complete"
|
|
974
|
+
);
|
|
975
|
+
|
|
976
|
+
// Case 2: session_id + rollout_path both non-empty, path exists.
|
|
977
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
978
|
+
static N: AtomicU64 = AtomicU64::new(0);
|
|
979
|
+
let n = N.fetch_add(1, Ordering::Relaxed);
|
|
980
|
+
let existing = std::env::temp_dir().join(format!(
|
|
981
|
+
"ta-rm039-sess001-existing-{}-{}.jsonl",
|
|
982
|
+
std::process::id(),
|
|
983
|
+
n
|
|
984
|
+
));
|
|
985
|
+
std::fs::write(&existing, b"{}\n").expect("write fixture rollout file");
|
|
986
|
+
let agent_complete = serde_json::json!({
|
|
987
|
+
"session_id": "sess-real",
|
|
988
|
+
"rollout_path": existing.to_string_lossy(),
|
|
989
|
+
});
|
|
990
|
+
assert!(
|
|
991
|
+
agent_session_complete(&agent_complete),
|
|
992
|
+
"an agent with session_id + an existing rollout file remains complete"
|
|
993
|
+
);
|
|
994
|
+
let _ = std::fs::remove_file(&existing);
|
|
995
|
+
|
|
996
|
+
// Case 3: empty session_id must still fail completeness regardless.
|
|
997
|
+
let agent_no_session = serde_json::json!({
|
|
998
|
+
"session_id": "",
|
|
999
|
+
"rollout_path": "/tmp/whatever",
|
|
1000
|
+
});
|
|
1001
|
+
assert!(!agent_session_complete(&agent_no_session));
|
|
1002
|
+
|
|
1003
|
+
// Case 4: empty rollout_path remains incomplete (legacy contract).
|
|
1004
|
+
let agent_no_path = serde_json::json!({
|
|
1005
|
+
"session_id": "sess-x",
|
|
1006
|
+
"rollout_path": "",
|
|
1007
|
+
});
|
|
1008
|
+
assert!(!agent_session_complete(&agent_no_path));
|
|
1009
|
+
}
|
|
877
1010
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//! unit-6 (Stage 2) — provider session namespace.
|
|
2
|
+
//!
|
|
3
|
+
//! Houses session-specific provider concerns: resume preflight,
|
|
4
|
+
//! session capture (moved from `crate::session_capture` in unit-6),
|
|
5
|
+
//! session backing checks, etc.
|
|
6
|
+
|
|
7
|
+
pub mod capture;
|
|
8
|
+
pub mod resume;
|
|
9
|
+
|
|
10
|
+
pub use resume::{
|
|
11
|
+
ProviderBackingCheck, RecoveryHint, ResumeDecisionDetail, ResumePreflight,
|
|
12
|
+
ResumePreflightOutcome, ResumeRefusalReason,
|
|
13
|
+
};
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
//! unit-5 (Stage 2) — resume preflight + closed `ResumeRefusalReason` enum.
|
|
2
|
+
//!
|
|
3
|
+
//! Today the restart resume gate flattens every refusal into one of two
|
|
4
|
+
//! opaque strings:
|
|
5
|
+
//!
|
|
6
|
+
//! * `"no_persisted_session_id"` — no `session_id` in state
|
|
7
|
+
//! * `"session_unresumable"` — `session_id` set but backing missing
|
|
8
|
+
//! OR provider can't resume OR session
|
|
9
|
+
//! ambiguous (all collapsed to one string)
|
|
10
|
+
//!
|
|
11
|
+
//! That string is the user-facing diagnostic, so debugging "why did restart
|
|
12
|
+
//! refuse?" requires reading source. This unit replaces the catch-all
|
|
13
|
+
//! `session_unresumable` with a closed enum carrying the structured
|
|
14
|
+
//! distinction.
|
|
15
|
+
//!
|
|
16
|
+
//! Migration strategy: ADDITIVE. `UnresumableWorker.reason: String` stays
|
|
17
|
+
//! (every CLI/JSON caller already reads it). A new optional
|
|
18
|
+
//! `refusal_reason: Option<ResumeRefusalReason>` carries the structured
|
|
19
|
+
//! value alongside. Callers that need actionable diagnostics flip to the
|
|
20
|
+
//! enum; legacy callers see the same string they always saw.
|
|
21
|
+
|
|
22
|
+
use std::path::PathBuf;
|
|
23
|
+
|
|
24
|
+
/// Layer 2 self-healing hint (architect probe 2026-06-22, §Recommended
|
|
25
|
+
/// design): operator-facing diagnostic that names the agent / cwd /
|
|
26
|
+
/// provider for a missing-backing refusal so the operator can manually
|
|
27
|
+
/// find / repair the dropped session. Carried alongside the refusal —
|
|
28
|
+
/// NEVER consumed for automatic resume. Auto-recovery requires multi-key
|
|
29
|
+
/// filtering + backing revalidation (Layer 3 follow-up, see
|
|
30
|
+
/// `session.recovery.candidate_promoted` event design).
|
|
31
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
32
|
+
pub struct RecoveryHint {
|
|
33
|
+
/// Agent role label that was passed at launch as `--name` / `-n`
|
|
34
|
+
/// (Claude / Copilot). Codex has no launch-time name flag, so this
|
|
35
|
+
/// is `None` for Codex workers (probe verdict 2026-06-22).
|
|
36
|
+
pub provider_session_name_hint: Option<String>,
|
|
37
|
+
/// Spawn cwd recorded for the worker — the second key in the
|
|
38
|
+
/// "provider+cwd+name+updated_at" filter Layer 3 will require.
|
|
39
|
+
pub spawn_cwd: Option<PathBuf>,
|
|
40
|
+
/// Wire provider name (`codex` / `claude` / `claude_code` /
|
|
41
|
+
/// `copilot`). Used for picker hint string only.
|
|
42
|
+
pub provider: String,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
impl RecoveryHint {
|
|
46
|
+
/// Build a human-readable picker hint (one line). Layer 2 surfaces
|
|
47
|
+
/// this in the CLI refusal message and in the
|
|
48
|
+
/// `session.recovery.candidate_hint` event payload.
|
|
49
|
+
pub fn picker_hint(&self) -> String {
|
|
50
|
+
match (&self.provider_session_name_hint, &self.spawn_cwd) {
|
|
51
|
+
(Some(name), Some(cwd)) => format!(
|
|
52
|
+
"{} session named '{}' under cwd {}",
|
|
53
|
+
self.provider,
|
|
54
|
+
name,
|
|
55
|
+
cwd.display()
|
|
56
|
+
),
|
|
57
|
+
(Some(name), None) => {
|
|
58
|
+
format!("{} session named '{}'", self.provider, name)
|
|
59
|
+
}
|
|
60
|
+
(None, Some(cwd)) => format!("{} session under cwd {}", self.provider, cwd.display()),
|
|
61
|
+
(None, None) => format!("{} session", self.provider),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Structured reason a worker is unresumable. Closed enum — future
|
|
67
|
+
/// reasons require a code change, which is the point.
|
|
68
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
69
|
+
pub enum ResumeRefusalReason {
|
|
70
|
+
/// State has no `session_id` for this worker. Fresh start would be
|
|
71
|
+
/// safe iff `--allow-fresh` is set.
|
|
72
|
+
NoSessionId,
|
|
73
|
+
/// State has `session_id` but the provider backing file (Codex rollout
|
|
74
|
+
/// JSONL / Claude transcript) is missing on disk. Distinct from
|
|
75
|
+
/// "no session" because it implies provider lost the backing.
|
|
76
|
+
SessionBackingStoreMissing {
|
|
77
|
+
/// Paths the runtime probed for the backing file.
|
|
78
|
+
checked_paths: Vec<PathBuf>,
|
|
79
|
+
/// Layer 2 self-healing diagnostic (architect probe 2026-06-22):
|
|
80
|
+
/// agent / cwd / provider hint the operator can use to find or
|
|
81
|
+
/// repair the dropped session via the provider's own picker
|
|
82
|
+
/// (`claude --resume <name>`, `copilot --resume=<name>`).
|
|
83
|
+
/// **Never auto-consumed for resume** — auto-recovery requires
|
|
84
|
+
/// the Layer 3 multi-key filter + backing revalidation contract.
|
|
85
|
+
recovery_hint: Option<RecoveryHint>,
|
|
86
|
+
},
|
|
87
|
+
/// Provider does not support resume at the protocol level (e.g.
|
|
88
|
+
/// some auth modes).
|
|
89
|
+
ProviderResumeUnsupported {
|
|
90
|
+
/// Provider name (`codex` / `claude` / `copilot` / `fake`).
|
|
91
|
+
provider: String,
|
|
92
|
+
},
|
|
93
|
+
/// Session capture polling did not converge before the deadline
|
|
94
|
+
/// (the existing `RefusedResumeNotReady` shape).
|
|
95
|
+
SessionCaptureIncomplete,
|
|
96
|
+
/// State session_id differs from the provider's observed session
|
|
97
|
+
/// (T6 L5.5 drift). Caller should reconcile before resuming.
|
|
98
|
+
SessionDrift {
|
|
99
|
+
expected: String,
|
|
100
|
+
actual: String,
|
|
101
|
+
},
|
|
102
|
+
/// Catch-all for refusals the structured shape hasn't taxonomized
|
|
103
|
+
/// yet. Carries the legacy free-form string. This variant exists so
|
|
104
|
+
/// the enum is BACKWARD-COMPATIBLE with the historical
|
|
105
|
+
/// `session_unresumable` string — every value the old code emitted
|
|
106
|
+
/// can be lifted into the enum without losing fidelity.
|
|
107
|
+
Other { legacy_reason: String },
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
impl ResumeRefusalReason {
|
|
111
|
+
/// Stable wire string mirroring the historical
|
|
112
|
+
/// `UnresumableWorker.reason` values. Use this when emitting JSON or
|
|
113
|
+
/// log fields so downstream consumers see the same strings they
|
|
114
|
+
/// always have.
|
|
115
|
+
pub fn wire(&self) -> &'static str {
|
|
116
|
+
match self {
|
|
117
|
+
ResumeRefusalReason::NoSessionId => "no_persisted_session_id",
|
|
118
|
+
ResumeRefusalReason::SessionBackingStoreMissing { .. } => {
|
|
119
|
+
"session_backing_store_missing"
|
|
120
|
+
}
|
|
121
|
+
ResumeRefusalReason::ProviderResumeUnsupported { .. } => {
|
|
122
|
+
"provider_resume_unsupported"
|
|
123
|
+
}
|
|
124
|
+
ResumeRefusalReason::SessionCaptureIncomplete => "session_capture_incomplete",
|
|
125
|
+
ResumeRefusalReason::SessionDrift { .. } => "session_drift",
|
|
126
|
+
// For Other we still report the legacy wire so the existing
|
|
127
|
+
// `session_unresumable` JSON shape is preserved end-to-end.
|
|
128
|
+
ResumeRefusalReason::Other { .. } => "session_unresumable",
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// Lift a legacy free-form `reason` string into the structured enum.
|
|
133
|
+
/// Round-trip-safe with `wire()` for the canonical names.
|
|
134
|
+
pub fn from_legacy(reason: &str) -> Self {
|
|
135
|
+
match reason {
|
|
136
|
+
"no_persisted_session_id" => ResumeRefusalReason::NoSessionId,
|
|
137
|
+
"session_backing_store_missing" => {
|
|
138
|
+
ResumeRefusalReason::SessionBackingStoreMissing {
|
|
139
|
+
checked_paths: Vec::new(),
|
|
140
|
+
recovery_hint: None,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
"provider_resume_unsupported" => ResumeRefusalReason::ProviderResumeUnsupported {
|
|
144
|
+
provider: String::new(),
|
|
145
|
+
},
|
|
146
|
+
"session_capture_incomplete" => ResumeRefusalReason::SessionCaptureIncomplete,
|
|
147
|
+
other => ResumeRefusalReason::Other {
|
|
148
|
+
legacy_reason: other.to_string(),
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/// Outcome of `ResumePreflight::check`.
|
|
155
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
156
|
+
pub enum ResumePreflightOutcome {
|
|
157
|
+
/// Worker can resume from the named session.
|
|
158
|
+
Resume {
|
|
159
|
+
session_id: String,
|
|
160
|
+
},
|
|
161
|
+
/// `--allow-fresh` was set; worker will start fresh.
|
|
162
|
+
FreshStart,
|
|
163
|
+
/// Resume refused. Caller MUST NOT proceed with teardown/spawn.
|
|
164
|
+
Refuse {
|
|
165
|
+
reason: ResumeRefusalReason,
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/// Information about whether a provider backing file is present on disk.
|
|
170
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
171
|
+
pub struct ProviderBackingCheck {
|
|
172
|
+
pub paths: Vec<PathBuf>,
|
|
173
|
+
pub exists: bool,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/// Per-decision detail emitted to events / JSON.
|
|
177
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
178
|
+
pub struct ResumeDecisionDetail {
|
|
179
|
+
pub agent_id: String,
|
|
180
|
+
pub session_id: Option<String>,
|
|
181
|
+
pub provider: String,
|
|
182
|
+
pub backing: Option<ProviderBackingCheck>,
|
|
183
|
+
pub outcome: ResumePreflightOutcome,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/// Pure resume preflight evaluator. No I/O — callers pass facts in.
|
|
187
|
+
pub struct ResumePreflight;
|
|
188
|
+
|
|
189
|
+
impl ResumePreflight {
|
|
190
|
+
/// Decide a single worker's resume outcome from the facts the
|
|
191
|
+
/// restart code already collected. Mirrors the existing
|
|
192
|
+
/// `classify_restart_plan_with_resume_validation` decision tree:
|
|
193
|
+
///
|
|
194
|
+
/// * session_id present + provider can resume + backing exists -> Resume
|
|
195
|
+
/// * session_id present + (no resume OR no backing) + allow_fresh -> FreshStart
|
|
196
|
+
/// * session_id present + (no resume OR no backing) + !allow_fresh -> Refuse
|
|
197
|
+
/// * session_id absent + allow_fresh -> FreshStart
|
|
198
|
+
/// * session_id absent + !allow_fresh -> Refuse(NoSessionId)
|
|
199
|
+
pub fn check(
|
|
200
|
+
session_id: Option<&str>,
|
|
201
|
+
provider_can_resume: bool,
|
|
202
|
+
backing: Option<&ProviderBackingCheck>,
|
|
203
|
+
provider_name: &str,
|
|
204
|
+
allow_fresh: bool,
|
|
205
|
+
) -> ResumePreflightOutcome {
|
|
206
|
+
Self::check_with_hint(
|
|
207
|
+
session_id,
|
|
208
|
+
provider_can_resume,
|
|
209
|
+
backing,
|
|
210
|
+
provider_name,
|
|
211
|
+
allow_fresh,
|
|
212
|
+
None,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/// Layer 2 self-healing variant: same decision tree as
|
|
217
|
+
/// [`Self::check`], but propagates an optional `RecoveryHint` into
|
|
218
|
+
/// the `SessionBackingStoreMissing` refusal so the CLI / event log
|
|
219
|
+
/// can surface a "look for session named X under cwd Y" pointer to
|
|
220
|
+
/// the operator. The hint is for HUMAN consumption only — no
|
|
221
|
+
/// auto-resume happens off it (Layer 3 follow-up).
|
|
222
|
+
pub fn check_with_hint(
|
|
223
|
+
session_id: Option<&str>,
|
|
224
|
+
provider_can_resume: bool,
|
|
225
|
+
backing: Option<&ProviderBackingCheck>,
|
|
226
|
+
provider_name: &str,
|
|
227
|
+
allow_fresh: bool,
|
|
228
|
+
recovery_hint: Option<RecoveryHint>,
|
|
229
|
+
) -> ResumePreflightOutcome {
|
|
230
|
+
match session_id {
|
|
231
|
+
Some(sid) if provider_can_resume => {
|
|
232
|
+
let backing_present = backing.map(|b| b.exists).unwrap_or(true);
|
|
233
|
+
if backing_present {
|
|
234
|
+
ResumePreflightOutcome::Resume {
|
|
235
|
+
session_id: sid.to_string(),
|
|
236
|
+
}
|
|
237
|
+
} else if allow_fresh {
|
|
238
|
+
ResumePreflightOutcome::FreshStart
|
|
239
|
+
} else {
|
|
240
|
+
ResumePreflightOutcome::Refuse {
|
|
241
|
+
reason: ResumeRefusalReason::SessionBackingStoreMissing {
|
|
242
|
+
checked_paths: backing
|
|
243
|
+
.map(|b| b.paths.clone())
|
|
244
|
+
.unwrap_or_default(),
|
|
245
|
+
recovery_hint,
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
Some(_) if allow_fresh => ResumePreflightOutcome::FreshStart,
|
|
251
|
+
Some(_) => ResumePreflightOutcome::Refuse {
|
|
252
|
+
reason: ResumeRefusalReason::ProviderResumeUnsupported {
|
|
253
|
+
provider: provider_name.to_string(),
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
None if allow_fresh => ResumePreflightOutcome::FreshStart,
|
|
257
|
+
None => ResumePreflightOutcome::Refuse {
|
|
258
|
+
reason: ResumeRefusalReason::NoSessionId,
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#[cfg(test)]
|
|
265
|
+
mod tests {
|
|
266
|
+
use super::*;
|
|
267
|
+
|
|
268
|
+
#[test]
|
|
269
|
+
fn wire_round_trip_canonical_strings() {
|
|
270
|
+
for wire in [
|
|
271
|
+
"no_persisted_session_id",
|
|
272
|
+
"session_backing_store_missing",
|
|
273
|
+
"provider_resume_unsupported",
|
|
274
|
+
"session_capture_incomplete",
|
|
275
|
+
] {
|
|
276
|
+
assert_eq!(ResumeRefusalReason::from_legacy(wire).wire(), wire);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
#[test]
|
|
281
|
+
fn other_preserves_legacy_session_unresumable_wire() {
|
|
282
|
+
let r = ResumeRefusalReason::from_legacy("session_unresumable");
|
|
283
|
+
assert_eq!(r.wire(), "session_unresumable");
|
|
284
|
+
assert!(matches!(r, ResumeRefusalReason::Other { .. }));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
#[test]
|
|
288
|
+
fn preflight_resume_when_session_and_backing_present() {
|
|
289
|
+
let backing = ProviderBackingCheck {
|
|
290
|
+
paths: vec![PathBuf::from("/tmp/x.jsonl")],
|
|
291
|
+
exists: true,
|
|
292
|
+
};
|
|
293
|
+
let out = ResumePreflight::check(Some("sess-1"), true, Some(&backing), "codex", false);
|
|
294
|
+
assert!(matches!(out, ResumePreflightOutcome::Resume { .. }));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#[test]
|
|
298
|
+
fn preflight_refuses_when_session_id_missing_without_allow_fresh() {
|
|
299
|
+
let out = ResumePreflight::check(None, true, None, "codex", false);
|
|
300
|
+
assert_eq!(
|
|
301
|
+
out,
|
|
302
|
+
ResumePreflightOutcome::Refuse {
|
|
303
|
+
reason: ResumeRefusalReason::NoSessionId
|
|
304
|
+
}
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#[test]
|
|
309
|
+
fn preflight_distinguishes_backing_missing_from_no_session_id() {
|
|
310
|
+
let backing = ProviderBackingCheck {
|
|
311
|
+
paths: vec![PathBuf::from("/missing.jsonl")],
|
|
312
|
+
exists: false,
|
|
313
|
+
};
|
|
314
|
+
let out = ResumePreflight::check(Some("sess-x"), true, Some(&backing), "codex", false);
|
|
315
|
+
match out {
|
|
316
|
+
ResumePreflightOutcome::Refuse { reason } => {
|
|
317
|
+
assert_eq!(reason.wire(), "session_backing_store_missing");
|
|
318
|
+
}
|
|
319
|
+
other => panic!("expected backing-missing refusal; got {other:?}"),
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#[test]
|
|
324
|
+
fn preflight_allow_fresh_converts_refusals_to_fresh() {
|
|
325
|
+
let backing = ProviderBackingCheck {
|
|
326
|
+
paths: vec![PathBuf::from("/missing.jsonl")],
|
|
327
|
+
exists: false,
|
|
328
|
+
};
|
|
329
|
+
assert_eq!(
|
|
330
|
+
ResumePreflight::check(None, true, None, "codex", true),
|
|
331
|
+
ResumePreflightOutcome::FreshStart
|
|
332
|
+
);
|
|
333
|
+
assert_eq!(
|
|
334
|
+
ResumePreflight::check(Some("sess-x"), true, Some(&backing), "codex", true),
|
|
335
|
+
ResumePreflightOutcome::FreshStart
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Layer 2 self-healing tests ────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
#[test]
|
|
342
|
+
fn recovery_hint_picker_string_with_all_fields() {
|
|
343
|
+
let hint = RecoveryHint {
|
|
344
|
+
provider_session_name_hint: Some("coder".to_string()),
|
|
345
|
+
spawn_cwd: Some(PathBuf::from("/repo/team-a")),
|
|
346
|
+
provider: "claude".to_string(),
|
|
347
|
+
};
|
|
348
|
+
assert_eq!(
|
|
349
|
+
hint.picker_hint(),
|
|
350
|
+
"claude session named 'coder' under cwd /repo/team-a"
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
#[test]
|
|
355
|
+
fn recovery_hint_picker_string_no_name_no_cwd() {
|
|
356
|
+
// Codex worker — no launch-time name, spawn_cwd missing.
|
|
357
|
+
let hint = RecoveryHint {
|
|
358
|
+
provider_session_name_hint: None,
|
|
359
|
+
spawn_cwd: None,
|
|
360
|
+
provider: "codex".to_string(),
|
|
361
|
+
};
|
|
362
|
+
assert_eq!(hint.picker_hint(), "codex session");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
#[test]
|
|
366
|
+
fn preflight_with_hint_attaches_to_backing_missing_refusal() {
|
|
367
|
+
let backing = ProviderBackingCheck {
|
|
368
|
+
paths: vec![PathBuf::from("/missing.jsonl")],
|
|
369
|
+
exists: false,
|
|
370
|
+
};
|
|
371
|
+
let hint = RecoveryHint {
|
|
372
|
+
provider_session_name_hint: Some("coder".to_string()),
|
|
373
|
+
spawn_cwd: Some(PathBuf::from("/repo")),
|
|
374
|
+
provider: "claude".to_string(),
|
|
375
|
+
};
|
|
376
|
+
let out = ResumePreflight::check_with_hint(
|
|
377
|
+
Some("sess-1"),
|
|
378
|
+
true,
|
|
379
|
+
Some(&backing),
|
|
380
|
+
"claude",
|
|
381
|
+
false,
|
|
382
|
+
Some(hint.clone()),
|
|
383
|
+
);
|
|
384
|
+
match out {
|
|
385
|
+
ResumePreflightOutcome::Refuse {
|
|
386
|
+
reason: ResumeRefusalReason::SessionBackingStoreMissing {
|
|
387
|
+
recovery_hint: Some(h),
|
|
388
|
+
..
|
|
389
|
+
},
|
|
390
|
+
} => {
|
|
391
|
+
assert_eq!(h, hint);
|
|
392
|
+
}
|
|
393
|
+
other => panic!("expected SessionBackingStoreMissing with hint; got {other:?}"),
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
#[test]
|
|
398
|
+
fn preflight_no_hint_legacy_behavior_unchanged() {
|
|
399
|
+
// Default check() (no hint) still produces None recovery_hint —
|
|
400
|
+
// wire-string-compatible with pre-Layer-2 callers.
|
|
401
|
+
let backing = ProviderBackingCheck {
|
|
402
|
+
paths: vec![PathBuf::from("/missing.jsonl")],
|
|
403
|
+
exists: false,
|
|
404
|
+
};
|
|
405
|
+
let out = ResumePreflight::check(Some("sess-x"), true, Some(&backing), "codex", false);
|
|
406
|
+
match out {
|
|
407
|
+
ResumePreflightOutcome::Refuse {
|
|
408
|
+
reason: ResumeRefusalReason::SessionBackingStoreMissing {
|
|
409
|
+
recovery_hint, ..
|
|
410
|
+
},
|
|
411
|
+
} => {
|
|
412
|
+
assert!(recovery_hint.is_none());
|
|
413
|
+
}
|
|
414
|
+
other => panic!("expected backing-missing refusal; got {other:?}"),
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|