Ticket graphs

Every ticket as the root of its provenance graph — goal, patch, approval, runs and (as the ticket-class build lands) mandate, manifestations, guarantees, journey, artifacts and children. Rendered through the <<<TicketGraphList>>> tag.

← all ticket-graphs

DONE#379 forge-refactor

Rewrite coupler:coupler/server.py as behavior-preserving, installable, control-config'd Python [forge-rw:ad7c8896d4d55d8d]

goal

THIS UNIT SUPERSEDES #297 — it is the SAME module and the same work, re-filed to be SELF-CONTAINED. The superseded unit named its source only as a `git cat-file` command against the forge's bare repos, which the sandboxed pair_loop worker that executes these tickets cannot run, so it could not be done as filed (#346; #259 is the proof). A ticket body is permanent in SQL (tg_tickets_immutable_identity, migrations/0029_ticket_authorization .sql:111-117), so that body could not be repaired — this ticket carries the fix instead, and the original is halted by a stop ticket rather than erased. Do the work HERE. Rewrite ONE unique forge module as entirely new, behavior-preserving, installable, control-config'd Python, per the FORGE-wide refactor standard #200 (#std). This ticket covers ONE module — the one identified below — and nothing else. It was filed against the module's DEDUPLICATED identity, so if that module was copy-pasted across the forge, every copy is covered by this single ticket. MODULE KEY : forge-rw:ad7c8896d4d55d8d (the ticketer's idempotency marker — it lives in this ticket's TITLE. Do not edit the title, or the module will be re-filed as a duplicate.) IDENTITY : AST-NORMALIZED — codebean's `_ast_norm` (#183), i.e. ast.dump(ast.parse(ast.unparse(ast.parse(src)))). Copies of this module that differ only in comments, blank lines, quoting or spacing carry the SAME module-sha and are covered by THIS ticket. A differing docstring is real content, so a module whose docstring differs is a DIFFERENT module with its own ticket. SIZE : 13999 bytes EXTENSION : .py CANONICAL SOURCE (the location this ticket is named for): repo : coupler path : coupler/server.py ref : HEAD (as recorded when this module was first filed) blob : fe6aaa84331ef0616cca49f1a72c218ba4c46e66 (git blob sha1) REFERENCE ONLY — you do NOT need this command. This module's bytes are embedded COMPLETE in THE ORIGINAL SOURCE block below, and that is what the rewrite is based on. For a reader who does have forge access: git --git-dir=/Users/stevenpeterson/code/installed/jazz-project/config/forge/remotes/coupler.git cat-file blob fe6aaa84331ef0616cca49f1a72c218ba4c46e66 THE SAME MODULE ALSO LIVES AT THESE FORGE TIPS — 1 copy/copies. They SHARE THIS ticket: this module is rewritten ONCE, and every location below is satisfied by that one rewrite. Do not file a ticket per copy. gitlab__testmonkeyalpha__coupler:coupler/server.py HISTORICAL OCCURRENCES of this exact blob — every OTHER DISTINCT {repo, path} the omni-git index (`occ`, joined on the git blob sha1) records this byte-identical file at, with the index's own `ts` for the first row of each. Any place this run enumerated LIVE is listed under the forge tips above, not here. Context for the rewrite, not extra work: testmonkeyalpha/coupler:coupler/server.py [occ.ts gitlab] THE ORIGINAL SOURCE, EMBEDDED — YOU NEED NO FORGE ACCESS TO DO THIS TICKET. The module's own bytes are reproduced below, inside this ticket. THEY are the authoritative copy and the behavior-preserving basis: rewrite from them. The `git --git-dir=... cat-file blob` command under CANONICAL SOURCE above is kept only as a REFERENCE for a reader who happens to have forge access — running it is NOT part of this ticket. (Why the bytes are here at all: the pair_loop worker that executes this ticket is sandboxed to the ticket's own congruency worktree and cannot read the forge's bare repos. Ticket #346 is the fix that put them on the ticket.) The block BEGINS at the line ===ORIGINAL SOURCE (verbatim, the behavior-preserving basis)=== and ENDS at the first line ===END SOURCE=== Everything strictly between those two lines is the module — 13999 byte(s), byte for byte, with no re-indentation, no re-wrapping and nothing elided. ===ORIGINAL SOURCE (verbatim, the behavior-preserving basis)=== #!/usr/bin/env python3 """ coupler/server.py — HTTP MCP server on localhost:7770. Run this as a long-lived daemon. Restart it to deploy updates. Claude Desktop never needs to restart — bridge.py reconnects automatically. Usage: python -m coupler.server [--port 7770] """ import argparse import json import os import sys from pathlib import Path import httpx from fastmcp import FastMCP, Client from fastmcp.client.transports import StdioTransport from fastmcp.server import create_proxy # ── jazz_telemetry (graceful: never hard-fail if the lib isn't present) ───────── sys.path.insert(0, str(Path.home() / "code" / "forge" / "work" / "jazz_telemetry")) try: from jazz_telemetry import telemetry, service_running except Exception: # pragma: no cover - telemetry is best-effort from contextlib import contextmanager as _cm def telemetry(*_a, **_k): class _N: def emit(self, *a, **k): pass return _N() @_cm def service_running(*_a, **_k): yield None JAZZ_ROOT = Path(os.environ.get("JAZZ_ROOT", Path.home() / "code/workspace/main")) JAZZ_DEV = Path(os.environ.get("JAZZ_DEV", Path.home() / "code/jazz")) def _env_port(name: str, default: int) -> int: """Read an integer port from env, tolerating Kubernetes service-link vars. k8s injects <SERVICENAME>_PORT=tcp://<clusterip>:<port> for every Service in the namespace. When the Service is named 'coupler', that clobbers our own COUPLER_PORT with a 'tcp://…' URL. Fall back to the default (or the URL's trailing port) instead of crashing on int('tcp://…').""" raw = os.environ.get(name) if raw is None: return default try: return int(raw) except ValueError: tail = raw.rsplit(":", 1)[-1] # 'tcp://10.43.0.1:8000' -> '8000' try: return int(tail) except ValueError: return default PORT = _env_port("COUPLER_PORT", 7770) def _load_build() -> dict: p = JAZZ_ROOT / "conf/env/build.json" return json.loads(p.read_text()) if p.exists() else {} # Downstream jazz MCP servers the coupler can mount — single source of truth. # notes_mcp retired: a JSON tree-of-notes prototype over conf/notes/notes.json, NOT a # notes_db client (a red herring). The real brain is notes_db (:8001) / notes_db_mcp (:8002). JAZZ_SERVICES = [ ("notes_db", "notes_db_mcp"), ("z3", "z3_mcp"), ("codebean", "codebean_mcp"), ("bootstrap", "bootstrap_mcp"), ("services", "service_registry_mcp"), ("volctl", "volctl"), ("gs", "git_surface_mcp"), # git surface + VM control (vm_* tools, machine time-travel) ] def _disabled_services() -> set: """Which MCP servers to skip. Sources, merged: • env COUPLER_DISABLE="z3,notes" (comma-separated) • build.json {"coupler": {"disabled": ["z3"]}} or {"coupler": {"z3": false}} Default: everything enabled.""" names = {s.strip() for s in os.environ.get("COUPLER_DISABLE", "").split(",") if s.strip()} cfg = _load_build().get("coupler", {}) or {} names |= set(cfg.get("disabled", [])) names |= {k for k, v in cfg.items() if k != "disabled" and v is False} return names def _notes_python() -> str: """Interpreter used to launch downstream MCP subprocesses. They run under the SAME venv as coupler — whatever Python is running this server. (Historically this hunted a conda env via build.json; conda is gone since old_jazz.) Override with COUPLER_PYTHON if a downstream needs a different interpreter. """ return os.environ.get("COUPLER_PYTHON", sys.executable) def build_app() -> FastMCP: python = _notes_python() build = _load_build() version = build.get("version", "unknown") print(f" coupler/server starting", file=sys.stderr) print(f" jazz version : {version}", file=sys.stderr) print(f" notes python : {python}", file=sys.stderr) print(f" port : {PORT}", file=sys.stderr) app = FastMCP("coupler") # Plain HTTP liveness/readiness endpoint (no MCP session needed). Lets a k8s # readiness probe / reverse proxy confirm the server is up without opening an # SSE stream. Mirrors the POC stub's /health contract. from starlette.responses import JSONResponse as _JSONResponse @app.custom_route("/health", methods=["GET"]) async def _health(_request): return _JSONResponse({"status": "ok", "role": "coupler", "version": version, "transport": "sse"}) _tel = telemetry("coupler", version=version) _tel.emit("build_app", status="ok", port=PORT) # app constructed; lifecycle start is in main() # track which services mounted successfully for the status tool _services: dict[str, str] = {} # name → "ok" | "down" | "off" disabled = _disabled_services() if disabled: print(f" disabled : {', '.join(sorted(disabled))}", file=sys.stderr) # ── jazz service proxies ───────────────────────────────────────────────── for name, module in JAZZ_SERVICES: if name in disabled: _services[name] = "off" _tel.emit("mount", status="off", service=name, module=module) print(f" skipped : {name} (disabled)", file=sys.stderr) continue try: proxy = create_proxy( # env={**os.environ}: the MCP stdio client strips custom vars by default, # so downstream MCPs never saw CODEBEAN_ROOT etc. (BUG-0002). Pass it through. Client(StdioTransport(command=python, args=["-m", module], env={**os.environ})) ) app.mount(proxy, namespace=name) _services[name] = "ok" _tel.emit("mount", status="ok", service=name, module=module) print(f" mounted : {name} ({module})", file=sys.stderr) except Exception as e: _services[name] = "down" _tel.emit("mount", status="down", service=name, module=module, error=f"{type(e).__name__}: {e}") print(f" [warn] {name}: {e}", file=sys.stderr) # ── status tool ────────────────────────────────────────────────────────── @app.tool() async def coupler_status() -> str: """ Check which coupler services are available right now. Call this if a tool fails unexpectedly, before retrying, or to decide whether to proceed with a workflow that depends on a specific service. Returns a JSON object: {service: "ok" | "degraded" | "down"}. """ status = dict(_services) # re-probe each jazz service with a quick list-tools ping (skip disabled) for name, module in JAZZ_SERVICES: if name in disabled: status[name] = "off" continue try: async with Client(StdioTransport(command=python, args=["-m", module], env={**os.environ})) as c: await c.list_tools() status[name] = "ok" except Exception: status[name] = "down" # probe UI try: async with httpx.AsyncClient() as c: r = await c.get( os.environ.get("JSON_CONTROLS_URL", "http://127.0.0.1:8142") + "/pages", timeout=2.0) status["ui"] = "ok" if r.status_code < 500 else "degraded" except Exception: status["ui"] = "down" status["coupler"] = "ok" status["jazz_version"] = version return json.dumps(status, indent=2) # ── why5 ───────────────────────────────────────────────────────────────── sys.path.insert(0, str(JAZZ_DEV / "jazz")) try: from agent.why5 import Why5, from_ci_failure _sessions: dict = {} @app.tool() def why5_new(session_id: str, trigger: str = "") -> str: """Start a new 5-why root cause analysis session.""" _sessions[session_id] = Why5(session_id=session_id, trigger=trigger) return f"why5 session '{session_id}' started" @app.tool() def why5_fact(session_id: str, name: str, value: bool) -> str: """Assert a known observable fact into a why5 session.""" _sessions[session_id].fact(name, value) return f"fact '{name}' = {value}" @app.tool() def why5_link(session_id: str, label: str, cause: str, because: str) -> str: """Add a causal link: if `because` is true then `cause` must be true.""" _sessions[session_id].link(label, cause=cause, because=because) return f"link [{label}] {because} → {cause}" @app.tool() def why5_check(session_id: str, save: bool = False) -> str: """Verify the causal chain with Z3. Optionally save to notes_db.""" w = _sessions.get(session_id) if not w: return f"error: session '{session_id}' not found" result = w.check() if save: w.save() return json.dumps(result, indent=2) @app.tool() def why5_from_ci_failure(session_id: str, branch: str, sha: str, pytest_output: str, save: bool = False) -> str: """Build and verify a why5 chain from CI pytest output.""" w = from_ci_failure(session_id, branch=branch, pre_sha=sha, pytest_output=pytest_output) _sessions[session_id] = w result = w.check() if save: w.save() return json.dumps(result, indent=2) print(f" mounted : why5", file=sys.stderr) except Exception as e: print(f" [warn] why5: {e}", file=sys.stderr) # ── UI bridge ──────────────────────────────────────────────────────────── UI_BASE = os.environ.get("JSON_CONTROLS_URL", "http://127.0.0.1:8142") T = 5.0 def _get(path, **p): r = httpx.get(f"{UI_BASE}{path}", params=p, timeout=T); r.raise_for_status(); return r.json() def _post(path, body): r = httpx.post(f"{UI_BASE}{path}", json=body, timeout=T); r.raise_for_status(); return r.json() def _put(path, body): r = httpx.put(f"{UI_BASE}{path}", json=body, timeout=T); r.raise_for_status(); return r.json() @app.tool() def ui_set_page(config: dict) -> str: """Push a page config to the browser UI.""" name = config.get("name") if not name: return "error: config must have a 'name' field" try: _post(f"/page/{name}", config); return f"page '{name}' registered" except Exception as e: return f"error: {e}" @app.tool() def ui_get_state(page: str) -> str: """Get the current value of every control on a UI page.""" try: return json.dumps(_get(f"/state/{page}"), indent=2) except Exception as e: return f"error: {e}" @app.tool() def ui_set_value(page: str, name: str, value: str) -> str: """Set a control value on a UI page.""" parsed: object = value if value.lower() == "true": parsed = True elif value.lower() == "false": parsed = False else: try: parsed = int(value) except ValueError: try: parsed = float(value) except ValueError: pass try: _put(f"/state/{page}/{name}", {"value": parsed}); return f"set {page}.{name} = {parsed!r}" except Exception as e: return f"error: {e}" @app.tool() def ui_list_pages() -> str: """List all registered UI pages.""" try: pages = _get("/pages") return json.dumps(pages, indent=2) if pages else "no pages registered" except Exception as e: return f"error: {e}" @app.tool() def ui_add_message(page: str, control: str, role: str, text: str) -> str: """Append a message to a chatbox control on a UI page.""" try: _post(f"/message/{page}/{control}", {"role": role, "text": text}); return f"message added to {page}.{control}" except Exception as e: return f"error: {e}" @app.tool() def ui_get_log(limit: int = 20) -> str: """Get recent control change events from the browser.""" try: log = _get("/log", limit=limit) return json.dumps(log, indent=2) if log else "no changes yet" except Exception as e: return f"error: {e}" print(f" mounted : ui (port 8142)", file=sys.stderr) return app def main(): import asyncio parser = argparse.ArgumentParser() parser.add_argument("--port", type=int, default=PORT) # Bind host. Defaults to loopback so the local (blue) deployment is unchanged; # set --host 0.0.0.0 or COUPLER_HOST=0.0.0.0 when running inside a container / # k8s pod so the SSE endpoint is reachable through the cluster (Caddy proxy). parser.add_argument("--host", default=os.environ.get("COUPLER_HOST", "127.0.0.1")) args = parser.parse_args() app = build_app() # service_running emits service.start + periodic service.heartbeat + service.stop with service_running("coupler", port=args.port): asyncio.run(app.run_http_async(transport="sse", host=args.host, port=args.port)) if __name__ == "__main__": main() ===END SOURCE=== THE CALLABLE SURFACE — every callable in the original, lowered with codebean's `lower_node` (#183). The rewrite must present THIS surface (same names, same parameters, same defaults) so callers of the original keep working: _env_port(name: str, default: int) -> int _load_build() -> dict _disabled_services() -> set _notes_python() -> str build_app() -> FastMCP main() CAPTURED BEHAVIORAL EXEMPLARS: none. no function in this module is minable — criteria_miner mines only self-contained pure functions (it refuses anything that mentions a world-touching or non-deterministic name, plus methods and no-arg functions), and nothing here qualified This does NOT weaken the ticket's bar — it MOVES the work: you must write the equivalence exemplars by hand from the original's behavior before rewriting, and they are what settles this ticket. THE STANDARD — #200's four requirements, which this ticket exists to enforce. All four are the standard's own words; none is optional: 1. BEHAVIOR-PRESERVING, VERIFIED NOT ASSERTED — the deliverable is ENTIRELY NEW source code (rewritten, not copied). It must reproduce the ORIGINAL's behavior, and the proof is the exemplars above (captured via codebean lower_* #183 + criteria_miner IO-exemplars, exactly as #200 specifies). 2. A PYTHON MODULE — regardless of the source language. This module's source is `.py`; the rewrite is Python either way. 3. INSTALLABLE — a proper package: pyproject.toml, `pip install -e .` works, console entry points where applicable. 4. CONTROL-CONFIG'D — NO hardcoded paths or params. A JSON/registry config object drives it (the install.json / registry.json idiom of this repo), and per note-for-claude a tool that cannot see its config THROWS rather than guessing a default. ACCEPTANCE (#200's, per rewrite ticket) — this ticket settles when ALL of: a. the new source reproduces every captured exemplar above, plus the edge cases you add for what sampling cannot reach (paste the runs as evidence); b. it presents the callable surface above, so existing callers keep working; c. `pip install -e .` installs it clean from its own pyproject.toml; d. it runs off a config object with ZERO hardcoded settings, and throws without one. Kind is `build` (a pair: coder + adversarial tester). This ticket hangs off #200 by a `spawned` edge, so a stop ticket aimed at #200 brakes every rewrite in this programme. Filed mechanically by checkouts/current/congruency/tools/forge_refactor_ticketer.py under ticket #201, from the forge bare repos at /Users/stevenpeterson/code/installed/jazz-project/config/forge/remotes. Its original source is embedded above (#346), so this ticket is complete on its own.

patch

none

approval

unapproved

runs

no runs recorded

mandate (clauses)

not yet recorded — lands with the ticket-class build

manifestations

not yet recorded — lands with the ticket-class build

guarantees

not yet recorded — lands with the ticket-class build

journey (blunders & successes)

not yet recorded — lands with the ticket-class build

artifacts (forge)

not yet recorded — lands with the ticket-class build

source (forge tree)

browse congruency source (forge-parked tree)

children

#297 Rewrite coupler:coupler/server.py as behavior-preserving, installable, control-config'd Python [forge-rw:ad7c8896d4d55d8d] (supersedes · REQUESTED)