Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <KEY>`, 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:
Expand All @@ -79,6 +80,10 @@ Run state is namespaced per project at:

` .piv-loop/projects/<project-id>/runs/<LINEAR_KEY>.json `

Polling cycle errors are logged per project at:

` .piv-loop/projects/<project-id>/errors.log `

Legacy fallback for default project:

` .piv-loop/runs/<LINEAR_KEY>.json `
Expand Down
38 changes: 37 additions & 1 deletion src/core/state.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<string, unknown>;
context?: Record<string, unknown>;
recordedAt?: string;
}

export async function appendProjectErrorLog(
cwd: string,
projectId: string,
entry: ProjectErrorLogEntryInput,
): Promise<void> {
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,
Expand Down
79 changes: 69 additions & 10 deletions src/core/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ import { type AgentAdapter, createAgentAdapter } from "./agent-adapter";
import { type LoadedConfig, getProjectById } from "./config";
import { type ReviewOutcome, parseReviewOutcome } from "./review";
import {
appendProjectErrorLog,
applyRunLease,
clearRunLease,
hasRunLeaseConflict,
isRunLeaseExpired,
listRunStates,
loadRunState,
normalizeIssueKey,
projectErrorLogPath,
saveRunState,
transitionStage,
} from "./state";
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -222,14 +280,15 @@ export function shouldStopPolling(
options: RunOptions,
cycle: number,
totalIssues: number,
cycleHadError = false,
): boolean {
if (!polling.enabled || options.issueArg) {
return true;
}
if (polling.maxCycles !== undefined && cycle >= polling.maxCycles) {
return true;
}
if (totalIssues === 0 && polling.exitWhenIdle) {
if (totalIssues === 0 && polling.exitWhenIdle && !cycleHadError) {
return true;
}
return false;
Expand Down
41 changes: 41 additions & 0 deletions tests/state.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, unknown>;
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",
});
});
});
17 changes: 17 additions & 0 deletions tests/workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ describe("shouldStopPolling", () => {
{ poll: true },
2,
3,
true,
);
expect(stop).toBe(true);
});
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading