ergane-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ergane_cli-0.1.0.dist-info/METADATA +148 -0
- ergane_cli-0.1.0.dist-info/RECORD +121 -0
- ergane_cli-0.1.0.dist-info/WHEEL +4 -0
- ergane_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ergane_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- ergane_cli-0.1.0.dist-info/licenses/NOTICE +27 -0
- factory/__init__.py +0 -0
- factory/activities/__init__.py +0 -0
- factory/activities/agent_activities.py +745 -0
- factory/activities/merge_activities.py +654 -0
- factory/activities/notify_activities.py +1078 -0
- factory/activities/roadmap_activities.py +577 -0
- factory/activities/usage_activities.py +593 -0
- factory/activities/verify_activities.py +617 -0
- factory/cli/__init__.py +0 -0
- factory/cli/completion.py +150 -0
- factory/cli/doctor.py +484 -0
- factory/cli/env.py +247 -0
- factory/cli/errors.py +77 -0
- factory/cli/init.py +1057 -0
- factory/cli/install.py +679 -0
- factory/cli/main.py +212 -0
- factory/cli/nouns/__init__.py +59 -0
- factory/cli/nouns/answer.py +134 -0
- factory/cli/nouns/build.py +1222 -0
- factory/cli/nouns/completion.py +13 -0
- factory/cli/nouns/doctor.py +13 -0
- factory/cli/nouns/env.py +13 -0
- factory/cli/nouns/escalations.py +93 -0
- factory/cli/nouns/findings.py +13 -0
- factory/cli/nouns/init.py +13 -0
- factory/cli/nouns/install.py +99 -0
- factory/cli/nouns/repo.py +13 -0
- factory/cli/nouns/roadmap.py +13 -0
- factory/cli/nouns/spec.py +511 -0
- factory/cli/nouns/status.py +18 -0
- factory/cli/nouns/usage.py +13 -0
- factory/cli/nouns/worker.py +69 -0
- factory/cli/repo.py +569 -0
- factory/cli/repo_export.py +282 -0
- factory/cli/roadmap.py +434 -0
- factory/cli/status.py +844 -0
- factory/cli/usage.py +129 -0
- factory/config.py +262 -0
- factory/controlplane/__init__.py +0 -0
- factory/controlplane/config.py +863 -0
- factory/controlplane/resolve.py +434 -0
- factory/controlplane/verify.py +827 -0
- factory/discovery/__init__.py +3 -0
- factory/discovery/llm_scanner.py +176 -0
- factory/doctor/__init__.py +0 -0
- factory/doctor/cli.py +392 -0
- factory/doctor/models.py +165 -0
- factory/doctor/probes.py +522 -0
- factory/doctor/scaffold.py +184 -0
- factory/doctor/store.py +449 -0
- factory/env.py +131 -0
- factory/escalation/__init__.py +0 -0
- factory/escalation/client.py +168 -0
- factory/escalation/question.py +161 -0
- factory/escalation/workflow.py +470 -0
- factory/locking.py +85 -0
- factory/mergequeue/__init__.py +21 -0
- factory/mergequeue/classify.py +133 -0
- factory/mergequeue/forge.py +401 -0
- factory/mergequeue/gh.py +484 -0
- factory/mergequeue/github_forge.py +349 -0
- factory/mergequeue/merge_queue_ruleset.json +32 -0
- factory/mergequeue/messages.py +141 -0
- factory/mergequeue/models.py +324 -0
- factory/mergequeue/onboard.py +537 -0
- factory/mergequeue/wiring.py +466 -0
- factory/notify/__init__.py +0 -0
- factory/notify/adapter.py +263 -0
- factory/notify/messages.py +474 -0
- factory/notify/service.py +809 -0
- factory/notify/webhook.py +151 -0
- factory/personas.yaml +161 -0
- factory/registry.py +583 -0
- factory/roadmap/__init__.py +8 -0
- factory/roadmap/cli.py +125 -0
- factory/roadmap/discovery.py +286 -0
- factory/roadmap/models.py +583 -0
- factory/roadmap/schedule.py +377 -0
- factory/roadmap/workflow.py +1364 -0
- factory/supervision/__init__.py +0 -0
- factory/supervision/alert.py +239 -0
- factory/supervision/probe.py +467 -0
- factory/supervision/temporal_server.py +121 -0
- factory/supervision/units.py +649 -0
- factory/usage/__init__.py +0 -0
- factory/usage/aggregate.py +113 -0
- factory/usage/cli.py +223 -0
- factory/usage/ledger.py +295 -0
- factory/usage/litellm_client.py +486 -0
- factory/usage/models.py +149 -0
- factory/verify/__init__.py +0 -0
- factory/verify/criteria.py +488 -0
- factory/verify/diffbounds.py +159 -0
- factory/verify/diffcheck.py +506 -0
- factory/verify/factory_yaml.py +842 -0
- factory/verify/gates.py +1341 -0
- factory/verify/judge.py +775 -0
- factory/verify/ladder.py +145 -0
- factory/verify/models.py +745 -0
- factory/verify/question.py +167 -0
- factory/verify/store.py +1366 -0
- factory/verify/toolchain.py +370 -0
- factory/worker.py +296 -0
- factory/workgraph/__init__.py +0 -0
- factory/workgraph/adapter.py +1333 -0
- factory/workgraph/cli.py +801 -0
- factory/workgraph/delta.py +278 -0
- factory/workgraph/derive.py +607 -0
- factory/workgraph/detector.py +509 -0
- factory/workgraph/landed.py +496 -0
- factory/workgraph/models.py +494 -0
- factory/workgraph/preflight.py +651 -0
- factory/workgraph/prompt.py +796 -0
- factory/workgraph/workflow.py +2733 -0
- factory/workgraph/worktree.py +1322 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
"""042-US2: the systemd user units the engine generates, installs and removes.
|
|
2
|
+
|
|
3
|
+
The design input is not in this repository. Five unit files and a probe script
|
|
4
|
+
were hand-written on the operator's host after a 2026-08-11 outage and live in
|
|
5
|
+
another repo entirely; they work, and every path in them is that one host's.
|
|
6
|
+
This module is those lessons made portable: the containment, the kill
|
|
7
|
+
semantics and the restart bound are carried over verbatim in meaning, and every
|
|
8
|
+
literal path is resolved from the operator's own installation instead
|
|
9
|
+
(SC-006).
|
|
10
|
+
|
|
11
|
+
Four things here are load-bearing, and each was paid for:
|
|
12
|
+
|
|
13
|
+
- **`KillMode=control-group`.** Agents, gate subprocesses and anything they
|
|
14
|
+
spawned live in the unit's cgroup, and stopping the unit must take the whole
|
|
15
|
+
tree. A bare kill of the worker pid is what leaves 66 MiB orphans on PID 1. A
|
|
16
|
+
unit missing this starts, restarts and supervises perfectly while leaking.
|
|
17
|
+
- **The slice.** Orphans reparent to PID 1 but do *not* leave their cgroup, so
|
|
18
|
+
a leak stays inside `MemoryMax` and the kernel reclaims within the slice
|
|
19
|
+
rather than taking the host down. `TasksMax` is the other half: 8,131
|
|
20
|
+
processes is far past anything a healthy floor needs.
|
|
21
|
+
- **The probe is outside the slice.** Every other generated unit goes in it;
|
|
22
|
+
the probe's does not, and that is the whole point — a supervisor inside the
|
|
23
|
+
contained slice is reclaimed alongside the leak it exists to report.
|
|
24
|
+
- **The command line may not contain `python -`.** On 2026-08-12 an agent ran
|
|
25
|
+
`pkill -f "python -"` to clean up its own strays; that matched the worker's
|
|
26
|
+
and the bridge's `uv run python -m factory.worker` command lines and killed
|
|
27
|
+
both. 011/US4's pid namespace fixes the root defect for dispatched agents;
|
|
28
|
+
the spelling still protects the callers it does not cover, and it costs
|
|
29
|
+
nothing.
|
|
30
|
+
|
|
31
|
+
Provenance is recorded at install time — a digest of what was written — so
|
|
32
|
+
`uninstall` *knows* which files are the engine's rather than guessing from
|
|
33
|
+
their names. The operator's own unit of a colliding name may be the one keeping
|
|
34
|
+
their host alive, and it is reported rather than deleted (FR-008).
|
|
35
|
+
|
|
36
|
+
The probe's own unit and timer belong to 042-US4 with the probe itself, and
|
|
37
|
+
could not have shipped ahead of it: a unit whose `ExecStart` names a module
|
|
38
|
+
that does not exist yet exits 1 on `ModuleNotFoundError`, and 1 is inside the
|
|
39
|
+
`SuccessExitStatus=0 1` such a unit declares — the timer would read green while
|
|
40
|
+
supervising nothing.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import dataclasses
|
|
46
|
+
import hashlib
|
|
47
|
+
import json
|
|
48
|
+
import subprocess
|
|
49
|
+
import sys
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
from typing import Callable, Mapping, Sequence
|
|
52
|
+
|
|
53
|
+
from factory.cli.errors import OperatorError
|
|
54
|
+
from factory.registry import resolve_state_home
|
|
55
|
+
|
|
56
|
+
SLICE_UNIT = "ergane.slice"
|
|
57
|
+
WORKER_UNIT = "ergane-worker.service"
|
|
58
|
+
BRIDGE_UNIT = "ergane-bridge.service"
|
|
59
|
+
TEMPORAL_UNIT = "ergane-temporal.service"
|
|
60
|
+
PROBE_UNIT = "ergane-probe.service"
|
|
61
|
+
PROBE_TIMER = "ergane-probe.timer"
|
|
62
|
+
|
|
63
|
+
#: One wrapper, shared by every unit, taking the module to run as its argument.
|
|
64
|
+
WRAPPER_NAME = "ergane-run.sh"
|
|
65
|
+
|
|
66
|
+
#: Where the provenance record lives, beside the wrapper rather than beside the
|
|
67
|
+
#: units: a state file under a config directory is what makes `~/.config`
|
|
68
|
+
#: un-copyable between hosts.
|
|
69
|
+
MANIFEST_NAME = "installed.json"
|
|
70
|
+
|
|
71
|
+
#: What an agent's stray-cleanup sweep matched on 2026-08-12.
|
|
72
|
+
PKILL_PATTERN = "python -"
|
|
73
|
+
|
|
74
|
+
#: The units `install` enables. Neither the slice nor the probe *service* is
|
|
75
|
+
#: among them — the slice is pulled in by the `Slice=` lines that reference it
|
|
76
|
+
#: and the probe service by its timer, so enabling either would be declaring a
|
|
77
|
+
#: `WantedBy` that systemd then has to reconcile.
|
|
78
|
+
#: Units enabled for every installation. TEMPORAL_UNIT is added when the
|
|
79
|
+
#: layout's `temporal_mode` is `managed`; it is deliberately not in this tuple
|
|
80
|
+
#: because external mode must not enable a unit that was not generated.
|
|
81
|
+
ENABLE_TARGETS = (WORKER_UNIT, BRIDGE_UNIT, TEMPORAL_UNIT, PROBE_TIMER)
|
|
82
|
+
|
|
83
|
+
_MODULES = {
|
|
84
|
+
WORKER_UNIT: "factory.worker",
|
|
85
|
+
BRIDGE_UNIT: "factory.notify.service",
|
|
86
|
+
PROBE_UNIT: "factory.supervision.probe",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclasses.dataclass(frozen=True)
|
|
91
|
+
class CommandResult:
|
|
92
|
+
"""What a command said, in the two terms any caller here needs."""
|
|
93
|
+
|
|
94
|
+
code: int
|
|
95
|
+
out: str = ""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _run_command(argv: Sequence[str]) -> CommandResult:
|
|
99
|
+
"""Run one command. The seam every test in this story closes.
|
|
100
|
+
|
|
101
|
+
Tests pass their own; nothing in the suite may reach the systemd session
|
|
102
|
+
running the factory, because on this host that session *is* the factory.
|
|
103
|
+
"""
|
|
104
|
+
finished = subprocess.run( # noqa: S603 - fixed argv, no shell
|
|
105
|
+
list(argv), capture_output=True, text=True, check=False
|
|
106
|
+
)
|
|
107
|
+
return CommandResult(finished.returncode, finished.stdout.strip())
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclasses.dataclass(frozen=True)
|
|
111
|
+
class InstallLayout:
|
|
112
|
+
"""Every path a generated file may name, and nothing else (SC-006).
|
|
113
|
+
|
|
114
|
+
`roots` is what the scan is against: a generated unit that references a
|
|
115
|
+
path outside these starts, restarts and supervises perfectly on the one
|
|
116
|
+
host where that path happens to exist.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
install_root: Path
|
|
120
|
+
interpreter: Path
|
|
121
|
+
unit_dir: Path
|
|
122
|
+
generated_dir: Path
|
|
123
|
+
env_command: str | None = None
|
|
124
|
+
memory_high: str = "32G"
|
|
125
|
+
memory_max: str = "48G"
|
|
126
|
+
tasks_max: int = 2000
|
|
127
|
+
restart_window_s: int = 300
|
|
128
|
+
restart_burst: int = 5
|
|
129
|
+
probe_interval: str = "2min"
|
|
130
|
+
temporal_mode: str = "external"
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def roots(self) -> tuple[Path, ...]:
|
|
134
|
+
# The Temporal db lives under the state home, one directory above the
|
|
135
|
+
# supervision subdirectory (SC-004). Including that parent in roots lets
|
|
136
|
+
# the path scan treat it as inside the operator's own installation.
|
|
137
|
+
return (
|
|
138
|
+
self.install_root,
|
|
139
|
+
self.unit_dir,
|
|
140
|
+
self.generated_dir,
|
|
141
|
+
self.generated_dir.parent,
|
|
142
|
+
self.interpreter,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def wrapper(self) -> Path:
|
|
147
|
+
return self.generated_dir / WRAPPER_NAME
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def temporal_db_path(self) -> Path:
|
|
151
|
+
"""Where the managed Temporal server keeps its SQLite history."""
|
|
152
|
+
return self.generated_dir.parent / "temporal" / "dev.db"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def supervision_home() -> Path:
|
|
156
|
+
"""Where the engine keeps what supervision generates and remembers.
|
|
157
|
+
|
|
158
|
+
Under 034/us2's `resolve_state_home`, which honors `ERGANE_STATE_HOME` and
|
|
159
|
+
the legacy name ahead of XDG. Deriving a second answer from
|
|
160
|
+
`XDG_STATE_HOME` here is exactly the drift that resolver exists to prevent.
|
|
161
|
+
"""
|
|
162
|
+
return resolve_state_home() / "ergane" / "supervision"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def resolve_layout(
|
|
166
|
+
*,
|
|
167
|
+
home: Path | str | None = None,
|
|
168
|
+
install_root: Path | str | None = None,
|
|
169
|
+
interpreter: Path | str | None = None,
|
|
170
|
+
unit_dir: Path | str | None = None,
|
|
171
|
+
generated_dir: Path | str | None = None,
|
|
172
|
+
env_command: str | None = None,
|
|
173
|
+
) -> InstallLayout:
|
|
174
|
+
"""Resolve where this installation actually is.
|
|
175
|
+
|
|
176
|
+
`install_root` defaults to the directory holding the `factory` package,
|
|
177
|
+
which is the repository root in a checkout and `site-packages` in a wheel —
|
|
178
|
+
either way it is *this* installation and never a literal.
|
|
179
|
+
"""
|
|
180
|
+
base = Path.home() if home is None else Path(home)
|
|
181
|
+
root = (
|
|
182
|
+
Path(__file__).resolve().parent.parent.parent
|
|
183
|
+
if install_root is None
|
|
184
|
+
else Path(install_root)
|
|
185
|
+
)
|
|
186
|
+
exe = Path(sys.executable if interpreter is None else interpreter)
|
|
187
|
+
return InstallLayout(
|
|
188
|
+
install_root=root,
|
|
189
|
+
interpreter=_python3_spelling(exe),
|
|
190
|
+
unit_dir=base / ".config/systemd/user" if unit_dir is None else Path(unit_dir),
|
|
191
|
+
generated_dir=(
|
|
192
|
+
supervision_home() if generated_dir is None else Path(generated_dir)
|
|
193
|
+
),
|
|
194
|
+
env_command=env_command,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _python3_spelling(exe: Path) -> Path:
|
|
199
|
+
"""Prefer the `python3` a venv always carries beside its `python`.
|
|
200
|
+
|
|
201
|
+
Not cosmetic: `…/bin/python -m factory.worker` is the command line the
|
|
202
|
+
2026-08-12 sweep matched, and `…/bin/python3 -m` is the one it did not.
|
|
203
|
+
"""
|
|
204
|
+
sibling = exe.with_name("python3")
|
|
205
|
+
if exe.name != "python3" and sibling.exists():
|
|
206
|
+
return sibling
|
|
207
|
+
return exe
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def command_line(layout: InstallLayout, module: str) -> str:
|
|
211
|
+
"""The argv the kernel will show for a unit running `module`.
|
|
212
|
+
|
|
213
|
+
Distinct from the file's text on purpose: the wrapper quotes the
|
|
214
|
+
interpreter, so a bare `python` reads as `python" -m` on disk and as
|
|
215
|
+
`python -m` in `/proc/<pid>/cmdline`. `pkill -f` reads the second one.
|
|
216
|
+
"""
|
|
217
|
+
return f"{layout.interpreter} -m {module}"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclasses.dataclass(frozen=True)
|
|
221
|
+
class GeneratedFile:
|
|
222
|
+
"""One file the engine writes, and where it goes."""
|
|
223
|
+
|
|
224
|
+
name: str
|
|
225
|
+
text: str
|
|
226
|
+
directory: Path
|
|
227
|
+
mode: int = 0o644
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def path(self) -> Path:
|
|
231
|
+
return self.directory / self.name
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _temporal_managed(layout: InstallLayout) -> bool:
|
|
235
|
+
"""Whether this installation generates the managed Temporal server unit.
|
|
236
|
+
|
|
237
|
+
`ergane worker install` is the supervised path and defaults to managed. The
|
|
238
|
+
`ergane install` walkthrough sets `temporal_mode="external"` when the
|
|
239
|
+
operator chose external Temporal, and that layout is what `resolve_layout`
|
|
240
|
+
will produce for the same host.
|
|
241
|
+
"""
|
|
242
|
+
return layout.temporal_mode == "managed"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def generated_files(layout: InstallLayout) -> tuple[GeneratedFile, ...]:
|
|
246
|
+
"""Every file `install` writes, rendered from `layout` and nothing else."""
|
|
247
|
+
for module in _MODULES.values():
|
|
248
|
+
if PKILL_PATTERN in command_line(layout, module):
|
|
249
|
+
raise OperatorError(
|
|
250
|
+
f"the interpreter at {layout.interpreter} would give this unit a "
|
|
251
|
+
f"command line containing {PKILL_PATTERN!r}, which an agent's "
|
|
252
|
+
"stray-cleanup sweep matched on 2026-08-12, killing the worker "
|
|
253
|
+
"it was running under; point at the venv's python3 instead"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
units = layout.unit_dir
|
|
257
|
+
generated = layout.generated_dir
|
|
258
|
+
files: list[GeneratedFile] = [
|
|
259
|
+
GeneratedFile(SLICE_UNIT, _slice_text(layout), units),
|
|
260
|
+
GeneratedFile(WORKER_UNIT, _worker_text(layout), units),
|
|
261
|
+
GeneratedFile(BRIDGE_UNIT, _bridge_text(layout), units),
|
|
262
|
+
GeneratedFile(PROBE_UNIT, _probe_text(layout), units),
|
|
263
|
+
GeneratedFile(PROBE_TIMER, _timer_text(layout), units),
|
|
264
|
+
GeneratedFile(WRAPPER_NAME, _wrapper_text(layout), generated, 0o755),
|
|
265
|
+
]
|
|
266
|
+
if _temporal_managed(layout):
|
|
267
|
+
files.insert(
|
|
268
|
+
3, GeneratedFile(TEMPORAL_UNIT, _temporal_text(layout), units)
|
|
269
|
+
)
|
|
270
|
+
return tuple(files)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _slice_text(layout: InstallLayout) -> str:
|
|
274
|
+
return f"""\
|
|
275
|
+
[Unit]
|
|
276
|
+
Description=ergane — memory-contained slice
|
|
277
|
+
|
|
278
|
+
[Slice]
|
|
279
|
+
# Orphaned processes reparent to PID 1 but do NOT leave their cgroup, so a leak
|
|
280
|
+
# stays inside these limits and the kernel reclaims within the slice instead of
|
|
281
|
+
# taking the host down with it. MemoryHigh applies pressure first; MemoryMax is
|
|
282
|
+
# the wall.
|
|
283
|
+
MemoryHigh={layout.memory_high}
|
|
284
|
+
MemoryMax={layout.memory_max}
|
|
285
|
+
|
|
286
|
+
# A runaway spawn is the other half of the same failure.
|
|
287
|
+
TasksMax={layout.tasks_max}
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _service_text(
|
|
292
|
+
layout: InstallLayout,
|
|
293
|
+
*,
|
|
294
|
+
description: str,
|
|
295
|
+
module: str,
|
|
296
|
+
restart: str,
|
|
297
|
+
stop_timeout_s: int,
|
|
298
|
+
) -> str:
|
|
299
|
+
return f"""\
|
|
300
|
+
[Unit]
|
|
301
|
+
Description={description}
|
|
302
|
+
# Give up rather than flap: a restart loop during a memory storm deepens it.
|
|
303
|
+
StartLimitIntervalSec={layout.restart_window_s}
|
|
304
|
+
StartLimitBurst={layout.restart_burst}
|
|
305
|
+
|
|
306
|
+
[Service]
|
|
307
|
+
Type=simple
|
|
308
|
+
Slice={SLICE_UNIT}
|
|
309
|
+
WorkingDirectory={layout.install_root}
|
|
310
|
+
ExecStart={layout.wrapper} {module}
|
|
311
|
+
|
|
312
|
+
# The point, not a default worth losing: agents, gate subprocesses and anything
|
|
313
|
+
# they spawned are in this unit's cgroup, and stopping the unit must take the
|
|
314
|
+
# whole tree. A bare kill of the main pid is what leaves orphans on PID 1.
|
|
315
|
+
KillMode=control-group
|
|
316
|
+
KillSignal=SIGTERM
|
|
317
|
+
TimeoutStopSec={stop_timeout_s}
|
|
318
|
+
|
|
319
|
+
Restart={restart}
|
|
320
|
+
RestartSec=10
|
|
321
|
+
|
|
322
|
+
[Install]
|
|
323
|
+
WantedBy=default.target
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _worker_text(layout: InstallLayout) -> str:
|
|
328
|
+
return _service_text(
|
|
329
|
+
layout,
|
|
330
|
+
description="ergane — factory worker (workgraph task queue)",
|
|
331
|
+
module=_MODULES[WORKER_UNIT],
|
|
332
|
+
restart="on-failure",
|
|
333
|
+
stop_timeout_s=120,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _bridge_text(layout: InstallLayout) -> str:
|
|
338
|
+
return _service_text(
|
|
339
|
+
layout,
|
|
340
|
+
# The listening half of the operator channel. Its death is invisible
|
|
341
|
+
# from inside the factory: the sending half is a workflow activity that
|
|
342
|
+
# needs no bridge, so questions keep flowing outward while every inbound
|
|
343
|
+
# answer falls on the floor. This unit is the only watcher it has.
|
|
344
|
+
description="ergane — operator channel bridge (replies to signals)",
|
|
345
|
+
module=_MODULES[BRIDGE_UNIT],
|
|
346
|
+
restart="always",
|
|
347
|
+
stop_timeout_s=30,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _temporal_text(layout: InstallLayout) -> str:
|
|
352
|
+
"""The managed Temporal server unit: persistence, containment, restart bound.
|
|
353
|
+
|
|
354
|
+
The Go dev server is downloaded on demand by the temporalio package and cached
|
|
355
|
+
under the state home. Persistence lives there too: a host reinstall that
|
|
356
|
+
preserves the state directory keeps history, while a fresh install starts
|
|
357
|
+
clean — which is the contract SC-004 defends.
|
|
358
|
+
"""
|
|
359
|
+
db_path = layout.temporal_db_path
|
|
360
|
+
# Use a tiny Python shim so the command line does not contain `python -`.
|
|
361
|
+
# The shim starts the local dev server on the bound frontend port and blocks
|
|
362
|
+
# until the process exits; systemd receives the server's stdout and the
|
|
363
|
+
# usual signals.
|
|
364
|
+
return f"""\
|
|
365
|
+
[Unit]
|
|
366
|
+
Description=ergane — managed Temporal server (SQLite persistence)
|
|
367
|
+
# Give up rather than flap: a restart loop during a memory storm deepens it.
|
|
368
|
+
StartLimitIntervalSec={layout.restart_window_s}
|
|
369
|
+
StartLimitBurst={layout.restart_burst}
|
|
370
|
+
|
|
371
|
+
[Service]
|
|
372
|
+
Type=simple
|
|
373
|
+
Slice={SLICE_UNIT}
|
|
374
|
+
WorkingDirectory={layout.install_root}
|
|
375
|
+
ExecStart={layout.wrapper} factory.supervision.temporal_server --db-filename {db_path} --namespace ergane --log-level warn
|
|
376
|
+
|
|
377
|
+
# The point, not a default worth losing: the dev server spawns child processes,
|
|
378
|
+
# and stopping the unit must take the whole tree. A bare kill of the main pid is
|
|
379
|
+
# what leaves orphans on PID 1.
|
|
380
|
+
KillMode=control-group
|
|
381
|
+
KillSignal=SIGTERM
|
|
382
|
+
TimeoutStopSec=60
|
|
383
|
+
|
|
384
|
+
Restart=on-failure
|
|
385
|
+
RestartSec=10
|
|
386
|
+
|
|
387
|
+
[Install]
|
|
388
|
+
WantedBy=default.target
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _probe_text(layout: InstallLayout) -> str:
|
|
393
|
+
return f"""\
|
|
394
|
+
[Unit]
|
|
395
|
+
Description=ergane — stack liveness, memory and orphan-leak probe
|
|
396
|
+
|
|
397
|
+
[Service]
|
|
398
|
+
Type=oneshot
|
|
399
|
+
# Deliberately NOT in {SLICE_UNIT}: the supervisor must outlive what it watches.
|
|
400
|
+
# A probe inside the contained slice is reclaimed alongside the leak it exists
|
|
401
|
+
# to report.
|
|
402
|
+
ExecStart={layout.wrapper} {_MODULES[PROBE_UNIT]}
|
|
403
|
+
# A degraded verdict exits 1 by design; that is a report, not a unit failure.
|
|
404
|
+
# Exit 2 — an alert nobody received — is a failure, and must stay one.
|
|
405
|
+
SuccessExitStatus=0 1
|
|
406
|
+
"""
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _timer_text(layout: InstallLayout) -> str:
|
|
410
|
+
return f"""\
|
|
411
|
+
[Unit]
|
|
412
|
+
Description=ergane — run the stack probe on its interval
|
|
413
|
+
|
|
414
|
+
[Timer]
|
|
415
|
+
OnBootSec=90s
|
|
416
|
+
OnUnitActiveSec={layout.probe_interval}
|
|
417
|
+
AccuracySec=15s
|
|
418
|
+
Unit={PROBE_UNIT}
|
|
419
|
+
|
|
420
|
+
[Install]
|
|
421
|
+
WantedBy=timers.target
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _wrapper_text(layout: InstallLayout) -> str:
|
|
426
|
+
"""The one indirection, and the two reasons it is not resolved into units.
|
|
427
|
+
|
|
428
|
+
systemd cannot evaluate a command substitution, so an operator's
|
|
429
|
+
environment command can only run from a script; resolving it into
|
|
430
|
+
`Environment=` lines instead would write their credentials to disk in a
|
|
431
|
+
file the journal echoes back. And `PATH` is composed at *start* time rather
|
|
432
|
+
than frozen at install time, because a unit whose `PATH` predates the
|
|
433
|
+
toolchain installed after it is a real defect this repository has already
|
|
434
|
+
paid for (`factory/verify/toolchain.py`).
|
|
435
|
+
"""
|
|
436
|
+
environment = (
|
|
437
|
+
f'eval "$({layout.env_command})"\n' if layout.env_command else ""
|
|
438
|
+
)
|
|
439
|
+
return f"""\
|
|
440
|
+
#!/bin/sh
|
|
441
|
+
# Generated by `ergane worker install`. Edit the engine, not this file:
|
|
442
|
+
# uninstall removes it only while it still matches what install wrote.
|
|
443
|
+
set -eu
|
|
444
|
+
cd "{layout.install_root}"
|
|
445
|
+
PATH="$HOME/.local/bin:$PATH"
|
|
446
|
+
export PATH
|
|
447
|
+
{environment}exec "{layout.interpreter}" -m "$1"
|
|
448
|
+
"""
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
@dataclasses.dataclass(frozen=True)
|
|
452
|
+
class InstallReport:
|
|
453
|
+
"""What was written, what was left alone, and what came up."""
|
|
454
|
+
|
|
455
|
+
written: tuple[str, ...]
|
|
456
|
+
kept: tuple[str, ...]
|
|
457
|
+
active: tuple[str, ...]
|
|
458
|
+
enabled: tuple[str, ...]
|
|
459
|
+
linger: bool
|
|
460
|
+
|
|
461
|
+
def render(self) -> str:
|
|
462
|
+
lines = [f"wrote {len(self.written)} file(s) to the unit directory"]
|
|
463
|
+
lines += [f" active+enabled: {name}" for name in sorted(self.active)]
|
|
464
|
+
if not self.linger:
|
|
465
|
+
lines.append(" WARN: linger is off; user units stop at logout")
|
|
466
|
+
lines += [f" left alone (not written by ergane): {n}" for n in self.kept]
|
|
467
|
+
return "\n".join(lines)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@dataclasses.dataclass(frozen=True)
|
|
471
|
+
class UninstallReport:
|
|
472
|
+
"""What was removed, and what was deliberately not."""
|
|
473
|
+
|
|
474
|
+
removed: tuple[str, ...]
|
|
475
|
+
kept: tuple[str, ...]
|
|
476
|
+
|
|
477
|
+
def render(self) -> str:
|
|
478
|
+
lines = [f"removed {len(self.removed)} file(s)"]
|
|
479
|
+
lines += [f" left in place (not written by ergane): {n}" for n in self.kept]
|
|
480
|
+
return "\n".join(lines)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def install(
|
|
484
|
+
layout: InstallLayout,
|
|
485
|
+
*,
|
|
486
|
+
run: Callable[[Sequence[str]], CommandResult] | None = None,
|
|
487
|
+
) -> InstallReport:
|
|
488
|
+
"""Write the units, enable them, enable linger, and report what came up.
|
|
489
|
+
|
|
490
|
+
A file the engine did not write is never overwritten — that is the half of
|
|
491
|
+
the collision that cannot be undone.
|
|
492
|
+
"""
|
|
493
|
+
runner = _run_command if run is None else run
|
|
494
|
+
layout.unit_dir.mkdir(parents=True, exist_ok=True)
|
|
495
|
+
layout.generated_dir.mkdir(parents=True, exist_ok=True)
|
|
496
|
+
|
|
497
|
+
recorded = _read_manifest(layout)
|
|
498
|
+
written: list[str] = []
|
|
499
|
+
kept: list[str] = []
|
|
500
|
+
fresh: dict[str, str] = {}
|
|
501
|
+
for generated in generated_files(layout):
|
|
502
|
+
if _is_someone_elses(generated, recorded):
|
|
503
|
+
kept.append(generated.name)
|
|
504
|
+
continue
|
|
505
|
+
generated.path.write_text(generated.text, encoding="utf-8")
|
|
506
|
+
generated.path.chmod(generated.mode)
|
|
507
|
+
written.append(generated.name)
|
|
508
|
+
fresh[generated.name] = _digest(generated.text)
|
|
509
|
+
_write_manifest(layout, fresh)
|
|
510
|
+
|
|
511
|
+
runner(("systemctl", "--user", "daemon-reload"))
|
|
512
|
+
# Without linger a user unit is stopped when the last session ends, which
|
|
513
|
+
# turns "supervised" into "supervised until the operator closes their laptop".
|
|
514
|
+
linger = runner(("loginctl", "enable-linger")).code == 0
|
|
515
|
+
for name in ENABLE_TARGETS:
|
|
516
|
+
if name in written:
|
|
517
|
+
runner(("systemctl", "--user", "enable", "--now", name))
|
|
518
|
+
|
|
519
|
+
return InstallReport(
|
|
520
|
+
written=tuple(written),
|
|
521
|
+
kept=tuple(kept),
|
|
522
|
+
active=_reading(runner, "is-active"),
|
|
523
|
+
enabled=_reading(runner, "is-enabled"),
|
|
524
|
+
linger=linger,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def uninstall(
|
|
529
|
+
layout: InstallLayout,
|
|
530
|
+
*,
|
|
531
|
+
run: Callable[[Sequence[str]], CommandResult] | None = None,
|
|
532
|
+
open_epics: Callable[[], Sequence[str]] | None = None,
|
|
533
|
+
) -> UninstallReport:
|
|
534
|
+
"""Remove exactly the files install wrote, and report the rest (FR-008).
|
|
535
|
+
|
|
536
|
+
The epic read comes first and touches nothing: a disable issued before the
|
|
537
|
+
refusal is a half-uninstall, which is worse than either outcome.
|
|
538
|
+
"""
|
|
539
|
+
epics = tuple((_open_epic_ids if open_epics is None else open_epics)())
|
|
540
|
+
if epics:
|
|
541
|
+
raise OperatorError(
|
|
542
|
+
f"refusing to uninstall while {', '.join(epics)} is in flight: "
|
|
543
|
+
"removing the worker mid-epic strands the attempt it is running; "
|
|
544
|
+
"let it land, or kill it first"
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
runner = _run_command if run is None else run
|
|
548
|
+
recorded = _read_manifest(layout)
|
|
549
|
+
for name in ENABLE_TARGETS:
|
|
550
|
+
if name in recorded:
|
|
551
|
+
# Before deleting: systemd holds the parsed unit in memory, and a
|
|
552
|
+
# file removed out from under a running unit leaves it up and
|
|
553
|
+
# invisible to `disable` until the next reboot.
|
|
554
|
+
runner(("systemctl", "--user", "disable", "--now", name))
|
|
555
|
+
|
|
556
|
+
removed: list[str] = []
|
|
557
|
+
kept: list[str] = []
|
|
558
|
+
for generated in generated_files(layout):
|
|
559
|
+
if not generated.path.exists():
|
|
560
|
+
continue
|
|
561
|
+
if _is_someone_elses(generated, recorded):
|
|
562
|
+
kept.append(generated.name)
|
|
563
|
+
continue
|
|
564
|
+
generated.path.unlink()
|
|
565
|
+
removed.append(generated.name)
|
|
566
|
+
(layout.generated_dir / MANIFEST_NAME).unlink(missing_ok=True)
|
|
567
|
+
|
|
568
|
+
runner(("systemctl", "--user", "daemon-reload"))
|
|
569
|
+
return UninstallReport(tuple(removed), tuple(kept))
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _reading(
|
|
573
|
+
runner: Callable[[Sequence[str]], CommandResult], question: str
|
|
574
|
+
) -> tuple[str, ...]:
|
|
575
|
+
"""Which of the enable targets answer `question` affirmatively.
|
|
576
|
+
|
|
577
|
+
Read back rather than inferred: `enable --now` returning 0 is not the same
|
|
578
|
+
claim as the unit being up, and the difference is what an operator wants
|
|
579
|
+
from an install that says it succeeded.
|
|
580
|
+
"""
|
|
581
|
+
return tuple(
|
|
582
|
+
name
|
|
583
|
+
for name in ENABLE_TARGETS
|
|
584
|
+
if runner(("systemctl", "--user", question, name)).code == 0
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _is_someone_elses(generated: GeneratedFile, recorded: Mapping[str, str]) -> bool:
|
|
589
|
+
"""Whether this file on disk is one the engine did not write.
|
|
590
|
+
|
|
591
|
+
Provenance is a digest, not a filename: an operator who tuned `MemoryMax`
|
|
592
|
+
by hand has made the file theirs, and the tuning was probably a response to
|
|
593
|
+
something.
|
|
594
|
+
"""
|
|
595
|
+
if not generated.path.exists():
|
|
596
|
+
return False
|
|
597
|
+
return recorded.get(generated.name) != _digest(
|
|
598
|
+
generated.path.read_text(encoding="utf-8")
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _digest(text: str) -> str:
|
|
603
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _read_manifest(layout: InstallLayout) -> dict[str, str]:
|
|
607
|
+
try:
|
|
608
|
+
document = json.loads(
|
|
609
|
+
(layout.generated_dir / MANIFEST_NAME).read_text(encoding="utf-8")
|
|
610
|
+
)
|
|
611
|
+
return {str(k): str(v) for k, v in document["units"].items()}
|
|
612
|
+
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
|
613
|
+
return {}
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _write_manifest(layout: InstallLayout, units: Mapping[str, str]) -> None:
|
|
617
|
+
(layout.generated_dir / MANIFEST_NAME).write_text(
|
|
618
|
+
json.dumps({"units": dict(units)}, indent=2, sort_keys=True), encoding="utf-8"
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _open_epic_ids() -> tuple[str, ...]:
|
|
623
|
+
"""Which epics are open right now — FR-012's refusal, as a read.
|
|
624
|
+
|
|
625
|
+
Imported inside the function on purpose, and it stays that way when 042-US4
|
|
626
|
+
lands: the probe imports this module for the unit names, and it is the
|
|
627
|
+
process that runs when Temporal is the thing that died. A module-scope
|
|
628
|
+
Temporal import here would put a client on that path for the sake of a
|
|
629
|
+
capacity read the probe never performs. The query is the roadmap's own, so
|
|
630
|
+
a closed epic is narrowed away on the server rather than here.
|
|
631
|
+
"""
|
|
632
|
+
import asyncio
|
|
633
|
+
|
|
634
|
+
from factory.activities.roadmap_activities import _OPEN_EPIC_STATUS
|
|
635
|
+
from factory.cli.nouns import _open_client
|
|
636
|
+
from factory.cli.status import EPIC_ID_PREFIX
|
|
637
|
+
|
|
638
|
+
async def listed() -> tuple[str, ...]:
|
|
639
|
+
client = await _open_client()
|
|
640
|
+
found = [
|
|
641
|
+
str(execution.id)
|
|
642
|
+
async for execution in client.list_workflows(
|
|
643
|
+
f'ExecutionStatus = "{_OPEN_EPIC_STATUS}"'
|
|
644
|
+
)
|
|
645
|
+
if str(execution.id).startswith(EPIC_ID_PREFIX)
|
|
646
|
+
]
|
|
647
|
+
return tuple(sorted(found))
|
|
648
|
+
|
|
649
|
+
return asyncio.run(listed())
|
|
File without changes
|