fix(ux): include cloud provider dashboard URLs in script failure and interrupt messages (#1029)

When spawn scripts fail or are interrupted, error messages now include
the cloud provider's actual dashboard URL instead of generic "check your
cloud provider dashboard" text. This helps users quickly navigate to
their provider to check server status, clean up orphaned resources, or
debug provisioning failures.

Agent: ux-engineer

Co-authored-by: A <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
A 2026-02-13 16:01:57 -08:00 committed by GitHub
parent 7d6bc0292b
commit 059690f8d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 113 additions and 21 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "@openrouter/spawn", "name": "@openrouter/spawn",
"version": "0.2.79", "version": "0.2.80",
"type": "module", "type": "module",
"bin": { "bin": {
"spawn": "cli.js" "spawn": "cli.js"

View file

@ -199,7 +199,7 @@ describe("execScript bash execution error handling", () => {
const warnText = warnMessages.join("\n"); const warnText = warnMessages.join("\n");
expect(warnText).toContain("interrupted"); expect(warnText).toContain("interrupted");
expect(warnText).toContain("server"); expect(warnText).toContain("server");
expect(warnText).toContain("cloud provider dashboard"); expect(warnText).toContain("dashboard");
}); });
}); });

View file

@ -606,3 +606,82 @@ describe("buildRetryCommand", () => {
expect(buildRetryCommand("aider", "vultr", "")).toBe("spawn aider vultr"); expect(buildRetryCommand("aider", "vultr", "")).toBe("spawn aider vultr");
}); });
}); });
describe("dashboard URL in guidance", () => {
describe("getScriptFailureGuidance with dashboardUrl", () => {
it("should include dashboard URL for exit code 1 when provided", () => {
const lines = getScriptFailureGuidance(1, "hetzner", undefined, "https://console.hetzner.cloud/");
const joined = lines.join("\n");
expect(joined).toContain("https://console.hetzner.cloud/");
expect(joined).toContain("dashboard");
});
it("should include dashboard URL for exit code 130 when provided", () => {
const lines = getScriptFailureGuidance(130, "sprite", undefined, "https://sprite.sh");
const joined = lines.join("\n");
expect(joined).toContain("https://sprite.sh");
expect(joined).toContain("dashboard");
});
it("should include dashboard URL for exit code 137 when provided", () => {
const lines = getScriptFailureGuidance(137, "vultr", undefined, "https://my.vultr.com/");
const joined = lines.join("\n");
expect(joined).toContain("https://my.vultr.com/");
});
it("should include dashboard URL for default exit code when provided", () => {
const lines = getScriptFailureGuidance(42, "digitalocean", undefined, "https://cloud.digitalocean.com/");
const joined = lines.join("\n");
expect(joined).toContain("https://cloud.digitalocean.com/");
});
it("should fall back to generic message when no dashboardUrl", () => {
const lines = getScriptFailureGuidance(130, "sprite");
const joined = lines.join("\n");
expect(joined).toContain("cloud provider dashboard");
expect(joined).not.toContain("https://");
});
it("should not add dashboard URL for exit codes 127, 126, 255, 2", () => {
for (const code of [127, 126, 255, 2]) {
const lines = getScriptFailureGuidance(code, "hetzner", undefined, "https://console.hetzner.cloud/");
const joined = lines.join("\n");
expect(joined).not.toContain("https://console.hetzner.cloud/");
}
});
});
describe("getSignalGuidance with dashboardUrl", () => {
it("should include dashboard URL for SIGKILL when provided", () => {
const lines = getSignalGuidance("SIGKILL", "https://console.hetzner.cloud/");
const joined = lines.join("\n");
expect(joined).toContain("https://console.hetzner.cloud/");
expect(joined).toContain("dashboard");
});
it("should include dashboard URL for SIGTERM when provided", () => {
const lines = getSignalGuidance("SIGTERM", "https://my.vultr.com/");
const joined = lines.join("\n");
expect(joined).toContain("https://my.vultr.com/");
});
it("should include dashboard URL for SIGINT when provided", () => {
const lines = getSignalGuidance("SIGINT", "https://cloud.digitalocean.com/");
const joined = lines.join("\n");
expect(joined).toContain("https://cloud.digitalocean.com/");
});
it("should fall back to generic message when no dashboardUrl", () => {
const lines = getSignalGuidance("SIGKILL");
const joined = lines.join("\n");
expect(joined).toContain("cloud provider dashboard");
expect(joined).not.toContain("https://");
});
it("should not add dashboard URL for SIGHUP", () => {
const lines = getSignalGuidance("SIGHUP", "https://example.com");
const joined = lines.join("\n");
expect(joined).not.toContain("https://example.com");
});
});
});

