mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Codify interrupt handoff contract
This commit is contained in:
1 parent
0a2230b2ec
commit
99607ff5c0
4 files changed
+376
No files matched your search
@@ -254,6 +254,26 @@ Document-scoped activity may still route work when it is converted into an expli
|
||||
|
||||
Freeform document approval text is not auto-acceptance. Plan approval, implementation approval, or review acceptance must flow through the explicit interaction, approval, execution-policy, assignment, or blocker primitives that define who owns the next move.
|
||||
|
||||
### Comment interrupts and ownership handoffs
|
||||
|
||||
A board comment can be an interrupt, an ownership change, both, or neither. Paperclip must keep those concepts separate in the product contract.
|
||||
|
||||
An interrupt stops the current live execution path for the issue. It does not, by itself, select the next owner. If an active run is interrupted by the board, the run may still terminate with the underlying `cancelled` status, but the issue activity and wake context should make the operator intent visible as an interruption rather than an unexplained runtime failure.
|
||||
|
||||
An ownership change selects who owns the issue after the comment is committed:
|
||||
|
||||
- setting `assigneeAgentId` makes the named agent the owner
|
||||
- setting `assigneeUserId`, or clearing `assigneeAgentId`, makes the issue human-owned or unassigned
|
||||
- leaving assignee fields unchanged preserves the current owner
|
||||
|
||||
A wake is the delivery path for a selected agent owner. If an interrupting update also assigns a non-terminal, non-backlog issue to an agent, Paperclip should enqueue one wake for the new assignee and include the interrupting comment and interrupted run id in the wake payload/context when available. Stale scheduled retries for the previous owner must not run after ownership changes away from that owner.
|
||||
|
||||
If the committed update assigns the issue to a user, clears the agent assignee, or leaves the issue without an agent owner, Paperclip must not imply that an agent handoff happened. The issue is then waiting on the human owner or on a future explicit assignment, blocker, approval, interaction, monitor, or recovery action.
|
||||
|
||||
Plain text is not assignment. Writing an agent's name, role, or team label in a comment does not change ownership and does not create an agent wake. Agent routing from comment text requires a structured agent mention that resolves inside the company, an explicit `assigneeAgentId` mutation, or an existing current agent assignee receiving normal issue-thread feedback.
|
||||
|
||||
Pause and tree-control previews should make the same distinction visible. They should report whether the affected subtree contains live running work, queued wakes, agent-owned work, or only human-owned/static issues, so a pause after a handoff does not look like it interrupted agent execution when no agent execution path existed.
|
||||
|
||||
### Adapter-backed workspace coherence
|
||||
|
||||
For adapter-backed execution, an active run or queued wake counts as a live path only when Paperclip can also prove that the selected workspace is coherent for that adapter invocation. A wake that cannot start in the intended workspace is only a failed delivery attempt, not a healthy liveness path.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,129 @@ 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");
|
||||
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");
|
||||
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 +483,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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user