engineering-process 1.1.0__tar.gz → 1.1.2__tar.gz
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.
- {engineering_process-1.1.0/engineering_process.egg-info → engineering_process-1.1.2}/PKG-INFO +1 -1
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/__init__.py +1 -1
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/_supervisor_posix.py +93 -93
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/commands.py +1 -1
- {engineering_process-1.1.0 → engineering_process-1.1.2/engineering_process.egg-info}/PKG-INFO +1 -1
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process.egg-info/SOURCES.txt +2 -1
- {engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/verify-change/SKILL.md +4 -3
- {engineering_process-1.1.0 → engineering_process-1.1.2}/pyproject.toml +1 -1
- {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_automation.py +20 -1
- {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_commands.py +61 -6
- engineering_process-1.1.2/tests/test_supervisor_posix.py +112 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/LICENSE +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/MANIFEST.in +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/README.md +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/__main__.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/_supervisor_windows.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/_windows_job.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/adoption.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/cli.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/contracts.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/distribution.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/helper_launch.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/lifecycle.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/project.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/publication_compat.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/release.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/repository.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/requirements-dev.txt +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/requirements-runtime.txt +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/skills.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/supervision.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process.egg-info/dependency_links.txt +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process.egg-info/entry_points.txt +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process.egg-info/requires.txt +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process.egg-info/top_level.txt +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/process-graph.json +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/finish-change/SKILL.md +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/implement-change/SKILL.md +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/improve-process/SKILL.md +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/plan-change/SKILL.md +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/review-change/SKILL.md +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/run-change/SKILL.md +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/start-change/SKILL.md +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/change.schema.json +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/plan.schema.json +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/process-graph.schema.json +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/process-lock.schema.json +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/project-legacy.schema.json +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/project.schema.json +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/receipt.schema.json +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/release-change.schema.json +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/release.schema.json +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/review.schema.json +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/run.schema.json +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/setup.cfg +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/templates/AGENTS.process.md +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/templates/PULL_REQUEST_TEMPLATE.md +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/templates/adopt-process-windows-job.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/templates/adopt-process.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_adoption.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_architecture.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_cli.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_contracts.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_lifecycle.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_publication_compat.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_release.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_repository.py +0 -0
- {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_skills.py +0 -0
{engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/_supervisor_posix.py
RENAMED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from contextlib import suppress
|
|
5
6
|
import ctypes
|
|
6
7
|
import os
|
|
7
8
|
from pathlib import Path
|
|
@@ -17,6 +18,8 @@ from .supervision import CleanupOutcome, NATURAL_DRAIN_GRACE_MILLISECONDS
|
|
|
17
18
|
|
|
18
19
|
PR_SET_CHILD_SUBREAPER = 36
|
|
19
20
|
RUN_ID_ENVIRONMENT_VARIABLE = "ENGINEERING_PROCESS_RUN_ID"
|
|
21
|
+
PROCESS_TABLE_TIMEOUT_SECONDS = 10
|
|
22
|
+
PROCESS_TABLE_OBSERVATION_INTERVAL_SECONDS = 0.05
|
|
20
23
|
_SUBREAPER_ENABLED = False
|
|
21
24
|
|
|
22
25
|
|
|
@@ -31,39 +34,44 @@ def _enable_subreaper() -> None:
|
|
|
31
34
|
_SUBREAPER_ENABLED = True
|
|
32
35
|
|
|
33
36
|
|
|
34
|
-
def
|
|
37
|
+
def _ps_process_table() -> tuple[dict[int, int], str | None]:
|
|
38
|
+
try:
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
["ps", "-axo", "pid=,ppid="],
|
|
41
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
42
|
+
timeout=PROCESS_TABLE_TIMEOUT_SECONDS, check=False, text=True,
|
|
43
|
+
)
|
|
44
|
+
except subprocess.TimeoutExpired:
|
|
45
|
+
return {}, f"process table snapshot timed out after {PROCESS_TABLE_TIMEOUT_SECONDS} seconds"
|
|
46
|
+
except OSError:
|
|
47
|
+
return {}, "process table snapshot could not start"
|
|
48
|
+
if result.returncode != 0:
|
|
49
|
+
return {}, f"process table snapshot exited with status {result.returncode}"
|
|
50
|
+
try:
|
|
51
|
+
rows = (line.split() for line in result.stdout.splitlines())
|
|
52
|
+
table = {int(pid): int(parent) for pid, parent in rows}
|
|
53
|
+
except (TypeError, ValueError):
|
|
54
|
+
return {}, "process table snapshot was malformed"
|
|
55
|
+
if os.getpid() not in table:
|
|
56
|
+
return {}, "process table snapshot was incomplete"
|
|
57
|
+
return table, None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _process_table() -> tuple[dict[int, int], str | None]:
|
|
35
61
|
table: dict[int, int] = {}
|
|
36
62
|
if Path("/proc").is_dir():
|
|
37
63
|
for entry in Path("/proc").iterdir():
|
|
38
64
|
if not entry.name.isdecimal():
|
|
39
65
|
continue
|
|
40
66
|
try:
|
|
41
|
-
for line in (entry / "status").read_text(
|
|
42
|
-
encoding="utf-8", errors="replace"
|
|
43
|
-
).splitlines():
|
|
67
|
+
for line in (entry / "status").read_text(encoding="utf-8", errors="replace").splitlines():
|
|
44
68
|
if line.startswith("PPid:"):
|
|
45
69
|
table[int(entry.name)] = int(line.split()[1])
|
|
46
70
|
break
|
|
47
71
|
except (OSError, ValueError, IndexError):
|
|
48
72
|
continue
|
|
49
|
-
return table
|
|
50
|
-
|
|
51
|
-
["ps", "-axo", "pid=,ppid="],
|
|
52
|
-
stdin=subprocess.DEVNULL,
|
|
53
|
-
stdout=subprocess.PIPE,
|
|
54
|
-
stderr=subprocess.DEVNULL,
|
|
55
|
-
timeout=2,
|
|
56
|
-
check=False,
|
|
57
|
-
text=True,
|
|
58
|
-
)
|
|
59
|
-
if result.returncode == 0:
|
|
60
|
-
for line in result.stdout.splitlines():
|
|
61
|
-
try:
|
|
62
|
-
pid, parent = (int(value) for value in line.split())
|
|
63
|
-
except (ValueError, TypeError):
|
|
64
|
-
continue
|
|
65
|
-
table[pid] = parent
|
|
66
|
-
return table
|
|
73
|
+
return table, None
|
|
74
|
+
return _ps_process_table()
|
|
67
75
|
|
|
68
76
|
|
|
69
77
|
def _descendants(root: int, table: Mapping[int, int]) -> set[int]:
|
|
@@ -100,10 +108,8 @@ def _pid_alive(pid: int) -> bool:
|
|
|
100
108
|
try:
|
|
101
109
|
state = status.read_text(encoding="utf-8").rsplit(")", 1)[1].split()[0]
|
|
102
110
|
if state == "Z":
|
|
103
|
-
|
|
111
|
+
with suppress(OSError):
|
|
104
112
|
os.waitpid(pid, os.WNOHANG)
|
|
105
|
-
except OSError:
|
|
106
|
-
pass
|
|
107
113
|
return False
|
|
108
114
|
except (OSError, IndexError):
|
|
109
115
|
pass
|
|
@@ -111,11 +117,9 @@ def _pid_alive(pid: int) -> bool:
|
|
|
111
117
|
|
|
112
118
|
|
|
113
119
|
def _process_group_exists(process_group: int) -> bool:
|
|
114
|
-
|
|
120
|
+
with suppress(OSError):
|
|
115
121
|
while os.waitpid(-process_group, os.WNOHANG)[0]:
|
|
116
122
|
pass
|
|
117
|
-
except OSError:
|
|
118
|
-
pass
|
|
119
123
|
try:
|
|
120
124
|
os.killpg(process_group, 0)
|
|
121
125
|
except ProcessLookupError:
|
|
@@ -127,10 +131,8 @@ def _process_group_exists(process_group: int) -> bool:
|
|
|
127
131
|
|
|
128
132
|
def _signal_processes(processes: set[int], signal_number: int) -> None:
|
|
129
133
|
for pid in processes:
|
|
130
|
-
|
|
134
|
+
with suppress(OSError):
|
|
131
135
|
os.kill(pid, signal_number)
|
|
132
|
-
except OSError:
|
|
133
|
-
pass
|
|
134
136
|
|
|
135
137
|
|
|
136
138
|
def _wait_for_process_group(process_group: int, timeout: float) -> bool:
|
|
@@ -145,20 +147,12 @@ def _wait_for_process_group(process_group: int, timeout: float) -> bool:
|
|
|
145
147
|
def _terminate_group(process_group: int, grace_seconds: float) -> CleanupOutcome:
|
|
146
148
|
if not _process_group_exists(process_group):
|
|
147
149
|
return CleanupOutcome(bounded=True)
|
|
148
|
-
|
|
150
|
+
with suppress(OSError):
|
|
149
151
|
os.killpg(process_group, signal.SIGTERM)
|
|
150
|
-
except ProcessLookupError:
|
|
151
|
-
return CleanupOutcome(bounded=True, descendants_found=True)
|
|
152
|
-
except OSError:
|
|
153
|
-
pass
|
|
154
152
|
if _wait_for_process_group(process_group, grace_seconds):
|
|
155
153
|
return CleanupOutcome(bounded=True, descendants_found=True)
|
|
156
|
-
|
|
154
|
+
with suppress(OSError):
|
|
157
155
|
os.killpg(process_group, signal.SIGKILL)
|
|
158
|
-
except ProcessLookupError:
|
|
159
|
-
return CleanupOutcome(bounded=True, descendants_found=True)
|
|
160
|
-
except OSError:
|
|
161
|
-
pass
|
|
162
156
|
bounded = _wait_for_process_group(process_group, grace_seconds)
|
|
163
157
|
return CleanupOutcome(
|
|
164
158
|
bounded=bounded,
|
|
@@ -167,10 +161,24 @@ def _terminate_group(process_group: int, grace_seconds: float) -> CleanupOutcome
|
|
|
167
161
|
)
|
|
168
162
|
|
|
169
163
|
|
|
164
|
+
def _terminate_root(process: subprocess.Popen[bytes], grace_seconds: float) -> bool:
|
|
165
|
+
for signal_number in (signal.SIGTERM, signal.SIGKILL):
|
|
166
|
+
with suppress(OSError):
|
|
167
|
+
os.killpg(process.pid, signal_number)
|
|
168
|
+
try:
|
|
169
|
+
process.wait(timeout=grace_seconds)
|
|
170
|
+
return True
|
|
171
|
+
except subprocess.TimeoutExpired:
|
|
172
|
+
pass
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
170
176
|
class PosixProcessSupervisor:
|
|
171
177
|
def __init__(self) -> None:
|
|
172
178
|
self._known_descendants: dict[int, set[int]] = {}
|
|
173
179
|
self._run_ids: dict[int, str] = {}
|
|
180
|
+
self._last_observation: dict[int, float] = {}
|
|
181
|
+
self._observation_errors: dict[int, str] = {}
|
|
174
182
|
|
|
175
183
|
def resolve_application(
|
|
176
184
|
self,
|
|
@@ -234,37 +242,43 @@ class PosixProcessSupervisor:
|
|
|
234
242
|
)
|
|
235
243
|
self._known_descendants[process.pid] = set()
|
|
236
244
|
self._run_ids[process.pid] = run_id
|
|
237
|
-
self.
|
|
245
|
+
self._observation_errors.pop(process.pid, None)
|
|
246
|
+
self._observe(process, force=True)
|
|
238
247
|
return process
|
|
239
248
|
|
|
240
|
-
def
|
|
241
|
-
|
|
249
|
+
def _observe(self, process: subprocess.Popen[bytes], *, force: bool) -> None:
|
|
250
|
+
now = time.monotonic()
|
|
251
|
+
if not force and now - self._last_observation.get(process.pid, 0) < PROCESS_TABLE_OBSERVATION_INTERVAL_SECONDS:
|
|
252
|
+
return
|
|
253
|
+
table, error = _process_table()
|
|
254
|
+
self._last_observation[process.pid] = time.monotonic()
|
|
242
255
|
known = self._known_descendants.setdefault(process.pid, set())
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
256
|
+
if error is not None:
|
|
257
|
+
self._observation_errors.setdefault(process.pid, error)
|
|
258
|
+
else:
|
|
259
|
+
known.update(_descendants(process.pid, table))
|
|
260
|
+
run_id = self._run_ids.get(process.pid)
|
|
261
|
+
if sys.platform.startswith("linux") and run_id is not None:
|
|
262
|
+
known.update(
|
|
263
|
+
pid
|
|
264
|
+
for pid, parent in table.items()
|
|
265
|
+
if parent == os.getpid()
|
|
251
266
|
and pid != process.pid
|
|
252
267
|
and _has_run_id(pid, run_id)
|
|
253
268
|
)
|
|
254
|
-
}
|
|
255
|
-
known.update(adopted)
|
|
256
269
|
|
|
257
|
-
def
|
|
258
|
-
self,
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
self.observe(process)
|
|
270
|
+
def observe(self, process: subprocess.Popen[bytes]) -> None:
|
|
271
|
+
self._observe(process, force=False)
|
|
272
|
+
|
|
273
|
+
def _terminate_detached(self, process: subprocess.Popen[bytes], grace_seconds: float) -> CleanupOutcome:
|
|
274
|
+
self._observe(process, force=True)
|
|
263
275
|
candidates = self._known_descendants.pop(process.pid, set())
|
|
264
276
|
self._run_ids.pop(process.pid, None)
|
|
277
|
+
self._last_observation.pop(process.pid, None)
|
|
278
|
+
observation_error = self._observation_errors.pop(process.pid, None)
|
|
265
279
|
live = {pid for pid in candidates if _pid_alive(pid)}
|
|
266
280
|
if not live:
|
|
267
|
-
return CleanupOutcome(bounded=
|
|
281
|
+
return CleanupOutcome(bounded=observation_error is None, error=observation_error)
|
|
268
282
|
_signal_processes(live, signal.SIGTERM)
|
|
269
283
|
deadline = time.monotonic() + grace_seconds
|
|
270
284
|
while time.monotonic() < deadline and any(_pid_alive(pid) for pid in live):
|
|
@@ -281,11 +295,16 @@ class PosixProcessSupervisor:
|
|
|
281
295
|
os.waitpid(pid, os.WNOHANG)
|
|
282
296
|
except (ChildProcessError, OSError):
|
|
283
297
|
pass
|
|
284
|
-
|
|
298
|
+
descendants_bounded = not any(_pid_alive(pid) for pid in live)
|
|
299
|
+
error = observation_error or (
|
|
300
|
+
None
|
|
301
|
+
if descendants_bounded
|
|
302
|
+
else "detached descendants survived bounded termination"
|
|
303
|
+
)
|
|
285
304
|
return CleanupOutcome(
|
|
286
|
-
bounded=
|
|
305
|
+
bounded=descendants_bounded and error is None,
|
|
287
306
|
descendants_found=True,
|
|
288
|
-
error=
|
|
307
|
+
error=error,
|
|
289
308
|
)
|
|
290
309
|
|
|
291
310
|
def terminate(
|
|
@@ -294,34 +313,15 @@ class PosixProcessSupervisor:
|
|
|
294
313
|
*,
|
|
295
314
|
grace_seconds: float,
|
|
296
315
|
) -> CleanupOutcome:
|
|
297
|
-
self.
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
except subprocess.TimeoutExpired:
|
|
307
|
-
try:
|
|
308
|
-
os.killpg(process.pid, signal.SIGKILL)
|
|
309
|
-
except ProcessLookupError:
|
|
310
|
-
pass
|
|
311
|
-
except OSError:
|
|
312
|
-
pass
|
|
313
|
-
try:
|
|
314
|
-
process.wait(timeout=grace_seconds)
|
|
315
|
-
except subprocess.TimeoutExpired:
|
|
316
|
-
detached = self._terminate_detached(process, grace_seconds)
|
|
317
|
-
return CleanupOutcome(
|
|
318
|
-
bounded=False,
|
|
319
|
-
descendants_found=detached.descendants_found,
|
|
320
|
-
error=(
|
|
321
|
-
"command root process survived bounded process-group termination"
|
|
322
|
-
+ (f"; {detached.error}" if detached.error else "")
|
|
323
|
-
),
|
|
324
|
-
)
|
|
316
|
+
self._observe(process, force=True)
|
|
317
|
+
if not _terminate_root(process, grace_seconds):
|
|
318
|
+
detached = self._terminate_detached(process, grace_seconds)
|
|
319
|
+
error = "command root process survived bounded process-group termination"
|
|
320
|
+
return CleanupOutcome(
|
|
321
|
+
bounded=False,
|
|
322
|
+
descendants_found=detached.descendants_found,
|
|
323
|
+
error=error + (f"; {detached.error}" if detached.error else ""),
|
|
324
|
+
)
|
|
325
325
|
descendants = _terminate_group(process.pid, grace_seconds)
|
|
326
326
|
detached = self._terminate_detached(process, grace_seconds)
|
|
327
327
|
return CleanupOutcome(
|
|
@@ -342,7 +342,7 @@ class PosixProcessSupervisor:
|
|
|
342
342
|
# alive, even after the original process has exited. Give descendants that
|
|
343
343
|
# were synchronously asked to stop one short bounded interval to disappear
|
|
344
344
|
# naturally before classifying them as abandoned background work.
|
|
345
|
-
self.
|
|
345
|
+
self._observe(process, force=True)
|
|
346
346
|
if not _process_group_exists(process.pid):
|
|
347
347
|
return self._terminate_detached(process, grace_seconds)
|
|
348
348
|
natural_drain_seconds = min(
|
|
@@ -155,6 +155,7 @@ def _run_check(project_root: Path, check: dict[str, Any]) -> dict[str, Any]:
|
|
|
155
155
|
)
|
|
156
156
|
descendants = descendants or cleanup.descendants_found
|
|
157
157
|
cleanup_error = cleanup_error or cleanup.error
|
|
158
|
+
cleanup_error = cleanup_error or (None if cleanup.bounded else "unbounded cleanup")
|
|
158
159
|
exit_code = process.returncode if process.returncode is not None else -1
|
|
159
160
|
|
|
160
161
|
for reader in readers:
|
|
@@ -173,7 +174,6 @@ def _run_check(project_root: Path, check: dict[str, Any]) -> dict[str, Any]:
|
|
|
173
174
|
exit_code == 0
|
|
174
175
|
and not timed_out
|
|
175
176
|
and not output_exceeded
|
|
176
|
-
and not descendants
|
|
177
177
|
and not stream_failed
|
|
178
178
|
)
|
|
179
179
|
return {
|
{engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/verify-change/SKILL.md
RENAMED
|
@@ -11,9 +11,10 @@ profile through the lifecycle:
|
|
|
11
11
|
processctl change verify --change-id ID --profile PROFILE
|
|
12
12
|
|
|
13
13
|
Commands are exact argument arrays with timeouts. Do not substitute a different tool
|
|
14
|
-
or narrower check when a required command fails. A command failure, timeout,
|
|
15
|
-
|
|
16
|
-
change in implementing.
|
|
14
|
+
or narrower check when a required command fails. A command failure, timeout, output
|
|
15
|
+
or stream failure, failed descendant cleanup, or tracked repository mutation is a
|
|
16
|
+
failure and leaves the change in implementing. Successfully cleaned post-exit
|
|
17
|
+
descendants remain recorded without replacing the foreground command result.
|
|
17
18
|
|
|
18
19
|
The contract must already include conditional profiles required by affected enforced
|
|
19
20
|
capabilities. Run those profiles exactly; do not run every planned production gate for
|
|
@@ -7,6 +7,9 @@ import unittest
|
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
ROOT = Path(__file__).resolve().parent.parent
|
|
10
|
+
ACTIVE_PROCESS_PIN = re.compile(
|
|
11
|
+
r"^engineering-process==([^\s\\]+)\s*$", re.MULTILINE
|
|
12
|
+
)
|
|
10
13
|
|
|
11
14
|
|
|
12
15
|
class AutomationTests(unittest.TestCase):
|
|
@@ -141,11 +144,27 @@ class AutomationTests(unittest.TestCase):
|
|
|
141
144
|
self.assertEqual("production", readiness["target"])
|
|
142
145
|
self.assertEqual("production", readiness["stage"])
|
|
143
146
|
self.assertEqual([{"id": "library-cli", "version": 1}], readiness["packs"])
|
|
144
|
-
|
|
147
|
+
adopted = json.loads(
|
|
148
|
+
(ROOT / ".process" / "process.lock").read_text(encoding="utf-8")
|
|
149
|
+
)
|
|
150
|
+
requirements = (ROOT / "requirements" / "process.in").read_text(
|
|
151
|
+
encoding="utf-8"
|
|
152
|
+
)
|
|
153
|
+
self.assertEqual(
|
|
154
|
+
[adopted["process"]["version"]], ACTIVE_PROCESS_PIN.findall(requirements)
|
|
155
|
+
)
|
|
145
156
|
workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
|
|
146
157
|
self.assertIn("processctl adoption check", workflow)
|
|
147
158
|
self.assertIn("processctl doctor --project-root .", workflow)
|
|
148
159
|
|
|
160
|
+
def test_self_adoption_pin_parser_ignores_comments_and_prefix_collisions(self) -> None:
|
|
161
|
+
self.assertEqual(
|
|
162
|
+
["1.0.10"], ACTIVE_PROCESS_PIN.findall("engineering-process==1.0.10\n")
|
|
163
|
+
)
|
|
164
|
+
self.assertEqual(
|
|
165
|
+
[], ACTIVE_PROCESS_PIN.findall("# engineering-process==1.0.1\n")
|
|
166
|
+
)
|
|
167
|
+
|
|
149
168
|
def test_external_actions_are_immutably_pinned(self) -> None:
|
|
150
169
|
for workflow in (ROOT / ".github" / "workflows").glob("*.yml"):
|
|
151
170
|
for reference in re.findall(r"uses:\s*([^\s#]+)", workflow.read_text(encoding="utf-8")):
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import ctypes
|
|
4
|
+
import io
|
|
3
5
|
import os
|
|
4
6
|
from pathlib import Path
|
|
5
7
|
import subprocess
|
|
@@ -7,8 +9,38 @@ import sys
|
|
|
7
9
|
import tempfile
|
|
8
10
|
import time
|
|
9
11
|
import unittest
|
|
12
|
+
from unittest.mock import Mock, patch
|
|
10
13
|
|
|
11
14
|
from engineering_process.commands import run_check, run_profile
|
|
15
|
+
from engineering_process.supervision import CleanupOutcome
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def windows_process_is_running(process_id: int) -> bool:
|
|
19
|
+
from ctypes import wintypes
|
|
20
|
+
|
|
21
|
+
synchronize = 0x00100000
|
|
22
|
+
wait_failed = 0xFFFFFFFF
|
|
23
|
+
wait_timeout = 0x00000102
|
|
24
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
25
|
+
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
26
|
+
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
27
|
+
kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
|
28
|
+
kernel32.WaitForSingleObject.restype = wintypes.DWORD
|
|
29
|
+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
30
|
+
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
31
|
+
handle = kernel32.OpenProcess(synchronize, False, process_id)
|
|
32
|
+
if not handle:
|
|
33
|
+
error = ctypes.get_last_error()
|
|
34
|
+
if error == 87: # ERROR_INVALID_PARAMETER: the PID no longer exists.
|
|
35
|
+
return False
|
|
36
|
+
raise ctypes.WinError(error)
|
|
37
|
+
try:
|
|
38
|
+
result = kernel32.WaitForSingleObject(handle, 0)
|
|
39
|
+
if result == wait_failed:
|
|
40
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
41
|
+
return result == wait_timeout
|
|
42
|
+
finally:
|
|
43
|
+
kernel32.CloseHandle(handle)
|
|
12
44
|
|
|
13
45
|
|
|
14
46
|
class CommandTests(unittest.TestCase):
|
|
@@ -64,6 +96,27 @@ class CommandTests(unittest.TestCase):
|
|
|
64
96
|
self.assertEqual("failed", report["status"])
|
|
65
97
|
self.assertTrue(report["timedOut"])
|
|
66
98
|
|
|
99
|
+
def test_unbounded_cleanup_fails_without_an_adapter_error(self) -> None:
|
|
100
|
+
process = Mock(stdout=io.BytesIO(), stderr=io.BytesIO(), returncode=0)
|
|
101
|
+
process.poll.return_value = 0
|
|
102
|
+
supervisor = Mock()
|
|
103
|
+
supervisor.spawn.return_value = process
|
|
104
|
+
supervisor.finalize.return_value = CleanupOutcome(bounded=False)
|
|
105
|
+
with tempfile.TemporaryDirectory() as directory, patch(
|
|
106
|
+
"engineering_process.commands.process_supervisor",
|
|
107
|
+
return_value=supervisor,
|
|
108
|
+
):
|
|
109
|
+
report = run_check(
|
|
110
|
+
Path(directory),
|
|
111
|
+
{
|
|
112
|
+
"id": "unbounded-cleanup",
|
|
113
|
+
"run": [sys.executable, "-c", "raise SystemExit(0)"],
|
|
114
|
+
"timeoutSeconds": 10,
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
self.assertEqual("failed", report["status"])
|
|
118
|
+
self.assertTrue(report["streamFailed"])
|
|
119
|
+
|
|
67
120
|
@unittest.skipUnless(
|
|
68
121
|
sys.platform.startswith("linux"), "Linux subreaper ownership assertion"
|
|
69
122
|
)
|
|
@@ -93,7 +146,7 @@ class CommandTests(unittest.TestCase):
|
|
|
93
146
|
unrelated.wait(timeout=3)
|
|
94
147
|
|
|
95
148
|
@unittest.skipIf(os.name == "nt", "POSIX process-group assertion")
|
|
96
|
-
def
|
|
149
|
+
def test_surviving_descendant_is_terminated_after_success(self) -> None:
|
|
97
150
|
script = (
|
|
98
151
|
"import subprocess, sys; "
|
|
99
152
|
"subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)']); "
|
|
@@ -108,7 +161,7 @@ class CommandTests(unittest.TestCase):
|
|
|
108
161
|
"timeoutSeconds": 10,
|
|
109
162
|
},
|
|
110
163
|
)
|
|
111
|
-
self.assertEqual("
|
|
164
|
+
self.assertEqual("passed", report["status"])
|
|
112
165
|
self.assertTrue(report["descendantsTerminated"])
|
|
113
166
|
|
|
114
167
|
@unittest.skipUnless(
|
|
@@ -142,7 +195,7 @@ class CommandTests(unittest.TestCase):
|
|
|
142
195
|
@unittest.skipUnless(
|
|
143
196
|
sys.platform.startswith("linux"), "Linux subreaper containment assertion"
|
|
144
197
|
)
|
|
145
|
-
def
|
|
198
|
+
def test_detached_descendant_is_terminated_after_success(self) -> None:
|
|
146
199
|
with tempfile.TemporaryDirectory() as directory:
|
|
147
200
|
root = Path(directory)
|
|
148
201
|
pid_path = root / "child.pid"
|
|
@@ -166,12 +219,12 @@ class CommandTests(unittest.TestCase):
|
|
|
166
219
|
deadline = time.monotonic() + 3
|
|
167
220
|
while Path(f"/proc/{pid}").exists() and time.monotonic() < deadline:
|
|
168
221
|
time.sleep(0.02)
|
|
169
|
-
self.assertEqual("
|
|
222
|
+
self.assertEqual("passed", report["status"])
|
|
170
223
|
self.assertTrue(report["descendantsTerminated"])
|
|
171
224
|
self.assertFalse(Path(f"/proc/{pid}").exists())
|
|
172
225
|
|
|
173
226
|
@unittest.skipUnless(os.name == "nt", "Windows Job Object containment assertion")
|
|
174
|
-
def
|
|
227
|
+
def test_windows_job_object_terminates_descendants_after_success(self) -> None:
|
|
175
228
|
with tempfile.TemporaryDirectory() as directory:
|
|
176
229
|
root = Path(directory)
|
|
177
230
|
pid_path = root / "child.pid"
|
|
@@ -189,8 +242,10 @@ class CommandTests(unittest.TestCase):
|
|
|
189
242
|
"timeoutSeconds": 10,
|
|
190
243
|
},
|
|
191
244
|
)
|
|
192
|
-
|
|
245
|
+
pid = int(pid_path.read_text(encoding="utf-8"))
|
|
246
|
+
self.assertEqual("passed", report["status"])
|
|
193
247
|
self.assertTrue(report["descendantsTerminated"])
|
|
248
|
+
self.assertFalse(windows_process_is_running(pid))
|
|
194
249
|
|
|
195
250
|
|
|
196
251
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
import unittest
|
|
10
|
+
from unittest.mock import patch
|
|
11
|
+
|
|
12
|
+
from engineering_process import _supervisor_posix as posix
|
|
13
|
+
from engineering_process.commands import run_check
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PosixSupervisorTests(unittest.TestCase):
|
|
17
|
+
def test_ps_snapshot_failures_are_sanitized_and_bounded(self) -> None:
|
|
18
|
+
cases = (
|
|
19
|
+
(
|
|
20
|
+
subprocess.TimeoutExpired(["ps"], posix.PROCESS_TABLE_TIMEOUT_SECONDS),
|
|
21
|
+
"process table snapshot timed out after 10 seconds",
|
|
22
|
+
),
|
|
23
|
+
(OSError("sensitive host detail"), "process table snapshot could not start"),
|
|
24
|
+
)
|
|
25
|
+
for failure, expected in cases:
|
|
26
|
+
with self.subTest(expected=expected), patch.object(
|
|
27
|
+
posix.subprocess, "run", side_effect=failure
|
|
28
|
+
) as run:
|
|
29
|
+
table, error = posix._ps_process_table()
|
|
30
|
+
self.assertEqual({}, table)
|
|
31
|
+
self.assertEqual(expected, error)
|
|
32
|
+
self.assertEqual(
|
|
33
|
+
posix.PROCESS_TABLE_TIMEOUT_SECONDS,
|
|
34
|
+
run.call_args.kwargs["timeout"],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
result = SimpleNamespace(returncode=7, stdout="")
|
|
38
|
+
with patch.object(posix.subprocess, "run", return_value=result):
|
|
39
|
+
table, error = posix._ps_process_table()
|
|
40
|
+
self.assertEqual({}, table)
|
|
41
|
+
self.assertEqual("process table snapshot exited with status 7", error)
|
|
42
|
+
|
|
43
|
+
result = SimpleNamespace(returncode=0, stdout="not-a-process-table\n")
|
|
44
|
+
with patch.object(posix.subprocess, "run", return_value=result):
|
|
45
|
+
table, error = posix._ps_process_table()
|
|
46
|
+
self.assertEqual({}, table)
|
|
47
|
+
self.assertEqual("process table snapshot was malformed", error)
|
|
48
|
+
|
|
49
|
+
result = SimpleNamespace(
|
|
50
|
+
returncode=0,
|
|
51
|
+
stdout=f"{os.getpid()} 1\nmalformed-row\n",
|
|
52
|
+
)
|
|
53
|
+
with patch.object(posix.subprocess, "run", return_value=result):
|
|
54
|
+
table, error = posix._ps_process_table()
|
|
55
|
+
self.assertEqual({}, table)
|
|
56
|
+
self.assertEqual("process table snapshot was malformed", error)
|
|
57
|
+
|
|
58
|
+
def test_routine_observation_is_throttled_but_boundaries_are_forced(self) -> None:
|
|
59
|
+
supervisor = posix.PosixProcessSupervisor()
|
|
60
|
+
process = SimpleNamespace(pid=12345)
|
|
61
|
+
with (
|
|
62
|
+
patch.object(posix, "_process_table", return_value=({}, None)) as snapshot,
|
|
63
|
+
patch.object(
|
|
64
|
+
posix.time,
|
|
65
|
+
"monotonic",
|
|
66
|
+
side_effect=(1.0, 1.001, 1.01, 1.02, 1.021),
|
|
67
|
+
),
|
|
68
|
+
):
|
|
69
|
+
supervisor.observe(process)
|
|
70
|
+
supervisor.observe(process)
|
|
71
|
+
supervisor._observe(process, force=True)
|
|
72
|
+
self.assertEqual(2, snapshot.call_count)
|
|
73
|
+
|
|
74
|
+
def test_snapshot_failure_makes_detached_cleanup_fail_closed(self) -> None:
|
|
75
|
+
supervisor = posix.PosixProcessSupervisor()
|
|
76
|
+
process = SimpleNamespace(pid=12345)
|
|
77
|
+
supervisor._known_descendants[process.pid] = set()
|
|
78
|
+
supervisor._run_ids[process.pid] = "bounded-test-run"
|
|
79
|
+
with patch.object(
|
|
80
|
+
posix,
|
|
81
|
+
"_process_table",
|
|
82
|
+
return_value=({}, "process table snapshot timed out after 10 seconds"),
|
|
83
|
+
):
|
|
84
|
+
outcome = supervisor._terminate_detached(process, grace_seconds=0)
|
|
85
|
+
self.assertFalse(outcome.bounded)
|
|
86
|
+
self.assertFalse(outcome.descendants_found)
|
|
87
|
+
self.assertEqual(
|
|
88
|
+
"process table snapshot timed out after 10 seconds", outcome.error
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@unittest.skipIf(os.name == "nt", "POSIX cleanup result assertion")
|
|
92
|
+
def test_snapshot_failure_returns_a_failed_check_instead_of_escaping(self) -> None:
|
|
93
|
+
with tempfile.TemporaryDirectory() as directory, patch.object(
|
|
94
|
+
posix,
|
|
95
|
+
"_process_table",
|
|
96
|
+
return_value=({}, "process table snapshot timed out after 10 seconds"),
|
|
97
|
+
):
|
|
98
|
+
report = run_check(
|
|
99
|
+
Path(directory),
|
|
100
|
+
{
|
|
101
|
+
"id": "snapshot-failure",
|
|
102
|
+
"run": [sys.executable, "-c", "raise SystemExit(0)"],
|
|
103
|
+
"timeoutSeconds": 10,
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
self.assertEqual("failed", report["status"])
|
|
107
|
+
self.assertTrue(report["streamFailed"])
|
|
108
|
+
self.assertFalse(report["timedOut"])
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/_supervisor_windows.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/helper_launch.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/publication_compat.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/requirements-dev.txt
RENAMED
|
File without changes
|
{engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/requirements-runtime.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process.egg-info/requires.txt
RENAMED
|
File without changes
|
{engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
{engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/finish-change/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/plan-change/SKILL.md
RENAMED
|
File without changes
|
{engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/review-change/SKILL.md
RENAMED
|
File without changes
|
{engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/run-change/SKILL.md
RENAMED
|
File without changes
|
{engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/start-change/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{engineering_process-1.1.0 → engineering_process-1.1.2}/templates/adopt-process-windows-job.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|