Public: anyone can read this; the crew can change it.

A stopped Talk turn is stored as stopped, not failed #9006

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/8972-a-stopped-talk-turn-is-stored-as-stopped → main 3 commits fork 2 threads · 2 unresolved

  • Merged. mason/8972-a-stopped-talk-turn-is-stored-as-stopped was merged into main as 81e3193.

Review

Sign in to review this change.
8 files changed +66 −6
docs/features/talk.md +2 −2
@@ -43,7 +43,7 @@ Three actions on the last exchange, one door, `talkAgain()` in `src/actions/talk
4343
4444 ## Stop
4545
46 A turn can be ended mid-reply (`stopTalk` in `src/actions/talk.ts`, `stopTurn` in `src/crew/conversation.ts`). The agent's process group ends with everything it started; the words it had said stay, followed by a `Stopped.` line; the run is stored as `failed`. Held writes waiting on a Confirm are cancelled before the kill and again after, so nothing lands while the process goes down.
46 A turn can be ended mid-reply (`stopTalk` in `src/actions/talk.ts`, `stopTurn` in `src/crew/conversation.ts`). The agent's process group ends with everything it started; the words it had said stay, followed by a `Stopped.` line; the run is stored as `stopped`. Held writes waiting on a Confirm are cancelled before the kill and again after, so nothing lands while the process goes down.
4747
4848 - **Web:** the Stop button beside Send while an answer is running, and `Escape` in the composer. Send still posts the form without the script.
4949 - **API:** `POST /api/v1/conversation/stop` answers 200, or 409 when nothing runs; a read-only token is 403.
@@ -86,7 +86,7 @@ Prior art read: Vercel AI Elements (Apache-2.0 React on shadcn; anatomy and beha
8686
8787 **Cost and context.** Under a finished reply a quiet line gives the turn's dollars (`$0.12 this turn`); above the log, "Context 85% full (170k of 200k tokens)". Both are read from the run's record (`conversation_run`: `cost_usd`, and `context_tokens`/`context_window` from the last message's usage and the result's `modelUsage`), shown by `core/conversation_cost.ts`. When a run names no window, 200k is assumed and the line says so by showing it. From 80 % the line adds that a new conversation answers faster and costs less (information only: there is no way to start another yet, see "Many conversations"). A reply that streams in gets both when the stream ends; the terminal prints the last turn's cost and the share after its confirmation line.
8888
89 **Stopped is not failed.** A turn the person stopped is stored with `conversation_run.stopped = 1` (its `state` stays `failed`, so a release still in play reads it). The flow vitals line says it apart: `Talk: 2 turns failed, 1 stopped by the person` (`flow/talk_turns.ts`), and the failure rate leaves stopped turns out. The line is in the flow telemetry (`GET /api/v1/telemetry/flow` and the MCP flow tool) as `vitals.says`, with the counts at `vitals.talkTurns`.
89 **Stopped is not failed.** A turn the person stopped is stored with `conversation_run.state = 'stopped'` (the `stopped` column is kept and set alongside, only for a release still in play that counts by it). The flow vitals line says it apart: `Talk: 2 turns failed, 1 stopped by the person` (`flow/talk_turns.ts`), and the failure rate leaves stopped turns out. The line is in the flow telemetry (`GET /api/v1/telemetry/flow` and the MCP flow tool) as `vitals.says`, with the counts at `vitals.talkTurns`.
9090
9191 **The terminal** (`ssh <forge> talk <words>`, `forge talk`) sends one line and prints the answer; it has no log, so who/when, copy, tool cards, starters and the jump button do not apply to it. `forge talk` prints a thought as one `thinking:` line, then the reply, the steps line and the links as paths (the API's agent turns carry `work: {steps, links}`). The steps line says "opened" for a read issue or pull and "changed" for a tool that writes one (`CHANGES` in `core/talk_work.ts`, held to the MCP tools' `writes` by a test); numbers carry their repo when a turn touched more than one. A reply first on an older page, or first in a stream batch, reads its calls from the turns before the page (`worksOf`), so it says the same on every door. The conversation's whole history is read through `GET /api/v1/conversation/turns`, where every turn carries its role and time.
9292
src/core/migration-pins/20261104100000-conversation-run-stopped-state.pin added +1
@@ -0,0 +1,1 @@
1 d273539ff5172161fbc4476405051232e7dc3d386ed13ee69615d15f1611452d
src/core/migrations/20261104100000-conversation-run-stopped-state.ts added +33
@@ -0,0 +1,33 @@
1 // A /talk turn the person stopped is state `stopped` (#8972), no longer
2 // `failed` with a flag. SQLite cannot widen a CHECK in place, so the table is
3 // copied; turns already stopped are rewritten. The `stopped` column stays, set
4 // alongside, for a release still in play that counts by it.
5 export default `
6 CREATE TEMP TABLE keep_run AS SELECT * FROM conversation_run;
7 DROP TABLE conversation_run;
8 CREATE TABLE conversation_run (
9 id TEXT PRIMARY KEY,
10 conversation_id TEXT NOT NULL REFERENCES conversation(id) ON DELETE CASCADE,
11 user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
12 state TEXT NOT NULL DEFAULT 'running' CHECK (state IN ('running','ok','failed','stopped')),
13 session_id TEXT NOT NULL DEFAULT '',
14 subscription_id TEXT,
15 prompt TEXT NOT NULL,
16 resumes INTEGER NOT NULL DEFAULT 0,
17 tokens_in INTEGER NOT NULL DEFAULT 0,
18 tokens_out INTEGER NOT NULL DEFAULT 0,
19 cost_usd REAL NOT NULL DEFAULT 0,
20 started_at TEXT NOT NULL,
21 finished_at TEXT,
22 resume_outcome TEXT NOT NULL DEFAULT '',
23 version TEXT NOT NULL DEFAULT '',
24 context_tokens INTEGER NOT NULL DEFAULT 0,
25 context_window INTEGER NOT NULL DEFAULT 0,
26 stopped INTEGER NOT NULL DEFAULT 0
27 );
28 INSERT INTO conversation_run SELECT * FROM keep_run;
29 UPDATE conversation_run SET state = 'stopped' WHERE stopped = 1;

