Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,15 +191,15 @@ The `context.Context` used for runtime dispatch and handler execution.
_Avoid_: Background work context

**Dispatch Mode**:
Whether the routed handler runs before or after the adapter acknowledges the platform: synchronous by default, or deferred so the platform is acknowledged first.
Whether the adapter acknowledges the platform after the routed handler returns or without waiting for it: synchronous by default, or deferred.
_Avoid_: Concurrency Strategy, async flag

**Ack-Then-Work**:
The deferred-dispatch contract where the adapter acknowledges within the platform deadline after the synchronous prelude, then the handler runs on the **Detached Work Context**.
The deferred-dispatch contract where the adapter acknowledges within the platform deadline after the synchronous prelude, without waiting for the handler, which runs on the **Detached Work Context**.
_Avoid_: Synchronous handler, fire-and-forget goroutine

**Detached Work Context**:
A runtime-derived context that outlives the inbound request or **Delivery** so a deferred handler keeps working after acknowledgement; bounded by a detach timeout and cancelled by **Runtime Shutdown**.
A runtime-derived context that outlives the inbound request or **Delivery** so a deferred handler keeps working after the adapter acknowledges; bounded by a detach timeout and cancelled by **Runtime Shutdown**.
_Avoid_: Request context, context.Background()

**Admission Bound**:
Expand Down Expand Up @@ -450,7 +450,7 @@ _Avoid_: Full platform schema, strict external SDK model
- A **Lock Conflict** is acknowledged to the platform by default and recorded as unhandled runtime contention.
- An adapter accepts and normalizes a **Delivery**, but **Runtime Dispatch** decides which handler runs.
- The **Linear App-Actor Slice** preserves synchronous **Runtime Dispatch** for MVP; long-running Linear agent work should post an early **Agent Activity Thought** and enqueue follow-up work in application code.
- **Runtime Dispatch** defaults to synchronous (**Dispatch Mode** sync); a deferred **Dispatch Mode** (**Ack-Then-Work**) is available opt-in and runs the handler on the **Detached Work Context** after acknowledgement.
- **Runtime Dispatch** defaults to synchronous (**Dispatch Mode** sync); a deferred **Dispatch Mode** (**Ack-Then-Work**) is available opt-in, runs the handler on the **Detached Work Context**, and acknowledges without waiting for the handler.
- The synchronous **Dispatch Context** comes from the inbound webhook request, or from the handler context of a **Delivery** that a **Listener** receives; under deferred **Dispatch Mode** the **Thread Lock** is held across the detached work and refreshed before its lease expires.
- Runtime locks must be released when **Dispatch Context** is cancelled or handler execution exits.
- **Runtime State** mutations performed during synchronous dispatch respect the caller's **Dispatch Context** and fail when it is cancelled.
Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,11 @@ go get github.com/coder/chat/state/nats
runs Socket Mode, `Chat.Webhook` serves HTTP, and a bot can use both. See
[receiving deliveries](docs/reference.md#receiving-deliveries).
- **Ack-then-work dispatch.** Handlers run synchronously by default; opt in
to `DispatchDeferred` to run them after the event is acknowledged, with
lock renewal and an admission cap. Five concurrency strategies (drop,
queue, debounce, concurrent, burst) decide what happens when events
overlap on one thread; debounce and burst require deferred dispatch —
[defer long-running work](docs/how-to/deferred-dispatch.md).
to `DispatchDeferred` to acknowledge the event without waiting for the
handler, with lock renewal and an admission cap. Five concurrency
strategies (drop, queue, debounce, concurrent, burst) decide what happens
when events overlap on one thread; debounce and burst require deferred
dispatch — [defer long-running work](docs/how-to/deferred-dispatch.md).
- **Slash commands and interactive components.** Commands and button clicks
have their own hooks; Block Kit and modals go through typed adapter access —
[slash commands](docs/how-to/slash-commands.md),
Expand Down Expand Up @@ -224,7 +224,7 @@ works through these steps [runtime.go:Chat.dispatch, runtime.go:Chat.prelude]:
6. **Route.** Commands go to `OnCommand` and interactions to `OnInteraction`.
A message goes to `OnSubscribedMessage` when its thread is subscribed,
else to `OnNewMention` when it is a mention or a direct message
[runtime.go:Chat.route].
[runtime.go:Chat.prelude, runtime.go:Chat.route].
7. **Mark.** When the prelude knows the outcome (dropped on a lock conflict,
ignored, or routed), `State.MarkEvent` records the event ID for
`DedupeTTL`. A duplicate stops here [runtime.go:Chat.prelude,
Expand Down Expand Up @@ -301,7 +301,7 @@ Invariants:
not cancelled [runtime.go:Chat.dispatchSync].
- Lock waiters poll `State.AcquireLock` every `ThreadLockTTL/20`, clamped to
1ms–1s, because `State` does not signal a release
[runtime.go:Chat.pollForLock].
[runtime.go:Chat.pollForLock, runtime.go:Chat.queuePollInterval].
- `Shutdown` closes admission, cancels deferred work, waits for it to drain,
stops listeners and waits for them, shuts down the adapters, and then
shuts down `State`. `ctx` bounds the two waits. If `ctx` ends first,
Expand Down
4 changes: 2 additions & 2 deletions adapters/linear/raw.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ func RawMessageFrom(m *chat.Message) (*RawMessage, bool) {
}

// newFirstThoughtDeadline parses the session-created timestamp and computes the
// first-thought deadline. It returns nil when the timestamp is absent or
// unparseable so the surface stays best-effort.
// first-thought deadline. When the timestamp is absent or unparseable, it uses
// now() as the creation time. It returns nil only when now is also nil.
func newFirstThoughtDeadline(createdAt string, now func() time.Time) *FirstThoughtDeadline {
created, ok := parseLinearTime(createdAt)
if !ok {
Expand Down
6 changes: 4 additions & 2 deletions adapters/slack/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,10 @@
// attempt cap (Options.RetryPolicy.MaxAttempts), a cumulative backoff ceiling
// (MaxElapsed), and the caller's context deadline. The single load-bearing
// invariant is that in-line synchronous retry never sleeps past the caller's
// context deadline, so retry under the default DispatchSync stays inside Slack's
// 3-second ack window and cannot trigger a platform redelivery storm.
// context deadline. The deadline check and MaxElapsed bound only the sleep
// between attempts, not the HTTP attempts. Retry under the default DispatchSync
// stays inside Slack's 3-second ack window only when the caller's context
// deadline or an Options.Client timeout also limits those attempts.
//
// The RetryPolicy is per-adapter platform config in Options, never Runtime
// Options. RetryPolicy and RateLimited are type aliases of slackapi.RetryPolicy
Expand Down
6 changes: 3 additions & 3 deletions adapters/slack/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ type Options struct {
// it is adapter-owned wiring, not on the core Adapter interface. Nil is a no-op.
Observer chat.Observer
// RetryPolicy bounds outbound Slack rate-limit retry/backoff (ADR 0005). The
// zero value applies a conservative default that stays well under Slack's
// 3-second ack window; MaxAttempts: 1 disables retry. It is per-adapter
// platform config, never Runtime Options.
// zero value applies a conservative default that caps the total backoff sleep
// at 2 seconds but does not bound the HTTP attempts; MaxAttempts: 1 disables
// retry. It is per-adapter platform config, never Runtime Options.
RetryPolicy RetryPolicy
}

Expand Down
16 changes: 9 additions & 7 deletions adapters/slack/slackapi/retry.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ import (

// RetryPolicy bounds outbound Slack rate-limit retry/backoff (ADR 0005). Retry is
// bounded by attempt count and cumulative elapsed backoff, honors Slack's
// Retry-After header, and never sleeps past the caller's context deadline so
// in-line synchronous retry cannot outlive the platform ack window (Slack's
// 3-second budget). The zero value applies a conservative default; MaxAttempts: 1
// disables retry for callers that want raw single-shot behavior. A Retry-After
// longer than MaxDelay is capped at MaxDelay, so the retry comes earlier than
// Slack asked. Callers that must honor long Retry-After values should raise
// MaxDelay and MaxElapsed.
// Retry-After header, and never sleeps past the caller's context deadline. The
// deadline check and MaxElapsed bound only the sleep between attempts, not the
// HTTP attempts, so in-line synchronous retry stays inside Slack's 3-second ack
// window only when the caller's context deadline or an Options.HTTPClient
// timeout also limits those attempts. The zero value applies a conservative
// default; MaxAttempts: 1 disables retry for callers that want raw single-shot
// behavior. A Retry-After longer than MaxDelay is capped at MaxDelay, so the
// retry comes earlier than Slack asked. Callers that must honor long
// Retry-After values should raise MaxDelay and MaxElapsed.
type RetryPolicy struct {
// MaxAttempts is the total number of attempts including the first. Zero applies
// a conservative default; 1 disables retry.
Expand Down
12 changes: 7 additions & 5 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,13 @@
// - [DispatchSync], the default, runs the handler on the webhook request
// context and acknowledges after it returns. Use it for fast handlers.
// - [DispatchDeferred] (ack-then-work) runs dedupe before the
// acknowledgement, then runs the handler on a detached context bounded by
// [RuntimeOptions].DetachTimeout, which must be positive. Under every
// strategy except [ConcurrencyConcurrent], which takes no lock, the
// runtime renews the thread lock while the handler runs; if the lease is
// lost, it cancels the handler's context with [ErrPreempted] as the cause.
// acknowledgement and returns to the adapter without waiting for the
// handler. The handler runs on a detached context bounded by
// [RuntimeOptions].DetachTimeout, which must be positive, and can act
// before the acknowledgement is written. Under every strategy except
// [ConcurrencyConcurrent], which takes no lock, the runtime renews the
// thread lock while the handler runs; if the lease is lost, it cancels
// the handler's context with [ErrPreempted] as the cause.
//
// In both modes an accepted event is acknowledged even when its handler
// returns an error; the error is logged and observed, not retried.
Expand Down
4 changes: 2 additions & 2 deletions docs/how-to/deferred-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Start from `chat.DefaultRuntimeOptions()` so you keep the required
| Option | Default | What it does |
| --- | --- | --- |
| `Dispatch` | `DispatchSync` | `DispatchDeferred` turns on ack-then-work. |
| `DetachTimeout` | `0` | How long a handler may run after the acknowledgement. Required under deferred dispatch: `chat.New` fails while it is zero. |
| `DetachTimeout` | `0` | The timeout of the detached context that each deferred handler runs on. Required under deferred dispatch: `chat.New` fails while it is zero. |
| `Concurrency` | `ConcurrencyDrop` | What happens to an event that arrives while a handler holds the thread lock. See [Pick a concurrency strategy](#pick-a-concurrency-strategy). |
| `MaxDetached` | `1024` | The cap on deferred work in flight. Must be positive under deferred dispatch. See [Handle overload](#handle-overload). |

Expand Down Expand Up @@ -77,7 +77,7 @@ Follow three rules:

- **Use the `ctx` you are given for every call.** It carries the
`DetachTimeout` deadline and the runtime's cancellation signal.
- **Expect no platform retry.** An error returned after the acknowledgement
- **Expect no platform retry.** An error that a deferred handler returns
is logged and observed, but the platform never redelivers the event. If the
work must not be lost, make it idempotent and put it on your own queue.
- **Expect shutdown to abort work.** `Shutdown(ctx)` cancels the detached
Expand Down
12 changes: 7 additions & 5 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,9 +316,10 @@ inbound webhook request context, or on the delivery context of a listener,
before the platform acknowledgement. For
long-running work, opt in to `DispatchDeferred` (ack-then-work,
[ADR 0002](adr/0002-async-dispatch.md)): the dedupe and lock prelude runs
before the acknowledgement, then the handler runs on a detached work context
with automatic lock lease renewal, bounded by `DetachTimeout` (which must be
positive under deferred dispatch). Under deferred dispatch `MaxDetached`
before the acknowledgement, and the adapter acknowledges without waiting for
the handler, which runs on a detached work context with automatic lock lease
renewal, bounded by `DetachTimeout` (which must be positive under deferred
dispatch). Under deferred dispatch `MaxDetached`
bounds admitted-but-incomplete deliveries; a delivery arriving at the bound is
rejected with `chat.ErrAdmissionRejected` before acknowledgement and before
dedupe marking ([ADR 0015](adr/0015-runtime-coordination.md)). After
Expand Down Expand Up @@ -525,8 +526,9 @@ if ok {
issue-comment threads from the root comment and its replies, with the same
newest-first ordering and `Before` cursor semantics, clamped to Linear's
maximum page size.
- Long fetches belong after the acknowledgement, under deferred dispatch; the
runtime never fetches history on the inbound request path.
- Long fetches belong in a deferred handler, which does not delay the
acknowledgement; the runtime never fetches history on the inbound request
path.

This deliberately diverges from Vercel Chat SDK's end-to-end stored-history
model: persistence of conversation content stays an application concern.
Expand Down
4 changes: 2 additions & 2 deletions examples/linear-agent-hello-world/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ Expected behavior:
Thinking...
```

5. The app posts a `search-codebase` action and adds a **Draft PR** external
link to the session.
5. The app posts a `search-codebase` action and sets the session's external
links to one **Draft PR** link, which replaces any existing links.
6. The app posts the final response, a bold `hello from Linear app actor`
line followed by a note inviting a follow-up prompt.

Expand Down
4 changes: 2 additions & 2 deletions examples/linear-agent-hello-world/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ func newMentionHandler(linearAccess linearAgentAccess, pending *pendingSelection
}

// Ack-Then-Work: post a first thought inside the ~10s first-thought window,
// then do the rest. Under DispatchDeferred this whole handler already runs on
// the Detached Work Context after the webhook is acknowledged.
// then do the rest. Under DispatchDeferred this whole handler runs on the
// Detached Work Context, and the webhook is acknowledged without waiting for it.
_, _ = linearAccess.PostThought(ctx, ev.Thread.ID(), "Thinking...")

// Show native tool-call progress as an action.
Expand Down
15 changes: 9 additions & 6 deletions examples/slack-nats-state/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ or newer).
## Slack App Setup

Set up a Slack app as in the [tutorial](../../docs/tutorials/slack-bot.md):
Steps 2–3 create the app and collect credentials, Step 5 exposes your
machine, and Step 6 subscribes to events. This example also replies to follow-up messages in
subscribed threads, so add Step 8's `channels:history` scope and
`message.channels` event too.
Steps 2–3 create the app and collect credentials, and
[Use HTTP Instead Of Socket Mode](../../docs/tutorials/slack-bot.md#use-http-instead-of-socket-mode)
exposes your machine and sets the request URL. This example also replies to
follow-up messages in subscribed threads, so add the `channels:history` scope
and `message.channels` event from
[Step 6](../../docs/tutorials/slack-bot.md#step-6-make-it-yours) too.

| Setting | Value |
| --- | --- |
Expand Down Expand Up @@ -82,10 +84,11 @@ In Slack, mention the bot in a channel where it is present:
@your-bot hello
```

The bot replies in the same thread and subscribes the thread:
The bot replies in the same thread with portable Markdown and subscribes the
thread:

```text
hello world from NATS state. This thread is now subscribed.
**hello** _world_ from NATS state. This thread is now subscribed.
```

Send another message in the same Slack thread. The bot should route it as a
Expand Down
15 changes: 9 additions & 6 deletions examples/slack-postgres-state/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ are routed through Postgres-backed state.
## Slack App Setup

Set up a Slack app as in the [tutorial](../../docs/tutorials/slack-bot.md):
Steps 2–3 create the app and collect credentials, Step 5 exposes your
machine, and Step 6 subscribes to events. This example also replies to follow-up messages in
subscribed threads, so add Step 8's `channels:history` scope and
`message.channels` event too.
Steps 2–3 create the app and collect credentials, and
[Use HTTP Instead Of Socket Mode](../../docs/tutorials/slack-bot.md#use-http-instead-of-socket-mode)
exposes your machine and sets the request URL. This example also replies to
follow-up messages in subscribed threads, so add the `channels:history` scope
and `message.channels` event from
[Step 6](../../docs/tutorials/slack-bot.md#step-6-make-it-yours) too.

| Setting | Value |
| --- | --- |
Expand Down Expand Up @@ -79,10 +81,11 @@ In Slack, mention the bot in a channel where it is present:
@your-bot hello
```

The bot replies in the same thread and subscribes the thread:
The bot replies in the same thread with portable Markdown and subscribes the
thread:

```text
hello world from Postgres state. This thread is now subscribed.
**hello** _world_ from Postgres state. This thread is now subscribed.
```

Send another message in the same Slack thread. The bot should route it as a
Expand Down
15 changes: 9 additions & 6 deletions examples/slack-redis-state/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ routed through Redis-backed state.
## Slack App Setup

Set up a Slack app as in the [tutorial](../../docs/tutorials/slack-bot.md):
Steps 2–3 create the app and collect credentials, Step 5 exposes your
machine, and Step 6 subscribes to events. This example also replies to follow-up messages in
subscribed threads, so add Step 8's `channels:history` scope and
`message.channels` event too.
Steps 2–3 create the app and collect credentials, and
[Use HTTP Instead Of Socket Mode](../../docs/tutorials/slack-bot.md#use-http-instead-of-socket-mode)
exposes your machine and sets the request URL. This example also replies to
follow-up messages in subscribed threads, so add the `channels:history` scope
and `message.channels` event from
[Step 6](../../docs/tutorials/slack-bot.md#step-6-make-it-yours) too.

| Setting | Value |
| --- | --- |
Expand Down Expand Up @@ -79,10 +81,11 @@ In Slack, mention the bot in a channel where it is present:
@your-bot hello
```

The bot replies in the same thread and subscribes the thread:
The bot replies in the same thread with portable Markdown and subscribes the
thread:

```text
hello world from Redis state. This thread is now subscribed.
**hello** _world_ from Redis state. This thread is now subscribed.
```

Send another message in the same Slack thread. The bot should route it as a
Expand Down
2 changes: 1 addition & 1 deletion internal/ratelimit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ These packages import it:
- A shifted delay that is non-positive (shift overflow) or above `maxDelay` becomes `maxDelay`. `[ratelimit.go:BackoffDelay]`
- The result is `min(max(delay, retryAfter), maxDelay)`. A platform `retryAfter` can raise the delay but never above `maxDelay`. `[ratelimit.go:BackoffDelay]`
- `d <= 0` returns `ctx.Err()` at once without waiting. `[ratelimit.go:SleepCtx]`
- The sleep never outlasts the caller's context. The doc comment says this keeps retry inside the platform ack window. That holds only when the caller's context deadline fits that window. `[ratelimit.go:SleepCtx]`
- The sleep never outlasts the caller's context. `[ratelimit.go:SleepCtx]`

## Side Effects

Expand Down
3 changes: 1 addition & 2 deletions internal/ratelimit/ratelimit.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,7 @@ func BackoffDelay(base, maxDelay time.Duration, attempt int, retryAfter time.Dur
}

// SleepCtx sleeps for d but returns the context error if the context ends first,
// so retry never outlasts the caller's context (and thus never blows the platform
// ack window).
// so a backoff sleep never outlasts the caller's context.
func SleepCtx(ctx context.Context, d time.Duration) error {
if d <= 0 {
return ctx.Err()
Expand Down
Loading