Files
tireless/crates/tireless-data/migrations/0002_job_lane.sql
rob thijssen 98f193d16b
All checks were successful
deploy / deploy (push) Successful in 6m1s
feat(data): implement JobStore and the Gitea read client
Closes #3 and #4.

Both specs had gaps that only appeared on contact, which is worth recording
because it is evidence for the plan-quality question in #10:

- `enqueue(&[DiscoveredIssue])` cannot know a JobKind. It is derivable from
  labels, so the store now carries the LabelProtocol and core gained
  `job_kind_for`. The precedence when an operator applies several mode labels
  had to be decided: plan beats implement, because planning produces the
  implementation children and so loses nothing, while the reverse silently
  discards the decomposition that was also asked for.
- `claim_next(worker, allowed_lanes)` had no lane to filter on. Migration 0002
  adds one, recorded at enqueue by `routing::lane_for` — the same function
  `route` now delegates to, so the two cannot drift. Deriving it at claim time
  instead would mean a forge request per claim, since labels are not stored.
- `list_opted_in_issues` returned a bare Vec with nowhere to put the ETag that
  #4's own step 3 requires, so a caller could not make the next poll
  conditional. It returns an `IssuePage` now, which also carries `not_modified`
  — a 304 is not an empty repo, and a poller that conflated them would treat
  every quiet poll as every issue having disappeared.

The claim is one statement: a CTE takes the row lock with SKIP LOCKED and the
update writes the claim, so select and update share a transaction without
managing one by hand. Returning a job to pending clears the claim, because
`pending_holds_no_claim` refuses the half-done version — the database is what
makes lease expiry safe rather than the code remembering to.

Enum values round-trip through serde rather than a hand-written match, so the
schema's check constraints and the Rust types are provably one vocabulary.

Also replaced a test from #2 that asserted exactly one migration exists. It
failed on the first legitimate migration, which teaches people to edit the
assertion rather than think. It now asserts what it was reaching for: versions
unique and ascending.

Verified against Postgres 18 — 15 database tests covering enqueue idempotency
across repeated polls, lane filtering including the agent:oc override, claim
metadata, lease expiry and reclaim, terminal jobs refusing transition, renewal
requiring you still hold the claim, and re-routing a queued job when its labels
change. Plus 12 mock-forge tests: If-None-Match sent and 304 distinguished from
empty, ETag surfaced, 429 retried but bounded, 503 retried then succeeding, 404
not retried, pull requests filtered out, and every unimplemented write failing
without touching the forge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
2026-08-07 20:20:09 +03:00

38 lines
1.9 KiB
SQL

-- The lane a job will run on, recorded when it is enqueued.
--
-- `JobStore::claim_next` takes the lanes its caller is allowed to start, because
-- the governor caps them separately (design.md §5). Without this column that
-- filter cannot be expressed in SQL, and a runner would have to claim a job
-- before discovering it belongs to a lane that is currently held — taking the
-- row out of reach of the runner that could have run it.
--
-- Denormalised on purpose. The lane is derived from the job kind, whether it has
-- a parent, and any `tireless/agent:*` override on the issue — and the labels
-- are not stored, so deriving it at claim time would mean a forge request per
-- claim. `tireless_core::routing::lane_for` computes it once, at enqueue, and
-- the poller refreshes it when labels change. The value is therefore a cache of
-- an operator's expressed intent, which is exactly the accuracy the label
-- protocol already promises (§2.2).
--
-- Nullable, with no default: a job enqueued before this migration has no
-- recorded lane, and guessing one would be worse than leaving it unclaimable
-- until the next poll refreshes it. There are no such jobs today — nothing has
-- ever been enqueued — but the reasoning is what matters for the next migration
-- that adds a column the claim path depends on.
alter table job
add column lane text
check (lane in ('claude_code', 'opencode'));
-- Supersedes job_claimable_idx from 0001. The claim now filters on lane as well
-- as state, and still orders by created_at alone so `LIMIT 1` can stop at the
-- first match rather than sorting the pending set — the property measured in
-- #2 at 4 buffers versus 720.
--
-- Lane leads because it is an equality filter with two values, so it partitions
-- the index cleanly; created_at follows to give the ordering for free.
drop index job_claimable_idx;
create index job_claimable_idx
on job (lane, created_at)
where state = 'pending';