import type { YourComputerCall } from "../shared/your-computer.ts"; export interface AutoVmClaimOwner { threadId: string; generation: string; } export interface AutoVmClaimSlot { owner: AutoVmClaimOwner; /** Bind, lease, boot; again only after a network failure. `call` is what the first screen call would open. */ claim: (call?: YourComputerCall) => Promise; /** Set by startAutoVmClaim; presence means the claim already fired. */ begin?: Promise; /** The fired claim rejected; the slot stays so the gate keeps refusing screen calls instead of forwarding them to an unclaimed VM. */ failed?: boolean; failure?: string; /** What went wrong in the computer's own words, when `failure` is the line the person sees. */ cause?: string; /** The failure took the conversation's automatic pick off this computer. */ backOnAuto?: boolean; /** The last claim failed on the network; the next screen call claims again. */ retryReason?: string; attempts?: number; claimed?: boolean; /** Dispatch mounted the computer tools without claiming, so the gate fires the claim on the first screen call. */ lazy?: boolean; label?: string; localVm?: boolean; pendingReason?: string; boxId?: string; startsComputer?: true; /** Called once after the slot is marked failed, unless the person declined; the fail-closed refusal does not depend on it firing. */ onRejected?: (failure: string, error?: unknown) => void; } export type AutoVmClaimTable = Map; export const MAX_CLAIM_ATTEMPTS = 3; const NETWORK_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", "EPIPE", "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]); /** A claim that never got an answer: marked `transient`, a fetch timeout, or Node's fetch failing on a socket error (its `cause`). */ export function transientClaimFailure(error: unknown): boolean { for (let current = error, depth = 0; current && typeof current === "object" && depth < 4; current = (current as { cause?: unknown }).cause, depth++) { const failure = current as { transient?: unknown; name?: unknown; code?: unknown }; if (failure.transient === true || failure.name === "TimeoutError" || (typeof failure.code === "string" && NETWORK_CODES.has(failure.code))) return true; } return false; } /** Keeps a claim's network failure recognizable through a wrapper that rewords it for the person. */ export function keepTransient(original: unknown, shown: unknown): unknown { return shown instanceof Error && transientClaimFailure(original) ? Object.assign(shown, { transient: true }) : shown; } /** Once per dispatch generation, or again after a network failure, at most MAX_CLAIM_ATTEMPTS times. Any other rejection keeps * its slot marked failed, and a rejection mutates only the slot it fired from, so a stale claim never touches a newer generation's. */ export function startAutoVmClaim(table: AutoVmClaimTable, threadId: string, generation: string, call?: YourComputerCall): void { const slot = table.get(threadId); if (!slot || slot.owner.threadId !== threadId || slot.owner.generation !== generation || slot.begin || slot.failed) return; const attempt = (slot.attempts ?? 0) + 1; slot.attempts = attempt; slot.retryReason = undefined; const begin: Promise = slot.claim(call).then( () => { slot.claimed = true; }, (error: unknown) => { const failure = error instanceof Error ? error.message : String(error); if (transientClaimFailure(error) && attempt < MAX_CLAIM_ATTEMPTS) { slot.retryReason = failure; if (slot.begin === begin) slot.begin = undefined; return; } slot.failed = true; slot.failure = failure; // A person's No is an answer, not a failure: the screen calls say so, and nothing reports a broken turn. if ((error as { declined?: unknown } | null)?.declined !== true) slot.onRejected?.(slot.failure, error); }, ); slot.begin = begin; } /** What every screen call hears for the rest of a turn whose computer could not be claimed. */ export function lazyClaimFailedText(slot: Pick): string { const label = slot.label ?? "this computer"; const why = (slot.cause ?? slot.failure)?.trim().replace(/[\s.]+$/, ""); if (!slot.startsComputer) { return `This turn could not claim ${label}${why ? ` (${why})` : ""}. This call was not performed. Do not retry computer work in this turn; tell the person what you could not do.`; } return `${label.charAt(0).toUpperCase()}${label.slice(1)} could not be started${why ? `: ${why}` : ""}. This call was not performed.` + `${slot.backOnAuto ? " This conversation is back on Auto." : ""} Do not retry computer work in this turn; tell the person what you could not do.`; } export function lazyClaimRetryText(slot: Pick): string { return `This turn could not reach ${slot.label ?? "this computer"} just now (${slot.retryReason}). This call was not performed; try it again in a moment.`; } export function settledWithin(work: Promise | null, ms: number, signal?: AbortSignal): Promise { return new Promise((resolve) => { const done = () => { clearTimeout(timer); signal?.removeEventListener("abort", done); resolve(); }; const timer = setTimeout(done, Math.max(0, ms)); signal?.addEventListener("abort", done, { once: true }); work?.then(done, done); }); } export function withinBudget(work: Promise, ms: number, message: string): Promise { let timer: ReturnType | undefined; const expired = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); }); return Promise.race([work, expired]).finally(() => clearTimeout(timer)); }