Preserve scope on manual heartbeat invokes (#5323)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The agent live-run route lets operators trigger a manual heartbeat
invocation so an agent can pick up a specific issue or step out of band
> - The current route flow drops the caller's scope (issue/run context)
when forwarding the manual invoke into the heartbeat service, so the
resulting run loses the targeting the operator specified
> - This pull request threads the operator-supplied scope through the
manual invoke path on both the server route and the UI client, with a
regression test that confirms the scope round-trips
> - The benefit is manual heartbeat invokes from the live-run UI
actually pick up the scoped issue/run instead of falling through to the
agent's default routine

## What Changed

- `server/src/routes/agents.ts`: forward the operator-supplied scope
into the manual invoke heartbeat service call
- `server/src/__tests__/agent-live-run-routes.test.ts`: new test
verifying the manual invoke path preserves scope
- `ui/src/api/agents.ts`: pass scope through the live-run client API

## Verification

- `pnpm vitest run --no-coverage
server/src/__tests__/agent-live-run-routes.test.ts`
- `pnpm typecheck` clean

## Risks

Low. The change is purely additive on the route surface — handlers that
did not previously pass scope continue to work; handlers that did pass
it now have it preserved instead of dropped.

## Model Used

Claude Opus 4.7 (1M context)

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable — new test covers
the preserved-scope path
- [x] If this change affects the UI, I have included before/after
screenshots — N/A (internal API change, no visible UI shift)
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Devin Foley
2026-05-05 19:30:08 -07:00
committed by GitHub
parent 9fb0c73e0a
commit 83e7ecc58e
3 changed files with 151 additions and 24 deletions
@@ -12,6 +12,7 @@ const mockHeartbeatService = vi.hoisted(() => ({
getActiveRunIssueSummaryForAgent: vi.fn(),
getRunLogAccess: vi.fn(),
readLog: vi.fn(),
wakeup: vi.fn(),
}));
const mockIssueService = vi.hoisted(() => ({
@@ -26,6 +27,8 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
listCompanyIds: vi.fn(),
}));
const routeAgentId = "11111111-1111-4111-8111-111111111111";
function registerModuleMocks() {
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
@@ -210,6 +213,14 @@ describe("agent live run routes", () => {
content: "chunk",
nextOffset: 5,
});
mockHeartbeatService.wakeup.mockResolvedValue({
id: "run-1",
companyId: "company-1",
agentId: "agent-1",
status: "queued",
invocationSource: "on_demand",
triggerDetail: "manual",
});
});
it("returns a compact active run payload for issue polling", async () => {
@@ -524,4 +535,66 @@ describe("agent live run routes", () => {
expect(res.body).toHaveLength(4);
expect(db.select).toHaveBeenCalledTimes(2);
});
it("passes scoped wake fields through the legacy heartbeat invoke route", async () => {
const res = await requestApp(
await createApp(),
(baseUrl) => request(baseUrl)
.post(`/api/agents/${routeAgentId}/heartbeat/invoke?companyId=company-1`)
.send({
reason: "issue_assigned",
payload: {
issueId: "issue-1",
taskId: "issue-1",
taskKey: "issue-1",
},
forceFreshSession: true,
}),
);
expect(res.status, JSON.stringify(res.body)).toBe(202);
// The legacy /heartbeat/invoke endpoint forwards only the wake fields the
// caller actually supplied so empty-body callers (e.g. e2e suites) match
// the original fixed-arg `heartbeat.invoke()` shape exactly. When the
// caller supplies reason / payload / forceFreshSession those are
// forwarded; idempotencyKey is omitted unless explicitly set.
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, {
source: "on_demand",
triggerDetail: "manual",
reason: "issue_assigned",
payload: {
issueId: "issue-1",
taskId: "issue-1",
taskKey: "issue-1",
},
requestedByActorType: "user",
requestedByActorId: "local-board",
contextSnapshot: {
triggeredBy: "board",
actorId: "local-board",
forceFreshSession: true,
},
});
});
it("calls heartbeat.wakeup with the legacy minimal shape when the body is empty", async () => {
const res = await requestApp(
await createApp(),
(baseUrl) => request(baseUrl)
.post(`/api/agents/${routeAgentId}/heartbeat/invoke?companyId=company-1`)
.send({}),
);
expect(res.status, JSON.stringify(res.body)).toBe(202);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, {
source: "on_demand",
triggerDetail: "manual",
requestedByActorType: "user",
requestedByActorId: "local-board",
contextSnapshot: {
triggeredBy: "board",
actorId: "local-board",
},
});
});
});
+66 -16
View File
@@ -2883,7 +2883,25 @@ export function agentRoutes(
res.json({ ok: true });
});
router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => {
// Shared handler body for the wakeup-style endpoints. The two routes differ
// only in:
// - `source` — the modern /wakeup endpoint reads it from the request body
// (timer|assignment|on_demand|automation) while the legacy
// /heartbeat/invoke endpoint hardcodes "on_demand", since it has only
// ever produced on-demand invocations.
// - skipped-response shape — the modern endpoint surfaces the rich
// SkippedWakeupResponse; the legacy endpoint stays on the simpler
// { status: "skipped" } shape for backward compat.
type HeartbeatSource = "timer" | "assignment" | "on_demand" | "automation";
type WakeupRouteOpts = {
source: HeartbeatSource | undefined;
skippedResponse: (agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>) => unknown | Promise<unknown>;
};
const handleWakeupRoute = async (
req: Request,
res: Response,
opts: WakeupRouteOpts,
): Promise<void> => {
const id = req.params.id as string;
const agent = await svc.getById(id);
if (!agent) {
@@ -2902,7 +2920,7 @@ export function agentRoutes(
}
const run = await heartbeat.wakeup(id, {
source: req.body.source,
source: opts.source,
triggerDetail: req.body.triggerDetail ?? "manual",
reason: req.body.reason ?? null,
payload: req.body.payload ?? null,
@@ -2917,7 +2935,7 @@ export function agentRoutes(
});
if (!run) {
res.status(202).json(await buildSkippedWakeupResponse(agent, req.body.payload ?? null));
res.status(202).json(await opts.skippedResponse(agent));
return;
}
@@ -2935,9 +2953,23 @@ export function agentRoutes(
});
res.status(202).json(run);
};
router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => {
await handleWakeupRoute(req, res, {
source: req.body.source,
skippedResponse: (agent) => buildSkippedWakeupResponse(agent, req.body.payload ?? null),
});
});
router.post("/agents/:id/heartbeat/invoke", async (req, res) => {
// Legacy endpoint. Hardcodes `source: "on_demand"` (the prior behavior
// before the wakeup/invoke convergence). Reads scope fields directly off
// the body without `validate(wakeAgentSchema)` because callers — including
// the e2e suite — post an empty body, and the schema rejects undefined
// / missing bodies. Only forwards fields the caller actually supplied so
// an empty body produces the original fixed-arg `heartbeat.invoke()`
// shape exactly.
const id = req.params.id as string;
const agent = await svc.getById(id);
if (!agent) {
@@ -2955,19 +2987,37 @@ export function agentRoutes(
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
}
const run = await heartbeat.invoke(
id,
"on_demand",
{
triggeredBy: req.actor.type,
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
},
"manual",
{
actorType: req.actor.type === "agent" ? "agent" : "user",
actorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
},
);
const body = (req.body ?? {}) as Partial<{
reason: unknown;
payload: unknown;
idempotencyKey: unknown;
forceFreshSession: unknown;
triggerDetail: unknown;
}>;
const contextSnapshot: Record<string, unknown> = {
triggeredBy: req.actor.type,
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
};
if (body.forceFreshSession === true) {
contextSnapshot.forceFreshSession = true;
}
const wakeOpts: Parameters<typeof heartbeat.wakeup>[1] = {
source: "on_demand",
triggerDetail: typeof body.triggerDetail === "string" ? body.triggerDetail as "manual" | "system" | "ping" | "callback" : "manual",
requestedByActorType: req.actor.type === "agent" ? "agent" : "user",
requestedByActorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
contextSnapshot,
};
if (typeof body.reason === "string" && body.reason.length > 0) {
wakeOpts.reason = body.reason;
}
if (body.payload && typeof body.payload === "object" && !Array.isArray(body.payload)) {
wakeOpts.payload = body.payload as Record<string, unknown>;
}
if (typeof body.idempotencyKey === "string" && body.idempotencyKey.length > 0) {
wakeOpts.idempotencyKey = body.idempotencyKey;
}
const run = await heartbeat.wakeup(id, wakeOpts);
if (!run) {
res.status(202).json({ status: "skipped" });
+12 -8
View File
@@ -69,6 +69,15 @@ export interface AgentPermissionUpdate {
canAssignTasks: boolean;
}
export interface AgentWakeRequest {
source?: "timer" | "assignment" | "on_demand" | "automation";
triggerDetail?: "manual" | "ping" | "callback" | "system";
reason?: string | null;
payload?: Record<string, unknown> | null;
idempotencyKey?: string | null;
forceFreshSession?: boolean;
}
function withCompanyScope(path: string, companyId?: string) {
if (!companyId) return path;
const separator = path.includes("?") ? "&" : "?";
@@ -204,16 +213,11 @@ export const agentsApi = {
`/companies/${companyId}/adapters/${type}/test-environment`,
data,
),
invoke: (id: string, companyId?: string) => api.post<HeartbeatRun>(agentPath(id, companyId, "/heartbeat/invoke"), {}),
invoke: (id: string, companyId?: string, data: AgentWakeRequest = {}) =>
api.post<HeartbeatRun>(agentPath(id, companyId, "/heartbeat/invoke"), data),
wakeup: (
id: string,
data: {
source?: "timer" | "assignment" | "on_demand" | "automation";
triggerDetail?: "manual" | "ping" | "callback" | "system";
reason?: string | null;
payload?: Record<string, unknown> | null;
idempotencyKey?: string | null;
},
data: AgentWakeRequest,
companyId?: string,
) => api.post<AgentWakeupResponse>(agentPath(id, companyId, "/wakeup"), data),
loginWithClaude: (id: string, companyId?: string) =>