@meistrari/agent-core 0.1.10 → 0.1.11

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.
@@ -0,0 +1,34 @@
1
+ # Supervisor interruption recovery
2
+
3
+ Live Codex 0.1.10 validation reproduced a ready replacement connection with an
4
+ old command and tool still running after SIGKILL. No output marker was written.
5
+ Chat cancellation subsequently terminated the sandbox; its independent terminal
6
+ session reconciliation now finalizes the chat command.
7
+
8
+ Unreleased core changes persist active turn identity in the step projector.
9
+ When the provider factory reopens a persisted provider session, recovery first
10
+ drains the step journal, then journals explicit interrupted terminal facts for
11
+ unfinished turns. Step closure precedes the turn fact and retains incomplete
12
+ JSON details. Repeated recovery does not create another logical terminal fact.
13
+ A failure during emission reuses the saved frames and deterministic identity.
14
+ Ordinary reconnects to the same in-memory runtime do not trigger interruption.
15
+
16
+ This is not yet ready for release or acceptance:
17
+
18
+ - Dispatch receipts now fence the prompt-accepted / command-applied crash window
19
+ against resubmission. The new process atomically marks the command failed and
20
+ records that provider acceptance is uncertain. Agent-api now validates the
21
+ command-failure identity and projects failed prompt dispatch onto terminal
22
+ product/run state, with a pre-commit crash/deduplication Postgres regression.
23
+ - Recovery readiness now gates command dispatch and uncertain-failure receipts.
24
+ Child turns close before the main terminal fact. Barrier and child-ordering
25
+ regressions pass; lint/typecheck pass, with 340 full-suite tests plus the
26
+ subsequently added child-ordering regression verified in the projector suite.
27
+ - Missing starts, child turns, normal terminal sessions, provider readiness,
28
+ and failed oversized publication now have explicit regression coverage.
29
+ Failed uploads retain the identical JSON bytes/hash and do not emit a terminal
30
+ turn before publication succeeds.
31
+ - Repeat active crash validation using a new exact npm artifact and image.
32
+ - Do not infer that external tool side effects are exactly once.
33
+
34
+ No running template or bound sandbox was hot-updated with these source changes.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meistrari/agent-core",
3
3
  "type": "module",
4
- "version": "0.1.10",
4
+ "version": "0.1.11",
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",
@@ -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.10",
4
- sha: "af84feab98a91cd1cd31661c9fa3c0a72521ccf6",
5
- buildTime: "2026-09-14T15:18:33.258Z",
3
+ version: "0.1.11",
4
+ sha: "8a060e09b239547c1fb7731ab7e73d2439619a38",
5
+ buildTime: "2026-09-14T17:32:26.510Z",
6
6
  } as const
