mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 03:08:10 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Low-trust review contains work that may include hostile input. > - Its default intake boundary currently blocks direct human chat and tasks outside that boundary. > - Human direction should authorize the assigned work while preserving containment. > - Existing conversations and execution requests already identify direct human instructions. > - This pull request derives exact-task authority from those records and the current assignee. > - The agent can perform that work without gaining access to unrelated tasks or privileged tools. ## Linked Issues or Issue Description **What happened?** A low-trust agent with a project boundary rejects its owner's direct Agent Chat before provider execution. Human-assigned tasks outside that project fail the same check. **Expected behavior** An authorized human can talk to the agent or assign it a task. The exact task runs with its existing sandbox, credential, and tool restrictions. **Steps to reproduce** 1. Enable Agent Chat and isolated workspaces. Configure a sandbox agent with low-trust review scoped to an intake project. 2. Send the agent a direct board chat message, or assign it a projectless task. 3. Observe `low_trust_boundary_mismatch` before execution. Related: #14766 adds private task directories for repo-free low-trust execution. It is now merged into master and included in the branch base, so CI and staging verify the combined behavior. ## What Changed - Derive owner-chat access from existing conversation identity. - Derive exact-task access from the existing human requester and server-owned request origin, including coalesced requests. Plugin and external sender attribution do not authorize work. - Follow existing `retryOfRunId` database links for automatic continuations, checking company, agent, and task throughout; cancelled ancestors cannot grant authority. - Require a live run and current assignment. Preserve sandbox, credential, privileged-tool, responsible-user, and quarantined-output checks. - Retain board backlog assignments in existing request records without starting execution. Reassignment cancels old human requests in the common service transaction, including plugin writes; late settlement cannot revive them. - Add real database and HTTP coverage for request provenance, retry ancestry, cancelled runs, concurrent reassignment, spoofing, and containment. Document the rule. - Preserve legacy board assignment requests through their existing source, reason, and human requester. - Use the existing wrapped-error helper for concurrent chat-question idempotency; a deterministic race test reproduces the CI failure before the fix and passes after it. - No new schema, migrations, or user-identity fields. Existing requester columns hold attribution. ## Verification - Passed the focused database, policy-retention, HTTP, and reassignment tests locally. The HTTP test creates a task through the real board route and checks the resulting persisted wakeup before exercising agent reads, comments, mutations, and review handoff. - Database tests hold a reassignment transaction open to verify coherent authorization before and after commit, with a two-connection pool. They cover retries, coalesced requests, cancelled ancestry, invalid cross-company/agent/task links, cycles, and forged attribution. - Full local `pnpm -r typecheck` and `pnpm build` passed on the final commit (`d107c26df`). [Latest-head CI](https://github.com/paperclipai/paperclip/actions/runs/36815589542) passed: 54 successful checks, two expected skips, including all eight browser-test shards. Greptile is 5/5 on this exact commit with no unresolved threads. Local tests were targeted; the full test suite ran through CI’s test matrix. - The revised HTTP suite passed all 13 tests; database authorization tests passed all 11, including legacy compatibility and late watchdog settlement; the backlog route contract passed all 3 tests. Another 102 tests covering durable chat admission, wake queues, and Cursor execution passed. - All 90 interaction-service tests passed with both create calls deliberately held until their optimistic reads complete, forcing duplicate-key recovery. That forced race failed before switching to the shared wrapped-error helper. - Previous staging proof covered owner chat and projectless task persistence. The simplified revision has not been redeployed; that earlier proof is not claimed for the new implementation. ## Risks - This is an authorization change: only the live run's exact task qualifies, and normal responsible-user restrictions still apply. - Existing request and retry records are authoritative. Merely naming a responsible/originating user or an external connector sender does not qualify. - Reassignment invalidates existing human request records transactionally. A cancelled run or request cannot regain authority when the task is assigned back. - Ordinary task exceptions require server-owned origin or the legacy board assignment source/reason/actor combination. Existing owner chats use conversation identity. ## Model Used OpenAI GPT-6 in Codex, with reasoning, repository tools, code execution, and browser testing. The runtime does not expose a more specific model ID or context-window size. ## 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>
269 lines
19 KiB
TypeScript
269 lines
19 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { agents, companies, createDb, heartbeatRuns, agentWakeupRequests, issues, projects } from "@paperclipai/db";
|
|
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { createPostgresWatchdogAdapter } from "../modules/active-run-watchdog/adapters/postgres.js";
|
|
import { issueService } from "../services/issues.js";
|
|
import { authorizationService } from "../services/authorization.js";
|
|
import { resolveAndRetainRunTrustPreset } from "../services/run-trust-preset.js";
|
|
import { assertLowTrustWorkspaceIsolation } from "../services/low-trust-runtime-containment.js";
|
|
import { retainBacklogHumanAssignment, withHumanDirectedWork } from "../services/human-directed-work.js";
|
|
import { resolveCoreTrustPreset } from "../services/trust-preset-resolver.js";
|
|
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
(support.supported ? describe : describe.skip)("human-directed low-trust work", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
beforeAll(async () => {
|
|
database = await startEmbeddedPostgresTestDatabase("paperclip-human-work-");
|
|
db = createDb(database.connectionString, { maxConnections: 2 });
|
|
}, 30_000);
|
|
afterAll(async () => { await database?.cleanup(); }, 60_000);
|
|
|
|
async function seed() {
|
|
const companyId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: companyId, issuePrefix: companyId.slice(0, 8) });
|
|
const [project] = await db.insert(projects).values({ companyId, name: "Contained intake" }).returning();
|
|
const [agent] = await db.insert(agents).values({
|
|
companyId, name: "Contained agent", role: "engineer", adapterType: "paperclip_runner",
|
|
permissions: { authorizationPolicy: { trustPreset: LOW_TRUST_REVIEW_PRESET,
|
|
trustBoundary: { mode: LOW_TRUST_REVIEW_PRESET, companyId, projectIds: [project.id] } } },
|
|
}).returning();
|
|
const [other] = await db.insert(agents).values({ companyId, name: "Other", role: "engineer" }).returning();
|
|
return { companyId, agent, other };
|
|
}
|
|
async function task(f: Awaited<ReturnType<typeof seed>>) {
|
|
return issueService(db).create(f.companyId, {
|
|
title: `Direct task ${randomUUID()}`, assigneeAgentId: f.agent.id,
|
|
createdByUserId: "owner", responsibleUserId: "owner",
|
|
});
|
|
}
|
|
async function execution(f: Awaited<ReturnType<typeof seed>>, issue: Awaited<ReturnType<typeof task>>,
|
|
options: { human?: boolean; context?: Record<string, unknown>; retryOfRunId?: string; status?: string } = {}) {
|
|
const [wake] = await db.insert(agentWakeupRequests).values({ companyId: f.companyId, agentId: f.agent.id,
|
|
source: options.human ? "assignment" : "automation", status: "claimed",
|
|
requestedByActorType: options.human ? "user" : "system", requestedByActorId: options.human ? "owner" : null,
|
|
payload: { issueId: issue.id, _paperclipWakeContext: { source: "issue.update" } } }).returning();
|
|
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agent.id,
|
|
status: options.status ?? "running", responsibleUserId: "owner", wakeupRequestId: wake.id,
|
|
retryOfRunId: options.retryOfRunId, contextSnapshot: { issueId: issue.id, ...options.context } }).returning();
|
|
await db.update(agentWakeupRequests).set({ runId: run.id }).where(eq(agentWakeupRequests.id, wake.id));
|
|
return { run, wake, actor: { type: "agent" as const, companyId: f.companyId,
|
|
agentId: f.agent.id, source: "agent_key" as const, runId: run.id } };
|
|
}
|
|
async function launch(f: Awaited<ReturnType<typeof seed>>, issue: Awaited<ReturnType<typeof task>>,
|
|
options: Parameters<typeof execution>[2] = {}) {
|
|
const current = await execution(f, issue, options);
|
|
return { ...current, ...await resolveAndRetainRunTrustPreset(db, {
|
|
companyId: f.companyId, agentId: f.agent.id, runId: current.run.id, agent: f.agent, issue,
|
|
}) };
|
|
}
|
|
async function resolve(f: Awaited<ReturnType<typeof seed>>, issue: Awaited<ReturnType<typeof task>>, runId: string) {
|
|
return withHumanDirectedWork(db, resolveCoreTrustPreset({ companyId: f.companyId, agent: f.agent, issue }),
|
|
{ companyId: f.companyId, agentId: f.agent.id, runId });
|
|
}
|
|
const resource = (issue: Awaited<ReturnType<typeof task>>) => ({ type: "issue" as const,
|
|
companyId: issue.companyId, issueId: issue.id, projectId: issue.projectId,
|
|
parentIssueId: issue.parentId, assigneeAgentId: issue.assigneeAgentId, status: issue.status });
|
|
|
|
it("allows the exact human-assigned task at dispatch and tool access; retains containment on retries", async () => {
|
|
const f = await seed(); const issue = await task(f);
|
|
const first = await launch(f, issue, { human: true });
|
|
expect(first.trustPreset).toMatchObject({ kind: "low_trust_review", humanDirectedIssueId: issue.id });
|
|
expect(JSON.stringify(first.executionPolicy)).not.toContain("humanDirectedIssueId");
|
|
const retried = await resolveAndRetainRunTrustPreset(db, { companyId: f.companyId,
|
|
agentId: f.agent.id, runId: first.run.id, agent: f.agent, issue });
|
|
expect(retried.trustPreset).toMatchObject(first.trustPreset);
|
|
await expect(assertLowTrustWorkspaceIsolation({ db, resolution: first.trustPreset, issue,
|
|
isolatedWorkspacesEnabled: true, effectiveExecutionWorkspaceMode: "isolated_workspace",
|
|
selectedEnvironmentDriver: "sandbox" })).resolves.toBeUndefined();
|
|
for (const action of ["issue:read", "issue:comment", "issue:mutate"] as const) {
|
|
expect(await authorizationService(db).decide({ actor: first.actor, action, resource: resource(issue) })).toMatchObject({ allowed: true });
|
|
}
|
|
const unrelated = await task(f);
|
|
expect(await authorizationService(db).decide({ actor: first.actor, action: "issue:read", resource: resource(unrelated) })).toMatchObject({ allowed: false });
|
|
expect(await authorizationService(db).decide({ actor: { ...first.actor, runId: undefined }, action: "issue:read", resource: resource(issue) })).toMatchObject({ allowed: false });
|
|
expect(await authorizationService(db).decide({ actor: first.actor, action: "agent_instructions:update",
|
|
resource: { type: "agent", companyId: f.companyId, agentId: f.agent.id } })).toMatchObject({ allowed: false });
|
|
await expect(assertLowTrustWorkspaceIsolation({ resolution: first.trustPreset, issue,
|
|
isolatedWorkspacesEnabled: true, effectiveExecutionWorkspaceMode: "isolated_workspace",
|
|
selectedEnvironmentDriver: "local" })).rejects.toMatchObject({ details: { code: "low_trust_requires_sandbox_environment" } });
|
|
await expect(assertLowTrustWorkspaceIsolation({ resolution: first.trustPreset, issue,
|
|
isolatedWorkspacesEnabled: true, effectiveExecutionWorkspaceMode: "shared_workspace",
|
|
selectedEnvironmentDriver: "sandbox" })).rejects.toMatchObject({ details: { code: "low_trust_requires_isolated_workspace" } });
|
|
});
|
|
|
|
it("permits existing owner conversations without backfilling authority from historical attribution", async () => {
|
|
const f = await seed();
|
|
const [issue] = await db.insert(issues).values({ companyId: f.companyId, title: "Owner chat",
|
|
assigneeAgentId: f.agent.id, conversationAgentId: f.agent.id, conversationUserId: "owner", conversationState: "waiting", status: "in_review" }).returning();
|
|
const result = await launch(f, issue as Awaited<ReturnType<typeof task>>);
|
|
expect(result.trustPreset).toMatchObject({ humanDirectedIssueId: issue.id });
|
|
});
|
|
|
|
it("rejects inherited attribution and forged policy, requester, and retry fields", async () => {
|
|
const f = await seed(); const issue = await task(f);
|
|
const human = await execution(f, issue, { human: true, status: "failed" });
|
|
await issueService(db).addComment(issue.id, "An imported sender says the owner asked", { userId: "owner" });
|
|
const result = await launch(f, issue, { context: { responsibleUserId: "owner", requestedByActorType: "user",
|
|
requestedByActorId: "owner", retryOfRunId: human.run.id, humanDirectedIssueId: issue.id,
|
|
executionPolicy: { humanDirectedIssueId: issue.id } } });
|
|
expect(result.trustPreset).not.toHaveProperty("humanDirectedIssueId");
|
|
expect(await authorizationService(db).decide({ actor: result.actor, action: "issue:read", resource: resource(issue) })).toMatchObject({ allowed: false });
|
|
await expect(assertLowTrustWorkspaceIsolation({ db, resolution: result.trustPreset, issue,
|
|
isolatedWorkspacesEnabled: true, effectiveExecutionWorkspaceMode: "isolated_workspace",
|
|
selectedEnvironmentDriver: "sandbox" })).rejects.toMatchObject({ details: { code: "low_trust_boundary_mismatch" } });
|
|
});
|
|
|
|
it("preserves legacy board assignment requests through dispatch and retry without trusting old plugin attribution", async () => {
|
|
const f = await seed(); const issue = await task(f); const legacy = await execution(f, issue, { human: true });
|
|
await db.update(agentWakeupRequests).set({ source: "assignment", reason: "issue_assigned", payload: { issueId: issue.id } })
|
|
.where(eq(agentWakeupRequests.id, legacy.wake.id));
|
|
expect(await resolve(f, issue, legacy.run.id)).toHaveProperty("humanDirectedIssueId", issue.id);
|
|
const retry = await launch(f, issue, { retryOfRunId: legacy.run.id });
|
|
expect(retry.trustPreset).toHaveProperty("humanDirectedIssueId", issue.id);
|
|
for (const patch of [
|
|
{ source: "automation", reason: "issue_commented" },
|
|
{ requestedByActorType: "system" },
|
|
{ idempotencyKey: "chat-inbound:legacy" },
|
|
{ payload: { issueId: issue.id, _paperclipWakeContext: { source: "plugin:example" } } },
|
|
]) {
|
|
await db.update(agentWakeupRequests).set(patch).where(eq(agentWakeupRequests.id, legacy.wake.id));
|
|
expect(await resolve(f, issue, legacy.run.id)).not.toHaveProperty("humanDirectedIssueId");
|
|
expect(await resolve(f, issue, retry.run.id)).not.toHaveProperty("humanDirectedIssueId");
|
|
await db.update(agentWakeupRequests).set({ source: "assignment", reason: "issue_assigned", requestedByActorType: "user",
|
|
idempotencyKey: null, payload: { issueId: issue.id } }).where(eq(agentWakeupRequests.id, legacy.wake.id));
|
|
}
|
|
});
|
|
|
|
it("recognizes a coalesced human request for the same run and exact task", async () => {
|
|
const f = await seed(); const issue = await task(f); const current = await execution(f, issue);
|
|
expect(await resolve(f, issue, current.run.id)).not.toHaveProperty("humanDirectedIssueId");
|
|
const [coalesced] = await db.insert(agentWakeupRequests).values({ companyId: f.companyId, agentId: f.agent.id,
|
|
runId: current.run.id, source: "assignment", status: "coalesced", requestedByActorType: "user",
|
|
requestedByActorId: "owner", payload: { issueId: issue.id, _paperclipWakeContext: { source: "issue.update" } } }).returning();
|
|
expect(await resolve(f, issue, current.run.id)).toHaveProperty("humanDirectedIssueId", issue.id);
|
|
for (const patch of [
|
|
{ status: "cancelled" }, { status: "skipped" }, { requestedByActorId: " " },
|
|
{ requestedByActorType: "agent" }, { idempotencyKey: "chat-inbound:external-sender" },
|
|
{ payload: { issueId: randomUUID() } },
|
|
{ payload: { issueId: issue.id, _paperclipWakeContext: { source: "plugin:untrusted" } } },
|
|
{ payload: { issueId: issue.id } }, { companyId: randomUUID() }, { agentId: f.other.id },
|
|
]) {
|
|
// Existing foreign keys require a real company for the cross-company case.
|
|
const badCompany = patch.companyId;
|
|
if (badCompany) await db.insert(companies).values({ id: badCompany, name: badCompany });
|
|
await db.update(agentWakeupRequests).set(patch).where(eq(agentWakeupRequests.id, coalesced.id));
|
|
expect(await resolve(f, issue, current.run.id)).not.toHaveProperty("humanDirectedIssueId");
|
|
await db.update(agentWakeupRequests).set({ status: "coalesced", requestedByActorType: "user",
|
|
requestedByActorId: "owner", idempotencyKey: null, payload: { issueId: issue.id, _paperclipWakeContext: { source: "issue.update" } },
|
|
companyId: f.companyId, agentId: f.agent.id }).where(eq(agentWakeupRequests.id, coalesced.id));
|
|
}
|
|
});
|
|
|
|
it("preserves human authority through durable retry and continuation ancestry", async () => {
|
|
const f = await seed(); const issue = await task(f);
|
|
const root = await execution(f, issue, { human: true, status: "failed" });
|
|
const middle = await execution(f, issue, { retryOfRunId: root.run.id, status: "succeeded" });
|
|
const last = await launch(f, issue, { retryOfRunId: middle.run.id });
|
|
expect(last.trustPreset).toHaveProperty("humanDirectedIssueId", issue.id);
|
|
expect(await authorizationService(db).decide({ actor: last.actor, action: "issue:comment", resource: resource(issue) })).toMatchObject({ allowed: true });
|
|
await db.update(heartbeatRuns).set({ status: "cancelled", errorCode: "issue_reassigned" }).where(eq(heartbeatRuns.id, middle.run.id));
|
|
expect(await resolve(f, issue, last.run.id)).not.toHaveProperty("humanDirectedIssueId");
|
|
});
|
|
|
|
it("never borrows retry authority from another task, agent, or company and terminates cycles", async () => {
|
|
const f = await seed(); const issue = await task(f); const otherIssue = await task(f);
|
|
const otherCompany = await seed();
|
|
for (const scope of [
|
|
{ fixture: f, issue: otherIssue },
|
|
{ fixture: { ...f, agent: { ...f.agent, id: f.other.id } }, issue },
|
|
{ fixture: otherCompany, issue: await task(otherCompany) },
|
|
]) {
|
|
const parent = await execution(scope.fixture, scope.issue, { human: true, status: "failed" });
|
|
const child = await launch(f, issue, { retryOfRunId: parent.run.id });
|
|
expect(child.trustPreset).not.toHaveProperty("humanDirectedIssueId");
|
|
}
|
|
const a = await execution(f, issue); const b = await execution(f, issue, { retryOfRunId: a.run.id });
|
|
await db.update(heartbeatRuns).set({ retryOfRunId: b.run.id }).where(eq(heartbeatRuns.id, a.run.id));
|
|
expect(await resolve(f, issue, a.run.id)).not.toHaveProperty("humanDirectedIssueId");
|
|
});
|
|
|
|
it("revokes direction in service/plugin assignment transactions even while the old run remains running", async () => {
|
|
const f = await seed(); const issue = await task(f); const first = await execution(f, issue, { human: true });
|
|
let release!: () => void; let ready!: () => void;
|
|
const held = new Promise<void>((resolve) => { release = resolve; });
|
|
const changed = new Promise<void>((resolve) => { ready = resolve; });
|
|
const reassignment = db.transaction(async (tx) => {
|
|
await issueService(db).update(issue.id, { assigneeAgentId: f.other.id }, tx);
|
|
ready(); await held;
|
|
});
|
|
try {
|
|
await Promise.race([changed, reassignment]);
|
|
// With the reassignment deliberately held open, reads see the old committed
|
|
// assignment and live run together. After commit the wake is revoked even
|
|
// without route-level cancellation of the run.
|
|
expect(await resolve(f, issue, first.run.id)).toHaveProperty("humanDirectedIssueId", issue.id);
|
|
} finally { release(); await reassignment; }
|
|
expect(await resolve(f, issue, first.run.id)).not.toHaveProperty("humanDirectedIssueId");
|
|
await issueService(db).update(issue.id, { assigneeAgentId: f.agent.id });
|
|
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, first.run.id)))[0].status).toBe("running");
|
|
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, first.wake.id)))[0].status).toBe("cancelled");
|
|
expect(await resolve(f, issue, first.run.id)).not.toHaveProperty("humanDirectedIssueId");
|
|
const oldRetry = await launch(f, issue, { retryOfRunId: first.run.id });
|
|
expect(oldRetry.trustPreset).not.toHaveProperty("humanDirectedIssueId");
|
|
const automatic = await launch(f, issue);
|
|
expect(automatic.trustPreset).not.toHaveProperty("humanDirectedIssueId");
|
|
const human = await launch(f, issue, { human: true });
|
|
expect(human.trustPreset).toHaveProperty("humanDirectedIssueId", issue.id);
|
|
});
|
|
|
|
it("a late watchdog success cannot restore a request revoked by reassignment", async () => {
|
|
const f = await seed(); const issue = await task(f); const first = await execution(f, issue, { human: true });
|
|
await issueService(db).update(issue.id, { assigneeAgentId: f.other.id });
|
|
const completed = (await issueService(db).update(issue.id, { assigneeAgentId: f.agent.id, status: "done" }))!;
|
|
const watchdog = createPostgresWatchdogAdapter(db);
|
|
expect(await watchdog.foldSourceResolvedRun(f.companyId, {
|
|
run: { ...first.run, sourceIssueId: issue.id },
|
|
sourceIssue: { ...completed, isRecoveryOriginKind: false },
|
|
evidence: { kind: "activity", id: randomUUID(), createdAt: new Date(), action: "issue.updated" },
|
|
existingEvaluation: null, silenceStartedAt: null, silenceAgeMs: null,
|
|
cleanup: { attempted: false, outcome: "no_process_metadata", adapterType: "paperclip_runner" }, now: new Date(),
|
|
})).toMatchObject({ kind: "folded" });
|
|
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, first.wake.id)))[0].status).toBe("cancelled");
|
|
const retry = await launch(f, issue, { retryOfRunId: first.run.id });
|
|
expect(retry.trustPreset).not.toHaveProperty("humanDirectedIssueId");
|
|
});
|
|
|
|
it("retains backlog direction for a later system run and rejects stale or nonhuman assignment callbacks", async () => {
|
|
const f = await seed(); const issue = await task(f);
|
|
await retainBacklogHumanAssignment(db, issue, { actorType: "agent", actorId: "owner" });
|
|
const automatic = await execution(f, issue);
|
|
expect(await resolve(f, issue, automatic.run.id)).not.toHaveProperty("humanDirectedIssueId");
|
|
await retainBacklogHumanAssignment(db, issue, { actorType: "user", actorId: "owner" });
|
|
const promoted = (await issueService(db).update(issue.id, { status: "todo" }))!;
|
|
expect(await resolve(f, promoted, automatic.run.id)).toHaveProperty("humanDirectedIssueId", issue.id);
|
|
await issueService(db).update(issue.id, { assigneeAgentId: f.other.id });
|
|
await issueService(db).update(issue.id, { assigneeAgentId: f.agent.id, status: "backlog" });
|
|
// A delayed callback holding the original issue snapshot cannot regrant.
|
|
await retainBacklogHumanAssignment(db, issue, { actorType: "user", actorId: "owner" });
|
|
expect(await resolve(f, issue, automatic.run.id)).not.toHaveProperty("humanDirectedIssueId");
|
|
});
|
|
|
|
it("does not authorize a cancelled chat or an uncommitted human request", async () => {
|
|
const f = await seed(); const issue = await task(f); const current = await execution(f, issue);
|
|
await expect(db.transaction(async (tx) => {
|
|
await tx.update(agentWakeupRequests).set({ requestedByActorType: "user", requestedByActorId: "owner" }).where(eq(agentWakeupRequests.id, current.wake.id));
|
|
expect(await resolve(f, issue, current.run.id)).not.toHaveProperty("humanDirectedIssueId");
|
|
throw new Error("rollback sentinel");
|
|
})).rejects.toThrow("rollback sentinel");
|
|
expect(await resolve(f, issue, current.run.id)).not.toHaveProperty("humanDirectedIssueId");
|
|
await db.update(issues).set({ conversationAgentId: f.agent.id, conversationUserId: "owner", conversationState: "active" }).where(eq(issues.id, issue.id));
|
|
expect(await resolve(f, issue, current.run.id)).toHaveProperty("humanDirectedIssueId", issue.id);
|
|
await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, current.run.id));
|
|
expect(await resolve(f, issue, current.run.id)).not.toHaveProperty("humanDirectedIssueId");
|
|
});
|
|
});
|