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.
@@ -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