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
37 changes: 37 additions & 0 deletions .github/release-notes/v2.3.0.md
Original file line number Diff line number Diff line change
@@ -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.
223 changes: 199 additions & 24 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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: |
Expand All @@ -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
Expand All @@ -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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 注入新的
Expand Down
2 changes: 1 addition & 1 deletion adapters/chainlit/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions deploy/helm/lingxigraph/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/lingxigraph/version.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Single source of truth for the package and API version."""

__version__ = "2.2.0"
__version__ = "2.3.0"

__all__ = ["__version__"]
4 changes: 2 additions & 2 deletions tests/test_agent_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading