Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
14 changes: 12 additions & 2 deletions packages/coding-agent/src/sdk/bus/notification-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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" };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude leak artifacts before parsing their payloads

When exact-unlink detaches an endpoint but scrub_regular_file_openat fails, the retained .gjc-delete-notification-endpoint-*.json quarantine still contains the original valid url/token endpoint JSON. Because the new name predicate is consulted only by unreadableEndpointResult, that payload parses successfully and is still classified as an endpoint, so health and recovery continue counting or processing the artifact this change intends to ignore. Apply the leak-artifact name check before reading/parsing the file.

Useful? React with 👍 / 👎.

return { kind: "unreadable" };
}

/**
Expand Down
40 changes: 40 additions & 0 deletions packages/coding-agent/test/notifications-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
import {
buildNotificationStatusReport,
checkNotificationHealth,
classifyNotificationEndpoint,
formatNotificationHealthReport,
formatNotificationRecoveryReport,
formatNotificationStatusReport,
Expand All @@ -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";
Expand Down Expand Up @@ -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<string, string>) {
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<typeof classifyNotificationEndpoint>[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");
});
});
Loading