mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Cases keep facts, documents, and one primary task across agent handoffs. > - Each case currently needs its own procedure text. > - Agents need to reuse procedures and move cases between them without replacing the task. > - This change adds company workflow definitions with a name and Markdown prompt. > - Existing handoff and document transactions preserve authority, revisions, and recovery. > - Cases remain behind `experimental.enableCases`. ## Linked Issues or Issue Description Refs #16959. Related proposal: #3951 uses task graphs and a visual editor. This change extends existing Cases with prompt definitions and keeps its current handoff model. **Problem or motivation** Agents cannot share a reusable procedure across cases or inspect a case's movement between procedures. **Proposed solution** Add name-and-prompt workflow definitions. Adopt a prompt into the primary task's revisioned workflow document through a handoff. Keep phases as free text. **Alternatives considered** Defer graphs, workflow versions, run entities, role schemas, authoring screens, and Spec Kit integration. **Roadmap alignment** This extends the existing Cases experiment and same-task review flow. It adds no work queue or new execution engine. The maintainer approved this scope in the implementation plan. ## What Changed - Add company-scoped definition storage, shared request schemas, REST routes, and four optional Cases tools. - Accept a workflow ID at kickoff. Agent handoffs can adopt, refresh, preserve, or detach a workflow. - Capture prompt text and the document base revision at acceptance. Apply workflow, phase, and document revision together after outgoing execution settles. - Hold conflicting document edits for recovery. Preserve cancellation, replay, and restart behavior. - Show current workflow names and captured transition names/phases in Work, history, and Audit. - Update instructions, API/tool contracts, capability maps, and the isolated workflow journey. - Pin bounded workflow prompt reads to a content fingerprint; continuation conflicts on edits and can restart safely. - Remove experimental Cases/workflow/Mermaid guidance from the always-on native prompt. Extract runtime-context prompt strings into a CODEOWNERS-protected constants file while preserving the public imports. - Refresh Copilot’s pinned source identity to v57, including the extracted prompt file and retaining historical identities. ## Verification Local checks: repository `pnpm -r typecheck` and `pnpm build` pass on the prompt-extraction tree (`88222d45d8`). Full repository coverage previously completed through the supported isolated shard groups (1,082 general server suites, 161 serialized suites, and all three workspace groups). The earlier default serial run was interrupted and remains incomplete. The first sharded run retained the failures described below; targeted corrections and separate limited-concurrency reruns pass. All CI jobs, security checks, and the 5/5 Greptile review passed on `fc1bd79c28`. Fresh CI and review are pending for the prompt extraction in `88222d45d8`. Focused verification passes: real-database handoff recovery and reuse; 2,002 native-tool/API contract checks; 144 Runner catalog/discovery checks; 27 Runner context/coverage checks; 37 case/Audit UI checks; and Product E2E oracle calibration. Token gates, migration safety/numbering, module boundaries, capability maps, and generated tool/Storybook catalogs pass. The final route/context corrections pass 49 API/native-tool tests, 90 real-database handoff tests, and server typecheck; 142 Copilot installation/sidecar tests pass with the refreshed identity. Greptile’s first review found that successive prompt pages could mix definitions after an edit. The fix returns a content fingerprint, requires it for continuation, and rejects edits with 409. Strict live/catalog schemas pass (171 Runner checks). The 2,005 API/OpenAPI tests pass; the initial native regression caught compression’s weak ETag prefix, and all 23 native integration tests pass after handling it. The regression covers same-length edits and a four-byte restart window. No stored version fields or entities were added. The prompt extraction passes all 462 checks across six focused suites, including persisted-context recovery and provider transport. The first local transport attempt lacked the required debug Runner binaries (102 failures, retained); after building the normal prerequisites, all 283 tests in that suite pass without source or assertion changes. Existing saved snapshots retain their recorded prompt text, and new executions use core-only prompt v7. The extracted prompt source is now covered by both CODEOWNERS and Copilot’s source fingerprint. Live-provider evidence is separate from those simulated checks: the isolated API-key campaign `reusable-workflows-api-final-2026-10-21` passed with local code matching `a1d73b4652`. Four actual Codex API runs read the adopted task documents, moved one case A → B → A on the same task, completed normally, and passed all nine durable checks. Browser assertions verified the current workflow/phase, historical names/phases, five handoff records including terminal completion, and the completed original task. Cleanup passed. The local report remains under `tests/runner-e2e/results/reusable-workflows-api-final-2026-10-21/`; no credentials or screenshots are committed. CI initially caught an omitted route-inventory entry and a stale pinned instruction-source hash; both are corrected and separately verified. Local serialized coverage also recorded an unrelated environment-route socket disconnect and activity-route timeout; their separate 11-test and 17-test suites passed without code or timeout changes. An unchanged chunked-webhook check also passed its separate four-test rerun. The initial adapter/database workspace groups hit time and resource limits under default worker counts; complete reruns with `VITEST_MAX_WORKERS=2` pass, including all 55 database files and 211 tests. The UI/CLI groups passed 837 files and 9,442 tests in the original run. Original failures remain retained and are not regraded. Earlier failed live campaigns remain unchanged: subscription HTTP 429 before agent execution; an obsolete exact-four-row oracle that rejected the valid terminal fifth row; and a chat-marker expectation that did not account for the existing explicit-comment presentation policy. The final fixture requires terminal completion and places the marker in its authoritative comment. Positive/negative oracle tests and the fresh live campaign pass. ## Risks - The additive migration adds a table and nullable case/handoff fields. Existing rows keep null workflow metadata; no assignments are inferred. - Adoption can wait on an intervening task-document edit. Recovery must cancel the old intent and accept a new one after readback. - Shared definition edits affect future adoptions. Existing case copies keep their revisions. - The feature remains experimental. This PR does not merge or deploy the change. - Live acceptance covers native Codex before the prompt extraction; this cleanup has contract/transport verification rather than a new live-provider campaign. The refreshed Copilot identity has static compatibility evidence, not new live-provider qualification. ## Model Used OpenAI Codex, configured model ID `gpt-6-astra`, xhigh reasoning. Used terminal execution, file editing, and tool calls. The active context-window size is not exposed. ## 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1203 lines
96 KiB
TypeScript
1203 lines
96 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { createServer } from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import { activityLog, agentApiKeys, agentWakeupRequests, agents, authSessions, authUsers, boardApiKeys, companyMemberships, principalPermissionGrants, caseAccessSources, caseEvents, caseHandoffs, caseIssueLinks, cases, companies, createDb, documentRevisions, documents, dotAgentBindings, heartbeatRuns, issueComments, issueDocuments, issues, issueWorkProducts } from "@paperclipai/db";
|
|
import { startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
|
|
import { caseHandoffRoutes } from "../routes/case-handoffs.js";
|
|
import { errorHandler } from "../middleware/error-handler.js";
|
|
import { resolveCloudTenantActor } from "../middleware/auth.js";
|
|
import { caseHandoffService } from "./case-handoffs.js";
|
|
import { caseHandoffDeliveryService } from "./case-handoff-delivery.js";
|
|
import { completeTerminalCaseHandoffsForIssue, pendingCaseHandoffForRun, recordCaseHandoffExecutionReleased } from "./heartbeat/case-handoff-lifecycle.js";
|
|
import { assertAgentRunWriteAllowed } from "../agent-run-cancellation.js";
|
|
import { instanceSettingsService } from "./instance-settings.js";
|
|
import { getCaseExecutionContext } from "./case-context.js";
|
|
import { canActorReadCase, retainCaseAccessSource } from "./case-access.js";
|
|
import { buildNativeExecutionInput } from "./native-runtime/native-execution-input.js";
|
|
import { nativeRuntimeContextFixture } from "./native-runtime/runtime-context.test-fixture.js";
|
|
import { buildLowTrustSourceTrust } from "./source-trust.js";
|
|
import { issueService } from "./issues.js";
|
|
import { documentService } from "./documents.js";
|
|
import type { AuthorizationActor } from "./authorization.js";
|
|
import { recoveryService } from "./recovery/service.js";
|
|
import { enrichCaseWork } from "./case-work-summary.js";
|
|
import { workflowService } from "./workflows.js";
|
|
|
|
describe("durable same-task case handoffs", () => {
|
|
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
const board = { type: "board" as const, source: "local_implicit" as const };
|
|
beforeAll(async () => {
|
|
temporary = await startEmbeddedPostgresTestDatabase("case-handoffs-");
|
|
db = createDb(temporary.connectionString);
|
|
await instanceSettingsService(db).updateExperimental({ enableCases: true });
|
|
});
|
|
afterAll(async () => { await temporary?.cleanup(); });
|
|
afterEach(async () => {
|
|
await db.delete(activityLog);
|
|
await db.delete(caseHandoffs); await db.delete(caseIssueLinks); await db.delete(cases);
|
|
await db.delete(documentRevisions); await db.delete(documents);
|
|
await db.delete(issues); await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests);
|
|
await db.delete(agentApiKeys); await db.delete(boardApiKeys); await db.delete(authSessions);
|
|
await db.delete(principalPermissionGrants); await db.delete(companyMemberships); await db.delete(authUsers);
|
|
await db.delete(agents); await db.delete(companies);
|
|
});
|
|
|
|
async function fixture() {
|
|
const companyId = randomUUID(), triage = randomUUID(), engineer = randomUUID(), verifier = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Factory", issuePrefix: `F${companyId.slice(0, 6)}` });
|
|
await db.insert(agents).values([
|
|
{ id: triage, companyId, name: "Triage", role: "ceo", status: "active", adapterType: "process" },
|
|
{ id: engineer, companyId, name: "Engineer", role: "engineer", status: "active", adapterType: "process", reportsTo: triage },
|
|
{ id: verifier, companyId, name: "Verifier", role: "engineer", status: "active", adapterType: "process", reportsTo: triage },
|
|
]);
|
|
const service = caseHandoffService(db);
|
|
const input = { idempotencyKey: randomUUID(), case: { create: { caseType: "work", title: "Implement the feature", summary: "Overall objective", fields: { acceptance: "Verified" } } },
|
|
toAgentId: triage, phase: "triage", instructions: "Research and decide the next step",
|
|
workflow_document_text: "# Workflow\nTriage → implementation → verification → corrections → done." };
|
|
return { companyId, triage, engineer, verifier, service, input };
|
|
}
|
|
|
|
function queue(now: () => Date, crashAfterCommit = false) {
|
|
const wakeup = vi.fn(async (agentId: string, options: any) => {
|
|
const receipt = options.durableCaseHandoffRequest;
|
|
await db.insert(agentWakeupRequests).values({ id: receipt.wakeupRequestId, companyId: (await db.select().from(agents).where(eq(agents.id, agentId)))[0]!.companyId,
|
|
agentId, source: options.source, status: "queued", reason: options.reason, payload: options.payload, idempotencyKey: options.idempotencyKey }).onConflictDoNothing();
|
|
if (crashAfterCommit) throw new Error("Connection lost after queue commit");
|
|
return { id: receipt.wakeupRequestId };
|
|
});
|
|
return { wakeup, worker: caseHandoffDeliveryService(db, { heartbeat: { wakeup } as any, now }) };
|
|
}
|
|
|
|
async function sourceRun(companyId: string, agentId: string, issueId: string) {
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", runtimeMode: "legacy", invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId } });
|
|
await db.update(issues).set({ status: "in_progress", executionRunId: runId, checkoutRunId: runId }).where(eq(issues.id, issueId));
|
|
return runId;
|
|
}
|
|
|
|
async function delayedTransfer(prepare?: (f: Awaited<ReturnType<typeof fixture>>, taskId: string, runId: string) => Promise<AuthorizationActor | void>) {
|
|
const f = await fixture();
|
|
let at = new Date(Date.now() + 60_000);
|
|
const q = queue(() => at);
|
|
const first = await f.service.handoff(f.companyId, board, f.input);
|
|
await q.worker.sweepPending();
|
|
const runId = await sourceRun(f.companyId, f.triage, first.taskId);
|
|
const actor = await prepare?.(f, first.taskId, runId) ?? {
|
|
type: "agent", agentId: f.triage, companyId: f.companyId, runId, source: "agent_jwt",
|
|
} satisfies AuthorizationActor;
|
|
const [task] = await db.select().from(issues).where(eq(issues.id, first.taskId));
|
|
const receipt = await f.service.handoff(f.companyId, actor, { case: { id: first.caseId }, idempotencyKey: randomUUID(),
|
|
expectedHandoffVersion: 1, expectedIssueStatusVersion: task!.statusVersion,
|
|
toAgentId: f.engineer, phase: "implementation", instructions: "Use the saved findings" });
|
|
expect(receipt.state).toBe("awaiting_release");
|
|
return { ...f, first, runId, actor, task: task!, receipt, q,
|
|
async release() {
|
|
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
|
|
await recordCaseHandoffExecutionReleased(db, runId, first.taskId);
|
|
},
|
|
async sweep() { at = new Date(at.getTime() + 60_000); await q.worker.sweepPending(); },
|
|
async assertHeld(message: RegExp) {
|
|
const [handoff] = await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, receipt.handoffId));
|
|
expect(handoff).toMatchObject({ state: "awaiting_release", lastErrorCode: "access_or_admission_blocked" });
|
|
expect(handoff!.lastError).toMatch(message);
|
|
expect((await db.select().from(issues).where(eq(issues.id, first.taskId)))[0])
|
|
.toMatchObject({ assigneeAgentId: f.triage, status: task!.status, statusVersion: task!.statusVersion });
|
|
expect((await db.select().from(cases).where(eq(cases.id, first.caseId)))[0]!.phase).toBe("triage");
|
|
expect(q.wakeup).toHaveBeenCalledTimes(1);
|
|
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, handoff!.wakeupRequestId))).toEqual([]);
|
|
},
|
|
};
|
|
}
|
|
|
|
async function cloudTransfer() {
|
|
const userId = randomUUID(), stackId = `stack-${randomUUID()}`, stackSlug = "handoff-cloud";
|
|
const state: { role: string; status: number; body?: unknown; afterResponse?: () => void } = { role: "owner", status: 200 };
|
|
const requests: Array<{ path: string; userId: string | undefined; stackId: string | undefined; authenticated: boolean }> = [];
|
|
const backend = createServer((req, res) => {
|
|
requests.push({ path: req.url!, userId: req.headers["x-paperclip-cloud-user-id"] as string | undefined,
|
|
stackId: req.headers["x-paperclip-cloud-stack-id"] as string | undefined,
|
|
authenticated: req.headers.authorization === "Bearer cloud-authority-test-token" });
|
|
res.writeHead(state.status, { "content-type": "application/json" });
|
|
res.end(JSON.stringify(state.body ?? { stacks: [{ stackSlug, role: state.role, isCurrent: true }] }));
|
|
state.afterResponse?.();
|
|
});
|
|
await new Promise<void>(resolve => backend.listen(0, "127.0.0.1", resolve));
|
|
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "cloud-authority-test-token");
|
|
vi.stubEnv("PAPERCLIP_CLOUD_API_ORIGIN", `http://127.0.0.1:${(backend.address() as AddressInfo).port}`);
|
|
vi.stubEnv("PAPERCLIP_CLOUD_STACK_ID", stackId);
|
|
vi.stubEnv("PAPERCLIP_STACK_SLUG", stackSlug);
|
|
const flag = (enabled: boolean) => vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", JSON.stringify({ v: 1, mode: "cloud", catalogVersion: "test",
|
|
features: { enableCases: true, enableOwnerInstanceAdmin: enabled }, plugins: { autoInstall: [] } }));
|
|
flag(true);
|
|
const close = async () => { await new Promise<void>((resolve, reject) => backend.close(error => error ? reject(error) : resolve())); vi.unstubAllEnvs(); };
|
|
try {
|
|
const f = await delayedTransfer(async (f) => {
|
|
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "owner" });
|
|
await db.update(agents).set({ permissions: { authorizationPolicy: { protectedAgent: { blockAssignment: true } } } }).where(eq(agents.id, f.engineer));
|
|
const headers: Record<string, string> = { "x-paperclip-cloud-tenant-token": "cloud-authority-test-token", "x-paperclip-cloud-user-id": userId,
|
|
"x-paperclip-cloud-user-email": `${userId}@example.test`, "x-paperclip-cloud-stack-id": stackId, "x-paperclip-cloud-stack-role": "owner" };
|
|
const actor = await resolveCloudTenantActor(db, { header: name => headers[name] });
|
|
expect(actor).toMatchObject({ type: "board", source: "cloud_tenant", userId, isInstanceAdmin: true });
|
|
return actor!;
|
|
});
|
|
expect(requests).toHaveLength(0); // Remote revalidation belongs to delayed apply.
|
|
return { ...f, state, requests, stackSlug, stackId, userId, flag, close };
|
|
} catch (error) { await close(); throw error; }
|
|
}
|
|
|
|
async function terminalFixture() {
|
|
const f = await fixture(), q = queue(() => new Date(Date.now() + 60_000));
|
|
const first = await f.service.handoff(f.companyId, board, { ...f.input, toAgentId: f.engineer });
|
|
await q.worker.sweepPending();
|
|
const runId = await sourceRun(f.companyId, f.engineer, first.taskId);
|
|
const actor = { type: "agent" as const, companyId: f.companyId, agentId: f.engineer, runId, source: "agent_key" as const };
|
|
const [issue] = await db.select().from(issues).where(eq(issues.id, first.taskId));
|
|
const input = { to: "done" as const, phase: "done" as const, case: { id: first.caseId }, idempotencyKey: randomUUID(),
|
|
instructions: "The saved findings are ready; close the case after ordinary task completion.",
|
|
expectedHandoffVersion: 1, expectedIssueStatusVersion: issue!.statusVersion };
|
|
return { ...f, q, first, runId, actor, input };
|
|
}
|
|
|
|
it("reuses definitions, moves A → B → A, refreshes, and detaches without replacing the task", async () => {
|
|
const f = await fixture(), q = queue(() => new Date(Date.now() + 60_000));
|
|
const definitions = workflowService(db), docs = documentService(db);
|
|
const a = await definitions.write(f.companyId, board, { name: "Research", prompt: "Research the question." });
|
|
const b = await definitions.write(f.companyId, board, { name: "Delivery", prompt: "Deliver the findings." });
|
|
const { workflow_document_text: _inline, ...kickoff } = f.input;
|
|
const first = await f.service.handoff(f.companyId, board, { ...kickoff, workflowId: a.id });
|
|
const second = await f.service.handoff(f.companyId, board, { ...kickoff, idempotencyKey: randomUUID(), workflowId: a.id });
|
|
await q.worker.sweepPending();
|
|
await definitions.write(f.companyId, board, { name: "Research renamed", prompt: "Research with citations." }, a.id);
|
|
expect((await docs.getIssueDocumentByKey(first.taskId, "workflow"))?.body).toBe(a.prompt);
|
|
expect((await docs.getIssueDocumentByKey(second.taskId, "workflow"))?.body).toBe(a.prompt);
|
|
for (const [index, workflowId] of [b.id, a.id, a.id, null].entries()) {
|
|
const [task] = await db.select().from(issues).where(eq(issues.id, first.taskId));
|
|
const input = { case: { id: first.caseId }, idempotencyKey: randomUUID(), workflowId,
|
|
expectedHandoffVersion: index + 1, expectedIssueStatusVersion: task!.statusVersion,
|
|
toAgentId: f.triage, phase: `custom phase ${index}`, instructions: "Continue with this procedure." };
|
|
const accepted = await f.service.handoff(f.companyId, board, input);
|
|
expect(accepted.taskId).toBe(first.taskId);
|
|
expect(await f.service.handoff(f.companyId, board, input)).toMatchObject({ handoffId: accepted.handoffId, replayed: true });
|
|
await q.worker.sweepPending();
|
|
}
|
|
const [current] = await db.select().from(cases).where(eq(cases.id, first.caseId));
|
|
expect(current).toMatchObject({ workflowId: null, phase: "custom phase 3", workIssueId: first.taskId });
|
|
expect((await docs.getIssueDocumentByKey(first.taskId, "workflow"))?.body).toBe("Research with citations.");
|
|
expect(await docs.listIssueDocumentRevisions(first.taskId, "workflow")).toHaveLength(4);
|
|
const history = await f.service.list(board, first.caseId);
|
|
expect(history.items).toHaveLength(5);
|
|
expect(history.items.at(-1)?.workflowTransition?.to.name).toBe("Research");
|
|
expect(history.items[1]?.workflowTransition?.to.name).toBe("Research renamed");
|
|
expect(history.items.every(item => !("workflowPrompt" in item) && !("baseWorkflowDocumentRevisionId" in item))).toBe(true);
|
|
});
|
|
|
|
it("captures pending prompts, preserves intervening edits, and cancels without adopting", async () => {
|
|
const f = await fixture(), docs = documentService(db), definitions = workflowService(db);
|
|
let at = new Date(Date.now() + 60_000);
|
|
const q = queue(() => at);
|
|
const first = await f.service.handoff(f.companyId, board, f.input);
|
|
await q.worker.sweepPending();
|
|
const workflow = await definitions.write(f.companyId, board, { name: "New procedure", prompt: "Accepted prompt" });
|
|
const runId = await sourceRun(f.companyId, f.triage, first.taskId);
|
|
const [task] = await db.select().from(issues).where(eq(issues.id, first.taskId));
|
|
const receipt = await f.service.handoff(f.companyId, board, { case: { id: first.caseId }, workflowId: workflow.id,
|
|
idempotencyKey: randomUUID(), expectedHandoffVersion: 1, expectedIssueStatusVersion: task!.statusVersion,
|
|
toAgentId: f.engineer, phase: "arbitrary next phase", instructions: "Adopt on transfer" });
|
|
expect(receipt.state).toBe("awaiting_release");
|
|
await definitions.write(f.companyId, board, { prompt: "Edited shared prompt" }, workflow.id);
|
|
const original = await docs.getIssueDocumentByKey(first.taskId, "workflow");
|
|
await docs.upsertIssueDocument({ issueId: first.taskId, key: "workflow", format: "markdown",
|
|
body: "Intervening case edit", baseRevisionId: original!.latestRevisionId });
|
|
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
|
|
await recordCaseHandoffExecutionReleased(db, runId, first.taskId);
|
|
at = new Date(at.getTime() + 60_000);
|
|
await q.worker.sweepPending();
|
|
const [pending] = await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, receipt.handoffId));
|
|
expect(pending).toMatchObject({ workflowPrompt: "Accepted prompt", assignmentAppliedAt: null });
|
|
expect(pending!.lastError).toBe("Document was updated by someone else");
|
|
expect((await db.select().from(cases).where(eq(cases.id, first.caseId)))[0]).toMatchObject({ workflowId: null, phase: "triage" });
|
|
await f.service.cancel(board, first.caseId, receipt.handoffId);
|
|
expect((await docs.getIssueDocumentByKey(first.taskId, "workflow"))?.body).toBe("Intervening case edit");
|
|
expect(await docs.listIssueDocumentRevisions(first.taskId, "workflow")).toHaveLength(2);
|
|
const next = await caseHandoffService(db).handoff(f.companyId, board, { case: { id: first.caseId }, workflowId: workflow.id,
|
|
idempotencyKey: randomUUID(), expectedHandoffVersion: 2, expectedIssueStatusVersion: task!.statusVersion,
|
|
toAgentId: f.engineer, phase: "retry with current document", instructions: "Adopt after re-reading" });
|
|
await definitions.write(f.companyId, board, { prompt: "Changed after acceptance again" }, workflow.id);
|
|
at = new Date(at.getTime() + 60_000);
|
|
await queue(() => at, true).worker.sweepPending();
|
|
at = new Date(at.getTime() + 120_000);
|
|
await queue(() => at).worker.sweepPending();
|
|
expect((await docs.getIssueDocumentByKey(first.taskId, "workflow"))?.body).toBe("Edited shared prompt");
|
|
expect(await docs.listIssueDocumentRevisions(first.taskId, "workflow")).toHaveLength(3);
|
|
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, next.handoffId)))[0]).toMatchObject({ state: "delivered" });
|
|
});
|
|
|
|
it("creates one initial task through REST and replays lost responses without title dedupe", async () => {
|
|
const f = await fixture();
|
|
const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.actor = board; next(); });
|
|
app.use("/api", caseHandoffRoutes(db)); app.use(errorHandler);
|
|
const first = await request(app).post(`/api/companies/${f.companyId}/case-handoffs`).send(f.input).expect(202);
|
|
const replay = await request(app).post(`/api/companies/${f.companyId}/case-handoffs`).send(f.input).expect(200);
|
|
expect(replay.body).toMatchObject({ taskId: first.body.taskId, handoffId: first.body.handoffId, replayed: true });
|
|
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(1);
|
|
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, first.body.taskId))).toHaveLength(1);
|
|
expect(await documentService(db).getIssueDocumentByKey(first.body.taskId, "workflow"))
|
|
.toMatchObject({ key: "workflow", title: "Workflow", format: "markdown", body: f.input.workflow_document_text, latestRevisionNumber: 1 });
|
|
expect(await documentService(db).listIssueDocumentRevisions(first.body.taskId, "workflow")).toHaveLength(1);
|
|
await request(app).post(`/api/companies/${f.companyId}/case-handoffs`).send({ ...f.input, workflow_document_text: "Changed workflow" }).expect(409);
|
|
const second = await f.service.handoff(f.companyId, board, { ...f.input, idempotencyKey: randomUUID() });
|
|
expect(second.taskId).not.toBe(first.body.taskId);
|
|
await request(app).post(`/api/companies/${f.companyId}/case-handoffs`).send({ ...f.input, instructions: "Different" }).expect(409);
|
|
});
|
|
|
|
it("rejects missing, blank, colliding or oversized workflow seed inputs through REST without side effects", async () => {
|
|
const f = await fixture();
|
|
const app = express(); app.use(express.json({ limit: "1mb" })); app.use((req, _res, next) => { req.actor = board; next(); });
|
|
app.use("/api", caseHandoffRoutes(db)); app.use(errorHandler);
|
|
for (const override of [{ workflow_document_text: undefined }, { workflow_document_text: " \n\t " },
|
|
{ workflow_document_text: "x".repeat(200_001) },
|
|
{ initialDocuments: [{ key: " Workflow ", title: "Conflicting workflow", body: "Do something else" }] },
|
|
{ workflow_document_text: "x".repeat(200_000), initialDocuments: [{ key: "brief", title: "Brief", body: "x" }] }]) {
|
|
await request(app).post(`/api/companies/${f.companyId}/case-handoffs`).send({ ...f.input, ...override }).expect(400);
|
|
}
|
|
for (const table of [cases, issues, documents, documentRevisions, caseHandoffs, caseEvents, issueComments, activityLog, agentWakeupRequests]) {
|
|
expect(await db.select().from(table).where(eq(table.companyId, f.companyId))).toHaveLength(0);
|
|
}
|
|
});
|
|
|
|
it("requires a workflow when starting a passive case but leaves legacy task binding available", async () => {
|
|
const f = await fixture();
|
|
const [passive] = await db.insert(cases).values({ companyId: f.companyId, caseNumber: 1,
|
|
identifier: `${f.companyId}-C1`, caseType: "work", title: "Legacy passive case" }).returning();
|
|
const { workflow_document_text: _workflow, ...base } = f.input;
|
|
const input = { ...base, case: { id: passive!.id }, expectedHandoffVersion: 0 };
|
|
await expect(f.service.handoff(f.companyId, board, input)).rejects.toMatchObject({ status: 422 });
|
|
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(0);
|
|
expect(await db.select().from(documents).where(eq(documents.companyId, f.companyId))).toHaveLength(0);
|
|
expect((await db.select().from(cases).where(eq(cases.id, passive!.id)))[0]).toMatchObject({ workIssueId: null, handoffVersion: 0 });
|
|
const [task] = await db.insert(issues).values({ companyId: f.companyId, title: "Existing task", status: "todo", assigneeAgentId: f.triage }).returning();
|
|
const bound = await f.service.handoff(f.companyId, board, { ...input, bindIssueId: task!.id, expectedIssueStatusVersion: task!.statusVersion });
|
|
expect(bound.taskId).toBe(task!.id);
|
|
expect(await documentService(db).listIssueDocuments(task!.id)).toEqual([]);
|
|
});
|
|
|
|
it("seeds the workflow on a newly created case's bound task and replays exactly one revision", async () => {
|
|
const f = await fixture();
|
|
const [task] = await db.insert(issues).values({ companyId: f.companyId, title: "Keep this title", description: "Keep this objective",
|
|
status: "todo", assigneeAgentId: f.triage }).returning();
|
|
const input = { ...f.input, bindIssueId: task!.id, expectedIssueStatusVersion: task!.statusVersion };
|
|
const first = await f.service.handoff(f.companyId, board, input);
|
|
expect(await f.service.handoff(f.companyId, board, input)).toMatchObject({ taskId: task!.id, caseId: first.caseId, replayed: true });
|
|
expect((await db.select().from(issues).where(eq(issues.id, task!.id)))[0]).toMatchObject({ title: task!.title, description: task!.description });
|
|
expect(await documentService(db).getIssueDocumentByKey(task!.id, "workflow")).toMatchObject({ title: "Workflow", body: input.workflow_document_text });
|
|
expect(await documentService(db).listIssueDocumentRevisions(task!.id, "workflow")).toHaveLength(1);
|
|
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(1);
|
|
});
|
|
|
|
it("rolls back new-case binding if the task already has a workflow document", async () => {
|
|
const f = await fixture();
|
|
const [task] = await db.insert(issues).values({ companyId: f.companyId, title: "Existing task", status: "todo", assigneeAgentId: f.triage }).returning();
|
|
const before = await documentService(db).upsertIssueDocument({ issueId: task!.id, key: "workflow", title: "Original workflow", format: "markdown", body: "Keep this workflow" });
|
|
await expect(f.service.handoff(f.companyId, board, { ...f.input, bindIssueId: task!.id,
|
|
expectedIssueStatusVersion: task!.statusVersion })).rejects.toMatchObject({ status: 409 });
|
|
expect(await documentService(db).getIssueDocumentByKey(task!.id, "workflow")).toMatchObject({ body: "Keep this workflow", latestRevisionId: before.document.latestRevisionId });
|
|
expect(await documentService(db).listIssueDocumentRevisions(task!.id, "workflow")).toHaveLength(1);
|
|
for (const table of [cases, caseHandoffs, caseEvents, issueComments, activityLog, agentWakeupRequests]) {
|
|
expect(await db.select().from(table).where(eq(table.companyId, f.companyId))).toHaveLength(0);
|
|
}
|
|
expect((await db.select().from(issues).where(eq(issues.id, task!.id)))[0]).toMatchObject({ statusVersion: task!.statusVersion, assigneeAgentId: f.triage });
|
|
});
|
|
|
|
it("rejects workflow replacement on an existing primary task without changing its document", async () => {
|
|
const f = await fixture(), first = await f.service.handoff(f.companyId, board, f.input);
|
|
const [task] = await db.select().from(issues).where(eq(issues.id, first.taskId));
|
|
await expect(f.service.handoff(f.companyId, board, { ...f.input, case: { id: first.caseId }, idempotencyKey: randomUUID(),
|
|
expectedHandoffVersion: 1, expectedIssueStatusVersion: task!.statusVersion, workflow_document_text: "Overwrite it" })).rejects.toMatchObject({ status: 422 });
|
|
expect(await documentService(db).getIssueDocumentByKey(first.taskId, "workflow")).toMatchObject({ body: f.input.workflow_document_text, latestRevisionNumber: 1 });
|
|
expect(await db.select().from(caseHandoffs).where(eq(caseHandoffs.caseId, first.caseId))).toHaveLength(1);
|
|
});
|
|
|
|
it("commits ordinary task documents before wake delivery and replays without extra revisions", async () => {
|
|
const f = await fixture();
|
|
const input = { ...f.input, workflow_document_text: " # Workflow\r\nPreserve literal \\n in the example.\n ",
|
|
initialDocuments: [{ key: " Brief ", title: "Brief", body: "Research the receipt" },
|
|
{ key: "plan", title: "Plan", body: "Validate\\nImplement" }] };
|
|
const first = await f.service.handoff(f.companyId, board, input);
|
|
const observed = vi.fn(async (_agentId: string, options: any) => {
|
|
expect(await db.select().from(issueDocuments).where(eq(issueDocuments.issueId, first.taskId))).toHaveLength(3);
|
|
expect(await db.select().from(documentRevisions).where(eq(documentRevisions.companyId, f.companyId))).toHaveLength(3);
|
|
expect(await documentService(db).getIssueDocumentByKey(first.taskId, "workflow"))
|
|
.toMatchObject({ title: "Workflow", body: input.workflow_document_text, latestRevisionNumber: 1 });
|
|
const receipt = options.durableCaseHandoffRequest;
|
|
await db.insert(agentWakeupRequests).values({ id: receipt.wakeupRequestId, companyId: f.companyId,
|
|
agentId: f.triage, source: options.source, status: "queued", reason: options.reason, payload: options.payload,
|
|
idempotencyKey: options.idempotencyKey });
|
|
return { id: receipt.wakeupRequestId };
|
|
});
|
|
await caseHandoffDeliveryService(db, { heartbeat: { wakeup: observed } as any,
|
|
now: () => new Date(Date.now() + 60_000) }).sweepPending();
|
|
expect(observed).toHaveBeenCalledTimes(1);
|
|
const replay = await f.service.handoff(f.companyId, board, input);
|
|
expect(replay).toMatchObject({ handoffId: first.handoffId, caseId: first.caseId, taskId: first.taskId, replayed: true });
|
|
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(1);
|
|
expect(await db.select().from(documents).where(eq(documents.companyId, f.companyId))).toHaveLength(3);
|
|
expect(await db.select().from(documentRevisions).where(eq(documentRevisions.companyId, f.companyId))).toHaveLength(3);
|
|
expect((await db.select().from(documents).where(eq(documents.title, "Plan")))[0]).toMatchObject({ latestBody: "Validate\nImplement",
|
|
latestRevisionNumber: 1 });
|
|
expect(await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.companyId), eq(activityLog.action, "issue.document_created")))).toHaveLength(3);
|
|
await expect(f.service.handoff(f.companyId, board, { ...input,
|
|
initialDocuments: [{ key: "brief", title: "Brief", body: "Changed" }] })).rejects.toMatchObject({ status: 409 });
|
|
});
|
|
|
|
it("starts a passive case with documents without making another case", async () => {
|
|
const f = await fixture();
|
|
const [passive] = await db.insert(cases).values({ companyId: f.companyId, caseNumber: 1,
|
|
identifier: `${f.companyId}-C1`, caseType: "work", title: "Passive research" }).returning();
|
|
const first = await f.service.handoff(f.companyId, board, { ...f.input, case: { id: passive!.id }, expectedHandoffVersion: 0,
|
|
initialDocuments: [{ key: "research", title: "Research", body: "Initial findings" }] });
|
|
expect(first.caseId).toBe(passive!.id);
|
|
expect(await db.select().from(cases).where(eq(cases.companyId, f.companyId))).toHaveLength(1);
|
|
expect((await db.select().from(issueDocuments).where(eq(issueDocuments.issueId, first.taskId))).map(row => row.key).sort()).toEqual(["research", "workflow"]);
|
|
});
|
|
|
|
it("preserves source-run attribution and normal private task ownership for initial documents", async () => {
|
|
const f = await fixture(), sourceId = randomUUID();
|
|
await db.insert(issues).values({ id: sourceId, companyId: f.companyId, title: "Private intake",
|
|
visibility: "private", assigneeAgentId: f.triage });
|
|
const runId = await sourceRun(f.companyId, f.triage, sourceId);
|
|
const actor = { type: "agent" as const, companyId: f.companyId, agentId: f.triage, runId, source: "agent_key" as const };
|
|
const first = await f.service.handoff(f.companyId, actor, { ...f.input, toAgentId: f.engineer,
|
|
initialDocuments: [{ key: "brief", title: "Brief", body: "Private findings" }] });
|
|
expect((await db.select().from(issues).where(eq(issues.id, first.taskId)))[0]).toMatchObject({ parentId: sourceId,
|
|
visibility: "private", assigneeAgentId: f.engineer, originRunId: runId });
|
|
expect((await db.select().from(documents).where(eq(documents.companyId, f.companyId)))[0])
|
|
.toMatchObject({ createdByAgentId: f.triage, createdByUserId: null, sourceTrust: null });
|
|
expect((await db.select().from(documentRevisions).where(eq(documentRevisions.companyId, f.companyId)))[0])
|
|
.toMatchObject({ createdByAgentId: f.triage, createdByUserId: null, createdByRunId: runId, revisionNumber: 1 });
|
|
expect(await canActorReadCase(db, { type: "agent", companyId: f.companyId, agentId: f.verifier },
|
|
{ id: first.caseId, companyId: f.companyId })).toBe(false);
|
|
});
|
|
|
|
it("rolls back case, task, earlier document, revision, history and audit if a document write fails", async () => {
|
|
const f = await fixture();
|
|
await db.execute(sql`alter table documents add constraint case_initial_document_test_failure check (title <> 'Reject document')`);
|
|
try {
|
|
await expect(f.service.handoff(f.companyId, board, { ...f.input, initialDocuments: [
|
|
{ key: "first", title: "First", body: "This insert succeeds before the controlled fault" },
|
|
{ key: "fail", title: "Reject document", body: "This insert fails" },
|
|
] })).rejects.toThrow();
|
|
for (const table of [cases, issues, documents, documentRevisions, caseHandoffs, caseEvents, issueComments, activityLog, agentWakeupRequests]) {
|
|
expect(await db.select().from(table).where(eq(table.companyId, f.companyId))).toHaveLength(0);
|
|
}
|
|
} finally {
|
|
await db.execute(sql`alter table documents drop constraint case_initial_document_test_failure`);
|
|
}
|
|
});
|
|
|
|
it("rejects initial documents on existing work without mutating its documents or routing", async () => {
|
|
const f = await fixture(), first = await f.service.handoff(f.companyId, board, f.input);
|
|
const [issue] = await db.select().from(issues).where(eq(issues.id, first.taskId));
|
|
await expect(f.service.handoff(f.companyId, board, { ...f.input, idempotencyKey: randomUUID(), case: { id: first.caseId },
|
|
expectedHandoffVersion: 1, expectedIssueStatusVersion: issue!.statusVersion,
|
|
initialDocuments: [{ key: "late", title: "Late", body: "Must not be attached" }] })).rejects.toMatchObject({ status: 422 });
|
|
expect(await db.select().from(issueDocuments).where(eq(issueDocuments.issueId, first.taskId))).toMatchObject([{ key: "workflow" }]);
|
|
expect(await db.select().from(caseHandoffs).where(eq(caseHandoffs.caseId, first.caseId))).toHaveLength(1);
|
|
expect((await db.select().from(cases).where(eq(cases.id, first.caseId)))[0]).toMatchObject({ handoffVersion: 1 });
|
|
});
|
|
|
|
it.each(["unknown", "pending_approval", "terminated"] as const)("rejects an %s recipient with 422 before creating case work", async status => {
|
|
const f = await fixture();
|
|
const toAgentId = status === "unknown" ? randomUUID() : f.triage;
|
|
if (status !== "unknown") await db.update(agents).set({ status }).where(eq(agents.id, toAgentId));
|
|
await expect(f.service.handoff(f.companyId, board, { ...f.input, toAgentId })).rejects.toMatchObject({ status: 422 });
|
|
expect(await db.select().from(cases).where(eq(cases.companyId, f.companyId))).toHaveLength(0);
|
|
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(0);
|
|
expect(await db.select().from(issueComments).where(eq(issueComments.companyId, f.companyId))).toHaveLength(0);
|
|
expect(await db.select().from(caseHandoffs).where(eq(caseHandoffs.companyId, f.companyId))).toHaveLength(0);
|
|
});
|
|
|
|
it.each(["protected", "cycle"] as const)("keeps an implicit %s source parent from creating an unauthorized child", async reason => {
|
|
const f = await fixture(), sourceId = randomUUID();
|
|
await db.insert(issues).values({ id: sourceId, companyId: f.companyId, title: "Intake task", assigneeAgentId: f.triage,
|
|
createdByAgentId: reason === "cycle" ? f.engineer : null,
|
|
executionPolicy: reason === "protected" ? { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } : null });
|
|
const runId = await sourceRun(f.companyId, f.triage, sourceId);
|
|
const kickoff = await f.service.handoff(f.companyId, { type: "agent", companyId: f.companyId, agentId: f.triage, runId, source: "agent_key" }, {
|
|
...f.input, toAgentId: f.engineer,
|
|
});
|
|
const [task] = await db.select().from(issues).where(eq(issues.id, kickoff.taskId));
|
|
expect(task).toMatchObject({ parentId: null, originRunId: runId, assigneeAgentId: f.engineer });
|
|
expect(await db.select().from(issues).where(eq(issues.parentId, sourceId))).toHaveLength(0);
|
|
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(2);
|
|
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, kickoff.taskId))).toHaveLength(1);
|
|
});
|
|
|
|
it("does not discard a retained private parent when child assignment is protected", async () => {
|
|
const f = await fixture(), sourceId = randomUUID(), caseId = randomUUID();
|
|
await db.insert(issues).values({ id: sourceId, companyId: f.companyId, title: "Private retained source", visibility: "private", assigneeAgentId: f.triage,
|
|
executionPolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } });
|
|
await db.insert(cases).values({ id: caseId, companyId: f.companyId, caseNumber: 1, identifier: `${f.companyId}-C1`, caseType: "work", title: "Private research" });
|
|
await retainCaseAccessSource(db, { companyId: f.companyId, caseId, sourceKind: "issue", sourceId });
|
|
const runId = await sourceRun(f.companyId, f.triage, sourceId);
|
|
await expect(f.service.handoff(f.companyId, { type: "agent", companyId: f.companyId, agentId: f.triage, runId, source: "agent_key" }, {
|
|
case: { id: caseId }, expectedHandoffVersion: 0, idempotencyKey: randomUUID(), toAgentId: f.engineer, phase: "triage", instructions: "Confidential findings",
|
|
workflow_document_text: f.input.workflow_document_text,
|
|
})).rejects.toMatchObject({ status: 403 });
|
|
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(1);
|
|
expect(await db.select().from(issueComments).where(eq(issueComments.companyId, f.companyId))).toHaveLength(0);
|
|
expect(await db.select().from(caseHandoffs).where(eq(caseHandoffs.caseId, caseId))).toHaveLength(0);
|
|
expect((await db.select().from(cases).where(eq(cases.id, caseId)))[0]).toMatchObject({ workIssueId: null, handoffVersion: 0 });
|
|
});
|
|
|
|
it("serializes concurrent routing and keeps the same task through correction rounds", async () => {
|
|
const f = await fixture(); let at = new Date(Date.now() + 60_000); const q = queue(() => at);
|
|
const first = await f.service.handoff(f.companyId, board, f.input);
|
|
await q.worker.sweepPending();
|
|
const [initial] = await db.select().from(issues).where(eq(issues.id, first.taskId));
|
|
const next = { idempotencyKey: randomUUID(), case: { id: first.caseId }, expectedHandoffVersion: 1,
|
|
expectedIssueStatusVersion: initial!.statusVersion, toAgentId: f.engineer, phase: "implementation", instructions: "Implement the researched solution" };
|
|
const raced = await Promise.allSettled([f.service.handoff(f.companyId, board, next), f.service.handoff(f.companyId, board, { ...next, idempotencyKey: randomUUID() })]);
|
|
expect(raced.filter(item => item.status === "fulfilled")).toHaveLength(1);
|
|
expect(raced.filter(item => item.status === "rejected")).toHaveLength(1);
|
|
await q.worker.sweepPending();
|
|
for (const [phase, toAgentId] of [["verification", f.verifier], ["implementation", f.engineer]] as const) {
|
|
const [caseRow] = await db.select().from(cases).where(eq(cases.id, first.caseId));
|
|
const [issue] = await db.select().from(issues).where(eq(issues.id, first.taskId));
|
|
const handoff = await f.service.handoff(f.companyId, board, { case: { id: first.caseId }, idempotencyKey: randomUUID(),
|
|
expectedHandoffVersion: caseRow!.handoffVersion, expectedIssueStatusVersion: issue!.statusVersion,
|
|
toAgentId, phase, instructions: `Continue ${phase}` });
|
|
expect(handoff.taskId).toBe(first.taskId); at = new Date(at.getTime() + 60_000); await q.worker.sweepPending();
|
|
}
|
|
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(1);
|
|
expect((await db.select().from(cases).where(eq(cases.id, first.caseId)))[0]).toMatchObject({ phase: "implementation", handoffVersion: 4, status: "draft" });
|
|
expect((await f.service.list(board, first.caseId)).items[0]).toMatchObject({ toAgentName: "Engineer", canControl: false });
|
|
const resumedRun = await sourceRun(f.companyId, f.engineer, first.taskId);
|
|
const productId = randomUUID();
|
|
await db.insert(issueWorkProducts).values({ id: productId, companyId: f.companyId, issueId: first.taskId, type: "pull_request", provider: "github",
|
|
title: "Implementation pull request", status: "ready", url: "https://example.test/pull/1" });
|
|
const referenceContext = await getCaseExecutionContext(db, { companyId: f.companyId, issueId: first.taskId, agentId: f.engineer, runId: resumedRun });
|
|
expect(JSON.parse(referenceContext!.data).workProducts).toEqual(expect.arrayContaining([expect.objectContaining({ id: productId,
|
|
readPath: `/api/issues/${first.taskId}/work-products` })]));
|
|
await db.update(cases).set({ fields: { result: "x".repeat(20_000) } }).where(eq(cases.id, first.caseId));
|
|
const context = await getCaseExecutionContext(db, { companyId: f.companyId, issueId: first.taskId, agentId: f.engineer, runId: resumedRun });
|
|
expect(context).toMatchObject({ phase: "implementation", handoffVersion: 4, taskId: first.taskId, omitted: true });
|
|
expect(context!.incomingHandoff!.instructions).toContain("Continue implementation");
|
|
expect(context!.data.length).toBeLessThanOrEqual(12_000);
|
|
expect(context!.issueStatusVersion).toBe((await db.select().from(issues).where(eq(issues.id, first.taskId)))[0]!.statusVersion);
|
|
});
|
|
|
|
it("waits for authoritative process settlement and survives a worker restart", async () => {
|
|
const f = await fixture(); let at = new Date(Date.now() + 60_000); const q = queue(() => at);
|
|
const first = await f.service.handoff(f.companyId, board, f.input); await q.worker.sweepPending();
|
|
const runId = await sourceRun(f.companyId, f.triage, first.taskId);
|
|
const [issue] = await db.select().from(issues).where(eq(issues.id, first.taskId));
|
|
const next = await f.service.handoff(f.companyId, board, { case: { id: first.caseId }, idempotencyKey: randomUUID(), expectedHandoffVersion: 1,
|
|
expectedIssueStatusVersion: issue!.statusVersion, toAgentId: f.engineer, phase: "implementation", instructions: "Use these findings" });
|
|
expect(next.state).toBe("awaiting_release");
|
|
await q.worker.sweepPending(); expect(q.wakeup).toHaveBeenCalledTimes(1);
|
|
// A terminal DB status is deliberately insufficient while a process or its
|
|
// workspace might still be alive.
|
|
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
|
|
at = new Date(at.getTime() + 60_000); await q.worker.sweepPending(); expect(q.wakeup).toHaveBeenCalledTimes(1);
|
|
await recordCaseHandoffExecutionReleased(db, runId, first.taskId);
|
|
at = new Date(at.getTime() + 60_000);
|
|
const restarted = queue(() => at); await restarted.worker.sweepPending();
|
|
expect(restarted.wakeup).toHaveBeenCalledTimes(1);
|
|
expect((await db.select().from(issues).where(eq(issues.id, first.taskId)))[0]).toMatchObject({ assigneeAgentId: f.engineer, status: "todo", executionRunId: null, checkoutRunId: null });
|
|
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, next.handoffId)))[0]!.state).toBe("delivered");
|
|
});
|
|
|
|
it("excludes awaiting and cancelled self-handoff instructions from execution context", async () => {
|
|
const f = await fixture(), q = queue(() => new Date(Date.now() + 60_000));
|
|
const first = await f.service.handoff(f.companyId, board, f.input); await q.worker.sweepPending();
|
|
const runId = await sourceRun(f.companyId, f.triage, first.taskId);
|
|
const [issue] = await db.select().from(issues).where(eq(issues.id, first.taskId));
|
|
const instructions = "UNAPPLIED_SELF_HANDOFF_INSTRUCTIONS";
|
|
const next = await f.service.handoff(f.companyId, { type: "agent", companyId: f.companyId, agentId: f.triage, runId, source: "agent_key" }, {
|
|
case: { id: first.caseId }, idempotencyKey: randomUUID(), expectedHandoffVersion: 1,
|
|
expectedIssueStatusVersion: issue!.statusVersion, toAgentId: f.triage, phase: "specification", instructions,
|
|
});
|
|
expect(next.state).toBe("awaiting_release");
|
|
const awaiting = await getCaseExecutionContext(db, { companyId: f.companyId, issueId: first.taskId, agentId: f.triage, runId });
|
|
expect(awaiting).toMatchObject({ phase: "triage", handoffVersion: 2, incomingHandoff: null });
|
|
expect(JSON.stringify(awaiting)).not.toContain(instructions);
|
|
|
|
expect(await f.service.cancel(board, first.caseId, next.handoffId)).toMatchObject({ state: "cancelled" });
|
|
await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, runId));
|
|
await recordCaseHandoffExecutionReleased(db, runId, first.taskId);
|
|
const resumedRunId = await sourceRun(f.companyId, f.triage, first.taskId);
|
|
const resumed = await getCaseExecutionContext(db, { companyId: f.companyId, issueId: first.taskId, agentId: f.triage, runId: resumedRunId });
|
|
expect(resumed).toMatchObject({ phase: "triage", handoffVersion: 2, incomingHandoff: null });
|
|
expect(JSON.stringify(resumed)).not.toContain(instructions);
|
|
|
|
await db.update(issues).set({ workMode: "skill_test" }).where(eq(issues.id, first.taskId));
|
|
expect(await getCaseExecutionContext(db, { companyId: f.companyId, issueId: first.taskId, agentId: f.triage, runId: resumedRunId })).toBeNull();
|
|
await db.update(issues).set({ workMode: "standard" }).where(eq(issues.id, first.taskId));
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: first.taskId, paperclipAgentJwtScope: { kind: "skill_test", issueId: first.taskId } } })
|
|
.where(eq(heartbeatRuns.id, resumedRunId));
|
|
expect(await getCaseExecutionContext(db, { companyId: f.companyId, issueId: first.taskId, agentId: f.triage, runId: resumedRunId })).toBeNull();
|
|
});
|
|
|
|
it("withdraws deleted incoming instruction comments without copying their retained old body", async () => {
|
|
const f = await fixture(), q = queue(() => new Date(Date.now() + 60_000));
|
|
const original = "DELETED_CASE_HANDOFF_SOURCE_BODY";
|
|
const first = await f.service.handoff(f.companyId, board, { ...f.input, instructions: original });
|
|
await q.worker.sweepPending();
|
|
const runId = await sourceRun(f.companyId, f.triage, first.taskId);
|
|
const [handoff] = await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, first.handoffId));
|
|
const before = await getCaseExecutionContext(db, { companyId: f.companyId, issueId: first.taskId, agentId: f.triage, runId });
|
|
expect(before!.incomingHandoff!.instructions).toContain(original);
|
|
await db.update(issueComments).set({ deletedAt: new Date() }).where(eq(issueComments.id, handoff!.commentId!));
|
|
const after = await getCaseExecutionContext(db, { companyId: f.companyId, issueId: first.taskId, agentId: f.triage, runId });
|
|
expect(after!.incomingHandoff).toMatchObject({ commentId: handoff!.commentId, sourceCommentDeleted: true });
|
|
expect(after!.incomingHandoff!.instructions).toContain("withdrawn");
|
|
expect(JSON.stringify(after)).not.toContain(original);
|
|
expect((await db.select().from(issueComments).where(eq(issueComments.id, handoff!.commentId!)))[0]!.body).toContain(original);
|
|
});
|
|
|
|
it.each(["queued", "failed", "cancelled"])("recovers a queue-commit/ack crash after receipt becomes %s without a duplicate wake", async receiptStatus => {
|
|
const f = await fixture(); let at = new Date(Date.now() + 60_000); const first = await f.service.handoff(f.companyId, board, f.input);
|
|
const crashing = queue(() => at, true); await crashing.worker.sweepPending();
|
|
const row = (await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, first.handoffId)))[0]!;
|
|
expect(row.state).toBe("pending_dispatch");
|
|
if (receiptStatus !== "queued") {
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.triage, status: receiptStatus,
|
|
wakeupRequestId: row.wakeupRequestId, invocationSource: "assignment", contextSnapshot: { issueId: first.taskId } });
|
|
await db.update(agentWakeupRequests).set({ status: receiptStatus, runId }).where(eq(agentWakeupRequests.id, row.wakeupRequestId));
|
|
}
|
|
at = new Date(at.getTime() + 60_000);
|
|
const recovered = queue(() => at); await recovered.worker.sweepPending();
|
|
expect(recovered.wakeup).not.toHaveBeenCalled();
|
|
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, row.wakeupRequestId))).toHaveLength(1);
|
|
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, row.id)))[0]!.state).toBe("delivered");
|
|
});
|
|
|
|
it("preserves immediate readiness when source cleanup races the waiting worker's retry", async () => {
|
|
const f = await fixture(), at = new Date(Date.now() + 60_000), q = queue(() => at);
|
|
const first = await f.service.handoff(f.companyId, board, f.input); await q.worker.sweepPending();
|
|
const runId = await sourceRun(f.companyId, f.triage, first.taskId);
|
|
const [issue] = await db.select().from(issues).where(eq(issues.id, first.taskId));
|
|
const next = await f.service.handoff(f.companyId, board, { case: { id: first.caseId }, idempotencyKey: randomUUID(), expectedHandoffVersion: 1,
|
|
expectedIssueStatusVersion: issue!.statusVersion, toAgentId: f.engineer, phase: "implementation", instructions: "Continue" });
|
|
const racing = caseHandoffDeliveryService(db, { now: () => at, heartbeat: { wakeup: q.wakeup,
|
|
requestCaseHandoffYield: async () => {
|
|
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
|
|
await recordCaseHandoffExecutionReleased(db, runId, first.taskId);
|
|
},
|
|
} as any });
|
|
await racing.sweepPending();
|
|
const [ready] = await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, next.handoffId));
|
|
expect(ready!.nextAttemptAt.getTime()).toBeLessThanOrEqual(at.getTime());
|
|
await queue(() => at).worker.sweepPending();
|
|
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, next.handoffId)))[0]!.state).toBe("delivered");
|
|
});
|
|
|
|
it("reclaims expired worker leases and does not dispatch superseded assignments", async () => {
|
|
const f = await fixture(); let at = new Date(Date.now() + 60_000); const first = await f.service.handoff(f.companyId, board, f.input);
|
|
await db.update(caseHandoffs).set({ leaseExpiresAt: new Date(at.getTime() + 60_000) }).where(eq(caseHandoffs.id, first.handoffId));
|
|
const q = queue(() => at); await q.worker.sweepPending(); expect(q.wakeup).not.toHaveBeenCalled();
|
|
await db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, first.taskId));
|
|
at = new Date(at.getTime() + 120_000); await q.worker.sweepPending();
|
|
expect(q.wakeup).not.toHaveBeenCalled();
|
|
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, first.handoffId)))[0]!.state).toBe("cancelled");
|
|
});
|
|
|
|
it("lets only one of two delivery workers claim a handoff", async () => {
|
|
const f = await fixture(), at = new Date(Date.now() + 60_000);
|
|
await f.service.handoff(f.companyId, board, f.input);
|
|
const first = queue(() => at), second = queue(() => at);
|
|
await Promise.all([first.worker.sweepPending(), second.worker.sweepPending()]);
|
|
expect(first.wakeup.mock.calls.length + second.wakeup.mock.calls.length).toBe(1);
|
|
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(1);
|
|
});
|
|
|
|
it("binds a passive case once and rejects additional primary ownership", async () => {
|
|
const f = await fixture(), caseId = randomUUID(), issueId = randomUUID();
|
|
await db.insert(cases).values({ id: caseId, companyId: f.companyId, caseNumber: 1, identifier: `${f.companyId}-C1`, caseType: "work", title: "Passive" });
|
|
await db.insert(issues).values({ id: issueId, companyId: f.companyId, title: "Existing work", status: "todo", assigneeAgentId: f.triage });
|
|
const bound = await f.service.handoff(f.companyId, board, { case: { id: caseId }, idempotencyKey: randomUUID(), expectedHandoffVersion: 0,
|
|
bindIssueId: issueId, expectedIssueStatusVersion: 0, toAgentId: f.engineer, phase: "implementation", instructions: "Start implementation" });
|
|
expect(bound.taskId).toBe(issueId);
|
|
expect(await db.select().from(caseIssueLinks).where(and(eq(caseIssueLinks.caseId, caseId), eq(caseIssueLinks.issueId, issueId)))).toHaveLength(1);
|
|
await expect(f.service.handoff(f.companyId, board, { ...f.input, bindIssueId: issueId, expectedIssueStatusVersion: 1 })).rejects.toThrow("already the primary");
|
|
});
|
|
|
|
it("starts work for a passive case with multiple open references while preserving all retained sources", async () => {
|
|
const f = await fixture(), caseId = randomUUID(), firstRef = randomUUID(), secondRef = randomUUID();
|
|
await db.insert(cases).values({ id: caseId, companyId: f.companyId, caseNumber: 1, identifier: `${f.companyId}-C1`, caseType: "work", title: "Open reference research" });
|
|
await db.insert(issues).values([
|
|
{ id: firstRef, companyId: f.companyId, title: "First research reference", visibility: "open" },
|
|
{ id: secondRef, companyId: f.companyId, title: "Second research reference", visibility: "open" },
|
|
]);
|
|
for (const sourceId of [firstRef, secondRef]) {
|
|
await retainCaseAccessSource(db, { companyId: f.companyId, caseId, sourceKind: "issue", sourceId });
|
|
await db.insert(caseIssueLinks).values({ companyId: f.companyId, caseId, issueId: sourceId, role: "reference" });
|
|
}
|
|
const kickoff = await f.service.handoff(f.companyId, board, { case: { id: caseId }, expectedHandoffVersion: 0, idempotencyKey: randomUUID(),
|
|
toAgentId: f.triage, phase: "triage", instructions: "Combine the research references", workflow_document_text: f.input.workflow_document_text });
|
|
expect([firstRef, secondRef]).not.toContain(kickoff.taskId);
|
|
expect(await db.select().from(caseAccessSources).where(eq(caseAccessSources.caseId, caseId))).toHaveLength(3);
|
|
expect(await db.select().from(caseIssueLinks).where(eq(caseIssueLinks.caseId, caseId))).toHaveLength(3);
|
|
});
|
|
|
|
it("preserves private same-task access and prevents copying case instructions into wider work", async () => {
|
|
const f = await fixture(), caseId = randomUUID(), privateId = randomUUID(), openId = randomUUID();
|
|
await db.insert(issues).values([
|
|
{ id: privateId, companyId: f.companyId, title: "Private work", status: "todo", visibility: "private", assigneeAgentId: f.triage },
|
|
{ id: openId, companyId: f.companyId, title: "Company-open work", status: "todo", assigneeAgentId: f.triage },
|
|
]);
|
|
await db.insert(cases).values({ id: caseId, companyId: f.companyId, caseNumber: 1, identifier: `${f.companyId}-C1`, caseType: "work", title: "Private case" });
|
|
await retainCaseAccessSource(db, { companyId: f.companyId, caseId, sourceKind: "issue", sourceId: privateId });
|
|
const owner = { type: "agent" as const, companyId: f.companyId, agentId: f.triage, source: "agent_key" as const };
|
|
const input = { case: { id: caseId }, expectedHandoffVersion: 0, expectedIssueStatusVersion: 0,
|
|
idempotencyKey: randomUUID(), toAgentId: f.triage, phase: "triage", instructions: "Confidential findings" };
|
|
await expect(f.service.handoff(f.companyId, owner, { ...input, bindIssueId: openId })).rejects.toThrow("private task boundary");
|
|
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, openId))).toHaveLength(0);
|
|
await f.service.handoff(f.companyId, owner, { ...input, idempotencyKey: randomUUID(), bindIssueId: privateId, toAgentId: f.engineer });
|
|
expect(await canActorReadCase(db, { ...owner, agentId: f.engineer }, caseId)).toBe(true);
|
|
expect(await canActorReadCase(db, { ...owner, agentId: f.verifier }, caseId)).toBe(false);
|
|
await expect(f.service.list({ ...owner, agentId: f.verifier }, caseId)).rejects.toThrow("Case not found");
|
|
});
|
|
|
|
it("revalidates retained-source trust at acceptance, assignment, and wake dispatch", async () => {
|
|
const f = await fixture(), caseId = randomUUID(), sourceId = randomUUID(), q = queue(() => new Date(Date.now() + 60_000));
|
|
const quarantine = buildLowTrustSourceTrust({ issueId: sourceId });
|
|
await db.insert(issues).values({ id: sourceId, companyId: f.companyId, title: "Retained research", sourceTrust: quarantine });
|
|
await db.insert(cases).values({ id: caseId, companyId: f.companyId, caseNumber: 1, identifier: `${f.companyId}-C1`, caseType: "work", title: "Persistent research" });
|
|
await retainCaseAccessSource(db, { companyId: f.companyId, caseId, sourceKind: "issue", sourceId });
|
|
const input = { case: { id: caseId }, idempotencyKey: randomUUID(), expectedHandoffVersion: 0,
|
|
toAgentId: f.triage, phase: "triage", instructions: "Review the research", workflow_document_text: f.input.workflow_document_text };
|
|
await expect(f.service.handoff(f.companyId, board, input)).rejects.toMatchObject({ status: 403 });
|
|
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(1);
|
|
expect(await db.select().from(caseHandoffs).where(eq(caseHandoffs.caseId, caseId))).toHaveLength(0);
|
|
|
|
await db.update(issues).set({ sourceTrust: null }).where(eq(issues.id, sourceId));
|
|
const first = await f.service.handoff(f.companyId, board, input);
|
|
await db.update(issues).set({ sourceTrust: quarantine }).where(eq(issues.id, sourceId));
|
|
await expect(f.service.authorizePendingDispatch(first.handoffId)).rejects.toMatchObject({ status: 403 });
|
|
expect(q.wakeup).not.toHaveBeenCalled();
|
|
await db.update(issues).set({ sourceTrust: null }).where(eq(issues.id, sourceId));
|
|
await q.worker.sweepPending();
|
|
|
|
const runId = await sourceRun(f.companyId, f.triage, first.taskId);
|
|
const [issue] = await db.select().from(issues).where(eq(issues.id, first.taskId));
|
|
const next = await f.service.handoff(f.companyId, board, { case: { id: caseId }, idempotencyKey: randomUUID(), expectedHandoffVersion: 1,
|
|
expectedIssueStatusVersion: issue!.statusVersion, toAgentId: f.engineer, phase: "implementation", instructions: "Build the solution" });
|
|
await db.update(issues).set({ sourceTrust: quarantine }).where(eq(issues.id, sourceId));
|
|
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
|
|
await recordCaseHandoffExecutionReleased(db, runId, first.taskId);
|
|
await expect(f.service.applyPending(next.handoffId)).rejects.toMatchObject({ status: 403 });
|
|
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, next.handoffId)))[0]!.state).toBe("awaiting_release");
|
|
expect((await db.select().from(issues).where(eq(issues.id, first.taskId)))[0]!.assigneeAgentId).toBe(f.triage);
|
|
expect((await db.select().from(cases).where(eq(cases.id, caseId)))[0]!.phase).toBe("triage");
|
|
expect(q.wakeup).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("respects review gates and permits cancellation while the experiment is disabled", async () => {
|
|
const f = await fixture(), first = await f.service.handoff(f.companyId, board, f.input);
|
|
await instanceSettingsService(db).updateExperimental({ enableCases: false });
|
|
try {
|
|
await expect(f.service.handoff(f.companyId, board, { ...f.input, idempotencyKey: randomUUID() })).rejects.toThrow("Cases are disabled");
|
|
expect(await f.service.cancel(board, first.caseId, first.handoffId)).toMatchObject({ state: "cancelled" });
|
|
} finally { await instanceSettingsService(db).updateExperimental({ enableCases: true }); }
|
|
await db.update(issues).set({ executionState: { status: "pending" } }).where(eq(issues.id, first.taskId));
|
|
await expect(f.service.handoff(f.companyId, board, { case: { id: first.caseId }, idempotencyKey: randomUUID(), expectedHandoffVersion: 1,
|
|
expectedIssueStatusVersion: 0, toAgentId: f.engineer, phase: "implementation", instructions: "Skip review" })).rejects.toThrow("review");
|
|
expect((await db.select().from(cases).where(eq(cases.id, first.caseId)))[0]!.handoffVersion).toBe(1);
|
|
});
|
|
|
|
it("continues outgoing yield cleanup while disabled without transferring or waking successor work", async () => {
|
|
const f = await fixture(), at = new Date(Date.now() + 60_000), q = queue(() => at);
|
|
const first = await f.service.handoff(f.companyId, board, f.input); await q.worker.sweepPending();
|
|
const runId = await sourceRun(f.companyId, f.triage, first.taskId);
|
|
const [issue] = await db.select().from(issues).where(eq(issues.id, first.taskId));
|
|
const next = await f.service.handoff(f.companyId, board, { case: { id: first.caseId }, idempotencyKey: randomUUID(), expectedHandoffVersion: 1,
|
|
expectedIssueStatusVersion: issue!.statusVersion, toAgentId: f.engineer, phase: "implementation", instructions: "Continue when enabled" });
|
|
const requestCaseHandoffYield = vi.fn().mockResolvedValue(undefined);
|
|
const disabledWorker = caseHandoffDeliveryService(db, { now: () => at, heartbeat: { wakeup: q.wakeup, requestCaseHandoffYield } as any });
|
|
await instanceSettingsService(db).updateExperimental({ enableCases: false });
|
|
try {
|
|
await disabledWorker.sweepPending();
|
|
expect(requestCaseHandoffYield).toHaveBeenCalledExactlyOnceWith({ runId, issueId: first.taskId, handoffId: next.handoffId });
|
|
expect(q.wakeup).toHaveBeenCalledTimes(1);
|
|
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, next.handoffId)))[0]!.state).toBe("awaiting_release");
|
|
expect((await db.select().from(issues).where(eq(issues.id, first.taskId)))[0]!.assigneeAgentId).toBe(f.triage);
|
|
} finally { await instanceSettingsService(db).updateExperimental({ enableCases: true }); }
|
|
});
|
|
|
|
it("does not advance a settled source whose pinned external binding was revoked", async () => {
|
|
const f = await fixture(), at = new Date(Date.now() + 60_000), q = queue(() => at);
|
|
const first = await f.service.handoff(f.companyId, board, f.input); await q.worker.sweepPending();
|
|
const runId = await sourceRun(f.companyId, f.triage, first.taskId), bindingId = randomUUID();
|
|
const [issue] = await db.select().from(issues).where(eq(issues.id, first.taskId));
|
|
const next = await f.service.handoff(f.companyId, board, { case: { id: first.caseId }, idempotencyKey: randomUUID(), expectedHandoffVersion: 1,
|
|
expectedIssueStatusVersion: issue!.statusVersion, toAgentId: f.engineer, phase: "implementation", instructions: "Continue" });
|
|
const pinned = buildNativeExecutionInput({ companyId: f.companyId, runId, agentId: f.triage,
|
|
issue: { id: first.taskId, identifier: issue!.identifier, title: issue!.title, description: issue!.description, workMode: "standard" },
|
|
taskPrompt: "Handoff", provider: "openai_dot", dotBinding: { companyId: f.companyId, agentId: f.triage, bindingId, bindingGeneration: 1,
|
|
acceptByUnixMs: Date.now() + 60_000, expiresAtUnixMs: Date.now() + 120_000 },
|
|
workspace: { id: randomUUID(), cwd: "/tmp", repoUrl: null, repoRef: null, branchName: null }, normalizedSessionId: null,
|
|
runtimeContext: nativeRuntimeContextFixture(), completionContract: { id: randomUUID(), sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1",
|
|
contract: { revision: "1", objective: "Handoff", criteria: [{ id: "work", requirement: "Continue the task" }] } },
|
|
});
|
|
await db.insert(dotAgentBindings).values({ id: bindingId, companyId: f.companyId, agentId: f.triage, operatorId: "operator", status: "revoked", revokedAt: new Date() });
|
|
await db.update(heartbeatRuns).set({ status: "succeeded", driverKind: "openai_dot_mcp", runnerProfileJson: { nativeExecutionInput: pinned } }).where(eq(heartbeatRuns.id, runId));
|
|
await recordCaseHandoffExecutionReleased(db, runId, first.taskId);
|
|
await q.worker.sweepPending();
|
|
const [waiting] = await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, next.handoffId));
|
|
expect(waiting).toMatchObject({ state: "awaiting_release", lastErrorCode: "access_or_admission_blocked" });
|
|
expect(q.wakeup).toHaveBeenCalledTimes(1);
|
|
expect((await db.select().from(issues).where(eq(issues.id, first.taskId)))[0]!.assigneeAgentId).toBe(f.triage);
|
|
});
|
|
|
|
it("holds delayed assignment after recipient policy revocation and resumes when restored", async () => {
|
|
const f = await fixture();
|
|
let at = new Date(Date.now() + 60_000);
|
|
const q = queue(() => at);
|
|
const first = await f.service.handoff(f.companyId, board, f.input);
|
|
await q.worker.sweepPending();
|
|
const runId = await sourceRun(f.companyId, f.triage, first.taskId);
|
|
const actor = { type: "agent" as const, agentId: f.triage, companyId: f.companyId, runId, source: "agent_jwt" as const };
|
|
const [before] = await db.select().from(issues).where(eq(issues.id, first.taskId));
|
|
const next = await f.service.handoff(f.companyId, actor, {
|
|
case: { id: first.caseId }, idempotencyKey: randomUUID(), expectedHandoffVersion: 1,
|
|
expectedIssueStatusVersion: before!.statusVersion, toAgentId: f.engineer,
|
|
phase: "implementation", instructions: "Implement the saved findings after release.",
|
|
});
|
|
expect(next).toMatchObject({ state: "awaiting_release", callerMustYield: true });
|
|
await db.update(agents).set({ permissions: { authorizationPolicy: { protectedAgent: { blockAssignment: true } } } })
|
|
.where(eq(agents.id, f.engineer));
|
|
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
|
|
await recordCaseHandoffExecutionReleased(db, runId, first.taskId);
|
|
const [settled] = await db.select().from(issues).where(eq(issues.id, first.taskId));
|
|
expect(settled!.statusVersion).toBe(before!.statusVersion);
|
|
await q.worker.sweepPending();
|
|
const [held] = await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, next.handoffId));
|
|
expect(held).toMatchObject({ state: "awaiting_release", lastErrorCode: "access_or_admission_blocked" });
|
|
expect(held!.lastError).toMatch(/protected-agent policy/);
|
|
expect((await db.select().from(issues).where(eq(issues.id, first.taskId)))[0])
|
|
.toMatchObject({ assigneeAgentId: f.triage, status: "in_progress", statusVersion: before!.statusVersion });
|
|
expect((await db.select().from(cases).where(eq(cases.id, first.caseId)))[0]!.phase).toBe("triage");
|
|
expect(q.wakeup).toHaveBeenCalledTimes(1);
|
|
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, held!.wakeupRequestId))).toEqual([]);
|
|
await db.update(agents).set({ permissions: {} }).where(eq(agents.id, f.engineer));
|
|
at = new Date(at.getTime() + 60_000);
|
|
await queue(() => at).worker.sweepPending();
|
|
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, next.handoffId)))[0]!.state).toBe("delivered");
|
|
expect((await db.select().from(issues).where(eq(issues.id, first.taskId)))[0])
|
|
.toMatchObject({ id: first.taskId, status: "todo", assigneeAgentId: f.engineer });
|
|
expect((await db.select().from(cases).where(eq(cases.id, first.caseId)))[0]!.phase).toBe("implementation");
|
|
});
|
|
|
|
it("rechecks the original agent's current assignment grant on a protected task", async () => {
|
|
let grantId = "";
|
|
const f = await delayedTransfer(async (f, taskId) => {
|
|
await db.update(issues).set({ executionPolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).where(eq(issues.id, taskId));
|
|
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "agent", principalId: f.triage, status: "active", membershipRole: "member" });
|
|
const [grant] = await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent", principalId: f.triage, permissionKey: "tasks:assign" }).returning();
|
|
grantId = grant!.id;
|
|
});
|
|
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, grantId));
|
|
await f.release(); await f.sweep();
|
|
await f.assertHeld(/protected|assignment|permission/i);
|
|
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent", principalId: f.triage, permissionKey: "tasks:assign" });
|
|
await f.sweep();
|
|
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, f.receipt.handoffId)))[0]!.state).toBe("delivered");
|
|
});
|
|
|
|
it("rechecks the original agent's active authority after settlement", async () => {
|
|
const f = await delayedTransfer();
|
|
await db.update(agents).set({ status: "terminated" }).where(eq(agents.id, f.triage));
|
|
await f.release(); await f.sweep(); await f.assertHeld(/actor agent.*not active/i);
|
|
});
|
|
|
|
it.each(["revoked", "scope", "responsible_user"] as const)("retains and revalidates the original agent key (%s)", async reason => {
|
|
const keyId = randomUUID(), userId = randomUUID();
|
|
const f = await delayedTransfer(async (f, _taskId, runId) => {
|
|
await db.insert(authUsers).values({ id: userId, name: "Responsible operator", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
|
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "owner" });
|
|
await db.insert(agentApiKeys).values({ id: keyId, companyId: f.companyId, agentId: f.triage, name: "Intake key", keyHash: randomUUID(), responsibleUserId: userId, scopeConfig: { kind: "standard" } });
|
|
return { type: "agent", agentId: f.triage, companyId: f.companyId, runId, source: "agent_key", keyId, keyScope: { kind: "standard" }, onBehalfOfUserId: userId };
|
|
});
|
|
await db.update(agentApiKeys).set(reason === "revoked" ? { revokedAt: new Date() } : reason === "scope" ?
|
|
{ scopeConfig: { kind: "skill_test", issueId: f.first.taskId } } : { responsibleUserId: randomUUID() }).where(eq(agentApiKeys.id, keyId));
|
|
await f.release(); await f.sweep(); await f.assertHeld(/key was revoked or its scope changed/i);
|
|
});
|
|
|
|
it.each([true, false])("preserves a current agent key's accepted authority (run header: %s)", async withRunHeader => {
|
|
const userId = randomUUID(), keyId = randomUUID();
|
|
const f = await delayedTransfer(async (f, taskId, runId) => {
|
|
await db.insert(authUsers).values({ id: userId, name: "Key owner", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
|
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "owner" });
|
|
await db.insert(agentApiKeys).values({ id: keyId, companyId: f.companyId, agentId: f.triage, name: "Assignment key", keyHash: randomUUID(), responsibleUserId: userId });
|
|
// A checkout may outlast execution ownership. An ordinary authenticated
|
|
// key can hand it off without claiming a JWT-bound run identity.
|
|
if (!withRunHeader) await db.update(issues).set({ executionRunId: null }).where(eq(issues.id, taskId));
|
|
return { type: "agent", companyId: f.companyId, agentId: f.triage, source: "agent_key", keyId,
|
|
onBehalfOfUserId: userId, ...(withRunHeader ? { runId } : {}) };
|
|
});
|
|
await f.release(); await f.sweep();
|
|
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, f.receipt.handoffId)))[0]!.state).toBe("delivered");
|
|
expect((await db.select().from(issues).where(eq(issues.id, f.first.taskId)))[0]).toMatchObject({ assigneeAgentId: f.engineer, status: "todo" });
|
|
});
|
|
|
|
it("does not drop an external authorizer's current permission ceiling", async () => {
|
|
const userId = randomUUID();
|
|
const f = await delayedTransfer(async (f, _taskId, runId) => {
|
|
await db.insert(authUsers).values({ id: userId, name: "Authorizing operator", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
|
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "owner" });
|
|
return { type: "agent", agentId: f.triage, companyId: f.companyId, runId, source: "agent_jwt", authorizingUserId: userId };
|
|
});
|
|
await db.delete(companyMemberships).where(eq(companyMemberships.principalId, userId));
|
|
await f.release(); await f.sweep(); await f.assertHeld(/actor.*access|authoriz|permission|membership/i);
|
|
});
|
|
|
|
it.each(["missing", "identity", "scope", "run"] as const)("fails closed on %s retained actor provenance", async reason => {
|
|
const f = await delayedTransfer();
|
|
const [row] = await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, f.receipt.handoffId));
|
|
await db.update(caseHandoffs).set({ actorContext: reason === "missing" ? null : reason === "identity" ?
|
|
{ ...row!.actorContext, actorId: f.engineer } : reason === "run" ? { ...row!.actorContext, runId: randomUUID() } : { ...row!.actorContext, keyScope: { kind: "invented" } } }).where(eq(caseHandoffs.id, f.receipt.handoffId));
|
|
await f.release(); await f.sweep(); await f.assertHeld(reason === "run" ? /execution identity changed/i : /original actor context is unavailable/i);
|
|
});
|
|
|
|
it("rechecks board membership without promoting its sentinel identity to local authority", async () => {
|
|
const userId = "board";
|
|
const f = await delayedTransfer(async f => {
|
|
await db.insert(authUsers).values({ id: userId, name: "Authenticated board", email: "board@example.test", createdAt: new Date(), updatedAt: new Date() });
|
|
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "owner" });
|
|
const sessionId = randomUUID();
|
|
await db.insert(authSessions).values({ id: sessionId, userId, token: randomUUID(), createdAt: new Date(), updatedAt: new Date(), expiresAt: new Date(Date.now() + 60_000) });
|
|
return { type: "board", userId, source: "session", sessionId, companyIds: [f.companyId] };
|
|
});
|
|
await db.delete(companyMemberships).where(eq(companyMemberships.principalId, userId));
|
|
await f.release(); await f.sweep(); await f.assertHeld(/actor.*access|permission|membership|grant/i);
|
|
});
|
|
|
|
it("restores a current trusted Cloud owner's elevation for the delayed assignment", async () => {
|
|
const f = await cloudTransfer();
|
|
try {
|
|
const [stored] = await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, f.receipt.handoffId));
|
|
expect(stored!.actorContext).not.toHaveProperty("isInstanceAdmin");
|
|
expect(stored!.actorContext).not.toHaveProperty("stackRole");
|
|
await f.release(); await f.sweep();
|
|
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, f.receipt.handoffId)))[0]!.state).toBe("delivered");
|
|
expect((await db.select().from(issues).where(eq(issues.id, f.first.taskId)))[0]).toMatchObject({ assigneeAgentId: f.engineer, status: "todo" });
|
|
expect(f.requests).toEqual([{ path: "/v1/tenant/portfolio", userId: f.userId, stackId: f.stackId, authenticated: true }]);
|
|
} finally { await f.close(); }
|
|
});
|
|
|
|
it.each(["admin", "member", "support", "viewer"])("checks current Cloud role %s and the live managed elevation flag", async role => {
|
|
const f = await cloudTransfer();
|
|
try {
|
|
// The local company membership remains owner throughout; it cannot
|
|
// substitute for current Cloud stack ownership or grant elevation.
|
|
f.state.role = role;
|
|
await f.release(); await f.sweep(); await f.assertHeld(role === "viewer" ? /membership does not allow task assignment/i : /protected-agent policy/i);
|
|
expect((await db.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.userId))))[0]!.membershipRole).toBe("owner");
|
|
f.state.role = "owner"; f.flag(false);
|
|
await f.sweep(); await f.assertHeld(/protected-agent policy/i);
|
|
f.flag(true);
|
|
await f.sweep();
|
|
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, f.receipt.handoffId)))[0]!.state).toBe("delivered");
|
|
expect(f.requests).toHaveLength(3); // Every attempt revalidates; no portfolio cache.
|
|
} finally { await f.close(); }
|
|
});
|
|
|
|
it.each(["forbidden", "upstream", "malformed", "ambiguous", "mismatched", "config_changed", "flag_changed"])("fails closed on %s current Cloud authority", async failure => {
|
|
const f = await cloudTransfer();
|
|
try {
|
|
if (failure === "forbidden") f.state.status = 403;
|
|
else if (failure === "upstream") f.state.status = 503;
|
|
else if (failure === "malformed") f.state.body = { stacks: [{ stackSlug: f.stackSlug, role: {}, isCurrent: true }] };
|
|
else if (failure === "ambiguous") f.state.body = { stacks: [1, 2].map(() => ({ stackSlug: f.stackSlug, role: "owner", isCurrent: true })) };
|
|
else if (failure === "mismatched") f.state.body = { stacks: [{ stackSlug: "different-stack", role: "owner", isCurrent: true }] };
|
|
else if (failure === "config_changed") f.state.afterResponse = () => vi.stubEnv("PAPERCLIP_STACK_SLUG", "different-stack");
|
|
else f.state.afterResponse = () => f.flag(false);
|
|
await f.release(); await f.sweep();
|
|
await f.assertHeld(failure === "flag_changed" ? /protected-agent policy/i : /Current Cloud tenant authority is unavailable/i);
|
|
expect(f.requests).toHaveLength(1);
|
|
} finally { await f.close(); }
|
|
});
|
|
|
|
it("does not let current Cloud ownership replace revoked local company membership", async () => {
|
|
const f = await cloudTransfer();
|
|
try {
|
|
await db.delete(companyMemberships).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.userId)));
|
|
await f.release(); await f.sweep(); await f.assertHeld(/not a current company member/i);
|
|
} finally { await f.close(); }
|
|
});
|
|
|
|
it.each(["board_key_revoked", "board_key_expired", "session_deleted", "session_expired"] as const)("revalidates original board credentials (%s)", async reason => {
|
|
const userId = randomUUID(), credentialId = randomUUID();
|
|
const key = reason.startsWith("board_key");
|
|
const f = await delayedTransfer(async f => {
|
|
await db.insert(authUsers).values({ id: userId, name: "Board operator", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
|
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "owner" });
|
|
if (key) await db.insert(boardApiKeys).values({ id: credentialId, userId, name: "CLI operator", keyHash: randomUUID(), expiresAt: new Date(Date.now() + 60_000) });
|
|
else await db.insert(authSessions).values({ id: credentialId, userId, token: randomUUID(), createdAt: new Date(), updatedAt: new Date(), expiresAt: new Date(Date.now() + 60_000) });
|
|
return { type: "board", userId, companyIds: [f.companyId], ...(key ? { source: "board_key", keyId: credentialId } : { source: "session", sessionId: credentialId }) };
|
|
});
|
|
if (reason === "board_key_revoked") await db.update(boardApiKeys).set({ revokedAt: new Date() }).where(eq(boardApiKeys.id, credentialId));
|
|
else if (reason === "board_key_expired") await db.update(boardApiKeys).set({ expiresAt: new Date(0) }).where(eq(boardApiKeys.id, credentialId));
|
|
else if (reason === "session_deleted") await db.delete(authSessions).where(eq(authSessions.id, credentialId));
|
|
else await db.update(authSessions).set({ expiresAt: new Date(0) }).where(eq(authSessions.id, credentialId));
|
|
await f.release(); await f.sweep(); await f.assertHeld(/board (key|session) was revoked or expired/i);
|
|
});
|
|
|
|
it("cannot restore recorded local board authority in a managed Cloud instance", async () => {
|
|
const f = await delayedTransfer(async () => board);
|
|
await f.release();
|
|
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "test-cloud-instance");
|
|
try { await f.sweep(); await f.assertHeld(/local board authority is unavailable/i); }
|
|
finally { vi.unstubAllEnvs(); }
|
|
});
|
|
|
|
it("preserves private ownership transfer and does not reauthorize the old owner after assignment", async () => {
|
|
const f = await delayedTransfer(async (_f, taskId) => { await db.update(issues).set({ visibility: "private" }).where(eq(issues.id, taskId)); });
|
|
await f.release();
|
|
expect(await f.service.applyPending(f.receipt.handoffId)).toMatchObject({ state: "pending_dispatch" });
|
|
const [caseRow] = await db.select().from(cases).where(eq(cases.id, f.first.caseId));
|
|
expect(await canActorReadCase(db, f.actor, caseRow!)).toBe(false);
|
|
await expect(f.service.authorizePendingDispatch(f.receipt.handoffId)).resolves.toBeUndefined();
|
|
await f.sweep();
|
|
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, f.receipt.handoffId)))[0]!.state).toBe("delivered");
|
|
expect((await db.select().from(issues).where(eq(issues.id, f.first.taskId)))[0])
|
|
.toMatchObject({ id: f.first.taskId, visibility: "private", assigneeAgentId: f.engineer, status: "todo" });
|
|
});
|
|
|
|
it("omits server-only actor provenance from REST history, retry, cancel, and execution context", async () => {
|
|
const f = await delayedTransfer();
|
|
const [stored] = await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, f.receipt.handoffId));
|
|
expect(stored!.actorContext).toMatchObject({ schema: "paperclip.case-handoff-actor.v1", actorId: f.triage, source: "agent_jwt", runId: f.runId });
|
|
const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.actor = board; next(); });
|
|
app.use("/api", caseHandoffRoutes(db)); app.use(errorHandler);
|
|
const history = await request(app).get(`/api/cases/${f.first.caseId}/handoffs`).expect(200);
|
|
expect(history.body.items).toHaveLength(2);
|
|
const [caseRow] = await db.select().from(cases).where(eq(cases.id, f.first.caseId));
|
|
const summary = await enrichCaseWork(db, f.companyId, [caseRow!], board);
|
|
expect(summary[0]!.pendingHandoff).toMatchObject({ id: f.receipt.handoffId });
|
|
expect(summary[0]!.pendingHandoff).not.toHaveProperty("actorContext");
|
|
for (const row of history.body.items) expect(row).not.toHaveProperty("actorContext");
|
|
const retry = await request(app).post(`/api/cases/${f.first.caseId}/handoffs/${f.receipt.handoffId}/retry`).expect(200);
|
|
expect(retry.body).not.toHaveProperty("actorContext");
|
|
const context = await getCaseExecutionContext(db, { companyId: f.companyId, issueId: f.first.taskId, agentId: f.triage, runId: f.runId });
|
|
expect(JSON.stringify(context)).not.toContain("paperclip.case-handoff-actor");
|
|
const events = await db.select().from(caseEvents).where(eq(caseEvents.caseId, f.first.caseId));
|
|
expect(JSON.stringify(events)).not.toContain("actorContext");
|
|
const cancelled = await request(app).post(`/api/cases/${f.first.caseId}/handoffs/${f.receipt.handoffId}/cancel`).expect(200);
|
|
expect(cancelled.body).toMatchObject({ state: "cancelled" });
|
|
expect(cancelled.body).not.toHaveProperty("actorContext");
|
|
const replayCancel = await request(app).post(`/api/cases/${f.first.caseId}/handoffs/${f.receipt.handoffId}/cancel`).expect(200);
|
|
expect(replayCancel.body).not.toHaveProperty("actorContext");
|
|
});
|
|
|
|
it("records terminal intent without assignment authority, source yield, task completion, or another wake", async () => {
|
|
const f = await terminalFixture();
|
|
await db.update(issues).set({ executionPolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } })
|
|
.where(eq(issues.id, f.first.taskId));
|
|
const [before] = await db.select().from(issues).where(eq(issues.id, f.first.taskId));
|
|
const receipt = await f.service.handoff(f.companyId, f.actor, f.input);
|
|
expect(receipt).toMatchObject({ destinationKind: "done", state: "awaiting_completion", completedAt: null,
|
|
taskId: f.first.taskId, handoffVersion: 2, callerMustYield: false, replayed: false });
|
|
const [task] = await db.select().from(issues).where(eq(issues.id, f.first.taskId));
|
|
expect(task).toMatchObject({ status: before!.status, statusVersion: before!.statusVersion, assigneeAgentId: f.engineer,
|
|
executionRunId: f.runId, checkoutRunId: f.runId, description: before!.description });
|
|
expect((await db.select().from(cases).where(eq(cases.id, f.first.caseId)))[0]).toMatchObject({ status: "draft", phase: "triage", handoffVersion: 2, completedAt: null });
|
|
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, receipt.handoffId)))[0]).toMatchObject({
|
|
destinationKind: "done", toAgentId: null, sourceRunId: f.runId, assignmentAppliedAt: null, deliveredAt: null });
|
|
expect(await pendingCaseHandoffForRun(db, f.runId)).toBeNull();
|
|
await db.transaction(tx => assertAgentRunWriteAllowed(tx as unknown as typeof db, f.companyId, { agentId: f.engineer, runId: f.runId }));
|
|
expect(await f.service.retry(f.actor, f.first.caseId, receipt.handoffId)).toMatchObject({ state: "awaiting_completion" });
|
|
await f.q.worker.sweepPending();
|
|
expect(f.q.wakeup).toHaveBeenCalledTimes(1);
|
|
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.first.taskId))).toHaveLength(2);
|
|
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(1);
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
|
|
});
|
|
|
|
it.each(["sweep", "locked_escalation"] as const)(
|
|
"retains ordinary %s recovery after a terminal-intent source crashes before task completion",
|
|
async mode => {
|
|
const f = await terminalFixture();
|
|
const receipt = await f.service.handoff(f.companyId, f.actor, f.input);
|
|
expect(receipt).toMatchObject({ state: "awaiting_completion", callerMustYield: false });
|
|
// The source stopped before completing the ordinary task. A terminal
|
|
// intent owns no successor run: paused-owner recovery must stay visible.
|
|
const [failedRun] = await db.update(heartbeatRuns).set({
|
|
status: "failed", errorCode: "process_lost", error: "Source crashed after recording terminal intent",
|
|
finishedAt: new Date(),
|
|
}).where(eq(heartbeatRuns.id, f.runId)).returning();
|
|
await db.update(agents).set({ status: "paused" }).where(eq(agents.id, f.engineer));
|
|
const [observed] = await db.select().from(issues).where(eq(issues.id, f.first.taskId));
|
|
const enqueueWakeup = vi.fn(async () => null);
|
|
const recovery = recoveryService(db, { enqueueWakeup });
|
|
if (mode === "sweep") {
|
|
expect(await recovery.reconcileStrandedAssignedIssues())
|
|
.toMatchObject({ escalated: 1, issueIds: [f.first.taskId] });
|
|
} else {
|
|
expect(await recovery.escalateStrandedAssignedIssue({
|
|
issue: observed!, previousStatus: "in_progress", latestRun: failedRun!,
|
|
comment: "The source stopped before the task completed and its assignee is paused.",
|
|
})).toMatchObject({ id: f.first.taskId, status: "blocked" });
|
|
}
|
|
expect((await db.select().from(issues).where(eq(issues.id, f.first.taskId)))[0])
|
|
.toMatchObject({ status: "blocked", assigneeAgentId: f.engineer, completedAt: null });
|
|
expect((await db.select().from(cases).where(eq(cases.id, f.first.caseId)))[0])
|
|
.toMatchObject({ status: "draft", completedAt: null });
|
|
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, receipt.handoffId)))[0])
|
|
.toMatchObject({ state: "awaiting_completion", destinationKind: "done", completedAt: null });
|
|
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(1);
|
|
expect(enqueueWakeup).not.toHaveBeenCalled();
|
|
expect(f.q.wakeup).toHaveBeenCalledTimes(1);
|
|
|
|
// Existing task controls resolve the visible hold. The original intent
|
|
// closes the same case only when ordinary task completion is accepted.
|
|
await db.update(agents).set({ status: "active" }).where(eq(agents.id, f.engineer));
|
|
await issueService(db).update(f.first.taskId, { status: "done" });
|
|
expect((await db.select().from(issues).where(eq(issues.id, f.first.taskId)))[0])
|
|
.toMatchObject({ id: f.first.taskId, status: "done" });
|
|
expect((await db.select().from(cases).where(eq(cases.id, f.first.caseId)))[0])
|
|
.toMatchObject({ status: "done", phase: "done", completedAt: expect.any(Date) });
|
|
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, receipt.handoffId)))[0])
|
|
.toMatchObject({ state: "completed", completedAt: expect.any(Date) });
|
|
expect(f.q.wakeup).toHaveBeenCalledTimes(1);
|
|
},
|
|
);
|
|
|
|
it("commits task and terminal case completion atomically through the ordinary issue service", async () => {
|
|
const f = await terminalFixture(), receipt = await f.service.handoff(f.companyId, f.actor, f.input);
|
|
await expect(db.transaction(async tx => {
|
|
await issueService(tx as unknown as typeof db).update(f.first.taskId, { status: "done", actorAgentId: f.engineer,
|
|
actorRunId: f.runId }, tx as unknown as typeof db);
|
|
throw new Error("Crash before commit");
|
|
})).rejects.toThrow("Crash before commit");
|
|
expect((await db.select().from(issues).where(eq(issues.id, f.first.taskId)))[0]!.status).toBe("in_progress");
|
|
expect((await db.select().from(cases).where(eq(cases.id, f.first.caseId)))[0]!.status).toBe("draft");
|
|
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, receipt.handoffId)))[0]!.state).toBe("awaiting_completion");
|
|
await issueService(db).update(f.first.taskId, { status: "done", actorAgentId: f.engineer, actorRunId: f.runId });
|
|
const [completedCase] = await db.select().from(cases).where(eq(cases.id, f.first.caseId));
|
|
const [completed] = await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, receipt.handoffId));
|
|
expect(completedCase).toMatchObject({ status: "done", phase: "done", handoffVersion: 2 });
|
|
expect(completedCase!.completedAt).toBeInstanceOf(Date);
|
|
expect(completed).toMatchObject({ destinationKind: "done", state: "completed", toAgentId: null });
|
|
expect(completed!.completedAt).toBeInstanceOf(Date);
|
|
expect(await db.select().from(activityLog).where(and(eq(activityLog.entityId, f.first.caseId), eq(activityLog.action, "case.handoff_completed")))).toHaveLength(1);
|
|
expect(await completeTerminalCaseHandoffsForIssue(db, { companyId: f.companyId, issueId: f.first.taskId })).toBe(0);
|
|
expect(await f.service.handoff(f.companyId, f.actor, f.input)).toMatchObject({ handoffId: receipt.handoffId,
|
|
state: "completed", callerMustYield: false, replayed: true });
|
|
expect(f.q.wakeup).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each(["todo", "in_review", "blocked"] as const)("leaves %s work and its review or blocker unresolved when terminal intent is recorded", async status => {
|
|
const f = await terminalFixture();
|
|
await db.update(issues).set({ status, executionState: status === "in_review" ? { status: "pending" } : null })
|
|
.where(eq(issues.id, f.first.taskId));
|
|
const [task] = await db.select().from(issues).where(eq(issues.id, f.first.taskId));
|
|
const receipt = await f.service.handoff(f.companyId, board, { ...f.input, expectedIssueStatusVersion: task!.statusVersion });
|
|
expect(receipt).toMatchObject({ state: "awaiting_completion", callerMustYield: false });
|
|
expect(await completeTerminalCaseHandoffsForIssue(db, { companyId: f.companyId, issueId: f.first.taskId })).toBe(0);
|
|
expect((await db.select().from(issues).where(eq(issues.id, f.first.taskId)))[0]!.status).toBe(status);
|
|
expect((await db.select().from(cases).where(eq(cases.id, f.first.caseId)))[0]!.status).toBe("draft");
|
|
await instanceSettingsService(db).updateExperimental({ enableCases: false });
|
|
try { expect(await f.service.cancel(board, f.first.caseId, receipt.handoffId)).toMatchObject({ state: "cancelled" }); }
|
|
finally { await instanceSettingsService(db).updateExperimental({ enableCases: true }); }
|
|
await db.update(issues).set({ status: "done", executionState: null }).where(eq(issues.id, f.first.taskId));
|
|
expect(await completeTerminalCaseHandoffsForIssue(db, { companyId: f.companyId, issueId: f.first.taskId })).toBe(0);
|
|
expect((await db.select().from(cases).where(eq(cases.id, f.first.caseId)))[0]!.status).toBe("draft");
|
|
});
|
|
|
|
it("settles a terminal intent immediately for an already completed primary task", async () => {
|
|
const f = await terminalFixture();
|
|
await db.update(issues).set({ status: "done", executionRunId: null, checkoutRunId: null }).where(eq(issues.id, f.first.taskId));
|
|
const [task] = await db.select().from(issues).where(eq(issues.id, f.first.taskId));
|
|
const receipt = await f.service.handoff(f.companyId, board, { ...f.input, expectedIssueStatusVersion: task!.statusVersion });
|
|
expect(receipt).toMatchObject({ state: "completed", destinationKind: "done", callerMustYield: false });
|
|
expect(receipt.completedAt).toBeInstanceOf(Date);
|
|
expect((await db.select().from(cases).where(eq(cases.id, f.first.caseId)))[0]).toMatchObject({ status: "done", phase: "done" });
|
|
expect(f.q.wakeup).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("serializes terminal and agent handoffs into one routing decision", async () => {
|
|
const f = await terminalFixture();
|
|
const accepted = await Promise.allSettled([
|
|
f.service.handoff(f.companyId, board, f.input),
|
|
f.service.handoff(f.companyId, board, { case: f.input.case, idempotencyKey: randomUUID(),
|
|
expectedHandoffVersion: 1, expectedIssueStatusVersion: f.input.expectedIssueStatusVersion,
|
|
toAgentId: f.verifier, phase: "verification", instructions: "Review the saved work" }),
|
|
]);
|
|
expect(accepted.filter(row => row.status === "fulfilled")).toHaveLength(1);
|
|
expect(accepted.find(row => row.status === "rejected")).toMatchObject({ reason: { status: 409 } });
|
|
expect((await db.select().from(cases).where(eq(cases.id, f.first.caseId)))[0]!.handoffVersion).toBe(2);
|
|
expect(await db.select().from(caseHandoffs).where(eq(caseHandoffs.caseId, f.first.caseId))).toHaveLength(2);
|
|
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.first.taskId))).toHaveLength(2);
|
|
});
|
|
|
|
it("replays terminal intent after ownership changes without granting fresh mutation authority", async () => {
|
|
const f = await terminalFixture(), receipt = await f.service.handoff(f.companyId, f.actor, f.input);
|
|
await issueService(db).update(f.first.taskId, { assigneeAgentId: f.verifier });
|
|
expect(await f.service.handoff(f.companyId, f.actor, f.input)).toMatchObject({ handoffId: receipt.handoffId, replayed: true,
|
|
state: "cancelled", callerMustYield: false });
|
|
await expect(f.service.handoff(f.companyId, f.actor, { ...f.input, idempotencyKey: randomUUID() })).rejects.toMatchObject({ status: 409 });
|
|
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.first.taskId))).toHaveLength(2);
|
|
});
|
|
|
|
it("normalizes terminal REST phase defaults and replays the same receipt without duplicating comments", async () => {
|
|
const f = await terminalFixture(), app = express();
|
|
app.use(express.json()); app.use((req, _res, next) => { req.actor = f.actor; next(); });
|
|
app.use("/api", caseHandoffRoutes(db)); app.use(errorHandler);
|
|
const path = `/api/companies/${f.companyId}/case-handoffs`;
|
|
const first = await request(app).post(path).send({ ...f.input, phase: undefined }).expect(202);
|
|
const replay = await request(app).post(path).send(f.input).expect(200);
|
|
expect(first.body).toMatchObject({ destinationKind: "done", state: "awaiting_completion", callerMustYield: false, completedAt: null });
|
|
expect(replay.body).toMatchObject({ handoffId: first.body.handoffId, replayed: true });
|
|
await request(app).post(path).send({ ...f.input, instructions: "A different request" }).expect(409);
|
|
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.first.taskId))).toHaveLength(2);
|
|
});
|
|
|
|
it("enforces terminal destination-state constraints and pending uniqueness in the real database", async () => {
|
|
const f = await terminalFixture(), receipt = await f.service.handoff(f.companyId, f.actor, f.input);
|
|
const [row] = await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, receipt.handoffId));
|
|
await expect(db.insert(caseHandoffs).values({ ...row!, id: randomUUID(), version: 3, idempotencyKey: randomUUID() }))
|
|
.rejects.toMatchObject({ cause: { code: "23505" } });
|
|
await expect(db.update(caseHandoffs).set({ toAgentId: f.verifier }).where(eq(caseHandoffs.id, row!.id)))
|
|
.rejects.toMatchObject({ cause: { code: "23514" } });
|
|
await expect(db.update(caseHandoffs).set({ state: "pending_dispatch" }).where(eq(caseHandoffs.id, row!.id)))
|
|
.rejects.toMatchObject({ cause: { code: "23514" } });
|
|
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, row!.id)))[0]).toMatchObject({
|
|
destinationKind: "done", toAgentId: null, state: "awaiting_completion" });
|
|
});
|
|
|
|
it("reauthorizes terminal receipt replay against newly retained private sources", async () => {
|
|
const f = await terminalFixture(), receipt = await f.service.handoff(f.companyId, f.actor, f.input), privateId = randomUUID();
|
|
await db.insert(issues).values({ id: privateId, companyId: f.companyId, title: "Private retained findings", visibility: "private", assigneeAgentId: f.verifier });
|
|
await retainCaseAccessSource(db, { companyId: f.companyId, caseId: f.first.caseId, sourceKind: "issue", sourceId: privateId });
|
|
await expect(f.service.handoff(f.companyId, f.actor, f.input)).rejects.toMatchObject({ status: 404 });
|
|
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, receipt.handoffId)))[0]!.state).toBe("awaiting_completion");
|
|
});
|
|
|
|
it.each(["cancelled", "planning", "conversation", "passive", "wrong_owner", "stale_version", "quarantine"] as const)(
|
|
"rejects unsupported terminal %s without recording or closing work", async reason => {
|
|
const f = await terminalFixture();
|
|
if (reason === "cancelled") await db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, f.first.taskId));
|
|
if (reason === "planning") await db.update(issues).set({ workMode: "planning" }).where(eq(issues.id, f.first.taskId));
|
|
if (reason === "conversation") await db.update(issues).set({ conversationAgentId: f.engineer,
|
|
conversationUserId: "fixture-user", conversationState: "active" }).where(eq(issues.id, f.first.taskId));
|
|
if (reason === "passive") await db.update(cases).set({ workIssueId: null }).where(eq(cases.id, f.first.caseId));
|
|
if (reason === "quarantine") await db.update(issues).set({ sourceTrust: buildLowTrustSourceTrust({ issueId: f.first.taskId }) }).where(eq(issues.id, f.first.taskId));
|
|
const actor = reason === "wrong_owner" ? { ...f.actor, agentId: f.verifier, runId: undefined } : board;
|
|
const [task] = await db.select().from(issues).where(eq(issues.id, f.first.taskId));
|
|
const input = { ...f.input, expectedIssueStatusVersion: task!.statusVersion + Number(reason === "stale_version") };
|
|
const status = reason === "wrong_owner" || reason === "quarantine" ? 403 : reason === "stale_version" ? 409 : 422;
|
|
await expect(f.service.handoff(f.companyId, actor, input)).rejects.toMatchObject({ status });
|
|
expect(await db.select().from(caseHandoffs).where(eq(caseHandoffs.caseId, f.first.caseId))).toHaveLength(1);
|
|
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.first.taskId))).toHaveLength(1);
|
|
expect((await db.select().from(cases).where(eq(cases.id, f.first.caseId)))[0]).toMatchObject({ status: "draft", handoffVersion: 1 });
|
|
});
|
|
});
|