devlyn-cli 3.0.1 → 3.1.1
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.
- package/AGENTS.md +1 -1
- package/CLAUDE.md +3 -3
- package/README.md +20 -3
- package/bin/devlyn.js +69 -23
- package/config/skills/_shared/adapters/claude.md +3 -2
- package/config/skills/_shared/adapters/codex.md +1 -1
- package/config/skills/_shared/archive_run.py +5 -3
- package/config/skills/_shared/codex-config.md +13 -8
- package/config/skills/_shared/codex-monitored.sh +12 -19
- package/config/skills/_shared/collect-codex-findings.py +1 -0
- package/config/skills/_shared/engine-preflight.md +2 -0
- package/config/skills/_shared/finish-gate.py +4 -1
- package/config/skills/_shared/invocation-receipt.py +194 -5
- package/config/skills/_shared/judge-role-evidence.py +33 -6
- package/config/skills/_shared/phase-prompt-render.py +2 -0
- package/config/skills/_shared/platform-support.py +370 -0
- package/config/skills/_shared/process-evidence.py +4 -2
- package/config/skills/_shared/resolve-bootstrap.py +102 -59
- package/config/skills/_shared/resolve-stop-hook.py +3 -1
- package/config/skills/_shared/role-config.py +7 -5
- package/config/skills/_shared/run-bounded.py +35 -21
- package/config/skills/_shared/spec-verify-check.py +274 -157
- package/config/skills/_shared/state-phase-write.py +26 -9
- package/config/skills/_shared/task-complete.py +1173 -0
- package/config/skills/_shared/verify-merge-findings.py +9 -8
- package/config/skills/devlyn:queue/SKILL.md +3 -3
- package/config/skills/devlyn:resolve/SKILL.md +14 -7
- package/config/skills/devlyn:resolve/references/outer-loop.md +10 -2
- package/config/skills/devlyn:resolve/references/phases/verify.md +5 -3
- package/config/skills/devlyn:resolve/references/task-completion.md +117 -0
- package/package.json +1 -1
- package/scripts/lint-skills.sh +10 -6
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Native lock, argv, process and UTF-8 boundaries shared by harness callers."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import errno
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import signal
|
|
13
|
+
import stat
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def configure_utf8():
|
|
20
|
+
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
21
|
+
if hasattr(stream, "reconfigure"):
|
|
22
|
+
stream.reconfigure(encoding="utf-8", errors="strict")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def open_stdin(path):
|
|
26
|
+
fd = os.open(path, os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NONBLOCK", 0))
|
|
27
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
28
|
+
os.close(fd)
|
|
29
|
+
raise ValueError(f"stdin file is not a regular file: {path}")
|
|
30
|
+
return os.fdopen(fd, "rb")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@contextlib.contextmanager
|
|
34
|
+
def file_lock(path, *, blocking=False):
|
|
35
|
+
with Path(path).open("a+b") as handle:
|
|
36
|
+
if os.name == "nt":
|
|
37
|
+
import msvcrt
|
|
38
|
+
while True:
|
|
39
|
+
handle.seek(0)
|
|
40
|
+
try:
|
|
41
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
42
|
+
break
|
|
43
|
+
except OSError as exc:
|
|
44
|
+
# CRT maps ERROR_LOCK_VIOLATION to EACCES (winerror may be absent).
|
|
45
|
+
contended = exc.errno == errno.EACCES and getattr(exc, "winerror", None) in (None, 33)
|
|
46
|
+
if not contended:
|
|
47
|
+
raise
|
|
48
|
+
if not blocking:
|
|
49
|
+
raise BlockingIOError(errno.EAGAIN, "lock occupied", str(path)) from exc
|
|
50
|
+
time.sleep(0.05)
|
|
51
|
+
try:
|
|
52
|
+
yield
|
|
53
|
+
finally:
|
|
54
|
+
handle.seek(0)
|
|
55
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
56
|
+
else:
|
|
57
|
+
import fcntl
|
|
58
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB))
|
|
59
|
+
try:
|
|
60
|
+
yield
|
|
61
|
+
finally:
|
|
62
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def native_argv(argv):
|
|
66
|
+
"""Resolve supported npm engine shims to their package's Node bin, never cmd /c."""
|
|
67
|
+
if os.name != "nt":
|
|
68
|
+
return list(argv)
|
|
69
|
+
binary = shutil.which(argv[0])
|
|
70
|
+
if binary is None:
|
|
71
|
+
raise FileNotFoundError(f"executable unavailable: {argv[0]}")
|
|
72
|
+
shim = Path(binary)
|
|
73
|
+
if shim.suffix.lower() not in {".cmd", ".bat"}:
|
|
74
|
+
return [binary, *argv[1:]]
|
|
75
|
+
packages = {"codex": "@openai/codex", "claude": "@anthropic-ai/claude-code"}
|
|
76
|
+
package = packages.get(shim.stem.lower())
|
|
77
|
+
if shim.suffix.lower() != ".cmd" or package is None:
|
|
78
|
+
raise OSError(f"unsupported native command shim: {shim}")
|
|
79
|
+
root = (shim.parent.parent if shim.parent.name == ".bin" else shim.parent / "node_modules") / package
|
|
80
|
+
try:
|
|
81
|
+
manifest = json.loads((root / "package.json").read_text(encoding="utf-8"))
|
|
82
|
+
except (ValueError, UnicodeError) as exc:
|
|
83
|
+
raise OSError(f"invalid npm engine manifest: {shim}") from exc
|
|
84
|
+
if not isinstance(manifest, dict):
|
|
85
|
+
raise OSError(f"invalid npm engine manifest: {shim}")
|
|
86
|
+
entry = manifest.get("bin")
|
|
87
|
+
entry = entry.get(shim.stem.lower()) if isinstance(entry, dict) else entry
|
|
88
|
+
if manifest.get("name") != package or not isinstance(entry, str):
|
|
89
|
+
raise OSError(f"invalid npm engine bin: {shim}")
|
|
90
|
+
target = (root / entry).resolve(strict=True)
|
|
91
|
+
if not target.is_relative_to(root.resolve()) or target.suffix not in {".js", ".cjs", ".mjs"}:
|
|
92
|
+
raise OSError(f"unsupported npm engine bin: {target}")
|
|
93
|
+
raw = shim.read_text(encoding="utf-8")
|
|
94
|
+
matches = re.findall(r'"%_prog%"\s+"%dp0%\\([^"\r\n]+)"\s+%\*', raw)
|
|
95
|
+
if len(matches) != 1 or (shim.parent / matches[0]).resolve() != target:
|
|
96
|
+
raise OSError(f"unrecognized npm engine shim: {shim}")
|
|
97
|
+
node = shim.parent / "node.exe"
|
|
98
|
+
binary = str(node) if node.is_file() else shutil.which("node.exe")
|
|
99
|
+
if binary is None:
|
|
100
|
+
raise FileNotFoundError(f"node.exe unavailable for {shim}")
|
|
101
|
+
return [binary, str(target), *argv[1:]]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def system_exit_code(code):
|
|
105
|
+
# CPython SystemExit uses signed C long (32 bits on Windows), unlike DWORD.
|
|
106
|
+
return code - 0x100000000 if os.name == "nt" and code >= 0x80000000 else code
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if os.name == "nt":
|
|
110
|
+
import ctypes
|
|
111
|
+
from ctypes import wintypes
|
|
112
|
+
import msvcrt
|
|
113
|
+
|
|
114
|
+
class _BasicLimits(ctypes.Structure):
|
|
115
|
+
_fields_ = [("PerProcessUserTimeLimit", ctypes.c_longlong),
|
|
116
|
+
("PerJobUserTimeLimit", ctypes.c_longlong),
|
|
117
|
+
("LimitFlags", wintypes.DWORD),
|
|
118
|
+
("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
119
|
+
("MaximumWorkingSetSize", ctypes.c_size_t),
|
|
120
|
+
("ActiveProcessLimit", wintypes.DWORD),
|
|
121
|
+
("Affinity", ctypes.c_size_t),
|
|
122
|
+
("PriorityClass", wintypes.DWORD),
|
|
123
|
+
("SchedulingClass", wintypes.DWORD)]
|
|
124
|
+
|
|
125
|
+
class _ExtendedLimits(ctypes.Structure):
|
|
126
|
+
_fields_ = [("BasicLimitInformation", _BasicLimits),
|
|
127
|
+
("IoInfo", ctypes.c_ulonglong * 6),
|
|
128
|
+
("ProcessMemoryLimit", ctypes.c_size_t),
|
|
129
|
+
("JobMemoryLimit", ctypes.c_size_t),
|
|
130
|
+
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
131
|
+
("PeakJobMemoryUsed", ctypes.c_size_t)]
|
|
132
|
+
|
|
133
|
+
class _JobProcessIds(ctypes.Structure):
|
|
134
|
+
_fields_ = [("NumberOfAssignedProcesses", wintypes.DWORD),
|
|
135
|
+
("NumberOfProcessIdsInList", wintypes.DWORD),
|
|
136
|
+
("ProcessIdList", ctypes.c_size_t * 1)]
|
|
137
|
+
|
|
138
|
+
def _checked(result, function, _args):
|
|
139
|
+
if not result:
|
|
140
|
+
error = ctypes.get_last_error()
|
|
141
|
+
raise ctypes.WinError(error, f"{function.__name__}: {ctypes.FormatError(error)}")
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
_kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
145
|
+
for _name, _args, _result in (
|
|
146
|
+
("CreateJobObjectW", [ctypes.c_void_p, wintypes.LPCWSTR], wintypes.HANDLE),
|
|
147
|
+
("SetInformationJobObject", [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD], wintypes.BOOL),
|
|
148
|
+
("QueryInformationJobObject", [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, ctypes.c_void_p], wintypes.BOOL),
|
|
149
|
+
("AssignProcessToJobObject", [wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL),
|
|
150
|
+
("TerminateJobObject", [wintypes.HANDLE, wintypes.UINT], wintypes.BOOL),
|
|
151
|
+
("OpenProcess", [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE),
|
|
152
|
+
("IsProcessInJob", [wintypes.HANDLE, wintypes.HANDLE, ctypes.POINTER(wintypes.BOOL)], wintypes.BOOL),
|
|
153
|
+
("GetCurrentProcess", [], wintypes.HANDLE),
|
|
154
|
+
("DuplicateHandle", [wintypes.HANDLE, wintypes.HANDLE, wintypes.HANDLE, ctypes.POINTER(wintypes.HANDLE), wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.BOOL),
|
|
155
|
+
("CloseHandle", [wintypes.HANDLE], wintypes.BOOL),
|
|
156
|
+
("PeekNamedPipe", [wintypes.HANDLE, ctypes.c_void_p, wintypes.DWORD, ctypes.c_void_p, ctypes.POINTER(wintypes.DWORD), ctypes.c_void_p], wintypes.BOOL),
|
|
157
|
+
):
|
|
158
|
+
_function = getattr(_kernel, _name)
|
|
159
|
+
_function.argtypes, _function.restype, _function.errcheck = _args, _result, _checked
|
|
160
|
+
|
|
161
|
+
_kernel.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
|
162
|
+
_kernel.WaitForSingleObject.restype = wintypes.DWORD # WAIT_OBJECT_0 is successful zero, not BOOL.
|
|
163
|
+
|
|
164
|
+
def _read_control(fd, count):
|
|
165
|
+
deadline = time.monotonic() + 10
|
|
166
|
+
data = b""
|
|
167
|
+
while len(data) < count:
|
|
168
|
+
available = wintypes.DWORD()
|
|
169
|
+
_kernel.PeekNamedPipe(msvcrt.get_osfhandle(fd), None, 0, None, ctypes.byref(available), None)
|
|
170
|
+
if available.value:
|
|
171
|
+
data += os.read(fd, min(count - len(data), available.value))
|
|
172
|
+
elif time.monotonic() >= deadline:
|
|
173
|
+
raise OSError("Windows job launch handshake timed out")
|
|
174
|
+
else:
|
|
175
|
+
time.sleep(0.01)
|
|
176
|
+
return data
|
|
177
|
+
|
|
178
|
+
class _WindowsJob:
|
|
179
|
+
def __init__(self):
|
|
180
|
+
self.handle = None
|
|
181
|
+
self.child = None
|
|
182
|
+
self.creating = False
|
|
183
|
+
self.interrupted_signal = None
|
|
184
|
+
|
|
185
|
+
def start(self, argv, stdin):
|
|
186
|
+
self.handle = _kernel.CreateJobObjectW(None, None)
|
|
187
|
+
limits = _ExtendedLimits()
|
|
188
|
+
limits.BasicLimitInformation.LimitFlags = 0x2000 # KILL_ON_JOB_CLOSE; no breakaway.
|
|
189
|
+
_kernel.SetInformationJobObject(self.handle, 9, ctypes.byref(limits), ctypes.sizeof(limits))
|
|
190
|
+
with contextlib.ExitStack() as controls, contextlib.ExitStack() as inherited_handles:
|
|
191
|
+
inherited_job = wintypes.HANDLE()
|
|
192
|
+
current = _kernel.GetCurrentProcess()
|
|
193
|
+
_kernel.DuplicateHandle(current, self.handle, current, ctypes.byref(inherited_job), 0, True, 2)
|
|
194
|
+
inherited_handles.callback(_kernel.CloseHandle, inherited_job)
|
|
195
|
+
ready_read, ready_write = os.pipe()
|
|
196
|
+
controls.callback(os.close, ready_read); inherited_handles.callback(os.close, ready_write)
|
|
197
|
+
allow_read, allow_write = os.pipe()
|
|
198
|
+
inherited_handles.callback(os.close, allow_read); controls.callback(os.close, allow_write)
|
|
199
|
+
inherited = [inherited_job.value, msvcrt.get_osfhandle(ready_write), msvcrt.get_osfhandle(allow_read)]
|
|
200
|
+
for handle in inherited[1:]:
|
|
201
|
+
os.set_handle_inheritable(handle, True)
|
|
202
|
+
startup = subprocess.STARTUPINFO()
|
|
203
|
+
startup.lpAttributeList = {"handle_list": inherited}
|
|
204
|
+
command = [sys.executable, "-I", "-S", str(Path(__file__).resolve()), "--job-bootstrap",
|
|
205
|
+
str(inherited[0]), str(inherited[2]), str(inherited[1]), *argv]
|
|
206
|
+
# Retain the Popen object even if __init__ raises after native creation.
|
|
207
|
+
self.child = subprocess.Popen.__new__(subprocess.Popen)
|
|
208
|
+
self.creating = True
|
|
209
|
+
try:
|
|
210
|
+
self.child.__init__(command, stdin=stdin, startupinfo=startup, close_fds=True)
|
|
211
|
+
finally:
|
|
212
|
+
self.creating = False
|
|
213
|
+
if self.interrupted_signal is not None:
|
|
214
|
+
raise SystemExit(128 + self.interrupted_signal)
|
|
215
|
+
inherited_handles.close()
|
|
216
|
+
if _read_control(ready_read, 1) != b"R":
|
|
217
|
+
raise OSError("Windows job enrollment did not report ready")
|
|
218
|
+
os.write(allow_write, b"G")
|
|
219
|
+
self.target_pid = int.from_bytes(_read_control(ready_read, 4), "little")
|
|
220
|
+
return self.child
|
|
221
|
+
|
|
222
|
+
def terminate(self):
|
|
223
|
+
try:
|
|
224
|
+
# Stop startup first so no new enrollment/dispatch can race job quiescence.
|
|
225
|
+
if self.child is not None and getattr(self.child, "_child_created", False):
|
|
226
|
+
try:
|
|
227
|
+
if self.child.poll() is None:
|
|
228
|
+
self.child.kill()
|
|
229
|
+
self.child.wait(timeout=5)
|
|
230
|
+
finally:
|
|
231
|
+
self.child._handle.Close()
|
|
232
|
+
finally:
|
|
233
|
+
if self.handle is not None:
|
|
234
|
+
with contextlib.ExitStack() as handles:
|
|
235
|
+
handles.callback(_kernel.CloseHandle, self.handle)
|
|
236
|
+
limits = _ExtendedLimits() # Zero ActiveProcessLimit: no freed slot admits a new child.
|
|
237
|
+
limits.BasicLimitInformation.LimitFlags = 0x2008 # KILL_ON_JOB_CLOSE | ACTIVE_PROCESS.
|
|
238
|
+
_kernel.SetInformationJobObject(self.handle, 9, ctypes.byref(limits), ctypes.sizeof(limits))
|
|
239
|
+
capacity = 1
|
|
240
|
+
while True:
|
|
241
|
+
buffer = ctypes.create_string_buffer(_JobProcessIds.ProcessIdList.offset + capacity * ctypes.sizeof(ctypes.c_size_t))
|
|
242
|
+
ids = _JobProcessIds.from_buffer(buffer)
|
|
243
|
+
try:
|
|
244
|
+
_kernel.QueryInformationJobObject(self.handle, 3, buffer, len(buffer), None)
|
|
245
|
+
break
|
|
246
|
+
except OSError as exc:
|
|
247
|
+
if exc.winerror != 234: # ERROR_MORE_DATA: never accept a partial list.
|
|
248
|
+
raise
|
|
249
|
+
capacity = max(capacity * 2, ids.NumberOfAssignedProcesses)
|
|
250
|
+
members = []
|
|
251
|
+
pids = (ctypes.c_size_t * ids.NumberOfProcessIdsInList).from_buffer(buffer, _JobProcessIds.ProcessIdList.offset)
|
|
252
|
+
for pid in pids:
|
|
253
|
+
try:
|
|
254
|
+
process = _kernel.OpenProcess(0x101000, False, pid) # SYNCHRONIZE | QUERY_LIMITED_INFORMATION.
|
|
255
|
+
except OSError as exc:
|
|
256
|
+
if exc.winerror == 87: # Already gone; other open failures remain visible.
|
|
257
|
+
continue
|
|
258
|
+
raise
|
|
259
|
+
handles.callback(_kernel.CloseHandle, process)
|
|
260
|
+
member = wintypes.BOOL()
|
|
261
|
+
_kernel.IsProcessInJob(process, self.handle, ctypes.byref(member))
|
|
262
|
+
if member.value: # A reused PID in another job is not our process identity.
|
|
263
|
+
members.append((pid, process))
|
|
264
|
+
# Natural exits before capture do not prove pending I/O or historical object finalization.
|
|
265
|
+
_kernel.TerminateJobObject(self.handle, 1)
|
|
266
|
+
deadline = time.monotonic() + 5
|
|
267
|
+
for pid, process in members:
|
|
268
|
+
remaining = max(0, int((deadline - time.monotonic()) * 1000))
|
|
269
|
+
result = _kernel.WaitForSingleObject(process, remaining)
|
|
270
|
+
if result == 258: # WAIT_TIMEOUT.
|
|
271
|
+
raise OSError(f"Windows job teardown timed out waiting for process {pid}")
|
|
272
|
+
if result == 0xFFFFFFFF: # WAIT_FAILED.
|
|
273
|
+
error = ctypes.get_last_error()
|
|
274
|
+
raise ctypes.WinError(error, f"WaitForSingleObject: {ctypes.FormatError(error)}")
|
|
275
|
+
if result != 0:
|
|
276
|
+
raise OSError(f"WaitForSingleObject: unexpected result {result} for process {pid}")
|
|
277
|
+
|
|
278
|
+
def _job_bootstrap(argv):
|
|
279
|
+
job, allow, ready = map(int, argv[:3])
|
|
280
|
+
with contextlib.ExitStack() as handles:
|
|
281
|
+
allow_fd = msvcrt.open_osfhandle(allow, os.O_RDONLY | os.O_BINARY)
|
|
282
|
+
handles.callback(os.close, allow_fd)
|
|
283
|
+
ready_fd = msvcrt.open_osfhandle(ready, os.O_WRONLY | os.O_BINARY)
|
|
284
|
+
handles.callback(os.close, ready_fd)
|
|
285
|
+
try:
|
|
286
|
+
_kernel.AssignProcessToJobObject(job, _kernel.GetCurrentProcess())
|
|
287
|
+
finally:
|
|
288
|
+
_kernel.CloseHandle(job)
|
|
289
|
+
os.write(ready_fd, b"R")
|
|
290
|
+
if _read_control(allow_fd, 1) != b"G":
|
|
291
|
+
raise OSError("Windows job launch was not authorized")
|
|
292
|
+
with subprocess.Popen(argv[3:], stdin=sys.stdin.buffer, close_fds=True) as target:
|
|
293
|
+
os.write(ready_fd, target.pid.to_bytes(4, "little"))
|
|
294
|
+
return target.wait()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def terminate_tree(child, job=None):
|
|
298
|
+
if job is not None:
|
|
299
|
+
job.terminate()
|
|
300
|
+
return
|
|
301
|
+
try:
|
|
302
|
+
os.killpg(child.pid, signal.SIGTERM)
|
|
303
|
+
except ProcessLookupError:
|
|
304
|
+
child.wait()
|
|
305
|
+
return
|
|
306
|
+
# Reap the leader and retain the grace period for its surviving descendants.
|
|
307
|
+
deadline = time.monotonic() + 5
|
|
308
|
+
try:
|
|
309
|
+
child.wait(timeout=5)
|
|
310
|
+
except subprocess.TimeoutExpired:
|
|
311
|
+
pass # The leader also needs the final group KILL below.
|
|
312
|
+
time.sleep(max(0, deadline - time.monotonic()))
|
|
313
|
+
try:
|
|
314
|
+
os.killpg(child.pid, signal.SIGKILL)
|
|
315
|
+
except ProcessLookupError:
|
|
316
|
+
pass # Every member already exited during the grace period.
|
|
317
|
+
child.wait()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def run_process(argv, stdin, seconds, *, heartbeat=0):
|
|
321
|
+
child = None
|
|
322
|
+
job = _WindowsJob() if os.name == "nt" else None
|
|
323
|
+
previous = {}
|
|
324
|
+
|
|
325
|
+
def interrupted(signum, _frame):
|
|
326
|
+
# CPython stores the native handle only after CreateProcess's finally block.
|
|
327
|
+
if job is not None and job.creating:
|
|
328
|
+
job.interrupted_signal = signum
|
|
329
|
+
return
|
|
330
|
+
raise SystemExit(128 + signum)
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
334
|
+
previous[sig] = signal.signal(sig, interrupted)
|
|
335
|
+
child = job.start(argv, stdin) if job is not None else subprocess.Popen(argv, stdin=stdin, start_new_session=True)
|
|
336
|
+
started = time.monotonic()
|
|
337
|
+
next_heartbeat = started + heartbeat
|
|
338
|
+
if heartbeat:
|
|
339
|
+
print(f"[codex-monitored] codex pid={job.target_pid if job is not None else child.pid}", file=sys.stderr, flush=True)
|
|
340
|
+
while True:
|
|
341
|
+
try:
|
|
342
|
+
return child.wait(timeout=0.1)
|
|
343
|
+
except subprocess.TimeoutExpired:
|
|
344
|
+
now = time.monotonic()
|
|
345
|
+
if seconds and now - started >= seconds:
|
|
346
|
+
if heartbeat:
|
|
347
|
+
print(f"[codex-monitored] timeout: elapsed={int(now-started)}s limit={seconds}s", file=sys.stderr, flush=True)
|
|
348
|
+
return 124
|
|
349
|
+
if heartbeat and now >= next_heartbeat:
|
|
350
|
+
print(f"[codex-monitored] heartbeat: elapsed={int(now-started)}s", file=sys.stderr, flush=True)
|
|
351
|
+
next_heartbeat = now + heartbeat
|
|
352
|
+
finally:
|
|
353
|
+
try:
|
|
354
|
+
if job is not None or child is not None:
|
|
355
|
+
terminate_tree(child, job)
|
|
356
|
+
finally:
|
|
357
|
+
for sig, handler in previous.items():
|
|
358
|
+
signal.signal(sig, handler)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
if __name__ == "__main__" and os.name == "nt":
|
|
362
|
+
configure_utf8()
|
|
363
|
+
try:
|
|
364
|
+
if sys.argv[1:2] != ["--job-bootstrap"]:
|
|
365
|
+
raise ValueError("expected internal Windows job bootstrap")
|
|
366
|
+
code = _job_bootstrap(sys.argv[2:])
|
|
367
|
+
except (OSError, ValueError) as exc:
|
|
368
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
369
|
+
code = 2
|
|
370
|
+
raise SystemExit(system_exit_code(code))
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
"""Capture and validate run-scoped process evidence without third-party dependencies."""
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import runpy
|
|
5
6
|
import argparse
|
|
6
7
|
import hashlib
|
|
7
8
|
import json
|
|
@@ -358,7 +359,7 @@ def capture_process(
|
|
|
358
359
|
started = time.monotonic()
|
|
359
360
|
try:
|
|
360
361
|
proc = subprocess.run(
|
|
361
|
-
item.get("cmd") if "cmd" in item else item["argv"],
|
|
362
|
+
item.get("cmd") if "cmd" in item else runpy.run_path(pathlib.Path(__file__).with_name("platform-support.py"))["native_argv"](item["argv"]),
|
|
362
363
|
cwd=work, shell="cmd" in item, capture_output=True,
|
|
363
364
|
timeout=item["timeout_sec"], check=False,
|
|
364
365
|
)
|
|
@@ -374,7 +375,7 @@ def capture_process(
|
|
|
374
375
|
outcome = {"kind": "timeout", "exit_code": None, "signal": None}
|
|
375
376
|
except OSError as exc:
|
|
376
377
|
stdout = b""
|
|
377
|
-
stderr =
|
|
378
|
+
stderr = str(exc).encode("utf-8")
|
|
378
379
|
outcome = {"kind": "spawn_error", "exit_code": None, "signal": None}
|
|
379
380
|
return _append_entry(
|
|
380
381
|
work, manifest_path, run_id, phase, round_, item["id"], _execution(item),
|
|
@@ -906,4 +907,5 @@ def main() -> int:
|
|
|
906
907
|
|
|
907
908
|
|
|
908
909
|
if __name__ == "__main__":
|
|
910
|
+
runpy.run_path(str(pathlib.Path(__file__).with_name("platform-support.py")))["configure_utf8"]()
|
|
909
911
|
raise SystemExit(main())
|