From 03b3b1cc237903e265707bfe5e0562ec11a616f8 Mon Sep 17 00:00:00 2001 From: siddseethepalli Date: Sat, 18 Apr 2026 20:45:40 +0000 Subject: [PATCH] feat(assistant): add update-bulletin-job background processor --- .../src/__tests__/update-bulletin-job.test.ts | 224 ++++++++++++++++++ .../src/memory/conversation-title-service.ts | 1 + assistant/src/prompts/update-bulletin-job.ts | 100 ++++++++ 3 files changed, 325 insertions(+) create mode 100644 assistant/src/__tests__/update-bulletin-job.test.ts create mode 100644 assistant/src/prompts/update-bulletin-job.ts diff --git a/assistant/src/__tests__/update-bulletin-job.test.ts b/assistant/src/__tests__/update-bulletin-job.test.ts new file mode 100644 index 00000000000..8e0e9063ada --- /dev/null +++ b/assistant/src/__tests__/update-bulletin-job.test.ts @@ -0,0 +1,224 @@ +import { createHash } from "node:crypto"; +import { existsSync, rmSync, writeFileSync } from "node:fs"; +import { + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; + +import { getWorkspacePromptPath } from "../util/platform.js"; + +// ── In-memory checkpoint store ─────────────────────────────────────── +const store = new Map(); +let setCheckpointCallCount = 0; + +mock.module("../memory/checkpoints.js", () => ({ + getMemoryCheckpoint: (key: string) => store.get(key) ?? null, + setMemoryCheckpoint: (key: string, value: string) => { + setCheckpointCallCount += 1; + store.set(key, value); + }, +})); + +// ── Mutable config stub ────────────────────────────────────────────── +const updatesConfig = { enabled: true }; + +mock.module("../config/loader.js", () => ({ + getConfig: () => ({ updates: updatesConfig }), +})); + +// ── bootstrapConversation + wakeAgentForOpportunity mocks ──────────── +let bootstrapCalls = 0; +let wakeCalls = 0; +let wakeShouldThrow = false; +// A side-effect function invoked during wake. Lets tests simulate the +// agent deleting UPDATES.md while the wake is in flight. +let wakeSideEffect: (() => void) | null = null; + +mock.module("../memory/conversation-bootstrap.js", () => ({ + bootstrapConversation: (_opts: unknown) => { + bootstrapCalls += 1; + return { id: `conv-${bootstrapCalls}` }; + }, +})); + +mock.module("../runtime/agent-wake.js", () => ({ + wakeAgentForOpportunity: async () => { + wakeCalls += 1; + if (wakeSideEffect) { + wakeSideEffect(); + } + if (wakeShouldThrow) { + throw new Error("simulated wake failure"); + } + return { invoked: true, producedToolCalls: false }; + }, +})); + +const { runUpdateBulletinJobIfNeeded } = await import( + "../prompts/update-bulletin-job.js" +); + +const HASH_CHECKPOINT_KEY = "updates:last_processed_hash"; +const EMPTY_HASH = "empty"; + +function sha256(input: string): string { + return createHash("sha256").update(input).digest("hex"); +} + +const workspacePath = getWorkspacePromptPath("UPDATES.md"); + +describe("runUpdateBulletinJobIfNeeded", () => { + beforeEach(() => { + store.clear(); + setCheckpointCallCount = 0; + bootstrapCalls = 0; + wakeCalls = 0; + wakeShouldThrow = false; + wakeSideEffect = null; + updatesConfig.enabled = true; + if (existsSync(workspacePath)) { + rmSync(workspacePath); + } + }); + + afterEach(() => { + if (existsSync(workspacePath)) { + rmSync(workspacePath); + } + }); + + test("config disabled — no bootstrap, no wake, no checkpoint change", async () => { + updatesConfig.enabled = false; + writeFileSync(workspacePath, "## Real content", "utf-8"); + + await runUpdateBulletinJobIfNeeded(); + + expect(bootstrapCalls).toBe(0); + expect(wakeCalls).toBe(0); + expect(setCheckpointCallCount).toBe(0); + expect(store.has(HASH_CHECKPOINT_KEY)).toBe(false); + }); + + test("file missing, stored hash absent — no wake; stored becomes 'empty'", async () => { + expect(existsSync(workspacePath)).toBe(false); + expect(store.has(HASH_CHECKPOINT_KEY)).toBe(false); + + await runUpdateBulletinJobIfNeeded(); + + expect(bootstrapCalls).toBe(0); + expect(wakeCalls).toBe(0); + expect(store.get(HASH_CHECKPOINT_KEY)).toBe(EMPTY_HASH); + }); + + test("file missing, stored hash already 'empty' — no wake; no checkpoint write", async () => { + store.set(HASH_CHECKPOINT_KEY, EMPTY_HASH); + // Reset the counter to ignore the priming write above. + setCheckpointCallCount = 0; + + await runUpdateBulletinJobIfNeeded(); + + expect(bootstrapCalls).toBe(0); + expect(wakeCalls).toBe(0); + expect(setCheckpointCallCount).toBe(0); + expect(store.get(HASH_CHECKPOINT_KEY)).toBe(EMPTY_HASH); + }); + + test("file present but whitespace-only — treated as empty; stored hash 'empty'", async () => { + writeFileSync(workspacePath, " \n\n\t\n", "utf-8"); + + await runUpdateBulletinJobIfNeeded(); + + expect(bootstrapCalls).toBe(0); + expect(wakeCalls).toBe(0); + expect(store.get(HASH_CHECKPOINT_KEY)).toBe(EMPTY_HASH); + }); + + test("file present with content, stored hash absent — bootstrap + wake; stored hash is sha256(trimmed)", async () => { + const content = "## Release 1.2.3\n\nNew thing.\n"; + writeFileSync(workspacePath, content, "utf-8"); + + await runUpdateBulletinJobIfNeeded(); + + expect(bootstrapCalls).toBe(1); + expect(wakeCalls).toBe(1); + expect(store.get(HASH_CHECKPOINT_KEY)).toBe(sha256(content.trim())); + }); + + test("file present, stored hash matches current — no wake", async () => { + const content = "## Release 1.2.3\n\nSame content.\n"; + writeFileSync(workspacePath, content, "utf-8"); + store.set(HASH_CHECKPOINT_KEY, sha256(content.trim())); + setCheckpointCallCount = 0; + + await runUpdateBulletinJobIfNeeded(); + + expect(bootstrapCalls).toBe(0); + expect(wakeCalls).toBe(0); + expect(setCheckpointCallCount).toBe(0); + }); + + test("file present, stored hash differs — wake invoked; stored hash updates", async () => { + const oldContent = "## Old"; + const newContent = "## New content v2"; + writeFileSync(workspacePath, newContent, "utf-8"); + store.set(HASH_CHECKPOINT_KEY, sha256(oldContent)); + + await runUpdateBulletinJobIfNeeded(); + + expect(bootstrapCalls).toBe(1); + expect(wakeCalls).toBe(1); + expect(store.get(HASH_CHECKPOINT_KEY)).toBe(sha256(newContent.trim())); + expect(store.get(HASH_CHECKPOINT_KEY)).not.toBe(sha256(oldContent)); + }); + + test("agent deletes file mid-wake — stored hash becomes 'empty'", async () => { + const content = "## Release X\n\nStuff to process.\n"; + writeFileSync(workspacePath, content, "utf-8"); + wakeSideEffect = () => { + rmSync(workspacePath); + }; + + await runUpdateBulletinJobIfNeeded(); + + expect(bootstrapCalls).toBe(1); + expect(wakeCalls).toBe(1); + expect(existsSync(workspacePath)).toBe(false); + expect(store.get(HASH_CHECKPOINT_KEY)).toBe(EMPTY_HASH); + }); + + test("wake completes but file unchanged — stored hash = hash of content; rerun is a no-op", async () => { + const content = "## Release Y\n\nAgent chose to no-op.\n"; + writeFileSync(workspacePath, content, "utf-8"); + + await runUpdateBulletinJobIfNeeded(); + + expect(bootstrapCalls).toBe(1); + expect(wakeCalls).toBe(1); + expect(store.get(HASH_CHECKPOINT_KEY)).toBe(sha256(content.trim())); + + // Second run — hash matches, so we short-circuit before bootstrap/wake. + await runUpdateBulletinJobIfNeeded(); + + expect(bootstrapCalls).toBe(1); + expect(wakeCalls).toBe(1); + }); + + test("wake throws — function does not reject; warning logged", async () => { + const content = "## Release Z"; + writeFileSync(workspacePath, content, "utf-8"); + wakeShouldThrow = true; + + // Must not throw. + await expect(runUpdateBulletinJobIfNeeded()).resolves.toBeUndefined(); + + expect(bootstrapCalls).toBe(1); + expect(wakeCalls).toBe(1); + // Hash was never updated because the try/catch returned before the + // self-healing step. + expect(store.has(HASH_CHECKPOINT_KEY)).toBe(false); + }); +}); diff --git a/assistant/src/memory/conversation-title-service.ts b/assistant/src/memory/conversation-title-service.ts index 6a1b19ed565..1a494946415 100644 --- a/assistant/src/memory/conversation-title-service.ts +++ b/assistant/src/memory/conversation-title-service.ts @@ -37,6 +37,7 @@ export type TitleOrigin = | "filing" | "local" | "task_submit" + | "updates-bulletin" | "misc"; export interface TitleContext { diff --git a/assistant/src/prompts/update-bulletin-job.ts b/assistant/src/prompts/update-bulletin-job.ts new file mode 100644 index 00000000000..5ec9a510c5a --- /dev/null +++ b/assistant/src/prompts/update-bulletin-job.ts @@ -0,0 +1,100 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; + +import { getConfig } from "../config/loader.js"; +import { + getMemoryCheckpoint, + setMemoryCheckpoint, +} from "../memory/checkpoints.js"; +import { bootstrapConversation } from "../memory/conversation-bootstrap.js"; +import { wakeAgentForOpportunity } from "../runtime/agent-wake.js"; +import { getLogger } from "../util/logger.js"; +import { getWorkspacePromptPath } from "../util/platform.js"; + +const log = getLogger("update-bulletin-job"); + +const HASH_CHECKPOINT_KEY = "updates:last_processed_hash"; +const EMPTY_HASH = "empty"; +const UPDATE_BULLETIN_HINT = + "Check ~/.vellum/workspace/UPDATES.md — new release notes are present. Apply any assistant-facing behavior changes (new tools, deprecations, memory updates). If the user would benefit from knowing about a user-facing change, surface it only when the next topic makes it relevant — do not interrupt them with a proactive message. When you're done processing, delete UPDATES.md with `rm ~/.vellum/workspace/UPDATES.md` (already auto-allowed). A silent no-op is preferable to low-signal chatter."; + +function computeHash(content: string): string { + return createHash("sha256").update(content).digest("hex"); +} + +function readTrimmedContent(path: string): string | null { + if (!existsSync(path)) return null; + try { + return readFileSync(path, "utf-8").trim(); + } catch { + return null; + } +} + +/** + * Fire-and-forget background processor for the release-notes bulletin. + * + * If `~/.vellum/workspace/UPDATES.md` has new (unprocessed) content, this + * bootstraps a background conversation and wakes the agent loop with a hint + * pointing at the file. De-duplication uses a sha256 content hash stored in + * the `updates:last_processed_hash` memory checkpoint — an `"empty"` sentinel + * represents a missing/blank file so the job skips the common no-op case. + * + * The function never throws: any error inside the bootstrap/wake flow is + * logged at `warn` and swallowed, so callers can safely invoke it in a + * non-awaited context. + */ +export async function runUpdateBulletinJobIfNeeded(): Promise { + if (getConfig().updates.enabled === false) { + return; + } + + const updatesPath = getWorkspacePromptPath("UPDATES.md"); + const trimmed = readTrimmedContent(updatesPath); + + if (trimmed === null || trimmed.length === 0) { + const stored = getMemoryCheckpoint(HASH_CHECKPOINT_KEY); + if (stored !== EMPTY_HASH) { + setMemoryCheckpoint(HASH_CHECKPOINT_KEY, EMPTY_HASH); + } + return; + } + + const currentHash = computeHash(trimmed); + const stored = getMemoryCheckpoint(HASH_CHECKPOINT_KEY); + if (stored === currentHash) { + return; + } + + try { + const conv = bootstrapConversation({ + conversationType: "background", + source: "updates-bulletin", + origin: "updates-bulletin", + systemHint: "Processing release updates", + groupId: "system:background", + }); + await wakeAgentForOpportunity({ + conversationId: conv.id, + hint: UPDATE_BULLETIN_HINT, + source: "updates-bulletin", + }); + + // Self-healing: re-read after the wake. If the agent deleted the file + // (or emptied it), store the empty sentinel. Otherwise, store the + // fresh hash so we don't re-wake on the same content if the agent + // chose to no-op. + const afterTrimmed = readTrimmedContent(updatesPath); + if (afterTrimmed === null || afterTrimmed.length === 0) { + setMemoryCheckpoint(HASH_CHECKPOINT_KEY, EMPTY_HASH); + } else { + setMemoryCheckpoint(HASH_CHECKPOINT_KEY, computeHash(afterTrimmed)); + } + } catch (err) { + log.warn( + { err }, + "update-bulletin-job: wake flow threw; swallowing so callers can fire-and-forget", + ); + return; + } +}