diff --git a/services/hermes/scripts/execution_pool_protocol.py b/services/hermes/scripts/execution_pool_protocol.py index 007b8b0f..482eff81 100644 --- a/services/hermes/scripts/execution_pool_protocol.py +++ b/services/hermes/scripts/execution_pool_protocol.py @@ -93,6 +93,13 @@ def payload_digest(payload: Any) -> str: return hashlib.sha256(canonical_json(payload)).hexdigest() +def truncate_utf8(value: str, maximum: int) -> str: + """Bound display text by UTF-8 bytes without splitting a character.""" + if maximum < 0: + raise ProtocolError("text byte limit is invalid") + return value.encode("utf-8", "replace")[:maximum].decode("utf-8", "ignore") + + def derive_ordinal_key(master: bytes, ordinal: int) -> bytes: """Derive one cryptographically isolated worker authority from the pool root.""" if ordinal not in range(3) or not 32 <= len(master) <= 4096: diff --git a/services/hermes/scripts/execution_pool_worker.py b/services/hermes/scripts/execution_pool_worker.py index 0dad7e20..c6a2aa03 100644 --- a/services/hermes/scripts/execution_pool_worker.py +++ b/services/hermes/scripts/execution_pool_worker.py @@ -19,6 +19,7 @@ from execution_pool_protocol import ( ProtocolError, atomic_json, canonical_json, + truncate_utf8, ) ROOT = Path(os.environ.get("HERMES_WORKER_ROOT", "/workspace")) ORDINAL = int(os.environ.get("HERMES_WORKER_ORDINAL", "-1")) @@ -380,7 +381,7 @@ def execute(assignment: dict[str, Any]) -> None: "finish", binding=binding, payload=result_payload, - title=str(structured.get("summary") or f"Hermes task {binding['task_id']}")[:240], + title=truncate_utf8(str(structured.get("summary") or f"Hermes task {binding['task_id']}"), 512), body=json.dumps(structured, indent=2, sort_keys=True)[:12000], ) if not response.get("ack", {}).get("accepted"): diff --git a/services/hermes/scripts/scm_resume_bootstrap.py b/services/hermes/scripts/scm_resume_bootstrap.py index 571a8c79..21f43666 100755 --- a/services/hermes/scripts/scm_resume_bootstrap.py +++ b/services/hermes/scripts/scm_resume_bootstrap.py @@ -12,7 +12,7 @@ import sys from pathlib import Path from typing import Any -from execution_pool_protocol import ProtocolError, canonical_json, read_key +from execution_pool_protocol import ProtocolError, canonical_json, read_key, truncate_utf8 from execution_pool_scm import Boundary ROOT = Path(os.environ.get("HERMES_WORKER_ROOT", "/workspace")) @@ -88,7 +88,7 @@ def bootstrap(key: bytes, raw_assignment: Any) -> dict[str, Any]: boundary = Boundary(key) assignment = boundary.verify(raw_assignment) structured = _completed_result(assignment) - title = structured["summary"].strip()[:512] + title = truncate_utf8(structured["summary"].strip(), 512) body = json.dumps(structured, indent=2, sort_keys=True) return boundary.resume_artifact(assignment, {"title": title, "body": body}, structured) diff --git a/testing/tests/test_hermes_execution_pool_submission.py b/testing/tests/test_hermes_execution_pool_submission.py index bea48efe..4804bae5 100644 --- a/testing/tests/test_hermes_execution_pool_submission.py +++ b/testing/tests/test_hermes_execution_pool_submission.py @@ -459,6 +459,30 @@ def test_mediator_bootstrap_recovers_one_unique_nested_session_result(tmp_path, assert resume_bootstrap._completed_result(exact) == completed +def test_mediator_bootstrap_truncates_multibyte_title_at_the_byte_limit(tmp_path, monkeypatch): + """Receipt title limits count UTF-8 bytes while preserving full evidence.""" + exact = protocol.sign_envelope( + KEY, "assignment", binding(), payload(root_task_id="t_deadbeef", continuation_kind="repair") + ) + log = tmp_path / "session-state" / "metis" / "t_deadbeef" / "42.log" + log.parent.mkdir(parents=True) + completed = {**RESULT, "summary": "界" * 200} + log.write_bytes(protocol.canonical_json({"structured_output": completed}) + b"\n") + monkeypatch.setattr(resume_bootstrap, "ROOT", tmp_path) + seen = {} + monkeypatch.setattr( + scm.Boundary, "resume_artifact", + lambda _self, _assignment, request, structured: seen.update( + request=request, structured=structured + ) or {}, + ) + + resume_bootstrap.bootstrap(KEY, exact) + + assert len(seen["request"]["title"].encode()) <= 512 + assert seen["structured"]["summary"] == completed["summary"] + + def test_mediator_bootstrap_rejects_a_symlinked_log_ancestor(tmp_path, monkeypatch): exact = protocol.sign_envelope(KEY, "assignment", binding(), payload()) outside = tmp_path / "outside"