@ikon85/agent-workflow-kit 0.28.0 → 0.29.0
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/skills/board-to-waves/SKILL.md +13 -3
- package/.agents/skills/to-issues/SKILL.md +102 -27
- package/.agents/skills/to-prd/SKILL.md +13 -3
- package/.agents/skills/to-waves/SKILL.md +16 -4
- package/.agents/skills/wrapup/SKILL.md +0 -1
- package/.claude/skills/board-to-waves/SKILL.md +13 -3
- package/.claude/skills/codex-build/SKILL.md +69 -23
- package/.claude/skills/codex-review/SKILL.md +47 -37
- package/.claude/skills/grill-me-codex/SKILL.md +45 -34
- package/.claude/skills/grill-with-docs-codex/SKILL.md +46 -34
- package/.claude/skills/to-issues/SKILL.md +102 -27
- package/.claude/skills/to-prd/SKILL.md +13 -3
- package/.claude/skills/to-waves/SKILL.md +16 -4
- package/.claude/skills/wrapup/SKILL.md +0 -1
- package/README.md +23 -0
- package/agent-workflow-kit.package.json +56 -16
- package/docs/research/wave-152-consumer-acceptance.md +98 -0
- package/package.json +1 -1
- package/scripts/board-sync.py +3 -7
- package/scripts/build-kit.test.mjs +42 -1
- package/scripts/codex-exec-scenarios/fake-codex.mjs +152 -0
- package/scripts/codex-exec.sh +566 -0
- package/scripts/codex-exec.test.mjs +815 -0
- package/scripts/codex_proc.py +602 -0
- package/scripts/find-by-marker.py +68 -0
- package/scripts/marker_lib.py +88 -0
- package/scripts/render-anchor.py +111 -0
- package/scripts/test_marker_lib.py +154 -0
- package/scripts/test_render_anchor.py +267 -0
- package/scripts/test_retro_wrapup_contract.py +6 -0
- package/scripts/test_skill_codex_exec_lifecycle.py +123 -0
- package/src/lib/bundle.mjs +10 -0
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run one Codex round in an owned process group and classify its result."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import selectors
|
|
12
|
+
import secrets
|
|
13
|
+
import signal
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
REDACTIONS = (
|
|
22
|
+
(re.compile(r"(?i)(token|api[_-]?key|password)=\S+"), r"\1=[REDACTED]"),
|
|
23
|
+
(re.compile(r"(?i)bearer\s+\S+"), "Bearer [REDACTED]"),
|
|
24
|
+
)
|
|
25
|
+
CANCEL_REQUESTED = False
|
|
26
|
+
HEARTBEAT_INTERVAL_SECONDS = 0.1
|
|
27
|
+
CANCEL_WAIT_SECONDS = 2.0
|
|
28
|
+
OWNERSHIP_TOKEN = re.compile(r"^[0-9a-f]{64}$")
|
|
29
|
+
LEASE_DIR_NAME = "round.lease"
|
|
30
|
+
LEASE_OWNER_NAME = "owner.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def request_cancel(_signum: int, _frame: object) -> None:
|
|
34
|
+
global CANCEL_REQUESTED
|
|
35
|
+
CANCEL_REQUESTED = True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def atomic_write(path: Path, text: str) -> None:
|
|
39
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
40
|
+
with temporary.open("w", encoding="utf-8") as handle:
|
|
41
|
+
os.chmod(temporary, 0o600)
|
|
42
|
+
handle.write(text)
|
|
43
|
+
handle.flush()
|
|
44
|
+
os.fsync(handle.fileno())
|
|
45
|
+
os.replace(temporary, path)
|
|
46
|
+
fsync_directory(path.parent)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def exclusive_write(path: Path, text: str) -> None:
|
|
50
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
51
|
+
try:
|
|
52
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
53
|
+
handle.write(text)
|
|
54
|
+
handle.flush()
|
|
55
|
+
os.fsync(handle.fileno())
|
|
56
|
+
except BaseException:
|
|
57
|
+
path.unlink(missing_ok=True)
|
|
58
|
+
raise
|
|
59
|
+
fsync_directory(path.parent)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def fsync_directory(path: Path) -> None:
|
|
63
|
+
descriptor = os.open(path, os.O_RDONLY)
|
|
64
|
+
try:
|
|
65
|
+
os.fsync(descriptor)
|
|
66
|
+
finally:
|
|
67
|
+
os.close(descriptor)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def remove_durable(path: Path) -> None:
|
|
71
|
+
path.unlink(missing_ok=True)
|
|
72
|
+
fsync_directory(path.parent)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def read_object(path: Path) -> dict | None:
|
|
76
|
+
try:
|
|
77
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
78
|
+
except (FileNotFoundError, OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
79
|
+
return None
|
|
80
|
+
return value if isinstance(value, dict) else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def publish_runtime(state_dir: Path, token: str, round_number: int, phase: str) -> None:
|
|
84
|
+
atomic_write(
|
|
85
|
+
state_dir / "runtime.json",
|
|
86
|
+
json.dumps({
|
|
87
|
+
"token": token,
|
|
88
|
+
"round": round_number,
|
|
89
|
+
"phase": phase,
|
|
90
|
+
"heartbeat": time.time(),
|
|
91
|
+
}, sort_keys=True) + "\n",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def lease_dir(state_dir: Path) -> Path:
|
|
96
|
+
return state_dir / LEASE_DIR_NAME
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def lease_owner(state_dir: Path) -> dict | None:
|
|
100
|
+
return read_object(lease_dir(state_dir) / LEASE_OWNER_NAME)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def valid_owner(owner: dict | None) -> bool:
|
|
104
|
+
if not owner:
|
|
105
|
+
return False
|
|
106
|
+
token = owner.get("token")
|
|
107
|
+
round_number = owner.get("round")
|
|
108
|
+
return bool(
|
|
109
|
+
isinstance(token, str) and OWNERSHIP_TOKEN.fullmatch(token)
|
|
110
|
+
and isinstance(round_number, int) and not isinstance(round_number, bool)
|
|
111
|
+
and round_number >= 1
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def acquire_lease_for_state(state_dir: Path, fallback_round: int | None = None) -> str | None:
|
|
116
|
+
lease = lease_dir(state_dir)
|
|
117
|
+
try:
|
|
118
|
+
lease.mkdir(mode=0o700)
|
|
119
|
+
except (FileExistsError, FileNotFoundError):
|
|
120
|
+
return None
|
|
121
|
+
try:
|
|
122
|
+
try:
|
|
123
|
+
round_number = int((state_dir / "next-round").read_text(encoding="utf-8").strip())
|
|
124
|
+
except (FileNotFoundError, OSError, UnicodeDecodeError, ValueError):
|
|
125
|
+
if fallback_round is None:
|
|
126
|
+
raise
|
|
127
|
+
round_number = fallback_round
|
|
128
|
+
if round_number < 1:
|
|
129
|
+
raise ValueError
|
|
130
|
+
token = secrets.token_hex(32)
|
|
131
|
+
atomic_write(
|
|
132
|
+
lease / LEASE_OWNER_NAME,
|
|
133
|
+
json.dumps({
|
|
134
|
+
"token": token,
|
|
135
|
+
"round": round_number,
|
|
136
|
+
"acquiredAt": time.time(),
|
|
137
|
+
}, sort_keys=True) + "\n",
|
|
138
|
+
)
|
|
139
|
+
fsync_directory(state_dir)
|
|
140
|
+
return token
|
|
141
|
+
except (FileNotFoundError, OSError, UnicodeDecodeError, ValueError):
|
|
142
|
+
shutil.rmtree(lease, ignore_errors=True)
|
|
143
|
+
return None
|
|
144
|
+
except BaseException:
|
|
145
|
+
shutil.rmtree(lease, ignore_errors=True)
|
|
146
|
+
raise
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def release_lease_for_state(state_dir: Path, token: str) -> bool:
|
|
150
|
+
lease = lease_dir(state_dir)
|
|
151
|
+
owner = lease_owner(state_dir)
|
|
152
|
+
if not valid_owner(owner) or owner.get("token") != token:
|
|
153
|
+
return False
|
|
154
|
+
try:
|
|
155
|
+
remove_durable(lease / LEASE_OWNER_NAME)
|
|
156
|
+
lease.rmdir()
|
|
157
|
+
fsync_directory(state_dir)
|
|
158
|
+
except OSError:
|
|
159
|
+
return False
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def remove_runtime_if_owned(state_dir: Path, token: str, round_number: int) -> bool:
|
|
164
|
+
runtime_path = state_dir / "runtime.json"
|
|
165
|
+
runtime = read_object(runtime_path)
|
|
166
|
+
if not runtime or runtime.get("token") != token or runtime.get("round") != round_number:
|
|
167
|
+
return False
|
|
168
|
+
remove_durable(runtime_path)
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def matching_cancel_request(state_dir: Path, token: str, round_number: int) -> bool:
|
|
173
|
+
request = read_object(state_dir / "cancel.request")
|
|
174
|
+
return bool(
|
|
175
|
+
request
|
|
176
|
+
and request.get("token") == token
|
|
177
|
+
and request.get("round") == round_number
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def redact(data: bytes) -> bytes:
|
|
182
|
+
text = data.decode("utf-8", errors="replace")
|
|
183
|
+
for pattern, replacement in REDACTIONS:
|
|
184
|
+
text = pattern.sub(replacement, text)
|
|
185
|
+
return text.encode()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def terminate_group(process: subprocess.Popen[bytes]) -> None:
|
|
189
|
+
try:
|
|
190
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
191
|
+
except ProcessLookupError:
|
|
192
|
+
return
|
|
193
|
+
deadline = time.monotonic() + 0.3
|
|
194
|
+
while time.monotonic() < deadline:
|
|
195
|
+
try:
|
|
196
|
+
os.killpg(process.pid, 0)
|
|
197
|
+
except ProcessLookupError:
|
|
198
|
+
return
|
|
199
|
+
time.sleep(0.02)
|
|
200
|
+
try:
|
|
201
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
202
|
+
except ProcessLookupError:
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def parse_events(stdout: bytes) -> tuple[bool, str | None, str | None]:
|
|
207
|
+
malformed = False
|
|
208
|
+
thread_id = None
|
|
209
|
+
verdict = None
|
|
210
|
+
for raw_line in stdout.decode("utf-8", errors="replace").splitlines():
|
|
211
|
+
if not raw_line.strip():
|
|
212
|
+
continue
|
|
213
|
+
try:
|
|
214
|
+
event = json.loads(raw_line)
|
|
215
|
+
except json.JSONDecodeError:
|
|
216
|
+
malformed = True
|
|
217
|
+
continue
|
|
218
|
+
if not isinstance(event, dict):
|
|
219
|
+
malformed = True
|
|
220
|
+
continue
|
|
221
|
+
event_type = event.get("type")
|
|
222
|
+
if not isinstance(event_type, str):
|
|
223
|
+
malformed = True
|
|
224
|
+
continue
|
|
225
|
+
if "item" in event and not isinstance(event["item"], dict):
|
|
226
|
+
malformed = True
|
|
227
|
+
continue
|
|
228
|
+
item = event.get("item", {})
|
|
229
|
+
if "item" in event and not isinstance(item.get("type"), str):
|
|
230
|
+
malformed = True
|
|
231
|
+
continue
|
|
232
|
+
if event_type == "thread.started":
|
|
233
|
+
candidate = event.get("thread_id")
|
|
234
|
+
if not isinstance(candidate, str):
|
|
235
|
+
malformed = True
|
|
236
|
+
else:
|
|
237
|
+
thread_id = candidate
|
|
238
|
+
if event_type == "item.completed" and item.get("type") == "agent_message":
|
|
239
|
+
candidate = item.get("text")
|
|
240
|
+
if not isinstance(candidate, str):
|
|
241
|
+
malformed = True
|
|
242
|
+
else:
|
|
243
|
+
verdict = candidate or None
|
|
244
|
+
return malformed, thread_id, verdict
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def has_valid_thread_start(stdout: bytes) -> bool:
|
|
248
|
+
_, thread_id, _ = parse_events(stdout)
|
|
249
|
+
return thread_id is not None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def classification(reason: str | None, returncode: int, stdout: bytes) -> dict:
|
|
253
|
+
malformed, thread_id, verdict = parse_events(stdout)
|
|
254
|
+
signal_name = signal.Signals(-returncode).name if returncode < 0 else None
|
|
255
|
+
if reason == "cancelled":
|
|
256
|
+
status = "CANCELLED"
|
|
257
|
+
elif reason == "hung":
|
|
258
|
+
status = "HUNG"
|
|
259
|
+
elif reason == "timeout":
|
|
260
|
+
status = "TIMEOUT"
|
|
261
|
+
elif returncode < 0:
|
|
262
|
+
status = "SIGNALLED"
|
|
263
|
+
elif returncode != 0:
|
|
264
|
+
status = "EXEC_FAILED"
|
|
265
|
+
elif malformed:
|
|
266
|
+
status = "MALFORMED-JSON"
|
|
267
|
+
elif not thread_id:
|
|
268
|
+
status = "NO-THREAD"
|
|
269
|
+
elif not verdict:
|
|
270
|
+
status = "NO-VERDICT"
|
|
271
|
+
else:
|
|
272
|
+
status = "OK"
|
|
273
|
+
return {
|
|
274
|
+
"status": status,
|
|
275
|
+
"threadId": thread_id,
|
|
276
|
+
"verdict": verdict,
|
|
277
|
+
"originalExitStatus": returncode if returncode >= 0 else None,
|
|
278
|
+
"signal": signal_name,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def drain_process(args: argparse.Namespace, process: subprocess.Popen[bytes],
|
|
283
|
+
token: str) -> tuple[bytes, int, str | None]:
|
|
284
|
+
selector = selectors.DefaultSelector()
|
|
285
|
+
buffers = {"stdout": bytearray(), "stderr": bytearray()}
|
|
286
|
+
stdout_path = Path(args.state_dir) / f"round-{args.round}.stdout.jsonl"
|
|
287
|
+
stderr_path = Path(args.state_dir) / f"round-{args.round}.stderr.log"
|
|
288
|
+
state_dir = Path(args.state_dir)
|
|
289
|
+
started_at = time.monotonic()
|
|
290
|
+
last_pre_thread_activity = started_at
|
|
291
|
+
last_heartbeat = 0.0
|
|
292
|
+
saw_thread = False
|
|
293
|
+
reason = None
|
|
294
|
+
terminated = False
|
|
295
|
+
with stdout_path.open("xb") as stdout_file, stderr_path.open("xb") as stderr_file:
|
|
296
|
+
for stream, name in ((process.stdout, "stdout"), (process.stderr, "stderr")):
|
|
297
|
+
os.set_blocking(stream.fileno(), False)
|
|
298
|
+
selector.register(stream, selectors.EVENT_READ, name)
|
|
299
|
+
while selector.get_map():
|
|
300
|
+
now = time.monotonic()
|
|
301
|
+
if now - last_heartbeat >= HEARTBEAT_INTERVAL_SECONDS:
|
|
302
|
+
publish_runtime(state_dir, token, args.round, "running")
|
|
303
|
+
last_heartbeat = now
|
|
304
|
+
if matching_cancel_request(state_dir, token, args.round) or CANCEL_REQUESTED:
|
|
305
|
+
reason = "cancelled"
|
|
306
|
+
elif now - started_at >= args.timeout:
|
|
307
|
+
reason = "timeout"
|
|
308
|
+
elif not saw_thread and now - last_pre_thread_activity >= args.probe_timeout:
|
|
309
|
+
reason = "hung"
|
|
310
|
+
if reason and not terminated:
|
|
311
|
+
terminate_group(process)
|
|
312
|
+
terminated = True
|
|
313
|
+
for key, _ in selector.select(timeout=0.03):
|
|
314
|
+
try:
|
|
315
|
+
chunk = os.read(key.fileobj.fileno(), 65_536)
|
|
316
|
+
except BlockingIOError:
|
|
317
|
+
continue
|
|
318
|
+
if not chunk:
|
|
319
|
+
selector.unregister(key.fileobj)
|
|
320
|
+
continue
|
|
321
|
+
if not saw_thread:
|
|
322
|
+
last_pre_thread_activity = time.monotonic()
|
|
323
|
+
buffers[key.data].extend(chunk)
|
|
324
|
+
if key.data == "stdout":
|
|
325
|
+
stdout_file.write(chunk)
|
|
326
|
+
if has_valid_thread_start(bytes(buffers["stdout"])):
|
|
327
|
+
saw_thread = True
|
|
328
|
+
if process.poll() is not None and not selector.get_map():
|
|
329
|
+
break
|
|
330
|
+
stderr_file.write(redact(bytes(buffers["stderr"])))
|
|
331
|
+
stdout_file.flush()
|
|
332
|
+
stderr_file.flush()
|
|
333
|
+
os.fsync(stdout_file.fileno())
|
|
334
|
+
os.fsync(stderr_file.fileno())
|
|
335
|
+
return bytes(buffers["stdout"]), process.wait(), reason
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def settle_before_publication(args: argparse.Namespace, token: str,
|
|
339
|
+
reason: str | None) -> str | None:
|
|
340
|
+
state_dir = Path(args.state_dir)
|
|
341
|
+
publish_runtime(state_dir, token, args.round, "settling")
|
|
342
|
+
marker = os.environ.get("CODEX_EXEC_TEST_SETTLE_MARKER")
|
|
343
|
+
if marker:
|
|
344
|
+
atomic_write(Path(marker), token + "\n")
|
|
345
|
+
deadline = time.monotonic() + 2.0
|
|
346
|
+
while time.monotonic() < deadline:
|
|
347
|
+
publish_runtime(state_dir, token, args.round, "settling")
|
|
348
|
+
if matching_cancel_request(state_dir, token, args.round) or CANCEL_REQUESTED:
|
|
349
|
+
return "cancelled"
|
|
350
|
+
time.sleep(0.02)
|
|
351
|
+
if matching_cancel_request(state_dir, token, args.round) or CANCEL_REQUESTED:
|
|
352
|
+
return "cancelled"
|
|
353
|
+
return reason
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def publish_result(args: argparse.Namespace, token: str, result: dict) -> None:
|
|
357
|
+
state_dir = Path(args.state_dir)
|
|
358
|
+
if result.get("threadId"):
|
|
359
|
+
atomic_write(state_dir / "thread-id", result["threadId"] + "\n")
|
|
360
|
+
atomic_write(state_dir / "next-round", f"{args.round + 1}\n")
|
|
361
|
+
result_path = state_dir / f"round-{args.round}.result.json"
|
|
362
|
+
exclusive_write(result_path, json.dumps(result, sort_keys=True) + "\n")
|
|
363
|
+
atomic_write(state_dir / "latest", result_path.name + "\n")
|
|
364
|
+
if not remove_runtime_if_owned(state_dir, token, args.round):
|
|
365
|
+
raise RuntimeError("runtime ownership changed before terminal publication")
|
|
366
|
+
print(json.dumps(result, sort_keys=True), flush=True)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def run_round(args: argparse.Namespace) -> int:
|
|
370
|
+
state_dir = Path(args.state_dir)
|
|
371
|
+
command = args.command[1:] if args.command[:1] == ["--"] else args.command
|
|
372
|
+
if not command:
|
|
373
|
+
raise SystemExit("process command is required")
|
|
374
|
+
prompt_path = state_dir / f"round-{args.round}.prompt.txt"
|
|
375
|
+
if prompt_path.exists():
|
|
376
|
+
raise SystemExit(f"round {args.round} already exists")
|
|
377
|
+
prompt_path.write_bytes(Path(args.prompt_file).read_bytes())
|
|
378
|
+
signal.signal(signal.SIGINT, request_cancel)
|
|
379
|
+
signal.signal(signal.SIGTERM, request_cancel)
|
|
380
|
+
token = args.lease_token
|
|
381
|
+
owner = lease_owner(state_dir)
|
|
382
|
+
if not valid_owner(owner) or owner.get("token") != token or owner.get("round") != args.round:
|
|
383
|
+
raise SystemExit("round lease ownership mismatch")
|
|
384
|
+
if (state_dir / "runtime.json").exists():
|
|
385
|
+
raise SystemExit("run already has an active runtime")
|
|
386
|
+
with prompt_path.open("rb") as prompt:
|
|
387
|
+
process = subprocess.Popen(
|
|
388
|
+
command,
|
|
389
|
+
stdin=prompt,
|
|
390
|
+
stdout=subprocess.PIPE,
|
|
391
|
+
stderr=subprocess.PIPE,
|
|
392
|
+
start_new_session=True,
|
|
393
|
+
)
|
|
394
|
+
try:
|
|
395
|
+
pid_marker = os.environ.get("CODEX_EXEC_TEST_CHILD_PID_FILE")
|
|
396
|
+
if pid_marker:
|
|
397
|
+
atomic_write(Path(pid_marker), f"{process.pid}\n")
|
|
398
|
+
if os.environ.get("CODEX_EXEC_TEST_RUNTIME_WRITE_FAIL") == "1":
|
|
399
|
+
raise OSError("injected runtime publication failure")
|
|
400
|
+
publish_runtime(state_dir, token, args.round, "running")
|
|
401
|
+
stdout, returncode, reason = drain_process(args, process, token)
|
|
402
|
+
except BaseException:
|
|
403
|
+
terminate_group(process)
|
|
404
|
+
process.wait()
|
|
405
|
+
remove_runtime_if_owned(state_dir, token, args.round)
|
|
406
|
+
raise
|
|
407
|
+
reason = settle_before_publication(args, token, reason)
|
|
408
|
+
result = classification(reason, returncode, stdout)
|
|
409
|
+
result.update({
|
|
410
|
+
"runId": (state_dir / "run-id").read_text().strip(),
|
|
411
|
+
"stateDir": str(state_dir),
|
|
412
|
+
"round": args.round,
|
|
413
|
+
"profile": args.profile,
|
|
414
|
+
"sandbox": args.sandbox,
|
|
415
|
+
})
|
|
416
|
+
publish_result(args, token, result)
|
|
417
|
+
return 0 if result["status"] == "OK" else 1
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def lease_acquire(args: argparse.Namespace) -> int:
|
|
421
|
+
state_dir = Path(args.state_dir)
|
|
422
|
+
token = acquire_lease_for_state(state_dir)
|
|
423
|
+
if not token:
|
|
424
|
+
return 1
|
|
425
|
+
print(token, flush=True)
|
|
426
|
+
return 0
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def lease_release(args: argparse.Namespace) -> int:
|
|
430
|
+
if os.environ.get("CODEX_EXEC_TEST_LEASE_RELEASE_FAIL") == "1":
|
|
431
|
+
return 1
|
|
432
|
+
return 0 if release_lease_for_state(Path(args.state_dir), args.token) else 1
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def abort_claim(args: argparse.Namespace) -> int:
|
|
436
|
+
state_dir = Path(args.state_dir)
|
|
437
|
+
deadline = time.monotonic() + CANCEL_WAIT_SECONDS
|
|
438
|
+
while time.monotonic() < deadline:
|
|
439
|
+
runtime_path = state_dir / "runtime.json"
|
|
440
|
+
runtime = read_object(runtime_path)
|
|
441
|
+
runtime_is_live = live_runtime(
|
|
442
|
+
runtime_path, time.time(), CANCEL_WAIT_SECONDS,
|
|
443
|
+
)
|
|
444
|
+
token = None if runtime_is_live else acquire_lease_for_state(state_dir)
|
|
445
|
+
if token:
|
|
446
|
+
print(token, flush=True)
|
|
447
|
+
return 0
|
|
448
|
+
ownership = runtime or lease_owner(state_dir)
|
|
449
|
+
if not valid_owner(ownership):
|
|
450
|
+
time.sleep(0.02)
|
|
451
|
+
continue
|
|
452
|
+
token = ownership["token"]
|
|
453
|
+
round_number = ownership["round"]
|
|
454
|
+
atomic_write(
|
|
455
|
+
state_dir / "cancel.request",
|
|
456
|
+
json.dumps({
|
|
457
|
+
"token": token,
|
|
458
|
+
"round": round_number,
|
|
459
|
+
"requestedAt": time.time(),
|
|
460
|
+
}, sort_keys=True) + "\n",
|
|
461
|
+
)
|
|
462
|
+
time.sleep(0.02)
|
|
463
|
+
return 1
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def live_lease(state_dir: Path, now: float, stale_seconds: float) -> bool:
|
|
467
|
+
owner = lease_owner(state_dir)
|
|
468
|
+
if not valid_owner(owner):
|
|
469
|
+
return False
|
|
470
|
+
acquired_at = owner.get("acquiredAt")
|
|
471
|
+
if not isinstance(acquired_at, (int, float)) or isinstance(acquired_at, bool):
|
|
472
|
+
return False
|
|
473
|
+
return math.isfinite(acquired_at) and 0 <= now - acquired_at < stale_seconds
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def claim_cleanup_lease(state_dir: Path, now: float, stale_seconds: float) -> str | None:
|
|
477
|
+
token = acquire_lease_for_state(state_dir, fallback_round=1)
|
|
478
|
+
if token:
|
|
479
|
+
return token
|
|
480
|
+
if live_lease(state_dir, now, stale_seconds):
|
|
481
|
+
return None
|
|
482
|
+
if live_runtime(state_dir / "runtime.json", now, stale_seconds):
|
|
483
|
+
return None
|
|
484
|
+
lease = lease_dir(state_dir)
|
|
485
|
+
quarantine = state_dir / f".stale-lease.{secrets.token_hex(8)}"
|
|
486
|
+
try:
|
|
487
|
+
lease.rename(quarantine)
|
|
488
|
+
except (FileNotFoundError, OSError):
|
|
489
|
+
return None
|
|
490
|
+
try:
|
|
491
|
+
return acquire_lease_for_state(state_dir, fallback_round=1)
|
|
492
|
+
finally:
|
|
493
|
+
shutil.rmtree(quarantine, ignore_errors=True)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def live_runtime(runtime_path: Path, now: float, stale_seconds: float) -> bool:
|
|
497
|
+
runtime = read_object(runtime_path)
|
|
498
|
+
if not runtime:
|
|
499
|
+
return False
|
|
500
|
+
token = runtime.get("token")
|
|
501
|
+
round_number = runtime.get("round")
|
|
502
|
+
phase = runtime.get("phase")
|
|
503
|
+
heartbeat = runtime.get("heartbeat")
|
|
504
|
+
return bool(
|
|
505
|
+
isinstance(token, str) and OWNERSHIP_TOKEN.fullmatch(token)
|
|
506
|
+
and isinstance(round_number, int) and not isinstance(round_number, bool) and round_number >= 1
|
|
507
|
+
and phase in {"running", "settling"}
|
|
508
|
+
and isinstance(heartbeat, (int, float)) and not isinstance(heartbeat, bool)
|
|
509
|
+
and math.isfinite(heartbeat)
|
|
510
|
+
and 0 <= now - heartbeat < stale_seconds
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def cleanup(args: argparse.Namespace) -> int:
|
|
515
|
+
if not math.isfinite(args.stale_seconds) or args.stale_seconds <= 0 or args.max_delete < 0:
|
|
516
|
+
return 2
|
|
517
|
+
root = Path(args.state_root)
|
|
518
|
+
now = time.time()
|
|
519
|
+
removed = 0
|
|
520
|
+
candidates = []
|
|
521
|
+
for state_dir in root.glob("codex-exec.*"):
|
|
522
|
+
try:
|
|
523
|
+
candidates.append((state_dir.stat().st_mtime, state_dir))
|
|
524
|
+
except FileNotFoundError:
|
|
525
|
+
continue
|
|
526
|
+
candidates.sort(key=lambda candidate: candidate[0])
|
|
527
|
+
marker = os.environ.get("CODEX_EXEC_TEST_CLEANUP_PAUSE_MARKER")
|
|
528
|
+
if marker:
|
|
529
|
+
atomic_write(Path(marker), "listed\n")
|
|
530
|
+
time.sleep(0.3)
|
|
531
|
+
for _, state_dir in candidates:
|
|
532
|
+
if removed >= args.max_delete:
|
|
533
|
+
break
|
|
534
|
+
try:
|
|
535
|
+
preclaim_mtime = state_dir.stat().st_mtime
|
|
536
|
+
if now - preclaim_mtime < args.stale_seconds:
|
|
537
|
+
continue
|
|
538
|
+
except (FileNotFoundError, OSError):
|
|
539
|
+
continue
|
|
540
|
+
token = claim_cleanup_lease(state_dir, now, args.stale_seconds)
|
|
541
|
+
if not token:
|
|
542
|
+
continue
|
|
543
|
+
deleted = False
|
|
544
|
+
try:
|
|
545
|
+
try:
|
|
546
|
+
valid = (state_dir / "run-id").read_text().strip() == state_dir.name.removeprefix("codex-exec.")
|
|
547
|
+
except (FileNotFoundError, OSError):
|
|
548
|
+
continue
|
|
549
|
+
protected = (
|
|
550
|
+
(state_dir / "debug-retain").exists()
|
|
551
|
+
or live_runtime(state_dir / "runtime.json", time.time(), args.stale_seconds)
|
|
552
|
+
)
|
|
553
|
+
if valid and now - preclaim_mtime >= args.stale_seconds and not protected:
|
|
554
|
+
try:
|
|
555
|
+
shutil.rmtree(state_dir)
|
|
556
|
+
except FileNotFoundError:
|
|
557
|
+
continue
|
|
558
|
+
deleted = True
|
|
559
|
+
removed += 1
|
|
560
|
+
finally:
|
|
561
|
+
if not deleted and state_dir.exists():
|
|
562
|
+
release_lease_for_state(state_dir, token)
|
|
563
|
+
return 0
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def parser() -> argparse.ArgumentParser:
|
|
567
|
+
root = argparse.ArgumentParser()
|
|
568
|
+
commands = root.add_subparsers(dest="action", required=True)
|
|
569
|
+
run = commands.add_parser("run")
|
|
570
|
+
run.add_argument("--state-dir", required=True)
|
|
571
|
+
run.add_argument("--round", required=True, type=int)
|
|
572
|
+
run.add_argument("--profile", required=True)
|
|
573
|
+
run.add_argument("--sandbox", required=True)
|
|
574
|
+
run.add_argument("--timeout", required=True, type=float)
|
|
575
|
+
run.add_argument("--probe-timeout", required=True, type=float)
|
|
576
|
+
run.add_argument("--prompt-file", required=True)
|
|
577
|
+
run.add_argument("--lease-token", required=True)
|
|
578
|
+
run.add_argument("command", nargs=argparse.REMAINDER)
|
|
579
|
+
acquire = commands.add_parser("lease-acquire")
|
|
580
|
+
acquire.add_argument("--state-dir", required=True)
|
|
581
|
+
release = commands.add_parser("lease-release")
|
|
582
|
+
release.add_argument("--state-dir", required=True)
|
|
583
|
+
release.add_argument("--token", required=True)
|
|
584
|
+
stop = commands.add_parser("abort-claim")
|
|
585
|
+
stop.add_argument("--state-dir", required=True)
|
|
586
|
+
stale = commands.add_parser("cleanup")
|
|
587
|
+
stale.add_argument("--state-root", required=True)
|
|
588
|
+
stale.add_argument("--stale-seconds", required=True, type=float)
|
|
589
|
+
stale.add_argument("--max-delete", required=True, type=int)
|
|
590
|
+
return root
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
if __name__ == "__main__":
|
|
594
|
+
parsed = parser().parse_args()
|
|
595
|
+
actions = {
|
|
596
|
+
"run": run_round,
|
|
597
|
+
"lease-acquire": lease_acquire,
|
|
598
|
+
"lease-release": lease_release,
|
|
599
|
+
"abort-claim": abort_claim,
|
|
600
|
+
"cleanup": cleanup,
|
|
601
|
+
}
|
|
602
|
+
sys.exit(actions[parsed.action](parsed))
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Find GitHub issues by one allowlisted, exact-value identity marker."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
14
|
+
|
|
15
|
+
from board_config import ConfigError, load_board_config # noqa: E402
|
|
16
|
+
from marker_lib import ( # noqa: E402
|
|
17
|
+
UnknownMarkerKind, find_by_marker, reconcile_after_create,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
GH_TIMEOUT_SECONDS = 30
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _gh(args: list[str]) -> str:
|
|
24
|
+
try:
|
|
25
|
+
result = subprocess.run(
|
|
26
|
+
["gh", *args], capture_output=True, text=True,
|
|
27
|
+
timeout=GH_TIMEOUT_SECONDS,
|
|
28
|
+
)
|
|
29
|
+
except subprocess.TimeoutExpired as exc:
|
|
30
|
+
raise RuntimeError(
|
|
31
|
+
f"gh api timed out after {GH_TIMEOUT_SECONDS}s") from exc
|
|
32
|
+
if result.returncode != 0:
|
|
33
|
+
raise RuntimeError(result.stderr.strip() or "gh api failed")
|
|
34
|
+
return result.stdout
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
38
|
+
parser = argparse.ArgumentParser(
|
|
39
|
+
description="Find issues carrying an exact allowlisted identity marker")
|
|
40
|
+
parser.add_argument("--kind", required=True, help="marker kind")
|
|
41
|
+
parser.add_argument("--slug", required=True, help="exact marker value")
|
|
42
|
+
parser.add_argument("--created", type=int,
|
|
43
|
+
help="post-create reconciliation for this issue number")
|
|
44
|
+
return parser
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main(argv: list[str] | None = None, *, repo: str | None = None,
|
|
48
|
+
gh: Callable[[list[str]], str] = _gh) -> int:
|
|
49
|
+
args = build_parser().parse_args(argv)
|
|
50
|
+
try:
|
|
51
|
+
target_repo = repo or load_board_config()["repo"]
|
|
52
|
+
if args.created is None:
|
|
53
|
+
result = find_by_marker(target_repo, args.kind, args.slug, gh)
|
|
54
|
+
else:
|
|
55
|
+
result = reconcile_after_create(
|
|
56
|
+
target_repo, args.kind, args.slug, args.created, gh)
|
|
57
|
+
except UnknownMarkerKind as exc:
|
|
58
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
59
|
+
return 2
|
|
60
|
+
except (ConfigError, RuntimeError, ValueError, json.JSONDecodeError) as exc:
|
|
61
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
62
|
+
return 1
|
|
63
|
+
print(json.dumps(result, separators=(",", ":")))
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
sys.exit(main())
|