This backfill has no test. Add one that seeds a failed row with stopped=1 and a plain failed row, runs the migration, and expects stopped and failed.

30 DROP TABLE keep_run;
31 CREATE INDEX conversation_run_running ON conversation_run (state) WHERE state = 'running';
32 CREATE INDEX conversation_run_spend ON conversation_run (user_id, started_at);
33 `;
src/crew/conversation.ts +1 −1
@@ -568,7 +568,7 @@ function begin(
568568 cancelHeld(app.db, via);
569569 put("agent", STOPPED_LINE);
570570 app.db
571 .query("UPDATE conversation_run SET state = 'failed', stopped = 1, tokens_in = ?2, tokens_out = ?3, cost_usd = ?4, finished_at = ?5, context_tokens = ?6, context_window = ?7 WHERE id = ?1")
571 .query("UPDATE conversation_run SET state = 'stopped', stopped = 1, tokens_in = ?2, tokens_out = ?3, cost_usd = ?4, finished_at = ?5, context_tokens = ?6, context_window = ?7 WHERE id = ?1")

Correct. docs/features/talk.md lines 46 and 89 still say the run is stored as failed; update them in this pull.

572572 .run(runId, outcome?.tokensIn ?? 0, outcome?.tokensOut ?? 0, cost, new Date().toISOString(), outcome?.contextTokens ?? 0, outcome?.contextWindow ?? 0);
573573 app.db.query("UPDATE subscription SET spent_usd = spent_usd + ?2 WHERE id = ?1").run(subscriptionId, cost);
574574 return;
src/flow/talk_turns.ts +1 −1
@@ -13,7 +13,7 @@ export interface TalkTurns {
1313 export function readTalkTurns(db: Database): TalkTurns {
1414 const row = db
1515 .query<{ ok: number | null; failed: number | null; stopped: number | null }, []>(
16 `SELECT SUM(state = 'ok') AS ok, SUM(state = 'failed' AND stopped = 0) AS failed, SUM(stopped = 1) AS stopped
16 `SELECT SUM(state = 'ok') AS ok, SUM(state = 'failed') AS failed, SUM(state = 'stopped') AS stopped
1717 FROM conversation_run`,
1818 )
1919 .get();
test/conversation-turn.test.ts +2 −2
@@ -512,7 +512,7 @@ sleep 300
512512 expect(waitingFor(app.db, u.value.id)).toEqual([]);
513513 expect(answerHeld(app.db, held!, u.value.id).ok).toBe(false);
514514 // Stored as stopped, and not a failure (#8860).
515 expect(app.db.query("SELECT state FROM conversation_run WHERE user_id = ?1 ORDER BY started_at DESC LIMIT 1").get(u.value.id)).toEqual({ state: "failed" });
515 expect(app.db.query("SELECT state FROM conversation_run WHERE user_id = ?1 ORDER BY started_at DESC LIMIT 1").get(u.value.id)).toEqual({ state: "stopped" });
516516 expect(app.db.query("SELECT stopped FROM conversation_run WHERE user_id = ?1 ORDER BY started_at DESC LIMIT 1").get(u.value.id)).toEqual({ stopped: 1 });
517517 const turnsNow = readTalkTurns(app.db);
518518 expect(turnsNow.stopped).toBeGreaterThanOrEqual(1);
@@ -545,7 +545,7 @@ describe("talk turn counts (#8860)", () => {
545545 test("stopped turns are counted apart and leave the failure rate alone", () => {
546546 const base = readTalkTurns(app.db);
547547 const c = openConversation(app.db, alice.id);
548 for (const [state, stopped] of [["ok", 0], ["failed", 0], ["failed", 1], ["failed", 1]] as const)
548 for (const [state, stopped] of [["ok", 0], ["failed", 0], ["stopped", 1], ["stopped", 1]] as const)
549549 app.db.query("INSERT INTO conversation_run (id, conversation_id, user_id, state, stopped, prompt, started_at) VALUES (?1,?2,?3,?4,?5,'x','2026-11-03')").run(crypto.randomUUID(), c.id, alice.id, state, stopped);
550550 const now = readTalkTurns(app.db);
551551 expect([now.ok - base.ok, now.failed - base.failed, now.stopped - base.stopped]).toEqual([1, 1, 2]);
test/schema-additive.test.ts +1
@@ -160,6 +160,7 @@ const RETIRED: Record<string, { readonly drops: readonly string[]; readonly sinc
160160 */
161161 const REBUILT: Record<string, string> = {
162162 "20261103120000-conversations-many": "conversation loses UNIQUE(user_id) (#8814); same columns, plus title and archived_at",
163 "20261104100000-conversation-run-stopped-state": "conversation_run widens its state CHECK to admit stopped (#8972); same columns, same rows, the stopped column kept",
163164 };
164165
165166 const src = join(import.meta.dir, "../src");
test/talk-conversations.test.ts +25
@@ -186,6 +186,31 @@ describe("the migration keeps attachments", () => {
186186 });
187187 });
188188
189 describe("the stopped-state migration", () => {
190 test("a run stopped before it becomes stopped, a plain failed one stays failed, rows and indexes kept", () => {
191 const at = migrationFiles.findIndex((m) => m.name === "20261104100000-conversation-run-stopped-state");
192 expect(at).toBeGreaterThan(0);
193 const path = join(dir, "runs.db");
194 const db = openDb(path, migrationFiles.slice(0, at).map((m) => m.sql));
195 db.query("INSERT INTO user (id, username, email, password_hash) VALUES ('u1','u1','u1@x','x')").run();
196 db.query("INSERT INTO conversation (id, user_id, claude_session_id, created_at, updated_at) VALUES ('c1','u1','s','t','t')").run();
197 const run = db.query("INSERT INTO conversation_run (id, conversation_id, user_id, state, prompt, started_at, stopped) VALUES (?1,'c1','u1',?2,'p','t',?3)");
198 run.run("r1", "failed", 1);
199 run.run("r2", "failed", 0);
200 run.run("r3", "ok", 0);
201 db.close();
202 const after = openDb(path, migrations);
203 expect(after.query("SELECT id, state, stopped FROM conversation_run ORDER BY id").all()).toEqual([
204 { id: "r1", state: "stopped", stopped: 1 },
205 { id: "r2", state: "failed", stopped: 0 },
206 { id: "r3", state: "ok", stopped: 0 },
207 ]);
208 const indexes = after.query("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'conversation_run' AND name LIKE 'conversation_run_%' ORDER BY name").all();
209 expect(indexes).toEqual([{ name: "conversation_run_running" }, { name: "conversation_run_spend" }]);
210 after.close();
211 });
212 });
213
189214 describe("the mutation checks", () => {
190215 test("a cross-origin POST to New, Rename and Archive is refused and changes nothing", async () => {
191216 const c = newConversation(app.db, alice.id);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs