@autumn-dev/autumn-bus 0.1.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts ADDED
@@ -0,0 +1,255 @@
1
+ import { BusError } from './errors.js'
2
+ import type { BusErrorCode } from './errors.js'
3
+ import type {
4
+ Agent,
5
+ AgentLifecycle,
6
+ AskHumanInput,
7
+ BusHealth,
8
+ BusMessage,
9
+ BusTask,
10
+ CreateScopeInput,
11
+ CreateScopeResult,
12
+ DeliveryReceipt,
13
+ HumanEscalation,
14
+ InboxReservation,
15
+ RegisterAgentInput,
16
+ RegisterAgentResult,
17
+ SendMessageInput
18
+ } from './protocol.js'
19
+
20
+ interface Success<T> {
21
+ ok: true
22
+ result: T
23
+ }
24
+
25
+ interface Failure {
26
+ ok: false
27
+ error: { code: BusErrorCode; message: string; details?: Record<string, unknown> }
28
+ }
29
+
30
+ export interface OperationOptions {
31
+ signal?: AbortSignal
32
+ timeoutMs?: number
33
+ }
34
+
35
+ async function request<T>(
36
+ address: string,
37
+ token: string | undefined,
38
+ method: string,
39
+ path: string,
40
+ value?: unknown,
41
+ options: OperationOptions = {}
42
+ ): Promise<T> {
43
+ const timeoutMs = options.timeoutMs ?? 30_000
44
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
45
+ throw new BusError('INVALID_ARGUMENT', 'timeoutMs must be a positive finite number')
46
+ }
47
+ const controller = new AbortController()
48
+ const onAbort = () => controller.abort(options.signal?.reason ?? new Error('Operation aborted'))
49
+ if (options.signal?.aborted) onAbort()
50
+ else options.signal?.addEventListener('abort', onAbort, { once: true })
51
+ const timeout = setTimeout(
52
+ () => controller.abort(new Error(`Autumn Bus request timed out after ${timeoutMs}ms`)),
53
+ timeoutMs
54
+ )
55
+ let response: Response
56
+ let text: string
57
+ try {
58
+ response = await fetch(`${address}${path}`, {
59
+ method,
60
+ headers: {
61
+ accept: 'application/json',
62
+ ...(value === undefined ? {} : { 'content-type': 'application/json' }),
63
+ ...(token === undefined ? {} : { authorization: `Bearer ${token}` })
64
+ },
65
+ ...(value === undefined ? {} : { body: JSON.stringify(value) }),
66
+ signal: controller.signal
67
+ })
68
+ text = await response.text()
69
+ } catch (error) {
70
+ const cause = controller.signal.aborted ? controller.signal.reason : error
71
+ const message = cause instanceof Error ? cause.message : 'Network request failed'
72
+ throw new BusError('INTERNAL', `Autumn Bus request failed: ${message}`)
73
+ } finally {
74
+ clearTimeout(timeout)
75
+ options.signal?.removeEventListener('abort', onAbort)
76
+ }
77
+ let payload: Success<T> | Failure | T
78
+ try {
79
+ payload = JSON.parse(text) as Success<T> | Failure | T
80
+ } catch {
81
+ throw new BusError('INTERNAL', `Autumn Bus returned a non-JSON response with HTTP ${response.status}`)
82
+ }
83
+ if (response.ok) {
84
+ if (payload && typeof payload === 'object' && 'ok' in payload && payload.ok === true)
85
+ return (payload as Success<T>).result
86
+ return payload as T
87
+ }
88
+ if (
89
+ payload &&
90
+ typeof payload === 'object' &&
91
+ 'ok' in payload &&
92
+ payload.ok === false &&
93
+ 'error' in payload &&
94
+ payload.error &&
95
+ typeof payload.error === 'object' &&
96
+ 'code' in payload.error &&
97
+ 'message' in payload.error
98
+ ) {
99
+ const failure = payload as Failure
100
+ throw new BusError(failure.error.code, failure.error.message, failure.error.details)
101
+ }
102
+ throw new BusError('INTERNAL', `Autumn Bus request failed with HTTP ${response.status}`)
103
+ }
104
+
105
+ export class AutumnBusAdminClient {
106
+ constructor(
107
+ readonly address: string,
108
+ private readonly adminToken: string
109
+ ) {}
110
+
111
+ health(options?: OperationOptions): Promise<BusHealth> {
112
+ return request(this.address, undefined, 'GET', '/health', undefined, options)
113
+ }
114
+
115
+ createScope(input: CreateScopeInput = {}, options?: OperationOptions): Promise<CreateScopeResult> {
116
+ return request(this.address, this.adminToken, 'POST', '/v1/scopes', input, options)
117
+ }
118
+
119
+ async shutdown(options?: OperationOptions): Promise<void> {
120
+ await request(this.address, this.adminToken, 'POST', '/v1/admin/shutdown', {}, options)
121
+ }
122
+ }
123
+
124
+ export class AutumnBusScopeClient {
125
+ constructor(
126
+ readonly address: string,
127
+ readonly scopeToken: string
128
+ ) {}
129
+
130
+ registerAgent(input: RegisterAgentInput, options?: OperationOptions): Promise<RegisterAgentResult> {
131
+ return request(this.address, this.scopeToken, 'POST', '/v1/agents', input, options)
132
+ }
133
+
134
+ listAgents(options?: OperationOptions): Promise<Agent[]> {
135
+ return request(this.address, this.scopeToken, 'GET', '/v1/agents', undefined, options)
136
+ }
137
+
138
+ async linkAgents(left: string, right: string, options?: OperationOptions): Promise<void> {
139
+ await request(this.address, this.scopeToken, 'POST', '/v1/links', { left, right }, options)
140
+ }
141
+
142
+ listEscalations(options?: OperationOptions): Promise<HumanEscalation[]> {
143
+ return request(this.address, this.scopeToken, 'GET', '/v1/scope/escalations', undefined, options)
144
+ }
145
+
146
+ resolveEscalation(id: string, answer: string, options?: OperationOptions): Promise<HumanEscalation> {
147
+ return request(this.address, this.scopeToken, 'POST', `/v1/scope/escalations/${encodeURIComponent(id)}/resolve`, {
148
+ answer
149
+ }, options)
150
+ }
151
+ }
152
+
153
+ export class AutumnBusClient {
154
+ constructor(
155
+ readonly address: string,
156
+ readonly agentToken: string
157
+ ) {}
158
+
159
+ heartbeat(lifecycle: AgentLifecycle, ready = true, leaseMs?: number, options?: OperationOptions): Promise<Agent> {
160
+ return request(this.address, this.agentToken, 'PATCH', '/v1/me/heartbeat', {
161
+ lifecycle,
162
+ ready,
163
+ ...(leaseMs === undefined ? {} : { leaseMs })
164
+ }, options)
165
+ }
166
+
167
+ listPeers(options?: OperationOptions): Promise<Agent[]> {
168
+ return request(this.address, this.agentToken, 'GET', '/v1/peers', undefined, options)
169
+ }
170
+
171
+ sendMessage(input: SendMessageInput, options?: OperationOptions): Promise<DeliveryReceipt> {
172
+ return request(this.address, this.agentToken, 'POST', '/v1/messages', input, options)
173
+ }
174
+
175
+ receipt(messageId: string, options?: OperationOptions): Promise<DeliveryReceipt> {
176
+ return request(this.address, this.agentToken, 'GET', `/v1/messages/${encodeURIComponent(messageId)}`, undefined, options)
177
+ }
178
+
179
+ reserveInbox(limit = 50, options?: OperationOptions): Promise<InboxReservation | null> {
180
+ return request(this.address, this.agentToken, 'POST', '/v1/inbox/reserve', { limit }, options)
181
+ }
182
+
183
+ commitInbox(reservationId: string, options?: OperationOptions): Promise<BusMessage[]> {
184
+ return request(
185
+ this.address,
186
+ this.agentToken,
187
+ 'POST',
188
+ `/v1/inbox/${encodeURIComponent(reservationId)}/commit`,
189
+ {},
190
+ options
191
+ )
192
+ }
193
+
194
+ async releaseInbox(reservationId: string, options?: OperationOptions): Promise<void> {
195
+ await request(
196
+ this.address,
197
+ this.agentToken,
198
+ 'POST',
199
+ `/v1/inbox/${encodeURIComponent(reservationId)}/release`,
200
+ {},
201
+ options
202
+ )
203
+ }
204
+
205
+ async pullInbox(limit = 50, options?: OperationOptions): Promise<BusMessage[]> {
206
+ const reservation = await this.reserveInbox(limit, options)
207
+ return reservation ? this.commitInbox(reservation.id, options) : []
208
+ }
209
+
210
+ async acknowledgeMessages(messageIds: string[], options?: OperationOptions): Promise<number> {
211
+ const result = await request<{ acknowledged: number }>(
212
+ this.address,
213
+ this.agentToken,
214
+ 'POST',
215
+ '/v1/messages/ack',
216
+ { messageIds },
217
+ options
218
+ )
219
+ return result.acknowledged
220
+ }
221
+
222
+ addTask(description: string, dependencies: string[] = [], options?: OperationOptions): Promise<BusTask> {
223
+ return request(this.address, this.agentToken, 'POST', '/v1/tasks', { description, dependencies }, options)
224
+ }
225
+
226
+ listTasks(options?: OperationOptions): Promise<BusTask[]> {
227
+ return request(this.address, this.agentToken, 'GET', '/v1/tasks', undefined, options)
228
+ }
229
+
230
+ claimTask(taskId: string, options?: OperationOptions): Promise<BusTask> {
231
+ return request(this.address, this.agentToken, 'POST', `/v1/tasks/${encodeURIComponent(taskId)}/claim`, {}, options)
232
+ }
233
+
234
+ releaseTask(taskId: string, options?: OperationOptions): Promise<BusTask> {
235
+ return request(this.address, this.agentToken, 'POST', `/v1/tasks/${encodeURIComponent(taskId)}/release`, {}, options)
236
+ }
237
+
238
+ completeTask(taskId: string, note?: string, options?: OperationOptions): Promise<BusTask> {
239
+ return request(this.address, this.agentToken, 'POST', `/v1/tasks/${encodeURIComponent(taskId)}/complete`, {
240
+ ...(note === undefined ? {} : { note })
241
+ }, options)
242
+ }
243
+
244
+ askHuman(input: AskHumanInput, options?: OperationOptions): Promise<HumanEscalation> {
245
+ return request(this.address, this.agentToken, 'POST', '/v1/escalations', input, options)
246
+ }
247
+
248
+ escalation(id: string, options?: OperationOptions): Promise<HumanEscalation> {
249
+ return request(this.address, this.agentToken, 'GET', `/v1/escalations/${encodeURIComponent(id)}`, undefined, options)
250
+ }
251
+
252
+ mcpEndpoint(): { url: string; headers: Record<string, string> } {
253
+ return { url: `${this.address}/mcp`, headers: { Authorization: `Bearer ${this.agentToken}` } }
254
+ }
255
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,38 @@
1
+ export type BusErrorCode =
2
+ | 'INVALID_ARGUMENT'
3
+ | 'UNAUTHENTICATED'
4
+ | 'PERMISSION_DENIED'
5
+ | 'NOT_FOUND'
6
+ | 'METHOD_NOT_ALLOWED'
7
+ | 'CONFLICT'
8
+ | 'BACKPRESSURE'
9
+ | 'INTERNAL'
10
+
11
+ const STATUS_BY_CODE: Readonly<Record<BusErrorCode, number>> = {
12
+ INVALID_ARGUMENT: 400,
13
+ UNAUTHENTICATED: 401,
14
+ PERMISSION_DENIED: 403,
15
+ NOT_FOUND: 404,
16
+ METHOD_NOT_ALLOWED: 405,
17
+ CONFLICT: 409,
18
+ BACKPRESSURE: 429,
19
+ INTERNAL: 500
20
+ }
21
+
22
+ export class BusError extends Error {
23
+ readonly status: number
24
+
25
+ constructor(
26
+ readonly code: BusErrorCode,
27
+ message: string,
28
+ readonly details?: Record<string, unknown>
29
+ ) {
30
+ super(message)
31
+ this.name = 'BusError'
32
+ this.status = STATUS_BY_CODE[code]
33
+ }
34
+ }
35
+
36
+ export function asBusError(error: unknown): BusError {
37
+ return error instanceof BusError ? error : new BusError('INTERNAL', 'Internal Autumn Bus error')
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './client.js'
2
+ export * from './errors.js'
3
+ export * from './protocol.js'
4
+ export * from './session.js'
@@ -0,0 +1,161 @@
1
+ export const AUTUMN_BUS_PROTOCOL_VERSION = '0.1' as const
2
+
3
+ export type ScopeId = string
4
+ export type AgentId = string
5
+ export type ExecutionId = string
6
+ export type MessageId = string
7
+ export type TaskId = string
8
+
9
+ export type AgentLifecycle = 'starting' | 'ready' | 'working' | 'idle' | 'needs_input' | 'offline'
10
+ export type MessageMode = 'notify' | 'request' | 'response'
11
+ export type DeliveryState = 'queued' | 'reserved' | 'delivered' | 'acknowledged' | 'expired'
12
+ export type TaskStatus = 'open' | 'claimed' | 'done'
13
+ export type EscalationStatus = 'pending' | 'resolved'
14
+
15
+ export interface AgentCapability {
16
+ name: string
17
+ description?: string
18
+ }
19
+
20
+ export interface AgentIdentity {
21
+ scopeId: ScopeId
22
+ agentId: AgentId
23
+ executionId: ExecutionId
24
+ }
25
+
26
+ export interface Agent {
27
+ id: AgentId
28
+ displayName: string
29
+ capabilities: AgentCapability[]
30
+ lifecycle: AgentLifecycle
31
+ ready: boolean
32
+ reachable: boolean
33
+ executionId: ExecutionId
34
+ registeredAt: string
35
+ updatedAt: string
36
+ }
37
+
38
+ export interface ContextItem {
39
+ kind: 'text' | 'file' | 'url' | 'reference'
40
+ title: string
41
+ text?: string
42
+ uri?: string
43
+ mediaType?: string
44
+ }
45
+
46
+ export interface BusMessage {
47
+ id: MessageId
48
+ scopeId: ScopeId
49
+ from: AgentId
50
+ to: AgentId
51
+ mode: MessageMode
52
+ body: string
53
+ context: ContextItem[]
54
+ responseTo?: MessageId
55
+ state: DeliveryState
56
+ createdAt: string
57
+ expiresAt?: string
58
+ deliveredAt?: string
59
+ acknowledgedAt?: string
60
+ repliedAt?: string
61
+ responseMessageId?: MessageId
62
+ }
63
+
64
+ export interface DeliveryReceipt {
65
+ messageId: MessageId
66
+ state: DeliveryState
67
+ acceptedAt: string
68
+ deliveredAt?: string
69
+ acknowledgedAt?: string
70
+ repliedAt?: string
71
+ responseMessageId?: MessageId
72
+ }
73
+
74
+ export interface InboxReservation {
75
+ id: string
76
+ expiresAt: string
77
+ messages: BusMessage[]
78
+ }
79
+
80
+ export interface BusTask {
81
+ id: TaskId
82
+ scopeId: ScopeId
83
+ description: string
84
+ createdBy: AgentId
85
+ claimedBy?: AgentId
86
+ status: TaskStatus
87
+ dependencies: TaskId[]
88
+ note?: string
89
+ createdAt: string
90
+ updatedAt: string
91
+ }
92
+
93
+ export interface HumanEscalation {
94
+ id: string
95
+ scopeId: ScopeId
96
+ agentId: AgentId
97
+ question: string
98
+ options: string[]
99
+ status: EscalationStatus
100
+ answer?: string
101
+ createdAt: string
102
+ resolvedAt?: string
103
+ }
104
+
105
+ export interface CreateScopeInput {
106
+ id?: ScopeId
107
+ }
108
+
109
+ export interface CreateScopeResult {
110
+ scopeId: ScopeId
111
+ scopeToken: string
112
+ }
113
+
114
+ export interface RegisterAgentInput {
115
+ id?: AgentId
116
+ displayName: string
117
+ capabilities?: AgentCapability[]
118
+ connectTo?: AgentId[]
119
+ leaseMs?: number
120
+ }
121
+
122
+ export interface RegisterAgentResult extends AgentIdentity {
123
+ agentToken: string
124
+ leaseExpiresAt: string
125
+ }
126
+
127
+ export interface SendMessageInput {
128
+ to: AgentId
129
+ body: string
130
+ mode?: MessageMode
131
+ responseTo?: MessageId
132
+ idempotencyKey?: string
133
+ expiresInMs?: number
134
+ context?: ContextItem[]
135
+ }
136
+
137
+ export interface AddTaskInput {
138
+ description: string
139
+ dependencies?: TaskId[]
140
+ }
141
+
142
+ export interface AskHumanInput {
143
+ question: string
144
+ options?: string[]
145
+ }
146
+
147
+ export interface BusRunFile {
148
+ protocolVersion: string
149
+ address: string
150
+ pid: number
151
+ startedAt: string
152
+ adminToken: string
153
+ }
154
+
155
+ export interface BusHealth {
156
+ name: 'autumn-bus'
157
+ protocolVersion: string
158
+ runtimeVersion: string
159
+ status: 'ready'
160
+ startedAt: string
161
+ }
package/src/session.ts ADDED
@@ -0,0 +1,216 @@
1
+ import { AutumnBusClient, AutumnBusScopeClient } from './client.js'
2
+ import type {
3
+ Agent,
4
+ AgentLifecycle,
5
+ BusMessage,
6
+ BusTask,
7
+ RegisterAgentInput,
8
+ RegisterAgentResult
9
+ } from './protocol.js'
10
+
11
+ export interface AgentSessionOptions {
12
+ address: string
13
+ scopeToken: string
14
+ registration: RegisterAgentInput
15
+ heartbeatIntervalMs?: number
16
+ initialLifecycle?: AgentLifecycle
17
+ initialReady?: boolean
18
+ signal?: AbortSignal
19
+ }
20
+
21
+ export interface InboxPollingOptions {
22
+ limit?: number
23
+ intervalMs?: number
24
+ maxIntervalMs?: number
25
+ backoffFactor?: number
26
+ signal?: AbortSignal
27
+ }
28
+
29
+ export interface ClaimedTaskResult<T> {
30
+ task: BusTask
31
+ value: T
32
+ }
33
+
34
+ function delay(milliseconds: number, signal?: AbortSignal): Promise<void> {
35
+ return new Promise((resolve, reject) => {
36
+ if (signal?.aborted) {
37
+ reject(signal.reason ?? new Error('Operation aborted'))
38
+ return
39
+ }
40
+ const onAbort = () => {
41
+ clearTimeout(timer)
42
+ reject(signal?.reason ?? new Error('Operation aborted'))
43
+ }
44
+ const timer = setTimeout(() => {
45
+ signal?.removeEventListener('abort', onAbort)
46
+ resolve()
47
+ }, milliseconds)
48
+ signal?.addEventListener('abort', onAbort, { once: true })
49
+ })
50
+ }
51
+
52
+ export function requiredEnvironmentValue(
53
+ environment: Readonly<Record<string, string | undefined>>,
54
+ name: string
55
+ ): string {
56
+ const value = environment[name]
57
+ if (!value) throw new Error(`${name} is required`)
58
+ return value
59
+ }
60
+
61
+ export function newIdempotencyKey(): string {
62
+ return crypto.randomUUID()
63
+ }
64
+
65
+ export class AutumnBusAgentSession {
66
+ readonly client: AutumnBusClient
67
+ readonly registration: RegisterAgentResult
68
+ readonly done: Promise<void>
69
+
70
+ private readonly leaseMs: number
71
+ private readonly heartbeatIntervalMs: number
72
+ private lifecycle: AgentLifecycle
73
+ private ready: boolean
74
+ private timer: ReturnType<typeof setTimeout> | undefined
75
+ private pendingHeartbeat: Promise<Agent> | undefined
76
+ private resolveDone!: () => void
77
+ private closed = false
78
+ private sessionError: unknown
79
+
80
+ private constructor(options: AgentSessionOptions, registration: RegisterAgentResult) {
81
+ this.registration = registration
82
+ this.client = new AutumnBusClient(options.address, registration.agentToken)
83
+ this.leaseMs = options.registration.leaseMs || 300_000
84
+ this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? Math.floor(this.leaseMs / 3)
85
+ this.lifecycle = options.initialLifecycle ?? 'starting'
86
+ this.ready = options.initialReady ?? false
87
+ this.done = new Promise((resolve) => {
88
+ this.resolveDone = resolve
89
+ })
90
+ options.signal?.addEventListener('abort', () => void this.close(), { once: true })
91
+ }
92
+
93
+ static async start(options: AgentSessionOptions): Promise<AutumnBusAgentSession> {
94
+ if (options.signal?.aborted) {
95
+ throw options.signal.reason ?? new Error('Operation aborted')
96
+ }
97
+ const leaseMs = options.registration.leaseMs || 300_000
98
+ const interval = options.heartbeatIntervalMs ?? Math.floor(leaseMs / 3)
99
+ const lifecycle = options.initialLifecycle ?? 'starting'
100
+ const ready = options.initialReady ?? false
101
+ if (interval <= 0 || interval >= leaseMs) {
102
+ throw new Error('heartbeatIntervalMs must be shorter than the execution lease')
103
+ }
104
+ if (lifecycle === 'offline' && ready) {
105
+ throw new Error('offline agents cannot be ready')
106
+ }
107
+ const scope = new AutumnBusScopeClient(options.address, options.scopeToken)
108
+ const registration = await scope.registerAgent({ ...options.registration, leaseMs })
109
+ if (options.signal?.aborted) {
110
+ const client = new AutumnBusClient(options.address, registration.agentToken)
111
+ try {
112
+ await client.heartbeat('offline', false, leaseMs)
113
+ } catch {
114
+ // The lease remains the cleanup fallback if the execution was replaced.
115
+ }
116
+ throw options.signal.reason ?? new Error('Operation aborted')
117
+ }
118
+ const session = new AutumnBusAgentSession(options, registration)
119
+ await session.client.heartbeat(session.lifecycle, session.ready, session.leaseMs)
120
+ session.scheduleHeartbeat()
121
+ return session
122
+ }
123
+
124
+ get error(): unknown {
125
+ return this.sessionError
126
+ }
127
+
128
+ async setState(lifecycle: AgentLifecycle, ready: boolean): Promise<Agent> {
129
+ if (this.closed) throw new Error('agent session is closed')
130
+ if (lifecycle === 'offline' && ready) throw new Error('offline agents cannot be ready')
131
+ this.lifecycle = lifecycle
132
+ this.ready = ready
133
+ return this.client.heartbeat(lifecycle, ready, this.leaseMs)
134
+ }
135
+
136
+ async close(): Promise<void> {
137
+ if (this.closed) return
138
+ this.closed = true
139
+ if (this.timer !== undefined) clearTimeout(this.timer)
140
+ try {
141
+ await this.pendingHeartbeat
142
+ await this.client.heartbeat('offline', false, this.leaseMs)
143
+ } catch (error) {
144
+ if (this.sessionError === undefined) this.sessionError = error
145
+ } finally {
146
+ this.resolveDone()
147
+ }
148
+ }
149
+
150
+ private scheduleHeartbeat(): void {
151
+ if (this.closed) return
152
+ this.timer = setTimeout(() => {
153
+ this.pendingHeartbeat = this.client.heartbeat(this.lifecycle, this.ready, this.leaseMs)
154
+ void this.pendingHeartbeat.then(
155
+ () => {
156
+ this.pendingHeartbeat = undefined
157
+ this.scheduleHeartbeat()
158
+ },
159
+ (error: unknown) => {
160
+ this.pendingHeartbeat = undefined
161
+ this.sessionError = error
162
+ this.closed = true
163
+ this.resolveDone()
164
+ }
165
+ )
166
+ }, this.heartbeatIntervalMs)
167
+ }
168
+ }
169
+
170
+ export async function* pollInbox(
171
+ client: AutumnBusClient,
172
+ options: InboxPollingOptions = {}
173
+ ): AsyncGenerator<BusMessage[]> {
174
+ const limit = options.limit ?? 50
175
+ const intervalMs = options.intervalMs ?? 1_000
176
+ const maxIntervalMs = options.maxIntervalMs ?? 10_000
177
+ const backoffFactor = options.backoffFactor ?? 1.5
178
+ if (limit < 1 || limit > 100) throw new Error('limit must be between 1 and 100')
179
+ if (intervalMs < 1) throw new Error('intervalMs must be positive')
180
+ if (maxIntervalMs < intervalMs) throw new Error('maxIntervalMs must not be shorter than intervalMs')
181
+ if (backoffFactor < 1 || !Number.isFinite(backoffFactor)) {
182
+ throw new Error('backoffFactor must be a finite number of at least 1')
183
+ }
184
+ let nextIntervalMs = intervalMs
185
+ while (!options.signal?.aborted) {
186
+ const messages = await client.pullInbox(limit, options.signal ? { signal: options.signal } : undefined)
187
+ if (messages.length > 0) {
188
+ nextIntervalMs = intervalMs
189
+ yield messages
190
+ } else {
191
+ nextIntervalMs = Math.min(maxIntervalMs, Math.ceil(nextIntervalMs * backoffFactor))
192
+ }
193
+ if (!options.signal?.aborted) await delay(nextIntervalMs, options.signal)
194
+ }
195
+ }
196
+
197
+ export async function withClaimedTask<T>(
198
+ client: AutumnBusClient,
199
+ taskId: string,
200
+ work: (task: BusTask) => Promise<T>,
201
+ completionNote?: (value: T) => string | undefined
202
+ ): Promise<ClaimedTaskResult<T>> {
203
+ const claimed = await client.claimTask(taskId)
204
+ try {
205
+ const value = await work(claimed)
206
+ const task = await client.completeTask(taskId, completionNote?.(value))
207
+ return { task, value }
208
+ } catch (error) {
209
+ try {
210
+ await client.releaseTask(taskId)
211
+ } catch {
212
+ // Preserve the work error. Lease recovery remains the final fallback.
213
+ }
214
+ throw error
215
+ }
216
+ }