From cf0d8bf7037b125dac4b78d5fe6af0f787446523 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 13 Sep 2026 16:43:29 +0000 Subject: [PATCH 1/4] fix: repair subject release tracking after legacy migration022 --- .pr/subject-release-upgrade.json | 12 +++ .pr/subject-release-upgrade.md | 14 +++ .../023_repair_subject_release_tracking.py | 44 +++++++++ tests/test_subject_release_migration.py | 97 +++++++++++++++++++ 4 files changed, 167 insertions(+) create mode 100644 .pr/subject-release-upgrade.json create mode 100644 .pr/subject-release-upgrade.md create mode 100644 migrations/versions/023_repair_subject_release_tracking.py create mode 100644 tests/test_subject_release_migration.py diff --git a/.pr/subject-release-upgrade.json b/.pr/subject-release-upgrade.json new file mode 100644 index 00000000..57dd3b07 --- /dev/null +++ b/.pr/subject-release-upgrade.json @@ -0,0 +1,12 @@ +[ + { + "case": "main without repair migration", + "returncode": 1, + "result": "legacy022 fails on current ORM run query; current022 and fresh cases pass" + }, + { + "case": "same source plus repair023", + "returncode": 0, + "result": "all3 upgrade cases pass; existing run and release timestamp preserved; repeated upgrade and downgrade022/reupgrade pass" + } +] \ No newline at end of file diff --git a/.pr/subject-release-upgrade.md b/.pr/subject-release-upgrade.md new file mode 100644 index 00000000..0142188d --- /dev/null +++ b/.pr/subject-release-upgrade.md @@ -0,0 +1,14 @@ +# Subject-release migration upgrade evidence + +The installed Git checkout moved from `c756d24c91bc12da8bb329be815824bbc77dafe5` to `a05e89370be94beb72b34fedaf129da678c294d5` on `vasco/external-conversations`. Both use Alembic022, but only the latter adds `subject_released_at`. Their1.9.1/1.10.0 distribution metadata must not be confused with the release tags, which contain no022 migration. + +`tests/test_subject_release_migration.py` builds the old022 schema from the exact historical DDL, then exercises normal Alembic upgrade and current ORM queries. It also checks modern022 and fresh databases, preserves an existing completed run and existing release timestamp, and repeats startup upgrade plus downgrade-to022/re-upgrade. + +| Source | Result | +| --- | --- | +| Main645f204 without the new migration, same regression tests | Legacy022 fails: `sqlite3.OperationalError: no such column: automation_runs.subject_released_at`; other2 cases pass. | +| Same source with forward migration023 | All3 cases pass. | + +Ruff, pycodestyle and Pyright checks pass. Tests use disposable SQLite files only; no VM, running service, production database or Docker container was modified. PostgreSQL execution was not exercised here; the repair uses the same SQLAlchemy/Alembic column and dialect-specific index declarations as current022. + +Follow-up before integrating the factory profile draft: Automation453 currently uses revision023 for agent-profile selection. Rebase it after this main-based repair lands, rename its profile migration to024, and set that migration's `down_revision` to023. Do not combine two different revision023 files. No factory branch or running factory was changed by this PR. diff --git a/migrations/versions/023_repair_subject_release_tracking.py b/migrations/versions/023_repair_subject_release_tracking.py new file mode 100644 index 00000000..9ee2a6f8 --- /dev/null +++ b/migrations/versions/023_repair_subject_release_tracking.py @@ -0,0 +1,44 @@ +"""Repair databases that applied 022 before subject release tracking was added. + +Revision ID: 023 +Revises: 022 +Create Date: 2026-09-13 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + + +revision: str = "023" +down_revision: str = "022" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + columns = sa.inspect(op.get_bind()).get_columns("automation_runs") + if any(column["name"] == "subject_released_at" for column in columns): + return # The newer version of 022 already created the column and index. + + op.add_column( + "automation_runs", + sa.Column("subject_released_at", sa.DateTime(timezone=True), nullable=True), + ) + op.drop_index("ix_automation_runs_subject", table_name="automation_runs") + where = sa.text("subject_key IS NOT NULL AND subject_released_at IS NULL") + op.create_index( + "ix_automation_runs_subject", + "automation_runs", + ["automation_id", "subject_key", "created_at"], + unique=False, + postgresql_where=where, + sqlite_where=where, + ) + + +def downgrade() -> None: + # Current 022 already owns this schema. Preserve it (and release history) + # when returning to 022; that revision's downgrade removes both columns. + pass diff --git a/tests/test_subject_release_migration.py b/tests/test_subject_release_migration.py new file mode 100644 index 00000000..70924b9d --- /dev/null +++ b/tests/test_subject_release_migration.py @@ -0,0 +1,97 @@ +"""Upgrade both schemas that were deployed with the same revision 022.""" + +from datetime import datetime +from pathlib import Path +from uuid import uuid4 + +import pytest +import sqlalchemy as sa +from alembic import command +from alembic.config import Config +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import Session + +from openhands.automation.models import Automation, AutomationRun, AutomationRunStatus + + +@pytest.mark.parametrize("schema", ["legacy_022", "current_022", "fresh"]) +def test_subject_release_upgrade_preserves_runs(schema, tmp_path, monkeypatch): + root = Path(__file__).parent.parent + url = f"sqlite:///{tmp_path / 'upgrade.db'}" + monkeypatch.setenv("AUTOMATION_DB_URL", url) + config = Config(str(root / "alembic.ini")) + config.set_main_option("script_location", str(root / "migrations")) + engine = sa.create_engine(url) + try: + target = "021" if schema == "legacy_022" else "022" + command.upgrade(config, "head" if schema == "fresh" else target) + if schema == "legacy_022": + # Exact 022 DDL from c756d24c91bc12da8bb329be815824bbc77dafe5: + # the original index had a PostgreSQL predicate, but no SQLite one. + with engine.begin() as connection: + operations = Operations(MigrationContext.configure(connection)) + operations.add_column( + "automation_runs", sa.Column("subject_key", sa.String(500)) + ) + operations.create_index( + "ix_automation_runs_subject", + "automation_runs", + ["automation_id", "subject_key", "created_at"], + postgresql_where=sa.text("subject_key IS NOT NULL"), + ) + command.stamp(config, "022") + with Session(engine) as session: + with pytest.raises(OperationalError, match="subject_released_at"): + session.scalars(sa.select(AutomationRun)).all() + + automation_id, run_id = uuid4(), uuid4() + released_at = None if schema == "legacy_022" else datetime(2026, 9, 13) + with engine.begin() as connection: + connection.execute( + sa.insert(Automation).values( + id=automation_id, + user_id=uuid4(), + org_id=uuid4(), + name="existing automation", + trigger={}, + tarball_path="fixture.tar.gz", + entrypoint="python main.py", + ) + ) + values = {"subject_released_at": released_at} if released_at else {} + connection.execute( + sa.insert(AutomationRun).values( + id=run_id, + automation_id=automation_id, + status=AutomationRunStatus.COMPLETED, + subject_key="repo/issue/1", + **values, + ) + ) + + command.upgrade(config, "head") + command.upgrade(config, "head") # Normal subsequent service startup. + with Session(engine) as session: + run = session.scalars(sa.select(AutomationRun)).one() + assert run.id == run_id + assert run.subject_key == "repo/issue/1" + assert run.status == AutomationRunStatus.COMPLETED + assert run.subject_released_at == released_at + index = next( + item + for item in sa.inspect(engine).get_indexes("automation_runs") + if item["name"] == "ix_automation_runs_subject" + ) + assert str(index.get("dialect_options", {}).get("sqlite_where")) == ( + "subject_key IS NOT NULL AND subject_released_at IS NULL" + ) + command.downgrade(config, "022") + with Session(engine) as session: + run = session.get(AutomationRun, run_id) + assert run is not None + assert run.subject_released_at == released_at + command.upgrade(config, "head") + finally: + engine.dispose() From 4daa6a5159c71262013f2fbfc85c561c89766373 Mon Sep 17 00:00:00 2001 From: allhands-bot Date: Sun, 13 Sep 2026 17:12:50 +0000 Subject: [PATCH 2/4] chore: Remove PR-only artifacts [automated] --- .pr/subject-release-upgrade.json | 12 ------------ .pr/subject-release-upgrade.md | 14 -------------- 2 files changed, 26 deletions(-) delete mode 100644 .pr/subject-release-upgrade.json delete mode 100644 .pr/subject-release-upgrade.md diff --git a/.pr/subject-release-upgrade.json b/.pr/subject-release-upgrade.json deleted file mode 100644 index 57dd3b07..00000000 --- a/.pr/subject-release-upgrade.json +++ /dev/null @@ -1,12 +0,0 @@ -[ - { - "case": "main without repair migration", - "returncode": 1, - "result": "legacy022 fails on current ORM run query; current022 and fresh cases pass" - }, - { - "case": "same source plus repair023", - "returncode": 0, - "result": "all3 upgrade cases pass; existing run and release timestamp preserved; repeated upgrade and downgrade022/reupgrade pass" - } -] \ No newline at end of file diff --git a/.pr/subject-release-upgrade.md b/.pr/subject-release-upgrade.md deleted file mode 100644 index 0142188d..00000000 --- a/.pr/subject-release-upgrade.md +++ /dev/null @@ -1,14 +0,0 @@ -# Subject-release migration upgrade evidence - -The installed Git checkout moved from `c756d24c91bc12da8bb329be815824bbc77dafe5` to `a05e89370be94beb72b34fedaf129da678c294d5` on `vasco/external-conversations`. Both use Alembic022, but only the latter adds `subject_released_at`. Their1.9.1/1.10.0 distribution metadata must not be confused with the release tags, which contain no022 migration. - -`tests/test_subject_release_migration.py` builds the old022 schema from the exact historical DDL, then exercises normal Alembic upgrade and current ORM queries. It also checks modern022 and fresh databases, preserves an existing completed run and existing release timestamp, and repeats startup upgrade plus downgrade-to022/re-upgrade. - -| Source | Result | -| --- | --- | -| Main645f204 without the new migration, same regression tests | Legacy022 fails: `sqlite3.OperationalError: no such column: automation_runs.subject_released_at`; other2 cases pass. | -| Same source with forward migration023 | All3 cases pass. | - -Ruff, pycodestyle and Pyright checks pass. Tests use disposable SQLite files only; no VM, running service, production database or Docker container was modified. PostgreSQL execution was not exercised here; the repair uses the same SQLAlchemy/Alembic column and dialect-specific index declarations as current022. - -Follow-up before integrating the factory profile draft: Automation453 currently uses revision023 for agent-profile selection. Rebase it after this main-based repair lands, rename its profile migration to024, and set that migration's `down_revision` to023. Do not combine two different revision023 files. No factory branch or running factory was changed by this PR. From 6364fa1008340277cab7e1473f0eec0a639955a1 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 16 Sep 2026 20:08:46 +0000 Subject: [PATCH 3/4] fix: place subject repair after current migrations --- ...racking.py => 026_repair_subject_release_tracking.py} | 8 ++++---- tests/test_subject_release_migration.py | 9 ++++++++- 2 files changed, 12 insertions(+), 5 deletions(-) rename migrations/versions/{023_repair_subject_release_tracking.py => 026_repair_subject_release_tracking.py} (94%) diff --git a/migrations/versions/023_repair_subject_release_tracking.py b/migrations/versions/026_repair_subject_release_tracking.py similarity index 94% rename from migrations/versions/023_repair_subject_release_tracking.py rename to migrations/versions/026_repair_subject_release_tracking.py index 9ee2a6f8..4903d572 100644 --- a/migrations/versions/023_repair_subject_release_tracking.py +++ b/migrations/versions/026_repair_subject_release_tracking.py @@ -1,7 +1,7 @@ """Repair databases that applied 022 before subject release tracking was added. -Revision ID: 023 -Revises: 022 +Revision ID: 026 +Revises: 025 Create Date: 2026-09-13 """ @@ -11,8 +11,8 @@ from alembic import op -revision: str = "023" -down_revision: str = "022" +revision: str = "026" +down_revision: str = "025" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None diff --git a/tests/test_subject_release_migration.py b/tests/test_subject_release_migration.py index 70924b9d..f2360aca 100644 --- a/tests/test_subject_release_migration.py +++ b/tests/test_subject_release_migration.py @@ -88,10 +88,17 @@ def test_subject_release_upgrade_preserves_runs(schema, tmp_path, monkeypatch): "subject_key IS NOT NULL AND subject_released_at IS NULL" ) command.downgrade(config, "022") + # Current ORM models include fields from later migrations, so inspect + # revision 022 with SQL instead of trying to load it through the model. + with engine.connect() as connection: + downgraded = connection.execute( + sa.text("SELECT subject_released_at FROM automation_runs") + ).scalar_one() + assert (downgraded is None) == (released_at is None) + command.upgrade(config, "head") with Session(engine) as session: run = session.get(AutomationRun, run_id) assert run is not None assert run.subject_released_at == released_at - command.upgrade(config, "head") finally: engine.dispose() From 5954b34716bbbb3ba2b185c70194276b83da759e Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 17 Sep 2026 11:48:06 +0000 Subject: [PATCH 4/4] test: reproduce deployed subject schema at revision 025 --- tests/test_subject_release_migration.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_subject_release_migration.py b/tests/test_subject_release_migration.py index f2360aca..4c05f96c 100644 --- a/tests/test_subject_release_migration.py +++ b/tests/test_subject_release_migration.py @@ -42,6 +42,9 @@ def test_subject_release_upgrade_preserves_runs(schema, tmp_path, monkeypatch): postgresql_where=sa.text("subject_key IS NOT NULL"), ) command.stamp(config, "022") + # This is the deployed failure state: later migrations applied, + # but Alembic could not see that revision 022's schema was stale. + command.upgrade(config, "025") with Session(engine) as session: with pytest.raises(OperationalError, match="subject_released_at"): session.scalars(sa.select(AutomationRun)).all()