Skip to content
Merged
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
346 changes: 83 additions & 263 deletions src/backends/progressMonitor.ts

Large diffs are not rendered by default.

103 changes: 103 additions & 0 deletions src/backends/progressState/accumulator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* Progress state accumulator for CASCADE agents.
*
* Accumulates tool calls, text snippets, completed tasks, and iteration
* counts using ring buffers. Provides a snapshot of current progress
* context for use by the progress model and posting layers.
*/

import { loadTodos } from '../../gadgets/todo/storage.js';
import type { ProgressContext } from '../progressModel.js';
import type { LogWriter } from '../types.js';

export const RING_BUFFER_MAX = 20;
export const TEXT_SNIPPETS_MAX = 10;
export const COMPLETED_TASKS_MAX = 5;

/**
* Extract a meaningful detail string from tool call params.
* Returns file paths, commands, or search patterns — the most useful
* context for progress reporting.
*/
export function summarizeToolParams(_toolName: string, params?: Record<string, unknown>): string {
if (!params) return '';
if (params.file_path) return String(params.file_path);
if (params.filePath) return String(params.filePath);
if (params.command) return String(params.command).slice(0, 100);
if (params.pattern) {
const detail = String(params.pattern);
return params.path ? `${detail} in ${params.path}` : detail;
}
return '';
}

export class ProgressAccumulator {
private recentToolCalls: { name: string; detail?: string; timestamp: number }[] = [];
private recentTextSnippets: { text: string; timestamp: number }[] = [];
private completedTasks: { subject: string; summary: string; timestamp: number }[] = [];
private currentIteration = 0;
private maxIterations = 0;
private readonly startTime = Date.now();

constructor(private readonly logWriter: LogWriter) {}

onIteration(iteration: number, maxIterations: number): void {
this.currentIteration = iteration;
this.maxIterations = maxIterations;
}

onToolCall(toolName: string, params?: Record<string, unknown>): void {
const detail = summarizeToolParams(toolName, params);
this.recentToolCalls.push({
name: toolName,
detail: detail || undefined,
timestamp: Date.now(),
});
if (this.recentToolCalls.length > RING_BUFFER_MAX) {
this.recentToolCalls.shift();
}
this.logWriter('INFO', 'Tool call', { toolName, params });
}

onText(content: string): void {
if (content.trim()) {
this.recentTextSnippets.push({
text: content.slice(0, 200),
timestamp: Date.now(),
});
if (this.recentTextSnippets.length > TEXT_SNIPPETS_MAX) {
this.recentTextSnippets.shift();
}
}
this.logWriter('INFO', 'Agent text output', { length: content.length });
}

onTaskCompleted(taskId: string, subject: string, summary: string): void {
this.completedTasks.push({
subject,
summary: summary.slice(0, 300),
timestamp: Date.now(),
});
if (this.completedTasks.length > COMPLETED_TASKS_MAX) {
this.completedTasks.shift();
}
this.logWriter('INFO', 'Task completed', { taskId, subject });
}

getSnapshot(agentType: string, taskDescription: string): ProgressContext {
const todos = loadTodos();
const elapsedMinutes = (Date.now() - this.startTime) / 60_000;

return {
agentType,
taskDescription,
elapsedMinutes,
iteration: this.currentIteration,
maxIterations: this.maxIterations,
todos,
recentToolCalls: [...this.recentToolCalls],
recentTextSnippets: [...this.recentTextSnippets],
completedTasks: [...this.completedTasks],
};
}
}
51 changes: 51 additions & 0 deletions src/backends/progressState/githubPoster.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* GitHub PR progress comment poster.
*
* Updates the initial PR comment with AI-generated progress summaries.
* Reads the session state to find the initial comment ID, formats the
* GitHub progress comment, and updates it via the GitHub client.
*/

import { formatGitHubProgressComment } from '../../config/statusUpdateConfig.js';
import { getSessionState } from '../../gadgets/sessionState.js';
import { githubClient } from '../../github/client.js';
import type { LogWriter } from '../types.js';

export interface GitHubProgressPosterConfig {
owner: string;
repo: string;
headerMessage: string;
logWriter: LogWriter;
}

export class GitHubProgressPoster {
constructor(private readonly config: GitHubProgressPosterConfig) {}

async update(
summary: string,
iteration: number,
maxIterations: number,
agentType: string,
): Promise<void> {
const { initialCommentId } = getSessionState();
if (!initialCommentId) return;

const body = formatGitHubProgressComment(
this.config.headerMessage,
iteration,
maxIterations,
agentType,
);
// Replace the todo section with the AI-generated summary
const bodyWithSummary = body.replace(/\n\n📋[\s\S]*?\n\n/, `\n\n${summary}\n\n`);
await githubClient.updatePRComment(
this.config.owner,
this.config.repo,
initialCommentId,
bodyWithSummary,
);
this.config.logWriter('INFO', 'Updated GitHub PR comment with progress', {
commentId: initialCommentId,
});
}
}
113 changes: 113 additions & 0 deletions src/backends/progressState/pmPoster.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* PM (Project Management) progress comment poster.
*
* Manages the create-once/update-in-place/fallback-to-new lifecycle
* for progress comments on Trello/JIRA work items. Handles state file
* coordination with the PostComment gadget subprocess.
*/

