mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 18:56:05 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A task has one assignee. An agent often finishes its work by handing its own task to a person for review. > - The task update route stops the outgoing run before it saves a reassignment. > - Since #16082, a legacy run that hands its own task to a person is exempt, so it finishes its turn. Native runs were not exempt. > - On staging, a native run (ACPX Claude, 11.5 minutes of work) sent `PATCH /api/issues/{id}` to hand its task to a person. The route cancelled that run with `issue_reassigned`. The agent saw `api_transport_failure`. The task moved to the person, but it kept a stale execution blocker ("Saved work recovered. Send a message to continue."). > - The native runtime assumes a running run still owns its task. So widening the exemption alone is not enough: the run then fails after its result is accepted, and its finalizer can act on the person's task. > - This pull request applies the exemption to native runs and makes the native terminal fence, restart recovery and finalizer handle a run that gave its task away. > - The benefit is that an agent can hand its task to a person and finish normally, with no blocker on the person's task. ## Linked Issues or Issue Description Refs #16082 (legacy exemption). Related: #20541 (agent-to-agent self handoff) and #18293 (agent key handoff). Those PRs cover other actors and targets. This PR covers native runs that hand their task to a person. **What happened?** A native agent run handed its own task to a person (`assigneeAgentId: null`, `assigneeUserId`, status `in_review`) through its signed run token. The route cancelled the run that sent the request (`issue_reassigned`, "Cancelled before issue reassignment"). The cancelled native run then went through workspace recovery. The person's task kept an execution blocker with `restore_failed`. **Expected behavior** The run that hands its own task to a person keeps running and finishes its turn. The task rests `in_review` with the person. No execution blocker or recovery action stays on the task. The outgoing run cannot change the task after the hand-off. **Steps to reproduce** 1. Run a native agent on its assigned task. 2. From that run, send `PATCH /api/issues/{id}` with `assigneeAgentId: null`, a person's `assigneeUserId`, and status `in_review`. 3. Before this fix, the route cancels the run with `issue_reassigned`. **Why the exemption was legacy-only** #16082 limited the exemption to the run shape of its incident and kept a verified stop for every other case. The new H2 chaos workload showed why native needed more than the route change. With only the route widened: 1. The run's terminal fence `assertCurrentWakeCommentsRead` requires the run to still own the task. It threw `native_current_wake_comments_binding_changed` after the result was accepted. The run then failed with `native_session_lease_lost`, and its finalizer recorded `workspace_finalization_failed` as an agent-owned recovery on the person's task. 2. A restart after the hand-off left the run `running`, because restart recovery refused it with `issue_execution_lock_changed`. 3. The native finalizer only superseded a `done` decision. A `needs_review` report moved the person's task back to `in_progress` and queued a continuation for the outgoing agent. 4. The REST guard against the outgoing run's later mutations (`issue_handed_off`) applied to legacy runs only. ## What Changed - `server/src/routes/issues.ts`: the signed current-run human self-handoff exemption now applies to native runs. All other checks stay: exact run, agent, company and task, `agent_jwt` source, human recipient. For a native run the route first records the hand-off on the run (`resultJson.humanSelfHandoff`, new `native-human-self-handoff.ts`). Board reassignment, agent keys, and handoffs to an agent or to nobody still stop the outgoing run. - `current-wake-comments.ts`: the terminal fence accepts a run whose recorded hand-off matches the person who now owns the task. Without the record (for example a board reassignment), or after a later reassignment, it still refuses. - `native-restart-recovery.ts`: after a restart, a handed-off run is not resumed. It passes the execution-lock check and is then settled by the existing ownership gate (`task_ownership_or_status_changed`) without changing the person's task, instead of staying `running`. - `status-arbiter.ts` and `native-run-finalizer.ts`: `supersedeHandedOffDecision` keeps the person's task status and strips retries, continuations, owner wakes, recoveries, blockers and reviewer bindings, also from decisions that already preserve the status. Two effects stay: a governed request's interaction, and the workspace-finalization repair, so saved work is not lost. Review runs are not affected. - `server/src/run-task-ownership.ts` (renamed from `legacy-run-task-ownership.ts`): `assertHandedOffRunTaskOwnership` rejects later task mutations from the outgoing legacy or native run, in the route and under the update transaction's locks. - `tests/restart-chaos/`: new opt-in workload `H2`. The agent saves a brief, then hands the task to its creator in review as its last step. The oracle requires the task to rest `in_review` with the creator, every run to settle without `issue_reassigned`, readable non-empty run history, and no execution blocker or active recovery action. - Docs: `doc/execution-semantics.md`, task feature map and agent access map, chaos README. ## Verification - Red on `origin/master` (same tests, master source): the route cancels the native run (500, and 409 `runner_goal_reassignment_stop_unconfirmed`); the finalizer moves the person's task to `in_progress` with a continuation for the outgoing agent; a late `done` from the outgoing native run closes the person's task. - Red chaos wave on master's server (`--server-from` a master checkout, `--adapters claude --workloads H2 --faults K1 --matrix`): 0/2 pass. Both runs ended `cancelled` / `issue_reassigned` (`T:handoff_did_not_cancel_run`). - Green focused tests on this branch: - `native-human-self-handoff.test.ts` (embedded Postgres): hand-off through the real issue service; the run stays `running`; `done`, `needs_review` and failed-turn reports leave the task `in_review` with the person, with `getExecutionBlocker` null and no recovery action; the terminal fence accepts only the recorded hand-off; a late change from the outgoing run fails with `issue_handed_off`; pure policy cases. - `issue-agent-mutation-ownership-routes.test.ts`: native and legacy self-handoffs persist without `cancelRun`, and native records the hand-off before the update. Board, agent key, other run, task, agent and company still stop the run. - Native runtime suite (restart recovery, wake comments, arbiter, stale-owner completion), `issues-service`, `low-trust-red-team-routes`: pass. Server and restart-chaos typecheck pass. `node scripts/check-maps.mjs` passes. - Green chaos waves on this branch (local, `--adapters claude --workloads H2`): - `--matrix --faults K1 --seed 4459`: 2/2 pass (K0 baseline and a K1 crossing). - `--tasks 3 --faults K1 --fault-after-seconds 60` and `--fault-after-seconds 100`: 3/3 pass each. In the first, all three restarts landed after the hand-off while the runs were live; the runs settled and the tasks stayed with the person. - The final waves ran on the pre-rebase head `8a2a3b6a74`. The rebase onto master only merged the chaos workload lists (`types.ts`, `config.ts`, `journeys.ts`). No Daytona wave was run. ## Risks - The outgoing native turn keeps running after the person owns the task. Its native task tools already require the run to own the task, so they refuse (`paperclip_runner_tool_binding_not_authorized`). Its REST mutations are rejected with `issue_handed_off`. - A restart after the hand-off settles the run as failed (`native_restart_recovery_blocked`) instead of resuming it. The person's task is not changed and needs no Retry. - The finalizer rule covers any native run whose task belongs to a person at finalization, not only a self-handoff. Before this change such a run could still move the person's task and queue its own continuation. - Saved state: `resultJson.humanSelfHandoff` is a new optional run key. An older server ignores it, so a run handed off on the new server and finished on an old one fails its fence as today. No schema change. - No API shape change. > 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 - Claude (Anthropic), model ID `claude-opus-5-5`, through Claude Code with tool use and code execution. The session does not expose the context window or reasoning mode. ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
199 lines
15 KiB
TypeScript
199 lines
15 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { agents, authUsers, companies, companyMemberships, completionContracts, createDb, heartbeatRuns, issueRecoveryActions,
|
|
issues, nativeRunFinalizations, statusDecisions, type Db } from "@paperclipai/db";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
|
import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
|
|
import { getExecutionBlocker } from "../execution-blocker.js";
|
|
import { issueService } from "../issues.js";
|
|
import { NativeRunCoordinatorStore } from "./native-run-coordinator-store.js";
|
|
import { finalizeNativeRun } from "./native-run-finalizer.js";
|
|
import { assertCurrentWakeCommentsRead } from "./current-wake-comments.js";
|
|
import { recordNativeHumanSelfHandoff } from "./native-human-self-handoff.js";
|
|
import { NATIVE_STATUS_ARBITER_POLICY_VERSION, supersedeHandedOffDecision, type NativeStatusDecision } from "./status-arbiter.js";
|
|
|
|
describe("handed-off task decision policy", () => {
|
|
const incomplete: NativeStatusDecision = { policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, statusAction: "in_progress",
|
|
toStatus: "in_progress", reasonCode: "completion_evidence_incomplete", unblockDescriptor: null,
|
|
effects: [{ kind: "enqueue_continuation", agentId: "agent-a", summary: "Finish", idempotencyKey: "k", continuationKind: "same_agent" }] };
|
|
const person = { status: "in_review", assigneeAgentId: null, assigneeUserId: "user-1" };
|
|
|
|
it("preserves a person's task against the outgoing run's decision", () => {
|
|
expect(supersedeHandedOffDecision(incomplete, person)).toEqual({ ...incomplete, statusAction: "preserve",
|
|
toStatus: "in_review", reasonCode: "completion_owner_superseded", effects: [] });
|
|
});
|
|
it.each([
|
|
["the agent still owns the task", { ...person, assigneeAgentId: "agent-a", assigneeUserId: null }],
|
|
["the task is unassigned", { ...person, assigneeUserId: null }],
|
|
])("leaves the decision alone when %s", (_label, issue) => {
|
|
expect(supersedeHandedOffDecision(incomplete, issue)).toBe(incomplete);
|
|
});
|
|
it("keeps a governed request's interaction, without waking the outgoing agent", () => {
|
|
const interaction = { kind: "create_interaction", gate: { kind: "approval", id: "a" } };
|
|
const gated = { ...incomplete, statusAction: "in_review", toStatus: "in_review", reasonCode: "governed_gate_pending",
|
|
effects: [interaction, { kind: "notify_owner", agentId: "agent-a", reason: "governed_gate_pending" }] } as unknown as NativeStatusDecision;
|
|
expect(supersedeHandedOffDecision(gated, person)).toEqual({ ...gated, effects: [interaction] });
|
|
});
|
|
it("drops a failed turn's retry of the outgoing agent from a preserving decision", () => {
|
|
const failedTurn: NativeStatusDecision = { ...incomplete, statusAction: "preserve", toStatus: "in_review",
|
|
reasonCode: "run_failed_partial_evidence_preserved",
|
|
effects: [{ kind: "release_checkout" }, { kind: "schedule_retry", cause: "x", summary: "Retry", agentId: "agent-a" }] };
|
|
expect(supersedeHandedOffDecision(failedTurn, person)).toEqual({ ...failedTurn, effects: [{ kind: "release_checkout" }] });
|
|
});
|
|
it("drops a finalization error that is not a workspace save failure", () => {
|
|
const monitorLost: NativeStatusDecision = { ...incomplete, statusAction: "preserve", toStatus: "in_review",
|
|
reasonCode: "native_monitor_authority_lost", effects: [{ kind: "record_finalization_error",
|
|
cause: "native_monitor_authority_lost", nextAction: "Reschedule", agentId: "agent-a" }] };
|
|
expect(supersedeHandedOffDecision(monitorLost, person)).toEqual({ ...monitorLost, effects: [] });
|
|
});
|
|
it("keeps the workspace repair of a failed finalization, so saved work is not lost", () => {
|
|
const finalizationFailed: NativeStatusDecision = { ...incomplete, statusAction: "preserve", toStatus: "in_review",
|
|
reasonCode: "finalization_failed_claim_preserved", effects: [{ kind: "record_finalization_error",
|
|
cause: "workspace_finalization_failed", nextAction: "Repair", agentId: "agent-a" }] };
|
|
expect(supersedeHandedOffDecision(finalizationFailed, person)).toBe(finalizationFailed);
|
|
});
|
|
});
|
|
|
|
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_NATIVE_HUMAN_HANDOFF_TEST_DATABASE_URL");
|
|
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
|
|
|
/**
|
|
* A native run that hands its own task to a person keeps running and finishes
|
|
* its turn. The task stays with that person: no execution blocker, no
|
|
* recovery action, and the outgoing run cannot change the task afterward.
|
|
*/
|
|
describe.skipIf(!support.supported)("native run hands its own task to a person", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
|
let db: Db;
|
|
beforeAll(async () => {
|
|
if (!external) database = await startEmbeddedPostgresTestDatabase("native-human-handoff-");
|
|
db = createDb(external ?? database!.connectionString);
|
|
}, 60_000);
|
|
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
|
|
|
|
async function runningNativeTask() {
|
|
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), userId = randomUUID();
|
|
const runId = randomUUID(), sessionId = randomUUID(), runnerId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Handoff fixture", issuePrefix: companyId.slice(0, 8) });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Nibbler", adapterType: "paperclip_runner" });
|
|
await db.insert(authUsers).values({ id: userId, name: "Reviewer", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
|
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "operator" });
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Draft the plan", status: "in_progress",
|
|
assigneeAgentId: agentId, createdByUserId: userId, description: "Draft the plan and hand it to me for review." });
|
|
const [contract] = await db.insert(completionContracts).values({ companyId, issueId, revision: 1,
|
|
schemaVersion: "paperclip.completion-contract.v1", policyVersion: "test", risk: "low", completionAuthority: "agent_claim_policy",
|
|
incompleteCriteriaPolicy: "preserve_non_terminal", contractJson: { revision: "test", objective: "Draft the plan",
|
|
criteria: [{ id: "objective", requirement: "Complete the task" }] },
|
|
canonicalSha256: `handoff-${issueId}`, createdByActorType: "system", createdByActorId: "test" }).returning();
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: issueId, nativeSessionId: sessionId,
|
|
runnerInstanceId: runnerId, completionContractId: contract.id, completionContractSha256: contract.canonicalSha256,
|
|
status: "running", runtimeMode: "native", invocationSource: "assignment", contextSnapshot: { issueId, source: "issue.assigned" } });
|
|
await db.update(issues).set({ executionRunId: runId, checkoutRunId: runId }).where(eq(issues.id, issueId));
|
|
const port = new NativeRunCoordinatorStore(db, { companyId, issueId, runId, agentId, normalizedSessionId: sessionId,
|
|
runnerSourceInstanceId: runnerId, completionContractId: contract.id, completionContractSha256: contract.canonicalSha256,
|
|
completionContractRevision: "test", completionContractCriterionIds: ["objective"] });
|
|
return { companyId, agentId, issueId, userId, runId, port };
|
|
}
|
|
|
|
type Fixture = Awaited<ReturnType<typeof runningNativeTask>>;
|
|
|
|
/** The REST PATCH the agent sends through its run token (route exemption applied). */
|
|
/** What the route does for the run's own hand-off: record it on the run, then save the assignment. */
|
|
async function handOffToPerson(f: Fixture, options: { record?: boolean } = {}) {
|
|
if (options.record !== false) {
|
|
expect(await recordNativeHumanSelfHandoff(db, { companyId: f.companyId, runId: f.runId, agentId: f.agentId,
|
|
issueId: f.issueId, assigneeUserId: f.userId })).toBe(true);
|
|
}
|
|
return issueService(db).update(f.issueId, { assigneeAgentId: null, assigneeUserId: f.userId, status: "in_review",
|
|
actorAgentId: f.agentId, actorRunId: f.runId } as never);
|
|
}
|
|
|
|
it("lets the run's terminal fence accept the task it handed to a person, and only that hand-off", async () => {
|
|
const binding = (f: Fixture) => ({ companyId: f.companyId, issueId: f.issueId, runId: f.runId, agentId: f.agentId });
|
|
const recorded = await runningNativeTask();
|
|
await handOffToPerson(recorded);
|
|
await expect(assertCurrentWakeCommentsRead(db, binding(recorded), null)).resolves.toBeUndefined();
|
|
|
|
// Without the run's record (for example a board reassignment), the fence still refuses.
|
|
const unrecorded = await runningNativeTask();
|
|
await handOffToPerson(unrecorded, { record: false });
|
|
await expect(assertCurrentWakeCommentsRead(db, binding(unrecorded), null)).rejects.toThrow("native_current_wake_comments_binding_changed");
|
|
|
|
// A truncated chat wake: after the hand-off the run must still have read the wake it started with.
|
|
const wake = { bindingDigest: "wake-digest" } as unknown as Parameters<typeof assertCurrentWakeCommentsRead>[2];
|
|
// It passes the binding comparison and reaches the read requirement, which it has not met.
|
|
await expect(assertCurrentWakeCommentsRead(db, binding(recorded), wake)).rejects.toThrow("native_current_wake_comments_unread");
|
|
|
|
// A later reassignment to someone else is not the hand-off this run made.
|
|
const other = randomUUID();
|
|
await db.insert(authUsers).values({ id: other, name: "Other", email: `${other}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
|
await db.update(issues).set({ assigneeUserId: other }).where(eq(issues.id, recorded.issueId));
|
|
await expect(assertCurrentWakeCommentsRead(db, binding(recorded), null)).rejects.toThrow("native_current_wake_comments_binding_changed");
|
|
});
|
|
|
|
const results = {
|
|
done: { schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", summary: "Plan drafted and handed to the reviewer.",
|
|
completionClaim: { contractRevision: "test", objectiveSatisfied: true,
|
|
criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: ["task:self"] }], remainingWork: [] },
|
|
evidence: [{ ref: "task:self" }], verification: [], attentionRequests: [], artifacts: [] },
|
|
needs_review: { schema: "paperclip.run_result.v1", reportedWorkDisposition: "needs_review", summary: "Waiting for the reviewer.",
|
|
completionClaim: { contractRevision: "test", objectiveSatisfied: false,
|
|
criteria: [{ criterionId: "objective", status: "unknown", evidenceRefs: [] }],
|
|
remainingWork: [{ description: "Reviewer decides", blocksCompletion: true }] },
|
|
evidence: [], verification: [], attentionRequests: [], artifacts: [] },
|
|
} as const;
|
|
|
|
it.each(["done", "needs_review"] as const)("finishes normally after a %s report and leaves no execution blocker", async disposition => {
|
|
const f = await runningNativeTask();
|
|
await handOffToPerson(f);
|
|
const handed = (await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]!;
|
|
expect(handed).toMatchObject({ status: "in_review", assigneeAgentId: null, assigneeUserId: f.userId, executionRunId: null });
|
|
// The run that handed off is still running; nothing cancelled it.
|
|
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0]).toMatchObject({ status: "running", errorCode: null });
|
|
|
|
const result = JSON.parse(JSON.stringify(results[disposition]).replaceAll("task:self", `task:${f.issueId}`));
|
|
await f.port.completeRun({ result, terminal: { schema: "paperclip.prp.terminal.v1", runTerminalState: "succeeded",
|
|
turnTerminalState: "completed", reportedWorkDisposition: disposition }, turnId: `handoff-${disposition}` });
|
|
await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: false });
|
|
const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
const [decision] = await db.select().from(statusDecisions).where(eq(statusDecisions.id, coordinator!.decisionId!));
|
|
expect(decision).toMatchObject({ toStatus: "in_review", reasonCode: "completion_owner_superseded",
|
|
decisionJson: expect.objectContaining({ statusAction: "preserve", effects: [] }) });
|
|
|
|
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toMatchObject({
|
|
status: "in_review", assigneeAgentId: null, assigneeUserId: f.userId,
|
|
});
|
|
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull();
|
|
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]);
|
|
});
|
|
|
|
it("does not retry the outgoing agent after a failed turn", async () => {
|
|
const f = await runningNativeTask();
|
|
await handOffToPerson(f);
|
|
const result = JSON.parse(JSON.stringify(results.needs_review));
|
|
await f.port.completeRun({ result, terminal: { schema: "paperclip.prp.terminal.v1", runTerminalState: "failed",
|
|
turnTerminalState: "failed", reportedWorkDisposition: "needs_review" }, turnId: "handoff-failed" });
|
|
await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: false });
|
|
const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
const [decision] = await db.select().from(statusDecisions).where(eq(statusDecisions.id, coordinator!.decisionId!));
|
|
expect(coordinator!.phase).toBe("committed");
|
|
expect((decision!.decisionJson as { effects: Array<{ kind: string }> }).effects
|
|
.filter(effect => ["schedule_retry", "enqueue_continuation", "record_recovery", "bind_blocker", "notify_owner"].includes(effect.kind)))
|
|
.toEqual([]);
|
|
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toMatchObject({
|
|
status: "in_review", assigneeAgentId: null, assigneeUserId: f.userId,
|
|
});
|
|
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull();
|
|
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]);
|
|
});
|
|
|
|
it("rejects the outgoing native run's later change to the person's task", async () => {
|
|
const f = await runningNativeTask();
|
|
await handOffToPerson(f);
|
|
await expect(issueService(db).update(f.issueId, { status: "done", actorAgentId: f.agentId, actorRunId: f.runId } as never))
|
|
.rejects.toMatchObject({ details: { code: "issue_handed_off" } });
|
|
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toMatchObject({ status: "in_review", assigneeUserId: f.userId });
|
|
});
|
|
});
|