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.
Files changed (33) hide show
  1. loopy_loop/__init__.py +1 -0
  2. loopy_loop/cli.py +293 -0
  3. loopy_loop/config.py +491 -0
  4. loopy_loop/coordinator_app.py +706 -0
  5. loopy_loop/harness_runner.py +195 -0
  6. loopy_loop/models.py +163 -0
  7. loopy_loop/scheduler.py +232 -0
  8. loopy_loop/sessions.py +313 -0
  9. loopy_loop/state_store.py +110 -0
  10. loopy_loop/templates/inner_outer_eval/.gitignore +4 -0
  11. loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_reviewer/config.yaml +11 -0
  12. loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_reviewer/prompt.txt +97 -0
  13. loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_runner/config.yaml +12 -0
  14. loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_runner/prompt.txt +86 -0
  15. loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/inner/config.yaml +8 -0
  16. loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/inner/prompt.txt +223 -0
  17. loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/outer/config.yaml +8 -0
  18. loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/outer/prompt.txt +333 -0
  19. loopy_loop/templates/inner_outer_eval/loopy_loop_config.yaml +45 -0
  20. loopy_loop/templates/inner_outer_eval/loopy_loop_goal.txt +5 -0
  21. loopy_loop/templates/pm_planner_dispatcher/.gitignore +1 -0
  22. loopy_loop/templates/pm_planner_dispatcher/.loopy_loop/workflow_sets/pm_planner_dispatcher/workflows/dispatcher/config.yaml +8 -0
  23. loopy_loop/templates/pm_planner_dispatcher/.loopy_loop/workflow_sets/pm_planner_dispatcher/workflows/dispatcher/prompt.txt +78 -0
  24. loopy_loop/templates/pm_planner_dispatcher/.loopy_loop/workflow_sets/pm_planner_dispatcher/workflows/planner/config.yaml +9 -0
  25. loopy_loop/templates/pm_planner_dispatcher/.loopy_loop/workflow_sets/pm_planner_dispatcher/workflows/planner/prompt.txt +99 -0
  26. loopy_loop/templates/pm_planner_dispatcher/loopy_loop_config.yaml +24 -0
  27. loopy_loop/templates/pm_planner_dispatcher/loopy_loop_goal.txt +1 -0
  28. loopy_loop/worker.py +265 -0
  29. loopy_loop-0.2.0.dist-info/METADATA +408 -0
  30. loopy_loop-0.2.0.dist-info/RECORD +33 -0
  31. loopy_loop-0.2.0.dist-info/WHEEL +4 -0
  32. loopy_loop-0.2.0.dist-info/entry_points.txt +3 -0
  33. 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