Skip to content

miner(freshness): checkSubmissionFreshness matches a claim row from the wrong forge host #10004

Description

@JSONbored

⚠️ Definition of Done: this issue must be completed in full, in a single PR. Do not split this
work across multiple PRs, and do not defer any Deliverable below to a follow-up issue. A PR that
satisfies only some of the Deliverables, stubs a required test, or leaves a checkbox
partially-done does NOT resolve this issue and will be closed.

Context

checkSubmissionFreshness is the last gate before a fully-completed coding attempt opens a PR. Its first check is
the miner's own claim status, at packages/loopover-miner/lib/submission-freshness-check.ts:110-113:

  const claim = claimLedger.listClaims({ repoFullName }).find((c) => c.issueNumber === candidate.issueNumber);
  if (!claim || claim.status !== "active") {
    return abort(eventLedger, repoFullName, candidate.issueNumber, "claim_superseded");
  }

listClaims({ repoFullName }) is forge-blind. Its backing statement carries no api_base_url predicate —
packages/loopover-miner/lib/claim-ledger.ts:248-250:

  const listRepoStatement = db.prepare(
    "SELECT * FROM miner_claims WHERE repo_full_name = ? ORDER BY id ASC",
  );

Since #5563 the claim ledger's uniqueness key is (api_base_url, repo_full_name, issue_number)
(packages/loopover-miner/lib/claim-ledger.ts:162-171), so the same owner/repo#N legitimately has one row per
forge host
. An operator creates those rows through the CLI's own flag —
packages/loopover-miner/lib/claim-ledger-cli.ts:7:

  "Usage: loopover-miner claim claim <owner/repo> <issue#> [--note <text>] [--api-base-url <url>] [--dry-run] [--json]";

.find(...) therefore returns whichever host's row has the lowest id, not this attempt's row. The attempt's own
claim is always recorded against the github.com default — attempt-cli.ts:796-802 calls
claimIssueWithinCap(repoFullName, issueNumber, note, undefined, cap), and normalizeApiBaseUrl(undefined)
resolves to DEFAULT_FORGE_CONFIG.apiBaseUrl (packages/loopover-miner/lib/claim-ledger.ts:129-133).

Concrete failure: an operator ran loopover-miner claim claim acme/widgets 42 --api-base-url https://forge.internal
and later claim release, leaving a released row with id = 1. A subsequent github.com attempt acme/widgets 42
records its own active row with id = 2. At submission time .find() returns the id = 1 row, sees
status !== "active", and aborts with claim_superseded — discarding a completed create/iterate/self-review run
and its worktree work, for a claim on an unrelated host. The module's own header calls this out as the expensive
case: "Aborting here discards a fully-completed create/iterate loop's local work"
(packages/loopover-miner/lib/submission-freshness-check.ts:21-22).

The mirror-image failure is a false pass: if the other host's row happens to be active and this attempt's own
claim was released or expired, the check passes on a claim the attempt does not hold.

Every sibling that touches these rows already scopes by host, with explicit comments naming this exact hazard.
sweepExpiredClaims (packages/loopover-miner/lib/claim-ledger-expiry.ts:47-51):

  for (const claim of expired) {
    // Echo the row's OWN apiBaseUrl back (#5563) rather than defaulting: two forge hosts can each have an
    // active claim on the same owner/repo#issue, and defaulting here would expire the wrong host's row.
    const updated = store.expireClaim(claim.repoFullName, claim.issueNumber, claim.apiBaseUrl);

ensureManagedPrRow (packages/loopover-miner/lib/manage-poll.ts:159-173) does the same for the portfolio queue,
noting "listQueue(repoFullName) is forge-BLIND, so the existence check has to compare the host too".

countActiveRepoStatement (packages/loopover-miner/lib/claim-ledger.ts:257-262) is deliberately forge-blind, but
its comment scopes that decision to the concurrency cap — "the cap's MEANING is unchanged". The freshness check
is an identity check on one specific row, not a cap, so the same reasoning does not apply.

ClaimEntry already carries apiBaseUrl (packages/loopover-miner/lib/claim-ledger.ts:17-25); only the narrowed
seam type SubmissionFreshnessClaimLedger
(packages/loopover-miner/lib/submission-freshness-check.ts:46-48) drops it.

Requirements

  • checkSubmissionFreshness must select the claim row belonging to the candidate's own forge host, not the first
    row for that repo across all hosts.
  • SubmissionFreshnessCandidate gains an optional apiBaseUrl. When omitted or nullish it must resolve to
    DEFAULT_FORGE_CONFIG.apiBaseUrl (packages/loopover-miner/lib/forge-config.ts:22-32), exactly matching the
    claim ledger's own normalizeApiBaseUrl at packages/loopover-miner/lib/claim-ledger.ts:129-133, so every
    existing single-forge caller behaves identically to today.
  • SubmissionFreshnessClaimLedger's row type must include apiBaseUrl: string, and the match must compare it —
    a row whose apiBaseUrl differs from the candidate's resolved host must be ignored entirely (neither a pass nor
    a claim_superseded abort).
  • When no row exists for the candidate's own host, the result must remain { fresh: false, reason: "claim_superseded" } with the same submission_freshness_abort event payload as today
    (packages/loopover-miner/lib/submission-freshness-check.ts:157-169).
  • attempt-runner.ts's call site (packages/loopover-miner/lib/attempt-runner.ts:235-238) must pass the same
    host the attempt's claim was recorded under, so the runner and the ledger cannot disagree.
  • Do NOT change claimIssueWithinCap's forge-blind countActiveRepoStatement — the cap's cross-forge meaning is
    intentional and documented at packages/loopover-miner/lib/claim-ledger.ts:257-259.
  • Do NOT change the live-snapshot retry loop, the issue_closed / already_addressed /
    live_state_unavailable decisions, or their event payloads.

