zettcode-sdk 0.3.4__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.
- zettcode/daemon/__init__.py +17 -0
- zettcode/daemon/__main__.py +10 -0
- zettcode/daemon/cli.py +693 -0
- zettcode/daemon/companions.py +63 -0
- zettcode/daemon/process.py +559 -0
- zettcode/daemon/run.py +678 -0
- zettcode/daemon/state.py +356 -0
- zettcode/doctor.py +167 -0
- zettcode/mentions.py +320 -0
- zettcode/sdk/__init__.py +195 -0
- zettcode/sdk/builtins.py +608 -0
- zettcode/sdk/client.py +1810 -0
- zettcode/sdk/commands.py +9 -0
- zettcode/sdk/daemons.py +244 -0
- zettcode/sdk/errors.py +132 -0
- zettcode/sdk/events.py +434 -0
- zettcode/sdk/extensions/__init__.py +6 -0
- zettcode/sdk/extensions/mcp.py +276 -0
- zettcode/sdk/extensions/messaging.py +281 -0
- zettcode/sdk/handlers.py +126 -0
- zettcode/sdk/messenger.py +299 -0
- zettcode/sdk/models.py +156 -0
- zettcode/sdk/py.typed +0 -0
- zettcode/sdk/session.py +1212 -0
- zettcode/sdk/translation.py +384 -0
- zettcode/sdk/types.py +907 -0
- zettcode/table.py +95 -0
- zettcode_sdk-0.3.4.dist-info/METADATA +21 -0
- zettcode_sdk-0.3.4.dist-info/RECORD +30 -0
- zettcode_sdk-0.3.4.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""What ``zettcode broker restart`` does about the background agents: stop them, and start them again afterwards.
|
|
2
|
+
|
|
3
|
+
A daemon is a session running headless on the broker. When the broker is replaced by one that speaks another protocol
|
|
4
|
+
it cannot reconnect and waits for ever, so a restart ends each daemon and, once the new broker is up, starts it again
|
|
5
|
+
on the same stored session, model, reasoning level, name and shell level. The conversation is not touched: a daemon is
|
|
6
|
+
only the process that runs it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from zettcode.broker.cli import Companions
|
|
14
|
+
|
|
15
|
+
from .process import Daemons, SpawnFailed
|
|
16
|
+
from .state import Registry, State
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DaemonCompanions(Companions):
|
|
20
|
+
"""The daemons on this machine, as the broker command sees them."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, registry: Registry | None = None) -> None:
|
|
23
|
+
"""Work on ``registry``, or on the machine's own."""
|
|
24
|
+
self.daemons = Daemons(registry)
|
|
25
|
+
self._stopped: list[State] = []
|
|
26
|
+
|
|
27
|
+
def _alive(self) -> list[State]:
|
|
28
|
+
return [r.state for r in self.daemons.registry.all() if r.state is not None and not r.dead]
|
|
29
|
+
|
|
30
|
+
def listing(self) -> list[str]:
|
|
31
|
+
"""Return one line for each daemon whose process is running."""
|
|
32
|
+
lines = []
|
|
33
|
+
for state in self._alive():
|
|
34
|
+
what = state.name or state.title or "-"
|
|
35
|
+
lines.append(f"{state.session_id} {what} {state.workspace}")
|
|
36
|
+
return lines
|
|
37
|
+
|
|
38
|
+
def stop(self) -> list[str]:
|
|
39
|
+
"""Stop every running daemon, keeping what is needed to start it again."""
|
|
40
|
+
records = [r for r in self.daemons.registry.all() if r.state is not None and not r.dead]
|
|
41
|
+
# What is started again is exactly what was stopped here, not what a second look at the folder finds.
|
|
42
|
+
self._stopped = [r.state for r in records if r.state is not None]
|
|
43
|
+
return [f"{record.session_id} {self.daemons.stop(record)}" for record in records]
|
|
44
|
+
|
|
45
|
+
def start(self) -> list[tuple[bool, str]]:
|
|
46
|
+
"""Start each stopped daemon again on its own session."""
|
|
47
|
+
results: list[tuple[bool, str]] = []
|
|
48
|
+
for state in self._stopped:
|
|
49
|
+
try:
|
|
50
|
+
self.daemons.start(
|
|
51
|
+
Path(state.workspace),
|
|
52
|
+
model=state.model,
|
|
53
|
+
effort=state.effort,
|
|
54
|
+
resume=state.session_id,
|
|
55
|
+
name=state.name,
|
|
56
|
+
permission=state.permission or "safe",
|
|
57
|
+
)
|
|
58
|
+
except SpawnFailed as error:
|
|
59
|
+
results.append((False, f"{state.session_id} could not be started again: {error}"))
|
|
60
|
+
else:
|
|
61
|
+
results.append((True, f"{state.session_id} started again"))
|
|
62
|
+
self._stopped = []
|
|
63
|
+
return results
|
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
"""Starting a daemon and stopping one: the process behind a :class:`~.state.Record`.
|
|
2
|
+
|
|
3
|
+
A daemon outlives the terminal that started it, so it is started detached: its own session on POSIX, its own
|
|
4
|
+
process group on Windows, no inherited standard input, and its output in a file rather than in whatever window
|
|
5
|
+
happened to run the command. :class:`Daemons` is the whole of it — ``start`` waits for the daemon to say it is
|
|
6
|
+
ready, so a start that returns is a session another agent can message; ``stop`` is SIGTERM, then SIGKILL, then the
|
|
7
|
+
record goes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import contextlib
|
|
14
|
+
import dataclasses
|
|
15
|
+
import os
|
|
16
|
+
import secrets
|
|
17
|
+
import shutil
|
|
18
|
+
import signal
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import IO, TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
from . import PERMISSIONS
|
|
26
|
+
from .state import START_SECONDS, Record, Registry, pid_runs
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
|
|
31
|
+
from .run import Options
|
|
32
|
+
from .state import State
|
|
33
|
+
|
|
34
|
+
#: How long a stop may take before the process is killed outright.
|
|
35
|
+
KILL_GRACE = 5.0
|
|
36
|
+
|
|
37
|
+
#: How often a start looks for the state file its child writes.
|
|
38
|
+
POLL_SECONDS = 0.05
|
|
39
|
+
|
|
40
|
+
__all__ = ["Daemons", "KILL_GRACE", "SpawnFailed", "StillRunning", "StillWorking", "UpdateFailed"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SpawnFailed(RuntimeError):
|
|
44
|
+
"""A daemon was started and never became reachable."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, said: str, log: Path | None = None) -> None:
|
|
47
|
+
"""Carry the reason, and the file the child's first words went to."""
|
|
48
|
+
super().__init__(said)
|
|
49
|
+
self.log = log
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class UpdateFailed(RuntimeError):
|
|
53
|
+
"""What a reader asked to change about a daemon cannot be changed, and why."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class StillWorking(RuntimeError):
|
|
57
|
+
"""A daemon was asked to stop in the middle of a turn without ``force``."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class StillRunning(RuntimeError):
|
|
61
|
+
"""A daemon was asked to be deleted while its process still runs."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Daemons:
|
|
65
|
+
"""The running daemons of one registry: start one, stop one, and keep the directories between the two.
|
|
66
|
+
|
|
67
|
+
The registry is all the state there is; this class is only what a process does to it, so a command holding one
|
|
68
|
+
can start, stop and list without passing a folder around.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, registry: Registry | None = None) -> None:
|
|
72
|
+
"""Work on ``registry``, or on the machine's own."""
|
|
73
|
+
self.registry = registry if registry is not None else Registry()
|
|
74
|
+
|
|
75
|
+
# -- starting ----------------------------------------------------------------------------------
|
|
76
|
+
def start(
|
|
77
|
+
self,
|
|
78
|
+
workspace: Path,
|
|
79
|
+
*,
|
|
80
|
+
model: str | None = None,
|
|
81
|
+
effort: str | None = None,
|
|
82
|
+
resume: str | None = None,
|
|
83
|
+
name: str | None = None,
|
|
84
|
+
title: str | None = None,
|
|
85
|
+
permission: str = "full",
|
|
86
|
+
) -> Record:
|
|
87
|
+
"""Start one daemon in ``workspace`` and return the record it wrote once it was reachable.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
workspace: The directory the session works in.
|
|
91
|
+
model: The model to run on, or ``None`` for the first configured.
|
|
92
|
+
effort: The reasoning level, or ``None`` for the model's own default.
|
|
93
|
+
resume: A stored session to continue, or ``None`` for a fresh one.
|
|
94
|
+
name: A label to record beside the session, so ``daemon list`` says what it is for.
|
|
95
|
+
title: What to call the session. ``None`` lets it name itself from its first answer.
|
|
96
|
+
permission: The shell level it runs at: ``deny``, ``safe`` or ``full``.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
SpawnFailed: The permission is not one of :data:`~zettcode.daemon.PERMISSIONS`, the workspace is not a
|
|
100
|
+
directory, the process exited, or it never became reachable in :data:`~.state.START_SECONDS`.
|
|
101
|
+
"""
|
|
102
|
+
workspace = workspace.expanduser().resolve()
|
|
103
|
+
if permission not in PERMISSIONS:
|
|
104
|
+
# Refused here rather than by the child: a process that cannot be started is not worth starting, and
|
|
105
|
+
# its failure would have to be read out of a log file.
|
|
106
|
+
raise SpawnFailed(f"a daemon's permission is one of {', '.join(PERMISSIONS)}, not {permission!r}")
|
|
107
|
+
if not workspace.is_dir():
|
|
108
|
+
# Popen's own refusal for a missing cwd is a FileNotFoundError that says nothing about the argument.
|
|
109
|
+
raise SpawnFailed(f"{workspace} is not a directory, so a daemon has nowhere to work")
|
|
110
|
+
if title is not None and not title.strip():
|
|
111
|
+
# The daemon would be started and then fail to name itself; refusing here says which argument it was.
|
|
112
|
+
raise SpawnFailed("a session's title needs at least one character")
|
|
113
|
+
token = secrets.token_hex(8)
|
|
114
|
+
early = self.registry.workspace(workspace, create=True) / ".spawn"
|
|
115
|
+
early.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
log_path = early / f"{token}.log"
|
|
117
|
+
with log_path.open("wb") as log:
|
|
118
|
+
child = _Child(
|
|
119
|
+
self._command(_options(workspace, token, model, effort, resume, name, title, permission)),
|
|
120
|
+
workspace,
|
|
121
|
+
log,
|
|
122
|
+
)
|
|
123
|
+
record = self._await(child, token, START_SECONDS)
|
|
124
|
+
if record is None:
|
|
125
|
+
child.abandon()
|
|
126
|
+
self._forget(token)
|
|
127
|
+
_clear(early, log_path)
|
|
128
|
+
raise SpawnFailed(_tail(log_path), log_path)
|
|
129
|
+
_clear(early, log_path)
|
|
130
|
+
return record
|
|
131
|
+
|
|
132
|
+
def _command(self, options: Options) -> list[str]:
|
|
133
|
+
"""Return the argv that runs one daemon, as this interpreter would run it."""
|
|
134
|
+
argv = [
|
|
135
|
+
sys.executable,
|
|
136
|
+
"-m",
|
|
137
|
+
"zettcode.daemon",
|
|
138
|
+
"run",
|
|
139
|
+
"--token",
|
|
140
|
+
options.token,
|
|
141
|
+
"--workspace",
|
|
142
|
+
str(options.workspace),
|
|
143
|
+
]
|
|
144
|
+
if options.session is not None:
|
|
145
|
+
argv += ["--session", options.session]
|
|
146
|
+
if options.model is not None:
|
|
147
|
+
argv += ["--model", options.model]
|
|
148
|
+
if options.effort is not None:
|
|
149
|
+
argv += ["--effort", options.effort]
|
|
150
|
+
if options.name is not None:
|
|
151
|
+
argv += ["--name", options.name]
|
|
152
|
+
if options.title is not None:
|
|
153
|
+
argv += ["--title", options.title]
|
|
154
|
+
argv += ["--permission", options.permission]
|
|
155
|
+
return argv
|
|
156
|
+
|
|
157
|
+
def _await(self, child: _Child, token: str, timeout: float) -> Record | None:
|
|
158
|
+
"""Poll the records until one says it is this start's, and reachable; ``None`` when it never did."""
|
|
159
|
+
deadline = time.monotonic() + timeout
|
|
160
|
+
while time.monotonic() < deadline:
|
|
161
|
+
record = self._ours(token)
|
|
162
|
+
if record is not None and record.status in ("idle", "working"):
|
|
163
|
+
return record
|
|
164
|
+
if child.exited():
|
|
165
|
+
# One last look: it may have said hello and then gone, and that is the truth to report.
|
|
166
|
+
return self._ours(token)
|
|
167
|
+
time.sleep(POLL_SECONDS)
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
def _ours(self, token: str) -> Record | None:
|
|
171
|
+
"""Return the record of the daemon holding ``token``, if it has written one."""
|
|
172
|
+
return next(
|
|
173
|
+
(record for record in self.registry.all() if record.state is not None and record.state.token == token),
|
|
174
|
+
None,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def _forget(self, token: str) -> None:
|
|
178
|
+
"""Remove the record a failed start left behind, so it is not listed as a daemon that once ran."""
|
|
179
|
+
record = self._ours(token)
|
|
180
|
+
if record is not None:
|
|
181
|
+
record.remove()
|
|
182
|
+
|
|
183
|
+
# -- stopping ----------------------------------------------------------------------------------
|
|
184
|
+
def stop(self, record: Record, *, grace: float = KILL_GRACE, force: bool = False, keep: bool = False) -> str:
|
|
185
|
+
"""Stop the daemon ``record`` describes, and say what happened in one sentence.
|
|
186
|
+
|
|
187
|
+
By default the record is removed too (``kill``); with ``keep`` it stays, as a dead one, so the daemon is
|
|
188
|
+
still listed and can be started again or deleted. A dead record is only removed (or, with ``keep``, left):
|
|
189
|
+
the process it described is gone already. Whether it is ``working`` is read from the state file, which can be a
|
|
190
|
+
moment old, so a daemon that has just changed turn may be judged by the one before; ``--force`` is the answer to
|
|
191
|
+
that, not a retry. The session is never touched — it is a conversation like any
|
|
192
|
+
other, and ``zettcode resume <id>`` opens it afterwards.
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
StillWorking: The daemon is in the middle of a turn and ``force`` was not given; stopping it would throw
|
|
196
|
+
that turn away.
|
|
197
|
+
"""
|
|
198
|
+
state = record.state
|
|
199
|
+
if state is None:
|
|
200
|
+
record.remove()
|
|
201
|
+
return "removed: it never recorded what it was"
|
|
202
|
+
if not record.alive:
|
|
203
|
+
if keep:
|
|
204
|
+
return "was already gone; its record was kept"
|
|
205
|
+
record.remove()
|
|
206
|
+
return "was already gone; its record was removed"
|
|
207
|
+
if state.status == "working" and not force:
|
|
208
|
+
raise StillWorking("it is working on a turn; stop it anyway with --force, or wait until it is idle")
|
|
209
|
+
pid = state.pid
|
|
210
|
+
try:
|
|
211
|
+
record.change(lambda current: dataclasses.replace(current or state, status="stopping", keep=keep))
|
|
212
|
+
except OSError as error:
|
|
213
|
+
# The status is a courtesy to a reader looking while the signal lands; a state another writer holds
|
|
214
|
+
# must not keep `kill` from sending it.
|
|
215
|
+
record.log(f"the state file could not be written: {error}")
|
|
216
|
+
if not _signal(pid, signal.SIGTERM):
|
|
217
|
+
return self._gone(record, keep, "was already gone")
|
|
218
|
+
deadline = time.monotonic() + grace
|
|
219
|
+
while time.monotonic() < deadline:
|
|
220
|
+
# The daemon removes its own record on the way out (unless asked to keep it), so the folder going is the
|
|
221
|
+
# proof it stopped — a pid can outlive the process it named, as a zombie its parent has not reaped.
|
|
222
|
+
if not record.folder.exists() or not pid_runs(pid):
|
|
223
|
+
if keep:
|
|
224
|
+
return "stopped; its record was kept"
|
|
225
|
+
record.remove()
|
|
226
|
+
return "stopped"
|
|
227
|
+
time.sleep(POLL_SECONDS)
|
|
228
|
+
_signal(pid, getattr(signal, "SIGKILL", signal.SIGTERM))
|
|
229
|
+
if not keep:
|
|
230
|
+
record.remove()
|
|
231
|
+
return "did not stop in time, so it was killed"
|
|
232
|
+
|
|
233
|
+
@staticmethod
|
|
234
|
+
def _gone(record: Record, keep: bool, said: str) -> str:
|
|
235
|
+
"""Finish a stop whose process was already gone: keep the record or remove it."""
|
|
236
|
+
if keep:
|
|
237
|
+
return f"{said}; its record was kept"
|
|
238
|
+
record.remove()
|
|
239
|
+
return f"{said}; its record was removed"
|
|
240
|
+
|
|
241
|
+
def delete(self, record: Record) -> str:
|
|
242
|
+
"""Remove a daemon for good: its record and its conversation, which cannot be brought back.
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
StillRunning: The daemon's process is alive; stop it first.
|
|
246
|
+
UpdateFailed: Another process has the session open, so it is not ours to remove.
|
|
247
|
+
"""
|
|
248
|
+
if record.alive:
|
|
249
|
+
raise StillRunning("it is running; stop it before deleting it")
|
|
250
|
+
state = record.state
|
|
251
|
+
removed = "its record was removed"
|
|
252
|
+
if state is not None:
|
|
253
|
+
if self._erase(state.session_id, state.workspace):
|
|
254
|
+
removed = "its record and its conversation were removed"
|
|
255
|
+
record.remove()
|
|
256
|
+
return removed
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def _erase(session_id: str, workspace: str) -> bool:
|
|
260
|
+
"""Remove the stored conversation of a session, holding its lock; return whether there was one."""
|
|
261
|
+
from zettcode.engine.storage import SessionLocks, SessionStore
|
|
262
|
+
|
|
263
|
+
store = SessionStore(workspace=workspace)
|
|
264
|
+
try:
|
|
265
|
+
folder = store.session_dir(session_id)
|
|
266
|
+
except ValueError:
|
|
267
|
+
return False
|
|
268
|
+
if not folder.exists():
|
|
269
|
+
return False
|
|
270
|
+
locks = SessionLocks(store.session_dir)
|
|
271
|
+
try:
|
|
272
|
+
taken = locks.claim(session_id)
|
|
273
|
+
except ValueError as error:
|
|
274
|
+
raise UpdateFailed(str(error)) from error
|
|
275
|
+
if not taken:
|
|
276
|
+
raise UpdateFailed("the session is open in another process, so it is not ours to delete")
|
|
277
|
+
try:
|
|
278
|
+
shutil.rmtree(folder, ignore_errors=True)
|
|
279
|
+
finally:
|
|
280
|
+
locks.release()
|
|
281
|
+
return True
|
|
282
|
+
|
|
283
|
+
# -- what a reader may change afterwards --------------------------------------------------------
|
|
284
|
+
def update(
|
|
285
|
+
self,
|
|
286
|
+
record: Record,
|
|
287
|
+
*,
|
|
288
|
+
title: str | None = None,
|
|
289
|
+
name: str | None = None,
|
|
290
|
+
model: str | None = None,
|
|
291
|
+
effort: str | None = None,
|
|
292
|
+
) -> Record:
|
|
293
|
+
"""Change what is recorded about one daemon: its label, its session's title, or what it runs on.
|
|
294
|
+
|
|
295
|
+
The label is the daemon's, so it may be changed while it runs; the daemon keeps a name a reader put there
|
|
296
|
+
(see ``Daemon._label``). The title is the *session's*, and a session is written to only by the process that
|
|
297
|
+
holds its lock: while the daemon runs it holds it, and when it is gone the session is free. A blank title is
|
|
298
|
+
refused here rather than written as one. Given together, a label is written even when the title cannot be,
|
|
299
|
+
and the error says so.
|
|
300
|
+
|
|
301
|
+
A model or an effort is a request for a daemon that is running: it is written into the record and the daemon
|
|
302
|
+
puts it in force before its next turn (or within a beat), keeping the conversation. A daemon that is gone
|
|
303
|
+
cannot be moved to another model — it has no process to move.
|
|
304
|
+
|
|
305
|
+
This blocks while it writes the session's metadata, so it is called where no event loop runs — the CLI
|
|
306
|
+
calls it directly and the SDK from a worker thread.
|
|
307
|
+
|
|
308
|
+
Raises:
|
|
309
|
+
UpdateFailed: There is no state to change, the daemon still runs, its session is open in another
|
|
310
|
+
process, its record cannot be written, the title is not one a session can carry, or the model or
|
|
311
|
+
the effort is not one this machine has configured.
|
|
312
|
+
"""
|
|
313
|
+
if title is None and name is None and model is None and effort is None:
|
|
314
|
+
return record
|
|
315
|
+
state = record.state
|
|
316
|
+
if state is None:
|
|
317
|
+
raise UpdateFailed("this daemon never recorded what it was, so there is nothing to change")
|
|
318
|
+
cleaned = None if title is None else title.strip()
|
|
319
|
+
label = None if name is None else (name.strip() or None)
|
|
320
|
+
wanted_model = None if model is None else model.strip()
|
|
321
|
+
wanted_effort = None if effort is None else effort.strip()
|
|
322
|
+
if model is not None and not wanted_model:
|
|
323
|
+
raise UpdateFailed("a model needs a name")
|
|
324
|
+
if effort is not None and not wanted_effort:
|
|
325
|
+
raise UpdateFailed("an effort needs a name")
|
|
326
|
+
if wanted_model is not None or wanted_effort is not None:
|
|
327
|
+
# A level is checked against the model the daemon would end on: its own, a model a previous request
|
|
328
|
+
# already asked for, or the one this command names.
|
|
329
|
+
pending_effort = wanted_effort if wanted_effort is not None else state.requested_effort
|
|
330
|
+
try:
|
|
331
|
+
if not record.alive:
|
|
332
|
+
raise UpdateFailed("the daemon is not running, so it cannot be moved to another model")
|
|
333
|
+
self._check_runnable(state, wanted_model, pending_effort)
|
|
334
|
+
except UpdateFailed as error:
|
|
335
|
+
said = str(error)
|
|
336
|
+
if (
|
|
337
|
+
record.alive
|
|
338
|
+
and wanted_model is not None
|
|
339
|
+
and wanted_effort is None
|
|
340
|
+
and state.requested_effort is not None
|
|
341
|
+
and " takes " in said
|
|
342
|
+
):
|
|
343
|
+
# The level that blocked it is not the daemon's own but one an earlier command asked for.
|
|
344
|
+
said += f"; the level {state.requested_effort!r} was already requested, so set one it takes first"
|
|
345
|
+
# A label or a title is allowed whatever the settings are; write them and say what happened,
|
|
346
|
+
# rather than refusing the whole command for the one argument that could not be taken.
|
|
347
|
+
if title is None and name is None:
|
|
348
|
+
raise UpdateFailed(said) from error
|
|
349
|
+
try:
|
|
350
|
+
self.update(record, title=title, name=name)
|
|
351
|
+
except UpdateFailed as secondary:
|
|
352
|
+
raise UpdateFailed(f"{said}; and {secondary}") from error
|
|
353
|
+
raise UpdateFailed(f"{said}; the other changes were written") from error
|
|
354
|
+
other = name is not None or wanted_model is not None or wanted_effort is not None
|
|
355
|
+
|
|
356
|
+
def applied(current: State | None, *, title: str | None) -> State:
|
|
357
|
+
base = current or state
|
|
358
|
+
if name is not None:
|
|
359
|
+
base = dataclasses.replace(base, name=label)
|
|
360
|
+
if wanted_model is not None:
|
|
361
|
+
base = dataclasses.replace(base, requested_model=wanted_model)
|
|
362
|
+
if wanted_effort is not None:
|
|
363
|
+
base = dataclasses.replace(base, requested_effort=wanted_effort)
|
|
364
|
+
if wanted_model is not None or wanted_effort is not None:
|
|
365
|
+
# A new request has not failed yet; an error left by an older one is about that one.
|
|
366
|
+
base = dataclasses.replace(base, requested_error=None)
|
|
367
|
+
if title is not None:
|
|
368
|
+
base = dataclasses.replace(base, title=title)
|
|
369
|
+
return base
|
|
370
|
+
|
|
371
|
+
if title is not None and not cleaned:
|
|
372
|
+
if other:
|
|
373
|
+
self._write(record, lambda current: applied(current, title=None))
|
|
374
|
+
raise UpdateFailed("a title needs at least one character; the other changes were written")
|
|
375
|
+
raise UpdateFailed("a title needs at least one character")
|
|
376
|
+
if cleaned is None:
|
|
377
|
+
return self._write(record, lambda current: applied(current, title=None))
|
|
378
|
+
if record.alive:
|
|
379
|
+
message = "the daemon is running, and its session is its own; stop it before naming it"
|
|
380
|
+
if other:
|
|
381
|
+
self._write(record, lambda current: applied(current, title=None))
|
|
382
|
+
message += "; the other changes were written"
|
|
383
|
+
raise UpdateFailed(message)
|
|
384
|
+
try:
|
|
385
|
+
self._rename(record.session_id, state.workspace, cleaned)
|
|
386
|
+
except UpdateFailed as error:
|
|
387
|
+
if other:
|
|
388
|
+
self._write(record, lambda current: applied(current, title=None))
|
|
389
|
+
raise UpdateFailed(f"{error}; the other changes were written") from error
|
|
390
|
+
raise
|
|
391
|
+
try:
|
|
392
|
+
return self._write(record, lambda current: applied(current, title=cleaned))
|
|
393
|
+
except UpdateFailed as error:
|
|
394
|
+
raise UpdateFailed(f"{error}; the session was renamed, so its record still says the old title") from error
|
|
395
|
+
|
|
396
|
+
@staticmethod
|
|
397
|
+
def _check_runnable(state: State, model: str | None, effort: str | None) -> None:
|
|
398
|
+
"""Refuse a model or a level this machine cannot run, naming the ones it can.
|
|
399
|
+
|
|
400
|
+
A model is resolved the way every layer resolves one (H26): its display name first, its id only when no
|
|
401
|
+
display name answers, and a name two entries share is refused rather than guessed. A level belongs to a
|
|
402
|
+
model, so it is checked against the one the request would end on — the daemon's own when only a level is
|
|
403
|
+
asked for, and that one must be findable, or there is nothing to check the level against.
|
|
404
|
+
"""
|
|
405
|
+
from zettcode.sdk import list_models
|
|
406
|
+
|
|
407
|
+
try:
|
|
408
|
+
models = list_models()
|
|
409
|
+
except ValueError as error:
|
|
410
|
+
raise UpdateFailed(str(error)) from error
|
|
411
|
+
chosen = None
|
|
412
|
+
if model is not None or effort is not None:
|
|
413
|
+
wanted = model if model is not None else (state.requested_model or state.model or "")
|
|
414
|
+
shown = [entry for entry in models if entry.name == wanted]
|
|
415
|
+
matches = shown or [entry for entry in models if entry.model == wanted]
|
|
416
|
+
if len(matches) > 1:
|
|
417
|
+
raise UpdateFailed(f"Ambiguous model: {wanted}; give one a display_model")
|
|
418
|
+
chosen = matches[0] if matches else None
|
|
419
|
+
if model is not None and chosen is None:
|
|
420
|
+
raise UpdateFailed(f"No model called {model!r}; configured: {', '.join(entry.name for entry in models)}")
|
|
421
|
+
if effort is not None:
|
|
422
|
+
if chosen is None:
|
|
423
|
+
if state.requested_model is None and state.model is None:
|
|
424
|
+
raise UpdateFailed("this daemon has not recorded a model, so its levels cannot be checked")
|
|
425
|
+
raise UpdateFailed(
|
|
426
|
+
f"the daemon's model {state.requested_model or state.model!r} is not in this configuration, "
|
|
427
|
+
"so its levels cannot be checked"
|
|
428
|
+
)
|
|
429
|
+
if effort not in chosen.efforts:
|
|
430
|
+
raise UpdateFailed(f"{chosen.name} takes {', '.join(chosen.efforts)}, not {effort!r}")
|
|
431
|
+
|
|
432
|
+
@staticmethod
|
|
433
|
+
def _write(record: Record, produce: Callable[[State | None], State]) -> Record:
|
|
434
|
+
"""Write the record through the lock that keeps a daemon's refresh and a reader's change apart."""
|
|
435
|
+
try:
|
|
436
|
+
record.change(produce)
|
|
437
|
+
except OSError as error:
|
|
438
|
+
raise UpdateFailed(f"the daemon's record could not be written: {error}") from error
|
|
439
|
+
return record
|
|
440
|
+
|
|
441
|
+
@staticmethod
|
|
442
|
+
def _rename(session_id: str, workspace: str, title: str) -> None:
|
|
443
|
+
"""Write the session's title into its metadata, holding the session's lock while it is written.
|
|
444
|
+
|
|
445
|
+
The lock is what S16/H27 mean by "written only by the process holding it": asking whether it is held and
|
|
446
|
+
then writing without it is a check-then-act a terminal can slip into between the two steps.
|
|
447
|
+
"""
|
|
448
|
+
from zettcode.engine.storage import SessionLocks, SessionStore
|
|
449
|
+
|
|
450
|
+
store = SessionStore(workspace=workspace)
|
|
451
|
+
locks = SessionLocks(store.session_dir)
|
|
452
|
+
try:
|
|
453
|
+
taken = locks.claim(session_id)
|
|
454
|
+
except ValueError as error:
|
|
455
|
+
raise UpdateFailed(str(error)) from error
|
|
456
|
+
if not taken:
|
|
457
|
+
raise UpdateFailed("the session is open in another process, so its name is not ours to write")
|
|
458
|
+
try:
|
|
459
|
+
asyncio.run(store.set_title(session_id, title))
|
|
460
|
+
except (OSError, ValueError) as error:
|
|
461
|
+
raise UpdateFailed(str(error)) from error
|
|
462
|
+
finally:
|
|
463
|
+
locks.release()
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
class _Child:
|
|
467
|
+
"""The process a start made, kept just long enough to know whether it worked."""
|
|
468
|
+
|
|
469
|
+
def __init__(self, argv: list[str], workspace: Path, log: IO[bytes]) -> None:
|
|
470
|
+
if os.name == "nt": # pragma: no cover - exercised on Windows only
|
|
471
|
+
self.process = subprocess.Popen( # noqa: S603 - the argv is ours, not a caller's string
|
|
472
|
+
argv,
|
|
473
|
+
stdin=subprocess.DEVNULL,
|
|
474
|
+
stdout=log,
|
|
475
|
+
stderr=subprocess.STDOUT,
|
|
476
|
+
cwd=workspace,
|
|
477
|
+
creationflags=0x00000008 | 0x00000200, # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
|
|
478
|
+
)
|
|
479
|
+
else:
|
|
480
|
+
self.process = subprocess.Popen( # noqa: S603 - the argv is ours, not a caller's string
|
|
481
|
+
argv,
|
|
482
|
+
stdin=subprocess.DEVNULL,
|
|
483
|
+
stdout=log,
|
|
484
|
+
stderr=subprocess.STDOUT,
|
|
485
|
+
cwd=workspace,
|
|
486
|
+
start_new_session=True,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
def exited(self) -> bool:
|
|
490
|
+
"""Return whether it has already ended."""
|
|
491
|
+
return self.process.poll() is not None
|
|
492
|
+
|
|
493
|
+
def abandon(self) -> None:
|
|
494
|
+
"""Stop a child that never became reachable, so a failed start leaves nothing running."""
|
|
495
|
+
if self.process.poll() is None:
|
|
496
|
+
self.process.terminate()
|
|
497
|
+
deadline = time.monotonic() + KILL_GRACE
|
|
498
|
+
while time.monotonic() < deadline:
|
|
499
|
+
if self.process.poll() is not None:
|
|
500
|
+
return
|
|
501
|
+
time.sleep(POLL_SECONDS)
|
|
502
|
+
with contextlib.suppress(OSError):
|
|
503
|
+
self.process.kill()
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _options(
|
|
507
|
+
workspace: Path,
|
|
508
|
+
token: str,
|
|
509
|
+
model: str | None,
|
|
510
|
+
effort: str | None,
|
|
511
|
+
resume: str | None,
|
|
512
|
+
name: str | None,
|
|
513
|
+
title: str | None,
|
|
514
|
+
permission: str,
|
|
515
|
+
) -> Options:
|
|
516
|
+
"""Build what ``daemon run`` should be told, without importing its module until it is needed."""
|
|
517
|
+
from .run import Options
|
|
518
|
+
|
|
519
|
+
return Options(
|
|
520
|
+
workspace=workspace,
|
|
521
|
+
token=token,
|
|
522
|
+
session=resume,
|
|
523
|
+
model=model,
|
|
524
|
+
effort=effort,
|
|
525
|
+
name=name,
|
|
526
|
+
title=title,
|
|
527
|
+
permission=permission,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _clear(early: Path, log_path: Path) -> None:
|
|
532
|
+
"""Remove the start's early log when it said nothing, and the folder when it is empty."""
|
|
533
|
+
try:
|
|
534
|
+
if log_path.exists() and log_path.stat().st_size == 0:
|
|
535
|
+
log_path.unlink()
|
|
536
|
+
if early.is_dir() and not any(early.iterdir()):
|
|
537
|
+
early.rmdir()
|
|
538
|
+
except OSError:
|
|
539
|
+
pass
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _tail(path: Path, lines: int = 12) -> str:
|
|
543
|
+
"""Return the last lines the child wrote before it gave up."""
|
|
544
|
+
try:
|
|
545
|
+
text = path.read_text(encoding="utf-8", errors="replace").strip()
|
|
546
|
+
except OSError:
|
|
547
|
+
return "the daemon did not say why it could not start"
|
|
548
|
+
if not text:
|
|
549
|
+
return "the daemon stopped before it could say anything"
|
|
550
|
+
return "\n".join(text.splitlines()[-lines:])
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _signal(pid: int, number: int) -> bool:
|
|
554
|
+
"""Send one signal; ``False`` when the process is not there to take it."""
|
|
555
|
+
try:
|
|
556
|
+
os.kill(pid, number)
|
|
557
|
+
except OSError:
|
|
558
|
+
return False
|
|
559
|
+
return True
|