@meistrari/agent-core 0.1.0 → 0.1.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/README.md CHANGED
@@ -45,3 +45,15 @@ bunx agent-core-supervisor \
45
45
  Use `--loopback-tcp` for local and image-build probes. Production images default to the protected Unix socket. The extension module exports `default`, `extensions`, or `createSupervisorExtensions()` matching `SupervisorExtensions`.
46
46
 
47
47
  Publishing runs from `main` through `.github/workflows/publish.yml` (conventional commits decide the bump).
48
+
49
+ ## Durable command outcomes
50
+
51
+ A supervisor `received` ACK advances the command delivery cursor only. Implementations of
52
+ `DurableEventSink.record` must atomically store each durable event, project
53
+ `supervisor.command.applied` or `supervisor.command.failed` into command state, and advance the
54
+ ACK floor. Duplicate events must not repeat projections. This keeps outcomes consistent when
55
+ the worker disconnects after persistence but before sending its event ACK.
56
+
57
+ The former `ConnectionAuthorityStore.projectAppliedCommand` callback has been removed.
58
+ Move that projection into the durable-event transaction; receiving a command does not prove
59
+ that the provider executed it. See `InMemoryRuntimeControlPlane.record` for the test fixture.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meistrari/agent-core",
3
3
  "type": "module",
4
- "version": "0.1.0",
4
+ "version": "0.1.2",
5
5
  "packageManager": "bun@1.3.12",
6
6
  "description": "Shared contracts and runtime modules for Tela coding-agent sandboxes: agent protocol, supervisor wire protocol, resident supervisor, worker runtime client, and Claude/Codex harness adapters.",
7
7
  "license": "UNLICENSED",
@@ -14,6 +14,8 @@
14
14
  "registry": "https://registry.npmjs.org"
15
15
  },
16
16
  "exports": {
17
+ "./steps/contract": "./src/steps/contract.ts",
18
+ "./steps/projector": "./src/steps/projector.ts",
17
19
  "./errors": "./src/errors/index.ts",
18
20
  "./errors/application-error": "./src/errors/application-error.ts",
19
21
  "./logger": "./src/logger/index.ts",
@@ -89,9 +91,9 @@
89
91
  }
90
92
  },
91
93
  "dependencies": {
92
- "jose": "^6.1.3",
94
+ "jose": "6.2.11",
93
95
  "pino": "^9.7.0",
94
- "ulid": "^3.0.1",
96
+ "ulid": "3.0.2",
95
97
  "zod": "^4.1.12"
96
98
  },
97
99
  "devDependencies": {
@@ -100,9 +102,9 @@
100
102
  "@anthropic-ai/sdk": "0.93.0",
101
103
  "@modelcontextprotocol/sdk": "1.29.0",
102
104
  "@types/bun": "1.3.12",
103
- "@types/pg": "^8.15.5",
105
+ "@types/pg": "8.23.1",
104
106
  "eslint": "^9.30.0",
105
- "pg": "^8.16.3",
107
+ "pg": "8.23.0",
106
108
  "typescript": "5.9.3"
107
109
  }
108
110
  }
