A rewound turn's id is forgotten, so sending it again runs the turn anew

The turn log answered a repeated id with what that turn had answered, which
was right for a prompt sent twice over a dropped link and wrong for a turn
the web UI had just rewound to edit: the same id came back with the old
reply. rewind now returns the ids it took out, both handlers forget them from
the log, and the turn file drops them too, or the next load would have
brought them back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Fable 5.1 committed 2026-10-18 04:37:44 +02:00
1 parent 837f38c744
commit ce98f7878a
7 files changed
+49 -15

No files matched your search

+1 -1
View File
@@ -33,7 +33,7 @@ Harness spec 1.1.0: planning as one structure shared with LLeMbas.
- `_lembas/session/rewind` (capability `rewind`): the web UI editing or regenerating from a turn - `_lembas/session/rewind` (capability `rewind`): the web UI editing or regenerating from a turn
takes that turn and every later one out of the conversation and the history — as `/undo` takes that turn and every later one out of the conversation and the history — as `/undo`
does, through the same store marking — without touching the files; a session a terminal runs does, through the same store marking — without touching the files; a session a terminal runs
is rewound there, and its transcript redrawn. is rewound there, and its transcript redrawn. A rewound turn's id is forgotten, so the web UI re-sending it runs the turn anew rather than being answered what the reverted turn said.
- Prototypes in the terminal: a completed `prototype-chat` / `prototype-page` fence in a reply - Prototypes in the terminal: a completed `prototype-chat` / `prototype-page` fence in a reply
is saved as a file — the model's output as it is, under is saved as a file — the model's output as it is, under
`~/.local/state/lembas/prototypes/<session>/<n>-<slug>.html`, never in the project — and drawn `~/.local/state/lembas/prototypes/<session>/<n>-<slug>.html`, never in the project — and drawn
+2 -1
View File
@@ -1008,7 +1008,8 @@ export class AcpAgent {
if (s.prompting || s.turns.queued().length) throw new RpcError(INVALID_PARAMS, "this session is working — rewind it when the reply is done") if (s.prompting || s.turns.queued().length) throw new RpcError(INVALID_PARAMS, "this session is working — rewind it when the reply is done")
const reverted = s.app.turns.rewind(turnId) const reverted = s.app.turns.rewind(turnId)
if (reverted === undefined) throw new RpcError(INVALID_PARAMS, `this session has no turn ${turnId}`) if (reverted === undefined) throw new RpcError(INVALID_PARAMS, `this session has no turn ${turnId}`)
return { reverted } s.turns.forget(reverted)
return { reverted: reverted.length }
} }
/** `_lembas/session/delete`: the chat was deleted in the web UI, so the session is too — /** `_lembas/session/delete`: the chat was deleted in the web UI, so the session is too —
+3 -2
View File
@@ -511,9 +511,10 @@ export class Share {
const turnId = String(p?.turnId ?? "") const turnId = String(p?.turnId ?? "")
const reverted = this.app.turns.rewind(turnId) const reverted = this.app.turns.rewind(turnId)
if (reverted === undefined) throw new RpcError(INVALID_PARAMS, `this session has no turn ${turnId}`) if (reverted === undefined) throw new RpcError(INVALID_PARAMS, `this session has no turn ${turnId}`)
this.turns.forget(reverted)
this.hooks.rewound?.() this.hooks.rewound?.()
this.hooks.notice(`the web UI rewound ${reverted} turn${reverted === 1 ? "" : "s"} — the files they changed stay as they are`) this.hooks.notice(`the web UI rewound ${reverted.length} turn${reverted.length === 1 ? "" : "s"} — the files they changed stay as they are`)
return { reverted } return { reverted: reverted.length }
}) })
peer.handle("hub/steer", (p) => { peer.handle("hub/steer", (p) => {
this.mine(p) this.mine(p)
+27
View File
@@ -30,6 +30,8 @@ const TURN_FILE_DAYS = 30
export interface TurnStore<R> { export interface TurnStore<R> {
load(): [string, R | null][] load(): [string, R | null][]
save(done: [string, R | null][]): void save(done: [string, R | null][]): void
/** Turns a rewind took out: gone from the file too, or the next load would bring them back. */
forget?(ids: string[]): void
} }
/** The file's entries, those that are entries: a damaged file is an empty one, never a crash. */ /** The file's entries, those that are entries: a damaged file is an empty one, never a crash. */
@@ -64,6 +66,16 @@ export function turnFile<R>(file: string): TurnStore<R> & { drop(): void } {
renameSync(tmp, file) renameSync(tmp, file)
} catch {} } catch {}
}, },
forget(ids) {
try {
const gone = new Set(ids)
const kept = readEntries<R>(file).filter(([id]) => !gone.has(id))
mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
writeFileSync(tmp, JSON.stringify(kept), { mode: 0o600 })
renameSync(tmp, file)
} catch {}
},
drop() { drop() {
rmSync(file, { force: true }) rmSync(file, { force: true })
}, },
@@ -178,6 +190,21 @@ export class TurnLog<R = unknown> {
this.running = id this.running = id
} }
/** Turns a rewind took out of the conversation: forgotten, so the same id sent again runs
* anew instead of answering what the reverted turn answered. */
forget(ids: Iterable<string>) {
const gone: string[] = []
for (const id of ids) {
if (this.entries.get(id)?.state === "done") {
this.entries.delete(id)
this.values.delete(id)
if (this.last === id) this.last = undefined
gone.push(id)
}
}
if (gone.length) this.store?.forget?.(gone)
}
ended(id: string) { ended(id: string) {
const e = this.entries.get(id) const e = this.entries.get(id)
if (e) e.state = "done" if (e) e.state = "done"
+5 -4
View File
@@ -123,10 +123,11 @@ export class Store {
return rows.find((r) => (JSON.parse(r.json) as Message).role === "user" && (JSON.parse(r.json) as { turnId?: string }).turnId === turnId)?.id return rows.find((r) => (JSON.parse(r.json) as Message).role === "user" && (JSON.parse(r.json) as { turnId?: string }).turnId === turnId)?.id
} }
/** How many turns start at row `fromId` or after it: the visible user messages that carry a turn id. */ /** The turns that start at row `fromId` or after it, oldest first: the ids the visible user
turnsFrom(sessionId: string, fromId: number): number { * messages carry. */
const rows = this.db.query("SELECT json FROM messages WHERE session_id = ? AND role = 'user' AND kind = 'message' AND id >= ?").all(sessionId, fromId) as { json: string }[] turnIdsFrom(sessionId: string, fromId: number): string[] {
return rows.filter((r) => typeof (JSON.parse(r.json) as { turnId?: string }).turnId === "string").length const rows = this.db.query("SELECT json FROM messages WHERE session_id = ? AND role = 'user' AND kind = 'message' AND id >= ? ORDER BY id").all(sessionId, fromId) as { json: string }[]
return rows.map((r) => (JSON.parse(r.json) as { turnId?: string }).turnId).filter((id): id is string => typeof id === "string")
} }
/** Record a compaction: what the model sees from here on starts with this summary. */ /** Record a compaction: what the model sees from here on starts with this summary. */
+7 -6
View File
@@ -118,9 +118,10 @@ export class Turns {
/** `_lembas/session/rewind`: the turn `turnId` and every later one leave the conversation the /** `_lembas/session/rewind`: the turn `turnId` and every later one leave the conversation the
* model sees and the history — their rows re-kinded as /undo re-kinds a turn's, the messages * model sees and the history — their rows re-kinded as /undo re-kinds a turn's, the messages
* dropped — and the files are left as they are (the client says so beside its control). The * dropped — and the files are left as they are (the client says so beside its control). The
* number of turns reverted, or undefined for a turn this session does not have. Not while a * ids of the turns reverted, oldest first, or undefined for a turn this session does not have;
* prompt runs: the caller checks. */ * the caller forgets them from its turn log, or a client re-sending one would get the old
rewind(turnId: string): number | undefined { * answer. Not while a prompt runs: the caller checks. */
rewind(turnId: string): string[] | undefined {
const sessionId = this.engine.sessionId const sessionId = this.engine.sessionId
const i = this.done.findIndex((t) => t.turnId === turnId) const i = this.done.findIndex((t) => t.turnId === turnId)
if (i >= 0) { if (i >= 0) {
@@ -130,20 +131,20 @@ export class Turns {
this.engine.messages.splice(first.at) this.engine.messages.splice(first.at)
if (this.store && sessionId && last.lastRow >= first.firstRow) this.store.setKind(sessionId, first.firstRow, last.lastRow, "reverted") if (this.store && sessionId && last.lastRow >= first.firstRow) this.store.setKind(sessionId, first.firstRow, last.lastRow, "reverted")
this.undone = [] this.undone = []
return dropped.length return dropped.map((t) => t.turnId)
} }
// A turn run before this process had the session (opened from the store): by its row. Every // A turn run before this process had the session (opened from the store): by its row. Every
// turn run here came after it, so they all go too. // turn run here came after it, so they all go too.
if (!this.store || !sessionId) return undefined if (!this.store || !sessionId) return undefined
const row = this.store.turnRow(sessionId, turnId) const row = this.store.turnRow(sessionId, turnId)
if (row === undefined) return undefined if (row === undefined) return undefined
const count = this.store.turnsFrom(sessionId, row) const ids = this.store.turnIdsFrom(sessionId, row)
this.store.setKind(sessionId, row, Number.MAX_SAFE_INTEGER, "reverted") this.store.setKind(sessionId, row, Number.MAX_SAFE_INTEGER, "reverted")
const at = this.engine.messages.findIndex((m) => m.role === "user" && m.turnId === turnId) const at = this.engine.messages.findIndex((m) => m.role === "user" && m.turnId === turnId)
if (at >= 0) this.engine.messages.splice(at) if (at >= 0) this.engine.messages.splice(at)
this.done = [] this.done = []
this.undone = [] this.undone = []
return count return ids
} }
/** Everything changed since the session's first prompt, as a unified diff. */ /** Everything changed since the session's first prompt, as a unified diff. */
+4 -1
View File
@@ -383,7 +383,10 @@ test("rewind: the turn and every later one leave the conversation; the next prom
// Gone from the history and from what the model is sent; the file is as it was. // Gone from the history and from what the model is sent; the file is as it was.
const h: any = await c.request("_lembas/session/history", { sessionId: s.sessionId }) const h: any = await c.request("_lembas/session/history", { sessionId: s.sessionId })
expect(h.turns.filter((t: any) => t.role === "user").map((t: any) => t.text)).toEqual(["one"]) expect(h.turns.filter((t: any) => t.role === "user").map((t: any) => t.text)).toEqual(["one"])
await prompt("four", "T4") // The reverted ids are forgotten: the same id sent again runs anew instead of answering what
// the reverted turn answered (LLeMbas re-sends a turn under the id of the edited message).
await prompt("four", "T2")
expect(fake.requests.length).toBe(4)
const sent = JSON.stringify(fake.requests[3].messages) const sent = JSON.stringify(fake.requests[3].messages)
expect(sent).toContain("first answer") expect(sent).toContain("first answer")
expect(sent).not.toContain("second answer") expect(sent).not.toContain("second answer")