fix(server): wake milestones when interactions replace older cards

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-25 08:54:55 -05:00
1 parent a406da5bf6
commit 0214d06a64
4 files changed
+53 -5

No files matched your search

@@ -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.
@@ -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.
@@ -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"
@@ -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({