M6 FDQ-73 — expiry/terminal reconciliation sweep (grey_two) - #33
Merged
Conversation
The SDK never delivers job.expired/job.rejected to the adapter (acpAgent.fireHandler gates on shouldRespond, whose RESPONDERS map omits job.expired and sets job.rejected:[]), there is no client-side expiry timer, and the delivery poll is FUNDED-only — so a submitted-then- terminal job that isn't `completed` strands as status='submitted' and no stiff is ever recorded. The reputation ladder can't advance without this sweep; it is a hard prerequisite for flip-to-enforce. - reputationDb.ts: add listExpiredSubmitted(nowIso, limit) to TrackedJobsRepo — a bounded, oldest-first SELECT of still-`submitted` rows past expires_at. SELECT only (FDQ-65). - reputationReconciler.ts: each poll tick, pull expired-submitted rows, fetch each job's TRUE on-chain status, and resolve via the gate's idempotent onJobTerminal — a stiff only for a genuine `expired`; `rejected`/`completed` resolve with no stiff; still-live/unknown left `submitted` and retried. Ported from plugin-acp reconcileTrackedJobs but triggered on expires_at (not active-set dropout), removing the "dropped-from-active but still live" ambiguity — false-strike prevention by construction. Reads status + records only; NEVER signs (safe under OBSERVE_ONLY). Fail-soft at tick + per-row. - acpAdapter.ts: reputationReconciler seam; sweep on the existing poll cadence (cleared by stop()); fetchJobStatus via the SDK getJob (jobStatus ?? status convention). - main.ts: share the gate's trackedRepo, build + inject the reconciler. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- test/reputationReconciler.test.ts: 10 deterministic tests (injected clock + fake tracked repo + injected status fetcher) — terminal discrimination (expired→one stiff clean→warned; rejected/completed→resolve no stiff; still-funded + not-yet-expired→untouched), idempotency (two sweeps → one stiff), fail-soft (list throws → tick skipped; per-row getJob throw → row skipped, loop continues), and listExpiredSubmitted grant compliance (bounded SELECT — no DELETE/TRUNCATE/UPDATE/INSERT). - test/buyerReputationGate.test.ts: interface-conformance for the fake tracked repo. vitest run 50/50, tier-1 smoke, typecheck, build, lint all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follows #32 (C′). Closes the FDQ-73 gap: the SDK never delivers
job.expired/job.rejectedto the adapter (acpAgent.fireHandlergates onshouldRespond, whoseRESPONDERSmap omitsjob.expiredand setsjob.rejected:[]), there's no client-side expiry timer, and the delivery poll is FUNDED-only — so a submitted-then-terminal job that isn'tcompletedstrands asstatus='submitted'and no stiff is ever recorded. The reputation ladder can't advance without this sweep, so it's a hard prerequisite for flip-to-enforce.What it does
Each poll tick:
listExpiredSubmitted(now)(bounded, oldest-first SELECT of still-submittedrows pastexpires_at) → for each, fetch the job's true on-chain status → discriminate:onJobTerminal,clean→warned→…)submitted, retried next tickPort-mapping rationale
Ported from plugin-acp
reconcileTrackedJobs(read-only under lock #5), with one deliberate change: it triggers onexpires_at < now(bounded SELECT) rather than "dropped out of the live active set." That removes the source's "dropped-from-active but actually still live" ambiguity — the authoritative-status check is the sole discriminator, so a past-SLA row that's still funded on-chain maps tonulland is left alone. Marking a rejected job as expired-and-stiffed would be a false strike; this prevents it by construction. No duplicated resolve/stiff logic — it reuses the gate'sonJobTerminal(which ownsresolveIfSubmitted'sWHERE status='submitted'idempotency guard + stiff-only-on-expired).Safety
setBudget/submit/reject); safe underOBSERVE_ONLY.stop().Fail-safe empirical note (verified in soak, not a blocker)
toTerminalassumesgetJobreturnsexpired/rejected/completed. If the API uses other tokens →toTerminalreturnsnull→ the row stayssubmitted(under-resolves — never a false stiff). Confirmed against a real stranded job during the post-redeploy soak (a controlled expiry test), adding any missing token as a one-line follow-up.Gates
vitest run50/50 (10 new) · tier-1 smoke ✓ · typecheck ✓ · build ✓ · lint ✓Sequencing
C′ (#32) merged → this PR → one box redeploy carrying both (separate authorized step):
BUYER_GATING_BLOCK_ENABLED=false, verifyreputationGate: 'shadow'+ sweep running + seller healthy on0xa966…+ grey stopped.