Files
homeclaw/src/gateway/background-task-runner.ts
T
kimandClaude Sonnet 4.6 9ad63ec8b1 v3.0.8: 스킬앱 독립화 + 치과에이전트 + 음악/MCP/보이스 라우트 + DB 워크플로 개선
- 스킬앱 HTML 내장화: accountant/lawyer/investor/weather/music/mind/dental-agent
  각 앱이 SKILL_CONTENT를 HTML에 직접 내장 → .smallclaw/skills/ SKILL.md 파일 제거
- 새 게이트웨이 라우트: routes-dental, routes-music, routes-mcp, routes-voice
- skillContext 서버 지원: /api/chat에서 skill 컨텍스트를 callerCtx로 주입
- 세션 삭제 API: DELETE /api/admin/sessions/:username/:id
- Telegram defaultUsername: 어드민 계정 자동 추론
- Ollama /api/ollama/models에 vision 지원 여부 플래그 추가
- DB 워크플로 개선: workflow_images.py 대규모 리팩터링
- 아두이노 에뮬레이터 추가 개선 (arduino-emulator.html +400줄)
- 우즈베크어 학습 콘텐츠 및 MIDI 파일 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 17:07:58 +09:00

1371 lines
58 KiB
TypeScript

/**
* background-task-runner.ts
*
* Executes a TaskRecord autonomously in the background, detached from any HTTP request.
* Re-enters handleChat() round-by-round using the task's stored context.
* Writes progress to the task journal. Broadcasts status updates via WebSocket.
*/
import {
loadTask,
saveTask,
updateTaskStatus,
appendJournal,
mutatePlan,
updateResumeContext,
resolveSubagentCompletion,
type TaskRecord,
} from './task-store';
import { clearHistory, addMessage, getHistory, flushSession } from './session';
import { getConfig, getUserWorkspace } from '../config/config';
import { callSecondaryTaskStepAuditor } from '../orchestration/multi-agent';
import { getModelForRole } from '../providers/factory';
import { errorCategorizer } from './error-categorizer';
import { getRetryStrategy } from './retry-strategy';
import { getErrorAnalyzer } from './error-analyzer';
import { getErrorHistory } from './error-history';
import {
callErrorHealer,
callCompletionVerifier,
MAX_HEAL_ATTEMPTS,
} from './task-self-healer';
// Pause registry (global singleton map).
// Server-v2 calls BackgroundTaskRunner.requestPause(id) to signal a running
// task it should stop at the next round boundary.
const pauseRequests = new Set<string>();
// Active runners (prevents duplicate concurrent runners for same task).
const activeRunners = new Set<string>();
const MAX_RESUME_MESSAGES = 10;
const BACKGROUND_SESSION_MAX_MESSAGES = 40;
const DEFAULT_ROUND_TIMEOUT_MS = 120_000;
const MAX_STEP_VERIFICATION_RETRIES = 2;
const MAX_TOTAL_SELF_HEAL_ATTEMPTS = 5;
const MAX_TOTAL_ROUNDS = Number(process.env.LOCALCLAW_BG_MAX_ROUNDS) || 50;
function resolveRoundTimeoutMs(isResearchTask?: boolean): number {
const candidates = [
process.env.LOCALCLAW_BG_ROUND_TIMEOUT_MS,
process.env.LOCALCLAW_TASK_ROUND_TIMEOUT_MS,
];
for (const raw of candidates) {
const n = Number(raw);
if (Number.isFinite(n) && n >= 10_000) return Math.floor(n);
}
// Research tasks (web search, browser automation, news aggregation) need longer timeout
// to account for API calls, page loading, and content synthesis (5 min)
if (isResearchTask) {
return 300_000; // 5 minutes for research tasks
}
return DEFAULT_ROUND_TIMEOUT_MS; // 2 minutes for regular tasks
}
export class BackgroundTaskRunner {
private taskId: string;
private handleChat: (
message: string,
sessionId: string,
sendSSE: (event: string, data: any) => void,
pinnedMessages?: Array<{ role: string; content: string }>,
abortSignal?: { aborted: boolean },
callerContext?: string,
modelOverride?: string,
executionMode?: 'interactive' | 'background_task' | 'heartbeat' | 'cron',
username?: string,
) => Promise<{ type: string; text: string; thinking?: string }>;
private broadcast: (data: object) => void;
private telegramChannel: {
sendToAllowed: (text: string) => Promise<void>;
sendMessage?: (chatId: number, text: string) => Promise<void>;
} | null;
private openingAction: string | undefined;
constructor(
taskId: string,
handleChat: BackgroundTaskRunner['handleChat'],
broadcast: (data: object) => void,
telegramChannel: {
sendToAllowed: (text: string) => Promise<void>;
sendMessage?: (chatId: number, text: string) => Promise<void>;
} | null,
openingAction?: string,
) {
this.taskId = taskId;
this.handleChat = handleChat;
this.broadcast = broadcast;
this.telegramChannel = telegramChannel;
this.openingAction = openingAction;
}
static requestPause(taskId: string): void {
pauseRequests.add(taskId);
}
static isRunning(taskId: string): boolean {
return activeRunners.has(taskId);
}
/**
* Force-release a task from activeRunners.
* Only use when a runner is confirmed dead (e.g. stale 'running' status with no live runner).
*/
static forceRelease(taskId: string): void {
if (activeRunners.has(taskId)) {
console.warn(`[BackgroundTaskRunner] Force-releasing stale activeRunners entry for task ${taskId}`);
activeRunners.delete(taskId);
pauseRequests.delete(taskId);
}
}
/**
* Get list of all currently running task IDs
*/
static getRunningTasks(): string[] {
return Array.from(activeRunners);
}
/**
* Interrupt a task for schedule execution
* Marks the task with schedule context so heartbeat can resume it later
*/
static interruptTaskForSchedule(taskId: string, scheduleId: string): boolean {
if (!activeRunners.has(taskId)) {
return false; // Task not running
}
const task = loadTask(taskId);
if (!task) return false;
// Mark task with schedule interruption context
updateTaskStatus(taskId, 'paused', {
pauseReason: 'interrupted_by_schedule',
pausedByScheduleId: scheduleId,
pausedAt: Date.now(),
pausedAtStepIndex: task.currentStepIndex,
shouldResumeAfterSchedule: true,
});
// Request pause at next round boundary
pauseRequests.add(taskId);
console.log(`[BackgroundTaskRunner] Task ${taskId} interrupted by schedule ${scheduleId}`);
return true;
}
/**
* Resume a task that was paused by a schedule
*/
static resumeTaskAfterSchedule(taskId: string, scheduleId: string): boolean {
const task = loadTask(taskId);
if (!task) return false;
// Only resume if it was paused by this specific schedule
if (task.pausedByScheduleId !== scheduleId) {
console.warn(`[BackgroundTaskRunner] Task ${taskId} not paused by schedule ${scheduleId}`);
return false;
}
// Clear pause context and mark for resumption
updateTaskStatus(taskId, 'running', {
pauseReason: undefined,
pausedByScheduleId: undefined,
pausedAt: undefined,
pausedAtStepIndex: undefined,
shouldResumeAfterSchedule: false,
});
// Start a new runner if not already active
if (!activeRunners.has(taskId)) {
console.log(`[BackgroundTaskRunner] Resuming task ${taskId} after schedule ${scheduleId} completed`);
// Note: caller should invoke new BackgroundTaskRunner(taskId, ...).start()
}
return true;
}
async start(): Promise<void> {
const { taskId } = this;
if (activeRunners.has(taskId)) {
console.log(`[BackgroundTaskRunner] Task ${taskId} already running - skipping duplicate start.`);
return;
}
const task = loadTask(taskId);
if (!task) {
console.error(`[BackgroundTaskRunner] Task ${taskId} not found.`);
return;
}
if (task.status === 'complete' || task.status === 'failed') {
console.log(`[BackgroundTaskRunner] Task ${taskId} is already ${task.status} - nothing to do.`);
return;
}
activeRunners.add(taskId);
pauseRequests.delete(taskId);
try {
await this._run();
} finally {
activeRunners.delete(taskId);
pauseRequests.delete(taskId);
}
}
private _buildCallerContext(task: TaskRecord): string {
const profileNote = task.subagentProfile
? `\nSub-agent role: ${task.subagentProfile}. Stay focused on your assigned task only. Do NOT call delegate_to_specialist or subagent_spawn.`
: '';
const resumeNote = task.resumeContext?.onResumeInstruction
? `\n${task.resumeContext.onResumeInstruction}`
: '';
// Add specific guidance for research/news tasks
const isResearchTask = /\b(research|search|news|articles?|web.*search|browser|gather|collect|summari)\b/i.test(
task.prompt + ' ' + task.title
);
// Detect X.com / Twitter tasks and inject login state guidance
const isXTask = /\b(x\.com|twitter|tweet|retweet|post.*tweet|reply.*tweet)\b/i.test(
task.prompt + ' ' + task.title
);
const xLoginGuidance = isXTask
? `\nX.COM LOGIN NOTE: If the browser page title shows "(N) Home / X", "Home / X", or any title ending in "/ X", ` +
`the user IS already logged in to X — do NOT ask to confirm login or suggest they log in. ` +
`Proceed directly with the task action (compose tweet, click reply, etc.).` +
`\nX.COM POSTING FLOW: When posting a tweet, follow EXACTLY these steps and NO others:\n` +
` 1. browser_open("https://x.com") — the snapshot shows the composer at the top.\n` +
` 2. Find the textbox with name "Post text" or "What's happening" in the snapshot — browser_fill it.\n` +
` 3. The fill result shows "⚠️ COMPOSER SUBMIT BUTTON: @N" — browser_click(@N) immediately.\n` +
` 4. If browser_fill auto-posted (result says "Tweet has been posted successfully"), you are DONE. Stop immediately.\n` +
` 5. Only call browser_snapshot ONCE to verify if auto-post did NOT happen. Then STOP.\n` +
`Do NOT scroll, press PageDown, or take additional snapshots after confirming the tweet posted. ` +
`Do NOT call browser_snapshot before filling. ` +
`Do NOT call browser_snapshot after browser_open — it already returned a snapshot. ` +
`After the tweet is confirmed posted, your ONLY valid next action is writing your FINAL: summary. ` +
`There are ZERO valid reasons to scroll before filling the composer or after confirming the post.`
: '';
const researchGuidance = isResearchTask ? [
``,
`[RESEARCH TASK GUIDANCE - SNAPSHOT → FETCH → SYNTHESIZE FLOW]`,
`Your research follows a strict 3-phase flow. Execute each phase fully before moving to the next:`,
``,
`PHASE 1: COLLECT SNAPSHOTS (identify article sources)`,
` • browser_open() to news sites (Reuters, AP, BBC, CNN, etc)`,
` • browser_snapshot() to see the page structure`,
` • Look at the snapshot Elements list - find links/headlines (elements with @##)`,
` • Do NOT stop at snapshot. Always proceed to Phase 2.`,
``,
`PHASE 2: FETCH CONTENT (extract actual article text)`,
` • From snapshot elements, identify article URLs in the link references`,
` • Use web_fetch(url) on 4-6 different article URLs to get full text`,
` • Store key facts/headlines from each article as you fetch`,
` • After fetching articles, you have real data to work with`,
``,
`PHASE 3: SYNTHESIZE (analyze and deliver final answer)`,
` • Review all fetched content together - identify 3-5 most significant events`,
` • Cross-reference facts (does story appear in multiple sources?)`,
` • Create concise bullets with facts + source attribution`,
` • Deliver final summary to user with citations`,
``,
`CRITICAL: If you say "Snapshot complete. Awaiting next directive" — STOP and re-read this.`,
`That phrase means you've stalled in Phase 1. CONTINUE to Phase 2: use web_fetch() on URLs.`,
`Each phase builds on the previous. Do not pause between phases; execute the full flow.`,
`[/RESEARCH TASK GUIDANCE]`,
].join('\n') : '';
const workspacePath = task.ownerUsername
? getUserWorkspace(task.ownerUsername)
: getConfig().getWorkspacePath();
const envInfo = [
``,
`[EXECUTION ENVIRONMENT]`,
`OS: Linux — do NOT use Windows commands (taskkill, cmd.exe, backslashes, python.exe)`,
`Python: always use "python3" (not "python")`,
`Shell: bash`,
`Workspace path: ${workspacePath}`,
`IMPORTANT: This is a background task. You cannot schedule future check-ins via write_note.`,
`If you need to poll a running process, wait inline with a shell loop. Do not write "will check at HH:MM" — that check will never happen.`,
`[/EXECUTION ENVIRONMENT]`,
].join('\n');
return [
`[BACKGROUND TASK CONTEXT]`,
`Task ID: ${task.id}`,
`Task Title: ${task.title}`,
`Original Request: ${task.prompt.slice(0, 400)}`,
`Current Step: ${task.currentStepIndex + 1}/${task.plan.length}`,
task.plan[task.currentStepIndex]
? `Step Description: ${task.plan[task.currentStepIndex].description}`
: '',
`You are running autonomously. Execute the task step by step.${profileNote}${resumeNote}${xLoginGuidance}${researchGuidance}${envInfo}`,
`[/BACKGROUND TASK CONTEXT]`,
].filter(Boolean).join('\n');
}
private _restoreSessionForRetry(sessionId: string, resumeMessages: any[], username?: string): void {
clearHistory(sessionId, username);
for (const msg of resumeMessages) {
if (msg && (msg.role === 'user' || msg.role === 'assistant')) {
addMessage(sessionId, {
role: msg.role,
content: String(msg.content || ''),
timestamp: msg.timestamp || Date.now(),
}, {
disableMemoryFlushCheck: true,
disableCompactionCheck: true,
disableAutoSave: true,
maxMessages: BACKGROUND_SESSION_MAX_MESSAGES,
}, username);
}
}
}
private _persistResumeContextSnapshot(taskId: string, sessionId: string, username?: string): void {
const task = loadTask(taskId);
const existingRound = Number(task?.resumeContext?.round) || 0;
const sessionHistory = getHistory(sessionId, 40, username);
updateResumeContext(taskId, {
messages: sessionHistory.slice(-MAX_RESUME_MESSAGES).map(h => ({
role: h.role,
content: h.content,
timestamp: h.timestamp,
})),
round: existingRound,
});
}
/**
* Fast-path check: did the model's result already satisfy the top-level goal,
* even though we're mid-plan? Looks for explicit TASK_COMPLETE signals or
* result text that clearly matches the original task prompt.
*
* Returns true only when there is strong evidence the user's goal is done.
* Erring on the side of false keeps the normal verifier path as the default.
*/
private _isGoalAchievedEarly(task: TaskRecord, resultText: string): boolean {
const text = resultText.toLowerCase();
// Explicit model signal
if (/task[_\s-]?complete[:\s]/i.test(resultText)) return true;
// The model quoted or summarised the original goal and said it's done
const referenceWords = task.prompt.toLowerCase().split(/\s+/).filter(w => w.length > 4).slice(0, 12);
const hitCount = referenceWords.filter(w => text.includes(w)).length;
const hitRatio = referenceWords.length > 0 ? hitCount / referenceWords.length : 0;
const completionPhrases = [
'successfully sent', 'has replied', 'chatgpt responded', 'chatgpt replied',
'message sent', 'reply received', 'goal accomplished', 'already done',
'already completed', 'already achieved', 'task is done', 'task already',
'objective met', 'objective achieved',
];
const hasCompletionPhrase = completionPhrases.some(p => text.includes(p));
// Strong signal: result references the goal AND contains a completion phrase
if (hitRatio >= 0.5 && hasCompletionPhrase) return true;
// Very strong signal: step 1 already captured the full answer
// (e.g. the browser opened, the message was sent, the reply was read)
if (task.currentStepIndex === 0 && hasCompletionPhrase && hitRatio >= 0.3) return true;
return false;
}
private async _withRoundTimeout<T>(
op: Promise<T>,
timeoutMs: number,
abortSignal?: { aborted: boolean },
): Promise<T> {
let timeoutId: NodeJS.Timeout | null = null;
const timeoutPromise = new Promise<T>((_, reject) => {
timeoutId = setTimeout(() => {
if (abortSignal) abortSignal.aborted = true;
reject(new Error(`Round timeout (${Math.round(timeoutMs / 1000)}s)`));
}, timeoutMs);
if (timeoutId && typeof (timeoutId as any).unref === 'function') {
(timeoutId as any).unref();
}
});
try {
return await Promise.race([op, timeoutPromise]);
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}
private async _runRoundWithRetry(
task: TaskRecord,
prompt: string,
sessionId: string,
sendSSE: (event: string, data: any) => void,
abortSignal: { aborted: boolean },
): Promise<
| { ok: true; result: { type: string; text: string; thinking?: string } }
| { ok: false; reason: string; detail: string }
> {
const MAX_TRANSPORT_RETRIES = 2;
const RETRY_DELAY_MS = 4000;
// Detect if this is a research task (needs longer timeout for web search + synthesis)
const isResearchTask = /\b(research|search|news|articles?|web.*search|browser|scroll|page|google)\b/i.test(
task.prompt + ' ' + task.title
);
const roundTimeoutMs = resolveRoundTimeoutMs(isResearchTask);
const resumeMessages = Array.isArray(task.resumeContext?.messages)
? task.resumeContext.messages.slice(-MAX_RESUME_MESSAGES)
: [];
const callerContext = this._buildCallerContext(task);
for (let attempt = 0; attempt <= MAX_TRANSPORT_RETRIES; attempt++) {
let attemptResult: { type: string; text: string; thinking?: string };
const attemptAbortSignal = { aborted: abortSignal.aborted };
const bgModel = getModelForRole('background_task');
const bgModelOverride = bgModel || undefined;
try {
attemptResult = await this._withRoundTimeout(
this.handleChat(
prompt,
sessionId,
sendSSE,
undefined,
attemptAbortSignal,
callerContext,
bgModelOverride,
'background_task',
task.ownerUsername,
),
roundTimeoutMs,
attemptAbortSignal,
);
} catch (retryErr: any) {
const errMsg = String(retryErr?.message || retryErr || 'unknown');
appendJournal(task.id, {
type: 'error',
content: `Attempt ${attempt + 1} threw: ${errMsg.slice(0, 200)}`,
});
if (attempt < MAX_TRANSPORT_RETRIES) {
await new Promise(r => setTimeout(r, RETRY_DELAY_MS * (attempt + 1)));
this._restoreSessionForRetry(sessionId, resumeMessages, task.ownerUsername);
continue;
}
return {
ok: false,
reason: `Task stopped after ${MAX_TRANSPORT_RETRIES + 1} failed attempts.`,
detail: errMsg.slice(0, 600),
};
}
const text = String(attemptResult.text || '');
const isTransportError =
text.startsWith('Error: Ollama')
|| text.startsWith('Error: fetch failed')
|| text.startsWith('Error: provider')
|| text.includes('fetch failed');
if (isTransportError) {
const errSnippet = text.slice(0, 200);
// Use RetryStrategy for smarter backoff tracking
const retryStrategy = getRetryStrategy();
if (!retryStrategy.getState(task.id)) {
retryStrategy.createRetryState(task.id, {
maxAttempts: MAX_TRANSPORT_RETRIES + 1,
baseDelayMs: RETRY_DELAY_MS,
maxDelayMs: 30000,
jitter: true,
});
}
const retryResult = retryStrategy.recordAttempt(task.id);
appendJournal(task.id, {
type: 'error',
content: `Transport error (attempt ${retryResult.attemptsUsed}/${MAX_TRANSPORT_RETRIES + 1}): ${errSnippet}`,
});
console.warn(`[BackgroundTaskRunner] Task ${task.id} transport error attempt ${retryResult.attemptsUsed}:`, errSnippet);
if (retryResult.canRetry && attempt < MAX_TRANSPORT_RETRIES) {
appendJournal(task.id, {
type: 'status_push',
content: `Retrying in ${retryResult.delayMs}ms (attempt ${retryResult.attemptsUsed}/${MAX_TRANSPORT_RETRIES + 1})`,
});
await new Promise(r => setTimeout(r, retryResult.delayMs || RETRY_DELAY_MS * (attempt + 1)));
this._restoreSessionForRetry(sessionId, resumeMessages, task.ownerUsername);
continue;
}
// Retries exhausted — clear state and surface error
retryStrategy.clearState(task.id);
return {
ok: false,
reason: `Task paused after transport retries were exhausted at step ${task.currentStepIndex + 1}.`,
detail: errSnippet,
};
}
if (text.startsWith('Error:')) {
appendJournal(task.id, {
type: 'error',
content: `Model returned error: ${text.slice(0, 200)}`,
});
return {
ok: false,
reason: `Task paused because the model returned an unrecoverable error at step ${task.currentStepIndex + 1}.`,
detail: text.slice(0, 600),
};
}
return { ok: true, result: attemptResult };
}
return {
ok: false,
reason: 'Task paused because no valid result was produced.',
detail: 'No result after retry loop.',
};
}
private async _run(): Promise<void> {
const { taskId } = this;
updateTaskStatus(taskId, 'running');
appendJournal(taskId, { type: 'resume', content: 'Runner started.' });
const initialTask = loadTask(taskId);
if (!initialTask) return;
this._broadcast('task_running', { taskId, title: initialTask.title });
const ownerUsername = initialTask.ownerUsername;
// Keep session ID deterministic per task so resume restores the same context key.
// clearHistory() prevents stale cross-run contamination while preserving this mapping.
const sessionId = `task_${taskId}`;
clearHistory(sessionId, ownerUsername);
// Restore conversation context from prior runs.
const initialMessages = Array.isArray(initialTask.resumeContext?.messages)
? initialTask.resumeContext.messages.slice(-MAX_RESUME_MESSAGES)
: [];
if (initialMessages.length > 0) {
for (const msg of initialMessages) {
if (msg && (msg.role === 'user' || msg.role === 'assistant')) {
addMessage(sessionId, {
role: msg.role,
content: String(msg.content || ''),
timestamp: msg.timestamp || Date.now(),
}, {
disableMemoryFlushCheck: true,
disableCompactionCheck: true,
disableAutoSave: true,
maxMessages: BACKGROUND_SESSION_MAX_MESSAGES,
}, ownerUsername);
}
}
appendJournal(taskId, {
type: 'resume',
content: `Restored ${initialMessages.length} message(s) from prior run context.`,
});
}
// Fake SSE sender writing to task journal.
const signatureRounds: string[][] = [];
const toolSignatureCounts = new Map<string, number>();
let currentRoundSignatures: string[] = [];
let roundStallReason: string | null = null;
// Full evidence log for the current round — used by the step auditor.
let currentRoundToolLog: Array<{ tool: string; args: any; result: string; error: boolean }> = [];
const finalizeRoundSignatures = (): void => {
signatureRounds.push(currentRoundSignatures);
while (signatureRounds.length > 6) {
const dropped = signatureRounds.shift() || [];
for (const sig of dropped) {
const next = (toolSignatureCounts.get(sig) || 0) - 1;
if (next <= 0) toolSignatureCounts.delete(sig);
else toolSignatureCounts.set(sig, next);
}
}
};
const sendSSE = (event: string, data: any) => {
if (event === 'tool_call') {
// Refresh the open task panel on every tool call so steps update in real-time.
// task_step_done only fires after verification, so without this the panel is
// stale while the task is actively running between step completions.
this._broadcast('task_panel_update', { taskId });
const sig = `${String(data.action || 'unknown')}:${JSON.stringify(data.args || {})}`;
currentRoundSignatures.push(sig);
const next = (toolSignatureCounts.get(sig) || 0) + 1;
toolSignatureCounts.set(sig, next);
// Stall detection: browser research tools (scroll/wait/key) get a high threshold since
// they naturally repeat while exploring pages. Snapshots get a tighter limit (5) because
// repeated identical snapshots with no intervening action = the AI is stuck in a loop.
// Nav tools (click/fill/open) also get a higher threshold for research flows.
const isBrowserSnapshotTool = /^browser_snapshot$/i.test(String(data.action || ''));
const isBrowserResearchTool = /^browser_(press_key|wait|scroll)$/i.test(String(data.action || ''));
const isBrowserNavTool = /^browser_(click|fill|open)$/i.test(String(data.action || ''));
const isShellTool = /^shell$/i.test(String(data.action || ''));
const stallThreshold = isBrowserSnapshotTool ? 3 // snapshots: stall after 3 identical
: isBrowserResearchTool ? 6 // scroll/wait/key: 6 — looping without acting
: isBrowserNavTool ? 20 // click/fill/open: 20 for nav flows
: isShellTool ? 6 // shell: 6 — allow retries with slight variation
: 4; // all other tools: 4
if (next > stallThreshold && !roundStallReason) {
roundStallReason = `Stall detected: ${String(data.action || 'unknown')} called ${next} times without progress (last 6 rounds).`;
}
// Also detect: all tools this round are scroll/wait/snapshot with zero clicks/fills/opens.
// Only flag after 6+ such calls — short scroll sequences (e.g. post-tweet verification)
// are normal and should not be treated as stalls.
if (!roundStallReason && currentRoundSignatures.length >= 6) {
const hasNavAction = currentRoundSignatures.some(s => /^browser_(click|fill|open):/.test(s));
const allScroll = currentRoundSignatures.every(s => /^browser_(press_key|wait|scroll|snapshot):/.test(s));
if (!hasNavAction && allScroll) {
roundStallReason = `Stall detected: ${currentRoundSignatures.length} consecutive scroll/wait/snapshot calls with no click, fill, or navigation action. Agent is looping without interacting with the page.`;
}
}
appendJournal(taskId, {
type: 'tool_call',
content: `${data.action || 'unknown'}(${JSON.stringify(data.args || {}).slice(0, 80)})`,
});
this._broadcast('task_tool_call', { taskId, tool: data.action, args: data.args });
// Pre-populate an entry; result will be filled in by the tool_result handler.
currentRoundToolLog.push({ tool: String(data.action || 'unknown'), args: data.args ?? {}, result: '', error: false });
} else if (event === 'tool_result') {
appendJournal(taskId, {
type: 'tool_result',
content: `${data.action || 'unknown'}: ${String(data.result || '').slice(0, 120)}${data.error ? ' [ERROR]' : ''}`,
detail: data.error ? String(data.result || '') : undefined,
});
// Track repeated shell errors so we can inject hints into the next prompt.
if (data.error && /^shell$/i.test(String(data.action || ''))) {
const cmd = String(data.args?.command || '').slice(0, 200);
const prev = shellErrorHints.get(cmd) || { count: 0, lastError: '' };
shellErrorHints.set(cmd, { count: prev.count + 1, lastError: String(data.result || '').slice(0, 300) });
}
// Fill in result for the matching pending entry so the auditor has full evidence.
for (let i = currentRoundToolLog.length - 1; i >= 0; i--) {
if (currentRoundToolLog[i].tool === (data.action || 'unknown') && currentRoundToolLog[i].result === '') {
currentRoundToolLog[i].result = String(data.result || '').slice(0, 1200);
currentRoundToolLog[i].error = !!data.error;
break;
}
}
}
};
const abortSignal = { aborted: false };
let firstRound = true;
let lastResultSummary = '';
const stepRetryHints = new Map<number, string>();
const stepVerificationRetries = new Map<number, number>();
let totalRounds = 0;
let totalSelfHealAttempts = 0;
// Track shell commands that have repeatedly failed so we can inject hints
const shellErrorHints = new Map<string, { count: number; lastError: string }>();
while (true) {
totalRounds++;
const task = loadTask(taskId);
if (!task) return;
if (task.status === 'complete' || task.status === 'failed') return;
if (totalRounds > MAX_TOTAL_ROUNDS) {
appendJournal(taskId, {
type: 'status_push',
content: `Reached maximum total rounds (${MAX_TOTAL_ROUNDS}). Pausing for assistance.`,
});
await this._pauseForAssistance(task, 'max_rounds_reached',
`Task exceeded ${MAX_TOTAL_ROUNDS} total execution rounds.`);
return;
}
if (pauseRequests.has(taskId)) {
const pauseReason = task.pauseReason || 'user_pause';
const scheduleId = task.pausedByScheduleId;
updateTaskStatus(taskId, 'paused', { pauseReason });
let pauseMsg = 'Paused by user request.';
if (pauseReason === 'interrupted_by_schedule' && scheduleId) {
pauseMsg = `Paused by scheduled task (schedule: ${scheduleId}). Will resume after schedule completes.`;
}
appendJournal(taskId, { type: 'pause', content: pauseMsg });
this._broadcast('task_paused', { taskId, reason: pauseReason, scheduleId });
flushSession(sessionId, ownerUsername);
return;
}
// Parent is blocked waiting for child sub-agents to finish — exit loop.
// scheduleTaskFollowup() will re-queue this task when all children complete.
if (task.status === 'waiting_subagent') {
activeRunners.delete(taskId);
appendJournal(taskId, { type: 'pause', content: 'Waiting for sub-agents to complete.' });
flushSession(sessionId, ownerUsername);
return;
}
if (task.currentStepIndex >= task.plan.length) {
// All plan steps complete — now do a final synthesis round to format response for user
if (!task.finalSummary) {
// Check if any other tasks are paused by this task (were interrupted by schedules)
const pausedTasksNote = task.pausedByScheduleId
? `\n\nNote: This task is paused by schedule ${task.pausedByScheduleId} and will resume later.`
: '';
const synthesisPrompt = [
`Task "${task.title}" has completed all planned steps.`,
`Here is what you found/accomplished:`,
`${(lastResultSummary || 'Task execution complete').slice(0, 500)}`,
``,
`Now format this as a concise, clear response directly to the user about their original request: "${task.prompt.slice(0, 200)}"`,
`Do NOT say "Task complete" or mention this was a background task. Just give them the information they asked for.${pausedTasksNote}`,
].join('\n');
updateTaskStatus(taskId, 'running');
currentRoundToolLog = [];
const synthesisOutcome = await this._runRoundWithRetry(task, synthesisPrompt, sessionId, sendSSE, abortSignal);
if (synthesisOutcome.ok && synthesisOutcome.result?.text) {
const synthesisText = String(synthesisOutcome.result.text || '').trim();
// ── Completion verifier: ensure the final message is actually good ──
const freshTaskForVerify = loadTask(taskId);
const resynthAttempts = Number(freshTaskForVerify?.resynthAttempts) || 0;
const verifyDecision = await callCompletionVerifier({
task: freshTaskForVerify || task,
finalMessage: synthesisText,
resynthAttempt: resynthAttempts,
});
appendJournal(taskId, {
type: 'status_push',
content: `[Verifier] ${verifyDecision.action}: ${verifyDecision.reasoning}`,
});
if (verifyDecision.action === 'RESYNTH') {
// One more synthesis round with the verifier's hint injected
const freshTask2 = loadTask(taskId);
if (freshTask2) {
freshTask2.resynthAttempts = resynthAttempts + 1;
saveTask(freshTask2);
}
const resynthPrompt = [
`Task "${task.title}" needs an improved final response.`,
`Verifier feedback: ${verifyDecision.hint}`,
`Original request: "${task.prompt.slice(0, 200)}"`,
`Previous attempt: ${synthesisText.slice(0, 400)}`,
`Produce a complete, corrected response now.`,
].join('\n');
const resynthOutcome = await this._runRoundWithRetry(task, resynthPrompt, sessionId, sendSSE, abortSignal);
const finalMsg = resynthOutcome.ok
? String(resynthOutcome.result.text || synthesisText).trim()
: synthesisText;
updateTaskStatus(taskId, 'complete', { finalSummary: finalMsg });
appendJournal(taskId, { type: 'status_push', content: 'Task complete. Re-synthesized and verified.' });
this._broadcast('task_complete', { taskId, summary: finalMsg });
await this._deliverToChannel(task, finalMsg);
this._persistResumeContextSnapshot(taskId, sessionId, ownerUsername);
flushSession(sessionId, ownerUsername);
return;
}
// DELIVER or DELIVER_ANYWAY — use verifier's (possibly cleaned) message
const deliverMsg = verifyDecision.message || synthesisText;
updateTaskStatus(taskId, 'complete', { finalSummary: deliverMsg });
appendJournal(taskId, { type: 'status_push', content: 'Task complete. Final synthesis verified.' });
this._broadcast('task_complete', { taskId, summary: deliverMsg });
await this._deliverToChannel(task, deliverMsg);
this._persistResumeContextSnapshot(taskId, sessionId, ownerUsername);
flushSession(sessionId, ownerUsername);
return;
} else {
// Synthesis failed, use last known summary
const fallbackSummary = lastResultSummary || 'Task completed all planned steps.';
updateTaskStatus(taskId, 'complete', { finalSummary: fallbackSummary });
appendJournal(taskId, { type: 'status_push', content: 'Task complete (synthesis round skipped).' });
this._broadcast('task_complete', { taskId, summary: fallbackSummary });
await this._deliverToChannel(task, fallbackSummary);
this._persistResumeContextSnapshot(taskId, sessionId, ownerUsername);
flushSession(sessionId, ownerUsername);
return;
}
} else {
// finalSummary already set, just mark complete and deliver
updateTaskStatus(taskId, 'complete', { finalSummary: task.finalSummary });
appendJournal(taskId, { type: 'status_push', content: 'Task complete: final summary already prepared.' });
this._broadcast('task_complete', { taskId, summary: task.finalSummary });
await this._deliverToChannel(task, task.finalSummary);
this._persistResumeContextSnapshot(taskId, sessionId, ownerUsername);
flushSession(sessionId, ownerUsername);
return;
}
}
updateTaskStatus(taskId, 'running');
const currentStep = task.plan[task.currentStepIndex];
const retryHint = stepRetryHints.get(task.currentStepIndex);
const failedCmds = Array.from(shellErrorHints.entries())
.filter(([, v]) => v.count >= 2)
.map(([cmd, v]) => ` - "${cmd}" (failed ${v.count}x): ${v.lastError.slice(0, 150)}`);
const shellErrorNote = failedCmds.length > 0
? `\nSHELL ERROR HISTORY (do NOT retry these commands — try a different approach):\n${failedCmds.join('\n')}`
: '';
const prompt = firstRound
? (
this.openingAction
? `[Resuming task from heartbeat. Opening action: ${this.openingAction}]\n\n${task.prompt}`
: task.prompt
)
: [
`Continue task: ${task.title}`,
``,
`CURRENT STEP: ${task.currentStepIndex + 1} of ${task.plan.length}`,
`STEP GOAL: ${currentStep?.description || 'No step description provided.'}`,
``,
`You MUST complete this step before moving on. When done, clearly state what you did to complete it so the verifier can confirm.`,
retryHint ? `VERIFIER FEEDBACK (previous attempt failed): ${retryHint}` : '',
shellErrorNote,
`REMAINING STEPS:`,
...task.plan.slice(task.currentStepIndex + 1).map((s, i) =>
` Step ${task.currentStepIndex + 2 + i}: ${s.description}`
),
``,
`Previous result: ${(lastResultSummary || 'No previous result.').slice(0, 300)}`,
].filter(Boolean).join('\n');
firstRound = false;
currentRoundSignatures = [];
roundStallReason = null;
currentRoundToolLog = [];
const roundOutcome = await this._runRoundWithRetry(task, prompt, sessionId, sendSSE, abortSignal);
finalizeRoundSignatures();
if (roundStallReason) {
// Deliver whatever the agent produced before pausing — the inline reasoning /
// final message was already computed but never sent because the stall check
// fires before _deliverToChannel. Flush it now so the user sees it in chat.
const partialResult = roundOutcome.ok ? String(roundOutcome.result?.text || '').trim() : '';
const isBrowserScrollLoop = /scroll|press_key|snapshot.*loop|looping without/i.test(roundStallReason);
if (partialResult) {
try {
const freshTask = loadTask(taskId);
if (freshTask) {
await this._deliverToChannel(freshTask, partialResult);
}
} catch { /* best effort */ }
}
// If it's a scroll/snapshot loop stall AND the model already sent a message,
// silently pause without the noisy error blast — user already got the model's reply.
if (isBrowserScrollLoop && partialResult) {
updateTaskStatus(task.id, 'needs_assistance', { pauseReason: 'error' });
appendJournal(task.id, {
type: 'pause',
content: `Task paused (browser loop detected): ${String(roundStallReason).slice(0, 220)}`,
});
this._broadcast('task_paused', { taskId: task.id, reason: 'needs_assistance' });
return;
}
await this._pauseForAssistance(task, roundStallReason);
return;
}
if (!roundOutcome.ok) {
await this._pauseForAssistance(task, roundOutcome.reason, roundOutcome.detail);
return;
}
const result = roundOutcome.result;
lastResultSummary = String(result.text || '').replace(/\s+/g, ' ').trim();
const sessionHistory = getHistory(sessionId, 40, ownerUsername);
updateResumeContext(taskId, {
messages: sessionHistory.slice(-MAX_RESUME_MESSAGES).map(h => ({
role: h.role,
content: h.content,
timestamp: h.timestamp,
})),
round: (Number(task.resumeContext?.round) || 0) + 1,
});
flushSession(sessionId, ownerUsername);
if (pauseRequests.has(taskId)) {
const task = loadTask(taskId);
const pauseReason = task?.pauseReason || 'user_pause';
const scheduleId = task?.pausedByScheduleId;
updateTaskStatus(taskId, 'paused', { pauseReason });
let pauseMsg = 'Paused by user request.';
if (pauseReason === 'interrupted_by_schedule' && scheduleId) {
pauseMsg = `Paused by scheduled task (schedule: ${scheduleId}). Will resume after schedule completes.`;
}
appendJournal(taskId, { type: 'pause', content: pauseMsg });
this._broadcast('task_paused', { taskId, reason: pauseReason, scheduleId });
flushSession(sessionId, ownerUsername);
return;
}
const freshTask = loadTask(taskId);
if (!freshTask || !freshTask.plan[freshTask.currentStepIndex]) {
updateTaskStatus(taskId, 'complete', { finalSummary: result.text });
appendJournal(taskId, { type: 'status_push', content: `Task complete: ${result.text.slice(0, 200)}` });
this._broadcast('task_complete', { taskId, summary: result.text });
await this._deliverToChannel(task, `Task complete: ${task.title}\n\n${result.text}`, { forceTelegram: true });
this._persistResumeContextSnapshot(taskId, sessionId, ownerUsername);
flushSession(sessionId, ownerUsername);
return;
}
// ── Early goal-completion fast-path ──────────────────────────────────
// If the model's result already satisfies the original user goal
// (e.g. it opened ChatGPT, sent the message, and got a reply in step 1),
// mark the task complete immediately without running the remaining plan steps.
{
const freshForGoalCheck = loadTask(taskId);
if (freshForGoalCheck && this._isGoalAchievedEarly(freshForGoalCheck, lastResultSummary)) {
const summary = lastResultSummary.slice(0, 400);
updateTaskStatus(taskId, 'complete', { finalSummary: summary });
appendJournal(taskId, {
type: 'status_push',
content: `Goal achieved early at step ${freshForGoalCheck.currentStepIndex + 1} — skipping remaining ${freshForGoalCheck.plan.length - freshForGoalCheck.currentStepIndex - 1} step(s). ${summary}`,
});
this._broadcast('task_complete', { taskId, summary });
await this._deliverToChannel(freshForGoalCheck, `Task complete: ${freshForGoalCheck.title}\n\n${summary}`, { forceTelegram: true });
this._persistResumeContextSnapshot(taskId, sessionId, ownerUsername);
flushSession(sessionId, ownerUsername);
return;
}
}
// If handleChat hit its internal tool-round cap, skip verification and
// just continue to the next round — the step isn't done yet but the work
// is still in progress. Treating this as a verification failure would
// incorrectly burn retries and eventually kill the task.
const hitMaxSteps = /^hit max steps/i.test(lastResultSummary);
if (hitMaxSteps) {
appendJournal(taskId, { type: 'status_push', content: 'Round hit max tool steps - continuing to next round.' });
continue;
}
// Multi-step evidence audit:
// Ask the secondary model to inspect this round's tool evidence and
// mark every pending plan step that is provably complete.
const pendingSteps = freshTask.plan
.map((s, i) => ({ index: i, description: s.description, status: s.status }))
.filter(s => s.status !== 'done' && s.status !== 'skipped');
const auditResult = await callSecondaryTaskStepAuditor({
pendingSteps: pendingSteps.map(s => ({ index: s.index, description: s.description })),
toolCallLog: currentRoundToolLog,
resultText: lastResultSummary,
});
if (!auditResult || auditResult.completed_steps.length === 0) {
// Auditor found nothing done, treat as incomplete and retry.
const stepIndex = freshTask.currentStepIndex;
const retries = (stepVerificationRetries.get(stepIndex) || 0) + 1;
stepVerificationRetries.set(stepIndex, retries);
const reason = auditResult
? 'No plan steps were evidenced as complete by this round\'s tool calls.'
: 'Step auditor unavailable; assuming incomplete.';
stepRetryHints.set(stepIndex, reason);
appendJournal(taskId, {
type: 'status_push',
content: `Auditor found no completed steps (${retries}/${MAX_STEP_VERIFICATION_RETRIES}): ${reason}`,
});
if (retries >= MAX_STEP_VERIFICATION_RETRIES) {
totalSelfHealAttempts++;
if (totalSelfHealAttempts > MAX_TOTAL_SELF_HEAL_ATTEMPTS) {
appendJournal(taskId, {
type: 'status_push',
content: `Self-heal attempts exceeded limit (${MAX_TOTAL_SELF_HEAL_ATTEMPTS}). Escalating to user.`,
});
await this._pauseForAssistance(
task,
`Self-heal attempts exceeded limit (${MAX_TOTAL_SELF_HEAL_ATTEMPTS}).`,
'Too many self-heal attempts without progress.',
);
flushSession(sessionId, ownerUsername);
return;
}
const healed = await this._attemptSelfHeal(
task,
`Step ${stepIndex + 1} failed verification after ${retries} retries.`,
reason,
lastResultSummary,
sessionId,
ownerUsername,
);
if (healed === 'continue') { stepVerificationRetries.delete(stepIndex); continue; }
if (healed === 'complete') { flushSession(sessionId, ownerUsername); return; }
// healed === 'escalate' — fall through to _pauseForAssistance
await this._pauseForAssistance(
task,
`Step ${stepIndex + 1} failed verification after ${retries} retries.`,
reason,
);
flushSession(sessionId, ownerUsername);
return;
}
continue;
}
const completedIndices = Array.from(new Set(auditResult.completed_steps))
.filter((idx) => Number.isInteger(idx) && idx >= 0 && idx < freshTask.plan.length)
.sort((a, b) => a - b);
if (completedIndices.length === 0) {
const stepIndex = freshTask.currentStepIndex;
const retries = (stepVerificationRetries.get(stepIndex) || 0) + 1;
stepVerificationRetries.set(stepIndex, retries);
const reason = 'Auditor returned only out-of-range step indices.';
stepRetryHints.set(stepIndex, reason);
appendJournal(taskId, {
type: 'status_push',
content: `Auditor result rejected (${retries}/${MAX_STEP_VERIFICATION_RETRIES}): ${reason}`,
});
if (retries >= MAX_STEP_VERIFICATION_RETRIES) {
totalSelfHealAttempts++;
if (totalSelfHealAttempts > MAX_TOTAL_SELF_HEAL_ATTEMPTS) {
appendJournal(taskId, {
type: 'status_push',
content: `Self-heal attempts exceeded limit (${MAX_TOTAL_SELF_HEAL_ATTEMPTS}). Escalating to user.`,
});
await this._pauseForAssistance(
task,
`Self-heal attempts exceeded limit (${MAX_TOTAL_SELF_HEAL_ATTEMPTS}).`,
'Too many self-heal attempts without progress.',
);
flushSession(sessionId, ownerUsername);
return;
}
const healed = await this._attemptSelfHeal(
task,
`Step ${stepIndex + 1} failed verification after ${retries} retries.`,
reason,
lastResultSummary,
sessionId,
ownerUsername,
);
if (healed === 'continue') { stepVerificationRetries.delete(stepIndex); continue; }
if (healed === 'complete') { flushSession(sessionId, ownerUsername); return; }
await this._pauseForAssistance(
task,
`Step ${stepIndex + 1} failed verification after ${retries} retries.`,
reason,
);
flushSession(sessionId, ownerUsername);
return;
}
continue;
}
const mutations = completedIndices.map((idx) => ({
op: 'complete' as const,
step_index: idx,
notes: (auditResult.notes[idx] || lastResultSummary).slice(0, 200),
}));
// Apply any structural plan mutations the auditor recommended (add/skip/modify steps)
// This is how the plan adapts mid-execution: skip redundant steps, add recovery steps,
// correct step descriptions based on what was actually discovered.
if (auditResult.plan_mutations && auditResult.plan_mutations.length > 0) {
const adaptMutations: Parameters<typeof mutatePlan>[1] = [];
for (const m of auditResult.plan_mutations) {
if (m.op === 'add') {
adaptMutations.push({ op: 'add', after_index: m.after_index, description: m.description });
} else if (m.op === 'skip') {
// 'skip' translates to completing the step with a skip note
adaptMutations.push({ op: 'complete', step_index: m.step_index, notes: `[SKIPPED] ${m.reason}` });
} else if (m.op === 'modify') {
adaptMutations.push({ op: 'modify', step_index: m.step_index, description: m.description });
}
}
if (adaptMutations.length > 0) {
appendJournal(taskId, {
type: 'status_push',
content: `Auditor adapted plan: ${auditResult.plan_mutations.map(m => `${m.op}@${(m as any).step_index ?? (m as any).after_index}`).join(', ')}`,
});
mutatePlan(taskId, adaptMutations);
this._broadcast('task_step_done', { taskId, planAdapted: true, mutations: auditResult.plan_mutations });
}
}
appendJournal(taskId, {
type: 'status_push',
content: `Auditor confirmed step(s) ${completedIndices.map(i => i + 1).join(', ')} complete based on tool evidence.`,
});
mutatePlan(taskId, mutations);
// Clear retry state for any step that just got confirmed.
for (const idx of completedIndices) {
stepRetryHints.delete(idx);
stepVerificationRetries.delete(idx);
}
// Reload after mutations and advance currentStepIndex past all completed/skipped steps.
const updated = loadTask(taskId);
if (!updated) return;
const previousStep = updated.currentStepIndex;
let nextStep = previousStep;
while (nextStep < updated.plan.length) {
const status = updated.plan[nextStep]?.status;
if (status !== 'done' && status !== 'skipped') break;
nextStep++;
}
if (nextStep >= updated.plan.length) {
updateTaskStatus(taskId, 'complete', { finalSummary: result.text });
appendJournal(taskId, { type: 'status_push', content: `Task complete: ${result.text.slice(0, 200)}` });
this._broadcast('task_complete', { taskId, summary: result.text });
await this._deliverToChannel(task, `Task complete: ${task.title}\n\n${result.text}`, { forceTelegram: true });
this._persistResumeContextSnapshot(taskId, sessionId, ownerUsername);
flushSession(sessionId, ownerUsername);
return;
}
if (nextStep !== previousStep) {
updated.currentStepIndex = nextStep;
saveTask(updated);
appendJournal(taskId, {
type: 'status_push',
content: `Step pointer advanced from ${previousStep + 1} to ${nextStep + 1} after multi-step audit.`,
});
this._broadcast('task_step_done', {
taskId,
completedStep: previousStep,
completedSteps: completedIndices,
nextStep,
autoContinued: true,
});
}
}
}
/**
* Intercepts a step-verification failure and attempts AI-powered self-healing
* before bothering the user.
*
* Returns:
* 'continue' — healer issued a retry hint; caller should continue the run loop
* 'complete' — healer force-completed the task; caller should return
* 'escalate' — healer gave up; caller should fall through to _pauseForAssistance
*/
private async _attemptSelfHeal(
task: TaskRecord,
reason: string,
detail: string,
lastResultText: string,
sessionId: string,
username?: string,
): Promise<'continue' | 'complete' | 'escalate'> {
const freshTask = loadTask(task.id);
if (!freshTask) return 'escalate';
const healAttempt = Number(freshTask.selfHealAttempts) || 0;
appendJournal(task.id, {
type: 'status_push',
content: `[SelfHealer] Intercepting failure (attempt ${healAttempt + 1}/${MAX_HEAL_ATTEMPTS}): ${reason.slice(0, 120)}`,
});
this._broadcast('task_self_healing', {
taskId: task.id,
attempt: healAttempt + 1,
maxAttempts: MAX_HEAL_ATTEMPTS,
reason: reason.slice(0, 200),
});
const decision = await callErrorHealer({
task: freshTask,
failureReason: reason,
failureDetail: detail,
lastResultText,
healAttempt,
});
appendJournal(task.id, {
type: 'status_push',
content: `[SelfHealer] Decision: ${decision.action} — ${decision.reasoning}`,
});
console.log(`[SelfHealer] Task ${task.id} attempt ${healAttempt + 1}: ${decision.action} — ${decision.reasoning}`);
// Increment counter regardless of outcome
freshTask.selfHealAttempts = healAttempt + 1;
saveTask(freshTask);
if (decision.action === 'FORCE_COMPLETE') {
// Mark all pending plan steps as done via the healer
const planMutations = freshTask.plan
.map((s, i) => ({ op: 'complete' as const, step_index: i, notes: '[SelfHealer] Force-completed by self-healer' }))
.filter((_, i) => freshTask.plan[i].status !== 'done' && freshTask.plan[i].status !== 'skipped');
if (planMutations.length > 0) {
mutatePlan(task.id, planMutations);
}
updateTaskStatus(task.id, 'complete', { finalSummary: decision.message });
appendJournal(task.id, {
type: 'status_push',
content: `[SelfHealer] Task force-completed. Delivering recovered message.`,
});
this._broadcast('task_complete', { taskId: task.id, summary: decision.message });
await this._deliverToChannel(freshTask, decision.message);
this._persistResumeContextSnapshot(task.id, sessionId, username);
return 'complete';
}
if (decision.action === 'RESUME_WITH_HINT') {
// If the healer corrected a step description, apply it
if (decision.newStepDescription) {
mutatePlan(task.id, [{
op: 'modify',
step_index: freshTask.currentStepIndex,
description: decision.newStepDescription,
}]);
}
// Inject the hint into the resume context so the next round sees it
updateResumeContext(task.id, {
onResumeInstruction: `[SelfHealer correction] ${decision.hint}`,
});
appendJournal(task.id, {
type: 'status_push',
content: `[SelfHealer] Resuming with hint: ${decision.hint.slice(0, 150)}`,
});
return 'continue';
}
// ESCALATE
return 'escalate';
}
private async _pauseForAssistance(task: TaskRecord, reason: string, detail?: string): Promise<void> {
updateTaskStatus(task.id, 'needs_assistance', { pauseReason: 'error' });
appendJournal(task.id, {
type: 'pause',
content: `Task paused for assistance: ${reason.slice(0, 220)}`,
detail: detail ? detail.slice(0, 1200) : undefined,
});
// ── Categorize error and broadcast error response UI ──
const fullErrorMsg = detail ? `${reason}\n${detail}` : reason;
const categorization = errorCategorizer.categorizeError(fullErrorMsg);
// Record in error analyzer (pattern learning) and history
try {
const analyzer = getErrorAnalyzer();
const history = getErrorHistory();
if (categorization.category !== 'unknown') {
analyzer.recordError(fullErrorMsg, categorization.category);
}
history.add({
taskId: task.id,
errorMessage: reason.substring(0, 200),
category: categorization.category,
resolved: false,
});
} catch {}
// Only show error response panel if we detected a specific error type with high confidence
if (categorization.confidence > 0.7 && categorization.template) {
appendJournal(task.id, {
type: 'status_push',
content: `Error categorized as "${categorization.category}" (confidence: ${(categorization.confidence * 100).toFixed(0)}%): ${categorization.reasoning}`,
});
this._broadcast('task_error_requires_response', {
taskId: task.id,
errorCategory: categorization.category,
errorMessage: reason,
errorDetail: detail || '',
template: categorization.template,
});
}
this._broadcast('task_paused', { taskId: task.id, reason: 'needs_assistance' });
this._broadcast('task_needs_assistance', {
taskId: task.id,
title: task.title,
reason,
detail: detail || '',
});
const message = [
`Task paused and needs input: ${task.title}`,
`Reason: ${reason}`,
detail ? `Details: ${detail}` : '',
`Reply in this chat with any adjustment or confirmation, and I will resume the task.`,
`Task ID: ${task.id}`,
].filter(Boolean).join('\n');
await this._deliverToChannel(task, message);
// Always send escalation to Telegram when available, even for web-origin tasks.
if (this.telegramChannel && task.channel !== 'telegram') {
try { await this.telegramChannel.sendToAllowed(message); } catch {}
}
}
private _broadcast(event: string, data: object): void {
try {
this.broadcast({ type: event, ...data });
} catch {}
}
private async _deliverToChannel(
task: TaskRecord,
message: string,
opts?: { forceTelegram?: boolean },
): Promise<void> {
// ─ Sub-agent path: notify parent instead of delivering to user chat ─
if (task.parentTaskId) {
try {
const { parentTask, allChildrenDone } = resolveSubagentCompletion(task.id, message);
if (parentTask && allChildrenDone) {
console.log(`[SubAgent] All children done for parent ${parentTask.id} — scheduling quick resume.`);
// Signal the broadcast interceptor in server-v2 to scheduleTaskFollowup
this._broadcast('task_step_followup_needed', {
taskId: parentTask.id,
delayMs: 2000,
});
} else if (parentTask) {
console.log(`[SubAgent] Child ${task.id} done; parent ${parentTask.id} still waiting on more children.`);
}
} catch (e) {
console.warn('[SubAgent] resolveSubagentCompletion error:', e);
}
// Sub-agents never deliver directly to user chat — return early.
return;
}
try {
addMessage(task.sessionId, {
role: 'user',
content: `[BACKGROUND_TASK_RESULT task_id=${task.id}]`,
timestamp: Date.now() - 1,
}, {}, task.ownerUsername);
addMessage(task.sessionId, { role: 'assistant', content: message, timestamp: Date.now() }, {}, task.ownerUsername);
} catch (e) {
console.warn('[BTR] Delivery failed (addMessage):', e);
}
if ((opts?.forceTelegram || task.channel === 'telegram') && this.telegramChannel) {
try {
if (task.telegramChatId && typeof this.telegramChannel.sendMessage === 'function') {
await this.telegramChannel.sendMessage(task.telegramChatId, message);
} else {
await this.telegramChannel.sendToAllowed(message);
}
} catch (e) {
console.warn('[BTR] Delivery failed (telegram):', e);
}
}
// For web channel, broadcast via WS so any open chat session sees it.
this._broadcast('task_notification', {
taskId: task.id,
sessionId: task.sessionId,
channel: task.channel,
message,
});
}
}