View file

@ -367,7 +367,7 @@ export async function cmdInteractive(): Promise<void> {
p.log.info(`Next time, run directly: ${pc.cyan(`spawn ${agentChoice} ${cloudChoice}`)}`); p.log.info(`Next time, run directly: ${pc.cyan(`spawn ${agentChoice} ${cloudChoice}`)}`);
p.outro("Handing off to spawn script..."); p.outro("Handing off to spawn script...");
await execScript(cloudChoice, agentChoice, undefined, getAuthHint(manifest, cloudChoice)); await execScript(cloudChoice, agentChoice, undefined, getAuthHint(manifest, cloudChoice), manifest.clouds[cloudChoice].url);
} }
// ── Run ──────────────────────────────────────────────────────────────────────── // ── Run ────────────────────────────────────────────────────────────────────────
@ -591,7 +591,7 @@ export async function cmdRun(agent: string, cloud: string, prompt?: string, dryR
const suffix = prompt ? " with prompt..." : "..."; const suffix = prompt ? " with prompt..." : "...";
p.log.step(`Launching ${pc.bold(agentName)} on ${pc.bold(cloudName)}${suffix}`); p.log.step(`Launching ${pc.bold(agentName)} on ${pc.bold(cloudName)}${suffix}`);
await execScript(cloud, agent, prompt, getAuthHint(manifest, cloud)); await execScript(cloud, agent, prompt, getAuthHint(manifest, cloud), manifest.clouds[cloud].url);
} }
export function getStatusDescription(status: number): string { export function getStatusDescription(status: number): string {
@ -695,7 +695,10 @@ export function credentialHints(cloud: string, authHint?: string, verb = "Missin
return lines; return lines;
} }
export function getSignalGuidance(signal: string): string[] { export function getSignalGuidance(signal: string, dashboardUrl?: string): string[] {
const dashboardHint = dashboardUrl
? ` - Check your dashboard: ${pc.cyan(dashboardUrl)}`
: " - Check your cloud provider dashboard to stop or delete any unused servers";
switch (signal) { switch (signal) {
case "SIGKILL": case "SIGKILL":
return [ return [
@ -703,7 +706,7 @@ export function getSignalGuidance(signal: string): string[] {
" - Out of memory (OOM killer terminated the process)", " - Out of memory (OOM killer terminated the process)",
" - The server may not have enough RAM for this agent", " - The server may not have enough RAM for this agent",
" - Try a larger instance size or a different cloud provider", " - Try a larger instance size or a different cloud provider",
" - Check your cloud provider dashboard to stop or delete any unused servers", dashboardHint,
]; ];
case "SIGTERM": case "SIGTERM":
return [ return [
@ -711,12 +714,13 @@ export function getSignalGuidance(signal: string): string[] {
" - The process was stopped by the system or a supervisor", " - The process was stopped by the system or a supervisor",
" - Server shutdown or reboot in progress", " - Server shutdown or reboot in progress",
" - Cloud provider terminated the instance (spot/preemptible instance or billing issue)", " - Cloud provider terminated the instance (spot/preemptible instance or billing issue)",
dashboardHint,
]; ];
case "SIGINT": case "SIGINT":
return [ return [
"Script was interrupted (Ctrl+C).", "Script was interrupted (Ctrl+C).",
"Note: If a server was already created, it may still be running.", "Note: If a server was already created, it may still be running.",
" Check your cloud provider dashboard to stop or delete any unused servers.", dashboardHint,
]; ];
case "SIGHUP": case "SIGHUP":
return [ return [
@ -729,25 +733,28 @@ export function getSignalGuidance(signal: string): string[] {
return [ return [
`Script was killed by signal ${signal}.`, `Script was killed by signal ${signal}.`,
" - The process was terminated by the system or another process", " - The process was terminated by the system or another process",
" - Check your cloud provider dashboard for any orphaned servers", dashboardHint,
]; ];
} }
} }
export function getScriptFailureGuidance(exitCode: number | null, cloud: string, authHint?: string): string[] { export function getScriptFailureGuidance(exitCode: number | null, cloud: string, authHint?: string, dashboardUrl?: string): string[] {
const dashboardHint = dashboardUrl
? ` - Check your dashboard: ${pc.cyan(dashboardUrl)}`
: " - Check your cloud provider dashboard to stop or delete any unused servers";
switch (exitCode) { switch (exitCode) {
case 130: case 130:
return [ return [
"Script was interrupted (Ctrl+C).", "Script was interrupted (Ctrl+C).",
"Note: If a server was already created, it may still be running.", "Note: If a server was already created, it may still be running.",
" Check your cloud provider dashboard to stop or delete any unused servers.", dashboardHint,
]; ];
case 137: case 137:
return [ return [
"Script was killed (likely by the system due to timeout or out of memory).", "Script was killed (likely by the system due to timeout or out of memory).",
" - The server may not have enough RAM for this agent", " - The server may not have enough RAM for this agent",
" - Try a larger instance size or a different cloud provider", " - Try a larger instance size or a different cloud provider",
" - Check your cloud provider dashboard to stop or delete any unused servers", dashboardHint,
]; ];
case 255: case 255:
return [ return [
@ -780,6 +787,7 @@ export function getScriptFailureGuidance(exitCode: number | null, cloud: string,
...credentialHints(cloud, authHint), ...credentialHints(cloud, authHint),
" - Cloud provider API error (quota, rate limit, or region issue)", " - Cloud provider API error (quota, rate limit, or region issue)",
" - Server provisioning failed (try again or pick a different region)", " - Server provisioning failed (try again or pick a different region)",
...(dashboardUrl ? [` - Check your dashboard: ${pc.cyan(dashboardUrl)}`] : []),
]; ];
default: default:
return [ return [
@ -787,6 +795,7 @@ export function getScriptFailureGuidance(exitCode: number | null, cloud: string,
...credentialHints(cloud, authHint, "Missing"), ...credentialHints(cloud, authHint, "Missing"),
" - Cloud provider API rate limit or quota exceeded", " - Cloud provider API rate limit or quota exceeded",
" - Missing local dependencies (SSH, curl, jq)", " - Missing local dependencies (SSH, curl, jq)",
...(dashboardUrl ? [` - Check your dashboard: ${pc.cyan(dashboardUrl)}`] : []),
]; ];
} }
} }
@ -801,7 +810,7 @@ export function buildRetryCommand(agent: string, cloud: string, prompt?: string)
return `spawn ${agent} ${cloud} --prompt-file <your-prompt-file>`; return `spawn ${agent} ${cloud} --prompt-file <your-prompt-file>`;
} }
function reportScriptFailure(errMsg: string, cloud: string, agent: string, authHint?: string, prompt?: string): never { function reportScriptFailure(errMsg: string, cloud: string, agent: string, authHint?: string, prompt?: string, dashboardUrl?: string): never {
p.log.error("Spawn script failed"); p.log.error("Spawn script failed");
console.error("\nError:", errMsg); console.error("\nError:", errMsg);
@ -813,8 +822,8 @@ function reportScriptFailure(errMsg: string, cloud: string, agent: string, authH
const signal = signalMatch ? signalMatch[1] : null; const signal = signalMatch ? signalMatch[1] : null;
const lines = signal const lines = signal
? getSignalGuidance(signal) ? getSignalGuidance(signal, dashboardUrl)
: getScriptFailureGuidance(exitCode, cloud, authHint); : getScriptFailureGuidance(exitCode, cloud, authHint, dashboardUrl);
console.error(""); console.error("");
for (const line of lines) console.error(line); for (const line of lines) console.error(line);
console.error(""); console.error("");
@ -833,23 +842,27 @@ export function isRetryableExitCode(errMsg: string): boolean {
return code === 255; return code === 255;
} }
function handleUserInterrupt(errMsg: string): void { function handleUserInterrupt(errMsg: string, dashboardUrl?: string): void {
if (!errMsg.includes("interrupted by user") && !errMsg.includes("killed by SIGINT")) return; if (!errMsg.includes("interrupted by user") && !errMsg.includes("killed by SIGINT")) return;
console.error(); console.error();
p.log.warn("Script interrupted (Ctrl+C)."); p.log.warn("Script interrupted (Ctrl+C).");
p.log.warn("If a server was already created, it may still be running."); p.log.warn("If a server was already created, it may still be running.");
p.log.warn(` Check your cloud provider dashboard to stop or delete any unused servers.`); if (dashboardUrl) {
p.log.warn(` Check your dashboard: ${pc.cyan(dashboardUrl)}`);
} else {
p.log.warn(` Check your cloud provider dashboard to stop or delete any unused servers.`);
}
process.exit(130); process.exit(130);
} }
async function runWithRetries(script: string, prompt?: string): Promise<string | undefined> { async function runWithRetries(script: string, prompt?: string, dashboardUrl?: string): Promise<string | undefined> {
for (let attempt = 1; attempt <= MAX_RETRIES + 1; attempt++) { for (let attempt = 1; attempt <= MAX_RETRIES + 1; attempt++) {
try { try {
await runBash(script, prompt); await runBash(script, prompt);
return undefined; // success return undefined; // success
} catch (err) { } catch (err) {
const errMsg = getErrorMessage(err); const errMsg = getErrorMessage(err);
handleUserInterrupt(errMsg); handleUserInterrupt(errMsg, dashboardUrl);
if (attempt <= MAX_RETRIES && isRetryableExitCode(errMsg)) { if (attempt <= MAX_RETRIES && isRetryableExitCode(errMsg)) {
const delay = RETRY_DELAYS[attempt - 1]; const delay = RETRY_DELAYS[attempt - 1];
@ -864,7 +877,7 @@ async function runWithRetries(script: string, prompt?: string): Promise<string |
return "Script failed after all retries"; return "Script failed after all retries";
} }
async function execScript(cloud: string, agent: string, prompt?: string, authHint?: string): Promise<void> { async function execScript(cloud: string, agent: string, prompt?: string, authHint?: string, dashboardUrl?: string): Promise<void> {
const url = `https://openrouter.ai/labs/spawn/${cloud}/${agent}.sh`; const url = `https://openrouter.ai/labs/spawn/${cloud}/${agent}.sh`;
const ghUrl = `${RAW_BASE}/${cloud}/${agent}.sh`; const ghUrl = `${RAW_BASE}/${cloud}/${agent}.sh`;
@ -887,9 +900,9 @@ async function execScript(cloud: string, agent: string, prompt?: string, authHin
// Non-fatal: don't block the spawn if history write fails // Non-fatal: don't block the spawn if history write fails
} }
const lastErr = await runWithRetries(scriptContent, prompt); const lastErr = await runWithRetries(scriptContent, prompt, dashboardUrl);
if (lastErr) { if (lastErr) {
reportScriptFailure(lastErr, cloud, agent, authHint, prompt); reportScriptFailure(lastErr, cloud, agent, authHint, prompt, dashboardUrl);
} }
} }