@ikon85/agent-workflow-kit 0.27.1 → 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.
Files changed (79) hide show
  1. package/.agents/skills/ask-matt/SKILL.md +2 -2
  2. package/.agents/skills/board-to-waves/SKILL.md +13 -3
  3. package/.agents/skills/grill-me/SKILL.md +1 -1
  4. package/.agents/skills/grill-with-docs/SKILL.md +1 -1
  5. package/.agents/skills/kit-update/SKILL.md +12 -0
  6. package/.agents/skills/orchestrate-wave/SKILL.md +68 -21
  7. package/.agents/skills/orchestrate-wave/references/builder-contract.md +6 -4
  8. package/.agents/skills/retro/SKILL.md +24 -1
  9. package/.agents/skills/scale-check/SKILL.md +2 -2
  10. package/.agents/skills/setup-workflow/SKILL.md +44 -1
  11. package/.agents/skills/setup-workflow/workflow-overview.md +5 -5
  12. package/.agents/skills/tdd/SKILL.md +68 -14
  13. package/.agents/skills/to-issues/SKILL.md +104 -29
  14. package/.agents/skills/to-prd/SKILL.md +13 -3
  15. package/.agents/skills/to-waves/SKILL.md +16 -4
  16. package/.agents/skills/verify-spike/SKILL.md +1 -1
  17. package/.agents/skills/wrapup/SKILL.md +14 -3
  18. package/.claude/hooks/drift-guard.py +5 -2
  19. package/.claude/hooks/kit-origin-edit-hint.py +64 -0
  20. package/.claude/skills/ask-matt/SKILL.md +2 -2
  21. package/.claude/skills/board-to-waves/SKILL.md +13 -3
  22. package/.claude/skills/codex-build/SKILL.md +69 -23
  23. package/.claude/skills/codex-review/SKILL.md +47 -37
  24. package/.claude/skills/grill-me/SKILL.md +1 -1
  25. package/.claude/skills/grill-me-codex/SKILL.md +45 -34
  26. package/.claude/skills/grill-with-docs/SKILL.md +1 -1
  27. package/.claude/skills/grill-with-docs-codex/SKILL.md +46 -34
  28. package/.claude/skills/kit-update/SKILL.md +12 -0
  29. package/.claude/skills/orchestrate-wave/SKILL.md +68 -21
  30. package/.claude/skills/orchestrate-wave/references/builder-contract.md +6 -4
  31. package/.claude/skills/retro/SKILL.md +23 -0
  32. package/.claude/skills/scale-check/SKILL.md +2 -2
  33. package/.claude/skills/setup-workflow/SKILL.md +44 -1
  34. package/.claude/skills/setup-workflow/workflow-overview.md +5 -5
  35. package/.claude/skills/skill-manifest.json +1 -1
  36. package/.claude/skills/tdd/SKILL.md +68 -14
  37. package/.claude/skills/to-issues/SKILL.md +104 -29
  38. package/.claude/skills/to-prd/SKILL.md +13 -3
  39. package/.claude/skills/to-waves/SKILL.md +16 -4
  40. package/.claude/skills/verify-spike/SKILL.md +1 -1
  41. package/.claude/skills/wrapup/SKILL.md +14 -3
  42. package/README.md +70 -7
  43. package/agent-workflow-kit.package.json +99 -43
  44. package/docs/adr/0001-consumer-divergence-policy.md +49 -0
  45. package/docs/agents/wave-anchor-template.md +1 -1
  46. package/docs/research/wave-152-consumer-acceptance.md +98 -0
  47. package/package.json +1 -1
  48. package/scripts/board-sync.py +187 -12
  49. package/scripts/build-kit.test.mjs +42 -1
  50. package/scripts/codex-exec-scenarios/fake-codex.mjs +152 -0
  51. package/scripts/codex-exec.sh +566 -0
  52. package/scripts/codex-exec.test.mjs +815 -0
  53. package/scripts/codex_proc.py +602 -0
  54. package/scripts/find-by-marker.py +68 -0
  55. package/scripts/marker_lib.py +88 -0
  56. package/scripts/pr-body-check.py +34 -6
  57. package/scripts/pr_body_e2e.py +83 -0
  58. package/scripts/render-anchor.py +111 -0
  59. package/scripts/test_board_sync.py +208 -0
  60. package/scripts/test_census_backstop.py +56 -3
  61. package/scripts/test_marker_lib.py +154 -0
  62. package/scripts/test_orchestrate_wave_contract.py +116 -0
  63. package/scripts/test_pr_body_check.py +245 -0
  64. package/scripts/test_render_anchor.py +267 -0
  65. package/scripts/test_retro_wrapup_contract.py +117 -0
  66. package/scripts/test_skill_codex_exec_lifecycle.py +123 -0
  67. package/scripts/test_tdd_contract.py +78 -0
  68. package/src/cli.mjs +44 -8
  69. package/src/commands/diff.mjs +5 -2
  70. package/src/commands/init.mjs +12 -0
  71. package/src/commands/own.mjs +16 -0
  72. package/src/commands/uninstall.mjs +8 -1
  73. package/src/commands/update.mjs +37 -9
  74. package/src/lib/bundle.mjs +14 -0
  75. package/src/lib/consumerPath.mjs +30 -0
  76. package/src/lib/manifest.mjs +18 -0
  77. package/src/lib/ownedDiff.mjs +88 -0
  78. package/src/lib/updateCandidate.mjs +14 -0
  79. package/src/lib/updateReconcile.mjs +45 -6
@@ -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())