@@ -191,8 +191,20 @@ export function mapClaudeMessage(message: SDKMessage, state: ClaudeEventMapperSt
191
191
  // Claude Agent SDK result usage is per completed ask/turn for the stream-json path we use,
192
192
  // not a monotonic counter across the long-lived query. Emit it directly so summing
193
193
  // normalized usage events gives the run total; do not diff against prior results.
194
- if (usage)
195
- drafts.push({ type: 'usage', turnId, actor: mainActor, payload: { usage } })
194
+ if (usage) {
195
+ drafts.push({ type: 'usage', turnId, actor: mainActor, payload: { usage, accounting: {
196
+ observationId: message.uuid,
197
+ mode: 'increment',
198
+ scopeId: turnId,
199
+ includesChildren: true,
200
+ models: Object.fromEntries(Object.entries(message.modelUsage ?? {}).map(([model, counts]) => [model, {
201
+ inputTokens: counts.inputTokens,
202
+ outputTokens: counts.outputTokens,
203
+ cacheReadTokens: counts.cacheReadInputTokens,
204
+ cacheWriteTokens: counts.cacheCreationInputTokens,
205
+ }])),
206
+ } } })
207
+ }
196
208
  endOpenReasoning(drafts, state, turnId)
197
209
  const interruptAccepted = state.interrupt.status === 'accepted' && state.interrupt.turnId === turnId
198
210
  drafts.push({ type: 'turn.ended', turnId, actor: mainActor, payload: turnEndedPayload(message, interruptAccepted) })
@@ -71,6 +71,7 @@ export class ClaudeProvider implements AgentProvider<'claude'> {
71
71
  const sdkOptions: Options = removeUndefined({
72
72
  abortController,
73
73
  cwd: input.cwd,
74
+ settingSources: ['project', 'local'],
74
75
  env: this.environment,
75
76
  model: input.model,
76
77
  thinking: { type: 'adaptive', display: 'summarized' },
@@ -285,8 +285,8 @@ export class ClaudeRun implements AgentRun {
285
285
  // session identity mismatch are provider protocol drift and fail the run closed. `cancelled`
286
286
  // (a steered-over/aborted command) and `discarded` (the session ended with the command still
287
287
  // queued) are valid observed states but NEGATIVE terminals — never a positive acceptance, so
288
- // they are validated and no-op even when a waiter is pending; the pending prompt's real outcome
289
- // is settled by run close/error as delivery-unknown. Valid positive frames without a pending
288
+ // they reject any pending waiter as delivery-unknown so command processing can advance.
289
+ // Valid positive frames without a pending
290
290
  // waiter are internal lifecycle for already-settled or non-prompt inputs and are ignored.
291
291
  private settleAcceptance(frame: ClaudeCommandLifecycleMessage): void {
292
292
  const parsed = commandLifecycleFrameSchema.safeParse(frame)
@@ -302,8 +302,12 @@ export class ClaudeRun implements AgentRun {
302
302
  details: { expected: this.metadata.providerSessionId, actual: parsed.data.session_id },
303
303
  })
304
304
  }
305
- if (parsed.data.state === 'cancelled' || parsed.data.state === 'discarded')
305
+ if (parsed.data.state === 'cancelled' || parsed.data.state === 'discarded') {
306
+ this.acceptanceWaiters.get(parsed.data.command_uuid)?.reject(new AgentDeliveryUnknownError({
307
+ message: `Claude command ended as ${parsed.data.state} before acceptance was observed.`,
308
+ }))
306
309
  return
310
+ }
307
311
  this.acceptanceWaiters.get(parsed.data.command_uuid)?.resolve()
308
312
  }
309
313
 
@@ -3,6 +3,7 @@ import type { AgentWorkStatus } from '../../protocol/agent-work'
3
3
  import type { AgentEventDraft } from '../agent-event-stream'
4
4
  import type { CodexJsonRpcMessage, CodexServerNotification } from './codex-json-rpc-client'
5
5
  import type { ServerNotificationParamsByMethod } from './codex-protocol'
6
+ import { createHash } from 'node:crypto'
6
7
  import { jsonObjectSchema, jsonValueSchema } from '../../protocol'
7
8
  import { CANONICAL_TOOL } from '../../protocol/agent-tool-name'
8
9
  import { createRuntimeWorkItemId } from '../agent-id'
@@ -96,7 +97,19 @@ export function mapCodexNotification(notification: CodexNotification, state: Cod
96
97
  const usage = usageFromTokenUsage(notification.params.tokenUsage)
97
98
  if (!usage)
98
99
  return []
99
- return emitOrBufferByThread({ draft: { type: 'usage', turnId: notification.params.turnId, payload: { usage } }, threadId: notification.params.threadId, state })
100
+ return emitOrBufferByThread({ draft: { type: 'usage', turnId: notification.params.turnId, payload: {
101
+ usage,
102
+ accounting: {
103
+ observationId: createHash('sha256').update(notification.params.threadId).update(JSON.stringify(notification.params.tokenUsage.total)).digest('hex'),
104
+ mode: 'cumulative',
105
+ scopeId: notification.params.threadId,
106
+ includesChildren: false,
107
+ usage: {
108
+ ...usageFromCounts(notification.params.tokenUsage.total),
109
+ inputTokens: Math.max(0, notification.params.tokenUsage.total.inputTokens - notification.params.tokenUsage.total.cachedInputTokens),
110
+ },
111
+ },
112
+ } }, threadId: notification.params.threadId, state })
100
113
  }
101
114
  case 'turn/plan/updated':
102
115
  if (!state.startedTurnIds.has(notification.params.turnId) || notification.params.threadId !== state.mainThreadId)
@@ -4,7 +4,7 @@ import { agentRunMetadataSchema } from './agent-metadata'
4
4
  import { agentPayloadOverflowSchema } from './agent-overflow'
5
5
  import { agentProviderIdSchema } from './agent-provider'
6
6
  import { agentToolCallStatusSchema, agentToolResultSchema } from './agent-tool'
7
- import { agentUsageSchema } from './agent-usage'
7
+ import { agentUsageAccountingSchema, agentUsageSchema } from './agent-usage'
8
8
  import { agentUserInputAnswersSchema, agentUserInputQuestionSchema } from './agent-user-input'
9
9
  import { agentWorkObservationSchema } from './agent-work'
10
10
 
@@ -265,6 +265,7 @@ export const usageAgentEventSchema = agentTurnEventBaseSchema.extend({
265
265
  type: z.literal('usage'),
266
266
  payload: z.object({
267
267
  usage: agentUsageSchema,
268
+ accounting: agentUsageAccountingSchema.optional(),
268
269
  }).strict(),
269
270
  }).strict()
270
271
 
@@ -12,3 +12,17 @@ export const agentUsageSchema = z.object({
12
12
  }).strict()
13
13
 
14
14
  export type AgentUsage = z.infer<typeof agentUsageSchema>
15
+
16
+ /**
17
+ * Accounting semantics are separate from display counters. Consumers persist
18
+ * observations/checkpoints before acknowledging events, including across runs.
19
+ */
20
+ export const agentUsageAccountingSchema = z.object({
21
+ observationId: z.string().min(1),
22
+ mode: z.enum(['increment', 'cumulative']),
23
+ scopeId: z.string().min(1),
24
+ includesChildren: z.boolean(),
25
+ usage: agentUsageSchema.optional(),
26
+ model: z.string().min(1).optional(),
27
+ models: z.record(z.string(), agentUsageSchema).optional(),
28
+ }).strict()
@@ -1,6 +1,6 @@
1
1
  // Generated by scripts/write-provenance.ts. Do not edit by hand.
2
2
  export const generatedProvenance = {
3
- version: "0.1.0",
4
- sha: "9cd95365c6d39d1f44098fdd8006087c3adabfb4",
5
- buildTime: "2026-09-04T20:01:49.852Z",
3
+ version: "0.1.2",
4
+ sha: "85081b3dd3667033dfb1dfd399a6f59d3fd42d43",
5
+ buildTime: "2026-09-09T11:47:21.679Z",
6
6
  } as const
@@ -0,0 +1,94 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { createHash } from 'node:crypto'
3
+ import { z } from 'zod'
4
+ import { agentEventActorSchema } from '../protocol/agent-event'
5
+ import { jsonValueSchema } from '../protocol/agent-json'
6
+
7
+ export const stepSummarySchema = z.object({
8
+ version: z.literal(1),
9
+ stepId: z.string().uuid(),
10
+ sessionId: z.string(),
11
+ provider: z.enum(['claude', 'codex']),
12
+ providerSessionId: z.string(),
13
+ sourceId: z.string(),
14
+ actor: agentEventActorSchema,
15
+ turnId: z.string(),
16
+ commandId: z.string().nullable(),
17
+ kind: z.enum(['message', 'tool', 'reasoning']),
18
+ role: z.enum(['assistant', 'user', 'system']).nullable(),
19
+ toolName: z.string().nullable(),
20
+ status: z.enum(['running', 'completed', 'failed', 'interrupted']),
21
+ incomplete: z.boolean(),
22
+ startedAt: z.string().datetime(),
23
+ endedAt: z.string().datetime().nullable(),
24
+ preview: z.string(),
25
+ }).strict()
26
+ export const stepDetailSchema = stepSummarySchema.extend({
27
+ content: z.record(z.string(), jsonValueSchema),
28
+ }).strict()
29
+ export type StepSummary = z.infer<typeof stepSummarySchema>
30
+ export type StepDetailV1 = z.infer<typeof stepDetailSchema>
31
+
32
+ /** Stable bytes across JSONB round trips and deterministic upload retries. */
33
+ export function serializeStepDetail(value: unknown): string {
34
+ return JSON.stringify(value, (_key, item: unknown) => {
35
+ if (item && typeof item === 'object' && !Array.isArray(item))
36
+ return Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))
37
+ return item
38
+ })
39
+ }
40
+
41
+ export const stepClosedSchema = z.object({
42
+ step: stepSummarySchema,
43
+ sha256: z.string().regex(/^[a-f0-9]{64}$/u),
44
+ byteSize: z.number().int().positive(),
45
+ textParts: z.number().int().nonnegative(),
46
+ detail: stepDetailSchema.optional(),
47
+ reference: z.string().startsWith('vault://').optional(),
48
+ }).strict().refine(value => Boolean(value.detail) !== Boolean(value.reference), 'Exactly one detail source is required')
49
+
50
+ export function stepIdFor(input: Pick<StepSummary, 'sessionId' | 'providerSessionId' | 'actor' | 'turnId' | 'kind' | 'sourceId'>): string {
51
+ const hex = createHash('sha256').update(JSON.stringify([
52
+ input.sessionId,
53
+ input.providerSessionId,
54
+ input.actor.actorId,
55
+ input.turnId,
56
+ input.kind,
57
+ input.sourceId,
58
+ ])).digest('hex')
59
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`
60
+ }
61
+
62
+ /** Both decoded UTF-8 and JSON representation stay bounded without dropping text. */
63
+ export function splitStepText(text: string): string[] {
64
+ const parts: string[] = []
65
+ let part = ''
66
+ let bytes = 2
67
+ for (const character of text) {
68
+ const size = Buffer.byteLength(JSON.stringify(character)) - 2
69
+ if (bytes + size > 16 * 1024) {
70
+ parts.push(part)
71
+ part = ''
72
+ bytes = 2
73
+ }
74
+ part += character
75
+ bytes += size
76
+ }
77
+ if (part)
78
+ parts.push(part)
79
+ return parts
80
+ }
81
+
82
+ export function stepPreview(value: unknown): string {
83
+ const text = (typeof value === 'string' ? value : JSON.stringify(value) ?? '').replaceAll('\0', '\\0')
84
+ let preview = ''
85
+ let bytes = 0
86
+ for (const character of text) {
87
+ const size = Buffer.byteLength(JSON.stringify(character)) - 2
88
+ if (bytes + size > 2048)
89
+ return `${preview}…`
90
+ bytes += size
91
+ preview += character
92
+ }
93
+ return preview
94
+ }
@@ -0,0 +1,195 @@
1
+ import type { AgentEvent } from '../protocol'
2
+ import type { WireEventBody } from '../supervisor-protocol/event-body'
3
+ import type { StepDetailV1, StepSummary } from './contract'
4
+ import { Buffer } from 'node:buffer'
5
+ import { createHash } from 'node:crypto'
6
+ import { Database } from 'bun:sqlite'
7
+ import { wrapAgentEvent } from '../supervisor-protocol/agent-event-wrapper'
8
+ import { serializeStepDetail, splitStepText, stepIdFor, stepPreview } from './contract'
9
+
10
+ /**
11
+ * Same-binary local recovery. The journal is marked delivered only after emit
12
+ * synchronously commits all frames into the supervisor's own durable outbox.
13
+ */
14
+ export class DurableStepProjector {
15
+ private readonly db: Database
16
+ private currentEventId = ''
17
+
18
+ constructor(private readonly options: {
19
+ path: string
20
+ upload: (input: { stepId: string, sha256: string, body: string, byteSize: number }) => Promise<string>
21
+ }) {
22
+ this.db = new Database(options.path, { create: true })
23
+ this.db.exec(`pragma journal_mode=WAL; pragma synchronous=FULL;
24
+ create table if not exists steps(id text primary key, body text not null, last_event_id text);
25
+ create table if not exists journal(id text primary key, event text, frames text);
26
+ `)
27
+ if (!this.db.query<{ name: string }, []>('pragma table_info(steps)').all().some(column => column.name === 'last_event_id'))
28
+ this.db.exec('alter table steps add column last_event_id text')
29
+ }
30
+
31
+ close(): void { this.db.close() }
32
+
33
+ async recover(emit: (body: WireEventBody) => void): Promise<void> {
34
+ for (;;) {
35
+ const row = this.db.query<{ id: string, event: string, frames: string | null }, []>(
36
+ 'select id, event, frames from journal where event is not null order by rowid limit 1',
37
+ ).get()
38
+ if (!row)
39
+ return
40
+ await this.deliver(row.id, JSON.parse(row.event) as AgentEvent, row.frames, emit)
41
+ }
42
+ }
43
+
44
+ async process(event: AgentEvent, emit: (body: WireEventBody) => void): Promise<void> {
45
+ if (['message.delta', 'tool.output.delta', 'reasoning.summary.delta'].includes(event.type)) {
46
+ // Live deltas never become historical tool payloads. Large transport
47
+ // deltas are split rather than dropped by the supervisor frame limit.
48
+ const body = wrapAgentEvent(event)
49
+ const key = event.type === 'reasoning.summary.delta' ? 'text' : 'delta'
50
+ const payload = event.payload as Record<string, unknown>
51
+ for (const part of splitStepText(String(payload[key] ?? ''))) {
52
+ // Use smaller slices for envelope headroom within the 16 KiB cap.
53
+ for (const chunk of splitStepText(part).flatMap(part => [...part].length > 2000 ? part.match(/.{1,2000}/gsu)! : [part]))
54
+ emit({ ...body, payload: { ...payload, [key]: chunk } } as WireEventBody)
55
+ }
56
+ return
57
+ }
58
+ const found = this.db.query<{ event: string | null, frames: string | null }, [string]>(
59
+ 'select event, frames from journal where id=?',
60
+ ).get(event.eventId)
61
+ if (found?.event === null)
62
+ return
63
+ if (!found)
64
+ this.db.query('insert into journal(id,event) values (?,?)').run(event.eventId, JSON.stringify(event))
65
+ await this.deliver(event.eventId, event, found?.frames ?? null, emit)
66
+ }
67
+
68
+ private async deliver(id: string, event: AgentEvent, saved: string | null, emit: (body: WireEventBody) => void): Promise<void> {
69
+ this.currentEventId = id
70
+ const frames = saved ? JSON.parse(saved) as WireEventBody[] : await this.project(event)
71
+ if (!saved)
72
+ this.db.query('update journal set frames=? where id=?').run(JSON.stringify(frames), id)
73
+ for (const frame of frames)
74
+ emit(frame)
75
+ this.db.query('update journal set event=null, frames=null where id=?').run(id)
76
+ }
77
+
78
+ private async project(event: AgentEvent): Promise<WireEventBody[]> {
79
+ const kind = event.type.startsWith('message.')
80
+ ? 'message'
81
+ : event.type.startsWith('tool.call.')
82
+ ? 'tool'
83
+ : event.type.startsWith('reasoning.') ? 'reasoning' : undefined
84
+ if (!kind || !('turnId' in event)) {
85
+ const frames: WireEventBody[] = []
86
+ if (event.type === 'turn.ended' || event.type === 'session.ended') {
87
+ for (const row of this.db.query<{ body: string, last_event_id: string | null }, []>('select body, last_event_id from steps').all()) {
88
+ const step = JSON.parse(row.body) as StepDetailV1
89
+ if ((step.status !== 'running' && row.last_event_id !== event.eventId) || (event.type === 'turn.ended' && (step.turnId !== event.turnId || step.actor.actorId !== event.actor.actorId)))
90
+ continue
91
+ step.status = event.type === 'turn.ended' && event.payload.status === 'failed' ? 'failed' : 'interrupted'
92
+ step.incomplete = true
93
+ step.endedAt = event.timestamp
94
+ frames.push(...await this.finish(step))
95
+ }
96
+ }
97
+ frames.push(wrapAgentEvent(event))
98
+ return frames
99
+ }
100
+ const payload = event.payload as Record<string, unknown>
101
+ const sourceId = String(payload.messageId ?? payload.toolCallId ?? payload.reasoningId)
102
+ const identity = { sessionId: event.sessionId, providerSessionId: event.providerSessionId, actor: event.actor, turnId: event.turnId, kind: kind as StepSummary['kind'], sourceId }
103
+ const stepId = stepIdFor(identity)
104
+ const row = this.db.query<{ body: string, last_event_id: string | null }, [string]>('select body, last_event_id from steps where id=?').get(stepId)
105
+ const step: StepDetailV1 = row
106
+ ? JSON.parse(row.body)
107
+ : {
108
+ ...identity,
109
+ stepId,
110
+ version: 1,
111
+ provider: event.provider,
112
+ commandId: typeof payload.commandId === 'string' ? payload.commandId : null,
113
+ role: kind === 'message' ? payload.role as StepSummary['role'] : null,
114
+ toolName: typeof payload.toolName === 'string' ? payload.toolName : null,
115
+ status: 'running',
116
+ incomplete: !event.type.endsWith('started'),
117
+ startedAt: event.timestamp,
118
+ endedAt: null,
119
+ preview: '',
120
+ content: {},
121
+ }
122
+ if (step.status !== 'running' && row?.last_event_id !== event.eventId)
123
+ return []
124
+ if (event.type.endsWith('started')) {
125
+ if (row && row.last_event_id !== event.eventId)
126
+ return []
127
+ if (step.status !== 'running')
128
+ return []
129
+ if (kind === 'tool') {
130
+ step.content.input = payload.input as StepDetailV1['content'][string] ?? null
131
+ step.preview = stepPreview(payload.input)
132
+ }
133
+ this.save(step)
134
+ return [this.frame('product.step.started', { step: summary(step) })]
135
+ }
136
+ if (kind === 'message') {
137
+ step.content.text = typeof payload.text === 'string' ? payload.text : ''
138
+ step.preview = ''
139
+ }
140
+ else if (kind === 'tool') {
141
+ step.content.output = payload.output as StepDetailV1['content'][string] ?? null
142
+ step.content.error = payload.error as StepDetailV1['content'][string] ?? null
143
+ step.preview = stepPreview(payload.output ?? payload.error)
144
+ }
145
+ else {
146
+ step.content.summary = typeof payload.summary === 'string' ? payload.summary : ''
147
+ step.preview = stepPreview(payload.summary)
148
+ }
149
+ step.status = payload.status === 'failed' ? 'failed' : payload.status === 'cancelled' ? 'interrupted' : 'completed'
150
+ if (kind === 'tool' && payload.output === undefined && payload.error === undefined) {
151
+ step.incomplete = true
152
+ step.status = 'interrupted'
153
+ }
154
+ step.endedAt = event.timestamp
155
+ if (payload.overflow)
156
+ throw new Error('Step projection requires complete provider content before overflow truncation.')
157
+ return await this.finish(step)
158
+ }
159
+
160
+ private async finish(step: StepDetailV1): Promise<WireEventBody[]> {
161
+ if (step.kind === 'message') {
162
+ // References, not repeated complete tool payloads, connect message content.
163
+ step.content.toolSteps = this.db.query<{ id: string }, [string, string]>(`
164
+ select id from steps where json_extract(body,'$.kind')='tool'
165
+ and json_extract(body,'$.turnId')=? and json_extract(body,'$.actor.actorId')=? order by rowid
166
+ `).all(step.turnId, step.actor.actorId).map(row => row.id)
167
+ }
168
+ this.save(step)
169
+ const body = serializeStepDetail(step)
170
+ const sha256 = createHash('sha256').update(body).digest('hex')
171
+ const byteSize = Buffer.byteLength(body)
172
+ const text = step.kind === 'message' && typeof step.content.text === 'string' ? splitStepText(step.content.text) : []
173
+ const detailSource = byteSize <= 32 * 1024
174
+ ? { detail: step }
175
+ : { reference: await this.options.upload({ stepId: step.stepId, sha256, byteSize, body }) }
176
+ return [
177
+ ...text.map((part, index) => this.frame('product.step.text', { step: summary(step), index, count: text.length, text: part })),
178
+ this.frame('product.step.closed', { step: summary(step), sha256, byteSize, textParts: text.length, ...detailSource }),
179
+ ]
180
+ }
181
+
182
+ private save(step: StepDetailV1): void {
183
+ this.db.query('insert into steps(id,body,last_event_id) values (?,?,?) on conflict(id) do update set body=excluded.body,last_event_id=excluded.last_event_id')
184
+ .run(step.stepId, JSON.stringify(step), this.currentEventId)
185
+ }
186
+
187
+ private frame(type: `product.${string}`, payload: unknown): WireEventBody {
188
+ return { type, payload } as WireEventBody
189
+ }
190
+ }
191
+
192
+ function summary(step: StepDetailV1): StepSummary {
193
+ const { content: _content, ...value } = step
194
+ return value
195
+ }
@@ -16,6 +16,10 @@ export interface AgentProviderBindingStore {
16
16
  }
17
17
 
18
18
  export interface AgentSupervisorProviderFactoryOptions {
19
+ eventProcessor?: {
20
+ recover: (emit: (body: WireEventBody) => void) => Promise<void>
21
+ process: (event: AgentEvent, emit: (body: WireEventBody) => void) => Promise<void>
22
+ }
19
23
  createProvider: (input: {
20
24
  bootstrap: SessionBootstrapBody
21
25
  credentials?: EphemeralCredentials
@@ -69,6 +73,9 @@ export function createAgentSupervisorProviderFactory(
69
73
 
70
74
  return new AgentSupervisorRuntime({
71
75
  run,
76
+ emit: input.emit,
77
+ onFatal: input.onFatal,
78
+ eventProcessor: options.eventProcessor,
72
79
  prepareInputAttachments: options.prepareInputAttachments,
73
80
  onSecretsRefreshed: options.onSecretsRefreshed,
74
81
  })
@@ -76,17 +83,27 @@ export function createAgentSupervisorProviderFactory(
76
83
  }
77
84
 
78
85
  class AgentSupervisorRuntime implements SupervisorAgentRuntime {
86
+ private readonly attached = Promise.withResolvers<void>()
79
87
  private state: SupervisorAgentRunSnapshot = { status: 'attached', agentState: null }
80
88
  private emit: ((body: WireEventBody) => void) | undefined
81
89
  private readonly pendingEvents: WireEventBody[] = []
82
90
  private readonly eventPump: Promise<void>
91
+ private eventFailure: unknown
83
92
 
84
93
  constructor(private readonly input: {
85
94
  run: AgentRun
95
+ emit?: (body: WireEventBody) => void
96
+ onFatal?: (error: unknown) => void
97
+ eventProcessor?: AgentSupervisorProviderFactoryOptions['eventProcessor']
86
98
  prepareInputAttachments?: AgentSupervisorProviderFactoryOptions['prepareInputAttachments']
87
99
  onSecretsRefreshed?: AgentSupervisorProviderFactoryOptions['onSecretsRefreshed']
88
100
  }) {
89
- this.eventPump = this.consumeEvents()
101
+ if (input.emit)
102
+ this.attachEmitter(input.emit)
103
+ this.eventPump = this.consumeEvents().catch((error: unknown) => {
104
+ this.eventFailure = error
105
+ input.onFatal?.(error)
106
+ })
90
107
  }
91
108
 
92
109
  snapshot(): SupervisorAgentRunSnapshot {
@@ -94,6 +111,8 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
94
111
  }
95
112
 
96
113
  async handle(input: Parameters<SupervisorAgentRuntime['handle']>[0]): Promise<void> {
114
+ if (this.eventFailure)
115
+ throw this.eventFailure
97
116
  this.attachEmitter(input.emit)
98
117
  const metadata = this.input.run.metadata
99
118
  if (input.body.type === 'agent.send-prompt') {
@@ -154,6 +173,7 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
154
173
  }
155
174
 
156
175
  async close(input: { signal: AbortSignal }): Promise<void> {
176
+ this.attached.resolve()
157
177
  const metadata = this.input.run.metadata
158
178
  await this.input.run.stop({
159
179
  type: 'agent.stop',
@@ -167,15 +187,25 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
167
187
 
168
188
  private attachEmitter(emit: (body: WireEventBody) => void): void {
169
189
  this.emit = emit
190
+ this.attached.resolve()
170
191
  for (const event of this.pendingEvents.splice(0))
171
192
  emit(event)
172
193
  }
173
194
 
174
195
  private async consumeEvents(): Promise<void> {
196
+ if (this.input.eventProcessor) {
197
+ await this.attached.promise
198
+ if (!this.emit)
199
+ return
200
+ await this.input.eventProcessor.recover(body => this.emit!(body))
201
+ }
175
202
  for await (const event of this.input.run.events) {
176
203
  if (event.type === 'session.state.changed')
177
204
  this.state = { status: 'attached', agentState: event.payload.state }
178
- this.emitEvent(event)
205
+ if (this.input.eventProcessor)
206
+ await this.input.eventProcessor.process(event, body => this.emit!(body))
207
+ else
208
+ this.emitEvent(event)
179
209
  }
180
210
  }
181
211
 
@@ -34,7 +34,12 @@ export function createBootstrapBinder(input: {
34
34
  shutdown: (code: number) => void
35
35
  logger: Logger
36
36
  }): BootstrapBinder {
37
+ let pending = Promise.resolve()
37
38
  return async (request) => {
39
+ const previous = pending
40
+ const released = Promise.withResolvers<void>()
41
+ pending = released.promise
42
+ await previous
38
43
  try {
39
44
  return await bind(request)
40
45
  }
@@ -55,6 +60,9 @@ export function createBootstrapBinder(input: {
55
60
  terminate: () => input.shutdown(TERMINAL_ERROR_CODE),
56
61
  }
57
62
  }
63
+ finally {
64
+ released.resolve()
65
+ }
58
66
  }
59
67
 
60
68
  async function bind(request: {
@@ -27,6 +27,8 @@ export interface SupervisorProviderFactory {
27
27
  gitToken?: SessionBootstrapGitToken
28
28
  rpc: SupervisorRpcClient
29
29
  signal: AbortSignal
30
+ emit?: (body: WireEventBody) => void
31
+ onFatal?: (error: unknown) => void
30
32
  }) => Promise<SupervisorAgentRuntime>
31
33
  }
32
34
 
@@ -185,6 +185,8 @@ export class SupervisorRuntimeHandler implements RuntimeConnectionHandler {
185
185
  ...initialSecrets,
186
186
  rpc: this.rpc,
187
187
  signal: this.lifecycleController.signal,
188
+ emit: body => this.emit(body),
189
+ onFatal: error => this.dependencies.onFatal(error),
188
190
  }).then(async (agent) => {
189
191
  if ((this.dependencies.credentials !== initialSecrets.credentials
190
192
  || this.dependencies.gitToken !== initialSecrets.gitToken) && agent.refreshSecrets) {
@@ -381,6 +381,7 @@ export class ResidentSupervisorWebSocketServer {
381
381
  return
382
382
  input.socket.data.terminalRejection = undefined
383
383
  input.socket.close(internalErrorCloseCode, 'Supervisor rejection receipt was not observed.')
384
+ input.terminate()
384
385
  }, terminalRejectionReceiptBudgetMs)
385
386
  timeout.unref?.()
386
387
  input.socket.data.terminalRejection = { terminate: input.terminate, timeout }
@@ -26,6 +26,7 @@ EphemeralEventSink, RpcDispatcher, BootstrapProvider, SessionFailureSink, Runtim
26
26
  readonly ephemeralEventLog: Array<Parameters<EphemeralEventSink['publish']>[0]> = []
27
27
  readonly readySnapshots: SupervisorAgentRunSnapshot[] = []
28
28
  readonly projectedCommandSequences: number[] = []
29
+ readonly failedCommandSequences: number[] = []
29
30
  readonly failureLog: Array<{ kind: string, code?: string, detail?: string }> = []
30
31
  readonly rpcCalls: SupervisorRpcRequestEnvelope[] = []
31
32
  onDurableRecord: ((event: DurableEventEnvelope) => void | Promise<void>) | undefined
@@ -105,7 +106,7 @@ EphemeralEventSink, RpcDispatcher, BootstrapProvider, SessionFailureSink, Runtim
105
106
  return {
106
107
  renewed,
107
108
  ...(renewed ? { leaseExpiresAt: new Date(Date.now() + 30_000) } : {}),
108
- hasOutstandingCommand: this.projectedCommandSequences.length < this.commandLog.length - 1,
109
+ hasOutstandingCommand: this.projectedCommandSequences.length + this.failedCommandSequences.length < this.commandLog.length,
109
110
  }
110
111
  }
111
112
 
@@ -131,10 +132,6 @@ EphemeralEventSink, RpcDispatcher, BootstrapProvider, SessionFailureSink, Runtim
131
132
  return this.claimed && input.generation === this.generation
132
133
  }
133
134
 
134
- async projectAppliedCommand(input: { commandSeq: number }): Promise<void> {
135
- this.projectedCommandSequences.push(input.commandSeq)
136
- }
137
-
138
135
  async initial(): Promise<CommandEnvelope | undefined> {
139
136
  return this.commandLog[0]
140
137
  }
@@ -157,6 +154,10 @@ EphemeralEventSink, RpcDispatcher, BootstrapProvider, SessionFailureSink, Runtim
157
154
  return { status: 'duplicate', highWaterMark: this.ackFloor }
158
155
  if (event.seq !== this.ackFloor + 1)
159
156
  return { status: 'gap' }
157
+ if (event.body.type === 'supervisor.command.applied')
158
+ this.projectedCommandSequences.push(event.body.payload.commandSeq)
159
+ if (event.body.type === 'supervisor.command.failed')
160
+ this.failedCommandSequences.push(event.body.payload.commandSeq)
160
161
  this.durableEventLog.push(event)
161
162
  this.ackFloor = event.seq
162
163
  await this.onDurableRecord?.(event)
@@ -98,11 +98,6 @@ export class SessionCommandPump {
98
98
  return
99
99
  }
100
100
  this.afterSequence = ack.commandSeq
101
- await this.dependencies.authorityStore.projectAppliedCommand({
102
- sessionSandboxId: this.dependencies.sessionSandboxId,
103
- runtimeConnectionId: this.dependencies.runtimeConnectionId,
104
- commandSeq: ack.commandSeq,
105
- })
106
101
  }
107
102
  }
108
103
  }
@@ -31,6 +31,7 @@ export class SessionSandboxConnectionAttempt {
31
31
 
32
32
  constructor(private readonly dependencies: {
33
33
  claim: ConnectionClaim
34
+ establishmentDeadline: number
34
35
  replicaId: string
35
36
  trafficTokenEncryptionKey: string
36
37
  signControlAuthority: ControlAuthoritySigner
@@ -96,7 +97,7 @@ export class SessionSandboxConnectionAttempt {
96
97
  signal: this.controller.signal,
97
98
  onMessage: frame => this.processor ? this.processor.receive(frame) : queuedFrames.push(frame),
98
99
  onClose: close => closed.resolve(close),
99
- }), performance.now() + this.dependencies.timings.dialTimeoutMs, this.controller.signal, () => new ConnectionClosedError({
100
+ }), Math.min(this.dependencies.establishmentDeadline, performance.now() + this.dependencies.timings.dialTimeoutMs), this.controller.signal, () => new ConnectionClosedError({
100
101
  ready: false,
101
102
  detail: 'Supervisor connection dial timed out.',
102
103
  }))
@@ -160,7 +161,15 @@ export class SessionSandboxConnectionAttempt {
160
161
  ...secrets,
161
162
  }))
162
163
 
163
- const readiness = await processor.waitForReadiness({ signal: this.controller.signal }).catch(async (error: unknown) => {
164
+ const readiness = await beforeDeadline(Promise.race([
165
+ processor.waitForReadiness({ signal: this.controller.signal }),
166
+ closed.promise.then((close) => {
167
+ throw new ConnectionClosedError({ ready: false, detail: `${close.code}:${close.reason}` })
168
+ }),
169
+ ]), Math.min(this.dependencies.establishmentDeadline, performance.now() + 10_000), this.controller.signal, () => new ConnectionClosedError({
170
+ ready: false,
171
+ detail: 'Supervisor bootstrap timed out.',
172
+ })).catch(async (error: unknown) => {
164
173
  if (error instanceof BootstrapRejectedAckError) {
165
174
  await processor.acknowledgeBootstrapRejection(error.ack)
166
175
  throw new DeterministicEstablishmentError(error.ack.errorCode, error.ack.detail, true)
@@ -44,7 +44,6 @@ export interface ConnectionAuthorityStore {
44
44
  markConnectionReconnecting: (input: { sessionSandboxId: string, generation: number }) => Promise<void>
45
45
  releaseConnection: (input: { sessionSandboxId: string, generation: number, reason: string }) => Promise<void>
46
46
  markConnectionRetryExhausted: (input: { sessionSandboxId: string, generation: number, replicaId: string }) => Promise<boolean>
47
- projectAppliedCommand: (input: { sessionSandboxId: string, runtimeConnectionId: string, commandSeq: number }) => Promise<void>
48
47
  }
49
48
 
50
49
  export interface CommandSource {
@@ -58,6 +57,9 @@ export type DurableEventResult
58
57
 
59
58
  export interface DurableEventSink {
60
59
  getAckFloor: (input: { sessionSandboxId: string }) => Promise<number>
60
+ // Atomically persist the event, advance the ACK floor, and project its command outcome.
61
+ // Only supervisor.command.applied marks a command applied; received ACKs never do.
62
+ // Replayed events must not duplicate projections, including after reconnect.
61
63
  record: (input: SessionSandboxRef & { runtimeConnectionId: string, event: DurableEventEnvelope }) => Promise<DurableEventResult>
62
64
  }
63
65
 
@@ -154,6 +154,7 @@ class OwnedConnection {
154
154
  while (!this.controller.signal.aborted) {
155
155
  const attempt = new SessionSandboxConnectionAttempt({
156
156
  claim,
157
+ establishmentDeadline,
157
158
  replicaId: this.dependencies.replicaId,
158
159
  trafficTokenEncryptionKey: this.dependencies.trafficTokenEncryptionKey,
159
160
  signControlAuthority: this.dependencies.signControlAuthority,