Skip to content

feat(streams): durable incremental output as a Lifecycle capability (agents/streams) - #2173

Open
mattzcarey wants to merge 28 commits into
mainfrom
feat/streams-capability
Open

feat(streams): durable incremental output as a Lifecycle capability (agents/streams)#2173
mattzcarey wants to merge 28 commits into
mainfrom
feat/streams-capability

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Durable execution for Lifecycle Objects: the Tasks capability (agents/tasks, replayable durable work), the Streams capability (agents/streams, durable incremental output), and the replatform of chat's in-flight streaming (AIChatAgent, Think) onto them. Supersedes #2168 — this PR carries that work, rebased onto the Lifecycle work-queue rework (#2175) and the WebSockets capability extraction (#2169).

Tasks (agents/tasks)

Durable, replayable execution owned by one capability instance:

readonly tasks = new Tasks({
  definitions: {
    "generate@v1": async (input: GenerateInput, step: TaskStep) => {
        const a = await step.do("fetch", () => fetchIt(input));       // journaled
        await step.sleep("cool-off", "30 seconds");                    // durable
        return step.do("write", { retries: { limit: 5 } }, () => write(a));
      }
  }
});
readonly lifecycle = Lifecycle.install(this).use(this.tasks);

Replay-from-top with journal memoization, first-deadline-authority sleeps, per-step retry policy with backoff, cooperative cancellation, idempotency keys, and generation fencing against concurrent claims. There is no separate recovery mode: an unclean interruption replays the handler, and replay safety comes from step idempotency keys (external writes deduplicate) and durable evidence read at the top of the work — a producer that starts at stream.cursor resumes instead of redoing. A task:attempt:interrupted event carries the step a lost attempt left mid-execution. (An earlier revision shipped a { run, recover } callback surface; it was removed after using it end-to-end showed replay-plus-evidence covers every consumer — the RFC records the reasoning.)

