diff --git a/src/db.ts b/src/db.ts index c194af4b..8d82c48f 100644 --- a/src/db.ts +++ b/src/db.ts @@ -228,7 +228,8 @@ function createSchema(database: Database.Database): void { priority INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, started_at INTEGER, - completed_at INTEGER + completed_at INTEGER, + autopushed_at INTEGER ); CREATE INDEX IF NOT EXISTS idx_mission_status @@ -527,6 +528,11 @@ function runMigrations(database: Database.Database): void { `); logger.info('Migration: made mission_tasks.assigned_agent nullable'); } + const missionColsPost = database.prepare(`PRAGMA table_info(mission_tasks)`).all() as Array<{ name: string }>; + if (!missionColsPost.some((c) => c.name === 'autopushed_at')) { + database.exec(`ALTER TABLE mission_tasks ADD COLUMN autopushed_at INTEGER`); + logger.info('Migration: added autopushed_at column to mission_tasks'); + } } /** @internal - for tests only. Creates a fresh in-memory database. */ @@ -1764,6 +1770,9 @@ export interface MissionTask { created_at: number; started_at: number | null; completed_at: number | null; + /** Unix ts when the mission-autopush hook fired Telegram notification. + * NULL = not yet pushed. Used as an atomic CAS claim so we never double-fire. */ + autopushed_at: number | null; } export function createMissionTask( @@ -1773,6 +1782,7 @@ export function createMissionTask( assignedAgent: string | null = null, createdBy = 'dashboard', priority = 0, + _acceptanceCriteria: string | null = null, ): void { const now = Math.floor(Date.now() / 1000); db.prepare( @@ -1903,6 +1913,29 @@ export function resetStuckMissionTasks(agentId: string): number { return result.changes; } +/** + * Atomically claim a mission for Telegram autopush notification. + * + * Stamps autopushed_at with the current unix timestamp IFF it's currently NULL. + * Returns true on the first claim, false on every subsequent call for the same + * mission — this is how the mission-autopush hook guarantees exactly-once + * notification even if the scheduler completion path is re-entered. + * + * Idempotent by design: safe to call on any mission id, including non-existent + * ones (returns false) and already-pushed ones (returns false). + */ +export function markMissionAutopushed(id: string): boolean { + const result = db.prepare( + `UPDATE mission_tasks SET autopushed_at = ? WHERE id = ? AND autopushed_at IS NULL`, + ).run(Math.floor(Date.now() / 1000), id); + return result.changes > 0; +} + +/** + * Reason tag for an auto-retry, surfaced in the Telegram ping + hive_mind log + * so Aditya can see WHY the watchdog reran something without reading source. + */ +export type RetryReason = 'turn_cap' | 'timeout'; // ── Audit Log ──────────────────────────────────────────────────────── export function insertAuditLog( diff --git a/src/mission-autopush.test.ts b/src/mission-autopush.test.ts new file mode 100644 index 00000000..622225d7 --- /dev/null +++ b/src/mission-autopush.test.ts @@ -0,0 +1,361 @@ +/** + * Unit tests for the mission-autopush hook. + * + * Covers the spec contract: + * 1. completeMissionTask → notifyMissionCompletion fires exactly one Telegram message + * 2. Rate-limit: >3 completions inside the batch window collapse to 1 batched msg + * 3. Non-main-created missions (spoke-to-spoke) do NOT notify + * 4. status='cancelled' does NOT notify + * 5. Exactly-once: calling notifyMissionCompletion twice for the same id → 1 message + * 6. MISSION_AUTOPUSH_DISABLED=1 → no messages sent + * + * All tests use a short batch window (20ms) so the flush timer fires quickly + * without blocking the test suite, and inject a mock send function so we + * never hit the real Telegram API. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// NOTE: config is imported for its side effects only (reads env). We need +// TELEGRAM_BOT_TOKEN / ALLOWED_CHAT_ID to be truthy so the hook doesn't +// short-circuit on missing credentials. Easiest way: set them in the env +// before the config module is first imported. +process.env.TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || 'test-token-xyz'; +process.env.ALLOWED_CHAT_ID = process.env.ALLOWED_CHAT_ID || '7678675171'; +// Field-level encryption key for the test DB — any 32-byte hex will do. +process.env.DB_ENCRYPTION_KEY = + process.env.DB_ENCRYPTION_KEY || + '0'.repeat(64); + +import { + _initTestDatabase, + createMissionTask, + completeMissionTask, + getMissionTask, +} from './db.js'; +import { + notifyMissionCompletion, + _setSendFnForTest, + _resetSendFnForTest, + _setBatchWindowMsForTest, + _resetStateForTest, + _flushNowForTest, + formatSingle, + formatBatched, +} from './mission-autopush.js'; + +interface SentMessage { + token: string; + chatId: string; + text: string; +} + +/** + * Helper: queue a completed mission (main-created by default) and fire the + * hook. Returns the id so tests can assert on payload shape. + */ +function seedAndComplete(opts: { + id: string; + title?: string; + createdBy?: string; + assignedAgent?: string | null; + status?: 'completed' | 'failed' | 'cancelled'; + result?: string | null; + error?: string; +}): string { + const { + id, + title = 'Test mission ' + id, + createdBy = 'main', + assignedAgent = 'builder', + status = 'completed', + result = 'done', + error, + } = opts; + createMissionTask(id, title, 'prompt body', assignedAgent, createdBy, 5, null); + if (status === 'cancelled') { + // Mimic cancelMissionTask: status='cancelled', don't call completeMissionTask. + // The hook should NEVER be triggered from the cancel path; this test + // verifies that if someone DOES accidentally invoke it, the filter blocks. + // We set status directly via a completed-then-cancelled dance. + } else { + completeMissionTask(id, result, status, error); + } + return id; +} + +describe('mission-autopush', () => { + let sent: SentMessage[]; + + beforeEach(() => { + _initTestDatabase(); + _resetStateForTest(); + // Short window so timers fire quickly in tests. + _setBatchWindowMsForTest(20); + sent = []; + _setSendFnForTest(async (token, chatId, text) => { + sent.push({ token, chatId, text }); + }); + delete process.env.MISSION_AUTOPUSH_DISABLED; + }); + + afterEach(() => { + _resetSendFnForTest(); + _resetStateForTest(); + }); + + // ── Spec requirement 1: single completion fires exactly one message ── + + it('fires exactly one Telegram message for a single main-created completion', async () => { + seedAndComplete({ id: 'aaaaaaaa', status: 'completed' }); + notifyMissionCompletion('aaaaaaaa'); + + await _flushNowForTest(); + + expect(sent).toHaveLength(1); + expect(sent[0].text).toContain('Mission completed'); + expect(sent[0].text).toContain('Test mission aaaaaaaa'); + // Token + chatId come from config.ts (which reads .env at import time, + // overriding the process.env we set in this test file). Just verify + // the hook passed *something* non-empty — the exact values depend on + // which .env the test runner picks up. + expect(sent[0].token.length).toBeGreaterThan(0); + expect(sent[0].chatId.length).toBeGreaterThan(0); + }); + + it('fires a failure ping with error body included', async () => { + seedAndComplete({ + id: 'bbbbbbbb', + status: 'failed', + result: null, + error: 'GHL API returned 403 — token expired', + }); + notifyMissionCompletion('bbbbbbbb'); + + await _flushNowForTest(); + + expect(sent).toHaveLength(1); + expect(sent[0].text).toContain('Mission failed'); + expect(sent[0].text).toContain('GHL API returned 403'); + }); + + // ── Spec requirement 2: rate-limit / batching ──────────────────────── + + it('batches 5 completions inside the window into a single message', async () => { + for (let i = 0; i < 5; i++) { + seedAndComplete({ + id: `id${i}xxxx`, + title: `Task ${i}`, + status: 'completed', + }); + notifyMissionCompletion(`id${i}xxxx`); + } + + await _flushNowForTest(); + + expect(sent).toHaveLength(1); + expect(sent[0].text).toMatch(/5 missions finished/); + expect(sent[0].text).toContain('id0xxxx'); + expect(sent[0].text).toContain('id4xxxx'); + }); + + it('sends individually when buffer holds exactly 3 completions', async () => { + for (let i = 0; i < 3; i++) { + seedAndComplete({ id: `t${i}xxxxx`, title: `Task ${i}` }); + notifyMissionCompletion(`t${i}xxxxx`); + } + + await _flushNowForTest(); + + // Threshold is > 3 → 3 is still individual (boundary case). + expect(sent).toHaveLength(3); + }); + + // ── Spec requirement 3: spoke-to-spoke delegations do NOT notify ───── + + it('does NOT notify when mission was created by another agent (e.g. ops)', async () => { + seedAndComplete({ id: 'cccccccc', createdBy: 'ops', status: 'completed' }); + notifyMissionCompletion('cccccccc'); + + await _flushNowForTest(); + + expect(sent).toHaveLength(0); + }); + + it('does NOT notify when mission was created by the watchdog', async () => { + seedAndComplete({ id: 'dddddddd', createdBy: 'watchdog', status: 'completed' }); + notifyMissionCompletion('dddddddd'); + + await _flushNowForTest(); + + expect(sent).toHaveLength(0); + }); + + // ── Spec requirement 4: status='cancelled' is silent ───────────────── + + it("does NOT notify for status='cancelled' missions", async () => { + createMissionTask( + 'eeeeeeee', + 'Cancelled task', + 'prompt', + 'builder', + 'main', + 5, + null, + ); + // Cancellation doesn't go through completeMissionTask — simulate by + // writing status directly. Hook should still filter it out. + completeMissionTask('eeeeeeee', null, 'failed'); + // Now overwrite to cancelled to exercise the filter. + const m = getMissionTask('eeeeeeee'); + expect(m).not.toBeNull(); + // directly mutate status via the DB by re-creating the row? Easier: + // we just trust the hook's filter — it only fires for completed/failed. + // Clear sent from the failed write above before testing cancel path. + sent = []; + + // Simulate what would happen if someone called notifyMissionCompletion() + // on a mission whose status had been flipped to 'cancelled' externally. + // Use raw SQL via the in-memory test DB. + const Database = (await import('better-sqlite3')).default; + // Can't easily reach the test DB handle from here, so just verify the + // filter at the getMissionTask level: build a synthetic mission. + // Simpler: use completeMissionTask with an invalid status won't work + // (it's typed). Instead, re-create with status 'cancelled' via direct + // INSERT... but db is private. We cover the cancelled branch in unit + // terms via the createMissionTask-with-cancelled-status path below. + void Database; + + // Use a fresh id with 'cancelled' status injected via the cancel helper: + createMissionTask( + 'ffffffff', + 'Cancel target', + 'prompt', + 'builder', + 'main', + 5, + null, + ); + const { cancelMissionTask } = await import('./db.js'); + cancelMissionTask('ffffffff'); + const cancelled = getMissionTask('ffffffff'); + expect(cancelled?.status).toBe('cancelled'); + + notifyMissionCompletion('ffffffff'); + await _flushNowForTest(); + + expect(sent).toHaveLength(0); + }); + + // ── Spec requirement 5: exactly-once / double-fire guard ───────────── + + it('is exactly-once — calling notifyMissionCompletion twice pushes one message', async () => { + seedAndComplete({ id: 'gggggggg', status: 'completed' }); + + notifyMissionCompletion('gggggggg'); + notifyMissionCompletion('gggggggg'); // second call — watchdog re-stamp scenario + + await _flushNowForTest(); + + expect(sent).toHaveLength(1); + + // And autopushed_at is now stamped — further calls remain silent. + const after = getMissionTask('gggggggg'); + expect(after?.autopushed_at).toBeGreaterThan(0); + + sent = []; + notifyMissionCompletion('gggggggg'); // third call — long after + await _flushNowForTest(); + expect(sent).toHaveLength(0); + }); + + // ── Spec requirement 6: opt-out kill switch ────────────────────────── + + it('MISSION_AUTOPUSH_DISABLED=1 suppresses all notifications', async () => { + process.env.MISSION_AUTOPUSH_DISABLED = '1'; + + seedAndComplete({ id: 'hhhhhhhh', status: 'completed' }); + notifyMissionCompletion('hhhhhhhh'); + + await _flushNowForTest(); + + expect(sent).toHaveLength(0); + // autopushed_at should NOT be stamped when disabled — so toggling the env + // var back on will still deliver the notification later. + const after = getMissionTask('hhhhhhhh'); + expect(after?.autopushed_at).toBeNull(); + }); + + // ── Non-regression: missing mission id is a no-op, not a throw ─────── + + it('does nothing if the mission id does not exist', async () => { + notifyMissionCompletion('deadbeef'); + await _flushNowForTest(); + expect(sent).toHaveLength(0); + }); + + // ── formatSingle / formatBatched output shape ──────────────────────── + + it('formatSingle includes id, agent, title, status, and artifact if present', () => { + const m = { + id: 'abcd1234', + title: 'Ship the thing', + prompt: '', + assigned_agent: 'builder', + status: 'completed' as const, + result: 'done\nArtifact: /tmp/report.md', + error: null, + created_by: 'main', + priority: 5, + created_at: 0, + started_at: null, + completed_at: null, + acceptance_criteria: null, + timeout_ms: null, + autopushed_at: null, + retry_attempt: 0, + retried_from: null, + }; + const text = formatSingle(m); + expect(text).toContain('✅'); + expect(text).toContain('Ship the thing'); + expect(text).toContain('abcd1234'); + expect(text).toContain('@builder'); + expect(text).toContain('Artifact: /tmp/report.md'); + }); + + it('formatBatched summarises mixed completed+failed batch', () => { + const mk = (id: string, status: 'completed' | 'failed', title: string, error?: string) => ({ + id, title, prompt: '', assigned_agent: 'ops', + status, result: status === 'completed' ? 'ok' : null, + error: error ?? null, created_by: 'main', priority: 5, + created_at: 0, started_at: null, completed_at: null, + acceptance_criteria: null, timeout_ms: null, autopushed_at: null, + retry_attempt: 0, retried_from: null, + }); + const text = formatBatched([ + mk('aaaa0000', 'completed', 'A'), + mk('bbbb0000', 'completed', 'B'), + mk('cccc0000', 'failed', 'C', 'boom'), + mk('dddd0000', 'completed', 'D'), + ]); + expect(text).toContain('4 missions finished (3 ok, 1 failed)'); + expect(text).toContain('aaaa0000'); + expect(text).toContain('cccc0000'); + expect(text).toContain('boom'); + }); + + // ── Vitest: ensure no timers leaked across tests ───────────────────── + + it('resets cleanly between tests', () => { + // Spam the buffer and reset without flushing — state must be clean. + for (let i = 0; i < 10; i++) { + seedAndComplete({ id: `z${i}xxxxx` }); + notifyMissionCompletion(`z${i}xxxxx`); + } + _resetStateForTest(); + vi.useFakeTimers(); + vi.advanceTimersByTime(10_000); + vi.useRealTimers(); + expect(sent).toHaveLength(0); + }); +}); diff --git a/src/mission-autopush.ts b/src/mission-autopush.ts new file mode 100644 index 00000000..9c69830f --- /dev/null +++ b/src/mission-autopush.ts @@ -0,0 +1,376 @@ +/** + * Mission autopush — auto-notification hook that pings Aditya's main Telegram + * chat whenever a mission created by Rudy (created_by='main') transitions to + * status='completed' or 'failed'. + * + * Motivating problem: Aditya delegates work to a spoke via mission-cli; the + * spoke finishes; Aditya has no idea unless he asks. This module closes that + * loop by pushing a short completion ping to the main bot chat. + * + * Design contract: + * 1. ONLY fires for mission_tasks where created_by='main' — spoke-to-spoke + * delegations and watchdog-queued auto-triage do NOT bother Aditya. + * 2. ONLY fires for status in {completed, failed}. status='cancelled' is + * silent (operator already knows — they cancelled it). + * 3. Exactly-once per mission. Uses the `autopushed_at` column on + * mission_tasks as an atomic CAS claim (see db.markMissionAutopushed). + * Watchdog re-stamping escalated_at cannot double-fire the hook. + * 4. Rate-limited. If >3 completions land inside the batch window, they + * collapse into a single Telegram message so bursts don't spam the chat. + * 5. Opt-out via env var MISSION_AUTOPUSH_DISABLED=1. + * + * Wiring: src/scheduler.ts calls notifyMissionCompletion(id) immediately after + * each completeMissionTask() call in runDueMissionTasks. The hook is the ONLY + * path that stamps autopushed_at, so no other completion path risks a + * duplicate ping. + * + * Always routes via TELEGRAM_BOT_TOKEN → ALLOWED_CHAT_ID (the main bot chat), + * regardless of which spoke executed the mission. Aditya sees all his + * delegated work land in one place. + */ + +import { ALLOWED_CHAT_ID, TELEGRAM_BOT_TOKEN } from './config.js'; +import { getMissionTask, markMissionAutopushed, MissionTask, RetryReason } from './db.js'; +import { logger } from './logger.js'; + +// ── Config ─────────────────────────────────────────────────────────── + +/** + * How long to wait after the first buffered completion before flushing. + * Tuned to be long enough to absorb natural bursts (several spokes finishing + * near-simultaneously) while still feeling responsive for single completions. + * Tests override via setBatchWindowMs(). + */ +const DEFAULT_BATCH_WINDOW_MS = 2_000; + +/** + * If buffer has STRICTLY MORE than this many items when flushed, collapse + * into a single batched message. 3 individual messages is still OK; 4+ + * means we're spamming. + */ +const BATCH_THRESHOLD = 3; + +/** Max chars of result/error body to include in a single-mission push. */ +const RESULT_PREVIEW_CHARS = 500; + +// ── Test hooks ─────────────────────────────────────────────────────── + +type SendFn = (token: string, chatId: string, text: string) => Promise; + +let batchWindowMs = DEFAULT_BATCH_WINDOW_MS; +let sendFn: SendFn = defaultTelegramSend; +let pendingIds: string[] = []; +let flushTimer: NodeJS.Timeout | null = null; + +/** @internal Test hook — override the telegram sender (use a spy/mock). */ +export function _setSendFnForTest(fn: SendFn): void { + sendFn = fn; +} + +/** @internal Test hook — reset to default sender (call in afterEach). */ +export function _resetSendFnForTest(): void { + sendFn = defaultTelegramSend; +} + +/** @internal Test hook — tune the batch window so tests don't wait 2s. */ +export function _setBatchWindowMsForTest(ms: number): void { + batchWindowMs = ms; +} + +/** @internal Test hook — clear buffer + cancel any pending flush. */ +export function _resetStateForTest(): void { + pendingIds = []; + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + batchWindowMs = DEFAULT_BATCH_WINDOW_MS; +} + +/** @internal Test hook — force the flush to run synchronously. */ +export async function _flushNowForTest(): Promise { + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + await flushBuffer(); +} + +// ── Public entry point ────────────────────────────────────────────── + +/** + * Notify the main Telegram chat that a mission has completed or failed. + * + * Called from the scheduler immediately after completeMissionTask(). Non-blocking + * and swallow-all-errors: a notification failure must NEVER cause the scheduler + * to crash or retry the underlying work. + * + * The filtering rules (created_by='main', status in {completed, failed}, not + * already pushed) run inside this function — callers don't need to pre-check. + */ +export function notifyMissionCompletion(missionId: string): void { + // Opt-out kill switch — honour env var set at invocation time so it can be + // toggled without restarting the scheduler (well, next completion picks it up). + if (process.env.MISSION_AUTOPUSH_DISABLED === '1') { + return; + } + + try { + const mission = getMissionTask(missionId); + if (!mission) { + logger.debug({ missionId }, 'mission-autopush: mission not found, skipping'); + return; + } + + // Filter 1: only missions Rudy queued on Aditya's behalf. + if (mission.created_by !== 'main') { + return; + } + + // Filter 2: only terminal-with-outcome statuses. 'cancelled' is silent + // because the operator knows (they cancelled it). 'queued'/'running' + // shouldn't hit this path at all, but guard anyway. + if (mission.status !== 'completed' && mission.status !== 'failed') { + return; + } + + // Filter 3 (the exactly-once guard): atomic CAS on autopushed_at. + // If this returns false the row was already claimed by an earlier call. + if (!markMissionAutopushed(mission.id)) { + logger.debug( + { missionId: mission.id }, + 'mission-autopush: already autopushed, skipping duplicate', + ); + return; + } + + // Missing credentials is not a hard failure — log once and move on. + // The hook is best-effort; it must never break the scheduler. + if (!TELEGRAM_BOT_TOKEN || !ALLOWED_CHAT_ID) { + logger.warn( + { missionId: mission.id }, + 'mission-autopush: TELEGRAM_BOT_TOKEN or ALLOWED_CHAT_ID unset — skipping push', + ); + return; + } + + enqueueForPush(mission.id); + } catch (err) { + logger.error({ err, missionId }, 'mission-autopush: notifyMissionCompletion threw'); + } +} + +// ── Buffering + flush ──────────────────────────────────────────────── + +function enqueueForPush(missionId: string): void { + pendingIds.push(missionId); + if (!flushTimer) { + flushTimer = setTimeout(() => { + flushTimer = null; + void flushBuffer(); + }, batchWindowMs); + } +} + +async function flushBuffer(): Promise { + const ids = pendingIds; + pendingIds = []; + if (ids.length === 0) return; + + // Re-read each mission at flush time — the batch window is short, but + // the row is the single source of truth for title/result/agent. + const missions = ids + .map((id) => getMissionTask(id)) + .filter((m): m is MissionTask => m !== null); + if (missions.length === 0) return; + + try { + if (missions.length > BATCH_THRESHOLD) { + await sendFn(TELEGRAM_BOT_TOKEN, ALLOWED_CHAT_ID, formatBatched(missions)); + } else { + for (const m of missions) { + await sendFn(TELEGRAM_BOT_TOKEN, ALLOWED_CHAT_ID, formatSingle(m)); + } + } + } catch (err) { + logger.error( + { err, count: missions.length, ids: missions.map((m) => m.id) }, + 'mission-autopush: flush failed', + ); + } +} + +// ── Message formatting ────────────────────────────────────────────── + +/** + * Detect an artifact path inside the result body. + * + * Two shapes supported (either is enough): + * 1. Explicit label: "Artifact: /abs/path" or "artifact_path: /abs/path" + * 2. Bare absolute path on its own line ending with a recognisable extension + * + * Kept conservative on purpose — false positives here clutter the ping with + * meaningless filenames. + */ +function extractArtifactPath(result: string | null): string | null { + if (!result) return null; + const labelled = /(?:^|\n)\s*(?:Artifact|artifact_path|Output file|File)\s*:\s*(\S+)/.exec( + result, + ); + if (labelled && labelled[1]) return labelled[1]; + const bare = /(?:^|\n)\s*(\/(?:Users|home|tmp|var)\/\S+\.(?:md|json|png|jpg|jpeg|csv|pdf|txt|log|html|ts|tsx|js|py|sh|mp4|mp3|wav))\b/.exec( + result, + ); + return bare ? bare[1] : null; +} + +function shortId(id: string): string { + // Mission IDs are already 8 hex chars (randomBytes(4).toString('hex')). + // Slice anyway so this is robust to future id-length changes. + return id.slice(0, 8); +} + +function clip(s: string | null | undefined, max: number): string { + if (!s) return ''; + const t = s.trim(); + if (t.length <= max) return t; + return t.slice(0, max - 1) + '…'; +} + +function statusGlyph(status: string): string { + return status === 'completed' ? '✅' : '❌'; +} + +export function formatSingle(m: MissionTask): string { + const glyph = statusGlyph(m.status); + const agent = m.assigned_agent ? `@${m.assigned_agent}` : '(unassigned)'; + const lines: string[] = []; + lines.push(`${glyph} Mission ${m.status}: ${m.title}`); + lines.push(`ID: ${shortId(m.id)}`); + lines.push(`Agent: ${agent}`); + + if (m.status === 'failed') { + const body = m.error || m.result || '(no error body)'; + lines.push(''); + lines.push(clip(body, RESULT_PREVIEW_CHARS)); + } else if (m.result) { + lines.push(''); + lines.push(clip(m.result, RESULT_PREVIEW_CHARS)); + } + + const artifact = extractArtifactPath(m.result); + if (artifact) { + lines.push(''); + lines.push(`Artifact: ${artifact}`); + } + + return lines.join('\n'); +} + +export function formatBatched(missions: MissionTask[]): string { + const done = missions.filter((m) => m.status === 'completed').length; + const failed = missions.filter((m) => m.status === 'failed').length; + const header = `📋 ${missions.length} missions finished (${done} ok, ${failed} failed):`; + const lines: string[] = [header, '']; + for (const m of missions) { + const glyph = statusGlyph(m.status); + const agent = m.assigned_agent ? `@${m.assigned_agent}` : '(unassigned)'; + const suffix = + m.status === 'failed' && m.error ? ` — ${clip(m.error, 120)}` : ''; + lines.push(`${glyph} ${shortId(m.id)} ${agent} — ${clip(m.title, 80)}${suffix}`); + } + lines.push(''); + lines.push('Reply "/mission result " for details.'); + return lines.join('\n'); +} + +// ── Retry-dispatch ping (out-of-band, not part of the completion buffer) ── + +export interface RetryDispatchPingArgs { + /** New retry mission id (retry_attempt=1). */ + childId: string; + /** Original failed mission id. */ + parentId: string; + /** Agent the retry was dispatched to. Mirrors the parent's assigned_agent. */ + assignedAgent: string | null; + /** Retry mission title — already "[retry] ". */ + title: string; + /** Why the parent failed — used in the ping text. */ + reason: RetryReason; +} + +/** + * Format the single-line Telegram ping for a watchdog-dispatched auto-retry. + * + * Intentionally separate from the completion buffer — the retry itself is a + * dispatch event (not a completion), and we want it to land immediately so + * Aditya sees the auto-retry happened without waiting for the batch window. + */ +export function formatRetryDispatch(args: RetryDispatchPingArgs): string { + const agent = args.assignedAgent ? `@${args.assignedAgent}` : '(unassigned)'; + const reasonLabel = args.reason === 'turn_cap' ? 'turn cap' : 'timeout'; + // Short parent id for readability — mirrors formatSingle's shortId. + const parentShort = args.parentId.slice(0, 8); + return ( + `🔄 Auto-retry: ${agent} "${clip(args.title, 120)}" (attempt 2/2) — ` + + `parent ${parentShort} hit ${reasonLabel}` + ); +} + +/** + * Send the retry-dispatch ping to the main bot chat. Mirrors the fall-open + * behaviour of notifyMissionCompletion — missing env / opt-out / send error + * must never propagate back to the watchdog and block retry dispatch. + * + * Unbuffered on purpose: auto-retries are infrequent and the user wants to + * know IMMEDIATELY that a retry fired, not 2s later bundled with other work. + */ +export async function notifyRetryDispatch(args: RetryDispatchPingArgs): Promise { + if (process.env.MISSION_AUTOPUSH_DISABLED === '1') return; + if (!TELEGRAM_BOT_TOKEN || !ALLOWED_CHAT_ID) { + logger.warn( + { childId: args.childId, parentId: args.parentId }, + 'mission-autopush: TELEGRAM_BOT_TOKEN or ALLOWED_CHAT_ID unset — skipping retry ping', + ); + return; + } + try { + await sendFn(TELEGRAM_BOT_TOKEN, ALLOWED_CHAT_ID, formatRetryDispatch(args)); + } catch (err) { + logger.error( + { err, childId: args.childId, parentId: args.parentId }, + 'mission-autopush: retry dispatch ping failed', + ); + } +} + +// ── Default Telegram sender (AbortController 10s timeout per builder rules) ── + +async function defaultTelegramSend( + token: string, + chatId: string, + text: string, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + try { + const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + // Plain text — no HTML parse_mode — to avoid needing to escape the + // result body (which can contain arbitrary characters from agents). + text, + disable_web_page_preview: true, + }), + signal: controller.signal, + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`telegram ${res.status}: ${body.slice(0, 200)}`); + } + } finally { + clearTimeout(timeout); + } +} diff --git a/src/scheduler.ts b/src/scheduler.ts index a49f242c..918b3554 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -17,6 +17,7 @@ import { messageQueue } from './message-queue.js'; import { runAgent } from './agent.js'; import { formatForTelegram, splitMessage } from './bot.js'; import { emitChatEvent } from './state.js'; +import { notifyMissionCompletion } from './mission-autopush.js'; type Sender = (text: string) => Promise; @@ -159,11 +160,13 @@ async function runDueMissionTasks(): Promise { if (result.aborted) { completeMissionTask(mission.id, null, 'failed', 'Timed out after 10 minutes'); + notifyMissionCompletion(mission.id); logger.warn({ missionId: mission.id }, 'Mission task timed out'); try { await sender('Mission task timed out: "' + mission.title + '"'); } catch {} } else { const text = result.text?.trim() || 'Task completed with no output.'; completeMissionTask(mission.id, text, 'completed'); + notifyMissionCompletion(mission.id); logger.info({ missionId: mission.id }, 'Mission task completed'); // Send result to Telegram @@ -192,6 +195,7 @@ async function runDueMissionTasks(): Promise { clearTimeout(timeout); const errMsg = err instanceof Error ? err.message : String(err); completeMissionTask(mission.id, null, 'failed', errMsg.slice(0, 500)); + notifyMissionCompletion(mission.id); logger.error({ err, missionId: mission.id }, 'Mission task failed'); } finally { runningTaskIds.delete(missionKey);