leasehold 0.2.0__py3-none-any.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 (69) hide show
  1. leasehold/__init__.py +18 -0
  2. leasehold/__main__.py +8 -0
  3. leasehold/access.py +43 -0
  4. leasehold/admission.py +373 -0
  5. leasehold/agent.py +570 -0
  6. leasehold/api.py +129 -0
  7. leasehold/busy.py +62 -0
  8. leasehold/claude_code.py +218 -0
  9. leasehold/cli/__init__.py +151 -0
  10. leasehold/cli/admin.py +55 -0
  11. leasehold/cli/agents.py +45 -0
  12. leasehold/cli/daemon.py +169 -0
  13. leasehold/cli/demo.py +64 -0
  14. leasehold/cli/hooks.py +190 -0
  15. leasehold/cli/leases.py +170 -0
  16. leasehold/cli/push.py +42 -0
  17. leasehold/cli/run.py +57 -0
  18. leasehold/cli/setup.py +30 -0
  19. leasehold/cli/views.py +214 -0
  20. leasehold/cli/watch.py +49 -0
  21. leasehold/client.py +86 -0
  22. leasehold/commands.py +99 -0
  23. leasehold/contain.py +279 -0
  24. leasehold/demo.py +228 -0
  25. leasehold/discovery.py +125 -0
  26. leasehold/doctor.py +314 -0
  27. leasehold/events.py +139 -0
  28. leasehold/exits.py +16 -0
  29. leasehold/facts.py +139 -0
  30. leasehold/guard.py +88 -0
  31. leasehold/handoff.py +190 -0
  32. leasehold/hold.py +451 -0
  33. leasehold/hooknotes.py +111 -0
  34. leasehold/jobs.py +117 -0
  35. leasehold/kinds.py +17 -0
  36. leasehold/ledger.py +833 -0
  37. leasehold/lend.py +101 -0
  38. leasehold/load.py +131 -0
  39. leasehold/machine.py +453 -0
  40. leasehold/mcp_server.py +748 -0
  41. leasehold/metrics.py +91 -0
  42. leasehold/model.py +79 -0
  43. leasehold/observe.py +174 -0
  44. leasehold/paths.py +120 -0
  45. leasehold/process.py +64 -0
  46. leasehold/proctable.py +84 -0
  47. leasehold/programs.py +55 -0
  48. leasehold/py.typed +0 -0
  49. leasehold/quota.py +44 -0
  50. leasehold/report.py +190 -0
  51. leasehold/server.py +190 -0
  52. leasehold/session.py +131 -0
  53. leasehold/setup.py +196 -0
  54. leasehold/shell.py +312 -0
  55. leasehold/shims.py +197 -0
  56. leasehold/static/ui.html +1135 -0
  57. leasehold/status.py +152 -0
  58. leasehold/template.py +98 -0
  59. leasehold/templates/machine.toml +88 -0
  60. leasehold/templates/user.toml +60 -0
  61. leasehold/terminal.py +87 -0
  62. leasehold/top.py +1374 -0
  63. leasehold/units.py +33 -0
  64. leasehold/webui.py +159 -0
  65. leasehold-0.2.0.dist-info/METADATA +163 -0
  66. leasehold-0.2.0.dist-info/RECORD +69 -0
  67. leasehold-0.2.0.dist-info/WHEEL +4 -0
  68. leasehold-0.2.0.dist-info/entry_points.txt +2 -0
  69. leasehold-0.2.0.dist-info/licenses/LICENSE +202 -0
