dos-kernel 0.22.0__py3-none-win_amd64.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/arbiter.py
ADDED
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
"""The lane-admission kernel — `arbitrate(request, live_leases, config) -> decision`.
|
|
2
|
+
|
|
3
|
+
This is the crown jewel (dispatch-os-vision §4 scheduler / ACR Plane ①): a
|
|
4
|
+
**pure** admission policy. State (the live leases) goes in, a decision comes out,
|
|
5
|
+
no I/O — so the concurrency *policy* is unit-tested without spawning a single
|
|
6
|
+
live loop, and you can *prove* properties about it. Almost no production OS
|
|
7
|
+
scheduler has this property.
|
|
8
|
+
|
|
9
|
+
Extracted from the origin repo's `scripts/fanout_state.py` (`arbitrate_lane`,
|
|
10
|
+
~L2772). The extraction is **lift-and-shift, not redesign**: the decision logic
|
|
11
|
+
is byte-for-byte the proven code. The ONE change is the §5.5.4 mechanism/policy
|
|
12
|
+
split — the origin function reached into `next_up_render._CLUSTERS` and module-
|
|
13
|
+
level `_AUTOPICK_CLUSTERS` / `_EXCLUSIVE_LANES` constants for the *job repo's*
|
|
14
|
+
lane taxonomy. Here those become `SubstrateConfig.lanes` (per-workspace data),
|
|
15
|
+
so the kernel never names a domain lane: point it at a benchmark repo's lanes,
|
|
16
|
+
or a calendar's, or a k8s namespace's, and it arbitrates those unchanged.
|
|
17
|
+
|
|
18
|
+
The vision's **capability-lattice generalization** (every touchable resource a
|
|
19
|
+
lattice node; admit iff the requested capability set is *provably disjoint*) is a
|
|
20
|
+
separate redesign that would sit on top of this pure arbiter — deliberately out
|
|
21
|
+
of scope here (PO4 scope guard / audit G4). This ships the arbiter the lattice
|
|
22
|
+
would later stand on.
|
|
23
|
+
|
|
24
|
+
What this arbiter *is*, named after its mechanism rather than the "lane" metaphor:
|
|
25
|
+
a **lock manager whose granularity is a glob-set** — a lane is a *leased
|
|
26
|
+
predicate-lock over a region of the workspace*, admitted by predicate-disjointness
|
|
27
|
+
(`_tree.prefixes_collide` is the predicate-intersection test; the soft-overlap
|
|
28
|
+
ratio in `lane_overlap` is a *loosened lock-compatibility function*, not a
|
|
29
|
+
swim-lane fudge). The capability-lattice above is then **the same primitive over a
|
|
30
|
+
richer predicate algebra than path-prefixes** — a new `prefixes_collide`, not a new
|
|
31
|
+
arbiter. See `docs/89_the-lane-is-a-region-lock.md` (which is also the forward
|
|
32
|
+
litmus for what belongs in here: a region-lock property, never a swim-lane one).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from typing import Callable
|
|
38
|
+
|
|
39
|
+
from dos._tree import lane_trees_disjoint as _trees_disjoint # noqa: F401
|
|
40
|
+
from dos._tree import tree_disjoint_from_all_live as _disjoint_from_all_live
|
|
41
|
+
from dos.lane_overlap import overlap_verdict
|
|
42
|
+
from dos.config import LaneTaxonomy, SubstrateConfig, ensure
|
|
43
|
+
from dos.admission import (
|
|
44
|
+
AdmissionPredicate,
|
|
45
|
+
AdmissionRequest,
|
|
46
|
+
AdmissionVerdict,
|
|
47
|
+
built_in_predicates,
|
|
48
|
+
run_predicates,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LaneDecision:
|
|
53
|
+
"""Result of `arbitrate` — what a dispatch loop should do at Step 0.
|
|
54
|
+
|
|
55
|
+
``outcome`` is one of:
|
|
56
|
+
'acquire' — admitted; ``lane`` is the lane to lease (may differ from the
|
|
57
|
+
requested lane when auto-pick reassigned it).
|
|
58
|
+
'refuse' — not admitted; ``reason`` explains why, ``free_clusters`` lists
|
|
59
|
+
any cluster lanes the operator could pick instead.
|
|
60
|
+
``auto_picked`` is True when ``lane`` was chosen by auto-pick.
|
|
61
|
+
``pick_count`` is the best-effort pick-availability signal (see ``pick_oracle``).
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
__slots__ = ("outcome", "lane", "lane_kind", "tree", "auto_picked",
|
|
65
|
+
"reason", "free_clusters", "pick_count")
|
|
66
|
+
|
|
67
|
+
def __init__(self, outcome: str, *, lane: str = "", lane_kind: str = "",
|
|
68
|
+
tree: list[str] | None = None, auto_picked: bool = False,
|
|
69
|
+
reason: str = "", free_clusters: list[str] | None = None,
|
|
70
|
+
pick_count: int | None = None):
|
|
71
|
+
self.outcome = outcome
|
|
72
|
+
self.lane = lane
|
|
73
|
+
self.lane_kind = lane_kind
|
|
74
|
+
self.tree = tree or []
|
|
75
|
+
self.auto_picked = auto_picked
|
|
76
|
+
self.reason = reason
|
|
77
|
+
self.free_clusters = free_clusters or []
|
|
78
|
+
self.pick_count = pick_count
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> dict:
|
|
81
|
+
return {
|
|
82
|
+
"outcome": self.outcome, "lane": self.lane,
|
|
83
|
+
"lane_kind": self.lane_kind, "tree": self.tree,
|
|
84
|
+
"auto_picked": self.auto_picked, "reason": self.reason,
|
|
85
|
+
"free_clusters": self.free_clusters,
|
|
86
|
+
"pick_count": self.pick_count,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _lease_blocks(requested_tree: list[str], lease_tree: list[str]) -> bool:
|
|
91
|
+
"""Does an EXISTING lease block a NEW acquire?
|
|
92
|
+
|
|
93
|
+
Empty-tree rules (asymmetric on lease side):
|
|
94
|
+
* empty LEASE tree → does NOT block (a lease that named no blast radius
|
|
95
|
+
cannot claim conflict; otherwise one empty-tree lease wedges every
|
|
96
|
+
subsequent acquire).
|
|
97
|
+
* empty REQUESTED tree vs KNOWN lease tree → blocks (unknown blast radius
|
|
98
|
+
is never safe).
|
|
99
|
+
* both empty → does NOT block (lone-loop safe).
|
|
100
|
+
|
|
101
|
+
Both-known delegates to `dos.lane_overlap.overlap_verdict` — a ratio-only
|
|
102
|
+
soft-overlap policy (admit when ≤30 % of the requested tree shares prefixes
|
|
103
|
+
with the lease). Pure; tested in isolation.
|
|
104
|
+
"""
|
|
105
|
+
if not lease_tree:
|
|
106
|
+
return False
|
|
107
|
+
if not requested_tree:
|
|
108
|
+
return True
|
|
109
|
+
return not overlap_verdict(list(requested_tree), list(lease_tree)).admissible
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _admission_verdict(
|
|
113
|
+
*, lane: str, kind: str, tree: list[str], live_leases: list[dict],
|
|
114
|
+
predicates: list[AdmissionPredicate], config: SubstrateConfig,
|
|
115
|
+
) -> AdmissionVerdict:
|
|
116
|
+
"""Run the FULL admission conjunction for a candidate lane (ADM Phase 1).
|
|
117
|
+
|
|
118
|
+
The single seam every collision check in `arbitrate` now routes through: the
|
|
119
|
+
built-in `DisjointnessPredicate` (a behavior-preserving wrap of the old inline
|
|
120
|
+
`_lease_blocks` / `overlap_verdict`) PLUS `SelfModifyPredicate` PLUS any
|
|
121
|
+
workspace-discovered predicate, composed conjunctively by
|
|
122
|
+
`admission.run_predicates` (first refusal wins; all-admit ⇒ admit; a predicate
|
|
123
|
+
that raises is a fail-closed refuse). The disjointness predicate alone
|
|
124
|
+
reproduces the legacy verdict exactly, so the existing suite stays green; the
|
|
125
|
+
extra predicates can only ADD refusals (the conjunctive-only invariant), never
|
|
126
|
+
loosen, so a disjoint job-lane pair still admits.
|
|
127
|
+
"""
|
|
128
|
+
request = AdmissionRequest(lane=lane, kind=kind, tree=tuple(tree or ()))
|
|
129
|
+
return run_predicates(predicates, request, live_leases, config)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _lease_collision(
|
|
133
|
+
*, lane: str, kind: str, tree: list[str], live_leases: list[dict],
|
|
134
|
+
predicates: list[AdmissionPredicate], config: SubstrateConfig,
|
|
135
|
+
) -> bool:
|
|
136
|
+
"""True iff admitting this candidate lane is refused by ANY predicate against
|
|
137
|
+
ANY live lease — the boolean the auto-pick loops want (they only need "is this
|
|
138
|
+
candidate blocked," then move to the next ladder rung). A thin wrapper over
|
|
139
|
+
`_admission_verdict` so the loops read like the old `any(_lease_blocks(...))`."""
|
|
140
|
+
return not _admission_verdict(
|
|
141
|
+
lane=lane, kind=kind, tree=tree, live_leases=live_leases,
|
|
142
|
+
predicates=predicates, config=config,
|
|
143
|
+
).admitted
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def arbitrate(
|
|
147
|
+
*,
|
|
148
|
+
requested_lane: str,
|
|
149
|
+
requested_kind: str,
|
|
150
|
+
requested_tree: list[str],
|
|
151
|
+
live_leases: list[dict],
|
|
152
|
+
config: SubstrateConfig | None = None,
|
|
153
|
+
auto_pick_order: list[tuple[str, str, list[str]]] | None = None,
|
|
154
|
+
named_lanes: tuple[tuple[str, tuple[str, ...]], ...] | None = None,
|
|
155
|
+
derived_lanes: list[tuple[str, list[str]]] | None = None,
|
|
156
|
+
force: bool = False,
|
|
157
|
+
pick_oracle: Callable[[str, str, list[str]], int | None] | None = None,
|
|
158
|
+
rank_key: Callable[[str, str, list[str]], float | None] | None = None,
|
|
159
|
+
predicates: list[AdmissionPredicate] | None = None,
|
|
160
|
+
class_budgets: dict[str, int] | None = None,
|
|
161
|
+
live_siblings: list[dict] | None = None,
|
|
162
|
+
sibling_tree_lookup: Callable[[str], list[str] | None] | None = None,
|
|
163
|
+
) -> LaneDecision:
|
|
164
|
+
"""PURE admission: decide whether a loop may start, and on which lane.
|
|
165
|
+
|
|
166
|
+
No I/O — ``live_leases`` is passed in, the decision is returned. The lane
|
|
167
|
+
taxonomy (which lanes are concurrent clusters, which are exclusive, each
|
|
168
|
+
lane's canonical tree) comes from ``config.lanes`` (defaulting to the active
|
|
169
|
+
workspace config); the kernel never hard-codes a domain lane name.
|
|
170
|
+
|
|
171
|
+
Inputs:
|
|
172
|
+
requested_lane — the lane the operator asked for ('' = bare invocation).
|
|
173
|
+
requested_kind — 'cluster' | 'keyword' | 'global' | '' (bare → auto-pick).
|
|
174
|
+
requested_tree — the file tree of the requested lane.
|
|
175
|
+
live_leases — dicts with at least {lane, lane_kind, tree}.
|
|
176
|
+
config — the SubstrateConfig whose `lanes` taxonomy to arbitrate
|
|
177
|
+
over (None → the process-active config).
|
|
178
|
+
auto_pick_order — optional `(lane_name, lane_kind, tree)` ladder walked for
|
|
179
|
+
BARE invocations; first lane whose tree is disjoint from
|
|
180
|
+
every live lease wins (or, with `rank_key`, the highest-
|
|
181
|
+
ranked such lane — see below). Takes precedence over the
|
|
182
|
+
legacy named_lanes/derived_lanes.
|
|
183
|
+
named_lanes / derived_lanes — LEGACY pre-ladder fallbacks (kept for the
|
|
184
|
+
replay tests); ignored when `auto_pick_order` is supplied.
|
|
185
|
+
force — OPERATOR OVERRIDE. With an explicit `requested_lane`,
|
|
186
|
+
honor it literally and skip the disjointness/overlap/same-
|
|
187
|
+
lane refuses. The one thing force still respects is a live
|
|
188
|
+
exclusive lane (it holds the shared spine). Never auto-picks.
|
|
189
|
+
pick_oracle — BEST-EFFORT pick-availability gate. `(name, kind, tree) ->
|
|
190
|
+
int | None`; the arbiter SKIPS any auto-pick lane the
|
|
191
|
+
oracle confidently reports as 0 picks. `None` (can't tell)
|
|
192
|
+
means DO NOT skip — oracle failure can only add skips,
|
|
193
|
+
never remove a viable fallback. Only consulted on the
|
|
194
|
+
bare/unresolved-keyword auto-pick path. Pure: the oracle
|
|
195
|
+
does any I/O, not us.
|
|
196
|
+
rank_key — OPTIONAL value-aware picker (docs/91, research-area §3).
|
|
197
|
+
`(name, kind, tree) -> float | None`; when supplied, the
|
|
198
|
+
BARE auto-pick walk visits the `auto_pick_order` candidates
|
|
199
|
+
in DESCENDING rank order instead of ladder order, so the
|
|
200
|
+
admitted lane is the highest-ranked one that is *also*
|
|
201
|
+
disjoint + available — i.e. the argmax over the admissible
|
|
202
|
+
set. `None` for a candidate (or a `rank_key` that raises on
|
|
203
|
+
it) means "no opinion" and sinks that candidate below every
|
|
204
|
+
ranked one, ties (and the all-`None` case) preserving ladder
|
|
205
|
+
order — so `rank_key=None` is byte-identical to the legacy
|
|
206
|
+
first-fit walk (the regression guard). **Soundness floor:**
|
|
207
|
+
rank_key only chooses the ORDER among candidates; it is
|
|
208
|
+
applied *before* the unchanged disjointness + availability
|
|
209
|
+
gate, so it can never admit a colliding lane — the worst a
|
|
210
|
+
bad rank can do is pick a suboptimal-but-still-disjoint lane
|
|
211
|
+
(`rank, never re-admit`, docs/89/§90.3). Like `pick_oracle`,
|
|
212
|
+
it is resolved at the CALL BOUNDARY (a driver/host yield
|
|
213
|
+
estimator); the kernel never learns what "value" means — the
|
|
214
|
+
signal stays driver-side (docs/76). Only consulted on the
|
|
215
|
+
bare auto-pick path with `auto_pick_order` supplied.
|
|
216
|
+
predicates — the admission-predicate conjunction to run the collision
|
|
217
|
+
check through (ADM Phase 1). None → the BUILT-IN set only
|
|
218
|
+
(`DisjointnessPredicate` + `SelfModifyPredicate`), which
|
|
219
|
+
are pure and always-on, so `arbitrate` itself does NO I/O
|
|
220
|
+
on its default path — staying pure exactly as documented.
|
|
221
|
+
Workspace-DISCOVERED `dos.predicates` plugins are resolved
|
|
222
|
+
at the CALL BOUNDARY and passed in (the CLI's
|
|
223
|
+
`cmd_arbitrate` does `admission.active_predicates()` and
|
|
224
|
+
threads the full list here, the same place it discovers a
|
|
225
|
+
renderer) — mirroring how `pick_oracle` is resolved by the
|
|
226
|
+
caller, never inside the pure kernel. A test injects an
|
|
227
|
+
explicit list to run a hermetic conjunction. Predicates
|
|
228
|
+
compose CONJUNCTIVELY — every one must admit — and can
|
|
229
|
+
only REFUSE, never force-admit (the one invariant that
|
|
230
|
+
keeps an open predicate set safe); `--force` remains the
|
|
231
|
+
sole override of a predicate refusal, exactly as for the
|
|
232
|
+
disjointness refuse.
|
|
233
|
+
class_budgets — OPTIONAL concurrency-class budgets (docs/97 Phase 1, the
|
|
234
|
+
worked lesson docs/96). `{lane_kind: max_concurrent}` — how
|
|
235
|
+
many live leases of a given KIND may be held at once. On the
|
|
236
|
+
BARE auto-pick walk, a candidate whose kind is already at its
|
|
237
|
+
budget (live count of that kind >= max_concurrent) is SKIPPED,
|
|
238
|
+
so the arbiter cannot mint an (N+1)-th holder of a budgeted
|
|
239
|
+
class. This is the in-kernel home for the `priority` class
|
|
240
|
+
budget the *host* used to enforce by pre-filtering the
|
|
241
|
+
`auto_pick_order` (job `fanout_state.py`'s
|
|
242
|
+
`_priority_budget_exhausted` drop) — exactly the layering the
|
|
243
|
+
plan moves down. WHY a parameter and not an
|
|
244
|
+
`AdmissionPredicate`: a budget is a CROSS-lease count over the
|
|
245
|
+
whole live set, but a predicate is called once per
|
|
246
|
+
`(request, single-live-lease)` pair and cannot count across
|
|
247
|
+
leases (admission.py "called once per (request, live_lease)
|
|
248
|
+
pair") — so the budget is its own pure step here, the same
|
|
249
|
+
shape docs/97's admission model gives it (step 1 "count live
|
|
250
|
+
leases with class == C", separate from the per-lease region
|
|
251
|
+
resolution). PURE: counts the passed-in `live_leases`, no I/O;
|
|
252
|
+
the host supplies the budget VALUE (its `priority_slots:`
|
|
253
|
+
config), the kernel owns the ADMISSION logic — the docs/97
|
|
254
|
+
DOS-vs-host boundary verbatim. `None` (default) → no budgets →
|
|
255
|
+
byte-identical to the pre-budget walk (the regression guard).
|
|
256
|
+
Only consulted on the bare auto-pick path; a directly-named or
|
|
257
|
+
`--force`d lane is never budget-gated (it is the operator's
|
|
258
|
+
explicit choice, and the same-lane/exclusive refuses already
|
|
259
|
+
bound its concurrency). The N candidates that DO pass the
|
|
260
|
+
budget still bind disjoint regions via the unchanged
|
|
261
|
+
disjointness gate, so this is genuine N-way concurrency, safe
|
|
262
|
+
by construction — never a fixed set of pre-named slots (the
|
|
263
|
+
docs/89 §4.4 swim-lane category error this deliberately avoids).
|
|
264
|
+
live_siblings — OPTIONAL un-leased live runs the bare auto-pick should
|
|
265
|
+
PREFER to avoid colliding with (the single-pick-ceiling fix,
|
|
266
|
+
FQ-449). A lease guards a lane; a *sibling* is a live run that
|
|
267
|
+
holds no lease yet (a freshly-launched loop, an un-leased
|
|
268
|
+
`/dispatch` child) but whose tree this loop will collide with
|
|
269
|
+
post-acquire. Dicts with at least `{lane}`; their trees are
|
|
270
|
+
resolved via `sibling_tree_lookup`. When supplied (with the
|
|
271
|
+
lookup), the BARE walk runs a FIRST pass that admits only a
|
|
272
|
+
candidate whose tree is provably disjoint from EVERY sibling
|
|
273
|
+
(via `dos._tree.tree_disjoint_from_all_live`, the same predicate
|
|
274
|
+
the post-acquire sibling-scan escape uses); only if that pass
|
|
275
|
+
finds nothing does it FALL BACK to the unchanged walk (which may
|
|
276
|
+
return a sibling-overlapping lane — today's behavior). So the
|
|
277
|
+
ceiling shifts: a busy fleet spreads across disjoint lanes instead
|
|
278
|
+
of all colliding on the top-priority cluster, and the worst case
|
|
279
|
+
is byte-identical to the pre-fix walk. `None` (default) → no
|
|
280
|
+
first pass → byte-identical to today (the regression guard). Like
|
|
281
|
+
`pick_oracle`/`rank_key`, the sibling state is gathered at the
|
|
282
|
+
CALL BOUNDARY (the host scans live loop dirs) and passed in; the
|
|
283
|
+
kernel does NO I/O. Only consulted on the bare auto-pick path.
|
|
284
|
+
sibling_tree_lookup — `(lane) -> tree | None`; resolves a sibling's lane name to
|
|
285
|
+
its file tree for the `live_siblings` disjointness pass. Same
|
|
286
|
+
callable shape the sibling-scan escape takes. Ignored unless
|
|
287
|
+
`live_siblings` is non-empty.
|
|
288
|
+
"""
|
|
289
|
+
cfg = ensure(config)
|
|
290
|
+
# The `predicates=None` default is the built-in conjunction — but it must be
|
|
291
|
+
# WORKSPACE-AWARE so a foreign repo's `**/*` lane is not refused as SELF_MODIFY
|
|
292
|
+
# against kernel files that don't exist under it. We pass `config=cfg` (NOT a
|
|
293
|
+
# bare `workspace=` path), so `built_in_predicates` reads the CACHED
|
|
294
|
+
# `cfg.workspace` facts gathered at config-build time — NO disk I/O here, the
|
|
295
|
+
# arbiter stays pure. A config whose facts are None (a hand-built test config
|
|
296
|
+
# that never probed) degrades to the conservative full static set, unchanged.
|
|
297
|
+
# This closes the foreign-repo over-refusal: the arbiter's own default now
|
|
298
|
+
# matches what the CLI's `active_predicates(workspace=cfg.root)` already did.
|
|
299
|
+
preds = predicates if predicates is not None else built_in_predicates(config=cfg)
|
|
300
|
+
lanes: LaneTaxonomy = cfg.lanes
|
|
301
|
+
autopick_clusters = list(lanes.autopick)
|
|
302
|
+
|
|
303
|
+
# Lane NAMES are compared case-INsensitively, the same fold the lane TREES already
|
|
304
|
+
# use (`_cluster_tree` lowercases, the disjointness/self-modify predicates fold via
|
|
305
|
+
# `_tree.norm_tree_prefix`). Without this, `dos arbitrate --lane Orchestration`
|
|
306
|
+
# did NOT match the canonical exclusive `orchestration` lane on a case-insensitive
|
|
307
|
+
# FS, so the run-alone refuse was silently skipped and the request degraded to
|
|
308
|
+
# auto-pick. `_lane_key` is the single fold every NAME membership test below runs
|
|
309
|
+
# through; the original `requested_lane` string is preserved for display/output.
|
|
310
|
+
def _lane_key(name: str) -> str:
|
|
311
|
+
return str(name or "").casefold()
|
|
312
|
+
|
|
313
|
+
exclusive_lanes = {_lane_key(x) for x in lanes.exclusive}
|
|
314
|
+
requested_lane_key = _lane_key(requested_lane)
|
|
315
|
+
|
|
316
|
+
def _cluster_tree(cluster: str) -> list[str]:
|
|
317
|
+
"""The canonical file tree for a cluster lane, from the config taxonomy."""
|
|
318
|
+
return lanes.tree_for(cluster.lower()) or lanes.tree_for(cluster)
|
|
319
|
+
|
|
320
|
+
# Every lane NAME this workspace's taxonomy recognises, case-folded. Used to
|
|
321
|
+
# tell an auto-pick redirect WHY the requested lane was not granted: a name in
|
|
322
|
+
# this set that the picker skipped was *held* ("busy"); a name absent from it
|
|
323
|
+
# was never a lane here at all. Conflating the two makes the kernel narrate a
|
|
324
|
+
# false "was busy" for a typo'd / foreign lane name (docs/104 §4).
|
|
325
|
+
# `global` is the generic exclusive lane every workspace has; any other
|
|
326
|
+
# exclusive lane (e.g. a host's `orchestration`) is already folded in via
|
|
327
|
+
# `*lanes.exclusive`, so no host lane name is hardcoded here.
|
|
328
|
+
_known_lane_keys = {
|
|
329
|
+
_lane_key(n)
|
|
330
|
+
for n in (*lanes.concurrent, *lanes.exclusive, *lanes.autopick,
|
|
331
|
+
*lanes.trees.keys(), *lanes.aliases.keys(),
|
|
332
|
+
"global")
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
def _redirect_why(default_when_busy: str) -> str:
|
|
336
|
+
"""Honest parenthetical for an auto-pick redirect away from a NAMED request.
|
|
337
|
+
|
|
338
|
+
``requested 'X' was busy`` only when X is a real lane this workspace knows
|
|
339
|
+
and the picker found it held; ``'X' is not a lane in this workspace`` when
|
|
340
|
+
the name is unknown — never the former masquerading as a diagnosis of the
|
|
341
|
+
latter.
|
|
342
|
+
"""
|
|
343
|
+
if requested_lane and requested_lane_key not in _known_lane_keys:
|
|
344
|
+
return f"(requested {requested_lane!r} is not a lane in this workspace)"
|
|
345
|
+
return default_when_busy
|
|
346
|
+
|
|
347
|
+
live_lanes = {_lane_key(l.get("lane", "")) for l in live_leases}
|
|
348
|
+
live_kinds = {str(l.get("lane_kind", "")) for l in live_leases}
|
|
349
|
+
|
|
350
|
+
def _free_clusters() -> list[str]:
|
|
351
|
+
return [c for c in autopick_clusters if _lane_key(c) not in live_lanes]
|
|
352
|
+
|
|
353
|
+
# Concurrency-class budgets (docs/97 Phase 1). Normalize once: drop non-positive
|
|
354
|
+
# / non-int budgets (a budget of 0 or a garbled value would silently wedge a
|
|
355
|
+
# whole class — the safe direction is "no budget for that kind", matching the
|
|
356
|
+
# host's `_load_priority_slots` fallback-to-default-on-bad-config posture). The
|
|
357
|
+
# live count per kind is computed once over the passed-in leases (PURE — no I/O).
|
|
358
|
+
_budgets: dict[str, int] = {}
|
|
359
|
+
if class_budgets:
|
|
360
|
+
for _k, _v in class_budgets.items():
|
|
361
|
+
if isinstance(_v, int) and not isinstance(_v, bool) and _v > 0:
|
|
362
|
+
_budgets[str(_k)] = _v
|
|
363
|
+
_live_kind_counts: dict[str, int] = {}
|
|
364
|
+
if _budgets:
|
|
365
|
+
for _l in live_leases:
|
|
366
|
+
_lk = str(_l.get("lane_kind", ""))
|
|
367
|
+
_live_kind_counts[_lk] = _live_kind_counts.get(_lk, 0) + 1
|
|
368
|
+
|
|
369
|
+
def _budget_exhausted(kind: str) -> bool:
|
|
370
|
+
"""True iff class ``kind`` is at (or over) its concurrency budget — the
|
|
371
|
+
live count of leases of this kind has reached ``max_concurrent``. Kinds
|
|
372
|
+
with no declared budget never exhaust (return False). The cross-lease count
|
|
373
|
+
a per-lease `AdmissionPredicate` structurally cannot express (admission.py),
|
|
374
|
+
so it lives here as the docs/97 step-1 budget gate."""
|
|
375
|
+
cap = _budgets.get(str(kind))
|
|
376
|
+
if cap is None:
|
|
377
|
+
return False
|
|
378
|
+
return _live_kind_counts.get(str(kind), 0) >= cap
|
|
379
|
+
|
|
380
|
+
_saw_any_candidate = False
|
|
381
|
+
_all_disjoint_were_zero = True
|
|
382
|
+
# Bookkeeping for the docs/97 budget refuse: True iff the ONLY thing that kept
|
|
383
|
+
# the bare walk from admitting a candidate was a class budget (every otherwise-
|
|
384
|
+
# viable candidate was budget-skipped). Distinguishes "class at budget, wait for
|
|
385
|
+
# a slot" from the generic "ladder exhausted / all 0-pick" refuses, so the
|
|
386
|
+
# operator sees the real cause (and that waiting — not /replan — is the lever).
|
|
387
|
+
_saw_budget_skip = False
|
|
388
|
+
_saw_non_budget_candidate = False
|
|
389
|
+
# The pick count `_admit_lane` last computed — cached so the admit DECISION
|
|
390
|
+
# and the `pick_count` REPORTED on the resulting LaneDecision come from the
|
|
391
|
+
# SAME oracle call. Re-calling `_picks` at the return site (the old code) let
|
|
392
|
+
# a non-deterministic / side-effecting oracle report a count that disagreed
|
|
393
|
+
# with the value that actually drove admission (adversarial-review finding).
|
|
394
|
+
_last_pick_count: int | None = None
|
|
395
|
+
|
|
396
|
+
def _picks(name: str, kind: str, tree: list[str]) -> int | None:
|
|
397
|
+
if pick_oracle is None:
|
|
398
|
+
return None
|
|
399
|
+
try:
|
|
400
|
+
n = pick_oracle(name, kind, list(tree))
|
|
401
|
+
except Exception:
|
|
402
|
+
return None # best-effort: oracle failure never blocks a lane
|
|
403
|
+
return n if isinstance(n, int) else None
|
|
404
|
+
|
|
405
|
+
def _safe_rank(name: str, kind: str, tree: list[str]) -> float | None:
|
|
406
|
+
"""The value-aware picker's rank for one candidate, fail-soft (docs/91).
|
|
407
|
+
|
|
408
|
+
Mirrors `_picks`: a `rank_key` that raises or returns a non-number yields
|
|
409
|
+
`None` ("no opinion"), so a broken estimator degrades to ladder order and
|
|
410
|
+
never blocks or mis-admits a lane (the `pick_oracle` best-effort rule,
|
|
411
|
+
applied to ranking). PURE: the estimator does any I/O, not us.
|
|
412
|
+
"""
|
|
413
|
+
if rank_key is None:
|
|
414
|
+
return None
|
|
415
|
+
try:
|
|
416
|
+
r = rank_key(name, kind, list(tree))
|
|
417
|
+
except Exception:
|
|
418
|
+
return None
|
|
419
|
+
return float(r) if isinstance(r, (int, float)) and not isinstance(r, bool) else None
|
|
420
|
+
|
|
421
|
+
def _ranked(order: list[tuple[str, str, list[str]]]) \
|
|
422
|
+
-> list[tuple[str, str, list[str]]]:
|
|
423
|
+
"""Reorder the bare auto-pick candidates by descending rank (docs/91 §3).
|
|
424
|
+
|
|
425
|
+
STABLE: candidates keep their relative ladder order within an equal rank,
|
|
426
|
+
and a `None` rank (no opinion) sinks below every ranked candidate — so with
|
|
427
|
+
`rank_key is None` the list is returned UNCHANGED (every rank is `None`, the
|
|
428
|
+
sort key is constant, Python's stable sort is a no-op) and the walk below is
|
|
429
|
+
byte-identical to the legacy first-fit. Ranking only chooses the ORDER the
|
|
430
|
+
unchanged disjointness+availability gate is tried in; it cannot admit a
|
|
431
|
+
colliding lane (the soundness floor — `rank, never re-admit`).
|
|
432
|
+
"""
|
|
433
|
+
if rank_key is None:
|
|
434
|
+
return order
|
|
435
|
+
# Sort key: opinionated candidates (rank is not None) first, by rank desc;
|
|
436
|
+
# `enumerate` index makes the sort explicitly stable for ties / no-opinion.
|
|
437
|
+
def _key(item: tuple[int, tuple[str, str, list[str]]]):
|
|
438
|
+
idx, (name, kind, tree_seq) = item
|
|
439
|
+
r = _safe_rank(name, kind or "cluster", list(tree_seq or []))
|
|
440
|
+
has = r is not None
|
|
441
|
+
# descending rank for the opinionated; ladder index ascending as tiebreak
|
|
442
|
+
return (0 if has else 1, -(r or 0.0), idx)
|
|
443
|
+
return [it for _, it in sorted(enumerate(order), key=_key)]
|
|
444
|
+
|
|
445
|
+
def _admit_lane(name: str, kind: str, tree: list[str]) -> bool:
|
|
446
|
+
"""A lane that passed the concurrency check — should we admit it?
|
|
447
|
+
|
|
448
|
+
Clusters/slot/priority/derived: admit unless the oracle is CONFIDENT the
|
|
449
|
+
lane has 0 picks (abstain ⇒ admit). The NAMED rung is INVERTED: require a
|
|
450
|
+
positive pick signal — an abstain there is a skip (the oracle is blind to
|
|
451
|
+
named lanes' file-glob trees, so abstain ≠ "has work"). Updates the
|
|
452
|
+
all-skipped-on-zero bookkeeping for the refuse path.
|
|
453
|
+
"""
|
|
454
|
+
nonlocal _saw_any_candidate, _all_disjoint_were_zero, _last_pick_count
|
|
455
|
+
_saw_any_candidate = True
|
|
456
|
+
n = _picks(name, kind, tree)
|
|
457
|
+
_last_pick_count = n # cache for the pick_count on the resulting decision
|
|
458
|
+
if n is None:
|
|
459
|
+
if kind == "named" and pick_oracle is not None:
|
|
460
|
+
return False
|
|
461
|
+
_all_disjoint_were_zero = False
|
|
462
|
+
return True
|
|
463
|
+
if n <= 0:
|
|
464
|
+
return False
|
|
465
|
+
_all_disjoint_were_zero = False
|
|
466
|
+
return True
|
|
467
|
+
|
|
468
|
+
# An exclusive lane is live → nothing else may start. (force respects this.)
|
|
469
|
+
# The exclusive set is config-declared (`cfg.lanes.exclusive`, folded into
|
|
470
|
+
# `exclusive_lanes`), never a hardcoded host lane name — `global` is the generic
|
|
471
|
+
# constant; a host's own exclusive lanes (e.g. `orchestration`) come from its
|
|
472
|
+
# taxonomy. Fold the live lease kinds the same way before the membership test
|
|
473
|
+
# (`live_kinds` is raw; `exclusive_lanes` is already case-folded).
|
|
474
|
+
if {_lane_key(k) for k in live_kinds} & exclusive_lanes:
|
|
475
|
+
held = next(l for l in live_leases
|
|
476
|
+
if _lane_key(l.get("lane_kind", "")) in exclusive_lanes)
|
|
477
|
+
return LaneDecision(
|
|
478
|
+
"refuse",
|
|
479
|
+
reason=(f"an exclusive lane is live (lane={held.get('lane')!r}, "
|
|
480
|
+
f"kind={held.get('lane_kind')!r}, loop="
|
|
481
|
+
f"{held.get('loop_ts')!r}); it touches the whole "
|
|
482
|
+
f"portfolio — wait for it to finish."
|
|
483
|
+
+ (" (--force cannot override an exclusive live lane.)"
|
|
484
|
+
if force else "")),
|
|
485
|
+
free_clusters=[],
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
# OPERATOR OVERRIDE — `--force` with an explicit lane.
|
|
489
|
+
if force and requested_lane:
|
|
490
|
+
forced_kind = requested_kind or "keyword"
|
|
491
|
+
same_lane = requested_lane_key in live_lanes
|
|
492
|
+
return LaneDecision(
|
|
493
|
+
"acquire", lane=requested_lane, lane_kind=forced_kind,
|
|
494
|
+
tree=requested_tree, auto_picked=False,
|
|
495
|
+
reason=(f"FORCED lane {requested_lane!r} (operator --force; "
|
|
496
|
+
f"lane concern overridden"
|
|
497
|
+
+ (", same-lane sibling present — concurrent edits to the "
|
|
498
|
+
"same tree are now the operator's responsibility"
|
|
499
|
+
if same_lane else "")
|
|
500
|
+
+ (", tree is EMPTY — unknown blast radius accepted"
|
|
501
|
+
if not requested_tree else "")
|
|
502
|
+
+ ")."),
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
# Same-lane collision → refuse (auto-pick can still rescue a cluster request).
|
|
506
|
+
if requested_lane and requested_lane_key in live_lanes:
|
|
507
|
+
if requested_kind == "cluster":
|
|
508
|
+
pass # fall through to auto-pick
|
|
509
|
+
else:
|
|
510
|
+
return LaneDecision(
|
|
511
|
+
"refuse",
|
|
512
|
+
reason=(f"lane {requested_lane!r} is already held by a live "
|
|
513
|
+
f"loop — pick a different --scope or wait."),
|
|
514
|
+
free_clusters=_free_clusters(),
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
# Exclusive-lane request → admit only when nothing else is live. `global` is
|
|
518
|
+
# the generic exclusive KIND every workspace has; any other exclusive lane is
|
|
519
|
+
# recognised via the config-declared `exclusive_lanes` set, never a hardcoded
|
|
520
|
+
# host name.
|
|
521
|
+
if requested_kind == "global" or requested_kind in exclusive_lanes or \
|
|
522
|
+
requested_lane_key in exclusive_lanes:
|
|
523
|
+
if live_leases:
|
|
524
|
+
return LaneDecision(
|
|
525
|
+
"refuse",
|
|
526
|
+
reason=(f"{requested_lane or 'global'!r} is an exclusive lane; "
|
|
527
|
+
f"{len(live_leases)} loop(s) already live. It must run "
|
|
528
|
+
f"alone — wait for them to finish."),
|
|
529
|
+
free_clusters=_free_clusters(),
|
|
530
|
+
)
|
|
531
|
+
# Record the exclusive lane's OWN name as the lease kind (so the live-kind
|
|
532
|
+
# exclusivity check above recognises it), falling back to the generic
|
|
533
|
+
# `global` kind for a bare/global request.
|
|
534
|
+
kind = requested_lane_key if requested_lane_key in exclusive_lanes else "global"
|
|
535
|
+
# Even an exclusive lane (which runs alone, so disjointness is moot) must
|
|
536
|
+
# pass the REQUEST-ABSOLUTE predicates — an `orchestration`/`global` lease
|
|
537
|
+
# whose tree rewrites the kernel's own running code is the SELF_MODIFY
|
|
538
|
+
# hazard, and it must refuse here too (not just on the keyword path). The
|
|
539
|
+
# conjunction runs against the empty-lease sentinel, so disjointness admits
|
|
540
|
+
# and self-modify still fires. `--force` skipped this (handled above).
|
|
541
|
+
verdict = _admission_verdict(
|
|
542
|
+
lane=requested_lane or "global", kind=kind, tree=requested_tree,
|
|
543
|
+
live_leases=live_leases, predicates=preds, config=cfg,
|
|
544
|
+
)
|
|
545
|
+
if not verdict.admitted:
|
|
546
|
+
return LaneDecision(
|
|
547
|
+
"refuse", reason=verdict.reason, free_clusters=_free_clusters(),
|
|
548
|
+
)
|
|
549
|
+
return LaneDecision(
|
|
550
|
+
"acquire", lane=requested_lane or "global", lane_kind=kind,
|
|
551
|
+
tree=requested_tree, auto_picked=False,
|
|
552
|
+
reason=f"exclusive lane {requested_lane or 'global'!r} — no other "
|
|
553
|
+
f"loop live, admitted.",
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
# Cluster request on a FREE lane → admit, but FIRST run the admission
|
|
557
|
+
# conjunction (ADM): the disjointness predicate confirms the cluster's tree is
|
|
558
|
+
# disjoint from every live lease (a free lane NAME can still have an
|
|
559
|
+
# overlapping tree), and the request-absolute predicates (self-modify, budget)
|
|
560
|
+
# fire regardless. Without this gate a `--kind cluster` request would bypass
|
|
561
|
+
# both the collision check AND the SELF_MODIFY guard — the regression the
|
|
562
|
+
# adversarial review caught. `--force` still skips this (handled above).
|
|
563
|
+
if requested_kind == "cluster" and requested_lane_key not in live_lanes:
|
|
564
|
+
tree = requested_tree or _cluster_tree(requested_lane)
|
|
565
|
+
verdict = _admission_verdict(
|
|
566
|
+
lane=requested_lane, kind="cluster", tree=tree,
|
|
567
|
+
live_leases=live_leases, predicates=preds, config=cfg,
|
|
568
|
+
)
|
|
569
|
+
if not verdict.admitted:
|
|
570
|
+
return LaneDecision(
|
|
571
|
+
"refuse", reason=verdict.reason, free_clusters=_free_clusters(),
|
|
572
|
+
)
|
|
573
|
+
return LaneDecision(
|
|
574
|
+
"acquire", lane=requested_lane, lane_kind="cluster",
|
|
575
|
+
tree=tree, auto_picked=False,
|
|
576
|
+
reason=f"cluster lane {requested_lane!r} free — admitted.",
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
# Keyword request with a NON-EMPTY tree → run the admission conjunction
|
|
580
|
+
# (disjointness + self-modify + any workspace predicate). First refusal wins;
|
|
581
|
+
# the disjointness predicate reproduces the old soft-overlap verdict exactly,
|
|
582
|
+
# so a disjoint keyword lane still admits — while a self-modifying one now
|
|
583
|
+
# refuses with the typed SELF_MODIFY reason it could not carry before.
|
|
584
|
+
if requested_kind == "keyword" and requested_tree:
|
|
585
|
+
verdict = _admission_verdict(
|
|
586
|
+
lane=requested_lane, kind="keyword", tree=requested_tree,
|
|
587
|
+
live_leases=live_leases, predicates=preds, config=cfg,
|
|
588
|
+
)
|
|
589
|
+
if not verdict.admitted:
|
|
590
|
+
reason = f"{verdict.reason} Use a free cluster lane instead."
|
|
591
|
+
return LaneDecision(
|
|
592
|
+
"refuse", reason=reason, free_clusters=_free_clusters(),
|
|
593
|
+
)
|
|
594
|
+
return LaneDecision(
|
|
595
|
+
"acquire", lane=requested_lane, lane_kind="keyword",
|
|
596
|
+
tree=requested_tree, auto_picked=False,
|
|
597
|
+
reason=(f"keyword lane {requested_lane!r} admitted (disjoint or "
|
|
598
|
+
f"under the soft-overlap threshold vs all "
|
|
599
|
+
f"{len(live_leases)} live lease(s))."),
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
# Keyword request whose tree is EMPTY → degrade to "take any open plan".
|
|
603
|
+
unresolved_keyword = requested_kind == "keyword" and not requested_tree
|
|
604
|
+
|
|
605
|
+
# UNKNOWN_LANE (docs/104 §4, control-flow arm). An empty-tree keyword splits
|
|
606
|
+
# into two epistemically DIFFERENT requests that the old code conflated:
|
|
607
|
+
# (a) a name THIS workspace knows (a real lane/alias whose live plan just
|
|
608
|
+
# isn't running right now) → legitimate "I wanted that, fall through to
|
|
609
|
+
# auto-pick" — the `_unresolved_suffix` degrade below is correct.
|
|
610
|
+
# (b) a name the taxonomy has NEVER heard of (`playbooks`, a typo, a foreign
|
|
611
|
+
# lane) → the operator asserted a SPECIFIC concern the kernel cannot
|
|
612
|
+
# place. Silently auto-picking a DIFFERENT free lane (CID) is the docs/103
|
|
613
|
+
# disease turned inward: the kernel narrates `acquire` for a region it was
|
|
614
|
+
# not asked about, and the lease then describes the WRONG tree — so
|
|
615
|
+
# disjointness is computed against a region the agent will not touch (a
|
|
616
|
+
# soundness hole, not just a misleading reason). Auto-pick's license is
|
|
617
|
+
# "you expressed NO preference"; it does not extend to substituting a
|
|
618
|
+
# concern you named. The honest verdict is a typed refuse that surfaces
|
|
619
|
+
# the lanes this workspace actually knows — never a guess.
|
|
620
|
+
# This is the control-flow twin of `_redirect_why` (which already fixed the
|
|
621
|
+
# *reason string* for the busy-redirect path but left the DEGRADE in place).
|
|
622
|
+
if (unresolved_keyword and requested_lane
|
|
623
|
+
and requested_lane_key not in _known_lane_keys):
|
|
624
|
+
_known_sorted = sorted(
|
|
625
|
+
{n for n in (*lanes.concurrent, *lanes.exclusive, *lanes.autopick)})
|
|
626
|
+
return LaneDecision(
|
|
627
|
+
"refuse",
|
|
628
|
+
reason=(
|
|
629
|
+
f"UNKNOWN_LANE: {requested_lane!r} is not a lane in this "
|
|
630
|
+
f"workspace, so the kernel will not guess a substitute for it "
|
|
631
|
+
f"(auto-pick only chooses when you express NO preference). "
|
|
632
|
+
f"Known lanes: {', '.join(_known_sorted) or '(none)'}. "
|
|
633
|
+
f"Pass one of those as --scope, run a bare invocation to "
|
|
634
|
+
f"auto-pick any free lane, or register {requested_lane!r} as a "
|
|
635
|
+
f"lane in dos.toml."),
|
|
636
|
+
free_clusters=_free_clusters(),
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
bare = (not requested_lane) or unresolved_keyword
|
|
640
|
+
_unresolved_suffix = (
|
|
641
|
+
f" (requested --scope {requested_lane!r} matched no live plan — "
|
|
642
|
+
f"degraded to auto-pick)" if unresolved_keyword else "")
|
|
643
|
+
|
|
644
|
+
# FQ-449 single-pick-ceiling: the bare auto-pick PREFERS a ladder lane whose
|
|
645
|
+
# tree is provably disjoint from every un-leased live SIBLING, falling back to
|
|
646
|
+
# the unchanged walk only if none is. Active only when the host passed
|
|
647
|
+
# `live_siblings` + `sibling_tree_lookup`; otherwise byte-identical to the
|
|
648
|
+
# pre-fix first-fit. (A lease is already gated by `_lease_collision`; a sibling
|
|
649
|
+
# holds no lease yet but will collide post-acquire — without this, the bare loop
|
|
650
|
+
# picks the top lane, acquires it, then self-arrests at the Step-0 sibling-scan,
|
|
651
|
+
# shipping at most one pick. Spreading the fleet across disjoint lanes at
|
|
652
|
+
# SELECTION time is the structural fix.)
|
|
653
|
+
_sibling_filter_active = bool(live_siblings) and sibling_tree_lookup is not None
|
|
654
|
+
|
|
655
|
+
def _bare_pass(*, require_sibling_disjoint: bool) -> LaneDecision | None:
|
|
656
|
+
"""One walk of the bare auto-pick ladder; returns a decision or None.
|
|
657
|
+
|
|
658
|
+
The legacy first-disjoint-wins loop verbatim, with ONE added gate when
|
|
659
|
+
`require_sibling_disjoint` is True: a candidate that passes the lease
|
|
660
|
+
disjointness + availability gates must ALSO be disjoint from every live
|
|
661
|
+
sibling, else it is skipped. Re-establishes the refuse-path bookkeeping
|
|
662
|
+
from scratch on each call (so whichever pass falls through last leaves the
|
|
663
|
+
canonical flags the refuse branches read). The `nonlocal` set mirrors the
|
|
664
|
+
flags the original inline loop mutated.
|
|
665
|
+
"""
|
|
666
|
+
nonlocal _saw_budget_skip, _saw_non_budget_candidate
|
|
667
|
+
nonlocal _saw_any_candidate, _all_disjoint_were_zero, _last_pick_count
|
|
668
|
+
_saw_budget_skip = False
|
|
669
|
+
_saw_non_budget_candidate = False
|
|
670
|
+
_saw_any_candidate = False
|
|
671
|
+
_all_disjoint_were_zero = True
|
|
672
|
+
_last_pick_count = None
|
|
673
|
+
# `_ranked` reorders by descending rank when a `rank_key` is supplied,
|
|
674
|
+
# leaving the list UNCHANGED otherwise — so over the reordered list this
|
|
675
|
+
# returns the highest-RANKED lane that is also disjoint+available, and with
|
|
676
|
+
# no `rank_key` it is exactly the old first-fit. Ranking picks the order;
|
|
677
|
+
# the gate still decides.
|
|
678
|
+
ranked_picked = rank_key is not None
|
|
679
|
+
for name, kind, tree_seq in _ranked(auto_pick_order):
|
|
680
|
+
tree_list = list(tree_seq or [])
|
|
681
|
+
if not tree_list:
|
|
682
|
+
continue
|
|
683
|
+
if name in live_lanes:
|
|
684
|
+
continue
|
|
685
|
+
# docs/97 Phase 1 — class budget gate, BEFORE the disjointness
|
|
686
|
+
# check: a candidate whose kind is already at its `max_concurrent`
|
|
687
|
+
# is skipped regardless of whether its tree would be disjoint, so
|
|
688
|
+
# the arbiter never mints an (N+1)-th holder of a budgeted class.
|
|
689
|
+
# The check is on `kind or "cluster"` to match the kind the admit
|
|
690
|
+
# path below leases under (an empty kind defaults to "cluster").
|
|
691
|
+
_cand_kind = kind or "cluster"
|
|
692
|
+
if _budget_exhausted(_cand_kind):
|
|
693
|
+
_saw_budget_skip = True
|
|
694
|
+
continue
|
|
695
|
+
_saw_non_budget_candidate = True
|
|
696
|
+
if not _lease_collision(
|
|
697
|
+
lane=name, kind=_cand_kind, tree=tree_list,
|
|
698
|
+
live_leases=live_leases, predicates=preds, config=cfg):
|
|
699
|
+
# FQ-449 first pass: also require sibling-disjointness. A candidate
|
|
700
|
+
# that overlaps a live sibling is skipped here so a LATER ladder
|
|
701
|
+
# lane (disjoint from both leases AND siblings) can win; the second
|
|
702
|
+
# pass (require=False) re-admits it as the unchanged last resort.
|
|
703
|
+
if require_sibling_disjoint and not _disjoint_from_all_live(
|
|
704
|
+
requested_tree=tree_list,
|
|
705
|
+
live=list(live_siblings or []),
|
|
706
|
+
sibling_tree_lookup=sibling_tree_lookup):
|
|
707
|
+
continue
|
|
708
|
+
if not _admit_lane(name, _cand_kind, tree_list):
|
|
709
|
+
continue
|
|
710
|
+
return LaneDecision(
|
|
711
|
+
"acquire", lane=name, lane_kind=_cand_kind,
|
|
712
|
+
tree=tree_list, auto_picked=True,
|
|
713
|
+
reason=(f"auto-picked {_cand_kind} lane {name!r} "
|
|
714
|
+
+ ("by value-aware rank" if ranked_picked
|
|
715
|
+
else "from priority ladder")
|
|
716
|
+
+ (" (disjoint from all live siblings)"
|
|
717
|
+
if require_sibling_disjoint else "")
|
|
718
|
+
+ "." + _unresolved_suffix),
|
|
719
|
+
pick_count=_last_pick_count, # same call that drove admission
|
|
720
|
+
)
|
|
721
|
+
return None
|
|
722
|
+
|
|
723
|
+
if auto_pick_order is not None:
|
|
724
|
+
if bare:
|
|
725
|
+
if _sibling_filter_active:
|
|
726
|
+
_decided = _bare_pass(require_sibling_disjoint=True)
|
|
727
|
+
if _decided is not None:
|
|
728
|
+
return _decided
|
|
729
|
+
# No sibling-disjoint lane on the ladder — fall back to the
|
|
730
|
+
# unchanged walk (which may pick a sibling-overlapping lane, exactly
|
|
731
|
+
# today's behavior; the post-acquire sibling-scan then handles it).
|
|
732
|
+
_decided = _bare_pass(require_sibling_disjoint=False)
|
|
733
|
+
if _decided is not None:
|
|
734
|
+
return _decided
|
|
735
|
+
else:
|
|
736
|
+
for cand in autopick_clusters:
|
|
737
|
+
if cand in live_lanes:
|
|
738
|
+
continue
|
|
739
|
+
cand_tree = _cluster_tree(cand)
|
|
740
|
+
if not _lease_collision(
|
|
741
|
+
lane=cand, kind="cluster", tree=cand_tree,
|
|
742
|
+
live_leases=live_leases, predicates=preds, config=cfg):
|
|
743
|
+
if not _admit_lane(cand, "cluster", cand_tree):
|
|
744
|
+
continue
|
|
745
|
+
return LaneDecision(
|
|
746
|
+
"acquire", lane=cand, lane_kind="cluster",
|
|
747
|
+
tree=cand_tree, auto_picked=True,
|
|
748
|
+
reason=(f"auto-picked free cluster lane {cand!r} "
|
|
749
|
+
+ _redirect_why(
|
|
750
|
+
f"(requested {requested_lane!r} was busy)")
|
|
751
|
+
+ "."),
|
|
752
|
+
pick_count=_last_pick_count, # same call that drove admission
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
# docs/97 Phase 1 — the class-budget refuse, FIRST (most specific cause).
|
|
756
|
+
# When the bare walk admitted nothing AND every candidate it would have
|
|
757
|
+
# tried was budget-skipped (no candidate failed for any OTHER reason), the
|
|
758
|
+
# binding constraint is a concurrency budget, not drained work or a tree
|
|
759
|
+
# collision. Surface that honestly: the lever is "wait for a slot of this
|
|
760
|
+
# class to free" — NOT /replan (the work exists; the class is just full) and
|
|
761
|
+
# NOT --scope (a scoped request of the same kind hits the same budget). The
|
|
762
|
+
# CLASS_BUDGET_EXHAUSTED token mirrors docs/97's named refuse so a downstream
|
|
763
|
+
# cause-classifier can route it distinctly from DRAIN / ladder-exhausted.
|
|
764
|
+
if _saw_budget_skip and not _saw_non_budget_candidate:
|
|
765
|
+
_at = sorted(
|
|
766
|
+
f"{k} ({_live_kind_counts.get(k, 0)}/{_budgets[k]})"
|
|
767
|
+
for k in _budgets if _budget_exhausted(k)
|
|
768
|
+
)
|
|
769
|
+
return LaneDecision(
|
|
770
|
+
"refuse",
|
|
771
|
+
reason=("CLASS_BUDGET_EXHAUSTED: every auto-pick candidate belongs "
|
|
772
|
+
"to a concurrency class already at its max_concurrent "
|
|
773
|
+
f"budget ({', '.join(_at)}) — admitting one would exceed the "
|
|
774
|
+
"budget. The work exists and the regions are fine; the class "
|
|
775
|
+
"is simply full. Wait for a holder of that class to release "
|
|
776
|
+
"(do NOT /replan — there is nothing to refill), or raise the "
|
|
777
|
+
"class budget if the concurrency is genuinely safe."
|
|
778
|
+
+ _unresolved_suffix),
|
|
779
|
+
free_clusters=[c for c in autopick_clusters if c not in live_lanes],
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
if _saw_any_candidate and _all_disjoint_were_zero:
|
|
783
|
+
return LaneDecision(
|
|
784
|
+
"refuse",
|
|
785
|
+
reason=("every concurrency-free lane on the priority ladder has "
|
|
786
|
+
"0 pickable phases right now (all soak-gated / sibling-"
|
|
787
|
+
"gated / already claimed) — leasing one would only DRAIN. "
|
|
788
|
+
"Refusing at Step 0 instead. Run /replan, wait for an open "
|
|
789
|
+
"soak window to close, or pass --scope <lane-with-work>."
|
|
790
|
+
+ _unresolved_suffix),
|
|
791
|
+
free_clusters=[c for c in autopick_clusters if c not in live_lanes],
|
|
792
|
+
pick_count=0,
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
return LaneDecision(
|
|
796
|
+
"refuse",
|
|
797
|
+
reason=("priority ladder exhausted; no lane is free with a tree "
|
|
798
|
+
"disjoint from every live lease. Wait for one to release, "
|
|
799
|
+
"or pass --scope <free-lane> explicitly."
|
|
800
|
+
+ _unresolved_suffix),
|
|
801
|
+
free_clusters=[c for c in autopick_clusters if c not in live_lanes],
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
# ── LEGACY PATH — no ladder supplied. Three-rung fallback. ──────────────
|
|
805
|
+
for cand in autopick_clusters:
|
|
806
|
+
if cand in live_lanes:
|
|
807
|
+
continue
|
|
808
|
+
cand_tree = _cluster_tree(cand)
|
|
809
|
+
if not _lease_collision(
|
|
810
|
+
lane=cand, kind="cluster", tree=cand_tree,
|
|
811
|
+
live_leases=live_leases, predicates=preds, config=cfg):
|
|
812
|
+
if unresolved_keyword:
|
|
813
|
+
why = _unresolved_suffix.strip()
|
|
814
|
+
elif requested_lane:
|
|
815
|
+
why = _redirect_why(f"(requested {requested_lane!r} was busy)")
|
|
816
|
+
else:
|
|
817
|
+
why = "(bare invocation)"
|
|
818
|
+
return LaneDecision(
|
|
819
|
+
"acquire", lane=cand, lane_kind="cluster",
|
|
820
|
+
tree=cand_tree, auto_picked=True,
|
|
821
|
+
reason=f"auto-picked free cluster lane {cand!r} {why}.",
|
|
822
|
+
)
|
|
823
|
+
if bare:
|
|
824
|
+
for name, tree_tup in (named_lanes or ()):
|
|
825
|
+
tree_list = list(tree_tup)
|
|
826
|
+
if not tree_list or name in live_lanes:
|
|
827
|
+
continue
|
|
828
|
+
if not _lease_collision(
|
|
829
|
+
lane=name, kind="named", tree=tree_list,
|
|
830
|
+
live_leases=live_leases, predicates=preds, config=cfg):
|
|
831
|
+
return LaneDecision(
|
|
832
|
+
"acquire", lane=name, lane_kind="named",
|
|
833
|
+
tree=tree_list, auto_picked=True,
|
|
834
|
+
reason=(f"auto-picked named non-cluster lane {name!r} "
|
|
835
|
+
f"(legacy fallback path)."),
|
|
836
|
+
)
|
|
837
|
+
for plan_id, tree_list in (derived_lanes or []):
|
|
838
|
+
if not tree_list or plan_id in live_lanes:
|
|
839
|
+
continue
|
|
840
|
+
if not _lease_collision(
|
|
841
|
+
lane=plan_id, kind="derived", tree=list(tree_list),
|
|
842
|
+
live_leases=live_leases, predicates=preds, config=cfg):
|
|
843
|
+
return LaneDecision(
|
|
844
|
+
"acquire", lane=plan_id, lane_kind="derived",
|
|
845
|
+
tree=list(tree_list), auto_picked=True,
|
|
846
|
+
reason=(f"auto-picked derived plan lane {plan_id!r} "
|
|
847
|
+
f"(legacy fallback path)."),
|
|
848
|
+
)
|
|
849
|
+
return LaneDecision(
|
|
850
|
+
"refuse",
|
|
851
|
+
reason=("all concurrent cluster lanes are held by live loops — no free "
|
|
852
|
+
"lane to auto-pick. Wait for one to finish, then re-invoke."
|
|
853
|
+
+ _unresolved_suffix),
|
|
854
|
+
free_clusters=[],
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
# Back-compat alias: the origin function was named `arbitrate_lane`.
|
|
859
|
+
arbitrate_lane = arbitrate
|