@@ -23,6 +23,7 @@ export class DurableStepProjector {
23
23
  this.db.exec(`pragma journal_mode=WAL; pragma synchronous=FULL;
24
24
  create table if not exists steps(id text primary key, body text not null, last_event_id text);
25
25
  create table if not exists journal(id text primary key, event text, frames text);
26
+ create table if not exists active_turns(identity text primary key, event text not null);
26
27
  create table if not exists turn_commands(
27
28
  session_id text not null, provider_session_id text not null,
28
29
  actor_id text not null, turn_id text not null, command_id text not null,
@@ -39,15 +40,63 @@ export class DurableStepProjector {
39
40
 
40
41
  close(): void { this.db.close() }
41
42
 
42
- async recover(emit: (body: WireEventBody) => void): Promise<void> {
43
+ async recover(emit: (body: WireEventBody) => void, options?: { interruptUnfinishedTurns: boolean }): Promise<void> {
43
44
  for (;;) {
44
45
  const row = this.db.query<{ id: string, event: string, frames: string | null }, []>(
45
46
  'select id, event, frames from journal where event is not null order by rowid limit 1',
46
47
  ).get()
47
48
  if (!row)
48
- return
49
+ break
49
50
  await this.deliver(row.id, JSON.parse(row.event) as AgentEvent, row.frames, emit)
50
51
  }
52
+ if (!options?.interruptUnfinishedTurns)
53
+ return
54
+ // A provider can expose a tool/message without a turn-start receipt.
55
+ // Recover its known identity from unfinished assembly, never fabricate
56
+ // missing content or emit a synthetic successful start.
57
+ for (;;) {
58
+ const orphan = this.db.query<{ body: string }, []>(`
59
+ select body from steps where json_extract(body,'$.status')='running'
60
+ and not exists (select 1 from active_turns where identity=json_array(
61
+ json_extract(body,'$.sessionId'),json_extract(body,'$.providerSessionId'),
62
+ json_extract(body,'$.actor.actorId'),json_extract(body,'$.turnId')))
63
+ order by id limit 1
64
+ `).get()
65
+ if (!orphan)
66
+ break
67
+ const step = JSON.parse(orphan.body) as StepDetailV1
68
+ const identity = JSON.stringify([step.sessionId, step.providerSessionId, step.actor.actorId, step.turnId])
69
+ this.db.query('insert into active_turns(identity,event) values (?,?)').run(identity, JSON.stringify({
70
+ type: 'turn.started',
71
+ eventId: `recovered-context:${step.stepId}`,
72
+ sequence: 0,
73
+ provider: step.provider,
74
+ sessionId: step.sessionId,
75
+ providerSessionId: step.providerSessionId,
76
+ actor: step.actor,
77
+ turnId: step.turnId,
78
+ timestamp: step.startedAt,
79
+ payload: {},
80
+ }))
81
+ }
82
+ // A newly opened local provider process cannot own the old active turn.
83
+ // Journal synthetic terminal facts before emitting; never resend prompts.
84
+ for (;;) {
85
+ const row = this.db.query<{ identity: string, event: string }, []>(`
86
+ select identity,event from active_turns
87
+ order by (json_extract(event,'$.actor.type')='main'), identity limit 1
88
+ `).get()
89
+ if (!row)
90
+ return
91
+ const previous = JSON.parse(row.event) as Extract<AgentEvent, { type: 'turn.started' }>
92
+ await this.process({
93
+ ...previous,
94
+ eventId: `supervisor-recovery:${createHash('sha256').update(row.identity).digest('hex')}`,
95
+ type: 'turn.ended',
96
+ timestamp: new Date().toISOString(),
97
+ payload: { status: 'interrupted', reason: 'supervisor_process_restarted' },
98
+ }, emit)
99
+ }
51
100
  }
52
101
 
53
102
  async process(event: AgentEvent, emit: (body: WireEventBody) => void): Promise<void> {
@@ -92,6 +141,10 @@ export class DurableStepProjector {
92
141
 
93
142
  private async project(event: AgentEvent): Promise<WireEventBody[]> {
94
143
  const commandId = this.correlateCommand(event)
144
+ if (event.type === 'turn.started') {
145
+ this.db.query('insert into active_turns(identity,event) values (?,?) on conflict(identity) do nothing')
146
+ .run(JSON.stringify([event.sessionId, event.providerSessionId, event.actor.actorId, event.turnId]), JSON.stringify(event))
147
+ }
95
148
  const kind = event.type.startsWith('message.')
96
149
  ? 'message'
97
150
  : event.type.startsWith('tool.call.')
@@ -113,6 +166,14 @@ export class DurableStepProjector {
113
166
  }
114
167
  }
115
168
  frames.push(wrapAgentEvent(event))
169
+ if (event.type === 'turn.ended') {
170
+ this.db.query('delete from active_turns where identity=?')
171
+ .run(JSON.stringify([event.sessionId, event.providerSessionId, event.actor.actorId, event.turnId]))
172
+ }
173
+ if (event.type === 'session.ended') {
174
+ this.db.query('delete from active_turns where json_extract(event,\'$.sessionId\')=? and json_extract(event,\'$.providerSessionId\')=?')
175
+ .run(event.sessionId, event.providerSessionId)
176
+ }
116
177
  return frames
117
178
  }
118
179
  const payload = event.payload as Record<string, unknown>
@@ -17,7 +17,7 @@ export interface AgentProviderBindingStore {
17
17
 
18
18
  export interface AgentSupervisorProviderFactoryOptions {
19
19
  eventProcessor?: {
20
- recover: (emit: (body: WireEventBody) => void) => Promise<void>
20
+ recover: (emit: (body: WireEventBody) => void, options?: { interruptUnfinishedTurns: boolean }) => Promise<void>
21
21
  process: (event: AgentEvent, emit: (body: WireEventBody) => void) => Promise<void>
22
22
  }
23
23
  createProvider: (input: {
@@ -76,6 +76,7 @@ export function createAgentSupervisorProviderFactory(
76
76
  emit: input.emit,
77
77
  onFatal: input.onFatal,
78
78
  eventProcessor: options.eventProcessor,
79
+ interruptUnfinishedTurns: Boolean(previousProviderSessionId),
79
80
  prepareInputAttachments: options.prepareInputAttachments,
80
81
  onSecretsRefreshed: options.onSecretsRefreshed,
81
82
  })
@@ -84,6 +85,7 @@ export function createAgentSupervisorProviderFactory(
84
85
 
85
86
  class AgentSupervisorRuntime implements SupervisorAgentRuntime {
86
87
  private readonly attached = Promise.withResolvers<void>()
88
+ private readonly recovered = Promise.withResolvers<void>()
87
89
  private state: SupervisorAgentRunSnapshot = { status: 'attached', agentState: null }
88
90
  private emit: ((body: WireEventBody) => void) | undefined
89
91
  private readonly pendingEvents: WireEventBody[] = []
@@ -95,12 +97,17 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
95
97
  emit?: (body: WireEventBody) => void
96
98
  onFatal?: (error: unknown) => void
97
99
  eventProcessor?: AgentSupervisorProviderFactoryOptions['eventProcessor']
100
+ interruptUnfinishedTurns?: boolean
98
101
  prepareInputAttachments?: AgentSupervisorProviderFactoryOptions['prepareInputAttachments']
99
102
  onSecretsRefreshed?: AgentSupervisorProviderFactoryOptions['onSecretsRefreshed']
100
103
  }) {
104
+ // Recovery may fail before the owner awaits ready; keep the rejection
105
+ // observed while preserving it for the readiness/command boundary.
106
+ void this.recovered.promise.catch(() => undefined)
101
107
  if (input.emit)
102
108
  this.attachEmitter(input.emit)
103
109
  this.eventPump = this.consumeEvents().catch((error: unknown) => {
110
+ this.recovered.reject(error)
104
111
  this.eventFailure = error
105
112
  input.onFatal?.(error)
106
113
  })
@@ -110,10 +117,13 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
110
117
  return this.state
111
118
  }
112
119
 
120
+ async ready(): Promise<void> { await this.recovered.promise }
121
+
113
122
  async handle(input: Parameters<SupervisorAgentRuntime['handle']>[0]): Promise<void> {
123
+ this.attachEmitter(input.emit)
124
+ await this.ready()
114
125
  if (this.eventFailure)
115
126
  throw this.eventFailure
116
- this.attachEmitter(input.emit)
117
127
  const metadata = this.input.run.metadata
118
128
  if (input.body.type === 'agent.send-prompt') {
119
129
  const inputAttachmentPreparation = await this.input.prepareInputAttachments?.({
@@ -198,8 +208,9 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
198
208
  await this.attached.promise
199
209
  if (!this.emit)
200
210
  return
201
- await this.input.eventProcessor.recover(body => this.emit!(body))
211
+ await this.input.eventProcessor.recover(body => this.emit!(body), { interruptUnfinishedTurns: Boolean(this.input.interruptUnfinishedTurns) })
202
212
  }
213
+ this.recovered.resolve()
203
214
  for await (const event of this.input.run.events) {
204
215
  if (event.type === 'session.state.changed')
205
216
  this.state = { status: 'attached', agentState: event.payload.state }
@@ -65,6 +65,7 @@ export class SupervisorStore {
65
65
  store.initializeSchema()
66
66
  else if (version !== schemaVersion)
67
67
  throw new Error(`Unsupported supervisor SQLite schema version: ${version}.`)
68
+ store.database.exec('CREATE TABLE IF NOT EXISTS command_dispatches (seq INTEGER PRIMARY KEY REFERENCES commands(seq))')
68
69
  return store
69
70
  }
70
71
  catch (cause) {
@@ -203,7 +204,40 @@ export class SupervisorStore {
203
204
  }
204
205
 
205
206
  markCommand(input: { commandSeq: number, status: 'applied' | 'failed' }): void {
206
- this.database.query(`UPDATE commands SET process_status = ? WHERE seq = ?`).run(input.status, input.commandSeq)
207
+ this.database.transaction(() => {
208
+ this.database.query(`UPDATE commands SET process_status = ? WHERE seq = ?`).run(input.status, input.commandSeq)
209
+ this.database.query('DELETE FROM command_dispatches WHERE seq = ?').run(input.commandSeq)
210
+ })()
211
+ }
212
+
213
+ /** Commit before calling a provider: a process crash makes the outcome uncertain. */
214
+ beginCommandDispatch(commandSeq: number): void {
215
+ this.database.query('INSERT INTO command_dispatches(seq) VALUES (?)').run(commandSeq)
216
+ }
217
+
218
+ /** New process only. Receipt and failure share a transaction; no prompt retry. */
219
+ recoverCommandDispatches(): void {
220
+ this.database.transaction(() => {
221
+ const rows = this.database.query<CommandRow, []>(`
222
+ SELECT command_id, commands.seq, body_json, body_hash, process_status FROM commands
223
+ JOIN command_dispatches ON command_dispatches.seq=commands.seq ORDER BY commands.seq
224
+ `).all()
225
+ for (const row of rows) {
226
+ this.appendDurableEvent({
227
+ occurredAt: new Date().toISOString(),
228
+ body: {
229
+ type: 'supervisor.command.failed',
230
+ payload: {
231
+ commandId: row.command_id,
232
+ commandSeq: row.seq,
233
+ bodyType: commandBodyFromRow(row).type,
234
+ failure: { type: 'command', detail: 'Supervisor restarted during dispatch; provider acceptance is uncertain. Command was not resubmitted.' },
235
+ },
236
+ },
237
+ })
238
+ this.markCommand({ commandSeq: row.seq, status: 'failed' })
239
+ }
240
+ })()
207
241
  }
208
242
 
209
243
  lastReceivedCommandSeq(): number {
@@ -5,6 +5,7 @@ import type { SupervisorAgentRunSnapshot } from '../supervisor-protocol/supervis
5
5
  import type { SupervisorRpcClient } from './rpc-client'
6
6
 
7
7
  export interface SupervisorAgentRuntime {
8
+ ready?: () => Promise<void>
8
9
  snapshot: () => SupervisorAgentRunSnapshot
9
10
  handle: (input: {
10
11
  commandId: string
@@ -188,6 +188,11 @@ export class SupervisorRuntimeHandler implements RuntimeConnectionHandler {
188
188
  emit: body => this.emit(body),
189
189
  onFatal: error => this.dependencies.onFatal(error),
190
190
  }).then(async (agent) => {
191
+ await agent.ready?.()
192
+ const previousEventMax = this.dependencies.store.localEventMax()
193
+ this.dependencies.store.recoverCommandDispatches()
194
+ for (const event of this.dependencies.store.eventsAfter(previousEventMax))
195
+ this.sendEvent(event)
191
196
  if ((this.dependencies.credentials !== initialSecrets.credentials
192
197
  || this.dependencies.gitToken !== initialSecrets.gitToken) && agent.refreshSecrets) {
193
198
  await agent.refreshSecrets({
@@ -210,6 +215,7 @@ export class SupervisorRuntimeHandler implements RuntimeConnectionHandler {
210
215
  command: CommandEnvelope & { body: WireCommandBody }
211
216
  }): Promise<void> {
212
217
  try {
218
+ this.dependencies.store.beginCommandDispatch(input.command.commandSeq)
213
219
  await input.agent.handle({
214
220
  commandId: input.command.commandId,
215
221
  commandSeq: input.command.commandSeq,