engineering-process 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- engineering_process/__init__.py +3 -0
- engineering_process/_download_worker.py +73 -0
- engineering_process/_supervisor_posix.py +162 -0
- engineering_process/_supervisor_windows.py +148 -0
- engineering_process/_windows_job.py +419 -0
- engineering_process/bootstrap.py +217 -0
- engineering_process/bundles.py +81 -0
- engineering_process/cli.py +1012 -0
- engineering_process/contracts.py +1274 -0
- engineering_process/distribution.py +90 -0
- engineering_process/environment.py +753 -0
- engineering_process/helper_launch.py +37 -0
- engineering_process/lifecycle.py +1024 -0
- engineering_process/managed.py +89 -0
- engineering_process/markdown.py +78 -0
- engineering_process/publication.py +463 -0
- engineering_process/requirements-build.txt +1 -0
- engineering_process/requirements-dev.txt +10 -0
- engineering_process/requirements-runtime.txt +3 -0
- engineering_process/runner.py +191 -0
- engineering_process/runtime.py +55 -0
- engineering_process/skills.py +118 -0
- engineering_process/supervision.py +72 -0
- engineering_process/syncing.py +398 -0
- engineering_process/tooling.py +891 -0
- engineering_process-0.1.0.data/data/share/engineering-process/bundles.json +47 -0
- engineering_process-0.1.0.data/data/share/engineering-process/examples/change.json +26 -0
- engineering_process-0.1.0.data/data/share/engineering-process/examples/plan.json +28 -0
- engineering_process-0.1.0.data/data/share/engineering-process/examples/project.json +54 -0
- engineering_process-0.1.0.data/data/share/engineering-process/examples/review.json +31 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/change.schema.json +88 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/lifecycle.schema.json +122 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/plan.schema.json +105 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/process-lock.schema.json +39 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/project.schema.json +515 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/review.schema.json +140 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/reviewer-attestation.schema.json +30 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/verification.schema.json +120 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/assess-design/SKILL.md +39 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/build-frontend/SKILL.md +38 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/build-frontend-foundation/SKILL.md +36 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/change-api/SKILL.md +36 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/cross-repo-change/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/define-change-contract/SKILL.md +40 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/design-module/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/evolve-process/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/finish-change/SKILL.md +34 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/govern-ui/SKILL.md +40 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-change/SKILL.md +41 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-module/SKILL.md +36 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-use-case/SKILL.md +36 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/integrate-mcp/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/maintain-docs/SKILL.md +35 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/plan-change/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/publish-change/SKILL.md +38 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/review-change/SKILL.md +49 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/run-change/SKILL.md +51 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/run-change/references/execution.md +88 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/run-project-command/SKILL.md +55 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/specify-use-case/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/verify-change/SKILL.md +38 -0
- engineering_process-0.1.0.data/data/share/engineering-process/templates/AGENTS.process.md +17 -0
- engineering_process-0.1.0.data/data/share/engineering-process/templates/PULL_REQUEST_TEMPLATE.md +27 -0
- engineering_process-0.1.0.dist-info/METADATA +387 -0
- engineering_process-0.1.0.dist-info/RECORD +69 -0
- engineering_process-0.1.0.dist-info/WHEEL +5 -0
- engineering_process-0.1.0.dist-info/entry_points.txt +2 -0
- engineering_process-0.1.0.dist-info/licenses/LICENSE +21 -0
- engineering_process-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Private force-terminable HTTPS download worker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from .contracts import ContractError, ManagedToolArtifact
|
|
11
|
+
from .tooling import _download_artifact_direct, _validated_https_target
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_INPUT_LIMIT = 16_384
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _payload() -> tuple[ManagedToolArtifact, Path, float]:
|
|
18
|
+
content = sys.stdin.buffer.read(_INPUT_LIMIT + 1)
|
|
19
|
+
if len(content) > _INPUT_LIMIT:
|
|
20
|
+
raise ContractError("download worker input exceeds its limit")
|
|
21
|
+
try:
|
|
22
|
+
value = json.loads(content.decode("ascii"))
|
|
23
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
24
|
+
raise ContractError(f"download worker input is invalid: {error}") from error
|
|
25
|
+
if not isinstance(value, dict) or set(value) != {
|
|
26
|
+
"destination",
|
|
27
|
+
"maxDownloadBytes",
|
|
28
|
+
"timeoutSeconds",
|
|
29
|
+
"url",
|
|
30
|
+
}:
|
|
31
|
+
raise ContractError("download worker input has invalid fields")
|
|
32
|
+
url = value["url"]
|
|
33
|
+
maximum = value["maxDownloadBytes"]
|
|
34
|
+
timeout = value["timeoutSeconds"]
|
|
35
|
+
destination_value = value["destination"]
|
|
36
|
+
if not isinstance(url, str):
|
|
37
|
+
raise ContractError("download worker URL must be a string")
|
|
38
|
+
_validated_https_target(url, url)
|
|
39
|
+
if isinstance(maximum, bool) or not isinstance(maximum, int) or maximum < 1:
|
|
40
|
+
raise ContractError("download worker byte limit must be positive")
|
|
41
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout <= 0:
|
|
42
|
+
raise ContractError("download worker timeout must be positive")
|
|
43
|
+
if not isinstance(destination_value, str) or "\x00" in destination_value:
|
|
44
|
+
raise ContractError("download worker destination is invalid")
|
|
45
|
+
destination = Path(destination_value)
|
|
46
|
+
if not destination.is_absolute():
|
|
47
|
+
raise ContractError("download worker destination must be absolute")
|
|
48
|
+
artifact = ManagedToolArtifact(
|
|
49
|
+
platform="worker",
|
|
50
|
+
url=url,
|
|
51
|
+
checksum="sha256:" + "0" * 64,
|
|
52
|
+
archive_format="file",
|
|
53
|
+
strip_components=0,
|
|
54
|
+
max_download_bytes=maximum,
|
|
55
|
+
max_extracted_bytes=maximum,
|
|
56
|
+
max_files=1,
|
|
57
|
+
commands={},
|
|
58
|
+
)
|
|
59
|
+
return artifact, destination, time.monotonic() + float(timeout)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main() -> int:
|
|
63
|
+
try:
|
|
64
|
+
artifact, destination, deadline = _payload()
|
|
65
|
+
_download_artifact_direct(artifact, destination, deadline=deadline)
|
|
66
|
+
return 0
|
|
67
|
+
except Exception as error:
|
|
68
|
+
print(str(error), file=sys.stderr)
|
|
69
|
+
return 125
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""POSIX process-group backend for foreground-task supervision."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from typing import Mapping
|
|
11
|
+
|
|
12
|
+
from .supervision import CleanupOutcome
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _process_group_exists(process_group: int) -> bool:
|
|
16
|
+
try:
|
|
17
|
+
os.killpg(process_group, 0)
|
|
18
|
+
except ProcessLookupError:
|
|
19
|
+
return False
|
|
20
|
+
except PermissionError:
|
|
21
|
+
return True
|
|
22
|
+
return True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _wait_for_process_group(process_group: int, timeout: float) -> bool:
|
|
26
|
+
deadline = time.monotonic() + timeout
|
|
27
|
+
while time.monotonic() < deadline:
|
|
28
|
+
if not _process_group_exists(process_group):
|
|
29
|
+
return True
|
|
30
|
+
time.sleep(0.02)
|
|
31
|
+
return not _process_group_exists(process_group)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _terminate_group(process_group: int, grace_seconds: float) -> CleanupOutcome:
|
|
35
|
+
if not _process_group_exists(process_group):
|
|
36
|
+
return CleanupOutcome(bounded=True)
|
|
37
|
+
try:
|
|
38
|
+
os.killpg(process_group, signal.SIGTERM)
|
|
39
|
+
except ProcessLookupError:
|
|
40
|
+
return CleanupOutcome(bounded=True, descendants_found=True)
|
|
41
|
+
except OSError:
|
|
42
|
+
pass
|
|
43
|
+
if _wait_for_process_group(process_group, grace_seconds):
|
|
44
|
+
return CleanupOutcome(bounded=True, descendants_found=True)
|
|
45
|
+
try:
|
|
46
|
+
os.killpg(process_group, signal.SIGKILL)
|
|
47
|
+
except ProcessLookupError:
|
|
48
|
+
return CleanupOutcome(bounded=True, descendants_found=True)
|
|
49
|
+
except OSError:
|
|
50
|
+
pass
|
|
51
|
+
bounded = _wait_for_process_group(process_group, grace_seconds)
|
|
52
|
+
return CleanupOutcome(
|
|
53
|
+
bounded=bounded,
|
|
54
|
+
descendants_found=True,
|
|
55
|
+
error=(
|
|
56
|
+
None
|
|
57
|
+
if bounded
|
|
58
|
+
else "command process group could not be terminated within the bounded grace period"
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class PosixProcessSupervisor:
|
|
64
|
+
def resolve_application(
|
|
65
|
+
self,
|
|
66
|
+
command: str,
|
|
67
|
+
*,
|
|
68
|
+
working_directory: Path,
|
|
69
|
+
environment: Mapping[str, str],
|
|
70
|
+
) -> Path:
|
|
71
|
+
if not command or "\x00" in command:
|
|
72
|
+
raise OSError("command executable is invalid")
|
|
73
|
+
supplied = Path(command)
|
|
74
|
+
if supplied.is_absolute() or supplied.parent != Path("."):
|
|
75
|
+
candidate = supplied if supplied.is_absolute() else working_directory / supplied
|
|
76
|
+
try:
|
|
77
|
+
resolved = candidate.resolve(strict=True)
|
|
78
|
+
except OSError as error:
|
|
79
|
+
raise OSError(f"executable is unavailable: {command}") from error
|
|
80
|
+
if not resolved.is_file() or not os.access(resolved, os.X_OK):
|
|
81
|
+
raise OSError(f"executable is unavailable: {command}")
|
|
82
|
+
return resolved
|
|
83
|
+
for raw_directory in environment.get("PATH", "").split(os.pathsep):
|
|
84
|
+
directory = Path(raw_directory or ".")
|
|
85
|
+
if not directory.is_absolute():
|
|
86
|
+
directory = working_directory / directory
|
|
87
|
+
candidate = directory / command
|
|
88
|
+
try:
|
|
89
|
+
resolved = candidate.resolve(strict=True)
|
|
90
|
+
except OSError:
|
|
91
|
+
continue
|
|
92
|
+
if resolved.is_file() and os.access(resolved, os.X_OK):
|
|
93
|
+
return resolved
|
|
94
|
+
raise OSError(f"executable is unavailable: {command}")
|
|
95
|
+
|
|
96
|
+
def spawn(
|
|
97
|
+
self,
|
|
98
|
+
command: tuple[str, ...],
|
|
99
|
+
*,
|
|
100
|
+
working_directory: Path,
|
|
101
|
+
environment: Mapping[str, str],
|
|
102
|
+
) -> subprocess.Popen[bytes]:
|
|
103
|
+
application = self.resolve_application(
|
|
104
|
+
command[0],
|
|
105
|
+
working_directory=working_directory,
|
|
106
|
+
environment=environment,
|
|
107
|
+
)
|
|
108
|
+
return subprocess.Popen(
|
|
109
|
+
command,
|
|
110
|
+
executable=application,
|
|
111
|
+
cwd=working_directory,
|
|
112
|
+
stdin=subprocess.DEVNULL,
|
|
113
|
+
stdout=subprocess.PIPE,
|
|
114
|
+
stderr=subprocess.PIPE,
|
|
115
|
+
shell=False,
|
|
116
|
+
env=dict(environment),
|
|
117
|
+
start_new_session=True,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def terminate(
|
|
121
|
+
self,
|
|
122
|
+
process: subprocess.Popen[bytes],
|
|
123
|
+
*,
|
|
124
|
+
grace_seconds: float,
|
|
125
|
+
) -> CleanupOutcome:
|
|
126
|
+
try:
|
|
127
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
128
|
+
except ProcessLookupError:
|
|
129
|
+
pass
|
|
130
|
+
except OSError:
|
|
131
|
+
pass
|
|
132
|
+
try:
|
|
133
|
+
process.wait(timeout=grace_seconds)
|
|
134
|
+
except subprocess.TimeoutExpired:
|
|
135
|
+
try:
|
|
136
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
137
|
+
except ProcessLookupError:
|
|
138
|
+
pass
|
|
139
|
+
except OSError:
|
|
140
|
+
pass
|
|
141
|
+
try:
|
|
142
|
+
process.wait(timeout=grace_seconds)
|
|
143
|
+
except subprocess.TimeoutExpired:
|
|
144
|
+
return CleanupOutcome(
|
|
145
|
+
bounded=False,
|
|
146
|
+
error="command root process survived bounded process-group termination",
|
|
147
|
+
)
|
|
148
|
+
descendants = _terminate_group(process.pid, grace_seconds)
|
|
149
|
+
return CleanupOutcome(bounded=descendants.bounded, error=descendants.error)
|
|
150
|
+
|
|
151
|
+
def finalize(
|
|
152
|
+
self,
|
|
153
|
+
process: subprocess.Popen[bytes],
|
|
154
|
+
*,
|
|
155
|
+
grace_seconds: float,
|
|
156
|
+
) -> CleanupOutcome:
|
|
157
|
+
# The group id remains valid while any descendant from the owned session is
|
|
158
|
+
# alive, even after the original process has exited.
|
|
159
|
+
return _terminate_group(process.pid, grace_seconds)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
POSIX_SUPERVISOR = PosixProcessSupervisor()
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Windows Job Object backend for foreground-task supervision."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import subprocess
|
|
8
|
+
from typing import Mapping
|
|
9
|
+
|
|
10
|
+
from .helper_launch import isolated_helper_command
|
|
11
|
+
from .supervision import CleanupOutcome
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_UNSUPPORTED_SHELL_SUFFIXES = {".bat", ".cmd"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_windows_application(
|
|
18
|
+
command: str,
|
|
19
|
+
*,
|
|
20
|
+
working_directory: Path,
|
|
21
|
+
environment: Mapping[str, str],
|
|
22
|
+
path_separator: str | None = None,
|
|
23
|
+
) -> Path:
|
|
24
|
+
"""Resolve a native executable without CreateProcessW's CWD-first search.
|
|
25
|
+
|
|
26
|
+
Relative PATH entries are ignored. A project executable is therefore selected
|
|
27
|
+
only when the command explicitly contains a path, never merely because a
|
|
28
|
+
same-named file exists in the checkout.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
if not command or "\x00" in command:
|
|
32
|
+
raise OSError("command executable is invalid")
|
|
33
|
+
separator = os.pathsep if path_separator is None else path_separator
|
|
34
|
+
supplied = Path(command)
|
|
35
|
+
explicit_path = supplied.is_absolute() or supplied.parent != Path(".")
|
|
36
|
+
suffix = supplied.suffix.casefold()
|
|
37
|
+
if suffix in _UNSUPPORTED_SHELL_SUFFIXES:
|
|
38
|
+
raise OSError(
|
|
39
|
+
f"Windows batch command {command} requires a shell; only native .exe commands are supported"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
candidates: list[Path] = []
|
|
43
|
+
names = (command,) if suffix else (f"{command}.exe",)
|
|
44
|
+
if explicit_path:
|
|
45
|
+
base = supplied if supplied.is_absolute() else working_directory / supplied
|
|
46
|
+
candidates.append(base if suffix else Path(f"{base}.exe"))
|
|
47
|
+
else:
|
|
48
|
+
for raw_directory in environment.get("PATH", "").split(separator):
|
|
49
|
+
if not raw_directory:
|
|
50
|
+
continue
|
|
51
|
+
directory = Path(raw_directory)
|
|
52
|
+
if not directory.is_absolute():
|
|
53
|
+
continue
|
|
54
|
+
candidates.extend(directory / name for name in names)
|
|
55
|
+
|
|
56
|
+
for candidate in candidates:
|
|
57
|
+
try:
|
|
58
|
+
resolved = candidate.resolve(strict=True)
|
|
59
|
+
except OSError:
|
|
60
|
+
continue
|
|
61
|
+
if resolved.is_file() and resolved.suffix.casefold() == ".exe":
|
|
62
|
+
return resolved
|
|
63
|
+
raise OSError(f"native Windows executable is unavailable: {command}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class WindowsProcessSupervisor:
|
|
67
|
+
def resolve_application(
|
|
68
|
+
self,
|
|
69
|
+
command: str,
|
|
70
|
+
*,
|
|
71
|
+
working_directory: Path,
|
|
72
|
+
environment: Mapping[str, str],
|
|
73
|
+
) -> Path:
|
|
74
|
+
return resolve_windows_application(
|
|
75
|
+
command,
|
|
76
|
+
working_directory=working_directory,
|
|
77
|
+
environment=environment,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def spawn(
|
|
81
|
+
self,
|
|
82
|
+
command: tuple[str, ...],
|
|
83
|
+
*,
|
|
84
|
+
working_directory: Path,
|
|
85
|
+
environment: Mapping[str, str],
|
|
86
|
+
) -> subprocess.Popen[bytes]:
|
|
87
|
+
application = self.resolve_application(
|
|
88
|
+
command[0],
|
|
89
|
+
working_directory=working_directory,
|
|
90
|
+
environment=environment,
|
|
91
|
+
)
|
|
92
|
+
wrapper = isolated_helper_command(
|
|
93
|
+
"engineering_process._windows_job",
|
|
94
|
+
"--application",
|
|
95
|
+
str(application),
|
|
96
|
+
"--",
|
|
97
|
+
*command,
|
|
98
|
+
)
|
|
99
|
+
return subprocess.Popen(
|
|
100
|
+
wrapper,
|
|
101
|
+
cwd=working_directory,
|
|
102
|
+
stdin=subprocess.DEVNULL,
|
|
103
|
+
stdout=subprocess.PIPE,
|
|
104
|
+
stderr=subprocess.PIPE,
|
|
105
|
+
shell=False,
|
|
106
|
+
env=dict(environment),
|
|
107
|
+
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def terminate(
|
|
111
|
+
self,
|
|
112
|
+
process: subprocess.Popen[bytes],
|
|
113
|
+
*,
|
|
114
|
+
grace_seconds: float,
|
|
115
|
+
) -> CleanupOutcome:
|
|
116
|
+
if process.poll() is not None:
|
|
117
|
+
return CleanupOutcome(bounded=True)
|
|
118
|
+
try:
|
|
119
|
+
process.terminate()
|
|
120
|
+
process.wait(timeout=grace_seconds)
|
|
121
|
+
return CleanupOutcome(bounded=True)
|
|
122
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
123
|
+
try:
|
|
124
|
+
process.kill()
|
|
125
|
+
except OSError:
|
|
126
|
+
pass
|
|
127
|
+
try:
|
|
128
|
+
process.wait(timeout=grace_seconds)
|
|
129
|
+
except subprocess.TimeoutExpired:
|
|
130
|
+
return CleanupOutcome(
|
|
131
|
+
bounded=False,
|
|
132
|
+
error="Windows Job Object wrapper survived bounded termination",
|
|
133
|
+
)
|
|
134
|
+
return CleanupOutcome(bounded=True)
|
|
135
|
+
|
|
136
|
+
def finalize(
|
|
137
|
+
self,
|
|
138
|
+
process: subprocess.Popen[bytes],
|
|
139
|
+
*,
|
|
140
|
+
grace_seconds: float,
|
|
141
|
+
) -> CleanupOutcome:
|
|
142
|
+
del process, grace_seconds
|
|
143
|
+
# The helper does not return until the target exits and its Job Object has
|
|
144
|
+
# no live descendants. Cleanup failures are represented by its exit code.
|
|
145
|
+
return CleanupOutcome(bounded=True)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
WINDOWS_SUPERVISOR = WindowsProcessSupervisor()
|