Skip to content

refactor: unify SQLite and PostgreSQL backend cores - #37

Merged
bayleafwalker merged 13 commits into
mainfrom
codex/p21-reconcile
Aug 13, 2026
Merged

refactor: unify SQLite and PostgreSQL backend cores#37
bayleafwalker merged 13 commits into
mainfrom
codex/p21-reconcile

Conversation

@bayleafwalker

Copy link
Copy Markdown
Owner

What changed

Reconcile P2.1 on current main by replaying the exact 12-commit backend-unification chain from c0ee7ed through ce09f54.

The extraction moves backend-neutral row, sprint, track, ref/dep, work-item, event, claim, and transaction-scaffold logic into dedicated core modules while retaining the existing SQLite/PostgreSQL adapters and public CLI behavior.

Why

This keeps the SQLite and PostgreSQL implementations aligned behind shared semantics and provides the foundation for the remaining backend/runtime work.

Scope

This PR is intentionally limited to P2.1. It does not include P4.2 test splitting or P3 CLI extraction work.

Validation

  • Full suite: 1496 passed, 175 skipped
  • Focused P2.1 contracts: 284 passed
  • CLI structure and served-route inventory: 16 passed
  • Disposable PostgreSQL integration: 126 passed
  • PostgreSQL cleanup evidence: complete; zero remaining rows
  • git diff --check and Python bytecode compilation passed

actionq-dispatcher and others added 13 commits August 13, 2026 19:05
…ows.py

