@team-agent/installer 0.4.11 → 0.5.1
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 +1 -0
- package/crates/team-agent/src/cli/diagnose.rs +2 -15
- package/crates/team-agent/src/cli/emit.rs +98 -5
- package/crates/team-agent/src/cli/mod.rs +2 -0
- package/crates/team-agent/src/cli/named_address.rs +864 -0
- package/crates/team-agent/src/cli/send.rs +104 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/mod.rs +1 -0
- package/crates/team-agent/src/cli/tests/named_address.rs +455 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +4 -0
- package/crates/team-agent/src/compiler.rs +11 -20
- package/crates/team-agent/src/coordinator/orphan.rs +2 -2
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +5 -4
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +1776 -2
- package/crates/team-agent/src/coordinator/steps/mod.rs +19 -0
- package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +78 -7
- package/crates/team-agent/src/coordinator/tests/abnormal.rs +195 -0
- package/crates/team-agent/src/coordinator/tick.rs +31 -932
- package/crates/team-agent/src/diagnose/orphans.rs +66 -8
- package/crates/team-agent/src/layout/worker_window_helpers.rs +5 -7
- package/crates/team-agent/src/leader/provider_attribution.rs +7 -5
- package/crates/team-agent/src/leader/rediscover.rs +1 -11
- package/crates/team-agent/src/lifecycle/launch/plan.rs +19 -8
- package/crates/team-agent/src/lifecycle/launch.rs +135 -58
- package/crates/team-agent/src/lifecycle/lock.rs +302 -0
- package/crates/team-agent/src/lifecycle/mod.rs +1 -0
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -11
- package/crates/team-agent/src/lifecycle/restart/agent.rs +169 -113
- package/crates/team-agent/src/lifecycle/restart/common.rs +108 -17
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -32
- package/crates/team-agent/src/lifecycle/restart/remove.rs +16 -2
- package/crates/team-agent/src/lifecycle/restart/selection.rs +2 -1
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +6 -0
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +3 -3
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +321 -0
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +35 -5
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +675 -0
- package/crates/team-agent/src/lifecycle/tests.rs +3 -0
- package/crates/team-agent/src/lifecycle/types.rs +8 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +24 -9
- package/crates/team-agent/src/mcp_server/tools.rs +157 -68
- package/crates/team-agent/src/messaging/activity.rs +43 -18
- package/crates/team-agent/src/messaging/delivery.rs +161 -97
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +22 -1
- package/crates/team-agent/src/messaging/results.rs +34 -9
- package/crates/team-agent/src/messaging/scheduler.rs +52 -9
- package/crates/team-agent/src/model/spec.rs +10 -6
- package/crates/team-agent/src/provider/adapter.rs +10 -1038
- package/crates/team-agent/src/provider/adapters/claude.rs +3 -8
- package/crates/team-agent/src/provider/adapters/copilot.rs +2 -5
- package/crates/team-agent/src/provider/classify.rs +23 -48
- package/crates/team-agent/src/provider/command.rs +16 -7
- package/crates/team-agent/src/provider/faults.rs +93 -53
- package/crates/team-agent/src/provider/session/capture.rs +4 -15
- package/crates/team-agent/src/provider/session_scan/claude.rs +309 -0
- package/crates/team-agent/src/provider/session_scan/codex.rs +40 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +350 -0
- package/crates/team-agent/src/provider/session_scan/copilot.rs +202 -0
- package/crates/team-agent/src/provider/session_scan.rs +65 -27
- package/crates/team-agent/src/provider/tests/faults.rs +80 -0
- package/crates/team-agent/src/provider/types.rs +33 -1
- package/crates/team-agent/src/provider/wire.rs +171 -1
- package/crates/team-agent/src/state/identity.rs +242 -57
- package/crates/team-agent/src/state/identity_keys.rs +9 -12
- package/crates/team-agent/src/state/mod.rs +5 -1
- package/crates/team-agent/src/state/owner_gate.rs +59 -15
- package/crates/team-agent/src/state/ownership.rs +12 -11
- package/crates/team-agent/src/state/paths.rs +13 -6
- package/crates/team-agent/src/state/persist.rs +671 -128
- package/crates/team-agent/src/state/projection.rs +240 -49
- package/crates/team-agent/src/state/selector.rs +11 -5
- package/crates/team-agent/src/tmux_backend/tests.rs +51 -2
- package/crates/team-agent/src/tmux_backend.rs +42 -5
- package/crates/team-agent/src/transport/test_support.rs +55 -6
- package/package.json +4 -4
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
use std::path::{Path, PathBuf};
|
|
2
|
+
|
|
3
|
+
use crate::provider::types::{CaptureVia, CapturedSession, Confidence, RolloutPath, SessionId};
|
|
4
|
+
|
|
5
|
+
use super::{CaptureSessionContext, CapturedSessionCandidate};
|
|
6
|
+
|
|
7
|
+
pub(super) fn scan_session_store(context: &CaptureSessionContext) -> Vec<CapturedSessionCandidate> {
|
|
8
|
+
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
|
|
9
|
+
return Vec::new();
|
|
10
|
+
};
|
|
11
|
+
let db_path = home.join(".copilot").join("session-store.db");
|
|
12
|
+
if !db_path.exists() {
|
|
13
|
+
return Vec::new();
|
|
14
|
+
}
|
|
15
|
+
let Ok(conn) = rusqlite::Connection::open_with_flags(
|
|
16
|
+
&db_path,
|
|
17
|
+
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
|
18
|
+
) else {
|
|
19
|
+
return Vec::new();
|
|
20
|
+
};
|
|
21
|
+
if let Some(expected) = context.expected_session_id.as_ref() {
|
|
22
|
+
let hit: Option<String> = conn
|
|
23
|
+
.prepare("select id from sessions where id = ?1 limit 1")
|
|
24
|
+
.ok()
|
|
25
|
+
.and_then(|mut stmt| {
|
|
26
|
+
stmt.query_row([expected.as_str()], |row| row.get::<_, String>(0))
|
|
27
|
+
.ok()
|
|
28
|
+
});
|
|
29
|
+
if let Some(session_id) = hit {
|
|
30
|
+
return vec![copilot_candidate(session_id, &db_path, context)];
|
|
31
|
+
}
|
|
32
|
+
return Vec::new();
|
|
33
|
+
}
|
|
34
|
+
let _ = (&db_path, &conn);
|
|
35
|
+
Vec::new()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
fn copilot_candidate(
|
|
39
|
+
session_id: String,
|
|
40
|
+
db_path: &Path,
|
|
41
|
+
context: &CaptureSessionContext,
|
|
42
|
+
) -> CapturedSessionCandidate {
|
|
43
|
+
CapturedSessionCandidate {
|
|
44
|
+
captured: CapturedSession {
|
|
45
|
+
session_id: Some(SessionId::new(session_id)),
|
|
46
|
+
rollout_path: Some(RolloutPath::new(db_path.to_path_buf())),
|
|
47
|
+
captured_via: CaptureVia::FsWatch,
|
|
48
|
+
attribution_confidence: Confidence::High,
|
|
49
|
+
spawn_cwd: context.spawn_cwd.clone(),
|
|
50
|
+
},
|
|
51
|
+
positive_agent_id_match: false,
|
|
52
|
+
agent_path_match: false,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#[cfg(test)]
|
|
57
|
+
mod tests {
|
|
58
|
+
#![allow(clippy::unwrap_used)]
|
|
59
|
+
|
|
60
|
+
use super::*;
|
|
61
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
62
|
+
|
|
63
|
+
struct HomeGuard {
|
|
64
|
+
prev: Option<std::ffi::OsString>,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
impl HomeGuard {
|
|
68
|
+
fn set(home: &Path) -> Self {
|
|
69
|
+
let prev = std::env::var_os("HOME");
|
|
70
|
+
std::env::set_var("HOME", home);
|
|
71
|
+
Self { prev }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
impl Drop for HomeGuard {
|
|
76
|
+
fn drop(&mut self) {
|
|
77
|
+
match &self.prev {
|
|
78
|
+
Some(v) => std::env::set_var("HOME", v),
|
|
79
|
+
None => std::env::remove_var("HOME"),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fn tmp_root(tag: &str) -> PathBuf {
|
|
85
|
+
static CTR: AtomicU64 = AtomicU64::new(0);
|
|
86
|
+
let dir = std::env::temp_dir().join(format!(
|
|
87
|
+
"ta-e11-copilot-{}-{}-{}",
|
|
88
|
+
tag,
|
|
89
|
+
std::process::id(),
|
|
90
|
+
CTR.fetch_add(1, Ordering::Relaxed)
|
|
91
|
+
));
|
|
92
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
93
|
+
dir
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fn seed_copilot_db(home: &Path, rows: &[(&str, &str, i64)]) {
|
|
97
|
+
let dir = home.join(".copilot");
|
|
98
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
99
|
+
let conn = rusqlite::Connection::open(dir.join("session-store.db")).unwrap();
|
|
100
|
+
conn.execute(
|
|
101
|
+
"create table sessions (id text primary key, cwd text, updated_at integer)",
|
|
102
|
+
[],
|
|
103
|
+
)
|
|
104
|
+
.unwrap();
|
|
105
|
+
for (id, cwd, updated) in rows {
|
|
106
|
+
conn.execute(
|
|
107
|
+
"insert into sessions (id, cwd, updated_at) values (?1, ?2, ?3)",
|
|
108
|
+
rusqlite::params![id, cwd, updated],
|
|
109
|
+
)
|
|
110
|
+
.unwrap();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#[test]
|
|
115
|
+
#[serial_test::serial(env)]
|
|
116
|
+
fn copilot_expected_id_wins_over_leader_latest_same_cwd() {
|
|
117
|
+
let base = tmp_root("expected");
|
|
118
|
+
let home = base.join("home");
|
|
119
|
+
std::fs::create_dir_all(&home).unwrap();
|
|
120
|
+
let cwd = base.join("ws");
|
|
121
|
+
std::fs::create_dir_all(&cwd).unwrap();
|
|
122
|
+
let worker_id = "1142c4c2-0000-4000-8000-000000000001";
|
|
123
|
+
let leader_id = "f9c5485d-0000-4000-8000-00000000beef";
|
|
124
|
+
seed_copilot_db(
|
|
125
|
+
&home,
|
|
126
|
+
&[
|
|
127
|
+
(worker_id, &cwd.to_string_lossy(), 100),
|
|
128
|
+
(leader_id, &cwd.to_string_lossy(), 999),
|
|
129
|
+
],
|
|
130
|
+
);
|
|
131
|
+
let home_guard = HomeGuard::set(&home);
|
|
132
|
+
let ctx = CaptureSessionContext {
|
|
133
|
+
agent_id: "worker".to_string(),
|
|
134
|
+
spawn_cwd: cwd.clone(),
|
|
135
|
+
pane_id: None,
|
|
136
|
+
pane_pid: None,
|
|
137
|
+
spawned_at: None,
|
|
138
|
+
expected_session_id: Some(SessionId::new(worker_id)),
|
|
139
|
+
provider_projects_root: None,
|
|
140
|
+
};
|
|
141
|
+
let out = scan_session_store(&ctx);
|
|
142
|
+
assert_eq!(out.len(), 1);
|
|
143
|
+
assert_eq!(
|
|
144
|
+
out[0].captured.session_id.as_ref().unwrap().as_str(),
|
|
145
|
+
worker_id
|
|
146
|
+
);
|
|
147
|
+
drop(home_guard);
|
|
148
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#[test]
|
|
152
|
+
#[serial_test::serial(env)]
|
|
153
|
+
fn copilot_expected_id_absent_in_db_returns_empty_not_leader() {
|
|
154
|
+
let base = tmp_root("absent");
|
|
155
|
+
let home = base.join("home");
|
|
156
|
+
std::fs::create_dir_all(&home).unwrap();
|
|
157
|
+
let cwd = base.join("ws");
|
|
158
|
+
std::fs::create_dir_all(&cwd).unwrap();
|
|
159
|
+
let leader_id = "f9c5485d-0000-4000-8000-00000000beef";
|
|
160
|
+
seed_copilot_db(&home, &[(leader_id, &cwd.to_string_lossy(), 999)]);
|
|
161
|
+
let home_guard = HomeGuard::set(&home);
|
|
162
|
+
let ctx = CaptureSessionContext {
|
|
163
|
+
agent_id: "worker".to_string(),
|
|
164
|
+
spawn_cwd: cwd.clone(),
|
|
165
|
+
pane_id: None,
|
|
166
|
+
pane_pid: None,
|
|
167
|
+
spawned_at: None,
|
|
168
|
+
expected_session_id: Some(SessionId::new("1142c4c2-0000-4000-8000-000000000001")),
|
|
169
|
+
provider_projects_root: None,
|
|
170
|
+
};
|
|
171
|
+
let out = scan_session_store(&ctx);
|
|
172
|
+
assert!(out.is_empty());
|
|
173
|
+
drop(home_guard);
|
|
174
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#[test]
|
|
178
|
+
#[serial_test::serial(env)]
|
|
179
|
+
fn copilot_no_expected_same_cwd_only_leader_row_returns_empty_not_leader() {
|
|
180
|
+
let base = tmp_root("noexp");
|
|
181
|
+
let home = base.join("home");
|
|
182
|
+
std::fs::create_dir_all(&home).unwrap();
|
|
183
|
+
let cwd = base.join("ws");
|
|
184
|
+
std::fs::create_dir_all(&cwd).unwrap();
|
|
185
|
+
let leader_id = "f9c5485d-0000-4000-8000-00000000beef";
|
|
186
|
+
seed_copilot_db(&home, &[(leader_id, &cwd.to_string_lossy(), 999)]);
|
|
187
|
+
let home_guard = HomeGuard::set(&home);
|
|
188
|
+
let ctx = CaptureSessionContext {
|
|
189
|
+
agent_id: "worker".to_string(),
|
|
190
|
+
spawn_cwd: cwd.clone(),
|
|
191
|
+
pane_id: None,
|
|
192
|
+
pane_pid: None,
|
|
193
|
+
spawned_at: None,
|
|
194
|
+
expected_session_id: None,
|
|
195
|
+
provider_projects_root: None,
|
|
196
|
+
};
|
|
197
|
+
let out = scan_session_store(&ctx);
|
|
198
|
+
assert!(out.is_empty());
|
|
199
|
+
drop(home_guard);
|
|
200
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -1,49 +1,87 @@
|
|
|
1
|
-
//!
|
|
1
|
+
//! Provider session-scan boundary.
|
|
2
2
|
//!
|
|
3
|
-
//!
|
|
4
|
-
//!
|
|
5
|
-
//!
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
//! `ProviderAdapter` keeps the public polling facade in `adapter.rs`; this
|
|
4
|
+
//! module owns one-shot backing-store scans and provider-specific attribution
|
|
5
|
+
//! filters.
|
|
6
|
+
|
|
7
|
+
use std::path::PathBuf;
|
|
8
|
+
|
|
9
|
+
use crate::provider::types::{CapturedSession, ProviderError, SessionId};
|
|
10
|
+
use crate::provider::Provider;
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
pub(crate) mod claude;
|
|
13
|
+
pub(crate) mod codex;
|
|
14
|
+
pub(crate) mod common;
|
|
15
|
+
pub(crate) mod copilot;
|
|
16
|
+
|
|
17
|
+
/// Marker label for which scan strategy a provider uses.
|
|
15
18
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
16
19
|
pub enum SessionScanStrategy {
|
|
17
|
-
/// Codex rollout jsonl scan (the existing
|
|
18
|
-
/// `agent_rollout_path`-based check).
|
|
19
20
|
CodexJsonl,
|
|
20
|
-
/// Claude transcript scan (per-cwd `.claude/projects/<cwd>/*.jsonl`).
|
|
21
21
|
ClaudeTranscript,
|
|
22
|
-
/// Copilot scan (different home; provider-specific layout).
|
|
23
22
|
CopilotHome,
|
|
24
|
-
/// Provider does not require a backing scan to decide resume — the
|
|
25
|
-
/// session-id alone is the authority.
|
|
26
23
|
NotRequired,
|
|
27
24
|
}
|
|
28
25
|
|
|
29
26
|
impl SessionScanStrategy {
|
|
30
|
-
pub fn for_provider(provider:
|
|
27
|
+
pub fn for_provider(provider: Provider) -> Self {
|
|
31
28
|
match provider {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
crate::provider::Provider::GeminiCli => Self::NotRequired,
|
|
38
|
-
crate::provider::Provider::Fake => Self::NotRequired,
|
|
29
|
+
Provider::Codex => Self::CodexJsonl,
|
|
30
|
+
Provider::Claude | Provider::ClaudeCode => Self::ClaudeTranscript,
|
|
31
|
+
Provider::Copilot => Self::CopilotHome,
|
|
32
|
+
Provider::GeminiCli => Self::NotRequired,
|
|
33
|
+
Provider::Fake => Self::NotRequired,
|
|
39
34
|
}
|
|
40
35
|
}
|
|
41
36
|
}
|
|
42
37
|
|
|
38
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
39
|
+
pub struct CaptureSessionContext {
|
|
40
|
+
pub agent_id: String,
|
|
41
|
+
pub spawn_cwd: PathBuf,
|
|
42
|
+
pub pane_id: Option<String>,
|
|
43
|
+
pub pane_pid: Option<u32>,
|
|
44
|
+
pub spawned_at: Option<String>,
|
|
45
|
+
pub expected_session_id: Option<SessionId>,
|
|
46
|
+
pub provider_projects_root: Option<PathBuf>,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
50
|
+
pub struct CapturedSessionCandidate {
|
|
51
|
+
pub captured: CapturedSession,
|
|
52
|
+
pub positive_agent_id_match: bool,
|
|
53
|
+
pub agent_path_match: bool,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
pub(crate) fn scan_session_candidates_once(
|
|
57
|
+
provider: Provider,
|
|
58
|
+
context: &CaptureSessionContext,
|
|
59
|
+
) -> Result<Vec<CapturedSessionCandidate>, ProviderError> {
|
|
60
|
+
if matches!(provider, Provider::Copilot) {
|
|
61
|
+
return Ok(copilot::scan_session_store(context));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let candidates = common::candidate_session_files(provider, context)?;
|
|
65
|
+
let mut out = common::parse_candidate_files(provider, context, candidates);
|
|
66
|
+
|
|
67
|
+
if matches!(provider, Provider::Codex) {
|
|
68
|
+
codex::apply_spawned_at_filter(&mut out, context);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if matches!(provider, Provider::Claude | Provider::ClaudeCode) {
|
|
72
|
+
return claude::apply_expected_session_filter(context, out);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
common::apply_spawn_time_window_if_unique(context, &mut out);
|
|
76
|
+
common::sort_expected_first_if_needed(context, &mut out);
|
|
77
|
+
Ok(out)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
pub(crate) use claude::rollout_path_has_leader_marker as rollout_path_has_claude_leader_marker;
|
|
81
|
+
|
|
43
82
|
#[cfg(test)]
|
|
44
83
|
mod tests {
|
|
45
84
|
use super::*;
|
|
46
|
-
use crate::provider::Provider;
|
|
47
85
|
|
|
48
86
|
#[test]
|
|
49
87
|
fn strategy_per_provider() {
|
|
@@ -44,6 +44,10 @@ fn p2_claude_api_error_fault_requires_level_error() {
|
|
|
44
44
|
assert_eq!(facts.len(), 1);
|
|
45
45
|
assert_eq!(facts[0].signature.as_str(), "api_error");
|
|
46
46
|
assert_eq!(facts[0].turn_id.as_ref().map(TurnId::as_str), Some("s-1"));
|
|
47
|
+
assert_eq!(facts[0].api_error_status, None);
|
|
48
|
+
assert_eq!(facts[0].error.as_deref(), None);
|
|
49
|
+
assert_eq!(facts[0].request_id.as_deref(), None);
|
|
50
|
+
assert_eq!(facts[0].assistant_uuid.as_deref(), None);
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
// P1 — claude api_error turn_id fallback chain = sessionId -> parentUuid -> uuid
|
|
@@ -61,6 +65,82 @@ fn p2_claude_api_error_turn_id_fallback_parentuuid_then_uuid() {
|
|
|
61
65
|
assert_eq!(f2[0].turn_id.as_ref().map(TurnId::as_str), Some("uu-1"));
|
|
62
66
|
}
|
|
63
67
|
|
|
68
|
+
#[test]
|
|
69
|
+
fn p2_claude_assistant_api_error_fault_uses_uuid_and_structured_details() {
|
|
70
|
+
let records = vec![serde_json::json!({
|
|
71
|
+
"type": "assistant",
|
|
72
|
+
"parentUuid": "parent-1",
|
|
73
|
+
"uuid": "assistant-1",
|
|
74
|
+
"requestId": "req_011CceNfWj2aPY5gtCdakULt",
|
|
75
|
+
"message": {"role": "assistant", "content": [
|
|
76
|
+
{"type": "text", "text": "There's an issue with the selected model."}
|
|
77
|
+
]},
|
|
78
|
+
"error": "model_not_found",
|
|
79
|
+
"isApiErrorMessage": true,
|
|
80
|
+
"apiErrorStatus": 404,
|
|
81
|
+
"sessionId": "session-1",
|
|
82
|
+
"version": "2.1.181"
|
|
83
|
+
})];
|
|
84
|
+
|
|
85
|
+
let facts = read_fault_facts(&records, Provider::ClaudeCode);
|
|
86
|
+
|
|
87
|
+
assert_eq!(facts.len(), 1);
|
|
88
|
+
assert_eq!(facts[0].signature.as_str(), "api_error");
|
|
89
|
+
assert_eq!(facts[0].turn_id.as_ref().map(TurnId::as_str), Some("assistant-1"));
|
|
90
|
+
assert_eq!(facts[0].api_error_status, Some(404));
|
|
91
|
+
assert_eq!(facts[0].error.as_deref(), Some("model_not_found"));
|
|
92
|
+
assert_eq!(facts[0].request_id.as_deref(), Some("req_011CceNfWj2aPY5gtCdakULt"));
|
|
93
|
+
assert_eq!(facts[0].assistant_uuid.as_deref(), Some("assistant-1"));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#[test]
|
|
97
|
+
fn p2_latest_explicit_error_fact_scans_claude_tail_past_bookkeeping() {
|
|
98
|
+
let text = [
|
|
99
|
+
line(serde_json::json!({
|
|
100
|
+
"type": "assistant",
|
|
101
|
+
"parentUuid": "245fce6f-2427-4b05-af01-8619df64afab",
|
|
102
|
+
"uuid": "94e88d55-aac7-46bc-85cd-b7fcfc8a9ef6",
|
|
103
|
+
"requestId": "req_011CceUVQ94dxahgwAHf8sdS",
|
|
104
|
+
"message": {"role": "assistant", "content": [
|
|
105
|
+
{"type": "text", "text": "There's an issue with the selected model."}
|
|
106
|
+
]},
|
|
107
|
+
"error": "model_not_found",
|
|
108
|
+
"isApiErrorMessage": true,
|
|
109
|
+
"apiErrorStatus": 404,
|
|
110
|
+
"sessionId": "97ec1070-f19b-49ed-b60f-3cb158e92053",
|
|
111
|
+
"version": "2.1.181"
|
|
112
|
+
})),
|
|
113
|
+
line(serde_json::json!({
|
|
114
|
+
"type": "system",
|
|
115
|
+
"subtype": "turn_duration",
|
|
116
|
+
"uuid": "6c96328b-8c01-46c0-b68e-a096a89c904f",
|
|
117
|
+
"parentUuid": "94e88d55-aac7-46bc-85cd-b7fcfc8a9ef6",
|
|
118
|
+
"sessionId": "97ec1070-f19b-49ed-b60f-3cb158e92053"
|
|
119
|
+
})),
|
|
120
|
+
line(serde_json::json!({"type": "file-history-snapshot"})),
|
|
121
|
+
]
|
|
122
|
+
.join("\n");
|
|
123
|
+
|
|
124
|
+
let fact = latest_explicit_error_fact(Provider::ClaudeCode, &text)
|
|
125
|
+
.expect("Claude assistant API error must survive following bookkeeping rows");
|
|
126
|
+
|
|
127
|
+
assert_eq!(fact.signature.as_str(), "api_error");
|
|
128
|
+
assert_eq!(
|
|
129
|
+
fact.turn_id.as_ref().map(TurnId::as_str),
|
|
130
|
+
Some("94e88d55-aac7-46bc-85cd-b7fcfc8a9ef6")
|
|
131
|
+
);
|
|
132
|
+
assert_eq!(fact.api_error_status, Some(404));
|
|
133
|
+
assert_eq!(fact.error.as_deref(), Some("model_not_found"));
|
|
134
|
+
assert_eq!(fact.request_id.as_deref(), Some("req_011CceUVQ94dxahgwAHf8sdS"));
|
|
135
|
+
assert_eq!(
|
|
136
|
+
fact.assistant_uuid.as_deref(),
|
|
137
|
+
Some("94e88d55-aac7-46bc-85cd-b7fcfc8a9ef6")
|
|
138
|
+
);
|
|
139
|
+
assert!(latest_explicit_error_fact(Provider::Copilot, &text).is_none());
|
|
140
|
+
assert!(latest_explicit_error_fact(Provider::GeminiCli, &text).is_none());
|
|
141
|
+
assert!(latest_explicit_error_fact(Provider::Fake, &text).is_none());
|
|
142
|
+
}
|
|
143
|
+
|
|
64
144
|
// P1 — codex requestApproval turn_id = params.turnId OR params.turn_id (codex.py:79).
|
|
65
145
|
#[test]
|
|
66
146
|
fn p2_codex_approval_turn_id_accepts_snake_case() {
|
|
@@ -452,12 +452,44 @@ pub struct ClassifyResult {
|
|
|
452
452
|
}
|
|
453
453
|
|
|
454
454
|
/// abnormal track 消费的 fault/approval fact(`provider_state.read_fault_facts`)。
|
|
455
|
-
/// C8 dedup key = `(signature, turn_id)`。`turn_id` 可 `None`(`api_error`
|
|
455
|
+
/// C8 dedup key = `(signature, turn_id)`。`turn_id` 可 `None`(旧 `api_error` 可无 ids)。
|
|
456
456
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
457
457
|
pub struct FaultFact {
|
|
458
458
|
pub signature: Signature,
|
|
459
459
|
pub turn_id: Option<TurnId>,
|
|
460
460
|
pub kind: FactKind,
|
|
461
|
+
pub api_error_status: Option<i64>,
|
|
462
|
+
pub error: Option<String>,
|
|
463
|
+
pub request_id: Option<String>,
|
|
464
|
+
pub assistant_uuid: Option<String>,
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
impl FaultFact {
|
|
468
|
+
pub fn new(signature: Signature, turn_id: Option<TurnId>, kind: FactKind) -> Self {
|
|
469
|
+
Self {
|
|
470
|
+
signature,
|
|
471
|
+
turn_id,
|
|
472
|
+
kind,
|
|
473
|
+
api_error_status: None,
|
|
474
|
+
error: None,
|
|
475
|
+
request_id: None,
|
|
476
|
+
assistant_uuid: None,
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
pub fn with_api_error_details(
|
|
481
|
+
mut self,
|
|
482
|
+
api_error_status: Option<i64>,
|
|
483
|
+
error: Option<String>,
|
|
484
|
+
request_id: Option<String>,
|
|
485
|
+
assistant_uuid: Option<String>,
|
|
486
|
+
) -> Self {
|
|
487
|
+
self.api_error_status = api_error_status;
|
|
488
|
+
self.error = error;
|
|
489
|
+
self.request_id = request_id;
|
|
490
|
+
self.assistant_uuid = assistant_uuid;
|
|
491
|
+
self
|
|
492
|
+
}
|
|
461
493
|
}
|
|
462
494
|
|
|
463
495
|
/// take-over reminder 判定结果(`idle_predicate.evaluate_takeover_reminder` `_result`)。
|
|
@@ -51,6 +51,52 @@ pub(crate) fn parse_provider(raw: &str) -> Option<Provider> {
|
|
|
51
51
|
None
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/// Parse only canonical persisted provider wire strings.
|
|
55
|
+
///
|
|
56
|
+
/// Use this for spec/schema surfaces that historically rejected aliases such as
|
|
57
|
+
/// `claude-code`; use [`parse_provider`] when reading state/log fields where
|
|
58
|
+
/// historical aliases must remain accepted.
|
|
59
|
+
pub(crate) fn parse_canonical_provider(raw: &str) -> Option<Provider> {
|
|
60
|
+
all_providers()
|
|
61
|
+
.iter()
|
|
62
|
+
.copied()
|
|
63
|
+
.find(|provider| provider_wire(*provider) == raw)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
pub(crate) fn all_providers() -> &'static [Provider] {
|
|
67
|
+
ALL_PROVIDERS
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
pub(crate) fn is_claude_family(provider: Provider) -> bool {
|
|
71
|
+
matches!(provider, Provider::Claude | Provider::ClaudeCode)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
pub(crate) fn requires_resume_backing(provider: Provider) -> bool {
|
|
75
|
+
matches!(
|
|
76
|
+
provider,
|
|
77
|
+
Provider::Codex | Provider::Claude | Provider::ClaudeCode | Provider::Copilot
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
pub(crate) fn provider_model_keys(provider: Provider) -> &'static [&'static str] {
|
|
82
|
+
match provider {
|
|
83
|
+
Provider::Claude => &["claude", "claude_code"],
|
|
84
|
+
Provider::ClaudeCode => &["claude_code", "claude"],
|
|
85
|
+
Provider::Codex => &["codex"],
|
|
86
|
+
Provider::Copilot => &["copilot"],
|
|
87
|
+
Provider::GeminiCli => &["gemini_cli"],
|
|
88
|
+
Provider::Fake => &["fake"],
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
pub(crate) fn builtin_provider_model(provider: Provider) -> Option<&'static str> {
|
|
93
|
+
match provider {
|
|
94
|
+
Provider::Claude | Provider::ClaudeCode => Some("claude-sonnet-4-6"),
|
|
95
|
+
Provider::Codex => Some("gpt-5.5"),
|
|
96
|
+
Provider::Copilot | Provider::GeminiCli | Provider::Fake => None,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
54
100
|
/// All aliases (including the canonical wire form) that parse to `provider`.
|
|
55
101
|
/// The canonical wire form is always the first entry, so callers that need
|
|
56
102
|
/// the "primary" string can take `aliases(p)[0]` — though most code should
|
|
@@ -97,10 +143,18 @@ mod tests {
|
|
|
97
143
|
use super::*;
|
|
98
144
|
|
|
99
145
|
#[test]
|
|
100
|
-
fn
|
|
146
|
+
fn provider_wire_round_trip_all_providers() {
|
|
101
147
|
for &p in ALL_PROVIDERS {
|
|
102
148
|
let wire = provider_wire(p);
|
|
103
149
|
assert_eq!(parse_provider(wire), Some(p), "round-trip {p:?}");
|
|
150
|
+
assert_eq!(
|
|
151
|
+
parse_canonical_provider(wire),
|
|
152
|
+
Some(p),
|
|
153
|
+
"canonical parse {p:?}"
|
|
154
|
+
);
|
|
155
|
+
for alias in aliases(p) {
|
|
156
|
+
assert_eq!(parse_provider(alias), Some(p), "alias {alias:?} -> {p:?}");
|
|
157
|
+
}
|
|
104
158
|
}
|
|
105
159
|
}
|
|
106
160
|
|
|
@@ -110,6 +164,122 @@ mod tests {
|
|
|
110
164
|
assert_eq!(parse_provider("claude-code"), Some(Provider::ClaudeCode));
|
|
111
165
|
}
|
|
112
166
|
|
|
167
|
+
#[test]
|
|
168
|
+
fn provider_wire_migrated_sites_compile_without_local_provider_clones() {
|
|
169
|
+
let capture_rs = include_str!("session/capture.rs");
|
|
170
|
+
let restart_selection_rs = include_str!("../lifecycle/restart/selection.rs");
|
|
171
|
+
let model_spec_rs = include_str!("../model/spec.rs");
|
|
172
|
+
let compiler_rs = include_str!("../compiler.rs");
|
|
173
|
+
let delivery_rs = include_str!("../messaging/delivery.rs");
|
|
174
|
+
let rediscover_rs = include_str!("../leader/rediscover.rs");
|
|
175
|
+
let profile_smoke_rs = include_str!("../lifecycle/profile_smoke.rs");
|
|
176
|
+
let diagnose_rs = include_str!("../cli/diagnose.rs");
|
|
177
|
+
|
|
178
|
+
assert!(
|
|
179
|
+
capture_rs.contains("provider::wire") && capture_rs.contains("parse_provider"),
|
|
180
|
+
"session capture must import the canonical provider parser"
|
|
181
|
+
);
|
|
182
|
+
assert!(
|
|
183
|
+
!capture_rs.contains("fn parse_provider("),
|
|
184
|
+
"session capture must not carry a local provider parser copy"
|
|
185
|
+
);
|
|
186
|
+
assert!(
|
|
187
|
+
capture_rs.contains("is_claude_family"),
|
|
188
|
+
"session capture must use typed Claude-family detection"
|
|
189
|
+
);
|
|
190
|
+
assert!(
|
|
191
|
+
restart_selection_rs.contains("parse_canonical_provider")
|
|
192
|
+
&& restart_selection_rs.contains("requires_resume_backing"),
|
|
193
|
+
"restart selection must route resumable backing through typed provider helpers"
|
|
194
|
+
);
|
|
195
|
+
assert!(
|
|
196
|
+
!restart_selection_rs.contains(r#""codex" | "claude" | "claude_code" | "copilot""#),
|
|
197
|
+
"restart selection must not keep a local resumable provider string match"
|
|
198
|
+
);
|
|
199
|
+
assert!(
|
|
200
|
+
!model_spec_rs.contains("SUPPORTED_PROVIDERS"),
|
|
201
|
+
"model spec must not keep a local provider whitelist"
|
|
202
|
+
);
|
|
203
|
+
assert!(
|
|
204
|
+
model_spec_rs.contains("parse_canonical_provider")
|
|
205
|
+
&& model_spec_rs.contains("is_claude_family"),
|
|
206
|
+
"model spec must validate providers through typed helpers"
|
|
207
|
+
);
|
|
208
|
+
assert!(
|
|
209
|
+
compiler_rs.contains("parse_canonical_provider")
|
|
210
|
+
&& compiler_rs.contains("provider_model_keys")
|
|
211
|
+
&& compiler_rs.contains("wire_builtin_provider_model"),
|
|
212
|
+
"compiler effort/model defaults must use typed provider helpers"
|
|
213
|
+
);
|
|
214
|
+
assert!(
|
|
215
|
+
delivery_rs.contains("parse_canonical_provider")
|
|
216
|
+
&& delivery_rs.contains("parse_provider")
|
|
217
|
+
&& delivery_rs.contains("provider_wire"),
|
|
218
|
+
"message delivery provider dispatch must use wire helpers"
|
|
219
|
+
);
|
|
220
|
+
for (path, source) in [
|
|
221
|
+
("leader/rediscover.rs", rediscover_rs),
|
|
222
|
+
("lifecycle/profile_smoke.rs", profile_smoke_rs),
|
|
223
|
+
("cli/diagnose.rs", diagnose_rs),
|
|
224
|
+
] {
|
|
225
|
+
assert!(
|
|
226
|
+
!source.contains("fn provider_wire("),
|
|
227
|
+
"{path} must not carry a local provider_wire clone"
|
|
228
|
+
);
|
|
229
|
+
assert!(
|
|
230
|
+
source.contains("provider::wire"),
|
|
231
|
+
"{path} must import provider wire helpers"
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#[test]
|
|
237
|
+
fn provider_wire_command_grammar_sites_are_named_helpers_not_provider_parse() {
|
|
238
|
+
let provider_attribution_rs = include_str!("../leader/provider_attribution.rs");
|
|
239
|
+
let worker_window_helpers_rs = include_str!("../layout/worker_window_helpers.rs");
|
|
240
|
+
let runtime_detectors_rs = include_str!("../coordinator/runtime_detectors.rs");
|
|
241
|
+
let emit_rs = include_str!("../cli/emit.rs");
|
|
242
|
+
let messaging_helpers_rs = include_str!("../messaging/helpers.rs");
|
|
243
|
+
|
|
244
|
+
assert!(
|
|
245
|
+
provider_attribution_rs.contains("COMMAND_GRAMMAR_PROVIDER_COMMANDS")
|
|
246
|
+
&& provider_attribution_rs.contains("provider_from_command_text"),
|
|
247
|
+
"leader provider attribution must name command grammar explicitly"
|
|
248
|
+
);
|
|
249
|
+
assert!(
|
|
250
|
+
worker_window_helpers_rs.contains("provider_window_name")
|
|
251
|
+
&& worker_window_helpers_rs.contains("Command/UI grammar"),
|
|
252
|
+
"worker window helpers must keep a named window grammar helper"
|
|
253
|
+
);
|
|
254
|
+
assert!(
|
|
255
|
+
runtime_detectors_rs.contains("runtime_event_provider_name")
|
|
256
|
+
&& runtime_detectors_rs.contains("Event-field grammar"),
|
|
257
|
+
"runtime detectors must name event provider grammar explicitly"
|
|
258
|
+
);
|
|
259
|
+
assert!(
|
|
260
|
+
emit_rs.contains("LEADER_PASSTHROUGH_COMMANDS")
|
|
261
|
+
&& emit_rs.contains("is_leader_passthrough_command"),
|
|
262
|
+
"CLI emit must name passthrough command grammar explicitly"
|
|
263
|
+
);
|
|
264
|
+
assert!(
|
|
265
|
+
messaging_helpers_rs.contains("non_provider_command")
|
|
266
|
+
&& messaging_helpers_rs.contains("Activity command grammar"),
|
|
267
|
+
"messaging helpers must name activity command grammar explicitly"
|
|
268
|
+
);
|
|
269
|
+
for (path, source) in [
|
|
270
|
+
("leader/provider_attribution.rs", provider_attribution_rs),
|
|
271
|
+
("layout/worker_window_helpers.rs", worker_window_helpers_rs),
|
|
272
|
+
("coordinator/runtime_detectors.rs", runtime_detectors_rs),
|
|
273
|
+
("cli/emit.rs", emit_rs),
|
|
274
|
+
("messaging/helpers.rs", messaging_helpers_rs),
|
|
275
|
+
] {
|
|
276
|
+
assert!(
|
|
277
|
+
!source.contains("parse_canonical_provider"),
|
|
278
|
+
"{path} is command/UI grammar and must not reuse provider identity parsing"
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
113
283
|
#[test]
|
|
114
284
|
fn unknown_string_returns_none() {
|
|
115
285
|
assert_eq!(parse_provider(""), None);
|