xo-harness 0.1.0 → 0.1.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.
@@ -149,8 +149,14 @@ export class TaskSupervisor {
149
149
  const key = taskKey(event.taskId, event.callId);
150
150
  if (this.#terminalTasks.has(key))
151
151
  return false;
152
- await this.#record(event);
153
152
  this.#terminalTasks.add(key);
153
+ try {
154
+ await this.#record(event);
155
+ }
156
+ catch (error) {
157
+ this.#terminalTasks.delete(key);
158
+ throw error;
159
+ }
154
160
  await this.#flush();
155
161
  if (deliverContext)
156
162
  await this.#deliverContext(event, "terminal");
@@ -54,15 +54,27 @@ export class ToolRuntime {
54
54
  }
55
55
  async #settleCompleted(callId, outcome) {
56
56
  this.#assertNotTerminal(callId);
57
- await this.#record({ type: "tool.completed", callId, outcome });
58
57
  this.#terminal.add(callId);
58
+ try {
59
+ await this.#record({ type: "tool.completed", callId, outcome });
60
+ }
61
+ catch (error) {
62
+ this.#terminal.delete(callId);
63
+ throw error;
64
+ }
59
65
  await this.#flush();
60
66
  await this.#deliver(callId, outcome);
61
67
  }
62
68
  async #settleFailure(callId, message) {
63
69
  this.#assertNotTerminal(callId);
64
- await this.#record({ type: "tool.failed", callId, error: message });
65
70
  this.#terminal.add(callId);
71
+ try {
72
+ await this.#record({ type: "tool.failed", callId, error: message });
73
+ }
74
+ catch (error) {
75
+ this.#terminal.delete(callId);
76
+ throw error;
77
+ }
66
78
  await this.#flush();
67
79
  await this.#deliver(callId, { type: "failed", error: message });
68
80
  }
@@ -5,6 +5,8 @@ import type { ToolRegistry } from "./tools.js";
5
5
  export interface CreateVoiceSessionOptions {
6
6
  sessionId: string;
7
7
  provider: VoiceProvider;
8
+ /** Cancels provider startup and closes the live session when its owner is revoked. */
9
+ signal?: AbortSignal;
8
10
  store: EventStore;
9
11
  tools: ToolRegistry;
10
12
  instructions?: string;
@@ -13,6 +13,7 @@ export class VoiceSession {
13
13
  #pendingRecords = new Set();
14
14
  #tasks;
15
15
  #toolRuntime;
16
+ #detachOwnerAbort;
16
17
  #providerPump;
17
18
  #closePromise;
18
19
  #maxDurationTimer;
@@ -28,7 +29,7 @@ export class VoiceSession {
28
29
  flush: () => this.#store.flush(this.id),
29
30
  sessionSignal: this.#controller.signal,
30
31
  onError: () => {
31
- void this.close("storage_error");
32
+ this.#closeFromInternal("storage_error");
32
33
  },
33
34
  });
34
35
  this.#toolRuntime = new ToolRuntime({
@@ -43,6 +44,10 @@ export class VoiceSession {
43
44
  });
44
45
  }
45
46
  static async create(options) {
47
+ const existingEvents = await options.store.list(options.sessionId);
48
+ if (existingEvents.length > 0) {
49
+ throw new Error(`Session id already exists: ${options.sessionId}`);
50
+ }
46
51
  const startedAt = performance.now();
47
52
  const startedEvent = await options.store.append(createUnsequencedEvent(options.sessionId, startedAt, {
48
53
  type: "session.started",
@@ -55,7 +60,15 @@ export class VoiceSession {
55
60
  : { outputModalities: [...options.outputModalities] }),
56
61
  }));
57
62
  await options.store.flush(options.sessionId);
63
+ if (options.signal?.aborted) {
64
+ await recordFailedCreation(options.store, options.sessionId, startedAt, "owner_revoked");
65
+ throw options.signal.reason ?? new Error("Session owner revoked before startup");
66
+ }
58
67
  const providerController = new AbortController();
68
+ const abortProviderFromOwner = () => providerController.abort(options.signal?.reason ?? "owner_revoked");
69
+ if (options.signal?.aborted)
70
+ abortProviderFromOwner();
71
+ options.signal?.addEventListener("abort", abortProviderFromOwner, { once: true });
59
72
  let providerSession;
60
73
  try {
61
74
  providerSession = await options.provider.createSession({
@@ -67,18 +80,33 @@ export class VoiceSession {
67
80
  });
68
81
  }
69
82
  catch (error) {
83
+ options.signal?.removeEventListener("abort", abortProviderFromOwner);
70
84
  providerController.abort(error);
71
- await recordFailedCreation(options.store, options.sessionId, startedAt);
85
+ await recordFailedCreation(options.store, options.sessionId, startedAt, options.signal?.aborted ? "owner_revoked" : "provider_create_failed");
72
86
  throw error;
73
87
  }
88
+ if (options.signal?.aborted) {
89
+ options.signal.removeEventListener("abort", abortProviderFromOwner);
90
+ await providerSession.close("owner_revoked").catch(() => undefined);
91
+ await recordFailedCreation(options.store, options.sessionId, startedAt, "owner_revoked");
92
+ throw options.signal.reason ?? new Error("Session owner revoked during startup");
93
+ }
74
94
  const session = new VoiceSession(options, providerSession, startedAt, startedEvent);
75
95
  session.#controller.signal.addEventListener("abort", () => providerController.abort(session.#controller.signal.reason), {
76
96
  once: true,
77
97
  });
