diff --git a/content/docs/agent-runtime/workspace-runtime.md b/content/docs/agent-runtime/workspace-runtime.md index f8840a6..44f03d2 100644 --- a/content/docs/agent-runtime/workspace-runtime.md +++ b/content/docs/agent-runtime/workspace-runtime.md @@ -42,7 +42,7 @@ Common runtime environment variables: ```bash WORKSPACE_ID=ws-123 WORKSPACE_CONFIG_PATH=/configs -PLATFORM_URL=http://platform:8080 +PLATFORM_URL=http://host.docker.internal:8080 PARENT_ID= AWARENESS_URL=http://awareness:37800 AWARENESS_NAMESPACE=workspace:ws-123 diff --git a/content/docs/changelog.mdx b/content/docs/changelog.mdx index 0717703..9627ad8 100644 --- a/content/docs/changelog.mdx +++ b/content/docs/changelog.mdx @@ -8,6 +8,23 @@ Entries are published daily at 23:50 UTC. --- +## 2026-05-13 + +### โœจ New features + +- **Graceful shutdown support for remote agents**: `run_heartbeat_loop()` and `run_agent_loop()` in `molecule-sdk-python` now accept a `stop_event: threading.Event` parameter. Set the event from a SIGTERM handler to exit the loop cleanly with return value `"stopped"` โ€” enabling proper graceful shutdown in Kubernetes, Docker, and other container-orchestrated environments. (`molecule-sdk-python` [#8](https://git.moleculesai.app/molecule-ai/molecule-sdk-python/pulls/8)) + +### ๐Ÿ”ง Fixes + +- **PLATFORM_URL defaults aligned across all runtime modules**: all workspace runtime modules (`a2a_cli.py`, `a2a_client.py`, `a2a_mcp_server.py`, and 10 others) now consistently default `PLATFORM_URL` to `http://host.docker.internal:8080`. Previously some modules defaulted to `http://platform:8080`, causing connection failures in containerized deployments where the Docker host is not named `platform`. (`molecule-ai-workspace-runtime` [#12](https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-runtime/pulls/12)) + +### ๐Ÿงน Internal + +- **Canvas CI hardening**: publish workflow updated to pipefail-safe shell probes; Gitea cache export no longer masks errors; canvas image published to ECR. (`molecule-core` [#773](https://git.moleculesai.app/molecule-ai/molecule-core/pulls/773), [#776](https://git.moleculesai.app/molecule-ai/molecule-core/pulls/776), [#777](https://git.moleculesai.app/molecule-ai/molecule-core/pulls/777)) +- **Go lint CI hardening**: `golangci-lint run` no longer masked with `|| true`, so lint failures now fail the build loudly instead of being silently swallowed. (`molecule-core` [#781](https://git.moleculesai.app/molecule-ai/molecule-core/pulls/781)) + +--- + ## 2026-05-12 ### ๐Ÿ”’ Security diff --git a/content/docs/guides/remote-workspaces.md b/content/docs/guides/remote-workspaces.md index bbcda4d..ab173c4 100644 --- a/content/docs/guides/remote-workspaces.md +++ b/content/docs/guides/remote-workspaces.md @@ -119,12 +119,20 @@ secrets = client.pull_secrets() # Phase 30.2 โ€” decrypt API keys print("Secrets:", list(secrets.keys())) # Keep alive + respond to platform commands +import threading, signal, sys + +stop_event = threading.Event() +signal.signal(signal.SIGTERM, lambda *_: stop_event.set()) + client.run_heartbeat_loop( task_supplier = lambda: { "current_task": "idle", "active_tasks": 0, - } + }, + stop_event = stop_event, ) +# โ†’ exits with "stopped" on SIGTERM, "paused" if platform pauses us, +# "removed" if the workspace is deleted, or loops forever if neither. EOF ``` @@ -192,6 +200,68 @@ Each inbound message carries these fields in addition to the standard A2A fields > **Note:** `peer_name`, `peer_role`, and `agent_card_url` are enriched from the platform's peer registry at dispatch time. They are `None` if the sending peer has not registered an agent card. +### run_heartbeat_loop(stop_event=, max_iterations=, task_supplier=) + +Drives heartbeat + state-poll on a timer. Returns the terminal status when the loop exits. + +```python +import threading, signal + +stop_event = threading.Event() +signal.signal(signal.SIGTERM, lambda *_: stop_event.set()) + +status = client.run_heartbeat_loop( + max_iterations = None, # None = run until paused/deleted; int = stop after N ticks + task_supplier = lambda: { # optional โ€” report current task to the canvas + "current_task": "idle", + "active_tasks": 0, + }, + stop_event = stop_event, # set() to exit cleanly with return value "stopped" +) +# status is one of: "stopped" | "paused" | "removed" | "max_iterations" +``` + +| Parameter | Type | Description | +|---|---|---| +| `stop_event` | `threading.Event \| None` | When set, the loop exits cleanly with `"stopped"`. Use in a SIGTERM handler for graceful Kubernetes/Docker shutdown. Ignored when `None`. | +| `max_iterations` | `int \| None` | Stop after N loop iterations. `None` (default) = run until the workspace is paused or deleted. | +| `task_supplier` | `callable \| None` | Zero-arg callable returning `{"current_task": str, "active_tasks": int}`. Reports activity to the canvas on each tick. | + +Errors from the heartbeat or state poll are logged and the loop continues โ€” a transient platform hiccup does not take the agent offline. + +### run_agent_loop(handler, delivery=, stop_event=, max_iterations=, task_supplier=) + +Combined heartbeat + state-poll + inbound-delivery loop. The recommended entry point for external agent authors: registers, heartbeats, state-polls, and dispatches inbound A2A messages in one synchronous call. + +```python +from molecule_agent import RemoteAgentClient, PollDelivery +import threading, signal + +stop_event = threading.Event() +signal.signal(signal.SIGTERM, lambda *_: stop_event.set()) + +async def handle(msg): + print(f"Got message: {msg.method}") + return "Acknowledged" + +status = client.run_agent_loop( + handler = handle, + delivery = None, # defaults to PollDelivery โ€” correct for agents without a public URL + stop_event = stop_event, # set() to exit cleanly + max_iterations = None, + task_supplier = lambda: {"current_task": "idle", "active_tasks": 0}, +) +# status is one of: "stopped" | "paused" | "removed" | "max_iterations" +``` + +| Parameter | Type | Description | +|---|---|---| +| `handler` | `Callable[[InboundMessage], str \| None]` | Called once per inbound A2A message. Return a non-empty string to auto-reply; `None` to skip the reply. | +| `delivery` | `InboundDelivery \| None` | Delivery mechanism. Defaults to `PollDelivery` (polling, no inbound URL needed). Pass `PushDelivery` wrapped around an `A2AServer` for push-mode agents. | +| `stop_event` | `threading.Event \| None` | When set, the loop exits cleanly with `"stopped"`. Ignored when `None`. | +| `max_iterations` | `int \| None` | Stop after N loop iterations. `None` = run until paused/deleted. | +| `task_supplier` | `callable \| None` | Zero-arg callable returning `{"current_task": str, "active_tasks": int}`. | + ### Security: OFFSEC-003 โ€” trust-boundary markers on peer responses When a remote workspace receives a `delegate_task` response from an external peer, the platform wraps the peer-generated content in `[A2A_RESULT_FROM_PEER]...[/A2A_RESULT_FROM_PEER]` trust-boundary markers. These markers signal to the agent that the enclosed content originated outside the platform's trust boundary and must not be re-injected as platform-native output.