Skip to content

Commit abaeebb

Browse files
committed
fix(webapp): make a repeated watch card close leave the revision alone
`settleInvestigationStateAndCloseCard()` bumped the revision before it looked at the transcript, so a redelivered or replayed action settled the row a second time while the append refused the duplicate card. The row then held revision 2 and the transcript's terminal card revision 1: the live run rendered 2, a refresh rendered 1, for a tool advertised as idempotent on the action id. The transaction now locks the investigation, checks the tenancy triple, locks the chat, and looks for the message id before it writes anything. An action already in the transcript returns the stored card and the current revision untouched. Lock order stays investigation then chat, matching `persistTurn` and the sweep's `settleInvestigationAndCloseCard`; taking the chat first here would deadlock against them. A missing or deleted chat was indistinguishable from an already-closed card — both came back `closed: false` — and the settle committed anyway, which is exactly the terminal-row-without-a-card the transaction exists to prevent. It is now `{ ok: false, error: "chat_missing" }`, and the watch lane logs it as the race it is rather than a fault.
1 parent d52c224 commit abaeebb

3 files changed

Lines changed: 151 additions & 10 deletions

File tree

apps/webapp/test/dashboardAgentWatchCardAtomicity.test.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
getInvestigation,
66
listStaleOpenInvestigations,
77
settleInvestigationStateAndCloseCard,
8+
softDeleteChat,
89
upsertInvestigationRevision,
910
type DashboardAgentDb,
1011
type DashboardAgentDbClient,
@@ -121,7 +122,8 @@ describe("closing a consented watch investigation's card", () => {
121122
investigationStateSchema.parse((await getInvestigation(agentDb, { id }))?.state).outcome
122123
).toBe("inconclusive");
123124

124-
// The lane dedupes on the action, so a redelivered kick closes nothing twice.
125+
// The lane dedupes on the action, so a redelivered kick closes nothing twice —
126+
// and must not bump the revision, or the row runs ahead of the stored card.
125127
const again = await settleInvestigationStateAndCloseCard(agentDb, {
126128
id,
127129
chatId,
@@ -130,8 +132,65 @@ describe("closing a consented watch investigation's card", () => {
130132
state: forceSettledInvestigationState(openState()),
131133
messageId: MESSAGE_ID,
132134
});
133-
expect(again).toMatchObject({ ok: true, closed: false });
134-
expect((await transcript(chatId)).length).toBe(1);
135+
expect(again).toMatchObject({ ok: true, id, revision: 1, closed: false });
136+
expect((await getInvestigation(agentDb, { id }))?.revision).toBe(1);
137+
138+
const after = await transcript(chatId);
139+
expect(after.map((message) => message.id)).toEqual([MESSAGE_ID]);
140+
expect(after[0]!.parts[0]!.output.blocks[0]).toMatchObject({ id, revision: 1 });
141+
// The replayed result is the card the transcript holds, not a second rendering.
142+
expect((again as { card: unknown }).card).toEqual(after[0]);
143+
},
144+
30_000
145+
);
146+
147+
postgresTest(
148+
"settles nothing when the chat was deleted, so the sweep still selects the row",
149+
async ({ prisma, postgresContainer }) => {
150+
const chatId = "chat_watch_card_deleted";
151+
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
152+
const id = await seed(chatId, openState());
153+
await softDeleteChat(agentDb, { chatId, userId: USER_ID });
154+
155+
expect(
156+
await settleInvestigationStateAndCloseCard(agentDb, {
157+
id,
158+
chatId,
159+
projectRef: PROJECT_REF,
160+
environmentRef: ENV_REF,
161+
state: forceSettledInvestigationState(openState()),
162+
messageId: MESSAGE_ID,
163+
})
164+
).toEqual({ ok: false, error: "chat_missing" });
165+
166+
const row = await getInvestigation(agentDb, { id });
167+
expect(row?.revision).toBe(0);
168+
expect((row!.state as { outcome?: string }).outcome).toBe("in_progress");
169+
},
170+
30_000
171+
);
172+
173+
postgresTest(
174+
"settles nothing when the chat row was never there",
175+
async ({ prisma, postgresContainer }) => {
176+
const chatId = "chat_watch_card_absent";
177+
await boot(prisma, postgresContainer.getConnectionUri(), "chat_watch_card_present");
178+
const id = await seed(chatId, openState());
179+
180+
expect(
181+
await settleInvestigationStateAndCloseCard(agentDb, {
182+
id,
183+
chatId,
184+
projectRef: PROJECT_REF,
185+
environmentRef: ENV_REF,
186+
state: forceSettledInvestigationState(openState()),
187+
messageId: MESSAGE_ID,
188+
})
189+
).toEqual({ ok: false, error: "chat_missing" });
190+
191+
const row = await getInvestigation(agentDb, { id });
192+
expect(row?.revision).toBe(0);
193+
expect((row!.state as { outcome?: string }).outcome).toBe("in_progress");
135194
},
136195
30_000
137196
);

internal-packages/dashboard-agent-db/src/queries.ts

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -857,16 +857,43 @@ export type ClosedInvestigationCard =
857857
id: string;
858858
revision: number;
859859
card: InvestigationCardMessage;
860-
/** False when that message id was already in the chat. */
860+
/** False when that message id was already in the chat, so this call wrote nothing. */
861861
closed: boolean;
862862
}
863-
| { ok: false; error: "not_found" | "context_mismatch" };
863+
| { ok: false; error: "not_found" | "context_mismatch" | "chat_missing" };
864+
865+
/** The stored message under `messageId`, read from the transcript rather than rebuilt. */
866+
async function storedMessageById(
867+
tx: DashboardAgentDbOrTx,
868+
params: { chatId: string; messageId: string }
869+
): Promise<InvestigationCardMessage | null> {
870+
const rows = await tx
871+
.select({
872+
message: sql<InvestigationCardMessage | null>`(
873+
select message
874+
from jsonb_array_elements(coalesce(${chats.messages}, '[]'::jsonb)) as message
875+
where message->>'id' = ${params.messageId}
876+
limit 1
877+
)`,
878+
})
879+
.from(chats)
880+
.where(eq(chats.id, params.chatId))
881+
.limit(1);
882+
883+
return rows[0]?.message ?? null;
884+
}
864885

