A PBS datastore reached the unified registry twice, once from the PBS
snapshot adapter under the canonical <instance-id>/<name> source ID and
once from the PBS poller's models.Storage conversion under the legacy
<instance-id>-<name> storage ID. The thresholds page rendered two cards
per datastore and their alert overrides split across both key formats,
with only the legacy key honoured at evaluation time.
Skip the redundant storage-conversion ingest when the canonical
datastore resource is already registered, carry the canonical ID as a
storage alias so evaluation accepts overrides in either format, and add
the legacy dash key to the frontend candidate list so pre-existing
overrides stay bound to the single surviving card and re-home onto the
canonical key on the next save.
Fixes#1591
Contract-Neutral: behavior fix with regression tests across registry/alerts/frontend; contract docs carry another agent's WIP on the shared tree, delta deferred
The PVE backup poll classified storage as PBS-backed by the pbs- name
prefix as well as the storage type. A dir storage merely named pbs-*
(content backup, is_mountpoint) lost all of its vzdump backups from the
Backups view whenever a direct PBS connection was also configured,
because the poll skipped its content as 'PBS is authoritative'. Classify
by storage type only.
Fixes#1592
Contract-Neutral: behavior fix with regression test; monitoring contract docs carry another agent's WIP on the shared tree, delta deferred
The canonical alert evaluation reactivation branch read and mutated
recentlyResolved and resolvedAlias while holding only m.mu, while the
broadcaster and recovery paths guarded them with resolvedMutex, so the
two lock domains did not exclude each other and a flapping resource
re-firing within the five-minute cooldown concurrently with the state
broadcast crashed the daemon with a concurrent map access fault
(issue #1590, reported with exact sites and a race repro).
Both eval sites now take resolvedMutex for the lookup and removal only,
keeping history and dispatch outside the lock. GetResolvedAlert takes
the write lock because getResolvedAlertNoLock backfills resolvedAlias
on a canonical-identity miss, which made even read-locked lookups
racy against each other. The lock ordering documentation now states
the real rule the cleanup path already relied on: resolvedMutex is
subordinate to m.mu and may be acquired while holding it, never the
reverse.
Contract-Neutral: crash bugfix restoring the documented resolved-map lock guarantee (#1590); no contract surface changes
The Patrol finding-notification work (23695681a, e0e454abf) added
patrol_finding_notifications_enabled and
patrol_finding_notify_min_severity to the AI settings payload but did
not update the six JSON snapshot pins, leaving Build and Test red on
every push since. The fields are intended API surface; the snapshots
now include them.
Contract-Neutral: snapshot refresh for the intended Patrol finding-notification fields added in 23695681a/e0e454abf; no behavior change
With no systems connected, the Connected systems page rendered two
buttons both named Add infrastructure, the page-header CTA and the
posture band's setup action. Identical accessible names on one page are
ambiguous for assistive tech, and the strict-mode accessible-name
locators in several recovered Core E2E specs resolved both and failed
on every fresh-backend CI run. The zero-state posture action is now
Add your first system, which also reads more task-first next to its
own detail line.
Contract-Neutral: zero-state CTA label disambiguation; no contract surface changes
Both specs came back from quarantine on 2026-07-17 proven against
chromium, but Core E2E also runs them on mobile-safari, where they have
failed on every run since. The mobile layout collapses the thresholds
platform-scope filter into the FilterBar sheet, so the TrueNAS scope
button the first spec clicks does not exist and the click falls through
to the platform nav tab, and platform tables render as cards, so the
tr-row locators the second spec asserts on match nothing. Skip both on
mobile projects the same way 06-theme-visual does; mobile presentation
coverage lives in 04-mobile.spec.ts.
The desired side of the /api/connections command-policy comparison is
contractually the effective config served to the agent after token scope
and binding checks, but a host whose recorded TokenID no longer resolved
to a live API token skipped sanitization entirely and kept the raw
profile desire. A stale binding, typical after a token was revoked and
the agent reinstalled, then presented as Command policy mismatch even
though the served runtime config could never enable commands and the
operator had nothing to fix (seen on issue #1564). When API tokens
exist and the host's token cannot be resolved, desired command policy
now fails closed to disabled.
The contract delta also records the install-token mint semantics that
landed with the Add Pulse Agent scope fix, closing the deferred
agent-lifecycle delta noted on that commit's contract-neutral trailer.
Contract-Neutral: fail-closed bugfix bringing the connections command-policy view into conformance with the existing agent-lifecycle contract clause; the staged agent-lifecycle delta records the semantics, api-contracts/storage-recovery have no real delta
Completes #1369. The delivery path landed in 23695681a with config-only
gating; this surfaces the two fields so operators can turn finding
notifications off or restrict them to critical findings without editing
config by hand.
The AI settings GET response and update request carry
patrol_finding_notifications_enabled and
patrol_finding_notify_min_severity, with the same warning-or-critical
validation the alert-trigger severity field uses. The Patrol settings
page gains a Notifications card between Triggers and Model readiness,
mirroring the alert-trigger toggle-plus-severity pattern, with the
severity select shown only while notifications are enabled.
Exercised end to end against a scratch backend in mock mode. A severity
change and an explicit opt-out both survive save, reload, and the GET
round trip, and the opt-out hides the severity select immediately.
Patrol findings previously reached operators only through the Relay
mobile push path. Anyone relying on the email, webhook, or Apprise
destinations they already configured for alerts got no proactive signal
when Patrol detected a problem and had to open /patrol to learn about
it. This is the delivery half of #1369.
Each newly stored warning or critical finding now also flows through a
FindingNotifyCallback wired in the router, which projects the finding
into the alert shape the notification manager delivers. The callback
fires only on the genuinely-new path in recordFindingWithInvestigation,
so a finding notifies at most once per lifetime regardless of how many
later runs re-detect it, and SendAlert's own per-ID cooldown backstops
that. Demo mode never notifies.
Gating lives in AIConfig via patrol_finding_notifications_enabled
(default on, matching the long-standing default for mobile push) and
patrol_finding_notify_min_severity (warning or critical, default
warning). The enabled flag persists without omitempty so an explicit
opt-out survives reload while pre-existing configs inherit the default.
The settings surface for these fields follows in a separate commit once
the AI settings handler is free.
Keys linked to Readonly Admin users return 403 on endpoints Pulse
polls through the deprecated REST bridge on TrueNAS SCALE 25.10
(issue #1589); note the Full Admin workaround until the WebSocket API
migration (#1550) lands.
Contract-Neutral: user docs note for the TrueNAS 25.10 role limitation (#1589)
The manual-run safety timer declared 'connection timed out' whenever
the Patrol stream stayed quiet for 15 seconds, but the trigger request
had already succeeded and a self-hosted model cold-loading can take
longer than that to emit its first event, so it reported failure over
runs that were still warming up (issue #1588). The timer now refreshes
Patrol data first and only surfaces a failure when the status shows no
run in progress, with wording that names the provider rather than the
connection.
Contract-Neutral: frontend toast timing bugfix (#1588); no contract surface changes
The Add Pulse Agent flow composed a fixed scope list in the frontend,
so ticking Enable Pulse command execution added --enable-commands to
the install command while the token it shipped with never carried
agent:exec, and the command channel rejected every registration
(issues #1586, #1564, confirmed by a reporter's server log). Even with
the scope, the generic token had no binding metadata, so the first-use
binding gate would have refused it next.
The install token is now minted through POST /api/agent-install-command
with type 'host'. The server decides the scopes from enableCommands at
mint time, stamps the install_type/issued_via metadata that makes the
token eligible for first-use command-channel binding
(canBindProxmoxAgentInstallExecToken renamed canBindAgentInstallExecToken
and extended to the host install type), and returns the sanitized token
record. The frontend regenerates the token when the checkbox toggles,
since scopes cannot be upgraded on an existing token, and revokes the
superseded token so toggling does not accumulate orphans.
Contract-Neutral: install-token mint bugfix (#1586, #1564): checkbox-promised exec scope now real; agent-lifecycle contract delta deferred because the contract docs carry another agent's uncommitted WIP on the shared tree
The rejection told operators to re-run the agent installer, but the
token minted by the original install command cannot gain the exec scope
after the fact and install tokens are single use, so re-running the
same command loops forever (issues #1586, #1564). Name the actual
recovery step instead.
Contract-Neutral: reworded operator-facing rejection copy (#1586, #1564); no behavior or contract change
A pool built from /dev/disk/by-id/nvme-MODEL_SERIAL_1-part1 references
(systemd appends the _1 namespace token) made serialFromByID take the
last underscore token, so it extracted the namespace digit instead of
the serial and the disk fell back to the generic 'ZFS' usage string
while an identically-built node without the suffix resolved its pool
name. Reported with exact zpool output in issue #1540.
Contract-Neutral: bugfix restoring intended disk-to-pool annotation (#1540); no contract surface changes
The server-side disk-to-pool matcher learned nvme-eui.<hex> zpool member
references in 4dcc18fbd, but the host agent keeps its own copy of the
matching for SMART pool annotation and never derived a key from the
nvme-eui form, nor stripped the eui. prefix smartctl reports on NVMe
WWNs. systemd's nvme by-id links can also carry a trailing _<n>
namespace suffix (nvme-MODEL_SERIAL_1), which made the serial
derivation return the namespace digit instead of the serial. Both gaps
left a pool member unmatched so the disk fell back to the generic
usage string (issue #1540).
Contract-Neutral: bugfix restoring intended disk-to-pool annotation (#1540); no contract surface changes
wilddev65's rc.2 retest confirmed the connection-keying fix split the two
same-serial CORE boxes but left four symptoms. All four trace to Pulse
reading fields the TrueNAS API has never served, which the pre-fix
identity flapping had masked behind bigger breakage:
- Datasets all showed Offline: pool.dataset.query returns no "mounted"
field on any TrueNAS version (CORE 13 and SCALE both strip it from the
property allowlist), and the parser read absence as unmounted. A listed
dataset now defaults to mounted unless "locked" (encrypted, key
unloaded) or an explicit mounted=false says otherwise.
- Disks all showed Attention/Unknown: disk.query carries no status or
smart_status field either, so every disk landed on UNKNOWN health,
which the storage table bucketed as Attention. Per-disk ZFS state
(ONLINE/DEGRADED/FAULTED/...) and pool membership are now derived from
the pool.query topology that both platforms attach unconditionally --
disk.query's extra.pools cannot cross the REST bridge, whose query
params turn into filters that silently match nothing. Disks therefore
nest under their pools again, and a genuinely unreadable health renders
as neutral Unknown instead of amber Attention.
- Disk temperatures were blank: disk.temperatures takes parameters, so
REST v2.0 has only ever served it as POST with a body keyed by param
name; the client's GET failed on every version, and CORE (no JSON-RPC
endpoint) has no reporting fallback to rescue it. The ladder now tries
native JSON-RPC reporting first and falls back to the POST form, and
the provider surfaces API-reported disk temps as SMART sensor entries
so the host Thermals card lists disks for API-backed systems.
- The storage table ordered identically-named pools (a DR pair both
named "tank") by map iteration order, flipping between refreshes;
ordering now tie-breaks on system name then id.
Verified end-to-end against two stub TrueNAS CORE 13 REST servers
reporting the same placeholder DMI serial: storage tab nests both
systems' pools/datasets/disks correctly with temperatures and Healthy
states, and the system drawer Thermals card lists per-disk readings.
Completes the first-session elicitation fix that a3f8b18bf started at
the tool layer. The second observed failure mode ("Are there any alerts
I should look at?" on Ollama qwen3:8b) had the model calling
pulse_question BEFORE any tool call, so no tool error copy and no tool
description can reach it — the resolve-before-asking prompt policy
(945ed2198) needs runtime teeth for small local models.
The agentic loop now refuses an interactive pulse_question issued
before the run has attempted any real tool call: the model gets an
error tool result steering it to read-only enumeration
(pulse_summarize {"action":"fleet"} and the alert tools need no
parameters), no question card or clarify event reaches the stream,
sibling tool calls from the same provider turn keep processing instead
of tripping the interactive-set skip path, any queued tool attempt
satisfies the gate for the rest of the run, and it fails open after 2
refusals so an unanswerable prompt cannot livelock. Non-interactive
profiles keep their existing separate block. The system prompt
discloses the gate; question-flow tests now open with a look turn the
way real runs must; a corpus scenario pins the stream promise (a
natural first question produces an answer, never a clarification card).
Live-verified on a real Ollama qwen3:8b scratch instance (real local
agent, not mock): both natural openers stream tool-backed answers with
zero question events. Full ./internal/ai/... suite green. ai-runtime
contract updated in-commit.
A fresh install's natural first question ("how is my machine doing?",
observed live with Ollama qwen3:8b) made the model call pulse_summarize
action=fleet without resource_ids; the tool errored and the agentic loop
surfaced a structured question asking a first-run user for "the
comma-separated list of resource IDs" — pure jargon, funnel-killing
(GitHub discussion #1042). Two root fixes:
pulse_summarize self-targets. action=fleet with resource_ids omitted now
enumerates the known fleet from the executor's unified resource provider
(infrastructure parents first, then guests, then storage; deduped;
bounded at the existing 50-resource cap with a truncation note). Both
modes resolve what models actually pass — canonical unified IDs and
unambiguous names — onto the reporting request shape the way the API
path's resolveReportSubject does: the canonical ID stays ResourceID
(findings/recovery keying) and the resolved metrics target rides
MetricsResourceID, so store queries find data instead of silently
returning zero points. Reporting types are classified from the unified
resource (agent-backed hosts "agent", pure Proxmox nodes "node" — the
documented target-type exception — Docker hosts "docker-host");
resource_type becomes an optional filter/default. Remaining error paths
instruct the model to enumerate or retry and forbid asking the operator
for resource IDs.
Ask-user policy covers tool-argument recovery. The system prompt's
resolve-before-asking section (945e2198's target policy) now extends to
failed tool calls: missing/invalid arguments are self-recovered
(enumerate, retry), and internal identifiers are never valid questions.
The pulse_question description carries the same prohibition so the
structured clarification surface cannot become an identifier elicitation
channel on small local models.
ai-runtime and api-contracts contracts updated in-commit (the
subsystem_lookup line pin follows the api-contracts insertion); full
./internal/ai/... and ./internal/agentcapabilities/... suites green.
Same defect class as 64fb3d198 (pkg/securityutil): the SSRF-pinned webhook
dialer resolved the host and dialed only the first permitted IP, so a host
resolving to ::1 ahead of 127.0.0.1 while the receiver listens on one
loopback family — or a multi-A-record host with a dead leading address —
got a hard connection failure even though curl works. The dialer now tries
each permitted resolved IP in resolution order; every candidate still
passes the private-IP/allowlist validation, so rebinding protection is
unchanged.
Evaluated migrating onto securityutil.NewRestrictedOutboundHTTPClient and
rejected a straight swap: the webhook client's isPrivateIP blocks a wider
range set (CGNAT, benchmarking, TEST-NET), its redirect policy allows
re-validated cross-origin hops where securityutil enforces same-origin,
Proxy is deliberately nil, and the private-IP allowlist is a live per-CIDR
runtime hook — a swap would loosen the SSRF policy and change behavior.
Adds a resolver test seam on NotificationManager and a regression test
pinning the ::1-first fallback; notifications contract records the
multi-IP dial obligation.
Closes chip task_5e91afd9.
Production telemetry showed 1,437 installs on the external-agent surface but
only 2 on the Pulse MCP adapter. Live verification proved the pulse_mcp
telemetry surface attribution is honest (adapter tools/call records
surface=pulse_mcp; header-less calls record agent_api), so the gap is real -
but the funnel comparison is also misleading: agent_api counts any scoped
API-token REST consumer (scripts, dashboards polling /api/config/nodes or
/api/ai/patrol/findings), not BYO AI agents. Recorded that interpretation
constraint on PulseIntelligenceExternalAgentEvidence.
The genuine product gap: the in-app connector setup (Settings -> Pulse
Intelligence -> Assistant -> External agents) was invisible to sidebar search.
Searching 'mcp', 'claude', 'opencode', or 'connector' returned 'No settings
found' because search matched only nav labels and header descriptions.
- Add search-only keywords to SettingsNavItem and match them in
filteredTabGroups; the Assistant item now carries mcp / model context
protocol / external agent(s) / claude / opencode / connector / pulse-mcp.
- Name external agent (MCP) connectors in the Assistant header description
(EN/DE/ES kept in sync), so the page header states what the page contains.
- Pin the search behavior with parameterized useSettingsAccess tests plus a
settingsArchitecture source proof; record the keyword channel in the
frontend-primitives contract (obligation 41) and the product-copy allowance
in the security-privacy i18n extension point.
Two diagnosability gaps found by exercising the docker-update vertical
live with a real agent, each of which turns 'approved action cannot
execute' into a dead end with no visible cause:
- The durable operation-receipt store failing to open (e.g. unwritable
state dir) silently registered the agent with receipt version 0; the
only symptom was a server-side claim that the agent was too old. The
agent now logs the real cause and state dir loudly at startup.
- The server readiness copy asserted 'older agent version' for any
receipt-version mismatch. It now names both causes (old version or
unavailable state directory) and points at the agent logs.
(The related docker collect-cycle watchdog landed separately in
a0f75b1bb with a hung-daemon reproducer.)
Contract-Neutral: agent-side diagnosability: receipt-store failure warning + honest readiness copy; no public contract delta
The 30d window said 18 of 26 approved action attempts failed but
carried zero cause information. Every approved attempt that is not a
verified success now lands in exactly one content-free bucket:
- pre_dispatch: terminally refused before dispatch (plan drift, expiry,
emergency stop, policy authorization)
- execution: dispatched execution failed or ended inconclusive
- unverified: execution succeeded but outcome verification was not
confirmed (reads as success in the UI, counted as failure here)
- stuck_executing: still executing over an hour after dispatch
plus one sanitized machine reason code for the most recent failure.
Successes, failure buckets, and recent in-flight attempts partition the
attempt count, so the next window explains its own gap.
RefuseActionExecution now persists the specific refusal code
(plan_drift, action_plan_expired, ...) as the canonical execution
reason code instead of the generic pre_dispatch_refused, so audit truth
and telemetry distinguish refusal causes without message parsing.
Privacy disclosures updated in both PRIVACY.md copies; the adoption
report script surfaces the new counters.
Contract-Neutral: additive content-free telemetry counters + specific pre-dispatch refusal reason codes; privacy disclosures updated in-commit per security-privacy extension point
Production telemetry (30d) showed 18 of 26 approved governed-action
attempts never reaching verified success. The dominant stranding path:
HandleExecuteAction dispatched under the HTTP request context, so a
reverse proxy or browser dropping the connection mid-operation (docker
image pulls run minutes; proxies cut at 60-100s) cancelled the committed
dispatch and left the action executing forever — recovery only ran at
restart or agent re-registration, which never fires while the agent
stays healthily connected.
- Execute now dispatches under context.WithoutCancel: a committed
dispatch runs to its terminal state regardless of the caller's
connection, bounded by the per-operation transport timeout.
- A standing 2-minute recovery loop re-drives durable-dispatch
reconciliation so any abandoned executing action (timeout, send
failure, crash) converges from the agent's durable receipt instead
of waiting for a reconnect that may never come.
Verified live: docker-update executed with the client aborting at 3s;
the agent completed pull+recreate+verify and the action reached
completed/verified with the replacement container running.
Contract-Neutral: behavioral fix: detach approved-action dispatch from client ctx + periodic recovery loop; no public contract delta
Production telemetry shows 17 weekly-active installs with Relay enabled out
of 6,225 — the weakest adoption of any feature flag. A funnel walk from a
fresh session showed why: the only Relay surface was the last item of the
last Settings section, hidden entirely from unlicensed installs, described
in transport jargon, and never mentioned on the alert Notifications page
where "get alerts on my phone" actually lives.
- Alerts → Notifications: new "Mobile push notifications" destinations
section (AlertPushDestinationsSection) presenting push via Pulse Mobile
alongside email/Apprise/webhooks. Licensed installs get a pointer to the
canonical /settings/system-relay panel; unlicensed installs get the shared
FeatureGateSection with the relay upgrade destination, suppressed when
upgrade prompts are hidden. Page description now names mobile push.
- Settings nav: system-relay no longer sets hideWhenUnavailable — the
Remote Access entry stays visible to free installs and the panel-owned
upgrade gate communicates the paid boundary. relayRead capability hiding
is unchanged.
- Remote Access copy rewritten around user value: push notifications and
remote monitoring through Pulse Mobile with no port forwarding or VPN;
pairing help drops "provisions a dedicated relay access credential"
jargon, names iOS + Android early access, and states that download-page
install links appear after activation-key entry. EN/ES/DE headers updated.
- Contracts updated in-commit: alerts.md (destinations catalog extension
point), frontend-primitives.md (system-relay is not a deep-link-only
flow), cloud-paid.md (value-first relay presentation obligation).
Contract-Neutral: i18n messages edit changes settings-header/alerts-destinations copy only; no security-privacy delta; alerts/cloud-paid/frontend-primitives contracts updated in-commit
Follow-up to the 2026-07-17 live docker-update exercise where a DiskUsage
roundtrip against a colima daemon parked 6+ minutes even though
dockerCallWithRetry wraps every call in a 20s context.WithTimeout.
Investigation result: context deadline propagation through moby client
v0.5.0 (request.go, API-version negotiation) and the otelhttp transport
wrapper is intact. Reproducing with a deliberately hung unix-socket
daemon aborts DiskUsage at the deadline in both hang shapes (pre-header
and mid-body), so there is no client-library bug to fix or file
upstream; the production stall's root cause remains environmental
(deadline timer never fired process-side).
Containment and diagnosis:
- buildReport now runs under dockerCollectCycleTimeout (5m) so a wedged
cycle can never stall the module indefinitely, plus an independent
watchdog timer that logs an error with a full goroutine dump if the
cycle outlives even that deadline - capturing exactly the evidence
that was missing from the original incident.
- hung_daemon_deadline_test.go pins that a context deadline aborts the
real moby client against a hung unix-socket daemon (pre-header and
mid-body stalls), guarding future moby/otelhttp upgrades.
The incident note referenced a dockerCollectCycleTimeout watchdog as
already added; it did not exist on any branch - this commit is that
containment, landed for real.
Contract-Neutral: dockeragent collect-cycle watchdog containment: timeout plumbing only, no collection-semantics or contract-surface delta
The restricted outbound dialer resolved a hostname, validated the results,
then dialed only the first permitted IP. When localhost resolves to ::1
ahead of 127.0.0.1 and the target service binds only 127.0.0.1 (Ollama's
default), the dial failed with 'connect: connection refused' even though
curl and browsers connect fine via address-family fallback. This broke the
AI provider setup quickstart: the blessed http://localhost:11434 Ollama URL
failed Pulse's connection test on IPv6-first hosts (setup-friction class of
#847/#1003).
Try each permitted IP from the validating resolution in order until one
connects. Every candidate is still validated against the outbound policy
from the same resolution, so the DNS-rebinding pinning guarantee is
unchanged. Fixes all consumers of the shared client (Ollama provider, OIDC,
SSO, connection probes, availability poller); api-contracts wording updated
to match. Live-verified against Ollama 0.32.1 bound to 127.0.0.1 only:
TestConnection via http://localhost:11434 failed before, passes after.
fetch_rows_remote loaded every row in the window into a list and
json.dumps'd the whole payload on the remote side; on the 1GB
pulse-license droplet a 30-day window over a 104k-row DB got the
process OOM-killed. The remote helper now prints a db_stats header
line and then one JSON line per row while iterating the cursor, so
it never holds the full result set. Local parsing updated to match
and covered by unit tests; fetch_rows_local is unchanged.
Verified end to end against the droplet with --since-days 30.
Contract-Neutral: fetch_rows_remote OOM fix: stream JSON-lines instead of one giant payload; no contract delta, report output unchanged
Pure formatting (line wraps/collapses) flagged by prettier on the files
touched in 817aaeabc; no behavioral change. Lint (backend + frontend)
fully green; all touched vitest suites re-run green.
Contract-Neutral: prettier line-wrap only on files from 817aaeabc; no behavioral or contract delta
Production telemetry (30d) shows Patrol averaging ~9.4 billed AI calls
per run while only ~1-2 come from the analysis passes. The remainder is
the alert auto-resolve review: one QuickAnalysis call per active alert
(>=10min old) on every run, re-asking the model about the same standing
alert every interval even when the trigger condition demonstrably still
holds.
Two structural changes, both resolution-neutral:
- Still-firing gate: skip the model review for alerts whose current
snapshot still shows the trigger condition (metric at/above threshold,
offline resource still offline). A correct review could only answer
KEEP, so the question is not worth a billed call. The gate never
resolves anything locally - uncertain cases (unknown types, missing
resources, unmapped metrics) still go to the model, which retains
sole authority over resolution.
- Batched review: the remaining candidates are reviewed in one model
call per 20 alerts (numbered verdict lines, unparseable -> KEEP,
bare-RESOLVE fallback for single-alert batches) instead of one call
per alert.
Steady-state runs with standing alerts drop from N review calls to 0;
runs where conditions may have cleared pay ceil(K/20) instead of K.
QuickAnalysisRequest gains a TargetType tag (recorded on the usage
event) so cost telemetry can decompose alert-review spend from the
main patrol pass going forward. Contract updated in-commit
(ai-runtime: model-owned resolve direction, cost-gated keep direction,
batched review, fail-safe parsing, alert_autoresolve usage tagging).
Production telemetry (7d, 2026-07-17) shows a persistent tail of live
installs on 6.0.0-rc.x and 6.0.3/6.0.4 while 6.0.5 has been stable since
2026-07-09. Root cause for the reachable stranding class: release
selection trusted GitHub's created_at ordering, and this repo interleaves
v5-line maintenance releases with v6 releases (v5.1.36 was created the
day before v6.0.5). Whenever a v5.1.x release is the most recently
created stable:
- stable channel (getLatestReleaseForChannel) returned the first
non-prerelease in list order -> "no update" for every v6 install
until the next v6 release ships
- the RSS rate-limit fallback returned the first feed entry matching the
channel, same failure, both channels
- the unattended updater trusted /releases/latest, which GitHub defines
as most-recently-created stable -> timer no-ops in the same window
All three now select the highest eligible version: the API path tracks
newest stable + newest prerelease in one pass (prerelease = GitHub flag
OR tag shape), the feed fallback picks the max matching the channel, and
pulse-auto-update.sh scans /releases pairing each tag with its own
draft/prerelease flags before the fail-closed shape filter, keeping
/releases/latest as fallback. helm-chart-* tags fail semver parsing and
are dropped everywhere.
Verified not broken (pinned by new tests): rc-channel installs are
offered the newer stable (6.0.0-rc.6 -> 6.0.5) and move onto the next rc
line when one opens (-> 6.1.0-rc.2); the auto-update prerelease filter
(fail-closed is_prerelease_tag, metadata-flag refusal) still refuses
prerelease targets on stable. Go tests fail on the pre-fix code
(returned 5.1.37 / "no update").
Contract: deployment-installability now pins version-max release
selection with proofs in manager_stranded_upgrade_test.go and
pulse_auto_update_test.go.
Telemetry shows thousands of installs configure an AI provider but almost
none ever use the interactive Assistant. A live first-session exercise
(fresh install, Ollama qwen3:8b quickstart) found why: after enabling,
nothing changes on screen — the launcher and handoff buttons are gated on
sessionCapabilities.assistantEnabled, which was only read at page load;
the empty transcript was blank; and the blessed Ollama+qwen3:8b path
reported Patrol degraded while telling the user to pull the model they
had just selected.
- Setup-modal success now opens the Assistant drawer, and the AI settings
save paths refresh the assistantEnabled capability in place
(aiChatStore.refreshEnabledFromServer) so entry points appear without a
reload; toasts point at the Assistant instead of back at settings.
- The empty transcript owns a plain-language welcome and three suggested
prompts that dispatch as real turns (ASSISTANT_SUGGESTED_PROMPTS).
- Patrol static readiness: the blessed Ollama Patrol model is Ready;
other Ollama models keep the warning, now naming the selected model.
- pulse_summarize fleet argument errors instruct the model to enumerate
resources itself instead of interrogating the operator (observed live:
'how is my machine doing?' ended in a resource-ID elicitation).
Contracts: ai-runtime and frontend-primitives Current State updated.
Tests: full internal/ai + internal/api suites green; vitest ChatMessages,
AISettings, aiChat store, and settingsArchitecture suites green; flow
verified live end-to-end.
Production telemetry (30d) shows the Pulse Intelligence funnel collapsing at
governed actions: 332 installs enabled chat actions but only 23 created an
action plan. Root cause: both action dials default off, and an install in the
default Watch only mode has no forward path — findings on unlocked installs
show neither an Investigate button (gated on non-monitor modes) nor any nudge
(the Pro handoff only renders when the capability is plan-locked), and the
empty Actions inbox never says why it is empty.
- Patrol findings on unlocked Watch-only installs now offer 'Switch to Ask
first' in the expanded row (same slot as the Pro handoff), driving
handleAutonomyChange('approval') so the Investigate action appears in place.
- The empty Actions Open tab now explains that Watch only never queues fixes
and points at the Patrol mode switch, or names the Pro capability on locked
installs (presentation-policy aware, upgrade link suppressed with
hideUpgrade).
- Contracts updated: patrol-intelligence (expanded-row forward-path rule),
unified-resources (Actions calm-state producer explanation), api-contracts
(canonical autonomy read/save reuse), frontend-primitives (guidance stays in
governed presentation helpers).
Verified live on a scratch instance (locked drawer keeps the Pro handoff with
no nudge; empty inbox renders the locked explanation) plus DOM tests for the
unlocked slot, which needs the Pro runtime to exercise live.
Every mock-mode boot (e2e/CI managed backends, qual runs, demo containers)
was sending startup/heartbeat pings describing the synthetic fixture fleet,
registering as real installs in the license-server telemetry DB and
dominating the VMware/Kubernetes/TrueNAS adoption aggregates (346 of 6,225
weekly-active installs matched the mock signature, incl. 120 of 227 on
6.1.0-rc.1).
- internal/telemetry: route startup and heartbeat sends through sendEvent,
which drops the ping while mock.IsMockEnabled() is true; checked per event
so runtime mock toggles take effect immediately. Disclosed in the package
doc and both PRIVACY.md copies.
- scripts/telemetry_adoption_report.py: exclude historical mock-fleet rows
(kubernetes_pods = 120×N with vmware_hosts = 7×N, the internal/mock
fixture template) by default, report the excluded row/install counts, and
add --include-mock-fleet to audit them. Versions through 6.1.0-rc.2 keep
pinging the mock fleet until upgraded, so report-side filtering stays
necessary.
- security-privacy contract: record the mock-mode suppression boundary and
the report-side mock-fleet exclusion as subsystem invariants.
- Tests: Go sendEvent suppression/sending pair against an httptest endpoint;
Python signature-matcher and summarize_rows exclusion coverage.
make lint (dupl) flagged two clone pairs: shouldRestartAIChat vs
aiSettingsUpdateTouchesPatrolReadiness shared the forty-field provider
config block, and the approved/rejected action decision-ID collectors in
Pulse Intelligence telemetry differed only by lifecycle state and audit
predicate.
Extract aiSettingsUpdateTouchesProviderConfig for the shared field
check and parameterize pulseIntelligenceActionDecisionIDs by state,
label, and audit predicate, keeping the two named wrappers. The
decision-telemetry contract tests now pin the wrapper call sites, which
still fix the lifecycle state and audit predicate per decision kind.
Contract-Neutral: behavior-preserving dedup of internal helpers to clear dupl lint findings; no public-contract delta
The upstream removal of HandleRunCommand's dead legacy body (ec5e44da0)
deleted TestNormalizeRunCommandApprovalTarget with it, but the v6
host-alias standards test still required that test's snippet in
ai_handlers_test.go, so TestV6DirectHostAliasValidatorCoverage failed.
Drop only the retired validator's snippet; the surviving execute and
investigate-alert validator pins keep host-alias rejection covered.
cancel-in-progress meant a busy main never completed a verdict: four
consecutive runs were cancelled by follow-up pushes this afternoon, so
the workflow produced neither green nor red for hours. Adopt the Core
E2E concurrency policy instead and let the in-progress run finish while
queued runs collapse to the newest pending one.
The raw-command endpoint answers 410 Gone unconditionally; the old
implementation below the retirement return was unreachable (govet) and
kept normalizeRunCommandApprovalTarget, normalizeAIExecuteTargetType,
and AIRunCommandRequest alive with no live callers. Remove them and the
tests that only exercised the removed helpers.
Contract-Neutral: remove unreachable dead code below the 410 retirement return in HandleRunCommand; endpoint behavior unchanged
The core.bare=true corruption of the shared repository recurred on
2026-07-17: a script that runs scratch git commands while the pre-commit
environment from a linked worktree (absolute GIT_DIR) is still exported
re-initializes the REAL repository as bare. a0fda6b26 fixed four helper
test files but missed two spots in scripts/release_control/internal:
- verify_commit_slice_test.py's git() helper only popped GIT_INDEX_FILE,
so its scratch 'git init' calls re-init the real repo when GIT_DIR is
inherited. It now scrubs via the shared repo_file_io.strip_local_git_env.
- verify_commit_slice.py's production git_env() kept the inherited hook
env even when unit tests patch REPO_ROOT to a temporary repository,
pointing git plumbing (including index writes) at the wrong repo. It
now scrubs in the test-patched branch only, matching format_staged_go.
Regression teeth:
- verify_commit_slice_test.py gains a canary test that exports the real
hook env shape (absolute GIT_DIR + GIT_INDEX_FILE, no GIT_WORK_TREE —
with GIT_WORK_TREE set the corruption does not reproduce) against a
scratch repo + linked worktree and asserts core.bare stays false.
- repo_file_io_test.py (runs in the pre-commit battery) gains a static
guard failing any release-control *_test.py that runs scratch
'git init' without referencing strip_local_git_env.
- The six hand-rolled 4-var pop loops from a0fda6b26 migrate to the
shared strip_local_git_env helper so the guard enforces one pattern.
Verified: full release-control battery green; every touched test file
also green with GIT_DIR/GIT_INDEX_FILE pointed at a canary repo's linked
worktree, canary config and status intact afterward.