@meistrari/agent-core 0.1.13 → 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.
- package/README.md +32 -0
- package/docs/credential-isolation-loopback-2026-09-14.md +30 -0
- package/package.json +1 -1
- package/src/agents/claude/claude-event-mapper.ts +29 -6
- package/src/agents/claude/claude-run.ts +70 -2
- package/src/agents/codex/codex-run.ts +12 -2
- package/src/protocol/agent-command.ts +2 -0
- package/src/protocol/agent-usage.ts +1 -0
- package/src/provenance.gen.ts +3 -3
- package/src/steps/contract.ts +4 -1
- package/src/steps/projector.ts +17 -1
- package/src/supervisor/agent-provider-factory.ts +60 -5
- package/src/supervisor/bootstrap-binder.ts +3 -2
- package/src/supervisor/index.ts +1 -1
- package/src/supervisor/persistence/supervisor-store.ts +22 -8
- package/src/supervisor/runtime-handler.ts +30 -2
- package/src/supervisor-protocol/bootstrap.ts +4 -1
- package/src/supervisor-protocol/command-body.ts +4 -1
- package/src/supervisor-protocol/envelopes/supervisor-to-control-plane.ts +1 -0
- package/src/testing/in-memory-runtime-control-plane.ts +118 -12
- package/src/testing/index.ts +1 -1
- package/src/worker-runtime-client/command-pump.ts +85 -10
- package/src/worker-runtime-client/connection-attempt.ts +95 -20
- package/src/worker-runtime-client/frame-processor.ts +242 -58
- package/src/worker-runtime-client/index.ts +8 -2
- package/src/worker-runtime-client/ports.ts +34 -2
- package/src/worker-runtime-client/rpc-request-manager.ts +152 -107
- package/src/worker-runtime-client/sandbox-connection-runtime.ts +72 -35
|
@@ -46,6 +46,9 @@ export interface PersistedSupervisorBinding {
|
|
|
46
46
|
runtimeConnectionAttemptId: string
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** A command whose sequence is already journaled under a different identity or body. */
|
|
50
|
+
export const commandSequenceConflictCode = 'agent-core.command-sequence-conflict'
|
|
51
|
+
|
|
49
52
|
export class SupervisorStore {
|
|
50
53
|
private closed = false
|
|
51
54
|
|
|
@@ -113,12 +116,14 @@ export class SupervisorStore {
|
|
|
113
116
|
now,
|
|
114
117
|
)
|
|
115
118
|
this.insertAttempt(input)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
if (input.body.initialCommand) {
|
|
120
|
+
this.insertCommand({
|
|
121
|
+
kind: 'command',
|
|
122
|
+
commandId: input.body.initialCommand.commandId,
|
|
123
|
+
commandSeq: input.body.initialCommand.commandSeq,
|
|
124
|
+
body: input.body.initialCommand.body,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
122
127
|
return {
|
|
123
128
|
body: input.body,
|
|
124
129
|
bodyHash,
|
|
@@ -175,12 +180,12 @@ export class SupervisorStore {
|
|
|
175
180
|
if (existing.command_id !== envelope.commandId
|
|
176
181
|
|| existing.body_hash !== sha256CanonicalJson(body)
|
|
177
182
|
|| existing.body_json !== canonicalJson(body)) {
|
|
178
|
-
throw new SupervisorPersistenceError({ message: 'Command sequence conflicts with durable inbox state.' })
|
|
183
|
+
throw new SupervisorPersistenceError({ code: commandSequenceConflictCode, message: 'Command sequence conflicts with durable inbox state.' })
|
|
179
184
|
}
|
|
180
185
|
return { status: 'received', duplicate: true }
|
|
181
186
|
}
|
|
182
187
|
const maximum = this.lastReceivedCommandSeq()
|
|
183
|
-
if (envelope.commandSeq !== maximum + 1)
|
|
188
|
+
if (this.binding()?.body.commandTransport !== 'wss-v1' && envelope.commandSeq !== maximum + 1)
|
|
184
189
|
throw new SupervisorPersistenceError({ message: 'Command sequence contains a gap.' })
|
|
185
190
|
try {
|
|
186
191
|
this.insertCommand({ ...envelope, body })
|
|
@@ -240,6 +245,15 @@ export class SupervisorStore {
|
|
|
240
245
|
})()
|
|
241
246
|
}
|
|
242
247
|
|
|
248
|
+
/** Stable identity of this SQLite journal, independent of socket and worker. */
|
|
249
|
+
journalSourceId(): string {
|
|
250
|
+
this.database.query('INSERT OR IGNORE INTO metadata (key, value) VALUES (\'journal_source_id\', ?)').run(crypto.randomUUID())
|
|
251
|
+
const row = this.database.query<{ value: string }, []>('SELECT value FROM metadata WHERE key = \'journal_source_id\'').get()
|
|
252
|
+
if (!row)
|
|
253
|
+
throw new SupervisorPersistenceError({ message: 'Missing journal identity.' })
|
|
254
|
+
return row.value
|
|
255
|
+
}
|
|
256
|
+
|
|
243
257
|
lastReceivedCommandSeq(): number {
|
|
244
258
|
return this.database.query<{ maximum: number }, []>('SELECT COALESCE(MAX(seq), 0) AS maximum FROM commands').get()?.maximum ?? 0
|
|
245
259
|
}
|
|
@@ -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.
|
|
103
|
-
|
|
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
|
-
|
|
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.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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.
|
|
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: {
|
|
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
|
}
|
package/src/testing/index.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
87
|
-
sessionId: this.dependencies.sessionId,
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
138
|
+
continue
|
|
99
139
|
}
|
|
100
|
-
this.
|
|
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:
|
|
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
|
}
|