Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
472d491
feat(server): agents read failing CI logs and re-run checks through M…
juliusmarminge Oct 29, 2026
fdd6a18
fix(server): CI tools keep sign-in failures, refresh checks after a r…
juliusmarminge Nov 11, 2026
51308b7
fix(server): CI reruns cover only the selected jobs and logs stay bou…
juliusmarminge Nov 11, 2026
e802bf8
fix(server): a GitHub run with more jobs than one page re-runs one jo…
juliusmarminge Nov 11, 2026
cc5d94a
fix(server): ci_logs reads at most five logs unless a check is named
juliusmarminge Nov 11, 2026
6ba205e
fix(evals): ci_logs in the eval runner reads five logs like the service
juliusmarminge Nov 11, 2026
1bcc3ea
fix(server): shorter ci_logs description
juliusmarminge Nov 11, 2026
bbb3f17
fix(server): agent instructions point CI work at ci_logs and ci_rerun
juliusmarminge Nov 11, 2026
85ca8b8
fix(evals): pull_request_create in the eval runner opens one PR per c…
juliusmarminge Nov 11, 2026
ff00094
fix(evals): the simulated host shows PR changes the agent made throug…
juliusmarminge Nov 11, 2026
dd6d661
fix(evals): a new stack layer in the eval runner skips every world PR…
juliusmarminge Nov 11, 2026
995fd79
fix(evals): PR tools without a number act on the checked-out stack la…
juliusmarminge Nov 11, 2026
3a30576
test(server): Muse replay fixtures name the CI tools in the PR instru…
juliusmarminge Nov 11, 2026
2af412e
chore(server): agent context budget and eval forge log after the revi…
juliusmarminge Nov 13, 2026
a3e399b
feat(server): CI tools are listed only where the thread's project is …
juliusmarminge Nov 13, 2026
6110f02
fix(server): a CI rerun keeps a watched thread working until its chec…
juliusmarminge Nov 13, 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
6 changes: 5 additions & 1 deletion apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1618,13 +1618,17 @@ it.effect(
"pull_request_read",
"pull_request_review",
"pull_request_merge",
"ci_logs",
"ci_rerun",
]),
);
const noForgeTools = yield* listedTools("noForgeToken");
expect(noForgeTools).toEqual(
expect.arrayContaining(["link_pull_request", "watch_pull_request"]),
);
expect(noForgeTools.filter((name) => name.startsWith("pull_request_"))).toEqual([]);
expect(
noForgeTools.filter((name) => name.startsWith("pull_request_") || name.startsWith("ci_")),
).toEqual([]);
}),
).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
Expand Down
40 changes: 40 additions & 0 deletions apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,46 @@ describe("source control tools", () => {
}),
);

it.effect("re-running checks reopens a watch that saw its checks finish", () =>
Effect.gen(function* () {
const watch = {
startedAt: "2026-08-20T00:00:00.000Z",
headSha: "abc123",
failedChecks: ["test"],
passed: false,
passedChecks: [],
remarksThrough: "2026-08-20T00:00:00.000Z",
remarkIds: [],
conflicting: false,
wakes: 0,
};
const rerun = (checksPending: boolean | undefined, reran: boolean) =>
Effect.gen(function* () {
const harness = yield* makeHarness({
thread: {
...branchThread,
pullRequests: [
makeLink(42, {
watch: checksPending === undefined ? watch : { ...watch, checksPending },
}),
],
},
pullRequests: {
rerunChecks: () => Effect.succeed([{ name: "test", rerun: reran, detail: null }]),
},
});
yield* harness.call("ci_rerun", {});
return yield* Ref.get(harness.commands);
});
expect(yield* rerun(false, true)).toMatchObject([
{ type: "thread.pull-request-watch.checks-reopen", threadId: THREAD_ID },
]);
// Nothing re-ran, or the watch has no answer to clear.
expect(yield* rerun(false, false)).toEqual([]);
expect(yield* rerun(undefined, true)).toEqual([]);
}),
);

