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
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions services/langgraph/src/subgraphs/devops/deployer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down
132 changes: 132 additions & 0 deletions services/langgraph/tests/unit/test_deploy_poll_cadence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""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 datetime import UTC, datetime
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}"
RUN_STAMP = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")


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": RUN_STAMP,
"updated_at": RUN_STAMP,
"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"),
]