From 22b3c4505d5b61c0aa41acc02e1f78f2bd3ac48b Mon Sep 17 00:00:00 2001 From: q Date: Mon, 19 Oct 2026 02:59:05 +0000 Subject: [PATCH 1/2] Poll deploy and rerun completion every 3 s through the GitHub client waiters --- docs/CHANGELOG.md | 2 + .../src/subgraphs/devops/deployer.py | 4 + .../tests/unit/test_deploy_poll_cadence.py | 130 ++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 services/langgraph/tests/unit/test_deploy_poll_cadence.py diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f55e4c277..77f8af37d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,8 @@ See the CHANGELOG rule in [AGENTS.md](../AGENTS.md). - New products scaffold from kit tag `0.11.0` (commit `59a0752c`): parallel PR CI behind fail-closed `lint-and-test`, native SSH deploy with `up --wait`; existing products keep their workflow until updated. +- The deployer rereads a pending deploy run and an accepted failed-job rerun every 3 s instead of + 15 s, so a finished deploy is seen sooner; the first read stays immediate. ## 2026-10-18 diff --git a/services/langgraph/src/subgraphs/devops/deployer.py b/services/langgraph/src/subgraphs/devops/deployer.py index 53a23dc7d..a848dcf04 100644 --- a/services/langgraph/src/subgraphs/devops/deployer.py +++ b/services/langgraph/src/subgraphs/devops/deployer.py @@ -36,6 +36,8 @@ DEPLOY_WORKFLOW = "deploy.yml" DEPLOY_TIMEOUT_SECONDS = 600 +# Each waiter reads the run once before its first sleep; this paces the rereads. +DEPLOY_POLL_SECONDS = 3 # A rerun's run reappears in the API a moment after the request; unit tests set it to 0. RERUN_SETTLE_SECONDS = 3 @@ -402,6 +404,7 @@ async def _try_deploy_rerun( repo, run_id, timeout_seconds=DEPLOY_TIMEOUT_SECONDS, + poll_interval=DEPLOY_POLL_SECONDS, cancel_check=lambda: self._run_cancelled(deploy_run_id), ) logger.info("deploy_rerun_passed", run_id=run_id) @@ -513,6 +516,7 @@ async def _dispatch_and_wait( workflow_file=DEPLOY_WORKFLOW, branch=ref, timeout_seconds=DEPLOY_TIMEOUT_SECONDS, + poll_interval=DEPLOY_POLL_SECONDS, created_after=dispatch_time, head_sha=deployed_commit_sha or None, cancel_check=lambda: self._run_cancelled(run_id), diff --git a/services/langgraph/tests/unit/test_deploy_poll_cadence.py b/services/langgraph/tests/unit/test_deploy_poll_cadence.py new file mode 100644 index 000000000..b502e36e9 --- /dev/null +++ b/services/langgraph/tests/unit/test_deploy_poll_cadence.py @@ -0,0 +1,130 @@ +"""The deployer polls deploy completion every 3 s through the real GitHub client waiters. + +GitHub Actions is a `respx` fake that answers scripted run states; sleeps are recorded, +never slept, so the order of reads and waits is the evidence, not wall-clock timing. +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock + +import httpx +import pytest +import respx + +from shared.clients.github import GitHubAppClient +from src.subgraphs.devops.deployer import DeployerNode +from tests.unit.deploy_fences import held_deploy_fence + +BUILT_SHA = "b" * 40 +RUN_ID = 7 +RUNS_PATH = "/repos/org/product/actions/workflows/deploy.yml/runs" +RUN_PATH = f"/repos/org/product/actions/runs/{RUN_ID}" + + +def _run(status: str, conclusion: str | None = None) -> dict: + return { + "id": RUN_ID, + "status": status, + "conclusion": conclusion, + "html_url": f"https://github.com/org/product/actions/runs/{RUN_ID}", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "head_sha": BUILT_SHA, + } + + +class Actions: + """Answers each run read with the next scripted state and logs every read and sleep.""" + + def __init__(self, listed: list[dict | None], by_id: list[dict]) -> None: + self.listed = listed + self.by_id = by_id + self.events: list[tuple] = [] + + def handle(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + if request.method == "GET" and path == RUNS_PATH: + self.events.append(("list",)) + run = self.listed.pop(0) + return httpx.Response(200, json={"workflow_runs": [run] if run else []}) + if request.method == "GET" and path == RUN_PATH: + self.events.append(("read",)) + return httpx.Response(200, json=self.by_id.pop(0)) + if request.method == "POST" and path.endswith("/rerun-failed-jobs"): + self.events.append(("rerun",)) + return httpx.Response(201) + if request.method == "POST" and path.endswith("/dispatches"): + self.events.append(("dispatch",)) + return httpx.Response(204) + if path.startswith("/repos/org/product/git/refs"): + self.events.append(("tag", request.method)) + return httpx.Response(201 if request.method == "POST" else 204, json={}) + # Failure-log reads: the waiter reports a failure without them. + return httpx.Response(404) + + async def sleep(self, seconds: float) -> None: + self.events.append(("sleep", seconds)) + + +async def _deploy(actions: Actions, monkeypatch) -> tuple[dict, bool]: + github = GitHubAppClient() + github.get_token = AsyncMock(return_value="github-token") + monkeypatch.setattr(asyncio, "sleep", actions.sleep) + with respx.mock(assert_all_called=False) as mock: + mock.route(host="api.github.com").mock(side_effect=actions.handle) + return await DeployerNode()._dispatch_and_wait( + github, "org", "product", BUILT_SHA, None, held_deploy_fence() + ) + + +@pytest.mark.asyncio +async def test_dispatch_rereads_a_pending_run_every_three_seconds_and_stops_on_success( + monkeypatch, +): + actions = Actions( + listed=[None, _run("in_progress"), _run("completed", "success")], + by_id=[], + ) + + run_info, rerun = await _deploy(actions, monkeypatch) + + assert (run_info["id"], run_info["conclusion"], rerun) == (RUN_ID, "success", False) + assert actions.events == [ + ("tag", "POST"), + ("dispatch",), + ("list",), + ("sleep", 3), + ("list",), + ("sleep", 3), + ("list",), + ("tag", "DELETE"), + ] + + +@pytest.mark.asyncio +async def test_an_accepted_rerun_settles_once_then_polls_its_run_every_three_seconds( + monkeypatch, +): + failed = _run("completed", "failure") + actions = Actions( + listed=[failed, failed], + by_id=[_run("in_progress"), _run("completed", "success")], + ) + + run_info, rerun = await _deploy(actions, monkeypatch) + + assert (run_info["id"], run_info["conclusion"], rerun) == (RUN_ID, "success", True) + assert actions.events == [ + ("tag", "POST"), + ("dispatch",), + ("list",), + ("list",), + ("rerun",), + ("sleep", 3), + ("read",), + ("sleep", 3), + ("read",), + ("tag", "DELETE"), + ] From 16b614450136634bd5ebdf9a54adb28e7b012938 Mon Sep 17 00:00:00 2001 From: q Date: Mon, 19 Oct 2026 03:19:39 +0000 Subject: [PATCH 2/2] Generate the deploy cadence fixture timestamp instead of a literal date --- services/langgraph/tests/unit/test_deploy_poll_cadence.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/services/langgraph/tests/unit/test_deploy_poll_cadence.py b/services/langgraph/tests/unit/test_deploy_poll_cadence.py index b502e36e9..ecd070d30 100644 --- a/services/langgraph/tests/unit/test_deploy_poll_cadence.py +++ b/services/langgraph/tests/unit/test_deploy_poll_cadence.py @@ -7,6 +7,7 @@ from __future__ import annotations import asyncio +from datetime import UTC, datetime from unittest.mock import AsyncMock import httpx @@ -21,6 +22,7 @@ RUN_ID = 7 RUNS_PATH = "/repos/org/product/actions/workflows/deploy.yml/runs" RUN_PATH = f"/repos/org/product/actions/runs/{RUN_ID}" +RUN_STAMP = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") def _run(status: str, conclusion: str | None = None) -> dict: @@ -29,8 +31,8 @@ def _run(status: str, conclusion: str | None = None) -> dict: "status": status, "conclusion": conclusion, "html_url": f"https://github.com/org/product/actions/runs/{RUN_ID}", - "created_at": "2026-01-01T00:00:00Z", - "updated_at": "2026-01-01T00:00:00Z", + "created_at": RUN_STAMP, + "updated_at": RUN_STAMP, "head_sha": BUILT_SHA, }