6969 "company" : "retail-co" ,
7070 "entry" : "triage" ,
7171 "state" : ".state/retail.json" ,
72+ # seat -> (port, tools in scope, of which mutating). The counts are the
73+ # contract this whole design rests on: `triage` holding a write tool, or
74+ # `exchanges` reaching the refund tool, means the scoping silently broke
75+ # and every later result is meaningless. `--check` asserts them.
7276 "seats" : {
73- "triage" : 8801 ,
74- "exchanges" : 8802 ,
75- "refunds" : 8803 ,
76- "cancellations" : 8804 ,
77- "amendments" : 8805 ,
77+ "triage" : (8801 , 9 , 0 ),
78+ "exchanges" : (8802 , 9 , 1 ),
79+ "refunds" : (8803 , 8 , 1 ),
80+ "cancellations" : (8804 , 8 , 1 ),
81+ "amendments" : (8805 , 11 , 3 ),
82+ },
83+ "desks" : {
84+ "triage" : ["triage" ],
85+ "order_ops" : ["cancellations" , "amendments" ],
86+ "returns" : ["exchanges" , "refunds" ],
7887 },
7988 "collection" : "orders" ,
8089 "key" : "order_id" ,
107116 "entry" : "triage" ,
108117 "state" : ".state/airline.json" ,
109118 "seats" : {
110- "triage" : 8811 ,
111- "booking" : 8812 ,
112- "changes" : 8813 ,
113- "refunds" : 8814 ,
119+ "triage" : (8811 , 8 , 0 ),
120+ "booking" : (8812 , 9 , 1 ),
121+ "changes" : (8813 , 11 , 3 ),
122+ "refunds" : (8814 , 10 , 2 ),
123+ },
124+ "desks" : {
125+ "triage" : ["triage" ],
126+ "booking" : ["booking" ],
127+ "post_booking" : ["changes" , "refunds" ],
114128 },
115129 "collection" : "reservations" ,
116130 "key" : "reservation_id" ,
@@ -172,6 +186,12 @@ def call(self, method: str, path: str, body: Any = None, timeout: float = 120):
172186 return err .code , json .loads (raw )
173187 except ValueError :
174188 return err .code , raw .decode (errors = "replace" )
189+ except (urllib .error .URLError , OSError ) as err :
190+ # Nothing listening, or the host died mid-run. Reported as 0 rather
191+ # than raising, so `--check` can say "unreachable" instead of dying
192+ # with a traceback — and so it is never mistaken for a 404 from a
193+ # host that IS answering.
194+ return 0 , f"unreachable: { err } "
175195
176196 def sign_in (self ) -> None :
177197 """No-op when auth is `none`; otherwise the loopback dev-code flow."""
@@ -337,6 +357,92 @@ def grade(task: dict, state: dict, spec: dict) -> tuple[bool, str]:
337357 return (not problems ), "; " .join (problems ) or "matches"
338358
339359
360+ def check (host : "Host" , spec : dict , domain : str , state_path : Path ) -> int :
361+ """Preflight: assert every layer this run depends on, spending no model call.
362+
363+ Each of these has failed silently at least once while this bundle was being
364+ built, and each looked like a model problem from the transcript alone:
365+
366+ * a role server reading a state file deleted under it — every tool call came
367+ back `Error executing tool`, which reads as the agent using them wrong;
368+ * `mcp.json` declaring server names the runner never registered, so a desk
369+ deliberated confidently with no tools at all;
370+ * a scope quietly widening, which makes a pass meaningless rather than loud;
371+ * no inference key, which surfaces as a 401 on the first turn rather than at
372+ boot;
373+ * a desk with one member where two were intended — `deliberates()` needs
374+ two, so the room never convenes and one seat decides alone.
375+ """
376+ bad = 0
377+
378+ def ok (label : str , good : bool , detail : str = "" ) -> None :
379+ nonlocal bad
380+ bad += 0 if good else 1
381+ mark = "ok " if good else "FAIL"
382+ print (f" [{ mark } ] { label } { (' — ' + detail ) if detail else '' } " )
383+
384+ print (f"role servers ({ domain } )" )
385+ for seat , (port , want_tools , want_mut ) in spec ["seats" ].items ():
386+ url = f"http://127.0.0.1:{ port } /mcp"
387+ body = json .dumps ({"jsonrpc" : "2.0" , "id" : 1 ,
388+ "method" : "tools/list" , "params" : {}}).encode ()
389+ req = urllib .request .Request (url , data = body , method = "POST" )
390+ req .add_header ("content-type" , "application/json" )
391+ req .add_header ("accept" , "application/json, text/event-stream" )
392+ try :
393+ with urllib .request .urlopen (req , timeout = 20 ) as resp :
394+ raw = resp .read ().decode (errors = "replace" )
395+ except Exception as err : # noqa: BLE001 — any failure is the same verdict
396+ ok (f"{ seat } :{ port } " , False , f"unreachable ({ err } )" )
397+ continue
398+ tools = len (re .findall (r'"name":"[a-z_]+"' , raw ))
399+ mut = raw .count ("write/mutates" )
400+ ok (f"{ seat } :{ port } " , tools == want_tools and mut == want_mut ,
401+ f"{ tools } tools / { mut } mutating, wanted { want_tools } /{ want_mut } " )
402+
403+ print ("company" )
404+ status , desks = host .call ("GET" , f"{ host .scope } /desks" )
405+ ok ("desks readable" , status == 200 , f"HTTP { status } " )
406+ if status == 200 and isinstance (desks , list ):
407+ got = {d .get ("id" ): sorted (d .get ("members" ) or []) for d in desks }
408+ for desk , members in spec ["desks" ].items ():
409+ ok (f"desk { desk } " , got .get (desk ) == sorted (members ),
410+ f"members={ got .get (desk )} , wanted { sorted (members )} " )
411+ if len (members ) >= 2 :
412+ ok (f"desk { desk } can deliberate" , len (got .get (desk ) or []) >= 2 ,
413+ "needs two seats" )
414+
415+ print ("mcp wiring" )
416+ status , servers = host .call ("GET" , f"{ host .scope } /mcp/servers" )
417+ if status != 200 or not isinstance (servers , list ):
418+ ok ("server list" , False , f"HTTP { status } " )
419+ else :
420+ by_name = {x .get ("name" ): x for x in servers }
421+ for seat in spec ["seats" ]:
422+ name = f"tau2-{ domain } -{ seat } "
423+ row = by_name .get (name )
424+ live = bool (row and row .get ("enabled" ))
425+ ok (f"{ name } enabled" , live ,
426+ "" if live else ("not registered" if row is None else "declared but disabled" ))
427+ if row and row .get ("enabled" ):
428+ st , tools = host .call ("GET" , f"{ host .scope } /mcp/servers/{ urllib .parse .quote (name )} /tools" )
429+ ok (f"{ name } reachable from the host" , st == 200 and isinstance (tools , list ),
430+ f"HTTP { st } " )
431+
432+ print ("inference" )
433+ status , body = host .call ("POST" , f"{ host .scope } /inference/test" , {}, timeout = 120 )
434+ fine = status == 200 and isinstance (body , dict ) and body .get ("ok" )
435+ ok ("credential probes clean" , bool (fine ),
436+ (body or {}).get ("error" , f"HTTP { status } " ) if not fine else "" )
437+
438+ print ("tau2 state" )
439+ ok (f"{ state_path } present" , state_path .exists (),
440+ "delete it AND restart the servers to reseed — they seed at boot" )
441+
442+ print (f"\n { 'all checks passed' if not bad else str (bad ) + ' check(s) failed' } " )
443+ return bad
444+
445+
340446def main () -> int :
341447 ap = argparse .ArgumentParser (
342448 description = __doc__ , formatter_class = argparse .RawDescriptionHelpFormatter
@@ -345,6 +451,9 @@ def main() -> int:
345451 ap .add_argument ("--base" , default = "http://127.0.0.1:8080" , help = "running `opencompany serve`" )
346452 ap .add_argument ("--tau2" , type = Path , default = Path ("../opencompany-tau2" ),
347453 help = "the opencompany-tau2 checkout (tasks + shared state)" )
454+ ap .add_argument ("--check" , action = "store_true" ,
455+ help = "verify servers, desks, mcp wiring, credential and state, "
456+ "then exit — spends no model call" )
348457 ap .add_argument ("--task" , help = "one task id" )
349458 ap .add_argument ("--tasks" , help = "comma-separated task ids" )
350459 ap .add_argument ("--host" , default = "127.0.0.1" , help = "host the role servers bound to" )
@@ -365,6 +474,15 @@ def main() -> int:
365474 ap .error (f"unknown domain { args .domain !r} ; known: { ', ' .join (DOMAINS )} " )
366475 spec = DOMAINS [args .domain ]
367476
477+ state_path = args .tau2 / spec ["state" ]
478+ host = Host (args .base , spec ["company" ])
479+
480+ if args .check :
481+ host .sign_in ()
482+ for seat , (port , _t , _m ) in spec ["seats" ].items ():
483+ host .register_mcp (f"tau2-{ args .domain } -{ seat } " , f"http://{ args .host } :{ port } /mcp" )
484+ return check (host , spec , args .domain , state_path )
485+
368486 ids = []
369487 if args .task :
370488 ids = [args .task ]
@@ -379,11 +497,8 @@ def main() -> int:
379497 if missing :
380498 raise SystemExit (f"no such { args .domain } task(s): { ', ' .join (missing )} " )
381499
382- state_path = args .tau2 / spec ["state" ]
383-
384- host = Host (args .base , spec ["company" ])
385500 host .sign_in ()
386- for seat , port in spec ["seats" ].items ():
501+ for seat , ( port , _tools , _mut ) in spec ["seats" ].items ():
387502 name = f"tau2-{ args .domain } -{ seat } "
388503 status , body = host .register_mcp (name , f"http://{ args .host } :{ port } /mcp" )
389504 if status >= 300 :
0 commit comments