File size: 7,836 Bytes
6aa76ed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
import { useState } from "react";
import { AlertTriangle, X } from "lucide-react";
import type { StatusResponse } from "@/lib/api";
import { useI18n } from "@/i18n";

/**
 * App-wide warning banner for resource trouble (NS-656): memory pressure
 * and disk exhaustion.
 *
 * Triggers, worst-first:
 * 1. Disk critical β€” the HERMES_HOME volume is nearly full. Worst because
 *    the failure mode is silent data loss (SQLite writes failing, sessions
 *    and config not persisting), not just a restart (OOF-2/OOF-107).
 * 2. Memory critical β€” the gateway's heartbeat shows system memory in the
 *    `critical` band right now.
 * 3. Post-mortem β€” the previous gateway life died uncleanly and its last
 *    heartbeat showed near-exhausted memory (`last_boot_suspected_oom`).
 *    This is a heuristic, not proof the OOM killer acted β€” copy says so.
 * 4. Disk elevated / 5. memory elevated β€” early warnings.
 *
 * All of this previously died in server-side log files; a hosted agent
 * could be OOM-killed hourly or fill its disk completely while the
 * dashboard looked healthy.
 *
 * Dismissal semantics (session-scoped, sessionStorage):
 * - EVERY dismissal key embeds the reporting boot (`boot_id`), so a gateway
 *   restart invalidates all of them. Without this, dismissing `critical`,
 *   rebooting, and coming back still-critical would hide the NEW incident β€”
 *   and mask the OOM notice too, since critical takes precedence. Disk
 *   entries share the scheme: disk state has no boot relationship, but
 *   re-surfacing a still-full disk after a restart is the desired behavior.
 * - Within one boot, dismissal masks only the dismissed trigger; escalation
 *   (elevated β†’ critical, in either domain) re-opens immediately, and a
 *   confirmed recovery (pressure back to "ok", not "unknown") clears that
 *   domain's live dismissals so the NEXT episode in the same boot surfaces
 *   again.
 */

const STORAGE_KEY = "memoryBannerDismissed";
const MEMORY_LIVE_TRIGGERS = ["critical", "elevated"];
const DISK_LIVE_TRIGGERS = ["disk_critical", "disk_elevated"];

function readDismissed(): string[] {
  try {
    const parsed: unknown = JSON.parse(
      sessionStorage.getItem(STORAGE_KEY) ?? "[]",
    );
    // Pre-incident-key builds stored a bare trigger string; JSON.parse
    // throws on those, landing in the catch β€” a clean reset, not a crash.
    return Array.isArray(parsed)
      ? parsed.filter((entry): entry is string => typeof entry === "string")
      : [];
  } catch {
    return [];
  }
}

function writeDismissed(entries: string[]) {
  try {
    sessionStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
  } catch {
    /* ignore */
  }
}

function entryMatches(triggers: string[]) {
  return (entry: string) =>
    triggers.some((sev) => entry === sev || entry.startsWith(`${sev}:`));
}

