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/supervise.py
ADDED
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
"""SUP — the population verdict: *is the worker roster at its target, and what
|
|
2
|
+
should change to get it there?*
|
|
3
|
+
|
|
4
|
+
The supervisor is DOS's **init / PID-1** for a fleet of dispatch-loops. A
|
|
5
|
+
workspace declares a lane roster (its `[lanes]` taxonomy); each worker holds one
|
|
6
|
+
lane lease and runs a dispatch loop inside it. The supervisor counts the held
|
|
7
|
+
leases against a target population and emits a per-tick PLAN to close the gap —
|
|
8
|
+
SPAWN the lanes that are free, REAP the lanes whose worker is dead, FLAG the ones
|
|
9
|
+
that are spinning, HOLD the ones that are advancing. It keeps N workers alive the
|
|
10
|
+
way `init` keeps the system's services alive: declaratively, by reconciling the
|
|
11
|
+
observed population toward the desired one, never by trusting a worker's own
|
|
12
|
+
report that it is "still working."
|
|
13
|
+
|
|
14
|
+
This module is `liveness`'s sibling — a **pure** verdict function, the
|
|
15
|
+
`arbitrate()` / `classify()` shape:
|
|
16
|
+
|
|
17
|
+
liveness.classify (ProgressEvidence, policy) -> LivenessVerdict
|
|
18
|
+
supervise.supervise (SuperviseEvidence, policy) -> SuperviseVerdict
|
|
19
|
+
^ THIS module
|
|
20
|
+
|
|
21
|
+
All I/O — replaying the lane journal into the live-lease set, classifying each
|
|
22
|
+
run's liveness, reading the clock, and (the output) actually spawning a
|
|
23
|
+
subprocess or writing a journal entry — happens in the CALLER (the `dos loop`
|
|
24
|
+
CLI's evidence-gather and the supervisor driver), never inside `supervise()`.
|
|
25
|
+
`supervise()` makes no subprocess, file, or clock call: the per-lane `Liveness`
|
|
26
|
+
is gathered at the boundary (one `liveness.classify` per run) and frozen onto the
|
|
27
|
+
evidence; the verdict only *reads* it. That is what lets the whole population
|
|
28
|
+
verdict be replay-tested on frozen fixtures, away from anything that needs a live
|
|
29
|
+
multi-minute fleet to reproduce (the `liveness` design value, restated for the
|
|
30
|
+
population axis).
|
|
31
|
+
|
|
32
|
+
`SuperviseVerdict` is a `verdict.py` **COUSIN, not a member** (verdict.py:41-47).
|
|
33
|
+
It shares the `classify` *shape* — closed-enum verdict + one-line `reason` +
|
|
34
|
+
echoed evidence + `to_dict()` — but its output is an **EFFECT decision** (spawn /
|
|
35
|
+
reap / hold / flag), not an epistemic belief about ground-truth state. Like
|
|
36
|
+
`arbitrate()` and `spawn/reap`, it is therefore deliberately NOT registered as a
|
|
37
|
+
`TypedVerdict`: forcing an effect-emitter under the epistemic Protocol would make
|
|
38
|
+
that type a god-type that means nothing. We match the value shape so the JSON /
|
|
39
|
+
MCP / renderer seam is uniform; we do not claim it answers "is this claim true?"
|
|
40
|
+
|
|
41
|
+
The disposition ladder, per lane (the five things a worker can be told):
|
|
42
|
+
|
|
43
|
+
* HOLD — the lane's worker is ADVANCING (≥1 commit / lease event since start).
|
|
44
|
+
Leave it alone. Counts toward the alive population.
|
|
45
|
+
* FLAG — the lane's worker is SPINNING: alive (fresh heartbeat) but landing no
|
|
46
|
+
forward delta. **Advisory only.** The supervisor FLAGS a spinning
|
|
47
|
+
worker, it never auto-reaps it — a SPINNING run still counts as alive
|
|
48
|
+
and keeps its lease. This mirrors `liveness`: SPINNING is the verdict
|
|
49
|
+
with no enforcement home. A FLAG is ALWAYS emitted for a spinner.
|
|
50
|
+
* PROPOSE_HALT — the lane's worker is SPINNING *and has been for longer than the
|
|
51
|
+
policy's `spin_halt_after_ms` threshold*. This is the escalation of the
|
|
52
|
+
FLAG that closes the "acting on a spin" gap (docs/82 LVN-3a, docs/90
|
|
53
|
+
§5): the supervisor PROPOSES the operator/driver halt the stuck worker.
|
|
54
|
+
It is **advisory, never autonomous** — the kernel emits a typed proposal
|
|
55
|
+
and stops there (the docs/99 actuation boundary: the supervisor has no
|
|
56
|
+
standing to halt a peer's control flow and no domain knowledge to kill
|
|
57
|
+
its process). Critically it is **NOT a REAP**: the spinner KEEPS its
|
|
58
|
+
lease, its region stays held, it is never a spawn candidate — so a
|
|
59
|
+
proposal never frees a region or triggers a replacement spawn. The
|
|
60
|
+
proposal rides its OWN verdict tuple (`proposed_halt`), never the
|
|
61
|
+
`reap` channel, so `reap_stalled` semantics and the spawn-refill
|
|
62
|
+
coupling are byte-identical. Off by default (`spin_halt_after_ms=None`).
|
|
63
|
+
* REAP — the lane's worker is STALLED: no fresh heartbeat, no commits — dead or
|
|
64
|
+
hung. Its lease should be released/scavenged so the lane is free
|
|
65
|
+
again. A reaped lane immediately re-enters the spawn-candidate pool in
|
|
66
|
+
the SAME verdict: a STALLED worker yields BOTH a REAP plan AND, if the
|
|
67
|
+
roster is still under target, a replacement SPAWN. (One tick:
|
|
68
|
+
kill-and-refill, the supervisor's whole job.) The line between REAP and
|
|
69
|
+
PROPOSE_HALT is *dead vs alive*: REAP frees a confirmed-dead lease (and
|
|
70
|
+
is safe to enact because a second SIGTERM to a dead pid is a no-op);
|
|
71
|
+
PROPOSE_HALT only *proposes* stopping a worker that is still alive, so
|
|
72
|
+
the kernel must not act on it — stopping a live foreign process is the
|
|
73
|
+
domain knowledge the kernel deliberately lacks (docs/99 §3).
|
|
74
|
+
* SPAWN — the lane is FREE (no live lease) and the population is under its
|
|
75
|
+
admissible target. Emit a spawn LanePlan; the CALLER turns it into a
|
|
76
|
+
shell command line. The kernel never `Popen`s.
|
|
77
|
+
|
|
78
|
+
The population verdict (the roster-level outcome):
|
|
79
|
+
|
|
80
|
+
* AT_TARGET — alive == min(target, admissible) and nothing to spawn.
|
|
81
|
+
* FILLING — alive < target and there is admissible headroom; the
|
|
82
|
+
verdict carries the SPAWN plans that close the gap.
|
|
83
|
+
* TARGET_UNREACHABLE— target > admissible: the roster cannot hold this many
|
|
84
|
+
concurrent disjoint workers no matter what. The reason
|
|
85
|
+
names the fix (declare more pairwise-disjoint concurrent
|
|
86
|
+
lanes in `dos.toml [lanes]`), it is not a transient state.
|
|
87
|
+
It STILL carries a fill-to-admissible SPAWN plan (a roster
|
|
88
|
+
that can hold one worker but was asked for three should
|
|
89
|
+
still run that one worker) — a caller acts on `.spawn`
|
|
90
|
+
regardless of outcome; the outcome only frames WHY the
|
|
91
|
+
population is below target. Also the **no-plan floor**: an
|
|
92
|
+
empty roster has admissible 0, so any positive target is
|
|
93
|
+
UNREACHABLE with no spawns — the verdict still returns, it
|
|
94
|
+
never crashes.
|
|
95
|
+
* OVER_TARGET — alive > target. The supervisor FLAGS the excess (advisory)
|
|
96
|
+
but **never reaps a healthy worker** to shrink the
|
|
97
|
+
population: reaping is reserved for STALLED runs. Choosing
|
|
98
|
+
which healthy worker to retire is an operator/driver call,
|
|
99
|
+
not a mechanical kernel one (the distrust-state /
|
|
100
|
+
distrust-judgment line again).
|
|
101
|
+
|
|
102
|
+
The **no-plan floor** (`test_verify_no_plan` sibling): `supervise()` must return
|
|
103
|
+
a verdict for an empty roster — `SuperviseEvidence(lanes=(), target=N)` yields
|
|
104
|
+
`TARGET_UNREACHABLE` with `admissible=0`, never an exception.
|
|
105
|
+
|
|
106
|
+
The **double-spawn race guard** (the `pending` field): between the tick that
|
|
107
|
+
emits a SPAWN and the tick where that worker's ACQUIRE lands in the journal, the
|
|
108
|
+
lane has no live lease but a spawn is already in flight. If the supervisor
|
|
109
|
+
re-spawned on it every tick in that window it would launch an UNBOUNDED stampede
|
|
110
|
+
of duplicate workers for one lane. The caller marks such a lane `pending=True`
|
|
111
|
+
(it has an in-flight spawn the journal hasn't reflected yet); a pending lane
|
|
112
|
+
COUNTS toward `alive` ("alive-or-coming"), occupies its region for the spawn
|
|
113
|
+
disjointness walk, but is NOT a held lease and is NOT a spawn candidate. The race
|
|
114
|
+
is thus BOUNDED to at most one extra worker per lane per in-flight window, not an
|
|
115
|
+
unbounded stampede — the supervisor analogue of an idempotent reconcile.
|
|
116
|
+
|
|
117
|
+
The **spawn soundness floor**: the SPAWN plan `supervise()` emits is disjoint by
|
|
118
|
+
construction. The spawn walk is a region-aware greedy seeded with the regions of
|
|
119
|
+
every already-alive worker (ADVANCING / counted-SPINNING / pending): a FREE lane
|
|
120
|
+
is emitted only when its tree is `_tree.lane_trees_disjoint` from every alive
|
|
121
|
+
region AND every spawn already chosen this tick. So the plan never proposes two
|
|
122
|
+
workers on overlapping lanes, and never proposes a worker onto a region a live
|
|
123
|
+
worker already holds — even though the worker's own `arbitrate` at Step 0 is the
|
|
124
|
+
authoritative gate (the supervisor's pick is an advisory hint, but an *honest*
|
|
125
|
+
one). This may emit FEWER than the headroom count when candidates collide with
|
|
126
|
+
held regions — that is correct: the headroom was illusory.
|
|
127
|
+
|
|
128
|
+
Admissible is computed PURE from the per-lane trees (docs/89 — a lane is a
|
|
129
|
+
region-lock over a glob-set). It imports the kernel sibling `dos._tree`
|
|
130
|
+
(`scope.py` does the same; the layering litmus is "no host, no I/O", not "no
|
|
131
|
+
sibling import"): two concurrent lanes may hold workers simultaneously only when
|
|
132
|
+
their trees are pairwise disjoint (`_tree.lane_trees_disjoint`, which treats an
|
|
133
|
+
empty or universal/leading-glob tree as never-disjoint — conservative, exactly
|
|
134
|
+
right). The generic default (`main` and `global`, both `**/*`) computes to
|
|
135
|
+
admissible 1: `main`'s universal tree is disjoint from nothing, so no second
|
|
136
|
+
concurrent worker can join. Correct.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
from __future__ import annotations
|
|
140
|
+
|
|
141
|
+
import enum
|
|
142
|
+
from dataclasses import dataclass
|
|
143
|
+
from typing import Optional
|
|
144
|
+
|
|
145
|
+
from dos import _tree
|
|
146
|
+
from dos.liveness import Liveness
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Disposition(str, enum.Enum):
|
|
150
|
+
"""What the supervisor tells one lane to do — a closed, five-valued set.
|
|
151
|
+
|
|
152
|
+
`str`-valued so it round-trips through a CLI stdout token / JSON field
|
|
153
|
+
without a lookup table (mirrors `liveness.Liveness`).
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
SPAWN = "SPAWN" # lane is FREE and under target — start a worker
|
|
157
|
+
REAP = "REAP" # lane's worker is STALLED (dead/hung) — release its lease
|
|
158
|
+
HOLD = "HOLD" # lane's worker is ADVANCING — leave it alone
|
|
159
|
+
FLAG = "FLAG" # lane's worker is SPINNING, or it is excess — advise, don't act
|
|
160
|
+
# lane's worker is SPINNING *past the policy threshold* — the supervisor
|
|
161
|
+
# PROPOSES the operator/driver halt it. NOT a reap (the lease is NOT released)
|
|
162
|
+
# and NOT executed by the kernel — a typed proposal carried in its own verdict
|
|
163
|
+
# tuple, on the docs/99 advisory / PDP-not-PEP floor. The escalation of the
|
|
164
|
+
# FLAG `acting-on-spin` (docs/90 §5) the SPINNING branch's comment named unbuilt.
|
|
165
|
+
PROPOSE_HALT = "PROPOSE_HALT"
|
|
166
|
+
|
|
167
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
168
|
+
return self.value
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class SuperviseOutcome(str, enum.Enum):
|
|
172
|
+
"""The roster-level population verdict — a closed, four-valued set.
|
|
173
|
+
|
|
174
|
+
AT_TARGET / FILLING are the healthy steady-state and the converging-toward-it
|
|
175
|
+
states; TARGET_UNREACHABLE / OVER_TARGET are the two off-target ends (too
|
|
176
|
+
many lanes asked for vs the disjointness ceiling, and too many workers alive).
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
AT_TARGET = "AT_TARGET" # alive == target (within admissible)
|
|
180
|
+
FILLING = "FILLING" # under target, headroom exists — spawning
|
|
181
|
+
TARGET_UNREACHABLE = "TARGET_UNREACHABLE" # target > admissible — declare more lanes
|
|
182
|
+
OVER_TARGET = "OVER_TARGET" # alive > target — excess flagged, not reaped
|
|
183
|
+
|
|
184
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
185
|
+
return self.value
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True)
|
|
189
|
+
class SupervisePolicy:
|
|
190
|
+
"""The knobs that shape the population verdict — policy, not mechanism.
|
|
191
|
+
|
|
192
|
+
The same "mechanism is kernel, thresholds are config" split as
|
|
193
|
+
`liveness.LivenessPolicy`. The defaults are GENERIC (target one worker, count
|
|
194
|
+
spinners as alive, reap the dead); a workspace declares its own in
|
|
195
|
+
`dos.toml [supervise]` read back through `SubstrateConfig`, the
|
|
196
|
+
closed-config-as-data pattern (`[lanes]` / `[liveness]` / `[stamp]`).
|
|
197
|
+
|
|
198
|
+
target — the desired number of live workers across the
|
|
199
|
+
roster. The supervisor fills up to it (bounded by
|
|
200
|
+
admissible) and flags above it.
|
|
201
|
+
count_spinning_as_alive— whether a SPINNING worker counts toward the alive
|
|
202
|
+
population. Default True: a spinning run still holds
|
|
203
|
+
its lease and is up, so re-spawning its lane would
|
|
204
|
+
just duplicate the worker. (SPINNING is advisory —
|
|
205
|
+
we flag it, we don't pretend the lease is free.)
|
|
206
|
+
reap_stalled — whether a STALLED worker yields a REAP plan. Default
|
|
207
|
+
True: a dead/hung worker's lease must be released so
|
|
208
|
+
the lane is free to refill. Set False to make the
|
|
209
|
+
supervisor report-only (no reaps emitted).
|
|
210
|
+
spin_halt_after_ms — the acting-on-spin threshold (docs/90 §5): how long
|
|
211
|
+
a worker may be SPINNING before the supervisor
|
|
212
|
+
escalates its FLAG to a PROPOSE_HALT (a *proposed*,
|
|
213
|
+
never autonomous, stop). Default **None = off** — the
|
|
214
|
+
advisory-only default that reproduces today's
|
|
215
|
+
pure-FLAG behaviour byte-for-byte (the same opt-in
|
|
216
|
+
posture as `reap_stalled=False`). When set, a spinner
|
|
217
|
+
whose `spinning_age_ms` (gathered at the boundary)
|
|
218
|
+
meets or exceeds this many milliseconds ALSO yields a
|
|
219
|
+
PROPOSE_HALT plan. It is NEVER a reap: the spinner
|
|
220
|
+
keeps its lease either way. Keep it generous: too low
|
|
221
|
+
proposes halts on legitimate eventual-consistency
|
|
222
|
+
polling (the `tool_stream` false-resurface hazard).
|
|
223
|
+
worker_launch_template — the operator-facing command the `dos loop` SPAWN plan
|
|
224
|
+
prints for each free lane (`{lane}` is substituted).
|
|
225
|
+
Default is the **vendor-neutral** bare skill
|
|
226
|
+
invocation `/dos-dispatch-loop --lane {lane}` — the
|
|
227
|
+
kernel names no agent-runtime BINARY (the `claude -p
|
|
228
|
+
"…"` wrapper is a vendor specific that belongs in a
|
|
229
|
+
driver / the host's `dos.toml [supervise]`, not the
|
|
230
|
+
kernel CLI). A host that wants the full launcher line
|
|
231
|
+
declares it here.
|
|
232
|
+
max_concurrency — the **derived-claim concurrency cap** (docs/283): the
|
|
233
|
+
number of workers the supervisor may keep alive on a
|
|
234
|
+
REPEATABLE auto-pick lane WITHOUT that many disjoint
|
|
235
|
+
trees being pre-declared in `dos.toml [lanes]`. Default
|
|
236
|
+
**None = off** — admissible stays the pre-declared
|
|
237
|
+
pairwise-disjoint static-lane count (today's behaviour,
|
|
238
|
+
byte-for-byte). When set, a roster that has at least one
|
|
239
|
+
`repeatable` lane (a fungible auto-pick handle whose
|
|
240
|
+
disjointness is enforced PER-PICK by each worker's own
|
|
241
|
+
`arbitrate` at Step 0, not by a fixed tree) may admit up
|
|
242
|
+
to this many concurrent workers — the supervisor synthesises
|
|
243
|
+
the spawn SLOTS onto the repeatable handle, and the arbiter
|
|
244
|
+
remains the authoritative per-pick gate (the supervisor's
|
|
245
|
+
pick was always an advisory hint, docstring ¶ "spawn
|
|
246
|
+
soundness floor"). This is the answer to "a concurrency
|
|
247
|
+
limit I don't have to declare lane-by-lane in advance":
|
|
248
|
+
declare ONE budget number, not N disjoint trees. It NEVER
|
|
249
|
+
lifts the cap above its own value, NEVER overrides a live
|
|
250
|
+
EXCLUSIVE lane (an exclusive worker still caps the
|
|
251
|
+
population at 1), and NEVER weakens the disjointness the
|
|
252
|
+
arbiter enforces — it only stops `_admissible` from
|
|
253
|
+
refusing a target the operator has explicitly budgeted for.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
target: int = 1
|
|
257
|
+
count_spinning_as_alive: bool = True
|
|
258
|
+
reap_stalled: bool = True
|
|
259
|
+
spin_halt_after_ms: Optional[int] = None
|
|
260
|
+
worker_launch_template: str = "/dos-dispatch-loop --lane {lane}"
|
|
261
|
+
max_concurrency: Optional[int] = None
|
|
262
|
+
|
|
263
|
+
def __post_init__(self) -> None:
|
|
264
|
+
if self.target < 0:
|
|
265
|
+
raise ValueError("supervise target must be non-negative")
|
|
266
|
+
if self.spin_halt_after_ms is not None and self.spin_halt_after_ms < 0:
|
|
267
|
+
raise ValueError("supervise spin_halt_after_ms must be non-negative or None")
|
|
268
|
+
if self.max_concurrency is not None and self.max_concurrency < 1:
|
|
269
|
+
raise ValueError("supervise max_concurrency must be >= 1 or None")
|
|
270
|
+
if "{lane}" not in self.worker_launch_template:
|
|
271
|
+
raise ValueError(
|
|
272
|
+
"supervise worker_launch_template must contain the '{lane}' "
|
|
273
|
+
"placeholder (the per-lane substitution point)"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def to_dict(self) -> dict:
|
|
277
|
+
"""The JSON shape `dos doctor --json` publishes (the `cooldown`/`stamp`
|
|
278
|
+
seam-report convention) — the knobs that shape the population verdict, so
|
|
279
|
+
an operator/skill reads the active target + reap + spin-halt posture
|
|
280
|
+
without re-parsing `dos.toml`."""
|
|
281
|
+
return {
|
|
282
|
+
"target": self.target,
|
|
283
|
+
"count_spinning_as_alive": self.count_spinning_as_alive,
|
|
284
|
+
"reap_stalled": self.reap_stalled,
|
|
285
|
+
"spin_halt_after_ms": self.spin_halt_after_ms,
|
|
286
|
+
"worker_launch_template": self.worker_launch_template,
|
|
287
|
+
"max_concurrency": self.max_concurrency,
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
DEFAULT_POLICY = SupervisePolicy()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@dataclass(frozen=True)
|
|
295
|
+
class LaneLiveness:
|
|
296
|
+
"""One lane's observed state, gathered by the CALLER before the call.
|
|
297
|
+
|
|
298
|
+
No journal read, no `liveness.classify`, no clock inside the verdict — the
|
|
299
|
+
arbiter rule. The CLI's evidence-gather (the boundary) replays the lane
|
|
300
|
+
journal into the live-lease set (`lane_journal.replay`), runs one
|
|
301
|
+
`liveness.classify` per held lease, reads each lane's tree from
|
|
302
|
+
`cfg.lanes.tree_for(lane)`, sets `is_exclusive` from the METHOD
|
|
303
|
+
`cfg.lanes.is_exclusive(lane)` (note: that is a method on `LaneTaxonomy`,
|
|
304
|
+
while this is a bool field — fill the field FROM the method), then freezes
|
|
305
|
+
the result here.
|
|
306
|
+
|
|
307
|
+
lane — the lane name (a key in `cfg.lanes.trees`).
|
|
308
|
+
liveness — the per-run `Liveness` verdict for the worker holding this
|
|
309
|
+
lane, or **None when the lane is FREE** (no live lease). A
|
|
310
|
+
None liveness on a non-pending lane is a spawn candidate.
|
|
311
|
+
tree — the lane's glob-set (`cfg.lanes.tree_for(lane)`), used for
|
|
312
|
+
the pairwise-disjointness / admissible computation. A lane
|
|
313
|
+
with no declared tree (`()`) is treated as universal-greedy
|
|
314
|
+
by `_tree.lane_trees_disjoint` (never disjoint).
|
|
315
|
+
is_exclusive — True if the lane is in `cfg.lanes.exclusive`: it never runs
|
|
316
|
+
alongside any other worker (it caps the whole population at
|
|
317
|
+
1 when it is the only thing the roster can admit).
|
|
318
|
+
pending — True if a spawn for this lane is in flight but its ACQUIRE
|
|
319
|
+
has not yet landed in the journal (the double-spawn race
|
|
320
|
+
window). A pending lane COUNTS toward alive but is NOT a held
|
|
321
|
+
lease and is NOT re-spawned. See the module docstring.
|
|
322
|
+
spinning_age_ms— for a SPINNING lane, how long (ms) the worker has been
|
|
323
|
+
spinning — the staleness that MADE it SPINNING. Gathered at
|
|
324
|
+
the boundary from the SAME journal newest-heartbeat-age
|
|
325
|
+
`liveness.classify` already consumed (zero new I/O); the
|
|
326
|
+
verdict only READS it (the arbiter purity rule). `None` when
|
|
327
|
+
not gathered or not applicable (a FREE / ADVANCING lane); a
|
|
328
|
+
`None` here can never produce a PROPOSE_HALT — the kernel
|
|
329
|
+
never proposes a halt on absent evidence (fail-quiet).
|
|
330
|
+
repeatable — True if this lane is a FUNGIBLE auto-pick handle: its
|
|
331
|
+
disjointness is enforced PER-PICK by each worker's own
|
|
332
|
+
`arbitrate` at Step 0, not by a fixed pre-declared tree, so
|
|
333
|
+
MORE THAN ONE worker may hold it at once (each resolves the
|
|
334
|
+
handle to a distinct narrow per-pick claim). The CALLER sets
|
|
335
|
+
it from `cfg.lanes.autopick` membership AND non-exclusivity (an
|
|
336
|
+
exclusive lane is never repeatable). It is the seam the
|
|
337
|
+
`max_concurrency` cap rides: when the policy budgets a
|
|
338
|
+
concurrency higher than the static disjoint-tree count, the
|
|
339
|
+
supervisor may synthesise extra spawn slots ONTO a repeatable
|
|
340
|
+
lane (the derived-claim model, docs/283) instead of demanding
|
|
341
|
+
N pre-declared disjoint trees. Default False = the lane admits
|
|
342
|
+
at most one worker (the pre-declared static-tree behaviour).
|
|
343
|
+
"""
|
|
344
|
+
|
|
345
|
+
lane: str
|
|
346
|
+
liveness: Optional[Liveness] = None
|
|
347
|
+
tree: tuple[str, ...] = ()
|
|
348
|
+
is_exclusive: bool = False
|
|
349
|
+
pending: bool = False
|
|
350
|
+
spinning_age_ms: Optional[int] = None
|
|
351
|
+
repeatable: bool = False
|
|
352
|
+
|
|
353
|
+
def __post_init__(self) -> None:
|
|
354
|
+
if not self.lane:
|
|
355
|
+
raise ValueError("LaneLiveness.lane must be a non-empty lane name")
|
|
356
|
+
if self.repeatable and self.is_exclusive:
|
|
357
|
+
raise ValueError(
|
|
358
|
+
"LaneLiveness cannot be both repeatable and exclusive — an "
|
|
359
|
+
"exclusive lane runs alone, so it can never be a fungible "
|
|
360
|
+
"multi-holder auto-pick handle"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@dataclass(frozen=True)
|
|
365
|
+
class SuperviseEvidence:
|
|
366
|
+
"""Everything `supervise()` needs, gathered by the CALLER before the call.
|
|
367
|
+
|
|
368
|
+
`lanes` is the full roster — one `LaneLiveness` per declared lane, in roster
|
|
369
|
+
order (concurrent lanes then exclusive lanes, declaration-order, de-duped) so
|
|
370
|
+
the SPAWN walk is deterministic. `target` is the desired live population
|
|
371
|
+
(defaulted from policy but pinned here so the verdict echoes the exact target
|
|
372
|
+
it judged against, the `liveness` evidence-echo discipline).
|
|
373
|
+
"""
|
|
374
|
+
|
|
375
|
+
lanes: tuple[LaneLiveness, ...] = ()
|
|
376
|
+
target: int = 1
|
|
377
|
+
|
|
378
|
+
def __post_init__(self) -> None:
|
|
379
|
+
if self.target < 0:
|
|
380
|
+
raise ValueError("supervise evidence target must be non-negative")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@dataclass(frozen=True)
|
|
384
|
+
class LanePlan:
|
|
385
|
+
"""One per-lane instruction in the verdict — lane + disposition + reason.
|
|
386
|
+
|
|
387
|
+
Pure data; the CALLER turns a SPAWN/REAP into a shell command line / journal
|
|
388
|
+
write. A FLAG/HOLD is informational (the operator-facing tally row).
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
lane: str
|
|
392
|
+
disposition: Disposition
|
|
393
|
+
reason: str
|
|
394
|
+
|
|
395
|
+
def to_dict(self) -> dict:
|
|
396
|
+
return {
|
|
397
|
+
"lane": self.lane,
|
|
398
|
+
"disposition": self.disposition.value,
|
|
399
|
+
"reason": self.reason,
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@dataclass(frozen=True)
|
|
404
|
+
class SuperviseVerdict:
|
|
405
|
+
"""The single verdict `supervise()` returns, with the evidence echoed back.
|
|
406
|
+
|
|
407
|
+
`verdict` is the typed `SuperviseOutcome`. `reason` is a one-line
|
|
408
|
+
operator-facing summary (the tally-row string). `spawn` / `reap` / `flag` /
|
|
409
|
+
`proposed_halt` are the per-lane plans, split by what the caller acts on (spawn
|
|
410
|
+
a process / reap a dead lease / surface advisory / surface a *proposed* halt of
|
|
411
|
+
a live-but-stuck worker). `proposed_halt` is a SEPARATE channel from `reap` on
|
|
412
|
+
purpose: a reap frees a confirmed-dead lease (the driver enacts it), a proposed
|
|
413
|
+
halt only PROPOSES stopping a still-alive spinner (the driver surfaces it, never
|
|
414
|
+
enacts it) — folding them would let `reap_stalled` policy or a driver's reap
|
|
415
|
+
code accidentally act on a mere proposal, breaking the docs/99 advisory floor.
|
|
416
|
+
`alive` and `admissible` are the two population counts the verdict turned on,
|
|
417
|
+
carried so `dos loop --output json` emits the verdict *and the facts behind it*
|
|
418
|
+
in one object (the renderer seam) — legible distrust: the operator sees not just
|
|
419
|
+
FILLING but *why* (alive 1 < target 2, admissible 2). `to_dict` is the JSON shape.
|
|
420
|
+
"""
|
|
421
|
+
|
|
422
|
+
verdict: SuperviseOutcome
|
|
423
|
+
reason: str
|
|
424
|
+
evidence: SuperviseEvidence
|
|
425
|
+
spawn: tuple[LanePlan, ...] = ()
|
|
426
|
+
reap: tuple[LanePlan, ...] = ()
|
|
427
|
+
flag: tuple[LanePlan, ...] = ()
|
|
428
|
+
proposed_halt: tuple[LanePlan, ...] = ()
|
|
429
|
+
alive: int = 0
|
|
430
|
+
admissible: int = 0
|
|
431
|
+
|
|
432
|
+
def to_dict(self) -> dict:
|
|
433
|
+
return {
|
|
434
|
+
"verdict": self.verdict.value,
|
|
435
|
+
"reason": self.reason,
|
|
436
|
+
"alive": self.alive,
|
|
437
|
+
"admissible": self.admissible,
|
|
438
|
+
"target": self.evidence.target,
|
|
439
|
+
"spawn": [p.to_dict() for p in self.spawn],
|
|
440
|
+
"reap": [p.to_dict() for p in self.reap],
|
|
441
|
+
"flag": [p.to_dict() for p in self.flag],
|
|
442
|
+
"proposed_halt": [p.to_dict() for p in self.proposed_halt],
|
|
443
|
+
"evidence": {
|
|
444
|
+
"target": self.evidence.target,
|
|
445
|
+
"lanes": [
|
|
446
|
+
{
|
|
447
|
+
"lane": ln.lane,
|
|
448
|
+
"liveness": ln.liveness.value if ln.liveness is not None else None,
|
|
449
|
+
"tree": list(ln.tree),
|
|
450
|
+
"is_exclusive": ln.is_exclusive,
|
|
451
|
+
"pending": ln.pending,
|
|
452
|
+
"spinning_age_ms": ln.spinning_age_ms,
|
|
453
|
+
"repeatable": ln.repeatable,
|
|
454
|
+
}
|
|
455
|
+
for ln in self.evidence.lanes
|
|
456
|
+
],
|
|
457
|
+
},
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _admissible(
|
|
462
|
+
lanes: tuple[LaneLiveness, ...], max_concurrency: Optional[int] = None
|
|
463
|
+
) -> int:
|
|
464
|
+
"""The largest number of workers the roster could hold simultaneously. PURE.
|
|
465
|
+
|
|
466
|
+
A lane is a region-lock over its `tree` (docs/89); two workers may run at once
|
|
467
|
+
only when their trees are pairwise disjoint. So the admissible CONCURRENT
|
|
468
|
+
population is the size of the largest set of concurrent lanes that are pairwise
|
|
469
|
+
tree-disjoint (`_tree.lane_trees_disjoint`, which is conservative: an empty or
|
|
470
|
+
universal/leading-glob tree is disjoint from nothing).
|
|
471
|
+
|
|
472
|
+
Static computation (greedy, deterministic in roster order):
|
|
473
|
+
|
|
474
|
+
* Consider only CONCURRENT lanes (is_exclusive=False). Walk them in roster
|
|
475
|
+
order; admit a lane into the accumulating set S only if its tree is
|
|
476
|
+
disjoint from every lane already in S. |S| is the admissible concurrent
|
|
477
|
+
population. (A universal-tree lane like `main` `**/*` admits first and then
|
|
478
|
+
blocks every later concurrent lane, so the count is 1 — correct: only one
|
|
479
|
+
worker can safely own the whole tree.)
|
|
480
|
+
* If there are NO concurrent lanes but ≥1 EXCLUSIVE lane in the roster, one
|
|
481
|
+
exclusive worker can run alone → admissible = 1.
|
|
482
|
+
* Empty roster → admissible = 0 (the no-plan floor).
|
|
483
|
+
|
|
484
|
+
Exclusive lanes never *add* to a concurrent count (an exclusive worker runs
|
|
485
|
+
alone by definition); they only matter when they are the roster's only option.
|
|
486
|
+
|
|
487
|
+
The DERIVED-CLAIM ceiling (docs/283 — `max_concurrency`): the static count is
|
|
488
|
+
blind to the fungible auto-pick model, where a lane is a HANDLE whose
|
|
489
|
+
disjointness is enforced PER-PICK at acquire time (each worker's own
|
|
490
|
+
`arbitrate` resolves the handle to a distinct narrow claim) rather than by a
|
|
491
|
+
fixed pre-declared tree. A workspace that runs that model can hold many more
|
|
492
|
+
than `static` workers without enumerating that many disjoint trees. So when
|
|
493
|
+
`max_concurrency` is set AND the roster carries ≥1 REPEATABLE lane (a
|
|
494
|
+
non-exclusive auto-pick handle), the admissible ceiling is lifted to
|
|
495
|
+
`max(static, max_concurrency)` — BUT a live exclusive lane still caps the
|
|
496
|
+
population at 1 (an exclusive worker runs alone, full stop), and the cap is
|
|
497
|
+
never raised above its own declared value. With `max_concurrency` unset, or a
|
|
498
|
+
roster with no repeatable lane, the ceiling is exactly the static count — the
|
|
499
|
+
behaviour is byte-for-byte today's.
|
|
500
|
+
"""
|
|
501
|
+
concurrent = [ln for ln in lanes if not ln.is_exclusive]
|
|
502
|
+
admitted: list[LaneLiveness] = []
|
|
503
|
+
for ln in concurrent:
|
|
504
|
+
if all(
|
|
505
|
+
_tree.lane_trees_disjoint(list(ln.tree), list(other.tree))
|
|
506
|
+
for other in admitted
|
|
507
|
+
):
|
|
508
|
+
admitted.append(ln)
|
|
509
|
+
count = len(admitted)
|
|
510
|
+
if count == 0:
|
|
511
|
+
# No concurrent lane could be admitted. If the roster has any exclusive
|
|
512
|
+
# lane at all, one exclusive worker can run alone.
|
|
513
|
+
if any(ln.is_exclusive for ln in lanes):
|
|
514
|
+
# An exclusive-only roster caps at 1 even under a concurrency budget —
|
|
515
|
+
# the budget rides REPEATABLE auto-pick lanes, and an exclusive lane is
|
|
516
|
+
# never repeatable (enforced in LaneLiveness.__post_init__).
|
|
517
|
+
return 1
|
|
518
|
+
return 0
|
|
519
|
+
# A declared concurrency budget lifts the static ceiling, but ONLY when the
|
|
520
|
+
# roster actually carries a fungible repeatable lane to ride it (else the budget
|
|
521
|
+
# is meaningless — there is no handle a second worker could disjointly take).
|
|
522
|
+
if max_concurrency is not None and any(ln.repeatable for ln in lanes):
|
|
523
|
+
return max(count, max_concurrency)
|
|
524
|
+
return count
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def supervise(
|
|
528
|
+
ev: SuperviseEvidence, policy: SupervisePolicy = DEFAULT_POLICY
|
|
529
|
+
) -> SuperviseVerdict:
|
|
530
|
+
"""Reconcile the observed worker population toward `target`. PURE — no I/O.
|
|
531
|
+
|
|
532
|
+
Walks the roster once to classify each lane into a disposition, counts the
|
|
533
|
+
alive population and the regions those workers hold, computes the admissible
|
|
534
|
+
ceiling, then selects the spawn plan that closes the gap WITHOUT proposing an
|
|
535
|
+
overlapping region. The whole thing is a frozen-evidence → typed-verdict
|
|
536
|
+
function: every input (the per-lane `Liveness`, the clock that produced it,
|
|
537
|
+
the journal it was folded from) was gathered at the caller boundary, exactly
|
|
538
|
+
like `liveness.classify`.
|
|
539
|
+
|
|
540
|
+
Per-lane ladder (step 1):
|
|
541
|
+
* pending=True -> HOLD-equivalent: counts toward alive ("alive-or-coming")
|
|
542
|
+
and occupies its region for the spawn-disjointness walk,
|
|
543
|
+
but emits NO plan and is NEVER a spawn candidate (the
|
|
544
|
+
double-spawn race guard).
|
|
545
|
+
* liveness None -> the lane is FREE: a spawn candidate (no plan yet; the
|
|
546
|
+
spawn walk in step 4 decides whether — and whether it can
|
|
547
|
+
disjointly — fill it).
|
|
548
|
+
* ADVANCING -> HOLD; counts toward alive; its region is held.
|
|
549
|
+
* SPINNING -> FLAG always (advisory — we never auto-reap a spinner).
|
|
550
|
+
ADDITIONALLY, if policy.spin_halt_after_ms is set and the
|
|
551
|
+
lane's spinning_age_ms meets it, emit a PROPOSE_HALT into
|
|
552
|
+
the SEPARATE `proposed_halt` channel (acting-on-spin,
|
|
553
|
+
docs/90 §5) — a *proposed* stop, never a reap: the spinner
|
|
554
|
+
keeps its lease and is never a spawn candidate.
|
|
555
|
+
If count_spinning_as_alive: counts toward alive and its
|
|
556
|
+
region is held. If NOT: report-only — it is NOT counted
|
|
557
|
+
alive and NOT refilled (a spinner still holds its lease;
|
|
558
|
+
the supervisor will not displace a live worker).
|
|
559
|
+
* STALLED -> if reap_stalled: emit a REAP and make the lane a spawn
|
|
560
|
+
candidate again (kill-and-refill in one tick). If not
|
|
561
|
+
reap_stalled: report-only, lane is not refilled. A dead
|
|
562
|
+
worker never counts as alive and never holds a region.
|
|
563
|
+
"""
|
|
564
|
+
lanes = ev.lanes
|
|
565
|
+
target = ev.target
|
|
566
|
+
|
|
567
|
+
spawn_candidates: list[LaneLiveness] = [] # FREE / reaped lanes
|
|
568
|
+
held_trees: list[tuple[str, ...]] = [] # regions occupied by alive workers
|
|
569
|
+
reaps: list[LanePlan] = []
|
|
570
|
+
flags: list[LanePlan] = []
|
|
571
|
+
proposed_halts: list[LanePlan] = [] # spinners past the spin_halt threshold
|
|
572
|
+
alive = 0
|
|
573
|
+
|
|
574
|
+
for ln in lanes:
|
|
575
|
+
if ln.pending:
|
|
576
|
+
# A spawn is in flight; the ACQUIRE hasn't landed. Count it as
|
|
577
|
+
# alive-or-coming, hold its region, emit nothing, never re-spawn.
|
|
578
|
+
alive += 1
|
|
579
|
+
held_trees.append(ln.tree)
|
|
580
|
+
continue
|
|
581
|
+
|
|
582
|
+
lv = ln.liveness
|
|
583
|
+
if lv is None:
|
|
584
|
+
# FREE lane — a spawn candidate, no plan emitted yet.
|
|
585
|
+
spawn_candidates.append(ln)
|
|
586
|
+
continue
|
|
587
|
+
|
|
588
|
+
if lv == Liveness.ADVANCING:
|
|
589
|
+
alive += 1
|
|
590
|
+
# A repeatable (fungible auto-pick) lane holds NO fixed region — its
|
|
591
|
+
# claim is narrowed per-pick by the arbiter — so a held repeatable lane
|
|
592
|
+
# must NOT seed `held_trees` (it would block nothing real) and, crucially,
|
|
593
|
+
# may still accept MORE workers up to the concurrency budget. We record
|
|
594
|
+
# it as a re-spawnable handle below rather than locking its tree here.
|
|
595
|
+
if ln.repeatable:
|
|
596
|
+
spawn_candidates.append(ln)
|
|
597
|
+
else:
|
|
598
|
+
held_trees.append(ln.tree)
|
|
599
|
+
continue
|
|
600
|
+
|
|
601
|
+
if lv == Liveness.SPINNING:
|
|
602
|
+
# Advisory FLAG always; whether it counts as alive is policy. A
|
|
603
|
+
# spinner ALWAYS holds its lease, so it is NEVER a spawn candidate —
|
|
604
|
+
# re-spawning its lane would try to displace a live worker. When the
|
|
605
|
+
# policy does not count it as alive it is simply report-only (the
|
|
606
|
+
# `reap_stalled=False` shape).
|
|
607
|
+
flags.append(
|
|
608
|
+
LanePlan(
|
|
609
|
+
lane=ln.lane,
|
|
610
|
+
disposition=Disposition.FLAG,
|
|
611
|
+
reason=(
|
|
612
|
+
"worker is SPINNING (alive, no forward delta) — advisory; "
|
|
613
|
+
"the supervisor flags a spinner, it never auto-reaps it"
|
|
614
|
+
),
|
|
615
|
+
)
|
|
616
|
+
)
|
|
617
|
+
# Acting-on-spin (docs/90 §5): escalate the FLAG to a *proposed* halt
|
|
618
|
+
# when the spin has lasted past the policy threshold. This is the ONLY
|
|
619
|
+
# net-new effect of the SPINNING branch — and it is purely additive:
|
|
620
|
+
# * it appends to `proposed_halts`, NOT to `spawn_candidates`, so the
|
|
621
|
+
# spinner is still never re-spawned;
|
|
622
|
+
# * it does NOT touch `alive` / `held_trees` (those stay governed
|
|
623
|
+
# solely by `count_spinning_as_alive` below), so the population /
|
|
624
|
+
# admissible math — and therefore the disjoint-by-construction spawn
|
|
625
|
+
# plan — is byte-identical to today's;
|
|
626
|
+
# * it NEVER releases the lease (unlike REAP), so the kernel proposes
|
|
627
|
+
# stopping a *live* worker without acting on it (the docs/99 floor).
|
|
628
|
+
# Fail-quiet: a None `spinning_age_ms` (evidence not gathered) can never
|
|
629
|
+
# produce a proposal — the kernel does not propose a halt on absent
|
|
630
|
+
# evidence. Threshold is a `>=` so an exactly-at-threshold spin escalates.
|
|
631
|
+
if (
|
|
632
|
+
policy.spin_halt_after_ms is not None
|
|
633
|
+
and ln.spinning_age_ms is not None
|
|
634
|
+
and ln.spinning_age_ms >= policy.spin_halt_after_ms
|
|
635
|
+
):
|
|
636
|
+
proposed_halts.append(
|
|
637
|
+
LanePlan(
|
|
638
|
+
lane=ln.lane,
|
|
639
|
+
disposition=Disposition.PROPOSE_HALT,
|
|
640
|
+
reason=(
|
|
641
|
+
f"worker has been SPINNING for {ln.spinning_age_ms}ms "
|
|
642
|
+
f"(>= spin_halt_after_ms {policy.spin_halt_after_ms}) — "
|
|
643
|
+
f"PROPOSING a halt (advisory; the operator/driver enacts "
|
|
644
|
+
f"it, the kernel never kills a live worker)"
|
|
645
|
+
),
|
|
646
|
+
)
|
|
647
|
+
)
|
|
648
|
+
if policy.count_spinning_as_alive:
|
|
649
|
+
alive += 1
|
|
650
|
+
held_trees.append(ln.tree)
|
|
651
|
+
continue
|
|
652
|
+
|
|
653
|
+
if lv == Liveness.STALLED:
|
|
654
|
+
if policy.reap_stalled:
|
|
655
|
+
reaps.append(
|
|
656
|
+
LanePlan(
|
|
657
|
+
lane=ln.lane,
|
|
658
|
+
disposition=Disposition.REAP,
|
|
659
|
+
reason=(
|
|
660
|
+
"worker is STALLED (no fresh heartbeat, no commits) — "
|
|
661
|
+
"release the lease; the lane refills if under target"
|
|
662
|
+
),
|
|
663
|
+
)
|
|
664
|
+
)
|
|
665
|
+
# A reaped lane is FREE again this very tick — re-enter the pool.
|
|
666
|
+
spawn_candidates.append(ln)
|
|
667
|
+
# reap_stalled=False: report-only, lane is neither reaped nor refilled
|
|
668
|
+
# (and a dead worker does NOT count as alive — it holds no real lease).
|
|
669
|
+
continue
|
|
670
|
+
|
|
671
|
+
admissible = _admissible(lanes, policy.max_concurrency)
|
|
672
|
+
|
|
673
|
+
# How many we still want to start: never exceed admissible, never go negative.
|
|
674
|
+
spawn_needed = max(0, min(target, admissible) - alive)
|
|
675
|
+
|
|
676
|
+
# The spawn soundness floor: walk candidates in roster order, concurrent lanes
|
|
677
|
+
# first (an exclusive lane only fills if nothing concurrent can — it would cap
|
|
678
|
+
# the population at 1), and admit a candidate ONLY when its region is disjoint
|
|
679
|
+
# from every already-alive worker's region AND every spawn already chosen this
|
|
680
|
+
# tick. The emitted plan is therefore disjoint by construction — it can never
|
|
681
|
+
# propose two workers on overlapping lanes, nor a worker onto a held region.
|
|
682
|
+
# (May emit fewer than spawn_needed when candidates collide — correct: the
|
|
683
|
+
# headroom was illusory.) `sorted` is stable, so roster order holds within each
|
|
684
|
+
# exclusivity group.
|
|
685
|
+
#
|
|
686
|
+
# The derived-claim exception (docs/283 `max_concurrency`): a REPEATABLE free
|
|
687
|
+
# lane is a fungible auto-pick HANDLE, not a fixed-tree region-lock — each
|
|
688
|
+
# worker that takes it resolves it (via its own `arbitrate` at Step 0) to a
|
|
689
|
+
# DISTINCT narrow per-pick claim. So a repeatable lane may be emitted MORE THAN
|
|
690
|
+
# ONCE (one SPAWN per synthesised slot, up to spawn_needed) and its tree is NOT
|
|
691
|
+
# added to `chosen_trees` (it holds no fixed region to collide with). The
|
|
692
|
+
# per-pick disjointness is the arbiter's job, not the supervisor's — the
|
|
693
|
+
# supervisor only budgets the SLOT COUNT (the operator's declared
|
|
694
|
+
# max_concurrency), exactly the "a concurrency limit not declared lane-by-lane"
|
|
695
|
+
# ask. A non-repeatable (fixed-tree) lane keeps the old single-emit, region-locking
|
|
696
|
+
# behaviour byte-for-byte.
|
|
697
|
+
# Order: fixed-tree concurrent lanes FIRST (they fill exactly as before, region
|
|
698
|
+
# by region), THEN repeatable handles (they soak any remaining budget without a
|
|
699
|
+
# region lock), THEN exclusive lanes (only if nothing else can fill). The sort
|
|
700
|
+
# key is a 2-tuple (repeatable-or-exclusive bucket); `sorted` is stable so roster
|
|
701
|
+
# order holds within each bucket. This keeps the no-`max_concurrency` path
|
|
702
|
+
# byte-for-byte (no lane is repeatable there, so the key degenerates to the old
|
|
703
|
+
# `is_exclusive` sort).
|
|
704
|
+
def _spawn_order_key(ln: LaneLiveness) -> tuple[int, int]:
|
|
705
|
+
if ln.is_exclusive:
|
|
706
|
+
return (2, 0)
|
|
707
|
+
if ln.repeatable:
|
|
708
|
+
return (1, 0)
|
|
709
|
+
return (0, 0)
|
|
710
|
+
|
|
711
|
+
ordered = sorted(spawn_candidates, key=_spawn_order_key)
|
|
712
|
+
spawns: list[LanePlan] = []
|
|
713
|
+
chosen_trees: list[tuple[str, ...]] = []
|
|
714
|
+
for ln in ordered:
|
|
715
|
+
if len(spawns) >= spawn_needed:
|
|
716
|
+
break
|
|
717
|
+
if ln.repeatable:
|
|
718
|
+
# A fungible handle: synthesise as many slots as the budget still wants.
|
|
719
|
+
# No disjointness check against held/chosen trees (the handle locks no
|
|
720
|
+
# fixed region — the arbiter narrows each pick at acquire time) and no
|
|
721
|
+
# tree added to chosen_trees (so a later fixed-tree candidate is judged
|
|
722
|
+
# only against real region-locks). Bounded by spawn_needed.
|
|
723
|
+
while len(spawns) < spawn_needed:
|
|
724
|
+
spawns.append(
|
|
725
|
+
LanePlan(
|
|
726
|
+
lane=ln.lane,
|
|
727
|
+
disposition=Disposition.SPAWN,
|
|
728
|
+
reason=(
|
|
729
|
+
f"repeatable auto-pick lane under target "
|
|
730
|
+
f"(alive {alive} < target {target}, admissible "
|
|
731
|
+
f"{admissible}, max_concurrency {policy.max_concurrency}) "
|
|
732
|
+
f"— spawn a worker; the arbiter narrows its per-pick claim"
|
|
733
|
+
),
|
|
734
|
+
)
|
|
735
|
+
)
|
|
736
|
+
continue
|
|
737
|
+
disjoint = all(
|
|
738
|
+
_tree.lane_trees_disjoint(list(ln.tree), list(t))
|
|
739
|
+
for t in held_trees + chosen_trees
|
|
740
|
+
)
|
|
741
|
+
if not disjoint:
|
|
742
|
+
continue
|
|
743
|
+
spawns.append(
|
|
744
|
+
LanePlan(
|
|
745
|
+
lane=ln.lane,
|
|
746
|
+
disposition=Disposition.SPAWN,
|
|
747
|
+
reason=(
|
|
748
|
+
f"lane is free and the roster is under target "
|
|
749
|
+
f"(alive {alive} < target {target}, admissible {admissible}) "
|
|
750
|
+
f"— spawn a worker"
|
|
751
|
+
),
|
|
752
|
+
)
|
|
753
|
+
)
|
|
754
|
+
chosen_trees.append(ln.tree)
|
|
755
|
+
|
|
756
|
+
# Step 5 — the population verdict. Precedence note: TARGET_UNREACHABLE
|
|
757
|
+
# deliberately dominates OVER_TARGET — a roster whose disjointness ceiling is
|
|
758
|
+
# below target is the operator's FIRST lever (raise the ceiling), so it is the
|
|
759
|
+
# more actionable verdict even when the population also happens to be over the
|
|
760
|
+
# (unreachable) target. TARGET_UNREACHABLE still carries the fill-to-admissible
|
|
761
|
+
# spawns computed above (run the workers the roster CAN hold).
|
|
762
|
+
if target > admissible:
|
|
763
|
+
outcome = SuperviseOutcome.TARGET_UNREACHABLE
|
|
764
|
+
reason = (
|
|
765
|
+
f"target {target} exceeds admissible {admissible}: the roster cannot "
|
|
766
|
+
f"hold that many pairwise-disjoint concurrent workers. Declare more "
|
|
767
|
+
f"disjoint concurrent lanes in dos.toml [lanes] to raise the ceiling."
|
|
768
|
+
)
|
|
769
|
+
elif alive > target:
|
|
770
|
+
outcome = SuperviseOutcome.OVER_TARGET
|
|
771
|
+
reason = (
|
|
772
|
+
f"alive {alive} exceeds target {target}: the excess is FLAGGED, not "
|
|
773
|
+
f"reaped — the supervisor never retires a healthy worker (that is an "
|
|
774
|
+
f"operator/driver call). Reaping is reserved for STALLED runs."
|
|
775
|
+
)
|
|
776
|
+
# Flag the healthy excess (advisory). Do NOT emit a reap. Pick the excess
|
|
777
|
+
# from currently-held ADVANCING lanes in reverse roster order so the FLAG
|
|
778
|
+
# is deterministic. NOTE: spinners contributing to the over-count are
|
|
779
|
+
# already FLAGged in the per-lane pass above, and a transient `pending`
|
|
780
|
+
# excess is intentionally not re-flagged (it self-resolves when the ACQUIRE
|
|
781
|
+
# lands), so this best-effort advisory re-flags only the ADVANCING excess.
|
|
782
|
+
excess = alive - target
|
|
783
|
+
held_advancing = [
|
|
784
|
+
ln
|
|
785
|
+
for ln in lanes
|
|
786
|
+
if not ln.pending and ln.liveness == Liveness.ADVANCING
|
|
787
|
+
]
|
|
788
|
+
for ln in reversed(held_advancing):
|
|
789
|
+
if excess <= 0:
|
|
790
|
+
break
|
|
791
|
+
flags.append(
|
|
792
|
+
LanePlan(
|
|
793
|
+
lane=ln.lane,
|
|
794
|
+
disposition=Disposition.FLAG,
|
|
795
|
+
reason=(
|
|
796
|
+
f"healthy worker beyond target {target} (alive {alive}) — "
|
|
797
|
+
f"flagged as excess; retiring it is an operator decision"
|
|
798
|
+
),
|
|
799
|
+
)
|
|
800
|
+
)
|
|
801
|
+
excess -= 1
|
|
802
|
+
elif spawns:
|
|
803
|
+
outcome = SuperviseOutcome.FILLING
|
|
804
|
+
reason = (
|
|
805
|
+
f"alive {alive} < target {target} (admissible {admissible}); spawning "
|
|
806
|
+
f"{len(spawns)} worker(s) to fill the roster"
|
|
807
|
+
)
|
|
808
|
+
else:
|
|
809
|
+
outcome = SuperviseOutcome.AT_TARGET
|
|
810
|
+
reason = (
|
|
811
|
+
f"alive {alive} at target {min(target, admissible)} "
|
|
812
|
+
f"(admissible {admissible}) — roster is full, nothing to spawn"
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
return SuperviseVerdict(
|
|
816
|
+
verdict=outcome,
|
|
817
|
+
reason=reason,
|
|
818
|
+
evidence=ev,
|
|
819
|
+
spawn=tuple(spawns),
|
|
820
|
+
reap=tuple(reaps),
|
|
821
|
+
flag=tuple(flags),
|
|
822
|
+
proposed_halt=tuple(proposed_halts),
|
|
823
|
+
alive=alive,
|
|
824
|
+
admissible=admissible,
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
# ---------------------------------------------------------------------------
|
|
829
|
+
# The `[supervise]` config seam — modelled on `dos.cooldown` / `dos.stamp`.
|
|
830
|
+
# ---------------------------------------------------------------------------
|
|
831
|
+
# The supervisor is DOS's always-on "separate program"; its POLICY (how many
|
|
832
|
+
# workers to keep alive, whether a spinner counts as up, whether to reap the
|
|
833
|
+
# dead) is the same mechanism/policy split every other seam draws — the kernel
|
|
834
|
+
# owns the population verdict, the workspace owns the numbers. Before this seam,
|
|
835
|
+
# `dos loop --target N` was the ONLY way to set the target and the two booleans
|
|
836
|
+
# were unreachable from the operator surface (the CLI hardcoded the policy). Now
|
|
837
|
+
# a workspace declares them ONCE in `dos.toml [supervise]` and BOTH the `dos loop`
|
|
838
|
+
# emitter and the long-lived watchdog driver read the same declaration; an
|
|
839
|
+
# explicit `--target` still overrides the config value at the call boundary (a
|
|
840
|
+
# one-off run wanting a different population than the standing default).
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def policy_from_table(
|
|
844
|
+
table: dict, *, base: SupervisePolicy = DEFAULT_POLICY
|
|
845
|
+
) -> SupervisePolicy:
|
|
846
|
+
"""Build a `SupervisePolicy` from a parsed `[supervise]` TOML table. PURE.
|
|
847
|
+
|
|
848
|
+
Each field the table names overrides ``base``; omitted fields inherit. An
|
|
849
|
+
unknown key raises (the `cooldown.policy_from_table` / `stamp.convention_from_table`
|
|
850
|
+
posture — a typo'd knob is a loud error, not a silent no-op). The validated
|
|
851
|
+
shape: ``target`` must be a non-negative int (delegated to
|
|
852
|
+
`SupervisePolicy.__post_init__`); the two booleans must be real bools;
|
|
853
|
+
``spin_halt_after_ms`` (or the ergonomic ``spin_halt_after_minutes``) must be a
|
|
854
|
+
non-negative number, and an explicit ``spin_halt_after_ms = 0`` / absent leaves
|
|
855
|
+
it at the base (None = the acting-on-spin escalation stays off).
|
|
856
|
+
"""
|
|
857
|
+
if not isinstance(table, dict):
|
|
858
|
+
raise ValueError(f"[supervise] must be a table, got {type(table).__name__}")
|
|
859
|
+
known = {
|
|
860
|
+
"target", "count_spinning_as_alive", "reap_stalled",
|
|
861
|
+
"spin_halt_after_ms", "spin_halt_after_minutes",
|
|
862
|
+
"worker_launch_template", "max_concurrency",
|
|
863
|
+
}
|
|
864
|
+
unknown = set(table) - known
|
|
865
|
+
if unknown:
|
|
866
|
+
raise ValueError(
|
|
867
|
+
f"[supervise] has unknown key(s) {sorted(unknown)}; known keys are {sorted(known)}"
|
|
868
|
+
)
|
|
869
|
+
if "spin_halt_after_ms" in table and "spin_halt_after_minutes" in table:
|
|
870
|
+
raise ValueError(
|
|
871
|
+
"[supervise] declares both spin_halt_after_ms and spin_halt_after_minutes; "
|
|
872
|
+
"pick one"
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
target = base.target
|
|
876
|
+
if "target" in table:
|
|
877
|
+
v = table["target"]
|
|
878
|
+
# A TOML bool is an int subclass; reject it so `target = true` is a clear
|
|
879
|
+
# error rather than silently meaning 1 (the `cooldown._int` guard).
|
|
880
|
+
if isinstance(v, bool) or not isinstance(v, int):
|
|
881
|
+
raise ValueError(
|
|
882
|
+
f"[supervise].target must be a non-negative integer, got {type(v).__name__}"
|
|
883
|
+
)
|
|
884
|
+
target = v
|
|
885
|
+
|
|
886
|
+
def _bool(key: str, current: bool) -> bool:
|
|
887
|
+
if key not in table:
|
|
888
|
+
return current
|
|
889
|
+
v = table[key]
|
|
890
|
+
if not isinstance(v, bool):
|
|
891
|
+
raise ValueError(f"[supervise].{key} must be a boolean, got {type(v).__name__}")
|
|
892
|
+
return v
|
|
893
|
+
|
|
894
|
+
spin_halt_after_ms = base.spin_halt_after_ms
|
|
895
|
+
if "spin_halt_after_ms" in table:
|
|
896
|
+
v = table["spin_halt_after_ms"]
|
|
897
|
+
# A TOML bool is an int subclass; reject it (the `target`/`cooldown` guard).
|
|
898
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
|
899
|
+
raise ValueError(
|
|
900
|
+
f"[supervise].spin_halt_after_ms must be a non-negative number, "
|
|
901
|
+
f"got {type(v).__name__}"
|
|
902
|
+
)
|
|
903
|
+
spin_halt_after_ms = int(v)
|
|
904
|
+
elif "spin_halt_after_minutes" in table:
|
|
905
|
+
v = table["spin_halt_after_minutes"]
|
|
906
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
|
907
|
+
raise ValueError(
|
|
908
|
+
f"[supervise].spin_halt_after_minutes must be a non-negative number, "
|
|
909
|
+
f"got {type(v).__name__}"
|
|
910
|
+
)
|
|
911
|
+
spin_halt_after_ms = int(v * 60 * 1000)
|
|
912
|
+
|
|
913
|
+
worker_launch_template = base.worker_launch_template
|
|
914
|
+
if "worker_launch_template" in table:
|
|
915
|
+
v = table["worker_launch_template"]
|
|
916
|
+
if not isinstance(v, str):
|
|
917
|
+
raise ValueError(
|
|
918
|
+
f"[supervise].worker_launch_template must be a string, got {type(v).__name__}"
|
|
919
|
+
)
|
|
920
|
+
# `{lane}` presence is validated in __post_init__ (loud on a missing
|
|
921
|
+
# placeholder, the same posture as the other knobs).
|
|
922
|
+
worker_launch_template = v
|
|
923
|
+
|
|
924
|
+
max_concurrency = base.max_concurrency
|
|
925
|
+
if "max_concurrency" in table:
|
|
926
|
+
v = table["max_concurrency"]
|
|
927
|
+
# A TOML bool is an int subclass; reject it (the `target` guard). >= 1 is
|
|
928
|
+
# validated in __post_init__ (a budget below 1 is a clear error, not 0-cap).
|
|
929
|
+
if isinstance(v, bool) or not isinstance(v, int):
|
|
930
|
+
raise ValueError(
|
|
931
|
+
f"[supervise].max_concurrency must be a positive integer or absent, "
|
|
932
|
+
f"got {type(v).__name__}"
|
|
933
|
+
)
|
|
934
|
+
max_concurrency = v
|
|
935
|
+
|
|
936
|
+
return SupervisePolicy(
|
|
937
|
+
target=target,
|
|
938
|
+
count_spinning_as_alive=_bool("count_spinning_as_alive", base.count_spinning_as_alive),
|
|
939
|
+
reap_stalled=_bool("reap_stalled", base.reap_stalled),
|
|
940
|
+
spin_halt_after_ms=spin_halt_after_ms,
|
|
941
|
+
worker_launch_template=worker_launch_template,
|
|
942
|
+
max_concurrency=max_concurrency,
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def load_from_toml(
|
|
947
|
+
path, *, base: SupervisePolicy = DEFAULT_POLICY
|
|
948
|
+
) -> SupervisePolicy:
|
|
949
|
+
"""Build a `SupervisePolicy` from a `dos.toml`'s `[supervise]` table.
|
|
950
|
+
|
|
951
|
+
Returns ``base`` unchanged when the file is absent, has no `[supervise]`
|
|
952
|
+
table, or `tomllib` is unavailable. A present-but-malformed table raises.
|
|
953
|
+
Mirrors `cooldown.load_from_toml` (incl. the `utf-8-sig` BOM strip)."""
|
|
954
|
+
from pathlib import Path
|
|
955
|
+
p = Path(path)
|
|
956
|
+
if not p.exists():
|
|
957
|
+
return base
|
|
958
|
+
try:
|
|
959
|
+
import tomllib
|
|
960
|
+
except ModuleNotFoundError: # pragma: no cover - py<3.11 fallback
|
|
961
|
+
try:
|
|
962
|
+
import tomli as tomllib # type: ignore
|
|
963
|
+
except ModuleNotFoundError:
|
|
964
|
+
return base
|
|
965
|
+
data = tomllib.loads(p.read_text(encoding="utf-8-sig"))
|
|
966
|
+
table = data.get("supervise")
|
|
967
|
+
if not isinstance(table, dict) or not table:
|
|
968
|
+
return base
|
|
969
|
+
return policy_from_table(table, base=base)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
# ---------------------------------------------------------------------------
|
|
973
|
+
# The roster-order-sensitivity lint — the spawn-ranking "descope" (docs/210 §pivot).
|
|
974
|
+
# ---------------------------------------------------------------------------
|
|
975
|
+
# Value-aware spawn RANKING (fill the highest-value free lane first) was
|
|
976
|
+
# investigated and DECLINED: the spawn walk is a greedy disjointness walk whose
|
|
977
|
+
# ORDER changes the outcome only under a triple-rare condition — two CONCURRENT
|
|
978
|
+
# lanes whose regions OVERLAP, while the population is capacity-limited so the walk
|
|
979
|
+
# can't fill them both. In the designed disjoint-concurrent norm (docs/89) every
|
|
980
|
+
# free lane spawns regardless of order, so ranking is a measured no-op; and the one
|
|
981
|
+
# legitimate value-ordering seam already exists (the arbiter's `rank_key`, docs/91),
|
|
982
|
+
# so a second supervisor ranker would be debt. The honest fix for the rare
|
|
983
|
+
# order-sensitive case is therefore a CONFIG-TIME lint, not a runtime ranker: name
|
|
984
|
+
# the overlapping-concurrent-lane pairs so the operator declares disjoint lanes (or
|
|
985
|
+
# marks one exclusive), removing the order-sensitivity at its source. Pure + read-only.
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def overlapping_concurrent_lanes(
|
|
989
|
+
lanes: tuple[tuple[str, tuple[str, ...], bool], ...],
|
|
990
|
+
) -> tuple[tuple[str, str], ...]:
|
|
991
|
+
"""The CONCURRENT lane PAIRS whose regions overlap — the only roster shape in
|
|
992
|
+
which the spawn walk's ORDER changes which lanes get filled. PURE.
|
|
993
|
+
|
|
994
|
+
``lanes`` is ``(lane_name, tree, is_exclusive)`` per declared lane (the caller
|
|
995
|
+
gathers it from `cfg.lanes`). Two lanes are an overlap finding iff BOTH are
|
|
996
|
+
concurrent (an exclusive lane runs alone — it never co-spawns, so its overlap
|
|
997
|
+
is moot) AND their trees are NOT disjoint by `_tree.lane_trees_disjoint` (the
|
|
998
|
+
same predicate the admissible computation uses; a treeless/universal lane is
|
|
999
|
+
never disjoint, so it overlaps every concurrent peer — correct, that lane caps
|
|
1000
|
+
concurrency at 1 and makes the rest of the roster order-sensitive). Pairs are
|
|
1001
|
+
returned in a stable (name-sorted) order, each pair name-sorted, so the lint is
|
|
1002
|
+
deterministic. An empty tuple means the roster is order-INSENSITIVE: every free
|
|
1003
|
+
lane that can be admitted is disjoint from every other, so spawn order is
|
|
1004
|
+
irrelevant and value-aware ranking would be a no-op (the investigated finding).
|
|
1005
|
+
"""
|
|
1006
|
+
concurrent = [(name, tree) for (name, tree, exclusive) in lanes if not exclusive]
|
|
1007
|
+
pairs: list[tuple[str, str]] = []
|
|
1008
|
+
for i in range(len(concurrent)):
|
|
1009
|
+
for j in range(i + 1, len(concurrent)):
|
|
1010
|
+
a_name, a_tree = concurrent[i]
|
|
1011
|
+
b_name, b_tree = concurrent[j]
|
|
1012
|
+
if not _tree.lane_trees_disjoint(list(a_tree), list(b_tree)):
|
|
1013
|
+
pairs.append(tuple(sorted((a_name, b_name)))) # type: ignore[arg-type]
|
|
1014
|
+
return tuple(sorted(set(pairs)))
|