From 8128ebcb4a7278828db7d61401998f80bfd6ffb3 Mon Sep 17 00:00:00 2001 From: "Echo (Instar Agent)" Date: Thu, 4 Jun 2026 20:10:37 -0700 Subject: [PATCH] =?UTF-8?q?feat(updates):=20silence=20update=20mechanics?= =?UTF-8?q?=20=E2=80=94=20version/restart=20churn=20=E2=86=92=20logs,=20no?= =?UTF-8?q?t=20the=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Agent Updates topic was flooding with update *mechanics* the user can't use — raw version numbers and restart plumbing ("Just updated to v1.3.217. Restarting…", "vX applied but I'm still running vY", cascade-batch "rolling into the pending restart at HH:MM"). mature-update-announcements (#698) silenced the *feature-announcement* path; this silences the *mechanics* path. New pure module src/core/updateNotifyPolicy.ts classifies every update notification into mechanics | interruption | actionable | failure-escalated; AutoUpdater.notify() and the restart-handshake emit in server.ts gate on it at their single notify funnel. Default kind is `mechanics` (silent) so any future un-audited callsite stays quiet instead of spamming. The user now hears about an update ONLY for: a new capability (the maturity layer), a restart actually interrupting their active work right now (plain, version-free "back in a few seconds"), or a genuinely stuck update. All restart/interruption copy rewritten version-free. Opt into a single quiet "just refreshed in the background" heartbeat with `updates.backgroundRefreshHeartbeat: true` (default false = full silence); it can never re-introduce version churn. Tests: update-notify-policy (both sides of every branch) + update-notify-routing (funnel wiring integrity). Updated notification-spam-prevention, auto-updater-failures, graceful-updates-phase2, update-notification-topic-lock to the new contract (mechanics silent; interruption/actionable version-free). Agent awareness via CLAUDE.md template + PostUpdateMigrator (own content-sniff guard). Spec + ELI16 + side-effects review + release fragment included. Spec: docs/specs/quiet-update-mechanics.md Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/specs/quiet-update-mechanics.eli16.md | 39 +++++ docs/specs/quiet-update-mechanics.md | 112 ++++++++++++++ src/commands/server.ts | 27 +++- src/core/AutoUpdater.ts | 117 +++++++++++--- src/core/PostUpdateMigrator.ts | 17 +++ src/core/types.ts | 8 + src/core/updateNotifyPolicy.ts | 121 +++++++++++++++ src/scaffold/templates.ts | 2 + ...pdateMigrator-quietUpdateMechanics.test.ts | 121 +++++++++++++++ tests/unit/auto-updater-failures.test.ts | 14 +- .../feature-delivery-completeness.test.ts | 1 + tests/unit/graceful-updates-phase2.test.ts | 18 ++- .../unit/notification-spam-prevention.test.ts | 14 +- .../update-notification-topic-lock.test.ts | 10 +- tests/unit/update-notify-policy.test.ts | 77 ++++++++++ tests/unit/update-notify-routing.test.ts | 143 ++++++++++++++++++ upgrades/next/quiet-update-mechanics.md | 57 +++++++ .../side-effects/quiet-update-mechanics.md | 80 ++++++++++ 18 files changed, 930 insertions(+), 48 deletions(-) create mode 100644 docs/specs/quiet-update-mechanics.eli16.md create mode 100644 docs/specs/quiet-update-mechanics.md create mode 100644 src/core/updateNotifyPolicy.ts create mode 100644 tests/unit/PostUpdateMigrator-quietUpdateMechanics.test.ts create mode 100644 tests/unit/update-notify-policy.test.ts create mode 100644 tests/unit/update-notify-routing.test.ts create mode 100644 upgrades/next/quiet-update-mechanics.md create mode 100644 upgrades/side-effects/quiet-update-mechanics.md diff --git a/docs/specs/quiet-update-mechanics.eli16.md b/docs/specs/quiet-update-mechanics.eli16.md new file mode 100644 index 000000000..bb60f3dba --- /dev/null +++ b/docs/specs/quiet-update-mechanics.eli16.md @@ -0,0 +1,39 @@ +# Quiet Update Mechanics — Plain-English Overview + +> The one-line version: update messages full of version numbers and restart plumbing now go to the logs instead of buzzing the user — they only hear about an update when something is genuinely new, a restart is about to interrupt them, or an update is truly stuck. + +## The problem in one breath + +The agent's "Updates" channel was filling up with messages the user can't use: "Just updated to v1.3.217. Restarting…", "v1.3.217 was applied but I'm still running v1.3.218, the next restart should pick it up", "rolling into the pending restart at 02:42". To the user these read as meaningless version churn — exactly the "notifications that reference things I have no clue about" they complained about. None of it is something they need to read or act on. + +## What already exists + +- **The maturity layer (shipped as #698)** — makes *feature announcements* silent-by-default and honest about how finished a feature is (Experimental / Preview / Stable). It only governs "here's a new capability" messages, NOT restart/version status. +- **The auto-updater + restart handshake** — the machinery that downloads an update, restarts the server to load it, verifies the new version booted, and coordinates so two updates don't cause two back-to-back restarts. Along the way it emits a dozen hardcoded status messages — and those are what leaked to the user. +- **The Agent Updates topic** — the dedicated channel all of these messages route to. + +## What this adds + +A single rule that sorts every update message into one of four buckets before it can reach the user: **mechanics** (version/restart churn — goes to the logs), **interruption** (a restart is hitting the user's active work right now — they hear a plain "back in a few seconds"), **actionable** (auto-updates are off, so they must say "update"), and **stuck** (an update genuinely failed after retries). Only the last three reach the user, and all the restart/interruption wording was rewritten to drop version numbers entirely. + +Secondary changes: the default bucket is "mechanics" (silent), so any future update message a developer forgets to classify stays quiet instead of accidentally spamming. And there's an opt-in flag for people who'd rather get one quiet "just refreshed in the background" note than total silence. + +## The new pieces + +- **`updateNotifyPolicy`** — a tiny, pure decision function: given a message's bucket, it answers "does this reach the user, yes or no?" It does no I/O and holds no state, so it's easy to prove correct on every branch. It is NOT allowed to send anything itself or know anything about Telegram — it only decides; the caller sends. That line matters because it keeps the "who hears what" rule in one tested place instead of scattered across a dozen message sites. + +## The safeguards + +**Prevents the version-churn flood from coming back.** The opt-in "background heartbeat" flag can only ever surface ONE specific message (the post-restart "I'm current" note). Every other mechanics message stays silent even with the flag on, so the flag can't be a backdoor that reopens the flood. + +**Prevents accidental future spam.** Because the default bucket is "silent mechanics," a new update message added later is quiet unless someone deliberately marks it user-facing. Forgetting is safe; the failure mode points at silence, not noise. + +**Prevents losing a genuinely-important signal.** A restart that actually interrupts the user, and an update that's truly stuck after retries, still reach them — just without the version jargon. We did not silence everything; we silenced the noise. + +## What ships when + +One change, one PR: the policy module, the classification wired through the auto-updater and the restart handshake, the version-free message rewrites, the config flag, full unit tests, the agent-awareness note (template + migration), and the release fragment. It ships on the next npm update; existing agents get the behavior automatically and the awareness note through the migration. + +## What you actually need to decide + +You already decided: option A (full silence for no-op updates), which is the default this ships with — do you agree this is clear to ship? diff --git a/docs/specs/quiet-update-mechanics.md b/docs/specs/quiet-update-mechanics.md new file mode 100644 index 000000000..db32cf6a2 --- /dev/null +++ b/docs/specs/quiet-update-mechanics.md @@ -0,0 +1,112 @@ +# Spec — Quiet Update Mechanics + +**Status:** shipped +**Parent principle:** Near-Silent Notifications (housekeeping → logs, not the user) +**Sibling:** `mature-update-announcements.md` (the *feature-announcement* layer; this is the *mechanics* layer) + +## Problem + +The Agent Updates topic was flooding with update **mechanics** — raw version +numbers and restart plumbing the user has no use for. Real messages observed in +a live Updates topic: + +- `Just updated to v1.3.181. Restarting to pick up the changes.` +- `Update to v1.3.183 was applied but I'm still running v1.3.184. The next restart should pick it up.` +- `Update v1.3.215 queued — rolling into the pending restart at 02:21 (about 10m)…` +- `Update to v1.3.217 was applied but I'm still running v1.3.218. The next restart should pick it up.` + +None of this is user-relevant. It is operational status that leaked into a +user-facing topic, and it reads as meaningless version churn — "notifications +that reference features the user has no clue about" (user feedback, 2026-06-04). + +`mature-update-announcements` (PR #698) made the **feature-announcement** path +silent-by-default and maturity-honest, but it never touched the **mechanics** +path — these messages are not announcements, they are hardcoded restart/version +status emitted from `AutoUpdater` and the restart handshake. So the noise +remained. + +## Policy (option A — full silence, the default) + +The user hears about an update only when one of these is true: + +1. **A genuinely new capability shipped** — governed by the `user_announcement` + front-matter layer (`mature-update-announcements`), NOT by this module. +2. **A restart is actually interrupting their active work right now** + (`interruption`) — a plain, **version-free** heads-up ("back in a few + seconds"), never "v1.3.X". +3. **They must take an action** (`actionable`) — e.g. auto-apply is off and a + manual update is available. +4. **An update is genuinely stuck after retries** (`failure-escalated`) — the + restart isn't taking the new code after repeated attempts. + +Everything else — version churn, restart-batch coordination, transient version +skew that self-heals on the next restart, a transient apply failure that retries +next cycle — is `mechanics` and goes to the **logs only**. + +### Option B — background-refresh heartbeat (opt-in) + +Some operators prefer a single quiet "I just refreshed in the background, I'm +current" note over total silence. Opt in with +`updates.backgroundRefreshHeartbeat: true` in `.instar/config.json` (default +`false` = option A). It surfaces ONLY the post-restart background-refresh +confirmation as a plain, version-free line; every other `mechanics` event stays +silent regardless, so the flag can never re-introduce the version-churn flood. + +## Design — single classification at the funnel ("Structure > Willpower") + +A pure policy module, `src/core/updateNotifyPolicy.ts`, exports +`decideUpdateNotify(kind, opts) → { reachUser, reason }` over four kinds: +`mechanics | interruption | actionable | failure-escalated`. + +`AutoUpdater.notify(message, kind = 'mechanics', opts)` consults the policy at +the single notify funnel: a non-`reachUser` decision logs and returns; otherwise +it sends to the Updates topic exactly as before. The **default kind is +`mechanics`**, so any future, un-audited `notify()` callsite is silent by default +rather than accidentally spamming the user. The restart handshake's failure emit +in `src/commands/server.ts` applies the same policy (non-escalated mismatch → +silent; escalated → reaches the user, version-free). + +### Callsite map + +| Source | Old message (gist) | Kind | Reaches user? | +|---|---|---|---| +| `AutoUpdater` version-skew nudge | "vX downloaded, still running vY" | `mechanics` | no (silent) | +| `AutoUpdater` manual-update-available (auto-apply off) | "new version vX available, say update" | `actionable` | yes (version-free) | +| `AutoUpdater` transient apply failure | "tried vX, didn't work, retry next cycle" | `mechanics` | no (silent) | +| `AutoUpdater` max-deferral forced restart | "deferred… max wait reached, restarting now" | `interruption` | yes (version-free) | +| `AutoUpdater` restart narration (active sessions) | "Just updated to vX. Restarting…" | `interruption` | yes (version-free) | +| `AutoUpdater` idle restart | (was silent) | `mechanics` + bg-confirmation | only under option B | +| `AutoUpdater` deferral threshold warnings | "vX installed, restart in ~5m / ~30m" | `interruption` | yes (version-free) | +| `AutoUpdater` cascade-batch | "rolling into the pending restart at HH:MM" | `mechanics` | no (silent) | +| `server.ts` handshake mismatch (non-escalated) | "vX applied but still running vY" | `mechanics` | no (silent) | +| `server.ts` handshake mismatch (escalated) | "restart didn't pick up the new code" | `failure-escalated` | yes (version-free) | + +Patch-only restart narration remains suppressed (Fork 3 of +`mature-update-announcements`); the handshake still runs for verification. + +## Tests + +- `tests/unit/update-notify-policy.test.ts` — pure policy, both sides of every + branch (silent vs reach-user, heartbeat on/off, confirmation vs not, unknown + kind → silent fail-safe). +- `tests/unit/update-notify-routing.test.ts` — wiring integrity: a real + `AutoUpdater.notify()` of kind `mechanics` never calls `telegram.sendToTopic`, + while `interruption`/`actionable`/`failure-escalated` do; option-B gating. +- Updated `notification-spam-prevention`, `auto-updater-failures`, + `graceful-updates-phase2`, `update-notification-topic-lock` to assert the new + contract (mechanics silent; interruption/actionable version-free). + +## Migration parity + +- **Code-only behavior change** — the silencing ships in `AutoUpdater` / + `server.ts` / `updateNotifyPolicy`, so existing agents get it on npm update; no + config migration is required (absence of `backgroundRefreshHeartbeat` = option + A = the desired default). +- **Agent awareness** — a "Quiet update mechanics" block is added to the + CLAUDE.md template (`generateClaudeMd`) and backfilled to existing agents via + `PostUpdateMigrator.migrateClaudeMd()` under its own content-sniff guard + (separate from the maturity-honesty marker, so agents that already have the + maturity section still receive this one). +- **Type** — `UpdateConfig.backgroundRefreshHeartbeat?: boolean` and + `AutoUpdaterConfig.backgroundRefreshHeartbeat?: boolean`, wired through the + `server.ts` construction. diff --git a/src/commands/server.ts b/src/commands/server.ts index 6d24d82a1..6f2007b9c 100644 --- a/src/commands/server.ts +++ b/src/commands/server.ts @@ -40,6 +40,7 @@ import { lifelineOwnsPoll as lifelineOwnsTelegramPoll } from '../lifeline/Telegr import { DispatchManager } from '../core/DispatchManager.js'; import { UpdateChecker } from '../core/UpdateChecker.js'; import { AutoUpdater } from '../core/AutoUpdater.js'; +import { decideUpdateNotify } from '../core/updateNotifyPolicy.js'; import { UpdateRestartHandshake, verifyRestartHandshake } from '../core/UpdateRestartHandshake.js'; import { AutoDispatcher } from '../core/AutoDispatcher.js'; import { DispatchExecutor } from '../core/DispatchExecutor.js'; @@ -5475,18 +5476,28 @@ export async function startServer(options: StartOptions): Promise { } restartHandshake.clearHandshake(); } else if (outcome.kind === 'failed') { - const msg = outcome.escalate - ? `Heads up — I tried to update to v${outcome.expectedVersion} but the restart didn't pick up the new code. Still running v${outcome.runningVersion}. Retry ${outcome.retryCount}.` - : `Update to v${outcome.expectedVersion} was applied but I'm still running v${outcome.runningVersion}. The next restart should pick it up.`; + // A non-escalated mismatch is the transient, self-healing case the user + // flagged as noise ("vX applied but I'm still running vY — the next + // restart should pick it up"). It's update mechanics → log only. Only an + // ESCALATED failure (the restart genuinely isn't taking the new code + // after repeated retries) reaches the user, and version-free. + const kind = outcome.escalate ? 'failure-escalated' : 'mechanics'; + const userMsg = + `Heads up — I tried to apply an update but the restart didn't take, so I'm still on the previous version. ` + + `I'll keep retrying automatically; flagging it in case it persists.`; + const logMsg = + `[restart-handshake] failed (escalate=${outcome.escalate}, retry=${outcome.retryCount}): ` + + `expected v${outcome.expectedVersion}, running v${outcome.runningVersion}`; + const decision = decideUpdateNotify(kind); const topicId = state.get('agent-updates-topic') || 0; - if (telegram && topicId) { + if (decision.reachUser && telegram && topicId) { try { - await telegram.sendToTopic(topicId, msg); + await telegram.sendToTopic(topicId, userMsg); } catch (err) { console.warn(`[restart-handshake] failure notification failed: ${err instanceof Error ? err.message : String(err)}`); } } else { - console.warn(`[restart-handshake] ${msg}`); + console.warn(`${logMsg} — ${decision.reason}`); } } } catch (err) { @@ -5509,6 +5520,10 @@ export async function startServer(options: StartOptions): Promise { // Primary-developer mode (per-agent opt-in) — never defer a restart for // active sessions or the restart window; always roll onto the latest. restartImmediately: config.updates?.restartImmediately ?? false, + // Option B for update messaging — surface a single quiet "refreshed in + // the background" heartbeat instead of full silence. Default false + // (= option A, full silence). Spec: docs/specs/quiet-update-mechanics.md. + backgroundRefreshHeartbeat: config.updates?.backgroundRefreshHeartbeat ?? false, // codex-instar audit Item 4 — wire the handshake into AutoUpdater so // the pre-restart notification is DEFERRED into the marker file. restartHandshake, diff --git a/src/core/AutoUpdater.ts b/src/core/AutoUpdater.ts index 88e90a5e5..689f91c42 100644 --- a/src/core/AutoUpdater.ts +++ b/src/core/AutoUpdater.ts @@ -31,6 +31,10 @@ import { SafeFsExecutor } from './SafeFsExecutor.js'; import { crossesBreaking, writeLifelineRestartSignal } from './version-skew.js'; import { RestartCascadeDampener, formatLocalTimeHHMM } from './RestartCascadeDampener.js'; import type { UpdateRestartHandshake } from './UpdateRestartHandshake.js'; +import { + decideUpdateNotify, + type UpdateNotifyKind, +} from './updateNotifyPolicy.js'; export interface AutoUpdaterConfig { /** How often to check for updates, in minutes. Default: 30 */ @@ -79,6 +83,16 @@ export interface AutoUpdaterConfig { * Spec: docs/specs/restart-immediately-spec.md. */ restartImmediately?: boolean; + /** + * Option B for user-facing update messaging (per-agent opt-in via + * `updates.backgroundRefreshHeartbeat`). When true, the single post-restart + * "I just refreshed in the background, I'm current" confirmation surfaces as a + * quiet, version-free heartbeat instead of being fully silent. Every other + * update-mechanics notification stays silent regardless. Default false + * (= option A, full silence — version/restart churn goes to the logs only). + * Spec: docs/specs/quiet-update-mechanics.md. + */ + backgroundRefreshHeartbeat?: boolean; } export interface AutoUpdaterStatus { @@ -197,6 +211,7 @@ export class AutoUpdater { restartWindow: config?.restartWindow ?? null, restartCascadeDampenerWindowMs: config?.restartCascadeDampenerWindowMs ?? 15 * 60_000, restartImmediately: config?.restartImmediately ?? false, + backgroundRefreshHeartbeat: config?.backgroundRefreshHeartbeat ?? false, // codex-instar audit Item 4 — Required demands every field, so we // coerce undefined to undefined explicitly; consumers branch on // truthiness in gatedRestart. @@ -405,15 +420,18 @@ export class AutoUpdater { this.notifiedVersionMismatch = info.latestVersion; // Check if restart is actively deferred — if so, clarify that's the reason if (deferral) { + // Mechanics: version skew self-heals on the next restart. Housekeeping. await this.notify( `v${info.latestVersion} is downloaded and waiting for a restart — still running v${info.currentVersion}. ` + `Restart is being held back by ${deferral.reason}. ` + - `I'll switch over automatically once they finish.` + `I'll switch over automatically once they finish.`, + 'mechanics', ); } else { await this.notify( `v${info.latestVersion} is downloaded but the process hasn't restarted yet — still running v${info.currentVersion}. ` + - `A server restart will activate the new version.` + `A server restart will activate the new version.`, + 'mechanics', ); } } @@ -433,10 +451,12 @@ export class AutoUpdater { // Notify with actionable instructions — don't leave the user hanging // Only notify once per detected version (avoid spam on every tick) if (!this.coalescingUntil) { + // Actionable: auto-updates are off, so the user must trigger this one. + // Version-free — the number is noise; the action is what matters. await this.notify( - `There's a new version available (v${info.latestVersion}). I'm currently on v${info.currentVersion}.\n\n` + - `Auto-updates are off. Just say "update" or "apply the update" and I'll handle it. ` + - `Or to turn on auto-updates so this happens automatically, say "turn on auto-updates".` + `There's a new update available, but auto-updates are off so it needs your go-ahead. ` + + `Just say "update" and I'll apply it — or say "turn on auto-updates" and I'll handle these automatically from now on.`, + 'actionable', ); // Set coalescingUntil as a "notified" marker to prevent re-notification this.coalescingUntil = 'notified'; @@ -508,10 +528,15 @@ export class AutoUpdater { this.lastError = result.message; this.saveState(); console.error(`[AutoUpdater] Update failed: ${result.message}`); + // Mechanics: a transient apply failure that self-heals on the next + // cycle is not something the user needs to know about — it's not broken, + // nothing for them to do. Housekeeping. (A genuinely STUCK update, after + // the restart-verification retries are exhausted, surfaces as + // 'failure-escalated' from the restart handshake in server.ts.) await this.notify( - `Heads up — I tried to update to v${targetVersion} but it didn't work out. ` + - `I'm still running fine on v${result.previousVersion}, so nothing's broken. ` + - `I'll try again next cycle.` + `Tried to update to v${targetVersion} but the apply didn't work out. ` + + `Still running fine on v${result.previousVersion}; will retry next cycle.`, + 'mechanics', ); return; } @@ -724,9 +749,11 @@ export class AutoUpdater { const hasRunningSessions = runningSessions.length > 0; if (result.reason?.includes('Max deferral')) { - // Forced restart after max deferral — user needs to know + // Interruption: a forced restart is happening right now. The user is + // genuinely interrupted, so they hear about it — plainly, no version churn. await this.notify( - `Update to v${newVersion} was deferred for active sessions, but the maximum wait has been reached. Restarting now.` + `Heads up — I need to restart to finish applying an update. I held off while you had work running, but I can't wait any longer, so I'm restarting now. Back in a few seconds.`, + 'interruption', ); } else if (hasRunningSessions) { // Sessions exist but aren't blocking — user needs a heads-up. @@ -757,9 +784,13 @@ export class AutoUpdater { // is holding a restart" stays genuinely useful. crossesBreaking() // === false ⇒ same major.minor ⇒ patch-only; malformed ⇒ true (narrate). const patchOnly = !crossesBreaking(previousVersion, newVersion); + // Interruption (sessions are active): the user is being restarted, so + // they get a plain, version-free heads-up — never "v1.3.X". Patch-only + // bumps stay silent (Fork 3) but still write the handshake so restart + // verification + failed-restart escalation run for patch updates too. const restartNote = patchOnly ? '' - : `Just updated to v${newVersion}. Restarting to pick up the changes.`; + : `Heads up — I applied a quick update and need to restart to pick it up. Back in a few seconds.`; if (this.config.restartHandshake) { try { this.config.restartHandshake.writePendingHandshake({ @@ -773,10 +804,10 @@ export class AutoUpdater { console.warn( `[AutoUpdater] Handshake write failed; falling back to immediate notify: ${err instanceof Error ? err.message : String(err)}`, ); - if (restartNote) await this.notify(restartNote); + if (restartNote) await this.notify(restartNote, 'interruption'); } } else if (restartNote) { - await this.notify(restartNote); + await this.notify(restartNote, 'interruption'); } if (patchOnly) { console.log( @@ -791,9 +822,18 @@ export class AutoUpdater { await new Promise(r => setTimeout(r, delaySecs * 1000)); } } else { - // No active sessions — silent restart. Don't notify the user. - // Updates should be invisible when nobody's working. + // No active sessions — the restart is invisible; nobody's working. + // Option A (default): fully silent. Option B + // (updates.backgroundRefreshHeartbeat): a single quiet, version-free + // "I refreshed in the background" note so the user knows I'm current. + // The policy keeps this silent unless the heartbeat flag is on, so this + // call can never re-introduce version churn. console.log(`[AutoUpdater] Silent restart — no active sessions (updating to v${newVersion})`); + await this.notify( + `Just refreshed in the background — I'm up to date and ready.`, + 'mechanics', + { isBackgroundRefreshConfirmation: true }, + ); } // CRITICAL: Save state BEFORE requesting restart. The process may exit // immediately after requestRestart (ForegroundRestartWatcher picks up the @@ -820,15 +860,19 @@ export class AutoUpdater { nextRetryAt: new Date(Date.now() + (result.retryInMs ?? 300_000)).toISOString(), }); - // Send warnings at thresholds + // Send warnings at thresholds. Interruption: a restart that WILL interrupt + // the user's active work is exactly the case they want to hear about — but + // plainly and version-free ("your work is holding a restart"), not as + // "v1.3.X installed". if (this.gate.shouldSendFinalWarning()) { await this.notify( - `Update to v${newVersion} installed. Server will restart in ~5 minutes regardless of active sessions.` + `Heads up — I have an update ready and I'll need to restart in about 5 minutes, even if work is still running. A good moment to wrap up anything you don't want interrupted.`, + 'interruption', ); } else if (this.gate.shouldSendFirstWarning()) { await this.notify( - `Update to v${newVersion} installed but restart is being deferred for ${result.blockingSessions?.length} active session(s). ` + - `Will force restart in ~30 minutes if sessions don't finish.` + `Heads up — I have an update ready, but I'm holding the restart while you've got work running. If it's still going in about 30 minutes I'll go ahead and restart.`, + 'interruption', ); } @@ -882,9 +926,15 @@ export class AutoUpdater { : false; if (hasActive && this.lastNotifiedRestartVersion !== newVersion) { this.lastNotifiedRestartVersion = newVersion; + // Mechanics: restart-batching coordination is internal plumbing — the + // user flagged "rolling into the pending restart at HH:MM" as exactly + // the noise they don't want. The actual interruption heads-up (if a + // restart will interrupt active work) comes from the threshold warnings + // in gatedRestart. Housekeeping. await this.notify( `Update v${newVersion} queued — rolling into the pending restart at ` + - `${formatLocalTimeHHMM(eligibleAt, fireAt)} (about ${Math.max(1, Math.round(waitMs / 60_000))}m) so you don't get hit by two back-to-back restarts.` + `${formatLocalTimeHHMM(eligibleAt, fireAt)} (about ${Math.max(1, Math.round(waitMs / 60_000))}m) so two restarts coalesce into one.`, + 'mechanics', ); } @@ -1011,10 +1061,31 @@ export class AutoUpdater { } /** - * Send a notification via Telegram (if configured). - * Falls back to console logging if Telegram is not available. + * Send an update notification — but only when the policy says it should reach + * the user. Update *mechanics* (version churn, restart coordination, + * self-healing version skew) are housekeeping and go to the logs only; the + * user hears about an update only when a restart is interrupting them + * (`interruption`), they must act (`actionable`), or an update is genuinely + * stuck (`failure-escalated`). See updateNotifyPolicy.ts. + * + * Defaults to `mechanics` so any future, un-audited notify() callsite stays + * silent rather than accidentally spamming the Updates topic. */ - private async notify(message: string): Promise { + private async notify( + message: string, + kind: UpdateNotifyKind = 'mechanics', + opts: { isBackgroundRefreshConfirmation?: boolean } = {}, + ): Promise { + const decision = decideUpdateNotify(kind, { + backgroundRefreshHeartbeat: this.config.backgroundRefreshHeartbeat, + isBackgroundRefreshConfirmation: opts.isBackgroundRefreshConfirmation, + }); + if (!decision.reachUser) { + // Housekeeping — log only, never the user's Updates topic. + console.log(`[AutoUpdater] (silent · ${kind}: ${decision.reason}) ${message}`); + return; + } + const formatted = message; if (this.telegram) { diff --git a/src/core/PostUpdateMigrator.ts b/src/core/PostUpdateMigrator.ts index dfe5ad388..c99fc2266 100644 --- a/src/core/PostUpdateMigrator.ts +++ b/src/core/PostUpdateMigrator.ts @@ -2824,6 +2824,23 @@ User-facing update announcements are *opt-in and maturity-tagged*, authored in t result.upgraded.push('CLAUDE.md: added Maturity honesty (silent-by-default user announcements) section'); } + // Quiet update mechanics (quiet-update-mechanics spec). The maturity layer + // above silenced the *feature-announcement* path; this silences the *update + // mechanics* path (version churn + restart coordination). Existing agents + // need to know so they mirror it when self-narrating a restart/update. + // Separate guard from the maturity-honesty marker so agents that ALREADY + // have the maturity section still receive this newer block. + if (!content.includes('Quiet update mechanics (version/restart churn')) { + const quietMechanicsSection = ` +### Quiet update mechanics (version/restart churn → logs, not the user) + +Update *mechanics* are housekeeping and never reach the user's Updates topic — raw version numbers, "Just updated to vX. Restarting…", "vX applied but I'm still running vY", cascade-batch "rolling into the pending restart at HH:MM", and transient self-healing apply failures all go to the logs only. The user hears about an update ONLY when (1) a genuinely new capability ships (the maturity layer above), (2) a restart is actually interrupting their active work right now — a plain, *version-free* "back in a few seconds" heads-up, never "v1.3.X", or (3) an update is genuinely stuck after retries. Opt into a single quiet "just refreshed in the background" heartbeat with \`updates.backgroundRefreshHeartbeat: true\` in \`.instar/config.json\` (default false = full silence); it can never re-introduce version churn. When I narrate my OWN restart/update (via \`/telegram/post-update\`), I follow the same rule: no version numbers, no restart plumbing — a human "back now" only if it actually mattered to the user. Spec: \`docs/specs/quiet-update-mechanics.md\`. +`; + content += '\n' + quietMechanicsSection; + patched = true; + result.upgraded.push('CLAUDE.md: added Quiet update mechanics section'); + } + // Close the Loop (Untracked = Abandoned) — STANDARDS-REGISTRY amendment // ratified with Justin 2026-05-31. The "nothing slips through the cracks" // principle was made a constitutional standard; existing agents need the diff --git a/src/core/types.ts b/src/core/types.ts index 6f0bdbc2b..8ff89b6f7 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -3010,6 +3010,14 @@ export interface UpdateConfig { * instar developer's own agent, not the general fleet. * Spec: docs/specs/restart-immediately-spec.md. */ restartImmediately?: boolean; + /** Option B for user-facing update messaging (per-agent opt-in; default false + * = option A, full silence). When true, the single post-restart "I just + * refreshed in the background, I'm current" confirmation surfaces as a quiet, + * version-free heartbeat instead of being silent. Update *mechanics* (raw + * version numbers, restart coordination, self-healing version skew) are + * housekeeping and go to the logs only regardless of this flag — it can never + * re-introduce the version-churn flood. Spec: docs/specs/quiet-update-mechanics.md. */ + backgroundRefreshHeartbeat?: boolean; } export interface MessagingAdapterConfig { diff --git a/src/core/updateNotifyPolicy.ts b/src/core/updateNotifyPolicy.ts new file mode 100644 index 000000000..4a116ddd4 --- /dev/null +++ b/src/core/updateNotifyPolicy.ts @@ -0,0 +1,121 @@ +/** + * Update-notification policy — decides which auto-update notifications reach + * the user's Updates topic and which are housekeeping (logs only). + * + * ## Why this exists + * + * The Agent Updates topic was flooding with update *mechanics* — raw version + * numbers and restart plumbing the user has no use for: + * + * "Just updated to v1.3.217. Restarting to pick up the changes." + * "Update to v1.3.217 was applied but I'm still running v1.3.218 — + * the next restart should pick it up." + * "Update v1.3.215 queued — rolling into the pending restart at 02:21…" + * + * None of that is user-relevant; it is operational status that leaked into a + * user-facing topic. The `user_announcement` / maturity layer + * (mature-update-announcements spec) made the *feature-announcement* path + * silent-by-default. This module does the same for the *mechanics* path. + * + * ## Policy (option A — full silence, the default) + * + * The user hears about an update only when one of these is true: + * 1. A genuinely new capability shipped — governed ELSEWHERE by the + * `user_announcement` front-matter layer, not by this module. + * 2. A restart is actually interrupting them right now (`interruption`). + * 3. They must take an action — e.g. a manual update is available and + * auto-apply is off (`actionable`). + * 4. An update is genuinely stuck after retries (`failure-escalated`). + * + * Everything else — version churn, restart coordination, transient version + * skew that self-heals on the next restart — is `mechanics` and goes to the + * logs only. + * + * ## Option B — background-refresh heartbeat + * + * Some operators prefer a single, quiet "I just refreshed in the background" + * note over total silence. That is opt-in via + * `updates.backgroundRefreshHeartbeat` (default false = option A). When on, it + * surfaces ONLY the post-restart background-refresh confirmation as a plain, + * version-free line; every other `mechanics` event stays silent regardless, so + * the flag can never re-introduce the version-churn flood. + * + * Pure module — no I/O, no side effects — so it is trivially unit-testable on + * both sides of every branch. + */ + +export type UpdateNotifyKind = + | 'mechanics' + | 'interruption' + | 'actionable' + | 'failure-escalated'; + +export interface UpdateNotifyDecision { + /** True ⇒ send to the user's Updates topic. False ⇒ log only (housekeeping). */ + reachUser: boolean; + /** Human-readable rationale — surfaced in logs for the silent path. */ + reason: string; +} + +export interface UpdateNotifyPolicyOptions { + /** + * Option B: when true, the single background-refresh confirmation is allowed + * to surface as a quiet heartbeat instead of being fully silent. Default + * false (= option A, full silence). Has no effect on any other `mechanics` + * event. + */ + backgroundRefreshHeartbeat?: boolean; + /** + * True only for the specific post-restart "I'm refreshed and current" event. + * The heartbeat flag is honored ONLY when this is set — every other + * `mechanics` event ignores the flag and stays silent. + */ + isBackgroundRefreshConfirmation?: boolean; +} + +/** + * Decide whether an update notification of the given kind should reach the + * user. See the module header for the full policy. + */ +export function decideUpdateNotify( + kind: UpdateNotifyKind, + opts: UpdateNotifyPolicyOptions = {}, +): UpdateNotifyDecision { + switch (kind) { + case 'interruption': + return { + reachUser: true, + reason: 'a restart is interrupting the user right now', + }; + case 'actionable': + return { + reachUser: true, + reason: 'the user must take an action (e.g. apply a manual update)', + }; + case 'failure-escalated': + return { + reachUser: true, + reason: 'an update is genuinely stuck after retries', + }; + case 'mechanics': + if (opts.backgroundRefreshHeartbeat && opts.isBackgroundRefreshConfirmation) { + return { + reachUser: true, + reason: 'background-refresh heartbeat enabled (option B)', + }; + } + return { + reachUser: false, + reason: 'update mechanics — housekeeping, logs only', + }; + default: { + // Exhaustiveness guard: an unhandled kind defaults to silent so a new, + // unaudited notification can never accidentally spam the user. + const _never: never = kind; + return { + reachUser: false, + reason: `unknown notify kind (${String(_never)}) — defaulting to silent`, + }; + } + } +} diff --git a/src/scaffold/templates.ts b/src/scaffold/templates.ts index 2b373aa43..54741c875 100644 --- a/src/scaffold/templates.ts +++ b/src/scaffold/templates.ts @@ -490,6 +490,8 @@ This routes feedback to the Instar maintainers automatically. Valid types: \`bug - **When to use** (PROACTIVE — this is the trigger): the moment I am about to author a conversational message whose subject is *me* shipping, updating, or restarting — including post-restart "I'm back" confirmations — I use this endpoint. Authoring such a message via the standard Telegram reply path puts release chatter into whatever conversation the user was last in, which is the bug this routing closes. - **Maturity honesty (silent-by-default user announcements)**: user-facing update announcements are *opt-in and maturity-tagged*, authored in the release's upgrade guide (\`user_announcement\` front-matter: each change is \`audience: user|agent-only\` + \`maturity: experimental|preview|stable\`). The post-update notifier stays SILENT unless a change was explicitly promoted to \`audience: user\`, and experimental/preview features are announced as such (⚗️ Experimental / 🧪 Preview) — never implied to be finished. When I narrate my own ship here, I mirror that honesty: I do NOT announce a feature that ships dark/disabled as if it works, and I don't dress up an infra change as a finished capability. Patch-level "restarting…" notices are suppressed (only deferral warnings — "your work is holding a restart" — still surface). +- **Quiet update mechanics (version/restart churn → logs, not the user)**: update *mechanics* are housekeeping and never reach the user's Updates topic — raw version numbers, "Just updated to vX. Restarting…", "vX applied but I'm still running vY", cascade-batch "rolling into the pending restart at HH:MM", and transient self-healing apply failures all go to the logs only. The user hears about an update ONLY when (1) a genuinely new capability ships (the maturity layer above), (2) a restart is actually interrupting their active work right now — a plain, *version-free* "back in a few seconds" heads-up, never "v1.3.X", or (3) an update is genuinely stuck after retries. Opt into a single quiet "just refreshed in the background" heartbeat with \`updates.backgroundRefreshHeartbeat: true\` in \`.instar/config.json\` (default false = full silence); it can never re-introduce version churn. When I narrate my OWN restart/update here, I follow the same rule: no version numbers, no restart plumbing — a human "back now" only if it actually mattered to the user. Spec: \`docs/specs/quiet-update-mechanics.md\`. + **Secret Drop** — Securely collect secrets (API keys, passwords, tokens) from users without exposing them in chat history. - Request a secret: \`curl -X POST -H "Authorization: Bearer $AUTH" http://localhost:${port}/secrets/request -H 'Content-Type: application/json' -d '{"label":"OpenAI API Key","description":"Needed for GPT integration","topicId":TOPIC_ID}'\` - The response includes a one-time URL (\`localUrl\` and \`tunnelUrl\`). Send this link to the user. diff --git a/tests/unit/PostUpdateMigrator-quietUpdateMechanics.test.ts b/tests/unit/PostUpdateMigrator-quietUpdateMechanics.test.ts new file mode 100644 index 000000000..ae1618ba3 --- /dev/null +++ b/tests/unit/PostUpdateMigrator-quietUpdateMechanics.test.ts @@ -0,0 +1,121 @@ +/** + * Verifies PostUpdateMigrator backfills the "Quiet update mechanics" guidance + * into existing agents' CLAUDE.md on update (quiet-update-mechanics spec — + * Migration Parity Standard). + * + * The behavior change (silencing update mechanics) ships in code, so existing + * agents get it on npm update. But the AWARENESS — that the agent must mirror + * the same rule when self-narrating its OWN restart/update (no version numbers, + * no restart plumbing) — only reaches existing agents through this migration. It + * uses its OWN content-sniff guard, separate from the maturity-honesty marker, + * so an agent that already has the maturity section still receives this block. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { PostUpdateMigrator } from '../../src/core/PostUpdateMigrator.js'; +import { SafeFsExecutor } from '../../src/core/SafeFsExecutor.js'; + +type MigrationResult = { upgraded: string[]; skipped: string[]; errors: string[] }; + +function newMigrator(projectDir: string): PostUpdateMigrator { + return new PostUpdateMigrator({ + projectDir, + stateDir: path.join(projectDir, '.instar'), + port: 4042, + hasTelegram: false, + projectName: 'test', + }); +} + +function runClaudeMdMigration(migrator: PostUpdateMigrator): MigrationResult { + const result: MigrationResult = { upgraded: [], skipped: [], errors: [] }; + (migrator as unknown as { migrateClaudeMd(r: MigrationResult): void }).migrateClaudeMd(result); + return result; +} + +const GUARD = 'Quiet update mechanics (version/restart churn'; +const HEADING = 'Quiet update mechanics (version/restart churn → logs, not the user)'; + +describe('PostUpdateMigrator — quiet-update-mechanics CLAUDE.md section', () => { + let projectDir: string; + let claudeMdPath: string; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'instar-quiet-')); + fs.mkdirSync(path.join(projectDir, '.instar'), { recursive: true }); + claudeMdPath = path.join(projectDir, 'CLAUDE.md'); + }); + + afterEach(() => { + SafeFsExecutor.safeRmSync(projectDir, { + recursive: true, + force: true, + operation: 'tests/unit/PostUpdateMigrator-quietUpdateMechanics.test.ts:cleanup', + }); + }); + + it('adds the section when CLAUDE.md does not already contain it', () => { + fs.writeFileSync(claudeMdPath, '# CLAUDE.md\n\nMy existing CLAUDE.md.\n'); + + const result = runClaudeMdMigration(newMigrator(projectDir)); + + expect(result.errors).toEqual([]); + expect(result.upgraded.some(u => u.includes('Quiet update mechanics'))).toBe(true); + + const after = fs.readFileSync(claudeMdPath, 'utf-8'); + expect(after).toContain(HEADING); + expect(after).toContain('version-free'); + expect(after).toContain('backgroundRefreshHeartbeat'); + expect(after).toContain('quiet-update-mechanics.md'); + }); + + it('is idempotent — re-running does not add a duplicate section', () => { + fs.writeFileSync(claudeMdPath, '# CLAUDE.md\n\nMy existing CLAUDE.md.\n'); + + runClaudeMdMigration(newMigrator(projectDir)); + const afterFirst = fs.readFileSync(claudeMdPath, 'utf-8'); + + const result2 = runClaudeMdMigration(newMigrator(projectDir)); + const afterSecond = fs.readFileSync(claudeMdPath, 'utf-8'); + + expect(result2.upgraded.some(u => u.includes('Quiet update mechanics'))).toBe(false); + expect(afterSecond).toBe(afterFirst); + const headingMatches = afterSecond.match(/### Quiet update mechanics/g); + expect(headingMatches?.length).toBe(1); + }); + + it('still backfills for an agent that ALREADY has the maturity-honesty section', () => { + // The separate guard is the whole point: maturity honesty present, quiet + // mechanics absent ⇒ the migration must still add the quiet-mechanics block. + fs.writeFileSync( + claudeMdPath, + '# CLAUDE.md\n\n### Maturity honesty (silent-by-default user announcements)\n\nAlready here.\n', + ); + + const result = runClaudeMdMigration(newMigrator(projectDir)); + + expect(result.upgraded.some(u => u.includes('Quiet update mechanics'))).toBe(true); + expect(fs.readFileSync(claudeMdPath, 'utf-8')).toContain(HEADING); + }); + + it('does not double-patch an agent that already has the marker', () => { + fs.writeFileSync(claudeMdPath, `# CLAUDE.md\n\n- **${GUARD} → logs, not the user)**: already here.\n`); + + const result = runClaudeMdMigration(newMigrator(projectDir)); + + expect(result.upgraded.some(u => u.includes('Quiet update mechanics'))).toBe(false); + }); +}); + +describe('generateClaudeMd template includes the quiet-update-mechanics guidance', () => { + it('the source template emits the marker so fresh installs get it too', () => { + const templateSource = fs.readFileSync( + path.join(process.cwd(), 'src/scaffold/templates.ts'), + 'utf-8', + ); + expect(templateSource).toContain(GUARD); + }); +}); diff --git a/tests/unit/auto-updater-failures.test.ts b/tests/unit/auto-updater-failures.test.ts index 231cd8952..fad7614fe 100644 --- a/tests/unit/auto-updater-failures.test.ts +++ b/tests/unit/auto-updater-failures.test.ts @@ -185,7 +185,13 @@ describe('AutoUpdater — failure paths', () => { expect(saved.lastError).toBe('npm install threw ENOMEM'); }); - it('still sends failure notification via Telegram', async () => { + it('keeps a transient (self-healing) apply failure SILENT — logs only', async () => { + // quiet-update-mechanics spec: a transient apply failure that retries on + // the next cycle ("still running fine, will retry") is update mechanics, + // not something the user must act on — so it goes to the logs, not the + // Updates topic. A genuinely STUCK update (restart won't take after + // repeated retries) still reaches the user as 'failure-escalated' from the + // restart handshake in server.ts. const telegram = createMockTelegram(); const mockChecker = createMockUpdateChecker({ @@ -209,10 +215,8 @@ describe('AutoUpdater — failure paths', () => { await (updater as any).tick(); - // Notification about the failure was sent - expect(telegram.sendToTopic).toHaveBeenCalled(); - const callArg = (telegram.sendToTopic as any).mock.calls[0][1] as string; - expect(callArg).toContain('didn\'t work out'); + // The transient failure is housekeeping — it must NOT reach the user. + expect(telegram.sendToTopic).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/feature-delivery-completeness.test.ts b/tests/unit/feature-delivery-completeness.test.ts index 9a5b02c83..467d4e5da 100644 --- a/tests/unit/feature-delivery-completeness.test.ts +++ b/tests/unit/feature-delivery-completeness.test.ts @@ -248,6 +248,7 @@ describe('Feature Delivery Completeness', () => { '/session/clock', // ROBUST-SESSION-TIME-AWARENESS read surface (templated "Session Clock" + migrator): observability the agent READS to answer "how long have I been running / how much is left?" — like /codex/usage and /metrics/features, not a framework-shadowed user-invokable capability 'Token-Burn Alerts', // BurnDetector noise/activity-gate awareness (monitoring.burnDetection): operational observability the agent READS to answer "why am I getting these token alerts / turn them off" — migrator-only behavioral/config guidance like 'Topic-Flood Guard' / Sentinel Notifications, no user-invokable route / template-shadow parity. (Was previously untracked — a pre-existing red in this guard, fixed here.) '/resources/summary', // per-agent ResourceLedger Phase B (CPU/memory) READ surface (templated "Resource Usage (CPU + memory + rate-limit events)" + migrator): observability the agent READS to answer "how much CPU/memory am I using right now?" — like /codex/usage, /metrics/features, /session/clock, and /resources/rate-limits, not a framework-shadowed user-invokable capability + 'Quiet update mechanics (version/restart churn', // quiet-update-mechanics spec: behavioral/operational guard (how the agent self-narrates a restart/update — version/restart churn → logs, not the user), migrator-only like 'Maturity honesty' / 'Agent Updates topic' — no user-invokable route / template-shadow parity. ALSO emitted by the template directly so a fresh init is never double-patched. ]; it('all new migrator CLAUDE.md sections are tracked', () => { diff --git a/tests/unit/graceful-updates-phase2.test.ts b/tests/unit/graceful-updates-phase2.test.ts index 658f9fc03..e60f8766a 100644 --- a/tests/unit/graceful-updates-phase2.test.ts +++ b/tests/unit/graceful-updates-phase2.test.ts @@ -550,10 +550,15 @@ describe('Phase 2B+E: AutoUpdater with session gating', () => { await (updater as any).tick(); - // Should have notified about the upcoming restart + // Should have sent a plain, version-free interruption heads-up about the + // restart (quiet-update-mechanics spec — no "v1.3.X" version churn). const calls = (telegram.sendToTopic as any).mock.calls; const messages = calls.map((c: any[]) => c[1] as string); - expect(messages.some((m: string) => m.includes('restarting in') || m.includes('will resume') || m.includes('Restarting to pick up') || m.includes('updated to'))).toBe(true); + expect(messages.some((m: string) => + m.includes('Back in a few seconds') || m.includes('restart to pick it up'), + )).toBe(true); + // And it must NOT leak a raw version number to the user. + expect(messages.some((m: string) => /v?\d+\.\d+\.\d+/.test(m))).toBe(false); // Restart should have proceeded (idle sessions don't block) const flagPath = path.join(tmpDir, 'state', 'restart-requested.json'); @@ -625,12 +630,15 @@ describe('Phase 2C: Notify-only mode (autoApply: false)', () => { expect(mockChecker.applyUpdate).not.toHaveBeenCalled(); expect(updater.getStatus().pendingUpdate).toBe('0.9.9'); - // Should have sent notification with instructions + // Should have sent an actionable notice (auto-updates off → user must act). + // Version-free per quiet-update-mechanics spec — the action matters, not the + // version number. const calls = (telegram.sendToTopic as any).mock.calls; expect(calls.length).toBeGreaterThan(0); const msg = calls[0][1] as string; - expect(msg).toContain('Auto-updates are off'); - expect(msg).toContain('apply the update'); + expect(msg).toMatch(/auto-updates are off/i); + expect(msg).toMatch(/say "update"/i); + expect(msg).not.toMatch(/v?\d+\.\d+\.\d+/); }); it('does not re-notify on subsequent ticks for same version', async () => { diff --git a/tests/unit/notification-spam-prevention.test.ts b/tests/unit/notification-spam-prevention.test.ts index 967321e9f..acbea416a 100644 --- a/tests/unit/notification-spam-prevention.test.ts +++ b/tests/unit/notification-spam-prevention.test.ts @@ -182,15 +182,17 @@ describe('AutoUpdater loop prevention', () => { updater.start(); - // First tick — should send mismatch notification + // First tick — version-skew mismatch is now SILENT update mechanics + // (quiet-update-mechanics spec): it self-heals on the next restart, so it + // goes to the logs only, never the user's Updates topic. The original + // anti-spam concern ("send once, not repeatedly") is satisfied even more + // strongly — it never reaches the user at all. await vi.advanceTimersByTimeAsync(15_000); - const firstCallCount = telegram.sendToTopic.mock.calls.length; - expect(firstCallCount).toBe(1); - expect(telegram.sendToTopic.mock.calls[0][1]).toContain('still running v0.9.8'); + expect(telegram.sendToTopic.mock.calls.length).toBe(0); - // Second tick — should NOT send duplicate mismatch notification + // Second tick — still silent. await vi.advanceTimersByTimeAsync(65_000); - expect(telegram.sendToTopic.mock.calls.length).toBe(firstCallCount); + expect(telegram.sendToTopic.mock.calls.length).toBe(0); updater.stop(); }); diff --git a/tests/unit/update-notification-topic-lock.test.ts b/tests/unit/update-notification-topic-lock.test.ts index e77a93f55..e4bd7dbc1 100644 --- a/tests/unit/update-notification-topic-lock.test.ts +++ b/tests/unit/update-notification-topic-lock.test.ts @@ -98,7 +98,9 @@ describe('update announcement topic lock', () => { telegram, ); - await (updater as any).notify('update available'); + // Use a user-reaching kind ('interruption') so this exercises TOPIC + // ROUTING, not the silent-mechanics default (quiet-update-mechanics spec). + await (updater as any).notify('update available', 'interruption'); expect(telegram.sendToTopic).toHaveBeenCalledTimes(1); expect(telegram.sendToTopic).toHaveBeenCalledWith(4242, 'update available'); @@ -119,7 +121,9 @@ describe('update announcement topic lock', () => { telegram, ); - await (updater as any).notify('update available'); + // 'interruption' WOULD reach the user — proving the not-called assertion + // is about the missing Updates topic, not the silent-mechanics default. + await (updater as any).notify('update available', 'interruption'); expect(telegram.sendToTopic).not.toHaveBeenCalled(); }); @@ -136,7 +140,7 @@ describe('update announcement topic lock', () => { telegram, ); - await (updater as any).notify('update available'); + await (updater as any).notify('update available', 'interruption'); expect(telegram.sendToTopic).not.toHaveBeenCalled(); }); diff --git a/tests/unit/update-notify-policy.test.ts b/tests/unit/update-notify-policy.test.ts new file mode 100644 index 000000000..9127b5caa --- /dev/null +++ b/tests/unit/update-notify-policy.test.ts @@ -0,0 +1,77 @@ +/** + * Unit tests for the update-notification policy (src/core/updateNotifyPolicy.ts). + * + * Pure-logic coverage of BOTH sides of every decision branch (Testing Integrity + * Standard — semantic correctness on both sides of each boundary): + * - mechanics → silent by default + * - mechanics + heartbeat flag + confirmation → reaches user + * - mechanics + heartbeat flag but NOT the confirmation → still silent + * - mechanics + confirmation but flag OFF → silent + * - interruption / actionable / failure-escalated → always reach the user + */ + +import { describe, it, expect } from 'vitest'; +import { + decideUpdateNotify, + type UpdateNotifyKind, +} from '../../src/core/updateNotifyPolicy.js'; + +describe('decideUpdateNotify', () => { + it('silences plain update mechanics by default (option A)', () => { + const d = decideUpdateNotify('mechanics'); + expect(d.reachUser).toBe(false); + expect(d.reason).toMatch(/housekeeping|logs only/i); + }); + + it('always surfaces an interruption (a restart hitting the user now)', () => { + const d = decideUpdateNotify('interruption'); + expect(d.reachUser).toBe(true); + expect(d.reason).toMatch(/interrupt/i); + }); + + it('always surfaces an actionable notice (user must apply a manual update)', () => { + const d = decideUpdateNotify('actionable'); + expect(d.reachUser).toBe(true); + expect(d.reason).toMatch(/action/i); + }); + + it('always surfaces a genuinely stuck (escalated) update failure', () => { + const d = decideUpdateNotify('failure-escalated'); + expect(d.reachUser).toBe(true); + expect(d.reason).toMatch(/stuck/i); + }); + + describe('option B — background-refresh heartbeat', () => { + it('surfaces the background-refresh confirmation when the flag is ON', () => { + const d = decideUpdateNotify('mechanics', { + backgroundRefreshHeartbeat: true, + isBackgroundRefreshConfirmation: true, + }); + expect(d.reachUser).toBe(true); + expect(d.reason).toMatch(/heartbeat/i); + }); + + it('stays silent for the confirmation when the flag is OFF (default A)', () => { + const d = decideUpdateNotify('mechanics', { + backgroundRefreshHeartbeat: false, + isBackgroundRefreshConfirmation: true, + }); + expect(d.reachUser).toBe(false); + }); + + it('stays silent for NON-confirmation mechanics even when the flag is ON', () => { + // The flag must never re-introduce the version-churn flood — only the + // single dedicated confirmation event can surface. + const d = decideUpdateNotify('mechanics', { + backgroundRefreshHeartbeat: true, + isBackgroundRefreshConfirmation: false, + }); + expect(d.reachUser).toBe(false); + }); + }); + + it('defaults an unknown kind to silent (fail-safe against accidental spam)', () => { + const d = decideUpdateNotify('totally-new-kind' as unknown as UpdateNotifyKind); + expect(d.reachUser).toBe(false); + }); +}); diff --git a/tests/unit/update-notify-routing.test.ts b/tests/unit/update-notify-routing.test.ts new file mode 100644 index 000000000..2cd66a679 --- /dev/null +++ b/tests/unit/update-notify-routing.test.ts @@ -0,0 +1,143 @@ +/** + * Wiring-integrity tests for AutoUpdater's notify() funnel. + * + * The pure policy lives in update-notify-policy.test.ts. THIS file proves the + * funnel is actually wired to the policy and to Telegram — i.e. the decision is + * not a no-op: + * - a 'mechanics' notify NEVER reaches Telegram (housekeeping → logs only) + * - an 'interruption' / 'actionable' / 'failure-escalated' notify DOES reach + * Telegram + * - the option-B heartbeat is gated by the backgroundRefreshHeartbeat config: + * OFF → the background-refresh confirmation is silent; ON → it sends + * + * Regression target: the Updates topic was flooding with version-churn + * mechanics ("Just updated to v1.3.217. Restarting…", "vX applied but I'm still + * running vY", "rolling into the pending restart at 02:42"). These assertions + * lock in that those mechanics no longer reach the user. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import os from 'node:os'; +import { AutoUpdater } from '../../src/core/AutoUpdater.js'; +import type { UpdateChecker } from '../../src/core/UpdateChecker.js'; +import type { TelegramAdapter } from '../../src/messaging/TelegramAdapter.js'; +import type { StateManager } from '../../src/core/StateManager.js'; +import type { AutoUpdaterConfig } from '../../src/core/AutoUpdater.js'; + +const UPDATES_TOPIC = 7849; + +function mockUpdateChecker(): UpdateChecker { + return { + check: vi.fn(), + applyUpdate: vi.fn(), + getInstalledVersion: vi.fn().mockReturnValue('1.3.217'), + getLastCheck: vi.fn().mockReturnValue(null), + rollback: vi.fn(), + canRollback: vi.fn().mockReturnValue(false), + getRollbackInfo: vi.fn().mockReturnValue(null), + fetchChangelog: vi.fn().mockResolvedValue(undefined), + } as unknown as UpdateChecker; +} + +function mockTelegram(): TelegramAdapter { + return { + sendToTopic: vi.fn().mockResolvedValue(undefined), + platform: 'telegram', + start: vi.fn(), + stop: vi.fn(), + send: vi.fn(), + onMessage: vi.fn(), + resolveUser: vi.fn(), + } as unknown as TelegramAdapter; +} + +function mockState(): StateManager { + return { + get: vi.fn((key: string) => (key === 'agent-updates-topic' ? UPDATES_TOPIC : undefined)), + set: vi.fn(), + getSession: vi.fn().mockReturnValue(null), + saveSession: vi.fn(), + listSessions: vi.fn().mockReturnValue([]), + deleteSession: vi.fn(), + } as unknown as StateManager; +} + +function makeUpdater(config?: Partial) { + const telegram = mockTelegram(); + const updater = new AutoUpdater( + mockUpdateChecker(), + mockState(), + os.tmpdir(), + config as AutoUpdaterConfig, + telegram, + null, + ); + // notify() is private; the funnel is what we're testing, so reach it directly. + const notify = (msg: string, kind?: string, opts?: unknown) => + (updater as unknown as { + notify: (m: string, k?: string, o?: unknown) => Promise; + }).notify(msg, kind, opts); + return { updater, telegram, notify }; +} + +describe('AutoUpdater.notify funnel routing', () => { + beforeEach(() => vi.clearAllMocks()); + + it('does NOT send update mechanics to Telegram (silent → logs only)', async () => { + const { telegram, notify } = makeUpdater(); + await notify('v1.3.217 applied but still running v1.3.218', 'mechanics'); + expect(telegram.sendToTopic).not.toHaveBeenCalled(); + }); + + it('defaults an untagged notify to silent mechanics (fail-safe)', async () => { + const { telegram, notify } = makeUpdater(); + await notify('some future un-audited update notice'); + expect(telegram.sendToTopic).not.toHaveBeenCalled(); + }); + + it('sends an interruption to Telegram (a restart hitting the user now)', async () => { + const { telegram, notify } = makeUpdater(); + await notify('Heads up — restarting now. Back in a few seconds.', 'interruption'); + expect(telegram.sendToTopic).toHaveBeenCalledTimes(1); + expect(telegram.sendToTopic).toHaveBeenCalledWith( + UPDATES_TOPIC, + expect.stringContaining('Back in a few seconds'), + ); + }); + + it('sends an actionable notice to Telegram', async () => { + const { telegram, notify } = makeUpdater(); + await notify('Say "update" and I will apply it.', 'actionable'); + expect(telegram.sendToTopic).toHaveBeenCalledTimes(1); + }); + + it('sends an escalated failure to Telegram', async () => { + const { telegram, notify } = makeUpdater(); + await notify('The restart did not take.', 'failure-escalated'); + expect(telegram.sendToTopic).toHaveBeenCalledTimes(1); + }); + + describe('option B — backgroundRefreshHeartbeat', () => { + it('silences the background-refresh confirmation when OFF (default A)', async () => { + const { telegram, notify } = makeUpdater({ backgroundRefreshHeartbeat: false }); + await notify('Just refreshed in the background — I am current.', 'mechanics', { + isBackgroundRefreshConfirmation: true, + }); + expect(telegram.sendToTopic).not.toHaveBeenCalled(); + }); + + it('sends the background-refresh confirmation when ON', async () => { + const { telegram, notify } = makeUpdater({ backgroundRefreshHeartbeat: true }); + await notify('Just refreshed in the background — I am current.', 'mechanics', { + isBackgroundRefreshConfirmation: true, + }); + expect(telegram.sendToTopic).toHaveBeenCalledTimes(1); + }); + + it('still silences NON-confirmation mechanics when ON (no flood regression)', async () => { + const { telegram, notify } = makeUpdater({ backgroundRefreshHeartbeat: true }); + await notify('rolling into the pending restart at 02:42', 'mechanics'); + expect(telegram.sendToTopic).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/upgrades/next/quiet-update-mechanics.md b/upgrades/next/quiet-update-mechanics.md new file mode 100644 index 000000000..c4cd27dfb --- /dev/null +++ b/upgrades/next/quiet-update-mechanics.md @@ -0,0 +1,57 @@ + + +## What Changed + +Update **mechanics** are now housekeeping — they go to the logs, not the user's +Agent Updates topic. The Updates topic had been flooding with raw version churn +and restart plumbing ("Just updated to v1.3.217. Restarting…", "vX applied but +I'm still running vY — the next restart should pick it up", cascade-batch +"rolling into the pending restart at 02:42"), none of which is user-relevant. +`mature-update-announcements` (#698) silenced the *feature-announcement* path; +this silences the *mechanics* path. + +A pure policy module (`src/core/updateNotifyPolicy.ts`) classifies every update +notification into `mechanics | interruption | actionable | failure-escalated`, +and `AutoUpdater.notify()` (plus the restart-handshake emit in `server.ts`) +gates on it at the single notify funnel. **Default kind is `mechanics`** so any +future un-audited callsite is silent rather than spammy. The user now hears about +an update ONLY when: a genuinely new capability ships (the maturity layer), a +restart is actually interrupting their active work right now (a plain, +**version-free** "back in a few seconds" — never "v1.3.X"), or an update is +genuinely stuck after retries. All restart/interruption copy was rewritten +version-free. Opt into a single quiet "just refreshed in the background" +heartbeat with `updates.backgroundRefreshHeartbeat: true` (default false = full +silence); it can never re-introduce version churn. + +Code-only behavior change (ships on npm update; no config migration needed — +absence of the new flag = full silence). Agent awareness is added to the +CLAUDE.md template and backfilled to existing agents via `PostUpdateMigrator` +under its own content-sniff guard. Spec: `docs/specs/quiet-update-mechanics.md`. + +## What to Tell Your User + +- audience: user — maturity: stable +- **Quieter, more relevant updates**: "I'll stop pinging you with version + numbers and restart plumbing — that all goes to the logs now. You'll only hear + about an update when there's genuinely something new for you, or when a restart + is actually about to interrupt you (and even then, plainly — no version jargon)." + +## Summary of New Capabilities + +- Update **mechanics** (version churn, restart coordination, self-healing skew) + are now silent — they go to the logs, never the user's Updates topic. The user + hears about an update only for a new capability, a real interruption + (version-free), or a genuinely stuck update. +- `updates.backgroundRefreshHeartbeat` (default `false`): opt into a single quiet + "just refreshed in the background" note instead of full silence. Cannot + re-introduce version churn. +- Behavioral self-narration rule (CLAUDE.md): when narrating my OWN + restart/update, no version numbers or restart plumbing — a human "back now" + only if it actually mattered to the user. + +## Evidence + +- New: `vitest run tests/unit/update-notify-policy.test.ts tests/unit/update-notify-routing.test.ts` → **16/16 green** (policy both-sides + funnel wiring integrity: `mechanics` never calls `sendToTopic`, `interruption`/`actionable`/`failure-escalated` do, option-B gating, unknown-kind → silent fail-safe). +- Updated to the new contract and green: `notification-spam-prevention`, `auto-updater-failures`, `graceful-updates-phase2`, `update-notification-topic-lock` (mechanics now silent; interruption/actionable now version-free — asserted no `\d+\.\d+\.\d+` leaks). +- Regression-safe: `AutoUpdater`, `AutoUpdater-cascade-dampener`, `restart-window`, `tests/e2e/self-heal-cascade-and-drift`, `tests/integration/updates-status-restart-immediately-route`, `stall-recovery-e2e` all green. +- `tsc --noEmit` clean. diff --git a/upgrades/side-effects/quiet-update-mechanics.md b/upgrades/side-effects/quiet-update-mechanics.md new file mode 100644 index 000000000..35b8bbf58 --- /dev/null +++ b/upgrades/side-effects/quiet-update-mechanics.md @@ -0,0 +1,80 @@ +# Side-Effects Review — Quiet Update Mechanics + +**Version / slug:** `quiet-update-mechanics` +**Date:** `2026-06-04` +**Author:** `Echo (instar-dev agent)` +**Second-pass reviewer:** `not required (Tier 1)` + +## Summary of the change + +Update **mechanics** notifications (raw version numbers + restart plumbing) are reclassified as housekeeping and routed to the logs instead of the user's Agent Updates topic. A new pure module `src/core/updateNotifyPolicy.ts` classifies every update notification into `mechanics | interruption | actionable | failure-escalated`; `AutoUpdater.notify()` (`src/core/AutoUpdater.ts`) and the restart-handshake emit (`src/commands/server.ts`) gate on that decision at their single notify funnel. The default kind is `mechanics` (silent). All restart/interruption copy was rewritten version-free. A `updates.backgroundRefreshHeartbeat` config flag (`src/core/types.ts`, default false) opts into a single quiet background-refresh note. Files: `updateNotifyPolicy.ts` (new), `AutoUpdater.ts`, `server.ts`, `types.ts`, CLAUDE.md template + `PostUpdateMigrator.ts`, spec + eli16 + release fragment, and updated/new tests. + +## Decision-point inventory + +- `AutoUpdater.notify()` funnel — **modify** — now consults `decideUpdateNotify(kind)` and drops non-`reachUser` messages to the log. +- `server.ts` restart-handshake `failed` emit — **modify** — non-escalated mismatch → silent; escalated → reaches user, version-free. +- version-skew nudge / transient apply failure / cascade-batch notice — **modify** — reclassified `mechanics` (silent). +- max-deferral forced restart / restart narration / deferral threshold warnings — **modify** — `interruption`, version-free, still reach the user. +- manual-update-available (auto-apply off) — **modify** — `actionable`, version-free, still reaches the user. +- idle-restart path — **add** — emits a `mechanics` + `isBackgroundRefreshConfirmation` note (silent unless option B). + +## 1. Over-block + +This is a notification *suppression* surface, so "over-block" = silencing a message the user genuinely needed. Concrete cases considered: + +- A restart that interrupts the user's active work → still sent (`interruption`), so NOT over-silenced. +- A genuinely stuck update (restart won't take after retries) → still sent (`failure-escalated`), so NOT over-silenced. +- A manual update awaiting the user's go-ahead (auto-apply off) → still sent (`actionable`), so NOT over-silenced. + +The only messages now silenced are version churn, restart-batch coordination, transient self-healing skew, and transient apply failures that retry next cycle — none of which carry user-actionable information. The user explicitly requested this silence (option A, 2026-06-04). + +## 2. Under-block + +"Under-block" = noise that still reaches the user. The default-`mechanics` rule means a *new* update message is silent unless explicitly classified user-facing, so the failure mode points at silence, not leakage. Residual: the three reaching kinds (`interruption`/`actionable`/`failure-escalated`) still send — by design — but are now version-free, so even they can't leak version churn. A test asserts no `\d+\.\d+\.\d+` appears in the interruption/actionable copy. + +## 3. Level-of-abstraction fit + +Correct layer. The policy is a low-level pure decision function (no I/O, no state) that the existing `notify()` funnel *uses* — it does not re-implement sending and does not run parallel to an existing gate. It feeds the single chokepoint every update notification already passed through, rather than adding a new interception point. This mirrors the sibling `mature-update-announcements` design (the announcement layer) one level down at the mechanics layer. + +## 4. Signal vs authority compliance + +**Required reference:** docs/signal-vs-authority.md + +- [x] No — this change has no block/allow surface in the security sense; it is a routing/visibility decision over the agent's OWN outbound status messages (not user input, not another agent's traffic). + +The policy holds "reach the user or log" authority, but the logic is trivial and deterministic (a 4-way switch on an internal enum the calling code sets), not a brittle classifier guessing at untrusted input. There is no content parsing, no heuristic, nothing to be fooled. It cannot wrongly suppress a user message because it never sees user messages — only the auto-updater's own status strings, each explicitly tagged at the callsite by the developer. + +## 5. Interactions + +- **Shadowing:** the policy runs at the top of `notify()`, before the Telegram send. It can suppress the send, but suppression is the intended behavior and is logged. It does not shadow any other check — `notify()` is the terminal step. The patch-only Fork-3 suppression (mature-update-announcements) still runs upstream and is unaffected; the restart handshake is still written for verification even when the message is suppressed. +- **Double-fire:** none introduced. The existing per-version dedup guards (`notifiedVersionMismatch`, `lastNotifiedRestartVersion`) are untouched; reclassifying their messages to silent doesn't change their once-per-version semantics. +- **Races:** no new shared state. The `backgroundRefreshHeartbeat` flag is read-only config. No new timers or files. +- **Feedback loops:** none. Suppressing a notification cannot feed back into the updater's decision logic. + +## 6. External surfaces + +- **Other agents same machine:** none — this only changes what this agent posts to its own Updates topic. +- **Install base:** yes, intentionally — every agent gets quieter update messaging on npm update. This is the requested behavior. No breaking change to any API or message contract consumers depend on (the Updates topic is human-facing, not machine-parsed). +- **External systems:** Telegram — strictly *fewer* sends. No format change to the messages that still go out beyond removing version numbers. +- **Persistent state:** none. No ledger, DB, or state-file schema change. `backgroundRefreshHeartbeat` is an optional config key (absent = default false). +- **Timing/runtime:** none. + +## 7. Rollback cost + +Pure code change — revert the PR and ship as the next patch. No persistent state is written, no migration to undo (the config key is additive and optional; absence = the shipped default). No agent-state repair needed. During a rollback window the only "regression" the user would see is the *return* of the version-churn noise — annoying, not harmful, and self-corrects when the revert propagates. Worst realistic failure mode (over-silencing) is recoverable: flip `backgroundRefreshHeartbeat` on for a heartbeat, or revert. Low risk → Tier 1. + +## Conclusion + +The review surfaced no over-block of user-needed messages (interruption / actionable / stuck all still reach the user) and no new races, double-fires, or external-contract breaks. The one design refinement made during review: the option-B heartbeat flag was scoped so it can surface ONLY the single background-refresh confirmation, never any other mechanics message — closing the "flag reopens the flood" path. Clear to ship as Tier 1. + +## Second-pass review (if required) + +**Reviewer:** not required — Tier 1 (low-risk, notification-suppression-only, no persistent state, additive config, user pre-approved the approach). + +## Evidence pointers + +- `tests/unit/update-notify-policy.test.ts`, `tests/unit/update-notify-routing.test.ts` — 16/16 green (policy both-sides + funnel wiring). +- `tests/unit/PostUpdateMigrator-quietUpdateMechanics.test.ts` — 5/5 (migration parity). +- Updated to new contract + green: `notification-spam-prevention`, `auto-updater-failures`, `graceful-updates-phase2`, `update-notification-topic-lock`. +- Regression-safe: `AutoUpdater`, `AutoUpdater-cascade-dampener`, `restart-window`, e2e `self-heal-cascade-and-drift`, integration `updates-status-restart-immediately-route`, `stall-recovery-e2e`. +- `tsc` build clean.