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/child_coordination.py
ADDED
|
@@ -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
|
+
)
|