fix(server): wake chat milestones when questions resolve or retire

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-25 08:41:12 -05:00
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)