Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
52 changes: 50 additions & 2 deletions apps/penpal/frontend/src/components/CommentsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,26 @@ describe('CommentsPanel', () => {
expect(screen.getByText('Anchor text not found in document')).toBeDefined();
});

it('renders agent working indicator', () => {
it('renders agent working indicator visible when active', () => {
const workingThread: ThreadResponse = { ...mockThread, agentWorking: true };
const { container } = renderPanel([workingThread]);
expect(container.querySelector('.thread-working')).toBeDefined();
const el = container.querySelector('.thread-working');
expect(el).toBeDefined();
expect(el?.classList.contains('hidden')).toBe(false);
});

it('renders agent working indicator hidden when inactive', () => {
const { container } = renderPanel([mockThread]);
const el = container.querySelector('.thread-working');
expect(el).toBeDefined();
expect(el?.classList.contains('hidden')).toBe(true);
});

it('always renders agent indicator in DOM for layout stability', () => {
const { container } = renderPanel();
const el = container.querySelector('.agent-indicator');
expect(el).toBeDefined();
expect(el?.classList.contains('hidden')).toBe(true);
});

it('renders agent status indicator when running', () => {
Expand All @@ -156,4 +172,36 @@ describe('CommentsPanel', () => {
expect(screen.getByText('Agent')).toBeDefined();
expect(screen.getByText('45%')).toBeDefined();
});

it('agent indicator is visible when running, hidden when not', () => {
const { container, rerender } = render(
<MemoryRouter>
<CommentsPanel
threads={[mockThread]}
anchorLines={{ 'thread-1': 5 }}
project="test/project"
filePath="thoughts/test.md"
onRefresh={vi.fn()}
agentStatus={{ running: true, contextPercent: 45 }}
/>
</MemoryRouter>,
);
const indicator = container.querySelector('.agent-indicator');
expect(indicator?.classList.contains('hidden')).toBe(false);

rerender(
<MemoryRouter>
<CommentsPanel
threads={[mockThread]}
anchorLines={{ 'thread-1': 5 }}
project="test/project"
filePath="thoughts/test.md"
onRefresh={vi.fn()}
agentStatus={{ running: false }}
/>
</MemoryRouter>,
);
const indicatorAfter = container.querySelector('.agent-indicator');
expect(indicatorAfter?.classList.contains('hidden')).toBe(true);
});
});
60 changes: 28 additions & 32 deletions apps/penpal/frontend/src/components/CommentsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,28 +98,26 @@ export default function CommentsPanel({
<span>
Comments {openCount > 0 && <span id="comments-count">{openCount}</span>}
</span>
{agentStatus?.running && (
<span className="agent-indicator">
<span className="agent-dot" />
<span>Agent</span>
{agentStatus.contextPercent !== undefined && (
<span
className={`agent-context-label ${agentContextColorClass(agentStatus.contextPercent)}`}
>
{Math.round(agentStatus.contextPercent)}%
</span>
)}
<button
className="agent-stop-btn"
onClick={(e) => {
e.stopPropagation();
api.stopAgent(project).then(onRefresh);
}}
<span className={`agent-indicator${agentStatus?.running ? '' : ' hidden'}`}>
<span className="agent-dot" />
<span>Agent</span>
{agentStatus?.running && agentStatus.contextPercent !== undefined && (
<span
className={`agent-context-label ${agentContextColorClass(agentStatus.contextPercent)}`}
>
Stop
</button>
</span>
)}
{Math.round(agentStatus.contextPercent)}%
</span>
)}
<button
className="agent-stop-btn"
onClick={(e) => {
e.stopPropagation();
api.stopAgent(project).then(onRefresh);
}}
>
Stop
</button>
</span>
{resolvedThreads.length > 0 && (
<span id="resolved-toggle">
<label style={{ cursor: 'pointer', fontWeight: 'normal', textTransform: 'none', letterSpacing: 'normal' }}>
Expand Down Expand Up @@ -456,17 +454,15 @@ function ThreadCard({
</div>
)}

{/* Working indicator */}
{thread.agentWorking && (
<div className="thread-working">
<span className="agent-dot" />
<span className="working-dots">
<span>.</span>
<span>.</span>
<span>.</span>
</span>
</div>
)}
{/* Working indicator — always rendered to avoid layout shift */}
<div className={`thread-working${thread.agentWorking ? '' : ' hidden'}`}>
<span className="agent-dot" />
<span className="working-dots">
<span>.</span>
<span>.</span>
<span>.</span>
</span>
</div>

{/* Actions */}
{!replyOpen && (
Expand Down
2 changes: 2 additions & 0 deletions apps/penpal/frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,7 @@ a:hover { text-decoration: underline; }
margin-left: auto;
margin-right: 8px;
}
.agent-indicator.hidden { visibility: hidden; }
.agent-indicator .agent-dot {
display: inline-block;
width: 7px;
Expand Down Expand Up @@ -985,6 +986,7 @@ a:hover { text-decoration: underline; }
color: var(--accent-success);
font-size: 0.85em;
}
.thread-working.hidden { visibility: hidden; }
.thread-working .agent-dot {
display: inline-block;
width: 7px;
Expand Down
39 changes: 39 additions & 0 deletions apps/penpal/frontend/src/pages/FilePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,45 @@ describe('FilePage', () => {
});
});

it('does not auto-start agent when server reports cooldown', async () => {
vi.mocked(api.getAgentStatus).mockResolvedValue({
...agentNotRunning,
needsAgent: true,
cooldown: true,
});

renderFilePage();

await waitFor(() => {
expect(api.getAgentStatus).toHaveBeenCalled();
});

expect(api.startAgent).not.toHaveBeenCalled();
});

it('rate-limits auto-start attempts to prevent rapid restart loops', async () => {
// First call: needsAgent triggers auto-start
vi.mocked(api.getAgentStatus)
.mockResolvedValueOnce({ ...agentNotRunning, needsAgent: true })
.mockResolvedValue({ ...agentNotRunning, needsAgent: true });
vi.mocked(api.startAgent).mockResolvedValue(agentNotRunning);

renderFilePage();

await waitFor(() => {
expect(api.startAgent).toHaveBeenCalledTimes(1);
});

// Simulate SSE agent event triggering another fetchAgentStatus
const { onEvent } = getSSECallbacks();
await act(async () => {
onEvent!({ type: 'agents', project: 'ws/proj' });
});

// Should NOT have auto-started again (rate limited to 30s)
expect(api.startAgent).toHaveBeenCalledTimes(1);
});

it('refreshes content on SSE files event', async () => {
vi.mocked(api.getAgentStatus).mockResolvedValue(agentNotRunning);

Expand Down
8 changes: 7 additions & 1 deletion apps/penpal/frontend/src/pages/FilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default function FilePage() {
const contentRef = useRef<HTMLDivElement>(null);
const mermaidDraggingRef = useRef(false);
const agentPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const lastAutoStartRef = useRef<number>(0);
const [chatWidth, setChatWidth] = useState(() => {
const saved = localStorage.getItem('chatPanelWidth');
return saved ? parseInt(saved, 10) : 340;
Expand Down Expand Up @@ -204,7 +205,12 @@ export default function FilePage() {
try {
const status = await api.getAgentStatus(project);
setAgentStatus(status);
if (status.needsAgent && !status.running) {
if (status.needsAgent && !status.running && !status.cooldown) {
// Rate limit: don't auto-start if we attempted one recently.
// This prevents rapid restart loops when the agent keeps crashing.
const now = Date.now();
if (now - lastAutoStartRef.current < 30_000) return;
lastAutoStartRef.current = now;
Comment on lines +212 to +213

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make auto-start throttle project-scoped

The 30s auto-start throttle uses a single timestamp for the whole FilePage instance, so an auto-start in one project suppresses auto-start in another project visited shortly after. When users switch files/projects quickly, needsAgent=true for project B can be skipped by this guard even though B has never attempted a start; the throttle state needs to be keyed by project (or reset on project change).

Useful? React with 👍 / 👎.

// Auto-start, then fetch updated status to show the running dot
try {
await api.startAgent(project);
Expand Down
1 change: 1 addition & 0 deletions apps/penpal/frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export interface AgentStatus {
pid: number;
startedAt: string;
running: boolean;
cooldown?: boolean;
contextWindow: number;
contextUsed: number;
contextPercent: number;
Expand Down
72 changes: 59 additions & 13 deletions apps/penpal/internal/agents/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,23 +30,34 @@ type Agent struct {
numTurns int // number of assistant turns
}

// quickExitThreshold is the minimum run time to consider an agent exit
// "normal". Exits faster than this are tracked as quick exits to prevent
// rapid restart loops.
const quickExitThreshold = 15 * time.Second

// restartCooldown is the minimum time to wait after a quick exit before
// allowing another agent start for the same project.
const restartCooldown = 30 * time.Second

// Manager manages Claude Code agent processes, one per project.
type Manager struct {
mu sync.Mutex
agents map[string]*Agent // key: qualified project name
cache *cache.Cache
comments *comments.Store
port int
onChange func(projectName string) // called when agent starts or stops
claudeBin func() string // returns resolved path to claude binary
mu sync.Mutex
agents map[string]*Agent // key: qualified project name
lastQuickExit map[string]time.Time // key: project name -> time of last quick exit
cache *cache.Cache
comments *comments.Store
port int
onChange func(projectName string) // called when agent starts or stops
claudeBin func() string // returns resolved path to claude binary
}

func New(c *cache.Cache, cs *comments.Store, port int) *Manager {
return &Manager{
agents: make(map[string]*Agent),
cache: c,
comments: cs,
port: port,
agents: make(map[string]*Agent),
lastQuickExit: make(map[string]time.Time),
cache: c,
comments: cs,
port: port,
}
}

Expand All @@ -65,7 +76,8 @@ func (m *Manager) SetOnChange(fn func(projectName string)) {
}

// Start launches a Claude agent for the given project.
// Returns nil if an agent is already running for this project.
// Returns nil if an agent is already running for this project or if the
// project is in a restart cooldown (last agent exited quickly).
func (m *Manager) Start(projectName string) (*Agent, error) {
m.mu.Lock()
defer m.mu.Unlock()
Expand All @@ -80,6 +92,13 @@ func (m *Manager) Start(projectName string) (*Agent, error) {
}
}

// Prevent rapid restart loops: if the last agent for this project
// exited quickly (crashed), wait before allowing another start.
if t, ok := m.lastQuickExit[projectName]; ok && time.Since(t) < restartCooldown {
log.Printf("Agent start for %s blocked: cooldown after quick exit (%s ago)", projectName, time.Since(t).Round(time.Second))
return nil, nil
}

proj := m.cache.FindProject(projectName)
if proj == nil {
return nil, fmt.Errorf("project %q not found", projectName)
Expand Down Expand Up @@ -161,7 +180,17 @@ func (m *Manager) Start(projectName string) (*Agent, error) {
logFile.Close()
os.Remove(mcpConfigPath)
close(agent.done)
log.Printf("Agent exited for %s (PID %d): %v", projectName, agent.PID, agent.exitErr)

runDuration := time.Since(agent.StartedAt)
log.Printf("Agent exited for %s (PID %d, ran %s): %v", projectName, agent.PID, runDuration.Round(time.Second), agent.exitErr)

// Track quick exits to prevent rapid restart loops
if runDuration < quickExitThreshold {
m.mu.Lock()
m.lastQuickExit[projectName] = time.Now()
m.mu.Unlock()
Comment on lines +188 to +191

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate cooldown updates on crash exits only

This branch marks every short-lived run as a "quick exit" without checking why the process ended, so a user-initiated stop (or any normal short run) within 15s still sets cooldown and blocks restart attempts for 30s. In practice, clicking Stop shortly after startup can prevent immediate restart even though nothing crashed; cooldown tracking should be limited to crash-like exits instead of all short durations.

Useful? React with 👍 / 👎.

log.Printf("Agent for %s exited quickly (%s) — restart cooldown active for %s", projectName, runDuration.Round(time.Millisecond), restartCooldown)
}

m.comments.ClearProjectHeartbeats(projectName)
m.comments.ClearProjectWorking(projectName)
Expand Down Expand Up @@ -219,6 +248,7 @@ type AgentStatus struct {
PID int `json:"pid"`
StartedAt time.Time `json:"startedAt"`
Running bool `json:"running"`
Cooldown bool `json:"cooldown,omitempty"` // true when agent recently crashed and restarts are blocked
ContextWindow int `json:"contextWindow"`
ContextUsed int `json:"contextUsed"`
ContextPercent float64 `json:"contextPercent"`
Expand All @@ -233,6 +263,13 @@ func (m *Manager) Status(projectName string) *AgentStatus {

agent, ok := m.agents[projectName]
if !ok {
// No agent exists — check if we're in cooldown
if t, ok := m.lastQuickExit[projectName]; ok && time.Since(t) < restartCooldown {
return &AgentStatus{
Project: projectName,
Cooldown: true,
}
}
return nil
}

Expand Down Expand Up @@ -284,6 +321,15 @@ func (m *Manager) SimulateFinished(projectName string) {
}
}

// SetQuickExit records a quick exit for the given project, activating the
// restart cooldown. This is intended for testing the cooldown behavior
// without requiring an actual agent process.
func (m *Manager) SetQuickExit(projectName string) {
m.mu.Lock()
defer m.mu.Unlock()
m.lastQuickExit[projectName] = time.Now()
}

// StopAll terminates all running agents (for server shutdown).
func (m *Manager) StopAll() {
m.mu.Lock()
Expand Down
Loading
Loading