/** * ChainHandoffService — Lifecycle Module * * Knowledge stack, supervisor engine, cycle management, and auto-escalation. * ADR-101 Stages 2 & 3. */ import { dbRun, isPostgres } from '../../database/connection.js'; import { apiLogger } from '../../utils/logger.js'; import { generateBaseId } from '../../utils/baseId.js'; import { STATE, AGENT_USERS, TICKETS_TABLE_ID, SUPERVISOR_CONFIG, generateChainId, } from './constants.js'; import { getAgentName } from './routing.js'; // T6 fix: In-memory mutex to prevent concurrent supervisor triggers for the same chain const _supervisorLocks = new Set(); // ----- ADR-101 STAGE 2: KNOWLEDGE STACK ----- /** * Build a structured knowledge summary from chain tasks. * Parses task `why` fields to extract decisions, resolved/unresolved issues, * and artifacts (files, test results). * * ADR-101 Stage 2: Data collection only — no cycle restart. * * @param {Array} chainTasks - Array of tasks from getChainTasks() * @returns {Object} Structured knowledge summary */ function buildKnowledgeSummary(chainTasks) { if (!chainTasks || chainTasks.length === 0) { return { summary: 'No tasks in chain.', tasks: [], decisions: [], resolved: [], unresolved: [], artifacts: { files_created: [], files_modified: [], tests_passed: 0, tests_failed: 0 }, }; } const decisions = new Set(); const resolved = new Set(); const unresolved = new Set(); const filesCreated = new Set(); const filesModified = new Set(); let testsPassed = 0; let testsFailed = 0; const taskSummaries = []; for (const task of chainTasks) { const why = task.why || ''; // Extract structured markers from why field const decisionMatches = why.match(/DECISION:\s*([^\n]+)/gi) || []; for (const m of decisionMatches) { decisions.add(m.replace(/^DECISION:\s*/i, '').trim()); } const resolvedMatches = why.match(/(?:RESOLVED|FIXED):\s*([^\n]+)/gi) || []; for (const m of resolvedMatches) { resolved.add(m.replace(/^(?:RESOLVED|FIXED):\s*/i, '').trim()); } const unresolvedMatches = why.match(/(?:BLOCKER|UNRESOLVED|FAILED):\s*([^\n]+)/gi) || []; for (const m of unresolvedMatches) { unresolved.add(m.replace(/^(?:BLOCKER|UNRESOLVED|FAILED):\s*/i, '').trim()); } // Extract file paths const createdFiles = why.match(/(?:created|wrote|generated):\s*([^\n]+)/gi) || []; for (const m of createdFiles) { const path = m.replace(/^(?:created|wrote|generated):\s*/i, '').trim(); if (path && path.length < 200) filesCreated.add(path); } const modifiedFiles = why.match(/(?:modified|updated|changed):\s*([^\n]+)/gi) || []; for (const m of modifiedFiles) { const path = m.replace(/^(?:modified|updated|changed):\s*/i, '').trim(); if (path && path.length < 200) filesModified.add(path); } // Extract test counts const passMatch = why.match(/(\d+)\s*(?:tests?\s*)?pass(?:ed|ing)?/i); if (passMatch) testsPassed += parseInt(passMatch[1], 10); const failMatch = why.match(/(\d+)\s*(?:tests?\s*)?fail(?:ed|ing)?/i); if (failMatch) testsFailed += parseInt(failMatch[1], 10); // Task summary taskSummaries.push({ step: task.step, what: task.what, state: task.chain_status || 'unknown', assigned_to: getAgentName(task.assigned_to), }); } // Build condensed summary text const completedCount = chainTasks.filter( t => t.state === STATE.DONE || t.chain_status === 'completed' ).length; const summaryParts = [ `${completedCount}/${chainTasks.length} tasks completed.`, ]; if (decisions.size > 0) summaryParts.push(`Decisions: ${[...decisions].join('; ')}.`); if (resolved.size > 0) summaryParts.push(`Resolved: ${[...resolved].join('; ')}.`); if (unresolved.size > 0) summaryParts.push(`Unresolved: ${[...unresolved].join('; ')}.`); if (testsPassed > 0 || testsFailed > 0) { summaryParts.push(`Tests: ${testsPassed} passed, ${testsFailed} failed.`); } return { summary: summaryParts.join(' '), tasks: taskSummaries, decisions: [...decisions], resolved: [...resolved], unresolved: [...unresolved], artifacts: { files_created: [...filesCreated], files_modified: [...filesModified], tests_passed: testsPassed, tests_failed: testsFailed, }, }; } /** * Build a knowledge stack entry for a completed cycle. * Standardized format stored in knowledge_stack[] across cycles. * * ADR-101 Stage 2: Builder ready, used by triggerSupervisor() in Stage 3. * * @param {Object} params * @param {number} params.cycle - Cycle number (1-based) * @param {string} params.chainId - Chain ID for this cycle * @param {string} params.startedAt - ISO timestamp when cycle started * @param {Object} params.knowledgeSummary - Result from buildKnowledgeSummary() * @returns {Object} Knowledge stack entry */ function buildCycleKnowledgeEntry(params) { const { cycle, chainId, startedAt, knowledgeSummary } = params; return { cycle, chain_id: chainId, started_at: startedAt, completed_at: new Date().toISOString(), tasks_completed: knowledgeSummary.tasks.filter(t => t.state === 'completed').length, tasks_total: knowledgeSummary.tasks.length, summary: knowledgeSummary.summary, key_decisions: knowledgeSummary.decisions, blockers_resolved: knowledgeSummary.resolved, unresolved_issues: knowledgeSummary.unresolved, artifacts: knowledgeSummary.artifacts, }; } /** * Format accumulated knowledge stack into a readable supervisor context string. * Used by triggerSupervisor() (Stage 3) to build the prompt for Nikich. * * ADR-101 Stage 2: Method ready, not yet called in production. * * @param {Object} params * @param {Array} params.knowledgeStack - Previous cycle knowledge entries * @param {Object} params.currentCycleKnowledge - Current cycle's buildKnowledgeSummary result * @param {string} params.originalGoal - Original task goal (never changes across cycles) * @param {number} params.cycleNumber - Current cycle number (1-based) * @param {string} params.chainId - Current chain ID * @returns {string} Formatted supervisor context */ function formatSupervisorContext(params) { const { knowledgeStack = [], currentCycleKnowledge = {}, originalGoal = 'Unknown goal', cycleNumber = 1, chainId = '', } = params; const lines = []; const maxCycles = SUPERVISOR_CONFIG.max_cycles; lines.push(`[SUPERVISOR MODE — Cycle ${cycleNumber}/${maxCycles}]`); lines.push(`Goal: ${originalGoal}`); lines.push(`Current chain: ${chainId}`); lines.push(''); // Previous cycles if (knowledgeStack.length > 0) { lines.push('=== PREVIOUS CYCLES ==='); for (const entry of knowledgeStack) { lines.push(`--- Cycle ${entry.cycle} (${entry.chain_id}) ---`); lines.push(`Tasks: ${entry.tasks_completed}/${entry.tasks_total} completed`); if (entry.started_at && entry.completed_at) { lines.push(`Time: ${entry.started_at} → ${entry.completed_at}`); } lines.push(`Summary: ${entry.summary}`); if (entry.key_decisions?.length > 0) { lines.push(`Decisions: ${entry.key_decisions.join('; ')}`); } if (entry.blockers_resolved?.length > 0) { lines.push(`Resolved: ${entry.blockers_resolved.join('; ')}`); } if (entry.unresolved_issues?.length > 0) { lines.push(`⚠ Unresolved: ${entry.unresolved_issues.join('; ')}`); } if (entry.artifacts) { if (entry.artifacts.files_created?.length > 0) { lines.push(`Files created: ${entry.artifacts.files_created.join(', ')}`); } if (entry.artifacts.files_modified?.length > 0) { lines.push(`Files modified: ${entry.artifacts.files_modified.join(', ')}`); } lines.push(`Tests: ${entry.artifacts.tests_passed || 0} passed, ${entry.artifacts.tests_failed || 0} failed`); } lines.push(''); } } // Current cycle lines.push('=== CURRENT CYCLE ==='); lines.push(`Summary: ${currentCycleKnowledge.summary || 'No data'}`); if (currentCycleKnowledge.decisions?.length > 0) { lines.push(`Decisions: ${currentCycleKnowledge.decisions.join('; ')}`); } if (currentCycleKnowledge.resolved?.length > 0) { lines.push(`Resolved: ${currentCycleKnowledge.resolved.join('; ')}`); } if (currentCycleKnowledge.unresolved?.length > 0) { lines.push(`⚠ Unresolved: ${currentCycleKnowledge.unresolved.join('; ')}`); } if (currentCycleKnowledge.artifacts) { const a = currentCycleKnowledge.artifacts; if (a.files_created?.length > 0) lines.push(`Files created: ${a.files_created.join(', ')}`); if (a.files_modified?.length > 0) lines.push(`Files modified: ${a.files_modified.join(', ')}`); lines.push(`Tests: ${a.tests_passed || 0} passed, ${a.tests_failed || 0} failed`); } lines.push(''); // Task details if (currentCycleKnowledge.tasks?.length > 0) { lines.push('Tasks in this cycle:'); for (const t of currentCycleKnowledge.tasks) { lines.push(` Step ${t.step}: [${t.state}] ${t.what} → ${t.assigned_to}`); } lines.push(''); } // Decision instructions lines.push('=== YOUR DECISION ==='); lines.push('Use the supervisor_decide tool with ONE of:'); lines.push(' CONTINUE — unresolved tasks or failing tests. Provide next_cycle_plan.'); lines.push(' COMPLETE — all acceptance criteria met. Provide final summary.'); lines.push(' ESCALATE — blocker that agents cannot resolve. Provide blocker description.'); return lines.join('\n'); } // ----- ADR-101 STAGE 3: SUPERVISOR ENGINE ----- /** * T7 fix: Ensure the why field contains knowledge-parseable markers. * If no markers are present, appends a reminder template so agents * produce structured output that buildKnowledgeSummary() can parse. */ function _enforceKnowledgeMarkers(why) { if (!why || typeof why !== 'string') return why || ''; const hasMarkers = /(?:DECISION|RESOLVED|FIXED|BLOCKER|UNRESOLVED|FAILED):/i.test(why); if (hasMarkers) return why; return why + '\n\n[When completing this task, include structured markers in your response: DECISION: , RESOLVED: , BLOCKER: ]'; } /** * Trigger the Chain Supervisor (Nikich) at the configured step. * Collects all chain data, builds knowledge summary, and dispatches * a supervisor task with full memory. * * ADR-101 Stage 3: Core supervisor trigger. * * @param {Object} params * @param {string} params.chain_id - Current chain ID * @param {number} params.cycle_number - Current cycle number (1-based) * @param {string} params.cycle_group_id - Group ID shared across all cycles * @param {string} params.original_goal - Original task goal (never changes) * @param {Array} params.knowledge_stack - Previous cycle summaries * @returns {Promise} Supervisor ticket */ async function triggerSupervisor({ chain_id, cycle_number, cycle_group_id, original_goal, knowledge_stack, space_id }) { // T6 fix: Prevent concurrent supervisor triggers for the same chain if (_supervisorLocks.has(chain_id)) { apiLogger.warn({ chain_id, cycle_number }, 'ChainHandoff: Supervisor trigger already in progress for this chain — skipping duplicate'); return { _supervisor: true, _duplicate: true, chain_id, cycle_number }; } _supervisorLocks.add(chain_id); try { // 1. Gather all tasks in this chain const chainTasks = await this.getChainTasks(chain_id); const chainStatus = await this.getChainStatus(chain_id); // 2. Build knowledge summary for current cycle const currentSummary = buildKnowledgeSummary(chainTasks); // 3. Build cycle knowledge entry and append to stack const cycleEntry = buildCycleKnowledgeEntry({ cycle: cycle_number, chainId: chain_id, startedAt: chainTasks[0]?.dispatched_at || new Date().toISOString(), knowledgeSummary: currentSummary, }); const updatedStack = [...knowledge_stack, cycleEntry]; // 4. Format context for supervisor prompt const supervisorContext = formatSupervisorContext({ knowledgeStack: knowledge_stack, currentCycleKnowledge: currentSummary, originalGoal: original_goal, cycleNumber: cycle_number, chainId: chain_id, }); // 5. Log cycle transition await this.logActivity({ action: 'supervisor_triggered', agent_id: SUPERVISOR_CONFIG.supervisor_agent_id, chain_id, details: { cycle_number, cycle_group_id, tasks_in_cycle: chainTasks.length, completed: chainStatus.progress?.completed || 0, summary: currentSummary.summary.substring(0, 500), }, }); // 6. Dispatch supervisor task to Nikich (bypass normal dispatch to avoid re-trigger) const baseId = generateBaseId('ticket'); const ticketData = { what: `[SUPERVISOR] Cycle ${cycle_number} analysis: ${original_goal.substring(0, 100)}`, why: supervisorContext, assigned_to: SUPERVISOR_CONFIG.supervisor_agent_id, priority: 24274, // high type: 24269, // task state: STATE.BACKLOG, acceptance_criteria: 'Analyze cycle results and decide: CONTINUE / COMPLETE / ESCALATE', _chain: { chain_id, step: SUPERVISOR_CONFIG.trigger_at_step, dispatched_by: AGENT_USERS.ORCHESTRATOR, dispatched_at: new Date().toISOString(), status: 'dispatched', }, _chain_memory: { cycle_number, cycle_group_id, parent_chain_id: chain_id, knowledge_stack: updatedStack, original_goal, }, }; const dataJson = JSON.stringify(ticketData); let result; if (isPostgres()) { result = await dbRun( `INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at) VALUES ($1, $2, $3::jsonb, NOW(), NOW()) RETURNING id`, [TICKETS_TABLE_ID, baseId, dataJson] ); } else { result = await dbRun( `INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at) VALUES (?, ?, ?, datetime('now'), datetime('now'))`, [TICKETS_TABLE_ID, baseId, dataJson] ); } const ticketId = result?.lastInsertRowid || result?.rows?.[0]?.id; apiLogger.info({ ticketId, chain_id, cycle_number, cycle_group_id, }, 'ChainHandoff: Supervisor triggered (ADR-101)'); // === T4 fix: Schedule timeout fallback if Nikich doesn't pick up === const supervisorTimeoutMs = SUPERVISOR_CONFIG.supervisor_timeout_ms || 10 * 60 * 1000; // 10 min default setTimeout(async () => { try { // Check if supervisor ticket is still in BACKLOG (not picked up) const ticket = await this.getTicket(ticketId); const tData = typeof ticket?.data === 'string' ? JSON.parse(ticket.data) : (ticket?.data || {}); if (tData.state === STATE.BACKLOG) { apiLogger.warn({ ticketId, chain_id, cycle_number }, 'ChainHandoff: Supervisor timeout — Nikich offline, auto-escalating'); await this.autoEscalate(chain_id, ticketData._chain_memory, 'SUPERVISOR_TIMEOUT', space_id); } } catch (timeoutErr) { apiLogger.error({ err: timeoutErr, ticketId }, 'ChainHandoff: Supervisor timeout check failed'); } }, supervisorTimeoutMs); return { ticket_id: ticketId, chain_id, step: SUPERVISOR_CONFIG.trigger_at_step, cycle_number, cycle_group_id, state: STATE.BACKLOG, assigned_to: SUPERVISOR_CONFIG.supervisor_agent_id, what: ticketData.what, data: ticketData, _supervisor: true, // flag for callers to know this is a supervisor ticket }; } finally { // T6 fix: Release lock after supervisor trigger completes (or fails) _supervisorLocks.delete(chain_id); } } /** * Start a new chain cycle with accumulated knowledge. * Creates a new chain_id, resets step to 1, carries forward full memory. * * ADR-101 Stage 3: Cycle restart with memory propagation. * * @param {Object} params * @param {string} params.cycle_group_id - Shared group ID across all cycles * @param {number} params.cycle_number - New cycle number (1-based) * @param {Array} params.knowledge_stack - All previous cycle summaries * @param {string} params.original_goal - Original task goal (constant) * @param {string} params.next_cycle_plan - Plan text from supervisor's decision * @param {Array} params.tasks - Task list [{what, assigned_to, why?, priority?}] * @returns {Promise} New chain with all dispatched tasks */ async function startNewCycle({ cycle_group_id, cycle_number, knowledge_stack, original_goal, next_cycle_plan, tasks }) { const newChainId = generateChainId(); // Log new cycle start await this.logActivity({ action: 'cycle_started', agent_id: SUPERVISOR_CONFIG.supervisor_agent_id, chain_id: newChainId, details: { cycle_number, cycle_group_id, parent_chain_id: knowledge_stack[knowledge_stack.length - 1]?.chain_id, plan: next_cycle_plan?.substring(0, 500), tasks_count: tasks.length, }, }); // ADR-109 Part D: FYI notification at cycle 3+ (non-blocking audit trail) if (cycle_number >= 3) { try { await this.logActivity({ action: 'cycle_fyi_notification', agent_id: SUPERVISOR_CONFIG.supervisor_agent_id, chain_id: newChainId, details: { cycle: cycle_number, cycle_group_id, original_goal: original_goal?.substring(0, 200), message: `Cycle ${cycle_number} started, human may want to review`, }, }); apiLogger.info({ cycle_number, cycle_group_id, newChainId }, 'ChainHandoff: Cycle 3+ FYI notification logged (ADR-109)'); } catch (err) { // Non-blocking — FYI logging should never break the cycle apiLogger.warn({ err, cycle_number }, 'ChainHandoff: Failed to log cycle FYI notification'); } } // Dispatch tasks with memory propagation (max trigger_at_step - 1 tasks) const maxTasks = SUPERVISOR_CONFIG.trigger_at_step - 1; // 8 tasks max const results = []; for (let i = 0; i < tasks.length && i < maxTasks; i++) { const task = tasks[i]; const result = await this.dispatchSubtask({ what: task.what, why: _enforceKnowledgeMarkers(task.why || `[Cycle ${cycle_number}] ${next_cycle_plan || ''}`.substring(0, 500)), assigned_to: task.assigned_to, priority: task.priority || 24274, chain_id: newChainId, step: i + 1, dispatched_by: SUPERVISOR_CONFIG.supervisor_agent_id, _chain_memory: { cycle_number, cycle_group_id, parent_chain_id: knowledge_stack[knowledge_stack.length - 1]?.chain_id, knowledge_stack, original_goal, }, }); results.push(result); } apiLogger.info({ newChainId, cycle_number, cycle_group_id, tasksDispatched: results.length, }, 'ChainHandoff: New cycle started (ADR-101)'); return { chain_id: newChainId, cycle_number, cycle_group_id, original_goal, tasks: results, }; } /** * Auto-escalate when safety limits are breached. * Creates an escalation ticket with full cycle history. * * ADR-101 Stage 3: Safety guard escalation. * * @param {string} chain_id - Current chain ID * @param {Object} memory - Chain memory object (_chain_memory) * @param {string} reason - Escalation reason code * @returns {Promise} Escalation ticket */ async function autoEscalate(chain_id, memory, reason) { const reasonMessages = { MAX_CYCLES_REACHED: `Maximum cycles reached (${SUPERVISOR_CONFIG.max_cycles})`, MAX_DURATION_REACHED: `Maximum duration reached (${SUPERVISOR_CONFIG.max_duration_ms / 3600000}h)`, MAX_TASKS_REACHED: `Maximum total tasks reached (${SUPERVISOR_CONFIG.max_total_tasks})`, SUPERVISOR_TIMEOUT: `Supervisor (Nikich) did not pick up within timeout (${(SUPERVISOR_CONFIG.supervisor_timeout_ms || 600000) / 60000} min)`, }; const report = [ `AUTO-ESCALATION: ${reasonMessages[reason] || reason}`, '', `Goal: ${memory.original_goal || 'unknown'}`, `Cycles completed: ${memory.cycle_number || 0}`, `Cycle group: ${memory.cycle_group_id || 'unknown'}`, '', '--- Knowledge from all cycles ---', ...(memory.knowledge_stack || []).map(k => `Cycle ${k.cycle}: ${k.summary || 'no summary'}` ), ].join('\n'); await this.logActivity({ action: 'supervisor_auto_escalate', agent_id: SUPERVISOR_CONFIG.supervisor_agent_id, chain_id, details: { reason, cycle_number: memory.cycle_number, cycle_group_id: memory.cycle_group_id, original_goal: memory.original_goal, }, }); apiLogger.warn({ chain_id, reason, cycle_number: memory.cycle_number, }, 'ChainHandoff: Auto-escalation triggered (ADR-101)'); // Create escalation ticket assigned to orchestrator for human review const baseId = generateBaseId('ticket'); const ticketData = { what: `[ESCALATION] ${reasonMessages[reason] || reason}: ${(memory.original_goal || '').substring(0, 80)}`, why: report, assigned_to: AGENT_USERS.ORCHESTRATOR, priority: 24274, type: 24269, state: STATE.BACKLOG, _chain: { chain_id, step: SUPERVISOR_CONFIG.trigger_at_step, dispatched_by: SUPERVISOR_CONFIG.supervisor_agent_id, dispatched_at: new Date().toISOString(), status: 'escalated', }, _chain_memory: memory, _escalation: { reason, escalated_at: new Date().toISOString(), auto: true, }, }; const dataJson = JSON.stringify(ticketData); let result; if (isPostgres()) { result = await dbRun( `INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at) VALUES ($1, $2, $3::jsonb, NOW(), NOW()) RETURNING id`, [TICKETS_TABLE_ID, baseId, dataJson] ); } else { result = await dbRun( `INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at) VALUES (?, ?, ?, datetime('now'), datetime('now'))`, [TICKETS_TABLE_ID, baseId, dataJson] ); } const ticketId = result?.lastInsertRowid || result?.rows?.[0]?.id; return { ticket_id: ticketId, chain_id, step: SUPERVISOR_CONFIG.trigger_at_step, state: STATE.BACKLOG, assigned_to: AGENT_USERS.ORCHESTRATOR, what: ticketData.what, data: ticketData, _escalation: true, }; } export { buildKnowledgeSummary, buildCycleKnowledgeEntry, formatSupervisorContext, triggerSupervisor, startNewCycle, autoEscalate, };