@meistrari/agent-core 0.2.0-experimental.0 → 0.2.0-experimental.1

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.
@@ -6,12 +6,15 @@ import type { EventEnvelope } from '../supervisor-protocol/envelopes/supervisor-
6
6
  import type { WireEventBody } from '../supervisor-protocol/event-body'
7
7
  import type { SupervisorStore } from './persistence/supervisor-store'
8
8
  import type { SupervisorAgentRuntime, SupervisorProviderFactory } from './provider-factory'
9
+ import { ZodError } from 'zod'
9
10
  import { jsonUtf8ByteLength } from '../protocol'
10
11
  import { durabilityOf } from '../supervisor-protocol/durability'
11
12
  import { wireEventBodySchema } from '../supervisor-protocol/event-body'
12
13
  import { maxDurableEventBytes, maxEphemeralDeltaBytes } from '../supervisor-protocol/payload-overflow'
13
14
  import { decodeControlPlaneFrame, encodeWireFrame } from '../supervisor-protocol/wire-codec'
14
15
  import { EphemeralFrameBuffer } from './ephemeral-frame-buffer'
16
+ import { SupervisorPersistenceError } from './errors'
17
+ import { commandSequenceConflictCode } from './persistence/supervisor-store'
15
18
  import { SupervisorRpcClient } from './rpc-client'
16
19
 
17
20
  export interface ResidentSupervisorSocket {
@@ -99,8 +102,24 @@ export class SupervisorRuntimeHandler implements RuntimeConnectionHandler {
99
102
  this.schedulePendingCommands()
100
103
  }
101
104
  catch (error) {
102
- this.dependencies.logger.warn({ err: error }, 'rejecting invalid supervisor command')
103
- socket.close(1008, 'Supervisor command was rejected.')
105
+ const rejection = this.dependencies.store.binding()?.body.commandTransport === 'wss-v1'
106
+ ? terminalCommandRejection(error)
107
+ : undefined
108
+ if (!rejection) {
109
+ this.dependencies.logger.warn({ err: error }, 'rejecting invalid supervisor command')
110
+ socket.close(1008, 'Supervisor command was rejected.')
111
+ return
112
+ }
113
+ // wss-v1: answer a terminal rejection so the worker persists it instead of
114
+ // redelivering the same command on every reconnect.
115
+ this.dependencies.logger.warn({ err: error, commandSeq: frame.commandSeq, errorCode: rejection.errorCode }, 'rejecting supervisor command')
116
+ this.sendRetained(encodeWireFrame({
117
+ kind: 'command.ack',
118
+ commandId: frame.commandId,
119
+ commandSeq: frame.commandSeq,
120
+ status: 'rejected',
121
+ ...rejection,
122
+ }))
104
123
  }
105
124
  return
106
125
  }
@@ -328,3 +347,12 @@ function frameText(data: string | ArrayBuffer | Uint8Array): string {
328
347
  return data
329
348
  return new TextDecoder().decode(data)
330
349
  }
350
+
351
+ /** Only failures the same frame would reproduce are terminal; storage faults still close the socket. */
352
+ function terminalCommandRejection(error: unknown): { errorCode: string, detail: string } | undefined {
353
+ if (error instanceof ZodError)
354
+ return { errorCode: 'supervisor.command.invalid-body', detail: 'Command body failed wire validation.' }
355
+ if (error instanceof SupervisorPersistenceError && error.code === commandSequenceConflictCode)
356
+ return { errorCode: 'supervisor.command.conflicting-duplicate', detail: error.message.slice(0, 240) }
357
+ return undefined
358
+ }
@@ -59,12 +59,15 @@ export const sessionBootstrapBodySchema = z.object({
59
59
  }),
60
60
  gitBotIdentity: gitIdentitySchema.optional(),
61
61
  profile: sessionProfileSchema.optional(),
