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
435 changes: 385 additions & 50 deletions apps/server/src/agents/manager.ts

Large diffs are not rendered by default.

24 changes: 14 additions & 10 deletions apps/server/src/personas/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,19 @@ type PersonaFrontmatter = {
};

const PERSONAS_DIR = ".dispatch/personas";
const MAX_DIFF_BYTES = 50 * 1024;
export const MAX_DIFF_BYTES = 50 * 1024;

export function truncateDiffForPrompt(diff: string): string {
if (Buffer.byteLength(diff, "utf-8") <= MAX_DIFF_BYTES) {
return diff;
}

const decoder = new TextDecoder("utf-8", { fatal: false });
return (
decoder.decode(Buffer.from(diff, "utf-8").subarray(0, MAX_DIFF_BYTES)) +
"\n\n[... diff truncated at 50KB ...]"
);
}

export function parseFrontmatter(content: string): {
frontmatter: PersonaFrontmatter;
Expand Down Expand Up @@ -144,14 +156,6 @@ export function assemblePersonaPrompt(
context: string,
diff: string
): string {
let truncatedDiff = diff;
if (Buffer.byteLength(diff, "utf-8") > MAX_DIFF_BYTES) {
const decoder = new TextDecoder("utf-8", { fatal: false });
truncatedDiff =
decoder.decode(Buffer.from(diff, "utf-8").subarray(0, MAX_DIFF_BYTES)) +
"\n\n[... diff truncated at 50KB ...]";
}

// Strip legacy {{context}} and {{diff}} placeholders if present — Dispatch
// now appends these sections automatically so persona files don't need them.
const personaBody = persona.body
Expand All @@ -162,6 +166,6 @@ export function assemblePersonaPrompt(
personaBody.trimEnd(),
STANDARD_FEEDBACK_GUIDANCE,
`## Context from parent agent\n${context}`,
`## Changes to review\n${truncatedDiff}`,
`## Changes to review\n${truncateDiffForPrompt(diff)}`,
].join("\n\n");
}
20 changes: 20 additions & 0 deletions apps/server/src/reviews/poll-cadence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export const RECHECK_POLL_TIMEOUT = "cancelled" as const;

export function pollCadenceSeconds(
submittedAt: Date,
now: Date
): number | typeof RECHECK_POLL_TIMEOUT {
const elapsedMs = now.getTime() - submittedAt.getTime();
const elapsedSeconds = elapsedMs / 1000;

if (elapsedSeconds > 2 * 60 * 60) {
return RECHECK_POLL_TIMEOUT;
}
if (elapsedSeconds < 9 * 60) {
return 180;
}
if (elapsedSeconds < 24 * 60) {
return 300;
}
return 600;
}
88 changes: 87 additions & 1 deletion apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
assemblePersonaPrompt,
} from "./personas/loader.js";
import { buildPersonaReviewDiff } from "./personas/review-diff.js";
import { truncateDiffForPrompt } from "./personas/loader.js";
import {
isPasswordSet,
setPassword,
Expand Down Expand Up @@ -1613,7 +1614,14 @@ async function registerRoutes() {

reply.hijack();
await handleMcpRequest(request.raw, reply.raw, request.body, {
agent,
agent: {
id: agent.id,
cwd: agent.cwd,
persona: agent.persona,
parentAgentId: agent.parentAgentId,
baseBranch: agent.baseBranch,
review: null,
},
repoRoot,
worktreeRoot,
sendNotify: mcpSendNotify,
Expand Down Expand Up @@ -1664,6 +1672,9 @@ async function registerRoutes() {
if (!agent) {
return reply.code(404).send({ error: "Agent not found." });
}
const review = agent.persona
? await agentManager.getPersonaReview(agentId)
: null;
const activeJobRun = await jobService.getActiveRunForAgent(agentId);
if (activeJobRun) {
return reply
Expand All @@ -1688,6 +1699,7 @@ async function registerRoutes() {
persona: agent.persona,
parentAgentId: agent.parentAgentId,
baseBranch: agent.baseBranch,
review: review ? { allowRecheck: review.allowRecheck } : null,
},
repoRoot,
worktreeRoot,
Expand All @@ -1702,6 +1714,8 @@ async function registerRoutes() {
getFeedback: mcpGetFeedback,
resolveFeedback: mcpResolveFeedback,
submitResolution: mcpSubmitResolution,
awaitRecheck: mcpAwaitRecheck,
cancelRecheck: mcpCancelRecheck,
upsertPin: mcpUpsertPin,
deletePin: mcpDeletePin,
getParentContext: mcpGetParentContext,
Expand Down Expand Up @@ -5309,6 +5323,61 @@ async function mcpSubmitResolution(
return result;
}

async function mcpAwaitRecheck(
agentId: string
): Promise<import("./shared/mcp/server.js").AwaitRecheckResponse> {
const reviewer = await agentManager.getAgent(agentId);
if (!reviewer?.persona) {
throw new Error(
"dispatch_await_recheck is only available to reviewer agents."
);
}

const result = await agentManager.awaitReviewRecheck(agentId);
if (result.status !== "ready") {
return result;
}

const diffSincePreviousRound =
reviewer.cwd && result.review.lastReviewedCommit
? await diffSinceCommit(reviewer.cwd, result.review.lastReviewedCommit)
: "";

return {
status: "ready",
summary: result.resolution.summary,
resolutions: result.resolutions,
diffSincePreviousRound: truncateDiffForPrompt(diffSincePreviousRound),
};
}

async function mcpCancelRecheck(
agentId: string,
input: { personaAgentId: string; reason?: string }
): Promise<void> {
const review = await agentManager.cancelReviewRecheck({
parentAgentId: agentId,
personaAgentId: input.personaAgentId,
reason: input.reason ?? null,
});
const [child, parent] = await Promise.all([
agentManager.getAgent(input.personaAgentId),
agentManager.getAgent(review.parentAgentId),
]);
if (child) {
uiEventBroker.publish({
type: "agent.upsert",
agent: withStreamFlag(child),
});
}
if (parent) {
uiEventBroker.publish({
type: "agent.upsert",
agent: withStreamFlag(parent),
});
}
}

async function mcpUpsertPin(
agentId: string,
pin: { label: string; value: string; type: string }
Expand Down Expand Up @@ -5482,6 +5551,7 @@ async function mcpLaunchPersona(
persona: string;
context: string;
agentType?: (typeof AGENT_TYPES)[number];
allowRecheck?: boolean;
}
): Promise<{ agentId: string; persona: string; parentAgentId: string }> {
const parent = await agentManager.getAgent(agentId);
Expand Down Expand Up @@ -5573,6 +5643,7 @@ async function mcpLaunchPersona(
parentAgentId: agentId,
persona: opts.persona,
lastReviewedCommit: launchCommit,
allowRecheck: opts.allowRecheck,
});

// Re-fetch so the SSE event includes the review subquery data
Expand Down Expand Up @@ -5607,6 +5678,21 @@ async function mcpLaunchPersona(
return { agentId: agent.id, persona: opts.persona, parentAgentId: agentId };
}

async function diffSinceCommit(
cwd: string,
baseCommit: string
): Promise<string> {
const result = await runCommand(
"git",
["-C", cwd, "diff", `${baseCommit}...HEAD`],
{ allowedExitCodes: [0, 128] }
);
if (result.exitCode !== 0) {
return "";
}
return result.stdout;
}

async function mcpShareMedia(
agentId: string,
opts: {
Expand Down
Loading
Loading