865886
/**
866887
* Same atomicity as {@link settleInvestigationAndCloseCard}, for a caller that brings
867888
* its own terminal state and its own message id — the consented watch investigation,
868889
* which dedupes on the action rather than on the revision.
869890
*
891+
* Idempotent on that message id, and the locks are what make it so: a redelivered
892+
* action must not bump the revision, or the row moves ahead of the card the transcript
893+
* already holds and the panel renders a different revision before and after a refresh.
894+
* A missing or deleted chat settles nothing — a terminal row with no card is the
895+
* permanent spinner this transaction exists to prevent.
896+
*
870897
* Throwing is the point: the caller's retry only happens if the failure reaches it, and
871898
* a rolled-back settle leaves the `in_progress` row the stale sweep still selects.
872899
*/
@@ -882,6 +909,56 @@ export async function settleInvestigationStateAndCloseCard(
882909
}
883910
): Promise<ClosedInvestigationCard> {
884911
return db.transaction(async (tx) => {
912+
// Investigation before chat, the order `persistTurn` and the sweep's
913+
// `settleInvestigationAndCloseCard` already take. Reversing it here would deadlock
914+
// against them.
915+
const investigationRows = await tx
916+
.select({
917+
id: investigations.id,
918+
revision: investigations.revision,
919+
chatId: investigations.chatId,
920+
projectRef: investigations.projectRef,
921+
environmentRef: investigations.environmentRef,
922+
})
923+
.from(investigations)
924+
.where(eq(investigations.id, params.id))
925+
.limit(1)
926+
.for("update");
927+
928+
const investigation = investigationRows[0];
929+
if (!investigation) return { ok: false, error: "not_found" };
930+
if (
931+
investigation.chatId !== params.chatId ||
932+
investigation.projectRef !== params.projectRef ||
933+
investigation.environmentRef !== params.environmentRef
934+
) {
935+
return { ok: false, error: "context_mismatch" };
936+
}
937+
938+
const chatRows = await tx
939+
.select({ id: chats.id, deletedAt: chats.deletedAt })
940+
.from(chats)
941+
.where(eq(chats.id, params.chatId))
942+
.limit(1)
943+
.for("update");
944+
945+
const chat = chatRows[0];
946+
if (!chat || chat.deletedAt) return { ok: false, error: "chat_missing" };
947+
948+
const already = await storedMessageById(tx, {
949+
chatId: params.chatId,
950+
messageId: params.messageId,
951+
});
952+
if (already) {
953+
return {
954+
ok: true,
955+
id: investigation.id,
956+
revision: investigation.revision,
957+
card: already,
958+
closed: false,
959+
};
960+
}
961+
885962
const result = await upsertInvestigationRevision(tx, {
886963
id: params.id,
887964
chatId: params.chatId,
@@ -905,6 +982,10 @@ export async function settleInvestigationStateAndCloseCard(
905982
chatId: params.chatId,
906983
message: card,
907984
});
985+
if (!closed) {
986+
throw new Error(`Investigation ${result.id} settled without appending its closing card`);
987+
}
988+
908989
return { ok: true, id: result.id, revision: result.revision, card, closed };
909990
});
910991
}

internal-packages/dashboard-agent/src/watch-actions.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -623,11 +623,12 @@ async function closeCardInTranscript(args: {
623623
messageId: args.messageId,
624624
});
625625
if (!result.ok) {
626-
logger.error("dashboard-agent watch investigation couldn't close its card", {
627-
chatId,
628-
investigationId,
629-
error: result.error,
630-
});
626+
// A chat deleted mid-investigation is a race, not a fault: nothing settled, and
627+
// there is no transcript left to close the card in.
628+
const message = "dashboard-agent watch investigation couldn't close its card";
629+
const details = { chatId, investigationId, error: result.error };
630+
if (result.error === "chat_missing") logger.warn(message, details);
631+
else logger.error(message, details);
631632
return;
632633
}
633634

0 commit comments

Comments
 (0)