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
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Read-only Batch inspect and recovery projections."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from batch_state_machine import BATCH_STEPS, phase_for_step
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def failed_action(state: dict[str, Any]) -> dict[str, Any] | None:
|
|
13
|
+
"""Return public facts for the action that paused Batch, if any."""
|
|
14
|
+
|
|
15
|
+
if state.get("phase") != "paused":
|
|
16
|
+
return None
|
|
17
|
+
action_results = dict(state.get("action_results") or {})
|
|
18
|
+
if not action_results:
|
|
19
|
+
return None
|
|
20
|
+
action_id, result = next(reversed(action_results.items()))
|
|
21
|
+
if not isinstance(result, dict):
|
|
22
|
+
return {"actionId": str(action_id)}
|
|
23
|
+
failed: dict[str, Any] = {"actionId": str(action_id)}
|
|
24
|
+
result_type = result.get("type")
|
|
25
|
+
if isinstance(result_type, str):
|
|
26
|
+
failed["type"] = result_type
|
|
27
|
+
outcome = result.get("outcome")
|
|
28
|
+
if isinstance(outcome, str):
|
|
29
|
+
failed["outcome"] = outcome
|
|
30
|
+
detail = result.get("detail")
|
|
31
|
+
if isinstance(detail, str) and detail.strip():
|
|
32
|
+
failed["detail"] = detail.strip()
|
|
33
|
+
return failed
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def current_phase(
|
|
37
|
+
state: dict[str, Any],
|
|
38
|
+
failed_action_facts: dict[str, Any] | None,
|
|
39
|
+
) -> str | None:
|
|
40
|
+
"""Project the current user-visible Batch phase without mutating state."""
|
|
41
|
+
|
|
42
|
+
next_action = state.get("next_action")
|
|
43
|
+
if isinstance(next_action, dict):
|
|
44
|
+
phase = next_action.get("phase")
|
|
45
|
+
return str(phase) if isinstance(phase, str) else None
|
|
46
|
+
if failed_action_facts is not None:
|
|
47
|
+
action_type = failed_action_facts.get("type")
|
|
48
|
+
if isinstance(action_type, str) and action_type in BATCH_STEPS:
|
|
49
|
+
return phase_for_step(action_type)
|
|
50
|
+
task_name = state.get("current_task")
|
|
51
|
+
if isinstance(task_name, str):
|
|
52
|
+
task_phases = dict(state.get("task_phases") or {})
|
|
53
|
+
phases = task_phases.get(task_name)
|
|
54
|
+
if isinstance(phases, list) and phases:
|
|
55
|
+
phase = phases[-1]
|
|
56
|
+
return str(phase) if isinstance(phase, str) else None
|
|
57
|
+
phase = state.get("phase")
|
|
58
|
+
return str(phase) if isinstance(phase, str) else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def recovery_facts(state: dict[str, Any]) -> dict[str, object]:
|
|
62
|
+
"""Return read-only recovery hints for a paused Batch operation."""
|
|
63
|
+
|
|
64
|
+
if state.get("phase") != "paused":
|
|
65
|
+
return {}
|
|
66
|
+
operation_id = state.get("operation_id")
|
|
67
|
+
recovery: dict[str, object] = {}
|
|
68
|
+
if isinstance(operation_id, str) and operation_id.strip():
|
|
69
|
+
recovery["resumeCommand"] = f"batch-action resume {operation_id.strip()}"
|
|
70
|
+
task_name = state.get("current_task")
|
|
71
|
+
if isinstance(task_name, str) and task_name.strip():
|
|
72
|
+
recovery["retryTask"] = task_name.strip()
|
|
73
|
+
pause_reason = state.get("pause_reason")
|
|
74
|
+
if isinstance(pause_reason, str) and pause_reason.strip():
|
|
75
|
+
recovery["pausedReason"] = pause_reason.strip()
|
|
76
|
+
return recovery
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def inspect_projection(operation_id: str, state: dict[str, Any]) -> dict[str, Any]:
|
|
80
|
+
"""Build the public inspect payload without state mutation or IO."""
|
|
81
|
+
|
|
82
|
+
failed = failed_action(state)
|
|
83
|
+
return {
|
|
84
|
+
"operationId": operation_id,
|
|
85
|
+
"state": state.get("phase"),
|
|
86
|
+
"rootTask": state.get("root_task"),
|
|
87
|
+
"currentPhase": current_phase(state, failed),
|
|
88
|
+
"currentTask": state.get("current_task"),
|
|
89
|
+
"completedTasks": list(state.get("completed_tasks") or []),
|
|
90
|
+
"pausedReason": state.get("pause_reason"),
|
|
91
|
+
"failedAction": failed,
|
|
92
|
+
"nextAction": state.get("next_action"),
|
|
93
|
+
"recovery": recovery_facts(state),
|
|
94
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Pure in-memory Batch state transitions and action construction."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
BATCH_STEPS = (
|
|
10
|
+
"start_task",
|
|
11
|
+
"init_implement_context",
|
|
12
|
+
"await_implement_result",
|
|
13
|
+
"review_task",
|
|
14
|
+
"init_check_context",
|
|
15
|
+
"await_check_result",
|
|
16
|
+
"complete_task",
|
|
17
|
+
"archive_task",
|
|
18
|
+
"commit_task",
|
|
19
|
+
)
|
|
20
|
+
STEP_PHASES = {
|
|
21
|
+
"start_task": "start",
|
|
22
|
+
"await_implement_result": "implement",
|
|
23
|
+
"review_task": "review",
|
|
24
|
+
"await_check_result": "check",
|
|
25
|
+
"complete_task": "complete",
|
|
26
|
+
"archive_task": "archive",
|
|
27
|
+
"commit_task": "commit",
|
|
28
|
+
}
|
|
29
|
+
LIFECYCLE_STATUSES = {
|
|
30
|
+
"start_task": "in_progress",
|
|
31
|
+
"review_task": "review",
|
|
32
|
+
"complete_task": "completed",
|
|
33
|
+
"archive_task": "completed",
|
|
34
|
+
}
|
|
35
|
+
RUNTIME_ROLES = {
|
|
36
|
+
"init_implement_context": "implement",
|
|
37
|
+
"await_implement_result": "implement",
|
|
38
|
+
"init_check_context": "check",
|
|
39
|
+
"await_check_result": "check",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BatchExecutionError(RuntimeError):
|
|
44
|
+
"""Raised when Batch graph, state, or host input is invalid."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, code: str, detail: str) -> None:
|
|
47
|
+
self.code = code
|
|
48
|
+
self.detail = detail
|
|
49
|
+
super().__init__(f"{code}: {detail}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def phase_for_step(step: str) -> str:
|
|
53
|
+
"""Return the public Batch phase for a step name."""
|
|
54
|
+
|
|
55
|
+
if "implement" in step:
|
|
56
|
+
return "implement"
|
|
57
|
+
if "check" in step:
|
|
58
|
+
return "check"
|
|
59
|
+
return STEP_PHASES[step]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def runtime_for(state: dict[str, Any], task_name: str, role: str) -> dict[str, Any]:
|
|
63
|
+
"""Return a previously initialized runtime context reference."""
|
|
64
|
+
|
|
65
|
+
runtime_contexts = dict(state.get("runtime_contexts") or {})
|
|
66
|
+
task_contexts = dict(runtime_contexts.get(task_name) or {})
|
|
67
|
+
runtime = task_contexts.get(role)
|
|
68
|
+
if not isinstance(runtime, dict):
|
|
69
|
+
raise BatchExecutionError(
|
|
70
|
+
"BATCH-RUNTIME-MISSING-001",
|
|
71
|
+
f"runtime context was not initialized for {task_name}:{role}",
|
|
72
|
+
)
|
|
73
|
+
return runtime
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_action(state: dict[str, Any], task_name: str, step: str) -> dict[str, Any]:
|
|
77
|
+
"""Build the next host-neutral Batch action without external side effects."""
|
|
78
|
+
|
|
79
|
+
sequence = int(state.get("next_action_sequence") or 0) + 1
|
|
80
|
+
state["next_action_sequence"] = sequence
|
|
81
|
+
action: dict[str, Any] = {
|
|
82
|
+
"action_id": f"{state['operation_id']}:{sequence}:{task_name}:{step}",
|
|
83
|
+
"type": step,
|
|
84
|
+
"phase": phase_for_step(step),
|
|
85
|
+
"task": task_name,
|
|
86
|
+
"task_dir": f".cowork-flow/tasks/{task_name}",
|
|
87
|
+
}
|
|
88
|
+
role = RUNTIME_ROLES.get(step)
|
|
89
|
+
if step.startswith("init_") and role:
|
|
90
|
+
action.update(
|
|
91
|
+
{
|
|
92
|
+
"role": role,
|
|
93
|
+
"agent_type": f"cowork-{role}",
|
|
94
|
+
"title": f"{role.capitalize()} Batch task {task_name}",
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
if step.startswith("await_") and role:
|
|
98
|
+
runtime = runtime_for(state, task_name, role)
|
|
99
|
+
action.update(
|
|
100
|
+
{
|
|
101
|
+
"role": role,
|
|
102
|
+
"runtime_context_id": runtime["runtime_context_id"],
|
|
103
|
+
"host_context_key": runtime["host_context_key"],
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
return action
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def advance_state(state: dict[str, Any]) -> bool:
|
|
110
|
+
"""Advance Batch state in memory until it needs Host input or is complete."""
|
|
111
|
+
|
|
112
|
+
if state.get("phase") in {"completed", "paused"}:
|
|
113
|
+
return False
|
|
114
|
+
if isinstance(state.get("next_action"), dict):
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
changed = False
|
|
118
|
+
completed_tasks = list(state.get("completed_tasks") or [])
|
|
119
|
+
task_steps = dict(state.get("task_steps") or {})
|
|
120
|
+
retry_count = dict(state.get("retry_count") or {})
|
|
121
|
+
|
|
122
|
+
for task_name in state["ordered_tasks"]:
|
|
123
|
+
if task_name in completed_tasks:
|
|
124
|
+
continue
|
|
125
|
+
if state.get("current_task") != task_name:
|
|
126
|
+
state["current_task"] = task_name
|
|
127
|
+
changed = True
|
|
128
|
+
completed_steps = list(task_steps.get(task_name) or [])
|
|
129
|
+
for step in BATCH_STEPS:
|
|
130
|
+
if step in completed_steps:
|
|
131
|
+
continue
|
|
132
|
+
action = build_action(state, task_name, step)
|
|
133
|
+
state["next_action"] = action
|
|
134
|
+
state["phase"] = "awaiting_host"
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
completed_tasks.append(task_name)
|
|
138
|
+
state["completed_tasks"] = completed_tasks
|
|
139
|
+
retry_count[task_name] = 0
|
|
140
|
+
state["retry_count"] = retry_count
|
|
141
|
+
state["current_task"] = None
|
|
142
|
+
changed = True
|
|
143
|
+
|
|
144
|
+
state["phase"] = "completed"
|
|
145
|
+
state["current_task"] = None
|
|
146
|
+
state["next_action"] = None
|
|
147
|
+
state["pause_reason"] = None
|
|
148
|
+
return True or changed
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def mark_step_completed(
|
|
152
|
+
state: dict[str, Any],
|
|
153
|
+
action: dict[str, Any],
|
|
154
|
+
result: dict[str, Any],
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Record a verified Host action result in the in-memory Batch state."""
|
|
157
|
+
|
|
158
|
+
task_name = str(action["task"])
|
|
159
|
+
step = str(action["type"])
|
|
160
|
+
task_steps = dict(state.get("task_steps") or {})
|
|
161
|
+
completed_steps = list(task_steps.get(task_name) or [])
|
|
162
|
+
if step not in completed_steps:
|
|
163
|
+
completed_steps.append(step)
|
|
164
|
+
task_steps[task_name] = completed_steps
|
|
165
|
+
state["task_steps"] = task_steps
|
|
166
|
+
|
|
167
|
+
phase = STEP_PHASES.get(step)
|
|
168
|
+
if phase:
|
|
169
|
+
task_phases = dict(state.get("task_phases") or {})
|
|
170
|
+
completed_phases = list(task_phases.get(task_name) or [])
|
|
171
|
+
if phase not in completed_phases:
|
|
172
|
+
completed_phases.append(phase)
|
|
173
|
+
task_phases[task_name] = completed_phases
|
|
174
|
+
state["task_phases"] = task_phases
|
|
175
|
+
|
|
176
|
+
if step == "commit_task":
|
|
177
|
+
commits = dict(state.get("commits") or {})
|
|
178
|
+
commits[task_name] = result["commit_id"].strip()
|
|
179
|
+
state["commits"] = commits
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def pause_state(
|
|
183
|
+
state: dict[str, Any],
|
|
184
|
+
action: dict[str, Any],
|
|
185
|
+
result: dict[str, Any],
|
|
186
|
+
detail: str,
|
|
187
|
+
) -> dict[str, Any]:
|
|
188
|
+
"""Move Batch state to paused after a fail-closed Host result."""
|
|
189
|
+
|
|
190
|
+
task_name = str(action["task"])
|
|
191
|
+
retry_count = dict(state.get("retry_count") or {})
|
|
192
|
+
retry_count[task_name] = int(retry_count.get(task_name, 0)) + 1
|
|
193
|
+
state["retry_count"] = retry_count
|
|
194
|
+
action_results = dict(state.get("action_results") or {})
|
|
195
|
+
action_results[result["action_id"]] = dict(result)
|
|
196
|
+
state["action_results"] = action_results
|
|
197
|
+
state["phase"] = "paused"
|
|
198
|
+
state["current_task"] = task_name
|
|
199
|
+
state["next_action"] = None
|
|
200
|
+
state["pause_reason"] = f"{task_name}:{action['type']}: {detail}"
|
|
201
|
+
return state
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Batch Host-result verification separated from state advancement."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
from batch_state_machine import (
|
|
12
|
+
LIFECYCLE_STATUSES,
|
|
13
|
+
RUNTIME_ROLES,
|
|
14
|
+
BatchExecutionError,
|
|
15
|
+
runtime_for,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
CommitVerifier = Callable[[str], bool]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize_result(payload: dict[str, Any]) -> dict[str, Any]:
|
|
22
|
+
"""Normalize a Host action result and fail closed on malformed input."""
|
|
23
|
+
|
|
24
|
+
if not isinstance(payload, dict):
|
|
25
|
+
raise BatchExecutionError(
|
|
26
|
+
"BATCH-RESULT-PAYLOAD-001",
|
|
27
|
+
"Host action result must be a JSON object",
|
|
28
|
+
)
|
|
29
|
+
result = dict(payload)
|
|
30
|
+
for key in ("action_id", "type", "outcome"):
|
|
31
|
+
value = result.get(key)
|
|
32
|
+
if not isinstance(value, str) or not value.strip():
|
|
33
|
+
raise BatchExecutionError(
|
|
34
|
+
"BATCH-RESULT-PAYLOAD-001",
|
|
35
|
+
f"Host action result is missing {key}",
|
|
36
|
+
)
|
|
37
|
+
result[key] = value.strip()
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def required_text(payload: dict[str, Any], key: str, detail: str) -> str:
|
|
42
|
+
value = payload.get(key)
|
|
43
|
+
if not isinstance(value, str) or not value.strip():
|
|
44
|
+
raise BatchExecutionError("BATCH-RESULT-PAYLOAD-001", detail)
|
|
45
|
+
return value.strip()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def optional_text(payload: dict[str, Any], key: str) -> str | None:
|
|
49
|
+
value = payload.get(key)
|
|
50
|
+
if isinstance(value, str) and value.strip():
|
|
51
|
+
return value.strip()
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class BatchActionVerifier:
|
|
56
|
+
"""Verify Host facts for one Batch action without advancing the state machine."""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
repo_root: Path,
|
|
61
|
+
runtime_contexts: Any,
|
|
62
|
+
commit_verifier: CommitVerifier,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.repo_root = Path(repo_root)
|
|
65
|
+
self.runtime_contexts = runtime_contexts
|
|
66
|
+
self.commit_verifier = commit_verifier
|
|
67
|
+
|
|
68
|
+
def verify_success(
|
|
69
|
+
self,
|
|
70
|
+
state: dict[str, Any],
|
|
71
|
+
action: dict[str, Any],
|
|
72
|
+
result: dict[str, Any],
|
|
73
|
+
) -> None:
|
|
74
|
+
action_type = str(action["type"])
|
|
75
|
+
task_name = str(action["task"])
|
|
76
|
+
if action_type == "archive_task":
|
|
77
|
+
self.verify_archive_result(task_name, result)
|
|
78
|
+
return
|
|
79
|
+
if action_type in LIFECYCLE_STATUSES:
|
|
80
|
+
self.verify_task_status(task_name, LIFECYCLE_STATUSES[action_type], result)
|
|
81
|
+
return
|
|
82
|
+
if action_type.startswith("init_"):
|
|
83
|
+
self.verify_runtime_initialized(
|
|
84
|
+
state,
|
|
85
|
+
task_name,
|
|
86
|
+
RUNTIME_ROLES[action_type],
|
|
87
|
+
result,
|
|
88
|
+
)
|
|
89
|
+
return
|
|
90
|
+
if action_type.startswith("await_"):
|
|
91
|
+
self.verify_runtime_completed(
|
|
92
|
+
state,
|
|
93
|
+
task_name,
|
|
94
|
+
RUNTIME_ROLES[action_type],
|
|
95
|
+
action,
|
|
96
|
+
result,
|
|
97
|
+
)
|
|
98
|
+
return
|
|
99
|
+
if action_type == "commit_task":
|
|
100
|
+
commit_id = required_text(result, "commit_id", "commit result is missing commit_id")
|
|
101
|
+
if not self.commit_verifier(commit_id):
|
|
102
|
+
raise BatchExecutionError(
|
|
103
|
+
"BATCH-COMMIT-VERIFY-001",
|
|
104
|
+
f"commit_id could not be verified: {commit_id}",
|
|
105
|
+
)
|
|
106
|
+
return
|
|
107
|
+
raise BatchExecutionError(
|
|
108
|
+
"BATCH-ACTION-UNKNOWN-001",
|
|
109
|
+
f"unsupported Batch action: {action_type}",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def verify_task_status(
|
|
113
|
+
self,
|
|
114
|
+
task_name: str,
|
|
115
|
+
expected_status: str,
|
|
116
|
+
result: dict[str, Any],
|
|
117
|
+
) -> None:
|
|
118
|
+
reported = optional_text(result, "task_status")
|
|
119
|
+
if reported and reported != expected_status:
|
|
120
|
+
raise BatchExecutionError(
|
|
121
|
+
"BATCH-TASK-STATUS-001",
|
|
122
|
+
f"Host reported task status {reported}; expected {expected_status}",
|
|
123
|
+
)
|
|
124
|
+
task_path = self.repo_root / ".cowork-flow" / "tasks" / task_name / "task.json"
|
|
125
|
+
try:
|
|
126
|
+
task_data = json.loads(task_path.read_text(encoding="utf-8"))
|
|
127
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
128
|
+
raise BatchExecutionError(
|
|
129
|
+
"BATCH-TASK-LOAD-001",
|
|
130
|
+
f"cannot load task status for {task_name}: {error}",
|
|
131
|
+
) from error
|
|
132
|
+
actual = task_data.get("status")
|
|
133
|
+
if actual != expected_status:
|
|
134
|
+
raise BatchExecutionError(
|
|
135
|
+
"BATCH-TASK-STATUS-001",
|
|
136
|
+
f"task status for {task_name} is {actual}; expected {expected_status}",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def verify_archive_result(self, task_name: str, result: dict[str, Any]) -> None:
|
|
140
|
+
destination = required_text(
|
|
141
|
+
result,
|
|
142
|
+
"archive_destination",
|
|
143
|
+
"archive result is missing archive_destination",
|
|
144
|
+
)
|
|
145
|
+
destination_path = Path(destination)
|
|
146
|
+
if destination_path.is_absolute():
|
|
147
|
+
raise BatchExecutionError(
|
|
148
|
+
"BATCH-ARCHIVE-VERIFY-001",
|
|
149
|
+
f"archive_destination must be relative: {destination}",
|
|
150
|
+
)
|
|
151
|
+
archive_root = (self.repo_root / ".cowork-flow" / "tasks" / "archive").resolve()
|
|
152
|
+
archive_path = (self.repo_root / destination_path).resolve()
|
|
153
|
+
try:
|
|
154
|
+
archive_path.relative_to(archive_root)
|
|
155
|
+
except ValueError as error:
|
|
156
|
+
raise BatchExecutionError(
|
|
157
|
+
"BATCH-ARCHIVE-VERIFY-001",
|
|
158
|
+
"archive_destination must be under "
|
|
159
|
+
f".cowork-flow/tasks/archive: {destination}",
|
|
160
|
+
) from error
|
|
161
|
+
if archive_path.name != task_name:
|
|
162
|
+
raise BatchExecutionError(
|
|
163
|
+
"BATCH-ARCHIVE-VERIFY-001",
|
|
164
|
+
f"archive_destination task name is {archive_path.name}; expected {task_name}",
|
|
165
|
+
)
|
|
166
|
+
if not archive_path.is_dir():
|
|
167
|
+
raise BatchExecutionError(
|
|
168
|
+
"BATCH-ARCHIVE-VERIFY-001",
|
|
169
|
+
f"archive_destination does not exist: {destination}",
|
|
170
|
+
)
|
|
171
|
+
task_json = archive_path / "task.json"
|
|
172
|
+
try:
|
|
173
|
+
task_data = json.loads(task_json.read_text(encoding="utf-8"))
|
|
174
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
175
|
+
raise BatchExecutionError(
|
|
176
|
+
"BATCH-ARCHIVE-VERIFY-001",
|
|
177
|
+
f"archive_destination task.json cannot be loaded: {error}",
|
|
178
|
+
) from error
|
|
179
|
+
logical_task_names = {task_name}
|
|
180
|
+
if len(task_name) > 6 and task_name[2] == "-" and task_name[5] == "-":
|
|
181
|
+
logical_task_names.add(task_name[6:])
|
|
182
|
+
if not any(task_data.get(field) in logical_task_names for field in ("name", "id")):
|
|
183
|
+
raise BatchExecutionError(
|
|
184
|
+
"BATCH-ARCHIVE-VERIFY-001",
|
|
185
|
+
"archive_destination task identity is "
|
|
186
|
+
f"name={task_data.get('name')} id={task_data.get('id')}; "
|
|
187
|
+
f"expected one of {sorted(logical_task_names)}",
|
|
188
|
+
)
|
|
189
|
+
if task_data.get("status") != "completed":
|
|
190
|
+
raise BatchExecutionError(
|
|
191
|
+
"BATCH-ARCHIVE-VERIFY-001",
|
|
192
|
+
f"archive_destination task status is {task_data.get('status')}; expected completed",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def verify_runtime_initialized(
|
|
196
|
+
self,
|
|
197
|
+
state: dict[str, Any],
|
|
198
|
+
task_name: str,
|
|
199
|
+
role: str,
|
|
200
|
+
result: dict[str, Any],
|
|
201
|
+
) -> None:
|
|
202
|
+
runtime_context_id = required_text(
|
|
203
|
+
result,
|
|
204
|
+
"runtime_context_id",
|
|
205
|
+
"runtime initialization result is missing runtime_context_id",
|
|
206
|
+
)
|
|
207
|
+
host_context_key = required_text(
|
|
208
|
+
result,
|
|
209
|
+
"host_context_key",
|
|
210
|
+
"runtime initialization result is missing host_context_key",
|
|
211
|
+
)
|
|
212
|
+
context = self.runtime_contexts.load(runtime_context_id)
|
|
213
|
+
self.verify_runtime_identity(context, task_name, role, runtime_context_id)
|
|
214
|
+
status = context.get("status")
|
|
215
|
+
if status == "closed":
|
|
216
|
+
raise BatchExecutionError(
|
|
217
|
+
"BATCH-RUNTIME-CLOSED-001",
|
|
218
|
+
f"runtime context is already closed: {runtime_context_id}",
|
|
219
|
+
)
|
|
220
|
+
bound_key = context.get("bound_context_key")
|
|
221
|
+
if bound_key and bound_key != host_context_key:
|
|
222
|
+
raise BatchExecutionError(
|
|
223
|
+
"BATCH-RUNTIME-BIND-001",
|
|
224
|
+
f"runtime context {runtime_context_id} is bound to {bound_key}, not {host_context_key}",
|
|
225
|
+
)
|
|
226
|
+
runtime_contexts = dict(state.get("runtime_contexts") or {})
|
|
227
|
+
task_contexts = dict(runtime_contexts.get(task_name) or {})
|
|
228
|
+
task_contexts[role] = {
|
|
229
|
+
"runtime_context_id": runtime_context_id,
|
|
230
|
+
"host_context_key": host_context_key,
|
|
231
|
+
}
|
|
232
|
+
runtime_contexts[task_name] = task_contexts
|
|
233
|
+
state["runtime_contexts"] = runtime_contexts
|
|
234
|
+
|
|
235
|
+
def verify_runtime_completed(
|
|
236
|
+
self,
|
|
237
|
+
state: dict[str, Any],
|
|
238
|
+
task_name: str,
|
|
239
|
+
role: str,
|
|
240
|
+
action: dict[str, Any],
|
|
241
|
+
result: dict[str, Any],
|
|
242
|
+
) -> None:
|
|
243
|
+
runtime = runtime_for(state, task_name, role)
|
|
244
|
+
runtime_context_id = required_text(
|
|
245
|
+
result,
|
|
246
|
+
"runtime_context_id",
|
|
247
|
+
"runtime result is missing runtime_context_id",
|
|
248
|
+
)
|
|
249
|
+
host_context_key = required_text(
|
|
250
|
+
result,
|
|
251
|
+
"host_context_key",
|
|
252
|
+
"runtime result is missing host_context_key",
|
|
253
|
+
)
|
|
254
|
+
if runtime_context_id != runtime["runtime_context_id"]:
|
|
255
|
+
raise BatchExecutionError(
|
|
256
|
+
"BATCH-RUNTIME-ID-001",
|
|
257
|
+
"runtime_context_id does not match initialized context: "
|
|
258
|
+
f"{runtime_context_id}; expected {runtime['runtime_context_id']}",
|
|
259
|
+
)
|
|
260
|
+
if host_context_key != runtime["host_context_key"]:
|
|
261
|
+
raise BatchExecutionError(
|
|
262
|
+
"BATCH-RUNTIME-BIND-001",
|
|
263
|
+
"host_context_key does not match initialized context: "
|
|
264
|
+
f"{host_context_key}; expected {runtime['host_context_key']}",
|
|
265
|
+
)
|
|
266
|
+
if action.get("runtime_context_id") != runtime_context_id:
|
|
267
|
+
raise BatchExecutionError(
|
|
268
|
+
"BATCH-RUNTIME-ID-001",
|
|
269
|
+
"runtime_context_id does not match pending action: "
|
|
270
|
+
f"{runtime_context_id}; expected {action.get('runtime_context_id')}",
|
|
271
|
+
)
|
|
272
|
+
context = self.runtime_contexts.load(runtime_context_id)
|
|
273
|
+
self.verify_runtime_identity(context, task_name, role, runtime_context_id)
|
|
274
|
+
status = context.get("status")
|
|
275
|
+
if status not in {"bound", "closed"}:
|
|
276
|
+
raise BatchExecutionError(
|
|
277
|
+
"BATCH-RUNTIME-BIND-001",
|
|
278
|
+
f"runtime context {runtime_context_id} is not bound",
|
|
279
|
+
)
|
|
280
|
+
if context.get("bound_context_key") != host_context_key:
|
|
281
|
+
raise BatchExecutionError(
|
|
282
|
+
"BATCH-RUNTIME-BIND-001",
|
|
283
|
+
f"runtime context {runtime_context_id} bound_context_key does not match result: "
|
|
284
|
+
f"{context.get('bound_context_key')}; expected {host_context_key}",
|
|
285
|
+
)
|
|
286
|
+
if status == "bound":
|
|
287
|
+
if not self.runtime_contexts.close(runtime_context_id):
|
|
288
|
+
raise BatchExecutionError(
|
|
289
|
+
"BATCH-RUNTIME-CLOSE-001",
|
|
290
|
+
f"runtime context could not be closed: {runtime_context_id}",
|
|
291
|
+
)
|
|
292
|
+
closed = self.runtime_contexts.load(runtime_context_id)
|
|
293
|
+
if closed.get("status") != "closed":
|
|
294
|
+
raise BatchExecutionError(
|
|
295
|
+
"BATCH-RUNTIME-CLOSE-001",
|
|
296
|
+
f"runtime context did not close: {runtime_context_id}",
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
@staticmethod
|
|
300
|
+
def verify_runtime_identity(
|
|
301
|
+
context: dict[str, Any],
|
|
302
|
+
task_name: str,
|
|
303
|
+
role: str,
|
|
304
|
+
runtime_context_id: str,
|
|
305
|
+
) -> None:
|
|
306
|
+
if not context:
|
|
307
|
+
raise BatchExecutionError(
|
|
308
|
+
"BATCH-RUNTIME-MISSING-001",
|
|
309
|
+
f"runtime context does not exist: {runtime_context_id}",
|
|
310
|
+
)
|
|
311
|
+
if context.get("scope") != "subagent":
|
|
312
|
+
raise BatchExecutionError(
|
|
313
|
+
"BATCH-RUNTIME-SCOPE-001",
|
|
314
|
+
f"runtime context is not subagent-scoped: {runtime_context_id}",
|
|
315
|
+
)
|
|
316
|
+
if context.get("role") != role:
|
|
317
|
+
raise BatchExecutionError(
|
|
318
|
+
"BATCH-RUNTIME-ROLE-001",
|
|
319
|
+
f"runtime context role is not {role}: {runtime_context_id}",
|
|
320
|
+
)
|
|
321
|
+
expected_task_dir = f".cowork-flow/tasks/{task_name}"
|
|
322
|
+
if context.get("task_dir") != expected_task_dir:
|
|
323
|
+
raise BatchExecutionError(
|
|
324
|
+
"BATCH-RUNTIME-TASK-001",
|
|
325
|
+
f"runtime context task_dir is not {expected_task_dir}",
|
|
326
|
+
)
|
|
@@ -41,5 +41,8 @@ Use the `task next` route payload and its `activatedSkill`/`recommendedSkill` fi
|
|
|
41
41
|
Select at most one public Skill. If the payload has no Skill, follow the allowed
|
|
42
42
|
operation directly or report the blocker; never guess a second workflow entry.
|
|
43
43
|
|
|
44
|
+
Before editing for implement, read `implement.jsonl` and every listed `file` entry.
|
|
45
|
+
If `test-first` is listed, read it and write the red test before production code.
|
|
46
|
+
|
|
44
47
|
Public Skills carry workflow guidance. Runtime Gates carry hard enforcement.
|
|
45
48
|
Do not recreate an internal protocol layer or another process authority.
|