devlyn-cli 4.3.0 → 4.3.4
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/bin/cleanup-history.json +76 -1
- package/bin/devlyn.js +172 -48
- package/bin/skill-history.json +133 -2
- package/config/skills/_shared/harness-store.py +8 -2
- package/config/skills/_shared/platform-support.py +12 -0
- package/config/skills/_shared/task-completion.md +6 -3
- package/config/skills/_shared/warm-build-dirs.py +97 -2
- package/config/skills/devlyn-ideate/scripts/queue.py +10 -1
- package/harness/AGENTS.md +37 -0
- package/harness/CLAUDE.md +37 -0
- package/native/claude/.claude-plugin/plugin.json +6 -0
- package/native/claude/skills/_shared/.ruff.toml +2 -0
- package/native/claude/skills/_shared/adapters/claude.md +15 -0
- package/native/claude/skills/_shared/adapters/codex.md +14 -0
- package/native/claude/skills/_shared/adapters/omp.md +6 -0
- package/native/claude/skills/_shared/codex-monitored.sh +263 -0
- package/native/claude/skills/_shared/engine-doctor.sh +70 -0
- package/native/claude/skills/_shared/engine-preflight.md +29 -0
- package/native/claude/skills/_shared/expected-contract.py +242 -0
- package/native/claude/skills/_shared/expected.schema.json +120 -0
- package/native/claude/skills/_shared/harness-store.py +207 -0
- package/native/claude/skills/_shared/invocation-receipt.py +499 -0
- package/native/claude/skills/_shared/platform-support.py +407 -0
- package/native/claude/skills/_shared/role-config.py +247 -0
- package/native/claude/skills/_shared/runtime-principles.md +35 -0
- package/native/claude/skills/_shared/task-complete.py +2865 -0
- package/native/claude/skills/_shared/task-completion.md +249 -0
- package/native/claude/skills/_shared/warm-build-dirs.py +1065 -0
- package/native/claude/skills/devlyn-engines/SKILL.md +63 -0
- package/native/claude/skills/devlyn-ideate/SKILL.md +132 -0
- package/native/claude/skills/devlyn-ideate/references/elicitation.md +29 -0
- package/native/claude/skills/devlyn-ideate/references/loop.md +123 -0
- package/native/claude/skills/devlyn-ideate/references/package-format.md +128 -0
- package/native/claude/skills/devlyn-ideate/references/plan-add.md +12 -0
- package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +882 -0
- package/native/claude/skills/devlyn-ideate/scripts/queue.py +2191 -0
- package/native/codex/.agents/plugins/marketplace.json +12 -0
- package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +6 -0
- package/native/codex/plugins/devlyn/skills/_shared/.ruff.toml +2 -0
- package/native/codex/plugins/devlyn/skills/_shared/adapters/claude.md +15 -0
- package/native/codex/plugins/devlyn/skills/_shared/adapters/codex.md +14 -0
- package/native/codex/plugins/devlyn/skills/_shared/adapters/omp.md +6 -0
- package/native/codex/plugins/devlyn/skills/_shared/codex-monitored.sh +263 -0
- package/native/codex/plugins/devlyn/skills/_shared/engine-doctor.sh +70 -0
- package/native/codex/plugins/devlyn/skills/_shared/engine-preflight.md +29 -0
- package/native/codex/plugins/devlyn/skills/_shared/expected-contract.py +242 -0
- package/native/codex/plugins/devlyn/skills/_shared/expected.schema.json +120 -0
- package/native/codex/plugins/devlyn/skills/_shared/harness-store.py +207 -0
- package/native/codex/plugins/devlyn/skills/_shared/invocation-receipt.py +499 -0
- package/native/codex/plugins/devlyn/skills/_shared/platform-support.py +407 -0
- package/native/codex/plugins/devlyn/skills/_shared/role-config.py +247 -0
- package/native/codex/plugins/devlyn/skills/_shared/runtime-principles.md +35 -0
- package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +2865 -0
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +249 -0
- package/native/codex/plugins/devlyn/skills/_shared/warm-build-dirs.py +1065 -0
- package/native/codex/plugins/devlyn/skills/devlyn-engines/SKILL.md +63 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +132 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/elicitation.md +29 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +123 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/package-format.md +128 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/plan-add.md +12 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +882 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +2191 -0
- package/package.json +1 -1
- package/release.json +1630 -0
|
@@ -0,0 +1,407 @@
|
|
|
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 os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import signal
|
|
12
|
+
import stat
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def managed_node():
|
|
19
|
+
"""The Node that runs devlyn-cli's own scripts: the app's override is authoritative, and an invalid one never
|
|
20
|
+
falls back to PATH. Project and engine commands keep the session's own Node."""
|
|
21
|
+
value = os.environ.get("DEVLYN_NODE")
|
|
22
|
+
if value is None:
|
|
23
|
+
return None
|
|
24
|
+
node = Path(value)
|
|
25
|
+
if not node.is_absolute() or not node.is_file() or not os.access(node, os.X_OK):
|
|
26
|
+
raise OSError("DEVLYN_NODE must be an absolute path to an executable file")
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def configure_utf8():
|
|
31
|
+
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
32
|
+
if hasattr(stream, "reconfigure"):
|
|
33
|
+
stream.reconfigure(encoding="utf-8", errors="strict")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def open_stdin(path):
|
|
37
|
+
fd = os.open(path, os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NONBLOCK", 0))
|
|
38
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
39
|
+
os.close(fd)
|
|
40
|
+
raise ValueError(f"stdin file is not a regular file: {path}")
|
|
41
|
+
return os.fdopen(fd, "rb")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@contextlib.contextmanager
|
|
45
|
+
def file_lock(path, *, blocking=False):
|
|
46
|
+
with Path(path).open("a+b") as handle:
|
|
47
|
+
if os.name == "nt":
|
|
48
|
+
import msvcrt
|
|
49
|
+
while True:
|
|
50
|
+
handle.seek(0)
|
|
51
|
+
try:
|
|
52
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
53
|
+
break
|
|
54
|
+
except OSError as exc:
|
|
55
|
+
# CRT maps ERROR_LOCK_VIOLATION to EACCES (winerror may be absent).
|
|
56
|
+
contended = exc.errno == errno.EACCES and getattr(exc, "winerror", None) in (None, 33)
|
|
57
|
+
if not contended:
|
|
58
|
+
raise
|
|
59
|
+
if not blocking:
|
|
60
|
+
raise BlockingIOError(errno.EAGAIN, "lock occupied", str(path)) from exc
|
|
61
|
+
time.sleep(0.05)
|
|
62
|
+
try:
|
|
63
|
+
yield
|
|
64
|
+
finally:
|
|
65
|
+
handle.seek(0)
|
|
66
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
67
|
+
else:
|
|
68
|
+
import fcntl
|
|
69
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB))
|
|
70
|
+
try:
|
|
71
|
+
yield
|
|
72
|
+
finally:
|
|
73
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
SHIM_NODE = re.compile(r'IF EXIST "%(?:~dp0|dp0%)\\node\.exe"')
|
|
77
|
+
SHIM_RUN = re.compile(r'(?:"%_prog%"|"%~dp0\\node\.exe"|\bnode)\s+"%(?:~dp0|dp0%)\\([^"\r\n]+)"\s+%\*')
|
|
78
|
+
SHIM_SET = re.compile(r'(?im)^[ \t]*@?SET[ \t]+"?([^=" \t]+)=')
|
|
79
|
+
NPM_CLI = re.compile(r'SET "(NP[MX]_CLI_JS)=%~dp0\\([^"\r\n]+)"')
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def native_argv(argv, cwd=None):
|
|
83
|
+
"""Resolve argv as Windows does, for a command that will run in cwd; a Node command shim runs its Node and script
|
|
84
|
+
directly, never cmd /c."""
|
|
85
|
+
if os.name != "nt":
|
|
86
|
+
return list(argv)
|
|
87
|
+
binary = shutil.which(argv[0])
|
|
88
|
+
if binary is None:
|
|
89
|
+
raise FileNotFoundError(f"executable unavailable: {argv[0]}")
|
|
90
|
+
shim = Path(binary)
|
|
91
|
+
if shim.suffix.lower() not in {".cmd", ".bat"}:
|
|
92
|
+
return [binary, *argv[1:]]
|
|
93
|
+
node = shim.parent / "node.exe"
|
|
94
|
+
node = str(node) if node.is_file() else shutil.which("node.exe")
|
|
95
|
+
script = shim_script(shim, shim.read_text(encoding="utf-8", errors="replace"), node, cwd)
|
|
96
|
+
if script is None:
|
|
97
|
+
raise OSError(f"unsupported native command shim: {shim}; use argv with an explicit interpreter, such as node and the script")
|
|
98
|
+
if node is None:
|
|
99
|
+
raise FileNotFoundError(f"node.exe unavailable for {shim}")
|
|
100
|
+
return [node, str(script.resolve(strict=True)), *argv[1:]]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def shim_script(shim, raw, node, cwd=None):
|
|
104
|
+
"""The script a Node command shim runs, as cmd.exe would resolve it, else None: the one target of an npm, pnpm
|
|
105
|
+
or corepack cmd-shim that sets no environment, or for Node.js's own npm.cmd and npx.cmd the global prefix's
|
|
106
|
+
npm when installed there, else the bundled one."""
|
|
107
|
+
targets = set(SHIM_RUN.findall(raw))
|
|
108
|
+
if SHIM_NODE.search(raw) and len(targets) == 1 and set(SHIM_SET.findall(raw)) <= {"dp0", "_prog", "PATHEXT"}:
|
|
109
|
+
return shim.parent / targets.pop().replace("\\", "/")
|
|
110
|
+
cli = NPM_CLI.search(raw)
|
|
111
|
+
if not cli or f'"%NODE_EXE%" "%{cli[1]}%" %*' not in raw:
|
|
112
|
+
return None
|
|
113
|
+
prefix_js = re.search(r'SET "NPM_PREFIX_JS=%~dp0\\([^"\r\n]+)"', raw)
|
|
114
|
+
global_cli = re.search(rf'SET "NPM_PREFIX_{cli[1]}=%%F\\([^"\r\n]+)"', raw)
|
|
115
|
+
if prefix_js and global_cli and node:
|
|
116
|
+
# As the shim's FOR /F does: the last nonempty line npm-prefix.js prints names the global prefix.
|
|
117
|
+
output = subprocess.run([node, str(shim.parent / prefix_js[1].replace("\\", "/"))], cwd=cwd, capture_output=True,
|
|
118
|
+
text=True, encoding="utf-8").stdout
|
|
119
|
+
prefix = [line for line in output.splitlines() if line]
|
|
120
|
+
if prefix and (installed := Path(prefix[-1]) / global_cli[1].replace("\\", "/")).is_file():
|
|
121
|
+
return installed
|
|
122
|
+
return shim.parent / cli[2].replace("\\", "/")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def system_exit_code(code):
|
|
126
|
+
# CPython SystemExit uses signed C long (32 bits on Windows), unlike DWORD.
|
|
127
|
+
return code - 0x100000000 if os.name == "nt" and code >= 0x80000000 else code
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def mark_not_content_indexed(path):
|
|
131
|
+
"""Disable Windows content indexing for a directory and children created afterwards."""
|
|
132
|
+
if os.name != "nt":
|
|
133
|
+
return
|
|
134
|
+
attrs = _kernel.GetFileAttributesW(str(path))
|
|
135
|
+
_checked(attrs != 0xFFFFFFFF, _kernel.GetFileAttributesW, (str(path),)) # INVALID_FILE_ATTRIBUTES.
|
|
136
|
+
_kernel.SetFileAttributesW(str(path), attrs | stat.FILE_ATTRIBUTE_NOT_CONTENT_INDEXED)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if os.name == "nt":
|
|
140
|
+
import _winapi
|
|
141
|
+
import ctypes
|
|
142
|
+
from ctypes import wintypes
|
|
143
|
+
import msvcrt
|
|
144
|
+
|
|
145
|
+
class _BasicLimits(ctypes.Structure):
|
|
146
|
+
_fields_ = [("PerProcessUserTimeLimit", ctypes.c_longlong),
|
|
147
|
+
("PerJobUserTimeLimit", ctypes.c_longlong),
|
|
148
|
+
("LimitFlags", wintypes.DWORD),
|
|
149
|
+
("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
150
|
+
("MaximumWorkingSetSize", ctypes.c_size_t),
|
|
151
|
+
("ActiveProcessLimit", wintypes.DWORD),
|
|
152
|
+
("Affinity", ctypes.c_size_t),
|
|
153
|
+
("PriorityClass", wintypes.DWORD),
|
|
154
|
+
("SchedulingClass", wintypes.DWORD)]
|
|
155
|
+
|
|
156
|
+
class _ExtendedLimits(ctypes.Structure):
|
|
157
|
+
_fields_ = [("BasicLimitInformation", _BasicLimits),
|
|
158
|
+
("IoInfo", ctypes.c_ulonglong * 6),
|
|
159
|
+
("ProcessMemoryLimit", ctypes.c_size_t),
|
|
160
|
+
("JobMemoryLimit", ctypes.c_size_t),
|
|
161
|
+
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
162
|
+
("PeakJobMemoryUsed", ctypes.c_size_t)]
|
|
163
|
+
|
|
164
|
+
class _JobProcessIds(ctypes.Structure):
|
|
165
|
+
_fields_ = [("NumberOfAssignedProcesses", wintypes.DWORD),
|
|
166
|
+
("NumberOfProcessIdsInList", wintypes.DWORD),
|
|
167
|
+
("ProcessIdList", ctypes.c_size_t * 1)]
|
|
168
|
+
|
|
169
|
+
def _checked(result, function, _args):
|
|
170
|
+
if not result:
|
|
171
|
+
error = ctypes.get_last_error()
|
|
172
|
+
raise ctypes.WinError(error, f"{function.__name__}: {ctypes.FormatError(error)}")
|
|
173
|
+
return result
|
|
174
|
+
|
|
175
|
+
_kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
176
|
+
for _name, _args, _result in (
|
|
177
|
+
("CreateJobObjectW", [ctypes.c_void_p, wintypes.LPCWSTR], wintypes.HANDLE),
|
|
178
|
+
("SetInformationJobObject", [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD], wintypes.BOOL),
|
|
179
|
+
("QueryInformationJobObject", [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, ctypes.c_void_p], wintypes.BOOL),
|
|
180
|
+
("AssignProcessToJobObject", [wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL),
|
|
181
|
+
("TerminateJobObject", [wintypes.HANDLE, wintypes.UINT], wintypes.BOOL),
|
|
182
|
+
("OpenProcess", [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE),
|
|
183
|
+
("IsProcessInJob", [wintypes.HANDLE, wintypes.HANDLE, ctypes.POINTER(wintypes.BOOL)], wintypes.BOOL),
|
|
184
|
+
("GetCurrentProcess", [], wintypes.HANDLE),
|
|
185
|
+
("DuplicateHandle", [wintypes.HANDLE, wintypes.HANDLE, wintypes.HANDLE, ctypes.POINTER(wintypes.HANDLE), wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.BOOL),
|
|
186
|
+
("CloseHandle", [wintypes.HANDLE], wintypes.BOOL),
|
|
187
|
+
("PeekNamedPipe", [wintypes.HANDLE, ctypes.c_void_p, wintypes.DWORD, ctypes.c_void_p, ctypes.POINTER(wintypes.DWORD), ctypes.c_void_p], wintypes.BOOL),
|
|
188
|
+
("SetFileAttributesW", [wintypes.LPCWSTR, wintypes.DWORD], wintypes.BOOL),
|
|
189
|
+
):
|
|
190
|
+
_function = getattr(_kernel, _name)
|
|
191
|
+
_function.argtypes, _function.restype, _function.errcheck = _args, _result, _checked
|
|
192
|
+
|
|
193
|
+
_kernel.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
|
194
|
+
_kernel.WaitForSingleObject.restype = wintypes.DWORD # WAIT_OBJECT_0 is successful zero, not BOOL.
|
|
195
|
+
|
|
196
|
+
_kernel.GetFileAttributesW.argtypes = [wintypes.LPCWSTR]
|
|
197
|
+
_kernel.GetFileAttributesW.restype = wintypes.DWORD # Failure is INVALID_FILE_ATTRIBUTES, not zero.
|
|
198
|
+
|
|
199
|
+
def _read_control(fd, count):
|
|
200
|
+
deadline = time.monotonic() + 10
|
|
201
|
+
data = b""
|
|
202
|
+
while len(data) < count:
|
|
203
|
+
available = wintypes.DWORD()
|
|
204
|
+
_kernel.PeekNamedPipe(msvcrt.get_osfhandle(fd), None, 0, None, ctypes.byref(available), None)
|
|
205
|
+
if available.value:
|
|
206
|
+
data += os.read(fd, min(count - len(data), available.value))
|
|
207
|
+
elif time.monotonic() >= deadline:
|
|
208
|
+
raise OSError("Windows job launch handshake timed out")
|
|
209
|
+
else:
|
|
210
|
+
time.sleep(0.01)
|
|
211
|
+
return data
|
|
212
|
+
|
|
213
|
+
class _WindowsJob:
|
|
214
|
+
def __init__(self):
|
|
215
|
+
self.handle = None
|
|
216
|
+
self.child = None
|
|
217
|
+
self.creating = False
|
|
218
|
+
self.interrupted_signal = None
|
|
219
|
+
|
|
220
|
+
def start(self, argv, stdin):
|
|
221
|
+
self.handle = _kernel.CreateJobObjectW(None, None)
|
|
222
|
+
limits = _ExtendedLimits()
|
|
223
|
+
limits.BasicLimitInformation.LimitFlags = 0x2000 # KILL_ON_JOB_CLOSE; no breakaway.
|
|
224
|
+
_kernel.SetInformationJobObject(self.handle, 9, ctypes.byref(limits), ctypes.sizeof(limits))
|
|
225
|
+
with contextlib.ExitStack() as controls, contextlib.ExitStack() as inherited_handles:
|
|
226
|
+
inherited_job = wintypes.HANDLE()
|
|
227
|
+
current = _kernel.GetCurrentProcess()
|
|
228
|
+
_kernel.DuplicateHandle(current, self.handle, current, ctypes.byref(inherited_job), 0, True, 2)
|
|
229
|
+
inherited_handles.callback(_kernel.CloseHandle, inherited_job)
|
|
230
|
+
ready_read, ready_write = os.pipe()
|
|
231
|
+
controls.callback(os.close, ready_read); inherited_handles.callback(os.close, ready_write)
|
|
232
|
+
allow_read, allow_write = os.pipe()
|
|
233
|
+
inherited_handles.callback(os.close, allow_read); controls.callback(os.close, allow_write)
|
|
234
|
+
inherited = [inherited_job.value, msvcrt.get_osfhandle(ready_write), msvcrt.get_osfhandle(allow_read)]
|
|
235
|
+
for handle in inherited[1:]:
|
|
236
|
+
os.set_handle_inheritable(handle, True)
|
|
237
|
+
startup = subprocess.STARTUPINFO()
|
|
238
|
+
startup.lpAttributeList = {"handle_list": inherited}
|
|
239
|
+
command = [sys.executable, "-I", "-S", str(Path(__file__).resolve()), "--job-bootstrap",
|
|
240
|
+
str(inherited[0]), str(inherited[2]), str(inherited[1]), *argv]
|
|
241
|
+
# Retain the Popen object even if __init__ raises after native creation.
|
|
242
|
+
self.child = subprocess.Popen.__new__(subprocess.Popen)
|
|
243
|
+
self.creating = True
|
|
244
|
+
try:
|
|
245
|
+
self.child.__init__(command, stdin=stdin, startupinfo=startup, close_fds=True)
|
|
246
|
+
finally:
|
|
247
|
+
self.creating = False
|
|
248
|
+
if self.interrupted_signal is not None:
|
|
249
|
+
raise SystemExit(128 + self.interrupted_signal)
|
|
250
|
+
inherited_handles.close()
|
|
251
|
+
if _read_control(ready_read, 1) != b"R":
|
|
252
|
+
raise OSError("Windows job enrollment did not report ready")
|
|
253
|
+
os.write(allow_write, b"G")
|
|
254
|
+
self.target_pid = int.from_bytes(_read_control(ready_read, 4), "little")
|
|
255
|
+
return self.child
|
|
256
|
+
|
|
257
|
+
def terminate(self):
|
|
258
|
+
try:
|
|
259
|
+
# Stop startup first so no new enrollment/dispatch can race job quiescence.
|
|
260
|
+
if self.child is not None and getattr(self.child, "_child_created", False):
|
|
261
|
+
try:
|
|
262
|
+
if self.child.poll() is None:
|
|
263
|
+
self.child.kill()
|
|
264
|
+
# kill() can cache an exit code before the native handle is signaled.
|
|
265
|
+
if _winapi.WaitForSingleObject(self.child._handle, 5000) == _winapi.WAIT_TIMEOUT:
|
|
266
|
+
raise subprocess.TimeoutExpired(self.child.args, 5)
|
|
267
|
+
finally:
|
|
268
|
+
self.child._handle.Close()
|
|
269
|
+
finally:
|
|
270
|
+
if self.handle is not None:
|
|
271
|
+
with contextlib.ExitStack() as handles:
|
|
272
|
+
handles.callback(_kernel.CloseHandle, self.handle)
|
|
273
|
+
limits = _ExtendedLimits() # Zero ActiveProcessLimit: no freed slot admits a new child.
|
|
274
|
+
limits.BasicLimitInformation.LimitFlags = 0x2008 # KILL_ON_JOB_CLOSE | ACTIVE_PROCESS.
|
|
275
|
+
_kernel.SetInformationJobObject(self.handle, 9, ctypes.byref(limits), ctypes.sizeof(limits))
|
|
276
|
+
capacity = 1
|
|
277
|
+
while True:
|
|
278
|
+
buffer = ctypes.create_string_buffer(_JobProcessIds.ProcessIdList.offset + capacity * ctypes.sizeof(ctypes.c_size_t))
|
|
279
|
+
ids = _JobProcessIds.from_buffer(buffer)
|
|
280
|
+
try:
|
|
281
|
+
_kernel.QueryInformationJobObject(self.handle, 3, buffer, len(buffer), None)
|
|
282
|
+
break
|
|
283
|
+
except OSError as exc:
|
|
284
|
+
if exc.winerror != 234: # ERROR_MORE_DATA: never accept a partial list.
|
|
285
|
+
raise
|
|
286
|
+
capacity = max(capacity * 2, ids.NumberOfAssignedProcesses)
|
|
287
|
+
members = []
|
|
288
|
+
pids = (ctypes.c_size_t * ids.NumberOfProcessIdsInList).from_buffer(buffer, _JobProcessIds.ProcessIdList.offset)
|
|
289
|
+
for pid in pids:
|
|
290
|
+
try:
|
|
291
|
+
process = _kernel.OpenProcess(0x101000, False, pid) # SYNCHRONIZE | QUERY_LIMITED_INFORMATION.
|
|
292
|
+
except OSError as exc:
|
|
293
|
+
if exc.winerror == 87: # Already gone; other open failures remain visible.
|
|
294
|
+
continue
|
|
295
|
+
raise
|
|
296
|
+
handles.callback(_kernel.CloseHandle, process)
|
|
297
|
+
member = wintypes.BOOL()
|
|
298
|
+
_kernel.IsProcessInJob(process, self.handle, ctypes.byref(member))
|
|
299
|
+
if member.value: # A reused PID in another job is not our process identity.
|
|
300
|
+
members.append((pid, process))
|
|
301
|
+
# Natural exits before capture do not prove pending I/O or historical object finalization.
|
|
302
|
+
_kernel.TerminateJobObject(self.handle, 1)
|
|
303
|
+
deadline = time.monotonic() + 5
|
|
304
|
+
for pid, process in members:
|
|
305
|
+
remaining = max(0, int((deadline - time.monotonic()) * 1000))
|
|
306
|
+
result = _kernel.WaitForSingleObject(process, remaining)
|
|
307
|
+
if result == 258: # WAIT_TIMEOUT.
|
|
308
|
+
raise OSError(f"Windows job teardown timed out waiting for process {pid}")
|
|
309
|
+
if result == 0xFFFFFFFF: # WAIT_FAILED.
|
|
310
|
+
error = ctypes.get_last_error()
|
|
311
|
+
raise ctypes.WinError(error, f"WaitForSingleObject: {ctypes.FormatError(error)}")
|
|
312
|
+
if result != 0:
|
|
313
|
+
raise OSError(f"WaitForSingleObject: unexpected result {result} for process {pid}")
|
|
314
|
+
|
|
315
|
+
def _job_bootstrap(argv):
|
|
316
|
+
job, allow, ready = map(int, argv[:3])
|
|
317
|
+
with contextlib.ExitStack() as handles:
|
|
318
|
+
allow_fd = msvcrt.open_osfhandle(allow, os.O_RDONLY | os.O_BINARY)
|
|
319
|
+
handles.callback(os.close, allow_fd)
|
|
320
|
+
ready_fd = msvcrt.open_osfhandle(ready, os.O_WRONLY | os.O_BINARY)
|
|
321
|
+
handles.callback(os.close, ready_fd)
|
|
322
|
+
try:
|
|
323
|
+
_kernel.AssignProcessToJobObject(job, _kernel.GetCurrentProcess())
|
|
324
|
+
finally:
|
|
325
|
+
_kernel.CloseHandle(job)
|
|
326
|
+
os.write(ready_fd, b"R")
|
|
327
|
+
if _read_control(allow_fd, 1) != b"G":
|
|
328
|
+
raise OSError("Windows job launch was not authorized")
|
|
329
|
+
with subprocess.Popen(argv[3:], stdin=sys.stdin.buffer, close_fds=True) as target:
|
|
330
|
+
os.write(ready_fd, target.pid.to_bytes(4, "little"))
|
|
331
|
+
return target.wait()
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def terminate_tree(child, job=None):
|
|
335
|
+
if job is not None:
|
|
336
|
+
job.terminate()
|
|
337
|
+
return
|
|
338
|
+
try:
|
|
339
|
+
os.killpg(child.pid, signal.SIGTERM)
|
|
340
|
+
except ProcessLookupError:
|
|
341
|
+
child.wait()
|
|
342
|
+
return
|
|
343
|
+
# Reap the leader and retain the grace period for its surviving descendants.
|
|
344
|
+
deadline = time.monotonic() + 5
|
|
345
|
+
try:
|
|
346
|
+
child.wait(timeout=5)
|
|
347
|
+
except subprocess.TimeoutExpired:
|
|
348
|
+
pass # The leader also needs the final group KILL below.
|
|
349
|
+
time.sleep(max(0, deadline - time.monotonic()))
|
|
350
|
+
try:
|
|
351
|
+
os.killpg(child.pid, signal.SIGKILL)
|
|
352
|
+
except ProcessLookupError:
|
|
353
|
+
pass # Every member already exited during the grace period.
|
|
354
|
+
child.wait()
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def run_process(argv, stdin, seconds, *, heartbeat=0):
|
|
358
|
+
child = None
|
|
359
|
+
job = _WindowsJob() if os.name == "nt" else None
|
|
360
|
+
previous = {}
|
|
361
|
+
|
|
362
|
+
def interrupted(signum, _frame):
|
|
363
|
+
# CPython stores the native handle only after CreateProcess's finally block.
|
|
364
|
+
if job is not None and job.creating:
|
|
365
|
+
job.interrupted_signal = signum
|
|
366
|
+
return
|
|
367
|
+
raise SystemExit(128 + signum)
|
|
368
|
+
|
|
369
|
+
try:
|
|
370
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
371
|
+
previous[sig] = signal.signal(sig, interrupted)
|
|
372
|
+
child = job.start(argv, stdin) if job is not None else subprocess.Popen(argv, stdin=stdin, start_new_session=True)
|
|
373
|
+
started = time.monotonic()
|
|
374
|
+
next_heartbeat = started + heartbeat
|
|
375
|
+
if heartbeat:
|
|
376
|
+
print(f"[codex-monitored] codex pid={job.target_pid if job is not None else child.pid}", file=sys.stderr, flush=True)
|
|
377
|
+
while True:
|
|
378
|
+
try:
|
|
379
|
+
return child.wait(timeout=0.1)
|
|
380
|
+
except subprocess.TimeoutExpired:
|
|
381
|
+
now = time.monotonic()
|
|
382
|
+
if seconds and now - started >= seconds:
|
|
383
|
+
if heartbeat:
|
|
384
|
+
print(f"[codex-monitored] timeout: elapsed={int(now-started)}s limit={seconds}s", file=sys.stderr, flush=True)
|
|
385
|
+
return 124
|
|
386
|
+
if heartbeat and now >= next_heartbeat:
|
|
387
|
+
print(f"[codex-monitored] heartbeat: elapsed={int(now-started)}s", file=sys.stderr, flush=True)
|
|
388
|
+
next_heartbeat = now + heartbeat
|
|
389
|
+
finally:
|
|
390
|
+
try:
|
|
391
|
+
if job is not None or child is not None:
|
|
392
|
+
terminate_tree(child, job)
|
|
393
|
+
finally:
|
|
394
|
+
for sig, handler in previous.items():
|
|
395
|
+
signal.signal(sig, handler)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
if __name__ == "__main__" and os.name == "nt":
|
|
399
|
+
configure_utf8()
|
|
400
|
+
try:
|
|
401
|
+
if sys.argv[1:2] != ["--job-bootstrap"]:
|
|
402
|
+
raise ValueError("expected internal Windows job bootstrap")
|
|
403
|
+
code = _job_bootstrap(sys.argv[2:])
|
|
404
|
+
except (OSError, ValueError) as exc:
|
|
405
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
406
|
+
code = 2
|
|
407
|
+
raise SystemExit(system_exit_code(code))
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Resolve and pin the devlyn executor; no model dispatch."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import runpy
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import stat
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
|
|
18
|
+
sys.dont_write_bytecode = True
|
|
19
|
+
SHARED = Path(__file__).resolve().parent
|
|
20
|
+
# Earlier releases also pinned pipeline judge and worker roles; those keys stay as written and select nothing.
|
|
21
|
+
RETIRED = ("pair_judge_priority", "roles")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fail(detail, reason="invalid-engine-config"):
|
|
25
|
+
raise ValueError(f"BLOCKED:{reason}: {detail}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def pairs(items):
|
|
29
|
+
result = {}
|
|
30
|
+
for key, value in items:
|
|
31
|
+
if key in result:
|
|
32
|
+
fail(f"duplicate JSON key: {key}")
|
|
33
|
+
result[key] = value
|
|
34
|
+
return result
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def loads(raw):
|
|
38
|
+
try:
|
|
39
|
+
return json.loads(raw, object_pairs_hook=pairs,
|
|
40
|
+
parse_constant=lambda value: fail(f"invalid JSON constant: {value}"))
|
|
41
|
+
except (json.JSONDecodeError, UnicodeError, TypeError) as exc:
|
|
42
|
+
fail(f"invalid JSON: {exc}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def digest(raw):
|
|
46
|
+
return hashlib.sha256(raw).hexdigest()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def encoded(value):
|
|
50
|
+
return (json.dumps(value, sort_keys=True, indent=2) + "\n").encode()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def name(value, field):
|
|
54
|
+
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", value):
|
|
55
|
+
fail(f"{field} must be a nonempty identifier")
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def adapter(engine, shared=SHARED):
|
|
60
|
+
name(engine, "engine")
|
|
61
|
+
path = shared / "adapters" / f"{engine}.md"
|
|
62
|
+
if not path.is_file():
|
|
63
|
+
fail(f"no adapter for {engine}; valid: {', '.join(sorted(p.stem for p in (shared / 'adapters').glob('*.md')))}")
|
|
64
|
+
text = path.read_text(encoding="utf-8")
|
|
65
|
+
if re.search(r"^executor: no\s*$", text, re.M):
|
|
66
|
+
fail(f"{engine} is ineligible as executor")
|
|
67
|
+
return text
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def validate(config, shared=SHARED):
|
|
71
|
+
if not isinstance(config, dict):
|
|
72
|
+
fail("configuration must be an object")
|
|
73
|
+
if "executor" in config:
|
|
74
|
+
adapter(config["executor"], shared=shared)
|
|
75
|
+
return config
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def read_config(path, *, optional=False, shared=SHARED):
|
|
79
|
+
path = Path(path)
|
|
80
|
+
try:
|
|
81
|
+
if optional:
|
|
82
|
+
try:
|
|
83
|
+
path.lstat()
|
|
84
|
+
except FileNotFoundError:
|
|
85
|
+
for parent in path.parents:
|
|
86
|
+
try:
|
|
87
|
+
mode = parent.stat().st_mode
|
|
88
|
+
except FileNotFoundError:
|
|
89
|
+
continue
|
|
90
|
+
if not stat.S_ISDIR(mode):
|
|
91
|
+
fail(f"cannot read role configuration {path}: {parent} is not a directory")
|
|
92
|
+
return {}, {"path": str(path.absolute()), "sha256": None}
|
|
93
|
+
raise
|
|
94
|
+
raw = path.read_bytes()
|
|
95
|
+
value = validate(loads(raw), shared=shared)
|
|
96
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
97
|
+
fail(f"cannot read role configuration {path}: {exc}")
|
|
98
|
+
return value, {"path": str(path.resolve()), "sha256": digest(raw)}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def resolve(work, default_engine, *, available=None, shared=SHARED):
|
|
102
|
+
"""The project's executor pin, else the invoking CLI; status only, never a dispatch decision."""
|
|
103
|
+
config, source = read_config(Path(work) / ".devlyn/engines.json", optional=True, shared=shared)
|
|
104
|
+
engine = config.get("executor", default_engine)
|
|
105
|
+
# read_config validates pins; the invoking host needs no adapter to report its own default.
|
|
106
|
+
name(engine, "engine")
|
|
107
|
+
available = available or (lambda candidate: shutil.which(candidate) is not None)
|
|
108
|
+
return {"executor": {"engine": engine, "source": "engines.json" if "executor" in config else "default",
|
|
109
|
+
"availability": "CLI-present/auth-unchecked" if available(engine) else "CLI-unavailable"},
|
|
110
|
+
"inactive": [key for key in RETIRED if key in config], "input": source}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def select(work, default_engine, *, available=None, shared=SHARED):
|
|
114
|
+
"""The executor for dispatch. A pin is a promise: an unavailable pinned engine is never substituted."""
|
|
115
|
+
result = resolve(work, default_engine, available=available, shared=shared)
|
|
116
|
+
executor = result["executor"]
|
|
117
|
+
adapter(executor["engine"], shared=shared)
|
|
118
|
+
if executor["source"] == "engines.json" and executor["availability"] == "CLI-unavailable":
|
|
119
|
+
fail(f"pinned executor {executor['engine']} is unavailable; install and authenticate its CLI, "
|
|
120
|
+
f"verify `{executor['engine']} --version`, and retry, or pin an available engine with `devlyn-engines executor <name>`",
|
|
121
|
+
f"{executor['engine']}-unavailable")
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def edit(work, executor, shared=SHARED):
|
|
126
|
+
"""Pin `executor`, or with None clear devlyn's pins; unrelated keys keep their values."""
|
|
127
|
+
path = Path(work) / ".devlyn/engines.json"
|
|
128
|
+
config, _ = read_config(path, optional=True, shared=shared)
|
|
129
|
+
if executor is None:
|
|
130
|
+
for key in ("executor", *RETIRED):
|
|
131
|
+
config.pop(key, None)
|
|
132
|
+
else:
|
|
133
|
+
adapter(executor, shared=shared)
|
|
134
|
+
config["executor"] = executor
|
|
135
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
if not config:
|
|
137
|
+
path.unlink(missing_ok=True)
|
|
138
|
+
return
|
|
139
|
+
fd, temp = tempfile.mkstemp(prefix="engines.json.tmp.", dir=path.parent)
|
|
140
|
+
try:
|
|
141
|
+
with os.fdopen(fd, "wb") as handle:
|
|
142
|
+
handle.write(encoded(config))
|
|
143
|
+
if path.exists():
|
|
144
|
+
os.chmod(temp, path.stat().st_mode & 0o777)
|
|
145
|
+
os.replace(temp, path)
|
|
146
|
+
finally:
|
|
147
|
+
Path(temp).unlink(missing_ok=True)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def self_test():
|
|
151
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
152
|
+
work = Path(temp)
|
|
153
|
+
(work / ".devlyn").mkdir()
|
|
154
|
+
path = work / ".devlyn/engines.json"
|
|
155
|
+
here, absent = (lambda engine: True), (lambda engine: False)
|
|
156
|
+
assert resolve(work, "claude", available=here)["executor"] == {
|
|
157
|
+
"engine": "claude", "source": "default", "availability": "CLI-present/auth-unchecked"}
|
|
158
|
+
# An explicit pin binds; retired keys are reported, not validated, and their bytes stay.
|
|
159
|
+
legacy = b'{"executor":"codex","pair_judge_priority":["grok"],"roles":{"worker":{"engine":"unknown"}},"keep":7}\n'
|
|
160
|
+
path.write_bytes(legacy)
|
|
161
|
+
status = resolve(work, "claude", available=here)
|
|
162
|
+
assert status["executor"]["engine"] == "codex" and status["executor"]["source"] == "engines.json", status
|
|
163
|
+
assert status["inactive"] == ["pair_judge_priority", "roles"] and path.read_bytes() == legacy
|
|
164
|
+
# A worker profile never becomes the executor.
|
|
165
|
+
path.write_bytes(b'{"roles":{"worker":{"engine":"codex"}}}')
|
|
166
|
+
assert resolve(work, "claude", available=here)["executor"]["engine"] == "claude"
|
|
167
|
+
# Dispatch fails closed for an unavailable pin; the invoking CLI is reported, never blocked.
|
|
168
|
+
path.write_bytes(b'{"executor":"codex"}')
|
|
169
|
+
try:
|
|
170
|
+
select(work, "claude", available=absent)
|
|
171
|
+
except ValueError as exc:
|
|
172
|
+
assert str(exc).startswith("BLOCKED:codex-unavailable:"), exc
|
|
173
|
+
else:
|
|
174
|
+
raise AssertionError("an unavailable pinned executor was dispatched")
|
|
175
|
+
assert resolve(work, "claude", available=absent)["executor"]["availability"] == "CLI-unavailable"
|
|
176
|
+
path.unlink()
|
|
177
|
+
assert select(work, "claude", available=absent)["executor"]["source"] == "default"
|
|
178
|
+
# Pins are validated before any byte changes.
|
|
179
|
+
ineligible = work / "shared"
|
|
180
|
+
(ineligible / "adapters").mkdir(parents=True)
|
|
181
|
+
(ineligible / "adapters/judge.md").write_text("## Role eligibility\n\nexecutor: no\n", encoding="utf-8")
|
|
182
|
+
(ineligible / "adapters/codex.md").write_text("# Codex adapter\n", encoding="utf-8")
|
|
183
|
+
path.write_bytes(legacy)
|
|
184
|
+
for engine, shared, message in (("grok", SHARED, "no adapter for grok"), ("judge", ineligible, "ineligible as executor"),
|
|
185
|
+
("../codex", SHARED, "nonempty identifier")):
|
|
186
|
+
try:
|
|
187
|
+
edit(work, engine, shared=shared)
|
|
188
|
+
except ValueError as exc:
|
|
189
|
+
assert "BLOCKED:invalid-engine-config" in str(exc) and message in str(exc), exc
|
|
190
|
+
else:
|
|
191
|
+
raise AssertionError(f"{engine} was pinned")
|
|
192
|
+
assert path.read_bytes() == legacy
|
|
193
|
+
for bad in (b'{"executor":"codex","executor":"claude"}', b'{"executor":NaN}', b'[]', b'{"executor":"grok"}'):
|
|
194
|
+
path.write_bytes(bad)
|
|
195
|
+
try:
|
|
196
|
+
resolve(work, "claude", available=here)
|
|
197
|
+
except ValueError as exc:
|
|
198
|
+
assert "BLOCKED:invalid-engine-config" in str(exc), exc
|
|
199
|
+
else:
|
|
200
|
+
raise AssertionError(bad)
|
|
201
|
+
path.write_bytes(legacy)
|
|
202
|
+
edit(work, "claude")
|
|
203
|
+
assert loads(path.read_bytes()) == {**loads(legacy), "executor": "claude"}
|
|
204
|
+
edit(work, None)
|
|
205
|
+
assert loads(path.read_bytes()) == {"keep": 7}
|
|
206
|
+
path.write_bytes(b'{"executor":"codex","roles":{}}')
|
|
207
|
+
edit(work, None)
|
|
208
|
+
assert not path.exists()
|
|
209
|
+
# The CLI reports blocks on stderr without a traceback and changes nothing.
|
|
210
|
+
path.write_bytes(b'{"executor":"codex"}')
|
|
211
|
+
proc = subprocess.run([sys.executable, str(Path(__file__).resolve()), "--workdir", str(work), "--select"],
|
|
212
|
+
capture_output=True, text=True, encoding="utf-8", env={**os.environ, "PATH": str(work / "no-binaries")})
|
|
213
|
+
assert proc.returncode == 1 and proc.stderr.startswith("BLOCKED:codex-unavailable:"), proc.stderr
|
|
214
|
+
assert "Traceback" not in proc.stderr and path.read_bytes() == b'{"executor":"codex"}'
|
|
215
|
+
print("PASS role-config self-test: executor pin, retired keys inactive, fail-closed selection and atomic validated writes")
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def main():
|
|
220
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
221
|
+
parser.add_argument("--self-test", action="store_true")
|
|
222
|
+
parser.add_argument("--workdir", type=Path, default=Path.cwd())
|
|
223
|
+
parser.add_argument("--default-engine", default="claude")
|
|
224
|
+
action = parser.add_mutually_exclusive_group()
|
|
225
|
+
action.add_argument("--select", action="store_true", help="resolve for dispatch: an unavailable pinned executor fails closed")
|
|
226
|
+
action.add_argument("--set-executor", metavar="ENGINE")
|
|
227
|
+
action.add_argument("--clear", action="store_true", help="remove the executor pin and the inactive retired keys")
|
|
228
|
+
args = parser.parse_args()
|
|
229
|
+
try:
|
|
230
|
+
if args.self_test:
|
|
231
|
+
return self_test()
|
|
232
|
+
if args.set_executor is not None or args.clear:
|
|
233
|
+
edit(args.workdir, args.set_executor)
|
|
234
|
+
value = (select if args.select else resolve)(args.workdir, args.default_engine)
|
|
235
|
+
if args.set_executor and value["executor"]["availability"] == "CLI-unavailable":
|
|
236
|
+
print(f"Pinned {args.set_executor}, which is unavailable here: dispatch stops with "
|
|
237
|
+
f"BLOCKED:{args.set_executor}-unavailable until it is installed.", file=sys.stderr)
|
|
238
|
+
print(json.dumps(value, sort_keys=True))
|
|
239
|
+
return 0
|
|
240
|
+
except (ValueError, OSError) as exc:
|
|
241
|
+
print(str(exc), file=sys.stderr)
|
|
242
|
+
return 1
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
if __name__ == "__main__":
|
|
246
|
+
runpy.run_path(str(Path(__file__).with_name("platform-support.py")))["configure_utf8"]()
|
|
247
|
+
raise SystemExit(main())
|