First increment of the db/pg backend unification (sprintctl#2150,
agentops#2144 P2.1).

The SQLite (db.py) and PostgreSQL (pg.py) backends each carried their own
copy of claim/row serialization: _serialize_claim existed in both modules
with identical logic, and pg.py privately owned the row normalisers
(_iso/_norm) that produce the SQLite-compatible public value contract.

New module sprintctl/rows.py now owns the single serialization contract:
- iso_timestamp / normalize_row (was pg._iso / pg._norm)
- claim_identity_status / claim_event_identity / claim_attempt_identity
  (was db._claim_identity_status etc.)
- serialize_claim (was _serialize_claim, duplicated in both backends)

db.py and pg.py keep their historical import surfaces via aliases so
authority.py and external consumers are untouched. No behavior change:
full SQLite suite passes (1487 passed; two test_doctor env failures
pre-exist on base) and the disposable-PostgreSQL suite shows an identical
failure set to base (8 pre-existing env failures, 169 passed).

Refs: sprintctl#2150, agentops#2144
…sprintcore.py

Second increment of the db/pg backend unification (sprintctl#2150,
agentops#2144 P2.1). Sprint-table operations (create_sprint, get_sprint,
get_active_sprint, list_active_sprints, list_sprints, set_sprint_kind) each
carried their own SQLite and PostgreSQL query strings with duplicated
WHERE-clause and tenant-scoping logic.

New module sprintctl/sprintcore.py owns the query shapes once behind a
SprintConn protocol; db.py and pg.py now supply thin execution adapters
(_SprintSqlite, _SprintPg) and keep their historical public signatures
unchanged. Also resolves prior behavioral drift: get_active_sprint now
breaks created_at ties by id DESC on both backends (previously SQLite
lacked the tiebreak that pg had), and create_sprint coerces falsy
start_date/end_date to NULL on both backends.

Full SQLite suite: 1487 passed, 180 skipped, 2 failed (pre-existing
test_doctor env failures, same as base per prior increment's commit).
Disposable-PostgreSQL suite not re-run in this session (no local PG
instance available); adapter dispatch only changes call sites, not the
SQL shapes or pg.py's _norm/_iso normalization path.

Refs: sprintctl#2150, agentops#2144
…rackcore.py

Third increment of the db/pg backend unification (sprintctl#2150,
agentops#2144 P2.1). Track-table operations (get_or_create_track,
list_tracks, get_track) each carried duplicated SQLite/PostgreSQL query
strings, including a dialect-specific upsert (INSERT OR IGNORE vs
ON CONFLICT ... DO NOTHING).

New module sprintctl/trackcore.py owns the query shapes behind a TrackConn
protocol; db.py and pg.py supply thin execution adapters (_TrackSqlite,
_TrackPg), including an insert_ignore() method that lets each backend keep
its own upsert dialect while the calling logic stays shared.

Also fixes a latent bug from the previous increment: pg.py referenced
sprintctl.sprintcore as _sprintcore without importing it, so every call to
pg.create_sprint/get_sprint/get_active_sprint/list_active_sprints/
set_sprint_kind/list_sprints would have raised NameError at runtime. This
was invisible to the local suite because the PostgreSQL integration tests
are skipped without a disposable PG instance. Added the missing import
alongside the new trackcore import; verified with pyflakes (via `uv tool
run pyflakes`) that no unresolved names remain in db.py/pg.py.

Full SQLite suite: 1487 passed, 180 skipped, 2 failed (same pre-existing
test_doctor env failures as base, no regressions).

Refs: sprintctl#2150, agentops#2144
Fourth increment of the db/pg backend unification (sprintctl#2150,
agentops#2144 P2.1). Adds refcore.py and depcore.py alongside the existing
sprintcore.py/trackcore.py, following the same protocol-adapter pattern.

refcore.py owns ref-table CRUD (add/list/list-for-items/remove). Validation
that calls other backend-specific functions (ref_type membership check,
URL normalization, get_work_item existence check) stays in each backend's
wrapper; the core module only owns the raw storage shape. The SQLite
IN-clause pattern replaces pg.py's previous ANY(%s) array syntax for
list_refs_for_items — both are standard SQL and functionally equivalent.

depcore.py owns dep-table CRUD, including the two blocker/blocked-by
listing queries that JOIN work_item. Introduces a join_tenant_clause()
adapter method so PostgreSQL's extra `d.repo_id = wi.repo_id` join
condition (needed because dep and work_item are both tenant-scoped tables)
stays out of the shared query builder; SQLite's adapter returns "" since
it has no repo_id column.

Full SQLite suite: 1487 passed, 180 skipped, 2 failed (same pre-existing
test_doctor env failures as base, no regressions). Verified via pyflakes
(uv tool run pyflakes) that db.py/pg.py have no unresolved names.

Refs: sprintctl#2150, agentops#2144
…itemcore.py

Fifth increment of the db/pg backend unification (sprintctl#2150,
agentops#2144 P2.1), following the sequencing from the architectural plan
for the remaining WorkItem/Event/Claim sections (the first three were
CRUD-shaped like Sprint/Track/Ref/Dep; the transactional CAS operations
update_work_item_description and set_work_item_status are intentionally
left in place — their locking is backend-specific by design, not just
duplicated SQL).

New module sprintctl/workitemcore.py owns:
- create_work_item, get_work_item, list_work_items, set_work_item_priority
  query shapes, behind a WorkItemConn protocol.
- The pure validators (validate_work_item_description, validate_priority,
  effective_priority, PRIORITY_MIN/MAX) that used to live in db.py and be
  imported into pg.py via `from .db import (...)`. That made pg.py depend
  on db.py for logic with zero SQLite dependency — backwards for the
  target architecture where the two backends are peers. Both now import
  these from workitemcore.py instead.

Two new protocol members beyond what Sprint/Track/Ref/Dep needed:
- update_one(sql, params) -> bool: runs an UPDATE, commits and returns
  True if exactly one row matched, else rolls back and returns False.
  Unifies sqlite's cur.rowcount check with pg's RETURNING-id-or-None check.
- join_tenant_clause reused from depcore's pattern: list_work_items' JOIN
  against track needs `wi.repo_id = t.repo_id` on pg (absent on sqlite).

Full SQLite suite: 1487 passed, 180 skipped, 2 failed (same pre-existing
test_doctor env failures as base, no regressions). Verified via pyflakes
that db.py/pg.py have no unresolved names; the workitemcore re-export
"unused import" warnings are expected — validate_priority/effective_priority
are consumed as db.validate_priority/pg.effective_priority module
attributes by cli.py, application.py, context_candidates.py, and
tests/test_priority.py, not by bare-name reference within db.py/pg.py.

Refs: sprintctl#2150, agentops#2144
…ventcore.py

Sixth increment of the db/pg backend unification (sprintctl#2150,
agentops#2144 P2.1), per the architectural plan's step 2 (Event read/write
paths). Transactional operations (create_event, create_archive_import_event,
close_sprint_with_boundary_event, _require_receipt_close_boundary) stay in
each backend's wrapper: they call other backend-specific functions and
control their own commit/rollback boundary.

New module sprintctl/eventcore.py owns:
- _insert_event's raw storage shape (insert_event_row), list_events,
  list_events_limited, list_knowledge_candidates, and the takeup event
  query, behind an EventConn protocol (ph, tenant_params, query_all,
  insert_uncommitted — events are always inserted inside a larger
  caller-controlled transaction, so this doesn't auto-commit like the
  other cores' insert_id).
- The pure takeup/payload helpers that used to live in db.py and be
  imported into pg.py (process_takeup_events, _decode_event_payload,
  _takeup_key, _takeup_actor_key, _serialize_takeup_row,
  _serialize_released_takeup, KNOWLEDGE_EVENT_TYPES, TAKEUP_EVENT_TYPES).
  Same peer-dependency fix as workitemcore.py's validators: both backends
  now import from eventcore instead of pg.py depending on db.py.

Converges the three previously-inconsistent payload-decode call sites the
architectural plan flagged as a live drift risk: db.list_knowledge_candidates
inlined its own json.loads, and pg.py had two more variants
(list_knowledge_candidates's inline decode and _takeup_events_pg's inline
decode). All three now route through the single _decode_event_payload
helper. pg's list_events/list_events_limited keep the "re-stringify jsonb
dict to match sqlite's text convention" behavior via a new pure
_stringify_payload helper (no protocol method needed — it just inspects
the already-fetched row's payload type).

Fixed one now-obsolete test assertion: test_takeup_pg_contract.py asserted
the literal params passed to cursor.execute() were a `list`
(`["test-repo", ...]`); the shared query builder passes a `tuple` instead
(matching every other adapter in sprintcore/trackcore/refcore/depcore/
workitemcore), which psycopg accepts identically. Same content, same
order, same "queries the connection directly, not via sqlite helpers"
guarantee the test's docstring describes — updated the assertion to
compare as a tuple rather than changing behavior to match the old test.

Full SQLite suite: 1487 passed, 180 skipped, 2 failed (same pre-existing
test_doctor env failures as base, no regressions). Verified via pyflakes
that db.py/pg.py have no unresolved names.

Refs: sprintctl#2150, agentops#2144
…aimcore.py

Seventh increment of the db/pg backend unification (sprintctl#2150,
agentops#2144 P2.1) — sub-increment 4a of the Claim extraction per the
architectural plan. Pure helpers and read-only queries only; create_claim,
heartbeat_claim, release_claim, and handoff_claim stay in each backend's
wrapper for now (backend-specific locking: SQLite BEGIN IMMEDIATE vs
PostgreSQL advisory + row locks, plus a collision-retry loop) — later
sub-increments (4b/4c/4d) will address those individually per the plan's
risk-based sequencing.

New module sprintctl/claimcore.py owns:
- get_claim, list_claims_by_sprint, list_claims, find_claim_by_identity,
  get_active_exclusive_claim_row, get_active_coordinate_claim_row, behind
  a ClaimConn protocol.
- CLAIM_TYPES, ClaimConflict, _generate_claim_token — previously defined
  once in db.py and duplicated verbatim in pg.py (pg.py had its own
  _generate_claim_token identical to db.py's). Both backends now import
  the same objects from claimcore.

Two new protocol members, both preserving an existing intentional
behavioral distinction rather than flattening it:
- now_sql(): sqlite's strftime(...,'now') vs pg's statement_timestamp() —
  pg deliberately does NOT use now(), which would pin to transaction start
  on a long-lived served-mode connection and make lease-expiry comparisons
  wrong. Documented in the protocol docstring so future edits don't
  "simplify" this away.
- expires_at_offset_sql(): the "now + N seconds" interval expression,
  parameterized identically to the rest of the query builder.
- true_literal ("1" vs "true") for the exclusive-boolean filter, and
  join_tenant_clause reused from the depcore/workitemcore pattern for
  list_claims_by_sprint's JOIN against work_item.

Full SQLite suite: 1487 passed, 180 skipped, 2 failed (same pre-existing
test_doctor env failures as base, no regressions). Verified via pyflakes
that db.py/pg.py have no unresolved names; removed now-genuinely-unused
`secrets` imports from both modules (their only use was
_generate_claim_token, now in claimcore.py).

Refs: sprintctl#2150, agentops#2144
…laimcore.py

Eighth increment of the db/pg backend unification (sprintctl#2150,
agentops#2144 P2.1) — sub-increment 4b of the Claim extraction per the
architectural plan. heartbeat_claim and release_claim have no admission
arbitration (single-row update/delete), so they're lower risk than
create_claim/handoff_claim, which stay backend-specific for now
(locking + retry loop).

New in sprintctl/claimcore.py:
- heartbeat_update / release_delete: the raw mutation shapes, using the
  now_sql()/expires_at_offset_sql() protocol members already established
  in 4a. Added a mutate() protocol member (matching sprintcore/depcore's
  pattern) since these are the first Claim operations that write.
- heartbeat_rejection_event / release_rejection_event: pure (event_type,
  payload) builders for the claim-proof-rejected path. These were already
  100% backend-agnostic Python (no conn dependency) but copy-pasted
  between db.py and pg.py.

Bug fix surfaced by the extraction: pg.py's release_claim always tagged
its rejection event ["claims", "coordination", "release"], while db.py
additionally used ["claims", "coordination", "ambiguity", "legacy"] for
legacy claims with no claim_token — a real behavioral drift, not a
stylistic difference. release_rejection_event converges both backends on
db.py's more specific tagging. No test exercised this on the pg path
locally (pg claim tests require SPRINTCTL_TEST_PG_URL, unavailable in
this environment), so this went uncaught until now; flagging here in case
anything downstream filters coordination-failure events by tag and
expects pg's narrower (buggy) set.

Full SQLite suite: 1487 passed, 180 skipped, 2 failed (same pre-existing
test_doctor env failures as base, no regressions). Verified via pyflakes
that db.py/pg.py have no unresolved names.

Refs: sprintctl#2150, agentops#2144
Ninth increment of the db/pg backend unification (sprintctl#2150,
agentops#2144 P2.1) — sub-increment 4c of the Claim extraction per the
architectural plan. handoff_claim has more branches than heartbeat/release
(legacy-ambiguous, lost-proof-adopted, rotate-vs-transfer) but no admission
race, matching the plan's "medium-high risk, same payload-builder pattern
applied to a bigger function" assessment.

New in sprintctl/claimcore.py:
- handoff_update: the raw UPDATE shape (agent, claim_token, lease_epoch,
  expiry, identity fields, heartbeat), reusing now_sql()/
  expires_at_offset_sql() from 4a/4b.
- handoff_legacy_ambiguous_event / handoff_rejection_event /
  handoff_success_event: pure payload builders for the three event paths.

Two real drifts surfaced and fixed here (documented in detail in the
module docstring):

1. lease_epoch fencing gap: pg.py's handoff UPDATE bumped lease_epoch on
   rotation/legacy-adoption; db.py's never did. lease_epoch is consumed by
   terminal_recovery_server.py and authority.py as a fencing token to
   reject stale in-flight operations after a claim changes hands — the
   SQLite gap meant a session holding a pre-handoff lease_epoch could
   still pass an expected_lease_epoch check post-handoff on that backend.
   No existing test exercised this on SQLite. Added three regression
   assertions in test_claims.py: rotate and legacy-adopt now assert
   lease_epoch 1->2, and a new test confirms transfer-mode-without-
   token-rotation correctly does NOT bump it (the fix shouldn't
   overcorrect to bumping on every handoff call).

2. Rejection-event audit gap: pg.py's attempted_by payloads (both the
   legacy-ambiguity and coordination-failure branches) carried only
   actor/claim_id/claim_token_present, dropping runtime_session_id,
   instance_id, branch, worktree_path, commit_sha, pr_ref, hostname, pid
   that db.py included. Converged on db.py's richer identity.

create_claim stays backend-specific (sub-increment 4d, deferred): it has
the collision-retry loop and pg's two-tier advisory+row locking, the
highest-risk piece per the plan, and genuinely needs a live PostgreSQL
instance to validate concurrency behavior — unavailable in this
environment.

Full SQLite suite: 1488 passed (1487 baseline + 1 new test), 180 skipped,
2 failed (same pre-existing test_doctor env failures as base, no
regressions). Verified via pyflakes that db.py/pg.py have no unresolved
names.

Refs: sprintctl#2150, agentops#2144
…e_work_item_description

Tenth increment of the db/pg backend unification (sprintctl#2150,
agentops#2144 P2.1). Per a planning pass's execution-ready design, this
prototypes the multi-statement-transaction abstraction the plan's earlier
architectural review said create_claim needs, on the smaller-blast-radius
WorkItem CAS surface first.

New WorkItemConn protocol members (workitemcore.py): begin_txn/commit/
rollback for the transaction envelope, for_update_of/lock_for_update for
row locking, execute for non-committing statements inside an open
transaction, and insert_event to delegate to each backend's _insert_event.
SQLite's begin_txn issues BEGIN IMMEDIATE (whole-DB write lock, taken
before any read); PostgreSQL's is a no-op since the connection is already
in an explicit transaction and the lock comes from lock_for_update's
SELECT ... FOR UPDATE instead. This is a real, documented concurrency-model
difference the abstraction does not paper over: two concurrent SQLite CAS
writers on *different* work items still serialize against each other
(unchanged from before this extraction), while PostgreSQL's row lock lets
them proceed in parallel — what both backends must guarantee identically
is that two writers racing the *same* item produce exactly one accepted
write and one EditConflict.

Moved EditConflict/StatusConflict and the description-revision helpers
(_description_revision, validate_item_edit_revision) from db.py into
workitemcore.py, matching the peer-dependency fix already applied to the
priority validators: pg.py now imports these from workitemcore directly
instead of via db.py, and db.py re-exports them for existing callers
(db.EditConflict etc. are used pervasively in application.py/cli.py/tests).

Extracted get_work_item_with_edit_revision (kept as a plain, unlocked read
— application.py/cli.py call it standalone to preview a revision before
constructing an edit request) and update_work_item_description (using a
new private _lock_work_item_with_edit_revision helper internally, since
its correlated edit_version subquery doesn't fit the generic
lock_for_update(table, id) shape cleanly).

Added a genuine coverage gap the planning pass identified: SQLite had no
concurrency test proving begin_txn's BEGIN IMMEDIATE actually serializes
two real concurrent writers on the same item, only a sequential two-
connection CAS-conflict test (test_two_readers_cannot_apply_the_same_edit_revision).
PostgreSQL already had this via
test_pg_integration.py::test_overlapping_edit_writers_accept_exactly_one_revision
(threading.Barrier across two independent connections). Added the mirror
test on the SQLite side in test_core.py; verified non-flaky across 5 runs.

Full SQLite suite: 1491 passed (1490 baseline + 1 new test), 180 skipped,
0 failed — the 2 previously-documented "pre-existing" test_doctor failures
turned out to be an environment artifact (missing psycopg/remote extra in
this venv, installed earlier this session to run the PG suite), not a
real baseline; they're gone now, unrelated to this change. Full
disposable-PostgreSQL suite: 169 passed, same 8 pre-existing failures (all
in terminal_recovery_pg.py/authority.py, untouched by this work), zero
regressions. Verified via pyflakes that db.py/pg.py/workitemcore.py have
no unresolved names.

Refs: sprintctl#2150, agentops#2144
Eleventh increment of the db/pg backend unification (sprintctl#2150,
agentops#2144 P2.1). Validates the begin_txn/lock_for_update/commit/
rollback/execute primitives prototyped on update_work_item_description
against a second, structurally similar caller, per the planning pass's
explicit recommendation to do this immediately in the same session.

set_work_item_status's own business logic (VALID_TRANSITIONS check,
active-exclusive-claim lookup and proof verification, blocker-dependency
check) stays in each backend's wrapper as before — it calls other
backend-specific functions (_get_active_exclusive_claim_row,
_emit_claim_event, _require_claim_proof, list_deps_blocking) spanning
claim/dep/event concerns, not just work_item, so it doesn't fit a single
shared workitemcore function the way update_work_item_description did.
What moved is the transaction/locking boilerplate around that logic: the
initial BEGIN IMMEDIATE + item read on SQLite and SELECT ... FOR UPDATE on
PostgreSQL are now both `_WorkItemSqlite(conn).lock_for_update("work_item",
item_id)` / `_WorkItemPg(store).lock_for_update(...)` — the generic
primitive (no correlated-subquery case here, unlike the edit-revision
read), confirming it generalizes to a second caller as intended. The final
UPDATE and commit/rollback likewise route through the same adapter
instance instead of duplicated raw SQL per backend.

Also moved item_status_revision/validate_item_status_revision from db.py
into workitemcore.py (same peer-dependency fix as the other work-item
pure helpers); sprint_status_revision/validate_sprint_status_revision stay
in db.py since they belong to sprint-transition logic (set_sprint_status,
close_sprint_with_boundary_event), not yet touched.

Full SQLite suite: 1491 passed, 180 skipped, 0 failed. Full disposable-
PostgreSQL suite: 169 passed, same 8 pre-existing failures (terminal_
recovery_pg.py/authority.py, untouched), zero regressions. Verified via
pyflakes that db.py/pg.py/workitemcore.py have no unresolved names.

Noted but not added: neither backend has a concurrency test proving two
overlapping set_work_item_status transitions on the same item resolve to
exactly one accepted writer (unlike update_work_item_description, which
had this on PostgreSQL already). This is a pre-existing gap on both
backends, not an asymmetry this extraction introduced or one backend
already covered — left as a follow-up rather than expanding this
increment's scope.

Refs: sprintctl#2150, agentops#2144
…e scaffold

Completes the P2.1 Claim extraction (4d+4e): ClaimConn grows the same
begin_txn/commit/rollback/lock_for_update-style scaffold introduced for
work-item CAS ops, plus claim-specific members (lock_capability_arbitration,
is_claim_token_collision, maintenance_capability_active_sql,
emit_claim_event). create_claim and handoff_claim move into claimcore.py as
single implementations; db.py/pg.py keep thin per-backend wrappers. Lock
order is preserved exactly per backend -- PostgreSQL's work-item row lock is
now taken unconditionally on exclusive creates (previously only reached via
the redundant _lock_claim_arbitration_row helper), and SQLite gains the same
existence-check read as a harmless byte-for-byte-equivalent addition.

require_claim_proof and MAX_CLAIM_TOKEN_INSERT_RETRIES move to claimcore.py
(same asymmetry fix as EditConflict/StatusConflict), with db.py re-exporting
both for cli.py and pg.py's existing import sites.

Verified against both backends: 1660 passed / 8 pre-existing unrelated
failures (terminal-recovery ledger tests, a str/datetime PG driver mismatch)
against a disposable sprintctl_test_agent/sprintctl_test_run PG instance.
Two tests updated to track relocated internals: test_failure_modes.py's
token-collision tests now patch claimcore._generate_claim_token (where it's
actually called from), and test_pg_integration.py's advisory-lock race test
patches _ClaimPg.lock_capability_arbitration instead of the removed
_lock_repo_claim_capability_arbitration free function.
@bayleafwalker
bayleafwalker marked this pull request as ready for review August 13, 2026 16:16
@bayleafwalker
bayleafwalker merged commit c7de104 into main Aug 13, 2026
4 checks passed
@bayleafwalker
bayleafwalker deleted the codex/p21-reconcile branch August 13, 2026 16:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant