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/lifecycle.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""`lifecycle` — the plan-class taxonomy + transition triggers, AS DATA (docs/207 §5c).
|
|
2
|
+
|
|
3
|
+
The `job` repo's `class-cycle` hardcodes a 5-class taxonomy (ACTIVE / MAINTENANCE
|
|
4
|
+
/ PARK / TOMB / DRAFT) and 9 named triggers (T1..T9) — the plan-classification
|
|
5
|
+
lifecycle. That taxonomy is POLICY: a repo that only wants ACTIVE/DONE should not
|
|
6
|
+
inherit a job-shaped lifecycle. This module lifts the class set + the trigger list
|
|
7
|
+
into per-workspace `[lifecycle]` data, exactly as `[stamp]` lifted the ship
|
|
8
|
+
grammar and `[enumerate]` lifted the phase grammar.
|
|
9
|
+
|
|
10
|
+
The split (docs/207 §"less house-style"):
|
|
11
|
+
|
|
12
|
+
* MECHANISM (the `dos-class-cycle` skill): evaluate triggers → build candidates
|
|
13
|
+
deterministically → spawn a JUDGE-rung adjudicator (`dos.judges`, advisory,
|
|
14
|
+
fail-to-abstain) to approve/defer each → apply gated transitions → log. Domain-
|
|
15
|
+
free; the same shape for any taxonomy.
|
|
16
|
+
* POLICY (this data): WHICH classes exist, WHICH transitions are legal, the
|
|
17
|
+
trigger list, and the failsafes (per-cycle cap, per-plan cooldown, a veto
|
|
18
|
+
class). All declared in `[lifecycle]`; a 2-class repo declares `active`/`done`.
|
|
19
|
+
|
|
20
|
+
The kernel carries the SHAPE + the validation (a transition must name known
|
|
21
|
+
classes; an unknown key raises); the consuming repo declares the taxonomy. The
|
|
22
|
+
judge *content* (the prompt) is a host `dos.judges` driver — forcing it generic
|
|
23
|
+
would re-couple the kernel (docs/207 §"what deliberately does NOT get genericized").
|
|
24
|
+
|
|
25
|
+
Pure stdlib — a near-leaf like `reasons`/`stamp`. No I/O in the verdict path; the
|
|
26
|
+
TOML read is at the `load_from_toml` boundary.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class LifecycleTransition:
|
|
36
|
+
"""One legal class transition + the trigger that proposes it.
|
|
37
|
+
|
|
38
|
+
``frm`` / ``to`` are class names (must be members of the declared class set).
|
|
39
|
+
``trigger`` is an opaque token the skill evaluates (the kernel never interprets
|
|
40
|
+
it — the host's evaluator owns what `idle_30d` means). ``auto`` is whether a
|
|
41
|
+
safe mechanical apply is allowed (gated, one commit) vs surface-for-a-human.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
frm: str
|
|
45
|
+
to: str
|
|
46
|
+
trigger: str
|
|
47
|
+
auto: bool = False
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict:
|
|
50
|
+
return {"from": self.frm, "to": self.to, "trigger": self.trigger, "auto": self.auto}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class LifecyclePolicy:
|
|
55
|
+
"""The plan-class lifecycle, as data — the `[lifecycle]` table.
|
|
56
|
+
|
|
57
|
+
* ``classes`` — the declared class set (e.g. `("active","done")` or the job
|
|
58
|
+
5-class set). The FIRST class is the default for a freshly-declared plan.
|
|
59
|
+
* ``transitions`` — the legal `LifecycleTransition`s; each names classes from
|
|
60
|
+
``classes`` and an opaque trigger token.
|
|
61
|
+
* ``veto_class`` — a class whose plans are NEVER auto-transitioned (the job
|
|
62
|
+
P0-veto): a high-priority plan a human must move by hand. Empty = no veto.
|
|
63
|
+
* ``max_transitions_per_cycle`` — the per-cycle cap (the job daily cap), so a
|
|
64
|
+
runaway judge cannot churn the whole portfolio in one tick. Default 5.
|
|
65
|
+
* ``per_plan_cooldown_hours`` — a plan transitioned within this window is not
|
|
66
|
+
a candidate again (the job 72h cooldown). Default 72.
|
|
67
|
+
|
|
68
|
+
The defaults are GENERIC. A repo declares its own in `dos.toml [lifecycle]`.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
classes: tuple[str, ...] = ("active", "done")
|
|
72
|
+
transitions: tuple[LifecycleTransition, ...] = ()
|
|
73
|
+
veto_class: str = ""
|
|
74
|
+
max_transitions_per_cycle: int = 5
|
|
75
|
+
per_plan_cooldown_hours: int = 72
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def default_class(self) -> str:
|
|
79
|
+
"""The class a freshly-declared plan starts in (the first declared class)."""
|
|
80
|
+
return self.classes[0] if self.classes else ""
|
|
81
|
+
|
|
82
|
+
def is_known_class(self, name: str) -> bool:
|
|
83
|
+
return name in self.classes
|
|
84
|
+
|
|
85
|
+
def legal_transition(self, frm: str, to: str) -> bool:
|
|
86
|
+
"""True iff `frm → to` is a declared legal transition."""
|
|
87
|
+
return any(t.frm == frm and t.to == to for t in self.transitions)
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict:
|
|
90
|
+
return {
|
|
91
|
+
"classes": list(self.classes),
|
|
92
|
+
"transitions": [t.to_dict() for t in self.transitions],
|
|
93
|
+
"veto_class": self.veto_class,
|
|
94
|
+
"max_transitions_per_cycle": self.max_transitions_per_cycle,
|
|
95
|
+
"per_plan_cooldown_hours": self.per_plan_cooldown_hours,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# The generic default: two classes, one auto transition, no veto. A repo with a
|
|
100
|
+
# richer taxonomy declares it. This is the `[stamp]` GENERIC_ twin.
|
|
101
|
+
GENERIC_LIFECYCLE = LifecyclePolicy(
|
|
102
|
+
classes=("active", "done"),
|
|
103
|
+
transitions=(LifecycleTransition("active", "done", "all_phases_shipped", auto=True),),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def policy_from_table(
|
|
108
|
+
table: dict, *, base: LifecyclePolicy = GENERIC_LIFECYCLE
|
|
109
|
+
) -> LifecyclePolicy:
|
|
110
|
+
"""Build a `LifecyclePolicy` from a parsed `[lifecycle]` TOML table. PURE.
|
|
111
|
+
|
|
112
|
+
Each field the table names overrides ``base``; omitted inherit. An unknown key
|
|
113
|
+
raises (the `stamp.convention_from_table` posture). A transition that names a
|
|
114
|
+
class NOT in the declared set raises (a typo'd class is a host mistake worth
|
|
115
|
+
surfacing — the kernel validates the SHAPE, the repo declares the taxonomy).
|
|
116
|
+
"""
|
|
117
|
+
if not isinstance(table, dict):
|
|
118
|
+
raise ValueError(f"[lifecycle] must be a table, got {type(table).__name__}")
|
|
119
|
+
known = {"classes", "transitions", "veto_class",
|
|
120
|
+
"max_transitions_per_cycle", "per_plan_cooldown_hours"}
|
|
121
|
+
unknown = set(table) - known
|
|
122
|
+
if unknown:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"[lifecycle] has unknown key(s) {sorted(unknown)}; known keys are {sorted(known)}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
classes = base.classes
|
|
128
|
+
if "classes" in table:
|
|
129
|
+
raw = table["classes"]
|
|
130
|
+
if not isinstance(raw, (list, tuple)) or not all(isinstance(x, str) for x in raw) or not raw:
|
|
131
|
+
raise ValueError("[lifecycle].classes must be a non-empty list of strings")
|
|
132
|
+
classes = tuple(raw)
|
|
133
|
+
class_set = set(classes)
|
|
134
|
+
|
|
135
|
+
transitions = base.transitions
|
|
136
|
+
if "transitions" in table:
|
|
137
|
+
raw_t = table["transitions"]
|
|
138
|
+
if not isinstance(raw_t, (list, tuple)):
|
|
139
|
+
raise ValueError("[lifecycle].transitions must be a list of {from,to,trigger,auto} tables")
|
|
140
|
+
out: list[LifecycleTransition] = []
|
|
141
|
+
for entry in raw_t:
|
|
142
|
+
if not isinstance(entry, dict):
|
|
143
|
+
raise ValueError("[lifecycle].transitions entries must be tables")
|
|
144
|
+
frm, to, trig = entry.get("from"), entry.get("to"), entry.get("trigger")
|
|
145
|
+
if not all(isinstance(x, str) and x for x in (frm, to, trig)):
|
|
146
|
+
raise ValueError(
|
|
147
|
+
"[lifecycle] transition needs string from/to/trigger; got "
|
|
148
|
+
f"{entry!r}"
|
|
149
|
+
)
|
|
150
|
+
if frm not in class_set or to not in class_set:
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"[lifecycle] transition {frm!r}->{to!r} names a class not in "
|
|
153
|
+
f"the declared set {sorted(class_set)}"
|
|
154
|
+
)
|
|
155
|
+
auto = entry.get("auto", False)
|
|
156
|
+
if not isinstance(auto, bool):
|
|
157
|
+
raise ValueError("[lifecycle] transition `auto` must be a boolean")
|
|
158
|
+
out.append(LifecycleTransition(frm=frm, to=to, trigger=trig, auto=auto))
|
|
159
|
+
transitions = tuple(out)
|
|
160
|
+
|
|
161
|
+
veto = base.veto_class
|
|
162
|
+
if "veto_class" in table:
|
|
163
|
+
if not isinstance(table["veto_class"], str):
|
|
164
|
+
raise ValueError("[lifecycle].veto_class must be a string")
|
|
165
|
+
veto = table["veto_class"]
|
|
166
|
+
if veto and veto not in class_set:
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f"[lifecycle].veto_class {veto!r} is not in the declared set {sorted(class_set)}"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def _int(key: str, current: int) -> int:
|
|
172
|
+
if key not in table:
|
|
173
|
+
return current
|
|
174
|
+
v = table[key]
|
|
175
|
+
if isinstance(v, bool) or not isinstance(v, int):
|
|
176
|
+
raise ValueError(f"[lifecycle].{key} must be an int, got {type(v).__name__}")
|
|
177
|
+
return v
|
|
178
|
+
|
|
179
|
+
return LifecyclePolicy(
|
|
180
|
+
classes=classes,
|
|
181
|
+
transitions=transitions,
|
|
182
|
+
veto_class=veto,
|
|
183
|
+
max_transitions_per_cycle=_int("max_transitions_per_cycle", base.max_transitions_per_cycle),
|
|
184
|
+
per_plan_cooldown_hours=_int("per_plan_cooldown_hours", base.per_plan_cooldown_hours),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def load_from_toml(
|
|
189
|
+
path, *, base: LifecyclePolicy = GENERIC_LIFECYCLE
|
|
190
|
+
) -> LifecyclePolicy:
|
|
191
|
+
"""Build a `LifecyclePolicy` from a `dos.toml`'s `[lifecycle]` table.
|
|
192
|
+
|
|
193
|
+
Returns ``base`` unchanged when the file is absent, has no `[lifecycle]` table,
|
|
194
|
+
or `tomllib` is unavailable. A present-but-malformed table raises. Mirrors
|
|
195
|
+
`stamp.load_from_toml` (incl. the `utf-8-sig` BOM strip)."""
|
|
196
|
+
from pathlib import Path
|
|
197
|
+
p = Path(path)
|
|
198
|
+
if not p.exists():
|
|
199
|
+
return base
|
|
200
|
+
try:
|
|
201
|
+
import tomllib
|
|
202
|
+
except ModuleNotFoundError: # pragma: no cover - py<3.11 fallback
|
|
203
|
+
try:
|
|
204
|
+
import tomli as tomllib # type: ignore
|
|
205
|
+
except ModuleNotFoundError:
|
|
206
|
+
return base
|
|
207
|
+
data = tomllib.loads(p.read_text(encoding="utf-8-sig"))
|
|
208
|
+
table = data.get("lifecycle")
|
|
209
|
+
if not isinstance(table, dict) or not table:
|
|
210
|
+
return base
|
|
211
|
+
return policy_from_table(table, base=base)
|
dos/liveness.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""LVN — the liveness verdict: *is the agent actually moving, or just spinning?*
|
|
2
|
+
|
|
3
|
+
docs/82 — the 4th distrust syscall, the **temporal completion of `verify()`**.
|
|
4
|
+
`verify` distrusts a *finished* claim ("I shipped P"); LVN distrusts an
|
|
5
|
+
*in-flight* one ("I'm making progress"). A spinning agent is a systematically
|
|
6
|
+
optimistic narrator of its own motion — it reports "almost there / refining the
|
|
7
|
+
approach" while re-editing the same file and landing zero commits. It cannot see
|
|
8
|
+
its own loop; the operator can, but only by reading output and judging by hand
|
|
9
|
+
the thing DOS exists to adjudicate mechanically. LVN is the verdict that ends the
|
|
10
|
+
watching: it asks the **git history and the lane journal**, never the agent,
|
|
11
|
+
whether ground-truth state advanced.
|
|
12
|
+
|
|
13
|
+
This module is `loop_decide`'s sibling — a **pure** verdict function, the
|
|
14
|
+
`arbitrate()` / `gate_policy` shape:
|
|
15
|
+
|
|
16
|
+
arbiter.arbitrate (request, live_leases, config) -> decision
|
|
17
|
+
loop_decide.decide (LoopState, IterationOutcome) -> LoopDecision
|
|
18
|
+
liveness.classify (ProgressEvidence, policy) -> LivenessVerdict
|
|
19
|
+
^ THIS module
|
|
20
|
+
|
|
21
|
+
All I/O — reading git, the journal, the clock — happens in the CALLER (the
|
|
22
|
+
`dos liveness` CLI's evidence-gather), exactly as `pick_oracle`'s reads happen
|
|
23
|
+
outside `arbitrate()` and `verify`'s git reads happen outside the classifier.
|
|
24
|
+
`classify()` makes no subprocess, file, or clock call: `now_ms` is a field on the
|
|
25
|
+
evidence, never read inside the verdict. That is what lets the whole verdict be
|
|
26
|
+
replay-tested on frozen fixtures, away from anything that needs a live
|
|
27
|
+
multi-minute agent run to reproduce (the `loop_decide` design value, restated for
|
|
28
|
+
the temporal axis).
|
|
29
|
+
|
|
30
|
+
The verdict ladder, top to bottom — the whole point is that a reader holds it in
|
|
31
|
+
their head:
|
|
32
|
+
|
|
33
|
+
1. ADVANCING — **any forward delta**: ≥1 commit since the run's start SHA, OR a
|
|
34
|
+
state-mutating lane-journal event since start. State moved. (This rung wins
|
|
35
|
+
over everything below — a run that committed is advancing regardless of how
|
|
36
|
+
fresh its heartbeat is.) Also the **young-and-alive** case: a run with a
|
|
37
|
+
fresh heartbeat that is younger than `grace_ms` has not earned a SPINNING
|
|
38
|
+
accusation yet — it is alive and we decline to judge it stuck (no liveness
|
|
39
|
+
*problem* detected), the grace guard against a false-positive on a run that
|
|
40
|
+
simply hasn't committed in its first minute. NOTE: LVN says bytes *moved* (or
|
|
41
|
+
"no problem yet"), never that they moved *well* — quality is an advisory
|
|
42
|
+
judge's call (`llm_judge`), never this deterministic kernel verb (the
|
|
43
|
+
distrust-state / distrust-judgment line).
|
|
44
|
+
2. SPINNING — no forward delta, the run is **alive** (a heartbeat fresher than
|
|
45
|
+
`spin_ms`), AND it has been alive long enough to judge (run-age ≥
|
|
46
|
+
`grace_ms`): alive, narrating, not moving — the signal with no existing
|
|
47
|
+
home. This is the rung `loop_decide`'s self-report breakers can't reach,
|
|
48
|
+
because it reads ground truth, not the caller's `IterationOutcome` token.
|
|
49
|
+
3. STALLED — no forward delta and **not alive**: the newest heartbeat is older
|
|
50
|
+
than `spin_ms`, or there is no heartbeat at all. The run is dead or hung —
|
|
51
|
+
the orphan-sweep's input, not a spin.
|
|
52
|
+
|
|
53
|
+
`SPINNING` is ADVISORY. LVN reports; it never kills a process or refuses a lease.
|
|
54
|
+
A loop may consult LVN and choose to stop (the natural first consumer, LVN-3a),
|
|
55
|
+
and the decisions queue may surface a spinning run — but the liveness verdict and
|
|
56
|
+
the admission decision stay different syscalls (a `LivenessPredicate` over ADM's
|
|
57
|
+
conjunctive seam is a possible *separate* opt-in driver policy, not LVN).
|
|
58
|
+
|
|
59
|
+
No-plan discipline (`test_verify_no_plan` sibling): LVN must return a verdict in a
|
|
60
|
+
plain git repo with a run-id and a start SHA and *nothing else* — no plan, no
|
|
61
|
+
registry, no journal, no telemetry. Commits-since-start alone is a sufficient
|
|
62
|
+
ADVANCING/SPINNING/STALLED signal; every richer input (`journal_events_since`,
|
|
63
|
+
`last_heartbeat_age_ms`, `tokens_spent_since`) is OPTIONAL and the verdict
|
|
64
|
+
degrades to the commit + caller-supplied-heartbeat rungs when they are absent.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
import enum
|
|
70
|
+
from dataclasses import dataclass
|
|
71
|
+
from typing import Optional
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Liveness(str, enum.Enum):
|
|
75
|
+
"""The typed liveness verdict — three states, mutually exclusive.
|
|
76
|
+
|
|
77
|
+
`str`-valued so it round-trips through a CLI stdout token / exit-code map
|
|
78
|
+
without a lookup table (mirrors `gate_classify.Verdict` and
|
|
79
|
+
`loop_decide.OutcomeKind`).
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
ADVANCING = "ADVANCING" # ground-truth state moved since the run started
|
|
83
|
+
SPINNING = "SPINNING" # the run is alive (heartbeat fresh) but state is NOT moving
|
|
84
|
+
STALLED = "STALLED" # no fresh heartbeat, no commits — dead/hung, not spinning
|
|
85
|
+
|
|
86
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
87
|
+
return self.value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class LivenessPolicy:
|
|
92
|
+
"""The windows that separate ADVANCING/SPINNING/STALLED — policy, not mechanism.
|
|
93
|
+
|
|
94
|
+
The same "mechanism is kernel, thresholds are config" split as
|
|
95
|
+
`loop_decide`'s `max_unclear` / `max_iterations`. The defaults are GENERIC
|
|
96
|
+
(no host tuning); a workspace declares its own in `dos.toml [liveness]`
|
|
97
|
+
(LVN-3c) read back through `SubstrateConfig`, the closed-config-as-data
|
|
98
|
+
pattern (`[lanes]` / `[stamp]` / `[reasons]`).
|
|
99
|
+
|
|
100
|
+
The two windows have distinct jobs (the spec is loose on their interaction;
|
|
101
|
+
this is the resolution Phase 1 pins):
|
|
102
|
+
|
|
103
|
+
spin_ms — the **heartbeat-freshness bound** that proves the run is *alive*.
|
|
104
|
+
A heartbeat younger than this means the process is up; older
|
|
105
|
+
(or absent) means it is not demonstrably alive → STALLED. It is
|
|
106
|
+
the alive/dead boundary.
|
|
107
|
+
grace_ms — the **minimum run-age** an alive-but-idle run must reach before
|
|
108
|
+
LVN will accuse it of SPINNING. Below it, a run with a fresh
|
|
109
|
+
heartbeat and no commits is simply young — alive, not yet stuck —
|
|
110
|
+
and the verdict withholds the accusation (reports ADVANCING:
|
|
111
|
+
"no liveness problem yet"). This is the false-positive guard so a
|
|
112
|
+
run that hasn't committed in its first minute isn't called
|
|
113
|
+
spinning. (Run-age is `now_ms - run_started_ms`, both on the
|
|
114
|
+
evidence — this is the one rung that reads them.)
|
|
115
|
+
|
|
116
|
+
Defaults: 30 min grace, 15 min spin. So: a run is alive while its heartbeat is
|
|
117
|
+
≤15 min old; an alive run with no forward delta is called SPINNING only once
|
|
118
|
+
it is ≥30 min old; otherwise it is too young to judge (ADVANCING-benign). The
|
|
119
|
+
granularity matches what the spine records (minutes), per the
|
|
120
|
+
no-sub-second-liveness non-goal.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
grace_ms: int = 30 * 60 * 1000 # 30 minutes — min run-age before SPINNING
|
|
124
|
+
spin_ms: int = 15 * 60 * 1000 # 15 minutes — heartbeat-freshness (alive) bound
|
|
125
|
+
|
|
126
|
+
def __post_init__(self) -> None:
|
|
127
|
+
if self.grace_ms < 0 or self.spin_ms < 0:
|
|
128
|
+
raise ValueError("liveness windows must be non-negative (ms)")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
DEFAULT_POLICY = LivenessPolicy()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class ProgressEvidence:
|
|
136
|
+
"""Everything `classify()` needs, gathered by the CALLER before the call.
|
|
137
|
+
|
|
138
|
+
No git, no journal, no clock inside the verdict — the arbiter rule. The CLI's
|
|
139
|
+
evidence-gather (the boundary) decodes `run_id.ts_ms_of(run_id)` for the
|
|
140
|
+
start, counts `git_delta.commits_since(start_sha)` for the commit delta, folds
|
|
141
|
+
the lane journal for the heartbeat/event rungs, and reads the wall clock —
|
|
142
|
+
then freezes all of it here and hands it to the pure classifier.
|
|
143
|
+
|
|
144
|
+
run_started_ms — epoch-ms the run began (`run_id.ts_ms_of`). Carried
|
|
145
|
+
for the `--output json` consumer + age framing; the
|
|
146
|
+
verdict reads ages, not absolute times.
|
|
147
|
+
now_ms — wall-clock epoch-ms, injected at the boundary (the
|
|
148
|
+
env BANS `Date.now()` in reproducible paths; the
|
|
149
|
+
verdict never reads a clock).
|
|
150
|
+
commits_since_start — the authoritative forward delta
|
|
151
|
+
(`len(git_delta.commits_since(start_sha))`). ≥1 ⇒
|
|
152
|
+
ADVANCING, on its own, with everything else absent
|
|
153
|
+
(the no-plan floor).
|
|
154
|
+
journal_events_since — count of lease-*work* lane-journal events
|
|
155
|
+
(ACQUIRE/RELEASE/SCAVENGE/RECONCILE — NOT a keepalive
|
|
156
|
+
HEARTBEAT, which is a *beat* not progress; see
|
|
157
|
+
`journal_delta._EVENT_OPS`) since start. ≥1 ⇒
|
|
158
|
+
ADVANCING even with 0 commits — work is happening at
|
|
159
|
+
the lease layer (wired in LVN Phase 2; 0 in Phase 1).
|
|
160
|
+
last_heartbeat_age_ms — now − newest HEARTBEAT/ACQUIRE ts. None = never
|
|
161
|
+
beat (or the journal rung is absent). Separates
|
|
162
|
+
SPINNING (fresh) from STALLED (stale/None past grace).
|
|
163
|
+
tokens_spent_since — OPTIONAL waste signal: cost burned with no commit. A
|
|
164
|
+
workspace that tracks per-run cost may pass it; one
|
|
165
|
+
that doesn't passes None and the verdict is unaffected
|
|
166
|
+
(never required — the no-telemetry discipline). Echoed
|
|
167
|
+
for the json consumer; not an input to the ladder.
|
|
168
|
+
process_alive — OPTIONAL **unforgeable** OS-process rung (docs/95,
|
|
169
|
+
gathered by `proc_delta.probe` at the boundary):
|
|
170
|
+
True = the OS confirms the run's pid is up, False =
|
|
171
|
+
the OS confirms it is gone, None = could not tell
|
|
172
|
+
(no pid / foreign host / unsupported platform). It is
|
|
173
|
+
**demote-only**: a confident `False` flips an
|
|
174
|
+
otherwise-SPINNING run to STALLED (a fresh heartbeat on
|
|
175
|
+
a dead process is the forgeable-beat gap this closes);
|
|
176
|
+
True/None NEVER promote a dead/stalled run to alive.
|
|
177
|
+
A workspace that can't probe passes None and the
|
|
178
|
+
verdict is byte-identical to before this field existed.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
run_started_ms: int
|
|
182
|
+
now_ms: int
|
|
183
|
+
commits_since_start: int
|
|
184
|
+
journal_events_since: int = 0
|
|
185
|
+
last_heartbeat_age_ms: Optional[int] = None
|
|
186
|
+
tokens_spent_since: Optional[int] = None
|
|
187
|
+
process_alive: Optional[bool] = None
|
|
188
|
+
|
|
189
|
+
def __post_init__(self) -> None:
|
|
190
|
+
if self.commits_since_start < 0 or self.journal_events_since < 0:
|
|
191
|
+
raise ValueError("evidence counts must be non-negative")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True)
|
|
195
|
+
class LivenessVerdict:
|
|
196
|
+
"""The single verdict `classify()` returns, with the evidence echoed back.
|
|
197
|
+
|
|
198
|
+
`verdict` is the typed `Liveness`. `reason` is a one-line operator-facing
|
|
199
|
+
summary (the tally-row string). `evidence` is the `ProgressEvidence` that
|
|
200
|
+
drove the call, carried so `dos liveness --output json` can emit the verdict
|
|
201
|
+
*and the facts behind it* in one object (the renderer seam, RND/Axis-4) —
|
|
202
|
+
legible distrust: the operator sees not just SPINNING but *why* (0 commits,
|
|
203
|
+
heartbeat 8m fresh). `to_dict` is the json shape.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
verdict: Liveness
|
|
207
|
+
reason: str
|
|
208
|
+
evidence: ProgressEvidence
|
|
209
|
+
|
|
210
|
+
def to_dict(self) -> dict:
|
|
211
|
+
ev = self.evidence
|
|
212
|
+
return {
|
|
213
|
+
"verdict": self.verdict.value,
|
|
214
|
+
"reason": self.reason,
|
|
215
|
+
"evidence": {
|
|
216
|
+
"run_started_ms": ev.run_started_ms,
|
|
217
|
+
"now_ms": ev.now_ms,
|
|
218
|
+
"commits_since_start": ev.commits_since_start,
|
|
219
|
+
"journal_events_since": ev.journal_events_since,
|
|
220
|
+
"last_heartbeat_age_ms": ev.last_heartbeat_age_ms,
|
|
221
|
+
"tokens_spent_since": ev.tokens_spent_since,
|
|
222
|
+
"process_alive": ev.process_alive,
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def classify(
|
|
228
|
+
ev: ProgressEvidence, policy: LivenessPolicy = DEFAULT_POLICY
|
|
229
|
+
) -> LivenessVerdict:
|
|
230
|
+
"""Classify one run's liveness from already-gathered evidence. PURE — no I/O.
|
|
231
|
+
|
|
232
|
+
Reads the ladder top to bottom (this function IS the answer to "is it
|
|
233
|
+
moving?"):
|
|
234
|
+
|
|
235
|
+
1. ADVANCING — any forward delta (≥1 commit OR ≥1 state-mutating journal
|
|
236
|
+
event since start), OR the young-and-alive case (a fresh heartbeat on a
|
|
237
|
+
run younger than `grace_ms`): state moved, or there is no liveness
|
|
238
|
+
problem to flag yet.
|
|
239
|
+
2. SPINNING — no forward delta, a heartbeat fresher than `spin_ms` (alive),
|
|
240
|
+
AND run-age ≥ `grace_ms` (old enough to judge): alive, narrating, not
|
|
241
|
+
moving.
|
|
242
|
+
3. STALLED — no forward delta and not alive: the newest heartbeat is older
|
|
243
|
+
than `spin_ms`, or there is none at all. Dead/hung.
|
|
244
|
+
|
|
245
|
+
The ADVANCING/rest boundary is the *forward delta* (never clock-dependent).
|
|
246
|
+
The alive/dead boundary is heartbeat freshness vs `spin_ms`. The
|
|
247
|
+
too-young-to-judge guard is run-age (`now_ms - run_started_ms`) vs `grace_ms`
|
|
248
|
+
— the ONE rung that reads the clock fields, and it reads them as a delta the
|
|
249
|
+
caller pre-stamped, not by calling a clock (the clock-is-injected rule).
|
|
250
|
+
"""
|
|
251
|
+
# 1a. ADVANCING (forward delta) — the authoritative rung and the no-plan
|
|
252
|
+
# floor: a commit since start answers in a plain git repo with no journal
|
|
253
|
+
# at all. A state-mutating journal event counts too — work at the lease
|
|
254
|
+
# layer is progress even with no commit yet (wired in Phase 2;
|
|
255
|
+
# journal_events_since is 0 in Phase 1, so this reduces to the commit
|
|
256
|
+
# rung). Checked FIRST so a run that moved is never mislabelled on a
|
|
257
|
+
# stale-heartbeat technicality.
|
|
258
|
+
if ev.commits_since_start >= 1:
|
|
259
|
+
return LivenessVerdict(
|
|
260
|
+
verdict=Liveness.ADVANCING,
|
|
261
|
+
reason=(
|
|
262
|
+
f"{ev.commits_since_start} commit(s) since the run's start SHA "
|
|
263
|
+
f"— ground-truth state moved"
|
|
264
|
+
),
|
|
265
|
+
evidence=ev,
|
|
266
|
+
)
|
|
267
|
+
if ev.journal_events_since >= 1:
|
|
268
|
+
return LivenessVerdict(
|
|
269
|
+
verdict=Liveness.ADVANCING,
|
|
270
|
+
reason=(
|
|
271
|
+
f"{ev.journal_events_since} state-mutating lane-journal event(s) "
|
|
272
|
+
f"since start — progress at the lease layer (0 commits)"
|
|
273
|
+
),
|
|
274
|
+
evidence=ev,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# No forward delta. Heartbeat freshness decides alive-vs-dead; run-age decides
|
|
278
|
+
# whether an alive run is old enough to be called spinning.
|
|
279
|
+
age = ev.last_heartbeat_age_ms
|
|
280
|
+
alive = age is not None and age <= policy.spin_ms
|
|
281
|
+
|
|
282
|
+
if alive:
|
|
283
|
+
# 1b. STALLED (proc-rung demote, docs/95) — the heartbeat says alive, but
|
|
284
|
+
# the OS says the run's pid is CONFIDENTLY gone. A fresh heartbeat on a
|
|
285
|
+
# dead process is the forgeable-beat gap: the wrapper kept touching the
|
|
286
|
+
# beat (or the crash left a fresh `heartbeat_at`) after the process
|
|
287
|
+
# died. The unforgeable OS rung overrides the forgeable one — DEMOTE
|
|
288
|
+
# to STALLED. Checked BEFORE the young-and-alive guard: a confidently-
|
|
289
|
+
# dead process is dead regardless of run-age — the "too young to judge"
|
|
290
|
+
# grace exists to spare a LIVE young run a false SPINNING, and its
|
|
291
|
+
# premise (the run is up) is exactly what the OS just refuted. Only a
|
|
292
|
+
# confident `False` demotes; `True`/`None` fall through and the
|
|
293
|
+
# heartbeat verdict stands (the rung never PROMOTES dead→alive — it can
|
|
294
|
+
# only make the verdict more skeptical).
|
|
295
|
+
if ev.process_alive is False:
|
|
296
|
+
return LivenessVerdict(
|
|
297
|
+
verdict=Liveness.STALLED,
|
|
298
|
+
reason=(
|
|
299
|
+
f"heartbeat {age} ms old says alive, but the OS reports the "
|
|
300
|
+
f"run's process is gone — STALLED (the unforgeable proc rung "
|
|
301
|
+
f"overrides a forgeable heartbeat; docs/95)"
|
|
302
|
+
),
|
|
303
|
+
evidence=ev,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# 1c. ADVANCING (young-and-alive guard) — an alive run younger than
|
|
307
|
+
# `grace_ms` has not earned a SPINNING accusation. It is up, it just
|
|
308
|
+
# hasn't had time to commit; reporting SPINNING here would be a
|
|
309
|
+
# false positive on a run in its first minutes. We withhold and report
|
|
310
|
+
# "no liveness problem yet" (ADVANCING is the benign/no-action verdict
|
|
311
|
+
# — it explicitly does NOT claim a commit landed; the reason says so).
|
|
312
|
+
run_age = ev.now_ms - ev.run_started_ms
|
|
313
|
+
if run_age < policy.grace_ms:
|
|
314
|
+
return LivenessVerdict(
|
|
315
|
+
verdict=Liveness.ADVANCING,
|
|
316
|
+
reason=(
|
|
317
|
+
f"alive (heartbeat {age} ms old) and only {run_age} ms into "
|
|
318
|
+
f"the run (< grace {policy.grace_ms} ms) — too young to judge "
|
|
319
|
+
f"spinning; no liveness problem yet (0 commits so far)"
|
|
320
|
+
),
|
|
321
|
+
evidence=ev,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# 2. SPINNING — alive, old enough to judge, and not moving. A heartbeat
|
|
325
|
+
# fresher than `spin_ms` proves the run is up; with 0 commits and 0 new
|
|
326
|
+
# journal events past the grace age, it is burning tokens narrating
|
|
327
|
+
# motion it isn't making. The signal with no home in `loop_decide`
|
|
328
|
+
# (which reads the self-report, not ground truth).
|
|
329
|
+
return LivenessVerdict(
|
|
330
|
+
verdict=Liveness.SPINNING,
|
|
331
|
+
reason=(
|
|
332
|
+
f"alive (heartbeat {age} ms old ≤ spin window {policy.spin_ms} ms) "
|
|
333
|
+
f"and {run_age} ms into the run (≥ grace {policy.grace_ms} ms) but "
|
|
334
|
+
f"0 commits and 0 lane events since start — spinning"
|
|
335
|
+
),
|
|
336
|
+
evidence=ev,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# 3. STALLED — no forward delta and not alive: the newest heartbeat is older
|
|
340
|
+
# than `spin_ms`, or there is none at all (None). The run is dead or hung,
|
|
341
|
+
# the orphan-sweep's input — not a spin (a spin requires proof of life).
|
|
342
|
+
if age is None:
|
|
343
|
+
reason = (
|
|
344
|
+
"no heartbeat and 0 commits since start "
|
|
345
|
+
"— run is dead or hung (never beat)"
|
|
346
|
+
)
|
|
347
|
+
else:
|
|
348
|
+
reason = (
|
|
349
|
+
f"heartbeat {age} ms old (> spin window {policy.spin_ms} ms) and "
|
|
350
|
+
f"0 commits since start — run is dead or hung, not spinning"
|
|
351
|
+
)
|
|
352
|
+
return LivenessVerdict(verdict=Liveness.STALLED, reason=reason, evidence=ev)
|