stacktrace-cli 0.5.2__py3-none-any.whl → 0.6.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.
@@ -14,6 +14,7 @@ import os
14
14
  from collections.abc import Mapping, Sequence
15
15
  from dataclasses import dataclass
16
16
  from datetime import timedelta
17
+ from decimal import Decimal, InvalidOperation
17
18
 
18
19
  #: Every setting, as `(attribute, environment variable, default spelling)`.
19
20
  #: One table so a flag, its environment variable and its default cannot drift
@@ -25,6 +26,10 @@ SETTINGS: tuple[tuple[str, str, str], ...] = (
25
26
  ("fleet_interval", "STACKTRACE_FLEET_INTERVAL", "5m"),
26
27
  ("webhook_interval", "STACKTRACE_WEBHOOK_INTERVAL", "1m"),
27
28
  ("bom_interval", "STACKTRACE_BOM_INTERVAL", "6h"),
29
+ # ADR-0081: opt-in only. A detached `start` inherits this the same way it
30
+ # already inherits TYPESAFE_API_KEY -- by setting the environment
31
+ # variable before forking, not by re-typing the flag.
32
+ ("reasoning", "STACKTRACE_REASONING", "false"),
28
33
  )
29
34
 
30
35
  _UNITS: dict[str, int] = {"s": 1, "m": 60, "h": 3600, "d": 86400}
@@ -41,6 +46,20 @@ _UNITS: dict[str, int] = {"s": 1, "m": 60, "h": 3600, "d": 86400}
41
46
  #: close to this limit.
42
47
  _MAX_DURATION_SECONDS = 1e9
43
48
 
49
+ #: A duration this small is never a legitimate cadence — every real scan,
50
+ #: fleet, webhook, or BOM interval this repo suggests is measured in minutes
51
+ #: to hours, and `0` (disabled) is handled separately. Rejecting it here,
52
+ #: rather than only bounding it above, closes a class of review findings a
53
+ #: `Job` deep in the scheduler kept surfacing one instance of at a time: a
54
+ #: value small enough relative to a `monotonic()` reading in the millions of
55
+ #: seconds (a long-running host's uptime) makes `now - anchor` divided by
56
+ #: `interval` overflow to infinity, or round back to `anchor` on the float
57
+ #: grid, either way breaking arithmetic that assumes `interval` is a sane,
58
+ #: representable step. Validating the input at this boundary means that
59
+ #: arithmetic never has to defend itself against a value that should not have
60
+ #: reached it.
61
+ _MIN_POSITIVE_DURATION_SECONDS = 1.0
62
+
44
63
 
45
64
  def parse_duration(value: str) -> float:
