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.
Files changed (43) hide show
  1. package/README.md +16 -1
  2. package/package.json +6 -3
  3. package/src/cli.js +6 -0
  4. package/src/commands/source-refresh.js +40 -0
  5. package/src/lib/copy-template.js +130 -1
  6. package/src/lib/host-assets.js +216 -71
  7. package/template/.codex/hooks.json +1 -1
  8. package/template/.cowork-flow/.version +1 -1
  9. package/template/.cowork-flow/scripts/adapters/cli/task.py +0 -2
  10. package/template/.cowork-flow/scripts/adapters/cli/task_archive_commands.py +12 -0
  11. package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +18 -133
  12. package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +82 -1
  13. package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +98 -114
  14. package/template/.cowork-flow/scripts/infra/skill_manifest.py +23 -3
  15. package/template/.cowork-flow/scripts/infra/storage/operation_log.py +40 -0
  16. package/template/.cowork-flow/scripts/infra/storage/state_store.py +356 -1
  17. package/template/.cowork-flow/scripts/services/context_discovery.py +156 -0
  18. package/template/.cowork-flow/scripts/services/context_jsonl.py +119 -0
  19. package/template/.cowork-flow/scripts/services/context_paths.py +186 -0
  20. package/template/.cowork-flow/scripts/services/lifecycle_checks.py +23 -4
  21. package/template/.cowork-flow/scripts/services/lifecycle_policy.py +128 -0
  22. package/template/.cowork-flow/scripts/services/plan_binding.py +146 -0
  23. package/template/.cowork-flow/scripts/services/task_archive.py +100 -14
  24. package/template/.cowork-flow/scripts/services/task_context.py +49 -479
  25. package/template/.cowork-flow/scripts/services/task_creation.py +31 -27
  26. package/template/.cowork-flow/scripts/services/task_lifecycle.py +155 -18
  27. package/template/.cowork-flow/spec/contracts/error-output-as-data.md +8 -0
  28. package/template/.cowork-flow/spec/runtime/index.md +1 -1
  29. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +32 -6
  30. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  31. package/template/skills/batch-execution/scripts/batch_execution.py +111 -629
  32. package/template/skills/batch-execution/scripts/batch_recovery.py +94 -0
  33. package/template/skills/batch-execution/scripts/batch_state_machine.py +201 -0
  34. package/template/skills/batch-execution/scripts/batch_verification.py +326 -0
  35. package/template/skills/cowork-flow/SKILL.md +3 -0
  36. package/template/skills/party-mode/scripts/party_actions.py +95 -0
  37. package/template/skills/party-mode/scripts/party_mode_v2.py +129 -205
  38. package/template/skills/party-mode/scripts/party_reports.py +177 -0
  39. package/template/skills/party-mode/scripts/party_state_machine.py +189 -0
  40. package/template/skills/runtime-health/scripts/doctor.py +204 -15
  41. package/template/skills/task-review/scripts/review_check.py +135 -2
  42. package/template/skills/test-first/SKILL.md +18 -3
  43. package/template/skills/test-first/manifest.json +8 -4
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env python3
2
- """Recoverable task-graph Batch execution state machine."""
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
- 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
- "archive_task",
29
- "commit_task",
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
- def __init__(self, code: str, detail: str) -> None:
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
- state = dict(snapshot.data)
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 = self._normalize_result(payload)
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 = self._optional_text(result, "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._verify_success(state, action, result)
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
- self._mark_step_completed(state, action, result)
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.get("phase") in {"completed", "paused"}:
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
- task_name = str(action["task"])
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
- _, graph_digest = self._plan(root_task)
749
- if state.get("graph_digest") != graph_digest:
750
- raise BatchExecutionError(
751
- "BATCH-GRAPH-CHANGED-001",
752
- "task graph changed after Batch state was created",
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
- "BATCH-STATE-SAVE-001",
855
- error.detail,
856
- ) from error
857
-
858
- @staticmethod
859
- def _phase_for_step(step: str) -> str:
860
- if "implement" in step:
861
- return "implement"
862
- if "check" in step:
863
- return "check"
864
- return STEP_PHASES[step]
865
-
866
- @staticmethod
867
- def _normalize_result(payload: dict) -> dict:
868
- if not isinstance(payload, dict):
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
+ ]