Skip to content

Commit 02f0560

Browse files
committed
feat: add comprehensive error messages with context for user guidance
- Enhance CLI error messages with detailed examples and suggestions - Add contextual error messages to file system operations - Improve OpenCode execution error reporting with debugging hints - Add detailed troubleshooting information for all retry failures - Enhance main loop error messages with recovery suggestions - Provide specific guidance for common error scenarios - Include actionable next steps in all error paths - Fix test helpers to work with updated Logger structure All error messages now include: - Clear description of what went wrong - Possible causes of the error - Actionable steps to resolve the issue - Context-specific hints based on error type Signed-off-by: leocavalcante <[email protected]>
1 parent 9545ae0 commit 02f0560

File tree

7 files changed

+301
-50
lines changed

7 files changed

+301
-50
lines changed

src/cli.zig

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,69 @@ pub fn printVersion(file: std.fs.File) void {
187187
/// Format error message for CLI errors
188188
pub fn formatError(err: ParseError, file: std.fs.File) void {
189189
const msg = switch (err) {
190-
ParseError.MissingRequiredArgs => "Error: Either --provider or both --planning-model and --execution-model are required\nUse -h or --help for usage information\n",
191-
ParseError.UnknownOption => "Error: Unknown option\nUse -h or --help for usage information\n",
192-
ParseError.UnknownProvider => "Error: Unknown provider\nAvailable providers: github, anthropic, openai, opencode\n",
193-
ParseError.InvalidProjectDir => "Error: Project directory does not exist or is not accessible\n",
194-
ParseError.MissingOptionValue => "Error: Option requires a value\nUse -h or --help for usage information\n",
190+
ParseError.MissingRequiredArgs =>
191+
\\Error: Missing required model configuration
192+
\\
193+
\\You must specify models using either:
194+
\\ 1. Provider preset: opencoder --provider github
195+
\\ 2. Explicit models: opencoder -P planning-model -E execution-model
196+
\\
197+
\\Available providers: github, anthropic, openai, opencode
198+
\\
199+
\\For detailed help, run: opencoder --help
200+
\\
201+
,
202+
ParseError.UnknownOption =>
203+
\\Error: Unknown command-line option
204+
\\
205+
\\Common options:
206+
\\ --provider PROVIDER Use a provider preset
207+
\\ -P, --planning-model Specify planning model
208+
\\ -E, --execution-model Specify execution model
209+
\\ -v, --verbose Enable verbose logging
210+
\\ -p, --project DIR Set project directory
211+
\\
212+
\\For complete usage information, run: opencoder --help
213+
\\
214+
,
215+
ParseError.UnknownProvider =>
216+
\\Error: Unknown provider specified
217+
\\
218+
\\Available providers:
219+
\\ github - GitHub Copilot models (Claude Opus 4.5 / Sonnet 4.5)
220+
\\ anthropic - Anthropic models (Claude Sonnet 4 / Haiku)
221+
\\ openai - OpenAI models (GPT-4 / GPT-4o-mini)
222+
\\ opencode - OpenCode free models (GLM-4.7 / Minimax M2.1)
223+
\\
224+
\\Usage: opencoder --provider <name>
225+
\\Example: opencoder --provider github
226+
\\
227+
,
228+
ParseError.InvalidProjectDir =>
229+
\\Error: Project directory not found or not accessible
230+
\\
231+
\\Please check that:
232+
\\ 1. The directory path exists
233+
\\ 2. You have read/write permissions
234+
\\ 3. The path is specified correctly
235+
\\
236+
\\Current directory will be used if -p/--project is not specified.
237+
\\
238+
\\Example: opencoder --provider github -p /path/to/project
239+
\\
240+
,
241+
ParseError.MissingOptionValue =>
242+
\\Error: Command-line option missing required value
243+
\\
244+
\\Options that require values:
245+
\\ --provider PROVIDER (e.g., --provider github)
246+
\\ -P MODEL (e.g., -P anthropic/claude-sonnet-4)
247+
\\ -E MODEL (e.g., -E anthropic/claude-haiku)
248+
\\ -p DIR (e.g., -p /path/to/project)
249+
\\
250+
\\For detailed help, run: opencoder --help
251+
\\
252+
,
195253
};
196254
_ = file.write(msg) catch {};
197255
}

src/evaluator.zig

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ test "EvaluationResult enum values" {
7474

7575
test "hasPendingTasks returns false for missing file" {
7676
const allocator = std.testing.allocator;
77-
const result = hasPendingTasks("/tmp/nonexistent_plan.md", allocator);
77+
const result = hasPendingTasks("/tmp/nonexistent_plan.md", allocator, 1024 * 1024);
7878
try std.testing.expect(!result);
7979
}
8080

@@ -85,7 +85,7 @@ test "hasPendingTasks returns true for plan with pending tasks" {
8585
// Write test plan
8686
try fsutil.writeFile(test_path, "- [ ] Task 1\n- [x] Task 2\n");
8787

88-
const result = hasPendingTasks(test_path, allocator);
88+
const result = hasPendingTasks(test_path, allocator, 1024 * 1024);
8989
try std.testing.expect(result);
9090

9191
// Clean up
@@ -99,7 +99,7 @@ test "hasPendingTasks returns false for completed plan" {
9999
// Write test plan
100100
try fsutil.writeFile(test_path, "- [x] Task 1\n- [x] Task 2\n");
101101

102-
const result = hasPendingTasks(test_path, allocator);
102+
const result = hasPendingTasks(test_path, allocator, 1024 * 1024);
103103
try std.testing.expect(!result);
104104

105105
// Clean up

src/executor.zig

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -107,34 +107,46 @@ pub const Executor = struct {
107107
var attempt: u32 = 0;
108108
while (attempt < self.cfg.max_retries) : (attempt += 1) {
109109
if (attempt > 0) {
110-
self.log.statusFmt("[Cycle {d}] Evaluating (retry {d}/{d})...", .{ cycle, attempt + 1, self.cfg.max_retries });
110+
self.log.statusFmt("[Cycle {d}] Evaluation retry {d}/{d}", .{ cycle, attempt + 1, self.cfg.max_retries });
111111
}
112112

113113
const result = self.runOpencode(self.cfg.planning_model, title, prompt, false);
114114
if (result) |output| {
115115
// Check for COMPLETE or NEEDS_WORK in output
116116
if (std.mem.indexOf(u8, output, "COMPLETE") != null) {
117+
self.log.logFmt("[Cycle {d}] Evaluation result: COMPLETE", .{cycle});
117118
self.allocator.free(output);
118119
return "COMPLETE";
119120
} else if (std.mem.indexOf(u8, output, "NEEDS_WORK") != null) {
121+
self.log.logFmt("[Cycle {d}] Evaluation result: NEEDS_WORK", .{cycle});
120122
self.allocator.free(output);
121123
return "NEEDS_WORK";
124+
} else {
125+
self.log.logErrorFmt("[Cycle {d}] Evaluation returned unexpected response", .{cycle});
126+
self.log.logError(" Expected: 'COMPLETE' or 'NEEDS_WORK'");
127+
self.log.logError(" Hint: The evaluation model may need more specific instructions");
122128
}
123129
self.allocator.free(output);
124-
} else |_| {
125-
// Error running opencode
130+
} else |err| {
131+
self.log.logErrorFmt("[Cycle {d}] Evaluation attempt {d} failed: {s}", .{ cycle, attempt + 1, @errorName(err) });
126132
}
127133

128134
// Backoff before retry
129135
if (attempt + 1 < self.cfg.max_retries) {
130136
const sleep_time = self.cfg.backoff_base * std.math.pow(u32, 2, attempt);
131-
self.log.statusFmt("[Cycle {d}] Retrying evaluation in {d}s...", .{ cycle, sleep_time });
137+
self.log.statusFmt("[Cycle {d}] Waiting {d}s before retry...", .{ cycle, sleep_time });
132138
std.Thread.sleep(@as(u64, sleep_time) * std.time.ns_per_s);
133139
}
134140
}
135141

136142
// Default to NEEDS_WORK if we couldn't determine
137-
self.log.logErrorFmt("Failed to get evaluation result after {d} attempts (cycle {d}), defaulting to NEEDS_WORK", .{ self.cfg.max_retries, cycle });
143+
self.log.logError("");
144+
self.log.logErrorFmt("[Cycle {d}] Failed to get evaluation result after {d} attempts", .{ cycle, self.cfg.max_retries });
145+
self.log.logError(" Defaulting to NEEDS_WORK to continue safely");
146+
self.log.logError(" Possible causes:");
147+
self.log.logError(" - Model API unavailable or rate limited");
148+
self.log.logError(" - Evaluation prompt not producing expected output");
149+
self.log.logError(" - Network connectivity issues");
138150
return "NEEDS_WORK";
139151
}
140152

@@ -152,21 +164,35 @@ pub const Executor = struct {
152164

153165
while (attempt < self.cfg.max_retries) : (attempt += 1) {
154166
if (attempt > 0) {
155-
self.log.logFmt("Attempt {d}/{d}", .{ attempt + 1, self.cfg.max_retries });
167+
self.log.logFmt("Retry attempt {d}/{d}", .{ attempt + 1, self.cfg.max_retries });
156168
}
157169

158170
const result = self.runOpencode(model, title, prompt, continue_session);
159171
if (result) |output| {
160172
self.allocator.free(output);
161173
return .success;
162174
} else |err| {
163-
self.log.logErrorFmt("opencode failed (attempt {d}/{d}): {s} with model '{s}'", .{ attempt + 1, self.cfg.max_retries, @errorName(err), model });
175+
self.log.logErrorFmt("OpenCode execution failed (attempt {d}/{d})", .{ attempt + 1, self.cfg.max_retries });
176+
self.log.logErrorFmt(" Model: {s}", .{model});
177+
self.log.logErrorFmt(" Error: {s}", .{@errorName(err)});
178+
179+
if (attempt + 1 == self.cfg.max_retries) {
180+
// Last attempt, provide detailed troubleshooting
181+
self.log.logError("");
182+
self.log.logError("All retry attempts exhausted. Troubleshooting tips:");
183+
self.log.logError(" 1. Verify 'opencode' CLI is installed: which opencode");
184+
self.log.logError(" 2. Check if opencode works directly: opencode --version");
185+
self.log.logError(" 3. Verify model is available: opencode models list");
186+
self.log.logError(" 4. Check API credentials are configured properly");
187+
self.log.logError(" 5. Review network connectivity and API rate limits");
188+
self.log.logErrorFmt(" 6. Try increasing OPENCODER_MAX_RETRIES (current: {d})", .{self.cfg.max_retries});
189+
}
164190
}
165191

166192
// Backoff before retry
167193
if (attempt + 1 < self.cfg.max_retries) {
168194
const sleep_time = self.cfg.backoff_base * std.math.pow(u32, 2, attempt);
169-
self.log.logFmt("Retrying in {d}s...", .{sleep_time});
195+
self.log.logFmt("Waiting {d}s before retry...", .{sleep_time});
170196
std.Thread.sleep(@as(u64, sleep_time) * std.time.ns_per_s);
171197
}
172198
}
@@ -228,16 +254,34 @@ pub const Executor = struct {
228254
if (code == 0) {
229255
return stdout_list.toOwnedSlice(self.allocator);
230256
}
231-
self.log.logErrorFmt("opencode exited with code {d} (model: {s}, title: {s})", .{ code, model, title });
257+
self.log.logErrorFmt("OpenCode process exited with non-zero status", .{});
258+
self.log.logErrorFmt(" Exit code: {d}", .{code});
259+
self.log.logErrorFmt(" Model: {s}", .{model});
260+
self.log.logErrorFmt(" Title: {s}", .{title});
261+
262+
// Provide context based on exit code
263+
if (code == 1) {
264+
self.log.logError(" Common causes: Invalid arguments, API authentication failure");
265+
} else if (code == 2) {
266+
self.log.logError(" Common causes: Model not found, invalid model specification");
267+
} else if (code >= 126 and code <= 127) {
268+
self.log.logError(" Common causes: opencode CLI not found or not executable");
269+
self.log.logError(" Hint: Verify installation with 'which opencode'");
270+
}
232271
},
233272
.Signal => |sig| {
234-
self.log.logErrorFmt("opencode terminated by signal {d} (model: {s}, title: {s})", .{ sig, model, title });
273+
self.log.logErrorFmt("OpenCode process terminated by signal {d}", .{sig});
274+
self.log.logErrorFmt(" Model: {s}", .{model});
275+
self.log.logError(" This usually indicates the process was killed externally");
276+
self.log.logError(" Hint: Check system resources (memory, CPU) and logs");
235277
},
236278
.Stopped => |sig| {
237-
self.log.logErrorFmt("opencode stopped by signal {d} (model: {s}, title: {s})", .{ sig, model, title });
279+
self.log.logErrorFmt("OpenCode process stopped by signal {d}", .{sig});
280+
self.log.logErrorFmt(" Model: {s}", .{model});
238281
},
239282
.Unknown => |status| {
240-
self.log.logErrorFmt("opencode terminated with unknown status {d} (model: {s}, title: {s})", .{ status, model, title });
283+
self.log.logErrorFmt("OpenCode process terminated with unknown status {d}", .{status});
284+
self.log.logErrorFmt(" Model: {s}", .{model});
241285
},
242286
}
243287

@@ -258,6 +302,7 @@ fn createTestLogger(allocator: Allocator) !*Logger {
258302

259303
try std.fs.cwd().makePath(temp_dir);
260304

305+
const main_log_path = try std.fs.path.join(allocator, &.{ temp_dir, "main.log" });
261306
const cycle_log_dir = try std.fs.path.join(allocator, &.{ temp_dir, "cycles" });
262307
const alerts_file = try std.fs.path.join(allocator, &.{ temp_dir, "alerts.log" });
263308

@@ -266,6 +311,7 @@ fn createTestLogger(allocator: Allocator) !*Logger {
266311
const logger_ptr = try allocator.create(Logger);
267312
logger_ptr.* = Logger{
268313
.main_log = null,
314+
.main_log_path = main_log_path,
269315
.cycle_log_dir = cycle_log_dir,
270316
.alerts_file = alerts_file,
271317
.cycle = 0,
@@ -281,6 +327,7 @@ fn destroyTestLogger(logger_ptr: *Logger, allocator: Allocator) void {
281327
const temp_base = std.fs.path.dirname(logger_ptr.cycle_log_dir) orelse "/tmp";
282328
std.fs.cwd().deleteTree(temp_base) catch {};
283329

330+
allocator.free(logger_ptr.main_log_path);
284331
allocator.free(logger_ptr.cycle_log_dir);
285332
allocator.free(logger_ptr.alerts_file);
286333
allocator.destroy(logger_ptr);

src/fs.zig

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ const std = @import("std");
77
const fs = std.fs;
88
const Allocator = std.mem.Allocator;
99

10+
/// File system operation errors with context
11+
pub const FsError = error{
12+
DirectoryCreationFailed,
13+
FileReadFailed,
14+
FileWriteFailed,
15+
FileNotAccessible,
16+
};
17+
1018
/// Paths to opencoder workspace files and directories
1119
pub const Paths = struct {
1220
opencoder_dir: []const u8,
@@ -33,7 +41,13 @@ pub const Paths = struct {
3341
/// Initialize the .opencoder directory structure
3442
pub fn initDirectories(project_dir: []const u8, allocator: Allocator) !Paths {
3543
// Build all paths
36-
const opencoder_dir = try std.fs.path.join(allocator, &.{ project_dir, ".opencoder" });
44+
const opencoder_dir = std.fs.path.join(allocator, &.{ project_dir, ".opencoder" }) catch |err| {
45+
if (@import("builtin").is_test == false) {
46+
const stderr_file = std.fs.File{ .handle = std.posix.STDERR_FILENO };
47+
_ = stderr_file.write("Error: Failed to construct .opencoder directory path\n") catch {};
48+
}
49+
return err;
50+
};
3751
errdefer allocator.free(opencoder_dir);
3852

3953
const state_file = try std.fs.path.join(allocator, &.{ opencoder_dir, "state.json" });
@@ -57,8 +71,22 @@ pub fn initDirectories(project_dir: []const u8, allocator: Allocator) !Paths {
5771
const history_dir = try std.fs.path.join(allocator, &.{ opencoder_dir, "history" });
5872
errdefer allocator.free(history_dir);
5973

60-
// Create directories
61-
try ensureDir(opencoder_dir);
74+
// Create directories with better error context
75+
ensureDir(opencoder_dir) catch |err| {
76+
if (@import("builtin").is_test == false) {
77+
const stderr_file = std.fs.File{ .handle = std.posix.STDERR_FILENO };
78+
_ = stderr_file.write("\nError: Failed to initialize workspace directory structure\n") catch {};
79+
_ = stderr_file.write("\nPossible causes:\n") catch {};
80+
_ = stderr_file.write(" - Insufficient permissions in project directory\n") catch {};
81+
_ = stderr_file.write(" - Disk full or quota exceeded\n") catch {};
82+
_ = stderr_file.write(" - File system is read-only\n") catch {};
83+
_ = stderr_file.write("\nSuggested actions:\n") catch {};
84+
_ = stderr_file.write(" 1. Check write permissions for project directory\n") catch {};
85+
_ = stderr_file.write(" 2. Check disk space: df -h\n") catch {};
86+
_ = stderr_file.write(" 3. Try a different project directory with -p flag\n") catch {};
87+
}
88+
return err;
89+
};
6290
try ensureDir(logs_dir);
6391
try ensureDir(cycle_log_dir);
6492
try ensureDir(history_dir);
@@ -79,6 +107,11 @@ pub fn initDirectories(project_dir: []const u8, allocator: Allocator) !Paths {
79107
pub fn ensureDir(path: []const u8) !void {
80108
fs.cwd().makePath(path) catch |err| {
81109
if (err != error.PathAlreadyExists) {
110+
if (@import("builtin").is_test == false) {
111+
const stderr_file = std.fs.File{ .handle = std.posix.STDERR_FILENO };
112+
_ = stderr_file.write("Error: Failed to create directory\n") catch {};
113+
_ = stderr_file.write("Hint: Check parent directory permissions and available disk space\n") catch {};
114+
}
82115
return err;
83116
}
84117
};
@@ -92,16 +125,49 @@ pub fn fileExists(path: []const u8) bool {
92125

93126
/// Read entire file contents
94127
pub fn readFile(path: []const u8, allocator: Allocator, max_size: usize) ![]u8 {
95-
const file = try fs.cwd().openFile(path, .{});
128+
const file = fs.cwd().openFile(path, .{}) catch |err| {
129+
// Only print errors in non-test builds
130+
if (@import("builtin").is_test == false) {
131+
const stderr_file = std.fs.File{ .handle = std.posix.STDERR_FILENO };
132+
_ = stderr_file.write("Error: Failed to open file\n") catch {};
133+
_ = stderr_file.write("Hint: Check that the file exists and you have read permissions\n") catch {};
134+
}
135+
return err;
136+
};
96137
defer file.close();
97-
return try file.readToEndAlloc(allocator, max_size);
138+
139+
return file.readToEndAlloc(allocator, max_size) catch |err| {
140+
if (@import("builtin").is_test == false) {
141+
const stderr_file = std.fs.File{ .handle = std.posix.STDERR_FILENO };
142+
_ = stderr_file.write("Error: Failed to read file\n") catch {};
143+
if (err == error.StreamTooLong) {
144+
_ = stderr_file.write("Hint: File exceeds maximum size. Consider increasing OPENCODER_MAX_FILE_SIZE\n") catch {};
145+
}
146+
}
147+
return err;
148+
};
98149
}
99150

100151
/// Write contents to file
101152
pub fn writeFile(path: []const u8, contents: []const u8) !void {
102-
const file = try fs.cwd().createFile(path, .{});
153+
const file = fs.cwd().createFile(path, .{}) catch |err| {
154+
if (@import("builtin").is_test == false) {
155+
const stderr_file = std.fs.File{ .handle = std.posix.STDERR_FILENO };
156+
_ = stderr_file.write("Error: Failed to create/open file\n") catch {};
157+
_ = stderr_file.write("Hint: Check directory permissions and available disk space\n") catch {};
158+
}
159+
return err;
160+
};
103161
defer file.close();
104-
try file.writeAll(contents);
162+
163+
file.writeAll(contents) catch |err| {
164+
if (@import("builtin").is_test == false) {
165+
const stderr_file = std.fs.File{ .handle = std.posix.STDERR_FILENO };
166+
_ = stderr_file.write("Error: Failed to write to file\n") catch {};
167+
_ = stderr_file.write("Hint: Check available disk space and file system permissions\n") catch {};
168+
}
169+
return err;
170+
};
105171
}
106172

107173
/// Delete a file if it exists

src/logger.zig

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,7 @@ test "rotate creates rotated log file" {
412412
defer allocator.free(logs_dir);
413413
try fs.cwd().makePath(logs_dir);
414414

415-
var log = try Logger.init(test_dir, false, allocator);
415+
var log = try Logger.init(test_dir, false, allocator, 2048);
416416
defer log.deinit();
417417

418418
log.log("test message");
@@ -434,7 +434,7 @@ test "cleanup removes old cycle logs" {
434434
defer allocator.free(cycle_dir);
435435
try fs.cwd().makePath(cycle_dir);
436436

437-
var log = try Logger.init(test_dir, false, allocator);
437+
var log = try Logger.init(test_dir, false, allocator, 2048);
438438
defer log.deinit();
439439

440440
const cycle_path = try std.fs.path.join(allocator, &.{ cycle_dir, "cycle_001.log" });

0 commit comments

Comments
 (0)