diff --git a/apps/host-daemon/src/event-sink.test.ts b/apps/host-daemon/src/event-sink.test.ts index 52510a4e6a..9bafbb7787 100644 --- a/apps/host-daemon/src/event-sink.test.ts +++ b/apps/host-daemon/src/event-sink.test.ts @@ -1,6 +1,21 @@ import { threadScope } from "@bb/domain"; import { describe, expect, it, vi } from "vitest"; import { createEventSink, type CreateEventSinkOptions } from "./event-sink.js"; +import { ServerResponseError } from "./server-client.js"; + +// The server rejects an event it can never store — e.g. a turn-scoped event +// whose turn/started it never saw — with a non-retryable 409. Reposting the +// identical batch always produces the identical rejection. +function permanentRejection(bodyMessage: string): ServerResponseError { + return new ServerResponseError({ + action: "post events", + bodyMessage, + code: "invalid_request", + retryable: false, + status: 409, + statusText: "Conflict", + }); +} function createLogger(): CreateEventSinkOptions["logger"] { return { @@ -129,7 +144,7 @@ describe("event sink", () => { expect(postEvents).toHaveBeenCalledTimes(1); }); - it("logs a debug tripwire once when the queue grows large while undelivered", () => { + it("warns once when the queue grows large while undelivered", () => { const logger = createLogger(); const sink = createEventSink({ isSessionOpen: () => false, @@ -140,18 +155,198 @@ describe("event sink", () => { for (let index = 0; index < 511; index += 1) { sink.emit({ threadId: "thr_1", event: systemErrorEvent("thr_1") }); } - expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); // Crossing the depth threshold fires the tripwire once... sink.emit({ threadId: "thr_1", event: systemErrorEvent("thr_1") }); sink.emit({ threadId: "thr_1", event: systemErrorEvent("thr_1") }); - expect(logger.debug).toHaveBeenCalledTimes(1); - expect(logger.debug).toHaveBeenCalledWith( + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ queueDepth: 512 }), expect.any(String), ); }); + it("drops a permanently rejected event instead of retrying it forever", async () => { + const logger = createLogger(); + const postEvents = vi.fn( + async (events) => { + if (events.some((event) => event.threadId === "thr_poison")) { + throw permanentRejection( + "Cannot append provider/unhandled for turn auto-compact-1 before turn/started is stored", + ); + } + return { + kind: "accepted", + acceptedEvents: events.map((event, eventIndex) => ({ + eventIndex, + sequence: eventIndex + 1, + threadId: event.threadId, + })), + rejectedEvents: [], + }; + }, + ); + const sink = createEventSink({ + isSessionOpen: () => true, + logger, + postEvents, + }); + + sink.emit({ + threadId: "thr_poison", + event: systemErrorEvent("thr_poison"), + }); + await sink.flush(); + + // The poison event is gone, so a later flush has nothing left to send. + postEvents.mockClear(); + await sink.flush(); + expect(postEvents).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + it("delivers events queued behind a permanently rejected event", async () => { + // The production wedge: one undeliverable event sat at the head of the + // single host-wide queue, so every other thread's events piled up behind it + // and never reached the server. Every thread showed as stuck until the app + // was restarted. + const delivered: string[] = []; + const postEvents = vi.fn( + async (events) => { + if (events.some((event) => event.threadId === "thr_poison")) { + throw permanentRejection( + "Cannot append provider/unhandled for turn auto-compact-1 before turn/started is stored", + ); + } + delivered.push(...events.map((event) => event.threadId)); + return { + kind: "accepted", + acceptedEvents: events.map((event, eventIndex) => ({ + eventIndex, + sequence: eventIndex + 1, + threadId: event.threadId, + })), + rejectedEvents: [], + }; + }, + ); + let sessionOpen = false; + const sink = createEventSink({ + isSessionOpen: () => sessionOpen, + logger: createLogger(), + postEvents, + }); + + // One poison event, then healthy traffic from two other threads behind it, + // all accumulated while the session was closed — the same shape as the + // production queue at the moment the wedge began. + sink.emit({ + threadId: "thr_poison", + event: systemErrorEvent("thr_poison"), + }); + sink.emit({ threadId: "thr_a", event: systemErrorEvent("thr_a") }); + sink.emit({ threadId: "thr_b", event: systemErrorEvent("thr_b") }); + sink.emit({ threadId: "thr_a", event: systemErrorEvent("thr_a") }); + + await sink.flush(); + expect(postEvents).not.toHaveBeenCalled(); + + sessionOpen = true; + await sink.flush(); + + expect(delivered).toEqual(["thr_a", "thr_b", "thr_a"]); + + // Nothing is left behind: the queue fully drained. + postEvents.mockClear(); + await sink.flush(); + expect(postEvents).not.toHaveBeenCalled(); + }); + + it("keeps retrying a batch that fails for a retryable reason", async () => { + const postEvents = vi + .fn() + .mockRejectedValueOnce( + new ServerResponseError({ + action: "post events", + bodyMessage: "database is locked", + code: "internal_error", + retryable: true, + status: 500, + statusText: "Internal Server Error", + }), + ) + .mockImplementation(async (events) => ({ + kind: "accepted", + acceptedEvents: events.map((event, eventIndex) => ({ + eventIndex, + sequence: eventIndex + 1, + threadId: event.threadId, + })), + rejectedEvents: [], + })); + const sink = createEventSink({ + isSessionOpen: () => true, + logger: createLogger(), + postEvents, + }); + + sink.emit({ threadId: "thr_1", event: systemErrorEvent("thr_1") }); + await sink.flush(); + await sink.flush(); + + expect(postEvents).toHaveBeenCalledTimes(2); + expect(postEvents).toHaveBeenLastCalledWith([ + { threadId: "thr_1", event: systemErrorEvent("thr_1") }, + ]); + }); + + it("keeps events queued when the session, not the batch, is rejected", async () => { + // 401 inactive_session is a non-retryable 4xx that says nothing about the + // events — the daemon is about to reopen a session and deliver them. Only + // `invalid_request` means the payload itself is the problem, so these must + // survive rather than get bisected away one at a time. + const postEvents = vi + .fn() + .mockRejectedValueOnce( + new ServerResponseError({ + action: "post events", + bodyMessage: "Session is not active", + code: "inactive_session", + retryable: false, + status: 401, + statusText: "Unauthorized", + }), + ) + .mockImplementation(async (events) => ({ + kind: "accepted", + acceptedEvents: events.map((event, eventIndex) => ({ + eventIndex, + sequence: eventIndex + 1, + threadId: event.threadId, + })), + rejectedEvents: [], + })); + const sink = createEventSink({ + isSessionOpen: () => true, + logger: createLogger(), + postEvents, + }); + + sink.emit({ threadId: "thr_1", event: systemErrorEvent("thr_1") }); + sink.emit({ threadId: "thr_2", event: systemErrorEvent("thr_2") }); + await sink.flush(); + + // Only the one failed attempt: no bisecting, nothing dropped. + expect(postEvents).toHaveBeenCalledTimes(1); + + await sink.flush(); + expect(postEvents).toHaveBeenLastCalledWith([ + { threadId: "thr_1", event: systemErrorEvent("thr_1") }, + { threadId: "thr_2", event: systemErrorEvent("thr_2") }, + ]); + }); + it("never throws from emit regardless of how many events queue up", () => { const sink = createEventSink({ isSessionOpen: () => false, diff --git a/apps/host-daemon/src/event-sink.ts b/apps/host-daemon/src/event-sink.ts index a055335989..4280556341 100644 --- a/apps/host-daemon/src/event-sink.ts +++ b/apps/host-daemon/src/event-sink.ts @@ -6,14 +6,15 @@ import type { } from "@bb/host-daemon-contract"; import { normalizeCaughtError, runtimeErrorLogFields } from "./error-utils.js"; import type { HostDaemonLogger } from "./logger.js"; +import { ServerResponseError } from "./server-client.js"; const DEFAULT_DEBOUNCE_MS = 100; // Tripwires for noticing that delivery has stalled and the in-memory queue is -// growing. These only emit a debug log — they never drop, fault, or bound the -// queue. If they fire in practice, that is the signal to add real backpressure. -const QUEUE_DEPTH_DEBUG_THRESHOLD = 512; -const QUEUE_AGE_DEBUG_THRESHOLD_MS = 30_000; +// growing. These only warn — they never drop, fault, or bound the queue. If +// they fire in practice, that is the signal to add real backpressure. +const QUEUE_DEPTH_WARN_THRESHOLD = 512; +const QUEUE_AGE_WARN_THRESHOLD_MS = 30_000; export interface EventSinkInput { event: ThreadEvent; @@ -87,6 +88,23 @@ export function shouldFlushThreadEventImmediately(event: ThreadEvent): boolean { return isWaitingForApprovalItemEvent(event); } +// True when the server refused these events for what they *are*, so reposting +// them unchanged can only produce the same refusal: a malformed batch (400) or +// one carrying an event the store will never accept (409). Both answer with +// `invalid_request`. +// +// The code check is what keeps this narrow. `/session/events` also fails +// non-retryably with `unauthorized` and `inactive_session` (401), and those say +// nothing about the events themselves — the daemon must keep them queued for +// the session it is about to reopen, not discard them. +function isPermanentPostRejection(error: Error): boolean { + return ( + error instanceof ServerResponseError && + !error.retryable && + error.code === "invalid_request" + ); +} + function summarizeRejectedEvents( events: readonly HostDaemonRejectedEvent[], ): RejectedEventSummary[] { @@ -114,13 +132,15 @@ export function createEventSink(options: CreateEventSinkOptions): EventSink { const queueDepth = queue.length; const queueAgeMs = Date.now() - backedUpSinceMs; if ( - queueDepth < QUEUE_DEPTH_DEBUG_THRESHOLD && - queueAgeMs < QUEUE_AGE_DEBUG_THRESHOLD_MS + queueDepth < QUEUE_DEPTH_WARN_THRESHOLD && + queueAgeMs < QUEUE_AGE_WARN_THRESHOLD_MS ) { return; } backpressureLogged = true; - options.logger.debug( + // A stalled queue means every thread on this host is silently falling + // behind in the UI, so this needs to be visible at the default log level. + options.logger.warn( { queueDepth, queueAgeMs }, "Daemon event queue is backing up; delivery may be stalled", ); @@ -155,38 +175,88 @@ export function createEventSink(options: CreateEventSinkOptions): EventSink { }, delayMs); } - // Posts queued events while the session is open. Events that cannot be - // delivered right now — because the session is closed or the post failed — - // stay queued and are retried by the next flush (the next emit, or the - // reconnect that reopens the session). The queue lives only in memory, so a - // daemon crash drops anything still pending; that is an accepted tradeoff. - async function drainQueue(): Promise { - while (queue.length > 0 && !disposed && options.isSessionOpen()) { - const batch = queue.slice(); - let response: EventPostResult; - try { - response = await options.postEvents(batch); - } catch (error) { + // Delivers `batch` and reports how many of its leading events no longer need + // sending — either the server took them or they were undeliverable by + // construction and were dropped. A count below `batch.length` means delivery + // stopped early and the remainder must be retried by a later flush. + // + // The server marks a rejection non-retryable when reposting the identical + // payload can only produce the identical rejection (a turn-scoped event whose + // turn/started it never saw, for instance, which it answers with 409). Such a + // batch must never be retried as-is: the queue is host-wide, so one + // undeliverable event at its head would stall every thread on the machine + // until the daemon restarted. Bisect instead — the server appends a batch in + // a single transaction and rolls the whole thing back when it refuses one + // event, so nothing was committed and re-posting the halves cannot duplicate. + // That isolates the offending events in O(log n) posts and lets the healthy + // ones through. + async function deliverBatch( + batch: readonly HostDaemonEventEnvelope[], + ): Promise { + let response: EventPostResult; + try { + response = await options.postEvents([...batch]); + } catch (error) { + const normalized = normalizeCaughtError(error); + if (!isPermanentPostRejection(normalized)) { options.logger.error( - runtimeErrorLogFields(normalizeCaughtError(error)), + runtimeErrorLogFields(normalized), "Failed to post daemon events; will retry on the next flush", ); - return; + return 0; } - if (response.rejectedEvents.length > 0) { - options.logger.warn( + const [offending] = batch; + if (batch.length === 1 && offending !== undefined) { + options.logger.error( { - rejectedEvents: summarizeRejectedEvents(response.rejectedEvents), + ...runtimeErrorLogFields(normalized), + eventType: offending.event.type, + threadId: offending.threadId, }, - "Server rejected daemon events", + "Dropped a daemon event the server will never accept", ); + return 1; + } + + const midpoint = Math.floor(batch.length / 2); + const deliveredFromFirstHalf = await deliverBatch( + batch.slice(0, midpoint), + ); + if (deliveredFromFirstHalf < midpoint) { + return deliveredFromFirstHalf; } - queue.splice(0, batch.length); + return midpoint + (await deliverBatch(batch.slice(midpoint))); + } + + if (response.rejectedEvents.length > 0) { + options.logger.warn( + { + rejectedEvents: summarizeRejectedEvents(response.rejectedEvents), + }, + "Server rejected daemon events", + ); + } + return batch.length; + } + + // Posts queued events while the session is open. Events that cannot be + // delivered right now — because the session is closed or the post failed — + // stay queued and are retried by the next flush (the next emit, or the + // reconnect that reopens the session). The queue lives only in memory, so a + // daemon crash drops anything still pending; that is an accepted tradeoff. + async function drainQueue(): Promise { + while (queue.length > 0 && !disposed && options.isSessionOpen()) { + const batch = queue.slice(); + const delivered = await deliverBatch(batch); + queue.splice(0, delivered); if (queue.length === 0) { backedUpSinceMs = null; backpressureLogged = false; } + if (delivered < batch.length) { + return; + } } } diff --git a/apps/server/test/internal/internal-event-append-ownership.test.ts b/apps/server/test/internal/internal-event-append-ownership.test.ts index 234ea3453b..445881e58f 100644 --- a/apps/server/test/internal/internal-event-append-ownership.test.ts +++ b/apps/server/test/internal/internal-event-append-ownership.test.ts @@ -266,6 +266,76 @@ describe("internal event append ownership", () => { } }); + it("accepts a batch carrying a provider/unhandled event for a turn bb never started", async () => { + // The production wedge, at the route that produced it. Codex labels its + // automatic-compaction traffic with a turn id of its own making, so the + // daemon posted a provider/unhandled event scoped to `auto-compact-1`. The + // append rolled the whole batch back and answered 409 — which the daemon, + // holding one queue for every thread on the host, reposted verbatim until + // the app was restarted. The orphan event must be dropped and its batch + // must survive. + const { harness, session, thread } = await setupEventRoute(); + try { + const response = await postEventBatch({ + harness, + sessionId: session.id, + events: [ + { + threadId: thread.id, + event: { + type: "provider/unhandled", + threadId: thread.id, + providerThreadId: "provider-compacting-session", + providerId: "codex", + rawType: "sdk/custom", + scope: turnScope("auto-compact-1"), + rawEvent: { + jsonrpc: "2.0", + method: "sdk/message", + params: { threadId: thread.id, turnId: "auto-compact-1" }, + }, + }, + }, + { + threadId: thread.id, + event: { + type: "system/error", + threadId: thread.id, + scope: threadScope(), + message: "queued behind the orphan event", + }, + }, + ], + }); + + expect(response.status).toBe(200); + await expect(readJson(response)).resolves.toEqual({ + acceptedEvents: [ + { + eventIndex: 1, + threadId: thread.id, + sequence: 1, + }, + ], + rejectedEvents: [], + }); + expect( + harness.db + .select() + .from(events) + .where(eq(events.threadId, thread.id)) + .all(), + ).toMatchObject([ + { + sequence: 1, + type: "system/error", + }, + ]); + } finally { + await harness.cleanup(); + } + }); + it("assigns distinct sequences for simultaneous requests on the same thread", async () => { const { harness, session, thread } = await setupEventRoute(); try { diff --git a/packages/agent-runtime/src/codex/adapter.test.ts b/packages/agent-runtime/src/codex/adapter.test.ts index 4582400899..3cce024952 100644 --- a/packages/agent-runtime/src/codex/adapter.test.ts +++ b/packages/agent-runtime/src/codex/adapter.test.ts @@ -2697,13 +2697,18 @@ describe("codex provider adapter", () => { }, }); + // Thread scope, not turnScope("turn-1"): this notification failed schema + // parsing, so nothing here vouches for that turn id being one bb started. + // Turn-scoping an event whose turn/started the server never stored gets the + // event dropped; thread scope keeps it. Codex notifications bb *does* parse + // still carry turn scope — see the handled item/started cases above. expect(events).toContainEqual( expect.objectContaining({ type: "provider/unhandled", providerId: "codex", rawType: "item/tool/requestUserInput", threadId: "t1", - scope: turnScope("turn-1"), + scope: threadScope(), }), ); }); diff --git a/packages/agent-runtime/src/shared/provider-unhandled-event.test.ts b/packages/agent-runtime/src/shared/provider-unhandled-event.test.ts index c0b5320bad..98456ad02c 100644 --- a/packages/agent-runtime/src/shared/provider-unhandled-event.test.ts +++ b/packages/agent-runtime/src/shared/provider-unhandled-event.test.ts @@ -33,4 +33,38 @@ describe("provider unhandled events", () => { }, }); }); + + it("scopes to the turn the caller supplies", () => { + const event = createUnhandledProviderEvent({ + providerId: "codex", + rawType: "sdk/custom", + turnId: "turn_bb_owned", + rawEvent: { + jsonrpc: "2.0", + method: "sdk/message", + params: { threadId: "thread-1" }, + }, + }); + + expect(event.scope).toEqual({ kind: "turn", turnId: "turn_bb_owned" }); + }); + + it("ignores a provider-supplied turn id the caller did not vouch for", () => { + // Codex labels its automatic-compaction traffic with a `turnId` of its own + // making ("auto-compact-1"). bb never started that turn, so scoping to it + // produces an event the server can never store: it rejects the whole batch + // with 409 MissingStoredTurnStartedError, and the daemon then retries that + // same batch forever, wedging every thread on the host. + const event = createUnhandledProviderEvent({ + providerId: "codex", + rawType: "sdk/custom", + rawEvent: { + jsonrpc: "2.0", + method: "sdk/message", + params: { threadId: "thread-1", turnId: "auto-compact-1" }, + }, + }); + + expect(event.scope).toEqual({ kind: "thread" }); + }); }); diff --git a/packages/agent-runtime/src/shared/provider-unhandled-event.ts b/packages/agent-runtime/src/shared/provider-unhandled-event.ts index 4cab7a6d3a..597885e420 100644 --- a/packages/agent-runtime/src/shared/provider-unhandled-event.ts +++ b/packages/agent-runtime/src/shared/provider-unhandled-event.ts @@ -61,19 +61,18 @@ function getThreadIdFromRawEvent(rawEvent: JsonRpcMessage): string { return getStringProperty(rawEvent.params, "threadId") ?? UNSTAMPED_THREAD_ID; } -function getTurnIdFromRawEvent(rawEvent: JsonRpcMessage): string | undefined { - if (!isRecord(rawEvent.params)) { - return undefined; - } - return getStringProperty(rawEvent.params, "turnId"); -} - export function createUnhandledProviderEvent( args: CreateUnhandledProviderEventArgs, ): ProviderUnhandledEvent { const threadId = args.threadId ?? getThreadIdFromRawEvent(args.rawEvent); const providerThreadId = args.providerThreadId ?? threadId; - const turnId = args.turnId ?? getTurnIdFromRawEvent(args.rawEvent); + // Only a turn id the caller vouched for — one bb itself opened and can + // therefore be trusted to have a stored turn/started — may scope this event. + // A provider labels its own internal traffic with turn ids of its own making + // (Codex tags automatic-compaction events "auto-compact-N"), and callers omit + // `turnId` precisely when bb has no active turn, so reading one out of the + // raw event would scope the event to a turn that never existed. + const turnId = args.turnId; return { type: "provider/unhandled", diff --git a/packages/db/src/data/events.ts b/packages/db/src/data/events.ts index 102127124e..43e76362c1 100644 --- a/packages/db/src/data/events.ts +++ b/packages/db/src/data/events.ts @@ -383,11 +383,20 @@ function listStoredTurnStartedKeySet( // parent's session, which reports the parent's last-turn usage scoped to a turn // the forked thread never started. Dropping such an orphan snapshot is correct // and avoids wedging the whole event batch (which would otherwise roll back the -// fork's identity + turn events and retry forever). Turn-content events still -// require a stored turn/started, so genuine ordering bugs are still caught. +// fork's identity + turn events and retry forever). +// +// provider/unhandled is here for the same reason: it is a diagnostic +// passthrough for provider traffic bb has no translation for, and the provider +// can label that traffic with a turn id of its own making (Codex tags +// automatic-compaction events "auto-compact-N"). Losing one is a non-event; +// failing the batch it rode in with is not. +// +// Turn-content events still require a stored turn/started, so genuine ordering +// bugs are still caught. const ORPHAN_DROPPABLE_TURN_EVENT_TYPES: ReadonlySet = new Set([ "thread/tokenUsage/updated", "thread/contextWindowUsage/updated", + "provider/unhandled", ]); type DaemonTurnStartDisposition = "append" | "skip-orphan-snapshot"; diff --git a/packages/db/test/data/events.test.ts b/packages/db/test/data/events.test.ts index a33e7c302f..2380c1ce80 100644 --- a/packages/db/test/data/events.test.ts +++ b/packages/db/test/data/events.test.ts @@ -477,6 +477,56 @@ describe("events", () => { ).toEqual(["turn/started"]); }); + it("drops orphan provider/unhandled events instead of failing the batch", () => { + const { db, thread } = setup(); + + // A provider can label its own internal traffic with a turn id bb never + // started (Codex tags automatic-compaction events "auto-compact-N"). An + // unhandled passthrough event is diagnostic only, so dropping it is always + // cheaper than rolling back the batch it rode in with — which the daemon + // would then repost forever, stalling every thread on the host. + const result = db.transaction( + (tx) => + appendDaemonEventsInTransaction(tx, [ + { + threadId: thread.id, + type: "provider/unhandled", + ...createTurnEventFields({ turnId: "auto-compact-1" }), + environmentId: null, + providerThreadId: "provider_thr_compacting", + data: JSON.stringify({ + providerThreadId: "provider_thr_compacting", + providerId: "codex", + rawType: "sdk/custom", + rawEvent: { + jsonrpc: "2.0", + method: "sdk/message", + params: { turnId: "auto-compact-1" }, + }, + }), + }, + { + threadId: thread.id, + type: "turn/started", + ...createTurnEventFields({ turnId: "turn_after_compaction" }), + environmentId: null, + providerThreadId: "provider_thr_compacting", + data: JSON.stringify({ + providerThreadId: "provider_thr_compacting", + turnId: "turn_after_compaction", + }), + }, + ]), + { behavior: "immediate" }, + ); + + expect(result.skippedTurnUnstartedInputIndexes).toEqual([0]); + expect(result.insertedInputIndexes).toEqual([1]); + expect( + listEvents(db, { threadId: thread.id }).map((event) => event.type), + ).toEqual(["turn/started"]); + }); + it("accepts daemon turn-scoped events after earlier turn/started in the same batch", () => { const { db, thread } = setup(); diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts index b150b3a5c6..c9948123be 100644 --- a/packages/host-daemon-contract/src/commands.ts +++ b/packages/host-daemon-contract/src/commands.ts @@ -36,7 +36,7 @@ import { providerCliStatusResponseSchema, } from "./local.js"; -export const HOST_DAEMON_PROTOCOL_VERSION = 101 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 102 as const; export { BRANCH_LIST_LIMIT_MAX, diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index 550d834c05..b32059bf3a 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -1055,10 +1055,14 @@ describe("host-daemon local schemas", () => { }); describe("host-daemon command schemas", () => { - // Version 101 builds on Codex inference deadlines in version 100 with - // provider-native history checkpointing and ownership-leased staged rewinds. - it("uses protocol version 101 for leased staged thread rewind cleanup", () => { - expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(101); + // Version 102 stops the event sink from reposting a batch the server has + // permanently refused. An enrolled daemon on an older build retries such a + // batch forever, and because the queue is host-wide that stalls every thread + // on the machine until it restarts, so it must update before it delivers more + // events. It also stops scoping provider/unhandled events to turn ids that + // came from the provider rather than from bb. + it("uses protocol version 102 for non-repeating permanent event rejections", () => { + expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(102); }); it("binds Plan cancellation to a required turn id and typed result", () => {