2026-05-15 11:49:44 +09:00
|
|
|
import { log } from "./logger"
|
|
|
|
|
import {
|
|
|
|
|
DEFAULT_SESSION_IDLE_SETTLE_MS,
|
|
|
|
|
isSessionActive,
|
|
|
|
|
settleAfterSessionIdle,
|
|
|
|
|
} from "./session-idle-settle"
|
|
|
|
|
|
|
|
|
|
export const DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS = 250
|
2026-05-16 00:49:28 +09:00
|
|
|
export const DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS = 30_000
|
2026-05-15 11:49:44 +09:00
|
|
|
|
|
|
|
|
type PromptAsyncInput = {
|
|
|
|
|
path?: { id?: string }
|
|
|
|
|
body?: unknown
|
|
|
|
|
query?: unknown
|
|
|
|
|
signal?: unknown
|
|
|
|
|
[key: string]: unknown
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type PromptAsyncClient<TInput> = {
|
|
|
|
|
session?: {
|
|
|
|
|
status?: () => Promise<unknown>
|
|
|
|
|
promptAsync?: (input: TInput) => Promise<unknown>
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type PromptClient<TInput> = {
|
|
|
|
|
session?: {
|
|
|
|
|
status?: () => Promise<unknown>
|
|
|
|
|
prompt?: (input: TInput) => Promise<unknown>
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type PromptAsyncReservation = {
|
|
|
|
|
source: string
|
|
|
|
|
reservedAt: number
|
|
|
|
|
token: symbol
|
2026-05-15 13:19:10 +09:00
|
|
|
expiresAt?: number
|
2026-05-15 11:49:44 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-16 00:49:28 +09:00
|
|
|
declare function setTimeout(callback: () => void, delay?: number): ReturnType<typeof globalThis.setTimeout>
|
|
|
|
|
declare function clearTimeout(timeout: ReturnType<typeof globalThis.setTimeout>): void
|
|
|
|
|
|
2026-05-15 11:49:44 +09:00
|
|
|
export type PromptAsyncGateResult =
|
|
|
|
|
| { status: "dispatched"; response: unknown }
|
|
|
|
|
| { status: "active" }
|
|
|
|
|
| { status: "reserved"; reservedBy: string }
|
|
|
|
|
| { status: "unavailable" }
|
|
|
|
|
| { status: "failed"; error: unknown }
|
|
|
|
|
|
2026-05-15 21:52:29 +09:00
|
|
|
type PromptAsyncReservationReleaseOptions = {
|
|
|
|
|
reservedBy?: string | readonly string[]
|
|
|
|
|
reservedByPrefix?: string | readonly string[]
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 11:49:44 +09:00
|
|
|
const promptAsyncReservations = new Map<string, PromptAsyncReservation>()
|
|
|
|
|
|
2026-05-15 13:19:10 +09:00
|
|
|
function pruneExpiredReservations(now = Date.now()): void {
|
|
|
|
|
for (const [sessionID, reservation] of promptAsyncReservations) {
|
|
|
|
|
if (typeof reservation.expiresAt === "number" && reservation.expiresAt <= now) {
|
|
|
|
|
promptAsyncReservations.delete(sessionID)
|
|
|
|
|
log("[prompt-async-gate] expired reservation released", {
|
|
|
|
|
sessionID,
|
|
|
|
|
source: reservation.source,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getActiveReservation(sessionID: string): PromptAsyncReservation | undefined {
|
|
|
|
|
pruneExpiredReservations()
|
|
|
|
|
return promptAsyncReservations.get(sessionID)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 21:52:29 +09:00
|
|
|
function reservationSourceMatches(
|
|
|
|
|
reservationSource: string,
|
|
|
|
|
expectedSource: string | readonly string[],
|
|
|
|
|
expectedPrefix?: string | readonly string[],
|
|
|
|
|
): boolean {
|
|
|
|
|
if (typeof expectedSource === "string") {
|
|
|
|
|
if (reservationSource === expectedSource) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
} else if (expectedSource.includes(reservationSource)) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (expectedPrefix === undefined) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 00:49:28 +09:00
|
|
|
const prefixes = typeof expectedPrefix === "string" ? [expectedPrefix] : expectedPrefix
|
|
|
|
|
return prefixes
|
|
|
|
|
.filter((prefix) => prefix.length > 0 && prefix.endsWith(":"))
|
|
|
|
|
.some((prefix) => reservationSource.startsWith(prefix))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function withDispatchTimeout<T>(
|
|
|
|
|
operation: Promise<T>,
|
|
|
|
|
dispatchTimeoutMs: number,
|
|
|
|
|
operationName: string,
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
if (dispatchTimeoutMs <= 0) {
|
|
|
|
|
return operation
|
2026-05-15 21:52:29 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-16 00:49:28 +09:00
|
|
|
let timeoutID: ReturnType<typeof globalThis.setTimeout> | undefined
|
|
|
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
|
|
|
timeoutID = setTimeout(() => {
|
|
|
|
|
reject(new Error(`${operationName} timed out after ${dispatchTimeoutMs}ms`))
|
|
|
|
|
}, dispatchTimeoutMs)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return await Promise.race([operation, timeoutPromise])
|
|
|
|
|
} finally {
|
|
|
|
|
if (timeoutID !== undefined) {
|
|
|
|
|
clearTimeout(timeoutID)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-15 21:52:29 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-16 00:49:28 +09:00
|
|
|
async function dispatchAfterSessionIdle<TInput>(args: {
|
|
|
|
|
sessionName: "promptAsync" | "prompt"
|
|
|
|
|
client: { session?: { status?: () => Promise<unknown> } }
|
2026-05-15 11:49:44 +09:00
|
|
|
sessionID: string
|
|
|
|
|
input: TInput
|
|
|
|
|
source: string
|
2026-05-16 00:49:28 +09:00
|
|
|
settleMs: number
|
|
|
|
|
postDispatchHoldMs: number
|
|
|
|
|
dispatchTimeoutMs: number
|
|
|
|
|
checkStatus: boolean
|
|
|
|
|
dispatch: (input: TInput) => Promise<unknown>
|
2026-05-15 11:49:44 +09:00
|
|
|
}): Promise<PromptAsyncGateResult> {
|
|
|
|
|
const {
|
2026-05-16 00:49:28 +09:00
|
|
|
sessionName,
|
2026-05-15 11:49:44 +09:00
|
|
|
client,
|
|
|
|
|
sessionID,
|
|
|
|
|
input,
|
|
|
|
|
source,
|
2026-05-16 00:49:28 +09:00
|
|
|
settleMs,
|
|
|
|
|
postDispatchHoldMs,
|
|
|
|
|
dispatchTimeoutMs,
|
|
|
|
|
checkStatus,
|
|
|
|
|
dispatch,
|
2026-05-15 11:49:44 +09:00
|
|
|
} = args
|
|
|
|
|
|
2026-05-15 13:19:10 +09:00
|
|
|
const existing = getActiveReservation(sessionID)
|
2026-05-15 11:49:44 +09:00
|
|
|
if (existing) {
|
2026-05-16 00:49:28 +09:00
|
|
|
log(`[prompt-async-gate] ${sessionName} skipped because session is reserved`, {
|
2026-05-15 11:49:44 +09:00
|
|
|
sessionID,
|
|
|
|
|
source,
|
|
|
|
|
reservedBy: existing.source,
|
|
|
|
|
reservedAgeMs: Date.now() - existing.reservedAt,
|
|
|
|
|
})
|
|
|
|
|
return { status: "reserved", reservedBy: existing.source }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const reservation: PromptAsyncReservation = {
|
|
|
|
|
source,
|
|
|
|
|
reservedAt: Date.now(),
|
|
|
|
|
token: Symbol(source),
|
|
|
|
|
}
|
|
|
|
|
promptAsyncReservations.set(sessionID, reservation)
|
2026-05-16 00:49:28 +09:00
|
|
|
let dispatchAttempted = false
|
2026-05-15 11:49:44 +09:00
|
|
|
|
|
|
|
|
try {
|
2026-05-16 00:49:28 +09:00
|
|
|
const canReadStatus = checkStatus && typeof client.session?.status === "function"
|
2026-05-15 11:49:44 +09:00
|
|
|
if (settleMs > 0) {
|
|
|
|
|
await settleAfterSessionIdle(settleMs)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (canReadStatus && await isSessionActive(client, sessionID)) {
|
2026-05-16 00:49:28 +09:00
|
|
|
log(`[prompt-async-gate] ${sessionName} skipped because session is active`, { sessionID, source })
|
2026-05-15 11:49:44 +09:00
|
|
|
return { status: "active" }
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 00:49:28 +09:00
|
|
|
log(`[prompt-async-gate] ${sessionName} dispatching`, { sessionID, source })
|
|
|
|
|
dispatchAttempted = true
|
|
|
|
|
const response = await withDispatchTimeout(
|
|
|
|
|
dispatch(input),
|
|
|
|
|
dispatchTimeoutMs,
|
|
|
|
|
`[prompt-async-gate] ${sessionName} dispatch`,
|
|
|
|
|
)
|
|
|
|
|
log(`[prompt-async-gate] ${sessionName} dispatched`, { sessionID, source })
|
2026-05-15 11:49:44 +09:00
|
|
|
return { status: "dispatched", response }
|
|
|
|
|
} catch (error) {
|
2026-05-16 00:49:28 +09:00
|
|
|
log(`[prompt-async-gate] ${sessionName} failed`, { sessionID, source, error: String(error) })
|
2026-05-15 11:49:44 +09:00
|
|
|
return { status: "failed", error }
|
|
|
|
|
} finally {
|
|
|
|
|
const current = promptAsyncReservations.get(sessionID)
|
|
|
|
|
if (current?.token === reservation.token) {
|
2026-05-16 00:49:28 +09:00
|
|
|
if (dispatchAttempted && postDispatchHoldMs > 0) {
|
2026-05-15 13:19:10 +09:00
|
|
|
reservation.expiresAt = Date.now() + postDispatchHoldMs
|
|
|
|
|
} else {
|
|
|
|
|
promptAsyncReservations.delete(sessionID)
|
|
|
|
|
}
|
2026-05-15 11:49:44 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 00:49:28 +09:00
|
|
|
export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(args: {
|
|
|
|
|
client: PromptAsyncClient<TInput>
|
2026-05-15 11:49:44 +09:00
|
|
|
sessionID: string
|
|
|
|
|
input: TInput
|
|
|
|
|
source: string
|
|
|
|
|
settleMs?: number
|
|
|
|
|
postDispatchHoldMs?: number
|
2026-05-16 00:49:28 +09:00
|
|
|
dispatchTimeoutMs?: number
|
2026-05-15 11:49:44 +09:00
|
|
|
checkStatus?: boolean
|
|
|
|
|
}): Promise<PromptAsyncGateResult> {
|
|
|
|
|
const {
|
|
|
|
|
client,
|
|
|
|
|
sessionID,
|
|
|
|
|
input,
|
|
|
|
|
source,
|
|
|
|
|
settleMs = DEFAULT_SESSION_IDLE_SETTLE_MS,
|
|
|
|
|
} = args
|
2026-05-15 12:05:03 +09:00
|
|
|
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
|
2026-05-16 00:49:28 +09:00
|
|
|
const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS
|
|
|
|
|
const promptAsync = client.session?.promptAsync
|
2026-05-15 11:49:44 +09:00
|
|
|
|
2026-05-16 00:49:28 +09:00
|
|
|
if (typeof promptAsync !== "function") {
|
|
|
|
|
log("[prompt-async-gate] promptAsync unavailable", { sessionID, source })
|
2026-05-15 11:49:44 +09:00
|
|
|
return { status: "unavailable" }
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 00:49:28 +09:00
|
|
|
return dispatchAfterSessionIdle({
|
|
|
|
|
sessionName: "promptAsync",
|
|
|
|
|
client,
|
|
|
|
|
sessionID,
|
|
|
|
|
input,
|
2026-05-15 11:49:44 +09:00
|
|
|
source,
|
2026-05-16 00:49:28 +09:00
|
|
|
settleMs,
|
|
|
|
|
postDispatchHoldMs,
|
|
|
|
|
dispatchTimeoutMs,
|
|
|
|
|
checkStatus: args.checkStatus !== false,
|
|
|
|
|
dispatch: (dispatchInput) => promptAsync(dispatchInput),
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-05-15 11:49:44 +09:00
|
|
|
|
2026-05-16 00:49:28 +09:00
|
|
|
export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
|
|
|
|
|
client: PromptClient<TInput>
|
|
|
|
|
sessionID: string
|
|
|
|
|
input: TInput
|
|
|
|
|
source: string
|
|
|
|
|
settleMs?: number
|
|
|
|
|
postDispatchHoldMs?: number
|
|
|
|
|
dispatchTimeoutMs?: number
|
|
|
|
|
checkStatus?: boolean
|
|
|
|
|
}): Promise<PromptAsyncGateResult> {
|
|
|
|
|
const {
|
|
|
|
|
client,
|
|
|
|
|
sessionID,
|
|
|
|
|
input,
|
|
|
|
|
source,
|
|
|
|
|
settleMs = DEFAULT_SESSION_IDLE_SETTLE_MS,
|
|
|
|
|
} = args
|
|
|
|
|
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
|
|
|
|
|
const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS
|
|
|
|
|
const prompt = client.session?.prompt
|
2026-05-15 11:49:44 +09:00
|
|
|
|
2026-05-16 00:49:28 +09:00
|
|
|
if (typeof prompt !== "function") {
|
|
|
|
|
log("[prompt-async-gate] prompt unavailable", { sessionID, source })
|
|
|
|
|
return { status: "unavailable" }
|
2026-05-15 11:49:44 +09:00
|
|
|
}
|
2026-05-16 00:49:28 +09:00
|
|
|
|
|
|
|
|
return dispatchAfterSessionIdle({
|
|
|
|
|
sessionName: "prompt",
|
|
|
|
|
client,
|
|
|
|
|
sessionID,
|
|
|
|
|
input,
|
|
|
|
|
source,
|
|
|
|
|
settleMs,
|
|
|
|
|
postDispatchHoldMs,
|
|
|
|
|
dispatchTimeoutMs,
|
|
|
|
|
checkStatus: args.checkStatus !== false,
|
|
|
|
|
dispatch: (dispatchInput) => prompt(dispatchInput),
|
|
|
|
|
})
|
2026-05-15 11:49:44 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function releaseAllPromptAsyncReservationsForTesting(): void {
|
|
|
|
|
promptAsyncReservations.clear()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 21:52:29 +09:00
|
|
|
export function releasePromptAsyncReservation(
|
|
|
|
|
sessionID: string,
|
|
|
|
|
source: string,
|
|
|
|
|
options?: PromptAsyncReservationReleaseOptions,
|
|
|
|
|
): boolean {
|
2026-05-15 11:49:44 +09:00
|
|
|
const existing = promptAsyncReservations.get(sessionID)
|
|
|
|
|
if (!existing) {
|
2026-05-15 21:52:29 +09:00
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const expectedSource = options?.reservedBy ?? source
|
|
|
|
|
if (!reservationSourceMatches(existing.source, expectedSource, options?.reservedByPrefix)) {
|
|
|
|
|
log("[prompt-async-gate] promptAsync reservation release skipped for different source", {
|
|
|
|
|
sessionID,
|
|
|
|
|
source,
|
|
|
|
|
reservedBy: existing.source,
|
|
|
|
|
})
|
|
|
|
|
return false
|
2026-05-15 11:49:44 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
promptAsyncReservations.delete(sessionID)
|
|
|
|
|
log("[prompt-async-gate] promptAsync reservation released", {
|
|
|
|
|
sessionID,
|
|
|
|
|
source,
|
|
|
|
|
reservedBy: existing.source,
|
|
|
|
|
})
|
2026-05-15 21:52:29 +09:00
|
|
|
return true
|
2026-05-15 11:49:44 +09:00
|
|
|
}
|