import { INITIAL_MESSAGES } from '../../config/agentMessages.js';
import { getPMProviderOrNull } from '../../pm/index.js';
import { readProgressCommentId, writeProgressCommentId } from '../progressState.js';
import type { LogWriter } from '../types.js';

export interface PMProgressPosterConfig {
agentType: string;
cardId: string;
repoDir?: string;
logWriter: LogWriter;
}

export class PMProgressPoster {
private progressCommentId: string | null = null;

constructor(private readonly config: PMProgressPosterConfig) {}

getCommentId(): string | null {
return this.progressCommentId;
}

setCommentId(commentId: string): void {
this.progressCommentId = commentId;
}

private formatInitialMessage(): string {
return (
INITIAL_MESSAGES[this.config.agentType] ??
`**🚀 Starting** (${this.config.agentType})\n\nWorking on this now. Progress updates will follow...`
);
}

private maybeWriteStateFile(commentId: string | null): void {
if (this.config.repoDir && commentId) {
writeProgressCommentId(this.config.repoDir, this.config.cardId, commentId);
}
}

async postInitial(): Promise<void> {
const provider = getPMProviderOrNull();
if (!provider) return;

const message = this.formatInitialMessage();
this.progressCommentId = await provider.addComment(this.config.cardId, message);
this.config.logWriter('INFO', 'Posted initial progress comment to work item', {
cardId: this.config.cardId,
commentId: this.progressCommentId,
});

// Write state file so PostComment gadget can update this comment
this.maybeWriteStateFile(this.progressCommentId);
}

async update(summary: string): Promise<void> {
const provider = getPMProviderOrNull();
if (!provider) return;

const { cardId } = this.config;

if (this.progressCommentId) {
// If the PostComment gadget (subprocess) cleared the state file,
// the agent has posted its final comment to this ID — do not overwrite.
const stateFile = readProgressCommentId(this.config.repoDir);
if (!stateFile) {
this.config.logWriter('DEBUG', 'State file cleared by agent — skipping progress update', {
commentId: this.progressCommentId,
});
this.progressCommentId = null;
return;
}

// Subsequent ticks: update the existing comment.
try {
await provider.updateComment(cardId, this.progressCommentId, summary);
this.config.logWriter('INFO', 'Updated progress comment on work item', {
cardId,
commentId: this.progressCommentId,
});
} catch (updateErr) {
// Comment may have been deleted — fall back to creating a new one
this.config.logWriter('WARN', 'Failed to update progress comment, creating new one', {
error: String(updateErr),
});
this.progressCommentId = await provider.addComment(cardId, summary);
this.config.logWriter('INFO', 'Posted new progress comment to work item', {
cardId,
commentId: this.progressCommentId,
});
// Update state file with new comment ID
this.maybeWriteStateFile(this.progressCommentId);
}
} else {
// First tick: create the comment and store its ID.
// This branch is reached when postInitial() failed (transient API error)
// and the first tick creates the comment instead.
this.progressCommentId = await provider.addComment(cardId, summary);
this.config.logWriter('INFO', 'Posted progress update to work item', {
cardId,
commentId: this.progressCommentId,
});
// Write state file so PostComment gadget can find this comment
this.maybeWriteStateFile(this.progressCommentId);
}
}
}
55 changes: 55 additions & 0 deletions src/backends/progressState/scheduler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Progressive timer scheduler for CASCADE progress monitor.
*
* Fires a tick callback according to a progressive schedule (e.g., 1min,
* 3min, 5min) before falling back to a steady-state interval. Pure
* scheduling — no business logic.
*/

/** Default progressive schedule: 1min, 3min, 5min, then every intervalMinutes */
export const DEFAULT_SCHEDULE_MINUTES = [1, 3, 5];

export class ProgressScheduler {
private timer: ReturnType<typeof setTimeout> | null = null;
private tickIndex = 0;
private stopped = false;

constructor(
private readonly schedule: number[],
private readonly intervalMinutes: number,
) {}

/**
* Start the scheduler, calling `tickFn` on each tick.
* `tickFn` is awaited before the next tick is scheduled.
*/
start(tickFn: () => Promise<void>): void {
this.scheduleNextTick(tickFn);
}

/** Stop the scheduler. No more ticks will fire after this call. */
stop(): void {
this.stopped = true;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}

private scheduleNextTick(tickFn: () => Promise<void>): void {
const delayMinutes =
this.tickIndex < this.schedule.length ? this.schedule[this.tickIndex] : this.intervalMinutes;
const delayMs = delayMinutes * 60 * 1000;
this.timer = setTimeout(() => {
void this.tickAndScheduleNext(tickFn);
}, delayMs);
}

private async tickAndScheduleNext(tickFn: () => Promise<void>): Promise<void> {
await tickFn();
this.tickIndex++;
if (!this.stopped) {
this.scheduleNextTick(tickFn);
}
}
}
Loading