Repository navigation
Make the market-maker cycle a resumable saga with explicit step state #152
Description
Overview
runMarketMakerCycle() in src/agents/market-maker.agent.ts is OracleDesk's main write path: DB market → question pin and spec commitment → on-chain creation through the treasury → reasoning trace → trace pin and registry publication. The steps run sequentially in one try block, several failures are swallowed, and nothing records which steps finished. A crash or a Pinata/RPC hiccup leaves partial state that nothing revisits:
- a
PENDINGmarket withquestionHash/metaUriset but no on-chain market, becausecreateMarketOnChainthrew afterpinJsonand the DB update; - a live on-chain market (
onChainMarketIdset) with no published creation trace, becausepinAndPublishTracethrew and the error was swallowed; - a trace pinned before its market was on-chain, which
pinAndPublishTracenever publishes later; - in the worst case, an on-chain market whose DB row never received
onChainMarketIdbecause the process died betweensignAndSend()returning andprisma.market.update. A naive retry then creates a second market and spends a second 100 USDC seed from treasury capital.
This issue turns the cycle into a saga. Every step is idempotent, checkpoints its output in a persisted run record, and can be resumed by a resumer after a crash or transient failure. Compensating actions apply where they are safe.
Background & current behavior
Step by step, in runMarketMakerCycle():
aggregateSignals()→generateMarketQuestion(signals)→isDuplicateMarket(question)(each may end the cycle with null).createMarketFromProposal(proposal)→PENDINGMarketplusAgentLogMARKET_CREATED.createMarketOnChain(market)(src/services/market-chain.service.ts):resolutionHash(spec)→pinJson(questionDocument(...))→prisma.market.update({ resolutionSpec, resolutionHash, questionHash, metaUri, seedAmountRaw })→agentCreateMarket(...)→ in live mode, updateonChainMarketId,creationTxHash,status: 'ACTIVE'. Errors are caught in the agent and logged ("market stays PENDING").generateReasoningTrace({...})(src/services/reasoning.service.ts), which makes an LLM call for hedge conditions and creates aReasoningTrace.pinAndPublishTrace({ traceId, payload: {...tracePayload, onChainMarketId}, action: 'create_market', onChainMarketId })(src/services/trace-publish.service.ts). Errors are caught and logged.
Related facts:
pinJsonis idempotent for identical bytes.questionDocument()is deterministic, but the trace payload includestimestamp: new Date().toISOString().- In live mode a
write()throw inchain.service.tsis ambiguous (the transaction may have landed). - Callers:
src/cron/ingestion.cron.tsandtriggerMarketGenerationinsrc/controllers/market.controller.ts. - Indexer (
src/services/indexer.service.ts) storeschain_eventsbut does not projectMarketCreated. The market-coreMarketCreatedevent has noquestion_hash, so it cannot be used to find a lost creation.get_market(id).question_hash/meta_urican.
Goals
- A persisted
market_generation_runsrecord with an explicit state machine and per-step inputs and outputs. - Each step is idempotent and checkpoints its output before the next starts.
- Lost on-chain creations are detected and recovered by matching
question_hash/meta_uribefore any re-submission, so a seed is never spent twice. - Pinned-but-unpublished traces are published once their market is on-chain, within the same saga.
- A resumer (startup + periodic) picks up incomplete runs, with bounded retries and exponential back-off, and moves runs that cannot continue to
NEEDS_ATTENTION. - Compensations: unreachable PENDING markets are marked
CANCELLEDwith a reason. Nothing on-chain is ever compensated automatically.
Non-goals
- BullMQ or distributed locking (separate issue); a Postgres row lock is enough here.
- A general chain-write outbox (separate issue); only the minimal create-market recovery is added.
- Admin-supplied generation parameters (wave draft Record indexer ledger gaps instead of silently skipping past RPC retention #10) and the trader cycle.
Proposed implementation
- Schema (new migration).
enum GenerationRunState { STARTED SIGNALS_READY PROPOSED MARKET_PERSISTED QUESTION_PINNED CHAIN_SUBMITTED CHAIN_CONFIRMED TRACE_PERSISTED TRACE_PINNED TRACE_PUBLISHED COMPLETED SKIPPED NEEDS_ATTENTION CANCELLED } model MarketGenerationRun { id String @id @default(uuid()) trigger String // 'cron' | 'admin' | 'resume' state GenerationRunState @default(STARTED) attempts Int @default(0) nextAttemptAt DateTime? lockedBy String? lockedUntil DateTime? inputs Json? // signals snapshot ids, proposal outputs Json @default("{}") // { questionCid, questionHash, resolutionHash, chainTxHash, onChainMarketId, traceId, traceCid, traceHash, traceTxHash, onChainTraceId } marketId String? @unique lastError Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt market Market? @relation(fields: [marketId], references: [id], onDelete: SetNull) @@index([state, nextAttemptAt]) @@map("market_generation_runs") }
src/services/market-saga.service.ts(new). It owns the state machine.type StepFn = (run: RunCtx) => Promise<Partial<RunOutputs> & { next: GenerationRunState }>; const STEPS: Record<GenerationRunState, StepFn | undefined>; export async function startRun(trigger: 'cron'|'admin'): Promise<string>; export async function advance(runId: string, opts?: { maxSteps?: number }): Promise<MarketGenerationRun>; export async function resumeIncomplete(limit = 5): Promise<number>;
advanceclaims the run withUPDATE … SET lockedBy, lockedUntil = now()+5min WHERE id = $1 AND (lockedUntil IS NULL OR lockedUntil < now()) RETURNING *, runs one step, and persistsoutputs+statein one update before moving on. Steps read inputs only from the run record and the DB.- Steps and their idempotency.
STARTED → SIGNALS_READY → PROPOSED: store a compact signal snapshot and the validated proposal ininputs. No signals or a duplicate →SKIPPED.PROPOSED → MARKET_PERSISTED: ifrun.marketIdis set, reuse it. Otherwise callcreateMarketFromProposaland setrun.marketIdin the same$transaction(refactorcreateMarketFromProposalto accept atxclient).MARKET_PERSISTED → QUESTION_PINNED: splitcreateMarketOnChainintoprepareMarketCommitment(market, spec)(resolution hash, pin, DB update; idempotent because the bytes are deterministic, and ifmarket.questionHashis already set, verify instead of re-pinning) andsubmitMarketCreation(market).QUESTION_PINNED → CHAIN_SUBMITTED → CHAIN_CONFIRMED: before submitting, run lost-creation recovery: scanmarket_count()downward over the lastSAGA_RECOVERY_SCANids (default 50) withgetOnChainMarket(id)and matchquestion_hash === outputs.questionHashandcreator === CONTRACTS.treasury. A match means the market already exists, so adopt the id. Otherwise callagentCreateMarket. In live mode, recordchainTxHashbefore awaiting confirmation (add an optionalonBuilt(hash)callback towrite()inchain.service.ts). On an ambiguousChainError, stay atCHAIN_SUBMITTEDand confirm on the next attempt viagetTransaction(chainTxHash)plus recovery, never by blind re-submission. Dry-run reachesCHAIN_CONFIRMEDwithonChainMarketId: null.CHAIN_CONFIRMED → TRACE_PERSISTED: ifoutputs.traceIdexists, reuse it. Otherwise callgenerateReasoningTrace. Store the exacttracePayload(including itstimestampandtraceId) inoutputs.tracePayloadso later pins hash identical bytes.TRACE_PERSISTED → TRACE_PINNED:pinJson(outputs.tracePayload). Compare with an existingtraceHashif set.TRACE_PINNED → TRACE_PUBLISHED: only whenonChainMarketIdis set. Before publishing, checkmarket_trace_count(id)plusmarket_trace_atfor an existing trace with the sametrace_hash(recovery, as above), thenpublishTrace. Dry-run →COMPLETEDwithout publishing.
SplitpinAndPublishTraceintrace-publish.service.tsintopinTraceandpublishPinnedTraceso the saga can checkpoint between them. KeeppinAndPublishTraceas a wrapper for the trader path.
- Retry and back-off. On a step error, increment
attempts, storelastError(code,message,step), and setnextAttemptAt = now + min(2^attempts × 30 s, 1 h). AfterSAGA_MAX_ATTEMPTS(default 8), setNEEDS_ATTENTION. Permanent errors (IPFS_NOT_CONFIGURED,TradeCapExceeded,DailyCapExceeded) go straight toNEEDS_ATTENTION. - Compensation. A run in
NEEDS_ATTENTIONbeforeCHAIN_SUBMITTEDwhose market isPENDINGgets an admin actionPOST /admin/generation-runs/:id/cancelthat sets the market toCANCELLED(with anAgentLog) and the run toCANCELLED. AfterCHAIN_SUBMITTED, cancelling is refused, because the on-chain state belongs to the contracts. - Wiring.
runMarketMakerCycle()becomesconst id = await startRun(trigger); return summarize(await advance(id, { maxSteps: Infinity })), keeping theMarketCycleResultshape formarket.controller.ts.src/cron/ingestion.cron.tsalso callsresumeIncomplete()before starting a new run, andsrc/server.tscalls it once at boot.- Admin routes
GET /admin/generation-runsandGET /admin/generation-runs/:id, documented indocs/api.md.
- Fault injection. A test-only
setSagaFaultHook(step, fn)seam throws before or after a named step.
Edge cases & pitfalls
- Two processes resuming the same run: the conditional lock update must be the only way to claim a run. Lock expiry must be longer than the slowest step (LLM plus chain confirmation).
generateReasoningTracewrites the DB row and calls the LLM. If the process crashes after the insert but before the checkpoint, a retry creates a second trace. Wrap it to accept a pre-generated id (traceIdstored in outputs before the insert), or look up an existing trace by(marketId, decisionType='MARKET_CREATION').- Recovery scans must check
creator, since another creator could reuse the samequestion_hash. - Schema evolution:
inputs/outputsare JSON. Version them (outputs.v = 1) so future steps can migrate them.
Testing plan
tests/market-saga.test.ts(new), with in-memory Prisma (as intests/indexer.test.ts), a fakeIpfsTransport, and stubbed chain and LLM calls:happy path reaches COMPLETED with all outputs recordedcrash after QUESTION_PINNED resumes without re-pinning different bytesambiguous agent_create_market failure is recovered by question_hash scan, not resubmittedtrace pinned before market on-chain is published after CHAIN_CONFIRMEDexisting on-chain trace with same hash is adopted on resumepermanent error moves run to NEEDS_ATTENTIONconcurrent advance calls cannot claim the same runfault injected at every step boundary eventually COMPLETES(property-style loop over steps)
- Commands:
npx prisma migrate dev --name market_generation_runs,npm run build,npm test,npm run check:contracts.
Acceptance criteria
-
market_generation_runspersists state, inputs, outputs, attempts, locks and errors for every cycle. - Each step checkpoints its output before the next starts, and re-running any step after a simulated crash produces no duplicate side effects.
- On-chain creation recovery matches
question_hashand the treasury creator before any re-submission, so no seed is ever spent twice in tests. - Traces pinned before the market was on-chain are published once it is, and existing on-chain traces with the same hash are adopted.
- A resumer runs at boot and on each ingestion tick, with exponential back-off and a
NEEDS_ATTENTIONterminal state. -
createMarketOnChainandpinAndPublishTraceare split into checkpointable parts, and existing callers keep working. - Admin endpoints list and inspect runs and cancel pre-chain runs, documented in
docs/api.md. - Fault-injection tests cover every step boundary.
-
npm run build,npm testandnpm run check:contractspass.
Resources
src/agents/market-maker.agent.ts,src/services/market-chain.service.ts,src/services/trace-publish.service.tssrc/services/market.service.ts,src/services/reasoning.service.ts,src/services/ipfs.service.ts,src/services/chain.service.tssrc/cron/ingestion.cron.ts,src/controllers/market.controller.ts,src/server.ts,prisma/schema.prisma,docs/architecture.md(ADR 0001 section)docs/wave-issues/10-generation-params.md,docs/wave-issues/13-persistent-generation-jobs.md(related, not overlapping)- Saga pattern: https://microservices.io/patterns/data/saga.html
- Stellar RPC
getTransaction: https://developers.stellar.org/docs/data/apis/rpc/api-reference/methods/getTransaction - PostgreSQL row locking: https://www.postgresql.org/docs/16/explicit-locking.html#LOCKING-ROWS
Important
Raise your pull request against the develop branch, not main.
All contributor work for this issue must be submitted as a PR into develop. Pull requests opened against main will not be merged and are closed automatically. Create your branch from develop, and before opening the PR double-check that the base branch is set to develop.
Generated by Claude Code
Activity
affy-exe commentedon Oct 17, 2026
@affy-exe has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @affy-exe to this issue.
bade2brazy commentedon Oct 17, 2026
@bade2brazy has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @bade2brazy to this issue.
drips-wave commentedon Oct 17, 2026
Congratulations, @bade2brazy! 🎉 Your application was accepted by the repo's maintainers, and the issue is due on October 22, 2026.
🧑💻 @bade2brazy: Please resolve the issue such that the repo's maintainers have enough time to review your contribution before the due date. You'll earn Points for completing the issue on-time, which will make you eligible for a share of the Stellar Wave Program's reward pool.
Warning
When opening a PR, please link it to this issue to ensure it gets tracked accurately. Points are awarded when this issue is marked as completed by the maintainer.
🤠 Repo maintainers: Please keep an eye on the contributor's progress and review their work before the due date. You can manage this issue, including adjusting its complexity and points, here.
🌊 Happy Wave 🌊
bade2brazy commentedon Oct 17, 2026
Resolved in PR #161
feat: implement market-maker saga, versioned schemas, confidential tr…
drips-wave commentedon Oct 25, 2026
This issue has been completed by @bade2brazy as part of the Stellar Wave Program's 10th Wave 🥳
😎 @bade2brazy: You earned 200 Points for completing this issue! Once the Wave's rewards are finalized, you'll be eligible for a percentage of the Wave's reward pool based on the percentage of total points you've earned. Learn more here. You can also Leave a review to share your experience working on this issue.
🧑💻 Repo maintainers: How'd the contributor do? Leave a review to share your experience working with them.