ergane-cli 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.
- ergane_cli-0.1.0.dist-info/METADATA +148 -0
- ergane_cli-0.1.0.dist-info/RECORD +121 -0
- ergane_cli-0.1.0.dist-info/WHEEL +4 -0
- ergane_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ergane_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- ergane_cli-0.1.0.dist-info/licenses/NOTICE +27 -0
- factory/__init__.py +0 -0
- factory/activities/__init__.py +0 -0
- factory/activities/agent_activities.py +745 -0
- factory/activities/merge_activities.py +654 -0
- factory/activities/notify_activities.py +1078 -0
- factory/activities/roadmap_activities.py +577 -0
- factory/activities/usage_activities.py +593 -0
- factory/activities/verify_activities.py +617 -0
- factory/cli/__init__.py +0 -0
- factory/cli/completion.py +150 -0
- factory/cli/doctor.py +484 -0
- factory/cli/env.py +247 -0
- factory/cli/errors.py +77 -0
- factory/cli/init.py +1057 -0
- factory/cli/install.py +679 -0
- factory/cli/main.py +212 -0
- factory/cli/nouns/__init__.py +59 -0
- factory/cli/nouns/answer.py +134 -0
- factory/cli/nouns/build.py +1222 -0
- factory/cli/nouns/completion.py +13 -0
- factory/cli/nouns/doctor.py +13 -0
- factory/cli/nouns/env.py +13 -0
- factory/cli/nouns/escalations.py +93 -0
- factory/cli/nouns/findings.py +13 -0
- factory/cli/nouns/init.py +13 -0
- factory/cli/nouns/install.py +99 -0
- factory/cli/nouns/repo.py +13 -0
- factory/cli/nouns/roadmap.py +13 -0
- factory/cli/nouns/spec.py +511 -0
- factory/cli/nouns/status.py +18 -0
- factory/cli/nouns/usage.py +13 -0
- factory/cli/nouns/worker.py +69 -0
- factory/cli/repo.py +569 -0
- factory/cli/repo_export.py +282 -0
- factory/cli/roadmap.py +434 -0
- factory/cli/status.py +844 -0
- factory/cli/usage.py +129 -0
- factory/config.py +262 -0
- factory/controlplane/__init__.py +0 -0
- factory/controlplane/config.py +863 -0
- factory/controlplane/resolve.py +434 -0
- factory/controlplane/verify.py +827 -0
- factory/discovery/__init__.py +3 -0
- factory/discovery/llm_scanner.py +176 -0
- factory/doctor/__init__.py +0 -0
- factory/doctor/cli.py +392 -0
- factory/doctor/models.py +165 -0
- factory/doctor/probes.py +522 -0
- factory/doctor/scaffold.py +184 -0
- factory/doctor/store.py +449 -0
- factory/env.py +131 -0
- factory/escalation/__init__.py +0 -0
- factory/escalation/client.py +168 -0
- factory/escalation/question.py +161 -0
- factory/escalation/workflow.py +470 -0
- factory/locking.py +85 -0
- factory/mergequeue/__init__.py +21 -0
- factory/mergequeue/classify.py +133 -0
- factory/mergequeue/forge.py +401 -0
- factory/mergequeue/gh.py +484 -0
- factory/mergequeue/github_forge.py +349 -0
- factory/mergequeue/merge_queue_ruleset.json +32 -0
- factory/mergequeue/messages.py +141 -0
- factory/mergequeue/models.py +324 -0
- factory/mergequeue/onboard.py +537 -0
- factory/mergequeue/wiring.py +466 -0
- factory/notify/__init__.py +0 -0
- factory/notify/adapter.py +263 -0
- factory/notify/messages.py +474 -0
- factory/notify/service.py +809 -0
- factory/notify/webhook.py +151 -0
- factory/personas.yaml +161 -0
- factory/registry.py +583 -0
- factory/roadmap/__init__.py +8 -0
- factory/roadmap/cli.py +125 -0
- factory/roadmap/discovery.py +286 -0
- factory/roadmap/models.py +583 -0
- factory/roadmap/schedule.py +377 -0
- factory/roadmap/workflow.py +1364 -0
- factory/supervision/__init__.py +0 -0
- factory/supervision/alert.py +239 -0
- factory/supervision/probe.py +467 -0
- factory/supervision/temporal_server.py +121 -0
- factory/supervision/units.py +649 -0
- factory/usage/__init__.py +0 -0
- factory/usage/aggregate.py +113 -0
- factory/usage/cli.py +223 -0
- factory/usage/ledger.py +295 -0
- factory/usage/litellm_client.py +486 -0
- factory/usage/models.py +149 -0
- factory/verify/__init__.py +0 -0
- factory/verify/criteria.py +488 -0
- factory/verify/diffbounds.py +159 -0
- factory/verify/diffcheck.py +506 -0
- factory/verify/factory_yaml.py +842 -0
- factory/verify/gates.py +1341 -0
- factory/verify/judge.py +775 -0
- factory/verify/ladder.py +145 -0
- factory/verify/models.py +745 -0
- factory/verify/question.py +167 -0
- factory/verify/store.py +1366 -0
- factory/verify/toolchain.py +370 -0
- factory/worker.py +296 -0
- factory/workgraph/__init__.py +0 -0
- factory/workgraph/adapter.py +1333 -0
- factory/workgraph/cli.py +801 -0
- factory/workgraph/delta.py +278 -0
- factory/workgraph/derive.py +607 -0
- factory/workgraph/detector.py +509 -0
- factory/workgraph/landed.py +496 -0
- factory/workgraph/models.py +494 -0
- factory/workgraph/preflight.py +651 -0
- factory/workgraph/prompt.py +796 -0
- factory/workgraph/workflow.py +2733 -0
- factory/workgraph/worktree.py +1322 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
"""The activity surface of verification.
|
|
2
|
+
|
|
3
|
+
`factory/verify/` is a library of pure functions and one-purpose runners; this
|
|
4
|
+
module is where they become things the orchestrator can call, which means it
|
|
5
|
+
owns exactly two concerns the library deliberately does not: reading the world
|
|
6
|
+
at a known moment, and turning a library exception into an error a workflow can
|
|
7
|
+
branch on without reading prose.
|
|
8
|
+
|
|
9
|
+
`snapshot_criteria` is the first of those calls and the only one that runs
|
|
10
|
+
*before* an agent does any work. What it returns is the node's goalposts for the
|
|
11
|
+
rest of its life (FR-010) — every later attempt is judged against this value,
|
|
12
|
+
not against whatever the spec file says by then — so it stamps the moment it
|
|
13
|
+
read and hashes the bytes it read, and the difference between that hash and a
|
|
14
|
+
later one is the whole of drift detection (R8).
|
|
15
|
+
|
|
16
|
+
Its two failure modes are why the parser raises two different exception classes.
|
|
17
|
+
A spec the grammar refuses and a spec file that is not there both mean "this
|
|
18
|
+
node cannot be verified", but they send an operator to different places: one
|
|
19
|
+
goes and edits a requirement, the other fixes a wrong `specs_root` or a
|
|
20
|
+
misspelled feature. They come back as `CRITERIA_PARSE_FAILED` and
|
|
21
|
+
`CRITERIA_FILE_MISSING` so the workflow can tell them apart. Both are
|
|
22
|
+
non-retryable — neither a malformed spec nor an absent file becomes well-formed
|
|
23
|
+
by being read again a second later, and the ladder's retry budget exists for
|
|
24
|
+
proxies and worktrees, not for typos.
|
|
25
|
+
|
|
26
|
+
The parse error carries the parser's message verbatim. That message names the
|
|
27
|
+
one offending requirement and no sibling, and it is the operator's entire
|
|
28
|
+
diagnosis; an error that only said "spec did not parse" would make this activity
|
|
29
|
+
strictly less useful than the library call it wraps.
|
|
30
|
+
|
|
31
|
+
The four activities that follow verify an attempt once the agent has stopped, and
|
|
32
|
+
between them they draw the same line twice, in opposite directions:
|
|
33
|
+
|
|
34
|
+
- **A gate that fails is data.** `run_gates` returns one `GateResult` per declared
|
|
35
|
+
gate whatever they did, and one `CONFIG_ERROR` result for a manifest it cannot
|
|
36
|
+
use, because an exception here would cost the attempt the very output the retry
|
|
37
|
+
prompt quotes (FR-006) and an empty gate list is what a naive verdict reads as
|
|
38
|
+
"nothing failed" (SC-002).
|
|
39
|
+
- **A worktree that vanished is not.** `check_output` raises `WORKTREE_MISSING`
|
|
40
|
+
rather than reporting the clean worktree an absent directory resembles:
|
|
41
|
+
charging an infrastructure failure to the agent's attempt budget is the same
|
|
42
|
+
mistake as passing on an empty diff, pointed the other way. `run_judge` draws
|
|
43
|
+
the line in the same place — `JUDGE_UNAVAILABLE` is an outage, and whether it
|
|
44
|
+
becomes a gates-only PASS is `compose_result`'s decision, not this module's.
|
|
45
|
+
|
|
46
|
+
`record_verification` is the one write. It refuses a row it cannot attribute
|
|
47
|
+
before writing anything (the discipline component 1's teardown follows: every
|
|
48
|
+
rollup and every escalation summary reads this store by epic and node, so a row
|
|
49
|
+
missing one is not thin, it is absent from the query that would have shown the
|
|
50
|
+
gap), and it re-hashes the spec file to set `criteria_drift` when the caller has
|
|
51
|
+
not already found it — which is what makes drift a fact about the file rather
|
|
52
|
+
than a race.
|
|
53
|
+
|
|
54
|
+
Credentials are conspicuously absent from all of it. The judge authenticates with
|
|
55
|
+
the per-attempt virtual key component 1 minted, which arrives in the dispatch;
|
|
56
|
+
`LITELLM_MASTER_KEY` sits in the same worker environment and has no path into
|
|
57
|
+
these calls (FR-009).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
import asyncio
|
|
63
|
+
import hashlib
|
|
64
|
+
import os
|
|
65
|
+
from contextlib import closing
|
|
66
|
+
from dataclasses import dataclass, field, replace
|
|
67
|
+
from datetime import datetime, timezone
|
|
68
|
+
from pathlib import Path
|
|
69
|
+
|
|
70
|
+
import httpx
|
|
71
|
+
from temporalio import activity
|
|
72
|
+
from temporalio.exceptions import ApplicationError
|
|
73
|
+
|
|
74
|
+
from factory.verify import diffcheck, gates, judge, store
|
|
75
|
+
from factory.verify.criteria import CriteriaParseError, load_criteria
|
|
76
|
+
from factory.verify.judge import DEFAULT_MAX_JUDGE_RETRIES
|
|
77
|
+
from factory.verify.models import (
|
|
78
|
+
CriteriaSet,
|
|
79
|
+
GateResult,
|
|
80
|
+
JudgeVerdict,
|
|
81
|
+
OutputCheck,
|
|
82
|
+
VerificationResult,
|
|
83
|
+
)
|
|
84
|
+
from factory.env import (
|
|
85
|
+
ERGANE_VERIFICATION_DB_PATH_ENV,
|
|
86
|
+
FACTORY_VERIFICATION_DB_PATH_ENV,
|
|
87
|
+
resolve_env_path,
|
|
88
|
+
)
|
|
89
|
+
from factory.verify.question import (
|
|
90
|
+
QUESTION_HEADING, # noqa: F401 -- re-exported for the prompt contract (T008)
|
|
91
|
+
QuestionMarker,
|
|
92
|
+
TranscriptReadError,
|
|
93
|
+
detect_operator_question,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
#: The activity error type for a spec the grammar refuses (spec US1). The
|
|
97
|
+
#: message is the parser's, naming the one requirement at fault.
|
|
98
|
+
CRITERIA_PARSE_FAILED = "CRITERIA_PARSE_FAILED"
|
|
99
|
+
|
|
100
|
+
#: The activity error type for a spec file that is not where the dispatch said
|
|
101
|
+
#: it would be — a wiring mistake, not a spec to go fix.
|
|
102
|
+
CRITERIA_FILE_MISSING = "CRITERIA_FILE_MISSING"
|
|
103
|
+
|
|
104
|
+
#: The activity error type for a worktree that is not there, or that git refuses
|
|
105
|
+
#: to read — infrastructure, not a verdict (contracts/activities.md).
|
|
106
|
+
WORKTREE_MISSING = "WORKTREE_MISSING"
|
|
107
|
+
|
|
108
|
+
#: The activity error type for a proxy that stayed down through the judge's own
|
|
109
|
+
#: HTTP retries. Retryable, unlike every other error here: a backend that is down
|
|
110
|
+
#: now may be up in a minute.
|
|
111
|
+
JUDGE_UNAVAILABLE = "JUDGE_UNAVAILABLE"
|
|
112
|
+
|
|
113
|
+
#: The activity error type for a verification result no rollup could account for.
|
|
114
|
+
ATTRIBUTION_INCOMPLETE = "ATTRIBUTION_INCOMPLETE"
|
|
115
|
+
|
|
116
|
+
#: The activity error type for a transcript the marker detector could not read
|
|
117
|
+
#: (008-US1). Infrastructure, not a verdict: a vanished or unreadable archive must
|
|
118
|
+
#: never be read as a clean attempt that happened to ask nothing, so it raises the
|
|
119
|
+
#: way `WORKTREE_MISSING` does rather than returning "no question" (FR-010).
|
|
120
|
+
DETECT_FAILED = "DETECT_FAILED"
|
|
121
|
+
|
|
122
|
+
#: The system of record's filename under `<specs_root>/<feature>/` (D-023).
|
|
123
|
+
SPEC_FILENAME = "spec.md"
|
|
124
|
+
|
|
125
|
+
#: Where the evidence store lives when the worker does not say otherwise —
|
|
126
|
+
#: beside component 1's ledger under the same `.factory/` (quickstart §5).
|
|
127
|
+
DEFAULT_VERIFICATION_DB_PATH = ".factory/verification.db"
|
|
128
|
+
|
|
129
|
+
VERIFICATION_DB_PATH_ENV = "FACTORY_VERIFICATION_DB_PATH" # legacy re-export
|
|
130
|
+
ERGANE_VERIFICATION_DB_PATH_ENV = ERGANE_VERIFICATION_DB_PATH_ENV # re-export
|
|
131
|
+
|
|
132
|
+
#: Pause between the judge's HTTP attempts, at module scope so a test can zero it
|
|
133
|
+
#: without waiting out a backoff to prove an outage is an outage.
|
|
134
|
+
JUDGE_RETRY_BACKOFF_S = judge.DEFAULT_RETRY_BACKOFF_S
|
|
135
|
+
|
|
136
|
+
#: The dimensions the evidence store is queried by (FR-002, SC-005) — the ones
|
|
137
|
+
#: whose absence a reader cannot detect, because the row is simply not in the
|
|
138
|
+
#: answer they got.
|
|
139
|
+
_ATTRIBUTION_FIELDS = ("epic_id", "node_id", "spec_ref")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def judge_transport() -> httpx.AsyncBaseTransport | None:
|
|
143
|
+
"""The transport the judge's chat completion goes out over (None = the network).
|
|
144
|
+
|
|
145
|
+
A seam, not a factory: tests replace it to point the one HTTP call at a fake
|
|
146
|
+
proxy without supplying a credential, so the per-attempt virtual key still has
|
|
147
|
+
to arrive from the dispatch and a wrong one is still refused.
|
|
148
|
+
"""
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class SnapshotCriteriaInput:
|
|
154
|
+
"""A node's dispatch, in the terms the criteria parser needs.
|
|
155
|
+
|
|
156
|
+
`requirement_keys` is what this node owes — empty means the whole feature,
|
|
157
|
+
which is a legitimate dispatch and must not quietly become "no criteria"
|
|
158
|
+
(a node verified against nothing passes on an empty diff, the very failure
|
|
159
|
+
FR-004 exists to prevent). `spec_ref` is component 1's work-attribution key
|
|
160
|
+
and is carried through untouched: the activity has no business inventing one.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
specs_root: str
|
|
164
|
+
feature: str
|
|
165
|
+
spec_ref: str
|
|
166
|
+
requirement_keys: list[str] = field(default_factory=list)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@activity.defn
|
|
170
|
+
async def snapshot_criteria(request: SnapshotCriteriaInput) -> CriteriaSet:
|
|
171
|
+
"""Take the node's acceptance criteria from the spec, once (FR-010).
|
|
172
|
+
|
|
173
|
+
A pure read and parse, safe for Temporal to retry: two runs against
|
|
174
|
+
unchanged bytes agree on everything but the timestamp.
|
|
175
|
+
|
|
176
|
+
Raises `CRITERIA_FILE_MISSING` when there is no `spec.md` at the dispatched
|
|
177
|
+
path, `CRITERIA_PARSE_FAILED` when there is one and the grammar refuses it.
|
|
178
|
+
Both are non-retryable.
|
|
179
|
+
"""
|
|
180
|
+
path = spec_path(request.specs_root, request.feature)
|
|
181
|
+
try:
|
|
182
|
+
return load_criteria(
|
|
183
|
+
path,
|
|
184
|
+
feature=request.feature,
|
|
185
|
+
spec_ref=request.spec_ref,
|
|
186
|
+
requirement_keys=request.requirement_keys,
|
|
187
|
+
)
|
|
188
|
+
except (FileNotFoundError, NotADirectoryError, IsADirectoryError) as exc:
|
|
189
|
+
# Every one of these means the same thing to the operator: nothing
|
|
190
|
+
# readable at that path. The path is in the message because the root,
|
|
191
|
+
# the feature name and the filename are all suspects.
|
|
192
|
+
raise ApplicationError(
|
|
193
|
+
f"no feature spec at {path}",
|
|
194
|
+
type=CRITERIA_FILE_MISSING,
|
|
195
|
+
non_retryable=True,
|
|
196
|
+
) from exc
|
|
197
|
+
except CriteriaParseError as exc:
|
|
198
|
+
# Verbatim: the parser already named the offending requirement and said
|
|
199
|
+
# what is wrong with it, and that is the whole diagnosis.
|
|
200
|
+
raise ApplicationError(
|
|
201
|
+
str(exc),
|
|
202
|
+
type=CRITERIA_PARSE_FAILED,
|
|
203
|
+
non_retryable=True,
|
|
204
|
+
) from exc
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def spec_path(specs_root: str, feature: str) -> Path:
|
|
208
|
+
"""The file D-023 designates as the system of record for one feature."""
|
|
209
|
+
return Path(specs_root) / feature / SPEC_FILENAME
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# --- gates ------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass(frozen=True)
|
|
216
|
+
class RunGatesInput:
|
|
217
|
+
"""Which worktree to check, and which manifest says how.
|
|
218
|
+
|
|
219
|
+
`factory_yaml_path` defaults to the worktree's own committed manifest, which
|
|
220
|
+
is the only shape the factory dispatches; it is settable because conflating
|
|
221
|
+
the two would make an operator's manifest unreadable from anywhere else.
|
|
222
|
+
`timeout_overrides` is the verification config an operator is holding right
|
|
223
|
+
now, and it wins over what the repo declared.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
worktree_path: str
|
|
227
|
+
factory_yaml_path: str | None = None
|
|
228
|
+
timeout_overrides: dict[str, int] = field(default_factory=dict)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class _HeartbeatingExecutor:
|
|
232
|
+
"""A gate executor that tells Temporal which gate is starting, then delegates.
|
|
233
|
+
|
|
234
|
+
A repo's test suite may legitimately run for the full 600s per-gate deadline,
|
|
235
|
+
so an activity silent until the last gate finished would be killed by its own
|
|
236
|
+
heartbeat timeout long before it had an answer. Reporting sits in the
|
|
237
|
+
`GateExecutor` seam (R3) rather than inside the runner because progress is a
|
|
238
|
+
property of running the gates as an activity, not of running them at all.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
def __init__(
|
|
242
|
+
self, inner: gates.GateExecutor, loop: asyncio.AbstractEventLoop
|
|
243
|
+
) -> None:
|
|
244
|
+
self._inner = inner
|
|
245
|
+
self._loop = loop
|
|
246
|
+
|
|
247
|
+
def run(self, invocation: gates.GateInvocation) -> gates.ExecutionOutcome:
|
|
248
|
+
# This runs inside `asyncio.to_thread`'s worker thread. The activity
|
|
249
|
+
# context rides in on the copied contextvars, but an async activity's
|
|
250
|
+
# heartbeat must be delivered from the event-loop thread — called here
|
|
251
|
+
# it dies in `asyncio.create_task` with no running loop (found live
|
|
252
|
+
# 2026-08-05; the test environment's synchronous heartbeat cannot see
|
|
253
|
+
# the difference). `call_soon_threadsafe` re-enters the loop carrying
|
|
254
|
+
# this thread's context, activity context included — and delivers the
|
|
255
|
+
# beat while the gate below still owns this thread, which is the whole
|
|
256
|
+
# point of beating per gate.
|
|
257
|
+
self._loop.call_soon_threadsafe(activity.heartbeat, invocation.name)
|
|
258
|
+
return self._inner.run(invocation)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@activity.defn
|
|
262
|
+
async def run_gates(request: RunGatesInput) -> list[GateResult]:
|
|
263
|
+
"""Run the target repo's declared gates and report every one of them (FR-002).
|
|
264
|
+
|
|
265
|
+
Never raises for a gate: a non-zero exit, a deadline and an unusable
|
|
266
|
+
`factory.yaml` all come back as `GateResult`s, because failures are the
|
|
267
|
+
evidence the retry prompt quotes and the verdict truth table reads. A missing
|
|
268
|
+
or malformed manifest is one `CONFIG_ERROR` result, never zero results.
|
|
269
|
+
|
|
270
|
+
Runs in a worker thread: gates are subprocesses that own the wall clock for as
|
|
271
|
+
long as their deadline allows, and blocking the event loop for ten minutes
|
|
272
|
+
would stall every other activity this worker is running.
|
|
273
|
+
"""
|
|
274
|
+
return await asyncio.to_thread(
|
|
275
|
+
gates.run_gates,
|
|
276
|
+
request.worktree_path,
|
|
277
|
+
manifest_path=request.factory_yaml_path,
|
|
278
|
+
executor=_HeartbeatingExecutor(
|
|
279
|
+
gates.resolve_gate_executor(
|
|
280
|
+
request.worktree_path,
|
|
281
|
+
manifest_path=request.factory_yaml_path,
|
|
282
|
+
),
|
|
283
|
+
asyncio.get_running_loop(),
|
|
284
|
+
),
|
|
285
|
+
timeout_overrides=request.timeout_overrides,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# --- output check -----------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@dataclass(frozen=True)
|
|
293
|
+
class CheckOutputInput:
|
|
294
|
+
"""The anti-rubber-stamp question, in the terms the persona registry sets.
|
|
295
|
+
|
|
296
|
+
`write_scope` crosses this boundary as a plain string (component 1's
|
|
297
|
+
`WriteScope` value) and decides which half of the evidence is the criterion:
|
|
298
|
+
the diff, or the declared artifacts (R7).
|
|
299
|
+
|
|
300
|
+
`base_ref` is the node's branch point (D-027): "did the attempt do work" is
|
|
301
|
+
answered against where the node began, because an agent following the inner
|
|
302
|
+
ralph contract commits as it goes — against HEAD, a fully committed attempt
|
|
303
|
+
reads as no work at all, and committed out-of-scope changes read as none.
|
|
304
|
+
The default is R7's original semantics, correct only for a caller that
|
|
305
|
+
knows nothing committed mid-attempt; the epic workflow always passes the
|
|
306
|
+
prepared worktree's real base, and the interpreter suite asserts it does.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
worktree_path: str
|
|
310
|
+
write_scope: str
|
|
311
|
+
base_ref: str = "HEAD"
|
|
312
|
+
expected_artifacts: list[str] = field(default_factory=list)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@activity.defn
|
|
316
|
+
async def check_output(request: CheckOutputInput) -> OutputCheck:
|
|
317
|
+
"""Decide whether the node proved it did any work at all (FR-004).
|
|
318
|
+
|
|
319
|
+
Read-only, and safe to retry. Raises `WORKTREE_MISSING` when the worktree is
|
|
320
|
+
gone or git cannot read it: an absent directory resembles a clean worktree
|
|
321
|
+
exactly, and reporting one would charge an infrastructure failure to the
|
|
322
|
+
agent's attempt budget. It stays retryable for the same reason — a worktree
|
|
323
|
+
the worker could not reach this second is the workflow's retry budget to
|
|
324
|
+
spend, not the ladder's.
|
|
325
|
+
"""
|
|
326
|
+
try:
|
|
327
|
+
return await asyncio.to_thread(
|
|
328
|
+
diffcheck.check_output,
|
|
329
|
+
request.worktree_path,
|
|
330
|
+
request.write_scope,
|
|
331
|
+
request.expected_artifacts,
|
|
332
|
+
request.base_ref,
|
|
333
|
+
)
|
|
334
|
+
except diffcheck.WorktreeMissingError as exc:
|
|
335
|
+
# The path travels with the error: by the time an operator reads this
|
|
336
|
+
# they are holding a node id, not a directory.
|
|
337
|
+
raise ApplicationError(str(exc), type=WORKTREE_MISSING) from exc
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# --- operator-question detection (008-US1) ----------------------------------
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@dataclass(frozen=True)
|
|
344
|
+
class DetectQuestionInput:
|
|
345
|
+
"""The archived transcript the marker detector reads (D-018's evidence).
|
|
346
|
+
|
|
347
|
+
The detector reads ``stdout.log`` from the attempt's transcript directory —
|
|
348
|
+
the same ``AdapterResult.transcript_path`` the adapter points at the archive
|
|
349
|
+
on every termination path. The workflow owns the attribution (epic/node/
|
|
350
|
+
attempt) and the detector owns nothing but the read, so the only input it
|
|
351
|
+
needs is the path. No verdict travels in or out: the marker's only possible
|
|
352
|
+
effect is to park the node, never to grade it (FR-010).
|
|
353
|
+
"""
|
|
354
|
+
|
|
355
|
+
transcript_path: str
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@activity.defn
|
|
359
|
+
async def detect_operator_question_activity(
|
|
360
|
+
request: DetectQuestionInput,
|
|
361
|
+
) -> QuestionMarker:
|
|
362
|
+
"""Scan the archived transcript for the OPERATOR QUESTION marker (008-US1).
|
|
363
|
+
|
|
364
|
+
Read-only, and safe to retry. Raises ``DETECT_FAILED`` when the transcript is
|
|
365
|
+
missing or unreadable — the same line ``check_output`` draws for a vanished
|
|
366
|
+
worktree: an absent archive resembles a marker-free attempt exactly, and
|
|
367
|
+
reporting one would let an infrastructure failure masquerade as a clean
|
|
368
|
+
attempt that happened to ask nothing. ``None`` (no marker, or a marker with
|
|
369
|
+
an empty body) is the common case the workflow grades as today; an
|
|
370
|
+
unreadable archive is the one outcome that is neither QUESTION nor the common
|
|
371
|
+
case, and it stays retryable so the workflow's retry budget — not the
|
|
372
|
+
ladder's — spends it.
|
|
373
|
+
"""
|
|
374
|
+
try:
|
|
375
|
+
marker = await asyncio.to_thread(
|
|
376
|
+
detect_operator_question, Path(request.transcript_path)
|
|
377
|
+
)
|
|
378
|
+
except TranscriptReadError as exc:
|
|
379
|
+
raise ApplicationError(str(exc), type=DETECT_FAILED) from exc
|
|
380
|
+
return marker if marker is not None else QuestionMarker(is_question=False)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# --- judge ------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@dataclass(frozen=True)
|
|
387
|
+
class RunJudgeInput:
|
|
388
|
+
"""One bounded judge invocation (R4).
|
|
389
|
+
|
|
390
|
+
`virtual_key` is the per-attempt key component 1 minted for persona `judge`
|
|
391
|
+
and `model_alias` is that persona's registry alias — both arrive in the
|
|
392
|
+
dispatch, because this module reads no credential from the environment and
|
|
393
|
+
code never names a model (constitution V, VII). `judge_attempt` is the
|
|
394
|
+
caller's count: it is what turns an unreadable response into a retry now and
|
|
395
|
+
a FAIL once the cap is reached (SC-003).
|
|
396
|
+
"""
|
|
397
|
+
|
|
398
|
+
criteria: CriteriaSet
|
|
399
|
+
diff_text: str
|
|
400
|
+
virtual_key: str
|
|
401
|
+
proxy_url: str
|
|
402
|
+
model_alias: str
|
|
403
|
+
judge_attempt: int = 1
|
|
404
|
+
prior_feedback: str | None = None
|
|
405
|
+
max_judge_retries: int = DEFAULT_MAX_JUDGE_RETRIES
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@activity.defn
|
|
409
|
+
async def run_judge(request: RunJudgeInput) -> JudgeVerdict:
|
|
410
|
+
"""Score the node's diff against its dispatched scenarios (FR-003).
|
|
411
|
+
|
|
412
|
+
Called only when `judge_required` says a verdict is still open — a node whose
|
|
413
|
+
lint gate failed in two seconds must not cost a completion to find that out.
|
|
414
|
+
|
|
415
|
+
A response the strict parser refuses is not an error: it consumes one judge
|
|
416
|
+
attempt and comes back as RETRY, or as FAIL once `judge_attempt` has reached
|
|
417
|
+
the cap, carrying the parse failure as the next prompt's feedback. Only an
|
|
418
|
+
unreachable proxy raises, as `JUDGE_UNAVAILABLE`, and it is retryable —
|
|
419
|
+
falling back to a gates-only verdict is `compose_result`'s decision to make
|
|
420
|
+
after the workflow's retry budget is spent, not this activity's.
|
|
421
|
+
"""
|
|
422
|
+
try:
|
|
423
|
+
return await judge.run_judge(
|
|
424
|
+
request.criteria,
|
|
425
|
+
request.diff_text,
|
|
426
|
+
proxy_url=request.proxy_url,
|
|
427
|
+
virtual_key=request.virtual_key,
|
|
428
|
+
model_alias=request.model_alias,
|
|
429
|
+
prior_feedback=request.prior_feedback,
|
|
430
|
+
judge_attempt=request.judge_attempt,
|
|
431
|
+
max_judge_retries=request.max_judge_retries,
|
|
432
|
+
transport=judge_transport(),
|
|
433
|
+
retry_backoff_s=JUDGE_RETRY_BACKOFF_S,
|
|
434
|
+
)
|
|
435
|
+
except judge.JudgeUnavailableError as exc:
|
|
436
|
+
# The library already scrubbed its own message; nothing is added here
|
|
437
|
+
# that was not already safe to write down (FR-009).
|
|
438
|
+
raise ApplicationError(str(exc), type=JUDGE_UNAVAILABLE) from exc
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# --- recording --------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@dataclass(frozen=True)
|
|
445
|
+
class RecordVerificationInput:
|
|
446
|
+
"""One attempt's composed evidence, plus the spec file to re-hash.
|
|
447
|
+
|
|
448
|
+
`criteria_source_path` is optional because an interpreter may not be holding
|
|
449
|
+
the path; without it the caller's `criteria_drift` stands, which is the only
|
|
450
|
+
honest answer available when there is nothing left to compare against.
|
|
451
|
+
"""
|
|
452
|
+
|
|
453
|
+
result: VerificationResult
|
|
454
|
+
criteria_source_path: str | None = None
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@dataclass(frozen=True)
|
|
458
|
+
class RecordedVerification:
|
|
459
|
+
"""What the store did: the row's stable id, and the drift it ended up with.
|
|
460
|
+
|
|
461
|
+
`criteria_drift` comes back because the activity may have recomputed it, and
|
|
462
|
+
the caller's next step — the escalation summary, the retry prompt — should
|
|
463
|
+
read the same value the row does.
|
|
464
|
+
"""
|
|
465
|
+
|
|
466
|
+
row_id: int
|
|
467
|
+
criteria_drift: bool
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@activity.defn
|
|
471
|
+
async def record_verification(
|
|
472
|
+
request: RecordVerificationInput,
|
|
473
|
+
) -> RecordedVerification:
|
|
474
|
+
"""Write one attempt's evidence to `.factory/verification.db` (invariant 3).
|
|
475
|
+
|
|
476
|
+
Upserts on `(epic_id, node_id, attempt, form)`, so a Temporal re-run lands on
|
|
477
|
+
the first run's row and the returned id is stable.
|
|
478
|
+
|
|
479
|
+
Raises `ATTRIBUTION_INCOMPLETE`, before writing anything, for a result that no
|
|
480
|
+
rollup could account for. Non-retryable: the dispatch is wrong, not the
|
|
481
|
+
moment, and a rerun rebuilds exactly the same unaccountable row.
|
|
482
|
+
"""
|
|
483
|
+
_require_attribution(request.result)
|
|
484
|
+
_require_provenance_when_external(request.result)
|
|
485
|
+
result = _with_drift(request.result, request.criteria_source_path)
|
|
486
|
+
|
|
487
|
+
with closing(store.connect(_store_path())) as conn:
|
|
488
|
+
row_id = store.upsert_result(conn, result)
|
|
489
|
+
|
|
490
|
+
return RecordedVerification(row_id=row_id, criteria_drift=result.criteria_drift)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _require_provenance_when_external(result: VerificationResult) -> None:
|
|
494
|
+
"""Refuse an external completion that would store no provenance (035-US1 FR-005).
|
|
495
|
+
|
|
496
|
+
The column is nullable in the schema because agent-completed work has none,
|
|
497
|
+
but the external-completion path must not be able to write a row without it.
|
|
498
|
+
"""
|
|
499
|
+
if result.provenance is None:
|
|
500
|
+
return
|
|
501
|
+
if not result.provenance.strip():
|
|
502
|
+
raise ApplicationError(
|
|
503
|
+
"external completion provenance must be a non-empty string",
|
|
504
|
+
type=ATTRIBUTION_INCOMPLETE,
|
|
505
|
+
non_retryable=True,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _require_attribution(result: VerificationResult) -> None:
|
|
510
|
+
"""Refuse a verification row that could never be found again (SC-005).
|
|
511
|
+
|
|
512
|
+
Blank counts as absent: the store's `NOT NULL` would accept `""` and every
|
|
513
|
+
per-node query would then report a nameless group. `attempt` is checked the
|
|
514
|
+
same way for the same reason — the DDL's `CHECK (attempt >= 1)` would refuse
|
|
515
|
+
a zero anyway, but as an opaque `IntegrityError` rather than as the name of
|
|
516
|
+
the dimension the dispatch dropped.
|
|
517
|
+
"""
|
|
518
|
+
missing = [name for name in _ATTRIBUTION_FIELDS if _is_blank(getattr(result, name))]
|
|
519
|
+
if not isinstance(result.attempt, int) or result.attempt < 1:
|
|
520
|
+
missing.append("attempt")
|
|
521
|
+
if not missing:
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
raise ApplicationError(
|
|
525
|
+
f"unattributable verification result for epic {result.epic_id!r} "
|
|
526
|
+
f"node {result.node_id!r}: missing {', '.join(sorted(missing))}",
|
|
527
|
+
type=ATTRIBUTION_INCOMPLETE,
|
|
528
|
+
non_retryable=True,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
@dataclass(frozen=True)
|
|
533
|
+
class RecordExternalCompletionInput:
|
|
534
|
+
"""One external-completion signal decision to log (035-US1)."""
|
|
535
|
+
|
|
536
|
+
epic_id: str
|
|
537
|
+
node_id: str
|
|
538
|
+
branch: str
|
|
539
|
+
provenance: str
|
|
540
|
+
accepted: bool
|
|
541
|
+
reason: str | None
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@activity.defn
|
|
545
|
+
async def record_external_completion(
|
|
546
|
+
request: RecordExternalCompletionInput,
|
|
547
|
+
) -> int:
|
|
548
|
+
"""Log an accepted or refused external-completion signal.
|
|
549
|
+
|
|
550
|
+
Idempotent in intent, not by key: every signal the workflow decides on gets
|
|
551
|
+
its own row, because a refusal that is silently dropped is the defect this
|
|
552
|
+
feature exists to prevent.
|
|
553
|
+
"""
|
|
554
|
+
with closing(store.connect(_store_path())) as conn:
|
|
555
|
+
return store.record_external_completion_signal(
|
|
556
|
+
conn,
|
|
557
|
+
epic_id=request.epic_id,
|
|
558
|
+
node_id=request.node_id,
|
|
559
|
+
branch=request.branch,
|
|
560
|
+
provenance=request.provenance,
|
|
561
|
+
accepted=request.accepted,
|
|
562
|
+
reason=request.reason,
|
|
563
|
+
recorded_at=_now(),
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _now() -> str:
|
|
568
|
+
"""ISO-8601 UTC timestamp for store rows."""
|
|
569
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
# Keep the old module-level `_now` shadow visible for any internal callers.
|
|
573
|
+
now = _now
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _is_blank(value: object) -> bool:
|
|
577
|
+
return not isinstance(value, str) or not value.strip()
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _with_drift(
|
|
581
|
+
result: VerificationResult, criteria_source_path: str | None
|
|
582
|
+
) -> VerificationResult:
|
|
583
|
+
"""Set `criteria_drift` from the spec file's current bytes, if it is still open.
|
|
584
|
+
|
|
585
|
+
Drift the caller already found is never cleared: a verifier that watched the
|
|
586
|
+
spec change mid-attempt must not have its finding overwritten by a file that
|
|
587
|
+
has since been restored. The row keeps the *snapshot's* hash either way — the
|
|
588
|
+
node was judged against those bytes (FR-010), and recording the new ones would
|
|
589
|
+
make the evidence agree with a spec this attempt never saw.
|
|
590
|
+
"""
|
|
591
|
+
if result.criteria_drift or criteria_source_path is None:
|
|
592
|
+
return result
|
|
593
|
+
return replace(
|
|
594
|
+
result,
|
|
595
|
+
criteria_drift=_has_drifted(Path(criteria_source_path), result.criteria_sha256),
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _has_drifted(source: Path, snapshot_sha256: str) -> bool:
|
|
600
|
+
"""Whether the spec file's bytes still hash to the dispatch snapshot's (R8).
|
|
601
|
+
|
|
602
|
+
A file that cannot be read is drift, not "no drift detected": a deleted spec
|
|
603
|
+
is the loudest possible form of the system of record moving under a node.
|
|
604
|
+
"""
|
|
605
|
+
try:
|
|
606
|
+
current = hashlib.sha256(source.read_bytes()).hexdigest()
|
|
607
|
+
except OSError:
|
|
608
|
+
return True
|
|
609
|
+
return current != snapshot_sha256
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _store_path() -> Path:
|
|
613
|
+
return resolve_env_path(
|
|
614
|
+
ERGANE_VERIFICATION_DB_PATH_ENV,
|
|
615
|
+
FACTORY_VERIFICATION_DB_PATH_ENV,
|
|
616
|
+
DEFAULT_VERIFICATION_DB_PATH,
|
|
617
|
+
)
|
factory/cli/__init__.py
ADDED
|
File without changes
|