mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
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:
1 parent
ded813ad6f
commit
f91a6e27c0
34 files changed
+40023
-139
No files matched your search
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user