98
+ options.signal?.removeEventListener("abort", abortProviderFromOwner);
99
+ if (options.signal) {
100
+ const abortSessionFromOwner = () => {
101
+ session.#closeFromInternal("owner_revoked");
102
+ };
103
+ options.signal.addEventListener("abort", abortSessionFromOwner, { once: true });
104
+ session.#detachOwnerAbort = () => options.signal?.removeEventListener("abort", abortSessionFromOwner);
105
+ }
78
106
  session.#providerPump = session.#pumpProviderEvents();
79
107
  if (options.maxDurationMs !== undefined) {
80
108
  session.#maxDurationTimer = setTimeout(() => {
81
- void session.close("max_duration_reached");
109
+ session.#closeFromInternal("max_duration_reached");
82
110
  }, options.maxDurationMs);
83
111
  }
84
112
  return session;
@@ -131,7 +159,12 @@ export class VoiceSession {
131
159
  this.#closePromise ??= this.#performClose(reason);
132
160
  return this.#closePromise;
133
161
  }
162
+ #closeFromInternal(reason) {
163
+ void this.close(reason).catch(() => undefined);
164
+ }
134
165
  async #performClose(reason) {
166
+ this.#detachOwnerAbort?.();
167
+ this.#detachOwnerAbort = undefined;
135
168
  if (this.#maxDurationTimer)
136
169
  clearTimeout(this.#maxDurationTimer);
137
170
  this.#controller.abort(reason);
@@ -198,11 +231,11 @@ export class VoiceSession {
198
231
  recoverable: event.recoverable,
199
232
  });
200
233
  if (!event.recoverable)
201
- void this.close("provider_error");
234
+ this.#closeFromInternal("provider_error");
202
235
  break;
203
236
  case "closed":
204
237
  await this.#record({ type: "provider.closed", reason: event.reason });
205
- void this.close("provider_closed");
238
+ this.#closeFromInternal("provider_closed");
206
239
  break;
207
240
  }
208
241
  }
@@ -221,14 +254,14 @@ export class VoiceSession {
221
254
  this.#toolExecutions.add(execution);
222
255
  void execution.then(() => this.#toolExecutions.delete(execution), () => {
223
256
  this.#toolExecutions.delete(execution);
224
- void this.close("storage_error");
257
+ this.#closeFromInternal("storage_error");
225
258
  });
226
259
  }
227
260
  #trackRecord(record) {
228
261
  this.#pendingRecords.add(record);
229
262
  void record.then(() => this.#pendingRecords.delete(record), () => {
230
263
  this.#pendingRecords.delete(record);
231
- void this.close("storage_error");
264
+ this.#closeFromInternal("storage_error");
232
265
  });
233
266
  }
234
267
  async #record(event) {
@@ -251,11 +284,11 @@ function createUnsequencedEvent(sessionId, startedAt, event) {
251
284
  sessionTimeMs: performance.now() - startedAt,
252
285
  };
253
286
  }
254
- async function recordFailedCreation(store, sessionId, startedAt) {
287
+ async function recordFailedCreation(store, sessionId, startedAt, reason) {
255
288
  try {
256
289
  await store.append(createUnsequencedEvent(sessionId, startedAt, {
257
290
  type: "session.ended",
258
- reason: "provider_create_failed",
291
+ reason,
259
292
  }));
260
293
  await store.flush(sessionId);
261
294
  }
@@ -9,6 +9,8 @@ export interface XOOptions {
9
9
  }
10
10
  export interface StartSessionOptions {
11
11
  provider: VoiceProvider;
12
+ /** Cancels provider startup and closes the live session when its owner is revoked. */
13
+ signal?: AbortSignal;
12
14
  sessionId?: string;
13
15
  instructions?: string;
14
16
  /** What the model may produce this session; ["text"] disables audio output. Default: audio. */
@@ -14,6 +14,7 @@ export class XO {
14
14
  return VoiceSession.create({
15
15
  sessionId: options.sessionId ?? crypto.randomUUID(),
16
16
  provider: options.provider,
17
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
17
18
  store: this.store,
18
19
  tools: this.tools,
19
20
  ...(options.instructions === undefined ? {} : { instructions: options.instructions }),
@@ -28,7 +28,7 @@ export class JsonlEventStore {
28
28
  this.#assertSessionId(sessionId);
29
29
  return this.#tails.run(sessionId, async () => {
30
30
  try {
31
- const file = await open(this.#sessionPath(sessionId), "r");
31
+ const file = await open(this.#sessionPath(sessionId), "r+");
32
32
  try {
33
33
  await file.sync();
34
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xo-harness",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A TypeScript-first agent harness for continuous, fully duplex voice models.",
5
5
  "type": "module",
6
6
  "sideEffects": [