@springbrand/agent-runtime 0.3.0-alpha.17 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/agent-runtime",
3
- "version": "0.3.0-alpha.17",
3
+ "version": "0.3.0-alpha.18",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -71,7 +71,7 @@ import type {
71
71
  } from "@earendil-works/pi-ai";
72
72
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
73
73
  import { openCloudflareSession } from "../../session";
74
- import { openSessionHarness, prepareSubmissionOperation } from "../../harness";
74
+ import { ModelRecovery, openSessionHarness, prepareSubmissionOperation } from "../../harness";
75
75
  import type { SessionHarness } from "../../harness";
76
76
  import {
77
77
  compileHarnessTools,
@@ -789,10 +789,13 @@ export abstract class AgentRuntimeKernel<
789
789
  (key) => this.ensureRuntimeVersion(key),
790
790
  );
791
791
  const activeRuntime = this.requireRuntime();
792
+ // A fresh provider Session per Submission keeps one failing upstream route from pinning later turns.
793
+ const recovery = new ModelRecovery(submissionId);
792
794
  const harnessInput = this.pi.harnessInputs(
793
795
  activeRuntime.pi,
794
796
  submission.assemblyDescriptor,
795
797
  {
798
+ modelSessionId: () => recovery.sessionId(),
796
799
  assertRequestAllowed: () => {
797
800
  const current = this.db.submissions.find(submissionId);
798
801
  if (current?.abortReason || current?.unattendedFailure) throw new Error(current.abortReason ?? current.unattendedFailure!);
@@ -875,6 +878,7 @@ export abstract class AgentRuntimeKernel<
875
878
  streamOptions: { maxRetries: 0 },
876
879
  compaction: harnessInput.compaction,
877
880
  toolExecution: "parallel",
881
+ recovery,
878
882
  beforeTool: (call, _api, context) => gate.beforeTool({ toolName: call.name, toolCallId: call.id, args: call.arguments }, context),
879
883
  afterResponse: (message) => {
880
884
  for (const part of message.content) if (part.type === "toolCall") part.arguments = prepareToolInput(part.name, part.arguments) as JsonObject;
@@ -883,6 +887,7 @@ export abstract class AgentRuntimeKernel<
883
887
  this.activeHarness = runtime;
884
888
  let stream: Awaited<ReturnType<typeof bindHarnessStream>> | undefined;
885
889
  let deactivate = () => {};
890
+ let released = false;
886
891
  try {
887
892
 
888
893
  if ((submission.abortReason || submission.unattendedFailure) && runtime.openOperation) {
@@ -985,6 +990,7 @@ export abstract class AgentRuntimeKernel<
985
990
  idempotent: true,
986
991
  });
987
992
  }
993
+ released = true;
988
994
  return this.db.submissions.find(submissionId)!;
989
995
  }
990
996
  const record = outcome.outcome;
@@ -1025,6 +1031,14 @@ export abstract class AgentRuntimeKernel<
1025
1031
  deactivate();
1026
1032
  if (this.activeHarness === runtime) this.activeHarness = undefined;
1027
1033
  await runtime.close(BACKGROUND_CONTEXT).catch(() => undefined);
1034
+ if (released) {
1035
+ // Pi closes the Harness on release and drops undelivered stream frames; the
1036
+ // canonical transcript gives a paused question or approval its complete input.
1037
+ this.broadcast(json({
1038
+ type: MessageType.CF_AGENT_CHAT_MESSAGES,
1039
+ messages: await this.getMessages(),
1040
+ }));
1041
+ }
1028
1042
  }
1029
1043
  }
1030
1044
 
@@ -424,6 +424,16 @@ export abstract class CloudflareSubAgent<
424
424
  const progress = progressFor(event);
425
425
  if (progress) this.persistProgress(runId, progress);
426
426
  },
427
+ // Live frames can stop short of the last response; the canonical entries are the billed total.
428
+ onEntries: (entries) => {
429
+ const canonical = emptySubagentUsage();
430
+ for (const entry of entries) {
431
+ const message = entry.model?.[0];
432
+ if (message?.role === "assistant") addSubagentUsage(canonical, message.usage);
433
+ }
434
+ Object.assign(usage, canonical);
435
+ this.persistUsage(runId, usage);
436
+ },
427
437
  });
428
438
  publish(projection.finish({ status: "completed", endedAt: Date.now() }));
429
439
  this.completeRun(runId, output, usage);
@@ -1,5 +1,5 @@
1
1
  import type { StreamFn } from "@earendil-works/pi-agent-core";
2
- import type { AgentEvent as HarnessEvent, Storage as Session, ToolRegistration } from "@earendil-works/pi-durable";
2
+ import type { AgentEvent as HarnessEvent, AgentEventStream, EntryRecord, Storage as Session, ToolRegistration } from "@earendil-works/pi-durable";
3
3
  import {
4
4
  awaitWithContext,
5
5
  BACKGROUND_CONTEXT,
@@ -38,8 +38,21 @@ export interface CloudflareSubAgentRun {
38
38
  tools?: ToolRegistration<any, any>[];
39
39
  signal?: AbortSignal;
40
40
  timeoutMs?: number;
41
- /** Native operation events for projections that must survive model retries. */
41
+ /** Native operation events for live projections; they may stop short of the final frame. */
42
42
  onEvent?: (event: HarnessEvent, signal: AbortSignal) => void | Promise<void>;
43
+ /** This operation's canonical entries after each settle or release, the source of its final usage. */
44
+ onEntries?: (entries: readonly EntryRecord[]) => void | Promise<void>;
45
+ }
46
+
47
+ /** The first failed Tool result among an operation's canonical entries. */
48
+ function toolFailureOf(entries: readonly EntryRecord[]): { name: string; error: string } | undefined {
49
+ for (const entry of entries) {
50
+ const result = entry.model?.[0];
51
+ if (result?.role !== "toolResult" || !result.isError) continue;
52
+ const diagnostics = (entry.data as { diagnostics?: Array<{ severity: string; message: string }> } | undefined)?.diagnostics;
53
+ return { name: result.toolName, error: diagnostics?.find((item) => item.severity === "error")?.message ?? toolErrorText(result) };
54
+ }
55
+ return undefined;
43
56
  }
44
57
 
45
58
  function textOf(message: AssistantMessage): string {
@@ -129,7 +142,13 @@ export async function runCloudflareSubAgent(run: CloudflareSubAgentRun): Promise
129
142
  compaction: { enabled: false, reserveTokens: 1, keepRecentTokens: 1 },
130
143
  } satisfies Omit<SessionHarnessOptions, "storage">;
131
144
  let runtime: SessionHarness | undefined;
145
+ let events: AgentEventStream | undefined;
132
146
  let toolFailure: { name: string; error: string } | undefined;
147
+ const operationEntries = async (active: SessionHarness): Promise<EntryRecord[]> => {
148
+ const submission = await active.submissionFor(operationId, BACKGROUND_CONTEXT);
149
+ const entries = await active.entries(BACKGROUND_CONTEXT);
150
+ return submission?.entry === undefined ? [] : entries.filter((entry) => entry.id >= submission.entry!);
151
+ };
133
152
 
134
153
  let timedOut = false;
135
154
  const timeout = setTimeout(() => {
@@ -148,15 +167,9 @@ export async function runCloudflareSubAgent(run: CloudflareSubAgentRun): Promise
148
167
  if (runtime.openOperation && runtime.openOperation.operationId !== operationId) {
149
168
  throw new Error("SubAgent Session has an unrelated open operation");
150
169
  }
151
- const watch = await runtime.watch(context);
152
- watch.start(async (events) => {
153
- for (const event of events) {
154
- await run.onEvent?.(event, signal);
155
- if (event.type === "tool_execution_end") {
156
- const result = event.entry?.model?.[0];
157
- if (result?.role === "toolResult" && result.isError) toolFailure ??= { name: event.toolName, error: ((event.entry?.data as { diagnostics?: Array<{ severity: string; message: string }> } | undefined)?.diagnostics?.find((item) => item.severity === "error")?.message ?? toolErrorText(result)) };
158
- }
159
- }
170
+ events = await runtime.watch(context);
171
+ events.start(async (batch) => {
172
+ for (const event of batch) await run.onEvent?.(event, signal);
160
173
  });
161
174
  }
162
175
  const result = await runtime.run(
@@ -168,12 +181,18 @@ export async function runCloudflareSubAgent(run: CloudflareSubAgentRun): Promise
168
181
  : undefined,
169
182
  context,
170
183
  );
184
+ await events?.stop();
185
+ events = undefined;
171
186
  if (result.kind === "released") {
172
187
  await runtime.close(BACKGROUND_CONTEXT);
173
188
  runtime = undefined;
174
189
  continue;
175
190
  }
176
191
  settled = result;
192
+ // Pi's stop may drop the last frames; failures and usage come from the canonical record.
193
+ const owned = await operationEntries(runtime);
194
+ toolFailure = toolFailureOf(owned);
195
+ await run.onEntries?.(owned);
177
196
  break;
178
197
  }
179
198
  if (timedOut) throw new Error("SubAgent run timed out");
@@ -215,6 +234,7 @@ export async function runCloudflareSubAgent(run: CloudflareSubAgentRun): Promise
215
234
  await runtime.abort(BACKGROUND_CONTEXT).catch(() => undefined);
216
235
  await runtime.run(undefined, BACKGROUND_CONTEXT).catch(() => undefined);
217
236
  }
237
+ await events?.stop();
218
238
  await runtime?.close(BACKGROUND_CONTEXT).catch(() => undefined);
219
239
  }
220
240
  }
