@hunter-harness/workflow-harness 0.2.28 → 0.2.30

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 (162) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/contracts/workflow-policy.json +34 -2
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +323 -32
  7. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +28 -51
  13. package/harness/bundles/general/claude-code/harness-sync/reference.md +89 -142
  14. package/harness/bundles/general/claude-code/harness-test/SKILL.md +26 -2
  15. package/harness/bundles/general/claude-code/harness-test/checklist.md +15 -0
  16. package/harness/bundles/general/claude-code/harness-test/reference.md +28 -4
  17. package/harness/bundles/general/claude-code/scripts/harness_change.py +189 -0
  18. package/harness/bundles/general/claude-code/scripts/harness_test_runner.py +974 -0
  19. package/harness/bundles/general/claude-code/scripts/harness_workflow_policy.py +87 -0
  20. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  21. package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +34 -2
  22. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  23. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  24. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +323 -32
  26. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  31. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +30 -51
  32. package/harness/bundles/general/codebuddy/harness-sync/reference.md +89 -142
  33. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +26 -2
  34. package/harness/bundles/general/codebuddy/harness-test/checklist.md +15 -0
  35. package/harness/bundles/general/codebuddy/harness-test/reference.md +28 -4
  36. package/harness/bundles/general/codebuddy/scripts/harness_change.py +189 -0
  37. package/harness/bundles/general/codebuddy/scripts/harness_test_runner.py +974 -0
  38. package/harness/bundles/general/codebuddy/scripts/harness_workflow_policy.py +87 -0
  39. package/harness/bundles/general/codex/.harness-build.json +1 -1
  40. package/harness/bundles/general/codex/contracts/workflow-policy.json +34 -2
  41. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  42. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  43. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  44. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +323 -32
  45. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  46. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  48. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  49. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  50. package/harness/bundles/general/codex/harness-sync/SKILL.md +30 -51
  51. package/harness/bundles/general/codex/harness-sync/reference.md +89 -142
  52. package/harness/bundles/general/codex/harness-test/SKILL.md +26 -2
  53. package/harness/bundles/general/codex/harness-test/checklist.md +15 -0
  54. package/harness/bundles/general/codex/harness-test/reference.md +28 -4
  55. package/harness/bundles/general/codex/scripts/harness_change.py +189 -0
  56. package/harness/bundles/general/codex/scripts/harness_test_runner.py +974 -0
  57. package/harness/bundles/general/codex/scripts/harness_workflow_policy.py +87 -0
  58. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  59. package/harness/bundles/general/cursor/contracts/workflow-policy.json +34 -2
  60. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  61. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  62. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  63. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +323 -32
  64. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  65. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  66. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  67. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  68. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  69. package/harness/bundles/general/cursor/harness-sync/SKILL.md +30 -51
  70. package/harness/bundles/general/cursor/harness-sync/reference.md +89 -142
  71. package/harness/bundles/general/cursor/harness-test/SKILL.md +26 -2
  72. package/harness/bundles/general/cursor/harness-test/checklist.md +15 -0
  73. package/harness/bundles/general/cursor/harness-test/reference.md +28 -4
  74. package/harness/bundles/general/cursor/scripts/harness_change.py +189 -0
  75. package/harness/bundles/general/cursor/scripts/harness_test_runner.py +974 -0
  76. package/harness/bundles/general/cursor/scripts/harness_workflow_policy.py +87 -0
  77. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  78. package/harness/bundles/java/claude-code/contracts/workflow-policy.json +34 -2
  79. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  80. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  81. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  82. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  83. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +323 -32
  84. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  85. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  86. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  87. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  88. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  89. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  90. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +28 -51
  91. package/harness/bundles/java/claude-code/harness-sync/reference.md +89 -142
  92. package/harness/bundles/java/claude-code/harness-test/SKILL.md +26 -2
  93. package/harness/bundles/java/claude-code/scripts/harness_change.py +189 -0
  94. package/harness/bundles/java/claude-code/scripts/harness_test_runner.py +974 -0
  95. package/harness/bundles/java/claude-code/scripts/harness_workflow_policy.py +87 -0
  96. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  97. package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +34 -2
  98. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  99. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  100. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  101. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  102. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +323 -32
  103. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  104. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  105. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  106. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  107. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  108. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  109. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +30 -51
  110. package/harness/bundles/java/codebuddy/harness-sync/reference.md +89 -142
  111. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +26 -2
  112. package/harness/bundles/java/codebuddy/scripts/harness_change.py +189 -0
  113. package/harness/bundles/java/codebuddy/scripts/harness_test_runner.py +974 -0
  114. package/harness/bundles/java/codebuddy/scripts/harness_workflow_policy.py +87 -0
  115. package/harness/bundles/java/codex/.harness-build.json +1 -1
  116. package/harness/bundles/java/codex/contracts/workflow-policy.json +34 -2
  117. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  118. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  119. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  120. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  121. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +323 -32
  122. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  123. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  124. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  125. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  126. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  127. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  128. package/harness/bundles/java/codex/harness-sync/SKILL.md +30 -51
  129. package/harness/bundles/java/codex/harness-sync/reference.md +89 -142
  130. package/harness/bundles/java/codex/harness-test/SKILL.md +26 -2
  131. package/harness/bundles/java/codex/scripts/harness_change.py +189 -0
  132. package/harness/bundles/java/codex/scripts/harness_test_runner.py +974 -0
  133. package/harness/bundles/java/codex/scripts/harness_workflow_policy.py +87 -0
  134. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  135. package/harness/bundles/java/cursor/contracts/workflow-policy.json +34 -2
  136. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  137. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  138. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  139. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  140. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +323 -32
  141. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  142. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  143. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  144. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  145. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  146. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  147. package/harness/bundles/java/cursor/harness-sync/SKILL.md +30 -51
  148. package/harness/bundles/java/cursor/harness-sync/reference.md +89 -142
  149. package/harness/bundles/java/cursor/harness-test/SKILL.md +26 -2
  150. package/harness/bundles/java/cursor/scripts/harness_change.py +189 -0
  151. package/harness/bundles/java/cursor/scripts/harness_test_runner.py +974 -0
  152. package/harness/bundles/java/cursor/scripts/harness_workflow_policy.py +87 -0
  153. package/harness/manifests/general/claude-code.json +32 -19
  154. package/harness/manifests/general/codebuddy.json +32 -19
  155. package/harness/manifests/general/codex.json +32 -19
  156. package/harness/manifests/general/cursor.json +32 -19
  157. package/harness/manifests/java/claude-code.json +32 -19
  158. package/harness/manifests/java/codebuddy.json +32 -19
  159. package/harness/manifests/java/codex.json +32 -19
  160. package/harness/manifests/java/cursor.json +32 -19
  161. package/hunter-workflow-family.json +18 -2
  162. package/package.json +1 -1
