-
Notifications
You must be signed in to change notification settings - Fork 98
feat(assistant): add update-bulletin-job background processor #26399
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, string>(); | ||
| 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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."; | ||
|
siddseethepalli marked this conversation as resolved.
siddseethepalli marked this conversation as resolved.
|
||
|
|
||
| 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<void> { | ||
|
siddseethepalli marked this conversation as resolved.
|
||
| 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; | ||
| } | ||
|
siddseethepalli marked this conversation as resolved.
|
||
|
|
||
| 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; | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.