fix(server): wake chat milestones after confirmation verdicts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-25 08:51:11 -05:00
1 parent 20f8c301f5
commit a406da5bf6
4 files changed
+79 -4

No files matched your search

@@ -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.
@@ -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.
@@ -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 });
@@ -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)