Skip to content

Add durable Think submissions API#1511

Merged
threepointone merged 4 commits into
mainfrom
think-durable-submissions
May 12, 2026
Merged

Add durable Think submissions API#1511
threepointone merged 4 commits into
mainfrom
think-durable-submissions

Conversation

@threepointone
Copy link
Copy Markdown
Contributor

@threepointone threepointone commented May 12, 2026

Summary

Adds a durable asynchronous submission API to @cloudflare/think so programmatic callers can enqueue a Think turn, get a durable acceptance response quickly, retry safely, and inspect or cancel the turn later.

This PR introduces:

  • submitMessages(messages, { submissionId, idempotencyKey, metadata }) for durable programmatic turn submission.
  • Submission inspection, listing, cancellation, and cleanup APIs.
  • A small public status model: pending, running, completed, aborted, skipped, and error.
  • Submission lifecycle observability events: submission:create, submission:status, and submission:error.
  • A focused user guide, contributor-facing design doc, changeset, and a new examples/think-submissions demo app.

Why

saveMessages() is intentionally blocking: it injects messages and waits for the model turn to finish. That works for in-process callers, but it creates timeout ambiguity for RPC callers, webhooks, and external jobs. If the caller times out, it cannot tell whether the turn was never accepted, is queued, is running, or already completed. Retrying can duplicate messages or run the same external job twice.

submitMessages() creates a durable acceptance boundary instead: the caller gets back a persisted submission record before inference starts, and can retry by idempotencyKey without appending duplicate messages.

Addresses the core problem in #1479.

Design

The implementation is Think-specific rather than a base Agent primitive because the hard parts are chat semantics:

  • submitted work is serialized UIMessage[]
  • messages are appended to Think's Session store only after the submission is claimed
  • execution uses Think's existing turn queue, inference loop, streaming, cancellation, and chat recovery machinery
  • hibernation recovery must understand Session state and resumable chat evidence

The durable source of truth is a new cf_think_submissions SQLite table. Rows are drained FIFO with an idempotent scheduled wakeup, and indexed for queue, request lookup, and terminal cleanup paths.

Important invariants:

  • A row is inserted before submitMessages() returns accepted: true.
  • Submitted messages are appended to Session only after a row moves from pending to running.
  • messages_applied_at is set only after Session append succeeds, and acts as the replay safety boundary.
  • Startup recovery requeues unapplied running submissions, but never replays if any submitted message is already present in Session.
  • Pending submissions are synchronously marked skipped during turn reset so they cannot be claimed after reset.
  • Terminal states are protected by conditional updates so late stream or recovery completions cannot overwrite aborted or skipped.
  • AbortSignal is intentionally not part of submitMessages(); durable cancellation goes through cancelSubmission(submissionId).

Docs And Example

This PR adds:

  • docs/think/programmatic-submissions.md for user-facing API guidance.
  • design/think-durable-submissions.md for contributor-facing rationale, state transitions, failure boundaries, recovery rules, and operational guidance.
  • Updates to server-driven messages, webhooks, workflows, Think index/client-tools docs to distinguish submitMessages() from saveMessages() and workflows.
  • examples/think-submissions, a focused demo of durable submission, idempotent retry, status inspection, cancellation, cleanup, and chat status visualization.

Test Coverage

Adds packages/think/src/tests/submissions.test.ts with coverage for:

  • fast durable acceptance and normal completion
  • idempotent retries by key and submission id
  • concurrent first submissions with the same idempotency key
  • conflicting submissionId/idempotencyKey pairs
  • empty submission rejection before persistence
  • FIFO draining
  • pending and running cancellation
  • turn reset skipping pending submissions
  • startup recovery for unapplied, applied, partially applied, and malformed durable rows
  • chat recovery continuation behavior
  • cancellation during active recovered continuation
  • list/delete filtering, limits, cutoffs, and active-row protection
  • status hook durability and submission lifecycle logs

Also extends observability tests for submission:* event routing.

Validation

Ran the following successfully:

  • npm run test -w @cloudflare/think -- src/tests/submissions.test.ts — 29 passed
  • npm run test -w @cloudflare/think — 325 passed
  • npm run build -w @cloudflare/think
  • npm run check — exports, formatting, oxlint, and typecheck across 82 projects
  • npm run test — full workspace test suite
  • npx vite build in examples/think-submissions

Made with Cursor


Open in Devin Review

Introduce submitMessages() for Think so RPC callers can durably accept a programmatic chat turn without waiting for model execution to finish. The new API persists a submission row before execution, supports idempotent retries through idempotencyKey, exposes inspection/list/cancel/delete helpers, and emits submission lifecycle observability events.

The implementation uses cf_think_submissions as a SQLite-backed ledger and an idempotent scheduled drain as the wakeup mechanism. Submissions move through a small pending/running/terminal state machine, append messages to Session only after being claimed, and use messages_applied_at as the replay boundary so hibernation recovery never duplicates already-applied messages. Pending submissions are synchronously skipped during turn reset, terminal states are protected with conditional updates, and recovered chat continuations stay running until they reach a terminal outcome.

