cowork-flow 0.0.37 → 0.0.39
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.
- package/README.md +27 -14
- package/package.json +1 -1
- package/src/commands/init.js +39 -28
- package/src/commands/sync.js +2 -2
- package/src/lib/asset-plan.js +127 -0
- package/src/lib/copy-template.js +101 -141
- package/src/lib/host-assets.js +231 -0
- package/src/lib/plan-applier.js +329 -0
- package/src/lib/platforms.js +15 -76
- package/src/lib/skill-registry.js +503 -0
- package/template/.claude/agents/cowork-check.md +10 -1
- package/template/.claude/agents/cowork-implement.md +11 -2
- package/template/.claude/hooks/inject-workflow-state.py +51 -254
- package/template/.codex/agents/cowork-check.toml +12 -3
- package/template/.codex/agents/cowork-implement.toml +11 -2
- package/template/.codex/agents/default.toml +3 -2
- package/template/.codex/agents/explorer.toml +3 -2
- package/template/.codex/agents/worker.toml +2 -1
- package/template/.codex/hooks/inject-workflow-state.py +46 -265
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/application/__init__.py +1 -0
- package/template/.cowork-flow/scripts/application/batch_execution.py +743 -0
- package/template/.cowork-flow/scripts/application/runtime_context_service.py +365 -0
- package/template/.cowork-flow/scripts/application/task_archive.py +220 -0
- package/template/.cowork-flow/scripts/application/task_context.py +765 -0
- package/template/.cowork-flow/scripts/application/task_creation.py +201 -0
- package/template/.cowork-flow/scripts/application/task_lifecycle.py +532 -0
- package/template/.cowork-flow/scripts/application/task_tree.py +179 -0
- package/template/.cowork-flow/scripts/commands/doctor.py +78 -57
- package/template/.cowork-flow/scripts/commands/party_mode_v2.py +671 -425
- package/template/.cowork-flow/scripts/commands/subagent.py +38 -26
- package/template/.cowork-flow/scripts/commands/task.py +535 -1953
- package/template/.cowork-flow/scripts/commands/task_archive_commands.py +195 -0
- package/template/.cowork-flow/scripts/commands/task_context_commands.py +240 -0
- package/template/.cowork-flow/scripts/commands/task_create_command.py +164 -0
- package/template/.cowork-flow/scripts/commands/task_navigation.py +407 -0
- package/template/.cowork-flow/scripts/commands/task_parser.py +364 -0
- package/template/.cowork-flow/scripts/commands/task_support.py +76 -0
- package/template/.cowork-flow/scripts/commands/task_tree_commands.py +160 -0
- package/template/.cowork-flow/scripts/common/core/execution_context.py +161 -85
- package/template/.cowork-flow/scripts/common/core/host_manifest.py +393 -0
- package/template/.cowork-flow/scripts/common/core/skill_registry.py +568 -0
- package/template/.cowork-flow/scripts/common/gates/coding_standards.py +58 -7
- package/template/.cowork-flow/scripts/common/gates/gates.py +323 -46
- package/template/.cowork-flow/scripts/common/gates/models.py +196 -0
- package/template/.cowork-flow/scripts/common/gates/registry.py +79 -0
- package/template/.cowork-flow/scripts/common/gates/tdd_evidence.py +13 -13
- package/template/.cowork-flow/scripts/common/gates/validate_coding_standards.py +104 -62
- package/template/.cowork-flow/scripts/common/gates/validate_implementation.py +39 -22
- package/template/.cowork-flow/scripts/common/gates/validate_rules.py +223 -40
- package/template/.cowork-flow/scripts/common/git/git_context.py +216 -182
- package/template/.cowork-flow/scripts/common/host/__init__.py +1 -0
- package/template/.cowork-flow/scripts/common/host/workflow_state_hook.py +303 -0
- package/template/.cowork-flow/scripts/common/storage/__init__.py +1 -0
- package/template/.cowork-flow/scripts/common/storage/operation_log.py +72 -0
- package/template/.cowork-flow/scripts/common/storage/state_store.py +279 -0
- package/template/.cowork-flow/scripts/common/storage/unit_of_work.py +214 -0
- package/template/.cowork-flow/scripts/common/task/active_task.py +93 -135
- package/template/.cowork-flow/scripts/common/task/batch_mode.py +33 -120
- package/template/.cowork-flow/scripts/common/task/decision_review.py +85 -0
- package/template/.cowork-flow/scripts/common/task/readiness.py +9 -15
- package/template/.cowork-flow/scripts/common/task/task_repository.py +156 -0
- package/template/.cowork-flow/scripts/run.py +24 -0
- package/template/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
- package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
- package/template/.cowork-flow/spec/contracts/workflow-state-templates.md +1 -1
- package/template/.cowork-flow/spec/protocols/decision-review.md +28 -0
- package/template/.cowork-flow/spec/protocols/review.md +34 -0
- package/template/.cowork-flow/spec/protocols/spec-maintenance.md +16 -0
- package/template/.cowork-flow/spec/protocols/tdd.md +30 -0
- package/template/.cowork-flow/spec/references/definition-of-done.md +4 -4
- package/template/.cowork-flow/spec/references/orchestration-patterns.md +1 -1
- package/template/.cowork-flow/spec/runtime/contract-registry.json +25 -0
- package/template/.cowork-flow/spec/runtime/host-assets.json +214 -0
- package/template/.cowork-flow/spec/runtime/index.md +8 -0
- package/template/.cowork-flow/spec/runtime/rules.json +61 -10
- package/template/.cowork-flow/spec/runtime/skill-registry.json +397 -0
- package/template/.cowork-flow/spec/schemas/host-assets.schema.json +173 -0
- package/template/.cowork-flow/spec/schemas/rules.schema.json +10 -1
- package/template/.cowork-flow/spec/schemas/skill-registry.schema.json +202 -0
- package/template/.cowork-flow/workflow.md +18 -13
- package/template/.opencode/agents/cowork-check.md +11 -0
- package/template/.opencode/agents/cowork-implement.md +16 -1
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/.zcode/hooks/inject-context.js +3 -85
- package/template/.zcode/hooks/inject-context.selfcheck.mjs +6 -6
- package/template/.zcode/scaffold/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
- package/template/.zcode/scaffold/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
- package/template/.zcode/scaffold/.cowork-flow/spec/runtime/rules.json +61 -10
- package/template/.zcode/scaffold/.cowork-flow/spec/schemas/rules.schema.json +10 -1
- package/template/.zcode/scaffold/.cowork-flow/workflow.md +18 -13
- package/template/.zcode/scaffold/AGENTS.md +18 -8
- package/template/AGENTS.md +1 -1
- package/template/skills/batch-mode/SKILL.md +140 -76
- package/template/skills/brainstorming/SKILL.md +2 -2
- package/template/skills/cowork-flow/SKILL.md +42 -0
- package/template/skills/party-mode/SHARED-BOUNDARIES.md +1 -1
- package/template/skills/party-mode/SKILL.md +48 -119
- package/template/.cowork-flow/scripts/common/gates/spec_validator.py +0 -53
- package/template/.cowork-flow/scripts/common/gates/validate_jsonl.py +0 -32
- package/template/skills/before-dev/SKILL.md +0 -41
- package/template/skills/check/SKILL.md +0 -53
- package/template/skills/check/simplification-guide.md +0 -47
- package/template/skills/continue/SKILL.md +0 -29
- package/template/skills/doubt-review/SKILL.md +0 -118
- package/template/skills/finish-work/SKILL.md +0 -32
- package/template/skills/party-mode-v2/SKILL.md +0 -75
- package/template/skills/start/SKILL.md +0 -78
- package/template/skills/tdd/SKILL.md +0 -74
- package/template/skills/update-spec/SKILL.md +0 -39
- package/template/skills/using-cowork-flow/SKILL.md +0 -80
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Recoverable task-graph Batch execution state machine."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Callable
|
|
11
|
+
|
|
12
|
+
from application.runtime_context_service import (
|
|
13
|
+
RuntimeContextError,
|
|
14
|
+
RuntimeContextService,
|
|
15
|
+
)
|
|
16
|
+
from application.task_tree import TaskTreeService
|
|
17
|
+
from common.storage.state_store import StateStore, StateStoreError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
BATCH_STEPS = (
|
|
21
|
+
"start_task",
|
|
22
|
+
"init_implement_context",
|
|
23
|
+
"await_implement_result",
|
|
24
|
+
"review_task",
|
|
25
|
+
"init_check_context",
|
|
26
|
+
"await_check_result",
|
|
27
|
+
"complete_task",
|
|
28
|
+
"commit_task",
|
|
29
|
+
)
|
|
30
|
+
STEP_PHASES = {
|
|
31
|
+
"start_task": "start",
|
|
32
|
+
"await_implement_result": "implement",
|
|
33
|
+
"review_task": "review",
|
|
34
|
+
"await_check_result": "check",
|
|
35
|
+
"complete_task": "complete",
|
|
36
|
+
"commit_task": "commit",
|
|
37
|
+
}
|
|
38
|
+
LIFECYCLE_STATUSES = {
|
|
39
|
+
"start_task": "in_progress",
|
|
40
|
+
"review_task": "review",
|
|
41
|
+
"complete_task": "completed",
|
|
42
|
+
}
|
|
43
|
+
RUNTIME_ROLES = {
|
|
44
|
+
"init_implement_context": "implement",
|
|
45
|
+
"await_implement_result": "implement",
|
|
46
|
+
"init_check_context": "check",
|
|
47
|
+
"await_check_result": "check",
|
|
48
|
+
}
|
|
49
|
+
CommitVerifier = Callable[[str], bool]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class BatchExecutionError(RuntimeError):
|
|
53
|
+
"""Raised when Batch graph, state, or host input is invalid."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, code: str, detail: str) -> None:
|
|
56
|
+
self.code = code
|
|
57
|
+
self.detail = detail
|
|
58
|
+
super().__init__(f"{code}: {detail}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class BatchExecutionService:
|
|
62
|
+
"""Publish and verify one host-neutral Batch action at a time."""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
repo_root: Path,
|
|
67
|
+
*,
|
|
68
|
+
state_store: StateStore | None = None,
|
|
69
|
+
commit_verifier: CommitVerifier | None = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
self.repo_root = Path(repo_root)
|
|
72
|
+
self.state_store = state_store or StateStore()
|
|
73
|
+
self.tree = TaskTreeService(self.repo_root)
|
|
74
|
+
self.runtime_contexts = RuntimeContextService(
|
|
75
|
+
self.repo_root,
|
|
76
|
+
state_store=self.state_store,
|
|
77
|
+
)
|
|
78
|
+
self.commit_verifier = commit_verifier or self._verify_git_commit
|
|
79
|
+
|
|
80
|
+
def start(self, root_task: str) -> dict:
|
|
81
|
+
ordered_tasks, graph_digest = self._plan(root_task)
|
|
82
|
+
operation_id = f"batch-{root_task}"
|
|
83
|
+
path = self._state_path(operation_id)
|
|
84
|
+
snapshot = self._load(path)
|
|
85
|
+
if snapshot.exists:
|
|
86
|
+
state = dict(snapshot.data)
|
|
87
|
+
if state.get("graph_digest") != graph_digest:
|
|
88
|
+
raise BatchExecutionError(
|
|
89
|
+
"BATCH-GRAPH-CHANGED-001",
|
|
90
|
+
"task graph changed after Batch state was created",
|
|
91
|
+
)
|
|
92
|
+
return self._advance(path, state)
|
|
93
|
+
|
|
94
|
+
state = {
|
|
95
|
+
"schema_version": 2,
|
|
96
|
+
"operation_id": operation_id,
|
|
97
|
+
"root_task": root_task,
|
|
98
|
+
"graph_digest": graph_digest,
|
|
99
|
+
"ordered_tasks": list(ordered_tasks),
|
|
100
|
+
"current_task": None,
|
|
101
|
+
"completed_tasks": [],
|
|
102
|
+
"commits": {},
|
|
103
|
+
"retry_count": {},
|
|
104
|
+
"task_phases": {},
|
|
105
|
+
"task_steps": {},
|
|
106
|
+
"runtime_contexts": {},
|
|
107
|
+
"action_results": {},
|
|
108
|
+
"next_action_sequence": 0,
|
|
109
|
+
"next_action": None,
|
|
110
|
+
"phase": "ready",
|
|
111
|
+
"pause_reason": None,
|
|
112
|
+
}
|
|
113
|
+
self._save(path, state)
|
|
114
|
+
return self._advance(path, state)
|
|
115
|
+
|
|
116
|
+
def resume(self, operation_id: str) -> dict:
|
|
117
|
+
path = self._state_path(operation_id)
|
|
118
|
+
snapshot = self._load(path)
|
|
119
|
+
if not snapshot.exists:
|
|
120
|
+
raise BatchExecutionError(
|
|
121
|
+
"BATCH-STATE-MISSING-001",
|
|
122
|
+
f"Batch state does not exist: {operation_id}",
|
|
123
|
+
)
|
|
124
|
+
state = dict(snapshot.data)
|
|
125
|
+
self._assert_graph_unchanged(state)
|
|
126
|
+
if state.get("phase") == "paused":
|
|
127
|
+
state["phase"] = "running"
|
|
128
|
+
state["pause_reason"] = None
|
|
129
|
+
state["next_action"] = None
|
|
130
|
+
self._save(path, state)
|
|
131
|
+
return self._advance(path, state)
|
|
132
|
+
|
|
133
|
+
def record_result(self, operation_id: str, payload: dict) -> dict:
|
|
134
|
+
path = self._state_path(operation_id)
|
|
135
|
+
snapshot = self._load(path)
|
|
136
|
+
if not snapshot.exists:
|
|
137
|
+
raise BatchExecutionError(
|
|
138
|
+
"BATCH-STATE-MISSING-001",
|
|
139
|
+
f"Batch state does not exist: {operation_id}",
|
|
140
|
+
)
|
|
141
|
+
state = dict(snapshot.data)
|
|
142
|
+
self._assert_graph_unchanged(state)
|
|
143
|
+
result = self._normalize_result(payload)
|
|
144
|
+
action_id = result["action_id"]
|
|
145
|
+
previous = dict(state.get("action_results") or {}).get(action_id)
|
|
146
|
+
if previous is not None:
|
|
147
|
+
if previous != result:
|
|
148
|
+
raise BatchExecutionError(
|
|
149
|
+
"BATCH-RESULT-CONFLICT-001",
|
|
150
|
+
f"action result changed after recording: {action_id}",
|
|
151
|
+
)
|
|
152
|
+
return state
|
|
153
|
+
|
|
154
|
+
action = state.get("next_action")
|
|
155
|
+
if not isinstance(action, dict):
|
|
156
|
+
raise BatchExecutionError(
|
|
157
|
+
"BATCH-ACTION-MISSING-001",
|
|
158
|
+
"Batch is not waiting for a Host action",
|
|
159
|
+
)
|
|
160
|
+
if action_id != action.get("action_id"):
|
|
161
|
+
raise BatchExecutionError(
|
|
162
|
+
"BATCH-ACTION-MISMATCH-001",
|
|
163
|
+
f"expected action {action.get('action_id')}, got {action_id}",
|
|
164
|
+
)
|
|
165
|
+
if result["type"] != action.get("type"):
|
|
166
|
+
raise BatchExecutionError(
|
|
167
|
+
"BATCH-ACTION-TYPE-001",
|
|
168
|
+
f"expected action type {action.get('type')}, "
|
|
169
|
+
f"got {result['type']}",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if result["outcome"] != "success":
|
|
173
|
+
detail = self._optional_text(result, "detail")
|
|
174
|
+
return self._pause(
|
|
175
|
+
path,
|
|
176
|
+
state,
|
|
177
|
+
action,
|
|
178
|
+
result,
|
|
179
|
+
detail or "Host action reported failure",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
self._verify_success(state, action, result)
|
|
184
|
+
except (BatchExecutionError, RuntimeContextError) as error:
|
|
185
|
+
detail = getattr(error, "detail", str(error))
|
|
186
|
+
return self._pause(
|
|
187
|
+
path,
|
|
188
|
+
state,
|
|
189
|
+
action,
|
|
190
|
+
result,
|
|
191
|
+
detail,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
action_results = dict(state.get("action_results") or {})
|
|
195
|
+
action_results[action_id] = result
|
|
196
|
+
state["action_results"] = action_results
|
|
197
|
+
self._mark_step_completed(state, action, result)
|
|
198
|
+
state["next_action"] = None
|
|
199
|
+
state["phase"] = "running"
|
|
200
|
+
state["pause_reason"] = None
|
|
201
|
+
self._save(path, state)
|
|
202
|
+
return self._advance(path, state)
|
|
203
|
+
|
|
204
|
+
def _advance(self, path: Path, state: dict) -> dict:
|
|
205
|
+
if state.get("phase") in {"completed", "paused"}:
|
|
206
|
+
return state
|
|
207
|
+
if isinstance(state.get("next_action"), dict):
|
|
208
|
+
return state
|
|
209
|
+
|
|
210
|
+
completed_tasks = list(state.get("completed_tasks") or [])
|
|
211
|
+
task_steps = dict(state.get("task_steps") or {})
|
|
212
|
+
retry_count = dict(state.get("retry_count") or {})
|
|
213
|
+
|
|
214
|
+
for task_name in state["ordered_tasks"]:
|
|
215
|
+
if task_name in completed_tasks:
|
|
216
|
+
continue
|
|
217
|
+
state["current_task"] = task_name
|
|
218
|
+
completed_steps = list(task_steps.get(task_name) or [])
|
|
219
|
+
for step in BATCH_STEPS:
|
|
220
|
+
if step in completed_steps:
|
|
221
|
+
continue
|
|
222
|
+
action = self._build_action(state, task_name, step)
|
|
223
|
+
state["next_action"] = action
|
|
224
|
+
state["phase"] = "awaiting_host"
|
|
225
|
+
self._save(path, state)
|
|
226
|
+
return state
|
|
227
|
+
|
|
228
|
+
completed_tasks.append(task_name)
|
|
229
|
+
state["completed_tasks"] = completed_tasks
|
|
230
|
+
retry_count[task_name] = 0
|
|
231
|
+
state["retry_count"] = retry_count
|
|
232
|
+
state["current_task"] = None
|
|
233
|
+
self._save(path, state)
|
|
234
|
+
|
|
235
|
+
state["phase"] = "completed"
|
|
236
|
+
state["current_task"] = None
|
|
237
|
+
state["next_action"] = None
|
|
238
|
+
state["pause_reason"] = None
|
|
239
|
+
self._save(path, state)
|
|
240
|
+
return state
|
|
241
|
+
|
|
242
|
+
def _build_action(
|
|
243
|
+
self,
|
|
244
|
+
state: dict,
|
|
245
|
+
task_name: str,
|
|
246
|
+
step: str,
|
|
247
|
+
) -> dict:
|
|
248
|
+
sequence = int(state.get("next_action_sequence") or 0) + 1
|
|
249
|
+
state["next_action_sequence"] = sequence
|
|
250
|
+
action = {
|
|
251
|
+
"action_id": (
|
|
252
|
+
f"{state['operation_id']}:{sequence}:{task_name}:{step}"
|
|
253
|
+
),
|
|
254
|
+
"type": step,
|
|
255
|
+
"phase": self._phase_for_step(step),
|
|
256
|
+
"task": task_name,
|
|
257
|
+
"task_dir": f".cowork-flow/tasks/{task_name}",
|
|
258
|
+
}
|
|
259
|
+
role = RUNTIME_ROLES.get(step)
|
|
260
|
+
if step.startswith("init_") and role:
|
|
261
|
+
action.update(
|
|
262
|
+
{
|
|
263
|
+
"role": role,
|
|
264
|
+
"agent_type": f"cowork-{role}",
|
|
265
|
+
"title": (
|
|
266
|
+
f"{role.capitalize()} Batch task {task_name}"
|
|
267
|
+
),
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
if step.startswith("await_") and role:
|
|
271
|
+
runtime = self._runtime_for(state, task_name, role)
|
|
272
|
+
action.update(
|
|
273
|
+
{
|
|
274
|
+
"role": role,
|
|
275
|
+
"runtime_context_id": runtime["runtime_context_id"],
|
|
276
|
+
"host_context_key": runtime["host_context_key"],
|
|
277
|
+
}
|
|
278
|
+
)
|
|
279
|
+
return action
|
|
280
|
+
|
|
281
|
+
def _verify_success(
|
|
282
|
+
self,
|
|
283
|
+
state: dict,
|
|
284
|
+
action: dict,
|
|
285
|
+
result: dict,
|
|
286
|
+
) -> None:
|
|
287
|
+
action_type = str(action["type"])
|
|
288
|
+
task_name = str(action["task"])
|
|
289
|
+
if action_type in LIFECYCLE_STATUSES:
|
|
290
|
+
self._verify_task_status(
|
|
291
|
+
task_name,
|
|
292
|
+
LIFECYCLE_STATUSES[action_type],
|
|
293
|
+
result,
|
|
294
|
+
)
|
|
295
|
+
return
|
|
296
|
+
if action_type.startswith("init_"):
|
|
297
|
+
self._verify_runtime_initialized(
|
|
298
|
+
state,
|
|
299
|
+
task_name,
|
|
300
|
+
RUNTIME_ROLES[action_type],
|
|
301
|
+
result,
|
|
302
|
+
)
|
|
303
|
+
return
|
|
304
|
+
if action_type.startswith("await_"):
|
|
305
|
+
self._verify_runtime_completed(
|
|
306
|
+
state,
|
|
307
|
+
task_name,
|
|
308
|
+
RUNTIME_ROLES[action_type],
|
|
309
|
+
action,
|
|
310
|
+
result,
|
|
311
|
+
)
|
|
312
|
+
return
|
|
313
|
+
if action_type == "commit_task":
|
|
314
|
+
commit_id = self._required_text(
|
|
315
|
+
result,
|
|
316
|
+
"commit_id",
|
|
317
|
+
"commit result is missing commit_id",
|
|
318
|
+
)
|
|
319
|
+
if not self.commit_verifier(commit_id):
|
|
320
|
+
raise BatchExecutionError(
|
|
321
|
+
"BATCH-COMMIT-VERIFY-001",
|
|
322
|
+
f"commit could not be verified: {commit_id}",
|
|
323
|
+
)
|
|
324
|
+
return
|
|
325
|
+
raise BatchExecutionError(
|
|
326
|
+
"BATCH-ACTION-UNKNOWN-001",
|
|
327
|
+
f"unsupported Batch action: {action_type}",
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def _verify_task_status(
|
|
331
|
+
self,
|
|
332
|
+
task_name: str,
|
|
333
|
+
expected_status: str,
|
|
334
|
+
result: dict,
|
|
335
|
+
) -> None:
|
|
336
|
+
reported = self._optional_text(result, "task_status")
|
|
337
|
+
if reported and reported != expected_status:
|
|
338
|
+
raise BatchExecutionError(
|
|
339
|
+
"BATCH-TASK-STATUS-001",
|
|
340
|
+
f"Host reported task status {reported}; "
|
|
341
|
+
f"expected {expected_status}",
|
|
342
|
+
)
|
|
343
|
+
task_path = (
|
|
344
|
+
self.repo_root
|
|
345
|
+
/ ".cowork-flow"
|
|
346
|
+
/ "tasks"
|
|
347
|
+
/ task_name
|
|
348
|
+
/ "task.json"
|
|
349
|
+
)
|
|
350
|
+
try:
|
|
351
|
+
task_data = json.loads(
|
|
352
|
+
task_path.read_text(encoding="utf-8")
|
|
353
|
+
)
|
|
354
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
355
|
+
raise BatchExecutionError(
|
|
356
|
+
"BATCH-TASK-LOAD-001",
|
|
357
|
+
f"cannot load task status for {task_name}: {error}",
|
|
358
|
+
) from error
|
|
359
|
+
actual = task_data.get("status")
|
|
360
|
+
if actual != expected_status:
|
|
361
|
+
raise BatchExecutionError(
|
|
362
|
+
"BATCH-TASK-STATUS-001",
|
|
363
|
+
f"task status for {task_name} is {actual}; "
|
|
364
|
+
f"expected {expected_status}",
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def _verify_runtime_initialized(
|
|
368
|
+
self,
|
|
369
|
+
state: dict,
|
|
370
|
+
task_name: str,
|
|
371
|
+
role: str,
|
|
372
|
+
result: dict,
|
|
373
|
+
) -> None:
|
|
374
|
+
runtime_context_id = self._required_text(
|
|
375
|
+
result,
|
|
376
|
+
"runtime_context_id",
|
|
377
|
+
"runtime initialization result is missing runtime_context_id",
|
|
378
|
+
)
|
|
379
|
+
host_context_key = self._required_text(
|
|
380
|
+
result,
|
|
381
|
+
"host_context_key",
|
|
382
|
+
"runtime initialization result is missing host_context_key",
|
|
383
|
+
)
|
|
384
|
+
context = self.runtime_contexts.load(runtime_context_id)
|
|
385
|
+
self._verify_runtime_identity(
|
|
386
|
+
context,
|
|
387
|
+
task_name,
|
|
388
|
+
role,
|
|
389
|
+
runtime_context_id,
|
|
390
|
+
)
|
|
391
|
+
status = context.get("status")
|
|
392
|
+
if status == "closed":
|
|
393
|
+
raise BatchExecutionError(
|
|
394
|
+
"BATCH-RUNTIME-CLOSED-001",
|
|
395
|
+
f"runtime context is already closed: {runtime_context_id}",
|
|
396
|
+
)
|
|
397
|
+
bound_key = context.get("bound_context_key")
|
|
398
|
+
if bound_key and bound_key != host_context_key:
|
|
399
|
+
raise BatchExecutionError(
|
|
400
|
+
"BATCH-RUNTIME-BIND-001",
|
|
401
|
+
f"runtime context {runtime_context_id} is bound to "
|
|
402
|
+
f"{bound_key}, not {host_context_key}",
|
|
403
|
+
)
|
|
404
|
+
runtime_contexts = dict(state.get("runtime_contexts") or {})
|
|
405
|
+
task_contexts = dict(runtime_contexts.get(task_name) or {})
|
|
406
|
+
task_contexts[role] = {
|
|
407
|
+
"runtime_context_id": runtime_context_id,
|
|
408
|
+
"host_context_key": host_context_key,
|
|
409
|
+
}
|
|
410
|
+
runtime_contexts[task_name] = task_contexts
|
|
411
|
+
state["runtime_contexts"] = runtime_contexts
|
|
412
|
+
|
|
413
|
+
def _verify_runtime_completed(
|
|
414
|
+
self,
|
|
415
|
+
state: dict,
|
|
416
|
+
task_name: str,
|
|
417
|
+
role: str,
|
|
418
|
+
action: dict,
|
|
419
|
+
result: dict,
|
|
420
|
+
) -> None:
|
|
421
|
+
runtime = self._runtime_for(state, task_name, role)
|
|
422
|
+
runtime_context_id = self._required_text(
|
|
423
|
+
result,
|
|
424
|
+
"runtime_context_id",
|
|
425
|
+
"runtime result is missing runtime_context_id",
|
|
426
|
+
)
|
|
427
|
+
host_context_key = self._required_text(
|
|
428
|
+
result,
|
|
429
|
+
"host_context_key",
|
|
430
|
+
"runtime result is missing host_context_key",
|
|
431
|
+
)
|
|
432
|
+
if runtime_context_id != runtime["runtime_context_id"]:
|
|
433
|
+
raise BatchExecutionError(
|
|
434
|
+
"BATCH-RUNTIME-ID-001",
|
|
435
|
+
"runtime result does not match initialized context",
|
|
436
|
+
)
|
|
437
|
+
if host_context_key != runtime["host_context_key"]:
|
|
438
|
+
raise BatchExecutionError(
|
|
439
|
+
"BATCH-RUNTIME-BIND-001",
|
|
440
|
+
"runtime result does not match expected host context",
|
|
441
|
+
)
|
|
442
|
+
if action.get("runtime_context_id") != runtime_context_id:
|
|
443
|
+
raise BatchExecutionError(
|
|
444
|
+
"BATCH-RUNTIME-ID-001",
|
|
445
|
+
"pending action runtime context changed",
|
|
446
|
+
)
|
|
447
|
+
context = self.runtime_contexts.load(runtime_context_id)
|
|
448
|
+
self._verify_runtime_identity(
|
|
449
|
+
context,
|
|
450
|
+
task_name,
|
|
451
|
+
role,
|
|
452
|
+
runtime_context_id,
|
|
453
|
+
)
|
|
454
|
+
status = context.get("status")
|
|
455
|
+
if status not in {"bound", "closed"}:
|
|
456
|
+
raise BatchExecutionError(
|
|
457
|
+
"BATCH-RUNTIME-BIND-001",
|
|
458
|
+
f"runtime context {runtime_context_id} is not bound",
|
|
459
|
+
)
|
|
460
|
+
if context.get("bound_context_key") != host_context_key:
|
|
461
|
+
raise BatchExecutionError(
|
|
462
|
+
"BATCH-RUNTIME-BIND-001",
|
|
463
|
+
f"runtime context {runtime_context_id} has unexpected "
|
|
464
|
+
"bound_context_key",
|
|
465
|
+
)
|
|
466
|
+
if status == "bound":
|
|
467
|
+
if not self.runtime_contexts.close(runtime_context_id):
|
|
468
|
+
raise BatchExecutionError(
|
|
469
|
+
"BATCH-RUNTIME-CLOSE-001",
|
|
470
|
+
"runtime context could not be closed: "
|
|
471
|
+
f"{runtime_context_id}",
|
|
472
|
+
)
|
|
473
|
+
closed = self.runtime_contexts.load(runtime_context_id)
|
|
474
|
+
if closed.get("status") != "closed":
|
|
475
|
+
raise BatchExecutionError(
|
|
476
|
+
"BATCH-RUNTIME-CLOSE-001",
|
|
477
|
+
f"runtime context did not close: {runtime_context_id}",
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
@staticmethod
|
|
481
|
+
def _verify_runtime_identity(
|
|
482
|
+
context: dict,
|
|
483
|
+
task_name: str,
|
|
484
|
+
role: str,
|
|
485
|
+
runtime_context_id: str,
|
|
486
|
+
) -> None:
|
|
487
|
+
if not context:
|
|
488
|
+
raise BatchExecutionError(
|
|
489
|
+
"BATCH-RUNTIME-MISSING-001",
|
|
490
|
+
f"runtime context does not exist: {runtime_context_id}",
|
|
491
|
+
)
|
|
492
|
+
if context.get("scope") != "subagent":
|
|
493
|
+
raise BatchExecutionError(
|
|
494
|
+
"BATCH-RUNTIME-SCOPE-001",
|
|
495
|
+
f"runtime context is not subagent-scoped: "
|
|
496
|
+
f"{runtime_context_id}",
|
|
497
|
+
)
|
|
498
|
+
if context.get("role") != role:
|
|
499
|
+
raise BatchExecutionError(
|
|
500
|
+
"BATCH-RUNTIME-ROLE-001",
|
|
501
|
+
f"runtime context role is not {role}: "
|
|
502
|
+
f"{runtime_context_id}",
|
|
503
|
+
)
|
|
504
|
+
expected_task_dir = f".cowork-flow/tasks/{task_name}"
|
|
505
|
+
if context.get("task_dir") != expected_task_dir:
|
|
506
|
+
raise BatchExecutionError(
|
|
507
|
+
"BATCH-RUNTIME-TASK-001",
|
|
508
|
+
f"runtime context task_dir is not {expected_task_dir}",
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
def _mark_step_completed(
|
|
512
|
+
self,
|
|
513
|
+
state: dict,
|
|
514
|
+
action: dict,
|
|
515
|
+
result: dict,
|
|
516
|
+
) -> None:
|
|
517
|
+
task_name = str(action["task"])
|
|
518
|
+
step = str(action["type"])
|
|
519
|
+
task_steps = dict(state.get("task_steps") or {})
|
|
520
|
+
completed_steps = list(task_steps.get(task_name) or [])
|
|
521
|
+
if step not in completed_steps:
|
|
522
|
+
completed_steps.append(step)
|
|
523
|
+
task_steps[task_name] = completed_steps
|
|
524
|
+
state["task_steps"] = task_steps
|
|
525
|
+
|
|
526
|
+
phase = STEP_PHASES.get(step)
|
|
527
|
+
if phase:
|
|
528
|
+
task_phases = dict(state.get("task_phases") or {})
|
|
529
|
+
completed_phases = list(task_phases.get(task_name) or [])
|
|
530
|
+
if phase not in completed_phases:
|
|
531
|
+
completed_phases.append(phase)
|
|
532
|
+
task_phases[task_name] = completed_phases
|
|
533
|
+
state["task_phases"] = task_phases
|
|
534
|
+
|
|
535
|
+
if step == "commit_task":
|
|
536
|
+
commits = dict(state.get("commits") or {})
|
|
537
|
+
commits[task_name] = result["commit_id"].strip()
|
|
538
|
+
state["commits"] = commits
|
|
539
|
+
|
|
540
|
+
def _pause(
|
|
541
|
+
self,
|
|
542
|
+
path: Path,
|
|
543
|
+
state: dict,
|
|
544
|
+
action: dict,
|
|
545
|
+
result: dict,
|
|
546
|
+
detail: str,
|
|
547
|
+
) -> dict:
|
|
548
|
+
task_name = str(action["task"])
|
|
549
|
+
retry_count = dict(state.get("retry_count") or {})
|
|
550
|
+
retry_count[task_name] = int(retry_count.get(task_name, 0)) + 1
|
|
551
|
+
state["retry_count"] = retry_count
|
|
552
|
+
action_results = dict(state.get("action_results") or {})
|
|
553
|
+
action_results[result["action_id"]] = dict(result)
|
|
554
|
+
state["action_results"] = action_results
|
|
555
|
+
state["phase"] = "paused"
|
|
556
|
+
state["current_task"] = task_name
|
|
557
|
+
state["next_action"] = None
|
|
558
|
+
state["pause_reason"] = (
|
|
559
|
+
f"{task_name}:{action['type']}: {detail}"
|
|
560
|
+
)
|
|
561
|
+
self._save(path, state)
|
|
562
|
+
return state
|
|
563
|
+
|
|
564
|
+
def _runtime_for(
|
|
565
|
+
self,
|
|
566
|
+
state: dict,
|
|
567
|
+
task_name: str,
|
|
568
|
+
role: str,
|
|
569
|
+
) -> dict:
|
|
570
|
+
runtime_contexts = dict(state.get("runtime_contexts") or {})
|
|
571
|
+
task_contexts = dict(runtime_contexts.get(task_name) or {})
|
|
572
|
+
runtime = task_contexts.get(role)
|
|
573
|
+
if not isinstance(runtime, dict):
|
|
574
|
+
raise BatchExecutionError(
|
|
575
|
+
"BATCH-RUNTIME-MISSING-001",
|
|
576
|
+
f"runtime context was not initialized for "
|
|
577
|
+
f"{task_name}:{role}",
|
|
578
|
+
)
|
|
579
|
+
return runtime
|
|
580
|
+
|
|
581
|
+
def _assert_graph_unchanged(self, state: dict) -> None:
|
|
582
|
+
root_task = state.get("root_task")
|
|
583
|
+
if not isinstance(root_task, str) or not root_task.strip():
|
|
584
|
+
raise BatchExecutionError(
|
|
585
|
+
"BATCH-STATE-ROOT-001",
|
|
586
|
+
"Batch state is missing root_task",
|
|
587
|
+
)
|
|
588
|
+
_, graph_digest = self._plan(root_task)
|
|
589
|
+
if state.get("graph_digest") != graph_digest:
|
|
590
|
+
raise BatchExecutionError(
|
|
591
|
+
"BATCH-GRAPH-CHANGED-001",
|
|
592
|
+
"task graph changed after Batch state was created",
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
def _plan(self, root_task: str) -> tuple[tuple[str, ...], str]:
|
|
596
|
+
nodes = self.tree.active_nodes()
|
|
597
|
+
if root_task not in nodes:
|
|
598
|
+
raise BatchExecutionError(
|
|
599
|
+
"BATCH-GRAPH-MISSING-001",
|
|
600
|
+
f"root task does not exist: {root_task}",
|
|
601
|
+
)
|
|
602
|
+
ordered: list[str] = []
|
|
603
|
+
visiting: set[str] = set()
|
|
604
|
+
visited: set[str] = set()
|
|
605
|
+
|
|
606
|
+
def visit(name: str) -> None:
|
|
607
|
+
if name in visiting:
|
|
608
|
+
raise BatchExecutionError(
|
|
609
|
+
"BATCH-GRAPH-CYCLE-001",
|
|
610
|
+
f"task graph cycle detected at {name}",
|
|
611
|
+
)
|
|
612
|
+
if name in visited:
|
|
613
|
+
return
|
|
614
|
+
node = nodes.get(name)
|
|
615
|
+
if node is None:
|
|
616
|
+
raise BatchExecutionError(
|
|
617
|
+
"BATCH-GRAPH-MISSING-001",
|
|
618
|
+
f"task graph references missing task: {name}",
|
|
619
|
+
)
|
|
620
|
+
visiting.add(name)
|
|
621
|
+
for child in node.children:
|
|
622
|
+
visit(child)
|
|
623
|
+
visiting.remove(name)
|
|
624
|
+
visited.add(name)
|
|
625
|
+
if not node.children:
|
|
626
|
+
ordered.append(name)
|
|
627
|
+
|
|
628
|
+
visit(root_task)
|
|
629
|
+
graph_payload = {
|
|
630
|
+
name: list(nodes[name].children)
|
|
631
|
+
for name in sorted(visited)
|
|
632
|
+
}
|
|
633
|
+
digest = hashlib.sha256(
|
|
634
|
+
json.dumps(
|
|
635
|
+
graph_payload,
|
|
636
|
+
sort_keys=True,
|
|
637
|
+
separators=(",", ":"),
|
|
638
|
+
).encode("utf-8")
|
|
639
|
+
).hexdigest()
|
|
640
|
+
return tuple(ordered), digest
|
|
641
|
+
|
|
642
|
+
def _verify_git_commit(self, commit_id: str) -> bool:
|
|
643
|
+
try:
|
|
644
|
+
completed = subprocess.run(
|
|
645
|
+
[
|
|
646
|
+
"git",
|
|
647
|
+
"-C",
|
|
648
|
+
str(self.repo_root),
|
|
649
|
+
"cat-file",
|
|
650
|
+
"-e",
|
|
651
|
+
f"{commit_id}^{{commit}}",
|
|
652
|
+
],
|
|
653
|
+
check=False,
|
|
654
|
+
capture_output=True,
|
|
655
|
+
encoding="utf-8",
|
|
656
|
+
errors="replace",
|
|
657
|
+
)
|
|
658
|
+
except OSError:
|
|
659
|
+
return False
|
|
660
|
+
return completed.returncode == 0
|
|
661
|
+
|
|
662
|
+
def _state_path(self, operation_id: str) -> Path:
|
|
663
|
+
return (
|
|
664
|
+
self.repo_root
|
|
665
|
+
/ ".cowork-flow"
|
|
666
|
+
/ "runtime"
|
|
667
|
+
/ "batches"
|
|
668
|
+
/ f"{operation_id}.json"
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
def _load(self, path: Path):
|
|
672
|
+
try:
|
|
673
|
+
return self.state_store.load(path, missing_ok=True)
|
|
674
|
+
except StateStoreError as error:
|
|
675
|
+
raise BatchExecutionError(
|
|
676
|
+
"BATCH-STATE-LOAD-001",
|
|
677
|
+
error.detail,
|
|
678
|
+
) from error
|
|
679
|
+
|
|
680
|
+
def _save(self, path: Path, state: dict) -> None:
|
|
681
|
+
try:
|
|
682
|
+
snapshot = self.state_store.load(path, missing_ok=True)
|
|
683
|
+
self.state_store.replace(
|
|
684
|
+
path,
|
|
685
|
+
state,
|
|
686
|
+
expected_revision=snapshot.revision,
|
|
687
|
+
operation_id=(
|
|
688
|
+
f"{state['operation_id']}:checkpoint:"
|
|
689
|
+
f"{snapshot.revision + 1}"
|
|
690
|
+
),
|
|
691
|
+
)
|
|
692
|
+
except StateStoreError as error:
|
|
693
|
+
raise BatchExecutionError(
|
|
694
|
+
"BATCH-STATE-SAVE-001",
|
|
695
|
+
error.detail,
|
|
696
|
+
) from error
|
|
697
|
+
|
|
698
|
+
@staticmethod
|
|
699
|
+
def _phase_for_step(step: str) -> str:
|
|
700
|
+
if "implement" in step:
|
|
701
|
+
return "implement"
|
|
702
|
+
if "check" in step:
|
|
703
|
+
return "check"
|
|
704
|
+
return STEP_PHASES[step]
|
|
705
|
+
|
|
706
|
+
@staticmethod
|
|
707
|
+
def _normalize_result(payload: dict) -> dict:
|
|
708
|
+
if not isinstance(payload, dict):
|
|
709
|
+
raise BatchExecutionError(
|
|
710
|
+
"BATCH-RESULT-PAYLOAD-001",
|
|
711
|
+
"Host action result must be a JSON object",
|
|
712
|
+
)
|
|
713
|
+
result = dict(payload)
|
|
714
|
+
for key in ("action_id", "type", "outcome"):
|
|
715
|
+
value = result.get(key)
|
|
716
|
+
if not isinstance(value, str) or not value.strip():
|
|
717
|
+
raise BatchExecutionError(
|
|
718
|
+
"BATCH-RESULT-PAYLOAD-001",
|
|
719
|
+
f"Host action result is missing {key}",
|
|
720
|
+
)
|
|
721
|
+
result[key] = value.strip()
|
|
722
|
+
return result
|
|
723
|
+
|
|
724
|
+
@staticmethod
|
|
725
|
+
def _required_text(
|
|
726
|
+
payload: dict,
|
|
727
|
+
key: str,
|
|
728
|
+
detail: str,
|
|
729
|
+
) -> str:
|
|
730
|
+
value = payload.get(key)
|
|
731
|
+
if not isinstance(value, str) or not value.strip():
|
|
732
|
+
raise BatchExecutionError(
|
|
733
|
+
"BATCH-RESULT-PAYLOAD-001",
|
|
734
|
+
detail,
|
|
735
|
+
)
|
|
736
|
+
return value.strip()
|
|
737
|
+
|
|
738
|
+
@staticmethod
|
|
739
|
+
def _optional_text(payload: dict, key: str) -> str | None:
|
|
740
|
+
value = payload.get(key)
|
|
741
|
+
if isinstance(value, str) and value.strip():
|
|
742
|
+
return value.strip()
|
|
743
|
+
return None
|