mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
## Thinking Path > - Paperclip is the open source control plane people use to coordinate AI-agent companies. > - Issue status transitions determine whether work keeps moving or silently stalls. > - A blocked issue previously could rely on prose alone, leaving the intended unblock owner unstructured and unnotified. > - Existing blocker-attention classification could identify stalled chains, but the signal was not delivered to the board attention feed. > - Blocked transitions also need rollout-safe deduplication so upgrades do not notify for historical issues and repeated processing does not create notification storms. > - This pull request adds structured unblock descriptors, prospective transition timestamps, owner delivery, and board attention routing with focused authorization controls. > - The benefit is that newly blocked work has an explicit, routable next action without weakening company boundaries or allowing agents to inject arbitrary human attention items. ## Linked Issues or Issue Description Related documentation PR: #10094. ### Subsystem affected Cross-cutting: `server/`, `packages/db`, and `packages/shared`. ### Problem or motivation An issue can enter `blocked` without a machine-readable unblock path. Prose-only ownership does not reliably wake the responsible agent or surface human-owned work, while the existing `blockerAttention` classifier is not delivered to an operator-facing attention feed. ### Proposed solution Require new transitions into `blocked` to have unresolved blockers, a pending interaction/approval, or a structured `{ owner, action }` descriptor. Notify an allowed owner once per prospective transition, route human-owned cases to board attention, and leave pre-rollout blocked issues untouched. ### Alternatives considered - Keep prose-only blockers: rejected because ownership remains unroutable. - Backfill all historical blocked issues: rejected because upgrades would create notification storms. - Let agents target arbitrary users or the board: rejected after security review because it creates an attention-injection channel. ### Roadmap alignment Aligns with `ROADMAP.md` → “Enforced Outcomes (watchdogs, recovery actions, review gates)” by making blocked work carry an explicit continuation path. ### Additional context The implementation is prospective-only and deduplicated per blocked transition. Agent-authored descriptors are limited to the acting agent; board actors retain human-owner routing. ## What Changed - Added persisted unblock descriptors and prospective blocked-transition delivery timestamps with an idempotent migration. - Added shared types and validation for board, user, and agent unblock owners. - Enforced valid blocked transitions and same-company owner validation in the issue update route. - Restricted agent-authored descriptors to the acting agent itself, preventing board/user attention injection by compromised agents. - Added one-per-transition agent wake delivery and prospective-only rollout gating. - Routed human-owned blocker attention into the board attention feed. - Added focused tests for validation, prospective delivery, flap deduplication, attention routing, route authorization, and stop-relay compatibility. ## Verification - `pnpm -r typecheck` - `pnpm exec vitest run server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts server/src/__tests__/routable-blocked.test.ts server/src/__tests__/attention-service.test.ts packages/shared/src/validators/issue.test.ts` - `AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= pnpm test:run` - `pnpm build` - `pnpm --filter @paperclipai/db check:migrations` ## Risks - Behavioral shift: new `blocked` transitions without a real blocker, pending governed action, or structured descriptor now return `422`. - Notification abuse is constrained by same-company validation, agent self-only routing, prospective rollout gating, and transition-scoped deduplication. - Migration risk is low: columns are additive, nullable, and use `IF NOT EXISTS`; historical blocked issues are not backfilled or notified. > 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 CLI with GPT-5.4, reasoning-enabled tool use and code execution. The runtime did not expose a context-window value. ## 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 not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
77 lines
2.8 KiB
TypeScript
77 lines
2.8 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
deliverAgentUnblockNotification,
|
|
ROUTABLE_BLOCKED_ROLLOUT_AT,
|
|
} from "../services/routable-blocked.js";
|
|
|
|
const agentId = "00000000-0000-4000-8000-000000000001";
|
|
|
|
function blockedIssue(input: {
|
|
transitionAt?: Date | null;
|
|
notifiedAt?: Date | null;
|
|
} = {}) {
|
|
return {
|
|
id: "00000000-0000-4000-8000-000000000002",
|
|
status: "blocked",
|
|
unblockDescriptor: { owner: { agentId }, action: "Review the finding" } as const,
|
|
blockedTransitionAt: input.transitionAt === undefined
|
|
? new Date(ROUTABLE_BLOCKED_ROLLOUT_AT.getTime() + 1)
|
|
: input.transitionAt,
|
|
blockedOwnerNotifiedAt: input.notifiedAt ?? null,
|
|
};
|
|
}
|
|
|
|
describe("routable blocked notifications", () => {
|
|
it("wakes the named agent and records delivery on a prospective transition", async () => {
|
|
const wakeup = vi.fn(async () => undefined);
|
|
const markNotified = vi.fn(async () => undefined);
|
|
const now = new Date("2026-07-23T18:30:00.000Z");
|
|
const issue = blockedIssue();
|
|
|
|
await expect(deliverAgentUnblockNotification({ issue, wakeup, markNotified, now: () => now }))
|
|
.resolves.toBe(true);
|
|
expect(wakeup).toHaveBeenCalledWith(agentId, expect.objectContaining({
|
|
reason: "issue_unblock_requested",
|
|
idempotencyKey: `issue-unblock:${issue.id}:${issue.blockedTransitionAt!.toISOString()}`,
|
|
payload: { issueId: issue.id, action: "Review the finding" },
|
|
}));
|
|
expect(markNotified).toHaveBeenCalledWith(now);
|
|
});
|
|
|
|
it("leaves pre-existing blocked issues untouched", async () => {
|
|
const wakeup = vi.fn(async () => undefined);
|
|
const markNotified = vi.fn(async () => undefined);
|
|
|
|
await expect(deliverAgentUnblockNotification({
|
|
issue: blockedIssue({ transitionAt: new Date(ROUTABLE_BLOCKED_ROLLOUT_AT.getTime() - 1) }),
|
|
wakeup,
|
|
markNotified,
|
|
})).resolves.toBe(false);
|
|
expect(wakeup).not.toHaveBeenCalled();
|
|
expect(markNotified).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("deduplicates one transition and notifies again after a blocked flap", async () => {
|
|
const wakeup = vi.fn(async () => undefined);
|
|
const markNotified = vi.fn(async () => undefined);
|
|
const firstTransition = new Date(ROUTABLE_BLOCKED_ROLLOUT_AT.getTime() + 1);
|
|
const secondTransition = new Date(ROUTABLE_BLOCKED_ROLLOUT_AT.getTime() + 2);
|
|
|
|
await deliverAgentUnblockNotification({
|
|
issue: blockedIssue({ transitionAt: firstTransition, notifiedAt: new Date() }),
|
|
wakeup,
|
|
markNotified,
|
|
});
|
|
await deliverAgentUnblockNotification({
|
|
issue: blockedIssue({ transitionAt: secondTransition }),
|
|
wakeup,
|
|
markNotified,
|
|
});
|
|
|
|
expect(wakeup).toHaveBeenCalledTimes(1);
|
|
expect(wakeup.mock.calls[0]?.[1]).toMatchObject({
|
|
idempotencyKey: expect.stringContaining(secondTransition.toISOString()),
|
|
});
|
|
});
|
|
});
|