Skip to content

fix: exactly-once entry delivery, repaired EVM examples, README that compiles - #42

Open
LauJoeYing wants to merge 4 commits into
mainfrom
fix/examples-provider-field-and-docs
Open

fix: exactly-once entry delivery, repaired EVM examples, README that compiles#42
LauJoeYing wants to merge 4 commits into
mainfrom
fix/examples-provider-field-and-docs

Conversation

@LauJoeYing

@LauJoeYing LauJoeYing commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Three issues were reported from the field against v0.1.12. All three reproduce.
Two were documentation drift; one is a real concurrency bug in AcpAgent.start()
that is worse than the docs described.

1. Docs didn't match the installed code — fixed, plus a second instance of the same bug

CreateAcpClientInput accepts evmProvider / solanaProvider; there is no
provider key. All 8 EVM examples and 3 README snippets used provider:, so
they failed to compile and threw "At least one provider must be provided" at
runtime. Root cause: tsconfig.json excludes src/examples* — correctly, they
must not ship in dist/ — so nothing ever type-checked them.

Fixed in 7e14667: tsconfig.examples.json + npm run typecheck:examples (15
errors before, clean after), and the runtime error now names the right key for
plain-JS consumers who get no compile error at all. walletAddress being viem's
Address — so an env-sourced address needs as \0x${string}`` — is now
documented.

Nothing guarded the README, though, and the fix shipped a fresh instance of the
same class of bug. Every fenced typescript block was extracted and compiled
against the packed tarball's .d.ts, the way a reader would use it:

  • The newly added evaluator quick start didn't compile.
    session.entries.find((e) => e.kind === "message" && ...) returns
    JobRoomEntry, so requirement?.content is a type error. Narrowed with an
    e is AgentMessage predicate, plus an import block so the snippet is
    self-contained.
  • The Agent Discovery snippet indexed agents[0].offerings[0] unguarded — a
    compile error under noUncheckedIndexedAccess (which our own tsconfig sets and
    typecheck:examples enforces), and an empty browseAgents result is a real
    runtime case that src/examples/basic/buyer.ts already guards.

13 of 14 blocks now compile standalone; the exception is a deliberate one-line
property fragment. A negative control confirms the harness catches the original
provider: + un-cast address mistake.

2. Raw createJob carries no requirement — resolved as documentation

Only createJobFromOffering / createJobByOfferingName post the "requirement"
entry. createJob and the three hook variants put a job on-chain and stop, so an
evaluator on that path receives a deliverable with no stated ask. Added TSDoc on
all four creators, a README section, and a standalone evaluator quick start —
neither existing quick start covered the evaluator role. Confirmed live: the
requirement message posts on the offering path and the seller reads it.

3. Duplicate delivery — a real bug, not just restart replay

The docs framed duplicates as a cross-restart replay contract. They also happen
inside a single process with no restart. start() registers the live handler
and awaits connect() before hydrateSessions() — the stream must be live first
or entries occurring during catch-up are lost, but dispatching them immediately
is worse, because hydration hasn't built the session yet. Two deterministic
reproductions:

role symptom cause
provider handler ran twice on one job.funded — two submits for one funding live entry dispatched against a session built from zero history, then hydration re-delivered it as the job's latest
evaluator handler ran zero times — a ruling silently never happens a first sighting that isn't job.created has no role info, so inferRoles([]) fell back to ["provider"] and shouldRespond dropped the job.submitted. The session also kept 1 of 2 history entries, so toContext() and status were built on partial history

Entry identity was the underlying problem: dispatch tested membership with
session.entries.includes(entry), a reference compare, and the same logical
entry arrives as a different object on every path that produces it — an SSE frame
and a getHistory() response each parse their own copy.

Fixed at the root:

  • entryKey() (src/events/entryKey.ts, exported) — stable content key,
    since JobRoomEntry carries no server-assigned id.
  • AcpAgent.start() — live entries are queued while hydrating is set and
    drained afterwards in a finally, so a hydration failure can't strand them.
  • JobSession.appendEntry() is idempotent and returns whether the entry was
    new; hasEntry() and mergeEntries() added. mergeEntries folds history in
    timestamp order because status scans backwards for the newest system event —
    appending older history after a newer live entry would walk status backwards.
  • dispatch() — a first sighting that isn't job.created fetches history so
    roles resolve; a failed fetch is logged rather than swallowed and the entry is
    still delivered.
  • getOrCreateSession() merges supplied history into an existing session
    instead of discarding it.

Cross-restart replay is unchanged and still deliberate — it's what lets an agent
killed mid-flow pick the job back up. Integrators still need persistent dedup for
that, and the in-process key set dies with the process by design. The README's
restart section previously claimed the SDK "does not dedupe for you" and
"deliberately can't"; it now states exactly where that line falls.

Left untouched — needs a decision

  • reason doesn't round-trip. reject("deliverable rejected") reaches
    handlers as 0x64656c69...0000: right-padded bytes32, nothing decodes it,
    and the type says string. Decoding it in the SDK would change event payloads
    for existing consumers, so this PR only documents the behavior, the 32-byte
    truncation limit, and a hexToString(..., { size: 32 }) decode. Whether to
    decode it properly is your call.
  • fetchJob() swallows the cause (src/jobSession.ts:208-212) — every
    failure becomes "Failed to fetch job N". A BigInt("0.01") error surfaced as
    that generic message and cost a debug cycle.
  • src/examples/basic/buyer.ts errors out of the box against our own seller:
    the placeholder { description: ... } doesn't satisfy the factCheck schema,
    which requires claim. Schema validation working as intended, but the first
    thing a new user runs fails.
  • Nothing guards the README in CI. The block-extraction harness that caught
    both README bugs works and can land as npm run typecheck:readme if wanted.

Verification

  • npm run typecheck, typecheck:examples, typecheck:tests, npm test, and
    npm run build all clean. dist/ still excludes tests and examples.

  • tests/entryDelivery.test.ts — 7 cases over fake transports, no framework, no
    network. npm test was previously a stub that exited 1. Against the parent
    commit the suite fails 5/7; the 2 that pass before and after are the ones
    asserting preserved behavior (restart replay, the job.created path), so the
    fix is provably narrow.

  • Live on Base mainnet, both evaluator directions, before and after the fix:

    job flow result
    74087 create → requirement → budget → fund → submit → complete ✅ 22.6s
    74088 same → reject ✅ funds refunded
    74089 killed at budget.set, restarted ✅ replayed → completed
    74090 full flow, patched SDK ✅ 25.4s
    74091 killed at budget.set → restarted, patched SDK ✅ replayed → completed

    Every event fired exactly once in every run. No jobs left in flight.

Review focus

The delivery-semantics change in acpAgent.ts / jobSession.ts / entryKey.ts
is the part that warrants scrutiny — it changes what happens inside start().
Then the four untouched items above, particularly reason.

🤖 Generated with Claude Code


Note

Medium Risk
Changes start()/dispatch() delivery semantics for all agents—behavioral but covered by new tests; on-chain side effects still need integrator idempotency across restarts.

Overview
Core fix: AcpAgent.start() no longer double-fires or silently drops entry events when the stream connects before hydration finishes. Live events are queued during hydration and drained afterward; entryKey() compares entries by content (not object reference); JobSession adds idempotent appendEntry, timestamp-ordered mergeEntries, and tryClaimDelivery / unclaimDelivery so handler delivery is separate from the transcript. dispatch() now fetches history on first sighting when the entry isn’t job.created, so evaluator roles resolve instead of defaulting to provider.

Integrator docs: README gains evaluator quick start, restart/replay semantics (in-process dedup vs cross-restart persistence), requirement-message behavior (only offering-based creators post "requirement"), bytes32 reason decoding notes, and provider adapter keys. TSDoc on raw job creators matches. createAcpClients throws a clear error if callers pass deprecated provider.

Tooling / examples: All EVM examples use evmProvider; npm test runs tests/entryDelivery.test.ts (9 delivery scenarios); typecheck, typecheck:examples, typecheck:tests added. LLM examples switch to claude-opus-5; @anthropic-ai/sdk added as a devDependency.

Reviewed by Cursor Bugbot for commit 975145f. Bugbot is set up for automated code reviews on this repo. Configure here.

LauJoeYing and others added 3 commits August 19, 2026 14:22
Three issues reported from the field, all reproduced against the code.

1. `provider` vs `evmProvider`. `CreateAcpClientInput` accepts `evmProvider` /
   `solanaProvider`; there is no `provider` key. All 8 EVM examples and 3 README
   snippets used `provider:`, so they failed to compile and threw "At least one
   provider ... must be provided" at runtime. Root cause: tsconfig.json excludes
   `src/examples*` (correctly -- they must not ship in dist/), so nothing ever
   type-checked them. Adds tsconfig.examples.json + `npm run typecheck:examples`
   so this can't regress, and makes the runtime error name the right key for
   plain-JS consumers who get no compile error at all.

   Also documents that `walletAddress` is viem's `Address`, so an env-sourced
   address needs an `as \`0x${string}\`` cast -- every example already did this,
   the README never mentioned it.

2. Raw job creation carries no requirement. Only createJobFromOffering /
   createJobByOfferingName post the "requirement" entry; createJob and the three
   hook variants send nothing but `description`. An evaluator on that path gets a
   deliverable with no stated ask. Adds TSDoc on all four creators, a README
   section, and a standalone evaluator quick start -- neither existing quick
   start covered the evaluator role.

3. Duplicate event delivery on restart. hydrateSessions() fires the handler with
   the latest entry of every active job on every start(), by design, so restarts
   replay whatever the job was waiting on. There is no dedup: the check in
   dispatch() is object identity, and JobRoomEntry has no stable id. Documents
   the replay contract and the persistent-dedup pattern it requires, and notes
   that the contract rejecting a redundant complete/reject is a failure path,
   not a deduplication mechanism.

Also fixes the llm/ examples, which passed model: "gemini-3.1-flash-lite-preview"
to a bare `new Anthropic()` client -- a Google model id on the Anthropic API.

Verified: `npm run typecheck` and `npm run typecheck:examples` both clean (the
latter reported 15 errors before this change); createAcpClients error paths and
the evmProvider happy path exercised directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`start()` registers the live entry handler and awaits `transport.connect()`
before `hydrateSessions()`. The stream has to be live first -- otherwise
entries occurring during catch-up are lost outright -- but dispatching them
immediately is worse, because hydration has not built the session yet. Two
deterministic failures came out of that window, both reproducible in a single
process with no restart involved:

  - provider role: the handler ran *twice* for one `job.funded`. The live entry
    dispatched against a session built from no history, then hydration
    re-delivered the same entry as the job's latest. Two submits for one
    funding; for an evaluator, two rulings for one submission.

  - evaluator role: the handler ran *zero* times. A first sighting that isn't
    `job.created` has no role information, so `inferRoles([])` fell back to
    ["provider"] and `shouldRespond` dropped the `job.submitted`. A ruling
    silently never happened. The session also kept 1 of 2 history entries, so
    `toContext()` and `status` were computed on partial history.

Entry identity was the underlying problem: `dispatch` tested membership with
`session.entries.includes(entry)`, a reference compare, and the same logical
entry arrives as a different object on every path that produces it -- an SSE
frame and a `getHistory()` response each parse their own copy.

Fixes:

  - `entryKey()` (new, exported): stable content key for a `JobRoomEntry`,
    since the type carries no server-assigned id.
  - `AcpAgent.start()`: queue live entries while `hydrating` is set, drain after
    hydration in a `finally` so a hydration failure can't strand them.
  - `JobSession.appendEntry()` is idempotent and returns whether the entry was
    new; `hasEntry()` and `mergeEntries()` added. `mergeEntries` sorts by
    timestamp because `status` scans backwards for the newest system event, so
    folding older history in after a newer live entry would walk status
    backwards.
  - `dispatch()`: a first sighting that isn't `job.created` fetches history so
    roles resolve; failure to fetch is logged rather than swallowed and the
    entry is still delivered.
  - `getOrCreateSession()` merges supplied history into an existing session
    instead of discarding it.

Replay across process restarts is unchanged and still deliberate: it is what
lets an agent killed mid-flow pick the job back up. Integrators still need
persistent dedup for that, and the key set here dies with the process by
design.

Adds tests/entryDelivery.test.ts -- 7 cases over fake transports, no framework,
no network. Run via `npm test` (previously a stub that exited 1), type-checked
via `npm run typecheck:tests` with tsconfig.tests.json. Against the previous
commit the suite fails 5/7; the 2 that pass before and after are the ones
asserting preserved behavior (restart replay, the `job.created` path), so the
change is provably narrow. tests/ stays out of dist/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every README code block was extracted and compiled against the packed
tarball's `.d.ts`, the way a reader would use it. Three problems:

  - The evaluator quick start didn't compile. `session.entries.find((e) =>
    e.kind === "message" && ...)` returns `JobRoomEntry`, so `requirement
    ?.content` is a type error. Narrowed with an `e is AgentMessage` predicate
    and added the import block so the snippet stands alone.

  - The Agent Discovery snippet indexed `agents[0].offerings[0]` unguarded.
    That's a compile error under `noUncheckedIndexedAccess` (which this repo's
    own tsconfig sets and `typecheck:examples` enforces), and an empty
    `browseAgents` result is a real runtime case -- src/examples/basic/buyer.ts
    already guards it. Guarded both index reads; the local is now `seller` so it
    no longer collides with the `provider` declared later in the same block.

  - `reason` on `job.completed` / `job.rejected` is typed `string`, but the
    value delivered is the on-chain `bytes32`: the string hex-encoded and
    right-padded, e.g. `"rejected"` arrives as `0x72656a6563746564...0000`.
    Nothing decoded it and nothing said so. Documented the actual behavior, the
    32-byte truncation limit, and a `hexToString(..., { size: 32 })` decode
    (viem is already a dependency). Behavior itself is untouched -- decoding it
    in the SDK would change event payloads for existing consumers.

Also corrects the restart & replay section, which claimed the SDK "does not
dedupe for you" and "deliberately can't". Since the previous commit it does,
within a process, by content key. The cross-restart replay it can't dedupe is
still the integrator's job, so the section now says exactly where that line
falls and why an in-memory set was never the answer for it.

13 of 14 typescript blocks now compile standalone; the exception is a
deliberate one-line property fragment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/acpAgent.ts Outdated
`dispatch` gated the handler on `appendEntry`'s return value:

    const entryIsNew = session.appendEntry(entry);
    if (!entryIsNew && !sessionIsNew) return;
    ...
    await session.fetchJob();
    this.fireHandler(session, entry);

The entry was recorded in the transcript before `fetchJob()` and
`fireHandler()` ran, so a transient `getJob()` failure -- routine while the
observer lags behind chain state -- left it permanently marked "seen". Every
later arrival of the same logical entry hit `entryIsNew === false` and
returned: the live entry drained after a failed hydrate, a reconnect replay,
any of it. The in-process exactly-once path delivered zero times, silently.

`hydrateSessions()` compounded it. Its `await session.fetchJob()` had no
try/catch, so one lagging job threw out of the whole loop and every job after
it in `getActiveJobs()` order lost its hydration replay too.

Being in `entries` is a fact about history, not about the handler. The two are
now tracked separately:

  - `JobSession` gains `deliveredEntryKeys`, `tryClaimDelivery()` and
    `unclaimDelivery()`. The claim is synchronous, so two paths racing the same
    entry can't both win it.
  - `dispatch()` and `hydrateSessions()` claim before the fetch and release on
    failure, leaving a later dispatch or replay free to retry. Hydration's
    failure path `continue`s to the next job instead of aborting the loop.
  - The `job.created` role-swap branch hands the claim from the superseded
    session to its replacement.
  - `appendEntry()` stays idempotent but no longer carries delivery meaning.

Verified against the parent commit by running the two new cases on its source
in a detached worktree: 7/9, with the live-dispatch case asserting 0 !== 1
(handler fired zero times) and the hydration case throwing out of
`hydrateSessions`. The 7 pre-existing cases pass before and after, so the
change is narrow. 9/9 here, `typecheck` and `typecheck:tests` clean.

One narrower hole is left open deliberately. Live dispatch is fire-and-forget,
so if two copies of an entry arrive concurrently *and* the first one's
`fetchJob()` fails, the second returns on the still-held claim before the first
releases it -- zero deliveries, no pending replay. Closing it needs an
in-flight-promise map keyed by entry to serialize duplicate dispatches, which
is a separate change; both triggers fixed here are sequential.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 975145f. Configure here.

Comment thread src/acpAgent.ts
);
continue;
}
this.fireHandler(session, latest);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hydration omits historical delivery claims

High Severity

tryClaimDelivery replaced transcript membership as the in-process dedupe, but hydration only claims the latest history entry. dispatch then treats any other already-known entry as new. Live frames queued during getHistory (or a later job.created session rebuild, which does not copy deliveredEntryKeys) can fire the handler again after the latest event was already replayed.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 975145f. Configure here.

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