From a406da5bf6897719e8ceb2a983a1d7d0299d2bbf Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 25 Oct 2026 08:51:11 -0500 Subject: [PATCH] fix(server): wake chat milestones after confirmation verdicts Co-Authored-By: Paperclip --- ...2026-10-24-recurring-server-sleep-stack.md | 8 ++++ ...26-10-25-chat-run-milestone-sleep-audit.md | 41 +++++++++++++++++++ .../__tests__/chat-run-milestone-work.test.ts | 29 +++++++++++-- .../src/services/issue-thread-interactions.ts | 5 +++ 4 files changed, 79 insertions(+), 4 deletions(-) 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 49bf79b645a..2f50cff9ef5 100644 --- a/doc/plans/2026-10-24-recurring-server-sleep-stack.md +++ b/doc/plans/2026-10-24-recurring-server-sleep-stack.md @@ -1488,3 +1488,11 @@ 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. + + +Slice 28 now also wakes lifecycle projection from confirmation accept/reject and +live/historical comment invalidation of questions and confirmations. Six real +producer regressions failed before the added intent signals. All 169 cases across +the worker and interaction-service suites pass, as does production server +typechecking. The draft remains incomplete: other producers and production +registration/polling removal are still pending. See the milestone audit for logs. 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 fd8ba4c53b3..e06a14da121 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 @@ -768,3 +768,44 @@ 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. + + +## Confirmation verdicts and comment invalidation + +The interaction service owns verdict and invalidation writes. Acceptance and +rejection now register milestone intent before the caller's optional in-transaction +resolution hook. Existing authorization, issue/proposal/interaction lock order, +workspace finalization checks and postcommit effects remain unchanged. Only +request_confirmation affects the current milestone response-slot policy; +checkbox confirmations do not acquire this additional intent. + +Live human-comment invalidation registers intent for question/confirmation rows +before changing their state. Historical-comment reconciliation registers one +issue-scoped intent per relevant bulk write. A historical checkbox-only group +remains outside this notification. No new wrapper, table or periodic retry was +introduced. All paths continue using the existing outer-transaction protocol. + +Evidence: + +- `/tmp/sleep28-confirmation-before.log`: real acceptance/rejection both fail to + produce the newly eligible failure notice; confirmation skip/withdraw/terminal + expiry already pass using the previous checkpoint's signals. +- `/tmp/sleep28-confirmation-invalidation-before.log`: six missing-notice + regressions fail: acceptance/rejection and question/confirmation supersession + by live/historical comments. The invalidation tests verify the actual service + expired their original interaction before asserting the missing notice. +- `/tmp/sleep28-confirmation-tests.log`: 169 cases pass, comprising all 63 worker + cases and 106 existing interaction-service cases, using the same separate + self-cleaning PostgreSQL databases as the previous checkpoint. +- `/tmp/sleep28-confirmation-typecheck.log`: production server typecheck passes. + It excludes test files. `git diff --check` passes. + +The latest fetched master is 3536b3649f98155d92b45cfb5dc3abcc2fea97d6; it has +not yet been reconciled into this stack. CI run 40198785531 for parent checkpoint +20f8c301f5 was still live with no reported failures at inspection. This does not +establish current-head final acceptance. + +Remaining interaction producers include document-target invalidation, deleted +addressees, startup supersession and creation/replacement. Provider receipt, +conversation/configuration and alternate recovery audits also remain. Production +worker registration and lifecycle polling removal are still intentionally pending. diff --git a/server/src/__tests__/chat-run-milestone-work.test.ts b/server/src/__tests__/chat-run-milestone-work.test.ts index 1fbaec618a3..38fd3c0695a 100644 --- a/server/src/__tests__/chat-run-milestone-work.test.ts +++ b/server/src/__tests__/chat-run-milestone-work.test.ts @@ -633,13 +633,23 @@ 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 => { + it.each([ + ["question", "answer"], ["question", "skip"], ["question", "cancel"], + ["question", "withdraw"], ["question", "expire"], ["question", "historical answer"], + ["confirmation", "accept"], ["confirmation", "reject"], ["confirmation", "skip"], + ["confirmation", "withdraw"], ["confirmation", "expire"], + ["question", "comment"], ["question", "historical comment"], + ["confirmation", "comment"], ["confirmation", "historical comment"], + ] as const)("rechecks an unsent %s after %s", async (kind, 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, { + const interaction = await interactions.create(issue, kind === "confirmation" ? { + kind: "request_confirmation", continuationPolicy: "wake_assignee", sourceRunId: source.id, + title: "Confirm", payload: { version: 1, prompt: "Continue?", supersedeOnUserComment: true }, + } : { kind: "ask_user_questions", continuationPolicy: "wake_assignee", sourceRunId: source.id, - title: "Choose", payload: { version: 1, title: "Choose", questions: [{ + title: "Choose", payload: { version: 1, title: "Choose", supersedeOnUserComment: true, questions: [{ id: "choice", prompt: "Continue?", selectionMode: "single", required: true, options: [{ id: "yes", label: "Yes" }], }] }, @@ -651,7 +661,18 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu await start(); await flush(); expect((await publications()).some(row => row.idempotencyKey.startsWith(`run:${source.id}:`))).toBe(false); await quiet(); - if (action === "skip") { + if (action === "comment" || action === "historical comment") { + const [comment] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, + authorType: "user", authorUserId: f.userId, origin: "comment", body: "Ignore the earlier request" }).returning(); + const expired = action === "comment" + ? await interactions.expireRequestConfirmationsSupersededByComment(issue, comment, { userId: f.userId }) + : await interactions.expireRequestConfirmationsSupersededByHistoricalComments(issue); + expect(expired.map(row => row.id)).toContain(interaction.id); + } else if (action === "accept") { + await interactions.acceptInteraction({ ...issue, projectId: null, goalId: null }, interaction.id, {}, { userId: f.userId }); + } else if (action === "reject") { + await interactions.rejectInteraction(issue, interaction.id, { reason: "No longer needed" }, { userId: f.userId }); + } else 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 }); diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index d062c7d8bf8..37223eca1d8 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -2175,6 +2175,7 @@ export function issueThreadInteractionService( const postCommitActivityPublications: ActivityPublication[] = []; const result = await db.transaction(async (tx) => { await assertInteractionRunWriteAllowed(tx as unknown as Db, args.issue, args.actor); + if (args.current.kind === "request_confirmation") await notifyChatRunMilestones(tx, args.current); await args.mutationOptions?.beforeResolveInTransaction?.(tx); // Policy mutations and review transitions use the same issue-row lock, // so the authoritative review policy and requester are stable through @@ -2458,6 +2459,7 @@ export function issueThreadInteractionService( const now = new Date(); const updated = await db.transaction(async (tx) => { await assertInteractionRunWriteAllowed(tx as unknown as Db, args.issue, args.actor); + if (args.current.kind === "request_confirmation") await notifyChatRunMilestones(tx, args.current); await args.mutationOptions?.beforeResolveInTransaction?.(tx); const issueContext = await tx .select({ @@ -4401,6 +4403,7 @@ export function issueThreadInteractionService( const expired: IssueThreadInteraction[] = []; for (const row of superseded) { 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) @@ -4537,6 +4540,7 @@ export function issueThreadInteractionService( ); if (!sampleQuestionRow) continue; const updatedInteractions = await db.transaction(async (tx) => { + await notifyChatRunMilestones(tx, sampleQuestionRow); const updatedRows = await tx .update(issueThreadInteractions) .set({ @@ -4575,6 +4579,7 @@ export function issueThreadInteractionService( ); if (!sampleConfirmationRow) continue; const updatedInteractions = await db.transaction(async (tx) => { + if (commentRows.some(row => row.kind === "request_confirmation")) await notifyChatRunMilestones(tx, sampleConfirmationRow); await notifyDeliveryWork(tx, DELIVERY_QUEUES.toolReviewProjection); const updatedRows = await tx .update(issueThreadInteractions)