diff --git a/packages/amico-run/src/remote_executor.ts b/packages/amico-run/src/remote_executor.ts index 25aa3343..a39fc274 100644 --- a/packages/amico-run/src/remote_executor.ts +++ b/packages/amico-run/src/remote_executor.ts @@ -68,7 +68,13 @@ export class RemoteExecutor implements Executor { this.pollMs = opts.pollMs ?? 2000; this.warmingBudgetMs = opts.warmingBudgetMs ?? 15 * 60 * 1000; this.lostAfterMs = opts.lostAfterMs ?? 10 * 60 * 1000; - this.maxWallclock = opts.maxWallclock; + // The wall-clock cap is NEVER optional (2026-08-18 GPU-plane pass): the + // cloud bundle has no cooperative stop, so an uncapped run is a billing + // hang by construction. Ladder: explicit > env > generous default (2h — + // typical solves are minutes; hard two-mode problems < 1h; 2h covers the + // legitimate tail without letting a wedged run outlive the day). + const envCap = Number(process.env.AMICODE_REMOTE_MAX_WALLCLOCK_S); + this.maxWallclock = opts.maxWallclock ?? (Number.isFinite(envCap) && envCap > 0 ? envCap : 7200); } async submit(scriptPath: string | undefined, opts: SubmitOpts = {}): Promise { @@ -87,7 +93,7 @@ export class RemoteExecutor implements Executor { // ---- step 2: Δ2 submit — still no run dir; a rejected submit ran nothing ---- const payload: Record = { script: readFileSync(script, "utf8"), filename: basename(script) }; - if (this.maxWallclock !== undefined) payload.max_wallclock = this.maxWallclock; + payload.max_wallclock = this.maxWallclock!; // always set — see the constructor ladder let res: Response; try { res = await fetch(`${cfg.baseUrl}/solves`, { diff --git a/packages/amico-run/test/remote_executor.test.ts b/packages/amico-run/test/remote_executor.test.ts index e8101584..5bdf3e86 100644 --- a/packages/amico-run/test/remote_executor.test.ts +++ b/packages/amico-run/test/remote_executor.test.ts @@ -26,6 +26,34 @@ async function collect(events: AsyncIterable): Promise { return out; } +describe("RemoteExecutor.submit — the wall-clock cap is NEVER optional (GPU-plane anti-hang)", () => { + // 2026-08-18 architecture pass: launch.ts constructed RemoteExecutor() bare, + // so max_wallclock was NEVER sent — an uncapped cloud run on a bundle with + // no cooperative stop bills until the heat death of the instance. The cap + // is now always in the payload: explicit > env > generous default. + it("always sends max_wallclock — the generous default when nothing sets it", async () => { + await withCloud(async (fake) => { + await ex(fake).submit(fakeJulia(tmpRoot(), "solve.jl", "// julia body"), { runsRoot: join(tmpRoot(), "runs") }); + expect(fake.submits[0].body.max_wallclock).toBe(7200); + }); + }); + it("an explicit cap rides the payload verbatim; env override beats the default", async () => { + await withCloud(async (fake) => { + await ex(fake, { maxWallclock: 300 }).submit(fakeJulia(tmpRoot(), "solve.jl", "// julia body"), { runsRoot: join(tmpRoot(), "runs") }); + expect(fake.submits[0].body.max_wallclock).toBe(300); + }); + await withCloud(async (fake) => { + process.env.AMICODE_REMOTE_MAX_WALLCLOCK_S = "1200"; + try { + await ex(fake).submit(fakeJulia(tmpRoot(), "solve.jl", "// julia body"), { runsRoot: join(tmpRoot(), "runs") }); + expect(fake.submits[0].body.max_wallclock).toBe(1200); + } finally { + delete process.env.AMICODE_REMOTE_MAX_WALLCLOCK_S; + } + }); + }); +}); + describe("RemoteExecutor.submit — Δ2 wire shape + local mirror", () => { it("POSTs script CONTENT + filename with the Bearer credential; 202 → conforming mirror run dir", async () => { await withCloud(async (fake) => {