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,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import traceback
|
|
9
|
+
from typing import Callable
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
from team_harness import TeamHarness
|
|
13
|
+
from team_harness import TeamHarnessError
|
|
14
|
+
from team_harness import TeamHarnessResult
|
|
15
|
+
|
|
16
|
+
from loopy_loop.config import ConfigError
|
|
17
|
+
from loopy_loop.config import normalize_api_base
|
|
18
|
+
from loopy_loop.config import resolve_api_key
|
|
19
|
+
from loopy_loop.config import RootConfig
|
|
20
|
+
from loopy_loop.models import IterationResult
|
|
21
|
+
from loopy_loop.models import RootConfigSnapshot
|
|
22
|
+
from loopy_loop.sessions import HARNESS_RUN_ID_FILENAME
|
|
23
|
+
from loopy_loop.sessions import PROMPT_FILENAME
|
|
24
|
+
from loopy_loop.sessions import RESULT_FILENAME
|
|
25
|
+
from loopy_loop.sessions import RESULT_TEXT_FILENAME
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TeamHarnessLike(Protocol):
|
|
29
|
+
async def run(self, task: str) -> TeamHarnessResult: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_harness_iteration(
|
|
33
|
+
*,
|
|
34
|
+
repo_root: Path,
|
|
35
|
+
config_snapshot: RootConfigSnapshot,
|
|
36
|
+
rendered_prompt: str,
|
|
37
|
+
harness_output_root: Path | None = None,
|
|
38
|
+
harness_factory: Callable[..., TeamHarnessLike] = TeamHarness,
|
|
39
|
+
) -> IterationResult:
|
|
40
|
+
root_config = RootConfig.model_validate(
|
|
41
|
+
config_snapshot.model_dump(exclude={"goal_hash"})
|
|
42
|
+
)
|
|
43
|
+
resolved_api_key = resolve_api_key(config=root_config)
|
|
44
|
+
harness_kwargs = _build_harness_kwargs(
|
|
45
|
+
repo_root=repo_root,
|
|
46
|
+
config_snapshot=config_snapshot,
|
|
47
|
+
resolved_api_key=resolved_api_key,
|
|
48
|
+
harness_output_root=harness_output_root,
|
|
49
|
+
harness_factory=harness_factory,
|
|
50
|
+
)
|
|
51
|
+
harness = harness_factory(**harness_kwargs)
|
|
52
|
+
try:
|
|
53
|
+
result = asyncio.run(harness.run(task=rendered_prompt))
|
|
54
|
+
except ConfigError:
|
|
55
|
+
raise
|
|
56
|
+
except TeamHarnessError as exc:
|
|
57
|
+
traceback.print_exc()
|
|
58
|
+
harness_run_id, harness_output_dir = _failure_harness_paths(
|
|
59
|
+
detail=exc.detail, harness_output_root=harness_output_root
|
|
60
|
+
)
|
|
61
|
+
return IterationResult(
|
|
62
|
+
success=False,
|
|
63
|
+
text=None,
|
|
64
|
+
error=str(exc),
|
|
65
|
+
error_detail=exc.detail,
|
|
66
|
+
harness_run_id=harness_run_id,
|
|
67
|
+
harness_output_dir=harness_output_dir,
|
|
68
|
+
)
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
traceback.print_exc()
|
|
71
|
+
return IterationResult(
|
|
72
|
+
success=False, text=None, error=str(exc), harness_run_id=""
|
|
73
|
+
)
|
|
74
|
+
return _normalize_harness_result(
|
|
75
|
+
result=result, harness_output_root=harness_output_root
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _build_harness_kwargs(
|
|
80
|
+
*,
|
|
81
|
+
repo_root: Path,
|
|
82
|
+
config_snapshot: RootConfigSnapshot,
|
|
83
|
+
resolved_api_key: str | None,
|
|
84
|
+
harness_output_root: Path | None,
|
|
85
|
+
harness_factory: Callable[..., TeamHarnessLike],
|
|
86
|
+
) -> dict[str, object]:
|
|
87
|
+
kwargs: dict[str, object] = {
|
|
88
|
+
"provider": config_snapshot.team_harness_provider,
|
|
89
|
+
"model": config_snapshot.team_harness_model,
|
|
90
|
+
"api_base": normalize_api_base(value=config_snapshot.team_harness_api_base),
|
|
91
|
+
"api_key": resolved_api_key,
|
|
92
|
+
"agents": config_snapshot.team_harness_agents,
|
|
93
|
+
"system_prompt": config_snapshot.team_harness_system_prompt_extension,
|
|
94
|
+
"cwd": str(repo_root),
|
|
95
|
+
"console_mode": "silent",
|
|
96
|
+
}
|
|
97
|
+
retry_kwargs = {
|
|
98
|
+
"max_retries": config_snapshot.team_harness_max_retries,
|
|
99
|
+
"retry_base_delay_s": config_snapshot.team_harness_retry_base_delay_s,
|
|
100
|
+
"retry_max_delay_s": config_snapshot.team_harness_retry_max_delay_s,
|
|
101
|
+
}
|
|
102
|
+
configured_retry_kwargs = {
|
|
103
|
+
key: value for key, value in retry_kwargs.items() if value is not None
|
|
104
|
+
}
|
|
105
|
+
if configured_retry_kwargs:
|
|
106
|
+
if _supports_kwargs(
|
|
107
|
+
harness_factory=harness_factory, names=configured_retry_kwargs.keys()
|
|
108
|
+
):
|
|
109
|
+
kwargs.update(configured_retry_kwargs)
|
|
110
|
+
else:
|
|
111
|
+
raise ConfigError(
|
|
112
|
+
"Installed team-harness does not support coordinator retry "
|
|
113
|
+
"controls; upgrade team-harness or remove "
|
|
114
|
+
"team_harness_max_retries, team_harness_retry_base_delay_s, "
|
|
115
|
+
"and team_harness_retry_max_delay_s from loopy_loop_config.yaml."
|
|
116
|
+
)
|
|
117
|
+
agent_override_kwargs = {
|
|
118
|
+
"agent_models": config_snapshot.team_harness_agent_models,
|
|
119
|
+
"agent_reasoning_efforts": config_snapshot.team_harness_agent_reasoning_efforts,
|
|
120
|
+
}
|
|
121
|
+
if _supports_kwargs(
|
|
122
|
+
harness_factory=harness_factory, names=agent_override_kwargs.keys()
|
|
123
|
+
):
|
|
124
|
+
kwargs.update(agent_override_kwargs)
|
|
125
|
+
elif any(agent_override_kwargs.values()):
|
|
126
|
+
raise ConfigError(
|
|
127
|
+
"Installed team-harness does not support per-agent model overrides; "
|
|
128
|
+
"upgrade team-harness or remove team_harness_agent_models and "
|
|
129
|
+
"team_harness_agent_reasoning_efforts from loopy_loop_config.yaml."
|
|
130
|
+
)
|
|
131
|
+
if harness_output_root is not None:
|
|
132
|
+
if _supports_kwargs(harness_factory=harness_factory, names=["output_dir"]):
|
|
133
|
+
kwargs["output_dir"] = str(harness_output_root)
|
|
134
|
+
else:
|
|
135
|
+
raise ConfigError(
|
|
136
|
+
"Installed team-harness does not support SDK output_dir; "
|
|
137
|
+
"upgrade team-harness."
|
|
138
|
+
)
|
|
139
|
+
return kwargs
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _supports_kwargs(
|
|
143
|
+
*, harness_factory: Callable[..., TeamHarnessLike], names: Iterable[str]
|
|
144
|
+
) -> bool:
|
|
145
|
+
signature = inspect.signature(harness_factory)
|
|
146
|
+
parameters = signature.parameters.values()
|
|
147
|
+
if any(parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in parameters):
|
|
148
|
+
return True
|
|
149
|
+
return all(name in signature.parameters for name in names)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _failure_harness_paths(
|
|
153
|
+
*, detail: dict[str, object] | None, harness_output_root: Path | None
|
|
154
|
+
) -> tuple[str, str]:
|
|
155
|
+
if not detail:
|
|
156
|
+
return "", ""
|
|
157
|
+
run_id_value = detail.get("run_id")
|
|
158
|
+
output_dir_value = detail.get("session_output_dir")
|
|
159
|
+
run_id = run_id_value if isinstance(run_id_value, str) else ""
|
|
160
|
+
output_dir = output_dir_value if isinstance(output_dir_value, str) else ""
|
|
161
|
+
if not output_dir and run_id and harness_output_root is not None:
|
|
162
|
+
output_dir = str(harness_output_root / run_id)
|
|
163
|
+
return run_id, output_dir
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def write_iteration_artifacts(
|
|
167
|
+
*, iteration_dir: Path, rendered_prompt: str, iteration_result: IterationResult
|
|
168
|
+
) -> None:
|
|
169
|
+
iteration_dir.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
(iteration_dir / PROMPT_FILENAME).write_text(rendered_prompt, encoding="utf-8")
|
|
171
|
+
(iteration_dir / RESULT_TEXT_FILENAME).write_text(
|
|
172
|
+
iteration_result.text or "", encoding="utf-8"
|
|
173
|
+
)
|
|
174
|
+
(iteration_dir / HARNESS_RUN_ID_FILENAME).write_text(
|
|
175
|
+
iteration_result.harness_run_id, encoding="utf-8"
|
|
176
|
+
)
|
|
177
|
+
payload = iteration_result.model_dump()
|
|
178
|
+
(iteration_dir / RESULT_FILENAME).write_text(
|
|
179
|
+
json.dumps(payload, indent=2), encoding="utf-8"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _normalize_harness_result(
|
|
184
|
+
*, result: TeamHarnessResult, harness_output_root: Path | None = None
|
|
185
|
+
) -> IterationResult:
|
|
186
|
+
harness_output_dir = ""
|
|
187
|
+
if harness_output_root is not None and result.run_id:
|
|
188
|
+
harness_output_dir = str(harness_output_root / result.run_id)
|
|
189
|
+
return IterationResult(
|
|
190
|
+
success=True,
|
|
191
|
+
text=result.text,
|
|
192
|
+
error=None,
|
|
193
|
+
harness_run_id=result.run_id,
|
|
194
|
+
harness_output_dir=harness_output_dir,
|
|
195
|
+
)
|
loopy_loop/models.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from datetime import UTC
|
|
5
|
+
from typing import Literal
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
from pydantic import ConfigDict
|
|
10
|
+
from pydantic import Field
|
|
11
|
+
from pydantic import field_validator
|
|
12
|
+
from pydantic import model_validator
|
|
13
|
+
|
|
14
|
+
DEFAULT_LOCK_TIMEOUT_SECONDS = 30.0
|
|
15
|
+
CONTROL_SCHEMA_VERSION = 1
|
|
16
|
+
GOAL_CHECK_SCHEMA_VERSION = 1
|
|
17
|
+
RUN_ACTION = "run"
|
|
18
|
+
STOP_ACTION = "stop"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def utc_now() -> datetime:
|
|
22
|
+
return datetime.now(UTC).replace(microsecond=0)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RootConfigSnapshot(BaseModel):
|
|
26
|
+
model_config = ConfigDict(extra="forbid")
|
|
27
|
+
|
|
28
|
+
goal: str = Field(...)
|
|
29
|
+
goal_hash: str = Field(...)
|
|
30
|
+
workflow_set: str = Field(...)
|
|
31
|
+
completion_criteria: list[str] = Field(...)
|
|
32
|
+
stop_criteria: list[str] = Field(...)
|
|
33
|
+
max_turns: int = Field(...)
|
|
34
|
+
goal_check_consecutive_failures_cap: int = Field(...)
|
|
35
|
+
team_harness_provider: str = Field(...)
|
|
36
|
+
team_harness_model: str = Field(...)
|
|
37
|
+
team_harness_agents: list[str] = Field(...)
|
|
38
|
+
team_harness_agent_models: dict[str, str] = Field(default_factory=dict)
|
|
39
|
+
team_harness_agent_reasoning_efforts: dict[str, str] = Field(default_factory=dict)
|
|
40
|
+
team_harness_max_retries: int | None = Field(default=None)
|
|
41
|
+
team_harness_retry_base_delay_s: float | None = Field(default=None)
|
|
42
|
+
team_harness_retry_max_delay_s: float | None = Field(default=None)
|
|
43
|
+
team_harness_api_base: str = Field(...)
|
|
44
|
+
team_harness_api_key_env: str = Field(...)
|
|
45
|
+
team_harness_system_prompt_extension: str = Field(...)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CurrentTask(BaseModel):
|
|
49
|
+
workflow_set: str = Field(...)
|
|
50
|
+
workflow_id: str = Field(...)
|
|
51
|
+
session_id: str = Field(...)
|
|
52
|
+
iteration: int = Field(...)
|
|
53
|
+
started_at: datetime = Field(...)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TaskResponse(BaseModel):
|
|
57
|
+
action: Literal["run", "stop"] = Field(...)
|
|
58
|
+
workflow_set: str | None = Field(default=None)
|
|
59
|
+
workflow_id: str | None = Field(default=None)
|
|
60
|
+
session_id: str | None = Field(default=None)
|
|
61
|
+
iteration: int | None = Field(default=None)
|
|
62
|
+
config_snapshot: RootConfigSnapshot | None = Field(default=None)
|
|
63
|
+
stop_reason: str | None = Field(default=None)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class HistoryEntry(BaseModel):
|
|
67
|
+
iteration: int = Field(...)
|
|
68
|
+
workflow_set: str = Field(...)
|
|
69
|
+
workflow_id: str = Field(...)
|
|
70
|
+
session_id: str = Field(...)
|
|
71
|
+
success: bool = Field(...)
|
|
72
|
+
error: str | None = Field(default=None)
|
|
73
|
+
started_at: datetime = Field(...)
|
|
74
|
+
finished_at: datetime = Field(...)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class LoopState(BaseModel):
|
|
78
|
+
status: Literal["running", "stopped", "goal_met", "failed", "max_turns"] = Field(
|
|
79
|
+
default="running"
|
|
80
|
+
)
|
|
81
|
+
goal_hash: str = Field(...)
|
|
82
|
+
workflow_set: str = Field(...)
|
|
83
|
+
parent_session_id: str | None = Field(default=None)
|
|
84
|
+
max_turns: int = Field(...)
|
|
85
|
+
active_session_id: str = Field(...)
|
|
86
|
+
goal_met: bool = Field(default=False)
|
|
87
|
+
stop_requested: bool = Field(default=False)
|
|
88
|
+
unresolvable_error: bool = Field(default=False)
|
|
89
|
+
stop_reason: str | None = Field(default=None)
|
|
90
|
+
iteration_count: int = Field(default=0)
|
|
91
|
+
goal_check_consecutive_failures: int = Field(default=0)
|
|
92
|
+
current_task: CurrentTask | None = Field(default=None)
|
|
93
|
+
history: list[HistoryEntry] = Field(default_factory=list)
|
|
94
|
+
config_snapshot: RootConfigSnapshot = Field(...)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class FinishedRequest(BaseModel):
|
|
98
|
+
workflow_id: str = Field(...)
|
|
99
|
+
session_id: str = Field(...)
|
|
100
|
+
iteration: int = Field(...)
|
|
101
|
+
success: bool = Field(...)
|
|
102
|
+
text: str | None = Field(default=None)
|
|
103
|
+
error: str | None = Field(default=None)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ControlSignal(BaseModel):
|
|
107
|
+
state: Literal["running", "stopped"] = Field(...)
|
|
108
|
+
reason: str = Field(...)
|
|
109
|
+
stop_reason: Literal["goal_met", "unresolvable_error"] | None = Field(default=None)
|
|
110
|
+
schema_version: int = Field(...)
|
|
111
|
+
|
|
112
|
+
@field_validator("schema_version")
|
|
113
|
+
@classmethod
|
|
114
|
+
def validate_schema_version(cls, value: int) -> int:
|
|
115
|
+
if value != CONTROL_SCHEMA_VERSION:
|
|
116
|
+
raise ValueError(f"schema_version must equal {CONTROL_SCHEMA_VERSION}")
|
|
117
|
+
return value
|
|
118
|
+
|
|
119
|
+
@model_validator(mode="after")
|
|
120
|
+
def validate_stop_reason(self) -> Self:
|
|
121
|
+
if self.state == "running" and self.stop_reason is not None:
|
|
122
|
+
raise ValueError("running control state must not set stop_reason")
|
|
123
|
+
if self.state == "stopped" and self.stop_reason is None:
|
|
124
|
+
raise ValueError("stopped control state must set stop_reason")
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class GoalCheckSignal(BaseModel):
|
|
129
|
+
goal_met: bool = Field(...)
|
|
130
|
+
reason: str = Field(...)
|
|
131
|
+
schema_version: int = Field(default=GOAL_CHECK_SCHEMA_VERSION)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class IterationResult(BaseModel):
|
|
135
|
+
success: bool = Field(...)
|
|
136
|
+
text: str | None = Field(default=None)
|
|
137
|
+
error: str | None = Field(default=None)
|
|
138
|
+
error_detail: dict[str, object] | None = Field(default=None)
|
|
139
|
+
harness_run_id: str = Field(default="")
|
|
140
|
+
harness_output_dir: str = Field(default="")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ChildSessionRequest(BaseModel):
|
|
144
|
+
workflow_set: str = Field(...)
|
|
145
|
+
goal: str = Field(...)
|
|
146
|
+
schema_version: int = Field(default=1)
|
|
147
|
+
|
|
148
|
+
@field_validator("schema_version")
|
|
149
|
+
@classmethod
|
|
150
|
+
def validate_schema_version(cls, value: int) -> int:
|
|
151
|
+
if value != 1:
|
|
152
|
+
raise ValueError("schema_version must equal 1")
|
|
153
|
+
return value
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class ChildSessionRecord(BaseModel):
|
|
157
|
+
session_id: str = Field(...)
|
|
158
|
+
workflow_set: str = Field(...)
|
|
159
|
+
goal_hash: str = Field(...)
|
|
160
|
+
status: str = Field(...)
|
|
161
|
+
created_at: datetime = Field(...)
|
|
162
|
+
completed_at: datetime | None = Field(default=None)
|
|
163
|
+
stop_reason: str | None = Field(default=None)
|
loopy_loop/scheduler.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from loopy_loop.config import WorkflowDefinition
|
|
4
|
+
from loopy_loop.models import HistoryEntry
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def choose_next_workflow(
|
|
8
|
+
*,
|
|
9
|
+
workflows: list[WorkflowDefinition],
|
|
10
|
+
history: list[HistoryEntry],
|
|
11
|
+
iteration_count: int,
|
|
12
|
+
) -> WorkflowDefinition | None:
|
|
13
|
+
eligible: list[tuple[int, int, WorkflowDefinition]] = []
|
|
14
|
+
last_successful_workflow_id = _last_successful_workflow_id(history=history)
|
|
15
|
+
has_successful_history = any(entry.success for entry in history)
|
|
16
|
+
has_successful_non_goal_check = any(
|
|
17
|
+
entry.success and entry.workflow_id != "goal_check" for entry in history
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
for workflow in workflows:
|
|
21
|
+
if not _workflow_eligible(
|
|
22
|
+
workflow=workflow,
|
|
23
|
+
history=history,
|
|
24
|
+
iteration_count=iteration_count,
|
|
25
|
+
last_successful_workflow_id=last_successful_workflow_id,
|
|
26
|
+
has_successful_history=has_successful_history,
|
|
27
|
+
has_successful_non_goal_check=has_successful_non_goal_check,
|
|
28
|
+
ignore_run_every=False,
|
|
29
|
+
):
|
|
30
|
+
continue
|
|
31
|
+
eligible.append(
|
|
32
|
+
(
|
|
33
|
+
workflow.priority,
|
|
34
|
+
_workflow_score(
|
|
35
|
+
workflow_id=workflow.id,
|
|
36
|
+
history=history,
|
|
37
|
+
iteration_count=iteration_count,
|
|
38
|
+
run_every=workflow.run_every,
|
|
39
|
+
),
|
|
40
|
+
workflow,
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if not eligible:
|
|
45
|
+
return _failed_workflow_retry(
|
|
46
|
+
workflows=workflows,
|
|
47
|
+
history=history,
|
|
48
|
+
iteration_count=iteration_count,
|
|
49
|
+
last_successful_workflow_id=last_successful_workflow_id,
|
|
50
|
+
has_successful_history=has_successful_history,
|
|
51
|
+
has_successful_non_goal_check=has_successful_non_goal_check,
|
|
52
|
+
)
|
|
53
|
+
return max(eligible, key=lambda item: (item[0], item[1], item[2].id))[2]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _workflow_eligible(
|
|
57
|
+
*,
|
|
58
|
+
workflow: WorkflowDefinition,
|
|
59
|
+
history: list[HistoryEntry],
|
|
60
|
+
iteration_count: int,
|
|
61
|
+
last_successful_workflow_id: str | None,
|
|
62
|
+
has_successful_history: bool,
|
|
63
|
+
has_successful_non_goal_check: bool,
|
|
64
|
+
ignore_run_every: bool,
|
|
65
|
+
) -> bool:
|
|
66
|
+
if not workflow.enabled:
|
|
67
|
+
return False
|
|
68
|
+
if iteration_count < workflow.not_before_iteration:
|
|
69
|
+
return False
|
|
70
|
+
if workflow.id == "goal_check" and not has_successful_non_goal_check:
|
|
71
|
+
return False
|
|
72
|
+
if (
|
|
73
|
+
workflow.must_follow is not None
|
|
74
|
+
and workflow.must_follow != last_successful_workflow_id
|
|
75
|
+
):
|
|
76
|
+
return False
|
|
77
|
+
is_run_on_start = workflow.run_on_start and not has_successful_history
|
|
78
|
+
if (
|
|
79
|
+
workflow.run_after_successes is not None
|
|
80
|
+
and not is_run_on_start
|
|
81
|
+
and not _run_after_successes_satisfied(workflow=workflow, history=history)
|
|
82
|
+
):
|
|
83
|
+
return False
|
|
84
|
+
if ignore_run_every:
|
|
85
|
+
return True
|
|
86
|
+
return _run_every_satisfied(
|
|
87
|
+
workflow_id=workflow.id,
|
|
88
|
+
history=history,
|
|
89
|
+
iteration_count=iteration_count,
|
|
90
|
+
run_every=workflow.run_every,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _failed_workflow_retry(
|
|
95
|
+
*,
|
|
96
|
+
workflows: list[WorkflowDefinition],
|
|
97
|
+
history: list[HistoryEntry],
|
|
98
|
+
iteration_count: int,
|
|
99
|
+
last_successful_workflow_id: str | None,
|
|
100
|
+
has_successful_history: bool,
|
|
101
|
+
has_successful_non_goal_check: bool,
|
|
102
|
+
) -> WorkflowDefinition | None:
|
|
103
|
+
"""Retry the latest failed workflow only when normal scheduling is stuck.
|
|
104
|
+
|
|
105
|
+
A failed run should not unlock downstream must_follow workflows, but it also
|
|
106
|
+
should not consume its own cadence slot so completely that the loop has no
|
|
107
|
+
recoverable next workflow. This fallback preserves the normal scheduling
|
|
108
|
+
pass and bypasses only run_every for the same workflow that just failed.
|
|
109
|
+
"""
|
|
110
|
+
if not history or history[-1].success:
|
|
111
|
+
return None
|
|
112
|
+
workflow = next(
|
|
113
|
+
(
|
|
114
|
+
candidate
|
|
115
|
+
for candidate in workflows
|
|
116
|
+
if candidate.id == history[-1].workflow_id
|
|
117
|
+
),
|
|
118
|
+
None,
|
|
119
|
+
)
|
|
120
|
+
if workflow is None:
|
|
121
|
+
return None
|
|
122
|
+
if not _workflow_eligible(
|
|
123
|
+
workflow=workflow,
|
|
124
|
+
history=history,
|
|
125
|
+
iteration_count=iteration_count,
|
|
126
|
+
last_successful_workflow_id=last_successful_workflow_id,
|
|
127
|
+
has_successful_history=has_successful_history,
|
|
128
|
+
has_successful_non_goal_check=has_successful_non_goal_check,
|
|
129
|
+
ignore_run_every=True,
|
|
130
|
+
):
|
|
131
|
+
return None
|
|
132
|
+
return workflow
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _last_successful_workflow_id(*, history: list[HistoryEntry]) -> str | None:
|
|
136
|
+
for entry in reversed(history):
|
|
137
|
+
if entry.success:
|
|
138
|
+
return entry.workflow_id
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _workflow_score(
|
|
143
|
+
*,
|
|
144
|
+
workflow_id: str,
|
|
145
|
+
history: list[HistoryEntry],
|
|
146
|
+
iteration_count: int,
|
|
147
|
+
run_every: int,
|
|
148
|
+
) -> int:
|
|
149
|
+
last_entry = _last_entry_for_workflow(workflow_id=workflow_id, history=history)
|
|
150
|
+
if last_entry is None:
|
|
151
|
+
return 10**9
|
|
152
|
+
return (
|
|
153
|
+
_steps_since_last_run(
|
|
154
|
+
workflow_id=workflow_id, history=history, iteration_count=iteration_count
|
|
155
|
+
)
|
|
156
|
+
- run_every
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _run_every_satisfied(
|
|
161
|
+
*,
|
|
162
|
+
workflow_id: str,
|
|
163
|
+
history: list[HistoryEntry],
|
|
164
|
+
iteration_count: int,
|
|
165
|
+
run_every: int,
|
|
166
|
+
) -> bool:
|
|
167
|
+
last_entry = _last_entry_for_workflow(workflow_id=workflow_id, history=history)
|
|
168
|
+
if last_entry is None:
|
|
169
|
+
return True
|
|
170
|
+
return (
|
|
171
|
+
_steps_since_last_run(
|
|
172
|
+
workflow_id=workflow_id, history=history, iteration_count=iteration_count
|
|
173
|
+
)
|
|
174
|
+
>= run_every
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _run_after_successes_satisfied(
|
|
179
|
+
*, workflow: WorkflowDefinition, history: list[HistoryEntry]
|
|
180
|
+
) -> bool:
|
|
181
|
+
rule = workflow.run_after_successes
|
|
182
|
+
if rule is None:
|
|
183
|
+
return True
|
|
184
|
+
target_success_count = _successful_workflow_count(
|
|
185
|
+
workflow_id=rule.workflow_id, history=history
|
|
186
|
+
)
|
|
187
|
+
previous_target_success_count = _successful_target_count_at_last_candidate_success(
|
|
188
|
+
candidate_workflow_id=workflow.id,
|
|
189
|
+
target_workflow_id=rule.workflow_id,
|
|
190
|
+
history=history,
|
|
191
|
+
)
|
|
192
|
+
return target_success_count - previous_target_success_count >= rule.every
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _successful_workflow_count(*, workflow_id: str, history: list[HistoryEntry]) -> int:
|
|
196
|
+
return sum(
|
|
197
|
+
1 for entry in history if entry.success and entry.workflow_id == workflow_id
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _successful_target_count_at_last_candidate_success(
|
|
202
|
+
*, candidate_workflow_id: str, target_workflow_id: str, history: list[HistoryEntry]
|
|
203
|
+
) -> int:
|
|
204
|
+
last_candidate_index: int | None = None
|
|
205
|
+
for index, entry in enumerate(history):
|
|
206
|
+
if entry.success and entry.workflow_id == candidate_workflow_id:
|
|
207
|
+
last_candidate_index = index
|
|
208
|
+
if last_candidate_index is None:
|
|
209
|
+
return 0
|
|
210
|
+
return sum(
|
|
211
|
+
1
|
|
212
|
+
for entry in history[: last_candidate_index + 1]
|
|
213
|
+
if entry.success and entry.workflow_id == target_workflow_id
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _steps_since_last_run(
|
|
218
|
+
*, workflow_id: str, history: list[HistoryEntry], iteration_count: int
|
|
219
|
+
) -> int:
|
|
220
|
+
last_entry = _last_entry_for_workflow(workflow_id=workflow_id, history=history)
|
|
221
|
+
if last_entry is None:
|
|
222
|
+
return iteration_count
|
|
223
|
+
return iteration_count - last_entry.iteration
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _last_entry_for_workflow(
|
|
227
|
+
*, workflow_id: str, history: list[HistoryEntry]
|
|
228
|
+
) -> HistoryEntry | None:
|
|
229
|
+
for entry in reversed(history):
|
|
230
|
+
if entry.workflow_id == workflow_id:
|
|
231
|
+
return entry
|
|
232
|
+
return None
|