ww-agentic-workflows 1.0.0.dev3__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.
- ww/__init__.py +18 -0
- ww/_bundled_extensions/ww/git/extension.py +1728 -0
- ww/action_execution.py +887 -0
- ww/actions/__init__.py +94 -0
- ww/actions/command.py +444 -0
- ww/actions/contracts.py +699 -0
- ww/actions/extension.py +197 -0
- ww/actions/mcp.py +84 -0
- ww/actions/prompt.py +74 -0
- ww/actions/skill.py +62 -0
- ww/actions/slash_command.py +63 -0
- ww/agents.py +151 -0
- ww/amendments.py +54 -0
- ww/artifacts.py +93 -0
- ww/assessments.py +181 -0
- ww/assets/__init__.py +2 -0
- ww/assets/agent_instructions.md +49 -0
- ww/assets/docs/examples.md +879 -0
- ww/assets/docs/features.md +4639 -0
- ww/assets/docs/specification.md +1876 -0
- ww/assets/noww_skill.md +11 -0
- ww/assets/workflows/catchall.yaml +26 -0
- ww/assets/workflows/onboarding.yaml +586 -0
- ww/assets/workflows/scriptize.yaml +130 -0
- ww/assets/ww-automate_skill.md +23 -0
- ww/assets/ww-deduce-feedback_skill.md +38 -0
- ww/assets/ww-feedback-rules_skill.md +48 -0
- ww/assets/ww-learn-project_skill.md +22 -0
- ww/assets/ww-refresh_skill.md +26 -0
- ww/assets/ww-rule_skill.md +83 -0
- ww/assets/ww-rules-from-artifacts_skill.md +22 -0
- ww/assets/ww-scriptize_skill.md +33 -0
- ww/assets/ww-setup_skill.md +94 -0
- ww/assets/ww-solve_skill.md +23 -0
- ww/assets/ww-suggest_skill.md +32 -0
- ww/assets/ww-wizard_skill.md +105 -0
- ww/assets/ww_skill.md +59 -0
- ww/assignments.py +283 -0
- ww/bootstrap.py +405 -0
- ww/builtin_workflows.py +215 -0
- ww/changes.py +225 -0
- ww/child_coordination.py +482 -0
- ww/children.py +106 -0
- ww/claude_permissions.py +115 -0
- ww/cli/__init__.py +7 -0
- ww/cli/__main__.py +6 -0
- ww/cli/audit.py +129 -0
- ww/cli/catalogs.py +131 -0
- ww/cli/discover.py +607 -0
- ww/cli/initialization.py +898 -0
- ww/cli/lookup.py +287 -0
- ww/cli/main.py +1768 -0
- ww/cli/parser.py +1200 -0
- ww/cli/prompts.py +217 -0
- ww/cli/updates.py +117 -0
- ww/completion_artifacts.py +156 -0
- ww/completion_inputs.py +39 -0
- ww/config/__init__.py +582 -0
- ww/config/actions.py +591 -0
- ww/config/composition.py +571 -0
- ww/config/rules.py +511 -0
- ww/config/steps.py +1220 -0
- ww/config/values.py +223 -0
- ww/config_files.py +191 -0
- ww/config_writes.py +264 -0
- ww/contracts.py +155 -0
- ww/control.py +41 -0
- ww/defaults.py +130 -0
- ww/design_docs.py +32 -0
- ww/discovery.py +104 -0
- ww/documents.py +217 -0
- ww/errors.py +18 -0
- ww/executable.py +43 -0
- ww/execution_models/__init__.py +64 -0
- ww/execution_models/construction.py +148 -0
- ww/execution_models/decoding.py +38 -0
- ww/execution_models/plan_codec.py +565 -0
- ww/execution_models/records.py +1206 -0
- ww/execution_models/runs.py +266 -0
- ww/extensions/__init__.py +40 -0
- ww/extensions/api.py +559 -0
- ww/extensions/registry.py +864 -0
- ww/extensions/store.py +78 -0
- ww/feedback.py +342 -0
- ww/handler_repairs.py +57 -0
- ww/hooks/__init__.py +40 -0
- ww/hooks/agents.py +380 -0
- ww/hooks/install.py +168 -0
- ww/hooks/notices.py +206 -0
- ww/hooks/records.py +209 -0
- ww/hooks/runtime.py +266 -0
- ww/hooks/transcripts.py +183 -0
- ww/inspect.py +896 -0
- ww/instructions/__init__.py +17 -0
- ww/instructions/builder.py +1682 -0
- ww/instructions/commands.py +335 -0
- ww/instructions/handoff.py +149 -0
- ww/instructions/models.py +686 -0
- ww/instructions/policy.py +219 -0
- ww/instructions/text.py +168 -0
- ww/interactions.py +187 -0
- ww/interpolation.py +37 -0
- ww/item_passes.py +167 -0
- ww/items.py +99 -0
- ww/locking.py +207 -0
- ww/metadata_publication.py +230 -0
- ww/onboarding.py +229 -0
- ww/open_work.py +236 -0
- ww/operations.py +193 -0
- ww/operator_ui/__init__.py +16 -0
- ww/operator_ui/page.html +351 -0
- ww/operator_ui/server.py +215 -0
- ww/operator_ui/session.py +389 -0
- ww/operator_ui/sheet.py +104 -0
- ww/operator_ui/view.py +109 -0
- ww/output.py +339 -0
- ww/output_adapters/__init__.py +12 -0
- ww/output_adapters/base.py +25 -0
- ww/output_adapters/json_adapter.py +37 -0
- ww/output_adapters/markdown.py +2293 -0
- ww/output_adapters/rule_pages.py +337 -0
- ww/output_adapters/terminal.py +21 -0
- ww/package_updates.py +167 -0
- ww/plan/__init__.py +38 -0
- ww/plan/actions.py +207 -0
- ww/plan/compiler.py +1492 -0
- ww/plan/constructs.py +456 -0
- ww/plan/models.py +665 -0
- ww/project_config.py +752 -0
- ww/recovery.py +401 -0
- ww/replanning.py +367 -0
- ww/results.py +77 -0
- ww/rule_checks.py +230 -0
- ww/rule_conversion.py +331 -0
- ww/rule_disputes.py +148 -0
- ww/rule_store.py +456 -0
- ww/rule_verification.py +714 -0
- ww/rule_views.py +447 -0
- ww/rule_writes.py +920 -0
- ww/run_coordination.py +158 -0
- ww/runtimes.py +105 -0
- ww/service.py +4405 -0
- ww/setup_apply.py +428 -0
- ww/step_values.py +20 -0
- ww/storage.py +447 -0
- ww/storage_adapters/__init__.py +36 -0
- ww/storage_adapters/base.py +540 -0
- ww/storage_adapters/filesystem.py +370 -0
- ww/storage_adapters/memory.py +195 -0
- ww/storage_adapters/project_metadata.py +69 -0
- ww/storage_adapters/task_document.py +484 -0
- ww/task_ids.py +114 -0
- ww/task_references.py +124 -0
- ww/transitions.py +1619 -0
- ww/updates.py +399 -0
- ww/upgrade.py +95 -0
- ww/validation.py +168 -0
- ww/variables.py +275 -0
- ww/workflow_config.py +854 -0
- ww/workflow_update.py +239 -0
- ww/workflow_validation.py +1260 -0
- ww/workspace.py +50 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
ww/transitions.py
ADDED
|
@@ -0,0 +1,1619 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Pure, named state transitions for workflow execution.
|
|
3
|
+
|
|
4
|
+
The lifecycle service decides when a transition is allowed and when it is
|
|
5
|
+
committed. This module owns the corresponding immutable record changes so the
|
|
6
|
+
item, run, and step-projection invariants are changed together.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import replace
|
|
14
|
+
|
|
15
|
+
from ww.assessments import outcome_region, pending_assessment
|
|
16
|
+
from ww.children import ChildTask
|
|
17
|
+
from ww.contracts import StepStatus
|
|
18
|
+
from ww.control import loop_control
|
|
19
|
+
from ww.errors import StateError
|
|
20
|
+
from ww.execution_models import (
|
|
21
|
+
CheckReport,
|
|
22
|
+
CommandExecution,
|
|
23
|
+
Dispute,
|
|
24
|
+
ExecutionState,
|
|
25
|
+
InputRequest,
|
|
26
|
+
PlanItemExecution,
|
|
27
|
+
PlanSnapshot,
|
|
28
|
+
StepProgress,
|
|
29
|
+
build_step_projection,
|
|
30
|
+
new_item_execution,
|
|
31
|
+
operation_scope_for,
|
|
32
|
+
)
|
|
33
|
+
from ww.execution_models.records import RuleResolution
|
|
34
|
+
from ww.items import WorkItem
|
|
35
|
+
from ww.plan import (
|
|
36
|
+
LoopBoundary,
|
|
37
|
+
PlanItem,
|
|
38
|
+
PlannedCheck,
|
|
39
|
+
WorkflowPlan,
|
|
40
|
+
number_step_paths,
|
|
41
|
+
)
|
|
42
|
+
from ww.workflow_config import ProvidedVariable
|
|
43
|
+
|
|
44
|
+
Clock = Callable[[], str]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def retry_failed_item(
|
|
48
|
+
state: ExecutionState, plan: WorkflowPlan, now: Clock
|
|
49
|
+
) -> ExecutionState:
|
|
50
|
+
"""Return the current failed item to pending for another attempt."""
|
|
51
|
+
records = list(state.item_executions)
|
|
52
|
+
values = dict(state.workflow_values)
|
|
53
|
+
if state.cursor < len(records):
|
|
54
|
+
record = records[state.cursor]
|
|
55
|
+
item = plan.items[state.cursor]
|
|
56
|
+
supplied = record.supplied_values
|
|
57
|
+
if item.owner == "ww" and item.execution == "automatic" and item.provide:
|
|
58
|
+
# The handler failed with the values it was given; ask for them
|
|
59
|
+
# again rather than replaying them. They stay on the record so
|
|
60
|
+
# the request can show what was supplied last time.
|
|
61
|
+
previous = tuple(
|
|
62
|
+
(value.name, values.pop(value.name))
|
|
63
|
+
for value in item.provide
|
|
64
|
+
if value.name in values
|
|
65
|
+
)
|
|
66
|
+
supplied = previous or supplied
|
|
67
|
+
# Commands are replaced in-place when a failed automatic item is
|
|
68
|
+
# retried. Preserve the prior attempt so its stream references remain
|
|
69
|
+
# discoverable through the public artifact listing.
|
|
70
|
+
history = (*state.execution_history, record)
|
|
71
|
+
records[state.cursor] = replace(
|
|
72
|
+
record,
|
|
73
|
+
status="pending",
|
|
74
|
+
error=None,
|
|
75
|
+
repair_pending=False,
|
|
76
|
+
retry_errors=(),
|
|
77
|
+
repair_failures=0
|
|
78
|
+
if state.failure_kind == "fix_limit"
|
|
79
|
+
else record.repair_failures,
|
|
80
|
+
supplied_values=supplied,
|
|
81
|
+
# A retry after the fix limit gives the worker a fresh count; the
|
|
82
|
+
# rejected attempts stay in the history record. A retry after a
|
|
83
|
+
# dispute keeps the count: the check stands.
|
|
84
|
+
check_reports=(
|
|
85
|
+
() if state.failure_kind == "fix_limit" else record.check_reports
|
|
86
|
+
),
|
|
87
|
+
dispute=None,
|
|
88
|
+
)
|
|
89
|
+
else:
|
|
90
|
+
history = state.execution_history
|
|
91
|
+
return project_steps(
|
|
92
|
+
replace(
|
|
93
|
+
state,
|
|
94
|
+
status="pending",
|
|
95
|
+
active_item_id=None,
|
|
96
|
+
item_executions=tuple(records),
|
|
97
|
+
execution_history=history,
|
|
98
|
+
workflow_values=tuple(values.items()),
|
|
99
|
+
last_error=None,
|
|
100
|
+
failure_kind=None,
|
|
101
|
+
updated_at=now(),
|
|
102
|
+
),
|
|
103
|
+
plan,
|
|
104
|
+
now,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def waive_checks(
|
|
109
|
+
state: ExecutionState,
|
|
110
|
+
plan: WorkflowPlan,
|
|
111
|
+
waived: tuple[str, ...],
|
|
112
|
+
reason: str,
|
|
113
|
+
now: Clock,
|
|
114
|
+
) -> ExecutionState:
|
|
115
|
+
"""Return a stopped step to its worker without the ``waived`` checks.
|
|
116
|
+
|
|
117
|
+
At the fix limit every check and rule of the step is waived, after a
|
|
118
|
+
dispute the one it named. The next completion skips them, their rules
|
|
119
|
+
are not verified, and its artifact records the waiver with its reason;
|
|
120
|
+
nothing else about the step changes.
|
|
121
|
+
"""
|
|
122
|
+
records = list(state.item_executions)
|
|
123
|
+
record = records[state.cursor]
|
|
124
|
+
waivers = dict(record.checks_waived)
|
|
125
|
+
waivers.update(dict.fromkeys(waived, reason))
|
|
126
|
+
records[state.cursor] = replace(
|
|
127
|
+
record,
|
|
128
|
+
status="pending",
|
|
129
|
+
error=None,
|
|
130
|
+
checks_waived=tuple(waivers.items()),
|
|
131
|
+
dispute=None,
|
|
132
|
+
)
|
|
133
|
+
return project_steps(
|
|
134
|
+
replace(
|
|
135
|
+
state,
|
|
136
|
+
status="pending",
|
|
137
|
+
active_item_id=None,
|
|
138
|
+
item_executions=tuple(records),
|
|
139
|
+
last_error=None,
|
|
140
|
+
failure_kind=None,
|
|
141
|
+
updated_at=now(),
|
|
142
|
+
),
|
|
143
|
+
plan,
|
|
144
|
+
now,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def fix_limits(item: PlanItem, record: PlanItemExecution) -> dict[str, int]:
|
|
149
|
+
"""How often each check of an agent item may fail before the operator decides.
|
|
150
|
+
|
|
151
|
+
A verifier's verdict on a rule counts under the rule's ID, a derived check
|
|
152
|
+
under its name.
|
|
153
|
+
"""
|
|
154
|
+
limits = {rule.id: rule.max_fixes for rule in item.rules}
|
|
155
|
+
limits.update(
|
|
156
|
+
{check.id: check.max_fixes for check in (*item.checks, *record.resolved_checks)}
|
|
157
|
+
)
|
|
158
|
+
return limits
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def reject_completion(
|
|
162
|
+
state: ExecutionState,
|
|
163
|
+
plan: WorkflowPlan,
|
|
164
|
+
item: PlanItem,
|
|
165
|
+
report: CheckReport,
|
|
166
|
+
artifact: str | None,
|
|
167
|
+
now: Clock,
|
|
168
|
+
*,
|
|
169
|
+
keep_active: bool = True,
|
|
170
|
+
) -> ExecutionState:
|
|
171
|
+
"""Record a completion ww refused because checks failed.
|
|
172
|
+
|
|
173
|
+
The step stays in progress for its worker to fix, with the supplied
|
|
174
|
+
artifact kept as the draft to revise. A check that has now failed as many
|
|
175
|
+
times as its ``max_fixes`` allows stops the run for the operator instead.
|
|
176
|
+
``keep_active`` is false when someone other than the step's worker
|
|
177
|
+
learned of the failure, a verifier or the operator's approval: the step
|
|
178
|
+
then waits to be handed back to a worker.
|
|
179
|
+
"""
|
|
180
|
+
records = list(state.item_executions)
|
|
181
|
+
record = replace(
|
|
182
|
+
records[state.cursor],
|
|
183
|
+
check_reports=(*records[state.cursor].check_reports, report),
|
|
184
|
+
draft_artifact=artifact,
|
|
185
|
+
held_completion=None,
|
|
186
|
+
)
|
|
187
|
+
limits = fix_limits(item, record)
|
|
188
|
+
exhausted = [
|
|
189
|
+
result.id
|
|
190
|
+
for result in report.failed
|
|
191
|
+
if record.check_failures(result.id) >= limits.get(result.id, 1)
|
|
192
|
+
]
|
|
193
|
+
if not exhausted and keep_active:
|
|
194
|
+
records[state.cursor] = record
|
|
195
|
+
return replace(state, item_executions=tuple(records), updated_at=now())
|
|
196
|
+
if not exhausted:
|
|
197
|
+
records[state.cursor] = replace(record, status="pending")
|
|
198
|
+
return project_steps(
|
|
199
|
+
replace(
|
|
200
|
+
state,
|
|
201
|
+
status="pending",
|
|
202
|
+
active_item_id=None,
|
|
203
|
+
item_executions=tuple(records),
|
|
204
|
+
assignment_item_id=None,
|
|
205
|
+
assignment_token=None,
|
|
206
|
+
assignment_model=None,
|
|
207
|
+
assignment_reasoning=None,
|
|
208
|
+
assignment_selected_agent=None,
|
|
209
|
+
assignment_selected_model=None,
|
|
210
|
+
assignment_selected_reasoning=None,
|
|
211
|
+
updated_at=now(),
|
|
212
|
+
),
|
|
213
|
+
plan,
|
|
214
|
+
now,
|
|
215
|
+
)
|
|
216
|
+
message = "check limit reached: " + ", ".join(exhausted)
|
|
217
|
+
records[state.cursor] = replace(record, status="failed", error=message)
|
|
218
|
+
return project_steps(
|
|
219
|
+
replace(
|
|
220
|
+
state,
|
|
221
|
+
status="failed",
|
|
222
|
+
active_item_id=item.id,
|
|
223
|
+
item_executions=tuple(records),
|
|
224
|
+
last_error=message,
|
|
225
|
+
failure_kind="fix_limit",
|
|
226
|
+
updated_at=now(),
|
|
227
|
+
),
|
|
228
|
+
plan,
|
|
229
|
+
now,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def dispute_check(
|
|
234
|
+
state: ExecutionState, plan: WorkflowPlan, dispute: Dispute, now: Clock
|
|
235
|
+
) -> ExecutionState:
|
|
236
|
+
"""Stop the run for the operator: the step's worker disputes a check.
|
|
237
|
+
|
|
238
|
+
Nothing about the rejections changes; the operator either lets the check
|
|
239
|
+
stand (``next --retry``) or waives it for this step (``next --force``).
|
|
240
|
+
"""
|
|
241
|
+
message = f"check disputed: {dispute.check}"
|
|
242
|
+
records = list(state.item_executions)
|
|
243
|
+
records[state.cursor] = replace(
|
|
244
|
+
records[state.cursor], status="failed", error=message, dispute=dispute
|
|
245
|
+
)
|
|
246
|
+
return project_steps(
|
|
247
|
+
replace(
|
|
248
|
+
state,
|
|
249
|
+
status="failed",
|
|
250
|
+
item_executions=tuple(records),
|
|
251
|
+
last_error=message,
|
|
252
|
+
failure_kind="check_disputed",
|
|
253
|
+
updated_at=now(),
|
|
254
|
+
),
|
|
255
|
+
plan,
|
|
256
|
+
now,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def stop_for_values(
|
|
261
|
+
state: ExecutionState,
|
|
262
|
+
plan: WorkflowPlan,
|
|
263
|
+
item: PlanItem,
|
|
264
|
+
message: str,
|
|
265
|
+
now: Clock,
|
|
266
|
+
) -> ExecutionState:
|
|
267
|
+
"""Stop the run for the operator: a ``ww.`` value the step reads is missing.
|
|
268
|
+
|
|
269
|
+
The step does not start. ``next --retry`` checks the values again, for
|
|
270
|
+
example after the operator created the branch they come from; ``next
|
|
271
|
+
--force`` skips the step.
|
|
272
|
+
"""
|
|
273
|
+
records = list(state.item_executions)
|
|
274
|
+
records[state.cursor] = replace(
|
|
275
|
+
records[state.cursor], status="failed", error=message
|
|
276
|
+
)
|
|
277
|
+
return project_steps(
|
|
278
|
+
replace(
|
|
279
|
+
state,
|
|
280
|
+
status="failed",
|
|
281
|
+
active_item_id=item.id,
|
|
282
|
+
item_executions=tuple(records),
|
|
283
|
+
last_error=message,
|
|
284
|
+
failure_kind="value_unavailable",
|
|
285
|
+
updated_at=now(),
|
|
286
|
+
),
|
|
287
|
+
plan,
|
|
288
|
+
now,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def skip_failed_item(
|
|
293
|
+
state: ExecutionState, plan: WorkflowPlan, now: Clock, reason: str | None = None
|
|
294
|
+
) -> ExecutionState:
|
|
295
|
+
"""Advance past the current failed item after an explicit force request."""
|
|
296
|
+
records = list(state.item_executions)
|
|
297
|
+
if state.cursor < len(records) and reason:
|
|
298
|
+
prior_error = records[state.cursor].error
|
|
299
|
+
records[state.cursor] = replace(
|
|
300
|
+
records[state.cursor],
|
|
301
|
+
error=f"{prior_error or 'operator force-skipped'}\nForce reason: {reason}",
|
|
302
|
+
)
|
|
303
|
+
return project_steps(
|
|
304
|
+
replace(
|
|
305
|
+
state,
|
|
306
|
+
status="pending",
|
|
307
|
+
active_item_id=None,
|
|
308
|
+
cursor=state.cursor + 1,
|
|
309
|
+
item_executions=tuple(records),
|
|
310
|
+
last_error=None,
|
|
311
|
+
failure_kind=None,
|
|
312
|
+
updated_at=now(),
|
|
313
|
+
),
|
|
314
|
+
plan,
|
|
315
|
+
now,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def begin_agent_item(
|
|
320
|
+
state: ExecutionState,
|
|
321
|
+
plan: WorkflowPlan,
|
|
322
|
+
item: PlanItem,
|
|
323
|
+
*,
|
|
324
|
+
model: str,
|
|
325
|
+
reasoning: str,
|
|
326
|
+
selected_agent: str | None = None,
|
|
327
|
+
selected_model: str | None = None,
|
|
328
|
+
selected_reasoning: str | None = None,
|
|
329
|
+
change_mark: str | None = None,
|
|
330
|
+
resolution: tuple[tuple[RuleResolution, ...], tuple[PlannedCheck, ...]]
|
|
331
|
+
| None = None,
|
|
332
|
+
now: Clock,
|
|
333
|
+
) -> ExecutionState:
|
|
334
|
+
"""Mark one agent-owned item and its run as in progress.
|
|
335
|
+
|
|
336
|
+
``change_mark`` is the tree the step's change set starts from, and
|
|
337
|
+
``resolution`` how its rules without a command are enforced; a step that
|
|
338
|
+
began before keeps what it began with.
|
|
339
|
+
"""
|
|
340
|
+
records = list(state.item_executions)
|
|
341
|
+
record = records[state.cursor]
|
|
342
|
+
if resolution is not None and not record.rule_resolutions:
|
|
343
|
+
records[state.cursor] = replace(
|
|
344
|
+
record,
|
|
345
|
+
status="in_progress",
|
|
346
|
+
started_at=record.started_at or now(),
|
|
347
|
+
attempts=record.attempts + 1,
|
|
348
|
+
model=model,
|
|
349
|
+
reasoning=reasoning,
|
|
350
|
+
selected_agent=selected_agent,
|
|
351
|
+
selected_model=selected_model,
|
|
352
|
+
selected_reasoning=selected_reasoning,
|
|
353
|
+
change_mark=record.change_mark or change_mark,
|
|
354
|
+
rule_resolutions=resolution[0],
|
|
355
|
+
resolved_checks=resolution[1],
|
|
356
|
+
)
|
|
357
|
+
else:
|
|
358
|
+
records[state.cursor] = replace(
|
|
359
|
+
record,
|
|
360
|
+
status="in_progress",
|
|
361
|
+
started_at=record.started_at or now(),
|
|
362
|
+
attempts=record.attempts + 1,
|
|
363
|
+
model=model,
|
|
364
|
+
reasoning=reasoning,
|
|
365
|
+
selected_agent=selected_agent,
|
|
366
|
+
selected_model=selected_model,
|
|
367
|
+
selected_reasoning=selected_reasoning,
|
|
368
|
+
change_mark=record.change_mark or change_mark,
|
|
369
|
+
)
|
|
370
|
+
return project_steps(
|
|
371
|
+
replace(
|
|
372
|
+
state,
|
|
373
|
+
status="in_progress",
|
|
374
|
+
active_item_id=item.id,
|
|
375
|
+
item_executions=tuple(records),
|
|
376
|
+
updated_at=now(),
|
|
377
|
+
),
|
|
378
|
+
plan,
|
|
379
|
+
now,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def interrupt_automatic_item(
|
|
384
|
+
state: ExecutionState, plan: WorkflowPlan, item: PlanItem, now: Clock
|
|
385
|
+
) -> ExecutionState:
|
|
386
|
+
"""Record an automatic action whose externally visible outcome is unknown."""
|
|
387
|
+
message = f"automatic handler {item.name!r} was interrupted; its outcome is unknown"
|
|
388
|
+
records = list(state.item_executions)
|
|
389
|
+
record = records[state.cursor]
|
|
390
|
+
records[state.cursor] = replace(
|
|
391
|
+
record,
|
|
392
|
+
status="interrupted",
|
|
393
|
+
commands=tuple(
|
|
394
|
+
replace(command, status="interrupted")
|
|
395
|
+
if command.status == "in_progress"
|
|
396
|
+
else command
|
|
397
|
+
for command in record.commands
|
|
398
|
+
),
|
|
399
|
+
error=message,
|
|
400
|
+
)
|
|
401
|
+
return project_steps(
|
|
402
|
+
replace(
|
|
403
|
+
state,
|
|
404
|
+
status="interrupted",
|
|
405
|
+
active_item_id=item.id,
|
|
406
|
+
item_executions=tuple(records),
|
|
407
|
+
last_error=message,
|
|
408
|
+
updated_at=now(),
|
|
409
|
+
),
|
|
410
|
+
plan,
|
|
411
|
+
now,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def settle_stale_automatic_item(
|
|
416
|
+
state: ExecutionState, plan: WorkflowPlan, item: PlanItem, now: Clock
|
|
417
|
+
) -> ExecutionState:
|
|
418
|
+
"""Classify an automatic item whose process died before it recorded an end.
|
|
419
|
+
|
|
420
|
+
A command segment that already recorded its non-zero exit is a known
|
|
421
|
+
failure: the external process finished, only ww's bookkeeping was cut
|
|
422
|
+
short. Any other stale record still has an operation unaccounted for and
|
|
423
|
+
stays an unknown outcome.
|
|
424
|
+
"""
|
|
425
|
+
commands = state.item_executions[state.cursor].commands
|
|
426
|
+
if any(command.status == "in_progress" for command in commands):
|
|
427
|
+
return interrupt_automatic_item(state, plan, item, now)
|
|
428
|
+
failed = next((command for command in commands if command.status == "failed"), None)
|
|
429
|
+
if failed is None:
|
|
430
|
+
return interrupt_automatic_item(state, plan, item, now)
|
|
431
|
+
return _fail_stale_automatic_item(state, plan, item, failed, now)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _fail_stale_automatic_item(
|
|
435
|
+
state: ExecutionState,
|
|
436
|
+
plan: WorkflowPlan,
|
|
437
|
+
item: PlanItem,
|
|
438
|
+
command: CommandExecution,
|
|
439
|
+
now: Clock,
|
|
440
|
+
) -> ExecutionState:
|
|
441
|
+
exit_code = f" ({command.exit_code})" if command.exit_code is not None else ""
|
|
442
|
+
# ``CommandExecution.index`` is the segment's one-based declaration ordinal.
|
|
443
|
+
message = (
|
|
444
|
+
f"automatic handler {item.name!r} failed{exit_code} at command "
|
|
445
|
+
f"{command.index}; ww was interrupted before it recorded the failure"
|
|
446
|
+
)
|
|
447
|
+
detail = command.stderr.strip() or command.stdout.strip()
|
|
448
|
+
if detail:
|
|
449
|
+
message = f"{message}\n\n{detail}"
|
|
450
|
+
records = list(state.item_executions)
|
|
451
|
+
records[state.cursor] = replace(
|
|
452
|
+
records[state.cursor], status="failed", error=message
|
|
453
|
+
)
|
|
454
|
+
return project_steps(
|
|
455
|
+
replace(
|
|
456
|
+
state,
|
|
457
|
+
status="failed",
|
|
458
|
+
active_item_id=item.id,
|
|
459
|
+
item_executions=tuple(records),
|
|
460
|
+
last_error=message,
|
|
461
|
+
updated_at=now(),
|
|
462
|
+
),
|
|
463
|
+
plan,
|
|
464
|
+
now,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def resume_interrupted_item(
|
|
469
|
+
state: ExecutionState,
|
|
470
|
+
plan: WorkflowPlan,
|
|
471
|
+
item: PlanItem,
|
|
472
|
+
*,
|
|
473
|
+
now: Clock,
|
|
474
|
+
) -> ExecutionState:
|
|
475
|
+
"""Make an interrupted action runnable after its outcome is resolved."""
|
|
476
|
+
records = list(state.item_executions)
|
|
477
|
+
record = records[state.cursor]
|
|
478
|
+
records[state.cursor] = replace(
|
|
479
|
+
record,
|
|
480
|
+
status="pending",
|
|
481
|
+
error=None,
|
|
482
|
+
commands=tuple(
|
|
483
|
+
replace(command, status="pending")
|
|
484
|
+
if command.status == "interrupted"
|
|
485
|
+
else command
|
|
486
|
+
for command in record.commands
|
|
487
|
+
),
|
|
488
|
+
)
|
|
489
|
+
return project_steps(
|
|
490
|
+
replace(
|
|
491
|
+
state,
|
|
492
|
+
status="pending",
|
|
493
|
+
active_item_id=item.id,
|
|
494
|
+
item_executions=tuple(records),
|
|
495
|
+
last_error=None,
|
|
496
|
+
updated_at=now(),
|
|
497
|
+
),
|
|
498
|
+
plan,
|
|
499
|
+
now,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def record_recovery_uncertainty(
|
|
504
|
+
state: ExecutionState, error: str, now: Clock
|
|
505
|
+
) -> ExecutionState:
|
|
506
|
+
"""Keep an interrupted item intact while exposing a checker diagnostic."""
|
|
507
|
+
return replace(state, last_error=error, updated_at=now())
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def attest_cli_command(
|
|
511
|
+
state: ExecutionState,
|
|
512
|
+
plan: WorkflowPlan,
|
|
513
|
+
item: PlanItem,
|
|
514
|
+
command_index: int,
|
|
515
|
+
output: str,
|
|
516
|
+
output_reference: str | None,
|
|
517
|
+
now: Clock,
|
|
518
|
+
) -> ExecutionState:
|
|
519
|
+
"""Record operator attestation for one interrupted CLI command segment."""
|
|
520
|
+
records = list(state.item_executions)
|
|
521
|
+
record = records[state.cursor]
|
|
522
|
+
commands = list(record.commands)
|
|
523
|
+
commands[command_index] = replace(
|
|
524
|
+
commands[command_index],
|
|
525
|
+
status="completed",
|
|
526
|
+
completed_at=now(),
|
|
527
|
+
exit_code=0,
|
|
528
|
+
stdout=output,
|
|
529
|
+
stdout_ref=output_reference,
|
|
530
|
+
)
|
|
531
|
+
records[state.cursor] = replace(
|
|
532
|
+
record, status="pending", error=None, commands=tuple(commands)
|
|
533
|
+
)
|
|
534
|
+
return project_steps(
|
|
535
|
+
replace(
|
|
536
|
+
state,
|
|
537
|
+
status="pending",
|
|
538
|
+
active_item_id=item.id,
|
|
539
|
+
item_executions=tuple(records),
|
|
540
|
+
last_error=None,
|
|
541
|
+
updated_at=now(),
|
|
542
|
+
),
|
|
543
|
+
plan,
|
|
544
|
+
now,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def attest_extension_item(
|
|
549
|
+
state: ExecutionState,
|
|
550
|
+
plan: WorkflowPlan,
|
|
551
|
+
values: dict[str, str],
|
|
552
|
+
result: str,
|
|
553
|
+
working_directory: str | None,
|
|
554
|
+
now: Clock,
|
|
555
|
+
) -> ExecutionState:
|
|
556
|
+
"""Complete an interrupted extension from an operator/checker attestation."""
|
|
557
|
+
records = list(state.item_executions)
|
|
558
|
+
records[state.cursor] = replace(
|
|
559
|
+
records[state.cursor],
|
|
560
|
+
status="completed",
|
|
561
|
+
completed_at=now(),
|
|
562
|
+
error=None,
|
|
563
|
+
output_values=tuple(values.items()),
|
|
564
|
+
result=result,
|
|
565
|
+
)
|
|
566
|
+
workflow_values = {**dict(state.workflow_values), **values}
|
|
567
|
+
return project_steps(
|
|
568
|
+
replace(
|
|
569
|
+
state,
|
|
570
|
+
status="pending",
|
|
571
|
+
active_item_id=None,
|
|
572
|
+
cursor=state.cursor + 1,
|
|
573
|
+
item_executions=tuple(records),
|
|
574
|
+
last_error=None,
|
|
575
|
+
updated_at=now(),
|
|
576
|
+
working_directory=working_directory or state.working_directory,
|
|
577
|
+
workflow_values=tuple(workflow_values.items()),
|
|
578
|
+
),
|
|
579
|
+
plan,
|
|
580
|
+
now,
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def fail_agent_item(
|
|
585
|
+
state: ExecutionState,
|
|
586
|
+
plan: WorkflowPlan,
|
|
587
|
+
item: PlanItem,
|
|
588
|
+
error: str,
|
|
589
|
+
now: Clock,
|
|
590
|
+
) -> ExecutionState:
|
|
591
|
+
"""Fail the active agent item and preserve the same error on the run."""
|
|
592
|
+
message = f"agent item {item.name!r} failed: {error}"
|
|
593
|
+
records = list(state.item_executions)
|
|
594
|
+
records[state.cursor] = replace(
|
|
595
|
+
records[state.cursor], status="failed", error=message
|
|
596
|
+
)
|
|
597
|
+
return project_steps(
|
|
598
|
+
replace(
|
|
599
|
+
state,
|
|
600
|
+
status="failed",
|
|
601
|
+
active_item_id=item.id,
|
|
602
|
+
item_executions=tuple(records),
|
|
603
|
+
last_error=message,
|
|
604
|
+
updated_at=now(),
|
|
605
|
+
),
|
|
606
|
+
plan,
|
|
607
|
+
now,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def supply_requested_input(
|
|
612
|
+
state: ExecutionState,
|
|
613
|
+
item_index: int,
|
|
614
|
+
supplied: dict[str, str],
|
|
615
|
+
now: Clock,
|
|
616
|
+
selected_agent: str | None = None,
|
|
617
|
+
selected_model: str | None = None,
|
|
618
|
+
selected_reasoning: str | None = None,
|
|
619
|
+
) -> ExecutionState:
|
|
620
|
+
"""Satisfy a pending input request and make its item runnable again."""
|
|
621
|
+
values = {**dict(state.workflow_values), **supplied}
|
|
622
|
+
records = list(state.item_executions)
|
|
623
|
+
records[item_index] = replace(
|
|
624
|
+
records[item_index],
|
|
625
|
+
status="pending",
|
|
626
|
+
supplied_values=tuple(supplied.items()),
|
|
627
|
+
error=None,
|
|
628
|
+
selected_agent=selected_agent,
|
|
629
|
+
selected_model=selected_model,
|
|
630
|
+
selected_reasoning=selected_reasoning,
|
|
631
|
+
)
|
|
632
|
+
return replace(
|
|
633
|
+
state,
|
|
634
|
+
status="pending",
|
|
635
|
+
pending_input_request=None,
|
|
636
|
+
workflow_values=tuple(values.items()),
|
|
637
|
+
item_executions=tuple(records),
|
|
638
|
+
updated_at=now(),
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def complete_agent_item(
|
|
643
|
+
state: ExecutionState,
|
|
644
|
+
plan: WorkflowPlan,
|
|
645
|
+
supplied: dict[str, str],
|
|
646
|
+
artifact_reference: str | None,
|
|
647
|
+
now: Clock,
|
|
648
|
+
selected_agent: str | None = None,
|
|
649
|
+
selected_model: str | None = None,
|
|
650
|
+
selected_reasoning: str | None = None,
|
|
651
|
+
clear_selected_model: bool = False,
|
|
652
|
+
clear_selected_reasoning: bool = False,
|
|
653
|
+
summary_for_next: str | None = None,
|
|
654
|
+
check_report: CheckReport | None = None,
|
|
655
|
+
) -> ExecutionState:
|
|
656
|
+
"""Complete the active agent item and merge its provided values.
|
|
657
|
+
|
|
658
|
+
``check_report`` is the passing report of the checks ww ran, if any.
|
|
659
|
+
"""
|
|
660
|
+
records = list(state.item_executions)
|
|
661
|
+
records[state.cursor] = replace(
|
|
662
|
+
records[state.cursor],
|
|
663
|
+
check_reports=(
|
|
664
|
+
(*records[state.cursor].check_reports, check_report)
|
|
665
|
+
if check_report is not None
|
|
666
|
+
else records[state.cursor].check_reports
|
|
667
|
+
),
|
|
668
|
+
draft_artifact=None,
|
|
669
|
+
held_completion=None,
|
|
670
|
+
status="completed",
|
|
671
|
+
completed_at=now(),
|
|
672
|
+
supplied_values=tuple(supplied.items()),
|
|
673
|
+
artifact=artifact_reference,
|
|
674
|
+
summary_for_next=summary_for_next,
|
|
675
|
+
selected_agent=selected_agent or records[state.cursor].selected_agent,
|
|
676
|
+
selected_model=(
|
|
677
|
+
None
|
|
678
|
+
if clear_selected_model
|
|
679
|
+
else selected_model
|
|
680
|
+
if selected_model is not None
|
|
681
|
+
else records[state.cursor].selected_model
|
|
682
|
+
),
|
|
683
|
+
selected_reasoning=(
|
|
684
|
+
None
|
|
685
|
+
if clear_selected_reasoning
|
|
686
|
+
else selected_reasoning
|
|
687
|
+
if selected_reasoning is not None
|
|
688
|
+
else records[state.cursor].selected_reasoning
|
|
689
|
+
),
|
|
690
|
+
)
|
|
691
|
+
values = {**dict(state.workflow_values), **supplied}
|
|
692
|
+
return project_steps(
|
|
693
|
+
replace(
|
|
694
|
+
state,
|
|
695
|
+
status="pending",
|
|
696
|
+
active_item_id=None,
|
|
697
|
+
cursor=state.cursor + 1,
|
|
698
|
+
item_executions=tuple(records),
|
|
699
|
+
workflow_values=tuple(values.items()),
|
|
700
|
+
updated_at=now(),
|
|
701
|
+
),
|
|
702
|
+
plan,
|
|
703
|
+
now,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def begin_child_workflow(
|
|
708
|
+
state: ExecutionState, item: PlanItem, now: Clock
|
|
709
|
+
) -> ExecutionState:
|
|
710
|
+
"""Enter the coordinator-owned wait state for a child workflow item."""
|
|
711
|
+
records = list(state.item_executions)
|
|
712
|
+
records[state.cursor] = replace(
|
|
713
|
+
records[state.cursor], status="in_progress", started_at=now()
|
|
714
|
+
)
|
|
715
|
+
return replace(
|
|
716
|
+
state,
|
|
717
|
+
status="in_progress",
|
|
718
|
+
active_item_id=item.id,
|
|
719
|
+
item_executions=tuple(records),
|
|
720
|
+
updated_at=now(),
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def fail_child_workflow(
|
|
725
|
+
state: ExecutionState,
|
|
726
|
+
plan: WorkflowPlan,
|
|
727
|
+
item: PlanItem,
|
|
728
|
+
child_id: str,
|
|
729
|
+
now: Clock,
|
|
730
|
+
message: str | None = None,
|
|
731
|
+
) -> ExecutionState:
|
|
732
|
+
"""Fail the active child coordinator when one of its children fails.
|
|
733
|
+
|
|
734
|
+
``message`` names another cause: ww could not start the child itself.
|
|
735
|
+
"""
|
|
736
|
+
message = message or f"child {child_id!r} failed"
|
|
737
|
+
records = list(state.item_executions)
|
|
738
|
+
records[state.cursor] = replace(
|
|
739
|
+
records[state.cursor], status="failed", error=message
|
|
740
|
+
)
|
|
741
|
+
return project_steps(
|
|
742
|
+
replace(
|
|
743
|
+
state,
|
|
744
|
+
status="failed",
|
|
745
|
+
active_item_id=item.id,
|
|
746
|
+
item_executions=tuple(records),
|
|
747
|
+
last_error=message,
|
|
748
|
+
updated_at=now(),
|
|
749
|
+
),
|
|
750
|
+
plan,
|
|
751
|
+
now,
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def complete_child_workflow(
|
|
756
|
+
state: ExecutionState,
|
|
757
|
+
plan: WorkflowPlan,
|
|
758
|
+
now: Clock,
|
|
759
|
+
artifact: str | None = None,
|
|
760
|
+
) -> ExecutionState:
|
|
761
|
+
"""Complete a child coordinator after its children finish.
|
|
762
|
+
|
|
763
|
+
``artifact`` is the saved result of a per-child run: the child's summary.
|
|
764
|
+
"""
|
|
765
|
+
records = list(state.item_executions)
|
|
766
|
+
records[state.cursor] = replace(
|
|
767
|
+
records[state.cursor],
|
|
768
|
+
status="completed",
|
|
769
|
+
completed_at=now(),
|
|
770
|
+
artifact=artifact,
|
|
771
|
+
)
|
|
772
|
+
return project_steps(
|
|
773
|
+
replace(
|
|
774
|
+
state,
|
|
775
|
+
status="pending",
|
|
776
|
+
active_item_id=None,
|
|
777
|
+
cursor=state.cursor + 1,
|
|
778
|
+
item_executions=tuple(records),
|
|
779
|
+
updated_at=now(),
|
|
780
|
+
),
|
|
781
|
+
plan,
|
|
782
|
+
now,
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def complete_child_summary(
|
|
787
|
+
state: ExecutionState, summary: str, now: Clock
|
|
788
|
+
) -> ExecutionState:
|
|
789
|
+
"""Complete the summary item that immediately follows child coordination."""
|
|
790
|
+
records = list(state.item_executions)
|
|
791
|
+
records[state.cursor] = replace(
|
|
792
|
+
records[state.cursor], status="completed", completed_at=now()
|
|
793
|
+
)
|
|
794
|
+
values = {**dict(state.workflow_values), "summary": summary}
|
|
795
|
+
return replace(
|
|
796
|
+
state,
|
|
797
|
+
status="pending",
|
|
798
|
+
cursor=state.cursor + 1,
|
|
799
|
+
item_executions=tuple(records),
|
|
800
|
+
workflow_values=tuple(values.items()),
|
|
801
|
+
updated_at=now(),
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def await_item_input(
|
|
806
|
+
state: ExecutionState,
|
|
807
|
+
item: PlanItem,
|
|
808
|
+
missing: tuple[ProvidedVariable, ...],
|
|
809
|
+
now: Clock,
|
|
810
|
+
) -> ExecutionState:
|
|
811
|
+
"""Pause an automatic item until all declared values are supplied."""
|
|
812
|
+
records = list(state.item_executions)
|
|
813
|
+
records[state.cursor] = replace(records[state.cursor], status="awaiting_input")
|
|
814
|
+
return replace(
|
|
815
|
+
state,
|
|
816
|
+
status="awaiting_input",
|
|
817
|
+
item_executions=tuple(records),
|
|
818
|
+
pending_input_request=InputRequest(item_id=item.id, values=missing),
|
|
819
|
+
updated_at=now(),
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def block_item_phase(state: ExecutionState, message: str, now: Clock) -> ExecutionState:
|
|
824
|
+
"""Stop before leaving an items pass whose items lack what it declares.
|
|
825
|
+
|
|
826
|
+
Nothing failed and the next step has not started: ``next --retry``
|
|
827
|
+
checks the items again once the operator recorded what they lack.
|
|
828
|
+
"""
|
|
829
|
+
return replace(
|
|
830
|
+
state,
|
|
831
|
+
status="failed",
|
|
832
|
+
last_error=message,
|
|
833
|
+
failure_kind="pass_incomplete",
|
|
834
|
+
updated_at=now(),
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def advance_completed_item(state: ExecutionState, now: Clock) -> ExecutionState:
|
|
839
|
+
"""Move the cursor past an item already recorded as completed."""
|
|
840
|
+
return replace(state, cursor=state.cursor + 1, updated_at=now())
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def pause_for_agent(state: ExecutionState, now: Clock) -> ExecutionState:
|
|
844
|
+
"""Expose a pending agent item without activating it."""
|
|
845
|
+
return replace(state, status="pending", updated_at=now())
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def enter_loop(
|
|
849
|
+
state: ExecutionState, plan: WorkflowPlan, item: PlanItem, now: Clock
|
|
850
|
+
) -> ExecutionState:
|
|
851
|
+
"""Enter a loop once and advance to its first ordinary nested step."""
|
|
852
|
+
loop = loop_control(item)
|
|
853
|
+
if loop is None or loop.boundary != "enter":
|
|
854
|
+
raise StateError("enter_loop requires a loop entry item")
|
|
855
|
+
records = list(state.item_executions)
|
|
856
|
+
records[state.cursor] = replace(
|
|
857
|
+
records[state.cursor], status="completed", completed_at=now()
|
|
858
|
+
)
|
|
859
|
+
iterations = {
|
|
860
|
+
**dict(state.loop_iterations),
|
|
861
|
+
loop.loop_id: 1,
|
|
862
|
+
}
|
|
863
|
+
return project_steps(
|
|
864
|
+
replace(
|
|
865
|
+
state,
|
|
866
|
+
cursor=state.cursor + 1,
|
|
867
|
+
status="pending",
|
|
868
|
+
item_executions=tuple(records),
|
|
869
|
+
loop_iterations=tuple(iterations.items()),
|
|
870
|
+
updated_at=now(),
|
|
871
|
+
),
|
|
872
|
+
plan,
|
|
873
|
+
now,
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def repeat_loop(
|
|
878
|
+
state: ExecutionState, plan: WorkflowPlan, item: PlanItem, now: Clock
|
|
879
|
+
) -> ExecutionState:
|
|
880
|
+
"""Reset a completed loop body for its next durable iteration."""
|
|
881
|
+
loop = loop_control(item)
|
|
882
|
+
if loop is None or loop.boundary != "repeat":
|
|
883
|
+
raise StateError("repeat_loop requires a loop repeat item")
|
|
884
|
+
records = list(state.item_executions)
|
|
885
|
+
entry = next(
|
|
886
|
+
(
|
|
887
|
+
index
|
|
888
|
+
for index in range(state.cursor - 1, -1, -1)
|
|
889
|
+
if (candidate := loop_control(plan.items[index])) is not None
|
|
890
|
+
and candidate.loop_id == loop.loop_id
|
|
891
|
+
and candidate.boundary == "enter"
|
|
892
|
+
),
|
|
893
|
+
None,
|
|
894
|
+
)
|
|
895
|
+
if entry is None:
|
|
896
|
+
raise StateError(f"loop {loop.loop_id!r} has no entry boundary")
|
|
897
|
+
iteration = dict(state.loop_iterations).get(loop.loop_id, 1) + 1
|
|
898
|
+
scope = loop_operation_scope(state, plan, entry, iteration)
|
|
899
|
+
history = [*state.execution_history, *records[entry + 1 : state.cursor + 1]]
|
|
900
|
+
for index in range(entry + 1, state.cursor + 1):
|
|
901
|
+
records[index] = new_item_execution(state.task_id, scope, plan.items[index])
|
|
902
|
+
iterations = {
|
|
903
|
+
**dict(state.loop_iterations),
|
|
904
|
+
loop.loop_id: iteration,
|
|
905
|
+
}
|
|
906
|
+
return project_steps(
|
|
907
|
+
replace(
|
|
908
|
+
state,
|
|
909
|
+
cursor=entry + 1,
|
|
910
|
+
status="pending",
|
|
911
|
+
active_item_id=None,
|
|
912
|
+
item_executions=tuple(records),
|
|
913
|
+
execution_history=tuple(history),
|
|
914
|
+
assignment_item_id=None,
|
|
915
|
+
assignment_token=None,
|
|
916
|
+
loop_iterations=tuple(iterations.items()),
|
|
917
|
+
updated_at=now(),
|
|
918
|
+
),
|
|
919
|
+
plan,
|
|
920
|
+
now,
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def loop_limit_reached(state: ExecutionState, item: PlanItem) -> bool:
|
|
925
|
+
"""Return whether a repeat boundary has exhausted its saved iteration limit."""
|
|
926
|
+
loop = loop_control(item)
|
|
927
|
+
if loop is None or loop.boundary != "repeat":
|
|
928
|
+
return False
|
|
929
|
+
return dict(state.loop_iterations).get(loop.loop_id, 0) >= loop.max_times
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def exit_exhausted_loop(
|
|
933
|
+
state: ExecutionState, plan: WorkflowPlan, now: Clock, reason: str | None = None
|
|
934
|
+
) -> ExecutionState:
|
|
935
|
+
"""Leave a loop at its iteration limit after an explicit operator force.
|
|
936
|
+
|
|
937
|
+
The repeat boundary is recorded as completed with the operator's reason so
|
|
938
|
+
the exit stays visible in the run history, and execution continues with
|
|
939
|
+
whatever follows the loop wrapper.
|
|
940
|
+
"""
|
|
941
|
+
boundary = plan.items[state.cursor] if state.cursor < len(plan.items) else None
|
|
942
|
+
if boundary is None or not loop_limit_reached(state, boundary):
|
|
943
|
+
raise StateError("exit_exhausted_loop requires a loop at its iteration limit")
|
|
944
|
+
records = list(state.item_executions)
|
|
945
|
+
note = "operator force-exited the loop at its iteration limit"
|
|
946
|
+
if reason:
|
|
947
|
+
note = f"{note}\nForce reason: {reason}"
|
|
948
|
+
records[state.cursor] = replace(
|
|
949
|
+
records[state.cursor], status="completed", completed_at=now(), error=note
|
|
950
|
+
)
|
|
951
|
+
return project_steps(
|
|
952
|
+
replace(
|
|
953
|
+
state,
|
|
954
|
+
status="pending",
|
|
955
|
+
active_item_id=None,
|
|
956
|
+
assignment_item_id=None,
|
|
957
|
+
assignment_token=None,
|
|
958
|
+
cursor=state.cursor + 1,
|
|
959
|
+
item_executions=tuple(records),
|
|
960
|
+
last_error=None,
|
|
961
|
+
updated_at=now(),
|
|
962
|
+
),
|
|
963
|
+
plan,
|
|
964
|
+
now,
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def loop_operation_scope(
|
|
969
|
+
state: ExecutionState,
|
|
970
|
+
plan: WorkflowPlan,
|
|
971
|
+
entry: int,
|
|
972
|
+
iteration: int,
|
|
973
|
+
) -> str:
|
|
974
|
+
"""Return an operation namespace including every enclosing loop iteration."""
|
|
975
|
+
loop_entry = plan.items[entry]
|
|
976
|
+
active = loop_control(loop_entry)
|
|
977
|
+
if active is None: # pragma: no cover - caller invariant
|
|
978
|
+
raise StateError("loop entry has no loop ID")
|
|
979
|
+
enclosing = set(loop_entry.ancestors)
|
|
980
|
+
lineage = [
|
|
981
|
+
loop.loop_id
|
|
982
|
+
for item in plan.items[: entry + 1]
|
|
983
|
+
if (loop := loop_control(item)) is not None
|
|
984
|
+
and loop.boundary == "enter"
|
|
985
|
+
and (loop.loop_id in enclosing or loop.loop_id == active.loop_id)
|
|
986
|
+
]
|
|
987
|
+
iterations = dict(state.loop_iterations)
|
|
988
|
+
encoded_segments = []
|
|
989
|
+
for loop_id in lineage:
|
|
990
|
+
value = _loop_iteration(loop_id, active.loop_id, iteration, iterations)
|
|
991
|
+
encoded_segments.append(f"{loop_id}:{value}")
|
|
992
|
+
encoded = ":".join(encoded_segments)
|
|
993
|
+
return f"{operation_scope_for(state)}{LOOP_SCOPE_MARKER}{encoded}"
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
# What ``loop_operation_scope`` puts between the run's scope and the loops.
|
|
997
|
+
LOOP_SCOPE_MARKER = ":loop:"
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def loop_iteration_of(record: PlanItemExecution, loop_id: str) -> int | None:
|
|
1001
|
+
"""The iteration of ``loop_id`` an execution record was made for.
|
|
1002
|
+
|
|
1003
|
+
Read back from the record's operation ID, which ``loop_operation_scope``
|
|
1004
|
+
encodes as ``loop:<loop>:<iteration>:...``; a record without a segment
|
|
1005
|
+
for the loop belongs to its first iteration. ``None`` when the record has
|
|
1006
|
+
no operation ID to read.
|
|
1007
|
+
"""
|
|
1008
|
+
if record.operation_id is None:
|
|
1009
|
+
return None
|
|
1010
|
+
scope = record.operation_id.removesuffix(f":{record.plan_item_id}")
|
|
1011
|
+
_, marker, encoded = scope.partition(LOOP_SCOPE_MARKER)
|
|
1012
|
+
if not marker:
|
|
1013
|
+
return 1
|
|
1014
|
+
# The loop's "<loop>:<iteration>" pair, e.g. "review:3" in "build:1:review:3".
|
|
1015
|
+
found = re.search(rf"(?:^|:){re.escape(loop_id)}:(\d+)(?=:|$)", encoded)
|
|
1016
|
+
return int(found.group(1)) if found else 1
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _loop_iteration(
|
|
1020
|
+
loop_id: str, active_loop_id: str, iteration: int, iterations: dict[str, int]
|
|
1021
|
+
) -> int:
|
|
1022
|
+
"""Select the reset iteration or its enclosing loop's saved iteration."""
|
|
1023
|
+
return iteration if loop_id == active_loop_id else iterations[loop_id]
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def enclosing_loop_entry_index(plan: WorkflowPlan, item_index: int) -> int:
|
|
1027
|
+
"""Return the entry boundary for a plan item nested in a loop."""
|
|
1028
|
+
item = plan.items[item_index]
|
|
1029
|
+
lineage = {item.step, *item.ancestors}
|
|
1030
|
+
entry = next(
|
|
1031
|
+
(
|
|
1032
|
+
index
|
|
1033
|
+
for index in range(item_index - 1, -1, -1)
|
|
1034
|
+
if (loop := loop_control(plan.items[index])) is not None
|
|
1035
|
+
and loop.boundary == "enter"
|
|
1036
|
+
and loop.loop_id in lineage
|
|
1037
|
+
),
|
|
1038
|
+
None,
|
|
1039
|
+
)
|
|
1040
|
+
if entry is None:
|
|
1041
|
+
raise StateError(f"step {item.step!r} has no enclosing loop")
|
|
1042
|
+
return entry
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def request_loop_exit(
|
|
1046
|
+
state: ExecutionState,
|
|
1047
|
+
plan: WorkflowPlan,
|
|
1048
|
+
stopped_item: PlanItem,
|
|
1049
|
+
wrapper_artifact_reference: str | None,
|
|
1050
|
+
now: Clock,
|
|
1051
|
+
) -> ExecutionState:
|
|
1052
|
+
"""Persist a worker's stop decision while its completion hooks still run.
|
|
1053
|
+
|
|
1054
|
+
A ``break`` that ends per-child stages has no loop wrapper to record.
|
|
1055
|
+
"""
|
|
1056
|
+
if stopped_item.loop_break is None or stopped_item.owner != "agent":
|
|
1057
|
+
raise StateError("request_loop_exit requires a break-enabled agent step")
|
|
1058
|
+
if stopped_item.breaks_children:
|
|
1059
|
+
return replace(state, loop_exit_item_id=stopped_item.id, updated_at=now())
|
|
1060
|
+
stopped_index = next(
|
|
1061
|
+
index for index, item in enumerate(plan.items) if item.id == stopped_item.id
|
|
1062
|
+
)
|
|
1063
|
+
entry = enclosing_loop_entry_index(plan, stopped_index)
|
|
1064
|
+
records = list(state.item_executions)
|
|
1065
|
+
records[entry] = replace(records[entry], artifact=wrapper_artifact_reference)
|
|
1066
|
+
return project_steps(
|
|
1067
|
+
replace(
|
|
1068
|
+
state,
|
|
1069
|
+
loop_exit_item_id=stopped_item.id,
|
|
1070
|
+
item_executions=tuple(records),
|
|
1071
|
+
updated_at=now(),
|
|
1072
|
+
),
|
|
1073
|
+
plan,
|
|
1074
|
+
now,
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def request_loop_continue(
|
|
1079
|
+
state: ExecutionState,
|
|
1080
|
+
plan: WorkflowPlan,
|
|
1081
|
+
continued_item: PlanItem,
|
|
1082
|
+
now: Clock,
|
|
1083
|
+
) -> ExecutionState:
|
|
1084
|
+
"""Persist a worker's continue decision until its completion hooks finish."""
|
|
1085
|
+
if continued_item.loop_continue is None or continued_item.owner != "agent":
|
|
1086
|
+
raise StateError("request_loop_continue requires a continue-enabled agent step")
|
|
1087
|
+
return replace(state, loop_continue_item_id=continued_item.id, updated_at=now())
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def finish_loop_continue(
|
|
1091
|
+
state: ExecutionState, plan: WorkflowPlan, now: Clock
|
|
1092
|
+
) -> ExecutionState:
|
|
1093
|
+
"""Restart the enclosing loop after a continue step's completion lifecycle."""
|
|
1094
|
+
if state.loop_continue_item_id is None:
|
|
1095
|
+
return state
|
|
1096
|
+
continued_index = next(
|
|
1097
|
+
index
|
|
1098
|
+
for index, item in enumerate(plan.items)
|
|
1099
|
+
if item.id == state.loop_continue_item_id
|
|
1100
|
+
)
|
|
1101
|
+
entry = enclosing_loop_entry_index(plan, continued_index)
|
|
1102
|
+
repeat = next(
|
|
1103
|
+
index
|
|
1104
|
+
for index in range(continued_index, len(plan.items))
|
|
1105
|
+
if (loop := loop_control(plan.items[index])) is not None
|
|
1106
|
+
and loop.boundary == "repeat"
|
|
1107
|
+
and loop.loop_id == _required_loop_control(plan.items[entry]).loop_id
|
|
1108
|
+
)
|
|
1109
|
+
continued_item = plan.items[continued_index]
|
|
1110
|
+
# The completion hooks are still part of the continued item's lifecycle.
|
|
1111
|
+
# Do not reset the body until they have run (or reported their own state).
|
|
1112
|
+
if (
|
|
1113
|
+
state.cursor < len(plan.items)
|
|
1114
|
+
and plan.items[state.cursor].step == continued_item.step
|
|
1115
|
+
):
|
|
1116
|
+
return state
|
|
1117
|
+
boundary = plan.items[repeat]
|
|
1118
|
+
if loop_limit_reached(state, boundary):
|
|
1119
|
+
# Match the normal repeat boundary: retain the completed iteration and
|
|
1120
|
+
# expose the manager escalation instead of silently starting another.
|
|
1121
|
+
return project_steps(
|
|
1122
|
+
replace(
|
|
1123
|
+
state,
|
|
1124
|
+
cursor=repeat,
|
|
1125
|
+
status="pending",
|
|
1126
|
+
active_item_id=None,
|
|
1127
|
+
loop_continue_item_id=None,
|
|
1128
|
+
updated_at=now(),
|
|
1129
|
+
),
|
|
1130
|
+
plan,
|
|
1131
|
+
now,
|
|
1132
|
+
)
|
|
1133
|
+
records = list(state.item_executions)
|
|
1134
|
+
loop_iterations: dict[str, int] = dict(state.loop_iterations)
|
|
1135
|
+
iteration = (
|
|
1136
|
+
loop_iterations.get(_required_loop_control(plan.items[entry]).loop_id, 1) + 1
|
|
1137
|
+
)
|
|
1138
|
+
loop_iterations[_required_loop_control(plan.items[entry]).loop_id] = iteration
|
|
1139
|
+
scope = loop_operation_scope(state, plan, entry, iteration)
|
|
1140
|
+
history = [*state.execution_history, *records[entry + 1 : repeat + 1]]
|
|
1141
|
+
for index in range(entry + 1, repeat + 1):
|
|
1142
|
+
records[index] = new_item_execution(state.task_id, scope, plan.items[index])
|
|
1143
|
+
return project_steps(
|
|
1144
|
+
replace(
|
|
1145
|
+
state,
|
|
1146
|
+
cursor=entry + 1,
|
|
1147
|
+
status="pending",
|
|
1148
|
+
active_item_id=None,
|
|
1149
|
+
item_executions=tuple(records),
|
|
1150
|
+
execution_history=tuple(history),
|
|
1151
|
+
assignment_item_id=None,
|
|
1152
|
+
assignment_token=None,
|
|
1153
|
+
loop_iterations=tuple(loop_iterations.items()),
|
|
1154
|
+
loop_continue_item_id=None,
|
|
1155
|
+
updated_at=now(),
|
|
1156
|
+
),
|
|
1157
|
+
plan,
|
|
1158
|
+
now,
|
|
1159
|
+
)
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def finish_loop_exit(
|
|
1163
|
+
state: ExecutionState, plan: WorkflowPlan, now: Clock
|
|
1164
|
+
) -> ExecutionState:
|
|
1165
|
+
"""Exit after the stopping step's own completion lifecycle has finished.
|
|
1166
|
+
|
|
1167
|
+
A loop is left after its repeat boundary. A ``break`` in a per-child
|
|
1168
|
+
stage skips every remaining per-child stage instead; the caller marks
|
|
1169
|
+
the children that never started as skipped.
|
|
1170
|
+
"""
|
|
1171
|
+
if state.loop_exit_item_id is None:
|
|
1172
|
+
return state
|
|
1173
|
+
completed_index = next(
|
|
1174
|
+
(
|
|
1175
|
+
index
|
|
1176
|
+
for index, item in enumerate(plan.items)
|
|
1177
|
+
if item.id == state.loop_exit_item_id
|
|
1178
|
+
),
|
|
1179
|
+
None,
|
|
1180
|
+
)
|
|
1181
|
+
if completed_index is None:
|
|
1182
|
+
raise StateError("loop exit references an unknown plan item")
|
|
1183
|
+
stopped_item = plan.items[completed_index]
|
|
1184
|
+
if (
|
|
1185
|
+
state.cursor < len(plan.items)
|
|
1186
|
+
and plan.items[state.cursor].step == stopped_item.step
|
|
1187
|
+
):
|
|
1188
|
+
return state
|
|
1189
|
+
if stopped_item.breaks_children:
|
|
1190
|
+
last = max(
|
|
1191
|
+
index
|
|
1192
|
+
for index, item in enumerate(plan.items)
|
|
1193
|
+
if item.child_stage == stopped_item.child_stage
|
|
1194
|
+
and item.child_number is not None
|
|
1195
|
+
)
|
|
1196
|
+
return _skip_to(
|
|
1197
|
+
state,
|
|
1198
|
+
plan,
|
|
1199
|
+
max(state.cursor, last + 1),
|
|
1200
|
+
"skipped because a children break gate passed",
|
|
1201
|
+
now,
|
|
1202
|
+
)
|
|
1203
|
+
entry = enclosing_loop_entry_index(plan, completed_index)
|
|
1204
|
+
loop_id = _required_loop_control(plan.items[entry]).loop_id
|
|
1205
|
+
repeat = next(
|
|
1206
|
+
(
|
|
1207
|
+
index
|
|
1208
|
+
for index in range(state.cursor, len(plan.items))
|
|
1209
|
+
if (loop := loop_control(plan.items[index])) is not None
|
|
1210
|
+
and loop.boundary == "repeat"
|
|
1211
|
+
and loop.loop_id == loop_id
|
|
1212
|
+
),
|
|
1213
|
+
None,
|
|
1214
|
+
)
|
|
1215
|
+
if repeat is None:
|
|
1216
|
+
raise StateError(f"loop {loop_id!r} has no repeat boundary")
|
|
1217
|
+
return _skip_to(
|
|
1218
|
+
state, plan, repeat + 1, "skipped because a loop break gate passed", now
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def _skip_to(
|
|
1223
|
+
state: ExecutionState, plan: WorkflowPlan, stop: int, result: str, now: Clock
|
|
1224
|
+
) -> ExecutionState:
|
|
1225
|
+
"""Complete every item from the cursor up to ``stop`` as skipped by a break."""
|
|
1226
|
+
records = list(state.item_executions)
|
|
1227
|
+
for index in range(state.cursor, stop):
|
|
1228
|
+
records[index] = replace(
|
|
1229
|
+
records[index], status="completed", completed_at=now(), result=result
|
|
1230
|
+
)
|
|
1231
|
+
return project_steps(
|
|
1232
|
+
replace(
|
|
1233
|
+
state,
|
|
1234
|
+
cursor=stop,
|
|
1235
|
+
status="pending",
|
|
1236
|
+
active_item_id=None,
|
|
1237
|
+
item_executions=tuple(records),
|
|
1238
|
+
loop_exit_item_id=None,
|
|
1239
|
+
updated_at=now(),
|
|
1240
|
+
),
|
|
1241
|
+
plan,
|
|
1242
|
+
now,
|
|
1243
|
+
)
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def complete_run(
|
|
1247
|
+
state: ExecutionState, plan: WorkflowPlan, now: Clock
|
|
1248
|
+
) -> ExecutionState:
|
|
1249
|
+
"""Mark a run complete after its cursor reaches the end of the plan."""
|
|
1250
|
+
return project_steps(
|
|
1251
|
+
replace(state, status="completed", active_item_id=None, updated_at=now()),
|
|
1252
|
+
plan,
|
|
1253
|
+
now,
|
|
1254
|
+
)
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def materialize_item_plan(
|
|
1258
|
+
state: ExecutionState,
|
|
1259
|
+
snapshot: PlanSnapshot,
|
|
1260
|
+
collector: PlanItem,
|
|
1261
|
+
items: tuple[WorkItem, ...],
|
|
1262
|
+
now: Clock,
|
|
1263
|
+
) -> tuple[ExecutionState, PlanSnapshot]:
|
|
1264
|
+
"""Expand one ``items`` pass for the items collected when it completes.
|
|
1265
|
+
|
|
1266
|
+
Only the pass ``collector`` declares is expanded, right after it, from
|
|
1267
|
+
its own templates in the template plan; every other pass keeps its
|
|
1268
|
+
templates (or its concrete stages) untouched. The pass's earlier
|
|
1269
|
+
stages, from a previous loop round, are replaced: the new round runs
|
|
1270
|
+
the items collected now, so an item added since joins it and the
|
|
1271
|
+
membership of a running pass never changes. A pass without stages
|
|
1272
|
+
(``items: {steps: []}``), or one that collected no items, expands to
|
|
1273
|
+
nothing. The snapshot is written in the current schema, whose pass
|
|
1274
|
+
identity the expanded plan relies on.
|
|
1275
|
+
"""
|
|
1276
|
+
pass_id = collector.item_pass
|
|
1277
|
+
if pass_id is None:
|
|
1278
|
+
raise StateError(f"items step {collector.name!r} has no item pass")
|
|
1279
|
+
template = snapshot.template_plan or snapshot.plan
|
|
1280
|
+
templates = tuple(
|
|
1281
|
+
entry
|
|
1282
|
+
for entry in template.items
|
|
1283
|
+
if entry.item_template
|
|
1284
|
+
and entry.child_stage is None
|
|
1285
|
+
and entry.item_pass == pass_id
|
|
1286
|
+
)
|
|
1287
|
+
if not templates:
|
|
1288
|
+
return state, snapshot
|
|
1289
|
+
members = frozenset(
|
|
1290
|
+
entry.id
|
|
1291
|
+
for entry in snapshot.plan.items
|
|
1292
|
+
if entry.item_pass == pass_id
|
|
1293
|
+
and entry.child_stage is None
|
|
1294
|
+
and (entry.item_template or entry.item_id is not None)
|
|
1295
|
+
)
|
|
1296
|
+
anchor = next(
|
|
1297
|
+
(
|
|
1298
|
+
index
|
|
1299
|
+
for index, entry in enumerate(snapshot.plan.items)
|
|
1300
|
+
if entry.id == collector.id
|
|
1301
|
+
),
|
|
1302
|
+
None,
|
|
1303
|
+
)
|
|
1304
|
+
if anchor is None:
|
|
1305
|
+
raise StateError(f"items step {collector.name!r} is not in the plan")
|
|
1306
|
+
return _expand_templates(
|
|
1307
|
+
state,
|
|
1308
|
+
snapshot,
|
|
1309
|
+
templates,
|
|
1310
|
+
"{item}",
|
|
1311
|
+
tuple(
|
|
1312
|
+
(f"item-{number}", f"item:{work_item.id}", {"item_id": work_item.id})
|
|
1313
|
+
for number, work_item in enumerate(items, 1)
|
|
1314
|
+
),
|
|
1315
|
+
now,
|
|
1316
|
+
replaced=members,
|
|
1317
|
+
after=collector.id,
|
|
1318
|
+
scope=_record_scope(state, anchor),
|
|
1319
|
+
)
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def _record_scope(state: ExecutionState, index: int) -> str:
|
|
1323
|
+
"""The operation namespace the record at ``index`` was created in.
|
|
1324
|
+
|
|
1325
|
+
A loop round gives its records a namespace of their own; stages expanded
|
|
1326
|
+
in that round share their collection step's.
|
|
1327
|
+
"""
|
|
1328
|
+
record = state.item_executions[index]
|
|
1329
|
+
prefix, suffix = f"{state.task_id}:", f":{record.plan_item_id}"
|
|
1330
|
+
operation = record.operation_id
|
|
1331
|
+
if operation is None or not (
|
|
1332
|
+
operation.startswith(prefix) and operation.endswith(suffix)
|
|
1333
|
+
):
|
|
1334
|
+
return operation_scope_for(state)
|
|
1335
|
+
return operation[len(prefix) : -len(suffix)]
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
def materialize_child_plan(
|
|
1339
|
+
state: ExecutionState,
|
|
1340
|
+
snapshot: PlanSnapshot,
|
|
1341
|
+
children: tuple[ChildTask, ...],
|
|
1342
|
+
now: Clock,
|
|
1343
|
+
) -> tuple[ExecutionState, PlanSnapshot]:
|
|
1344
|
+
"""Replace per-child stage templates with one lifecycle per child.
|
|
1345
|
+
|
|
1346
|
+
Each child's stages carry its position in the run's children, which is
|
|
1347
|
+
stable: children are only ever appended, and a child keeps its position
|
|
1348
|
+
when it binds its own ID.
|
|
1349
|
+
"""
|
|
1350
|
+
templates = tuple(
|
|
1351
|
+
entry
|
|
1352
|
+
for entry in snapshot.plan.items
|
|
1353
|
+
if entry.item_template and entry.child_stage is not None
|
|
1354
|
+
)
|
|
1355
|
+
if not templates:
|
|
1356
|
+
return state, snapshot
|
|
1357
|
+
if not children:
|
|
1358
|
+
raise StateError(
|
|
1359
|
+
"children step completed without recorded children; use add-child"
|
|
1360
|
+
)
|
|
1361
|
+
return _expand_templates(
|
|
1362
|
+
state,
|
|
1363
|
+
snapshot,
|
|
1364
|
+
templates,
|
|
1365
|
+
"{child}",
|
|
1366
|
+
tuple(
|
|
1367
|
+
(f"child-{number}", f"child:{number}", {"child_number": number})
|
|
1368
|
+
for number in range(1, len(children) + 1)
|
|
1369
|
+
),
|
|
1370
|
+
now,
|
|
1371
|
+
)
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def _expand_templates(
|
|
1375
|
+
state: ExecutionState,
|
|
1376
|
+
snapshot: PlanSnapshot,
|
|
1377
|
+
templates: tuple[PlanItem, ...],
|
|
1378
|
+
placeholder: str,
|
|
1379
|
+
units: tuple[tuple[str, str, dict[str, str | int]], ...],
|
|
1380
|
+
now: Clock,
|
|
1381
|
+
*,
|
|
1382
|
+
replaced: frozenset[str] | None = None,
|
|
1383
|
+
after: str | None = None,
|
|
1384
|
+
scope: str | None = None,
|
|
1385
|
+
) -> tuple[ExecutionState, PlanSnapshot]:
|
|
1386
|
+
"""Expand ``templates`` once per unit at the first template's position.
|
|
1387
|
+
|
|
1388
|
+
Each unit is its path segment (replacing ``placeholder`` in every path),
|
|
1389
|
+
its plan-item ID suffix, and the fields binding the copy to its unit.
|
|
1390
|
+
``replaced`` names the plan items the expansion replaces, the templates
|
|
1391
|
+
by default; with ``after`` the expansion goes right after that item
|
|
1392
|
+
instead. New records are created in ``scope``, the run's by default.
|
|
1393
|
+
"""
|
|
1394
|
+
template_ids = (
|
|
1395
|
+
replaced
|
|
1396
|
+
if replaced is not None
|
|
1397
|
+
else frozenset(template.id for template in templates)
|
|
1398
|
+
)
|
|
1399
|
+
|
|
1400
|
+
def concrete_path(value: str, segment: str) -> str:
|
|
1401
|
+
return value.replace(placeholder, segment)
|
|
1402
|
+
|
|
1403
|
+
def expand(
|
|
1404
|
+
template: PlanItem, segment: str, suffix: str, bind: dict[str, str | int]
|
|
1405
|
+
) -> PlanItem:
|
|
1406
|
+
operation = template.operation
|
|
1407
|
+
if isinstance(operation, LoopBoundary):
|
|
1408
|
+
operation = replace(
|
|
1409
|
+
operation, loop_id=concrete_path(operation.loop_id, segment)
|
|
1410
|
+
)
|
|
1411
|
+
return replace(
|
|
1412
|
+
template,
|
|
1413
|
+
id=f"{template.id}:{suffix}",
|
|
1414
|
+
operation=operation,
|
|
1415
|
+
step=concrete_path(template.step, segment),
|
|
1416
|
+
parent=(
|
|
1417
|
+
concrete_path(template.parent, segment)
|
|
1418
|
+
if template.parent is not None
|
|
1419
|
+
else None
|
|
1420
|
+
),
|
|
1421
|
+
ancestors=tuple(
|
|
1422
|
+
concrete_path(path, segment) for path in template.ancestors
|
|
1423
|
+
),
|
|
1424
|
+
artifact_dependency=(
|
|
1425
|
+
concrete_path(template.artifact_dependency, segment)
|
|
1426
|
+
if template.artifact_dependency is not None
|
|
1427
|
+
else None
|
|
1428
|
+
),
|
|
1429
|
+
loop_id=(
|
|
1430
|
+
concrete_path(template.loop_id, segment)
|
|
1431
|
+
if template.loop_id is not None
|
|
1432
|
+
else None
|
|
1433
|
+
),
|
|
1434
|
+
assessment_parent=(
|
|
1435
|
+
concrete_path(template.assessment_parent, segment)
|
|
1436
|
+
if template.assessment_parent is not None
|
|
1437
|
+
else None
|
|
1438
|
+
),
|
|
1439
|
+
item_template=False,
|
|
1440
|
+
item_id=str(bind["item_id"]) if "item_id" in bind else template.item_id,
|
|
1441
|
+
child_number=(
|
|
1442
|
+
int(bind["child_number"])
|
|
1443
|
+
if "child_number" in bind
|
|
1444
|
+
else template.child_number
|
|
1445
|
+
),
|
|
1446
|
+
)
|
|
1447
|
+
|
|
1448
|
+
expansion = [
|
|
1449
|
+
expand(template, segment, suffix, bind)
|
|
1450
|
+
for segment, suffix, bind in units
|
|
1451
|
+
for template in templates
|
|
1452
|
+
]
|
|
1453
|
+
concrete: list[PlanItem] = []
|
|
1454
|
+
expanded_templates = False
|
|
1455
|
+
for entry in snapshot.plan.items:
|
|
1456
|
+
if entry.id in template_ids:
|
|
1457
|
+
if after is None and not expanded_templates:
|
|
1458
|
+
concrete.extend(expansion)
|
|
1459
|
+
expanded_templates = True
|
|
1460
|
+
continue
|
|
1461
|
+
concrete.append(entry)
|
|
1462
|
+
if entry.id == after:
|
|
1463
|
+
concrete.extend(expansion)
|
|
1464
|
+
|
|
1465
|
+
plan = replace(
|
|
1466
|
+
snapshot.plan,
|
|
1467
|
+
items=number_step_paths(
|
|
1468
|
+
tuple(
|
|
1469
|
+
replace(entry, position=index)
|
|
1470
|
+
for index, entry in enumerate(concrete, 1)
|
|
1471
|
+
)
|
|
1472
|
+
),
|
|
1473
|
+
)
|
|
1474
|
+
old_records = {record.plan_item_id: record for record in state.item_executions}
|
|
1475
|
+
records = tuple(
|
|
1476
|
+
replace(old_records[entry.id], position=entry.position)
|
|
1477
|
+
if entry.id in old_records
|
|
1478
|
+
else new_item_execution(
|
|
1479
|
+
state.task_id, scope or operation_scope_for(state), entry
|
|
1480
|
+
)
|
|
1481
|
+
for entry in plan.items
|
|
1482
|
+
)
|
|
1483
|
+
revised_snapshot = replace(
|
|
1484
|
+
snapshot, plan=plan, plan_revision=snapshot.plan_revision + 1
|
|
1485
|
+
)
|
|
1486
|
+
revised_state = replace(
|
|
1487
|
+
state,
|
|
1488
|
+
item_executions=records,
|
|
1489
|
+
steps=build_step_projection(plan, state.steps),
|
|
1490
|
+
plan_revision=revised_snapshot.plan_revision,
|
|
1491
|
+
plan_digest=revised_snapshot.plan_digest,
|
|
1492
|
+
)
|
|
1493
|
+
return project_steps(revised_state, plan, now), revised_snapshot
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
def finish_selection(state: ExecutionState, target: str, now: Clock) -> ExecutionState:
|
|
1497
|
+
"""Complete a handoff-selection run without performing coordination I/O."""
|
|
1498
|
+
records = list(state.item_executions)
|
|
1499
|
+
records[state.cursor] = replace(
|
|
1500
|
+
records[state.cursor], status="completed", completed_at=now()
|
|
1501
|
+
)
|
|
1502
|
+
return replace(
|
|
1503
|
+
state,
|
|
1504
|
+
status="completed",
|
|
1505
|
+
active_item_id=None,
|
|
1506
|
+
cursor=state.cursor + 1,
|
|
1507
|
+
item_executions=tuple(records),
|
|
1508
|
+
workflow_values=tuple(
|
|
1509
|
+
{
|
|
1510
|
+
**dict(state.workflow_values),
|
|
1511
|
+
"summary": f"handed off to {target}",
|
|
1512
|
+
}.items()
|
|
1513
|
+
),
|
|
1514
|
+
updated_at=now(),
|
|
1515
|
+
)
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
def project_steps(
|
|
1519
|
+
state: ExecutionState, plan: WorkflowPlan, now: Clock
|
|
1520
|
+
) -> ExecutionState:
|
|
1521
|
+
"""Rebuild human-facing step status from authoritative item records."""
|
|
1522
|
+
by_path: dict[str, list[str]] = {}
|
|
1523
|
+
for item, record in zip(plan.items, state.item_executions, strict=True):
|
|
1524
|
+
by_path.setdefault(item.step, []).append(
|
|
1525
|
+
"in_progress"
|
|
1526
|
+
if record.repair_pending and state.status != "failed"
|
|
1527
|
+
else record.status
|
|
1528
|
+
)
|
|
1529
|
+
|
|
1530
|
+
def refresh(node: StepProgress) -> StepProgress:
|
|
1531
|
+
children = tuple(refresh(child) for child in node.children)
|
|
1532
|
+
statuses = [*by_path.get(node.path, []), *(child.status for child in children)]
|
|
1533
|
+
status: StepStatus
|
|
1534
|
+
if any(value == "failed" for value in statuses):
|
|
1535
|
+
status = "failed"
|
|
1536
|
+
elif statuses and all(value == "completed" for value in statuses):
|
|
1537
|
+
status = "completed"
|
|
1538
|
+
elif any(
|
|
1539
|
+
value in {"in_progress", "interrupted", "awaiting_input", "completed"}
|
|
1540
|
+
for value in statuses
|
|
1541
|
+
):
|
|
1542
|
+
status = "in_progress"
|
|
1543
|
+
else:
|
|
1544
|
+
status = "pending"
|
|
1545
|
+
started = node.started_at or (
|
|
1546
|
+
now() if status in {"in_progress", "completed", "failed"} else None
|
|
1547
|
+
)
|
|
1548
|
+
completed = (
|
|
1549
|
+
now()
|
|
1550
|
+
if status == "completed" and node.completed_at is None
|
|
1551
|
+
else node.completed_at
|
|
1552
|
+
)
|
|
1553
|
+
return replace(
|
|
1554
|
+
node,
|
|
1555
|
+
status=status,
|
|
1556
|
+
started_at=started,
|
|
1557
|
+
completed_at=completed,
|
|
1558
|
+
children=children,
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
return replace(state, steps=tuple(refresh(node) for node in state.steps))
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
def _required_loop_control(item: PlanItem) -> LoopBoundary:
|
|
1565
|
+
"""Read a loop descriptor where the persisted state already guarantees one."""
|
|
1566
|
+
loop = loop_control(item)
|
|
1567
|
+
if loop is None: # pragma: no cover - caller invariant
|
|
1568
|
+
raise StateError("loop control item has no loop capability")
|
|
1569
|
+
return loop
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
def select_assessment_outcome(
|
|
1573
|
+
state: ExecutionState, plan: WorkflowPlan, outcome: str | None, now: Clock
|
|
1574
|
+
) -> ExecutionState:
|
|
1575
|
+
"""Apply the answer to a completed assessment before its work is dispatched.
|
|
1576
|
+
|
|
1577
|
+
An outcome with steps moves the cursor to them and skips the others; one
|
|
1578
|
+
that stops the workflow, including the compact form's ``negative``, skips
|
|
1579
|
+
everything left, so the run completes.
|
|
1580
|
+
"""
|
|
1581
|
+
pending = pending_assessment(state, plan)
|
|
1582
|
+
if pending is None:
|
|
1583
|
+
if outcome is not None:
|
|
1584
|
+
raise StateError("--outcome is only valid when selecting a pending assess")
|
|
1585
|
+
return state
|
|
1586
|
+
if outcome is None:
|
|
1587
|
+
raise StateError(
|
|
1588
|
+
"pending assess requires --outcome <" + "|".join(pending.labels) + ">"
|
|
1589
|
+
)
|
|
1590
|
+
chosen = pending.outcome(outcome)
|
|
1591
|
+
if chosen is None:
|
|
1592
|
+
raise StateError(
|
|
1593
|
+
f"unknown assessment outcome {outcome!r}; expected "
|
|
1594
|
+
+ ", ".join(pending.labels)
|
|
1595
|
+
)
|
|
1596
|
+
records = list(state.item_executions)
|
|
1597
|
+
records[pending.index] = replace(records[pending.index], assessment_outcome=outcome)
|
|
1598
|
+
skipped = f"skipped: assessment selected {outcome}"
|
|
1599
|
+
if chosen.stops:
|
|
1600
|
+
for index in range(state.cursor, len(records)):
|
|
1601
|
+
records[index] = replace(
|
|
1602
|
+
records[index], status="completed", completed_at=now(), result=skipped
|
|
1603
|
+
)
|
|
1604
|
+
return replace(state, cursor=len(records), item_executions=tuple(records))
|
|
1605
|
+
if not pending.declared:
|
|
1606
|
+
return replace(state, item_executions=tuple(records))
|
|
1607
|
+
group = outcome_region(plan, pending.index)
|
|
1608
|
+
for index in group:
|
|
1609
|
+
if plan.items[index].assessment_outcome != outcome:
|
|
1610
|
+
records[index] = replace(
|
|
1611
|
+
records[index], status="completed", completed_at=now(), result=skipped
|
|
1612
|
+
)
|
|
1613
|
+
# An undeclared standard outcome has no work of its own: continue after
|
|
1614
|
+
# every outcome's work.
|
|
1615
|
+
selected = next(
|
|
1616
|
+
(index for index in group if plan.items[index].assessment_outcome == outcome),
|
|
1617
|
+
max(group) + 1,
|
|
1618
|
+
)
|
|
1619
|
+
return replace(state, cursor=selected, item_executions=tuple(records))
|