diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 941674524f..3cd5c01fab 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -3,6 +3,8 @@ ## [Unreleased] ### Fixed + +- `notify health` no longer counts retained native cleanup leak artifacts (`.gjc-exact-unlink-placeholder-*`, `.gjc-delete-*`) as endpoint files. They are unparseable by construction, so they were reported as unreadable endpoints and pinned health to a WARN that `notify recovery` could never clear. - ACP session configuration now emits the spec-defined `category` field on the Mode, Model, and Thinking select options (`mode`, `model`, `thought_level`), so standards-compliant ACP clients such as Paseo discover models, modes, and thinking levels instead of an empty model picker (#3922). - The ACP session model catalog is now filtered to active providers via `providers.list/active`, falling back to the full catalog on older session hosts, so ACP clients no longer list models for providers without usable credentials (#3922). diff --git a/packages/coding-agent/src/sdk/bus/notification-service.ts b/packages/coding-agent/src/sdk/bus/notification-service.ts index 18b78c551e..47a0877081 100644 --- a/packages/coding-agent/src/sdk/bus/notification-service.ts +++ b/packages/coding-agent/src/sdk/bus/notification-service.ts @@ -39,7 +39,12 @@ import { DiscordLiveProvider } from "./discord-live-provider"; import type { DiscordDiagnosticProvider } from "./discord-provider"; import { SlackLiveProvider } from "./slack-live-provider"; import type { SlackDiagnosticProvider } from "./slack-provider"; -import { type OwnerFreshnessSnapshot, readOwnerFreshnessSnapshot, type TelegramDaemonFs } from "./telegram-daemon"; +import { + isNotificationLeakArtifactName, + type OwnerFreshnessSnapshot, + readOwnerFreshnessSnapshot, + type TelegramDaemonFs, +} from "./telegram-daemon"; import { DAEMON_GENERATION } from "./telegram-daemon-contract"; const DEFAULT_API_BASE = "https://api.telegram.org"; @@ -446,7 +451,12 @@ function isCanonicalLifecycleArtifactName(name: string): boolean { } function unreadableEndpointResult(file: string): NotificationEndpointClassification { - return isCanonicalLifecycleArtifactName(path.basename(file)) ? { kind: "non-endpoint" } : { kind: "unreadable" }; + const name = path.basename(file); + // Retained native cleanup leak artifacts are not endpoint candidates. They are + // unparseable by construction (often zero-byte), so counting them as unreadable + // pins `notify health` to a WARN that `notify recovery` can never clear. + if (isCanonicalLifecycleArtifactName(name) || isNotificationLeakArtifactName(name)) return { kind: "non-endpoint" }; + return { kind: "unreadable" }; } /** diff --git a/packages/coding-agent/test/notifications-service.test.ts b/packages/coding-agent/test/notifications-service.test.ts index 47dd88fd5b..12bafb4be7 100644 --- a/packages/coding-agent/test/notifications-service.test.ts +++ b/packages/coding-agent/test/notifications-service.test.ts @@ -13,6 +13,7 @@ import type { import { buildNotificationStatusReport, checkNotificationHealth, + classifyNotificationEndpoint, formatNotificationHealthReport, formatNotificationRecoveryReport, formatNotificationStatusReport, @@ -21,6 +22,7 @@ import { sendNotificationTest, writeNotificationDiagnostic, } from "../src/sdk/bus/notification-service"; +import { NOTIFICATION_LEAK_ARTIFACT_PREFIXES } from "../src/sdk/bus/telegram-daemon"; import { DAEMON_GENERATION } from "../src/sdk/bus/telegram-daemon-contract"; const TOKEN = "1234567890:ABCDEFghijkLmnOpQrsTuvWxYz012345678"; @@ -1304,3 +1306,41 @@ describe("notification-service diagnostic sanitization (secret-safe)", () => { expect(result.detail).toContain("no usable message receipt"); }); }); +describe("endpoint classification of retained cleanup leak artifacts", () => { + const dir = "/state/sdk"; + function fsWith(bytes: Record) { + return { + readEndpointFile: async (file: string) => { + const raw = bytes[file]; + if (raw === undefined) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + return { bytes: Buffer.from(raw), identity: {} }; + }, + } as unknown as Parameters[0]; + } + + test("classifies every native leak artifact prefix as a non-endpoint", async () => { + const names = NOTIFICATION_LEAK_ARTIFACT_PREFIXES.map(prefix => `${prefix}100000d-8b39653`); + const fs = fsWith(Object.fromEntries(names.map(name => [path.join(dir, name), ""]))); + for (const name of names) { + const record = await classifyNotificationEndpoint(fs, path.join(dir, name), () => false); + expect(record.kind).toBe("non-endpoint"); + } + }); + + test("still reports a genuinely corrupt endpoint file as unreadable", async () => { + const file = path.join(dir, "019fc01a-c994-7000-a08e-d0ae1fceef89.json"); + const record = await classifyNotificationEndpoint(fsWith({ [file]: "{not json" }), file, () => false); + expect(record.kind).toBe("unreadable"); + }); + + test("keeps notify health OK when only leak artifacts sit beside live endpoints", async () => { + const live = path.join(dir, "019fc01a-c994-7000-a08e-d0ae1fceef89.json"); + const placeholder = path.join(dir, ".gjc-exact-unlink-placeholder-100000d-8b39653"); + const fs = fsWith({ + [live]: JSON.stringify({ sessionId: "s", url: "ws://x", token: "t", pid: process.pid }), + [placeholder]: "", + }); + expect((await classifyNotificationEndpoint(fs, live, () => true)).kind).toBe("endpoint"); + expect((await classifyNotificationEndpoint(fs, placeholder, () => true)).kind).toBe("non-endpoint"); + }); +});