Add focused coverage for fast acceptance, idempotent retries, FIFO draining, cancellation, reset races, startup recovery, chat recovery, malformed durable rows, cleanup filters, and recovered-continuation cancellation. Also add user docs, a contributor-facing design doc, a changeset, observability tests, and a dedicated think-submissions example that demonstrates durable submission, retry, status inspection, cancellation, and cleanup flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 12, 2026

🦋 Changeset detected

Latest commit: ae9a7f6

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cloudflare/think Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

View 6 additional findings in Devin Review.

Open in Devin Review

Comment thread packages/think/src/think.ts Outdated
doneSent = true;
} catch (error) {
streamError = error instanceof Error ? error.message : "Stream error";
this._programmaticStreamErrors.set(requestId, streamError);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Memory leak: _programmaticStreamErrors entries never cleaned up for non-submission callers of _streamResult

_streamResult sets this._programmaticStreamErrors.set(requestId, streamError) in its catch block (line 3886), but this map is only cleaned up inside _runSubmission's finally block (line 2817). _streamResult is called from 5 different paths — submissions, saveMessages, continueLastTurn, WebSocket chat requests, and auto-continuation — but only the submission path removes entries. For the other 4 callers, each stream error adds a (UUID, errorMessage) entry that is never deleted, accumulating over the DO isolate's lifetime.

Prompt for agents
The _programmaticStreamErrors map is written to inside _streamResult (a shared method called by all chat paths) but only cleaned up inside _runSubmission (the submission-specific path). This means entries accumulate forever for non-submission stream errors (saveMessages, continueLastTurn, WebSocket chat, auto-continuation).

Approach 1: Instead of setting the error in the shared _streamResult method, capture the stream error at the _runSubmission call site. _runSubmission already has access to the result object. You could have _streamResult return or throw the error information, and let _runSubmission capture it locally without using a shared map at all.

Approach 2: If the shared map approach is retained, clean up entries at every call site that invokes _streamResult, not just _runSubmission. This would mean adding cleanup in _runProgrammaticMessagesTurn, the WebSocket chat handler, and the auto-continuation path.

Approach 1 is cleaner since it avoids the shared mutable state entirely.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread examples/think-submissions/index.html Outdated
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.svg" />
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Example uses favicon.svg instead of required favicon.ico per examples/AGENTS.md

The examples/AGENTS.md required structure specifies public/favicon.ico and the index.html template uses <link rel="icon" href="/favicon.ico" />. The new think-submissions example uses favicon.svg (examples/think-submissions/index.html:6) and has a public/favicon.svg file instead. This deviates from the documented convention that the majority of examples (25 out of 28) follow.

Suggested change
<link rel="icon" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 12, 2026

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@1511

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@1511

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@1511

hono-agents

npm i https://pkg.pr.new/hono-agents@1511

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@1511

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@1511

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@1511

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@1511

commit: ae9a7f6

threepointone and others added 3 commits May 12, 2026 18:45
Only durable submissions need to capture stream errors in the programmatic stream error map so their terminal row can be marked as error after _streamResult returns. Other callers such as saveMessages, WebSocket chat turns, continuations, and auto-continuation already handle stream errors through the normal response hook path and should not leave request-scoped entries behind for the isolate lifetime.

Scope _programmaticStreamErrors writes behind an explicit capture option used by _runSubmission, and add regression coverage that a non-submission stream failure does not retain an entry. Also align the think-submissions example with the examples directory convention by using public/favicon.ico from the standard example favicon instead of a custom favicon.svg.

Co-authored-by: Cursor <cursoragent@cursor.com>
A captured programmatic stream error should only turn an otherwise completed submission into an error. If the underlying programmatic turn reports aborted or skipped, those explicit terminal outcomes must win even when abort/reset also surfaced as a stream iterator error.

Factor submission final status selection into a helper, clear error_message for non-error terminal states, and add regression coverage for completed+error, aborted+error, and skipped+error precedence.

Co-authored-by: Cursor <cursoragent@cursor.com>
Keep the stale-evidence safety net for recovered durable submissions, but expose the recovery freshness window as a protected static setting that Think subclasses can tune for legitimate long-running turns. The default remains 15 minutes, preserving the existing behavior for normal agents while avoiding a hardcoded limit for providers or workloads that can validly run longer before a Durable Object restart.

Update the recovery check to read submissionRecoveryStaleMs from the concrete subclass, document the override point in the durable submissions design doc, and add coverage proving that an older recoverable fiber remains running when a subclass extends the stale window.

Co-authored-by: Cursor <cursoragent@cursor.com>
@threepointone threepointone merged commit bf3860c into main May 12, 2026
4 checks passed
@threepointone threepointone deleted the think-durable-submissions branch May 12, 2026 18:10
@github-actions github-actions Bot mentioned this pull request May 12, 2026
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