diff --git a/README.md b/README.md index 6afca71e..150ea37b 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ Legacy fallback for default project: bun run src/index.ts run --project default bun run src/index.ts run --all-projects bun run src/index.ts run --project default --issue ENG-123 +bun run src/index.ts run --project default --poll +bun run src/index.ts run --project default --poll --poll-interval-ms 15000 --max-poll-cycles 20 bun run src/index.ts status --project default --issue ENG-123 bun run src/index.ts projects ``` @@ -69,6 +71,9 @@ Optional: - `GITHUB_BASE_BRANCH` (default `main`) - `PIV_WORKSPACE_PATH` (default current directory; state root) - `PIV_EXECUTION_PATH` (default `PIV_WORKSPACE_PATH`; command execution path) +- `PIV_POLL_INTERVAL_MS` (default `30000`; polling sleep between cycles) +- `PIV_MAX_POLL_CYCLES` (optional; stop polling after this many cycles) +- `PIV_EXIT_WHEN_IDLE` (optional; default `1`, set `0` to keep polling when no issues are found) - `PIV_DRY_RUN=1` to avoid Linear/GitHub mutations - `PIV_DEV_MODE=1` to stream Codex stdout/stderr logs during runs - `CODEX_SANDBOX` (optional; leave empty to disable sandbox, or set `read-only`, `workspace-write`, `danger-full-access`) diff --git a/src/args.ts b/src/args.ts index aee308a6..7d34cb02 100644 --- a/src/args.ts +++ b/src/args.ts @@ -24,10 +24,23 @@ export function parseArgs(argv: string[]): CliCommand { const issueArg = readFlagValue(args, "--issue"); const projectId = readFlagValue(args, "--project"); const allProjects = args.includes("--all-projects"); + const poll = args.includes("--poll"); + const pollIntervalMs = readOptionalPositiveInt(args, "--poll-interval-ms"); + const maxPollCycles = readOptionalPositiveInt(args, "--max-poll-cycles"); if (projectId && allProjects) { throw new Error("run command cannot use --project with --all-projects"); } - return { kind: "run", options: { issueArg, projectId, allProjects } }; + return { + kind: "run", + options: { + issueArg, + projectId, + allProjects, + poll, + pollIntervalMs, + maxPollCycles, + }, + }; } if (command === "status") { @@ -56,3 +69,18 @@ function readFlagValue(args: string[], flag: string): string | undefined { } return args[index + 1]; } + +function readOptionalPositiveInt( + args: string[], + flag: string, +): number | undefined { + const raw = readFlagValue(args, flag); + if (raw === undefined) { + return undefined; + } + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${flag} must be a positive integer`); + } + return value; +} diff --git a/src/config.ts b/src/config.ts index 9f0304b6..0eeb035e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -71,6 +71,11 @@ function buildEnvBase(cwd: string): ProjectRuntimeConfig { }, autoCreateLabels: env.LINEAR_AUTO_CREATE_LABELS !== "0", }, + polling: { + intervalMs: Number(env.PIV_POLL_INTERVAL_MS ?? "30000"), + maxCycles: parseOptionalPositiveInt(env.PIV_MAX_POLL_CYCLES), + exitWhenIdle: env.PIV_EXIT_WHEN_IDLE !== "0", + }, github: { useGhCli: true, defaultBugLabel: env.GITHUB_BUG_LABEL ?? "bug", @@ -202,6 +207,11 @@ function mergeRuntime( ...(rootDefaults.github ?? {}), ...(project.github ?? {}), }, + polling: { + ...base.polling, + ...(rootDefaults.polling ?? {}), + ...(project.polling ?? {}), + }, codex: { ...base.codex, ...(rootDefaults.codex ?? {}), @@ -216,6 +226,19 @@ function mergeRuntime( }; } +function parseOptionalPositiveInt( + value: string | undefined, +): number | undefined { + if (!value) { + return undefined; + } + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + return undefined; + } + return parsed; +} + function validateProjects(projects: ResolvedProjectConfig[]): void { if (projects.length === 0) { throw new Error("At least one project configuration is required"); @@ -287,4 +310,21 @@ function validateProject(project: ResolvedProjectConfig): void { .join(", ")}`, ); } + if ( + !Number.isInteger(project.polling.intervalMs) || + project.polling.intervalMs <= 0 + ) { + throw new Error( + `Polling interval must be a positive integer for project '${project.id}'`, + ); + } + if ( + project.polling.maxCycles !== undefined && + (!Number.isInteger(project.polling.maxCycles) || + project.polling.maxCycles <= 0) + ) { + throw new Error( + `Polling max cycles must be a positive integer for project '${project.id}'`, + ); + } } diff --git a/src/index.ts b/src/index.ts index 57dd8f3b..f32f1edc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,7 +59,7 @@ function printHelp(): void { "piv-loop - Codex CLI orchestration workflow", "", "Commands:", - " piv-loop run [--project ] [--issue ]", + " piv-loop run [--project ] [--issue ] [--poll] [--poll-interval-ms ] [--max-poll-cycles ]", " piv-loop run --all-projects [--issue ]", " piv-loop status --project --issue ", " piv-loop projects", diff --git a/src/types.ts b/src/types.ts index 6d484995..178d4a1b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,6 +58,11 @@ export interface ProjectRuntimeConfig { labelMap: LinearLabelMap; autoCreateLabels: boolean; }; + polling: { + intervalMs: number; + maxCycles?: number; + exitWhenIdle: boolean; + }; github: { useGhCli: boolean; defaultBugLabel: string; @@ -162,4 +167,8 @@ export interface RunOptions { issueArg?: string; projectId?: string; allProjects?: boolean; + poll?: boolean; + pollIntervalMs?: number; + maxPollCycles?: number; + exitWhenIdle?: boolean; } diff --git a/src/workflow.ts b/src/workflow.ts index 60e9ebbe..13ea9b02 100644 --- a/src/workflow.ts +++ b/src/workflow.ts @@ -63,64 +63,122 @@ async function runProjectWorkflow( ): Promise { const linear = new LinearClient(config); const projectLogger = logger.child({ projectId: config.id }); - const issues = await linear.fetchWork(options.issueArg); - if (issues.length === 0) { - projectLogger.info("No eligible Linear issues found."); - return; - } + const polling = resolvePollingSettings(config, options); + let cycle = 0; - for (const issue of issues) { - const key = normalizeIssueKey(issue.identifier); - const issueLogger = projectLogger.child({ issueKey: key }); - const existing = await loadRunState(config.workspacePath, config.id, key); - const runState: RunState = - existing ?? - ({ - projectId: config.id, - projectName: config.name, - workspacePath: config.executionPath, - repository: { - owner: config.repo.owner, - name: config.repo.name, - baseBranch: config.repo.baseBranch, - }, - issue: { - id: issue.id, - key, - title: issue.title, - url: issue.url, - }, - stage: "received", - bugs: [], - startedAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } satisfies RunState); - - try { - await executeIssue(config, linear, runState); - issueLogger.info({ stage: runState.stage }, "Issue workflow finished"); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - runState.lastError = message; - runState.stage = "blocked"; - await saveRunState(config.workspacePath, runState); - await safeLinearStageUpdate(linear, runState.issue.id, "blocked"); - await safeLinearComment( - linear, - runState.issue.id, - `PIV loop failed and marked blocked.\n\nError:\n${message}`, - ); - issueLogger.error( - { - err: normalizeError(error), - stage: runState.stage, - }, - "Issue workflow failed", + while (true) { + cycle += 1; + const issues = await linear.fetchWork(options.issueArg); + projectLogger.info( + { cycle, issueCount: issues.length, pollingEnabled: polling.enabled }, + "Fetched eligible Linear issues", + ); + + if (issues.length === 0) { + projectLogger.info({ cycle }, "No eligible Linear issues found."); + } + + for (const issue of issues) { + await processIssue(config, linear, issue); + } + + if (!polling.enabled || options.issueArg) { + return; + } + if (polling.maxCycles !== undefined && cycle >= polling.maxCycles) { + projectLogger.info( + { cycle, maxCycles: polling.maxCycles }, + "Polling stopped after reaching configured max cycles", ); + return; + } + if (issues.length === 0 && polling.exitWhenIdle) { + projectLogger.info({ cycle }, "Polling exited after idle cycle."); + return; } + + await sleep(polling.intervalMs); + } +} + +async function processIssue( + config: ResolvedProjectConfig, + linear: LinearClient, + issue: { id: string; identifier: string; title: string; url: string }, +): Promise { + const key = normalizeIssueKey(issue.identifier); + const issueLogger = logger.child({ projectId: config.id, issueKey: key }); + const existing = await loadRunState(config.workspacePath, config.id, key); + const runState: RunState = + existing ?? + ({ + projectId: config.id, + projectName: config.name, + workspacePath: config.executionPath, + repository: { + owner: config.repo.owner, + name: config.repo.name, + baseBranch: config.repo.baseBranch, + }, + issue: { + id: issue.id, + key, + title: issue.title, + url: issue.url, + }, + stage: "received", + bugs: [], + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } satisfies RunState); + + try { + await executeIssue(config, linear, runState); + issueLogger.info({ stage: runState.stage }, "Issue workflow finished"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + runState.lastError = message; + runState.stage = "blocked"; + await saveRunState(config.workspacePath, runState); + await safeLinearStageUpdate(linear, runState.issue.id, "blocked"); + await safeLinearComment( + linear, + runState.issue.id, + `PIV loop failed and marked blocked.\n\nError:\n${message}`, + ); + issueLogger.error( + { + err: normalizeError(error), + stage: runState.stage, + }, + "Issue workflow failed", + ); } } +export interface PollingSettings { + enabled: boolean; + intervalMs: number; + maxCycles?: number; + exitWhenIdle: boolean; +} + +export function resolvePollingSettings( + config: ResolvedProjectConfig, + options: RunOptions, +): PollingSettings { + return { + enabled: options.poll === true, + intervalMs: options.pollIntervalMs ?? config.polling.intervalMs, + maxCycles: options.maxPollCycles ?? config.polling.maxCycles, + exitWhenIdle: options.exitWhenIdle ?? config.polling.exitWhenIdle, + }; +} + +export async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + async function executeIssue( config: ResolvedProjectConfig, linear: LinearClient, diff --git a/tests/args.test.ts b/tests/args.test.ts index ae25b900..771e5f3a 100644 --- a/tests/args.test.ts +++ b/tests/args.test.ts @@ -6,7 +6,14 @@ describe("parseArgs", () => { const parsed = parseArgs(["bun", "piv-loop", "run", "--issue", "ABC-1"]); expect(parsed).toEqual({ kind: "run", - options: { issueArg: "ABC-1", projectId: undefined, allProjects: false }, + options: { + issueArg: "ABC-1", + projectId: undefined, + allProjects: false, + poll: false, + pollIntervalMs: undefined, + maxPollCycles: undefined, + }, }); }); @@ -14,10 +21,52 @@ describe("parseArgs", () => { const parsed = parseArgs(["bun", "piv-loop", "run", "--project", "api"]); expect(parsed).toEqual({ kind: "run", - options: { projectId: "api", allProjects: false }, + options: { + projectId: "api", + allProjects: false, + poll: false, + pollIntervalMs: undefined, + maxPollCycles: undefined, + }, }); }); + it("parses run polling flags", () => { + const parsed = parseArgs([ + "bun", + "piv-loop", + "run", + "--poll", + "--poll-interval-ms", + "15000", + "--max-poll-cycles", + "20", + ]); + expect(parsed).toEqual({ + kind: "run", + options: { + issueArg: undefined, + projectId: undefined, + allProjects: false, + poll: true, + pollIntervalMs: 15000, + maxPollCycles: 20, + }, + }); + }); + + it("rejects invalid poll-interval-ms", () => { + expect(() => + parseArgs(["bun", "piv-loop", "run", "--poll-interval-ms", "0"]), + ).toThrow("--poll-interval-ms must be a positive integer"); + }); + + it("rejects invalid max-poll-cycles", () => { + expect(() => + parseArgs(["bun", "piv-loop", "run", "--max-poll-cycles", "-1"]), + ).toThrow("--max-poll-cycles must be a positive integer"); + }); + it("parses status command", () => { const parsed = parseArgs([ "bun", @@ -34,4 +83,17 @@ describe("parseArgs", () => { projectId: "api", }); }); + + it("rejects project with all-projects", () => { + expect(() => + parseArgs([ + "bun", + "piv-loop", + "run", + "--project", + "api", + "--all-projects", + ]), + ).toThrow("run command cannot use --project with --all-projects"); + }); }); diff --git a/tests/codex.test.ts b/tests/codex.test.ts index 811afbbc..19e24981 100644 --- a/tests/codex.test.ts +++ b/tests/codex.test.ts @@ -33,6 +33,11 @@ const config: ResolvedProjectConfig = { }, autoCreateLabels: true, }, + polling: { + intervalMs: 30000, + maxCycles: 10, + exitWhenIdle: true, + }, github: { useGhCli: true, defaultBugLabel: "bug" }, codex: { binary: "codex", diff --git a/tests/config.test.ts b/tests/config.test.ts index 8589e046..0c1254ee 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -18,6 +18,9 @@ const envKeys = [ "CODEX_MODEL_PLAN", "CODEX_MODEL_IMPLEMENT", "CODEX_MODEL_REVIEW_TEST", + "PIV_POLL_INTERVAL_MS", + "PIV_MAX_POLL_CYCLES", + "PIV_EXIT_WHEN_IDLE", ] as const; const previousEnv: Record = {}; @@ -31,7 +34,13 @@ describe("loadConfig", () => { ? "workspace-write" : key === "CODEX_HOME" ? "" - : key.toLowerCase(); + : key === "PIV_POLL_INTERVAL_MS" + ? "30000" + : key === "PIV_MAX_POLL_CYCLES" + ? "" + : key === "PIV_EXIT_WHEN_IDLE" + ? "1" + : key.toLowerCase(); } }); @@ -48,6 +57,19 @@ describe("loadConfig", () => { "linear_status_assigned", ); expect(config.projects[0]?.executionPath).toBe("piv_execution_path"); + expect(config.projects[0]?.polling.intervalMs).toBe(30000); + expect(config.projects[0]?.polling.maxCycles).toBeUndefined(); + expect(config.projects[0]?.polling.exitWhenIdle).toBe(true); + }); + + it("loads polling values from env", async () => { + process.env.PIV_POLL_INTERVAL_MS = "15000"; + process.env.PIV_MAX_POLL_CYCLES = "20"; + process.env.PIV_EXIT_WHEN_IDLE = "0"; + const config = await loadConfig(process.cwd()); + expect(config.projects[0]?.polling.intervalMs).toBe(15000); + expect(config.projects[0]?.polling.maxCycles).toBe(20); + expect(config.projects[0]?.polling.exitWhenIdle).toBe(false); }); it("disables codex sandbox by default", async () => { diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index 44b8f3fc..34697c33 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { buildPlanComment, parseReviewOutcome } from "../src/workflow"; +import type { ResolvedProjectConfig } from "../src/types"; +import { + buildPlanComment, + parseReviewOutcome, + resolvePollingSettings, +} from "../src/workflow"; describe("parseReviewOutcome", () => { it("parses pass with no bugs", () => { @@ -41,3 +46,77 @@ describe("buildPlanComment", () => { expect(comment).toContain("(No plan summary returned by planning agent.)"); }); }); + +describe("resolvePollingSettings", () => { + const project = { + id: "default", + name: "Default", + workspacePath: "/tmp/state", + executionPath: "/tmp/repo", + repo: { + owner: "acme", + name: "repo", + baseBranch: "main", + }, + linear: { + apiKey: "key", + apiUrl: "https://api.linear.app/graphql", + pollLimit: 10, + statusMap: { + assigned: "Todo", + planning: "In Progress", + implementing: "In Progress", + pr_created: "In Review", + reviewing: "In Review", + testing: "In Review", + blocked: "Canceled", + done: "Done", + }, + labelMap: {}, + autoCreateLabels: true, + }, + polling: { + intervalMs: 30000, + maxCycles: 12, + exitWhenIdle: true, + }, + github: { + useGhCli: true, + defaultBugLabel: "bug", + }, + codex: { + binary: "codex", + }, + skills: { + plan: "plan.md", + implement: "implement.md", + reviewTest: "review.md", + }, + dryRun: true, + } satisfies ResolvedProjectConfig; + + it("uses project defaults when options are unset", () => { + const settings = resolvePollingSettings(project, {}); + expect(settings).toEqual({ + enabled: false, + intervalMs: 30000, + maxCycles: 12, + exitWhenIdle: true, + }); + }); + + it("applies cli overrides", () => { + const settings = resolvePollingSettings(project, { + poll: true, + pollIntervalMs: 15000, + maxPollCycles: 2, + exitWhenIdle: false, + }); + expect(settings).toEqual({ + enabled: true, + intervalMs: 15000, + maxCycles: 2, + exitWhenIdle: false, + }); + }); +});