1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
use crate::vcs::{VcsBackend, VcsError};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum OrchestratorError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON serialization error: {0}")]
Json(#[from] serde_json::Error),
#[error("Agent command failed: {0}")]
AgentCommand(String),
#[error("Parse error: {0}")]
Parse(String),
#[error("No changes found")]
NoChanges,
#[error("UTF-8 conversion error: {0}")]
Utf8(#[from] std::string::FromUtf8Error),
#[error("Config load error: {0}")]
ConfigLoad(String),
#[error("Config parse error: {0}")]
ConfigParse(String),
#[error("Hook execution failed ({hook_type}): {message}")]
HookFailed { hook_type: String, message: String },
#[error("Hook timed out ({hook_type}): exceeded {timeout_secs}s")]
HookTimeout {
hook_type: String,
timeout_secs: u64,
},
#[error("Failed to launch editor: {0}")]
EditorLaunchFailed(String),
#[error("Change directory not found: {0}")]
ChangeNotFound(String),
#[error("VCS error: {0}")]
Vcs(Box<VcsError>),
// Legacy error variants kept for backward compatibility
// These delegate to VcsError internally
#[error("Git command failed: {0}")]
GitCommand(String),
#[error("Git merge conflict: {0}")]
GitConflict(String),
#[error("Git has uncommitted changes: {0}")]
#[allow(dead_code)] // Used when VcsError::UncommittedChanges is converted
GitUncommittedChanges(String),
#[error("No VCS backend available for parallel execution")]
#[allow(dead_code)] // Reserved for future use when git is unavailable
NoVcsBackend,
#[error("Permission auto-rejected: {denied_path}\n{guidance}")]
#[allow(dead_code)]
// Legacy soft-block variant retained for older apply paths.
PermissionBlocked {
denied_path: String,
guidance: String,
},
#[error("Repeated unresolved permission/tool policy denial: {guidance}")]
PermissionStalled {
denied_path: String,
guidance: String,
},
/// The sole per-change Apply-dispatch budget owner refused to reserve
/// another dispatch because the positive `max_iterations` ceiling is spent.
///
/// This is deliberately a distinct variant rather than an untyped
/// [`OrchestratorError::AgentCommand`]: CLI, TUI, and remote-controlled run
/// boundaries must preserve `iteration_limit` finish-status ownership rather
/// than reclassify budget exhaustion as an ordinary agent-command crash.
#[error("Max iterations ({max}) reached for change '{change_id}' after {attempts} Apply dispatch(es): {diagnostic}")]
IterationLimit {
change_id: String,
attempts: u32,
max: u32,
diagnostic: String,
},
/// An in-flight operation observed explicit cancellation and terminated its
/// child.
///
/// Typed rather than an untyped [`OrchestratorError::AgentCommand`] so run
/// boundaries can report one intentional stop for both global cancellation
/// and a per-change queue stop instead of an execution failure. The
/// rendering is unchanged, so existing message-based handling keeps working.
#[error("Cancelled {operation} for '{change_id}' in workspace '{workspace}'")]
Cancelled {
operation: String,
change_id: String,
workspace: String,
},
/// An owned command was terminated by its absolute runtime limit.
///
/// Typed rather than an ordinary non-zero exit because the exit status of a
/// SIGKILLed agent is indistinguishable from a crash. A crash is retryable
/// evidence; this is a boundary decision, and retrying it in the same run
/// would re-run exactly the work the limit just stopped.
#[error(
"{operation} for '{change_id}' in workspace '{workspace}' was terminated by its \
absolute runtime limit of {limit_secs}s; this invocation is not retried in this run"
)]
RuntimeLimit {
operation: String,
change_id: String,
workspace: String,
limit_secs: u64,
},
}
impl OrchestratorError {
/// Explicit cancellation of `operation` for `change_id` in `workspace`.
pub fn cancelled(
operation: impl Into<String>,
change_id: impl Into<String>,
workspace: &std::path::Path,
) -> Self {
OrchestratorError::Cancelled {
operation: operation.into(),
change_id: change_id.into(),
workspace: workspace.display().to_string(),
}
}
/// An owned command stopped by its absolute runtime limit.
pub fn runtime_limit(
operation: impl Into<String>,
change_id: impl Into<String>,
workspace: &std::path::Path,
limit_secs: u64,
) -> Self {
OrchestratorError::RuntimeLimit {
operation: operation.into(),
change_id: change_id.into(),
workspace: workspace.display().to_string(),
limit_secs,
}
}
/// True when this error is an explicit operator/queue cancellation rather
/// than a failure.
pub fn is_cancellation(&self) -> bool {
matches!(self, OrchestratorError::Cancelled { .. })
}
/// True when this error is absolute-runtime-limit termination.
///
/// Kept separate from [`Self::is_cancellation`] so an operator stop and a
/// runaway command that Conflux stopped by itself stay distinguishable in
/// reporting, even though neither may be retried in the same run.
#[allow(dead_code)] // Read by apply-interruption coverage, not by the binary.
pub fn is_runtime_limit(&self) -> bool {
matches!(self, OrchestratorError::RuntimeLimit { .. })
}
/// True when a boundary deliberately terminated the invocation.
///
/// A deliberate termination is a decision, not a transient failure: the run
/// may report it and stop, but it must never turn it back into another
/// dispatch of the same work.
#[allow(dead_code)] // Read by apply-interruption coverage, not by the binary.
pub fn is_terminal_interruption(&self) -> bool {
self.is_cancellation() || self.is_runtime_limit()
}
}
#[allow(dead_code)] // Legacy API helpers, kept for backward compatibility
impl OrchestratorError {
/// Create a GitCommand error (legacy, prefer VcsError::git_command)
pub fn git_command(msg: impl Into<String>) -> Self {
OrchestratorError::GitCommand(msg.into())
}
/// Create an error from VcsError with proper variant mapping
pub fn from_vcs_error(err: VcsError) -> Self {
match err {
VcsError::Command {
backend,
message,
command,
working_dir,
stderr,
stdout,
} => {
// Use the Display implementation which includes full context
let full_message = format!(
"{}",
VcsError::Command {
backend,
message: message.clone(),
command: command.clone(),
working_dir: working_dir.clone(),
stderr: stderr.clone(),
stdout: stdout.clone(),
}
);
match backend {
VcsBackend::Git => OrchestratorError::GitCommand(full_message),
VcsBackend::Auto => OrchestratorError::Vcs(Box::new(VcsError::Command {
backend,
message,
command,
working_dir,
stderr,
stdout,
})),
}
}
VcsError::Conflict { backend, details } => match backend {
VcsBackend::Git => OrchestratorError::GitConflict(details),
VcsBackend::Auto => {
OrchestratorError::Vcs(Box::new(VcsError::Conflict { backend, details }))
}
},
VcsError::NotAvailable { .. } => OrchestratorError::NoVcsBackend,
VcsError::UncommittedChanges(msg) => OrchestratorError::GitUncommittedChanges(msg),
VcsError::NoBackend => OrchestratorError::NoVcsBackend,
VcsError::Io(e) => OrchestratorError::Io(e),
}
}
}
impl From<VcsError> for OrchestratorError {
fn from(err: VcsError) -> Self {
OrchestratorError::Vcs(Box::new(err))
}
}
pub type Result<T> = std::result::Result<T, OrchestratorError>;