bosun 0.34.7 → 0.34.8

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.
@@ -63,6 +63,88 @@ const activeSessions = new Map();
63
63
  // Alert cooldowns: "alert_type:attempt_id" -> timestamp
64
64
  const alertCooldowns = new Map();
65
65
  const ALERT_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes between same alert
66
+ const FAILED_SESSION_ALERT_MIN_COOLDOWN_MS = 60 * 60 * 1000; // Keep noisy failed-session summaries coarse-grained
67
+ const ALERT_COOLDOWN_RETENTION_MS = Math.max(
68
+ FAILED_SESSION_ALERT_MIN_COOLDOWN_MS * 3,
69
+ 3 * 60 * 60 * 1000,
70
+ ); // keep cooldown history bounded
71
+ const ALERT_COOLDOWN_REPLAY_MAX_BYTES = Math.max(
72
+ 256 * 1024,
73
+ Number(process.env.AGENT_ALERT_COOLDOWN_REPLAY_MAX_BYTES || 2 * 1024 * 1024) || 2 * 1024 * 1024,
74
+ );
75
+
76
+ function getAlertCooldownMs(alert) {
77
+ const type = String(alert?.type || "").trim().toLowerCase();
78
+ if (type === "failed_session_high_errors") {
79
+ return Math.max(ALERT_COOLDOWN_MS, FAILED_SESSION_ALERT_MIN_COOLDOWN_MS);
80
+ }
81
+ return Math.max(0, ALERT_COOLDOWN_MS);
82
+ }
83
+
84
+ function extractTaskToken(value) {
85
+ const normalized = String(value || "").trim();
86
+ if (!normalized) return "";
87
+ const prefixMatch = normalized.match(
88
+ /^([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})(?:-|$)/i,
89
+ );
90
+ return prefixMatch?.[1] || normalized;
91
+ }
92
+
93
+ function deriveAlertScopeId(alert) {
94
+ const taskId = extractTaskToken(alert?.task_id);
95
+ if (taskId) return taskId;
96
+ return extractTaskToken(alert?.attempt_id);
97
+ }
98
+
99
+ function buildAlertCooldownKey(alert) {
100
+ const type = String(alert?.type || "unknown").trim().toLowerCase() || "unknown";
101
+ const scopeId = deriveAlertScopeId(alert);
102
+ if (scopeId && (type === "failed_session_high_errors" || type === "stuck_agent")) {
103
+ return `${type}:task:${scopeId}`;
104
+ }
105
+ return `${type}:${String(alert?.attempt_id || "unknown")}`;
106
+ }
107
+
108
+ function pruneStaleAlertCooldowns(nowMs = Date.now()) {
109
+ const now = Number(nowMs) || Date.now();
110
+ const cutoff = now - ALERT_COOLDOWN_RETENTION_MS;
111
+ for (const [key, ts] of alertCooldowns.entries()) {
112
+ const lastTs = Number(ts);
113
+ if (!Number.isFinite(lastTs) || lastTs < cutoff) {
114
+ alertCooldowns.delete(key);
115
+ }
116
+ }
117
+ }
118
+
119
+ async function hydrateAlertCooldownsFromLog() {
120
+ if (!existsSync(ALERTS_LOG)) return;
121
+ try {
122
+ const fileStat = await stat(ALERTS_LOG);
123
+ if (!fileStat.size) return;
124
+ const start = Math.max(0, fileStat.size - ALERT_COOLDOWN_REPLAY_MAX_BYTES);
125
+ const stream = createReadStream(ALERTS_LOG, { start, encoding: "utf8" });
126
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
127
+ const maxCooldownMs = Math.max(ALERT_COOLDOWN_MS, FAILED_SESSION_ALERT_MIN_COOLDOWN_MS);
128
+ const cutoff = Date.now() - maxCooldownMs;
129
+ for await (const line of rl) {
130
+ const trimmed = String(line || "").trim();
131
+ if (!trimmed) continue;
132
+ try {
133
+ const entry = JSON.parse(trimmed);
134
+ const ts = Date.parse(String(entry?.timestamp || ""));
135
+ if (!Number.isFinite(ts) || ts < cutoff) continue;
136
+ const cooldownMs = getAlertCooldownMs(entry);
137
+ if (ts < Date.now() - cooldownMs) continue;
138
+ const key = String(entry?._cooldown_key || "").trim() || buildAlertCooldownKey(entry);
139
+ alertCooldowns.set(key, ts);
140
+ } catch {
141
+ // ignore malformed jsonl
142
+ }
143
+ }
144
+ } catch {
145
+ // best-effort hydration only
146
+ }
147
+ }
66
148
 
67
149
  // ── Log Tailing ─────────────────────────────────────────────────────────────
68
150
 
@@ -70,6 +152,14 @@ let filePosition = 0;
70
152
  let isRunning = false;
71
153
  let stuckSweepTimer = null;
72
154
 
155
+ function parseEnvBoolean(value, fallback = false) {
156
+ if (value == null || value === "") return fallback;
157
+ const normalized = String(value).trim().toLowerCase();
158
+ if (["1", "true", "yes", "y", "on"].includes(normalized)) return true;
159
+ if (["0", "false", "no", "n", "off"].includes(normalized)) return false;
160
+ return fallback;
161
+ }
162
+
73
163
  /**
74
164
  * Start the analyzer loop
75
165
  */
@@ -88,14 +178,29 @@ export async function startAnalyzer() {
88
178
  if (!existsSync(ALERTS_LOG)) {
89
179
  await writeFile(ALERTS_LOG, "");
90
180
  }
181
+ await hydrateAlertCooldownsFromLog();
91
182
  } catch (err) {
92
183
  console.warn(`[agent-work-analyzer] Failed to init alerts log: ${err.message}`);
93
184
  }
94
185
 
95
- // Initial read of existing log
186
+ // Initial positioning for existing log.
187
+ // Default behavior is true tailing (start at EOF) to avoid replaying stale
188
+ // historical sessions on monitor restart, which can re-emit old alerts and
189
+ // trigger noisy false-positive loops. Operators can opt in to replay for
190
+ // forensics via AGENT_ANALYZER_REPLAY_STARTUP=1.
96
191
  if (existsSync(AGENT_WORK_STREAM)) {
97
- filePosition = await processLogFile(filePosition);
98
- pruneStaleSessionsAfterReplay();
192
+ const replayStartup = parseEnvBoolean(
193
+ process.env.AGENT_ANALYZER_REPLAY_STARTUP,
194
+ false,
195
+ );
196
+ if (replayStartup) {
197
+ filePosition = await processLogFile(filePosition);
198
+ pruneStaleSessionsAfterReplay();
199
+ } else {
200
+ const streamStats = await stat(AGENT_WORK_STREAM);
201
+ filePosition = Math.max(0, Number(streamStats?.size || 0));
202
+ activeSessions.clear();
203
+ }
99
204
  } else {
100
205
  // Ensure the stream file exists so the watcher doesn't throw
101
206
  try {
@@ -154,7 +259,12 @@ export function stopAnalyzer() {
154
259
  async function processLogFile(startPosition) {
155
260
  try {
156
261
  const stats = await stat(AGENT_WORK_STREAM);
157
- if (stats.size <= startPosition) {
262
+ if (stats.size < startPosition) {
263
+ // Log file was truncated/rotated. Reset offset so new entries are not
264
+ // skipped forever after rotation.
265
+ return 0;
266
+ }
267
+ if (stats.size === startPosition) {
158
268
  return startPosition; // No new data
159
269
  }
160
270
 
@@ -163,12 +273,29 @@ async function processLogFile(startPosition) {
163
273
  encoding: "utf8",
164
274
  });
165
275
 
166
- const rl = createInterface({ input: stream });
167
- let bytesRead = startPosition;
276
+ let chunkText = "";
277
+ for await (const chunk of stream) {
278
+ chunkText += String(chunk || "");
279
+ }
280
+ if (!chunkText) {
281
+ return startPosition;
282
+ }
168
283
 
169
- for await (const line of rl) {
170
- bytesRead += Buffer.byteLength(line, "utf8") + 1; // +1 for newline
284
+ const lastNewlineIdx = chunkText.lastIndexOf("\n");
285
+ let processText = "";
286
+ let trailing = "";
287
+ if (lastNewlineIdx >= 0) {
288
+ processText = chunkText.slice(0, lastNewlineIdx + 1);
289
+ trailing = chunkText.slice(lastNewlineIdx + 1);
290
+ } else {
291
+ trailing = chunkText;
292
+ }
171
293
 
294
+ const lines = processText
295
+ .split(/\r?\n/)
296
+ .map((line) => String(line || "").trim())
297
+ .filter(Boolean);
298
+ for (const line of lines) {
172
299
  try {
173
300
  const event = JSON.parse(line);
174
301
  await analyzeEvent(event);
@@ -179,7 +306,21 @@ async function processLogFile(startPosition) {
179
306
  }
180
307
  }
181
308
 
182
- return bytesRead;
309
+ // If trailing text is present without newline, treat it as a potentially
310
+ // partial line and only consume it when it is valid JSON. This avoids data
311
+ // loss when writers flush an incomplete line temporarily.
312
+ const trailingTrimmed = String(trailing || "").trim();
313
+ if (trailingTrimmed) {
314
+ try {
315
+ const trailingEvent = JSON.parse(trailingTrimmed);
316
+ await analyzeEvent(trailingEvent);
317
+ return startPosition + Buffer.byteLength(chunkText, "utf8");
318
+ } catch {
319
+ return startPosition + Buffer.byteLength(processText, "utf8");
320
+ }
321
+ }
322
+
323
+ return startPosition + Buffer.byteLength(processText, "utf8");
183
324
  } catch (err) {
184
325
  if (err.code !== "ENOENT") {
185
326
  console.error(`[agent-work-analyzer] Error reading log: ${err.message}`);
@@ -450,11 +591,12 @@ function startStuckSweep() {
450
591
  * @param {Object} alert - Alert data
451
592
  */
452
593
  async function emitAlert(alert) {
453
- const alertKey = `${alert.type}:${alert.attempt_id}`;
594
+ const alertKey = buildAlertCooldownKey(alert);
595
+ const cooldownMs = getAlertCooldownMs(alert);
454
596
 
455
597
  // Check cooldown
456
598
  const lastAlert = alertCooldowns.get(alertKey);
457
- if (lastAlert && Date.now() - lastAlert < ALERT_COOLDOWN_MS) {
599
+ if (lastAlert && Date.now() - lastAlert < cooldownMs) {
458
600
  return; // Skip duplicate alerts
459
601
  }
460
602
 
@@ -462,6 +604,7 @@ async function emitAlert(alert) {
462
604
 
463
605
  const alertEntry = {
464
606
  timestamp: new Date().toISOString(),
607
+ _cooldown_key: alertKey,
465
608
  ...alert,
466
609
  };
467
610
 
@@ -477,7 +620,7 @@ async function emitAlert(alert) {
477
620
 
478
621
  // ── Cleanup Old Sessions ────────────────────────────────────────────────────
479
622
 
480
- setInterval(() => {
623
+ const cleanupTimer = setInterval(() => {
481
624
  const cutoff = Date.now() - 60 * 60 * 1000; // 1 hour
482
625
 
483
626
  for (const [attemptId, session] of activeSessions.entries()) {
@@ -486,7 +629,8 @@ setInterval(() => {
486
629
  activeSessions.delete(attemptId);
487
630
  }
488
631
  }
632
+ pruneStaleAlertCooldowns();
489
633
  }, 10 * 60 * 1000); // Cleanup every 10 minutes
634
+ cleanupTimer.unref?.();
490
635
 
491
636
  // ── Exports ─────────────────────────────────────────────────────────────────
492
-
package/lib/logger.mjs CHANGED
@@ -68,6 +68,45 @@ let errorLogDirEnsured = false;
68
68
  /** @type {Set<string>} Modules to always show at DEBUG level even when console is at INFO */
69
69
  const verboseModules = new Set();
70
70
 
71
+ const STDERR_NOISE_PATTERNS = [
72
+ /^(?:\(node:\d+\)\s+)?ExperimentalWarning:\s+SQLite is an experimental feature.*$/i,
73
+ /^\(Use `node --trace-warnings .*`.*\)\s*$/i,
74
+ /^Use `node --trace-warnings .*`.*$/i,
75
+ /^warning:\s+in the working copy of '.*',\s+(?:CRLF will be replaced by LF|LF will be replaced by CRLF) the next time Git touches it\.?\s*$/i,
76
+ /^(?:\[maintenance\]\s+)?local\s+'[^']+'\s+diverged\s+\(\d+↑\s+\d+↓\)\s+but has uncommitted changes\s+[—-]\s+skipping\.?\s*$/i,
77
+ ];
78
+
79
+ function normalizeStderrNoiseLine(line) {
80
+ return String(line || "")
81
+ .replace(/\u001b\[[0-9;]*[A-Za-z]/g, "")
82
+ .replace(/^\d{4}-\d{2}-\d{2}T[0-9:.+-]+Z?\s+/, "")
83
+ .replace(/^\d{2}:\d{2}:\d{2}(?:\.\d+)?\s+/, "")
84
+ .trim()
85
+ .replace(/^(?:\[[^\]]+\]\s*)+/, "")
86
+ .trim();
87
+ }
88
+
89
+ function shouldSuppressStderrNoise(text) {
90
+ const normalized = String(text || "")
91
+ .split(/\r?\n/)
92
+ .map((line) => normalizeStderrNoiseLine(line))
93
+ .filter(Boolean);
94
+ if (normalized.length === 0) return false;
95
+ return normalized.every((line) =>
96
+ STDERR_NOISE_PATTERNS.some((pattern) => pattern.test(line)),
97
+ );
98
+ }
99
+
100
+ function stripKnownStderrNoiseLines(text) {
101
+ const rawLines = String(text || "").split(/\r?\n/);
102
+ const kept = rawLines.filter((line) => {
103
+ const normalized = normalizeStderrNoiseLine(line);
104
+ if (!normalized) return false;
105
+ return !STDERR_NOISE_PATTERNS.some((pattern) => pattern.test(normalized));
106
+ });
107
+ return kept.join("\n").trim();
108
+ }
109
+
71
110
  // ── Configuration ───────────────────────────────────────────────────────────
72
111
 
73
112
  /**
@@ -229,6 +268,7 @@ function writeToFile(levelName, module, msg) {
229
268
  */
230
269
  function writeToErrorFile(levelName, module, msg) {
231
270
  if (!errorLogFilePath) return;
271
+ if (shouldSuppressStderrNoise(msg)) return;
232
272
  if (!errorLogDirEnsured) {
233
273
  try {
234
274
  mkdirSync(dirname(errorLogFilePath), { recursive: true });
@@ -482,6 +522,9 @@ export function installConsoleInterceptor(opts = {}) {
482
522
  const msg = args
483
523
  .map((a) => (typeof a === "string" ? a : String(a)))
484
524
  .join(" ");
525
+ if (shouldSuppressStderrNoise(msg)) {
526
+ return;
527
+ }
485
528
  const tagMatch = typeof msg === "string" ? msg.match(TAG_RE) : null;
486
529
  const mod = tagMatch?.[1] || "stderr";
487
530
  if (logFilePath && LogLevel.WARN >= fileLevel) {
@@ -507,6 +550,9 @@ export function installConsoleInterceptor(opts = {}) {
507
550
  return typeof a === "string" ? a : String(a);
508
551
  })
509
552
  .join(" ");
553
+ if (shouldSuppressStderrNoise(msg)) {
554
+ return;
555
+ }
510
556
  const tagMatch = typeof msg === "string" ? msg.match(TAG_RE) : null;
511
557
  const mod = tagMatch?.[1] || "stderr";
512
558
  if (logFilePath && LogLevel.ERROR >= fileLevel) {
@@ -578,11 +624,29 @@ export function installConsoleInterceptor(opts = {}) {
578
624
  throw err;
579
625
  }
580
626
  };
627
+ const acknowledgeSuppressedWrite = (...rest) => {
628
+ const cb = rest.find((value) => typeof value === "function");
629
+ if (cb) {
630
+ try {
631
+ cb();
632
+ } catch {
633
+ /* ignore callback failures for suppressed noise */
634
+ }
635
+ }
636
+ return true;
637
+ };
581
638
  process.stderr.write = (chunk, ...rest) => {
582
639
  if (!_inInterceptor) {
583
640
  const text = typeof chunk === "string" ? chunk : chunk?.toString?.("utf8") || "";
584
641
  if (text.trim()) {
585
- writeToErrorFile("STDERR", "process", text.replace(/\n$/, ""));
642
+ if (shouldSuppressStderrNoise(text)) {
643
+ return acknowledgeSuppressedWrite(...rest);
644
+ }
645
+ const filtered = stripKnownStderrNoiseLines(text);
646
+ if (!filtered) {
647
+ return acknowledgeSuppressedWrite(...rest);
648
+ }
649
+ writeToErrorFile("STDERR", "process", filtered.replace(/\n$/, ""));
586
650
  }
587
651
  }
588
652
  return safeStderrWrite(chunk, ...rest);
package/maintenance.mjs CHANGED
@@ -935,7 +935,7 @@ export function syncLocalTrackingBranches(repoRoot, branches) {
935
935
  windowsHide: true,
936
936
  });
937
937
  if (statusCheck.stdout?.trim()) {
938
- console.warn(
938
+ console.log(
939
939
  `[maintenance] local '${branch}' diverged (${ahead}↑ ${behind}↓) but has uncommitted changes — skipping`,
940
940
  );
941
941
  continue;
@@ -988,7 +988,7 @@ export function syncLocalTrackingBranches(repoRoot, branches) {
988
988
  windowsHide: true,
989
989
  });
990
990
  if (statusCheck.stdout?.trim()) {
991
- console.warn(
991
+ console.log(
992
992
  `[maintenance] '${branch}' is checked out with uncommitted changes — skipping pull`,
993
993
  );
994
994
  continue;
@@ -1139,4 +1139,3 @@ export async function runMaintenanceSweep(opts = {}) {
1139
1139
  branchesDeleted,
1140
1140
  };
1141
1141
  }
1142
-