⚠️ Required pattern: mirror packages/loopover-miner/lib/claim-ledger-expiry.ts:47-51 and
packages/loopover-miner/lib/manage-poll.ts:159-173 — resolve the host with the same
"omitted/blank → DEFAULT_FORGE_CONFIG.apiBaseUrl" rule the stores use, then compare it explicitly. What does
NOT satisfy this issue: (a) changing listRepoStatement in claim-ledger.ts to add an api_base_url predicate,
which silently rescopes listClaims/listActiveClaims for every other caller including the concurrency cap;
(b) filtering with .find(c => c.issueNumber === n && c.status === "active"), which papers over the wrong-host
match by picking any active row and reintroduces the false-pass case; (c) a test-only PR.

Deliverables

  • checkSubmissionFreshness in packages/loopover-miner/lib/submission-freshness-check.ts resolves the
    candidate's apiBaseUrl (defaulting to DEFAULT_FORGE_CONFIG.apiBaseUrl) and matches on
    issueNumber and that host.
  • Given a claim ledger returning [{ repoFullName: "acme/widgets", issueNumber: 42, status: "released", apiBaseUrl: "https://forge.internal" }, { repoFullName: "acme/widgets", issueNumber: 42, status: "active", apiBaseUrl: "https://github.com/ghapi" }] (in that order) and a candidate with no apiBaseUrl,
    checkSubmissionFreshness proceeds to the live-snapshot fetch and returns { fresh: true } — asserted in
    the existing test/unit/miner-submission-freshness-check.test.ts.
  • Given only [{ …, status: "active", apiBaseUrl: "https://forge.internal" }] and a candidate with no
    apiBaseUrl, the result is { fresh: false, reason: "claim_superseded" } and one
    submission_freshness_abort event is appended — asserted in the same test file.
  • Given [{ …, status: "active", apiBaseUrl: "https://forge.internal" }] and a candidate with
    apiBaseUrl: "https://forge.internal", the result is { fresh: true } — asserted in the same test file.
  • An end-to-end assertion that runMinerAttempt threads the attempt's host into the freshness candidate —
    asserted in test/unit/miner-attempt-runner.test.ts.
  • A regression test named for this bug (e.g. REGRESSION: a released claim on another forge host does not abort this host's submission) that fails against the current code.

All Deliverables above are required in a single PR. A PR that satisfies only some of them — for example one that
adds apiBaseUrl to the candidate type but leaves attempt-runner.ts passing nothing, so the runner and ledger
still disagree on a non-default host — does not resolve this issue.

Test Coverage Requirements

This repo enforces 99%+ Codecov patch coverage, branch-counted. vitest.config.ts's coverage.include lists
packages/loopover-miner/lib/**/*.ts, so submission-freshness-check.ts and attempt-runner.ts are measured and
gated. Every branch the change introduces needs both arms tested: the candidate apiBaseUrl supplied vs
omitted/nullish default; the row-host matches vs differs comparison; the resulting !claim vs
claim.status !== "active" arms of the existing guard at
packages/loopover-miner/lib/submission-freshness-check.ts:111; and, if a row normalizes a blank/absent
apiBaseUrl, both the blank and non-blank arms.

Expected Outcome

A completed attempt is no longer discarded as claim_superseded because of an unrelated forge host's stale claim
row, and the freshness gate can no longer pass on a claim this attempt does not hold — the claim identity check
becomes host-scoped, matching the (api_base_url, repo_full_name, issue_number) key the ledger has used since
#5563.

Links & Resources

  • packages/loopover-miner/lib/submission-freshness-check.ts:110-113 — the forge-blind .find
  • packages/loopover-miner/lib/submission-freshness-check.ts:46-48 — the narrowed ledger seam that drops apiBaseUrl
  • packages/loopover-miner/lib/claim-ledger.ts:162-171, :248-250, :257-262 — the composite key, the
    forge-blind list statement, and the deliberately forge-blind cap
  • packages/loopover-miner/lib/claim-ledger-expiry.ts:47-51 — the host-echoing precedent
  • packages/loopover-miner/lib/manage-poll.ts:159-173 — the same precedent for the portfolio queue
  • packages/loopover-miner/lib/claim-ledger-cli.ts:7 — the --api-base-url flag that makes multi-host rows reachable
  • packages/loopover-miner/lib/attempt-cli.ts:796-802 — the attempt's own claim, recorded on the default host

Metadata

Metadata

Assignees

No one assigned

    Labels

    gittensor:bugGittensor-scored bug fix — scores a 0.05x multiplier.help wantedExtra attention is needed

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions