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

A resumed turn of the forge's agent does not repeat a write that finished unreported #8257

Merged opened this

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

Merged

mason/forge:mason/8254-a-resumed-turn-of-the-forge-s-agent-does → main 6 commits fork 2 threads · 2 unresolved

  • Merged. mason/8254-a-resumed-turn-of-the-forge-s-agent-does was merged into main as 756c83e.

Review

Sign in to review this change.
8 files changed +298 −3
src/core/conversation_writes.ts added +86
@@ -0,0 +1,86 @@
1 /**
2 * The writes of a conversation's agent, kept so a resumed turn does none
3 * twice (#8254). Each is recorded before it runs and completed with its
4 * answer. A turn the restart cut leaves its records marked `resumed`; the
5 * same write sent again by the resumed turn is answered from its record —
6 * once, so a deliberate repeat after that goes ahead. A fresh turn starts
7 * with none. Plain data in and out; the door is `http/mcp.ts`.
8 */
9
10 import type { Database } from "bun:sqlite";
11 import { sha256 } from "../kernel/hash.ts";
12 import { id } from "../kernel/id.ts";
13
14 /** The longest answer kept to tell a resumed turn what was done. */
15 export const RESULT_MAX = 4000;
16
17 /** The arguments as one string whatever the order of their keys. */
18 const canonical = (v: unknown): string =>
19 Array.isArray(v)
20 ? `[${v.map(canonical).join(",")}]`
21 : v !== null && typeof v === "object"
22 ? `{${Object.entries(v)
23 .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
24 .map(([k, x]) => `${JSON.stringify(k)}:${canonical(x)}`)
25 .join(",")}}`
26 : (JSON.stringify(v) ?? "null");
27
28 export const argsHash = (args: Record<string, unknown>): string => sha256(canonical(args));
29
30 /** How long a record is kept: a resume comes within minutes, so a day is far past it. */
31 export const KEEP_MS = 24 * 60 * 60 * 1000;
32
33 /** A write about to run: its record's id. Records past `KEEP_MS` go with it, so none outlives its conversation for long. */
34 export function recordWrite(db: Database, via: string, tool: string, args: Record<string, unknown>, now: number = Date.now()): string {
35 const rid = id();
36 db.query("DELETE FROM conversation_write WHERE created_at < ?1").run(new Date(now - KEEP_MS).toISOString());
37 db.query("INSERT INTO conversation_write (id, via, tool, args_hash, created_at) VALUES (?1,?2,?3,?4,?5)").run(
38 rid,
39 via,
40 tool,
41 argsHash(args),
42 new Date(now).toISOString(),
43 );
44 return rid;
45 }
46
47 /** The write ended: what it answered, kept for a resumed turn. */
48 export function completeWrite(db: Database, recordId: string, result: string): void {
49 db.query("UPDATE conversation_write SET result = ?2 WHERE id = ?1").run(recordId, result.slice(0, RESULT_MAX));
50 }
51
52 /** The write failed: nothing was done, so nothing is kept to say it was. */
53 export function dropWrite(db: Database, recordId: string): void {
54 db.query("DELETE FROM conversation_write WHERE id = ?1").run(recordId);
55 }
56
57 /** A fresh turn: the last turn's writes are history. */
58 export function forgetWrites(db: Database, via: string): void {
59 db.query("DELETE FROM conversation_write WHERE via = ?1").run(via);
60 }
61
62 /** The turn was cut: what it recorded is what its resumption must not repeat. */
63 export function carryWrites(db: Database, via: string): void {
64 db.query("UPDATE conversation_write SET resumed = 1 WHERE via = ?1").run(via);
65 }
66
67 /**
68 * The record of this write from the turn that was cut, claimed once: its
69 * answer, or `null` as the answer when it began and never ended. Undefined
70 * when the cut turn did not send it.
71 */
72 export function claimDone(
73 db: Database,
74 via: string,
75 tool: string,
76 args: Record<string, unknown>,
77 ): { result: string | null } | undefined {
78 const row = db
79 .query<{ id: string; result: string | null }, [string, string, string]>(
80 "SELECT id, result FROM conversation_write WHERE via = ?1 AND tool = ?2 AND args_hash = ?3 AND resumed = 1 ORDER BY id LIMIT 1",
81 )
82 .get(via, tool, argsHash(args));
83 if (row === null) return undefined;
84 db.query("DELETE FROM conversation_write WHERE id = ?1").run(row.id);
85 return { result: row.result };
86 }
src/core/migration-pins/20261102160000-conversation-write.pin added +1
@@ -0,0 +1,1 @@
1 31477f4f2cfeb49850f31d7e2374b08407cd3fef58d6bfb120623c392492093c
src/core/migrations/20261102160000-conversation-write.ts added +17
@@ -0,0 +1,17 @@
1 // Each write the forge's agent sends is recorded before it runs (#8254): the
2 // tool, a hash of its arguments, and later what it answered. A turn the
3 // restart cut is resumed from a session that may not have heard how a write
4 // ended; the record lets the forge answer the same write again with what was
5 // done, instead of doing it twice. Only the hash is kept, never the arguments.
6 export default `
7 CREATE TABLE conversation_write (
8 id TEXT PRIMARY KEY,
9 via TEXT NOT NULL,
10 tool TEXT NOT NULL,
11 args_hash TEXT NOT NULL,
12 result TEXT,
13 resumed INTEGER NOT NULL DEFAULT 0,
14 created_at TEXT NOT NULL
15 );
16 CREATE INDEX conversation_write_via ON conversation_write (via, tool, args_hash);
17 `;
src/crew/conversation.ts +8 −2
@@ -9,8 +9,10 @@
99 * A message sent while a turn runs is steered into it (`steerRun`). A turn
1010 * the forge's restart cut short is found at boot (`resumeInterrupted`, as
1111 * `interrupted.ts` finds runs) and resumed once from its session. Resuming
12 * repeats no write: a held write is single-use (`core/fence.ts`), and the
13 * resumed turn is told to check what is done before doing anything.
12 * repeats no write: a held write is single-use (`core/fence.ts`), every write
13 * the cut turn sent is recorded and answered from its record if sent again
14 * (`core/conversation_writes.ts`), and the resumed turn is told to check
outdated

