diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97bacb163..331e1b16d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -109,7 +109,7 @@ In TUI chat mode there is no completion gate — the session stays open across t rather than forwarding to a successor. `/clear` and `/new` bump a delivery generation and call `SessionBridge.clearQueuedDelivery()` so queued input and deferred recoveries from the previous session cannot enter the new one. -- **Session rotation** — Uses a serial session-operation queue (`createSessionOperationQueue`, not a boolean flag) so rotation, compaction continuation, and `agentProxy.deliver` never race a concurrent rebuild. Each operation chains onto the tail, ensuring in-flight work completes before the agent is torn down. +- **Session rotation** — Uses a serial session-operation queue (`createSessionOperationQueue`, not a boolean flag) so rotation, compaction continuation, and `agentProxy.deliver` never race a concurrent rebuild. Each operation chains onto the tail; continuation hops are preemptible so interrupt and rotation abort a hung deliver instead of waiting forever, while serial rebuild/reload/rotation still finish `close()` before the next op. ### Exec Runner (`src/exec/runner.ts`) diff --git a/src/tui/deliver-agent-message.test.ts b/src/tui/deliver-agent-message.test.ts index b144f6a6c..4768c1de6 100644 --- a/src/tui/deliver-agent-message.test.ts +++ b/src/tui/deliver-agent-message.test.ts @@ -131,7 +131,7 @@ describe("runGenerationGuardedDeliver", () => { }); describe("settleCompactionContinuationHop", () => { - test("a superseded hop requeues instead of delivering to the outgoing agent", async () => { + test("a superseded hop does not deliver to the outgoing agent", async () => { let generation = 1; const stillCurrent = () => generation === 1; let delivered = 0; @@ -195,14 +195,55 @@ describe("settleCompactionContinuationHop", () => { expect(runs).toBe(1); }); - test("continuation enqueued then interrupt rebuild queued lands on the replacement agent", async () => { - const { enqueue, awaitTail } = createSessionOperationQueue(); + test("an accepted hop stays accepted if generation flips after deliver", async () => { + let generation = 1; + const result = await settleCompactionContinuationHop({ + stillCurrent: () => generation === 1, + deliver: async () => { + generation = 2; + return { status: "accepted" as const }; + }, + onSuperseded: () => { + throw new Error("accepted hop must not be relabeled superseded"); + }, + }); + expect(result).toEqual({ status: "accepted" }); + }); + + test("a closed hop that goes stale does not retry deliver", async () => { + let generation = 1; + let attempts = 0; + const result = await settleCompactionContinuationHop({ + stillCurrent: () => generation === 1, + deliver: async () => { + attempts += 1; + generation = 2; + return { + status: "not-delivered" as const, + reason: "agent-closed" as const, + detail: "agent is closed", + }; + }, + onSuperseded: () => undefined, + }); + expect(result).toEqual({ + status: "not-delivered", + reason: "superseded", + detail: "session identity changed before delivery", + }); + expect(attempts).toBe(1); + }); + + test("continuation enqueued then interrupt rebuild queued does not auto-deliver to the replacement agent", async () => { + const { enqueue, enqueuePreemptible, abortInFlight, awaitTail } = + createSessionOperationQueue(); const deliveryGeneration = createDeliveryGeneration(); let agent = "outgoing"; const deliveredTo: string[] = []; + const order: string[] = []; enqueueCompactionContinuationHop({ - enqueue, + enqueue: enqueuePreemptible, captureGeneration: () => deliveryGeneration.capture(), deliver: async () => { deliveredTo.push(agent); @@ -211,17 +252,213 @@ describe("settleCompactionContinuationHop", () => { onResult: () => undefined, }); + startInterruptRebuild({ + deliveryGeneration: { + bump: () => { + order.push("bump"); + deliveryGeneration.bump(); + }, + }, + markSendAborted: () => undefined, + abortInFlight: () => { + order.push("abortInFlight"); + abortInFlight(); + }, + enqueue: (op) => { + order.push("enqueue"); + return enqueue(op); + }, + rebuild: async () => { + agent = "replacement"; + }, + }); + + expect(order[0]).toBe("bump"); + expect(order.indexOf("abortInFlight")).toBeGreaterThan( + order.indexOf("bump"), + ); + expect(order.indexOf("enqueue")).toBeGreaterThan( + order.indexOf("abortInFlight"), + ); + + await awaitTail(); + expect(deliveredTo).not.toContain("outgoing"); + expect(deliveredTo).not.toContain("replacement"); + }); + + test("hung hop then startInterruptRebuild runs rebuild without delivering to the outgoing agent", async () => { + const { enqueue, enqueuePreemptible, abortInFlight, awaitTail } = + createSessionOperationQueue(); + const deliveryGeneration = createDeliveryGeneration(); + let hopStarted = false; + let rebuilt = false; + const deliveredTo: string[] = []; + + enqueueCompactionContinuationHop({ + enqueue: enqueuePreemptible, + captureGeneration: () => deliveryGeneration.capture(), + deliver: async () => { + hopStarted = true; + await new Promise(() => undefined); + deliveredTo.push("outgoing"); + return { status: "accepted" as const }; + }, + onResult: () => undefined, + }); + + await Promise.resolve(); + await Promise.resolve(); + expect(hopStarted).toBe(true); + startInterruptRebuild({ deliveryGeneration, markSendAborted: () => undefined, + abortInFlight, enqueue, rebuild: async () => { - agent = "replacement"; + rebuilt = true; }, }); + let timer: ReturnType | undefined; + try { + await Promise.race([ + awaitTail(), + new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new Error("rebuild did not run; hung hop parked the tail"), + ), + 250, + ); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } + expect(rebuilt).toBe(true); + expect(deliveredTo).toEqual([]); + }); + + test("stall-abort of a hung continuation does not requeue onto the replacement agent", async () => { + const { enqueue, enqueuePreemptible, abortInFlight, awaitTail } = + createSessionOperationQueue(); + const deliveryGeneration = createDeliveryGeneration(); + let settleDeliver: + | ((result: { + status: "not-delivered"; + reason: "agent-closed"; + detail: string; + }) => void) + | undefined; + let deliverCalls = 0; + let hopStarted = false; + let rebuilt = false; + + enqueueCompactionContinuationHop({ + enqueue: enqueuePreemptible, + captureGeneration: () => deliveryGeneration.capture(), + deliver: async () => { + deliverCalls += 1; + hopStarted = true; + return await new Promise((resolve) => { + settleDeliver = resolve; + }); + }, + onResult: () => undefined, + }); + + await Promise.resolve(); + await Promise.resolve(); + expect(hopStarted).toBe(true); + + deliveryGeneration.bump(); + abortInFlight(); + enqueue(async () => { + rebuilt = true; + }); + + let timer: ReturnType | undefined; + try { + await Promise.race([ + awaitTail(), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error("stall abort left the tail parked")), + 250, + ); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } + + expect(rebuilt).toBe(true); + settleDeliver?.({ + status: "not-delivered", + reason: "agent-closed", + detail: "agent is closed", + }); + await Promise.resolve(); + await Promise.resolve(); + expect(deliverCalls).toBe(1); + }); + + test("two distinct continuation seqs both enqueue", async () => { + const { enqueuePreemptible, awaitTail } = createSessionOperationQueue(); + const deliveryGeneration = createDeliveryGeneration(); + const delivered: number[] = []; + + enqueueCompactionContinuationHop({ + enqueue: enqueuePreemptible, + captureGeneration: () => deliveryGeneration.capture(), + deliver: async () => { + delivered.push(1); + return { status: "accepted" as const }; + }, + onResult: () => undefined, + }); + enqueueCompactionContinuationHop({ + enqueue: enqueuePreemptible, + captureGeneration: () => deliveryGeneration.capture(), + deliver: async () => { + delivered.push(2); + return { status: "accepted" as const }; + }, + onResult: () => undefined, + }); + + await awaitTail(); + expect(delivered).toEqual([1, 2]); + }); + + test("an interrupt-stale hop does not report not-delivered", async () => { + const { enqueue, enqueuePreemptible, abortInFlight, awaitTail } = + createSessionOperationQueue(); + const deliveryGeneration = createDeliveryGeneration(); + const notices: string[] = []; + + enqueueCompactionContinuationHop({ + enqueue: enqueuePreemptible, + captureGeneration: () => deliveryGeneration.capture(), + deliver: async () => ({ status: "accepted" as const }), + onResult: (result) => { + if (result.status === "accepted") return; + notices.push(result.detail); + }, + }); + + startInterruptRebuild({ + deliveryGeneration, + markSendAborted: () => undefined, + abortInFlight, + enqueue, + rebuild: async () => undefined, + }); + await awaitTail(); - expect(deliveredTo).toEqual(["replacement"]); + expect(notices).toEqual([]); }); }); diff --git a/src/tui/delivery-queue.ts b/src/tui/delivery-queue.ts index af90d345f..b10d5479b 100644 --- a/src/tui/delivery-queue.ts +++ b/src/tui/delivery-queue.ts @@ -84,9 +84,9 @@ export async function deliverAgentMessage( /** * Settles a deliver that was enqueued on the serial operation queue against - * the shoot generation captured at enqueue time. The queue is FIFO with no - * preemption, so a deliver queued ahead of a reload still executes after the - * reload has replaced the agent — the generation must be re-checked when the + * the shoot generation captured at enqueue time. Serial ops stay FIFO with + * no preemption; continuation hops are preemptible, so a hung compact-continue + * cannot park interrupt. Either way the generation must be re-checked when the * queued closure runs, not just when it enqueues. A stale deliver takes the * `onStale` path (the caller reports `not-delivered`); a current deliver runs * the real settle. This is what closes the reload-vs-async-deliver race: a @@ -112,9 +112,9 @@ function compactionContinuationIsSuperseded( /** * Compact continuation is consume-once at the stream gate. Interrupt rebuild - * bumps generation and enqueues a rebuild behind this hop, so a stale hop - * must re-queue rather than retry liveAgent in this op. A closed hop still - * retries once here. + * bumps generation and aborts an in-flight hop so a hung deliver cannot park + * the serial tail. An interrupt-stale or abort-abandoned hop is fail-closed: + * it must not re-queue onto the replacement agent. */ export async function settleCompactionContinuationHop(options: { stillCurrent: () => boolean; @@ -135,15 +135,21 @@ export async function settleCompactionContinuationHop(options: { return first; } if (first.status === "not-delivered" && first.reason === "agent-closed") { + if (!options.stillCurrent()) { + return { + status: "not-delivered", + reason: "superseded", + detail: "session identity changed before delivery", + }; + } return options.deliver(); } return first; } /** - * Enqueue a compact-continue hop. If interrupt already bumped generation and - * queued a rebuild behind this hop, the continue is scheduled again after - * that rebuild instead of retrying liveAgent in the same op. + * Enqueue a compact-continue hop on the preemptible lane. Interrupt and stall + * abort drop the hop instead of re-queueing it onto a replacement agent. */ export function enqueueCompactionContinuationHop(options: { enqueue: (op: () => Promise) => unknown; @@ -151,19 +157,17 @@ export function enqueueCompactionContinuationHop(options: { deliver: () => Promise; onResult: (result: AgentDeliveryResult) => void; }): void { - const schedule = (): void => { - const stillCurrent = options.captureGeneration(); - void options.enqueue(async () => { - const result = await settleCompactionContinuationHop({ - stillCurrent, - deliver: options.deliver, - onSuperseded: schedule, - }); - if (compactionContinuationIsSuperseded(result)) return; - options.onResult(result); + const stillCurrent = options.captureGeneration(); + void options.enqueue(async () => { + const result = await settleCompactionContinuationHop({ + stillCurrent, + deliver: options.deliver, + onSuperseded: () => undefined, }); - }; - schedule(); + if (!stillCurrent()) return; + if (compactionContinuationIsSuperseded(result)) return; + options.onResult(result); + }); } /** Operator-facing copy for a settled delivery that did not accept. */ @@ -408,24 +412,98 @@ export function drainSteersOnly(state: SessionQueueState): { // Serial promise chain for session-scoped operations (reload, interrupt, deliver). // Each task runs after the previous one settles; failures do not block the tail. +// Continuation hops enqueue as preemptible so interrupt/stall-abort can settle +// the wrapper without waiting forever on Agent.deliver. Rebuild/reload/rotation +// stay serial so close() can finish. export interface SessionOperationQueue { /** Enqueue an async operation; returns a promise for this operation's settlement. */ enqueue: (op: () => Promise) => Promise; + /** + * Same tail as `enqueue`, marked preemptible. `abortInFlight` races the + * current op against a captured abort signal so a hung continuation cannot + * park rebuild or send. Quit still starts shutdown first, then awaits the + * tail — it does not call `abortInFlight`. + */ + enqueuePreemptible: (op: () => Promise) => Promise; + /** + * Abort the in-flight op only when it is preemptible, then mint a fresh + * controller so a rebuild enqueued after this call is not cancelled. + */ + abortInFlight: () => void; /** Await the tail of the queue (all prior operations finished or failed). */ awaitTail: () => Promise; } +type SessionOpKind = "serial" | "preemptible"; + +function whenAborted(signal: AbortSignal): { + promise: Promise; + dispose: () => void; +} { + let onAbort: (() => void) | undefined; + const promise = new Promise((resolve) => { + if (signal.aborted) { + resolve(); + return; + } + onAbort = () => resolve(); + signal.addEventListener("abort", onAbort, { once: true }); + }); + return { + promise, + dispose: () => { + if (onAbort !== undefined) signal.removeEventListener("abort", onAbort); + }, + }; +} + export function createSessionOperationQueue(): SessionOperationQueue { let tail: Promise = Promise.resolve(); - - const enqueue = (op: () => Promise): Promise => { - tail = tail.then(op, op); + let abortController = new AbortController(); + let currentKind: SessionOpKind | null = null; + + const enqueueKind = ( + kind: SessionOpKind, + op: () => Promise, + ): Promise => { + const execute = async (): Promise => { + currentKind = kind; + try { + if (kind !== "preemptible") { + await op(); + return; + } + const signal = abortController.signal; + const abort = whenAborted(signal); + const running = op(); + try { + const outcome = await Promise.race([ + running.then(() => "ran" as const), + abort.promise.then(() => "aborted" as const), + ]); + if (outcome === "aborted") { + void running.catch(() => undefined); + } + } finally { + abort.dispose(); + } + } finally { + currentKind = null; + } + }; + tail = tail.then(execute, execute); return tail; }; return { - enqueue, + enqueue: (op) => enqueueKind("serial", op), + enqueuePreemptible: (op) => enqueueKind("preemptible", op), + abortInFlight: () => { + if (currentKind !== "preemptible") return; + abortController.abort(); + abortController = new AbortController(); + }, awaitTail: () => tail.catch(() => undefined), }; } diff --git a/src/tui/runner/exit.test.ts b/src/tui/runner/exit.test.ts index f6b8df00d..fde9cd3bc 100644 --- a/src/tui/runner/exit.test.ts +++ b/src/tui/runner/exit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, spyOn, test } from "bun:test"; import { EventEmitter } from "node:events"; -import { type Agent } from "@intx/agent"; +import { AgentClosedError, type Agent } from "@intx/agent"; import { getLogger } from "@intx/log"; import type { InferenceSource } from "@intx/types/runtime"; @@ -269,6 +269,73 @@ describe("agentProxy.send vs /clear", () => { hung.spy.mockRestore(); } }); + + test("interrupt of a hung continuation unblocks send awaitTail and resend reaches the live agent", async () => { + const sends: string[] = []; + const agent = recordingAgent(sends); + const { state, services } = stubSendLifecycle(agent); + state.initialCodexProfile = undefined; + services.workflowHost = { + reattach: () => undefined, + } as unknown as RunnerServices["workflowHost"]; + services.cycleRecorder = { + dispose: async () => "", + reset: () => undefined, + handleEvent: () => undefined, + } as unknown as RunnerServices["cycleRecorder"]; + services.buildAgent = (async () => + agent) as unknown as RunnerServices["buildAgent"]; + services.directorHolder = {} as unknown as RunnerServices["directorHolder"]; + services.subAgentSessions = { + cancelAll: async () => [], + list: () => [], + } as unknown as RunnerServices["subAgentSessions"]; + + const { agentProxy } = await createRunLifecycle(state, services); + let hopStarted = false; + services.sessionOps.enqueuePreemptible(async () => { + hopStarted = true; + await new Promise(() => undefined); + }); + await Promise.resolve(); + await Promise.resolve(); + expect(hopStarted).toBe(true); + + const pending = agentProxy.send("blocked by hung hop"); + await Promise.resolve(); + await Promise.resolve(); + + defined(state.interrupt, "interrupt")(); + + let timer: ReturnType | undefined; + try { + await Promise.race([ + pending.then( + () => { + throw new Error("send should reject after interrupt"); + }, + (err: unknown) => { + expect(err).toBeInstanceOf(AgentClosedError); + }, + ), + new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new Error("send awaitTail did not settle after interrupt"), + ), + 250, + ); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } + + await services.sessionOps.awaitTail(); + await agentProxy.send("after rebuild"); + expect(sends).toEqual(["after rebuild"]); + }); }); const rebuildMockState: ReactorState = {} as unknown as ReactorState; diff --git a/src/tui/runner/exit.ts b/src/tui/runner/exit.ts index 7c6df6025..a0ad6ecb6 100644 --- a/src/tui/runner/exit.ts +++ b/src/tui/runner/exit.ts @@ -72,12 +72,13 @@ export function resetSessionForRotation( state: Pick, services: Pick< RunnerServices, - "deliveryGeneration" | "emitter" | "subAgentSessions" + "deliveryGeneration" | "emitter" | "subAgentSessions" | "sessionOps" >, ): Promise { let cancelledWorkers: Promise = Promise.resolve([]); const reset = (): void => { services.deliveryGeneration.bump(); + services.sessionOps.abortInFlight(); cancelFeedbackCapture(); services.emitter.emit("session.clear"); cancelledWorkers = services.subAgentSessions.cancelAll("Session cleared"); @@ -178,18 +179,22 @@ export function agentRebuildFailure(err: unknown): Error { } /** - * Hard-stop interrupt: bump delivery generation, then enqueue the agent rebuild. - * The bump aborts the outstanding permission gate (overlay dismissed, no grant) - * before enqueue so a later accept cannot mint into the rebuilt identity. + * Hard-stop interrupt: bump delivery generation, abort a preemptible + * continuation hop, then enqueue the agent rebuild. The bump aborts the + * outstanding permission gate (overlay dismissed, no grant) before enqueue + * so a later accept cannot mint into the rebuilt identity. Aborting the hop + * must happen before enqueue so rebuild does not wait forever on deliver(). */ export function startInterruptRebuild(args: { deliveryGeneration: { bump: () => void }; markSendAborted: () => void; + abortInFlight: () => void; enqueue: (op: () => Promise) => unknown; rebuild: () => Promise; }): void { args.deliveryGeneration.bump(); args.markSendAborted(); + args.abortInFlight(); void args.enqueue(args.rebuild); } @@ -336,10 +341,9 @@ export async function createRunLifecycle( // the same message the old requestContinuation closure delivered, // through the serial op queue like every other deliver. Each emission // is answered once: a replayed duplicate of an already-answered - // emission is ignored instead of re-delivered. A hop superseded by - // interrupt rebuild (generation bump + rebuild already queued) is - // re-queued onto the replacement agent so consume-once cannot land on - // the outgoing liveAgent. + // emission is ignored instead of re-delivered. Interrupt/stall-abort + // drops a superseded hop rather than re-queueing it onto the + // replacement agent. if (continuationGate.shouldDeliver(event.seq)) { state.enqueueCompactionContinuation?.(() => liveAgent(state).deliver(buildCompactionContinuationMessage()), @@ -577,6 +581,9 @@ export async function createRunLifecycle( markSendAborted: () => { state.sendAborted = true; }, + abortInFlight: () => { + services.sessionOps.abortInFlight(); + }, enqueue: enqueueOp, rebuild: async () => { try { diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index 479338acf..df1a9b7c4 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -576,7 +576,7 @@ export async function assembleTUISession( deliverToLiveAgent: () => void, ): void => { enqueueCompactionContinuationHop({ - enqueue: sessionOps.enqueue, + enqueue: sessionOps.enqueuePreemptible, captureGeneration: () => deliveryGeneration.capture(), deliver: () => deliverAgentMessage({ diff --git a/src/tui/runner/wiring.ask-wake.test.ts b/src/tui/runner/wiring.ask-wake.test.ts index 19b3f5b10..b44f9cba4 100644 --- a/src/tui/runner/wiring.ask-wake.test.ts +++ b/src/tui/runner/wiring.ask-wake.test.ts @@ -214,7 +214,12 @@ for (const phase of ["settled", "prequeued", "deferred"] as const) { resetting = true; resetSessionForRotation( { withFleetPublicationSuspended: publisher.withSuspended }, - { deliveryGeneration, emitter, subAgentSessions: store }, + { + deliveryGeneration, + emitter, + subAgentSessions: store, + sessionOps: queue, + }, ); resetting = false; expect(scheduled).toHaveLength(beforeScheduled); diff --git a/src/tui/session-operation-queue.test.ts b/src/tui/session-operation-queue.test.ts index b7a6bb614..7ac8eee15 100644 --- a/src/tui/session-operation-queue.test.ts +++ b/src/tui/session-operation-queue.test.ts @@ -1,6 +1,32 @@ import { test, expect } from "bun:test"; import { createSessionOperationQueue } from "./delivery-queue.js"; +async function expectSettlesSoon( + promise: Promise, + label: string, + ms = 250, +): Promise { + let timer: ReturnType | undefined; + try { + await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`${label} did not settle within ${ms}ms`)), + ms, + ); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + test("serial operation queue executes operations in order without interleaving", async () => { const log: string[] = []; const { enqueue, awaitTail } = createSessionOperationQueue(); @@ -19,8 +45,7 @@ test("serial operation queue executes operations in order without interleaving", log.push("B:end"); }); - await Promise.resolve(); - await Promise.resolve(); + await flushMicrotasks(); expect(log).toEqual(["A:start"]); resolveA(); @@ -78,3 +103,94 @@ test("a failed delivery does not block a rotation queued behind it", async () => await awaitTail(); expect(log).toEqual(["deliver:start", "rotate"]); }); + +test("abortInFlight unblocks a hung preemptible op so the next serial op can run", async () => { + const log: string[] = []; + const { enqueue, enqueuePreemptible, abortInFlight, awaitTail } = + createSessionOperationQueue(); + + enqueuePreemptible(async () => { + log.push("preempt:start"); + await new Promise(() => undefined); + }); + enqueue(async () => { + log.push("serial"); + }); + + await flushMicrotasks(); + expect(log).toEqual(["preempt:start"]); + + abortInFlight(); + await expectSettlesSoon(awaitTail(), "awaitTail after abortInFlight"); + expect(log).toEqual(["preempt:start", "serial"]); +}); + +test("abortInFlight does not abort a serial op", async () => { + const log: string[] = []; + let resolveSerial: () => void = () => undefined; + const gate = new Promise((r) => (resolveSerial = r)); + const { enqueue, abortInFlight, awaitTail } = createSessionOperationQueue(); + + enqueue(async () => { + log.push("serial:start"); + await gate; + log.push("serial:end"); + }); + enqueue(async () => { + log.push("next"); + }); + + await flushMicrotasks(); + abortInFlight(); + await flushMicrotasks(); + expect(log).toEqual(["serial:start"]); + + resolveSerial(); + await awaitTail(); + expect(log).toEqual(["serial:start", "serial:end", "next"]); +}); + +test("abandoned preemptible op resolving later does not run the next op twice", async () => { + const log: string[] = []; + let resolveHung: () => void = () => undefined; + const hung = new Promise((r) => (resolveHung = r)); + const { enqueue, enqueuePreemptible, abortInFlight, awaitTail } = + createSessionOperationQueue(); + + enqueuePreemptible(async () => { + log.push("preempt:start"); + await hung; + log.push("preempt:end"); + }); + enqueue(async () => { + log.push("serial"); + }); + + await flushMicrotasks(); + abortInFlight(); + await expectSettlesSoon(awaitTail(), "awaitTail after abortInFlight"); + expect(log).toEqual(["preempt:start", "serial"]); + + resolveHung(); + await flushMicrotasks(); + await awaitTail(); + expect(log.filter((entry) => entry === "serial")).toHaveLength(1); + expect(log).toEqual(["preempt:start", "serial", "preempt:end"]); +}); + +test("a failed preemptible op does not block the tail", async () => { + const log: string[] = []; + const { enqueue, enqueuePreemptible, awaitTail } = + createSessionOperationQueue(); + + enqueuePreemptible(async () => { + log.push("preempt"); + throw new Error("deliver failed"); + }); + enqueue(async () => { + log.push("serial"); + }); + + await awaitTail(); + expect(log).toEqual(["preempt", "serial"]); +}); diff --git a/tests/unit/approval-resume.test.ts b/tests/unit/approval-resume.test.ts index c5a080dad..6d67ac53f 100644 --- a/tests/unit/approval-resume.test.ts +++ b/tests/unit/approval-resume.test.ts @@ -373,6 +373,7 @@ describe("approval resume generation capture", () => { markSendAborted: () => { events.push("abort"); }, + abortInFlight: () => undefined, enqueue: (op) => { events.push("enqueue"); return enqueue(op); diff --git a/tests/unit/tui/runner.test.ts b/tests/unit/tui/runner.test.ts index fd97e037e..5692af6d9 100644 --- a/tests/unit/tui/runner.test.ts +++ b/tests/unit/tui/runner.test.ts @@ -328,6 +328,9 @@ test("interrupt bumps delivery generation before enqueueing rebuild", () => { markSendAborted: () => { order.push("abort"); }, + abortInFlight: () => { + order.push("abortInFlight"); + }, enqueue: (op) => { order.push("enqueue"); return op(); @@ -337,5 +340,8 @@ test("interrupt bumps delivery generation before enqueueing rebuild", () => { }, }); expect(order[0]).toBe("bump"); - expect(order.indexOf("enqueue")).toBeGreaterThan(0); + expect(order.indexOf("abortInFlight")).toBeGreaterThan(order.indexOf("bump")); + expect(order.indexOf("enqueue")).toBeGreaterThan( + order.indexOf("abortInFlight"), + ); });