Public: anyone can read this; the crew can change it.
A stopped Talk turn is stored as stopped, not failed #9006
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8972-a-stopped-talk-turn-is-stored-as-stopped → main 3 commits fork 2 threads · 2 unresolved
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 | ||
| 43 | 43 | |
| 44 | 44 | ## Stop |
| 45 | 45 | |
| 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. | |
| 47 | 47 | |
| 48 | 48 | - **Web:** the Stop button beside Send while an answer is running, and `Escape` in the composer. Send still posts the form without the script. |
| 49 | 49 | - **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 | ||
| 86 | 86 | |
| 87 | 87 | **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. |
| 88 | 88 | |
| 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`. | |
| 90 | 90 | |
| 91 | 91 | **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. |
| 92 | 92 |
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; | |
| 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( | ||
| 568 | 568 | cancelHeld(app.db, via); |
| 569 | 569 | put("agent", STOPPED_LINE); |
| 570 | 570 | 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. |
||
| 572 | 572 | .run(runId, outcome?.tokensIn ?? 0, outcome?.tokensOut ?? 0, cost, new Date().toISOString(), outcome?.contextTokens ?? 0, outcome?.contextWindow ?? 0); |
| 573 | 573 | app.db.query("UPDATE subscription SET spent_usd = spent_usd + ?2 WHERE id = ?1").run(subscriptionId, cost); |
| 574 | 574 | return; |
src/flow/talk_turns.ts +1 −1
| @@ -13,7 +13,7 @@ export interface TalkTurns { | ||
| 13 | 13 | export function readTalkTurns(db: Database): TalkTurns { |
| 14 | 14 | const row = db |
| 15 | 15 | .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 | |
| 17 | 17 | FROM conversation_run`, |
| 18 | 18 | ) |
| 19 | 19 | .get(); |
test/conversation-turn.test.ts +2 −2
| @@ -512,7 +512,7 @@ sleep 300 | ||
| 512 | 512 | expect(waitingFor(app.db, u.value.id)).toEqual([]); |
| 513 | 513 | expect(answerHeld(app.db, held!, u.value.id).ok).toBe(false); |
| 514 | 514 | // 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" }); | |
| 516 | 516 | 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 }); |
| 517 | 517 | const turnsNow = readTalkTurns(app.db); |
| 518 | 518 | expect(turnsNow.stopped).toBeGreaterThanOrEqual(1); |
| @@ -545,7 +545,7 @@ describe("talk turn counts (#8860)", () => { | ||
| 545 | 545 | test("stopped turns are counted apart and leave the failure rate alone", () => { |
| 546 | 546 | const base = readTalkTurns(app.db); |
| 547 | 547 | 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) | |
| 549 | 549 | 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); |
| 550 | 550 | const now = readTalkTurns(app.db); |
| 551 | 551 | 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 | ||
| 160 | 160 | */ |
| 161 | 161 | const REBUILT: Record<string, string> = { |
| 162 | 162 | "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", | |
| 163 | 164 | }; |
| 164 | 165 | |
| 165 | 166 | const src = join(import.meta.dir, "../src"); |
test/talk-conversations.test.ts +25
| @@ -186,6 +186,31 @@ describe("the migration keeps attachments", () => { | ||
| 186 | 186 | }); |
| 187 | 187 | }); |
| 188 | 188 | |
| 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 | ||
| 189 | 214 | describe("the mutation checks", () => { |
| 190 | 215 | test("a cross-origin POST to New, Rename and Archive is refused and changes nothing", async () => { |
| 191 | 216 | const c = newConversation(app.db, alice.id); |
new above
offline
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.