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.
- stacktrace_cli/__init__.py +1 -1
- stacktrace_cli/analysis.py +98 -34
- stacktrace_cli/cloud/__init__.py +10 -0
- stacktrace_cli/cloud/setup.sh +51 -11
- stacktrace_cli/correlate/observed.py +62 -6
- stacktrace_cli/daemon/cli.py +113 -4
- stacktrace_cli/daemon/config.py +85 -5
- stacktrace_cli/daemon/jobs.py +48 -1
- stacktrace_cli/daemon/observer.py +31 -2
- stacktrace_cli/daemon/presentation.py +28 -0
- stacktrace_cli/daemon/runtime.py +46 -18
- stacktrace_cli/detector/deterministic.py +1859 -20
- stacktrace_cli/detector/render.py +1 -4
- stacktrace_cli/detector/rules.py +27 -4
- stacktrace_cli/detector/run.py +9 -1
- stacktrace_cli/monitor/reasoning.py +8 -6
- stacktrace_cli/monitor/server.py +4 -16
- stacktrace_cli/monitor/site/app.js +18 -291
- stacktrace_cli/monitor/site/index.html +0 -12
- stacktrace_cli/monitor/site/styles.css +0 -35
- stacktrace_cli/monitor/watch.py +176 -66
- stacktrace_cli/remote/identity.py +41 -12
- {stacktrace_cli-0.5.2.dist-info → stacktrace_cli-0.6.0.dist-info}/METADATA +3 -3
- {stacktrace_cli-0.5.2.dist-info → stacktrace_cli-0.6.0.dist-info}/RECORD +26 -26
- {stacktrace_cli-0.5.2.dist-info → stacktrace_cli-0.6.0.dist-info}/WHEEL +0 -0
- {stacktrace_cli-0.5.2.dist-info → stacktrace_cli-0.6.0.dist-info}/entry_points.txt +0 -0
stacktrace_cli/daemon/config.py
CHANGED
|
@@ -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.
|
|
129
|
-
to the default, because a status surface that raises on a
|
|
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
|
-
|
|
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
|
|
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")
|
stacktrace_cli/daemon/jobs.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
|
stacktrace_cli/daemon/runtime.py
CHANGED
|
@@ -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,
|
|
196
|
-
|
|
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
|
|
216
|
+
return StartOutcome.ALREADY_RUNNING
|
|
207
217
|
if daemon_is_running(paths.socket):
|
|
208
218
|
report_start(StartOutcome.ALREADY_RUNNING)
|
|
209
|
-
return
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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:
|