@zvada/agent-server 0.3.5 → 0.3.7-replay.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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.7-replay.1
4
+
5
+ - Add `AgentServerClient.onEventGap` for consumers using `startTurn` and `onEvent`.
6
+ Missing replay data and server restarts are reported before surviving events.
7
+ A gap describes incomplete delivery; native execution may still be running.
8
+ - Track turns that disconnect before their first event and keep replay attempts
9
+ bound to the transport that started them. Preserve frames received during a
10
+ reconnect handshake even when they have already left the server's replay window.
11
+
12
+ ## 0.3.6
13
+
14
+ - Unsupported Codex app-server requests now receive an explicit protocol error.
15
+ Previously, requests such as MCP elicitations were treated as file-edit
16
+ approvals and could leave a turn waiting in the permission broker. Command
17
+ and file approvals and host-managed credential refresh keep their existing behavior.
18
+
3
19
  ## 0.3.5
4
20
 
5
21
  - Switching Codex ChatGPT accounts or API keys now recreates the native process
package/docs/consuming.md CHANGED
@@ -264,7 +264,19 @@ product that doesn't surface them debugs blind.
264
264
  and replays by default), `attach` (an accepted socket, e.g. the server dialed
265
265
  OUT to you via `--dial`). The client dedupes by per-session `seq` and heals
266
266
  gaps via `events/replay`; an unfillable gap raises `EventGapError` instead of
267
- silently losing events. The WebSocket wire itself carries no auth — keep it
267
+ silently losing events. When using `startTurn` with `onEvent`, subscribe to
268
+ `onEventGap` before starting work. It runs synchronously before any surviving
269
+ suffix and returns an unsubscribe function. `runTurn().result` continues to
270
+ reject on these same gaps.
271
+
272
+ A gap describes incomplete delivery, not a successful stop. Keep the affected
273
+ turn's output marked incomplete even if its native terminal says `end_turn`.
274
+ `server_restarted` and `session_missing` mean the old terminal cannot arrive;
275
+ `evicted`, `replay_failed`, and `log_reset` can require a turn-stamped
276
+ `cancelTurn(sessionId, turnId)` to reconcile execution. Never cancel a successor
277
+ by reading its ID after an asynchronous recovery operation.
278
+
279
+ The WebSocket wire itself carries no auth — keep it
268
280
  on a trusted channel (localhost, sandbox-internal, or behind your own
269
281
  authenticated boundary).
270
282
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvada/agent-server",
3
- "version": "0.3.5",
3
+ "version": "0.3.7-replay.1",
4
4
  "description": "Harness-agnostic agent execution engine: run Claude Code, Codex (SDK/CLI + app-server), and any ACP agent behind one interface with a normalized event stream, multi-turn sessions, and resume. Root export is the wire contract; /core, /server, /client are the seats.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -62,6 +62,13 @@ export class EventGapError extends Error {
62
62
  override readonly name = "EventGapError";
63
63
  }
64
64
 
65
+ /** A delivery failure, reported before surviving events. Execution may continue. */
66
+ export interface SessionEventGap {
67
+ sessionId: string;
68
+ reason: "evicted" | "replay_failed" | "session_missing" | "server_restarted" | "log_reset";
69
+ error: EventGapError;
70
+ }
71
+
65
72
  /**
66
73
  * A running turn: consume `events` (ends after `turn.ended`) or await `result`.
67
74
  *
@@ -145,11 +152,16 @@ export class AgentServerClient {
145
152
  private readonly pending = new Map<JsonRpcId, PendingRequest>();
146
153
  /** Per-session seq discipline — the shared `/protocol` implementation. */
147
154
  private readonly cursor = createSeqCursor();
148
- private readonly heldBack = new Map<string, DecodedWireEventEnvelope[]>();
155
+ private readonly heldBack = new Map<
156
+ string,
157
+ Array<{ envelope: DecodedWireEventEnvelope; transport: WireTransport | null }>
158
+ >();
149
159
  /** The server process behind the transport, learned at `initialize`. */
150
160
  private serverInstanceId: string | undefined;
151
- private readonly syncing = new Set<string>();
161
+ private readonly syncing = new Map<string, WireTransport>();
152
162
  private readonly eventHandlers = new Set<(envelope: DecodedWireEventEnvelope) => void>();
163
+ private readonly gapHandlers = new Set<(gap: SessionEventGap) => void>();
164
+ private awaitingHandshake = false;
153
165
  private readonly turnBySession = new Map<string, InternalTurn>();
