From 7e7ba1866c6ec2a80c100c29ec1249a577ad6a30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Tue, 18 Aug 2026 11:56:44 +0800 Subject: [PATCH 01/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=E8=B6=85=E6=97=B6=E5=90=8E=E4=BF=9D=E7=95=99=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E8=BF=9B=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/eval/harbor/relay_agent.py | 83 ++++++++++-- packages/eval/harbor/test_relay_lifecycle.py | 120 +++++++++++++++++- .../__tests__/lifecycle-boundaries.test.ts | 11 +- 3 files changed, 194 insertions(+), 20 deletions(-) diff --git a/packages/eval/harbor/relay_agent.py b/packages/eval/harbor/relay_agent.py index 903ba96ac4..7d08c4af92 100644 --- a/packages/eval/harbor/relay_agent.py +++ b/packages/eval/harbor/relay_agent.py @@ -160,8 +160,9 @@ async def run(self, instruction: str, environment: Any, context: Any) -> None: # waiting on those processes here — `environment.exec` has already # returned — so tearing them down would not unblock anything; it # would only edit the thing about to be measured, and edit it for - # some subjects and not others. Cancellation still quiesces, because - # there the subject has not stopped and the trial is being abandoned. + # some subjects and not others. Host abort still quiesces, because + # that trial is abandoned. Framework timeout does not: the verifier + # still scores what the subject left. if not await _send( writer, { @@ -190,14 +191,20 @@ async def run(self, instruction: str, environment: Any, context: Any) -> None: # A subject that already exited has nothing left to settle, and # tearing its scope down here would remove what the verifier is # about to score -- the same environment edit this relay stopped - # making on the ordinary path. Only a subject still running is - # brought to a stop. + # making on the ordinary path. A still-running subject is + # stopped so the execution call can return. Host abort then + # quiesces the leftover group; framework timeout does not, + # because the verifier still runs. if terminal_projection is not None: result = terminal_result - else: + elif _host_teardown_requested: result = await _settle_or_destroy( environment, cwd, scope_path, execution, self._teardown_timeout ) + else: + result = await _stop_subject_for_timeout( + environment, cwd, scope_path, execution, self._teardown_timeout + ) if result is not None: await _persist_subject_outputs(environment, result) if ( @@ -568,7 +575,7 @@ async def _settle(environment: Any, cwd: str, scope_path: str, execution: Any) - else: result = None for signal, timeout in (("TERM", 20), ("KILL", 10)): - await _signal(environment, cwd, scope_path, signal) + await _signal_group(environment, cwd, scope_path, signal) try: result = await asyncio.wait_for(asyncio.shield(execution), timeout=timeout) break @@ -584,6 +591,52 @@ async def _settle(environment: Any, cwd: str, scope_path: str, execution: Any) - return result +async def _stop_subject_for_timeout( + environment: Any, + cwd: str, + scope_path: str, + execution: Any, + timeout: float, +) -> Any | None: + # The verifier still scores this trial. Stop only the subject so + # `environment.exec` can return; do not hunt descendants or delete the + # environment. Those leftovers are what the task asked the subject to leave. + if execution.cancelled(): + raise RuntimeError("Maka Eval subject execution was cancelled") + if execution.done(): + return execution.result() + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + result = None + for signal, slice_timeout in (("TERM", 20.0), ("KILL", 10.0)): + remaining = deadline - loop.time() + if remaining <= 0: + break + await _signal_leader(environment, cwd, scope_path, signal) + try: + result = await asyncio.wait_for( + asyncio.shield(execution), + timeout=min(slice_timeout, remaining), + ) + break + except asyncio.CancelledError: + if execution.cancelled(): + raise RuntimeError("Maka Eval subject execution was cancelled") from None + raise + except (TimeoutError, asyncio.TimeoutError): + pass + if result is not None: + return result + if execution.done() and not execution.cancelled(): + return execution.result() + if not execution.done(): + execution.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + remaining = max(0.001, deadline - loop.time()) + await asyncio.wait_for(execution, timeout=remaining) + return None + + async def _settle_or_destroy( environment: Any, cwd: str, @@ -614,7 +667,7 @@ async def _settle_or_destroy( return None -async def _signal(environment: Any, cwd: str, scope_path: str, signal: str) -> None: +async def _signal_group(environment: Any, cwd: str, scope_path: str, signal: str) -> None: command = ( f"pgid=$(cat {shlex.quote(scope_path)} 2>/dev/null) || exit 0; " "case $pgid in ''|0|*[!0-9]*) exit 0;; esac; " @@ -628,11 +681,25 @@ async def _signal(environment: Any, cwd: str, scope_path: str, signal: str) -> N ) +async def _signal_leader(environment: Any, cwd: str, scope_path: str, signal: str) -> None: + command = ( + f"pgid=$(cat {shlex.quote(scope_path)} 2>/dev/null) || exit 0; " + "case $pgid in ''|0|*[!0-9]*) exit 0;; esac; " + f"kill -{signal} -- \"$pgid\"" + ) + with contextlib.suppress(Exception): + await environment.exec( + command, + cwd=cwd, + timeout_sec=5, + ) + + async def _quiesce_scope(environment: Any, cwd: str, scope_path: str) -> None: if not await _scope_active(environment, cwd, scope_path): return for signal, timeout in (("TERM", 10), ("KILL", 5)): - await _signal(environment, cwd, scope_path, signal) + await _signal_group(environment, cwd, scope_path, signal) deadline = asyncio.get_running_loop().time() + timeout while asyncio.get_running_loop().time() < deadline: if not await _scope_active(environment, cwd, scope_path): diff --git a/packages/eval/harbor/test_relay_lifecycle.py b/packages/eval/harbor/test_relay_lifecycle.py index fe1e8d1fd6..fc5c6b7222 100644 --- a/packages/eval/harbor/test_relay_lifecycle.py +++ b/packages/eval/harbor/test_relay_lifecycle.py @@ -159,6 +159,27 @@ async def stop(self, delete=False): self.stopped = delete +class TimeoutScopeEnvironment(SimultaneousEnvironment): + """A still-running subject whose descendants must survive framework timeout.""" + + def __init__(self): + super().__init__() + self.commands = [] + self.stopped = False + + async def stop(self, delete=False): + self.stopped = delete + + async def exec(self, command, cwd=None, timeout_sec=None): + self.commands.append(command) + if _is_teardown(command): + raise AssertionError("framework timeout must not signal the process group") + if _is_leader_stop(command): + self.release.set() + return SimpleNamespace(return_code=0, stdout="", stderr="") + return await super().exec(command, cwd=cwd, timeout_sec=timeout_sec) + + class LiveScopeEnvironment(SimultaneousEnvironment): """A subject whose process group outlives it, as a task's own service does.""" @@ -194,9 +215,17 @@ async def exec(self, command, cwd=None, timeout_sec=None): def _is_teardown(command: str) -> bool: - # `kill -0` is how the relay asks whether the scope is still there; only - # TERM and KILL end it. - return "kill -TERM" in command or "kill -KILL" in command + # Host abort signals the recorded process group. Framework timeout stops + # only the subject leader (`"$pgid"`), which must not count as teardown. + return ('-"$pgid"' in command) and ("kill -TERM" in command or "kill -KILL" in command) + + +def _is_leader_stop(command: str) -> bool: + return ( + ("kill -TERM" in command or "kill -KILL" in command) + and '"$pgid"' in command + and '-"$pgid"' not in command + ) class RecordingEnvironment: @@ -528,10 +557,10 @@ async def accept(reader, writer): server.close() await server.wait_closed() - async def test_framework_timeout_survives_destroy_fallback(self): + async def test_framework_timeout_leaves_the_environment_for_the_verifier(self): relay = load_relay() environment = FrameworkTimeoutEnvironment() - token = f"framework-destroy-{os.getpid()}" + token = f"framework-timeout-{os.getpid()}" connected = asyncio.get_running_loop().create_future() async def accept(reader, writer): @@ -558,7 +587,51 @@ async def accept(reader, writer): self.assertEqual(executed["termination"], "framework_timeout") self.assertEqual(executed["exitCode"], 124) self.assertEqual(executed["diagnostic"]["category"], "result-frame-missing") - self.assertTrue(environment.stopped) + # The verifier still runs after a timeout. Deleting the environment + # here would score a different trial than the one the subject left. + self.assertFalse(environment.stopped) + with self.assertRaises(asyncio.CancelledError): + await running + finally: + writer.close() + server.close() + await server.wait_closed() + + async def test_framework_timeout_stops_the_subject_without_the_process_group(self): + relay = load_relay() + environment = TimeoutScopeEnvironment() + token = f"framework-leader-{os.getpid()}" + connected = asyncio.get_running_loop().create_future() + + async def accept(reader, writer): + connected.set_result((reader, writer)) + + server = await asyncio.start_server(accept, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + agent = relay.RelayAgent( + logs_dir=Path(tempfile.gettempdir()), relay_host="127.0.0.1", + relay_port=port, relay_token=token, teardown_timeout_ms=1_000, + ) + running = asyncio.create_task(agent.run("solve", environment, None)) + reader, writer = await connected + try: + await reader.readline() + writer.write((__import__("json").dumps({ + "token": token, "kind": "execute", "command": "/bin/true", "args": [], + "credentials": {}, "resultToken": "0" * 32, + }) + "\n").encode()) + await writer.drain() + await environment.started.wait() + running.cancel() + executed = __import__("json").loads(await asyncio.wait_for(reader.readline(), 0.5)) + self.assertEqual(executed["termination"], "framework_timeout") + self.assertEqual(executed["exitCode"], 124) + self.assertTrue(any(_is_leader_stop(command) for command in environment.commands)) + self.assertEqual( + [command for command in environment.commands if _is_teardown(command)], + [], + ) + self.assertFalse(environment.stopped) with self.assertRaises(asyncio.CancelledError): await running finally: @@ -566,6 +639,41 @@ async def accept(reader, writer): server.close() await server.wait_closed() + async def test_host_teardown_of_a_running_subject_still_destroys(self): + relay = load_relay() + environment = FrameworkTimeoutEnvironment() + token = f"host-destroy-{os.getpid()}" + connected = asyncio.get_running_loop().create_future() + + async def accept(reader, writer): + connected.set_result((reader, writer)) + + server = await asyncio.start_server(accept, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + agent = relay.RelayAgent( + logs_dir=Path(tempfile.gettempdir()), relay_host="127.0.0.1", + relay_port=port, relay_token=token, teardown_timeout_ms=50, + ) + running = asyncio.create_task(agent.run("solve", environment, None)) + reader, writer = await connected + try: + await reader.readline() + writer.write((__import__("json").dumps({ + "token": token, "kind": "execute", "command": "/bin/true", "args": [], + "credentials": {}, "resultToken": "0" * 32, + }) + "\n").encode()) + await writer.drain() + await environment.started.wait() + relay.request_host_teardown() + running.cancel() + with self.assertRaises(asyncio.CancelledError): + await asyncio.wait_for(running, timeout=0.5) + self.assertTrue(environment.stopped) + finally: + writer.close() + server.close() + await server.wait_closed() + @unittest.skipUnless(shutil.which("setsid"), "requires GNU setsid") async def test_scope_waits_for_child_and_publishes_bounded_stdout(self): relay = load_relay() diff --git a/packages/eval/src/__tests__/lifecycle-boundaries.test.ts b/packages/eval/src/__tests__/lifecycle-boundaries.test.ts index c039dabe11..2fa3797e61 100644 --- a/packages/eval/src/__tests__/lifecycle-boundaries.test.ts +++ b/packages/eval/src/__tests__/lifecycle-boundaries.test.ts @@ -560,12 +560,11 @@ test('Maka forwards the configured Runtime Host settlement budget', async () => ); }); -// The relay tears the subject's process group down unless the wrapper exits -// zero, so every wrapper has to project the same status the same way — an arm -// whose failures exit zero would keep its background services through the -// verifier while the others lose theirs. This pins both halves of the Maka -// side: what the shim projects, and that the adapter reads the frame rather -// than re-deciding from the code it just projected. +// The wrapper's exit code is a projection of the status in its result frame, +// carried for anything that can read only the exit code. An arm that exited +// zero on failure would disagree with the frame. This pins both halves of the +// Maka side: what the shim projects, and that the adapter reads the frame +// rather than re-deciding from the code it just projected. test('the Maka shim projects only a completed subject as a zero exit', async () => { const shim = new URL('../harbor-maka-subject.js', import.meta.url); for (const [projection, expectedExit, expectedStatus] of [ From bd9ae72c919250d3debb2c6f126a6fd919dc1659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Tue, 18 Aug 2026 13:08:22 +0800 Subject: [PATCH 02/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AF=84=E6=B5=8B?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E5=90=8E=E5=8F=B0=E8=BF=9B=E7=A8=8B=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/eval/harbor/relay_agent.py | 49 ++++++++--- packages/eval/harbor/test_relay_lifecycle.py | 81 ++++++++++++++++--- .../__tests__/lifecycle-boundaries.test.ts | 5 +- packages/eval/src/harbor-maka-subject.ts | 1 + .../__tests__/hosted-execution-client.test.ts | 49 +++++++++++ .../src/client/hosted-execution.ts | 55 ++++++++++++- 6 files changed, 216 insertions(+), 24 deletions(-) diff --git a/packages/eval/harbor/relay_agent.py b/packages/eval/harbor/relay_agent.py index 7d08c4af92..fb6a892988 100644 --- a/packages/eval/harbor/relay_agent.py +++ b/packages/eval/harbor/relay_agent.py @@ -607,12 +607,29 @@ async def _stop_subject_for_timeout( return execution.result() loop = asyncio.get_running_loop() deadline = loop.time() + timeout + destroy_reserve = min(20.0, timeout * 0.2) + stop_deadline = deadline - destroy_reserve result = None for signal, slice_timeout in (("TERM", 20.0), ("KILL", 10.0)): - remaining = deadline - loop.time() + remaining = stop_deadline - loop.time() if remaining <= 0: break - await _signal_leader(environment, cwd, scope_path, signal) + signalled = await _signal_leader(environment, cwd, scope_path, signal) + if not signalled: + # A vanished leader can race the environment.exec completion by a + # few scheduling turns. Admit that terminal result, but do not + # spend the rest of the timeout pretending an unissued signal is + # evidence that a live subject stopped. + try: + return await asyncio.wait_for( + asyncio.shield(execution), timeout=min(0.1, remaining) + ) + except asyncio.CancelledError: + if execution.cancelled(): + raise RuntimeError("Maka Eval subject execution was cancelled") from None + raise + except (TimeoutError, asyncio.TimeoutError): + break try: result = await asyncio.wait_for( asyncio.shield(execution), @@ -629,12 +646,21 @@ async def _stop_subject_for_timeout( return result if execution.done() and not execution.cancelled(): return execution.result() - if not execution.done(): - execution.cancel() + # A verifier cannot measure a stable environment while the subject may + # still be mutating it. Fail closed instead of publishing a scoreable + # framework_timeout frame without positive leader-exit evidence. + try: + remaining = max(0.001, deadline - loop.time()) + await asyncio.wait_for(environment.stop(delete=True), timeout=remaining) + except Exception: + pass + finally: + if not execution.done(): + execution.cancel() with contextlib.suppress(asyncio.CancelledError, Exception): remaining = max(0.001, deadline - loop.time()) await asyncio.wait_for(execution, timeout=remaining) - return None + raise RuntimeError("Maka Eval could not confirm subject exit after framework timeout") async def _settle_or_destroy( @@ -681,18 +707,21 @@ async def _signal_group(environment: Any, cwd: str, scope_path: str, signal: str ) -async def _signal_leader(environment: Any, cwd: str, scope_path: str, signal: str) -> None: +async def _signal_leader(environment: Any, cwd: str, scope_path: str, signal: str) -> bool: command = ( - f"pgid=$(cat {shlex.quote(scope_path)} 2>/dev/null) || exit 0; " - "case $pgid in ''|0|*[!0-9]*) exit 0;; esac; " + f"pgid=$(cat {shlex.quote(scope_path)} 2>/dev/null) || exit 1; " + "case $pgid in ''|0|*[!0-9]*) exit 1;; esac; " f"kill -{signal} -- \"$pgid\"" ) - with contextlib.suppress(Exception): - await environment.exec( + try: + result = await environment.exec( command, cwd=cwd, timeout_sec=5, ) + return result.return_code == 0 + except Exception: + return False async def _quiesce_scope(environment: Any, cwd: str, scope_path: str) -> None: diff --git a/packages/eval/harbor/test_relay_lifecycle.py b/packages/eval/harbor/test_relay_lifecycle.py index fc5c6b7222..5c81f09141 100644 --- a/packages/eval/harbor/test_relay_lifecycle.py +++ b/packages/eval/harbor/test_relay_lifecycle.py @@ -23,6 +23,8 @@ import json import os import shutil +import signal +import socket import subprocess import sys import tempfile @@ -557,7 +559,7 @@ async def accept(reader, writer): server.close() await server.wait_closed() - async def test_framework_timeout_leaves_the_environment_for_the_verifier(self): + async def test_unconfirmed_framework_timeout_is_not_reported_as_scoreable(self): relay = load_relay() environment = FrameworkTimeoutEnvironment() token = f"framework-timeout-{os.getpid()}" @@ -583,14 +585,11 @@ async def accept(reader, writer): await writer.drain() await environment.started.wait() running.cancel() - executed = __import__("json").loads(await asyncio.wait_for(reader.readline(), 0.5)) - self.assertEqual(executed["termination"], "framework_timeout") - self.assertEqual(executed["exitCode"], 124) - self.assertEqual(executed["diagnostic"]["category"], "result-frame-missing") - # The verifier still runs after a timeout. Deleting the environment - # here would score a different trial than the one the subject left. - self.assertFalse(environment.stopped) - with self.assertRaises(asyncio.CancelledError): + self.assertEqual(await asyncio.wait_for(reader.readline(), 0.5), b"") + # The subject never acknowledged either leader-only signal. Do not + # let the verifier race a process that may still mutate its input. + self.assertTrue(environment.stopped) + with self.assertRaisesRegex(RuntimeError, "could not confirm subject exit"): await running finally: writer.close() @@ -757,6 +756,70 @@ async def test_settle_kills_descendants_before_verification_boundary(self): finally: Path(scope_path).unlink(missing_ok=True) + @unittest.skipUnless(shutil.which("setsid"), "requires GNU setsid") + async def test_framework_timeout_preserves_a_live_background_service(self): + relay = load_relay() + environment = LocalEnvironment() + token = f"timeout-service-{os.getpid()}" + scope_path = f"/tmp/maka-eval-{token}.pid" + service_pid = None + execution = None + with tempfile.TemporaryDirectory() as directory: + pid_path = Path(directory) / "service.pid" + port_path = Path(directory) / "service.port" + server = ( + "import os,socket;" + "server=socket.socket();" + "server.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1);" + "server.bind(('127.0.0.1',0));server.listen(1);" + f"open({str(pid_path)!r},'w').write(str(os.getpid()));" + f"open({str(port_path)!r},'w').write(str(server.getsockname()[1]));" + "connection,_=server.accept();connection.sendall(b'alive');connection.close()" + ) + subject = ( + "import subprocess,sys,time;" + f"subprocess.Popen([sys.executable,'-c',{server!r}]," + "stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL);" + "time.sleep(60)" + ) + request = { + "command": sys.executable, + "args": ["-c", subject], + "credentials": {}, + "resultToken": "0" * 32, + } + try: + command = await relay._prepare_command( + environment, request, token, scope_path + ) + execution = asyncio.create_task( + environment.exec(command, cwd=directory) + ) + deadline = time.monotonic() + 2 + while not (pid_path.exists() and port_path.exists()): + if time.monotonic() >= deadline: + self.fail("background service did not start") + await asyncio.sleep(0.01) + service_pid = int(pid_path.read_text()) + result = await relay._stop_subject_for_timeout( + environment, directory, scope_path, execution, 2 + ) + self.assertIsNotNone(result) + with socket.create_connection( + ("127.0.0.1", int(port_path.read_text())), timeout=1 + ) as connection: + self.assertEqual(connection.recv(5), b"alive") + finally: + if execution is not None and not execution.done(): + with contextlib.suppress(Exception): + await relay._settle( + environment, directory, scope_path, execution + ) + Path(scope_path).unlink(missing_ok=True) + if service_pid is not None: + with contextlib.suppress(ProcessLookupError): + os.kill(service_pid, signal.SIGTERM) + async def test_an_exited_subject_is_left_alone_whatever_it_reported(self): # The verifier scores the environment the task was left in, so a subject # that stopped on its own keeps whatever it started — a service the task diff --git a/packages/eval/src/__tests__/lifecycle-boundaries.test.ts b/packages/eval/src/__tests__/lifecycle-boundaries.test.ts index 2fa3797e61..889d00baec 100644 --- a/packages/eval/src/__tests__/lifecycle-boundaries.test.ts +++ b/packages/eval/src/__tests__/lifecycle-boundaries.test.ts @@ -587,7 +587,10 @@ test('the Maka shim projects only a completed subject as a zero exit', async () const client = join(root, 'client.mjs'); await writeFile( client, - `export async function runHostedExecution() { return ${JSON.stringify(frame)}; }\n`, + `export async function runHostedExecution(input) { + if (input.abortPolicy !== 'preserve_environment') throw new Error('missing Eval abort policy'); + return ${JSON.stringify(frame)}; +}\n`, ); const { exitCode, stdout } = await execFileAsync( process.execPath, diff --git a/packages/eval/src/harbor-maka-subject.ts b/packages/eval/src/harbor-maka-subject.ts index fbd82b2b2c..b98f940553 100644 --- a/packages/eval/src/harbor-maka-subject.ts +++ b/packages/eval/src/harbor-maka-subject.ts @@ -73,6 +73,7 @@ try { baseUrl: payload.baseUrl, execution: payload.execution, signal: abort.signal, + abortPolicy: 'preserve_environment', hostSettlementTimeoutMs: payload.hostSettlementTimeoutMs, }); } finally { diff --git a/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts index 596cfab0ab..937e24b3af 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts @@ -110,6 +110,47 @@ test('post-connect cancellation reports the owned Host settlement outcome', asyn } }); +test('environment-preserving abort detaches without cancelling or settling the Host', async () => { + const abort = new AbortController(); + const started = deferred(); + const closed = deferred(); + const events: string[] = []; + const connected = ownedHost({ + request: async (operation: string) => { + events.push(operation); + if (operation !== 'hosted.execution.start') { + throw new Error(`Unexpected operation ${operation}`); + } + started.resolve(); + await closed.promise; + throw new Error('connection detached'); + }, + }); + connected.connection.close = async () => { + if (events.includes('close')) return; + events.push('close'); + closed.resolve(); + }; + connected.host.releaseToEnvironment = () => { + events.push('release'); + }; + connected.host.settle = async () => { + assert.fail('a detached Host must not settle'); + }; + + const execution = runHostedExecutionWithDependencies( + { ...input(abort.signal), abortPolicy: 'preserve_environment' }, + { connectOwnedRuntimeHost: async () => connected as never }, + ); + await started.promise; + abort.abort(); + const result = await execution; + + assert.equal(result.kind, 'indeterminate'); + assert.equal(result.failureReason, 'Hosted execution continues for environment verification'); + assert.deepEqual(events, ['hosted.execution.start', 'release', 'close']); +}); + test('explicit target mutation settles the first Host before execution reconnects', async () => { const projection = settled('completed'); const events: string[] = []; @@ -358,3 +399,11 @@ function ownedHost(connection: Record, clean = false) { }, }; } + +function deferred() { + let resolve!: () => void; + const promise = new Promise((accept) => { + resolve = accept; + }); + return { promise, resolve }; +} diff --git a/packages/runtime-host/src/client/hosted-execution.ts b/packages/runtime-host/src/client/hosted-execution.ts index fbce39b1f5..a791889373 100644 --- a/packages/runtime-host/src/client/hosted-execution.ts +++ b/packages/runtime-host/src/client/hosted-execution.ts @@ -33,6 +33,7 @@ export interface RunHostedExecutionInput { readonly execution: HostedExecutionStartInput; readonly baseUrl?: string; readonly signal?: AbortSignal; + readonly abortPolicy?: 'cancel' | 'preserve_environment'; readonly hostSettlementTimeoutMs?: number; } @@ -76,6 +77,7 @@ export async function runHostedExecutionWithDependencies( { kind: 'connected' } > = initial; let projection: HostedExecutionProjection; + let detached = false; try { input.signal?.throwIfAborted(); const target = input.execution.session.modelTarget; @@ -114,7 +116,15 @@ export async function runHostedExecutionWithDependencies( connected = reconnected; } } - projection = await executeHostedExecution(connected.connection, input.execution, input.signal); + const execution = await executeHostedExecution( + connected.connection, + connected.host, + input.execution, + input.signal, + input.abortPolicy ?? 'cancel', + ); + projection = execution.projection; + detached = execution.detached; } catch { projection = input.signal?.aborted ? indeterminate(input.execution.executionId, 'Hosted execution was cancelled') @@ -126,6 +136,7 @@ export async function runHostedExecutionWithDependencies( await connected.connection.close().catch(() => undefined); } + if (detached) return projection; if (preservesHostedExecutionEnvironment(projection)) { connected.host.releaseToEnvironment(); return projection; @@ -137,11 +148,25 @@ export async function runHostedExecutionWithDependencies( } async function executeHostedExecution( - connection: Pick, + connection: Pick, + host: { releaseToEnvironment(): void }, execution: HostedExecutionStartInput, signal: AbortSignal | undefined, -): Promise { + abortPolicy: NonNullable, +): Promise<{ readonly projection: HostedExecutionProjection; readonly detached: boolean }> { + let detached = false; + let closeForDetach: Promise | undefined; + const detach = () => { + if (abortPolicy !== 'preserve_environment' || detached) return; + detached = true; + host.releaseToEnvironment(); + closeForDetach = connection.close().catch(() => undefined); + }; const cancel = () => { + if (abortPolicy === 'preserve_environment') { + detach(); + return; + } void connection .request('hosted.execution.cancel', { executionId: execution.executionId }) .catch(() => undefined); @@ -149,9 +174,31 @@ async function executeHostedExecution( signal?.addEventListener('abort', cancel, { once: true }); if (signal?.aborted) cancel(); try { - return await connection.request('hosted.execution.start', execution); + const projection = await connection.request('hosted.execution.start', execution); + return { + projection: + detached && !preservesHostedExecutionEnvironment(projection) + ? indeterminate( + execution.executionId, + 'Hosted execution continues for environment verification', + ) + : projection, + detached, + }; + } catch (error) { + if (detached) { + return { + projection: indeterminate( + execution.executionId, + 'Hosted execution continues for environment verification', + ), + detached: true, + }; + } + throw error; } finally { signal?.removeEventListener('abort', cancel); + await closeForDetach; } } From fc94e908eb366ac8e6cb12e09b82ca155f1065ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Tue, 18 Aug 2026 14:25:30 +0800 Subject: [PATCH 03/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=AD=89?= =?UTF-8?q?=E5=BE=85=E6=B6=88=E5=A4=B1=E7=9A=84=E4=B8=BB=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E5=B9=B6=E6=A0=A1=E5=87=86=E7=BB=84=E4=BF=A1=E5=8F=B7=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/eval/harbor/relay_agent.py | 45 ++++++----- packages/eval/harbor/test_relay_lifecycle.py | 81 ++++++++++++++++++-- 2 files changed, 97 insertions(+), 29 deletions(-) diff --git a/packages/eval/harbor/relay_agent.py b/packages/eval/harbor/relay_agent.py index fb6a892988..21563b7526 100644 --- a/packages/eval/harbor/relay_agent.py +++ b/packages/eval/harbor/relay_agent.py @@ -622,7 +622,7 @@ async def _stop_subject_for_timeout( # evidence that a live subject stopped. try: return await asyncio.wait_for( - asyncio.shield(execution), timeout=min(0.1, remaining) + asyncio.shield(execution), timeout=remaining ) except asyncio.CancelledError: if execution.cancelled(): @@ -649,17 +649,7 @@ async def _stop_subject_for_timeout( # A verifier cannot measure a stable environment while the subject may # still be mutating it. Fail closed instead of publishing a scoreable # framework_timeout frame without positive leader-exit evidence. - try: - remaining = max(0.001, deadline - loop.time()) - await asyncio.wait_for(environment.stop(delete=True), timeout=remaining) - except Exception: - pass - finally: - if not execution.done(): - execution.cancel() - with contextlib.suppress(asyncio.CancelledError, Exception): - remaining = max(0.001, deadline - loop.time()) - await asyncio.wait_for(execution, timeout=remaining) + await _destroy_environment(environment, execution, deadline, loop) raise RuntimeError("Maka Eval could not confirm subject exit after framework timeout") @@ -679,20 +669,29 @@ async def _settle_or_destroy( timeout=max(0.001, deadline - loop.time() - stop_reserve), ) except Exception: - try: - remaining = max(0.001, deadline - loop.time()) - await asyncio.wait_for(environment.stop(delete=True), timeout=remaining) - except Exception: - pass - finally: - if not execution.done(): - execution.cancel() - with contextlib.suppress(asyncio.CancelledError, Exception): - remaining = max(0.001, deadline - loop.time()) - await asyncio.wait_for(execution, timeout=remaining) + await _destroy_environment(environment, execution, deadline, loop) return None +async def _destroy_environment( + environment: Any, + execution: Any, + deadline: float, + loop: asyncio.AbstractEventLoop, +) -> None: + try: + remaining = max(0.001, deadline - loop.time()) + await asyncio.wait_for(environment.stop(delete=True), timeout=remaining) + except Exception: + pass + finally: + if not execution.done(): + execution.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + remaining = max(0.001, deadline - loop.time()) + await asyncio.wait_for(execution, timeout=remaining) + + async def _signal_group(environment: Any, cwd: str, scope_path: str, signal: str) -> None: command = ( f"pgid=$(cat {shlex.quote(scope_path)} 2>/dev/null) || exit 0; " diff --git a/packages/eval/harbor/test_relay_lifecycle.py b/packages/eval/harbor/test_relay_lifecycle.py index 5c81f09141..d8482d7786 100644 --- a/packages/eval/harbor/test_relay_lifecycle.py +++ b/packages/eval/harbor/test_relay_lifecycle.py @@ -182,6 +182,26 @@ async def exec(self, command, cwd=None, timeout_sec=None): return await super().exec(command, cwd=cwd, timeout_sec=timeout_sec) +class IgnoringLeaderEnvironment(SimultaneousEnvironment): + """Delivers leader TERM/KILL but keeps the subject running.""" + + def __init__(self): + super().__init__() + self.commands = [] + self.stopped = False + + async def stop(self, delete=False): + self.stopped = delete + + async def exec(self, command, cwd=None, timeout_sec=None): + self.commands.append(command) + if _is_teardown(command): + raise AssertionError("framework timeout must not signal the process group") + if _is_leader_stop(command): + return SimpleNamespace(return_code=0, stdout="", stderr="") + return await super().exec(command, cwd=cwd, timeout_sec=timeout_sec) + + class LiveScopeEnvironment(SimultaneousEnvironment): """A subject whose process group outlives it, as a task's own service does.""" @@ -217,16 +237,16 @@ async def exec(self, command, cwd=None, timeout_sec=None): def _is_teardown(command: str) -> bool: - # Host abort signals the recorded process group. Framework timeout stops - # only the subject leader (`"$pgid"`), which must not count as teardown. - return ('-"$pgid"' in command) and ("kill -TERM" in command or "kill -KILL" in command) + # Host abort emits `kill -- "-$pgid"`. Framework timeout emits + # `kill -- "$pgid"`, which must not count as teardown. + return ('"-$pgid"' in command) and ("kill -TERM" in command or "kill -KILL" in command) def _is_leader_stop(command: str) -> bool: return ( ("kill -TERM" in command or "kill -KILL" in command) and '"$pgid"' in command - and '-"$pgid"' not in command + and '"-$pgid"' not in command ) @@ -323,6 +343,14 @@ async def trial(*_args): class RelayLifecycleTest(unittest.IsolatedAsyncioTestCase): + def test_scope_predicates_distinguish_group_teardown_from_leader_stop(self): + group = 'kill -TERM -- "-$pgid"' + leader = 'kill -TERM -- "$pgid"' + self.assertTrue(_is_teardown(group)) + self.assertFalse(_is_leader_stop(group)) + self.assertFalse(_is_teardown(leader)) + self.assertTrue(_is_leader_stop(leader)) + def test_host_teardown_exits_successfully_after_trial_unwinds(self): with tempfile.TemporaryDirectory() as directory: marker = Path(directory) / "cleanup-attempted" @@ -586,8 +614,49 @@ async def accept(reader, writer): await environment.started.wait() running.cancel() self.assertEqual(await asyncio.wait_for(reader.readline(), 0.5), b"") - # The subject never acknowledged either leader-only signal. Do not - # let the verifier race a process that may still mutate its input. + # SimultaneousEnvironment answers every `pgid=` command with 3, so + # `_signal_leader` reports a vanished leader. The completion race + # never delivers, so the relay must fail closed rather than score. + self.assertTrue(environment.stopped) + with self.assertRaisesRegex(RuntimeError, "could not confirm subject exit"): + await running + finally: + writer.close() + server.close() + await server.wait_closed() + + async def test_ignored_leader_signals_fail_closed_without_a_scoreable_timeout(self): + relay = load_relay() + environment = IgnoringLeaderEnvironment() + token = f"framework-ignore-{os.getpid()}" + connected = asyncio.get_running_loop().create_future() + + async def accept(reader, writer): + connected.set_result((reader, writer)) + + server = await asyncio.start_server(accept, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + agent = relay.RelayAgent( + logs_dir=Path(tempfile.gettempdir()), relay_host="127.0.0.1", + relay_port=port, relay_token=token, teardown_timeout_ms=50, + ) + running = asyncio.create_task(agent.run("solve", environment, None)) + reader, writer = await connected + try: + await reader.readline() + writer.write((__import__("json").dumps({ + "token": token, "kind": "execute", "command": "/bin/true", "args": [], + "credentials": {}, "resultToken": "0" * 32, + }) + "\n").encode()) + await writer.drain() + await environment.started.wait() + running.cancel() + self.assertEqual(await asyncio.wait_for(reader.readline(), 0.5), b"") + self.assertTrue(any(_is_leader_stop(command) for command in environment.commands)) + self.assertEqual( + [command for command in environment.commands if _is_teardown(command)], + [], + ) self.assertTrue(environment.stopped) with self.assertRaisesRegex(RuntimeError, "could not confirm subject exit"): await running From b65ae2360a4e0682f2eb64eb6964230727ec570b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Tue, 18 Aug 2026 14:46:35 +0800 Subject: [PATCH 04/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=89=98?= =?UTF-8?q?=E7=AE=A1=E6=89=A7=E8=A1=8C=E5=90=AF=E5=8A=A8=E5=90=8E=E5=86=8D?= =?UTF-8?q?=E5=88=86=E7=A6=BB=E5=B9=B6=E9=99=90=E5=88=B6=E4=BF=A1=E5=8F=B7?= =?UTF-8?q?=E6=97=B6=E9=95=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/eval/harbor/relay_agent.py | 19 ++++++++-- packages/eval/harbor/test_relay_lifecycle.py | 38 +++++++++++++++++++ .../__tests__/hosted-execution-client.test.ts | 34 +++++++++++++++++ .../src/client/hosted-execution.ts | 16 +++++++- 4 files changed, 102 insertions(+), 5 deletions(-) diff --git a/packages/eval/harbor/relay_agent.py b/packages/eval/harbor/relay_agent.py index 21563b7526..beb8e552ff 100644 --- a/packages/eval/harbor/relay_agent.py +++ b/packages/eval/harbor/relay_agent.py @@ -614,7 +614,14 @@ async def _stop_subject_for_timeout( remaining = stop_deadline - loop.time() if remaining <= 0: break - signalled = await _signal_leader(environment, cwd, scope_path, signal) + signalled = await _signal_leader( + environment, cwd, scope_path, signal, timeout_sec=min(5.0, remaining) + ) + remaining = stop_deadline - loop.time() + if remaining <= 0: + if execution.done() and not execution.cancelled(): + return execution.result() + break if not signalled: # A vanished leader can race the environment.exec completion by a # few scheduling turns. Admit that terminal result, but do not @@ -706,7 +713,13 @@ async def _signal_group(environment: Any, cwd: str, scope_path: str, signal: str ) -async def _signal_leader(environment: Any, cwd: str, scope_path: str, signal: str) -> bool: +async def _signal_leader( + environment: Any, + cwd: str, + scope_path: str, + signal: str, + timeout_sec: float = 5, +) -> bool: command = ( f"pgid=$(cat {shlex.quote(scope_path)} 2>/dev/null) || exit 1; " "case $pgid in ''|0|*[!0-9]*) exit 1;; esac; " @@ -716,7 +729,7 @@ async def _signal_leader(environment: Any, cwd: str, scope_path: str, signal: st result = await environment.exec( command, cwd=cwd, - timeout_sec=5, + timeout_sec=max(0.001, timeout_sec), ) return result.return_code == 0 except Exception: diff --git a/packages/eval/harbor/test_relay_lifecycle.py b/packages/eval/harbor/test_relay_lifecycle.py index d8482d7786..1937427a41 100644 --- a/packages/eval/harbor/test_relay_lifecycle.py +++ b/packages/eval/harbor/test_relay_lifecycle.py @@ -188,6 +188,7 @@ class IgnoringLeaderEnvironment(SimultaneousEnvironment): def __init__(self): super().__init__() self.commands = [] + self.signal_timeouts = [] self.stopped = False async def stop(self, delete=False): @@ -195,6 +196,8 @@ async def stop(self, delete=False): async def exec(self, command, cwd=None, timeout_sec=None): self.commands.append(command) + if _is_leader_stop(command): + self.signal_timeouts.append(timeout_sec) if _is_teardown(command): raise AssertionError("framework timeout must not signal the process group") if _is_leader_stop(command): @@ -343,6 +346,41 @@ async def trial(*_args): class RelayLifecycleTest(unittest.IsolatedAsyncioTestCase): + async def test_leader_stop_exec_timeout_stays_inside_the_teardown_budget(self): + relay = load_relay() + environment = IgnoringLeaderEnvironment() + token = f"framework-budget-{os.getpid()}" + connected = asyncio.get_running_loop().create_future() + + async def accept(reader, writer): + connected.set_result((reader, writer)) + + server = await asyncio.start_server(accept, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + agent = relay.RelayAgent( + logs_dir=Path(tempfile.gettempdir()), relay_host="127.0.0.1", + relay_port=port, relay_token=token, teardown_timeout_ms=50, + ) + running = asyncio.create_task(agent.run("solve", environment, None)) + reader, writer = await connected + try: + await reader.readline() + writer.write((__import__("json").dumps({ + "token": token, "kind": "execute", "command": "/bin/true", "args": [], + "credentials": {}, "resultToken": "0" * 32, + }) + "\n").encode()) + await writer.drain() + await environment.started.wait() + running.cancel() + with self.assertRaisesRegex(RuntimeError, "could not confirm subject exit"): + await asyncio.wait_for(running, timeout=0.5) + self.assertNotEqual(environment.signal_timeouts, []) + self.assertTrue(all(timeout is not None and timeout <= 0.05 for timeout in environment.signal_timeouts)) + finally: + writer.close() + server.close() + await server.wait_closed() + def test_scope_predicates_distinguish_group_teardown_from_leader_stop(self): group = 'kill -TERM -- "-$pgid"' leader = 'kill -TERM -- "$pgid"' diff --git a/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts index 937e24b3af..90ed28632b 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts @@ -151,6 +151,40 @@ test('environment-preserving abort detaches without cancelling or settling the H assert.deepEqual(events, ['hosted.execution.start', 'release', 'close']); }); +test('environment-preserving abort before start does not claim execution continues', async () => { + const abort = new AbortController(); + const events: string[] = []; + const connected = ownedHost({ + request: async (operation: string) => { + events.push(operation); + throw new Error(`Unexpected operation ${operation}`); + }, + }); + connected.host.releaseToEnvironment = () => { + events.push('release'); + }; + connected.host.settle = async () => { + events.push('settle'); + return true; + }; + + const result = await runHostedExecutionWithDependencies( + { ...input(abort.signal), abortPolicy: 'preserve_environment' }, + { + connectOwnedRuntimeHost: async () => { + abort.abort(); + return connected as never; + }, + }, + ); + + assert.equal(result.kind, 'indeterminate'); + assert.equal(result.failureReason, 'Hosted execution was cancelled'); + assert.equal(events.includes('hosted.execution.start'), false); + assert.equal(events.includes('release'), false); + assert.equal(events.includes('settle'), true); +}); + test('explicit target mutation settles the first Host before execution reconnects', async () => { const projection = settled('completed'); const events: string[] = []; diff --git a/packages/runtime-host/src/client/hosted-execution.ts b/packages/runtime-host/src/client/hosted-execution.ts index a791889373..6a493699b8 100644 --- a/packages/runtime-host/src/client/hosted-execution.ts +++ b/packages/runtime-host/src/client/hosted-execution.ts @@ -155,9 +155,10 @@ async function executeHostedExecution( abortPolicy: NonNullable, ): Promise<{ readonly projection: HostedExecutionProjection; readonly detached: boolean }> { let detached = false; + let dispatched = false; let closeForDetach: Promise | undefined; const detach = () => { - if (abortPolicy !== 'preserve_environment' || detached) return; + if (abortPolicy !== 'preserve_environment' || detached || !dispatched) return; detached = true; host.releaseToEnvironment(); closeForDetach = connection.close().catch(() => undefined); @@ -167,13 +168,24 @@ async function executeHostedExecution( detach(); return; } + if (!dispatched) return; void connection .request('hosted.execution.cancel', { executionId: execution.executionId }) .catch(() => undefined); }; signal?.addEventListener('abort', cancel, { once: true }); - if (signal?.aborted) cancel(); + if (signal?.aborted) { + if (abortPolicy === 'preserve_environment') { + signal.removeEventListener('abort', cancel); + return { + projection: indeterminate(execution.executionId, 'Hosted execution was cancelled'), + detached: false, + }; + } + cancel(); + } try { + dispatched = true; const projection = await connection.request('hosted.execution.start', execution); return { projection: From 1449e88d1438bf7a9514a2d33a1390531d0275a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Tue, 18 Aug 2026 22:03:26 +0800 Subject: [PATCH 05/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E5=87=86?= =?UTF-8?q?=E5=85=A5=E5=90=8E=E6=89=8D=E4=BF=9D=E7=95=99=E5=B9=B6=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E5=8F=96=E6=B6=88=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/eval/harbor/relay_agent.py | 151 +++++++++++------- packages/eval/harbor/test_relay_lifecycle.py | 43 +++++ .../__tests__/hosted-execution-client.test.ts | 65 +++++++- .../src/client/hosted-execution.ts | 79 ++++----- 4 files changed, 239 insertions(+), 99 deletions(-) diff --git a/packages/eval/harbor/relay_agent.py b/packages/eval/harbor/relay_agent.py index beb8e552ff..9043565b7b 100644 --- a/packages/eval/harbor/relay_agent.py +++ b/packages/eval/harbor/relay_agent.py @@ -116,6 +116,7 @@ async def run(self, instruction: str, environment: Any, context: Any) -> None: execution: asyncio.Task[Any] | None = None decision: asyncio.Task[dict[str, Any]] | None = None request: dict[str, Any] | None = None + cwd = "" execution_reported = False scope_path = f"/logs/agent/.maka-eval-{self._token}.pid" environment_path = f"/tmp/maka-eval-{self._token}.env" @@ -182,66 +183,30 @@ async def run(self, instruction: str, environment: Any, context: Any) -> None: "verify", ) except asyncio.CancelledError: - if request is not None and execution is not None: - execution_terminal = execution.done() and not execution.cancelled() - terminal_projection = None - if execution_terminal: - terminal_result = execution.result() - terminal_projection = _project_result(terminal_result, request) - # A subject that already exited has nothing left to settle, and - # tearing its scope down here would remove what the verifier is - # about to score -- the same environment edit this relay stopped - # making on the ordinary path. A still-running subject is - # stopped so the execution call can return. Host abort then - # quiesces the leftover group; framework timeout does not, - # because the verifier still runs. - if terminal_projection is not None: - result = terminal_result - elif _host_teardown_requested: - result = await _settle_or_destroy( - environment, cwd, scope_path, execution, self._teardown_timeout - ) - else: - result = await _stop_subject_for_timeout( - environment, cwd, scope_path, execution, self._teardown_timeout + # A cancelled task would otherwise fail every await in this + # handler. Uncancel so cleanup can run; a second cancel or any + # cleanup error destroys the environment before we re-raise. + current = asyncio.current_task() + if current is not None and hasattr(current, "uncancel"): + current.uncancel() + try: + if request is not None and execution is not None: + await self._finalize_cancelled_execution( + environment, + cwd, + scope_path, + execution, + request, + writer, + execution_reported, ) - if result is not None: - await _persist_subject_outputs(environment, result) - if ( - result is not None - and not execution_reported - and (execution_terminal or not _host_teardown_requested) - ): - stdout, diagnostic = terminal_projection or _project_result(result, request) - with contextlib.suppress(Exception): - await _send( - writer, - { - "token": self._token, - "kind": "executed", - "termination": "exited" if execution_terminal else "framework_timeout", - "exitCode": result.return_code if execution_terminal else 124, - "stdout": stdout, - "diagnostic": diagnostic, - }, - ) - elif not execution_reported and not _host_teardown_requested: + except asyncio.CancelledError: + if request is not None and execution is not None: with contextlib.suppress(Exception): - await _send( - writer, - { - "token": self._token, - "kind": "executed", - "termination": "framework_timeout", - "exitCode": 124, - "stdout": "", - "diagnostic": ( - _carrier_diagnostic("result-frame-missing", b"") - if request.get("captureStdout", True) - else {"category": "none"} - ), - }, + await _settle_or_destroy( + environment, cwd, scope_path, execution, self._teardown_timeout ) + raise raise except RelayTransportClosed: if request is not None and execution is not None: @@ -279,6 +244,76 @@ async def run(self, instruction: str, environment: Any, context: Any) -> None: writer.close() await asyncio.wait_for(writer.wait_closed(), timeout=1) + async def _finalize_cancelled_execution( + self, + environment: Any, + cwd: str, + scope_path: str, + execution: asyncio.Task[Any], + request: dict[str, Any], + writer: Any, + execution_reported: bool, + ) -> None: + execution_terminal = execution.done() and not execution.cancelled() + terminal_projection = None + if execution_terminal: + terminal_result = execution.result() + terminal_projection = _project_result(terminal_result, request) + # A subject that already exited has nothing left to settle, and + # tearing its scope down here would remove what the verifier is + # about to score -- the same environment edit this relay stopped + # making on the ordinary path. A still-running subject is + # stopped so the execution call can return. Host abort then + # quiesces the leftover group; framework timeout does not, + # because the verifier still runs. + if terminal_projection is not None: + result = terminal_result + elif _host_teardown_requested: + result = await _settle_or_destroy( + environment, cwd, scope_path, execution, self._teardown_timeout + ) + else: + result = await _stop_subject_for_timeout( + environment, cwd, scope_path, execution, self._teardown_timeout + ) + if result is not None: + await _persist_subject_outputs(environment, result) + if ( + result is not None + and not execution_reported + and (execution_terminal or not _host_teardown_requested) + ): + stdout, diagnostic = terminal_projection or _project_result(result, request) + with contextlib.suppress(Exception): + await _send( + writer, + { + "token": self._token, + "kind": "executed", + "termination": "exited" if execution_terminal else "framework_timeout", + "exitCode": result.return_code if execution_terminal else 124, + "stdout": stdout, + "diagnostic": diagnostic, + }, + ) + elif not execution_reported and not _host_teardown_requested: + with contextlib.suppress(Exception): + await _send( + writer, + { + "token": self._token, + "kind": "executed", + "termination": "framework_timeout", + "exitCode": 124, + "stdout": "", + "diagnostic": ( + _carrier_diagnostic("result-frame-missing", b"") + if request.get("captureStdout", True) + else {"category": "none"} + ), + }, + ) + async def _prepare_command( environment: Any, @@ -615,7 +650,7 @@ async def _stop_subject_for_timeout( if remaining <= 0: break signalled = await _signal_leader( - environment, cwd, scope_path, signal, timeout_sec=min(5.0, remaining) + environment, cwd, scope_path, signal, timeout_sec=remaining ) remaining = stop_deadline - loop.time() if remaining <= 0: diff --git a/packages/eval/harbor/test_relay_lifecycle.py b/packages/eval/harbor/test_relay_lifecycle.py index 1937427a41..213d817ccb 100644 --- a/packages/eval/harbor/test_relay_lifecycle.py +++ b/packages/eval/harbor/test_relay_lifecycle.py @@ -205,6 +205,13 @@ async def exec(self, command, cwd=None, timeout_sec=None): return await super().exec(command, cwd=cwd, timeout_sec=timeout_sec) +class SlowLeaderStopEnvironment(IgnoringLeaderEnvironment): + async def exec(self, command, cwd=None, timeout_sec=None): + if _is_leader_stop(command): + await asyncio.sleep(0.05) + return await super().exec(command, cwd=cwd, timeout_sec=timeout_sec) + + class LiveScopeEnvironment(SimultaneousEnvironment): """A subject whose process group outlives it, as a task's own service does.""" @@ -703,6 +710,42 @@ async def accept(reader, writer): server.close() await server.wait_closed() + async def test_second_cancel_during_timeout_cleanup_still_destroys(self): + relay = load_relay() + environment = SlowLeaderStopEnvironment() + token = f"framework-recancel-{os.getpid()}" + connected = asyncio.get_running_loop().create_future() + + async def accept(reader, writer): + connected.set_result((reader, writer)) + + server = await asyncio.start_server(accept, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + agent = relay.RelayAgent( + logs_dir=Path(tempfile.gettempdir()), relay_host="127.0.0.1", + relay_port=port, relay_token=token, teardown_timeout_ms=200, + ) + running = asyncio.create_task(agent.run("solve", environment, None)) + reader, writer = await connected + try: + await reader.readline() + writer.write((__import__("json").dumps({ + "token": token, "kind": "execute", "command": "/bin/true", "args": [], + "credentials": {}, "resultToken": "0" * 32, + }) + "\n").encode()) + await writer.drain() + await environment.started.wait() + running.cancel() + await asyncio.sleep(0.01) + running.cancel() + with self.assertRaises(asyncio.CancelledError): + await asyncio.wait_for(running, timeout=1) + self.assertTrue(environment.stopped) + finally: + writer.close() + server.close() + await server.wait_closed() + async def test_framework_timeout_stops_the_subject_without_the_process_group(self): relay = load_relay() environment = TimeoutScopeEnvironment() diff --git a/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts index 90ed28632b..aedd873f3c 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts @@ -19,6 +19,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { RuntimeHostRequestInterruptedError } from '../client/connection.js'; import { runHostedExecutionWithDependencies } from '../client/hosted-execution.js'; test('diagnostics disconnect after settlement preserves the canonical result', async () => { @@ -123,7 +124,7 @@ test('environment-preserving abort detaches without cancelling or settling the H } started.resolve(); await closed.promise; - throw new Error('connection detached'); + throw admittedInterrupt(); }, }); connected.connection.close = async () => { @@ -148,7 +149,49 @@ test('environment-preserving abort detaches without cancelling or settling the H assert.equal(result.kind, 'indeterminate'); assert.equal(result.failureReason, 'Hosted execution continues for environment verification'); - assert.deepEqual(events, ['hosted.execution.start', 'release', 'close']); + assert.deepEqual(events, ['hosted.execution.start', 'close', 'release']); +}); + +test('pre-admission preserve abort does not claim execution continues', async () => { + const abort = new AbortController(); + const started = deferred(); + const closed = deferred(); + const events: string[] = []; + const connected = ownedHost({ + request: async (operation: string) => { + events.push(operation); + if (operation !== 'hosted.execution.start') { + throw new Error(`Unexpected operation ${operation}`); + } + started.resolve(); + await closed.promise; + throw queuedInterrupt(); + }, + }); + connected.connection.close = async () => { + if (events.includes('close')) return; + events.push('close'); + closed.resolve(); + }; + connected.host.releaseToEnvironment = () => { + events.push('release'); + }; + connected.host.settle = async () => { + events.push('settle'); + return true; + }; + + const execution = runHostedExecutionWithDependencies( + { ...input(abort.signal), abortPolicy: 'preserve_environment' }, + { connectOwnedRuntimeHost: async () => connected as never }, + ); + await started.promise; + abort.abort(); + const result = await execution; + + assert.equal(result.kind, 'indeterminate'); + assert.equal(result.failureReason, 'Hosted execution was cancelled'); + assert.deepEqual(events, ['hosted.execution.start', 'close', 'settle']); }); test('environment-preserving abort before start does not claim execution continues', async () => { @@ -434,6 +477,24 @@ function ownedHost(connection: Record, clean = false) { }; } +function admittedInterrupt() { + return new RuntimeHostRequestInterruptedError( + 'hosted.execution.start', + 'command', + 'dispatched', + 'connection_lost', + ); +} + +function queuedInterrupt() { + return new RuntimeHostRequestInterruptedError( + 'hosted.execution.start', + 'command', + 'not_dispatched', + 'connection_lost', + ); +} + function deferred() { let resolve!: () => void; const promise = new Promise((accept) => { diff --git a/packages/runtime-host/src/client/hosted-execution.ts b/packages/runtime-host/src/client/hosted-execution.ts index 6a493699b8..1d8c886d05 100644 --- a/packages/runtime-host/src/client/hosted-execution.ts +++ b/packages/runtime-host/src/client/hosted-execution.ts @@ -25,7 +25,7 @@ import { type HostedExecutionStartInput, } from '../protocol/index.js'; import { connectOwnedRuntimeHost } from './connect-or-spawn.js'; -import type { RuntimeHostConnection } from './connection.js'; +import { RuntimeHostRequestInterruptedError, type RuntimeHostConnection } from './connection.js'; import { configureHostedExecutionTarget } from './hosted-execution-target.js'; export interface RunHostedExecutionInput { @@ -154,66 +154,67 @@ async function executeHostedExecution( signal: AbortSignal | undefined, abortPolicy: NonNullable, ): Promise<{ readonly projection: HostedExecutionProjection; readonly detached: boolean }> { - let detached = false; - let dispatched = false; - let closeForDetach: Promise | undefined; - const detach = () => { - if (abortPolicy !== 'preserve_environment' || detached || !dispatched) return; - detached = true; - host.releaseToEnvironment(); - closeForDetach = connection.close().catch(() => undefined); - }; - const cancel = () => { + let closeForAbort: Promise | undefined; + const onAbort = () => { if (abortPolicy === 'preserve_environment') { - detach(); + closeForAbort = connection.close().catch(() => undefined); return; } - if (!dispatched) return; void connection .request('hosted.execution.cancel', { executionId: execution.executionId }) .catch(() => undefined); }; - signal?.addEventListener('abort', cancel, { once: true }); + signal?.addEventListener('abort', onAbort, { once: true }); if (signal?.aborted) { - if (abortPolicy === 'preserve_environment') { - signal.removeEventListener('abort', cancel); - return { - projection: indeterminate(execution.executionId, 'Hosted execution was cancelled'), - detached: false, - }; - } - cancel(); + signal.removeEventListener('abort', onAbort); + return { + projection: indeterminate(execution.executionId, 'Hosted execution was cancelled'), + detached: false, + }; } try { - dispatched = true; const projection = await connection.request('hosted.execution.start', execution); - return { - projection: - detached && !preservesHostedExecutionEnvironment(projection) - ? indeterminate( + if (signal?.aborted && abortPolicy === 'preserve_environment') { + host.releaseToEnvironment(); + return { + projection: preservesHostedExecutionEnvironment(projection) + ? projection + : indeterminate( execution.executionId, 'Hosted execution continues for environment verification', - ) - : projection, - detached, - }; + ), + detached: true, + }; + } + return { projection, detached: false }; } catch (error) { - if (detached) { + if (signal?.aborted && abortPolicy === 'preserve_environment') { + if (isAdmittedInterrupt(error)) { + host.releaseToEnvironment(); + return { + projection: indeterminate( + execution.executionId, + 'Hosted execution continues for environment verification', + ), + detached: true, + }; + } return { - projection: indeterminate( - execution.executionId, - 'Hosted execution continues for environment verification', - ), - detached: true, + projection: indeterminate(execution.executionId, 'Hosted execution was cancelled'), + detached: false, }; } throw error; } finally { - signal?.removeEventListener('abort', cancel); - await closeForDetach; + signal?.removeEventListener('abort', onAbort); + await closeForAbort; } } +function isAdmittedInterrupt(error: unknown): boolean { + return error instanceof RuntimeHostRequestInterruptedError && error.dispatch === 'dispatched'; +} + function indeterminate(executionId: string, failureReason: string): HostedExecutionProjection { return { executionId, kind: 'indeterminate', failureReason }; } From 0d9545a3d46f82ed65278c2841e55cbdf880fc1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Tue, 18 Aug 2026 22:12:01 +0800 Subject: [PATCH 06/13] =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=9A=E4=BA=8C?= =?UTF-8?q?=E6=AC=A1=E5=8F=96=E6=B6=88=E5=89=8D=E7=AD=89=E5=BE=85=E4=B8=BB?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E5=81=9C=E6=AD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/eval/harbor/test_relay_lifecycle.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/eval/harbor/test_relay_lifecycle.py b/packages/eval/harbor/test_relay_lifecycle.py index 213d817ccb..f211c2ec2d 100644 --- a/packages/eval/harbor/test_relay_lifecycle.py +++ b/packages/eval/harbor/test_relay_lifecycle.py @@ -206,8 +206,13 @@ async def exec(self, command, cwd=None, timeout_sec=None): class SlowLeaderStopEnvironment(IgnoringLeaderEnvironment): + def __init__(self): + super().__init__() + self.leader_stop_started = asyncio.Event() + async def exec(self, command, cwd=None, timeout_sec=None): if _is_leader_stop(command): + self.leader_stop_started.set() await asyncio.sleep(0.05) return await super().exec(command, cwd=cwd, timeout_sec=timeout_sec) @@ -736,7 +741,7 @@ async def accept(reader, writer): await writer.drain() await environment.started.wait() running.cancel() - await asyncio.sleep(0.01) + await environment.leader_stop_started.wait() running.cancel() with self.assertRaises(asyncio.CancelledError): await asyncio.wait_for(running, timeout=1) From d94da86fa593338558a85fa8fe0cf1dc416aaed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Wed, 19 Aug 2026 00:36:40 +0800 Subject: [PATCH 07/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E5=88=86=E7=A6=BB=E5=89=8D=E8=A6=81=E6=B1=82=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF=E5=87=86=E5=85=A5=E4=BB=A4=E7=89=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../runtime-host-operator-command.test.ts | 1 + packages/eval/harbor/relay_agent.py | 58 ++++++--- packages/eval/harbor/test_relay_lifecycle.py | 119 ++++++++++++++++++ .../__tests__/hosted-execution-client.test.ts | 40 +++--- .../hosted-execution-coordinator.test.ts | 34 +++++ .../src/__tests__/protocol.test.ts | 6 + .../src/client/hosted-execution.ts | 15 +-- .../src/protocol/hosted-execution.ts | 28 +++++ packages/runtime-host/src/protocol/index.ts | 4 +- .../server/hosted-execution-coordinator.ts | 70 +++++++++-- 10 files changed, 322 insertions(+), 53 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts index 477757f534..f0053e9dfe 100644 --- a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts @@ -209,6 +209,7 @@ describe('Runtime Host operator commands', () => { 'access.credential.rotation.prepare', 'access.credential.rotation.revoke', 'host.upgrade.prepare', + 'hosted.execution.admit', 'hosted.execution.cancel', 'hosted.execution.start', ], diff --git a/packages/eval/harbor/relay_agent.py b/packages/eval/harbor/relay_agent.py index 9043565b7b..621cc87242 100644 --- a/packages/eval/harbor/relay_agent.py +++ b/packages/eval/harbor/relay_agent.py @@ -189,24 +189,16 @@ async def run(self, instruction: str, environment: Any, context: Any) -> None: current = asyncio.current_task() if current is not None and hasattr(current, "uncancel"): current.uncancel() - try: - if request is not None and execution is not None: - await self._finalize_cancelled_execution( - environment, - cwd, - scope_path, - execution, - request, - writer, - execution_reported, - ) - except asyncio.CancelledError: - if request is not None and execution is not None: - with contextlib.suppress(Exception): - await _settle_or_destroy( - environment, cwd, scope_path, execution, self._teardown_timeout - ) - raise + if request is not None and execution is not None: + await self._cleanup_cancelled_execution( + environment, + cwd, + scope_path, + execution, + request, + writer, + execution_reported, + ) raise except RelayTransportClosed: if request is not None and execution is not None: @@ -244,6 +236,36 @@ async def run(self, instruction: str, environment: Any, context: Any) -> None: writer.close() await asyncio.wait_for(writer.wait_closed(), timeout=1) + async def _cleanup_cancelled_execution( + self, + environment: Any, + cwd: str, + scope_path: str, + execution: asyncio.Task[Any], + request: dict[str, Any], + writer: Any, + execution_reported: bool, + ) -> None: + try: + await self._finalize_cancelled_execution( + environment, + cwd, + scope_path, + execution, + request, + writer, + execution_reported, + ) + except BaseException: + with contextlib.suppress(Exception): + await asyncio.wait_for( + _settle_or_destroy( + environment, cwd, scope_path, execution, self._teardown_timeout + ), + timeout=self._teardown_timeout, + ) + raise + async def _finalize_cancelled_execution( self, environment: Any, diff --git a/packages/eval/harbor/test_relay_lifecycle.py b/packages/eval/harbor/test_relay_lifecycle.py index f211c2ec2d..4cc33d442f 100644 --- a/packages/eval/harbor/test_relay_lifecycle.py +++ b/packages/eval/harbor/test_relay_lifecycle.py @@ -100,6 +100,17 @@ async def exec(self, command, cwd=None, timeout_sec=None): return SimpleNamespace(return_code=0, stdout="", stderr="") +class DummyWriter: + def is_closing(self): + return False + + def write(self, _value): + return None + + async def drain(self): + return None + + class ClosedWriter: def is_closing(self): return False @@ -205,6 +216,20 @@ async def exec(self, command, cwd=None, timeout_sec=None): return await super().exec(command, cwd=cwd, timeout_sec=timeout_sec) +class ExplodingSubjectEnvironment: + def __init__(self): + self.stopped = False + + async def exec(self, command, cwd=None, timeout_sec=None): + return SimpleNamespace(return_code=0, stdout="", stderr="") + + async def stop(self, delete=False): + self.stopped = delete + + async def upload_file(self, source, target): + return None + + class SlowLeaderStopEnvironment(IgnoringLeaderEnvironment): def __init__(self): super().__init__() @@ -235,6 +260,19 @@ async def exec(self, command, cwd=None, timeout_sec=None): return await super().exec(command, cwd=cwd, timeout_sec=timeout_sec) +class PersistFailureEnvironment(LiveScopeEnvironment): + def __init__(self): + super().__init__() + self.stopped = False + + async def stop(self, delete=False): + self.stopped = delete + + async def upload_file(self, source, target): + if str(target).endswith("maka-subject.stdout.txt"): + raise RuntimeError("persist failed") + + class TransportLossEnvironment(SimultaneousEnvironment): async def exec(self, command, cwd=None, timeout_sec=None): if "kill -TERM" in command: @@ -751,6 +789,87 @@ async def accept(reader, writer): server.close() await server.wait_closed() + async def test_execution_exception_during_host_abort_still_destroys(self): + relay = load_relay() + environment = ExplodingSubjectEnvironment() + + async def boom(): + raise RuntimeError("subject execution exploded") + + execution = asyncio.create_task(boom()) + await asyncio.sleep(0) + relay.request_host_teardown() + agent = relay.RelayAgent( + logs_dir=Path(tempfile.gettempdir()), + relay_host="127.0.0.1", + relay_port=1, + relay_token="token", + teardown_timeout_ms=1_000, + ) + with self.assertRaisesRegex(RuntimeError, "subject execution exploded"): + await agent._cleanup_cancelled_execution( + environment, + "/", + "/tmp/missing", + execution, + {"resultToken": "0" * 32, "captureStdout": True}, + DummyWriter(), + False, + ) + self.assertTrue(environment.stopped) + + async def test_persistence_failure_during_host_abort_still_destroys(self): + relay = load_relay() + environment = PersistFailureEnvironment() + token = f"host-persist-{os.getpid()}" + connected = asyncio.get_running_loop().create_future() + + async def accept(reader, writer): + connected.set_result((reader, writer)) + + server = await asyncio.start_server(accept, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + agent = relay.RelayAgent( + logs_dir=Path(tempfile.gettempdir()), + relay_host="127.0.0.1", + relay_port=port, + relay_token=token, + teardown_timeout_ms=1_000, + ) + running = asyncio.create_task(agent.run("solve", environment, None)) + reader, writer = await connected + try: + await reader.readline() + writer.write( + ( + __import__("json").dumps( + { + "token": token, + "kind": "execute", + "command": "/bin/true", + "args": [], + "credentials": {}, + "resultToken": "0" * 32, + } + ) + + "\n" + ).encode() + ) + await writer.drain() + environment.release.set() + await environment.finished.wait() + relay.request_host_teardown() + running.cancel() + with self.assertRaisesRegex(RuntimeError, "persist failed"): + await asyncio.wait_for(running, timeout=1) + self.assertTrue( + any(_is_teardown(command) for command in environment.commands), + ) + finally: + writer.close() + server.close() + await server.wait_closed() + async def test_framework_timeout_stops_the_subject_without_the_process_group(self): relay = load_relay() environment = TimeoutScopeEnvironment() diff --git a/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts index aedd873f3c..88e189db35 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts @@ -113,18 +113,21 @@ test('post-connect cancellation reports the owned Host settlement outcome', asyn test('environment-preserving abort detaches without cancelling or settling the Host', async () => { const abort = new AbortController(); - const started = deferred(); + const admitted = deferred(); const closed = deferred(); const events: string[] = []; const connected = ownedHost({ request: async (operation: string) => { events.push(operation); + if (operation === 'hosted.execution.admit') { + admitted.resolve(); + return { executionId: ID, admissionToken: ADMISSION_TOKEN }; + } if (operation !== 'hosted.execution.start') { throw new Error(`Unexpected operation ${operation}`); } - started.resolve(); await closed.promise; - throw admittedInterrupt(); + throw dispatchedInterrupt(); }, }); connected.connection.close = async () => { @@ -143,16 +146,21 @@ test('environment-preserving abort detaches without cancelling or settling the H { ...input(abort.signal), abortPolicy: 'preserve_environment' }, { connectOwnedRuntimeHost: async () => connected as never }, ); - await started.promise; + await admitted.promise; abort.abort(); const result = await execution; assert.equal(result.kind, 'indeterminate'); assert.equal(result.failureReason, 'Hosted execution continues for environment verification'); - assert.deepEqual(events, ['hosted.execution.start', 'close', 'release']); + assert.deepEqual(events, [ + 'hosted.execution.admit', + 'close', + 'hosted.execution.start', + 'release', + ]); }); -test('pre-admission preserve abort does not claim execution continues', async () => { +test('frame-written admit interruption is not a server admission', async () => { const abort = new AbortController(); const started = deferred(); const closed = deferred(); @@ -160,12 +168,12 @@ test('pre-admission preserve abort does not claim execution continues', async () const connected = ownedHost({ request: async (operation: string) => { events.push(operation); - if (operation !== 'hosted.execution.start') { + if (operation !== 'hosted.execution.admit') { throw new Error(`Unexpected operation ${operation}`); } started.resolve(); await closed.promise; - throw queuedInterrupt(); + throw dispatchedInterrupt(); }, }); connected.connection.close = async () => { @@ -191,7 +199,7 @@ test('pre-admission preserve abort does not claim execution continues', async () assert.equal(result.kind, 'indeterminate'); assert.equal(result.failureReason, 'Hosted execution was cancelled'); - assert.deepEqual(events, ['hosted.execution.start', 'close', 'settle']); + assert.deepEqual(events, ['hosted.execution.admit', 'close', 'settle']); }); test('environment-preserving abort before start does not claim execution continues', async () => { @@ -341,6 +349,7 @@ test('explicit target reconnect cancellation preserves the cancelled outcome', a const ID = '00000000-0000-4000-8000-000000000001'; const CONNECTION_ID = '00000000-0000-4000-8000-000000000002'; +const ADMISSION_TOKEN = '00000000-0000-4000-8000-0000000000ad'; function input(signal?: AbortSignal) { return { @@ -477,24 +486,15 @@ function ownedHost(connection: Record, clean = false) { }; } -function admittedInterrupt() { +function dispatchedInterrupt() { return new RuntimeHostRequestInterruptedError( - 'hosted.execution.start', + 'hosted.execution.admit', 'command', 'dispatched', 'connection_lost', ); } -function queuedInterrupt() { - return new RuntimeHostRequestInterruptedError( - 'hosted.execution.start', - 'command', - 'not_dispatched', - 'connection_lost', - ); -} - function deferred() { let resolve!: () => void; const promise = new Promise((accept) => { diff --git a/packages/runtime-host/src/__tests__/hosted-execution-coordinator.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-coordinator.test.ts index ed448e41dc..e3bec006c1 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-coordinator.test.ts @@ -43,6 +43,32 @@ test('cancel before start prevents the hosted execution from running', async () assert.equal(runs, 0); }); +test('admit returns a server-owned token before start waits for settlement', async () => { + const release = deferred(); + const coordinator = new HostHostedExecutionCoordinator( + async (input) => { + await release.promise; + return settled(input.executionId, 'completed'); + }, + () => {}, + ); + + const admitted = await coordinator.handlers['hosted.execution.admit'](input(), context()); + assert.equal(admitted.ok, true); + if (!admitted.ok) return; + assert.equal(admitted.result.executionId, ID); + assert.match(admitted.result.admissionToken, /^[0-9a-f-]{36}$/i); + + const waiting = coordinator.handlers['hosted.execution.start'](input(), context()); + const again = await coordinator.handlers['hosted.execution.admit'](input(), context()); + assert.equal(again.ok, true); + if (again.ok) assert.equal(again.result.admissionToken, admitted.result.admissionToken); + release.resolve(); + const started = await waiting; + assert.equal(started.ok, true); + if (started.ok) assert.equal(started.result.kind, 'settled'); +}); + test('cancelling a settled subject reclaims its verification environment', async () => { let drains = 0; const coordinator = new HostHostedExecutionCoordinator( @@ -97,3 +123,11 @@ function context() { acquireResidency: () => ({ release() {} }), }; } + +function deferred() { + let resolve!: () => void; + const promise = new Promise((accept) => { + resolve = accept; + }); + return { promise, resolve }; +} diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 7bcbdbc7ae..90545a5fc4 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -97,6 +97,12 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 22); }); + test('publishes a new compatibility epoch for hosted.execution.admit', () => { + // Epoch 25 has start and cancel only. Mixed-version peers must fail + // during handshake instead of sending an unknown admit command. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 25); + }); + test('rejects the legacy connection update result in the current compatibility epoch', () => { assert.throws( () => diff --git a/packages/runtime-host/src/client/hosted-execution.ts b/packages/runtime-host/src/client/hosted-execution.ts index 1d8c886d05..93587f5810 100644 --- a/packages/runtime-host/src/client/hosted-execution.ts +++ b/packages/runtime-host/src/client/hosted-execution.ts @@ -25,7 +25,7 @@ import { type HostedExecutionStartInput, } from '../protocol/index.js'; import { connectOwnedRuntimeHost } from './connect-or-spawn.js'; -import { RuntimeHostRequestInterruptedError, type RuntimeHostConnection } from './connection.js'; +import { type RuntimeHostConnection } from './connection.js'; import { configureHostedExecutionTarget } from './hosted-execution-target.js'; export interface RunHostedExecutionInput { @@ -172,9 +172,14 @@ async function executeHostedExecution( detached: false, }; } + let admissionToken: string | undefined; try { + if (abortPolicy === 'preserve_environment') { + const admission = await connection.request('hosted.execution.admit', execution); + admissionToken = admission.admissionToken; + } const projection = await connection.request('hosted.execution.start', execution); - if (signal?.aborted && abortPolicy === 'preserve_environment') { + if (signal?.aborted && abortPolicy === 'preserve_environment' && admissionToken) { host.releaseToEnvironment(); return { projection: preservesHostedExecutionEnvironment(projection) @@ -189,7 +194,7 @@ async function executeHostedExecution( return { projection, detached: false }; } catch (error) { if (signal?.aborted && abortPolicy === 'preserve_environment') { - if (isAdmittedInterrupt(error)) { + if (admissionToken) { host.releaseToEnvironment(); return { projection: indeterminate( @@ -211,10 +216,6 @@ async function executeHostedExecution( } } -function isAdmittedInterrupt(error: unknown): boolean { - return error instanceof RuntimeHostRequestInterruptedError && error.dispatch === 'dispatched'; -} - function indeterminate(executionId: string, failureReason: string): HostedExecutionProjection { return { executionId, kind: 'indeterminate', failureReason }; } diff --git a/packages/runtime-host/src/protocol/hosted-execution.ts b/packages/runtime-host/src/protocol/hosted-execution.ts index f1fee49dea..6cf2344649 100644 --- a/packages/runtime-host/src/protocol/hosted-execution.ts +++ b/packages/runtime-host/src/protocol/hosted-execution.ts @@ -50,6 +50,11 @@ export interface HostedExecutionReferenceInput { readonly executionId: string; } +export interface HostedExecutionAdmissionAck { + readonly executionId: string; + readonly admissionToken: string; +} + export interface HostedExecutionUsage { readonly inputTokens: number; readonly outputTokens: number; @@ -84,6 +89,18 @@ export function preservesHostedExecutionEnvironment( } export const HOSTED_EXECUTION_OPERATION_SPECS = { + 'hosted.execution.admit': defineOperation< + HostedExecutionStartInput, + HostedExecutionAdmissionAck, + (typeof ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: ERRORS, + usesHostPaths: () => true, + decodeInput: decodeHostedExecutionStartInput, + decodeOutput: decodeHostedExecutionAdmissionAck, + }), 'hosted.execution.start': defineOperation< HostedExecutionStartInput, HostedExecutionProjection, @@ -136,6 +153,17 @@ export function decodeHostedExecutionReferenceInput(value: unknown): HostedExecu return { executionId: requireEntityId(input.executionId, 'executionId') }; } +export function decodeHostedExecutionAdmissionAck(value: unknown): HostedExecutionAdmissionAck { + const ack = requireExactRecord(value, 'Hosted execution admission', [ + 'executionId', + 'admissionToken', + ]); + return { + executionId: requireEntityId(ack.executionId, 'executionId'), + admissionToken: requireEntityId(ack.admissionToken, 'admissionToken'), + }; +} + export function decodeHostedExecutionProjection(value: unknown): HostedExecutionProjection { const result = requireRecord(value, 'Hosted execution projection'); const executionId = requireEntityId(result.executionId, 'executionId'); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 0edebdf8df..f6198a5070 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -91,7 +91,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 46 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 47 as const; +// 47: Hosted execution adds an explicit server admission operation. Older +// peers cannot prove admission before preserve-detach cleanup. // 46: Queued message content can be edited in place (queue.entry.update). // 45: Connection onboarding inputs require `baseUrl` and `connectionId`, and // results can carry the `base_url_not_configured` / `connection_not_found` diff --git a/packages/runtime-host/src/server/hosted-execution-coordinator.ts b/packages/runtime-host/src/server/hosted-execution-coordinator.ts index e8307b0813..fd4648b374 100644 --- a/packages/runtime-host/src/server/hosted-execution-coordinator.ts +++ b/packages/runtime-host/src/server/hosted-execution-coordinator.ts @@ -17,6 +17,7 @@ * under the License. */ +import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import { preservesHostedExecutionEnvironment } from '../protocol/index.js'; import type { @@ -27,8 +28,17 @@ import type { } from '../protocol/index.js'; import type { HostedExecutionOperationHandlerMap } from './operation-dispatcher.js'; +type EnsuredHostedExecution = + | { + readonly ok: true; + readonly admissionToken: string; + readonly task: Promise; + } + | { readonly ok: false; readonly outcome: OperationOutcome<'hosted.execution.start'> }; + export class HostHostedExecutionCoordinator { readonly handlers: HostedExecutionOperationHandlerMap = { + 'hosted.execution.admit': (input) => this.#admit(input), 'hosted.execution.start': (input) => this.#start(input), 'hosted.execution.cancel': (input) => this.#cancel(input), }; @@ -38,6 +48,7 @@ export class HostHostedExecutionCoordinator { { readonly input: HostedExecutionStartInput; readonly abort: AbortController; + readonly admissionToken: string; readonly task: Promise; } >(); @@ -62,25 +73,65 @@ export class HostHostedExecutionCoordinator { await Promise.all([...this.#executions.values()].map(({ task }) => task)); } + async #admit( + input: HostedExecutionStartInput, + ): Promise> { + const ensured = this.#ensureExecution(input); + if (!ensured.ok) { + return ensured.outcome.ok + ? { + ok: false, + error: { + code: 'invalid_request', + message: 'Hosted execution was cancelled before admission', + }, + } + : ensured.outcome; + } + return { + ok: true, + result: { executionId: input.executionId, admissionToken: ensured.admissionToken }, + }; + } + async #start( input: HostedExecutionStartInput, ): Promise> { + const ensured = this.#ensureExecution(input); + if (!ensured.ok) return ensured.outcome; + return { ok: true, result: structuredClone(await ensured.task) }; + } + + #ensureExecution(input: HostedExecutionStartInput): EnsuredHostedExecution { if (this.#cancelled.has(input.executionId)) { this.requestDrain(); return { - ok: true, - result: indeterminate(input.executionId, 'Hosted execution was cancelled before admission'), + ok: false, + outcome: { + ok: true, + result: indeterminate( + input.executionId, + 'Hosted execution was cancelled before admission', + ), + }, }; } const existing = this.#executions.get(input.executionId); if (existing) { - if (!isDeepStrictEqual(existing.input, input)) return conflict(); - return { ok: true, result: structuredClone(await existing.task) }; + if (!isDeepStrictEqual(existing.input, input)) return { ok: false, outcome: conflict() }; + return { ok: true, admissionToken: existing.admissionToken, task: existing.task }; } if (!this.#accepting) { - return { ok: false, error: { code: 'host_draining', message: 'Runtime Host is draining' } }; + return { + ok: false, + outcome: { + ok: false, + error: { code: 'host_draining', message: 'Runtime Host is draining' }, + }, + }; } const abort = new AbortController(); + const admissionToken = randomUUID(); const task = this.run(input, abort.signal) .catch(() => indeterminate(input.executionId, 'Runtime Host could not settle execution')) .then((result) => { @@ -90,8 +141,13 @@ export class HostHostedExecutionCoordinator { .finally(() => { this.#executions.delete(input.executionId); }); - this.#executions.set(input.executionId, { input: structuredClone(input), abort, task }); - return { ok: true, result: structuredClone(await task) }; + this.#executions.set(input.executionId, { + input: structuredClone(input), + abort, + admissionToken, + task, + }); + return { ok: true, admissionToken, task }; } async #cancel( From 647e58f0d3c7aabde9d35b9a6a209c279f55b9cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Sun, 23 Aug 2026 21:13:27 +0800 Subject: [PATCH 08/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=89=98=E7=AE=A1?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E5=87=86=E5=85=A5=E4=B8=8E=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 12 + .../harbor/test_harbor_trial_lifecycle.py | 375 ++++++++++++++++++ packages/eval/package.json | 2 +- .../__tests__/execution-composition.test.ts | 25 +- .../execution-model-composition.test.ts | 36 +- .../__tests__/hosted-execution-client.test.ts | 20 +- .../hosted-execution-coordinator.test.ts | 116 +++++- .../hosted-execution-tool-profile.test.ts | 26 +- .../src/client/hosted-execution.ts | 11 +- .../src/protocol/hosted-execution.ts | 22 +- packages/runtime-host/src/protocol/index.ts | 5 +- .../server/hosted-execution-coordinator.ts | 162 +++++--- scripts/ci-test-plan.test.mjs | 16 + 13 files changed, 718 insertions(+), 110 deletions(-) create mode 100644 packages/eval/harbor/test_harbor_trial_lifecycle.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3367e20fe..0a45d51802 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -204,6 +204,18 @@ jobs: packages/eval/harbor npm --workspace @maka/eval run test:egress-proxy:live + - name: Install Harbor lifecycle test dependencies + if: contains(steps.plan.outputs.standard_workspaces, 'packages/eval') + run: | + python3 -m venv "$RUNNER_TEMP/maka-harbor-lifecycle" + "$RUNNER_TEMP/maka-harbor-lifecycle/bin/python" -m pip install --disable-pip-version-check 'harbor==0.20.0' + + - name: Run real Harbor lifecycle tests + if: contains(steps.plan.outputs.standard_workspaces, 'packages/eval') + env: + MAKA_EVAL_HARBOR_LIFECYCLE_TEST: '1' + run: "$RUNNER_TEMP/maka-harbor-lifecycle/bin/python" packages/eval/harbor/test_harbor_trial_lifecycle.py + - name: Run Runtime Host tests if: steps.plan.outputs.runtime_host == 'true' run: npm --workspace @maka/runtime-host run test:dist diff --git a/packages/eval/harbor/test_harbor_trial_lifecycle.py b/packages/eval/harbor/test_harbor_trial_lifecycle.py new file mode 100644 index 0000000000..aa97584de0 --- /dev/null +++ b/packages/eval/harbor/test_harbor_trial_lifecycle.py @@ -0,0 +1,375 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +import asyncio +import importlib.metadata +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +import uuid +from pathlib import Path +from typing import Any + + +ENABLED = os.environ.get("MAKA_EVAL_HARBOR_LIFECYCLE_TEST") == "1" +HARBOR_VERSION = "0.20.0" +RELAY_TOKEN = "0" * 32 +RESULT_TOKEN = "1" * 32 +ROOT = Path(__file__).resolve().parent + + +@unittest.skipUnless(ENABLED, "real Harbor lifecycle test is opt-in") +class HarborTrialLifecycleTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + if shutil.which("docker") is None: + raise AssertionError("Docker CLI is required for the real Harbor lifecycle test") + if importlib.metadata.version("harbor") != HARBOR_VERSION: + raise AssertionError(f"Harbor {HARBOR_VERSION} is required") + _docker("info", "--format", "{{.ServerVersion}}") + _docker("compose", "version") + + if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + import eval_framework + + eval_framework.install("harbor") + + def setUp(self) -> None: + import relay_agent + + relay_agent._host_teardown_requested = False + + def test_framework_timeout_preserves_background_for_verifier(self) -> None: + asyncio.run(self._framework_timeout_preserves_background_for_verifier()) + + def test_host_abort_destroys_background_environment(self) -> None: + asyncio.run(self._host_abort_destroys_background_environment()) + + async def _framework_timeout_preserves_background_for_verifier(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + task_dir = _write_task(root) + exchange = RelayExchange(wait_for_verification=True) + async with exchange.serve() as port: + trial = await _create_trial( + task_dir, + root / "trials", + _trial_name("framework-timeout"), + port, + timeout_sec=1.0, + ) + result = await asyncio.wait_for(trial.run(), timeout=120) + + self.assertIsNone(result.exception_info) + self.assertIsNotNone(result.verifier_result) + assert result.verifier_result is not None + self.assertEqual(result.verifier_result.rewards, {"reward": 1.0}) + self.assertIsNotNone(exchange.executed) + assert exchange.executed is not None + self.assertEqual(exchange.executed["termination"], "framework_timeout") + self.assertEqual(exchange.executed["exitCode"], 124) + self.assertIsNone(exchange.error) + self.assertEqual(_project_containers(trial.config.trial_name), []) + + async def _host_abort_destroys_background_environment(self) -> None: + from relay_agent import request_host_teardown + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + task_dir = _write_task(root) + trial_name = _trial_name("host-abort") + exchange = RelayExchange(wait_for_verification=False) + trial_task: asyncio.Task[Any] | None = None + container_id: str | None = None + async with exchange.serve() as port: + trial = await _create_trial( + task_dir, + root / "trials", + trial_name, + port, + timeout_sec=60.0, + ) + try: + trial_task = asyncio.create_task(trial.run()) + await asyncio.wait_for(exchange.execute_sent.wait(), timeout=60) + container_id = await _wait_for_subject(trial_name) + + request_host_teardown() + trial_task.cancel() + with self.assertRaises(asyncio.CancelledError): + await asyncio.wait_for(trial_task, timeout=30) + await _wait_for_container_removal(container_id) + finally: + if trial_task is not None and not trial_task.done(): + request_host_teardown() + trial_task.cancel() + try: + await asyncio.wait_for(trial_task, timeout=30) + except (asyncio.CancelledError, TimeoutError): + pass + _remove_project_containers(trial_name) + + self.assertIsNone(exchange.executed) + self.assertIsNone(exchange.error) + self.assertEqual(_project_containers(trial_name), []) + + +class RelayExchange: + def __init__(self, *, wait_for_verification: bool): + self.wait_for_verification = wait_for_verification + self.execute_sent = asyncio.Event() + self.executed: dict[str, Any] | None = None + self.error: BaseException | None = None + self._connections = 0 + self._server: asyncio.Server | None = None + + class _Serving: + def __init__(self, exchange: RelayExchange): + self.exchange = exchange + + async def __aenter__(self) -> int: + server = await asyncio.start_server(self.exchange._handle, "127.0.0.1", 0) + self.exchange._server = server + socket = server.sockets[0] + return int(socket.getsockname()[1]) + + async def __aexit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: + assert self.exchange._server is not None + self.exchange._server.close() + await self.exchange._server.wait_closed() + + def serve(self) -> RelayExchange._Serving: + return self._Serving(self) + + async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + self._connections += 1 + try: + if self._connections != 1: + raise AssertionError("Harbor trial opened more than one relay connection") + ready = await _read_message(reader) + if ready.get("token") != RELAY_TOKEN or ready.get("kind") != "ready": + raise AssertionError(f"invalid relay ready frame: {ready!r}") + await _write_message( + writer, + { + "token": RELAY_TOKEN, + "kind": "execute", + "command": "/bin/sh", + "args": ["-c", _subject_script()], + "credentials": {}, + "environment": {}, + "resultToken": RESULT_TOKEN, + "captureStdout": False, + }, + ) + self.execute_sent.set() + if not self.wait_for_verification: + await reader.read() + return + + executed = await _read_message(reader) + if executed.get("token") != RELAY_TOKEN or executed.get("kind") != "executed": + raise AssertionError(f"invalid relay execution frame: {executed!r}") + self.executed = executed + await _write_message(writer, {"token": RELAY_TOKEN, "kind": "verify"}) + await reader.read() + except BaseException as error: + self.error = error + finally: + writer.close() + await writer.wait_closed() + + +async def _create_trial( + task_dir: Path, + trials_dir: Path, + trial_name: str, + relay_port: int, + *, + timeout_sec: float, +) -> Any: + from harbor.models.trial.config import TrialConfig + from harbor.trial.trial import Trial + + config = TrialConfig.model_validate( + { + "task": {"path": str(task_dir)}, + "trial_name": trial_name, + "trials_dir": str(trials_dir), + "agent": { + "import_path": "relay_agent:RelayAgent", + "override_timeout_sec": timeout_sec, + "kwargs": { + "relay_host": "127.0.0.1", + "relay_port": relay_port, + "relay_token": RELAY_TOKEN, + "teardown_timeout_ms": 5000, + }, + }, + "environment": {"type": "docker", "delete": True}, + "verifier": {"override_timeout_sec": 10}, + } + ) + return await Trial.create(config) + + +def _write_task(root: Path) -> Path: + task = root / "task" + (task / "environment").mkdir(parents=True) + (task / "tests").mkdir() + (task / "instruction.md").write_text("Keep a background service running.\n") + (task / "task.toml").write_text( + '\n'.join( + [ + 'version = "1.0"', + "", + "[agent]", + "timeout_sec = 60.0", + "", + "[verifier]", + "timeout_sec = 10.0", + "", + "[environment]", + "build_timeout_sec = 60.0", + "", + ] + ) + ) + (task / "environment" / "Dockerfile").write_text( + "FROM ubuntu:24.04\n" + "RUN command -v setsid >/dev/null\n" + "WORKDIR /app\n" + ) + verifier = task / "tests" / "test.sh" + verifier.write_text( + "#!/bin/sh\n" + "reward=0\n" + "if test -s /tmp/maka-background.pid && " + "kill -0 \"$(cat /tmp/maka-background.pid)\" 2>/dev/null; then\n" + " before=$(wc -c < /tmp/maka-background.heartbeat 2>/dev/null || printf 0)\n" + " sleep 0.4\n" + " after=$(wc -c < /tmp/maka-background.heartbeat 2>/dev/null || printf 0)\n" + " if test \"$after\" -gt \"$before\"; then reward=1; fi\n" + "fi\n" + "printf '%s\\n' \"$reward\" > /logs/verifier/reward.txt\n" + ) + verifier.chmod(0o755) + return task + + +def _subject_script() -> str: + return ( + "trap 'exit 0' TERM; " + "(trap '' TERM; while :; do printf x >> /tmp/maka-background.heartbeat; " + "sleep 0.1; done) & " + "child=$!; printf '%s\\n' \"$child\" > /tmp/maka-background.pid; " + "while :; do sleep 1; done" + ) + + +async def _read_message(reader: asyncio.StreamReader) -> dict[str, Any]: + raw = await asyncio.wait_for(reader.readline(), timeout=30) + if not raw: + raise AssertionError("relay connection closed before the expected frame") + value = json.loads(raw) + if not isinstance(value, dict): + raise AssertionError(f"relay frame is not an object: {value!r}") + return value + + +async def _write_message(writer: asyncio.StreamWriter, value: dict[str, Any]) -> None: + writer.write(json.dumps(value, separators=(",", ":")).encode() + b"\n") + await writer.drain() + + +async def _wait_for_subject(trial_name: str) -> str: + deadline = asyncio.get_running_loop().time() + 60 + while asyncio.get_running_loop().time() < deadline: + containers = _project_containers(trial_name) + if len(containers) == 1: + container_id = containers[0] + ready = subprocess.run( + ["docker", "exec", container_id, "sh", "-c", "test -s /tmp/maka-background.pid"], + check=False, + capture_output=True, + text=True, + ) + if ready.returncode == 0: + return container_id + await asyncio.sleep(0.1) + raise AssertionError(f"subject did not start in Harbor project {trial_name!r}") + + +async def _wait_for_container_removal(container_id: str) -> None: + deadline = asyncio.get_running_loop().time() + 20 + while asyncio.get_running_loop().time() < deadline: + inspected = subprocess.run( + ["docker", "inspect", container_id], + check=False, + capture_output=True, + text=True, + ) + if inspected.returncode != 0: + return + await asyncio.sleep(0.1) + raise AssertionError(f"Harbor container {container_id} survived Host abort") + + +def _project_containers(trial_name: str) -> list[str]: + project = f"{trial_name}__env" + result = _docker( + "ps", + "--all", + "--quiet", + "--filter", + f"label=com.docker.compose.project={project}", + "--filter", + "label=com.docker.compose.service=main", + ) + return [line for line in result.splitlines() if line] + + +def _remove_project_containers(trial_name: str) -> None: + containers = _project_containers(trial_name) + if containers: + _docker("rm", "--force", *containers) + + +def _docker(*args: str) -> str: + completed = subprocess.run( + ["docker", *args], + check=True, + capture_output=True, + text=True, + timeout=30, + ) + return completed.stdout.strip() + + +def _trial_name(prefix: str) -> str: + return f"maka-{prefix}-{uuid.uuid4().hex[:12]}" + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/eval/package.json b/packages/eval/package.json index f23d7fce60..e66c4aca47 100644 --- a/packages/eval/package.json +++ b/packages/eval/package.json @@ -34,7 +34,7 @@ "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo", "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", - "test:dist": "node --test \"dist/**/*.test.js\" && python3 harbor/test_eval_framework.py && python3 harbor/test_relay_contract.py && python3 harbor/test_relay_lifecycle.py && python3 harbor/test_egress_filter.py && python3 harbor/test_run_trial_policy.py && python3 harbor/test_relay_artifacts.py && python3 harbor/test_cell_egress_namespace.py", + "test:dist": "node --test \"dist/**/*.test.js\" && python3 harbor/test_eval_framework.py && python3 harbor/test_relay_contract.py && python3 harbor/test_relay_lifecycle.py && python3 harbor/test_harbor_trial_lifecycle.py && python3 harbor/test_egress_filter.py && python3 harbor/test_run_trial_policy.py && python3 harbor/test_relay_artifacts.py && python3 harbor/test_cell_egress_namespace.py", "test:egress-proxy:live": "python3 harbor/test_egress_filter_live.py" }, "dependencies": { diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 67180218e0..255718fed8 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -349,16 +349,23 @@ test('hosted execution settles while its tracked environment resource remains ve try { await composition.recover(); const executionId = '00000000-0000-4000-8000-000000000111'; - const execution = composition.handlers['hosted.execution.start']( - { - executionId, - session: { - workspace: { kind: 'host_path', path: root }, - modelTarget: { kind: 'default' }, - name: 'Hosted environment test', - }, - content: { text: 'leave the environment ready for verification' }, + const executionInput = { + executionId, + session: { + workspace: { kind: 'host_path' as const, path: root }, + modelTarget: { kind: 'default' as const }, + name: 'Hosted environment test', }, + content: { text: 'leave the environment ready for verification' }, + }; + const admitted = await composition.handlers['hosted.execution.admit']( + executionInput, + operationContext, + ); + assert.equal(admitted.ok, true); + if (!admitted.ok) return; + const execution = composition.handlers['hosted.execution.start']( + { execution: executionInput, admissionToken: admitted.result.admissionToken }, operationContext, ); let settled = false; diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 424eaa35a6..539f9928f5 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -888,24 +888,28 @@ test('hosted execution freezes the headless coding provider wire contract', asyn ); await composition.recover(); const executionId = '00000000-0000-4000-8000-000000000777'; - const outcome = await composition.handlers['hosted.execution.start']( - { - executionId, - session: { - workspace: { kind: 'host_path', path: root }, - modelTarget: { - kind: 'explicit', - connectionSlug: 'profile-deepseek', - model: 'deepseek-v4-flash', - }, - permissionMode: 'bypass', - collaborationMode: 'agent', - orchestrationMode: 'default', - toolProfile: 'headless-coding-v1', + const executionInput = { + executionId, + session: { + workspace: { kind: 'host_path' as const, path: root }, + modelTarget: { + kind: 'explicit' as const, + connectionSlug: 'profile-deepseek', + model: 'deepseek-v4-flash', }, - content: { text: 'Complete the benchmark task.' }, - maxSteps: 100_000, + permissionMode: 'bypass' as const, + collaborationMode: 'agent' as const, + orchestrationMode: 'default' as const, + toolProfile: 'headless-coding-v1' as const, }, + content: { text: 'Complete the benchmark task.' }, + maxSteps: 100_000, + }; + const admitted = await composition.handlers['hosted.execution.admit'](executionInput, context); + assert.equal(admitted.ok, true); + if (!admitted.ok) return; + const outcome = await composition.handlers['hosted.execution.start']( + { execution: executionInput, admissionToken: admitted.result.admissionToken }, context, ); assert.equal(outcome.ok, true); diff --git a/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts index 88e189db35..ff767b8e3f 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-client.test.ts @@ -26,7 +26,8 @@ test('diagnostics disconnect after settlement preserves the canonical result', a for (const status of ['completed', 'failed'] as const) { const projection = settled(status); const connected = ownedHost({ - request: async () => projection, + request: async (operation: string) => + operation === 'hosted.execution.admit' ? admission() : projection, queryHostDiagnostics: async () => { throw new Error('connection closed'); }, @@ -45,6 +46,7 @@ test('abort observed with a completed response does not replace the result', asy const projection = settled('completed'); const connected = ownedHost({ request: async (operation: string) => { + if (operation === 'hosted.execution.admit') return admission(); if (operation === 'hosted.execution.start') { abort.abort(); return projection; @@ -117,15 +119,19 @@ test('environment-preserving abort detaches without cancelling or settling the H const closed = deferred(); const events: string[] = []; const connected = ownedHost({ - request: async (operation: string) => { + request: async (operation: string, requestInput: unknown) => { events.push(operation); if (operation === 'hosted.execution.admit') { admitted.resolve(); - return { executionId: ID, admissionToken: ADMISSION_TOKEN }; + return admission(); } if (operation !== 'hosted.execution.start') { throw new Error(`Unexpected operation ${operation}`); } + assert.deepEqual(requestInput, { + execution: input().execution, + admissionToken: ADMISSION_TOKEN, + }); await closed.promise; throw dispatchedInterrupt(); }, @@ -243,6 +249,7 @@ test('explicit target mutation settles the first Host before execution reconnect const second = ownedHost({ request: async (operation: string) => { events.push(`second:${operation}`); + if (operation === 'hosted.execution.admit') return admission(); assert.equal(operation, 'hosted.execution.start'); return projection; }, @@ -273,6 +280,7 @@ test('explicit target mutation settles the first Host before execution reconnect 'first:close', 'first:settle', 'connect:2', + 'second:hosted.execution.admit', 'second:hosted.execution.start', 'second:close', 'second:release', @@ -286,6 +294,7 @@ test('explicit target already admitted executes without reconnecting', async () request: async (operation: string) => { events.push(operation); if (operation === 'connection.catalog.query') return catalogPage(true); + if (operation === 'hosted.execution.admit') return admission(); if (operation === 'hosted.execution.start') return projection; throw new Error(`Unexpected operation ${operation}`); }, @@ -307,6 +316,7 @@ test('explicit target already admitted executes without reconnecting', async () assert.deepEqual(events, [ 'connection.catalog.query', 'connection.catalog.query', + 'hosted.execution.admit', 'hosted.execution.start', 'release', ]); @@ -351,6 +361,10 @@ const ID = '00000000-0000-4000-8000-000000000001'; const CONNECTION_ID = '00000000-0000-4000-8000-000000000002'; const ADMISSION_TOKEN = '00000000-0000-4000-8000-0000000000ad'; +function admission() { + return { executionId: ID, admissionToken: ADMISSION_TOKEN }; +} + function input(signal?: AbortSignal) { return { rootPath: '/runtime-host', diff --git a/packages/runtime-host/src/__tests__/hosted-execution-coordinator.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-coordinator.test.ts index e3bec006c1..190e0788b8 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-coordinator.test.ts @@ -35,7 +35,10 @@ test('cancel before start prevents the hosted execution from running', async () { executionId: ID }, context(), ); - const started = await coordinator.handlers['hosted.execution.start'](input(), context()); + const started = await coordinator.handlers['hosted.execution.start']( + { execution: input(), admissionToken: TOKEN }, + context(), + ); assert.equal(cancelled.ok, true); assert.equal(started.ok, true); @@ -59,7 +62,10 @@ test('admit returns a server-owned token before start waits for settlement', asy assert.equal(admitted.result.executionId, ID); assert.match(admitted.result.admissionToken, /^[0-9a-f-]{36}$/i); - const waiting = coordinator.handlers['hosted.execution.start'](input(), context()); + const waiting = coordinator.handlers['hosted.execution.start']( + { execution: input(), admissionToken: admitted.result.admissionToken }, + context(), + ); const again = await coordinator.handlers['hosted.execution.admit'](input(), context()); assert.equal(again.ok, true); if (again.ok) assert.equal(again.result.admissionToken, admitted.result.admissionToken); @@ -77,14 +83,112 @@ test('cancelling a settled subject reclaims its verification environment', async drains += 1; }, ); - await coordinator.handlers['hosted.execution.start'](input(), context()); + const admitted = await coordinator.handlers['hosted.execution.admit'](input(), context()); + assert.equal(admitted.ok, true); + if (!admitted.ok) return; + await coordinator.handlers['hosted.execution.start']( + { execution: input(), admissionToken: admitted.result.admissionToken }, + context(), + ); await coordinator.handlers['hosted.execution.cancel']({ executionId: ID }, context()); assert.equal(drains, 1); }); +test('start without server admission is rejected without running', async () => { + let runs = 0; + const coordinator = new HostHostedExecutionCoordinator( + async (execution) => { + runs += 1; + return settled(execution.executionId, 'completed'); + }, + () => {}, + ); + + const started = await coordinator.handlers['hosted.execution.start']( + { execution: input(), admissionToken: TOKEN }, + context(), + ); + + assert.equal(started.ok, false); + if (!started.ok) assert.equal(started.error.code, 'invalid_request'); + assert.equal(runs, 0); +}); + +test('wrong admission token cannot claim an admitted execution', async () => { + let runs = 0; + const coordinator = new HostHostedExecutionCoordinator( + async (execution) => { + runs += 1; + return settled(execution.executionId, 'completed'); + }, + () => {}, + ); + const admitted = await coordinator.handlers['hosted.execution.admit'](input(), context()); + assert.equal(admitted.ok, true); + if (admitted.ok) assert.notEqual(admitted.result.admissionToken, TOKEN); + + const started = await coordinator.handlers['hosted.execution.start']( + { execution: input(), admissionToken: TOKEN }, + context(), + ); + + assert.equal(started.ok, false); + if (!started.ok) assert.equal(started.error.code, 'operation_conflict'); + assert.equal(runs, 1); +}); + +test('fast settlement remains cached and cannot execute twice', async () => { + let runs = 0; + const coordinator = new HostHostedExecutionCoordinator( + async (execution) => { + runs += 1; + return settled(execution.executionId, 'completed'); + }, + () => {}, + ); + const admitted = await coordinator.handlers['hosted.execution.admit'](input(), context()); + assert.equal(admitted.ok, true); + if (!admitted.ok) return; + await new Promise((resolve) => setImmediate(resolve)); + + const startInput = { execution: input(), admissionToken: admitted.result.admissionToken }; + const first = await coordinator.handlers['hosted.execution.start'](startInput, context()); + const replay = await coordinator.handlers['hosted.execution.start'](startInput, context()); + + assert.equal(first.ok, true); + assert.deepEqual(replay, first); + assert.equal(runs, 1); +}); + +test('admission authority cannot cross connection or Host epoch', async () => { + const coordinator = new HostHostedExecutionCoordinator( + async (execution) => settled(execution.executionId, 'completed'), + () => {}, + ); + const owner = context(); + const admitted = await coordinator.handlers['hosted.execution.admit'](input(), owner); + assert.equal(admitted.ok, true); + if (!admitted.ok) return; + const startInput = { execution: input(), admissionToken: admitted.result.admissionToken }; + + for (const foreign of [ + context({ connectionId: 'other-connection' }), + context({ hostEpoch: 'other-epoch' }), + ]) { + const started = await coordinator.handlers['hosted.execution.start'](startInput, foreign); + assert.equal(started.ok, false); + if (!started.ok) assert.equal(started.error.code, 'operation_conflict'); + + const readmitted = await coordinator.handlers['hosted.execution.admit'](input(), foreign); + assert.equal(readmitted.ok, false); + if (!readmitted.ok) assert.equal(readmitted.error.code, 'operation_conflict'); + } +}); + const ID = '00000000-0000-4000-8000-000000000001'; +const TOKEN = '00000000-0000-4000-8000-0000000000ff'; function settled(executionId: string, status: 'completed' | 'failed') { return { @@ -115,10 +219,10 @@ function input() { }; } -function context() { +function context(overrides: { hostEpoch?: string; connectionId?: string } = {}) { return { - hostEpoch: 'host-epoch', - connectionId: 'hosted-execution', + hostEpoch: overrides.hostEpoch ?? 'host-epoch', + connectionId: overrides.connectionId ?? 'hosted-execution', principal: 'runtime_host' as const, acquireResidency: () => ({ release() {} }), }; diff --git a/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts index 3e3bf291bc..0e8e76bc0e 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts @@ -21,7 +21,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { MakaTool } from '@maka/runtime/tool-runtime'; import { z } from 'zod'; -import { decodeHostedExecutionStartInput } from '../protocol/index.js'; +import { + decodeHostedExecutionAdmittedStartInput, + decodeHostedExecutionStartInput, +} from '../protocol/index.js'; import { hostedExecutionRunProfile, projectHostedExecutionTools, @@ -48,6 +51,27 @@ test('hosted execution tool profiles are durable Session creation inputs', () => ); }); +test('hosted execution start requires the server admission token', () => { + const execution = decodeHostedExecutionStartInput({ + executionId: '00000000-0000-4000-8000-000000000001', + session: { + workspace: { kind: 'host_path', path: '/workspace' }, + modelTarget: { kind: 'explicit', connectionSlug: 'provider', model: 'model' }, + }, + content: { text: 'solve' }, + }); + const decoded = decodeHostedExecutionAdmittedStartInput({ + execution, + admissionToken: '00000000-0000-4000-8000-000000000002', + }); + assert.deepEqual(decoded.execution, execution); + assert.equal(decoded.admissionToken, '00000000-0000-4000-8000-000000000002'); + assert.throws( + () => decodeHostedExecutionAdmittedStartInput({ execution, admissionToken: undefined }), + /admissionToken/u, + ); +}); + test('the headless coding profile freezes prompt, tools, memory, and foreground Bash', async () => { const profile = hostedExecutionRunProfile('headless-coding-v1'); assert.ok(profile); diff --git a/packages/runtime-host/src/client/hosted-execution.ts b/packages/runtime-host/src/client/hosted-execution.ts index 93587f5810..1124c7cab0 100644 --- a/packages/runtime-host/src/client/hosted-execution.ts +++ b/packages/runtime-host/src/client/hosted-execution.ts @@ -174,11 +174,12 @@ async function executeHostedExecution( } let admissionToken: string | undefined; try { - if (abortPolicy === 'preserve_environment') { - const admission = await connection.request('hosted.execution.admit', execution); - admissionToken = admission.admissionToken; - } - const projection = await connection.request('hosted.execution.start', execution); + const admission = await connection.request('hosted.execution.admit', execution); + admissionToken = admission.admissionToken; + const projection = await connection.request('hosted.execution.start', { + execution, + admissionToken, + }); if (signal?.aborted && abortPolicy === 'preserve_environment' && admissionToken) { host.releaseToEnvironment(); return { diff --git a/packages/runtime-host/src/protocol/hosted-execution.ts b/packages/runtime-host/src/protocol/hosted-execution.ts index 6cf2344649..a0aef92bf4 100644 --- a/packages/runtime-host/src/protocol/hosted-execution.ts +++ b/packages/runtime-host/src/protocol/hosted-execution.ts @@ -55,6 +55,11 @@ export interface HostedExecutionAdmissionAck { readonly admissionToken: string; } +export interface HostedExecutionAdmittedStartInput { + readonly execution: HostedExecutionStartInput; + readonly admissionToken: string; +} + export interface HostedExecutionUsage { readonly inputTokens: number; readonly outputTokens: number; @@ -102,7 +107,7 @@ export const HOSTED_EXECUTION_OPERATION_SPECS = { decodeOutput: decodeHostedExecutionAdmissionAck, }), 'hosted.execution.start': defineOperation< - HostedExecutionStartInput, + HostedExecutionAdmittedStartInput, HostedExecutionProjection, (typeof ERRORS)[number] >({ @@ -110,7 +115,7 @@ export const HOSTED_EXECUTION_OPERATION_SPECS = { availability: 'ready', errors: ERRORS, usesHostPaths: () => true, - decodeInput: decodeHostedExecutionStartInput, + decodeInput: decodeHostedExecutionAdmittedStartInput, decodeOutput: decodeHostedExecutionProjection, }), 'hosted.execution.cancel': defineOperation< @@ -148,6 +153,19 @@ export function decodeHostedExecutionStartInput(value: unknown): HostedExecution }; } +export function decodeHostedExecutionAdmittedStartInput( + value: unknown, +): HostedExecutionAdmittedStartInput { + const input = requireExactRecord(value, 'Admitted Hosted execution start input', [ + 'execution', + 'admissionToken', + ]); + return { + execution: decodeHostedExecutionStartInput(input.execution), + admissionToken: requireEntityId(input.admissionToken, 'admissionToken'), + }; +} + export function decodeHostedExecutionReferenceInput(value: unknown): HostedExecutionReferenceInput { const input = requireExactRecord(value, 'Hosted execution reference', ['executionId']); return { executionId: requireEntityId(input.executionId, 'executionId') }; diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index f6198a5070..525b68fb3c 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -92,8 +92,9 @@ export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 47 as const; -// 47: Hosted execution adds an explicit server admission operation. Older -// peers cannot prove admission before preserve-detach cleanup. +// 47: Hosted execution requires a server-issued admission token on start and +// binds it to one Host epoch and connection. Older peers cannot enforce +// at-most-once execution or prove admission before preserve-detach cleanup. // 46: Queued message content can be edited in place (queue.entry.update). // 45: Connection onboarding inputs require `baseUrl` and `connectionId`, and // results can carry the `base_url_not_configured` / `connection_not_found` diff --git a/packages/runtime-host/src/server/hosted-execution-coordinator.ts b/packages/runtime-host/src/server/hosted-execution-coordinator.ts index fd4648b374..49ca43966b 100644 --- a/packages/runtime-host/src/server/hosted-execution-coordinator.ts +++ b/packages/runtime-host/src/server/hosted-execution-coordinator.ts @@ -21,37 +21,36 @@ import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import { preservesHostedExecutionEnvironment } from '../protocol/index.js'; import type { + HostedExecutionAdmittedStartInput, HostedExecutionProjection, HostedExecutionReferenceInput, HostedExecutionStartInput, OperationOutcome, } from '../protocol/index.js'; -import type { HostedExecutionOperationHandlerMap } from './operation-dispatcher.js'; +import type { + ConnectionContext, + HostedExecutionOperationHandlerMap, +} from './operation-dispatcher.js'; -type EnsuredHostedExecution = - | { - readonly ok: true; - readonly admissionToken: string; - readonly task: Promise; - } - | { readonly ok: false; readonly outcome: OperationOutcome<'hosted.execution.start'> }; +interface HostedExecutionRecord { + readonly input: HostedExecutionStartInput; + readonly authority: { + readonly hostEpoch: string; + readonly connectionId: string; + }; + readonly abort: AbortController; + readonly admissionToken: string; + readonly task: Promise; +} export class HostHostedExecutionCoordinator { readonly handlers: HostedExecutionOperationHandlerMap = { - 'hosted.execution.admit': (input) => this.#admit(input), - 'hosted.execution.start': (input) => this.#start(input), + 'hosted.execution.admit': (input, context) => this.#admit(input, context), + 'hosted.execution.start': (input, context) => this.#start(input, context), 'hosted.execution.cancel': (input) => this.#cancel(input), }; - readonly #executions = new Map< - string, - { - readonly input: HostedExecutionStartInput; - readonly abort: AbortController; - readonly admissionToken: string; - readonly task: Promise; - } - >(); + readonly #executions = new Map(); readonly #cancelled = new Set(); #accepting = true; @@ -75,61 +74,82 @@ export class HostHostedExecutionCoordinator { async #admit( input: HostedExecutionStartInput, + context: ConnectionContext, ): Promise> { - const ensured = this.#ensureExecution(input); - if (!ensured.ok) { - return ensured.outcome.ok - ? { - ok: false, - error: { - code: 'invalid_request', - message: 'Hosted execution was cancelled before admission', - }, - } - : ensured.outcome; + if (this.#cancelled.has(input.executionId)) { + return { + ok: false, + error: { + code: 'invalid_request', + message: 'Hosted execution was cancelled before admission', + }, + }; + } + const existing = this.#executions.get(input.executionId); + if (existing) { + if ( + !isDeepStrictEqual(existing.input, input) || + !sameAuthority(existing.authority, context) + ) { + return conflict(); + } + return { + ok: true, + result: { executionId: input.executionId, admissionToken: existing.admissionToken }, + }; + } + if (!this.#accepting) { + return { + ok: false, + error: { code: 'host_draining', message: 'Runtime Host is draining' }, + }; } + + const execution = this.#createExecution(input, context); return { ok: true, - result: { executionId: input.executionId, admissionToken: ensured.admissionToken }, + result: { executionId: input.executionId, admissionToken: execution.admissionToken }, }; } async #start( - input: HostedExecutionStartInput, + input: HostedExecutionAdmittedStartInput, + context: ConnectionContext, ): Promise> { - const ensured = this.#ensureExecution(input); - if (!ensured.ok) return ensured.outcome; - return { ok: true, result: structuredClone(await ensured.task) }; - } - - #ensureExecution(input: HostedExecutionStartInput): EnsuredHostedExecution { - if (this.#cancelled.has(input.executionId)) { + if (this.#cancelled.has(input.execution.executionId)) { this.requestDrain(); return { - ok: false, - outcome: { - ok: true, - result: indeterminate( - input.executionId, - 'Hosted execution was cancelled before admission', - ), - }, + ok: true, + result: indeterminate( + input.execution.executionId, + 'Hosted execution was cancelled before admission', + ), }; } - const existing = this.#executions.get(input.executionId); - if (existing) { - if (!isDeepStrictEqual(existing.input, input)) return { ok: false, outcome: conflict() }; - return { ok: true, admissionToken: existing.admissionToken, task: existing.task }; - } - if (!this.#accepting) { + const existing = this.#executions.get(input.execution.executionId); + if (!existing) { return { ok: false, - outcome: { - ok: false, - error: { code: 'host_draining', message: 'Runtime Host is draining' }, + error: { + code: 'invalid_request', + message: 'Hosted execution was not admitted', }, }; } + if ( + existing.admissionToken !== input.admissionToken || + !isDeepStrictEqual(existing.input, input.execution) || + !sameAuthority(existing.authority, context) + ) { + return conflict(); + } + return { ok: true, result: structuredClone(await existing.task) }; + } + + #createExecution( + input: HostedExecutionStartInput, + context: ConnectionContext, + ): HostedExecutionRecord { const abort = new AbortController(); const admissionToken = randomUUID(); const task = this.run(input, abort.signal) @@ -137,17 +157,16 @@ export class HostHostedExecutionCoordinator { .then((result) => { if (!preservesHostedExecutionEnvironment(result)) this.requestDrain(); return result; - }) - .finally(() => { - this.#executions.delete(input.executionId); }); - this.#executions.set(input.executionId, { + const execution = { input: structuredClone(input), + authority: { hostEpoch: context.hostEpoch, connectionId: context.connectionId }, abort, admissionToken, task, - }); - return { ok: true, admissionToken, task }; + }; + this.#executions.set(input.executionId, execution); + return execution; } async #cancel( @@ -163,15 +182,28 @@ export class HostHostedExecutionCoordinator { }; } execution.abort.abort(); - return { ok: true, result: structuredClone(await execution.task) }; + const result = await execution.task; + if (preservesHostedExecutionEnvironment(result)) this.requestDrain(); + return { ok: true, result: structuredClone(result) }; } } -function conflict(): OperationOutcome<'hosted.execution.start'> { +function sameAuthority( + authority: HostedExecutionRecord['authority'], + context: ConnectionContext, +): boolean { + return ( + authority.hostEpoch === context.hostEpoch && authority.connectionId === context.connectionId + ); +} + +function conflict< + K extends 'hosted.execution.admit' | 'hosted.execution.start', +>(): OperationOutcome { return { ok: false, error: { code: 'operation_conflict', message: 'Hosted execution identity is already in use' }, - }; + } as OperationOutcome; } function indeterminate(executionId: string, failureReason: string): HostedExecutionProjection { diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 5324bebe6d..29d03138fa 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -317,6 +317,22 @@ test('core CI validates affected installed CLI packages on its existing runner', assert.match(workflow, /run: npm run release:cli:smoke/u); }); +test('Eval changes run the pinned real Harbor lifecycle contract', () => { + const workflow = readWorkflow('ci.yml'); + const install = workflow.indexOf(' - name: Install Harbor lifecycle test dependencies\n'); + const run = workflow.indexOf(' - name: Run real Harbor lifecycle tests\n'); + + assert.ok(install >= 0); + assert.ok(run > install); + for (const start of [install, run]) { + const step = workflow.slice(start, workflow.indexOf('\n - ', start + 1)); + assert.match(step, /contains\(steps\.plan\.outputs\.standard_workspaces, 'packages\/eval'\)/u); + } + assert.match(workflow.slice(install, run), /'harbor==0\.20\.0'/u); + assert.match(workflow.slice(run), /MAKA_EVAL_HARBOR_LIFECYCLE_TEST: '1'/u); + assert.match(workflow.slice(run), /packages\/eval\/harbor\/test_harbor_trial_lifecycle\.py/u); +}); + test('release contracts run against built CLI outputs', () => { const workflow = readWorkflow('ci.yml'); const buildIndex = workflow.indexOf(' - name: Build\n'); From 9274d8efcc5a2802f286e65fa020d3d433ff0848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Sun, 23 Aug 2026 22:16:56 +0800 Subject: [PATCH 09/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Harbor=20=E7=94=9F?= =?UTF-8?q?=E5=91=BD=E5=91=A8=E6=9C=9F=E5=B7=A5=E4=BD=9C=E6=B5=81=E8=AF=AD?= =?UTF-8?q?=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a45d51802..debd3093a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,7 +214,8 @@ jobs: if: contains(steps.plan.outputs.standard_workspaces, 'packages/eval') env: MAKA_EVAL_HARBOR_LIFECYCLE_TEST: '1' - run: "$RUNNER_TEMP/maka-harbor-lifecycle/bin/python" packages/eval/harbor/test_harbor_trial_lifecycle.py + run: | + "$RUNNER_TEMP/maka-harbor-lifecycle/bin/python" packages/eval/harbor/test_harbor_trial_lifecycle.py - name: Run Runtime Host tests if: steps.plan.outputs.runtime_host == 'true' From 09d211342b72f66c4b7d9347657de5bfa9be505e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Mon, 24 Aug 2026 10:08:08 +0800 Subject: [PATCH 10/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AF=84=E6=B5=8B?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E6=B8=85=E7=90=86=E9=A2=84=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/eval/harbor/relay_agent.py | 72 ++++++++++--------- packages/eval/harbor/run_trial.py | 34 +++++++++ .../harbor/test_harbor_trial_lifecycle.py | 1 + packages/eval/harbor/test_relay_lifecycle.py | 63 ++++++++++++++-- packages/eval/harbor/test_run_trial_policy.py | 25 +++++++ 5 files changed, 159 insertions(+), 36 deletions(-) diff --git a/packages/eval/harbor/relay_agent.py b/packages/eval/harbor/relay_agent.py index 621cc87242..28bbf18a0f 100644 --- a/packages/eval/harbor/relay_agent.py +++ b/packages/eval/harbor/relay_agent.py @@ -91,6 +91,7 @@ def __init__( relay_port: int, relay_token: str, teardown_timeout_ms: int, + framework_timeout_ms: int | None = None, **kwargs: Any, ): super().__init__(*args, **kwargs) @@ -100,6 +101,14 @@ def __init__( if not isinstance(teardown_timeout_ms, int) or teardown_timeout_ms <= 0: raise RuntimeError("Maka Eval teardown timeout is invalid") self._teardown_timeout = teardown_timeout_ms / 1000 + if framework_timeout_ms is None: + framework_timeout_ms = teardown_timeout_ms + if not isinstance(framework_timeout_ms, int) or framework_timeout_ms <= 0: + raise RuntimeError("Maka Eval framework timeout is invalid") + self._framework_timeout = min( + framework_timeout_ms / 1000, + self._teardown_timeout, + ) @staticmethod def name() -> str: @@ -189,8 +198,9 @@ async def run(self, instruction: str, environment: Any, context: Any) -> None: current = asyncio.current_task() if current is not None and hasattr(current, "uncancel"): current.uncancel() + framework_timeout_handled = False if request is not None and execution is not None: - await self._cleanup_cancelled_execution( + framework_timeout_handled = await self._cleanup_cancelled_execution( environment, cwd, scope_path, @@ -199,6 +209,8 @@ async def run(self, instruction: str, environment: Any, context: Any) -> None: writer, execution_reported, ) + if framework_timeout_handled and not _host_teardown_requested: + return raise except RelayTransportClosed: if request is not None and execution is not None: @@ -245,9 +257,15 @@ async def _cleanup_cancelled_execution( request: dict[str, Any], writer: Any, execution_reported: bool, - ) -> None: + ) -> bool: + cleanup_timeout = ( + self._teardown_timeout + if _host_teardown_requested + else self._framework_timeout + ) + deadline = asyncio.get_running_loop().time() + cleanup_timeout try: - await self._finalize_cancelled_execution( + return await self._finalize_cancelled_execution( environment, cwd, scope_path, @@ -255,15 +273,18 @@ async def _cleanup_cancelled_execution( request, writer, execution_reported, + cleanup_timeout, ) except BaseException: + remaining = deadline - asyncio.get_running_loop().time() with contextlib.suppress(Exception): - await asyncio.wait_for( - _settle_or_destroy( - environment, cwd, scope_path, execution, self._teardown_timeout - ), - timeout=self._teardown_timeout, - ) + if remaining > 0: + await asyncio.wait_for( + _settle_or_destroy( + environment, cwd, scope_path, execution, remaining + ), + timeout=remaining, + ) raise async def _finalize_cancelled_execution( @@ -275,7 +296,8 @@ async def _finalize_cancelled_execution( request: dict[str, Any], writer: Any, execution_reported: bool, - ) -> None: + cleanup_timeout: float, + ) -> bool: execution_terminal = execution.done() and not execution.cancelled() terminal_projection = None if execution_terminal: @@ -292,11 +314,11 @@ async def _finalize_cancelled_execution( result = terminal_result elif _host_teardown_requested: result = await _settle_or_destroy( - environment, cwd, scope_path, execution, self._teardown_timeout + environment, cwd, scope_path, execution, cleanup_timeout ) else: result = await _stop_subject_for_timeout( - environment, cwd, scope_path, execution, self._teardown_timeout + environment, cwd, scope_path, execution, cleanup_timeout ) if result is not None: await _persist_subject_outputs(environment, result) @@ -306,8 +328,8 @@ async def _finalize_cancelled_execution( and (execution_terminal or not _host_teardown_requested) ): stdout, diagnostic = terminal_projection or _project_result(result, request) - with contextlib.suppress(Exception): - await _send( + try: + execution_reported = await _send( writer, { "token": self._token, @@ -318,23 +340,9 @@ async def _finalize_cancelled_execution( "diagnostic": diagnostic, }, ) - elif not execution_reported and not _host_teardown_requested: - with contextlib.suppress(Exception): - await _send( - writer, - { - "token": self._token, - "kind": "executed", - "termination": "framework_timeout", - "exitCode": 124, - "stdout": "", - "diagnostic": ( - _carrier_diagnostic("result-frame-missing", b"") - if request.get("captureStdout", True) - else {"category": "none"} - ), - }, - ) + except Exception: + execution_reported = False + return execution_reported and not _host_teardown_requested async def _prepare_command( @@ -654,7 +662,7 @@ async def _stop_subject_for_timeout( scope_path: str, execution: Any, timeout: float, -) -> Any | None: +) -> Any: # The verifier still scores this trial. Stop only the subject so # `environment.exec` can return; do not hunt descendants or delete the # environment. Those leftovers are what the task asked the subject to leave. diff --git a/packages/eval/harbor/run_trial.py b/packages/eval/harbor/run_trial.py index 9419ef67a1..efebdc4308 100644 --- a/packages/eval/harbor/run_trial.py +++ b/packages/eval/harbor/run_trial.py @@ -24,6 +24,7 @@ import importlib import importlib.metadata import json +import math import os import signal import sys @@ -162,10 +163,43 @@ def apply_subject_egress_policy(task: object) -> None: agent.allowed_hosts = [allowed_host] +def apply_framework_timeout_budget(config: object, task: object) -> None: + task_config = task.config + defaults = ( + [ + step.agent.timeout_sec + if step.agent.timeout_sec is not None + else task_config.agent.timeout_sec + for step in task_config.steps + ] + if task_config.steps + else [task_config.agent.timeout_sec] + ) + multiplier = ( + config.agent_timeout_multiplier + if config.agent_timeout_multiplier is not None + else config.timeout_multiplier + ) + timeouts = [] + for default_timeout in defaults: + base_timeout = config.agent.override_timeout_sec or default_timeout + if base_timeout is None: + continue + maximum = config.agent.max_timeout_sec or float("inf") + timeouts.append(min(base_timeout, maximum) * multiplier) + if not timeouts: + return + timeout_ms = math.floor(min(timeouts) * 1000) + if timeout_ms <= 0: + raise RuntimeError("Harbor agent timeout is shorter than one millisecond") + config.agent.kwargs["framework_timeout_ms"] = timeout_ms + + async def create_harbor_trial(trial_type: type, config: object) -> object: trial_type._resolve_agent_skills(config) task, task_download_result = await trial_type._load_task(config) apply_subject_egress_policy(task) + apply_framework_timeout_budget(config, task) if task.has_steps: from harbor.trial.multi_step import MultiStepTrial diff --git a/packages/eval/harbor/test_harbor_trial_lifecycle.py b/packages/eval/harbor/test_harbor_trial_lifecycle.py index aa97584de0..6dd66350ea 100644 --- a/packages/eval/harbor/test_harbor_trial_lifecycle.py +++ b/packages/eval/harbor/test_harbor_trial_lifecycle.py @@ -225,6 +225,7 @@ async def _create_trial( "relay_port": relay_port, "relay_token": RELAY_TOKEN, "teardown_timeout_ms": 5000, + "framework_timeout_ms": int(timeout_sec * 1000), }, }, "environment": {"type": "docker", "delete": True}, diff --git a/packages/eval/harbor/test_relay_lifecycle.py b/packages/eval/harbor/test_relay_lifecycle.py index 4cc33d442f..f219e43ffd 100644 --- a/packages/eval/harbor/test_relay_lifecycle.py +++ b/packages/eval/harbor/test_relay_lifecycle.py @@ -193,6 +193,20 @@ async def exec(self, command, cwd=None, timeout_sec=None): return await super().exec(command, cwd=cwd, timeout_sec=timeout_sec) +class FrameworkBudgetEnvironment(TimeoutScopeEnvironment): + def __init__(self): + super().__init__() + self.leader_timeouts = [] + + async def exec(self, command, cwd=None, timeout_sec=None): + if _is_leader_stop(command): + self.commands.append(command) + self.leader_timeouts.append(timeout_sec) + self.release.set() + return SimpleNamespace(return_code=0, stdout="", stderr="") + return await super().exec(command, cwd=cwd, timeout_sec=timeout_sec) + + class IgnoringLeaderEnvironment(SimultaneousEnvironment): """Delivers leader TERM/KILL but keeps the subject running.""" @@ -396,6 +410,49 @@ async def trial(*_args): class RelayLifecycleTest(unittest.IsolatedAsyncioTestCase): + async def test_framework_timeout_uses_its_own_budget_and_completes_the_relay(self): + relay = load_relay() + environment = FrameworkBudgetEnvironment() + token = f"framework-deadline-{os.getpid()}" + connected = asyncio.get_running_loop().create_future() + + async def accept(reader, writer): + connected.set_result((reader, writer)) + + server = await asyncio.start_server(accept, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + agent = relay.RelayAgent( + logs_dir=Path(tempfile.gettempdir()), + relay_host="127.0.0.1", + relay_port=port, + relay_token=token, + teardown_timeout_ms=5_000, + framework_timeout_ms=50, + ) + running = asyncio.create_task( + asyncio.wait_for(agent.run("solve", environment, None), timeout=0.05) + ) + reader, writer = await connected + try: + await reader.readline() + writer.write((json.dumps({ + "token": token, "kind": "execute", "command": "/bin/true", "args": [], + "credentials": {}, "resultToken": "0" * 32, + }) + "\n").encode()) + await writer.drain() + executed = json.loads(await asyncio.wait_for(reader.readline(), 0.5)) + self.assertEqual(executed["termination"], "framework_timeout") + await asyncio.wait_for(running, timeout=0.5) + self.assertNotEqual(environment.leader_timeouts, []) + self.assertTrue( + all(timeout is not None and timeout <= 0.05 for timeout in environment.leader_timeouts) + ) + self.assertFalse(environment.stopped) + finally: + writer.close() + server.close() + await server.wait_closed() + async def test_leader_stop_exec_timeout_stays_inside_the_teardown_budget(self): relay = load_relay() environment = IgnoringLeaderEnvironment() @@ -668,8 +725,7 @@ async def accept(reader, writer): executed = __import__("json").loads(await asyncio.wait_for(reader.readline(), 0.5)) self.assertEqual(executed["termination"], "framework_timeout") self.assertEqual(executed["exitCode"], 124) - with self.assertRaises(asyncio.CancelledError): - await running + await running finally: writer.close() server.close() @@ -905,8 +961,7 @@ async def accept(reader, writer): [], ) self.assertFalse(environment.stopped) - with self.assertRaises(asyncio.CancelledError): - await running + await running finally: writer.close() server.close() diff --git a/packages/eval/harbor/test_run_trial_policy.py b/packages/eval/harbor/test_run_trial_policy.py index 13e7d39814..de85577cee 100644 --- a/packages/eval/harbor/test_run_trial_policy.py +++ b/packages/eval/harbor/test_run_trial_policy.py @@ -32,6 +32,31 @@ class RunTrialPolicyTest(unittest.TestCase): + def test_framework_cleanup_budget_uses_the_shortest_resolved_agent_timeout(self) -> None: + config = SimpleNamespace( + timeout_multiplier=2, + agent_timeout_multiplier=None, + agent=SimpleNamespace( + override_timeout_sec=None, + max_timeout_sec=4, + kwargs={"relay_token": "token"}, + ), + ) + task = SimpleNamespace( + config=SimpleNamespace( + agent=SimpleNamespace(timeout_sec=3), + steps=[ + SimpleNamespace(agent=SimpleNamespace(timeout_sec=None)), + SimpleNamespace(agent=SimpleNamespace(timeout_sec=1.5)), + ], + ) + ) + + MODULE.apply_framework_timeout_budget(config, task) + + self.assertEqual(config.agent.kwargs["framework_timeout_ms"], 3000) + self.assertEqual(config.agent.kwargs["relay_token"], "token") + def test_forces_only_the_subject_phase_through_the_cell_proxy(self) -> None: agent = SimpleNamespace(network_mode=None, allowed_hosts=None) task = SimpleNamespace(config=SimpleNamespace(agent=agent)) From ea34b9774050ca80f43288c3a8262f1214eaf710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Mon, 24 Aug 2026 11:33:45 +0800 Subject: [PATCH 11/13] =?UTF-8?q?=E4=B8=BA=E6=A1=86=E6=9E=B6=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E4=BF=9D=E7=95=99=E5=BC=BA=E5=88=B6=E5=81=9C=E6=AD=A2?= =?UTF-8?q?=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/eval/harbor/relay_agent.py | 20 +++++---- packages/eval/harbor/test_relay_lifecycle.py | 44 ++++++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/packages/eval/harbor/relay_agent.py b/packages/eval/harbor/relay_agent.py index 28bbf18a0f..2f556d533c 100644 --- a/packages/eval/harbor/relay_agent.py +++ b/packages/eval/harbor/relay_agent.py @@ -675,23 +675,29 @@ async def _stop_subject_for_timeout( destroy_reserve = min(20.0, timeout * 0.2) stop_deadline = deadline - destroy_reserve result = None - for signal, slice_timeout in (("TERM", 20.0), ("KILL", 10.0)): + signals = (("TERM", 20.0), ("KILL", 10.0)) + for index, (signal, slice_timeout) in enumerate(signals): remaining = stop_deadline - loop.time() if remaining <= 0: break + # Reserve a bounded attempt for every remaining signal. A short + # framework deadline must not let a TERM-delayed shell consume the + # entire stop window before KILL can be issued. + attempt_budget = min(slice_timeout, remaining / (len(signals) - index)) + attempt_deadline = loop.time() + attempt_budget signalled = await _signal_leader( - environment, cwd, scope_path, signal, timeout_sec=remaining + environment, cwd, scope_path, signal, timeout_sec=attempt_budget ) - remaining = stop_deadline - loop.time() + remaining = min(attempt_deadline, stop_deadline) - loop.time() if remaining <= 0: if execution.done() and not execution.cancelled(): return execution.result() - break + continue if not signalled: # A vanished leader can race the environment.exec completion by a # few scheduling turns. Admit that terminal result, but do not - # spend the rest of the timeout pretending an unissued signal is - # evidence that a live subject stopped. + # spend this signal's reserved slice pretending an unissued signal + # is evidence that a live subject stopped. try: return await asyncio.wait_for( asyncio.shield(execution), timeout=remaining @@ -701,7 +707,7 @@ async def _stop_subject_for_timeout( raise RuntimeError("Maka Eval subject execution was cancelled") from None raise except (TimeoutError, asyncio.TimeoutError): - break + continue try: result = await asyncio.wait_for( asyncio.shield(execution), diff --git a/packages/eval/harbor/test_relay_lifecycle.py b/packages/eval/harbor/test_relay_lifecycle.py index f219e43ffd..6b335057df 100644 --- a/packages/eval/harbor/test_relay_lifecycle.py +++ b/packages/eval/harbor/test_relay_lifecycle.py @@ -230,6 +230,23 @@ async def exec(self, command, cwd=None, timeout_sec=None): return await super().exec(command, cwd=cwd, timeout_sec=timeout_sec) +class TermDelayedKillEnvironment(TimeoutScopeEnvironment): + """A shell that does not leave its blocking child until KILL arrives.""" + + def __init__(self): + super().__init__() + self.signal_timeouts = [] + + async def exec(self, command, cwd=None, timeout_sec=None): + if _is_leader_stop(command): + self.commands.append(command) + self.signal_timeouts.append(timeout_sec) + if "kill -KILL" in command: + self.release.set() + return SimpleNamespace(return_code=0, stdout="", stderr="") + return await super().exec(command, cwd=cwd, timeout_sec=timeout_sec) + + class ExplodingSubjectEnvironment: def __init__(self): self.stopped = False @@ -488,6 +505,33 @@ async def accept(reader, writer): server.close() await server.wait_closed() + async def test_short_framework_timeout_reserves_a_kill_attempt(self): + relay = load_relay() + environment = TermDelayedKillEnvironment() + execution = asyncio.create_task(environment.exec("setsid subject")) + await environment.started.wait() + + result = await relay._stop_subject_for_timeout( + environment, + "/workspace", + "/tmp/maka-eval-scope.pid", + execution, + 0.2, + ) + + self.assertEqual(result.return_code, 0) + self.assertEqual( + [ + signal + for signal in ("TERM", "KILL") + if any(f"kill -{signal}" in command for command in environment.commands) + ], + ["TERM", "KILL"], + ) + self.assertEqual(len(environment.signal_timeouts), 2) + self.assertTrue(all(0 < timeout <= 0.08 for timeout in environment.signal_timeouts)) + self.assertFalse(environment.stopped) + def test_scope_predicates_distinguish_group_teardown_from_leader_stop(self): group = 'kill -TERM -- "-$pgid"' leader = 'kill -TERM -- "$pgid"' From 94c588d00d149b4c2a8a516051e3d6d17e82f28a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Mon, 24 Aug 2026 12:26:36 +0800 Subject: [PATCH 12/13] =?UTF-8?q?=E5=87=8F=E5=B0=91=E6=A1=86=E6=9E=B6?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E7=9A=84=E5=AE=B9=E5=99=A8=E5=81=9C=E6=AD=A2?= =?UTF-8?q?=E5=BE=80=E8=BF=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/eval/harbor/relay_agent.py | 63 +++++++------------- packages/eval/harbor/test_relay_lifecycle.py | 20 ++++--- 2 files changed, 35 insertions(+), 48 deletions(-) diff --git a/packages/eval/harbor/relay_agent.py b/packages/eval/harbor/relay_agent.py index 2f556d533c..c052ffbfc8 100644 --- a/packages/eval/harbor/relay_agent.py +++ b/packages/eval/harbor/relay_agent.py @@ -674,30 +674,22 @@ async def _stop_subject_for_timeout( deadline = loop.time() + timeout destroy_reserve = min(20.0, timeout * 0.2) stop_deadline = deadline - destroy_reserve - result = None - signals = (("TERM", 20.0), ("KILL", 10.0)) - for index, (signal, slice_timeout) in enumerate(signals): - remaining = stop_deadline - loop.time() - if remaining <= 0: - break - # Reserve a bounded attempt for every remaining signal. A short - # framework deadline must not let a TERM-delayed shell consume the - # entire stop window before KILL can be issued. - attempt_budget = min(slice_timeout, remaining / (len(signals) - index)) - attempt_deadline = loop.time() + attempt_budget - signalled = await _signal_leader( - environment, cwd, scope_path, signal, timeout_sec=attempt_budget + remaining = stop_deadline - loop.time() + if remaining > 0: + # Escalate inside one environment call. Docker-backed environments pay + # a material control-plane round trip for every exec; separate TERM and + # KILL calls can consume a short framework budget even when KILL works. + # This command still targets only the session leader, so task-owned + # background processes remain available to the verifier. + await _stop_leader( + environment, + cwd, + scope_path, + grace_sec=min(0.1, max(0.001, remaining * 0.1)), + timeout_sec=remaining, ) - remaining = min(attempt_deadline, stop_deadline) - loop.time() - if remaining <= 0: - if execution.done() and not execution.cancelled(): - return execution.result() - continue - if not signalled: - # A vanished leader can race the environment.exec completion by a - # few scheduling turns. Admit that terminal result, but do not - # spend this signal's reserved slice pretending an unissued signal - # is evidence that a live subject stopped. + remaining = stop_deadline - loop.time() + if remaining > 0: try: return await asyncio.wait_for( asyncio.shield(execution), timeout=remaining @@ -707,21 +699,7 @@ async def _stop_subject_for_timeout( raise RuntimeError("Maka Eval subject execution was cancelled") from None raise except (TimeoutError, asyncio.TimeoutError): - continue - try: - result = await asyncio.wait_for( - asyncio.shield(execution), - timeout=min(slice_timeout, remaining), - ) - break - except asyncio.CancelledError: - if execution.cancelled(): - raise RuntimeError("Maka Eval subject execution was cancelled") from None - raise - except (TimeoutError, asyncio.TimeoutError): - pass - if result is not None: - return result + pass if execution.done() and not execution.cancelled(): return execution.result() # A verifier cannot measure a stable environment while the subject may @@ -784,17 +762,20 @@ async def _signal_group(environment: Any, cwd: str, scope_path: str, signal: str ) -async def _signal_leader( +async def _stop_leader( environment: Any, cwd: str, scope_path: str, - signal: str, + grace_sec: float, timeout_sec: float = 5, ) -> bool: command = ( f"pgid=$(cat {shlex.quote(scope_path)} 2>/dev/null) || exit 1; " "case $pgid in ''|0|*[!0-9]*) exit 1;; esac; " - f"kill -{signal} -- \"$pgid\"" + "kill -TERM -- \"$pgid\" 2>/dev/null || exit 1; " + f"sleep {grace_sec:.6f}; " + "kill -0 -- \"$pgid\" 2>/dev/null || exit 0; " + "kill -KILL -- \"$pgid\" 2>/dev/null" ) try: result = await environment.exec( diff --git a/packages/eval/harbor/test_relay_lifecycle.py b/packages/eval/harbor/test_relay_lifecycle.py index 6b335057df..a549e7c724 100644 --- a/packages/eval/harbor/test_relay_lifecycle.py +++ b/packages/eval/harbor/test_relay_lifecycle.py @@ -230,17 +230,23 @@ async def exec(self, command, cwd=None, timeout_sec=None): return await super().exec(command, cwd=cwd, timeout_sec=timeout_sec) -class TermDelayedKillEnvironment(TimeoutScopeEnvironment): - """A shell that does not leave its blocking child until KILL arrives.""" +class DelayedLeaderControlEnvironment(TimeoutScopeEnvironment): + """A Docker-like control path with signal and result-observation latency.""" def __init__(self): super().__init__() self.signal_timeouts = [] async def exec(self, command, cwd=None, timeout_sec=None): + if command.startswith("setsid"): + self.started.set() + await self.release.wait() + await asyncio.sleep(0.05) + return SimpleNamespace(return_code=0, stdout="", stderr="") if _is_leader_stop(command): self.commands.append(command) self.signal_timeouts.append(timeout_sec) + await asyncio.sleep(0.05) if "kill -KILL" in command: self.release.set() return SimpleNamespace(return_code=0, stdout="", stderr="") @@ -505,9 +511,9 @@ async def accept(reader, writer): server.close() await server.wait_closed() - async def test_short_framework_timeout_reserves_a_kill_attempt(self): + async def test_short_framework_timeout_escalates_in_one_control_round_trip(self): relay = load_relay() - environment = TermDelayedKillEnvironment() + environment = DelayedLeaderControlEnvironment() execution = asyncio.create_task(environment.exec("setsid subject")) await environment.started.wait() @@ -528,8 +534,8 @@ async def test_short_framework_timeout_reserves_a_kill_attempt(self): ], ["TERM", "KILL"], ) - self.assertEqual(len(environment.signal_timeouts), 2) - self.assertTrue(all(0 < timeout <= 0.08 for timeout in environment.signal_timeouts)) + self.assertEqual(len(environment.signal_timeouts), 1) + self.assertTrue(all(0 < timeout <= 0.16 for timeout in environment.signal_timeouts)) self.assertFalse(environment.stopped) def test_scope_predicates_distinguish_group_teardown_from_leader_stop(self): @@ -803,7 +809,7 @@ async def accept(reader, writer): running.cancel() self.assertEqual(await asyncio.wait_for(reader.readline(), 0.5), b"") # SimultaneousEnvironment answers every `pgid=` command with 3, so - # `_signal_leader` reports a vanished leader. The completion race + # `_stop_leader` reports a vanished leader. The completion race # never delivers, so the relay must fail closed rather than score. self.assertTrue(environment.stopped) with self.assertRaisesRegex(RuntimeError, "could not confirm subject exit"): From 461687f51397d2aeb59a3f74d012a715e2398474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Mon, 24 Aug 2026 12:52:44 +0800 Subject: [PATCH 13/13] =?UTF-8?q?=E9=99=90=E5=88=B6=E6=89=98=E7=AE=A1?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E5=9B=9E=E6=89=A7=E5=B9=B6=E5=88=86=E7=A6=BB?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/eval/harbor/relay_agent.py | 5 +- packages/eval/harbor/test_relay_lifecycle.py | 17 ++++++ .../hosted-execution-coordinator.test.ts | 61 ++++++++++++++++++- .../server/hosted-execution-coordinator.ts | 14 ++++- 4 files changed, 93 insertions(+), 4 deletions(-) diff --git a/packages/eval/harbor/relay_agent.py b/packages/eval/harbor/relay_agent.py index c052ffbfc8..a7dcacdbc7 100644 --- a/packages/eval/harbor/relay_agent.py +++ b/packages/eval/harbor/relay_agent.py @@ -393,7 +393,10 @@ async def _prepare_command( if secret_path is not None: secret_path.unlink(missing_ok=True) subject = shlex.join([request["command"], *request["args"]]) - output_redirect = "" if capture_stdout else " >/dev/null" + # An exit-code subject has no structured stdout to preserve. Detach both + # streams so a task-owned background process cannot keep Harbor's + # `docker compose exec` output pipe open after the subject leader exits. + output_redirect = "" if capture_stdout else " >/dev/null 2>&1" scope_error = shlex.quote(f"{SCOPE_ERROR_PREFIX} {result_token}\\n") inner = ( "umask 077; " diff --git a/packages/eval/harbor/test_relay_lifecycle.py b/packages/eval/harbor/test_relay_lifecycle.py index a549e7c724..7730e2f388 100644 --- a/packages/eval/harbor/test_relay_lifecycle.py +++ b/packages/eval/harbor/test_relay_lifecycle.py @@ -433,6 +433,23 @@ async def trial(*_args): class RelayLifecycleTest(unittest.IsolatedAsyncioTestCase): + async def test_exit_code_subject_detaches_both_output_streams(self): + relay = load_relay() + command = await relay._prepare_command( + ExplodingSubjectEnvironment(), + { + "command": "/bin/sh", + "args": ["-c", "run-subject"], + "credentials": {}, + "resultToken": "0" * 32, + "captureStdout": False, + }, + "detach-output", + "/tmp/maka-eval-detach-output.pid", + ) + + self.assertIn(">/dev/null 2>&1", command) + async def test_framework_timeout_uses_its_own_budget_and_completes_the_relay(self): relay = load_relay() environment = FrameworkBudgetEnvironment() diff --git a/packages/runtime-host/src/__tests__/hosted-execution-coordinator.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-coordinator.test.ts index 190e0788b8..968a8bfb97 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-coordinator.test.ts @@ -162,6 +162,63 @@ test('fast settlement remains cached and cannot execute twice', async () => { assert.equal(runs, 1); }); +test('one-shot Host rejects every different execution identity and retains one receipt', async () => { + let runs = 0; + let aborts = 0; + const coordinator = new HostHostedExecutionCoordinator( + async (execution, signal) => { + runs += 1; + signal.addEventListener('abort', () => { + aborts += 1; + }); + return settled(execution.executionId, 'completed'); + }, + () => {}, + ); + const admitted = await coordinator.handlers['hosted.execution.admit'](input(), context()); + assert.equal(admitted.ok, true); + + const outcomes = await Promise.all( + Array.from({ length: 200 }, (_, index) => + coordinator.handlers['hosted.execution.admit']( + input(`00000000-0000-4000-8001-${String(index).padStart(12, '0')}`), + context(), + ), + ), + ); + + assert.equal( + outcomes.every((outcome) => !outcome.ok), + true, + ); + for (const outcome of outcomes) { + if (!outcome.ok) assert.equal(outcome.error.code, 'operation_conflict'); + } + assert.equal(runs, 1); + assert.equal(aborts, 0); + coordinator.beginDrain(); + assert.equal(aborts, 1); +}); + +test('cancelling first binds the one-shot Host to that execution identity', async () => { + const coordinator = new HostHostedExecutionCoordinator( + async (execution) => settled(execution.executionId, 'completed'), + () => {}, + ); + const cancelled = await coordinator.handlers['hosted.execution.cancel']( + { executionId: ID }, + context(), + ); + assert.equal(cancelled.ok, true); + + const different = await coordinator.handlers['hosted.execution.admit']( + input('00000000-0000-4000-8000-000000000002'), + context(), + ); + assert.equal(different.ok, false); + if (!different.ok) assert.equal(different.error.code, 'operation_conflict'); +}); + test('admission authority cannot cross connection or Host epoch', async () => { const coordinator = new HostHostedExecutionCoordinator( async (execution) => settled(execution.executionId, 'completed'), @@ -208,9 +265,9 @@ function settled(executionId: string, status: 'completed' | 'failed') { }; } -function input() { +function input(executionId = ID) { return { - executionId: ID, + executionId, session: { workspace: { kind: 'host_path' as const, path: '/workspace' }, modelTarget: { kind: 'explicit' as const, connectionSlug: 'env-openai', model: 'model' }, diff --git a/packages/runtime-host/src/server/hosted-execution-coordinator.ts b/packages/runtime-host/src/server/hosted-execution-coordinator.ts index 49ca43966b..abd1faf91d 100644 --- a/packages/runtime-host/src/server/hosted-execution-coordinator.ts +++ b/packages/runtime-host/src/server/hosted-execution-coordinator.ts @@ -52,6 +52,7 @@ export class HostHostedExecutionCoordinator { readonly #executions = new Map(); readonly #cancelled = new Set(); + #executionId: string | undefined; #accepting = true; constructor( @@ -76,6 +77,9 @@ export class HostHostedExecutionCoordinator { input: HostedExecutionStartInput, context: ConnectionContext, ): Promise> { + if (this.#executionId !== undefined && this.#executionId !== input.executionId) { + return conflict(); + } if (this.#cancelled.has(input.executionId)) { return { ok: false, @@ -105,6 +109,7 @@ export class HostHostedExecutionCoordinator { }; } + this.#executionId = input.executionId; const execution = this.#createExecution(input, context); return { ok: true, @@ -116,6 +121,9 @@ export class HostHostedExecutionCoordinator { input: HostedExecutionAdmittedStartInput, context: ConnectionContext, ): Promise> { + if (this.#executionId !== undefined && this.#executionId !== input.execution.executionId) { + return conflict(); + } if (this.#cancelled.has(input.execution.executionId)) { this.requestDrain(); return { @@ -172,6 +180,10 @@ export class HostHostedExecutionCoordinator { async #cancel( input: HostedExecutionReferenceInput, ): Promise> { + if (this.#executionId !== undefined && this.#executionId !== input.executionId) { + return conflict(); + } + this.#executionId = input.executionId; this.#cancelled.add(input.executionId); const execution = this.#executions.get(input.executionId); if (!execution) { @@ -198,7 +210,7 @@ function sameAuthority( } function conflict< - K extends 'hosted.execution.admit' | 'hosted.execution.start', + K extends 'hosted.execution.admit' | 'hosted.execution.start' | 'hosted.execution.cancel', >(): OperationOutcome { return { ok: false,