From d7a64903f7f5d70382de12b2389b679300ba3232 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Thu, 22 Oct 2026 17:41:16 -0400 Subject: [PATCH 1/3] fix(channel): record who stopped a run, show it in the timeline, and tell the next run Why: packages/channel/src/room.ts#const StopEntry | a person's Stop is a durable ace.stopped entry whose user message reaches the next run; stopped events go only to watchers that ask with stops, since older clients' reducers can't take an unknown event kind --- apps/app/src/timeline.ts | 10 +++++++++- apps/app/src/transcript.ts | 21 +++++++++++++++++++++ apps/host/src/cli.ts | 4 +++- packages/channel/src/channel.ts | 15 ++++++++++++++- packages/channel/src/protocol.ts | 4 ++++ packages/channel/src/room.ts | 21 +++++++++++++++++++++ 6 files changed, 72 insertions(+), 3 deletions(-) diff --git a/apps/app/src/timeline.ts b/apps/app/src/timeline.ts index 42765aff..de08562e 100644 --- a/apps/app/src/timeline.ts +++ b/apps/app/src/timeline.ts @@ -225,7 +225,15 @@ export function toEvents( } } if (item.error) content.push(text(`Run failed: ${item.error}`)); - if (item.stopped) content.push(text("Stopped.")); + if (item.stopped) { + content.push( + text( + item.stopper + ? `Stopped by ${authorName(item.stopper, people, channels)}.` + : "Stopped.", + ), + ); + } if (item.withdrawn) content.push(text(withdrawal(item.withdrawn, people, channels))); return { ...base, diff --git a/apps/app/src/transcript.ts b/apps/app/src/transcript.ts index 3df4232d..b2375f3f 100644 --- a/apps/app/src/transcript.ts +++ b/apps/app/src/transcript.ts @@ -47,6 +47,8 @@ export type Item = tools: Tool[]; error?: string; stopped?: boolean; + /** Who stopped the run, once the channel records it. */ + stopper?: string; /** Queued messages withdrawn when the run was stopped. */ withdrawn?: QueuedMessage[]; }; @@ -232,6 +234,24 @@ export function apply(state: Transcript, event: Event): Transcript { const items = withdraw(state.items, state.withdrawn); return { ...state, busy, draft: "", items, withdrawn: [] }; } + case "stopped": { + const last = state.items.at(-1); + if (last?.kind === "reply") { + const item: Item = { ...last, stopped: true, stopper: event.author }; + return { ...state, items: [...state.items.slice(0, -1), item] }; + } + const item: Item = { + kind: "reply", + key: `s${event.at}`, + at: event.at, + model: "", + text: "", + tools: [], + stopped: true, + stopper: event.author, + }; + return { ...state, items: [...state.items, item] }; + } case "live": return { ...state, live: true }; case "agent": @@ -377,6 +397,7 @@ export function useTranscript( actions: true, typing: true, agent: true, + stops: true, ...(chat === undefined ? {} : { chat }), }, { event(event) { diff --git a/apps/host/src/cli.ts b/apps/host/src/cli.ts index ce54465f..ea616b97 100755 --- a/apps/host/src/cli.ts +++ b/apps/host/src/cli.ts @@ -173,6 +173,8 @@ function render(event: Event, info: ChannelInfo) { switch (event.kind) { case "message": return console.log(`${tag}${bold(event.author)}: ${event.text}`); + case "stopped": + return console.log(dim(`${tag}Stopped by ${event.author}.`)); case "reply": { const ending = event.error ? `\x1b[31m${event.text ? "\n" : ""}Run failed: ${event.error}\x1b[0m` @@ -212,7 +214,7 @@ async function watch(session: Session, replay = true): Promise<{ ended: Promise< const info = await session.request({ op: "info" }); let live = false; const ended = Promise.withResolvers(); - await session.request({ op: "watch", chat: chat() }, { + await session.request({ op: "watch", chat: chat(), stops: true }, { event(event) { if (event.kind === "live") return void (live = true); if (live || replay) render(event, info); diff --git a/packages/channel/src/channel.ts b/packages/channel/src/channel.ts index a3c93c75..92d1eeed 100644 --- a/packages/channel/src/channel.ts +++ b/packages/channel/src/channel.ts @@ -419,7 +419,15 @@ export class Channel { this.#author(request.author); // Whoever can't start a run here can't end the owner's either. await this.#mayInvoke(request.author); - return (await this.#conversation(request.chat)).abort(context); + const conversation = await this.#conversation(request.chat); + const running = this.#runs.has(conversation.id); + await conversation.abort(context); + if (!running) return; + await conversation.submit( + { type: "write", entry: room.stop(request.author, Date.now()) }, + context, + ); + return; } case "kill": this.#author(request.author); @@ -1034,6 +1042,7 @@ export class Channel { // a deleted message's content and shows an edited one's newest text. const forward = (event: Event) => { if ((event.kind === "deleted" || event.kind === "edited") && !actions) return; + if (event.kind === "stopped" && !request.stops) return; if (event.kind !== "message" || !deleted[event.entry]) return send(event); const { images: _, files: __, edited: ___, ...rest } = event; send({ ...rest, text: "", deleted: true }); @@ -1190,6 +1199,10 @@ function events(entry: EntryRecord): Event[] { const { target, text, at } = entry.data; return [{ kind: "edited", chat, entry: target, text, at }]; } + if (room.StopEntry.is(entry)) { + const at = entry.model![0]!.timestamp; + return [{ kind: "stopped", chat, at, author: entry.data.author }]; + } const human = room.human(entry); if (human) return [{ kind: "message", chat, entry: entry.id, ...human }]; const message = entry.model?.[0]; diff --git a/packages/channel/src/protocol.ts b/packages/channel/src/protocol.ts index 9681405f..cef57d60 100644 --- a/packages/channel/src/protocol.ts +++ b/packages/channel/src/protocol.ts @@ -297,6 +297,8 @@ export type Request = typing?: boolean; /** Include agent events; older clients do not understand them. */ agent?: boolean; + /** Include stopped events; older clients do not understand them. */ + stops?: boolean; }; export type Event = @@ -385,6 +387,8 @@ export type Event = */ | { kind: "recap"; chat: ChatId; calls: string[]; text: string } | { kind: "run"; chat: ChatId; state: "start" | "end" } + /** `author` stopped the run, whose aborted reply came before this. */ + | { kind: "stopped"; chat: ChatId; at: number; author: string } /** The chat's model or effort changed, so the next run uses these. */ | { kind: "agent"; chat: ChatId; model: ModelRef; effort: Effort } /** diff --git a/packages/channel/src/room.ts b/packages/channel/src/room.ts index abbd0fa3..238b5310 100644 --- a/packages/channel/src/room.ts +++ b/packages/channel/src/room.ts @@ -20,6 +20,27 @@ import { type Image, MAX_IMAGES, type TextFile } from "./protocol"; /** A human message that did not invoke an agent. */ export const MessageEntry = defineEntry<{ author: string; text: string }>("ace.message"); +/** + * A person stopped the run before this entry. Its user message tells the chat's next run, which + * otherwise sees only an aborted reply and can't tell a person's Stop from a failure. + */ +export const StopEntry = defineEntry<{ author: string }>("ace.stopped"); + +export function stop(author: string, timestamp: number) { + return { + kind: StopEntry.kind, + data: { author } satisfies JsonObject, + model: [ + { + role: "user", + content: + `[${author} stopped your previous run before it finished. Anything it was doing when stopped may not have completed.]`, + timestamp, + } satisfies UserMessage, + ], + }; +} + const AUTHOR = /^[A-Za-z0-9][A-Za-z0-9._@-]*$/; export function isAuthor(value: string): boolean { From 94faaf68c5330e527fc79d12072f29f90916ed7c Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Thu, 22 Oct 2026 17:41:18 -0400 Subject: [PATCH 2/3] lane: sync fix-stopped-run memory --- ...C72D0EX9F0DSEYGDY-a-person-s-stop-is-a-durable.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .lane/memory/packages/channel/src/room.ts/01M5JRZAPC72D0EX9F0DSEYGDY-a-person-s-stop-is-a-durable.md diff --git a/.lane/memory/packages/channel/src/room.ts/01M5JRZAPC72D0EX9F0DSEYGDY-a-person-s-stop-is-a-durable.md b/.lane/memory/packages/channel/src/room.ts/01M5JRZAPC72D0EX9F0DSEYGDY-a-person-s-stop-is-a-durable.md new file mode 100644 index 00000000..adc84acb --- /dev/null +++ b/.lane/memory/packages/channel/src/room.ts/01M5JRZAPC72D0EX9F0DSEYGDY-a-person-s-stop-is-a-durable.md @@ -0,0 +1,12 @@ +--- +id: 01M5JRZAPC72D0EX9F0DSEYGDY +anchor: const StopEntry +created: 2026-10-22T21:41:16Z +norm: '1' +sig: ff54ace4b8f574ba +body_hash: e3b0c44298fc1c14 +raw_hash: 69408999a208ec7e +lines: 27-27 +--- + +a person's Stop is a durable ace.stopped entry whose user message reaches the next run; stopped events go only to watchers that ask with stops, since older clients' reducers can't take an unknown event kind From 504c900d182ae86b8485b87558bdd67e5cb38569 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Thu, 22 Oct 2026 17:41:40 -0400 Subject: [PATCH 3/3] chore: confirm notes the stop change left true --- ...M4YQRMMB6JT09W33MJMPKSMF-a-re-watch-keeps-showing-the.md | 6 +++--- ...M4YYQEXJX6JQ04TBRZ4DW1X8-a-watch-rejected-with-discon.md | 6 +++--- ...M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md | 6 +++--- ...1M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md | 6 +++--- ...1M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md | 6 +++--- ...M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md | 6 +++--- ...M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md | 6 +++--- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.lane/memory/apps/app/src/transcript.ts/01M4YQRMMB6JT09W33MJMPKSMF-a-re-watch-keeps-showing-the.md b/.lane/memory/apps/app/src/transcript.ts/01M4YQRMMB6JT09W33MJMPKSMF-a-re-watch-keeps-showing-the.md index 675de253..c87dcf68 100644 --- a/.lane/memory/apps/app/src/transcript.ts/01M4YQRMMB6JT09W33MJMPKSMF-a-re-watch-keeps-showing-the.md +++ b/.lane/memory/apps/app/src/transcript.ts/01M4YQRMMB6JT09W33MJMPKSMF-a-re-watch-keeps-showing-the.md @@ -4,9 +4,9 @@ anchor: function useTranscript created: 2026-10-15T02:55:20Z norm: '1' sig: 3961cde46070e655 -body_hash: 715b52c056e02eac -raw_hash: 0c5f8df4fea08fca -vouched: 2026-10-22T20:30:45Z +body_hash: 835435c05643b8c8 +raw_hash: 4c294bc99f24c95b +vouched: 2026-10-22T21:41:25Z lines: 174-275 --- diff --git a/.lane/memory/apps/app/src/transcript.ts/01M4YYQEXJX6JQ04TBRZ4DW1X8-a-watch-rejected-with-discon.md b/.lane/memory/apps/app/src/transcript.ts/01M4YYQEXJX6JQ04TBRZ4DW1X8-a-watch-rejected-with-discon.md index b4a1fefb..6ea19873 100644 --- a/.lane/memory/apps/app/src/transcript.ts/01M4YYQEXJX6JQ04TBRZ4DW1X8-a-watch-rejected-with-discon.md +++ b/.lane/memory/apps/app/src/transcript.ts/01M4YYQEXJX6JQ04TBRZ4DW1X8-a-watch-rejected-with-discon.md @@ -4,9 +4,9 @@ anchor: function useTranscript created: 2026-10-15T04:57:01Z norm: '1' sig: 3961cde46070e655 -body_hash: 715b52c056e02eac -raw_hash: 0c5f8df4fea08fca -vouched: 2026-10-22T20:30:45Z +body_hash: 835435c05643b8c8 +raw_hash: 4c294bc99f24c95b +vouched: 2026-10-22T21:41:26Z lines: 197-314 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md b/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md index 980be26b..9dcf883e 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-14T17:33:22Z norm: '1' sig: c905677fa4be33c1 -body_hash: c6d2a615cf4deee2 -raw_hash: e9f2af52a8b6629d -vouched: 2026-10-22T21:13:56Z +body_hash: edc1045f02d80405 +raw_hash: 95969dbe14ea6781 +vouched: 2026-10-22T21:41:26Z lines: 114-792 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md b/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md index ff4d6744..3ff6cb0a 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-15T00:30:42Z norm: '1' sig: c905677fa4be33c1 -body_hash: c6d2a615cf4deee2 -raw_hash: e9f2af52a8b6629d -vouched: 2026-10-22T21:13:56Z +body_hash: edc1045f02d80405 +raw_hash: 95969dbe14ea6781 +vouched: 2026-10-22T21:41:26Z lines: 114-792 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md b/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md index 6d2abe39..2b9704cf 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-15T02:34:37Z norm: '1' sig: c905677fa4be33c1 -body_hash: c6d2a615cf4deee2 -raw_hash: e9f2af52a8b6629d -vouched: 2026-10-22T21:13:56Z +body_hash: edc1045f02d80405 +raw_hash: 95969dbe14ea6781 +vouched: 2026-10-22T21:41:26Z lines: 114-794 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md b/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md index 313f176e..245767af 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-15T10:17:55Z norm: '1' sig: c905677fa4be33c1 -body_hash: c6d2a615cf4deee2 -raw_hash: e9f2af52a8b6629d -vouched: 2026-10-22T21:13:56Z +body_hash: edc1045f02d80405 +raw_hash: 95969dbe14ea6781 +vouched: 2026-10-22T21:41:26Z lines: 121-817 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md b/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md index 7c563d49..628d202d 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-16T22:11:35Z norm: '1' sig: c905677fa4be33c1 -body_hash: c6d2a615cf4deee2 -raw_hash: e9f2af52a8b6629d -vouched: 2026-10-22T21:13:56Z +body_hash: edc1045f02d80405 +raw_hash: 95969dbe14ea6781 +vouched: 2026-10-22T21:41:26Z lines: 127-873 ---