loki-mode 7.129.5 → 8.0.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 (74) hide show
  1. package/README.md +81 -5
  2. package/SKILL.md +66 -5
  3. package/VERSION +1 -1
  4. package/autonomy/app-runner.sh +37 -0
  5. package/autonomy/completion-council.sh +862 -28
  6. package/autonomy/council-v2.sh +39 -0
  7. package/autonomy/crash.sh +3 -2
  8. package/autonomy/grill.sh +42 -0
  9. package/autonomy/hooks/validate-bash.sh +301 -4
  10. package/autonomy/lib/cockpit-render.sh +8 -2
  11. package/autonomy/lib/cr-rematerialize.py +101 -4
  12. package/autonomy/lib/deadline.py +957 -0
  13. package/autonomy/lib/dependency-setup.sh +205 -0
  14. package/autonomy/lib/done-recognition.sh +45 -0
  15. package/autonomy/lib/efficiency_cost.py +13 -6
  16. package/autonomy/lib/no_mock_scan.py +793 -0
  17. package/autonomy/lib/prd-enrich.sh +42 -0
  18. package/autonomy/lib/proof-analytics-props.py +69 -0
  19. package/autonomy/lib/proof-generator.py +282 -95
  20. package/autonomy/lib/proof-template.html +15 -12
  21. package/autonomy/lib/proof-verify.py +151 -102
  22. package/autonomy/lib/requirements_contract.py +848 -0
  23. package/autonomy/lib/sdk-mode.sh +103 -0
  24. package/autonomy/lib/secret-scan.sh +139 -0
  25. package/autonomy/lib/spec-expand.sh +208 -0
  26. package/autonomy/lib/tree_digest.py +266 -0
  27. package/autonomy/lib/voter-agents.sh +58 -8
  28. package/autonomy/lib/workspace_diff.py +124 -0
  29. package/autonomy/loki +189 -17
  30. package/autonomy/playwright-verify.sh +501 -0
  31. package/autonomy/prd-checklist.sh +17 -8
  32. package/autonomy/provider-offer.sh +111 -0
  33. package/autonomy/run.sh +4417 -676
  34. package/autonomy/sandbox.sh +42 -0
  35. package/autonomy/spec-interrogation.sh +148 -5
  36. package/autonomy/spec.sh +118 -1
  37. package/autonomy/telemetry.sh +133 -7
  38. package/autonomy/verify.sh +107 -0
  39. package/bin/loki +110 -3
  40. package/completions/_loki +10 -0
  41. package/completions/loki.bash +1 -1
  42. package/dashboard/__init__.py +1 -1
  43. package/dashboard/audit.py +96 -7
  44. package/dashboard/build_supervisor.py +1619 -0
  45. package/dashboard/control.py +8 -0
  46. package/dashboard/prompt_optimizer.py +7 -0
  47. package/dashboard/server.py +577 -22
  48. package/dashboard/static/trust.html +1 -1
  49. package/docs/ARCHITECTURE-OVERVIEW.md +207 -0
  50. package/docs/AUTONOMI-ECOSYSTEM.md +107 -0
  51. package/docs/INSTALLATION.md +19 -2
  52. package/docs/MODEL-EQUIVALENCE-HARNESS-PLAN.md +70 -0
  53. package/docs/PLANS-INDEX.md +33 -0
  54. package/docs/PRIVACY.md +29 -1
  55. package/docs/SIGNED-RECEIPTS.md +102 -0
  56. package/docs/SONNET5-DEFAULT-PLAN.md +1 -1
  57. package/docs/V8-ACCEPTANCE-TRIAGE-2026-07-24.md +114 -0
  58. package/docs/V8-AGENT-SDK-PLAN.md +692 -0
  59. package/docs/V8-COMPLEXITY-AUDIT-2026-07-24.md +45 -0
  60. package/docs/V8-MAJOR-RELEASE-PLAN.md +137 -0
  61. package/docs/V8-OVERNIGHT-PLAN-2026-07-25.md +82 -0
  62. package/docs/V8-RUNTIME-TRUTH-2026-07-25.md +232 -0
  63. package/docs/V8-SDK-RESEARCH-RAW.md +129 -0
  64. package/docs/alternative-installations.md +4 -4
  65. package/loki-ts/dist/loki.js +674 -285
  66. package/loki-ts/package.json +2 -0
  67. package/mcp/__init__.py +1 -1
  68. package/package.json +7 -6
  69. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  70. package/providers/claude.sh +75 -0
  71. package/providers/codex.sh +85 -13
  72. package/references/sdk-mode.md +106 -0
  73. package/skills/model-selection.md +1 -1
  74. package/skills/quality-gates.md +22 -0
