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
93 changes: 66 additions & 27 deletions src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getRepoRoot,
removeWorktree,
} from "../utils/git.js";
import { createProgressTracker } from "../utils/progress.js";

const execFileAsync = promisify(execFile);

Expand Down Expand Up @@ -213,24 +214,43 @@ export async function retry(opts: RunOptions): Promise<void> {
console.log(` Re-running ${failed.length} agent(s) in parallel (${runner.name})...`);
console.log();

const showProgress = opts.outputFormat === "text";
const tracker = showProgress
? createProgressTracker(
worktrees.map((w) => w.id),
Boolean(process.stdout.isTTY),
)
: null;

tracker?.start();

const agentPromises = worktrees.map(({ id, path }) =>
runner.run(id, {
prompt: previous.prompt,
worktreePath: path,
model: previous.model,
timeout: opts.timeout,
verbose: opts.verbose,
}),
runner
.run(id, {
prompt: previous.prompt,
worktreePath: path,
model: previous.model,
timeout: opts.timeout,
verbose: opts.verbose,
})
.then((result) => {
tracker?.onAgentComplete(result);
return result;
}),
);

const retriedAgents: AgentResult[] = await Promise.all(agentPromises);

for (const agent of retriedAgents) {
const icon = agent.status === "success" ? "✓" : agent.status === "timeout" ? "⏱" : "✗";
const files = agent.filesChanged.length;
console.log(
` Agent #${agent.id}: ${icon} ${agent.status} — ${files} files changed in ${Math.round(agent.duration / 1000)}s`,
);
tracker?.finish();

if (!showProgress || !process.stdout.isTTY) {
for (const agent of retriedAgents) {
const icon = agent.status === "success" ? "✓" : agent.status === "timeout" ? "⏱" : "✗";
const files = agent.filesChanged.length;
console.log(
` Agent #${agent.id}: ${icon} ${agent.status} — ${files} files changed in ${Math.round(agent.duration / 1000)}s`,
);
}
}
console.log();

Expand Down Expand Up @@ -400,25 +420,44 @@ export async function run(opts: RunOptions): Promise<void> {
console.log(` Running ${opts.attempts} agents in parallel (${runner.name})...`);
console.log();

const showProgress = opts.outputFormat === "text";
const tracker = showProgress
? createProgressTracker(
worktrees.map((w) => w.id),
Boolean(process.stdout.isTTY),
)
: null;

tracker?.start();

const agentPromises = worktrees.map(({ id, path }) =>
runner.run(id, {
prompt: opts.prompt,
worktreePath: path,
model: opts.model,
timeout: opts.timeout,
verbose: opts.verbose,
}),
runner
.run(id, {
prompt: opts.prompt,
worktreePath: path,
model: opts.model,
timeout: opts.timeout,
verbose: opts.verbose,
})
.then((result) => {
tracker?.onAgentComplete(result);
return result;
}),
);

const agents: AgentResult[] = await Promise.all(agentPromises);

// Report completion
for (const agent of agents) {
const icon = agent.status === "success" ? "✓" : agent.status === "timeout" ? "⏱" : "✗";
const files = agent.filesChanged.length;
console.log(
` Agent #${agent.id}: ${icon} ${agent.status} — ${files} files changed in ${Math.round(agent.duration / 1000)}s`,
);
tracker?.finish();

// Report completion (skip in TTY mode — progress tracker already showed status)
if (!showProgress || !process.stdout.isTTY) {
for (const agent of agents) {
const icon = agent.status === "success" ? "✓" : agent.status === "timeout" ? "⏱" : "✗";
const files = agent.filesChanged.length;
console.log(
` Agent #${agent.id}: ${icon} ${agent.status} — ${files} files changed in ${Math.round(agent.duration / 1000)}s`,
);
}
}
console.log();

Expand Down
163 changes: 163 additions & 0 deletions src/utils/progress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { AgentResult } from "../types.js";
import { createProgressTracker } from "./progress.js";

function makeAgent(overrides: Partial<AgentResult> = {}): AgentResult {
return {
id: 1,
worktree: "/tmp/thinktank-agent-1",
status: "success",
exitCode: 0,
duration: 45000,
output: "",
diff: "diff --git a/file.ts b/file.ts\n+added line",
filesChanged: ["file.ts", "other.ts"],
linesAdded: 2,
linesRemoved: 0,
...overrides,
};
}

function createMockStream(): { write(s: string): boolean; output: string[] } {
const output: string[] = [];
return {
write(s: string) {
output.push(s);
return true;
},
output,
};
}

describe("createProgressTracker — TTY mode", () => {
it("writes a running line for each agent on start", () => {
const stream = createMockStream();
const tracker = createProgressTracker([1, 2, 3], true, stream);
tracker.start();

assert.equal(stream.output.length, 3);
assert.ok(stream.output[0].includes("Agent #1: running..."));
assert.ok(stream.output[1].includes("Agent #2: running..."));
assert.ok(stream.output[2].includes("Agent #3: running..."));
});

it("updates the correct line when an agent completes", () => {
const stream = createMockStream();
const tracker = createProgressTracker([1, 2, 3], true, stream);
tracker.start();

const agent = makeAgent({ id: 2, duration: 45000, filesChanged: ["a.ts", "b.ts"] });
tracker.onAgentComplete(agent);

// Should have ANSI escape to move up and rewrite
const updateOutput = stream.output.slice(3).join("");
assert.ok(updateOutput.includes("Agent #2: done (45s, 2 files)"));
});

it("shows singular 'file' for 1 file changed", () => {
const stream = createMockStream();
const tracker = createProgressTracker([1], true, stream);
tracker.start();

const agent = makeAgent({ id: 1, duration: 10000, filesChanged: ["a.ts"] });
tracker.onAgentComplete(agent);

const updateOutput = stream.output.slice(1).join("");
assert.ok(updateOutput.includes("1 file)"), `Expected singular 'file' in: ${updateOutput}`);
});

it("shows status for failed agents", () => {
const stream = createMockStream();
const tracker = createProgressTracker([1], true, stream);
tracker.start();

const agent = makeAgent({ id: 1, status: "error", duration: 5000 });
tracker.onAgentComplete(agent);

const updateOutput = stream.output.slice(1).join("");
assert.ok(updateOutput.includes("error (5s)"));
});

it("shows status for timed-out agents", () => {
const stream = createMockStream();
const tracker = createProgressTracker([1], true, stream);
tracker.start();

const agent = makeAgent({ id: 1, status: "timeout", duration: 300000 });
tracker.onAgentComplete(agent);

const updateOutput = stream.output.slice(1).join("");
assert.ok(updateOutput.includes("timeout (300s)"));
});

it("handles all agents completing", () => {
const stream = createMockStream();
const tracker = createProgressTracker([1, 2], true, stream);
tracker.start();

tracker.onAgentComplete(makeAgent({ id: 1, duration: 30000, filesChanged: ["a.ts"] }));
tracker.onAgentComplete(makeAgent({ id: 2, duration: 40000, filesChanged: ["b.ts", "c.ts"] }));
tracker.finish();

const all = stream.output.join("");
assert.ok(all.includes("Agent #1: done (30s, 1 file)"));
assert.ok(all.includes("Agent #2: done (40s, 2 files)"));
});
});

describe("createProgressTracker — non-TTY mode", () => {
it("does not write anything on start", () => {
const stream = createMockStream();
const tracker = createProgressTracker([1, 2, 3], false, stream);
tracker.start();

assert.equal(stream.output.length, 0);
});

it("writes progress count on each completion", () => {
const stream = createMockStream();
const tracker = createProgressTracker([1, 2, 3], false, stream);
tracker.start();

tracker.onAgentComplete(makeAgent({ id: 1 }));
assert.ok(stream.output[0].includes("1/3 agents complete..."));

tracker.onAgentComplete(makeAgent({ id: 2 }));
assert.ok(stream.output[1].includes("2/3 agents complete..."));

tracker.onAgentComplete(makeAgent({ id: 3 }));
assert.ok(stream.output[2].includes("3/3 agents complete..."));
});

it("writes a trailing newline on finish", () => {
const stream = createMockStream();
const tracker = createProgressTracker([1, 2], false, stream);
tracker.start();

tracker.onAgentComplete(makeAgent({ id: 1 }));
tracker.onAgentComplete(makeAgent({ id: 2 }));
tracker.finish();

const last = stream.output[stream.output.length - 1];
assert.equal(last, "\n");
});

it("does not write trailing newline if no agents completed", () => {
const stream = createMockStream();
const tracker = createProgressTracker([1, 2], false, stream);
tracker.start();
tracker.finish();

assert.equal(stream.output.length, 0);
});

it("uses carriage return for in-place updates", () => {
const stream = createMockStream();
const tracker = createProgressTracker([1, 2], false, stream);
tracker.start();

tracker.onAgentComplete(makeAgent({ id: 1 }));
assert.ok(stream.output[0].startsWith("\r"));
});
});
66 changes: 66 additions & 0 deletions src/utils/progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { AgentResult } from "../types.js";

export interface ProgressTracker {
start(): void;
onAgentComplete(agent: AgentResult): void;
finish(): void;
}

/**
* Create a live progress tracker for parallel agent runs.
*
* TTY mode: writes one line per agent, updates in-place via ANSI cursor movement.
* Non-TTY mode: writes a single "X/N agents complete..." line on each completion.
*/
export function createProgressTracker(
agentIds: number[],
isTTY: boolean,
stream: { write(s: string): boolean } = process.stdout,
): ProgressTracker {
const total = agentIds.length;
let completed = 0;

if (isTTY) {
return {
start() {
for (const id of agentIds) {
stream.write(` Agent #${id}: running...\n`);
}
},
onAgentComplete(agent: AgentResult) {
completed++;
const index = agentIds.indexOf(agent.id);
const linesUp = total - index;
const secs = Math.round(agent.duration / 1000);
const files = agent.filesChanged.length;
const label =
agent.status === "success"
? `done (${secs}s, ${files} file${files !== 1 ? "s" : ""})`
: `${agent.status} (${secs}s)`;

// Move cursor up to the agent's line, overwrite, move back down
stream.write(`\x1b[${linesUp}A\r Agent #${agent.id}: ${label}\x1b[K\n`);
if (linesUp > 1) {
stream.write(`\x1b[${linesUp - 1}B`);
}
},
finish() {
// Cursor is already on the line after the last agent — nothing to do
},
};
}

// Non-TTY: simple one-liner
return {
start() {},
onAgentComplete() {
completed++;
stream.write(`\r ${completed}/${total} agents complete...`);
},
finish() {
if (completed > 0) {
stream.write("\n");
}
},
};
}
Loading