stacktrace-cli 0.5.3__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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  import logging as _logging
4
4
 
5
- __version__ = "0.5.3"
5
+ __version__ = "0.6.0"
6
6
 
7
7
  # Only the daemon attaches a handler (ADR-0048). Without this one, a WARNING
8
8
  # logged in any other process would reach Python's last-resort handler and
@@ -348,32 +348,6 @@ def _acquire(
348
348
  )
349
349
 
350
350
 
351
- def _correlated(
352
- *,
353
- agent_kinds: tuple[str, ...],
354
- window_start: datetime,
355
- bom_paths: tuple[Path, ...],
356
- project_map: tuple[str, ...],
357
- root: Path | None,
358
- session_ids: tuple[str, ...],
359
- attach_advisories: Any,
360
- build_all: Any = None,
361
- ) -> Acquired:
362
- """Everything up to the judging, shared by both entry points."""
363
- return _acquire(
364
- _collected(
365
- agent_kinds=agent_kinds,
366
- window_start=window_start,
367
- root=root,
368
- session_ids=session_ids,
369
- ),
370
- bom_paths=bom_paths,
371
- project_map=project_map,
372
- attach_advisories=attach_advisories,
373
- build_all=build_all,
374
- )
375
-
376
-
377
351
  def _reaches(detection: Any, window_start: datetime, window_end: datetime) -> bool:
378
352
  """Whether this finding's own span overlaps the window.
379
353
 
@@ -482,6 +456,7 @@ def analyse_progressively(
482
456
  history: DriftHistory | None = None,
483
457
  reasoning: bool = False,
484
458
  budget: int = DEFAULT_BUDGET,
459
+ exclude_from_reasoning: frozenset[tuple[str, str]] = frozenset(),
485
460
  ) -> Iterator[Analysis]:
486
461
  """The same pipeline, delivered in instalments.
487
462
 
@@ -634,12 +609,20 @@ def analyse_progressively(
634
609
  if not reasoning or not ordered:
635
610
  return
636
611
 
637
- # One more instalment, one run, one budget. Everything above has already
638
- # been published, so the page is complete and readable while this is in
639
- # flight — on a large window it is several hundred sequential requests and
640
- # holding the first paint behind it is what made the page look hung.
612
+ excluded_count = 0
613
+ if exclude_from_reasoning:
614
+ kept = tuple(
615
+ s
616
+ for s in view.sessions
617
+ if (s.session.session_id, s.session.agent_kind) not in exclude_from_reasoning
618
+ )
619
+ excluded_count = len(view.sessions) - len(kept)
620
+ reasoning_view = replace(view, sessions=kept) if excluded_count else view
621
+ else:
622
+ reasoning_view = view
623
+
641
624
  judged = run_detector(
642
- view,
625
+ reasoning_view,
643
626
  reasoning=True,
644
627
  budget=budget,
645
628
  cache=cache,
@@ -647,4 +630,23 @@ def analyse_progressively(
647
630
  jev_key=jev_key,
648
631
  history=history,
649
632
  )
633
+
634
+ if excluded_count:
635
+ extra_detections = tuple(
636
+ d
637
+ for d in accumulated.detections
638
+ if (d.session.session_id, d.session.agent_kind) in exclude_from_reasoning
639
+ )
640
+ extra_unknowns = tuple(
641
+ u
642
+ for u in accumulated.unknowns
643
+ if (u.session.session_id, u.session.agent_kind) in exclude_from_reasoning
644
+ )
645
+ judged = replace(
646
+ judged,
647
+ detections=judged.detections + extra_detections,
648
+ unknowns=judged.unknowns + extra_unknowns,
649
+ sessions=judged.sessions + excluded_count,
650
+ )
651
+
650
652
  yield _stage(judged, tuple(ordered))
@@ -12,6 +12,16 @@ CLI and runs this:
12
12
  uv tool install stacktrace-cli
13
13
  stacktrace-cloud-setup
14
14
 
15
+ Or, equivalently, one hosted line that wraps exactly those two steps (see
16
+ `stacktrace-site`'s `public/remote-install.sh`). Download before running rather
17
+ than piping directly into `sh`: without `pipefail` (the default in the `sh`
18
+ that a setup-script box runs), a failed `curl` still lets `sh` read an empty
19
+ script and exit 0, so a broken download would otherwise snapshot an
20
+ environment with neither the CLI nor the boot hook and report success:
21
+
22
+ curl -fsSL https://stacktrace.ai/remote-install.sh -o /tmp/remote-install.sh \
23
+ && sh /tmp/remote-install.sh
24
+
15
25
  Both variables matter: setup runs as root, and `uv tool install`'s root
16
26
  defaults put the tool's environment under `/root/.local/share/uv/tools` and the
17
27
  linked executable under `/root/.local/bin` — both inside `/root`, which a later
@@ -10,7 +10,10 @@
10
10
  # every home so it fires whichever user the session runs as.
11
11
  # 2. The asset identity, in every home's ~/.config/stacktrace/asset-id, so
12
12
  # `stacktrace remote sync` resolves it at the persisted rung (ADR-0065)
13
- # instead of minting a fresh asset per session.
13
+ # instead of minting a fresh asset per session. Beside it,
14
+ # asset-display-prefix makes the fleet label `cloud (<id>)` rather than
15
+ # the image's hostname (`vm`), which is the same on every box. Label
16
+ # only: the key above is what identifies the asset.
14
17
  #
15
18
  # The telemetry install id is deliberately NOT seeded here. Seeding it would
16
19
  # make `read_install_id()` non-None before any event runs, so the one-time
@@ -59,7 +62,7 @@ while [ $# -gt 0 ]; do
59
62
  # the loop spins forever — a malformed invocation would hang setup.
60
63
  --asset-id) [ $# -ge 2 ] || { echo "✗ --asset-id needs a value" >&2; exit 2; }; ASSET_ID="$2"; shift 2 ;;
61
64
  --root) [ $# -ge 2 ] || { echo "✗ --root needs a value" >&2; exit 2; }; ROOT="$2"; shift 2 ;;
62
- -h|--help) sed -n '2,49p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
65
+ -h|--help) sed -n '2,52p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
63
66
  *) echo "✗ unknown argument: $1" >&2; exit 2 ;;
64
67
  esac
65
68
  done
@@ -78,6 +81,7 @@ _uuid() {
78
81
  fi
79
82
  }
80
83
 
84
+ DISPLAY_PREFIX="cloud"
81
85
  [ -n "$ASSET_ID" ] || ASSET_ID="cloud-$(_uuid)"
82
86
  # remote/identity.py:_is_identifier rejects whitespace, non-printable
83
87
  # characters, and anything over 255 characters. A value this accepts but the
@@ -146,8 +150,12 @@ fi
146
150
 
147
151
  # Streams redirected because own_std_streams only re-points fd 1/2 on log
148
152
  # rollover; without this the daemon inherits the hook's pipe to Claude Code.
153
+ # --no-reasoning is explicit, not just `run`'s own default (ADR-0081): this
154
+ # hook never provisions TYPESAFE_API_KEY itself, but a cloud VM's ambient
155
+ # environment is not something this script controls or can audit ahead of
156
+ # time, and this surface has nobody present to notice if that changed.
149
157
  if ! stacktrace daemon status 2>/dev/null | grep -q '^running: yes'; then
150
- setsid nohup stacktrace daemon run </dev/null >>"$STATE/daemon.log" 2>&1 &
158
+ setsid nohup stacktrace daemon run --no-reasoning </dev/null >>"$STATE/daemon.log" 2>&1 &
151
159
  fi
152
160
  exit 0
153
161
  BOOTSCRIPT
@@ -176,6 +184,11 @@ for home in "${ROOT}"/root "${ROOT}"/home/*; do
176
184
  fi
177
185
  chmod 600 "$cfg/asset-id" 2>/dev/null || true
178
186
 
187
+ if ! printf '%s\n' "$DISPLAY_PREFIX" > "$cfg/asset-display-prefix" 2>/dev/null; then
188
+ echo "✗ cannot write $cfg/asset-display-prefix — not seeding $home" >&2; failed=1; continue
189
+ fi
190
+ chmod 600 "$cfg/asset-display-prefix" 2>/dev/null || true
191
+
179
192
  # Register the SessionStart hook, merging so any existing settings survive.
180
193
  # The hook is the point of seeding a home — a home whose registration fails
181
194
  # (an unwritable path, or a settings.json that is valid JSON of an unexpected
@@ -248,7 +261,7 @@ PY
248
261
  for d in "$home/.config" "$cfg" "$home/.claude"; do
249
262
  [ -e "$d" ] && chown --reference="$home" "$d" 2>/dev/null || true
250
263
  done
251
- chown --reference="$home" "$cfg/asset-id" \
264
+ chown --reference="$home" "$cfg/asset-id" "$cfg/asset-display-prefix" \
252
265
  "$home/.claude/settings.json" 2>/dev/null || true
253
266
 
254
267
  echo "✓ seeded $home"
@@ -1381,9 +1381,13 @@ _SEPARATORS = ("&&", "||", ";", "|", "\n")
1381
1381
  _WORD_START_BEFORE_COMMENT = frozenset(" \t\n;&|<>()")
1382
1382
 
1383
1383
 
1384
- def _split_on_separators(command: str) -> list[str]:
1384
+ def _split_on_separators(command: str) -> list[tuple[str, str | None]]:
1385
1385
  """The commands in a shell line, split only where the shell would split it.
1386
1386
 
1387
+ Returns ``(segment_text, preceding_operator)`` pairs. The first segment's
1388
+ operator is ``None``; every later segment carries the ``&&``, ``||``, ``;``,
1389
+ ``|`` or ``\\n`` that separated it from the one before it.
1390
+
1387
1391
  A control operator is an operator **only unquoted and unescaped** — that is
1388
1392
  the shell's own grammar, and it is verified against `bash` itself in
1389
1393
  `tests/test_shell_separators_match_the_shell.py` rather than asserted here.
@@ -1420,10 +1424,11 @@ def _split_on_separators(command: str) -> list[str]:
1420
1424
  left in, `shlex` hands the caller a bare newline where the next word should
1421
1425
  be, and `git \\<newline> push` loses its subcommand to it.
1422
1426
  """
1423
- parts: list[str] = []
1427
+ parts: list[tuple[str, str | None]] = []
1424
1428
  current: list[str] = []
1425
1429
  index = 0
1426
1430
  quote: str | None = None
1431
+ preceding_op: str | None = None
1427
1432
  #: Whether a `#` here would open a comment. True at the start of the string
1428
1433
  #: and after anything that ends a word; false after any character a word is
1429
1434
  #: made of, quotes included -- `echo "a"#b` prints `a#b`.
@@ -1475,11 +1480,20 @@ def _split_on_separators(command: str) -> list[str]:
1475
1480
  index += 1
1476
1481
  at_word_start = char in _WORD_START_BEFORE_COMMENT
1477
1482
  continue
1478
- parts.append("".join(current))
1483
+ segment_text = "".join(current)
1484
+ if segment_text.strip():
1485
+ parts.append((segment_text, preceding_op))
1486
+ preceding_op = separator
1487
+ else:
1488
+ # Two separators with nothing between them (e.g. "&&\n"):
1489
+ # keep the conditional operator over a plain terminator so
1490
+ # the next real segment inherits the right dependency.
1491
+ if separator in ("&&", "||") or preceding_op is None:
1492
+ preceding_op = separator
1479
1493
  current = []
1480
1494
  index += len(separator)
1481
1495
  at_word_start = True
1482
- parts.append("".join(current))
1496
+ parts.append(("".join(current), preceding_op))
1483
1497
  return parts
1484
1498
 
1485
1499
 
@@ -1521,7 +1535,7 @@ def _segments_of(text: str, depth: int) -> tuple[tuple[str, tuple[str, ...]], ..
1521
1535
  `command_segments` would run the masks a second time on their own output.
1522
1536
  """
1523
1537
  found: list[tuple[str, tuple[str, ...]]] = []
1524
- for part in _split_on_separators(text):
1538
+ for part, _op in _split_on_separators(text):
1525
1539
  # Comments are gone: `_split_on_separators` discards one at the word
1526
1540
  # start that opens it, which is the only place the shell begins one.
1527
1541
  # This used to skip a segment *beginning* with `#`, which caught the
@@ -1567,6 +1581,48 @@ def _segments_of(text: str, depth: int) -> tuple[tuple[str, tuple[str, ...]], ..
1567
1581
  return tuple(found)
1568
1582
 
1569
1583
 
1584
+ def command_segments_with_ops(
1585
+ command: object, depth: int = 0
1586
+ ) -> tuple[tuple[str, tuple[str, ...], str | None], ...]:
1587
+ """Like `command_segments`, but each entry also carries the shell operator
1588
+ that preceded it (``None`` for the first segment, then ``&&``, ``||``,
1589
+ ``;``, ``|`` or ``\\n``).
1590
+
1591
+ Only `_resolve_rm_targets` in the deterministic detector needs the operator
1592
+ context — to decide whether an inline ``cd`` reliably changed the directory
1593
+ and whether an ``rm`` segment is unconditionally reachable. Every other
1594
+ caller uses the plain `command_segments`, which discards the operator.
1595
+ """
1596
+ if not isinstance(command, str) or not command:
1597
+ return ()
1598
+ text = _readable_commands(command, depth)
1599
+ found: list[tuple[str, tuple[str, ...], str | None]] = []
1600
+ for part, preceding_op in _split_on_separators(text):
1601
+ part = part.strip()
1602
+ if not part:
1603
+ continue
1604
+ try:
1605
+ tokens = shlex.split(part)
1606
+ except ValueError:
1607
+ continue
1608
+ while tokens and (_ASSIGNMENT.match(tokens[0]) or tokens[0] in _NOT_A_BINARY):
1609
+ if tokens.pop(0) == "time":
1610
+ while tokens and tokens[0] in _TIME_OPTIONS:
1611
+ tokens.pop(0)
1612
+ if not tokens:
1613
+ continue
1614
+ head = tokens[0].rsplit("/", 1)[-1]
1615
+ if not head or all(char in _OPERATOR_CHARS for char in head):
1616
+ continue
1617
+ arguments = tuple(tokens[1:])
1618
+ found.append((head, arguments, preceding_op))
1619
+ if head in _SHELLS and depth < _MAX_SHELL_DEPTH:
1620
+ body = _shell_command_body(arguments)
1621
+ if body is not None:
1622
+ found.extend(command_segments_with_ops(body, depth + 1))
1623
+ return tuple(found)
1624
+
1625
+
1570
1626
  def redirect_targets(command: object) -> tuple[str, ...]:
1571
1627
  """Files a shell command redirects its output into.
1572
1628
 
@@ -1604,7 +1660,7 @@ def redirect_targets(command: object) -> tuple[str, ...]:
1604
1660
  # body, an arithmetic comparison, a `case` frame or the body of a function
1605
1661
  # nothing called is not a redirection, and each was named as a file before
1606
1662
  # its mask existed.
1607
- for part in _split_on_separators(_readable_commands(command, 0)):
1663
+ for part, _op in _split_on_separators(_readable_commands(command, 0)):
1608
1664
  part = part.strip()
1609
1665
  if not part:
1610
1666
  continue
@@ -14,7 +14,7 @@ from typing import TypeVar
14
14
 
15
15
  import click
16
16
 
17
- from .config import DaemonConfig
17
+ from .config import DaemonConfig, parse_reasoning
18
18
  from .paths import RuntimePaths
19
19
  from .presentation import render_finding
20
20
  from .store import FindingStore, SessionKey
@@ -117,6 +117,24 @@ def _retry_while_starting(
117
117
  help="How often to upload endpoint composition. 0 disables it."
118
118
  " Defaults to STACKTRACE_BOM_INTERVAL, then 6h.",
119
119
  )
120
+ @click.option(
121
+ "--reasoning/--no-reasoning",
122
+ # Declared `False`, not `None`: `test_the_reasoning_stage_is_off_by_
123
+ # default_everywhere_it_can_be_asked_for` reads this literal value off
124
+ # every command in the tree, on the theory that a default legible only
125
+ # after tracing what `DaemonConfig.resolve` does with `None` is exactly
126
+ # the kind of default that drifts unnoticed (ADR-0029). Whether the flag
127
+ # was actually typed is recovered below via `get_parameter_source`, the
128
+ # same "flag beats environment beats default" ladder every other option
129
+ # here gets from `resolve` seeing `None` -- Click just won't hand this one
130
+ # a `None` to pass through.
131
+ default=False,
132
+ hidden=True,
133
+ help="Analyse changed sessions with a reasoning model (stage three). Off"
134
+ " by default: pass --reasoning to opt in, and set TYPESAFE_API_KEY so"
135
+ " there is something to answer with. Defaults to STACKTRACE_REASONING,"
136
+ " then false.",
137
+ )
120
138
  def run_command(
121
139
  scan_interval: str | None,
122
140
  scan_since: str | None,
@@ -124,10 +142,17 @@ def run_command(
124
142
  fleet_interval: str | None,
125
143
  webhook_interval: str | None,
126
144
  bom_interval: str | None,
145
+ reasoning: bool,
127
146
  ) -> None:
128
147
  """Run the daemon in the foreground for a host supervisor."""
129
148
  _, _, run_daemon = _runtime()
130
149
  paths = RuntimePaths.from_environment()
150
+ ctx = click.get_current_context()
151
+ reasoning_flag = (
152
+ reasoning
153
+ if ctx.get_parameter_source("reasoning") is click.core.ParameterSource.COMMANDLINE
154
+ else None
155
+ )
131
156
  try:
132
157
  config = DaemonConfig.resolve(
133
158
  scan_interval=scan_interval,
@@ -136,6 +161,7 @@ def run_command(
136
161
  fleet_interval=fleet_interval,
137
162
  webhook_interval=webhook_interval,
138
163
  bom_interval=bom_interval,
164
+ reasoning=reasoning_flag,
139
165
  )
140
166
  _reject_unobservable_kinds(config)
141
167
  except ValueError as error:
@@ -331,12 +357,77 @@ def _database_line(paths: RuntimePaths) -> str:
331
357
  return f"{store.database_size()} bytes ({paths.state})"
332
358
 
333
359
 
360
+ _JEV_KEY_ENV = "TYPESAFE_API_KEY"
361
+
362
+
363
+ def _ensure_jev_key() -> None:
364
+ """Prompt for the jev key when it is not already in the environment.
365
+
366
+ Sets the key in ``os.environ`` so the forked daemon inherits it.
367
+ An empty response or a non-interactive terminal skips — reasoning
368
+ degrades gracefully when the analyzer reports unavailable.
369
+ """
370
+ if os.environ.get(_JEV_KEY_ENV):
371
+ return
372
+ if not sys.stdin.isatty():
373
+ return
374
+ key = click.prompt(
375
+ "TYPESAFE_API_KEY for reasoning (Enter to skip)",
376
+ default="",
377
+ hide_input=True,
378
+ show_default=False,
379
+ )
380
+ if key:
381
+ os.environ[_JEV_KEY_ENV] = key
382
+
383
+
334
384
  @daemon.command()
335
- def start() -> None:
385
+ @click.option(
386
+ "--reasoning/--no-reasoning",
387
+ default=False,
388
+ hidden=True,
389
+ help="Analyse changed sessions with a reasoning model (stage three). Off"
390
+ " by default: pass --reasoning to opt in for this run, and set"
391
+ " TYPESAFE_API_KEY so there is something to answer with.",
392
+ )
393
+ def start(reasoning: bool) -> None:
336
394
  """Start one detached daemon when no host service manager is available."""
337
395
  _runtime()
338
396
  from .lifecycle import StartOutcome, start_daemon, transition_lock
339
397
 
398
+ # `start_daemon` forks/execs inheriting `os.environ` as it stands right
399
+ # here -- the same route `_ensure_jev_key` uses to hand the detached
400
+ # process a key it just prompted for, and now the only route
401
+ # `--reasoning`/`--no-reasoning` has to reach that process's own
402
+ # `DaemonConfig.resolve()` too, which reads `STACKTRACE_REASONING`. When
403
+ # the flag was not actually typed, the same "flag beats environment beats
404
+ # default" ladder `run_command` gets from `resolve()` applies here too:
405
+ # an operator who already exported `STACKTRACE_REASONING=true` before
406
+ # calling `start` (the documented way to opt in a detached daemon,
407
+ # config.py's own comment on `SETTINGS`) must not have that silently
408
+ # overwritten by this command's unrelated `False` click default.
409
+ ctx = click.get_current_context()
410
+ explicit = ctx.get_parameter_source("reasoning") is click.core.ParameterSource.COMMANDLINE
411
+ if explicit:
412
+ enabled = reasoning
413
+ else:
414
+ inherited = os.environ.get("STACKTRACE_REASONING")
415
+ try:
416
+ enabled = inherited is not None and parse_reasoning(inherited)
417
+ except ValueError as error:
418
+ raise click.ClickException(str(error)) from error
419
+ if enabled:
420
+ _ensure_jev_key()
421
+ os.environ["STACKTRACE_REASONING"] = "true"
422
+ else:
423
+ # An explicit --no-reasoning, or no opt-in at all: either way, do not
424
+ # let a key already present in the *caller's* environment (a shell
425
+ # profile, an exported var) reach the daemon and reason anyway --
426
+ # that is exactly the implicit activation this opt-in exists to
427
+ # prevent.
428
+ os.environ.pop(_JEV_KEY_ENV, None)
429
+ os.environ["STACKTRACE_REASONING"] = "false"
430
+
340
431
  paths = RuntimePaths.from_environment()
341
432
  click.echo("Starting Stacktrace daemon...", err=True)
342
433
  try:
@@ -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")