loopy-loop 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- loopy_loop/__init__.py +1 -0
- loopy_loop/cli.py +293 -0
- loopy_loop/config.py +491 -0
- loopy_loop/coordinator_app.py +706 -0
- loopy_loop/harness_runner.py +195 -0
- loopy_loop/models.py +163 -0
- loopy_loop/scheduler.py +232 -0
- loopy_loop/sessions.py +313 -0
- loopy_loop/state_store.py +110 -0
- loopy_loop/templates/inner_outer_eval/.gitignore +4 -0
- loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_reviewer/config.yaml +11 -0
- loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_reviewer/prompt.txt +97 -0
- loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_runner/config.yaml +12 -0
- loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_runner/prompt.txt +86 -0
- loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/inner/config.yaml +8 -0
- loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/inner/prompt.txt +223 -0
- loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/outer/config.yaml +8 -0
- loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/outer/prompt.txt +333 -0
- loopy_loop/templates/inner_outer_eval/loopy_loop_config.yaml +45 -0
- loopy_loop/templates/inner_outer_eval/loopy_loop_goal.txt +5 -0
- loopy_loop/templates/pm_planner_dispatcher/.gitignore +1 -0
- loopy_loop/templates/pm_planner_dispatcher/.loopy_loop/workflow_sets/pm_planner_dispatcher/workflows/dispatcher/config.yaml +8 -0
- loopy_loop/templates/pm_planner_dispatcher/.loopy_loop/workflow_sets/pm_planner_dispatcher/workflows/dispatcher/prompt.txt +78 -0
- loopy_loop/templates/pm_planner_dispatcher/.loopy_loop/workflow_sets/pm_planner_dispatcher/workflows/planner/config.yaml +9 -0
- loopy_loop/templates/pm_planner_dispatcher/.loopy_loop/workflow_sets/pm_planner_dispatcher/workflows/planner/prompt.txt +99 -0
- loopy_loop/templates/pm_planner_dispatcher/loopy_loop_config.yaml +24 -0
- loopy_loop/templates/pm_planner_dispatcher/loopy_loop_goal.txt +1 -0
- loopy_loop/worker.py +265 -0
- loopy_loop-0.2.0.dist-info/METADATA +408 -0
- loopy_loop-0.2.0.dist-info/RECORD +33 -0
- loopy_loop-0.2.0.dist-info/WHEEL +4 -0
- loopy_loop-0.2.0.dist-info/entry_points.txt +3 -0
- loopy_loop-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
from typing import TypeVar
|
|
9
|
+
|
|
10
|
+
from fastapi import FastAPI
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from loopy_loop.config import ConfigError
|
|
14
|
+
from loopy_loop.config import derive_goal_hash
|
|
15
|
+
from loopy_loop.config import PreflightResult
|
|
16
|
+
from loopy_loop.config import run_preflight
|
|
17
|
+
from loopy_loop.config import WorkflowDefinition
|
|
18
|
+
from loopy_loop.models import ChildSessionRecord
|
|
19
|
+
from loopy_loop.models import ChildSessionRequest
|
|
20
|
+
from loopy_loop.models import ControlSignal
|
|
21
|
+
from loopy_loop.models import CurrentTask
|
|
22
|
+
from loopy_loop.models import FinishedRequest
|
|
23
|
+
from loopy_loop.models import GoalCheckSignal
|
|
24
|
+
from loopy_loop.models import HistoryEntry
|
|
25
|
+
from loopy_loop.models import IterationResult
|
|
26
|
+
from loopy_loop.models import LoopState
|
|
27
|
+
from loopy_loop.models import RootConfigSnapshot
|
|
28
|
+
from loopy_loop.models import STOP_ACTION
|
|
29
|
+
from loopy_loop.models import TaskResponse
|
|
30
|
+
from loopy_loop.models import utc_now
|
|
31
|
+
from loopy_loop.scheduler import choose_next_workflow
|
|
32
|
+
from loopy_loop.sessions import child_requests_dir_path
|
|
33
|
+
from loopy_loop.sessions import children_path
|
|
34
|
+
from loopy_loop.sessions import control_path
|
|
35
|
+
from loopy_loop.sessions import create_session_dir
|
|
36
|
+
from loopy_loop.sessions import create_session_id
|
|
37
|
+
from loopy_loop.sessions import goal_check_path
|
|
38
|
+
from loopy_loop.sessions import pending_finished_request_path
|
|
39
|
+
from loopy_loop.sessions import result_path
|
|
40
|
+
from loopy_loop.sessions import state_path
|
|
41
|
+
from loopy_loop.state_store import StateStore
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def create_coordinator_app(
|
|
47
|
+
*,
|
|
48
|
+
repo_root: Path,
|
|
49
|
+
resume: bool,
|
|
50
|
+
workflow_set: str | None = None,
|
|
51
|
+
goal_file: Path | None = None,
|
|
52
|
+
) -> FastAPI:
|
|
53
|
+
preflight = run_preflight(
|
|
54
|
+
repo_root=repo_root, workflow_set=workflow_set, goal_file=goal_file
|
|
55
|
+
)
|
|
56
|
+
store = StateStore(repo_root=repo_root)
|
|
57
|
+
service = CoordinatorService(
|
|
58
|
+
repo_root=repo_root, preflight=preflight, state_store=store, resume=resume
|
|
59
|
+
)
|
|
60
|
+
app = FastAPI()
|
|
61
|
+
app.state.service = service
|
|
62
|
+
|
|
63
|
+
@app.post("/register", response_model=TaskResponse)
|
|
64
|
+
def register_worker() -> TaskResponse:
|
|
65
|
+
return service.register_worker()
|
|
66
|
+
|
|
67
|
+
@app.post("/finished", response_model=TaskResponse)
|
|
68
|
+
def finish_assignment(request: FinishedRequest) -> TaskResponse:
|
|
69
|
+
return service.finish_assignment(request=request)
|
|
70
|
+
|
|
71
|
+
return app
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CoordinatorService:
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
repo_root: Path,
|
|
79
|
+
preflight: PreflightResult,
|
|
80
|
+
state_store: StateStore,
|
|
81
|
+
resume: bool,
|
|
82
|
+
) -> None:
|
|
83
|
+
self.repo_root = repo_root
|
|
84
|
+
self.preflight = preflight
|
|
85
|
+
self.preflights: dict[str, PreflightResult] = {
|
|
86
|
+
preflight.workflow_set: preflight
|
|
87
|
+
}
|
|
88
|
+
self.state_store = state_store
|
|
89
|
+
self._prepare_state(resume=resume)
|
|
90
|
+
|
|
91
|
+
def register_worker(self) -> TaskResponse:
|
|
92
|
+
recovered_pending_paths: list[Path] = []
|
|
93
|
+
|
|
94
|
+
def mutator(state: LoopState | None) -> tuple[LoopState, TaskResponse]:
|
|
95
|
+
current = _require_state(state=state)
|
|
96
|
+
now = utc_now()
|
|
97
|
+
|
|
98
|
+
# Step 3: If current_task is set (crash recovery from previous worker crash),
|
|
99
|
+
# recover a locally completed result before calling it abandoned.
|
|
100
|
+
# Important: this increment can itself trigger max_turns — that is correct
|
|
101
|
+
# behaviour. The stop check (step 4) will catch it immediately after.
|
|
102
|
+
if current.current_task is not None:
|
|
103
|
+
orphaned = current.current_task
|
|
104
|
+
recovered = self._read_recoverable_finished_request(
|
|
105
|
+
current_task=orphaned
|
|
106
|
+
)
|
|
107
|
+
if recovered is None:
|
|
108
|
+
current.history.append(
|
|
109
|
+
HistoryEntry(
|
|
110
|
+
iteration=orphaned.iteration,
|
|
111
|
+
workflow_set=orphaned.workflow_set,
|
|
112
|
+
workflow_id=orphaned.workflow_id,
|
|
113
|
+
session_id=orphaned.session_id,
|
|
114
|
+
success=False,
|
|
115
|
+
error="abandoned",
|
|
116
|
+
started_at=orphaned.started_at,
|
|
117
|
+
finished_at=now,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
current.iteration_count += 1
|
|
121
|
+
current.current_task = None
|
|
122
|
+
else:
|
|
123
|
+
recovered_request, pending_path = recovered
|
|
124
|
+
if pending_path is not None:
|
|
125
|
+
recovered_pending_paths.append(pending_path)
|
|
126
|
+
self._record_finished_task(
|
|
127
|
+
state=current,
|
|
128
|
+
active=orphaned,
|
|
129
|
+
request=recovered_request,
|
|
130
|
+
now=now,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Step 4: Check stop conditions after abandoned-task cleanup.
|
|
134
|
+
stop_response = self._stop_response_if_needed(state=current)
|
|
135
|
+
if stop_response is not None:
|
|
136
|
+
return current, stop_response
|
|
137
|
+
|
|
138
|
+
child_response = self._dispatch_child_session_after_success(state=current)
|
|
139
|
+
if child_response is not None:
|
|
140
|
+
return current, child_response
|
|
141
|
+
|
|
142
|
+
# Step 5: Choose next workflow.
|
|
143
|
+
workflows = self._workflows_for(workflow_set=current.workflow_set)
|
|
144
|
+
workflow = choose_next_workflow(
|
|
145
|
+
workflows=workflows,
|
|
146
|
+
history=current.history,
|
|
147
|
+
iteration_count=current.iteration_count,
|
|
148
|
+
)
|
|
149
|
+
if workflow is None:
|
|
150
|
+
current.stop_reason = "no_eligible_workflow"
|
|
151
|
+
current.status = "failed"
|
|
152
|
+
return current, TaskResponse(
|
|
153
|
+
action=STOP_ACTION, stop_reason="no_eligible_workflow"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Step 6: Set current_task and return run response.
|
|
157
|
+
current.current_task = CurrentTask(
|
|
158
|
+
workflow_set=current.workflow_set,
|
|
159
|
+
workflow_id=workflow.id,
|
|
160
|
+
session_id=current.active_session_id,
|
|
161
|
+
iteration=current.iteration_count + 1,
|
|
162
|
+
started_at=now,
|
|
163
|
+
)
|
|
164
|
+
return current, _build_run_response(
|
|
165
|
+
current_task=current.current_task,
|
|
166
|
+
config_snapshot=current.config_snapshot,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
response = self.state_store.mutate(mutator)
|
|
170
|
+
for path in recovered_pending_paths:
|
|
171
|
+
path.unlink(missing_ok=True)
|
|
172
|
+
return response
|
|
173
|
+
|
|
174
|
+
def finish_assignment(self, *, request: FinishedRequest) -> TaskResponse:
|
|
175
|
+
def mutator(state: LoopState | None) -> tuple[LoopState, TaskResponse]:
|
|
176
|
+
current = _require_state(state=state)
|
|
177
|
+
now = utc_now()
|
|
178
|
+
|
|
179
|
+
# Step 3: No active task — stale call. Dispatch as if /register was called.
|
|
180
|
+
# This handles the post-crash stale retry scenario safely.
|
|
181
|
+
if current.current_task is None:
|
|
182
|
+
# Check stop conditions first; if terminal, return stop.
|
|
183
|
+
stop_response = self._stop_response_if_needed(state=current)
|
|
184
|
+
if stop_response is not None:
|
|
185
|
+
return current, stop_response
|
|
186
|
+
child_response = self._dispatch_child_session_after_success(
|
|
187
|
+
state=current
|
|
188
|
+
)
|
|
189
|
+
if child_response is not None:
|
|
190
|
+
return current, child_response
|
|
191
|
+
workflows = self._workflows_for(workflow_set=current.workflow_set)
|
|
192
|
+
workflow = choose_next_workflow(
|
|
193
|
+
workflows=workflows,
|
|
194
|
+
history=current.history,
|
|
195
|
+
iteration_count=current.iteration_count,
|
|
196
|
+
)
|
|
197
|
+
if workflow is None:
|
|
198
|
+
current.stop_reason = "no_eligible_workflow"
|
|
199
|
+
current.status = "failed"
|
|
200
|
+
return current, TaskResponse(
|
|
201
|
+
action=STOP_ACTION, stop_reason="no_eligible_workflow"
|
|
202
|
+
)
|
|
203
|
+
current.current_task = CurrentTask(
|
|
204
|
+
workflow_set=current.workflow_set,
|
|
205
|
+
workflow_id=workflow.id,
|
|
206
|
+
session_id=current.active_session_id,
|
|
207
|
+
iteration=current.iteration_count + 1,
|
|
208
|
+
started_at=now,
|
|
209
|
+
)
|
|
210
|
+
return current, _build_run_response(
|
|
211
|
+
current_task=current.current_task,
|
|
212
|
+
config_snapshot=current.config_snapshot,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Step 4: Mismatch check — stale call for a different task.
|
|
216
|
+
# Do NOT mutate state; return the current task's run response so the
|
|
217
|
+
# caller knows what is actually running. Note: the returned workflow_id
|
|
218
|
+
# and iteration belong to the CURRENT (live) task, not the stale caller's
|
|
219
|
+
# completed task — this is intentional and safe in the single-worker model.
|
|
220
|
+
active = current.current_task
|
|
221
|
+
if (
|
|
222
|
+
request.session_id != active.session_id
|
|
223
|
+
or request.workflow_id != active.workflow_id
|
|
224
|
+
or request.iteration != active.iteration
|
|
225
|
+
):
|
|
226
|
+
return current, _build_run_response(
|
|
227
|
+
current_task=active, config_snapshot=current.config_snapshot
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Step 5: Match confirmed — process result.
|
|
231
|
+
self._record_finished_task(
|
|
232
|
+
state=current, active=active, request=request, now=now
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Step 6: Special cases that stop immediately.
|
|
236
|
+
if current.stop_reason == "goal_check_broken":
|
|
237
|
+
return current, TaskResponse(
|
|
238
|
+
action=STOP_ACTION, stop_reason="goal_check_broken"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Step 7: Check stop conditions.
|
|
242
|
+
stop_response = self._stop_response_if_needed(state=current)
|
|
243
|
+
if stop_response is not None:
|
|
244
|
+
return current, stop_response
|
|
245
|
+
|
|
246
|
+
# Step 8: Dispatch next workflow.
|
|
247
|
+
child_response = self._dispatch_child_session_after_success(state=current)
|
|
248
|
+
if child_response is not None:
|
|
249
|
+
return current, child_response
|
|
250
|
+
|
|
251
|
+
workflows = self._workflows_for(workflow_set=current.workflow_set)
|
|
252
|
+
workflow = choose_next_workflow(
|
|
253
|
+
workflows=workflows,
|
|
254
|
+
history=current.history,
|
|
255
|
+
iteration_count=current.iteration_count,
|
|
256
|
+
)
|
|
257
|
+
if workflow is None:
|
|
258
|
+
current.stop_reason = "no_eligible_workflow"
|
|
259
|
+
current.status = "failed"
|
|
260
|
+
return current, TaskResponse(
|
|
261
|
+
action=STOP_ACTION, stop_reason="no_eligible_workflow"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Step 9: Set new current_task and return run response.
|
|
265
|
+
current.current_task = CurrentTask(
|
|
266
|
+
workflow_set=current.workflow_set,
|
|
267
|
+
workflow_id=workflow.id,
|
|
268
|
+
session_id=current.active_session_id,
|
|
269
|
+
iteration=current.iteration_count + 1,
|
|
270
|
+
started_at=now,
|
|
271
|
+
)
|
|
272
|
+
return current, _build_run_response(
|
|
273
|
+
current_task=current.current_task,
|
|
274
|
+
config_snapshot=current.config_snapshot,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
response = self.state_store.mutate(mutator)
|
|
278
|
+
if response.action == STOP_ACTION:
|
|
279
|
+
parent_response = self._resume_parent_if_active_child_completed()
|
|
280
|
+
if parent_response is not None:
|
|
281
|
+
return parent_response
|
|
282
|
+
return response
|
|
283
|
+
|
|
284
|
+
def _record_finished_task(
|
|
285
|
+
self,
|
|
286
|
+
*,
|
|
287
|
+
state: LoopState,
|
|
288
|
+
active: CurrentTask,
|
|
289
|
+
request: FinishedRequest,
|
|
290
|
+
now: datetime,
|
|
291
|
+
) -> None:
|
|
292
|
+
success = request.success
|
|
293
|
+
error = request.error
|
|
294
|
+
|
|
295
|
+
if self._workflow_expects_goal_check_signal(
|
|
296
|
+
workflow_set=active.workflow_set, workflow_id=active.workflow_id
|
|
297
|
+
):
|
|
298
|
+
goal_signal = self._read_goal_check_signal(current_task=active)
|
|
299
|
+
if goal_signal is None:
|
|
300
|
+
success = False
|
|
301
|
+
error = "invalid_goal_check_output"
|
|
302
|
+
state.goal_check_consecutive_failures += 1
|
|
303
|
+
if (
|
|
304
|
+
state.goal_check_consecutive_failures
|
|
305
|
+
>= state.config_snapshot.goal_check_consecutive_failures_cap
|
|
306
|
+
):
|
|
307
|
+
state.stop_reason = "goal_check_broken"
|
|
308
|
+
state.status = "failed"
|
|
309
|
+
else:
|
|
310
|
+
state.goal_check_consecutive_failures = 0
|
|
311
|
+
|
|
312
|
+
if state.stop_reason != "goal_check_broken":
|
|
313
|
+
self._apply_session_control(state=state)
|
|
314
|
+
if state.stop_reason == "invalid_control_output":
|
|
315
|
+
success = False
|
|
316
|
+
error = "invalid_control_output"
|
|
317
|
+
|
|
318
|
+
state.history.append(
|
|
319
|
+
HistoryEntry(
|
|
320
|
+
iteration=active.iteration,
|
|
321
|
+
workflow_set=active.workflow_set,
|
|
322
|
+
workflow_id=active.workflow_id,
|
|
323
|
+
session_id=active.session_id,
|
|
324
|
+
success=success,
|
|
325
|
+
error=error,
|
|
326
|
+
started_at=active.started_at,
|
|
327
|
+
finished_at=now,
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
state.iteration_count += 1
|
|
331
|
+
state.current_task = None
|
|
332
|
+
|
|
333
|
+
def _read_recoverable_finished_request(
|
|
334
|
+
self, *, current_task: CurrentTask
|
|
335
|
+
) -> tuple[FinishedRequest, Path | None] | None:
|
|
336
|
+
pending = pending_finished_request_path(
|
|
337
|
+
repo_root=self.repo_root,
|
|
338
|
+
session_id=current_task.session_id,
|
|
339
|
+
iteration=current_task.iteration,
|
|
340
|
+
workflow_id=current_task.workflow_id,
|
|
341
|
+
)
|
|
342
|
+
request = _read_signal(path=pending, model=FinishedRequest)
|
|
343
|
+
if request is not None and _matches_current_task(
|
|
344
|
+
request=request, current_task=current_task
|
|
345
|
+
):
|
|
346
|
+
return request, pending
|
|
347
|
+
|
|
348
|
+
result = _read_signal(
|
|
349
|
+
path=result_path(
|
|
350
|
+
repo_root=self.repo_root,
|
|
351
|
+
session_id=current_task.session_id,
|
|
352
|
+
iteration=current_task.iteration,
|
|
353
|
+
workflow_id=current_task.workflow_id,
|
|
354
|
+
),
|
|
355
|
+
model=IterationResult,
|
|
356
|
+
)
|
|
357
|
+
if result is None:
|
|
358
|
+
return None
|
|
359
|
+
return (
|
|
360
|
+
FinishedRequest(
|
|
361
|
+
session_id=current_task.session_id,
|
|
362
|
+
workflow_id=current_task.workflow_id,
|
|
363
|
+
iteration=current_task.iteration,
|
|
364
|
+
success=result.success,
|
|
365
|
+
text=result.text,
|
|
366
|
+
error=result.error,
|
|
367
|
+
),
|
|
368
|
+
None,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def _prepare_state(self, *, resume: bool) -> None:
|
|
372
|
+
existing_state = self.state_store.read_state()
|
|
373
|
+
if existing_state is None:
|
|
374
|
+
self._write_fresh_state()
|
|
375
|
+
return
|
|
376
|
+
if self.state_store.is_terminal_state(state=existing_state):
|
|
377
|
+
self.state_store.archive_state()
|
|
378
|
+
self._write_fresh_state()
|
|
379
|
+
return
|
|
380
|
+
if not resume:
|
|
381
|
+
raise ConfigError(
|
|
382
|
+
"Found running loopy-loop state. Restart with --resume to continue "
|
|
383
|
+
"the in-progress session."
|
|
384
|
+
)
|
|
385
|
+
create_session_dir(
|
|
386
|
+
repo_root=self.repo_root,
|
|
387
|
+
session_id=existing_state.active_session_id,
|
|
388
|
+
goal_hash=existing_state.goal_hash,
|
|
389
|
+
goal=existing_state.config_snapshot.goal,
|
|
390
|
+
workflow_set=existing_state.workflow_set,
|
|
391
|
+
parent_session_id=existing_state.parent_session_id,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def _write_fresh_state(self) -> None:
|
|
395
|
+
session_id = create_session_id(goal_hash=self.preflight.root_config.goal_hash)
|
|
396
|
+
create_session_dir(
|
|
397
|
+
repo_root=self.repo_root,
|
|
398
|
+
session_id=session_id,
|
|
399
|
+
goal_hash=self.preflight.root_config.goal_hash,
|
|
400
|
+
goal=self.preflight.root_config.goal,
|
|
401
|
+
workflow_set=self.preflight.workflow_set,
|
|
402
|
+
)
|
|
403
|
+
self.state_store = StateStore(
|
|
404
|
+
repo_root=self.repo_root,
|
|
405
|
+
state_path=state_path(repo_root=self.repo_root, session_id=session_id),
|
|
406
|
+
)
|
|
407
|
+
snapshot = RootConfigSnapshot.model_validate(
|
|
408
|
+
self.preflight.root_config.model_dump()
|
|
409
|
+
)
|
|
410
|
+
state = LoopState(
|
|
411
|
+
status="running",
|
|
412
|
+
goal_hash=self.preflight.root_config.goal_hash,
|
|
413
|
+
workflow_set=self.preflight.workflow_set,
|
|
414
|
+
max_turns=self.preflight.root_config.max_turns,
|
|
415
|
+
active_session_id=session_id,
|
|
416
|
+
config_snapshot=snapshot,
|
|
417
|
+
)
|
|
418
|
+
self.state_store.write_state(state=state)
|
|
419
|
+
|
|
420
|
+
def _preflight_for(
|
|
421
|
+
self, *, workflow_set: str, goal: str | None = None
|
|
422
|
+
) -> PreflightResult:
|
|
423
|
+
preflight = self.preflights.get(workflow_set)
|
|
424
|
+
if preflight is None:
|
|
425
|
+
preflight = run_preflight(
|
|
426
|
+
repo_root=self.repo_root, workflow_set=workflow_set
|
|
427
|
+
)
|
|
428
|
+
self.preflights[workflow_set] = preflight
|
|
429
|
+
if goal is None:
|
|
430
|
+
return preflight
|
|
431
|
+
root_config = preflight.root_config.model_copy(
|
|
432
|
+
update={"goal": goal, "workflow_set": workflow_set}
|
|
433
|
+
)
|
|
434
|
+
return PreflightResult(
|
|
435
|
+
root_config=root_config,
|
|
436
|
+
workflow_set=workflow_set,
|
|
437
|
+
workflows=preflight.workflows,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
def _workflows_for(self, *, workflow_set: str) -> list[WorkflowDefinition]:
|
|
441
|
+
return self._preflight_for(workflow_set=workflow_set).workflows
|
|
442
|
+
|
|
443
|
+
def _workflows_by_id_for(
|
|
444
|
+
self, *, workflow_set: str
|
|
445
|
+
) -> dict[str, WorkflowDefinition]:
|
|
446
|
+
return {
|
|
447
|
+
workflow.id: workflow
|
|
448
|
+
for workflow in self._workflows_for(workflow_set=workflow_set)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
def _dispatch_child_session_if_requested(
|
|
452
|
+
self, *, state: LoopState
|
|
453
|
+
) -> TaskResponse | None:
|
|
454
|
+
if state.parent_session_id is not None:
|
|
455
|
+
return None
|
|
456
|
+
requests_dir = child_requests_dir_path(
|
|
457
|
+
repo_root=self.repo_root, session_id=state.active_session_id
|
|
458
|
+
)
|
|
459
|
+
if not requests_dir.exists():
|
|
460
|
+
return None
|
|
461
|
+
for request_path in sorted(requests_dir.glob("*.json")):
|
|
462
|
+
request = _read_signal(path=request_path, model=ChildSessionRequest)
|
|
463
|
+
if request is None:
|
|
464
|
+
continue
|
|
465
|
+
preflight = self._preflight_for(
|
|
466
|
+
workflow_set=request.workflow_set, goal=request.goal
|
|
467
|
+
)
|
|
468
|
+
workflows = preflight.workflows
|
|
469
|
+
workflow = choose_next_workflow(
|
|
470
|
+
workflows=workflows, history=[], iteration_count=0
|
|
471
|
+
)
|
|
472
|
+
if workflow is None:
|
|
473
|
+
continue
|
|
474
|
+
goal_hash = derive_goal_hash(goal=request.goal)
|
|
475
|
+
child_session_id = create_session_id(goal_hash=goal_hash)
|
|
476
|
+
create_session_dir(
|
|
477
|
+
repo_root=self.repo_root,
|
|
478
|
+
session_id=child_session_id,
|
|
479
|
+
goal_hash=goal_hash,
|
|
480
|
+
goal=request.goal,
|
|
481
|
+
workflow_set=request.workflow_set,
|
|
482
|
+
parent_session_id=state.active_session_id,
|
|
483
|
+
)
|
|
484
|
+
snapshot = RootConfigSnapshot.model_validate(
|
|
485
|
+
preflight.root_config.model_dump()
|
|
486
|
+
)
|
|
487
|
+
now = utc_now()
|
|
488
|
+
child_state = LoopState(
|
|
489
|
+
status="running",
|
|
490
|
+
goal_hash=goal_hash,
|
|
491
|
+
workflow_set=request.workflow_set,
|
|
492
|
+
parent_session_id=state.active_session_id,
|
|
493
|
+
max_turns=preflight.root_config.max_turns,
|
|
494
|
+
active_session_id=child_session_id,
|
|
495
|
+
config_snapshot=snapshot,
|
|
496
|
+
current_task=CurrentTask(
|
|
497
|
+
workflow_set=request.workflow_set,
|
|
498
|
+
workflow_id=workflow.id,
|
|
499
|
+
session_id=child_session_id,
|
|
500
|
+
iteration=1,
|
|
501
|
+
started_at=now,
|
|
502
|
+
),
|
|
503
|
+
)
|
|
504
|
+
child_store = StateStore(
|
|
505
|
+
repo_root=self.repo_root,
|
|
506
|
+
state_path=state_path(
|
|
507
|
+
repo_root=self.repo_root, session_id=child_session_id
|
|
508
|
+
),
|
|
509
|
+
)
|
|
510
|
+
child_store.write_state(state=child_state)
|
|
511
|
+
child_task = child_state.current_task
|
|
512
|
+
if child_task is None:
|
|
513
|
+
raise RuntimeError("Child session was created without a task")
|
|
514
|
+
self._append_child_record(
|
|
515
|
+
parent_session_id=state.active_session_id,
|
|
516
|
+
record=ChildSessionRecord(
|
|
517
|
+
session_id=child_session_id,
|
|
518
|
+
workflow_set=request.workflow_set,
|
|
519
|
+
goal_hash=goal_hash,
|
|
520
|
+
status="running",
|
|
521
|
+
created_at=now,
|
|
522
|
+
),
|
|
523
|
+
)
|
|
524
|
+
request_path.unlink(missing_ok=True)
|
|
525
|
+
self.state_store = child_store
|
|
526
|
+
return _build_run_response(
|
|
527
|
+
current_task=child_task, config_snapshot=child_state.config_snapshot
|
|
528
|
+
)
|
|
529
|
+
return None
|
|
530
|
+
|
|
531
|
+
def _dispatch_child_session_after_success(
|
|
532
|
+
self, *, state: LoopState
|
|
533
|
+
) -> TaskResponse | None:
|
|
534
|
+
if not state.history or not state.history[-1].success:
|
|
535
|
+
return None
|
|
536
|
+
return self._dispatch_child_session_if_requested(state=state)
|
|
537
|
+
|
|
538
|
+
def _resume_parent_if_active_child_completed(self) -> TaskResponse | None:
|
|
539
|
+
child_state = self.state_store.read_state()
|
|
540
|
+
if child_state is None or child_state.parent_session_id is None:
|
|
541
|
+
return None
|
|
542
|
+
if not self.state_store.is_terminal_state(state=child_state):
|
|
543
|
+
return None
|
|
544
|
+
self._mark_child_record_complete(child_state=child_state)
|
|
545
|
+
parent_store = StateStore(
|
|
546
|
+
repo_root=self.repo_root,
|
|
547
|
+
state_path=state_path(
|
|
548
|
+
repo_root=self.repo_root, session_id=child_state.parent_session_id
|
|
549
|
+
),
|
|
550
|
+
)
|
|
551
|
+
self.state_store = parent_store
|
|
552
|
+
return self.register_worker()
|
|
553
|
+
|
|
554
|
+
def _append_child_record(
|
|
555
|
+
self, *, parent_session_id: str, record: ChildSessionRecord
|
|
556
|
+
) -> None:
|
|
557
|
+
path = children_path(repo_root=self.repo_root, session_id=parent_session_id)
|
|
558
|
+
payload = _read_children_payload(path=path)
|
|
559
|
+
payload["children"].append(json.loads(record.model_dump_json()))
|
|
560
|
+
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
561
|
+
|
|
562
|
+
def _mark_child_record_complete(self, *, child_state: LoopState) -> None:
|
|
563
|
+
assert child_state.parent_session_id is not None
|
|
564
|
+
path = children_path(
|
|
565
|
+
repo_root=self.repo_root, session_id=child_state.parent_session_id
|
|
566
|
+
)
|
|
567
|
+
payload = _read_children_payload(path=path)
|
|
568
|
+
for record in payload["children"]:
|
|
569
|
+
if record.get("session_id") == child_state.active_session_id:
|
|
570
|
+
record["status"] = child_state.status
|
|
571
|
+
record["completed_at"] = utc_now().isoformat().replace("+00:00", "Z")
|
|
572
|
+
record["stop_reason"] = child_state.stop_reason
|
|
573
|
+
break
|
|
574
|
+
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
575
|
+
|
|
576
|
+
def _apply_stop_precedence(self, *, state: LoopState) -> str | None:
|
|
577
|
+
if state.goal_met:
|
|
578
|
+
state.status = "goal_met"
|
|
579
|
+
state.stop_reason = "goal_met"
|
|
580
|
+
return "goal_met"
|
|
581
|
+
if state.stop_requested:
|
|
582
|
+
state.status = "stopped"
|
|
583
|
+
state.stop_reason = "stop_requested"
|
|
584
|
+
return "stop_requested"
|
|
585
|
+
if state.unresolvable_error:
|
|
586
|
+
state.status = "failed"
|
|
587
|
+
state.stop_reason = "unresolvable_error"
|
|
588
|
+
return "unresolvable_error"
|
|
589
|
+
if state.iteration_count >= state.max_turns:
|
|
590
|
+
state.status = "max_turns"
|
|
591
|
+
state.stop_reason = "max_turns"
|
|
592
|
+
return "max_turns"
|
|
593
|
+
if state.status in {"stopped", "goal_met", "failed", "max_turns"}:
|
|
594
|
+
return state.stop_reason or state.status
|
|
595
|
+
return None
|
|
596
|
+
|
|
597
|
+
def _stop_response_if_needed(self, *, state: LoopState) -> TaskResponse | None:
|
|
598
|
+
self._apply_session_control(state=state)
|
|
599
|
+
stop_reason = self._apply_stop_precedence(state=state)
|
|
600
|
+
if stop_reason is not None:
|
|
601
|
+
return TaskResponse(action=STOP_ACTION, stop_reason=stop_reason)
|
|
602
|
+
return None
|
|
603
|
+
|
|
604
|
+
def _read_goal_check_signal(
|
|
605
|
+
self, *, current_task: CurrentTask
|
|
606
|
+
) -> GoalCheckSignal | None:
|
|
607
|
+
path = goal_check_path(
|
|
608
|
+
repo_root=self.repo_root,
|
|
609
|
+
session_id=current_task.session_id,
|
|
610
|
+
iteration=current_task.iteration,
|
|
611
|
+
workflow_id=current_task.workflow_id,
|
|
612
|
+
)
|
|
613
|
+
return _read_signal(path=path, model=GoalCheckSignal)
|
|
614
|
+
|
|
615
|
+
def _workflow_expects_goal_check_signal(
|
|
616
|
+
self, *, workflow_set: str, workflow_id: str
|
|
617
|
+
) -> bool:
|
|
618
|
+
workflow = self._workflows_by_id_for(workflow_set=workflow_set).get(workflow_id)
|
|
619
|
+
return workflow_id == "goal_check" or (
|
|
620
|
+
workflow is not None and workflow.emits_goal_check
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
def _apply_session_control(self, *, state: LoopState) -> None:
|
|
624
|
+
path = control_path(
|
|
625
|
+
repo_root=self.repo_root, session_id=state.active_session_id
|
|
626
|
+
)
|
|
627
|
+
if not path.exists():
|
|
628
|
+
return
|
|
629
|
+
signal = _read_signal(path=path, model=ControlSignal)
|
|
630
|
+
if signal is None:
|
|
631
|
+
state.status = "failed"
|
|
632
|
+
state.stop_reason = "invalid_control_output"
|
|
633
|
+
return
|
|
634
|
+
if signal.state == "running":
|
|
635
|
+
return
|
|
636
|
+
if signal.stop_reason == "goal_met":
|
|
637
|
+
state.goal_met = True
|
|
638
|
+
return
|
|
639
|
+
if signal.stop_reason == "unresolvable_error":
|
|
640
|
+
state.unresolvable_error = True
|
|
641
|
+
return
|
|
642
|
+
raise RuntimeError("unreachable")
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _build_run_response(
|
|
646
|
+
*, current_task: CurrentTask, config_snapshot: RootConfigSnapshot
|
|
647
|
+
) -> TaskResponse:
|
|
648
|
+
return TaskResponse(
|
|
649
|
+
action="run",
|
|
650
|
+
workflow_set=current_task.workflow_set,
|
|
651
|
+
workflow_id=current_task.workflow_id,
|
|
652
|
+
session_id=current_task.session_id,
|
|
653
|
+
iteration=current_task.iteration,
|
|
654
|
+
config_snapshot=config_snapshot,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _require_state(*, state: LoopState | None) -> LoopState:
|
|
659
|
+
if state is None:
|
|
660
|
+
raise RuntimeError("Coordinator state is not initialized")
|
|
661
|
+
return state
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _matches_current_task(
|
|
665
|
+
*, request: FinishedRequest, current_task: CurrentTask
|
|
666
|
+
) -> bool:
|
|
667
|
+
return (
|
|
668
|
+
request.session_id == current_task.session_id
|
|
669
|
+
and request.workflow_id == current_task.workflow_id
|
|
670
|
+
and request.iteration == current_task.iteration
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _read_children_payload(*, path: Path) -> dict[str, Any]:
|
|
675
|
+
try:
|
|
676
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
677
|
+
except (OSError, json.JSONDecodeError):
|
|
678
|
+
raw = {}
|
|
679
|
+
if isinstance(raw, dict) and isinstance(raw.get("children"), list):
|
|
680
|
+
return {"schema_version": 1, "children": raw["children"]}
|
|
681
|
+
if isinstance(raw, list):
|
|
682
|
+
return {"schema_version": 1, "children": raw}
|
|
683
|
+
return {"schema_version": 1, "children": []}
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
SignalModel = TypeVar("SignalModel", bound=BaseModel)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _read_signal(*, path: Path, model: type[SignalModel]) -> SignalModel | None:
|
|
690
|
+
if not path.exists():
|
|
691
|
+
return None
|
|
692
|
+
try:
|
|
693
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
694
|
+
except (OSError, json.JSONDecodeError):
|
|
695
|
+
logger.warning("Ignoring unreadable signal file at %s", path)
|
|
696
|
+
return None
|
|
697
|
+
try:
|
|
698
|
+
signal = model.model_validate(payload)
|
|
699
|
+
except Exception:
|
|
700
|
+
logger.warning("Ignoring invalid signal schema at %s", path)
|
|
701
|
+
return None
|
|
702
|
+
schema_version = getattr(signal, "schema_version", None)
|
|
703
|
+
if schema_version is not None and schema_version != 1:
|
|
704
|
+
logger.warning("Ignoring unsupported signal schema_version at %s", path)
|
|
705
|
+
return None
|
|
706
|
+
return signal
|