Rewrap this line to match the comment width around it.

15 * what is done before doing anything.
1416 */
1517
1618 import { mkdirSync } from "node:fs";
@@ -18,6 +20,7 @@ import { join } from "node:path";
1820 import type { App } from "../app.ts";
1921 import { dailyLeft } from "../core/account_ceiling.ts";
2022 import { appendTurn, openConversation, removeTurn, setClaudeSession, type Conversation } from "../core/conversations.ts";
23 import { carryWrites, forgetWrites } from "../core/conversation_writes.ts";
2124 import { WORDS_MAX } from "../core/fence.ts";
2225 import { PLATFORM, pickSubscription } from "../core/subscriptions.ts";
2326 import { getUserById } from "../core/users.ts";
@@ -148,6 +151,7 @@ export function resumeInterrupted(app: App, options: ConversationOptions): Promi
148151 for (const row of rows) {
149152 // The cut turn's token is revoked now, resumed or not.
150153 app.db.query("DELETE FROM token WHERE id IN (SELECT token_id FROM via_token WHERE via = ?1)").run(`conversation:${row.conversation_id}`);
154 carryWrites(app.db, `conversation:${row.conversation_id}`);
151155 app.db.query("UPDATE conversation_run SET state = 'failed', finished_at = ?2 WHERE id = ?1").run(row.id, new Date().toISOString());
152156 if (row.resumes >= 1) continue;
153157 const conversation = openConversation(app.db, row.user_id);
@@ -185,6 +189,8 @@ function begin(
185189 // A new turn's token replaces the last one's.
186190 const via = `conversation:${conversation.id}`;
187191 app.db.query("DELETE FROM token WHERE id IN (SELECT token_id FROM via_token WHERE via = ?1)").run(via);
192 // A fresh turn starts with no writes; a resumed one keeps what the cut turn did.
193 if (resumes === 0) forgetWrites(app.db, via);
188194 const lifetime = Math.min(options.timeoutMs + 60_000, VIA_TOKEN_MAX_MS - 1000);
189195 const minted = mintViaToken(app.db, userId, via, prompt.slice(0, WORDS_MAX), new Date(now.getTime() + lifetime).toISOString());
190196 if (!minted.ok) return err("unauthorized");
src/http/mcp.ts +16 −1
@@ -32,7 +32,8 @@ import { fileReport } from "../actions/reports.ts";
3232 import { REPORT_RECEIVED } from "../actions/report_words.ts";
3333 import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts";
3434 import { everydayOf } from "../core/everyday.ts";
35 import { type Args, visible, plansOnly, str, REPO_ARG, lean, fenceAnswer, heldWrite, askedFirst, readThrough } from "./mcp/shared.ts";
35 import { claimDone, completeWrite, dropWrite, recordWrite } from "../core/conversation_writes.ts";
36 import { type Args, alreadyDone, visible, plansOnly, str, REPO_ARG, lean, fenceAnswer, heldWrite, askedFirst, readThrough } from "./mcp/shared.ts";
3637 import { CONVERSATION_BRIEF } from "../crew/conversation_brief.ts";
3738 import { TOOLS } from "./mcp/tools.ts";
3839
@@ -201,9 +202,23 @@ function rpcOne(
201202 ],
202203 isError: true,
203204 });
205 // The forge's agent's write is recorded before it runs; one a cut turn
206 // already sent is answered with what was done, not done again (#8254).
207 const via = ctx.auth.through;
208 const writing = via !== undefined && tool.writes && !plansOnly(tool, args);
209 if (writing) {
210 const done = claimDone(ctx.app.db, via, tool.name, args);
211 if (done !== undefined) return reply(alreadyDone(tool.name, done.result));
212 }
213 const recorded = writing ? recordWrite(ctx.app.db, via, tool.name, args) : undefined;
204214 readThrough(ctx);
205215 return Promise.resolve(tool.run(ctx, user, args)).then((ran) => {
206216 const answer = fenceAnswer(ctx, ran);
217 if (recorded !== undefined) {
218 // A refused or failed write did nothing, so a resumed turn may send it again.
outdated

Stores error: message for failed writes; a resumed turn then hears already_done for a write that did not happen. Only record ok answers (or drop the record on error), and test it.

219 if (answer.ok) completeWrite(ctx.app.db, recorded, JSON.stringify(answer.value));
220 else dropWrite(ctx.app.db, recorded);
221 }
207222 return reply(
208223 answer.ok
209224 ? {
src/http/mcp/shared.ts +14
@@ -105,6 +105,20 @@ export function heldWrite(ctx: Ctx, user: User, tool: Tool, args: Args): Record<
105105 };
106106 }
107107
108 /**
109 * A write the cut turn already sent, as the resumed agent reads it: nothing
110 * is done again, and what was answered (or that it never ended) is said.
111 */
112 export function alreadyDone(tool: string, result: string | null): Record<string, unknown> {
113 const says =
114 result === null
115 ? "this write was started before the forge restarted and its end was not recorded, so it was not done again: read to see whether it happened, and send it again only if it did not"
116 : "this write was already done before the forge restarted, so it was not done again; its answer then is in `was`";
117 return {
118 content: [{ type: "text", text: JSON.stringify({ already_done: true, tool, says, ...(result === null ? {} : { was: result }) }) }],
119 };
120 }
121
108122 /** A call whose arguments a person could not read whole is refused, not kept. */
109123 const tooBig = (tool: string): Record<string, unknown> => ({
110124 content: [
test/conversation-resume-writes.test.ts added +151
@@ -0,0 +1,151 @@
1 /**
2 * A resumed turn does not repeat a write that finished unreported (#8254):
3 * the write is recorded before it runs; the turn is cut, resumed, and the
4 * same call sent again is answered with what was done, once.
5 */
6 import { heard } from "../src/core/fence.ts";
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { argsHash, carryWrites, KEEP_MS, recordWrite } from "../src/core/conversation_writes.ts";
14 import { createIssue, getIssue } from "../src/core/issues.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { mintViaToken } from "../src/core/via_token.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { promote } from "./helpers/members.ts";
19
20 let dataDir: string;
21 let app: App;
22 let servers: Servers;
23 let alice: User;
24 let rpcId = 1;
25 let worlds = 0;
26
27 async function call(token: string, name: string, args: Record<string, unknown>) {
28 const res = await fetch(`${servers.url}/mcp`, {
29 method: "POST",
30 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
31 body: JSON.stringify({ jsonrpc: "2.0", id: rpcId++, method: "tools/call", params: { name, arguments: args } }),
32 });
33 const body: any = await res.json();
34 return { text: body.result?.content?.[0]?.text as string, isError: body.result?.isError === true };
35 }
36
37 async function world() {
38 const n = ++worlds;
39 const made = await createRepo(app, alice, { name: `r${n}`, visibility: "public" });
40 if (!made.ok) throw new Error(String(made.error));
41 const issue = createIssue(app.db, { repoId: made.value.id, authorId: alice.id, title: "Ship it", body: "now" });
42 if (!issue.ok) throw new Error(issue.error);
43 const via = `conversation:r${n}`;
44 const mint = () => {
45 const m = mintViaToken(app.db, alice.id, via, `close ${issue.value.number} in alice/r${n}`, new Date(Date.now() + 600_000).toISOString());
46 if (!m.ok) throw new Error(m.error);
47 return m.value.token;
48 };
49 return { repo: `alice/r${n}`, repoId: made.value.id, number: issue.value.number, via, mint };
50 }
51
52 const state = (w: { repoId: string; number: number }) => {
53 const i = getIssue(app.db, w.repoId, w.number);
54 return i?.state;
55 };
56 const reopen = (w: { repoId: string; number: number }) =>
57 app.db.query("UPDATE issue SET state = 'open' WHERE repo_id = ?1 AND number = ?2").run(w.repoId, w.number);
58
59 beforeAll(async () => {
60 dataDir = await mkdtemp(join(tmpdir(), "forge-conversation-resume-writes-"));
61 app = createApp({ dataDir, port: 0 });
62 servers = await serve(app);
63 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
64 if (!u.ok) throw new Error(u.error);
65 promote(app.db, u.value);
66 alice = u.value;
67 }, 30_000);
68
69 afterAll(async () => {
70 servers.stop();
71 app.close();
72 await rm(dataDir, { recursive: true, force: true });
73 });
74
75 describe("a resumed turn", () => {
76 test("a write recorded, the turn resumed, the same call sent: done once", async () => {
77 const w = await world();
78 const args = { repo: w.repo, number: w.number };
79 const first = await call(w.mint(), "close", args);
80 expect(first.isError).toBe(false);
81 expect(state(w)).toBe("closed");
82 // Its answer never reached the session; something reopened it meanwhile.
83 reopen(w);
84 carryWrites(app.db, w.via);
85 const again = await call(w.mint(), "close", args);
86 expect(JSON.parse(again.text)).toMatchObject({ already_done: true, tool: "close" });
87 expect(JSON.parse(again.text).was).toContain("closed");
88 expect(state(w)).toBe("open"); // not done twice
89 // Once: a deliberate repeat after that goes ahead.
90 const third = await call(w.mint(), "close", args);
91 expect(JSON.parse(third.text).already_done).toBeUndefined();
92 expect(state(w)).toBe("closed");
93 });
94
95 test("a write that began and never ended is not repeated either, and says so", async () => {
96 const w = await world();
97 const args = { repo: w.repo, number: w.number };
98 app.db
99 .query("INSERT INTO conversation_write (id, via, tool, args_hash, created_at) VALUES ('cut1', ?1, 'close', ?2, ?3)")
100 .run(w.via, argsHash(args), new Date().toISOString());
101 carryWrites(app.db, w.via);
102 const again = await call(w.mint(), "close", args);
103 expect(JSON.parse(again.text)).toMatchObject({ already_done: true });
104 expect(JSON.parse(again.text).was).toBeUndefined();
105 expect(state(w)).toBe("open");
106 });
107
108 test("a different call, or a turn that was not cut, is done as asked", async () => {
109 const w = await world();
110 const args = { repo: w.repo, number: w.number };
111 await call(w.mint(), "close", args);
112 reopen(w);
113 // Not resumed: the same write again in an ordinary turn goes ahead.
114 const same = await call(w.mint(), "close", args);
115 expect(JSON.parse(same.text).already_done).toBeUndefined();
116 expect(state(w)).toBe("closed");
117 // Resumed, but another argument: not the same write.
118 reopen(w);
119 carryWrites(app.db, w.via);
120 // The first answer was fenced text, so the person must have named the write.
121 const token = w.mint();
122 heard(app.db, w.via, `close ${w.repo} ${w.number} dropped`);
123 const other = await call(token, "close", { ...args, reason: "dropped" });
124 expect(JSON.parse(other.text).already_done).toBeUndefined();
125 expect(state(w)).toBe("closed");
126 });
127
128 test("a write that failed is not answered as done when the cut turn is resumed", async () => {
129 const w = await world();
130 const args = { repo: w.repo, number: w.number + 99 };
131 const failed = await call(w.mint(), "close", args);
132 expect(failed.isError).toBe(true);
133 carryWrites(app.db, w.via);
134 const again = await call(w.mint(), "close", args);
135 expect(again.text).not.toContain("already_done");
136 });
137
138 test("records older than a day go when the next write is recorded", () => {
139 const via = "conversation:old";
140 const old = recordWrite(app.db, via, "close", { a: 1 }, Date.now() - KEEP_MS - 1000);
141 recordWrite(app.db, via, "close", { a: 2 });
142 const left = app.db.query("SELECT id FROM conversation_write WHERE via = ?1").all(via);
143 expect(left.length).toBe(1);
144 expect(left.some((r: any) => r.id === old)).toBe(false);
145 });
146
147 test("argument order does not change the hash", () => {
148 expect(argsHash({ a: 1, b: { c: [1, 2], d: "x" } })).toBe(argsHash({ b: { d: "x", c: [1, 2] }, a: 1 }));
149 expect(argsHash({ a: 1 })).not.toBe(argsHash({ a: 2 }));
150 });
151 });
test/conversation-turn.test.ts +5
@@ -154,12 +154,17 @@ describe("a conversation turn", () => {
154154 // Mid-turn the token is alice's own.
155155 const live = verifyToken(app.db, readFileSync(join(dir, "cut.token"), "utf8"));
156156 expect(live.ok && live.value.user.id).toBe(alice.id);
157 // A write the cut turn sent, recorded as it began.
158 const via = `conversation:${openConversation(app.db, alice.id).id}`;
159 app.db.query("INSERT INTO conversation_write (id, via, tool, args_hash, created_at) VALUES ('w1', ?1, 'close', 'h', '')").run(via);
157160 stop.abort(); // the forge stops: the row stays running
158161 if (cut.ok) await cut.value.done;
159162 expect(app.db.query("SELECT state FROM conversation_run WHERE state = 'running'").all()).toHaveLength(1);
160163
161164 const again = resumeInterrupted(app, options(stub("resumed", "sess-cut", "answer")));
162165 expect(again).toHaveLength(1);
166 // The resumed turn keeps the cut turn's writes, marked so a repeat is answered from them.
167 expect(app.db.query<{ resumed: number }, []>("SELECT resumed FROM conversation_write WHERE id = 'w1'").get()?.resumed).toBe(1);
163168 await Promise.all(again);
164169 const args = lines("resumed");
165170 expect(args[args.indexOf("--resume") + 1]).toBe("sess-cut");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs