Skip to content

feat(eve): workflow hooks - adopt ownership checks#232

Open
AndrewBarba wants to merge 2 commits into
mainfrom
barba/workflow-hook-ownership
Open

feat(eve): workflow hooks - adopt ownership checks#232
AndrewBarba wants to merge 2 commits into
mainfrom
barba/workflow-hook-ownership

Conversation

@AndrewBarba

Copy link
Copy Markdown
Collaborator

Summary

  • claim continuation hooks with getConflict() before the first agent turn
  • claim replacement hooks before releasing the active hook during rekeying
  • atomically resume deliveries and report the owner from resumeHook()
  • add unit, integration, production-bundle, documentation, and changeset coverage

Why

Continuation hooks use shared tokens, but eve previously relied on delayed hook registration and a separate owner lookup during delivery. That left ownership conflicts to surface after work could begin and introduced a lookup-to-resume race.

This adopts the Workflow SDK's ownership check as an explicit barrier. A competing run now fails before performing an agent turn, while the existing owner remains active and resumable. Tokenless sessions retain their existing behavior and claim ownership after the first turn establishes a token.

Validation

  • pnpm build
  • pnpm typecheck
  • pnpm lint
  • pnpm fmt
  • pnpm guard:invariants
  • pnpm docs:check
  • pnpm test:unit — 3,884 passed
  • pnpm test:integration — 369 passed
  • pnpm test:scenario — 261 passed, 15 skipped
  • pnpm exec eve eval --strict from e2e/fixtures/agent-tools was attempted, but the environment has neither AI_GATEWAY_API_KEY nor VERCEL_OIDC_TOKEN, so its 15 model-backed evals could not authenticate

Signed-off-by: Andrew Barba <barba@hey.com>
@vercel

vercel Bot commented Jun 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
eve-docs Ready Ready Preview, Comment, Open in v0 Jun 24, 2026 2:21am

@github-actions

Copy link
Copy Markdown
Contributor

Bundle + Package Summary: apps/fixtures/weather-agent

Key takeaways

  • No notable deltas vs main (9298c90).

Delta vs main (9298c90)

Area Metric Baseline Current Delta
Package Packed tarball 3.40 MB 3.40 MB +361 B ⚠️
Package Unpacked publish size 12.40 MB 12.40 MB +1.7 kB ⚠️
Package Installed footprint 52.60 MB 52.60 MB +1.7 kB ⚠️
Package Published files 2283 2285 +2
Package Installed files 5488 5490 +2
Runtime Unique function payloads 2 2 0
Runtime Total function bytes 9.37 MB 9.38 MB +6.3 kB ⚠️
Runtime Public routes 9 9 0
Changed function payloads vs main (9298c90) (2)
Function Status Baseline Current Delta Route changes
functions/.well-known/workflow/v1/flow.func changed 5.50 MB 5.50 MB +6.5 kB ⚠️ none
functions/__server.func changed 3.88 MB 3.88 MB -209 B ✅ none
Build Metadata
  • Preset: vercel
  • Nitro: nitro@3.0.260610-beta
  • Output directory: apps/fixtures/weather-agent/.vercel/output
  • Build metadata timestamp: 2026-06-24T02:21:30.069Z
  • Route aliases: 9 public, 1 internal (10 total aliases)
  • Vercel routes in config: 10
  • Severity legend: 🔴 dominant/large, 🟠 notable, 🟡 watch, ⚪ small
Package Drill-Down

Package Details

  • Package: eve@0.13.3
  • Package directory: packages/eve
  • Tarball: 3.40 MB (eve-0.13.3.tgz)
  • Unpacked payload: 12.40 MB across 2285 published files
  • Installed footprint: 52.60 MB across 5490 installed files
  • Installed root package: 11.13 MB
  • Installed dependencies: 41.48 MB
  • Runtime dependencies: 1
  • Peer dependencies: 12 (11 optional)

Installed footprint is measured from an isolated temporary npm install of the packed tarball.

Heavy installed dependencies

  • @rolldown/binding-linux-x64-gnu: 20.61 MB (39.2%)
  • eve: 11.13 MB (21.2%)
  • ai: 6.20 MB (11.8%)
  • zod: 5.04 MB (9.6%)
  • nitro: 2.41 MB (4.6%)
Publish payload breakdown
Published file size
🟠 dist/src/compiled/experimental-ai-sdk-code-mo... [####....................] 1.51 MB 12.1%
🟡 dist/src/compiled/@workflow/core/runtime.js      [##......................] 788.4 kB 6.4%
🟡 dist/src/compiled/@vercel/sandbox/index.js       [##......................] 632.0 kB 5.1%
🟡 dist/src/compiled/@chat-adapter/slack/index.js   [#.......................] 438.4 kB 3.5%
🟡 dist/src/compiled/_chunks/workflow/attribute-... [#.......................] 371.6 kB 3.0%
🔴 Other published files                            [########################] 8.66 MB 69.9%
Installed footprint breakdown
Installed package size
🔴 @rolldown/binding-linux-x64-gnu [########################] 20.61 MB 39.2%
🔴 eve                             [#############...........] 11.13 MB 21.2%
🔴 ai                              [#######.................] 6.20 MB 11.8%
🔴 zod                             [######..................] 5.04 MB 9.6%
🟠 nitro                           [###.....................] 2.41 MB 4.6%
🟡 rolldown                        [#.......................] 771.0 kB 1.5%
🔴 Other installed packages        [########................] 6.46 MB 12.3%
Runtime dependencies (1)
Package Range Notes
nitro 3.0.260610-beta
Peer dependencies (12)
Package Range Notes
@opentelemetry/api ^1.0.0 optional peer
@sveltejs/kit ^2.0.0 optional peer
ai catalog:
braintrust ^3.0.0 optional peer
just-bash ^3.0.0 optional peer
microsandbox ^0.5.0 optional peer
next ^16.0.0 optional peer
nuxt ^4.0.0 optional peer
react ^19.0.0 optional peer
svelte ^5.0.0 optional peer
vite ^8.0.0 optional peer
vue ^3.5.0 optional peer
Function Drill-Down

Payload Size Graph

Unique function payload size and share of total
🔴 functions/.well-known/workflow/v1/flow.func     [########################] 5.50 MB 58.7%
🔴 functions/__server.func                         [#################.......] 3.88 MB 41.3%

Top Function Payloads

🟠 functions/.well-known/workflow/v1/flow.func • 1 public route • 5.50 MB
Metric Value
Public routes /.well-known/workflow/v1/flow
Runtime nodejs24.x
Handler index.mjs
Payload 5.50 MB
Function files 5.50 MB across 27 files
Traced dependencies 0 B
Signal 🟠 Bundled file __eve_nitro_handler__.mjs is 1.52 MB (27.5%)

🟠 🔎 Dependency Analysis

📦 Bundled files:

Bundled file size
🟠 __eve_nitro_handler__.mjs              [########################] 1.52 MB 27.5%
🟠 _chunks/runtime.mjs                    [###############.........] 975.8 kB 17.7%
🟡 _chunks/sandbox.mjs                    [############............] 766.0 kB 13.9%
🟡 _chunks/attribute-changes-DUxG-Gic.mjs [#######.................] 473.2 kB 8.6%
🟡 _chunks/dist-DTchiX0N.mjs              [#######.................] 460.6 kB 8.4%
🟠 Other bundled files                    [#####################...] 1.31 MB 23.8%

🧾 Vercel Config

{
  "handler": "index.mjs",
  "launcherType": "Nodejs",
  "shouldAddHelpers": false,
  "supportsResponseStreaming": true,
  "runtime": "nodejs24.x",
  "environment": {
    "NODE_OPTIONS": "--experimental-require-module",
    "WORKFLOW_QUEUE_NAMESPACE": "eve"
  },
  "maxDuration": "max",
  "experimentalTriggers": [
    {
      "type": "queue/v2beta",
      "topic": "__eve_wkf_workflow_*",
      "consumer": "default",
      "retryAfterSeconds": 5,
      "initialDelaySeconds": 0
    }
  ]
}

🟠 functions/__server.func • 8 public routes, 1 internal alias • 3.88 MB
Metric Value
Public routes /
/eve/v1/callback/[token]
/eve/v1/connections/[name]/callback/[token]
/eve/v1/health
/eve/v1/info
/eve/v1/session
/eve/v1/session/[sessionId]
/eve/v1/session/[sessionId]/stream
Internal aliases /__server
Runtime nodejs24.x
Handler index.mjs
Payload 3.88 MB
Function files 3.88 MB across 21 files
Traced dependencies 0 B
Signal 🟠 Bundled file index.mjs is 1.40 MB (36.2%)

🟠 🔎 Dependency Analysis

📦 Bundled files:

Bundled file size
🟠 index.mjs                              [########################] 1.40 MB 36.2%
🟠 _chunks/runtime.mjs                    [###############.........] 883.8 kB 22.8%
🟠 _chunks/sandbox.mjs                    [#############...........] 766.0 kB 19.8%
🟡 _chunks/attribute-changes-DUxG-Gic.mjs [########................] 448.9 kB 11.6%
⚪ _libs/zod.mjs                          [##......................] 114.2 kB 2.9%
🟡 Other bundled files                    [####....................] 258.8 kB 6.7%

🧾 Vercel Config

{
  "handler": "index.mjs",
  "launcherType": "Nodejs",
  "shouldAddHelpers": false,
  "supportsResponseStreaming": true,
  "runtime": "nodejs24.x"
}

@AndrewBarba AndrewBarba marked this pull request as ready for review June 24, 2026 02:22
@AndrewBarba AndrewBarba requested review from ctgowrie and pranaygp June 24, 2026 02:22

@pranaygp pranaygp 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.

Reviewed the full diff and traced the design against the actual Workflow SDK (@workflow/core@5.0.0-beta.24, @workflow/world@5.0.0-beta.13) by reading the published .d.ts. No blocking issues — this is a clean, well-tested change and the semantics line up precisely with the SDK contract.

What I verified:

  • getConflict() resolves with the conflicting Run (.runId: string) or null once registration is committed, and only rejects with HookConflictError in the rare old-world case where the owner id was never recorded. claimHookOwnership handles both, and Run.runId is a plain string, so conflict.runId is sound.
  • resumeHook() returns Promise<Hook> and the world Hook carries runId: string, so collapsing getHookByToken + resumeHook into a single call is correct and removes the lookup-to-resume race. HookNotFoundError is still thrown by resumeHook, so the RuntimeNoActiveSessionError normalization holds.
  • claim-before-release ordering in rekeyHook, the consolidated try/finally cleanup, and the moved closeHookIterator/disposeHook helpers all check out. The integration test exercises the real local world end-to-end (contender fails before any turn work; owner stays resumable).

A few non-blocking notes left inline (mostly test-coverage / readability). One out-of-diff cleanup: getHookByToken is no longer used by any production code, but packages/eve/src/execution/workflow-steps.test.ts:108 still mocks it — vestigial, safe to drop in a follow-up.

Note: I reviewed statically and could not execute the suites in this worktree (no installed deps); relying on the author's reported green runs + CI.

try {
const hook = normalizeWorkflowHook(await getHookByToken(input.continuationToken));
await resumeHook(input.continuationToken, hookPayload);
const hook = normalizeWorkflowHook(await resumeHook(input.continuationToken, hookPayload));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nice — folding getHookByToken + resumeHook into one call removes the lookup-to-resume race. ✅ Confirmed against @workflow/core@5.0.0-beta.24: resumeHook returns Promise<Hook>, and the @workflow/world Hook carries runId: string, so hook.runId is sound.

Non-blocking: this return contract is now load-bearing for deliver() but is only asserted via a mock (resumeHookMock.mockResolvedValue({ runId })) in workflow-runtime.test.ts. The integration tier drives resumeHook directly rather than through runtime.deliver, so the real round-trip of deliver() → owning runId isn't covered against the local world. An integration assertion would lock this cross-package contract down so a future SDK bump can't silently break it.

// Claim the replacement before releasing the current token. A failed
// claim leaves the active hook intact until normal failure cleanup.
const nextHook = createHook<HookPayload>({ token: nextToken });
await claimHookOwnership(nextHook);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

getConflict() is a workflow suspension point — per the SDK docs, awaiting it "suspends the workflow to commit the hook registration." Since rekeyHook now runs before the first turn (for tokened sessions) and on every token change, channels that re-key per turn (e.g. Slack thread tokens) pay one extra durable commit per re-key versus the old synchronous createParkHook. That's the intended claim-before-release tradeoff — just flagging to confirm it was weighed for high-re-key channels.

try {
await closeParkHook();
} catch (error) {
await disposeHook(nextHook);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This branch — claim succeeded but releasing the old hook threw — doesn't appear to be covered. The sibling failures are (conflicting claim before first turn; conflicting rekey candidate disposed before touching the active hook), but a throwing closeParkHook() after a successful claim isn't.

Also worth a look: if closeParkHook() throws partway (e.g. closeHookIterator(iterator) throws), it leaves hook/iterator still pointing at the old hook, so the outer finally will call closeParkHook() again → a second disposeHook(hook) on the old hook. Harmless if the SDK's dispose is idempotent, but worth a test or a best-effort note here.

throw error;
}

function normalizeHookClaimError(error: unknown, token: string): unknown {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Per the SDK, getConflict() only rejects with HookConflictError in the rare old-world case where the owning run id was never recorded — i.e. whenever this catch path runs, conflictingRunId is by definition absent, so the normalized error will always have conflictingRunId: undefined (which the test confirms). The field names are correct (the SDK's HookConflictError does carry token/conflictingRunId), so this is fine as defensive code — a one-line comment noting "this path implies an unidentifiable owner" would save the next reader a trip through the SDK source to understand why the run id is dropped.

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.

2 participants