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,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)
@@ -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