codex-channel-molecule/codex_channel_molecule/daemon.py
Hongming Wang d6eb78dcee feat: initial bridge daemon
codex-channel-molecule is the codex-side counterpart to
hermes-channel-molecule. It long-polls the molecule platform inbox via
molecule_runtime.a2a_tools.tool_wait_for_message, runs `codex exec
--resume <session>` per inbound message, captures the assistant reply
from stdout, and routes it back through send_message_to_user (canvas
chat) or delegate_task (peer agent), then acks the inbox row.

Per chat thread (one canvas-user thread or one peer-workspace thread)
gets its own codex session_id, persisted to disk so daemon restarts
keep conversation context. Reply-routing failures skip the inbox_pop
ack so the platform's at-least-once delivery re-surfaces the row on
the next poll.

This daemon is the operator-unblock until openai/codex#17543 lands —
once codex itself accepts MCP custom notifications as Op::UserInput
through the wired-in MCP server, this daemon becomes redundant. The
README's deprecation-path section calls that out so future operators
know when to switch off.

Tests cover the dispatch loop with fake tools (8 tests asserting
exact contracts: canvas vs peer routing, session continuity,
persistence across restarts, timeout sentinel handling, at-least-once
on reply failure, exit-code surfacing, A2A multipart text). The
codex_runner tests are real-subprocess (fake codex script spawned via
asyncio.create_subprocess_exec) so the boot path matches production —
no in-process mocking of the spawn boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:09:09 -07:00

119 lines
3.3 KiB
Python

"""Console-script entry point — ``codex-channel-molecule``.
Validates the env-var contract (mirrors hermes-channel-molecule), wires
up signal handling, and runs the bridge loop until interrupted.
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
import signal
import sys
from .bridge import run_bridge
from .codex_runner import CodexRunner
_REQUIRED_ENV = ("WORKSPACE_ID", "PLATFORM_URL", "MOLECULE_WORKSPACE_TOKEN")
def _check_env() -> list[str]:
missing = [name for name in _REQUIRED_ENV if not os.environ.get(name, "").strip()]
return missing
def _parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="codex-channel-molecule",
description=(
"Bridge daemon — long-polls the molecule platform inbox and "
"runs `codex exec --resume <session>` per inbound message. "
"Reply routes back via send_message_to_user (canvas) or "
"delegate_task (peer). Per-thread codex session continuity "
"is persisted to disk."
),
)
parser.add_argument(
"--codex-bin",
default="codex",
help="codex CLI binary name or absolute path (default: codex).",
)
parser.add_argument(
"--turn-timeout-secs",
type=float,
default=600.0,
help="Per-turn ceiling on `codex exec` runtime (default: 600).",
)
parser.add_argument(
"--log-level",
default="INFO",
choices=("DEBUG", "INFO", "WARNING", "ERROR"),
help="Logging level (default: INFO).",
)
return parser.parse_args(argv)
async def _async_main(args: argparse.Namespace) -> int:
runner = CodexRunner(
codex_bin=args.codex_bin,
timeout_secs=args.turn_timeout_secs,
)
loop = asyncio.get_running_loop()
stop_event = asyncio.Event()
def _request_stop() -> None:
stop_event.set()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, _request_stop)
bridge_task = asyncio.create_task(run_bridge(runner=runner))
stop_task = asyncio.create_task(stop_event.wait())
done, pending = await asyncio.wait(
{bridge_task, stop_task},
return_when=asyncio.FIRST_COMPLETED,
)
for t in pending:
t.cancel()
for t in pending:
try:
await t
except (asyncio.CancelledError, Exception):
pass
return 0
def main(argv: list[str] | None = None) -> int:
args = _parse_args(argv if argv is not None else sys.argv[1:])
logging.basicConfig(
level=args.log_level,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
missing = _check_env()
if missing:
sys.stderr.write(
"codex-channel-molecule: required env vars not set: "
+ ", ".join(missing)
+ "\n WORKSPACE_ID — UUID of the workspace this daemon represents\n"
+ " PLATFORM_URL — https://<your-tenant>.moleculesai.app\n"
+ " MOLECULE_WORKSPACE_TOKEN — bearer token from the platform's\n"
+ " External Connect modal for that workspace\n"
)
return 2
try:
return asyncio.run(_async_main(args))
except KeyboardInterrupt:
return 130
if __name__ == "__main__":
raise SystemExit(main())