Background Task Overrides
Every background task runs on the same model, at the same effort level, and shouts its results to everyone. A five-minute mail check gets the same expensive Claude model as a deep code analysis. A task meant for one person notifies all users. A task configured to be silent still blasts failure messages to Telegram.
The post_to field is a three-value enum: 'telegram', 'hud', or 'none'. Only notifyComplete() checks it. The other three notification methods hardcode Telegram. Silence is skin-deep.
Three problems wearing one label: inflexible resources, coarse routing, and a bug that makes 'none' a lie.
Per-task Model and Effort
The simplest fix first. The background_tasks table gains two nullable columns: model and effort. NULL means "use the global config default." A non-null value overrides it.
No separate config table, no inheritance chain, no priority system. The runner checks the task first and falls back to config:
const model = task.model ?? config.string('background.runner.model');
const effort = task.effort ?? config.string('background.runner.effort');
The session factory's type signature changes to accept these as options. It went from (cwd, taskId) => Session to (cwd, taskId, opts?) => Session. That touches create-runner.ts where the factory is defined and runner.ts where it's called – but the null-coalescing chain keeps the fallback logic clean.
The create() store function accepts the new fields and passes them through:
const result = db
.prepare(
'INSERT INTO background_tasks (prompt, project_context, created_at, post_to, hud_entry_type, model, effort) VALUES (?, ?, ?, ?, ?, ?, ?)'
)
.run(
prompt,
projectContext,
now,
postTo,
opts.hudEntryType ?? null,
opts.model ?? null,
opts.effort ?? null
);
This is from
src/data/stores/background-tasks/create.ts
Two nullable columns and a null-coalescing check. That's the whole feature. A scheduled mail check can now run on Haiku at low effort while a deep analysis runs on Opus at high effort – each task carries its own configuration.
Flexible Channels
The old post_to enum – 'telegram', 'hud', 'none' – was replaced entirely with real channel names from the messenger. 'telegram:all', 'telegram:chris', 'hud', 'none'. No backwards-compatibility shim.
This is a clean break. The DB default changed from 'telegram' to 'telegram:all'. Any existing rows with the old 'telegram' value would fail to resolve – acceptable because background task rows are ephemeral. They're created, run, notified, and done. There's no long-lived data to migrate.
Validation happens at creation time. The create() function checks the channel name against the messenger's config:
const postTo = opts.postTo ?? 'telegram:all';
if (postTo !== 'none') {
const validChannels = Object.keys(channels());
if (!validChannels.includes(postTo)) {
throw new Error(
`Invalid post_to channel '${postTo}'. Valid channels: ${validChannels.join(', ')}, none`
);
}
}
This is from
src/data/stores/background-tasks/create.ts
A typo in the channel name throws immediately rather than failing silently when the task finishes. The error message lists every valid channel, so you don't have to guess.
The notifier uses channel names to decide formatting. If the channel is 'hud', the message needs to be a structured HudMessage with type, title, and body. Everything else gets plain text. It's a heuristic – but channel names are controlled vocabulary, and the HUD is the only channel that needs structured messages.
The Notification Bug
This is the fix hiding inside a feature.
Previously, only notifyComplete() checked post_to. The other three methods – notifyFailed(), notifyPaused(), notifyInterrupted() – all sent to Telegram unconditionally. A task with post_to='none' would stay silent on success but shout on failure. A task targeting the HUD would post completions there but send failures to Telegram.
The fix is a private #send() helper that all four methods route through:
#send(postTo: string, text: string, hudOpts?: HudMessage): void {
if (postTo === 'none') {
return;
}
let message: Message;
if (postTo === 'hud' && hudOpts) {
message = hudOpts;
} else {
message = text;
}
this.#messenger.send(postTo, message);
}
This is from
src/background-tasks/notifier.ts
The helper checks post_to once, picks the right format based on channel name, and delegates to the messenger. Every notification method calls #send() with both a plain text version and a HUD-formatted version – the helper decides which one to use based on the channel.
notifyComplete() still has its own implementation because completion messages include the task result and can be longer. But failures, pauses, and interruptions all go through #send():
notifyFailed(task: BackgroundTask): void {
const snippet = task.prompt.slice(0, 80);
const reason = task.result?.slice(0, 300) ?? 'unknown error';
this.#send(
task.post_to,
`❌ Background task failed: ${snippet}\n${reason}`,
{
type: task.hud_entry_type ?? 'background-task',
title: `Task failed: ${snippet}`,
body: reason,
}
);
}
This is from
src/background-tasks/notifier.ts
Silent tasks are now truly silent. Targeted tasks reach only their intended audience. The bug was never dramatic enough to demand its own fix – but bundled with the channel work, it fell out naturally.
Sentinel Expansion
Sentinels are special tokens a task can embed in its output to override its own configuration at runtime. The original system had one: [friday:post_to=...], parsed by a parsePostToOverride() function that looked for the old three-value enum.
The replacement is parseSentinels() – a single function that handles both the updated post_to sentinel and a new hud_entry_type sentinel:
const POST_TO_RE = /\[friday:post_to=([^\]]+)\]/i;
const HUD_ENTRY_TYPE_RE = /\[friday:hud_entry_type=([^\]]+)\]/i;
export function parseSentinels(output: string): SentinelOverrides {
const overrides: SentinelOverrides = {};
const postToMatch = POST_TO_RE.exec(output);
if (postToMatch) {
overrides.postTo = postToMatch[1].trim();
}
const hudMatch = HUD_ENTRY_TYPE_RE.exec(output);
if (hudMatch) {
overrides.hudEntryType = hudMatch[1].trim();
}
return overrides;
}
This is from
src/background-tasks/parse-sentinels.ts
Two regexes, one scan, an object with optional fields. The old parsePostToOverride() was deleted entirely – not wrapped, not deprecated, just replaced.
The runner applies these overrides after the task finishes but before notifying. A monitoring task that usually sends its results to Telegram can emit [friday:post_to=none] when there's nothing interesting to report. A task that discovers it should categorise its result differently can emit [friday:hud_entry_type=mail-check] to override the default HUD entry type.
Validation at the sentinel level is deliberately loose – a bad sentinel logs a warning instead of crashing. The task already ran successfully; a typo in a sentinel token shouldn't kill the notification. Creation-time validation catches the common case. Sentinel validation catches the edge case without punishing the task for it.
Conclusion
A mail check runs on Haiku, posts to one person, and stays silent on failure. A code analysis runs on Opus, posts to the HUD, and notifies everyone. Each task carries its own personality.
The quiet win was the notification bug. 'none' never meant none – failures always escaped to Telegram. Now all four lifecycle events route through #send(), and silence is real.
The messenger abstraction from the previous chapter made this trivial. The notifier already spoke in channel names – widening the vocabulary was a type change, not an architecture change.