ww-agentic-workflows 1.0.0.dev3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ww/__init__.py +18 -0
- ww/_bundled_extensions/ww/git/extension.py +1728 -0
- ww/action_execution.py +887 -0
- ww/actions/__init__.py +94 -0
- ww/actions/command.py +444 -0
- ww/actions/contracts.py +699 -0
- ww/actions/extension.py +197 -0
- ww/actions/mcp.py +84 -0
- ww/actions/prompt.py +74 -0
- ww/actions/skill.py +62 -0
- ww/actions/slash_command.py +63 -0
- ww/agents.py +151 -0
- ww/amendments.py +54 -0
- ww/artifacts.py +93 -0
- ww/assessments.py +181 -0
- ww/assets/__init__.py +2 -0
- ww/assets/agent_instructions.md +49 -0
- ww/assets/docs/examples.md +879 -0
- ww/assets/docs/features.md +4639 -0
- ww/assets/docs/specification.md +1876 -0
- ww/assets/noww_skill.md +11 -0
- ww/assets/workflows/catchall.yaml +26 -0
- ww/assets/workflows/onboarding.yaml +586 -0
- ww/assets/workflows/scriptize.yaml +130 -0
- ww/assets/ww-automate_skill.md +23 -0
- ww/assets/ww-deduce-feedback_skill.md +38 -0
- ww/assets/ww-feedback-rules_skill.md +48 -0
- ww/assets/ww-learn-project_skill.md +22 -0
- ww/assets/ww-refresh_skill.md +26 -0
- ww/assets/ww-rule_skill.md +83 -0
- ww/assets/ww-rules-from-artifacts_skill.md +22 -0
- ww/assets/ww-scriptize_skill.md +33 -0
- ww/assets/ww-setup_skill.md +94 -0
- ww/assets/ww-solve_skill.md +23 -0
- ww/assets/ww-suggest_skill.md +32 -0
- ww/assets/ww-wizard_skill.md +105 -0
- ww/assets/ww_skill.md +59 -0
- ww/assignments.py +283 -0
- ww/bootstrap.py +405 -0
- ww/builtin_workflows.py +215 -0
- ww/changes.py +225 -0
- ww/child_coordination.py +482 -0
- ww/children.py +106 -0
- ww/claude_permissions.py +115 -0
- ww/cli/__init__.py +7 -0
- ww/cli/__main__.py +6 -0
- ww/cli/audit.py +129 -0
- ww/cli/catalogs.py +131 -0
- ww/cli/discover.py +607 -0
- ww/cli/initialization.py +898 -0
- ww/cli/lookup.py +287 -0
- ww/cli/main.py +1768 -0
- ww/cli/parser.py +1200 -0
- ww/cli/prompts.py +217 -0
- ww/cli/updates.py +117 -0
- ww/completion_artifacts.py +156 -0
- ww/completion_inputs.py +39 -0
- ww/config/__init__.py +582 -0
- ww/config/actions.py +591 -0
- ww/config/composition.py +571 -0
- ww/config/rules.py +511 -0
- ww/config/steps.py +1220 -0
- ww/config/values.py +223 -0
- ww/config_files.py +191 -0
- ww/config_writes.py +264 -0
- ww/contracts.py +155 -0
- ww/control.py +41 -0
- ww/defaults.py +130 -0
- ww/design_docs.py +32 -0
- ww/discovery.py +104 -0
- ww/documents.py +217 -0
- ww/errors.py +18 -0
- ww/executable.py +43 -0
- ww/execution_models/__init__.py +64 -0
- ww/execution_models/construction.py +148 -0
- ww/execution_models/decoding.py +38 -0
- ww/execution_models/plan_codec.py +565 -0
- ww/execution_models/records.py +1206 -0
- ww/execution_models/runs.py +266 -0
- ww/extensions/__init__.py +40 -0
- ww/extensions/api.py +559 -0
- ww/extensions/registry.py +864 -0
- ww/extensions/store.py +78 -0
- ww/feedback.py +342 -0
- ww/handler_repairs.py +57 -0
- ww/hooks/__init__.py +40 -0
- ww/hooks/agents.py +380 -0
- ww/hooks/install.py +168 -0
- ww/hooks/notices.py +206 -0
- ww/hooks/records.py +209 -0
- ww/hooks/runtime.py +266 -0
- ww/hooks/transcripts.py +183 -0
- ww/inspect.py +896 -0
- ww/instructions/__init__.py +17 -0
- ww/instructions/builder.py +1682 -0
- ww/instructions/commands.py +335 -0
- ww/instructions/handoff.py +149 -0
- ww/instructions/models.py +686 -0
- ww/instructions/policy.py +219 -0
- ww/instructions/text.py +168 -0
- ww/interactions.py +187 -0
- ww/interpolation.py +37 -0
- ww/item_passes.py +167 -0
- ww/items.py +99 -0
- ww/locking.py +207 -0
- ww/metadata_publication.py +230 -0
- ww/onboarding.py +229 -0
- ww/open_work.py +236 -0
- ww/operations.py +193 -0
- ww/operator_ui/__init__.py +16 -0
- ww/operator_ui/page.html +351 -0
- ww/operator_ui/server.py +215 -0
- ww/operator_ui/session.py +389 -0
- ww/operator_ui/sheet.py +104 -0
- ww/operator_ui/view.py +109 -0
- ww/output.py +339 -0
- ww/output_adapters/__init__.py +12 -0
- ww/output_adapters/base.py +25 -0
- ww/output_adapters/json_adapter.py +37 -0
- ww/output_adapters/markdown.py +2293 -0
- ww/output_adapters/rule_pages.py +337 -0
- ww/output_adapters/terminal.py +21 -0
- ww/package_updates.py +167 -0
- ww/plan/__init__.py +38 -0
- ww/plan/actions.py +207 -0
- ww/plan/compiler.py +1492 -0
- ww/plan/constructs.py +456 -0
- ww/plan/models.py +665 -0
- ww/project_config.py +752 -0
- ww/recovery.py +401 -0
- ww/replanning.py +367 -0
- ww/results.py +77 -0
- ww/rule_checks.py +230 -0
- ww/rule_conversion.py +331 -0
- ww/rule_disputes.py +148 -0
- ww/rule_store.py +456 -0
- ww/rule_verification.py +714 -0
- ww/rule_views.py +447 -0
- ww/rule_writes.py +920 -0
- ww/run_coordination.py +158 -0
- ww/runtimes.py +105 -0
- ww/service.py +4405 -0
- ww/setup_apply.py +428 -0
- ww/step_values.py +20 -0
- ww/storage.py +447 -0
- ww/storage_adapters/__init__.py +36 -0
- ww/storage_adapters/base.py +540 -0
- ww/storage_adapters/filesystem.py +370 -0
- ww/storage_adapters/memory.py +195 -0
- ww/storage_adapters/project_metadata.py +69 -0
- ww/storage_adapters/task_document.py +484 -0
- ww/task_ids.py +114 -0
- ww/task_references.py +124 -0
- ww/transitions.py +1619 -0
- ww/updates.py +399 -0
- ww/upgrade.py +95 -0
- ww/validation.py +168 -0
- ww/variables.py +275 -0
- ww/workflow_config.py +854 -0
- ww/workflow_update.py +239 -0
- ww/workflow_validation.py +1260 -0
- ww/workspace.py +50 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
ww/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")
|