project-loop-harness 0.1.2__py3-none-any.whl
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.
- pcl/__init__.py +1 -0
- pcl/__main__.py +4 -0
- pcl/agents.py +501 -0
- pcl/checkpoints.py +201 -0
- pcl/cli.py +1404 -0
- pcl/commands.py +1006 -0
- pcl/db/migrations/001_initial.sql +180 -0
- pcl/db/schema.sql +180 -0
- pcl/db.py +49 -0
- pcl/decisions.py +275 -0
- pcl/errors.py +83 -0
- pcl/escalations.py +302 -0
- pcl/events.py +41 -0
- pcl/evidence.py +25 -0
- pcl/exporters.py +77 -0
- pcl/guards.py +14 -0
- pcl/ids.py +15 -0
- pcl/init_project.py +112 -0
- pcl/lifecycle.py +1073 -0
- pcl/links.py +108 -0
- pcl/mcp_server.py +328 -0
- pcl/migrations.py +220 -0
- pcl/paths.py +65 -0
- pcl/renderer.py +823 -0
- pcl/reports.py +766 -0
- pcl/resources.py +26 -0
- pcl/stories.py +762 -0
- pcl/templates/dashboard/dashboard.html +165 -0
- pcl/templates/project/AGENTS.block.md +16 -0
- pcl/templates/project/CLAUDE.block.md +13 -0
- pcl/templates/project/gitignore.fragment +13 -0
- pcl/templates/project/pcl.yaml +60 -0
- pcl/templates/skills/project-control-loop/SKILL.md +120 -0
- pcl/templates/workflows/defect_repair.yaml +61 -0
- pcl/templates/workflows/executor_smoke.yaml +32 -0
- pcl/templates/workflows/feature_coverage.yaml +52 -0
- pcl/templates/workflows/regression_loop.yaml +51 -0
- pcl/timeutil.py +7 -0
- pcl/validators.py +788 -0
- pcl/workflow_executor.py +911 -0
- pcl/workflow_proposal_validation.py +50 -0
- pcl/workflow_proposals.py +442 -0
- pcl/workflow_sandbox.py +683 -0
- pcl/workflow_verifier.py +333 -0
- pcl/workflow_yaml.py +190 -0
- pcl/workflows.py +569 -0
- project_loop_harness-0.1.2.dist-info/METADATA +361 -0
- project_loop_harness-0.1.2.dist-info/RECORD +52 -0
- project_loop_harness-0.1.2.dist-info/WHEEL +5 -0
- project_loop_harness-0.1.2.dist-info/entry_points.txt +3 -0
- project_loop_harness-0.1.2.dist-info/licenses/LICENSE +21 -0
- project_loop_harness-0.1.2.dist-info/top_level.txt +1 -0
pcl/commands.py
ADDED
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from json import JSONDecodeError
|
|
5
|
+
|
|
6
|
+
from .checkpoints import checkpoint_status
|
|
7
|
+
from .db import connect
|
|
8
|
+
from .evidence import record_inline_evidence
|
|
9
|
+
from .events import append_event
|
|
10
|
+
from .errors import InvalidInputError
|
|
11
|
+
from .guards import require_initialized
|
|
12
|
+
from .ids import next_prefixed_id
|
|
13
|
+
from .lifecycle import ACTIVE_JOB_STATUSES, ACTIVE_RUN_STATUSES, TERMINAL_JOB_STATUSES
|
|
14
|
+
from .links import linked_decisions_for_escalation
|
|
15
|
+
from .paths import ProjectPaths
|
|
16
|
+
from .timeutil import utc_now_iso
|
|
17
|
+
from .workflow_proposals import next_reviewable_workflow_proposal
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
FEATURE_STATUSES = {"discovered", "specified", "needs_test", "needs_fix", "passing", "done", "waived"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _normalized_json_object(raw: str, field_name: str) -> str:
|
|
24
|
+
try:
|
|
25
|
+
value = json.loads(raw)
|
|
26
|
+
except JSONDecodeError as exc:
|
|
27
|
+
raise InvalidInputError(
|
|
28
|
+
f"{field_name} must be valid JSON: {exc.msg}.",
|
|
29
|
+
details={"field": field_name, "position": exc.pos},
|
|
30
|
+
) from exc
|
|
31
|
+
if not isinstance(value, dict):
|
|
32
|
+
raise InvalidInputError(
|
|
33
|
+
f"{field_name} must be a JSON object.",
|
|
34
|
+
details={"field": field_name, "type": type(value).__name__},
|
|
35
|
+
)
|
|
36
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def create_goal(paths: ProjectPaths, *, title: str, completion_json: str = "{}", budget_json: str = "{}") -> str:
|
|
40
|
+
require_initialized(paths)
|
|
41
|
+
completion_json = _normalized_json_object(completion_json, "completion-json")
|
|
42
|
+
budget_json = _normalized_json_object(budget_json, "budget-json")
|
|
43
|
+
|
|
44
|
+
conn = connect(paths.db_path)
|
|
45
|
+
try:
|
|
46
|
+
goal_id = next_prefixed_id(conn, "goals", "G")
|
|
47
|
+
now = utc_now_iso()
|
|
48
|
+
conn.execute(
|
|
49
|
+
"""
|
|
50
|
+
INSERT INTO goals(id, title, status, completion_json, stop_conditions_json, budget_json, created_at, updated_at)
|
|
51
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
52
|
+
""",
|
|
53
|
+
(goal_id, title, "open", completion_json, "{}", budget_json, now, now),
|
|
54
|
+
)
|
|
55
|
+
append_event(
|
|
56
|
+
conn=conn,
|
|
57
|
+
events_path=paths.events_path,
|
|
58
|
+
event_type="goal_created",
|
|
59
|
+
entity_type="goal",
|
|
60
|
+
entity_id=goal_id,
|
|
61
|
+
payload={"title": title},
|
|
62
|
+
)
|
|
63
|
+
conn.commit()
|
|
64
|
+
return goal_id
|
|
65
|
+
finally:
|
|
66
|
+
conn.close()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def add_feature(paths: ProjectPaths, *, name: str, surface: str, description: str = "", evidence: str = "") -> str:
|
|
70
|
+
require_initialized(paths)
|
|
71
|
+
|
|
72
|
+
conn = connect(paths.db_path)
|
|
73
|
+
try:
|
|
74
|
+
feature_id = next_prefixed_id(conn, "features", "F")
|
|
75
|
+
now = utc_now_iso()
|
|
76
|
+
conn.execute(
|
|
77
|
+
"""
|
|
78
|
+
INSERT INTO features(id, name, surface, description, status, confidence, created_at, updated_at)
|
|
79
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
80
|
+
""",
|
|
81
|
+
(feature_id, name, surface, description, "discovered", "medium", now, now),
|
|
82
|
+
)
|
|
83
|
+
payload = {"name": name, "surface": surface, "description": description, "evidence": evidence}
|
|
84
|
+
append_event(
|
|
85
|
+
conn=conn,
|
|
86
|
+
events_path=paths.events_path,
|
|
87
|
+
event_type="feature_added",
|
|
88
|
+
entity_type="feature",
|
|
89
|
+
entity_id=feature_id,
|
|
90
|
+
payload=payload,
|
|
91
|
+
)
|
|
92
|
+
conn.commit()
|
|
93
|
+
return feature_id
|
|
94
|
+
finally:
|
|
95
|
+
conn.close()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def list_features(paths: ProjectPaths, *, status: str | None = None) -> list[dict]:
|
|
99
|
+
require_initialized(paths)
|
|
100
|
+
if status:
|
|
101
|
+
_require_feature_status(status)
|
|
102
|
+
|
|
103
|
+
clauses: list[str] = []
|
|
104
|
+
params: list[str] = []
|
|
105
|
+
if status:
|
|
106
|
+
clauses.append("status = ?")
|
|
107
|
+
params.append(status)
|
|
108
|
+
where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
109
|
+
|
|
110
|
+
conn = connect(paths.db_path)
|
|
111
|
+
try:
|
|
112
|
+
rows = conn.execute(
|
|
113
|
+
f"""
|
|
114
|
+
SELECT id, name, surface, description, status, confidence, created_at, updated_at
|
|
115
|
+
FROM features
|
|
116
|
+
{where_sql}
|
|
117
|
+
ORDER BY id
|
|
118
|
+
""",
|
|
119
|
+
tuple(params),
|
|
120
|
+
).fetchall()
|
|
121
|
+
return [dict(row) for row in rows]
|
|
122
|
+
finally:
|
|
123
|
+
conn.close()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def read_feature(paths: ProjectPaths, feature_id: str) -> dict:
|
|
127
|
+
require_initialized(paths)
|
|
128
|
+
_validate_identifier(feature_id, "feature_id")
|
|
129
|
+
|
|
130
|
+
conn = connect(paths.db_path)
|
|
131
|
+
try:
|
|
132
|
+
row = conn.execute(
|
|
133
|
+
"""
|
|
134
|
+
SELECT id, name, surface, description, status, confidence, created_at, updated_at
|
|
135
|
+
FROM features
|
|
136
|
+
WHERE id = ?
|
|
137
|
+
""",
|
|
138
|
+
(feature_id,),
|
|
139
|
+
).fetchone()
|
|
140
|
+
if row is None:
|
|
141
|
+
raise InvalidInputError(
|
|
142
|
+
f"Feature does not exist: {feature_id}",
|
|
143
|
+
details={"feature_id": feature_id},
|
|
144
|
+
)
|
|
145
|
+
return dict(row)
|
|
146
|
+
finally:
|
|
147
|
+
conn.close()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def set_feature_status(
|
|
151
|
+
paths: ProjectPaths,
|
|
152
|
+
feature_id: str,
|
|
153
|
+
*,
|
|
154
|
+
status: str,
|
|
155
|
+
summary: str,
|
|
156
|
+
evidence: str,
|
|
157
|
+
) -> dict:
|
|
158
|
+
require_initialized(paths)
|
|
159
|
+
_validate_identifier(feature_id, "feature_id")
|
|
160
|
+
_require_feature_status(status)
|
|
161
|
+
_require_text(summary, "--summary is required to update feature status.")
|
|
162
|
+
_require_text(evidence, "--evidence is required to update feature status.")
|
|
163
|
+
|
|
164
|
+
conn = connect(paths.db_path)
|
|
165
|
+
try:
|
|
166
|
+
feature = conn.execute(
|
|
167
|
+
"SELECT id, status FROM features WHERE id = ?",
|
|
168
|
+
(feature_id,),
|
|
169
|
+
).fetchone()
|
|
170
|
+
if feature is None:
|
|
171
|
+
raise InvalidInputError(
|
|
172
|
+
f"Feature does not exist: {feature_id}",
|
|
173
|
+
details={"feature_id": feature_id},
|
|
174
|
+
)
|
|
175
|
+
previous_status = str(feature["status"])
|
|
176
|
+
if previous_status == status:
|
|
177
|
+
raise InvalidInputError(
|
|
178
|
+
f"Feature {feature_id} is already {status}.",
|
|
179
|
+
details={"feature_id": feature_id, "status": status},
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
now = utc_now_iso()
|
|
183
|
+
evidence_id = record_inline_evidence(
|
|
184
|
+
conn,
|
|
185
|
+
evidence_type="feature_status",
|
|
186
|
+
summary=evidence.strip(),
|
|
187
|
+
context=f"feature/{feature_id}/status",
|
|
188
|
+
command="pcl feature status",
|
|
189
|
+
)
|
|
190
|
+
conn.execute(
|
|
191
|
+
"UPDATE features SET status = ?, updated_at = ? WHERE id = ?",
|
|
192
|
+
(status, now, feature_id),
|
|
193
|
+
)
|
|
194
|
+
append_event(
|
|
195
|
+
conn=conn,
|
|
196
|
+
events_path=paths.events_path,
|
|
197
|
+
event_type="feature_status_updated",
|
|
198
|
+
entity_type="feature",
|
|
199
|
+
entity_id=feature_id,
|
|
200
|
+
payload={
|
|
201
|
+
"previous_status": previous_status,
|
|
202
|
+
"status": status,
|
|
203
|
+
"summary": summary.strip(),
|
|
204
|
+
"evidence": evidence.strip(),
|
|
205
|
+
"evidence_id": evidence_id,
|
|
206
|
+
"source": "manual",
|
|
207
|
+
},
|
|
208
|
+
)
|
|
209
|
+
conn.commit()
|
|
210
|
+
return {
|
|
211
|
+
"ok": True,
|
|
212
|
+
"feature_id": feature_id,
|
|
213
|
+
"previous_status": previous_status,
|
|
214
|
+
"status": status,
|
|
215
|
+
"summary": summary.strip(),
|
|
216
|
+
"evidence_id": evidence_id,
|
|
217
|
+
}
|
|
218
|
+
finally:
|
|
219
|
+
conn.close()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def open_defect(
|
|
223
|
+
paths: ProjectPaths,
|
|
224
|
+
*,
|
|
225
|
+
feature_id: str,
|
|
226
|
+
severity: str,
|
|
227
|
+
expected: str,
|
|
228
|
+
actual: str,
|
|
229
|
+
test_case_id: str | None = None,
|
|
230
|
+
reproduction: str = "",
|
|
231
|
+
evidence: str = "",
|
|
232
|
+
) -> str:
|
|
233
|
+
require_initialized(paths)
|
|
234
|
+
|
|
235
|
+
conn = connect(paths.db_path)
|
|
236
|
+
try:
|
|
237
|
+
row = conn.execute("SELECT id FROM features WHERE id = ?", (feature_id,)).fetchone()
|
|
238
|
+
if row is None:
|
|
239
|
+
raise InvalidInputError(
|
|
240
|
+
f"Feature does not exist: {feature_id}",
|
|
241
|
+
details={"feature_id": feature_id},
|
|
242
|
+
)
|
|
243
|
+
defect_id = next_prefixed_id(conn, "defects", "D")
|
|
244
|
+
now = utc_now_iso()
|
|
245
|
+
evidence_id = None
|
|
246
|
+
if evidence.strip():
|
|
247
|
+
evidence_id = record_inline_evidence(
|
|
248
|
+
conn,
|
|
249
|
+
evidence_type="defect_open",
|
|
250
|
+
summary=evidence.strip(),
|
|
251
|
+
context=f"defect/{defect_id}/open",
|
|
252
|
+
command="pcl defect open",
|
|
253
|
+
)
|
|
254
|
+
conn.execute(
|
|
255
|
+
"""
|
|
256
|
+
INSERT INTO defects(id, feature_id, test_case_id, severity, expected, actual, reproduction, status, evidence_id, created_at, updated_at)
|
|
257
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
258
|
+
""",
|
|
259
|
+
(
|
|
260
|
+
defect_id,
|
|
261
|
+
feature_id,
|
|
262
|
+
test_case_id or None,
|
|
263
|
+
severity,
|
|
264
|
+
expected,
|
|
265
|
+
actual,
|
|
266
|
+
reproduction,
|
|
267
|
+
"open",
|
|
268
|
+
evidence_id,
|
|
269
|
+
now,
|
|
270
|
+
now,
|
|
271
|
+
),
|
|
272
|
+
)
|
|
273
|
+
conn.execute("UPDATE features SET status = ?, updated_at = ? WHERE id = ?", ("needs_fix", now, feature_id))
|
|
274
|
+
append_event(
|
|
275
|
+
conn=conn,
|
|
276
|
+
events_path=paths.events_path,
|
|
277
|
+
event_type="defect_opened",
|
|
278
|
+
entity_type="defect",
|
|
279
|
+
entity_id=defect_id,
|
|
280
|
+
payload={
|
|
281
|
+
"feature_id": feature_id,
|
|
282
|
+
"severity": severity,
|
|
283
|
+
"expected": expected,
|
|
284
|
+
"actual": actual,
|
|
285
|
+
"test_case_id": test_case_id,
|
|
286
|
+
"evidence": evidence,
|
|
287
|
+
"evidence_id": evidence_id,
|
|
288
|
+
},
|
|
289
|
+
)
|
|
290
|
+
conn.commit()
|
|
291
|
+
return defect_id
|
|
292
|
+
finally:
|
|
293
|
+
conn.close()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def loop_status(paths: ProjectPaths) -> dict:
|
|
297
|
+
require_initialized(paths)
|
|
298
|
+
|
|
299
|
+
conn = connect(paths.db_path)
|
|
300
|
+
try:
|
|
301
|
+
open_goals = conn.execute(
|
|
302
|
+
"""
|
|
303
|
+
SELECT id, title, status
|
|
304
|
+
FROM goals
|
|
305
|
+
WHERE status NOT IN ('closed', 'cancelled')
|
|
306
|
+
ORDER BY created_at DESC
|
|
307
|
+
"""
|
|
308
|
+
).fetchall()
|
|
309
|
+
open_defects = conn.execute(
|
|
310
|
+
"""
|
|
311
|
+
SELECT id, feature_id, severity, status
|
|
312
|
+
FROM defects
|
|
313
|
+
WHERE status NOT IN ('closed', 'waived')
|
|
314
|
+
ORDER BY created_at DESC
|
|
315
|
+
"""
|
|
316
|
+
).fetchall()
|
|
317
|
+
runs = conn.execute("SELECT id, workflow_id, status, iteration FROM workflow_runs ORDER BY started_at DESC LIMIT 10").fetchall()
|
|
318
|
+
return {
|
|
319
|
+
"open_goals": [dict(r) for r in open_goals],
|
|
320
|
+
"open_defects": [dict(r) for r in open_defects],
|
|
321
|
+
"recent_workflow_runs": [dict(r) for r in runs],
|
|
322
|
+
}
|
|
323
|
+
finally:
|
|
324
|
+
conn.close()
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def build_next_action(
|
|
328
|
+
*,
|
|
329
|
+
action_type: str,
|
|
330
|
+
command: str,
|
|
331
|
+
reason: str,
|
|
332
|
+
target,
|
|
333
|
+
priority: int,
|
|
334
|
+
blocking: bool,
|
|
335
|
+
requires_human: bool,
|
|
336
|
+
safe_to_run: bool,
|
|
337
|
+
expected_after: str,
|
|
338
|
+
) -> dict:
|
|
339
|
+
run_policy = _run_policy(
|
|
340
|
+
blocking=blocking,
|
|
341
|
+
requires_human=requires_human,
|
|
342
|
+
safe_to_run=safe_to_run,
|
|
343
|
+
)
|
|
344
|
+
return {
|
|
345
|
+
"type": action_type,
|
|
346
|
+
"command": command,
|
|
347
|
+
"reason": reason,
|
|
348
|
+
"target": target,
|
|
349
|
+
"priority": priority,
|
|
350
|
+
"blocking": blocking,
|
|
351
|
+
"requires_human": requires_human,
|
|
352
|
+
"safe_to_run": safe_to_run,
|
|
353
|
+
"expected_after": expected_after,
|
|
354
|
+
"run_policy": run_policy,
|
|
355
|
+
"human_guidance": _human_guidance(
|
|
356
|
+
run_policy=run_policy,
|
|
357
|
+
blocking=blocking,
|
|
358
|
+
),
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _run_policy(*, blocking: bool, requires_human: bool, safe_to_run: bool) -> str:
|
|
363
|
+
if safe_to_run:
|
|
364
|
+
return "agent_safe"
|
|
365
|
+
if requires_human:
|
|
366
|
+
return "human_decision"
|
|
367
|
+
if blocking:
|
|
368
|
+
return "manual_resolution"
|
|
369
|
+
return "manual_state_transition"
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _human_guidance(*, run_policy: str, blocking: bool) -> str:
|
|
373
|
+
prefix = "Normal loop continuation should wait. " if blocking else ""
|
|
374
|
+
if run_policy == "agent_safe":
|
|
375
|
+
return prefix + "An agent or automation may run this command in the current project context."
|
|
376
|
+
if run_policy == "human_decision":
|
|
377
|
+
return prefix + "A human should choose or confirm this state transition before the command is run."
|
|
378
|
+
if run_policy == "manual_resolution":
|
|
379
|
+
return prefix + "Resolve the blocking state deliberately; do not auto-run this command blindly."
|
|
380
|
+
return prefix + "This mutates durable loop state; run it deliberately after reviewing the recommendation."
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def next_action(paths: ProjectPaths) -> dict:
|
|
384
|
+
status = loop_status(paths)
|
|
385
|
+
escalation = _open_escalation_next_action(paths)
|
|
386
|
+
if escalation is not None:
|
|
387
|
+
return escalation
|
|
388
|
+
decision = _open_decision_next_action(paths)
|
|
389
|
+
if decision is not None:
|
|
390
|
+
return decision
|
|
391
|
+
needs_human = _needs_human_escalation_next_action(paths)
|
|
392
|
+
if needs_human is not None:
|
|
393
|
+
return needs_human
|
|
394
|
+
unfinished_executor = _unfinished_executor_next_action(paths)
|
|
395
|
+
if unfinished_executor is not None:
|
|
396
|
+
return unfinished_executor
|
|
397
|
+
active = _active_workflow_next_action(paths)
|
|
398
|
+
if active is not None:
|
|
399
|
+
return active
|
|
400
|
+
retry_executor = _failed_executor_retry_next_action(paths)
|
|
401
|
+
if retry_executor is not None:
|
|
402
|
+
return retry_executor
|
|
403
|
+
if status["open_defects"]:
|
|
404
|
+
defect = status["open_defects"][0]
|
|
405
|
+
return _defect_next_action(defect)
|
|
406
|
+
proposal = _workflow_proposal_review_next_action(paths)
|
|
407
|
+
if proposal is not None:
|
|
408
|
+
return proposal
|
|
409
|
+
checkpoint = _checkpoint_review_next_action(paths)
|
|
410
|
+
if checkpoint is not None:
|
|
411
|
+
return checkpoint
|
|
412
|
+
if status["open_goals"]:
|
|
413
|
+
goal = status["open_goals"][0]
|
|
414
|
+
return build_next_action(
|
|
415
|
+
action_type="continue_goal",
|
|
416
|
+
command=f"pcl loop run feature_coverage --goal {goal['id']}",
|
|
417
|
+
reason="There is an open goal and no open defects.",
|
|
418
|
+
target=goal,
|
|
419
|
+
priority=60,
|
|
420
|
+
blocking=False,
|
|
421
|
+
requires_human=False,
|
|
422
|
+
safe_to_run=False,
|
|
423
|
+
expected_after="A workflow run exists for the open goal.",
|
|
424
|
+
)
|
|
425
|
+
uncovered_feature = _uncovered_feature_next_action(paths)
|
|
426
|
+
if uncovered_feature is not None:
|
|
427
|
+
return uncovered_feature
|
|
428
|
+
return build_next_action(
|
|
429
|
+
action_type="create_goal",
|
|
430
|
+
command="pcl goal create --title 'Reach feature coverage'",
|
|
431
|
+
reason="No open goal exists.",
|
|
432
|
+
target=None,
|
|
433
|
+
priority=70,
|
|
434
|
+
blocking=False,
|
|
435
|
+
requires_human=True,
|
|
436
|
+
safe_to_run=False,
|
|
437
|
+
expected_after="An open goal exists and `pcl next` can route work from it.",
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _uncovered_feature_next_action(paths: ProjectPaths) -> dict | None:
|
|
442
|
+
conn = connect(paths.db_path)
|
|
443
|
+
try:
|
|
444
|
+
row = conn.execute(
|
|
445
|
+
"""
|
|
446
|
+
SELECT id, name, surface, description, status, confidence, created_at, updated_at
|
|
447
|
+
FROM features
|
|
448
|
+
WHERE status IN ('discovered', 'specified', 'needs_test', 'needs_fix')
|
|
449
|
+
ORDER BY created_at, id
|
|
450
|
+
LIMIT 1
|
|
451
|
+
"""
|
|
452
|
+
).fetchone()
|
|
453
|
+
if row is None:
|
|
454
|
+
return None
|
|
455
|
+
feature = dict(row)
|
|
456
|
+
feature_id = str(feature["id"])
|
|
457
|
+
return build_next_action(
|
|
458
|
+
action_type="cover_feature",
|
|
459
|
+
command=f"pcl goal create --title 'Cover feature {feature_id}'",
|
|
460
|
+
reason="No open goal exists, and a tracked feature still needs coverage work.",
|
|
461
|
+
target=feature,
|
|
462
|
+
priority=65,
|
|
463
|
+
blocking=False,
|
|
464
|
+
requires_human=True,
|
|
465
|
+
safe_to_run=False,
|
|
466
|
+
expected_after="An open goal exists for the uncovered feature and can run feature coverage.",
|
|
467
|
+
)
|
|
468
|
+
finally:
|
|
469
|
+
conn.close()
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _open_escalation_next_action(paths: ProjectPaths) -> dict | None:
|
|
473
|
+
conn = connect(paths.db_path)
|
|
474
|
+
try:
|
|
475
|
+
row = conn.execute(
|
|
476
|
+
"""
|
|
477
|
+
SELECT id, workflow_run_id, severity, question, recommendation, status, created_at
|
|
478
|
+
FROM escalations
|
|
479
|
+
WHERE status = 'open'
|
|
480
|
+
ORDER BY created_at DESC, id DESC
|
|
481
|
+
LIMIT 1
|
|
482
|
+
"""
|
|
483
|
+
).fetchone()
|
|
484
|
+
if row is None:
|
|
485
|
+
return None
|
|
486
|
+
escalation = dict(row)
|
|
487
|
+
decisions = [
|
|
488
|
+
dict(decision)
|
|
489
|
+
for decision in conn.execute(
|
|
490
|
+
"""
|
|
491
|
+
SELECT id, status, blocks_json, created_at
|
|
492
|
+
FROM decisions
|
|
493
|
+
ORDER BY id
|
|
494
|
+
"""
|
|
495
|
+
).fetchall()
|
|
496
|
+
]
|
|
497
|
+
linked_decisions = linked_decisions_for_escalation(decisions, str(escalation["id"]))
|
|
498
|
+
linked_decision_ids = [str(decision["id"]) for decision in linked_decisions]
|
|
499
|
+
escalation["linked_decision_ids"] = linked_decision_ids
|
|
500
|
+
if linked_decision_ids:
|
|
501
|
+
decision_id = linked_decision_ids[0]
|
|
502
|
+
command = (
|
|
503
|
+
f"pcl escalation resolve {escalation['id']} --decision {decision_id} "
|
|
504
|
+
"--summary 'Record the outcome'"
|
|
505
|
+
)
|
|
506
|
+
reason = "A human escalation is open and has a linked decision to record in the resolution."
|
|
507
|
+
else:
|
|
508
|
+
command = (
|
|
509
|
+
f"pcl decision open --escalation {escalation['id']} "
|
|
510
|
+
f"--question 'Record the human decision for {escalation['id']}' "
|
|
511
|
+
"--recommendation 'Choose the safe next step'"
|
|
512
|
+
)
|
|
513
|
+
reason = "A human escalation is open and needs a linked durable decision before resolution."
|
|
514
|
+
return build_next_action(
|
|
515
|
+
action_type="resolve_escalation",
|
|
516
|
+
command=command,
|
|
517
|
+
reason=reason,
|
|
518
|
+
target=escalation,
|
|
519
|
+
priority=10,
|
|
520
|
+
blocking=True,
|
|
521
|
+
requires_human=True,
|
|
522
|
+
safe_to_run=False,
|
|
523
|
+
expected_after=(
|
|
524
|
+
"The escalation is resolved with the linked decision."
|
|
525
|
+
if linked_decision_ids
|
|
526
|
+
else "A linked decision exists for the escalation."
|
|
527
|
+
),
|
|
528
|
+
)
|
|
529
|
+
finally:
|
|
530
|
+
conn.close()
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _open_decision_next_action(paths: ProjectPaths) -> dict | None:
|
|
534
|
+
conn = connect(paths.db_path)
|
|
535
|
+
try:
|
|
536
|
+
row = conn.execute(
|
|
537
|
+
"""
|
|
538
|
+
SELECT id, status, question, recommendation, blocks_json, created_at
|
|
539
|
+
FROM decisions
|
|
540
|
+
WHERE status = 'open'
|
|
541
|
+
ORDER BY created_at DESC, id DESC
|
|
542
|
+
LIMIT 1
|
|
543
|
+
"""
|
|
544
|
+
).fetchone()
|
|
545
|
+
if row is None:
|
|
546
|
+
return None
|
|
547
|
+
decision = dict(row)
|
|
548
|
+
return build_next_action(
|
|
549
|
+
action_type="resolve_decision",
|
|
550
|
+
command=(
|
|
551
|
+
f"pcl decision resolve {decision['id']} "
|
|
552
|
+
"--selected-option 'Record the selected option' --reason 'Explain the human decision'"
|
|
553
|
+
),
|
|
554
|
+
reason="A human decision is open and blocks safe continuation.",
|
|
555
|
+
target=decision,
|
|
556
|
+
priority=20,
|
|
557
|
+
blocking=True,
|
|
558
|
+
requires_human=True,
|
|
559
|
+
safe_to_run=False,
|
|
560
|
+
expected_after="The decision is resolved or waived.",
|
|
561
|
+
)
|
|
562
|
+
finally:
|
|
563
|
+
conn.close()
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _needs_human_escalation_next_action(paths: ProjectPaths) -> dict | None:
|
|
567
|
+
conn = connect(paths.db_path)
|
|
568
|
+
try:
|
|
569
|
+
placeholders = ", ".join("?" for _ in ACTIVE_RUN_STATUSES)
|
|
570
|
+
runs = conn.execute(
|
|
571
|
+
f"""
|
|
572
|
+
SELECT id, workflow_id, goal_id, status
|
|
573
|
+
FROM workflow_runs
|
|
574
|
+
WHERE status IN ({placeholders})
|
|
575
|
+
ORDER BY started_at DESC, id DESC
|
|
576
|
+
""",
|
|
577
|
+
tuple(sorted(ACTIVE_RUN_STATUSES)),
|
|
578
|
+
).fetchall()
|
|
579
|
+
for run in runs:
|
|
580
|
+
verification = conn.execute(
|
|
581
|
+
"""
|
|
582
|
+
SELECT
|
|
583
|
+
verifications.id,
|
|
584
|
+
verifications.result,
|
|
585
|
+
verifications.reasons_json,
|
|
586
|
+
verifications.created_at,
|
|
587
|
+
events.rowid AS event_rowid
|
|
588
|
+
FROM verifications
|
|
589
|
+
LEFT JOIN events
|
|
590
|
+
ON events.entity_type = 'verification'
|
|
591
|
+
AND events.entity_id = verifications.id
|
|
592
|
+
AND events.event_type = 'verification_recorded'
|
|
593
|
+
WHERE workflow_run_id = ?
|
|
594
|
+
ORDER BY verifications.created_at DESC, verifications.id DESC
|
|
595
|
+
LIMIT 1
|
|
596
|
+
""",
|
|
597
|
+
(run["id"],),
|
|
598
|
+
).fetchone()
|
|
599
|
+
if verification is None or verification["result"] != "needs_human":
|
|
600
|
+
continue
|
|
601
|
+
escalations = conn.execute(
|
|
602
|
+
"""
|
|
603
|
+
SELECT
|
|
604
|
+
escalations.id,
|
|
605
|
+
escalations.status,
|
|
606
|
+
escalations.created_at,
|
|
607
|
+
events.rowid AS event_rowid
|
|
608
|
+
FROM escalations
|
|
609
|
+
LEFT JOIN events
|
|
610
|
+
ON events.entity_type = 'escalation'
|
|
611
|
+
AND events.entity_id = escalations.id
|
|
612
|
+
AND events.event_type = 'escalation_opened'
|
|
613
|
+
WHERE escalations.workflow_run_id = ?
|
|
614
|
+
ORDER BY escalations.created_at DESC, escalations.id DESC
|
|
615
|
+
""",
|
|
616
|
+
(run["id"],),
|
|
617
|
+
).fetchall()
|
|
618
|
+
if any(_escalation_opened_at_or_after(escalation, verification) for escalation in escalations):
|
|
619
|
+
continue
|
|
620
|
+
target = dict(run)
|
|
621
|
+
target["verification_id"] = verification["id"]
|
|
622
|
+
target["verification_result"] = verification["result"]
|
|
623
|
+
target["reasons_json"] = verification["reasons_json"]
|
|
624
|
+
target["verification_created_at"] = verification["created_at"]
|
|
625
|
+
return build_next_action(
|
|
626
|
+
action_type="open_escalation",
|
|
627
|
+
command=(
|
|
628
|
+
f"pcl escalation open --run {run['id']} --severity high "
|
|
629
|
+
"--question 'What human decision is needed?' "
|
|
630
|
+
"--recommendation 'Review the needs_human verification and choose the next step'"
|
|
631
|
+
),
|
|
632
|
+
reason="The latest verification needs human input and no open escalation exists for this run.",
|
|
633
|
+
target=target,
|
|
634
|
+
priority=30,
|
|
635
|
+
blocking=True,
|
|
636
|
+
requires_human=True,
|
|
637
|
+
safe_to_run=False,
|
|
638
|
+
expected_after="An open escalation records the human-required ambiguity for this run.",
|
|
639
|
+
)
|
|
640
|
+
return None
|
|
641
|
+
finally:
|
|
642
|
+
conn.close()
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _escalation_opened_at_or_after(escalation, verification) -> bool:
|
|
646
|
+
escalation_event_rowid = escalation["event_rowid"]
|
|
647
|
+
verification_event_rowid = verification["event_rowid"]
|
|
648
|
+
if escalation_event_rowid is not None and verification_event_rowid is not None:
|
|
649
|
+
return int(escalation_event_rowid) >= int(verification_event_rowid)
|
|
650
|
+
return str(escalation["created_at"]) >= str(verification["created_at"])
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _defect_next_action(defect: dict) -> dict:
|
|
654
|
+
defect_id = defect["id"]
|
|
655
|
+
defect_status = defect.get("status")
|
|
656
|
+
commands = {
|
|
657
|
+
"open": (
|
|
658
|
+
"triage_defect",
|
|
659
|
+
f"pcl defect triage {defect_id} --summary 'Summarize impact and priority'",
|
|
660
|
+
"A defect is open and needs triage before repair starts.",
|
|
661
|
+
),
|
|
662
|
+
"triaged": (
|
|
663
|
+
"start_defect",
|
|
664
|
+
f"pcl defect start {defect_id} --summary 'Begin repair work'",
|
|
665
|
+
"A triaged defect is ready to start repair.",
|
|
666
|
+
),
|
|
667
|
+
"in_progress": (
|
|
668
|
+
"fix_defect",
|
|
669
|
+
f"pcl defect fix {defect_id} --summary 'Summarize the fix' --evidence 'Test or commit evidence'",
|
|
670
|
+
"A defect is in progress and needs fix evidence.",
|
|
671
|
+
),
|
|
672
|
+
"fixed": (
|
|
673
|
+
"verify_defect",
|
|
674
|
+
f"pcl defect verify {defect_id} --summary 'Summarize verification' --verification V-0001",
|
|
675
|
+
"A fixed defect needs an approved verification linked to its repair workflow.",
|
|
676
|
+
),
|
|
677
|
+
"verified": (
|
|
678
|
+
"close_defect",
|
|
679
|
+
f"pcl defect close {defect_id} --summary 'Close verified defect' --evidence 'Verification evidence'",
|
|
680
|
+
"A verified defect can be closed with evidence.",
|
|
681
|
+
),
|
|
682
|
+
}
|
|
683
|
+
action_type, command, reason = commands.get(
|
|
684
|
+
str(defect_status),
|
|
685
|
+
(
|
|
686
|
+
"repair_defect",
|
|
687
|
+
f"pcl loop run defect_repair --defect {defect_id}",
|
|
688
|
+
"There is at least one active defect.",
|
|
689
|
+
),
|
|
690
|
+
)
|
|
691
|
+
return build_next_action(
|
|
692
|
+
action_type=action_type,
|
|
693
|
+
command=command,
|
|
694
|
+
reason=reason,
|
|
695
|
+
target=defect,
|
|
696
|
+
priority=50,
|
|
697
|
+
blocking=False,
|
|
698
|
+
requires_human=False,
|
|
699
|
+
safe_to_run=False,
|
|
700
|
+
expected_after=f"Defect {defect_id} advances beyond {defect_status}.",
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _workflow_proposal_review_next_action(paths: ProjectPaths) -> dict | None:
|
|
705
|
+
proposal = next_reviewable_workflow_proposal(paths)
|
|
706
|
+
if proposal is None:
|
|
707
|
+
return None
|
|
708
|
+
proposal_id = str(proposal["id"])
|
|
709
|
+
return build_next_action(
|
|
710
|
+
action_type="review_workflow_proposal",
|
|
711
|
+
command=f"pcl workflow proposals approve {proposal_id} --summary 'Approve this workflow template'",
|
|
712
|
+
reason="A workflow proposal is waiting for human review before it can become executable.",
|
|
713
|
+
target=proposal,
|
|
714
|
+
priority=55,
|
|
715
|
+
blocking=False,
|
|
716
|
+
requires_human=True,
|
|
717
|
+
safe_to_run=False,
|
|
718
|
+
expected_after="The proposal is approved into `.project-loop/workflows/` or cancelled.",
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _checkpoint_review_next_action(paths: ProjectPaths) -> dict | None:
|
|
723
|
+
status = checkpoint_status(paths)
|
|
724
|
+
if not status["checkpoint_recommended"]:
|
|
725
|
+
return None
|
|
726
|
+
completed = status["completed_features_since_checkpoint"]
|
|
727
|
+
threshold = status["threshold"]
|
|
728
|
+
return build_next_action(
|
|
729
|
+
action_type="checkpoint_review",
|
|
730
|
+
command=(
|
|
731
|
+
"pcl checkpoint record --review-type integration "
|
|
732
|
+
"--summary 'Review commit/package checkpoint, UX checklist, and next big-goal priority' "
|
|
733
|
+
"--evidence 'Reviewed code state, validation results, UX checklist, and next feature priority'"
|
|
734
|
+
),
|
|
735
|
+
reason=(
|
|
736
|
+
f"{completed} features were marked done since the last checkpoint; "
|
|
737
|
+
f"the checkpoint threshold is {threshold}. Pause before another feature coverage run "
|
|
738
|
+
"and review the larger product goal."
|
|
739
|
+
),
|
|
740
|
+
target=status,
|
|
741
|
+
priority=58,
|
|
742
|
+
blocking=False,
|
|
743
|
+
requires_human=True,
|
|
744
|
+
safe_to_run=False,
|
|
745
|
+
expected_after=(
|
|
746
|
+
"A checkpoint_review evidence record exists, and `pcl next` can resume normal goal routing."
|
|
747
|
+
),
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _unfinished_executor_next_action(paths: ProjectPaths) -> dict | None:
|
|
752
|
+
conn = connect(paths.db_path)
|
|
753
|
+
try:
|
|
754
|
+
placeholders = ", ".join("?" for _ in ACTIVE_RUN_STATUSES)
|
|
755
|
+
runs = conn.execute(
|
|
756
|
+
f"""
|
|
757
|
+
SELECT id, workflow_id, goal_id, status, iteration, started_at
|
|
758
|
+
FROM workflow_runs
|
|
759
|
+
WHERE status IN ({placeholders})
|
|
760
|
+
ORDER BY started_at DESC, id DESC
|
|
761
|
+
""",
|
|
762
|
+
tuple(sorted(ACTIVE_RUN_STATUSES)),
|
|
763
|
+
).fetchall()
|
|
764
|
+
for run in runs:
|
|
765
|
+
latest_event = conn.execute(
|
|
766
|
+
"""
|
|
767
|
+
SELECT event_type, rowid
|
|
768
|
+
FROM events
|
|
769
|
+
WHERE entity_type = 'workflow_run'
|
|
770
|
+
AND entity_id = ?
|
|
771
|
+
AND event_type IN (
|
|
772
|
+
'workflow_execution_started',
|
|
773
|
+
'workflow_execution_resumed',
|
|
774
|
+
'workflow_execution_finished'
|
|
775
|
+
)
|
|
776
|
+
ORDER BY rowid DESC
|
|
777
|
+
LIMIT 1
|
|
778
|
+
""",
|
|
779
|
+
(run["id"],),
|
|
780
|
+
).fetchone()
|
|
781
|
+
if latest_event is None or latest_event["event_type"] == "workflow_execution_finished":
|
|
782
|
+
continue
|
|
783
|
+
target = dict(run)
|
|
784
|
+
target["latest_executor_event"] = latest_event["event_type"]
|
|
785
|
+
target["latest_executor_event_rowid"] = latest_event["rowid"]
|
|
786
|
+
return build_next_action(
|
|
787
|
+
action_type="resume_workflow_execution",
|
|
788
|
+
command=f"pcl loop execute {run['workflow_id']} --resume {run['id']}",
|
|
789
|
+
reason="An executor-owned workflow run is active without a finished execution event.",
|
|
790
|
+
target=target,
|
|
791
|
+
priority=35,
|
|
792
|
+
blocking=True,
|
|
793
|
+
requires_human=False,
|
|
794
|
+
safe_to_run=False,
|
|
795
|
+
expected_after="The existing workflow run has workflow execution evidence and a terminal status or next verification step.",
|
|
796
|
+
)
|
|
797
|
+
return None
|
|
798
|
+
finally:
|
|
799
|
+
conn.close()
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _failed_executor_retry_next_action(paths: ProjectPaths) -> dict | None:
|
|
803
|
+
conn = connect(paths.db_path)
|
|
804
|
+
try:
|
|
805
|
+
retried_run_ids = _retried_workflow_run_ids(conn)
|
|
806
|
+
rows = conn.execute(
|
|
807
|
+
"""
|
|
808
|
+
SELECT id, workflow_id, goal_id, status, iteration, started_at, ended_at, summary
|
|
809
|
+
FROM workflow_runs
|
|
810
|
+
WHERE status = 'failed'
|
|
811
|
+
ORDER BY ended_at DESC, started_at DESC, id DESC
|
|
812
|
+
"""
|
|
813
|
+
).fetchall()
|
|
814
|
+
for row in rows:
|
|
815
|
+
run_id = str(row["id"])
|
|
816
|
+
if run_id in retried_run_ids:
|
|
817
|
+
continue
|
|
818
|
+
finished = conn.execute(
|
|
819
|
+
"""
|
|
820
|
+
SELECT payload_json, rowid
|
|
821
|
+
FROM events
|
|
822
|
+
WHERE entity_type = 'workflow_run'
|
|
823
|
+
AND entity_id = ?
|
|
824
|
+
AND event_type = 'workflow_execution_finished'
|
|
825
|
+
ORDER BY rowid DESC
|
|
826
|
+
LIMIT 1
|
|
827
|
+
""",
|
|
828
|
+
(run_id,),
|
|
829
|
+
).fetchone()
|
|
830
|
+
if finished is None:
|
|
831
|
+
continue
|
|
832
|
+
payload = _parse_event_payload(str(finished["payload_json"] or "{}"))
|
|
833
|
+
if payload.get("status") != "failed":
|
|
834
|
+
continue
|
|
835
|
+
target = dict(row)
|
|
836
|
+
target["executor_event_rowid"] = finished["rowid"]
|
|
837
|
+
target["failure_reason"] = payload.get("failure_reason") or row["summary"]
|
|
838
|
+
target["evidence_id"] = payload.get("evidence_id") or ""
|
|
839
|
+
return build_next_action(
|
|
840
|
+
action_type="retry_workflow_execution",
|
|
841
|
+
command=f"pcl loop execute {row['workflow_id']} --retry {run_id}",
|
|
842
|
+
reason="The latest unretried executor workflow run failed and can be retried explicitly.",
|
|
843
|
+
target=target,
|
|
844
|
+
priority=45,
|
|
845
|
+
blocking=False,
|
|
846
|
+
requires_human=False,
|
|
847
|
+
safe_to_run=False,
|
|
848
|
+
expected_after="A new workflow run is linked to the failed run and records fresh execution evidence.",
|
|
849
|
+
)
|
|
850
|
+
return None
|
|
851
|
+
finally:
|
|
852
|
+
conn.close()
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _retried_workflow_run_ids(conn) -> set[str]:
|
|
856
|
+
rows = conn.execute(
|
|
857
|
+
"""
|
|
858
|
+
SELECT payload_json
|
|
859
|
+
FROM events
|
|
860
|
+
WHERE event_type = 'workflow_execution_retried'
|
|
861
|
+
ORDER BY rowid
|
|
862
|
+
"""
|
|
863
|
+
).fetchall()
|
|
864
|
+
retried: set[str] = set()
|
|
865
|
+
for row in rows:
|
|
866
|
+
payload = _parse_event_payload(str(row["payload_json"] or "{}"))
|
|
867
|
+
retry_of = payload.get("retry_of_workflow_run_id")
|
|
868
|
+
if isinstance(retry_of, str) and retry_of:
|
|
869
|
+
retried.add(retry_of)
|
|
870
|
+
return retried
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _parse_event_payload(payload_json: str) -> dict:
|
|
874
|
+
try:
|
|
875
|
+
payload = json.loads(payload_json)
|
|
876
|
+
except JSONDecodeError:
|
|
877
|
+
return {}
|
|
878
|
+
return payload if isinstance(payload, dict) else {}
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _active_workflow_next_action(paths: ProjectPaths) -> dict | None:
|
|
882
|
+
conn = connect(paths.db_path)
|
|
883
|
+
try:
|
|
884
|
+
placeholders = ", ".join("?" for _ in ACTIVE_RUN_STATUSES)
|
|
885
|
+
run = conn.execute(
|
|
886
|
+
f"""
|
|
887
|
+
SELECT id, workflow_id, goal_id, status
|
|
888
|
+
FROM workflow_runs
|
|
889
|
+
WHERE status IN ({placeholders})
|
|
890
|
+
ORDER BY started_at DESC, id DESC
|
|
891
|
+
LIMIT 1
|
|
892
|
+
""",
|
|
893
|
+
tuple(sorted(ACTIVE_RUN_STATUSES)),
|
|
894
|
+
).fetchone()
|
|
895
|
+
if run is None:
|
|
896
|
+
return None
|
|
897
|
+
|
|
898
|
+
job_placeholders = ", ".join("?" for _ in ACTIVE_JOB_STATUSES)
|
|
899
|
+
active_job = conn.execute(
|
|
900
|
+
f"""
|
|
901
|
+
SELECT id, role, status
|
|
902
|
+
FROM agent_jobs
|
|
903
|
+
WHERE workflow_run_id = ? AND status IN ({job_placeholders})
|
|
904
|
+
ORDER BY id
|
|
905
|
+
LIMIT 1
|
|
906
|
+
""",
|
|
907
|
+
(run["id"], *tuple(sorted(ACTIVE_JOB_STATUSES))),
|
|
908
|
+
).fetchone()
|
|
909
|
+
target = dict(run)
|
|
910
|
+
if active_job is not None:
|
|
911
|
+
target["job"] = dict(active_job)
|
|
912
|
+
return build_next_action(
|
|
913
|
+
action_type="continue_workflow",
|
|
914
|
+
command=f"pcl jobs read {active_job['id']}",
|
|
915
|
+
reason="A workflow run is already active and has queued or running jobs.",
|
|
916
|
+
target=target,
|
|
917
|
+
priority=40,
|
|
918
|
+
blocking=False,
|
|
919
|
+
requires_human=False,
|
|
920
|
+
safe_to_run=True,
|
|
921
|
+
expected_after="The agent job prompt is reviewed and the job can be executed or completed.",
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
job_statuses = _job_status_counts(conn, str(run["id"]))
|
|
925
|
+
failed_or_cancelled = {
|
|
926
|
+
status: count
|
|
927
|
+
for status, count in job_statuses.items()
|
|
928
|
+
if status in TERMINAL_JOB_STATUSES and status != "passed" and count
|
|
929
|
+
}
|
|
930
|
+
target["job_statuses"] = job_statuses
|
|
931
|
+
if failed_or_cancelled:
|
|
932
|
+
return build_next_action(
|
|
933
|
+
action_type="resolve_workflow_failure",
|
|
934
|
+
command=f"pcl loop fail {run['id']} --summary 'Explain why this run failed'",
|
|
935
|
+
reason="The active workflow has failed or cancelled jobs.",
|
|
936
|
+
target=target,
|
|
937
|
+
priority=40,
|
|
938
|
+
blocking=True,
|
|
939
|
+
requires_human=False,
|
|
940
|
+
safe_to_run=False,
|
|
941
|
+
expected_after="The active workflow is marked failed or otherwise resolved.",
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
approved = conn.execute(
|
|
945
|
+
"SELECT id FROM verifications WHERE workflow_run_id = ? AND result = 'approved' ORDER BY created_at DESC LIMIT 1",
|
|
946
|
+
(run["id"],),
|
|
947
|
+
).fetchone()
|
|
948
|
+
if approved is None:
|
|
949
|
+
return build_next_action(
|
|
950
|
+
action_type="record_verification",
|
|
951
|
+
command=f"pcl verification record --run {run['id']} --result approved --reason 'Summarize verification evidence'",
|
|
952
|
+
reason="All active workflow jobs are terminal, but no approved verification exists.",
|
|
953
|
+
target=target,
|
|
954
|
+
priority=40,
|
|
955
|
+
blocking=True,
|
|
956
|
+
requires_human=True,
|
|
957
|
+
safe_to_run=False,
|
|
958
|
+
expected_after="An approved, rejected, inconclusive, or needs_human verification exists for the run.",
|
|
959
|
+
)
|
|
960
|
+
target["verification_id"] = approved["id"]
|
|
961
|
+
return build_next_action(
|
|
962
|
+
action_type="complete_workflow",
|
|
963
|
+
command=f"pcl loop complete {run['id']} --summary 'Summarize completed workflow'",
|
|
964
|
+
reason="The active workflow has passed jobs and an approved verification.",
|
|
965
|
+
target=target,
|
|
966
|
+
priority=40,
|
|
967
|
+
blocking=False,
|
|
968
|
+
requires_human=False,
|
|
969
|
+
safe_to_run=False,
|
|
970
|
+
expected_after="The workflow run is marked passed.",
|
|
971
|
+
)
|
|
972
|
+
finally:
|
|
973
|
+
conn.close()
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _job_status_counts(conn, workflow_run_id: str) -> dict[str, int]:
|
|
977
|
+
rows = conn.execute(
|
|
978
|
+
"SELECT status, COUNT(*) AS count FROM agent_jobs WHERE workflow_run_id = ? GROUP BY status",
|
|
979
|
+
(workflow_run_id,),
|
|
980
|
+
).fetchall()
|
|
981
|
+
return {str(row["status"]): int(row["count"]) for row in rows}
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def to_pretty_json(value: object) -> str:
|
|
985
|
+
return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _require_feature_status(status: str) -> None:
|
|
989
|
+
if status not in FEATURE_STATUSES:
|
|
990
|
+
raise InvalidInputError(
|
|
991
|
+
f"Invalid feature status: {status}",
|
|
992
|
+
details={"status": status, "allowed": sorted(FEATURE_STATUSES)},
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def _require_text(value: str, message: str) -> None:
|
|
997
|
+
if not value.strip():
|
|
998
|
+
raise InvalidInputError(message)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _validate_identifier(value: str, field_name: str) -> None:
|
|
1002
|
+
if not value or not all(c.isalnum() or c in {"_", "-"} for c in value):
|
|
1003
|
+
raise InvalidInputError(
|
|
1004
|
+
f"Invalid {field_name}: {value}",
|
|
1005
|
+
details={"field": field_name, "value": value},
|
|
1006
|
+
)
|