server/auto-vm-claims.ts
Rama main · réplica de lectura del repositorio original.
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<void>;
/** Set by startAutoVmClaim; presence means the claim already fired. */
begin?: Promise<void>;
/** 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<string, AutoVmClaimSlot>;
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<void> = 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<AutoVmClaimSlot, "label" | "failure" | "cause" | "startsComputer" | "backOnAuto">): 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<AutoVmClaimSlot, "label" | "retryReason">): 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<unknown> | null, ms: number, signal?: AbortSignal): Promise<void> {
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<T>(work: Promise<T>, ms: number, message: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const expired = new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); });
return Promise.race([work, expired]).finally(() => clearTimeout(timer));
}