From 0214d06a641c22566cb3d4d9c49d664741d81462 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 25 Oct 2026 08:54:55 -0500 Subject: [PATCH] fix(server): wake milestones when interactions replace older cards Co-Authored-By: Paperclip --- ...2026-10-24-recurring-server-sleep-stack.md | 8 +++++ ...26-10-25-chat-run-milestone-sleep-audit.md | 31 +++++++++++++++++++ .../__tests__/chat-run-milestone-work.test.ts | 16 +++++++--- .../src/services/issue-thread-interactions.ts | 3 ++ 4 files changed, 53 insertions(+), 5 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 2f50cff9ef5..4aede3a325c 100644 --- a/doc/plans/2026-10-24-recurring-server-sleep-stack.md +++ b/doc/plans/2026-10-24-recurring-server-sleep-stack.md @@ -1496,3 +1496,11 @@ producer regressions failed before the added intent signals. All 169 cases acros 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. + + +Slice 28 creation/sibling replacement now registers intent before the first +interaction write. Both question and confirmation replacement failed before the +fix; all 184 milestone/integration/unit interaction cases and production server +typechecking pass. Remaining terminal-interaction writers are document-target +expiry, deleted-addressee cancellation and startup supersession; other producer +audits and production migration remain pending. 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 e06a14da121..ec374576a57 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 @@ -809,3 +809,34 @@ 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. + + +## Creation and sibling replacement + +Creating a question or confirmation can expire a prior card from the same agent, +release the prior run's provider response slot, and cancel its unsent prompt. +The creation transaction now registers a single issue-scoped milestone intent +before inserting the new row. This precedes both sibling invalidation and prompt +publication changes. Existing idempotent reuse and validation return/throw before +this boundary; unrelated interaction kinds remain outside it. Provider policy +and sibling-supersession semantics are unchanged. + +The replacement regressions create two runs before worker startup, suppress the +older failed run with its pending card, let the worker become quiet, then create +the replacement through the actual service. They verify that the old card is +expired and its failure notice appears without another run transition or poll. + +- `/tmp/sleep28-interaction-replacement-before.log`: question and confirmation + replacement both fail the missing-notice assertion before the creation signal. +- `/tmp/sleep28-interaction-replacement-tests.log`: all 184 cases pass across + the 65 milestone worker, 106 interaction-service integration and 13 interaction + service unit cases. PostgreSQL suites use separate isolated databases. +- `/tmp/sleep28-interaction-replacement-typecheck.log`: production server + typecheck passes; test files are excluded. `git diff --check` passes. + +The remaining direct terminal-interaction writers are document-target expiry +(the resolution-time helper and explicit document-change method), deletion of +an addressed agent, and startup supersession. Document-change callers are in +`routes/issues.ts`; the owning state mutation is in the interaction service. +Audit those before removing the lifecycle poll. This checkpoint still does not +register the worker in production or establish final CI/review acceptance. diff --git a/server/src/__tests__/chat-run-milestone-work.test.ts b/server/src/__tests__/chat-run-milestone-work.test.ts index 38fd3c0695a..2e866745ea8 100644 --- a/server/src/__tests__/chat-run-milestone-work.test.ts +++ b/server/src/__tests__/chat-run-milestone-work.test.ts @@ -640,20 +640,23 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu ["confirmation", "withdraw"], ["confirmation", "expire"], ["question", "comment"], ["question", "historical comment"], ["confirmation", "comment"], ["confirmation", "historical comment"], + ["question", "replacement"], ["confirmation", "replacement"], ] 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, kind === "confirmation" ? { - kind: "request_confirmation", continuationPolicy: "wake_assignee", sourceRunId: source.id, + const createInteraction = (sourceRunId: string) => interactions.create(issue, kind === "confirmation" ? { + kind: "request_confirmation", continuationPolicy: "wake_assignee", sourceRunId, title: "Confirm", payload: { version: 1, prompt: "Continue?", supersedeOnUserComment: true }, } : { - kind: "ask_user_questions", continuationPolicy: "wake_assignee", sourceRunId: source.id, + kind: "ask_user_questions", continuationPolicy: "wake_assignee", sourceRunId, title: "Choose", payload: { version: 1, title: "Choose", supersedeOnUserComment: true, questions: [{ id: "choice", prompt: "Continue?", selectionMode: "single", required: true, options: [{ id: "yes", label: "Yes" }], }] }, - }, { agentId: f.agentId, runId: source.id }); + }, { agentId: f.agentId, runId: sourceRunId }); + const interaction = await createInteraction(source.id); + const replacement = action === "replacement" ? await run() : null; 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)); @@ -661,7 +664,10 @@ 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 === "comment" || action === "historical comment") { + if (action === "replacement") { + await createInteraction(replacement!.id); + expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interaction.id)))[0].status).toBe("expired"); + } else 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" diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 37223eca1d8..fbac12e0a6f 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -3697,6 +3697,9 @@ export function issueThreadInteractionService( eq(chatVoiceSessions.callerAuthority, "guest_intake"))).limit(1); if (guest) throw unprocessable("Ask this unverified caller a clarification in a task comment; submit_request will deliver their spoken follow-up. Protected human-input questions require authenticated access.", {code: "voice_guest_use_task_comment"}); } + if (data.kind === "ask_user_questions" || data.kind === "request_confirmation") { + await notifyChatRunMilestones(tx, { companyId: issue.companyId, issueId: issue.id }); + } const [row] = await tx .insert(issueThreadInteractions) .values({