feat(issues): contain cross-issue agent side effects (#10837)

## Thinking Path

> - Paperclip is the control plane that coordinates autonomous agent
work.
> - Agents need to collaborate on issues beyond their current
assignment.
> - Cross-issue comments and updates are useful, but an unbounded run
can create cascading side effects.
> - The control plane must preserve company-wide collaboration while
containing each run's influence.
> - Comment attribution must also show the responsible user and the
acting agent in audits.
> - This pull request adds run-bound cross-issue containment,
attribution, and agent-class wake rules.
> - The benefit is safer collaboration without restoring issue-assignee
ownership restrictions.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Agent-authenticated issue comments, updates, reopen behavior, and
assignee wake routing.

**Subsystem affected**

Cross-cutting: server routes and services, shared contracts, database
schema and migration, and implementation documentation.

**Current behavior**

An authenticated agent can collaborate across company issues, but one
heartbeat run has no per-run side-effect boundary. Comment records also
do not persist the responsible user separately from the acting agent.

**Proposed behavior**

Require a valid heartbeat run for agent cross-issue comments and
updates. Audit each attempt and cap a run at 20 cross-issue effects.
Keep the cap in log-only mode until it automatically changes to
enforcement at 2026-08-11 00:00 UTC. Preserve same-issue writes. Use
agent-class wakes for agent comments. Keep same-run completion comments
from reopening completed work. Record the responsible user on
agent-authored comments and activity.

**Reason and benefit**

Agents can collaborate on other issues without an assignment gate, while
each run has an atomic and inspectable side-effect limit. Operators can
identify both the acting agent and the responsible user.

**Breaking changes**

After 2026-08-11 00:00 UTC, the twenty-first cross-issue comment or
update from one heartbeat run returns a containment error. Agent
cross-issue writes without valid run context are rejected. The migration
is additive and backfills existing agent-authored comment attribution
where the source data is available.

## What Changed

- Added an atomic per-run counter for cross-issue agent comments and
updates.
- Added audit events for allowed and rejected cross-issue effects.
- Added the automatic log-only to enforcement flip at 2026-08-11 00:00
UTC.
- Added responsible-user attribution to agent-authored comments,
activity records, shared types, and validators.
- Added an additive migration and migration coverage for existing
comments.
- Updated reopen, resume, and wake behavior so agent comments create
agent-class wakes and same-run completion comments remain inert.
- Updated the implementation specification and regression coverage.

## Verification

- `pnpm exec vitest run
server/src/__tests__/cross-issue-influence-limit.test.ts
server/src/__tests__/issue-comment-attribution-audit-routes.test.ts
server/src/__tests__/issue-comment-reopen-routes.test.ts
packages/db/src/issue-comment-on-behalf-migration.test.ts` — 97 tests
passed.
- `pnpm -r typecheck` — passed, including migration safety checks.
- `pnpm test:run` — server batch: 3,364 passed and 2 skipped; UI batch:
3,504 passed. One unrelated CLI doctor test warned because this agent
runtime injects static AWS credentials.
- `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm exec vitest
run cli/src/__tests__/secrets.test.ts` — 8 tests passed and confirmed
the CLI failure was ambient-environment sensitive.
- `pnpm build` — passed.

## Risks

- The fixed enforcement timestamp changes production behavior
automatically on 2026-08-11 00:00 UTC. Audit logs before that time
provide rollout visibility.
- The per-run counter serializes on the heartbeat-run row. This prevents
concurrent attempts from racing past the cap but adds a small lock scope
for cross-issue writes.
- Existing comments can only be backfilled when their acting run or
agent attribution is recoverable.

> 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 GPT-5 in the Codex agent runtime. The runtime did not expose a
context-window size. Reasoning, shell tools, code editing, and test
execution were enabled.

## 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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-08-04 13:17:49 -05:00
1 parent ded813ad6f
commit f91a6e27c0
34 files changed
+40023 -139

No files matched your search

+19
View File
@@ -573,6 +573,25 @@ budget gates, and pause gates remain independently enforced. Comment access is
structurally downstream of issue read access (`issue:comment` is a subset of
`issue:read`).
Cross-issue writes are contained per heartbeat run. An agent-authored comment
may wake the target assignee, including an explicit `resume: true` comment on a
`done` or `cancelled` issue, but the wake remains agent-class and is subject to
the normal agent rewake throttle; comment presentation cannot give it human
wake privileges. Agent issue comments and updates require a persisted heartbeat
run bound to the authenticated agent and company; missing, invalid, or mismatched
run context fails closed before mutation. A run may attempt at most 20 cross-issue comments or issue
updates across the shared counter. The server records each attempt with its
source issue, target issue, run, count, and rollout mode, and fails closed with
the cap in the error once enforcement is active. Assignee self-comments do not
wake the assignee, and a non-assignee comment cannot mint a mention grant.
Agent-authored issue comments persist the responsible user derived from the
authenticated actor; clients cannot choose that attribution. Each comment also
records the write-policy reason, and spoof attempts fail with an audited 422.
Every issue PATCH emits an `issue.updated` activity receipt containing the
actor, responsible user, run, authorization reason, and field-level before/after
changes so both agent and board edits are visible in the issue activity stream.
## 9.4 Permission Terminology and Default Visibility Rule
Paperclip V1 keeps a company-scoped visibility model as the default because centralized authorization and scoped work-object controls are not yet a core V1 control surface.
@@ -0,0 +1,156 @@
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import postgres from "postgres";
import { applyPendingMigrations, inspectMigrations } from "./client.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./test-embedded-postgres.js";
const MIGRATION_FILE = "0205_narrow_shiva.sql";
const cleanups: Array<() => Promise<void>> = [];
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
async function migrationHash() {
const content = await fs.promises.readFile(new URL(`./migrations/${MIGRATION_FILE}`, import.meta.url), "utf8");
return createHash("sha256").update(content).digest("hex");
}
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()?.();
});
describeEmbeddedPostgres("issue comment on-behalf attribution migration", () => {
it("reapplies the migration and round-trips the nullable user attribution FK", async () => {
const database = await startEmbeddedPostgresTestDatabase("paperclip-comment-on-behalf-");
cleanups.push(database.cleanup);
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
const historicalCompanyId = randomUUID();
const historicalAgentId = randomUUID();
const historicalIssueId = randomUUID();
const historicalRunId = randomUUID();
const historicalCommentId = randomUUID();
const historicalUserId = `historical-comment-user-${randomUUID()}`;
try {
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`;
await sql`ALTER TABLE "issue_comments" DROP CONSTRAINT IF EXISTS "issue_comments_on_behalf_of_user_id_user_id_fk"`;
await sql`ALTER TABLE "issue_comments" DROP COLUMN IF EXISTS "on_behalf_of_user_id"`;
await sql`
INSERT INTO "companies" ("id", "name", "issue_prefix")
VALUES (${historicalCompanyId}, 'Historical attribution company', 'HAC')
`;
await sql`
INSERT INTO "agents" ("id", "company_id", "name", "role", "adapter_type", "adapter_config")
VALUES (${historicalAgentId}, ${historicalCompanyId}, 'Historical comment agent', 'engineer', 'process', '{}'::jsonb)
`;
await sql`
INSERT INTO "issues" ("id", "company_id", "title", "identifier")
VALUES (${historicalIssueId}, ${historicalCompanyId}, 'Historical comment issue', 'HAC-1')
`;
await sql`
INSERT INTO "user" ("id", "name", "email", "email_verified", "created_at", "updated_at")
VALUES (${historicalUserId}, 'Historical Comment User', 'historical-comment-user@example.test', true, now(), now())
`;
await sql`
INSERT INTO "heartbeat_runs" ("id", "company_id", "agent_id", "status", "responsible_user_id")
VALUES (${historicalRunId}, ${historicalCompanyId}, ${historicalAgentId}, 'succeeded', ${historicalUserId})
`;
await sql`
INSERT INTO "issue_comments" (
"id", "company_id", "issue_id", "author_agent_id", "created_by_run_id", "body"
) VALUES (
${historicalCommentId}, ${historicalCompanyId}, ${historicalIssueId}, ${historicalAgentId},
${historicalRunId}, 'Historical attributed comment'
)
`;
} finally {
await sql.end();
}
expect(await inspectMigrations(database.connectionString)).toMatchObject({
status: "needsMigrations",
pendingMigrations: [MIGRATION_FILE],
});
await applyPendingMigrations(database.connectionString);
const verify = postgres(database.connectionString, { max: 1, onnotice: () => {} });
try {
const columns = await verify<{ column_name: string; data_type: string; is_nullable: string }[]>`
SELECT "column_name", "data_type", "is_nullable"
FROM "information_schema"."columns"
WHERE "table_schema" = 'public'
AND "table_name" = 'issue_comments'
AND "column_name" = 'on_behalf_of_user_id'
`;
expect(columns).toEqual([{
column_name: "on_behalf_of_user_id",
data_type: "text",
is_nullable: "YES",
}]);
const constraints = await verify<{ constraint_name: string; delete_rule: string }[]>`
SELECT tc."constraint_name", rc."delete_rule"
FROM "information_schema"."table_constraints" tc
JOIN "information_schema"."referential_constraints" rc
ON rc."constraint_schema" = tc."constraint_schema"
AND rc."constraint_name" = tc."constraint_name"
WHERE tc."table_schema" = 'public'
AND tc."table_name" = 'issue_comments'
AND tc."constraint_name" = 'issue_comments_on_behalf_of_user_id_user_id_fk'
`;
expect(constraints).toEqual([{
constraint_name: "issue_comments_on_behalf_of_user_id_user_id_fk",
delete_rule: "SET NULL",
}]);
const historical = await verify<{ on_behalf_of_user_id: string | null }[]>`
SELECT "on_behalf_of_user_id"
FROM "issue_comments"
WHERE "id" = ${historicalCommentId}
`;
expect(historical).toEqual([{ on_behalf_of_user_id: historicalUserId }]);
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const commentId = randomUUID();
const userId = `comment-user-${randomUUID()}`;
await verify`
INSERT INTO "companies" ("id", "name", "issue_prefix")
VALUES (${companyId}, 'Comment attribution company', 'CAC')
`;
await verify`
INSERT INTO "agents" ("id", "company_id", "name", "role", "adapter_type", "adapter_config")
VALUES (${agentId}, ${companyId}, 'Comment agent', 'engineer', 'process', '{}'::jsonb)
`;
await verify`
INSERT INTO "issues" ("id", "company_id", "title", "identifier")
VALUES (${issueId}, ${companyId}, 'Comment issue', 'CAC-1')
`;
await verify`
INSERT INTO "user" ("id", "name", "email", "email_verified", "created_at", "updated_at")
VALUES (${userId}, 'Comment User', 'comment-user@example.test', true, now(), now())
`;
await verify`
INSERT INTO "issue_comments" (
"id", "company_id", "issue_id", "author_agent_id", "on_behalf_of_user_id", "body"
) VALUES (${commentId}, ${companyId}, ${issueId}, ${agentId}, ${userId}, 'Attributed comment')
`;
const inserted = await verify<{ on_behalf_of_user_id: string | null }[]>`
SELECT "on_behalf_of_user_id" FROM "issue_comments" WHERE "id" = ${commentId}
`;
expect(inserted).toEqual([{ on_behalf_of_user_id: userId }]);
await verify`DELETE FROM "user" WHERE "id" = ${userId}`;
const afterDelete = await verify<{ on_behalf_of_user_id: string | null }[]>`
SELECT "on_behalf_of_user_id" FROM "issue_comments" WHERE "id" = ${commentId}
`;
expect(afterDelete).toEqual([{ on_behalf_of_user_id: null }]);
} finally {
await verify.end();
}
}, 30_000);
});
@@ -0,0 +1,10 @@
ALTER TABLE "issue_comments" ADD COLUMN "on_behalf_of_user_id" text;--> statement-breakpoint
UPDATE "issue_comments" c
SET "on_behalf_of_user_id" = hr."responsible_user_id"
FROM "heartbeat_runs" hr
JOIN "user" u ON u."id" = hr."responsible_user_id"
WHERE c."created_by_run_id" = hr."id"
AND c."company_id" = hr."company_id"
AND c."author_agent_id" = hr."agent_id"
AND c."on_behalf_of_user_id" IS NULL;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_on_behalf_of_user_id_user_id_fk" FOREIGN KEY ("on_behalf_of_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large. Load diff
@@ -1422,6 +1422,13 @@
"when": 1785702264749,
"tag": "0204_interaction_addressee",
"breakpoints": true
},
{
"idx": 205,
"version": "7",
"when": 1785853648731,
"tag": "0205_narrow_shiva",
"breakpoints": true
}
]
}
+2
View File
@@ -10,6 +10,7 @@ import { companies } from "./companies.js";
import { issues } from "./issues.js";
import { agents } from "./agents.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { authUsers } from "./auth.js";
export const issueComments = pgTable(
"issue_comments",
@@ -19,6 +20,7 @@ export const issueComments = pgTable(
issueId: uuid("issue_id").notNull().references(() => issues.id),
authorAgentId: uuid("author_agent_id").references(() => agents.id),
authorUserId: text("author_user_id"),
onBehalfOfUserId: text("on_behalf_of_user_id").references(() => authUsers.id, { onDelete: "set null" }),
authorType: text("author_type").$type<IssueCommentAuthorType>(),
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
// Persisted result of best-effort agent-attribution derivation for comments
@@ -1968,6 +1968,7 @@ Duplicate headings receive stable suffixes.
authorType: "user",
authorAgentId: null,
authorUserId: null,
onBehalfOfUserId: null,
body: "Comment evidence for the source bundle.",
presentation: null,
metadata: null,
@@ -2033,6 +2034,7 @@ Duplicate headings receive stable suffixes.
authorType: "user",
authorAgentId: null,
authorUserId: null,
onBehalfOfUserId: null,
body: "Authorization: Bearer ghp_supersecretcommenttoken1234567890",
presentation: null,
metadata: null,
@@ -2532,6 +2534,7 @@ Duplicate headings receive stable suffixes.
authorType: "user",
authorAgentId: null,
authorUserId: null,
onBehalfOfUserId: null,
body: "Authorization: Bearer ghp_patchsecretcommenttoken1234567890",
presentation: null,
metadata: null,
+1
View File
@@ -1727,6 +1727,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
authorType: options?.actorUserId ? "user" : options?.authorAgentId ? "agent" : "system",
authorAgentId: options?.actorUserId ? null : options?.authorAgentId ?? null,
authorUserId: options?.actorUserId ?? null,
onBehalfOfUserId: null,
body,
presentation: null,
metadata: null,
+1
View File
@@ -8,6 +8,7 @@ export interface ActivityEvent {
entityId: string;
agentId: string | null;
runId: string | null;
responsibleUserId?: string | null;
details: Record<string, unknown> | null;
createdAt: Date;
}
+3
View File
@@ -893,6 +893,8 @@ export interface IssueComment {
authorType: IssueCommentAuthorType;
authorAgentId: string | null;
authorUserId: string | null;
/** Responsible user attribution. Legacy and plugin-provided comment values may omit it. */
onBehalfOfUserId?: string | null;
createdByRunId?: string | null;
derivedAuthorAgentId?: string | null;
derivedCreatedByRunId?: string | null;
@@ -968,6 +970,7 @@ export interface IssueCommentMetadataSection {
export interface IssueCommentMetadata {
version: 1;
sourceRunId?: string | null;
authorizationReason?: string | null;
sections: IssueCommentMetadataSection[];
}
+3
View File
@@ -542,6 +542,7 @@ export const updateIssueSchema = createIssueBaseSchema.omit({
requestDepth: issueRequestDepthInputSchema.optional(),
assigneeAgentId: z.string().trim().min(1).optional().nullable(),
comment: multilineTextSchema.pipe(z.string().min(1)).optional(),
onBehalfOfUserId: z.string().trim().min(1).optional().nullable(),
reviewRequest: issueReviewRequestSchema.optional().nullable(),
reopen: z.boolean().optional(),
resume: z.boolean().optional(),
@@ -640,6 +641,7 @@ export const issueCommentMetadataSectionSchema = z.object({
export const issueCommentMetadataSchema = z.object({
version: z.literal(1),
sourceRunId: z.string().uuid().nullable().optional(),
authorizationReason: z.string().trim().min(1).max(160).nullable().optional(),
sections: z.array(issueCommentMetadataSectionSchema).min(1).max(20),
}).strict();
@@ -647,6 +649,7 @@ export type IssueCommentMetadata = z.infer<typeof issueCommentMetadataSchema>;
export const addIssueCommentSchema = z.object({
body: multilineTextSchema.pipe(z.string().min(1)),
onBehalfOfUserId: z.string().trim().min(1).optional().nullable(),
authorType: issueCommentAuthorTypeSchema.optional(),
presentation: issueCommentPresentationSchema.nullable().optional(),
metadata: issueCommentMetadataSchema.nullable().optional(),
@@ -0,0 +1,111 @@
import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
companies,
createDb,
heartbeatRuns,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import {
CROSS_ISSUE_INFLUENCE_ENFORCE_AT,
observeCrossIssueInfluence,
} from "../services/cross-issue-influence-limit.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres("cross-issue influence limit PostgreSQL serialization", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-cross-issue-cap-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("allows exactly one of concurrent attempts 20 and 21", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
const sourceIssueId = randomUUID();
const targetIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `C${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
defaultResponsibleUserId: "board-user",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Concurrent Coder",
role: "engineer",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
status: "running",
responsibleUserId: "board-user",
contextSnapshot: { issueId: sourceIssueId },
});
await db.insert(activityLog).values(
Array.from({ length: 19 }, () => ({
companyId,
actorType: "agent" as const,
actorId: agentId,
agentId,
runId,
action: "issue.cross_issue_influence_observed",
entityType: "issue",
entityId: targetIssueId,
})),
);
const input = {
companyId,
runId,
agentId,
targetIssueId,
targetIssueIdentifier: "CAP-2",
kind: "comment" as const,
now: CROSS_ISSUE_INFLUENCE_ENFORCE_AT,
};
const decisions = await Promise.all([
observeCrossIssueInfluence(db, input),
observeCrossIssueInfluence(db, { ...input, kind: "update" }),
]);
expect(decisions.map((decision) => decision?.allowed).sort()).toEqual([false, true]);
expect(decisions.map((decision) => decision?.count).sort((a, b) => Number(a) - Number(b))).toEqual([20, 21]);
const recorded = await db
.select({ action: activityLog.action })
.from(activityLog)
.where(and(eq(activityLog.companyId, companyId), eq(activityLog.runId, runId)));
expect(recorded.filter((row) => row.action === "issue.cross_issue_influence_observed")).toHaveLength(20);
expect(recorded.filter((row) => row.action === "issue.cross_issue_influence_cap_rejected")).toHaveLength(1);
});
});
@@ -0,0 +1,185 @@
import { describe, expect, it } from "vitest";
import {
CROSS_ISSUE_INFLUENCE_ENFORCE_AT,
CROSS_ISSUE_INFLUENCE_LIMIT,
crossIssueInfluenceLimitError,
evaluateCrossIssueInfluenceLimit,
observeCrossIssueInfluence,
} from "../services/cross-issue-influence-limit.ts";
function counterDb(
initialCount = 0,
runOverrides: Record<string, unknown> | null = {},
) {
let observedCount = initialCount;
const inserted: Array<Record<string, unknown>> = [];
const tx = {
select: (selection: Record<string, unknown>) => ({
from: () => ({
where: () => {
if (Object.keys(selection).includes("count")) {
return {
then: (resolve: (rows: unknown[]) => unknown) => resolve([{ count: observedCount }]),
};
}
return {
for: () => ({
then: (resolve: (rows: unknown[]) => unknown) => resolve(runOverrides === null ? [] : [{
id: "11111111-1111-4111-8111-111111111111",
companyId: "22222222-2222-4222-8222-222222222222",
agentId: "33333333-3333-4333-8333-333333333333",
responsibleUserId: "user-1",
contextSnapshot: { issueId: "44444444-4444-4444-8444-444444444444" },
...runOverrides,
}]),
}),
};
},
}),
}),
insert: () => ({
values: async (value: Record<string, unknown>) => {
inserted.push(value);
if (value.action === "issue.cross_issue_influence_observed") observedCount += 1;
},
}),
};
return {
db: {
transaction: async (callback: (value: typeof tx) => Promise<unknown>) => callback(tx),
},
inserted,
get observedCount() {
return observedCount;
},
};
}
describe("cross-issue influence limit rollout", () => {
it("logs observations without enforcement during the one-week rollout", () => {
const decision = evaluateCrossIssueInfluenceLimit({
priorCount: CROSS_ISSUE_INFLUENCE_LIMIT,
now: new Date(CROSS_ISSUE_INFLUENCE_ENFORCE_AT.getTime() - 1),
});
expect(decision).toMatchObject({
allowed: true,
mode: "log_only",
count: CROSS_ISSUE_INFLUENCE_LIMIT + 1,
cap: CROSS_ISSUE_INFLUENCE_LIMIT,
});
});
it("allows the twentieth influence and fails closed on the twenty-first after the flip", () => {
const now = CROSS_ISSUE_INFLUENCE_ENFORCE_AT;
expect(evaluateCrossIssueInfluenceLimit({ priorCount: 19, now })).toMatchObject({
allowed: true,
mode: "enforce",
count: 20,
cap: 20,
});
const rejected = evaluateCrossIssueInfluenceLimit({ priorCount: 20, now });
expect(rejected).toMatchObject({
allowed: false,
mode: "enforce",
count: 21,
cap: 20,
});
expect(crossIssueInfluenceLimitError(rejected)).toEqual({
error: "Cross-issue influence cap exceeded: this run is limited to 20 cross-issue comments or updates",
details: {
code: "cross_issue_influence_cap_exceeded",
cap: 20,
count: 21,
mode: "enforce",
enforceAt: CROSS_ISSUE_INFLUENCE_ENFORCE_AT.toISOString(),
},
});
});
it("uses one durable counter for cross-issue comments and PATCH updates", async () => {
const fake = counterDb();
const base = {
companyId: "22222222-2222-4222-8222-222222222222",
runId: "11111111-1111-4111-8111-111111111111",
agentId: "33333333-3333-4333-8333-333333333333",
targetIssueId: "55555555-5555-4555-8555-555555555555",
now: new Date(CROSS_ISSUE_INFLUENCE_ENFORCE_AT.getTime() - 1),
} as const;
await expect(observeCrossIssueInfluence(fake.db as never, { ...base, kind: "comment" }))
.resolves.toMatchObject({ count: 1, allowed: true });
await expect(observeCrossIssueInfluence(fake.db as never, { ...base, kind: "update" }))
.resolves.toMatchObject({ count: 2, allowed: true });
expect(fake.observedCount).toBe(2);
expect(fake.inserted.map((row) => (row.details as { kind: string }).kind)).toEqual(["comment", "update"]);
});
it("does not count same-issue writes", async () => {
const fake = counterDb(0, {
contextSnapshot: { issueId: "55555555-5555-4555-8555-555555555555" },
});
await expect(observeCrossIssueInfluence(fake.db as never, {
companyId: "22222222-2222-4222-8222-222222222222",
runId: "11111111-1111-4111-8111-111111111111",
agentId: "33333333-3333-4333-8333-333333333333",
targetIssueId: "55555555-5555-4555-8555-555555555555",
kind: "comment",
})).resolves.toBeNull();
expect(fake.inserted).toEqual([]);
});
it.each([
["missing", null],
["wrong-agent", { agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }],
["wrong-company", { companyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }],
] as const)("fails closed for a %s locked run", async (_label, runOverrides) => {
const fake = counterDb(0, runOverrides);
await expect(observeCrossIssueInfluence(fake.db as never, {
companyId: "22222222-2222-4222-8222-222222222222",
runId: "11111111-1111-4111-8111-111111111111",
agentId: "33333333-3333-4333-8333-333333333333",
targetIssueId: "55555555-5555-4555-8555-555555555555",
kind: "comment",
})).rejects.toMatchObject({
status: 403,
details: { code: "cross_issue_influence_run_context_required" },
});
expect(fake.inserted).toEqual([]);
});
it("fails closed before querying for a malformed run id", async () => {
const fake = counterDb();
await expect(observeCrossIssueInfluence(fake.db as never, {
companyId: "22222222-2222-4222-8222-222222222222",
runId: "attacker-controlled-run-id",
agentId: "33333333-3333-4333-8333-333333333333",
targetIssueId: "55555555-5555-4555-8555-555555555555",
kind: "comment",
})).rejects.toMatchObject({
status: 403,
details: { code: "cross_issue_influence_run_context_required" },
});
expect(fake.inserted).toEqual([]);
});
it("fails closed when the persisted run has no source issue", async () => {
const fake = counterDb(0, { contextSnapshot: {} });
await expect(observeCrossIssueInfluence(fake.db as never, {
companyId: "22222222-2222-4222-8222-222222222222",
runId: "11111111-1111-4111-8111-111111111111",
agentId: "33333333-3333-4333-8333-333333333333",
targetIssueId: "55555555-5555-4555-8555-555555555555",
kind: "update",
})).rejects.toMatchObject({
status: 403,
details: { code: "cross_issue_influence_run_context_required" },
});
expect(fake.inserted).toEqual([]);
});
});
@@ -158,6 +158,7 @@ describeEmbeddedPostgres("heartbeat issue rewake throttle", () => {
status?: string;
finishedSecondsAgo: number;
startedSecondsAgo?: number;
sessionIdAfter?: string;
}) {
const runId = randomUUID();
const finishedAt = new Date(Date.now() - input.finishedSecondsAgo * 1000);
@@ -174,6 +175,7 @@ describeEmbeddedPostgres("heartbeat issue rewake throttle", () => {
createdAt: startedAt,
startedAt,
finishedAt,
sessionIdAfter: input.sessionIdAfter,
contextSnapshot: { issueId: input.issueId, wakeReason: "issue_assigned" },
});
return runId;
@@ -245,7 +247,7 @@ describeEmbeddedPostgres("heartbeat issue rewake throttle", () => {
expect(admittedWake).not.toBeNull();
});
it("does not throttle comment-driven wakes even during a no-progress streak", async () => {
it("does not throttle system comment-driven wakes even during a no-progress streak", async () => {
const { companyId, agentId, issueId } = await seedCompanyAgentIssue();
await seedTerminalRun({ companyId, agentId, issueId, finishedSecondsAgo: 40 });
@@ -263,6 +265,94 @@ describeEmbeddedPostgres("heartbeat issue rewake throttle", () => {
expect(commentWake).not.toBeNull();
});
it("keeps agent comments throttled without hiding genuinely new human input", async () => {
const { companyId, agentId, issueId } = await seedCompanyAgentIssue();
await seedTerminalRun({ companyId, agentId, issueId, finishedSecondsAgo: 40 });
await seedTerminalRun({ companyId, agentId, issueId, finishedSecondsAgo: 10 });
const agentCommentId = randomUUID();
await db.insert(activityLog).values({
companyId,
actorType: "agent",
actorId: randomUUID(),
action: "issue.comment_added",
entityType: "issue",
entityId: issueId,
});
const throttledAgentCommentWake = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: { issueId, commentId: agentCommentId },
contextSnapshot: {
issueId,
wakeReason: "issue_commented",
wakeCommentId: agentCommentId,
},
requestedByActorType: "agent",
requestedByActorId: randomUUID(),
});
expect(throttledAgentCommentWake).toBeNull();
expect((await latestWakeRequest(agentId))?.reason).toBe("issue_rewake_throttled");
await db.insert(activityLog).values({
companyId,
actorType: "user",
actorId: "board-user",
action: "issue.comment_added",
entityType: "issue",
entityId: issueId,
});
const nextAgentCommentId = randomUUID();
const admittedAfterHumanInput = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: { issueId, commentId: nextAgentCommentId },
contextSnapshot: {
issueId,
wakeReason: "issue_commented",
wakeCommentId: nextAgentCommentId,
},
requestedByActorType: "agent",
requestedByActorId: randomUUID(),
});
expect(admittedAfterHumanInput).not.toBeNull();
});
it("keeps agent-authored explicit resume comments inside the no-progress cooldown", async () => {
const { companyId, agentId, issueId } = await seedCompanyAgentIssue();
const resumeFromRunId = await seedTerminalRun({
companyId,
agentId,
issueId,
finishedSecondsAgo: 40,
sessionIdAfter: randomUUID(),
});
await seedTerminalRun({ companyId, agentId, issueId, finishedSecondsAgo: 10 });
const commentId = randomUUID();
const resumeWake = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_reopened_via_comment",
payload: { issueId, commentId, resumeFromRunId, resumeIntent: true },
contextSnapshot: {
issueId,
wakeReason: "issue_reopened_via_comment",
wakeCommentId: commentId,
resumeIntent: true,
},
requestedByActorType: "agent",
requestedByActorId: randomUUID(),
});
expect(resumeWake).toBeNull();
expect((await latestWakeRequest(agentId))?.reason).toBe("issue_rewake_throttled");
});
it("does not throttle the wake that follows a failed run", async () => {
const { companyId, agentId, issueId } = await seedCompanyAgentIssue();
@@ -10,6 +10,7 @@ import {
agents,
agentRuntimeState,
agentWakeupRequests,
authUsers,
budgetPolicies,
companySecretBindings,
companySecrets,
@@ -304,6 +305,15 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-recovery-");
db = createDb(tempDb.connectionString);
const now = new Date();
await db.insert(authUsers).values({
id: "responsible-user",
name: "Responsible User",
email: "responsible-user@example.test",
emailVerified: true,
createdAt: now,
updatedAt: now,
});
}, 20_000);
afterEach(async () => {
@@ -140,6 +140,7 @@ describeEmbeddedPostgres("inbox archive routes", () => {
status: "running",
invocationSource: "assignment",
responsibleUserId,
contextSnapshot: { issueId },
});
await db.insert(issues).values({
id: issueId,
@@ -474,6 +474,12 @@ describe("issue activity event routes", () => {
...issue,
...patch,
updatedAt: new Date(),
changes: {
executionWorkspaceId: {
from: issue.executionWorkspaceId,
to: nextExecutionWorkspaceId,
},
},
}));
const dbMock = {
@@ -2,6 +2,7 @@ import { Readable } from "node:stream";
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { HttpError } from "../errors.js";
const issueId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
@@ -135,6 +136,7 @@ const mockExternalObjectService = vi.hoisted(() => ({
syncIssueSafely: vi.fn(async () => undefined),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
const mockObserveCrossIssueInfluence = vi.hoisted(() => vi.fn(async () => null));
function registerRouteMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
@@ -175,6 +177,16 @@ function registerRouteMocks() {
logActivity: mockLogActivity,
}));
vi.doMock("../services/cross-issue-influence-limit.js", () => ({
observeCrossIssueInfluence: mockObserveCrossIssueInfluence,
crossIssueInfluenceLimitError: vi.fn(),
crossIssueInfluenceRunContextError: () => new HttpError(
403,
"Agent issue comments and updates require a valid heartbeat run so cross-issue influence can be contained",
{ code: "cross_issue_influence_run_context_required" },
),
}));
vi.doMock("../services/index.js", () => ({
ISSUE_LIST_DEFAULT_LIMIT: 100,
ISSUE_LIST_MAX_LIMIT: 500,
@@ -369,6 +381,7 @@ describe("agent issue mutation checkout ownership", () => {
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/access.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/cross-issue-influence-limit.js");
vi.doUnmock("../services/agents.js");
vi.doUnmock("../services/documents.js");
vi.doUnmock("../services/external-objects.js");
@@ -512,6 +525,8 @@ describe("agent issue mutation checkout ownership", () => {
mockIssueService.update.mockReset();
mockIssueService.findMentionedAgents.mockReset();
mockLogActivity.mockClear();
mockObserveCrossIssueInfluence.mockReset();
mockObserveCrossIssueInfluence.mockResolvedValue(null);
mockDocumentService.upsertIssueDocument.mockReset();
mockWorkProductService.createForIssue.mockReset();
mockExternalObjectService.getIssueSummaries.mockClear();
@@ -804,7 +819,7 @@ describe("agent issue mutation checkout ownership", () => {
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("allows non-mentioned peer agents to post comments on visible issues", async () => {
it("keeps visible peer comments agent-class even when authorType tries to smuggle user wake privilege", async () => {
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "issue:read" || input.action === "issue:comment",
action: input.action,
@@ -814,7 +829,7 @@ describe("agent issue mutation checkout ownership", () => {
const res = await request(await createApp(peerActor()))
.post(`/api/issues/${issueId}/comments`)
.send({ body: "I was not mentioned." });
.send({ body: "I was not mentioned.", authorType: "user" });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
@@ -823,6 +838,14 @@ describe("agent issue mutation checkout ownership", () => {
expect.any(Object),
expect.any(Object),
);
await vi.waitFor(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ownerAgentId,
expect.objectContaining({
reason: "issue_commented",
requestedByActorType: "agent",
requestedByActorId: peerAgentId,
}),
));
});
it("keeps default-open peer comments on closed issues inert", async () => {
@@ -3,6 +3,7 @@ import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const AGENT_ACTOR_ID = "11111111-1111-4111-8111-111111111111";
const AGENT_RUN_ID = "44444444-4444-4444-8444-444444444444";
const PAUSED_AGENT_ID = "22222222-2222-4222-8222-222222222222";
const IDLE_AGENT_ID = "33333333-3333-4333-8333-333333333333";
@@ -39,6 +40,13 @@ const mockHeartbeatService = vi.hoisted(() => ({
cancelRun: vi.fn(async () => null),
}));
const mockObserveCrossIssueInfluence = vi.hoisted(() => vi.fn(async () => null));
vi.mock("../services/cross-issue-influence-limit.js", async (importOriginal) => ({
...await importOriginal<typeof import("../services/cross-issue-influence-limit.js")>(),
observeCrossIssueInfluence: mockObserveCrossIssueInfluence,
}));
vi.mock("../services/index.js", () => ({
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
@@ -138,20 +146,18 @@ function boardActor(): Actor {
};
}
// No runId on purpose: a run-less agent actor exercises the assignment guard
// without engaging watchdog-scope or checkout-ownership lookups.
function agentActor(): Actor {
return {
type: "agent",
agentId: AGENT_ACTOR_ID,
companyId: "company-1",
source: "agent_key",
runId: AGENT_RUN_ID,
};
}
// Minimal chainable/thenable db stub: any query resolves to an empty row set.
// Needed because some route paths (e.g. source-trust resolution for agent
// actors) query the db directly rather than through the mocked services.
// Run containment is mocked because this suite targets assignee invokability.
function stubDb(): any {
const query: any = {};
for (const method of ["select", "from", "where", "innerJoin", "leftJoin", "orderBy", "limit", "groupBy", "for"]) {
@@ -204,6 +210,8 @@ describe("issue assignee invokability guard", () => {
mockIssueService.createChild.mockReset();
mockIssueService.addComment.mockReset();
mockHeartbeatService.wakeup.mockClear();
mockObserveCrossIssueInfluence.mockClear();
mockObserveCrossIssueInfluence.mockResolvedValue(null);
});
it("refuses an agent assigning an issue to a paused agent", async () => {
@@ -0,0 +1,276 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { and, desc, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
authUsers,
companies,
companyMemberships,
createDb,
heartbeatRuns,
issueComments,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
vi.hoisted(() => {
process.env.PAPERCLIP_HOME = "/tmp/paperclip-test-home";
process.env.PAPERCLIP_INSTANCE_ID = "vitest";
process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs";
process.env.PAPERCLIP_IN_WORKTREE = "false";
});
vi.mock("../services/issue-assignment-wakeup.js", () => ({
queueIssueAssignmentWakeup: vi.fn(),
}));
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
type Db = ReturnType<typeof createDb>;
async function createApp(db: Db, actor: Express.Request["actor"]) {
const [{ activityRoutes }, { issueRoutes }] = await Promise.all([
import("../routes/activity.js"),
import("../routes/issues.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
app.use("/api", issueRoutes(db, {} as never));
app.use("/api", activityRoutes(db));
app.use((error: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(error.status ?? 500).json({ error: error.message ?? "Internal server error" });
});
return app;
}
describeEmbeddedPostgres("issue comment attribution and patch audit routes", () => {
let db!: Db;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-comment-attribution-audit-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(issueComments);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(companyMemberships);
await db.delete(agents);
await db.delete(companies);
await db.delete(authUsers);
});
afterAll(async () => tempDb?.cleanup());
async function seed() {
const company = await db.insert(companies).values({
name: "Attribution Company",
issuePrefix: `AT${randomUUID().replaceAll("-", "").slice(0, 6).toUpperCase()}`,
}).returning().then((rows) => rows[0]!);
const [actorAgent, targetAgent] = await db.insert(agents).values(["Actor", "Target"].map((name) => ({
companyId: company.id,
name,
role: "engineer",
status: "active" as const,
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
}))).returning();
const responsibleUserId = `responsible-${randomUUID()}`;
const boardUserId = `board-${randomUUID()}`;
const now = new Date();
await db.insert(authUsers).values([
{
id: responsibleUserId,
name: "Responsible User",
email: `${responsibleUserId}@example.test`,
emailVerified: true,
createdAt: now,
updatedAt: now,
},
{
id: boardUserId,
name: "Board User",
email: `${boardUserId}@example.test`,
emailVerified: true,
createdAt: now,
updatedAt: now,
},
]);
await db.insert(companyMemberships).values({
companyId: company.id,
principalType: "user",
principalId: responsibleUserId,
status: "active",
membershipRole: "member",
});
const [sourceIssue, issue] = await db.insert(issues).values([
{
companyId: company.id,
identifier: `${company.issuePrefix}-1`,
title: "Source issue",
status: "in_progress" as const,
priority: "medium" as const,
assigneeAgentId: actorAgent.id,
},
{
companyId: company.id,
identifier: `${company.issuePrefix}-2`,
title: "Foreign issue",
status: "done" as const,
priority: "medium" as const,
assigneeAgentId: targetAgent.id,
},
]).returning();
const run = await db.insert(heartbeatRuns).values({
companyId: company.id,
agentId: actorAgent.id,
status: "running",
responsibleUserId,
contextSnapshot: { issueId: sourceIssue.id },
}).returning().then((rows) => rows[0]!);
return { company, actorAgent, responsibleUserId, boardUserId, run, issue };
}
function agentActor(input: Awaited<ReturnType<typeof seed>>): Express.Request["actor"] {
return {
type: "agent",
agentId: input.actorAgent.id,
companyId: input.company.id,
runId: input.run.id,
onBehalfOfUserId: input.responsibleUserId,
onBehalfOfMemberships: [{
companyId: input.company.id,
membershipRole: "member",
status: "active",
}],
source: "agent_jwt",
};
}
it("attributes foreign-issue agent comments and emits complete agent and human PATCH receipts", async () => {
const fixture = await seed();
const agentApp = await createApp(db, agentActor(fixture));
const commentResponse = await request(agentApp)
.post(`/api/issues/${fixture.issue.id}/comments`)
.send({ body: "Cross-issue collaboration note" });
expect(commentResponse.status, JSON.stringify(commentResponse.body)).toBe(201);
expect(commentResponse.body).toMatchObject({
authorAgentId: fixture.actorAgent.id,
onBehalfOfUserId: fixture.responsibleUserId,
createdByRunId: fixture.run.id,
metadata: {
authorizationReason: "allow_visible_issue_write",
},
});
const agentPatch = await request(agentApp)
.patch(`/api/issues/${fixture.issue.id}`)
.send({ priority: "high" });
expect(agentPatch.status, JSON.stringify(agentPatch.body)).toBe(200);
expect(agentPatch.body.changes).toMatchObject({
priority: { from: "medium", to: "high" },
});
const boardApp = await createApp(db, {
type: "board",
userId: fixture.boardUserId,
companyIds: [fixture.company.id],
source: "local_implicit",
isInstanceAdmin: false,
});
const humanPatch = await request(boardApp)
.patch(`/api/issues/${fixture.issue.id}`)
.send({ title: "Human-edited issue" });
expect(humanPatch.status, JSON.stringify(humanPatch.body)).toBe(200);
const patchEvents = await db
.select()
.from(activityLog)
.where(and(
eq(activityLog.entityId, fixture.issue.id),
eq(activityLog.action, "issue.updated"),
))
.orderBy(desc(activityLog.createdAt));
expect(patchEvents).toEqual(expect.arrayContaining([
expect.objectContaining({
actorType: "agent",
actorId: fixture.actorAgent.id,
agentId: fixture.actorAgent.id,
runId: fixture.run.id,
responsibleUserId: fixture.responsibleUserId,
details: expect.objectContaining({
authorizationReason: "allow_visible_issue_write",
changes: expect.objectContaining({
priority: { from: "medium", to: "high" },
}),
}),
}),
expect.objectContaining({
actorType: "user",
actorId: fixture.boardUserId,
agentId: null,
runId: null,
responsibleUserId: fixture.boardUserId,
details: expect.objectContaining({
authorizationReason: "allow_board_actor",
changes: expect.objectContaining({
title: { from: "Foreign issue", to: "Human-edited issue" },
}),
}),
}),
]));
const activityResponse = await request(boardApp).get(`/api/issues/${fixture.issue.id}/activity`);
expect(activityResponse.status, JSON.stringify(activityResponse.body)).toBe(200);
expect(activityResponse.body).toEqual(expect.arrayContaining([
expect.objectContaining({
action: "issue.updated",
responsibleUserId: fixture.responsibleUserId,
details: expect.objectContaining({ authorizationReason: "allow_visible_issue_write" }),
}),
]));
}, 30_000);
it("rejects and audits an agent-supplied onBehalfOfUserId", async () => {
const fixture = await seed();
const response = await request(await createApp(db, agentActor(fixture)))
.post(`/api/issues/${fixture.issue.id}/comments`)
.send({ body: "Spoof attempt", onBehalfOfUserId: "someone-else" });
expect(response.status).toBe(422);
expect(await db.select().from(issueComments)).toHaveLength(0);
const event = await db
.select()
.from(activityLog)
.where(eq(activityLog.action, "issue.attribution_spoof_rejected"))
.then((rows) => rows[0]);
expect(event).toMatchObject({
actorId: fixture.actorAgent.id,
runId: fixture.run.id,
responsibleUserId: fixture.responsibleUserId,
details: {
identifier: fixture.issue.identifier,
surface: "issue.comment.create",
field: "onBehalfOfUserId",
requestedValue: "someone-else",
derivedFrom: "authenticated_actor",
},
});
}, 30_000);
});
@@ -1,6 +1,7 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { HttpError } from "../errors.js";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
@@ -84,6 +85,9 @@ const mockExternalObjectService = vi.hoisted(() => ({
syncCommentSafely: vi.fn(async () => undefined),
syncIssueSafely: vi.fn(async () => undefined),
}));
const mockObserveCrossIssueInfluence = vi.hoisted(() => vi.fn());
const mockCrossIssueInfluenceLimitError = vi.hoisted(() => vi.fn());
const mockCrossIssueInfluenceRunContextError = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
@@ -170,6 +174,12 @@ vi.mock("../services/external-objects.js", () => ({
externalObjectService: () => mockExternalObjectService,
}));
vi.mock("../services/cross-issue-influence-limit.js", () => ({
observeCrossIssueInfluence: mockObserveCrossIssueInfluence,
crossIssueInfluenceLimitError: mockCrossIssueInfluenceLimitError,
crossIssueInfluenceRunContextError: mockCrossIssueInfluenceRunContextError,
}));
function createApp() {
const app = express();
app.use(express.json());
@@ -220,6 +230,21 @@ function makeIssue(status: "todo" | "done" | "blocked" | "cancelled" | "in_progr
};
}
function makeIssueUpdateReceipt(
existing: ReturnType<typeof makeIssue>,
patch: Record<string, unknown>,
) {
const fields = Object.fromEntries(
Object.entries(patch).filter(([key]) => key !== "actorAgentId" && key !== "actorUserId"),
);
const changes = Object.fromEntries(
Object.entries(fields)
.filter(([key, value]) => !Object.is(existing[key as keyof typeof existing], value))
.map(([key, value]) => [key, { from: existing[key as keyof typeof existing], to: value }]),
);
return { ...existing, ...fields, changes };
}
function agentActor(agentId = "22222222-2222-4222-8222-222222222222") {
return {
type: "agent",
@@ -267,6 +292,9 @@ describe.sequential("issue comment reopen routes", () => {
mockIssueTreeControlService.getActivePauseHoldGate.mockReset();
mockExternalObjectService.syncCommentSafely.mockReset();
mockExternalObjectService.syncIssueSafely.mockReset();
mockObserveCrossIssueInfluence.mockReset();
mockCrossIssueInfluenceLimitError.mockReset();
mockCrossIssueInfluenceRunContextError.mockReset();
mockTxInsertValues.mockReset();
mockTxInsert.mockReset();
mockDbSelect.mockReset();
@@ -292,6 +320,22 @@ describe.sequential("issue comment reopen routes", () => {
mockHeartbeatService.cancelRun.mockResolvedValue(null);
mockExternalObjectService.syncCommentSafely.mockResolvedValue(undefined);
mockExternalObjectService.syncIssueSafely.mockResolvedValue(undefined);
mockObserveCrossIssueInfluence.mockResolvedValue({
allowed: true,
mode: "log_only",
count: 1,
cap: 20,
enforceAt: "2026-08-11T00:00:00.000Z",
});
mockCrossIssueInfluenceLimitError.mockImplementation((decision: { count: number; cap: number }) => ({
error: `Cross-issue influence cap exceeded: this run is limited to ${decision.cap} cross-issue comments or updates`,
details: { code: "cross_issue_influence_cap_exceeded", count: decision.count, cap: decision.cap },
}));
mockCrossIssueInfluenceRunContextError.mockImplementation(() => new HttpError(
403,
"Agent issue comments and updates require a valid heartbeat run so cross-issue influence can be contained",
{ code: "cross_issue_influence_run_context_required" },
));
mockLogActivity.mockResolvedValue(undefined);
mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]);
mockFeedbackService.saveIssueVote.mockResolvedValue({
@@ -400,11 +444,10 @@ describe.sequential("issue comment reopen routes", () => {
});
it("implicitly reopens closed issues via the PATCH comment path when reassigning to an agent", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("done"),
...patch,
}));
const issue = makeIssue("done");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) =>
makeIssueUpdateReceipt(issue, patch));
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
@@ -478,11 +521,10 @@ describe.sequential("issue comment reopen routes", () => {
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("reopens closed issues via the PATCH comment path", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("done"),
...patch,
}));
const issue = makeIssue("done");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) =>
makeIssueUpdateReceipt(issue, patch));
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
@@ -512,11 +554,10 @@ describe.sequential("issue comment reopen routes", () => {
});
it("implicitly reopens closed issues via POST comments when an agent is assigned", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("done"),
...patch,
}));
const issue = makeIssue("done");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) =>
makeIssueUpdateReceipt(issue, patch));
const res = await request(await installActor(createApp()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
@@ -654,11 +695,10 @@ describe.sequential("issue comment reopen routes", () => {
// reopen=true is the same log-class signal — the guard must suppress reopen.
it("does not reopen via POST comment+reopen when the assignee agent is the actor on a done issue", async () => {
const assigneeAgentId = "22222222-2222-4222-8222-222222222222";
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("done"),
...patch,
}));
const issue = makeIssue("done");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) =>
makeIssueUpdateReceipt(issue, patch));
mockIssueService.addComment.mockResolvedValue({
id: "comment-1",
issueId: "11111111-1111-4111-8111-111111111111",
@@ -786,11 +826,10 @@ describe.sequential("issue comment reopen routes", () => {
reason: "allow_explicit_grant",
explanation: "Allowed by test grant.",
}));
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("done"),
...patch,
}));
const issue = makeIssue("done");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) =>
makeIssueUpdateReceipt(issue, patch));
const res = await request(
await installActor(createApp(), {
@@ -1019,9 +1058,10 @@ describe.sequential("issue comment reopen routes", () => {
expect(mockIssueService.addComment).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"Paperclip needs a disposition before this issue can continue.",
{ agentId: undefined, userId: "local-board", runId: null },
{ agentId: undefined, userId: "local-board", runId: null, onBehalfOfUserId: undefined },
{
authorType: "user",
authorizationReason: "allow_board_actor",
presentation: { kind: "system_notice", tone: "warning", detailsDefaultOpen: false },
metadata,
sourceTrust: null,
@@ -1071,7 +1111,12 @@ describe.sequential("issue comment reopen routes", () => {
expect(mockIssueService.addComment).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"Recovered the execution path.\nHanded back to the original owner.",
{ agentId: "22222222-2222-4222-8222-222222222222", userId: undefined, runId: "run-1" },
{
agentId: "22222222-2222-4222-8222-222222222222",
userId: undefined,
runId: "run-1",
onBehalfOfUserId: null,
},
expect.objectContaining({
authorType: "agent",
presentation: {
@@ -1096,7 +1141,12 @@ describe.sequential("issue comment reopen routes", () => {
expect(mockIssueService.addComment).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"Normal work update.",
{ agentId: "22222222-2222-4222-8222-222222222222", userId: undefined, runId: "run-1" },
{
agentId: "22222222-2222-4222-8222-222222222222",
userId: undefined,
runId: "run-1",
onBehalfOfUserId: null,
},
expect.objectContaining({ presentation: null }),
);
});
@@ -1124,7 +1174,12 @@ describe.sequential("issue comment reopen routes", () => {
expect(mockIssueService.addComment).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"The run completed; here is the required summary.",
{ agentId: "22222222-2222-4222-8222-222222222222", userId: undefined, runId: "run-1" },
{
agentId: "22222222-2222-4222-8222-222222222222",
userId: undefined,
runId: "run-1",
onBehalfOfUserId: null,
},
expect.objectContaining({ presentation: null }),
);
});
@@ -1303,11 +1358,10 @@ describe.sequential("issue comment reopen routes", () => {
});
it("moves assigned blocked issues back to todo via the PATCH comment path", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("blocked"));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("blocked"),
...patch,
}));
const issue = makeIssue("blocked");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) =>
makeIssueUpdateReceipt(issue, patch));
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
@@ -1562,11 +1616,10 @@ describe.sequential("issue comment reopen routes", () => {
});
it("explicit same-agent resume works through the PATCH comment path", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("done"),
...patch,
}));
const issue = makeIssue("done");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) =>
makeIssueUpdateReceipt(issue, patch));
const res = await request(await installActor(createApp(), agentActor()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
@@ -1596,6 +1649,8 @@ describe.sequential("issue comment reopen routes", () => {
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
reason: "issue_reopened_via_comment",
requestedByActorType: "agent",
requestedByActorId: "22222222-2222-4222-8222-222222222222",
payload: expect.objectContaining({
commentId: "comment-1",
reopenedFrom: "done",
@@ -1664,20 +1719,266 @@ describe.sequential("issue comment reopen routes", () => {
);
});
it("rejects explicit agent resume intent from a non-assignee", async () => {
it("honors explicit agent resume intent from a default-open peer as an agent-class wake", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("done"),
...patch,
}));
mockAccessService.decide.mockImplementation(async (input: { action?: string }) => ({
allowed: input.action !== "tasks:manage_active_checkouts",
action: input.action,
reason: input.action === "issue:comment" || input.action === "issue:mutate"
? "allow_visible_issue_write"
: "deny_missing_grant",
explanation: "Allowed by the shared visible-issue write rule.",
}));
const res = await request(await installActor(createApp(), agentActor("44444444-4444-4444-8444-444444444444")))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "restart someone else's work", resume: true });
expect(res.status).toBe(403);
expect(res.body.error).toBe("Agent cannot mutate another agent's issue");
expect(res.status).toBe(201);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
{ status: "todo" },
);
expect(mockIssueService.addComment).toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
requestedByActorType: "agent",
reason: "issue_reopened_via_comment",
payload: expect.objectContaining({ resumeIntent: true }),
}),
);
});
it("bounds a cross-agent reply loop: peer comments wake and assignee self-replies do not", async () => {
const agentA = "44444444-4444-4444-8444-444444444444";
const agentB = "22222222-2222-4222-8222-222222222222";
mockIssueService.getById.mockResolvedValue({ ...makeIssue("todo"), assigneeAgentId: agentB });
let res = await request(await installActor(createApp(), agentActor(agentA)))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "A asks B for input" });
expect(res.status).toBe(201);
await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
agentB,
expect.objectContaining({
reason: "issue_commented",
requestedByActorType: "agent",
requestedByActorId: agentA,
}),
));
mockHeartbeatService.wakeup.mockClear();
mockIssueService.findMentionedAgents.mockClear();
res = await request(await installActor(createApp(), agentActor(agentB)))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "B replies on B's own issue" });
expect(res.status).toBe(201);
await vi.waitFor(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalledOnce());
await new Promise((resolve) => setTimeout(resolve, 0));
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
mockIssueService.getById.mockResolvedValue({ ...makeIssue("todo"), assigneeAgentId: agentA });
res = await request(await installActor(createApp(), agentActor(agentB)))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "B explicitly comments back on A's issue" });
expect(res.status).toBe(201);
await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
agentA,
expect.objectContaining({
reason: "issue_commented",
requestedByActorType: "agent",
requestedByActorId: agentB,
}),
));
});
it.each([
["comment", (app: express.Express) => request(app)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "cross-issue attempt 21" })],
["update", (app: express.Express) => request(app)
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ title: "cross-issue attempt 21" })],
] as const)("fails closed when a run exceeds the cross-issue %s cap", async (kind, sendRequest) => {
const agentA = "44444444-4444-4444-8444-444444444444";
mockIssueService.getById.mockResolvedValue({ ...makeIssue("todo"), assigneeAgentId: "22222222-2222-4222-8222-222222222222" });
mockHeartbeatService.getRun.mockResolvedValue({
id: "run-1",
companyId: "company-1",
agentId: agentA,
responsibleUserId: null,
contextSnapshot: { issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" },
});
mockObserveCrossIssueInfluence.mockResolvedValue({
allowed: false,
mode: "enforce",
count: 21,
cap: 20,
enforceAt: "2026-08-11T00:00:00.000Z",
});
const res = await sendRequest(await installActor(createApp(), agentActor(agentA)));
expect(res.status).toBe(429);
expect(res.body.error).toContain("limited to 20 cross-issue comments or updates");
expect(mockObserveCrossIssueInfluence).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
runId: "run-1",
agentId: agentA,
targetIssueId: "11111111-1111-4111-8111-111111111111",
kind,
}),
);
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it.each([
["hide", "2026-08-04T18:00:00.000Z"],
["unhide", null],
] as const)("counts a cross-issue %s PATCH before mutation", async (_label, hiddenAt) => {
const agentA = "44444444-4444-4444-8444-444444444444";
mockIssueService.getById.mockResolvedValue({
...makeIssue("todo"),
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
hiddenAt: hiddenAt === null ? new Date("2026-08-04T17:00:00.000Z") : null,
});
mockObserveCrossIssueInfluence.mockResolvedValue({
allowed: false,
mode: "enforce",
count: 21,
cap: 20,
enforceAt: "2026-08-11T00:00:00.000Z",
});
const res = await request(await installActor(createApp(), agentActor(agentA)))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ hiddenAt });
expect(res.status).toBe(429);
expect(res.body.error).toContain("limited to 20 cross-issue comments or updates");
expect(mockObserveCrossIssueInfluence).toHaveBeenCalledTimes(1);
expect(mockObserveCrossIssueInfluence).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ kind: "update" }),
);
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("counts bundled cross-issue PATCH updates and comments separately before mutation", async () => {
const agentA = "44444444-4444-4444-8444-444444444444";
mockIssueService.getById.mockResolvedValue({
...makeIssue("todo"),
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
});
const app = await installActor(createApp(), agentActor(agentA));
mockObserveCrossIssueInfluence
.mockResolvedValueOnce({
allowed: true,
mode: "enforce",
count: 20,
cap: 20,
enforceAt: "2026-08-11T00:00:00.000Z",
})
.mockResolvedValueOnce({
allowed: false,
mode: "enforce",
count: 21,
cap: 20,
enforceAt: "2026-08-11T00:00:00.000Z",
});
const res = await request(app)
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ title: "Bundled update", comment: "Bundled comment" });
expect(res.status).toBe(429);
expect(mockObserveCrossIssueInfluence.mock.calls.map(([, input]) => input.kind)).toEqual([
"update",
"comment",
]);
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
});
it("counts a comment-only cross-issue PATCH once", async () => {
const agentA = "44444444-4444-4444-8444-444444444444";
const existing = {
...makeIssue("todo"),
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
};
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...existing,
...patch,
}));
const res = await request(await installActor(createApp(), agentActor(agentA)))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "Comment without an issue update" });
expect(res.status).toBe(200);
expect(mockObserveCrossIssueInfluence).toHaveBeenCalledTimes(1);
expect(mockObserveCrossIssueInfluence).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ kind: "comment" }),
);
});
it.each([
["comment", (app: express.Express) => request(app)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "cross-issue write" })],
["update", (app: express.Express) => request(app)
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ title: "cross-issue write" })],
] as const)("rejects cross-issue %s writes without a run header", async (_kind, sendRequest) => {
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
const actor = { ...agentActor("44444444-4444-4444-8444-444444444444"), runId: undefined };
const res = await sendRequest(await installActor(createApp(), actor));
expect(res.status).toBe(403);
expect(res.body.details).toEqual({ code: "cross_issue_influence_run_context_required" });
expect(mockHeartbeatService.getRun).not.toHaveBeenCalled();
expect(mockObserveCrossIssueInfluence).not.toHaveBeenCalled();
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
});
it.each(["invalid", "wrong agent", "wrong company"])(
"rejects comment and PATCH writes with a %s run",
async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
mockObserveCrossIssueInfluence.mockRejectedValue(new HttpError(
403,
"Agent issue comments and updates require a valid heartbeat run so cross-issue influence can be contained",
{ code: "cross_issue_influence_run_context_required" },
));
const actor = agentActor("44444444-4444-4444-8444-444444444444");
const commentRes = await request(await installActor(createApp(), actor))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "cross-issue write" });
const updateRes = await request(await installActor(createApp(), actor))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ title: "cross-issue write" });
for (const res of [commentRes, updateRes]) {
expect(res.status).toBe(403);
expect(res.body.details).toEqual({ code: "cross_issue_influence_run_context_required" });
}
expect(mockObserveCrossIssueInfluence).toHaveBeenCalledTimes(2);
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
},
);
it("rejects explicit resume intent under an active pause hold", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueTreeControlService.getActivePauseHoldGate.mockResolvedValue({
@@ -1700,17 +2001,31 @@ describe.sequential("issue comment reopen routes", () => {
expect(mockIssueService.addComment).not.toHaveBeenCalled();
});
it("rejects explicit resume intent on cancelled issues", async () => {
it("honors explicit resume intent on cancelled issues", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("cancelled"));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("cancelled"),
...patch,
}));
const res = await request(await installActor(createApp(), agentActor()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "please resume", resume: true });
expect(res.status).toBe(409);
expect(res.body.error).toBe("Cancelled issues must be restored through the dedicated restore flow");
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
expect(res.status).toBe(201);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
{ status: "todo" },
);
expect(mockIssueService.addComment).toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
requestedByActorType: "agent",
reason: "issue_reopened_via_comment",
payload: expect.objectContaining({ reopenedFrom: "cancelled", resumeIntent: true }),
}),
);
});
it("interrupts an active run before a combined comment update", async () => {
@@ -31,11 +31,22 @@ const mockAccessService = vi.hoisted(() => ({
hasPermission: vi.fn(async () => false),
}));
const mockDbSelectWhere = vi.hoisted(() => vi.fn(() => ({
for: () => ({
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([{
id: "55555555-5555-4555-8555-555555555555",
companyId: "company-1",
agentId: "33333333-3333-4333-8333-333333333333",
contextSnapshot: { issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" },
permissions: null,
}]).then(onFulfilled, onRejected),
}),
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([{
id: "55555555-5555-4555-8555-555555555555",
companyId: "company-1",
agentId: "33333333-3333-4333-8333-333333333333",
contextSnapshot: null,
contextSnapshot: { issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" },
permissions: null,
}]).then(onFulfilled, onRejected),
})));
@@ -43,6 +54,8 @@ const mockDbSelectFrom = vi.hoisted(() => vi.fn(() => ({ where: mockDbSelectWher
const mockDbSelect = vi.hoisted(() => vi.fn(() => ({ from: mockDbSelectFrom })));
const mockDb = vi.hoisted(() => ({
select: mockDbSelect,
transaction: vi.fn(async (callback: (tx: { select: typeof mockDbSelect }) => Promise<unknown>) =>
callback({ select: mockDbSelect })),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
@@ -187,11 +200,22 @@ describe("issue execution policy routes", () => {
mockDbSelect.mockImplementation(() => ({ from: mockDbSelectFrom }));
mockDbSelectFrom.mockImplementation(() => ({ where: mockDbSelectWhere }));
mockDbSelectWhere.mockImplementation(() => ({
for: () => ({
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([{
id: "55555555-5555-4555-8555-555555555555",
companyId: "company-1",
agentId: "33333333-3333-4333-8333-333333333333",
contextSnapshot: { issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" },
permissions: null,
}]).then(onFulfilled, onRejected),
}),
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([{
id: "55555555-5555-4555-8555-555555555555",
companyId: "company-1",
agentId: "33333333-3333-4333-8333-333333333333",
contextSnapshot: null,
contextSnapshot: { issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" },
permissions: null,
}]).then(onFulfilled, onRejected),
}));
@@ -245,7 +269,7 @@ describe("issue execution policy routes", () => {
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
runId: "run-1",
runId: "55555555-5555-4555-8555-555555555555",
}))
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({ status: "in_review" });
@@ -287,7 +311,7 @@ describe("issue execution policy routes", () => {
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
runId: "run-1",
runId: "55555555-5555-4555-8555-555555555555",
}))
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({ status: "in_review" });
@@ -332,7 +356,7 @@ describe("issue execution policy routes", () => {
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
runId: "run-1",
runId: "55555555-5555-4555-8555-555555555555",
}))
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({ status: "in_review", executionPolicy: policy });
@@ -382,7 +406,7 @@ describe("issue execution policy routes", () => {
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
runId: "run-1",
runId: "55555555-5555-4555-8555-555555555555",
}))
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({
@@ -26,6 +26,7 @@ describe("isThrottleCandidateIssueRewake", () => {
const base = {
reason: "issue_assigned",
wakeCommentId: null,
requestedByActorType: "system" as const,
forceFreshSession: false,
hasExplicitResume: false,
};
@@ -38,12 +39,36 @@ describe("isThrottleCandidateIssueRewake", () => {
expect(isThrottleCandidateIssueRewake({ ...base, reason: "issue_graph_liveness_backstop" })).toBe(true);
});
it("never throttles wakes that carry new information or an explicit escalation", () => {
expect(isThrottleCandidateIssueRewake({ ...base, wakeCommentId: "comment-1" })).toBe(false);
it("keeps agent comments throttle-eligible without granting human comment privileges", () => {
expect(isThrottleCandidateIssueRewake({
...base,
reason: "issue_commented",
wakeCommentId: "comment-1",
requestedByActorType: "agent",
})).toBe(true);
expect(isThrottleCandidateIssueRewake({
...base,
reason: "issue_commented",
wakeCommentId: "comment-1",
requestedByActorType: "user",
})).toBe(false);
});
it("never throttles trusted explicit escalation wakes", () => {
expect(isThrottleCandidateIssueRewake({ ...base, forceFreshSession: true })).toBe(false);
expect(isThrottleCandidateIssueRewake({ ...base, hasExplicitResume: true })).toBe(false);
});
it("keeps agent-authored explicit resume comments throttle-eligible", () => {
expect(isThrottleCandidateIssueRewake({
...base,
reason: "issue_reopened_via_comment",
wakeCommentId: "comment-1",
requestedByActorType: "agent",
hasExplicitResume: true,
})).toBe(true);
});
it("passes event-shaped wake reasons through", () => {
for (const reason of [
"issue_commented",
@@ -147,6 +147,9 @@ describeEmbeddedPostgres("stale issue execution lock routes", () => {
executionAgentNameKey: "codexcoder",
executionLockedAt: new Date(),
});
await db.update(heartbeatRuns)
.set({ contextSnapshot: { issueId } })
.where(eq(heartbeatRuns.id, currentRunId));
const res = await request(createApp(agentActor(companyId, agentId, currentRunId)))
.patch(`/api/issues/${issueId}`)
@@ -290,6 +293,9 @@ describeEmbeddedPostgres("stale issue execution lock routes", () => {
executionAgentNameKey: "codexcoder",
executionLockedAt: new Date(),
});
await db.update(heartbeatRuns)
.set({ contextSnapshot: { issueId } })
.where(eq(heartbeatRuns.id, currentRunId));
const res = await request(createApp(agentActor(companyId, agentId, currentRunId)))
.patch(`/api/issues/${issueId}`)
@@ -16,13 +16,31 @@ const mockAgentService = vi.hoisted(() => ({
const mockTrackAgentTaskCompleted = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
const mockDbSelectWhere = vi.hoisted(() => vi.fn(() => ({
for: () => ({
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([{
id: "22222222-2222-4222-8222-222222222222",
companyId: "company-1",
agentId: "agent-1",
contextSnapshot: { issueId: "11111111-1111-4111-8111-111111111111" },
permissions: null,
}]).then(onFulfilled, onRejected),
}),
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([{ companyId: "company-1", permissions: null }]).then(onFulfilled, onRejected),
Promise.resolve([{
id: "22222222-2222-4222-8222-222222222222",
companyId: "company-1",
agentId: "agent-1",
contextSnapshot: { issueId: "11111111-1111-4111-8111-111111111111" },
permissions: null,
}]).then(onFulfilled, onRejected),
})));
const mockDbSelectFrom = vi.hoisted(() => vi.fn(() => ({ where: mockDbSelectWhere })));
const mockDbSelect = vi.hoisted(() => vi.fn(() => ({ from: mockDbSelectFrom })));
const mockDb = vi.hoisted(() => ({
select: mockDbSelect,
transaction: vi.fn(async (callback: (tx: { select: typeof mockDbSelect }) => Promise<unknown>) =>
callback({ select: mockDbSelect })),
}));
function registerModuleMocks() {
@@ -148,8 +166,24 @@ describe("issue telemetry routes", () => {
mockDbSelect.mockImplementation(() => ({ from: mockDbSelectFrom }));
mockDbSelectFrom.mockImplementation(() => ({ where: mockDbSelectWhere }));
mockDbSelectWhere.mockImplementation(() => ({
for: () => ({
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([{
id: "22222222-2222-4222-8222-222222222222",
companyId: "company-1",
agentId: "agent-1",
contextSnapshot: { issueId: "11111111-1111-4111-8111-111111111111" },
permissions: null,
}]).then(onFulfilled, onRejected),
}),
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([{ companyId: "company-1", permissions: null }]).then(onFulfilled, onRejected),
Promise.resolve([{
id: "22222222-2222-4222-8222-222222222222",
companyId: "company-1",
agentId: "agent-1",
contextSnapshot: { issueId: "11111111-1111-4111-8111-111111111111" },
permissions: null,
}]).then(onFulfilled, onRejected),
}));
});
@@ -166,7 +200,7 @@ describe("issue telemetry routes", () => {
type: "agent",
agentId: "agent-1",
companyId: "company-1",
runId: null,
runId: "22222222-2222-4222-8222-222222222222",
});
const res = await request(app)
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
+12
View File
@@ -64,6 +64,18 @@ describe("redaction", () => {
});
});
it("preserves authorization decision reasons in audit payloads", () => {
expect(redactEventPayload({
authorizationReason: "allow_scoped_agent_write",
authorization: "Bearer secret",
surface: "issue.comment.create",
})).toEqual({
authorizationReason: "allow_scoped_agent_write",
authorization: REDACTED_EVENT_VALUE,
surface: "issue.comment.create",
});
});
it("redacts common secret shapes from unstructured text", () => {
const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const githubToken = "ghp_1234567890abcdefghijklmnopqrstuvwxyz";
+7 -2
View File
@@ -4,6 +4,11 @@ const SECRET_FIELD_NAME_PATTERN =
String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|access[-_]?token|auth(?:_?token)?|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)[A-Za-z0-9_-]*`;
const SECRET_PAYLOAD_KEY_RE = new RegExp(SECRET_FIELD_NAME_PATTERN, "i");
// Authorization reasons are policy decision codes, not credentials. They must
// remain visible in audit receipts even though the field name contains
// "authorization". JWT-shaped values are still caught by the value guard below.
const AUDIT_REASON_PAYLOAD_KEY_RE = /^authorizationReason$/;
const AUDIT_SURFACE_PAYLOAD_KEY_RE = /^surface$/;
const COMMAND_PAYLOAD_KEY_RE =
/(^command$|^cmd$|command[-_]?line|resolved[-_]?command|PAPERCLIP_RESOLVED_COMMAND)/i;
const COMMAND_ARGS_PAYLOAD_KEY_RE = /^(commandArgs|command_?args|argv)$/i;
@@ -102,7 +107,7 @@ export function sanitizeRecord(record: Record<string, unknown>): Record<string,
redacted[key] = redactSensitiveText(value);
continue;
}
if (SECRET_PAYLOAD_KEY_RE.test(key)) {
if (SECRET_PAYLOAD_KEY_RE.test(key) && !AUDIT_REASON_PAYLOAD_KEY_RE.test(key)) {
if (isSecretRefBinding(value)) {
redacted[key] = sanitizeValue(value);
continue;
@@ -118,7 +123,7 @@ export function sanitizeRecord(record: Record<string, unknown>): Record<string,
redacted[key] = REDACTED_EVENT_VALUE;
continue;
}
if (typeof value === "string" && JWT_VALUE_RE.test(value)) {
if (typeof value === "string" && JWT_VALUE_RE.test(value) && !AUDIT_SURFACE_PAYLOAD_KEY_RE.test(key)) {
redacted[key] = REDACTED_EVENT_VALUE;
continue;
}
+156 -24
View File
@@ -201,6 +201,12 @@ import {
} from "../services/trust-preset-resolver.js";
import { externalObjectService } from "../services/external-objects.js";
import { deliverAgentUnblockNotification } from "../services/routable-blocked.js";
import {
crossIssueInfluenceLimitError,
crossIssueInfluenceRunContextError,
observeCrossIssueInfluence,
type CrossIssueInfluenceKind,
} from "../services/cross-issue-influence-limit.js";
const MAX_ISSUE_COMMENT_LIMIT = 500;
const updateIssueRouteSchema = updateIssueSchema.extend({
@@ -490,6 +496,35 @@ async function auditAgentIssueCreateAttributionSpoof(input: {
});
}
async function auditAgentIssueCommentAttributionSpoof(input: {
db: Db;
req: Request;
issue: { id: string; companyId: string; identifier: string | null };
surface: "issue.comment.create" | "issue.patch.comment";
requestedValue: string | null;
}) {
const actor = getActorInfo(input.req);
await logActivity(input.db, {
companyId: input.issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
responsibleUserIdOverride: authenticatedActorResponsibleUserId(input.req),
action: "issue.attribution_spoof_rejected",
entityType: "issue",
entityId: input.issue.id,
details: {
identifier: input.issue.identifier,
surface: input.surface,
field: "onBehalfOfUserId",
requestedValue: input.requestedValue,
derivedFrom: "authenticated_actor",
},
});
}
async function sanitizeIssueCreateAttribution<T extends object>(
db: Db,
req: Request,
@@ -535,7 +570,15 @@ async function sanitizeIssueCreateAttribution<T extends object>(
}
function authenticatedActorResponsibleUserId(req: Request) {
return req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : null;
return req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : undefined;
}
function issueWriteAuthorizationReason(
req: Request,
decision: true | { reason?: string | null },
) {
if (decision !== true && decision.reason) return decision.reason;
return req.actor.type === "agent" ? "allow_scoped_agent_write" : "allow_board_actor";
}
function readPlanConfirmationTargetForIssue(payload: unknown, issueId: string) {
@@ -1781,7 +1824,7 @@ function shouldHumanCommentResumeInProgressScheduledRetry(input: {
}
function isExplicitResumeCapableStatus(status: string | null | undefined) {
return status === "done" || status === "blocked" || status === "todo" || status === "in_progress";
return status === "done" || status === "cancelled" || status === "blocked" || status === "todo" || status === "in_progress";
}
// Log-class comment from the assignee agent on a terminal (done/cancelled)
@@ -2674,6 +2717,32 @@ export function issueRoutes(
return resolveActorSourceTrustForIssue({ db, issue, actor });
}
async function assertCrossIssueInfluenceWithinRunCap(
req: Request,
res: Response,
issue: { id: string; identifier?: string | null; companyId: string },
kind: CrossIssueInfluenceKind,
) {
if (req.actor.type !== "agent") return true;
if (!req.actor.agentId || !req.actor.runId) throw crossIssueInfluenceRunContextError();
// The counter transaction locks and validates the persisted run before it
// derives the source issue. Never trust the API-key run header by itself.
const decision = await observeCrossIssueInfluence(db, {
companyId: issue.companyId,
runId: req.actor.runId,
agentId: req.actor.agentId,
responsibleUserId: req.actor.onBehalfOfUserId ?? null,
targetIssueId: issue.id,
targetIssueIdentifier: issue.identifier ?? null,
kind,
});
if (!decision || decision.allowed) return true;
res.status(429).json(crossIssueInfluenceLimitError(decision));
return false;
}
function hasExplicitIssueWorkspaceCreateSelection(input: Record<string, unknown>) {
return input.parentId !== undefined ||
input.inheritExecutionWorkspaceFromIssueId !== undefined ||
@@ -4309,11 +4378,15 @@ export function issueRoutes(
async function assertExplicitResumeIntentAllowed(
req: Request,
res: Response,
issue: { id: string; companyId: string; status: string; assigneeAgentId: string | null },
issue: Parameters<typeof decideIssueAccess>[1],
options: { resumeIntent?: boolean } = {},
) {
if (await assertLowTrustControlPlaneDenied(req, res, issue.companyId, issue)) return false;
if (issue.status === "cancelled") {
// Structured resume intent is the sole comment surface that may revive a
// cancelled issue. Bare status transitions and `reopen` keep using the
// dedicated restore-flow guard.
if (issue.status === "cancelled" && options.resumeIntent !== true) {
res.status(409).json({
error: "Cancelled issues must be restored through the dedicated restore flow",
details: {
@@ -4378,6 +4451,8 @@ export function issueRoutes(
if (await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, issue.assigneeAgentId)) {
return true;
}
const boundaryDecision = await decideIssueAccess(req, issue, "issue:mutate");
if (isDefaultOpenIssueWriteDecision(boundaryDecision)) return true;
res.status(403).json({
error: "Agent cannot request follow-up for another agent's issue",
@@ -6104,7 +6179,7 @@ export function issueRoutes(
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
if (!(await assertAgentIssueMutationAllowed(req, res, issue, { allowVisibleIssueWrite: true }))) return;
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
@@ -7902,7 +7977,27 @@ export function issueRoutes(
const existing = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!existing) return;
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
if (!(await assertAgentIssueMutationAllowed(req, res, existing, { allowVisibleIssueWrite: true }))) return;
if (req.actor.type === "agent" && req.body.onBehalfOfUserId != null) {
await auditAgentIssueCommentAttributionSpoof({
db,
req,
issue: existing,
surface: "issue.patch.comment",
requestedValue: readNonEmptyString(req.body.onBehalfOfUserId),
});
res.status(422).json({ error: "Agent comments cannot set onBehalfOfUserId" });
return;
}
const issueMutationAccess = await assertAgentIssueMutationAllowed(
req,
res,
existing,
{ allowVisibleIssueWrite: true },
);
if (!issueMutationAccess) return;
const issueMutationAuthorizationReason = req.actor.type === "agent"
? issueWriteAuthorizationReason(req, await decideIssueAccess(req, existing, "issue:mutate"))
: issueWriteAuthorizationReason(req, true);
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, existing, req.body))) return;
const actor = getActorInfo(req);
@@ -7925,6 +8020,7 @@ export function issueRoutes(
resume: resumeRequested,
interrupt: interruptRequested,
hiddenAt: hiddenAtRaw,
onBehalfOfUserId: _requestedOnBehalfOfUserId,
...updateFields
} = req.body;
const shouldCancelActiveRunForCancelledStatus =
@@ -7941,7 +8037,10 @@ export function issueRoutes(
) {
return;
}
if (resumeRequested === true && !(await assertExplicitResumeIntentAllowed(req, res, existing))) return;
if (
resumeRequested === true &&
!(await assertExplicitResumeIntentAllowed(req, res, existing, { resumeIntent: true }))
) return;
const agentStatusTransitionRequiresResumeAuthority =
req.actor.type === "agent" &&
typeof updateFields.status === "string" &&
@@ -8031,12 +8130,21 @@ export function issueRoutes(
let interruptedRunId: string | null = null;
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing);
const isAgentWorkUpdate =
req.actor.type === "agent" && (Object.keys(updateFields).length > 0 || reviewRequest !== undefined);
req.actor.type === "agent" &&
(Object.keys(updateFields).length > 0 || reviewRequest !== undefined || hiddenAtRaw !== undefined);
if (closedExecutionWorkspace && (commentBody || isAgentWorkUpdate)) {
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);
return;
}
if (
isAgentWorkUpdate &&
!(await assertCrossIssueInfluenceWithinRunCap(req, res, existing, "update"))
) return;
if (
commentBody &&
!(await assertCrossIssueInfluenceWithinRunCap(req, res, existing, "comment"))
) return;
if (interruptRequested) {
if (!commentBody) {
@@ -8476,18 +8584,14 @@ export function issueRoutes(
logger.warn({ err, runId: actor.runId }, "failed to clear detached run warning after issue activity"));
}
// Build activity details with previous values for changed fields
const previous: Record<string, unknown> = {};
for (const key of Object.keys(updateFields)) {
if (key in existing && (existing as Record<string, unknown>)[key] !== (updateFields as Record<string, unknown>)[key]) {
previous[key] = (existing as Record<string, unknown>)[key];
}
}
if (Array.isArray(req.body.blockedByIssueIds)) {
previous.blockedByIssueIds = existingRelations?.blockedBy.map((relation) => relation.id) ?? [];
}
const hasFieldChanges = Object.keys(previous).length > 0;
// Use the service's row-lock-backed receipt as the activity source of truth.
// Requested fields alone miss server-side effects such as cleared run locks,
// status timestamps, goal fallback, and normalized relation arrays.
const issueChanges = issue.changes ?? {};
const previous: Record<string, unknown> = Object.fromEntries(
Object.entries(issueChanges).map(([key, change]) => [key, change.from]),
);
const hasFieldChanges = Object.keys(issueChanges).length > 0;
let workspaceChange = null;
if (hasIssueWorkspaceAuditChange(previous)) {
try {
@@ -8546,12 +8650,15 @@ export function issueRoutes(
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
responsibleUserIdOverride: authenticatedActorResponsibleUserId(req),
action: "issue.updated",
entityType: "issue",
entityId: issue.id,
details: {
...updateFields,
identifier: issue.identifier,
authorizationReason: issueMutationAuthorizationReason,
changes: issueChanges,
...(commentBody ? { source: "comment" } : {}),
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
@@ -8786,7 +8893,9 @@ export function issueRoutes(
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
runId: actor.runId,
onBehalfOfUserId: authenticatedActorResponsibleUserId(req),
}, {
authorizationReason: issueMutationAuthorizationReason,
sourceTrust: await sourceTrustForActorWrite(issue, actor),
});
await issueReferencesSvc.syncComment(comment.id);
@@ -8811,6 +8920,7 @@ export function issueRoutes(
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
responsibleUserIdOverride: authenticatedActorResponsibleUserId(req),
action: "issue.comment_added",
entityType: "issue",
entityId: issue.id,
@@ -8819,6 +8929,7 @@ export function issueRoutes(
bodySnippet: comment.body.slice(0, 120),
identifier: issue.identifier,
issueTitle: issue.title,
authorizationReason: issueMutationAuthorizationReason,
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
...(scheduledRetrySupersededByComment
@@ -9513,7 +9624,7 @@ export function issueRoutes(
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (req.actor.type === "agent") {
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
if (!(await assertAgentIssueMutationAllowed(req, res, issue, { allowVisibleIssueWrite: true }))) return;
if (await assertLowTrustControlPlaneDenied(req, res, issue.companyId, issue)) return;
} else {
assertBoard(req);
@@ -10280,8 +10391,20 @@ export function issueRoutes(
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (req.actor.type === "agent" && req.body.onBehalfOfUserId != null) {
await auditAgentIssueCommentAttributionSpoof({
db,
req,
issue,
surface: "issue.comment.create",
requestedValue: readNonEmptyString(req.body.onBehalfOfUserId),
});
res.status(422).json({ error: "Agent comments cannot set onBehalfOfUserId" });
return;
}
const commentAccessDecision = await assertAgentIssueCommentAllowed(req, res, issue);
if (!commentAccessDecision) return;
const commentAuthorizationReason = issueWriteAuthorizationReason(req, commentAccessDecision);
if (!assertStructuredCommentFieldsAllowed(req, res, {
presentation: req.body.presentation,
metadata: req.body.metadata,
@@ -10319,9 +10442,12 @@ export function issueRoutes(
issue.assigneeAgentId !== req.actor.agentId &&
!crossIssueCommentOnlyGrant
) {
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
if (!(await assertAgentIssueMutationAllowed(req, res, issue, { allowVisibleIssueWrite: true }))) return;
}
if (effectiveResumeRequested === true && !(await assertExplicitResumeIntentAllowed(req, res, issue))) return;
if (
effectiveResumeRequested === true &&
!(await assertExplicitResumeIntentAllowed(req, res, issue, { resumeIntent: true }))
) return;
if (effectiveResumeRequested !== true && effectiveReopenRequested === true && req.actor.type === "agent") {
if (!(await assertExplicitResumeIntentAllowed(req, res, issue))) return;
}
@@ -10367,6 +10493,7 @@ export function issueRoutes(
res.status(409).json({ error: "Issue follow-up blocked by unresolved blockers" });
return;
}
if (!(await assertCrossIssueInfluenceWithinRunCap(req, res, issue, "comment"))) return;
let reopened = false;
let reopenFromStatus: string | null = null;
let interruptedRunId: string | null = null;
@@ -10525,8 +10652,9 @@ export function issueRoutes(
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
runId: actor.runId,
onBehalfOfUserId: authenticatedActorResponsibleUserId(req),
},
commentOptions,
{ ...commentOptions, authorizationReason: commentAuthorizationReason },
tx,
);
const updated = actor.actorType === "user" && currentIssue.status !== "done"
@@ -10597,10 +10725,12 @@ export function issueRoutes(
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
runId: actor.runId,
onBehalfOfUserId: authenticatedActorResponsibleUserId(req),
}, {
authorType: req.body.authorType ?? (actor.actorType === "agent" ? "agent" : "user"),
presentation: commentPresentation,
metadata: req.body.metadata ?? null,
authorizationReason: commentAuthorizationReason,
sourceTrust: await sourceTrustForActorWrite(currentIssue, actor),
});
}
@@ -10625,6 +10755,7 @@ export function issueRoutes(
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
responsibleUserIdOverride: authenticatedActorResponsibleUserId(req),
action: "issue.comment_added",
entityType: "issue",
entityId: currentIssue.id,
@@ -10633,6 +10764,7 @@ export function issueRoutes(
bodySnippet: comment.body.slice(0, 120),
identifier: currentIssue.identifier,
issueTitle: currentIssue.title,
authorizationReason: commentAuthorizationReason,
...(isDirectParentReportDecision(commentAccessDecision)
? { directParentReportGrant: true }
: {}),
@@ -0,0 +1,186 @@
import { and, count, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { activityLog, heartbeatRuns } from "@paperclipai/db";
import { isUuidLike } from "@paperclipai/shared";
import { forbidden } from "../errors.js";
import { logger } from "../middleware/logger.js";
export const CROSS_ISSUE_INFLUENCE_LIMIT = 20;
export const CROSS_ISSUE_INFLUENCE_ENFORCE_AT = new Date("2026-08-11T00:00:00.000Z");
const CROSS_ISSUE_INFLUENCE_ACTIVITY = "issue.cross_issue_influence_observed";
const CROSS_ISSUE_INFLUENCE_REJECTED_ACTIVITY = "issue.cross_issue_influence_cap_rejected";
export type CrossIssueInfluenceKind = "comment" | "update";
export type CrossIssueInfluenceDecision = {
allowed: boolean;
mode: "log_only" | "enforce";
count: number;
cap: number;
enforceAt: string;
};
export function crossIssueInfluenceRunContextError() {
return forbidden(
"Agent issue comments and updates require a valid heartbeat run so cross-issue influence can be contained",
{ code: "cross_issue_influence_run_context_required" },
);
}
function readRunSourceIssueId(contextSnapshot: unknown) {
if (!contextSnapshot || typeof contextSnapshot !== "object" || Array.isArray(contextSnapshot)) return null;
const context = contextSnapshot as Record<string, unknown>;
for (const candidate of [context.issueId, context.taskId]) {
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
}
return null;
}
export function evaluateCrossIssueInfluenceLimit(input: {
priorCount: number;
now?: Date;
}): CrossIssueInfluenceDecision {
const now = input.now ?? new Date();
const mode = now >= CROSS_ISSUE_INFLUENCE_ENFORCE_AT ? "enforce" : "log_only";
const nextCount = input.priorCount + 1;
return {
allowed: mode === "log_only" || nextCount <= CROSS_ISSUE_INFLUENCE_LIMIT,
mode,
count: nextCount,
cap: CROSS_ISSUE_INFLUENCE_LIMIT,
enforceAt: CROSS_ISSUE_INFLUENCE_ENFORCE_AT.toISOString(),
};
}
/**
* Atomically observes one cross-issue influence attempt for a heartbeat run.
*
* Locking the run row serializes concurrent attempts from the same run. The
* observation is intentionally recorded before the route mutation: once the
* rollout reaches enforcement, failures cannot be used to race or probe past
* the fail-closed backstop.
*/
export async function observeCrossIssueInfluence(
db: Db,
input: {
companyId: string;
runId: string;
agentId: string;
responsibleUserId?: string | null;
targetIssueId: string;
targetIssueIdentifier?: string | null;
kind: CrossIssueInfluenceKind;
now?: Date;
},
): Promise<CrossIssueInfluenceDecision | null> {
// API-key callers control the run header. Reject malformed UUIDs before the
// database can turn an untrusted identifier into a PostgreSQL cast error.
if (!isUuidLike(input.runId)) throw crossIssueInfluenceRunContextError();
return db.transaction(async (tx) => {
const run = await tx
.select({
id: heartbeatRuns.id,
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
responsibleUserId: heartbeatRuns.responsibleUserId,
contextSnapshot: heartbeatRuns.contextSnapshot,
})
.from(heartbeatRuns)
.where(and(
eq(heartbeatRuns.id, input.runId),
eq(heartbeatRuns.companyId, input.companyId),
eq(heartbeatRuns.agentId, input.agentId),
))
.for("update")
.then((rows) => rows[0] ?? null);
if (
!run ||
run.companyId !== input.companyId ||
run.agentId !== input.agentId
) {
throw crossIssueInfluenceRunContextError();
}
const sourceIssueId = readRunSourceIssueId(run.contextSnapshot);
if (!sourceIssueId) throw crossIssueInfluenceRunContextError();
if (
sourceIssueId === input.targetIssueId ||
(input.targetIssueIdentifier && sourceIssueId.toUpperCase() === input.targetIssueIdentifier.toUpperCase())
) {
return null;
}
const priorCount = await tx
.select({ count: count() })
.from(activityLog)
.where(and(
eq(activityLog.companyId, input.companyId),
eq(activityLog.runId, input.runId),
eq(activityLog.action, CROSS_ISSUE_INFLUENCE_ACTIVITY),
))
.then((rows) => Number(rows[0]?.count ?? 0));
const decision = evaluateCrossIssueInfluenceLimit({ priorCount, now: input.now });
await tx.insert(activityLog).values({
companyId: input.companyId,
actorType: "agent",
actorId: input.agentId,
agentId: input.agentId,
runId: input.runId,
responsibleUserId: input.responsibleUserId ?? run.responsibleUserId ?? null,
action: decision.allowed
? CROSS_ISSUE_INFLUENCE_ACTIVITY
: CROSS_ISSUE_INFLUENCE_REJECTED_ACTIVITY,
entityType: "issue",
entityId: input.targetIssueId,
details: {
kind: input.kind,
sourceIssueId,
targetIssueId: input.targetIssueId,
targetIssueIdentifier: input.targetIssueIdentifier ?? null,
count: decision.count,
cap: decision.cap,
mode: decision.mode,
enforceAt: decision.enforceAt,
allowed: decision.allowed,
},
});
const logContext = {
event: "cross_issue_influence_cap",
companyId: input.companyId,
runId: input.runId,
agentId: input.agentId,
sourceIssueId,
targetIssueId: input.targetIssueId,
kind: input.kind,
count: decision.count,
cap: decision.cap,
mode: decision.mode,
enforceAt: decision.enforceAt,
allowed: decision.allowed,
};
if (decision.allowed) {
logger.info(logContext, "cross-issue influence observed");
} else {
logger.warn(logContext, "cross-issue influence cap exceeded");
}
return decision;
});
}
export function crossIssueInfluenceLimitError(decision: CrossIssueInfluenceDecision) {
return {
error: `Cross-issue influence cap exceeded: this run is limited to ${decision.cap} cross-issue comments or updates`,
details: {
code: "cross_issue_influence_cap_exceeded",
cap: decision.cap,
count: decision.count,
mode: decision.mode,
enforceAt: decision.enforceAt,
},
};
}
+24 -23
View File
@@ -1982,30 +1982,31 @@ export function executionWorkspaceService(db: Db) {
sourceIssueStatusChanged = sourceBefore.status !== updatedIssue.status;
}
const [auditComment] = await tx
.insert(issueComments)
.values({
companyId: lockedWorkspace.companyId,
issueId: lockedWorkspace.sourceIssueId,
authorAgentId: input.actor.actorType === "agent" ? input.actor.agentId : null,
authorUserId: input.actor.actorType === "user" ? input.actor.actorId : null,
// Keep all actor-authored comments on the central attribution path so
// an agent reconcile records its signed responsible user and policy
// reason just like comments written through the issue routes.
const { issueService } = await import("./issues.js");
const auditComment = await issueService(txDb).addComment(
lockedWorkspace.sourceIssueId,
formatBranchReconcileAuditComment({
mode: input.mode,
reason,
workspaceId: existing.id,
inspection,
recoveryActionId: recoveryAction?.id ?? null,
rescueRef,
}),
{
agentId: input.actor.actorType === "agent" ? input.actor.agentId ?? undefined : undefined,
userId: input.actor.actorType === "user" ? input.actor.actorId : undefined,
runId: input.actor.runId,
},
{
authorType: input.actor.actorType,
createdByRunId: input.actor.runId,
body: formatBranchReconcileAuditComment({
mode: input.mode,
reason,
workspaceId: existing.id,
inspection,
recoveryActionId: recoveryAction?.id ?? null,
rescueRef,
}),
})
.returning({ id: issueComments.id });
await tx
.update(issues)
.set({ updatedAt: now })
.where(eq(issues.id, lockedWorkspace.sourceIssueId));
authorizationReason: "execution_workspace_branch_reconcile",
},
tx,
);
return {
workspace: toExecutionWorkspace(updatedRow, lockedRuntimeServices),
+8
View File
@@ -17822,6 +17822,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
isThrottleCandidateIssueRewake({
reason,
wakeCommentId: wakeCommentId ?? null,
requestedByActorType: opts.requestedByActorType ?? null,
forceFreshSession: enrichedContextSnapshot.forceFreshSession === true,
hasExplicitResume: Boolean(explicitResumeSession),
})
@@ -17872,6 +17873,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
eq(activityLog.entityId, issue.id),
gt(activityLog.createdAt, lastRunFinishedAt),
inArray(activityLog.action, ISSUE_NEW_INPUT_ACTIVITY_ACTIONS),
wakeCommentId && opts.requestedByActorType === "agent"
? ne(activityLog.actorType, "agent")
: undefined,
),
)
.limit(1)
@@ -17885,6 +17889,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
.map((row) => row.runId)
.filter((runId): runId is string => Boolean(runId)),
),
// For an agent comment wake, the query excludes agent-authored
// activity while preserving genuinely new user/system input.
// Presentation/author metadata therefore cannot smuggle human
// wake privilege, nor can it mask an actual human response.
hasNewIssueInputSinceLastRun: newInputRows.length > 0,
});
+10 -5
View File
@@ -12,9 +12,11 @@
* This module decides when such a wake should be skipped: once an issue has
* accumulated a streak of consecutive succeeded-but-no-issue-progress runs by
* the same agent, further event-free wakes are held back for an escalating
* cooldown anchored to the last run's finish time. Any genuinely new input —
* a comment wake, fresh issue activity, an explicit resume, forceFreshSession,
* or an event-carrying wake reason — bypasses the throttle entirely.
* cooldown anchored to the last run's finish time. Fresh issue activity, an
* explicit resume, forceFreshSession, and event-carrying wake reasons bypass
* the throttle. Human comment wakes also bypass it. Agent-authored comment
* wakes deliberately stay in the normal throttle class so a cross-issue write
* cannot smuggle human wake privileges.
*
* Server-side recovery retries (process-loss retries, missing-comment
* follow-ups) insert their runs directly and never pass through this gate, so
@@ -98,6 +100,7 @@ export const ISSUE_NEW_INPUT_ACTIVITY_ACTIONS: string[] = [
export interface IssueRewakeCandidateInput {
reason: string | null;
wakeCommentId: string | null;
requestedByActorType?: "user" | "agent" | "system" | null;
forceFreshSession: boolean;
hasExplicitResume: boolean;
}
@@ -108,8 +111,10 @@ export interface IssueRewakeCandidateInput {
*/
export function isThrottleCandidateIssueRewake(input: IssueRewakeCandidateInput): boolean {
if (input.forceFreshSession) return false;
if (input.wakeCommentId) return false;
if (input.hasExplicitResume) return false;
// Explicit resume is an operator privilege, not an actor-class escape hatch.
// Agent-authored resume comments remain subject to the normal rewake throttle.
if (input.hasExplicitResume && input.requestedByActorType !== "agent") return false;
if (input.wakeCommentId) return input.requestedByActorType === "agent";
if (input.reason === null) return true;
return THROTTLED_ISSUE_REWAKE_REASONS.has(input.reason);
}
+55 -2
View File
@@ -392,6 +392,40 @@ async function resolveCommentCreatedByRunId(
return existing?.id ?? null;
}
async function resolveCommentResponsibleUserId(
dbOrTx: any,
companyId: string,
createdByRunId: string | null,
actorResponsibleUserId: string | null | undefined,
): Promise<string | null> {
const actorValue = actorResponsibleUserId?.trim() || null;
if (actorValue) return actorValue;
if (!createdByRunId) return null;
return dbOrTx
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.id, createdByRunId), eq(heartbeatRuns.companyId, companyId)))
.then((rows: Array<{ responsibleUserId: string | null }>) => rows[0]?.responsibleUserId?.trim() || null);
}
function withAgentCommentAuthorizationMetadata(
metadata: IssueCommentMetadata | null,
authorizationReason: string | null | undefined,
): IssueCommentMetadata {
const reason = authorizationReason?.trim() || "internal_agent_write";
return {
version: 1,
...(metadata?.sourceRunId !== undefined ? { sourceRunId: metadata.sourceRunId } : {}),
authorizationReason: reason,
sections: metadata?.sections.length
? metadata.sections
: [{
title: "Authorization",
rows: [{ type: "key_value", label: "Reason", value: reason }],
}],
};
}
/**
* Best-effort agent attribution for comments whose stored author is a non-human
* sentinel (e.g. `local-board`). Callers MUST pre-filter `comments` to drop any
@@ -8210,11 +8244,17 @@ export function issueService(db: Db) {
addComment: async (
issueId: string,
body: string,
actor: { agentId?: string; userId?: string; runId?: string | null },
actor: {
agentId?: string;
userId?: string;
runId?: string | null;
onBehalfOfUserId?: string | null;
},
options?: {
authorType?: IssueCommentAuthorType | null;
presentation?: IssueCommentPresentation | null;
metadata?: IssueCommentMetadata | null;
authorizationReason?: string | null;
sourceTrust?: typeof issueComments.$inferInsert.sourceTrust;
createdAt?: Date | string | null;
},
@@ -8237,7 +8277,6 @@ export function issueService(db: Db) {
);
assertIssueCommentAuthorTypeAllowed(actor, authorType);
const presentation = issueCommentPresentationSchema.nullable().parse(options?.presentation ?? null);
const metadata = issueCommentMetadataSchema.nullable().parse(options?.metadata ?? null);
const createdAt = options?.createdAt ? new Date(options.createdAt) : null;
// Invalid/stale run ids must not 500 the insert — null out unknowns.
const createdByRunId = await resolveCommentCreatedByRunId(dbOrTx, issue.companyId, actor.runId);
@@ -8247,6 +8286,19 @@ export function issueService(db: Db) {
"dropping invalid createdByRunId for issue comment insert",
);
}
const onBehalfOfUserId = actor.agentId
? await resolveCommentResponsibleUserId(
dbOrTx,
issue.companyId,
createdByRunId,
actor.onBehalfOfUserId,
)
: null;
const metadata = issueCommentMetadataSchema.nullable().parse(
actor.agentId
? withAgentCommentAuthorizationMetadata(options?.metadata ?? null, options?.authorizationReason)
: options?.metadata ?? null,
);
const [comment] = await dbOrTx
.insert(issueComments)
.values({
@@ -8254,6 +8306,7 @@ export function issueService(db: Db) {
issueId,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
onBehalfOfUserId,
authorType,
createdByRunId,
body: redactedBody,
+98 -17
View File
@@ -43,6 +43,7 @@ interface TestContext {
}
interface IssueRunLockState {
companyId: string;
assigneeAgentId: string | null;
checkoutRunId: string | null;
executionRunId: string | null;
@@ -57,11 +58,64 @@ async function createAgentRequest(token: string): Promise<APIRequestContext> {
}
/** Invoke a heartbeat run for an agent, returning the run ID. */
async function invokeHeartbeat(board: APIRequestContext, agentId: string): Promise<string> {
const res = await board.post(`${BASE_URL}/api/agents/${agentId}/heartbeat/invoke`);
async function invokeHeartbeat(
board: APIRequestContext,
agentId: string,
issueId: string,
): Promise<string> {
const res = await board.post(`${BASE_URL}/api/agents/${agentId}/heartbeat/invoke`, {
data: {
reason: "issue_assigned",
payload: { issueId, taskId: issueId, taskKey: issueId },
},
});
expect(res.ok()).toBe(true);
const run = await res.json();
return run.id;
if (typeof run.id === "string" && run.id.length > 0) return run.id;
// A stage transition can already be replacing the previous executor's run
// with the participant's queued run. If the legacy invoke is skipped and
// that run has already released the issue lock, recover it from the agent's
// recent run receipts.
const deadline = Date.now() + 3_000;
do {
const issueRunLock = await getIssueRunLockState(board, issueId);
if (issueRunLock.assigneeAgentId !== agentId) {
// Negative authorization cases intentionally invoke a non-participant.
// Preserve the server rejection instead of waiting for a run that must
// never be assigned to that agent.
return issueRunLock.executionRunId ?? issueRunLock.checkoutRunId ?? "";
}
const candidates = new Set<string>([
run.executionRunId,
issueRunLock.executionRunId,
issueRunLock.checkoutRunId,
].filter((candidate): candidate is string => Boolean(candidate)));
const recentRunsRes = await board.get(
`${BASE_URL}/api/companies/${issueRunLock.companyId}/heartbeat-runs?agentId=${agentId}&limit=20`,
);
if (recentRunsRes.ok()) {
const recentRuns = await recentRunsRes.json();
for (const recentRun of Array.isArray(recentRuns) ? recentRuns : []) {
if (typeof recentRun.id === "string") candidates.add(recentRun.id);
}
}
for (const candidate of candidates) {
const runRes = await board.get(`${BASE_URL}/api/heartbeat-runs/${candidate}`);
if (!runRes.ok()) continue;
const candidateRun = await runRes.json();
const context = candidateRun.contextSnapshot ?? {};
if (
candidateRun.agentId === agentId &&
(context.issueId === issueId || context.taskId === issueId)
) {
return candidate;
}
}
await new Promise((resolve) => setTimeout(resolve, 50));
} while (Date.now() < deadline);
throw new Error(`No issue-bound heartbeat run became available for agent ${agentId}`);
}
async function getIssueRunLockState(board: APIRequestContext, issueId: string): Promise<IssueRunLockState> {
@@ -69,6 +123,7 @@ async function getIssueRunLockState(board: APIRequestContext, issueId: string):
expect(res.ok()).toBe(true);
const issue = await res.json();
return {
companyId: issue.companyId,
assigneeAgentId: issue.assigneeAgentId ?? null,
checkoutRunId: issue.checkoutRunId ?? null,
executionRunId: issue.executionRunId ?? null,
@@ -81,19 +136,22 @@ async function retryAgentPatchWithCurrentLockOnConflict(
issueId: string,
failedRes: Awaited<ReturnType<APIRequestContext["patch"]>>,
patchData: Record<string, unknown>,
fallbackRunId: string,
) {
if (failedRes.status() !== 409) return failedRes;
const issueRunLock = await getIssueRunLockState(board, issueId);
if (issueRunLock.assigneeAgentId !== agent.agentId) return failedRes;
let res = failedRes;
for (let attempt = 0; attempt < 8 && res.status() === 409; attempt++) {
await new Promise((resolve) => setTimeout(resolve, Math.min(500, 50 * 2 ** attempt)));
const issueRunLock = await getIssueRunLockState(board, issueId);
if (issueRunLock.assigneeAgentId !== agent.agentId) return res;
const lockedRunId = issueRunLock.checkoutRunId ?? issueRunLock.executionRunId;
if (!lockedRunId) return failedRes;
const retryRes = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
headers: { "X-Paperclip-Run-Id": lockedRunId },
data: patchData,
});
return retryRes.ok() ? retryRes : failedRes;
const lockedRunId = issueRunLock.checkoutRunId ?? issueRunLock.executionRunId ?? fallbackRunId;
res = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
headers: { "X-Paperclip-Run-Id": lockedRunId },
data: patchData,
});
}
return res;
}
/**
@@ -126,7 +184,7 @@ async function agentPatch(
maxBackoffMs = 500,
}: { maxAttempts?: number; backoffMs?: number; maxBackoffMs?: number } = {},
) {
const runId = await invokeHeartbeat(board, agent.agentId);
const runId = await invokeHeartbeat(board, agent.agentId, issueId);
const patchWith = (patchRunId: string) =>
agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
headers: { "X-Paperclip-Run-Id": patchRunId },
@@ -154,7 +212,7 @@ async function agentCheckoutAndPatch(
expectedStatuses: string[],
patchData: Record<string, unknown>,
) {
const runId = await invokeHeartbeat(board, agent.agentId);
const runId = await invokeHeartbeat(board, agent.agentId, issueId);
const directPatchRes = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
headers: { "X-Paperclip-Run-Id": runId },
data: patchData,
@@ -168,7 +226,14 @@ async function agentCheckoutAndPatch(
});
if (!checkoutRes.ok()) {
if (checkoutRes.status() === 409) {
const res = await retryAgentPatchWithCurrentLockOnConflict(board, agent, issueId, checkoutRes, patchData);
const res = await retryAgentPatchWithCurrentLockOnConflict(
board,
agent,
issueId,
checkoutRes,
patchData,
runId,
);
if (res.ok()) {
return res;
}
@@ -192,7 +257,23 @@ async function agentCheckoutAndPatch(
headers: { "X-Paperclip-Run-Id": runId },
data: patchData,
});
return retryAgentPatchWithCurrentLockOnConflict(board, agent, issueId, res, patchData);
const retried = await retryAgentPatchWithCurrentLockOnConflict(
board,
agent,
issueId,
res,
patchData,
runId,
);
if (retried.status() !== 409) return retried;
// A no-op process adapter can replace and release the executor's lock faster
// than an agent-authored retry can adopt it. This flow already permits a
// board fallback when checkout loses that race; apply the same fallback when
// the post-checkout PATCH exhausts its bounded lock retries.
const issueRunLock = await getIssueRunLockState(board, issueId);
if (issueRunLock.assigneeAgentId !== agent.agentId) return retried;
return board.patch(`${BASE_URL}/api/issues/${issueId}`, { data: patchData });
}
async function setupCompany(boardRequest: APIRequestContext): Promise<TestContext> {