fix(publisher): resolve held lift jobs from chain proof — the check-chain dispatcher (GH#2270, part 3) - #2300
Open
Jurij89 wants to merge 36 commits into
Conversation
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.
Summary
blockedPendingRecoverypopulation is exactly this lane's work queue.resolvePublishTransaction(a second adapter entry point beside the receipt-onlyresolvePublishByTxHash, which keeps its non-recovery consumer in agent sync) returnsconfirmed / reverted / unrecognized / pending / not-found. Itsnot-foundis earned three times over (review rounds 1-2): with no receipt it asks the node for the TRANSACTION, so a mempool tx answerspending— and even then, a null lookup is point-in-time, backend-local evidence, so the runner releases nothing on it alone. Release by absence needs (1) the transaction missing, (2) its NONCE — recorded at signing via a typed, awaitedonBeforeBroadcast({txHash, nonce})write-ahead callback (zero extra RPC;onPhasestays pure instrumentation) — spent at a FINALIZED block by something else, so the recorded hash can never mine, and (3) the knowledge-asset id the job would mint provably absent on chain (oneownerOfeth_call against the id the job's seal pins), because a replacement transaction on the same nonce slot could have performed the very same publish and the nonce alone cannot tell. A job that would ALLOCATE a fresh id on re-run — unsealed raw lift, where a re-run would double-mint — has nothing to ask and is never released by absence. Every gap (no recorded nonce, no pinned id, no adapter support for finalized reads, a throw) fails closed toinconclusive. The runner'screateChainProofResolveradds a sixth, fail-closed member:inconclusive, where every unknown collapses (RPC error, unmapped result, and — deliberately — a null from a legacy adapter that cannot see the mempool, which may therefore never authorise a resend).recover()cadence and acts only on answers. Eligibility for the named lane isisHeldForChainProof— the same predicate that holds the job everywhere else, now with five surfaces: four that refuse to move a held job and one that resolves it. Per verdict:recovered→ finalize the SAME job (both evidence carriers rebuild through one path — a recovery-carrier-only job gets itsbroadcastrecord rebuilt from the evidence that held it, a bug our own matrix caught before review);not-found→ evidence-preserving reset on the same jobId (the checked hash rides along, so a later failure is still held);reverted→ WHOSE transaction decides: a job that sent it itself is re-recordedtx_reverted(the registry's proven-ineffective verdict — the KA is publishable again, the job is not blindly re-run on this node's money), while an inherited hash (this job failed pre-send; an earlier attempt sent the reverted tx) takes the same release as proven absence;pending/unrecognized/inconclusive→ stay held, ask again next tick.recoveryLookupTimeoutMsgate bounds how long a LIVE broadcast may stay unresolved while holding its wallet; this lane starts after that declaration, where an expiry could only mean "no proof, resend anyway" — the double publish this chain exists to prevent. A held job is chased indefinitely at one chain read per tick; the exits are proof or the operator's by-id clear.pause()now gates the dispatcher (it re-queues work and spends chain reads). The interrupted-job half ofrecover()deliberately keeps running while paused — it reconciles transactions this node already sent — and that asymmetry is pinned by its own row.LIFT_JOB_PENDING_CHAIN_PROOFresponses flip back toretryable: true, and the message says chain recovery is chasing the job (by-id clear stays the impatient-operator exit).verifyKAUpdate(receipt + root chain-of-custody) and finalizes, while release-by-ABSENCE stays CREATE-ONLY — an update has no monotone register to prove absence against ("intended root is not current" also describes our update landing and being superseded, and a release would re-apply a stale root over newer state), so unproven updates hold with the by-id exit, enforced at both the resolver and the disposition table. Round 4 also made verdicts survive concurrency (the disposition re-reads the job under the queue's claim lock, so a verdict resolved mid-clear-jobis dropped, never resurrected) and made the absence pair atomic (readFinalizedChainProofSnapshot: nonce + minted state read together at ONE pinned finalized block on ONE provider — a lagging fallback yields its own consistent pair, never a splice).agent.updatedropped it again), so every named-KA update transaction went out with NO durable pre-send record. Both hops now thread it, pinned end to end. Two more round-3 fixes: an ACCOUNTED hash (one the dispatcher itself proved absent or reverted) no longer re-holds the job after a later pre-send failure (txHashAccountedaudit-preserving mark, written only by the dispatcher's release paths), and no fallible phase instrumentation can run between the durable write-ahead and the send (phases first, durable callback last — a rejecting listener can abort a send, never fabricate a broadcast).broadcast.txHash, so PR-2 holds it — intended, and the dispatcher is what resolves it.tx_revertedis now reachable fromincluded(registry allowed-states widened, pre-send states still excluded): a reorg can replace an included job's transaction with a failing one, and the dispatcher records that truthfully.Related
Diagrams
A held job whose transaction the chain can account for
Before:
sequenceDiagram participant Job as Held failed job with txHash participant Recover as the recover loop participant Chain participant Operator Recover->>Job: canRetryFailedRecovery is false, never asked Note over Job: waits forever Operator->>Job: clear-job by id is the only exitAfter:
sequenceDiagram participant Job as Held failed job with txHash participant Recover as the recover loop participant Chain Recover->>Chain: resolvePublishTransaction for the held txHash alt confirmed Chain-->>Recover: publish found Recover->>Job: finalize the SAME job else proven absent by all three, missing tx and spent nonce and unminted id Chain-->>Recover: proven absence Recover->>Job: evidence-preserving reset, same jobId re-runs else reverted Chain-->>Recover: failure receipt Recover->>Job: own tx re-recorded as tx_reverted, inherited tx reset else pending or inconclusive Chain-->>Recover: nothing established Recover->>Job: stay held, ask next tick endRaw-lift send window
Before:
sequenceDiagram participant Job participant Publisher participant Chain Publisher->>Chain: sign and send, no durable record Note over Job: crash here leaves status validated, no txHash Publisher->>Job: the recover loop resets to accepted Publisher->>Chain: re-broadcast under a NEW hash Note over Chain: possible double publishAfter:
sequenceDiagram participant Job participant Publisher participant Chain Publisher->>Job: durable broadcast record with signed txHash Publisher->>Chain: send Note over Job: crash here leaves a held job carrying the hash Publisher->>Chain: dispatcher asks about THAT hash Chain-->>Publisher: verdict decides finalize, reset, or holdFiles changed
packages/chain/src/chain-adapter.tsPublishTransactionResolutionfive-state union;resolvePublishTransaction?on the adapter interface, with the second-entry-point rationalepackages/chain/src/evm-adapter-publish.tspackages/chain/src/mock-adapter.tstransactionStatesseam so tests can declare pending vs not-found (the mock previously read every unknown hash as absent);getFinalizedAccountNonceparitypackages/chain/src/evm-adapter-base.tsPreBroadcastSignalwrite-ahead callback awaited before the send (nonce off the signed transaction, zero extra RPC);readFinalizedChainProofSnapshotis the ONE minted/nonce surface (granular methods deleted; revert classification adapter-private); every mined verdict gated behind receipt-block finality + canonicalitypackages/publisher/src/async-lift-publisher-types.tschainProofResolver(typed lookup contract); the legacychainRecoveryResolverkey is REJECTED at construction with an error naming the replacement — loud break, no shimpackages/cli/src/publisher-chain-proof.tscreateChainProofResolver, nonce + identity proofs, result mapping); legacy-null →inconclusive;publisher-runner.tsstays the composition rootpackages/cli/src/daemon/routes/knowledge-assets.tsretryableflipped back totruewith the chase wordingpackages/publisher/src/async-lift-publisher-types.tsAsyncLiftChainProofResolution; resolver contract returns verdictspackages/publisher/src/async-lift-publisher-impl.tsdispatchFailedJobsOnChainProof(verdict switch, never-guard, paused gate, no-time-escape doc); sharedcreatePreSendBroadcastRecordernow wraps raw-lift sends;finalizeProvenKnowledgeAssetVmPublishshared by both lanes; KA-VMcanRetryFailedRecovery=isHeldForChainProofpackages/publisher/src/lift-job-failures.tstx_revertedallowed fromincluded(reorg truth); pre-send states stay excludedpackages/publisher/src/async-lift-retry-disposition.tsisHeldForChainProofdoc names its five surfacespackages/cli/skills/dkg-node/SKILL.mdTest plan
pnpm build:packages23/23; clean-roomtsc --noEmit(tsbuildinfo purged) on chain, publisher, cli, agent — zero errorsrecovery), with the meaningful direction killed insteadcanRetryFailedRecovery: () => false— turns every release/finalize row red (7/19), stated in-file; the raw-lift write-ahead fail-before is 5/6 rows with the no-regression row named🤖 Generated with Claude Code