@@ -0,0 +1,974 @@
1
+ #!/usr/bin/env python3
2
+ """Resource-safe test command runner for Hunter Harness workflows.
3
+
4
+ The runner keeps test commands sequential, lowers their scheduling priority,
5
+ enforces a single active run per project, and tears down the managed process
6
+ tree after every command. Resource-intensive profiles require an explicit
7
+ confirmation outside CI.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import ctypes
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import signal
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ import time
22
+ import uuid
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Mapping, Sequence
26
+
27
+ DEFAULT_MAX_WORKERS = 2
28
+ DEFAULT_TIMEOUT_SECONDS = 300.0
29
+ RESOURCE_INTENSIVE_PROFILES = frozenset({"system", "full"})
30
+ DEFAULT_RESOURCE_MODULES = (
31
+ "test_harness_integration.py",
32
+ "test_harness_service.py",
33
+ )
34
+ DEFAULT_DETACHED_SERVICE_MODULES = ("test_harness_service.py",)
35
+ TRUTHY_VALUES = frozenset({"1", "true", "yes", "on"})
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class TestModule:
40
+ """One isolated unittest module."""
41
+
42
+ name: str
43
+ path: Path
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class CommandResult:
48
+ """Result of one managed command."""
49
+
50
+ returncode: int
51
+ timed_out: bool
52
+ duration_seconds: float
53
+ process_tree_isolated: bool
54
+ output_tail: str = ""
55
+
56
+
57
+ class TestRunAlreadyActive(RuntimeError):
58
+ """Raised when another resource-safe runner owns the project lock."""
59
+
60
+
61
+ def _truthy(value: object) -> bool:
62
+ return str(value or "").strip().lower() in TRUTHY_VALUES
63
+
64
+
65
+ def bounded_worker_count(
66
+ requested: int,
67
+ environ: Mapping[str, str] | None = None,
68
+ ) -> int:
69
+ """Return a positive worker count that never exceeds the global safe cap."""
70
+
71
+ env = os.environ if environ is None else environ
72
+ raw_limit = env.get("HARNESS_TEST_MAX_WORKERS", str(DEFAULT_MAX_WORKERS))
73
+ try:
74
+ configured = int(raw_limit)
75
+ except (TypeError, ValueError):
76
+ configured = DEFAULT_MAX_WORKERS
77
+ configured = max(1, min(DEFAULT_MAX_WORKERS, configured))
78
+ return max(1, min(int(requested), configured))
79
+
80
+
81
+ def resource_profile_allowed(
82
+ profile: str,
83
+ confirmed: bool,
84
+ environ: Mapping[str, str] | None = None,
85
+ ) -> bool:
86
+ """Return whether a profile may execute in the current environment."""
87
+
88
+ if profile not in RESOURCE_INTENSIVE_PROFILES:
89
+ return True
90
+ env = os.environ if environ is None else environ
91
+ return bool(
92
+ confirmed
93
+ or _truthy(env.get("CI"))
94
+ or _truthy(env.get("HARNESS_ALLOW_RESOURCE_INTENSIVE_TESTS"))
95
+ )
96
+
97
+
98
+ def discover_test_modules(tests_dir: Path) -> list[TestModule]:
99
+ """Discover deterministic top-level unittest modules."""
100
+
101
+ root = Path(tests_dir).resolve()
102
+ if not root.is_dir():
103
+ raise FileNotFoundError(f"test directory does not exist: {root}")
104
+ return [
105
+ TestModule(path.name, path)
106
+ for path in sorted(root.glob("test_*.py"), key=lambda item: item.name)
107
+ if path.is_file()
108
+ ]
109
+
110
+
111
+ def build_unittest_plan(
112
+ tests_dir: Path,
113
+ profile: str,
114
+ *,
115
+ resource_modules: Sequence[str] = DEFAULT_RESOURCE_MODULES,
116
+ ) -> list[TestModule]:
117
+ """Partition unittest modules into safe, system, or full execution plans."""
118
+
119
+ if profile not in {"safe", "system", "full"}:
120
+ raise ValueError(f"unsupported test profile: {profile}")
121
+ modules = discover_test_modules(tests_dir)
122
+ resource_names = {Path(item).name for item in resource_modules}
123
+ regular = [item for item in modules if item.name not in resource_names]
124
+ intensive = [item for item in modules if item.name in resource_names]
125
+ if profile == "safe":
126
+ return regular
127
+ if profile == "system":
128
+ return intensive
129
+ return [*regular, *intensive]
130
+
131
+
132
+ def _pid_is_running(pid: int) -> bool:
133
+ if pid <= 0:
134
+ return False
135
+ if pid == os.getpid():
136
+ return True
137
+ if os.name == "nt":
138
+ process_query_limited_information = 0x1000
139
+ kernel32 = ctypes.windll.kernel32
140
+ kernel32.OpenProcess.restype = ctypes.c_void_p
141
+ handle = kernel32.OpenProcess(
142
+ process_query_limited_information,
143
+ False,
144
+ pid,
145
+ )
146
+ if not handle:
147
+ return False
148
+ try:
149
+ exit_code = ctypes.c_uint32()
150
+ if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
151
+ return False
152
+ return int(exit_code.value) == 259
153
+ finally:
154
+ kernel32.CloseHandle(ctypes.c_void_p(handle))
155
+ try:
156
+ os.kill(pid, 0)
157
+ except ProcessLookupError:
158
+ return False
159
+ except PermissionError:
160
+ return True
161
+ return True
162
+
163
+
164
+ class TestRunLock:
165
+ """Atomic per-project single-instance lock with stale-owner recovery."""
166
+
167
+ def __init__(self, project: Path, *, lock_root: Path | None = None) -> None:
168
+ resolved = str(Path(project).resolve())
169
+ if os.name == "nt":
170
+ resolved = resolved.casefold()
171
+ digest = hashlib.sha256(resolved.encode("utf-8")).hexdigest()[:24]
172
+ root = (
173
+ Path(lock_root)
174
+ if lock_root is not None
175
+ else Path(
176
+ os.environ.get(
177
+ "HARNESS_TEST_LOCK_ROOT",
178
+ str(Path(tempfile.gettempdir()) / "hunter-harness-test-locks"),
179
+ )
180
+ )
181
+ )
182
+ self.path = root / f"{digest}.lock"
183
+ self.token = uuid.uuid4().hex
184
+ self._owned = False
185
+
186
+ def _owner(self) -> dict[str, object]:
187
+ try:
188
+ return json.loads(self.path.read_text(encoding="utf-8"))
189
+ except (OSError, json.JSONDecodeError):
190
+ return {}
191
+
192
+ def acquire(self) -> None:
193
+ self.path.parent.mkdir(parents=True, exist_ok=True)
194
+ for attempt in range(2):
195
+ try:
196
+ descriptor = os.open(
197
+ self.path,
198
+ os.O_CREAT | os.O_EXCL | os.O_WRONLY,
199
+ 0o600,
200
+ )
201
+ except FileExistsError:
202
+ owner = self._owner()
203
+ try:
204
+ owner_pid = int(owner.get("pid", 0))
205
+ except (TypeError, ValueError):
206
+ owner_pid = 0
207
+ try:
208
+ age_seconds = time.time() - self.path.stat().st_mtime
209
+ except OSError:
210
+ age_seconds = 0
211
+ if _pid_is_running(owner_pid) or age_seconds < 30:
212
+ raise TestRunAlreadyActive(
213
+ "TEST_RUN_ALREADY_ACTIVE: "
214
+ f"lock={self.path} ownerPid={owner_pid or 'unknown'}"
215
+ )
216
+ try:
217
+ self.path.unlink()
218
+ except FileNotFoundError:
219
+ pass
220
+ if attempt == 0:
221
+ continue
222
+ raise TestRunAlreadyActive(
223
+ f"TEST_RUN_ALREADY_ACTIVE: could not reclaim {self.path}"
224
+ )
225
+ else:
226
+ payload = {
227
+ "pid": os.getpid(),
228
+ "token": self.token,
229
+ "startedAtUnix": time.time(),
230
+ }
231
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
232
+ json.dump(payload, stream, ensure_ascii=False)
233
+ stream.flush()
234
+ self._owned = True
235
+ return
236
+ raise TestRunAlreadyActive(f"TEST_RUN_ALREADY_ACTIVE: {self.path}")
237
+
238
+ def release(self) -> None:
239
+ if not self._owned:
240
+ return
241
+ owner = self._owner()
242
+ if owner.get("token") == self.token:
243
+ try:
244
+ self.path.unlink()
245
+ except FileNotFoundError:
246
+ pass
247
+ self._owned = False
248
+
249
+ def __enter__(self) -> "TestRunLock":
250
+ self.acquire()
251
+ return self
252
+
253
+ def __exit__(self, exc_type, exc, traceback) -> None:
254
+ self.release()
255
+
256
+
257
+ def lower_process_priority() -> bool:
258
+ """Best-effort scheduling priority reduction inherited by child processes."""
259
+
260
+ try:
261
+ if os.name == "nt":
262
+ below_normal_priority_class = 0x00004000
263
+ kernel32 = ctypes.windll.kernel32
264
+ return bool(
265
+ kernel32.SetPriorityClass(
266
+ kernel32.GetCurrentProcess(),
267
+ below_normal_priority_class,
268
+ )
269
+ )
270
+ os.nice(5)
271
+ return True
272
+ except (AttributeError, OSError):
273
+ return False
274
+
275
+
276
+ class _WindowsIoCounters(ctypes.Structure):
277
+ _fields_ = [
278
+ ("ReadOperationCount", ctypes.c_uint64),
279
+ ("WriteOperationCount", ctypes.c_uint64),
280
+ ("OtherOperationCount", ctypes.c_uint64),
281
+ ("ReadTransferCount", ctypes.c_uint64),
282
+ ("WriteTransferCount", ctypes.c_uint64),
283
+ ("OtherTransferCount", ctypes.c_uint64),
284
+ ]
285
+
286
+
287
+ class _WindowsBasicLimitInformation(ctypes.Structure):
288
+ _fields_ = [
289
+ ("PerProcessUserTimeLimit", ctypes.c_int64),
290
+ ("PerJobUserTimeLimit", ctypes.c_int64),
291
+ ("LimitFlags", ctypes.c_uint32),
292
+ ("MinimumWorkingSetSize", ctypes.c_size_t),
293
+ ("MaximumWorkingSetSize", ctypes.c_size_t),
294
+ ("ActiveProcessLimit", ctypes.c_uint32),
295
+ ("Affinity", ctypes.c_size_t),
296
+ ("PriorityClass", ctypes.c_uint32),
297
+ ("SchedulingClass", ctypes.c_uint32),
298
+ ]
299
+
300
+
301
+ class _WindowsExtendedLimitInformation(ctypes.Structure):
302
+ _fields_ = [
303
+ ("BasicLimitInformation", _WindowsBasicLimitInformation),
304
+ ("IoInfo", _WindowsIoCounters),
305
+ ("ProcessMemoryLimit", ctypes.c_size_t),
306
+ ("JobMemoryLimit", ctypes.c_size_t),
307
+ ("PeakProcessMemoryUsed", ctypes.c_size_t),
308
+ ("PeakJobMemoryUsed", ctypes.c_size_t),
309
+ ]
310
+
311
+
312
+ class _WindowsBasicAccountingInformation(ctypes.Structure):
313
+ _fields_ = [
314
+ ("TotalUserTime", ctypes.c_int64),
315
+ ("TotalKernelTime", ctypes.c_int64),
316
+ ("ThisPeriodTotalUserTime", ctypes.c_int64),
317
+ ("ThisPeriodTotalKernelTime", ctypes.c_int64),
318
+ ("TotalPageFaultCount", ctypes.c_uint32),
319
+ ("TotalProcesses", ctypes.c_uint32),
320
+ ("ActiveProcesses", ctypes.c_uint32),
321
+ ("TotalTerminatedProcesses", ctypes.c_uint32),
322
+ ]
323
+
324
+
325
+ class _WindowsKillOnCloseJob:
326
+ """Best-effort Windows Job Object that kills descendants when closed."""
327
+
328
+ _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
329
+ _JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x00000800
330
+ _JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS = 9
331
+ _JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION_CLASS = 1
332
+
333
+ def __init__(self) -> None:
334
+ self.handle: int | None = None
335
+
336
+ def assign(self, process: subprocess.Popen[object]) -> bool:
337
+ if os.name != "nt":
338
+ return False
339
+ kernel32 = ctypes.windll.kernel32
340
+ kernel32.CreateJobObjectW.restype = ctypes.c_void_p
341
+ handle = kernel32.CreateJobObjectW(None, None)
342
+ if not handle:
343
+ return False
344
+ info = _WindowsExtendedLimitInformation()
345
+ info.BasicLimitInformation.LimitFlags = (
346
+ self._JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
347
+ | self._JOB_OBJECT_LIMIT_BREAKAWAY_OK
348
+ )
349
+ configured = kernel32.SetInformationJobObject(
350
+ ctypes.c_void_p(handle),
351
+ self._JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS,
352
+ ctypes.byref(info),
353
+ ctypes.sizeof(info),
354
+ )
355
+ process_handle = getattr(process, "_handle", None)
356
+ assigned = bool(
357
+ configured
358
+ and process_handle
359
+ and kernel32.AssignProcessToJobObject(
360
+ ctypes.c_void_p(handle),
361
+ ctypes.c_void_p(int(process_handle)),
362
+ )
363
+ )
364
+ if not assigned:
365
+ kernel32.CloseHandle(ctypes.c_void_p(handle))
366
+ return False
367
+ self.handle = int(handle)
368
+ return True
369
+
370
+ def close(self) -> None:
371
+ if self.handle is None:
372
+ return
373
+ ctypes.windll.kernel32.CloseHandle(ctypes.c_void_p(self.handle))
374
+ self.handle = None
375
+
376
+ def terminate_and_wait(self, timeout_seconds: float = 5.0) -> None:
377
+ if self.handle is None:
378
+ return
379
+ kernel32 = ctypes.windll.kernel32
380
+ handle = ctypes.c_void_p(self.handle)
381
+ kernel32.TerminateJobObject(handle, 1)
382
+ deadline = time.monotonic() + max(0.0, timeout_seconds)
383
+ while time.monotonic() < deadline:
384
+ accounting = _WindowsBasicAccountingInformation()
385
+ queried = kernel32.QueryInformationJobObject(
386
+ handle,
387
+ self._JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION_CLASS,
388
+ ctypes.byref(accounting),
389
+ ctypes.sizeof(accounting),
390
+ None,
391
+ )
392
+ if not queried or accounting.ActiveProcesses == 0:
393
+ break
394
+ time.sleep(0.02)
395
+ self.close()
396
+
397
+
398
+ class _WindowsProcessEntry(ctypes.Structure):
399
+ _fields_ = [
400
+ ("dwSize", ctypes.c_uint32),
401
+ ("cntUsage", ctypes.c_uint32),
402
+ ("th32ProcessID", ctypes.c_uint32),
403
+ ("th32DefaultHeapID", ctypes.c_size_t),
404
+ ("th32ModuleID", ctypes.c_uint32),
405
+ ("cntThreads", ctypes.c_uint32),
406
+ ("th32ParentProcessID", ctypes.c_uint32),
407
+ ("pcPriClassBase", ctypes.c_long),
408
+ ("dwFlags", ctypes.c_uint32),
409
+ ("szExeFile", ctypes.c_wchar * 260),
410
+ ]
411
+
412
+
413
+ def _windows_process_parents() -> dict[int, int] | None:
414
+ if os.name != "nt":
415
+ return {}
416
+ kernel32 = ctypes.windll.kernel32
417
+ create_snapshot = kernel32.CreateToolhelp32Snapshot
418
+ create_snapshot.restype = ctypes.c_void_p
419
+ handle = create_snapshot(0x00000002, 0)
420
+ invalid_handle = ctypes.c_void_p(-1).value
421
+ if not handle or handle == invalid_handle:
422
+ return None
423
+ entry = _WindowsProcessEntry()
424
+ entry.dwSize = ctypes.sizeof(entry)
425
+ parents: dict[int, int] = {}
426
+ try:
427
+ if not kernel32.Process32FirstW(ctypes.c_void_p(handle), ctypes.byref(entry)):
428
+ return None
429
+ while True:
430
+ parents[int(entry.th32ProcessID)] = int(entry.th32ParentProcessID)
431
+ if not kernel32.Process32NextW(
432
+ ctypes.c_void_p(handle),
433
+ ctypes.byref(entry),
434
+ ):
435
+ break
436
+ return parents
437
+ finally:
438
+ kernel32.CloseHandle(ctypes.c_void_p(handle))
439
+
440
+
441
+ def _update_windows_descendants(
442
+ root_pid: int,
443
+ known_descendants: set[int],
444
+ ) -> bool:
445
+ parents = _windows_process_parents()
446
+ if parents is None:
447
+ return False
448
+ changed = True
449
+ while changed:
450
+ changed = False
451
+ for pid, parent_pid in parents.items():
452
+ if pid == root_pid or pid in known_descendants:
453
+ continue
454
+ if parent_pid == root_pid or parent_pid in known_descendants:
455
+ known_descendants.add(pid)
456
+ changed = True
457
+ return True
458
+
459
+
460
+ def _terminate_tracked_windows_descendants(pids: set[int]) -> None:
461
+ targeted: list[int] = []
462
+ kernel32 = ctypes.windll.kernel32
463
+ kernel32.OpenProcess.restype = ctypes.c_void_p
464
+ for pid in sorted(pids, reverse=True):
465
+ if not _pid_is_running(pid):
466
+ continue
467
+ targeted.append(pid)
468
+ handle = kernel32.OpenProcess(0x00100001, False, pid)
469
+ terminated = False
470
+ if handle:
471
+ try:
472
+ terminated = bool(
473
+ kernel32.TerminateProcess(ctypes.c_void_p(handle), 1)
474
+ )
475
+ if terminated:
476
+ kernel32.WaitForSingleObject(ctypes.c_void_p(handle), 5000)
477
+ finally:
478
+ kernel32.CloseHandle(ctypes.c_void_p(handle))
479
+ if not terminated:
480
+ subprocess.run(
481
+ ["taskkill", "/PID", str(pid), "/T", "/F"],
482
+ stdout=subprocess.DEVNULL,
483
+ stderr=subprocess.DEVNULL,
484
+ check=False,
485
+ timeout=10,
486
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
487
+ )
488
+ deadline = time.monotonic() + 5.0
489
+ while targeted and time.monotonic() < deadline:
490
+ targeted = [pid for pid in targeted if _pid_is_running(pid)]
491
+ if targeted:
492
+ time.sleep(0.05)
493
+
494
+
495
+ def detached_process_capability(cwd: Path) -> tuple[bool, str]:
496
+ """Probe the nested Windows breakaway pattern used by service tests."""
497
+
498
+ if os.name != "nt":
499
+ return True, "not-windows"
500
+ flags = 0x00000008 | 0x00000200 | 0x08000000 | 0x01000000
501
+ with tempfile.TemporaryDirectory(prefix="harness-detached-probe-") as raw_tmp:
502
+ marker = Path(raw_tmp) / "nested-breakaway.ok"
503
+ probe_source = (
504
+ "import pathlib,subprocess,sys;"
505
+ "flags=0x00000008|0x00000200|0x08000000|0x01000000;"
506
+ "child=subprocess.Popen([sys.executable,'-c','pass'],"
507
+ "creationflags=flags,stdin=subprocess.DEVNULL,"
508
+ "stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL);"
509
+ "code=child.wait(timeout=5);"
510
+ "pathlib.Path(sys.argv[1]).write_text(str(code),encoding='utf-8')"
511
+ )
512
+ try:
513
+ process = subprocess.Popen(
514
+ [sys.executable, "-c", probe_source, str(marker)],
515
+ cwd=str(Path(cwd).resolve()),
516
+ stdin=subprocess.DEVNULL,
517
+ stdout=subprocess.PIPE,
518
+ stderr=subprocess.STDOUT,
519
+ creationflags=flags,
520
+ text=True,
521
+ encoding="utf-8",
522
+ errors="replace",
523
+ )
524
+ except OSError as exc:
525
+ return False, str(exc)
526
+ try:
527
+ output, _ = process.communicate(timeout=8)
528
+ except subprocess.TimeoutExpired:
529
+ subprocess.run(
530
+ ["taskkill", "/PID", str(process.pid), "/T", "/F"],
531
+ stdout=subprocess.DEVNULL,
532
+ stderr=subprocess.DEVNULL,
533
+ check=False,
534
+ timeout=10,
535
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
536
+ )
537
+ return False, "nested breakaway probe timed out"
538
+ if process.returncode == 0 and marker.is_file():
539
+ return True, "nested breakaway available"
540
+ detail_lines = [line.strip() for line in (output or "").splitlines() if line.strip()]
541
+ detail = detail_lines[-1] if detail_lines else ""
542
+ return False, detail[-500:] or f"probe exit={process.returncode}"
543
+
544
+
545
+ def _terminate_process_tree(
546
+ process: subprocess.Popen[object],
547
+ *,
548
+ windows_job: _WindowsKillOnCloseJob | None,
549
+ ) -> None:
550
+ if windows_job is not None and windows_job.handle is not None:
551
+ windows_job.terminate_and_wait()
552
+ elif os.name == "nt":
553
+ subprocess.run(
554
+ ["taskkill", "/PID", str(process.pid), "/T", "/F"],
555
+ stdout=subprocess.DEVNULL,
556
+ stderr=subprocess.DEVNULL,
557
+ check=False,
558
+ timeout=10,
559
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
560
+ )
561
+ else:
562
+ try:
563
+ os.killpg(process.pid, signal.SIGTERM)
564
+ except ProcessLookupError:
565
+ pass
566
+ try:
567
+ process.wait(timeout=2)
568
+ except subprocess.TimeoutExpired:
569
+ try:
570
+ os.killpg(process.pid, signal.SIGKILL)
571
+ except ProcessLookupError:
572
+ pass
573
+ if process.poll() is None:
574
+ process.kill()
575
+ try:
576
+ process.wait(timeout=5)
577
+ except subprocess.TimeoutExpired:
578
+ pass
579
+
580
+
581
+ def _cleanup_descendants_after_success(
582
+ process: subprocess.Popen[object],
583
+ *,
584
+ windows_job: _WindowsKillOnCloseJob | None,
585
+ ) -> None:
586
+ if windows_job is not None and windows_job.handle is not None:
587
+ windows_job.terminate_and_wait()
588
+ return
589
+ if os.name != "nt":
590
+ try:
591
+ os.killpg(process.pid, signal.SIGTERM)
592
+ except ProcessLookupError:
593
+ pass
594
+
595
+
596
+ def run_managed_command(
597
+ argv: Sequence[str],
598
+ *,
599
+ cwd: Path,
600
+ timeout_seconds: float,
601
+ environ: Mapping[str, str] | None = None,
602
+ capture_output: bool = False,
603
+ allow_detached_processes: bool = False,
604
+ ) -> CommandResult:
605
+ """Run one command with a timeout and guaranteed process-tree cleanup."""
606
+
607
+ if not argv:
608
+ raise ValueError("managed command must not be empty")
609
+ child_env = os.environ.copy()
610
+ if environ:
611
+ child_env.update({str(key): str(value) for key, value in environ.items()})
612
+ child_env.setdefault("PYTHONUNBUFFERED", "1")
613
+ lower_process_priority()
614
+
615
+ popen_kwargs: dict[str, object] = {
616
+ "cwd": str(Path(cwd).resolve()),
617
+ "env": child_env,
618
+ "shell": False,
619
+ }
620
+ output_stream = tempfile.TemporaryFile(mode="w+b") if capture_output else None
621
+ if output_stream is not None:
622
+ popen_kwargs["stdout"] = output_stream
623
+ popen_kwargs["stderr"] = subprocess.STDOUT
624
+ if os.name == "nt":
625
+ popen_kwargs["creationflags"] = getattr(
626
+ subprocess,
627
+ "CREATE_NEW_PROCESS_GROUP",
628
+ 0,
629
+ )
630
+ else:
631
+ popen_kwargs["start_new_session"] = True
632
+
633
+ started = time.monotonic()
634
+ try:
635
+ process = subprocess.Popen(list(argv), **popen_kwargs)
636
+ windows_job = (
637
+ _WindowsKillOnCloseJob()
638
+ if os.name == "nt" and not allow_detached_processes
639
+ else None
640
+ )
641
+ isolated = bool(windows_job and windows_job.assign(process))
642
+ known_windows_descendants: set[int] = set()
643
+ windows_snapshot_ok = True
644
+ if os.name != "nt":
645
+ isolated = True
646
+ timed_out = False
647
+ try:
648
+ deadline = time.monotonic() + max(0.01, float(timeout_seconds))
649
+ while True:
650
+ if os.name == "nt":
651
+ windows_snapshot_ok = (
652
+ _update_windows_descendants(
653
+ process.pid,
654
+ known_windows_descendants,
655
+ )
656
+ and windows_snapshot_ok
657
+ )
658
+ polled = process.poll()
659
+ if polled is not None:
660
+ returncode = polled
661
+ break
662
+ if time.monotonic() >= deadline:
663
+ timed_out = True
664
+ _terminate_process_tree(process, windows_job=windows_job)
665
+ returncode = 124
666
+ break
667
+ time.sleep(0.05)
668
+ except KeyboardInterrupt:
669
+ _terminate_process_tree(process, windows_job=windows_job)
670
+ if os.name == "nt":
671
+ _terminate_tracked_windows_descendants(known_windows_descendants)
672
+ raise
673
+ if not timed_out:
674
+ _cleanup_descendants_after_success(process, windows_job=windows_job)
675
+ if os.name == "nt":
676
+ _update_windows_descendants(process.pid, known_windows_descendants)
677
+ _terminate_tracked_windows_descendants(known_windows_descendants)
678
+ isolated = (
679
+ windows_snapshot_ok
680
+ if allow_detached_processes
681
+ else isolated and windows_snapshot_ok
682
+ )
683
+ duration = time.monotonic() - started
684
+ output_tail = ""
685
+ if output_stream is not None:
686
+ output_stream.flush()
687
+ output_stream.seek(0, os.SEEK_END)
688
+ size = output_stream.tell()
689
+ output_stream.seek(max(0, size - 65536))
690
+ output_tail = output_stream.read().decode("utf-8", errors="replace")
691
+ return CommandResult(
692
+ returncode=returncode,
693
+ timed_out=timed_out,
694
+ duration_seconds=duration,
695
+ process_tree_isolated=isolated,
696
+ output_tail=output_tail,
697
+ )
698
+ finally:
699
+ if output_stream is not None:
700
+ output_stream.close()
701
+
702
+
703
+ def _execution_environment(profile: str, max_workers: int) -> dict[str, str]:
704
+ return {
705
+ "HARNESS_TEST_PROFILE": profile,
706
+ "HARNESS_TEST_MAX_WORKERS": str(
707
+ bounded_worker_count(max_workers, os.environ)
708
+ ),
709
+ }
710
+
711
+
712
+ def _print_plan(profile: str, plan: Sequence[TestModule]) -> None:
713
+ payload = {
714
+ "profile": profile,
715
+ "moduleCount": len(plan),
716
+ "modules": [item.name for item in plan],
717
+ "maxWorkers": bounded_worker_count(DEFAULT_MAX_WORKERS, os.environ),
718
+ "processTreeCleanup": True,
719
+ "singleInstance": True,
720
+ }
721
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
722
+
723
+
724
+ def _run_unittest(args: argparse.Namespace) -> int:
725
+ project = Path(args.project).resolve()
726
+ tests_dir = Path(args.tests_dir)
727
+ if not tests_dir.is_absolute():
728
+ tests_dir = project / tests_dir
729
+ plan = build_unittest_plan(
730
+ tests_dir,
731
+ args.profile,
732
+ resource_modules=args.resource_module,
733
+ )
734
+ if args.dry_run:
735
+ _print_plan(args.profile, plan)
736
+ return 0
737
+ if not resource_profile_allowed(
738
+ args.profile,
739
+ args.confirm_resource_intensive,
740
+ os.environ,
741
+ ):
742
+ print(
743
+ "RESOURCE_CONFIRMATION_REQUIRED: "
744
+ f"profile={args.profile}; rerun with --confirm-resource-intensive",
745
+ file=sys.stderr,
746
+ )
747
+ return 2
748
+ if not plan:
749
+ print(f"NO_TEST_MODULES: profile={args.profile} testsDir={tests_dir}", file=sys.stderr)
750
+ return 2
751
+
752
+ environment = _execution_environment(args.profile, args.max_workers)
753
+ failures: list[str] = []
754
+ failed_modules: set[str] = set()
755
+ executed_count = 0
756
+ started = time.monotonic()
757
+ detached_service_modules = set(args.detached_service_module)
758
+ detached_capability_checked = False
759
+ try:
760
+ with TestRunLock(project):
761
+ for index, module in enumerate(plan, start=1):
762
+ if (
763
+ module.name in detached_service_modules
764
+ and not detached_capability_checked
765
+ ):
766
+ available, detail = detached_process_capability(project)
767
+ detached_capability_checked = True
768
+ if not available:
769
+ print(
770
+ "DETACHED_PROCESS_CAPABILITY_UNAVAILABLE: "
771
+ f"{detail}; rerun in an environment that permits "
772
+ "nested Windows breakaway",
773
+ file=sys.stderr,
774
+ )
775
+ return 5
776
+ print(
777
+ f"[harness-test] {index}/{len(plan)} {module.name} "
778
+ f"profile={args.profile} maxWorkers={environment['HARNESS_TEST_MAX_WORKERS']}",
779
+ flush=True,
780
+ )
781
+ command = [
782
+ sys.executable,
783
+ "-m",
784
+ "unittest",
785
+ "discover",
786
+ "-s",
787
+ str(tests_dir),
788
+ "-p",
789
+ module.name,
790
+ ]
791
+ if args.verbosity == 0:
792
+ command.append("-q")
793
+ elif args.verbosity == 2:
794
+ command.append("-v")
795
+ result = run_managed_command(
796
+ command,
797
+ cwd=project,
798
+ timeout_seconds=args.timeout_seconds,
799
+ environ=environment,
800
+ capture_output=args.verbosity == 0,
801
+ allow_detached_processes=module.name in detached_service_modules,
802
+ )
803
+ executed_count += 1
804
+ module_failure_count = len(failures)
805
+ if result.timed_out:
806
+ failures.append(f"{module.name}:TIMEOUT")
807
+ failed_modules.add(module.name)
808
+ elif result.returncode != 0:
809
+ failures.append(f"{module.name}:EXIT_{result.returncode}")
810
+ failed_modules.add(module.name)
811
+ if not result.process_tree_isolated:
812
+ failures.append(f"{module.name}:PROCESS_TREE_ISOLATION_UNAVAILABLE")
813
+ failed_modules.add(module.name)
814
+ module_failed = len(failures) > module_failure_count
815
+ if module_failed and result.output_tail:
816
+ print(result.output_tail, file=sys.stderr, end="")
817
+ if module_failed and not args.keep_going:
818
+ break
819
+ except TestRunAlreadyActive as exc:
820
+ print(str(exc), file=sys.stderr)
821
+ return 3
822
+
823
+ duration = time.monotonic() - started
824
+ status = "PASS" if not failures else "FAIL"
825
+ print(
826
+ f"[harness-test] {status} profile={args.profile} "
827
+ f"modulesPassed={executed_count - len(failed_modules)} "
828
+ f"modulesExecuted={executed_count} modulesPlanned={len(plan)} "
829
+ f"durationSeconds={duration:.2f}",
830
+ flush=True,
831
+ )
832
+ if failures:
833
+ print("[harness-test] failures=" + ",".join(failures), file=sys.stderr)
834
+ return 1
835
+ return 0
836
+
837
+
838
+ def _run_exec(args: argparse.Namespace) -> int:
839
+ command = list(args.command)
840
+ if command and command[0] == "--":
841
+ command = command[1:]
842
+ if not command:
843
+ print("managed exec requires a command after --", file=sys.stderr)
844
+ return 2
845
+ if not resource_profile_allowed(
846
+ args.profile,
847
+ args.confirm_resource_intensive,
848
+ os.environ,
849
+ ):
850
+ print(
851
+ "RESOURCE_CONFIRMATION_REQUIRED: "
852
+ f"profile={args.profile}; rerun with --confirm-resource-intensive",
853
+ file=sys.stderr,
854
+ )
855
+ return 2
856
+ project = Path(args.project).resolve()
857
+ environment = _execution_environment(args.profile, args.max_workers)
858
+ try:
859
+ with TestRunLock(project):
860
+ if args.allow_detached_processes:
861
+ available, detail = detached_process_capability(project)
862
+ if not available:
863
+ print(
864
+ "DETACHED_PROCESS_CAPABILITY_UNAVAILABLE: "
865
+ f"{detail}; rerun in an environment that permits "
866
+ "nested Windows breakaway",
867
+ file=sys.stderr,
868
+ )
869
+ return 5
870
+ result = run_managed_command(
871
+ command,
872
+ cwd=project,
873
+ timeout_seconds=args.timeout_seconds,
874
+ environ=environment,
875
+ allow_detached_processes=args.allow_detached_processes,
876
+ )
877
+ except TestRunAlreadyActive as exc:
878
+ print(str(exc), file=sys.stderr)
879
+ return 3
880
+ if not result.process_tree_isolated:
881
+ print("PROCESS_TREE_ISOLATION_UNAVAILABLE", file=sys.stderr)
882
+ return 4
883
+ if result.timed_out:
884
+ print(
885
+ f"TEST_COMMAND_TIMEOUT: timeoutSeconds={args.timeout_seconds}",
886
+ file=sys.stderr,
887
+ )
888
+ return 124
889
+ return result.returncode
890
+
891
+
892
+ def build_parser() -> argparse.ArgumentParser:
893
+ parser = argparse.ArgumentParser(
894
+ description="Run tests with bounded workers, locking, timeout, and cleanup."
895
+ )
896
+ subparsers = parser.add_subparsers(dest="command_name", required=True)
897
+
898
+ common = argparse.ArgumentParser(add_help=False)
899
+ common.add_argument("--project", default=".")
900
+ common.add_argument(
901
+ "--profile",
902
+ choices=("safe", "system", "full"),
903
+ default="safe",
904
+ )
905
+ common.add_argument(
906
+ "--timeout-seconds",
907
+ type=float,
908
+ default=DEFAULT_TIMEOUT_SECONDS,
909
+ )
910
+ common.add_argument(
911
+ "--max-workers",
912
+ type=int,
913
+ default=DEFAULT_MAX_WORKERS,
914
+ )
915
+ common.add_argument("--confirm-resource-intensive", action="store_true")
916
+
917
+ unittest_parser = subparsers.add_parser(
918
+ "unittest",
919
+ parents=[common],
920
+ help="Run discovered Python unittest modules in isolated processes.",
921
+ )
922
+ unittest_parser.add_argument(
923
+ "--tests-dir",
924
+ default="harness/scripts/tests",
925
+ )
926
+ unittest_parser.add_argument(
927
+ "--resource-module",
928
+ action="append",
929
+ default=list(DEFAULT_RESOURCE_MODULES),
930
+ help="Module reserved for system/full profiles; may be repeated.",
931
+ )
932
+ unittest_parser.add_argument(
933
+ "--detached-service-module",
934
+ action="append",
935
+ default=list(DEFAULT_DETACHED_SERVICE_MODULES),
936
+ help="Module that intentionally tests detached services; may be repeated.",
937
+ )
938
+ unittest_parser.add_argument("--keep-going", action="store_true")
939
+ unittest_parser.add_argument("--dry-run", action="store_true")
940
+ unittest_parser.add_argument(
941
+ "--verbosity",
942
+ type=int,
943
+ choices=(0, 1, 2),
944
+ default=1,
945
+ )
946
+ unittest_parser.set_defaults(handler=_run_unittest)
947
+
948
+ exec_parser = subparsers.add_parser(
949
+ "exec",
950
+ parents=[common],
951
+ help="Run one arbitrary test command under the same safety controls.",
952
+ )
953
+ exec_parser.add_argument("command", nargs=argparse.REMAINDER)
954
+ exec_parser.add_argument(
955
+ "--allow-detached-processes",
956
+ action="store_true",
957
+ help="Use exact Windows PID-lineage tracking instead of a Job Object.",
958
+ )
959
+ exec_parser.set_defaults(handler=_run_exec)
960
+ return parser
961
+
962
+
963
+ def main(argv: Sequence[str] | None = None) -> int:
964
+ parser = build_parser()
965
+ args = parser.parse_args(argv)
966
+ if args.timeout_seconds <= 0:
967
+ parser.error("--timeout-seconds must be positive")
968
+ if args.max_workers <= 0:
969
+ parser.error("--max-workers must be positive")
970
+ return int(args.handler(args))
971
+
972
+
973
+ if __name__ == "__main__":
974
+ raise SystemExit(main())