@springbrand/agent-runtime 0.3.0-alpha.16 → 0.3.0-alpha.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,537 +1,14 @@
1
- import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
- import {
3
- prepareStorageCommit,
4
- validateCommittedWrites,
5
- resolveListReadOptions,
6
- value,
7
- branchTip,
8
- setValue,
9
- insertEntry,
10
- insertUsage,
11
- type Storage,
12
- type Write,
13
- type CommitResult,
14
- type Entry,
15
- type EntryStructure,
16
- type EntryScan,
17
- type StorageBranchScan,
18
- type UsageRow,
19
- type UsageScan,
20
- type Value,
21
- type ValueList,
22
- type StoredValue,
23
- type ListElement,
24
- type ListReadOptions,
25
- type SessionStats,
26
- type SessionMetadata,
27
- } from "@earendil-works/pi-agent-core/harness/session";
28
- import type { Context } from "@earendil-works/pi-agent-core/harness/context";
29
-
30
- export const CURRENT_PI_STORAGE_VERSION = 3;
31
-
32
- /** Pi's Storage contract, with every physical commit confined to one DO transaction. */
33
- export class CloudflareSessionStorage implements Storage {
34
- private closed = false;
35
- private readonly sql: SqlStorage;
36
- readonly metadata: SessionMetadata;
37
-
38
- constructor(private readonly storage: DurableObjectStorage) {
39
- this.sql = storage.sql;
40
- const hasVersion =
41
- this.sql
42
- .exec(
43
- "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'pi_storage_state'",
44
- )
45
- .toArray().length > 0;
46
- const hasLegacy =
47
- this.sql
48
- .exec(
49
- "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'pi_session_entries'",
50
- )
51
- .toArray().length > 0;
52
- if (!hasVersion && hasLegacy) {
53
- throw new Error(
54
- "Pi session requires an explicit legacy-to-0.85 migration",
55
- );
56
- }
57
- if (hasVersion) {
58
- const version = this.sql
59
- .exec<{
60
- storage_version: number;
61
- }>("SELECT storage_version FROM pi_storage_state WHERE singleton = 1")
62
- .one().storage_version;
63
- if (version !== CURRENT_PI_STORAGE_VERSION)
64
- throw new Error(
65
- `Unsupported Cloudflare Pi storage version: ${version}`,
66
- );
67
- }
68
- this.sql.exec(`
69
- CREATE TABLE IF NOT EXISTS pi_session_entry_submission (entry_id TEXT PRIMARY KEY, submission_id TEXT NOT NULL);
70
- CREATE TABLE IF NOT EXISTS pi_entries (
71
- id TEXT PRIMARY KEY, seq INTEGER NOT NULL UNIQUE, parent_id TEXT,
72
- type TEXT NOT NULL, custom_type TEXT, timestamp INTEGER NOT NULL, body TEXT NOT NULL
73
- );
74
- CREATE TABLE IF NOT EXISTS pi_values (
75
- namespace TEXT NOT NULL, key TEXT NOT NULL, seq INTEGER NOT NULL, body TEXT NOT NULL,
76
- PRIMARY KEY(namespace, key)
77
- );
78
- CREATE TABLE IF NOT EXISTS pi_lists (
79
- namespace TEXT NOT NULL, key TEXT NOT NULL, seq INTEGER PRIMARY KEY, body TEXT NOT NULL
80
- );
81
- CREATE INDEX IF NOT EXISTS pi_lists_address ON pi_lists(namespace, key, seq);
82
- CREATE TABLE IF NOT EXISTS pi_usage (id TEXT PRIMARY KEY, seq INTEGER NOT NULL UNIQUE, body TEXT NOT NULL);
83
- CREATE TABLE IF NOT EXISTS pi_storage_state (singleton INTEGER PRIMARY KEY CHECK(singleton = 1), next_seq INTEGER NOT NULL, storage_version INTEGER NOT NULL, session_id TEXT NOT NULL, created_at INTEGER NOT NULL, stats TEXT NOT NULL);
84
- `);
85
- this.sql.exec(
86
- "INSERT OR IGNORE INTO pi_storage_state VALUES (1, 1, ?, ?, ?, ?)",
87
- CURRENT_PI_STORAGE_VERSION,
88
- crypto.randomUUID(),
89
- Date.now(),
90
- JSON.stringify({
91
- messageCount: 0,
92
- usage: {
93
- input: 0,
94
- output: 0,
95
- cacheRead: 0,
96
- cacheWrite: 0,
97
- totalTokens: 0,
98
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
99
- },
100
- }),
101
- );
102
- const row = this.sql
103
- .exec<{
104
- storage_version: number;
105
- session_id: string;
106
- created_at: number;
107
- }>(
108
- "SELECT storage_version, session_id, created_at FROM pi_storage_state WHERE singleton = 1",
109
- )
110
- .one();
111
- this.metadata = {
112
- id: row.session_id,
113
- createdAt: row.created_at,
114
- storageVersion: row.storage_version,
115
- };
116
- }
117
-
118
- private assertOpen(context?: Context): void {
119
- if (this.closed) throw new Error("Session storage is closed");
120
- context?.abortSignal?.throwIfAborted();
121
- }
122
-
123
- /** Synchronous boundary also used by the host's admission/settlement transaction. */
124
- commitSync(writes: Write[], context?: Context): CommitResult {
125
- this.assertOpen(context);
126
- return this.storage.transactionSync(() => {
127
- const firstSeq = this.sql
128
- .exec<{
129
- next_seq: number;
130
- }>("SELECT next_seq FROM pi_storage_state WHERE singleton = 1")
131
- .one().next_seq;
132
- const prepared = prepareStorageCommit(writes, firstSeq, Date.now());
133
- validateCommittedWrites(prepared.writes, firstSeq, {
134
- hasEntryId: (id) =>
135
- this.sql.exec("SELECT 1 FROM pi_entries WHERE id = ?", id).toArray()
136
- .length > 0,
137
- hasEntryOrUsageId: (id) =>
138
- this.sql
139
- .exec(
140
- "SELECT 1 FROM pi_entries WHERE id = ? UNION ALL SELECT 1 FROM pi_usage WHERE id = ?",
141
- id,
142
- id,
143
- )
144
- .toArray().length > 0,
145
- });
146
- const stats = this.statsSync();
147
- for (const write of prepared.writes) {
148
- switch (write.kind) {
149
- case "entry": {
150
- const { kind: _kind, ...entry } = write;
151
- if (entry.type === "message") stats.messageCount += 1;
152
- this.sql.exec(
153
- "INSERT INTO pi_entries VALUES (?, ?, ?, ?, ?, ?, ?)",
154
- entry.id,
155
- entry.seq,
156
- entry.parentId,
157
- entry.type,
158
- entry.customType ?? null,
159
- entry.timestamp,
160
- JSON.stringify(entry),
161
- );
162
- break;
163
- }
164
- case "usage": {
165
- const { kind: _kind, ...row } = write;
166
- for (const key of [
167
- "input",
168
- "output",
169
- "cacheRead",
170
- "cacheWrite",
171
- "totalTokens",
172
- "cacheWrite1h",
173
- "reasoning",
174
- ] as const) {
175
- if (row.usage[key] !== undefined)
176
- stats.usage[key] = (stats.usage[key] ?? 0) + row.usage[key];
177
- }
178
- for (const key of [
179
- "input",
180
- "output",
181
- "cacheRead",
182
- "cacheWrite",
183
- "total",
184
- ] as const)
185
- stats.usage.cost[key] += row.usage.cost[key];
186
- this.sql.exec(
187
- "INSERT INTO pi_usage VALUES (?, ?, ?)",
188
- row.id,
189
- row.seq,
190
- JSON.stringify(row),
191
- );
192
- break;
193
- }
194
- case "value":
195
- if (write.op === "delete")
196
- this.sql.exec(
197
- "DELETE FROM pi_values WHERE namespace = ? AND key = ?",
198
- write.namespace,
199
- write.key,
200
- );
201
- else
202
- this.sql.exec(
203
- "INSERT INTO pi_values VALUES (?, ?, ?, ?) ON CONFLICT(namespace, key) DO UPDATE SET seq = excluded.seq, body = excluded.body",
204
- write.namespace,
205
- write.key,
206
- write.seq,
207
- JSON.stringify(write.value),
208
- );
209
- break;
210
- case "list":
211
- if (write.op === "delete")
212
- this.sql.exec(
213
- "DELETE FROM pi_lists WHERE namespace = ? AND key = ?",
214
- write.namespace,
215
- write.key,
216
- );
217
- else
218
- this.sql.exec(
219
- "INSERT INTO pi_lists VALUES (?, ?, ?, ?)",
220
- write.namespace,
221
- write.key,
222
- write.seq,
223
- JSON.stringify(write.value),
224
- );
225
- break;
226
- }
227
- }
228
- this.sql.exec(
229
- "UPDATE pi_storage_state SET next_seq = ?, stats = ? WHERE singleton = 1",
230
- firstSeq + writes.length,
231
- JSON.stringify(stats),
232
- );
233
- return { ...prepared.result, stats };
234
- });
235
- }
236
-
237
- async commit(writes: Write[], context: Context): Promise<CommitResult> {
238
- return this.commitSync(writes, context);
239
- }
240
-
241
- async getEntries(
242
- ids: string[],
243
- context: Context,
244
- ): Promise<Map<string, Entry>> {
245
- return this.getEntriesSync(ids, context);
246
- }
247
-
248
- getEntriesSync(ids: string[], context?: Context): Map<string, Entry> {
249
- this.assertOpen(context);
250
- const entries = new Map<string, Entry>();
251
- for (const id of ids) {
252
- const row = this.sql
253
- .exec<{ body: string }>("SELECT body FROM pi_entries WHERE id = ?", id)
254
- .toArray()[0];
255
- if (row) entries.set(id, JSON.parse(row.body));
256
- }
257
- return entries;
258
- }
259
-
260
- async getValue<T>(
261
- address: Value<T>,
262
- context: Context,
263
- ): Promise<StoredValue<T> | undefined> {
264
- return this.getValueSync(address, context);
265
- }
266
-
267
- getValueSync<T>(
268
- address: Value<T>,
269
- context?: Context,
270
- ): StoredValue<T> | undefined {
271
- this.assertOpen(context);
272
- const row = this.sql
273
- .exec<{
274
- seq: number;
275
- body: string;
276
- }>(
277
- "SELECT seq, body FROM pi_values WHERE namespace = ? AND key = ?",
278
- address.namespace,
279
- address.key,
280
- )
281
- .toArray()[0];
282
- return row
283
- ? { address, seq: row.seq, value: JSON.parse(row.body) }
284
- : undefined;
285
- }
286
-
287
- async scanValues<T>(
288
- prefix: Value<T>,
289
- context: Context,
290
- ): Promise<StoredValue<T>[]> {
291
- this.assertOpen(context);
292
- return this.sql
293
- .exec<{ key: string; seq: number; body: string }>(
294
- "SELECT key, seq, body FROM pi_values WHERE namespace = ? AND substr(key, 1, length(?)) = ? ORDER BY key COLLATE BINARY",
295
- prefix.namespace,
296
- prefix.key,
297
- prefix.key,
298
- )
299
- .toArray()
300
- .map((row) => ({
301
- address: value<T>(prefix.namespace, row.key),
302
- seq: row.seq,
303
- value: JSON.parse(row.body),
304
- }));
305
- }
306
-
307
- async readList<T>(
308
- address: ValueList<T>,
309
- options: ListReadOptions | undefined,
310
- context: Context,
311
- ): Promise<ListElement<T>[]> {
312
- this.assertOpen(context);
313
- const resolved = resolveListReadOptions(options);
314
- const descending = resolved.order === "desc";
315
- const comparison = descending ? "<" : ">";
316
- return this.sql
317
- .exec<{ seq: number; body: string }>(
318
- `SELECT seq, body FROM pi_lists WHERE namespace = ? AND key = ? AND (? IS NULL OR seq ${comparison} ?) ORDER BY seq ${descending ? "DESC" : "ASC"} LIMIT ?`,
319
- address.namespace,
320
- address.key,
321
- resolved.cursor?.seq ?? null,
322
- resolved.cursor?.seq ?? null,
323
- resolved.limit,
324
- )
325
- .toArray()
326
- .map((row) => ({ seq: row.seq, value: JSON.parse(row.body) }));
327
- }
328
-
329
- private branchRows(query: StorageBranchScan, structure: boolean) {
330
- // Stops follow the requested traversal direction; filters must not hide a stop entry.
331
- const oldestFirst = query.order === "oldestFirst";
332
- const rows = this.sql
333
- .exec<{
334
- id: string;
335
- parentId: string | null;
336
- seq: number;
337
- timestamp: number;
338
- type: Entry["type"];
339
- customType: string | null;
340
- body?: string;
341
- }>(
342
- `
343
- WITH RECURSIVE path AS (
344
- SELECT id, parent_id, seq, timestamp, type, custom_type${structure ? "" : ", body"} FROM pi_entries WHERE id = ?
345
- UNION ALL
346
- SELECT e.id, e.parent_id, e.seq, e.timestamp, e.type, e.custom_type${structure ? "" : ", e.body"}
347
- FROM pi_entries e JOIN path p ON e.id = p.parent_id
348
- ) SELECT id, parent_id AS parentId, seq, timestamp, type, custom_type AS customType${structure ? "" : ", body"}
349
- FROM path ORDER BY seq ${oldestFirst ? "ASC" : "DESC"}
350
- `,
351
- query.start,
352
- )
353
- .toArray();
354
- if (!rows.length) throw new Error(`Unknown branch start: ${query.start}`);
355
- const root = oldestFirst ? rows[0]! : rows.at(-1)!;
356
- if (root.parentId !== null)
357
- throw new Error("Corrupt branch: missing parent");
358
- const stop = rows.findIndex(
359
- (row) => row.id === query.stopAtId || row.type === query.stopAtType,
360
- );
361
- return (stop < 0 ? rows : rows.slice(0, stop + 1))
362
- .filter(
363
- (row) =>
364
- (query.type === undefined || query.type === row.type) &&
365
- (query.customType === undefined ||
366
- query.customType === row.customType) &&
367
- (query.cursor === undefined ||
368
- (oldestFirst
369
- ? row.seq > query.cursor.seq
370
- : row.seq < query.cursor.seq)),
371
- )
372
- .slice(
373
- 0,
374
- query.limit === undefined ? undefined : Math.max(0, query.limit),
375
- );
376
- }
377
-
378
- async scanBranch(
379
- query: StorageBranchScan,
380
- context: Context,
381
- ): Promise<Entry[]> {
382
- return this.scanBranchSync(query, context);
383
- }
384
-
385
- scanBranchSync(query: StorageBranchScan, context?: Context): Entry[] {
386
- this.assertOpen(context);
387
- return this.branchRows(query, false).map((row) => JSON.parse(row.body!));
388
- }
389
-
390
- async scanBranchStructure(
391
- query: StorageBranchScan,
392
- context: Context,
393
- ): Promise<EntryStructure[]> {
394
- this.assertOpen(context);
395
- return this.branchRows(query, true).map(
396
- ({ customType, body: _body, ...row }) => ({
397
- ...row,
398
- ...(customType === null ? {} : { customType }),
399
- }),
400
- );
401
- }
402
-
403
- async scanEntries(query: EntryScan, context: Context): Promise<Entry[]> {
404
- this.assertOpen(context);
405
- return this.sql
406
- .exec<{ body: string }>(
407
- `
408
- SELECT body FROM pi_entries WHERE (? IS NULL OR type = ?) AND (? IS NULL OR custom_type = ?)
409
- AND (? IS NULL OR seq >= ?) AND (? IS NULL OR seq <= ?) ORDER BY seq ${query.order === "desc" ? "DESC" : "ASC"} LIMIT ?`,
410
- query.type ?? null,
411
- query.type ?? null,
412
- query.customType ?? null,
413
- query.customType ?? null,
414
- query.fromSeq ?? null,
415
- query.fromSeq ?? null,
416
- query.toSeq ?? null,
417
- query.toSeq ?? null,
418
- query.limit === undefined ? -1 : Math.max(0, Math.trunc(query.limit)),
419
- )
420
- .toArray()
421
- .map((row) => JSON.parse(row.body));
422
- }
423
-
424
- async scanUsage(query: UsageScan, context: Context): Promise<UsageRow[]> {
425
- this.assertOpen(context);
426
- return this.sql
427
- .exec<{ body: string }>(
428
- `
429
- SELECT body FROM pi_usage WHERE (? IS NULL OR seq >= ?) AND (? IS NULL OR seq <= ?)
430
- ORDER BY seq ${query.order === "desc" ? "DESC" : "ASC"} LIMIT ?`,
431
- query.fromSeq ?? null,
432
- query.fromSeq ?? null,
433
- query.toSeq ?? null,
434
- query.toSeq ?? null,
435
- query.limit === undefined ? -1 : Math.max(0, Math.trunc(query.limit)),
436
- )
437
- .toArray()
438
- .map((row) => JSON.parse(row.body));
439
- }
440
-
441
- private statsSync(): SessionStats {
442
- return JSON.parse(
443
- this.sql
444
- .exec<{
445
- stats: string;
446
- }>("SELECT stats FROM pi_storage_state WHERE singleton = 1")
447
- .one().stats,
448
- );
449
- }
450
-
451
- async getStats(context: Context): Promise<SessionStats> {
452
- this.assertOpen(context);
453
- return this.statsSync();
454
- }
455
-
456
- /** Host commits use this single branch; off-branch entries remain available by ID. */
457
- branch(): Entry[] {
458
- const tip = this.getValueSync(branchTip("main"))?.value;
459
- return tip ? this.scanBranchSync({ start: tip, order: "oldestFirst" }) : [];
460
- }
461
-
462
- entry(id: string): Entry | undefined {
463
- return this.getEntriesSync([id]).get(id);
464
- }
465
-
466
- appendMessage(input: {
467
- id: string;
468
- message: AgentMessage;
469
- submissionId?: string;
470
- }): boolean {
471
- return this.storage.transactionSync(() => {
472
- if (this.entry(input.id)) return false;
473
- const usage =
474
- input.message.role === "assistant" ||
475
- input.message.role === "toolResult"
476
- ? input.message.usage
477
- : undefined;
478
- this.commitSync([
479
- insertEntry({
480
- id: input.id,
481
- parentId: this.getValueSync(branchTip("main"))?.value ?? null,
482
- type: "message",
483
- message: input.message,
484
- }),
485
- setValue(branchTip("main"), input.id),
486
- ...(usage
487
- ? [
488
- insertUsage({
489
- id: crypto.randomUUID(),
490
- entryId: input.id,
491
- usage,
492
- adjustment: false,
493
- }),
494
- ]
495
- : []),
496
- ]);
497
- if (input.submissionId)
498
- this.sql.exec(
499
- "INSERT INTO pi_session_entry_submission VALUES (?, ?)",
500
- input.id,
501
- input.submissionId,
502
- );
503
- return true;
504
- });
505
- }
506
-
507
- entries(): Entry[] {
508
- return this.sql
509
- .exec<{ body: string }>("SELECT body FROM pi_entries ORDER BY seq")
510
- .toArray()
511
- .map((row) => JSON.parse(row.body));
512
- }
513
-
514
- clear(): void {
515
- this.storage.transactionSync(() => {
516
- this.sql.exec(
517
- "DELETE FROM pi_session_entry_submission; DELETE FROM pi_entries; DELETE FROM pi_values; DELETE FROM pi_lists; DELETE FROM pi_usage;",
518
- );
519
- const usage = {
520
- input: 0,
521
- output: 0,
522
- cacheRead: 0,
523
- cacheWrite: 0,
524
- totalTokens: 0,
525
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
526
- };
527
- this.sql.exec(
528
- "UPDATE pi_storage_state SET stats = ? WHERE singleton = 1",
529
- JSON.stringify({ messageCount: 0, usage }),
530
- );
531
- });
532
- }
533
-
534
- async close(_context?: Context): Promise<void> {
535
- this.closed = true;
536
- }
1
+ import { defineDoc, type ConversationId, type Storage } from "@earendil-works/pi-durable";
2
+ import { openDurableObjectSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/cloudflare";
3
+
4
+ /** The host selects a native conversation when regenerating or clearing history. */
5
+ export const RuntimeConversation = defineDoc({
6
+ kind: "springbrand.conversation",
7
+ version: 1,
8
+ scope: "session",
9
+ initial: (): { id: ConversationId | null } => ({ id: null }),
10
+ });
11
+
12
+ export async function openNativeStorage(storage: DurableObjectStorage): Promise<Storage> {
13
+ return openDurableObjectSqliteStorage(storage);
537
14
  }
@@ -1,39 +1,26 @@
1
- import { StorageBackedSession } from "@earendil-works/pi-agent-core/harness/session";
2
- import {
3
- migrateCurrentPiSession,
4
- migrateLegacyPiSession,
5
- } from "./cloudflare/migration";
6
- import { CloudflareSessionStorage } from "./cloudflare/storage";
1
+ import { createSession, type Storage } from "@earendil-works/pi-durable";
2
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
3
+ import { migratePiSession } from "./cloudflare/migration";
4
+ import { openNativeStorage } from "./cloudflare/storage";
7
5
 
8
- export { migrateLegacyPiSession } from "./cloudflare/migration";
9
- export { CloudflareSessionStorage } from "./cloudflare/storage";
6
+ export { RuntimeConversation } from "./cloudflare/storage";
7
+ export { migratePiSession } from "./cloudflare/migration";
10
8
 
11
- /** Open only after the startup transaction has installed the current database format. */
12
- export function openPiSessionStorage(
13
- storage: DurableObjectStorage,
14
- ): CloudflareSessionStorage {
15
- return storage.transactionSync(() => {
16
- const tables = new Set(
17
- storage.sql
18
- .exec<{ name: string }>(
19
- "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('pi_storage_state', 'pi_session_entries')",
20
- )
21
- .toArray()
22
- .map((row) => row.name),
23
- );
24
- if (!tables.has("pi_storage_state") && tables.has("pi_session_entries")) {
25
- migrateLegacyPiSession(storage, { dryRun: false });
26
- } else if (tables.has("pi_storage_state")) {
27
- migrateCurrentPiSession(storage);
28
- }
29
- return new CloudflareSessionStorage(storage);
30
- });
31
- }
9
+ const migrations = new WeakMap<DurableObjectStorage, Promise<void>>();
32
10
 
33
- /** Open the single official Pi Session owned by one Cloudflare Agent facet. */
34
- export function openCloudflareSession(
35
- storage: DurableObjectStorage,
36
- ): StorageBackedSession {
37
- const store = openPiSessionStorage(storage);
38
- return new StorageBackedSession(store.metadata, store);
11
+ /** Migration completes before native execution or history reads are admitted. */
12
+ export async function openCloudflareSession(storage: DurableObjectStorage): Promise<Storage> {
13
+ let migrating = migrations.get(storage);
14
+ if (!migrating) {
15
+ migrating = (async () => {
16
+ const native = await openNativeStorage(storage);
17
+ const session = createSession(native);
18
+ try { await migratePiSession(storage, session); }
19
+ finally { await session.close(BACKGROUND_CONTEXT); }
20
+ })();
21
+ migrations.set(storage, migrating);
22
+ migrating.catch(() => migrations.delete(storage));
23
+ }
24
+ await migrating;
25
+ return openNativeStorage(storage);
39
26
  }
@@ -1,5 +1,5 @@
1
1
  import type { AssistantMessage } from "@earendil-works/pi-ai";
2
- import type { CancelQueuedResult } from "@earendil-works/pi-agent-core";
2
+ import type { CancelQueuedResult } from "./steer-delivery";
3
3
  import type { ConversationHistory } from "../conversation/history";
4
4
  import type { MessageUiRepository } from "../conversation/repository";
5
5
  import type { RuntimeTelemetryCoordinator } from "../telemetry/coordinator";
@@ -13,6 +13,8 @@ import type { SteerRepository } from "./steer-repository";
13
13
 
14
14
  /** Commits the Submission outcome and human/Host facts after accepted steering is preserved. */
15
15
  export class SubmissionFinalizer {
16
+ /** User-stop markers appended after their terminal commit; `finish()` awaits its own. */
17
+ private readonly stopMarkers = new Map<string, Promise<unknown>>();
16
18
  constructor(private readonly options: {
17
19
  submissions: SubmissionRepository;
18
20
  steers: SteerRepository;
@@ -53,7 +55,13 @@ export class SubmissionFinalizer {
53
55
  const output = (await this.options.conversation.storedMessages())
54
56
  .filter((entry) => entry.submissionId === submissionId && entry.message.role === "assistant")
55
57
  .at(-1)?.message;
56
- return this.commit(submissionId, outcome, message, output?.role === "assistant" ? output : undefined);
58
+ const completed = this.commit(submissionId, outcome, message, output?.role === "assistant" ? output : undefined);
59
+ const marker = this.stopMarkers.get(submissionId);
60
+ if (marker) {
61
+ this.stopMarkers.delete(submissionId);
62
+ await marker;
63
+ }
64
+ return completed;
57
65
  }
58
66
 
59
67
  /** Shares atomic terminal facts with an admitted handoff cancelled before execution. */
@@ -63,7 +71,8 @@ export class SubmissionFinalizer {
63
71
  message?: string,
64
72
  output?: AssistantMessage,
65
73
  ): StoredSubmission {
66
- return this.options.transaction(() => {
74
+ let stoppedByUser = false;
75
+ const completed = this.options.transaction(() => {
67
76
  // Host admission and history reads can await while an explicit stop wins.
68
77
  const latest = this.read(submissionId);
69
78
  if (isTerminalSubmissionStatus(latest.status)) return latest;
@@ -79,16 +88,9 @@ export class SubmissionFinalizer {
79
88
  const status = effectiveOutcome === "succeeded" ? "completed" : effectiveOutcome === "aborted" ? "aborted" : "error";
80
89
  this.options.submissions.updateTerminal(submissionId, status, reason, Date.now());
81
90
  const completed = this.read(submissionId);
82
- if (effectiveOutcome === "aborted" && reason === USER_STOP_REASON) {
83
- if (completed.completedAt === null) {
84
- throw new Error(
85
- `Aborted SpringBrand submission has no completion time: ${submissionId}`,
86
- );
87
- }
88
- this.options.conversation.appendTurnAbortedMarker(
89
- submissionId,
90
- completed.completedAt,
91
- );
91
+ stoppedByUser = effectiveOutcome === "aborted" && reason === USER_STOP_REASON;
92
+ if (stoppedByUser && completed.completedAt === null) {
93
+ throw new Error(`Aborted SpringBrand submission has no completion time: ${submissionId}`);
92
94
  }
93
95
  this.options.events.submissionTerminal(completed);
94
96
  this.options.telemetry.capture("turnFinished", {
@@ -101,6 +103,20 @@ export class SubmissionFinalizer {
101
103
  });
102
104
  return completed;
103
105
  });
106
+ if (stoppedByUser) this.appendStopMarker(submissionId, completed.completedAt!);
107
+ return completed;
108
+ }
109
+
110
+ /**
111
+ * The browser projects `interruptedByUser` from this marker and the next model request sees it as user context.
112
+ * Pi history writes are async, so the marker follows the synchronous terminal commit.
113
+ */
114
+ private appendStopMarker(submissionId: string, completedAt: number): void {
115
+ const marker = this.options.conversation.appendTurnAbortedMarker(submissionId, completedAt).catch((error: unknown) => {
116
+ console.warn("[runtime-stop-marker:failed]", submissionId, error instanceof Error ? error.message : String(error));
117
+ });
118
+ this.stopMarkers.set(submissionId, marker);
119
+ this.options.waitUntil(marker);
104
120
  }
105
121
 
106
122
  private read(submissionId: string): StoredSubmission {