diff --git a/.gitea/workflows/publish-workspace-server-image.yml b/.gitea/workflows/publish-workspace-server-image.yml index e9ca5ec2..08a65d14 100644 --- a/.gitea/workflows/publish-workspace-server-image.yml +++ b/.gitea/workflows/publish-workspace-server-image.yml @@ -32,11 +32,9 @@ on: - '.gitea/workflows/publish-workspace-server-image.yml' workflow_dispatch: -# Serialize per-branch so two rapid staging pushes don't race the same -# :staging-latest tag retag. Allow staging and main to run in parallel -# (different GITHUB_REF → different concurrency group) since they -# produce different :staging- tags and last-write-wins on -# :staging-latest is acceptable across branches. +# Serialize per-branch so two rapid main pushes don't race the same +# :staging-latest tag retag. Allow parallel runs as they produce +# different :staging- tags and last-write-wins on :staging-latest. # # cancel-in-progress: false → in-flight builds finish; the next push's # build queues. This avoids a partially-pushed image. diff --git a/.staging-trigger b/.staging-trigger new file mode 100644 index 00000000..270a6560 --- /dev/null +++ b/.staging-trigger @@ -0,0 +1 @@ +staging trigger \ No newline at end of file diff --git a/manifest.json b/manifest.json index 2ac2f462..bde3a1d9 100644 --- a/manifest.json +++ b/manifest.json @@ -44,3 +44,4 @@ {"name": "mock-bigorg", "repo": "molecule-ai/molecule-ai-org-template-mock-bigorg", "ref": "main"} ] } +// Triggered by Integration Tester at 2026-05-10T08:52Z diff --git a/scripts/clone-manifest.sh b/scripts/clone-manifest.sh index 4e9e5d99..d6e343c8 100755 --- a/scripts/clone-manifest.sh +++ b/scripts/clone-manifest.sh @@ -37,6 +37,50 @@ PLUGINS_DIR="${4:?Missing plugins dir}" EXPECTED=0 CLONED=0 +# clone_one_with_retry — clone a single repo, retrying on transient failure. +# +# Why: the publish-workspace-server-image (and harness-replays) CI jobs +# clone the full manifest (~36 repos) serially on a memory-constrained +# Gitea Actions runner. Under host memory pressure the OOM killer +# occasionally SIGKILLs git-remote-https mid-clone: +# +# error: git-remote-https died of signal 9 +# fatal: the remote end hung up unexpectedly +# +# (observed in publish-workspace-server-image run 4622 on 2026-05-10 — the +# job died on the 14th of 36 clones, which wedged staging→main). One +# transient SIGKILL / network blip would otherwise fail the whole tenant +# image rebuild. Retrying after a short backoff lets the pressure subside. +# The durable fix is more runner RAM/swap (tracked with Infra-SRE); this +# just stops a single flake from being release-blocking. +# +# Args: +clone_one_with_retry() { + local tdir="$1" name="$2" url="$3" display="$4" ref="$5" + local attempt=1 max_attempts=3 backoff + + while : ; do + # A killed attempt can leave a partial directory behind; git clone + # refuses a non-empty target, so wipe it before each try. + rm -rf "$tdir/$name" + + if [ "$ref" = "main" ]; then + if git clone --depth=1 -q "$url" "$tdir/$name"; then return 0; fi + else + if git clone --depth=1 -q --branch "$ref" "$url" "$tdir/$name"; then return 0; fi + fi + + if [ "$attempt" -ge "$max_attempts" ]; then + echo "::error::clone failed after ${max_attempts} attempts: ${display}" >&2 + return 1 + fi + backoff=$((attempt * 3)) # 3s, then 6s + echo " ⚠ clone attempt ${attempt}/${max_attempts} failed for ${display} — retrying in ${backoff}s" >&2 + sleep "$backoff" + attempt=$((attempt + 1)) + done +} + clone_category() { local category="$1" local target_dir="$2" @@ -82,11 +126,7 @@ clone_category() { fi echo " cloning $display_url -> $target_dir/$name (ref=$ref)" - if [ "$ref" = "main" ]; then - git clone --depth=1 -q "$clone_url" "$target_dir/$name" - else - git clone --depth=1 -q --branch "$ref" "$clone_url" "$target_dir/$name" - fi + clone_one_with_retry "$target_dir" "$name" "$clone_url" "$display_url" "$ref" CLONED=$((CLONED + 1)) i=$((i + 1)) done diff --git a/workspace/builtin_tools/a2a_tools.py b/workspace/builtin_tools/a2a_tools.py index acdd15cb..48b813a1 100644 --- a/workspace/builtin_tools/a2a_tools.py +++ b/workspace/builtin_tools/a2a_tools.py @@ -77,6 +77,16 @@ async def delegate_task(workspace_id: str, task: str) -> str: return str(result) if isinstance(result, str) else "(no text)" elif "error" in data: err = data["error"] + # Handle both string-form errors ("error": "some string") + # and object-form errors ("error": {"message": "...", "code": ...}). + msg = "" + if isinstance(err, dict): + msg = err.get("message", "") + elif isinstance(err, str): + msg = err + else: + msg = str(err) + return f"Error: {msg}" msg = "" if isinstance(err, dict): msg = err.get("message", "") diff --git a/workspace/tests/test_a2a_tools_inbox_wrappers.py b/workspace/tests/test_a2a_tools_inbox_wrappers.py index adf5e8a9..64fdba85 100644 --- a/workspace/tests/test_a2a_tools_inbox_wrappers.py +++ b/workspace/tests/test_a2a_tools_inbox_wrappers.py @@ -15,7 +15,6 @@ The wrappers are ~40 LOC of glue. The full delivery behavior """ from __future__ import annotations -import asyncio import json from unittest.mock import MagicMock, patch @@ -29,24 +28,22 @@ def _require_workspace_id(monkeypatch): yield -def _run(coro): - return asyncio.get_event_loop().run_until_complete(coro) - - # --------------------------------------------------------------------------- # tool_inbox_peek # --------------------------------------------------------------------------- class TestToolInboxPeek: - def test_returns_not_enabled_when_state_none(self): + @pytest.mark.asyncio + async def test_returns_not_enabled_when_state_none(self): import a2a_tools with patch("inbox.get_state", return_value=None): - out = _run(a2a_tools.tool_inbox_peek()) + out = await a2a_tools.tool_inbox_peek() assert "not enabled" in out - def test_returns_json_array_of_messages(self): + @pytest.mark.asyncio + async def test_returns_json_array_of_messages(self): import a2a_tools msg1 = MagicMock() @@ -58,20 +55,21 @@ class TestToolInboxPeek: fake_state.peek.return_value = [msg1, msg2] with patch("inbox.get_state", return_value=fake_state): - out = _run(a2a_tools.tool_inbox_peek(limit=5)) + out = await a2a_tools.tool_inbox_peek(limit=5) # peek limit is forwarded fake_state.peek.assert_called_once_with(limit=5) parsed = json.loads(out) assert len(parsed) == 2 assert parsed[0]["activity_id"] == "a1" - def test_non_int_limit_falls_back_to_10(self): + @pytest.mark.asyncio + async def test_non_int_limit_falls_back_to_10(self): import a2a_tools fake_state = MagicMock() fake_state.peek.return_value = [] with patch("inbox.get_state", return_value=fake_state): - _run(a2a_tools.tool_inbox_peek(limit="garbage")) # type: ignore[arg-type] + await a2a_tools.tool_inbox_peek(limit="garbage") # type: ignore[arg-type] fake_state.peek.assert_called_once_with(limit=10) @@ -81,49 +79,54 @@ class TestToolInboxPeek: class TestToolInboxPop: - def test_returns_not_enabled_when_state_none(self): + @pytest.mark.asyncio + async def test_returns_not_enabled_when_state_none(self): import a2a_tools with patch("inbox.get_state", return_value=None): - out = _run(a2a_tools.tool_inbox_pop("act-1")) + out = await a2a_tools.tool_inbox_pop("act-1") assert "not enabled" in out - def test_rejects_empty_activity_id(self): + @pytest.mark.asyncio + async def test_rejects_empty_activity_id(self): import a2a_tools fake_state = MagicMock() with patch("inbox.get_state", return_value=fake_state): - out = _run(a2a_tools.tool_inbox_pop("")) + out = await a2a_tools.tool_inbox_pop("") assert "activity_id is required" in out fake_state.pop.assert_not_called() - def test_rejects_non_str_activity_id(self): + @pytest.mark.asyncio + async def test_rejects_non_str_activity_id(self): import a2a_tools fake_state = MagicMock() with patch("inbox.get_state", return_value=fake_state): - out = _run(a2a_tools.tool_inbox_pop(123)) # type: ignore[arg-type] + out = await a2a_tools.tool_inbox_pop(123) # type: ignore[arg-type] assert "activity_id is required" in out fake_state.pop.assert_not_called() - def test_returns_removed_true_when_popped(self): + @pytest.mark.asyncio + async def test_returns_removed_true_when_popped(self): import a2a_tools fake_state = MagicMock() fake_state.pop.return_value = MagicMock() # truthy = something was removed with patch("inbox.get_state", return_value=fake_state): - out = _run(a2a_tools.tool_inbox_pop("act-7")) + out = await a2a_tools.tool_inbox_pop("act-7") parsed = json.loads(out) assert parsed == {"removed": True, "activity_id": "act-7"} fake_state.pop.assert_called_once_with("act-7") - def test_returns_removed_false_when_unknown(self): + @pytest.mark.asyncio + async def test_returns_removed_false_when_unknown(self): import a2a_tools fake_state = MagicMock() fake_state.pop.return_value = None with patch("inbox.get_state", return_value=fake_state): - out = _run(a2a_tools.tool_inbox_pop("act-missing")) + out = await a2a_tools.tool_inbox_pop("act-missing") parsed = json.loads(out) assert parsed == {"removed": False, "activity_id": "act-missing"} @@ -134,25 +137,28 @@ class TestToolInboxPop: class TestToolWaitForMessage: - def test_returns_not_enabled_when_state_none(self): + @pytest.mark.asyncio + async def test_returns_not_enabled_when_state_none(self): import a2a_tools with patch("inbox.get_state", return_value=None): - out = _run(a2a_tools.tool_wait_for_message(timeout_secs=1.0)) + out = await a2a_tools.tool_wait_for_message(timeout_secs=1.0) assert "not enabled" in out - def test_timeout_payload_when_no_message(self): + @pytest.mark.asyncio + async def test_timeout_payload_when_no_message(self): import a2a_tools fake_state = MagicMock() fake_state.wait.return_value = None with patch("inbox.get_state", return_value=fake_state): - out = _run(a2a_tools.tool_wait_for_message(timeout_secs=0.1)) + out = await a2a_tools.tool_wait_for_message(timeout_secs=0.1) parsed = json.loads(out) assert parsed["timeout"] is True assert parsed["timeout_secs"] == 0.1 - def test_returns_message_when_delivered(self): + @pytest.mark.asyncio + async def test_returns_message_when_delivered(self): import a2a_tools msg = MagicMock() @@ -160,37 +166,40 @@ class TestToolWaitForMessage: fake_state = MagicMock() fake_state.wait.return_value = msg with patch("inbox.get_state", return_value=fake_state): - out = _run(a2a_tools.tool_wait_for_message(timeout_secs=2.0)) + out = await a2a_tools.tool_wait_for_message(timeout_secs=2.0) parsed = json.loads(out) assert parsed["activity_id"] == "a-9" - def test_timeout_clamped_to_300(self): + @pytest.mark.asyncio + async def test_timeout_clamped_to_300(self): import a2a_tools fake_state = MagicMock() fake_state.wait.return_value = None with patch("inbox.get_state", return_value=fake_state): - _run(a2a_tools.tool_wait_for_message(timeout_secs=99999)) + await a2a_tools.tool_wait_for_message(timeout_secs=99999) # Whatever wait was called with, it must not exceed 300 passed = fake_state.wait.call_args.args[0] assert passed == 300.0 - def test_timeout_clamped_to_zero_floor(self): + @pytest.mark.asyncio + async def test_timeout_clamped_to_zero_floor(self): import a2a_tools fake_state = MagicMock() fake_state.wait.return_value = None with patch("inbox.get_state", return_value=fake_state): - _run(a2a_tools.tool_wait_for_message(timeout_secs=-5)) + await a2a_tools.tool_wait_for_message(timeout_secs=-5) passed = fake_state.wait.call_args.args[0] assert passed == 0.0 - def test_non_numeric_timeout_falls_back_to_60(self): + @pytest.mark.asyncio + async def test_non_numeric_timeout_falls_back_to_60(self): import a2a_tools fake_state = MagicMock() fake_state.wait.return_value = None with patch("inbox.get_state", return_value=fake_state): - _run(a2a_tools.tool_wait_for_message(timeout_secs="garbage")) # type: ignore[arg-type] + await a2a_tools.tool_wait_for_message(timeout_secs="garbage") # type: ignore[arg-type] passed = fake_state.wait.call_args.args[0] assert passed == 60.0