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.
Files changed (178) hide show
  1. dos/__init__.py +261 -0
  2. dos/_bin/dos-hook.exe +0 -0
  3. dos/_filelock.py +255 -0
  4. dos/_job_policy.py +97 -0
  5. dos/_tree.py +145 -0
  6. dos/admission.py +433 -0
  7. dos/answer_shape.py +299 -0
  8. dos/arbiter.py +859 -0
  9. dos/archive_lock.py +266 -0
  10. dos/arg_provenance.py +814 -0
  11. dos/attest.py +472 -0
  12. dos/breaker.py +311 -0
  13. dos/churn.py +226 -0
  14. dos/claim_extract.py +229 -0
  15. dos/claim_ttl.py +150 -0
  16. dos/cli.py +8721 -0
  17. dos/commit_audit.py +666 -0
  18. dos/completion.py +466 -0
  19. dos/concurrency_class.py +154 -0
  20. dos/config.py +1380 -0
  21. dos/config_lint.py +464 -0
  22. dos/cooldown.py +390 -0
  23. dos/coverage.py +387 -0
  24. dos/dangling_intent.py +287 -0
  25. dos/data_class.py +397 -0
  26. dos/decisions.py +1274 -0
  27. dos/decisions_tui.py +251 -0
  28. dos/dispatch_top.py +740 -0
  29. dos/dispatch_top_tui.py +116 -0
  30. dos/drivers/__init__.py +40 -0
  31. dos/drivers/ci_status.py +630 -0
  32. dos/drivers/citation_resolve.py +703 -0
  33. dos/drivers/decision_stop.py +98 -0
  34. dos/drivers/export_file.py +173 -0
  35. dos/drivers/export_otlp.py +275 -0
  36. dos/drivers/export_statsd.py +242 -0
  37. dos/drivers/hook_dialects.py +391 -0
  38. dos/drivers/job.py +47 -0
  39. dos/drivers/llm_judge.py +360 -0
  40. dos/drivers/memory_recall.py +1231 -0
  41. dos/drivers/notify_slack.py +373 -0
  42. dos/drivers/notify_webhook.py +251 -0
  43. dos/drivers/operator_judge.py +114 -0
  44. dos/drivers/os_acceptance.py +228 -0
  45. dos/drivers/paste_log.py +132 -0
  46. dos/drivers/plan_scope.py +133 -0
  47. dos/drivers/self_improve.py +375 -0
  48. dos/drivers/similarity_judge.py +249 -0
  49. dos/drivers/state_diff.py +274 -0
  50. dos/drivers/supervisor.py +347 -0
  51. dos/drivers/watchdog.py +363 -0
  52. dos/drivers/workshop.py +160 -0
  53. dos/durable_schema.py +344 -0
  54. dos/effect_witness.py +393 -0
  55. dos/efficiency.py +318 -0
  56. dos/enforce.py +414 -0
  57. dos/enumerate.py +776 -0
  58. dos/env_print.py +378 -0
  59. dos/event_severity.py +258 -0
  60. dos/evidence.py +692 -0
  61. dos/exec_capability.py +256 -0
  62. dos/export_cursor.py +143 -0
  63. dos/exporter.py +320 -0
  64. dos/firing_label.py +353 -0
  65. dos/fleet_roll.py +226 -0
  66. dos/gate_classify.py +827 -0
  67. dos/gh4_coverage.py +179 -0
  68. dos/git_delta.py +122 -0
  69. dos/guard.py +215 -0
  70. dos/health.py +552 -0
  71. dos/help_summary.py +519 -0
  72. dos/home.py +934 -0
  73. dos/hook_binary.py +194 -0
  74. dos/hook_dialect.py +271 -0
  75. dos/hook_exit.py +191 -0
  76. dos/hook_install.py +437 -0
  77. dos/id_alloc.py +304 -0
  78. dos/improve.py +499 -0
  79. dos/intent_ledger.py +635 -0
  80. dos/interpret.py +176 -0
  81. dos/intervention.py +769 -0
  82. dos/intervention_eval.py +371 -0
  83. dos/journal_delta.py +308 -0
  84. dos/judge_eval.py +328 -0
  85. dos/judges.py +366 -0
  86. dos/lane_infer.py +127 -0
  87. dos/lane_journal.py +1001 -0
  88. dos/lane_lease.py +952 -0
  89. dos/lane_overlap.py +228 -0
  90. dos/lease_health.py +282 -0
  91. dos/lifecycle.py +211 -0
  92. dos/liveness.py +352 -0
  93. dos/lock_modes.py +185 -0
  94. dos/log_source.py +395 -0
  95. dos/loop_decide.py +1746 -0
  96. dos/marker_gate.py +254 -0
  97. dos/marker_sensor.py +396 -0
  98. dos/noop_streak.py +280 -0
  99. dos/notify.py +479 -0
  100. dos/observe.py +175 -0
  101. dos/oracle.py +1661 -0
  102. dos/overlap_eval.py +214 -0
  103. dos/overlap_policy.py +342 -0
  104. dos/packet_sidecar.py +267 -0
  105. dos/phase_shipped.py +1985 -0
  106. dos/pick_priority.py +225 -0
  107. dos/pickable.py +369 -0
  108. dos/picker_oracle.py +1037 -0
  109. dos/plan_board.py +513 -0
  110. dos/plan_board_tui.py +113 -0
  111. dos/plan_source.py +455 -0
  112. dos/posttool_sensor.py +528 -0
  113. dos/precursor_gate.py +499 -0
  114. dos/precursor_gate_eval.py +239 -0
  115. dos/preflight.py +825 -0
  116. dos/pretool_sensor.py +490 -0
  117. dos/proc_delta.py +181 -0
  118. dos/productivity.py +296 -0
  119. dos/provider_limit.py +242 -0
  120. dos/py.typed +4 -0
  121. dos/reason_morphology.py +299 -0
  122. dos/reasons.py +449 -0
  123. dos/reconcile.py +173 -0
  124. dos/recurring_wedge.py +206 -0
  125. dos/render.py +393 -0
  126. dos/result_state.py +468 -0
  127. dos/resume.py +578 -0
  128. dos/resume_evidence.py +293 -0
  129. dos/retention.py +344 -0
  130. dos/reward.py +372 -0
  131. dos/rewind.py +587 -0
  132. dos/rewind_evidence.py +168 -0
  133. dos/rewind_tokens.py +252 -0
  134. dos/run_id.py +342 -0
  135. dos/scope.py +520 -0
  136. dos/scope_source.py +382 -0
  137. dos/scout.py +982 -0
  138. dos/self_modify.py +209 -0
  139. dos/sibling_scan.py +569 -0
  140. dos/skills/EXAMPLES.md +584 -0
  141. dos/skills/dos-class-cycle/SKILL.md +107 -0
  142. dos/skills/dos-dispatch/SKILL.md +177 -0
  143. dos/skills/dos-dispatch-loop/SKILL.md +254 -0
  144. dos/skills/dos-goal-gate/SKILL.md +269 -0
  145. dos/skills/dos-next-up/SKILL.md +231 -0
  146. dos/skills/dos-promote/SKILL.md +114 -0
  147. dos/skills/dos-replan/SKILL.md +159 -0
  148. dos/skills/dos-replan-loop/SKILL.md +114 -0
  149. dos/skills/dos-self-improve/SKILL.md +213 -0
  150. dos/skills/dos-supervise-loop/SKILL.md +180 -0
  151. dos/skills/dos-unstick/SKILL.md +108 -0
  152. dos/skills/dos-witness-claim/SKILL.md +251 -0
  153. dos/stamp.py +1002 -0
  154. dos/state_health.py +387 -0
  155. dos/status.py +114 -0
  156. dos/stop_policy.py +334 -0
  157. dos/supervise.py +1014 -0
  158. dos/testwitness.py +392 -0
  159. dos/timeline.py +1027 -0
  160. dos/tokens.py +485 -0
  161. dos/tool_stream.py +393 -0
  162. dos/tool_stream_eval.py +226 -0
  163. dos/trace.py +524 -0
  164. dos/verdict.py +140 -0
  165. dos/verdict_cli.py +189 -0
  166. dos/verdict_journal.py +497 -0
  167. dos/verdict_rollup.py +217 -0
  168. dos/verdicts.py +181 -0
  169. dos/wedge_reason.py +282 -0
  170. dos_kernel-0.22.0.dist-info/METADATA +859 -0
  171. dos_kernel-0.22.0.dist-info/RECORD +178 -0
  172. dos_kernel-0.22.0.dist-info/WHEEL +5 -0
  173. dos_kernel-0.22.0.dist-info/entry_points.txt +39 -0
  174. dos_kernel-0.22.0.dist-info/licenses/LICENSE +21 -0
  175. dos_kernel-0.22.0.dist-info/top_level.txt +2 -0
  176. dos_mcp/__init__.py +52 -0
  177. dos_mcp/py.typed +2 -0
  178. 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)))