46
65
  """`5s`, `3d`, `6h`, or a bare count of seconds. `0` means disabled.
@@ -64,14 +83,52 @@ def parse_duration(value: str) -> float:
64
83
  ) from None
65
84
  if not math.isfinite(amount) or amount < 0:
66
85
  raise ValueError(f"duration must be a non-negative, finite number: {value!r}")
86
+ if amount == 0.0:
87
+ # `float()` underflows a positive-but-tiny literal to exactly `0.0`
88
+ # well before it would otherwise hit `_MIN_POSITIVE_DURATION_SECONDS`
89
+ # below (the smallest subnormal float is ~4.9e-324) -- silently
90
+ # accepting that as `scaled == 0` turns a mistyped positive interval
91
+ # into "disabled" rather than refusing it. `Decimal` parses the same
92
+ # literal exactly, with no binary-float rounding, telling a real zero
93
+ # apart from one that only looks like zero once converted -- except
94
+ # an exponent extreme enough to underflow `float()` (unlike `float`,
95
+ # which clamps) can still exceed `Decimal`'s own representable range
96
+ # and raise `InvalidOperation` rather than return a value to compare;
97
+ # that is exactly as not-a-literal-zero as any other reading here.
98
+ try:
99
+ is_literal_zero = Decimal(digits) == 0
100
+ except InvalidOperation:
101
+ is_literal_zero = False
102
+ if not is_literal_zero:
103
+ raise ValueError(
104
+ f"duration is too small ({value!r}); the minimum is "
105
+ f"{_MIN_POSITIVE_DURATION_SECONDS:.0f} second, or 0 to disable"
106
+ )
67
107
  scaled = amount * unit
68
108
  if not math.isfinite(scaled) or scaled > _MAX_DURATION_SECONDS:
69
109
  raise ValueError(
70
110
  f"duration is too large ({value!r}); the limit is {_MAX_DURATION_SECONDS:.0f} seconds"
71
111
  )
112
+ if 0 < scaled < _MIN_POSITIVE_DURATION_SECONDS:
113
+ raise ValueError(
114
+ f"duration is too small ({value!r}); the minimum is "
115
+ f"{_MIN_POSITIVE_DURATION_SECONDS:.0f} second, or 0 to disable"
116
+ )
72
117
  return scaled
73
118
 
74
119
 
120
+ def parse_reasoning(value: str) -> bool:
121
+ """`true` or `false`, case-insensitively. Anything else -- `TRUE`, `1`, a
122
+ typo like `treu` -- is a mistyped opt-in and must fail loudly rather than
123
+ silently resolve to off, the same way a mistyped duration fails rather
124
+ than silently falling back to a default.
125
+ """
126
+ normalized = value.strip().lower()
127
+ if normalized not in ("true", "false"):
128
+ raise ValueError(f"reasoning must be 'true' or 'false', not {value!r}")
129
+ return normalized == "true"
130
+
131
+
75
132
  @dataclass(frozen=True)
76
133
  class Setting:
77
134
  """One resolved value, the text it was resolved from, and where that came from."""
@@ -90,6 +147,7 @@ class DaemonConfig:
90
147
  fleet_interval: Setting
91
148
  webhook_interval: Setting
92
149
  bom_interval: Setting
150
+ reasoning: Setting
93
151
 
94
152
  @property
95
153
  def scan_interval_seconds(self) -> float:
@@ -111,6 +169,10 @@ class DaemonConfig:
111
169
  def bom_interval_seconds(self) -> float:
112
170
  return parse_duration(self.bom_interval.value)
113
171
 
172
+ @property
173
+ def reasoning_enabled(self) -> bool:
174
+ return parse_reasoning(self.reasoning.value)
175
+
114
176
  @property
115
177
  def kinds(self) -> tuple[str, ...]:
116
178
  """The agent kinds to observe, in the order given, without repeats."""
@@ -125,9 +187,14 @@ class DaemonConfig:
125
187
 
126
188
  @classmethod
127
189
  def from_document(cls, document: Mapping[str, object]) -> DaemonConfig:
128
- """Read back what a running daemon wrote. Anything unreadable falls back
129
- to the default, because a status surface that raises on a malformed file
130
- tells the operator less than one that says what the default is."""
190
+ """Read back what a running daemon wrote. A malformed entry falls back
191
+ to the default, because a status surface that raises on a corrupt file
192
+ tells the operator less than one that says what the default is -- but a
193
+ key missing from the document entirely is a different claim: the
194
+ incumbent daemon predates this setting and never had anything to
195
+ report, it did not choose the default. Reporting the default for that
196
+ case would let `daemon status` claim a value the incumbent, not-yet-
197
+ restarted process during a rolling upgrade may not be honoring."""
131
198
  resolved: dict[str, Setting] = {}
132
199
  for name, _variable, default in SETTINGS:
133
200
  entry = document.get(name)
@@ -136,7 +203,12 @@ class DaemonConfig:
136
203
  if isinstance(value, str) and isinstance(source, str):
137
204
  resolved[name] = Setting(value, source)
138
205
  continue
139
- resolved[name] = Setting(default, "default")
206
+ if name in document:
207
+ resolved[name] = Setting(default, "default")
208
+ else:
209
+ resolved[name] = Setting(
210
+ "unknown", "not reported by the running daemon (restart to report)"
211
+ )
140
212
  return cls(**resolved)
141
213
 
142
214
  @classmethod
@@ -149,6 +221,7 @@ class DaemonConfig:
149
221
  fleet_interval: str | None = None,
150
222
  webhook_interval: str | None = None,
151
223
  bom_interval: str | None = None,
224
+ reasoning: bool | None = None,
152
225
  environment: Mapping[str, str] | None = None,
153
226
  ) -> DaemonConfig:
154
227
  """Flag, then environment, then default — and validate here, not later.
@@ -166,13 +239,19 @@ class DaemonConfig:
166
239
  "fleet_interval": fleet_interval,
167
240
  "webhook_interval": webhook_interval,
168
241
  "bom_interval": bom_interval,
242
+ "reasoning": None if reasoning is None else ("true" if reasoning else "false"),
169
243
  }
170
244
  resolved: dict[str, Setting] = {}
171
245
  for name, variable, default in SETTINGS:
172
246
  flag = flags[name]
173
247
  if flag is not None:
174
248
  resolved[name] = Setting(flag, "flag")
175
- elif environ.get(variable):
249
+ elif variable in environ:
250
+ # Presence, not truthiness: `STACKTRACE_REASONING=""` (an
251
+ # unset-variable interpolation gone wrong, say) is the
252
+ # operator setting this to an empty string, not leaving it
253
+ # unset -- it must reach the same validation below as any
254
+ # other spelling, not quietly take the default.
176
255
  resolved[name] = Setting(environ[variable], f"environment ({variable})")
177
256
  else:
178
257
  resolved[name] = Setting(default, "default")
@@ -185,6 +264,7 @@ class DaemonConfig:
185
264
  config.fleet_interval_seconds,
186
265
  config.webhook_interval_seconds,
187
266
  config.bom_interval_seconds,
267
+ config.reasoning_enabled,
188
268
  )
189
269
  if not config.kinds:
190
270
  raise ValueError("--agent-kind must name at least one agent kind")
@@ -25,6 +25,7 @@ the interval's, unchanged.
25
25
  from __future__ import annotations
26
26
 
27
27
  import hashlib
28
+ import math
28
29
  import socket
29
30
  from collections.abc import Callable
30
31
  from contextlib import suppress
@@ -66,6 +67,19 @@ class Job:
66
67
  #: Whether anything wakes this job. `False` for the BOM job, which nothing
67
68
  #: does, so `--bom-interval 0` genuinely disables it.
68
69
  wakeable: bool = False
70
+ #: Whether this job's very first run keeps its staggered `_due_at`'s phase
71
+ #: for every run after, instead of collapsing onto `now`. Set for a
72
+ #: one-time startup kick (the BOM job): without it, that first `run_if_due`
73
+ #: would re-anchor `_due_at` on the moment every host happened to start
74
+ #: (or, if the initial scan runs long, the moment it finished) rather than
75
+ #: each host's `stagger()` slot, defeating the fleet-wide spread on every
76
+ #: run after the first, not just that one. Keyed on "first run ever"
77
+ #: rather than on whether `_due_at` is still ahead of `now` at execution
78
+ #: time, since a slow tick can put a low-offset host's `_due_at` in the
79
+ #: past before this ever runs. Fleet/webhook don't set this — they're
80
+ #: woken repeatedly by real events for the life of the daemon, where
81
+ #: collapsing onto `now` is the right call every time, not just the first.
82
+ preserve_stagger: bool = False
69
83
  _due_at: float | None = field(default=None, init=False)
70
84
  _woken: bool = field(default=False, init=False)
71
85
  _ran_at: float | None = field(default=None, init=False)
@@ -125,8 +139,10 @@ class Job:
125
139
  # run is about work that run may not have seen, so it must survive into
126
140
  # the next pass rather than being cleared by the one it raced.
127
141
  self._woken = False
142
+ is_first_run = self._ran_at is None
128
143
  self._ran_at = now
129
- if self.interval > 0:
144
+ preserve_this_run = self.preserve_stagger and is_first_run and self._due_at is not None
145
+ if self.interval > 0 and not preserve_this_run:
130
146
  self._due_at = now + self.interval
131
147
  try:
132
148
  self.run()
@@ -138,6 +154,37 @@ class Job:
138
154
  # job's, which every other job's own error already survives.
139
155
  with suppress(Exception):
140
156
  self.on_error(self.name, error)
