mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
fix(server): wake chat milestones when questions resolve or retire
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
f398314ecd
commit
20f8c301f5
4 files changed
+109
-1
No files matched your search
@@ -1472,3 +1472,19 @@ coverage. All 74 selected worker/adapter/policy cases and server production type
|
||||
pass. Remaining publication, interaction, configuration, question and monitor
|
||||
producer coverage still gates periodic-projector removal. Original inventory,
|
||||
remote gates/findings and final master reconciliation remain open.
|
||||
|
||||
## Slice 28 draft and publication/interaction coverage
|
||||
|
||||
[Draft #17606](https://github.com/paperclipai/paperclip/pull/17606) is stacked on
|
||||
#17592. It adds the lifecycle projector worker and covered producer signals;
|
||||
production registration and polling removal remain intentionally unfinished until
|
||||
the remaining producer audit is complete. Native progress stays a separate slice.
|
||||
|
||||
The latest local checkpoint covers real publication success/failure/operator
|
||||
resolution and question answer/cancel/withdraw/skip/terminal-expiry paths,
|
||||
including historical answers that enqueue no continuation. All 160 milestone and
|
||||
interaction-service cases pass on fresh isolated PostgreSQL databases. Four
|
||||
existing chat acceptance cases, publication policy cases, and server production
|
||||
typecheck passed at their recorded checkpoints. See the detailed milestone audit
|
||||
for retained regression/setup failures, controlled ports and remaining coverage.
|
||||
The initial draft CI was running; no final green-check or readiness claim is made.
|
||||
@@ -721,3 +721,50 @@ called after an interaction mutation cannot replace intent before that earlier
|
||||
mutation. Production registration and removal of the combined periodic owner
|
||||
remain pending. Publish this slice as a draft for CI feedback, not as a completed
|
||||
sleep migration or merge-ready PR.
|
||||
|
||||
## Interaction resolution checkpoint
|
||||
|
||||
Question answers, question cancellation, withdrawal, skipping and terminal-task
|
||||
expiry now signal milestone work before their relevant mutations. Withdrawal,
|
||||
skipping and expiry restrict the signal to ask_user_questions and
|
||||
request_confirmation, the two kinds used by lifecycle presentation arbitration.
|
||||
Existing actor checks, linked tool/secret resolution, pending-state CAS, caller
|
||||
hooks and transaction ownership remain in place. No service wrapper or new table
|
||||
was introduced. Dismissal alone does not change the arbitration predicate and
|
||||
adds no notification.
|
||||
|
||||
Historical answers are a distinct case: an ordinary deferred question on a done
|
||||
task remains answerable, but deliberately enqueues no question delivery. That
|
||||
path cannot rely on a later delivery claim to wake the milestone projector.
|
||||
The actual answer transaction now owns its notification without reopening work.
|
||||
|
||||
- `/tmp/sleep28-interaction-resolution-before.log`: answer, skip and historical
|
||||
answer all leave an eligible failure notice asleep before the fix.
|
||||
- `/tmp/sleep28-interaction-transitions-before.log`: six real producer variants
|
||||
fail the missing-notice assertion: answer, skip, cancel, withdraw, terminal
|
||||
expiry and historical answer. Cancel/withdraw also exposed a fixture cleanup
|
||||
error because they create durable chat actions referencing the conversation.
|
||||
- `/tmp/sleep28-interaction-transitions-tests.log`: notice assertions pass after
|
||||
the fix; cancel/withdraw still fail the missing chat-action cleanup. Cleanup
|
||||
now deletes this fixture's actions before its conversations. The old
|
||||
`paperclip_sleep28_worker_final_20261025_test` database retains partial failed
|
||||
fixtures and must not be reused for global worker quiet assertions.
|
||||
- `/tmp/sleep28-interaction-final-tests.log`: all 160 cases pass across the full
|
||||
milestone and existing interaction-service suites, on fresh separate databases
|
||||
`paperclip_sleep28_interaction_worker_20261025_test` and
|
||||
`paperclip_sleep28_interaction_service_20261025_test`. The former is the new
|
||||
self-cleaning milestone test database; the latter is exclusive to the service
|
||||
suite's broad cleanup.
|
||||
- `/tmp/sleep28-interaction-typecheck.log`: production server typecheck passes;
|
||||
test files are excluded. `git diff --check` passes.
|
||||
|
||||
Draft PR #17606 targets #17592. Its first head `f398314ecd406d2e0eebbfb81a5d9dd7edc7c437`
|
||||
started CI run 40198190088 and review run 40198190000. At the last inspection,
|
||||
24 checks succeeded and 27 remained in progress, with no reported failures.
|
||||
That is progress evidence, not final acceptance of this subsequent checkpoint.
|
||||
|
||||
Remaining producer work includes confirmation accept/reject and invalidation,
|
||||
interaction creation/source changes, provider-specific receipts, conversation
|
||||
bindings, configuration and alternate recovery writers. Production registration,
|
||||
removal of only the lifecycle polling lane, complete latest-head checks and final
|
||||
stack/master reconciliation remain required. No merge or full-goal completion.
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { activityLog, agents, agentWakeupRequests, authUsers, caseHandoffs, cases, companyMemberships, chatConversations, chatEndpoints, chatMessageLinks, chatPublications, companies, completionContracts, createDb, nativeRunFinalizations, nativeRunResults, statusDecisions, statusDecisionEffects, workAssessments, subscribeDatabaseWork,
|
||||
import { activityLog, agents, agentWakeupRequests, authUsers, caseHandoffs, cases, companyMemberships, chatActions, chatConversations, chatEndpoints, chatMessageLinks, chatPublications, companies, completionContracts, createDb, nativeRunFinalizations, nativeRunResults, statusDecisions, statusDecisionEffects, workAssessments, subscribeDatabaseWork,
|
||||
heartbeatRunEvents, heartbeatRuns, issueComments, issueQuestionResponseDeliveries, issueThreadInteractions, issues, toolApplications, toolConnections } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
||||
@@ -76,6 +76,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
await db.delete(activityLog).where(eq(activityLog.companyId, companyId));
|
||||
await db.delete(caseHandoffs).where(eq(caseHandoffs.companyId, companyId));
|
||||
await db.delete(cases).where(eq(cases.companyId, companyId));
|
||||
await db.delete(chatActions).where(eq(chatActions.companyId, companyId));
|
||||
await db.delete(chatMessageLinks).where(eq(chatMessageLinks.companyId, companyId));
|
||||
await db.delete(chatPublications).where(eq(chatPublications.companyId, companyId));
|
||||
await db.delete(chatConversations).where(eq(chatConversations.companyId, companyId));
|
||||
@@ -632,6 +633,44 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
expect((await publications()).map(row => row.idempotencyKey)).toContain(`run:${targetId}:queued:${f.endpointId}`);
|
||||
await quiet();
|
||||
});
|
||||
it.each(["answer", "skip", "cancel", "withdraw", "expire", "historical answer"] as const)("rechecks an unsent question after %s", async action => {
|
||||
const source = await run(f, { status: "failed", errorCode: "test_run_failed" });
|
||||
const interactions = issueThreadInteractionService(db);
|
||||
const issue = { id: f.issueId, companyId: f.companyId, status: "in_progress" };
|
||||
const interaction = await interactions.create(issue, {
|
||||
kind: "ask_user_questions", continuationPolicy: "wake_assignee", sourceRunId: source.id,
|
||||
title: "Choose", payload: { version: 1, title: "Choose", questions: [{
|
||||
id: "choice", prompt: "Continue?", selectionMode: "single", required: true,
|
||||
options: [{ id: "yes", label: "Yes" }],
|
||||
}] },
|
||||
}, { agentId: f.agentId, runId: source.id });
|
||||
if (action === "historical answer") {
|
||||
await interactions.dismissInteraction(issue, interaction.id, { userId: f.userId });
|
||||
await db.update(issues).set({ status: "done" }).where(eq(issues.id, f.issueId));
|
||||
}
|
||||
await start(); await flush();
|
||||
expect((await publications()).some(row => row.idempotencyKey.startsWith(`run:${source.id}:`))).toBe(false);
|
||||
await quiet();
|
||||
if (action === "skip") {
|
||||
await interactions.skipInteraction(issue, interaction.id, { reason: "No longer needed" }, { userId: f.userId });
|
||||
} else if (action === "cancel") {
|
||||
await interactions.cancelQuestions(issue, interaction.id, { reason: "No longer needed" }, { userId: f.userId });
|
||||
} else if (action === "withdraw") {
|
||||
await interactions.withdrawInteraction(issue, interaction.id, { reason: "No longer needed" }, { userId: f.userId });
|
||||
} else if (action === "expire") {
|
||||
await db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, f.issueId));
|
||||
await interactions.expirePendingInteractionsForTerminalIssue({ ...issue, status: "cancelled" }, { userId: f.userId });
|
||||
} else {
|
||||
await interactions.answerQuestions(issue, interaction.id,
|
||||
{ answers: [{ questionId: "choice", optionIds: ["yes"] }] }, { userId: f.userId });
|
||||
}
|
||||
await flush();
|
||||
expect((await publications()).map(row => row.idempotencyKey)).toContain(`run:${source.id}:failed:${f.endpointId}`);
|
||||
if (action === "historical answer") {
|
||||
expect(await db.select().from(issueQuestionResponseDeliveries).where(eq(issueQuestionResponseDeliveries.interactionId, interaction.id))).toEqual([]);
|
||||
}
|
||||
await quiet();
|
||||
});
|
||||
it("wakes a question continuation after its in-flight prompt receipt commits", async () => {
|
||||
await db.update(chatEndpoints).set({ provider: "discord" }).where(eq(chatEndpoints.id, f.endpointId));
|
||||
const source = await run(f, { status: "failed", contextSnapshot: { ...runValues().contextSnapshot, source: "chat:discord" } });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyChatRunMilestones } from "./chat-run-milestone-notifications.js";
|
||||
import { notifyToolReviewChange } from "./tool-review-work-notifications.js";
|
||||
import { normalizeEscapedLineBreaks } from "@paperclipai/shared/validators/text";
|
||||
import { notifyDeliveryWork, DELIVERY_QUEUES } from "./delivery-work-notifications.js";
|
||||
@@ -4832,6 +4833,7 @@ export function issueThreadInteractionService(
|
||||
// lifecycle reflection.
|
||||
const updated = await db
|
||||
.transaction(async (tx) => {
|
||||
if (["ask_user_questions", "request_confirmation"].includes(row.kind)) await notifyChatRunMilestones(tx, row);
|
||||
if (row.kind === "connection_intent") {
|
||||
await tx
|
||||
.delete(toolOauthStates)
|
||||
@@ -4922,6 +4924,7 @@ export function issueThreadInteractionService(
|
||||
// request must not outlive a withdrawn card.
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
||||
if (["ask_user_questions", "request_confirmation"].includes(current.kind)) await notifyChatRunMilestones(tx, current);
|
||||
await resolveLinkedToolActionRequests(tx, current, {
|
||||
status: "cancelled",
|
||||
fromStatuses: ["pending", "approved"],
|
||||
@@ -5057,6 +5060,7 @@ export function issueThreadInteractionService(
|
||||
if (!historicalAnswer) throw interactionIssueClosedError();
|
||||
}
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
||||
await notifyChatRunMilestones(tx, current);
|
||||
await mutationOptions.beforeResolveInTransaction?.(tx);
|
||||
const resolvedAt = new Date();
|
||||
const [row] = await tx
|
||||
@@ -5181,6 +5185,7 @@ export function issueThreadInteractionService(
|
||||
const now = new Date();
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
||||
if (["ask_user_questions", "request_confirmation"].includes(current.kind)) await notifyChatRunMilestones(tx, current);
|
||||
await resolveLinkedToolActionRequests(tx, current, {
|
||||
status: "cancelled",
|
||||
fromStatuses: ["pending", "approved"],
|
||||
@@ -5278,6 +5283,7 @@ export function issueThreadInteractionService(
|
||||
const reason = data.reason?.trim() || null;
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
||||
await notifyChatRunMilestones(tx, current);
|
||||
const resolvedAt = new Date();
|
||||
const [row] = await tx
|
||||
.update(issueThreadInteractions)
|
||||
|
||||
Reference in new issue
Block a user