Skip to content

M6 — FDQ-70 root-cause SDK import fix (drop tsx) + FDQ-70b setBudget idempotency guard - #31

Merged
Mayakovsky merged 2 commits into
mainfrom
m6-fdq70-sdk-fix
Jul 24, 2026
Merged

M6 — FDQ-70 root-cause SDK import fix (drop tsx) + FDQ-70b setBudget idempotency guard#31
Mayakovsky merged 2 commits into
mainfrom
m6-fdq70-sdk-fix

Conversation

@Mayakovsky

Copy link
Copy Markdown
Owner

Supersedes #30. Branched fresh off main (c7e966c), so the runtime is plain node and tsx is a devDependency by inheritance — no reverting hunk needed. Two logical commits.

FDQ-70 — root-cause SDK import fix (committed pnpm patch, drop tsx)

@virtuals-protocol/acp-node-v2@0.0.4 is bun-authored ESM with extensionless relative imports (dist/index.js: export * from "./acpAgent" …). bun tolerates them; strict Node ESM rejects them (ERR_MODULE_NOT_FOUND .../dist/acpAgent), so the standalone node adapter could not load the SDK. PR #30 worked around it with a production node --import tsx loader; Forces ruled: fix the root cause, keep plain Node.

  • Committed pnpm patch appends .js to all 52 relative specifiers across the SDK dist. Every target verified to be an existing sibling .js file; zero dynamic import()/require; no exports map. pnpm reapplies it deterministically on every install.
  • Durable: a version bump fails loudlyERR_PNPM_UNUSED_PATCH blocks the install (tested); it can never silently drop.
  • Light: plain Node at runtime (no loader); tsx stays a devDependency. Lockfile delta is pure patch-wiring — zero new packages; the FDQ-69b native-build decline (bufferutil/utf-8-validate/es5-ext) is intact.
  • Proof: on plain Node the unpatched SDK throws ERR_MODULE_NOT_FOUND; patched, it imports with all key exports present (AcpAgent, PrivyAlchemyEvmProviderAdapter, AssetToken, SseTransport, JobSession) — no wallet action. (13 KB patch is 52 mechanical +.js; verified 0 remaining extensionless, 0 double-appends.)

FDQ-70b — setBudget idempotency guard

job.created and requirement.message are distinct eventTypes that both drive the accept path, so claimDispatch's per-event key admitted both, and markDecided fired only after setBudget's await — a concurrent pair (SSE double-fire, or hydrateSessions re-firing a created-phase job at startup) could each pass the __decided check and each call setBudget (the #70220 double budget.set 0.25).

  • Fix: claimAccept(chainId, jobId) — a synchronous check-and-set claimed in handleEntry before handleJobCreated's first await, released in a finally (same key shape + claim/release pattern as the funded inFlight guard). Exactly one setBudget; a transient failure still retries via a later event; __decided makes success permanent. An INVARIANT comment marks that markDecided must stay synchronous-adjacent to the await.
  • submit needs no new guard (with evidence): already claimed synchronously by claimDispatch (recentJobs[jobId:job.funded] + inFlight, before any await) and re-checked against a fresh FUNDED status. The poll backstop routes through handleEntry and emits job.funded only, so the accept chokepoint covers every dispatch origin.
  • Tests (27/27): concurrent job.created+requirement.message → one setBudget; hydration re-fire → one; sequential-after-decided → no re-budget; concurrent job.funded → one submit; origin-independent accept guard (poll-shaped entry); real poll-timer vs SSE funded race → one submit. Verified the two accept-race tests fail (got 2) without the guard.

Gates

typecheck ✓ · build ✓ · vitest run 27/27 ✓ · tier-1 offline smoke ✓

Not in this PR

  • Box redeploy is a separate authorized step — the live box still runs the tsx workaround main never received. On its own go: pull → install → swap unit to plain-node ExecStart → restart → verify seller healthy on 0xa966…. Abort unchanged: systemctl disable --now grey-acp-adapterpm2 start grey. Never co-run.
  • FDQ-71 (logged, not actioned): the poll backstop covers funded→submit but not created→accept; a job.created missed during an SSE drop relies on SSE reconnect/replay or hydrateSessions at restart.

Mayakovsky and others added 2 commits July 24, 2026 12:45
…rop tsx)

@virtuals-protocol/acp-node-v2@0.0.4 ships bun-authored ESM with extensionless
relative imports (dist/index.js: `export * from "./acpAgent"` …). bun tolerates
them; strict Node ESM rejects them (ERR_MODULE_NOT_FOUND .../dist/acpAgent), so the
standalone `node` adapter process could not load the SDK.

Root-cause fix: a committed pnpm patch appending `.js` to all 52 relative specifiers
across the SDK dist (every target verified to be an existing sibling .js file; zero
dynamic import()/require; no exports map). pnpm reapplies it deterministically on every
install, and fails loudly (ERR_PNPM_UNUSED_PATCH) on any version bump. Keeps plain Node
at runtime — no `--import tsx` loader, tsx stays a devDependency. Adds zero packages to
the tree; the FDQ-69b native-build decline (bufferutil/utf-8-validate/es5-ext) is intact.

Proof: on plain Node the unpatched SDK throws ERR_MODULE_NOT_FOUND; patched, it imports
with all key exports present (AcpAgent, PrivyAlchemyEvmProviderAdapter, AssetToken,
SseTransport, JobSession) — no wallet action.

Supersedes PR #30 (tsx-as-runtime), which is branched off main and NOT merged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
job.created and requirement.message are DISTINCT eventTypes that both drive the accept
path, so claimDispatch's per-event key admitted both, and markDecided fired only AFTER
setBudget's await — a concurrent pair (SSE double-fire, or hydrateSessions re-firing a
created-phase job at startup) could each pass the __decided check and each call setBudget
(the #70220 double budget.set 0.25).

Fix: claimAccept(chainId, jobId) — a synchronous check-and-set claimed in handleEntry
BEFORE handleJobCreated's first await, released in a finally (same key shape and
claim/release pattern as the funded inFlight guard). Exactly one setBudget; a transient
accept failure still retries via a later event; __decided makes a successful accept
permanent. An INVARIANT comment marks that markDecided must stay synchronous-adjacent to
the setBudget await.

submit needs no new guard: it is already claimed synchronously by claimDispatch
(recentJobs[jobId:job.funded] + inFlight, set before any await) and re-checked against a
fresh FUNDED status. The poll backstop routes THROUGH handleEntry (dispatchPolledJob) and
emits job.funded only, so the accept chokepoint covers every dispatch origin.

Tests (27/27): concurrent job.created+requirement.message → one setBudget; hydration
re-fire → one; sequential-after-decided → no re-budget; concurrent job.funded → one
submit; origin-independent accept guard (poll-shaped entry); real poll-timer vs SSE
funded race → one submit. Verified the two accept-race tests fail (got 2) without the guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Mayakovsky
Mayakovsky merged commit 8ee26d6 into main Jul 24, 2026
3 checks passed
@Mayakovsky
Mayakovsky deleted the m6-fdq70-sdk-fix branch August 14, 2026 01:46
Mayakovsky added a commit that referenced this pull request Aug 24, 2026
…seded pointer, GH reply draft

- INVARIANTS.md #31: production hot-key manual-transfer-only, ratified 2026-08-23
- deploy.md: don't grep with a glob broad enough to match a real .env file
- CDP-BAZAAR-STEP2 directive: marked superseded, points to the V2 fresh-wallet version
- CDP-BAZAAR-REPLY-3045 draft: untracked GitHub-reply draft, still pending Forces' review/posting
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant