feat(notifications): tag remaining auto/loop/register-hooks notices + trace-writer

- auto.js, auto/loop.js, bootstrap/register-hooks.js: tag all
  autonomous-mode system notices with NOTICE_KIND.SYSTEM_NOTICE;
  add dedupe_key to loop-level model-policy and flow-audit notices
- web/notifications-service.ts: add repeatCount/lastTs/noticeKind to
  Notification type (schema v2 fields)
- uok/trace-writer.js: new unit trace writer
- tests/notification-store-grouping.test.mjs: grouping test coverage

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-10 20:14:22 +02:00
parent d33e30e885
commit 62fcf8fd20
6 changed files with 227 additions and 3 deletions

View file

@ -132,6 +132,7 @@ import {
initMetrics,
resetMetrics,
} from "./metrics.js";
import { NOTICE_KIND } from "./notification-store.js";
import { sendDesktopNotification } from "./notifications.js";
import { resolvePreset } from "./operating-model.js";
import {
@ -1262,7 +1263,12 @@ export async function pauseAuto(ctx, _pi, _errorContext) {
ctx?.ui.notify(
`${s.stepMode ? "Step" : "Autonomous"} mode paused (Escape). Type to interact, or ${resumeCmd} to resume.`,
"info",
{ kind: "terminal", blocking: true, source: "workflow" },
{
kind: "terminal",
blocking: true,
source: "workflow",
noticeKind: NOTICE_KIND.USER_VISIBLE,
},
);
}
/**
@ -1413,6 +1419,7 @@ async function runStartupDoctorFix(ctx, basePath) {
ctx.ui.notify(
`Startup doctor: applied ${report.fixesApplied.length} fix(es).`,
"info",
{ noticeKind: NOTICE_KIND.SYSTEM_NOTICE },
);
}
return report;

View file

@ -22,6 +22,7 @@ import {
} from "../uok/execution-graph.js";
import { resolveUokFlags } from "../uok/flags.js";
import { clearRunawayRecoveredRuntimeRecords } from "../uok/unit-runtime.js";
import { NOTICE_KIND } from "../notification-store.js";
import { logWarning } from "../workflow-logger.js";
import {
COOLDOWN_FALLBACK_WAIT_MS,
@ -1126,6 +1127,10 @@ export async function autoLoop(ctx, pi, s, deps) {
ctx.ui.notify(
`Autonomous mode paused: model-policy denied dispatch for ${loopErr.unitType}/${loopErr.unitId}. ${msg}`,
"error",
{
noticeKind: NOTICE_KIND.SYSTEM_NOTICE,
dedupe_key: `model-policy-blocked:${loopErr.unitType}:${loopErr.unitId}`,
},
);
deps.emitJournalEvent({
ts: new Date().toISOString(),

View file

@ -50,7 +50,7 @@ import {
observeToolResult,
resetToolWatchdog,
} from "../tool-watchdog.js";
import { initNotificationStore } from "../notification-store.js";
import { NOTICE_KIND, initNotificationStore } from "../notification-store.js";
import { initNotificationWidget } from "../notification-widget.js";
import {
isParallelActive,
@ -171,6 +171,7 @@ async function runSessionStartupDoctorFix(ctx) {
ctx.ui?.notify?.(
`Startup doctor: applied ${report.fixesApplied.length} fix(es).`,
"info",
{ noticeKind: NOTICE_KIND.SYSTEM_NOTICE },
);
}
const summary = summarizeDoctorIssues(report.issues);
@ -178,6 +179,7 @@ async function runSessionStartupDoctorFix(ctx) {
ctx.ui?.notify?.(
`Startup doctor found ${summary.errors} blocking issue(s). Run /doctor audit for details.`,
"warning",
{ noticeKind: NOTICE_KIND.SYSTEM_NOTICE },
);
}
} catch (error) {
@ -343,7 +345,10 @@ export function registerHooks(pi, ecosystemHandlers = []) {
killOverBudgetChildren: true,
});
if (!flow.ok) {
ctx.ui?.notify?.(`Flow audit: ${flow.recommendedAction}`, "warning");
ctx.ui?.notify?.(`Flow audit: ${flow.recommendedAction}`, "warning", {
noticeKind: NOTICE_KIND.SYSTEM_NOTICE,
dedupe_key: "flow-audit-recommended-action",
});
}
} catch {
/* non-fatal — flow audit must never block session start */
@ -368,6 +373,7 @@ export function registerHooks(pi, ecosystemHandlers = []) {
ctx.ui?.notify?.(
`Self-feedback ${id} auto-resolved — milestone is complete.`,
"info",
{ noticeKind: NOTICE_KIND.SYSTEM_NOTICE },
);
}
const triage = triageBlockedEntries(process.cwd());
@ -394,12 +400,14 @@ export function registerHooks(pi, ecosystemHandlers = []) {
ctx.ui?.notify?.(
`Self-feedback ${e.id} (${e.kind}) auto-resolved — sf bumped past ${e.sfVersion}. Originating unit ${unit} should be re-run.`,
"info",
{ noticeKind: NOTICE_KIND.SYSTEM_NOTICE },
);
}
if (triage.stillBlocked.length > 0) {
ctx.ui?.notify?.(
`${triage.stillBlocked.length} unresolved self-feedback entr${triage.stillBlocked.length === 1 ? "y" : "ies"} require sf fixes. See .sf/SELF-FEEDBACK.md or ~/.sf/agent/upstream-feedback.jsonl.`,
"warning",
{ noticeKind: NOTICE_KIND.SYSTEM_NOTICE },
);
}
// Forge-only: high/critical entries are queued as hidden follow-up repair
@ -413,6 +421,7 @@ export function registerHooks(pi, ecosystemHandlers = []) {
ctx.ui?.notify?.(
`${highBlocked.length} high/critical inline-fix candidate${highBlocked.length === 1 ? "" : "s"} pending in .sf/SELF-FEEDBACK.md: ${ids}`,
"warning",
{ noticeKind: NOTICE_KIND.SYSTEM_NOTICE },
);
const { dispatchSelfFeedbackInlineFixIfNeeded } = await import(
"../self-feedback-drain.js"
@ -433,6 +442,7 @@ export function registerHooks(pi, ecosystemHandlers = []) {
ctx.ui?.notify?.(
`Gap audit filed ${filed} new finding${filed === 1 ? "" : "s"} in ${selfFeedbackDestinationLabel(process.cwd())}`,
"info",
{ noticeKind: NOTICE_KIND.SYSTEM_NOTICE },
);
}
} catch {
@ -456,6 +466,7 @@ export function registerHooks(pi, ecosystemHandlers = []) {
ctx.ui?.notify?.(
`Upstream bridge filed ${filed} rollup${filed === 1 ? "" : "s"} in .sf/SELF-FEEDBACK.md`,
"info",
{ noticeKind: NOTICE_KIND.SYSTEM_NOTICE },
);
}
} catch {
@ -473,6 +484,7 @@ export function registerHooks(pi, ecosystemHandlers = []) {
ctx.ui?.notify?.(
`Promoted ${promoted} cluster${promoted === 1 ? "" : "s"} to requirements: ${requirementIds.join(", ")}`,
"info",
{ noticeKind: NOTICE_KIND.SYSTEM_NOTICE },
);
}
} catch {
@ -491,6 +503,10 @@ export function registerHooks(pi, ecosystemHandlers = []) {
ctx.ui?.notify?.(
"Resuming autonomous mode after compaction.",
"info",
{
noticeKind: NOTICE_KIND.SYSTEM_NOTICE,
dedupe_key: "autonomous-resume-after-compaction",
},
);
startAutoDetached(ctx, pi, process.cwd(), marker?.verbose === true, {
step: marker?.stepMode === true,

View file

@ -0,0 +1,106 @@
import { mkdirSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
_resetNotificationStore,
appendNotification,
initNotificationStore,
NOTICE_KIND,
NOTIFICATION_SCHEMA_VERSION_WRITE,
readNotifications,
} from "../notification-store.js";
describe("notification-store long-term grouping (schema v2)", () => {
let testDir;
beforeEach(() => {
_resetNotificationStore();
testDir = join(tmpdir(), `sf-notif-group-${Date.now()}`);
mkdirSync(testDir, { recursive: true });
initNotificationStore(testDir);
});
afterEach(() => {
try {
rmSync(testDir, { recursive: true });
} catch {
/* ignore */
}
_resetNotificationStore();
});
it("merges_duplicate_warnings_with_same_dedupe_key_into_one_row_with_repeatCount", () => {
appendNotification("quota edge", "warning", "notify", {
dedupe_key: "quota-roll",
});
appendNotification("quota edge", "warning", "notify", {
dedupe_key: "quota-roll",
});
const lines = readFileSync(
join(testDir, ".sf", "notifications.jsonl"),
"utf-8",
)
.trim()
.split("\n")
.filter(Boolean);
expect(lines.length).toBe(1);
const row = JSON.parse(lines[0]);
expect(row.schemaVersion).toBe(NOTIFICATION_SCHEMA_VERSION_WRITE);
expect(row.repeatCount).toBe(2);
expect(row.lastTs).toBeDefined();
const [latest] = readNotifications(testDir);
expect(latest.repeatCount).toBe(2);
});
it("does_not_merge_when_merge_is_false", () => {
appendNotification("unique audit", "warning", "notify", {
dedupe_key: "audit-x",
merge: false,
});
appendNotification("unique audit", "warning", "notify", {
dedupe_key: "audit-x",
merge: false,
});
const lines = readFileSync(
join(testDir, ".sf", "notifications.jsonl"),
"utf-8",
)
.trim()
.split("\n")
.filter(Boolean);
expect(lines.length).toBe(2);
});
it("does_not_merge_approval_request_kind_distinct_consent_rows", () => {
appendNotification("approve deploy?", "warning", "notify", {
kind: "approval_request",
dedupe_key: "deploy-approve",
});
appendNotification("approve deploy?", "warning", "notify", {
kind: "approval_request",
dedupe_key: "deploy-approve",
});
const lines = readFileSync(
join(testDir, ".sf", "notifications.jsonl"),
"utf-8",
)
.trim()
.split("\n")
.filter(Boolean);
expect(lines.length).toBe(2);
});
it("persists_noticeKind_on_new_rows", () => {
appendNotification("SM offline", "warning", "notify", {
dedupe_key: "sm-health",
noticeKind: NOTICE_KIND.SYSTEM_NOTICE,
});
const [e] = readNotifications(testDir);
expect(e.noticeKind).toBe(NOTICE_KIND.SYSTEM_NOTICE);
});
});

View file

@ -0,0 +1,86 @@
// Writes typed trace events to .sf/traces/<traceId>.jsonl
// Updates a `latest` symlink for easy tail -f access.
import {
appendFileSync,
closeSync,
existsSync,
mkdirSync,
openSync,
readdirSync,
readFileSync,
statSync,
symlinkSync,
unlinkSync,
} from "node:fs";
import { join } from "node:path";
import { sfRoot } from "../paths.js";
function tracesDir(basePath) {
return join(sfRoot(basePath), "traces");
}
function tracePath(basePath, traceId) {
return join(tracesDir(basePath), `${traceId}.jsonl`);
}
export function appendTraceEvent(basePath, traceId, event) {
if (!basePath || !traceId) return;
try {
const dir = tracesDir(basePath);
mkdirSync(dir, { recursive: true });
const path = tracePath(basePath, traceId);
const line = JSON.stringify({ ts: new Date().toISOString(), ...event });
if (!existsSync(path)) closeSync(openSync(path, "a"));
appendFileSync(path, `${line}\n`, "utf-8");
// Update latest symlink
const latestPath = join(dir, "latest");
try {
unlinkSync(latestPath);
} catch {
/* ok if missing */
}
symlinkSync(`${traceId}.jsonl`, latestPath);
} catch {
// trace writes must never break orchestration
}
}
export function readTraceEvents(basePath, type, windowHours = 24) {
// Read all trace files modified within windowHours, filter by event type
// Returns array of matching events
const dir = tracesDir(basePath);
if (!existsSync(dir)) return [];
const cutoff = Date.now() - windowHours * 60 * 60 * 1000;
const results = [];
let files;
try {
files = readdirSync(dir).filter(
(f) => f.endsWith(".jsonl") && f !== "latest",
);
} catch {
return [];
}
for (const file of files) {
try {
const filePath = join(dir, file);
if (statSync(filePath).mtimeMs < cutoff) continue;
const lines = readFileSync(filePath, "utf-8")
.split("\n")
.filter(Boolean);
for (const line of lines) {
try {
const ev = JSON.parse(line);
if (!type || ev.type === type) {
if (!ev.ts || new Date(ev.ts).getTime() >= cutoff)
results.push(ev);
}
} catch {
/* skip malformed lines */
}
}
} catch {
/* skip unreadable files */
}
}
return results;
}

View file

@ -20,6 +20,10 @@ export interface NotificationsData {
message: string;
source: string;
read: boolean;
/** Present when duplicate notices were merged (notification-store v2). */
repeatCount?: number;
lastTs?: string;
noticeKind?: string;
}>;
unreadCount: number;
totalCount: number;