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
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
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
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
+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")
const reverted = s.app.turns.rewind(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 —
+3 -2
View File
@@ -511,9 +511,10 @@ export class Share {
const turnId = String(p?.turnId ?? "")
const reverted = this.app.turns.rewind(turnId)
if (reverted === undefined) throw new RpcError(INVALID_PARAMS, `this session has no turn ${turnId}`)
this.turns.forget(reverted)
this.hooks.rewound?.()
this.hooks.notice(`the web UI rewound ${reverted} turn${reverted === 1 ? "" : "s"} — the files they changed stay as they are`)
return { reverted }
this.hooks.notice(`the web UI rewound ${reverted.length} turn${reverted.length === 1 ? "" : "s"} — the files they changed stay as they are`)
return { reverted: reverted.length }
})
peer.handle("hub/steer", (p) => {
this.mine(p)
+27
View File
@@ -30,6 +30,8 @@ const TURN_FILE_DAYS = 30
export interface TurnStore<R> {
load(): [string, R | null][]
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. */
@@ -64,6 +66,16 @@ export function turnFile<R>(file: string): TurnStore<R> & { drop(): void } {
renameSync(tmp, file)
} 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() {
rmSync(file, { force: true })
},
@@ -178,6 +190,21 @@ export class TurnLog<R = unknown> {
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) {
const e = this.entries.get(id)
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
}
/** How many turns start at row `fromId` or after it: the visible user messages that carry a turn id. */
turnsFrom(sessionId: string, fromId: number): number {
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 }[]
return rows.filter((r) => typeof (JSON.parse(r.json) as { turnId?: string }).turnId === "string").length
/** The turns that start at row `fromId` or after it, oldest first: the ids the visible user
* messages carry. */
turnIdsFrom(sessionId: string, fromId: number): string[] {
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. */
+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
* 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
* number of turns reverted, or undefined for a turn this session does not have. Not while a
* prompt runs: the caller checks. */
rewind(turnId: string): number | undefined {
* ids of the turns reverted, oldest first, or undefined for a turn this session does not have;
* the caller forgets them from its turn log, or a client re-sending one would get the old
* answer. Not while a prompt runs: the caller checks. */
rewind(turnId: string): string[] | undefined {
const sessionId = this.engine.sessionId
const i = this.done.findIndex((t) => t.turnId === turnId)
if (i >= 0) {
@@ -130,20 +131,20 @@ export class Turns {
this.engine.messages.splice(first.at)
if (this.store && sessionId && last.lastRow >= first.firstRow) this.store.setKind(sessionId, first.firstRow, last.lastRow, "reverted")
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
// turn run here came after it, so they all go too.
if (!this.store || !sessionId) return undefined
const row = this.store.turnRow(sessionId, turnId)
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")
const at = this.engine.messages.findIndex((m) => m.role === "user" && m.turnId === turnId)
if (at >= 0) this.engine.messages.splice(at)
this.done = []
this.undone = []
return count
return ids
}
/** 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.
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"])
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)
expect(sent).toContain("first answer")
expect(sent).not.toContain("second answer")