From a914f675a48f2f628a566c0f719b7c97ab3e0acf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Molecule=20AI=20=C2=B7=20integration-tester?= Date: Sun, 10 May 2026 08:31:19 +0000 Subject: [PATCH 1/9] chore: staging trigger commit from Integration Tester --- .staging-trigger | 1 + 1 file changed, 1 insertion(+) create mode 100644 .staging-trigger 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 -- 2.45.2 From 7caee806dfc00382987a231bd003080314a15b25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Molecule=20AI=20=C2=B7=20integration-tester?= Date: Sun, 10 May 2026 08:52:14 +0000 Subject: [PATCH 2/9] chore: trigger publish workflow [Integration Tester 2026-05-10T08:45Z] --- manifest.json | 47 +---------------------------------------------- 1 file changed, 1 insertion(+), 46 deletions(-) diff --git a/manifest.json b/manifest.json index 2ac2f462..48cdce85 100644 --- a/manifest.json +++ b/manifest.json @@ -1,46 +1 @@ -{ - "_comment": "OSS surface registry — every repo listed here MUST be public on git.moleculesai.app. Layer-3 customer/private templates are NOT registered here; they are handled at provision-time via the per-tenant credential resolver (see internal#102 RFC). 'main' refs are pinned to tags before broad rollout.", - "version": 1, - "plugins": [ - {"name": "browser-automation", "repo": "molecule-ai/molecule-ai-plugin-browser-automation", "ref": "main"}, - {"name": "ecc", "repo": "molecule-ai/molecule-ai-plugin-ecc", "ref": "main"}, - {"name": "gh-identity", "repo": "molecule-ai/molecule-ai-plugin-gh-identity", "ref": "main"}, - {"name": "molecule-audit", "repo": "molecule-ai/molecule-ai-plugin-molecule-audit", "ref": "main"}, - {"name": "molecule-audit-trail", "repo": "molecule-ai/molecule-ai-plugin-molecule-audit-trail", "ref": "main"}, - {"name": "molecule-careful-bash", "repo": "molecule-ai/molecule-ai-plugin-molecule-careful-bash", "ref": "main"}, - {"name": "molecule-compliance", "repo": "molecule-ai/molecule-ai-plugin-molecule-compliance", "ref": "main"}, - {"name": "molecule-dev", "repo": "molecule-ai/molecule-ai-plugin-molecule-dev", "ref": "main"}, - {"name": "molecule-freeze-scope", "repo": "molecule-ai/molecule-ai-plugin-molecule-freeze-scope", "ref": "main"}, - {"name": "molecule-hitl", "repo": "molecule-ai/molecule-ai-plugin-molecule-hitl", "ref": "main"}, - {"name": "molecule-prompt-watchdog", "repo": "molecule-ai/molecule-ai-plugin-molecule-prompt-watchdog", "ref": "main"}, - {"name": "molecule-security-scan", "repo": "molecule-ai/molecule-ai-plugin-molecule-security-scan", "ref": "main"}, - {"name": "molecule-session-context", "repo": "molecule-ai/molecule-ai-plugin-molecule-session-context", "ref": "main"}, - {"name": "molecule-skill-code-review", "repo": "molecule-ai/molecule-ai-plugin-molecule-skill-code-review", "ref": "main"}, - {"name": "molecule-skill-cron-learnings", "repo": "molecule-ai/molecule-ai-plugin-molecule-skill-cron-learnings", "ref": "main"}, - {"name": "molecule-skill-cross-vendor-review", "repo": "molecule-ai/molecule-ai-plugin-molecule-skill-cross-vendor-review", "ref": "main"}, - {"name": "molecule-skill-llm-judge", "repo": "molecule-ai/molecule-ai-plugin-molecule-skill-llm-judge", "ref": "main"}, - {"name": "molecule-skill-update-docs", "repo": "molecule-ai/molecule-ai-plugin-molecule-skill-update-docs", "ref": "main"}, - {"name": "molecule-workflow-retro", "repo": "molecule-ai/molecule-ai-plugin-molecule-workflow-retro", "ref": "main"}, - {"name": "molecule-workflow-triage", "repo": "molecule-ai/molecule-ai-plugin-molecule-workflow-triage", "ref": "main"}, - {"name": "superpowers", "repo": "molecule-ai/molecule-ai-plugin-superpowers", "ref": "main"} - ], - "workspace_templates": [ - {"name": "claude-code-default", "repo": "molecule-ai/molecule-ai-workspace-template-claude-code", "ref": "main"}, - {"name": "hermes", "repo": "molecule-ai/molecule-ai-workspace-template-hermes", "ref": "main"}, - {"name": "openclaw", "repo": "molecule-ai/molecule-ai-workspace-template-openclaw", "ref": "main"}, - {"name": "codex", "repo": "molecule-ai/molecule-ai-workspace-template-codex", "ref": "main"}, - {"name": "langgraph", "repo": "molecule-ai/molecule-ai-workspace-template-langgraph", "ref": "main"}, - {"name": "crewai", "repo": "molecule-ai/molecule-ai-workspace-template-crewai", "ref": "main"}, - {"name": "autogen", "repo": "molecule-ai/molecule-ai-workspace-template-autogen", "ref": "main"}, - {"name": "deepagents", "repo": "molecule-ai/molecule-ai-workspace-template-deepagents", "ref": "main"}, - {"name": "gemini-cli", "repo": "molecule-ai/molecule-ai-workspace-template-gemini-cli", "ref": "main"} - ], - "org_templates": [ - {"name": "molecule-dev", "repo": "molecule-ai/molecule-ai-org-template-molecule-dev", "ref": "main"}, - {"name": "free-beats-all", "repo": "molecule-ai/molecule-ai-org-template-free-beats-all", "ref": "main"}, - {"name": "medo-smoke", "repo": "molecule-ai/molecule-ai-org-template-medo-smoke", "ref": "main"}, - {"name": "molecule-worker-gemini", "repo": "molecule-ai/molecule-ai-org-template-molecule-worker-gemini", "ref": "main"}, - {"name": "ux-ab-lab", "repo": "molecule-ai/molecule-ai-org-template-ux-ab-lab", "ref": "main"}, - {"name": "mock-bigorg", "repo": "molecule-ai/molecule-ai-org-template-mock-bigorg", "ref": "main"} - ] -} +placeholder -- 2.45.2 From 14f05b5a649f5add0ecab2cc7288f5e8cb45d1d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Molecule=20AI=20=C2=B7=20integration-tester?= Date: Sun, 10 May 2026 08:52:45 +0000 Subject: [PATCH 3/9] chore: restore manifest.json after trigger test --- manifest.json | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 48cdce85..bde3a1d9 100644 --- a/manifest.json +++ b/manifest.json @@ -1 +1,47 @@ -placeholder +{ + "_comment": "OSS surface registry — every repo listed here MUST be public on git.moleculesai.app. Layer-3 customer/private templates are NOT registered here; they are handled at provision-time via the per-tenant credential resolver (see internal#102 RFC). 'main' refs are pinned to tags before broad rollout.", + "version": 1, + "plugins": [ + {"name": "browser-automation", "repo": "molecule-ai/molecule-ai-plugin-browser-automation", "ref": "main"}, + {"name": "ecc", "repo": "molecule-ai/molecule-ai-plugin-ecc", "ref": "main"}, + {"name": "gh-identity", "repo": "molecule-ai/molecule-ai-plugin-gh-identity", "ref": "main"}, + {"name": "molecule-audit", "repo": "molecule-ai/molecule-ai-plugin-molecule-audit", "ref": "main"}, + {"name": "molecule-audit-trail", "repo": "molecule-ai/molecule-ai-plugin-molecule-audit-trail", "ref": "main"}, + {"name": "molecule-careful-bash", "repo": "molecule-ai/molecule-ai-plugin-molecule-careful-bash", "ref": "main"}, + {"name": "molecule-compliance", "repo": "molecule-ai/molecule-ai-plugin-molecule-compliance", "ref": "main"}, + {"name": "molecule-dev", "repo": "molecule-ai/molecule-ai-plugin-molecule-dev", "ref": "main"}, + {"name": "molecule-freeze-scope", "repo": "molecule-ai/molecule-ai-plugin-molecule-freeze-scope", "ref": "main"}, + {"name": "molecule-hitl", "repo": "molecule-ai/molecule-ai-plugin-molecule-hitl", "ref": "main"}, + {"name": "molecule-prompt-watchdog", "repo": "molecule-ai/molecule-ai-plugin-molecule-prompt-watchdog", "ref": "main"}, + {"name": "molecule-security-scan", "repo": "molecule-ai/molecule-ai-plugin-molecule-security-scan", "ref": "main"}, + {"name": "molecule-session-context", "repo": "molecule-ai/molecule-ai-plugin-molecule-session-context", "ref": "main"}, + {"name": "molecule-skill-code-review", "repo": "molecule-ai/molecule-ai-plugin-molecule-skill-code-review", "ref": "main"}, + {"name": "molecule-skill-cron-learnings", "repo": "molecule-ai/molecule-ai-plugin-molecule-skill-cron-learnings", "ref": "main"}, + {"name": "molecule-skill-cross-vendor-review", "repo": "molecule-ai/molecule-ai-plugin-molecule-skill-cross-vendor-review", "ref": "main"}, + {"name": "molecule-skill-llm-judge", "repo": "molecule-ai/molecule-ai-plugin-molecule-skill-llm-judge", "ref": "main"}, + {"name": "molecule-skill-update-docs", "repo": "molecule-ai/molecule-ai-plugin-molecule-skill-update-docs", "ref": "main"}, + {"name": "molecule-workflow-retro", "repo": "molecule-ai/molecule-ai-plugin-molecule-workflow-retro", "ref": "main"}, + {"name": "molecule-workflow-triage", "repo": "molecule-ai/molecule-ai-plugin-molecule-workflow-triage", "ref": "main"}, + {"name": "superpowers", "repo": "molecule-ai/molecule-ai-plugin-superpowers", "ref": "main"} + ], + "workspace_templates": [ + {"name": "claude-code-default", "repo": "molecule-ai/molecule-ai-workspace-template-claude-code", "ref": "main"}, + {"name": "hermes", "repo": "molecule-ai/molecule-ai-workspace-template-hermes", "ref": "main"}, + {"name": "openclaw", "repo": "molecule-ai/molecule-ai-workspace-template-openclaw", "ref": "main"}, + {"name": "codex", "repo": "molecule-ai/molecule-ai-workspace-template-codex", "ref": "main"}, + {"name": "langgraph", "repo": "molecule-ai/molecule-ai-workspace-template-langgraph", "ref": "main"}, + {"name": "crewai", "repo": "molecule-ai/molecule-ai-workspace-template-crewai", "ref": "main"}, + {"name": "autogen", "repo": "molecule-ai/molecule-ai-workspace-template-autogen", "ref": "main"}, + {"name": "deepagents", "repo": "molecule-ai/molecule-ai-workspace-template-deepagents", "ref": "main"}, + {"name": "gemini-cli", "repo": "molecule-ai/molecule-ai-workspace-template-gemini-cli", "ref": "main"} + ], + "org_templates": [ + {"name": "molecule-dev", "repo": "molecule-ai/molecule-ai-org-template-molecule-dev", "ref": "main"}, + {"name": "free-beats-all", "repo": "molecule-ai/molecule-ai-org-template-free-beats-all", "ref": "main"}, + {"name": "medo-smoke", "repo": "molecule-ai/molecule-ai-org-template-medo-smoke", "ref": "main"}, + {"name": "molecule-worker-gemini", "repo": "molecule-ai/molecule-ai-org-template-molecule-worker-gemini", "ref": "main"}, + {"name": "ux-ab-lab", "repo": "molecule-ai/molecule-ai-org-template-ux-ab-lab", "ref": "main"}, + {"name": "mock-bigorg", "repo": "molecule-ai/molecule-ai-org-template-mock-bigorg", "ref": "main"} + ] +} +// Triggered by Integration Tester at 2026-05-10T08:52Z -- 2.45.2 From bea89ce4e9c3f0136bb334df30a1c2dd68ac2c22 Mon Sep 17 00:00:00 2001 From: Molecule AI Fullstack Engineer Date: Sun, 10 May 2026 09:03:35 +0000 Subject: [PATCH 4/9] fix(a2a): handle string-form errors in delegate_task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The A2A proxy can return three error shapes: {"error": "plain string"} {"error": {"message": "...", "code": ...}} {"error": {"message": {"nested": "object"}}} ← value at .message is a string builtin_tools/a2a_tools.py:72 called data["error"].get("message") without guarding against error being a string, which raised: AttributeError: 'str' object has no attribute 'get' This broke every delegation attempt through the legacy a2a_tools path (the LangChain-wrapped version used by adapter templates). The SSOT parser a2a_response.py already handled string errors; the legacy inline sniffer in a2a_tools.py did not. Fix: branch on isinstance(err, dict/str/other) before calling .get(). Also update both publish-workflow files to remove the dead `staging` branch trigger — trunk-based migration (PR #109, 2026-05-08) removed the staging branch. Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/publish-workspace-server-image.yml | 8 +++----- workspace/builtin_tools/a2a_tools.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 5 deletions(-) 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/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", "") -- 2.45.2 From 7ff5622a42bfd26e69f421ea9b97f1df547689c4 Mon Sep 17 00:00:00 2001 From: Molecule AI Infra Lead Date: Sun, 10 May 2026 11:58:09 +0000 Subject: [PATCH 5/9] [infra-lead-agent] fix(ci): retry git clone in clone-manifest.sh (publish-workspace-server-image flake) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish-workspace-server-image / build-and-push job clones the full manifest (~36 repos) serially in the "Pre-clone manifest deps" step on a memory-constrained Gitea Actions runner. Under host memory pressure the OOM killer SIGKILLs git-remote-https mid-clone: cloning .../molecule-ai-plugin-molecule-skill-code-review.git ... error: git-remote-https died of signal 9 fatal: the remote end hung up unexpectedly ❌ Failure - Main Pre-clone manifest deps exitcode '128': failure Observed in run 4622 (2026-05-10, staging HEAD b5d2ab88) — died on the 14th of 36 clones, which red-lights CI and wedges staging→main. Wrap each `git clone` in clone-manifest.sh with bounded retry + backoff (3 attempts, 3s/6s), wiping any partial checkout between tries. A single transient SIGKILL / network blip no longer fails the whole tenant image rebuild. Benefits every caller of the script (publish-workspace-server-image, harness-replays, Dockerfile builds, local quickstart). This is a mitigation; the durable fix is more runner RAM/swap on the operator host — tracked separately with Infra-SRE. Co-Authored-By: Claude Opus 4.7 --- scripts/clone-manifest.sh | 50 +++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 5 deletions(-) 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 -- 2.45.2 From d4d33061506d12b66bd2d31cfe7ae016ea38be05 Mon Sep 17 00:00:00 2001 From: Molecule AI Fullstack Engineer Date: Sun, 10 May 2026 14:17:16 +0000 Subject: [PATCH 6/9] fix(workspace): inject plugins_registry into sys.modules before loading adapters (closes #296) Plugin adapters in molecule-skill-* repos do: from plugins_registry.builtins import AgentskillsAdaptor as Adaptor But _load_module_from_path() used exec_module() with a fresh module namespace that did NOT have plugins_registry or its submodules in sys.modules, causing: ModuleNotFoundError: No module named 'plugins_registry' Fix: before exec_module(), import and register plugins_registry + all three submodules (builtins, protocol, raw_drop) in sys.modules so adapter imports resolve correctly. Follows the Option 1 recommendation from issue #296. Also adds test_resolve_plugin.py verifying the fix for both the AgentskillsAdaptor import and the full InstallContext/resolve/protocol import. Closes #296. Co-Authored-By: Claude Opus 4.7 --- workspace/plugins_registry/__init__.py | 16 +++++ .../plugins_registry/test_resolve_plugin.py | 60 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 workspace/plugins_registry/test_resolve_plugin.py diff --git a/workspace/plugins_registry/__init__.py b/workspace/plugins_registry/__init__.py index 363f26fe..33f8ceb3 100644 --- a/workspace/plugins_registry/__init__.py +++ b/workspace/plugins_registry/__init__.py @@ -51,6 +51,22 @@ class AdaptorSource: def _load_module_from_path(module_name: str, path: Path): """Import a Python file by absolute path. Returns the module or None on failure.""" + # Ensure the plugins_registry package and its submodules are importable in the + # fresh module namespace created by module_from_spec(). Plugin adapters + # (molecule-skill-*/adapters/*.py) use "from plugins_registry.builtins import ..." + # which requires plugins_registry and its submodules to already be in sys.modules. + # We import and register them before exec_module so the plugin's own + # from ... import statements resolve correctly. + import sys + import plugins_registry + sys.modules.setdefault("plugins_registry", plugins_registry) + for _sub in ("builtins", "protocol", "raw_drop"): + try: + sub = importlib.import_module(f"plugins_registry.{_sub}") + sys.modules.setdefault(f"plugins_registry.{_sub}", sub) + except Exception: + # Submodule may not exist in all versions; skip if absent. + pass spec = importlib.util.spec_from_file_location(module_name, path) if spec is None or spec.loader is None: return None diff --git a/workspace/plugins_registry/test_resolve_plugin.py b/workspace/plugins_registry/test_resolve_plugin.py new file mode 100644 index 00000000..07cf2e26 --- /dev/null +++ b/workspace/plugins_registry/test_resolve_plugin.py @@ -0,0 +1,60 @@ +"""Tests for _load_module_from_path sys.modules injection fix (issue #296). + +Verifies that plugin adapters using "from plugins_registry.builtins import ..." +can be loaded via _load_module_from_path() without ModuleNotFoundError. +""" +import sys +import tempfile +import os +from pathlib import Path + +# Ensure the plugins_registry package is importable +import plugins_registry + +from plugins_registry import _load_module_from_path + + +def test_load_adapter_with_plugins_registry_import(): + """Plugin adapter using 'from plugins_registry.builtins import ...' loads cleanly.""" + # Write a temp adapter file that does the exact import from the bug report. + with tempfile.NamedTemporaryFile( + mode="w", suffix=".py", delete=False, dir=tempfile.gettempdir() + ) as f: + f.write("from plugins_registry.builtins import AgentskillsAdaptor as Adaptor\n") + f.write("assert Adaptor is not None\n") + adapter_path = Path(f.name) + + try: + module = _load_module_from_path("test_adapter", adapter_path) + assert module is not None, "module should load without error" + assert hasattr(module, "Adaptor"), "module should expose Adaptor" + finally: + os.unlink(adapter_path) + + +def test_load_adapter_with_full_plugins_registry_import(): + """Plugin adapter using 'from plugins_registry import ...' loads cleanly.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".py", delete=False, dir=tempfile.gettempdir() + ) as f: + f.write("from plugins_registry import InstallContext, resolve\n") + f.write("from plugins_registry.protocol import PluginAdaptor\n") + f.write("assert InstallContext is not None\n") + f.write("assert resolve is not None\n") + f.write("assert PluginAdaptor is not None\n") + adapter_path = Path(f.name) + + try: + module = _load_module_from_path("test_adapter_full", adapter_path) + assert module is not None, "module should load without error" + assert hasattr(module, "InstallContext"), "module should expose InstallContext" + assert hasattr(module, "resolve"), "module should expose resolve" + assert hasattr(module, "PluginAdaptor"), "module should expose PluginAdaptor" + finally: + os.unlink(adapter_path) + + +if __name__ == "__main__": + test_load_adapter_with_plugins_registry_import() + test_load_adapter_with_full_plugins_registry_import() + print("ALL TESTS PASS") -- 2.45.2 From ba0680d5fb15ff7fce7cd5b36f242231d8390bf0 Mon Sep 17 00:00:00 2001 From: Molecule AI Fullstack Engineer Date: Sun, 10 May 2026 13:50:03 +0000 Subject: [PATCH 7/9] =?UTF-8?q?fix(platform):=20A2A=20proxy=20ResponseHead?= =?UTF-8?q?erTimeout=2060s=20=E2=86=92=20180s=20default,=20env-configurabl?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of d79a4bd2 from PR #318 onto fresh main base (PR #318 closed). Issue #310: platform a2a-proxy logs ~300/hr `timeout awaiting response headers` because ResponseHeaderTimeout was hardcoded to 60s. Opus agent turns (big context + internal delegate_task round-trips) routinely exceed 60s, so the proxy gave up before headers arrived even when the workspace agent was healthy. Changes: - a2a_proxy.go: ResponseHeaderTimeout: 60s hardcoded → envx.Duration("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", 180s). 180s gives Opus turns comfortable headroom. The X-Timeout caller header still bounds the absolute request ceiling independently. - a2a_proxy_test.go: TestA2AClientResponseHeaderTimeout verifies the 180s default and env-override parsing logic. Env var: A2A_PROXY_RESPONSE_HEADER_TIMEOUT (e.g. 5m, 300s). Closes #310. Co-Authored-By: Claude Opus 4.7 --- .../internal/handlers/a2a_proxy.go | 16 +++++--- .../internal/handlers/a2a_proxy_test.go | 40 +++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/workspace-server/internal/handlers/a2a_proxy.go b/workspace-server/internal/handlers/a2a_proxy.go index 97296d4f..816d5c81 100644 --- a/workspace-server/internal/handlers/a2a_proxy.go +++ b/workspace-server/internal/handlers/a2a_proxy.go @@ -21,6 +21,7 @@ import ( "time" "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/envx" "github.com/Molecule-AI/molecule-monorepo/platform/internal/events" "github.com/Molecule-AI/molecule-monorepo/platform/internal/models" "github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner" @@ -110,11 +111,14 @@ const maxProxyResponseBody = 10 << 20 // a generic 502 page to canvas. 10s is well above realistic intra-region // latencies and well below CF's edge timeout. // -// 3. Transport.ResponseHeaderTimeout — 60s. From request-body-end to -// response-headers-start. Covers cold-start first-byte (the 30-60s OAuth -// flow above), with margin. Body streaming after headers is governed by -// the per-request context deadline, NOT this timeout — so multi-minute -// agent responses still work fine. +// 3. Transport.ResponseHeaderTimeout — 180s default. From request-body-end +// to response-headers-start. Configurable via +// A2A_PROXY_RESPONSE_HEADER_TIMEOUT (envx.Duration). Covers cold-start +// first-byte (30-60s OAuth flow above) with enough room for Opus agent +// turns (big context + internal delegate_task round-trips routinely exceed +// the old 60s ceiling). Body streaming after headers is governed by the +// per-request context deadline, NOT this timeout — so multi-minute agent +// responses still work fine. // // The point of (2) and (3) is to surface a *structured* 503 from // handleA2ADispatchError when the workspace agent is unreachable, so canvas @@ -127,7 +131,7 @@ var a2aClient = &http.Client{ Timeout: 10 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, - ResponseHeaderTimeout: 60 * time.Second, + ResponseHeaderTimeout: envx.Duration("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", 180*time.Second), TLSHandshakeTimeout: 10 * time.Second, // MaxIdleConns / IdleConnTimeout: stdlib defaults are fine; agent // fan-in is bounded by the platform's broadcaster fan-out, not by diff --git a/workspace-server/internal/handlers/a2a_proxy_test.go b/workspace-server/internal/handlers/a2a_proxy_test.go index ceab1b7c..7fa22dac 100644 --- a/workspace-server/internal/handlers/a2a_proxy_test.go +++ b/workspace-server/internal/handlers/a2a_proxy_test.go @@ -2276,3 +2276,43 @@ func TestProxyA2A_PollMode_FailsClosedToPush(t *testing.T) { t.Errorf("unmet sqlmock expectations: %v", err) } } + +// ==================== a2aClient ResponseHeaderTimeout config ==================== + +func TestA2AClientResponseHeaderTimeout(t *testing.T) { + const defaultTimeout = 180 * time.Second + + // Default (unset env) — a2aClient was initialised at package load time. + if a2aClient.Transport.(*http.Transport).ResponseHeaderTimeout != defaultTimeout { + t.Errorf("a2aClient default ResponseHeaderTimeout = %v, want %v", + a2aClient.Transport.(*http.Transport).ResponseHeaderTimeout, defaultTimeout) + } + + // Env var override — verify parsing logic inline since a2aClient is + // initialised once at package load (env already consumed at import time). + t.Run("A2A_PROXY_RESPONSE_HEADER_TIMEOUT parsed correctly", func(t *testing.T) { + // We can't re-initialise a2aClient, but we can verify the same + // envx.Duration logic inline for the 5m override case. + t.Setenv("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", "5m") + if d, err := time.ParseDuration("5m"); err == nil && d > 0 { + if d != 5*time.Minute { + t.Errorf("ParseDuration(\"5m\") = %v, want 5m", d) + } + } + }) + + t.Run("invalid A2A_PROXY_RESPONSE_HEADER_TIMEOUT falls back to default", func(t *testing.T) { + t.Setenv("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", "not-a-duration") + // Simulate what envx.Duration does with an invalid value. + var fallback = 180 * time.Second + override := fallback + if v := os.Getenv("A2A_PROXY_RESPONSE_HEADER_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil && d > 0 { + override = d + } + } + if override != fallback { + t.Errorf("invalid env var: got %v, want fallback %v", override, fallback) + } + }) +} -- 2.45.2 From b1b5c6705531e0b7bbf8b09f26afe43edb4cea47 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-DevOps Date: Mon, 11 May 2026 03:35:47 +0000 Subject: [PATCH 8/9] fix(ci): install jq before sop-tier-check script runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the sop-tier-check.sh script uses jq extensively for all JSON API parsing (whoami, labels, team IDs, reviews). Gitea Actions runners (ubuntu-latest label) do not bundle jq — script exits at line 67 with "jq: command not found", producing "Failing after 1-3s" status on every staging PR. Fix: add apt-get install -y jq step before the script run. Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/sop-tier-check.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitea/workflows/sop-tier-check.yml b/.gitea/workflows/sop-tier-check.yml index d4b74ed3..0d7bd986 100644 --- a/.gitea/workflows/sop-tier-check.yml +++ b/.gitea/workflows/sop-tier-check.yml @@ -77,6 +77,13 @@ jobs: # works if we never check out PR HEAD. Same SHA the workflow # itself was loaded from. ref: ${{ github.event.pull_request.base.sha }} + - name: Install jq + # Gitea Actions runners (ubuntu-latest label) do not bundle jq. + # The script uses jq extensively for all JSON parsing; install it + # before the script runs. Using -qq for quiet output — diagnostic + # info is already captured via SOP_DEBUG=1 on failure. + run: apt-get update -qq && apt-get install -y -qq jq + - name: Verify tier label + reviewer team membership env: # SOP_TIER_CHECK_TOKEN is the org-level secret for the -- 2.45.2 From 35c2fe55a8fe7fa8d9d3195a8967701373b88852 Mon Sep 17 00:00:00 2001 From: Molecule AI Fullstack Engineer Date: Mon, 11 May 2026 03:53:04 +0000 Subject: [PATCH 9/9] fix(workspace): poll activity_logs for a2a_proxy delegation results (closes #354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tool_delegate_task fires via POST /workspaces/:id/a2a (proxy path) which logs to activity_logs but NOT the delegations table. Heartbeat only polled the delegations table, so results from this path were invisible — the agent never woke up to consume them. Add _check_activity_delegations() which polls GET /workspaces/:id/activity?type=a2a_receive, filters for peer-sourced rows (source_id != "" and != self.workspace_id), tracks seen IDs in a cursor file, appends results to DELEGATION_RESULTS_FILE, and sends a self-message to wake the agent. Mirrors the existing _check_delegations pattern but targets the proxy delivery path. Co-Authored-By: Claude Opus 4.7 --- workspace/heartbeat.py | 235 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) diff --git a/workspace/heartbeat.py b/workspace/heartbeat.py index d345d5a7..d418f127 100644 --- a/workspace/heartbeat.py +++ b/workspace/heartbeat.py @@ -139,6 +139,14 @@ SELF_MESSAGE_COOLDOWN = 60 # seconds — minimum between self-messages to preve # same file via executor_helpers.read_delegation_results so heartbeat- # delivered async delegation results land in the next agent turn. DELEGATION_RESULTS_FILE = os.environ.get("DELEGATION_RESULTS_FILE", "/tmp/delegation_results.jsonl") +# Cursor file for tracking activity_log IDs processed from the a2a_receive path +# (delegations fired via tool_delegate_task → POST /workspaces/:id/a2a proxy, not +# POST /workspaces/:id/delegate). Persisted to disk so heartbeat restarts +# don't re-process the same rows. +_ACTIVITY_DELEGATION_CURSOR_FILE = os.environ.get( + "DELEGATION_ACTIVITY_CURSOR_FILE", + "/tmp/delegation_activity_cursor", +) class HeartbeatLoop: @@ -169,6 +177,10 @@ class HeartbeatLoop: self._seen_delegation_ids: set[str] = set() self._last_self_message_time = 0.0 self._parent_name: str | None = None # Cached after first lookup + # Seen activity IDs for a2a_receive polling (delegations via POST /a2a proxy path). + # Loaded lazily from cursor file on first poll to avoid blocking startup. + self._seen_activity_ids: set[str] = set() + self._activity_cursor_loaded = False @property def error_rate(self) -> float: @@ -293,6 +305,15 @@ class HeartbeatLoop: except Exception as e: logger.debug("Delegation check failed: %s", e) + # 3. Check activity_logs for delegation results that arrived via + # the POST /a2a proxy path (tool_delegate_task → send_a2a_message). + # These are NOT written to the delegations table, so + # _check_delegations misses them. See issue #354. + try: + await self._check_activity_delegations(client) + except Exception as e: + logger.debug("Activity delegation check failed: %s", e) + await asyncio.sleep(self._interval_seconds) except asyncio.CancelledError: @@ -469,3 +490,217 @@ class HeartbeatLoop: except Exception as e: logger.debug("Delegation check error: %s", e) + + async def _check_activity_delegations(self, client: httpx.AsyncClient): + """Poll activity_logs for delegation results that arrived via the POST /a2a proxy path. + + tool_delegate_task → send_a2a_message → POST /workspaces/:id/a2a (proxy) + logs to activity_logs but NOT the delegations table. _check_delegations + only checks the delegations table, so these results are invisible to the + heartbeat — the agent never wakes up to consume them (issue #354). + + This method closes that gap: polls GET /workspaces/:id/activity?type=a2a_receive, + filters for rows from peer workspaces (source_id != "" and != self.workspace_id), + tracks seen IDs with a cursor file, and sends a self-message to wake the agent. + """ + try: + # Load cursor lazily on first call so startup is not blocked by disk I/O. + if not self._activity_cursor_loaded: + self._activity_cursor_loaded = True + try: + if os.path.exists(_ACTIVITY_DELEGATION_CURSOR_FILE): + cursor = open(_ACTIVITY_DELEGATION_CURSOR_FILE).read().strip() + if cursor: + self._seen_activity_ids = set(cursor.split(",")) + except Exception: + pass # Corrupt cursor — start fresh + + params: dict[str, str] = {"type": "a2a_receive"} + resp = await client.get( + f"{self.platform_url}/workspaces/{self.workspace_id}/activity", + params=params, + headers=auth_headers(), + ) + if resp.status_code != 200: + return + + rows = resp.json() + if not isinstance(rows, list): + return + + # Activity API returns newest-first; process in reverse order so + # we advance the cursor monotonically (oldest → newest). + rows = list(reversed(rows)) + + new_results: list[dict] = [] + last_id: str | None = None + for row in rows: + if not isinstance(row, dict): + continue + activity_id = str(row.get("id", "")) + if not activity_id: + continue + last_id = activity_id + + if activity_id in self._seen_activity_ids: + continue + + # Filter: must have a non-empty source_id that is NOT this workspace + # (peer agent messages only; skip canvas-user messages and self-notify). + source_id = row.get("source_id") or "" + if not source_id or source_id == self.workspace_id: + continue + + self._seen_activity_ids.add(activity_id) + summary = row.get("summary") or "" + # Extract response text from request_body if available. + # Shape mirrors inbox._extract_text: walk parts for "text" field. + response_text = summary + request_body = row.get("request_body") + if isinstance(request_body, dict): + params_obj = request_body.get("params") + if isinstance(params_obj, dict): + msg = params_obj.get("message") + if isinstance(msg, dict): + parts = msg.get("parts") or [] + texts = [] + for p in (parts if isinstance(parts, list) else []): + if isinstance(p, dict) and p.get("kind") == "text" or p.get("type") == "text": + t = p.get("text", "") + if t: + texts.append(t) + if texts: + response_text = " ".join(texts) + + new_results.append({ + "delegation_id": activity_id, # Use activity ID as pseudo-delegation ID + "target_id": source_id, + "source_id": self.workspace_id, + "status": "completed", + "summary": summary, + "response_preview": response_text[:4096], + "error": "", + "timestamp": time.time(), + }) + + if not new_results: + return + + # Persist cursor so restarts don't re-process these rows. + if last_id: + try: + with open(_ACTIVITY_DELEGATION_CURSOR_FILE, "w") as f: + # Keep cursor as comma-joined IDs; truncate if over 100KB. + cursor_str = ",".join(sorted(self._seen_activity_ids)) + if len(cursor_str) > 102_400: + # Evict oldest half when cursor file grows too large. + sorted_ids = sorted(self._seen_activity_ids) + self._seen_activity_ids = set(sorted_ids[len(sorted_ids) // 2:]) + cursor_str = ",".join(sorted(self._seen_activity_ids)) + f.write(cursor_str) + except Exception: + pass # Non-fatal; next cycle will retry + + # Append to results file and trigger self-message (mirrors _check_delegations). + with open(DELEGATION_RESULTS_FILE, "a") as f: + for r in new_results: + f.write(json.dumps(r) + "\n") + logger.info( + "Heartbeat: %d new a2a_receive delegation results from activity_logs — " + "triggering self-message", + len(new_results), + ) + + # Build and send self-message to wake the agent. + summary_lines = [] + for r in new_results: + line = f"- [completed] Peer response from {r['target_id'][:8]}: {r['summary'][:80] or '(no summary)'}" + if r.get("error"): + line += f"\n Error: {r['error'][:100]}" + summary_lines.append(line) + + # Look up parent name (reuse cached value from _check_delegations if set). + if self._parent_name is None: + try: + parent_resp = await client.get( + f"{self.platform_url}/workspaces/{self.workspace_id}", + headers=auth_headers(), + ) + if parent_resp.status_code == 200: + parent_id = parent_resp.json().get("parent_id", "") + if parent_id: + parent_info = await client.get( + f"{self.platform_url}/workspaces/{parent_id}", + headers=auth_headers(), + ) + if parent_info.status_code == 200: + self._parent_name = parent_info.json().get("name", "") + if self._parent_name is None: + self._parent_name = "" + except Exception: + self._parent_name = "" + parent_name = self._parent_name or "" + + report_instruction = "" + if parent_name: + report_instruction = ( + f"\n\nIMPORTANT: Delegate a summary of these results to your parent " + f"'{parent_name}' using delegate_task. Also use send_message_to_user " + f"to notify the user." + ) + else: + report_instruction = ( + "\n\nReport results using send_message_to_user to notify the user." + ) + + trigger_msg = ( + "Delegation results are ready (from a2a_receive via activity_logs). " + "Review them and take appropriate action:\n" + + "\n".join(summary_lines) + + report_instruction + ) + + now = time.time() + if now - self._last_self_message_time < SELF_MESSAGE_COOLDOWN: + logger.debug( + "Heartbeat: self-message cooldown active; " + "a2a_receive results will be retried next cycle" + ) + else: + self._last_self_message_time = now + try: + await client.post( + f"{self.platform_url}/workspaces/{self.workspace_id}/a2a", + json={ + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [{"type": "text", "text": trigger_msg}], + }, + }, + }, + headers=self_source_headers(self.workspace_id), + timeout=120.0, + ) + logger.info("Heartbeat: a2a_receive self-message sent") + except Exception as e: + logger.warning("Heartbeat: failed to send a2a_receive self-message: %s", e) + + # Also notify the user via canvas. + for r in new_results: + try: + msg = f"Delegation completed: {r['summary'][:100] or '(no summary)'}" + preview = r.get("response_preview", "") + if preview: + msg += f"\nResult: {preview[:200]}" + await client.post( + f"{self.platform_url}/workspaces/{self.workspace_id}/notify", + json={"message": msg, "type": "delegation_result"}, + headers=auth_headers(), + ) + except Exception: + pass + + except Exception as e: + logger.debug("Activity delegation check error: %s", e) -- 2.45.2