All checks were successful
deploy / deploy (push) Successful in 5m35s
Closes #2. Three tables mirroring tireless-entities, plus the sqlx compile-time-checking decision the rest of stage 1 inherits. RUNTIME QUERIES, NOT `query!`. No .sqlx metadata, no DATABASE_URL to build. lairball, the other house project on this cluster, does the same. The deciding argument is specific to tireless: compile-time checking makes a database a build dependency, and this crate is meant to be modified by a 27B model working unattended. A build that fails without a database it cannot provision is one that model cannot fix, and its documented failure mode is to improvise. The cost is named in store.rs — a malformed query is caught by a test, not by cargo. Enums are text with a check constraint, not Postgres enum types: adding a JobKind variant should be a migration, not an ALTER TYPE holding a lock. Unit tests assert every serde variant appears in the schema, so adding a variant without a migration fails the build rather than the first job of that kind. That surfaced a spelling that would have been permanent: `rename_all = "snake_case"` turns Forge::GitHub into `git_hub`, across the database, the JSON API and the generated TypeScript. Renamed to `github` now, while nothing is persisted and GitHub support is still disabled. The live-issue index is PARTIAL, and both directions of getting it wrong are silent. A plain unique constraint on (forge, owner, repo, number) would forbid re-running a terminal job, and would forbid the discovery lane outright, since discovery recurs against one tracking issue on a cooldown. Partial on the non-terminal states gives at most one live job per issue and unlimited history. The claim index orders by created_at alone and leaves kind as a filter, because the claim takes LIMIT 1 and can stop at the first match. Leading with kind sorts every pending row on every claim: measured at 720 buffers versus 4, and the gap grows with the backlog rather than staying fixed. INCLUDE (kind) was measured too and dropped — FOR UPDATE visits the heap regardless. Verified against Postgres 18, the same major as the house cluster: migrations apply to an empty database and are a no-op on the second run; all eight constraints reject what they should and admit what they should; and two concurrent claimers of one pending job produce exactly one winner, with the loser skipping rather than blocking. Those live tests are #[ignore]d, not skipped on a missing variable, so a green `cargo test` never implies the schema was exercised. CLAUDE.md says how to run them and that an applied migration must never be edited. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
188 lines
7.6 KiB
SQL
188 lines
7.6 KiB
SQL
-- tireless initial schema.
|
|
--
|
|
-- Three tables mirroring `tireless-entities`: the repos we watch, the work
|
|
-- claimed against their issues, and each agent invocation. Enums are stored as
|
|
-- text with a check constraint rather than as Postgres enum types: adding a
|
|
-- `JobKind` variant should be an ordinary migration, not an ALTER TYPE that
|
|
-- takes a lock on every table using it. The accepted values must match what
|
|
-- serde emits for each enum, and a test in `tireless-data` asserts they do.
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Repos tireless polls.
|
|
-- ---------------------------------------------------------------------------
|
|
create table tracked_repo (
|
|
id uuid primary key default gen_random_uuid(),
|
|
|
|
forge text not null check (forge in ('gitea', 'github')),
|
|
owner text not null,
|
|
repo text not null,
|
|
clone_url text not null,
|
|
default_branch text not null,
|
|
|
|
-- PollSchedule, flattened. The floor lives in config, not here: it is an
|
|
-- operator policy that can change, and clamping is core's job
|
|
-- (`Poll::clamp`). The database asserts only that the value is sane.
|
|
interval_seconds integer not null check (interval_seconds > 0),
|
|
quiet_from text,
|
|
quiet_until text,
|
|
enabled boolean not null default true,
|
|
|
|
last_etag text,
|
|
last_polled_at timestamptz,
|
|
created_at timestamptz not null default now(),
|
|
|
|
unique (forge, owner, repo),
|
|
|
|
-- Both ends of the quiet window or neither. A half-specified window runs
|
|
-- around the clock while the operator believes otherwise, which is silently
|
|
-- wrong rather than loudly broken. `Config::validate` enforces the same rule
|
|
-- on the global window.
|
|
constraint quiet_window_complete
|
|
check ((quiet_from is null) = (quiet_until is null))
|
|
);
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- One unit of work against one issue.
|
|
-- ---------------------------------------------------------------------------
|
|
create table job (
|
|
id uuid primary key default gen_random_uuid(),
|
|
|
|
-- IssueRef, flattened.
|
|
forge text not null check (forge in ('gitea', 'github')),
|
|
owner text not null,
|
|
repo text not null,
|
|
number bigint not null,
|
|
|
|
kind text not null
|
|
check (kind in ('discover', 'plan', 'implement')),
|
|
state text not null
|
|
check (state in ('pending', 'claimed', 'running',
|
|
'delivered', 'blocked', 'failed', 'abandoned')),
|
|
|
|
claimed_by text,
|
|
claim_expires_at timestamptz,
|
|
|
|
-- Set when this job's issue was produced by a Plan job. Presence of a parent
|
|
-- is what routes implementation to OpenCode, and what lets a child inherit
|
|
-- admission (design.md §2.5).
|
|
parent_job_id uuid references job (id),
|
|
|
|
attempts integer not null default 0 check (attempts >= 0),
|
|
last_error text,
|
|
|
|
-- When the poller last reconciled this job's forge labels. Labels mirror
|
|
-- this row; they are never read to make a decision (CLAUDE.md invariant 6).
|
|
-- The runner cannot write labels at all — a separate identity does it
|
|
-- (design.md §6.4) — so this column is how the two halves stay in step.
|
|
labels_synced_at timestamptz,
|
|
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now(),
|
|
|
|
-- A claim is a pair. Half a claim is a job that either cannot be released or
|
|
-- cannot be found.
|
|
constraint claim_complete
|
|
check ((claimed_by is null) = (claim_expires_at is null)),
|
|
|
|
-- A pending job holds no claim. This is what makes lease expiry correct:
|
|
-- returning a job to the pool *must* clear its claim, and the database
|
|
-- refuses the half-done version.
|
|
--
|
|
-- Deliberately not the converse. Terminal jobs keep `claimed_by` as an audit
|
|
-- trail of which worker delivered them.
|
|
constraint pending_holds_no_claim
|
|
check (state <> 'pending' or claimed_by is null)
|
|
);
|
|
|
|
-- At most one *live* job per issue, and any number of historical ones.
|
|
--
|
|
-- A plain unique constraint would be wrong in both directions. It would forbid
|
|
-- re-running a job after it reached a terminal state, which `tireless job run`
|
|
-- exists to do; and it would forbid the discovery lane entirely, since discovery
|
|
-- recurs against the same tracking issue on a cooldown (design.md §2.6).
|
|
create unique index job_live_issue_idx
|
|
on job (forge, owner, repo, number)
|
|
where state in ('pending', 'claimed', 'running');
|
|
|
|
-- The claim query: oldest pending job of an allowed kind.
|
|
--
|
|
-- Ordered by `created_at` alone, with `kind` left as a filter, because the claim
|
|
-- takes `LIMIT 1` and can then stop at the first match. Leading with `kind`
|
|
-- instead forces a sort of every pending row on every claim — measured at 720
|
|
-- buffers versus 4, and the gap grows with the backlog rather than staying
|
|
-- fixed. Partial on `pending` because that is a small slice of a table that only
|
|
-- grows.
|
|
--
|
|
-- No INCLUDE (kind): `FOR UPDATE` has to visit the heap regardless, so the extra
|
|
-- index payload measured as no gain.
|
|
create index job_claimable_idx
|
|
on job (created_at)
|
|
where state = 'pending';
|
|
|
|
-- Lease expiry sweeps held claims looking for passed deadlines.
|
|
create index job_claim_expiry_idx
|
|
on job (claim_expires_at)
|
|
where state in ('claimed', 'running');
|
|
|
|
-- History for one issue: powers the discovery cooldown ("when did a discover job
|
|
-- for this issue last finish?") and the operator asking what has been tried.
|
|
create index job_issue_history_idx
|
|
on job (forge, owner, repo, number, kind, created_at desc);
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- One invocation of one agent against one job.
|
|
--
|
|
-- A job may have several: a retry after a rate limit, or a follow-up turn
|
|
-- resuming the same agent session.
|
|
-- ---------------------------------------------------------------------------
|
|
create table agent_run (
|
|
id uuid primary key default gen_random_uuid(),
|
|
job_id uuid not null references job (id) on delete cascade,
|
|
|
|
agent text not null
|
|
check (agent in ('claude_code', 'opencode')),
|
|
model text,
|
|
|
|
-- Read from Claude Code's own `apiKeySource`, so the dashboard reports what
|
|
-- actually happened rather than what was intended (design.md §3.2).
|
|
billing text not null default 'unknown'
|
|
check (billing in ('subscription', 'api_key', 'unknown')),
|
|
|
|
session_id text,
|
|
|
|
outcome text
|
|
check (outcome in ('succeeded', 'failed', 'rate_limited',
|
|
'budget_exhausted', 'output_budget_exhausted',
|
|
'timed_out', 'cancelled')),
|
|
|
|
-- PullRequestRef, flattened. All six or none.
|
|
pr_forge text check (pr_forge in ('gitea', 'github')),
|
|
pr_owner text,
|
|
pr_repo text,
|
|
pr_number bigint,
|
|
pr_head_branch text,
|
|
pr_url text,
|
|
|
|
started_at timestamptz not null default now(),
|
|
finished_at timestamptz,
|
|
|
|
constraint pull_request_complete
|
|
check (num_nonnulls(pr_forge, pr_owner, pr_repo,
|
|
pr_number, pr_head_branch, pr_url) in (0, 6)),
|
|
|
|
-- A finished run has an outcome and an unfinished one does not. Without
|
|
-- this, a crashed writer leaves runs that look in-flight forever and the
|
|
-- governor counts them against concurrency.
|
|
constraint finished_has_outcome
|
|
check ((finished_at is null) = (outcome is null))
|
|
);
|
|
|
|
create index agent_run_job_idx
|
|
on agent_run (job_id, started_at desc);
|
|
|
|
-- The governor's window budget: runs started per lane inside a rolling window
|
|
-- (design.md §5). This is asked on every admission decision, so it is the one
|
|
-- index that has to be right from the start.
|
|
create index agent_run_lane_window_idx
|
|
on agent_run (agent, started_at desc);
|