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,177 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Pure Party Mode V2 final report projection helpers."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def discussion_posts(board: dict[str, Any]) -> list[dict[str, Any]]:
|
|
13
|
+
return [post for item in board.get("rounds", []) for post in item.get("posts", [])]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def discussion_responses(board: dict[str, Any]) -> list[dict[str, Any]]:
|
|
17
|
+
return [
|
|
18
|
+
response
|
|
19
|
+
for item in board.get("rounds", [])
|
|
20
|
+
for response in item.get("responses", [])
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def round_responses(board: dict[str, Any], round_number: int) -> list[dict[str, Any]]:
|
|
25
|
+
return [
|
|
26
|
+
response
|
|
27
|
+
for item in board.get("rounds", [])
|
|
28
|
+
if int(item.get("round", -1)) == round_number
|
|
29
|
+
for response in item.get("responses", [])
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def historical_disagreements(board: dict[str, Any], current_round: int) -> list[dict[str, Any]]:
|
|
34
|
+
return [
|
|
35
|
+
response
|
|
36
|
+
for item in board.get("rounds", [])
|
|
37
|
+
if int(item.get("round", -1)) < current_round
|
|
38
|
+
for response in item.get("responses", [])
|
|
39
|
+
if response.get("still_disagree")
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def round_summary_phase(board: dict[str, Any], round_number: int) -> str:
|
|
44
|
+
if round_number == int(board["round"]["current"]):
|
|
45
|
+
return str(board["round"]["phase"])
|
|
46
|
+
return "completed"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def rounds_summary(board: dict[str, Any]) -> list[dict[str, Any]]:
|
|
50
|
+
summaries: list[dict[str, Any]] = []
|
|
51
|
+
for item in board.get("rounds", []):
|
|
52
|
+
round_number = int(item.get("round", 0))
|
|
53
|
+
posts = item.get("posts", [])
|
|
54
|
+
responses = item.get("responses", [])
|
|
55
|
+
unresolved = [response for response in responses if response.get("still_disagree")]
|
|
56
|
+
summaries.append(
|
|
57
|
+
{
|
|
58
|
+
"round": round_number,
|
|
59
|
+
"phase": round_summary_phase(board, round_number),
|
|
60
|
+
"post_count": len(posts),
|
|
61
|
+
"response_count": len(responses),
|
|
62
|
+
"unresolved_count": len(unresolved),
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
return summaries
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def action_results_summary(base_dir: Path) -> list[dict[str, Any]]:
|
|
69
|
+
history_path = base_dir / "action_history.jsonl"
|
|
70
|
+
if not history_path.is_file():
|
|
71
|
+
return []
|
|
72
|
+
results: list[dict[str, Any]] = []
|
|
73
|
+
with history_path.open("r", encoding="utf-8") as handle:
|
|
74
|
+
for line in handle:
|
|
75
|
+
if not line.strip():
|
|
76
|
+
continue
|
|
77
|
+
entry = json.loads(line)
|
|
78
|
+
if entry.get("event") != "action-result":
|
|
79
|
+
continue
|
|
80
|
+
payload = entry.get("payload", {})
|
|
81
|
+
summary: dict[str, Any] = {
|
|
82
|
+
"action_id": payload.get("action_id"),
|
|
83
|
+
"type": payload.get("type"),
|
|
84
|
+
"outcome": payload.get("outcome"),
|
|
85
|
+
}
|
|
86
|
+
if payload.get("agent_id"):
|
|
87
|
+
summary["agent_id"] = payload["agent_id"]
|
|
88
|
+
results.append(summary)
|
|
89
|
+
return results
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def accepted_evidence_summary(responses: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
93
|
+
summaries: list[dict[str, Any]] = []
|
|
94
|
+
for response in responses:
|
|
95
|
+
if response.get("decision") != "concede":
|
|
96
|
+
continue
|
|
97
|
+
evidence = response.get("accepted_evidence", [])
|
|
98
|
+
if not evidence:
|
|
99
|
+
continue
|
|
100
|
+
summaries.append(
|
|
101
|
+
{
|
|
102
|
+
"agent_id": response.get("agent_id"),
|
|
103
|
+
"target_post_id": response.get("target_post_id"),
|
|
104
|
+
"accepted_evidence": evidence,
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
return summaries
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def report_pro_posts(posts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
111
|
+
return [
|
|
112
|
+
{"agent_id": post["agent_id"], "claim": post["claim"], "evidence": post["evidence"]}
|
|
113
|
+
for post in posts
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def report_con_responses(responses: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
118
|
+
return [
|
|
119
|
+
{
|
|
120
|
+
"agent_id": response["agent_id"],
|
|
121
|
+
"target_post_id": response["target_post_id"],
|
|
122
|
+
"reasoning": response["reasoning"],
|
|
123
|
+
}
|
|
124
|
+
for response in responses
|
|
125
|
+
if response.get("decision") == "maintain"
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def responses_with_delta(responses: list[dict[str, Any]], position_delta: str) -> list[dict[str, Any]]:
|
|
130
|
+
return [response for response in responses if response.get("position_delta") == position_delta]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def responses_with_decision(responses: list[dict[str, Any]], decision: str) -> list[dict[str, Any]]:
|
|
134
|
+
return [response for response in responses if response.get("decision") == decision]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def build_final_report(
|
|
138
|
+
*,
|
|
139
|
+
discussion_id: str,
|
|
140
|
+
board: dict[str, Any],
|
|
141
|
+
action_results: list[dict[str, Any]],
|
|
142
|
+
stop_reason: str,
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
"""Build a final advisory report from board facts without file writes."""
|
|
145
|
+
|
|
146
|
+
current_round = int(board["round"]["current"])
|
|
147
|
+
posts = discussion_posts(board)
|
|
148
|
+
responses = discussion_responses(board)
|
|
149
|
+
current_responses = round_responses(board, current_round)
|
|
150
|
+
current_unresolved = [
|
|
151
|
+
response for response in current_responses if response.get("still_disagree")
|
|
152
|
+
]
|
|
153
|
+
board_status = {
|
|
154
|
+
"round": current_round,
|
|
155
|
+
"phase": board["round"]["phase"],
|
|
156
|
+
"max_rounds": board["round"]["max"],
|
|
157
|
+
"termination_reason": stop_reason,
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
"schema_version": 1,
|
|
161
|
+
"discussion_id": discussion_id,
|
|
162
|
+
"terminal": True,
|
|
163
|
+
"stop_reason": stop_reason,
|
|
164
|
+
"board_status": board_status,
|
|
165
|
+
"rounds_summary": rounds_summary(board),
|
|
166
|
+
"action_results": list(action_results),
|
|
167
|
+
"accepted_evidence": accepted_evidence_summary(responses),
|
|
168
|
+
"next_actions": [],
|
|
169
|
+
"pro": report_pro_posts(posts),
|
|
170
|
+
"con": report_con_responses(responses),
|
|
171
|
+
"changed_positions": responses_with_delta(responses, "changed"),
|
|
172
|
+
"maintained_positions": responses_with_delta(responses, "unchanged"),
|
|
173
|
+
"revised_positions": responses_with_decision(responses, "revise"),
|
|
174
|
+
"current_unresolved_disagreements": current_unresolved,
|
|
175
|
+
"historical_disagreements": historical_disagreements(board, current_round),
|
|
176
|
+
"unresolved_disagreements": current_unresolved,
|
|
177
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Pure Party Mode V2 board state transitions and projections."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
CONFIDENCE_VALUES = {"low", "medium", "high"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def round_empty_state(items: list[Any], phase: str, *, item_type: str) -> str | None:
|
|
13
|
+
if items:
|
|
14
|
+
return None
|
|
15
|
+
if item_type == "posts":
|
|
16
|
+
if phase == "publish":
|
|
17
|
+
return "waiting_for_current_round_posts"
|
|
18
|
+
if phase == "closed":
|
|
19
|
+
return "discussion_closed"
|
|
20
|
+
return "no_current_round_posts"
|
|
21
|
+
if phase == "publish":
|
|
22
|
+
return "responses_not_open_yet"
|
|
23
|
+
if phase == "respond":
|
|
24
|
+
return "waiting_for_current_round_responses"
|
|
25
|
+
if phase == "closed":
|
|
26
|
+
return "discussion_closed"
|
|
27
|
+
return "no_current_round_responses"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_public_round(board: dict[str, Any]) -> dict[str, Any]:
|
|
31
|
+
"""Project the public current-round board without file IO."""
|
|
32
|
+
|
|
33
|
+
current_round_number = int(board["round"]["current"])
|
|
34
|
+
current = next(
|
|
35
|
+
(
|
|
36
|
+
item
|
|
37
|
+
for item in board.get("rounds", [])
|
|
38
|
+
if int(item.get("round", -1)) == current_round_number
|
|
39
|
+
),
|
|
40
|
+
{"round": current_round_number, "posts": [], "responses": [], "moderator_events": []},
|
|
41
|
+
)
|
|
42
|
+
posts = list(current.get("posts", []))
|
|
43
|
+
responses = list(current.get("responses", []))
|
|
44
|
+
phase = board["round"]["phase"]
|
|
45
|
+
expected_next_action = {
|
|
46
|
+
"publish": "post",
|
|
47
|
+
"respond": "respond",
|
|
48
|
+
"closed": "finalized",
|
|
49
|
+
}.get(str(phase), "monitor")
|
|
50
|
+
return {
|
|
51
|
+
"schema_version": 1,
|
|
52
|
+
"discussion_id": board["discussion_id"],
|
|
53
|
+
"round": current_round_number,
|
|
54
|
+
"phase": phase,
|
|
55
|
+
"topic": board["topic"],
|
|
56
|
+
"visible_posts": posts,
|
|
57
|
+
"visible_responses": responses,
|
|
58
|
+
"moderator_events": list(current.get("moderator_events", [])),
|
|
59
|
+
"empty_state": {
|
|
60
|
+
"visible_posts": round_empty_state(posts, phase, item_type="posts"),
|
|
61
|
+
"visible_responses": round_empty_state(responses, phase, item_type="responses"),
|
|
62
|
+
},
|
|
63
|
+
"expected_next_action": expected_next_action,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def initial_board(
|
|
68
|
+
discussion_id: str,
|
|
69
|
+
topic: str,
|
|
70
|
+
max_rounds: int,
|
|
71
|
+
*,
|
|
72
|
+
host_id: str | None = None,
|
|
73
|
+
) -> dict[str, Any]:
|
|
74
|
+
board: dict[str, Any] = {
|
|
75
|
+
"schema_version": 1,
|
|
76
|
+
"discussion_id": discussion_id,
|
|
77
|
+
"topic": topic,
|
|
78
|
+
"round": {"current": 1, "max": max_rounds, "phase": "publish"},
|
|
79
|
+
"rounds": [{"round": 1, "posts": [], "responses": [], "moderator_events": []}],
|
|
80
|
+
"termination": {"reason": None},
|
|
81
|
+
}
|
|
82
|
+
if host_id is not None:
|
|
83
|
+
board["host_id"] = host_id
|
|
84
|
+
return board
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def initial_agents_state(discussion_id: str, agents: list[dict[str, str]]) -> dict[str, Any]:
|
|
88
|
+
return {
|
|
89
|
+
"schema_version": 1,
|
|
90
|
+
"discussion_id": discussion_id,
|
|
91
|
+
"agents": [
|
|
92
|
+
{
|
|
93
|
+
"agent_id": agent["agent_id"],
|
|
94
|
+
"lens": agent["lens"],
|
|
95
|
+
"status": "pending",
|
|
96
|
+
"drift_warnings": 0,
|
|
97
|
+
"host_child_id": None,
|
|
98
|
+
}
|
|
99
|
+
for agent in agents
|
|
100
|
+
],
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def current_round(board: dict[str, Any]) -> dict[str, Any]:
|
|
105
|
+
current_round_number = int(board["round"]["current"])
|
|
106
|
+
for item in board.get("rounds", []):
|
|
107
|
+
if int(item.get("round", -1)) == current_round_number:
|
|
108
|
+
return item
|
|
109
|
+
item = {"round": current_round_number, "posts": [], "responses": [], "moderator_events": []}
|
|
110
|
+
board.setdefault("rounds", []).append(item)
|
|
111
|
+
return item
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def advance_board_state(
|
|
115
|
+
board: dict[str, Any],
|
|
116
|
+
*,
|
|
117
|
+
active_agent_ids: list[str],
|
|
118
|
+
fresh_context_per_round: bool,
|
|
119
|
+
) -> dict[str, Any]:
|
|
120
|
+
"""Advance the Party board in memory and return action/report facts."""
|
|
121
|
+
|
|
122
|
+
current = current_round(board)
|
|
123
|
+
current_round_number = int(board["round"]["current"])
|
|
124
|
+
phase = board["round"]["phase"]
|
|
125
|
+
if phase == "publish":
|
|
126
|
+
posted_agents = {str(post["agent_id"]) for post in current.get("posts", [])}
|
|
127
|
+
missing = sorted(set(active_agent_ids) - posted_agents)
|
|
128
|
+
if missing:
|
|
129
|
+
raise ValueError(f"publish_incomplete:{','.join(missing)}")
|
|
130
|
+
board["round"]["phase"] = "respond"
|
|
131
|
+
return {
|
|
132
|
+
"round": current_round_number,
|
|
133
|
+
"from": "publish",
|
|
134
|
+
"to": "respond",
|
|
135
|
+
"next_actions": {"round": current_round_number, "phase": "respond"},
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if phase != "respond":
|
|
139
|
+
raise ValueError("phase_not_advanceable")
|
|
140
|
+
|
|
141
|
+
responded_agents = {str(response["agent_id"]) for response in current.get("responses", [])}
|
|
142
|
+
missing = sorted(set(active_agent_ids) - responded_agents)
|
|
143
|
+
if missing:
|
|
144
|
+
raise ValueError(f"respond_incomplete:{','.join(missing)}")
|
|
145
|
+
if not any(bool(response.get("still_disagree")) for response in current.get("responses", [])):
|
|
146
|
+
board["round"]["phase"] = "closed"
|
|
147
|
+
board["termination"] = {"reason": "converged"}
|
|
148
|
+
return {"round": current_round_number, "from": "respond", "to": "closed", "reason": "converged"}
|
|
149
|
+
if current_round_number >= int(board["round"]["max"]):
|
|
150
|
+
board["round"]["phase"] = "closed"
|
|
151
|
+
board["termination"] = {"reason": "max_rounds_unconverged"}
|
|
152
|
+
return {
|
|
153
|
+
"round": current_round_number,
|
|
154
|
+
"from": "respond",
|
|
155
|
+
"to": "closed",
|
|
156
|
+
"reason": "max_rounds_unconverged",
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
next_round_number = current_round_number + 1
|
|
160
|
+
board["round"] = {
|
|
161
|
+
"current": next_round_number,
|
|
162
|
+
"max": board["round"]["max"],
|
|
163
|
+
"phase": "publish",
|
|
164
|
+
}
|
|
165
|
+
board.setdefault("rounds", []).append(
|
|
166
|
+
{"round": next_round_number, "posts": [], "responses": [], "moderator_events": []}
|
|
167
|
+
)
|
|
168
|
+
transition: dict[str, Any] = {
|
|
169
|
+
"round": current_round_number,
|
|
170
|
+
"from": "respond",
|
|
171
|
+
"to": "publish",
|
|
172
|
+
"next_round": next_round_number,
|
|
173
|
+
"next_actions": {"round": next_round_number, "phase": "publish"},
|
|
174
|
+
}
|
|
175
|
+
if fresh_context_per_round:
|
|
176
|
+
transition["close_children_reason"] = "fresh_context_per_round"
|
|
177
|
+
return transition
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def ensure_finalizable(board: dict[str, Any], *, manual_termination: bool) -> bool:
|
|
181
|
+
"""Close a board in memory for manual finalization when allowed."""
|
|
182
|
+
|
|
183
|
+
if board["round"]["phase"] == "closed":
|
|
184
|
+
return False
|
|
185
|
+
if not manual_termination:
|
|
186
|
+
raise ValueError("finalize_requires_closed_discussion")
|
|
187
|
+
board["round"]["phase"] = "closed"
|
|
188
|
+
board["termination"] = {"reason": "manual_terminated"}
|
|
189
|
+
return True
|
|
@@ -29,6 +29,8 @@ from adapters.host.host_manifest import (
|
|
|
29
29
|
)
|
|
30
30
|
from infra.paths import get_repo_root
|
|
31
31
|
from infra.skill_manifest import SkillManifestError, action_owners, load_skill_manifests
|
|
32
|
+
from infra.storage.operation_log import OperationLog
|
|
33
|
+
from infra.storage.state_store import DEFAULT_STALE_LOCK_SECONDS, StateStore
|
|
32
34
|
|
|
33
35
|
|
|
34
36
|
def _issue(
|
|
@@ -145,13 +147,30 @@ def _distribution_files(root: Path) -> tuple[Path, ...]:
|
|
|
145
147
|
return tuple(sorted(files))
|
|
146
148
|
|
|
147
149
|
|
|
150
|
+
def _source_checkout_protected_path(relative: Path) -> bool:
|
|
151
|
+
value = relative.as_posix()
|
|
152
|
+
if value in {".cowork-flow/config.yaml", ".cowork-flow/.developer"}:
|
|
153
|
+
return True
|
|
154
|
+
return value.startswith(".cowork-flow/tasks/") or value.startswith(
|
|
155
|
+
".cowork-flow/plans/"
|
|
156
|
+
) or value.startswith(".cowork-flow/.runtime/")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _source_checkout_live_files(template: Path) -> tuple[Path, ...]:
|
|
160
|
+
runtime_root = template / ".cowork-flow"
|
|
161
|
+
return tuple(
|
|
162
|
+
source
|
|
163
|
+
for source in _distribution_files(runtime_root)
|
|
164
|
+
if not _source_checkout_protected_path(source.relative_to(template))
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
148
168
|
def check_distribution(repo_root: Path) -> list[str]:
|
|
149
169
|
errors: list[str] = []
|
|
150
170
|
template = repo_root / "template"
|
|
151
171
|
if _distribution_root(repo_root) == repo_root:
|
|
152
172
|
return errors
|
|
153
|
-
|
|
154
|
-
for source in _distribution_files(runtime_root):
|
|
173
|
+
for source in _source_checkout_live_files(template):
|
|
155
174
|
relative = source.relative_to(template)
|
|
156
175
|
target = repo_root / relative
|
|
157
176
|
if target.is_file():
|
|
@@ -161,11 +180,6 @@ def check_distribution(repo_root: Path) -> list[str]:
|
|
|
161
180
|
errors,
|
|
162
181
|
drift="local live runtime drift",
|
|
163
182
|
)
|
|
164
|
-
for relative in (Path(".cowork-flow/run"), Path(".cowork-flow/run.cmd")):
|
|
165
|
-
source = template / relative
|
|
166
|
-
target = repo_root / relative
|
|
167
|
-
if source.is_file() and target.is_file():
|
|
168
|
-
_compare_file(source, target, errors, drift="local live runtime drift")
|
|
169
183
|
try:
|
|
170
184
|
host_manifest = load_host_manifest(template)
|
|
171
185
|
except HostManifestError as error:
|
|
@@ -301,6 +315,151 @@ def _print_task_hygiene_issues(issues: list[dict[str, str]]) -> None:
|
|
|
301
315
|
print(f"Hint: {issue['hint']}", file=sys.stderr)
|
|
302
316
|
|
|
303
317
|
|
|
318
|
+
def _diagnostic_path(repo_root: Path, path: Path) -> str:
|
|
319
|
+
try:
|
|
320
|
+
return path.resolve().relative_to(repo_root.resolve()).as_posix()
|
|
321
|
+
except ValueError:
|
|
322
|
+
return str(path)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _state_lock_code(status: str) -> str:
|
|
326
|
+
return f"STATE-RECOVERY-LOCK-{status.upper().replace('_', '-')}"
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _state_lock_command_hint(info) -> str:
|
|
330
|
+
if info.status != "recoverable":
|
|
331
|
+
return "Inspect the lock owner facts; do not delete unless the owner PID is missing and the lock is older than the stale threshold."
|
|
332
|
+
return (
|
|
333
|
+
"Use an explicit recovery action only after verification, for example "
|
|
334
|
+
"StateStore().remove_stale_lock(Path(<target>), stale_after_seconds="
|
|
335
|
+
f"{DEFAULT_STALE_LOCK_SECONDS})"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _state_lock_issue(repo_root: Path, info) -> dict[str, object]:
|
|
340
|
+
fields = info.to_dict()
|
|
341
|
+
fields.update(
|
|
342
|
+
{
|
|
343
|
+
"kind": "state_lock",
|
|
344
|
+
"lockPath": _diagnostic_path(repo_root, info.lock_path),
|
|
345
|
+
"target": str(info.target),
|
|
346
|
+
}
|
|
347
|
+
)
|
|
348
|
+
if info.age_seconds is not None:
|
|
349
|
+
fields["ageSeconds"] = round(info.age_seconds, 3)
|
|
350
|
+
return _issue(
|
|
351
|
+
code=_state_lock_code(info.status),
|
|
352
|
+
severity="warning",
|
|
353
|
+
path=_diagnostic_path(repo_root, info.lock_path),
|
|
354
|
+
message=info.detail,
|
|
355
|
+
command_hint=_state_lock_command_hint(info),
|
|
356
|
+
contract="runtime-health:state-recovery",
|
|
357
|
+
**fields,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _operation_command_hint() -> str:
|
|
362
|
+
return (
|
|
363
|
+
"Inspect the pending operation record; a trusted recovery command may "
|
|
364
|
+
"call UnitOfWork.recover_all(repo_root) without interpreting error "
|
|
365
|
+
"output as instructions."
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _pending_operation_issue(repo_root: Path, fact: dict) -> dict[str, object]:
|
|
370
|
+
error = fact.get("error")
|
|
371
|
+
if fact.get("phase") == "unreadable" and isinstance(error, dict):
|
|
372
|
+
return _issue(
|
|
373
|
+
code="STATE-RECOVERY-OPERATION-UNREADABLE",
|
|
374
|
+
severity="warning",
|
|
375
|
+
path=_diagnostic_path(repo_root, Path(str(fact.get("path") or ""))),
|
|
376
|
+
message=str(error.get("detail") or "operation record is unreadable"),
|
|
377
|
+
command_hint="Inspect the operation record manually; Doctor does not mutate recovery state.",
|
|
378
|
+
contract="runtime-health:state-recovery",
|
|
379
|
+
kind="pending_operation",
|
|
380
|
+
operationId=str(fact.get("operation_id") or ""),
|
|
381
|
+
phase=str(fact.get("phase") or "unreadable"),
|
|
382
|
+
errorCode=str(error.get("code") or ""),
|
|
383
|
+
)
|
|
384
|
+
operation_id = str(fact.get("operation_id") or "")
|
|
385
|
+
phase = str(fact.get("phase") or "unknown")
|
|
386
|
+
record_error = fact.get("error")
|
|
387
|
+
if phase == "conflicted":
|
|
388
|
+
error_detail = ""
|
|
389
|
+
error_code = ""
|
|
390
|
+
if isinstance(record_error, dict):
|
|
391
|
+
error_code = str(record_error.get("code") or "")
|
|
392
|
+
error_detail = str(record_error.get("detail") or "")
|
|
393
|
+
suffix = ": ".join(value for value in (error_code, error_detail) if value)
|
|
394
|
+
message = (
|
|
395
|
+
f"conflicted UnitOfWork operation {operation_id}"
|
|
396
|
+
+ (f": {suffix}" if suffix else "")
|
|
397
|
+
)
|
|
398
|
+
return _issue(
|
|
399
|
+
code="STATE-RECOVERY-CONFLICTED-OPERATION",
|
|
400
|
+
severity="warning",
|
|
401
|
+
path=_diagnostic_path(repo_root, Path(str(fact.get("path") or ""))),
|
|
402
|
+
message=message,
|
|
403
|
+
command_hint=(
|
|
404
|
+
"Resolve the recorded conflict manually; Doctor does not retry "
|
|
405
|
+
"or mutate conflicted operation state."
|
|
406
|
+
),
|
|
407
|
+
contract="runtime-health:state-recovery",
|
|
408
|
+
kind="pending_operation",
|
|
409
|
+
operationId=operation_id,
|
|
410
|
+
operationKind=str(fact.get("kind") or "unknown"),
|
|
411
|
+
phase=phase,
|
|
412
|
+
participantCount=fact.get("participant_count", 0),
|
|
413
|
+
)
|
|
414
|
+
return _issue(
|
|
415
|
+
code="STATE-RECOVERY-PENDING-OPERATION",
|
|
416
|
+
severity="warning",
|
|
417
|
+
path=_diagnostic_path(repo_root, Path(str(fact.get("path") or ""))),
|
|
418
|
+
message=f"pending UnitOfWork operation {operation_id} is in phase {phase}",
|
|
419
|
+
command_hint=_operation_command_hint(),
|
|
420
|
+
contract="runtime-health:state-recovery",
|
|
421
|
+
kind="pending_operation",
|
|
422
|
+
operationId=operation_id,
|
|
423
|
+
operationKind=str(fact.get("kind") or "unknown"),
|
|
424
|
+
phase=phase,
|
|
425
|
+
participantCount=fact.get("participant_count", 0),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def check_state_recovery(repo_root: Path) -> list[dict[str, object]]:
|
|
430
|
+
workflow = repo_root / ".cowork-flow"
|
|
431
|
+
if not workflow.is_dir():
|
|
432
|
+
return []
|
|
433
|
+
store = StateStore()
|
|
434
|
+
issues: list[dict[str, object]] = []
|
|
435
|
+
for lock_path in sorted(workflow.rglob("*.lock")):
|
|
436
|
+
if not lock_path.is_file():
|
|
437
|
+
continue
|
|
438
|
+
info = store.inspect_lock_path(
|
|
439
|
+
lock_path,
|
|
440
|
+
stale_after_seconds=DEFAULT_STALE_LOCK_SECONDS,
|
|
441
|
+
)
|
|
442
|
+
if info.status != "absent":
|
|
443
|
+
issues.append(_state_lock_issue(repo_root, info))
|
|
444
|
+
operation_log = OperationLog(repo_root, state_store=store)
|
|
445
|
+
for fact in operation_log.pending_facts():
|
|
446
|
+
issues.append(_pending_operation_issue(repo_root, fact))
|
|
447
|
+
return issues
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _print_state_recovery_issues(issues: list[dict[str, object]]) -> None:
|
|
451
|
+
for issue in issues:
|
|
452
|
+
print(
|
|
453
|
+
"WARNING: "
|
|
454
|
+
f"{issue['kind']}: {issue['path']}: "
|
|
455
|
+
f"{issue['message']}",
|
|
456
|
+
file=sys.stderr,
|
|
457
|
+
)
|
|
458
|
+
hint = issue.get("commandHint")
|
|
459
|
+
if hint:
|
|
460
|
+
print(f"Hint: {hint}", file=sys.stderr)
|
|
461
|
+
|
|
462
|
+
|
|
304
463
|
def check_runtime(repo_root: Path) -> list[str]:
|
|
305
464
|
errors: list[str] = []
|
|
306
465
|
try:
|
|
@@ -330,15 +489,45 @@ def check_runtime(repo_root: Path) -> list[str]:
|
|
|
330
489
|
return errors
|
|
331
490
|
|
|
332
491
|
|
|
333
|
-
def
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
492
|
+
def _all_check_result(repo_root: Path) -> dict[str, object]:
|
|
493
|
+
host_issues = _host_issues(repo_root)
|
|
494
|
+
runtime_errors = check_runtime(repo_root)
|
|
495
|
+
distribution_errors = check_distribution(repo_root)
|
|
496
|
+
task_hygiene_issues = check_task_hygiene(repo_root)
|
|
497
|
+
state_recovery_issues = check_state_recovery(repo_root)
|
|
498
|
+
errors: list[dict[str, object]] = []
|
|
499
|
+
for issue in host_issues:
|
|
500
|
+
errors.append({"kind": "host_adapter", **issue})
|
|
501
|
+
errors.extend(
|
|
502
|
+
{"kind": "runtime", "message": error}
|
|
503
|
+
for error in runtime_errors
|
|
504
|
+
)
|
|
505
|
+
errors.extend(
|
|
506
|
+
{"kind": "distribution", "message": error}
|
|
507
|
+
for error in distribution_errors
|
|
508
|
+
)
|
|
509
|
+
return {
|
|
510
|
+
"ok": not errors,
|
|
511
|
+
"errors": errors,
|
|
512
|
+
"issues": {
|
|
513
|
+
"hostAdapters": host_issues,
|
|
514
|
+
"taskHygiene": task_hygiene_issues,
|
|
515
|
+
"stateRecovery": state_recovery_issues,
|
|
516
|
+
},
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _run_checks(repo_root: Path, *, structured: bool = False) -> int:
|
|
521
|
+
result = _all_check_result(repo_root)
|
|
522
|
+
errors = result["errors"]
|
|
523
|
+
if structured:
|
|
524
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
525
|
+
return 1 if errors else 0
|
|
526
|
+
_print_task_hygiene_issues(result["issues"]["taskHygiene"])
|
|
527
|
+
_print_state_recovery_issues(result["issues"]["stateRecovery"])
|
|
339
528
|
if errors:
|
|
340
529
|
for error in errors:
|
|
341
|
-
print(f"ERROR: {error}", file=sys.stderr)
|
|
530
|
+
print(f"ERROR: {error['message']}", file=sys.stderr)
|
|
342
531
|
return 1
|
|
343
532
|
print("runtime health checks passed")
|
|
344
533
|
return 0
|
|
@@ -394,7 +583,7 @@ def main() -> int:
|
|
|
394
583
|
args = build_parser().parse_args()
|
|
395
584
|
repo_root = get_repo_root()
|
|
396
585
|
if args.all:
|
|
397
|
-
return _run_checks(repo_root)
|
|
586
|
+
return _run_checks(repo_root, structured=bool(args.json))
|
|
398
587
|
if args.host_adapters:
|
|
399
588
|
return _run_host_checks(repo_root, structured=bool(args.json))
|
|
400
589
|
if args.subagent_safety:
|