Skip to content
Merged
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@conway/automaton",
"version": "0.2.0",
"version": "0.2.1",
"description": "Conway Automaton - Sovereign AI Agent Runtime",
"type": "module",
"main": "dist/index.js",
Expand Down
2 changes: 0 additions & 2 deletions src/__tests__/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,12 +465,10 @@ describe("SandboxCleanup", () => {
lifecycle.transition("child-1", "healthy");
lifecycle.transition("child-1", "stopped");

const deleteSpy = vi.spyOn(conway, "deleteSandbox");
const cleanup = new SandboxCleanup(conway, lifecycle, db);
await cleanup.cleanup("child-1");

expect(lifecycle.getCurrentState("child-1")).toBe("cleaned_up");
expect(deleteSpy).toHaveBeenCalledWith("sandbox-1");
});

it("cleanup transitions failed to cleaned_up", async () => {
Expand Down
20 changes: 19 additions & 1 deletion src/__tests__/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,24 @@ export class MockConwayClient implements ConwayClient {
{ id: "gpt-4.1", provider: "openai", pricing: { inputPerMillion: 2.00, outputPerMillion: 8.00 } },
];
}

async registerAutomaton(_params: {
automatonId: string;
automatonAddress: import("viem").Address;
creatorAddress: import("viem").Address;
name: string;
bio?: string;
genesisPromptHash?: `0x${string}`;
account: import("viem").PrivateKeyAccount;
nonce?: string;
}): Promise<{ automaton: Record<string, unknown> }> {
return { automaton: {} };
}

createScopedClient(_targetSandboxId: string): ConwayClient {
// Return self so spies on exec/writeFile propagate to scoped clients
return this;
}
}

// ─── Mock Social Client ─────────────────────────────────────────
Expand Down Expand Up @@ -328,7 +346,7 @@ export function createTestConfig(
dbPath: "/tmp/test-state.db",
logLevel: "error",
walletAddress: "0x1234567890abcdef1234567890abcdef12345678" as `0x${string}`,
version: "0.2.0",
version: "0.2.1",
skillsDir: "/tmp/test-skills",
maxChildren: 3,
maxTurnsPerCycle: 25,
Expand Down
30 changes: 14 additions & 16 deletions src/__tests__/replication.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ describe("spawnChild", () => {
it("validates wallet address before creating child record", async () => {
// Mock exec to return valid wallet address on init
vi.spyOn(conway, "exec").mockImplementation(async (command: string) => {
if (command.includes("automaton --init")) {
if (command.includes("--init")) {
return { stdout: `Wallet initialized: ${validAddress}`, stderr: "", exitCode: 0 };
}
return { stdout: "ok", stderr: "", exitCode: 0 };
Expand All @@ -127,7 +127,7 @@ describe("spawnChild", () => {

it("throws on zero address from init", async () => {
vi.spyOn(conway, "exec").mockImplementation(async (command: string) => {
if (command.includes("automaton --init")) {
if (command.includes("--init")) {
return { stdout: `Wallet: ${zeroAddress}`, stderr: "", exitCode: 0 };
}
return { stdout: "ok", stderr: "", exitCode: 0 };
Expand All @@ -139,7 +139,7 @@ describe("spawnChild", () => {

it("throws when init returns no wallet address", async () => {
vi.spyOn(conway, "exec").mockImplementation(async (command: string) => {
if (command.includes("automaton --init")) {
if (command.includes("--init")) {
return { stdout: "initialization complete, no wallet", stderr: "", exitCode: 0 };
}
return { stdout: "ok", stderr: "", exitCode: 0 };
Expand All @@ -149,7 +149,7 @@ describe("spawnChild", () => {
.rejects.toThrow("Child wallet address invalid");
});

it("cleans up sandbox on exec failure", async () => {
it("propagates error on exec failure without calling deleteSandbox", async () => {
const deleteSpy = vi.spyOn(conway, "deleteSandbox");

// Make the first exec (apt-get install) fail
Expand All @@ -158,14 +158,15 @@ describe("spawnChild", () => {
await expect(spawnChild(conway, identity, db, genesis))
.rejects.toThrow();

expect(deleteSpy).toHaveBeenCalledWith("new-sandbox-id");
// Sandbox deletion is disabled — should not attempt cleanup
expect(deleteSpy).not.toHaveBeenCalled();
});

it("cleans up sandbox when wallet validation fails", async () => {
it("propagates error on wallet validation failure without calling deleteSandbox", async () => {
const deleteSpy = vi.spyOn(conway, "deleteSandbox");

vi.spyOn(conway, "exec").mockImplementation(async (command: string) => {
if (command.includes("automaton --init")) {
if (command.includes("--init")) {
return { stdout: `Wallet: ${zeroAddress}`, stderr: "", exitCode: 0 };
}
return { stdout: "ok", stderr: "", exitCode: 0 };
Expand All @@ -174,7 +175,8 @@ describe("spawnChild", () => {
await expect(spawnChild(conway, identity, db, genesis))
.rejects.toThrow("Child wallet address invalid");

expect(deleteSpy).toHaveBeenCalledWith("new-sandbox-id");
// Sandbox deletion is disabled — should not attempt cleanup
expect(deleteSpy).not.toHaveBeenCalled();
});

it("does not mask original error if deleteSandbox also throws", async () => {
Expand Down Expand Up @@ -218,7 +220,7 @@ describe("SandboxCleanup", () => {
vi.restoreAllMocks();
});

it("does not transition to cleaned_up when sandbox deletion fails", async () => {
it("transitions to cleaned_up even though sandbox deletion is disabled", async () => {
// Create a child and transition to stopped
lifecycle.initChild("child-1", "test-child", "sandbox-1", "test prompt");
lifecycle.transition("child-1", "sandbox_created", "created");
Expand All @@ -229,16 +231,12 @@ describe("SandboxCleanup", () => {
lifecycle.transition("child-1", "healthy", "healthy");
lifecycle.transition("child-1", "stopped", "stopped");

// Make deleteSandbox fail
vi.spyOn(conway, "deleteSandbox").mockRejectedValue(new Error("API unavailable"));

const cleanup = new SandboxCleanup(conway, lifecycle, db.raw);
await cleanup.cleanup("child-1");

await expect(cleanup.cleanup("child-1")).rejects.toThrow("API unavailable");

// Child should still be in "stopped" state, NOT "cleaned_up"
// Sandbox deletion is disabled, but cleanup still transitions state
const state = lifecycle.getCurrentState("child-1");
expect(state).toBe("stopped");
expect(state).toBe("cleaned_up");
});

it("transitions to cleaned_up when sandbox deletion succeeds", async () => {
Expand Down
9 changes: 4 additions & 5 deletions src/__tests__/tools-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,23 +444,22 @@ describe("delete_sandbox self-preservation", () => {
db.close();
});

it("blocks deleting own sandbox", async () => {
it("reports sandbox deletion is disabled for own sandbox", async () => {
const deleteTool = tools.find((t) => t.name === "delete_sandbox")!;
const result = await deleteTool.execute(
{ sandbox_id: ctx.identity.sandboxId },
ctx,
);
expect(result).toContain("Blocked");
expect(result).toContain("Self-preservation");
expect(result).toContain("disabled");
});

it("allows deleting other sandboxes", async () => {
it("reports sandbox deletion is disabled for other sandboxes", async () => {
const deleteTool = tools.find((t) => t.name === "delete_sandbox")!;
const result = await deleteTool.execute(
{ sandbox_id: "different-sandbox-id" },
ctx,
);
expect(result).toContain("deleted");
expect(result).toContain("disabled");
});
});

Expand Down
60 changes: 59 additions & 1 deletion src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,65 @@ export async function runAgentLoop(
name: child.name,
sandboxId: child.sandboxId,
};
} catch (sandboxError) {
} catch (sandboxError: any) {
// If the error is a 402 (insufficient credits), attempt topup and retry once
const is402 = sandboxError?.status === 402 ||
sandboxError?.message?.includes("INSUFFICIENT_CREDITS");

if (is402) {
const SANDBOX_TOPUP_COOLDOWN_MS = 60_000;
const lastAttempt = db.getKV("last_sandbox_topup_attempt");
const cooldownExpired = !lastAttempt ||
Date.now() - new Date(lastAttempt).getTime() >= SANDBOX_TOPUP_COOLDOWN_MS;

if (cooldownExpired) {
db.setKV("last_sandbox_topup_attempt", new Date().toISOString());
try {
const { topupForSandbox } = await import("../conway/topup.js");
const topupResult = await topupForSandbox({
apiUrl: config.conwayApiUrl,
account: identity.account,
error: sandboxError,
});

if (topupResult?.success) {
logger.info(`Sandbox topup succeeded ($${topupResult.amountUsd}), retrying spawn`, {
taskId: task.id,
});
// Retry spawn once after successful topup
try {
const { generateGenesisConfig: genGenesis } = await import("../replication/genesis.js");
const { spawnChild: retrySpawn } = await import("../replication/spawn.js");
const { ChildLifecycle: RetryLifecycle } = await import("../replication/lifecycle.js");

const retryRole = task.agentRole ?? "generalist";
const retryGenesis = genGenesis(identity, config, {
name: `worker-${retryRole}-${Date.now().toString(36)}`,
specialization: `${retryRole}: ${task.title}`,
});
const retryLifecycle = new RetryLifecycle(db.raw);
const child = await retrySpawn(conway, identity, db, retryGenesis, retryLifecycle);
return {
address: child.address,
name: child.name,
sandboxId: child.sandboxId,
};
} catch (retryError) {
logger.warn("Spawn retry after topup failed", {
taskId: task.id,
error: retryError instanceof Error ? retryError.message : String(retryError),
});
}
}
} catch (topupError) {
logger.warn("Sandbox topup attempt failed", {
taskId: task.id,
error: topupError instanceof Error ? topupError.message : String(topupError),
});
}
}
}

// Conway sandbox unavailable — fall back to local worker
logger.info("Conway sandbox unavailable, spawning local worker", {
taskId: task.id,
Expand Down
Loading
Loading