Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
7.7 KiB
id, title, status, wave, depends_on, plan, spec
| id | title | status | wave | depends_on | plan | spec | |
|---|---|---|---|---|---|---|---|
| 04-observations | Candidate pairs and evidence queries | done | 3 |
|
plan.md | ../../specs/task-delivery-ledger/spec.md |
Task 04: Candidate pairs and evidence queries
Populate the Observation the evaluator consumes: discover candidate
(task, repository) pairs and gather each pair's evidence from the task tables
and all three provider tables. Every table involved lives in the same database,
so this is plain SQL, not a cross-service call.
Candidate pairs
The union of task_repositories, non-empty task_sessions.repository_id, and
the (task_id, repository_id) of github_task_prs, gitlab_task_mrs and
azure_devops_task_prs — restricted to repository_id <> '' and to repositories
with deleted_at IS NULL. A soft-deleted repository freezes evaluation for its
pairs: existing rows are retained and last_evaluated_at stops advancing.
A task with no repository yields no ledger row. Absence means "not
applicable" and is a different fact from unknown; do not synthesize a row with
an empty repository_id to make the join tidy.
Ordering is tasks.created_at ASC, tasks.id ASC, repositories.id ASC. It must be
total and deterministic so an interrupted sweep resumes on the same order and
re-evaluates only what it did not reach.
Per-pair evidence
Fill the Observation fields from the plan's table. Snapshots reach the pair
through task_session_git_snapshots.session_id -> task_sessions.id, matching on
task_sessions.task_id and task_sessions.repository_id.
Three facts from the reference-store measurement that change the SQL:
aheadis base-branch-relative, not upstream-relative (internal/agent/runtime/lifecycle/event_types.go:288-294). It counts commits the branch has that its base does not, and it does not fall to zero on push. It is a valid "this branch carries work" signal and an invalid "this session authored work" signal. Use it only forobserved_branch_commitsand rule 3.- An empty
head_commitis not a distinct head. Filterhead_commit <> ''in everyCOUNT(DISTINCT ...), or a pair with two empty snapshots reads as having authored a commit. - A session with an empty
repository_idcontributes to no pair. Count it towarddelivery_ledger_sessions_unattributed_total(task 06 owns the counter; expose the count on the result here). Three such sessions exist in the reference store.
Provider rows
-- GitHub: detachment applies here and only here.
SELECT pr_url, merged_at, pr_number, base_branch FROM github_task_prs
WHERE task_id = ? AND repository_id = ?
AND merged_at IS NOT NULL AND detached_at IS NULL
-- GitLab: no detached_at column exists; the predicate is vacuously true.
SELECT mr_url, merged_at, mr_iid, base_branch FROM gitlab_task_mrs
WHERE task_id = ? AND repository_id = ? AND merged_at IS NOT NULL
-- Azure: no merged_at column exists; status carries merge.
SELECT pull_request_url, NULL, pull_request_id, target_branch
FROM azure_devops_task_prs
WHERE task_id = ? AND repository_id = ? AND status = 'completed'
Do not add a detached_at column to the GitLab or Azure tables, and do not add
merged_at to Azure. Those are provider-table changes this card does not own.
An Azure status outside the known vocabulary leaves the pair at unknown with
basis branch_commits_unmerged rather than being guessed.
Wrap each provider query so db.IsMissingTableError (task 01) yields zero rows
instead of failing the whole evaluation — a database where a provider store never
initialized has no such table.
Deterministic reference selection
delivery_refforpr_merge: earliestmerged_at; NULLmerged_at(every Azure row) sorts last; ties broken by number ascending; further ties by provider name ascending in the orderazure_devops,github,gitlab.delivery_reffordirect_commit: among default-branch snapshots sharing the greatestcreated_at, the lexicographically greatesthead_commit.observed_branch_commits:MAX(ahead), no tiebreak needed.
Both orderings must be total. Anything that falls back to a random identifier or a dialect-specific row number will make the idempotency test flap.
-
Acceptance:
- Candidate discovery covers all three sources, excludes empty
repository_idand soft-deleted repositories, and produces no row for a task with no repository. - A merged GitLab MR is found; a detached GitHub PR is not; an absent provider table yields zero rows rather than an error.
- Every
delivery_reftiebreak in the spec resolves as specified and is stable across repeated evaluation.
- Candidate discovery covers all three sources, excludes empty
-
Verification:
cd apps/backend && go test ./internal/delivery/... && make lint -
Files likely touched:
apps/backend/internal/delivery/observe.goapps/backend/internal/delivery/observe_test.goapps/backend/internal/delivery/models.go(addObservationif task 03 has not already)
-
Dependencies: Task 02 (package and types); task 01 (
db.IsMissingTableError). -
Parallelism: parallel-safe with task 03 — different files in the same package. Coordinate only on the
Observationstruct. -
Inputs: Spec Candidate pairs, Ordering, idempotency, concurrency, Failure modes, and Evidence the classification already works findings 1 and 3; plan decisions D3, D4 and D5; table shapes at
internal/task/repository/sqlite/base_schema.go:762,internal/github/store.go:107,internal/gitlab/store.go:70,internal/azuredevops/store.go:37. -
Output contract: summary, files changed, tests run with counts, blockers, risks, and task/plan status update in the same conversation.
Results
Files changed: internal/delivery/observe.go, internal/delivery/observe_test.go,
internal/delivery/provider_seed_test.go (seed helpers reused by later
tasks' tests), internal/delivery/models.go (Observation, shared with
task 03).
Commands run:
cd apps/backend && go test -run 'TestCandidates|TestSnapshotsForPair|TestProvidersForPair|TestGitHubPRs|TestGitLabMRs|TestAzureDevOpsPRs' ./internal/delivery/...→ok, 9 subtests pass, 0 fail:TestCandidates_ExcludesMissingRepositoryAndTask,TestCandidates_ExcludesMissingTask,TestCandidates_TaskWithNoRepositoryProducesNoRow,TestSnapshotsForPair_JoinsThroughSessions,TestSnapshotsForPair_NullHeadCommitNormalizesToEmpty,TestProvidersForPair_MissingTablesTolerated,TestGitHubPRs_MergedAndDetachedFromColumns,TestGitLabMRs_MergedFromColumnAndScopeIsProjectPath,TestAzureDevOpsPRs_StatusCaseInsensitiveAndUnrecognisedIsNotMerged.cd apps/backend && go test ./internal/delivery/...→ok, 99 subtests pass, 0 fail (full-package run, confirms no interaction with tasks 02/03/05/06).make lint— clean.
Acceptance verification: #1 (candidate discovery, missing-repository
exclusion, no row for a task with no repository) is covered by the three
TestCandidates_* cases above. #2 (merged GitLab MR found, detached GitHub
PR not, absent provider table yields zero rows) is covered by
TestGitHubPRs_MergedAndDetachedFromColumns,
TestGitLabMRs_MergedFromColumnAndScopeIsProjectPath, and
TestProvidersForPair_MissingTablesTolerated. #3 (delivery_ref tiebreaks)
lives in evaluator.go's selectProviderRef, not observe.go, and is
covered by the TestClassify* table cases (task 03's verification run).
Security/trust and external side-effects: None — read-only queries
against existing provider tables; db.IsMissingTableError (task 01)
classifies a missing table as zero rows rather than surfacing a raw driver
error.
teardown evidence. Record security/trust and external side-effect boundaries when
applicable, or explicitly state None.