62
+ commandTransport: z.literal('wss-v1').optional(),
62
63
  initialCommand: z.object({
63
64
  commandId: z.string().min(1),
64
65
  commandSeq: z.literal(1),
65
66
  body: wireCommandBodySchema,
66
- }).strict(),
67
+ }).strict().optional(),
67
68
  }).strict().superRefine((body, context) => {
69
+ if (body.commandTransport === 'wss-v1' ? body.initialCommand !== undefined : body.initialCommand === undefined)
70
+ context.addIssue({ code: 'custom', path: ['initialCommand'], message: 'wss-v1 bootstraps without a command; legacy bootstrap requires command one.' })
68
71
  const workspacePrefix = `${body.agent.cwd}/`
69
72
  for (const [index, repository] of body.repositories.entries()) {
70
73
  if (repository.root !== body.agent.cwd && !repository.root.startsWith(workspacePrefix)) {
@@ -1,5 +1,5 @@
1
1
  import z from 'zod'
2
- import { agentPromptModeSchema, agentPromptSchema, agentUserInputAnswersSchema, jsonObjectSchema, jsonUtf8ByteLength } from '../protocol'
2
+ import { agentPromptModeSchema, agentPromptSchema, agentReasoningEffortSchema, agentUserInputAnswersSchema, jsonObjectSchema, jsonUtf8ByteLength } from '../protocol'
3
3
 
4
4
  const wireSenderContextSchema = z.discriminatedUnion('kind', [
5
5
  z.object({ kind: z.literal('application') }).strict(),
@@ -40,7 +40,9 @@ export const wireCommandExtensionsSchema = jsonObjectSchema.refine(
40
40
  const wireSendPromptCommandSchema = z.object({
41
41
  type: z.literal('agent.send-prompt'),
42
42
  mode: agentPromptModeSchema,
43
+ turnId: z.string().min(1).optional(),
43
44
  model: z.string().trim().min(1).max(256).optional(),
45
+ reasoningEffort: agentReasoningEffortSchema.optional(),
44
46
  prompt: agentPromptSchema,
45
47
  attachments: z.array(wireInputAttachmentRefSchema).superRefine((attachments, context) => {
46
48
  const attachmentIds = new Set<string>()
@@ -60,6 +62,7 @@ const wireSendPromptCommandSchema = z.object({
60
62
 
61
63
  const wireInterruptCommandSchema = z.object({
62
64
  type: z.literal('agent.interrupt'),
65
+ turnId: z.string().min(1).optional(),
63
66
  }).strict()
64
67
 
65
68
  const wireRespondUserInputCommandSchema = z.object({
@@ -14,6 +14,7 @@ export const commandAckErrorCodeSchema = z.string()
14
14
 
15
15
  export const runtimeStateEnvelopeSchema = z.object({
16
16
  kind: z.literal('runtime.state'),
17
+ sourceId: z.string().uuid().optional(),
17
18
  lastReceivedCommandSeq: z.number().int().nonnegative(),
18
19
  nextEventSeq: eventSequenceSchema,
19
20
  agentRun: supervisorAgentRunSnapshotSchema,
@@ -9,6 +9,7 @@ import type {
9
9
  CommandSource,
10
10
  ConnectionAuthorityStore,
11
11
  ConnectionClaim,
12
+ ConnectionReadinessRejection,
12
13
  DurableEventSink,
13
14
  EphemeralEventSink,
14
15
  RpcDispatcher,
@@ -19,18 +20,37 @@ import { ulid } from 'ulid'
19
20
 
20
21
  type NotificationCallbacks = Parameters<RuntimeNotifications['start']>[0]
21
22
 
23
+ /** Admission state of an in-memory command; only wss-v1 selection consults it. */
24
+ type InMemoryCommandStatus = 'queued' | 'dispatched' | 'received' | 'withdrawn' | 'rejected'
25
+
26
+ /** Code the fixture answers when a session sandbox announces a different journal than it pinned. */
27
+ export const inMemoryJournalIdentityChangedCode = 'testing.journal-identity-changed'
28
+
22
29
  export class InMemoryRuntimeControlPlane implements ConnectionAuthorityStore, CommandSource, DurableEventSink,
23
30
  EphemeralEventSink, RpcDispatcher, BootstrapProvider, SessionFailureSink, RuntimeNotifications {
24
31
  readonly durableEventLog: DurableEventEnvelope[] = []
25
32
  readonly durableRecordAttempts: number[] = []
26
33
  readonly ephemeralEventLog: Array<Parameters<EphemeralEventSink['publish']>[0]> = []
27
34
  readonly readySnapshots: SupervisorAgentRunSnapshot[] = []
35
+ /** `lastReceivedCommandSeq` announced by each accepted bootstrap, in readiness order. */
36
+ readonly readyReceivedSequences: number[] = []
28
37
  readonly projectedCommandSequences: number[] = []
29
38
  readonly failedCommandSequences: number[] = []
39
+ /** wss-v1 receipts, in the order the pump persisted them. */
40
+ readonly receivedCommandSequences: number[] = []
41
+ /** wss-v1 terminal rejections, in the order the pump persisted them. */
42
+ readonly rejectedCommands: Array<{ commandSeq: number, errorCode: string, detail?: string }> = []
43
+ /** Journal identity announced by each accepted readiness, in order. */
44
+ readonly readyJournalSourceIds: Array<string | undefined> = []
45
+ /** Every wss-v1 selection with the receipts that were durable when it ran. */
46
+ readonly pendingSelections: Array<{ commandSeq: number | undefined, receivedBefore: number[] }> = []
30
47
  readonly failureLog: Array<{ kind: string, code?: string, detail?: string }> = []
31
48
  readonly rpcCalls: SupervisorRpcRequestEnvelope[] = []
32
49
  onDurableRecord: ((event: DurableEventEnvelope) => void | Promise<void>) | undefined
33
- private readonly commandLog: CommandEnvelope[]
50
+ private readonly commandLog: Array<CommandEnvelope & { body: WireCommandBody }>
51
+ private readonly commandStatus = new Map<number, InMemoryCommandStatus>()
52
+ private readonly wssV1: boolean
53
+ private pinnedJournalSourceId: string | undefined
34
54
  private notifications: NotificationCallbacks | undefined
35
55
  private claimed = false
36
56
  private generation = 0
@@ -41,30 +61,49 @@ EphemeralEventSink, RpcDispatcher, BootstrapProvider, SessionFailureSink, Runtim
41
61
  ref: { sessionId: string, sessionSandboxId: string, providerSandboxId: string }
42
62
  trafficAccessTokenEncrypted: string
43
63
  bootstrap: Omit<SessionBootstrapBody, 'sessionId' | 'initialCommand'>
44
- initialCommand: { commandId: string, body: WireCommandBody }
64
+ /** Required by the legacy bootstrap, forbidden by `commandTransport: 'wss-v1'`. */
65
+ initialCommand?: { commandId: string, body: WireCommandBody }
45
66
  secrets?: Awaited<ReturnType<BootstrapProvider['issueEphemeralSecrets']>>
46
67
  rpcHandlers?: Readonly<Record<string, (body: unknown) => unknown | Promise<unknown>>>
68
+ /** Journal this session sandbox was already bound to before the runtime under test started. */
69
+ pinnedJournalSourceId?: string
47
70
  }) {
48
- this.commandLog = [{
49
- kind: 'command',
50
- commandId: input.initialCommand.commandId,
51
- commandSeq: 1,
52
- body: input.initialCommand.body,
53
- }]
71
+ this.wssV1 = input.bootstrap.commandTransport === 'wss-v1'
72
+ this.pinnedJournalSourceId = input.pinnedJournalSourceId
73
+ if (this.wssV1 === (input.initialCommand !== undefined))
74
+ throw new Error('wss-v1 bootstraps without an initial command; the legacy bootstrap requires one.')
75
+ this.commandLog = input.initialCommand
76
+ ? [{
77
+ kind: 'command',
78
+ commandId: input.initialCommand.commandId,
79
+ commandSeq: 1,
80
+ body: input.initialCommand.body,
81
+ }]
82
+ : []
83
+ for (const command of this.commandLog)
84
+ this.commandStatus.set(command.commandSeq, 'queued')
54
85
  }
55
86
 
56
87
  enqueueCommand(input: { commandId?: string, body: WireCommandBody }): CommandEnvelope {
57
- const command: CommandEnvelope = {
58
- kind: 'command',
88
+ const command = {
89
+ kind: 'command' as const,
59
90
  commandId: input.commandId ?? ulid(),
60
91
  commandSeq: this.commandLog.length + 1,
61
92
  body: input.body,
62
93
  }
63
94
  this.commandLog.push(command)
95
+ this.commandStatus.set(command.commandSeq, 'queued')
64
96
  this.notifications?.onCommandsReady(this.input.ref.sessionId)
65
97
  return command
66
98
  }
67
99
 
100
+ /** Withdraw an admitted command before dispatch; its sequence stays allocated, leaving a gap. */
101
+ withdrawCommand(commandSeq: number): void {
102
+ if (this.commandStatus.get(commandSeq) !== 'queued')
103
+ throw new Error(`Command ${commandSeq} is not queued and cannot be withdrawn.`)
104
+ this.commandStatus.set(commandSeq, 'withdrawn')
105
+ }
106
+
68
107
  demandConnection(): void {
69
108
  this.notifications?.onConnectionDemand(this.input.ref.sessionSandboxId)
70
109
  }
@@ -106,7 +145,9 @@ EphemeralEventSink, RpcDispatcher, BootstrapProvider, SessionFailureSink, Runtim
106
145
  return {
107
146
  renewed,
108
147
  ...(renewed ? { leaseExpiresAt: new Date(Date.now() + 30_000) } : {}),
109
- hasOutstandingCommand: this.projectedCommandSequences.length + this.failedCommandSequences.length < this.commandLog.length,
148
+ hasOutstandingCommand: this.wssV1
149
+ ? this.deliverableCommands().length > 0
150
+ : this.projectedCommandSequences.length + this.failedCommandSequences.length < this.commandLog.length,
110
151
  }
111
152
  }
112
153
 
@@ -114,13 +155,32 @@ EphemeralEventSink, RpcDispatcher, BootstrapProvider, SessionFailureSink, Runtim
114
155
  return this.claimed && input.generation === this.generation
115
156
  }
116
157
 
117
- async markConnectionReady(input: { generation: number, agentRunSnapshot: SupervisorAgentRunSnapshot }): Promise<boolean> {
158
+ async markConnectionReady(input: {
159
+ generation: number
160
+ lastReceivedCommandSeq: number
161
+ agentRunSnapshot: SupervisorAgentRunSnapshot
162
+ journalSourceId?: string
163
+ }): Promise<boolean | ConnectionReadinessRejection> {
118
164
  if (!this.claimed || input.generation !== this.generation)
119
165
  return false
166
+ if (this.pinnedJournalSourceId !== undefined && input.journalSourceId !== this.pinnedJournalSourceId) {
167
+ return {
168
+ ready: false,
169
+ code: inMemoryJournalIdentityChangedCode,
170
+ detail: `Session sandbox is pinned to journal ${this.pinnedJournalSourceId}; supervisor announced ${input.journalSourceId ?? 'none'}.`,
171
+ }
172
+ }
173
+ this.pinnedJournalSourceId ??= input.journalSourceId
120
174
  this.readySnapshots.push(input.agentRunSnapshot)
175
+ this.readyReceivedSequences.push(input.lastReceivedCommandSeq)
176
+ this.readyJournalSourceIds.push(input.journalSourceId)
121
177
  return true
122
178
  }
123
179
 
180
+ journalSourceId(): string | undefined {
181
+ return this.pinnedJournalSourceId
182
+ }
183
+
124
184
  async markConnectionReconnecting(): Promise<void> {}
125
185
 
126
186
  async releaseConnection(input: { generation: number }): Promise<void> {
@@ -137,9 +197,35 @@ EphemeralEventSink, RpcDispatcher, BootstrapProvider, SessionFailureSink, Runtim
137
197
  }
138
198
 
139
199
  async next(input: { afterSequence: number }): Promise<CommandEnvelope | undefined> {
200
+ if (this.wssV1)
201
+ throw new Error('wss-v1 never advances a cumulative command cursor.')
140
202
  return this.commandLog.find(command => command.commandSeq > input.afterSequence)
141
203
  }
142
204
 
205
+ /** wss-v1 selection: cancel and steer jump the queue; withdrawn commands leave gaps. */
206
+ async pending(): Promise<CommandEnvelope | undefined> {
207
+ if (!this.wssV1)
208
+ throw new Error('Individual command selection requires commandTransport wss-v1.')
209
+ // Yield like a database round trip so admissions made in the same tick are visible.
210
+ await Promise.resolve()
211
+ const deliverable = this.deliverableCommands()
212
+ const selected = deliverable.find(command => isPriorityCommand(command.body)) ?? deliverable[0]
213
+ this.pendingSelections.push({ commandSeq: selected?.commandSeq, receivedBefore: [...this.receivedCommandSequences] })
214
+ if (selected)
215
+ this.commandStatus.set(selected.commandSeq, 'dispatched')
216
+ return selected
217
+ }
218
+
219
+ async received(input: { commandId: string, commandSeq: number }): Promise<void> {
220
+ this.settleDispatched(input, 'received')
221
+ this.receivedCommandSequences.push(input.commandSeq)
222
+ }
223
+
224
+ async rejected(input: { commandId: string, commandSeq: number, errorCode: string, detail?: string }): Promise<void> {
225
+ this.settleDispatched(input, 'rejected')
226
+ this.rejectedCommands.push({ commandSeq: input.commandSeq, errorCode: input.errorCode, detail: input.detail })
227
+ }
228
+
143
229
  async getAckFloor(): Promise<number> {
144
230
  return this.ackFloor
145
231
  }
@@ -203,4 +289,24 @@ EphemeralEventSink, RpcDispatcher, BootstrapProvider, SessionFailureSink, Runtim
203
289
  async stop(): Promise<void> {
204
290
  this.notifications = undefined
205
291
  }
292
+
293
+ private settleDispatched(input: { commandId: string, commandSeq: number }, status: 'received' | 'rejected'): void {
294
+ const command = this.commandLog[input.commandSeq - 1]
295
+ if (!command || command.commandId !== input.commandId)
296
+ throw new Error(`Receipt for unknown command ${input.commandSeq}.`)
297
+ if (this.commandStatus.get(input.commandSeq) !== 'dispatched')
298
+ throw new Error(`Command ${input.commandSeq} was not dispatched.`)
299
+ this.commandStatus.set(input.commandSeq, status)
300
+ }
301
+
302
+ private deliverableCommands(): Array<CommandEnvelope & { body: WireCommandBody }> {
303
+ return this.commandLog.filter((command) => {
304
+ const status = this.commandStatus.get(command.commandSeq)
305
+ return status === 'queued' || status === 'dispatched'
306
+ })
307
+ }
308
+ }
309
+
310
+ function isPriorityCommand(body: WireCommandBody): boolean {
311
+ return body.type === 'agent.interrupt' || (body.type === 'agent.send-prompt' && body.mode === 'steer')
206
312
  }
@@ -1,6 +1,6 @@
1
1
  export { createTestControlAuthority } from './es256-test-keys'
2
2
  export type { TestControlAuthority } from './es256-test-keys'
3
- export { InMemoryRuntimeControlPlane } from './in-memory-runtime-control-plane'
3
+ export { inMemoryJournalIdentityChangedCode, InMemoryRuntimeControlPlane } from './in-memory-runtime-control-plane'
4
4
  export { createLoopbackSupervisorConnectionOpener } from './loopback-supervisor-connection'
5
5
  export { createScriptedProvider } from './scripted-provider'
6
6
  export type { ScriptedCommandContext } from './scripted-provider'
@@ -3,11 +3,19 @@ import type { CommandAckEnvelope } from '../supervisor-protocol/envelopes/superv
3
3
  import type { CommandSource, ConnectionAuthorityStore, SupervisorConnection } from './ports'
4
4
  import { encodeWireFrame } from '../supervisor-protocol/wire-codec'
5
5
 
6
+ export class CommandAckTimeoutError extends Error {
7
+ constructor() {
8
+ super('agent-core.command-ack-timeout')
9
+ this.name = 'CommandAckTimeoutError'
10
+ }
11
+ }
12
+
6
13
  export class SessionCommandPump {
7
14
  private afterSequence: number
8
15
  private running = false
9
16
  private stopped = false
10
17
  private wakePending = false
18
+ private readonly idleWaiters = new Set<() => void>()
11
19
  private pending: {
12
20
  commandId: string
13
21
  commandSeq: number
@@ -19,13 +27,20 @@ export class SessionCommandPump {
19
27
  sessionSandboxId: string
20
28
  runtimeConnectionId: string
21
29
  afterSequence: number
30
+ individualReceipts?: boolean
31
+ ackTimeoutMs?: number
32
+ onFailure?: (error: unknown) => void
22
33
  commands: CommandSource
23
- authorityStore: ConnectionAuthorityStore
34
+ authorityStore?: ConnectionAuthorityStore
24
35
  connection: SupervisorConnection
25
36
  logger: Logger
26
37
  onRejected: (ack: Extract<CommandAckEnvelope, { status: 'rejected' }>) => Promise<void>
27
38
  }) {
28
39
  this.afterSequence = dependencies.afterSequence
40
+ if (dependencies.ackTimeoutMs !== undefined && (!Number.isSafeInteger(dependencies.ackTimeoutMs) || dependencies.ackTimeoutMs < 1))
41
+ throw new RangeError('ackTimeoutMs must be a positive safe integer')
42
+ if (dependencies.individualReceipts && (!dependencies.commands.pending || !dependencies.commands.received || !dependencies.commands.rejected))
43
+ throw new Error('wss-v1 requires individual command selection, receipt persistence and rejection persistence')
29
44
  }
30
45
 
31
46
  start(): void {
@@ -43,7 +58,7 @@ export class SessionCommandPump {
43
58
  acknowledge(ack: CommandAckEnvelope): void {
44
59
  const pending = this.pending
45
60
  if (!pending) {
46
- if (ack.status === 'received' && ack.commandSeq <= this.afterSequence)
61
+ if (ack.status === 'received' && (this.dependencies.individualReceipts || ack.commandSeq <= this.afterSequence))
47
62
  return
48
63
  this.dependencies.logger.warn({ commandSeq: ack.commandSeq }, 'closing after an unsolicited command acknowledgement')
49
64
  this.dependencies.connection.close(1008, 'Command ACK has no pending command.')
@@ -75,6 +90,13 @@ export class SessionCommandPump {
75
90
  return this.afterSequence
76
91
  }
77
92
 
93
+ /** Stop dispatch and wait for any in-flight selection/send/receipt to settle. */
94
+ async close(): Promise<void> {
95
+ this.stop()
96
+ if (this.running)
97
+ await new Promise<void>(resolve => this.idleWaiters.add(resolve))
98
+ }
99
+
78
100
  private async run(): Promise<void> {
79
101
  if (this.running)
80
102
  return
@@ -83,26 +105,58 @@ export class SessionCommandPump {
83
105
  while (!this.stopped && this.wakePending) {
84
106
  this.wakePending = false
85
107
  while (!this.stopped) {
86
- const command = await this.dependencies.commands.next({
87
- sessionId: this.dependencies.sessionId,
88
- runtimeConnectionId: this.dependencies.runtimeConnectionId,
89
- afterSequence: this.afterSequence,
90
- })
108
+ const command = this.dependencies.individualReceipts
109
+ ? await this.dependencies.commands.pending!({ sessionId: this.dependencies.sessionId, runtimeConnectionId: this.dependencies.runtimeConnectionId })
110
+ : await this.dependencies.commands.next({
111
+ sessionId: this.dependencies.sessionId,
112
+ runtimeConnectionId: this.dependencies.runtimeConnectionId,
113
+ afterSequence: this.afterSequence,
114
+ })
115
+ if (this.stopped)
116
+ return
91
117
  if (!command)
92
118
  break
93
119
  const ack = await this.sendAndWait(command)
94
120
  if (this.stopped)
95
121
  return
96
122
  if (ack.status === 'rejected') {
123
+ if (!this.dependencies.individualReceipts) {
124
+ await this.dependencies.onRejected(ack)
125
+ return
126
+ }
127
+ // Terminal for this command only: persist it so `pending` never offers it again,
128
+ // then keep delivering the rest of the session's work.
129
+ await this.dependencies.commands.rejected!({
130
+ sessionId: this.dependencies.sessionId,
131
+ runtimeConnectionId: this.dependencies.runtimeConnectionId,
132
+ commandId: ack.commandId,
133
+ commandSeq: ack.commandSeq,
134
+ errorCode: ack.errorCode,
135
+ detail: ack.detail,
136
+ })
97
137
  await this.dependencies.onRejected(ack)
98
- return
138
+ continue
99
139
  }
100
- this.afterSequence = ack.commandSeq
140
+ if (this.dependencies.individualReceipts) {
141
+ await this.dependencies.commands.received!({
142
+ sessionId: this.dependencies.sessionId,
143
+ runtimeConnectionId: this.dependencies.runtimeConnectionId,
144
+ commandId: ack.commandId,
145
+ commandSeq: ack.commandSeq,
146
+ })
147
+ }
148
+ this.afterSequence = Math.max(this.afterSequence, ack.commandSeq)
101
149
  }
102
150
  }
103
151
  }
152
+ catch (error) {
153
+ this.fail(error)
154
+ }
104
155
  finally {
105
156
  this.running = false
157
+ for (const resolve of this.idleWaiters)
158
+ resolve()
159
+ this.idleWaiters.clear()
106
160
  if (!this.stopped && this.wakePending)
107
161
  void this.run()
108
162
  }
@@ -110,18 +164,39 @@ export class SessionCommandPump {
110
164
 
111
165
  private async sendAndWait(command: NonNullable<Awaited<ReturnType<CommandSource['next']>>>): Promise<CommandAckEnvelope> {
112
166
  const waiter = Promise.withResolvers<CommandAckEnvelope>()
167
+ const timeout = this.dependencies.ackTimeoutMs === undefined ? undefined : setTimeout(() => this.fail(new CommandAckTimeoutError()), this.dependencies.ackTimeoutMs)
113
168
  this.pending = {
114
169
  commandId: command.commandId,
115
170
  commandSeq: command.commandSeq,
116
- settle: waiter.resolve,
171
+ settle: (ack) => {
172
+ clearTimeout(timeout)
173
+ waiter.resolve(ack)
174
+ },
117
175
  }
118
176
  try {
119
177
  await this.dependencies.connection.send(encodeWireFrame(command))
120
178
  return await waiter.promise
121
179
  }
122
180
  finally {
181
+ clearTimeout(timeout)
123
182
  if (this.pending?.commandId === command.commandId)
124
183
  this.pending = undefined
125
184
  }
126
185
  }
186
+
187
+ private fail(error: unknown): void {
188
+ if (this.stopped)
189
+ return
190
+ this.dependencies.logger.warn({ err: error }, 'command delivery failed; reconnect and reconcile durable attempts')
191
+ try {
192
+ this.dependencies.onFailure?.(error)
193
+ }
194
+ catch (callbackError) {
195
+ this.dependencies.logger.warn({ err: callbackError }, 'command failure callback failed')
196
+ }
197
+ finally {
198
+ this.stop()
199
+ this.dependencies.connection.close(1011, 'Command delivery failed.')
200
+ }
201
+ }
127
202
  }