2727)
2828
2929
30+ class MasterNotReady (RuntimeError ):
31+ """Transient serving startup, shutdown or unavailable protected receipt."""
32+
33+
3034def accepted_master (root : Path , branch : str ):
3135 """Reuse a protected serving master, never launch a model or use a candidate."""
3236 from rlm .v100 .competition import helper_client
@@ -36,22 +40,37 @@ def accepted_master(root: Path, branch: str):
3640 run = Path (active ["run" ]).resolve ()
3741 if not run .is_relative_to ((root / "research/mission" ).resolve ()):
3842 raise ValueError ("Mission escaped its owned directory" )
39- state = json .loads ((run / "status.json" ).read_text ())
43+ status_path = run / "status.json"
44+ state = json .loads (status_path .read_text ()) if status_path .exists () else {}
4045 paths = [run / "serving-active.json" , run / "learning/live.json" ]
4146 paths .append (run / f"profile-{ state .get ('context_window' )} .json" )
4247 path = next ((candidate for candidate in paths if candidate .is_file ()), None )
4348 if path is None :
44- raise RuntimeError ("Accepted master profile not ready; collected evidence retained" )
49+ raise MasterNotReady ("Accepted master profile not ready; collected evidence retained" )
50+ startup = run / f"server-{ state .get ('context_window' )} .startup.json"
51+ # The initial server is stopped when continuous learning starts its own
52+ # protected server. Its old startup file must not block the live profile.
53+ if (
54+ path .name .startswith ("profile-" )
55+ and startup .exists ()
56+ and json .loads (startup .read_text ()).get ("stage" ) != "ready"
57+ ):
58+ raise MasterNotReady ("Accepted V100 serving startup not ready; evidence retained" )
4559 profile = load_profile (path , root )
4660 client = helper_client (profile , root , branch )
4761 client .timeout = min (10 , client .timeout )
4862 # Full protected receipt and endpoint check precedes any generation. A
4963 # candidate temporarily occupying the same endpoint is never used.
50- assert_served_expert (client , profile , root )
64+ try :
65+ assert_served_expert (client , profile , root )
66+ except (FileNotFoundError , ProcessLookupError , requests .RequestException ) as error :
67+ raise MasterNotReady ("Accepted serving process or endpoint not ready" ) from error
5168 client .timeout = 40
5269 client .request_deadline = time .monotonic () + 60
5370 client .enable_thinking = False
5471 client .sampling_args = dict (client .sampling_args ) | {"max_tokens" : 1024 }
72+ client .research_token_ceiling = 1024
73+ client .research_thinking_allowed = False
5574 client .activity_actor = "income-research"
5675 return client
5776
@@ -86,52 +105,90 @@ def execute(root: Path, job: dict) -> dict:
86105 path = directory / "status.json"
87106 previous = json .loads (path .read_text ()) if path .exists () else {}
88107 same_goal = previous .get ("goal_id" ) == goal ["id" ]
108+ pending = None
109+ if same_goal and previous .get ("dossier" ):
110+ candidate = Path (previous ["dossier" ]).resolve ()
111+ if candidate .is_relative_to ((directory / "dossiers" ).resolve ()) and candidate .is_file ():
112+ saved = json .loads (candidate .read_text ())
113+ if (
114+ saved .get ("goal_id" ) == goal ["id" ]
115+ and saved .get ("analysis" ) is None
116+ and previous .get ("state" )
117+ in (
118+ "waiting for accepted master" ,
119+ "evidence collected; analysis blocked" ,
120+ )
121+ and saved .get ("analysis_attempts" , 0 ) < 3
122+ and 0 <= time .time () - saved ["source" ]["acquired" ] <= 86400
123+ ):
124+ pending = saved
89125 index = previous .get ("next_source" , 0 ) if same_goal else 0
90126 domain , url = STARTING_SOURCES [index % len (STARTING_SOURCES )]
127+ if pending :
128+ domain , url = previous ["domain" ], previous ["url" ]
91129 value = {
92130 "goal_id" : goal ["id" ],
93131 "job_id" : job ["id" ],
94132 "updated" : time .time (),
95133 "state" : "collecting primary terms" ,
96134 "domain" : domain ,
97135 "url" : url ,
98- "next_source" : index + 1 ,
136+ "next_source" : index if pending else index + 1 ,
99137 "sources_collected" : previous .get ("sources_collected" , 0 ) if same_goal else 0 ,
100138 "proposals_registered" : previous .get ("proposals_registered" , 0 ) if same_goal else 0 ,
101139 "actual_income_pln" : None ,
102140 "scope" : "Public-terms research and preparation only; no account, application, sale, security scan or payment executed" ,
103141 }
142+ if pending :
143+ value ["readiness_attempts" ] = previous .get ("readiness_attempts" , 0 )
104144 atomic_json (path , value )
105145 journal = ActivityLog (root , job ["branch" ], "income-research" )
106146 step = journal .write (
107147 "tools" ,
108148 "tool-start" ,
109- {"tool" : "observe_goal_source" , "arguments" : {"url" : url , "field" : "" }},
149+ {
150+ "tool" : "read_income_dossier" if pending else "observe_goal_source" ,
151+ "arguments" : {"path" : previous ["dossier" ]} if pending else {"url" : url , "field" : "" },
152+ },
110153 )
111154 try :
112- source = observe (root , url )
155+ source = pending [ "source" ] if pending else observe (root , url )
113156 except (ValueError , OSError , RuntimeError , requests .RequestException ) as error :
114157 value .update (state = "source unavailable" , error = str (error )[:400 ], updated = time .time ())
115158 atomic_json (path , value )
116159 journal .write ("errors" , "source-unavailable" , value , step_id = step )
117160 return {** value , "status" : "failed" }
118161 journal .write (
119- "tools" , "tool-result" , {"tool" : "observe_goal_source" , "result" : source }, step_id = step
162+ "tools" ,
163+ "tool-result" ,
164+ {
165+ "tool" : "read_income_dossier" if pending else "observe_goal_source" ,
166+ "result" : source ,
167+ "scope" : "Archived evidence reused; no new fetch" if pending else "New source fetch" ,
168+ },
169+ step_id = step ,
120170 )
121171 value .update (
122172 state = "evidence collected; analysis pending" ,
123173 evidence_id = source ["id" ],
124- sources_collected = value ["sources_collected" ] + 1 ,
174+ sources_collected = value ["sources_collected" ] + ( 0 if pending else 1 ) ,
125175 )
126176 dossier = directory / "dossiers" / (source ["id" ] + ".json" )
127- atomic_json (
128- dossier ,
129- {"goal_id" : goal ["id" ], "source" : source , "analysis" : None , "scope" : value ["scope" ]},
130- )
177+ saved = pending or {
178+ "goal_id" : goal ["id" ],
179+ "source" : source ,
180+ "analysis" : None ,
181+ "scope" : value ["scope" ],
182+ "analysis_attempts" : 0 ,
183+ }
184+ atomic_json (dossier , saved )
131185 value ["dossier" ] = str (dossier )
132186 atomic_json (path , value )
187+ attempts_before = saved .get ("analysis_attempts" , 0 )
133188 try :
134189 client = accepted_master (root , job ["branch" ])
190+ saved ["analysis_attempts" ] = saved .get ("analysis_attempts" , 0 ) + 1
191+ atomic_json (dossier , saved )
135192 response = native_turn (
136193 client ,
137194 [
@@ -165,6 +222,8 @@ def execute(root: Path, job: dict) -> dict:
165222 raise ValueError (
166223 "No accepted work or payment receipt; conservative income lower bound must be zero"
167224 )
225+ if (load_goal (root ) or {}).get ("id" ) != goal ["id" ]:
226+ raise ValueError ("Goal changed during analysis; proposal retained but not registered" )
168227 row = register (root , job ["branch" ], specification )
169228 atomic_json (
170229 dossier ,
@@ -175,8 +234,22 @@ def execute(root: Path, job: dict) -> dict:
175234 candidate_id = row ["id" ],
176235 proposals_registered = value ["proposals_registered" ] + 1 ,
177236 )
237+ except MasterNotReady as error :
238+ attempts = value .get ("readiness_attempts" , 0 ) + 1
239+ value .update (
240+ state = "waiting for accepted master" ,
241+ error = str (error )[:400 ],
242+ readiness_attempts = attempts ,
243+ retry_after_seconds = min (120 , 15 * 2 ** min (attempts , 3 )) if attempts <= 6 else 600 ,
244+ )
245+ journal .write ("steps" , "income-analysis-deferred" , value )
178246 except (ValueError , OSError , RuntimeError , requests .RequestException ) as error :
247+ saved ["analysis_attempts" ] = max (attempts_before + 1 , saved .get ("analysis_attempts" , 0 ))
248+ saved ["analysis_error" ] = str (error )[:400 ]
249+ atomic_json (dossier , saved )
179250 value .update (state = "evidence collected; analysis blocked" , error = str (error )[:400 ])
251+ if saved .get ("analysis_attempts" , 0 ) < 3 :
252+ value ["retry_after_seconds" ] = 120
180253 journal .write ("errors" , "income-analysis-blocked" , value )
181254 value ["updated" ] = time .time ()
182255 atomic_json (path , value )
0 commit comments