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:
7 files changed
+49
-15
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in new issue
Block a user