157
+ finally:
158
+ if preserve_this_run and self.interval > 0:
159
+ # The job's first run ever -- the BOM startup kick, however
160
+ # late it actually lands (a slow initial scan can push `now`
161
+ # well past this job's staggered `_due_at` before the tick
162
+ # loop ever reaches it, so comparing `_due_at` to `now` here
163
+ # cannot tell early from late). Keep that slot's phase --
164
+ # advanced by however many whole intervals the delay ate,
165
+ # never fewer than one -- rather than starting a fresh,
166
+ # unstaggered cadence from whenever this run happened.
167
+ # Measured against the clock *after* `run()` returns, not
168
+ # before: the run itself (an actual upload) can eat into the
169
+ # margin a pre-run measurement assumed was safe, and with a
170
+ # short `--bom-interval` that margin can be smaller than the
171
+ # run takes.
172
+ original_due_at = self._due_at
173
+ assert original_due_at is not None
174
+ after = self.clock()
175
+ # `floor(x) + 1`, not `ceil(x)`: when the delay lands on an
176
+ # exact multiple of the interval, `ceil` leaves this equal to
177
+ # `after` rather than past it, and `now >= _due_at` reads that
178
+ # as due again immediately. `floor(x) + 1 > x` always, for
179
+ # every real `x`, so the result is strictly future-facing
180
+ # with no boundary case left to special-case. This division
181
+ # assumes `interval` is `0` or a sane positive duration --
182
+ # `parse_duration` (`config.py`) refuses anything smaller,
183
+ # which is where that assumption belongs, not a defensive
184
+ # catch here for a value that should never reach a `Job` at
185
+ # all.
186
+ periods = max(1, math.floor((after - original_due_at) / self.interval) + 1)
187
+ self._due_at = original_due_at + periods * self.interval
141
188
  return True
142
189
 
143
190
 
@@ -36,6 +36,7 @@ from stacktrace_cli.correlate.orchestrate import (
36
36
  advisory_attacher,
37
37
  )
38
38
  from stacktrace_cli.detector.render import detection_json, unknown_json
39
+ from stacktrace_cli.detector.run import NO_ANALYZER
39
40
  from stacktrace_cli.sessions.access import IncrementalSessions, SessionView
40
41
 
41
42
  from .store import FindingStore, Notification, SessionKey
@@ -502,6 +503,17 @@ class ClaudeSessionObserver:
502
503
  underbuilt_now = True
503
504
  else:
504
505
  underbuilt_now = False
506
+ budget_exhausted_now = any(u.get("reason") == "budget_exhausted" for u in result.unknowns)
507
+ if budget_exhausted_now:
508
+ underbuilt_now = True
509
+ # `reasoning_not_requested` deliberately gets no such marker (ADR-0080).
510
+ # With reasoning off by default (ADR-0081) it is the routine outcome
511
+ # for a gate-tripping session, not the rare one `_underbuilt` was
512
+ # sized for -- mirroring `budget_exhausted` here would retry every
513
+ # such session on every tick, indefinitely, for as long as reasoning
514
+ # stays off. Enabling reasoning starts coverage from that point
515
+ # forward; it does not requeue what was already scanned while it was
516
+ # off.
505
517
  notifications = self.store.record(
506
518
  collected.session,
507
519
  result.findings,
@@ -521,7 +533,8 @@ class ClaudeSessionObserver:
521
533
  )
522
534
  for notification in notifications:
523
535
  self.publish(notification)
524
- self._fingerprints[collected.session] = collected.fingerprint
536
+ if not budget_exhausted_now:
537
+ self._fingerprints[collected.session] = collected.fingerprint
525
538
  self._scanned_generations[collected.session] = collected.composition_generation
526
539
  self._scanned_pending[collected.session] = underbuilt_now
527
540
  self._session_views[collected.session] = collected.view
@@ -554,12 +567,22 @@ def merge_views(collected: Iterable[Collected]) -> SessionView:
554
567
  )
555
568
 
556
569
 
570
+ #: ADR-0076's daemon-specific reasoning ceiling, "docs/adrs/0076-two-phase-
571
+ #: detection-pipeline.md" § *Async Phase 2*: `DEFAULT_BUDGET` (10) is sized
572
+ #: for a one-shot `detect`/`monitor` invocation, not a long-running scan whose
573
+ #: batches can be larger; leaving it at the generic default meant sessions
574
+ #: past the tenth in a batch got `budget_exhausted` on every tick.
575
+ DEFAULT_DAEMON_BUDGET = 50
576
+
577
+
557
578
  def analyse_batch(
558
579
  view: SessionView,
559
580
  collected: tuple[Collected, ...],
560
581
  since: datetime,
561
582
  *,
562
583
  agent_kinds: tuple[str, ...],
584
+ reasoning: bool = False,
585
+ analyzer: str = NO_ANALYZER,
563
586
  attach_advisories: Any = None,
564
587
  build_all: Any = None,
565
588
  ) -> dict[SessionKey, ScanResult]:
