fix: snapshot cleanup + claude install (name-prefix filter) (#2273)

* fix: claude snapshot build — remove npm fallback from install command

The native install (curl | bash) succeeds but exits non-zero due to a
PATH warning. The || fallback then tries `npm install` which doesn't
exist on the "minimal" tier → exit 127.

Fix: replace npm fallback with binary existence check (same pattern
as hermes agent). If install exits non-zero but ~/.local/bin/claude
exists, the build succeeds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: snapshot cleanup and lookup — use name prefix instead of tags

DO Packer builder `tags` only apply to the temporary build droplet,
not the resulting snapshot image. Both the workflow cleanup step and
the CLI's findSpawnSnapshot() were querying by `tag_name` which
returned nothing — old snapshots piled up and the CLI couldn't find
existing snapshots.

Fix: filter by snapshot name prefix (`spawn-{agent}-`) instead of
tags, in both the workflow and the CLI. Remove misleading `tags`
from the Packer template. Add test cases for name-prefix filtering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ahmed Abushagur 2026-03-06 21:32:58 -08:00 committed by GitHub
parent c3cb98daab
commit d77a067aa4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 64 additions and 15 deletions

View file

@ -90,10 +90,12 @@ jobs:
- name: Cleanup old snapshots
if: success()
run: |
# Keep only the latest snapshot per agent
# DO snapshots don't support tags — filter by name prefix instead
PREFIX="spawn-${AGENT_NAME}-"
SNAPSHOTS=$(curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" \
"https://api.digitalocean.com/v2/images?private=true&per_page=100&tag_name=spawn-${AGENT_NAME}" \
| jq -r '.images | sort_by(.created_at) | reverse | .[1:] | .[].id')
"https://api.digitalocean.com/v2/images?private=true&per_page=100" \
| jq -r --arg prefix "$PREFIX" \
'[.images[] | select(.name | startswith($prefix))] | sort_by(.created_at) | reverse | .[1:] | .[].id')
for ID in $SNAPSHOTS; do
echo "Deleting old snapshot: ${ID}"

View file

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

View file

@ -2,7 +2,7 @@
* do-snapshot.test.ts Tests for findSpawnSnapshot().
*
* Verifies snapshot lookup: happy path, empty results, API errors,
* invalid IDs, and network failures all return correct values.
* invalid IDs, name filtering, and network failures.
*/
import { afterAll, afterEach, describe, expect, it, mock } from "bun:test";
@ -37,14 +37,17 @@ describe("findSpawnSnapshot", () => {
images: [
{
id: 100,
name: "spawn-claude-20260101-0000",
created_at: "2026-01-01T00:00:00Z",
},
{
id: 200,
name: "spawn-claude-20260301-0000",
created_at: "2026-03-01T00:00:00Z",
},
{
id: 150,
name: "spawn-claude-20260201-0000",
created_at: "2026-02-01T00:00:00Z",
},
],
@ -57,6 +60,32 @@ describe("findSpawnSnapshot", () => {
expect(result).toBe("200");
});
it("filters by name prefix — ignores other agents", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
images: [
{
id: 300,
name: "spawn-codex-20260301-0000",
created_at: "2026-03-01T00:00:00Z",
},
{
id: 400,
name: "spawn-claude-20260201-0000",
created_at: "2026-02-01T00:00:00Z",
},
],
}),
),
),
);
const result = await findSpawnSnapshot("claude");
expect(result).toBe("400");
});
it("returns null when no images are found", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
@ -72,6 +101,27 @@ describe("findSpawnSnapshot", () => {
expect(result).toBeNull();
});
it("returns null when no images match the agent name", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
images: [
{
id: 100,
name: "spawn-codex-20260101-0000",
created_at: "2026-01-01T00:00:00Z",
},
],
}),
),
),
);
const result = await findSpawnSnapshot("claude");
expect(result).toBeNull();
});
it("returns null on API error response", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
@ -93,6 +143,7 @@ describe("findSpawnSnapshot", () => {
images: [
{
id: "not-a-number",
name: "spawn-claude-20260101-0000",
created_at: "2026-01-01T00:00:00Z",
},
],

View file

@ -887,14 +887,12 @@ async function waitForDropletActive(dropletId: string, maxAttempts = 60): Promis
export async function findSpawnSnapshot(agentName: string): Promise<string | null> {
try {
const text = await doApi(
"GET",
`/images?private=true&per_page=50&tag_name=spawn-${encodeURIComponent(agentName)}`,
undefined,
1,
);
// DO snapshots don't support tags — filter by name prefix instead
const prefix = `spawn-${agentName}-`;
const text = await doApi("GET", "/images?private=true&per_page=100", undefined, 1);
const data = parseJsonObj(text);
const images = toObjectArray(data?.images);
const allImages = toObjectArray(data?.images);
const images = allImages.filter((img) => isString(img.name) && img.name.startsWith(prefix));
if (images.length === 0) {
return null;
}

View file

@ -2,7 +2,7 @@
"claude": {
"tier": "minimal",
"install": [
"curl -fsSL https://claude.ai/install.sh | bash || mkdir -p ~/.npm-global/bin && npm install -g --prefix ~/.npm-global @anthropic-ai/claude-code"
"curl -fsSL https://claude.ai/install.sh | bash || [ -f /root/.local/bin/claude ]"
]
},
"codex": {

View file

@ -45,8 +45,6 @@ source "digitalocean" "spawn" {
"nyc1", "nyc3", "sfo3", "tor1", "ams3",
"lon1", "fra1", "blr1", "sgp1", "syd1",
]
tags = ["spawn", "spawn-${var.agent_name}"]
}
build {