qaas-python 0.1.0__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.
- qaas/adapters/__init__.py +19 -0
- qaas/adapters/tracker.py +1350 -0
- qaas/adapters/vcs.py +494 -0
- qaas/cli.py +1564 -0
- qaas/conductor.py +527 -0
- qaas/config.py +407 -0
- qaas/defaults/config/agents/arbiter.yaml +19 -0
- qaas/defaults/config/agents/cartographer.yaml +20 -0
- qaas/defaults/config/agents/clerk.yaml +21 -0
- qaas/defaults/config/agents/conduit.yaml +19 -0
- qaas/defaults/config/agents/forge.yaml +22 -0
- qaas/defaults/config/agents/mender.yaml +56 -0
- qaas/defaults/config/agents/proof.yaml +21 -0
- qaas/defaults/config/agents/surface.yaml +16 -0
- qaas/defaults/config/system.yaml +69 -0
- qaas/discover.py +227 -0
- qaas/envelope.py +290 -0
- qaas/guardrails.py +431 -0
- qaas/mcp/__init__.py +0 -0
- qaas/mcp/context.py +70 -0
- qaas/mcp/contract_diff.py +937 -0
- qaas/mcp/defect_memory.py +495 -0
- qaas/mcp/env_control.py +905 -0
- qaas/mcp/envelope_server.py +463 -0
- qaas/mcp/test_runner.py +773 -0
- qaas/mcp/tracker.py +412 -0
- qaas/mcp/vcs.py +506 -0
- qaas/paths.py +317 -0
- qaas/plugin/.claude-plugin/plugin.json +9 -0
- qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
- qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
- qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
- qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
- qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
- qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
- qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
- qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
- qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
- qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
- qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
- qaas/plugin/skills/flake-detection/SKILL.md +39 -0
- qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
- qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
- qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
- qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
- qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
- qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
- qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
- qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
- qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
- qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
- qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
- qaas/plugin/skills/routing-rules/SKILL.md +34 -0
- qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
- qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
- qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
- qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
- qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
- qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
- qaas/prompts/ARBITER.md +53 -0
- qaas/prompts/CARTOGRAPHER.md +46 -0
- qaas/prompts/CLERK.md +45 -0
- qaas/prompts/CONDUIT.md +44 -0
- qaas/prompts/FORGE.md +43 -0
- qaas/prompts/MENDER.md +55 -0
- qaas/prompts/PROOF.md +41 -0
- qaas/prompts/SURFACE.md +46 -0
- qaas/prompts/_shared.md +45 -0
- qaas/registry.py +465 -0
- qaas/runner.py +192 -0
- qaas/scorecard.py +425 -0
- qaas/sdk_compat.py +52 -0
- qaas/store.py +290 -0
- qaas/target.py +261 -0
- qaas/tasks.py +361 -0
- qaas/trace.py +270 -0
- qaas_python-0.1.0.dist-info/METADATA +388 -0
- qaas_python-0.1.0.dist-info/RECORD +81 -0
- qaas_python-0.1.0.dist-info/WHEEL +4 -0
- qaas_python-0.1.0.dist-info/entry_points.txt +2 -0
- qaas_python-0.1.0.dist-info/licenses/LICENSE +21 -0
qaas/conductor.py
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
"""CONDUCTOR — the run state machine.
|
|
2
|
+
|
|
3
|
+
Deliberately not an LLM. §4.1 wants its reasoning shallow (routing, not analysis)
|
|
4
|
+
and §10 makes it the enforcement point for budget and concurrency — and a model
|
|
5
|
+
cannot enforce a budget it is itself spending. Everything here is ordinary code:
|
|
6
|
+
dispatch, phase ordering, concurrency limits, the spend governor, the §8.3 loop
|
|
7
|
+
breakers, retries and escalation.
|
|
8
|
+
|
|
9
|
+
The phases exist because the dependencies are real, not for tidiness:
|
|
10
|
+
|
|
11
|
+
map -> discover -> reproduce -> file
|
|
12
|
+
|
|
13
|
+
Discovery cannot start without the map. Triage cannot start without findings.
|
|
14
|
+
Within a phase, agents are independent and run concurrently up to the mode's cap.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import subprocess
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Callable
|
|
25
|
+
|
|
26
|
+
from qaas.config import AgentSpec, SystemConfig, load_config
|
|
27
|
+
from qaas.envelope import DefectEnvelope
|
|
28
|
+
from qaas.mcp.context import ToolContext
|
|
29
|
+
from qaas.runner import RunOutcome, run_agent
|
|
30
|
+
from qaas.store import RunStore, SystemMapStore
|
|
31
|
+
from qaas import tasks
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BudgetExceeded(RuntimeError):
|
|
35
|
+
"""The run hit its spend or wall-clock cap. Not an error — a control working."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def target_revision(root: Path | str | None) -> dict[str, Any]:
|
|
39
|
+
"""What commit of the target this run is looking at, for `run_started`.
|
|
40
|
+
|
|
41
|
+
Without this a run is not pinned to any code: the ledger said which agents
|
|
42
|
+
ran and what they spent, but nothing said *what they read*, so a finding
|
|
43
|
+
could never be replayed against the tree that produced it. Now `qaas show`
|
|
44
|
+
can name the commit.
|
|
45
|
+
|
|
46
|
+
`dirty` is not decoration — a run against an edited working tree is not
|
|
47
|
+
pinned by its sha either, and that has to be visible rather than implied.
|
|
48
|
+
|
|
49
|
+
Never raises. A target that is not a git checkout (or has no git at all) is
|
|
50
|
+
an ordinary, supported state: the fields come back None and the run
|
|
51
|
+
proceeds. Provenance is worth recording, never worth failing a run for.
|
|
52
|
+
"""
|
|
53
|
+
if root is None:
|
|
54
|
+
return {"target_root": None, "target_sha": None, "target_dirty": None}
|
|
55
|
+
root = Path(root)
|
|
56
|
+
info: dict[str, Any] = {"target_root": str(root), "target_sha": None, "target_dirty": None}
|
|
57
|
+
|
|
58
|
+
def git(*args: str) -> str | None:
|
|
59
|
+
try:
|
|
60
|
+
proc = subprocess.run(
|
|
61
|
+
["git", "-C", str(root), *args],
|
|
62
|
+
capture_output=True, text=True, timeout=10, check=False,
|
|
63
|
+
)
|
|
64
|
+
except (OSError, subprocess.SubprocessError):
|
|
65
|
+
return None
|
|
66
|
+
return proc.stdout if proc.returncode == 0 else None
|
|
67
|
+
|
|
68
|
+
sha = git("rev-parse", "HEAD")
|
|
69
|
+
if sha is None:
|
|
70
|
+
return info # not a repo, no git, or an empty repo with no commits yet
|
|
71
|
+
info["target_sha"] = sha.strip()
|
|
72
|
+
status = git("status", "--porcelain")
|
|
73
|
+
if status is not None:
|
|
74
|
+
info["target_dirty"] = bool(status.strip())
|
|
75
|
+
branch = git("rev-parse", "--abbrev-ref", "HEAD")
|
|
76
|
+
if branch:
|
|
77
|
+
info["target_branch"] = branch.strip()
|
|
78
|
+
return info
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class RunReport:
|
|
83
|
+
run_id: str
|
|
84
|
+
mode: str
|
|
85
|
+
outcomes: list[RunOutcome] = field(default_factory=list)
|
|
86
|
+
escalations: list[str] = field(default_factory=list)
|
|
87
|
+
stopped_early: str | None = None
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def cost_usd(self) -> float:
|
|
91
|
+
return sum(o.result.cost_usd for o in self.outcomes)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def failed(self) -> list[str]:
|
|
95
|
+
return [o.result.agent for o in self.outcomes if not o.ok]
|
|
96
|
+
|
|
97
|
+
def summary(self) -> dict[str, Any]:
|
|
98
|
+
return {
|
|
99
|
+
"run_id": self.run_id,
|
|
100
|
+
"mode": self.mode,
|
|
101
|
+
"agents_run": len(self.outcomes),
|
|
102
|
+
"failed": self.failed,
|
|
103
|
+
"cost_usd": round(self.cost_usd, 4),
|
|
104
|
+
"escalations": self.escalations,
|
|
105
|
+
"stopped_early": self.stopped_early,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Budget:
|
|
110
|
+
"""The spend and wall-clock governor. Checked before every dispatch."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, max_usd: float, max_seconds: int, *, already_spent: float = 0.0):
|
|
113
|
+
self.max_usd = max_usd
|
|
114
|
+
self.max_seconds = max_seconds
|
|
115
|
+
#: What this run has already cost, including earlier invocations.
|
|
116
|
+
#:
|
|
117
|
+
#: A resumed run (`qaas run --run-id <existing>`) used to start the
|
|
118
|
+
#: counter at zero, so the cap was per *invocation*, not per run --
|
|
119
|
+
#: resume three times against a $20 mode and you could spend $60 while
|
|
120
|
+
#: every individual pass reported itself within budget. One real run
|
|
121
|
+
#: shows the effect: `cost $32.90 of $20.00 budget`. The wall clock is
|
|
122
|
+
#: deliberately NOT carried across: it measures this process, and a run
|
|
123
|
+
#: resumed the next morning has not been running all night.
|
|
124
|
+
self.spent = already_spent
|
|
125
|
+
self.started = time.monotonic()
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def elapsed(self) -> float:
|
|
129
|
+
return time.monotonic() - self.started
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def remaining_usd(self) -> float:
|
|
133
|
+
return max(0.0, self.max_usd - self.spent)
|
|
134
|
+
|
|
135
|
+
def spend(self, amount: float) -> None:
|
|
136
|
+
self.spent += amount
|
|
137
|
+
|
|
138
|
+
def check(self) -> None:
|
|
139
|
+
if self.spent >= self.max_usd:
|
|
140
|
+
raise BudgetExceeded(f"spend cap reached: ${self.spent:.2f} of ${self.max_usd:.2f}")
|
|
141
|
+
if self.elapsed >= self.max_seconds:
|
|
142
|
+
raise BudgetExceeded(
|
|
143
|
+
f"wall-clock cap reached: {self.elapsed:.0f}s of {self.max_seconds}s"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def allowance(self, spec: AgentSpec) -> float:
|
|
147
|
+
"""What this agent may spend: its own cap, or what the run has left."""
|
|
148
|
+
return max(0.01, min(spec.max_budget_usd, self.remaining_usd))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Conductor:
|
|
152
|
+
"""Owns one run from trigger to report."""
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
config: SystemConfig,
|
|
157
|
+
target_root: Path | None = None,
|
|
158
|
+
*,
|
|
159
|
+
root: Path | str = ".qaas",
|
|
160
|
+
on_event: Callable[[str, dict[str, Any]], None] | None = None,
|
|
161
|
+
tickets: list[str] | None = None,
|
|
162
|
+
):
|
|
163
|
+
self.config = config
|
|
164
|
+
#: The application under test. Defaults to whatever the active profile
|
|
165
|
+
#: says, which is the answer every caller wants; an explicit path is for
|
|
166
|
+
#: tests and for a run pointed at a clone that has no profile yet.
|
|
167
|
+
self.target_root = Path(target_root) if target_root is not None else config.target_root()
|
|
168
|
+
self.maps = SystemMapStore(root)
|
|
169
|
+
self.root = Path(root)
|
|
170
|
+
self.on_event = on_event
|
|
171
|
+
#: When set, a fix cycle works only these tickets. Verifying ten tickets
|
|
172
|
+
#: costs ten times as much as verifying one, and during development you
|
|
173
|
+
#: almost always want one.
|
|
174
|
+
self.tickets = set(tickets) if tickets else None
|
|
175
|
+
|
|
176
|
+
def _target_root(self) -> Path | None:
|
|
177
|
+
"""The checkout under examination, or None when nothing is configured.
|
|
178
|
+
|
|
179
|
+
Deliberately the same value the agents' tools are pointed at, so the
|
|
180
|
+
commit recorded in the ledger is the commit they actually read. This
|
|
181
|
+
used to compute `self.repo_root / config.target_app`; both of those are
|
|
182
|
+
gone -- `repo_root` conflated the qaas project with the target, and
|
|
183
|
+
`target_app` was the demo-shaped default that made the conflation look
|
|
184
|
+
like it worked.
|
|
185
|
+
"""
|
|
186
|
+
return self.target_root
|
|
187
|
+
|
|
188
|
+
def _emit(self, kind: str, **detail: Any) -> None:
|
|
189
|
+
if self.on_event:
|
|
190
|
+
self.on_event(kind, detail)
|
|
191
|
+
|
|
192
|
+
def _context(self, store: RunStore, spec: AgentSpec, map_version: str | None) -> ToolContext:
|
|
193
|
+
return ToolContext(
|
|
194
|
+
store=store,
|
|
195
|
+
maps=self.maps,
|
|
196
|
+
config=self.config,
|
|
197
|
+
agent=spec,
|
|
198
|
+
target_root=self.target_root,
|
|
199
|
+
map_version=map_version,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# -- the run ----------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
async def run(self, mode: str, *, run_id: str | None = None) -> RunReport:
|
|
205
|
+
specs = {s.name: s for s in self.config.enabled_agents(mode)}
|
|
206
|
+
run_mode = self.config.run_modes[mode]
|
|
207
|
+
store = RunStore(run_id, self.root) if run_id else RunStore.new(self.root)
|
|
208
|
+
# Carry forward what this run id has already spent, so a cap survives a
|
|
209
|
+
# resumption instead of resetting with it.
|
|
210
|
+
budget = Budget(
|
|
211
|
+
run_mode.max_budget_usd,
|
|
212
|
+
run_mode.max_wall_clock_s,
|
|
213
|
+
already_spent=store.total_cost_usd() if run_id else 0.0,
|
|
214
|
+
)
|
|
215
|
+
report = RunReport(run_id=store.run_id, mode=mode)
|
|
216
|
+
|
|
217
|
+
store.log(
|
|
218
|
+
"run_started",
|
|
219
|
+
mode=mode,
|
|
220
|
+
agents=sorted(specs),
|
|
221
|
+
budget_usd=run_mode.max_budget_usd,
|
|
222
|
+
wall_clock_s=run_mode.max_wall_clock_s,
|
|
223
|
+
**target_revision(self._target_root()),
|
|
224
|
+
)
|
|
225
|
+
self._emit("run_started", run_id=store.run_id, mode=mode, agents=sorted(specs))
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
map_version = await self._phase_map(specs, store, budget, report)
|
|
229
|
+
await self._phase_discover(specs, store, budget, report, mode, map_version)
|
|
230
|
+
await self._phase_reproduce(specs, store, budget, report, map_version)
|
|
231
|
+
if run_mode.files_tickets:
|
|
232
|
+
await self._phase_file(specs, store, budget, report, map_version)
|
|
233
|
+
else:
|
|
234
|
+
store.log("skipped", reason="mode does not file tickets", mode=mode)
|
|
235
|
+
await self._phase_verify(specs, store, budget, report, map_version)
|
|
236
|
+
except BudgetExceeded as exc:
|
|
237
|
+
report.stopped_early = str(exc)
|
|
238
|
+
report.escalations.append(str(exc))
|
|
239
|
+
store.log("escalation", reason=str(exc))
|
|
240
|
+
self._emit("stopped", reason=str(exc))
|
|
241
|
+
|
|
242
|
+
store.log("run_finished", **report.summary())
|
|
243
|
+
self._emit("run_finished", **report.summary())
|
|
244
|
+
return report
|
|
245
|
+
|
|
246
|
+
# -- phases -----------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
async def _phase_map(self, specs, store, budget, report) -> str | None:
|
|
249
|
+
"""Publish the map first. Everything downstream reads it."""
|
|
250
|
+
spec = specs.get("CARTOGRAPHER")
|
|
251
|
+
if spec is None:
|
|
252
|
+
return self.maps.latest_version()
|
|
253
|
+
|
|
254
|
+
budget.check()
|
|
255
|
+
before = self.maps.latest_version()
|
|
256
|
+
outcome = await self._dispatch(spec, store, budget, report, tasks.cartographer(self.config), None)
|
|
257
|
+
|
|
258
|
+
version = self.maps.latest_version()
|
|
259
|
+
if version == before or version is None:
|
|
260
|
+
# Everything downstream reads the map. A stale one is a worse failure
|
|
261
|
+
# than a missing one, so say plainly which we are running on.
|
|
262
|
+
note = "CARTOGRAPHER published no map" + (
|
|
263
|
+
f"; continuing on the previous map {before}" if before else "; no map exists"
|
|
264
|
+
)
|
|
265
|
+
report.escalations.append(note)
|
|
266
|
+
store.log("escalation", agent="CARTOGRAPHER", reason=note)
|
|
267
|
+
return version or before
|
|
268
|
+
|
|
269
|
+
async def _phase_discover(self, specs, store, budget, report, mode, map_version) -> None:
|
|
270
|
+
"""Discovery agents are independent. Run them concurrently, bounded."""
|
|
271
|
+
discovery = [s for name, s in specs.items() if s.layer == "discovery"]
|
|
272
|
+
if not discovery:
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
builders = {
|
|
276
|
+
"CONDUIT": lambda: tasks.conduit(self.config, mode),
|
|
277
|
+
"SURFACE": lambda: tasks.surface(self.config, mode),
|
|
278
|
+
}
|
|
279
|
+
jobs = [
|
|
280
|
+
(spec, builders[spec.name]())
|
|
281
|
+
for spec in discovery
|
|
282
|
+
if spec.name in builders
|
|
283
|
+
]
|
|
284
|
+
unknown = [s.name for s in discovery if s.name not in builders]
|
|
285
|
+
if unknown:
|
|
286
|
+
store.log("skipped", reason="no task builder", agents=unknown)
|
|
287
|
+
|
|
288
|
+
await self._gather(jobs, store, budget, report, map_version, self.config.run_modes[mode].max_concurrency)
|
|
289
|
+
|
|
290
|
+
async def _phase_reproduce(self, specs, store, budget, report, map_version) -> None:
|
|
291
|
+
"""One FORGE invocation per finding.
|
|
292
|
+
|
|
293
|
+
Separate contexts on purpose: reproducing finding B should not inherit
|
|
294
|
+
whatever FORGE talked itself into while working on finding A.
|
|
295
|
+
"""
|
|
296
|
+
spec = specs.get("FORGE")
|
|
297
|
+
if spec is None:
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
drafts = [e for e in store.envelopes() if e.reproduction.status.value == "unattempted"]
|
|
301
|
+
if not drafts:
|
|
302
|
+
store.log("skipped", agent="FORGE", reason="no findings to reproduce")
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
cap = self.config.thresholds.max_findings_per_agent_run
|
|
306
|
+
if len(drafts) > cap:
|
|
307
|
+
note = f"{len(drafts)} findings exceed the per-run cap of {cap}; triaging the most severe"
|
|
308
|
+
report.escalations.append(note)
|
|
309
|
+
store.log("escalation", agent="FORGE", reason=note)
|
|
310
|
+
drafts = sorted(drafts, key=lambda e: (e.severity.rank, -e.confidence))[:cap]
|
|
311
|
+
|
|
312
|
+
jobs = [
|
|
313
|
+
(spec, tasks.forge(draft, self.config, self.config.thresholds.flake_runs))
|
|
314
|
+
for draft in drafts
|
|
315
|
+
]
|
|
316
|
+
await self._gather(jobs, store, budget, report, map_version, concurrency=2)
|
|
317
|
+
|
|
318
|
+
async def _phase_file(self, specs, store, budget, report, map_version) -> None:
|
|
319
|
+
spec = specs.get("CLERK")
|
|
320
|
+
if spec is None:
|
|
321
|
+
return
|
|
322
|
+
fileable = [
|
|
323
|
+
e for e in store.envelopes()
|
|
324
|
+
if e.is_fileable(self.config.thresholds.min_confidence_to_file)[0]
|
|
325
|
+
]
|
|
326
|
+
if not fileable:
|
|
327
|
+
store.log("skipped", agent="CLERK", reason="nothing passed the gates")
|
|
328
|
+
return
|
|
329
|
+
cap = min(spec.policy.max_tickets_per_run, self.config.thresholds.max_tickets_per_run)
|
|
330
|
+
await self._dispatch(spec, store, budget, report, tasks.clerk(self.config, cap), map_version)
|
|
331
|
+
|
|
332
|
+
async def _phase_verify(self, specs, store, budget, report, map_version) -> None:
|
|
333
|
+
spec = specs.get("PROOF")
|
|
334
|
+
if spec is None:
|
|
335
|
+
return
|
|
336
|
+
pending = [e for e in store.envelopes() if e.jira.key]
|
|
337
|
+
if self.tickets:
|
|
338
|
+
pending = [e for e in pending if e.jira.key in self.tickets]
|
|
339
|
+
unknown = self.tickets - {e.jira.key for e in store.envelopes() if e.jira.key}
|
|
340
|
+
if unknown:
|
|
341
|
+
store.log("skipped", reason="unknown tickets", tickets=sorted(unknown))
|
|
342
|
+
if not pending:
|
|
343
|
+
store.log("skipped", agent="PROOF", reason="no tickets to verify")
|
|
344
|
+
return
|
|
345
|
+
for envelope in pending:
|
|
346
|
+
budget.check()
|
|
347
|
+
await self._verify_loop(envelope, specs, store, budget, report, map_version)
|
|
348
|
+
|
|
349
|
+
async def _verify_loop(self, envelope, specs, store, budget, report, map_version) -> None:
|
|
350
|
+
"""PROOF -> NOT_FIXED -> remediate -> PROOF, bounded by §8.3.
|
|
351
|
+
|
|
352
|
+
The bound is the point. Without `max_proof_reopens` a fix that keeps
|
|
353
|
+
missing the defect cycles until the budget is gone, and the run ends with
|
|
354
|
+
no verdict and no money left to reach one. Escalating after one reopen
|
|
355
|
+
costs a human five minutes; not escalating costs the whole run.
|
|
356
|
+
"""
|
|
357
|
+
ticket = envelope.jira.key
|
|
358
|
+
max_reopens = self.config.thresholds.max_proof_reopens
|
|
359
|
+
reopens = 0
|
|
360
|
+
|
|
361
|
+
# The envelope names the *repro* branch, which by construction carries a
|
|
362
|
+
# failing test and no fix -- it is written before any fix exists. Sending
|
|
363
|
+
# PROOF back there after a remediation round made this loop unable to
|
|
364
|
+
# ever reach VERIFIED: MENDER would fix, ARBITER approve, and PROOF
|
|
365
|
+
# re-verify the unfixed branch it had just failed on, burn a reopen and
|
|
366
|
+
# escalate. A live run recorded exactly that ("this branch cannot carry
|
|
367
|
+
# a fix"). Where MENDER put the fix is only knowable after the fact, so
|
|
368
|
+
# it is read back out of the ledger below.
|
|
369
|
+
repro_branch = envelope.reproduction.environment.branch or "main"
|
|
370
|
+
fix_branch: str | None = None
|
|
371
|
+
|
|
372
|
+
while True:
|
|
373
|
+
await self._dispatch(
|
|
374
|
+
specs["PROOF"], store, budget, report,
|
|
375
|
+
tasks.proof(ticket, envelope, branch=fix_branch or repro_branch), map_version,
|
|
376
|
+
)
|
|
377
|
+
verdict = self._latest_verdict(store, ticket)
|
|
378
|
+
|
|
379
|
+
if verdict is None:
|
|
380
|
+
self._escalate(report, store, "PROOF",
|
|
381
|
+
f"{ticket}: PROOF returned no verdict; the ticket stays in review")
|
|
382
|
+
return
|
|
383
|
+
if verdict == "VERIFIED":
|
|
384
|
+
store.log("verified", agent="PROOF", ticket_key=ticket, reopens=reopens)
|
|
385
|
+
return
|
|
386
|
+
if verdict == "REGRESSED":
|
|
387
|
+
self._escalate(report, store, "PROOF",
|
|
388
|
+
f"{ticket}: REGRESSED — the fix broke something else; blocking for a human")
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
# NOT_FIXED from here.
|
|
392
|
+
if reopens >= max_reopens:
|
|
393
|
+
self._escalate(report, store, "PROOF",
|
|
394
|
+
f"{ticket}: still NOT_FIXED after {reopens} reopen(s), the limit. "
|
|
395
|
+
"Escalating rather than cycling further")
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
reopens += 1
|
|
399
|
+
store.log("reopened", agent="PROOF", ticket_key=ticket, attempt=reopens)
|
|
400
|
+
mark = len(list(store.ledger("vcs")))
|
|
401
|
+
if not await self._remediate(envelope, specs, store, budget, report, map_version):
|
|
402
|
+
return
|
|
403
|
+
# Keep the previous branch if this round wrote nothing: a re-verify
|
|
404
|
+
# of the last fix beats silently falling back to the repro branch.
|
|
405
|
+
fix_branch = self._branch_written_since(store, mark) or fix_branch
|
|
406
|
+
|
|
407
|
+
async def _remediate(self, envelope, specs, store, budget, report, map_version) -> bool:
|
|
408
|
+
"""MENDER -> ARBITER, bounded. Returns whether a fix is ready to re-verify.
|
|
409
|
+
|
|
410
|
+
Phase 3 agents. In a Phase 1 roster neither exists, so a NOT_FIXED
|
|
411
|
+
verdict escalates to a human immediately — which is correct, and much
|
|
412
|
+
better than the loop silently re-running PROOF against unchanged code.
|
|
413
|
+
"""
|
|
414
|
+
ticket = envelope.jira.key
|
|
415
|
+
mender, arbiter = specs.get("MENDER"), specs.get("ARBITER")
|
|
416
|
+
|
|
417
|
+
if mender is None:
|
|
418
|
+
self._escalate(report, store, "PROOF",
|
|
419
|
+
f"{ticket}: NOT_FIXED and no MENDER in this run's roster. "
|
|
420
|
+
"Nothing here can produce a fix; a human takes it from here")
|
|
421
|
+
return False
|
|
422
|
+
|
|
423
|
+
for trip in range(1, self.config.thresholds.max_mender_arbiter_round_trips + 1):
|
|
424
|
+
budget.check()
|
|
425
|
+
await self._dispatch(mender, store, budget, report,
|
|
426
|
+
tasks.mender(ticket, envelope), map_version)
|
|
427
|
+
if arbiter is None:
|
|
428
|
+
return True
|
|
429
|
+
|
|
430
|
+
await self._dispatch(arbiter, store, budget, report,
|
|
431
|
+
tasks.arbiter(ticket, envelope), map_version)
|
|
432
|
+
review = self._latest_review(store, ticket)
|
|
433
|
+
if review == "APPROVE":
|
|
434
|
+
return True
|
|
435
|
+
if review == "ESCALATE_TO_HUMAN":
|
|
436
|
+
self._escalate(report, store, "ARBITER", f"{ticket}: ARBITER escalated the fix")
|
|
437
|
+
return False
|
|
438
|
+
store.log("review_round_trip", agent="ARBITER", ticket_key=ticket, trip=trip)
|
|
439
|
+
|
|
440
|
+
self._escalate(report, store, "ARBITER",
|
|
441
|
+
f"{ticket}: {self.config.thresholds.max_mender_arbiter_round_trips} "
|
|
442
|
+
"MENDER/ARBITER round trips without approval; escalating")
|
|
443
|
+
return False
|
|
444
|
+
|
|
445
|
+
@staticmethod
|
|
446
|
+
def _branch_written_since(store, mark: int) -> str | None:
|
|
447
|
+
"""The branch MENDER actually wrote to during one remediation round.
|
|
448
|
+
|
|
449
|
+
Scoped to the ledger entries added since `mark` rather than searched
|
|
450
|
+
run-wide, because a run verifies several tickets against one ledger and
|
|
451
|
+
an earlier ticket's `fix/*` branch is the wrong answer here. The last
|
|
452
|
+
write wins: MENDER ends a successful round on `push` or `open_pr`.
|
|
453
|
+
"""
|
|
454
|
+
for entry in reversed(list(store.ledger("vcs"))[mark:]):
|
|
455
|
+
if entry.agent != "MENDER":
|
|
456
|
+
continue
|
|
457
|
+
branch = entry.detail.get("branch")
|
|
458
|
+
if branch:
|
|
459
|
+
return str(branch)
|
|
460
|
+
return None
|
|
461
|
+
|
|
462
|
+
@staticmethod
|
|
463
|
+
def _latest_verdict(store, ticket_key: str) -> str | None:
|
|
464
|
+
"""PROOF's verdict is a typed ledger entry, never parsed from prose."""
|
|
465
|
+
verdicts = [e for e in store.ledger("verdict") if e.detail.get("ticket_key") == ticket_key]
|
|
466
|
+
return verdicts[-1].detail.get("verdict") if verdicts else None
|
|
467
|
+
|
|
468
|
+
@staticmethod
|
|
469
|
+
def _latest_review(store, ticket_key: str) -> str | None:
|
|
470
|
+
reviews = [e for e in store.ledger("review") if e.detail.get("ticket_key") == ticket_key]
|
|
471
|
+
return reviews[-1].detail.get("decision") if reviews else None
|
|
472
|
+
|
|
473
|
+
def _escalate(self, report, store, agent: str, note: str) -> None:
|
|
474
|
+
report.escalations.append(note)
|
|
475
|
+
store.log("escalation", agent=agent, reason=note)
|
|
476
|
+
self._emit("escalation", agent=agent, reason=note)
|
|
477
|
+
|
|
478
|
+
# -- dispatch ---------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
async def _gather(self, jobs, store, budget, report, map_version, concurrency: int) -> None:
|
|
481
|
+
"""Run jobs concurrently, but stop dispatching once the budget is gone.
|
|
482
|
+
|
|
483
|
+
The semaphore bounds how many run at once; the budget check inside each
|
|
484
|
+
slot means a run that blows its cap stops starting new work rather than
|
|
485
|
+
letting everything already queued through.
|
|
486
|
+
"""
|
|
487
|
+
if not jobs:
|
|
488
|
+
return
|
|
489
|
+
sem = asyncio.Semaphore(max(1, concurrency))
|
|
490
|
+
stopped: list[str] = []
|
|
491
|
+
|
|
492
|
+
async def one(spec: AgentSpec, task: str) -> None:
|
|
493
|
+
async with sem:
|
|
494
|
+
if stopped:
|
|
495
|
+
return
|
|
496
|
+
try:
|
|
497
|
+
budget.check()
|
|
498
|
+
except BudgetExceeded as exc:
|
|
499
|
+
stopped.append(str(exc))
|
|
500
|
+
return
|
|
501
|
+
await self._dispatch(spec, store, budget, report, task, map_version)
|
|
502
|
+
|
|
503
|
+
await asyncio.gather(*(one(spec, task) for spec, task in jobs))
|
|
504
|
+
if stopped:
|
|
505
|
+
raise BudgetExceeded(stopped[0])
|
|
506
|
+
|
|
507
|
+
async def _dispatch(self, spec, store, budget, report, task, map_version) -> RunOutcome:
|
|
508
|
+
ctx = self._context(store, spec, map_version)
|
|
509
|
+
allowance = budget.allowance(spec)
|
|
510
|
+
|
|
511
|
+
self._emit("agent_started", agent=spec.name, budget=allowance)
|
|
512
|
+
outcome = await run_agent(
|
|
513
|
+
spec, ctx, task, max_budget_usd=allowance, on_event=self.on_event
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
budget.spend(outcome.result.cost_usd)
|
|
517
|
+
report.outcomes.append(outcome)
|
|
518
|
+
if not outcome.ok:
|
|
519
|
+
note = f"{spec.name} failed: {outcome.result.error or outcome.result.subtype}"
|
|
520
|
+
report.escalations.append(note)
|
|
521
|
+
store.log("escalation", agent=spec.name, reason=note)
|
|
522
|
+
return outcome
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def build(config_dir: Path | str = "config", root: Path | str = ".qaas") -> Conductor:
|
|
526
|
+
config = load_config(config_dir)
|
|
527
|
+
return Conductor(config, root=root)
|