leasehold/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """leasehold: fair, load-aware admission for one shared Linux machine.
2
+
3
+ The library:
4
+
5
+ * `model`: pools, the machine's slots and how they are split (`PoolSet`);
6
+ * `admission`: the pure decision of what fits now (reservations, priority, turns between users
7
+ and between each user's sessions, FIFO per session, no backfill);
8
+ * `ledger`: the machine-wide SQLite ledger of leases, the queue and recent submits that applies
9
+ it, its holders fenced by `process` identity or by a TTL;
10
+ * `quota`: the submit-time per-user queued limit and create rate;
11
+ * `client`: a client for the machine agent's socket API.
12
+
13
+ The machine agent (`leasehold serve`) is built from `machine`, `agent`, `api` and `server`.
14
+ Only the standard library and pydantic.
15
+ """
16
+
17
+ #: The one place the version is written; the build reads it from here (`[tool.hatch.version]`).
18
+ __version__ = "0.2.0"
leasehold/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """`python -m leasehold`: the `leasehold` command."""
2
+
3
+ import sys
4
+
5
+ from leasehold.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
leasehold/access.py ADDED
@@ -0,0 +1,43 @@
1
+ """Who may do what on the machine agent's socket.
2
+
3
+ Anyone who can open the socket may take, renew and give back their own leases. Switching lending
4
+ and changing pool limits change what everyone else gets, so only an administrator may: root, the
5
+ user the daemon runs as, or a member of the config's `admin_group`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import grp
11
+ import os
12
+ import pwd
13
+ from collections.abc import Callable
14
+
15
+
16
+ class Forbidden(PermissionError):
17
+ """A request the asking user may not make."""
18
+
19
+
20
+ def in_group(user: str, group: str) -> bool:
21
+ """Whether `user` is in `group`, as the system's name service says (files, SSSD, LDAP):
22
+ its primary group or a supplementary one. An unknown user or group is never."""
23
+ try:
24
+ gid = grp.getgrnam(group).gr_gid
25
+ return gid in os.getgrouplist(user, pwd.getpwnam(user).pw_gid)
26
+ except (KeyError, OSError):
27
+ return False
28
+
29
+
30
+ def may_administer(
31
+ uid: int | None,
32
+ user: str | None,
33
+ *,
34
+ admin_group: str | None,
35
+ daemon_uid: int | None = None,
36
+ member: Callable[[str, str], bool] = in_group,
37
+ ) -> bool:
38
+ """Whether the peer (`uid`, `user`) is an administrator. An unknown peer never is."""
39
+ if uid is None:
40
+ return False
41
+ if uid == 0 or uid == (os.getuid() if daemon_uid is None else daemon_uid):
42
+ return True
43
+ return admin_group is not None and user is not None and member(user, admin_group)
leasehold/admission.py ADDED
@@ -0,0 +1,373 @@
1
+ """Which waiting items the pools can admit now: a pure function of what is held, what waits and
2
+ the policy (`decide`). The ledger (`leasehold.ledger`) calls it inside its one transaction and
3
+ turns the answer into leases.
4
+
5
+ The rules, per host of `C` slots (a machine's pools all share one), with `U_q` the weight pool
6
+ `q` holds and `R_q` its reservation:
7
+
8
+ * an item of weight `w` enters pool `p` only if `U_p + w <= max_p` and
9
+ `C - sum(U) - w >= sum over the host's other pools q of max(0, R_q - U_q)`: a reservation is
10
+ never lent to another pool, and nothing is ever preempted to make room;
11
+ * pools are served in priority order (lower first, then by name);
12
+ * inside a pool, users take turns: the next item is the head of the user with the fewest items
13
+ running in the pool, then the one served longest ago (`State.served`, which the ledger keeps,
14
+ so the rotation survives across calls and one-slot pools), then the oldest head. A user with
15
+ one queued item is admitted within one rotation however many another has queued;
16
+ * inside a user's turn, that user's **sessions** take turns by the same three keys
17
+ (`State.session_served`). A session is whatever the client names one (an agent session, a
18
+ terminal); items without one share one session per user. Splitting work into more sessions
19
+ only divides that user's own turns, never takes another user's. One session's own items stay
20
+ FIFO;
21
+ * a user at the pool's `per_user_max_running` is skipped until one of theirs ends;
22
+ * a `closed` pool admits nothing, and its waiting items keep their places; a `capped` pool
23
+ admits only up to its reservation, as if `max_p` were `R_p`;
24
+ * the item whose turn it is blocks its pool when it does not fit: nothing behind it -- its own
25
+ user's or another's -- is admitted around it (no backfill), so a heavy item is never starved
26
+ by lighter ones. Another pool is not blocked by it: the reservations are what protect a pool.
27
+
28
+ `AdmissionPolicy.standing` is weight that long-lived work outside the ledger already occupies
29
+ (a machine's `[[standing]]` entries). `decide` counts it as used before the held leases, and
30
+ never as running, so per-user limits and whose turn it is stay a matter of real leases only.
31
+
32
+ Weight held beyond a pool's `max` or a host's slots is allowed (`leasehold.ledger.Ledger.impose`
33
+ records work that was never asked for, such as an observed process): `fits` then fails for that
34
+ pool and host until enough of it ends.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from collections.abc import Iterable, Mapping
40
+ from dataclasses import dataclass, field
41
+
42
+ from leasehold.model import MACHINE, PoolSet
43
+
44
+ #: An item on the machine: (scope, item id), the scope being whatever keeps two clients' item
45
+ #: ids apart (a repository path, say, or `leasehold` for the daemon's own leases).
46
+ ItemKey = tuple[str, str]
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class PoolPolicy:
51
+ host: str
52
+ reserved: int
53
+ max: int
54
+ priority: int = 0
55
+ per_user_max_running: int | None = None
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class AdmissionPolicy:
60
+ """What `decide` reads: each host's slots and each pool's shape."""
61
+
62
+ slots: Mapping[str, int]
63
+ pools: Mapping[str, PoolPolicy]
64
+ #: Pools that admit nothing now (their items keep their place in line): how a machine agent
65
+ #: holds its lend pool shut while the machine is not idle. Empty for a plain client.
66
+ closed: frozenset[str] = frozenset()
67
+ #: Pool name to weight that work outside the ledger already holds. Counted as used before
68
+ #: any lease; never a running count.
69
+ standing: Mapping[str, int] = field(default_factory=dict, hash=False)
70
+ #: Pools that admit only up to their `reserved` now, whatever their `max` (items past it
71
+ #: keep their place in line): how a machine agent holds back new work while the machine is
72
+ #: under memory pressure. Empty for a plain client.
73
+ capped: frozenset[str] = frozenset()
74
+
75
+ @classmethod
76
+ def from_pools(
77
+ cls,
78
+ pool_set: PoolSet,
79
+ *,
80
+ closed: frozenset[str] = frozenset(),
81
+ standing: Mapping[str, int] | None = None,
82
+ capped: frozenset[str] = frozenset(),
83
+ ) -> AdmissionPolicy:
84
+ """The policy for one machine's `PoolSet`, every pool on host `MACHINE`."""
85
+ return cls(
86
+ slots={MACHINE: pool_set.slots},
87
+ pools={
88
+ name: PoolPolicy(
89
+ host=MACHINE,
90
+ reserved=p.reserved,
91
+ max=p.max,
92
+ priority=p.priority,
93
+ per_user_max_running=p.per_user_max_running,
94
+ )
95
+ for name, p in pool_set.pools.items()
96
+ },
97
+ closed=closed,
98
+ standing=dict(standing or {}),
99
+ capped=capped,
100
+ )
101
+
102
+ def refusal(self, pool: str, weight: int) -> str | None:
103
+ """Why an item of `weight` could never be admitted to `pool`, or None if it could."""
104
+ policy = self.pools.get(pool)
105
+ if policy is None:
106
+ return f"there is no pool {pool!r}"
107
+ slots = self.slots.get(policy.host, 0)
108
+ if weight > policy.max:
109
+ return f"weight {weight} is more than pool {pool!r}'s max {policy.max}"
110
+ if weight > slots:
111
+ return f"weight {weight} is more than the {slots} slots there are"
112
+ if self.standing and not fits(_Tally.of(State(), self), self, pool, weight):
113
+ held = sum(self.standing.values())
114
+ return (
115
+ f"weight {weight} cannot fit pool {pool!r} even on an idle machine: "
116
+ f"{held} slots are held by standing work"
117
+ )
118
+ return None
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class Held:
123
+ pool: str
124
+ host: str
125
+ user: str | None
126
+ weight: int
127
+ session: str | None = None
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class Waiting:
132
+ key: ItemKey
133
+ pool: str
134
+ user: str | None
135
+ weight: int
136
+ #: The item's place in its session's FIFO: when it was queued, then the ledger's sequence.
137
+ order: tuple[float, int]
138
+ session: str | None = None
139
+
140
+
141
+ #: (pool, user): who has had a turn in which pool.
142
+ Turn = tuple[str, str | None]
143
+ #: (pool, user, session): which of a user's sessions has had a turn in which pool.
144
+ SessionTurn = tuple[str, str | None, str | None]
145
+
146
+
147
+ @dataclass(frozen=True)
148
+ class State:
149
+ held: tuple[Held, ...] = ()
150
+ waiting: tuple[Waiting, ...] = ()
151
+ #: When each user last had an item admitted in each pool, as an increasing sequence number;
152
+ #: a user never served there is absent (served first).
153
+ served: Mapping[Turn, int] = field(default_factory=dict)
154
+ #: The same, per session of a user.
155
+ session_served: Mapping[SessionTurn, int] = field(default_factory=dict)
156
+
157
+
158
+ @dataclass
159
+ class _Tally:
160
+ used: dict[str, int] = field(default_factory=dict) # pool -> weight held
161
+ host_used: dict[str, int] = field(default_factory=dict) # host -> weight held
162
+ running: dict[Turn, int] = field(default_factory=dict)
163
+ session_running: dict[SessionTurn, int] = field(default_factory=dict)
164
+ served: dict[Turn, int] = field(default_factory=dict)
165
+ session_served: dict[SessionTurn, int] = field(default_factory=dict)
166
+ last_turn: int = 0
167
+
168
+ @classmethod
169
+ def of(cls, state: State, policy: AdmissionPolicy | None = None) -> _Tally:
170
+ """`state`'s leases, after any standing weight (which has no user and runs nothing)."""
171
+ tally = cls(
172
+ served=dict(state.served),
173
+ session_served=dict(state.session_served),
174
+ last_turn=max([*state.served.values(), *state.session_served.values()], default=0),
175
+ )
176
+ if policy is not None:
177
+ for pool, weight in policy.standing.items():
178
+ shaped = policy.pools.get(pool)
179
+ if shaped is None or weight < 1:
180
+ continue
181
+ tally.used[pool] = tally.used.get(pool, 0) + weight
182
+ tally.host_used[shaped.host] = tally.host_used.get(shaped.host, 0) + weight
183
+ for h in state.held:
184
+ tally.add(h.pool, h.host, h.user, h.session, h.weight)
185
+ return tally
186
+
187
+ def add(self, pool: str, host: str, user: str | None, session: str | None, weight: int):
188
+ self.used[pool] = self.used.get(pool, 0) + weight
189
+ self.host_used[host] = self.host_used.get(host, 0) + weight
190
+ self.running[(pool, user)] = self.running.get((pool, user), 0) + 1
191
+ key = (pool, user, session)
192
+ self.session_running[key] = self.session_running.get(key, 0) + 1
193
+
194
+ def serve(self, pool: str, user: str | None, session: str | None) -> None:
195
+ self.last_turn += 1
196
+ self.served[(pool, user)] = self.last_turn
197
+ self.session_served[(pool, user, session)] = self.last_turn
198
+
199
+
200
+ def top(policy: AdmissionPolicy, pool: str) -> int:
201
+ """The most `pool` may hold now: its `max`, or its `reserved` while it is capped."""
202
+ p = policy.pools[pool]
203
+ return p.reserved if pool in policy.capped else p.max
204
+
205
+
206
+ def fits(tally: _Tally, policy: AdmissionPolicy, pool: str, weight: int) -> bool:
207
+ """The two capacity rules, for `weight` more in `pool`."""
208
+ p = policy.pools[pool]
209
+ if tally.used.get(pool, 0) + weight > top(policy, pool):
210
+ return False
211
+ owed = sum(
212
+ max(0, q.reserved - tally.used.get(name, 0))
213
+ for name, q in policy.pools.items()
214
+ if name != pool and q.host == p.host
215
+ )
216
+ free = policy.slots.get(p.host, 0) - tally.host_used.get(p.host, 0) - weight
217
+ return free >= owed
218
+
219
+
220
+ #: A pool's line: user -> session -> that session's items, oldest first.
221
+ _Line = dict[str | None, dict[str | None, list[Waiting]]]
222
+
223
+
224
+ def _line(waiting: Iterable[Waiting], pool: str) -> _Line:
225
+ line: _Line = {}
226
+ for item in sorted((w for w in waiting if w.pool == pool), key=lambda w: w.order):
227
+ line.setdefault(item.user, {}).setdefault(item.session, []).append(item)
228
+ return line
229
+
230
+
231
+ _DONE = object() # nobody has anything left to admit (a user may be None, so not None)
232
+
233
+
234
+ def _next_user(line: _Line, tally: _Tally, pool: str, skip: set[str | None]) -> object:
235
+ """Whose turn it is in `pool`: fewest running, then served longest ago, then oldest head;
236
+ `_DONE` when nobody's."""
237
+ candidates = [u for u, sessions in line.items() if _any(sessions) and u not in skip]
238
+ if not candidates:
239
+ return _DONE
240
+ return min(
241
+ candidates,
242
+ key=lambda u: (
243
+ tally.running.get((pool, u), 0),
244
+ tally.served.get((pool, u), 0),
245
+ min(q[0].order for q in line[u].values() if q),
246
+ ),
247
+ )
248
+
249
+
250
+ def _next_session(
251
+ sessions: dict[str | None, list[Waiting]], tally: _Tally, pool: str, user: str | None
252
+ ) -> str | None:
253
+ """Which of `user`'s sessions goes next, by the same three keys."""
254
+ return min(
255
+ (s for s, q in sessions.items() if q),
256
+ key=lambda s: (
257
+ tally.session_running.get((pool, user, s), 0),
258
+ tally.session_served.get((pool, user, s), 0),
259
+ sessions[s][0].order,
260
+ ),
261
+ )
262
+
263
+
264
+ def _any(sessions: dict[str | None, list[Waiting]]) -> bool:
265
+ return any(sessions.values())
266
+
267
+
268
+ def _pools_in_order(policy: AdmissionPolicy) -> list[str]:
269
+ return sorted(policy.pools, key=lambda name: (policy.pools[name].priority, name))
270
+
271
+
272
+ def decide(state: State, policy: AdmissionPolicy) -> list[ItemKey]:
273
+ """The waiting items to admit now, in admission order. Waiting items in a pool the policy
274
+ does not have are left waiting; weight held in such a pool still counts against its host.
275
+
276
+ `policy.standing` is already occupied when this starts: it reduces what fits, and it does
277
+ not change whose turn it is."""
278
+ tally = _Tally.of(state, policy)
279
+ admits: list[ItemKey] = []
280
+ for pool in _pools_in_order(policy):
281
+ if pool in policy.closed:
282
+ continue
283
+ limit = policy.pools[pool].per_user_max_running
284
+ line = _line(state.waiting, pool)
285
+ at_limit: set[str | None] = set()
286
+ while True:
287
+ user = _next_user(line, tally, pool, at_limit)
288
+ if user is _DONE:
289
+ break
290
+ if limit is not None and tally.running.get((pool, user), 0) >= limit:
291
+ at_limit.add(user)
292
+ continue
293
+ session = _next_session(line[user], tally, pool, user)
294
+ head = line[user][session][0]
295
+ if not fits(tally, policy, pool, head.weight):
296
+ break # no backfill around the item whose turn it is
297
+ line[user][session].pop(0)
298
+ tally.add(pool, policy.pools[pool].host, user, session, head.weight)
299
+ tally.serve(pool, user, session)
300
+ admits.append(head.key)
301
+ return admits
302
+
303
+
304
+ def positions(state: State, policy: AdmissionPolicy) -> dict[ItemKey, int]:
305
+ """Each waiting item's 1-based place in its pool's line: the order `decide` would take
306
+ them in if capacity freed one at a time (per-user limits aside)."""
307
+ tally = _Tally.of(state)
308
+ out: dict[ItemKey, int] = {}
309
+ for pool in sorted({w.pool for w in state.waiting}):
310
+ line = _line(state.waiting, pool)
311
+ place = 0
312
+ while (user := _next_user(line, tally, pool, set())) is not _DONE:
313
+ session = _next_session(line[user], tally, pool, user)
314
+ head = line[user][session].pop(0)
315
+ place += 1
316
+ out[head.key] = place
317
+ tally.running[(pool, user)] = tally.running.get((pool, user), 0) + 1
318
+ key = (pool, user, session)
319
+ tally.session_running[key] = tally.session_running.get(key, 0) + 1
320
+ tally.serve(pool, user, session)
321
+ return out
322
+
323
+
324
+ def blocker(
325
+ state: State,
326
+ policy: AdmissionPolicy,
327
+ key: ItemKey,
328
+ places: Mapping[ItemKey, int] | None = None,
329
+ ) -> str | None:
330
+ """Why the waiting item `key` is not admitted now, in words, or None when it is not waiting
331
+ (or would be admitted). The first reason that applies, in this order: its pool is closed;
332
+ another item is ahead of it; its user is at the pool's per-user limit; its pool is capped
333
+ to its reservation; its pool is full;
334
+ the machine's free slots are owed to other pools' reservations or are all in use.
335
+ `places` is `positions(state, policy)`, for a caller asking about many items."""
336
+ item = next((w for w in state.waiting if w.key == key), None)
337
+ if item is None or item.pool not in policy.pools:
338
+ return None
339
+ pool = policy.pools[item.pool]
340
+ if item.pool in policy.closed:
341
+ return f"pool {item.pool} is closed"
342
+ place = (places if places is not None else positions(state, policy)).get(key, 1)
343
+ if place > 1:
344
+ return f"{place - 1} ahead of it in pool {item.pool}"
345
+ tally = _Tally.of(state, policy)
346
+ if (
347
+ pool.per_user_max_running is not None
348
+ and tally.running.get((item.pool, item.user), 0) >= pool.per_user_max_running
349
+ ):
350
+ return (
351
+ f"its user already runs {pool.per_user_max_running} in pool {item.pool}, "
352
+ "the per-user limit"
353
+ )
354
+ used = tally.used.get(item.pool, 0)
355
+ if item.pool in policy.capped and pool.reserved < used + item.weight <= pool.max:
356
+ return (
357
+ f"the machine is under pressure: pool {item.pool} admits only its "
358
+ f"{pool.reserved} reserved slots and holds {used}; this needs {item.weight}"
359
+ )
360
+ if used + item.weight > pool.max:
361
+ return f"pool {item.pool} holds {used} of {pool.max}; this needs {item.weight}"
362
+ if fits(tally, policy, item.pool, item.weight):
363
+ return None
364
+ slots = policy.slots.get(pool.host, 0)
365
+ free = slots - tally.host_used.get(pool.host, 0)
366
+ owed = sum(
367
+ max(0, q.reserved - tally.used.get(name, 0))
368
+ for name, q in policy.pools.items()
369
+ if name != item.pool and q.host == pool.host
370
+ )
371
+ if free >= item.weight and owed:
372
+ return f"{free} of {slots} slots are free, but {owed} are reserved for other pools"
373
+ return f"{max(free, 0)} of {slots} slots are free; this needs {item.weight}"