154
166
  private initializePromise: Promise<InitializeResult> | null = null;
155
167
  private closed = false;
@@ -266,16 +278,26 @@ export class AgentServerClient {
266
278
  // log will ever emit THEIR turn.ended — an un-failed handle hangs forever
267
279
  // (a session id the fresh process happens to reuse would even feed it
268
280
  // someone else's events).
269
- for (const sessionId of [...this.turnBySession.keys()]) {
270
- this.failTurn(
281
+ const sessionIds = new Set([...this.cursor.sessions(), ...this.turnBySession.keys()]);
282
+ for (const sessionId of sessionIds) {
283
+ this.reportEventGap(
271
284
  sessionId,
285
+ "server_restarted",
272
286
  new EventGapError(
273
287
  `server instance changed; the turn's session log died with the old process`,
274
288
  ),
275
289
  );
276
290
  }
277
291
  for (const sessionId of this.cursor.sessions()) this.cursor.reset(sessionId);
278
- this.heldBack.clear();
292
+ // Keep frames actually received from the fresh process: they may already
293
+ // have left its bounded replay window. Only old transport entries die.
294
+ for (const [sessionId, held] of this.heldBack) {
295
+ const fresh = held.filter((entry) => entry.transport === this.transport);
296
+ if (fresh.length) {
297
+ this.heldBack.set(sessionId, fresh);
298
+ this.cursor.seek(sessionId, 0);
299
+ } else this.heldBack.delete(sessionId);
300
+ }
279
301
  }
280
302
 
281
303
  /**
@@ -317,7 +339,14 @@ export class AgentServerClient {
317
339
  /** Quick-ack only — observe the turn via `onEvent` / `replay`. */
318
340
  async startTurn(params: TurnStartParams): Promise<{ sessionId: string; turnId: string }> {
319
341
  await this.initialize();
320
- return this.request(WIRE_METHODS.turnStart, params, TurnStartResultSchema);
342
+ const sessionId = params.sessionId ?? generateUUIDv7();
343
+ const turnId = params.turnId ?? generateUUIDv7();
344
+ this.cursor.seek(sessionId, this.cursor.last(sessionId));
345
+ return this.request(
346
+ WIRE_METHODS.turnStart,
347
+ { ...params, sessionId, turnId },
348
+ TurnStartResultSchema,
349
+ );
321
350
  }
322
351
 
323
352
  /** Answer a `permission.requested` event. */
@@ -380,6 +409,12 @@ export class AgentServerClient {
380
409
  return () => this.eventHandlers.delete(handler);
381
410
  }
382
411
 
412
+ /** Observe unrecoverable delivery gaps even when using startTurn without a handle. */
413
+ onEventGap(handler: (gap: SessionEventGap) => void): () => void {
414
+ this.gapHandlers.add(handler);
415
+ return () => this.gapHandlers.delete(handler);
416
+ }
417
+
383
418
  /** Ask the server to shut down, then close this client. */
384
419
  async shutdownServer(): Promise<void> {
385
420
  await this.request(WIRE_METHODS.shutdown, {}, ShutdownResultSchema).catch(() => undefined);
@@ -406,7 +441,10 @@ export class AgentServerClient {
406
441
  }
407
442
  if (transport.closed) throw new ConnectionClosedError("transport opened already closed");
408
443
  this.transport = transport;
409
- transport.onLine((line) => this.handleLine(line));
444
+ this.awaitingHandshake = this.reconnecting;
445
+ transport.onLine((line) => {
446
+ if (this.transport === transport) this.handleLine(line);
447
+ });
410
448
  transport.onClose((reason) => this.handleTransportClose(transport, reason));
411
449
  }
412
450
 
@@ -443,10 +481,15 @@ export class AgentServerClient {
443
481
  this.initializePromise = null;
444
482
  await this.initialize();
445
483
  if (this.closed || !this.transport) continue;
484
+ this.awaitingHandshake = false;
446
485
  // Heal every tracked session — including turns that saw no event
447
486
  // before the drop (or none after it): without a resync they would
448
487
  // never receive their turn.ended and would hang forever.
449
- const sessionIds = new Set([...this.cursor.sessions(), ...this.turnBySession.keys()]);
488
+ const sessionIds = new Set([
489
+ ...this.cursor.sessions(),
490
+ ...this.turnBySession.keys(),
491
+ ...this.heldBack.keys(),
492
+ ]);
450
493
  for (const sessionId of sessionIds) void this.syncSession(sessionId);
451
494
  if (!this.transport) continue;
452
495
  return;
@@ -500,7 +543,7 @@ export class AgentServerClient {
500
543
  const sessionId = envelope.sessionId;
501
544
  // While a replay is in flight, every envelope queues: the drain re-runs
502
545
  // the cursor over the merged (replayed + live) backlog in seq order.
503
- if (this.syncing.has(sessionId)) {
546
+ if (this.awaitingHandshake || this.syncing.get(sessionId) === this.transport) {
504
547
  this.holdBack(sessionId, envelope);
505
548
  return;
506
549
  }
@@ -513,6 +556,11 @@ export class AgentServerClient {
513
556
  // arm fires only if a fresh log appears with NO transport drop,
514
557
  // which no current topology produces. If it does: the queue is
515
558
  // dead-log state, the fresh frame is authoritative.
559
+ this.reportEventGap(
560
+ sessionId,
561
+ "log_reset",
562
+ new EventGapError(`session ${sessionId} log reset`),
563
+ );
516
564
  this.heldBack.delete(sessionId);
517
565
  this.deliver(envelope);
518
566
  return;
@@ -529,16 +577,17 @@ export class AgentServerClient {
529
577
 
530
578
  private holdBack(sessionId: string, envelope: DecodedWireEventEnvelope): void {
531
579
  const held = this.heldBack.get(sessionId) ?? [];
532
- held.push(envelope);
580
+ held.push({ envelope, transport: this.transport });
533
581
  this.heldBack.set(sessionId, held);
534
582
  }
535
583
 
536
584
  private drainHeld(sessionId: string): void {
585
+ if (this.awaitingHandshake) return;
537
586
  const held = this.heldBack.get(sessionId);
538
587
  if (!held?.length) return;
539
- held.sort((a, b) => a.seq - b.seq);
588
+ held.sort((a, b) => a.envelope.seq - b.envelope.seq);
540
589
  while (held.length) {
541
- const next = held[0] as DecodedWireEventEnvelope;
590
+ const next = held[0]!.envelope;
542
591
  // `gap` leaves the cursor untouched, so breaking here is safe: the
543
592
  // envelope stays queued for the replay that fills the hole.
544
593
  const verdict = this.cursor.advance(sessionId, next.seq);
@@ -547,9 +596,15 @@ export class AgentServerClient {
547
596
  if (verdict === "reset") {
548
597
  // Defense-in-depth only (see the reset arm in handleEnvelope):
549
598
  // drop the queue, deliver the fresh frame, resync for its tail.
599
+ this.reportEventGap(
600
+ sessionId,
601
+ "log_reset",
602
+ new EventGapError(`session ${sessionId} log reset`),
603
+ );
550
604
  held.length = 0;
551
605
  this.deliver(next);
552
- void this.syncSession(sessionId);
606
+ // Let any replay owning this drain release its transport first.
607
+ queueMicrotask(() => void this.syncSession(sessionId));
553
608
  break;
554
609
  }
555
610
  if (verdict !== "duplicate") this.deliver(next);
@@ -558,32 +613,48 @@ export class AgentServerClient {
558
613
  }
559
614
 
560
615
  private async syncSession(sessionId: string): Promise<void> {
561
- if (this.syncing.has(sessionId) || this.closed || !this.transport) return;
562
- this.syncing.add(sessionId);
616
+ const transport = this.transport;
617
+ if (
618
+ !transport ||
619
+ this.awaitingHandshake ||
620
+ this.closed ||
621
+ this.syncing.get(sessionId) === transport
622
+ )
623
+ return;
624
+ this.syncing.set(sessionId, transport);
625
+ const isCurrent = () => !this.closed && !this.awaitingHandshake && this.transport === transport;
563
626
  try {
564
627
  const maxAttempts = Math.max(1, this.options.maxReplayAttempts ?? 3);
565
628
  const retryDelay = this.options.replayRetryDelayMs ?? this.options.reconnectDelayMs ?? 250;
566
- for (let attempt = 1; attempt <= maxAttempts && !this.closed && this.transport; attempt++) {
629
+ for (let attempt = 1; attempt <= maxAttempts && isCurrent(); attempt++) {
630
+ this.drainHeld(sessionId);
631
+ if (!isCurrent()) return;
567
632
  const fromSeq = this.cursor.last(sessionId) + 1;
568
633
  try {
569
634
  const replayed = await this.replay(sessionId, fromSeq);
570
- if (replayed.firstAvailableSeq !== null && replayed.firstAvailableSeq > fromSeq) {
635
+ if (!isCurrent()) return;
636
+ for (const envelope of replayed.events) this.holdBack(sessionId, envelope);
637
+ this.drainHeld(sessionId);
638
+ const missingFrom = this.cursor.last(sessionId) + 1;
639
+ if (replayed.firstAvailableSeq !== null && replayed.firstAvailableSeq > missingFrom) {
571
640
  // The prefix was evicted — the gap is unfillable. Fail the turn
572
641
  // handle (its part reconstruction is broken) and jump forward.
573
- this.failTurn(
642
+ this.reportEventGap(
574
643
  sessionId,
644
+ "evicted",
575
645
  new EventGapError(
576
- `events ${fromSeq}..${replayed.firstAvailableSeq - 1} for session ${sessionId} were evicted`,
646
+ `events ${missingFrom}..${replayed.firstAvailableSeq - 1} for session ${sessionId} were evicted`,
577
647
  ),
578
648
  );
579
649
  this.cursor.seek(sessionId, replayed.firstAvailableSeq - 1);
580
650
  }
581
- for (const envelope of replayed.events) this.holdBack(sessionId, envelope);
582
651
  break;
583
652
  } catch (err) {
653
+ if (!isCurrent()) return;
584
654
  if (err instanceof WireRequestError && err.code === WIRE_ERROR_CODES.unknownSession) {
585
- this.failTurn(
655
+ this.reportEventGap(
586
656
  sessionId,
657
+ "session_missing",
587
658
  new EventGapError(`session ${sessionId} is unknown to the server (restarted?)`),
588
659
  );
589
660
  this.cursor.reset(sessionId);
@@ -592,8 +663,9 @@ export class AgentServerClient {
592
663
  }
593
664
  if (!this.transport || this.closed) break; // reconnect performs a fresh resync
594
665
  if (attempt === maxAttempts) {
595
- this.failTurn(
666
+ this.reportEventGap(
596
667
  sessionId,
668
+ "replay_failed",
597
669
  new EventGapError(
598
670
  `failed to replay events from ${fromSeq} for session ${sessionId} after ${maxAttempts} attempts`,
599
671
  ),
@@ -605,9 +677,9 @@ export class AgentServerClient {
605
677
  }
606
678
  }
607
679
  } finally {
608
- this.syncing.delete(sessionId);
680
+ if (this.syncing.get(sessionId) === transport) this.syncing.delete(sessionId);
609
681
  }
610
- this.drainHeld(sessionId);
682
+ if (isCurrent()) this.drainHeld(sessionId);
611
683
  }
612
684
 
613
685
  /** Dispatch an envelope the cursor already accepted (it owns the watermark). */
@@ -630,6 +702,21 @@ export class AgentServerClient {
630
702
  }
631
703
  }
632
704
 
705
+ private reportEventGap(
706
+ sessionId: string,
707
+ reason: SessionEventGap["reason"],
708
+ error: EventGapError,
709
+ ): void {
710
+ this.failTurn(sessionId, error);
711
+ for (const handler of [...this.gapHandlers]) {
712
+ try {
713
+ handler({ sessionId, reason, error });
714
+ } catch {
715
+ // A consumer callback cannot interrupt replay or other subscribers.
716
+ }
717
+ }
718
+ }
719
+
633
720
  private failTurn(sessionId: string, error: Error): void {
634
721
  const turn = this.turnBySession.get(sessionId);
635
722
  if (!turn) return;
@@ -6,6 +6,7 @@ export {
6
6
  type AgentServerClientOptions,
7
7
  ConnectionClosedError,
8
8
  EventGapError,
9
+ type SessionEventGap,
9
10
  type TurnHandle,
10
11
  WireRequestError,
11
12
  } from "./client.ts";
@@ -395,6 +395,16 @@ export class CodexAppServerAgent extends BaseAgent {
395
395
  chatgptPlanType: tokens.chatgptPlanType ?? null,
396
396
  };
397
397
  }
398
+ // Elicitations and other server requests have their own response shapes.
399
+ // Sending them to the approval broker can leave a turn waiting forever.
400
+ if (
401
+ method !== "item/commandExecution/requestApproval" &&
402
+ method !== "item/fileChange/requestApproval" &&
403
+ method !== "execCommandApproval" &&
404
+ method !== "applyPatchApproval"
405
+ ) {
406
+ throw new Error(`Unsupported Codex server request: ${method}`);
407
+ }
398
408
  const requestThread = params.threadId ?? params.conversationId;
399
409
  const v2 = method.startsWith("item/");
400
410
  const decline = v2 ? { decision: "decline" } : { decision: "denied" };