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.
Files changed (121) hide show
  1. ergane_cli-0.1.0.dist-info/METADATA +148 -0
  2. ergane_cli-0.1.0.dist-info/RECORD +121 -0
  3. ergane_cli-0.1.0.dist-info/WHEEL +4 -0
  4. ergane_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. ergane_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  6. ergane_cli-0.1.0.dist-info/licenses/NOTICE +27 -0
  7. factory/__init__.py +0 -0
  8. factory/activities/__init__.py +0 -0
  9. factory/activities/agent_activities.py +745 -0
  10. factory/activities/merge_activities.py +654 -0
  11. factory/activities/notify_activities.py +1078 -0
  12. factory/activities/roadmap_activities.py +577 -0
  13. factory/activities/usage_activities.py +593 -0
  14. factory/activities/verify_activities.py +617 -0
  15. factory/cli/__init__.py +0 -0
  16. factory/cli/completion.py +150 -0
  17. factory/cli/doctor.py +484 -0
  18. factory/cli/env.py +247 -0
  19. factory/cli/errors.py +77 -0
  20. factory/cli/init.py +1057 -0
  21. factory/cli/install.py +679 -0
  22. factory/cli/main.py +212 -0
  23. factory/cli/nouns/__init__.py +59 -0
  24. factory/cli/nouns/answer.py +134 -0
  25. factory/cli/nouns/build.py +1222 -0
  26. factory/cli/nouns/completion.py +13 -0
  27. factory/cli/nouns/doctor.py +13 -0
  28. factory/cli/nouns/env.py +13 -0
  29. factory/cli/nouns/escalations.py +93 -0
  30. factory/cli/nouns/findings.py +13 -0
  31. factory/cli/nouns/init.py +13 -0
  32. factory/cli/nouns/install.py +99 -0
  33. factory/cli/nouns/repo.py +13 -0
  34. factory/cli/nouns/roadmap.py +13 -0
  35. factory/cli/nouns/spec.py +511 -0
  36. factory/cli/nouns/status.py +18 -0
  37. factory/cli/nouns/usage.py +13 -0
  38. factory/cli/nouns/worker.py +69 -0
  39. factory/cli/repo.py +569 -0
  40. factory/cli/repo_export.py +282 -0
  41. factory/cli/roadmap.py +434 -0
  42. factory/cli/status.py +844 -0
  43. factory/cli/usage.py +129 -0
  44. factory/config.py +262 -0
  45. factory/controlplane/__init__.py +0 -0
  46. factory/controlplane/config.py +863 -0
  47. factory/controlplane/resolve.py +434 -0
  48. factory/controlplane/verify.py +827 -0
  49. factory/discovery/__init__.py +3 -0
  50. factory/discovery/llm_scanner.py +176 -0
  51. factory/doctor/__init__.py +0 -0
  52. factory/doctor/cli.py +392 -0
  53. factory/doctor/models.py +165 -0
  54. factory/doctor/probes.py +522 -0
  55. factory/doctor/scaffold.py +184 -0
  56. factory/doctor/store.py +449 -0
  57. factory/env.py +131 -0
  58. factory/escalation/__init__.py +0 -0
  59. factory/escalation/client.py +168 -0
  60. factory/escalation/question.py +161 -0
  61. factory/escalation/workflow.py +470 -0
  62. factory/locking.py +85 -0
  63. factory/mergequeue/__init__.py +21 -0
  64. factory/mergequeue/classify.py +133 -0
  65. factory/mergequeue/forge.py +401 -0
  66. factory/mergequeue/gh.py +484 -0
  67. factory/mergequeue/github_forge.py +349 -0
  68. factory/mergequeue/merge_queue_ruleset.json +32 -0
  69. factory/mergequeue/messages.py +141 -0
  70. factory/mergequeue/models.py +324 -0
  71. factory/mergequeue/onboard.py +537 -0
  72. factory/mergequeue/wiring.py +466 -0
  73. factory/notify/__init__.py +0 -0
  74. factory/notify/adapter.py +263 -0
  75. factory/notify/messages.py +474 -0
  76. factory/notify/service.py +809 -0
  77. factory/notify/webhook.py +151 -0
  78. factory/personas.yaml +161 -0
  79. factory/registry.py +583 -0
  80. factory/roadmap/__init__.py +8 -0
  81. factory/roadmap/cli.py +125 -0
  82. factory/roadmap/discovery.py +286 -0
  83. factory/roadmap/models.py +583 -0
  84. factory/roadmap/schedule.py +377 -0
  85. factory/roadmap/workflow.py +1364 -0
  86. factory/supervision/__init__.py +0 -0
  87. factory/supervision/alert.py +239 -0
  88. factory/supervision/probe.py +467 -0
  89. factory/supervision/temporal_server.py +121 -0
  90. factory/supervision/units.py +649 -0
  91. factory/usage/__init__.py +0 -0
  92. factory/usage/aggregate.py +113 -0
  93. factory/usage/cli.py +223 -0
  94. factory/usage/ledger.py +295 -0
  95. factory/usage/litellm_client.py +486 -0
  96. factory/usage/models.py +149 -0
  97. factory/verify/__init__.py +0 -0
  98. factory/verify/criteria.py +488 -0
  99. factory/verify/diffbounds.py +159 -0
  100. factory/verify/diffcheck.py +506 -0
  101. factory/verify/factory_yaml.py +842 -0
  102. factory/verify/gates.py +1341 -0
  103. factory/verify/judge.py +775 -0
  104. factory/verify/ladder.py +145 -0
  105. factory/verify/models.py +745 -0
  106. factory/verify/question.py +167 -0
  107. factory/verify/store.py +1366 -0
  108. factory/verify/toolchain.py +370 -0
  109. factory/worker.py +296 -0
  110. factory/workgraph/__init__.py +0 -0
  111. factory/workgraph/adapter.py +1333 -0
  112. factory/workgraph/cli.py +801 -0
  113. factory/workgraph/delta.py +278 -0
  114. factory/workgraph/derive.py +607 -0
  115. factory/workgraph/detector.py +509 -0
  116. factory/workgraph/landed.py +496 -0
  117. factory/workgraph/models.py +494 -0
  118. factory/workgraph/preflight.py +651 -0
  119. factory/workgraph/prompt.py +796 -0
  120. factory/workgraph/workflow.py +2733 -0
  121. factory/workgraph/worktree.py +1322 -0
@@ -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
+ )