3737 write_worker_logs as _write_worker_logs ,
3838)
3939from .run_transport import Assignment , WorkerResult
40- from .roster import Agent , Roster , is_cli_allowed , timeout_for , workers
40+ from .roster import Agent , Roster , is_cli_allowed , read_only_capability_error , timeout_for , workers
4141from .route_catalog import RouteBrief , route_brief , uncovered_stages , unknown_covers
4242
4343CODE_GRAPH_HEADING = "## Code graph context (GraphTrail, read-only)"
@@ -368,12 +368,18 @@ def build_plan_prompt(
368368 evidence : EvidenceBrief | None = None ,
369369 route : RouteBrief | None = None ,
370370) -> str :
371- worker_lines = "\n " .join (f"- { agent .name } : cli={ agent .cli } ; role={ agent .role } " for agent in workers (roster ))
371+ worker_lines = "\n " .join (
372+ f"- { agent .name } : cli={ agent .cli } ; "
373+ + (f"read_only_capable={ str (agent .read_only_capable ).lower ()} ; " if read_only else "" )
374+ + f"role={ agent .role } "
375+ for agent in workers (roster )
376+ )
372377 if not worker_lines :
373378 worker_lines = "- no workers configured"
374379
375380 note = f"\n Correction needed: { corrective_note } \n " if corrective_note else ""
376381 policy = f"\n \n { _read_only_rules ()} \n " if read_only else ""
382+ capability_rule = "- Assign only workers with read_only_capable=true.\n " if read_only else ""
377383 route_section = ""
378384 route_rule = ""
379385 if route is not None and route .attached and route .text :
@@ -395,6 +401,7 @@ def build_plan_prompt(
395401 "- Assignments in the same stage run in parallel; later stages receive earlier-stage worker results.\n "
396402 "- Omit stage only for backwards-compatible stage 1 assignments.\n "
397403 "- Assign only listed workers.\n "
404+ f"{ capability_rule } "
398405 "- Use zero assignments only if no worker is useful."
399406 f"{ route_rule } "
400407 f"{ policy } "
@@ -571,7 +578,7 @@ def _read_only_rules() -> str:
571578 )
572579
573580
574- def parse_plan (text : str , roster : Roster ) -> list [Assignment ]:
581+ def parse_plan (text : str , roster : Roster , * , read_only : bool = False ) -> list [Assignment ]:
575582 try :
576583 payload = _extract_json (text )
577584 except json .JSONDecodeError as exc :
@@ -601,6 +608,10 @@ def parse_plan(text: str, roster: Roster) -> list[Assignment]:
601608 raise ValueError (f"assignment references unknown worker: { worker !r} " )
602609 if worker == roster .orchestrator :
603610 raise ValueError ("assignment cannot target the orchestrator" )
611+ if read_only :
612+ capability_error = read_only_capability_error (roster .agents [worker ])
613+ if capability_error is not None :
614+ raise ValueError (capability_error )
604615 if not isinstance (subtask , str ) or not subtask .strip ():
605616 raise ValueError ("assignment.task must be a non-empty string" )
606617 raw_covers = item .get ("covers" , [])
@@ -824,7 +835,7 @@ def plan(
824835 _record_plan_attempt (attempts , stage = "initial" , result = first )
825836 raise RuntimeError (f"orchestrator failed during plan: { first .detail } " )
826837 try :
827- assignments = parse_plan (first .text , roster )
838+ assignments = parse_plan (first .text , roster , read_only = read_only )
828839 _record_plan_attempt (
829840 attempts ,
830841 stage = "initial" ,
@@ -859,7 +870,7 @@ def plan(
859870 _record_plan_attempt (attempts , stage = "correction" , result = second )
860871 raise RuntimeError (f"orchestrator failed during plan correction: { second .detail } " ) from exc
861872 try :
862- assignments = parse_plan (second .text , roster )
873+ assignments = parse_plan (second .text , roster , read_only = read_only )
863874 _record_plan_attempt (
864875 attempts ,
865876 stage = "correction" ,
@@ -911,7 +922,7 @@ def plan(
911922 _record_plan_attempt (attempts , stage = "coverage-correction" , result = revised_result )
912923 return assignments
913924 try :
914- revised = parse_plan (revised_result .text , roster )
925+ revised = parse_plan (revised_result .text , roster , read_only = read_only )
915926 except ValueError as exc :
916927 _record_plan_attempt (attempts , stage = "coverage-correction" , result = revised_result , parse_error = str (exc ))
917928 return assignments
@@ -1838,6 +1849,7 @@ def _roster_payload(roster: Roster) -> dict[str, object]:
18381849 "role" : agent .role ,
18391850 "timeout_seconds" : agent .timeout_seconds ,
18401851 "invalid_final_fallback" : agent .invalid_final_fallback ,
1852+ "read_only_capable" : agent .read_only_capable ,
18411853 # env tables hold names and references only, never secret
18421854 # values (enforced at roster load), so persisting them for
18431855 # resume is safe.
@@ -1960,12 +1972,16 @@ def _run_payload(
19601972 return payload
19611973
19621974
1963- def _direct_worker_error (worker : str , roster : Roster ) -> str | None :
1975+ def _direct_worker_error (worker : str , roster : Roster , * , read_only : bool = False ) -> str | None :
19641976 agent = roster .agents .get (worker )
19651977 if agent is None :
19661978 return f"unknown worker: { worker } "
19671979 if worker == roster .orchestrator :
19681980 return f"--worker cannot target orchestrator seat: { worker } "
1981+ if read_only :
1982+ capability_error = read_only_capability_error (agent )
1983+ if capability_error is not None :
1984+ return capability_error
19691985 if agent .cli is None :
19701986 return f"worker has no CLI adapter: { worker } "
19711987 if not is_cli_allowed (agent .cli , roster ):
@@ -2181,7 +2197,7 @@ def _payload(**kwargs: Any) -> dict[str, object]:
21812197 return _run_payload (lock_workspace = lock_workspace , ** kwargs )
21822198
21832199 if worker is not None :
2184- worker_error = _direct_worker_error (worker , roster )
2200+ worker_error = _direct_worker_error (worker , roster , read_only = read_only )
21852201 if worker_error is not None :
21862202 print (f"error: { worker_error } " , file = sys .stderr )
21872203 return 2
0 commit comments