engineering-process 1.0.1__tar.gz → 1.1.1__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 (71) hide show
  1. {engineering_process-1.0.1/engineering_process.egg-info → engineering_process-1.1.1}/PKG-INFO +67 -4
  2. {engineering_process-1.0.1 → engineering_process-1.1.1}/README.md +66 -3
  3. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/__init__.py +1 -1
  4. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/_supervisor_posix.py +93 -93
  5. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/cli.py +55 -111
  6. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/project.py +58 -1
  7. {engineering_process-1.0.1 → engineering_process-1.1.1/engineering_process.egg-info}/PKG-INFO +67 -4
  8. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process.egg-info/SOURCES.txt +2 -1
  9. {engineering_process-1.0.1 → engineering_process-1.1.1}/process_assets/skills/finish-change/SKILL.md +5 -0
  10. {engineering_process-1.0.1 → engineering_process-1.1.1}/process_assets/skills/implement-change/SKILL.md +6 -0
  11. engineering_process-1.1.1/process_assets/skills/improve-process/SKILL.md +75 -0
  12. {engineering_process-1.0.1 → engineering_process-1.1.1}/process_assets/skills/plan-change/SKILL.md +6 -0
  13. {engineering_process-1.0.1 → engineering_process-1.1.1}/process_assets/skills/review-change/SKILL.md +7 -0
  14. {engineering_process-1.0.1 → engineering_process-1.1.1}/process_assets/skills/run-change/SKILL.md +10 -2
  15. {engineering_process-1.0.1 → engineering_process-1.1.1}/process_assets/skills/start-change/SKILL.md +8 -0
  16. {engineering_process-1.0.1 → engineering_process-1.1.1}/process_assets/skills/verify-change/SKILL.md +6 -0
  17. {engineering_process-1.0.1 → engineering_process-1.1.1}/pyproject.toml +1 -1
  18. {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/project.schema.json +58 -1
  19. {engineering_process-1.0.1 → engineering_process-1.1.1}/tests/test_automation.py +63 -2
  20. {engineering_process-1.0.1 → engineering_process-1.1.1}/tests/test_cli.py +22 -0
  21. engineering_process-1.1.1/tests/test_contracts.py +232 -0
  22. engineering_process-1.1.1/tests/test_skills.py +88 -0
  23. engineering_process-1.1.1/tests/test_supervisor_posix.py +112 -0
  24. engineering_process-1.0.1/process_assets/skills/improve-process/SKILL.md +0 -22
  25. engineering_process-1.0.1/tests/test_contracts.py +0 -67
  26. engineering_process-1.0.1/tests/test_skills.py +0 -53
  27. {engineering_process-1.0.1 → engineering_process-1.1.1}/LICENSE +0 -0
  28. {engineering_process-1.0.1 → engineering_process-1.1.1}/MANIFEST.in +0 -0
  29. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/__main__.py +0 -0
  30. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/_supervisor_windows.py +0 -0
  31. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/_windows_job.py +0 -0
  32. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/adoption.py +0 -0
  33. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/commands.py +0 -0
  34. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/contracts.py +0 -0
  35. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/distribution.py +0 -0
  36. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/helper_launch.py +0 -0
  37. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/lifecycle.py +0 -0
  38. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/publication_compat.py +0 -0
  39. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/release.py +0 -0
  40. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/repository.py +0 -0
  41. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/requirements-dev.txt +0 -0
  42. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/requirements-runtime.txt +0 -0
  43. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/skills.py +0 -0
  44. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/supervision.py +0 -0
  45. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process.egg-info/dependency_links.txt +0 -0
  46. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process.egg-info/entry_points.txt +0 -0
  47. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process.egg-info/requires.txt +0 -0
  48. {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process.egg-info/top_level.txt +0 -0
  49. {engineering_process-1.0.1 → engineering_process-1.1.1}/process-graph.json +0 -0
  50. {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/change.schema.json +0 -0
  51. {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/plan.schema.json +0 -0
  52. {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/process-graph.schema.json +0 -0
  53. {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/process-lock.schema.json +0 -0
  54. {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/project-legacy.schema.json +0 -0
  55. {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/receipt.schema.json +0 -0
  56. {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/release-change.schema.json +0 -0
  57. {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/release.schema.json +0 -0
  58. {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/review.schema.json +0 -0
  59. {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/run.schema.json +0 -0
  60. {engineering_process-1.0.1 → engineering_process-1.1.1}/setup.cfg +0 -0
  61. {engineering_process-1.0.1 → engineering_process-1.1.1}/templates/AGENTS.process.md +0 -0
  62. {engineering_process-1.0.1 → engineering_process-1.1.1}/templates/PULL_REQUEST_TEMPLATE.md +0 -0
  63. {engineering_process-1.0.1 → engineering_process-1.1.1}/templates/adopt-process-windows-job.py +0 -0
  64. {engineering_process-1.0.1 → engineering_process-1.1.1}/templates/adopt-process.py +0 -0
  65. {engineering_process-1.0.1 → engineering_process-1.1.1}/tests/test_adoption.py +0 -0
  66. {engineering_process-1.0.1 → engineering_process-1.1.1}/tests/test_architecture.py +0 -0
  67. {engineering_process-1.0.1 → engineering_process-1.1.1}/tests/test_commands.py +0 -0
  68. {engineering_process-1.0.1 → engineering_process-1.1.1}/tests/test_lifecycle.py +0 -0
  69. {engineering_process-1.0.1 → engineering_process-1.1.1}/tests/test_publication_compat.py +0 -0
  70. {engineering_process-1.0.1 → engineering_process-1.1.1}/tests/test_release.py +0 -0
  71. {engineering_process-1.0.1 → engineering_process-1.1.1}/tests/test_repository.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engineering-process
3
- Version: 1.0.1
3
+ Version: 1.1.1
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
@@ -92,10 +92,72 @@ Commands are argument arrays, never shell strings. Each command has a finite tim
92
92
  Output has a hard aggregate budget; evidence stores byte counts and hashes, never raw
93
93
  stdout or stderr that could contain secrets.
94
94
 
95
+ ### Production readiness
96
+
97
+ A consumer declares `.process/readiness.json` with production as its direction, its
98
+ current stage, immutable pack versions, and the state of every required capability.
99
+ An enforced capability maps to project-required profiles; a planned capability names
100
+ the concrete gap without pretending to have evidence:
101
+
102
+ {
103
+ "target": "production",
104
+ "stage": "production",
105
+ "packs": [{"id": "library-cli", "version": 1}],
106
+ "capabilities": [
107
+ {"id": "correctness", "state": "enforced", "evidenceProfiles": ["development"]},
108
+ {"id": "runtime-safety", "state": "enforced", "evidenceProfiles": ["development"]},
109
+ {"id": "compatibility", "state": "enforced", "evidenceProfiles": ["development"]},
110
+ {"id": "portability", "state": "enforced", "evidenceProfiles": ["development", "review"]},
111
+ {"id": "installability", "state": "enforced", "evidenceProfiles": ["review"]},
112
+ {"id": "distribution-integrity", "state": "enforced", "evidenceProfiles": ["review"]},
113
+ {"id": "adoption-integrity", "state": "enforced", "evidenceProfiles": ["development", "review"]}
114
+ ]
115
+ }
116
+
117
+ `project validate` and `doctor` resolve that declaration to the exact checks owned by
118
+ the consumer. The declaration does not make a weak command sufficient: normal CI and
119
+ independent review still judge whether those commands prove the named capability. A
120
+ building consumer may keep planned gaps while ordinary development continues. A
121
+ production-stage declaration fails closed if any capability remains planned.
122
+
123
+ The sidecar is a deliberate self-hosting boundary. Public authority N continues to
124
+ validate the unchanged strict `.process/project.json` while source N+1 validates and
125
+ self-applies the new readiness contract. Adoption leaves the consumer-owned sidecar
126
+ in place, so every later authority can repeat the same forward-compatible sequence.
127
+ Pack versions are also immutable: a process update must keep `operations@1` working
128
+ even after `operations@2` exists. Process adoption and pack upgrades are separate
129
+ consumer-owned changes, preventing a new standard from deadlocking authority adoption.
130
+
131
+ `library-cli@1` was derived from this repository as a real producer and self-consumer.
132
+ `operations@1` was then derived from renovate-ops and requires auditability, automation
133
+ correctness, bounded execution, least privilege, policy integrity, recovery, and
134
+ target-selection integrity. `desktop-media@1` is derived from LyricRail and keeps its
135
+ existing correctness, input, source-portability, audit, media, package, and recovery-
136
+ mechanism evidence enforced. Stable dependency/recovery claims, signing, key custody,
137
+ runtime/license delivery, Linux advisory resolution, real-host workspace security,
138
+ updater, incident recovery, and independent security review remain planned.
139
+ Consumers without readiness remain compatible during that evidence-backed rollout.
140
+
141
+ For each ordinary change, `run-change` first surfaces this readiness view. The accepted
142
+ request and consumer rules determine which capabilities are affected. Every change
143
+ retains the project's baseline `requiredProfiles`; start and plan add any conditional
144
+ evidence profiles needed by affected capabilities and include a planned gap only when
145
+ the accepted request explicitly selects it. Implement and review protect the enforced
146
+ floor. A planned-to-enforced promotion is a reviewed consumer source diff with fresh
147
+ evidence. Unrelated planned gaps remain visible but do not block development, and no
148
+ skill chooses product priorities or changes readiness automatically.
149
+
150
+ When a consumer incident exposes a reusable process gap, `improve-process` first keeps
151
+ the consumer safe, then prepares a sanitized GitHub issue draft from that checkout.
152
+ It deduplicates by consumer/process-version/invariant, requires owner authorization
153
+ before `gh issue create`, and uses an accepted issue as the later process change source
154
+ and `consumerEvidence`. No producer clone, consumer-CI write token, automatic process
155
+ mutation, or wait for a process release is required to continue consumer development.
156
+
95
157
  Pin the process in requirements/process.in:
96
158
 
97
159
  --only-binary :all:
98
- engineering-process==0.9.0
160
+ engineering-process==1.0.1
99
161
 
100
162
  Generate requirements/process.txt with hashes, install that lock, then run:
101
163
 
@@ -232,7 +294,7 @@ At any point:
232
294
  ## Release to consumer PR
233
295
 
234
296
  Every opted-in consumer uses Renovate's pip-compile manager. Its engineering-process
235
- package rule is enabled, never automerges, and runs exactly:
297
+ package rule keeps the adoption pull request in draft and runs exactly:
236
298
 
237
299
  python .process/adopt-process.py --project-root . --requirements-lock requirements/process.txt
238
300
 
@@ -247,7 +309,8 @@ The release workflow publishes exact wheel and sdist bytes to PyPI, verifies the
247
309
  registry hashes, creates the immutable GitHub release, and sends one authenticated
248
310
  engineering-process-published event to renovate-ops. That control plane runs Renovate
249
311
  for each repository whose protected config explicitly opts in. Each consumer's normal
250
- CI and independent review decide whether its PR can merge.
312
+ CI and independent review decide whether its draft PR can merge; the consumer owner
313
+ authorizes that merge.
251
314
 
252
315
  This repository opts in through .github/renovate.json, so it receives the same
253
316
  adoption PR as every other consumer. See SELF_HOSTING.md and RELEASING.md.
@@ -68,10 +68,72 @@ Commands are argument arrays, never shell strings. Each command has a finite tim
68
68
  Output has a hard aggregate budget; evidence stores byte counts and hashes, never raw
69
69
  stdout or stderr that could contain secrets.
70
70
 
71
+ ### Production readiness
72
+
73
+ A consumer declares `.process/readiness.json` with production as its direction, its
74
+ current stage, immutable pack versions, and the state of every required capability.
75
+ An enforced capability maps to project-required profiles; a planned capability names
76
+ the concrete gap without pretending to have evidence:
77
+
78
+ {
79
+ "target": "production",
80
+ "stage": "production",
81
+ "packs": [{"id": "library-cli", "version": 1}],
82
+ "capabilities": [
83
+ {"id": "correctness", "state": "enforced", "evidenceProfiles": ["development"]},
84
+ {"id": "runtime-safety", "state": "enforced", "evidenceProfiles": ["development"]},
85
+ {"id": "compatibility", "state": "enforced", "evidenceProfiles": ["development"]},
86
+ {"id": "portability", "state": "enforced", "evidenceProfiles": ["development", "review"]},
87
+ {"id": "installability", "state": "enforced", "evidenceProfiles": ["review"]},
88
+ {"id": "distribution-integrity", "state": "enforced", "evidenceProfiles": ["review"]},
89
+ {"id": "adoption-integrity", "state": "enforced", "evidenceProfiles": ["development", "review"]}
90
+ ]
91
+ }
92
+
93
+ `project validate` and `doctor` resolve that declaration to the exact checks owned by
94
+ the consumer. The declaration does not make a weak command sufficient: normal CI and
95
+ independent review still judge whether those commands prove the named capability. A
96
+ building consumer may keep planned gaps while ordinary development continues. A
97
+ production-stage declaration fails closed if any capability remains planned.
98
+
99
+ The sidecar is a deliberate self-hosting boundary. Public authority N continues to
100
+ validate the unchanged strict `.process/project.json` while source N+1 validates and
101
+ self-applies the new readiness contract. Adoption leaves the consumer-owned sidecar
102
+ in place, so every later authority can repeat the same forward-compatible sequence.
103
+ Pack versions are also immutable: a process update must keep `operations@1` working
104
+ even after `operations@2` exists. Process adoption and pack upgrades are separate
105
+ consumer-owned changes, preventing a new standard from deadlocking authority adoption.
106
+
107
+ `library-cli@1` was derived from this repository as a real producer and self-consumer.
108
+ `operations@1` was then derived from renovate-ops and requires auditability, automation
109
+ correctness, bounded execution, least privilege, policy integrity, recovery, and
110
+ target-selection integrity. `desktop-media@1` is derived from LyricRail and keeps its
111
+ existing correctness, input, source-portability, audit, media, package, and recovery-
112
+ mechanism evidence enforced. Stable dependency/recovery claims, signing, key custody,
113
+ runtime/license delivery, Linux advisory resolution, real-host workspace security,
114
+ updater, incident recovery, and independent security review remain planned.
115
+ Consumers without readiness remain compatible during that evidence-backed rollout.
116
+
117
+ For each ordinary change, `run-change` first surfaces this readiness view. The accepted
118
+ request and consumer rules determine which capabilities are affected. Every change
119
+ retains the project's baseline `requiredProfiles`; start and plan add any conditional
120
+ evidence profiles needed by affected capabilities and include a planned gap only when
121
+ the accepted request explicitly selects it. Implement and review protect the enforced
122
+ floor. A planned-to-enforced promotion is a reviewed consumer source diff with fresh
123
+ evidence. Unrelated planned gaps remain visible but do not block development, and no
124
+ skill chooses product priorities or changes readiness automatically.
125
+
126
+ When a consumer incident exposes a reusable process gap, `improve-process` first keeps
127
+ the consumer safe, then prepares a sanitized GitHub issue draft from that checkout.
128
+ It deduplicates by consumer/process-version/invariant, requires owner authorization
129
+ before `gh issue create`, and uses an accepted issue as the later process change source
130
+ and `consumerEvidence`. No producer clone, consumer-CI write token, automatic process
131
+ mutation, or wait for a process release is required to continue consumer development.
132
+
71
133
  Pin the process in requirements/process.in:
72
134
 
73
135
  --only-binary :all:
74
- engineering-process==0.9.0
136
+ engineering-process==1.0.1
75
137
 
76
138
  Generate requirements/process.txt with hashes, install that lock, then run:
77
139
 
@@ -208,7 +270,7 @@ At any point:
208
270
  ## Release to consumer PR
209
271
 
210
272
  Every opted-in consumer uses Renovate's pip-compile manager. Its engineering-process
211
- package rule is enabled, never automerges, and runs exactly:
273
+ package rule keeps the adoption pull request in draft and runs exactly:
212
274
 
213
275
  python .process/adopt-process.py --project-root . --requirements-lock requirements/process.txt
214
276
 
@@ -223,7 +285,8 @@ The release workflow publishes exact wheel and sdist bytes to PyPI, verifies the
223
285
  registry hashes, creates the immutable GitHub release, and sends one authenticated
224
286
  engineering-process-published event to renovate-ops. That control plane runs Renovate
225
287
  for each repository whose protected config explicitly opts in. Each consumer's normal
226
- CI and independent review decide whether its PR can merge.
288
+ CI and independent review decide whether its draft PR can merge; the consumer owner
289
+ authorizes that merge.
227
290
 
228
291
  This repository opts in through .github/renovate.json, so it receives the same
229
292
  adoption PR as every other consumer. See SELF_HOSTING.md and RELEASING.md.
@@ -1,3 +1,3 @@
1
1
  """Agent-neutral engineering process."""
2
2
 
3
- VERSION = "1.0.1"
3
+ VERSION = "1.1.1"
@@ -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(