diff --git a/.github/release-notes/v2.3.0.md b/.github/release-notes/v2.3.0.md new file mode 100644 index 0000000..fe625d0 --- /dev/null +++ b/.github/release-notes/v2.3.0.md @@ -0,0 +1,37 @@ +# LingxiGraph v2.3.0 — Durable Mid-Run Steering + +LingxiGraph 2.3.0 introduces durable, application-controlled mid-run steering for long-running graph executions. External input can now be accepted while a Run is active or paused, persisted in PostgreSQL, and consumed only at application-chosen safe points without hard preemption or hidden state mutation. + +## Highlights + +- **Durable mid-run steering** — `POST /v1/runs/{run_id}/steer` and matching sync/async SDK APIs accept structured steering input with idempotency and payload-size enforcement. +- **Application-controlled safe points** — graphs decide when to inspect, peek, or drain steering through `runtime.has_steering`, `runtime.peek_steering()`, and `runtime.drain_steering()`. +- **Crash-safe delivery semantics** — PostgreSQL remains the source of truth; Redis is optional notification acceleration only. Stable steering identity and fenced consumption commits support retry and at-least-once redelivery without pretending to provide global exactly-once execution. +- **Correct pause/resume lineage** — pending steering transfers atomically to the resumed descendant Run. Typed `superseded_by_run_id` lineage prevents stale Run IDs or user metadata from forging Runtime control state. +- **Stronger cancellation and worker fencing** — expired `cancelling` leases recover to `cancelled`, paused cancellation terminates immediately, stale attempts cannot overwrite newer ownership, and retry state plus `worker_retrying` observability commit atomically. +- **First-class observability** — durable `run.steer.accepted`, `run.steer.consumed`, and `run.steer.superseded` lifecycle events expose identity, ordering, queue latency, safe-point location, and disposition. +- **Complete bilingual documentation** — API, Python SDK, concepts, operations, errors/events, migration guidance, and release documentation are aligned with the final Runtime behavior. + +## Upgrade + +```bash +pip install --upgrade "lingxigraph==2.3.0" +``` + +For PostgreSQL-backed deployments, run the forward-only schema migration before enabling the new Runtime behavior: + +```bash +alembic upgrade head +``` + +The schema must reach `0005_superseded_by_run_id`. Revisions `0002` through `0005` are additive and can be applied before the new binary, but old binaries do not understand the new steering/lineage semantics. + +## Compatibility + +This release does **not** introduce a breaking public API change for existing graphs that do not use steering. Steering remains opt-in, and Redis is never the sole source of durable steering state. + +Python **3.11+** is supported. + +## Release scope + +The primary implementation was developed and reviewed in #16 / #17, followed by documentation and release-pipeline hardening. See `CHANGELOG.md` for the complete technical change log. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8166dad..7cc16d5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,12 +1,17 @@ name: Publish Python package on: + push: + branches: + - main + paths: + - 'src/lingxigraph/version.py' workflow_dispatch: inputs: tag: description: Annotated release tag to publish required: true - default: v2.2.0 + default: v2.3.0 type: string repository: description: Package index @@ -21,36 +26,113 @@ permissions: contents: read jobs: - publish: + prepare: + name: Prepare release identity runs-on: ubuntu-latest permissions: - contents: read - id-token: write + contents: write + outputs: + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.release.outputs.version }} + repository: ${{ steps.release.outputs.repository }} steps: - - name: Validate tag input + - name: Check out repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Resolve and validate release + id: release + shell: bash env: - RELEASE_TAG: ${{ inputs.tag }} + EVENT_NAME: ${{ github.event_name }} + INPUT_TAG: ${{ inputs.tag }} + INPUT_REPOSITORY: ${{ inputs.repository }} run: | + set -euo pipefail + + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + RELEASE_TAG="$INPUT_TAG" + TARGET_REPOSITORY="$INPUT_REPOSITORY" + else + VERSION=$(python - <<'PY' + import pathlib + import re + text = pathlib.Path("src/lingxigraph/version.py").read_text() + match = re.search(r'__version__\s*=\s*"([0-9]+\.[0-9]+\.[0-9]+)"', text) + if match is None: + raise SystemExit("Unable to read __version__ from src/lingxigraph/version.py") + print(match.group(1)) + PY + ) + RELEASE_TAG="v$VERSION" + TARGET_REPOSITORY="pypi" + fi + if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Expected a tag such as v2.1.0, got: $RELEASE_TAG" >&2 + echo "Expected a tag such as v2.3.0, got: $RELEASE_TAG" >&2 exit 1 fi - - uses: actions/checkout@v4 + VERSION="${RELEASE_TAG#v}" + PYPROJECT_VERSION=$(python - <<'PY' + import tomllib + with open("pyproject.toml", "rb") as stream: + print(tomllib.load(stream)["project"]["version"]) + PY + ) + RUNTIME_VERSION=$(python - <<'PY' + import pathlib + import re + text = pathlib.Path("src/lingxigraph/version.py").read_text() + match = re.search(r'__version__\s*=\s*"([0-9]+\.[0-9]+\.[0-9]+)"', text) + if match is None: + raise SystemExit("Unable to read runtime version") + print(match.group(1)) + PY + ) + + test "$PYPROJECT_VERSION" = "$VERSION" + test "$RUNTIME_VERSION" = "$VERSION" + grep -q "^## $VERSION - " CHANGELOG.md + + git fetch --tags --force + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + test "$(git cat-file -t "$RELEASE_TAG")" = tag + git checkout --detach "$RELEASE_TAG" + test "$(git cat-file -t "$RELEASE_TAG")" = tag + else + if git rev-parse --verify "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then + echo "Release tag $RELEASE_TAG already exists; refusing an automatic duplicate publish." >&2 + exit 1 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$RELEASE_TAG" -m "LingxiGraph $VERSION" + git push origin "refs/tags/$RELEASE_TAG" + fi + + echo "tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "repository=$TARGET_REPOSITORY" >> "$GITHUB_OUTPUT" + + build: + name: Build release distributions + needs: prepare + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out release tag + uses: actions/checkout@v6 with: - ref: ${{ inputs.tag }} + ref: ${{ needs.prepare.outputs.tag }} fetch-depth: 0 - - name: Verify annotated tag - env: - RELEASE_TAG: ${{ inputs.tag }} - run: | - test "$(git describe --tags --exact-match)" = "$RELEASE_TAG" - test "$(git cat-file -t "$RELEASE_TAG")" = tag - - - uses: actions/setup-python@v5 + - name: Set up Python + uses: actions/setup-python@v6 with: - python-version: "3.13" + python-version: '3.13' - name: Build distributions run: | @@ -59,7 +141,7 @@ jobs: - name: Verify distribution metadata env: - RELEASE_TAG: ${{ inputs.tag }} + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} run: | python - <<'PY' import email @@ -83,12 +165,105 @@ jobs: assert expected in wheels[0].name and expected in sdists[0].name PY - - name: Publish to TestPyPI - if: inputs.repository == 'testpypi' + - name: Upload release distributions + uses: actions/upload-artifact@v6 + with: + name: python-dists-${{ needs.prepare.outputs.version }} + path: dist/ + if-no-files-found: error + retention-days: 14 + + publish-pypi: + name: Publish to PyPI + if: needs.prepare.outputs.repository == 'pypi' + needs: + - prepare + - build + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - name: Download release distributions + uses: actions/download-artifact@v7 + with: + name: python-dists-${{ needs.prepare.outputs.version }} + path: dist/ + + - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - repository-url: https://test.pypi.org/legacy/ + skip-existing: ${{ github.event_name == 'workflow_dispatch' }} + + publish-testpypi: + name: Publish to TestPyPI + if: needs.prepare.outputs.repository == 'testpypi' + needs: + - prepare + - build + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - name: Download release distributions + uses: actions/download-artifact@v7 + with: + name: python-dists-${{ needs.prepare.outputs.version }} + path: dist/ - - name: Publish to PyPI - if: inputs.repository == 'pypi' + - name: Publish package distributions to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + skip-existing: true + + github-release: + name: Publish GitHub Release + if: needs.prepare.outputs.repository == 'pypi' + needs: + - prepare + - build + - publish-pypi + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Check out release tag + uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare.outputs.tag }} + fetch-depth: 0 + + - name: Download release distributions + uses: actions/download-artifact@v7 + with: + name: python-dists-${{ needs.prepare.outputs.version }} + path: dist/ + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + VERSION: ${{ needs.prepare.outputs.version }} + shell: bash + run: | + set -euo pipefail + NOTES_FILE=".github/release-notes/$RELEASE_TAG.md" + + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release upload "$RELEASE_TAG" dist/* --clobber + exit 0 + fi + + if [ -f "$NOTES_FILE" ]; then + gh release create "$RELEASE_TAG" dist/* \ + --verify-tag \ + --latest \ + --title "LingxiGraph $RELEASE_TAG" \ + --notes-file "$NOTES_FILE" + else + gh release create "$RELEASE_TAG" dist/* \ + --verify-tag \ + --latest \ + --title "LingxiGraph $RELEASE_TAG" \ + --generate-notes + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index d3673fd..2b8e998 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +_No unreleased changes._ + +## 2.3.0 - 2026-08-18 + ### Durable mid-run steering (issue #16) - 新增 `POST /v1/runs/{run_id}/steer`:在 Run 执行期间(含 `paused`)durably 注入新的 diff --git a/adapters/chainlit/uv.lock b/adapters/chainlit/uv.lock index 900ffcb..b58aea7 100644 --- a/adapters/chainlit/uv.lock +++ b/adapters/chainlit/uv.lock @@ -858,7 +858,7 @@ wheels = [ [[package]] name = "lingxigraph" -version = "2.2.0" +version = "2.3.0" source = { editable = "../../" } [package.metadata] diff --git a/deploy/helm/lingxigraph/Chart.yaml b/deploy/helm/lingxigraph/Chart.yaml index b49833c..bd36aa8 100644 --- a/deploy/helm/lingxigraph/Chart.yaml +++ b/deploy/helm/lingxigraph/Chart.yaml @@ -2,6 +2,6 @@ apiVersion: v2 name: lingxigraph description: Enterprise LingxiGraph Agent Server and distributed workers type: application -version: 2.1.0 -appVersion: "2.1.0" +version: 2.3.0 +appVersion: "2.3.0" kubeVersion: ">=1.27.0-0" diff --git a/pyproject.toml b/pyproject.toml index b23e146..ca8aba7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "lingxigraph" -version = "2.2.0" +version = "2.3.0" description = "Enterprise, provider-neutral durable graph runtime for multi-agent systems." readme = "README.md" requires-python = ">=3.11" diff --git a/src/lingxigraph/version.py b/src/lingxigraph/version.py index f095cfd..d63c145 100644 --- a/src/lingxigraph/version.py +++ b/src/lingxigraph/version.py @@ -1,5 +1,5 @@ """Single source of truth for the package and API version.""" -__version__ = "2.2.0" +__version__ = "2.3.0" __all__ = ["__version__"] diff --git a/tests/test_agent_server.py b/tests/test_agent_server.py index d8608e6..cebf9b9 100644 --- a/tests/test_agent_server.py +++ b/tests/test_agent_server.py @@ -5,7 +5,7 @@ from fastapi.testclient import TestClient -from lingxigraph import END, START, StateGraph, interrupt +from lingxigraph import END, START, StateGraph, __version__, interrupt from lingxigraph.server import GraphRegistry, create_app from lingxigraph.server.security import Authenticator @@ -550,7 +550,7 @@ def test_interrupt_resume_and_protocol_gateways(self) -> None: json={"jsonrpc": "2.0", "id": 1, "method": "initialize"}, ) self.assertEqual(initialized.json()["result"]["serverInfo"]["name"], "LingxiGraph") - self.assertEqual(initialized.json()["result"]["serverInfo"]["version"], "2.2.0") + self.assertEqual(initialized.json()["result"]["serverInfo"]["version"], __version__) tools = client.post( "/mcp", headers=headers, diff --git a/uv.lock b/uv.lock index 04efe6b..52a16a1 100644 --- a/uv.lock +++ b/uv.lock @@ -1021,7 +1021,7 @@ wheels = [ [[package]] name = "lingxigraph" -version = "2.2.0" +version = "2.3.0" source = { editable = "." } [package.optional-dependencies]