diff --git a/README.md b/README.md index 150ea37b..0965e2fd 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ 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 run --all-projects --poll --no-exit-when-idle bun run src/index.ts status --project default --issue ENG-123 bun run src/index.ts projects ``` diff --git a/src/args.ts b/src/args.ts index 7d34cb02..588e53d4 100644 --- a/src/args.ts +++ b/src/args.ts @@ -25,6 +25,9 @@ export function parseArgs(argv: string[]): CliCommand { const projectId = readFlagValue(args, "--project"); const allProjects = args.includes("--all-projects"); const poll = args.includes("--poll"); + const exitWhenIdle = args.includes("--no-exit-when-idle") + ? false + : undefined; const pollIntervalMs = readOptionalPositiveInt(args, "--poll-interval-ms"); const maxPollCycles = readOptionalPositiveInt(args, "--max-poll-cycles"); if (projectId && allProjects) { @@ -37,6 +40,7 @@ export function parseArgs(argv: string[]): CliCommand { projectId, allProjects, poll, + exitWhenIdle, pollIntervalMs, maxPollCycles, }, diff --git a/src/index.ts b/src/index.ts index f32f1edc..28285560 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,8 +59,8 @@ function printHelp(): void { "piv-loop - Codex CLI orchestration workflow", "", "Commands:", - " piv-loop run [--project ] [--issue ] [--poll] [--poll-interval-ms ] [--max-poll-cycles ]", - " piv-loop run --all-projects [--issue ]", + " piv-loop run [--project ] [--issue ] [--poll] [--no-exit-when-idle] [--poll-interval-ms ] [--max-poll-cycles ]", + " piv-loop run --all-projects [--issue ] [--poll] [--no-exit-when-idle]", " piv-loop status --project --issue ", " piv-loop projects", " piv-loop help", diff --git a/src/workflow.ts b/src/workflow.ts index 2be62f6f..4fcc3fbe 100644 --- a/src/workflow.ts +++ b/src/workflow.ts @@ -35,8 +35,33 @@ export async function runWorkflow( return; } - for (const project of projects) { - await runProjectWorkflow(project, options); + const projectContexts = projects.map((project) => ({ + config: project, + linear: new LinearClient(project), + polling: resolvePollingSettings(project, options), + })); + const globalPolling = resolveGlobalPollingSettings(projectContexts); + let cycle = 0; + + while (true) { + cycle += 1; + let totalIssues = 0; + + for (const context of projectContexts) { + totalIssues += await runProjectCycle( + context.config, + options, + context.linear, + cycle, + context.polling, + ); + } + + if (shouldStopPolling(globalPolling, options, cycle, totalIssues)) { + return; + } + + await sleep(globalPolling.intervalMs); } } @@ -57,48 +82,61 @@ function pickProjects( return config.projects.slice(0, 1); } -async function runProjectWorkflow( +function resolveGlobalPollingSettings( + projects: Array<{ config: ResolvedProjectConfig; polling: PollingSettings }>, +): PollingSettings { + const first = projects[0]; + if (!first) { + return { + enabled: false, + intervalMs: 30000, + exitWhenIdle: true, + }; + } + return first.polling; +} + +export function shouldStopPolling( + polling: PollingSettings, + options: RunOptions, + cycle: number, + totalIssues: number, +): boolean { + if (!polling.enabled || options.issueArg) { + return true; + } + if (polling.maxCycles !== undefined && cycle >= polling.maxCycles) { + return true; + } + if (totalIssues === 0 && polling.exitWhenIdle) { + return true; + } + return false; +} + +async function runProjectCycle( config: ResolvedProjectConfig, options: RunOptions, -): Promise { - const linear = new LinearClient(config); + linear: LinearClient, + cycle: number, + polling: PollingSettings, +): Promise { const projectLogger = logger.child({ projectId: config.id }); - const polling = resolvePollingSettings(config, options); - let cycle = 0; - - 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); - } + const issues = await linear.fetchWork(options.issueArg); + projectLogger.info( + { cycle, issueCount: issues.length, pollingEnabled: polling.enabled }, + "Fetched eligible Linear issues", + ); - 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; - } + if (issues.length === 0) { + projectLogger.info({ cycle }, "No eligible Linear issues found."); + } - await sleep(polling.intervalMs); + for (const issue of issues) { + await processIssue(config, linear, issue); } + + return issues.length; } async function processIssue( diff --git a/tests/args.test.ts b/tests/args.test.ts index 771e5f3a..e352b0ce 100644 --- a/tests/args.test.ts +++ b/tests/args.test.ts @@ -11,6 +11,7 @@ describe("parseArgs", () => { projectId: undefined, allProjects: false, poll: false, + exitWhenIdle: undefined, pollIntervalMs: undefined, maxPollCycles: undefined, }, @@ -25,6 +26,7 @@ describe("parseArgs", () => { projectId: "api", allProjects: false, poll: false, + exitWhenIdle: undefined, pollIntervalMs: undefined, maxPollCycles: undefined, }, @@ -49,12 +51,35 @@ describe("parseArgs", () => { projectId: undefined, allProjects: false, poll: true, + exitWhenIdle: undefined, pollIntervalMs: 15000, maxPollCycles: 20, }, }); }); + it("parses no-exit-when-idle flag", () => { + const parsed = parseArgs([ + "bun", + "piv-loop", + "run", + "--poll", + "--no-exit-when-idle", + ]); + expect(parsed).toEqual({ + kind: "run", + options: { + issueArg: undefined, + projectId: undefined, + allProjects: false, + poll: true, + exitWhenIdle: false, + pollIntervalMs: undefined, + maxPollCycles: undefined, + }, + }); + }); + it("rejects invalid poll-interval-ms", () => { expect(() => parseArgs(["bun", "piv-loop", "run", "--poll-interval-ms", "0"]), diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index 74332805..67c85764 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -5,6 +5,7 @@ import { buildPlanComment, parseReviewOutcome, resolvePollingSettings, + shouldStopPolling, } from "../src/workflow"; describe("parseReviewOutcome", () => { @@ -122,6 +123,58 @@ describe("resolvePollingSettings", () => { }); }); +describe("shouldStopPolling", () => { + it("stops immediately when polling is disabled", () => { + const stop = shouldStopPolling( + { enabled: false, intervalMs: 30000, exitWhenIdle: true }, + {}, + 1, + 2, + ); + expect(stop).toBe(true); + }); + + it("stops immediately when issue is explicitly targeted", () => { + const stop = shouldStopPolling( + { enabled: true, intervalMs: 30000, exitWhenIdle: false }, + { poll: true, issueArg: "ENG-1" }, + 1, + 1, + ); + expect(stop).toBe(true); + }); + + it("stops after max polling cycles", () => { + const stop = shouldStopPolling( + { enabled: true, intervalMs: 30000, maxCycles: 2, exitWhenIdle: false }, + { poll: true }, + 2, + 3, + ); + expect(stop).toBe(true); + }); + + it("stops on global idle cycle only when enabled", () => { + const stop = shouldStopPolling( + { enabled: true, intervalMs: 30000, exitWhenIdle: true }, + { poll: true }, + 1, + 0, + ); + expect(stop).toBe(true); + }); + + it("continues when any project has work in the cycle", () => { + const stop = shouldStopPolling( + { enabled: true, intervalMs: 30000, exitWhenIdle: true }, + { poll: true }, + 1, + 1, + ); + expect(stop).toBe(false); + }); +}); + describe("buildIssueJobLogFields", () => { it("returns consistent issue job fields", () => { const now = new Date().toISOString();