Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b1d5f48
Add Vitest test harness with in-memory SQLite isolation and socket mocks
bh13731 Mar 31, 2026
f7cb787
feat: add autonomous agent workflow system (assess → review → impleme…
bh13731 Mar 31, 2026
0d1b90c
fix: ensure .orchestrator-worktrees parent dir exists before git work…
bh13731 Mar 31, 2026
cfe26a7
Fix TypeScript build errors: add missing Job fields, PR event types, …
bh13731 Mar 31, 2026
78164dc
fix: detect and re-fire stuck workflows on startup, fix worktree label
bh13731 Mar 31, 2026
bd41b1d
fix: stop WorktreeCleanup from deleting workflow worktrees for other …
bh13731 Mar 31, 2026
fa64104
fix: replace opaque Record<string, unknown> PR event types with concr…
bh13731 Mar 31, 2026
0a10078
fix: close remaining gaps in stuck-workflow recovery and worktree cle…
bh13731 Mar 31, 2026
bc92eed
fix: run workflow gap detector every 60s, not just at startup
bh13731 Mar 31, 2026
1a1dd73
test: add singleton-reset and dedup coverage for WorkflowManager.onJo…
bh13731 Mar 31, 2026
6983906
fix: detect and recover MCP-disconnected agents within 2 minutes
bh13731 Mar 31, 2026
f51787b
feat: add StopMode types, DB migration, and query support for flexibl…
bh13731 Mar 31, 2026
6d5feca
feat: add CostEstimator and token accumulation for budget-based stopping
bh13731 Mar 31, 2026
9fb6a9c
feat: enforce budget and time stopping conditions in HealthMonitor
bh13731 Mar 31, 2026
40623f2
feat: wire stop_mode/stop_value through WorkflowManager, API routes, …
bh13731 Mar 31, 2026
df9285f
feat: add StopModePicker UI component and integrate into JobForm and …
bh13731 Mar 31, 2026
df7786d
fix: trigger workflow/debate handlers when budget/time limit stops an…
bh13731 Mar 31, 2026
4a06c4d
Merge pull request #1 from bh13731/orchestrator/flexible-stopping-con…
bh13731 Mar 31, 2026
0c3c946
fix: remove stale module-level jobs list cache
bh13731 Mar 31, 2026
b29c0c8
Add comprehensive API integration test suite
bh13731 Apr 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,474 changes: 1,448 additions & 26 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
"server:dev": "NODE_OPTIONS='--experimental-sqlite' tsx watch --ignore './data/**' src/server/index.ts",
"client:dev": "vite",
"build": "tsc -p tsconfig.server.json && vite build",
"server:start": "node dist/server/index.js"
"server:start": "node dist/server/index.js",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.78.0",
Expand All @@ -31,14 +33,17 @@
"@types/node": "^20.19.35",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"@types/supertest": "^7.2.0",
"@types/uuid": "^9.0.8",
"@vitejs/plugin-react": "^4.7.0",
"concurrently": "^8.2.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"socket.io-client": "^4.8.3",
"supertest": "^7.2.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^5.4.21"
"vite": "^5.4.21",
"vitest": "^4.1.2"
}
}
44 changes: 42 additions & 2 deletions src/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,28 @@ const ProjectSelector = lazy(() => import('./components/ProjectSelector').then(m
const SettingsModal = lazy(() => import('./components/SettingsModal').then(m => ({ default: m.SettingsModal })));
const DebateForm = lazy(() => import('./components/DebateForm').then(m => ({ default: m.DebateForm })));
const DebateDetailModal = lazy(() => import('./components/DebateDetailModal').then(m => ({ default: m.DebateDetailModal })));
const WorkflowForm = lazy(() => import('./components/WorkflowForm').then(m => ({ default: m.WorkflowForm })));
const WorkflowDetailModal = lazy(() => import('./components/WorkflowDetailModal').then(m => ({ default: m.WorkflowDetailModal })));
const KnowledgeBaseModal = lazy(() => import('./components/KnowledgeBaseModal').then(m => ({ default: m.KnowledgeBaseModal })));
import { useSocket } from './hooks/useSocket';
import { useAgents } from './hooks/useAgents';
import { useJobs } from './hooks/useJobs';
import { useLocks } from './hooks/useLocks';
import { useProjects } from './hooks/useProjects';
import { useDebates } from './hooks/useDebates';
import { useWorkflows } from './hooks/useWorkflows';
import { useToasts } from './hooks/useToasts';
import { ToastFeed } from './components/ToastFeed';
import socket from './socket';
import type { AgentWithJob, AgentOutput, CreateJobRequest, CreateDebateRequest, Debate, Job, Template, BatchTemplate, Discussion, Proposal } from '@shared/types';
import type { AgentWithJob, AgentOutput, CreateJobRequest, CreateDebateRequest, CreateWorkflowRequest, Debate, Workflow, Job, Template, BatchTemplate, Discussion, Proposal } from '@shared/types';

export default function App() {
const { agents, setInitial: setInitialAgents, addAgent, updateAgent } = useAgents();
const { jobs, setInitial: setInitialJobs, addJob, updateJob } = useJobs();
const { locks, setInitial: setInitialLocks, addLock, removeLock } = useLocks();
const { projects, setInitial: setInitialProjects, addProject, updateProject, removeProject } = useProjects();
const { debates, setInitial: setInitialDebates, addDebate, updateDebate: updateDebateState } = useDebates();
const { workflows, setInitial: setInitialWorkflows, addWorkflow, updateWorkflow: updateWorkflowState } = useWorkflows();
const { toasts, dismiss: dismissToast } = useToasts();
const [templates, setTemplates] = useState<Template[]>([]);

Expand All @@ -54,6 +58,8 @@ export default function App() {
const [showDebateForm, setShowDebateForm] = useState(false);
const [debateFormInitial, setDebateFormInitial] = useState<Partial<CreateDebateRequest> | undefined>();
const [selectedDebate, setSelectedDebate] = useState<Debate | null>(null);
const [showWorkflowForm, setShowWorkflowForm] = useState(false);
const [selectedWorkflow, setSelectedWorkflow] = useState<Workflow | null>(null);
const [showKnowledgeBase, setShowKnowledgeBase] = useState(false);
const [activeProjectId, setActiveProjectId] = useState<string | null>(null);
const [archivedJobs, setArchivedJobs] = useState<Job[]>([]);
Expand Down Expand Up @@ -153,6 +159,7 @@ export default function App() {
setTemplates(snapshot.templates ?? []);
setInitialProjects(snapshot.projects ?? []);
setInitialDebates(snapshot.debates ?? []);
setInitialWorkflows(snapshot.workflows ?? []);
setDiscussions(snapshot.discussions ?? []);
setProposals(snapshot.proposals ?? []);
},
Expand All @@ -174,6 +181,8 @@ export default function App() {
onProjectNew: addProject,
onDebateNew: addDebate,
onDebateUpdate: updateDebateState,
onWorkflowNew: addWorkflow,
onWorkflowUpdate: updateWorkflowState,
onDiscussionNew: (discussion: Discussion) => setDiscussions(prev => [discussion, ...prev.filter(d => d.id !== discussion.id)]),
onDiscussionUpdate: (discussion: Discussion) => setDiscussions(prev => prev.map(d => d.id === discussion.id ? discussion : d)),
onProposalNew: (proposal: Proposal) => setProposals(prev => [proposal, ...prev.filter(p => p.id !== proposal.id)]),
Expand Down Expand Up @@ -328,6 +337,21 @@ export default function App() {
}
}, [activeProjectId]);

const handleSubmitWorkflow = useCallback(async (req: CreateWorkflowRequest) => {
const res = await fetch('/api/workflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error ?? 'Failed to create workflow');
}
const data = await res.json();
addProject(data.project);
setActiveProjectId(data.project.id);
}, [addProject]);

const handleSubmitDebate = useCallback(async (req: CreateDebateRequest) => {
const res = await fetch('/api/debates', {
method: 'POST',
Expand Down Expand Up @@ -381,7 +405,7 @@ export default function App() {

return (
<div className="app">
<Header onNewJob={() => setShowJobForm(true)} onTemplates={() => setShowTemplates(true)} onBatchTemplates={() => setShowBatchTemplates(true)} onUsage={() => setShowUsage(true)} onSearch={() => setShowSearch(true)} onTimeline={() => setShowGantt(true)} onDag={() => setShowDag(true)} onProjects={() => setShowProjects(true)} onSettings={() => setShowSettings(true)} onDebate={() => { setDebateFormInitial(undefined); setShowDebateForm(true); }} onDebates={debates.length > 0 ? debates : undefined} onSelectDebate={(d) => setSelectedDebate(d)} onKnowledgeBase={() => setShowKnowledgeBase(true)} onEye={() => setShowEye(v => !v)} eyeEnabled={eyeEnabled} eyeActive={showEye} eyeBadgeCount={showEye ? 0 : discussions.filter(d => d.needs_reply).length + proposals.filter(p => p.needs_reply).length} onHome={() => { setSelectedAgent(null); setActiveProjectId(null); setShowJobForm(false); setShowTemplates(false); setShowBatchTemplates(false); setShowUsage(false); setShowSearch(false); setShowGantt(false); setShowDag(false); setShowProjects(false); setShowSettings(false); setShowDebateForm(false); setShowKnowledgeBase(false); setShowEye(false); }} currentProjectName={activeProjectName} onClearProject={() => setActiveProjectId(null)} todayClaudeCost={todayClaudeCost ?? undefined} todayCodexCost={todayCodexCost ?? undefined} costAutoUpdate={costAutoUpdate} onToggleCostAutoUpdate={() => setCostAutoUpdate(v => !v)} />
<Header onNewJob={() => setShowJobForm(true)} onTemplates={() => setShowTemplates(true)} onBatchTemplates={() => setShowBatchTemplates(true)} onUsage={() => setShowUsage(true)} onSearch={() => setShowSearch(true)} onTimeline={() => setShowGantt(true)} onDag={() => setShowDag(true)} onProjects={() => setShowProjects(true)} onSettings={() => setShowSettings(true)} onDebate={() => { setDebateFormInitial(undefined); setShowDebateForm(true); }} onDebates={debates.length > 0 ? debates : undefined} onSelectDebate={(d) => setSelectedDebate(d)} onWorkflow={() => setShowWorkflowForm(true)} onWorkflows={workflows.length > 0 ? workflows : undefined} onSelectWorkflow={(w) => setSelectedWorkflow(w)} onKnowledgeBase={() => setShowKnowledgeBase(true)} onEye={() => setShowEye(v => !v)} eyeEnabled={eyeEnabled} eyeActive={showEye} eyeBadgeCount={showEye ? 0 : discussions.filter(d => d.needs_reply).length + proposals.filter(p => p.needs_reply).length} onHome={() => { setSelectedAgent(null); setActiveProjectId(null); setShowJobForm(false); setShowTemplates(false); setShowBatchTemplates(false); setShowUsage(false); setShowSearch(false); setShowGantt(false); setShowDag(false); setShowProjects(false); setShowSettings(false); setShowDebateForm(false); setShowWorkflowForm(false); setShowKnowledgeBase(false); setShowEye(false); }} currentProjectName={activeProjectName} onClearProject={() => setActiveProjectId(null)} todayClaudeCost={todayClaudeCost ?? undefined} todayCodexCost={todayCodexCost ?? undefined} costAutoUpdate={costAutoUpdate} onToggleCostAutoUpdate={() => setCostAutoUpdate(v => !v)} />

<div className="main-layout">
<div className={`left-sidebar-stack ${leftTab === 'lineage' && selectedAgent ? '' : 'left-sidebar-stack--narrow'}`}>
Expand Down Expand Up @@ -504,6 +528,22 @@ export default function App() {
<SettingsModal onClose={() => setShowSettings(false)} eyeEnabled={eyeEnabled} onEyeEnabledChange={setEyeEnabled} />
)}

{showWorkflowForm && (
<WorkflowForm
onSubmit={handleSubmitWorkflow}
onClose={() => setShowWorkflowForm(false)}
/>
)}

{selectedWorkflow && (
<WorkflowDetailModal
workflow={workflows.find(w => w.id === selectedWorkflow.id) ?? selectedWorkflow}
agents={agents}
onClose={() => setSelectedWorkflow(null)}
onWorkflowUpdate={updateWorkflowState}
/>
)}

{showDebateForm && (
<DebateForm
onSubmit={handleSubmitDebate}
Expand Down
34 changes: 32 additions & 2 deletions src/client/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
import type { Debate } from '@shared/types';
import type { Debate, Workflow } from '@shared/types';

interface HeaderProps {
onNewJob: () => void;
Expand All @@ -14,6 +14,9 @@ interface HeaderProps {
onDebate: () => void;
onDebates?: Debate[];
onSelectDebate?: (debate: Debate) => void;
onWorkflow: () => void;
onWorkflows?: Workflow[];
onSelectWorkflow?: (workflow: Workflow) => void;
onKnowledgeBase: () => void;
onEye: () => void;
eyeActive?: boolean;
Expand Down Expand Up @@ -53,12 +56,14 @@ function HurlicaLogo() {
);
}

export function Header({ onNewJob, onTemplates, onBatchTemplates, onUsage, onSearch, onTimeline, onDag, onProjects, onSettings, onDebate, onDebates, onSelectDebate, onKnowledgeBase, onEye, eyeActive, eyeBadgeCount, eyeEnabled, onHome, currentProjectName, onClearProject, todayClaudeCost, todayCodexCost, costAutoUpdate, onToggleCostAutoUpdate }: HeaderProps) {
export function Header({ onNewJob, onTemplates, onBatchTemplates, onUsage, onSearch, onTimeline, onDag, onProjects, onSettings, onDebate, onDebates, onSelectDebate, onWorkflow, onWorkflows, onSelectWorkflow, onKnowledgeBase, onEye, eyeActive, eyeBadgeCount, eyeEnabled, onHome, currentProjectName, onClearProject, todayClaudeCost, todayCodexCost, costAutoUpdate, onToggleCostAutoUpdate }: HeaderProps) {
const hasCost = (todayClaudeCost != null && todayClaudeCost > 0) || (todayCodexCost != null && todayCodexCost > 0);
const [moreOpen, setMoreOpen] = useState(false);
const moreRef = useRef<HTMLDivElement>(null);
const [debateMenuOpen, setDebateMenuOpen] = useState(false);
const debateMenuRef = useRef<HTMLDivElement>(null);
const [workflowMenuOpen, setWorkflowMenuOpen] = useState(false);
const workflowMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!moreOpen) return;
const handler = (e: MouseEvent) => { if (!moreRef.current?.contains(e.target as Node)) setMoreOpen(false); };
Expand Down Expand Up @@ -124,6 +129,31 @@ export function Header({ onNewJob, onTemplates, onBatchTemplates, onUsage, onSea
)}
</button>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 1, background: 'var(--border)', borderRadius: 6, overflow: 'visible' }}>
<button className="header-btn" onClick={onWorkflow} style={{ borderRadius: onWorkflows && onWorkflows.length > 0 ? '6px 0 0 6px' : '6px' }}>Autonomous Agents</button>
{onWorkflows && onWorkflows.length > 0 && (
<div ref={workflowMenuRef} style={{ position: 'relative' }}>
<button className="header-btn" style={{ padding: '5px 6px', borderRadius: '0 6px 6px 0' }} onClick={() => setWorkflowMenuOpen(v => !v)} title="View workflows">&#x25be;</button>
{workflowMenuOpen && (
<div style={{ position: 'absolute', top: '100%', right: 0, zIndex: 200, marginTop: 4, background: 'var(--bg-elevated)', border: '1px solid var(--border)', borderRadius: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.4)', minWidth: 280, maxWidth: 400, maxHeight: 360, overflowY: 'auto' }}>
<div style={{ padding: '8px 12px', fontSize: 11, fontWeight: 600, color: 'var(--text-dim)', textTransform: 'uppercase', letterSpacing: '0.08em', borderBottom: '1px solid var(--border)' }}>Autonomous Agents</div>
{[...onWorkflows].sort((a, b) => b.updated_at - a.updated_at).map(w => {
const statusColor = w.status === 'running' ? 'var(--status-running)' : w.status === 'complete' ? 'var(--status-done)' : w.status === 'blocked' ? '#f59e0b' : 'var(--status-failed)';
return (
<button key={w.id} onClick={() => { setWorkflowMenuOpen(false); onSelectWorkflow?.(w); }}
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 12px', background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', textAlign: 'left', borderBottom: '1px solid var(--border)' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--border)')} onMouseLeave={e => (e.currentTarget.style.background = 'none')}>
<span style={{ width: 7, height: 7, borderRadius: '50%', background: statusColor, flexShrink: 0, display: 'inline-block' }} />
<span style={{ flex: 1, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{w.title}</span>
<span style={{ fontSize: 11, color: 'var(--text-dim)', flexShrink: 0 }}>C{w.current_cycle}/{w.max_cycles}</span>
</button>
);
})}
</div>
)}
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 1, background: 'var(--border)', borderRadius: 6, overflow: 'visible' }}>
<button className="header-btn" onClick={onDebate} style={{ borderRadius: '6px 0 0 6px' }}>Debate</button>
{onDebates && onDebates.length > 0 && (
Expand Down
15 changes: 14 additions & 1 deletion src/client/components/JobForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import type { CreateJobRequest, Job, Template, RetryPolicy, ReviewConfig } from '@shared/types';
import type { CreateJobRequest, Job, Template, RetryPolicy, ReviewConfig, StopMode } from '@shared/types';
import { TemplateModelStats } from './TemplateModelStats';
import { StopModePicker } from './StopModePicker';
import { useModels } from '../hooks/useModels';

interface JobFormProps {
Expand All @@ -21,6 +22,8 @@ export function JobForm({ onSubmit, onClose, availableJobs = [] }: JobFormProps)
const [dependsOn, setDependsOn] = useState<string[]>([]);
const [interactive, setInteractive] = useState(true);
const [useWorktree, setUseWorktree] = useState(false);
const [stopMode, setStopMode] = useState<StopMode>('completion');
const [stopValue, setStopValue] = useState<number | null>(null);
const [repeatSeconds, setRepeatSeconds] = useState<number | ''>('');
const [retryPolicy, setRetryPolicy] = useState<RetryPolicy>('none');
const [maxRetries, setMaxRetries] = useState(3);
Expand Down Expand Up @@ -87,6 +90,8 @@ export function JobForm({ onSubmit, onClose, availableJobs = [] }: JobFormProps)
dependsOn: dependsOn.length > 0 ? dependsOn : undefined,
interactive: interactive || undefined,
useWorktree: useWorktree || undefined,
stopMode,
stopValue: stopValue ?? undefined,
repeatIntervalMs: repeatSeconds ? (repeatSeconds as number) * 1000 : undefined,
retryPolicy: retryPolicy !== 'none' ? retryPolicy : undefined,
maxRetries: retryPolicy !== 'none' ? maxRetries : undefined,
Expand Down Expand Up @@ -251,6 +256,14 @@ export function JobForm({ onSubmit, onClose, availableJobs = [] }: JobFormProps)
</label>
</div>

<StopModePicker
label="Stopping condition"
mode={stopMode}
value={stopValue}
onModeChange={setStopMode}
onValueChange={setStopValue}
/>

<div className="form-group">
<label htmlFor="repeatSeconds">
Repeat every
Expand Down
82 changes: 82 additions & 0 deletions src/client/components/StopModePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React from 'react';
import type { StopMode } from '@shared/types';

interface StopModePickerProps {
mode: StopMode;
value: number | null;
onModeChange: (mode: StopMode) => void;
onValueChange: (value: number | null) => void;
label?: string;
}

const MODES: { key: StopMode; label: string }[] = [
{ key: 'turns', label: 'Turns' },
{ key: 'budget', label: 'Budget' },
{ key: 'time', label: 'Time' },
{ key: 'completion', label: 'Run to Completion' },
];

export function StopModePicker({ mode, value, onModeChange, onValueChange, label }: StopModePickerProps) {
return (
<div className="form-group">
{label && <label>{label}</label>}
<div className="stop-mode-buttons">
{MODES.map(m => (
<button
key={m.key}
type="button"
className={`stop-mode-btn${mode === m.key ? ' active' : ''}`}
onClick={() => {
onModeChange(m.key);
if (m.key === 'completion') onValueChange(null);
}}
>
{m.label}
</button>
))}
</div>
<div className="stop-mode-input">
{mode === 'turns' && (
<input
type="number"
min={10}
max={500}
value={value ?? ''}
onChange={e => onValueChange(Number(e.target.value))}
placeholder="50"
/>
)}
{mode === 'budget' && (
<div className="stop-mode-prefixed-input">
<span className="stop-mode-prefix">$</span>
<input
type="number"
min={0.5}
max={100}
step={0.5}
value={value ?? ''}
onChange={e => onValueChange(Number(e.target.value))}
placeholder="5.00"
/>
</div>
)}
{mode === 'time' && (
<div className="stop-mode-prefixed-input">
<input
type="number"
min={5}
max={480}
value={value ?? ''}
onChange={e => onValueChange(Number(e.target.value))}
placeholder="60"
/>
<span className="stop-mode-suffix">min</span>
</div>
)}
{mode === 'completion' && (
<span className="stop-mode-hint">Runs until done (safety cap: 1000 turns)</span>
)}
</div>
</div>
);
}
Loading