Skip to content

Commit 72ccb09

Browse files
la14-1louisgvclaude
authored
feat: integrate Sprite keep-alive tasks for all Sprite agents (#2428)
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: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent e396a61 commit 72ccb09

4 files changed

Lines changed: 334 additions & 3 deletions

File tree

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openrouter/spawn",
3-
"version": "0.15.35",
3+
"version": "0.15.36",
44
"type": "module",
55
"bin": {
66
"spawn": "cli.js"
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
/**
2+
* sprite-keep-alive.test.ts — Tests for Sprite keep-alive integration.
3+
*
4+
* Verifies:
5+
* - installSpriteKeepAlive() downloads and installs the keep-alive script
6+
* - installSpriteKeepAlive() is gracefully non-fatal when download fails
7+
* - interactiveSession() wraps the cmd in a session script with keep-alive support
8+
*
9+
* IMPORTANT: Only mock.module "../shared/ssh" here — NOT "../shared/ui" or
10+
* "../shared/paths", as those are shared with other test files and would
11+
* cause failures in history.test.ts, paths.test.ts, etc.
12+
*/
13+
14+
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
15+
16+
// ── Mock only ../shared/ssh (not used directly by any other test file) ────────
17+
18+
const mockSpawnInteractive = mock((_args: string[]) => 0);
19+
const mockKillWithTimeout = mock(() => {});
20+
const mockSleep = mock(() => Promise.resolve());
21+
22+
mock.module("../shared/ssh", () => ({
23+
spawnInteractive: mockSpawnInteractive,
24+
killWithTimeout: mockKillWithTimeout,
25+
sleep: mockSleep,
26+
SSH_INTERACTIVE_OPTS: [],
27+
}));
28+
29+
// ── Import module under test after mocks ──────────────────────────────────────
30+
31+
const { installSpriteKeepAlive, interactiveSession } = await import("../sprite/sprite");
32+
33+
// ── Helpers ───────────────────────────────────────────────────────────────────
34+
35+
/** Build a mock Bun.SubprocessResult for spawnSync. */
36+
function makeSyncResult(exitCode: number, stdout = ""): ReturnType<typeof Bun.spawnSync> {
37+
return {
38+
exitCode,
39+
stdout: new TextEncoder().encode(stdout),
40+
stderr: new Uint8Array(),
41+
success: exitCode === 0,
42+
signalCode: null,
43+
resourceUsage: undefined,
44+
exited: exitCode,
45+
pid: 1234,
46+
};
47+
}
48+
49+
/** Build a minimal mock subprocess for Bun.spawn. */
50+
function makeSpawnResult(exitCode: number): {
51+
exited: Promise<number>;
52+
stderr: ReadableStream;
53+
} {
54+
return {
55+
exited: Promise.resolve(exitCode),
56+
stderr: new ReadableStream(),
57+
};
58+
}
59+
60+
// ── Tests: installSpriteKeepAlive ─────────────────────────────────────────────
61+
62+
describe("installSpriteKeepAlive", () => {
63+
let spawnSyncSpy: ReturnType<typeof spyOn>;
64+
let spawnSpy: ReturnType<typeof spyOn>;
65+
let stderrSpy: ReturnType<typeof spyOn>;
66+
67+
beforeEach(() => {
68+
stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);
69+
70+
// Make getSpriteCmd() find "sprite" via `which sprite`
71+
spawnSyncSpy = spyOn(Bun, "spawnSync").mockImplementation((args: string[]) => {
72+
if (Array.isArray(args) && args[0] === "which" && args[1] === "sprite") {
73+
return makeSyncResult(0, "sprite");
74+
}
75+
// sprite version call
76+
return makeSyncResult(0, "sprite v1.0.0");
77+
});
78+
79+
spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => makeSpawnResult(0));
80+
});
81+
82+
afterEach(() => {
83+
spawnSyncSpy.mockRestore();
84+
spawnSpy.mockRestore();
85+
stderrSpy.mockRestore();
86+
});
87+
88+
it("calls runSprite with the keep-alive script URL", async () => {
89+
const capturedCmds: string[] = [];
90+
spawnSpy.mockImplementation((args: string[]) => {
91+
const bashIdx = args.indexOf("bash");
92+
if (bashIdx !== -1 && args[bashIdx + 1] === "-c") {
93+
capturedCmds.push(args[bashIdx + 2]);
94+
}
95+
return makeSpawnResult(0);
96+
});
97+
98+
await installSpriteKeepAlive();
99+
100+
expect(capturedCmds.some((cmd) => cmd.includes("kurt-claw-f.sprites.app/sprite-keep-running.sh"))).toBe(true);
101+
expect(capturedCmds.some((cmd) => cmd.includes("sprite-keep-running"))).toBe(true);
102+
});
103+
104+
it("installs to ~/.local/bin and makes script executable", async () => {
105+
const capturedCmds: string[] = [];
106+
spawnSpy.mockImplementation((args: string[]) => {
107+
const bashIdx = args.indexOf("bash");
108+
if (bashIdx !== -1 && args[bashIdx + 1] === "-c") {
109+
capturedCmds.push(args[bashIdx + 2]);
110+
}
111+
return makeSpawnResult(0);
112+
});
113+
114+
await installSpriteKeepAlive();
115+
116+
expect(capturedCmds.some((cmd) => cmd.includes(".local/bin/sprite-keep-running"))).toBe(true);
117+
expect(capturedCmds.some((cmd) => cmd.includes("chmod +x"))).toBe(true);
118+
});
119+
120+
it("does not throw when script download fails", async () => {
121+
// Simulate runSprite throwing (process exits with code 1)
122+
spawnSpy.mockImplementation(() => makeSpawnResult(1));
123+
124+
// Should resolve without throwing
125+
await expect(installSpriteKeepAlive()).resolves.toBeUndefined();
126+
});
127+
});
128+
129+
// ── Tests: interactiveSession ─────────────────────────────────────────────────
130+
131+
describe("interactiveSession (keep-alive wrapper)", () => {
132+
let spawnSyncSpy: ReturnType<typeof spyOn>;
133+
let stderrSpy: ReturnType<typeof spyOn>;
134+
135+
beforeEach(() => {
136+
mockSpawnInteractive.mockClear();
137+
mockSpawnInteractive.mockImplementation(() => 0);
138+
stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);
139+
140+
// Make getSpriteCmd() find "sprite"
141+
spawnSyncSpy = spyOn(Bun, "spawnSync").mockImplementation((args: string[]) => {
142+
if (Array.isArray(args) && args[0] === "which" && args[1] === "sprite") {
143+
return makeSyncResult(0, "sprite");
144+
}
145+
return makeSyncResult(0, "sprite v1.0.0");
146+
});
147+
});
148+
149+
afterEach(() => {
150+
spawnSyncSpy.mockRestore();
151+
stderrSpy.mockRestore();
152+
delete process.env.SPAWN_PROMPT;
153+
});
154+
155+
it("base64-encodes the original cmd in the session script", async () => {
156+
const testCmd = "openclaw tui";
157+
const expectedB64 = Buffer.from(testCmd).toString("base64");
158+
159+
let capturedSessionScript = "";
160+
mockSpawnInteractive.mockImplementation((args: string[]) => {
161+
const bashIdx = args.indexOf("bash");
162+
if (bashIdx !== -1 && args[bashIdx + 1] === "-c") {
163+
capturedSessionScript = args[bashIdx + 2];
164+
}
165+
return 0;
166+
});
167+
168+
await interactiveSession(testCmd);
169+
170+
expect(capturedSessionScript).toContain(expectedB64);
171+
});
172+
173+
it("includes sprite-keep-running check in session script", async () => {
174+
let capturedSessionScript = "";
175+
mockSpawnInteractive.mockImplementation((args: string[]) => {
176+
const bashIdx = args.indexOf("bash");
177+
if (bashIdx !== -1 && args[bashIdx + 1] === "-c") {
178+
capturedSessionScript = args[bashIdx + 2];
179+
}
180+
return 0;
181+
});
182+
183+
await interactiveSession("my-agent --start");
184+
185+
expect(capturedSessionScript).toContain("sprite-keep-running");
186+
expect(capturedSessionScript).toContain("command -v sprite-keep-running");
187+
});
188+
189+
it("creates a temp file for the session script", async () => {
190+
let capturedSessionScript = "";
191+
mockSpawnInteractive.mockImplementation((args: string[]) => {
192+
const bashIdx = args.indexOf("bash");
193+
if (bashIdx !== -1 && args[bashIdx + 1] === "-c") {
194+
capturedSessionScript = args[bashIdx + 2];
195+
}
196+
return 0;
197+
});
198+
199+
await interactiveSession("agent cmd");
200+
201+
expect(capturedSessionScript).toContain("mktemp");
202+
expect(capturedSessionScript).toContain("base64 -d");
203+
expect(capturedSessionScript).toContain("trap");
204+
});
205+
206+
it("includes else branch for fallback to plain bash", async () => {
207+
let capturedSessionScript = "";
208+
mockSpawnInteractive.mockImplementation((args: string[]) => {
209+
const bashIdx = args.indexOf("bash");
210+
if (bashIdx !== -1 && args[bashIdx + 1] === "-c") {
211+
capturedSessionScript = args[bashIdx + 2];
212+
}
213+
return 0;
214+
});
215+
216+
await interactiveSession("fallback-agent");
217+
218+
expect(capturedSessionScript).toContain("else");
219+
expect(capturedSessionScript).toMatch(/else[\s\S]*bash/);
220+
});
221+
222+
it("handles multi-line restart loop commands (base64-encoded as single token)", async () => {
223+
const multilineCmd = [
224+
"_spawn_restarts=0",
225+
"while [ $_spawn_restarts -lt 10 ]; do",
226+
" openclaw tui",
227+
" _spawn_exit=$?",
228+
" _spawn_restarts=$((_spawn_restarts + 1))",
229+
"done",
230+
].join("\n");
231+
232+
const expectedB64 = Buffer.from(multilineCmd).toString("base64");
233+
let capturedSessionScript = "";
234+
mockSpawnInteractive.mockImplementation((args: string[]) => {
235+
const bashIdx = args.indexOf("bash");
236+
if (bashIdx !== -1 && args[bashIdx + 1] === "-c") {
237+
capturedSessionScript = args[bashIdx + 2];
238+
}
239+
return 0;
240+
});
241+
242+
await interactiveSession(multilineCmd);
243+
244+
expect(capturedSessionScript).toContain(expectedB64);
245+
});
246+
247+
it("uses -tty flag for interactive mode (SPAWN_PROMPT not set)", async () => {
248+
delete process.env.SPAWN_PROMPT;
249+
250+
let capturedArgs: string[] = [];
251+
mockSpawnInteractive.mockImplementation((args: string[]) => {
252+
capturedArgs = args;
253+
return 0;
254+
});
255+
256+
await interactiveSession("agent-cmd");
257+
258+
expect(capturedArgs).toContain("-tty");
259+
});
260+
261+
it("omits -tty flag when SPAWN_PROMPT is set", async () => {
262+
process.env.SPAWN_PROMPT = "non-interactive";
263+
264+
let capturedArgs: string[] = [];
265+
mockSpawnInteractive.mockImplementation((args: string[]) => {
266+
capturedArgs = args;
267+
return 0;
268+
});
269+
270+
await interactiveSession("agent-cmd");
271+
272+
expect(capturedArgs).not.toContain("-tty");
273+
});
274+
275+
it("returns the exit code from spawnInteractive", async () => {
276+
mockSpawnInteractive.mockImplementation(() => 42);
277+
278+
const exitCode = await interactiveSession("agent-cmd");
279+
280+
expect(exitCode).toBe(42);
281+
});
282+
});

packages/cli/src/sprite/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
ensureSpriteCli,
1414
getServerName,
1515
getVmConnection,
16+
installSpriteKeepAlive,
1617
interactiveSession,
1718
promptSpawnName,
1819
runSprite,
@@ -48,6 +49,7 @@ async function main() {
4849
await createSprite(name);
4950
await verifySpriteConnectivity();
5051
await setupShellEnvironment();
52+
await installSpriteKeepAlive();
5153
return getVmConnection();
5254
},
5355
getServerName,

packages/cli/src/sprite/sprite.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -541,13 +541,60 @@ export async function uploadFileSprite(localPath: string, remotePath: string): P
541541
});
542542
}
543543

544+
// ─── Keep-Alive ───────────────────────────────────────────────────────────────
545+
546+
/**
547+
* Download and install sprite-keep-running on the remote sprite.
548+
* This script wraps a command and keeps the sprite alive (via Sprite's /v1/tasks API)
549+
* as long as the agent is running — preventing inactivity shutdown.
550+
*
551+
* Non-fatal: logs a warning if download fails so deployment still proceeds.
552+
* Reference: https://kurt-claw-f.sprites.app/sprite-keep-running.sh
553+
*/
554+
export async function installSpriteKeepAlive(): Promise<void> {
555+
logStep("Installing Sprite keep-alive...");
556+
const scriptUrl = "https://kurt-claw-f.sprites.app/sprite-keep-running.sh";
557+
try {
558+
await runSprite(
559+
"mkdir -p ~/.local/bin && " +
560+
`curl -fsSL '${scriptUrl}' -o ~/.local/bin/sprite-keep-running && ` +
561+
"chmod +x ~/.local/bin/sprite-keep-running",
562+
60,
563+
);
564+
logInfo("Sprite keep-alive installed");
565+
} catch {
566+
logWarn("Could not install Sprite keep-alive — sprite may shut down during inactivity");
567+
}
568+
}
569+
544570
/**
545571
* Launch an interactive session on the sprite.
546572
* Uses -tty for interactive mode, plain exec when SPAWN_PROMPT is set.
573+
*
574+
* The session command is base64-encoded and written to a temp file to avoid
575+
* quoting issues with multi-line restart loop scripts. If sprite-keep-running
576+
* is installed, it wraps the command to keep the sprite alive via Sprite's
577+
* /v1/tasks API for the duration of the session.
547578
*/
548579
export async function interactiveSession(cmd: string): Promise<number> {
549580
const spriteCmd = getSpriteCmd()!;
550581

582+
// Encode the session command to handle multi-line restart loop scripts safely
583+
const cmdB64 = Buffer.from(cmd).toString("base64");
584+
585+
// Write cmd to a temp file and exec with keep-alive wrapper if available
586+
const sessionScript = [
587+
"_f=$(mktemp /tmp/spawn_XXXXXX.sh)",
588+
`printf '%s' '${cmdB64}' | base64 -d > "$_f"`,
589+
'chmod +x "$_f"',
590+
"trap 'rm -f \"$_f\"' EXIT INT TERM",
591+
"if command -v sprite-keep-running >/dev/null 2>&1; then",
592+
' sprite-keep-running bash "$_f"',
593+
"else",
594+
' bash "$_f"',
595+
"fi",
596+
].join("\n");
597+
551598
const args = process.env.SPAWN_PROMPT
552599
? [
553600
spriteCmd,
@@ -558,7 +605,7 @@ export async function interactiveSession(cmd: string): Promise<number> {
558605
"--",
559606
"bash",
560607
"-c",
561-
cmd,
608+
sessionScript,
562609
]
563610
: [
564611
spriteCmd,
@@ -570,7 +617,7 @@ export async function interactiveSession(cmd: string): Promise<number> {
570617
"--",
571618
"bash",
572619
"-c",
573-
cmd,
620+
sessionScript,
574621
];
575622

576623
const exitCode = spawnInteractive(args);

0 commit comments

Comments
 (0)