From 4e0ecdb3a8b2df0adc92367fc051a6f24ef8f9b3 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 16 Oct 2026 16:29:35 -0400 Subject: [PATCH] fix(watch-pr): cap polling sleeps at the deadline Signed-off-by: Rudy Celekli --- .../scripts/watch-pr/policy.test.ts | 62 +++++++++++++++++++ .../poteto-mode/scripts/watch-pr/policy.ts | 10 ++- .../scripts/watch-pr/policy.test.ts | 62 +++++++++++++++++++ .../poteto-mode/scripts/watch-pr/policy.ts | 10 ++- 4 files changed, 140 insertions(+), 4 deletions(-) diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.test.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.test.ts index 6dae16da7..966b75367 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.test.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.test.ts @@ -10,6 +10,7 @@ import { queryBackoffSeconds, readSnapshot, runQueued, + runSimple, selectTierMajorStackDecision, } from "./policy.ts"; import { @@ -418,3 +419,64 @@ it("uses the specified retry floor and cap", () => { expect(queryBackoffSeconds(1, 2)).toBe(120); expect(queryBackoffSeconds(60, 4)).toBe(300); }); + + +describe("polling deadline sleeps", () => { + for (const mode of ["single", "queued-stack", "query-retry"] as const) { + it(`caps ${mode} sleeps at the configured deadline`, async () => { + let now = 0; + const sleeps: number[] = []; + const pending = fakeReader({ fastPath: { kind: "checks", checks: [pendingCheck()] } }); + const reader = mode === "query-retry" + ? { ...pending, async pullRequest(): Promise { + throw new WatcherQueryError({ kind: "command-exit", retryable: true, code: 1, detail: "temporary failure" }); + } } + : pending; + const dependencies = { + reader, + clock: { + now: () => now, + observedAt: () => "2026-07-26T00:00:00.000Z", + async sleep(seconds: number) { sleeps.push(seconds); now += seconds; }, + }, + emit() {}, + }; + const polling = { ...options, interval: 60, timeout: 5 }; + const result = mode === "queued-stack" + ? await runQueued({ dependencies, contexts: [context(1)], options: polling }) + : await runSimple({ dependencies, contexts: [context(1)], mode: "single", statusOnly: false, options: polling }); + expect(result.kind).toBe("TIMEOUT"); + expect(sleeps).toEqual([5]); + expect(now).toBe(5); + }); + } + it("preserves the polling interval when the deadline is disabled", async () => { + let now = 0; + let reads = 0; + const sleeps: number[] = []; + const pending = fakeReader({ fastPath: { kind: "checks", checks: [pendingCheck()] } }); + const passed = fakeReader({ fastPath: { kind: "checks", checks: [passingCheck()] } }); + const result = await runSimple({ + dependencies: { + reader: { + ...pending, + async checksFastPath(pr) { + reads += 1; + return reads === 1 ? pending.checksFastPath(pr) : passed.checksFastPath(pr); + }, + }, + clock: { + now: () => now, + observedAt: () => "2026-07-26T00:00:00.000Z", + async sleep(seconds) { sleeps.push(seconds); now += seconds; }, + }, + emit() {}, + }, + contexts: [context(1)], mode: "single", statusOnly: false, + options: { ...options, interval: 60, timeout: 0 }, + }); + expect(result.kind).toBe("READY"); + expect(sleeps).toEqual([60]); + }); + +}); diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.ts index cabadb9bf..8179f2d8b 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.ts @@ -370,6 +370,12 @@ async function pollUntilTerminal(args: { }): Promise { let failures = 0; const started = args.dependencies.clock.now(); + const sleep = async (seconds: number): Promise => { + const remaining = args.options.timeout > 0 + ? Math.max(0, args.options.timeout - (args.dependencies.clock.now() - started)) + : seconds; + await args.dependencies.clock.sleep(Math.min(seconds, remaining)); + }; while (true) { let result: StepResult; try { @@ -400,7 +406,7 @@ async function pollUntilTerminal(args: { exitCode: 5, reason: { kind: "status-unavailable", failure: error.failure }, }); - await args.dependencies.clock.sleep(retryInSeconds); + await sleep(retryInSeconds); continue; } if (result.kind === "terminal") return result.verdict; @@ -410,7 +416,7 @@ async function pollUntilTerminal(args: { deadlinePassed(started, args.options, args.dependencies.clock.now()) ) return result.onDeadline(); - await args.dependencies.clock.sleep(result.seconds); + await sleep(result.seconds); } } } diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts b/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts index 6dae16da7..966b75367 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts @@ -10,6 +10,7 @@ import { queryBackoffSeconds, readSnapshot, runQueued, + runSimple, selectTierMajorStackDecision, } from "./policy.ts"; import { @@ -418,3 +419,64 @@ it("uses the specified retry floor and cap", () => { expect(queryBackoffSeconds(1, 2)).toBe(120); expect(queryBackoffSeconds(60, 4)).toBe(300); }); + + +describe("polling deadline sleeps", () => { + for (const mode of ["single", "queued-stack", "query-retry"] as const) { + it(`caps ${mode} sleeps at the configured deadline`, async () => { + let now = 0; + const sleeps: number[] = []; + const pending = fakeReader({ fastPath: { kind: "checks", checks: [pendingCheck()] } }); + const reader = mode === "query-retry" + ? { ...pending, async pullRequest(): Promise { + throw new WatcherQueryError({ kind: "command-exit", retryable: true, code: 1, detail: "temporary failure" }); + } } + : pending; + const dependencies = { + reader, + clock: { + now: () => now, + observedAt: () => "2026-07-26T00:00:00.000Z", + async sleep(seconds: number) { sleeps.push(seconds); now += seconds; }, + }, + emit() {}, + }; + const polling = { ...options, interval: 60, timeout: 5 }; + const result = mode === "queued-stack" + ? await runQueued({ dependencies, contexts: [context(1)], options: polling }) + : await runSimple({ dependencies, contexts: [context(1)], mode: "single", statusOnly: false, options: polling }); + expect(result.kind).toBe("TIMEOUT"); + expect(sleeps).toEqual([5]); + expect(now).toBe(5); + }); + } + it("preserves the polling interval when the deadline is disabled", async () => { + let now = 0; + let reads = 0; + const sleeps: number[] = []; + const pending = fakeReader({ fastPath: { kind: "checks", checks: [pendingCheck()] } }); + const passed = fakeReader({ fastPath: { kind: "checks", checks: [passingCheck()] } }); + const result = await runSimple({ + dependencies: { + reader: { + ...pending, + async checksFastPath(pr) { + reads += 1; + return reads === 1 ? pending.checksFastPath(pr) : passed.checksFastPath(pr); + }, + }, + clock: { + now: () => now, + observedAt: () => "2026-07-26T00:00:00.000Z", + async sleep(seconds) { sleeps.push(seconds); now += seconds; }, + }, + emit() {}, + }, + contexts: [context(1)], mode: "single", statusOnly: false, + options: { ...options, interval: 60, timeout: 0 }, + }); + expect(result.kind).toBe("READY"); + expect(sleeps).toEqual([60]); + }); + +}); diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts b/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts index cabadb9bf..8179f2d8b 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts @@ -370,6 +370,12 @@ async function pollUntilTerminal(args: { }): Promise { let failures = 0; const started = args.dependencies.clock.now(); + const sleep = async (seconds: number): Promise => { + const remaining = args.options.timeout > 0 + ? Math.max(0, args.options.timeout - (args.dependencies.clock.now() - started)) + : seconds; + await args.dependencies.clock.sleep(Math.min(seconds, remaining)); + }; while (true) { let result: StepResult; try { @@ -400,7 +406,7 @@ async function pollUntilTerminal(args: { exitCode: 5, reason: { kind: "status-unavailable", failure: error.failure }, }); - await args.dependencies.clock.sleep(retryInSeconds); + await sleep(retryInSeconds); continue; } if (result.kind === "terminal") return result.verdict; @@ -410,7 +416,7 @@ async function pollUntilTerminal(args: { deadlinePassed(started, args.options, args.dependencies.clock.now()) ) return result.onDeadline(); - await args.dependencies.clock.sleep(result.seconds); + await sleep(result.seconds); } } }