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.
Files changed (68) hide show
  1. {engineering_process-1.1.0/engineering_process.egg-info → engineering_process-1.1.2}/PKG-INFO +1 -1
  2. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/__init__.py +1 -1
  3. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/_supervisor_posix.py +93 -93
  4. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/commands.py +1 -1
  5. {engineering_process-1.1.0 → engineering_process-1.1.2/engineering_process.egg-info}/PKG-INFO +1 -1
  6. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process.egg-info/SOURCES.txt +2 -1
  7. {engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/verify-change/SKILL.md +4 -3
  8. {engineering_process-1.1.0 → engineering_process-1.1.2}/pyproject.toml +1 -1
  9. {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_automation.py +20 -1
  10. {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_commands.py +61 -6
  11. engineering_process-1.1.2/tests/test_supervisor_posix.py +112 -0
  12. {engineering_process-1.1.0 → engineering_process-1.1.2}/LICENSE +0 -0
  13. {engineering_process-1.1.0 → engineering_process-1.1.2}/MANIFEST.in +0 -0
  14. {engineering_process-1.1.0 → engineering_process-1.1.2}/README.md +0 -0
  15. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/__main__.py +0 -0
  16. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/_supervisor_windows.py +0 -0
  17. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/_windows_job.py +0 -0
  18. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/adoption.py +0 -0
  19. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/cli.py +0 -0
  20. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/contracts.py +0 -0
  21. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/distribution.py +0 -0
  22. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/helper_launch.py +0 -0
  23. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/lifecycle.py +0 -0
  24. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/project.py +0 -0
  25. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/publication_compat.py +0 -0
  26. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/release.py +0 -0
  27. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/repository.py +0 -0
  28. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/requirements-dev.txt +0 -0
  29. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/requirements-runtime.txt +0 -0
  30. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/skills.py +0 -0
  31. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process/supervision.py +0 -0
  32. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process.egg-info/dependency_links.txt +0 -0
  33. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process.egg-info/entry_points.txt +0 -0
  34. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process.egg-info/requires.txt +0 -0
  35. {engineering_process-1.1.0 → engineering_process-1.1.2}/engineering_process.egg-info/top_level.txt +0 -0
  36. {engineering_process-1.1.0 → engineering_process-1.1.2}/process-graph.json +0 -0
  37. {engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/finish-change/SKILL.md +0 -0
  38. {engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/implement-change/SKILL.md +0 -0
  39. {engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/improve-process/SKILL.md +0 -0
  40. {engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/plan-change/SKILL.md +0 -0
  41. {engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/review-change/SKILL.md +0 -0
  42. {engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/run-change/SKILL.md +0 -0
  43. {engineering_process-1.1.0 → engineering_process-1.1.2}/process_assets/skills/start-change/SKILL.md +0 -0
  44. {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/change.schema.json +0 -0
  45. {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/plan.schema.json +0 -0
  46. {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/process-graph.schema.json +0 -0
  47. {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/process-lock.schema.json +0 -0
  48. {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/project-legacy.schema.json +0 -0
  49. {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/project.schema.json +0 -0
  50. {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/receipt.schema.json +0 -0
  51. {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/release-change.schema.json +0 -0
  52. {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/release.schema.json +0 -0
  53. {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/review.schema.json +0 -0
  54. {engineering_process-1.1.0 → engineering_process-1.1.2}/schemas/run.schema.json +0 -0
  55. {engineering_process-1.1.0 → engineering_process-1.1.2}/setup.cfg +0 -0
  56. {engineering_process-1.1.0 → engineering_process-1.1.2}/templates/AGENTS.process.md +0 -0
  57. {engineering_process-1.1.0 → engineering_process-1.1.2}/templates/PULL_REQUEST_TEMPLATE.md +0 -0
  58. {engineering_process-1.1.0 → engineering_process-1.1.2}/templates/adopt-process-windows-job.py +0 -0
  59. {engineering_process-1.1.0 → engineering_process-1.1.2}/templates/adopt-process.py +0 -0
  60. {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_adoption.py +0 -0
  61. {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_architecture.py +0 -0
  62. {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_cli.py +0 -0
  63. {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_contracts.py +0 -0
  64. {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_lifecycle.py +0 -0
  65. {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_publication_compat.py +0 -0
  66. {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_release.py +0 -0
  67. {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_repository.py +0 -0
  68. {engineering_process-1.1.0 → engineering_process-1.1.2}/tests/test_skills.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engineering-process
3
- Version: 1.1.0
3
+ Version: 1.1.2
4
4
  Summary: A small agent-neutral engineering lifecycle with managed adoption
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/phuongnse/engineering-process
@@ -1,3 +1,3 @@
1
1
  """Agent-neutral engineering process."""
2
2
 
3
- VERSION = "1.1.0"
3
+ VERSION = "1.1.2"
@@ -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 _process_table() -> dict[int, int]:
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
- result = subprocess.run(
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
- try:
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
- try:
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
- try:
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
- try:
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
- try:
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.observe(process)
245
+ self._observation_errors.pop(process.pid, None)
246
+ self._observe(process, force=True)
238
247
  return process
239
248
 
240
- def observe(self, process: subprocess.Popen[bytes]) -> None:
241
- table = _process_table()
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
- known.update(_descendants(process.pid, table))
244
- run_id = self._run_ids.get(process.pid)
245
- if sys.platform.startswith("linux") and run_id is not None:
246
- adopted = {
247
- pid
248
- for pid, parent in table.items()
249
- if (
250
- parent == os.getpid()
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 _terminate_detached(
258
- self,
259
- process: subprocess.Popen[bytes],
260
- grace_seconds: float,
261
- ) -> CleanupOutcome:
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=True)
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
- bounded = not any(_pid_alive(pid) for pid in live)
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=bounded,
305
+ bounded=descendants_bounded and error is None,
287
306
  descendants_found=True,
288
- error=None if bounded else "detached descendants survived bounded termination",
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.observe(process)
298
- try:
299
- os.killpg(process.pid, signal.SIGTERM)
300
- except ProcessLookupError:
301
- pass
302
- except OSError:
303
- pass
304
- try:
305
- process.wait(timeout=grace_seconds)
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.observe(process)
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 {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engineering-process
3
- Version: 1.1.0
3
+ Version: 1.1.2
4
4
  Summary: A small agent-neutral engineering lifecycle with managed adoption
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/phuongnse/engineering-process
@@ -62,4 +62,5 @@ tests/test_lifecycle.py
62
62
  tests/test_publication_compat.py
63
63
  tests/test_release.py
64
64
  tests/test_repository.py
65
- tests/test_skills.py
65
+ tests/test_skills.py
66
+ tests/test_supervisor_posix.py
@@ -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
- surviving descendant, or tracked repository mutation is a failure and leaves the
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "engineering-process"
7
- version = "1.1.0"
7
+ version = "1.1.2"
8
8
  description = "A small agent-neutral engineering lifecycle with managed adoption"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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
- self.assertIn("engineering-process==1.0.1", (ROOT / "requirements" / "process.in").read_text(encoding="utf-8"))
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 test_surviving_descendant_is_terminated_and_fails(self) -> None:
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("failed", report["status"])
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 test_detached_descendant_is_terminated_and_fails(self) -> None:
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("failed", report["status"])
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 test_windows_job_object_terminates_descendants(self) -> None:
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
- self.assertEqual("failed", report["status"])
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()