diff --git a/doc/plans/2026-10-24-recurring-server-sleep-stack.md b/doc/plans/2026-10-24-recurring-server-sleep-stack.md index 1b4a40bab7a..49bf79b645a 100644 --- a/doc/plans/2026-10-24-recurring-server-sleep-stack.md +++ b/doc/plans/2026-10-24-recurring-server-sleep-stack.md @@ -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. diff --git a/doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md b/doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md index 34f670ba209..fd8ba4c53b3 100644 --- a/doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md +++ b/doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md @@ -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. diff --git a/server/src/__tests__/chat-run-milestone-work.test.ts b/server/src/__tests__/chat-run-milestone-work.test.ts index c71a6001052..1fbaec618a3 100644 --- a/server/src/__tests__/chat-run-milestone-work.test.ts +++ b/server/src/__tests__/chat-run-milestone-work.test.ts @@ -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" } }); diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 8c4ac914f13..d062c7d8bf8 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -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)