@@ -91,7 +91,7 @@ export class HarnessRuntimeConfig {
91
91
  prepared: PreparedPiRuntime,
92
92
  pinnedDescriptor: string,
93
93
  request: {
94
- readonly modelSessionId?: string;
94
+ readonly modelSessionId?: string | (() => string);
95
95
  readonly onGeneration?: PiGenerationLifecycleObserver;
96
96
  readonly assertRequestAllowed?: () => void;
97
97
  readonly onCompactionGeneration?: PiGenerationLifecycleObserver;
@@ -22,6 +22,7 @@ export class HarnessStreamProjection {
22
22
  private readonly encoder: PiChunkEncoder;
23
23
  private terminal?: AssistantMessage;
24
24
  private readonly shown = new Map<number, string>();
25
+ private readonly toolInputStarted = new Set<string>();
25
26
  private readonly toolOutput = new Map<string, string>();
26
27
  constructor(options: HarnessStreamOptions) { this.encoder = new PiChunkEncoder(options); }
27
28
  restore(message: UIMessage, streaming: boolean, snapshot?: SnapshotEvent): UIMessageChunk[] {
@@ -33,9 +34,15 @@ export class HarnessStreamProjection {
33
34
  for (const tool of snapshot?.tools ?? []) if (tool.output !== undefined) this.toolOutput.set(tool.callId, tool.output);
34
35
  return this.encoder.restore(message, streaming);
35
36
  }
36
- private block(block: AssistantMessage["content"][number], contentIndex: number): UIMessageChunk[] {
37
+ private block(block: AssistantMessage["content"][number], contentIndex: number, settled = false): UIMessageChunk[] {
37
38
  if (block.type === "toolCall" && (!block.id || !block.name)) return [];
38
- if (block.type === "toolCall") return this.encoder.encode({ type: "tool_execution_start", toolCallId: block.id, toolName: block.name, args: block.arguments } as LoopEvent);
39
+ if (block.type === "toolCall") {
40
+ // The encoder keeps the first input it sees, so only a settled message may publish arguments.
41
+ if (settled) return this.encoder.encode({ type: "tool_execution_start", toolCallId: block.id, toolName: block.name, args: block.arguments } as LoopEvent);
42
+ if (this.toolInputStarted.has(block.id)) return [];
43
+ this.toolInputStarted.add(block.id);
44
+ return [{ type: "tool-input-start", toolCallId: block.id, toolName: block.name, dynamic: true }];
45
+ }
39
46
  if (block.type !== "text" && block.type !== "thinking") return [];
40
47
  const start = block.type === "thinking"
41
48
  ? this.encoder.encode({ type: "message_update", assistantMessageEvent: { type: "thinking_start", contentIndex } } as LoopEvent)
@@ -47,7 +54,15 @@ export class HarnessStreamProjection {
47
54
  if (!delta) return start;
48
55
  return [...start, ...this.encoder.encode({ type: "message_update", assistantMessageEvent: { type: block.type === "text" ? "text_delta" : "thinking_delta", contentIndex, delta } } as LoopEvent)];
49
56
  }
50
- event(event: AgentEvent): UIMessageChunk[] {
57
+ /**
58
+ * Pi emits `run_start` only when the live run changes, so a stream bound to a continued run (after an approval or a
59
+ * slice release) sees tool or message frames first. The encoder's start is idempotent; lead the first frames with it.
60
+ */
61
+ private started(chunks: UIMessageChunk[]): UIMessageChunk[] {
62
+ return chunks.length === 0 ? chunks : [...this.encoder.start(), ...chunks];
63
+ }
64
+ event(event: AgentEvent): UIMessageChunk[] { return this.started(this.frames(event)); }
65
+ private frames(event: AgentEvent): UIMessageChunk[] {
51
66
  switch (event.type) {
52
67
  case "run_start": return this.encoder.start();
53
68
  case "turn_start": this.terminal = undefined; this.shown.clear(); return this.encoder.encode({ type: "turn_start" } as LoopEvent);
@@ -74,14 +89,14 @@ export class HarnessStreamProjection {
74
89
  const message = event.entry.model?.[0];
75
90
  if (message?.role !== "assistant") return [];
76
91
  this.terminal = message;
77
- return [...message.content.flatMap((block, index) => this.block(block, index)), ...this.encoder.encode({ type: "message_end", message }, { terminal: false })];
92
+ return [...message.content.flatMap((block, index) => this.block(block, index, true)), ...this.encoder.encode({ type: "message_end", message }, { terminal: false })];
78
93
  }
79
94
  default: return [];
80
95
  }
81
96
  }
82
- approvalRequested(toolCallId: string, approvalId: string): UIMessageChunk[] { return this.encoder.approvalRequested(toolCallId, approvalId); }
97
+ approvalRequested(toolCallId: string, approvalId: string): UIMessageChunk[] { return this.started(this.encoder.approvalRequested(toolCallId, approvalId)); }
83
98
  finish(result: SessionRunOutcome): UIMessageChunk[] {
84
- return this.encoder.finish({ stopReason: result.status === "aborted" ? "aborted" : result.status === "failed" ? "error" : this.terminal?.stopReason === "length" ? "length" : "stop", timestamp: result.endedAt, ...(result.error ? { errorMessage: result.error.message } : {}) });
99
+ return this.started(this.encoder.finish({ stopReason: result.status === "aborted" ? "aborted" : result.status === "failed" ? "error" : this.terminal?.stopReason === "length" ? "length" : "stop", timestamp: result.endedAt, ...(result.error ? { errorMessage: result.error.message } : {}) }));
85
100
  }
86
101
  }
87
102
 
@@ -92,6 +107,7 @@ export async function bindHarnessStream(runtime: SessionHarness, options: Harnes
92
107
  watch.start((events) => delivery = (async () => { for (const event of events) await options.publish(projection.event(event)); })());
93
108
  return {
94
109
  publishApprovalRequest: (toolCallId: string, approvalId: string) => options.publish(projection.approvalRequested(toolCallId, approvalId)),
110
+ // The Host follows the terminal frame with the canonical transcript, which completes any frame Pi's stop dropped.
95
111
  flushTerminal: async (result: SessionRunOutcome) => { await watch.stop(); await delivery; await options.publish(projection.finish(result)); },
96
112
  unsubscribe: async () => { await watch.stop(); await delivery; },
97
113
  };
@@ -18,6 +18,30 @@ import {
18
18
  type UIUserSidecar,
19
19
  } from "./message";
20
20
  import { openCloudflareSession, RuntimeConversation } from "../session";
21
+ import { dropLegacyPiTables } from "../session/cloudflare/migration";
22
+
23
+ /**
24
+ * Pi Durable 1.1 has no public API that deletes a conversation; `Tx` can only create, fork and retire documents.
25
+ * Clear therefore deletes every record outside the newly selected conversation from the official SQLite schema
26
+ * version 1. A different schema version keeps the records rather than guessing at its layout.
27
+ */
28
+ function deleteConversationsExcept(sql: SqlStorage, keep: number): void {
29
+ const schema = sql.exec<{ version: number }>("SELECT version FROM durable_schema WHERE singleton=1").toArray()[0]?.version;
30
+ if (schema !== 1) {
31
+ console.warn("[conversation-clear:degraded]", `Unsupported Pi Durable SQLite schema ${String(schema)}; previous records kept`);
32
+ return;
33
+ }
34
+ const documents = `SELECT id FROM documents WHERE (scope_kind='conversation' AND owner_id<>?1)
35
+ OR (scope_kind='task' AND owner_id IN (SELECT id FROM tasks WHERE conversation_id<>?1))`;
36
+ sql.exec(`DELETE FROM document_revisions WHERE document_id IN (${documents})`, keep);
37
+ sql.exec(`DELETE FROM record_ids WHERE id IN (${documents}
38
+ UNION SELECT id FROM submissions WHERE conversation_id<>?1 UNION SELECT id FROM tasks WHERE conversation_id<>?1
39
+ UNION SELECT id FROM entries WHERE conversation_id<>?1 UNION SELECT id FROM conversations WHERE id<>?1)`, keep);
40
+ sql.exec(`DELETE FROM documents WHERE id IN (${documents})`, keep);
41
+ for (const [table, column] of [["submissions", "conversation_id"], ["tasks", "conversation_id"], ["entries", "conversation_id"], ["conversations", "id"]]) {
42
+ sql.exec(`DELETE FROM ${table} WHERE ${column}<>?`, keep);
43
+ }
44
+ }
21
45
 
22
46
  /**
23
47
  * 本文件负责保存 Pi 的规范消息,并按浏览器或 Extension Host 的需要投影历史。
@@ -264,7 +288,8 @@ export class ConversationHistory {
264
288
  const custom = message.role === "custom";
265
289
  const entry = await tx.appendEntry(conversationId!, {
266
290
  kind: custom ? "springbrand.custom" : message.role === "toolResult" ? "pi.tool-result" : `pi.${message.role}`,
267
- ...(custom ? {} : { model: [message] }),
291
+ // Custom messages reach the model as user context, as Pi's convertToLlm did before the native harness.
292
+ model: [custom ? { role: "user", content: [...message.content], timestamp: message.timestamp } : message],
268
293
  data: JSON.parse(JSON.stringify({ displayId: id, createdAt: message.timestamp, ...(custom ? { message } : {}), ...(options.submissionId ? { submissionId: options.submissionId } : {}), ...(message.role === "toolResult" ? { diagnostics: [] } : {}) })),
269
294
  });
270
295
  if (options.userMessage) this.durability.upsertUserSidecar(String(entry.id), JSON.stringify({ id: options.userMessage.id, ...captureUIUserSidecar(options.userMessage) }));
@@ -419,10 +444,18 @@ export class ConversationHistory {
419
444
  * 两类 Transcript 数据必须一起删除,否则复用消息 ID 时可能重新读到旧附件或 UI 元数据。
420
445
  */
421
446
  async clear(): Promise<void> {
422
- await this.withSession((session) => session.commit(async (tx) => {
423
- const conversation = await tx.createConversation({ ownership: { kind: "ownerless" } });
424
- (await tx.doc(RuntimeConversation)).id = conversation.id;
425
- }, BACKGROUND_CONTEXT));
447
+ await this.storage.transaction(() => this.withSession(async (session) => {
448
+ const next = await session.commit(async (tx) => {
449
+ const conversation = await tx.createConversation({ ownership: { kind: "ownerless" } });
450
+ (await tx.doc(RuntimeConversation)).id = conversation.id;
451
+ return conversation.id;
452
+ }, BACKGROUND_CONTEXT);
453
+ deleteConversationsExcept(this.storage.sql, next);
454
+ await dropLegacyPiTables(this.storage, session);
455
+ // Entry associations name records that no longer exist.
456
+ this.storage.sql.exec("CREATE TABLE IF NOT EXISTS pi_session_entry_submission (entry_id TEXT PRIMARY KEY, submission_id TEXT NOT NULL)");
457
+ this.storage.sql.exec("DELETE FROM pi_session_entry_submission");
458
+ }));
426
459
  this.durability.clearUserSidecars();
427
460
  }
428
461
 
@@ -563,6 +596,8 @@ export class ConversationHistory {
563
596
  entry.message.role === "custom" &&
564
597
  entry.message.customType === "turn-aborted"
565
598
  ) {
599
+ // The marker is this stop's terminal; the no-answer fallback would add a second one.
600
+ if (pendingFailedSubmissionId === entry.submissionId) pendingFailedSubmissionId = undefined;
566
601
  const submission = this.durability.findSubmissionProjection(
567
602
  entry.submissionId,
568
603
  );
@@ -0,0 +1,206 @@
1
+ import type { AssistantMessage, Message, ToolCall, ToolResultMessage } from "@earendil-works/pi-ai";
2
+ import { isRetryableAssistantError } from "@earendil-works/pi-ai/utils/retry";
3
+ import type { EntryRecord } from "@earendil-works/pi-durable";
4
+ import type { ArtifactRef } from "../lib/artifacts";
5
+ import {
6
+ isModelStreamStallMessage,
7
+ isOpenRouterServerToolTimeout,
8
+ isRecoverableAssistantError,
9
+ } from "../models";
10
+
11
+ export const MAX_TOOL_RETRIES = 3;
12
+ export const TURN_FAILURE_CEILING = 8;
13
+ export const DEFAULT_MAX_WRAP_UP_MODEL_TURNS = 2;
14
+ export const TOOL_FAILURE_WRAP_UP_INSTRUCTION =
15
+ "This turn reached its tool failure limit. Stop calling tools now. Reply with a final message that explains what failed and the exact next step the user should take. Do not start new work.";
16
+ const RECOVERY_MAX_FAILURES = 5;
17
+ const STALL_MAX_FAILURES = 3;
18
+ const SERVER_TOOL_TIMEOUT_MAX_FAILURES = 2;
19
+ const ARTIFACT_MANIFEST_MAX_CHARS = 12 * 1024;
20
+ /** Harness diagnostics for calls that never crossed the Tool effect boundary. */
21
+ const UNEXECUTED_CODES = new Set(["blocked", "tool_unavailable", "invalid_arguments", "interrupted", "aborted"]);
22
+
23
+ function canonicalJson(value: unknown): string {
24
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
25
+ if (value !== null && typeof value === "object") {
26
+ return `{${Object.entries(value as Record<string, unknown>)
27
+ .sort(([left], [right]) => left.localeCompare(right))
28
+ .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`)
29
+ .join(",")}}`;
30
+ }
31
+ return JSON.stringify(value) ?? "null";
32
+ }
33
+
34
+ export function toolCallKey(toolName: string, args: unknown): string {
35
+ return `${toolName}\0${canonicalJson(args)}`;
36
+ }
37
+
38
+ function resultCodes(entry: EntryRecord): string[] {
39
+ const diagnostics = (entry.data as { diagnostics?: { code?: unknown }[] } | undefined)?.diagnostics;
40
+ return Array.isArray(diagnostics)
41
+ ? diagnostics.flatMap((item) => typeof item?.code === "string" ? [item.code] : [])
42
+ : [];
43
+ }
44
+
45
+ /**
46
+ * Per-Submission Tool failure budget: an exact call that keeps failing is blocked, and too many failures in one
47
+ * Submission switch it to a text-only wrap-up. Counts are rebuilt from canonical entries after a restart.
48
+ */
49
+ export class ToolGovernance {
50
+ private readonly failures = new Map<string, number>();
51
+ private total = 0;
52
+ wrapUp = false;
53
+ /** Completed model turns when the wrap-up began; the wrap-up then has a bounded number of text turns. */
54
+ wrapUpAt: number | undefined;
55
+
56
+ restore(entries: readonly EntryRecord[]): void {
57
+ this.failures.clear();
58
+ this.total = 0;
59
+ this.wrapUp = false;
60
+ this.wrapUpAt = undefined;
61
+ const calls = new Map<string, ToolCall>();
62
+ let turns = 0;
63
+ for (const entry of entries) {
64
+ const message = entry.model?.[0];
65
+ if (message?.role === "assistant") {
66
+ turns += 1;
67
+ for (const part of message.content) if (part.type === "toolCall") calls.set(part.id, part);
68
+ continue;
69
+ }
70
+ if (message?.role !== "toolResult") continue;
71
+ const call = calls.get(message.toolCallId);
72
+ if (!call) continue;
73
+ const codes = resultCodes(entry);
74
+ if (codes.some((code) => UNEXECUTED_CODES.has(code))) {
75
+ if (codes.includes("blocked") && this.exhausted(call)) this.startWrapUp(turns);
76
+ continue;
77
+ }
78
+ this.record(call, message.isError, turns);
79
+ }
80
+ }
81
+
82
+ exhausted(call: Pick<ToolCall, "name" | "arguments">): boolean {
83
+ return (this.failures.get(toolCallKey(call.name, call.arguments)) ?? 0) >= MAX_TOOL_RETRIES;
84
+ }
85
+
86
+ /** Returns a block reason, or undefined when the call may run. */
87
+ admit(call: Pick<ToolCall, "name" | "arguments">, turns: number): string | undefined {
88
+ if (this.wrapUp) return "This turn reached its tool failure limit. Stop calling tools now.";
89
+ if (!this.exhausted(call)) return undefined;
90
+ this.startWrapUp(turns);
91
+ return `This exact ${call.name} call has already failed ${MAX_TOOL_RETRIES} times — change the input or approach instead of retrying it verbatim.`;
92
+ }
93
+
94
+ record(call: Pick<ToolCall, "name" | "arguments">, isError: boolean, turns: number): void {
95
+ const key = toolCallKey(call.name, call.arguments);
96
+ if (!isError) {
97
+ this.failures.delete(key);
98
+ return;
99
+ }
100
+ this.failures.set(key, (this.failures.get(key) ?? 0) + 1);
101
+ this.total += 1;
102
+ if (this.total >= TURN_FAILURE_CEILING) this.startWrapUp(turns);
103
+ }
104
+
105
+ private startWrapUp(turns: number): void {
106
+ this.wrapUp = true;
107
+ this.wrapUpAt ??= turns;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Provider-error recovery the durable Harness cannot express by itself: per-class budgets, refusing deterministic
113
+ * failures, and a fresh provider Session after an OpenRouter server Tool timeout so retries leave a failing route.
114
+ * Pi only retries messages its own classifier accepts, so recoverable failures it does not recognize are labelled.
115
+ */
116
+ export class ModelRecovery {
117
+ private failures = 0;
118
+ private stalls = 0;
119
+ private serverToolTimeouts = 0;
120
+ private allowed = true;
121
+
122
+ constructor(private readonly baseSessionId: string) {}
123
+
124
+ /** Provider Session identity for the next request. */
125
+ sessionId(): string {
126
+ return this.serverToolTimeouts === 0
127
+ ? this.baseSessionId
128
+ : `${this.baseSessionId}:server-tool-recovery:${this.serverToolTimeouts}`;
129
+ }
130
+
131
+ /** Whether the Harness may retry the response last passed to {@link classify}. */
132
+ get retryAllowed(): boolean {
133
+ return this.allowed;
134
+ }
135
+
136
+ /** Classify one terminal provider response before the Harness decides whether to retry it. */
137
+ classify(message: AssistantMessage): void {
138
+ if (message.stopReason !== "error") {
139
+ this.allowed = true;
140
+ return;
141
+ }
142
+ this.failures += 1;
143
+ const stalled = isModelStreamStallMessage(message.errorMessage);
144
+ const serverToolTimeout = isOpenRouterServerToolTimeout(message);
145
+ if (stalled) this.stalls += 1;
146
+ if (serverToolTimeout) this.serverToolTimeouts += 1;
147
+ const recoverable = stalled || serverToolTimeout || isRecoverableAssistantError(message);
148
+ this.allowed = recoverable && this.failures < RECOVERY_MAX_FAILURES &&
149
+ (!stalled || this.stalls < STALL_MAX_FAILURES) &&
150
+ (!serverToolTimeout || this.serverToolTimeouts < SERVER_TOOL_TIMEOUT_MAX_FAILURES);
151
+ if (this.allowed && !isRetryableAssistantError(message)) {
152
+ // Pi's classifier does not know our stall watchdog or OpenRouter's HTTP/2 upstream failure. Only a retried
153
+ // attempt carries the label; a final failure keeps the provider text.
154
+ message.errorMessage = `network error: ${message.errorMessage ?? "recoverable provider failure"}`;
155
+ }
156
+ }
157
+ }
158
+
159
+ export function artifactRefFromMessage(message: Message | Pick<ToolResultMessage, "role" | "details">): ArtifactRef | undefined {
160
+ if (message.role !== "toolResult" || message.details === null ||
161
+ typeof message.details !== "object" ||
162
+ (message.details as { kind?: unknown }).kind !== "artifact_ref") {
163
+ return undefined;
164
+ }
165
+ const ref = message.details as Partial<ArtifactRef>;
166
+ if (typeof ref.path !== "string" || typeof ref.hash !== "string" ||
167
+ typeof ref.bytes !== "number" || typeof ref.preview !== "string" ||
168
+ typeof ref.note !== "string") return undefined;
169
+ return {
170
+ kind: "artifact_ref",
171
+ artifactId: typeof ref.artifactId === "string" ? ref.artifactId : `artifact_${ref.hash.slice(0, 24)}`,
172
+ path: ref.path,
173
+ bytes: ref.bytes,
174
+ hash: ref.hash,
175
+ workspaceRevision: typeof ref.workspaceRevision === "string" ? ref.workspaceRevision : "unknown",
176
+ availability: ref.availability === "unavailable" ? "unavailable" : "available",
177
+ preview: ref.preview,
178
+ note: ref.note,
179
+ };
180
+ }
181
+
182
+ /** Lightweight pointers to spilled Tool output, so the model can reread evidence after compaction. */
183
+ export function artifactManifestMessage(refs: ReadonlyMap<string, ArtifactRef>): Message | undefined {
184
+ if (refs.size === 0) return undefined;
185
+ const lines = [
186
+ "<workspace_artifact_manifest>",
187
+ "These are lightweight references to prior Tool output. Do not treat previews as complete. Read an available artifact with the Workspace readFile/readFilePage tool only when the current request needs its evidence.",
188
+ ];
189
+ let omitted = 0;
190
+ for (const ref of refs.values()) {
191
+ const preview = ref.preview.replace(/[\r\n]+/g, " ").slice(0, 160);
192
+ const line = [
193
+ `path=${ref.path}`,
194
+ ...(ref.availability === "unavailable" ? ["unavailable"] : []),
195
+ `preview=${preview}`,
196
+ ].join(" ");
197
+ if ([...lines, line, "</workspace_artifact_manifest>"].join("\n").length > ARTIFACT_MANIFEST_MAX_CHARS) {
198
+ omitted += 1;
199
+ continue;
200
+ }
201
+ lines.push(line);
202
+ }
203
+ if (omitted > 0) lines.push(`omitted=${omitted} additional artifact references due to manifest budget`);
204
+ lines.push("</workspace_artifact_manifest>");
205
+ return { role: "user", content: lines.join("\n"), timestamp: 1 };
206
+ }
@@ -7,3 +7,4 @@ export type {
7
7
  SessionRunRequest,
8
8
  } from "./session-harness";
9
9
  export { prepareSubmissionOperation } from "./submission-operation";
10
+ export { ModelRecovery } from "./governance";
@@ -13,10 +13,19 @@ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
13
13
  import type { Api, Message, Model, Models, ModelThinkingLevel, UserMessage } from "@earendil-works/pi-ai";
14
14
  import { RuntimeConversation } from "../session";
15
15
  import { projectToolOutputForModel } from "../context/memory/budget/gate";
16
+ import type { ArtifactRef } from "../lib/artifacts";
17
+ import {
18
+ DEFAULT_MAX_WRAP_UP_MODEL_TURNS, TOOL_FAILURE_WRAP_UP_INSTRUCTION, ModelRecovery, ToolGovernance,
19
+ artifactManifestMessage, artifactRefFromMessage,
20
+ } from "./governance";
21
+
22
+ const DEFAULT_WRAP_UP_INSTRUCTION = "Finish this request now without using tools. Summarize the completed work and any remaining limitation.";
16
23
 
17
24
  export interface SessionHarnessLimits {
18
25
  modelTurnsPerSlice?: number;
19
26
  maxModelTurns?: number;
27
+ /** Text turns allowed after the working or Tool failure budget before the Submission is stopped. */
28
+ maxWrapUpModelTurns?: number;
20
29
  wrapUpInstruction?: string;
21
30
  }
22
31
  export interface SessionHarnessOptions {
@@ -35,6 +44,8 @@ export interface SessionHarnessOptions {
35
44
  retry?: Partial<ConversationRetryPolicy>;
36
45
  toolExecution?: "parallel" | "sequential";
37
46
  limits?: SessionHarnessLimits;
47
+ /** Per-Submission provider error classification and provider Session identity. */
48
+ recovery?: ModelRecovery;
38
49
  beforeTool?: ToolHooks["beforeTool"];
39
50
  afterResponse?: GenerationHooks["afterResponse"];
40
51
  }
@@ -63,6 +74,11 @@ export class SessionHarness {
63
74
  private requestId?: string;
64
75
  private release!: (reason: "approval" | "interaction" | "slice") => void;
65
76
  private turnsAtOpen = 0;
77
+ private governance = new ToolGovernance();
78
+ private readonly artifactRefs = new Map<string, ArtifactRef>();
79
+ private wrapUpStarted = false;
80
+ /** Completed model turns of the current Submission. */
81
+ private ownedTurns = 0;
66
82
  private readonly released = new Promise<"approval" | "interaction" | "slice">((resolve) => { this.release = resolve; });
67
83
  private constructor(
68
84
  readonly harness: NativeHarness,
@@ -85,26 +101,59 @@ export class SessionHarness {
85
101
  sections: [section("system", () => options.systemPrompt, { tag: false })],
86
102
  hooks: [
87
103
  hook(GenerationTask, {
88
- beforeRequest: ({ messages }) => ({ messages: messages.map((message): Message => {
89
- if (message.role !== "toolResult" || exemptions.has(message.toolName)) return message;
90
- const next = projectToolOutputForModel({ content: message.content, details: message.details }) as Pick<typeof message, "content" | "details">;
91
- return { ...message, ...next };
92
- }) }),
93
- afterResponse: options.afterResponse,
104
+ beforeRequest: ({ messages }) => {
105
+ const projected = messages.map((message): Message => {
106
+ if (message.role !== "toolResult" || exemptions.has(message.toolName)) return message;
107
+ const next = projectToolOutputForModel({ content: message.content, details: message.details }) as Pick<typeof message, "content" | "details">;
108
+ return { ...message, ...next };
109
+ });
110
+ const manifest = artifactManifestMessage(runtime.artifactRefs);
111
+ const instruction = runtime.governance.wrapUp
112
+ ? TOOL_FAILURE_WRAP_UP_INSTRUCTION
113
+ : runtime.wrapUpStarted ? options.limits?.wrapUpInstruction ?? DEFAULT_WRAP_UP_INSTRUCTION : undefined;
114
+ return { messages: [
115
+ ...projected,
116
+ ...(manifest ? [manifest] : []),
117
+ ...(instruction ? [{ role: "user" as const, content: instruction, timestamp: 1 }] : []),
118
+ ] };
119
+ },
120
+ afterResponse: async (message, api, hookContext) => {
121
+ options.recovery?.classify(message);
122
+ await options.afterResponse?.(message, api, hookContext);
123
+ },
94
124
  afterTools: async (_assistant, _results, _api, hookContext) => {
95
125
  const entries = await runtime.entries(hookContext);
96
- const receipt = await runtime.submission?.status(hookContext);
97
- const owned = receipt?.entry ? entries.filter((entry) => entry.id >= receipt.entry!) : entries;
98
- const turns = owned.filter((entry) => entry.kind === "pi.assistant").length;
126
+ const turns = runtime.ownedTurns = (await runtime.ownedEntries(entries, hookContext)).filter((entry) => entry.kind === "pi.assistant").length;
99
127
  const total = entries.filter((entry) => entry.kind === "pi.assistant").length;
128
+ const maxTurns = options.limits?.maxModelTurns ?? 300;
129
+ const wrapUpAt = Math.min(maxTurns, runtime.governance.wrapUpAt ?? Infinity);
130
+ if (turns >= wrapUpAt + (options.limits?.maxWrapUpModelTurns ?? DEFAULT_MAX_WRAP_UP_MODEL_TURNS)) {
131
+ // The wrap-up is still calling Tools it no longer has; end the Submission instead of looping.
132
+ void runtime.conversation.abort(BACKGROUND_CONTEXT).catch(() => {});
133
+ return;
134
+ }
135
+ if (turns >= wrapUpAt && !runtime.wrapUpStarted) {
136
+ runtime.wrapUpStarted = true;
137
+ // Tools return on the next Submission's configure; the instruction is per request, never stored.
138
+ await runtime.conversation.configure({ tools: [] }, hookContext);
139
+ }
100
140
  if (total - runtime.turnsAtOpen >= (options.limits?.modelTurnsPerSlice ?? 30)) runtime.release("slice");
101
- if (turns >= (options.limits?.maxModelTurns ?? 300)) await runtime.conversation.configure({ tools: [], instructions: options.limits?.wrapUpInstruction ?? "Finish this request now without using tools. Summarize the completed work and any remaining limitation." }, hookContext);
102
141
  },
103
142
  }),
104
143
  hook(ToolTask, {
105
- beforeTool: options.beforeTool,
144
+ beforeTool: async (call, api, hookContext) => {
145
+ // Calls belong to the assistant turn after the last completed Tool round.
146
+ const block = runtime.governance.admit(call, runtime.ownedTurns + 1);
147
+ if (block !== undefined) return { block };
148
+ return options.beforeTool?.(call, api, hookContext);
149
+ },
106
150
  afterTool: async (call, result) => {
107
- const names = options.toolsToActivate?.(call.name, { content: [...result.content ?? []], details: result.details }, result.isError === true) ?? [];
151
+ runtime.governance.record(call, result.isError === true, runtime.ownedTurns + 1);
152
+ const ref = artifactRefFromMessage({ role: "toolResult", details: result.details });
153
+ if (ref) runtime.artifactRefs.set(ref.artifactId, ref);
154
+ const compiled = new Set(options.tools?.map((tool) => tool.name) ?? []);
155
+ const names = (options.toolsToActivate?.(call.name, { content: [...result.content ?? []], details: result.details }, result.isError === true) ?? [])
156
+ .filter((name) => compiled.has(name));
108
157
  return names.length ? { ...result, control: { ...result.control, addTools: names } } : undefined;
109
158
  },
110
159
  }),
@@ -113,7 +162,14 @@ export class SessionHarness {
113
162
  registry.install(extension);
114
163
  const harness = await Harness.open(storage, {
115
164
  models: options.models, registry,
116
- settings: { stream: options.streamOptions, compaction: options.compaction, toolExecution: options.toolExecution, retry: options.retry ?? { maxRetries: 4 } },
165
+ settings: {
166
+ stream: options.streamOptions, compaction: options.compaction, toolExecution: options.toolExecution,
167
+ // Read for every retry decision, after afterResponse has classified that response.
168
+ get retry() {
169
+ const retry = { maxRetries: 4, ...options.retry };
170
+ return options.recovery ? { ...retry, enabled: (retry.enabled ?? true) && options.recovery.retryAllowed } : retry;
171
+ },
172
+ },
117
173
  }, context);
118
174
  const pointer = await harness.snapshot(RuntimeConversation, context);
119
175
  const conversation = pointer?.id ? await harness.conversation(pointer.id, context) : await harness.root(context);
@@ -127,6 +183,7 @@ export class SessionHarness {
127
183
  runtime.submission = await harness.submission(unfinished[0].id, context);
128
184
  runtime.requestId = unfinished[0].requestId;
129
185
  }
186
+ await runtime.restoreGovernance(context);
130
187
  return runtime;
131
188
  }
132
189
 
@@ -142,6 +199,29 @@ export class SessionHarness {
142
199
  } while (cursor);
143
200
  return entries;
144
201
  }
202
+ /** Entries of the active Submission, from its input onward. */
203
+ async ownedEntries(entries: readonly EntryRecord[], context: Context): Promise<EntryRecord[]> {
204
+ const receipt = await this.submission?.status(context);
205
+ return receipt?.entry ? entries.filter((entry) => entry.id >= receipt.entry!) : [...entries];
206
+ }
207
+ private async restoreGovernance(context: Context): Promise<void> {
208
+ const entries = await this.entries(context);
209
+ this.artifactRefs.clear();
210
+ for (const entry of entries) {
211
+ const message = entry.model?.[0];
212
+ const ref = message ? artifactRefFromMessage(message) : undefined;
213
+ if (ref) this.artifactRefs.set(ref.artifactId, ref);
214
+ }
215
+ this.governance = new ToolGovernance();
216
+ this.wrapUpStarted = false;
217
+ this.ownedTurns = 0;
218
+ if (!this.submission) return;
219
+ const owned = await this.ownedEntries(entries, context);
220
+ this.governance.restore(owned);
221
+ this.ownedTurns = owned.filter((entry) => entry.kind === "pi.assistant").length;
222
+ const wrapUpAt = Math.min(this.options.limits?.maxModelTurns ?? 300, this.governance.wrapUpAt ?? Infinity);
223
+ this.wrapUpStarted = this.ownedTurns >= wrapUpAt;
224
+ }
145
225
  watch(context: Context): Promise<AgentEventStream> { return watchEvents(this.harness, this.conversation.id, context); }
146
226
  requestHumanRelease(reason: "approval" | "interaction"): void { this.release(reason); }
147
227
  async fork(at: EntryId | null, context: Context): Promise<void> {
@@ -167,10 +247,13 @@ export class SessionHarness {
167
247
  const names = new Set(this.options.activeToolNames ?? this.options.tools?.map((tool) => tool.name) ?? []);
168
248
  const previous = await this.harness.snapshot(AgentDoc, this.conversation.id, context);
169
249
  if (Array.isArray(previous?.tools)) for (const name of previous.tools) if (this.options.onDemandToolNames?.includes(name)) names.add(name);
170
- await this.conversation.configure({ model: { provider: this.options.model.provider, modelId: this.options.model.id }, thinkingLevel: this.options.thinkingLevel ?? "off", tools: this.options.tools?.filter((tool) => names.has(tool.name)) ?? [] }, context);
250
+ await this.conversation.configure({ model: { provider: this.options.model.provider, modelId: this.options.model.id }, thinkingLevel: this.options.thinkingLevel ?? "off", tools: this.options.tools?.filter((tool) => names.has(tool.name)) ?? [],
251
+ // Earlier runtimes stored the wrap-up instruction in the agent; it applies to one Submission only.
252
+ instructions: null }, context);
171
253
  this.requestId = request.operationId;
172
254
  this.submission = await this.conversation.submit({ type: "input", requestId: request.operationId, content: typeof request.prompt === "string" ? request.prompt : request.prompt.content, whenBusy: "reject" }, context);
173
255
  }
256
+ await this.restoreGovernance(context);
174
257
  }
175
258
  async run(request: SessionRunRequest | undefined, context: Context): Promise<SessionHarnessRunResult> {
176
259
  if (request) await this.admit(request, context);
@@ -537,14 +537,15 @@ export function modelRequestUrl(model: Model<Api>): string {
537
537
  */
538
538
  export function withProviderBoundary(
539
539
  streamFn: ProviderBoundStream,
540
- defaultSessionId?: string,
540
+ /** Host-owned provider Session; it overrides the durable Harness's per-conversation identity. */
541
+ hostSessionId?: string | (() => string),
541
542
  onGeneration?: PiGenerationLifecycleObserver,
542
543
  ): ProviderBoundStream {
543
544
  return (model, context, options) =>
544
545
  lazyStream(model, async () => {
545
546
  const requestId = crypto.randomUUID();
546
547
  const startedAt = Date.now();
547
- const sessionId = options?.sessionId ?? defaultSessionId;
548
+ const sessionId = (typeof hostSessionId === "function" ? hostSessionId() : hostSessionId) ?? options?.sessionId;
548
549
  const url = modelRequestUrl(model);
549
550
  const systemPrompt = "systemPrompt" in context && typeof context.systemPrompt === "string"
550
551
  ? context.systemPrompt
@@ -910,6 +911,11 @@ function configuredModel(
910
911
  ...(endpoint.protocol === "openrouter-messages"
911
912
  ? {
912
913
  ...anthropicMessagesCompat,
914
+ // The direct Anthropic catalog enables positional system and Tool updates, but
915
+ // OpenRouter Messages rejects `tool_addition` definitions. Fold them into the
916
+ // leading system and top-level tools so deferred Tool decoration still applies.
917
+ supportsMidConvoSystemMessages: false,
918
+ supportsMidConvoToolChanges: false,
913
919
  supportsMidConvoEffort: false,
914
920
  supportsEagerToolInputStreaming: false,
915
921
  supportsStrictTools:
@@ -922,6 +928,9 @@ function configuredModel(
922
928
  : endpoint.protocol === "openrouter-chat"
923
929
  ? {
924
930
  ...catalogCompat,
931
+ // OpenRouter Chat has no verified positional system or Tool update support.
932
+ supportsMidConvoSystemMessages: false,
933
+ supportsMidConvoToolChanges: false,
925
934
  thinkingFormat: "openrouter" as const,
926
935
  maxTokensField: "max_tokens" as const,
927
936
  supportsStore: false,
@@ -92,6 +92,89 @@ function normalizeLegacyAskUserEntry(value: unknown): unknown {
92
92
  return value;
93
93
  }
94
94
 
95
+ const TERMINAL_HOST_STATUSES = new Set(["completed", "failed", "aborted", "skipped", "error"]);
96
+ /** Legacy run phases whose state maps onto a native generation checkpoint without guessing. */
97
+ const RESUMABLE_PHASES = new Set(["starting", "checkpoint", "assistant.ready", "assistant.effect_pending", "assistant.retry_wait"]);
98
+ const MESSAGE_ROLES = new Set(["user", "assistant", "toolResult", "custom", "bashExecution", "branchSummary", "compactionSummary"]);
99
+ const UNKNOWN_EFFECT = "Tool execution was interrupted and its external outcome is unknown. Do not repeat the effect without reconciling it.";
100
+ const INTERRUPTED = "This request was interrupted by a runtime upgrade and did not finish. Send it again to continue.";
101
+
102
+ type LegacyResult = { status?: string; error?: { code?: string; message?: string } };
103
+ type ToolBatch = { assistantEntryId: string; turnId: string; calls: Array<{ status: string; sourceIndex: number; resultEntryId: string; replay?: string }> };
104
+
105
+ function warn(message: string): void {
106
+ console.warn("[pi-session-migration]", message);
107
+ }
108
+
109
+ /** Custom messages reach the model as user context, matching `ConversationHistory.append` (e.g. turn-aborted markers). */
110
+ function customModel(message: Message, createdAt: number): { model?: Message[] } {
111
+ const content = (message as unknown as { content?: unknown }).content;
112
+ if (typeof content === "string") return { model: [{ role: "user", content, timestamp: createdAt }] };
113
+ if (!Array.isArray(content) || !content.length) return {};
114
+ return { model: [{ role: "user", content: [...content] as Extract<Message, { role: "user" }>["content"], timestamp: createdAt }] };
115
+ }
116
+
117
+ function unknownOutcome(call: { id: string; name: string }, text = UNKNOWN_EFFECT): Message {
118
+ return { role: "toolResult", toolCallId: call.id, toolName: call.name, content: [{ type: "text", text }], isError: true, timestamp: Date.now() };
119
+ }
120
+
121
+ /**
122
+ * Normalizes historical records onto the native shapes and drops records that can never be valid model context.
123
+ * Dropped and branch-pointer records resolve to their nearest kept ancestor, so descendants stay on their branch.
124
+ */
125
+ function repairLegacyEntries(raw: LegacyEntry[]): { entries: LegacyEntry[]; resolve: (id: string | null) => string | null } {
126
+ const kept = new Map<string, LegacyEntry>();
127
+ const alias = new Map<string, string | null>();
128
+ const resolve = (id: string | null): string | null => {
129
+ for (let seen = 0; id !== null && !kept.has(id) && alias.has(id); seen++) {
130
+ if (seen > alias.size) throw new Error(`Cyclic historical Pi reference: ${id}`);
131
+ id = alias.get(id)!;
132
+ }
133
+ return id;
134
+ };
135
+ const entries: LegacyEntry[] = [];
136
+ for (const source of raw) {
137
+ const parentId = resolve(source.parentId);
138
+ const skip = (reason: string) => {
139
+ warn(`Skipped historical entry ${source.id}: ${reason}`);
140
+ alias.set(source.id, parentId);
141
+ };
142
+ if (source.type === "leaf") {
143
+ alias.set(source.id, resolve(typeof source.targetId === "string" ? source.targetId : source.targetId === null ? null : parentId));
144
+ continue;
145
+ }
146
+ let entry: LegacyEntry = { ...source, parentId };
147
+ if (source.type === "custom_message") {
148
+ if (typeof source.customType !== "string") { skip("custom message has no type"); continue; }
149
+ entry = {
150
+ id: source.id, parentId, type: "message", timestamp: source.timestamp,
151
+ message: { role: "custom", customType: source.customType, content: source.content, details: source.details, display: source.display, timestamp: timestamp(source) } as unknown as Message,
152
+ };
153
+ }
154
+ if (entry.type === "message") {
155
+ const message = entry.message as unknown as Record<string, unknown> | undefined;
156
+ if (!message || typeof message !== "object" || !MESSAGE_ROLES.has(message.role as string)) { skip("unknown message"); continue; }
157
+ if (message.role === "assistant" && message.stopReason === "pending") { skip("unsettled assistant"); continue; }
158
+ if (message.role === "toolResult") {
159
+ let call: { name: string } | undefined;
160
+ let duplicate = false;
161
+ for (let cursor = parentId; cursor && !call && !duplicate; cursor = kept.get(cursor)?.parentId ?? null) {
162
+ const ancestor = kept.get(cursor)?.message;
163
+ if (ancestor?.role === "toolResult" && ancestor.toolCallId === message.toolCallId) duplicate = true;
164
+ if (ancestor?.role === "assistant") {
165
+ call = ancestor.content.find((part) => part.type === "toolCall" && part.id === message.toolCallId) as { name: string } | undefined;
166
+ }
167
+ }
168
+ if (duplicate) { skip(`duplicate Tool result ${String(message.toolCallId)}`); continue; }
169
+ if (!call || call.name !== message.toolName) { skip(`orphan Tool result ${String(message.toolCallId)}`); continue; }
170
+ }
171
+ }
172
+ kept.set(entry.id, entry);
173
+ entries.push(entry);
174
+ }
175
+ return { entries, resolve };
176
+ }
177
+
95
178
  /** Converts records once; historical tables are retained until the upgrade is accepted. */
96
179
  export async function migratePiSession(storage: DurableObjectStorage, session: Session): Promise<void> {
97
180
  if ((await session.snapshot(Migration, BACKGROUND_CONTEXT))?.completed) return;
@@ -105,9 +188,11 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
105
188
  const rows = current || older
106
189
  ? sql.exec<{ body: string }>(`SELECT body FROM ${current ? "pi_entries" : "pi_session_entries"} ORDER BY seq`).toArray()
107
190
  : [];
108
- const entries = rows.map(({ body }) => normalizeLegacyAskUserEntry(JSON.parse(body)) as LegacyEntry);
191
+ const raw = rows.map(({ body }) => normalizeLegacyAskUserEntry(JSON.parse(body)) as LegacyEntry);
192
+ if (new Set(raw.map((entry) => entry.id)).size !== raw.length) throw new Error("Historical Pi entry IDs are not unique");
193
+ const repaired = repairLegacyEntries(raw);
194
+ const entries = repaired.entries;
109
195
  const byId = new Map(entries.map((entry) => [entry.id, entry]));
110
- if (byId.size !== entries.length) throw new Error("Historical Pi entry IDs are not unique");
111
196
  const values = current && hasTable(sql, "pi_values")
112
197
  ? sql.exec<{ namespace: string; key: string; body: string }>("SELECT namespace,key,body FROM pi_values").toArray()
113
198
  : [];
@@ -116,18 +201,25 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
116
201
  return row ? JSON.parse(row.body) as T : undefined;
117
202
  };
118
203
  const tipValue = values.find((row) => row.namespace === "pi.branch.tip" && row.key === "main");
119
- const last = entries.at(-1);
120
- let oldTip: string | null = tipValue ? JSON.parse(tipValue.body) : last?.type === "leaf" ? last.targetId as string | null : last?.id ?? null;
204
+ let oldTip: string | null = repaired.resolve(tipValue ? JSON.parse(tipValue.body) as string | null : raw.at(-1)?.id ?? null);
205
+ const hostRows = hasTable(sql, "pi_submissions")
206
+ ? sql.exec<{ submission_id: string; status: string }>("SELECT submission_id,status FROM pi_submissions").toArray()
207
+ : [];
208
+ const hostStatus = new Map(hostRows.map((row) => [row.submission_id, row.status]));
121
209
  const lane = read<{ currentOperationId?: string | null }>("pi.lane.state", "main");
122
210
  const operationId = lane?.currentOperationId;
123
- const result = operationId ? read<{ status?: string }>("pi.result", operationId) : undefined;
124
- const legacyRunning = older && hasTable(sql, "pi_submissions") ? sql.exec<{ submission_id: string }>("SELECT submission_id FROM pi_submissions WHERE status='running'").toArray() : [];
211
+ const legacyRunning = older ? hostRows.filter((row) => row.status === "running") : [];
125
212
  if (legacyRunning.length > 1) throw new Error("Multiple legacy operations are active in one conversation");
126
- const hostStatus = operationId && hasTable(sql, "pi_submissions")
127
- ? sql.exec<{ status: string }>("SELECT status FROM pi_submissions WHERE submission_id=?", operationId).toArray()[0]?.status
128
- : undefined;
129
- const terminal = hostStatus && ["completed", "failed", "aborted", "skipped"].includes(hostStatus);
130
- const active = operationId && result === undefined && !terminal ? operationId : legacyRunning[0]?.submission_id;
213
+ let active: string | undefined = operationId && read<LegacyResult>("pi.result", operationId) === undefined && !TERMINAL_HOST_STATUSES.has(hostStatus.get(operationId) ?? "")
214
+ ? operationId
215
+ : legacyRunning[0]?.submission_id;
216
+ // Navigation and standalone compaction never committed their target. The Host re-admits the owning
217
+ // Submission from its queued input, so the native runtime performs the whole step itself.
218
+ const intent = active ? read<{ intent?: { kind?: string } }>("pi.op.meta", active)?.intent?.kind ?? (active.endsWith(":regenerate") ? "navigation" : "run") : undefined;
219
+ if (active && intent !== "run") {
220
+ warn(`Dropped unfinished legacy ${intent} operation ${active}`);
221
+ active = undefined;
222
+ }
131
223
  const links = hasTable(sql, "pi_session_entry_submission")
132
224
  ? sql.exec<{ entry_id: string; submission_id: string }>("SELECT entry_id,submission_id FROM pi_session_entry_submission").toArray()
133
225
  : hasTable(sql, "pi_session_message_submission") ? sql.exec<{ entry_id: string; submission_id: string }>("SELECT message_id AS entry_id,submission_id FROM pi_session_message_submission").toArray() : [];
@@ -156,6 +248,10 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
156
248
  }
157
249
  }
158
250
  }
251
+ const commitResult = (id: string, outcome: Message) => {
252
+ const entry: LegacyEntry = { id, parentId: oldTip, type: "message", message: outcome, timestamp: outcome.timestamp };
253
+ entries.push(entry); byId.set(entry.id, entry); owners.set(entry.id, active!); oldTip = entry.id;
254
+ };
159
255
  if (older && active && hasTable(sql, "pi_recovery_milestones")) {
160
256
  const milestones = sql.exec<{ body: string }>("SELECT body FROM pi_recovery_milestones WHERE submission_id=? ORDER BY seq", active).toArray();
161
257
  const recovered = replayPiToolRecovery(milestones.map((row) => row.body));
@@ -163,36 +259,68 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
163
259
  for (const call of Object.values(recovered.toolCalls)) {
164
260
  if (existing.has(call.toolCallId)) continue;
165
261
  const pending = Object.values(recovered.approvals).some((row) => row.toolCallId === call.toolCallId && row.status === "pending") || Object.values(recovered.interactions).some((row) => row.toolCallId === call.toolCallId && row.status === "pending");
166
- const outcome = call.result ?? (!pending && call.retry === "non-idempotent" ? { role: "toolResult" as const, toolCallId: call.toolCallId, toolName: call.toolName, content: [{ type: "text" as const, text: "Interrupted legacy effect; its outcome is unknown. Reconcile it before repeating it." }], isError: true, timestamp: Date.now() } : undefined);
167
- if (!outcome) continue;
168
- const entry: LegacyEntry = { id: `legacy-result:${active}:${call.toolCallId}`, parentId: oldTip, type: "message", message: outcome, timestamp: outcome.timestamp };
169
- entries.push(entry); byId.set(entry.id, entry); owners.set(entry.id, active); oldTip = entry.id;
262
+ const outcome = call.result ?? (!pending && call.retry === "non-idempotent" ? unknownOutcome({ id: call.toolCallId, name: call.toolName }, "Interrupted legacy effect; its outcome is unknown. Reconcile it before repeating it.") : undefined);
263
+ if (outcome) commitResult(`legacy-result:${active}:${call.toolCallId}`, outcome);
170
264
  }
171
265
  }
172
- const operation = active ? read<{ at: string; batch?: { assistantEntryId: string; turnId: string; calls: Array<{ status: string; sourceIndex: number; resultEntryId: string; replay?: string }> } }>("pi.op.state", active) : undefined;
266
+ // An operation that cannot be resumed is settled as interrupted. It never replays an uncertain effect.
267
+ let interrupted: string | undefined;
268
+ const operation = active ? read<{ at: string; batch?: ToolBatch }>("pi.op.state", active) : undefined;
173
269
  if (active && operation?.at === "tools") {
174
- const assistant = byId.get(operation.batch!.assistantEntryId)?.message;
175
- if (assistant?.role !== "assistant") throw new Error("Historical Tool batch has no canonical assistant");
176
- for (const call of operation.batch!.calls) {
177
- const tool = assistant.content[call.sourceIndex];
178
- if (tool?.type !== "toolCall") throw new Error("Historical Tool batch index is invalid");
270
+ const message = operation.batch ? byId.get(operation.batch.assistantEntryId)?.message : undefined;
271
+ const assistant = message?.role === "assistant" ? message : undefined;
272
+ if (!assistant) interrupted = "Historical Tool batch has no canonical assistant";
273
+ for (const call of assistant ? operation.batch!.calls : []) {
274
+ const part = assistant!.content[call.sourceIndex];
275
+ if (typeof part !== "object" || part?.type !== "toolCall") { interrupted = "Historical Tool batch index is invalid"; continue; }
276
+ const tool = part;
179
277
  if (call.status === "completed") continue;
180
- const staged = read<{ type?: string; payload?: Message }>("pi.pending.entry", call.resultEntryId);
181
- let outcome = staged?.payload;
182
- if (call.status === "outcome_ready" && outcome?.role !== "toolResult") throw new Error("Historical Tool outcome is missing");
183
- if (call.status === "effect_pending" && call.replay !== "safe") {
184
- outcome = { role: "toolResult", toolCallId: tool.id, toolName: tool.name, content: [{ type: "text", text: "Tool execution was interrupted and its external outcome is unknown. Do not repeat the effect without reconciling it." }], isError: true, timestamp: Date.now() };
278
+ let outcome = read<{ type?: string; payload?: Message }>("pi.pending.entry", call.resultEntryId)?.payload;
279
+ if (outcome && (outcome.role !== "toolResult" || outcome.toolCallId !== tool.id || outcome.toolName !== tool.name)) {
280
+ warn(`Historical Tool outcome ${call.resultEntryId} conflicts with its call`);
281
+ outcome = unknownOutcome(tool);
185
282
  }
186
- if (outcome) {
187
- if (outcome.role !== "toolResult" || outcome.toolCallId !== tool.id || outcome.toolName !== tool.name) throw new Error("Historical Tool outcome conflicts with its call");
188
- const entry: LegacyEntry = { id: call.resultEntryId, parentId: oldTip, type: "message", message: outcome, timestamp: outcome.timestamp };
189
- entries.push(entry); byId.set(entry.id, entry); owners.set(entry.id, active); oldTip = entry.id;
283
+ if (call.status === "outcome_ready" && outcome?.role !== "toolResult") {
284
+ warn(`Historical Tool outcome ${call.resultEntryId} is missing`);
285
+ outcome = unknownOutcome(tool);
190
286
  }
287
+ if (call.status === "effect_pending" && call.replay !== "safe") outcome = unknownOutcome(tool);
288
+ if (outcome) commitResult(call.resultEntryId, outcome);
289
+ }
290
+ } else if (active && operation && !RESUMABLE_PHASES.has(operation.at)) {
291
+ interrupted = `Historical operation ${operation.at} cannot resume natively`;
292
+ }
293
+ const branch = (tip: string | null): LegacyEntry[] => {
294
+ const chain: LegacyEntry[] = [];
295
+ for (let id = tip; id; id = byId.get(id)?.parentId ?? null) {
296
+ const entry = byId.get(id);
297
+ if (!entry) throw new Error("Historical branch is invalid");
298
+ chain.unshift(entry);
191
299
  }
192
- } else if (active && operation && !["starting", "checkpoint", "assistant.ready", "assistant.effect_pending", "assistant.retry_wait"].includes(operation.at)) {
193
- throw new Error(`Historical operation ${operation.at} needs an explicit migration before it can run`);
300
+ return chain;
301
+ };
302
+ let owned = active ? branch(oldTip).filter((entry) => owners.get(entry.id) === active) : [];
303
+ let prompt = owned.find((entry) => entry.message?.role === "user");
304
+ if (active && !prompt) interrupted ??= "Active historical operation has no canonical user input";
305
+ if (active && interrupted) {
306
+ warn(`Settled legacy operation ${active} as interrupted: ${interrupted}`);
307
+ const assistant = [...owned].reverse().find((entry) => entry.message?.role === "assistant")?.message;
308
+ const settled = new Set(owned.flatMap((entry) => entry.message?.role === "toolResult" ? [entry.message.toolCallId] : []));
309
+ for (const part of assistant?.role === "assistant" ? assistant.content : []) {
310
+ if (part.type === "toolCall" && !settled.has(part.id)) commitResult(`legacy-interrupted:${active}:${part.id}`, unknownOutcome(part));
311
+ }
312
+ owned = branch(oldTip).filter((entry) => owners.get(entry.id) === active);
313
+ prompt = owned.find((entry) => entry.message?.role === "user");
194
314
  }
315
+ // Pi finished these operations before the Host recorded their terminal; settle them instead of rerunning input.
316
+ const finished = hostRows.flatMap((row) => {
317
+ if (row.submission_id === active || !["pending", "running"].includes(row.status)) return [];
318
+ const result = read<LegacyResult>("pi.result", row.submission_id);
319
+ return result ? [{ id: row.submission_id, result }] : [];
320
+ });
195
321
  const converted = new Map<string, EntryRecord>();
322
+ // Where a child continues; a compaction's retained context belongs to every branch through it.
323
+ const anchors = new Map<string, EntryRecord>();
196
324
  const conversationByEntry = new Map<string, ConversationId>();
197
325
  const tips = new Map<ConversationId, string>();
198
326
  const copiedUi: Array<{ id: string; body: string }> = [];
@@ -206,7 +334,7 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
206
334
  if (!entry.id || (entry.parentId !== null && !byId.has(entry.parentId))) {
207
335
  throw new Error(`Historical Pi entry has a missing parent: ${entry.id}`);
208
336
  }
209
- const parent = entry.parentId ? converted.get(entry.parentId) : undefined;
337
+ const parent = entry.parentId ? anchors.get(entry.parentId) : undefined;
210
338
  if (entry.parentId && !parent) throw new Error(`Historical Pi entry is out of order: ${entry.id}`);
211
339
  let conversationId = parent ? conversationByEntry.get(entry.parentId!)! : initial.id;
212
340
  if (parent && tips.get(conversationId) !== entry.parentId) {
@@ -214,15 +342,16 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
214
342
  }
215
343
  const message = entry.message;
216
344
  let record: EntryRecord;
345
+ let anchor: EntryRecord;
217
346
  if (entry.type === "message" && message) {
218
347
  const role = (message as { role: string }).role;
219
- record = await tx.appendEntry(conversationId, {
348
+ record = anchor = await tx.appendEntry(conversationId, {
220
349
  kind: role === "toolResult" ? "pi.tool-result" : role === "custom" ? "springbrand.custom" : `pi.${role}`,
221
- ...(role === "custom" ? {} : { model: [message] }),
222
- data: { displayId: entry.id, createdAt: timestamp(entry), ...(role === "custom" ? { message: message as unknown as Record<string, never> } : {}), ...(role === "toolResult" ? { diagnostics: [] } : {}) },
350
+ ...(role === "custom" ? customModel(message, timestamp(entry)) : { model: [message] }),
351
+ data: JSON.parse(JSON.stringify({ displayId: entry.id, createdAt: timestamp(entry), ...(role === "custom" ? { message } : {}), ...(role === "toolResult" ? { diagnostics: [] } : {}) })),
223
352
  });
224
353
  } else if (entry.type === "compaction" || entry.type === "branch_summary") {
225
- record = await tx.appendEntry(conversationId, {
354
+ record = anchor = await tx.appendEntry(conversationId, {
226
355
  kind: "pi.compaction", model: [{ role: "user", content: `<summary>\n${entry.summary ?? ""}\n</summary>`, timestamp: timestamp(entry) }],
227
356
  head: "self", data: { reason: "manual", displayId: entry.id, createdAt: timestamp(entry) },
228
357
  });
@@ -241,12 +370,13 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
241
370
  retainedTail = tail;
242
371
  }
243
372
  for (const retained of retainedTail ?? []) {
244
- await tx.appendEntry(conversationId, { kind: "springbrand.retained-context", model: [retained] });
373
+ anchor = await tx.appendEntry(conversationId, { kind: "springbrand.retained-context", model: [retained] });
245
374
  }
246
375
  } else {
247
- record = await tx.appendEntry(conversationId, { kind: "springbrand.history", data: { displayId: entry.id, createdAt: timestamp(entry), record: entry as unknown as Record<string, never> } });
376
+ record = anchor = await tx.appendEntry(conversationId, { kind: "springbrand.history", data: { displayId: entry.id, createdAt: timestamp(entry), record: entry as unknown as Record<string, never> } });
248
377
  }
249
378
  converted.set(entry.id, record);
379
+ anchors.set(entry.id, anchor);
250
380
  conversationByEntry.set(entry.id, conversationId);
251
381
  tips.set(conversationId, entry.id);
252
382
  if (entry.id === oldTip) selected = conversationId;
@@ -257,7 +387,7 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
257
387
  if (oldTip === null && entries.length) {
258
388
  selected = (await session.commit((tx) => tx.createConversation({ ownership: { kind: "ownerless" } }), BACKGROUND_CONTEXT)).id;
259
389
  } else if (oldTip && tips.get(selected) !== oldTip) {
260
- const tip = converted.get(oldTip);
390
+ const tip = anchors.get(oldTip);
261
391
  if (!tip) throw new Error("Historical branch tip is missing");
262
392
  selected = (await session.commit((tx) => tx.forkConversation(tip.conversationId, tip.id, { ownership: { kind: "ownerless" } }), BACKGROUND_CONTEXT)).id;
263
393
  }
@@ -271,16 +401,29 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
271
401
  if (typeof config.thinkingLevel === "string") agent.thinkingLevel = config.thinkingLevel as typeof agent.thinkingLevel;
272
402
  if (Array.isArray(config.activeToolNames)) agent.tools = config.activeToolNames as string[];
273
403
  }
274
- if (active) {
275
- const chain: LegacyEntry[] = [];
276
- for (let id = oldTip; id; id = byId.get(id)?.parentId ?? null) {
277
- const entry = byId.get(id);
278
- if (!entry) throw new Error("Active historical operation has an invalid branch");
279
- chain.unshift(entry);
404
+ const selectedBranch = branch(oldTip);
405
+ for (const { id, result } of finished) {
406
+ const ownedBySubmission = selectedBranch.filter((entry) => owners.get(entry.id) === id);
407
+ const input = ownedBySubmission.find((entry) => entry.message?.role === "user");
408
+ const reply = [...ownedBySubmission].reverse().find((entry) => entry.message?.role === "assistant");
409
+ const entry = input ? converted.get(input.id)!.id : undefined;
410
+ if ((result.status === "completed" || result.status === "declined") && entry !== undefined && reply) {
411
+ await tx.createSubmission({ conversationId: selected, requestId: id, type: "input", status: "done", entry, answer: converted.get(reply.id)!.id });
412
+ } else {
413
+ await tx.createSubmission({
414
+ conversationId: selected, requestId: id, type: "input", status: "unanswered",
415
+ reason: result.status === "aborted" ? "aborted" : result.status ?? "failed",
416
+ detail: result.error?.message ?? (result.status === "aborted" ? "Cancelled" : "The legacy request ended without an answer."),
417
+ ...(entry !== undefined ? { entry } : {}),
418
+ });
280
419
  }
281
- const owned = chain.filter((entry) => owners.get(entry.id) === active);
282
- const prompt = owned.find((entry) => entry.message?.role === "user");
283
- if (!prompt) throw new Error("Active historical operation has no canonical user input");
420
+ }
421
+ if (active && interrupted) {
422
+ await tx.createSubmission({
423
+ conversationId: selected, requestId: active, type: "input", status: "unanswered", reason: "interrupted", detail: INTERRUPTED,
424
+ ...(prompt ? { entry: converted.get(prompt.id)!.id } : {}),
425
+ });
426
+ } else if (active && prompt) {
284
427
  const promptEntry = converted.get(prompt.id)!;
285
428
  const submission = await tx.createSubmission({ conversationId: selected, requestId: active, type: "input", status: "placed", entry: promptEntry.id });
286
429
  const assistant = [...owned].reverse().find((entry) => entry.message?.role === "assistant");
@@ -302,13 +445,46 @@ export async function migratePiSession(storage: DurableObjectStorage, session: S
302
445
  sql.exec("CREATE TABLE IF NOT EXISTS pi_session_entry_submission (entry_id TEXT PRIMARY KEY, submission_id TEXT NOT NULL)");
303
446
  for (const link of copiedLinks) sql.exec("INSERT OR IGNORE INTO pi_session_entry_submission VALUES (?,?)", link.id, link.submissionId);
304
447
  for (const sidecar of copiedUi) sql.exec("INSERT OR IGNORE INTO pi_message_ui VALUES (?,?)", sidecar.id, sidecar.body);
448
+ if (hasTable(sql, "pi_pending_steers")) {
449
+ // Old Pi committed these Steers but the Host receipt survived; a native requeue would duplicate them.
450
+ const steers = sql.exec<{ message_id: string; harness_entry_id: string | null; ui_message_json: string | null }>("SELECT message_id,harness_entry_id,ui_message_json FROM pi_pending_steers").toArray();
451
+ for (const steer of steers) {
452
+ const record = converted.get(steer.harness_entry_id ?? "") ?? converted.get(steer.message_id);
453
+ if (!record) continue;
454
+ if (steer.ui_message_json) {
455
+ sql.exec("CREATE TABLE IF NOT EXISTS pi_message_ui (message_id TEXT PRIMARY KEY, body TEXT NOT NULL)");
456
+ sql.exec("INSERT OR IGNORE INTO pi_message_ui VALUES (?,?)", String(record.id), steer.ui_message_json);
457
+ }
458
+ sql.exec("DELETE FROM pi_pending_steers WHERE message_id=?", steer.message_id);
459
+ }
460
+ }
305
461
  for (const table of ["pi_approvals", "pi_tool_interactions"]) {
306
462
  if (!hasTable(sql, table)) continue;
307
463
  const rows = sql.exec<{ rowid: number; input_json: string }>(`SELECT rowid,input_json FROM ${table} WHERE tool_name='ask_user'`).toArray();
308
464
  for (const row of rows) sql.exec(`UPDATE ${table} SET input_json=? WHERE rowid=?`, JSON.stringify(normalizeLegacyAskUserInput(JSON.parse(row.input_json))), row.rowid);
465
+ // An interrupted operation can no longer continue its parked call, so its decision must not park Host recovery.
466
+ if (active && interrupted && hasColumns(sql, table, ["submission_id", "status"])) {
467
+ sql.exec(`UPDATE ${table} SET status=? WHERE submission_id=? AND status='pending'`, table === "pi_approvals" ? "rejected" : "cancelled", active);
468
+ }
309
469
  }
310
470
  if (hasTable(sql, "cf_agents_schedules")) {
311
471
  for (const callback of ["_cleanupStreamBuffers", "_piPlannedContinuation", "_piContinuationWatchdog", "_piApprovalContinuation", "_chatRecoveryRetry"]) sql.exec("DELETE FROM cf_agents_schedules WHERE callback=?", callback);
312
472
  }
313
473
  });
314
474
  }
475
+
476
+ function hasColumns(sql: SqlStorage, table: string, names: string[]): boolean {
477
+ const columns = new Set(sql.exec<{ name: string }>(`PRAGMA table_info(${table})`).toArray().map((row) => row.name));
478
+ return names.every((name) => columns.has(name));
479
+ }
480
+
481
+ const LEGACY_TABLES = [
482
+ "pi_entries", "pi_values", "pi_lists", "pi_usage", "pi_storage_state", "pi_session_entries", "pi_session_metadata",
483
+ "pi_legacy_entries_backup", "pi_recovery_milestones", "pi_session_message_submission", "pi_tool_settlements",
484
+ ];
485
+
486
+ /** Drops the inert pre-upgrade baseline once the migrated history it backs up is deleted. */
487
+ export async function dropLegacyPiTables(storage: DurableObjectStorage, session: Session): Promise<void> {
488
+ if (!(await session.snapshot(Migration, BACKGROUND_CONTEXT))?.completed) throw new Error("Legacy Pi tables are still the migration source");
489
+ for (const table of LEGACY_TABLES) storage.sql.exec(`DROP TABLE IF EXISTS ${table}`);
490
+ }
@@ -13,12 +13,14 @@ 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;
19
21
  messageUi: MessageUiRepository;
20
22
  transaction<T>(run: () => T): T;
21
- conversation: Pick<ConversationHistory, "append" | "storedMessages">;
23
+ conversation: Pick<ConversationHistory, "append" | "appendTurnAbortedMarker" | "storedMessages">;
22
24
  decisions: Pick<HarnessHumanDecisions, "cancelForSubmission">;
23
25
  events: Pick<RuntimeTurnEvents, "submissionTerminal">;
24
26
  telemetry: Pick<RuntimeTelemetryCoordinator, "capture">;
@@ -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,6 +88,10 @@ 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);
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}`);
94
+ }
82
95
  this.options.events.submissionTerminal(completed);
83
96
  this.options.telemetry.capture("turnFinished", {
84
97
  submissionId,
@@ -90,6 +103,20 @@ export class SubmissionFinalizer {
90
103
  });
91
104
  return completed;
92
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);
93
120
  }
94
121
 
95
122
  private read(submissionId: string): StoredSubmission {
@@ -168,8 +168,15 @@ export function compileHarnessTools(
168
168
  ...(candidate.deferLoading ? { deferLoading: true as const } : {}),
169
169
  async execute(args, api, context) {
170
170
  const toolCallId = api.callId;
171
+ // AgentTool progress is a replacing snapshot (web_search sends its accumulated text), but Pi output only
172
+ // appends and has no replace operation: append a grown snapshot's suffix, and start a new line otherwise.
173
+ let shown = "";
171
174
  const onUpdate = (update: AgentToolResult<unknown>) => {
172
- for (const part of update.content) if (part.type === "text") api.output(part.text);
175
+ const text = update.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("");
176
+ if (text && text !== shown) {
177
+ api.output(text.startsWith(shown) ? text.slice(shown.length) : shown ? `\n${text}` : text);
178
+ shown = text;
179
+ }
173
180
  if (update.details !== undefined) void api.details(update.details, context).catch(() => {});
174
181
  };
175
182
  const signal = context.abortSignal ?? new AbortController().signal;