dos-kernel 0.22.0__py3-none-win_arm64.whl
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.
- dos/__init__.py +261 -0
- dos/_bin/dos-hook.exe +0 -0
- dos/_filelock.py +255 -0
- dos/_job_policy.py +97 -0
- dos/_tree.py +145 -0
- dos/admission.py +433 -0
- dos/answer_shape.py +299 -0
- dos/arbiter.py +859 -0
- dos/archive_lock.py +266 -0
- dos/arg_provenance.py +814 -0
- dos/attest.py +472 -0
- dos/breaker.py +311 -0
- dos/churn.py +226 -0
- dos/claim_extract.py +229 -0
- dos/claim_ttl.py +150 -0
- dos/cli.py +8721 -0
- dos/commit_audit.py +666 -0
- dos/completion.py +466 -0
- dos/concurrency_class.py +154 -0
- dos/config.py +1380 -0
- dos/config_lint.py +464 -0
- dos/cooldown.py +390 -0
- dos/coverage.py +387 -0
- dos/dangling_intent.py +287 -0
- dos/data_class.py +397 -0
- dos/decisions.py +1274 -0
- dos/decisions_tui.py +251 -0
- dos/dispatch_top.py +740 -0
- dos/dispatch_top_tui.py +116 -0
- dos/drivers/__init__.py +40 -0
- dos/drivers/ci_status.py +630 -0
- dos/drivers/citation_resolve.py +703 -0
- dos/drivers/decision_stop.py +98 -0
- dos/drivers/export_file.py +173 -0
- dos/drivers/export_otlp.py +275 -0
- dos/drivers/export_statsd.py +242 -0
- dos/drivers/hook_dialects.py +391 -0
- dos/drivers/job.py +47 -0
- dos/drivers/llm_judge.py +360 -0
- dos/drivers/memory_recall.py +1231 -0
- dos/drivers/notify_slack.py +373 -0
- dos/drivers/notify_webhook.py +251 -0
- dos/drivers/operator_judge.py +114 -0
- dos/drivers/os_acceptance.py +228 -0
- dos/drivers/paste_log.py +132 -0
- dos/drivers/plan_scope.py +133 -0
- dos/drivers/self_improve.py +375 -0
- dos/drivers/similarity_judge.py +249 -0
- dos/drivers/state_diff.py +274 -0
- dos/drivers/supervisor.py +347 -0
- dos/drivers/watchdog.py +363 -0
- dos/drivers/workshop.py +160 -0
- dos/durable_schema.py +344 -0
- dos/effect_witness.py +393 -0
- dos/efficiency.py +318 -0
- dos/enforce.py +414 -0
- dos/enumerate.py +776 -0
- dos/env_print.py +378 -0
- dos/event_severity.py +258 -0
- dos/evidence.py +692 -0
- dos/exec_capability.py +256 -0
- dos/export_cursor.py +143 -0
- dos/exporter.py +320 -0
- dos/firing_label.py +353 -0
- dos/fleet_roll.py +226 -0
- dos/gate_classify.py +827 -0
- dos/gh4_coverage.py +179 -0
- dos/git_delta.py +122 -0
- dos/guard.py +215 -0
- dos/health.py +552 -0
- dos/help_summary.py +519 -0
- dos/home.py +934 -0
- dos/hook_binary.py +194 -0
- dos/hook_dialect.py +271 -0
- dos/hook_exit.py +191 -0
- dos/hook_install.py +437 -0
- dos/id_alloc.py +304 -0
- dos/improve.py +499 -0
- dos/intent_ledger.py +635 -0
- dos/interpret.py +176 -0
- dos/intervention.py +769 -0
- dos/intervention_eval.py +371 -0
- dos/journal_delta.py +308 -0
- dos/judge_eval.py +328 -0
- dos/judges.py +366 -0
- dos/lane_infer.py +127 -0
- dos/lane_journal.py +1001 -0
- dos/lane_lease.py +952 -0
- dos/lane_overlap.py +228 -0
- dos/lease_health.py +282 -0
- dos/lifecycle.py +211 -0
- dos/liveness.py +352 -0
- dos/lock_modes.py +185 -0
- dos/log_source.py +395 -0
- dos/loop_decide.py +1746 -0
- dos/marker_gate.py +254 -0
- dos/marker_sensor.py +396 -0
- dos/noop_streak.py +280 -0
- dos/notify.py +479 -0
- dos/observe.py +175 -0
- dos/oracle.py +1661 -0
- dos/overlap_eval.py +214 -0
- dos/overlap_policy.py +342 -0
- dos/packet_sidecar.py +267 -0
- dos/phase_shipped.py +1985 -0
- dos/pick_priority.py +225 -0
- dos/pickable.py +369 -0
- dos/picker_oracle.py +1037 -0
- dos/plan_board.py +513 -0
- dos/plan_board_tui.py +113 -0
- dos/plan_source.py +455 -0
- dos/posttool_sensor.py +528 -0
- dos/precursor_gate.py +499 -0
- dos/precursor_gate_eval.py +239 -0
- dos/preflight.py +825 -0
- dos/pretool_sensor.py +490 -0
- dos/proc_delta.py +181 -0
- dos/productivity.py +296 -0
- dos/provider_limit.py +242 -0
- dos/py.typed +4 -0
- dos/reason_morphology.py +299 -0
- dos/reasons.py +449 -0
- dos/reconcile.py +173 -0
- dos/recurring_wedge.py +206 -0
- dos/render.py +393 -0
- dos/result_state.py +468 -0
- dos/resume.py +578 -0
- dos/resume_evidence.py +293 -0
- dos/retention.py +344 -0
- dos/reward.py +372 -0
- dos/rewind.py +587 -0
- dos/rewind_evidence.py +168 -0
- dos/rewind_tokens.py +252 -0
- dos/run_id.py +342 -0
- dos/scope.py +520 -0
- dos/scope_source.py +382 -0
- dos/scout.py +982 -0
- dos/self_modify.py +209 -0
- dos/sibling_scan.py +569 -0
- dos/skills/EXAMPLES.md +584 -0
- dos/skills/dos-class-cycle/SKILL.md +107 -0
- dos/skills/dos-dispatch/SKILL.md +177 -0
- dos/skills/dos-dispatch-loop/SKILL.md +254 -0
- dos/skills/dos-goal-gate/SKILL.md +269 -0
- dos/skills/dos-next-up/SKILL.md +231 -0
- dos/skills/dos-promote/SKILL.md +114 -0
- dos/skills/dos-replan/SKILL.md +159 -0
- dos/skills/dos-replan-loop/SKILL.md +114 -0
- dos/skills/dos-self-improve/SKILL.md +213 -0
- dos/skills/dos-supervise-loop/SKILL.md +180 -0
- dos/skills/dos-unstick/SKILL.md +108 -0
- dos/skills/dos-witness-claim/SKILL.md +251 -0
- dos/stamp.py +1002 -0
- dos/state_health.py +387 -0
- dos/status.py +114 -0
- dos/stop_policy.py +334 -0
- dos/supervise.py +1014 -0
- dos/testwitness.py +392 -0
- dos/timeline.py +1027 -0
- dos/tokens.py +485 -0
- dos/tool_stream.py +393 -0
- dos/tool_stream_eval.py +226 -0
- dos/trace.py +524 -0
- dos/verdict.py +140 -0
- dos/verdict_cli.py +189 -0
- dos/verdict_journal.py +497 -0
- dos/verdict_rollup.py +217 -0
- dos/verdicts.py +181 -0
- dos/wedge_reason.py +282 -0
- dos_kernel-0.22.0.dist-info/METADATA +859 -0
- dos_kernel-0.22.0.dist-info/RECORD +178 -0
- dos_kernel-0.22.0.dist-info/WHEEL +5 -0
- dos_kernel-0.22.0.dist-info/entry_points.txt +39 -0
- dos_kernel-0.22.0.dist-info/licenses/LICENSE +21 -0
- dos_kernel-0.22.0.dist-info/top_level.txt +2 -0
- dos_mcp/__init__.py +52 -0
- dos_mcp/py.typed +2 -0
- dos_mcp/server.py +779 -0
dos/_tree.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Pure file-tree prefix algebra — the shared normalization the lane arbiter
|
|
2
|
+
and the overlap policy both stand on.
|
|
3
|
+
|
|
4
|
+
Lifted byte-for-byte (logic-identical) from the origin repo's
|
|
5
|
+
`scripts/next_up_render.py` (`_norm_tree_prefix`, `lane_trees_disjoint`). Those
|
|
6
|
+
two functions are the *only* part of the 3,326-line `next_up_render` the arbiter
|
|
7
|
+
actually needed; the rest is the reference userland app's operator-facing
|
|
8
|
+
`/next-up` rendering, which stays host-side. Pulling these two pure helpers into
|
|
9
|
+
their own leaf module
|
|
10
|
+
(rather than dragging `next_up_render`) is the §4 "port the spine, not the prose"
|
|
11
|
+
discipline applied at function granularity.
|
|
12
|
+
|
|
13
|
+
A *lane* owns a set of repo-relative path globs — its *tree*. Two lanes are safe
|
|
14
|
+
to run concurrently only when their trees are pairwise disjoint at the
|
|
15
|
+
directory-prefix level: no normalized prefix of one is a prefix of the other.
|
|
16
|
+
|
|
17
|
+
This is **predicate/range locking**, not swim-lane separation: a lane is a leased
|
|
18
|
+
predicate-lock over a region of the workspace, and `prefixes_collide` below is the
|
|
19
|
+
(conservative, decidable) predicate-intersection test it admits on — general
|
|
20
|
+
predicate-satisfiability being undecidable, the prefix rule over-approximates it.
|
|
21
|
+
See `docs/89_the-lane-is-a-region-lock.md`.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Callable, Optional
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def norm_tree_prefix(p: str) -> str:
|
|
30
|
+
"""Normalize one tree entry to a comparable directory prefix.
|
|
31
|
+
|
|
32
|
+
``agents/apply_*.py`` -> ``agents/apply_``; ``go/internal/ui/`` ->
|
|
33
|
+
``go/internal/ui/``; ``job_search/scoring.py`` -> ``job_search/scoring.py``.
|
|
34
|
+
A glob is truncated at the first ``*`` because everything after it is a
|
|
35
|
+
wildcard — two entries that share a pre-``*`` prefix can name the same file.
|
|
36
|
+
|
|
37
|
+
A **leading-glob** entry like ``**/*`` or ``*.py`` truncates to the EMPTY
|
|
38
|
+
prefix ``""`` — there is no pre-``*`` directory to anchor on. The empty
|
|
39
|
+
prefix is the *universal* prefix: every path starts with it, so it matches
|
|
40
|
+
**everything**. Callers must not silently drop it (that inverts a whole-repo
|
|
41
|
+
tree into "touches nothing"); see `prefixes_collide`.
|
|
42
|
+
|
|
43
|
+
**Case is folded** (``str.casefold``) so the prefix algebra matches the
|
|
44
|
+
semantics of a case-INsensitive filesystem — DOS's documented primary platform
|
|
45
|
+
is Windows, where ``Core/Engine/run.py`` and ``core/engine/run.py`` are the
|
|
46
|
+
SAME file. Without folding, the case-sensitive ``startswith`` in
|
|
47
|
+
`prefixes_collide` judges those two as disjoint, so two lanes editing one real
|
|
48
|
+
file would both be admitted a lease (a false-ADMIT → concurrent writes to one
|
|
49
|
+
file → corruption) and the SELF_MODIFY guard would be bypassable by mixed-case
|
|
50
|
+
paths (``SRC/dos/arbiter.py`` slips past). Folding is **unconditional** (not
|
|
51
|
+
branched on ``os.name``): a lane tree authored on one platform must collide
|
|
52
|
+
identically when the kernel runs on another (deterministic CI), and on a truly
|
|
53
|
+
case-sensitive FS treating two case-variants as colliding is a HARMLESS
|
|
54
|
+
over-refusal — exactly the safe, conservative over-approximation direction this
|
|
55
|
+
module already embraces (`lane_trees_disjoint`'s empty-tree rule). It does NOT
|
|
56
|
+
weaken the filename-prefix discrimination the workshop driver relies on
|
|
57
|
+
(``docs/ui-`` vs ``docs/svc-`` stay distinct after folding); it only ADDS the
|
|
58
|
+
case-variant collisions a case-insensitive FS demands.
|
|
59
|
+
"""
|
|
60
|
+
p = (p or "").replace("\\", "/").strip().casefold()
|
|
61
|
+
star = p.find("*")
|
|
62
|
+
if star != -1:
|
|
63
|
+
return p[:star]
|
|
64
|
+
return p
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def prefixes_collide(a: str, b: str) -> bool:
|
|
68
|
+
"""True iff two normalized prefixes can name the same file.
|
|
69
|
+
|
|
70
|
+
The single definition of "these two tree prefixes overlap," shared by every
|
|
71
|
+
collision check in the kernel (`lane_trees_disjoint`, `lane_overlap`, the
|
|
72
|
+
self-modify guard) so they cannot drift apart — the drift that let
|
|
73
|
+
`lane_overlap` call two ``**/*`` lanes "fully disjoint" while
|
|
74
|
+
`lane_trees_disjoint` (correctly) called them overlapping.
|
|
75
|
+
|
|
76
|
+
Two prefixes collide when one is a prefix of the other (the original rule).
|
|
77
|
+
The **empty prefix** (`""`, from a leading-glob like ``**/*``) is the
|
|
78
|
+
universal prefix — it collides with *everything*, including another empty
|
|
79
|
+
prefix — because ``"".startswith(x)`` is only true for ``x == ""`` but
|
|
80
|
+
``x.startswith("")`` is true for all ``x``. The asymmetry is handled here so
|
|
81
|
+
every caller treats a whole-repo glob as the maximal blast radius it is.
|
|
82
|
+
"""
|
|
83
|
+
return a.startswith(b) or b.startswith(a)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def lane_trees_disjoint(tree_a: list[str], tree_b: list[str]) -> bool:
|
|
87
|
+
"""True when two lane file trees cannot edit the same file.
|
|
88
|
+
|
|
89
|
+
**Conservative-by-design — an empty tree is treated as NOT disjoint.** An
|
|
90
|
+
empty tree is an *unknown* blast radius, not a *zero* one, so this returns
|
|
91
|
+
``False`` (unsafe / overlapping) when either tree is empty: the caller must
|
|
92
|
+
refuse a concurrent admission rather than assume the lane touches nothing.
|
|
93
|
+
"""
|
|
94
|
+
if not tree_a or not tree_b:
|
|
95
|
+
# Unknown blast radius — refuse. See the docstring.
|
|
96
|
+
return False
|
|
97
|
+
norm_a = [norm_tree_prefix(p) for p in tree_a if p]
|
|
98
|
+
norm_b = [norm_tree_prefix(p) for p in tree_b if p]
|
|
99
|
+
if not norm_a or not norm_b:
|
|
100
|
+
return False
|
|
101
|
+
for na in norm_a:
|
|
102
|
+
for nb in norm_b:
|
|
103
|
+
if na.startswith(nb) or nb.startswith(na):
|
|
104
|
+
return False
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def tree_disjoint_from_all_live(
|
|
109
|
+
*,
|
|
110
|
+
requested_tree: list[str],
|
|
111
|
+
live: list[dict],
|
|
112
|
+
sibling_tree_lookup: Callable[[str], Optional[list[str]]],
|
|
113
|
+
) -> bool:
|
|
114
|
+
"""True iff ``requested_tree`` is provably disjoint from EVERY live sibling.
|
|
115
|
+
|
|
116
|
+
The shared "can this region run alongside everything currently live" predicate
|
|
117
|
+
— the same posture as `lane_trees_disjoint`, lifted from `sibling_scan`'s own
|
|
118
|
+
`_disjoint_from_all_live` so the lane ARBITER (selection-time) and the SIBLING
|
|
119
|
+
SCAN (post-acquire escape) prove disjointness through one definition and cannot
|
|
120
|
+
drift apart. Conservative on three counts, each mapping an *unknown* to "cannot
|
|
121
|
+
prove disjoint → not safe":
|
|
122
|
+
|
|
123
|
+
* a sibling whose ``lane`` is empty/unknown → no resolvable tree → unknown
|
|
124
|
+
blast radius → NOT provably disjoint (returns ``False``). A read-only
|
|
125
|
+
activity-class sibling (an un-leased ``/replan``) must be filtered out
|
|
126
|
+
UPSTREAM by the caller, not waved through on an empty tree.
|
|
127
|
+
* a sibling whose tree resolves empty (lookup miss) → unknown → ``False``.
|
|
128
|
+
* any sibling whose tree OVERLAPS the requested tree → ``False``.
|
|
129
|
+
|
|
130
|
+
Only when every live sibling has a known, non-empty, disjoint tree is it safe to
|
|
131
|
+
run concurrently. An empty ``live`` (no siblings) is vacuously disjoint → ``True``.
|
|
132
|
+
"""
|
|
133
|
+
for s in live:
|
|
134
|
+
lane = str(s.get("lane") or "")
|
|
135
|
+
if not lane:
|
|
136
|
+
return False # unknown blast radius — cannot prove disjoint
|
|
137
|
+
try:
|
|
138
|
+
tree = list(sibling_tree_lookup(lane) or [])
|
|
139
|
+
except Exception:
|
|
140
|
+
tree = []
|
|
141
|
+
if not tree:
|
|
142
|
+
return False # tree did not resolve — unknown — not safe
|
|
143
|
+
if not lane_trees_disjoint(list(requested_tree), tree):
|
|
144
|
+
return False # provable overlap
|
|
145
|
+
return True
|
dos/admission.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""The admission-predicate seam — Axis 3 of hackability: pluggable safety hooks (ADM, docs/73).
|
|
2
|
+
|
|
3
|
+
The arbiter's admission logic — `_lease_blocks` + the ≤30 % soft-overlap
|
|
4
|
+
tree-disjointness rule (`lane_overlap.overlap_verdict`) — is the kernel's
|
|
5
|
+
**safety element**: it is what stops two agents editing the same files
|
|
6
|
+
concurrently. That logic used to be fixed. A workspace could not add its own
|
|
7
|
+
admission rule ("refuse a new lease when over the monthly token budget,"
|
|
8
|
+
"refuse a lease that would touch the orchestrator's own running code") without
|
|
9
|
+
forking the arbiter.
|
|
10
|
+
|
|
11
|
+
This module is the seam that lets it *register* one instead. An admission
|
|
12
|
+
predicate is a pure callable ``(request, live_lease, config) -> AdmissionVerdict``
|
|
13
|
+
resolved from the ``dos.predicates`` entry-point group (Phase 3). The arbiter
|
|
14
|
+
runs the built-in disjointness predicate **plus** any registered ones.
|
|
15
|
+
|
|
16
|
+
The one invariant that makes an *open* predicate set safe: **conjunctive-only**
|
|
17
|
+
=====================================================================================
|
|
18
|
+
|
|
19
|
+
This is the highest-risk axis — a buggy predicate that *loosens* admission could
|
|
20
|
+
let two agents collide, the exact failure the arbiter exists to prevent. The
|
|
21
|
+
guardrail is structural, not careful coding:
|
|
22
|
+
|
|
23
|
+
> **A predicate may only REFUSE. It can never force-admit over a built-in
|
|
24
|
+
> refusal.** Predicates compose conjunctively: admission requires the built-in
|
|
25
|
+
> disjointness check **and** every registered predicate to admit. Adding a
|
|
26
|
+
> predicate can only make admission *stricter*, never looser.
|
|
27
|
+
|
|
28
|
+
So the worst a buggy/malicious predicate can do is refuse too much (a visible,
|
|
29
|
+
safe-direction failure an operator notices immediately), never admit a collision.
|
|
30
|
+
The ``--force`` operator override stays the *only* thing that can overrule a
|
|
31
|
+
refusal — a predicate refusal is overridable by ``--force`` the same way a
|
|
32
|
+
disjointness refusal is; a predicate cannot itself force anything. There is
|
|
33
|
+
deliberately no return value that forces admission (`AdmissionVerdict` has only
|
|
34
|
+
``.admit()`` / ``.refuse(reason)`` — no "admit harder"), so the conjunctive-only
|
|
35
|
+
guarantee is enforced by the *shape of the type*, not by reviewer vigilance.
|
|
36
|
+
|
|
37
|
+
Purity & fail-closed
|
|
38
|
+
====================
|
|
39
|
+
|
|
40
|
+
A predicate is **pure**, exactly like the arbiter it runs inside (`arbiter.py`
|
|
41
|
+
"No I/O — `live_leases` is passed in, the decision is returned"): any I/O it
|
|
42
|
+
needs (reading a token-budget file) happens *before* the call, with the result
|
|
43
|
+
passed in via ``config`` or a pre-computed input — never inside the predicate
|
|
44
|
+
during arbitration. This mirrors how `pick_oracle` already does its I/O outside
|
|
45
|
+
the arbiter.
|
|
46
|
+
|
|
47
|
+
A predicate that *raises* is caught and converted to a **refuse** naming the
|
|
48
|
+
predicate (fail-closed) — the safe direction for a safety hook. This is the
|
|
49
|
+
*inverse* of the renderer rule (a renderer that raises degrades to ugly text,
|
|
50
|
+
because presentation is downstream of the kernel and can never mis-decide) and
|
|
51
|
+
is deliberate: a safety predicate that cannot answer must not admit. This is the
|
|
52
|
+
same posture as the design-law "oracle failure can only ADD refusals, never
|
|
53
|
+
remove one."
|
|
54
|
+
|
|
55
|
+
Pure stdlib + the kernel leaves it delegates to (`lane_overlap`) — no I/O, no
|
|
56
|
+
host names — so it sits in the kernel layer beside `arbiter`.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import sys
|
|
62
|
+
from dataclasses import dataclass
|
|
63
|
+
from typing import Protocol, runtime_checkable
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class AdmissionVerdict:
|
|
69
|
+
"""One predicate's answer: admit, or refuse with a reason.
|
|
70
|
+
|
|
71
|
+
Frozen and two-valued by design — there is no "force admit" constructor, so
|
|
72
|
+
a predicate is *structurally* incapable of overriding another's refusal
|
|
73
|
+
(the conjunctive-only invariant, enforced by the type). The boolean state is
|
|
74
|
+
read via the ``admitted`` property; the two constructors are ``.admit()`` and
|
|
75
|
+
``.refuse(reason)`` — the exact spelling the plan's north-star uses.
|
|
76
|
+
|
|
77
|
+
``reason`` is the operator-facing string a refusal carries (empty on an
|
|
78
|
+
admit). ``reason_class`` optionally carries a typed `reason_class` token (a
|
|
79
|
+
``dos.reasons`` registry token, e.g. ``SELF_MODIFY``) so a refusal is not
|
|
80
|
+
just prose but a verifiable/refusable/`dos man`-documented reason — the
|
|
81
|
+
Axis-1 mechanism. Built-in predicates set it; a workspace predicate may
|
|
82
|
+
leave it empty (its prose ``reason`` still surfaces).
|
|
83
|
+
|
|
84
|
+
The stored field is named ``_admit`` (private) so the ergonomic ``.admit()``
|
|
85
|
+
CONSTRUCTOR and the ``.admitted`` accessor do not collide with it — a public
|
|
86
|
+
field named ``admit`` would shadow the classmethod of the same name. Callers
|
|
87
|
+
read ``v.admitted`` (or just ``if not v.admitted``), never the underscore.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
_admit: bool
|
|
91
|
+
reason: str = ""
|
|
92
|
+
reason_class: str = ""
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def admitted(self) -> bool:
|
|
96
|
+
"""True iff this verdict admits. The public read accessor for the state."""
|
|
97
|
+
return self._admit
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def admit(cls) -> "AdmissionVerdict":
|
|
101
|
+
"""An admit verdict — the predicate raised no objection to this lease."""
|
|
102
|
+
return cls(_admit=True)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def refuse(cls, reason: str, *, reason_class: str = "") -> "AdmissionVerdict":
|
|
106
|
+
"""A refuse verdict carrying an operator-facing ``reason`` (and an
|
|
107
|
+
optional typed ``reason_class`` token). The ONLY non-admit constructor —
|
|
108
|
+
there is deliberately no force-admit (the conjunctive-only invariant)."""
|
|
109
|
+
return cls(_admit=False, reason=reason, reason_class=reason_class)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@runtime_checkable
|
|
113
|
+
class AdmissionPredicate(Protocol):
|
|
114
|
+
"""The contract a workspace implements to add an admission rule.
|
|
115
|
+
|
|
116
|
+
``name`` is the human label `dos doctor` lists and a fail-closed refusal
|
|
117
|
+
names. ``__call__`` is pure: it is handed the requested lease (lane/kind/
|
|
118
|
+
tree), ONE already-live lease to check against, and the active config, and
|
|
119
|
+
returns an `AdmissionVerdict`. It must do NO I/O — any data it needs is
|
|
120
|
+
pre-computed and read off ``config`` (or a field the caller cached there).
|
|
121
|
+
|
|
122
|
+
A predicate is called once per (request, live_lease) pair, the same shape
|
|
123
|
+
the built-in disjointness check has (it compares the request against each
|
|
124
|
+
live lease). A predicate that does not care about a specific lease admits.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
name: str
|
|
128
|
+
|
|
129
|
+
def __call__(self, request: "AdmissionRequest", live_lease: dict,
|
|
130
|
+
config: object) -> AdmissionVerdict:
|
|
131
|
+
...
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class AdmissionRequest:
|
|
136
|
+
"""The requested lease, as the pure datum a predicate sees.
|
|
137
|
+
|
|
138
|
+
A small frozen value (not the arbiter's loose kwargs) so a predicate has a
|
|
139
|
+
stable, documented shape to read — ``lane`` / ``kind`` / ``tree`` — without
|
|
140
|
+
being handed the arbiter's internals. Built by `arbiter.arbitrate` from its
|
|
141
|
+
``requested_*`` args just before the predicate sweep.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
lane: str
|
|
145
|
+
kind: str
|
|
146
|
+
tree: tuple[str, ...]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class DisjointnessPredicate:
|
|
150
|
+
"""The built-in tree-disjointness predicate — today's fixed admission rule,
|
|
151
|
+
now the FIRST registered predicate.
|
|
152
|
+
|
|
153
|
+
Delegates the both-known scoring to a resolved `overlap_policy.OverlapPolicy`
|
|
154
|
+
(default `PrefixOverlapPolicy`, AND-ed under the deterministic prefix floor),
|
|
155
|
+
while owning the empty-tree asymmetry itself. With the default policy the
|
|
156
|
+
floor-AND reproduces `lane_overlap.overlap_verdict` exactly, so routing the
|
|
157
|
+
arbiter's collision check through `run_predicates([DisjointnessPredicate()])`
|
|
158
|
+
stays byte-for-byte behavior-preserving (the load-bearing litmus: the entire
|
|
159
|
+
existing arbiter/overlap suite is green through this path).
|
|
160
|
+
|
|
161
|
+
The **empty-tree rules** (asymmetric on the lease side) are owned HERE, not in
|
|
162
|
+
the policy — they are soundness invariants about *unknown blast radius*, not a
|
|
163
|
+
*scoring* choice, so a swappable scorer never sees them (it cannot weaken the
|
|
164
|
+
unknown-blast-radius refusal). Reproduced verbatim from `arbiter._lease_blocks`:
|
|
165
|
+
* empty LEASE tree → does NOT block (a lease naming no blast radius cannot
|
|
166
|
+
claim conflict).
|
|
167
|
+
* empty REQUESTED tree vs a KNOWN lease tree → blocks (unknown blast
|
|
168
|
+
radius is never safe).
|
|
169
|
+
* both empty → does NOT block (lone-loop safe).
|
|
170
|
+
* both known → delegate to the policy via `admissible_under_floor`.
|
|
171
|
+
|
|
172
|
+
``policy`` is the scorer for the both-known case. It defaults to the built-in
|
|
173
|
+
`PrefixOverlapPolicy` (pure, no I/O — so a `DisjointnessPredicate()` with no
|
|
174
|
+
args is pure and byte-identical to the old inline rule). A boundary caller
|
|
175
|
+
(`built_in_predicates`) resolves a workspace's declared `dos.overlap_policies`
|
|
176
|
+
plugin and passes it in here — the resolve-at-the-boundary, I/O-free-hot-path
|
|
177
|
+
discipline `SelfModifyPredicate`'s `runtime_files` already uses. Whatever the
|
|
178
|
+
policy is, `admissible_under_floor` AND-s it under the unforgeable prefix floor,
|
|
179
|
+
so a misbehaving policy can only refuse-more, never admit a collision.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
name = "disjointness"
|
|
183
|
+
|
|
184
|
+
def __init__(self, policy=None) -> None:
|
|
185
|
+
# Lazy import keeps the DAG (`overlap_policy` imports `lane_overlap`, the
|
|
186
|
+
# same leaf `admission` already imports — no cycle, but keep it local so a
|
|
187
|
+
# default-constructed predicate has zero extra import cost on the hot path).
|
|
188
|
+
if policy is None:
|
|
189
|
+
from dos.overlap_policy import PrefixOverlapPolicy
|
|
190
|
+
policy = PrefixOverlapPolicy()
|
|
191
|
+
self._policy = policy
|
|
192
|
+
|
|
193
|
+
def __call__(self, request: AdmissionRequest, live_lease: dict,
|
|
194
|
+
config: object) -> AdmissionVerdict:
|
|
195
|
+
from dos.overlap_policy import admissible_under_floor
|
|
196
|
+
requested_tree = list(request.tree)
|
|
197
|
+
lease_tree = list(live_lease.get("tree") or [])
|
|
198
|
+
if not lease_tree:
|
|
199
|
+
return AdmissionVerdict.admit()
|
|
200
|
+
if not requested_tree:
|
|
201
|
+
return AdmissionVerdict.refuse(
|
|
202
|
+
f"lane {request.lane!r} has an EMPTY tree (unknown blast "
|
|
203
|
+
f"radius) and cannot share live lane "
|
|
204
|
+
f"{live_lease.get('lane')!r} — unknown blast radius is never "
|
|
205
|
+
f"safe to admit concurrently."
|
|
206
|
+
)
|
|
207
|
+
ov = admissible_under_floor(self._policy, requested_tree, lease_tree, config)
|
|
208
|
+
if ov.admissible:
|
|
209
|
+
return AdmissionVerdict.admit()
|
|
210
|
+
return AdmissionVerdict.refuse(
|
|
211
|
+
f"lane {request.lane!r} cannot share live lane "
|
|
212
|
+
f"{live_lease.get('lane')!r}: {ov.reason}."
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def run_predicates(
|
|
217
|
+
predicates: list[AdmissionPredicate],
|
|
218
|
+
request: AdmissionRequest,
|
|
219
|
+
live_leases: list[dict],
|
|
220
|
+
config: object,
|
|
221
|
+
) -> AdmissionVerdict:
|
|
222
|
+
"""Run the conjunction: every predicate against every live lease.
|
|
223
|
+
|
|
224
|
+
Returns the **first refusal** encountered (conjunctive — first refuse wins,
|
|
225
|
+
the conjunction short-circuits) or an admit if every predicate admits
|
|
226
|
+
against every live lease. The order is stable and documented: for each live
|
|
227
|
+
lease in turn, every predicate in ``predicates`` order is consulted; the
|
|
228
|
+
first ``refuse`` returned is the verdict. (Lease-outer / predicate-inner
|
|
229
|
+
mirrors the arbiter's inline per-lease sweep — `_lease_blocks` was checked
|
|
230
|
+
for each live lease — so the FIRST refusing lease is reported, the same lease
|
|
231
|
+
the inline code would have named.)
|
|
232
|
+
|
|
233
|
+
A predicate that **raises** — OR returns anything that is not an
|
|
234
|
+
`AdmissionVerdict` (a buggy plugin returning ``None`` / a dict / a look-alike
|
|
235
|
+
object) — is caught and converted to a refuse naming the predicate
|
|
236
|
+
(fail-closed): a safety hook that cannot give a well-typed answer must not
|
|
237
|
+
admit. This NEVER propagates the exception and NEVER trusts a foreign object's
|
|
238
|
+
truthiness: a buggy predicate degrades to a (safe-direction) refusal, it never
|
|
239
|
+
crashes arbitration and never sneaks an admit through a duck-typed
|
|
240
|
+
``.admitted``. The type check is what makes "a predicate can only refuse"
|
|
241
|
+
hold even against a predicate that does not return our type at all.
|
|
242
|
+
|
|
243
|
+
With ``live_leases == []`` there is no lease to compare against, but the
|
|
244
|
+
conjunction is NOT skipped: it runs once against a synthetic empty lease
|
|
245
|
+
(``{}``) so that **request-absolute** predicates — ones that refuse based on
|
|
246
|
+
the request alone, like `SelfModifyPredicate` (a self-modifying lease is a
|
|
247
|
+
hazard whether or not anything else is live) — still fire on an otherwise
|
|
248
|
+
idle repo. **Lease-relative** predicates (like `DisjointnessPredicate`) see
|
|
249
|
+
the empty lease, hit their "empty lease tree ⇒ admit" branch, and contribute
|
|
250
|
+
nothing — so a free lane with no leases still admits, exactly as before. This
|
|
251
|
+
closes the idle-repo gap the adversarial review found: SELF_MODIFY is no
|
|
252
|
+
longer silently bypassed when ``live_leases`` is empty. (A workspace predicate
|
|
253
|
+
that wants to ignore the no-lease case simply admits when ``live_lease`` is
|
|
254
|
+
falsy — `BudgetGuard` and `SelfModifyPredicate` both answer from the request,
|
|
255
|
+
so they are unaffected by the empty sentinel.)
|
|
256
|
+
"""
|
|
257
|
+
leases = live_leases if live_leases else [{}]
|
|
258
|
+
for lease in leases:
|
|
259
|
+
for pred in predicates:
|
|
260
|
+
name = getattr(pred, "name", type(pred).__name__)
|
|
261
|
+
try:
|
|
262
|
+
verdict = pred(request, lease, config)
|
|
263
|
+
except Exception as e: # fail-closed: a predicate that raises refuses
|
|
264
|
+
return AdmissionVerdict.refuse(
|
|
265
|
+
f"admission predicate {name!r} raised ({e!r}) — refusing "
|
|
266
|
+
f"fail-closed (a safety hook that cannot answer must not "
|
|
267
|
+
f"admit).",
|
|
268
|
+
)
|
|
269
|
+
# A predicate MUST return our `AdmissionVerdict`. Anything else (None,
|
|
270
|
+
# a dict, a duck-typed look-alike) is fail-closed-refused — we never
|
|
271
|
+
# consult a foreign object's `.admitted`, so no admit can leak through
|
|
272
|
+
# a wrong return type (the conjunctive-only invariant must hold even
|
|
273
|
+
# for a predicate that ignores the contract entirely).
|
|
274
|
+
if not isinstance(verdict, AdmissionVerdict):
|
|
275
|
+
return AdmissionVerdict.refuse(
|
|
276
|
+
f"admission predicate {name!r} returned a "
|
|
277
|
+
f"{type(verdict).__name__}, not an AdmissionVerdict — "
|
|
278
|
+
f"refusing fail-closed (a predicate that does not return the "
|
|
279
|
+
f"verdict type cannot be trusted to admit).",
|
|
280
|
+
)
|
|
281
|
+
if not verdict.admitted:
|
|
282
|
+
return verdict
|
|
283
|
+
return AdmissionVerdict.admit()
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
# Phase 3 — workspace predicate discovery via the `dos.predicates` entry-point
|
|
288
|
+
# group. Mirrors `render._discover_entry_point_renderers` exactly: load each
|
|
289
|
+
# registered predicate, append it AFTER the built-ins in the conjunction. The
|
|
290
|
+
# conjunctive runner only honors *refusals*, so a discovered predicate is
|
|
291
|
+
# structurally incapable of loosening admission — there is no "admit harder"
|
|
292
|
+
# return value to misuse. That is the safety contract of the open seam.
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
# The entry-point group a workspace registers a predicate under.
|
|
296
|
+
PREDICATE_ENTRY_POINT_GROUP = "dos.predicates"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _discover_entry_point_predicates(*, _stderr=None) -> list[tuple[str, AdmissionPredicate]]:
|
|
300
|
+
"""Find workspace predicates registered under the `dos.predicates` group.
|
|
301
|
+
|
|
302
|
+
A predicate plugin registers ``name = "pkg.module:PredicateClass"`` in its
|
|
303
|
+
``[project.entry-points."dos.predicates"]``. We load each, instantiate it,
|
|
304
|
+
and return ``(entry_point_name, predicate)`` pairs in sorted-by-name order
|
|
305
|
+
(stable, so `dos doctor` and the conjunction are deterministic).
|
|
306
|
+
|
|
307
|
+
A plugin that fails to load (bad import, constructor raises) is skipped with
|
|
308
|
+
a one-line stderr note rather than crashing every `dos arbitrate` (a broken
|
|
309
|
+
third-party plugin is the operator's to fix, not a kernel fault) — the same
|
|
310
|
+
posture `render._discover_entry_point_renderers` takes. There is no
|
|
311
|
+
built-in-name-collision concern here (unlike renderers): predicates are not
|
|
312
|
+
addressed by name, they are all simply appended to the conjunction, so a
|
|
313
|
+
duplicate name cannot shadow a built-in's behavior — it would only add
|
|
314
|
+
another refuse-only voice, which is always safe.
|
|
315
|
+
"""
|
|
316
|
+
stderr = _stderr if _stderr is not None else sys.stderr
|
|
317
|
+
out: list[tuple[str, AdmissionPredicate]] = []
|
|
318
|
+
try:
|
|
319
|
+
from importlib.metadata import entry_points
|
|
320
|
+
except Exception: # pragma: no cover - importlib.metadata always present py3.11+
|
|
321
|
+
return out
|
|
322
|
+
try:
|
|
323
|
+
eps = entry_points(group=PREDICATE_ENTRY_POINT_GROUP)
|
|
324
|
+
except TypeError: # pragma: no cover - py<3.10 selectable-API fallback
|
|
325
|
+
eps = entry_points().get(PREDICATE_ENTRY_POINT_GROUP, []) # type: ignore[attr-defined]
|
|
326
|
+
except Exception: # pragma: no cover - defensive: never let discovery crash arbitration
|
|
327
|
+
return out
|
|
328
|
+
for ep in sorted(eps, key=lambda e: e.name):
|
|
329
|
+
try:
|
|
330
|
+
obj = ep.load()
|
|
331
|
+
predicate = obj() if isinstance(obj, type) else obj
|
|
332
|
+
except Exception as e: # pragma: no cover - depends on third-party plugin
|
|
333
|
+
print(
|
|
334
|
+
f"warning: admission predicate plugin {ep.name!r} failed to "
|
|
335
|
+
f"load ({e}); skipping",
|
|
336
|
+
file=stderr,
|
|
337
|
+
)
|
|
338
|
+
continue
|
|
339
|
+
out.append((ep.name, predicate))
|
|
340
|
+
return out
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def built_in_predicates(*, workspace=None, config=None) -> list[AdmissionPredicate]:
|
|
344
|
+
"""The always-on predicates, in conjunction order.
|
|
345
|
+
|
|
346
|
+
Disjointness FIRST (the original fixed rule — its refusal is the one
|
|
347
|
+
`--force` is documented to skip), then `SelfModifyPredicate` (the
|
|
348
|
+
self-modification guard). Both are always present; a workspace's discovered
|
|
349
|
+
predicates append AFTER these.
|
|
350
|
+
|
|
351
|
+
Two ways to make the SELF_MODIFY guard **workspace-aware**, in precedence:
|
|
352
|
+
|
|
353
|
+
``config`` (PREFERRED, I/O-FREE) — a `SubstrateConfig` whose
|
|
354
|
+
``workspace`` facts were already gathered at build time
|
|
355
|
+
(`config.gather_workspace_facts`). The guard reads the CACHED
|
|
356
|
+
`config.kernel_runtime_files`, so NO disk access happens here. This is
|
|
357
|
+
what lets `arbiter.arbitrate` thread the config it already holds and stay
|
|
358
|
+
PURE while still scoping the guard to the served repo — the whole reason
|
|
359
|
+
the facts live on the config (see `config.WorkspaceFacts`). A config whose
|
|
360
|
+
facts are ``None`` (never gathered — a hand-built test config) falls
|
|
361
|
+
through to the conservative full set, exactly as `workspace=None` does.
|
|
362
|
+
|
|
363
|
+
``workspace`` (LEGACY, performs I/O) — a bare path. Triggers the existence
|
|
364
|
+
probe (`self_modify.existing_runtime_files`) inline. Kept for the
|
|
365
|
+
`active_predicates(workspace=…)` boundary callers (CLI/MCP/doctor) that
|
|
366
|
+
pass a path rather than a built config; their I/O is already boundary I/O.
|
|
367
|
+
|
|
368
|
+
With NEITHER given, the guard uses the full static `_DISPATCH_RUNTIME_FILES`
|
|
369
|
+
set — conservative: a `**/*` lane is treated as self-modifying when we cannot
|
|
370
|
+
prove otherwise (the safe direction for a safety guard). `config` wins over
|
|
371
|
+
`workspace` when both are passed (cached data beats a redundant probe).
|
|
372
|
+
|
|
373
|
+
Imported lazily from `dos.self_modify` to keep the import graph a DAG
|
|
374
|
+
(`self_modify` pulls `admission`; the list is rebuilt cheaply per call —
|
|
375
|
+
these are tiny stateless objects).
|
|
376
|
+
|
|
377
|
+
The **overlap policy** (the both-known disjointness scorer) is resolved HERE
|
|
378
|
+
too, at the boundary, and threaded into `DisjointnessPredicate(policy=…)` — so
|
|
379
|
+
the pure `arbitrate` never does the discovery I/O that resolving a non-`prefix`
|
|
380
|
+
policy needs. With no `config` (or a config naming no policy / the built-in
|
|
381
|
+
`prefix`), `active_overlap_policy` returns `PrefixOverlapPolicy` with NO
|
|
382
|
+
discovery, so the default predicate list is byte-identical to before the seam.
|
|
383
|
+
A workspace that declares `dos.toml [overlap] policy = "import-graph"` (or sets
|
|
384
|
+
`config.overlap_policy_name`) gets its plugin resolved and AND-ed under the
|
|
385
|
+
deterministic prefix floor inside the predicate.
|
|
386
|
+
"""
|
|
387
|
+
from dos.self_modify import SelfModifyPredicate, existing_runtime_files
|
|
388
|
+
from dos.overlap_policy import active_overlap_policy
|
|
389
|
+
cached = getattr(config, "kernel_runtime_files", None) if config is not None else None
|
|
390
|
+
if cached is not None:
|
|
391
|
+
# I/O-free path: the config already probed the workspace at build time.
|
|
392
|
+
guard = SelfModifyPredicate(runtime_files=tuple(cached))
|
|
393
|
+
elif workspace is not None:
|
|
394
|
+
# Legacy boundary path: probe the workspace now.
|
|
395
|
+
guard = SelfModifyPredicate(runtime_files=existing_runtime_files(workspace))
|
|
396
|
+
else:
|
|
397
|
+
# Conservative: no workspace info → guard against the full static set.
|
|
398
|
+
guard = SelfModifyPredicate()
|
|
399
|
+
policy = active_overlap_policy(config=config)
|
|
400
|
+
return [DisjointnessPredicate(policy=policy), guard]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def active_predicates(*, workspace=None, config=None, _stderr=None) -> list[AdmissionPredicate]:
|
|
404
|
+
"""The full conjunction a CALLER passes into `arbitrate`: built-ins THEN
|
|
405
|
+
discovered plugins.
|
|
406
|
+
|
|
407
|
+
This is the one place the order is composed, and it does ENTRY-POINT
|
|
408
|
+
DISCOVERY (I/O) — so it is called at the CALL BOUNDARY (the CLI's
|
|
409
|
+
`cmd_arbitrate`, `dos doctor`), NOT inside the pure `arbitrate` (whose
|
|
410
|
+
`predicates=None` default is the now-config-aware `built_in_predicates`).
|
|
411
|
+
Built-ins always lead (so a workspace plugin can only ADD a refuse-only voice
|
|
412
|
+
after them, never displace the disjointness/self-modify guards); discovered
|
|
413
|
+
plugins follow in sorted-by-name order.
|
|
414
|
+
|
|
415
|
+
``config`` (PREFERRED) forwards the built config so the SELF_MODIFY guard reads
|
|
416
|
+
its CACHED workspace facts — no redundant probe. ``workspace`` (a bare path) is
|
|
417
|
+
the legacy form that probes inline; both forward to `built_in_predicates`,
|
|
418
|
+
where `config` wins. A boundary caller that already built the config (the CLI
|
|
419
|
+
after `_apply_workspace`) should pass `config=cfg`; one that only has a path
|
|
420
|
+
passes `workspace=`. Either way the I/O is boundary I/O, the same category as
|
|
421
|
+
the entry-point discovery this function always does.
|
|
422
|
+
"""
|
|
423
|
+
discovered = [p for _name, p in _discover_entry_point_predicates(_stderr=_stderr)]
|
|
424
|
+
return built_in_predicates(workspace=workspace, config=config) + discovered
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def active_predicate_names(*, _stderr=None) -> list[str]:
|
|
428
|
+
"""The names of every active predicate (built-in + discovered), in
|
|
429
|
+
conjunction order — what `dos doctor` lists so an operator can see exactly
|
|
430
|
+
what gates their arbiter (the predicate analogue of "see the active reason
|
|
431
|
+
set")."""
|
|
432
|
+
return [getattr(p, "name", type(p).__name__)
|
|
433
|
+
for p in active_predicates(_stderr=_stderr)]
|