PG proof: fix task_attempts migration on real Postgres 16.15
ci / test (push) Failing after 33m23s

- attempt_ledger.ensure: relax gap-3 outcome NOT NULL on Postgres
  (ALTER COLUMN outcome DROP NOT NULL, probe-gated); without it
  every resume write on a gap-3-migrated PG table fails with
  NotNullViolation. SQLite keeps NOT NULL (no such ALTER).
- db.PgCursor: PRAGMA table_info fake rows had the notnull flag
  inverted (NO -> 0); now matches sqlite semantics (1 = NOT NULL).
  No caller read row[3] before, so this was latent.
- ledger_onboarding: stale 'PG path unverified' notes updated to
  point at the real-PG proof.
- tests: pragma_info notnull pin (fake driver) + _column_notnull
  probe pins (fresh + gap-3 SQLite).

Proof: 28/28 checks on real Postgres 16.15 (see verification/
codex-task-attempts-pg-proof-20261106.md).
This commit is contained in:
host committed 2026-11-05 21:11:56 +00:00
1 parent 698398dafb
commit 2fcde375a4
5 files changed
+104 -16

No files matched your search

+47 -7
View File
@@ -128,9 +128,16 @@ Persistence notes:
endpoint treats a missing table exactly like "no ledger rows":
unknown rates, not an error -- the same fail-soft shape already
used for missing ``cost_records``. The Postgres upgrade path
for a legacy-shaped table (fresh ``PG_DDL`` vs. adding the
missing unified columns) has NOT been verified against a real
Postgres instance in this batch -- see the batch summary.
for a legacy-shaped table was verified against a real
Postgres 16.15 instance on 2026-11-06 (fresh ``PG_DDL``,
three legacy shapes migrated in place, both writers, PK,
idempotent ensure; see
``verification/codex-task-attempts-pg-proof-20261106.md``).
One gap-3-only step is Postgres-only by nature: a gap-3
table's ``outcome`` was created NOT NULL, and ``ensure``
relaxes it with ``ALTER COLUMN outcome DROP NOT NULL``
(see ``ensure_task_attempts_table``); on SQLite that
statement is not valid and the column stays NOT NULL.
- The read path (``GET /metrics-report``) never creates the table:
a read-only endpoint must not mutate schema. Table creation
happens only on the write path, inside ``record_attempt`` /
@@ -217,9 +224,12 @@ SQLITE_DDL = TASK_ATTEMPTS_DDL
# endpoint. It is intentionally NOT part of POSTGRES_SCHEMA (see
# the module docstring: migrations coverage must not widen silently).
# For a legacy-shaped table already on Postgres, add whichever
# unified columns are missing (see UNIFIED_COLUMN_TYPES);
# that Postgres upgrade path is NOT yet verified against a real
# Postgres instance -- see the batch summary.
# unified columns are missing (see UNIFIED_COLUMN_TYPES) and
# relax a NOT NULL outcome if the legacy shape declared one
# (see ensure_task_attempts_table); that Postgres upgrade path
# was verified against a real Postgres 16.15 instance on
# 2026-11-06 -- see verification/
# codex-task-attempts-pg-proof-20261106.md.
PG_DDL = TASK_ATTEMPTS_DDL
# Unified column set, in DDL order. The migration in
@@ -272,6 +282,17 @@ def _existing_columns(conn, table):
return set()
def _column_notnull(conn, table):
"""列名 -> 是否 NOT NULL(PRAGMA table_info 的 row[3];
SQLite 与 PG 适配层含义一致:1 = NOT NULL,0 = 可空;
探针失败时回空 dict,调用方按“无需放宽”处理)。"""
try:
return {row[1]: bool(row[3]) for row in conn.execute(
"PRAGMA table_info(%s)" % table).fetchall()}
except Exception:
return {}
def ensure_task_attempts_table(conn):
"""Create the unified table if absent; migrate a legacy
table to it if present. Idempotent; does not touch existing
@@ -287,7 +308,12 @@ def ensure_task_attempts_table(conn):
is migrated in place by adding whichever unified columns it
lacks (``UNIFIED_COLUMN_TYPES``); added columns are always
nullable or defaulted, so existing rows are never blocked,
and no row is dropped or rewritten. This replaces the
and no row is dropped or rewritten. A gap-3 table's
``outcome`` was created NOT NULL; ensure also relaxes it to
nullable on Postgres (``ALTER COLUMN outcome DROP NOT
NULL``), because the resume writer inserts outcome NULL --
without the relax every resume write on such a table fails.
This replaces the
rehearsal-era runtime column-completion workaround: the
target here is the single canonical definition, and
``cloud.attempts`` / ``cloud.db`` share that same string
@@ -303,6 +329,20 @@ def ensure_task_attempts_table(conn):
% (col, UNIFIED_COLUMN_TYPES[col]))
except Exception:
pass
# gap-3 形态的 outcome 是 NOT NULL,而统一 DDL 要求它可空
# (恢复写路不报 outcome,NOT NULL 会使它在 PG 上每次写
# 行都报 NotNullViolation)。只在探针报 NOT NULL 时放宽:
# Postgres 上是 ALTER COLUMN DROP NOT NULL(已是可空时
# 不会走到这里);SQLite 上该语句不合法,try/except 吞
# 掉——SQLite 的 gap-3 表仍保留 NOT NULL(放宽需重建表,
# 与“迁移不做重建式类型收紧”的边界一致,本批只修 PG 路径)。
if _column_notnull(conn, "task_attempts").get("outcome"):
try:
conn.execute(
"ALTER TABLE task_attempts "
"ALTER COLUMN outcome DROP NOT NULL")
except Exception:
pass
def record_attempt(conn, task_id, outcome, is_defect=False,