mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-01 21:30:21 +00:00
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:
parent
7d6bc0292b
commit
059690f8d7
4 changed files with 113 additions and 21 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue