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
factory/verify/gates.py
ADDED
|
@@ -0,0 +1,1341 @@
|
|
|
1
|
+
"""Running the target repo's declared gates, and surviving the ones that misbehave.
|
|
2
|
+
|
|
3
|
+
Gates are the deterministic half of verification (FR-002): the commands the repo
|
|
4
|
+
committed in its `factory.yaml`, run in the node's worktree, in the order the
|
|
5
|
+
repo declared them. The happy path is four lines; everything else in this module
|
|
6
|
+
is about the three ways a gate refuses to be simple.
|
|
7
|
+
|
|
8
|
+
**A gate that fails is data.** Non-zero exit, deadline, unusable manifest — each
|
|
9
|
+
becomes a `GateResult` the verdict truth table reads. Nothing here raises past
|
|
10
|
+
its caller, because an exception would cost the attempt its evidence and an
|
|
11
|
+
empty gate list is exactly the shape a naive verdict mistakes for "nothing
|
|
12
|
+
failed" (SC-002). A broken manifest is one `CONFIG_ERROR` result, never zero
|
|
13
|
+
results.
|
|
14
|
+
|
|
15
|
+
**A gate that hangs owns children.** `bash -c "make test"` is a process tree, and
|
|
16
|
+
killing only its root leaves the grandchildren holding the output pipe: the
|
|
17
|
+
runner would still return, thirty seconds later, having waited for a `sleep` it
|
|
18
|
+
thought it had killed. So each gate gets its own session (`start_new_session`),
|
|
19
|
+
the deadline is enforced against the whole process *group*, and SIGTERM is
|
|
20
|
+
followed by SIGKILL once the grace period expires (R3) — a gate that traps TERM
|
|
21
|
+
does not get to outlive its timeout.
|
|
22
|
+
|
|
23
|
+
**A gate that fails still leaves evidence.** Output is drained by a thread rather
|
|
24
|
+
than collected at exit, so a killed gate's output is what it printed before it
|
|
25
|
+
died — which is where the explanation is. That drain keeps only a bounded window:
|
|
26
|
+
the last `OUTPUT_TAIL_LIMIT` bytes, because this text is copied into workflow
|
|
27
|
+
state, an escalation message and the evidence store, and one verbose test suite
|
|
28
|
+
should not be able to fill all three.
|
|
29
|
+
|
|
30
|
+
Execution sits behind the narrow `GateExecutor` seam (R3) so `ContainerExecutor`
|
|
31
|
+
can replace `SubprocessGateExecutor` later without touching verdict logic — and
|
|
32
|
+
so tests can assert the timeout the runner *resolved* without waiting out a
|
|
33
|
+
ten-minute default to prove it is ten minutes.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import signal
|
|
41
|
+
import subprocess
|
|
42
|
+
import threading
|
|
43
|
+
import time
|
|
44
|
+
from dataclasses import dataclass
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import IO, Mapping, Protocol, Sequence
|
|
47
|
+
|
|
48
|
+
from factory.verify.factory_yaml import (
|
|
49
|
+
MANIFEST_NAME,
|
|
50
|
+
PARSE_CLI_OK,
|
|
51
|
+
PARSE_CLI_REJECTED,
|
|
52
|
+
FactoryConfigError,
|
|
53
|
+
config_error_result,
|
|
54
|
+
load_factory_config,
|
|
55
|
+
)
|
|
56
|
+
from factory.verify.models import GateResult, GateStatus, VerificationConfig
|
|
57
|
+
from factory.verify.toolchain import (
|
|
58
|
+
DEFAULT_AGENT_RUNNER,
|
|
59
|
+
GIT,
|
|
60
|
+
NODE,
|
|
61
|
+
UV,
|
|
62
|
+
ResolvedTool,
|
|
63
|
+
ToolchainError,
|
|
64
|
+
container_path,
|
|
65
|
+
find_install_root,
|
|
66
|
+
resolve_toolchain,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
#: Deadline for a gate the manifest gives no `timeouts` entry. Sourced from
|
|
70
|
+
#: `VerificationConfig` rather than restated: that field is the knob an operator
|
|
71
|
+
#: edits, and a second literal here would let tuning it silently do nothing.
|
|
72
|
+
DEFAULT_GATE_TIMEOUT_S: int = VerificationConfig().gate_timeout_s
|
|
73
|
+
|
|
74
|
+
#: How many gate subprocesses may run at once across the whole host (007 FR-005).
|
|
75
|
+
#:
|
|
76
|
+
#: The hazard fan-out introduces: N nodes' gates on one host contend for CPU, so
|
|
77
|
+
#: the same suite takes longer purely because neighbours exist, and a fixed
|
|
78
|
+
#: wall-clock timeout converts that stretch into a spurious FAIL — a verdict
|
|
79
|
+
#: that is not a fact about the node's code. Bounding gate concurrency *below*
|
|
80
|
+
#: node concurrency fixes the property rather than tolerating it: a node's
|
|
81
|
+
#: agent runs in parallel while its gates take a turn, and a gate's wall-clock
|
|
82
|
+
#: measures its own work, never the queue it waited in. The default of one is
|
|
83
|
+
#: the strongest form — gates serialize across the host — and is below any
|
|
84
|
+
#: `max_concurrent_nodes` above one. It is a process-level bound because gates
|
|
85
|
+
#: already run through the worker's thread path (`asyncio.to_thread`), so a
|
|
86
|
+
#: semaphore here is the one limiter all of them cross.
|
|
87
|
+
DEFAULT_GATE_CONCURRENCY: int = 1
|
|
88
|
+
|
|
89
|
+
#: How much of a gate's combined output is kept as evidence (R3).
|
|
90
|
+
OUTPUT_TAIL_LIMIT = 32 * 1024
|
|
91
|
+
|
|
92
|
+
#: Environment variable names a gate subprocess may inherit — an allowlist, not
|
|
93
|
+
#: a denylist, because a denylist protects the credentials we thought of and
|
|
94
|
+
#: leaks the ones invented next quarter (constitution V, R3). Anything a gate
|
|
95
|
+
#: genuinely needs belongs in the repo's own command, where it is reviewable.
|
|
96
|
+
SCRUBBED_ENV_ALLOWLIST: tuple[str, ...] = (
|
|
97
|
+
"PATH",
|
|
98
|
+
"HOME",
|
|
99
|
+
"TMPDIR",
|
|
100
|
+
"LANG",
|
|
101
|
+
"LC_ALL",
|
|
102
|
+
"LC_CTYPE",
|
|
103
|
+
"TZ",
|
|
104
|
+
"TERM",
|
|
105
|
+
"USER",
|
|
106
|
+
"LOGNAME",
|
|
107
|
+
"SHELL",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
#: Used when the parent has no usable `PATH`. Without one, every gate fails with
|
|
111
|
+
#: "bash: command not found" — a config error wearing a gate failure's clothes.
|
|
112
|
+
FALLBACK_PATH = "/usr/local/bin:/usr/bin:/bin"
|
|
113
|
+
|
|
114
|
+
#: Seconds between SIGTERM and SIGKILL. Long enough for a test runner to flush
|
|
115
|
+
#: its report, short enough that a process ignoring TERM does not become the
|
|
116
|
+
#: verification's deadline.
|
|
117
|
+
DEFAULT_KILL_GRACE_S = 10.0
|
|
118
|
+
|
|
119
|
+
#: Absolute path the bwrap backend is pinned to. Ubuntu 24.04's AppArmor profile
|
|
120
|
+
#: permits unprivileged user namespaces only for the system binary at this path
|
|
121
|
+
#: (trap 6); a copied or vendored binary has no profile and fails with EPERM.
|
|
122
|
+
BWRAP_BACKEND_BINARY = Path("/usr/bin/bwrap")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def ordered_binds(binds: Sequence[tuple[str, str, str]]) -> list[str]:
|
|
126
|
+
"""Flatten filesystem binds into argv, shallowest path first.
|
|
127
|
+
|
|
128
|
+
bwrap applies mounts in argv order, so a bind whose destination *contains*
|
|
129
|
+
an earlier one silently replaces it. Every path in this factory has that
|
|
130
|
+
shape: a node worktree lives at
|
|
131
|
+
``<target_repo>/.factory/worktrees/<epic>/<node>``, inside both the runtime
|
|
132
|
+
root and the target repository, so emitting the worktree writable and the
|
|
133
|
+
repository read-only afterwards made the worktree read-only.
|
|
134
|
+
|
|
135
|
+
That is not hypothetical — it shipped. `011-agent-sandbox/US5` put gates
|
|
136
|
+
behind the boundary and the first real epic to run under it
|
|
137
|
+
(`033-ergane-install/us2`, 2026-08-15) failed its gate with
|
|
138
|
+
``failed to remove directory .venv/bin: Read-only file system``, because
|
|
139
|
+
``--ro-bind <target_repo>`` was emitted after ``--bind <worktree>``.
|
|
140
|
+
|
|
141
|
+
Sorting by depth makes the most specific bind win regardless of the order
|
|
142
|
+
callers happen to list them in, which is the property the mount set needs
|
|
143
|
+
and the one an ad-hoc "is this the immediate parent?" guard cannot provide.
|
|
144
|
+
Ties keep the caller's order, so two binds at the same depth stay as
|
|
145
|
+
written.
|
|
146
|
+
"""
|
|
147
|
+
ordered = sorted(binds, key=lambda bind: len(Path(bind[2]).parts))
|
|
148
|
+
argv: list[str] = []
|
|
149
|
+
for flag, source, dest in ordered:
|
|
150
|
+
argv.extend([flag, source, dest])
|
|
151
|
+
return argv
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
_READ_CHUNK = 64 * 1024
|
|
155
|
+
|
|
156
|
+
#: The drain trims back to the tail once it holds this multiple of the limit —
|
|
157
|
+
#: bounded memory without a slice on every chunk.
|
|
158
|
+
_TRIM_FACTOR = 4
|
|
159
|
+
|
|
160
|
+
#: How long to wait for the drain thread after the gate's group is gone. It ends
|
|
161
|
+
#: at EOF, which the SIGKILL guarantees; the bound is only so a process that
|
|
162
|
+
#: escaped its group cannot hang the verification.
|
|
163
|
+
_DRAIN_JOIN_S = 5.0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# Candidate parser protocol --------------------------------------------------
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True)
|
|
170
|
+
class CandidateOutcome:
|
|
171
|
+
"""Raw result from a candidate parser subprocess."""
|
|
172
|
+
|
|
173
|
+
exit_code: int
|
|
174
|
+
stdout: str
|
|
175
|
+
stderr: str
|
|
176
|
+
timed_out: bool = False
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True)
|
|
180
|
+
class _AcceptedConfig:
|
|
181
|
+
"""A candidate acceptance: the subset of the protocol the runner consumes."""
|
|
182
|
+
|
|
183
|
+
kind: str = "accepted"
|
|
184
|
+
gates: dict[str, str] | None = None
|
|
185
|
+
timeouts: dict[str, int] | None = None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True)
|
|
189
|
+
class _RejectedConfig:
|
|
190
|
+
"""A candidate rejection: the worker uses this message as the CONFIG_ERROR."""
|
|
191
|
+
|
|
192
|
+
kind: str = "rejected"
|
|
193
|
+
message: str = ""
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True)
|
|
197
|
+
class _CannotRun:
|
|
198
|
+
"""A candidate that could not run: reason is appended to fallback tail."""
|
|
199
|
+
|
|
200
|
+
kind: str = "cannot-run"
|
|
201
|
+
reason: str = ""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class CandidateRunner(Protocol):
|
|
205
|
+
"""A callable seam for candidate parser subprocesses.
|
|
206
|
+
|
|
207
|
+
Tests pass a plain callable `(worktree, manifest) -> CandidateOutcome`;
|
|
208
|
+
the real implementation is an object with a `run` method. Both shapes
|
|
209
|
+
are accepted so the public API matches the existing `executor` keyword.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def __call__(self, worktree: Path, manifest: Path) -> CandidateOutcome: ...
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
_CandidateResult = _AcceptedConfig | _RejectedConfig | _CannotRun
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# Invocation seam ------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclass(frozen=True)
|
|
222
|
+
class GateInvocation:
|
|
223
|
+
"""Everything decided before a gate runs — the resolved plan for one gate.
|
|
224
|
+
|
|
225
|
+
Timeout resolution (declared, overridden, defaulted), the worktree to run
|
|
226
|
+
in, and the scrubbed environment are all settled here, so an executor has no
|
|
227
|
+
policy left to get wrong and a test can assert the policy without running a
|
|
228
|
+
process.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
name: str
|
|
232
|
+
command: str
|
|
233
|
+
cwd: Path
|
|
234
|
+
timeout_s: int
|
|
235
|
+
env: dict[str, str]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass(frozen=True)
|
|
239
|
+
class ExecutionOutcome:
|
|
240
|
+
"""What an executor observed: still raw, not yet a verdict.
|
|
241
|
+
|
|
242
|
+
`exit_code` is None exactly when there was no exit status to read, which is
|
|
243
|
+
the timeout case; `timed_out` says so explicitly rather than leaving the
|
|
244
|
+
mapping to infer it, because a gate killed by an unrelated signal reports a
|
|
245
|
+
negative code and is an ordinary failure, not a deadline.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
exit_code: int | None
|
|
249
|
+
output: str
|
|
250
|
+
duration_s: float
|
|
251
|
+
timed_out: bool
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class GateExecutor(Protocol):
|
|
255
|
+
"""The one thing a gate backend does (R3): run an invocation, report back."""
|
|
256
|
+
|
|
257
|
+
def run(self, invocation: GateInvocation) -> ExecutionOutcome: ...
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# Gate concurrency (007 FR-005) ------------------------------------------------
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class GateConcurrencyLimiter:
|
|
264
|
+
"""A process-level bound on how many gates run at once (007 FR-005).
|
|
265
|
+
|
|
266
|
+
`run_gates` calls live in worker threads (`asyncio.to_thread` in the
|
|
267
|
+
activity), so a `threading.Semaphore` is the one limiter every gate crosses
|
|
268
|
+
regardless of which node dispatched it. A gate acquires a slot before its
|
|
269
|
+
process starts and releases it when the process is done; the wall-clock
|
|
270
|
+
bound is enforced inside that window, so a gate that waits for its turn
|
|
271
|
+
measures only its own work, never the queue. That is what makes the verdict
|
|
272
|
+
load-*independent* rather than load-*tolerant*.
|
|
273
|
+
|
|
274
|
+
`acquire()` returns how many *other* gates were in flight when this one got
|
|
275
|
+
its slot — the contention marker recorded on the `GateResult`, so a slow
|
|
276
|
+
verdict is auditable afterwards. Zero means the gate had the budget to
|
|
277
|
+
itself.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
def __init__(self, bound: int = DEFAULT_GATE_CONCURRENCY) -> None:
|
|
281
|
+
if bound < 1:
|
|
282
|
+
raise ValueError(
|
|
283
|
+
f"gate concurrency bound must be >= 1, not {bound}"
|
|
284
|
+
)
|
|
285
|
+
self._bound = bound
|
|
286
|
+
self._slots = threading.BoundedSemaphore(bound)
|
|
287
|
+
self._in_flight = 0
|
|
288
|
+
self._lock = threading.Lock()
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def bound(self) -> int:
|
|
292
|
+
return self._bound
|
|
293
|
+
|
|
294
|
+
def acquire(self) -> int:
|
|
295
|
+
"""Take a slot; return the count of *other* gates now in flight."""
|
|
296
|
+
self._slots.acquire()
|
|
297
|
+
with self._lock:
|
|
298
|
+
peers = self._in_flight
|
|
299
|
+
self._in_flight += 1
|
|
300
|
+
return peers
|
|
301
|
+
|
|
302
|
+
def release(self) -> None:
|
|
303
|
+
with self._lock:
|
|
304
|
+
self._in_flight = max(0, self._in_flight - 1)
|
|
305
|
+
self._slots.release()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
#: The limiter `run_gates` uses when a caller does not supply one. Module-level
|
|
309
|
+
#: so two `run_gates` calls in two worker threads — the shape fan-out produces
|
|
310
|
+
#: — share the same bound rather than each racing unbounded.
|
|
311
|
+
_default_limiter = GateConcurrencyLimiter(DEFAULT_GATE_CONCURRENCY)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# Environment ----------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def scrubbed_env(source: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
318
|
+
"""The environment a gate is allowed to see, drawn from `source` (default: this process').
|
|
319
|
+
|
|
320
|
+
Names outside `SCRUBBED_ENV_ALLOWLIST` are dropped, so `LITELLM_MASTER_KEY`
|
|
321
|
+
and `TELEGRAM_BOT_TOKEN` never reach a repo-declared command — nor does the
|
|
322
|
+
next credential the worker host acquires, which is the whole reason this is
|
|
323
|
+
an allowlist. Variables exported empty are treated as absent so `PATH=` gets
|
|
324
|
+
the fallback rather than breaking every gate.
|
|
325
|
+
"""
|
|
326
|
+
environ = os.environ if source is None else source
|
|
327
|
+
env = {name: environ[name] for name in SCRUBBED_ENV_ALLOWLIST if environ.get(name)}
|
|
328
|
+
env.setdefault("PATH", FALLBACK_PATH)
|
|
329
|
+
return env
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# Output tails ---------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def tail_output(text: str, limit: int = OUTPUT_TAIL_LIMIT) -> str:
|
|
336
|
+
"""The last `limit` bytes of `text`, verbatim and still decodable.
|
|
337
|
+
|
|
338
|
+
A suffix, with nothing inserted: this text is quoted into retry prompts
|
|
339
|
+
(FR-006, SC-004), so a truncation marker here would put the factory's own
|
|
340
|
+
voice inside what is supposed to be the tool's output. Marking truncation is
|
|
341
|
+
judge-input assembly's job (R6), where the model has to be told not to read
|
|
342
|
+
a cut as missing implementation.
|
|
343
|
+
|
|
344
|
+
The cap is bytes because that is what the store and the message budgets are
|
|
345
|
+
denominated in, but the cut lands on a character boundary — a tail sliced
|
|
346
|
+
mid-character would not decode.
|
|
347
|
+
"""
|
|
348
|
+
encoded = text.encode("utf-8")
|
|
349
|
+
if len(encoded) <= limit:
|
|
350
|
+
return text
|
|
351
|
+
return _tail_bytes(encoded, limit).decode("utf-8")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _tail_bytes(data: bytes, limit: int) -> bytes:
|
|
355
|
+
if len(data) <= limit:
|
|
356
|
+
return data
|
|
357
|
+
window = data[-limit:]
|
|
358
|
+
# Continuation bytes (0b10xxxxxx) at the front are the second half of a
|
|
359
|
+
# character the cut went through; dropping them is what keeps the rest valid.
|
|
360
|
+
start = 0
|
|
361
|
+
while start < len(window) and window[start] & 0xC0 == 0x80:
|
|
362
|
+
start += 1
|
|
363
|
+
return window[start:]
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class _TailBuffer:
|
|
367
|
+
"""A stream's last `limit` bytes, accumulated from another thread.
|
|
368
|
+
|
|
369
|
+
Bounded on purpose: a gate is free to print gigabytes, and holding them to
|
|
370
|
+
keep 32 KiB would make an over-chatty test suite an out-of-memory failure.
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
def __init__(self, limit: int = OUTPUT_TAIL_LIMIT) -> None:
|
|
374
|
+
self._limit = limit
|
|
375
|
+
self._lock = threading.Lock()
|
|
376
|
+
self._data = bytearray()
|
|
377
|
+
|
|
378
|
+
def feed(self, chunk: bytes) -> None:
|
|
379
|
+
with self._lock:
|
|
380
|
+
self._data += chunk
|
|
381
|
+
if len(self._data) > self._limit * _TRIM_FACTOR:
|
|
382
|
+
del self._data[: -self._limit]
|
|
383
|
+
|
|
384
|
+
def text(self) -> str:
|
|
385
|
+
with self._lock:
|
|
386
|
+
data = _tail_bytes(bytes(self._data), self._limit)
|
|
387
|
+
# `replace` rather than `strict`: a gate killed mid-write leaves a partial
|
|
388
|
+
# character, and losing the whole tail to one broken byte would lose the
|
|
389
|
+
# failure's explanation with it.
|
|
390
|
+
return data.decode("utf-8", errors="replace")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# Subprocess executor --------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class SubprocessGateExecutor:
|
|
397
|
+
"""Runs a gate as `bash -c <command>` in the worktree, on the worker host.
|
|
398
|
+
|
|
399
|
+
`bash -c` rather than an argv split because repo gate commands legitimately
|
|
400
|
+
use shell features (`&&`, globs, variables) and `factory.yaml` is
|
|
401
|
+
operator-committed config at the same trust level as CI config (R3). The
|
|
402
|
+
container-isolated executor belongs to the component that owns node
|
|
403
|
+
sandboxing; this one deliberately relies on the node's worktree already
|
|
404
|
+
being sandboxed.
|
|
405
|
+
"""
|
|
406
|
+
|
|
407
|
+
def __init__(self, *, grace_s: float = DEFAULT_KILL_GRACE_S) -> None:
|
|
408
|
+
self.grace_s = grace_s
|
|
409
|
+
|
|
410
|
+
def run(self, invocation: GateInvocation) -> ExecutionOutcome:
|
|
411
|
+
started = time.monotonic()
|
|
412
|
+
process = subprocess.Popen(
|
|
413
|
+
["bash", "-c", invocation.command],
|
|
414
|
+
cwd=str(invocation.cwd),
|
|
415
|
+
env=dict(invocation.env),
|
|
416
|
+
stdin=subprocess.DEVNULL,
|
|
417
|
+
stdout=subprocess.PIPE,
|
|
418
|
+
stderr=subprocess.STDOUT,
|
|
419
|
+
# Its own session, so the deadline can be enforced against the whole
|
|
420
|
+
# process tree instead of just the shell at its root.
|
|
421
|
+
start_new_session=True,
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
buffer = _TailBuffer()
|
|
425
|
+
drain = threading.Thread(
|
|
426
|
+
target=_drain,
|
|
427
|
+
args=(process.stdout, buffer),
|
|
428
|
+
name=f"gate-{invocation.name}-output",
|
|
429
|
+
daemon=True,
|
|
430
|
+
)
|
|
431
|
+
drain.start()
|
|
432
|
+
|
|
433
|
+
timed_out = False
|
|
434
|
+
try:
|
|
435
|
+
process.wait(timeout=invocation.timeout_s)
|
|
436
|
+
except subprocess.TimeoutExpired:
|
|
437
|
+
timed_out = True
|
|
438
|
+
self._reclaim(process)
|
|
439
|
+
|
|
440
|
+
drain.join(timeout=_DRAIN_JOIN_S)
|
|
441
|
+
duration_s = time.monotonic() - started
|
|
442
|
+
|
|
443
|
+
return ExecutionOutcome(
|
|
444
|
+
exit_code=None if timed_out else process.returncode,
|
|
445
|
+
output=buffer.text(),
|
|
446
|
+
duration_s=duration_s,
|
|
447
|
+
timed_out=timed_out,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
def _reclaim(self, process: subprocess.Popen[bytes]) -> None:
|
|
451
|
+
"""SIGTERM the gate's process group, then SIGKILL what survives (R3)."""
|
|
452
|
+
_signal_group(process, signal.SIGTERM)
|
|
453
|
+
try:
|
|
454
|
+
process.wait(timeout=self.grace_s)
|
|
455
|
+
except subprocess.TimeoutExpired:
|
|
456
|
+
pass
|
|
457
|
+
# Sent even when the shell died politely: its children are in the same
|
|
458
|
+
# group, outlive it, and keep the output pipe open until they are gone.
|
|
459
|
+
_signal_group(process, signal.SIGKILL)
|
|
460
|
+
process.wait()
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _signal_group(process: subprocess.Popen[bytes], sig: int) -> None:
|
|
464
|
+
# `start_new_session` makes the child its own group leader, so its pid is the
|
|
465
|
+
# group id. An already-empty group is the expected outcome of the second
|
|
466
|
+
# signal, not an error.
|
|
467
|
+
try:
|
|
468
|
+
os.killpg(process.pid, sig)
|
|
469
|
+
except (ProcessLookupError, PermissionError):
|
|
470
|
+
pass
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _drain(stream: IO[bytes] | None, buffer: _TailBuffer) -> None:
|
|
474
|
+
if stream is None:
|
|
475
|
+
return
|
|
476
|
+
try:
|
|
477
|
+
while chunk := stream.read(_READ_CHUNK):
|
|
478
|
+
buffer.feed(chunk)
|
|
479
|
+
except (OSError, ValueError):
|
|
480
|
+
# The pipe went away with the process; whatever was read is the evidence.
|
|
481
|
+
pass
|
|
482
|
+
finally:
|
|
483
|
+
stream.close()
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# Bubblewrap executor --------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
class BwrapGateExecutor:
|
|
490
|
+
"""Runs a gate inside the same bubblewrap boundary as the agent (US5).
|
|
491
|
+
|
|
492
|
+
The mount set is deliberately minimal: a read-only system tree, a tmpfs
|
|
493
|
+
for the gate's scratch state, the node worktree writable, the parent repo's
|
|
494
|
+
`.git` writable for git plumbing, and read-only toolchain leaves. Network
|
|
495
|
+
is intentionally not unshared — egress is out of scope — and the gate runs
|
|
496
|
+
in its own PID namespace with `--die-with-parent` so the existing group-kill
|
|
497
|
+
path reaches the whole tree.
|
|
498
|
+
"""
|
|
499
|
+
|
|
500
|
+
def __init__(self, *, grace_s: float = DEFAULT_KILL_GRACE_S) -> None:
|
|
501
|
+
self.grace_s = grace_s
|
|
502
|
+
|
|
503
|
+
def run(self, invocation: GateInvocation) -> ExecutionOutcome:
|
|
504
|
+
started = time.monotonic()
|
|
505
|
+
binary = BWRAP_BACKEND_BINARY
|
|
506
|
+
if not binary.is_file():
|
|
507
|
+
return ExecutionOutcome(
|
|
508
|
+
exit_code=127,
|
|
509
|
+
output=(
|
|
510
|
+
f"sandbox backend 'bwrap' not available: {binary} "
|
|
511
|
+
"missing on this host"
|
|
512
|
+
),
|
|
513
|
+
duration_s=0.0,
|
|
514
|
+
timed_out=False,
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
# Discovery happens while the argv is assembled, so a host missing a
|
|
518
|
+
# tool is refused by name here — before the fork — instead of reaching
|
|
519
|
+
# the operator as bwrap's own `Can't find source path` from a process
|
|
520
|
+
# that has already started. Same shape as the missing-binary refusal
|
|
521
|
+
# above: a 127 outcome carrying the reason, not an exception the gate
|
|
522
|
+
# runner has no place to put.
|
|
523
|
+
try:
|
|
524
|
+
argv = self._build_argv(invocation)
|
|
525
|
+
except ToolchainError as error:
|
|
526
|
+
return ExecutionOutcome(
|
|
527
|
+
exit_code=127,
|
|
528
|
+
output=str(error),
|
|
529
|
+
duration_s=time.monotonic() - started,
|
|
530
|
+
timed_out=False,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
process = subprocess.Popen(
|
|
534
|
+
argv,
|
|
535
|
+
cwd=str(invocation.cwd),
|
|
536
|
+
env=dict(invocation.env),
|
|
537
|
+
stdin=subprocess.DEVNULL,
|
|
538
|
+
stdout=subprocess.PIPE,
|
|
539
|
+
stderr=subprocess.STDOUT,
|
|
540
|
+
start_new_session=True,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
buffer = _TailBuffer()
|
|
544
|
+
drain = threading.Thread(
|
|
545
|
+
target=_drain,
|
|
546
|
+
args=(process.stdout, buffer),
|
|
547
|
+
name=f"gate-{invocation.name}-output",
|
|
548
|
+
daemon=True,
|
|
549
|
+
)
|
|
550
|
+
drain.start()
|
|
551
|
+
|
|
552
|
+
timed_out = False
|
|
553
|
+
try:
|
|
554
|
+
process.wait(timeout=invocation.timeout_s)
|
|
555
|
+
except subprocess.TimeoutExpired:
|
|
556
|
+
timed_out = True
|
|
557
|
+
self._reclaim(process)
|
|
558
|
+
|
|
559
|
+
drain.join(timeout=_DRAIN_JOIN_S)
|
|
560
|
+
duration_s = time.monotonic() - started
|
|
561
|
+
|
|
562
|
+
return ExecutionOutcome(
|
|
563
|
+
exit_code=None if timed_out else process.returncode,
|
|
564
|
+
output=buffer.text(),
|
|
565
|
+
duration_s=duration_s,
|
|
566
|
+
timed_out=timed_out,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
def _reclaim(self, process: subprocess.Popen[bytes]) -> None:
|
|
570
|
+
"""SIGTERM the gate's process group, then SIGKILL what survives (R3)."""
|
|
571
|
+
_signal_group(process, signal.SIGTERM)
|
|
572
|
+
try:
|
|
573
|
+
process.wait(timeout=self.grace_s)
|
|
574
|
+
except subprocess.TimeoutExpired:
|
|
575
|
+
pass
|
|
576
|
+
_signal_group(process, signal.SIGKILL)
|
|
577
|
+
process.wait()
|
|
578
|
+
|
|
579
|
+
def _build_argv(self, invocation: GateInvocation) -> list[str]:
|
|
580
|
+
"""Assemble the bwrap command from the proven mount set."""
|
|
581
|
+
worktree = invocation.cwd.resolve()
|
|
582
|
+
home = Path("/tmp/ergane-gate-home")
|
|
583
|
+
|
|
584
|
+
argv: list[str] = [
|
|
585
|
+
str(BWRAP_BACKEND_BINARY),
|
|
586
|
+
# Minimal system tree: read-only /usr plus the symlinks Ubuntu uses
|
|
587
|
+
# on aarch64. No /lib64 on this host.
|
|
588
|
+
"--ro-bind", "/usr", "/usr",
|
|
589
|
+
"--symlink", "usr/bin", "/bin",
|
|
590
|
+
"--symlink", "usr/lib", "/lib",
|
|
591
|
+
# Runtime pseudo-filesystems.
|
|
592
|
+
"--proc", "/proc",
|
|
593
|
+
"--dev", "/dev",
|
|
594
|
+
"--tmpfs", "/tmp",
|
|
595
|
+
]
|
|
596
|
+
|
|
597
|
+
# Every filesystem bind is collected here and emitted by `ordered_binds`,
|
|
598
|
+
# shallowest destination first, so a containing path can never overlay
|
|
599
|
+
# the more specific bind inside it. Listing order below is for the
|
|
600
|
+
# reader; the mount order is the helper's.
|
|
601
|
+
binds: list[tuple[str, str, str]] = []
|
|
602
|
+
|
|
603
|
+
# The parent read-only so bwrap does not create a writable intermediate
|
|
604
|
+
# directory exposing sibling worktrees (trap 9); the node worktree
|
|
605
|
+
# writable — only the leaf, at the same absolute path.
|
|
606
|
+
if worktree.parent not in (Path("/"),):
|
|
607
|
+
binds.append(("--ro-bind", str(worktree.parent), str(worktree.parent)))
|
|
608
|
+
binds.append(("--bind", str(worktree), str(worktree)))
|
|
609
|
+
|
|
610
|
+
# Git plumbing: a linked worktree's `.git` file points back to the
|
|
611
|
+
# parent repo's `.git/worktrees/<name>`. Bind the whole parent `.git`
|
|
612
|
+
# writable so commit/diff work; the working tree is read-only.
|
|
613
|
+
target_git_dir = _resolve_target_git_dir(worktree)
|
|
614
|
+
if target_git_dir is not None:
|
|
615
|
+
target_worktree = target_git_dir.parent
|
|
616
|
+
if target_worktree not in (worktree.parent, Path("/")):
|
|
617
|
+
binds.append(
|
|
618
|
+
("--ro-bind", str(target_worktree), str(target_worktree))
|
|
619
|
+
)
|
|
620
|
+
binds.append(("--bind", str(target_git_dir), str(target_git_dir)))
|
|
621
|
+
|
|
622
|
+
# Read-only toolchain leaves (trap 13), collected with the rest so a
|
|
623
|
+
# leaf inside the worktree could not be overlaid by it either, plus the
|
|
624
|
+
# interpreter the worktree's venv points at. The toolchain is resolved
|
|
625
|
+
# once and used twice — for these binds and for the container `PATH`
|
|
626
|
+
# below — so the two cannot name different directories.
|
|
627
|
+
tools = self._toolchain()
|
|
628
|
+
binds.extend(self._toolchain_binds(tools))
|
|
629
|
+
for source, dest in self._interpreter_binds(worktree):
|
|
630
|
+
binds.append(("--ro-bind", source, dest))
|
|
631
|
+
for source, dest in self._resolver_binds():
|
|
632
|
+
binds.append(("--ro-bind", source, dest))
|
|
633
|
+
|
|
634
|
+
# The package cache is writable, and is the one bind outside the
|
|
635
|
+
# worktree that is: see `_cache_binds`.
|
|
636
|
+
cache_binds = self._cache_binds()
|
|
637
|
+
for source, dest in cache_binds:
|
|
638
|
+
binds.append(("--bind", source, dest))
|
|
639
|
+
|
|
640
|
+
argv.extend(ordered_binds(binds))
|
|
641
|
+
argv.extend(["--chdir", str(worktree)])
|
|
642
|
+
|
|
643
|
+
# A factory-owned home for the gate, writable but not the operator's.
|
|
644
|
+
argv.extend(["--tmpfs", str(home)])
|
|
645
|
+
argv.extend(["--setenv", "HOME", str(home)])
|
|
646
|
+
for _, dest in cache_binds:
|
|
647
|
+
argv.extend(["--setenv", "UV_CACHE_DIR", dest])
|
|
648
|
+
|
|
649
|
+
# PATH must name the bind points inside the container — derived from
|
|
650
|
+
# the same resolutions the binds came from, never from a literal.
|
|
651
|
+
argv.extend(["--setenv", "PATH", container_path(tools)])
|
|
652
|
+
|
|
653
|
+
# A commit identity, so a gate that commits is not asked who it is.
|
|
654
|
+
for name, value in self._identity_env().items():
|
|
655
|
+
argv.extend(["--setenv", name, value])
|
|
656
|
+
|
|
657
|
+
# Process/signal boundary (trap 2).
|
|
658
|
+
argv.extend(["--unshare-pid", "--die-with-parent"])
|
|
659
|
+
|
|
660
|
+
# Finally the gate itself, as `bash -c <command>`.
|
|
661
|
+
argv.extend(["--", "bash", "-c", invocation.command])
|
|
662
|
+
return argv
|
|
663
|
+
|
|
664
|
+
def _toolchain(self) -> list[ResolvedTool]:
|
|
665
|
+
"""Discover the toolchain the gate may invoke, in `PATH` order.
|
|
666
|
+
|
|
667
|
+
`uv` and `git` are required — every gate command in this factory runs
|
|
668
|
+
through one and the verification store reads the other — so a host
|
|
669
|
+
without them is refused by name rather than handed to bwrap, which
|
|
670
|
+
would fail on the source path after the fork.
|
|
671
|
+
|
|
672
|
+
`node` and the agent runner are optional, and were already guarded
|
|
673
|
+
before discovery replaced the literals: a repository whose gates need
|
|
674
|
+
neither still has gates that run, and degrading quietly is the
|
|
675
|
+
behaviour this boundary shipped with.
|
|
676
|
+
|
|
677
|
+
The order is load-bearing: `container_path` derives the container's
|
|
678
|
+
`PATH` from it, and the resulting string must keep naming the package
|
|
679
|
+
manager's directory ahead of the system one.
|
|
680
|
+
"""
|
|
681
|
+
return resolve_toolchain(
|
|
682
|
+
(UV, NODE, GIT, DEFAULT_AGENT_RUNNER),
|
|
683
|
+
purpose="the gate boundary",
|
|
684
|
+
optional=(NODE, DEFAULT_AGENT_RUNNER),
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
def _toolchain_binds(
|
|
688
|
+
self, tools: Sequence[ResolvedTool] | None = None
|
|
689
|
+
) -> list[tuple[str, str, str]]:
|
|
690
|
+
"""Mounts for the toolchain the gate may invoke, as (flag, source, dest).
|
|
691
|
+
|
|
692
|
+
"May invoke" includes the agent runner: a repository whose suite
|
|
693
|
+
exercises its own dispatch path launches the agent *inside* the gate,
|
|
694
|
+
and that inner launch binds the runner by source path. Without it here
|
|
695
|
+
the inner boundary refuses to start at all —
|
|
696
|
+
``bwrap: Can't find source path .../share/claude/versions/2.1.223`` —
|
|
697
|
+
which surfaced as an `agent_error` termination in a test that was
|
|
698
|
+
actually asking a question about signals or deadlines. The whole
|
|
699
|
+
install directory is bound rather than the version the outer launch
|
|
700
|
+
happened to resolve, because the installer keeps several versions and
|
|
701
|
+
prunes them on its own schedule; the inner launch may resolve a
|
|
702
|
+
different one than this one did.
|
|
703
|
+
|
|
704
|
+
The runner's `PATH` entry is *recreated as a symlink* rather than bound,
|
|
705
|
+
and that is not decoration. A bind would flatten it: inside the
|
|
706
|
+
namespace `~/.local/bin/claude` would be a regular file, its
|
|
707
|
+
`<install>/versions/<version>` ancestry gone, and the next boundary in
|
|
708
|
+
(this repository's suite runs its own gates, so there is always a next
|
|
709
|
+
one) would resolve it to itself and bind a lone binary instead of the
|
|
710
|
+
install directory. Measured, before the symlink went in: the full suite
|
|
711
|
+
run inside its own gate failed with ``ls: cannot access
|
|
712
|
+
'/home/admin/.local/share/claude/versions': No such file or directory``.
|
|
713
|
+
Reproducing the layout instead of the file makes discovery give the same
|
|
714
|
+
answer at every depth.
|
|
715
|
+
|
|
716
|
+
Every path here comes from `_toolchain`, never from a literal: the
|
|
717
|
+
version numbers that used to sit in this method rotted out from under
|
|
718
|
+
it on the operator's own machine.
|
|
719
|
+
"""
|
|
720
|
+
resolved = self._toolchain() if tools is None else tools
|
|
721
|
+
binds: list[tuple[str, str, str]] = [
|
|
722
|
+
("--ro-bind", *tool.bind)
|
|
723
|
+
for tool in resolved
|
|
724
|
+
if tool.name != DEFAULT_AGENT_RUNNER
|
|
725
|
+
]
|
|
726
|
+
|
|
727
|
+
# The runner, if this host has one. The installation is looked up
|
|
728
|
+
# independently of the launcher (`find_install_root`) rather than read
|
|
729
|
+
# off it, because the two are absent independently: a boundary built by
|
|
730
|
+
# a factory predating this module mounts the payload and not the
|
|
731
|
+
# launcher, and deriving one from the other lost the payload there.
|
|
732
|
+
runner = next((t for t in resolved if t.name == DEFAULT_AGENT_RUNNER), None)
|
|
733
|
+
root = find_install_root(DEFAULT_AGENT_RUNNER, tool=runner)
|
|
734
|
+
if root is not None:
|
|
735
|
+
binds.append(("--ro-bind", str(root), str(root)))
|
|
736
|
+
if runner is not None:
|
|
737
|
+
if runner.found_at != runner.real_path:
|
|
738
|
+
binds.append(
|
|
739
|
+
("--symlink", str(runner.real_path), str(runner.found_at))
|
|
740
|
+
)
|
|
741
|
+
elif runner.found_at != root:
|
|
742
|
+
binds.append(("--ro-bind", *runner.bind))
|
|
743
|
+
return binds
|
|
744
|
+
|
|
745
|
+
def _identity_env(self) -> dict[str, str]:
|
|
746
|
+
"""A commit identity for the gate, owned by the factory.
|
|
747
|
+
|
|
748
|
+
`HOME` inside the boundary is a tmpfs, so git finds no global config
|
|
749
|
+
and refuses to commit: ``unable to auto-detect email address``. Tests
|
|
750
|
+
that build a throwaway repository and commit to it fail on that alone,
|
|
751
|
+
with a message about identity rather than about what they were
|
|
752
|
+
checking.
|
|
753
|
+
|
|
754
|
+
The operator's `~/.gitconfig` is deliberately *not* bound in to supply
|
|
755
|
+
it. On this host that file carries GitHub credential helpers, and
|
|
756
|
+
mounting it would hand every gate run the operator's push credentials
|
|
757
|
+
— the precise authority a verification boundary exists to withhold.
|
|
758
|
+
Naming the identity in the environment costs one mount less and leaks
|
|
759
|
+
nothing.
|
|
760
|
+
"""
|
|
761
|
+
name = "Ergane Gate"
|
|
762
|
+
email = "gate@ergane.local"
|
|
763
|
+
return {
|
|
764
|
+
"GIT_AUTHOR_NAME": name,
|
|
765
|
+
"GIT_AUTHOR_EMAIL": email,
|
|
766
|
+
"GIT_COMMITTER_NAME": name,
|
|
767
|
+
"GIT_COMMITTER_EMAIL": email,
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
def _resolver_binds(self) -> list[tuple[str, str]]:
|
|
771
|
+
"""Name resolution and trust roots, so a gate's network works at all.
|
|
772
|
+
|
|
773
|
+
The boundary deliberately does not unshare the network (egress is out
|
|
774
|
+
of scope), but a namespace with no `/etc/resolv.conf` cannot resolve a
|
|
775
|
+
hostname, and one with no trust store cannot complete a TLS handshake.
|
|
776
|
+
The result is not "no network" — it is `Temporary failure in name
|
|
777
|
+
resolution` in the middle of a package install, which reads like a
|
|
778
|
+
broken dependency rather than a missing mount.
|
|
779
|
+
|
|
780
|
+
Only these two paths are exposed, never `/etc` wholesale: the rest of
|
|
781
|
+
it is host configuration the gate has no business reading.
|
|
782
|
+
"""
|
|
783
|
+
binds: list[tuple[str, str]] = []
|
|
784
|
+
for path in ("/etc/resolv.conf", "/etc/ssl"):
|
|
785
|
+
if Path(path).exists():
|
|
786
|
+
binds.append((path, path))
|
|
787
|
+
return binds
|
|
788
|
+
|
|
789
|
+
def _cache_binds(self) -> list[tuple[str, str]]:
|
|
790
|
+
"""The package cache, writable, so a gate resolves as it does on the host.
|
|
791
|
+
|
|
792
|
+
`HOME` inside the boundary is a tmpfs, so a package manager finds an
|
|
793
|
+
empty cache and re-downloads everything a sync touches — on a host
|
|
794
|
+
whose cache is already warm, that turns a two-second gate into a
|
|
795
|
+
network-bound one, and on a host without egress it turns a passing
|
|
796
|
+
gate into a failing one. Binding the real cache makes the boundary's
|
|
797
|
+
behaviour match the host's, which is the property a verification
|
|
798
|
+
boundary needs: it changes *where a gate may write*, not *whether the
|
|
799
|
+
gate can run*.
|
|
800
|
+
|
|
801
|
+
Writable on purpose — a read-only cache is worse than none, because
|
|
802
|
+
the manager treats it as a corrupt one. `UV_CACHE_DIR` is set beside
|
|
803
|
+
this bind (the tmpfs HOME would otherwise send uv looking elsewhere).
|
|
804
|
+
"""
|
|
805
|
+
cache = Path.home() / ".cache" / "uv"
|
|
806
|
+
return [(str(cache), str(cache))] if cache.is_dir() else []
|
|
807
|
+
|
|
808
|
+
def _interpreter_binds(self, worktree: Path) -> list[tuple[str, str]]:
|
|
809
|
+
"""Bind the interpreter the worktree's virtualenv points at, if any.
|
|
810
|
+
|
|
811
|
+
A `uv` virtualenv's `bin/python` is a symlink to an interpreter outside
|
|
812
|
+
the worktree — on this host, under `~/.local/share/uv/python/`. Inside
|
|
813
|
+
a boundary that does not carry it the symlink dangles, `uv` reports
|
|
814
|
+
"Ignoring existing virtual environment linked to non-existent Python
|
|
815
|
+
interpreter" and tries to *rebuild* the venv, which is how
|
|
816
|
+
`033-ergane-install/us2` turned a read-only worktree into
|
|
817
|
+
``failed to remove directory .venv/bin`` rather than a clean error.
|
|
818
|
+
|
|
819
|
+
The path is read from the venv rather than hardcoded: a Python version
|
|
820
|
+
bump moves it, and a stale literal here would reintroduce the same
|
|
821
|
+
failure with a new version number in it. Nothing is bound when there is
|
|
822
|
+
no venv — a repo whose gates need no interpreter is not this method's
|
|
823
|
+
business.
|
|
824
|
+
"""
|
|
825
|
+
binds: list[tuple[str, str]] = []
|
|
826
|
+
|
|
827
|
+
# The managed-interpreter store, whole. Binding only the one version a
|
|
828
|
+
# venv currently points at is not enough: when the manager decides to
|
|
829
|
+
# rebuild the environment it re-runs interpreter *discovery*, and a
|
|
830
|
+
# store holding a single version reads as a store missing the one it
|
|
831
|
+
# wants — it then falls back to the system python, and the gate silently
|
|
832
|
+
# runs on a different interpreter than the host. Measured on
|
|
833
|
+
# 2026-08-15: the boundary reported 3.12.3 where the host reported
|
|
834
|
+
# 3.13.12. A verification boundary that changes the interpreter changes
|
|
835
|
+
# what verification measures, so the whole read-only store is bound.
|
|
836
|
+
store = Path.home() / ".local" / "share" / "uv" / "python"
|
|
837
|
+
if store.is_dir():
|
|
838
|
+
binds.append((str(store), str(store)))
|
|
839
|
+
|
|
840
|
+
# And the interpreter the venv actually points at, wherever it lives —
|
|
841
|
+
# a system or pyenv interpreter is outside the store above.
|
|
842
|
+
link = worktree / ".venv" / "bin" / "python"
|
|
843
|
+
try:
|
|
844
|
+
target = link.resolve(strict=True)
|
|
845
|
+
except (OSError, RuntimeError):
|
|
846
|
+
return binds
|
|
847
|
+
if not target.is_file():
|
|
848
|
+
return binds
|
|
849
|
+
# The interpreter's own installation tree — its stdlib sits beside the
|
|
850
|
+
# binary, so binding the executable alone yields an interpreter that
|
|
851
|
+
# cannot import anything.
|
|
852
|
+
root = target.parent.parent
|
|
853
|
+
if not str(root).startswith(str(store)):
|
|
854
|
+
binds.append((str(root), str(root)))
|
|
855
|
+
return binds
|
|
856
|
+
|
|
857
|
+
def _container_path(self) -> str:
|
|
858
|
+
"""The container's `PATH`, derived from the discovered toolchain.
|
|
859
|
+
|
|
860
|
+
Kept as a method because it is the one line of `_build_argv` a reader
|
|
861
|
+
looks for; the derivation itself belongs beside the discovery, in
|
|
862
|
+
`factory.verify.toolchain`, so the agent boundary shares it.
|
|
863
|
+
"""
|
|
864
|
+
return container_path(self._toolchain())
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _resolve_target_git_dir(worktree: Path) -> Path | None:
|
|
868
|
+
"""Return the parent repository's `.git` directory if this is a worktree.
|
|
869
|
+
|
|
870
|
+
A normal git repository has `.git` as a directory. A linked worktree
|
|
871
|
+
has `.git` as a file pointing at the real metadata under the parent
|
|
872
|
+
repo's `.git/worktrees/<name>`. Either way the parent `.git` directory
|
|
873
|
+
must be mounted writable for commit/diff to work (trap 1).
|
|
874
|
+
"""
|
|
875
|
+
git_file = worktree / ".git"
|
|
876
|
+
if not git_file.exists():
|
|
877
|
+
return None
|
|
878
|
+
if git_file.is_dir():
|
|
879
|
+
return git_file.resolve()
|
|
880
|
+
try:
|
|
881
|
+
text = git_file.read_text(encoding="utf-8").strip()
|
|
882
|
+
except OSError:
|
|
883
|
+
return None
|
|
884
|
+
prefix = "gitdir:"
|
|
885
|
+
if not text.startswith(prefix):
|
|
886
|
+
return None
|
|
887
|
+
gitdir = Path(text[len(prefix):].strip()).resolve()
|
|
888
|
+
return gitdir.parent.parent.resolve()
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
# Candidate parser implementation --------------------------------------------
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _interpret_candidate(
|
|
895
|
+
exit_code: int, stdout: str, stderr: str, timed_out: bool
|
|
896
|
+
) -> _CandidateResult:
|
|
897
|
+
"""Map a candidate parser subprocess result to accepted/rejected/cannot-run.
|
|
898
|
+
|
|
899
|
+
Exit 0 alone is not acceptance (spec US2-S5, plan trap 2): a pre-CLI
|
|
900
|
+
worktree's `factory.verify.factory_yaml` runs its module body, prints
|
|
901
|
+
nothing, and exits 0. Only exit 0 **and** stdout that parses as a valid
|
|
902
|
+
protocol JSON document means accepted. Any other exit code that is not the
|
|
903
|
+
documented rejection code is cannot-run, including exit 2 from `uv run`
|
|
904
|
+
over a worktree whose `pyproject.toml` an agent broke (plan trap 9).
|
|
905
|
+
"""
|
|
906
|
+
if timed_out:
|
|
907
|
+
return _CannotRun(reason="candidate parse timed out")
|
|
908
|
+
|
|
909
|
+
if exit_code == PARSE_CLI_REJECTED:
|
|
910
|
+
return _RejectedConfig(message=stderr.rstrip())
|
|
911
|
+
|
|
912
|
+
if exit_code != PARSE_CLI_OK:
|
|
913
|
+
return _CannotRun(reason=f"non-rejection exit code {exit_code}")
|
|
914
|
+
|
|
915
|
+
try:
|
|
916
|
+
document = json.loads(stdout)
|
|
917
|
+
except json.JSONDecodeError:
|
|
918
|
+
return _CannotRun(
|
|
919
|
+
reason="exit 0 but stdout did not parse as protocol JSON"
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
if not isinstance(document, dict):
|
|
923
|
+
return _CannotRun(reason="exit 0 but stdout did not parse as protocol JSON")
|
|
924
|
+
|
|
925
|
+
gates_view = document.get("gates")
|
|
926
|
+
timeouts_view = document.get("timeouts", {})
|
|
927
|
+
|
|
928
|
+
if not isinstance(gates_view, dict) or not gates_view:
|
|
929
|
+
return _CannotRun(reason="protocol gates mapping is empty")
|
|
930
|
+
if not all(isinstance(name, str) and isinstance(cmd, str) and cmd.strip()
|
|
931
|
+
for name, cmd in gates_view.items()):
|
|
932
|
+
return _CannotRun(
|
|
933
|
+
reason="protocol gates mapping is not a non-empty str-to-str mapping"
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
if not isinstance(timeouts_view, dict) or not all(
|
|
937
|
+
isinstance(name, str) and isinstance(seconds, int) and seconds > 0
|
|
938
|
+
for name, seconds in timeouts_view.items()
|
|
939
|
+
):
|
|
940
|
+
return _CannotRun(
|
|
941
|
+
reason="protocol timeouts are not str-to-positive-int"
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
return _AcceptedConfig(
|
|
945
|
+
gates=dict(gates_view),
|
|
946
|
+
timeouts=dict(timeouts_view),
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
class SubprocessCandidateRunner:
|
|
951
|
+
"""Runs the worktree's candidate parser as `uv run -q python -m ...`.
|
|
952
|
+
|
|
953
|
+
The protocol needs **separated** stdout/stderr streams, so this runner has
|
|
954
|
+
its own small subprocess plumbing rather than reusing `SubprocessGateExecutor`,
|
|
955
|
+
which merges stderr into stdout. stdout is parsed as protocol JSON; stderr is
|
|
956
|
+
kept for rejection and cannot-run messages.
|
|
957
|
+
|
|
958
|
+
The same environment discipline and group-kill timeout discipline as gate
|
|
959
|
+
commands apply here (FR-007): the subprocess gets `scrubbed_env()`, its own
|
|
960
|
+
session, a TERM-then-KILL reclaim on timeout, and the same default deadline.
|
|
961
|
+
The concurrency limiter is acquired around the whole run because a cold
|
|
962
|
+
`uv run` may build a venv (plan trap 5).
|
|
963
|
+
"""
|
|
964
|
+
|
|
965
|
+
def __init__(
|
|
966
|
+
self,
|
|
967
|
+
*,
|
|
968
|
+
timeout_s: int = DEFAULT_GATE_TIMEOUT_S,
|
|
969
|
+
grace_s: float = DEFAULT_KILL_GRACE_S,
|
|
970
|
+
limiter: GateConcurrencyLimiter | None = None,
|
|
971
|
+
) -> None:
|
|
972
|
+
self.timeout_s = timeout_s
|
|
973
|
+
self.grace_s = grace_s
|
|
974
|
+
self.limiter = limiter if limiter is not None else _default_limiter
|
|
975
|
+
|
|
976
|
+
def run(self, worktree: Path, manifest: Path) -> CandidateOutcome:
|
|
977
|
+
peers = self.limiter.acquire()
|
|
978
|
+
try:
|
|
979
|
+
return self._run_in_slot(worktree, manifest)
|
|
980
|
+
finally:
|
|
981
|
+
self.limiter.release()
|
|
982
|
+
|
|
983
|
+
def _run_in_slot(self, worktree: Path, manifest: Path) -> CandidateOutcome:
|
|
984
|
+
candidate_path = worktree / "factory" / "verify" / "factory_yaml.py"
|
|
985
|
+
if not candidate_path.exists():
|
|
986
|
+
return CandidateOutcome(
|
|
987
|
+
exit_code=-1,
|
|
988
|
+
stdout="",
|
|
989
|
+
stderr="",
|
|
990
|
+
timed_out=False,
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
started = time.monotonic()
|
|
994
|
+
process = subprocess.Popen(
|
|
995
|
+
[
|
|
996
|
+
"uv",
|
|
997
|
+
"run",
|
|
998
|
+
"-q",
|
|
999
|
+
"python",
|
|
1000
|
+
"-m",
|
|
1001
|
+
"factory.verify.factory_yaml",
|
|
1002
|
+
str(manifest),
|
|
1003
|
+
],
|
|
1004
|
+
cwd=str(worktree),
|
|
1005
|
+
env=scrubbed_env(),
|
|
1006
|
+
stdin=subprocess.DEVNULL,
|
|
1007
|
+
stdout=subprocess.PIPE,
|
|
1008
|
+
stderr=subprocess.PIPE,
|
|
1009
|
+
start_new_session=True,
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
stdout_buffer = _TailBuffer()
|
|
1013
|
+
stderr_buffer = _TailBuffer()
|
|
1014
|
+
stdout_drain = threading.Thread(
|
|
1015
|
+
target=_drain,
|
|
1016
|
+
args=(process.stdout, stdout_buffer),
|
|
1017
|
+
name="candidate-parser-stdout",
|
|
1018
|
+
daemon=True,
|
|
1019
|
+
)
|
|
1020
|
+
stderr_drain = threading.Thread(
|
|
1021
|
+
target=_drain,
|
|
1022
|
+
args=(process.stderr, stderr_buffer),
|
|
1023
|
+
name="candidate-parser-stderr",
|
|
1024
|
+
daemon=True,
|
|
1025
|
+
)
|
|
1026
|
+
stdout_drain.start()
|
|
1027
|
+
stderr_drain.start()
|
|
1028
|
+
|
|
1029
|
+
timed_out = False
|
|
1030
|
+
try:
|
|
1031
|
+
process.wait(timeout=self.timeout_s)
|
|
1032
|
+
except subprocess.TimeoutExpired:
|
|
1033
|
+
timed_out = True
|
|
1034
|
+
self._reclaim(process)
|
|
1035
|
+
|
|
1036
|
+
stdout_drain.join(timeout=_DRAIN_JOIN_S)
|
|
1037
|
+
stderr_drain.join(timeout=_DRAIN_JOIN_S)
|
|
1038
|
+
|
|
1039
|
+
return CandidateOutcome(
|
|
1040
|
+
exit_code=None if timed_out else process.returncode,
|
|
1041
|
+
stdout=stdout_buffer.text(),
|
|
1042
|
+
stderr=stderr_buffer.text(),
|
|
1043
|
+
timed_out=timed_out,
|
|
1044
|
+
)
|
|
1045
|
+
|
|
1046
|
+
def _reclaim(self, process: subprocess.Popen[bytes]) -> None:
|
|
1047
|
+
"""SIGTERM the candidate's process group, then SIGKILL what survives."""
|
|
1048
|
+
_signal_group(process, signal.SIGTERM)
|
|
1049
|
+
try:
|
|
1050
|
+
process.wait(timeout=self.grace_s)
|
|
1051
|
+
except subprocess.TimeoutExpired:
|
|
1052
|
+
pass
|
|
1053
|
+
_signal_group(process, signal.SIGKILL)
|
|
1054
|
+
process.wait()
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
_default_candidate_runner = SubprocessCandidateRunner()
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
# The runner -----------------------------------------------------------------
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def run_gates(
|
|
1064
|
+
worktree: Path | str,
|
|
1065
|
+
*,
|
|
1066
|
+
manifest_path: Path | str | None = None,
|
|
1067
|
+
executor: GateExecutor | None = None,
|
|
1068
|
+
timeout_overrides: Mapping[str, int] | None = None,
|
|
1069
|
+
concurrency_limiter: GateConcurrencyLimiter | None = None,
|
|
1070
|
+
candidate_runner: CandidateRunner | None = None,
|
|
1071
|
+
) -> list[GateResult]:
|
|
1072
|
+
"""Run every gate the manifest declares, in declaration order, and report each.
|
|
1073
|
+
|
|
1074
|
+
One result per declared gate — a failure or a timeout never cancels the
|
|
1075
|
+
gates after it, because the contract promises the caller a complete picture
|
|
1076
|
+
(contracts/activities.md) and a half-run suite understates what is broken.
|
|
1077
|
+
|
|
1078
|
+
`manifest_path` defaults to `<worktree>/factory.yaml` but is separately
|
|
1079
|
+
settable: the activity receives both paths, and conflating them would make
|
|
1080
|
+
the manifest unreadable from anywhere but the worktree. An unusable manifest
|
|
1081
|
+
— absent, unparseable, or schema-invalid — returns exactly one
|
|
1082
|
+
`CONFIG_ERROR` result and runs nothing at all.
|
|
1083
|
+
|
|
1084
|
+
`concurrency_limiter` bounds how many gates run at once across the host
|
|
1085
|
+
(007 FR-005): a gate acquires its slot before its process starts, so its
|
|
1086
|
+
wall-clock bound measures the gate's own work and never the queue it waited
|
|
1087
|
+
in — which is what keeps a neighbour's load from moving this node's verdict.
|
|
1088
|
+
Defaults to the process-level limiter, so two `run_gates` calls in two
|
|
1089
|
+
worker threads (the shape fan-out produces) share one bound.
|
|
1090
|
+
|
|
1091
|
+
`candidate_runner` resolves the manifest: when the worktree carries a
|
|
1092
|
+
candidate parser it is consulted first, via a subprocess inside the
|
|
1093
|
+
worktree, with the worker's imported parser as fallback. A candidate
|
|
1094
|
+
rejection is one `CONFIG_ERROR` carrying the candidate's message; a
|
|
1095
|
+
candidate that cannot run falls back to today's in-process behaviour.
|
|
1096
|
+
"""
|
|
1097
|
+
worktree = Path(worktree)
|
|
1098
|
+
manifest = (
|
|
1099
|
+
worktree / MANIFEST_NAME if manifest_path is None else Path(manifest_path)
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
# Resolve the backend early so a broken manifest still produces a CONFIG_ERROR
|
|
1103
|
+
# from the same code path, and so an explicit executor wins over runtime.
|
|
1104
|
+
backend = _resolve_gate_executor(
|
|
1105
|
+
executor, _read_runtime_from_manifest(manifest)
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
candidate_path = worktree / "factory" / "verify" / "factory_yaml.py"
|
|
1109
|
+
if candidate_runner is None:
|
|
1110
|
+
run_candidate = _default_candidate_runner.run
|
|
1111
|
+
else:
|
|
1112
|
+
run_candidate = candidate_runner
|
|
1113
|
+
if candidate_path.exists():
|
|
1114
|
+
candidate = run_candidate(worktree, manifest)
|
|
1115
|
+
interpreted = _interpret_candidate(
|
|
1116
|
+
candidate.exit_code, candidate.stdout, candidate.stderr, candidate.timed_out
|
|
1117
|
+
)
|
|
1118
|
+
else:
|
|
1119
|
+
interpreted = _CannotRun(reason="no candidate parser in worktree")
|
|
1120
|
+
|
|
1121
|
+
if isinstance(interpreted, _AcceptedConfig):
|
|
1122
|
+
gates_view = interpreted.gates or {}
|
|
1123
|
+
timeouts_view = interpreted.timeouts or {}
|
|
1124
|
+
return _run_gate_list(
|
|
1125
|
+
worktree,
|
|
1126
|
+
manifest,
|
|
1127
|
+
gates_view,
|
|
1128
|
+
timeouts_view,
|
|
1129
|
+
executor=backend,
|
|
1130
|
+
timeout_overrides=timeout_overrides,
|
|
1131
|
+
concurrency_limiter=concurrency_limiter,
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
if isinstance(interpreted, _RejectedConfig):
|
|
1135
|
+
return [
|
|
1136
|
+
GateResult(
|
|
1137
|
+
name="config",
|
|
1138
|
+
command="",
|
|
1139
|
+
status=GateStatus.CONFIG_ERROR,
|
|
1140
|
+
exit_code=None,
|
|
1141
|
+
duration_s=0.0,
|
|
1142
|
+
output_tail=interpreted.message,
|
|
1143
|
+
)
|
|
1144
|
+
]
|
|
1145
|
+
|
|
1146
|
+
# cannot-run -> fallback to the worker's imported parser, byte-for-byte
|
|
1147
|
+
# today's behaviour, but record the candidate's failure if the worker also
|
|
1148
|
+
# refuses.
|
|
1149
|
+
try:
|
|
1150
|
+
config = load_factory_config(manifest)
|
|
1151
|
+
except FactoryConfigError as error:
|
|
1152
|
+
fallback = config_error_result(error)
|
|
1153
|
+
reason = getattr(interpreted, "reason", "")
|
|
1154
|
+
if reason:
|
|
1155
|
+
return [
|
|
1156
|
+
GateResult(
|
|
1157
|
+
name=fallback.name,
|
|
1158
|
+
command=fallback.command,
|
|
1159
|
+
status=fallback.status,
|
|
1160
|
+
exit_code=fallback.exit_code,
|
|
1161
|
+
duration_s=fallback.duration_s,
|
|
1162
|
+
output_tail=f"{fallback.output_tail}\n[candidate parser could not run: {reason}]",
|
|
1163
|
+
)
|
|
1164
|
+
]
|
|
1165
|
+
return [fallback]
|
|
1166
|
+
|
|
1167
|
+
return _run_gate_list_from_config(
|
|
1168
|
+
worktree,
|
|
1169
|
+
config,
|
|
1170
|
+
executor=backend,
|
|
1171
|
+
timeout_overrides=timeout_overrides,
|
|
1172
|
+
concurrency_limiter=concurrency_limiter,
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _resolve_gate_executor(
|
|
1177
|
+
executor: GateExecutor | None, runtime: str | None
|
|
1178
|
+
) -> GateExecutor:
|
|
1179
|
+
"""Choose the gate backend from the manifest's `runtime:` when not overridden.
|
|
1180
|
+
|
|
1181
|
+
An explicit `executor` is used verbatim -- tests and callers that want a
|
|
1182
|
+
specific backend keep control. When the manifest declares `runtime: bwrap`
|
|
1183
|
+
and the system binary is present, the gate runs inside the same boundary as
|
|
1184
|
+
the agent (US5, FR-009). Otherwise the host subprocess executor is used,
|
|
1185
|
+
which keeps the suite green on hosts where bwrap is not installed (trap 5).
|
|
1186
|
+
"""
|
|
1187
|
+
if executor is not None:
|
|
1188
|
+
return executor
|
|
1189
|
+
if runtime == "bwrap" and BWRAP_BACKEND_BINARY.is_file():
|
|
1190
|
+
return BwrapGateExecutor()
|
|
1191
|
+
return SubprocessGateExecutor()
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def _read_runtime_from_manifest(manifest: Path) -> str | None:
|
|
1195
|
+
"""Return the manifest's `runtime:` value, or None if the manifest is unusable."""
|
|
1196
|
+
try:
|
|
1197
|
+
return load_factory_config(manifest).runtime
|
|
1198
|
+
except FactoryConfigError:
|
|
1199
|
+
return None
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def resolve_gate_executor(
|
|
1203
|
+
worktree: Path | str,
|
|
1204
|
+
*,
|
|
1205
|
+
manifest_path: Path | str | None = None,
|
|
1206
|
+
executor: GateExecutor | None = None,
|
|
1207
|
+
) -> GateExecutor:
|
|
1208
|
+
"""Public helper for callers that need the runtime-selected backend.
|
|
1209
|
+
|
|
1210
|
+
The verify activity uses this so its heartbeating wrapper wraps the same
|
|
1211
|
+
backend `run_gates` would have chosen (US5).
|
|
1212
|
+
"""
|
|
1213
|
+
worktree = Path(worktree)
|
|
1214
|
+
manifest = (
|
|
1215
|
+
worktree / MANIFEST_NAME if manifest_path is None else Path(manifest_path)
|
|
1216
|
+
)
|
|
1217
|
+
return _resolve_gate_executor(
|
|
1218
|
+
executor, _read_runtime_from_manifest(manifest)
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def _run_gate_list(
|
|
1223
|
+
worktree: Path,
|
|
1224
|
+
manifest: Path,
|
|
1225
|
+
gates_view: Mapping[str, str],
|
|
1226
|
+
timeouts_view: Mapping[str, int],
|
|
1227
|
+
*,
|
|
1228
|
+
executor: GateExecutor,
|
|
1229
|
+
timeout_overrides: Mapping[str, int] | None,
|
|
1230
|
+
concurrency_limiter: GateConcurrencyLimiter | None,
|
|
1231
|
+
) -> list[GateResult]:
|
|
1232
|
+
"""Run gates from a JSON view (candidate acceptance or fallback)."""
|
|
1233
|
+
backend = executor
|
|
1234
|
+
overrides = dict(timeout_overrides or {})
|
|
1235
|
+
env = scrubbed_env()
|
|
1236
|
+
limiter = (
|
|
1237
|
+
concurrency_limiter
|
|
1238
|
+
if concurrency_limiter is not None
|
|
1239
|
+
else _default_limiter
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
results: list[GateResult] = []
|
|
1243
|
+
for name, command in gates_view.items():
|
|
1244
|
+
invocation = GateInvocation(
|
|
1245
|
+
name=name,
|
|
1246
|
+
command=command,
|
|
1247
|
+
cwd=worktree,
|
|
1248
|
+
timeout_s=_resolve_timeout(name, timeouts_view, overrides),
|
|
1249
|
+
env=env,
|
|
1250
|
+
)
|
|
1251
|
+
peers = limiter.acquire()
|
|
1252
|
+
try:
|
|
1253
|
+
outcome = backend.run(invocation)
|
|
1254
|
+
finally:
|
|
1255
|
+
limiter.release()
|
|
1256
|
+
results.append(_to_result(invocation, outcome, peers))
|
|
1257
|
+
return results
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def _run_gate_list_from_config(
|
|
1261
|
+
worktree: Path,
|
|
1262
|
+
config,
|
|
1263
|
+
*,
|
|
1264
|
+
executor: GateExecutor,
|
|
1265
|
+
timeout_overrides: Mapping[str, int] | None,
|
|
1266
|
+
concurrency_limiter: GateConcurrencyLimiter | None,
|
|
1267
|
+
) -> list[GateResult]:
|
|
1268
|
+
"""Run gates from an in-process FactoryConfig (today's fallback path)."""
|
|
1269
|
+
backend = executor
|
|
1270
|
+
overrides = dict(timeout_overrides or {})
|
|
1271
|
+
env = scrubbed_env()
|
|
1272
|
+
limiter = (
|
|
1273
|
+
concurrency_limiter
|
|
1274
|
+
if concurrency_limiter is not None
|
|
1275
|
+
else _default_limiter
|
|
1276
|
+
)
|
|
1277
|
+
|
|
1278
|
+
results: list[GateResult] = []
|
|
1279
|
+
for name, command in config.gates.items():
|
|
1280
|
+
invocation = GateInvocation(
|
|
1281
|
+
name=name,
|
|
1282
|
+
command=command,
|
|
1283
|
+
cwd=worktree,
|
|
1284
|
+
timeout_s=_resolve_timeout(name, config.timeouts, overrides),
|
|
1285
|
+
env=env,
|
|
1286
|
+
)
|
|
1287
|
+
peers = limiter.acquire()
|
|
1288
|
+
try:
|
|
1289
|
+
outcome = backend.run(invocation)
|
|
1290
|
+
finally:
|
|
1291
|
+
limiter.release()
|
|
1292
|
+
results.append(_to_result(invocation, outcome, peers))
|
|
1293
|
+
return results
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def _resolve_timeout(
|
|
1297
|
+
name: str, declared: Mapping[str, int], overrides: Mapping[str, int]
|
|
1298
|
+
) -> int:
|
|
1299
|
+
"""Caller's override, else the manifest's declaration, else the default.
|
|
1300
|
+
|
|
1301
|
+
The caller wins because overrides come from the verification config an
|
|
1302
|
+
operator is holding right now; the manifest wins over the default because
|
|
1303
|
+
the repo knows which of its suites is the slow one.
|
|
1304
|
+
"""
|
|
1305
|
+
if name in overrides:
|
|
1306
|
+
return overrides[name]
|
|
1307
|
+
return declared.get(name, DEFAULT_GATE_TIMEOUT_S)
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def _to_result(
|
|
1311
|
+
invocation: GateInvocation,
|
|
1312
|
+
outcome: ExecutionOutcome,
|
|
1313
|
+
concurrent_gates: int = 0,
|
|
1314
|
+
) -> GateResult:
|
|
1315
|
+
"""Turn one execution into the evidence the verdict truth table reads.
|
|
1316
|
+
|
|
1317
|
+
`tail_output` is applied here as well as in the executor: the cap is a
|
|
1318
|
+
property of a `GateResult`, and a future executor across the `GateExecutor`
|
|
1319
|
+
seam should not be able to widen it by forgetting.
|
|
1320
|
+
|
|
1321
|
+
`concurrent_gates` is the contention marker (007 FR-005): how many *other*
|
|
1322
|
+
gates were in flight when this one got its turn. Zero for a gate that ran
|
|
1323
|
+
alone; a count for one that ran alongside peers, so a slow verdict is
|
|
1324
|
+
auditable rather than mysterious.
|
|
1325
|
+
"""
|
|
1326
|
+
if outcome.timed_out:
|
|
1327
|
+
status, exit_code = GateStatus.TIMEOUT, None
|
|
1328
|
+
elif outcome.exit_code == 0:
|
|
1329
|
+
status, exit_code = GateStatus.PASS, 0
|
|
1330
|
+
else:
|
|
1331
|
+
status, exit_code = GateStatus.FAIL, outcome.exit_code
|
|
1332
|
+
|
|
1333
|
+
return GateResult(
|
|
1334
|
+
name=invocation.name,
|
|
1335
|
+
command=invocation.command,
|
|
1336
|
+
status=status,
|
|
1337
|
+
exit_code=exit_code,
|
|
1338
|
+
duration_s=outcome.duration_s,
|
|
1339
|
+
output_tail=tail_output(outcome.output),
|
|
1340
|
+
concurrent_gates=concurrent_gates,
|
|
1341
|
+
)
|