mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
Move all built-in prompts from inline code to separate .md files in src/prompts/
for easier editing without code changes. Prompts use {{TEMPLATE_VARIABLES}} that
are substituted at runtime using the central substituteTemplateVariables function.
Changes:
- Add src/prompts/ directory with 7 prompt files (wizard, AutoRun, etc.)
- Add index.ts for central exports using Vite ?raw imports
- Add esbuild plugin in build-cli.mjs to support ?raw imports for CLI
- Update wizardPrompts.ts and phaseGenerator.ts to use central substitution
- Update CLAUDE.md documentation with new prompt location references
- Add TypeScript declaration for *.md?raw imports in global.d.ts
Claude ID: 38553613-f82f-4ce1-973e-fa80d42af3da
Maestro ID: b9bc0d08-5be2-4fdf-93cd-5618a8d53b35
1186 lines
45 KiB
TypeScript
1186 lines
45 KiB
TypeScript
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
import type { BatchRunState, BatchRunConfig, BatchDocumentEntry, Session, HistoryEntry, UsageStats, Group, AutoRunStats } from '../types';
|
|
import { substituteTemplateVariables, TemplateContext } from '../utils/templateVariables';
|
|
import { getBadgeForTime, getNextBadge, formatTimeRemaining } from '../constants/conductorBadges';
|
|
import { autorunSynopsisPrompt } from '../../prompts';
|
|
|
|
// Regex to count unchecked markdown checkboxes: - [ ] task
|
|
const UNCHECKED_TASK_REGEX = /^[\s]*-\s*\[\s*\]\s*.+$/gm;
|
|
|
|
// Regex to match checked markdown checkboxes for reset-on-completion
|
|
// Matches both [x] and [X] with various checkbox formats (standard and GitHub-style)
|
|
const CHECKED_TASK_REGEX = /^(\s*-\s*)\[[xX✓✔]\]/gm;
|
|
|
|
// Default empty batch state
|
|
const DEFAULT_BATCH_STATE: BatchRunState = {
|
|
isRunning: false,
|
|
isStopping: false,
|
|
// Multi-document progress (new fields)
|
|
documents: [],
|
|
currentDocumentIndex: 0,
|
|
currentDocTasksTotal: 0,
|
|
currentDocTasksCompleted: 0,
|
|
totalTasksAcrossAllDocs: 0,
|
|
completedTasksAcrossAllDocs: 0,
|
|
// Loop mode
|
|
loopEnabled: false,
|
|
loopIteration: 0,
|
|
// Folder path for file operations
|
|
folderPath: '',
|
|
// Worktree tracking
|
|
worktreeActive: false,
|
|
worktreePath: undefined,
|
|
worktreeBranch: undefined,
|
|
// Legacy fields (kept for backwards compatibility)
|
|
totalTasks: 0,
|
|
completedTasks: 0,
|
|
currentTaskIndex: 0,
|
|
originalContent: '',
|
|
sessionIds: []
|
|
};
|
|
|
|
interface BatchCompleteInfo {
|
|
sessionId: string;
|
|
sessionName: string;
|
|
completedTasks: number;
|
|
totalTasks: number;
|
|
wasStopped: boolean;
|
|
elapsedTimeMs: number;
|
|
}
|
|
|
|
interface PRResultInfo {
|
|
sessionId: string;
|
|
sessionName: string;
|
|
success: boolean;
|
|
prUrl?: string;
|
|
error?: string;
|
|
}
|
|
|
|
interface UseBatchProcessorProps {
|
|
sessions: Session[];
|
|
groups: Group[];
|
|
onUpdateSession: (sessionId: string, updates: Partial<Session>) => void;
|
|
onSpawnAgent: (sessionId: string, prompt: string, cwdOverride?: string) => Promise<{ success: boolean; response?: string; claudeSessionId?: string; usageStats?: UsageStats }>;
|
|
onSpawnSynopsis: (sessionId: string, cwd: string, claudeSessionId: string, prompt: string) => Promise<{ success: boolean; response?: string }>;
|
|
onAddHistoryEntry: (entry: Omit<HistoryEntry, 'id'>) => void;
|
|
onComplete?: (info: BatchCompleteInfo) => void;
|
|
// Callback for PR creation results (success or failure)
|
|
onPRResult?: (info: PRResultInfo) => void;
|
|
// TTS settings for speaking synopsis after each task
|
|
audioFeedbackEnabled?: boolean;
|
|
audioFeedbackCommand?: string;
|
|
// Auto Run stats for achievement progress in final summary
|
|
autoRunStats?: AutoRunStats;
|
|
}
|
|
|
|
interface UseBatchProcessorReturn {
|
|
// Map of session ID to batch state
|
|
batchRunStates: Record<string, BatchRunState>;
|
|
// Get batch state for a specific session
|
|
getBatchState: (sessionId: string) => BatchRunState;
|
|
// Check if any session has an active batch
|
|
hasAnyActiveBatch: boolean;
|
|
// Get list of session IDs with active batches
|
|
activeBatchSessionIds: string[];
|
|
// Start batch run for a specific session with multi-document support
|
|
startBatchRun: (sessionId: string, config: BatchRunConfig, folderPath: string) => Promise<void>;
|
|
// Stop batch run for a specific session
|
|
stopBatchRun: (sessionId: string) => void;
|
|
// Custom prompts per session
|
|
customPrompts: Record<string, string>;
|
|
setCustomPrompt: (sessionId: string, prompt: string) => void;
|
|
}
|
|
|
|
/**
|
|
* Format duration in human-readable format for loop summaries
|
|
*/
|
|
function formatLoopDuration(ms: number): string {
|
|
if (ms < 1000) return `${ms}ms`;
|
|
const seconds = Math.floor(ms / 1000);
|
|
if (seconds < 60) return `${seconds}s`;
|
|
const minutes = Math.floor(seconds / 60);
|
|
const remainingSeconds = seconds % 60;
|
|
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
|
|
const hours = Math.floor(minutes / 60);
|
|
const remainingMinutes = minutes % 60;
|
|
return `${hours}h ${remainingMinutes}m`;
|
|
}
|
|
|
|
/**
|
|
* Create a loop summary history entry
|
|
*/
|
|
interface LoopSummaryParams {
|
|
loopIteration: number;
|
|
loopTasksCompleted: number;
|
|
loopStartTime: number;
|
|
loopTotalInputTokens: number;
|
|
loopTotalOutputTokens: number;
|
|
loopTotalCost: number;
|
|
sessionCwd: string;
|
|
sessionId: string;
|
|
isFinal: boolean;
|
|
exitReason?: string;
|
|
}
|
|
|
|
function createLoopSummaryEntry(params: LoopSummaryParams): Omit<HistoryEntry, 'id'> {
|
|
const {
|
|
loopIteration,
|
|
loopTasksCompleted,
|
|
loopStartTime,
|
|
loopTotalInputTokens,
|
|
loopTotalOutputTokens,
|
|
loopTotalCost,
|
|
sessionCwd,
|
|
sessionId,
|
|
isFinal,
|
|
exitReason
|
|
} = params;
|
|
|
|
const loopElapsedMs = Date.now() - loopStartTime;
|
|
const loopNumber = loopIteration + 1;
|
|
const summaryPrefix = isFinal ? `Loop ${loopNumber} (final)` : `Loop ${loopNumber}`;
|
|
const loopSummary = `${summaryPrefix} completed: ${loopTasksCompleted} task${loopTasksCompleted !== 1 ? 's' : ''} accomplished`;
|
|
|
|
const loopDetails = [
|
|
`**${summaryPrefix} Summary**`,
|
|
'',
|
|
`- **Tasks Accomplished:** ${loopTasksCompleted}`,
|
|
`- **Duration:** ${formatLoopDuration(loopElapsedMs)}`,
|
|
loopTotalInputTokens > 0 || loopTotalOutputTokens > 0
|
|
? `- **Tokens:** ${(loopTotalInputTokens + loopTotalOutputTokens).toLocaleString()} (${loopTotalInputTokens.toLocaleString()} in / ${loopTotalOutputTokens.toLocaleString()} out)`
|
|
: '',
|
|
loopTotalCost > 0 ? `- **Cost:** $${loopTotalCost.toFixed(4)}` : '',
|
|
exitReason ? `- **Exit Reason:** ${exitReason}` : '',
|
|
].filter(line => line !== '').join('\n');
|
|
|
|
return {
|
|
type: 'AUTO',
|
|
timestamp: Date.now(),
|
|
summary: loopSummary,
|
|
fullResponse: loopDetails,
|
|
projectPath: sessionCwd,
|
|
sessionId: sessionId,
|
|
success: true,
|
|
elapsedTimeMs: loopElapsedMs,
|
|
usageStats: loopTotalInputTokens > 0 || loopTotalOutputTokens > 0 ? {
|
|
inputTokens: loopTotalInputTokens,
|
|
outputTokens: loopTotalOutputTokens,
|
|
cacheReadInputTokens: 0,
|
|
cacheCreationInputTokens: 0,
|
|
totalCostUsd: loopTotalCost,
|
|
contextWindow: 0
|
|
} : undefined
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Count unchecked tasks in markdown content
|
|
* Matches lines like: - [ ] task description
|
|
*/
|
|
export function countUnfinishedTasks(content: string): number {
|
|
const matches = content.match(UNCHECKED_TASK_REGEX);
|
|
return matches ? matches.length : 0;
|
|
}
|
|
|
|
/**
|
|
* Uncheck all markdown checkboxes in content (for reset-on-completion)
|
|
* Converts all - [x] to - [ ] (case insensitive)
|
|
*/
|
|
export function uncheckAllTasks(content: string): string {
|
|
return content.replace(CHECKED_TASK_REGEX, '$1[ ]');
|
|
}
|
|
|
|
/**
|
|
* Hook for managing batch processing of scratchpad tasks across multiple sessions
|
|
*/
|
|
// Synopsis prompt for batch tasks - requests a two-part response
|
|
const BATCH_SYNOPSIS_PROMPT = autorunSynopsisPrompt;
|
|
|
|
/**
|
|
* Parse a synopsis response into short summary and full synopsis
|
|
* Expected format:
|
|
* **Summary:** Short 1-2 sentence summary
|
|
* **Details:** Detailed paragraph...
|
|
*/
|
|
function parseSynopsis(response: string): { shortSummary: string; fullSynopsis: string } {
|
|
// Clean up ANSI codes and box drawing characters
|
|
const clean = response
|
|
.replace(/\x1b\[[0-9;]*m/g, '')
|
|
.replace(/─+/g, '')
|
|
.replace(/[│┌┐└┘├┤┬┴┼]/g, '')
|
|
.trim();
|
|
|
|
// Try to extract Summary and Details sections
|
|
const summaryMatch = clean.match(/\*\*Summary:\*\*\s*(.+?)(?=\*\*Details:\*\*|$)/is);
|
|
const detailsMatch = clean.match(/\*\*Details:\*\*\s*(.+?)$/is);
|
|
|
|
const shortSummary = summaryMatch?.[1]?.trim() || clean.split('\n')[0]?.trim() || 'Task completed';
|
|
const details = detailsMatch?.[1]?.trim() || '';
|
|
|
|
// Full synopsis includes both parts
|
|
const fullSynopsis = details ? `${shortSummary}\n\n${details}` : shortSummary;
|
|
|
|
return { shortSummary, fullSynopsis };
|
|
}
|
|
|
|
export function useBatchProcessor({
|
|
sessions,
|
|
groups,
|
|
onUpdateSession,
|
|
onSpawnAgent,
|
|
onSpawnSynopsis,
|
|
onAddHistoryEntry,
|
|
onComplete,
|
|
onPRResult,
|
|
audioFeedbackEnabled,
|
|
audioFeedbackCommand,
|
|
autoRunStats
|
|
}: UseBatchProcessorProps): UseBatchProcessorReturn {
|
|
// Batch states per session
|
|
const [batchRunStates, setBatchRunStates] = useState<Record<string, BatchRunState>>({});
|
|
|
|
// Custom prompts per session
|
|
const [customPrompts, setCustomPrompts] = useState<Record<string, string>>({});
|
|
|
|
// Refs for tracking stop requests per session
|
|
const stopRequestedRefs = useRef<Record<string, boolean>>({});
|
|
|
|
// Ref to always have access to latest sessions (fixes stale closure in startBatchRun)
|
|
const sessionsRef = useRef(sessions);
|
|
sessionsRef.current = sessions;
|
|
|
|
// Helper to get batch state for a session
|
|
const getBatchState = useCallback((sessionId: string): BatchRunState => {
|
|
return batchRunStates[sessionId] || DEFAULT_BATCH_STATE;
|
|
}, [batchRunStates]);
|
|
|
|
// Check if any session has an active batch
|
|
const hasAnyActiveBatch = Object.values(batchRunStates).some(state => state.isRunning);
|
|
|
|
// Get list of session IDs with active batches
|
|
const activeBatchSessionIds = Object.entries(batchRunStates)
|
|
.filter(([_, state]) => state.isRunning)
|
|
.map(([sessionId]) => sessionId);
|
|
|
|
// Set custom prompt for a session
|
|
const setCustomPrompt = useCallback((sessionId: string, prompt: string) => {
|
|
setCustomPrompts(prev => ({ ...prev, [sessionId]: prompt }));
|
|
}, []);
|
|
|
|
// Broadcast batch run state changes to web interface
|
|
useEffect(() => {
|
|
// Broadcast state for each session that has batch state
|
|
Object.entries(batchRunStates).forEach(([sessionId, state]) => {
|
|
if (state.isRunning || state.completedTasks > 0) {
|
|
window.maestro.web.broadcastAutoRunState(sessionId, {
|
|
isRunning: state.isRunning,
|
|
totalTasks: state.totalTasks,
|
|
completedTasks: state.completedTasks,
|
|
currentTaskIndex: state.currentTaskIndex,
|
|
isStopping: state.isStopping,
|
|
});
|
|
} else {
|
|
// When not running and no completed tasks, broadcast null to clear the state
|
|
window.maestro.web.broadcastAutoRunState(sessionId, null);
|
|
}
|
|
});
|
|
}, [batchRunStates]);
|
|
|
|
/**
|
|
* Helper function to read a document and count its tasks
|
|
*/
|
|
const readDocAndCountTasks = async (folderPath: string, filename: string): Promise<{ content: string; taskCount: number }> => {
|
|
const result = await window.maestro.autorun.readDoc(folderPath, filename + '.md');
|
|
if (!result.success || !result.content) {
|
|
return { content: '', taskCount: 0 };
|
|
}
|
|
return { content: result.content, taskCount: countUnfinishedTasks(result.content) };
|
|
};
|
|
|
|
/**
|
|
* Generate PR body from completed tasks
|
|
*/
|
|
const generatePRBody = (documents: BatchDocumentEntry[], totalTasksCompleted: number): string => {
|
|
const docList = documents.map(d => `- ${d.filename}`).join('\n');
|
|
return `## Auto Run Summary
|
|
|
|
**Documents processed:**
|
|
${docList}
|
|
|
|
**Total tasks completed:** ${totalTasksCompleted}
|
|
|
|
---
|
|
*This PR was automatically created by Maestro Auto Run.*`;
|
|
};
|
|
|
|
/**
|
|
* Start a batch processing run for a specific session with multi-document support
|
|
*/
|
|
const startBatchRun = useCallback(async (sessionId: string, config: BatchRunConfig, folderPath: string) => {
|
|
console.log('[BatchProcessor] startBatchRun called:', { sessionId, folderPath, config });
|
|
|
|
// Use sessionsRef to get latest sessions (handles case where session was just created)
|
|
const session = sessionsRef.current.find(s => s.id === sessionId);
|
|
if (!session) {
|
|
console.error('[BatchProcessor] Session not found for batch processing:', sessionId);
|
|
return;
|
|
}
|
|
|
|
const { documents, prompt, loopEnabled, maxLoops, worktree } = config;
|
|
console.log('[BatchProcessor] Config parsed - documents:', documents.length, 'loopEnabled:', loopEnabled, 'maxLoops:', maxLoops);
|
|
|
|
if (documents.length === 0) {
|
|
console.warn('[BatchProcessor] No documents provided for batch processing:', sessionId);
|
|
return;
|
|
}
|
|
|
|
// Debug log: show document configuration
|
|
console.log('[BatchProcessor] Starting batch with documents:', documents.map(d => ({
|
|
filename: d.filename,
|
|
resetOnCompletion: d.resetOnCompletion
|
|
})));
|
|
|
|
// Track batch start time for completion notification
|
|
const batchStartTime = Date.now();
|
|
|
|
// Reset stop flag for this session
|
|
stopRequestedRefs.current[sessionId] = false;
|
|
|
|
// Set up worktree if enabled
|
|
let effectiveCwd = session.cwd; // Default to session's cwd
|
|
let worktreeActive = false;
|
|
let worktreePath: string | undefined;
|
|
let worktreeBranch: string | undefined;
|
|
|
|
if (worktree?.enabled && worktree.path && worktree.branchName) {
|
|
console.log('[BatchProcessor] Setting up worktree at', worktree.path, 'with branch', worktree.branchName);
|
|
|
|
try {
|
|
// Set up or reuse the worktree
|
|
const setupResult = await window.maestro.git.worktreeSetup(
|
|
session.cwd,
|
|
worktree.path,
|
|
worktree.branchName
|
|
);
|
|
|
|
if (!setupResult.success) {
|
|
console.error('[BatchProcessor] Failed to set up worktree:', setupResult.error);
|
|
// Show error to user and abort
|
|
return;
|
|
}
|
|
|
|
// If worktree exists but on different branch, checkout the requested branch
|
|
if (setupResult.branchMismatch) {
|
|
console.log('[BatchProcessor] Worktree exists with different branch, checking out', worktree.branchName);
|
|
|
|
const checkoutResult = await window.maestro.git.worktreeCheckout(
|
|
worktree.path,
|
|
worktree.branchName,
|
|
true // createIfMissing
|
|
);
|
|
|
|
if (!checkoutResult.success) {
|
|
if (checkoutResult.hasUncommittedChanges) {
|
|
console.error('[BatchProcessor] Cannot checkout: worktree has uncommitted changes');
|
|
// Abort - user needs to handle uncommitted changes first
|
|
return;
|
|
} else {
|
|
console.error('[BatchProcessor] Failed to checkout branch:', checkoutResult.error);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Worktree is ready - use it as the working directory
|
|
effectiveCwd = worktree.path;
|
|
worktreeActive = true;
|
|
worktreePath = worktree.path;
|
|
worktreeBranch = worktree.branchName;
|
|
|
|
console.log('[BatchProcessor] Worktree ready at', effectiveCwd);
|
|
|
|
} catch (error) {
|
|
console.error('[BatchProcessor] Error setting up worktree:', error);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Get git branch for template variable substitution
|
|
let gitBranch: string | undefined;
|
|
if (session.isGitRepo) {
|
|
try {
|
|
const status = await window.maestro.git.getStatus(effectiveCwd);
|
|
gitBranch = status.branch;
|
|
} catch {
|
|
// Ignore git errors - branch will be empty string
|
|
}
|
|
}
|
|
|
|
// Find group name for this session (sessions have groupId, groups have id)
|
|
const sessionGroup = session.groupId ? groups.find(g => g.id === session.groupId) : null;
|
|
const groupName = sessionGroup?.name;
|
|
|
|
// Calculate initial total tasks across all documents
|
|
let initialTotalTasks = 0;
|
|
for (const doc of documents) {
|
|
const { taskCount } = await readDocAndCountTasks(folderPath, doc.filename);
|
|
console.log(`[BatchProcessor] Document ${doc.filename}: ${taskCount} tasks`);
|
|
initialTotalTasks += taskCount;
|
|
}
|
|
console.log(`[BatchProcessor] Initial total tasks: ${initialTotalTasks}`);
|
|
|
|
if (initialTotalTasks === 0) {
|
|
console.warn('No unchecked tasks found across all documents for session:', sessionId);
|
|
return;
|
|
}
|
|
|
|
// Initialize batch run state
|
|
setBatchRunStates(prev => ({
|
|
...prev,
|
|
[sessionId]: {
|
|
isRunning: true,
|
|
isStopping: false,
|
|
// Multi-document progress
|
|
documents: documents.map(d => d.filename),
|
|
currentDocumentIndex: 0,
|
|
currentDocTasksTotal: 0,
|
|
currentDocTasksCompleted: 0,
|
|
totalTasksAcrossAllDocs: initialTotalTasks,
|
|
completedTasksAcrossAllDocs: 0,
|
|
// Loop mode
|
|
loopEnabled,
|
|
loopIteration: 0,
|
|
maxLoops,
|
|
// Folder path for file operations
|
|
folderPath,
|
|
// Worktree tracking
|
|
worktreeActive,
|
|
worktreePath,
|
|
worktreeBranch,
|
|
// Legacy fields (for backwards compatibility)
|
|
totalTasks: initialTotalTasks,
|
|
completedTasks: 0,
|
|
currentTaskIndex: 0,
|
|
originalContent: '',
|
|
customPrompt: prompt !== '' ? prompt : undefined,
|
|
sessionIds: [],
|
|
startTime: batchStartTime
|
|
}
|
|
}));
|
|
|
|
// AUTORUN LOG: Start
|
|
try {
|
|
console.log('[AUTORUN] Logging start event - calling window.maestro.logger.autorun');
|
|
window.maestro.logger.autorun(
|
|
`Auto Run started`,
|
|
session.name,
|
|
{
|
|
documents: documents.map(d => d.filename),
|
|
totalTasks: initialTotalTasks,
|
|
loopEnabled,
|
|
maxLoops: maxLoops ?? 'unlimited'
|
|
}
|
|
);
|
|
console.log('[AUTORUN] Start event logged successfully');
|
|
} catch (err) {
|
|
console.error('[AUTORUN] Error logging start event:', err);
|
|
}
|
|
|
|
// Store custom prompt for persistence
|
|
setCustomPrompts(prev => ({ ...prev, [sessionId]: prompt }));
|
|
|
|
// Collect Claude session IDs and track completion
|
|
const claudeSessionIds: string[] = [];
|
|
let totalCompletedTasks = 0;
|
|
let loopIteration = 0;
|
|
|
|
// Per-loop tracking for loop summary
|
|
let loopStartTime = Date.now();
|
|
let loopTasksCompleted = 0;
|
|
let loopTasksDiscovered = 0;
|
|
let loopTotalInputTokens = 0;
|
|
let loopTotalOutputTokens = 0;
|
|
let loopTotalCost = 0;
|
|
|
|
// Cumulative tracking for final Auto Run summary (across all loops)
|
|
let totalInputTokens = 0;
|
|
let totalOutputTokens = 0;
|
|
let totalCost = 0;
|
|
|
|
// Track consecutive runs where document content didn't change to detect stalling
|
|
// If the document hash is identical before/after a run (and no tasks checked), the LLM is stuck
|
|
let consecutiveNoChangeCount = 0;
|
|
const MAX_CONSECUTIVE_NO_CHANGES = 2; // Exit after 2 consecutive runs with no document changes
|
|
let stalledDueToNoProgress = false;
|
|
|
|
// Helper to add final loop summary (defined here so it has access to tracking vars)
|
|
const addFinalLoopSummary = (exitReason: string) => {
|
|
// AUTORUN LOG: Exit
|
|
window.maestro.logger.autorun(
|
|
`Auto Run exiting: ${exitReason}`,
|
|
session.name,
|
|
{
|
|
reason: exitReason,
|
|
totalTasksCompleted: totalCompletedTasks,
|
|
loopsCompleted: loopIteration + 1
|
|
}
|
|
);
|
|
|
|
if (loopEnabled && (loopTasksCompleted > 0 || loopIteration > 0)) {
|
|
onAddHistoryEntry(createLoopSummaryEntry({
|
|
loopIteration,
|
|
loopTasksCompleted,
|
|
loopStartTime,
|
|
loopTotalInputTokens,
|
|
loopTotalOutputTokens,
|
|
loopTotalCost,
|
|
sessionCwd: session.cwd,
|
|
sessionId,
|
|
isFinal: true,
|
|
exitReason
|
|
}));
|
|
}
|
|
};
|
|
|
|
// Main processing loop (handles loop mode)
|
|
while (true) {
|
|
// Check for stop request
|
|
if (stopRequestedRefs.current[sessionId]) {
|
|
console.log('[BatchProcessor] Batch run stopped by user for session:', sessionId);
|
|
addFinalLoopSummary('Stopped by user');
|
|
break;
|
|
}
|
|
|
|
// Track if any tasks were processed in this iteration
|
|
let anyTasksProcessedThisIteration = false;
|
|
// Track tasks completed in non-reset documents this iteration
|
|
// This is critical for loop mode: if only reset docs have tasks, we'd loop forever
|
|
let tasksCompletedInNonResetDocs = 0;
|
|
|
|
// Process each document in order
|
|
for (let docIndex = 0; docIndex < documents.length; docIndex++) {
|
|
// Check for stop request before each document
|
|
if (stopRequestedRefs.current[sessionId]) {
|
|
console.log('[BatchProcessor] Batch run stopped by user at document', docIndex, 'for session:', sessionId);
|
|
break;
|
|
}
|
|
|
|
const docEntry = documents[docIndex];
|
|
const docFilePath = `${folderPath}/${docEntry.filename}.md`;
|
|
|
|
// Read document and count tasks
|
|
let { taskCount: remainingTasks, content: docContent } = await readDocAndCountTasks(folderPath, docEntry.filename);
|
|
|
|
// Handle documents with no unchecked tasks
|
|
if (remainingTasks === 0) {
|
|
// For reset-on-completion documents, check if there are checked tasks that need resetting
|
|
if (docEntry.resetOnCompletion && loopEnabled) {
|
|
const checkedTaskCount = (docContent.match(CHECKED_TASK_REGEX) || []).length;
|
|
if (checkedTaskCount > 0) {
|
|
console.log(`[BatchProcessor] Document ${docEntry.filename} has ${checkedTaskCount} checked tasks - resetting for next iteration`);
|
|
const resetContent = uncheckAllTasks(docContent);
|
|
await window.maestro.autorun.writeDoc(folderPath, docEntry.filename + '.md', resetContent);
|
|
// Update task count in state
|
|
const resetTaskCount = countUnfinishedTasks(resetContent);
|
|
setBatchRunStates(prev => ({
|
|
...prev,
|
|
[sessionId]: {
|
|
...prev[sessionId],
|
|
totalTasksAcrossAllDocs: prev[sessionId].totalTasksAcrossAllDocs + resetTaskCount,
|
|
totalTasks: prev[sessionId].totalTasks + resetTaskCount
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
console.log(`[BatchProcessor] Skipping document ${docEntry.filename} - no unchecked tasks`);
|
|
continue;
|
|
}
|
|
|
|
console.log(`[BatchProcessor] Processing document ${docEntry.filename} with ${remainingTasks} tasks`);
|
|
|
|
// AUTORUN LOG: Document processing
|
|
window.maestro.logger.autorun(
|
|
`Processing document: ${docEntry.filename}`,
|
|
session.name,
|
|
{
|
|
document: docEntry.filename,
|
|
tasksRemaining: remainingTasks,
|
|
loopNumber: loopIteration + 1
|
|
}
|
|
);
|
|
|
|
// Update state to show current document
|
|
setBatchRunStates(prev => ({
|
|
...prev,
|
|
[sessionId]: {
|
|
...prev[sessionId],
|
|
currentDocumentIndex: docIndex,
|
|
currentDocTasksTotal: remainingTasks,
|
|
currentDocTasksCompleted: 0
|
|
}
|
|
}));
|
|
|
|
let docTasksCompleted = 0;
|
|
|
|
// Process tasks in this document until none remain
|
|
while (remainingTasks > 0) {
|
|
// Check for stop request before each task
|
|
if (stopRequestedRefs.current[sessionId]) {
|
|
console.log('[BatchProcessor] Batch run stopped by user during document', docEntry.filename);
|
|
break;
|
|
}
|
|
|
|
// Build template context for this task
|
|
const templateContext: TemplateContext = {
|
|
session,
|
|
gitBranch,
|
|
groupName,
|
|
autoRunFolder: folderPath,
|
|
loopNumber: loopIteration + 1, // 1-indexed
|
|
documentName: docEntry.filename,
|
|
documentPath: docFilePath,
|
|
};
|
|
|
|
// Substitute template variables in the prompt
|
|
const finalPrompt = substituteTemplateVariables(prompt, templateContext);
|
|
|
|
// Read document content and expand template variables in it
|
|
const docReadResult = await window.maestro.autorun.readDoc(folderPath, docEntry.filename + '.md');
|
|
// Capture content before task run for stall detection
|
|
const contentBeforeTask = docReadResult.content || '';
|
|
if (docReadResult.success && docReadResult.content) {
|
|
const expandedDocContent = substituteTemplateVariables(docReadResult.content, templateContext);
|
|
// Write the expanded content back to the document temporarily
|
|
// (Claude will read this file, so it needs the expanded variables)
|
|
if (expandedDocContent !== docReadResult.content) {
|
|
await window.maestro.autorun.writeDoc(folderPath, docEntry.filename + '.md', expandedDocContent);
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Capture start time for elapsed time tracking
|
|
const taskStartTime = Date.now();
|
|
|
|
// Spawn agent with the prompt, using worktree path if active
|
|
const result = await onSpawnAgent(sessionId, finalPrompt, worktreeActive ? effectiveCwd : undefined);
|
|
|
|
// Capture elapsed time
|
|
const elapsedTimeMs = Date.now() - taskStartTime;
|
|
|
|
if (result.claudeSessionId) {
|
|
claudeSessionIds.push(result.claudeSessionId);
|
|
// Register as auto-initiated Maestro session
|
|
window.maestro.claude.registerSessionOrigin(session.cwd, result.claudeSessionId, 'auto')
|
|
.catch(err => console.error('[BatchProcessor] Failed to register session origin:', err));
|
|
}
|
|
|
|
anyTasksProcessedThisIteration = true;
|
|
|
|
// Re-read document to get updated task count and content
|
|
const { taskCount: newRemainingTasks, content: contentAfterTask } = await readDocAndCountTasks(folderPath, docEntry.filename);
|
|
// Calculate tasks completed - ensure it's never negative (Claude may have added tasks)
|
|
const tasksCompletedThisRun = Math.max(0, remainingTasks - newRemainingTasks);
|
|
|
|
// Detect stalling: if document content is unchanged and no tasks were checked off
|
|
const documentUnchanged = contentBeforeTask === contentAfterTask;
|
|
if (documentUnchanged && tasksCompletedThisRun === 0) {
|
|
consecutiveNoChangeCount++;
|
|
console.log(`[BatchProcessor] Document unchanged, no tasks completed (${consecutiveNoChangeCount}/${MAX_CONSECUTIVE_NO_CHANGES} consecutive)`);
|
|
} else {
|
|
// Reset counter on any document change or task completion
|
|
consecutiveNoChangeCount = 0;
|
|
}
|
|
|
|
// Update counters
|
|
docTasksCompleted += tasksCompletedThisRun;
|
|
totalCompletedTasks += tasksCompletedThisRun;
|
|
loopTasksCompleted += tasksCompletedThisRun;
|
|
|
|
// Track token usage for loop summary and cumulative totals
|
|
if (result.usageStats) {
|
|
loopTotalInputTokens += result.usageStats.inputTokens || 0;
|
|
loopTotalOutputTokens += result.usageStats.outputTokens || 0;
|
|
loopTotalCost += result.usageStats.totalCostUsd || 0;
|
|
// Also track cumulative totals for final summary
|
|
totalInputTokens += result.usageStats.inputTokens || 0;
|
|
totalOutputTokens += result.usageStats.outputTokens || 0;
|
|
totalCost += result.usageStats.totalCostUsd || 0;
|
|
}
|
|
|
|
// Track non-reset document completions for loop exit logic
|
|
if (!docEntry.resetOnCompletion) {
|
|
tasksCompletedInNonResetDocs += tasksCompletedThisRun;
|
|
}
|
|
|
|
// Update progress state
|
|
setBatchRunStates(prev => ({
|
|
...prev,
|
|
[sessionId]: {
|
|
...prev[sessionId],
|
|
currentDocTasksCompleted: docTasksCompleted,
|
|
completedTasksAcrossAllDocs: totalCompletedTasks,
|
|
// Legacy fields
|
|
completedTasks: totalCompletedTasks,
|
|
currentTaskIndex: totalCompletedTasks,
|
|
sessionIds: [...(prev[sessionId]?.sessionIds || []), result.claudeSessionId || '']
|
|
}
|
|
}));
|
|
|
|
// Generate synopsis for successful tasks with a Claude session
|
|
let shortSummary = `[${docEntry.filename}] Task completed`;
|
|
let fullSynopsis = shortSummary;
|
|
|
|
if (result.success && result.claudeSessionId) {
|
|
// Request a synopsis from the agent by resuming the session
|
|
try {
|
|
const synopsisResult = await onSpawnSynopsis(
|
|
sessionId,
|
|
session.cwd,
|
|
result.claudeSessionId,
|
|
BATCH_SYNOPSIS_PROMPT
|
|
);
|
|
|
|
if (synopsisResult.success && synopsisResult.response) {
|
|
const parsed = parseSynopsis(synopsisResult.response);
|
|
shortSummary = parsed.shortSummary;
|
|
fullSynopsis = parsed.fullSynopsis;
|
|
}
|
|
} catch (err) {
|
|
console.error('[BatchProcessor] Synopsis generation failed:', err);
|
|
}
|
|
} else if (!result.success) {
|
|
shortSummary = `[${docEntry.filename}] Task failed`;
|
|
fullSynopsis = shortSummary;
|
|
}
|
|
|
|
// Add history entry
|
|
onAddHistoryEntry({
|
|
type: 'AUTO',
|
|
timestamp: Date.now(),
|
|
summary: shortSummary,
|
|
fullResponse: fullSynopsis,
|
|
claudeSessionId: result.claudeSessionId,
|
|
projectPath: session.cwd,
|
|
sessionId: sessionId,
|
|
success: result.success,
|
|
usageStats: result.usageStats,
|
|
elapsedTimeMs
|
|
});
|
|
|
|
// Speak the synopsis via TTS if audio feedback is enabled
|
|
if (audioFeedbackEnabled && audioFeedbackCommand && shortSummary) {
|
|
window.maestro.notification.speak(shortSummary, audioFeedbackCommand).catch(err => {
|
|
console.error('[BatchProcessor] Failed to speak synopsis:', err);
|
|
});
|
|
}
|
|
|
|
// Check if we've hit the stalling threshold
|
|
if (consecutiveNoChangeCount >= MAX_CONSECUTIVE_NO_CHANGES) {
|
|
console.warn(`[BatchProcessor] Detected stalling: ${consecutiveNoChangeCount} consecutive runs with no document changes`);
|
|
stalledDueToNoProgress = true;
|
|
addFinalLoopSummary(`Document has unchecked tasks but appears complete (${consecutiveNoChangeCount} consecutive runs with no changes)`);
|
|
break;
|
|
}
|
|
|
|
remainingTasks = newRemainingTasks;
|
|
console.log(`[BatchProcessor] Document ${docEntry.filename}: ${remainingTasks} tasks remaining`);
|
|
|
|
} catch (error) {
|
|
console.error(`[BatchProcessor] Error running task in ${docEntry.filename} for session ${sessionId}:`, error);
|
|
// Continue to next task on error
|
|
remainingTasks--;
|
|
}
|
|
}
|
|
|
|
// Check for stop or stalling before doing reset
|
|
if (stopRequestedRefs.current[sessionId] || stalledDueToNoProgress) {
|
|
break;
|
|
}
|
|
|
|
// Document complete - handle reset-on-completion if enabled
|
|
console.log(`[BatchProcessor] Document ${docEntry.filename} complete. resetOnCompletion=${docEntry.resetOnCompletion}, docTasksCompleted=${docTasksCompleted}`);
|
|
if (docEntry.resetOnCompletion && docTasksCompleted > 0) {
|
|
console.log(`[BatchProcessor] Resetting document ${docEntry.filename} (reset-on-completion enabled)`);
|
|
|
|
// AUTORUN LOG: Document reset
|
|
window.maestro.logger.autorun(
|
|
`Resetting document: ${docEntry.filename}`,
|
|
session.name,
|
|
{
|
|
document: docEntry.filename,
|
|
tasksCompleted: docTasksCompleted,
|
|
loopNumber: loopIteration + 1
|
|
}
|
|
);
|
|
|
|
// Read the current content and uncheck all tasks
|
|
const { content: currentContent } = await readDocAndCountTasks(folderPath, docEntry.filename);
|
|
|
|
// Count checked tasks before reset
|
|
const checkedMatches = currentContent.match(CHECKED_TASK_REGEX) || [];
|
|
const checkedBefore = checkedMatches.length;
|
|
console.log(`[BatchProcessor] Document ${docEntry.filename} has ${checkedBefore} checked tasks before reset`);
|
|
console.log(`[BatchProcessor] Checked task matches:`, checkedMatches);
|
|
|
|
const resetContent = uncheckAllTasks(currentContent);
|
|
|
|
// Count unchecked tasks after reset
|
|
const uncheckedAfter = countUnfinishedTasks(resetContent);
|
|
console.log(`[BatchProcessor] Document ${docEntry.filename} has ${uncheckedAfter} unchecked tasks after reset`);
|
|
|
|
// Log first 500 chars of content before/after for debugging
|
|
console.log(`[BatchProcessor] Content before reset (first 500):`, currentContent.substring(0, 500));
|
|
console.log(`[BatchProcessor] Content after reset (first 500):`, resetContent.substring(0, 500));
|
|
|
|
// Write the reset content back
|
|
const writeResult = await window.maestro.autorun.writeDoc(folderPath, docEntry.filename + '.md', resetContent);
|
|
console.log(`[BatchProcessor] Write result for ${docEntry.filename}:`, writeResult);
|
|
|
|
// If loop is enabled, add the reset tasks back to the total
|
|
if (loopEnabled) {
|
|
const resetTaskCount = countUnfinishedTasks(resetContent);
|
|
setBatchRunStates(prev => ({
|
|
...prev,
|
|
[sessionId]: {
|
|
...prev[sessionId],
|
|
totalTasksAcrossAllDocs: prev[sessionId].totalTasksAcrossAllDocs + resetTaskCount,
|
|
totalTasks: prev[sessionId].totalTasks + resetTaskCount
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if we stalled due to no progress
|
|
if (stalledDueToNoProgress) {
|
|
break;
|
|
}
|
|
|
|
// Check if we should continue looping
|
|
if (!loopEnabled) {
|
|
// No loop mode - we're done after one pass
|
|
// AUTORUN LOG: Exit (non-loop mode)
|
|
window.maestro.logger.autorun(
|
|
`Auto Run completed (single pass)`,
|
|
session.name,
|
|
{
|
|
reason: 'Single pass completed',
|
|
totalTasksCompleted: totalCompletedTasks,
|
|
loopsCompleted: 1
|
|
}
|
|
);
|
|
break;
|
|
}
|
|
|
|
// Check if we've hit the max loop limit
|
|
if (maxLoops !== null && maxLoops !== undefined && loopIteration + 1 >= maxLoops) {
|
|
console.log(`[BatchProcessor] Reached max loop limit (${maxLoops}), exiting loop`);
|
|
addFinalLoopSummary(`Reached max loop limit (${maxLoops})`);
|
|
break;
|
|
}
|
|
|
|
// Check for stop request after full pass
|
|
if (stopRequestedRefs.current[sessionId]) {
|
|
addFinalLoopSummary('Stopped by user');
|
|
break;
|
|
}
|
|
|
|
// Safety check: if we didn't process ANY tasks this iteration, exit to avoid infinite loop
|
|
if (!anyTasksProcessedThisIteration) {
|
|
console.warn('[BatchProcessor] No tasks processed this iteration - exiting to avoid infinite loop');
|
|
addFinalLoopSummary('No tasks processed this iteration');
|
|
break;
|
|
}
|
|
|
|
// Loop mode: check if we should continue looping
|
|
// Check if there are any non-reset documents in the playbook
|
|
const hasAnyNonResetDocs = documents.some(doc => !doc.resetOnCompletion);
|
|
|
|
if (hasAnyNonResetDocs) {
|
|
// If we have non-reset docs, only continue if they have remaining tasks
|
|
let anyNonResetDocsHaveTasks = false;
|
|
for (const doc of documents) {
|
|
if (doc.resetOnCompletion) continue;
|
|
|
|
const { taskCount } = await readDocAndCountTasks(folderPath, doc.filename);
|
|
if (taskCount > 0) {
|
|
anyNonResetDocsHaveTasks = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!anyNonResetDocsHaveTasks) {
|
|
console.log('[BatchProcessor] All non-reset documents completed, exiting loop');
|
|
addFinalLoopSummary('All tasks completed');
|
|
break;
|
|
}
|
|
}
|
|
// If all documents are reset docs, we continue looping (maxLoops check above will stop us)
|
|
|
|
// Re-scan all documents to get fresh task counts for next loop (tasks may have been added/removed)
|
|
let newTotalTasks = 0;
|
|
for (const doc of documents) {
|
|
const { taskCount } = await readDocAndCountTasks(folderPath, doc.filename);
|
|
newTotalTasks += taskCount;
|
|
}
|
|
|
|
// Calculate loop elapsed time
|
|
const loopElapsedMs = Date.now() - loopStartTime;
|
|
|
|
// Add loop summary history entry
|
|
const loopSummary = `Loop ${loopIteration + 1} completed: ${loopTasksCompleted} task${loopTasksCompleted !== 1 ? 's' : ''} accomplished`;
|
|
const loopDetails = [
|
|
`**Loop ${loopIteration + 1} Summary**`,
|
|
'',
|
|
`- **Tasks Accomplished:** ${loopTasksCompleted}`,
|
|
`- **Duration:** ${formatLoopDuration(loopElapsedMs)}`,
|
|
loopTotalInputTokens > 0 || loopTotalOutputTokens > 0
|
|
? `- **Tokens:** ${(loopTotalInputTokens + loopTotalOutputTokens).toLocaleString()} (${loopTotalInputTokens.toLocaleString()} in / ${loopTotalOutputTokens.toLocaleString()} out)`
|
|
: '',
|
|
loopTotalCost > 0 ? `- **Cost:** $${loopTotalCost.toFixed(4)}` : '',
|
|
`- **Tasks Discovered for Next Loop:** ${newTotalTasks}`,
|
|
].filter(line => line !== '').join('\n');
|
|
|
|
onAddHistoryEntry({
|
|
type: 'AUTO',
|
|
timestamp: Date.now(),
|
|
summary: loopSummary,
|
|
fullResponse: loopDetails,
|
|
projectPath: session.cwd,
|
|
sessionId: sessionId,
|
|
success: true,
|
|
elapsedTimeMs: loopElapsedMs,
|
|
usageStats: loopTotalInputTokens > 0 || loopTotalOutputTokens > 0 ? {
|
|
inputTokens: loopTotalInputTokens,
|
|
outputTokens: loopTotalOutputTokens,
|
|
cacheReadInputTokens: 0,
|
|
cacheCreationInputTokens: 0,
|
|
totalCostUsd: loopTotalCost,
|
|
contextWindow: 0
|
|
} : undefined
|
|
});
|
|
|
|
// Reset per-loop tracking for next iteration
|
|
loopStartTime = Date.now();
|
|
loopTasksCompleted = 0;
|
|
loopTasksDiscovered = newTotalTasks;
|
|
loopTotalInputTokens = 0;
|
|
loopTotalOutputTokens = 0;
|
|
loopTotalCost = 0;
|
|
|
|
// AUTORUN LOG: Loop completion
|
|
window.maestro.logger.autorun(
|
|
`Loop ${loopIteration + 1} completed`,
|
|
session.name,
|
|
{
|
|
loopNumber: loopIteration + 1,
|
|
tasksCompleted: loopTasksCompleted,
|
|
tasksForNextLoop: newTotalTasks
|
|
}
|
|
);
|
|
|
|
// Continue looping
|
|
loopIteration++;
|
|
console.log(`[BatchProcessor] Starting loop iteration ${loopIteration + 1}: ${newTotalTasks} tasks across all documents`);
|
|
|
|
setBatchRunStates(prev => ({
|
|
...prev,
|
|
[sessionId]: {
|
|
...prev[sessionId],
|
|
loopIteration,
|
|
totalTasksAcrossAllDocs: newTotalTasks + prev[sessionId].completedTasksAcrossAllDocs,
|
|
totalTasks: newTotalTasks + prev[sessionId].completedTasks
|
|
}
|
|
}));
|
|
}
|
|
|
|
// Create PR if worktree was used, PR creation is enabled, and not stopped
|
|
const wasStopped = stopRequestedRefs.current[sessionId] || false;
|
|
const sessionName = session.name || session.cwd.split('/').pop() || 'Unknown';
|
|
if (worktreeActive && worktree?.createPROnCompletion && !wasStopped && totalCompletedTasks > 0) {
|
|
console.log('[BatchProcessor] Creating PR from worktree branch', worktreeBranch);
|
|
|
|
try {
|
|
// Use the user-selected target branch, or fall back to default branch detection
|
|
let baseBranch = worktree.prTargetBranch;
|
|
if (!baseBranch) {
|
|
const defaultBranchResult = await window.maestro.git.getDefaultBranch(session.cwd);
|
|
baseBranch = defaultBranchResult.success && defaultBranchResult.branch
|
|
? defaultBranchResult.branch
|
|
: 'main';
|
|
}
|
|
|
|
// Generate PR title and body
|
|
const prTitle = `Auto Run: ${documents.length} document(s) processed`;
|
|
const prBody = generatePRBody(documents, totalCompletedTasks);
|
|
|
|
// Create the PR (pass ghPath if configured)
|
|
const prResult = await window.maestro.git.createPR(
|
|
effectiveCwd,
|
|
baseBranch,
|
|
prTitle,
|
|
prBody,
|
|
worktree.ghPath
|
|
);
|
|
|
|
if (prResult.success) {
|
|
console.log('[BatchProcessor] PR created successfully:', prResult.prUrl);
|
|
// Notify caller of successful PR creation
|
|
if (onPRResult) {
|
|
onPRResult({
|
|
sessionId,
|
|
sessionName,
|
|
success: true,
|
|
prUrl: prResult.prUrl
|
|
});
|
|
}
|
|
} else {
|
|
console.warn('[BatchProcessor] PR creation failed:', prResult.error);
|
|
// Notify caller of PR creation failure (doesn't fail the run)
|
|
if (onPRResult) {
|
|
onPRResult({
|
|
sessionId,
|
|
sessionName,
|
|
success: false,
|
|
error: prResult.error
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('[BatchProcessor] Error creating PR:', error);
|
|
// Notify caller of PR creation error (doesn't fail the run)
|
|
if (onPRResult) {
|
|
onPRResult({
|
|
sessionId,
|
|
sessionName,
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add final Auto Run summary entry (no sessionId - this is a standalone synopsis)
|
|
const totalElapsedMs = Date.now() - batchStartTime;
|
|
const loopsCompleted = loopEnabled ? loopIteration + 1 : 1;
|
|
const statusText = stalledDueToNoProgress ? 'stalled' : wasStopped ? 'stopped' : 'completed';
|
|
|
|
// Calculate achievement progress for the summary
|
|
// Note: We use the stats BEFORE this run is recorded (the parent will call recordAutoRunComplete after)
|
|
// So we need to add totalElapsedMs to get the projected cumulative time
|
|
const projectedCumulativeTime = (autoRunStats?.cumulativeTimeMs || 0) + totalElapsedMs;
|
|
const currentBadge = getBadgeForTime(projectedCumulativeTime);
|
|
const nextBadge = getNextBadge(currentBadge);
|
|
const levelProgressText = nextBadge
|
|
? `Level ${currentBadge?.level || 0} → ${nextBadge.level}: ${formatTimeRemaining(projectedCumulativeTime, nextBadge)}`
|
|
: currentBadge
|
|
? `Level ${currentBadge.level} (${currentBadge.name}) - Maximum level achieved!`
|
|
: 'Level 0 → 1: ' + formatTimeRemaining(0, getBadgeForTime(0) || undefined);
|
|
|
|
const finalSummary = `Auto Run ${statusText}: ${totalCompletedTasks} task${totalCompletedTasks !== 1 ? 's' : ''} in ${formatLoopDuration(totalElapsedMs)}`;
|
|
|
|
const finalDetails = [
|
|
`**Auto Run Summary**`,
|
|
'',
|
|
`- **Status:** ${stalledDueToNoProgress ? 'Stalled (no progress detected)' : wasStopped ? 'Stopped by user' : 'Completed'}`,
|
|
`- **Tasks Completed:** ${totalCompletedTasks}`,
|
|
`- **Total Duration:** ${formatLoopDuration(totalElapsedMs)}`,
|
|
loopEnabled ? `- **Loops Completed:** ${loopsCompleted}` : '',
|
|
totalInputTokens > 0 || totalOutputTokens > 0
|
|
? `- **Total Tokens:** ${(totalInputTokens + totalOutputTokens).toLocaleString()} (${totalInputTokens.toLocaleString()} in / ${totalOutputTokens.toLocaleString()} out)`
|
|
: '',
|
|
totalCost > 0 ? `- **Total Cost:** $${totalCost.toFixed(4)}` : '',
|
|
'',
|
|
`- **Documents:** ${documents.map(d => d.filename).join(', ')}`,
|
|
'',
|
|
`**Achievement Progress**`,
|
|
`- ${levelProgressText}`,
|
|
].filter(line => line !== '').join('\n');
|
|
|
|
// This entry has no sessionId - it's a standalone Auto Run synopsis
|
|
onAddHistoryEntry({
|
|
type: 'AUTO',
|
|
timestamp: Date.now(),
|
|
summary: finalSummary,
|
|
fullResponse: finalDetails,
|
|
projectPath: session.cwd,
|
|
// No sessionId - this is a standalone synopsis entry
|
|
success: !wasStopped,
|
|
elapsedTimeMs: totalElapsedMs,
|
|
usageStats: totalInputTokens > 0 || totalOutputTokens > 0 ? {
|
|
inputTokens: totalInputTokens,
|
|
outputTokens: totalOutputTokens,
|
|
cacheReadInputTokens: 0,
|
|
cacheCreationInputTokens: 0,
|
|
totalCostUsd: totalCost,
|
|
contextWindow: 0
|
|
} : undefined,
|
|
achievementAction: 'openAbout' // Enable clickable link to achievements panel
|
|
});
|
|
|
|
// Reset state for this session (clear worktree tracking)
|
|
setBatchRunStates(prev => ({
|
|
...prev,
|
|
[sessionId]: {
|
|
isRunning: false,
|
|
isStopping: false,
|
|
documents: [],
|
|
currentDocumentIndex: 0,
|
|
currentDocTasksTotal: 0,
|
|
currentDocTasksCompleted: 0,
|
|
totalTasksAcrossAllDocs: 0,
|
|
completedTasksAcrossAllDocs: 0,
|
|
loopEnabled: false,
|
|
loopIteration: 0,
|
|
folderPath: '',
|
|
// Clear worktree tracking
|
|
worktreeActive: false,
|
|
worktreePath: undefined,
|
|
worktreeBranch: undefined,
|
|
totalTasks: 0,
|
|
completedTasks: 0,
|
|
currentTaskIndex: 0,
|
|
originalContent: '',
|
|
sessionIds: claudeSessionIds
|
|
}
|
|
}));
|
|
|
|
// Call completion callback if provided
|
|
if (onComplete) {
|
|
onComplete({
|
|
sessionId,
|
|
sessionName: session.name || session.cwd.split('/').pop() || 'Unknown',
|
|
completedTasks: totalCompletedTasks,
|
|
totalTasks: initialTotalTasks,
|
|
wasStopped,
|
|
elapsedTimeMs: Date.now() - batchStartTime
|
|
});
|
|
}
|
|
}, [onUpdateSession, onSpawnAgent, onSpawnSynopsis, onAddHistoryEntry, onComplete, onPRResult, audioFeedbackEnabled, audioFeedbackCommand]);
|
|
|
|
/**
|
|
* Request to stop the batch run for a specific session after current task completes
|
|
*/
|
|
const stopBatchRun = useCallback((sessionId: string) => {
|
|
stopRequestedRefs.current[sessionId] = true;
|
|
setBatchRunStates(prev => ({
|
|
...prev,
|
|
[sessionId]: {
|
|
...prev[sessionId],
|
|
isStopping: true
|
|
}
|
|
}));
|
|
}, []);
|
|
|
|
return {
|
|
batchRunStates,
|
|
getBatchState,
|
|
hasAnyActiveBatch,
|
|
activeBatchSessionIds,
|
|
startBatchRun,
|
|
stopBatchRun,
|
|
customPrompts,
|
|
setCustomPrompt
|
|
};
|
|
}
|