mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## Thinking Path > - Paperclip manages agents and their work. > - Agent Chat can wait for a user between useful turns. > - The task Properties panel already lets that user request context compaction. > - The queue treated that maintenance request as an ordinary wake and refused it while the chat waited. > - Compaction must maintain that session without starting a new conversation turn. > - This change admits the authenticated request, preserves the waiting state during execution, and prevents competing requests from saving extra maintenance work. > - Ordinary and unauthorized wakes remain blocked. ## Linked Issues or Issue Description Refs #19378, which introduced the existing Compact now action. Refs #19377, whose review identified this independent queue admission gap. Searches for `context_compaction` with `waiting` and `Compact now` with `waiting` found no existing fix for this admission bug. **What happened?** An owner clicks Compact now for a waiting Agent Chat conversation. The real compaction service reaches the heartbeat queue, receives no run, and reports that compaction did not start. **Expected behavior** The authenticated owner can request one separate compaction run while the chat waits. The request must keep its existing actor, task, agent, budget, and execution checks. Ordinary waiting-chat wakes must stay blocked. **Steps to reproduce** 1. Use an open Agent Chat conversation whose agent supports compaction. 2. Wait until the conversation is waiting for its owner. 3. Request Compact now from the task Properties panel. 4. Observe the refusal before this fix. The new database regression calls the real compaction service and queue to reproduce it. ## What Changed - Admit only the existing complete manual compaction envelope. Require a user actor, no coalescing, the compaction intent, and matching nonempty request IDs. - Preserve the waiting conversation state, session generation, reply boundary and unanswered questions in the existing conversation preparation owner. - Recheck active and deferred maintenance under the existing issue admission lock. Competing clicks cannot save a second maintenance request. - Add execution coverage with and without a pending question. Add concurrent admission and deferred-request controls, including admission after the pending receipt is cancelled. Keep the existing authorization and ordinary conversation controls. ## Verification - The original service-to-queue request failed before the admission fix. - The execution regression then caught the review finding: compaction succeeded, but both waiting-state controls failed. The same complete controls passed after the conversation preparation fix. - A real concurrent request test recorded two durable maintenance receipts before the atomic admission fix. It counts deferred authority as well as admitted runs. - Current integrated candidate: normal current Runner core and surface TypeScript exports passed. All seven normal App typecheck programs passed. The whole queue/execution suites passed 86/86, the whole context-compaction suite passed 5/5, and 11 selected ordinary conversation controls passed. There were 102 actual passes; the 29 unselected conversation cases are not counted as passes. - Database controls use fresh dedicated PostgreSQL 17 databases, Node 24, one test worker and pools capped at four. Private fixture adapters use the existing dedicated database and normal migrations. They keep test bodies, assertions and time limits intact. - Earlier fixture and setup failures remain recorded separately from product failures. These include an omitted external URL, an invalid closed-task fixture, a missing crypto import, an incorrect result-envelope matcher, and an unavailable test-library helper. Each was corrected at its owning boundary. A test-output text matcher also missed Vitest's abbreviated failure; the raw assertion stack and failure were retained. - Local session tests exercise the actual admission, native Runner lifecycle, accepted result and persisted conversation state through the supported test backend. They do not prove live provider compaction, UI rendering or deployed suspension. - Exact-head remote CI passed: 59 successful checks, three intentional skips and one neutral check. Greptile and Ponytail both reviewed this head at 5/5 with no actionable findings. Ponytail explicitly marked it Ship. - Master advanced during review. A local merge evaluation preserves every unrelated master change and all four PR files. The current App programs and all three execution controls passed again on that projected squash tree to check the workspace-finalization seam. ## Risks The change affects admission and preparation for one maintenance request. A broad exception could start ordinary work or reopen a waiting chat. The request therefore keeps the complete existing manual envelope and authorization checks. Conversation preparation still checks the session generation. The existing issue lock owns duplicate admission. There is no new timer, database migration or provider protocol. ## Model Used OpenAI Codex based on GPT-6 managed the change and reviewed the shared queue fix. OpenAI `gpt-6.1-sol` authored the focused regression coverage. Both used reasoning, repository tools and local code execution. The runtime did not expose the context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (the existing Compact now documentation remains accurate; no new configuration is added) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
176 lines
12 KiB
TypeScript
176 lines
12 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { agents, agentTaskSessions, authUsers, companyMemberships, createDb, heartbeatRuns,
|
|
issueComments, issueThreadInteractions, issues, nativeRunResults } from "@paperclipai/db";
|
|
import type { NativeRunIdentity, NativeSession, NativeSessionBackend, PrpEvent } from "@paperclipai/paperclip-runner";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { createRunDispatch } from "../modules/run-dispatch/index.js";
|
|
import { heartbeatService, type HeartbeatServiceOptions } from "../services/heartbeat.js";
|
|
import { contextCompactionService } from "../services/context-compaction.js";
|
|
import { instanceSettingsService } from "../services/instance-settings.js";
|
|
import { closeIdleWarmNativeSessionsForRestart } from "../services/native-runtime/native-session-executor.js";
|
|
import { nativeRestartFixture, waitForCondition } from "./helpers/native-restart-fixture.js";
|
|
import { getRecoveryPostgresSupport } from "./helpers/recovery-postgres-fixture.js";
|
|
|
|
// Only this owner opts into its dedicated database. Importing the fixture core
|
|
// avoids the default support facade's embedded-platform probe in that mode.
|
|
const dedicatedDatabaseUrl = process.env.PAPERCLIP_CONTEXT_COMPACTION_EXECUTION_TEST_DATABASE_URL;
|
|
const databaseOptions = dedicatedDatabaseUrl === undefined ? {} : { dedicatedDatabaseUrl };
|
|
const support = await getRecoveryPostgresSupport(databaseOptions);
|
|
|
|
describe.skipIf(!support.supported)("heartbeat context compaction execution", () => {
|
|
const fixture = nativeRestartFixture(databaseOptions);
|
|
|
|
async function conversation() {
|
|
const { companyId, agentId, temporary } = fixture.current;
|
|
const db = createDb(temporary.connectionString, { maxConnections: 4 });
|
|
const userId = randomUUID();
|
|
await db.insert(authUsers).values({ id: userId, name: "Conversation owner", email: `${userId}@example.invalid`,
|
|
createdAt: new Date(), updatedAt: new Date() });
|
|
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, membershipRole: "owner" });
|
|
await db.update(agents).set({ status: "idle", adapterConfig: { provider: "codex", lifecycleMode: "warm", idleTimeoutMs: 60_000 } })
|
|
.where(eq(agents.id, agentId));
|
|
await instanceSettingsService(db).updateExperimental({ enableAgentChat: true, enableNativeRunner: true });
|
|
const [issue] = await db.insert(issues).values({ companyId, title: "Keep the conversation parked", status: "in_progress",
|
|
assigneeAgentId: agentId, responsibleUserId: userId, conversationAgentId: agentId, conversationUserId: userId,
|
|
conversationState: "active" }).returning();
|
|
return { db, companyId, agentId, userId, issue };
|
|
}
|
|
|
|
// The first ordinary conversation turn produces a real idle checkpoint and
|
|
// task-session pointer through the normal owner. Its final text is a provider
|
|
// event, not a seeded result. The second turn uses the existing Runner
|
|
// compaction protocol; result() deliberately never supplies an outcome.
|
|
function conversationBackend(db: ReturnType<typeof createDb>) {
|
|
let identity: NativeRunIdentity;
|
|
let sourceInstanceId = "";
|
|
let activeTurnId: string | null = null;
|
|
let nextTurn = Promise.withResolvers<{ compact: boolean; turnId: string }>();
|
|
const startTurn = vi.fn<NativeSession["startTurn"]>(async input => {
|
|
const turnId = randomUUID();
|
|
activeTurnId = turnId;
|
|
nextTurn.resolve({ compact: input.compact === true, turnId });
|
|
return { turnId };
|
|
});
|
|
const capabilities = { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true };
|
|
const session: NativeSession = {
|
|
identity: () => identity,
|
|
capabilities: async () => capabilities,
|
|
attachRun: async input => { identity = input.identity; },
|
|
startTurn,
|
|
async *events() {
|
|
const turn = await nextTurn.promise;
|
|
const event = (sourceSeq: number, eventType: PrpEvent["eventType"], payload: Record<string, unknown>): PrpEvent => ({
|
|
schema: "paperclip.prp.event.v1", sourceKind: "runner", sourceInstanceId,
|
|
sourceEventId: `${sourceInstanceId}:${identity.runId}:${sourceSeq}`, sourceSeq,
|
|
runId: identity.runId, normalizedSessionId: identity.sessionId, turnId: turn.turnId,
|
|
schemaVersion: 1, priority: 0, emittedAt: new Date().toISOString(), eventType, payload,
|
|
});
|
|
yield event(1, "turn.started", {});
|
|
yield turn.compact
|
|
? event(2, "context.compacted", { schema: "paperclip.context.compacted.v1", compactionId: randomUUID(),
|
|
reason: "provider", preTokens: null, postTokens: null, sameSession: true })
|
|
: event(2, "item.completed", { kind: "agentMessage", channel: "final", text: "Ready for your next message." });
|
|
activeTurnId = null;
|
|
nextTurn = Promise.withResolvers<{ compact: boolean; turnId: string }>();
|
|
yield event(3, "turn.completed", { status: "completed" });
|
|
},
|
|
result: async () => null,
|
|
snapshot: async () => ({ backendKind: "mock", sessionId: "compaction-session", identity,
|
|
providerSessionId: "compaction-provider-session", cursor: null, activeTurnId, pendingRuntimeRequests: [], lineage: [] }),
|
|
close: vi.fn(async () => {}),
|
|
};
|
|
const factory: NonNullable<HeartbeatServiceOptions["nativeSessionBackendFactory"]> = execution => {
|
|
const backend: NativeSessionBackend = {
|
|
descriptor: async () => {
|
|
const [run] = await db.select({ runnerInstanceId: heartbeatRuns.runnerInstanceId }).from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, execution.binding.runId));
|
|
if (!run?.runnerInstanceId) throw new Error("Normal native admission did not persist a runner identity");
|
|
sourceInstanceId = run.runnerInstanceId;
|
|
return { kind: "mock", name: "conversation-compaction", version: "1", capabilities,
|
|
runtimeContextCapabilities: { instructions: "native", skills: "native", mcp: "native" } };
|
|
},
|
|
openSession: async input => { identity = input.identity; return session; },
|
|
recoverSession: async snapshot => { identity = snapshot.identity; return { recovered: true, session }; },
|
|
};
|
|
return backend;
|
|
};
|
|
return { factory, startTurn, session };
|
|
}
|
|
|
|
it.each([false, true])("keeps a successfully compacted conversation waiting (pending question: %s)", async pendingQuestion => {
|
|
const f = await conversation();
|
|
const backend = conversationBackend(f.db);
|
|
const heartbeat = heartbeatService(f.db, { nativeSessionBackendFactory: backend.factory });
|
|
try {
|
|
const [message] = await f.db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issue.id,
|
|
authorType: "user", authorUserId: f.userId, body: "Start this conversation." }).returning();
|
|
const first = await heartbeat.wakeup(f.agentId, { source: "on_demand", triggerDetail: "manual", reason: "issue_commented",
|
|
requestedByActorType: "user", requestedByActorId: f.userId, manualUserWake: true,
|
|
payload: { issueId: f.issue.id, commentId: message.id },
|
|
contextSnapshot: { issueId: f.issue.id, taskKey: f.issue.id, wakeCommentId: message.id } });
|
|
expect(first).not.toBeNull();
|
|
await waitForCondition("normal conversation execution and wakes to settle", () => heartbeat.getTaskDrainStatus().quiescent);
|
|
expect((await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, first!.id)))[0])
|
|
.toMatchObject({ status: "succeeded" });
|
|
const [idle] = await f.db.select().from(agentTaskSessions).where(and(eq(agentTaskSessions.companyId, f.companyId),
|
|
eq(agentTaskSessions.agentId, f.agentId), eq(agentTaskSessions.taskKey, f.issue.id)));
|
|
expect(idle).toMatchObject({ lastRunId: first!.id });
|
|
expect(idle.sessionParamsJson).toBeTruthy();
|
|
const [before] = await f.db.select().from(issues).where(eq(issues.id, f.issue.id));
|
|
expect(before).toMatchObject({ status: "in_review", conversationState: "waiting" });
|
|
const [question] = pendingQuestion ? await f.db.insert(issueThreadInteractions).values({
|
|
companyId: f.companyId, issueId: f.issue.id, createdByAgentId: f.agentId, sourceRunId: first!.id,
|
|
kind: "ask_user_questions", status: "pending", addresseeUserId: f.userId,
|
|
payload: { version: 1, questions: [{ id: "q", prompt: "Which option?", selectionMode: "single",
|
|
options: [{ id: "a", label: "Option A" }] }] },
|
|
}).returning() : [];
|
|
const commentsBefore = await f.db.select().from(issueComments).where(eq(issueComments.issueId, f.issue.id));
|
|
const interactionsBefore = await f.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, f.issue.id));
|
|
const compact = await contextCompactionService(f.db, { wakeup: heartbeat.wakeup }).request({
|
|
companyId: f.companyId, issue: before, userId: f.userId,
|
|
});
|
|
await waitForCondition("normal compaction execution and wakes to settle", () => heartbeat.getTaskDrainStatus().quiescent);
|
|
const [run] = await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, compact.runId));
|
|
expect(run).toMatchObject({ status: "succeeded", contextSnapshot: { contextCompactionRequestId: compact.requestId } });
|
|
expect(backend.startTurn).toHaveBeenCalledTimes(2);
|
|
expect(backend.startTurn).toHaveBeenLastCalledWith(expect.objectContaining({ compact: true, continuation: true }));
|
|
expect(await f.db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, compact.runId)))
|
|
.toEqual([expect.objectContaining({ schemaStatus: "accepted", resultJson: expect.objectContaining({
|
|
result: expect.objectContaining({ summary: "Context compacted on request.", reportedWorkDisposition: "yielded" }),
|
|
}) })]);
|
|
expect((await f.db.select().from(issues).where(eq(issues.id, f.issue.id)))[0]).toMatchObject({
|
|
status: "in_review", conversationState: "waiting", conversationSessionGeneration: before.conversationSessionGeneration,
|
|
conversationBoundaryCommentId: before.conversationBoundaryCommentId,
|
|
});
|
|
expect(await f.db.select().from(issueComments).where(eq(issueComments.issueId, f.issue.id))).toEqual(commentsBefore);
|
|
expect(await f.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, f.issue.id))).toEqual(interactionsBefore);
|
|
if (question) expect(question.status).toBe("pending");
|
|
expect(await heartbeat.wakeup(f.agentId, { source: "automation", triggerDetail: "system", reason: "issue.continuation_recovery",
|
|
requestedByActorType: "system", payload: { issueId: f.issue.id }, contextSnapshot: { issueId: f.issue.id } })).toBeNull();
|
|
expect(backend.startTurn).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
await heartbeat.drainActiveRunExecutions();
|
|
await closeIdleWarmNativeSessionsForRestart();
|
|
await f.db.$client.end();
|
|
}
|
|
}, 60_000);
|
|
|
|
it("still cancels an ordinary continuation while its question awaits an authorized answer", async () => {
|
|
const f = await conversation();
|
|
try {
|
|
await f.db.update(issues).set({ status: "in_review", conversationState: "waiting" }).where(eq(issues.id, f.issue.id));
|
|
await f.db.insert(issueThreadInteractions).values({ companyId: f.companyId, issueId: f.issue.id,
|
|
createdByAgentId: f.agentId, addresseeUserId: f.userId, kind: "ask_user_questions", status: "pending",
|
|
payload: { version: 1, questions: [{ id: "q", prompt: "Continue?", selectionMode: "single", options: [{ id: "a", label: "Yes" }] }] } });
|
|
const [run] = await f.db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId,
|
|
status: "queued", runtimeMode: "legacy", invocationSource: "automation", triggerDetail: "system", responsibleUserId: f.userId,
|
|
resultJson: { conversationContinuation: "continue_conversation_v1" }, contextSnapshot: { issueId: f.issue.id } }).returning();
|
|
const result = await createRunDispatch(f.db).cancelStaleQueuedRun({ runId: run.id, companyId: f.companyId, expectedStatus: "queued" });
|
|
expect(result).toMatchObject({ outcome: "cancelled", errorCode: "issue_waiting_for_response" });
|
|
expect((await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0])
|
|
.toMatchObject({ status: "cancelled", errorCode: "issue_waiting_for_response" });
|
|
} finally { await f.db.$client.end(); }
|
|
});
|
|
});
|