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
@@ -0,0 +1,482 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Parent and child workflow coordination.
3
+
4
+ A parent workflow collects children, then a child-workflow coordinator item
5
+ runs the configured workflow once per child, one at a time. With per-child
6
+ parent stages each child has its own coordinator item, which runs only that
7
+ child. The parent's child records are a projection of the children's own
8
+ run records; every reconciliation re-reads those records so an interrupted
9
+ publication can be repaired instead of replayed.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Callable
15
+ from dataclasses import replace
16
+ from typing import Protocol
17
+
18
+ from ww.children import ChildTask
19
+ from ww.completion_artifacts import write_completion_artifacts
20
+ from ww.contracts import ChildStatus, run_is_open
21
+ from ww.control import child_workflow
22
+ from ww.errors import StateError
23
+ from ww.execution_models import ExecutionState, PlanSnapshot
24
+ from ww.instructions import Instruction
25
+ from ww.plan import PlanItem
26
+ from ww.run_coordination import RunLifecycle
27
+ from ww.runtimes import runtime_instruction
28
+ from ww.storage_adapters import TaskStorageAdapter
29
+ from ww.task_ids import is_bootstrap_request, validate_child_id, validate_task_id
30
+ from ww.transitions import (
31
+ Clock,
32
+ begin_child_workflow,
33
+ complete_child_summary,
34
+ complete_child_workflow,
35
+ fail_child_workflow,
36
+ retry_failed_item,
37
+ )
38
+
39
+
40
+ class StartChildRun(Protocol):
41
+ """Start the child's own workflow run bound to its parent."""
42
+
43
+ def __call__(
44
+ self,
45
+ task_id: str,
46
+ workflow_name: str,
47
+ mode_names: tuple[str, ...],
48
+ agent: str,
49
+ *,
50
+ parent_task_id: str,
51
+ start_operation_id: str,
52
+ model: str,
53
+ reasoning: str,
54
+ workflow_runtime: str,
55
+ init_artifact: str,
56
+ project: str | None,
57
+ ) -> Instruction: ...
58
+
59
+
60
+ class StartChildIdentity(Protocol):
61
+ """Open the identity request through which a child obtains its own ID."""
62
+
63
+ def __call__(
64
+ self, child: ChildTask, workflow_name: str, parent: ExecutionState
65
+ ) -> Instruction: ...
66
+
67
+
68
+ class ChildCoordinator:
69
+ def __init__(
70
+ self,
71
+ tasks: TaskStorageAdapter,
72
+ lifecycle: RunLifecycle,
73
+ start_run: StartChildRun,
74
+ now: Clock,
75
+ validate_workflow: Callable[[str, ChildTask, ExecutionState], None],
76
+ start_identity: StartChildIdentity | None = None,
77
+ ) -> None:
78
+ self.tasks = tasks
79
+ self.lifecycle = lifecycle
80
+ self.start_run = start_run
81
+ self.now = now
82
+ self.validate_workflow = validate_workflow
83
+ self.start_identity = start_identity
84
+
85
+ def start_child(
86
+ self,
87
+ parent_task_id: str,
88
+ child_id: str,
89
+ *,
90
+ workflow_name: str | None = None,
91
+ workflow_runtime: str | None = None,
92
+ model: str | None = None,
93
+ reasoning: str | None = None,
94
+ agent: str | None = None,
95
+ ) -> Instruction:
96
+ validate_task_id(parent_task_id)
97
+ validate_child_id(child_id)
98
+ with self.tasks.lock_task(parent_task_id):
99
+ parent, snapshot = self.lifecycle.load(parent_task_id)
100
+ if parent.cursor >= len(snapshot.plan.items):
101
+ raise StateError("parent workflow is already completed")
102
+ coordinator = child_workflow(snapshot.plan.items[parent.cursor])
103
+ if coordinator is None:
104
+ raise StateError("parent workflow is not running its children")
105
+ children = list(self.tasks.read_children(parent_task_id, parent.run_id))
106
+ by_id = {child.id: child for child in children}
107
+ child = by_id.get(child_id)
108
+ if child is None:
109
+ raise StateError(f"child {child_id!r} was not found")
110
+ current = snapshot.plan.items[parent.cursor].child_number
111
+ if current is not None and children[current - 1].id != child_id:
112
+ raise StateError(
113
+ f"the parent runs child {children[current - 1].id!r} now; "
114
+ f"child {child_id!r} waits for its own stages"
115
+ )
116
+ if child.status not in {"pending", "starting"}:
117
+ raise StateError(f"child {child_id!r} is not pending")
118
+ start_operation_id = child.start_operation_id
119
+ if start_operation_id is None:
120
+ raise StateError(f"child {child_id!r} has no start operation")
121
+ if any(
122
+ other.id != child_id and other.status in {"starting", "in_progress"}
123
+ for other in children
124
+ ):
125
+ raise StateError("another child is already in progress")
126
+ workflow = (
127
+ workflow_name
128
+ if workflow_name is not None
129
+ else (
130
+ child.workflow
131
+ if child.status == "starting"
132
+ else coordinator.workflow
133
+ )
134
+ )
135
+ if child.status == "starting" and workflow != child.workflow:
136
+ raise StateError(
137
+ f"child {child.id!r} is already starting; workflow cannot change"
138
+ )
139
+ child = self._launch_settings(
140
+ child, parent, workflow_runtime, model, reasoning, agent
141
+ )
142
+ self.validate_workflow(workflow, child, parent)
143
+ child = replace(child, workflow=workflow, status="starting")
144
+ children[children.index(by_id[child_id])] = child
145
+ self.lifecycle.commit(parent, snapshot, children=tuple(children))
146
+ if is_bootstrap_request(child_id):
147
+ # The child obtains its own external ID first; ``bind_child`` then
148
+ # renames this record and publishes the child run.
149
+ if self.start_identity is None: # pragma: no cover - service wires it
150
+ raise StateError("child identity requests are not supported here")
151
+ return self.start_identity(child, workflow, parent)
152
+ child_task_id = f"{parent_task_id}/{child_id}"
153
+ existing, _, _ = self.tasks.read_task_record(child_task_id)
154
+ published = next(
155
+ (
156
+ run
157
+ for run in reversed(existing)
158
+ if run.state.parent_task_id == parent_task_id
159
+ and run.state.start_operation_id == child.start_operation_id
160
+ ),
161
+ None,
162
+ )
163
+ if published is not None:
164
+ # A previous process published the child before dying while
165
+ # publishing the parent projection. Reconcile instead of
166
+ # creating a duplicate run.
167
+ self.reconcile_after_child(child_task_id)
168
+ return self.lifecycle.instruction_status(child_task_id, published.run_id)
169
+ if any(run_is_open(run.state.status) for run in existing):
170
+ raise StateError("existing child run does not match its parent binding")
171
+ with self.tasks.lock_task(child_task_id):
172
+ child_instruction = self.start_run(
173
+ child_task_id,
174
+ workflow,
175
+ (),
176
+ child.agent or parent.agent,
177
+ parent_task_id=parent_task_id,
178
+ start_operation_id=start_operation_id,
179
+ model=child.model or parent.model,
180
+ reasoning=child.reasoning or parent.reasoning,
181
+ workflow_runtime=child.workflow_runtime or parent.workflow_runtime,
182
+ init_artifact=(
183
+ f"Requirements for child task {child_id}: {child.description}"
184
+ ),
185
+ project=child.project,
186
+ )
187
+ self._publish_child(parent_task_id, child_id, child_task_id, workflow)
188
+ return child_instruction
189
+
190
+ @staticmethod
191
+ def _launch_settings(
192
+ child: ChildTask,
193
+ parent: ExecutionState,
194
+ workflow_runtime: str | None,
195
+ model: str | None,
196
+ reasoning: str | None,
197
+ agent: str | None,
198
+ ) -> ChildTask:
199
+ """Resolve and freeze settings before any child launch side effect."""
200
+ inherited_model = child.model or parent.model
201
+ inherited_reasoning = child.reasoning or parent.reasoning
202
+ inherited_runtime = child.workflow_runtime or parent.workflow_runtime
203
+ resolved_model = model if model is not None else inherited_model
204
+ resolved_reasoning = (
205
+ reasoning
206
+ if reasoning is not None
207
+ else "auto"
208
+ if resolved_model != inherited_model
209
+ else inherited_reasoning
210
+ )
211
+ resolved_runtime = (
212
+ workflow_runtime if workflow_runtime is not None else inherited_runtime
213
+ )
214
+ inherited_agent = child.agent or parent.agent
215
+ resolved_agent = agent if agent is not None else inherited_agent
216
+ if not resolved_model.strip() or not resolved_reasoning.strip():
217
+ raise StateError("execution requires non-empty --model and --reasoning")
218
+ if not resolved_agent.strip():
219
+ raise StateError("execution requires a non-empty --agent")
220
+ runtime_instruction(resolved_runtime)
221
+ if child.status == "starting" and (
222
+ resolved_model != inherited_model
223
+ or resolved_reasoning != inherited_reasoning
224
+ or resolved_runtime != inherited_runtime
225
+ or resolved_agent != inherited_agent
226
+ ):
227
+ raise StateError(
228
+ f"child {child.id!r} is already starting; launch settings cannot change"
229
+ )
230
+ return replace(
231
+ child,
232
+ model=resolved_model,
233
+ reasoning=resolved_reasoning,
234
+ workflow_runtime=resolved_runtime,
235
+ agent=resolved_agent,
236
+ )
237
+
238
+ def bind_child(
239
+ self, parent_task_id: str, temporary_id: str, child_task_id: str
240
+ ) -> None:
241
+ """Rename a child from its identity request to its bound external ID.
242
+
243
+ Safe to repeat: once the record carries the bound ID nothing changes.
244
+ """
245
+ _, _, bound_id = child_task_id.rpartition("/")
246
+ with self.tasks.lock_task(parent_task_id):
247
+ parent, snapshot = self.lifecycle.load(parent_task_id)
248
+ children = list(self.tasks.read_children(parent_task_id, parent.run_id))
249
+ index = next(
250
+ (i for i, entry in enumerate(children) if entry.id == temporary_id),
251
+ None,
252
+ )
253
+ if index is None:
254
+ if not any(entry.id == bound_id for entry in children):
255
+ raise StateError(
256
+ f"child {temporary_id!r} was not found under {parent_task_id!r}"
257
+ )
258
+ return
259
+ if any(entry.id == bound_id for entry in children):
260
+ raise StateError(f"child {bound_id!r} already exists")
261
+ children[index] = replace(
262
+ children[index], id=bound_id, task_id=child_task_id
263
+ )
264
+ self.lifecycle.commit(parent, snapshot, children=tuple(children))
265
+ self._publish_child(
266
+ parent_task_id, bound_id, child_task_id, children[index].workflow
267
+ )
268
+
269
+ def _publish_child(
270
+ self, parent_task_id: str, child_id: str, child_task_id: str, workflow: str
271
+ ) -> None:
272
+ """Project the started child's run onto the parent's child record."""
273
+ child_runs = self.tasks.execution_runs(child_task_id)
274
+ child_run = child_runs[-1] if child_runs else None
275
+ child_status = (
276
+ child_run.status
277
+ if child_run and child_run.status in {"completed", "failed"}
278
+ else "in_progress"
279
+ )
280
+ with self.tasks.lock_task(parent_task_id):
281
+ parent, snapshot = self.lifecycle.load(parent_task_id)
282
+ children = list(self.tasks.read_children(parent_task_id, parent.run_id))
283
+ for index, entry in enumerate(children):
284
+ if entry.id == child_id:
285
+ children[index] = replace(
286
+ entry,
287
+ workflow=workflow,
288
+ status=child_status,
289
+ run_id=child_run.run_id if child_run else None,
290
+ summary=child_run.summary if child_run else None,
291
+ )
292
+ break
293
+ self.lifecycle.commit(parent, snapshot, children=tuple(children))
294
+ if child_status in {"completed", "failed"}:
295
+ # The child may have drained to a terminal state during start (an
296
+ # all-automatic workflow). Reconcile after the parent relink so
297
+ # the terminal state is not overwritten by the in-progress binding.
298
+ self.reconcile_after_child(child_task_id)
299
+
300
+ def refresh_parent(self, task_id: str) -> Instruction | None:
301
+ """Refresh a waiting parent from authoritative child run records."""
302
+ validate_task_id(task_id)
303
+ if "/" in task_id:
304
+ return None
305
+ runs, _, _ = self.tasks.read_task_record(task_id)
306
+ run = next(
307
+ (entry for entry in reversed(runs) if entry.state.status != "completed"),
308
+ runs[-1] if runs else None,
309
+ )
310
+ if (
311
+ run is None
312
+ or run.state.cursor >= len(run.snapshot.plan.items)
313
+ or child_workflow(run.snapshot.plan.items[run.state.cursor]) is None
314
+ or not run.children
315
+ ):
316
+ return None
317
+ self.reconcile_after_child(f"{task_id}/{run.children[0].id}")
318
+ refreshed, _, _ = self.tasks.read_task_record(task_id)
319
+ current = next(
320
+ (entry for entry in refreshed if entry.run_id == run.run_id), None
321
+ )
322
+ # A parent that was already failed is left to the command's own
323
+ # failure handling, so ``next --retry`` can reach recovery.
324
+ if (
325
+ current is not None
326
+ and current.state.status in {"completed", "failed"}
327
+ and current.state.status != run.state.status
328
+ ):
329
+ return self.lifecycle.render(current.state, current.snapshot)
330
+ return None
331
+
332
+ def reconcile_after_child(self, child_task_id: str) -> None:
333
+ """Project a child's run records onto its parent and advance the parent."""
334
+ parent_task_id, separator, child_id = child_task_id.rpartition("/")
335
+ if not separator:
336
+ return
337
+ with self.tasks.lock_task(parent_task_id):
338
+ parent, snapshot = self.lifecycle.load(parent_task_id)
339
+ children = list(self.tasks.read_children(parent_task_id, parent.run_id))
340
+ if not any(child.id == child_id for child in children):
341
+ return
342
+ changed = False
343
+ for index, child in enumerate(children):
344
+ refreshed = self._refresh_child(parent_task_id, child)
345
+ if refreshed is None:
346
+ continue
347
+ changed = changed or refreshed != child
348
+ children[index] = refreshed
349
+ projected = tuple(children)
350
+
351
+ def commit_children() -> None:
352
+ self.lifecycle.commit(parent, snapshot, children=projected)
353
+
354
+ if parent.cursor >= len(snapshot.plan.items):
355
+ if changed:
356
+ commit_children()
357
+ return
358
+ item = snapshot.plan.items[parent.cursor]
359
+ if child_workflow(item) is None:
360
+ if changed:
361
+ commit_children()
362
+ return
363
+ # A per-child coordinator waits for its own child only.
364
+ watched = (
365
+ (projected[item.child_number - 1],)
366
+ if item.child_number is not None
367
+ else projected
368
+ )
369
+ failed = next(
370
+ (child for child in watched if child.status == "failed"), None
371
+ )
372
+ if failed is not None:
373
+ if parent.status != "failed":
374
+ parent = fail_child_workflow(
375
+ parent, snapshot.plan, item, failed.id, self.now
376
+ )
377
+ commit_children()
378
+ elif changed:
379
+ commit_children()
380
+ return
381
+ if not watched or any(child.status != "completed" for child in watched):
382
+ # A recovery retry may find a published child whose parent
383
+ # relink was interrupted while the child was still pending.
384
+ # Persist the in-progress binding while the parent keeps waiting.
385
+ # A child ww could not launch has no run: it stays failed.
386
+ if parent.status == "failed" and any(
387
+ child.run_id is not None for child in watched
388
+ ):
389
+ parent = retry_failed_item(parent, snapshot.plan, self.now)
390
+ parent = begin_child_workflow(parent, item, self.now)
391
+ commit_children()
392
+ elif changed:
393
+ commit_children()
394
+ return
395
+ parent = complete_child_workflow(
396
+ parent,
397
+ snapshot.plan,
398
+ self.now,
399
+ artifact=(
400
+ self._write_child_result(parent, snapshot, item, watched[0])
401
+ if item.child_number is not None
402
+ else None
403
+ ),
404
+ )
405
+ commit_children()
406
+ parent, snapshot = self.lifecycle.drain(parent, snapshot)
407
+ if (
408
+ parent.cursor < len(snapshot.plan.items)
409
+ and snapshot.plan.items[parent.cursor].summary
410
+ ):
411
+ # A break in per-child stages skips the remaining children,
412
+ # which never ran.
413
+ summary = "Completed child tasks: " + ", ".join(
414
+ child.summary or child.id
415
+ for child in projected
416
+ if child.status == "completed"
417
+ )
418
+ skipped = [
419
+ child.id for child in projected if child.status != "completed"
420
+ ]
421
+ if skipped:
422
+ summary += "; skipped: " + ", ".join(skipped)
423
+ parent = complete_child_summary(parent, summary, self.now)
424
+ self.lifecycle.commit(parent, snapshot, children=projected)
425
+ self.lifecycle.drain(parent, snapshot)
426
+
427
+ def _write_child_result(
428
+ self,
429
+ parent: ExecutionState,
430
+ snapshot: PlanSnapshot,
431
+ item: PlanItem,
432
+ child: ChildTask,
433
+ ) -> str | None:
434
+ """Save a per-child run's result, the child's summary, as its artifact.
435
+
436
+ Later stages read it like any step's artifact (``artifact_from``).
437
+ """
438
+ artifact, _ = write_completion_artifacts(
439
+ self.tasks,
440
+ parent.task_id,
441
+ parent,
442
+ snapshot,
443
+ item,
444
+ None,
445
+ f"Child task `{child.task_id}` completed its `{child.workflow}` "
446
+ f"workflow.\n\nSummary: {child.summary or 'none recorded'}",
447
+ )
448
+ return artifact
449
+
450
+ def _refresh_child(self, parent_task_id: str, child: ChildTask) -> ChildTask | None:
451
+ """Return the child record as its own run records describe it."""
452
+ runs, _, _ = self.tasks.read_task_record(
453
+ child.task_id or f"{parent_task_id}/{child.id}"
454
+ )
455
+ run = next(
456
+ (
457
+ entry
458
+ for entry in reversed(runs)
459
+ if entry.state.parent_task_id == parent_task_id
460
+ and entry.state.start_operation_id == child.start_operation_id
461
+ ),
462
+ None,
463
+ )
464
+ if run is None:
465
+ return None
466
+ status: ChildStatus
467
+ if run.state.status == "completed":
468
+ status = "completed"
469
+ elif run.state.status == "failed":
470
+ status = "failed"
471
+ else:
472
+ status = "in_progress"
473
+ return replace(
474
+ child,
475
+ status=status,
476
+ run_id=run.run_id,
477
+ summary=(
478
+ dict(run.state.workflow_values).get("summary")
479
+ if run.state.status == "completed"
480
+ else None
481
+ ),
482
+ )
ww/children.py ADDED
@@ -0,0 +1,106 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Run-local child-task records for one level of parent orchestration."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, replace
7
+
8
+ from ww.contracts import ChildStatus
9
+ from ww.items import validate_item_fields
10
+ from ww.validation import expect_literal
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ChildTask:
15
+ id: str
16
+ description: str
17
+ workflow: str
18
+ task_id: str
19
+ status: ChildStatus = "pending"
20
+ run_id: str | None = None
21
+ summary: str | None = None
22
+ start_operation_id: str | None = None
23
+ parent_task_id: str | None = None
24
+ # The configured project the child works in; ``None`` means the root.
25
+ project: str | None = None
26
+ # Launch settings are frozen before starting, including identity requests.
27
+ workflow_runtime: str | None = None
28
+ model: str | None = None
29
+ reasoning: str | None = None
30
+ agent: str | None = None
31
+ # Custom fields, as items carry them; per-child parent stages read them
32
+ # as ``{{ww.child.field.<name>}}``. String values only.
33
+ fields: tuple[tuple[str, str], ...] = ()
34
+
35
+ def with_fields(self, values: dict[str, str]) -> ChildTask:
36
+ merged = {**dict(self.fields), **values}
37
+ return replace(self, fields=tuple(merged.items()))
38
+
39
+ def to_dict(self) -> dict[str, object]:
40
+ return {
41
+ "id": self.id,
42
+ "description": self.description,
43
+ "workflow": self.workflow,
44
+ "task_id": self.task_id,
45
+ "status": self.status,
46
+ "run_id": self.run_id,
47
+ "summary": self.summary,
48
+ "start_operation_id": self.start_operation_id,
49
+ "parent_task_id": self.parent_task_id,
50
+ "project": self.project,
51
+ "workflow_runtime": self.workflow_runtime,
52
+ "model": self.model,
53
+ "reasoning": self.reasoning,
54
+ "agent": self.agent,
55
+ "fields": dict(self.fields),
56
+ }
57
+
58
+ @classmethod
59
+ def from_dict(cls, data: object) -> ChildTask:
60
+ if not isinstance(data, dict):
61
+ raise ValueError("child task must be a mapping")
62
+ required = ("id", "description", "task_id", "status")
63
+ if not all(isinstance(data.get(key), str) and data[key] for key in required):
64
+ raise ValueError("child task fields must be non-empty strings")
65
+ if not isinstance(data.get("workflow"), str):
66
+ raise ValueError("child task workflow must be a string")
67
+ status = expect_literal(data["status"], ChildStatus, "child task status")
68
+ run_id, summary = data.get("run_id"), data.get("summary")
69
+ if run_id is not None and not isinstance(run_id, str):
70
+ raise ValueError("child task run ID must be a string or null")
71
+ if summary is not None and not isinstance(summary, str):
72
+ raise ValueError("child task summary must be a string or null")
73
+ for name in ("start_operation_id", "parent_task_id", "project"):
74
+ if data.get(name) is not None and not isinstance(data[name], str):
75
+ raise ValueError(f"child task {name} must be a string or null")
76
+ for name in ("workflow_runtime", "model", "reasoning", "agent"):
77
+ value = data.get(name)
78
+ if value is not None and (not isinstance(value, str) or not value.strip()):
79
+ raise ValueError(f"child task {name} must be non-empty or null")
80
+ if data.get("workflow_runtime") not in {None, "single", "auto"}:
81
+ raise ValueError("child task workflow_runtime must be single or auto")
82
+ return cls(
83
+ id=data["id"],
84
+ description=data["description"],
85
+ workflow=data["workflow"],
86
+ task_id=data["task_id"],
87
+ status=status,
88
+ run_id=run_id,
89
+ summary=summary,
90
+ start_operation_id=data.get("start_operation_id"),
91
+ parent_task_id=data.get("parent_task_id"),
92
+ project=data.get("project"),
93
+ workflow_runtime=data.get("workflow_runtime"),
94
+ model=data.get("model"),
95
+ reasoning=data.get("reasoning"),
96
+ agent=data.get("agent"),
97
+ fields=validate_item_fields(data.get("fields", {})),
98
+ )
99
+
100
+
101
+ def skip_pending(children: tuple[ChildTask, ...]) -> tuple[ChildTask, ...]:
102
+ """Mark every child that has not started as skipped, after a ``break``."""
103
+ return tuple(
104
+ replace(child, status="skipped") if child.status == "pending" else child
105
+ for child in children
106
+ )