it.effect("opens a PR from the thread's worktree and links it even if dismissed", () =>
Effect.gen(function* () {
const opened: Array<{ readonly cwd: string; readonly draft: boolean | undefined }> = [];
Expand Down
40 changes: 40 additions & 0 deletions apps/server/src/mcp/toolkits/pullRequests/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1105,6 +1105,46 @@ const make = Effect.gen(function* () {
return { ...identityOf(target), state: after.state };
}),
),
ci_logs: McpToolAccess.reads((input) =>
Effect.gen(function* () {
const { target, ref } = yield* locate(input);
const checks = yield* pullRequests
.checkLogs({
...ref,
tailLines: input.tailLines ?? 200,
...(input.check === undefined ? {} : { check: input.check }),
})
.pipe(Effect.mapError(forgeFailure));
return { ...identityOf(target), checks };
}),
),
ci_rerun: McpToolAccess.writesWithCapability((input) =>
Effect.gen(function* () {
const { thread, target, ref } = yield* locate(input);
const checks = yield* pullRequests
.rerunChecks({ ...ref, ...(input.check === undefined ? {} : { check: input.check }) })
.pipe(Effect.mapError(forgeFailure));
// Like a push, a rerun makes a watch's finished checks stale, so the thread keeps working
// until a read sees them run. The rerun already happened, so a failed reopen only logs.
if (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High pullRequests/handlers.ts:1129

A successful rerun can leave the thread with a finished-checks answer while checks are running, allowing the thread to complete prematurely. thread is the pre-rerun snapshot from locate, so this guard skips checks-reopen if that snapshot lacks checksPending, even when the watch reactor records checksPending: false during rerunChecks. Dispatch whenever a check was rerun; the orchestrator safely no-ops when no state needs clearing.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/mcp/toolkits/pullRequests/handlers.ts around line 1129:

A successful rerun can leave the thread with a finished-checks answer while checks are running, allowing the thread to complete prematurely. `thread` is the pre-rerun snapshot from `locate`, so this guard skips `checks-reopen` if that snapshot lacks `checksPending`, even when the watch reactor records `checksPending: false` during `rerunChecks`. Dispatch whenever a check was rerun; the orchestrator safely no-ops when no state needs clearing.

checks.some((check) => check.rerun) &&
threadPullRequestsOf(thread).some((link) => link.watch?.checksPending !== undefined)
) {
yield* engine
.dispatch({
type: "thread.pull-request-watch.checks-reopen",
commandId: yield* commandId("mcp-ci-rerun-checks", thread.id),
threadId: thread.id,
})
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("reopening a watch's checks after a rerun failed", cause),
),
);
}
return { ...identityOf(target), checks };
}),
),
} satisfies McpToolAccess.Handlers<typeof PullRequestsToolkit.tools>;
});

Expand Down
68 changes: 68 additions & 0 deletions apps/server/src/mcp/toolkits/pullRequests/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,72 @@ const MergePullRequestTool = Tool.make("pull_request_merge", {
.annotate(Tool.OpenWorld, true)
.annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenWithoutForge);

export const CheckLogsResult = Schema.Struct({
...PullRequestIdentity,
checks: Schema.Array(
Schema.Struct({
name: Schema.String,
url: Schema.NullOr(Schema.String),
log: Schema.NullOr(Schema.String),
truncated: Schema.Boolean,
detail: Schema.NullOr(Schema.String),
}),
),
});
export type CheckLogsResult = typeof CheckLogsResult.Type;

export const RerunChecksResult = Schema.Struct({
...PullRequestIdentity,
checks: Schema.Array(
Schema.Struct({
name: Schema.String,
rerun: Schema.Boolean,
detail: Schema.NullOr(Schema.String),
}),
),
});
export type RerunChecksResult = typeof RerunChecksResult.Type;

const CheckLogsTool = Tool.make("ci_logs", {
description:
"Read the tail of a PR's failing check logs, or one named check's. Use instead of gh run view.",
parameters: Schema.Struct({
...ThreadPullRequestInput.fields,
check: Schema.optional(TrimmedNonEmptyString),
tailLines: Schema.optional(
Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 2000 })).annotate({
description: "Default: 200.",
}),
),
}),
success: CheckLogsResult,
failure: PullRequestToolError,
dependencies: forgeDependencies,
})
.annotate(Tool.Title, "Read CI logs")
.annotate(Tool.Readonly, true)
.annotate(Tool.Destructive, false)
.annotate(Tool.Idempotent, true)
.annotate(Tool.OpenWorld, true)
.annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenWithoutForge);

const RerunChecksTool = Tool.make("ci_rerun", {
description: "Re-run a PR's failing checks, or one named check.",
parameters: Schema.Struct({
...ThreadPullRequestInput.fields,
check: Schema.optional(TrimmedNonEmptyString),
}),
success: RerunChecksResult,
failure: PullRequestToolError,
dependencies: forgeDependencies,
})
.annotate(Tool.Title, "Re-run CI")
.annotate(Tool.Readonly, false)
.annotate(Tool.Destructive, false)
.annotate(Tool.Idempotent, false)
.annotate(Tool.OpenWorld, true)
.annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenWithoutForge);

export const PullRequestsToolkit = Toolkit.make(
LinkPullRequestTool,
LinkPullRequestsTool,
Expand All @@ -781,4 +847,6 @@ export const PullRequestsToolkit = Toolkit.make(
RespondToPullRequestTool,
ReviewPullRequestTool,
MergePullRequestTool,
CheckLogsTool,
RerunChecksTool,
);
Loading
Loading