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,467 @@
|
|
|
1
|
+
"""042-US4: the probe — what it sees, what it says, and what it will not do.
|
|
2
|
+
|
|
3
|
+
It runs from a timer, outside the slice it watches, and it is the process that
|
|
4
|
+
has to work when Temporal is the thing that died. So it imports no client, and
|
|
5
|
+
it raises everything it has to say through US1's out-of-band path.
|
|
6
|
+
|
|
7
|
+
Three properties are the whole design:
|
|
8
|
+
|
|
9
|
+
- **It reports; it does not remediate (FR-014).** The instinct on reading "the
|
|
10
|
+
worker is down" is to restart it. systemd owns restarts, and a restart into
|
|
11
|
+
an already-dying host deepens an OOM storm rather than ending it. Reaping
|
|
12
|
+
orphaned test servers is the sole exception, and only because nothing else
|
|
13
|
+
ever will: a server whose parent died is reparented to PID 1, the fixtures'
|
|
14
|
+
`finally` never runs after a SIGKILL, and it accrues at ~66 MiB a copy until
|
|
15
|
+
the host dies — 2026-08-11, 8,131 copies, 123 GiB, one dead host.
|
|
16
|
+
- **Alerts are edge-triggered, with a heartbeat under them (FR-013).** The
|
|
17
|
+
timer fires every couple of minutes, and a page per firing is a channel the
|
|
18
|
+
operator mutes within a day — worse than none, because it still looks
|
|
19
|
+
supervised. One message per status change, plus a heartbeat on its own much
|
|
20
|
+
longer interval, so silence stays distinguishable from a dead probe.
|
|
21
|
+
- **It is loud when it cannot escalate (FR-015).** A probe whose alert went
|
|
22
|
+
nowhere is indistinguishable from a healthy floor, the exact failure this
|
|
23
|
+
unit exists to end — so it says so on stderr and exits 2, outside the
|
|
24
|
+
`SuccessExitStatus=0 1` its unit declares.
|
|
25
|
+
|
|
26
|
+
One deliberate divergence from the prior art, which pages on every reap: here a
|
|
27
|
+
reap below the configured threshold is a note, and only a count at or above it
|
|
28
|
+
pages (US4-S1). A leak producing two orphans an hour would otherwise page every
|
|
29
|
+
two minutes, which is the muting FR-013 forbids.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import dataclasses
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import signal
|
|
38
|
+
import socket
|
|
39
|
+
import sys
|
|
40
|
+
import time
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Callable, Sequence
|
|
43
|
+
|
|
44
|
+
from factory.supervision.alert import AlertOutcome, StackAlert, send_alert
|
|
45
|
+
from factory.supervision.units import (
|
|
46
|
+
BRIDGE_UNIT,
|
|
47
|
+
SLICE_UNIT,
|
|
48
|
+
TEMPORAL_UNIT,
|
|
49
|
+
WORKER_UNIT,
|
|
50
|
+
CommandResult,
|
|
51
|
+
_run_command,
|
|
52
|
+
supervision_home,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
HEALTHY = "healthy"
|
|
56
|
+
DEGRADED = "degraded"
|
|
57
|
+
|
|
58
|
+
#: What the whole-stack messages name as their subject.
|
|
59
|
+
STACK = "ergane stack"
|
|
60
|
+
|
|
61
|
+
#: `ps` reports `comm` truncated to 15 characters, so the leaked servers appear
|
|
62
|
+
#: as this and never as `temporal-test-server`. Matching the full name matches
|
|
63
|
+
#: nothing, forever, silently — which is a safety net that reports a healthy
|
|
64
|
+
#: floor while the host fills up.
|
|
65
|
+
ORPHAN_COMM_PREFIX = "temporal-test-s"
|
|
66
|
+
|
|
67
|
+
#: The probe's state, in one file: the last status, when it last changed, and
|
|
68
|
+
#: when the last heartbeat went out.
|
|
69
|
+
STATE_FILE = "probe.json"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclasses.dataclass(frozen=True)
|
|
73
|
+
class ProbeConfig:
|
|
74
|
+
"""The thresholds, and the units this installation supervises."""
|
|
75
|
+
|
|
76
|
+
units: tuple[str, ...] = (WORKER_UNIT, BRIDGE_UNIT, TEMPORAL_UNIT)
|
|
77
|
+
slice_unit: str = SLICE_UNIT
|
|
78
|
+
dial: tuple[tuple[str, int], ...] = ()
|
|
79
|
+
mem_warn_gib: int = 16
|
|
80
|
+
mem_crit_gib: int = 8
|
|
81
|
+
#: Grace, so a fresh spawn is never raced: reaping a two-second-old server
|
|
82
|
+
#: kills a test that was working.
|
|
83
|
+
orphan_min_age_s: int = 120
|
|
84
|
+
orphan_alert_at: int = 10
|
|
85
|
+
heartbeat_every_s: int = 86_400
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclasses.dataclass(frozen=True)
|
|
89
|
+
class Host:
|
|
90
|
+
"""Everything the probe reads about the machine, behind one seam."""
|
|
91
|
+
|
|
92
|
+
systemctl: Callable[[Sequence[str]], CommandResult]
|
|
93
|
+
process_table: Callable[[], str]
|
|
94
|
+
meminfo: Callable[[], str]
|
|
95
|
+
uptime_s: Callable[[], float]
|
|
96
|
+
kill: Callable[[int], None]
|
|
97
|
+
dial: Callable[[str, int], bool]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def real_host() -> Host:
|
|
101
|
+
"""The production readings. Resolved at call time so a test can refuse them."""
|
|
102
|
+
return Host(
|
|
103
|
+
systemctl=_run_command,
|
|
104
|
+
process_table=_read_process_table,
|
|
105
|
+
meminfo=lambda: Path("/proc/meminfo").read_text(encoding="utf-8"),
|
|
106
|
+
uptime_s=_uptime_s,
|
|
107
|
+
kill=lambda pid: _kill(pid),
|
|
108
|
+
dial=_dial,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclasses.dataclass(frozen=True)
|
|
113
|
+
class ProcessRow:
|
|
114
|
+
"""One line of `ps -eo pid=,ppid=,etimes=,comm=`."""
|
|
115
|
+
|
|
116
|
+
pid: int
|
|
117
|
+
ppid: int
|
|
118
|
+
age_s: int
|
|
119
|
+
comm: str
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def parse_process_table(text: str) -> tuple[ProcessRow, ...]:
|
|
123
|
+
rows: list[ProcessRow] = []
|
|
124
|
+
for line in text.splitlines():
|
|
125
|
+
parts = line.split()
|
|
126
|
+
if len(parts) < 4 or not parts[0].isdigit():
|
|
127
|
+
continue
|
|
128
|
+
rows.append(
|
|
129
|
+
ProcessRow(int(parts[0]), int(parts[1]), int(parts[2]), parts[3])
|
|
130
|
+
)
|
|
131
|
+
return tuple(rows)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def orphaned_test_servers(
|
|
135
|
+
rows: Sequence[ProcessRow], *, min_age_s: int = 120
|
|
136
|
+
) -> tuple[ProcessRow, ...]:
|
|
137
|
+
"""The three conditions, and each one is why the other two are not enough.
|
|
138
|
+
|
|
139
|
+
The truncated name, because `comm` is 15 characters. `ppid == 1`, because a
|
|
140
|
+
test server with a live parent is a running test rather than a leak. And
|
|
141
|
+
the age, because a fresh spawn is mid-fork and reaping it breaks the test
|
|
142
|
+
that was working. Drop any one and the net becomes a saboteur.
|
|
143
|
+
"""
|
|
144
|
+
return tuple(
|
|
145
|
+
row
|
|
146
|
+
for row in rows
|
|
147
|
+
if row.comm.startswith(ORPHAN_COMM_PREFIX)
|
|
148
|
+
and row.ppid == 1
|
|
149
|
+
and row.age_s > min_age_s
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def reap_orphans(
|
|
154
|
+
rows: Sequence[ProcessRow], *, kill: Callable[[int], None] | None = None
|
|
155
|
+
) -> int:
|
|
156
|
+
"""Kill each orphan and count what actually died.
|
|
157
|
+
|
|
158
|
+
The one thing this probe does that systemd cannot, and the one place in the
|
|
159
|
+
module that signals anything (FR-014). An orphan that exited between the
|
|
160
|
+
read and the kill is the normal case on a busy host, not an error.
|
|
161
|
+
"""
|
|
162
|
+
reaped = 0
|
|
163
|
+
for row in rows:
|
|
164
|
+
try:
|
|
165
|
+
if kill is None:
|
|
166
|
+
_kill(row.pid)
|
|
167
|
+
else:
|
|
168
|
+
kill(row.pid)
|
|
169
|
+
except OSError:
|
|
170
|
+
continue
|
|
171
|
+
reaped += 1
|
|
172
|
+
return reaped
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclasses.dataclass(frozen=True)
|
|
176
|
+
class UnitReading:
|
|
177
|
+
"""Whether a unit is up, and how long it has been down if it is not."""
|
|
178
|
+
|
|
179
|
+
name: str
|
|
180
|
+
active: bool
|
|
181
|
+
down_for_s: float
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def read_unit(name: str, host: Host) -> UnitReading:
|
|
185
|
+
"""Ask systemd, and take the outage duration from systemd's own clock.
|
|
186
|
+
|
|
187
|
+
`InactiveEnterTimestampMonotonic` is microseconds since boot, which is why
|
|
188
|
+
the host's uptime is the other half of the subtraction: an outage measured
|
|
189
|
+
against a state file would restart every time the probe's own state was
|
|
190
|
+
lost, and the state file is on the disk of the host that is failing.
|
|
191
|
+
"""
|
|
192
|
+
answer = host.systemctl(
|
|
193
|
+
(
|
|
194
|
+
"systemctl",
|
|
195
|
+
"--user",
|
|
196
|
+
"show",
|
|
197
|
+
name,
|
|
198
|
+
"-p",
|
|
199
|
+
"ActiveState",
|
|
200
|
+
"-p",
|
|
201
|
+
"InactiveEnterTimestampMonotonic",
|
|
202
|
+
"--value",
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
lines = [line.strip() for line in answer.out.splitlines()]
|
|
206
|
+
active = bool(lines) and lines[0] == "active"
|
|
207
|
+
since_us = int(lines[1]) if len(lines) > 1 and lines[1].isdigit() else 0
|
|
208
|
+
down_for = 0.0
|
|
209
|
+
if not active and since_us:
|
|
210
|
+
down_for = max(0.0, host.uptime_s() - since_us / 1_000_000)
|
|
211
|
+
return UnitReading(name, active, down_for)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclasses.dataclass(frozen=True)
|
|
215
|
+
class Verdict:
|
|
216
|
+
"""One pass over the machine: what is wrong, what was noted, who to name."""
|
|
217
|
+
|
|
218
|
+
status: str
|
|
219
|
+
problems: tuple[str, ...]
|
|
220
|
+
notes: tuple[str, ...]
|
|
221
|
+
reaped: int
|
|
222
|
+
subject: str
|
|
223
|
+
subject_duration_s: float
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def detail(self) -> str:
|
|
227
|
+
body = "; ".join(self.problems) if self.problems else "all units up"
|
|
228
|
+
if self.notes:
|
|
229
|
+
body = f"{body} ({', '.join(self.notes)})"
|
|
230
|
+
return body
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def assess(config: ProbeConfig, host: Host) -> Verdict:
|
|
234
|
+
"""Read the machine once and render a verdict.
|
|
235
|
+
|
|
236
|
+
Liveness alone was never the gap: on 2026-08-11 every unit was active while
|
|
237
|
+
the host ran out of memory. Headroom and orphan count are why this checks
|
|
238
|
+
more than `is-active`.
|
|
239
|
+
"""
|
|
240
|
+
problems: list[str] = []
|
|
241
|
+
notes: list[str] = []
|
|
242
|
+
|
|
243
|
+
readings = [read_unit(name, host) for name in config.units]
|
|
244
|
+
down = [reading for reading in readings if not reading.active]
|
|
245
|
+
problems += [f"{reading.name} not active" for reading in down]
|
|
246
|
+
|
|
247
|
+
available = _available_gib(host.meminfo())
|
|
248
|
+
if available is not None and available < config.mem_crit_gib:
|
|
249
|
+
problems.append(f"host memory CRITICAL: {available} GiB available")
|
|
250
|
+
elif available is not None and available < config.mem_warn_gib:
|
|
251
|
+
problems.append(f"host memory low: {available} GiB available")
|
|
252
|
+
|
|
253
|
+
for address, port in config.dial:
|
|
254
|
+
if not host.dial(address, port):
|
|
255
|
+
problems.append(f"{address}:{port} unreachable")
|
|
256
|
+
|
|
257
|
+
orphans = orphaned_test_servers(
|
|
258
|
+
parse_process_table(host.process_table()), min_age_s=config.orphan_min_age_s
|
|
259
|
+
)
|
|
260
|
+
reaped = reap_orphans(orphans, kill=host.kill)
|
|
261
|
+
if reaped:
|
|
262
|
+
notes.append(f"reaped {reaped} orphaned test server(s)")
|
|
263
|
+
if len(orphans) >= config.orphan_alert_at:
|
|
264
|
+
problems.append(f"test-server leak: {len(orphans)} orphaned test servers")
|
|
265
|
+
|
|
266
|
+
used = host.systemctl(
|
|
267
|
+
("systemctl", "--user", "show", config.slice_unit, "-p", "MemoryCurrent", "--value")
|
|
268
|
+
).out.strip()
|
|
269
|
+
if used.isdigit():
|
|
270
|
+
notes.append(f"{config.slice_unit} {int(used) // 1_073_741_824} GiB")
|
|
271
|
+
|
|
272
|
+
return Verdict(
|
|
273
|
+
status=DEGRADED if problems else HEALTHY,
|
|
274
|
+
problems=tuple(problems),
|
|
275
|
+
notes=tuple(notes),
|
|
276
|
+
reaped=reaped,
|
|
277
|
+
subject=down[0].name if down else STACK,
|
|
278
|
+
subject_duration_s=down[0].down_for_s if down else 0.0,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclasses.dataclass(frozen=True)
|
|
283
|
+
class ProbeState:
|
|
284
|
+
"""What the last run left behind, so this one can tell what changed."""
|
|
285
|
+
|
|
286
|
+
status: str | None
|
|
287
|
+
changed_at: float
|
|
288
|
+
heartbeat_at: float
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def read_state(state_dir: Path) -> ProbeState:
|
|
292
|
+
"""The previous run's state, or none at all.
|
|
293
|
+
|
|
294
|
+
An unreadable file reads as no state rather than raising: a truncated write
|
|
295
|
+
during an OOM is exactly the condition the next run exists to report.
|
|
296
|
+
"""
|
|
297
|
+
try:
|
|
298
|
+
document = json.loads((Path(state_dir) / STATE_FILE).read_text(encoding="utf-8"))
|
|
299
|
+
return ProbeState(
|
|
300
|
+
status=document.get("status"),
|
|
301
|
+
changed_at=float(document.get("changed_at", 0.0)),
|
|
302
|
+
heartbeat_at=float(document.get("heartbeat_at", 0.0)),
|
|
303
|
+
)
|
|
304
|
+
except (OSError, ValueError, TypeError, AttributeError):
|
|
305
|
+
return ProbeState(None, 0.0, 0.0)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def write_state(state_dir: Path, state: ProbeState) -> None:
|
|
309
|
+
directory = Path(state_dir)
|
|
310
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
311
|
+
(directory / STATE_FILE).write_text(
|
|
312
|
+
json.dumps(dataclasses.asdict(state)), encoding="utf-8"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@dataclasses.dataclass(frozen=True)
|
|
317
|
+
class AlertDecision:
|
|
318
|
+
"""Whether to page, and whether paging restarts the heartbeat clock."""
|
|
319
|
+
|
|
320
|
+
alert: StackAlert | None
|
|
321
|
+
heartbeat: bool
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def decide_alert(
|
|
325
|
+
verdict: Verdict, state: ProbeState, *, now: float, heartbeat_every_s: int
|
|
326
|
+
) -> AlertDecision:
|
|
327
|
+
"""The edge-trigger table (FR-013), and it is deliberately small.
|
|
328
|
+
|
|
329
|
+
A first observation of *healthy* is silent: there is no incident behind it,
|
|
330
|
+
and a fresh install that opens with "RECOVERED" teaches the operator that
|
|
331
|
+
the channel is noisy. A first observation of *degraded* pages, because that
|
|
332
|
+
one is real.
|
|
333
|
+
"""
|
|
334
|
+
if verdict.status == DEGRADED and state.status != DEGRADED:
|
|
335
|
+
return AlertDecision(
|
|
336
|
+
StackAlert(verdict.subject, verdict.detail, verdict.subject_duration_s),
|
|
337
|
+
False,
|
|
338
|
+
)
|
|
339
|
+
if verdict.status == HEALTHY and state.status == DEGRADED:
|
|
340
|
+
return AlertDecision(
|
|
341
|
+
StackAlert(
|
|
342
|
+
STACK, f"recovered — {verdict.detail}", max(0.0, now - state.changed_at)
|
|
343
|
+
),
|
|
344
|
+
False,
|
|
345
|
+
)
|
|
346
|
+
if (
|
|
347
|
+
verdict.status == HEALTHY
|
|
348
|
+
and state.status == HEALTHY
|
|
349
|
+
and now - state.heartbeat_at >= heartbeat_every_s
|
|
350
|
+
):
|
|
351
|
+
return AlertDecision(
|
|
352
|
+
StackAlert(STACK, f"healthy — {verdict.detail}", now - state.heartbeat_at),
|
|
353
|
+
True,
|
|
354
|
+
)
|
|
355
|
+
return AlertDecision(None, False)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@dataclasses.dataclass(frozen=True)
|
|
359
|
+
class ProbeRun:
|
|
360
|
+
"""One firing of the timer, whole."""
|
|
361
|
+
|
|
362
|
+
verdict: Verdict
|
|
363
|
+
alert: StackAlert | None
|
|
364
|
+
outcome: AlertOutcome | None
|
|
365
|
+
exit_code: int
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def run_probe(
|
|
369
|
+
config: ProbeConfig,
|
|
370
|
+
host: Host,
|
|
371
|
+
state_dir: Path,
|
|
372
|
+
*,
|
|
373
|
+
now: float | None = None,
|
|
374
|
+
alert: Callable[[StackAlert], AlertOutcome] = send_alert,
|
|
375
|
+
) -> ProbeRun:
|
|
376
|
+
"""Read the machine, decide, page if the edge says so, and record.
|
|
377
|
+
|
|
378
|
+
`alert` defaults to US1's synchronous door — the path that works when
|
|
379
|
+
Temporal is gone — and is injectable only so a test can assert the decision
|
|
380
|
+
without a transport.
|
|
381
|
+
"""
|
|
382
|
+
moment = time.time() if now is None else now
|
|
383
|
+
verdict = assess(config, host)
|
|
384
|
+
state = read_state(state_dir)
|
|
385
|
+
decision = decide_alert(
|
|
386
|
+
verdict, state, now=moment, heartbeat_every_s=config.heartbeat_every_s
|
|
387
|
+
)
|
|
388
|
+
outcome = alert(decision.alert) if decision.alert is not None else None
|
|
389
|
+
|
|
390
|
+
became_healthy = verdict.status == HEALTHY and state.status != HEALTHY
|
|
391
|
+
write_state(
|
|
392
|
+
state_dir,
|
|
393
|
+
ProbeState(
|
|
394
|
+
status=verdict.status,
|
|
395
|
+
changed_at=moment if verdict.status != state.status else state.changed_at,
|
|
396
|
+
heartbeat_at=(
|
|
397
|
+
moment if decision.heartbeat or became_healthy else state.heartbeat_at
|
|
398
|
+
),
|
|
399
|
+
),
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
print(f"{verdict.status}: {verdict.detail}", file=sys.stdout, flush=True)
|
|
403
|
+
if outcome is not None and not outcome.delivered:
|
|
404
|
+
# The same words US1 logs, on purpose: one phrase an operator greps the
|
|
405
|
+
# journal for, and the timer's failure mail carries this half.
|
|
406
|
+
print(
|
|
407
|
+
f"WARN: cannot escalate ({outcome.failure}): {outcome.text}",
|
|
408
|
+
file=sys.stderr,
|
|
409
|
+
flush=True,
|
|
410
|
+
)
|
|
411
|
+
return ProbeRun(verdict, decision.alert, outcome, 2)
|
|
412
|
+
return ProbeRun(
|
|
413
|
+
verdict, decision.alert, outcome, 0 if verdict.status == HEALTHY else 1
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _available_gib(meminfo: str) -> int | None:
|
|
418
|
+
for line in meminfo.splitlines():
|
|
419
|
+
if line.startswith("MemAvailable:"):
|
|
420
|
+
parts = line.split()
|
|
421
|
+
if len(parts) > 1 and parts[1].isdigit():
|
|
422
|
+
return int(parts[1]) // 1_048_576
|
|
423
|
+
return None
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _read_process_table() -> str:
|
|
427
|
+
return _run_command(("ps", "-eo", "pid=,ppid=,etimes=,comm=")).out
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _uptime_s() -> float:
|
|
431
|
+
return float(Path("/proc/uptime").read_text(encoding="utf-8").split()[0])
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _dial(address: str, port: int) -> bool:
|
|
435
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
436
|
+
probe.settimeout(3.0)
|
|
437
|
+
return probe.connect_ex((address, port)) == 0
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _kill(pid: int) -> None:
|
|
441
|
+
os.kill(pid, signal.SIGKILL)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _default_dial() -> tuple[tuple[str, int], ...]:
|
|
445
|
+
"""The declared Temporal address, if one resolves.
|
|
446
|
+
|
|
447
|
+
Imported here rather than at module scope, and swallowed rather than
|
|
448
|
+
raised: a probe that cannot parse a config has stopped supervising, and the
|
|
449
|
+
reachability check is the least of what it reports.
|
|
450
|
+
"""
|
|
451
|
+
try:
|
|
452
|
+
from factory.controlplane.resolve import resolve_temporal_target
|
|
453
|
+
|
|
454
|
+
address, _, port = resolve_temporal_target().address.rpartition(":")
|
|
455
|
+
return ((address or "127.0.0.1", int(port)),)
|
|
456
|
+
except Exception:
|
|
457
|
+
return ()
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
461
|
+
"""`python3 -m factory.supervision.probe` — what the timer runs."""
|
|
462
|
+
run = run_probe(ProbeConfig(dial=_default_dial()), real_host(), supervision_home())
|
|
463
|
+
return run.exit_code
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
if __name__ == "__main__": # pragma: no cover - process entry point
|
|
467
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""042-US3: managed Temporal dev-server entry point for systemd.
|
|
2
|
+
|
|
3
|
+
The unit runs this module rather than invoking a Temporal CLI binary directly
|
|
4
|
+
because the CLI is not guaranteed to be on `PATH`; the temporalio SDK downloads the
|
|
5
|
+
server binary on first use and caches it. The wrapper (`ergane-run.sh`) sets
|
|
6
|
+
`PATH` and the working directory, then calls the venv's python3 with this module
|
|
7
|
+
as an argument.
|
|
8
|
+
|
|
9
|
+
The command line intentionally avoids `python -`: a 2026-08-12 cleanup sweep ran
|
|
10
|
+
`pkill -f "python -"` and matched the worker's `python -m factory.worker`
|
|
11
|
+
command line. Using the wrapper plus `factory.supervision.temporal_server` keeps
|
|
12
|
+
the visible argv free of that substring.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import asyncio
|
|
19
|
+
import logging
|
|
20
|
+
import socket
|
|
21
|
+
import sys
|
|
22
|
+
from typing import Sequence
|
|
23
|
+
|
|
24
|
+
from temporalio.runtime import Runtime
|
|
25
|
+
from temporalio.testing import WorkflowEnvironment
|
|
26
|
+
|
|
27
|
+
DEFAULT_NAMESPACE = "ergane"
|
|
28
|
+
DEFAULT_LOG_LEVEL = "warn"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def _run(
|
|
32
|
+
*,
|
|
33
|
+
db_filename: str,
|
|
34
|
+
namespace: str,
|
|
35
|
+
ip: str,
|
|
36
|
+
port: int,
|
|
37
|
+
log_level: str,
|
|
38
|
+
) -> int:
|
|
39
|
+
"""Start the dev server and block until it is shut down."""
|
|
40
|
+
# Ensure a runtime exists before the server starts; the default is created
|
|
41
|
+
# on demand, but referencing it here makes shutdown deterministic.
|
|
42
|
+
_runtime = Runtime.default()
|
|
43
|
+
environment = await WorkflowEnvironment.start_local(
|
|
44
|
+
namespace=namespace,
|
|
45
|
+
ip=ip,
|
|
46
|
+
port=port,
|
|
47
|
+
dev_server_database_filename=db_filename,
|
|
48
|
+
dev_server_log_level=log_level,
|
|
49
|
+
)
|
|
50
|
+
try:
|
|
51
|
+
# Keep the process alive. The server child handles signals; this main
|
|
52
|
+
# thread just waits. WorkflowEnvironment does not expose a wait, so we
|
|
53
|
+
# sleep forever and let systemd's SIGTERM propagate through the wrapper.
|
|
54
|
+
while True:
|
|
55
|
+
await asyncio.sleep(3600)
|
|
56
|
+
finally:
|
|
57
|
+
await environment.shutdown()
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _free_port(ip: str) -> int:
|
|
62
|
+
"""Bind a free TCP port on `ip` and return it."""
|
|
63
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
64
|
+
probe.bind((ip, 0))
|
|
65
|
+
return probe.getsockname()[1]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
69
|
+
"""`python3 -m factory.supervision.temporal_server` — what the unit runs."""
|
|
70
|
+
parser = argparse.ArgumentParser(
|
|
71
|
+
description="Run the managed Temporal dev server under systemd supervision."
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--db-filename",
|
|
75
|
+
required=True,
|
|
76
|
+
help="SQLite database file for persistent workflow history",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--namespace",
|
|
80
|
+
default=DEFAULT_NAMESPACE,
|
|
81
|
+
help=f"Temporal namespace (default: {DEFAULT_NAMESPACE})",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--ip",
|
|
85
|
+
default="127.0.0.1",
|
|
86
|
+
help="IP address to bind the frontend to (default: 127.0.0.1)",
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--port",
|
|
90
|
+
type=int,
|
|
91
|
+
default=7233,
|
|
92
|
+
help="Frontend port; 0 means bind a free port (default: 7233)",
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--log-level",
|
|
96
|
+
default=DEFAULT_LOG_LEVEL,
|
|
97
|
+
help=f"Dev-server log level (default: {DEFAULT_LOG_LEVEL})",
|
|
98
|
+
)
|
|
99
|
+
args = parser.parse_args(argv)
|
|
100
|
+
|
|
101
|
+
port = _free_port(args.ip) if args.port == 0 else args.port
|
|
102
|
+
logging.basicConfig(level=logging.INFO)
|
|
103
|
+
logging.info(
|
|
104
|
+
"starting managed Temporal dev server on %s:%d (db=%s)",
|
|
105
|
+
args.ip,
|
|
106
|
+
port,
|
|
107
|
+
args.db_filename,
|
|
108
|
+
)
|
|
109
|
+
return asyncio.run(
|
|
110
|
+
_run(
|
|
111
|
+
db_filename=args.db_filename,
|
|
112
|
+
namespace=args.namespace,
|
|
113
|
+
ip=args.ip,
|
|
114
|
+
port=port,
|
|
115
|
+
log_level=args.log_level,
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__": # pragma: no cover - process entry point
|
|
121
|
+
raise SystemExit(main())
|