@@ -575,6 +598,10 @@ def analyse_batch(
575
598
  it as `window_start` and analyses the view whole, so a session-wide rule
576
599
  (ADR-0016) still sees the whole session and the old per-transcript
577
600
  `mtime - 1 minute` cutoff is not needed.
601
+
602
+ `reasoning`/`analyzer` default off (ADR-0081): the caller passes the
603
+ daemon's own resolved `DaemonConfig.reasoning_enabled`, so stage three
604
+ only runs when the operator opted in, `TYPESAFE_API_KEY` notwithstanding.
578
605
  """
579
606
  by_qualified_id = {item.qualified_id: item.session for item in collected}
580
607
  analysis = analyse_sessions(
@@ -582,7 +609,9 @@ def analyse_batch(
582
609
  agent_kinds=agent_kinds,
583
610
  since=since,
584
611
  session_ids=tuple(by_qualified_id),
585
- reasoning=False,
612
+ reasoning=reasoning,
613
+ analyzer=analyzer,
614
+ budget=DEFAULT_DAEMON_BUDGET,
586
615
  sample_budget=0,
587
616
  attach_advisories=attach_advisories,
588
617
  build_all=build_all,
@@ -24,6 +24,34 @@ _GUIDANCE = {
24
24
  "The agent encountered a block while working",
25
25
  "Inspect the recorded block reason before retrying the task.",
26
26
  ),
27
+ "stacktrace-destructive-action": (
28
+ "A recursive delete targeted a path outside the workspace",
29
+ "Check whether the deletion was intentional and restore from backup if not.",
30
+ ),
31
+ "stacktrace-credential-persisted": (
32
+ "Secret-shaped material was written to a file",
33
+ "Rotate the credential immediately and remove it from the file.",
34
+ ),
35
+ "stacktrace-infra-unsafe-action": (
36
+ "A mutating infrastructure command ran without explicit user request",
37
+ "Verify the action was intended and inspect its target environment.",
38
+ ),
39
+ "stacktrace-unrequested-publication": (
40
+ "Code or artifacts were published without explicit user request",
41
+ "Verify the publication was intended and inspect what was made public.",
42
+ ),
43
+ "stacktrace-mcp-transient-failure": (
44
+ "MCP auth failure followed by recovery on a server",
45
+ "Investigate whether the recovery involved a credential change or a transient issue.",
46
+ ),
47
+ "stacktrace-tool-retry-storm": (
48
+ "A tool was retried repeatedly with identical parameters",
49
+ "The agent did not adapt its approach after repeated failures.",
50
+ ),
51
+ "stacktrace-vulnerable-dep-invoked": (
52
+ "A dependency the agent used has a known vulnerability",
53
+ "Check the advisory and update the package if a fix is available.",
54
+ ),
27
55
  }
28
56
 
29
57
 
@@ -15,10 +15,11 @@ from contextlib import ExitStack, contextmanager, suppress
15
15
  from datetime import UTC, datetime
16
16
  from pathlib import Path
17
17
  from types import FrameType
18
- from typing import TextIO, TypeVar
18
+ from typing import Literal, TextIO, TypeVar
19
19
 
20
20
  from stacktrace_cli.build import version_label
21
21
  from stacktrace_cli.correlate.orchestrate import build_compositions
22
+ from stacktrace_cli.detector.run import NO_ANALYZER
22
23
  from stacktrace_cli.private_state import ensure_private_directory
23
24
  from stacktrace_cli.telemetry import emit_error
24
25
  from stacktrace_cli.telemetry.posthog import flush as telemetry_flush
@@ -98,13 +99,18 @@ def follow_notifications(
98
99
  maximum_delay: float = 2.0,
99
100
  ) -> None:
100
101
  reported_disconnect = False
102
+ subscribed_once = False
101
103
  delay = reconnect_delay
102
104
 
103
105
  def _on_subscribed() -> None:
104
- nonlocal reported_disconnect, delay
106
+ nonlocal reported_disconnect, subscribed_once, delay
105
107
  if reported_disconnect:
106
108
  stderr.write("Stacktrace monitor reconnected\n")
107
109
  stderr.flush()
110
+ elif not subscribed_once:
111
+ stderr.write("Stacktrace monitor connected\n")
112
+ stderr.flush()
113
+ subscribed_once = True
108
114
  reported_disconnect = False
109
115
  delay = reconnect_delay
110
116
 
@@ -192,8 +198,12 @@ def _await_initial_compositions(
192
198
 
193
199
 
194
200
  def run_daemon(
195
- paths: RuntimePaths, config: DaemonConfig | None = None, *, own_std_streams: bool = False
196
- ) -> int:
201
+ paths: RuntimePaths,
202
+ config: DaemonConfig | None = None,
203
+ *,
204
+ own_std_streams: bool = False,
205
+ on_ready: Callable[[], None] = lambda: None,
206
+ ) -> int | Literal[StartOutcome.ALREADY_RUNNING]:
197
207
  """The resident process: a socket, a scan cadence, and two ways to stop.
198
208
 
199
209
  `config` is resolved by the caller (`daemon/cli.py`) so a bad value fails
@@ -203,10 +213,10 @@ def run_daemon(
203
213
  with _daemon_lock(paths.lock) as acquired:
204
214
  if not acquired:
205
215
  report_start(StartOutcome.ALREADY_RUNNING)
206
- return 0
216
+ return StartOutcome.ALREADY_RUNNING
207
217
  if daemon_is_running(paths.socket):
208
218
  report_start(StartOutcome.ALREADY_RUNNING)
209
- return 0
219
+ return StartOutcome.ALREADY_RUNNING
210
220
  # Opened only by the lock holder: two processes rotating one file
211
221
  # would each rename the other's writes away (ADR-0048).
212
222
  handler = activity_log.configure(paths.log, own_std_streams=own_std_streams)
@@ -374,18 +384,29 @@ def run_daemon(
374
384
  # already parked on (`WebhookWorker._run`), independent of that
375
385
  # loop, so the drain this promises actually runs at once.
376
386
  server.on_webhook_wake = webhook_worker.wake
377
- jobs = Scheduler(
378
- (
379
- fleet,
380
- webhook,
381
- Job(
382
- name="bom",
383
- interval=settings.bom_interval_seconds,
384
- run=lambda: _log_bom(sync_bom()[0]),
385
- on_error=_log_job_error,
386
- ),
387
- )
387
+ bom = Job(
388
+ name="bom",
389
+ interval=settings.bom_interval_seconds,
390
+ run=lambda: _log_bom(sync_bom()[0]),
391
+ on_error=_log_job_error,
392
+ preserve_stagger=True,
388
393
  )
394
+ # Woken once, unconditionally, right here -- the same reason
395
+ # `fleet`/`webhook` are above: a freshly started daemon should not
396
+ # sit on a machine's inventory for up to `--bom-interval` (default
397
+ # 6h) before its first upload. `preserve_stagger=True` keeps this
398
+ # from costing the thing `stagger()` exists for: without it,
399
+ # `run_if_due` would re-anchor `_due_at` on this wake's `now` and
400
+ # every run after would too, converging every host provisioned
401
+ # from one script onto the same upload instant forever, not just
402
+ # this first one. With it, this run lands early but the *next*
403
+ # due time still comes from the original staggered slot plus the
404
+ # interval, so the fleet-wide spread survives having its first
405
+ # upload moved up. `--bom-interval 0` is unaffected: `bom.enabled`
406
+ # is still `False` (nothing else wakes it, per `Job.wakeable`), so
407
+ # `Scheduler` below drops it before this wake can do anything.
408
+ bom.wake()
409
+ jobs = Scheduler((fleet, webhook, bom))
389
410
 
390
411
  def wake_delivery_jobs() -> None:
391
412
  fleet.wake()
@@ -399,6 +420,8 @@ def run_daemon(
399
420
  collected,
400
421
  since,
401
422
  agent_kinds=kinds,
423
+ reasoning=settings.reasoning_enabled,
424
+ analyzer="jev" if settings.reasoning_enabled else NO_ANALYZER,
402
425
  attach_advisories=first.attach_advisories,
403
426
  build_all=compositions,
404
427
  ),
@@ -425,7 +448,12 @@ def run_daemon(
425
448
  # that raises must not skip `compositions.close()`.
426
449
  webhook_worker.start()
427
450
  try:
428
- service.run(on_ready=lambda: report_start(StartOutcome.STARTED))
451
+
452
+ def ready() -> None:
453
+ report_start(StartOutcome.STARTED)
454
+ on_ready()
455
+
456
+ service.run(on_ready=ready)
429
457
  finally:
430
458
  webhook_worker.close()
431
459
  try: