From 72cdd1335a3e9ae2951daee26385d3ac1a34c85b Mon Sep 17 00:00:00 2001 From: Daniel Hong Date: Fri, 27 Feb 2026 19:30:10 +0900 Subject: [PATCH 01/10] fix(replication): self-replication tools target child sandbox, not parent All replication tools (start_child, check_child_status, verify_child_constitution) were running commands on the parent's sandbox instead of the child's. Fixed by using createScopedClient(child.sandboxId) to route exec/writeFile to the correct sandbox. Also fixed spawn to clone from GitHub (matching README) instead of installing a non-existent npm package, and added proper error handling to start_child with process verification. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/__tests__/mocks.ts | 18 ++++++++++++ src/__tests__/replication.test.ts | 8 +++--- src/agent/tools.ts | 47 +++++++++++++++++++++++++------ src/replication/spawn.ts | 26 ++++++++++------- 4 files changed, 76 insertions(+), 23 deletions(-) diff --git a/src/__tests__/mocks.ts b/src/__tests__/mocks.ts index 6e588560..9cd52ee3 100644 --- a/src/__tests__/mocks.ts +++ b/src/__tests__/mocks.ts @@ -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 }> { + return { automaton: {} }; + } + + createScopedClient(_targetSandboxId: string): ConwayClient { + // Return self so spies on exec/writeFile propagate to scoped clients + return this; + } } // ─── Mock Social Client ───────────────────────────────────────── diff --git a/src/__tests__/replication.test.ts b/src/__tests__/replication.test.ts index da565430..0f33ae1d 100644 --- a/src/__tests__/replication.test.ts +++ b/src/__tests__/replication.test.ts @@ -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 }; @@ -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 }; @@ -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 }; @@ -165,7 +165,7 @@ describe("spawnChild", () => { 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 }; diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 2c309ad4..86ba23f9 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -1615,12 +1615,17 @@ Model: ${ctx.inference.getDefaultModel()} required: ["child_id"], }, execute: async (args, ctx) => { + const child = ctx.db.getChildById(args.child_id as string); + if (!child) return `Child ${args.child_id} not found.`; + const { ChildLifecycle } = await import("../replication/lifecycle.js"); const { ChildHealthMonitor } = await import("../replication/health.js"); const lifecycle = new ChildLifecycle(ctx.db.raw); + // Use a scoped client targeting the CHILD's sandbox for health checks + const childConway = ctx.conway.createScopedClient(child.sandboxId); const monitor = new ChildHealthMonitor( ctx.db.raw, - ctx.conway, + childConway, lifecycle, ); const result = await monitor.checkHealth(args.child_id as string); @@ -1649,14 +1654,36 @@ Model: ${ctx.inference.getDefaultModel()} lifecycle.transition(child.id, "starting", "start requested by parent"); - // Start the child process - await ctx.conway.exec( - "automaton --init && automaton --provision && systemctl start automaton 2>/dev/null || automaton --run &", - 60_000, - ); + // Create a scoped client targeting the CHILD's sandbox + const childConway = ctx.conway.createScopedClient(child.sandboxId); + + try { + // Start the child process with nohup so it survives exec session end + await childConway.exec( + "nohup node /root/automaton/dist/index.js --run > /root/.automaton/agent.log 2>&1 &", + 30_000, + ); - lifecycle.transition(child.id, "healthy", "started successfully"); - return `Child ${child.name} started and healthy.`; + // Brief pause then verify the process is actually running + const check = await childConway.exec( + "sleep 2 && pgrep -f 'index.js --run' > /dev/null && echo running || echo stopped", + 15_000, + ); + + if (check.stdout.trim() === "running") { + lifecycle.transition(child.id, "healthy", "started successfully"); + return `Child ${child.name} started and healthy.`; + } else { + lifecycle.transition(child.id, "failed", "process did not start"); + return `Child ${child.name} failed to start — process exited immediately. Check /root/.automaton/agent.log`; + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + try { + lifecycle.transition(child.id, "failed", `start failed: ${msg}`); + } catch { /* may already be in terminal state */ } + return `Failed to start child ${child.name}: ${msg}`; + } }, }, { @@ -1713,8 +1740,10 @@ Model: ${ctx.inference.getDefaultModel()} const { verifyConstitution } = await import("../replication/constitution.js"); + // Use a scoped client targeting the CHILD's sandbox + const childConway = ctx.conway.createScopedClient(child.sandboxId); const result = await verifyConstitution( - ctx.conway, + childConway, child.sandboxId, ctx.db.raw, ); diff --git a/src/replication/spawn.ts b/src/replication/spawn.ts index 1a46e330..a6829a42 100644 --- a/src/replication/spawn.ts +++ b/src/replication/spawn.ts @@ -103,7 +103,10 @@ export async function spawnChild( // Install runtime (on the CHILD sandbox) await childConway.exec("apt-get update -qq && apt-get install -y -qq nodejs npm git curl", 120_000); - await childConway.exec("npm install -g @conway/automaton@latest 2>/dev/null || true", 60_000); + await childConway.exec( + "git clone https://github.com/Conway-Research/automaton.git /root/automaton && cd /root/automaton && npm install && npm run build", + 180_000, + ); // Write genesis configuration (on the CHILD sandbox) await childConway.exec("mkdir -p /root/.automaton", 10_000); @@ -131,7 +134,7 @@ export async function spawnChild( lifecycle.transition(childId, "runtime_ready", "runtime installed"); // Initialize child wallet (on the CHILD sandbox) - const initResult = await childConway.exec("automaton --init 2>&1", 60_000); + const initResult = await childConway.exec("node /root/automaton/dist/index.js --init 2>&1", 60_000); const walletMatch = (initResult.stdout || "").match(/0x[a-fA-F0-9]{40}/); const childWallet = walletMatch ? walletMatch[0] : ""; @@ -232,15 +235,18 @@ async function spawnChildLegacy( }); sandboxId = sandbox.id; - await conway.exec( + // Create a scoped client so all exec/writeFile calls target the CHILD sandbox + const childConway = conway.createScopedClient(sandbox.id); + + await childConway.exec( "apt-get update -qq && apt-get install -y -qq nodejs npm git curl", 120_000, ); - await conway.exec( - "npm install -g @conway/automaton@latest 2>/dev/null || true", - 60_000, + await childConway.exec( + "git clone https://github.com/Conway-Research/automaton.git /root/automaton && cd /root/automaton && npm install && npm run build", + 180_000, ); - await conway.exec("mkdir -p /root/.automaton", 10_000); + await childConway.exec("mkdir -p /root/.automaton", 10_000); const genesisJson = JSON.stringify( { @@ -253,15 +259,15 @@ async function spawnChildLegacy( null, 2, ); - await conway.writeFile("/root/.automaton/genesis.json", genesisJson); + await childConway.writeFile("/root/.automaton/genesis.json", genesisJson); try { - await propagateConstitution(conway, sandbox.id, db.raw); + await propagateConstitution(childConway, sandbox.id, db.raw); } catch { // Constitution file not found } - const initResult = await conway.exec("automaton --init 2>&1", 60_000); + const initResult = await childConway.exec("node /root/automaton/dist/index.js --init 2>&1", 60_000); const walletMatch = (initResult.stdout || "").match(/0x[a-fA-F0-9]{40}/); const childWallet = walletMatch ? walletMatch[0] : ""; From 146da515cbf4cc7854e5bf7f6e2b65cb29d7ecec Mon Sep 17 00:00:00 2001 From: Daniel Hong Date: Fri, 27 Feb 2026 20:26:30 +0900 Subject: [PATCH 02/10] fix(self-mod): git-version source edits in repo root and add recovery tools editFile() was committing snapshots to ~/.automaton/ (state dir) instead of the repo root where source code lives. Now uses gitCommit() targeting process.cwd(). Also triggers `npm run build` after editing .ts/.js/.tsx/.jsx files so changes take effect at runtime. Adds revert_last_edit (git revert HEAD) and reset_to_upstream (git reset --hard origin/main) tools so the agent can recover from bad self-modifications. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/agent/tools.ts | 94 +++++++++++++++++++++++++++++++++++++++++++- src/self-mod/code.ts | 21 +++++++--- src/types.ts | 4 +- 3 files changed, 111 insertions(+), 8 deletions(-) diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 86ba23f9..04553723 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -418,7 +418,99 @@ export function createBuiltinTools(sandboxId: string): AutomatonTool[] { return result.error || "Unknown error during file edit"; } - return `File edited: ${filePath} (audited + git-committed)`; + const msg = `File edited: ${filePath} (audited + git-committed)`; + return result.error ? `${msg}\nWarning: ${result.error}` : msg; + }, + }, + { + name: "revert_last_edit", + description: + "Revert the last self-modification. Uses git to undo the most recent code change and rebuild.", + category: "self_mod", + riskLevel: "caution", + parameters: { type: "object", properties: {} }, + execute: async (_args, ctx) => { + const repoRoot = process.cwd(); + + // Show what we're reverting + const lastCommit = await ctx.conway.exec( + `cd '${repoRoot}' && git log -1 --oneline`, + 10_000, + ); + + // Revert + const result = await ctx.conway.exec( + `cd '${repoRoot}' && git revert HEAD --no-edit`, + 30_000, + ); + if (result.exitCode !== 0) { + return `Revert failed: ${result.stderr}`; + } + + // Rebuild + const build = await ctx.conway.exec( + `cd '${repoRoot}' && npm run build`, + 60_000, + ); + + // Audit log + const { logModification } = await import("../self-mod/audit-log.js"); + logModification(ctx.db, "code_revert", `Reverted: ${lastCommit.stdout.trim()}`, { + reversible: true, + }); + + return `Reverted: ${lastCommit.stdout.trim()}. ${build.exitCode === 0 ? "Rebuild succeeded." : "Rebuild failed: " + build.stderr}`; + }, + }, + { + name: "reset_to_upstream", + description: + "Reset your codebase to the official upstream release. Use when self-modifications have broken things beyond repair.", + category: "self_mod", + riskLevel: "dangerous", + parameters: { type: "object", properties: {} }, + execute: async (_args, ctx) => { + const repoRoot = process.cwd(); + + // Fetch latest upstream + const fetch = await ctx.conway.exec( + `cd '${repoRoot}' && git fetch origin main`, + 30_000, + ); + if (fetch.exitCode !== 0) { + return `Failed to fetch upstream: ${fetch.stderr}`; + } + + // Record what we're about to lose + const localCommits = await ctx.conway.exec( + `cd '${repoRoot}' && git log origin/main..HEAD --oneline`, + 10_000, + ); + + // Hard reset + const reset = await ctx.conway.exec( + `cd '${repoRoot}' && git reset --hard origin/main`, + 30_000, + ); + if (reset.exitCode !== 0) { + return `Reset failed: ${reset.stderr}`; + } + + // Reinstall + rebuild + const build = await ctx.conway.exec( + `cd '${repoRoot}' && npm install && npm run build`, + 120_000, + ); + + // Audit log + const { logModification } = await import("../self-mod/audit-log.js"); + logModification(ctx.db, "upstream_reset", "Reset to upstream origin/main", { + diff: localCommits.stdout.trim() || "(no local commits)", + reversible: false, + }); + + const discarded = localCommits.stdout.trim(); + return `Reset to upstream. ${discarded ? "Discarded local commits:\n" + discarded : "No local commits lost."} ${build.exitCode === 0 ? "Rebuild succeeded." : "Rebuild failed: " + build.stderr}`; }, }, { diff --git a/src/self-mod/code.ts b/src/self-mod/code.ts index 3a2d43c3..7a11b258 100644 --- a/src/self-mod/code.ts +++ b/src/self-mod/code.ts @@ -265,10 +265,10 @@ export async function editFile( oldContent = "(new file)"; } - // 6. Pre-modification git snapshot + // 6. Pre-modification git snapshot (in repo root, not ~/.automaton/) try { - const { commitStateChange } = await import("../git/state-versioning.js"); - await commitStateChange(conway, `pre-modify: ${reason}`, "snapshot"); + const { gitCommit } = await import("../git/tools.js"); + await gitCommit(conway, process.cwd(), `pre-modify: ${reason}`); } catch { // Git not available -- proceed without snapshot } @@ -292,14 +292,23 @@ export async function editFile( reversible: true, }); - // 9. Post-modification git commit + // 9. Post-modification git commit (in repo root) try { - const { commitStateChange } = await import("../git/state-versioning.js"); - await commitStateChange(conway, reason, "self-mod"); + const { gitCommit } = await import("../git/tools.js"); + await gitCommit(conway, process.cwd(), `self-mod: ${reason}`); } catch { // Git not available -- proceed without commit } + // 10. Rebuild if source file was edited + if (/\.(ts|js|tsx|jsx)$/.test(filePath)) { + try { + await conway.exec("npm run build", 60_000); + } catch { + return { success: true, error: "File edited but rebuild failed. Run 'npm run build' manually." }; + } + } + return { success: true }; } diff --git a/src/types.ts b/src/types.ts index dbe86d5d..9b471272 100644 --- a/src/types.ts +++ b/src/types.ts @@ -255,6 +255,7 @@ export interface ModificationEntry { export type ModificationType = | "code_edit" + | "code_revert" | "tool_install" | "mcp_install" | "config_change" @@ -267,7 +268,8 @@ export type ModificationType = | "soul_update" | "registry_update" | "child_spawn" - | "upstream_pull"; + | "upstream_pull" + | "upstream_reset"; // ─── Injection Defense ─────────────────────────────────────────── From e1cc3dc4fd5524dba1b23c10c36cd65a0fac0765 Mon Sep 17 00:00:00 2001 From: Daniel Hong Date: Fri, 27 Feb 2026 20:55:54 +0900 Subject: [PATCH 03/10] fix(topup): auto-topup credits on 402 before falling back to local worker When sandbox creation fails with 402 INSUFFICIENT_CREDITS, attempt to top up credits via x402 and retry once before falling back to a local worker. Uses a 60s cooldown to prevent hammering the topup endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/agent/loop.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++- src/conway/topup.ts | 57 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/src/agent/loop.ts b/src/agent/loop.ts index c35e4349..d9ecfd20 100644 --- a/src/agent/loop.ts +++ b/src/agent/loop.ts @@ -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, diff --git a/src/conway/topup.ts b/src/conway/topup.ts index feaaf066..8c866352 100644 --- a/src/conway/topup.ts +++ b/src/conway/topup.ts @@ -70,6 +70,63 @@ export async function topupCredits( }; } +/** + * Attempt a credit topup in response to a 402 sandbox creation error. + * + * Parses the error response to determine the deficit, picks the smallest + * tier that covers it, checks USDC balance, and calls topupCredits(). + * Returns null if the error isn't a 402 or topup can't proceed. + */ +export async function topupForSandbox(params: { + apiUrl: string; + account: PrivateKeyAccount; + error: Error & { status?: number; responseText?: string }; +}): Promise { + const { apiUrl, account, error } = params; + + if (error.status !== 402) return null; + + // Parse the 402 response body for credit details + let requiredCents: number | undefined; + let currentCents: number | undefined; + try { + const body = JSON.parse(error.responseText || "{}"); + requiredCents = body.details?.required_cents; + currentCents = body.details?.current_balance_cents; + } catch { + // If we can't parse the body, check for INSUFFICIENT_CREDITS in message + if (!error.message?.includes("INSUFFICIENT_CREDITS")) return null; + } + + // Calculate deficit in cents; default to minimum tier if details missing + const deficitCents = (requiredCents != null && currentCents != null) + ? requiredCents - currentCents + : TOPUP_TIERS[0] * 100; + + // Pick smallest tier that covers the deficit (tier is in USD, deficit in cents) + const selectedTier = TOPUP_TIERS.find((tier) => tier * 100 >= deficitCents) + ?? TOPUP_TIERS[0]; + + // Check USDC balance before attempting payment + let usdcBalance: number; + try { + usdcBalance = await getUsdcBalance(account.address); + } catch (err: any) { + logger.warn(`Failed to check USDC balance for sandbox topup: ${err.message}`); + return null; + } + + if (usdcBalance < selectedTier) { + logger.info( + `Sandbox topup skipped: USDC $${usdcBalance.toFixed(2)} < tier $${selectedTier}`, + ); + return null; + } + + logger.info(`Sandbox topup: deficit=${deficitCents}c, buying $${selectedTier} tier`); + return topupCredits(apiUrl, account, selectedTier); +} + /** * Bootstrap topup: buy the minimum tier ($5) on startup so the agent * can run inference. The agent decides larger topups itself via the From a81e29771532517686c0d9f7e3da4a83df95dfd5 Mon Sep 17 00:00:00 2001 From: Daniel Hong Date: Fri, 27 Feb 2026 22:37:02 +0900 Subject: [PATCH 04/10] fix(spawn): align sandbox specs with valid Conway pricing tiers The hardcoded diskGb=5 with 1024MB memory doesn't match any pricing tier (Medium requires 10GB disk). Add a SANDBOX_TIERS lookup so the disk and vCPU are always consistent with the requested memory size. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/replication/spawn.ts | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/replication/spawn.ts b/src/replication/spawn.ts index a6829a42..79349c52 100644 --- a/src/replication/spawn.ts +++ b/src/replication/spawn.ts @@ -18,6 +18,20 @@ import type { ChildLifecycle } from "./lifecycle.js"; import { ulid } from "ulid"; import { propagateConstitution } from "./constitution.js"; +/** Valid Conway sandbox pricing tiers. */ +const SANDBOX_TIERS = [ + { memoryMb: 512, vcpu: 1, diskGb: 5 }, + { memoryMb: 1024, vcpu: 1, diskGb: 10 }, + { memoryMb: 2048, vcpu: 2, diskGb: 20 }, + { memoryMb: 4096, vcpu: 2, diskGb: 40 }, + { memoryMb: 8192, vcpu: 4, diskGb: 80 }, +]; + +/** Find the smallest valid tier that has at least the requested memory. */ +function selectSandboxTier(requestedMemoryMb: number) { + return SANDBOX_TIERS.find((t) => t.memoryMb >= requestedMemoryMb) ?? SANDBOX_TIERS[0]; +} + /** * Validate that an address is a well-formed, non-zero Ethereum wallet address. */ @@ -73,15 +87,17 @@ export async function spawnChild( // is still running remotely, before creating a new one. reusedSandbox = await findReusableSandbox(conway, db); + const tier = selectSandboxTier(childMemoryMb); + let sandbox: { id: string }; if (reusedSandbox) { sandbox = reusedSandbox; } else { sandbox = await conway.createSandbox({ name: `automaton-child-${genesis.name.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`, - vcpu: 1, - memoryMb: childMemoryMb, - diskGb: 5, + vcpu: tier.vcpu, + memoryMb: tier.memoryMb, + diskGb: tier.diskGb, }); } sandboxId = sandbox.id; @@ -226,12 +242,14 @@ async function spawnChildLegacy( // Get child sandbox memory from config (default 1024MB) const childMemoryMb = (db as any).config?.childSandboxMemoryMb ?? 1024; + const legacyTier = selectSandboxTier(childMemoryMb); + try { const sandbox = await conway.createSandbox({ name: `automaton-child-${genesis.name.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`, - vcpu: 1, - memoryMb: childMemoryMb, - diskGb: 5, + vcpu: legacyTier.vcpu, + memoryMb: legacyTier.memoryMb, + diskGb: legacyTier.diskGb, }); sandboxId = sandbox.id; From bbbf4f0448e3b2cfc197422f3a054cd3f5e4d929 Mon Sep 17 00:00:00 2001 From: Daniel Hong Date: Fri, 27 Feb 2026 22:45:54 +0900 Subject: [PATCH 05/10] fix(topup): add auto-topup on 402 to spawn_child tool The spawn_child tool calls spawnChild() directly without the topup-and-retry logic that the orchestrator's spawnAgent has. Add the same pattern: catch 402, topup credits, retry once. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/agent/tools.ts | 55 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 04553723..4212dc85 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -1573,13 +1573,54 @@ Model: ${ctx.inference.getDefaultModel()} }); const lifecycle = new ChildLifecycle(ctx.db.raw); - const child = await spawnChild( - ctx.conway, - ctx.identity, - ctx.db, - genesis, - lifecycle, - ); + + let child; + try { + child = await spawnChild( + ctx.conway, + ctx.identity, + ctx.db, + genesis, + lifecycle, + ); + } catch (err: any) { + // Auto-topup on 402 insufficient credits and retry once + const is402 = err?.status === 402 || + err?.message?.includes("INSUFFICIENT_CREDITS"); + if (is402) { + const COOLDOWN_MS = 60_000; + const last = ctx.db.getKV("last_sandbox_topup_attempt"); + const cooldownOk = !last || + Date.now() - new Date(last).getTime() >= COOLDOWN_MS; + + if (cooldownOk) { + ctx.db.setKV("last_sandbox_topup_attempt", new Date().toISOString()); + const { topupForSandbox } = await import("../conway/topup.js"); + const topup = await topupForSandbox({ + apiUrl: ctx.config.conwayApiUrl, + account: ctx.identity.account, + error: err, + }); + if (topup?.success) { + const retryLifecycle = new ChildLifecycle(ctx.db.raw); + const retryGenesis = generateGenesisConfig(ctx.identity, ctx.config, { + name: args.name as string, + specialization: args.specialization as string | undefined, + message: args.message as string | undefined, + }); + child = await spawnChild( + ctx.conway, + ctx.identity, + ctx.db, + retryGenesis, + retryLifecycle, + ); + } + } + } + if (!child) throw err; + } + return `Child spawned: ${child.name} in sandbox ${child.sandboxId} (status: ${child.status})`; }, }, From 4ebb686fe3844b2b5ac4be9cfe7b55211da2a95c Mon Sep 17 00:00:00 2001 From: Daniel Hong Date: Fri, 27 Feb 2026 22:51:26 +0900 Subject: [PATCH 06/10] fix(conway): remove sandbox deletion (endpoint disabled by backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conway API no longer supports DELETE /v1/sandboxes — sandboxes are prepaid and non-refundable. Make deleteSandbox a no-op, remove cleanup calls in spawn error paths, update the delete_sandbox tool to inform the agent, and let SandboxCleanup transition to cleaned_up directly. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/__tests__/lifecycle.test.ts | 2 -- src/__tests__/replication.test.ts | 22 ++++++++++------------ src/__tests__/tools-security.test.ts | 9 ++++----- src/agent/tools.ts | 11 +++-------- src/conway/client.ts | 5 +++-- src/replication/cleanup.ts | 18 ++++++------------ src/replication/spawn.ts | 16 +++------------- 7 files changed, 29 insertions(+), 54 deletions(-) diff --git a/src/__tests__/lifecycle.test.ts b/src/__tests__/lifecycle.test.ts index c6f1f97c..792e62cd 100644 --- a/src/__tests__/lifecycle.test.ts +++ b/src/__tests__/lifecycle.test.ts @@ -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 () => { diff --git a/src/__tests__/replication.test.ts b/src/__tests__/replication.test.ts index 0f33ae1d..66dbcb86 100644 --- a/src/__tests__/replication.test.ts +++ b/src/__tests__/replication.test.ts @@ -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 @@ -158,10 +158,11 @@ 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) => { @@ -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 () => { @@ -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"); @@ -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 () => { diff --git a/src/__tests__/tools-security.test.ts b/src/__tests__/tools-security.test.ts index c4d8680e..17539030 100644 --- a/src/__tests__/tools-security.test.ts +++ b/src/__tests__/tools-security.test.ts @@ -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"); }); }); diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 4212dc85..3b725b4c 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -331,7 +331,7 @@ export function createBuiltinTools(sandboxId: string): AutomatonTool[] { }, { name: "delete_sandbox", - description: "Delete a sandbox. Cannot delete your own sandbox.", + description: "Delete a sandbox. Note: sandbox deletion is currently disabled by the Conway API.", category: "conway", riskLevel: "dangerous", parameters: { @@ -344,13 +344,8 @@ export function createBuiltinTools(sandboxId: string): AutomatonTool[] { }, required: ["sandbox_id"], }, - execute: async (args, ctx) => { - const targetId = args.sandbox_id as string; - if (targetId === ctx.identity.sandboxId) { - return "Blocked: Cannot delete your own sandbox. Self-preservation overrides this request."; - } - await ctx.conway.deleteSandbox(targetId); - return `Sandbox ${targetId} deleted`; + execute: async () => { + return "Sandbox deletion is disabled. Sandboxes are prepaid and non-refundable."; }, }, { diff --git a/src/conway/client.ts b/src/conway/client.ts index 4f08fc1e..bc791c93 100644 --- a/src/conway/client.ts +++ b/src/conway/client.ts @@ -270,8 +270,9 @@ export function createConwayClient(options: ConwayClientOptions): ConwayClient { }; }; - const deleteSandbox = async (targetId: string): Promise => { - await request("DELETE", `/v1/sandboxes/${targetId}`); + const deleteSandbox = async (_targetId: string): Promise => { + // Conway API no longer supports sandbox deletion. + // Sandboxes are prepaid and non-refundable — this is a no-op. }; const listSandboxes = async (): Promise => { diff --git a/src/replication/cleanup.ts b/src/replication/cleanup.ts index 3b778fd0..686a4e4c 100644 --- a/src/replication/cleanup.ts +++ b/src/replication/cleanup.ts @@ -33,18 +33,12 @@ export class SandboxCleanup { .prepare("SELECT sandbox_id FROM children WHERE id = ?") .get(childId) as { sandbox_id: string } | undefined; - if (childRow?.sandbox_id) { - try { - await this.conway.deleteSandbox(childRow.sandbox_id); - } catch (error) { - logger.error(`Failed to destroy sandbox for ${childId}`, error instanceof Error ? error : undefined); - // Do not transition to cleaned_up if sandbox deletion failed; - // the sandbox is still running and consuming resources. - throw error; - } - } - - this.lifecycle.transition(childId, "cleaned_up", "sandbox destroyed"); + // Sandbox deletion is disabled by the Conway API (prepaid, non-refundable). + // Transition to cleaned_up so the child slot is freed for reuse. + const sandboxNote = childRow?.sandbox_id + ? `sandbox ${childRow.sandbox_id} released (deletion disabled)` + : "no sandbox to clean up"; + this.lifecycle.transition(childId, "cleaned_up", sandboxNote); } /** diff --git a/src/replication/spawn.ts b/src/replication/spawn.ts index 79349c52..516e4573 100644 --- a/src/replication/spawn.ts +++ b/src/replication/spawn.ts @@ -201,16 +201,8 @@ export async function spawnChild( return child; } catch (error) { - // Cleanup: only delete sandbox if we CREATED it (not reused) - if (sandboxId && !reusedSandbox) { - try { - await conway.deleteSandbox(sandboxId); - } catch (cleanupErr) { - // Log cleanup failure instead of silently swallowing - const msg = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr); - console.warn(`[spawn] Failed to cleanup sandbox ${sandboxId}: ${msg}`); - } - } + // Note: sandbox deletion is disabled by the Conway API (prepaid, non-refundable). + // Failed sandboxes are left running and may be reused by findReusableSandbox(). // Transition to failed if lifecycle has been initialized try { @@ -317,9 +309,7 @@ async function spawnChildLegacy( return child; } catch (error) { - if (sandboxId) { - await conway.deleteSandbox(sandboxId).catch(() => {}); - } + // Sandbox deletion disabled — failed sandboxes left for potential reuse. throw error; } } From 21ab86257866b24b39c1938fb9a644c1b4347bbf Mon Sep 17 00:00:00 2001 From: Daniel Hong Date: Sat, 28 Feb 2026 01:28:25 +0900 Subject: [PATCH 07/10] chore: bump version to 0.2.1 Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- src/__tests__/mocks.ts | 2 +- src/config.ts | 2 +- src/index.ts | 2 +- src/types.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index be047d27..406bcba0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/__tests__/mocks.ts b/src/__tests__/mocks.ts index 9cd52ee3..dc4db55f 100644 --- a/src/__tests__/mocks.ts +++ b/src/__tests__/mocks.ts @@ -346,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, diff --git a/src/config.ts b/src/config.ts index 7e825e8e..e3c0eabe 100644 --- a/src/config.ts +++ b/src/config.ts @@ -144,7 +144,7 @@ export function createConfig(params: { dbPath: DEFAULT_CONFIG.dbPath || "~/.automaton/state.db", logLevel: (DEFAULT_CONFIG.logLevel as AutomatonConfig["logLevel"]) || "info", walletAddress: params.walletAddress, - version: DEFAULT_CONFIG.version || "0.2.0", + version: DEFAULT_CONFIG.version || "0.2.1", skillsDir: DEFAULT_CONFIG.skillsDir || "~/.automaton/skills", maxChildren: DEFAULT_CONFIG.maxChildren || 3, parentAddress: params.parentAddress, diff --git a/src/index.ts b/src/index.ts index 6e4c3385..9f47e485 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,7 +35,7 @@ import { randomUUID } from "crypto"; import { keccak256, toHex } from "viem"; const logger = createLogger("main"); -const VERSION = "0.2.0"; +const VERSION = "0.2.1"; async function main(): Promise { const args = process.argv.slice(2); diff --git a/src/types.ts b/src/types.ts index 9b471272..998c8ea1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -71,7 +71,7 @@ export const DEFAULT_CONFIG: Partial = { heartbeatConfigPath: "~/.automaton/heartbeat.yml", dbPath: "~/.automaton/state.db", logLevel: "info", - version: "0.2.0", + version: "0.2.1", skillsDir: "~/.automaton/skills", maxChildren: 3, maxTurnsPerCycle: 25, From 63e4f02247fa10defc147af976d64dcfa07c8c10 Mon Sep 17 00:00:00 2001 From: Daniel Hong <3947578+unifiedh@users.noreply.github.com> Date: Sat, 28 Feb 2026 02:06:30 +0900 Subject: [PATCH 08/10] Update src/replication/spawn.ts Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/replication/spawn.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/replication/spawn.ts b/src/replication/spawn.ts index 516e4573..aaead782 100644 --- a/src/replication/spawn.ts +++ b/src/replication/spawn.ts @@ -29,7 +29,7 @@ const SANDBOX_TIERS = [ /** Find the smallest valid tier that has at least the requested memory. */ function selectSandboxTier(requestedMemoryMb: number) { - return SANDBOX_TIERS.find((t) => t.memoryMb >= requestedMemoryMb) ?? SANDBOX_TIERS[0]; + return SANDBOX_TIERS.find((t) => t.memoryMb >= requestedMemoryMb) ?? SANDBOX_TIERS[SANDBOX_TIERS.length - 1]; } /** From 43258d1624751400809bb7ae48496e336b7e3584 Mon Sep 17 00:00:00 2001 From: Daniel Hong <3947578+unifiedh@users.noreply.github.com> Date: Sat, 28 Feb 2026 02:06:38 +0900 Subject: [PATCH 09/10] Update src/conway/topup.ts Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/conway/topup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/conway/topup.ts b/src/conway/topup.ts index 8c866352..8c6d93bc 100644 --- a/src/conway/topup.ts +++ b/src/conway/topup.ts @@ -84,7 +84,7 @@ export async function topupForSandbox(params: { }): Promise { const { apiUrl, account, error } = params; - if (error.status !== 402) return null; + if (error.status !== 402 && !error.message?.includes("INSUFFICIENT_CREDITS")) return null; // Parse the 402 response body for credit details let requiredCents: number | undefined; From 5f212a6857362bdf1070e325e405a1db85ec1113 Mon Sep 17 00:00:00 2001 From: Daniel Hong <3947578+unifiedh@users.noreply.github.com> Date: Sat, 28 Feb 2026 02:11:35 +0900 Subject: [PATCH 10/10] Update src/conway/topup.ts Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/conway/topup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/conway/topup.ts b/src/conway/topup.ts index 8c6d93bc..9de4681d 100644 --- a/src/conway/topup.ts +++ b/src/conway/topup.ts @@ -105,7 +105,7 @@ export async function topupForSandbox(params: { // Pick smallest tier that covers the deficit (tier is in USD, deficit in cents) const selectedTier = TOPUP_TIERS.find((tier) => tier * 100 >= deficitCents) - ?? TOPUP_TIERS[0]; + ?? TOPUP_TIERS[TOPUP_TIERS.length - 1]; // Check USDC balance before attempting payment let usdcBalance: number;