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.
Files changed (167) hide show
  1. ww/__init__.py +18 -0
  2. ww/_bundled_extensions/ww/git/extension.py +1728 -0
  3. ww/action_execution.py +887 -0
  4. ww/actions/__init__.py +94 -0
  5. ww/actions/command.py +444 -0
  6. ww/actions/contracts.py +699 -0
  7. ww/actions/extension.py +197 -0
  8. ww/actions/mcp.py +84 -0
  9. ww/actions/prompt.py +74 -0
  10. ww/actions/skill.py +62 -0
  11. ww/actions/slash_command.py +63 -0
  12. ww/agents.py +151 -0
  13. ww/amendments.py +54 -0
  14. ww/artifacts.py +93 -0
  15. ww/assessments.py +181 -0
  16. ww/assets/__init__.py +2 -0
  17. ww/assets/agent_instructions.md +49 -0
  18. ww/assets/docs/examples.md +879 -0
  19. ww/assets/docs/features.md +4639 -0
  20. ww/assets/docs/specification.md +1876 -0
  21. ww/assets/noww_skill.md +11 -0
  22. ww/assets/workflows/catchall.yaml +26 -0
  23. ww/assets/workflows/onboarding.yaml +586 -0
  24. ww/assets/workflows/scriptize.yaml +130 -0
  25. ww/assets/ww-automate_skill.md +23 -0
  26. ww/assets/ww-deduce-feedback_skill.md +38 -0
  27. ww/assets/ww-feedback-rules_skill.md +48 -0
  28. ww/assets/ww-learn-project_skill.md +22 -0
  29. ww/assets/ww-refresh_skill.md +26 -0
  30. ww/assets/ww-rule_skill.md +83 -0
  31. ww/assets/ww-rules-from-artifacts_skill.md +22 -0
  32. ww/assets/ww-scriptize_skill.md +33 -0
  33. ww/assets/ww-setup_skill.md +94 -0
  34. ww/assets/ww-solve_skill.md +23 -0
  35. ww/assets/ww-suggest_skill.md +32 -0
  36. ww/assets/ww-wizard_skill.md +105 -0
  37. ww/assets/ww_skill.md +59 -0
  38. ww/assignments.py +283 -0
  39. ww/bootstrap.py +405 -0
  40. ww/builtin_workflows.py +215 -0
  41. ww/changes.py +225 -0
  42. ww/child_coordination.py +482 -0
  43. ww/children.py +106 -0
  44. ww/claude_permissions.py +115 -0
  45. ww/cli/__init__.py +7 -0
  46. ww/cli/__main__.py +6 -0
  47. ww/cli/audit.py +129 -0
  48. ww/cli/catalogs.py +131 -0
  49. ww/cli/discover.py +607 -0
  50. ww/cli/initialization.py +898 -0
  51. ww/cli/lookup.py +287 -0
  52. ww/cli/main.py +1768 -0
  53. ww/cli/parser.py +1200 -0
  54. ww/cli/prompts.py +217 -0
  55. ww/cli/updates.py +117 -0
  56. ww/completion_artifacts.py +156 -0
  57. ww/completion_inputs.py +39 -0
  58. ww/config/__init__.py +582 -0
  59. ww/config/actions.py +591 -0
  60. ww/config/composition.py +571 -0
  61. ww/config/rules.py +511 -0
  62. ww/config/steps.py +1220 -0
  63. ww/config/values.py +223 -0
  64. ww/config_files.py +191 -0
  65. ww/config_writes.py +264 -0
  66. ww/contracts.py +155 -0
  67. ww/control.py +41 -0
  68. ww/defaults.py +130 -0
  69. ww/design_docs.py +32 -0
  70. ww/discovery.py +104 -0
  71. ww/documents.py +217 -0
  72. ww/errors.py +18 -0
  73. ww/executable.py +43 -0
  74. ww/execution_models/__init__.py +64 -0
  75. ww/execution_models/construction.py +148 -0
  76. ww/execution_models/decoding.py +38 -0
  77. ww/execution_models/plan_codec.py +565 -0
  78. ww/execution_models/records.py +1206 -0
  79. ww/execution_models/runs.py +266 -0
  80. ww/extensions/__init__.py +40 -0
  81. ww/extensions/api.py +559 -0
  82. ww/extensions/registry.py +864 -0
  83. ww/extensions/store.py +78 -0
  84. ww/feedback.py +342 -0
  85. ww/handler_repairs.py +57 -0
  86. ww/hooks/__init__.py +40 -0
  87. ww/hooks/agents.py +380 -0
  88. ww/hooks/install.py +168 -0
  89. ww/hooks/notices.py +206 -0
  90. ww/hooks/records.py +209 -0
  91. ww/hooks/runtime.py +266 -0
  92. ww/hooks/transcripts.py +183 -0
  93. ww/inspect.py +896 -0
  94. ww/instructions/__init__.py +17 -0
  95. ww/instructions/builder.py +1682 -0
  96. ww/instructions/commands.py +335 -0
  97. ww/instructions/handoff.py +149 -0
  98. ww/instructions/models.py +686 -0
  99. ww/instructions/policy.py +219 -0
  100. ww/instructions/text.py +168 -0
  101. ww/interactions.py +187 -0
  102. ww/interpolation.py +37 -0
  103. ww/item_passes.py +167 -0
  104. ww/items.py +99 -0
  105. ww/locking.py +207 -0
  106. ww/metadata_publication.py +230 -0
  107. ww/onboarding.py +229 -0
  108. ww/open_work.py +236 -0
  109. ww/operations.py +193 -0
  110. ww/operator_ui/__init__.py +16 -0
  111. ww/operator_ui/page.html +351 -0
  112. ww/operator_ui/server.py +215 -0
  113. ww/operator_ui/session.py +389 -0
  114. ww/operator_ui/sheet.py +104 -0
  115. ww/operator_ui/view.py +109 -0
  116. ww/output.py +339 -0
  117. ww/output_adapters/__init__.py +12 -0
  118. ww/output_adapters/base.py +25 -0
  119. ww/output_adapters/json_adapter.py +37 -0
  120. ww/output_adapters/markdown.py +2293 -0
  121. ww/output_adapters/rule_pages.py +337 -0
  122. ww/output_adapters/terminal.py +21 -0
  123. ww/package_updates.py +167 -0
  124. ww/plan/__init__.py +38 -0
  125. ww/plan/actions.py +207 -0
  126. ww/plan/compiler.py +1492 -0
  127. ww/plan/constructs.py +456 -0
  128. ww/plan/models.py +665 -0
  129. ww/project_config.py +752 -0
  130. ww/recovery.py +401 -0
  131. ww/replanning.py +367 -0
  132. ww/results.py +77 -0
  133. ww/rule_checks.py +230 -0
  134. ww/rule_conversion.py +331 -0
  135. ww/rule_disputes.py +148 -0
  136. ww/rule_store.py +456 -0
  137. ww/rule_verification.py +714 -0
  138. ww/rule_views.py +447 -0
  139. ww/rule_writes.py +920 -0
  140. ww/run_coordination.py +158 -0
  141. ww/runtimes.py +105 -0
  142. ww/service.py +4405 -0
  143. ww/setup_apply.py +428 -0
  144. ww/step_values.py +20 -0
  145. ww/storage.py +447 -0
  146. ww/storage_adapters/__init__.py +36 -0
  147. ww/storage_adapters/base.py +540 -0
  148. ww/storage_adapters/filesystem.py +370 -0
  149. ww/storage_adapters/memory.py +195 -0
  150. ww/storage_adapters/project_metadata.py +69 -0
  151. ww/storage_adapters/task_document.py +484 -0
  152. ww/task_ids.py +114 -0
  153. ww/task_references.py +124 -0
  154. ww/transitions.py +1619 -0
  155. ww/updates.py +399 -0
  156. ww/upgrade.py +95 -0
  157. ww/validation.py +168 -0
  158. ww/variables.py +275 -0
  159. ww/workflow_config.py +854 -0
  160. ww/workflow_update.py +239 -0
  161. ww/workflow_validation.py +1260 -0
  162. ww/workspace.py +50 -0
  163. ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
  164. ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
  165. ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
  166. ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
  167. 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))