feat(engine): capture stack traces at task-failure catch sites

Add formatError() helper that extracts both message and full stack from
unknown caught values, and use it at every status:"failed" catch site in
executor, agent-heartbeat, and triage. Stack traces now land in
store.logEntry outcome (persisted to task.log/activityLog) and in stderr
logger output, so failures like "Cannot read properties of undefined
(reading 'filter')" can be diagnosed without re-running.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-24 12:54:13 -07:00
parent eb13669a94
commit 59e270980e
10 changed files with 134 additions and 31 deletions

View File

@@ -25,6 +25,15 @@ vi.mock("./logger.js", () => {
return {
createLogger: vi.fn(() => createMockLogger()),
heartbeatLog: createMockLogger(),
formatError: (err: unknown) => {
if (err instanceof Error) {
const message = err.message || err.name || "Error";
const stack = err.stack;
return { message, stack, detail: stack ?? message };
}
const message = typeof err === "string" ? err : String(err);
return { message, detail: message };
},
};
});

View File

@@ -24,7 +24,7 @@ import { Type, type Static } from "@mariozechner/pi-ai";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createSendMessageTool, createReadMessagesTool, createMemoryTools, taskCreateParams } from "./agent-tools.js";
import { AgentLogger } from "./agent-logger.js";
import { resolveAgentInstructionsWithRatings, buildSystemPromptWithInstructions } from "./agent-instructions.js";
import { heartbeatLog } from "./logger.js";
import { heartbeatLog, formatError } from "./logger.js";
import { createRunAuditor, type EngineRunContext } from "./run-audit.js";
// Lazy import for pi — avoids pulling the pi SDK into the module graph
@@ -1350,12 +1350,12 @@ export class HeartbeatMonitor {
heartbeatLog.log(`Heartbeat completed for ${agentId} (${toolCallCount} tool calls, ~${estimatedOutputTokens} output tokens)`);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
heartbeatLog.error(`Heartbeat execution failed for ${agentId}: ${errorMessage}`);
const errorDetail = formatError(err).detail;
heartbeatLog.error(`Heartbeat execution failed for ${agentId}: ${errorDetail}`);
await flushAgentLogger();
await this.completeRun(agentId, run.id, {
status: "failed",
stderrExcerpt: errorMessage,
stderrExcerpt: errorDetail,
stdoutExcerpt: stdoutExcerpt || undefined,
});
} finally {
@@ -1375,8 +1375,9 @@ export class HeartbeatMonitor {
return (await this.store.getRunDetail(agentId, run.id))!;
} catch (err) {
const errorDetail = formatError(err).detail;
const errorMessage = err instanceof Error ? err.message : String(err);
heartbeatLog.error(`Heartbeat execution error for ${agentId}: ${errorMessage}`);
heartbeatLog.error(`Heartbeat execution error for ${agentId}: ${errorDetail}`);
await flushAgentLogger();
// Attempt to complete the run as failed if it's still active.
@@ -1385,7 +1386,7 @@ export class HeartbeatMonitor {
try {
await this.completeRun(agentId, run.id, {
status: "failed",
stderrExcerpt: errorMessage,
stderrExcerpt: errorDetail,
});
} catch (completeRunErr) {
const completeRunErrMsg = completeRunErr instanceof Error ? completeRunErr.message : String(completeRunErr);

View File

@@ -43,6 +43,15 @@ vi.mock("./logger.js", () => {
ipcLog: createMockLogger(),
projectManagerLog: createMockLogger(),
hybridExecutorLog: createMockLogger(),
formatError: (err: unknown) => {
if (err instanceof Error) {
const message = err.message || err.name || "Error";
const stack = err.stack;
return { message, stack, detail: stack ?? message };
}
const message = typeof err === "string" ? err : String(err);
return { message, detail: message };
},
};
});
vi.mock("./merger.js", () => ({

View File

@@ -18,7 +18,7 @@ import { ModelRegistry, SessionManager, type ToolDefinition, type AgentSession }
import { PRIORITY_EXECUTE, type AgentSemaphore } from "./concurrency.js";
import { getRegisteredWorktreePaths, isRegisteredGitWorktree, isUsableTaskWorktree, type WorktreePool } from "./worktree-pool.js";
import { AgentLogger } from "./agent-logger.js";
import { executorLog, reviewerLog } from "./logger.js";
import { executorLog, reviewerLog, formatError } from "./logger.js";
import { TokenCapDetector } from "./token-cap-detector.js";
import { isUsageLimitError, checkSessionError, type UsageLimitPauser } from "./usage-limit-detector.js";
import { isTransientError, isSilentTransientError } from "./transient-error-detector.js";
@@ -1572,7 +1572,7 @@ export class TaskExecutor {
await retryableStepWork();
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
const { message: errorMessage, detail: errorDetail, stack: errorStack } = formatError(err);
if (this.depAborted.has(task.id)) {
this.depAborted.delete(task.id);
await this.handleDepAbortCleanup(task.id, worktreePath);
@@ -1619,7 +1619,10 @@ export class TaskExecutor {
return;
}
executorLog.error(`${task.id} transient error retries exhausted: ${errorMessage}`);
executorLog.error(`${task.id} transient error retries exhausted: ${errorDetail}`);
if (errorStack) {
await this.store.logEntry(task.id, `Transient error retries exhausted: ${errorMessage}`, errorStack, this.currentRunContext);
}
await this.store.updateTask(task.id, {
status: "failed",
error: errorMessage,
@@ -1630,8 +1633,8 @@ export class TaskExecutor {
executorLog.log(`${task.id} transient retries exhausted → in-review`);
this.options.onError?.(task, err instanceof Error ? err : new Error(errorMessage));
} else {
executorLog.error(`${task.id} step-session execution failed:`, errorMessage);
await this.store.logEntry(task.id, `Step-session execution failed: ${errorMessage}`, undefined, this.currentRunContext);
executorLog.error(`${task.id} step-session execution failed:`, errorDetail);
await this.store.logEntry(task.id, `Step-session execution failed: ${errorMessage}`, errorStack ?? errorDetail, this.currentRunContext);
await this.store.updateTask(task.id, { status: "failed", error: errorMessage });
await this.store.moveTask(task.id, "in-review");
executorLog.log(`${task.id} step-session execution failed → in-review`);
@@ -2196,7 +2199,7 @@ export class TaskExecutor {
await retryableWork();
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
const { message: errorMessage, detail: errorDetail, stack: errorStack } = formatError(err);
if (this.depAborted.has(task.id)) {
// Dependency added mid-execution — discard worktree and move to triage
this.depAborted.delete(task.id);
@@ -2384,8 +2387,8 @@ export class TaskExecutor {
}
// Recovery budget exhausted — escalate to real failure
executorLog.error(`${task.id} transient error retries exhausted (${MAX_RECOVERY_RETRIES} attempts): ${errorMessage}`);
await this.store.logEntry(task.id, `Transient error retries exhausted after ${MAX_RECOVERY_RETRIES} attempts: ${errorMessage}`, undefined, this.currentRunContext);
executorLog.error(`${task.id} transient error retries exhausted (${MAX_RECOVERY_RETRIES} attempts): ${errorDetail}`);
await this.store.logEntry(task.id, `Transient error retries exhausted after ${MAX_RECOVERY_RETRIES} attempts: ${errorMessage}`, errorStack ?? errorDetail, this.currentRunContext);
await this.store.updateTask(task.id, {
status: "failed",
error: errorMessage,
@@ -2397,8 +2400,8 @@ export class TaskExecutor {
this.options.onError?.(task, err instanceof Error ? err : new Error(errorMessage));
return;
}
executorLog.error(`${task.id} execution failed:`, errorMessage);
await this.store.logEntry(task.id, `Execution failed: ${errorMessage}`, undefined, this.currentRunContext);
executorLog.error(`${task.id} execution failed:`, errorDetail);
await this.store.logEntry(task.id, `Execution failed: ${errorMessage}`, errorStack ?? errorDetail, this.currentRunContext);
await this.store.updateTask(task.id, { status: "failed", error: errorMessage });
await this.store.moveTask(task.id, "in-review");
executorLog.log(`${task.id} execution failed → in-review`);
@@ -3522,14 +3525,14 @@ ${failureFeedback}
};
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
const { message: errorMessage, detail: errorDetail, stack: errorStack } = formatError(err);
const completedAt = new Date().toISOString();
await this.store.logEntry(
task.id,
`[pre-merge] Workflow step failed: ${ws.name}`,
errorMessage,
errorStack ?? errorDetail,
);
executorLog.error(`${task.id} — [pre-merge] workflow step error: ${ws.name}${errorMessage}`);
executorLog.error(`${task.id} — [pre-merge] workflow step error: ${ws.name}${errorDetail}`);
// Update existing pending entry in place
const existingIdx = results.findIndex(r => r.workflowStepId === ws.id);
if (existingIdx >= 0) {

View File

@@ -27,6 +27,10 @@ export interface Logger {
* @returns A `Logger` whose output is prefixed and sent to stderr. Keeping
* engine logs off stdout prevents command/test output consumers from
* receiving Fusion execution chatter.
*
* The logger prepends an internal control-character severity marker
* so dashboard TUI console-capture can preserve info/warn/error
* semantics even when `log()` is transported via `console.error`.
*/
export declare function createLogger(prefix: string): Logger;
/** Logger for the scheduler subsystem. */
@@ -65,4 +69,18 @@ export declare const remoteNodeLog: Logger;
export declare const nodeHealthMonitorLog: Logger;
/** Logger for the peer exchange (gossip) subsystem. */
export declare const peerExchangeLog: Logger;
/**
* Extract both a short message and a full stack trace from an unknown caught
* value. Use this at catch sites instead of the
* `err instanceof Error ? err.message : String(err)` idiom so that the stack
* is preserved for logs, task `activityLog` entries, and surfaced diagnostics.
*
* `detail` is `message` when no stack is available and `message + "\n" + stack`
* otherwise — suitable for `store.logEntry(taskId, action, detail)`.
*/
export declare function formatError(err: unknown): {
message: string;
stack?: string;
detail: string;
};
//# sourceMappingURL=logger.d.ts.map

View File

@@ -1 +1 @@
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["logger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,WAAW,MAAM;IACrB,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAC/C,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAChD,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;CAClD;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAanD;AAED,0CAA0C;AAC1C,eAAO,MAAM,YAAY,QAA4B,CAAC;AAEtD,8CAA8C;AAC9C,eAAO,MAAM,WAAW,QAA2B,CAAC;AAEpD,iDAAiD;AACjD,eAAO,MAAM,SAAS,QAAyB,CAAC;AAEhD,iDAAiD;AACjD,eAAO,MAAM,KAAK,QAAqB,CAAC;AAExC,4DAA4D;AAC5D,eAAO,MAAM,aAAa,QAA6B,CAAC;AAExD,iDAAiD;AACjD,eAAO,MAAM,SAAS,QAAyB,CAAC;AAEhD,8CAA8C;AAC9C,eAAO,MAAM,eAAe,QAAgC,CAAC;AAE7D,uCAAuC;AACvC,eAAO,MAAM,WAAW,QAA2B,CAAC;AAEpD,2CAA2C;AAC3C,eAAO,MAAM,YAAY,QAA6B,CAAC;AAEvD,gDAAgD;AAChD,eAAO,MAAM,UAAU,QAA0B,CAAC;AAElD,oCAAoC;AACpC,eAAO,MAAM,MAAM,QAAsB,CAAC;AAE1C,gDAAgD;AAChD,eAAO,MAAM,iBAAiB,QAAkC,CAAC;AAEjE,gDAAgD;AAChD,eAAO,MAAM,iBAAiB,QAAkC,CAAC;AAEjE,kDAAkD;AAClD,eAAO,MAAM,YAAY,QAA4B,CAAC;AAEtD,oDAAoD;AACpD,eAAO,MAAM,YAAY,QAA4B,CAAC;AAEtD,wDAAwD;AACxD,eAAO,MAAM,aAAa,QAA8B,CAAC;AAEzD,yDAAyD;AACzD,eAAO,MAAM,oBAAoB,QAAsC,CAAC;AAExE,uDAAuD;AACvD,eAAO,MAAM,eAAe,QAAgC,CAAC"}
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,WAAW,MAAM;IACrB,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAC/C,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAChD,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;CAClD;AASD;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAanD;AAED,0CAA0C;AAC1C,eAAO,MAAM,YAAY,QAA4B,CAAC;AAEtD,8CAA8C;AAC9C,eAAO,MAAM,WAAW,QAA2B,CAAC;AAEpD,iDAAiD;AACjD,eAAO,MAAM,SAAS,QAAyB,CAAC;AAEhD,iDAAiD;AACjD,eAAO,MAAM,KAAK,QAAqB,CAAC;AAExC,4DAA4D;AAC5D,eAAO,MAAM,aAAa,QAA6B,CAAC;AAExD,iDAAiD;AACjD,eAAO,MAAM,SAAS,QAAyB,CAAC;AAEhD,8CAA8C;AAC9C,eAAO,MAAM,eAAe,QAAgC,CAAC;AAE7D,uCAAuC;AACvC,eAAO,MAAM,WAAW,QAA2B,CAAC;AAEpD,2CAA2C;AAC3C,eAAO,MAAM,YAAY,QAA6B,CAAC;AAEvD,gDAAgD;AAChD,eAAO,MAAM,UAAU,QAA0B,CAAC;AAElD,oCAAoC;AACpC,eAAO,MAAM,MAAM,QAAsB,CAAC;AAE1C,gDAAgD;AAChD,eAAO,MAAM,iBAAiB,QAAkC,CAAC;AAEjE,gDAAgD;AAChD,eAAO,MAAM,iBAAiB,QAAkC,CAAC;AAEjE,kDAAkD;AAClD,eAAO,MAAM,YAAY,QAA4B,CAAC;AAEtD,oDAAoD;AACpD,eAAO,MAAM,YAAY,QAA4B,CAAC;AAEtD,wDAAwD;AACxD,eAAO,MAAM,aAAa,QAA8B,CAAC;AAEzD,yDAAyD;AACzD,eAAO,MAAM,oBAAoB,QAAsC,CAAC;AAExE,uDAAuD;AACvD,eAAO,MAAM,eAAe,QAAgC,CAAC;AAE7D;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAkB7F"}

View File

@@ -1,5 +1,3 @@
/* eslint-env node */
/**
* Lightweight structured logger for the `@fusion/engine` package.
*
@@ -17,8 +15,8 @@
* of control for filtering, suppressing (e.g. in tests), or redirecting
* engine log output in the future.
*/
const LOG_LEVEL_MARKER_PREFIX = "\0fnlvl=";
const LOG_LEVEL_MARKER_SUFFIX = "\0";
const LOG_LEVEL_MARKER_PREFIX = "\u0000fnlvl=";
const LOG_LEVEL_MARKER_SUFFIX = "\u0000";
function withSeverityMarker(level, payload) {
return `${LOG_LEVEL_MARKER_PREFIX}${level}${LOG_LEVEL_MARKER_SUFFIX}${payload}`;
}
@@ -38,13 +36,13 @@ export function createLogger(prefix) {
const tag = `[${prefix}]`;
return {
log(message, ...args) {
globalThis.console.error(withSeverityMarker("info", `${tag} ${message}`), ...args);
console.error(withSeverityMarker("info", `${tag} ${message}`), ...args);
},
warn(message, ...args) {
globalThis.console.warn(withSeverityMarker("warn", `${tag} ${message}`), ...args);
console.warn(withSeverityMarker("warn", `${tag} ${message}`), ...args);
},
error(message, ...args) {
globalThis.console.error(withSeverityMarker("error", `${tag} ${message}`), ...args);
console.error(withSeverityMarker("error", `${tag} ${message}`), ...args);
},
};
}
@@ -84,4 +82,34 @@ export const remoteNodeLog = createLogger("remote-node");
export const nodeHealthMonitorLog = createLogger("node-health-monitor");
/** Logger for the peer exchange (gossip) subsystem. */
export const peerExchangeLog = createLogger("peer-exchange");
/**
* Extract both a short message and a full stack trace from an unknown caught
* value. Use this at catch sites instead of the
* `err instanceof Error ? err.message : String(err)` idiom so that the stack
* is preserved for logs, task `activityLog` entries, and surfaced diagnostics.
*
* `detail` is `message` when no stack is available and `message + "\n" + stack`
* otherwise — suitable for `store.logEntry(taskId, action, detail)`.
*/
export function formatError(err) {
if (err instanceof Error) {
const message = err.message || err.name || "Error";
const stack = err.stack;
const detail = stack && stack.includes(message) ? stack : stack ? `${message}\n${stack}` : message;
return { message, stack, detail };
}
let message;
if (typeof err === "string") {
message = err;
}
else {
try {
message = JSON.stringify(err);
}
catch {
message = String(err);
}
}
return { message, detail: message };
}
//# sourceMappingURL=logger.js.map

View File

@@ -1 +1 @@
{"version":3,"file":"logger.js","sourceRoot":"","sources":["logger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAQH;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAAC,MAAc;IACzC,MAAM,GAAG,GAAG,IAAI,MAAM,GAAG,CAAC;IAC1B,OAAO;QACL,GAAG,CAAC,OAAe,EAAE,GAAG,IAAe;YACrC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,IAAI,OAAO,EAAE,EAAE,GAAG,IAAI,CAAC,CAAC;QAC9C,CAAC;QACD,IAAI,CAAC,OAAe,EAAE,GAAG,IAAe;YACtC,OAAO,CAAC,IAAI,CAAC,GAAG,GAAG,IAAI,OAAO,EAAE,EAAE,GAAG,IAAI,CAAC,CAAC;QAC7C,CAAC;QACD,KAAK,CAAC,OAAe,EAAE,GAAG,IAAe;YACvC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,IAAI,OAAO,EAAE,EAAE,GAAG,IAAI,CAAC,CAAC;QAC9C,CAAC;KACF,CAAC;AACJ,CAAC;AAED,0CAA0C;AAC1C,MAAM,CAAC,MAAM,YAAY,GAAG,YAAY,CAAC,WAAW,CAAC,CAAC;AAEtD,8CAA8C;AAC9C,MAAM,CAAC,MAAM,WAAW,GAAG,YAAY,CAAC,UAAU,CAAC,CAAC;AAEpD,iDAAiD;AACjD,MAAM,CAAC,MAAM,SAAS,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAC;AAEhD,iDAAiD;AACjD,MAAM,CAAC,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,CAAC,CAAC;AAExC,4DAA4D;AAC5D,MAAM,CAAC,MAAM,aAAa,GAAG,YAAY,CAAC,YAAY,CAAC,CAAC;AAExD,iDAAiD;AACjD,MAAM,CAAC,MAAM,SAAS,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAC;AAEhD,8CAA8C;AAC9C,MAAM,CAAC,MAAM,eAAe,GAAG,YAAY,CAAC,eAAe,CAAC,CAAC;AAE7D,uCAAuC;AACvC,MAAM,CAAC,MAAM,WAAW,GAAG,YAAY,CAAC,UAAU,CAAC,CAAC;AAEpD,2CAA2C;AAC3C,MAAM,CAAC,MAAM,YAAY,GAAG,YAAY,CAAC,YAAY,CAAC,CAAC;AAEvD,gDAAgD;AAChD,MAAM,CAAC,MAAM,UAAU,GAAG,YAAY,CAAC,SAAS,CAAC,CAAC;AAElD,oCAAoC;AACpC,MAAM,CAAC,MAAM,MAAM,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC;AAE1C,gDAAgD;AAChD,MAAM,CAAC,MAAM,iBAAiB,GAAG,YAAY,CAAC,iBAAiB,CAAC,CAAC;AAEjE,gDAAgD;AAChD,MAAM,CAAC,MAAM,iBAAiB,GAAG,YAAY,CAAC,iBAAiB,CAAC,CAAC;AAEjE,kDAAkD;AAClD,MAAM,CAAC,MAAM,YAAY,GAAG,YAAY,CAAC,WAAW,CAAC,CAAC;AAEtD,oDAAoD;AACpD,MAAM,CAAC,MAAM,YAAY,GAAG,YAAY,CAAC,WAAW,CAAC,CAAC;AAEtD,wDAAwD;AACxD,MAAM,CAAC,MAAM,aAAa,GAAG,YAAY,CAAC,aAAa,CAAC,CAAC;AAEzD,yDAAyD;AACzD,MAAM,CAAC,MAAM,oBAAoB,GAAG,YAAY,CAAC,qBAAqB,CAAC,CAAC;AAExE,uDAAuD;AACvD,MAAM,CAAC,MAAM,eAAe,GAAG,YAAY,CAAC,eAAe,CAAC,CAAC"}
{"version":3,"file":"logger.js","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"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"}

View File

@@ -109,3 +109,32 @@ export const nodeHealthMonitorLog = createLogger("node-health-monitor");
/** Logger for the peer exchange (gossip) subsystem. */
export const peerExchangeLog = createLogger("peer-exchange");
/**
* Extract both a short message and a full stack trace from an unknown caught
* value. Use this at catch sites instead of the
* `err instanceof Error ? err.message : String(err)` idiom so that the stack
* is preserved for logs, task `activityLog` entries, and surfaced diagnostics.
*
* `detail` is `message` when no stack is available and `message + "\n" + stack`
* otherwise — suitable for `store.logEntry(taskId, action, detail)`.
*/
export function formatError(err: unknown): { message: string; stack?: string; detail: string } {
if (err instanceof Error) {
const message = err.message || err.name || "Error";
const stack = err.stack;
const detail = stack && stack.includes(message) ? stack : stack ? `${message}\n${stack}` : message;
return { message, stack, detail };
}
let message: string;
if (typeof err === "string") {
message = err;
} else {
try {
message = JSON.stringify(err);
} catch {
message = String(err);
}
}
return { message, detail: message };
}

View File

@@ -24,7 +24,7 @@ import { buildSessionSkillContext } from "./session-skill-context.js";
import { PRIORITY_SPECIFY, type AgentSemaphore } from "./concurrency.js";
import { AgentLogger } from "./agent-logger.js";
import { resolveAgentInstructions, buildSystemPromptWithInstructions } from "./agent-instructions.js";
import { triageLog, reviewerLog } from "./logger.js";
import { triageLog, reviewerLog, formatError } from "./logger.js";
import {
isUsageLimitError,
checkSessionError,
@@ -1084,7 +1084,7 @@ export class TriageProcessor {
await retryableWork();
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
const { message: errorMessage, detail: errorDetail, stack: errorStack } = formatError(err);
// Race condition: task was deleted (e.g. as a duplicate) between listTasks()
// and specifyTask(). The file is gone, so just log and skip — no point retrying.
if ((err as Record<string, unknown>).code === "ENOENT") {
@@ -1172,7 +1172,13 @@ export class TriageProcessor {
const msg = restoreErr instanceof Error ? restoreErr.message : String(restoreErr);
triageLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' after specification error: ${msg}`);
});
triageLog.error(`${task.id} specification failed:`, errorMessage);
triageLog.error(`${task.id} specification failed:`, errorDetail);
if (errorStack) {
await this.store.logEntry(task.id, `Specification failed: ${errorMessage}`, errorStack).catch((logErr: unknown) => {
const msg = logErr instanceof Error ? logErr.message : String(logErr);
triageLog.warn(`${task.id}: failed to persist specification-failure stack trace: ${msg}`);
});
}
this.options.onSpecifyError?.(task, err instanceof Error ? err : new Error(errorMessage));
}
} finally {