cowork-flow 0.0.48 → 0.0.49
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 +16 -1
- package/package.json +6 -3
- package/src/cli.js +6 -0
- package/src/commands/source-refresh.js +40 -0
- package/src/lib/copy-template.js +130 -1
- package/src/lib/host-assets.js +216 -71
- package/template/.codex/hooks.json +1 -1
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/task.py +0 -2
- package/template/.cowork-flow/scripts/adapters/cli/task_archive_commands.py +12 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +18 -133
- package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +82 -1
- package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +98 -114
- package/template/.cowork-flow/scripts/infra/skill_manifest.py +23 -3
- package/template/.cowork-flow/scripts/infra/storage/operation_log.py +40 -0
- package/template/.cowork-flow/scripts/infra/storage/state_store.py +356 -1
- package/template/.cowork-flow/scripts/services/context_discovery.py +156 -0
- package/template/.cowork-flow/scripts/services/context_jsonl.py +119 -0
- package/template/.cowork-flow/scripts/services/context_paths.py +186 -0
- package/template/.cowork-flow/scripts/services/lifecycle_checks.py +23 -4
- package/template/.cowork-flow/scripts/services/lifecycle_policy.py +128 -0
- package/template/.cowork-flow/scripts/services/plan_binding.py +146 -0
- package/template/.cowork-flow/scripts/services/task_archive.py +100 -14
- package/template/.cowork-flow/scripts/services/task_context.py +49 -479
- package/template/.cowork-flow/scripts/services/task_creation.py +31 -27
- package/template/.cowork-flow/scripts/services/task_lifecycle.py +155 -18
- package/template/.cowork-flow/spec/contracts/error-output-as-data.md +8 -0
- package/template/.cowork-flow/spec/runtime/index.md +1 -1
- package/template/.cowork-flow/spec/schemas/host-assets.schema.json +32 -6
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/skills/batch-execution/scripts/batch_execution.py +111 -629
- package/template/skills/batch-execution/scripts/batch_recovery.py +94 -0
- package/template/skills/batch-execution/scripts/batch_state_machine.py +201 -0
- package/template/skills/batch-execution/scripts/batch_verification.py +326 -0
- package/template/skills/cowork-flow/SKILL.md +3 -0
- package/template/skills/party-mode/scripts/party_actions.py +95 -0
- package/template/skills/party-mode/scripts/party_mode_v2.py +129 -205
- package/template/skills/party-mode/scripts/party_reports.py +177 -0
- package/template/skills/party-mode/scripts/party_state_machine.py +189 -0
- package/template/skills/runtime-health/scripts/doctor.py +204 -15
- package/template/skills/task-review/scripts/review_check.py +135 -2
- package/template/skills/test-first/SKILL.md +18 -3
- package/template/skills/test-first/manifest.json +8 -4
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Recoverable task-graph Batch execution facade."""
|
|
3
4
|
|
|
4
5
|
from __future__ import annotations
|
|
5
6
|
|
|
@@ -16,53 +17,30 @@ from services.workflow_runtime import (
|
|
|
16
17
|
from services.task_tree import TaskTreeService
|
|
17
18
|
from infra.storage.state_store import StateStore, StateStoreError
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
20
|
+
from batch_recovery import inspect_projection
|
|
21
|
+
from batch_state_machine import (
|
|
22
|
+
BATCH_STEPS,
|
|
23
|
+
LIFECYCLE_STATUSES,
|
|
24
|
+
RUNTIME_ROLES,
|
|
25
|
+
STEP_PHASES,
|
|
26
|
+
BatchExecutionError,
|
|
27
|
+
advance_state,
|
|
28
|
+
mark_step_completed,
|
|
29
|
+
pause_state,
|
|
30
|
+
phase_for_step,
|
|
31
|
+
)
|
|
32
|
+
from batch_verification import (
|
|
33
|
+
BatchActionVerifier,
|
|
34
|
+
normalize_result,
|
|
35
|
+
optional_text,
|
|
36
|
+
required_text,
|
|
30
37
|
)
|
|
31
|
-
STEP_PHASES = {
|
|
32
|
-
"start_task": "start",
|
|
33
|
-
"await_implement_result": "implement",
|
|
34
|
-
"review_task": "review",
|
|
35
|
-
"await_check_result": "check",
|
|
36
|
-
"complete_task": "complete",
|
|
37
|
-
"archive_task": "archive",
|
|
38
|
-
"commit_task": "commit",
|
|
39
|
-
}
|
|
40
|
-
LIFECYCLE_STATUSES = {
|
|
41
|
-
"start_task": "in_progress",
|
|
42
|
-
"review_task": "review",
|
|
43
|
-
"complete_task": "completed",
|
|
44
|
-
"archive_task": "completed",
|
|
45
|
-
}
|
|
46
|
-
RUNTIME_ROLES = {
|
|
47
|
-
"init_implement_context": "implement",
|
|
48
|
-
"await_implement_result": "implement",
|
|
49
|
-
"init_check_context": "check",
|
|
50
|
-
"await_check_result": "check",
|
|
51
|
-
}
|
|
52
|
-
CommitVerifier = Callable[[str], bool]
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
class BatchExecutionError(RuntimeError):
|
|
56
|
-
"""Raised when Batch graph, state, or host input is invalid."""
|
|
57
38
|
|
|
58
|
-
|
|
59
|
-
self.code = code
|
|
60
|
-
self.detail = detail
|
|
61
|
-
super().__init__(f"{code}: {detail}")
|
|
39
|
+
CommitVerifier = Callable[[str], bool]
|
|
62
40
|
|
|
63
41
|
|
|
64
42
|
class BatchExecutionService:
|
|
65
|
-
"""Publish and verify one host-neutral Batch action at a time."""
|
|
43
|
+
"""Publish, persist, and verify one host-neutral Batch action at a time."""
|
|
66
44
|
|
|
67
45
|
def __init__(
|
|
68
46
|
self,
|
|
@@ -79,6 +57,11 @@ class BatchExecutionService:
|
|
|
79
57
|
state_store=self.state_store,
|
|
80
58
|
)
|
|
81
59
|
self.commit_verifier = commit_verifier or self._verify_git_commit
|
|
60
|
+
self.verifier = BatchActionVerifier(
|
|
61
|
+
self.repo_root,
|
|
62
|
+
self.runtime_contexts,
|
|
63
|
+
self.commit_verifier,
|
|
64
|
+
)
|
|
82
65
|
|
|
83
66
|
def start(self, root_task: str) -> dict:
|
|
84
67
|
ordered_tasks, graph_digest = self._plan(root_task)
|
|
@@ -141,20 +124,7 @@ class BatchExecutionService:
|
|
|
141
124
|
"BATCH-STATE-MISSING-001",
|
|
142
125
|
f"Batch state does not exist: {operation_id}",
|
|
143
126
|
)
|
|
144
|
-
|
|
145
|
-
failed_action = self._failed_action(state)
|
|
146
|
-
return {
|
|
147
|
-
"operationId": operation_id,
|
|
148
|
-
"state": state.get("phase"),
|
|
149
|
-
"rootTask": state.get("root_task"),
|
|
150
|
-
"currentPhase": self._current_phase(state, failed_action),
|
|
151
|
-
"currentTask": state.get("current_task"),
|
|
152
|
-
"completedTasks": list(state.get("completed_tasks") or []),
|
|
153
|
-
"pausedReason": state.get("pause_reason"),
|
|
154
|
-
"failedAction": failed_action,
|
|
155
|
-
"nextAction": state.get("next_action"),
|
|
156
|
-
"recovery": self._recovery_facts(state),
|
|
157
|
-
}
|
|
127
|
+
return inspect_projection(operation_id, dict(snapshot.data))
|
|
158
128
|
|
|
159
129
|
def record_result(self, operation_id: str, payload: dict) -> dict:
|
|
160
130
|
path = self._state_path(operation_id)
|
|
@@ -166,7 +136,7 @@ class BatchExecutionService:
|
|
|
166
136
|
)
|
|
167
137
|
state = dict(snapshot.data)
|
|
168
138
|
self._assert_graph_unchanged(state)
|
|
169
|
-
result =
|
|
139
|
+
result = normalize_result(payload)
|
|
170
140
|
action_id = result["action_id"]
|
|
171
141
|
previous = dict(state.get("action_results") or {}).get(action_id)
|
|
172
142
|
if previous is not None:
|
|
@@ -191,12 +161,11 @@ class BatchExecutionService:
|
|
|
191
161
|
if result["type"] != action.get("type"):
|
|
192
162
|
raise BatchExecutionError(
|
|
193
163
|
"BATCH-ACTION-TYPE-001",
|
|
194
|
-
f"expected action type {action.get('type')}, "
|
|
195
|
-
f"got {result['type']}",
|
|
164
|
+
f"expected action type {action.get('type')}, got {result['type']}",
|
|
196
165
|
)
|
|
197
166
|
|
|
198
167
|
if result["outcome"] != "success":
|
|
199
|
-
detail =
|
|
168
|
+
detail = optional_text(result, "detail")
|
|
200
169
|
return self._pause(
|
|
201
170
|
path,
|
|
202
171
|
state,
|
|
@@ -206,21 +175,15 @@ class BatchExecutionService:
|
|
|
206
175
|
)
|
|
207
176
|
|
|
208
177
|
try:
|
|
209
|
-
self.
|
|
178
|
+
self.verifier.verify_success(state, action, result)
|
|
210
179
|
except (BatchExecutionError, RuntimeContextError) as error:
|
|
211
180
|
detail = getattr(error, "detail", str(error))
|
|
212
|
-
return self._pause(
|
|
213
|
-
path,
|
|
214
|
-
state,
|
|
215
|
-
action,
|
|
216
|
-
result,
|
|
217
|
-
detail,
|
|
218
|
-
)
|
|
181
|
+
return self._pause(path, state, action, result, detail)
|
|
219
182
|
|
|
220
183
|
action_results = dict(state.get("action_results") or {})
|
|
221
184
|
action_results[action_id] = result
|
|
222
185
|
state["action_results"] = action_results
|
|
223
|
-
|
|
186
|
+
mark_step_completed(state, action, result)
|
|
224
187
|
state["next_action"] = None
|
|
225
188
|
state["phase"] = "running"
|
|
226
189
|
state["pause_reason"] = None
|
|
@@ -228,475 +191,10 @@ class BatchExecutionService:
|
|
|
228
191
|
return self._advance(path, state)
|
|
229
192
|
|
|
230
193
|
def _advance(self, path: Path, state: dict) -> dict:
|
|
231
|
-
if state
|
|
232
|
-
return state
|
|
233
|
-
if isinstance(state.get("next_action"), dict):
|
|
234
|
-
return state
|
|
235
|
-
|
|
236
|
-
completed_tasks = list(state.get("completed_tasks") or [])
|
|
237
|
-
task_steps = dict(state.get("task_steps") or {})
|
|
238
|
-
retry_count = dict(state.get("retry_count") or {})
|
|
239
|
-
|
|
240
|
-
for task_name in state["ordered_tasks"]:
|
|
241
|
-
if task_name in completed_tasks:
|
|
242
|
-
continue
|
|
243
|
-
state["current_task"] = task_name
|
|
244
|
-
completed_steps = list(task_steps.get(task_name) or [])
|
|
245
|
-
for step in BATCH_STEPS:
|
|
246
|
-
if step in completed_steps:
|
|
247
|
-
continue
|
|
248
|
-
action = self._build_action(state, task_name, step)
|
|
249
|
-
state["next_action"] = action
|
|
250
|
-
state["phase"] = "awaiting_host"
|
|
251
|
-
self._save(path, state)
|
|
252
|
-
return state
|
|
253
|
-
|
|
254
|
-
completed_tasks.append(task_name)
|
|
255
|
-
state["completed_tasks"] = completed_tasks
|
|
256
|
-
retry_count[task_name] = 0
|
|
257
|
-
state["retry_count"] = retry_count
|
|
258
|
-
state["current_task"] = None
|
|
194
|
+
if advance_state(state):
|
|
259
195
|
self._save(path, state)
|
|
260
|
-
|
|
261
|
-
state["phase"] = "completed"
|
|
262
|
-
state["current_task"] = None
|
|
263
|
-
state["next_action"] = None
|
|
264
|
-
state["pause_reason"] = None
|
|
265
|
-
self._save(path, state)
|
|
266
196
|
return state
|
|
267
197
|
|
|
268
|
-
@classmethod
|
|
269
|
-
def _current_phase(
|
|
270
|
-
cls,
|
|
271
|
-
state: dict,
|
|
272
|
-
failed_action: dict | None,
|
|
273
|
-
) -> str | None:
|
|
274
|
-
next_action = state.get("next_action")
|
|
275
|
-
if isinstance(next_action, dict):
|
|
276
|
-
phase = next_action.get("phase")
|
|
277
|
-
return str(phase) if isinstance(phase, str) else None
|
|
278
|
-
if failed_action is not None:
|
|
279
|
-
action_type = failed_action.get("type")
|
|
280
|
-
if isinstance(action_type, str) and action_type in BATCH_STEPS:
|
|
281
|
-
return cls._phase_for_step(action_type)
|
|
282
|
-
task_name = state.get("current_task")
|
|
283
|
-
if isinstance(task_name, str):
|
|
284
|
-
task_phases = dict(state.get("task_phases") or {})
|
|
285
|
-
phases = task_phases.get(task_name)
|
|
286
|
-
if isinstance(phases, list) and phases:
|
|
287
|
-
phase = phases[-1]
|
|
288
|
-
return str(phase) if isinstance(phase, str) else None
|
|
289
|
-
phase = state.get("phase")
|
|
290
|
-
return str(phase) if isinstance(phase, str) else None
|
|
291
|
-
|
|
292
|
-
@staticmethod
|
|
293
|
-
def _failed_action(state: dict) -> dict | None:
|
|
294
|
-
if state.get("phase") != "paused":
|
|
295
|
-
return None
|
|
296
|
-
action_results = dict(state.get("action_results") or {})
|
|
297
|
-
if not action_results:
|
|
298
|
-
return None
|
|
299
|
-
action_id, result = next(reversed(action_results.items()))
|
|
300
|
-
if not isinstance(result, dict):
|
|
301
|
-
return {"actionId": str(action_id)}
|
|
302
|
-
failed = {"actionId": str(action_id)}
|
|
303
|
-
result_type = result.get("type")
|
|
304
|
-
if isinstance(result_type, str):
|
|
305
|
-
failed["type"] = result_type
|
|
306
|
-
outcome = result.get("outcome")
|
|
307
|
-
if isinstance(outcome, str):
|
|
308
|
-
failed["outcome"] = outcome
|
|
309
|
-
detail = result.get("detail")
|
|
310
|
-
if isinstance(detail, str) and detail.strip():
|
|
311
|
-
failed["detail"] = detail.strip()
|
|
312
|
-
return failed
|
|
313
|
-
|
|
314
|
-
@staticmethod
|
|
315
|
-
def _recovery_facts(state: dict) -> dict:
|
|
316
|
-
if state.get("phase") != "paused":
|
|
317
|
-
return {}
|
|
318
|
-
operation_id = state.get("operation_id")
|
|
319
|
-
recovery: dict[str, object] = {}
|
|
320
|
-
if isinstance(operation_id, str) and operation_id.strip():
|
|
321
|
-
recovery["resumeCommand"] = (
|
|
322
|
-
f"batch-action resume {operation_id.strip()}"
|
|
323
|
-
)
|
|
324
|
-
task_name = state.get("current_task")
|
|
325
|
-
if isinstance(task_name, str) and task_name.strip():
|
|
326
|
-
recovery["retryTask"] = task_name.strip()
|
|
327
|
-
pause_reason = state.get("pause_reason")
|
|
328
|
-
if isinstance(pause_reason, str) and pause_reason.strip():
|
|
329
|
-
recovery["pausedReason"] = pause_reason.strip()
|
|
330
|
-
return recovery
|
|
331
|
-
|
|
332
|
-
def _build_action(
|
|
333
|
-
self,
|
|
334
|
-
state: dict,
|
|
335
|
-
task_name: str,
|
|
336
|
-
step: str,
|
|
337
|
-
) -> dict:
|
|
338
|
-
sequence = int(state.get("next_action_sequence") or 0) + 1
|
|
339
|
-
state["next_action_sequence"] = sequence
|
|
340
|
-
action = {
|
|
341
|
-
"action_id": (
|
|
342
|
-
f"{state['operation_id']}:{sequence}:{task_name}:{step}"
|
|
343
|
-
),
|
|
344
|
-
"type": step,
|
|
345
|
-
"phase": self._phase_for_step(step),
|
|
346
|
-
"task": task_name,
|
|
347
|
-
"task_dir": f".cowork-flow/tasks/{task_name}",
|
|
348
|
-
}
|
|
349
|
-
role = RUNTIME_ROLES.get(step)
|
|
350
|
-
if step.startswith("init_") and role:
|
|
351
|
-
action.update(
|
|
352
|
-
{
|
|
353
|
-
"role": role,
|
|
354
|
-
"agent_type": f"cowork-{role}",
|
|
355
|
-
"title": (
|
|
356
|
-
f"{role.capitalize()} Batch task {task_name}"
|
|
357
|
-
),
|
|
358
|
-
}
|
|
359
|
-
)
|
|
360
|
-
if step.startswith("await_") and role:
|
|
361
|
-
runtime = self._runtime_for(state, task_name, role)
|
|
362
|
-
action.update(
|
|
363
|
-
{
|
|
364
|
-
"role": role,
|
|
365
|
-
"runtime_context_id": runtime["runtime_context_id"],
|
|
366
|
-
"host_context_key": runtime["host_context_key"],
|
|
367
|
-
}
|
|
368
|
-
)
|
|
369
|
-
return action
|
|
370
|
-
|
|
371
|
-
def _verify_success(
|
|
372
|
-
self,
|
|
373
|
-
state: dict,
|
|
374
|
-
action: dict,
|
|
375
|
-
result: dict,
|
|
376
|
-
) -> None:
|
|
377
|
-
action_type = str(action["type"])
|
|
378
|
-
task_name = str(action["task"])
|
|
379
|
-
if action_type == "archive_task":
|
|
380
|
-
self._verify_archive_result(task_name, result)
|
|
381
|
-
return
|
|
382
|
-
if action_type in LIFECYCLE_STATUSES:
|
|
383
|
-
self._verify_task_status(
|
|
384
|
-
task_name,
|
|
385
|
-
LIFECYCLE_STATUSES[action_type],
|
|
386
|
-
result,
|
|
387
|
-
)
|
|
388
|
-
return
|
|
389
|
-
if action_type.startswith("init_"):
|
|
390
|
-
self._verify_runtime_initialized(
|
|
391
|
-
state,
|
|
392
|
-
task_name,
|
|
393
|
-
RUNTIME_ROLES[action_type],
|
|
394
|
-
result,
|
|
395
|
-
)
|
|
396
|
-
return
|
|
397
|
-
if action_type.startswith("await_"):
|
|
398
|
-
self._verify_runtime_completed(
|
|
399
|
-
state,
|
|
400
|
-
task_name,
|
|
401
|
-
RUNTIME_ROLES[action_type],
|
|
402
|
-
action,
|
|
403
|
-
result,
|
|
404
|
-
)
|
|
405
|
-
return
|
|
406
|
-
if action_type == "commit_task":
|
|
407
|
-
commit_id = self._required_text(
|
|
408
|
-
result,
|
|
409
|
-
"commit_id",
|
|
410
|
-
"commit result is missing commit_id",
|
|
411
|
-
)
|
|
412
|
-
if not self.commit_verifier(commit_id):
|
|
413
|
-
raise BatchExecutionError(
|
|
414
|
-
"BATCH-COMMIT-VERIFY-001",
|
|
415
|
-
f"commit_id could not be verified: {commit_id}",
|
|
416
|
-
)
|
|
417
|
-
return
|
|
418
|
-
raise BatchExecutionError(
|
|
419
|
-
"BATCH-ACTION-UNKNOWN-001",
|
|
420
|
-
f"unsupported Batch action: {action_type}",
|
|
421
|
-
)
|
|
422
|
-
|
|
423
|
-
def _verify_task_status(
|
|
424
|
-
self,
|
|
425
|
-
task_name: str,
|
|
426
|
-
expected_status: str,
|
|
427
|
-
result: dict,
|
|
428
|
-
) -> None:
|
|
429
|
-
reported = self._optional_text(result, "task_status")
|
|
430
|
-
if reported and reported != expected_status:
|
|
431
|
-
raise BatchExecutionError(
|
|
432
|
-
"BATCH-TASK-STATUS-001",
|
|
433
|
-
f"Host reported task status {reported}; "
|
|
434
|
-
f"expected {expected_status}",
|
|
435
|
-
)
|
|
436
|
-
task_path = (
|
|
437
|
-
self.repo_root
|
|
438
|
-
/ ".cowork-flow"
|
|
439
|
-
/ "tasks"
|
|
440
|
-
/ task_name
|
|
441
|
-
/ "task.json"
|
|
442
|
-
)
|
|
443
|
-
try:
|
|
444
|
-
task_data = json.loads(
|
|
445
|
-
task_path.read_text(encoding="utf-8")
|
|
446
|
-
)
|
|
447
|
-
except (OSError, json.JSONDecodeError) as error:
|
|
448
|
-
raise BatchExecutionError(
|
|
449
|
-
"BATCH-TASK-LOAD-001",
|
|
450
|
-
f"cannot load task status for {task_name}: {error}",
|
|
451
|
-
) from error
|
|
452
|
-
actual = task_data.get("status")
|
|
453
|
-
if actual != expected_status:
|
|
454
|
-
raise BatchExecutionError(
|
|
455
|
-
"BATCH-TASK-STATUS-001",
|
|
456
|
-
f"task status for {task_name} is {actual}; "
|
|
457
|
-
f"expected {expected_status}",
|
|
458
|
-
)
|
|
459
|
-
|
|
460
|
-
def _verify_archive_result(
|
|
461
|
-
self,
|
|
462
|
-
task_name: str,
|
|
463
|
-
result: dict,
|
|
464
|
-
) -> None:
|
|
465
|
-
destination = self._required_text(
|
|
466
|
-
result,
|
|
467
|
-
"archive_destination",
|
|
468
|
-
"archive result is missing archive_destination",
|
|
469
|
-
)
|
|
470
|
-
destination_path = Path(destination)
|
|
471
|
-
if destination_path.is_absolute():
|
|
472
|
-
raise BatchExecutionError(
|
|
473
|
-
"BATCH-ARCHIVE-VERIFY-001",
|
|
474
|
-
f"archive_destination must be relative: {destination}",
|
|
475
|
-
)
|
|
476
|
-
archive_root = (
|
|
477
|
-
self.repo_root / ".cowork-flow" / "tasks" / "archive"
|
|
478
|
-
).resolve()
|
|
479
|
-
archive_path = (self.repo_root / destination_path).resolve()
|
|
480
|
-
try:
|
|
481
|
-
archive_path.relative_to(archive_root)
|
|
482
|
-
except ValueError as error:
|
|
483
|
-
raise BatchExecutionError(
|
|
484
|
-
"BATCH-ARCHIVE-VERIFY-001",
|
|
485
|
-
"archive_destination must be under "
|
|
486
|
-
f".cowork-flow/tasks/archive: {destination}",
|
|
487
|
-
) from error
|
|
488
|
-
if archive_path.name != task_name:
|
|
489
|
-
raise BatchExecutionError(
|
|
490
|
-
"BATCH-ARCHIVE-VERIFY-001",
|
|
491
|
-
f"archive_destination task name is {archive_path.name}; "
|
|
492
|
-
f"expected {task_name}",
|
|
493
|
-
)
|
|
494
|
-
if not archive_path.is_dir():
|
|
495
|
-
raise BatchExecutionError(
|
|
496
|
-
"BATCH-ARCHIVE-VERIFY-001",
|
|
497
|
-
f"archive_destination does not exist: {destination}",
|
|
498
|
-
)
|
|
499
|
-
task_json = archive_path / "task.json"
|
|
500
|
-
try:
|
|
501
|
-
task_data = json.loads(task_json.read_text(encoding="utf-8"))
|
|
502
|
-
except (OSError, json.JSONDecodeError) as error:
|
|
503
|
-
raise BatchExecutionError(
|
|
504
|
-
"BATCH-ARCHIVE-VERIFY-001",
|
|
505
|
-
f"archive_destination task.json cannot be loaded: {error}",
|
|
506
|
-
) from error
|
|
507
|
-
if task_data.get("name") != task_name:
|
|
508
|
-
raise BatchExecutionError(
|
|
509
|
-
"BATCH-ARCHIVE-VERIFY-001",
|
|
510
|
-
"archive_destination task.json name is "
|
|
511
|
-
f"{task_data.get('name')}; expected {task_name}",
|
|
512
|
-
)
|
|
513
|
-
if task_data.get("status") != "completed":
|
|
514
|
-
raise BatchExecutionError(
|
|
515
|
-
"BATCH-ARCHIVE-VERIFY-001",
|
|
516
|
-
"archive_destination task status is "
|
|
517
|
-
f"{task_data.get('status')}; expected completed",
|
|
518
|
-
)
|
|
519
|
-
|
|
520
|
-
def _verify_runtime_initialized(
|
|
521
|
-
self,
|
|
522
|
-
state: dict,
|
|
523
|
-
task_name: str,
|
|
524
|
-
role: str,
|
|
525
|
-
result: dict,
|
|
526
|
-
) -> None:
|
|
527
|
-
runtime_context_id = self._required_text(
|
|
528
|
-
result,
|
|
529
|
-
"runtime_context_id",
|
|
530
|
-
"runtime initialization result is missing runtime_context_id",
|
|
531
|
-
)
|
|
532
|
-
host_context_key = self._required_text(
|
|
533
|
-
result,
|
|
534
|
-
"host_context_key",
|
|
535
|
-
"runtime initialization result is missing host_context_key",
|
|
536
|
-
)
|
|
537
|
-
context = self.runtime_contexts.load(runtime_context_id)
|
|
538
|
-
self._verify_runtime_identity(
|
|
539
|
-
context,
|
|
540
|
-
task_name,
|
|
541
|
-
role,
|
|
542
|
-
runtime_context_id,
|
|
543
|
-
)
|
|
544
|
-
status = context.get("status")
|
|
545
|
-
if status == "closed":
|
|
546
|
-
raise BatchExecutionError(
|
|
547
|
-
"BATCH-RUNTIME-CLOSED-001",
|
|
548
|
-
f"runtime context is already closed: {runtime_context_id}",
|
|
549
|
-
)
|
|
550
|
-
bound_key = context.get("bound_context_key")
|
|
551
|
-
if bound_key and bound_key != host_context_key:
|
|
552
|
-
raise BatchExecutionError(
|
|
553
|
-
"BATCH-RUNTIME-BIND-001",
|
|
554
|
-
f"runtime context {runtime_context_id} is bound to "
|
|
555
|
-
f"{bound_key}, not {host_context_key}",
|
|
556
|
-
)
|
|
557
|
-
runtime_contexts = dict(state.get("runtime_contexts") or {})
|
|
558
|
-
task_contexts = dict(runtime_contexts.get(task_name) or {})
|
|
559
|
-
task_contexts[role] = {
|
|
560
|
-
"runtime_context_id": runtime_context_id,
|
|
561
|
-
"host_context_key": host_context_key,
|
|
562
|
-
}
|
|
563
|
-
runtime_contexts[task_name] = task_contexts
|
|
564
|
-
state["runtime_contexts"] = runtime_contexts
|
|
565
|
-
|
|
566
|
-
def _verify_runtime_completed(
|
|
567
|
-
self,
|
|
568
|
-
state: dict,
|
|
569
|
-
task_name: str,
|
|
570
|
-
role: str,
|
|
571
|
-
action: dict,
|
|
572
|
-
result: dict,
|
|
573
|
-
) -> None:
|
|
574
|
-
runtime = self._runtime_for(state, task_name, role)
|
|
575
|
-
runtime_context_id = self._required_text(
|
|
576
|
-
result,
|
|
577
|
-
"runtime_context_id",
|
|
578
|
-
"runtime result is missing runtime_context_id",
|
|
579
|
-
)
|
|
580
|
-
host_context_key = self._required_text(
|
|
581
|
-
result,
|
|
582
|
-
"host_context_key",
|
|
583
|
-
"runtime result is missing host_context_key",
|
|
584
|
-
)
|
|
585
|
-
if runtime_context_id != runtime["runtime_context_id"]:
|
|
586
|
-
raise BatchExecutionError(
|
|
587
|
-
"BATCH-RUNTIME-ID-001",
|
|
588
|
-
"runtime_context_id does not match initialized context: "
|
|
589
|
-
f"{runtime_context_id}; expected "
|
|
590
|
-
f"{runtime['runtime_context_id']}",
|
|
591
|
-
)
|
|
592
|
-
if host_context_key != runtime["host_context_key"]:
|
|
593
|
-
raise BatchExecutionError(
|
|
594
|
-
"BATCH-RUNTIME-BIND-001",
|
|
595
|
-
"host_context_key does not match initialized context: "
|
|
596
|
-
f"{host_context_key}; expected {runtime['host_context_key']}",
|
|
597
|
-
)
|
|
598
|
-
if action.get("runtime_context_id") != runtime_context_id:
|
|
599
|
-
raise BatchExecutionError(
|
|
600
|
-
"BATCH-RUNTIME-ID-001",
|
|
601
|
-
"runtime_context_id does not match pending action: "
|
|
602
|
-
f"{runtime_context_id}; expected "
|
|
603
|
-
f"{action.get('runtime_context_id')}",
|
|
604
|
-
)
|
|
605
|
-
context = self.runtime_contexts.load(runtime_context_id)
|
|
606
|
-
self._verify_runtime_identity(
|
|
607
|
-
context,
|
|
608
|
-
task_name,
|
|
609
|
-
role,
|
|
610
|
-
runtime_context_id,
|
|
611
|
-
)
|
|
612
|
-
status = context.get("status")
|
|
613
|
-
if status not in {"bound", "closed"}:
|
|
614
|
-
raise BatchExecutionError(
|
|
615
|
-
"BATCH-RUNTIME-BIND-001",
|
|
616
|
-
f"runtime context {runtime_context_id} is not bound",
|
|
617
|
-
)
|
|
618
|
-
if context.get("bound_context_key") != host_context_key:
|
|
619
|
-
raise BatchExecutionError(
|
|
620
|
-
"BATCH-RUNTIME-BIND-001",
|
|
621
|
-
f"runtime context {runtime_context_id} "
|
|
622
|
-
"bound_context_key does not match result: "
|
|
623
|
-
f"{context.get('bound_context_key')}; expected "
|
|
624
|
-
f"{host_context_key}",
|
|
625
|
-
)
|
|
626
|
-
if status == "bound":
|
|
627
|
-
if not self.runtime_contexts.close(runtime_context_id):
|
|
628
|
-
raise BatchExecutionError(
|
|
629
|
-
"BATCH-RUNTIME-CLOSE-001",
|
|
630
|
-
"runtime context could not be closed: "
|
|
631
|
-
f"{runtime_context_id}",
|
|
632
|
-
)
|
|
633
|
-
closed = self.runtime_contexts.load(runtime_context_id)
|
|
634
|
-
if closed.get("status") != "closed":
|
|
635
|
-
raise BatchExecutionError(
|
|
636
|
-
"BATCH-RUNTIME-CLOSE-001",
|
|
637
|
-
f"runtime context did not close: {runtime_context_id}",
|
|
638
|
-
)
|
|
639
|
-
|
|
640
|
-
@staticmethod
|
|
641
|
-
def _verify_runtime_identity(
|
|
642
|
-
context: dict,
|
|
643
|
-
task_name: str,
|
|
644
|
-
role: str,
|
|
645
|
-
runtime_context_id: str,
|
|
646
|
-
) -> None:
|
|
647
|
-
if not context:
|
|
648
|
-
raise BatchExecutionError(
|
|
649
|
-
"BATCH-RUNTIME-MISSING-001",
|
|
650
|
-
f"runtime context does not exist: {runtime_context_id}",
|
|
651
|
-
)
|
|
652
|
-
if context.get("scope") != "subagent":
|
|
653
|
-
raise BatchExecutionError(
|
|
654
|
-
"BATCH-RUNTIME-SCOPE-001",
|
|
655
|
-
f"runtime context is not subagent-scoped: "
|
|
656
|
-
f"{runtime_context_id}",
|
|
657
|
-
)
|
|
658
|
-
if context.get("role") != role:
|
|
659
|
-
raise BatchExecutionError(
|
|
660
|
-
"BATCH-RUNTIME-ROLE-001",
|
|
661
|
-
f"runtime context role is not {role}: "
|
|
662
|
-
f"{runtime_context_id}",
|
|
663
|
-
)
|
|
664
|
-
expected_task_dir = f".cowork-flow/tasks/{task_name}"
|
|
665
|
-
if context.get("task_dir") != expected_task_dir:
|
|
666
|
-
raise BatchExecutionError(
|
|
667
|
-
"BATCH-RUNTIME-TASK-001",
|
|
668
|
-
f"runtime context task_dir is not {expected_task_dir}",
|
|
669
|
-
)
|
|
670
|
-
|
|
671
|
-
def _mark_step_completed(
|
|
672
|
-
self,
|
|
673
|
-
state: dict,
|
|
674
|
-
action: dict,
|
|
675
|
-
result: dict,
|
|
676
|
-
) -> None:
|
|
677
|
-
task_name = str(action["task"])
|
|
678
|
-
step = str(action["type"])
|
|
679
|
-
task_steps = dict(state.get("task_steps") or {})
|
|
680
|
-
completed_steps = list(task_steps.get(task_name) or [])
|
|
681
|
-
if step not in completed_steps:
|
|
682
|
-
completed_steps.append(step)
|
|
683
|
-
task_steps[task_name] = completed_steps
|
|
684
|
-
state["task_steps"] = task_steps
|
|
685
|
-
|
|
686
|
-
phase = STEP_PHASES.get(step)
|
|
687
|
-
if phase:
|
|
688
|
-
task_phases = dict(state.get("task_phases") or {})
|
|
689
|
-
completed_phases = list(task_phases.get(task_name) or [])
|
|
690
|
-
if phase not in completed_phases:
|
|
691
|
-
completed_phases.append(phase)
|
|
692
|
-
task_phases[task_name] = completed_phases
|
|
693
|
-
state["task_phases"] = task_phases
|
|
694
|
-
|
|
695
|
-
if step == "commit_task":
|
|
696
|
-
commits = dict(state.get("commits") or {})
|
|
697
|
-
commits[task_name] = result["commit_id"].strip()
|
|
698
|
-
state["commits"] = commits
|
|
699
|
-
|
|
700
198
|
def _pause(
|
|
701
199
|
self,
|
|
702
200
|
path: Path,
|
|
@@ -705,39 +203,10 @@ class BatchExecutionService:
|
|
|
705
203
|
result: dict,
|
|
706
204
|
detail: str,
|
|
707
205
|
) -> dict:
|
|
708
|
-
|
|
709
|
-
retry_count = dict(state.get("retry_count") or {})
|
|
710
|
-
retry_count[task_name] = int(retry_count.get(task_name, 0)) + 1
|
|
711
|
-
state["retry_count"] = retry_count
|
|
712
|
-
action_results = dict(state.get("action_results") or {})
|
|
713
|
-
action_results[result["action_id"]] = dict(result)
|
|
714
|
-
state["action_results"] = action_results
|
|
715
|
-
state["phase"] = "paused"
|
|
716
|
-
state["current_task"] = task_name
|
|
717
|
-
state["next_action"] = None
|
|
718
|
-
state["pause_reason"] = (
|
|
719
|
-
f"{task_name}:{action['type']}: {detail}"
|
|
720
|
-
)
|
|
206
|
+
pause_state(state, action, result, detail)
|
|
721
207
|
self._save(path, state)
|
|
722
208
|
return state
|
|
723
209
|
|
|
724
|
-
def _runtime_for(
|
|
725
|
-
self,
|
|
726
|
-
state: dict,
|
|
727
|
-
task_name: str,
|
|
728
|
-
role: str,
|
|
729
|
-
) -> dict:
|
|
730
|
-
runtime_contexts = dict(state.get("runtime_contexts") or {})
|
|
731
|
-
task_contexts = dict(runtime_contexts.get(task_name) or {})
|
|
732
|
-
runtime = task_contexts.get(role)
|
|
733
|
-
if not isinstance(runtime, dict):
|
|
734
|
-
raise BatchExecutionError(
|
|
735
|
-
"BATCH-RUNTIME-MISSING-001",
|
|
736
|
-
f"runtime context was not initialized for "
|
|
737
|
-
f"{task_name}:{role}",
|
|
738
|
-
)
|
|
739
|
-
return runtime
|
|
740
|
-
|
|
741
210
|
def _assert_graph_unchanged(self, state: dict) -> None:
|
|
742
211
|
root_task = state.get("root_task")
|
|
743
212
|
if not isinstance(root_task, str) or not root_task.strip():
|
|
@@ -745,12 +214,64 @@ class BatchExecutionService:
|
|
|
745
214
|
"BATCH-STATE-ROOT-001",
|
|
746
215
|
"Batch state is missing root_task",
|
|
747
216
|
)
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
)
|
|
217
|
+
try:
|
|
218
|
+
ordered, graph_digest = self._plan(root_task)
|
|
219
|
+
except BatchExecutionError as error:
|
|
220
|
+
if getattr(error, "code", "") != "BATCH-GRAPH-MISSING-001":
|
|
221
|
+
raise
|
|
222
|
+
ordered = ()
|
|
223
|
+
graph_digest = None
|
|
224
|
+
if state.get("graph_digest") == graph_digest:
|
|
225
|
+
return
|
|
226
|
+
expected_remaining = self._remaining_ordered_tasks_after_archives(state)
|
|
227
|
+
if tuple(ordered) == tuple(expected_remaining):
|
|
228
|
+
return
|
|
229
|
+
raise BatchExecutionError(
|
|
230
|
+
"BATCH-GRAPH-CHANGED-001",
|
|
231
|
+
"task graph changed after Batch state was created",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def _remaining_ordered_tasks_after_archives(self, state: dict) -> tuple[str, ...]:
|
|
235
|
+
ordered = tuple(
|
|
236
|
+
task
|
|
237
|
+
for task in state.get("ordered_tasks", ())
|
|
238
|
+
if isinstance(task, str) and task.strip()
|
|
239
|
+
)
|
|
240
|
+
task_steps = dict(state.get("task_steps") or {})
|
|
241
|
+
completed = set(
|
|
242
|
+
task
|
|
243
|
+
for task in state.get("completed_tasks", ())
|
|
244
|
+
if isinstance(task, str)
|
|
245
|
+
)
|
|
246
|
+
archived_candidates = set(completed)
|
|
247
|
+
for task_name, steps in task_steps.items():
|
|
248
|
+
if isinstance(task_name, str) and "archive_task" in list(steps or []):
|
|
249
|
+
archived_candidates.add(task_name)
|
|
250
|
+
action = state.get("next_action")
|
|
251
|
+
if isinstance(action, dict) and action.get("type") in {"archive_task", "commit_task"}:
|
|
252
|
+
task_name = action.get("task")
|
|
253
|
+
if isinstance(task_name, str):
|
|
254
|
+
archived_candidates.add(task_name)
|
|
255
|
+
if state.get("phase") == "paused":
|
|
256
|
+
current_task = state.get("current_task")
|
|
257
|
+
if isinstance(current_task, str):
|
|
258
|
+
archived_candidates.add(current_task)
|
|
259
|
+
archived = {
|
|
260
|
+
task_name
|
|
261
|
+
for task_name in archived_candidates
|
|
262
|
+
if self._archived_task_exists(task_name)
|
|
263
|
+
}
|
|
264
|
+
return tuple(task for task in ordered if task not in archived)
|
|
265
|
+
|
|
266
|
+
def _archived_task_exists(self, task_name: str) -> bool:
|
|
267
|
+
archive_root = self.repo_root / ".cowork-flow" / "tasks" / "archive"
|
|
268
|
+
if not archive_root.is_dir():
|
|
269
|
+
return False
|
|
270
|
+
return any(
|
|
271
|
+
task_dir.name == task_name and (task_dir / "task.json").is_file()
|
|
272
|
+
for task_dir in archive_root.glob("*/*")
|
|
273
|
+
if task_dir.is_dir()
|
|
274
|
+
)
|
|
754
275
|
|
|
755
276
|
def _plan(self, root_task: str) -> tuple[tuple[str, ...], str]:
|
|
756
277
|
nodes = self.tree.active_nodes()
|
|
@@ -832,10 +353,7 @@ class BatchExecutionService:
|
|
|
832
353
|
try:
|
|
833
354
|
return self.state_store.load(path, missing_ok=True)
|
|
834
355
|
except StateStoreError as error:
|
|
835
|
-
raise BatchExecutionError(
|
|
836
|
-
"BATCH-STATE-LOAD-001",
|
|
837
|
-
error.detail,
|
|
838
|
-
) from error
|
|
356
|
+
raise BatchExecutionError("BATCH-STATE-LOAD-001", error.detail) from error
|
|
839
357
|
|
|
840
358
|
def _save(self, path: Path, state: dict) -> None:
|
|
841
359
|
try:
|
|
@@ -845,59 +363,23 @@ class BatchExecutionService:
|
|
|
845
363
|
state,
|
|
846
364
|
expected_revision=snapshot.revision,
|
|
847
365
|
operation_id=(
|
|
848
|
-
f"{state['operation_id']}:checkpoint:"
|
|
849
|
-
f"{snapshot.revision + 1}"
|
|
366
|
+
f"{state['operation_id']}:checkpoint:{snapshot.revision + 1}"
|
|
850
367
|
),
|
|
851
368
|
)
|
|
852
369
|
except StateStoreError as error:
|
|
853
|
-
raise BatchExecutionError(
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
raise BatchExecutionError(
|
|
870
|
-
"BATCH-RESULT-PAYLOAD-001",
|
|
871
|
-
"Host action result must be a JSON object",
|
|
872
|
-
)
|
|
873
|
-
result = dict(payload)
|
|
874
|
-
for key in ("action_id", "type", "outcome"):
|
|
875
|
-
value = result.get(key)
|
|
876
|
-
if not isinstance(value, str) or not value.strip():
|
|
877
|
-
raise BatchExecutionError(
|
|
878
|
-
"BATCH-RESULT-PAYLOAD-001",
|
|
879
|
-
f"Host action result is missing {key}",
|
|
880
|
-
)
|
|
881
|
-
result[key] = value.strip()
|
|
882
|
-
return result
|
|
883
|
-
|
|
884
|
-
@staticmethod
|
|
885
|
-
def _required_text(
|
|
886
|
-
payload: dict,
|
|
887
|
-
key: str,
|
|
888
|
-
detail: str,
|
|
889
|
-
) -> str:
|
|
890
|
-
value = payload.get(key)
|
|
891
|
-
if not isinstance(value, str) or not value.strip():
|
|
892
|
-
raise BatchExecutionError(
|
|
893
|
-
"BATCH-RESULT-PAYLOAD-001",
|
|
894
|
-
detail,
|
|
895
|
-
)
|
|
896
|
-
return value.strip()
|
|
897
|
-
|
|
898
|
-
@staticmethod
|
|
899
|
-
def _optional_text(payload: dict, key: str) -> str | None:
|
|
900
|
-
value = payload.get(key)
|
|
901
|
-
if isinstance(value, str) and value.strip():
|
|
902
|
-
return value.strip()
|
|
903
|
-
return None
|
|
370
|
+
raise BatchExecutionError("BATCH-STATE-SAVE-001", error.detail) from error
|
|
371
|
+
|
|
372
|
+
_phase_for_step = staticmethod(phase_for_step)
|
|
373
|
+
_normalize_result = staticmethod(normalize_result)
|
|
374
|
+
_required_text = staticmethod(required_text)
|
|
375
|
+
_optional_text = staticmethod(optional_text)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
__all__ = [
|
|
379
|
+
"BATCH_STEPS",
|
|
380
|
+
"BatchExecutionError",
|
|
381
|
+
"BatchExecutionService",
|
|
382
|
+
"LIFECYCLE_STATUSES",
|
|
383
|
+
"RUNTIME_ROLES",
|
|
384
|
+
"STEP_PHASES",
|
|
385
|
+
]
|