Queue integration (per #2175): every non-terminal run's authoritative next_at is mirrored as one Lifecycle queue job (id = run id, so a retime is a same-id push); wakes dispatch through onJob, whose outcome is derived from the run row after execution — the single source of truth. No capability touches the physical alarm; interrupted runs' overdue mirror jobs re-fire on the post-restart alarm derivation, which is the whole recovery trigger.

Agent integration: Agent installs readonly tasks with a declare readonly taskDefinitions typing surface, and Think/AIChatAgent run their chat turns and messenger replies as internal task definitions — the recovery engines unchanged, now entered when a replayed turn finds its live closure gone. The legacy runFiber engine remains for facet turns and existing users.

Streams (agents/streams)

Durable incremental output: an ordered chunk log per stream with a monotonic cursor.

readonly streams = new Streams();
readonly lifecycle = Lifecycle.install(this).use(this.streams);

const stream = await this.streams.open("reply:123", { tag: requestId });
stream.append(chunk);   // synchronous durable write; wakes live readers
stream.close();         // or stream.error(reason)

for await (const batch of this.streams.readBatches("reply:123", { from, onUpToDate })) { ... }
return sseResponse(this.streams, "reply:123", { request });  // SSE with Last-Event-ID resume

open() is idempotent on the id; reads replay from any cursor then tail live appends, independent of producer liveness; status() reports state and cursor — the durable evidence a replayed Task handler resumes from. Tags are indexed, deliberately non-unique lookup keys ("latest stream of this operation"). sseResponse serves the whole lifecycle in one call: each chunk's seq rides the SSE id: field so a reconnecting EventSource resumes via Last-Event-ID with zero client code, with up-to-date/done/error control events. The capability has no time-based behavior — appends happen in the producer's invocation, readers wake by append or reconnect — so it pushes zero jobs and works on facets.

Composition contract: a task step appends to a stream it does not own and starts its loop at stream.cursor, so a replay after interruption is a resume — the stream itself is the interruption evidence. Neither capability imports the other.

The chat/think replatform

ResumableStream — the store behind resumable chat streaming — is now a thin adapter over Streams: chat's in-flight output lives in the shared chunk log (packed ~10 wire chunks per stored segment for write economy), completion/error map onto settlement, retention keys off the stream row's updated_at (sweeps never scan the chunk table), and legacy cf_ai_chat_stream_* tables migrate wholesale — an in-flight stream survives the upgrade — then drop. The wire protocol, replay handshake, and recovery behavior are unchanged; both hosts expose the backing capability as readonly streams.

Storage-op accounting (benchmarked in-suite on real DO SQLite, 20 turns × 100 chunks): legacy pattern 240 rows written, replatformed adapter 440 (the append fence per segment — buying settled-write rejection, the cursor, and updated_at), naive per-chunk 4040. Retention sweeps drop from 239 rows read to 40 per pass. The benchmark asserts the model so regressions fail CI.

Structure

  • agents/tasks is decomposed: tasks.ts (state machine, ~900 lines), store.ts (tables, fenced writes, snapshot projection), engine-port.ts (the step-engine port), replay.ts (ReplayStep), plus types/errors/serialization/duration. agents/streams is streams.ts + sse.ts + types/errors.
  • The interrupted-step evidence is first-class: step.interrupted ({ name, attempt } | null) — handlers branch on it instead of querying engine tables; a task:attempt:interrupted event mirrors it.
  • The chat-turn Task definition lives once, in agents/chat (createChatTurnTaskDefinition); AIChatAgent and Think wire their protected internals through a narrow hooks contract. Replay wire-framing is one module (replay-frames.ts).
  • The Streams internal sync aperture is fully typed — no raw SQL crosses the capability boundary; chat streams carry their request id as the indexed tag, and the legacy-table migration reads chat's own tables through the host's sql.
  • Queue-mirror maintenance lives inside the settle/park helpers, so a state transition cannot forget its wake sync.

Verification (all real DOs, no fakes)

  • Capability suites drive real Durable Objects through real Lifecycles: standalone and composed fixtures for both capabilities, replay/retry/cancellation matrices, seeded-interruption replay-resume proofs, DDL snapshots.
  • Real SIGKILL e2es: task runs and stream producers killed mid-flight and recovered on restart; ai-chat's recovery e2e suite (11 tests) passes on the replatformed store.
  • Parity ratchet with unchanged assertions: think 887/887 (+2 react), ai-chat 737/737, agents full workers suite green, plus root typecheck (117 projects), sherif, exports, format, lint.
  • Design records: design/rfc-fibers.md, design/rfc-streams.md, design/alarm-coordination.md (updated for the queue model).

Deliberately deferred

  • Producer epoch fencing on open() and sliding-TTL retention in Streams (both need design; retention is one queue job when built).
  • Migrating chat's turn-end history writes onto a Session integration; transport helpers beyond SSE.
  • runFiber/startFiber deprecation — awaits migration evidence.

One Fibers capability per Lifecycle Object owns named definitions created
with fibers.create(name, run). Runs replay from the top on every attempt:
journaled step results, durable sleeps with first-deadline authority,
per-step retry/timeout policy, a status live gate, cooperative cancellation,
and generation-fenced claims. Deadlines live in cf_fiber_runs.next_at and
reach the shared physical alarm only through the Lifecycle alarm-contribution
model, exactly as the Scheduler does.
FiberHarnessObject drives the capability with real Lifecycle startup, real
SQLite, and real platform alarms; instance counters separate real step
execution from journal hits to prove replay memoization. Covers acceptance
dedup, retry parking, interrupted-attempt reclaim from a seeded dead
generation, sleep deadline authority, the status live gate, cancellation,
timeouts, divergence, missing definitions, Scheduler alarm coexistence, and
the bounded alarm batch.
Replace imperative fibers.create() handles and the startup registry lock
with a Scheduler-style constructor definitions map. The map is the registry,
rebuilt on every wake, so recovery of in-flight runs is correct by
construction — nothing to register at the right moment and no lock to trip
over. Runs start with the typed fibers.run(name, input, options);
fibers.handle(name) is a pure typed lens scoped to one definition; framework
internals attach through an internal composition-root resolver aperture
mirroring the Scheduler's callback-name resolver. Also adds the
examples/next/fibers example (smoke-tested end to end under wrangler dev)
and records the amendment in the RFC.
A definition may pair its handler with a recover callback that owns unclean
interruptions instead of automatic replay. It receives the run input,
metadata, and the interrupted step — name, attempt, stable idempotency key,
and the last checkpoint() the lost attempt wrote through its step attempt
context — and decides replay (immediate or deferred), complete, fail, or
cancel. Clean step failures never reach recovery; the retry policy owns
them. Recovery runs under its own 'recovering' claim with the same
generation fencing and claim-deadline backstop, survives its own
interruption (callbacks must be idempotent), and a throwing recover retries
on exponential backoff with a bounded budget before the run fails. The
internal composition-root resolver accepts { run, recover } entries so
framework definitions (the Think replatform) get the same recovery seam.
… messengers

Agent installs the capability automatically as experimental this.fibers.
Subclass definitions go on the overridable fiberDefinitions field (rebuilt
every wake, resolved lazily so field order never matters); framework
definitions attach through a composition-root aperture mirroring the
Scheduler's callback resolver; handlers run in the Agent's invocation
boundary; and due runs dispatch at the same startup point as the legacy
fiber scan — before the user's onStart.

Think chat turns, AIChatAgent chat turns, and Think messenger replies now
execute on the capability: each live closure runs as one journaled step in
the caller's invocation context with checkpoint-backed stash(), and an
unclean interruption synthesizes the legacy FiberRecoveryContext and routes
through the unchanged _handleInternalFiberRecovery -> ChatRecoveryEngine
seam (messenger recovery likewise, with re-entry checkpoints persisted in
host storage). The recovery brain did not move, which is why the full think
suite passes untouched: agents 1,938/1,938, think 887/887, ai-chat 655/655.

Legacy runFiber()/startFiber() user APIs are unchanged and still recovered
by their own scan; facet-hosted turns stay on the legacy engine until
routed Fibers land.
…ll e2e

Split the workers-pool harness so Fibers is proven both ways:
FiberHarnessObject now installs Fibers as its ONLY capability, and the new
FiberSchedulerCoexistObject installs Fibers and the Scheduler together for
the shared-alarm arbitration test.

Add fibers-capability-eviction.test.ts on the existing e2e kill harness:
real wrangler dev with persisted state, SIGKILL mid-step, restart. Step
executions are recorded in the host's own SQLite, proving completed steps
replayed from the journal without re-executing while the interrupted step
ran again; the { run, recover } variant proves the recovery callback
receives the interrupted step and its last checkpoint() across a real
process death and settles the run by decision.
Rename the new durable-execution capability from Fibers to Tasks:
agents/fibers -> agents/tasks, class Tasks, taskDefinitions on Agent,
Task* types/errors, task:* events on a new agents:task diagnostics channel,
and cf_agents_task_runs / cf_agents_task_steps tables (all unreleased).
'Fiber' now unambiguously means the legacy engine (runFiber/startFiber and
their vocabulary, all untouched) — the rename removes the two-meanings
problem the constructor-map redesign created, and lines up with the future
MCP Tasks adapter story. Fixtures, suites, the kill e2e, docs, the example,
and the RFC record all follow; the Agent schema DDL snapshot now includes
the capability's tables.

Add design/rfc-streams.md (proposed): a Streams capability owning the
durable chunk log, cursor, and replay-then-tail reads — the incremental-
output half of the pattern the Think migration validated — composed with
Tasks through checkpointed cursors and status() evidence, populated by
extracting chat's resumable-stream store.

Verified on the renamed engine: agents 1,938/1,938, think 887/887, ai-chat
655/655, tasks SIGKILL e2e 2/2.
One Streams instance per Durable Object owns an ordered, durable chunk log
per stream with a monotonic cursor: idempotent open(), synchronous durable
append() that wakes live readers, close()/error() settlement (no-op when
already terminal, so recovery callers stay idempotent), replay-then-tail
read({ from, signal }), and status() reporting state and cursor. Reads are
independent of producer liveness; the capability consumes only storage and
events — no alarm — so it also works on facets.

This is the incremental-output half of the pattern the Tasks migration
validated, composed without coupling: a task step appends to a stream and
checkpoints { streamId, cursor }, and its recover callback reads
streams.status() as durable interruption evidence. Producers that resume
from stream.cursor never duplicate a chunk.

Proven on real DOs in both fixture shapes (StreamHarnessObject standalone,
TaskStreamComposeObject composed, 13 tests) and across a real SIGKILL
(streams-capability-eviction e2e): the chunks appended before death survive
exactly, and recovery finalizes the stream at precisely that cursor. Ships
with examples/next/streams (SSE serving with cursor reconnects),
docs/agents/streams.md, and the accepted design/rfc-streams.md. The chat
resumable-stream migration follows separately with the chat suites as the
parity ratchet.
@changeset-bot

changeset-bot Bot commented Aug 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9f40fdf

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

This PR includes changesets to release 4 packages
Name Type
agents Patch
@cloudflare/ai-chat Patch
@cloudflare/think Patch
@cloudflare/agent-think Patch

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

devin-ai-integration[bot]

This comment was marked as resolved.

@pkg-pr-new

pkg-pr-new Bot commented Aug 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

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

@cloudflare/ai-chat

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

@cloudflare/codemode

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

hono-agents

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

@cloudflare/shell

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

@cloudflare/think

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

@cloudflare/voice

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

@cloudflare/worker-bundler

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

commit: 9f40fdf

readBatches(streamId, { from, signal, batchSize }) yields non-empty arrays
of consecutive chunks with the same lifecycle as read(): replay yields up
to batchSize chunks per array (default 100), and a live tail yields
everything that accumulated since the last wakeup as one array — so a
consumer paying per write (an SSE flush, an RPC hop, a history append)
pays once per backlog, not once per chunk.

read() now delegates to readBatches with per-chunk abort checks, so the
existing suite exercises the shared core; two new tests pin the batch
boundaries (batchSize slicing from a cursor, one-array-per-wakeup
coalescing on a live tail).
…ility

ResumableStream becomes chat's producer-side coalescing and wire-protocol
adapter over agents/streams: in-flight turn output lives in the shared
durable chunk log (cf_agents_streams / cf_agents_stream_chunks), written
through the capability's fenced append — which also wakes live
streams.read() consumers and emits agents:stream events — with
completion/error mapped onto stream settlement. The packed-segment write
policy (~10 wire chunks per stored chunk) stays in the adapter, so write
economy is preserved; retention keeps its 10m/1h windows but keys off the
stream row's updated_at, so sweeps never scan the chunk table. Legacy
cf_ai_chat_stream_* tables (both on-disk generations) migrate wholesale on
first construction — an in-flight stream keeps its id, chunks, and
last-activity across the upgrade — then are dropped.

The adapter's surface is synchronous and constructed before the Lifecycle
starts, so it runs on a loud-named internal sync aperture
(Streams.__DO_NOT_USE_WILL_BREAK__sync()) whose invariant-bearing writes go
through the same private methods as the public API. StreamStatus gains
updatedAt (last write activity). AIChatAgent, Think, and the experimental
recovery agents install the backing capability as readonly streams
(createChatStreams() raises maxChunkBytes for packed segments); the
recovery engines' stream-evidence lookups move from raw legacy-table SQL
onto adapter methods.

A storage-ops benchmark (storage-ops-bench.test.ts, real DO SQLite via
total_changes()) pins the cost model: packed adapter writes 440 rows vs
the legacy pattern's 240 (the fence per segment) and 4040 for naive
per-chunk appends, with retention-sweep reads down from 239 to 40 and no
longer proportional to stored chunks.

Parity ratchet, assertions unchanged: think 887/887 (+2 react), ai-chat
737/737 and e2e 11/11 (real wrangler SIGKILL recovery on the new store —
also fixes the nightly-only hasFiberRows helper stale since the chat turns
moved to the Tasks capability), agents 1954/1954.
- open(id, { tag }): an indexed, deliberately non-unique application lookup
  key, fixed at creation (a reopen naming a different tag throws — config
  conflict, not resume). list({ tag }) composes with the state filter,
  newest first, so 'latest stream of this operation' is
  list({ tag, limit: 1 })[0]. Part of the initial schema — the tables are
  unreleased, so no migration.
- readBatches onUpToDate: fires once when the reader first reaches the
  durable tail. Caught-up is distinct from ended — a live stream is up to
  date while tailing. Useful to flush replayed UI or flip a live indicator.
- sseResponse(streams, id, { request }): one-call SSE serving. Each chunk's
  seq rides the SSE id: field, so a reconnecting EventSource resumes via
  Last-Event-ID with zero client code (?from= works too); control events
  mark up-to-date and done/error (carrying the recorded reason); heartbeat
  comments keep idle proxies alive; request.signal aborts the tail; 404 for
  missing streams. examples/next/streams now serves through it.

Producer epoch fencing and sliding-TTL retention stay named follow-ups;
both need design.
Merges the Lifecycle work-queue rework (#2175) and the WebSockets
capability extraction (#2169), porting this branch's capabilities to the
new pattern:

- Tasks no longer implements the removed alarm-contribution surface
  (getNextAlarm/onAlarm/alarms.rearm). Every non-terminal run's
  authoritative next_at is mirrored as one Lifecycle queue job (id = run
  id, so a retime is a same-id push); wakes dispatch through onJob, whose
  outcome is derived from the run row after execution — the single source
  of truth that supersedes any same-id push made mid-drive. Startup
  reconcile mirrors every non-terminal run, which also covers seeded rows.
- The maxRunsPerAlarm batching option, the batch harness, and its test are
  gone: dispatch pacing (due ordering, backlog warnings, the memory-limit
  breaker) is the queue driver's job now.
- The boot-recovery aperture (__DO_NOT_USE_WILL_BREAK__dispatchDueRuns)
  and Agent's startup call are gone: interrupted runs' mirror jobs are
  overdue after a crash and re-fire on the post-startup alarm derivation.
- Streams needs no port: it has no time-based behavior (appends happen in
  the producer's invocation; readers wake by append or reconnect), pushes
  zero jobs, and keeps working on facets.
- Test seed helpers mirror seeded runs into cf_agents_jobs and backdate
  both rows, matching what acceptance does.
- Agent installs tasks alongside main's _webSockets in the capability
  chain; docs and changesets now describe the queue model.
@mattzcarey
mattzcarey changed the base branch from feat/fibers-capability to main August 28, 2026 22:43
devin-ai-integration[bot]

This comment was marked as resolved.

…play

The custom-recovery surface is gone: { run, recover } definitions,
TaskInterruption/TaskRecoveryDecision, the recovering state and its backoff
budget, step checkpoint(), and the engine's recovery claim path (~350
lines). Definitions are plain handlers again. An unclean interruption
replays the handler on the next wake; replay safety comes from step
idempotency keys (external writes deduplicate) and durable evidence read at
the top of the work — a producer that starts at stream.cursor resumes
instead of redoing. task:attempt:interrupted still reports the step a lost
attempt left mid-execution.

Two things made recover unnecessary once the API was used end-to-end: the
Streams capability turned interruption evidence into durable state a
replayed handler reads directly, and the chat replatform showed its one
real consumer — the ChatRecoveryEngine — expresses the same decision as a
branch at handler entry when the live closure is missing. Chat turns and
messenger replies now take exactly that shape: the live path persists its
stash snapshot in host storage; a replay whose closure is gone enters the
unchanged recovery engine with that snapshot plus stream evidence, keyed by
stable run ids (chat_<nonce>, msgr_<nonce>).

Fixtures, e2es, the example, docs, and changesets move to the replay
model; the SIGKILL e2es now prove resume-without-duplication (gapless seq
sequences) rather than finalize-at-cursor. The RFCs record the amendment.
Tasks and Streams are unreleased, so no compatibility surface changes.
…n definition, typed aperture, decomposition

- step.interrupted: the interrupted step ({ name, attempt } | null) is
  first-class on the step surface, captured once at claim. The engine, the
  test harness, and the e2e handler previously reimplemented the same raw
  journal query; all three now read the API, and it is the documented way a
  replayed handler branches on interruption evidence.
- The chat-turn Task definition lives once in agents/chat
  (createChatTurnTaskDefinition): AIChatAgent and Think wire their
  protected internals through a narrow hooks contract instead of carrying
  60 identical lines each. The stash fire-and-forget storage writes are
  .catch-guarded (previously an unhandled-rejection risk on the token hot
  path), and the snapshot-key vocabulary is centralized. Messenger
  registration renamed to _registerMessengerReplyTaskDefinition.
- The Streams internal sync aperture is fully typed: the raw exec escape
  hatch is gone, replaced by latestRowByTag/deleteMany/importStream/
  importChunk, so no SQL crosses the capability boundary. Chat streams
  carry their request id as the indexed tag (metadata keeps only the
  ownership marker), and the legacy-table migration reads chat's own
  tables through the host-supplied sql handle.
- tasks.ts decomposed under 1k lines: store.ts owns the tables (DDL, row
  access, fenced writes, snapshot projection) and engine-port.ts builds
  the step-engine port. Queue-mirror syncs moved inside the settle
  helpers, so a state transition cannot forget its wake.
- resumable-stream.ts decomposed: replay wire-framing extracted to
  replay-frames.ts, collapsing three near-identical send loops; the three
  latest-stream query variants collapsed onto latestRowByTag.
- sseResponse cancel race fixed: a heartbeat tick racing a client
  disconnect no longer throws from the interval, and finish() tolerates a
  cancelled controller.
- Fixture seeds updated to the tag column; docs, changesets, and mirrors
  updated (tasks.md documents step.interrupted).
The exhaustion e2es exposed a queue-starvation regression from the
work-queue port: _chatRecoveryRetry/_chatRecoveryContinue are schedule
callbacks — queue jobs — and the replatform made them await the recovered
turn inline. A turn is legitimately unbounded (a hanging model stream is
capped only by the step timeout), so one stuck recovery dispatch starved
every other job on the object: keepAlive stopped, the interrupted turn's
own replay-wake never fired, budgets never advanced, and onExhausted never
sealed. Legacy dispatch was fire-and-forget runFiber, which never had this
property.

The callbacks are now split: the public schedule-facing method awaits the
bounded pre-turn phase and detaches at the turn boundary via a handoff
(Promise.race of the dispatch against a reached-the-turn signal), so a
platform transient thrown before the turn still reaches the queue and
defers the job (#1730). A platform-class failure after the handoff — when
the job has already completed — re-defers itself by rescheduling the same
callback (isPlatformFailure, now re-exported through agents/chat). All
incident bookkeeping (OOM intercept, budget evaluation, stranded-child
reconcile) lives unchanged in the protected *Detached body, which fixtures
drive directly when they need settled-state assertions.

Verified end to end: the exhaustion e2es seal all three budget kinds again
(3/3), think 887/887 (+2 react) including the #1730 deferral and
storage-reset pairs, ai-chat 737/737, agents 1957/1957.
… wiring

The two design records had accumulated amendments faster than their
bodies: rfc-fibers.md was 2,785 lines of pre-implementation proposal in
'Fibers' vocabulary describing recover callbacks, checkpoints, and the
alarm-contribution model — all superseded — with the corrections stacked
at the bottom. Both files are rewritten as records of the shipped design
(~180 lines each): the problem, the shipped API and architecture, an
honest 'how the design evolved' section (runtime create() → constructor
map; alarm contribution → job queue; recover shipped-then-removed with
the reasoning; the checkpoint→cursor contract collapse), alternatives
considered, deferred work, and the verification stance. Filenames stay
for link stability.

docs/agents/tasks.md gets a real fix (the install example declared
'readonly fibers' but installed 'this.tasks'), section reordering so both
replay discussions sit together, and current-limits wording aligned with
the record. streams.md drops the last 'recovery finalizes' phrasing.

The Agent-side Tasks wiring Matt flagged is refactored at the source:
Tasks runs onError through the standard runInHostContext boundary itself
(hosts pass a plain callback — the hand-rolled runInInvocation scope bag
is gone), and the definition-resolver aperture exports its value type
(TaskDefinitionResolver, the input-erased TaskCallbacks form), replacing
the ReturnType<Parameters<...>> cast gymnastics with one documented cast.
Local 'oxfmt --check' accepts these markdown files while CI's identical
check rejects them; write mode settles the canonical form.
devin-ai-integration[bot]

This comment was marked as resolved.

The Agent-wiring refactor removed the last use; oxlint in CI rejects the
leftover import.
- sseResponse: a fresh EventSource connection (no Last-Event-ID header)
  now starts at chunk 0 — Number(null) parsed as 0, which skipped the
  first chunk and made the ?from= fallback unreachable.
- Tasks.handle().cancel() is scoped to its definition, matching get().
- run() rejects a runId/idempotencyKey pair that names two different
  runs instead of silently joining one of them.
- Task wake jobs are namespaced task:<runId> so caller-selected run ids
  stay inside Tasks' own job-id space.
- Documented why fire-and-forget stash writes are safe (Durable Object
  storage applies same-key operations in issuance order) in the chat
  turn and messenger reply definitions.
Four named rules now govern the queue (design/lifecycle-work-queue.md):

- Job ids are scoped to their owner: push replaces only the owner's own
  job, and a cross-owner id collision throws instead of silently
  replacing the other owner's job.
- Newer pushes win over drive results: every dispatched job carries a
  durable in-flight marker, a same-id push or reschedule clears it, and
  applyOutcome only applies to still-marked jobs — a wake pushed
  mid-drive can no longer be lost. The memory-limit breaker retimes
  through an unguarded path because its backoff must land regardless.
- Dispatch must be bounded: a dispatch outliving its job's hung timeout
  warns and emits job:slow_dispatch telemetry.
- Platform failures abort the drive loop (existing behavior, now a
  documented rule), and cross-owner drive order is explicitly
  unspecified so lanes or fairness can arrive without a contract
  change.
devin-ai-integration[bot]

This comment was marked as resolved.

…e gaps

- The drive loop refetches each due job before claiming it: a job
  replaced by an earlier dispatch in the same alarm cycle dispatches
  with fresh data, or is skipped when no longer due, instead of being
  driven and settled from its stale snapshot.
- Tasks.onJob is bounded: a queue-driven attempt holds the serial
  dispatch loop for at most a small budget, then detaches and keeps
  executing while the isolate lives. The claim backstop remains the
  durable wake, and a detached settle re-syncs the wake mirror, which
  supersedes the returned outcome.
- The idempotency key is the deduplication authority in run acceptance:
  a fresh runId alongside a key that names an existing run joins that
  run (the repeated-delivery pattern), while a run matched by ID with a
  different stored key still refuses the join.
- sseResponse checks for already-aborted signals before wiring abort
  listeners, so a pre-aborted request ends instead of tailing a live
  stream forever.
devin-ai-integration[bot]

This comment was marked as resolved.

A backdated push can auto-fire its alarm between two awaited arms (seen
on the slower CI runner), letting the victim dispatch before the retimer
even existed. Push both jobs far-future, backdate them synchronously in
one breath, then rearm.
- A platform-class failure in a task attempt (superseded isolate,
  memory-limit reset, storage transient) no longer settles the run as
  failed: the attempt unwinds and rethrows, the claim backstop stays the
  durable wake, and the next invocation reclaims and replays.
- The messenger reply definition durably persists its initial accepted
  snapshot before delivery begins, mirroring the chat turn definition;
  an isolate lost mid-answer can always recover through it.
- Chat tag lookups filter to cfChat-owned rows: the stream table is
  shared and tags are non-unique, so the newest row by tag alone could
  be an unrelated application stream masking chat recovery evidence.
  The sync aperture's latestRowByTag becomes rowsByTag.
- readBatches re-polls after onUpToDate fires instead of sleeping: the
  callback is application code and a synchronous append inside it fired
  its wake before any waiter registered — a lost wakeup.
- The last aborted waiter removes its stream's empty wake set, so
  abandoned reads stop accumulating map entries.
devin-ai-integration[bot]

This comment was marked as resolved.

…live stream

deleteMany (the sweep) and deleteUnchecked can remove a streaming row;
a reader parked in the live tail never woke to observe the deletion and
stayed pending until an unrelated abort.
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