diff --git a/packages/adapters/hermes/src/server/execute.onspawn.test.ts b/packages/adapters/hermes/src/server/execute.onspawn.test.ts index 23aef939b5..a919ddd236 100644 --- a/packages/adapters/hermes/src/server/execute.onspawn.test.ts +++ b/packages/adapters/hermes/src/server/execute.onspawn.test.ts @@ -119,6 +119,74 @@ describe("hermes-local adapter onSpawn forwarding", () => { expect(opts.onSpawn).toBeDefined(); }); + it("preserves a specific stderr diagnostic for a nonzero exit", async () => { + vi.mocked(serverUtils.runChildProcess).mockResolvedValueOnce({ + exitCode: 1, + signal: null, + timedOut: false, + stdout: "", + stderr: "Error: provider unavailable\n", + pid: null, + startedAt: null, + }); + + const { ctx } = makeCtx(); + const result = await execute(ctx as any); + + expect(result.errorMessage).toBe("Error: provider unavailable"); + }); + + it("reports the exit code when a nonzero exit has no diagnostic", async () => { + vi.mocked(serverUtils.runChildProcess).mockResolvedValueOnce({ + exitCode: 130, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: null, + startedAt: null, + }); + + const { ctx } = makeCtx(); + const result = await execute(ctx as any); + + expect(result.errorMessage).toBe("Hermes exited with code 130"); + }); + + it("leaves timeout diagnostics to the heartbeat timeout path", async () => { + vi.mocked(serverUtils.runChildProcess).mockResolvedValueOnce({ + exitCode: 143, + signal: "SIGTERM", + timedOut: true, + stdout: "", + stderr: "", + pid: null, + startedAt: null, + }); + + const { ctx } = makeCtx(); + const result = await execute(ctx as any); + + expect(result.errorMessage).toBeUndefined(); + }); + + it("does not label signal cancellation as a silent nonzero exit", async () => { + vi.mocked(serverUtils.runChildProcess).mockResolvedValueOnce({ + exitCode: null, + signal: "SIGTERM", + timedOut: false, + stdout: "", + stderr: "", + pid: null, + startedAt: null, + }); + + const { ctx } = makeCtx(); + const result = await execute(ctx as any); + + expect(result.errorMessage).toBeUndefined(); + }); + it("does not inherit PAPERCLIP_API_KEY without a harness token", async () => { const previousApiKey = process.env.PAPERCLIP_API_KEY; process.env.PAPERCLIP_API_KEY = "parent-process-key"; diff --git a/packages/adapters/hermes/src/server/execute.ts b/packages/adapters/hermes/src/server/execute.ts index b476ae214e..3b4c5d4bc4 100644 --- a/packages/adapters/hermes/src/server/execute.ts +++ b/packages/adapters/hermes/src/server/execute.ts @@ -563,6 +563,8 @@ export async function execute( if (parsed.errorMessage) { executionResult.errorMessage = parsed.errorMessage; + } else if (!result.timedOut && typeof result.exitCode === "number" && result.exitCode !== 0) { + executionResult.errorMessage = `Hermes exited with code ${result.exitCode}`; } if (parsed.usage) { diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 84ba03a1b5..4826ad0021 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -1202,6 +1202,40 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { return { companyId, agentId, runId, wakeupRequestId, issueId }; } + it("persists the normalized failure when an adapter omits its diagnostic", async () => { + mockAdapterExecute.mockResolvedValueOnce({ + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: null, + provider: "test", + model: "test-model", + }); + + const { agentId, runId } = await seedQueuedIssueRunFixture(); + const heartbeat = heartbeatService(db); + + await heartbeat.resumeQueuedRuns(); + await waitForRunToSettle(heartbeat, runId); + await heartbeat.waitForRunExecutionDrain(runId); + + const run = await heartbeat.getRun(runId); + const runtime = await db + .select({ lastError: agentRuntimeState.lastError }) + .from(agentRuntimeState) + .where(eq(agentRuntimeState.agentId, agentId)) + .then((rows) => rows[0] ?? null); + const agent = await db + .select({ status: agents.status, errorReason: agents.errorReason }) + .from(agents) + .where(eq(agents.id, agentId)) + .then((rows) => rows[0] ?? null); + + expect(run).toMatchObject({ status: "failed", error: "Adapter failed" }); + expect(runtime?.lastError).toBe("Adapter failed"); + expect(agent).toEqual({ status: "error", errorReason: "Adapter failed" }); + }); + it("keeps a local run active when the recorded pid is still alive", async () => { const child = spawnAliveProcess(); childProcesses.add(child); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index ff40d2f4d2..a1f53697e6 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -13395,7 +13395,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) sessionId: session.legacySessionId, lastRunId: run.id, lastRunStatus: run.status, - lastError: result.errorMessage ?? null, + lastError: run.error ?? null, totalInputTokens: sql`${agentRuntimeState.totalInputTokens} + ${inputTokens}`, totalOutputTokens: sql`${agentRuntimeState.totalOutputTokens} + ${outputTokens}`, totalCachedInputTokens: sql`${agentRuntimeState.totalCachedInputTokens} + ${cachedInputTokens}`, @@ -16037,7 +16037,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) ), sessionDisplayId: nextSessionState.displayId, lastRunId: finalizedRun.id, - lastError: outcome === "succeeded" ? null : (adapterResult.errorMessage ?? "run_failed"), + lastError: runErrorMessage, }); } } @@ -16045,7 +16045,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) await finalizeAgentStatus( agent.id, outcome, - outcome === "succeeded" ? null : (adapterResult.errorMessage ?? null), + runErrorMessage, { keepIdleOnFailure: outcome === "failed" &&