[codex] Clarify interrupt handoffs and scoped wake semantics (#7855)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The issue thread is the operator surface where comments, assignee
changes, pauses, resumes, and wakeups turn human intent into agent
execution.
> - Interrupting a live run and handing work to another assignee needs
clear semantics so the product does not accidentally keep work alive,
wake the wrong participant, or hide why an agent stopped.
> - Comment-driven wakes also need strict boundaries so closed, blocked,
and dependency-driven work only resumes when there is real actionable
input.
> - This pull request codifies the interrupt handoff contract,
implements backend scheduling behavior, and gives the UI clearer
handoff/pause language.
> - The benefit is a more inspectable and predictable task lifecycle for
both operators and agents.

## Linked Issues or Issue Description

Paperclip issue: `PAP-10664` / `PAP-10751`.

Problem: interrupting or reassigning live agent work could be ambiguous
in the UI and backend. Operators needed clearer feedback about whether a
handoff wakes an agent, what pause/cancel affects, and when comments
should revive execution. The backend also needed stronger tests around
comment wake boundaries, retry supersession, and structured agent
mention dispatch.

Related GitHub PR search found broad workflow-adjacent PRs #5082, #6359,
and #4083, but no exact duplicate for this head branch or
interrupt-handoff scope.

## What Changed

- Added an interrupt handoff semantics document covering destination
behavior, wake expectations, and live-run interruption states.
- Implemented backend interrupt handoff behavior and comment wake/reopen
handling in issue routes/services and heartbeat scheduling.
- Hardened structured agent mention dispatch so mentions resolve through
the intended dispatch path.
- Added UI helpers and components for handoff chips, wake rows,
interrupt banners, pause-affects summaries, and composer guidance.
- Updated the issue properties assignee picker and issue chat/composer
surfaces to make interrupt/reassign behavior clearer.
- Added backend, UI utility, component, and Storybook coverage for the
new behavior.
- Stabilized the new UI component tests with a local `flushSync`-backed
act helper matching existing repo practice in this dependency set.
- Addressed Greptile feedback by threading historical run `errorCode`
through issue-run data and operator-interrupted chat labels.
- Addressed Greptile's cancel ordering concern by terminating/deleting
in-memory heartbeat processes before cancellation status persistence,
with regression coverage for DB update failure.

## Verification

- `git diff --check $(git merge-base HEAD origin/master)..HEAD`
- `pnpm --filter @paperclipai/ui exec vitest run
src/lib/interrupt-handoff.test.ts src/lib/issue-chat-messages.test.ts
src/components/IssueProperties.test.tsx
src/components/interrupt-handoff/InterruptHandoffViews.test.tsx
--no-file-parallelism --maxWorkers=1` — 4 files / 91 tests passed before
the Greptile follow-ups.
- `pnpm run preflight:workspace-links && pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts
server/src/__tests__/heartbeat-retry-scheduling.test.ts
server/src/__tests__/issue-comment-reopen-routes.test.ts
server/src/__tests__/issue-tree-control-service.test.ts
server/src/__tests__/issue-update-comment-wakeup-routes.test.ts
server/src/__tests__/issues-service.test.ts --no-file-parallelism
--maxWorkers=1` — 6 files / 191 tests passed before the Greptile
follow-ups.
- `pnpm --filter @paperclipai/ui exec vitest run
src/lib/issue-chat-messages.test.ts --no-file-parallelism
--maxWorkers=1` — 1 file / 24 tests passed after the historical
`errorCode` follow-up.
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
server/src/__tests__/activity-routes.test.ts --no-file-parallelism
--maxWorkers=1` — 2 files / 11 tests passed after the historical
`errorCode` follow-up.
- `pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts
--no-file-parallelism --maxWorkers=1` — 1 file / 52 tests passed after
the cancel ordering follow-up.
- Greptile is green for head `272647636287d034bab8d981eaf5305865aa0f96`;
the old inline P2 is resolved/outdated.
- GitHub Actions, Socket, security-review, and Greptile checks are green
for head `272647636287d034bab8d981eaf5305865aa0f96`. The external
`security/snyk (cryppadotta)` status was still pending at
`https://app.snyk.io/org/cryppadotta/pr-checks/85b3e8f4-04e1-4f8e-9362-899c8148c23c`
after a bounded wait.

## Risks

- Medium: changes touch issue comments, wake scheduling, and live-run
interruption semantics, so regressions could affect when agents resume
or stay stopped.
- Medium: UI copy and state grouping for assignee changes may need
reviewer tuning after product review.
- Low migration risk: no database schema migration is included.
- The branch was created before the latest `origin/master` commits;
reviewers should confirm CI merge-base behavior and resolve any merge
conflicts if GitHub reports them.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5-based coding agent, tool use and local command
execution enabled. Exact hosted model build and context window were not
exposed by the runtime.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Screenshot note: this PR includes Storybook coverage for the new
interrupt handoff UI states rather than captured before/after browser
screenshots in this PR-creation heartbeat.

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dotta
2026-06-09 21:57:21 -05:00
committed by GitHub
parent 5d315ab778
commit 67b22d872f
24 changed files with 2713 additions and 265 deletions
@@ -43,6 +43,7 @@ import {
import { runningProcesses } from "../adapters/index.ts";
const mockTelemetryClient = vi.hoisted(() => ({ track: vi.fn() }));
const mockTrackAgentFirstHeartbeat = vi.hoisted(() => vi.fn());
const mockTerminateLocalService = vi.hoisted(() => vi.fn());
const mockAdapterExecute = vi.hoisted(() =>
vi.fn(async () => ({
exitCode: 0,
@@ -59,6 +60,17 @@ vi.mock("../telemetry.ts", () => ({
getTelemetryClient: () => mockTelemetryClient,
}));
vi.mock("../services/local-service-supervisor.js", async () => {
const actual = await vi.importActual<typeof import("../services/local-service-supervisor.js")>(
"../services/local-service-supervisor.js",
);
mockTerminateLocalService.mockImplementation(actual.terminateLocalService);
return {
...actual,
terminateLocalService: mockTerminateLocalService,
};
});
vi.mock("@paperclipai/shared/telemetry", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/shared/telemetry")>(
"@paperclipai/shared/telemetry",
@@ -277,6 +289,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
afterEach(async () => {
vi.clearAllMocks();
const localServiceSupervisor = await vi.importActual<typeof import("../services/local-service-supervisor.js")>(
"../services/local-service-supervisor.js",
);
mockTerminateLocalService.mockImplementation(localServiceSupervisor.terminateLocalService);
mockAdapterExecute.mockImplementation(async () => ({
exitCode: 0,
signal: null,
@@ -1893,6 +1909,35 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
);
});
it("terminates the in-memory process before persisting cancellation status", async () => {
const { runId } = await seedRunFixture({
agentStatus: "running",
includeIssue: false,
});
const heartbeat = heartbeatService(db);
runningProcesses.set(runId, {
child: { pid: 12345 } as ChildProcess,
graceSec: 1,
processGroupId: null,
});
mockTerminateLocalService.mockResolvedValueOnce(undefined);
const updateSpy = vi.spyOn(db, "update");
updateSpy.mockImplementationOnce((() => {
throw new Error("db update unavailable");
}) as typeof db.update);
try {
await expect(heartbeat.cancelRun(runId)).rejects.toThrow("db update unavailable");
expect(mockTerminateLocalService).toHaveBeenCalledWith(
expect.objectContaining({ pid: 12345, processGroupId: null }),
{ forceAfterMs: 1000 },
);
expect(runningProcesses.has(runId)).toBe(false);
} finally {
updateSpy.mockRestore();
}
});
it("records manual cancellation stop metadata", async () => {
const { runId } = await seedRunFixture({
agentStatus: "running",
@@ -1910,6 +1955,54 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
});
});
it("records operator interrupt cancellation metadata without changing terminal status", async () => {
const { runId, issueId } = await seedRunFixture({
agentStatus: "running",
includeIssue: true,
});
const heartbeat = heartbeatService(db);
const cancelled = await heartbeat.cancelRun(runId, "Interrupted by board comment", {
errorCode: "operator_interrupted",
resultJson: {
operatorInterrupted: true,
interruptionSource: "issue_comment_interrupt",
interruptedIssueId: issueId,
},
eventMessage: "run interrupted by board comment",
eventPayload: {
issueId,
source: "issue_comment_interrupt",
},
});
expect(cancelled?.status).toBe("cancelled");
expect(cancelled?.errorCode).toBe("operator_interrupted");
expect(cancelled?.error).toBe("Interrupted by board comment");
expect(cancelled?.resultJson).toMatchObject({
stopReason: "cancelled",
operatorInterrupted: true,
interruptionSource: "issue_comment_interrupt",
interruptedIssueId: issueId,
});
const events = await db
.select()
.from(heartbeatRunEvents)
.where(eq(heartbeatRunEvents.runId, runId));
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
eventType: "lifecycle",
stream: "system",
level: "warn",
message: "run interrupted by board comment",
payload: expect.objectContaining({
issueId,
source: "issue_comment_interrupt",
}),
});
});
it("dispatches assigned todo work with no prior run as a normal assignment wake", async () => {
const { companyId, agentId, issueId } = await seedAssignedTodoNoRunFixture();
const heartbeat = heartbeatService(db);
@@ -992,6 +992,114 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
expect(issue?.executionRunId).toBeNull();
});
it("does not promote a scheduled retry after the issue is handed to a human owner", async () => {
const companyId = randomUUID();
const oldAgentId = randomUUID();
const issueId = randomUUID();
const sourceRunId = randomUUID();
const now = new Date("2026-04-20T14:30:00.000Z");
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: oldAgentId,
companyId,
name: "ClaudeCoder",
role: "engineer",
status: "active",
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {
heartbeat: {
wakeOnDemand: true,
maxConcurrentRuns: 1,
},
},
permissions: {},
});
await db.insert(heartbeatRuns).values({
id: sourceRunId,
companyId,
agentId: oldAgentId,
invocationSource: "assignment",
triggerDetail: "system",
status: "failed",
error: "upstream overload",
errorCode: "adapter_failed",
finishedAt: now,
contextSnapshot: {
issueId,
wakeReason: "issue_assigned",
},
updatedAt: now,
createdAt: now,
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Retry human handoff",
status: "in_progress",
priority: "medium",
assigneeAgentId: oldAgentId,
executionRunId: sourceRunId,
executionAgentNameKey: "claudecoder",
executionLockedAt: now,
issueNumber: 1,
identifier: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}-3`,
});
const scheduled = await heartbeat.scheduleBoundedRetry(sourceRunId, {
now,
random: () => 0.5,
});
expect(scheduled.outcome).toBe("scheduled");
if (scheduled.outcome !== "scheduled") return;
await db.update(issues).set({
assigneeAgentId: null,
assigneeUserId: "local-board",
updatedAt: now,
}).where(eq(issues.id, issueId));
const promotion = await heartbeat.promoteDueScheduledRetries(scheduled.dueAt);
expect(promotion).toEqual({ promoted: 0, runIds: [] });
const oldRetry = await db
.select({
status: heartbeatRuns.status,
errorCode: heartbeatRuns.errorCode,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, scheduled.run.id))
.then((rows) => rows[0] ?? null);
expect(oldRetry).toEqual({
status: "cancelled",
errorCode: "issue_reassigned",
});
const issue = await db
.select({
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
executionRunId: issues.executionRunId,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
expect(issue).toMatchObject({
assigneeAgentId: null,
assigneeUserId: "local-board",
executionRunId: null,
});
});
it("does not promote a scheduled retry after the issue is cancelled", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
@@ -1281,7 +1281,23 @@ describe.sequential("issue comment reopen routes", () => {
expect(res.status).toBe(200);
expect(mockHeartbeatService.getRun).toHaveBeenCalledWith("run-1");
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("run-1");
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith(
"run-1",
"Interrupted by board comment",
expect.objectContaining({
errorCode: "operator_interrupted",
resultJson: expect.objectContaining({
operatorInterrupted: true,
interruptionSource: "issue_comment_interrupt",
interruptedIssueId: "11111111-1111-4111-8111-111111111111",
}),
eventMessage: "run interrupted by board comment",
eventPayload: expect.objectContaining({
issueId: "11111111-1111-4111-8111-111111111111",
source: "issue_comment_interrupt",
}),
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
@@ -1289,6 +1305,8 @@ describe.sequential("issue comment reopen routes", () => {
details: expect.objectContaining({
source: "issue_comment_interrupt",
issueId: "11111111-1111-4111-8111-111111111111",
cancellationKind: "operator_interrupted",
operatorInterrupted: true,
}),
}),
);
@@ -172,6 +172,55 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
});
});
it("previews a human-owned handoff as static work with no affected agent execution", async () => {
const companyId = randomUUID();
const rootIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: rootIssueId,
companyId,
title: "Human validation handoff",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: "local-board",
createdAt: new Date("2026-04-21T10:10:00.000Z"),
});
const svc = issueTreeControlService(db);
const preview = await svc.preview(companyId, rootIssueId, { mode: "pause" });
expect(preview.issues).toEqual([
expect.objectContaining({
id: rootIssueId,
status: "in_progress",
assigneeAgentId: null,
assigneeUserId: "local-board",
activeRun: null,
skipped: false,
skipReason: null,
}),
]);
expect(preview.totals).toMatchObject({
totalIssues: 1,
affectedIssues: 1,
skippedIssues: 0,
activeRuns: 0,
queuedRuns: 0,
affectedAgents: 0,
});
expect(preview.activeRuns).toEqual([]);
expect(preview.affectedAgents).toEqual([]);
expect(preview.warnings.map((warning) => warning.code)).not.toContain("running_runs_present");
expect(preview.warnings.map((warning) => warning.code)).not.toContain("queued_runs_present");
});
it("creates and releases normalized hold snapshots", async () => {
const companyId = randomUUID();
const rootIssueId = randomUUID();
@@ -3,6 +3,8 @@ import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const ASSIGNEE_AGENT_ID = "11111111-1111-4111-8111-111111111111";
const PREVIOUS_AGENT_ID = "22222222-2222-4222-8222-222222222222";
const MENTIONED_AGENT_ID = "33333333-3333-4333-8333-333333333333";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
@@ -269,6 +271,157 @@ describe("issue update comment wakeups", () => {
);
});
it("interrupts the active run and wakes the newly assigned agent with handoff context", async () => {
const existing = makeIssue({
assigneeAgentId: PREVIOUS_AGENT_ID,
assigneeUserId: null,
executionRunId: "run-1",
status: "in_progress",
});
const updated = makeIssue({
assigneeAgentId: ASSIGNEE_AGENT_ID,
assigneeUserId: null,
executionRunId: "run-1",
status: "in_progress",
});
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.update.mockResolvedValue(updated);
mockIssueService.addComment.mockResolvedValue({
id: "comment-interrupt-agent",
issueId: existing.id,
companyId: existing.companyId,
body: "stop and hand this to CodexCoder",
});
mockHeartbeatService.getRun.mockResolvedValue({
id: "run-1",
companyId: existing.companyId,
agentId: PREVIOUS_AGENT_ID,
status: "running",
contextSnapshot: { issueId: existing.id },
});
mockHeartbeatService.cancelRun.mockResolvedValue({
id: "run-1",
companyId: existing.companyId,
agentId: PREVIOUS_AGENT_ID,
status: "cancelled",
});
const res = await request(await createApp())
.patch(`/api/issues/${existing.id}`)
.send({
assigneeAgentId: ASSIGNEE_AGENT_ID,
assigneeUserId: null,
comment: "stop and hand this to CodexCoder",
interrupt: true,
});
expect(res.status).toBe(200);
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith(
"run-1",
"Interrupted by board comment",
expect.objectContaining({
errorCode: "operator_interrupted",
resultJson: expect.objectContaining({
operatorInterrupted: true,
interruptionSource: "issue_comment_interrupt",
interruptedIssueId: existing.id,
}),
eventMessage: "run interrupted by board comment",
eventPayload: expect.objectContaining({
issueId: existing.id,
source: "issue_comment_interrupt",
}),
}),
);
await vi.waitFor(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1));
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
source: "assignment",
reason: "issue_assigned",
payload: expect.objectContaining({
issueId: existing.id,
commentId: "comment-interrupt-agent",
interruptedRunId: "run-1",
mutation: "update",
}),
contextSnapshot: expect.objectContaining({
issueId: existing.id,
taskId: existing.id,
commentId: "comment-interrupt-agent",
wakeCommentId: "comment-interrupt-agent",
interruptedRunId: "run-1",
source: "issue.update",
}),
}),
);
});
it("interrupts the active run without waking an agent when the handoff assigns a user", async () => {
const existing = makeIssue({
assigneeAgentId: PREVIOUS_AGENT_ID,
assigneeUserId: null,
executionRunId: "run-2",
status: "in_progress",
});
const updated = makeIssue({
assigneeAgentId: null,
assigneeUserId: "local-board",
executionRunId: "run-2",
status: "in_progress",
});
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.update.mockResolvedValue(updated);
mockIssueService.addComment.mockResolvedValue({
id: "comment-interrupt-user",
issueId: existing.id,
companyId: existing.companyId,
body: "stop here, I will take it",
});
mockHeartbeatService.getRun.mockResolvedValue({
id: "run-2",
companyId: existing.companyId,
agentId: PREVIOUS_AGENT_ID,
status: "running",
contextSnapshot: { issueId: existing.id },
});
mockHeartbeatService.cancelRun.mockResolvedValue({
id: "run-2",
companyId: existing.companyId,
agentId: PREVIOUS_AGENT_ID,
status: "cancelled",
});
const res = await request(await createApp())
.patch(`/api/issues/${existing.id}`)
.send({
assigneeAgentId: null,
assigneeUserId: "local-board",
comment: "stop here, I will take it",
interrupt: true,
});
expect(res.status).toBe(200);
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith(
"run-2",
"Interrupted by board comment",
expect.objectContaining({
errorCode: "operator_interrupted",
resultJson: expect.objectContaining({
operatorInterrupted: true,
interruptionSource: "issue_comment_interrupt",
interruptedIssueId: existing.id,
}),
eventMessage: "run interrupted by board comment",
}),
);
await vi.waitFor(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalledWith(
existing.companyId,
"stop here, I will take it",
));
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("wakes the assignee on comment-only issue updates", async () => {
const existing = makeIssue({
assigneeAgentId: ASSIGNEE_AGENT_ID,
@@ -358,4 +511,78 @@ describe("issue update comment wakeups", () => {
}),
);
});
it("does not route a plain-text agent name on a human-owned issue comment", async () => {
const existing = makeIssue({
assigneeAgentId: null,
assigneeUserId: "local-board",
status: "in_progress",
});
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.addComment.mockResolvedValue({
id: "comment-plain-agent-name",
issueId: existing.id,
companyId: existing.companyId,
body: "QA please take the screenshot",
});
mockIssueService.findMentionedAgents.mockResolvedValue([]);
const res = await request(await createApp())
.post(`/api/issues/${existing.id}/comments`)
.send({
body: "QA please take the screenshot",
});
expect(res.status).toBe(201);
await vi.waitFor(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalledWith(
existing.companyId,
"QA please take the screenshot",
));
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("routes a structured mentioned agent without making that agent the issue owner", async () => {
const existing = makeIssue({
assigneeAgentId: null,
assigneeUserId: "local-board",
status: "in_progress",
});
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.addComment.mockResolvedValue({
id: "comment-structured-mention",
issueId: existing.id,
companyId: existing.companyId,
body: "[@QA](/agents/33333333-3333-4333-8333-333333333333) please inspect this",
});
mockIssueService.findMentionedAgents.mockResolvedValue([MENTIONED_AGENT_ID]);
const res = await request(await createApp())
.post(`/api/issues/${existing.id}/comments`)
.send({
body: "[@QA](/agents/33333333-3333-4333-8333-333333333333) please inspect this",
});
expect(res.status).toBe(201);
await vi.waitFor(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1));
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
MENTIONED_AGENT_ID,
expect.objectContaining({
source: "automation",
reason: "issue_comment_mentioned",
payload: {
issueId: existing.id,
commentId: "comment-structured-mention",
},
contextSnapshot: expect.objectContaining({
issueId: existing.id,
taskId: existing.id,
commentId: "comment-structured-mention",
wakeCommentId: "comment-structured-mention",
wakeReason: "issue_comment_mentioned",
source: "comment.mention",
}),
}),
);
});
});
+35 -1
View File
@@ -36,7 +36,7 @@ import {
ISSUE_LIST_MAX_LIMIT,
issueService,
} from "../services/issues.ts";
import { buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH } from "@paperclipai/shared";
import { buildAgentMentionHref, buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH } from "@paperclipai/shared";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -345,6 +345,40 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
});
});
it("resolves only structured same-company agent mentions", async () => {
const companyId = await seedAssignableAgentCompany();
const otherCompanyId = await seedAssignableAgentCompany();
const localAgentId = randomUUID();
const foreignAgentId = randomUUID();
await db.insert(agents).values([
agentRow(companyId, { id: localAgentId, name: "LocalAgent" }),
agentRow(otherCompanyId, { id: foreignAgentId, name: "ForeignAgent" }),
]);
const mentions = await svc.findMentionedAgents(
companyId,
[
`hello [@LocalAgent](${buildAgentMentionHref(localAgentId)})`,
`and [@ForeignAgent](${buildAgentMentionHref(foreignAgentId)})`,
].join(" "),
);
expect(mentions).toEqual([localAgentId]);
});
it("does not wake agents from raw @name text without a structured mention", async () => {
const companyId = await seedAssignableAgentCompany();
const localAgentId = randomUUID();
await db.insert(agents).values([
agentRow(companyId, { id: localAgentId, name: "LocalAgent" }),
]);
await expect(svc.findMentionedAgents(companyId, "@LocalAgent please inspect this"))
.resolves.toEqual([]);
});
it("returns issues an agent participated in across the supported signals", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
@@ -1,41 +0,0 @@
import { describe, expect, it } from "vitest";
import { normalizeAgentMentionToken } from "../services/issues.ts";
describe("normalizeAgentMentionToken", () => {
it("decodes hex numeric entities such as space (&#x20;)", () => {
expect(normalizeAgentMentionToken("Baba&#x20;")).toBe("Baba");
});
it("decodes decimal numeric entities", () => {
expect(normalizeAgentMentionToken("Baba&#32;")).toBe("Baba");
});
it("decodes common named whitespace entities", () => {
expect(normalizeAgentMentionToken("Baba&nbsp;")).toBe("Baba");
});
// Mid-token entity (review asked for this shape); we decode &amp;→&, not strip to "Baba" (that broke M&amp;M).
it("decodes a named entity in the middle of the token", () => {
expect(normalizeAgentMentionToken("Ba&amp;ba")).toBe("Ba&ba");
});
it("decodes &amp; so agent names with ampersands still match", () => {
expect(normalizeAgentMentionToken("M&amp;M")).toBe("M&M");
});
it("decodes additional named entities used in rich text (e.g. &copy;)", () => {
expect(normalizeAgentMentionToken("Agent&copy;Name")).toBe("Agent©Name");
});
it("leaves unknown semicolon-terminated named references unchanged", () => {
expect(normalizeAgentMentionToken("Baba&notarealentity;")).toBe("Baba&notarealentity;");
});
it("returns plain names unchanged", () => {
expect(normalizeAgentMentionToken("Baba")).toBe("Baba");
});
it("trims after decoding entities", () => {
expect(normalizeAgentMentionToken("Baba&#x20;&#x20;")).toBe("Baba");
});
});
+44 -4
View File
@@ -2106,6 +2106,26 @@ export function issueRoutes(
return runToInterrupt?.status === "running" ? runToInterrupt : null;
}
function operatorInterruptCancelOptions(input: { issueId: string; actor: ReturnType<typeof getActorInfo> }) {
return {
errorCode: "operator_interrupted",
resultJson: {
operatorInterrupted: true,
interruptionSource: "issue_comment_interrupt",
interruptedIssueId: input.issueId,
interruptedByActorType: input.actor.actorType,
interruptedByActorId: input.actor.actorId,
},
eventMessage: "run interrupted by board comment",
eventPayload: {
issueId: input.issueId,
source: "issue_comment_interrupt",
interruptedByActorType: input.actor.actorType,
interruptedByActorId: input.actor.actorId,
},
};
}
async function normalizeIssueAssigneeAgentReference(
companyId: string,
rawAssigneeAgentId: string | null | undefined,
@@ -4761,7 +4781,11 @@ export function issueRoutes(
const runToInterrupt = await resolveActiveIssueRun(existing);
if (runToInterrupt) {
const cancelled = await heartbeat.cancelRun(runToInterrupt.id);
const cancelled = await heartbeat.cancelRun(
runToInterrupt.id,
"Interrupted by board comment",
operatorInterruptCancelOptions({ issueId: existing.id, actor }),
);
if (cancelled) {
interruptedRunId = cancelled.id;
await logActivity(db, {
@@ -4773,7 +4797,13 @@ export function issueRoutes(
action: "heartbeat.cancelled",
entityType: "heartbeat_run",
entityId: cancelled.id,
details: { agentId: cancelled.agentId, source: "issue_comment_interrupt", issueId: existing.id },
details: {
agentId: cancelled.agentId,
source: "issue_comment_interrupt",
issueId: existing.id,
cancellationKind: "operator_interrupted",
operatorInterrupted: true,
},
});
}
}
@@ -6554,7 +6584,11 @@ export function issueRoutes(
const runToInterrupt = await resolveActiveIssueRun(currentIssue);
if (runToInterrupt) {
const cancelled = await heartbeat.cancelRun(runToInterrupt.id);
const cancelled = await heartbeat.cancelRun(
runToInterrupt.id,
"Interrupted by board comment",
operatorInterruptCancelOptions({ issueId: currentIssue.id, actor }),
);
if (cancelled) {
interruptedRunId = cancelled.id;
await logActivity(db, {
@@ -6566,7 +6600,13 @@ export function issueRoutes(
action: "heartbeat.cancelled",
entityType: "heartbeat_run",
entityId: cancelled.id,
details: { agentId: cancelled.agentId, source: "issue_comment_interrupt", issueId: currentIssue.id },
details: {
agentId: cancelled.agentId,
source: "issue_comment_interrupt",
issueId: currentIssue.id,
cancellationKind: "operator_interrupted",
operatorInterrupted: true,
},
});
}
}
+1
View File
@@ -387,6 +387,7 @@ export function activityService(db: Db) {
finishedAt: heartbeatRuns.finishedAt,
createdAt: heartbeatRuns.createdAt,
invocationSource: heartbeatRuns.invocationSource,
errorCode: heartbeatRuns.errorCode,
usageJson: summarizedUsageJson,
resultJson: summarizedResultJson,
logBytes: heartbeatRuns.logBytes,
+42 -25
View File
@@ -10649,41 +10649,58 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
return wakeupIds.length;
}
async function cancelRunInternal(runId: string, reason = "Cancelled by control plane") {
type CancelRunOptions = {
errorCode?: string;
resultJson?: Record<string, unknown>;
eventMessage?: string;
eventPayload?: Record<string, unknown>;
};
async function cancelRunInternal(runId: string, reason = "Cancelled by control plane", options: CancelRunOptions = {}) {
const run = await getRun(runId);
if (!run) throw notFound("Heartbeat run not found");
if (!CANCELLABLE_HEARTBEAT_RUN_STATUSES.includes(run.status as (typeof CANCELLABLE_HEARTBEAT_RUN_STATUSES)[number])) return run;
const agent = await getAgent(run.agentId);
const errorCode = options.errorCode ?? "cancelled";
const resultJson = agent
? {
...mergeRunStopMetadataForAgent(agent, "cancelled", {
resultJson: parseObject(run.resultJson),
errorCode,
errorMessage: reason,
}),
...(options.resultJson ?? {}),
}
: options.resultJson;
const running = runningProcesses.get(run.id);
if (running) {
await terminateHeartbeatRunProcess({
pid: running.child.pid ?? run.processPid,
processGroupId: running.processGroupId ?? run.processGroupId,
graceMs: Math.max(1, running.graceSec) * 1000,
});
} else if (run.processPid || run.processGroupId) {
await terminateHeartbeatRunProcess({
pid: run.processPid,
processGroupId: run.processGroupId,
});
try {
if (running) {
await terminateHeartbeatRunProcess({
pid: running.child.pid ?? run.processPid,
processGroupId: running.processGroupId ?? run.processGroupId,
graceMs: Math.max(1, running.graceSec) * 1000,
});
} else if (run.processPid || run.processGroupId) {
await terminateHeartbeatRunProcess({
pid: run.processPid,
processGroupId: run.processGroupId,
});
}
} finally {
runningProcesses.delete(run.id);
}
const finishedAt = new Date();
const cancelled = await setRunStatus(run.id, "cancelled", {
finishedAt: new Date(),
finishedAt,
error: reason,
errorCode: "cancelled",
...(agent ? {
resultJson: mergeRunStopMetadataForAgent(agent, "cancelled", {
resultJson: parseObject(run.resultJson),
errorCode: "cancelled",
errorMessage: reason,
}),
} : {}),
errorCode,
...(resultJson ? { resultJson } : {}),
});
await setWakeupStatus(run.wakeupRequestId, "cancelled", {
finishedAt: new Date(),
finishedAt,
error: reason,
});
@@ -10692,12 +10709,12 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
eventType: "lifecycle",
stream: "system",
level: "warn",
message: "run cancelled",
message: options.eventMessage ?? "run cancelled",
...(options.eventPayload ? { payload: options.eventPayload } : {}),
});
await releaseIssueExecutionAndPromote(cancelled);
}
runningProcesses.delete(run.id);
await finalizeAgentStatus(run.agentId, "cancelled");
await startNextQueuedRunForAgent(run.agentId);
return cancelled;
@@ -11137,7 +11154,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
};
},
cancelRun: (runId: string, reason?: string) => cancelRunInternal(runId, reason),
cancelRun: (runId: string, reason?: string, options?: CancelRunOptions) => cancelRunInternal(runId, reason, options),
cancelActiveForAgent: (agentId: string, reason?: string) => cancelActiveForAgentInternal(agentId, reason),
+5 -52
View File
@@ -1006,41 +1006,6 @@ function shouldIncludePluginOperationIssues(filters: IssueFilters | undefined) {
);
}
/** Named entities commonly emitted in saved issue bodies; unknown `&name;` sequences are left unchanged. */
const WELL_KNOWN_NAMED_HTML_ENTITIES: Readonly<Record<string, string>> = {
amp: "&",
apos: "'",
copy: "\u00A9",
gt: ">",
lt: "<",
nbsp: "\u00A0",
quot: '"',
ensp: "\u2002",
emsp: "\u2003",
thinsp: "\u2009",
};
function decodeNumericHtmlEntity(digits: string, radix: 16 | 10): string | null {
const n = Number.parseInt(digits, radix);
if (Number.isNaN(n) || n < 0 || n > 0x10ffff) return null;
try {
return String.fromCodePoint(n);
} catch {
return null;
}
}
/** Decodes HTML character references in a raw @mention capture so UI-encoded bodies match agent names. */
export function normalizeAgentMentionToken(raw: string): string {
let s = raw.replace(/&#x([0-9a-fA-F]+);/gi, (full, hex: string) => decodeNumericHtmlEntity(hex, 16) ?? full);
s = s.replace(/&#([0-9]+);/g, (full, dec: string) => decodeNumericHtmlEntity(dec, 10) ?? full);
s = s.replace(/&([a-z][a-z0-9]*);/gi, (full, name: string) => {
const decoded = WELL_KNOWN_NAMED_HTML_ENTITIES[name.toLowerCase()];
return decoded !== undefined ? decoded : full;
});
return s.trim();
}
export function deriveIssueUserContext(
issue: IssueUserContextInput,
userId: string,
@@ -6063,25 +6028,13 @@ export function issueService(db: Db) {
}),
findMentionedAgents: async (companyId: string, body: string) => {
const re = /\B@([^\s@,!?.]+)/g;
const tokens = new Set<string>();
let m: RegExpExecArray | null;
while ((m = re.exec(body)) !== null) {
const normalized = normalizeAgentMentionToken(m[1]);
if (normalized) tokens.add(normalized.toLowerCase());
}
const explicitAgentMentionIds = extractAgentMentionIds(body);
if (tokens.size === 0 && explicitAgentMentionIds.length === 0) return [];
const rows = await db.select({ id: agents.id, name: agents.name })
if (explicitAgentMentionIds.length === 0) return [];
const rows = await db.select({ id: agents.id })
.from(agents).where(eq(agents.companyId, companyId));
const resolved = new Set<string>(explicitAgentMentionIds);
for (const agent of rows) {
if (tokens.has(agent.name.toLowerCase())) {
resolved.add(agent.id);
}
}
return [...resolved];
const companyAgentIds = new Set(rows.map((agent) => agent.id));
return explicitAgentMentionIds.filter((agentId) => companyAgentIds.has(agentId));
},
findMentionedProjectIds: async (