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/service.py ADDED
@@ -0,0 +1,4405 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Plan-driven workflow lifecycle service."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import json
8
+ import secrets
9
+ import uuid
10
+ from collections.abc import Mapping, Sequence
11
+ from dataclasses import dataclass, replace
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ from ww.action_execution import (
16
+ ActionExecutor,
17
+ )
18
+ from ww.actions import (
19
+ PlannedAction,
20
+ actions,
21
+ )
22
+ from ww.amendments import MAX_AMENDMENT_LENGTH, Amendment, TaskRequirements
23
+ from ww.assessments import pending_assessment
24
+ from ww.assignments import (
25
+ active_assignment,
26
+ assignment_at,
27
+ completion_window,
28
+ completion_window_items,
29
+ )
30
+ from ww.bootstrap import BootstrapCoordinator
31
+ from ww.builtin_workflows import missing_lane, require_lane
32
+ from ww.changes import take_mark
33
+ from ww.child_coordination import ChildCoordinator
34
+ from ww.children import ChildTask, skip_pending
35
+ from ww.completion_artifacts import rule_outcomes, write_completion_artifacts
36
+ from ww.completion_inputs import (
37
+ group_metadata_values,
38
+ validate_requested_values,
39
+ validate_values,
40
+ )
41
+ from ww.config import YamlConfigurationLoader
42
+ from ww.contracts import CALLER_ROLES, CallerRole, run_is_open
43
+ from ww.control import child_workflow, loop_control, workflow_transition
44
+ from ww.defaults import (
45
+ AGENT_INSTRUCTIONS,
46
+ DEFAULT_PROJECT_CONFIG_JSON,
47
+ DEFAULT_WORKFLOWS_YAML,
48
+ PROJECT_LAUNCHER,
49
+ SKILLS,
50
+ skill_location,
51
+ )
52
+ from ww.documents import DocumentStore
53
+ from ww.errors import ConfigurationError, StateError
54
+ from ww.execution_models import (
55
+ PLAN_COMPILER_VERSION,
56
+ PLAN_SCHEMA_VERSION,
57
+ CheckReport,
58
+ Dispute,
59
+ ExecutionState,
60
+ HeldCompletion,
61
+ PlanItemExecution,
62
+ PlanSnapshot,
63
+ RuleResolution,
64
+ TaskRunAggregate,
65
+ initial_state,
66
+ operation_scope_for,
67
+ )
68
+ from ww.extensions import ExtensionRegistry, is_extension_reference, parse_reference
69
+ from ww.feedback import FeedbackStore
70
+ from ww.handler_repairs import close_assignment, needs_repair, request_repair
71
+ from ww.hooks.records import HookRecords, Interruption
72
+ from ww.instructions import Instruction, InstructionBuilder
73
+ from ww.instructions.commands import SUMMARY_FLAG, instruction_command
74
+ from ww.instructions.handoff import handoff_block
75
+ from ww.instructions.models import CheckPreview
76
+ from ww.interactions import InteractionLog, parse_transcript
77
+ from ww.interpolation import dependencies, interpolate
78
+ from ww.item_passes import (
79
+ item_collection,
80
+ leaving_pass,
81
+ pass_gate_failures,
82
+ reports_item_on_completion,
83
+ )
84
+ from ww.items import EDITABLE_WORK_ITEM_FIELDS, WorkItem, validate_item_fields
85
+ from ww.metadata_publication import MetadataPublisher, validate_metadata_values
86
+ from ww.operations import ChildLaunch
87
+ from ww.plan import (
88
+ PlanCompilationOptions,
89
+ PlanItem,
90
+ PlannedCheck,
91
+ WorkflowPlan,
92
+ compile_workflow_plan,
93
+ )
94
+ from ww.project_config import load_project_config
95
+ from ww.recovery import RecoveryCoordinator
96
+ from ww.replanning import PlanChange, plan_change
97
+ from ww.replanning import keep_plan as keep_plan_
98
+ from ww.replanning import replan as replan_
99
+ from ww.results import (
100
+ CleanupResult,
101
+ InitializationResult,
102
+ ItemUpdateResult,
103
+ ResetResult,
104
+ TaskStatus,
105
+ )
106
+ from ww.rule_checks import CheckScope, RuleChecker, change_set, item_reports
107
+ from ww.rule_conversion import SCRIPTIZE_WORKFLOW, scriptize_notice
108
+ from ww.rule_disputes import DisputeEntry, DisputeLog
109
+ from ww.rule_store import RuleStore
110
+ from ww.rule_verification import (
111
+ close_round,
112
+ hold_completion,
113
+ index_of,
114
+ judged_report,
115
+ judged_rules,
116
+ open_verification_round,
117
+ parse_rule_results,
118
+ record_round,
119
+ resolve_rules,
120
+ resume_held,
121
+ round_open,
122
+ skip_idle_verification,
123
+ to_verify,
124
+ verdicts_of,
125
+ verification_needs,
126
+ )
127
+ from ww.rule_views import RuleView, check_preview, rule_view
128
+ from ww.run_coordination import RunCoordinator
129
+ from ww.runtimes import requested_setting, runtime_instruction
130
+ from ww.storage import Storage
131
+ from ww.storage_adapters import (
132
+ CommandOutputAddress,
133
+ ProjectMetadataStorage,
134
+ TaskStorageAdapter,
135
+ )
136
+ from ww.task_ids import (
137
+ candidate_task_ids,
138
+ generated_bootstrap_id,
139
+ is_bootstrap_request,
140
+ task_id_claimed,
141
+ validate_child_id,
142
+ validate_task_id,
143
+ )
144
+ from ww.transitions import (
145
+ advance_completed_item,
146
+ await_item_input,
147
+ begin_agent_item,
148
+ begin_child_workflow,
149
+ block_item_phase,
150
+ complete_agent_item,
151
+ complete_run,
152
+ dispute_check,
153
+ enclosing_loop_entry_index,
154
+ enter_loop,
155
+ exit_exhausted_loop,
156
+ fail_agent_item,
157
+ fail_child_workflow,
158
+ finish_loop_continue,
159
+ finish_loop_exit,
160
+ finish_selection,
161
+ fix_limits,
162
+ loop_limit_reached,
163
+ materialize_child_plan,
164
+ materialize_item_plan,
165
+ pause_for_agent,
166
+ project_steps,
167
+ reject_completion,
168
+ repeat_loop,
169
+ request_loop_continue,
170
+ request_loop_exit,
171
+ retry_failed_item,
172
+ select_assessment_outcome,
173
+ settle_stale_automatic_item,
174
+ skip_failed_item,
175
+ stop_for_values,
176
+ supply_requested_input,
177
+ waive_checks,
178
+ )
179
+ from ww.variables import (
180
+ BRANCH_NAMING_STRATEGY,
181
+ CHILD_FIELD_PREFIX,
182
+ CHILD_VALUE_PREFIX,
183
+ CHOICES,
184
+ DOCUMENTS_PREFIX,
185
+ METADATA_PREFIX,
186
+ PROJECT,
187
+ PROJECT_METADATA_PREFIX,
188
+ child_value_name,
189
+ child_values,
190
+ item_binding_values,
191
+ runtime_variable_values,
192
+ unavailable_ww_values,
193
+ )
194
+ from ww.workflow_config import (
195
+ INIT_STEP_NAME,
196
+ ConfigurationLoader,
197
+ DocumentDefinition,
198
+ WorkflowConfiguration,
199
+ WorkflowDefinition,
200
+ )
201
+ from ww.workflow_validation import validate_configuration
202
+ from ww.workspace import relative_workspace, resolve_workspace
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class CompletionSelection:
207
+ """Normalized execution selection recorded for a completed agent item."""
208
+
209
+ selected_agent: str | None
210
+ selected_model: str | None
211
+ selected_reasoning: str | None
212
+ clear_selected_model: bool
213
+ clear_selected_reasoning: bool
214
+
215
+
216
+ def _normalize_completion_selection(
217
+ selected_agent: str | None,
218
+ selected_model: str | None,
219
+ selected_reasoning: str | None,
220
+ previous_selected_agent: str | None,
221
+ previous_selected_model: str | None,
222
+ ) -> CompletionSelection:
223
+ """Apply completion-time selection reset and inheritance policy."""
224
+ agent_changed = (
225
+ selected_agent is not None and selected_agent != previous_selected_agent
226
+ )
227
+ model_changed = selected_model not in {None, "auto", previous_selected_model}
228
+ return CompletionSelection(
229
+ selected_agent=selected_agent,
230
+ selected_model=None if selected_model == "auto" else selected_model,
231
+ selected_reasoning=(
232
+ None if selected_reasoning == "auto" else selected_reasoning
233
+ ),
234
+ clear_selected_model=selected_model == "auto"
235
+ or (agent_changed and selected_model is None),
236
+ clear_selected_reasoning=selected_reasoning == "auto"
237
+ or (agent_changed and selected_reasoning is None)
238
+ or (model_changed and selected_reasoning is None),
239
+ )
240
+
241
+
242
+ # The longest ``--summary``: one or two short sentences, since
243
+ # the detail belongs in the artifact and the summary reaches the manager.
244
+ SUMMARY_LIMIT = 500
245
+
246
+
247
+ @dataclass(frozen=True)
248
+ class OpenAssignment:
249
+ """A worker's open assignment as a worker command found it.
250
+
251
+ ``items`` are the ids of its agent items, taken before the command runs,
252
+ so the handoff can still name them after the assignment has ended.
253
+ """
254
+
255
+ token: str
256
+ items: tuple[str, ...]
257
+ active: str | None = None
258
+
259
+
260
+ FORCE_NOT_APPLICABLE = (
261
+ "cannot force a task that is not failed or interrupted and is not stopped "
262
+ "at a loop limit"
263
+ )
264
+ # Forcing would skip the step after the pass, not the pass's missing records.
265
+ FORCE_PAST_PASS_GATE = (
266
+ "an items pass gate cannot be forced: record what each item lacks with "
267
+ "update-item, then run next --retry"
268
+ )
269
+
270
+
271
+ class WorkflowService:
272
+ """Advance exactly the immutable plan snapshot persisted for each task."""
273
+
274
+ def __init__(
275
+ self,
276
+ storage: Storage,
277
+ task_persistence: TaskStorageAdapter | None = None,
278
+ extensions: ExtensionRegistry | None = None,
279
+ configuration_loader: ConfigurationLoader | None = None,
280
+ project_metadata: ProjectMetadataStorage | None = None,
281
+ feedback_store: FeedbackStore | None = None,
282
+ ) -> None:
283
+ self.storage = storage
284
+ self.tasks = task_persistence or storage.task_persistence
285
+ self.project_metadata_store = project_metadata or storage.project_metadata
286
+ self.extensions = extensions or ExtensionRegistry.discover(storage.root)
287
+ self.configuration_loader = configuration_loader or YamlConfigurationLoader(
288
+ storage.config_path, self.extensions
289
+ )
290
+ self.documents = DocumentStore(self.storage)
291
+ self.interactions = InteractionLog(self.storage)
292
+ self.feedback = feedback_store or FeedbackStore(self.storage)
293
+ self.hook_records = HookRecords(self.storage, self.tasks)
294
+ self.rule_store = RuleStore(self.storage.root)
295
+ self.rule_disputes = DisputeLog(self.storage.root)
296
+ self.instructions = InstructionBuilder(
297
+ self.tasks,
298
+ self._runtime_values,
299
+ child_values=self._child_values,
300
+ item_values=self._item_values,
301
+ worker_requirements=lambda: (
302
+ load_project_config(
303
+ self.storage.project_config_path
304
+ ).pages.worker_requirements
305
+ ),
306
+ root=self.storage.root,
307
+ documents=self.documents,
308
+ interactions=self.interactions,
309
+ )
310
+ self.runs = RunCoordinator(self.tasks)
311
+ self.metadata_publisher = MetadataPublisher(
312
+ self.tasks, self.project_metadata_store, self
313
+ )
314
+ self.bootstrap = BootstrapCoordinator(
315
+ self.storage,
316
+ self.tasks,
317
+ self.extensions,
318
+ now=_now,
319
+ generated_request_id=generated_bootstrap_id,
320
+ validate_task_id=validate_task_id,
321
+ task_exists=self._task_exists,
322
+ )
323
+ self.actions = ActionExecutor(
324
+ root=self.storage.root,
325
+ extensions=self.extensions,
326
+ commit=self.commit,
327
+ project_state=self._with_steps,
328
+ now=_now,
329
+ write_command_output=self.tasks.write_command_output,
330
+ read_command_output=self.tasks.read_command_output,
331
+ task_values=self._runtime_values,
332
+ metadata_publisher=self.metadata_publisher,
333
+ child_values=self._child_values,
334
+ item_values=self._item_values,
335
+ read_items=lambda state: self.tasks.read_items(state.task_id, state.run_id),
336
+ commit_items=self._commit_items,
337
+ )
338
+ self.recovery = RecoveryCoordinator(self.tasks, self.actions, self, _now)
339
+ self.rule_checker = RuleChecker(self.tasks.write_command_output, _now)
340
+ self.children = ChildCoordinator(
341
+ self.tasks,
342
+ self,
343
+ self._start,
344
+ _now,
345
+ self._validate_child_workflow,
346
+ self._start_child_identity,
347
+ )
348
+
349
+ def start(
350
+ self,
351
+ workflow_name: str,
352
+ task_id: str | None,
353
+ mode_names: tuple[str, ...] = (),
354
+ agent: str = "codex",
355
+ model: str = "auto",
356
+ reasoning: str = "auto",
357
+ workflow_runtime: str | None = None,
358
+ init_artifact: str | None = "Recorded requirements.",
359
+ *,
360
+ caller_role: CallerRole | None = None,
361
+ branch_naming_strategy: str | None = None,
362
+ project: str | None = None,
363
+ fresh_items: bool = False,
364
+ ) -> Instruction:
365
+ self._require_manager("start", caller_role)
366
+ # ``"on_request"`` allows it: an agent starts a task only when asked.
367
+ if self.extensions.config.disabled:
368
+ raise StateError(
369
+ "ww is disabled for this project (ww.json has "
370
+ '"enabled": false); do not use ww for this work'
371
+ )
372
+ if not agent:
373
+ raise StateError("start requires --agent")
374
+ if init_artifact is None or not init_artifact.strip():
375
+ raise StateError("start requires a non-empty --requirements")
376
+ self._validate_execution_metadata(model, reasoning)
377
+ if workflow_runtime is None:
378
+ # The flag outranks the workflow's own runtime, which outranks the
379
+ # project default.
380
+ declared = self._load_configuration().workflows_by_name.get(workflow_name)
381
+ workflow_runtime = (
382
+ declared.runtime if declared is not None and declared.runtime else None
383
+ ) or self.extensions.config.runtime
384
+ runtime_instruction(workflow_runtime)
385
+ if branch_naming_strategy is not None and not branch_naming_strategy.strip():
386
+ raise StateError("start --branch-strategy must be non-empty")
387
+ if project is not None:
388
+ self._project_directory(project)
389
+ if task_id is None:
390
+ bootstrap = self.bootstrap.step(
391
+ self._load_configuration(),
392
+ workflow_name,
393
+ mode_names,
394
+ agent,
395
+ self._unknown_modes,
396
+ project=project,
397
+ )
398
+ if bootstrap is not None:
399
+ instruction = replace(
400
+ self._tag_caller(
401
+ self.bootstrap.start(
402
+ workflow_name,
403
+ mode_names,
404
+ agent,
405
+ bootstrap,
406
+ model,
407
+ reasoning,
408
+ workflow_runtime,
409
+ branch_naming_strategy,
410
+ init_artifact,
411
+ project,
412
+ ),
413
+ caller_role,
414
+ ),
415
+ manager_intro=True,
416
+ )
417
+ return self._with_rules_notice(instruction, workflow_name)
418
+ instruction = replace(
419
+ self._tag_caller(
420
+ self._start_generated(
421
+ workflow_name,
422
+ mode_names,
423
+ agent,
424
+ model,
425
+ reasoning,
426
+ workflow_runtime,
427
+ branch_naming_strategy,
428
+ init_artifact,
429
+ project,
430
+ ),
431
+ caller_role,
432
+ ),
433
+ manager_intro=True,
434
+ )
435
+ return self._with_rules_notice(instruction, workflow_name)
436
+ validate_task_id(task_id)
437
+ with self.tasks.lock_task(task_id):
438
+ instruction = replace(
439
+ self._tag_caller(
440
+ self._start(
441
+ task_id,
442
+ workflow_name,
443
+ mode_names,
444
+ agent,
445
+ model=model,
446
+ reasoning=reasoning,
447
+ workflow_runtime=workflow_runtime,
448
+ init_artifact=init_artifact,
449
+ branch_naming_strategy=branch_naming_strategy,
450
+ project=project,
451
+ fresh_items=fresh_items,
452
+ ),
453
+ caller_role,
454
+ ),
455
+ manager_intro=True,
456
+ )
457
+ return self._with_rules_notice(instruction, workflow_name)
458
+
459
+ def _with_rules_notice(
460
+ self, instruction: Instruction, workflow_name: str
461
+ ) -> Instruction:
462
+ """The first page of a start, with the notice of rules without a check.
463
+
464
+ ``ww-scriptize-rules`` itself is the answer to the notice, so its
465
+ pages go without it. The task has started by now: a store that cannot
466
+ be read drops the notice rather than failing the start.
467
+ """
468
+ if workflow_name == SCRIPTIZE_WORKFLOW:
469
+ return instruction
470
+ try:
471
+ automation = self.rule_store.load()
472
+ except StateError:
473
+ return instruction
474
+ return replace(
475
+ instruction,
476
+ rules_notice=scriptize_notice(self._load_configuration(), automation),
477
+ )
478
+
479
+ def _project_path(self, project: str | None) -> str | None:
480
+ """The configured project's directory, without checking it exists."""
481
+ if project is None:
482
+ return None
483
+ definition = self.extensions.config.projects_by_name.get(project)
484
+ if definition is None:
485
+ return None
486
+ return str(definition.directory(self.storage.root))
487
+
488
+ def _project_directory(self, project: str | None) -> str | None:
489
+ """Resolve a configured project to the directory its tasks work in.
490
+
491
+ The result is persisted, so it is relative to the project root; see
492
+ :mod:`ww.workspace`.
493
+ """
494
+ if project is None:
495
+ return None
496
+ path = self._project_path(project)
497
+ if path is None:
498
+ raise StateError(self.extensions.config.unknown_project(project))
499
+ directory = Path(path)
500
+ if not directory.is_dir():
501
+ raise StateError(
502
+ f"project {project!r} directory does not exist: {directory}"
503
+ )
504
+ return relative_workspace(self.storage.root, directory)
505
+
506
+ def _start_generated(
507
+ self,
508
+ workflow_name: str,
509
+ mode_names: tuple[str, ...],
510
+ agent: str,
511
+ model: str,
512
+ reasoning: str,
513
+ workflow_runtime: str,
514
+ branch_naming_strategy: str | None,
515
+ init_artifact: str,
516
+ project: str | None = None,
517
+ ) -> Instruction:
518
+ """Start under the first generated ID that is free.
519
+
520
+ Each candidate is locked before it is checked, and released again if
521
+ it turns out to be taken.
522
+ """
523
+ task_format = self.extensions.task_format(project)
524
+ for candidate in candidate_task_ids(task_format):
525
+ validate_task_id(candidate)
526
+ with self.tasks.lock_task(candidate):
527
+ if not self._task_exists(candidate, workflow_name, project):
528
+ return self._start(
529
+ candidate,
530
+ workflow_name,
531
+ mode_names,
532
+ agent,
533
+ model=model,
534
+ reasoning=reasoning,
535
+ workflow_runtime=workflow_runtime,
536
+ init_artifact=init_artifact,
537
+ branch_naming_strategy=branch_naming_strategy,
538
+ project=project,
539
+ )
540
+ raise StateError(
541
+ f"cannot generate an unused task ID from task format {task_format!r}"
542
+ )
543
+
544
+ def _start(
545
+ self,
546
+ task_id: str,
547
+ workflow_name: str,
548
+ mode_names: tuple[str, ...],
549
+ agent: str,
550
+ bootstrap_step: str | None = None,
551
+ bootstrap_values: tuple[tuple[str, str], ...] = (),
552
+ bootstrap_request_id: str | None = None,
553
+ parent_task_id: str | None = None,
554
+ start_operation_id: str | None = None,
555
+ model: str = "auto",
556
+ reasoning: str = "auto",
557
+ workflow_runtime: str = "single",
558
+ branch_naming_strategy: str | None = None,
559
+ init_artifact: str = "",
560
+ project: str | None = None,
561
+ fresh_items: bool = False,
562
+ ) -> Instruction:
563
+ configuration = self._load_configuration()
564
+ working_directory = self._project_directory(project)
565
+ if workflow_name not in configuration.workflows_by_name:
566
+ raise ConfigurationError(f"workflow not found: {workflow_name}")
567
+ workflow = configuration.workflows_by_name[workflow_name]
568
+ require_lane(workflow)
569
+ unknown_modes = self._unknown_modes(mode_names, configuration)
570
+ if unknown_modes:
571
+ raise StateError("unknown mode(s): " + ", ".join(sorted(unknown_modes)))
572
+ plan = compile_workflow_plan(
573
+ configuration,
574
+ self.storage.root,
575
+ workflow_name,
576
+ agent,
577
+ task_id,
578
+ self.extensions,
579
+ PlanCompilationOptions(
580
+ task_id=task_id,
581
+ completed_bootstrap_step=bootstrap_step,
582
+ project=project,
583
+ modes=mode_names or None,
584
+ ),
585
+ self.extensions.config,
586
+ )
587
+ snapshot = PlanSnapshot(
588
+ schema_version=PLAN_SCHEMA_VERSION,
589
+ compiler_version=PLAN_COMPILER_VERSION,
590
+ configuration_digest=self._configuration_digest(configuration),
591
+ compiled_at=_now(),
592
+ plan=plan,
593
+ bootstrap_step=bootstrap_step,
594
+ )
595
+ if not snapshot.plan.items:
596
+ raise ConfigurationError(
597
+ f"workflow {workflow_name!r} has no executable plan items"
598
+ )
599
+ self._abandon_for_restart(task_id, workflow)
600
+ run_id = self.tasks.next_execution_run_id(task_id, workflow_name)
601
+ workflow_values = dict(bootstrap_values or (("task_id", task_id),))
602
+ if branch_naming_strategy is not None:
603
+ workflow_values[BRANCH_NAMING_STRATEGY] = branch_naming_strategy
604
+ if project is not None:
605
+ workflow_values[PROJECT] = project
606
+ state = replace(
607
+ initial_state(
608
+ snapshot,
609
+ tuple(mode_names or workflow.modes),
610
+ _now(),
611
+ run_id=run_id,
612
+ execution_instance_id=uuid.uuid4().hex,
613
+ parent_task_id=parent_task_id,
614
+ start_operation_id=start_operation_id,
615
+ workflow_runtime=workflow_runtime,
616
+ model=model,
617
+ reasoning=reasoning,
618
+ ),
619
+ run_id=run_id,
620
+ workflow_values=tuple(workflow_values.items()),
621
+ working_directory=working_directory,
622
+ pending_init_artifact=init_artifact,
623
+ )
624
+ if fresh_items:
625
+ self.tasks.write_shared_items(task_id, ())
626
+ self.commit(
627
+ state,
628
+ snapshot,
629
+ items=self._seed_shared_items(task_id, plan),
630
+ bootstrap_request_id=bootstrap_request_id,
631
+ )
632
+ state, snapshot = self._complete_initialization(state, snapshot)
633
+ return self.render(state, snapshot)
634
+
635
+ def _require_item_fields(
636
+ self, task_id: str, state: ExecutionState, item: PlanItem
637
+ ) -> None:
638
+ """A step's declared item fields must be set before it completes."""
639
+ items = self.tasks.read_items(task_id, state.run_id)
640
+ if item.item_id is not None:
641
+ items = tuple(entry for entry in items if entry.id == item.item_id)
642
+ missing = [
643
+ f"{field.name} on {entry.id}"
644
+ for entry in items
645
+ for field in item.update_item
646
+ if not entry.field(field.name)
647
+ ]
648
+ if missing:
649
+ raise StateError(
650
+ f"{item.name!r} sets item fields that are still empty: "
651
+ + ", ".join(missing)
652
+ + "; set them with update-item --field NAME=VALUE, then complete"
653
+ )
654
+
655
+ def _abandon_for_restart(self, task_id: str, workflow: WorkflowDefinition) -> None:
656
+ """Close an unfinished run of a restartable workflow before a new start.
657
+
658
+ The run stays in the task's history with everything it recorded; it
659
+ is only no longer the open run. An unfinished run of another
660
+ workflow is never abandoned this way.
661
+ """
662
+ runs, _handoff, _revision = self.tasks.read_task_record(task_id)
663
+ active = next(
664
+ (run for run in reversed(runs) if run_is_open(run.state.status)), None
665
+ )
666
+ if active is None:
667
+ return
668
+ if not workflow.restartable or active.workflow != workflow.name:
669
+ raise StateError(
670
+ f"task {task_id!r} already has an unfinished run {active.run_id!r} "
671
+ f"of workflow {active.workflow!r}; continue it with "
672
+ f"{instruction_command(task_id, role='manager')}. Only the "
673
+ "operator decides to reset the task instead, or to declare the "
674
+ "workflow restartable so a new start abandons its earlier run"
675
+ )
676
+ self.commit(
677
+ replace(
678
+ active.state,
679
+ status="abandoned",
680
+ last_error=f"abandoned by a new start of {workflow.name!r}",
681
+ updated_at=_now(),
682
+ ),
683
+ active.snapshot,
684
+ )
685
+
686
+ def _seed_shared_items(
687
+ self, task_id: str, plan: WorkflowPlan
688
+ ) -> tuple[WorkItem, ...] | None:
689
+ """A new run of a shared item flow starts from the task's items.
690
+
691
+ Identity, text, and references carry over; the outcome fields start
692
+ clear, because every run is a new round over the same items.
693
+ """
694
+ collection = item_collection(plan)
695
+ if collection is None or not collection.shared_items:
696
+ return None
697
+ return tuple(
698
+ WorkItem(
699
+ item.id,
700
+ item.item,
701
+ reference_to_id=item.reference_to_id,
702
+ fields=item.fields,
703
+ )
704
+ for item in self.tasks.read_shared_items(task_id)
705
+ )
706
+
707
+ def _share_items(
708
+ self, task_id: str, plan: WorkflowPlan, items: tuple[WorkItem, ...]
709
+ ) -> None:
710
+ """Refresh the task's shared items from the run's copy, when shared."""
711
+ collection = item_collection(plan)
712
+ if collection is not None and collection.shared_items:
713
+ self.tasks.write_shared_items(task_id, items)
714
+
715
+ def commit(
716
+ self,
717
+ state: ExecutionState,
718
+ snapshot: PlanSnapshot,
719
+ *,
720
+ items: tuple[WorkItem, ...] | None = None,
721
+ children: tuple[ChildTask, ...] | None = None,
722
+ handoff: str | None = None,
723
+ bootstrap_request_id: str | None = None,
724
+ ) -> None:
725
+ """Commit the complete run transition through the storage-adapter boundary."""
726
+ self.runs.commit_run(
727
+ state,
728
+ snapshot,
729
+ items=items,
730
+ children=children,
731
+ handoff=handoff,
732
+ bootstrap_request_id=bootstrap_request_id,
733
+ )
734
+ if (
735
+ state.status == "completed"
736
+ and load_project_config(self.storage.project_config_path).feedback_learning
737
+ ):
738
+ self.feedback.complete_task(state.task_id)
739
+
740
+ def _reported_items(
741
+ self, task_id: str, state: ExecutionState, item: PlanItem, reports: bool
742
+ ) -> tuple[WorkItem, ...] | None:
743
+ """The items with ``item``'s own item marked reported, if it reports.
744
+
745
+ The mark is committed with the completion that finishes the report
746
+ stage, so an item is never reported before that completion is durable.
747
+ """
748
+ if not reports or item.item_id is None:
749
+ return None
750
+ return tuple(
751
+ replace(entry, reported=True) if entry.id == item.item_id else entry
752
+ for entry in self.tasks.read_items(task_id, state.run_id)
753
+ )
754
+
755
+ def _commit_items(
756
+ self,
757
+ state: ExecutionState,
758
+ snapshot: PlanSnapshot,
759
+ items: tuple[WorkItem, ...],
760
+ ) -> None:
761
+ """Commit a transition together with the item records it changed."""
762
+ self.commit(state, snapshot, items=items)
763
+ self._share_items(state.task_id, snapshot.plan, items)
764
+
765
+ def feedback_sources(
766
+ self,
767
+ task_id: str,
768
+ run_id: str | None = None,
769
+ ) -> dict[str, object]:
770
+ """Expose completed learnable artifacts through the storage boundary."""
771
+ state, snapshot = self.load(task_id, run_id)
772
+ if state.status != "completed":
773
+ raise StateError("feedback deduction requires a completed workflow run")
774
+ sources = []
775
+ items = {item.id: item for item in snapshot.plan.items}
776
+ seen: set[str] = set()
777
+ for record in (*state.execution_history, *state.item_executions):
778
+ item = items.get(record.plan_item_id)
779
+ if (
780
+ item is None
781
+ or not item.learnable
782
+ or record.status != "completed"
783
+ or not record.artifact
784
+ or record.artifact in seen
785
+ ):
786
+ continue
787
+ seen.add(record.artifact)
788
+ source_id = (
789
+ "artifact-" + hashlib.sha256(record.artifact.encode()).hexdigest()[:16]
790
+ )
791
+ sources.append(
792
+ {
793
+ "id": source_id,
794
+ "step": item.step,
795
+ "artifact": record.artifact,
796
+ "encountered_at": record.completed_at or state.updated_at,
797
+ "content": self.tasks.read_execution_artifact(record.artifact),
798
+ }
799
+ )
800
+ return {"task": task_id, "run": state.run_id, "sources": sources}
801
+
802
+ def record_feedback(
803
+ self,
804
+ task_id: str,
805
+ analysis: object,
806
+ *,
807
+ run_id: str | None = None,
808
+ caller_role: CallerRole | None = None,
809
+ assignment: str | None = None,
810
+ ) -> dict[str, object]:
811
+ """Record deductions from a finished run, without modifying its plan/state."""
812
+ self._validate_caller_role(caller_role)
813
+ validate_task_id(task_id)
814
+ if not load_project_config(self.storage.project_config_path).feedback_learning:
815
+ raise StateError("feedback learning is disabled in ww.json")
816
+ with self.tasks.lock_task(task_id):
817
+ self._authorize_worker(task_id, caller_role, assignment)
818
+ sources = self.feedback_sources(task_id, run_id)
819
+ state, _ = self.load(task_id, run_id)
820
+ return self.feedback.record(
821
+ task_id, state.run_id, analysis, sources["sources"]
822
+ )
823
+
824
+ def _commit_runs(
825
+ self,
826
+ task_id: str,
827
+ aggregates: tuple[TaskRunAggregate, ...],
828
+ *,
829
+ handoff: str | None = None,
830
+ ) -> None:
831
+ self.runs.commit_runs(task_id, aggregates, handoff=handoff)
832
+
833
+ def next(
834
+ self,
835
+ task_id: str,
836
+ model: str = "auto",
837
+ reasoning: str = "auto",
838
+ force: bool = False,
839
+ retry: bool = False,
840
+ force_reason: str | None = None,
841
+ outcome: str | None = None,
842
+ selected_agent: str | None = None,
843
+ *,
844
+ caller_role: CallerRole | None = None,
845
+ reassign: bool = False,
846
+ replan: bool = False,
847
+ keep_plan: bool = False,
848
+ ) -> Instruction:
849
+ """Advance the task.
850
+
851
+ ``reassign`` gives the open assignment a new token, so its previous
852
+ worker can no longer act, and returns the page to dispatch it again.
853
+ When the workflow's definition changed since the run's plan was saved,
854
+ ``next`` stops at a ``plan_changed`` page first; ``replan`` takes the
855
+ new definition from the first changed item on, ``keep_plan`` carries
856
+ on with the saved plan, and either then advances as usual.
857
+ """
858
+ self._require_manager("next", caller_role)
859
+ if (replan or keep_plan) and (
860
+ replan == keep_plan or force or retry or outcome or reassign
861
+ ):
862
+ raise StateError(
863
+ "choose one of next --replan or --keep-plan, without other decisions"
864
+ )
865
+ if reassign:
866
+ if force or retry or outcome:
867
+ raise StateError("next --reassign takes no other decision")
868
+ return self._tag_caller(self._reassign(task_id), caller_role)
869
+ if force and (force_reason is None or not force_reason.strip()):
870
+ raise StateError("next --force requires --reason")
871
+ if force_reason is not None and not force:
872
+ raise StateError("--reason requires next --force")
873
+ if retry and force:
874
+ raise StateError("choose only one of next --retry or next --force")
875
+ self._validate_execution_metadata(model, reasoning)
876
+ self._validate_selected_agent(selected_agent)
877
+ refreshed = self.children.refresh_parent(task_id)
878
+ if refreshed is not None:
879
+ return self._tag_caller(refreshed, caller_role)
880
+ if not is_bootstrap_request(task_id):
881
+ stop = self._plan_gate(task_id, replan=replan, keep_plan=keep_plan)
882
+ if stop is not None:
883
+ return self._tag_caller(stop, caller_role)
884
+ instruction = self._next_command(
885
+ task_id,
886
+ model,
887
+ reasoning,
888
+ force,
889
+ retry,
890
+ force_reason,
891
+ outcome,
892
+ selected_agent=selected_agent,
893
+ caller_role=caller_role,
894
+ )
895
+ self.children.reconcile_after_child(task_id)
896
+ instruction = self._start_declared_child(task_id, instruction)
897
+ return self._tag_caller(instruction, caller_role)
898
+
899
+ def _start_declared_child(
900
+ self, task_id: str, instruction: Instruction
901
+ ) -> Instruction:
902
+ """Start the child a ``start_child`` stage declares, once it is reached.
903
+
904
+ The launch goes through ``start-child`` itself, so validation and
905
+ recording are the same as for a manual start. It runs after the
906
+ parent's lock is released because starting a child locks the parent.
907
+ A launch that cannot proceed fails the stage like any automatic
908
+ handler: the operator reads the cause and retries or replans.
909
+ """
910
+ if is_bootstrap_request(task_id) or "/" in task_id:
911
+ return instruction
912
+ state, snapshot = self.load(task_id)
913
+ if state.cursor >= len(snapshot.plan.items):
914
+ return instruction
915
+ item = snapshot.plan.items[state.cursor]
916
+ coordinator = child_workflow(item)
917
+ if (
918
+ coordinator is None
919
+ or coordinator.launch is None
920
+ or item.child_number is None
921
+ or state.status != "in_progress"
922
+ or state.item_executions[state.cursor].status != "in_progress"
923
+ ):
924
+ return instruction
925
+ children = self.tasks.read_children(state.task_id, state.run_id)
926
+ child = children[item.child_number - 1]
927
+ if child.status not in {"pending", "starting"}:
928
+ return instruction
929
+ # A start already begun keeps its frozen settings.
930
+ settings = (
931
+ {}
932
+ if child.status == "starting"
933
+ else self._launch_settings(
934
+ coordinator.launch, self._child_values(state, snapshot.plan, item)
935
+ )
936
+ )
937
+ try:
938
+ started = self.children.start_child(state.task_id, child.id, **settings)
939
+ except (StateError, ConfigurationError) as error:
940
+ with self.tasks.lock_task(state.task_id):
941
+ state, snapshot = self.load(state.task_id)
942
+ failed = fail_child_workflow(
943
+ state,
944
+ snapshot.plan,
945
+ item,
946
+ child.id,
947
+ _now,
948
+ f"ww could not start child {child.id!r} for step "
949
+ f"{item.name!r}: {error}",
950
+ )
951
+ self.commit(failed, snapshot)
952
+ return self.render(failed, snapshot)
953
+ return replace(
954
+ started,
955
+ notices=(
956
+ *started.notices,
957
+ f"ww started child `{child.id}` for step `{item.name}` of "
958
+ f"`{state.task_id}` with the launch settings recorded on the child.",
959
+ ),
960
+ )
961
+
962
+ @staticmethod
963
+ def _launch_settings(
964
+ launch: ChildLaunch, values: Mapping[str, str]
965
+ ) -> dict[str, str]:
966
+ """The ``start-child`` options a launch renders to from the child's record.
967
+
968
+ A setting that is absent, names a field the child lacks, or renders
969
+ empty is left out, so it inherits.
970
+ """
971
+ settings: dict[str, str] = {}
972
+ for name, template in (
973
+ ("workflow_name", launch.workflow),
974
+ ("workflow_runtime", launch.runtime),
975
+ ("model", launch.model),
976
+ ("reasoning", launch.reasoning),
977
+ ("agent", launch.agent),
978
+ ):
979
+ if template is None:
980
+ continue
981
+ bound = {name: "" for name in dependencies(template)} | dict(values)
982
+ rendered = interpolate(template, bound).strip()
983
+ if rendered:
984
+ settings[name] = rendered
985
+ return settings
986
+
987
+ def _next_command(
988
+ self,
989
+ task_id: str,
990
+ model: str = "auto",
991
+ reasoning: str = "auto",
992
+ force: bool = False,
993
+ retry: bool = False,
994
+ force_reason: str | None = None,
995
+ outcome: str | None = None,
996
+ selected_agent: str | None = None,
997
+ *,
998
+ caller_role: CallerRole | None = None,
999
+ ) -> Instruction:
1000
+ self._validate_execution_metadata(model, reasoning)
1001
+ request = (
1002
+ self.storage.read_bootstrap(task_id)
1003
+ if is_bootstrap_request(task_id)
1004
+ else None
1005
+ )
1006
+ if request is not None:
1007
+ return self.bootstrap.next(
1008
+ request,
1009
+ force,
1010
+ selected_agent=selected_agent,
1011
+ selected_model=None if model == "auto" else model,
1012
+ selected_reasoning=None if reasoning == "auto" else reasoning,
1013
+ start_task=self._start,
1014
+ status_task=self.instruction_status,
1015
+ bind_child=self.children.bind_child,
1016
+ )
1017
+ if retry:
1018
+ validate_task_id(task_id)
1019
+ return self.recovery.recover(task_id, retry=True)
1020
+ validate_task_id(task_id)
1021
+ with self.tasks.lock_task(task_id):
1022
+ return self._next(
1023
+ task_id,
1024
+ force,
1025
+ force_reason,
1026
+ outcome,
1027
+ model,
1028
+ reasoning,
1029
+ selected_agent=selected_agent,
1030
+ caller_role=caller_role,
1031
+ )
1032
+
1033
+ def recover(
1034
+ self,
1035
+ task_id: str,
1036
+ *,
1037
+ retry: bool = False,
1038
+ mark_succeeded: bool = False,
1039
+ output: str | None = None,
1040
+ working_directory: str | None = None,
1041
+ variables: tuple[tuple[str, str], ...] = (),
1042
+ caller_role: CallerRole | None = None,
1043
+ ) -> Instruction:
1044
+ self._require_manager("recover", caller_role)
1045
+ instruction = self.recovery.recover(
1046
+ task_id,
1047
+ retry=retry,
1048
+ mark_succeeded=mark_succeeded,
1049
+ output=output,
1050
+ working_directory=working_directory,
1051
+ variables=variables,
1052
+ )
1053
+ self.children.reconcile_after_child(task_id)
1054
+ return self._tag_caller(instruction, caller_role)
1055
+
1056
+ def _next(
1057
+ self,
1058
+ task_id: str,
1059
+ force: bool = False,
1060
+ force_reason: str | None = None,
1061
+ outcome: str | None = None,
1062
+ model: str = "auto",
1063
+ reasoning: str = "auto",
1064
+ selected_agent: str | None = None,
1065
+ *,
1066
+ caller_role: CallerRole | None = None,
1067
+ ) -> Instruction:
1068
+ state, snapshot = self.load(task_id)
1069
+ state = select_assessment_outcome(state, snapshot.plan, outcome, _now)
1070
+ if outcome is not None:
1071
+ self.commit(state, snapshot)
1072
+ state, snapshot = self.metadata_publisher.reconcile(state, snapshot)
1073
+ starting_instance = state.execution_instance_id
1074
+ if state.status == "completed":
1075
+ raise StateError(f"task {task_id!r} is already completed")
1076
+ if state.status == "abandoned":
1077
+ raise StateError(
1078
+ f"run {state.run_id!r} of task {task_id!r} was abandoned by a later "
1079
+ "start; work on the current run"
1080
+ )
1081
+ if state.status == "interrupted":
1082
+ if force:
1083
+ if state.cursor >= len(snapshot.plan.items):
1084
+ raise StateError(
1085
+ "interrupted task has no current item to force past"
1086
+ )
1087
+ state = skip_failed_item(state, snapshot.plan, _now, force_reason)
1088
+ self.commit(state, snapshot)
1089
+ else:
1090
+ # Never replay an operation with an unknown external outcome as
1091
+ # an incidental consequence of asking for the next instruction,
1092
+ # unless its handler declared the replay harmless. Otherwise
1093
+ # use ``next --retry`` or explicitly force past it.
1094
+ replayed = self.recovery.replay_if_idempotent(state, snapshot)
1095
+ if replayed is None:
1096
+ return self.render(state, snapshot)
1097
+ return self.resume(replayed, snapshot)
1098
+ if state.status == "failed":
1099
+ if force and state.failure_kind == "pass_incomplete":
1100
+ raise StateError(FORCE_PAST_PASS_GATE)
1101
+ if (
1102
+ force
1103
+ and state.failure_kind in {"fix_limit", "check_disputed"}
1104
+ and not needs_repair(state)
1105
+ ):
1106
+ # Forcing past a step at its fix limit completes it without
1107
+ # its checks, and past a dispute without the disputed one;
1108
+ # never without its work.
1109
+ assert force_reason is not None
1110
+ state = waive_checks(
1111
+ state,
1112
+ snapshot.plan,
1113
+ self._waivable(state, snapshot.plan),
1114
+ force_reason,
1115
+ _now,
1116
+ )
1117
+ self.commit(state, snapshot)
1118
+ elif force:
1119
+ if state.cursor >= len(snapshot.plan.items):
1120
+ raise StateError("failed task has no current item to force past")
1121
+ state = skip_failed_item(state, snapshot.plan, _now, force_reason)
1122
+ self.commit(state, snapshot)
1123
+ else:
1124
+ state = retry_failed_item(state, snapshot.plan, _now)
1125
+ self.commit(state, snapshot)
1126
+ elif force:
1127
+ if not self._at_loop_limit(state, snapshot):
1128
+ raise StateError(FORCE_NOT_APPLICABLE)
1129
+ state = exit_exhausted_loop(state, snapshot.plan, _now, force_reason)
1130
+ self.commit(state, snapshot)
1131
+ if state.status == "awaiting_input":
1132
+ return self.render(state, snapshot)
1133
+ if needs_repair(state):
1134
+ return self._dispatch_repair(
1135
+ state, snapshot, model, reasoning, selected_agent
1136
+ )
1137
+ if state.active_item_id:
1138
+ if child_workflow(snapshot.plan.items[state.cursor]) is not None:
1139
+ return self.render(state, snapshot)
1140
+ item = snapshot.plan.items[state.cursor]
1141
+ record = state.item_executions[state.cursor]
1142
+ if (
1143
+ item.owner == "ww"
1144
+ and item.execution == "automatic"
1145
+ and record.status == "in_progress"
1146
+ ):
1147
+ state = self.mark_interrupted(state, snapshot)
1148
+ replayed = self.recovery.replay_if_idempotent(state, snapshot)
1149
+ if replayed is not None:
1150
+ return self.resume(replayed, snapshot)
1151
+ return self.render(state, snapshot)
1152
+ if state.workflow_runtime == "single":
1153
+ # The same session holds every step: asking again for the one
1154
+ # already open is harmless, so show it instead of refusing.
1155
+ return self.render(state, snapshot)
1156
+ raise StateError("an agent item is already in progress; use complete")
1157
+ # One manager call carries through every coordinator boundary it
1158
+ # meets, so preparation hooks before a loop, or a loop nested in
1159
+ # another, never cost the manager a second `next` before the first
1160
+ # worker step can start. Each pass enters or repeats at most one
1161
+ # boundary, so the plan length bounds the passes.
1162
+ for _ in range(len(snapshot.plan.items) + 1):
1163
+ if state.cursor < len(snapshot.plan.items):
1164
+ boundary = snapshot.plan.items[state.cursor]
1165
+ loop = loop_control(boundary)
1166
+ if loop is not None:
1167
+ if loop_limit_reached(state, boundary):
1168
+ # The escalation instruction names the operator's
1169
+ # exit, ``next --force``; nothing else may start
1170
+ # another round.
1171
+ return self.render(state, snapshot)
1172
+ state = (
1173
+ enter_loop(state, snapshot.plan, boundary, _now)
1174
+ if loop.boundary == "enter"
1175
+ else repeat_loop(state, snapshot.plan, boundary, _now)
1176
+ )
1177
+ self.commit(state, snapshot)
1178
+ if caller_role == "manager" and state.assignment_item_id is None:
1179
+ state = self._begin_assignment(
1180
+ state,
1181
+ snapshot,
1182
+ model=model,
1183
+ reasoning=reasoning,
1184
+ selected_agent=selected_agent,
1185
+ )
1186
+ state, snapshot = self.drain(
1187
+ state,
1188
+ snapshot,
1189
+ state.assignment_item_id if caller_role == "manager" else None,
1190
+ )
1191
+ if caller_role == "manager" and (
1192
+ state.execution_instance_id != starting_instance
1193
+ or state.assignment_item_id is not None
1194
+ ):
1195
+ state, snapshot = self._activate_or_handoff(state, snapshot)
1196
+ if self._stopped_at_loop_boundary(state, snapshot):
1197
+ continue
1198
+ return self.render(state, snapshot)
1199
+ if state.status in {"completed", "awaiting_input", "failed"}:
1200
+ return self.render(state, snapshot)
1201
+ if (
1202
+ state.active_item_id
1203
+ and child_workflow(snapshot.plan.items[state.cursor]) is not None
1204
+ ):
1205
+ return self.render(state, snapshot)
1206
+ if loop_control(snapshot.plan.items[state.cursor]) is not None:
1207
+ if self._stopped_at_loop_boundary(state, snapshot):
1208
+ continue
1209
+ return self.render(state, snapshot)
1210
+ break
1211
+ else: # pragma: no cover - every pass consumes a boundary
1212
+ raise StateError("next did not reach an agent item")
1213
+ if needs_repair(state):
1214
+ return self._dispatch_repair(
1215
+ state, snapshot, model, reasoning, selected_agent
1216
+ )
1217
+ item = snapshot.plan.items[state.cursor]
1218
+ if item.owner != "agent":
1219
+ raise StateError("executor stopped on a non-agent item")
1220
+ state = begin_agent_item(
1221
+ state,
1222
+ snapshot.plan,
1223
+ item,
1224
+ model=model
1225
+ if model != "auto"
1226
+ else requested_setting(item.model) or state.model,
1227
+ reasoning=(
1228
+ reasoning
1229
+ if reasoning != "auto"
1230
+ else requested_setting(item.reasoning) or state.reasoning
1231
+ ),
1232
+ selected_model=model if model != "auto" else None,
1233
+ selected_reasoning=reasoning if reasoning != "auto" else None,
1234
+ change_mark=self._change_mark(state, snapshot.plan, item),
1235
+ resolution=self._resolution(state, snapshot.plan, item),
1236
+ now=_now,
1237
+ )
1238
+ self.commit(state, snapshot)
1239
+ return self.render(state, snapshot)
1240
+
1241
+ @staticmethod
1242
+ def _waivable(state: ExecutionState, plan: WorkflowPlan) -> tuple[str, ...]:
1243
+ """What ``next --force`` waives: the disputed check, or all of them."""
1244
+ record = state.item_executions[state.cursor]
1245
+ if state.failure_kind == "check_disputed":
1246
+ assert record.dispute is not None
1247
+ return (record.dispute.check,)
1248
+ return tuple(fix_limits(plan.items[state.cursor], record))
1249
+
1250
+ def force_target(self, task_id: str) -> str:
1251
+ """Describe what ``next --force`` would do, or raise when nothing can be forced.
1252
+
1253
+ The CLI asks this before its interactive confirmation, so an operator is
1254
+ never asked to approve a force that ww would then refuse.
1255
+ """
1256
+ validate_task_id(task_id)
1257
+ state, snapshot = self.load(task_id)
1258
+ items = snapshot.plan.items
1259
+ if (
1260
+ state.status == "failed"
1261
+ and state.failure_kind == "fix_limit"
1262
+ and not needs_repair(state)
1263
+ ):
1264
+ return (
1265
+ f"waive the failed checks of `{items[state.cursor].name}`: its "
1266
+ "worker completes it again without them, and the artifact "
1267
+ "records the waiver"
1268
+ )
1269
+ if state.status == "failed" and state.failure_kind == "check_disputed":
1270
+ (disputed,) = self._waivable(state, snapshot.plan)
1271
+ return (
1272
+ f"waive the disputed check `{disputed}` of "
1273
+ f"`{items[state.cursor].name}`: its worker completes it again "
1274
+ "without that check, and the artifact records the waiver"
1275
+ )
1276
+ if state.status == "failed" and state.failure_kind == "pass_incomplete":
1277
+ raise StateError(FORCE_PAST_PASS_GATE)
1278
+ if state.status in {"failed", "interrupted"}:
1279
+ if state.cursor >= len(items):
1280
+ raise StateError(
1281
+ f"{state.status} task has no current item to force past"
1282
+ )
1283
+ return (
1284
+ f"skip the {state.status} item `{items[state.cursor].name}` "
1285
+ "without running it"
1286
+ )
1287
+ if self._at_loop_limit(state, snapshot):
1288
+ loop = loop_control(items[state.cursor])
1289
+ assert loop is not None
1290
+ return (
1291
+ f"leave the `{loop.loop_id}` loop at its limit of {loop.max_times} "
1292
+ "iterations and continue with the steps after it"
1293
+ )
1294
+ raise StateError(FORCE_NOT_APPLICABLE)
1295
+
1296
+ @staticmethod
1297
+ def _stopped_at_loop_boundary(
1298
+ state: ExecutionState, snapshot: PlanSnapshot
1299
+ ) -> bool:
1300
+ """Whether ``next`` idles on a loop boundary it may still pass now."""
1301
+ return (
1302
+ state.status == "pending"
1303
+ and state.active_item_id is None
1304
+ and state.assignment_item_id is None
1305
+ and state.cursor < len(snapshot.plan.items)
1306
+ and loop_control(snapshot.plan.items[state.cursor]) is not None
1307
+ and not loop_limit_reached(state, snapshot.plan.items[state.cursor])
1308
+ )
1309
+
1310
+ @staticmethod
1311
+ def _at_loop_limit(state: ExecutionState, snapshot: PlanSnapshot) -> bool:
1312
+ return state.cursor < len(snapshot.plan.items) and loop_limit_reached(
1313
+ state, snapshot.plan.items[state.cursor]
1314
+ )
1315
+
1316
+ def mark_interrupted(
1317
+ self, state: ExecutionState, snapshot: PlanSnapshot
1318
+ ) -> ExecutionState:
1319
+ """Record that a persisted automatic operation outlived its process.
1320
+
1321
+ An ``in_progress`` record is only written immediately before invoking
1322
+ an external command or extension. Seeing one during a later command
1323
+ therefore means the outcome is unknown, unless a command segment had
1324
+ already recorded its non-zero exit, which is a known failure. Neither
1325
+ may be interpreted as agent-owned work or silently skipped.
1326
+ """
1327
+ item = snapshot.plan.items[state.cursor]
1328
+ state = settle_stale_automatic_item(state, snapshot.plan, item, _now)
1329
+ self.commit(state, snapshot)
1330
+ return state
1331
+
1332
+ def fail(
1333
+ self,
1334
+ task_id: str,
1335
+ error: str,
1336
+ *,
1337
+ caller_role: CallerRole | None = None,
1338
+ assignment: str | None = None,
1339
+ ) -> Instruction:
1340
+ self._validate_caller_role(caller_role)
1341
+ request = (
1342
+ self.storage.read_bootstrap(task_id)
1343
+ if is_bootstrap_request(task_id)
1344
+ else None
1345
+ )
1346
+ if request is not None:
1347
+ if not error.strip():
1348
+ raise StateError("--error must be non-empty")
1349
+ request["status"] = "failed"
1350
+ request["error"] = error.strip()
1351
+ self.storage.write_bootstrap(task_id, request)
1352
+ return self._tag_caller(self.bootstrap.instruction(request), caller_role)
1353
+ validate_task_id(task_id)
1354
+ if not error.strip():
1355
+ raise StateError("--error must be non-empty")
1356
+ with self.tasks.lock_task(task_id):
1357
+ self._authorize_worker(task_id, caller_role, assignment)
1358
+ opened = self._open_assignment(task_id, caller_role)
1359
+ instruction = self._with_handoff(
1360
+ task_id, self._fail(task_id, error.strip()), opened
1361
+ )
1362
+ self.children.reconcile_after_child(task_id)
1363
+ return self._tag_caller(instruction, caller_role)
1364
+
1365
+ def interact(
1366
+ self,
1367
+ task_id: str,
1368
+ *,
1369
+ operator: str | None = None,
1370
+ agent: str | None = None,
1371
+ transcript: str | None = None,
1372
+ choice: str | None = None,
1373
+ end: bool = False,
1374
+ pause: bool = False,
1375
+ caller_role: CallerRole | None = None,
1376
+ assignment: str | None = None,
1377
+ ) -> Instruction:
1378
+ """Record a conversation of an interactive step, end it, or pause it.
1379
+
1380
+ ``transcript`` is the whole conversation in the plain form
1381
+ :func:`parse_transcript` reads; its entries are appended in order
1382
+ under one time, before ``choice`` and ``end`` apply.
1383
+ """
1384
+ self._validate_caller_role(caller_role)
1385
+ validate_task_id(task_id)
1386
+ operator = (operator or "").strip() or None
1387
+ agent = (agent or "").strip() or None
1388
+ choice = (choice or "").strip() or None
1389
+ if transcript is not None and (operator is not None or agent is not None):
1390
+ raise StateError(
1391
+ "--transcript records both sides; leave out --operator-said "
1392
+ "and --agent-said"
1393
+ )
1394
+ spoken = parse_transcript(transcript) if transcript is not None else ()
1395
+ if (
1396
+ not spoken
1397
+ and operator is None
1398
+ and agent is None
1399
+ and choice is None
1400
+ and not (end or pause)
1401
+ ):
1402
+ raise StateError(
1403
+ "interact needs --transcript, --operator-said, --agent-said, or "
1404
+ "--choice text, --end, or --pause"
1405
+ )
1406
+ if end and pause:
1407
+ raise StateError(
1408
+ "--end finishes the conversation and --pause leaves it "
1409
+ "open; use one of them"
1410
+ )
1411
+ with self.tasks.lock_task(task_id):
1412
+ self._authorize_worker(task_id, caller_role, assignment)
1413
+ state, snapshot = self.load(task_id)
1414
+ state = self._record_interaction(
1415
+ state,
1416
+ snapshot,
1417
+ operator=operator,
1418
+ agent=agent,
1419
+ transcript=spoken,
1420
+ choice=choice,
1421
+ end=end,
1422
+ pause=pause,
1423
+ )
1424
+ self.commit(state, snapshot)
1425
+ return self._tag_caller(self.render(state, snapshot), caller_role)
1426
+
1427
+ def _record_interaction(
1428
+ self,
1429
+ state: ExecutionState,
1430
+ snapshot: PlanSnapshot,
1431
+ *,
1432
+ operator: str | None = None,
1433
+ agent: str | None = None,
1434
+ transcript: Sequence[tuple[str, str]] = (),
1435
+ choice: str | None = None,
1436
+ end: bool = False,
1437
+ pause: bool = False,
1438
+ end_text: str = "The operator ended the interaction.",
1439
+ ) -> ExecutionState:
1440
+ """Append the entries of one exchange and update the step's record.
1441
+
1442
+ The one recording path for a conversation, whether the operator spoke
1443
+ in the session or answered on the operator page. Callers hold the
1444
+ task lock and commit the returned state.
1445
+ """
1446
+ item, record = _interactive_item(state, snapshot)
1447
+ entries = record.interaction_entries
1448
+ chosen = record.chosen
1449
+ spoken: list[tuple[str, str]] = []
1450
+ if choice is not None:
1451
+ chosen = resolve_choice(item, choice)
1452
+ spoken.append(("operator", f"Choice: {chosen}"))
1453
+ spoken.extend(transcript)
1454
+ spoken.extend(
1455
+ (speaker, text)
1456
+ for speaker, text in (("operator", operator), ("agent", agent))
1457
+ if text is not None
1458
+ )
1459
+ entries += len(spoken)
1460
+ if end:
1461
+ if entries == 0:
1462
+ raise StateError(
1463
+ "nothing was recorded; record the operator's words before "
1464
+ "ending the interaction"
1465
+ )
1466
+ if item.choices and chosen is None:
1467
+ raise StateError(
1468
+ f"{item.name!r} offers choices; record the operator's pick "
1469
+ "with --choice before ending the interaction"
1470
+ )
1471
+ spoken.append(("end", end_text))
1472
+ if pause:
1473
+ spoken.append(("pause", "The operator is done for now."))
1474
+ self.interactions.append_entries(
1475
+ state.task_id,
1476
+ spoken,
1477
+ run_id=state.run_id,
1478
+ step=item.name,
1479
+ item_id=item.item_id,
1480
+ at=_now(),
1481
+ )
1482
+ records = list(state.item_executions)
1483
+ records[state.cursor] = replace(
1484
+ record,
1485
+ interaction_entries=entries,
1486
+ interaction_ended=end,
1487
+ chosen=chosen,
1488
+ )
1489
+ # A pause is lifted by the operator's own words, not by the agent's or
1490
+ # by ending; those may happen while they are away.
1491
+ spoke = (
1492
+ operator is not None
1493
+ or choice is not None
1494
+ or any(speaker == "operator" for speaker, _ in transcript)
1495
+ )
1496
+ return replace(
1497
+ state,
1498
+ item_executions=tuple(records),
1499
+ operator_paused=pause or (state.operator_paused and not spoke),
1500
+ updated_at=_now(),
1501
+ )
1502
+
1503
+ def interactions_text(self, task_id: str) -> str:
1504
+ """The task's whole record of conversations with the operator."""
1505
+ validate_task_id(task_id)
1506
+ return self.interactions.read(task_id)
1507
+
1508
+ def loop(
1509
+ self,
1510
+ task_id: str,
1511
+ variables: tuple[tuple[str, str], ...] = (),
1512
+ artifact: str | None = None,
1513
+ metadata_values: tuple[tuple[str, str], ...] = (),
1514
+ selected_agent: str | None = None,
1515
+ selected_model: str | None = None,
1516
+ selected_reasoning: str | None = None,
1517
+ continue_loop: bool = False,
1518
+ *,
1519
+ summary_for_next: str | None = None,
1520
+ caller_role: CallerRole | None = None,
1521
+ assignment: str | None = None,
1522
+ ) -> Instruction:
1523
+ """Complete a loop-control worker step."""
1524
+ self._validate_caller_role(caller_role)
1525
+ self._validate_selected_agent(selected_agent)
1526
+ for name, value in (
1527
+ ("selected model", selected_model),
1528
+ ("selected reasoning", selected_reasoning),
1529
+ ):
1530
+ if value is not None and not value.strip():
1531
+ raise StateError(f"{name} must be non-empty")
1532
+ validate_task_id(task_id)
1533
+ with self.tasks.lock_task(task_id):
1534
+ self._authorize_worker(task_id, caller_role, assignment)
1535
+ self._check_performer(task_id, caller_role, loop=True)
1536
+ opened = self._open_assignment(task_id, caller_role)
1537
+ instruction = self._complete(
1538
+ task_id,
1539
+ variables,
1540
+ artifact,
1541
+ metadata_values,
1542
+ selected_agent=selected_agent,
1543
+ selected_model=selected_model,
1544
+ selected_reasoning=selected_reasoning,
1545
+ summary_for_next=summary_for_next,
1546
+ caller_role=caller_role,
1547
+ stopping_loop=not continue_loop,
1548
+ continuing_loop=continue_loop,
1549
+ )
1550
+ instruction = self._with_handoff(
1551
+ task_id,
1552
+ self._open_next_in_single(task_id, instruction),
1553
+ opened,
1554
+ loop="continue" if continue_loop else "break",
1555
+ )
1556
+ return replace(
1557
+ self._tag_caller(instruction, caller_role),
1558
+ completion_registered=True,
1559
+ )
1560
+
1561
+ def _fail(self, task_id: str, error: str) -> Instruction:
1562
+ state, snapshot = self.load(task_id)
1563
+ if not state.active_item_id or state.cursor >= len(snapshot.plan.items):
1564
+ raise StateError("no agent item is in progress; use next")
1565
+ item = snapshot.plan.items[state.cursor]
1566
+ if needs_repair(state):
1567
+ state = replace(
1568
+ state, status="failed", failure_kind="work_failed", last_error=error
1569
+ )
1570
+ self.commit(state, snapshot)
1571
+ return self.render(state, snapshot)
1572
+ if item.id != state.active_item_id or item.owner != "agent":
1573
+ raise StateError("active plan item does not match the execution cursor")
1574
+ state = fail_agent_item(state, snapshot.plan, item, error, _now)
1575
+ self.commit(state, snapshot)
1576
+ return self.render(state, snapshot)
1577
+
1578
+ def complete(
1579
+ self,
1580
+ task_id: str,
1581
+ variables: tuple[tuple[str, str], ...] = (),
1582
+ artifact: str | None = None,
1583
+ metadata_values: tuple[tuple[str, str], ...] = (),
1584
+ selected_agent: str | None = None,
1585
+ selected_model: str | None = None,
1586
+ selected_reasoning: str | None = None,
1587
+ *,
1588
+ summary_for_next: str | None = None,
1589
+ caller_role: CallerRole | None = None,
1590
+ rule_results: tuple[str, ...] = (),
1591
+ assignment: str | None = None,
1592
+ ) -> Instruction:
1593
+ """Complete the active agent item.
1594
+
1595
+ A verification item reports one ``rule_results`` JSON object, a
1596
+ verdict, per rule it covers.
1597
+ """
1598
+ self._validate_caller_role(caller_role)
1599
+ self._validate_selected_agent(selected_agent)
1600
+ for name, value in (
1601
+ ("selected model", selected_model),
1602
+ ("selected reasoning", selected_reasoning),
1603
+ ):
1604
+ if value is not None and not value.strip():
1605
+ raise StateError(f"{name} must be non-empty")
1606
+ request = (
1607
+ self.storage.read_bootstrap(task_id)
1608
+ if is_bootstrap_request(task_id)
1609
+ else None
1610
+ )
1611
+ if request is not None:
1612
+ if metadata_values:
1613
+ raise StateError("bootstrap completion cannot save task metadata")
1614
+ if rule_results:
1615
+ raise StateError("a bootstrap completion verifies no rules")
1616
+ return replace(
1617
+ self._tag_caller(
1618
+ self.bootstrap.complete(
1619
+ task_id,
1620
+ request,
1621
+ variables,
1622
+ artifact,
1623
+ start_task=self._start,
1624
+ status_task=self.instruction_status,
1625
+ bind_child=self.children.bind_child,
1626
+ ),
1627
+ caller_role,
1628
+ ),
1629
+ completion_registered=True,
1630
+ )
1631
+ validate_task_id(task_id)
1632
+ with self.tasks.lock_task(task_id):
1633
+ self._authorize_worker(task_id, caller_role, assignment)
1634
+ self._check_performer(task_id, caller_role)
1635
+ opened = self._open_assignment(task_id, caller_role)
1636
+ instruction = self._complete(
1637
+ task_id,
1638
+ variables,
1639
+ artifact,
1640
+ metadata_values,
1641
+ selected_agent=selected_agent,
1642
+ selected_model=selected_model,
1643
+ selected_reasoning=selected_reasoning,
1644
+ summary_for_next=summary_for_next,
1645
+ caller_role=caller_role,
1646
+ rule_results=rule_results,
1647
+ )
1648
+ held = instruction.completion_held
1649
+ instruction = self._with_handoff(
1650
+ task_id,
1651
+ replace(
1652
+ self._open_next_in_single(task_id, instruction),
1653
+ completion_held=held,
1654
+ ),
1655
+ opened,
1656
+ )
1657
+ self.children.reconcile_after_child(task_id)
1658
+ return replace(
1659
+ self._tag_caller(instruction, caller_role),
1660
+ completion_registered=True,
1661
+ )
1662
+
1663
+ def _open_next_in_single(
1664
+ self, task_id: str, instruction: Instruction
1665
+ ) -> Instruction:
1666
+ """After a completion in the single runtime, open the next agent step.
1667
+
1668
+ One session does every step, so the ``next`` it would run anyway is
1669
+ run for it and the completion shows the next step's own page. The
1670
+ ``auto`` runtime keeps the manager's ``next``, which chooses a worker.
1671
+ Callers hold the task lock.
1672
+ """
1673
+ state, snapshot = self.load(task_id)
1674
+ plan = snapshot.plan
1675
+ if (
1676
+ state.workflow_runtime != "single"
1677
+ or state.status not in ("pending", "in_progress")
1678
+ or state.active_item_id is not None
1679
+ or state.cursor >= len(plan.items)
1680
+ or plan.items[state.cursor].owner != "agent"
1681
+ ):
1682
+ return instruction
1683
+ try:
1684
+ return self._next(task_id)
1685
+ except StateError:
1686
+ # The completion stands; opening the next step needs something
1687
+ # only the agent can give, such as an assessment outcome, and
1688
+ # the pending page says what.
1689
+ state, snapshot = self.load(task_id)
1690
+ return self.render(state, snapshot)
1691
+
1692
+ def _complete(
1693
+ self,
1694
+ task_id: str,
1695
+ variables: tuple[tuple[str, str], ...],
1696
+ artifact: str | None,
1697
+ metadata_values: tuple[tuple[str, str], ...],
1698
+ selected_agent: str | None = None,
1699
+ selected_model: str | None = None,
1700
+ selected_reasoning: str | None = None,
1701
+ *,
1702
+ summary_for_next: str | None = None,
1703
+ caller_role: CallerRole | None = None,
1704
+ stopping_loop: bool = False,
1705
+ continuing_loop: bool = False,
1706
+ initialization: bool = False,
1707
+ drain_stop: int | None = None,
1708
+ rule_results: tuple[str, ...] = (),
1709
+ replayed: bool = False,
1710
+ ) -> Instruction:
1711
+ """Complete the active agent item; see :meth:`complete`.
1712
+
1713
+ ``replayed`` records a held completion for a caller who is not the
1714
+ step's worker, such as a verifier: the step's automatic
1715
+ follow-ups still run, but its next agent item is not opened for that
1716
+ caller, whose assignment ends here.
1717
+ """
1718
+ state, snapshot = self.load(task_id)
1719
+ state, snapshot = self.metadata_publisher.reconcile(state, snapshot)
1720
+ if state.status == "failed":
1721
+ return self.render(state, snapshot)
1722
+ if needs_repair(state):
1723
+ if (
1724
+ variables
1725
+ or metadata_values
1726
+ or rule_results
1727
+ or stopping_loop
1728
+ or continuing_loop
1729
+ ):
1730
+ raise StateError(
1731
+ "repair completion accepts only an artifact and summary"
1732
+ )
1733
+ return self._complete_repair(state, snapshot, artifact, summary_for_next)
1734
+ supplied = validate_values(variables)
1735
+ supplied_metadata = group_metadata_values(metadata_values)
1736
+ if "task_id" in supplied:
1737
+ raise StateError(
1738
+ "task_id is reserved for bootstrap start without an explicit task ID"
1739
+ )
1740
+ if state.status == "awaiting_input":
1741
+ return self._complete_pending_input(
1742
+ state,
1743
+ snapshot,
1744
+ supplied,
1745
+ supplied_metadata,
1746
+ selected_agent=selected_agent,
1747
+ selected_model=selected_model,
1748
+ selected_reasoning=selected_reasoning,
1749
+ caller_role=caller_role,
1750
+ )
1751
+ if not state.active_item_id or state.cursor >= len(snapshot.plan.items):
1752
+ raise StateError("no agent item is in progress; use next")
1753
+ item = snapshot.plan.items[state.cursor]
1754
+ if item.id != state.active_item_id or item.owner != "agent":
1755
+ raise StateError("active plan item does not match the execution cursor")
1756
+ if stopping_loop and item.loop_break is None:
1757
+ raise StateError("active step is not permitted to break a loop")
1758
+ if continuing_loop and item.loop_continue is None:
1759
+ raise StateError("active step is not permitted to continue a loop")
1760
+ # A break that ends per-child stages has no loop wrapper.
1761
+ loop_entry = (
1762
+ snapshot.plan.items[enclosing_loop_entry_index(snapshot.plan, state.cursor)]
1763
+ if (stopping_loop and not item.breaks_children) or continuing_loop
1764
+ else None
1765
+ )
1766
+ assignment = active_assignment(
1767
+ snapshot.plan, state.assignment_item_id, runtime=state.workflow_runtime
1768
+ )
1769
+ if initialization:
1770
+ window_stop: int | None = state.cursor + 1
1771
+ else:
1772
+ window_stop = assignment.stop if assignment else None
1773
+ required, _ = completion_window(snapshot.plan, state.cursor, window_stop)
1774
+ validate_requested_values(supplied, required)
1775
+ self._validate_supplied_inputs(
1776
+ state,
1777
+ completion_window_items(snapshot.plan, state.cursor, window_stop),
1778
+ supplied,
1779
+ )
1780
+ task_metadata, project_metadata = validate_metadata_values(
1781
+ supplied_metadata, item.save_metadata
1782
+ )
1783
+ artifact_required = (
1784
+ item.phase == "step"
1785
+ and item.artifact
1786
+ and item.item_operation is None
1787
+ and item.child_operation is None
1788
+ ) or bool(loop_entry is not None and loop_entry.artifact)
1789
+ if artifact_required and (artifact is None or not artifact.strip()):
1790
+ raise StateError(
1791
+ f"artifact is required to complete {item.name!r}; "
1792
+ "pass a non-empty --artifact or configure artifact: false"
1793
+ )
1794
+ if item.update_item:
1795
+ self._require_item_fields(task_id, state, item)
1796
+ active_record = state.item_executions[state.cursor]
1797
+ if item.interactive and not active_record.interaction_ended:
1798
+ raise StateError(
1799
+ f"{item.name!r} is interactive: once the operator says the "
1800
+ "conversation is done, record it with `interact --transcript - "
1801
+ "--end`, then complete"
1802
+ )
1803
+ summary_for_next = (summary_for_next or "").strip() or None
1804
+ if summary_for_next is not None and len(summary_for_next) > SUMMARY_LIMIT:
1805
+ raise StateError(
1806
+ f"{SUMMARY_FLAG} has {len(summary_for_next)} characters; keep it "
1807
+ f"to {SUMMARY_LIMIT}: one or two short sentences, with the "
1808
+ "detail in the artifact"
1809
+ )
1810
+ if item.hands_over and summary_for_next is None:
1811
+ raise StateError(
1812
+ f"{item.name!r} needs {SUMMARY_FLAG}: one or two short sentences "
1813
+ "the next step will read about what was done and what it should know"
1814
+ )
1815
+ if item.child_operation == "collect" and not self.tasks.read_children(
1816
+ task_id, state.run_id
1817
+ ):
1818
+ raise StateError(
1819
+ "children step completed without recorded children; use add-child"
1820
+ )
1821
+ if item.verifies is not None:
1822
+ return self._complete_verification(
1823
+ state,
1824
+ snapshot,
1825
+ item,
1826
+ artifact,
1827
+ rule_results,
1828
+ caller_role=caller_role,
1829
+ )
1830
+ if rule_results:
1831
+ raise StateError(
1832
+ f"--rule-result reports a verification; {item.name!r} is not one"
1833
+ )
1834
+ held = active_record.held_completion
1835
+ waived = {key for key, _ in active_record.checks_waived}
1836
+ check_report = None
1837
+ if any(
1838
+ check.id not in waived
1839
+ for check in (*item.checks, *active_record.resolved_checks)
1840
+ ):
1841
+ check_report = self.rule_checker.run(
1842
+ state,
1843
+ item,
1844
+ self._check_scope(state, snapshot.plan, item),
1845
+ reuse=held.report if held is not None else None,
1846
+ )
1847
+ if check_report.failed:
1848
+ # Rejected: nothing of the completion is recorded, and the
1849
+ # step goes back to its worker, or to the operator at the limit.
1850
+ # A held completion failing a newly approved check waits to be
1851
+ # handed back: whoever triggered the check is not its worker.
1852
+ state = reject_completion(
1853
+ state,
1854
+ snapshot.plan,
1855
+ item,
1856
+ check_report,
1857
+ artifact,
1858
+ _now,
1859
+ keep_active=held is None,
1860
+ )
1861
+ self.commit(state, snapshot)
1862
+ return self.render(state, snapshot)
1863
+ if to_verify(item, active_record):
1864
+ held_page = self._hold_for_verification(
1865
+ state,
1866
+ snapshot,
1867
+ item,
1868
+ check_report,
1869
+ artifact,
1870
+ HeldCompletion(
1871
+ variables=variables,
1872
+ metadata_values=metadata_values,
1873
+ selected_agent=selected_agent,
1874
+ selected_model=selected_model,
1875
+ selected_reasoning=selected_reasoning,
1876
+ summary_for_next=summary_for_next,
1877
+ loop_control=(
1878
+ "break"
1879
+ if stopping_loop
1880
+ else "continue"
1881
+ if continuing_loop
1882
+ else None
1883
+ ),
1884
+ ),
1885
+ )
1886
+ if held_page is not None:
1887
+ return held_page
1888
+ updated_metadata, project_publication = self.metadata_publisher.prepare(
1889
+ task_id, state, item, task_metadata, project_metadata
1890
+ )
1891
+ promised_documents = self._promised_documents(snapshot.plan, item, state)
1892
+ artifact_reference, wrapper_artifact_reference = write_completion_artifacts(
1893
+ self.tasks,
1894
+ task_id,
1895
+ state,
1896
+ snapshot,
1897
+ item,
1898
+ loop_entry,
1899
+ artifact,
1900
+ rules=rule_outcomes(state, item, check_report),
1901
+ )
1902
+ selection = _normalize_completion_selection(
1903
+ selected_agent,
1904
+ selected_model,
1905
+ selected_reasoning,
1906
+ state.item_executions[state.cursor].selected_agent,
1907
+ state.item_executions[state.cursor].selected_model,
1908
+ )
1909
+ reported_items = self._reported_items(
1910
+ task_id,
1911
+ state,
1912
+ item,
1913
+ reports_item_on_completion(snapshot.plan, state.cursor),
1914
+ )
1915
+ state = complete_agent_item(
1916
+ state,
1917
+ snapshot.plan,
1918
+ supplied,
1919
+ artifact_reference,
1920
+ _now,
1921
+ selected_agent=selection.selected_agent,
1922
+ selected_model=selection.selected_model,
1923
+ selected_reasoning=selection.selected_reasoning,
1924
+ clear_selected_model=selection.clear_selected_model,
1925
+ clear_selected_reasoning=selection.clear_selected_reasoning,
1926
+ summary_for_next=summary_for_next,
1927
+ check_report=check_report,
1928
+ )
1929
+ if initialization:
1930
+ state = replace(state, pending_init_artifact=None)
1931
+ state = replace(
1932
+ state,
1933
+ pending_task_metadata=(updated_metadata.values if updated_metadata else ()),
1934
+ pending_project_metadata=project_publication,
1935
+ )
1936
+ if stopping_loop:
1937
+ state = request_loop_exit(
1938
+ state,
1939
+ snapshot.plan,
1940
+ item,
1941
+ wrapper_artifact_reference,
1942
+ _now,
1943
+ )
1944
+ elif continuing_loop:
1945
+ state = request_loop_continue(state, snapshot.plan, item, _now)
1946
+ elif item.item_operation == "collect":
1947
+ state, snapshot = materialize_item_plan(
1948
+ state,
1949
+ snapshot,
1950
+ item,
1951
+ self.tasks.read_items(task_id, state.run_id),
1952
+ _now,
1953
+ )
1954
+ elif item.child_operation == "collect":
1955
+ state, snapshot = materialize_child_plan(
1956
+ state, snapshot, self.tasks.read_children(task_id, state.run_id), _now
1957
+ )
1958
+ if reported_items is None:
1959
+ self.commit(state, snapshot)
1960
+ else:
1961
+ self._commit_items(state, snapshot, reported_items)
1962
+ for document in promised_documents:
1963
+ self.documents.record_update(
1964
+ document,
1965
+ task_id,
1966
+ run_id=state.run_id,
1967
+ step=item.name,
1968
+ updated_at=_now(),
1969
+ workspace=resolve_workspace(self.storage.root, state.working_directory),
1970
+ )
1971
+ state, snapshot = self.metadata_publisher.reconcile(state, snapshot)
1972
+ if caller_role is not None:
1973
+ if state.assignment_item_id is None:
1974
+ state = self._begin_assignment(
1975
+ state,
1976
+ snapshot,
1977
+ model=(
1978
+ state.item_executions[state.cursor - 1].model or state.model
1979
+ ),
1980
+ reasoning=(
1981
+ state.item_executions[state.cursor - 1].reasoning
1982
+ or state.reasoning
1983
+ ),
1984
+ cursor=max(0, state.cursor - 1),
1985
+ )
1986
+ state, snapshot = self._activate_or_handoff(
1987
+ state, snapshot, activate=not replayed
1988
+ )
1989
+ else:
1990
+ state, snapshot = self.drain(
1991
+ state,
1992
+ snapshot,
1993
+ stop_at=(
1994
+ drain_stop
1995
+ if initialization
1996
+ else (
1997
+ self._initialization_stop(snapshot.plan)
1998
+ if state.pending_init_artifact is not None
1999
+ else None
2000
+ )
2001
+ ),
2002
+ )
2003
+ return self.render(state, snapshot)
2004
+
2005
+ def _validate_supplied_inputs(
2006
+ self,
2007
+ state: ExecutionState,
2008
+ consumers: tuple[PlanItem, ...],
2009
+ supplied: dict[str, str],
2010
+ ) -> None:
2011
+ """Refuse a value its handler would reject, while nothing is saved yet.
2012
+
2013
+ The consuming handler sees its whole declared input set, already-known
2014
+ values included, and its own message becomes the refusal, so the agent
2015
+ corrects the value in place of a handler failure the operator would
2016
+ have to resolve.
2017
+ """
2018
+ known = {**dict(state.workflow_values), **supplied}
2019
+ for item in consumers:
2020
+ if item.owner != "ww" or item.execution != "automatic":
2021
+ continue
2022
+ if not any(value.name in supplied for value in item.provide):
2023
+ continue
2024
+ error = self.actions.validate_inputs(
2025
+ item,
2026
+ {
2027
+ value.name: known[value.name]
2028
+ for value in item.provide
2029
+ if value.name in known
2030
+ },
2031
+ )
2032
+ if error is not None:
2033
+ raise StateError(f"{item.name} rejected the supplied value: {error}")
2034
+
2035
+ def _complete_pending_input(
2036
+ self,
2037
+ state: ExecutionState,
2038
+ snapshot: PlanSnapshot,
2039
+ supplied: dict[str, str],
2040
+ supplied_metadata: dict[str, tuple[str, ...]],
2041
+ *,
2042
+ selected_agent: str | None,
2043
+ selected_model: str | None,
2044
+ selected_reasoning: str | None,
2045
+ caller_role: CallerRole | None,
2046
+ ) -> Instruction:
2047
+ """Persist requested input, then resume the active assignment or plan."""
2048
+ validate_metadata_values(supplied_metadata, ())
2049
+ request = state.pending_input_request
2050
+ if request is None:
2051
+ raise StateError("task is awaiting input without an input request")
2052
+ validate_requested_values(supplied, request.values)
2053
+ index = _index_for_id(snapshot.plan, request.item_id)
2054
+ self._validate_supplied_inputs(state, (snapshot.plan.items[index],), supplied)
2055
+ state = supply_requested_input(
2056
+ state,
2057
+ index,
2058
+ supplied,
2059
+ _now,
2060
+ selected_agent=selected_agent or state.assignment_selected_agent,
2061
+ selected_model=(
2062
+ None
2063
+ if selected_model == "auto"
2064
+ else selected_model or state.assignment_selected_model
2065
+ ),
2066
+ selected_reasoning=(
2067
+ None
2068
+ if selected_reasoning == "auto"
2069
+ else selected_reasoning or state.assignment_selected_reasoning
2070
+ ),
2071
+ )
2072
+ self.commit(state, snapshot)
2073
+ if caller_role is not None and state.assignment_item_id is None:
2074
+ state = self._begin_assignment(
2075
+ state, snapshot, model=state.model, reasoning=state.reasoning
2076
+ )
2077
+ if caller_role is not None:
2078
+ state, snapshot = self._activate_or_handoff(state, snapshot)
2079
+ else:
2080
+ state, snapshot = self.drain(state, snapshot)
2081
+ return self.render(state, snapshot)
2082
+
2083
+ def instruction(
2084
+ self,
2085
+ task_id: str,
2086
+ run_id: str | None = None,
2087
+ *,
2088
+ caller_role: CallerRole | None = None,
2089
+ assignment: str | None = None,
2090
+ ) -> Instruction:
2091
+ self._validate_caller_role(caller_role)
2092
+ request = (
2093
+ self.storage.read_bootstrap(task_id)
2094
+ if is_bootstrap_request(task_id)
2095
+ else None
2096
+ )
2097
+ if request is not None:
2098
+ if run_id is not None:
2099
+ raise StateError("bootstrap requests do not have workflow runs")
2100
+ return replace(
2101
+ self._tag_caller(self.bootstrap.instruction(request), caller_role),
2102
+ manager_intro=True,
2103
+ )
2104
+ validate_task_id(task_id)
2105
+ self._authorize_worker(task_id, caller_role, assignment, read=True)
2106
+ instruction, resolved = self._status_from_one_read(task_id, run_id)
2107
+ if caller_role != "worker" and run_id is None and resolved is not None:
2108
+ _, change = self._detect_plan_change(*resolved)
2109
+ if change is not None:
2110
+ return replace(
2111
+ self._tag_caller(
2112
+ _plan_changed_page(instruction, change), caller_role
2113
+ ),
2114
+ manager_intro=True,
2115
+ )
2116
+ if instruction.is_child_workflow_control:
2117
+ refreshed = self.children.refresh_parent(task_id)
2118
+ if refreshed is not None:
2119
+ instruction = refreshed
2120
+ if caller_role == "worker" and _manager_performs(instruction):
2121
+ # A worker that outlived its assignment must not perform the
2122
+ # manager's item: its page names the item and offers no command.
2123
+ instruction = replace(
2124
+ instruction,
2125
+ manager_only=True,
2126
+ continuation_command=None,
2127
+ loop_break_command=None,
2128
+ loop_continue_command=None,
2129
+ interact_commands=None,
2130
+ )
2131
+ return replace(self._tag_caller(instruction, caller_role), manager_intro=True)
2132
+
2133
+ def check(self, task_id: str) -> CheckPreview:
2134
+ """Run the active step's checks now, as its completion would.
2135
+
2136
+ Nothing is recorded: no attempt counts, the record is untouched, and
2137
+ no command output is kept. Rules a verifier judges are only named,
2138
+ since a verifier judges them when the step completes.
2139
+ """
2140
+ validate_task_id(task_id)
2141
+ state, snapshot = self.load(task_id)
2142
+ item, record = self._active_step(state, snapshot, "nothing to check")
2143
+ report = RuleChecker(None, _now).run(
2144
+ state, item, self._check_scope(state, snapshot.plan, item)
2145
+ )
2146
+ return check_preview(state, item, report)
2147
+
2148
+ def dispute(
2149
+ self,
2150
+ task_id: str,
2151
+ check_id: str,
2152
+ reason: str,
2153
+ *,
2154
+ caller_role: CallerRole | None = None,
2155
+ assignment: str | None = None,
2156
+ ) -> Instruction:
2157
+ """Stop for the operator: the step's worker disputes a check.
2158
+
2159
+ Only a check that rejected a completion of the step in progress can
2160
+ be disputed. The dispute goes into the project's dispute log, then
2161
+ onto the step's record; the operator lets the check stand or waives
2162
+ it for this step. Nothing is written to the rule-automation store.
2163
+ """
2164
+ self._validate_caller_role(caller_role)
2165
+ validate_task_id(task_id)
2166
+ reason = reason.strip()
2167
+ if not reason:
2168
+ raise StateError("dispute --reason must be non-empty")
2169
+ with self.tasks.lock_task(task_id):
2170
+ self._authorize_worker(task_id, caller_role, assignment)
2171
+ opened = self._open_assignment(task_id, caller_role)
2172
+ state, snapshot = self.load(task_id)
2173
+ item, _ = self._active_step(state, snapshot, "nothing to dispute")
2174
+ failed = [
2175
+ (report, result)
2176
+ for report in item_reports(state)
2177
+ for result in report.failed
2178
+ if result.id == check_id
2179
+ ]
2180
+ if not failed:
2181
+ raise StateError(
2182
+ f"nothing to dispute: no rejected completion of {item.name!r} "
2183
+ f"failed {check_id!r}; run check first to see what fails, "
2184
+ "and dispute a check the fix page names"
2185
+ )
2186
+ report, result = failed[-1]
2187
+ dispute = Dispute(
2188
+ check=check_id,
2189
+ reason=reason,
2190
+ attempt=report.attempt,
2191
+ disputed_at=_now(),
2192
+ command=result.command,
2193
+ output=result.output,
2194
+ )
2195
+ rule = next((rule for rule in item.rules if rule.id == check_id), None)
2196
+ self.rule_disputes.append(
2197
+ DisputeEntry(
2198
+ check=check_id,
2199
+ text_hash=rule.text_hash if rule else None,
2200
+ task_id=task_id,
2201
+ run_id=state.run_id,
2202
+ step=item.name,
2203
+ reason=reason,
2204
+ attempt=report.attempt,
2205
+ disputed_at=dispute.disputed_at,
2206
+ )
2207
+ )
2208
+ state = dispute_check(state, snapshot.plan, dispute, _now)
2209
+ self.commit(state, snapshot)
2210
+ instruction = self._with_handoff(
2211
+ task_id, self.render(state, snapshot), opened
2212
+ )
2213
+ self.children.reconcile_after_child(task_id)
2214
+ return self._tag_caller(instruction, caller_role)
2215
+
2216
+ def rule(self, task_id: str, rule_id: str) -> RuleView:
2217
+ """One rule or check of the task in full, as its plan froze it."""
2218
+ validate_task_id(task_id)
2219
+ state, snapshot = self.load(task_id)
2220
+ return rule_view(state, snapshot.plan, self.rule_store.load(), rule_id)
2221
+
2222
+ def _active_step(
2223
+ self, state: ExecutionState, snapshot: PlanSnapshot, refusal: str
2224
+ ) -> tuple[PlanItem, PlanItemExecution]:
2225
+ """The agent step in progress, or a refusal naming what is missing."""
2226
+ plan = snapshot.plan
2227
+ if (
2228
+ state.status != "in_progress"
2229
+ or state.active_item_id is None
2230
+ or state.cursor >= len(plan.items)
2231
+ ):
2232
+ raise StateError(
2233
+ f"{refusal}: no step of task {state.task_id!r} is in progress"
2234
+ )
2235
+ item = plan.items[state.cursor]
2236
+ record = state.item_executions[state.cursor]
2237
+ if item.owner != "agent" or record.status != "in_progress":
2238
+ raise StateError(
2239
+ f"{refusal}: no step of task {state.task_id!r} is in progress"
2240
+ )
2241
+ if item.verifies is not None:
2242
+ raise StateError(
2243
+ f"{refusal}: {item.name!r} verifies another step's rules and has "
2244
+ "no checks of its own"
2245
+ )
2246
+ return item, record
2247
+
2248
+ def task_status(
2249
+ self,
2250
+ task_id: str,
2251
+ run_id: str | None = None,
2252
+ *,
2253
+ caller_role: CallerRole | None = None,
2254
+ ) -> TaskStatus:
2255
+ """Return a compact summary of the currently active workflow run."""
2256
+ self._validate_caller_role(caller_role)
2257
+ if is_bootstrap_request(task_id):
2258
+ instruction = self.instruction(task_id, run_id, caller_role=caller_role)
2259
+ return TaskStatus(
2260
+ task_id=instruction.task_id,
2261
+ workflow=instruction.workflow,
2262
+ step=instruction.step,
2263
+ step_state=instruction.item_status or instruction.status,
2264
+ runtime=instruction.workflow_runtime,
2265
+ agent=instruction.selected_agent or instruction.requested_agent,
2266
+ model=instruction.selected_model or instruction.model,
2267
+ reasoning=instruction.selected_reasoning or instruction.reasoning,
2268
+ )
2269
+ state, snapshot = self.load(task_id, run_id)
2270
+ instruction = self.render(state, snapshot)
2271
+ if instruction.is_child_workflow_control:
2272
+ refreshed = self.children.refresh_parent(task_id)
2273
+ if refreshed is not None:
2274
+ instruction = refreshed
2275
+ step_state = next(
2276
+ (
2277
+ progress.status
2278
+ for progress in state.steps
2279
+ if progress.path == instruction.step
2280
+ ),
2281
+ instruction.item_status or instruction.status,
2282
+ )
2283
+ return TaskStatus(
2284
+ task_id=instruction.task_id,
2285
+ workflow=instruction.workflow,
2286
+ step=instruction.step,
2287
+ step_state=step_state,
2288
+ runtime=instruction.workflow_runtime,
2289
+ agent=instruction.selected_agent or state.agent,
2290
+ model=instruction.selected_model or instruction.model,
2291
+ reasoning=instruction.selected_reasoning or instruction.reasoning,
2292
+ )
2293
+
2294
+ def status(
2295
+ self,
2296
+ task_id: str,
2297
+ run_id: str | None = None,
2298
+ *,
2299
+ caller_role: CallerRole | None = None,
2300
+ assignment: str | None = None,
2301
+ ) -> Instruction:
2302
+ """Backward-compatible service alias for :meth:`instruction`."""
2303
+ return self.instruction(
2304
+ task_id, run_id, caller_role=caller_role, assignment=assignment
2305
+ )
2306
+
2307
+ def instruction_status(self, task_id: str, run_id: str | None) -> Instruction:
2308
+ """Render a task from one record read, so it reflects one revision."""
2309
+ return self._status_from_one_read(task_id, run_id)[0]
2310
+
2311
+ def _status_from_one_read(
2312
+ self, task_id: str, run_id: str | None
2313
+ ) -> tuple[Instruction, tuple[ExecutionState, PlanSnapshot] | None]:
2314
+ """The task's page and the run it renders, from one record read.
2315
+
2316
+ The run is ``None`` for the summary of a task with several runs.
2317
+ """
2318
+ runs, _, _ = self.tasks.read_task_record(task_id)
2319
+ if len(runs) > 1 and run_id is None:
2320
+ summary = Instruction(
2321
+ task_id=task_id,
2322
+ workflow="",
2323
+ status="task_summary",
2324
+ item_id=None,
2325
+ item_name=None,
2326
+ stage=None,
2327
+ step=None,
2328
+ parent=None,
2329
+ item_status=None,
2330
+ action_kind=None,
2331
+ action_text=None,
2332
+ task_runs=self.tasks.summarize_runs(runs),
2333
+ next_role="manager",
2334
+ control="handoff_manager",
2335
+ )
2336
+ return summary, None
2337
+ state, snapshot = self.runs.resolve(task_id, runs, run_id)
2338
+ return self.render(state, snapshot), (state, snapshot)
2339
+
2340
+ def requirements(self, task_id: str, run_id: str | None = None) -> TaskRequirements:
2341
+ """The requirements ``init`` recorded, with their amendments, read only."""
2342
+ validate_task_id(task_id)
2343
+ state, snapshot = self.load(task_id, run_id)
2344
+ return TaskRequirements(
2345
+ task_id,
2346
+ self.instructions.requirements(state, snapshot.plan),
2347
+ self.tasks.read_amendments(task_id),
2348
+ )
2349
+
2350
+ def amend(
2351
+ self,
2352
+ task_id: str,
2353
+ text: str,
2354
+ *,
2355
+ caller_role: CallerRole | None = None,
2356
+ assignment: str | None = None,
2357
+ ) -> Amendment:
2358
+ """Append a timestamped amendment to the task's requirements.
2359
+
2360
+ The recorded requirements are never rewritten. ``caller_role`` is who
2361
+ recorded it; without one the operator at the terminal did.
2362
+ """
2363
+ self._validate_caller_role(caller_role)
2364
+ validate_task_id(task_id)
2365
+ amendment_text = text.strip()
2366
+ if not amendment_text:
2367
+ raise StateError("amend requires non-empty --requirements")
2368
+ if len(amendment_text) > MAX_AMENDMENT_LENGTH:
2369
+ raise StateError(
2370
+ f"an amendment is a short clarification (at most "
2371
+ f"{MAX_AMENDMENT_LENGTH} characters); the original requirements "
2372
+ "stay as recorded"
2373
+ )
2374
+ with self.tasks.lock_task(task_id):
2375
+ self._authorize_worker(task_id, caller_role, assignment)
2376
+ state, _ = self.load(task_id)
2377
+ if state.status == "completed":
2378
+ raise StateError(
2379
+ f"task {task_id!r} is already completed; its requirements "
2380
+ "cannot be amended"
2381
+ )
2382
+ amendment = Amendment(_now(), caller_role or "operator", amendment_text)
2383
+ self.tasks.append_amendment(task_id, amendment)
2384
+ return amendment
2385
+
2386
+ def documents_listing(self, task_id: str | None) -> list[dict[str, object]]:
2387
+ """Describe every declared document, with its file and last update."""
2388
+ workspace = None
2389
+ if task_id is not None:
2390
+ validate_task_id(task_id)
2391
+ runs, _, _ = self.tasks.read_task_record(task_id)
2392
+ run = self.runs.select_run(runs)
2393
+ if run is not None:
2394
+ workspace = resolve_workspace(
2395
+ self.storage.root, run.state.working_directory
2396
+ )
2397
+ return self.documents.listing(
2398
+ self._load_configuration().documents, task_id, workspace
2399
+ )
2400
+
2401
+ def _promised_documents(
2402
+ self, plan: WorkflowPlan, item: PlanItem, state: ExecutionState
2403
+ ) -> tuple[DocumentDefinition, ...]:
2404
+ """The documents ``item`` promised to update, each verified to exist."""
2405
+ declared = {document.name: document for document in plan.documents}
2406
+ workspace = resolve_workspace(self.storage.root, state.working_directory)
2407
+ promised = []
2408
+ for update in item.update_document:
2409
+ document = declared.get(update.name)
2410
+ if document is None:
2411
+ raise StateError(
2412
+ f"document {update.name!r} is not declared in this run's plan"
2413
+ )
2414
+ path = self.documents.path(document, state.task_id, workspace)
2415
+ if not path.is_file():
2416
+ raise StateError(
2417
+ f"{item.name!r} promised to update document {update.name!r}, "
2418
+ f"but {path} does not exist; create it, then complete"
2419
+ )
2420
+ promised.append(document)
2421
+ return tuple(promised)
2422
+
2423
+ def metadata(self, task_id: str) -> dict[str, object]:
2424
+ """Return a task's durable metadata as a nested JSON-ready mapping."""
2425
+ validate_task_id(task_id)
2426
+ if not self.tasks.task_exists(task_id):
2427
+ raise StateError(f"task not found: {task_id}")
2428
+ metadata = self.tasks.read_task_metadata(task_id)
2429
+ return metadata.to_dict() if metadata is not None else {}
2430
+
2431
+ def project_metadata(self) -> dict[str, object]:
2432
+ """Return durable project metadata as a nested JSON-ready mapping."""
2433
+ metadata = self.project_metadata_store.read_project_metadata()
2434
+ return metadata.to_dict() if metadata is not None else {}
2435
+
2436
+ def _runtime_values(
2437
+ self, state: ExecutionState, plan: WorkflowPlan
2438
+ ) -> dict[str, str]:
2439
+ project = dict(state.workflow_values).get(PROJECT)
2440
+ # A declared append key that nothing has filled yet reads as empty
2441
+ # rather than leaving its placeholder in a prompt.
2442
+ empty_lists = {
2443
+ (
2444
+ f"{PROJECT_METADATA_PREFIX}{saved.key}"
2445
+ if saved.scope == "project"
2446
+ else f"{METADATA_PREFIX}{saved.key}"
2447
+ ): ""
2448
+ for item in plan.items
2449
+ for saved in item.save_metadata
2450
+ if saved.append
2451
+ }
2452
+ workspace = resolve_workspace(self.storage.root, state.working_directory)
2453
+ document_paths = {
2454
+ f"{DOCUMENTS_PREFIX}{document.name}": str(
2455
+ self.documents.path(document, state.task_id, workspace)
2456
+ )
2457
+ for document in plan.documents
2458
+ }
2459
+ values = {
2460
+ **empty_lists,
2461
+ **document_paths,
2462
+ **self.metadata_publisher.values(state.task_id),
2463
+ **runtime_variable_values(
2464
+ self.storage.root,
2465
+ state.task_id,
2466
+ state.working_directory,
2467
+ project,
2468
+ self._project_names(),
2469
+ self._project_path(project),
2470
+ ),
2471
+ }
2472
+ current = plan.items[state.cursor] if state.cursor < len(plan.items) else None
2473
+ choice_step = current
2474
+ if current is not None and current.phase != "step":
2475
+ choice_step = next(
2476
+ (
2477
+ item
2478
+ for item in plan.items
2479
+ if item.step == current.step and item.phase == "step"
2480
+ ),
2481
+ current,
2482
+ )
2483
+ values[CHOICES] = json.dumps(
2484
+ [choice.label for choice in choice_step.choices]
2485
+ if choice_step is not None
2486
+ else [],
2487
+ ensure_ascii=False,
2488
+ )
2489
+ bound: dict[str, dict[str, object] | None] = {}
2490
+ for item in plan.items:
2491
+ if not isinstance(item.operation, PlannedAction):
2492
+ continue
2493
+ if not actions.contains(item.kind):
2494
+ continue
2495
+ implementation = actions.get(item.kind)
2496
+ binding = implementation.traits(
2497
+ item.payload_as(implementation.planned_type)
2498
+ ).extension_binding
2499
+ if binding is None:
2500
+ continue
2501
+ identifier = parse_reference(binding.reference).identifier
2502
+ if identifier not in bound:
2503
+ bound[identifier] = binding.settings
2504
+ values = self.extensions.apply_variable_overrides(
2505
+ values,
2506
+ tuple(bound.items()),
2507
+ task_id=state.task_id,
2508
+ run_id=state.run_id,
2509
+ workflow=state.workflow,
2510
+ workflow_values=dict(state.workflow_values),
2511
+ workspace=workspace,
2512
+ lane=plan.lane,
2513
+ )
2514
+ # Resolved now, for the task as it stands; a value not available yet
2515
+ # is left out, and a step reading it stops for the operator
2516
+ # (``value_unavailable``) before it starts.
2517
+ return {
2518
+ **values,
2519
+ **self.extensions.namespace_values(
2520
+ self.extensions.namespace_variables(),
2521
+ task_id=state.task_id,
2522
+ run_id=state.run_id,
2523
+ workflow=state.workflow,
2524
+ workflow_values=dict(state.workflow_values),
2525
+ workspace=workspace,
2526
+ project=project or None,
2527
+ lane=plan.lane,
2528
+ ),
2529
+ }
2530
+
2531
+ def _unavailable_values(
2532
+ self, state: ExecutionState, plan: WorkflowPlan, item: PlanItem
2533
+ ) -> tuple[str, ...]:
2534
+ """The ``ww.`` values ``item``'s templates read that are missing now."""
2535
+ return unavailable_ww_values(
2536
+ item.dependencies,
2537
+ {
2538
+ **dict(state.workflow_values),
2539
+ **self._runtime_values(state, plan),
2540
+ **self._child_values(state, plan, item),
2541
+ },
2542
+ )
2543
+
2544
+ def _item_values(
2545
+ self, state: ExecutionState, plan: WorkflowPlan, item: PlanItem
2546
+ ) -> dict[str, str]:
2547
+ """``{{ww.item.*}}`` for a per-item stage: its item as stored right now.
2548
+
2549
+ Read from the run's items on every call, so a value changed in an
2550
+ earlier pass or by ``update-item`` after a gate stop is what renders;
2551
+ nothing is frozen into the plan. A step with no bound item, or whose
2552
+ item is gone, has none: reading one is a context error.
2553
+ """
2554
+ if item.item_id is None:
2555
+ return {}
2556
+ work = next(
2557
+ (
2558
+ entry
2559
+ for entry in self.tasks.read_items(state.task_id, state.run_id)
2560
+ if entry.id == item.item_id
2561
+ ),
2562
+ None,
2563
+ )
2564
+ return item_binding_values(item.item_id, work, item.dependencies)
2565
+
2566
+ def _child_values(
2567
+ self, state: ExecutionState, plan: WorkflowPlan, item: PlanItem
2568
+ ) -> dict[str, str]:
2569
+ """``{{ww.child.*}}`` for a per-child stage: its child as it stands now.
2570
+
2571
+ The child's extension values (``{{ww.child.git.branch}}``) are
2572
+ resolved for the child's own task; one its extension cannot give yet,
2573
+ such as the branch of a child that has not started, is left out.
2574
+ """
2575
+ if item.child_number is None:
2576
+ return {}
2577
+ children = self.tasks.read_children(state.task_id, state.run_id)
2578
+ if item.child_number > len(children):
2579
+ return {}
2580
+ child = children[item.child_number - 1]
2581
+ runs, _, _ = self.tasks.read_task_record(child.task_id)
2582
+ run = next(
2583
+ (
2584
+ entry
2585
+ for entry in reversed(runs)
2586
+ if entry.state.parent_task_id == state.task_id
2587
+ and entry.state.start_operation_id == child.start_operation_id
2588
+ ),
2589
+ None,
2590
+ )
2591
+ workflow = child.workflow or next(
2592
+ (
2593
+ coordinator.workflow
2594
+ for entry in plan.items
2595
+ if entry.child_number == item.child_number
2596
+ and (coordinator := child_workflow(entry)) is not None
2597
+ ),
2598
+ state.workflow,
2599
+ )
2600
+ namespaced = self.extensions.namespace_values(
2601
+ self.extensions.namespace_variables(),
2602
+ task_id=child.task_id,
2603
+ run_id=run.run_id if run else None,
2604
+ workflow=workflow,
2605
+ workflow_values=dict(run.state.workflow_values) if run else {},
2606
+ workspace=(
2607
+ resolve_workspace(self.storage.root, run.state.working_directory)
2608
+ if run
2609
+ else None
2610
+ ),
2611
+ project=child.project,
2612
+ lane=(run.snapshot.plan.lane if run else self._configured_lane(workflow)),
2613
+ )
2614
+ return {
2615
+ **child_values(child.id, child.description, child.project, child.fields),
2616
+ **{child_value_name(name): value for name, value in namespaced.items()},
2617
+ }
2618
+
2619
+ def _stage_child_id(self, state: ExecutionState, item: PlanItem) -> str | None:
2620
+ """The ID of the child a per-child stage belongs to, when it exists."""
2621
+ if item.child_number is None:
2622
+ return None
2623
+ children = self.tasks.read_children(state.task_id, state.run_id)
2624
+ if item.child_number > len(children):
2625
+ return None
2626
+ return children[item.child_number - 1].id
2627
+
2628
+ def _unavailable_error(
2629
+ self, names: tuple[str, ...], state: ExecutionState, item: PlanItem
2630
+ ) -> str:
2631
+ """Name each missing value and what provides it.
2632
+
2633
+ A child's field comes from its record, so the message says how to
2634
+ set it; any other value comes from an extension, the child's own for
2635
+ ``{{ww.child.<namespace>.*}}``.
2636
+ """
2637
+ owners = {
2638
+ name: identifier
2639
+ for name, (identifier, _) in self.extensions.namespaces().items()
2640
+ }
2641
+
2642
+ def describe(name: str) -> str:
2643
+ if name.startswith(CHILD_FIELD_PREFIX):
2644
+ field = name.removeprefix(CHILD_FIELD_PREFIX)
2645
+ child = self._stage_child_id(state, item) or "<child-id>"
2646
+ return (
2647
+ f"{{{{{name}}}}} (the child has no field {field!r}; set it "
2648
+ f"with `ww update-child {state.task_id} {child} --field "
2649
+ f"{field}=<value>`, or `--field` on add-child)"
2650
+ )
2651
+ _, _, rest = name.partition(".")
2652
+ if name.startswith(CHILD_VALUE_PREFIX):
2653
+ _, _, rest = rest.partition(".")
2654
+ owner = owners.get(rest.partition(".")[0])
2655
+ source = f" (provided by {owner})" if owner else ""
2656
+ return f"{{{{{name}}}}}{source}"
2657
+
2658
+ return "template value(s) not available for this task yet: " + ", ".join(
2659
+ describe(name) for name in names
2660
+ )
2661
+
2662
+ def reset(self, task_id: str) -> ResetResult:
2663
+ validate_task_id(task_id)
2664
+ with self.tasks.lock_task(task_id):
2665
+ children = self.tasks.child_task_ids(task_id)
2666
+ if children:
2667
+ raise StateError(
2668
+ f"cannot reset {task_id!r} while child task(s) exist: "
2669
+ + ", ".join(children)
2670
+ )
2671
+ # Side records go first, so the task directory is empty for the
2672
+ # storage adapter to remove.
2673
+ self.interactions.remove(task_id)
2674
+ self.hook_records.remove(task_id)
2675
+ self.documents.remove_task(task_id)
2676
+ # Extension records outlive task state otherwise, and a later
2677
+ # task under the same ID would inherit them.
2678
+ self.extensions.forget_task(task_id)
2679
+ return ResetResult(task_id, self.tasks.remove_task(task_id))
2680
+
2681
+ def interruption(self, task_id: str) -> Interruption | None:
2682
+ """The task's interruption while its interrupted attempt is still open."""
2683
+ validate_task_id(task_id)
2684
+ return self.hook_records.interruption(task_id)
2685
+
2686
+ def interruptions(self) -> tuple[tuple[str, Interruption], ...]:
2687
+ """Every task still marked as interrupted, newest first."""
2688
+ return self.hook_records.interruptions()
2689
+
2690
+ def cleanup(self) -> CleanupResult:
2691
+ """Prune obsolete lock sidecars outside any task-specific state."""
2692
+ return CleanupResult(self.storage.cleanup_locks())
2693
+
2694
+ def items(self, task_id: str, run_id: str | None = None) -> tuple[WorkItem, ...]:
2695
+ validate_task_id(task_id)
2696
+ runs, _handoff, _revision = self.tasks.read_task_record(task_id)
2697
+ selected = self.runs.select_run(runs, run_id)
2698
+ if selected is None:
2699
+ if run_id is not None:
2700
+ return ()
2701
+ raise StateError(f"task {task_id!r} has not been started; use start")
2702
+ return selected.items
2703
+
2704
+ def item(self, task_id: str, item_id: str, run_id: str | None = None) -> WorkItem:
2705
+ """Return one work item from one workflow run."""
2706
+ for item in self.items(task_id, run_id):
2707
+ if item.id == item_id:
2708
+ return item
2709
+ raise StateError(f"item {item_id!r} was not found")
2710
+
2711
+ def find_item(
2712
+ self, task_id: str, name: str, value: str, run_id: str | None = None
2713
+ ) -> WorkItem:
2714
+ """Return the work item whose custom field ``name`` holds ``value``."""
2715
+ for item in self.items(task_id, run_id):
2716
+ if item.field(name) == value:
2717
+ return item
2718
+ raise StateError(f"no item has {name} = {value!r}")
2719
+
2720
+ def _check_item_fields(
2721
+ self,
2722
+ task_id: str,
2723
+ plan: WorkflowPlan,
2724
+ items: tuple[WorkItem, ...],
2725
+ item: WorkItem,
2726
+ *,
2727
+ adding: bool,
2728
+ ) -> None:
2729
+ """Enforce the flow's identity and unique fields for one item.
2730
+
2731
+ A new item must carry the identity field. Across the unique fields,
2732
+ a value may appear once over all items of the run and, when the
2733
+ flow is shared, of the task's store.
2734
+ """
2735
+ collect = item_collection(plan)
2736
+ if collect is None:
2737
+ return
2738
+ if adding and collect.item_identity and not item.field(collect.item_identity):
2739
+ raise StateError(
2740
+ f"item {item.id!r} needs the field {collect.item_identity!r}: "
2741
+ f"pass --field {collect.item_identity}=<value>"
2742
+ )
2743
+ if not collect.item_unique:
2744
+ return
2745
+ others = [entry for entry in items if entry.id != item.id]
2746
+ if collect.shared_items:
2747
+ others += [
2748
+ entry
2749
+ for entry in self.tasks.read_shared_items(task_id)
2750
+ if entry.id != item.id
2751
+ ]
2752
+ taken = {
2753
+ entry.field(name): (entry.id, name)
2754
+ for entry in others
2755
+ for name in collect.item_unique
2756
+ if entry.field(name)
2757
+ }
2758
+ for name in collect.item_unique:
2759
+ value = item.field(name)
2760
+ if value and value in taken:
2761
+ holder, held_as = taken[value]
2762
+ raise StateError(
2763
+ f"{name} {value!r} is already the {held_as} of item {holder!r}"
2764
+ )
2765
+
2766
+ def artifacts(
2767
+ self, task_id: str, run_id: str | None = None
2768
+ ) -> tuple[dict[str, str], ...]:
2769
+ """Return stable artifact references for one workflow run.
2770
+
2771
+ Each reference is project-relative and stable across machines; ``path``
2772
+ beside it is the absolute location, which a worker running in a linked
2773
+ worktree needs because ``.ww`` lives under the primary checkout.
2774
+ """
2775
+ validate_task_id(task_id)
2776
+ state, snapshot = self.load(task_id, run_id)
2777
+ artifacts: list[dict[str, str]] = []
2778
+ for item, record in zip(
2779
+ snapshot.plan.items, state.item_executions, strict=True
2780
+ ):
2781
+ if record.status != "completed" or record.artifact is None:
2782
+ continue
2783
+ artifact = {
2784
+ "step": item.step,
2785
+ "artifact": record.artifact,
2786
+ "path": str(self.storage.root / record.artifact),
2787
+ }
2788
+ if item.phase != "step":
2789
+ artifact["hook"] = item.name
2790
+ artifact["hook_phase"] = item.phase
2791
+ artifacts.append(artifact)
2792
+ item_by_id = {item.id: item for item in snapshot.plan.items}
2793
+ listed_checks: set[str] = set()
2794
+ for record in (*state.item_executions, *state.execution_history):
2795
+ recorded = item_by_id.get(record.plan_item_id)
2796
+ if (
2797
+ recorded is None
2798
+ ): # pragma: no cover - aggregate validation prevents this
2799
+ continue
2800
+ for repair_reference in record.repair_artifacts:
2801
+ if repair_reference not in listed_checks:
2802
+ listed_checks.add(repair_reference)
2803
+ artifacts.append(
2804
+ {
2805
+ "step": recorded.step,
2806
+ "repair_artifact": repair_reference,
2807
+ "path": str(self.storage.root / repair_reference),
2808
+ }
2809
+ )
2810
+ for command in record.commands:
2811
+ for stream, command_reference in (
2812
+ ("stdout", command.stdout_ref),
2813
+ ("stderr", command.stderr_ref),
2814
+ ):
2815
+ if command_reference is None:
2816
+ continue
2817
+ artifacts.append(
2818
+ {
2819
+ "step": recorded.step,
2820
+ "command_output": command_reference,
2821
+ "path": str(self.storage.root / command_reference),
2822
+ "stream": stream,
2823
+ "operation_id": command.operation_id or "unknown",
2824
+ "attempt": str(command.attempts),
2825
+ }
2826
+ )
2827
+ for report in record.check_reports:
2828
+ for result in report.results:
2829
+ for stream, check_reference in (
2830
+ ("stdout", result.stdout_ref),
2831
+ ("stderr", result.stderr_ref),
2832
+ ):
2833
+ # A retried record's copy in the history repeats them.
2834
+ if check_reference is None or check_reference in listed_checks:
2835
+ continue
2836
+ listed_checks.add(check_reference)
2837
+ artifacts.append(
2838
+ {
2839
+ "step": recorded.step,
2840
+ "command_output": check_reference,
2841
+ "path": str(self.storage.root / check_reference),
2842
+ "stream": stream,
2843
+ "check": result.id,
2844
+ "attempt": str(report.attempt),
2845
+ }
2846
+ )
2847
+ return tuple(artifacts)
2848
+
2849
+ def add_item(self, task_id: str, item: WorkItem) -> WorkItem:
2850
+ validate_task_id(task_id)
2851
+ with self.tasks.lock_task(task_id):
2852
+ run_id = self.tasks.active_execution_run(task_id)
2853
+ if run_id is None:
2854
+ raise StateError(f"task {task_id!r} has no active workflow run")
2855
+ state, snapshot = self.load(task_id, run_id)
2856
+ items = self.tasks.read_items(task_id, run_id)
2857
+ if any(existing.id == item.id for existing in items):
2858
+ raise StateError(f"item {item.id!r} already exists")
2859
+ if item.reference_to_id and not any(
2860
+ existing.id == item.reference_to_id for existing in items
2861
+ ):
2862
+ raise StateError(
2863
+ f"item reference {item.reference_to_id!r} does not exist"
2864
+ )
2865
+ self._check_item_fields(task_id, snapshot.plan, items, item, adding=True)
2866
+ self.commit(state, snapshot, items=(*items, item))
2867
+ self._share_items(task_id, snapshot.plan, (*items, item))
2868
+ return item
2869
+
2870
+ def _project_names(self) -> tuple[str, ...]:
2871
+ return tuple(entry.name for entry in self.extensions.config.projects)
2872
+
2873
+ def add_child(
2874
+ self,
2875
+ task_id: str,
2876
+ child_id: str | None,
2877
+ description: str,
2878
+ project: str | None = None,
2879
+ fields: tuple[tuple[str, str], ...] = (),
2880
+ ) -> ChildTask:
2881
+ validate_task_id(task_id)
2882
+ if not description.strip():
2883
+ raise StateError("child description must be non-empty")
2884
+ fields = _child_fields(fields)
2885
+ if project is not None:
2886
+ self._project_directory(project)
2887
+ with self.tasks.lock_task(task_id):
2888
+ state, snapshot = self.load(task_id)
2889
+ if not (
2890
+ state.active_item_id
2891
+ and snapshot.plan.items[state.cursor].child_operation == "collect"
2892
+ ):
2893
+ raise StateError(
2894
+ "children can only be added while a children step is active"
2895
+ )
2896
+ children = self.tasks.read_children(task_id, state.run_id)
2897
+ if child_id is None and self._children_bind_identity(snapshot.plan):
2898
+ # The child gets its ID from its own first step; until then a
2899
+ # request ID names it, and ``start-child`` opens that request.
2900
+ child_id = generated_bootstrap_id()
2901
+ while any(entry.id == child_id for entry in children):
2902
+ child_id = generated_bootstrap_id()
2903
+ elif child_id is None:
2904
+ child_id = self._generated_child_id(task_id, children, project)
2905
+ validate_child_id(child_id)
2906
+ if any(child.id == child_id for child in children):
2907
+ raise StateError(f"child {child_id!r} already exists")
2908
+ child = ChildTask(
2909
+ id=child_id,
2910
+ description=description,
2911
+ workflow="",
2912
+ task_id=f"{task_id}/{child_id}",
2913
+ start_operation_id=(
2914
+ f"{task_id}:{operation_scope_for(state)}:{child_id}:start"
2915
+ ),
2916
+ parent_task_id=task_id,
2917
+ project=project,
2918
+ fields=fields,
2919
+ )
2920
+ self.commit(
2921
+ state,
2922
+ snapshot,
2923
+ children=(*children, child),
2924
+ )
2925
+ return child
2926
+
2927
+ def update_child(
2928
+ self,
2929
+ task_id: str,
2930
+ child_id: str,
2931
+ *,
2932
+ text: str | None = None,
2933
+ project: str | None = None,
2934
+ fields: tuple[tuple[str, str], ...] = (),
2935
+ ) -> ChildTask:
2936
+ """Change a child's text, project, or custom fields.
2937
+
2938
+ Text and project are allowed while the child is ``pending``: during
2939
+ the children step, in a per-child stage before the child runs, and
2940
+ while the parent waits for its children. A started child already
2941
+ holds its requirements. Custom fields only feed the parent's
2942
+ per-child stages, so they may change at any time.
2943
+ """
2944
+ validate_task_id(task_id)
2945
+ validate_child_id(child_id)
2946
+ if text is None and project is None and not fields:
2947
+ raise StateError("update-child needs --text, --project, or --field")
2948
+ if text is not None and not text.strip():
2949
+ raise StateError("child text must be non-empty")
2950
+ fields = _child_fields(fields)
2951
+ if project is not None:
2952
+ self._project_directory(project)
2953
+ with self.tasks.lock_task(task_id):
2954
+ state, snapshot = self.load(task_id)
2955
+ children = list(self.tasks.read_children(task_id, state.run_id))
2956
+ index = next(
2957
+ (i for i, child in enumerate(children) if child.id == child_id), None
2958
+ )
2959
+ if index is None:
2960
+ raise StateError(f"child {child_id!r} was not found")
2961
+ child = children[index]
2962
+ if (text is not None or project is not None) and child.status != "pending":
2963
+ raise StateError(
2964
+ f"child {child_id!r} is {child.status}; only a pending child "
2965
+ "can change its text or project"
2966
+ )
2967
+ child = replace(
2968
+ child,
2969
+ description=text if text is not None else child.description,
2970
+ project=project if project is not None else child.project,
2971
+ ).with_fields(dict(fields))
2972
+ children[index] = child
2973
+ self.commit(state, snapshot, children=tuple(children))
2974
+ return child
2975
+
2976
+ def start_child(
2977
+ self,
2978
+ parent_task_id: str,
2979
+ child_id: str,
2980
+ *,
2981
+ workflow_name: str | None = None,
2982
+ workflow_runtime: str | None = None,
2983
+ model: str | None = None,
2984
+ reasoning: str | None = None,
2985
+ agent: str | None = None,
2986
+ ) -> Instruction:
2987
+ return self.children.start_child(
2988
+ parent_task_id,
2989
+ child_id,
2990
+ workflow_name=workflow_name,
2991
+ workflow_runtime=workflow_runtime,
2992
+ model=model,
2993
+ reasoning=reasoning,
2994
+ agent=agent,
2995
+ )
2996
+
2997
+ def _validate_child_workflow(
2998
+ self, workflow_name: str, child: ChildTask, parent: ExecutionState
2999
+ ) -> None:
3000
+ """Validate a launch target before freezing the parent's child record."""
3001
+ configuration = self._load_configuration()
3002
+ workflow = configuration.workflows_by_name.get(workflow_name)
3003
+ if workflow is None:
3004
+ raise ConfigurationError(f"workflow not found: {workflow_name}")
3005
+ require_lane(workflow)
3006
+ self._project_directory(child.project)
3007
+ plan = compile_workflow_plan(
3008
+ configuration,
3009
+ self.storage.root,
3010
+ workflow_name,
3011
+ child.agent or parent.agent,
3012
+ child.task_id,
3013
+ self.extensions,
3014
+ PlanCompilationOptions(task_id=child.task_id, project=child.project),
3015
+ self.extensions.config,
3016
+ )
3017
+ if any(item.child_operation is not None for item in plan.items):
3018
+ raise StateError("child workflows cannot use children")
3019
+ if (
3020
+ is_bootstrap_request(child.id)
3021
+ and self.bootstrap.step(
3022
+ configuration,
3023
+ workflow_name,
3024
+ (),
3025
+ child.agent or parent.agent,
3026
+ self._unknown_modes,
3027
+ project=child.project,
3028
+ )
3029
+ is None
3030
+ ):
3031
+ raise StateError(
3032
+ f"child workflow {workflow_name!r} declares no variable task_id in "
3033
+ "its first step; add the child with an explicit --id"
3034
+ )
3035
+
3036
+ @staticmethod
3037
+ def _children_bind_identity(plan: WorkflowPlan) -> bool:
3038
+ return any(item.child_identity for item in plan.items)
3039
+
3040
+ def _start_child_identity(
3041
+ self, child: ChildTask, workflow_name: str, parent: ExecutionState
3042
+ ) -> Instruction:
3043
+ """Open, or show, the identity request a child's own first step answers."""
3044
+ existing = self.storage.read_bootstrap(child.id)
3045
+ if existing is not None:
3046
+ return self.bootstrap.instruction(existing)
3047
+ item = self.bootstrap.step(
3048
+ self._load_configuration(),
3049
+ workflow_name,
3050
+ (),
3051
+ child.agent or parent.agent,
3052
+ self._unknown_modes,
3053
+ project=child.project,
3054
+ )
3055
+ if item is None:
3056
+ raise StateError(
3057
+ f"child workflow {workflow_name!r} declares no variable task_id in "
3058
+ "its first step; add the child with an explicit --id"
3059
+ )
3060
+ return self.bootstrap.start(
3061
+ workflow_name,
3062
+ (),
3063
+ child.agent or parent.agent,
3064
+ item,
3065
+ child.model or parent.model,
3066
+ child.reasoning or parent.reasoning,
3067
+ child.workflow_runtime or parent.workflow_runtime,
3068
+ None,
3069
+ f"Requirements for child task {child.id}: {child.description}",
3070
+ child.project,
3071
+ request_id=child.id,
3072
+ parent_task_id=parent.task_id,
3073
+ start_operation_id=child.start_operation_id,
3074
+ )
3075
+
3076
+ def update_item(
3077
+ self,
3078
+ task_id: str,
3079
+ item_id: str,
3080
+ *,
3081
+ caller_role: CallerRole | None = None,
3082
+ **changes: object,
3083
+ ) -> ItemUpdateResult:
3084
+ validate_task_id(task_id)
3085
+ self._validate_caller_role(caller_role)
3086
+ unexpected = set(changes) - EDITABLE_WORK_ITEM_FIELDS - {"item"}
3087
+ if unexpected:
3088
+ raise StateError("unknown item field(s): " + ", ".join(sorted(unexpected)))
3089
+ custom = changes.pop("fields", None)
3090
+ if custom is not None and not isinstance(custom, dict):
3091
+ raise StateError("item fields must be a mapping")
3092
+ with self.tasks.lock_task(task_id):
3093
+ run_id = self.tasks.active_execution_run(task_id)
3094
+ if run_id is None:
3095
+ raise StateError(f"task {task_id!r} has no active workflow run")
3096
+ state, snapshot = self.load(task_id, run_id)
3097
+ if "item" in changes and not _collecting(state, snapshot):
3098
+ raise StateError(
3099
+ "an item's text can change only while the collection step is "
3100
+ "in progress"
3101
+ )
3102
+ items = list(self.tasks.read_items(task_id, run_id))
3103
+ index = next(
3104
+ (i for i, item in enumerate(items) if item.id == item_id), None
3105
+ )
3106
+ if index is None:
3107
+ raise StateError(f"item {item_id!r} was not found")
3108
+ try:
3109
+ updated = WorkItem.from_dict({**items[index].to_dict(), **changes})
3110
+ if custom is not None:
3111
+ updated = updated.with_fields(dict(validate_item_fields(custom)))
3112
+ except ValueError as error:
3113
+ raise StateError(str(error)) from error
3114
+ if custom is not None:
3115
+ self._check_item_fields(
3116
+ task_id, snapshot.plan, tuple(items), updated, adding=False
3117
+ )
3118
+ items[index] = updated
3119
+ self.commit(state, snapshot, items=tuple(items))
3120
+ self._share_items(task_id, snapshot.plan, tuple(items))
3121
+ instruction = self._tag_caller(self.render(state, snapshot), caller_role)
3122
+ return ItemUpdateResult(updated, instruction.continuation_command)
3123
+
3124
+ def remove_item(self, task_id: str, item_id: str) -> WorkItem:
3125
+ """Drop an item while the collection step is in progress."""
3126
+ validate_task_id(task_id)
3127
+ with self.tasks.lock_task(task_id):
3128
+ run_id = self.tasks.active_execution_run(task_id)
3129
+ if run_id is None:
3130
+ raise StateError(f"task {task_id!r} has no active workflow run")
3131
+ state, snapshot = self.load(task_id, run_id)
3132
+ if not _collecting(state, snapshot):
3133
+ raise StateError(
3134
+ "an item can be removed only while the collection step is in "
3135
+ "progress"
3136
+ )
3137
+ items = self.tasks.read_items(task_id, run_id)
3138
+ removed = next((item for item in items if item.id == item_id), None)
3139
+ if removed is None:
3140
+ raise StateError(f"item {item_id!r} was not found")
3141
+ referrers = [item.id for item in items if item.reference_to_id == item_id]
3142
+ if referrers:
3143
+ raise StateError(
3144
+ f"item {item_id!r} is referenced by " + ", ".join(referrers)
3145
+ )
3146
+ kept = tuple(item for item in items if item.id != item_id)
3147
+ self.commit(state, snapshot, items=kept)
3148
+ self._share_items(task_id, snapshot.plan, kept)
3149
+ return removed
3150
+
3151
+ def initialize(
3152
+ self,
3153
+ *,
3154
+ workflows: str = DEFAULT_WORKFLOWS_YAML,
3155
+ project_config: str = DEFAULT_PROJECT_CONFIG_JSON,
3156
+ ignore_runtime: bool = False,
3157
+ skill_installs: tuple[tuple[str, str], ...] = (),
3158
+ ) -> InitializationResult:
3159
+ """Create the project files, with each chosen skill in its directory."""
3160
+ return self.storage.initialize_project(
3161
+ workflows,
3162
+ project_config,
3163
+ PROJECT_LAUNCHER,
3164
+ AGENT_INSTRUCTIONS,
3165
+ ignore_runtime=ignore_runtime,
3166
+ skills=tuple(
3167
+ (skill_location(directory, name), SKILLS[name])
3168
+ for directory, name in skill_installs
3169
+ ),
3170
+ )
3171
+
3172
+ def drain(
3173
+ self,
3174
+ state: ExecutionState,
3175
+ snapshot: PlanSnapshot,
3176
+ assignment_item_id: str | None = None,
3177
+ stop_at: int | None = None,
3178
+ ) -> tuple[ExecutionState, PlanSnapshot]:
3179
+ plan = snapshot.plan
3180
+ while state.cursor < len(plan.items):
3181
+ if stop_at is not None and state.cursor >= stop_at:
3182
+ return state, snapshot
3183
+ prior = state
3184
+ state = finish_loop_continue(state, plan, _now)
3185
+ if state is not prior:
3186
+ self.commit(state, snapshot)
3187
+ exiting_children = _exiting_children(state, plan)
3188
+ state = finish_loop_exit(state, plan, _now)
3189
+ if state is not prior:
3190
+ self.commit(
3191
+ state,
3192
+ snapshot,
3193
+ children=(
3194
+ skip_pending(
3195
+ self.tasks.read_children(state.task_id, state.run_id)
3196
+ )
3197
+ if exiting_children and state.loop_exit_item_id is None
3198
+ else None
3199
+ ),
3200
+ )
3201
+ if state.cursor >= len(plan.items):
3202
+ break
3203
+ assignment = active_assignment(
3204
+ plan, assignment_item_id, runtime=state.workflow_runtime
3205
+ )
3206
+ if assignment_item_id is not None and (
3207
+ assignment is None or state.cursor >= assignment.stop
3208
+ ):
3209
+ return state, snapshot
3210
+ item = plan.items[state.cursor]
3211
+ if child_workflow(item) is not None:
3212
+ state = begin_child_workflow(state, item, _now)
3213
+ self.commit(state, snapshot)
3214
+ return state, snapshot
3215
+ blocked = self._block_unfinished_pass(state, plan)
3216
+ if blocked is not None:
3217
+ self.commit(blocked, snapshot)
3218
+ return blocked, snapshot
3219
+ record = state.item_executions[state.cursor]
3220
+ if needs_repair(state):
3221
+ return state, snapshot
3222
+ if record.status == "completed":
3223
+ state = advance_completed_item(state, _now)
3224
+ self.commit(state, snapshot)
3225
+ continue
3226
+ if workflow_transition(item) is not None:
3227
+ return self._handoff(state, snapshot, item)
3228
+ if loop_control(item) is not None:
3229
+ return state, snapshot
3230
+ if (
3231
+ item.name == INIT_STEP_NAME
3232
+ and item.step == INIT_STEP_NAME
3233
+ and item.phase == "step"
3234
+ and item.owner == "agent"
3235
+ and state.pending_init_artifact is not None
3236
+ ):
3237
+ state = begin_agent_item(
3238
+ state,
3239
+ plan,
3240
+ item,
3241
+ model=requested_setting(item.model) or state.model,
3242
+ reasoning=requested_setting(item.reasoning) or state.reasoning,
3243
+ now=_now,
3244
+ )
3245
+ self.commit(state, snapshot)
3246
+ return self._complete_initialization_item(state, snapshot, stop_at)
3247
+ if item.verifies is not None and not record.verification:
3248
+ # No round asks this verifier anything, for example after a
3249
+ # loop reset its record.
3250
+ state = skip_idle_verification(state, _now)
3251
+ self.commit(state, snapshot)
3252
+ continue
3253
+ if item.owner == "agent" and record.held_completion is not None:
3254
+ # Its verification finished, but ww stopped before recording
3255
+ # the completion: record it now.
3256
+ self._replay_held(state.task_id, state.cursor, caller_role=None)
3257
+ return self.load(state.task_id, state.run_id)
3258
+ if item.owner == "agent":
3259
+ unavailable = (
3260
+ self._unavailable_values(state, plan, item)
3261
+ if record.status != "in_progress"
3262
+ else ()
3263
+ )
3264
+ state = (
3265
+ stop_for_values(
3266
+ state,
3267
+ plan,
3268
+ item,
3269
+ self._unavailable_error(unavailable, state, item),
3270
+ _now,
3271
+ )
3272
+ if unavailable
3273
+ else pause_for_agent(state, _now)
3274
+ )
3275
+ self.commit(state, snapshot)
3276
+ return state, snapshot
3277
+ if pending_assessment(state, plan) is not None:
3278
+ # An outcome's automatic work waits for the chosen outcome,
3279
+ # as an outcome's agent work does.
3280
+ state = pause_for_agent(state, _now)
3281
+ self.commit(state, snapshot)
3282
+ return state, snapshot
3283
+ if not (item.execution == "automatic" and item.owner == "ww"):
3284
+ raise StateError(f"invalid automatic plan item {item.id!r}")
3285
+ missing = [
3286
+ value
3287
+ for value in item.provide
3288
+ if value.name not in dict(state.workflow_values)
3289
+ ]
3290
+ if missing:
3291
+ state = await_item_input(state, item, tuple(missing), _now)
3292
+ self.commit(state, snapshot)
3293
+ return state, snapshot
3294
+ state = self.actions.run(state, snapshot, item)
3295
+ if state.status == "failed":
3296
+ if item.on_failure == "fix":
3297
+ state = request_repair(state, plan, item, _now)
3298
+ self.commit(state, snapshot)
3299
+ return state, snapshot
3300
+ state = complete_run(state, plan, _now)
3301
+ self.commit(state, snapshot)
3302
+ return state, snapshot
3303
+
3304
+ def _block_unfinished_pass(
3305
+ self, state: ExecutionState, plan: WorkflowPlan
3306
+ ) -> ExecutionState | None:
3307
+ """Stop before leaving an items pass whose declared phases are unmet.
3308
+
3309
+ Checked once the pass's last stage is done and before the next item
3310
+ starts; only what the pass's stages declared, and actually ran, is
3311
+ required (see ``ww.item_passes``). When that last stage is an
3312
+ assessment, the pass ends only once its outcome is chosen: an outcome
3313
+ whose work belongs to the pass runs first, and one that stops the
3314
+ workflow ends the run without a gate.
3315
+ """
3316
+ pass_id = leaving_pass(plan, state.cursor)
3317
+ following = state.item_executions[state.cursor]
3318
+ if (
3319
+ pass_id is None
3320
+ or following.status != "pending"
3321
+ or following.started_at is not None
3322
+ or pending_assessment(state, plan) is not None
3323
+ ):
3324
+ return None
3325
+ unfinished = pass_gate_failures(
3326
+ plan,
3327
+ state.item_executions,
3328
+ pass_id,
3329
+ self.tasks.read_items(state.task_id, state.run_id),
3330
+ )
3331
+ if not unfinished:
3332
+ return None
3333
+ message = (
3334
+ f"items pass {pass_id!r} cannot complete; its items lack what its "
3335
+ "stages declare: "
3336
+ + "; ".join(unfinished)
3337
+ + ". Record it with update-item, then retry"
3338
+ )
3339
+ return block_item_phase(state, message, _now)
3340
+
3341
+ def _handoff(
3342
+ self, state: ExecutionState, snapshot: PlanSnapshot, item: PlanItem
3343
+ ) -> tuple[ExecutionState, PlanSnapshot]:
3344
+ if not snapshot.plan.handoff:
3345
+ raise StateError(
3346
+ "workflow transitions are only supported by handoff workflows"
3347
+ )
3348
+ decision = workflow_transition(item)
3349
+ if decision is None:
3350
+ raise StateError("handoff item has no workflow-transition capability")
3351
+ target = _render_template(
3352
+ decision.target,
3353
+ {
3354
+ **dict(state.workflow_values),
3355
+ **self._runtime_values(state, snapshot.plan),
3356
+ },
3357
+ )
3358
+ if not target or target == state.workflow:
3359
+ raise StateError("handoff target must name a different workflow")
3360
+ if self.tasks.read_handoff(state.task_id) is not None:
3361
+ raise StateError(f"task {state.task_id!r} already has a handoff marker")
3362
+ configuration = self._load_configuration()
3363
+ if target not in configuration.workflows_by_name:
3364
+ raise StateError(f"handoff target workflow not found: {target}")
3365
+ require_lane(configuration.workflows_by_name[target])
3366
+ new_snapshot = PlanSnapshot(
3367
+ schema_version=PLAN_SCHEMA_VERSION,
3368
+ compiler_version=PLAN_COMPILER_VERSION,
3369
+ configuration_digest=self._configuration_digest(configuration),
3370
+ compiled_at=_now(),
3371
+ plan=compile_workflow_plan(
3372
+ configuration,
3373
+ self.storage.root,
3374
+ target,
3375
+ state.agent,
3376
+ state.task_id,
3377
+ self.extensions,
3378
+ PlanCompilationOptions(
3379
+ task_id=state.task_id,
3380
+ project=dict(state.workflow_values).get(PROJECT) or None,
3381
+ modes=state.modes,
3382
+ ),
3383
+ self.extensions.config,
3384
+ ),
3385
+ )
3386
+ # The selection workflow is a run in its own right: finish it, and give
3387
+ # the target its own numbered run rather than overwriting the plan and
3388
+ # state that recorded how the target was chosen.
3389
+ finished = project_steps(
3390
+ finish_selection(state, target, _now), snapshot.plan, _now
3391
+ )
3392
+ existing_runs, _ = self.tasks.read_task_aggregate(state.task_id)
3393
+ next_number = (
3394
+ max(
3395
+ (int(run.run_id.split("-", 1)[0]) for run in existing_runs),
3396
+ default=0,
3397
+ )
3398
+ + 1
3399
+ )
3400
+ run_id = self.tasks.run_id_for(next_number, target)
3401
+ carried = {
3402
+ name: value
3403
+ for name, value in state.workflow_values
3404
+ if name in {PROJECT, BRANCH_NAMING_STRATEGY}
3405
+ }
3406
+ base_state = initial_state(
3407
+ new_snapshot,
3408
+ state.modes,
3409
+ _now(),
3410
+ run_id=run_id,
3411
+ execution_instance_id=uuid.uuid4().hex,
3412
+ parent_task_id=state.parent_task_id,
3413
+ start_operation_id=state.start_operation_id,
3414
+ workflow_runtime=state.workflow_runtime,
3415
+ model=state.model,
3416
+ reasoning=state.reasoning,
3417
+ )
3418
+ next_state = replace(
3419
+ base_state,
3420
+ run_id=run_id,
3421
+ workflow_values=tuple(
3422
+ {**dict(base_state.workflow_values), **carried}.items()
3423
+ ),
3424
+ working_directory=self._project_directory(carried.get(PROJECT)),
3425
+ pending_init_artifact=(
3426
+ "Continue task requirements after handoff from "
3427
+ f"{state.workflow} to {target}."
3428
+ ),
3429
+ )
3430
+ runs, _ = self.tasks.read_task_aggregate(state.task_id)
3431
+ source_existing = next(
3432
+ (run for run in runs if run.run_id == state.run_id), None
3433
+ )
3434
+ source = TaskRunAggregate(
3435
+ run_id=finished.run_id,
3436
+ workflow=finished.workflow,
3437
+ snapshot=snapshot,
3438
+ state=finished,
3439
+ items=source_existing.items if source_existing else (),
3440
+ children=source_existing.children if source_existing else (),
3441
+ bootstrap_request_id=(
3442
+ source_existing.bootstrap_request_id if source_existing else None
3443
+ ),
3444
+ )
3445
+ target_run = TaskRunAggregate(
3446
+ run_id=run_id,
3447
+ workflow=target,
3448
+ snapshot=new_snapshot,
3449
+ state=next_state,
3450
+ )
3451
+ self._commit_runs(
3452
+ state.task_id,
3453
+ tuple(source if run.run_id == source.run_id else run for run in runs)
3454
+ + (() if any(run.run_id == source.run_id for run in runs) else (source,))
3455
+ + (target_run,),
3456
+ handoff=f"{state.workflow}={target}",
3457
+ )
3458
+ return self._complete_initialization(next_state, new_snapshot)
3459
+
3460
+ def _complete_initialization(
3461
+ self, state: ExecutionState, snapshot: PlanSnapshot
3462
+ ) -> tuple[ExecutionState, PlanSnapshot]:
3463
+ """Advance only the persisted init lifecycle during start.
3464
+
3465
+ Init preparation may itself pause, fail, or request input. The
3466
+ submitted requirements stay on the state until the init item is
3467
+ reached; no first-user-step preparation can consume them during start.
3468
+ """
3469
+ return self.drain(
3470
+ state, snapshot, stop_at=self._initialization_stop(snapshot.plan)
3471
+ )
3472
+
3473
+ @staticmethod
3474
+ def _initialization_stop(plan: WorkflowPlan) -> int:
3475
+ """Return the first item outside the implicit init lifecycle."""
3476
+ return next(
3477
+ (
3478
+ index
3479
+ for index, item in enumerate(plan.items)
3480
+ if item.step != INIT_STEP_NAME
3481
+ ),
3482
+ len(plan.items),
3483
+ )
3484
+
3485
+ def _complete_initialization_item(
3486
+ self,
3487
+ state: ExecutionState,
3488
+ snapshot: PlanSnapshot,
3489
+ stop_at: int | None,
3490
+ ) -> tuple[ExecutionState, PlanSnapshot]:
3491
+ """Complete init without opening a normal assignment completion window."""
3492
+ artifact = state.pending_init_artifact
3493
+ if artifact is None: # pragma: no cover - guarded by _drain
3494
+ raise StateError("built-in init has no saved requirements")
3495
+ self._complete(
3496
+ state.task_id,
3497
+ (),
3498
+ artifact,
3499
+ (),
3500
+ initialization=True,
3501
+ drain_stop=stop_at,
3502
+ )
3503
+ return self.load(state.task_id, state.run_id)
3504
+
3505
+ def _begin_assignment(
3506
+ self,
3507
+ state: ExecutionState,
3508
+ snapshot: PlanSnapshot,
3509
+ *,
3510
+ model: str,
3511
+ reasoning: str,
3512
+ selected_agent: str | None = None,
3513
+ cursor: int | None = None,
3514
+ ) -> ExecutionState:
3515
+ """Persist the structural assignment selected by a manager command."""
3516
+ assignment = assignment_at(
3517
+ snapshot.plan,
3518
+ state.cursor if cursor is None else cursor,
3519
+ runtime=state.workflow_runtime,
3520
+ )
3521
+ if assignment is None:
3522
+ return state
3523
+ assigned_model = model if model != "auto" else None
3524
+ assigned_reasoning = reasoning if reasoning != "auto" else None
3525
+ state = replace(
3526
+ state,
3527
+ assignment_item_id=assignment.first_item_id,
3528
+ assignment_token=secrets.token_hex(4),
3529
+ assignment_model=assigned_model,
3530
+ assignment_reasoning=assigned_reasoning,
3531
+ assignment_selected_agent=selected_agent,
3532
+ assignment_selected_model=assigned_model,
3533
+ assignment_selected_reasoning=assigned_reasoning,
3534
+ )
3535
+ self.commit(state, snapshot)
3536
+ return state
3537
+
3538
+ def _activate_or_handoff(
3539
+ self,
3540
+ state: ExecutionState,
3541
+ snapshot: PlanSnapshot,
3542
+ *,
3543
+ activate: bool = True,
3544
+ ) -> tuple[ExecutionState, PlanSnapshot]:
3545
+ """Drain one assignment, activate its next agent item, or hand it back.
3546
+
3547
+ Without ``activate`` the assignment ends at its next agent item, which
3548
+ waits for the manager to dispatch it as a new assignment.
3549
+ """
3550
+ assignment_id = state.assignment_item_id
3551
+ if assignment_id is None:
3552
+ return state, snapshot
3553
+ state, snapshot = self.drain(state, snapshot, assignment_id)
3554
+ if needs_repair(state):
3555
+ return state, snapshot
3556
+ if state.status in {"failed", "interrupted", "awaiting_input"}:
3557
+ return state, snapshot
3558
+ assignment = active_assignment(
3559
+ snapshot.plan, assignment_id, runtime=state.workflow_runtime
3560
+ )
3561
+ if (
3562
+ not activate
3563
+ or state.status == "completed"
3564
+ or assignment is None
3565
+ or state.cursor >= assignment.stop
3566
+ ):
3567
+ # Records an assessment skipped sit right after the assignment's
3568
+ # last item; the next dispatch starts beyond them, not on one.
3569
+ while (
3570
+ state.cursor < len(snapshot.plan.items)
3571
+ and state.item_executions[state.cursor].status == "completed"
3572
+ ):
3573
+ state = advance_completed_item(state, _now)
3574
+ state = replace(
3575
+ state,
3576
+ assignment_item_id=None,
3577
+ assignment_token=None,
3578
+ assignment_model=None,
3579
+ assignment_reasoning=None,
3580
+ assignment_selected_agent=None,
3581
+ assignment_selected_model=None,
3582
+ assignment_selected_reasoning=None,
3583
+ )
3584
+ self.commit(state, snapshot)
3585
+ return state, snapshot
3586
+ item = snapshot.plan.items[state.cursor]
3587
+ if item.owner != "agent":
3588
+ raise StateError("assignment stopped on a non-agent item")
3589
+ state = begin_agent_item(
3590
+ state,
3591
+ snapshot.plan,
3592
+ item,
3593
+ model=state.assignment_model
3594
+ or requested_setting(item.model)
3595
+ or state.model,
3596
+ reasoning=state.assignment_reasoning
3597
+ or requested_setting(item.reasoning)
3598
+ or state.reasoning,
3599
+ selected_agent=state.assignment_selected_agent,
3600
+ selected_model=state.assignment_selected_model,
3601
+ selected_reasoning=state.assignment_selected_reasoning,
3602
+ change_mark=self._change_mark(state, snapshot.plan, item),
3603
+ resolution=self._resolution(state, snapshot.plan, item),
3604
+ now=_now,
3605
+ )
3606
+ self.commit(state, snapshot)
3607
+ return state, snapshot
3608
+
3609
+ def _hold_for_verification(
3610
+ self,
3611
+ state: ExecutionState,
3612
+ snapshot: PlanSnapshot,
3613
+ item: PlanItem,
3614
+ check_report: CheckReport | None,
3615
+ artifact: str | None,
3616
+ request: HeldCompletion,
3617
+ ) -> Instruction | None:
3618
+ """Hold a completion whose rules still need a verifier.
3619
+
3620
+ Returns ``None`` when nothing is left to verify, and the completion
3621
+ is recorded as usual. Otherwise the completion, its change set, and
3622
+ its draft artifact are kept on the record, and a verification round
3623
+ opens for the rules that need one.
3624
+ """
3625
+ record = state.item_executions[state.cursor]
3626
+ needs = verification_needs(item, record)
3627
+ if not needs:
3628
+ return None
3629
+ directory = self._check_scope(state, snapshot.plan, item).directory
3630
+ if check_report is not None:
3631
+ mark = check_report.mark
3632
+ else:
3633
+ mark = take_mark(directory) if record.change_mark else None
3634
+ files, unmarked = change_set(directory, record.change_mark, mark)
3635
+ held = replace(
3636
+ request,
3637
+ mark=mark,
3638
+ files=files,
3639
+ all_files=unmarked,
3640
+ draft_ref=self._write_draft(state, item, record, artifact),
3641
+ report=check_report,
3642
+ )
3643
+ state = hold_completion(state, snapshot.plan, held, artifact, _now)
3644
+ state, snapshot = open_verification_round(state, snapshot, item, needs, _now)
3645
+ self.commit(state, snapshot)
3646
+ return replace(self.render(state, snapshot), completion_held=True)
3647
+
3648
+ def _write_draft(
3649
+ self,
3650
+ state: ExecutionState,
3651
+ item: PlanItem,
3652
+ record: PlanItemExecution,
3653
+ artifact: str | None,
3654
+ ) -> str | None:
3655
+ """Write the held artifact where the verifiers can read it."""
3656
+ if artifact is None:
3657
+ return None
3658
+ return self.tasks.write_command_output(
3659
+ CommandOutputAddress(
3660
+ state.task_id,
3661
+ state.run_id or state.workflow,
3662
+ item.id,
3663
+ f"{record.operation_id or item.id}:draft",
3664
+ max(1, record.attempts),
3665
+ 1,
3666
+ "stdout",
3667
+ ),
3668
+ artifact,
3669
+ )
3670
+
3671
+ def _dispatch_repair(
3672
+ self,
3673
+ state: ExecutionState,
3674
+ snapshot: PlanSnapshot,
3675
+ model: str = "auto",
3676
+ reasoning: str = "auto",
3677
+ selected_agent: str | None = None,
3678
+ ) -> Instruction:
3679
+ item = snapshot.plan.items[state.cursor]
3680
+ if state.active_item_id is not None:
3681
+ return self.render(state, snapshot)
3682
+ records = list(state.item_executions)
3683
+ records[state.cursor] = replace(
3684
+ records[state.cursor],
3685
+ selected_agent=selected_agent,
3686
+ selected_model=model if model != "auto" else None,
3687
+ selected_reasoning=reasoning if reasoning != "auto" else None,
3688
+ )
3689
+ state = replace(
3690
+ close_assignment(state),
3691
+ item_executions=tuple(records),
3692
+ status="in_progress",
3693
+ active_item_id=item.id,
3694
+ assignment_item_id=item.id if state.workflow_runtime == "auto" else None,
3695
+ assignment_token=secrets.token_hex(4)
3696
+ if state.workflow_runtime == "auto"
3697
+ else None,
3698
+ assignment_model=model
3699
+ if model != "auto"
3700
+ else requested_setting(item.model) or state.model,
3701
+ assignment_reasoning=reasoning
3702
+ if reasoning != "auto"
3703
+ else requested_setting(item.reasoning) or state.reasoning,
3704
+ assignment_selected_agent=selected_agent,
3705
+ assignment_selected_model=model if model != "auto" else None,
3706
+ assignment_selected_reasoning=reasoning if reasoning != "auto" else None,
3707
+ updated_at=_now(),
3708
+ )
3709
+ self.commit(state, snapshot)
3710
+ return self.render(state, snapshot)
3711
+
3712
+ def _complete_repair(
3713
+ self,
3714
+ state: ExecutionState,
3715
+ snapshot: PlanSnapshot,
3716
+ artifact: str | None,
3717
+ summary: str | None,
3718
+ ) -> Instruction:
3719
+ item = snapshot.plan.items[state.cursor]
3720
+ if state.active_item_id != item.id:
3721
+ raise StateError("no repair assignment is in progress; use next")
3722
+ if artifact is None or not artifact.strip():
3723
+ raise StateError(
3724
+ "repair completion requires an artifact describing the fix"
3725
+ )
3726
+ record = state.item_executions[state.cursor]
3727
+ reference = self.tasks.write_command_output(
3728
+ CommandOutputAddress(
3729
+ state.task_id,
3730
+ state.run_id,
3731
+ item.id,
3732
+ f"{record.operation_id or item.id}:repair",
3733
+ max(1, record.repair_failures),
3734
+ 1,
3735
+ "stdout",
3736
+ ),
3737
+ artifact,
3738
+ )
3739
+ previous_assignment = state
3740
+ records = list(state.item_executions)
3741
+ records[state.cursor] = replace(
3742
+ record,
3743
+ repair_artifacts=(*record.repair_artifacts, reference),
3744
+ summary_for_next=summary,
3745
+ )
3746
+ state = replace(state, item_executions=tuple(records))
3747
+ state = close_assignment(retry_failed_item(state, snapshot.plan, _now))
3748
+ self.commit(state, snapshot)
3749
+ state, snapshot = self.drain(state, snapshot)
3750
+ if (
3751
+ needs_repair(state)
3752
+ and state.status != "failed"
3753
+ and state.workflow_runtime == "auto"
3754
+ and snapshot.plan.items[state.cursor].id == item.id
3755
+ ):
3756
+ # The same repair worker retains its assignment for another attempt.
3757
+ state = replace(
3758
+ state,
3759
+ status="in_progress",
3760
+ active_item_id=item.id,
3761
+ assignment_item_id=item.id,
3762
+ assignment_token=previous_assignment.assignment_token,
3763
+ assignment_model=previous_assignment.assignment_model,
3764
+ assignment_reasoning=previous_assignment.assignment_reasoning,
3765
+ assignment_selected_agent=previous_assignment.assignment_selected_agent,
3766
+ assignment_selected_model=previous_assignment.assignment_selected_model,
3767
+ assignment_selected_reasoning=previous_assignment.assignment_selected_reasoning,
3768
+ )
3769
+ self.commit(state, snapshot)
3770
+ return self.render(state, snapshot)
3771
+
3772
+ def _complete_verification(
3773
+ self,
3774
+ state: ExecutionState,
3775
+ snapshot: PlanSnapshot,
3776
+ item: PlanItem,
3777
+ artifact: str | None,
3778
+ rule_results: tuple[str, ...],
3779
+ *,
3780
+ caller_role: CallerRole | None,
3781
+ ) -> Instruction:
3782
+ """Record a verifier's verdicts, then continue the held step.
3783
+
3784
+ A failing verdict sends the step back to its worker; otherwise the
3785
+ next verifier of the round goes on, or ww records the held completion.
3786
+ """
3787
+ record = state.item_executions[state.cursor]
3788
+ target = item.verifies
3789
+ assert target is not None
3790
+ if not record.verification:
3791
+ raise StateError(f"{item.name!r} has no rules to verify in this round")
3792
+ if artifact is None or not artifact.strip():
3793
+ raise StateError(
3794
+ f"artifact is required to complete {item.name!r}: pass your "
3795
+ "findings with --artifact"
3796
+ )
3797
+ plan = snapshot.plan
3798
+ index = index_of(plan, target.item_id)
3799
+ step = plan.items[index]
3800
+ results = parse_rule_results(rule_results, record.verification)
3801
+ artifact_reference, _ = write_completion_artifacts(
3802
+ self.tasks, state.task_id, state, snapshot, item, None, artifact
3803
+ )
3804
+ state = complete_agent_item(state, plan, {}, artifact_reference, _now)
3805
+ verdicts = verdicts_of(results, item.id)
3806
+ state = record_round(state, index, verdicts, _now)
3807
+ step_record = state.item_executions[index]
3808
+ if any(verdict.verdict == "fail" for verdict in verdicts):
3809
+ at_step = replace(close_round(state, plan, step.id, _now), cursor=index)
3810
+ held = step_record.held_completion
3811
+ assert held is not None
3812
+ attempt = max((entry.attempt for entry in item_reports(at_step)), default=0)
3813
+ state = reject_completion(
3814
+ at_step,
3815
+ plan,
3816
+ step,
3817
+ judged_report(verdicts, attempt + 1, _now(), held),
3818
+ step_record.draft_artifact,
3819
+ _now,
3820
+ keep_active=False,
3821
+ )
3822
+ self.commit(state, snapshot)
3823
+ return self._after_verification(state, snapshot, caller_role)
3824
+ if round_open(state, plan, step.id):
3825
+ self.commit(state, snapshot)
3826
+ return self._after_verification(state, snapshot, caller_role)
3827
+ self.commit(state, snapshot)
3828
+ return self._replay_held(state.task_id, index, caller_role=caller_role)
3829
+
3830
+ def _after_verification(
3831
+ self,
3832
+ state: ExecutionState,
3833
+ snapshot: PlanSnapshot,
3834
+ caller_role: CallerRole | None,
3835
+ ) -> Instruction:
3836
+ """End a verifier's assignment, or open the next agent item without one."""
3837
+ if state.status == "failed":
3838
+ return self.render(state, snapshot)
3839
+ if caller_role is not None:
3840
+ state, snapshot = self._activate_or_handoff(state, snapshot)
3841
+ else:
3842
+ state, snapshot = self.drain(state, snapshot)
3843
+ return self.render(state, snapshot)
3844
+
3845
+ def _replay_held(
3846
+ self, task_id: str, index: int, *, caller_role: CallerRole | None
3847
+ ) -> Instruction:
3848
+ """Complete a held step exactly as its worker submitted it.
3849
+
3850
+ The checks run again, reusing results while the tree is unchanged;
3851
+ what is still to verify opens another round. A caller with a role
3852
+ gets the step's own assignment, so the completion window and the
3853
+ automatic items that drain after it are the step worker's; the caller
3854
+ is a verifier, never that worker, so the assignment ends before its
3855
+ next agent item, which the manager dispatches anew.
3856
+ """
3857
+ state, snapshot = self.load(task_id)
3858
+ record = state.item_executions[index]
3859
+ held = record.held_completion
3860
+ if held is None:
3861
+ raise StateError("the step has no held completion to record")
3862
+ state = resume_held(state, snapshot.plan, index, _now)
3863
+ self.commit(state, snapshot)
3864
+ if caller_role is not None:
3865
+ state = self._begin_assignment(
3866
+ state,
3867
+ snapshot,
3868
+ model=record.model or state.model,
3869
+ reasoning=record.reasoning or state.reasoning,
3870
+ cursor=index,
3871
+ )
3872
+ return self._complete(
3873
+ task_id,
3874
+ held.variables,
3875
+ record.draft_artifact,
3876
+ held.metadata_values,
3877
+ selected_agent=held.selected_agent,
3878
+ selected_model=held.selected_model,
3879
+ selected_reasoning=held.selected_reasoning,
3880
+ summary_for_next=held.summary_for_next,
3881
+ caller_role=caller_role,
3882
+ stopping_loop=held.loop_control == "break",
3883
+ continuing_loop=held.loop_control == "continue",
3884
+ replayed=True,
3885
+ )
3886
+
3887
+ def _resolution(
3888
+ self, state: ExecutionState, plan: WorkflowPlan, item: PlanItem
3889
+ ) -> tuple[tuple[RuleResolution, ...], tuple[PlannedCheck, ...]] | None:
3890
+ """How the rules without a command of a beginning step are enforced.
3891
+
3892
+ Read from the store once, when the step first begins; a step that
3893
+ began before keeps what it began with. A converted check applies only
3894
+ where its configuration files exist in the directory the step's
3895
+ checks run in.
3896
+ """
3897
+ if (
3898
+ not judged_rules(item)
3899
+ or state.item_executions[state.cursor].rule_resolutions
3900
+ ):
3901
+ return None
3902
+ return resolve_rules(
3903
+ item,
3904
+ self.rule_store.load(),
3905
+ directory=self._check_scope(state, plan, item).directory,
3906
+ )
3907
+
3908
+ def _check_scope(
3909
+ self, state: ExecutionState, plan: WorkflowPlan, item: PlanItem
3910
+ ) -> CheckScope:
3911
+ """The directory an item's checks run in and the values they render."""
3912
+ workspace, values = self.actions.item_scope(state, plan, item)
3913
+ return CheckScope(workspace or self.storage.root, values)
3914
+
3915
+ def _change_mark(
3916
+ self, state: ExecutionState, plan: WorkflowPlan, item: PlanItem
3917
+ ) -> str | None:
3918
+ """The tree a step's change set starts from, taken as it begins.
3919
+
3920
+ A step that already has one keeps it; a step without rules or checks,
3921
+ or a directory without git, has none. A step with rules needs it even
3922
+ without checks: its verifiers look at what it changed.
3923
+ """
3924
+ if (
3925
+ not (item.checks or item.rules)
3926
+ or state.item_executions[state.cursor].change_mark
3927
+ ):
3928
+ return None
3929
+ return take_mark(self._check_scope(state, plan, item).directory)
3930
+
3931
+ def render(self, state: ExecutionState, snapshot: PlanSnapshot) -> Instruction:
3932
+ return self.instructions.build(state, snapshot)
3933
+
3934
+ def resume(self, state: ExecutionState, snapshot: PlanSnapshot) -> Instruction:
3935
+ """Commit, continue the active assignment or drain, then instruct."""
3936
+ self.commit(state, snapshot)
3937
+ if state.assignment_item_id is not None:
3938
+ state, snapshot = self._activate_or_handoff(state, snapshot)
3939
+ else:
3940
+ state, snapshot = self.drain(state, snapshot)
3941
+ return self.render(state, snapshot)
3942
+
3943
+ @staticmethod
3944
+ def _validate_caller_role(caller_role: CallerRole | None) -> None:
3945
+ if caller_role is not None and caller_role not in CALLER_ROLES:
3946
+ raise StateError("caller role must be 'manager' or 'worker'")
3947
+
3948
+ def _reassign(self, task_id: str) -> Instruction:
3949
+ """Close the open assignment's token and issue a new one."""
3950
+ validate_task_id(task_id)
3951
+ with self.tasks.lock_task(task_id):
3952
+ state, snapshot = self.load(task_id)
3953
+ if state.assignment_item_id is None:
3954
+ raise StateError(
3955
+ f"task {task_id!r} has no open assignment to reassign; "
3956
+ "run next to dispatch one"
3957
+ )
3958
+ state = replace(state, assignment_token=secrets.token_hex(4))
3959
+ self.commit(state, snapshot)
3960
+ return self.render(state, snapshot)
3961
+
3962
+ def _authorize_worker(
3963
+ self,
3964
+ task_id: str,
3965
+ caller_role: CallerRole | None,
3966
+ assignment: str | None,
3967
+ *,
3968
+ read: bool = False,
3969
+ ) -> None:
3970
+ """Refuse a worker command that does not carry the open assignment's token.
3971
+
3972
+ Only the ``auto`` runtime delegates, so only a worker there can outlive
3973
+ its assignment. A missing or stale token ends the worker's turn: it
3974
+ returns to the manager, who alone can re-issue the token. A ``read``
3975
+ while no assignment is open is answered: its page only sends the
3976
+ worker back to the manager.
3977
+ """
3978
+ if caller_role != "worker":
3979
+ return
3980
+ state, _ = self.load(task_id)
3981
+ if state.workflow_runtime != "auto":
3982
+ return
3983
+ manager = instruction_command(task_id, role="manager")
3984
+ if state.assignment_token is None:
3985
+ if read:
3986
+ return
3987
+ raise StateError(
3988
+ "no assignment is open: your assignment has ended. Stop here "
3989
+ "and return to your manager; run no further ww command."
3990
+ )
3991
+ if assignment is None:
3992
+ raise StateError(
3993
+ "a worker command needs --assignment <token>, which the "
3994
+ "manager's bootstrap command gives. Stop and return to your "
3995
+ f"manager; it gets the token with `{manager}`."
3996
+ )
3997
+ if assignment != state.assignment_token:
3998
+ raise StateError(
3999
+ f"assignment {assignment} is not open: your assignment has "
4000
+ "ended. Stop here and return to your manager; run no further "
4001
+ "ww command."
4002
+ )
4003
+
4004
+ def _check_performer(
4005
+ self, task_id: str, caller_role: CallerRole | None, *, loop: bool = False
4006
+ ) -> None:
4007
+ """Refuse a completion by the role that does not perform the open step.
4008
+
4009
+ In ``auto`` the manager performs its own steps (``role: manager``, and
4010
+ interactive ones), so a worker never completes one. The manager keeps
4011
+ every override on the other steps; ``loop`` stays a worker command
4012
+ there, as the pages give it only to the step's worker.
4013
+ """
4014
+ state, snapshot = self.load(task_id)
4015
+ plan = snapshot.plan
4016
+ item = (
4017
+ plan.items[state.cursor]
4018
+ if state.active_item_id is not None and state.cursor < len(plan.items)
4019
+ else None
4020
+ )
4021
+ managers = (
4022
+ state.workflow_runtime == "auto"
4023
+ and item is not None
4024
+ and item.id == state.active_item_id
4025
+ and item.role == "manager"
4026
+ )
4027
+ if caller_role == "worker" and managers:
4028
+ assert item is not None
4029
+ raise StateError(
4030
+ f"this step is the manager's: {item.name!r} is performed by the "
4031
+ "manager in its own session, so a worker cannot complete it. "
4032
+ "Stop here and return to your manager; run no further ww command."
4033
+ )
4034
+ if loop and caller_role == "manager" and not managers:
4035
+ raise StateError("loop --break/--continue is a worker-role command")
4036
+
4037
+ def _open_assignment(
4038
+ self, task_id: str, caller_role: CallerRole | None
4039
+ ) -> OpenAssignment | None:
4040
+ """The worker's open assignment before its command runs, in ``auto``."""
4041
+ if caller_role != "worker":
4042
+ return None
4043
+ state, snapshot = self.load(task_id)
4044
+ if state.workflow_runtime != "auto" or state.assignment_token is None:
4045
+ return None
4046
+ if needs_repair(state):
4047
+ return OpenAssignment(
4048
+ state.assignment_token,
4049
+ (snapshot.plan.items[state.cursor].id,),
4050
+ state.active_item_id,
4051
+ )
4052
+ assignment = active_assignment(
4053
+ snapshot.plan, state.assignment_item_id, runtime=state.workflow_runtime
4054
+ )
4055
+ if assignment is None:
4056
+ return None
4057
+ return OpenAssignment(
4058
+ state.assignment_token,
4059
+ tuple(
4060
+ item.id
4061
+ for item in snapshot.plan.items[assignment.start : assignment.stop]
4062
+ if item.owner == "agent"
4063
+ ),
4064
+ state.active_item_id,
4065
+ )
4066
+
4067
+ def _with_handoff(
4068
+ self,
4069
+ task_id: str,
4070
+ instruction: Instruction,
4071
+ opened: OpenAssignment | None,
4072
+ *,
4073
+ loop: str | None = None,
4074
+ ) -> Instruction:
4075
+ """Add ww's handoff block when the worker's command ended its turn.
4076
+
4077
+ A ``continue`` resets the round's records into the history, so the
4078
+ items of the ended round are read from there.
4079
+ """
4080
+ if opened is None or instruction.next_role not in {"manager", "operator"}:
4081
+ return instruction
4082
+ state, snapshot = self.load(task_id)
4083
+ items = {item.id: item for item in snapshot.plan.items}
4084
+ current = {record.plan_item_id: record for record in state.item_executions}
4085
+ if loop == "continue":
4086
+ current.update(
4087
+ (record.plan_item_id, record) for record in state.execution_history
4088
+ )
4089
+ performed = tuple(
4090
+ (items[item_id], current[item_id])
4091
+ for item_id in opened.items
4092
+ if item_id in items and item_id in current
4093
+ )
4094
+ marked = next(
4095
+ ((item, record) for item, record in performed if record.change_mark),
4096
+ None,
4097
+ )
4098
+ files: tuple[str, ...] | None = None
4099
+ if marked is not None:
4100
+ directory = self._check_scope(state, snapshot.plan, marked[0]).directory
4101
+ end = take_mark(directory)
4102
+ if end is not None:
4103
+ files, _ = change_set(directory, marked[1].change_mark, end)
4104
+ block = handoff_block(
4105
+ task_id,
4106
+ opened.token,
4107
+ performed,
4108
+ root=self.storage.root,
4109
+ files=files,
4110
+ error=(
4111
+ state.last_error if state.status in {"failed", "interrupted"} else None
4112
+ ),
4113
+ loop_outcome=((opened.active, loop) if loop and opened.active else None),
4114
+ )
4115
+ return replace(instruction, handoff_block=block)
4116
+
4117
+ def _require_manager(self, command: str, caller_role: CallerRole | None) -> None:
4118
+ self._validate_caller_role(caller_role)
4119
+ if caller_role == "worker":
4120
+ raise StateError(f"{command} is a manager-role command")
4121
+
4122
+ @staticmethod
4123
+ def _tag_caller(
4124
+ instruction: Instruction, caller_role: CallerRole | None
4125
+ ) -> Instruction:
4126
+ return replace(instruction, caller_role=caller_role)
4127
+
4128
+ def load(
4129
+ self, task_id: str, run_id: str | None = None
4130
+ ) -> tuple[ExecutionState, PlanSnapshot]:
4131
+ validate_task_id(task_id)
4132
+ return self.runs.load(task_id, run_id)
4133
+
4134
+ def _with_steps(self, state: ExecutionState, plan: WorkflowPlan) -> ExecutionState:
4135
+ return project_steps(state, plan, _now)
4136
+
4137
+ def plan_change(self, task_id: str) -> PlanChange | None:
4138
+ """How the configuration changed the task's open run, read only."""
4139
+ validate_task_id(task_id)
4140
+ state, snapshot = self.load(task_id)
4141
+ return self._detect_plan_change(state, snapshot)[1]
4142
+
4143
+ def _detect_plan_change(
4144
+ self, state: ExecutionState, snapshot: PlanSnapshot
4145
+ ) -> tuple[str | None, PlanChange | None]:
4146
+ """The current configuration's digest and what it changes in the run.
4147
+
4148
+ Nothing is compiled while the digest matches the one the plan was
4149
+ saved under. A configuration that no longer loads, or no longer
4150
+ defines the run's workflow, changes nothing here: the run goes on
4151
+ with its saved plan, and ``lint`` reports the configuration.
4152
+ """
4153
+ if not run_is_open(state.status):
4154
+ return None, None
4155
+ try:
4156
+ configuration = self._load_configuration()
4157
+ digest = self._configuration_digest(configuration)
4158
+ if digest == snapshot.configuration_digest:
4159
+ return digest, None
4160
+ if state.workflow not in configuration.workflows_by_name:
4161
+ return None, None
4162
+ template = compile_workflow_plan(
4163
+ configuration,
4164
+ self.storage.root,
4165
+ state.workflow,
4166
+ state.agent,
4167
+ state.task_id,
4168
+ self.extensions,
4169
+ PlanCompilationOptions(
4170
+ task_id=state.task_id,
4171
+ completed_bootstrap_step=snapshot.bootstrap_step,
4172
+ project=dict(state.workflow_values).get(PROJECT) or None,
4173
+ modes=state.modes,
4174
+ ),
4175
+ self.extensions.config,
4176
+ )
4177
+ except ConfigurationError:
4178
+ return None, None
4179
+ change = plan_change(state, snapshot, template, digest)
4180
+ lane_missing = missing_lane(configuration.workflows_by_name[state.workflow])
4181
+ if change is not None and change.refusal is None and lane_missing:
4182
+ change = replace(change, refusal=lane_missing)
4183
+ return digest, change
4184
+
4185
+ def _plan_gate(
4186
+ self, task_id: str, *, replan: bool, keep_plan: bool
4187
+ ) -> Instruction | None:
4188
+ """Stop ``next`` at a changed plan, or apply the operator's choice.
4189
+
4190
+ Returns the ``plan_changed`` page, or ``None`` for ``next`` to go on.
4191
+ A configuration whose change leaves the run's plan as it is is
4192
+ adopted silently, so it is not compiled again.
4193
+ """
4194
+ validate_task_id(task_id)
4195
+ with self.tasks.lock_task(task_id):
4196
+ state, snapshot = self.load(task_id)
4197
+ digest, change = self._detect_plan_change(state, snapshot)
4198
+ if digest is not None and digest != snapshot.configuration_digest:
4199
+ if change is None or keep_plan:
4200
+ self.commit(*keep_plan_(state, snapshot, digest))
4201
+ return None
4202
+ if replan:
4203
+ if change.refusal is not None:
4204
+ raise StateError(f"cannot replan: {change.refusal}")
4205
+ self.commit(*replan_(state, snapshot, change, _now))
4206
+ return None
4207
+ return _plan_changed_page(self.render(state, snapshot), change)
4208
+ if replan or keep_plan:
4209
+ raise StateError(
4210
+ "the workflow has not changed since this run's plan was saved; "
4211
+ "there is nothing to replan"
4212
+ )
4213
+ return None
4214
+
4215
+ def _load_configuration(self) -> WorkflowConfiguration:
4216
+ """Load any notation through the shared normalized-model contract."""
4217
+ return validate_configuration(self.configuration_loader(), self.extensions)
4218
+
4219
+ def _configuration_digest(self, configuration: WorkflowConfiguration) -> str:
4220
+ """Fingerprint semantics rather than one frontend's source bytes."""
4221
+ value = (configuration, self.extensions.config.builtins)
4222
+ return hashlib.sha256(repr(value).encode("utf-8")).hexdigest()
4223
+
4224
+ def _unknown_modes(
4225
+ self, mode_names: tuple[str, ...], configuration: WorkflowConfiguration
4226
+ ) -> set[str]:
4227
+ known = {mode.name for mode in configuration.modes}
4228
+ unknown = set()
4229
+ for name in mode_names:
4230
+ if name in known:
4231
+ continue
4232
+ if is_extension_reference(name):
4233
+ self.extensions.mode(name)
4234
+ else:
4235
+ unknown.add(name)
4236
+ return unknown
4237
+
4238
+ def _task_exists(
4239
+ self,
4240
+ task_id: str,
4241
+ workflow_name: str | None = None,
4242
+ project: str | None = None,
4243
+ ) -> bool:
4244
+ return task_id_claimed(
4245
+ task_id,
4246
+ tasks=self.tasks,
4247
+ extensions=self.extensions,
4248
+ workflow_name=workflow_name,
4249
+ project=project,
4250
+ lane=self._configured_lane(workflow_name),
4251
+ )
4252
+
4253
+ def _configured_lane(self, workflow_name: str | None) -> str | None:
4254
+ """The lane the configured workflow takes, else its own name.
4255
+
4256
+ A configuration that does not load leaves the name as it is; the
4257
+ caller's own work reports that configuration.
4258
+ """
4259
+ if workflow_name is None:
4260
+ return None
4261
+ try:
4262
+ workflows = self._load_configuration().workflows_by_name
4263
+ except ConfigurationError:
4264
+ return workflow_name
4265
+ workflow = workflows.get(workflow_name)
4266
+ return workflow.lane if workflow is not None else workflow_name
4267
+
4268
+ def _generated_child_id(
4269
+ self,
4270
+ parent_task_id: str,
4271
+ children: tuple[ChildTask, ...],
4272
+ project: str | None = None,
4273
+ ) -> str:
4274
+ """Use the ordinary task-ID convention inside a parent namespace.
4275
+
4276
+ A child added with ``--project`` follows that project's task format.
4277
+ """
4278
+ existing = {child.id for child in children}
4279
+ for candidate in candidate_task_ids(self.extensions.task_format(project)):
4280
+ validate_child_id(candidate)
4281
+ if candidate not in existing and not self._task_exists(
4282
+ f"{parent_task_id}/{candidate}"
4283
+ ):
4284
+ return candidate
4285
+ raise StateError(
4286
+ f"cannot generate an unused child task ID beneath {parent_task_id!r}"
4287
+ )
4288
+
4289
+ @staticmethod
4290
+ def _validate_execution_metadata(model: str, reasoning: str) -> None:
4291
+ if not model or not reasoning:
4292
+ raise StateError("execution requires non-empty --model and --reasoning")
4293
+
4294
+ @staticmethod
4295
+ def _validate_selected_agent(agent: str | None) -> None:
4296
+ if agent is not None and (not agent.strip() or agent == "auto"):
4297
+ raise StateError("selected agent must be non-empty and must not be 'auto'")
4298
+
4299
+
4300
+ def resolve_choice(item: PlanItem, choice: str) -> str:
4301
+ """Match an operator's pick to a declared choice by label or number."""
4302
+ labels = [option.label for option in item.choices]
4303
+ if not labels:
4304
+ raise StateError(f"{item.name!r} offers no choices")
4305
+ if choice in labels:
4306
+ return choice
4307
+ if choice.isdigit() and 1 <= int(choice) <= len(labels):
4308
+ return labels[int(choice) - 1]
4309
+ lowered = {label.lower(): label for label in labels}
4310
+ if choice.lower() in lowered:
4311
+ return lowered[choice.lower()]
4312
+ raise StateError(
4313
+ f"{choice!r} is not one of the choices of {item.name!r}: "
4314
+ + ", ".join(f"{n}. {label}" for n, label in enumerate(labels, 1))
4315
+ )
4316
+
4317
+
4318
+ def _plan_changed_page(instruction: Instruction, change: PlanChange) -> Instruction:
4319
+ """The run's page turned into the operator's ``plan_changed`` stop."""
4320
+ return replace(
4321
+ instruction,
4322
+ plan_change=change,
4323
+ operator_reason="plan_changed",
4324
+ control="awaiting_operator",
4325
+ next_role="operator",
4326
+ )
4327
+
4328
+
4329
+ def _manager_performs(instruction: Instruction) -> bool:
4330
+ """Whether the open item is the manager's own in the ``auto`` runtime."""
4331
+ return (
4332
+ instruction.workflow_runtime == "auto"
4333
+ and instruction.item_status == "in_progress"
4334
+ and instruction.status == "in_progress"
4335
+ and instruction.role == "manager"
4336
+ )
4337
+
4338
+
4339
+ def _collecting(state: ExecutionState, snapshot: PlanSnapshot) -> bool:
4340
+ """Whether the run's collection step is the item in progress."""
4341
+ if not state.active_item_id or state.cursor >= len(snapshot.plan.items):
4342
+ return False
4343
+ item = snapshot.plan.items[state.cursor]
4344
+ return item.id == state.active_item_id and item.item_operation == "collect"
4345
+
4346
+
4347
+ def _interactive_item(
4348
+ state: ExecutionState, snapshot: PlanSnapshot
4349
+ ) -> tuple[PlanItem, PlanItemExecution]:
4350
+ """The interactive step in progress whose conversation is still open."""
4351
+ if not state.active_item_id or state.cursor >= len(snapshot.plan.items):
4352
+ raise StateError("no agent item is in progress; use next")
4353
+ item = snapshot.plan.items[state.cursor]
4354
+ record = state.item_executions[state.cursor]
4355
+ if item.id != state.active_item_id or not item.interactive:
4356
+ raise StateError(
4357
+ f"the current step {item.name!r} is not interactive; "
4358
+ "interact only records an interactive step"
4359
+ )
4360
+ if record.interaction_ended:
4361
+ raise StateError(
4362
+ f"the interaction of {item.name!r} has ended; complete the step"
4363
+ )
4364
+ return item, record
4365
+
4366
+
4367
+ def _now() -> str:
4368
+ return (
4369
+ datetime.now(timezone.utc)
4370
+ .replace(microsecond=0)
4371
+ .isoformat()
4372
+ .replace("+00:00", "Z")
4373
+ )
4374
+
4375
+
4376
+ def _render_template(template: str, values: dict[str, str]) -> str:
4377
+ missing = set(dependencies(template)) - set(values)
4378
+ if missing:
4379
+ raise StateError(
4380
+ "automatic handler is missing variable(s): " + ", ".join(sorted(missing))
4381
+ )
4382
+ return interpolate(template, values)
4383
+
4384
+
4385
+ def _child_fields(fields: tuple[tuple[str, str], ...]) -> tuple[tuple[str, str], ...]:
4386
+ """Validate ``--field`` values for a child, as for an item."""
4387
+ try:
4388
+ return validate_item_fields(dict(fields))
4389
+ except ValueError as error:
4390
+ raise StateError(str(error).replace("item field", "child field")) from error
4391
+
4392
+
4393
+ def _exiting_children(state: ExecutionState, plan: WorkflowPlan) -> bool:
4394
+ """Whether a pending break ends the per-child stages."""
4395
+ stopped = next(
4396
+ (item for item in plan.items if item.id == state.loop_exit_item_id), None
4397
+ )
4398
+ return stopped is not None and stopped.breaks_children
4399
+
4400
+
4401
+ def _index_for_id(plan: WorkflowPlan, item_id: str) -> int:
4402
+ for index, item in enumerate(plan.items):
4403
+ if item.id == item_id:
4404
+ return index
4405
+ raise StateError(f"plan item {item_id!r} is not in the task snapshot")