@@ -0,0 +1,957 @@
1
+ #!/usr/bin/env python3
2
+ """Run one command inside a reconciled process session with idle and hard deadlines."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import ctypes
7
+ import errno
8
+ import json
9
+ import os
10
+ import select
11
+ import selectors
12
+ import secrets
13
+ import signal
14
+ import stat
15
+ import subprocess
16
+ import sys
17
+ import time
18
+ from pathlib import Path
19
+ from typing import BinaryIO, NamedTuple
20
+
21
+
22
+ TIMEOUT_EXIT = 124
23
+ INTERNAL_EXIT = 125
24
+ _DARWIN_LIBPROC = None
25
+ _DARWIN_LIBC = None
26
+ _LINEAGE_POLL_SECONDS = 0.02
27
+ _QUIESCENCE_SECONDS = 0.20
28
+ _LINEAGE_DISCOVERY_WINDOW_SECONDS = 0.20
29
+ _RECONCILE_SCHEDULING_MARGIN_SECONDS = 0.30
30
+ _LINEAGE_ENV_NAME = "LOKI_DEADLINE_LINEAGE"
31
+ _CONTROL_ENV_NAME = "LOKI_DEADLINE_CONTROL_FILE"
32
+ _EXEC_BOOTSTRAP = """
33
+ import os
34
+ import sys
35
+
36
+ gate = int(sys.argv[1])
37
+ released = os.read(gate, 1)
38
+ os.close(gate)
39
+ if released != b"1":
40
+ raise SystemExit(125)
41
+ try:
42
+ os.execvpe(sys.argv[2], sys.argv[2:], os.environ)
43
+ except FileNotFoundError:
44
+ raise SystemExit(127)
45
+ except PermissionError:
46
+ raise SystemExit(126)
47
+ except OSError:
48
+ raise SystemExit(125)
49
+ """
50
+
51
+
52
+ class _DarwinProcBsdInfo(ctypes.Structure):
53
+ _fields_ = [
54
+ ("pbi_flags", ctypes.c_uint32),
55
+ ("pbi_status", ctypes.c_uint32),
56
+ ("pbi_xstatus", ctypes.c_uint32),
57
+ ("pbi_pid", ctypes.c_uint32),
58
+ ("pbi_ppid", ctypes.c_uint32),
59
+ ("pbi_uid", ctypes.c_uint32),
60
+ ("pbi_gid", ctypes.c_uint32),
61
+ ("pbi_ruid", ctypes.c_uint32),
62
+ ("pbi_rgid", ctypes.c_uint32),
63
+ ("pbi_svuid", ctypes.c_uint32),
64
+ ("pbi_svgid", ctypes.c_uint32),
65
+ ("rfu_1", ctypes.c_uint32),
66
+ ("pbi_comm", ctypes.c_char * 16),
67
+ ("pbi_name", ctypes.c_char * 32),
68
+ ("pbi_nfiles", ctypes.c_uint32),
69
+ ("pbi_pgid", ctypes.c_uint32),
70
+ ("pbi_pjobc", ctypes.c_uint32),
71
+ ("e_tdev", ctypes.c_uint32),
72
+ ("e_tpgid", ctypes.c_uint32),
73
+ ("pbi_nice", ctypes.c_int32),
74
+ ("pbi_start_tvsec", ctypes.c_uint64),
75
+ ("pbi_start_tvusec", ctypes.c_uint64),
76
+ ]
77
+
78
+
79
+ class _DarwinProcFdInfo(ctypes.Structure):
80
+ _fields_ = [
81
+ ("proc_fd", ctypes.c_int32),
82
+ ("proc_fdtype", ctypes.c_uint32),
83
+ ]
84
+
85
+
86
+ class _DarwinProcFileInfo(ctypes.Structure):
87
+ _fields_ = [
88
+ ("fi_openflags", ctypes.c_uint32),
89
+ ("fi_status", ctypes.c_uint32),
90
+ ("fi_offset", ctypes.c_int64),
91
+ ("fi_type", ctypes.c_int32),
92
+ ("fi_guardflags", ctypes.c_uint32),
93
+ ]
94
+
95
+
96
+ class _DarwinVinfoStat(ctypes.Structure):
97
+ _fields_ = [
98
+ ("vst_dev", ctypes.c_uint32),
99
+ ("vst_mode", ctypes.c_uint16),
100
+ ("vst_nlink", ctypes.c_uint16),
101
+ ("vst_ino", ctypes.c_uint64),
102
+ ("vst_uid", ctypes.c_uint32),
103
+ ("vst_gid", ctypes.c_uint32),
104
+ ("vst_times", ctypes.c_int64 * 10),
105
+ ("vst_blksize", ctypes.c_int32),
106
+ ("vst_flags", ctypes.c_uint32),
107
+ ("vst_gen", ctypes.c_uint32),
108
+ ("vst_rdev", ctypes.c_uint32),
109
+ ("vst_qspare", ctypes.c_int64 * 2),
110
+ ]
111
+
112
+
113
+ class _DarwinPipeInfo(ctypes.Structure):
114
+ _fields_ = [
115
+ ("pipe_stat", _DarwinVinfoStat),
116
+ ("pipe_handle", ctypes.c_uint64),
117
+ ("pipe_peerhandle", ctypes.c_uint64),
118
+ ("pipe_status", ctypes.c_int32),
119
+ ("rfu_1", ctypes.c_int32),
120
+ ]
121
+
122
+
123
+ class _DarwinPipeFdInfo(ctypes.Structure):
124
+ _fields_ = [
125
+ ("pfi", _DarwinProcFileInfo),
126
+ ("pipeinfo", _DarwinPipeInfo),
127
+ ]
128
+
129
+
130
+ def _darwin_libproc():
131
+ global _DARWIN_LIBPROC
132
+ if _DARWIN_LIBPROC is None:
133
+ library = ctypes.CDLL("/usr/lib/libproc.dylib", use_errno=True)
134
+ library.proc_listallpids.argtypes = [
135
+ ctypes.POINTER(ctypes.c_int),
136
+ ctypes.c_int,
137
+ ]
138
+ library.proc_listallpids.restype = ctypes.c_int
139
+ library.proc_pidinfo.argtypes = [
140
+ ctypes.c_int,
141
+ ctypes.c_int,
142
+ ctypes.c_uint64,
143
+ ctypes.c_void_p,
144
+ ctypes.c_int,
145
+ ]
146
+ library.proc_pidinfo.restype = ctypes.c_int
147
+ library.proc_pidfdinfo.argtypes = [
148
+ ctypes.c_int,
149
+ ctypes.c_int,
150
+ ctypes.c_int,
151
+ ctypes.c_void_p,
152
+ ctypes.c_int,
153
+ ]
154
+ library.proc_pidfdinfo.restype = ctypes.c_int
155
+ _DARWIN_LIBPROC = library
156
+ return _DARWIN_LIBPROC
157
+
158
+
159
+ def _darwin_libc():
160
+ global _DARWIN_LIBC
161
+ if _DARWIN_LIBC is None:
162
+ library = ctypes.CDLL(None, use_errno=True)
163
+ library.sysctl.argtypes = [
164
+ ctypes.POINTER(ctypes.c_int),
165
+ ctypes.c_uint,
166
+ ctypes.c_void_p,
167
+ ctypes.POINTER(ctypes.c_size_t),
168
+ ctypes.c_void_p,
169
+ ctypes.c_size_t,
170
+ ]
171
+ library.sysctl.restype = ctypes.c_int
172
+ _DARWIN_LIBC = library
173
+ return _DARWIN_LIBC
174
+
175
+
176
+ def _candidate_pids() -> list[int]:
177
+ if sys.platform == "darwin":
178
+ library = _darwin_libproc()
179
+ count = library.proc_listallpids(None, 0)
180
+ if count <= 0:
181
+ raise RuntimeError("attempt process table query failed")
182
+ capacity = count + 64
183
+ while True:
184
+ values = (ctypes.c_int * capacity)()
185
+ read = library.proc_listallpids(values, ctypes.sizeof(values))
186
+ if read <= 0:
187
+ raise RuntimeError("attempt process table query failed")
188
+ if read < capacity:
189
+ return [pid for pid in values[:read] if pid > 0]
190
+ capacity *= 2
191
+ if sys.platform.startswith("linux"):
192
+ try:
193
+ return [int(name) for name in os.listdir("/proc") if name.isdigit()]
194
+ except OSError as exc:
195
+ raise RuntimeError("attempt process table query failed") from exc
196
+ raise RuntimeError("attempt process table query is unsupported")
197
+
198
+
199
+ class _ProcessRecord(NamedTuple):
200
+ pid: int
201
+ ppid: int
202
+ session_id: int
203
+ identity: str
204
+
205
+
206
+ def _process_record(pid: int) -> _ProcessRecord | None:
207
+ if sys.platform == "darwin":
208
+ info = _DarwinProcBsdInfo()
209
+ size = ctypes.sizeof(info)
210
+ read = _darwin_libproc().proc_pidinfo(pid, 3, 0, ctypes.byref(info), size)
211
+ if read != size or info.pbi_pid != pid or info.pbi_status == 5:
212
+ return None
213
+ try:
214
+ session_id = os.getsid(pid)
215
+ except OSError as exc:
216
+ if exc.errno in (errno.ESRCH, errno.EPERM, errno.EACCES):
217
+ return None
218
+ raise RuntimeError("attempt session identity check failed") from exc
219
+ return _ProcessRecord(
220
+ pid,
221
+ int(info.pbi_ppid),
222
+ session_id,
223
+ f"darwin:{info.pbi_start_tvsec}:{info.pbi_start_tvusec}",
224
+ )
225
+ try:
226
+ with open(
227
+ f"/proc/{pid}/stat", encoding="utf-8", errors="replace"
228
+ ) as handle:
229
+ raw = handle.read()
230
+ fields = raw.rsplit(")", 1)[1].strip().split()
231
+ if fields[0] == "Z":
232
+ return None
233
+ return _ProcessRecord(
234
+ pid,
235
+ int(fields[1]),
236
+ int(fields[3]),
237
+ f"proc:{fields[19]}",
238
+ )
239
+ except (OSError, IndexError, ValueError):
240
+ return None
241
+
242
+
243
+ def _process_identity(pid: int) -> str:
244
+ record = _process_record(pid)
245
+ return record.identity if record is not None else ""
246
+
247
+
248
+ def _process_snapshot() -> dict[int, _ProcessRecord]:
249
+ snapshot: dict[int, _ProcessRecord] = {}
250
+ for pid in _candidate_pids():
251
+ record = _process_record(pid)
252
+ if record is not None:
253
+ snapshot[pid] = record
254
+ return snapshot
255
+
256
+
257
+ def _process_has_lineage_token(pid: int, token: str) -> bool:
258
+ needle = f"{_LINEAGE_ENV_NAME}={token}".encode("ascii")
259
+ if sys.platform == "darwin":
260
+ mib = (ctypes.c_int * 3)(1, 49, pid) # CTL_KERN, KERN_PROCARGS2
261
+ size = ctypes.c_size_t()
262
+ library = _darwin_libc()
263
+ if library.sysctl(mib, 3, None, ctypes.byref(size), None, 0) != 0:
264
+ return False
265
+ if size.value <= 0:
266
+ return False
267
+ buffer = ctypes.create_string_buffer(size.value)
268
+ if library.sysctl(
269
+ mib, 3, buffer, ctypes.byref(size), None, 0
270
+ ) != 0:
271
+ return False
272
+ return needle in bytes(buffer.raw[: size.value]).split(b"\0")
273
+ if sys.platform.startswith("linux"):
274
+ try:
275
+ with open(f"/proc/{pid}/environ", "rb") as handle:
276
+ return needle in handle.read().split(b"\0")
277
+ except OSError:
278
+ return False
279
+ return False
280
+
281
+
282
+ def _darwin_pipe_handles(pid: int, fd: int) -> frozenset[int]:
283
+ info = _DarwinPipeFdInfo()
284
+ size = ctypes.sizeof(info)
285
+ read = _darwin_libproc().proc_pidfdinfo(
286
+ pid, fd, 6, ctypes.byref(info), size # PROC_PIDFDPIPEINFO
287
+ )
288
+ if read != size:
289
+ return frozenset()
290
+ return frozenset(
291
+ value
292
+ for value in (
293
+ int(info.pipeinfo.pipe_handle),
294
+ int(info.pipeinfo.pipe_peerhandle),
295
+ )
296
+ if value != 0
297
+ )
298
+
299
+
300
+ def _process_has_lineage_pipe(pid: int, handles: frozenset[int]) -> bool:
301
+ if sys.platform != "darwin" or not handles:
302
+ return False
303
+ library = _darwin_libproc()
304
+ entry_size = ctypes.sizeof(_DarwinProcFdInfo)
305
+ required = library.proc_pidinfo(pid, 1, 0, None, 0) # PROC_PIDLISTFDS
306
+ if required <= 0:
307
+ return False
308
+ capacity = max(16, (required // entry_size) + 8)
309
+ entries = (_DarwinProcFdInfo * capacity)()
310
+ read = library.proc_pidinfo(
311
+ pid, 1, 0, ctypes.byref(entries), ctypes.sizeof(entries)
312
+ )
313
+ if read <= 0:
314
+ return False
315
+ for entry in entries[: read // entry_size]:
316
+ if entry.proc_fdtype != 6: # PROX_FDTYPE_PIPE
317
+ continue
318
+ if _darwin_pipe_handles(pid, int(entry.proc_fd)) & handles:
319
+ return True
320
+ return False
321
+
322
+
323
+ def _configure_child_subreaper() -> bool:
324
+ """Make Linux orphans observable without adding a runtime dependency."""
325
+ if not sys.platform.startswith("linux"):
326
+ return False
327
+ libc = ctypes.CDLL(None, use_errno=True)
328
+ prctl = getattr(libc, "prctl", None)
329
+ if prctl is None:
330
+ raise RuntimeError("attempt child subreaper is unavailable")
331
+ prctl.argtypes = [
332
+ ctypes.c_int,
333
+ ctypes.c_ulong,
334
+ ctypes.c_ulong,
335
+ ctypes.c_ulong,
336
+ ctypes.c_ulong,
337
+ ]
338
+ prctl.restype = ctypes.c_int
339
+ if prctl(36, 1, 0, 0, 0) != 0: # PR_SET_CHILD_SUBREAPER
340
+ raise RuntimeError("attempt child subreaper setup failed")
341
+ return True
342
+
343
+
344
+ class _LineageTracker:
345
+ """Retain birth-token-bound descendants across reparenting and setsid."""
346
+
347
+ def __init__(
348
+ self,
349
+ process: subprocess.Popen[bytes],
350
+ subreaper: bool,
351
+ token: str,
352
+ lineage_pipe_handles: frozenset[int],
353
+ ):
354
+ root = _process_record(process.pid)
355
+ if root is None:
356
+ raise RuntimeError("provider attempt identity is unavailable")
357
+ if not (
358
+ _process_has_lineage_token(root.pid, token)
359
+ or _process_has_lineage_pipe(root.pid, lineage_pipe_handles)
360
+ ):
361
+ raise RuntimeError("provider attempt lineage marker is unavailable")
362
+ self.root_pid = root.pid
363
+ self.session_id = root.session_id
364
+ self.subreaper = subreaper
365
+ self.token = token
366
+ self.lineage_pipe_handles = lineage_pipe_handles
367
+ self._marker_scan_until = 0.0
368
+ self._depths: dict[tuple[int, str], int] = {
369
+ (root.pid, root.identity): 0
370
+ }
371
+ self._proc_events = None
372
+ self._lineage_read_fd = -1
373
+ self._kernel_tracks_children = False
374
+ if sys.platform == "darwin":
375
+ try:
376
+ self._proc_events = select.kqueue()
377
+ self._kernel_tracks_children = self._watch_process(
378
+ root.pid, track_children=True
379
+ )
380
+ if not self._kernel_tracks_children:
381
+ self._marker_scan_until = (
382
+ time.monotonic() + _LINEAGE_DISCOVERY_WINDOW_SECONDS
383
+ )
384
+ except (AttributeError, OSError) as exc:
385
+ raise RuntimeError("attempt process lineage tracking failed") from exc
386
+
387
+ def _watch_process(self, pid: int, track_children: bool = False) -> bool:
388
+ if self._proc_events is None:
389
+ return False
390
+ fflags = select.KQ_NOTE_FORK | select.KQ_NOTE_EXIT
391
+ if track_children:
392
+ fflags |= select.KQ_NOTE_TRACK
393
+ change = select.kevent(
394
+ pid,
395
+ filter=select.KQ_FILTER_PROC,
396
+ flags=select.KQ_EV_ADD | select.KQ_EV_CLEAR,
397
+ fflags=fflags,
398
+ )
399
+ try:
400
+ self._proc_events.control([change], 0, 0)
401
+ except OSError as exc:
402
+ if track_children and exc.errno == errno.ENOTSUP:
403
+ self._watch_process(pid, track_children=False)
404
+ return False
405
+ if exc.errno == errno.ESRCH:
406
+ return False
407
+ raise
408
+ return track_children
409
+
410
+ def _depth_for_pid(self, pid: int) -> int:
411
+ depths = [
412
+ depth
413
+ for (tracked_pid, _identity), depth in self._depths.items()
414
+ if tracked_pid == pid
415
+ ]
416
+ return max(depths, default=0)
417
+
418
+ def _capture_proc_events(self) -> None:
419
+ if self._proc_events is None:
420
+ return
421
+ while True:
422
+ try:
423
+ events = self._proc_events.control(None, 256, 0)
424
+ except OSError as exc:
425
+ raise RuntimeError("attempt process lineage event read failed") from exc
426
+ for event in events:
427
+ if event.fflags & select.KQ_NOTE_TRACKERR:
428
+ raise RuntimeError("attempt process lineage event overflow")
429
+ if (
430
+ not self._kernel_tracks_children
431
+ and event.fflags
432
+ & (select.KQ_NOTE_FORK | select.KQ_NOTE_CHILD)
433
+ ):
434
+ self._marker_scan_until = max(
435
+ self._marker_scan_until,
436
+ time.monotonic() + _LINEAGE_DISCOVERY_WINDOW_SECONDS,
437
+ )
438
+ record = _process_record(int(event.ident))
439
+ if record is None:
440
+ continue
441
+ parent_pid = (
442
+ int(event.data)
443
+ if event.fflags & select.KQ_NOTE_CHILD
444
+ else record.ppid
445
+ )
446
+ self._depths.setdefault(
447
+ (record.pid, record.identity),
448
+ self._depth_for_pid(parent_pid) + 1,
449
+ )
450
+ if len(events) < 256:
451
+ return
452
+
453
+ def _snapshot(self) -> dict[int, _ProcessRecord]:
454
+ if not self._kernel_tracks_children:
455
+ return _process_snapshot()
456
+ snapshot: dict[int, _ProcessRecord] = {}
457
+ for pid, identity in tuple(self._depths):
458
+ record = _process_record(pid)
459
+ if record is not None and record.identity == identity:
460
+ snapshot[pid] = record
461
+ return snapshot
462
+
463
+ def refresh(self) -> list[tuple[int, str]]:
464
+ self._capture_proc_events()
465
+ snapshot = self._snapshot()
466
+ if time.monotonic() <= self._marker_scan_until:
467
+ for record in snapshot.values():
468
+ ref = (record.pid, record.identity)
469
+ if record.pid == os.getpid() or ref in self._depths:
470
+ continue
471
+ if _process_has_lineage_token(
472
+ record.pid, self.token
473
+ ) or _process_has_lineage_pipe(
474
+ record.pid, self.lineage_pipe_handles
475
+ ):
476
+ self._depths[ref] = self._depth_for_pid(record.ppid) + 1
477
+ self._watch_process(record.pid)
478
+ while True:
479
+ active_depths = {
480
+ pid: depth
481
+ for (pid, identity), depth in self._depths.items()
482
+ if pid in snapshot and snapshot[pid].identity == identity
483
+ }
484
+ additions: list[tuple[tuple[int, str], int]] = []
485
+ for record in snapshot.values():
486
+ ref = (record.pid, record.identity)
487
+ if ref in self._depths:
488
+ continue
489
+ parent_depth = active_depths.get(record.ppid)
490
+ if parent_depth is not None:
491
+ additions.append((ref, parent_depth + 1))
492
+ elif record.session_id == self.session_id:
493
+ additions.append((ref, 1))
494
+ elif self.subreaper and record.ppid == os.getpid():
495
+ # Linux reparents escaped double-fork descendants to this
496
+ # helper after PR_SET_CHILD_SUBREAPER. This process creates
497
+ # no other children after the provider leader.
498
+ additions.append((ref, 1))
499
+ if not additions:
500
+ break
501
+ for ref, depth in additions:
502
+ self._depths.setdefault(ref, depth)
503
+ self._watch_process(ref[0])
504
+
505
+ return sorted(
506
+ (
507
+ ref
508
+ for ref in self._depths
509
+ if ref[0] in snapshot and snapshot[ref[0]].identity == ref[1]
510
+ ),
511
+ key=lambda ref: self._depths[ref],
512
+ reverse=True,
513
+ )
514
+
515
+ def close(self) -> None:
516
+ if self._proc_events is not None:
517
+ self._proc_events.close()
518
+ self._proc_events = None
519
+ if self._lineage_read_fd >= 0:
520
+ os.close(self._lineage_read_fd)
521
+ self._lineage_read_fd = -1
522
+
523
+
524
+ def spawn_tracked(
525
+ command: list[str], **popen_kwargs: object
526
+ ) -> tuple[subprocess.Popen[bytes], _LineageTracker]:
527
+ """Spawn a gated session leader and arm identity-bound lineage tracking."""
528
+ if not command or "start_new_session" in popen_kwargs:
529
+ raise ValueError("tracked spawn requires a command and owns its session")
530
+
531
+ process: subprocess.Popen[bytes] | None = None
532
+ gate_read = gate_write = -1
533
+ lineage_read = lineage_write = -1
534
+ try:
535
+ subreaper = _configure_child_subreaper()
536
+ token = secrets.token_hex(16)
537
+ child_env = dict(popen_kwargs.pop("env", os.environ))
538
+ child_env[_LINEAGE_ENV_NAME] = token
539
+ child_env.pop(_CONTROL_ENV_NAME, None)
540
+ inherited = tuple(popen_kwargs.pop("pass_fds", ()))
541
+ gate_read, gate_write = os.pipe()
542
+ lineage_read, lineage_write = os.pipe()
543
+ handles = (
544
+ _darwin_pipe_handles(os.getpid(), lineage_read)
545
+ if sys.platform == "darwin"
546
+ else frozenset()
547
+ )
548
+ if sys.platform == "darwin" and not handles:
549
+ raise RuntimeError("attempt lineage pipe identity is unavailable")
550
+ process = subprocess.Popen(
551
+ [sys.executable, "-c", _EXEC_BOOTSTRAP, str(gate_read), *command],
552
+ start_new_session=True,
553
+ env=child_env,
554
+ pass_fds=(*inherited, gate_read, lineage_write),
555
+ **popen_kwargs,
556
+ )
557
+ os.close(gate_read)
558
+ gate_read = -1
559
+ tracker = _LineageTracker(process, subreaper, token, handles)
560
+ tracker._lineage_read_fd = lineage_read
561
+ lineage_read = -1
562
+ os.write(gate_write, b"1")
563
+ os.close(gate_write)
564
+ gate_write = -1
565
+ os.close(lineage_write)
566
+ lineage_write = -1
567
+ return process, tracker
568
+ except BaseException:
569
+ for descriptor in (gate_read, gate_write, lineage_read, lineage_write):
570
+ if descriptor >= 0:
571
+ try:
572
+ os.close(descriptor)
573
+ except OSError:
574
+ pass
575
+ if process is not None and process.poll() is None:
576
+ process.terminate()
577
+ try:
578
+ process.wait(timeout=1)
579
+ except subprocess.TimeoutExpired:
580
+ process.kill()
581
+ process.wait(timeout=1)
582
+ raise
583
+
584
+
585
+ def _signal_process(member: tuple[int, str], sig: int) -> None:
586
+ pid, identity = member
587
+ try:
588
+ if _process_identity(pid) != identity:
589
+ return
590
+ os.kill(pid, sig)
591
+ except ProcessLookupError:
592
+ return
593
+ except OSError as exc:
594
+ if exc.errno != errno.ESRCH:
595
+ raise
596
+
597
+
598
+ def reconcile_lineage(
599
+ process: subprocess.Popen[bytes], tracker: _LineageTracker, grace: float
600
+ ) -> dict[str, object]:
601
+ """Stop the exact tracked lineage and prove a bounded quiet interval."""
602
+ outcome: dict[str, object] = {
603
+ "checked": True,
604
+ "detected": False,
605
+ "quiescent": False,
606
+ "term_sent": [],
607
+ "kill_sent": [],
608
+ "remaining_pids": [],
609
+ "error": "",
610
+ }
611
+ sent: dict[int, set[tuple[int, str]]] = {
612
+ signal.SIGTERM: set(),
613
+ signal.SIGKILL: set(),
614
+ }
615
+
616
+ def refresh() -> list[tuple[int, str]]:
617
+ members = tracker.refresh()
618
+ if any(pid != process.pid for pid, _identity in members):
619
+ outcome["detected"] = True
620
+ return members
621
+
622
+ def signal_members(
623
+ members: list[tuple[int, str]], signum: int, field: str
624
+ ) -> None:
625
+ recorded = outcome[field]
626
+ assert isinstance(recorded, list)
627
+ for member in members:
628
+ _signal_process(member, signum)
629
+ if member not in sent[signum]:
630
+ sent[signum].add(member)
631
+ if member[0] != process.pid:
632
+ recorded.append(member[0])
633
+
634
+ started = time.monotonic()
635
+ total_grace = max(
636
+ float(grace) + _QUIESCENCE_SECONDS,
637
+ _QUIESCENCE_SECONDS + _RECONCILE_SCHEDULING_MARGIN_SECONDS,
638
+ )
639
+ final_deadline = started + total_grace
640
+ term_deadline = final_deadline - (
641
+ _QUIESCENCE_SECONDS + _RECONCILE_SCHEDULING_MARGIN_SECONDS
642
+ )
643
+ members = refresh()
644
+ signal_members(members, signal.SIGTERM, "term_sent")
645
+ while members and time.monotonic() < term_deadline:
646
+ time.sleep(_LINEAGE_POLL_SECONDS)
647
+ members = refresh()
648
+ signal_members(members, signal.SIGTERM, "term_sent")
649
+
650
+ quiet_since: float | None = None
651
+ while time.monotonic() < final_deadline:
652
+ members = refresh()
653
+ if members:
654
+ quiet_since = None
655
+ signal_members(members, signal.SIGKILL, "kill_sent")
656
+ elif quiet_since is None:
657
+ quiet_since = time.monotonic()
658
+ elif time.monotonic() - quiet_since >= _QUIESCENCE_SECONDS:
659
+ break
660
+ time.sleep(_LINEAGE_POLL_SECONDS)
661
+ members = refresh()
662
+ outcome["remaining_pids"] = [pid for pid, _identity in members]
663
+ outcome["quiescent"] = bool(
664
+ not members
665
+ and quiet_since is not None
666
+ and time.monotonic() - quiet_since >= _QUIESCENCE_SECONDS
667
+ )
668
+ return outcome
669
+
670
+
671
+ def _stop_lineage(
672
+ process: subprocess.Popen[bytes], tracker: _LineageTracker, grace: float
673
+ ) -> bool:
674
+ outcome = reconcile_lineage(process, tracker, grace)
675
+ members = outcome["remaining_pids"]
676
+ if not outcome["quiescent"]:
677
+ raise RuntimeError(
678
+ "provider attempt lineage is still active: "
679
+ + ",".join(str(pid) for pid in members)
680
+ )
681
+
682
+ try:
683
+ process.wait(timeout=1)
684
+ except subprocess.TimeoutExpired:
685
+ raise RuntimeError("provider attempt leader did not stop")
686
+ return bool(outcome["detected"])
687
+
688
+
689
+ def _exit_code(returncode: int) -> int:
690
+ return 128 + abs(returncode) if returncode < 0 else returncode
691
+
692
+
693
+ def _write_output(chunk: bytes, stream: BinaryIO) -> bool:
694
+ try:
695
+ stream.write(chunk)
696
+ stream.flush()
697
+ return True
698
+ except BrokenPipeError:
699
+ return False
700
+
701
+
702
+ def _emit_timeout(reason: str, hard_seconds: int, idle_seconds: int) -> None:
703
+ record = {
704
+ "type": "loki_provider_deadline",
705
+ "reason": reason,
706
+ "hard_seconds": hard_seconds,
707
+ "idle_seconds": idle_seconds,
708
+ }
709
+ print(json.dumps(record, separators=(",", ":")), file=sys.stderr, flush=True)
710
+
711
+
712
+ def _write_control(path: Path) -> bytes:
713
+ record = json.dumps(
714
+ {
715
+ "schema": "loki-deadline-control/v1",
716
+ "pid": os.getpid(),
717
+ "identity": _process_identity(os.getpid()),
718
+ },
719
+ sort_keys=True,
720
+ ).encode("utf-8")
721
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
722
+ if hasattr(os, "O_NOFOLLOW"):
723
+ flags |= os.O_NOFOLLOW
724
+ descriptor = os.open(path, flags, 0o600)
725
+ try:
726
+ os.fchmod(descriptor, 0o600)
727
+ os.write(descriptor, record)
728
+ finally:
729
+ os.close(descriptor)
730
+ return record
731
+
732
+
733
+ def _clear_control(path: Path, expected: bytes) -> None:
734
+ try:
735
+ if path.read_bytes() == expected:
736
+ path.unlink()
737
+ except OSError:
738
+ pass
739
+
740
+
741
+ def cancel_control(path: Path) -> int:
742
+ try:
743
+ info = os.lstat(path)
744
+ if (
745
+ not stat.S_ISREG(info.st_mode)
746
+ or info.st_uid != os.getuid()
747
+ or info.st_nlink != 1
748
+ or stat.S_IMODE(info.st_mode) != 0o600
749
+ or info.st_size > 1024
750
+ ):
751
+ return INTERNAL_EXIT
752
+ record = json.loads(path.read_text(encoding="utf-8"))
753
+ if set(record) != {"schema", "pid", "identity"}:
754
+ return INTERNAL_EXIT
755
+ pid = record["pid"]
756
+ identity = record["identity"]
757
+ if (
758
+ record["schema"] != "loki-deadline-control/v1"
759
+ or not isinstance(pid, int)
760
+ or pid <= 1
761
+ or not isinstance(identity, str)
762
+ or not identity
763
+ or _process_identity(pid) != identity
764
+ ):
765
+ return INTERNAL_EXIT
766
+ os.kill(pid, signal.SIGTERM)
767
+ return 0
768
+ except (OSError, ValueError, json.JSONDecodeError):
769
+ return INTERNAL_EXIT
770
+
771
+
772
+ def _drain_output(process: subprocess.Popen[bytes]) -> None:
773
+ for pipe, stream in (
774
+ (process.stdout, sys.stdout.buffer),
775
+ (process.stderr, sys.stderr.buffer),
776
+ ):
777
+ if pipe is None:
778
+ continue
779
+ while True:
780
+ try:
781
+ chunk = os.read(pipe.fileno(), 65536)
782
+ except BlockingIOError:
783
+ break
784
+ if not chunk:
785
+ break
786
+ if not _write_output(chunk, stream):
787
+ break
788
+
789
+
790
+ def _wait_for_output(
791
+ process: subprocess.Popen[bytes],
792
+ tracker: _LineageTracker,
793
+ hard_seconds: int,
794
+ idle_seconds: int,
795
+ ) -> tuple[int | None, str | None]:
796
+ if process.stdout is None or process.stderr is None:
797
+ return INTERNAL_EXIT, None
798
+
799
+ selector = selectors.DefaultSelector()
800
+ streams = {
801
+ process.stdout.fileno(): sys.stdout.buffer,
802
+ process.stderr.fileno(): sys.stderr.buffer,
803
+ }
804
+ for output_fd, stream in streams.items():
805
+ os.set_blocking(output_fd, False)
806
+ selector.register(output_fd, selectors.EVENT_READ, stream)
807
+ started = last_activity = time.monotonic()
808
+ open_fds = set(streams)
809
+
810
+ try:
811
+ while True:
812
+ tracker.refresh()
813
+ now = time.monotonic()
814
+ hard_remaining = hard_seconds - (now - started)
815
+ idle_remaining = (
816
+ idle_seconds - (now - last_activity) if idle_seconds > 0 else hard_remaining
817
+ )
818
+ wait_seconds = max(
819
+ 0.0,
820
+ min(hard_remaining, idle_remaining, _LINEAGE_POLL_SECONDS),
821
+ )
822
+
823
+ if open_fds:
824
+ for key, _events in selector.select(wait_seconds):
825
+ try:
826
+ chunk = os.read(key.fd, 65536)
827
+ except BlockingIOError:
828
+ continue
829
+ if chunk:
830
+ if not _write_output(chunk, key.data):
831
+ return 141, None
832
+ last_activity = time.monotonic()
833
+ else:
834
+ selector.unregister(key.fd)
835
+ open_fds.discard(key.fd)
836
+ elif wait_seconds > 0:
837
+ time.sleep(wait_seconds)
838
+
839
+ tracker.refresh()
840
+
841
+ returncode = process.poll()
842
+ if returncode is not None:
843
+ for output_fd in tuple(open_fds):
844
+ while True:
845
+ try:
846
+ chunk = os.read(output_fd, 65536)
847
+ except BlockingIOError:
848
+ break
849
+ if not chunk:
850
+ break
851
+ if not _write_output(chunk, streams[output_fd]):
852
+ return 141, None
853
+ return _exit_code(returncode), None
854
+
855
+ now = time.monotonic()
856
+ if now - started >= hard_seconds:
857
+ return None, "hard_timeout"
858
+ if idle_seconds > 0 and now - last_activity >= idle_seconds:
859
+ return None, "idle_timeout"
860
+ finally:
861
+ selector.close()
862
+
863
+
864
+ def main() -> int:
865
+ if len(sys.argv) == 3 and sys.argv[1] == "cancel":
866
+ return cancel_control(Path(sys.argv[2]))
867
+ if len(sys.argv) < 5:
868
+ return INTERNAL_EXIT
869
+ try:
870
+ hard_seconds = int(sys.argv[1])
871
+ kill_grace = int(sys.argv[2])
872
+ if sys.argv[3] == "--":
873
+ idle_seconds = 0
874
+ command = sys.argv[4:]
875
+ elif len(sys.argv) >= 6 and sys.argv[4] == "--":
876
+ idle_seconds = int(sys.argv[3])
877
+ command = sys.argv[5:]
878
+ else:
879
+ return INTERNAL_EXIT
880
+ except ValueError:
881
+ return INTERNAL_EXIT
882
+ if hard_seconds <= 0 or kill_grace <= 0 or idle_seconds < 0 or not command:
883
+ return INTERNAL_EXIT
884
+ if idle_seconds >= hard_seconds and idle_seconds != 0:
885
+ return INTERNAL_EXIT
886
+
887
+ process: subprocess.Popen[bytes] | None = None
888
+ tracker: _LineageTracker | None = None
889
+ try:
890
+ process, tracker = spawn_tracked(
891
+ command,
892
+ stdout=subprocess.PIPE,
893
+ stderr=subprocess.PIPE,
894
+ bufsize=0,
895
+ )
896
+ except (OSError, RuntimeError):
897
+ return INTERNAL_EXIT
898
+
899
+ def forward_and_exit(signum: int, _frame: object) -> None:
900
+ try:
901
+ _stop_lineage(process, tracker, min(float(kill_grace), 0.5))
902
+ except (PermissionError, RuntimeError):
903
+ raise SystemExit(INTERNAL_EXIT)
904
+ raise SystemExit(128 + signum)
905
+
906
+ for forwarded in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
907
+ signal.signal(forwarded, forward_and_exit)
908
+
909
+ control_path: Path | None = None
910
+ control_record = b""
911
+ try:
912
+ try:
913
+ raw_control_path = os.environ.get(_CONTROL_ENV_NAME, "")
914
+ if raw_control_path:
915
+ control_path = Path(raw_control_path)
916
+ control_record = _write_control(control_path)
917
+ returncode, timeout_reason = _wait_for_output(
918
+ process, tracker, hard_seconds, idle_seconds
919
+ )
920
+ except (OSError, PermissionError, RuntimeError):
921
+ try:
922
+ _stop_lineage(process, tracker, float(kill_grace))
923
+ except (PermissionError, RuntimeError):
924
+ pass
925
+ return INTERNAL_EXIT
926
+ if timeout_reason is not None:
927
+ _emit_timeout(timeout_reason, hard_seconds, idle_seconds)
928
+ try:
929
+ _stop_lineage(process, tracker, float(kill_grace))
930
+ except (PermissionError, RuntimeError):
931
+ return INTERNAL_EXIT
932
+ _drain_output(process)
933
+ return TIMEOUT_EXIT
934
+
935
+ if returncode == 141:
936
+ try:
937
+ _stop_lineage(process, tracker, float(kill_grace))
938
+ except (PermissionError, RuntimeError):
939
+ return INTERNAL_EXIT
940
+ return 141
941
+
942
+ # A provider must not leave tool processes alive after its own exit.
943
+ try:
944
+ had_descendants = _stop_lineage(process, tracker, 0.1)
945
+ except (PermissionError, RuntimeError):
946
+ return INTERNAL_EXIT
947
+ if had_descendants:
948
+ return INTERNAL_EXIT
949
+ return returncode if returncode is not None else INTERNAL_EXIT
950
+ finally:
951
+ if control_path is not None and control_record:
952
+ _clear_control(control_path, control_record)
953
+ tracker.close()
954
+
955
+
956
+ if __name__ == "__main__":
957
+ raise SystemExit(main())