From aa2e64b34b0146cdab101311f0cc2cda8ad41958 Mon Sep 17 00:00:00 2001 From: B <6723574+louisgv@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:30:00 +0000 Subject: [PATCH] feat: integrate Sprite keep-alive tasks for all Sprite agents Adds sprite-keep-running support so sprites stay alive during long agent sessions instead of shutting down due to inactivity. - Add installSpriteKeepAlive() to sprite/sprite.ts: downloads and installs the sprite-keep-running script (~/.local/bin) on the sprite during setup. Non-fatal: logs a warning if download fails so deployment still proceeds. - Modify interactiveSession() to wrap the session command in a temp script (base64-encoded to handle multi-line restart loops) and exec it via sprite-keep-running if available, with plain bash fallback. - Call installSpriteKeepAlive() in sprite/main.ts createServer() step after setupShellEnvironment(), applying to all Sprite agents. - Add sprite-keep-alive.test.ts: 11 unit tests covering download URL, install path, error resilience, session script structure, and keep-alive wrapper inclusion. Fixes #2424 Agent: issue-fixer Co-Authored-By: Claude Sonnet 4.5 --- packages/cli/package.json | 2 +- .../src/__tests__/sprite-keep-alive.test.ts | 282 ++++++++++++++++++ packages/cli/src/sprite/main.ts | 2 + packages/cli/src/sprite/sprite.ts | 51 +++- 4 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/__tests__/sprite-keep-alive.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index ce36667da..bc2520465 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.15.35", + "version": "0.15.36", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/sprite-keep-alive.test.ts b/packages/cli/src/__tests__/sprite-keep-alive.test.ts new file mode 100644 index 000000000..1515fb395 --- /dev/null +++ b/packages/cli/src/__tests__/sprite-keep-alive.test.ts @@ -0,0 +1,282 @@ +/** + * sprite-keep-alive.test.ts — Tests for Sprite keep-alive integration. + * + * Verifies: + * - installSpriteKeepAlive() downloads and installs the keep-alive script + * - installSpriteKeepAlive() is gracefully non-fatal when download fails + * - interactiveSession() wraps the cmd in a session script with keep-alive support + * + * IMPORTANT: Only mock.module "../shared/ssh" here — NOT "../shared/ui" or + * "../shared/paths", as those are shared with other test files and would + * cause failures in history.test.ts, paths.test.ts, etc. + */ + +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; + +// ── Mock only ../shared/ssh (not used directly by any other test file) ──────── + +const mockSpawnInteractive = mock((_args: string[]) => 0); +const mockKillWithTimeout = mock(() => {}); +const mockSleep = mock(() => Promise.resolve()); + +mock.module("../shared/ssh", () => ({ + spawnInteractive: mockSpawnInteractive, + killWithTimeout: mockKillWithTimeout, + sleep: mockSleep, + SSH_INTERACTIVE_OPTS: [], +})); + +// ── Import module under test after mocks ────────────────────────────────────── + +const { installSpriteKeepAlive, interactiveSession } = await import("../sprite/sprite"); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Build a mock Bun.SubprocessResult for spawnSync. */ +function makeSyncResult(exitCode: number, stdout = ""): ReturnType { + return { + exitCode, + stdout: new TextEncoder().encode(stdout), + stderr: new Uint8Array(), + success: exitCode === 0, + signalCode: null, + resourceUsage: undefined, + exited: exitCode, + pid: 1234, + }; +} + +/** Build a minimal mock subprocess for Bun.spawn. */ +function makeSpawnResult(exitCode: number): { + exited: Promise; + stderr: ReadableStream; +} { + return { + exited: Promise.resolve(exitCode), + stderr: new ReadableStream(), + }; +} + +// ── Tests: installSpriteKeepAlive ───────────────────────────────────────────── + +describe("installSpriteKeepAlive", () => { + let spawnSyncSpy: ReturnType; + let spawnSpy: ReturnType; + let stderrSpy: ReturnType; + + beforeEach(() => { + stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); + + // Make getSpriteCmd() find "sprite" via `which sprite` + spawnSyncSpy = spyOn(Bun, "spawnSync").mockImplementation((args: string[]) => { + if (Array.isArray(args) && args[0] === "which" && args[1] === "sprite") { + return makeSyncResult(0, "sprite"); + } + // sprite version call + return makeSyncResult(0, "sprite v1.0.0"); + }); + + spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => makeSpawnResult(0)); + }); + + afterEach(() => { + spawnSyncSpy.mockRestore(); + spawnSpy.mockRestore(); + stderrSpy.mockRestore(); + }); + + it("calls runSprite with the keep-alive script URL", async () => { + const capturedCmds: string[] = []; + spawnSpy.mockImplementation((args: string[]) => { + const bashIdx = args.indexOf("bash"); + if (bashIdx !== -1 && args[bashIdx + 1] === "-c") { + capturedCmds.push(args[bashIdx + 2]); + } + return makeSpawnResult(0); + }); + + await installSpriteKeepAlive(); + + expect(capturedCmds.some((cmd) => cmd.includes("kurt-claw-f.sprites.app/sprite-keep-running.sh"))).toBe(true); + expect(capturedCmds.some((cmd) => cmd.includes("sprite-keep-running"))).toBe(true); + }); + + it("installs to ~/.local/bin and makes script executable", async () => { + const capturedCmds: string[] = []; + spawnSpy.mockImplementation((args: string[]) => { + const bashIdx = args.indexOf("bash"); + if (bashIdx !== -1 && args[bashIdx + 1] === "-c") { + capturedCmds.push(args[bashIdx + 2]); + } + return makeSpawnResult(0); + }); + + await installSpriteKeepAlive(); + + expect(capturedCmds.some((cmd) => cmd.includes(".local/bin/sprite-keep-running"))).toBe(true); + expect(capturedCmds.some((cmd) => cmd.includes("chmod +x"))).toBe(true); + }); + + it("does not throw when script download fails", async () => { + // Simulate runSprite throwing (process exits with code 1) + spawnSpy.mockImplementation(() => makeSpawnResult(1)); + + // Should resolve without throwing + await expect(installSpriteKeepAlive()).resolves.toBeUndefined(); + }); +}); + +// ── Tests: interactiveSession ───────────────────────────────────────────────── + +describe("interactiveSession (keep-alive wrapper)", () => { + let spawnSyncSpy: ReturnType; + let stderrSpy: ReturnType; + + beforeEach(() => { + mockSpawnInteractive.mockClear(); + mockSpawnInteractive.mockImplementation(() => 0); + stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); + + // Make getSpriteCmd() find "sprite" + spawnSyncSpy = spyOn(Bun, "spawnSync").mockImplementation((args: string[]) => { + if (Array.isArray(args) && args[0] === "which" && args[1] === "sprite") { + return makeSyncResult(0, "sprite"); + } + return makeSyncResult(0, "sprite v1.0.0"); + }); + }); + + afterEach(() => { + spawnSyncSpy.mockRestore(); + stderrSpy.mockRestore(); + delete process.env.SPAWN_PROMPT; + }); + + it("base64-encodes the original cmd in the session script", async () => { + const testCmd = "openclaw tui"; + const expectedB64 = Buffer.from(testCmd).toString("base64"); + + let capturedSessionScript = ""; + mockSpawnInteractive.mockImplementation((args: string[]) => { + const bashIdx = args.indexOf("bash"); + if (bashIdx !== -1 && args[bashIdx + 1] === "-c") { + capturedSessionScript = args[bashIdx + 2]; + } + return 0; + }); + + await interactiveSession(testCmd); + + expect(capturedSessionScript).toContain(expectedB64); + }); + + it("includes sprite-keep-running check in session script", async () => { + let capturedSessionScript = ""; + mockSpawnInteractive.mockImplementation((args: string[]) => { + const bashIdx = args.indexOf("bash"); + if (bashIdx !== -1 && args[bashIdx + 1] === "-c") { + capturedSessionScript = args[bashIdx + 2]; + } + return 0; + }); + + await interactiveSession("my-agent --start"); + + expect(capturedSessionScript).toContain("sprite-keep-running"); + expect(capturedSessionScript).toContain("command -v sprite-keep-running"); + }); + + it("creates a temp file for the session script", async () => { + let capturedSessionScript = ""; + mockSpawnInteractive.mockImplementation((args: string[]) => { + const bashIdx = args.indexOf("bash"); + if (bashIdx !== -1 && args[bashIdx + 1] === "-c") { + capturedSessionScript = args[bashIdx + 2]; + } + return 0; + }); + + await interactiveSession("agent cmd"); + + expect(capturedSessionScript).toContain("mktemp"); + expect(capturedSessionScript).toContain("base64 -d"); + expect(capturedSessionScript).toContain("trap"); + }); + + it("includes else branch for fallback to plain bash", async () => { + let capturedSessionScript = ""; + mockSpawnInteractive.mockImplementation((args: string[]) => { + const bashIdx = args.indexOf("bash"); + if (bashIdx !== -1 && args[bashIdx + 1] === "-c") { + capturedSessionScript = args[bashIdx + 2]; + } + return 0; + }); + + await interactiveSession("fallback-agent"); + + expect(capturedSessionScript).toContain("else"); + expect(capturedSessionScript).toMatch(/else[\s\S]*bash/); + }); + + it("handles multi-line restart loop commands (base64-encoded as single token)", async () => { + const multilineCmd = [ + "_spawn_restarts=0", + "while [ $_spawn_restarts -lt 10 ]; do", + " openclaw tui", + " _spawn_exit=$?", + " _spawn_restarts=$((_spawn_restarts + 1))", + "done", + ].join("\n"); + + const expectedB64 = Buffer.from(multilineCmd).toString("base64"); + let capturedSessionScript = ""; + mockSpawnInteractive.mockImplementation((args: string[]) => { + const bashIdx = args.indexOf("bash"); + if (bashIdx !== -1 && args[bashIdx + 1] === "-c") { + capturedSessionScript = args[bashIdx + 2]; + } + return 0; + }); + + await interactiveSession(multilineCmd); + + expect(capturedSessionScript).toContain(expectedB64); + }); + + it("uses -tty flag for interactive mode (SPAWN_PROMPT not set)", async () => { + delete process.env.SPAWN_PROMPT; + + let capturedArgs: string[] = []; + mockSpawnInteractive.mockImplementation((args: string[]) => { + capturedArgs = args; + return 0; + }); + + await interactiveSession("agent-cmd"); + + expect(capturedArgs).toContain("-tty"); + }); + + it("omits -tty flag when SPAWN_PROMPT is set", async () => { + process.env.SPAWN_PROMPT = "non-interactive"; + + let capturedArgs: string[] = []; + mockSpawnInteractive.mockImplementation((args: string[]) => { + capturedArgs = args; + return 0; + }); + + await interactiveSession("agent-cmd"); + + expect(capturedArgs).not.toContain("-tty"); + }); + + it("returns the exit code from spawnInteractive", async () => { + mockSpawnInteractive.mockImplementation(() => 42); + + const exitCode = await interactiveSession("agent-cmd"); + + expect(exitCode).toBe(42); + }); +}); diff --git a/packages/cli/src/sprite/main.ts b/packages/cli/src/sprite/main.ts index dafc15c8f..4c63a26d7 100644 --- a/packages/cli/src/sprite/main.ts +++ b/packages/cli/src/sprite/main.ts @@ -13,6 +13,7 @@ import { ensureSpriteCli, getServerName, getVmConnection, + installSpriteKeepAlive, interactiveSession, promptSpawnName, runSprite, @@ -48,6 +49,7 @@ async function main() { await createSprite(name); await verifySpriteConnectivity(); await setupShellEnvironment(); + await installSpriteKeepAlive(); return getVmConnection(); }, getServerName, diff --git a/packages/cli/src/sprite/sprite.ts b/packages/cli/src/sprite/sprite.ts index 1e92edc55..58594b5d1 100644 --- a/packages/cli/src/sprite/sprite.ts +++ b/packages/cli/src/sprite/sprite.ts @@ -541,13 +541,60 @@ export async function uploadFileSprite(localPath: string, remotePath: string): P }); } +// ─── Keep-Alive ─────────────────────────────────────────────────────────────── + +/** + * Download and install sprite-keep-running on the remote sprite. + * This script wraps a command and keeps the sprite alive (via Sprite's /v1/tasks API) + * as long as the agent is running — preventing inactivity shutdown. + * + * Non-fatal: logs a warning if download fails so deployment still proceeds. + * Reference: https://kurt-claw-f.sprites.app/sprite-keep-running.sh + */ +export async function installSpriteKeepAlive(): Promise { + logStep("Installing Sprite keep-alive..."); + const scriptUrl = "https://kurt-claw-f.sprites.app/sprite-keep-running.sh"; + try { + await runSprite( + "mkdir -p ~/.local/bin && " + + `curl -fsSL '${scriptUrl}' -o ~/.local/bin/sprite-keep-running && ` + + "chmod +x ~/.local/bin/sprite-keep-running", + 60, + ); + logInfo("Sprite keep-alive installed"); + } catch { + logWarn("Could not install Sprite keep-alive — sprite may shut down during inactivity"); + } +} + /** * Launch an interactive session on the sprite. * Uses -tty for interactive mode, plain exec when SPAWN_PROMPT is set. + * + * The session command is base64-encoded and written to a temp file to avoid + * quoting issues with multi-line restart loop scripts. If sprite-keep-running + * is installed, it wraps the command to keep the sprite alive via Sprite's + * /v1/tasks API for the duration of the session. */ export async function interactiveSession(cmd: string): Promise { const spriteCmd = getSpriteCmd()!; + // Encode the session command to handle multi-line restart loop scripts safely + const cmdB64 = Buffer.from(cmd).toString("base64"); + + // Write cmd to a temp file and exec with keep-alive wrapper if available + const sessionScript = [ + "_f=$(mktemp /tmp/spawn_XXXXXX.sh)", + `printf '%s' '${cmdB64}' | base64 -d > "$_f"`, + 'chmod +x "$_f"', + "trap 'rm -f \"$_f\"' EXIT INT TERM", + "if command -v sprite-keep-running >/dev/null 2>&1; then", + ' sprite-keep-running bash "$_f"', + "else", + ' bash "$_f"', + "fi", + ].join("\n"); + const args = process.env.SPAWN_PROMPT ? [ spriteCmd, @@ -558,7 +605,7 @@ export async function interactiveSession(cmd: string): Promise { "--", "bash", "-c", - cmd, + sessionScript, ] : [ spriteCmd, @@ -570,7 +617,7 @@ export async function interactiveSession(cmd: string): Promise { "--", "bash", "-c", - cmd, + sessionScript, ]; const exitCode = spawnInteractive(args);