export function MemoryPressureBanner({
  status,
}: {
  status: StatusResponse | null;
}) {
  const { t } = useI18n();
  const memory = status?.memory;
  const disk = status?.disk;
  const pressure = memory?.pressure;
  const diskPressure = disk?.pressure;

  const [dismissed, setDismissed] = useState<string[]>(readDismissed);

  // Recovery reset (render-time state adjustment β€” the sanctioned React
  // pattern for reacting to prop changes without an effect): once a
  // domain's live pressure is demonstrably back to "ok", any dismissed
  // live entries for that domain describe a PAST episode β€” drop them so
  // the next one isn't silently hidden. "unknown" (stale/absent sample)
  // is absence of evidence, not recovery, and clears nothing. Each domain
  // recovers independently: a fixed disk must not un-dismiss a memory
  // warning or vice versa. Cross-boot invalidation doesn't need handling
  // here: boot_id is part of every dismissal key.
  const [prevPressure, setPrevPressure] = useState(pressure);
  const [prevDiskPressure, setPrevDiskPressure] = useState(diskPressure);
  if (pressure !== prevPressure || diskPressure !== prevDiskPressure) {
    setPrevPressure(pressure);
    setPrevDiskPressure(diskPressure);
    const recovered: Array<(entry: string) => boolean> = [];
    if (pressure === "ok") recovered.push(entryMatches(MEMORY_LIVE_TRIGGERS));
    if (diskPressure === "ok") recovered.push(entryMatches(DISK_LIVE_TRIGGERS));
    if (recovered.length > 0) {
      const isRecovered = (entry: string) =>
        recovered.some((match) => match(entry));
      if (dismissed.some(isRecovered)) {
        const next = dismissed.filter((entry) => !isRecovered(entry));
        writeDismissed(next);
        setDismissed(next);
      }
    }
  }

  // Active triggers, worst-first. Disk critical outranks memory critical:
  // imminent data loss beats imminent restart. Dismissal cascades β€” hiding
  // the top trigger surfaces the next one rather than silencing everything.
  const activeTriggers: string[] = [];
  if (diskPressure === "critical") activeTriggers.push("disk_critical");
  if (memory?.pressure === "critical") activeTriggers.push("critical");
  if (memory?.last_boot_suspected_oom) activeTriggers.push("oom_restart");
  if (diskPressure === "elevated") activeTriggers.push("disk_elevated");
  if (memory?.pressure === "elevated") activeTriggers.push("elevated");

  // Every dismissal is scoped to the reporting boot: `boot_id` changes on
  // each gateway life, so restarts invalidate prior dismissals of ANY kind.
  // A missing boot_id (degraded payload / pre-NS-656 image) degrades to a
  // shared per-severity bucket β€” old behavior, never a crash.
  const keyFor = (trig: string) => `${trig}:${memory?.boot_id ?? "unknown"}`;
  const trigger =
    activeTriggers.find((trig) => !dismissed.includes(keyFor(trig))) ?? null;
  const dismissKey = trigger ? keyFor(trigger) : null;

  if (!trigger || !dismissKey) return null;

  const dismiss = () => {
    setDismissed((prev) => {
      const next = prev.includes(dismissKey) ? prev : [...prev, dismissKey];
      writeDismissed(next);
      return next;
    });
  };

  const critical = trigger === "critical" || trigger === "disk_critical";
  const diskFreeLabel =
    disk?.free_mb != null ? ` (${Math.round(disk.free_mb)} MB free)` : "";
  const message =
    trigger === "disk_critical"
      ? `${
          t.app.diskCriticalBanner ??
          "Your agent's disk is almost full. New messages, memories, and settings may fail to save."
        }${diskFreeLabel}`
      : trigger === "disk_elevated"
        ? `${
            t.app.diskElevatedBanner ??
            "Your agent's disk is filling up. Consider clearing old sessions or expanding its storage."
          }${diskFreeLabel}`
        : trigger === "oom_restart"
          ? (t.app.memoryOomRestartBanner ??
            "Your agent restarted unexpectedly, most likely because it ran out of memory. Long sessions and many concurrent tasks increase memory use.")
          : critical
            ? (t.app.memoryCriticalBanner ??
              "Your agent is almost out of memory and may restart. Consider closing idle sessions or upgrading its memory.")
            : (t.app.memoryElevatedBanner ??
              "Your agent is running low on memory.");

  return (
    <div
      role="alert"
      data-testid="memory-pressure-banner"
      className={`flex items-center gap-2 border-b px-4 py-1.5 text-xs ${
        critical
          ? "border-red-500/40 bg-red-500/10 text-red-300"
          : "border-amber-500/40 bg-amber-500/10 text-amber-300"
      }`}
    >
      <AlertTriangle className="h-3.5 w-3.5 shrink-0" />
      <span className="min-w-0 flex-1">{message}</span>
      <button
        type="button"
        aria-label={t.app.dismiss ?? "Dismiss"}
        onClick={dismiss}
        className="shrink-0 opacity-70 hover:opacity-100"
      >
        <X className="h-3.5 w-3.5" />
      </button>
    </div>
  );
}