From fd8c874375978724e40105f89483739288a7cbfb Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sun, 15 Feb 2026 20:46:17 -0700 Subject: [PATCH 1/2] task/449: Add structured error handling with error codes --- README.md | 14 ++++++++++ index.js | 79 +++++++++++++++++++++++++++++++++++++++---------------- 2 files changed, 70 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 7d2209d..b530057 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,20 @@ Sends notifications for: - PR opened (each run) - Failures (each run) +## Error Codes + +When a task fails, the failure reason includes a structured error code in brackets (e.g. `[ERR_GIT_FAILED] Git setup failed: ...`). These codes appear in ATS task failure reasons, log output, and Telegram notifications. + +| Code | Meaning | +|------|---------| +| `ERR_TASK_NOT_FOUND` | Could not fetch or find the task in ATS | +| `ERR_CLAIM_FAILED` | Failed to claim the run task lease | +| `ERR_CLAUDE_TIMEOUT` | Claude Code timed out or exited with a non-zero code | +| `ERR_GIT_FAILED` | Git operation failed (checkout, pull, branch creation, push) | +| `ERR_PR_FAILED` | `gh pr create` failed | + +Errors that don't match a known code are reported as `ERR_UNKNOWN`. + ## Original Task Policy The runner **never** claims, completes, fails, or modifies the original task. It is treated as a read-only trigger/reference. All state management happens on the suffixed copy tasks. diff --git a/index.js b/index.js index 6887a88..9a6e022 100644 --- a/index.js +++ b/index.js @@ -33,6 +33,21 @@ const ACTOR_FLAGS = ['--actor-type', 'agent', '--actor-id', 'ats-project-runner' const ONESHOT_KEYWORDS = ['fix typo', 'update version', 'rename', 'bump', 'typo', 'version bump']; const ITERATIVE_KEYWORDS = ['add', 'implement', 'refactor', 'debug', 'investigate', 'build', 'create', 'feature']; +// === Error codes === +const ERR_TASK_NOT_FOUND = 'ERR_TASK_NOT_FOUND'; +const ERR_CLAIM_FAILED = 'ERR_CLAIM_FAILED'; +const ERR_CLAUDE_TIMEOUT = 'ERR_CLAUDE_TIMEOUT'; +const ERR_GIT_FAILED = 'ERR_GIT_FAILED'; +const ERR_PR_FAILED = 'ERR_PR_FAILED'; + +class ProjectRunnerError extends Error { + constructor(message, code) { + super(message); + this.name = 'ProjectRunnerError'; + this.code = code; + } +} + let running = true; let currentChild = null; // active Claude child process let processing = false; // true while a task is being processed (watch mode) @@ -211,9 +226,9 @@ function runClaude(prompt, repoPath) { child.on('close', (code, signal) => { clearTimeout(timer); if (signal === 'SIGTERM' || code === 143) { - reject(new Error('Claude timed out or was cancelled')); + reject(new ProjectRunnerError('Claude timed out or was cancelled', ERR_CLAUDE_TIMEOUT)); } else if (code !== 0) { - reject(new Error(`Claude exited with code ${code}`)); + reject(new ProjectRunnerError(`Claude exited with code ${code}`, ERR_CLAUDE_TIMEOUT)); } else { resolve(stdout); } @@ -280,8 +295,9 @@ async function processTask(task, project, projectName, runNumber, modeOverride, claimTask(runTaskId); postMessage(runTaskId, `Processing original task #${origTaskId}: ${title}`); } catch (err) { - log('error', 'Failed to claim run task', { runTaskId, error: err.message }); - return { success: false, error: err.message, run_number: runNumber }; + const claimErr = new ProjectRunnerError(`Failed to claim run task: ${err.message}`, ERR_CLAIM_FAILED); + log('error', 'Failed to claim run task', { runTaskId, code: claimErr.code, error: claimErr.message }); + return { success: false, error: claimErr.message, code: ERR_CLAIM_FAILED, run_number: runNumber }; } // 3. Lease renewal heartbeat (only on the run task) @@ -308,12 +324,20 @@ async function processTask(task, project, projectName, runNumber, modeOverride, catch { try { git(repoPath, 'rev-parse', '--verify', 'origin/master'); defaultBranch = 'master'; } catch {} } } - git(repoPath, 'checkout', defaultBranch); - git(repoPath, 'pull', '--ff-only'); + try { + git(repoPath, 'checkout', defaultBranch); + git(repoPath, 'pull', '--ff-only'); + } catch (err) { + throw new ProjectRunnerError(`Git setup failed: ${err.message}`, ERR_GIT_FAILED); + } const branchSuffix = runNumber > 1 ? `-run${runNumber}` : ''; const branchName = `task/${origTaskId}-${slugify(title)}${branchSuffix}`; - git(repoPath, 'checkout', '-b', branchName); + try { + git(repoPath, 'checkout', '-b', branchName); + } catch (err) { + throw new ProjectRunnerError(`Failed to create branch ${branchName}: ${err.message}`, ERR_GIT_FAILED); + } log('info', 'Created branch', { branchName }); postMessage(runTaskId, `Created branch: ${branchName}`); @@ -390,14 +414,21 @@ async function processTask(task, project, projectName, runNumber, modeOverride, let prUrl = null; if (hasChanges(repoPath)) { postMessage(runTaskId, 'Committing changes'); - git(repoPath, 'add', '-A'); - - const commitMsg = `task/${origTaskId}: ${title}`; - git(repoPath, 'commit', '-m', commitMsg); + try { + git(repoPath, 'add', '-A'); + const commitMsg = `task/${origTaskId}: ${title}`; + git(repoPath, 'commit', '-m', commitMsg); + } catch (err) { + throw new ProjectRunnerError(`Git commit failed: ${err.message}`, ERR_GIT_FAILED); + } log('info', 'Committed changes', { runTaskId, branchName }); postMessage(runTaskId, 'Pushing branch'); - git(repoPath, 'push', '-u', 'origin', branchName); + try { + git(repoPath, 'push', '-u', 'origin', branchName); + } catch (err) { + throw new ProjectRunnerError(`Git push failed: ${err.message}`, ERR_GIT_FAILED); + } log('info', 'Pushed branch', { runTaskId, branchName }); postMessage(runTaskId, 'Creating pull request'); @@ -420,8 +451,9 @@ async function processTask(task, project, projectName, runNumber, modeOverride, log('info', 'PR created', { runTaskId, prUrl }); postMessage(runTaskId, `PR created: ${prUrl}`); } catch (err) { - log('error', 'Failed to create PR', { runTaskId, error: err.message, stderr: err.stderr }); - postMessage(runTaskId, `PR creation failed: ${err.message}`); + const prErr = new ProjectRunnerError(`PR creation failed: ${err.message}`, ERR_PR_FAILED); + log('error', 'Failed to create PR', { runTaskId, code: prErr.code, error: prErr.message, stderr: err.stderr }); + postMessage(runTaskId, `[${ERR_PR_FAILED}] ${prErr.message}`); } } else { log('info', 'No changes to commit', { runTaskId }); @@ -453,16 +485,17 @@ async function processTask(task, project, projectName, runNumber, modeOverride, return { success: true, runTaskId, prUrl, branch: branchName, outputs, run_number: runNumber, summary }; } catch (err) { - log('error', 'Task processing failed', { runTaskId, error: err.message }); - postMessage(runTaskId, `Failed: ${err.message}`); + const errorCode = err.code || 'ERR_UNKNOWN'; + log('error', 'Task processing failed', { runTaskId, code: errorCode, error: err.message }); + postMessage(runTaskId, `Failed [${errorCode}]: ${err.message}`); - try { failTask(runTaskId, err.message); } catch {} - telegram(`\u274C Failed on ${projectName} (run-${runNumber})\nTask: ${title} (#${origTaskId})\n${err.message.slice(0, 200)}`); + try { failTask(runTaskId, `[${errorCode}] ${err.message}`); } catch {} + telegram(`\u274C Failed on ${projectName} (run-${runNumber})\nTask: ${title} (#${origTaskId})\n[${errorCode}] ${err.message.slice(0, 200)}`); // Clean up: try to get back to default branch try { git(repoPath, 'checkout', '-'); } catch {} - return { success: false, runTaskId, error: err.message, run_number: runNumber }; + return { success: false, runTaskId, error: err.message, code: errorCode, run_number: runNumber }; } finally { clearInterval(renewInterval); } @@ -772,11 +805,11 @@ function handleWatchEvent(event, projectName, channel, project) { try { fullTask = getTask(taskId); } catch (err) { - log('error', 'Failed to fetch task details', { taskId, error: err.message }); + log('error', 'Failed to fetch task details', { taskId, code: ERR_TASK_NOT_FOUND, error: err.message }); return; } if (!fullTask) { - log('error', 'Task not found when fetching details', { taskId }); + log('error', 'Task not found when fetching details', { taskId, code: ERR_TASK_NOT_FOUND }); return; } @@ -880,11 +913,11 @@ async function main() { try { task = getTask(taskId); } catch (err) { - log('error', 'Failed to fetch task', { taskId, error: err.message }); + log('error', 'Failed to fetch task', { taskId, code: ERR_TASK_NOT_FOUND, error: err.message }); process.exit(1); } if (!task) { - log('error', 'Task not found', { taskId }); + log('error', 'Task not found', { taskId, code: ERR_TASK_NOT_FOUND }); process.exit(1); } From 44683859b55eea777c082f4c16a9943a99bfc5d7 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sun, 15 Feb 2026 21:33:42 -0700 Subject: [PATCH 2/2] Normalize unknown error codes to ERR_UNKNOWN err.code can leak Node/system error codes (ENOENT, etc) instead of runner's ERR_* constants. Only pass through known runner codes; all others are normalized to ERR_UNKNOWN. Fixes Codex review comment on PR #1. Co-Authored-By: Claude Opus 4.6 --- index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index 9a6e022..2677f4a 100644 --- a/index.js +++ b/index.js @@ -39,6 +39,8 @@ const ERR_CLAIM_FAILED = 'ERR_CLAIM_FAILED'; const ERR_CLAUDE_TIMEOUT = 'ERR_CLAUDE_TIMEOUT'; const ERR_GIT_FAILED = 'ERR_GIT_FAILED'; const ERR_PR_FAILED = 'ERR_PR_FAILED'; +const ERR_UNKNOWN = 'ERR_UNKNOWN'; +const KNOWN_ERROR_CODES = new Set([ERR_TASK_NOT_FOUND, ERR_CLAIM_FAILED, ERR_CLAUDE_TIMEOUT, ERR_GIT_FAILED, ERR_PR_FAILED, ERR_UNKNOWN]); class ProjectRunnerError extends Error { constructor(message, code) { @@ -485,7 +487,7 @@ async function processTask(task, project, projectName, runNumber, modeOverride, return { success: true, runTaskId, prUrl, branch: branchName, outputs, run_number: runNumber, summary }; } catch (err) { - const errorCode = err.code || 'ERR_UNKNOWN'; + const errorCode = (err.code && KNOWN_ERROR_CODES.has(err.code)) ? err.code : ERR_UNKNOWN; log('error', 'Task processing failed', { runTaskId, code: errorCode, error: err.message }); postMessage(runTaskId, `Failed [${errorCode}]: ${err.message}`);