From b06646457cd269b58566be42cd466b90339e68fb Mon Sep 17 00:00:00 2001 From: 0xRoy <1997roylee@gmail.com> Date: Fri, 8 May 2026 18:41:38 +0800 Subject: [PATCH] [adhd.ai] ROY-34: address review feedback --- README.md | 5 +++ src/core/state.ts | 38 +++++++++++++++++++- src/core/workflow.ts | 79 ++++++++++++++++++++++++++++++++++++------ tests/state.test.ts | 41 ++++++++++++++++++++++ tests/workflow.test.ts | 17 +++++++++ 5 files changed, 169 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 98c845b0..5eb0eb96 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ Configuration is loaded from `adhd-ai.config.ts` and resolved into project-speci - Root defaults can define shared repo, linear, codex, skills, and dry-run behavior. - Optional root `notifications.email` settings can enable terminal outcome emails through Resend. - Polling is a single global config at the root `polling` key (`intervalMs`, `maxCycles`, `exitWhenIdle`, `staleRunTimeoutMs`) and applies to all selected projects in a run. +- During polling, per-project cycle failures are logged and the loop continues; targeted `--issue` runs and non-polling runs still fail fast. - Optional `linear.projectId` can scope each ADHD.ai project to a specific Linear project when selecting assigned work. - For targeted runs with `--all-projects --issue `, ADHD.ai routes the issue to exactly one project by matching `linear.projectId` to the Linear issue's `projectId`. - `projects` contains one or more project entries, each with: @@ -79,6 +80,10 @@ Run state is namespaced per project at: ` .piv-loop/projects//runs/.json ` +Polling cycle errors are logged per project at: + +` .piv-loop/projects//errors.log ` + Legacy fallback for default project: ` .piv-loop/runs/.json ` diff --git a/src/core/state.ts b/src/core/state.ts index efd4e87a..6fd26927 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -1,4 +1,10 @@ -import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { + appendFile, + mkdir, + readFile, + readdir, + writeFile, +} from "node:fs/promises"; import path from "node:path"; import type { RunState, WorkflowStage } from "./types"; @@ -27,6 +33,36 @@ export function stateFilePath( ); } +export function projectErrorLogPath(cwd: string, projectId: string): string { + return path.join(cwd, STATE_ROOT_DIR, projectId, "errors.log"); +} + +export interface ProjectErrorLogEntryInput { + cycle: number; + message: string; + error: Record; + context?: Record; + recordedAt?: string; +} + +export async function appendProjectErrorLog( + cwd: string, + projectId: string, + entry: ProjectErrorLogEntryInput, +): Promise { + const file = projectErrorLogPath(cwd, projectId); + await mkdir(path.dirname(file), { recursive: true }); + const payload = { + recordedAt: entry.recordedAt ?? new Date().toISOString(), + projectId, + cycle: entry.cycle, + message: entry.message, + error: entry.error, + ...(entry.context ? { context: entry.context } : {}), + }; + await appendFile(file, `${JSON.stringify(payload)}\n`, "utf8"); +} + export async function loadRunState( cwd: string, projectId: string, diff --git a/src/core/workflow.ts b/src/core/workflow.ts index 51a87553..22c34e9b 100644 --- a/src/core/workflow.ts +++ b/src/core/workflow.ts @@ -23,6 +23,7 @@ import { type AgentAdapter, createAgentAdapter } from "./agent-adapter"; import { type LoadedConfig, getProjectById } from "./config"; import { type ReviewOutcome, parseReviewOutcome } from "./review"; import { + appendProjectErrorLog, applyRunLease, clearRunLease, hasRunLeaseConflict, @@ -30,6 +31,7 @@ import { listRunStates, loadRunState, normalizeIssueKey, + projectErrorLogPath, saveRunState, transitionStage, } from "./state"; @@ -84,19 +86,75 @@ export async function runWorkflow( while (true) { cycle += 1; let totalIssues = 0; + let cycleHadError = false; for (const context of projectContexts) { - totalIssues += await runProjectCycle( - context.config, - config.notifications, - options, - context.linear, - cycle, - globalPolling, - ); + try { + totalIssues += await runProjectCycle( + context.config, + config.notifications, + options, + context.linear, + cycle, + globalPolling, + ); + } catch (error) { + if (!globalPolling.enabled || options.issueArg) { + throw error; + } + cycleHadError = true; + const message = error instanceof Error ? error.message : String(error); + const errorLogPath = projectErrorLogPath( + context.config.workspacePath, + context.config.id, + ); + try { + await appendProjectErrorLog( + context.config.workspacePath, + context.config.id, + { + cycle, + message, + error: normalizeError(error), + context: { + projectName: context.config.name, + pollingIntervalMs: globalPolling.intervalMs, + issueArg: options.issueArg ?? null, + }, + }, + ); + } catch (appendError) { + logger.error( + { + projectId: context.config.id, + cycle, + errorLogPath, + err: normalizeError(appendError), + }, + "Failed to append polling error log entry", + ); + } + logger.error( + { + projectId: context.config.id, + cycle, + errorLogPath, + err: normalizeError(error), + }, + "Project cycle failed during polling; continuing", + ); + } } - if (shouldStopPolling(globalPolling, options, cycle, totalIssues)) { + if ( + shouldStopPolling( + globalPolling, + options, + cycle, + totalIssues, + cycleHadError, + ) + ) { return; } @@ -222,6 +280,7 @@ export function shouldStopPolling( options: RunOptions, cycle: number, totalIssues: number, + cycleHadError = false, ): boolean { if (!polling.enabled || options.issueArg) { return true; @@ -229,7 +288,7 @@ export function shouldStopPolling( if (polling.maxCycles !== undefined && cycle >= polling.maxCycles) { return true; } - if (totalIssues === 0 && polling.exitWhenIdle) { + if (totalIssues === 0 && polling.exitWhenIdle && !cycleHadError) { return true; } return false; diff --git a/tests/state.test.ts b/tests/state.test.ts index 30a29a9d..c127eecc 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -1,10 +1,15 @@ import { describe, expect, it } from "bun:test"; +import { mkdtemp, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { + appendProjectErrorLog, applyRunLease, clearRunLease, hasRunLeaseConflict, isRunLeaseExpired, normalizeIssueKey, + projectErrorLogPath, transitionStage, } from "../src/core/state"; import type { RunState } from "../src/core/types"; @@ -94,4 +99,40 @@ describe("state helpers", () => { expect(hasRunLeaseConflict(state, "worker-b", 9999)).toBe(true); expect(hasRunLeaseConflict(state, "worker-a", 9999)).toBe(false); }); + + it("builds project-scoped error log paths", () => { + const logPath = projectErrorLogPath("/tmp/workspace", "default"); + expect(logPath).toBe( + "/tmp/workspace/.piv-loop/projects/default/errors.log", + ); + }); + + it("appends project polling errors as JSON lines", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "adhd-state-test-")); + await appendProjectErrorLog(cwd, "default", { + cycle: 3, + message: "Linear API failed", + error: { + name: "Error", + message: "boom", + }, + context: { + projectName: "Default", + }, + }); + + const content = await readFile(projectErrorLogPath(cwd, "default"), "utf8"); + const entry = JSON.parse(content.trim()) as Record; + expect(entry.projectId).toBe("default"); + expect(entry.cycle).toBe(3); + expect(entry.message).toBe("Linear API failed"); + expect(typeof entry.recordedAt).toBe("string"); + expect(entry.error).toEqual({ + name: "Error", + message: "boom", + }); + expect(entry.context).toEqual({ + projectName: "Default", + }); + }); }); diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index 2bda19ba..c54583f9 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -96,6 +96,7 @@ describe("shouldStopPolling", () => { { poll: true }, 2, 3, + true, ); expect(stop).toBe(true); }); @@ -129,6 +130,22 @@ describe("shouldStopPolling", () => { ); expect(stop).toBe(false); }); + + it("continues when idle cycle had a recoverable polling error", () => { + const stop = shouldStopPolling( + { + enabled: true, + intervalMs: 30000, + exitWhenIdle: true, + staleRunTimeoutMs: 3600000, + }, + { poll: true }, + 1, + 0, + true, + ); + expect(stop).toBe(false); + }); }); describe("stale run retry helpers", () => {