Skip to content

Commit da90e22

Browse files
committed
fix(app): fence prompt admission against session revert
1 parent 0dbd3f0 commit da90e22

22 files changed

Lines changed: 1557 additions & 447 deletions

File tree

packages/app/src/pages/session.tsx

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
sendFollowupDraft,
5454
} from "@/components/prompt-input/submit"
5555
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
56+
import { createFollowupSubmissionRegistry } from "@/pages/session/followup-submission"
5657
import {
5758
createForkAction,
5859
createOpenReviewFile,
@@ -1445,14 +1446,17 @@ export default function Page() {
14451446
return followup.edit[id]
14461447
})
14471448

1449+
const followupSubmissions = createFollowupSubmissionRegistry()
1450+
14481451
const followupMutation = useMutation(() => ({
14491452
mutationFn: async (input: { sessionID: string; id: string }) => {
14501453
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
14511454
if (!item) return
14521455

14531456
setFollowup("failed", input.sessionID, undefined)
14541457

1455-
const ok = await sendFollowupDraft({
1458+
const controller = new AbortController()
1459+
const promise = sendFollowupDraft({
14561460
client: sdk.client,
14571461
sync,
14581462
serverSync,
@@ -1461,11 +1465,18 @@ export default function Page() {
14611465
intentSource: "followup",
14621466
optimisticBusy: item.sessionDirectory === sdk.directory,
14631467
confirmPromptDraft,
1464-
}).catch((err) => {
1465-
setFollowup("failed", input.sessionID, input.id)
1466-
fail(err)
1467-
return false
1468+
promptPrepareSignal: controller.signal,
14681469
})
1470+
followupSubmissions.register({ ...input, controller, promise })
1471+
const ok = await promise
1472+
.catch((err) => {
1473+
setFollowup("failed", input.sessionID, input.id)
1474+
fail(err)
1475+
return false
1476+
})
1477+
.finally(() => {
1478+
followupSubmissions.clear(input.sessionID, input.id)
1479+
})
14691480
if (!ok) return
14701481

14711482
setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
@@ -1509,6 +1520,7 @@ export default function Page() {
15091520
}
15101521

15111522
const queueFollowup = (draft: FollowupDraft) => {
1523+
if (reverting()) return
15121524
setFollowup("items", draft.sessionID, (items) => [
15131525
...(items ?? []),
15141526
{ id: Identifier.ascending("message"), ...draft },
@@ -1520,6 +1532,7 @@ export default function Page() {
15201532
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
15211533

15221534
const sendFollowup = (sessionID: string, id: string) => {
1535+
if (reverting()) return Promise.resolve()
15231536
if (sync.session.get(sessionID)?.parentID) return Promise.resolve()
15241537
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
15251538
if (!item) return Promise.resolve()
@@ -1531,7 +1544,7 @@ export default function Page() {
15311544
const editFollowup = (id: string) => {
15321545
const sessionID = params.id
15331546
if (!sessionID) return
1534-
if (followupBusy(sessionID)) return
1547+
if (reverting() || followupBusy(sessionID)) return
15351548

15361549
const item = queuedFollowups().find((entry) => entry.id === id)
15371550
if (!item) return
@@ -1548,7 +1561,7 @@ export default function Page() {
15481561
const deleteFollowup = (id: string) => {
15491562
const sessionID = params.id
15501563
if (!sessionID) return
1551-
if (followupBusy(sessionID)) return
1564+
if (reverting() || followupBusy(sessionID)) return
15521565

15531566
setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id))
15541567
setFollowup("failed", sessionID, (value) => (value === id ? undefined : value))
@@ -1563,10 +1576,15 @@ export default function Page() {
15631576
const halt = (sessionID: string) =>
15641577
busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
15651578

1579+
const cancelFollowup = async (sessionID: string) => {
1580+
await followupSubmissions.cancel(sessionID)
1581+
}
1582+
15661583
const revertMutation = useMutation(() => ({
15671584
mutationFn: async (input: { sessionID: string; messageID: string }) => {
15681585
const value = draft(input.messageID)
1569-
await (promptInputControl?.cancelPending() ?? Promise.resolve())
1586+
await cancelFollowup(input.sessionID)
1587+
.then(() => promptInputControl?.cancelPending() ?? Promise.resolve())
15701588
.then(() => halt(input.sessionID))
15711589
.then(() => sdk.client.session.revert(input))
15721590
.then((result) => {
@@ -1595,7 +1613,8 @@ export default function Page() {
15951613
messageID: next.id,
15961614
})
15971615

1598-
await (promptInputControl?.cancelPending() ?? Promise.resolve())
1616+
await cancelFollowup(sessionID)
1617+
.then(() => promptInputControl?.cancelPending() ?? Promise.resolve())
15991618
.then(() => halt(sessionID))
16001619
.then(request)
16011620
.then((result) => {
@@ -1748,6 +1767,7 @@ export default function Page() {
17481767
queue: queueEnabled,
17491768
items: followupDock(),
17501769
sending: sendingFollowup(),
1770+
disabled: reverting(),
17511771
edit: editingFollowup(),
17521772
onQueue: queueFollowup,
17531773
onAbort: () => {

packages/app/src/pages/session/composer/session-composer-region.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export function SessionComposerRegion(props: {
3737
queue: () => boolean
3838
items: { id: string; text: string }[]
3939
sending?: string
40+
disabled?: boolean
4041
edit?: { id: string; prompt: FollowupDraft["prompt"]; context: FollowupDraft["context"] }
4142
onQueue: (draft: FollowupDraft) => void
4243
onAbort: () => void
@@ -280,6 +281,7 @@ export function SessionComposerRegion(props: {
280281
<SessionFollowupDock
281282
items={props.followup!.items}
282283
sending={props.followup!.sending}
284+
disabled={props.followup!.disabled}
283285
onSend={props.followup!.onSend}
284286
onEdit={props.followup!.onEdit}
285287
onDelete={props.followup!.onDelete}

packages/app/src/pages/session/composer/session-followup-dock.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useLanguage } from "@/context/language"
88
export function SessionFollowupDock(props: {
99
items: { id: string; text: string }[]
1010
sending?: string
11+
disabled?: boolean
1112
onSend: (id: string) => void
1213
onEdit: (id: string) => void
1314
onDelete: (id: string) => void
@@ -86,7 +87,7 @@ export function SessionFollowupDock(props: {
8687
size="small"
8788
variant="secondary"
8889
class="shrink-0"
89-
disabled={!!props.sending}
90+
disabled={props.disabled || !!props.sending}
9091
onClick={() => props.onSend(item.id)}
9192
>
9293
{language.t("session.followupDock.sendNow")}
@@ -95,7 +96,7 @@ export function SessionFollowupDock(props: {
9596
size="small"
9697
variant="ghost"
9798
class="shrink-0"
98-
disabled={!!props.sending}
99+
disabled={props.disabled || !!props.sending}
99100
onClick={() => props.onEdit(item.id)}
100101
>
101102
{language.t("session.followupDock.edit")}
@@ -104,7 +105,7 @@ export function SessionFollowupDock(props: {
104105
icon="close"
105106
size="small"
106107
variant="ghost"
107-
disabled={!!props.sending}
108+
disabled={props.disabled || !!props.sending}
108109
onClick={() => props.onDelete(item.id)}
109110
title={language.t("session.followupDock.delete")}
110111
aria-label={language.t("session.followupDock.delete")}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { createFollowupSubmissionRegistry } from "./followup-submission"
3+
4+
const deferred = () => {
5+
let resolve!: (value: boolean) => void
6+
const promise = new Promise<boolean>((done) => {
7+
resolve = done
8+
})
9+
return { promise, resolve }
10+
}
11+
12+
describe("follow-up submission registry", () => {
13+
test("cancel aborts and joins only the targeted session", async () => {
14+
const registry = createFollowupSubmissionRegistry()
15+
const first = deferred()
16+
const second = deferred()
17+
const firstController = new AbortController()
18+
const secondController = new AbortController()
19+
registry.register({ sessionID: "session-a", id: "a", controller: firstController, promise: first.promise })
20+
registry.register({ sessionID: "session-b", id: "b", controller: secondController, promise: second.promise })
21+
22+
let joined = false
23+
const cancel = registry.cancel("session-a").then(() => {
24+
joined = true
25+
})
26+
await Promise.resolve()
27+
expect(firstController.signal.aborted).toBe(true)
28+
expect(secondController.signal.aborted).toBe(false)
29+
expect(joined).toBe(false)
30+
first.resolve(false)
31+
await cancel
32+
expect(joined).toBe(true)
33+
second.resolve(true)
34+
})
35+
36+
test("an old completion cannot clear a replacement submission", async () => {
37+
const registry = createFollowupSubmissionRegistry()
38+
const current = deferred()
39+
const controller = new AbortController()
40+
registry.register({
41+
sessionID: "session-a",
42+
id: "replacement",
43+
controller,
44+
promise: current.promise,
45+
})
46+
registry.clear("session-a", "old")
47+
48+
const cancel = registry.cancel("session-a")
49+
expect(controller.signal.aborted).toBe(true)
50+
current.resolve(false)
51+
await cancel
52+
})
53+
})
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
export type FollowupSubmission = {
2+
readonly sessionID: string
3+
readonly id: string
4+
readonly controller: AbortController
5+
readonly promise: Promise<boolean>
6+
}
7+
8+
export function createFollowupSubmissionRegistry() {
9+
// Keyed by sessionID → Map<id, entry> so multiple concurrent followups per session
10+
// are all tracked and cancelled on revert (fixes single-entry overwrite gap).
11+
const submissions = new Map<string, Map<string, Omit<FollowupSubmission, "sessionID">>>()
12+
13+
return {
14+
register(input: FollowupSubmission) {
15+
let slot = submissions.get(input.sessionID)
16+
if (!slot) {
17+
slot = new Map()
18+
submissions.set(input.sessionID, slot)
19+
}
20+
slot.set(input.id, {
21+
id: input.id,
22+
controller: input.controller,
23+
promise: input.promise,
24+
})
25+
},
26+
clear(sessionID: string, id: string) {
27+
const slot = submissions.get(sessionID)
28+
if (!slot) return
29+
slot.delete(id)
30+
if (slot.size === 0) submissions.delete(sessionID)
31+
},
32+
async cancel(sessionID: string) {
33+
const slot = submissions.get(sessionID)
34+
if (!slot) return
35+
for (const sub of slot.values()) sub.controller.abort()
36+
await Promise.all([...slot.values()].map((sub) => sub.promise.catch(() => false)))
37+
},
38+
}
39+
}

packages/core/src/database/migration.gen.ts

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Effect } from "effect"
2+
import type { DatabaseMigration } from "../migration"
3+
4+
export default {
5+
id: "20260806060000_session_mutation_epoch",
6+
up(tx) {
7+
return Effect.gen(function* () {
8+
yield* tx.run("ALTER TABLE session ADD COLUMN mutation_epoch INTEGER NOT NULL DEFAULT 0")
9+
yield* tx.run("ALTER TABLE session_intent ADD COLUMN mutation_epoch INTEGER NOT NULL DEFAULT 0")
10+
yield* tx.run("ALTER TABLE session_steer ADD COLUMN mutation_epoch INTEGER NOT NULL DEFAULT 0")
11+
yield* tx.run("ALTER TABLE session_steer ADD COLUMN superseded_at INTEGER")
12+
yield* tx.run(`
13+
CREATE INDEX session_steer_session_epoch_pending_idx
14+
ON session_steer (session_id, mutation_epoch, delivery, consumed_seq, superseded_at, seq)
15+
`)
16+
})
17+
},
18+
} satisfies DatabaseMigration.Migration

packages/core/src/session/sql.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex, type AnySQLiteColumn } from "drizzle-orm/sqlite-core"
1+
import {
2+
sqliteTable,
3+
text,
4+
integer,
5+
index,
6+
primaryKey,
7+
real,
8+
uniqueIndex,
9+
type AnySQLiteColumn,
10+
} from "drizzle-orm/sqlite-core"
211
import { sql } from "drizzle-orm"
312
import * as DatabasePath from "../database/path"
413
import { ProjectTable } from "../project/sql"
@@ -46,6 +55,7 @@ export const SessionTable = sqliteTable(
4655
tokens_reasoning: integer().notNull().default(0),
4756
tokens_cache_read: integer().notNull().default(0),
4857
tokens_cache_write: integer().notNull().default(0),
58+
mutation_epoch: integer().notNull().default(0),
4959
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
5060
permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
5161
agent: text(),
@@ -182,10 +192,9 @@ export const SessionInputTable = sqliteTable(
182192
// loop (SessionPrompt.runLoop), where it is persisted as an ordinary tail user message. This is a
183193
// PLAIN durable buffer (direct row writes, NOT event-sourced) — deliberately distinct from
184194
// SessionInputTable, which is projected only by the dormant experimentalEventSystem V2 runner and
185-
// feeds a different (V2) history store. Consume-once is enforced by `consumed_seq`: `drainSteer`
186-
// atomically stamps every pending row it returns in one transaction, so a second drain (or a
187-
// concurrent one) sees no pending rows. `seq` is a per-session monotonic admission order (autoincrement
188-
// PK) so a drain returns steers in the exact order the user sent them.
195+
// feeds a different (V2) history store. Chat materialization writes the V1 message and consume stamp in
196+
// one transaction. `mutation_epoch` and `superseded_at` fence every pending row against revert/rewrite.
197+
// `seq` is a per-session monotonic admission order so a drain preserves exact send order.
189198
export const SessionSteerTable = sqliteTable(
190199
"session_steer",
191200
{
@@ -201,7 +210,9 @@ export const SessionSteerTable = sqliteTable(
201210
correlation_id: text(),
202211
prompt: text({ mode: "json" }).notNull().$type<Prompt>(),
203212
delivery: text().$type<SessionInput.Delivery>().notNull(),
213+
mutation_epoch: integer().notNull().default(0),
204214
consumed_seq: integer(),
215+
superseded_at: integer(),
205216
time_created: integer()
206217
.notNull()
207218
.$default(() => Date.now()),
@@ -221,16 +232,15 @@ export const SessionIntentTable = sqliteTable(
221232
.notNull()
222233
.references(() => SessionTable.id, { onDelete: "cascade" }),
223234
source: text().$type<"composer" | "intelligence" | "followup" | "rewrite">().notNull(),
224-
state: text()
225-
.$type<"preparing" | "admitting" | "admitted" | "canceled" | "superseded" | "failed">()
226-
.notNull(),
235+
state: text().$type<"preparing" | "admitting" | "admitted" | "canceled" | "superseded" | "failed">().notNull(),
227236
selected_variant: text().$type<"original" | "rewritten">(),
228237
selected_payload_hash: text(),
229238
delivery: text().$type<"turn" | SessionInput.Delivery>(),
230239
admitted_message_id: text(),
231240
correlation_id: text(),
232241
owner_token: text(),
233242
lease_expires_at: integer(),
243+
mutation_epoch: integer().notNull().default(0),
234244
version: integer().notNull().default(0),
235245
time_created: integer().notNull(),
236246
time_selected: integer(),
@@ -272,9 +282,7 @@ export const TaskRunTable = sqliteTable(
272282
child_session_id: text().$type<SessionSchema.ID>().notNull(),
273283
generation: integer().notNull(),
274284
delivery_mode: text().$type<"foreground" | "background">().notNull(),
275-
phase: text()
276-
.$type<"admission" | "research" | "finalize" | "settled" | "queue" | "provision">()
277-
.notNull(),
285+
phase: text().$type<"admission" | "research" | "finalize" | "settled" | "queue" | "provision">().notNull(),
278286
state: text()
279287
.$type<
280288
| "admitted"
@@ -388,7 +396,13 @@ export const TaskRunTable = sqliteTable(
388396
.where(sql`${table.state} IN ('admitted', 'provisioning', 'running', 'researching', 'finalizing')`),
389397
index("task_run_parent_state_idx").on(table.parent_session_id, table.state, table.time_updated),
390398
index("task_run_root_idx").on(table.root_run_id),
391-
index("task_run_queue_idx").on(table.state, table.available_at, table.priority, table.time_created, table.generation),
399+
index("task_run_queue_idx").on(
400+
table.state,
401+
table.available_at,
402+
table.priority,
403+
table.time_created,
404+
table.generation,
405+
),
392406
index("task_run_goal_idx").on(table.goal_id, table.goal_tick_seq, table.goal_role, table.goal_ordinal),
393407
],
394408
)

0 commit comments

Comments
 (0)