Pi từ mã nguồnpi v1.4.0

Chương 17Phần 5: Chord và Pi Durable

Pi Durable: mô hình khái niệm

Conversation, entry, document, commit, submission, và lần theo một input được trả lời.

  • 32 phút đọc
  • 49 đoạn code
  • 17 mục
Trong chương này17 mục
  1. Vai trò: vì sao có Pi Durable
  2. Mô hình tư duy
  3. Conversation, entry và các kind dựng sẵn
  4. Document: trạng thái JSON có kiểu
  5. Commit: bề mặt giao dịch
  6. Submission và inbox
  7. Extension và Registry
  8. Agent của từng conversation
  9. System prompt: section và entry hệ thống theo vị trí
  10. Harness.open, HarnessOptions và Settings
  11. Quan sát: viewState, watch, watchEvents
  12. Lần theo một input được trả lời
  13. API công khai và các ví dụ
  14. Quyết định thiết kế
  15. Cạm bẫy và trường hợp biên
  16. Đọc code ở đâu
  17. Tóm tắt

Chương này dựng mô hình khái niệm của @earendil-works/pi-durable (gọi tắt Pi Durable): harness bền (durable harness) mà Earendil phát hành cùng Pi 1.0. Ta đi từ bài toán, qua các bản ghi và kiểu dữ liệu thật, tới một lần theo dấu (trace) đầy đủ từ lúc gửi một câu hỏi đến lúc nhận câu trả lời. Phần bên dưới mặt nước (Session, transaction, các backend lưu trữ) nằm ở chương Durable: session, commit, document và storage; task, scheduler, generation và tool nằm ở chương Durable: task, scheduler, generation và tool.

Vai trò: vì sao có Pi Durable#

Coding agent của Pi (CLI pi, xem chương AgentSession) chạy trong một terminal, cho một người. Nếu tiến trình chết giữa một lượt, người dùng xem lại chuyện đã xảy ra rồi bảo nó làm tiếp. Bài "Pi Durable" của Earendil (2026-10-01) đặt ra một bài toán khác: một harness chạy được ở bất cứ đâu có runtime JavaScript, được truy cập từ nhiều bề mặt (giao diện terminal, web, bot chat), chứa rất nhiều hội thoại dài, sống sót qua lỗi bên trong lẫn bên ngoài, và cho nhiều người cùng điều khiển một agent.

Bài viết đưa ra vài tình huống cụ thể. Một agent bị cắt ngang giữa lời gọi tool; một tiến trình mới mở lại cùng storage và chạy tiếp. Request tới mô hình bị đứt được gửi lại, phần trả lời dở được giữ và đánh dấu là bị huỷ (aborted). Một tool không an toàn khi chạy lại, như deploy, được báo cho mô hình là bị gián đoạn thay vì chạy lần thứ hai. Một kênh Slack là một hội thoại, một thread mở từ một câu trả lời trong kênh là một fork (tách nhánh), và cả hai chạy song song.

README của package tóm lời hứa đó trong hai câu: hội thoại, lượt mô hình, lời gọi tool và state riêng của ứng dụng đều được commit vào storage trước khi hiển thị bất cứ thứ gì; nếu tiến trình chết giữa lượt, mở lại storage là công việc tiếp tục từ chỗ dừng (README.md:5). Nó dựng trên pi-ai để gọi mô hình và trên Chord để giữ state của document (README.md:7).

Quan hệ với phần còn lại của Pi cần nói rõ, vì tên gọi dễ gây nhầm:

  • Không dùng AgentSession, không dùng pi-agent-core. Phụ thuộc trực tiếp của package là Chord, pi-ai, pi-codemode và pi-telemetry (package.json:152–156). Vòng lặp agent ở đây là các task bền: task pi.generation tự gọi models.streamSimple() của pi-ai (generation.ts:484; spec.md:3953–3960). Coding agent cũng không phụ thuộc Pi Durable; chương Bản đồ monorepo vẽ hai chồng package này cạnh nhau.
  • Không thay thế coding agent. Bài công bố viết "It does not replace the Pi coding agent": Pi Durable là framework để xây ứng dụng có agent, coding agent chỉ là một loại, và bài học rút ra sẽ chảy ngược về coding agent khi chứng minh được giá trị.
  • Ai gọi nó. Một ứng dụng host do bạn viết: tiến trình Node, server, Durable Object của Cloudflare, hay trang web. Host mở Harness.open(storage, options, context), cài extension vào một registry, rồi gửi input và quan sát state đã commit.

Mô hình tư duy#

Toàn bộ thiết kế xoay quanh một quy tắc, ghi ngay đầu đặc tả:

Nghĩa là: mọi thay đổi (một entry mới, một task đổi trạng thái, một document được sửa) đi qua cùng một dòng commit nguyên tử; giao diện, sự kiện và mọi thứ bên ngoài chỉ thấy những gì đã nằm trong storage. Không có kênh hiển thị nào "nhanh hơn storage". Danh sách bất biến ngay sau đó cấm hiệu ứng bên ngoài (gọi mô hình, chạy tiến trình) chạy bên trong transaction, và coi lỗi storage là lỗi chết của Session (spec.md:63–78).

 Ứng dụng host  (tiến trình Node, server, Durable Object, trình duyệt)
   │  submit · configure · reset · compact · abort · viewState · watch · watchEvents
   ▼
 Harness ──────────── Registry: extension (tool, section, hook, wrap, task)
   │                      chỉ sống trong bộ nhớ tiến trình, không được lưu
   ├─ Submissions        nhận input/write, inbox, wait()
   ├─ TaskScheduler      chạy task: pi.generation, pi.tool, pi.compaction, task riêng
   ├─ ConversationViews  mount view cho UI, dẫn xuất từ các commit
   ▼
 Session  một dòng commit nguyên tử; cache document dưới dạng tracker của Chord
   ▼
 Storage  Memory · SQLite · JSONL · IndexedDB · SQLite của Durable Object

 Trong storage:  conversation ── entry (bất biến)        task (bản ghi thay thế trọn)
                       └──── document (pi.agent, pi.live, pi.inbox, pi.usage, pi.provider, của bạn)
                 submission (input hoặc write đang chờ, đã đặt, đã xong)
Harness là một Session có thêm scheduler, hàng đợi submission và view. Code (extension) nằm trong registry của tiến trình; dữ liệu (hội thoại, entry, task, document) nằm trong storage.

Trong code, quan hệ "Harness là một Session" là quan hệ kế thừa thật: HarnessImpl mở rộng SessionImpl, và mọi thành phần bên trong đọc ghi qua this.storage, tức storage đã được bọc lớp chặn lỗi của Session.

HarnessImpl: một SessionImpl có thêm scheduler, submission và view
pi/packages/durable/src/harness/harness.tsdòng 206–223TypeScript
/** Session kernel extended with conversation handles and a registry. */class HarnessImpl<Tool extends ToolRegistration> extends SessionImpl implements HarnessType {	readonly #options: HarnessOptions<Tool>;	readonly #hostSettings: () => HarnessSettings | undefined;	readonly #report: (error: unknown, info: ReportInfo, context?: Context) => void;	readonly #host: ConversationHost<Tool>;	readonly #tasks: TaskScheduler;	readonly #submissions: Submissions;	readonly #taskGraph: TaskGraphView;	constructor(rawStorage: Storage, options: HarnessOptions<Tool>, context: Context) {		const report = safeReport(options.onReport, options.telemetry ?? NOOP_TELEMETRY_CONTEXT);		const now = safeNow(options.now, report);		const hostSettings = currentSettings(options.settings);		// Read at each use, as all settings are, but only this field: it is read on every commit.		super(rawStorage, now, () => hostSettings()?.documentRetentionMs, options.setTimer);		// Every component reads and writes through the Session's failure guard: `rawStorage` serves only `super()`.		const storage = this.storage;
Hàm khởi tạo tiếp tục tạo TaskScheduler, Submissions, TaskGraphView và ConversationViews trên cùng storage đó (harness.ts:228–263).

Từ vựng dùng trong phần còn lại của chương (định nghĩa gốc ở README.md:92–104 và spec.md:46–61):

Thuật ngữNghĩaKiểu trong code
HarnessMột storage đang mở cộng bộ máy chạy agent trên đóHarness, harness/types.ts:783
SessionChủ của dòng commit, các bản ghi và documentSession, types.ts:1070
Hội thoại (conversation)Một phạm vi transcript; có thể fork từ hội thoại khác. Handle không giữ state, so sánh bằng idConversationRecord, Conversation
EntryMột bản ghi bất biến của transcriptEntryRecord
DocumentState JSON có kiểu, đặt cạnh transcript, chỉ đổi qua commitdefineDoc(), DocToken
CommitMột lần ghi nguyên tử: entry, document, task cùng lưu hoặc cùng khôngTx, commit()
TaskMáy trạng thái bền, lưu checkpoint ở mỗi bước; luôn có chủ (hội thoại hoặc task khác)TaskRecord, defineTask()
SubmissionThứ host gửi vào hội thoại: input của người dùng hoặc một entry cần ghi; có thể chờSubmissionRecord, Submission
Lượt (turn) / lần chạy (run)Một câu trả lời của mô hình và các lời gọi tool của nó / chuỗi lượt từ một input tới câu trả lời cuối. Hội thoại bận (busy) khi có runpi.live.run
Extension / RegistryGói code có tên (tool, section, hook, wrap, task) / tập extension tiến trình này đã càiExtension, Registry
AgentThứ một hội thoại chạy cùng: mô hình, mức suy nghĩ (thinking level), extension, tool, instructions, thư mục làm việc. Lưu theo tên, giải lại mỗi lần dùngAgentState, Agent

Conversation, entry và các kind dựng sẵn#

ID: một không gian số duy nhất#

Mọi bản ghi (hội thoại, entry, task, submission, document) lấy ID từ cùng một dãy số nguyên do storage cấp. Kiểu Id chỉ là number có gắn "nhãn" (brand) lúc biên dịch để TypeScript không cho truyền nhầm TaskId vào chỗ cần EntryId; trong JSON, JSONL và SQLite chúng là số thường (spec.md:242–246).

Id có nhãn, Seq của commit, và ID dành riêng cho hội thoại gốc
pi/packages/durable/src/types.tsdòng 18–40TypeScript
declare const idBrand: unique symbol;/** Erased nominal number identifying one durable record kind. */export type Id<Kind extends string, Type = unknown> = number & {	readonly [idBrand]: {		readonly kind: Kind;		readonly type: Type;	};};export type ConversationId = Id<"conversation">;export type EntryId = Id<"entry">;export type TaskId<Result = unknown> = Id<"task", Result>;export type SubmissionId = Id<"submission">;export type DocumentId = Id<"document">;declare const seqBrand: unique symbol;/** Strictly increasing sequence assigned to one atomic storage commit; gaps are permitted. */export type Seq = number & { readonly [seqBrand]: "sequence" };/** The root conversation always uses this reserved ID. */export const ROOT_CONVERSATION_ID = 1 as ConversationId;
Seq là số thứ tự của một commit trong storage, tăng nghiêm ngặt và được phép có khoảng trống. Nó mang nhãn riêng để một ID thực thể không bao giờ bị dùng làm điểm commit của document.

Vì dùng chung một dãy và storage hứa cấp số lớn hơn mọi số đã cấp trước đó (types.ts:1185–1189), thứ tự ID cũng là thứ tự tạo: quét entry theo ID là quét theo thời gian. Hội thoại gốc luôn có ID 1, nên mọi backend bắt đầu cấp từ 2; bộ kiểm thử tuân thủ có hẳn một ca cho việc này (storage-conformance.ts:98).

ConversationRecord: cha để thừa kế, chủ để quy trách nhiệm#

ConversationRecord: danh tính, nguồn fork và cạnh sở hữu
pi/packages/durable/src/types.tsdòng 393–409TypeScript
/** Ownership selected explicitly whenever a conversation is created. */export type ConversationOwnership = { readonly kind: "ownerless" } | { readonly kind: "task"; readonly taskId: TaskId };/** Immutable identity, history ancestry, and task ownership of a transcript scope. */export type ConversationRecord = {	readonly id: ConversationId;	/** Fork source and inclusive parent entry through which history is inherited. */	readonly parent?: {		readonly conversationId: ConversationId;		readonly at: EntryId;	};	/** Creator edge used for attribution, subtree abort, and subtree idle waits. */	readonly owner?: {		readonly conversationId: ConversationId;		readonly taskId: TaskId;	};};

Hai trường tuỳ chọn có hai vai khác nhau (spec.md:247–255). parent nói hội thoại này là fork của hội thoại nào, tại entry nào: nó thấy các entry của cha tới parent.at. owner nói task nào đã tạo ra nó, ví dụ một subagent do tool subagent tạo; cạnh này dùng để huỷ cả cây và chờ cả cây rảnh, không phải để phân quyền. Khi tạo, người gọi luôn chọn quyền sở hữu tường minh (ownerless hoặc task); Session tự suy ra hội thoại của task chủ. Bản ghi bất biến: một hội thoại không bao giờ đổi cha hay đổi chủ.

EntryRecord: thứ mô hình thấy và thứ ứng dụng giữ#

EntryRecord và EntryDraft
pi/packages/durable/src/types.tsdòng 427–449TypeScript
/** Immutable transcript event with separate model-facing and application-facing payloads. */export type EntryRecord = {	readonly id: EntryId;	readonly conversationId: ConversationId;	/** Application-defined entry discriminator. */	readonly kind: string;	/** Messages contributed to model context; absent for display or bookkeeping entries. */	readonly model?: readonly Message[];	/** JSON payload consumed by views, extensions, or bookkeeping logic. */	readonly data?: JsonValue;	/** First entry in the active context selected by this entry. */	readonly head?: EntryId;	/** Context-only overrides of earlier visible entries. */	readonly edits?: readonly ContextEdit[];	/** Task that appended this entry, when it was produced by durable work. */	readonly byTaskId?: TaskId;};/** Entry content supplied before the Session assigns identity and task attribution. */export type EntryDraft = Omit<EntryRecord, "id" | "conversationId" | "byTaskId" | "head"> & {	/** `"self"` starts active context at the newly assigned entry ID. */	readonly head?: EntryId | "self";};
ContextEdit ngay phía trên (types.ts:411–425) là một chỉnh sửa chỉ áp vào ngữ cảnh: omit bỏ phần đóng góp của entry đích, replace thay nó bằng các message khác.

Một entry tách hai luồng dữ liệu. model là các message (theo kiểu Message của pi-ai) mà entry đóng góp vào request kế tiếp gửi mô hình; data là JSON tuỳ ý cho view, extension hay sổ sách. Một entry không có model chỉ để hiển thị. head đánh dấu điểm bắt đầu của ngữ cảnh đang hoạt động: entry mang head là một "head marker", và khi viết nháp, head: "self" nghĩa là ngữ cảnh mới bắt đầu ngay tại entry này. edits sửa đóng góp của các entry cũ hơn mà không đụng vào chúng. byTaskId ghi task nào đã append entry, do Session đóng dấu từ phạm vi của commit.

Entry không bao giờ bị sửa hay xoá. Reset, compaction (nén ngữ cảnh) và chỉnh sửa đều là entry mới. Đây là khác biệt lớn so với cây JSONL của coding agent (chương Session): ở đó nhánh là cấu trúc của file; ở đây "nhánh" là một hội thoại khác có parent, và "quên bớt ngữ cảnh" là một head marker.

Các kind dựng sẵn#

entries.ts: defineEntry và sáu token dựng sẵn
pi/packages/durable/src/entries.tsdòng 5–34TypeScript
/** Define a typed entry kind whose `is()` guard narrows by `EntryRecord.kind`. */export function defineEntry<D extends JsonValue = never>(kind: string): Entry<D> {	if (typeof kind !== "string" || kind.length === 0) throw new TypeError("Entry kind must be a non-empty string");	return {		kind,		is: (entry: EntryRecord | undefined): entry is TypedEntry<D> => entry !== undefined && entry.kind === kind,	};}/** User input: `model` is `[UserMessage]`. Written by submissions. */export const UserEntry = defineEntry("pi.user");/** Provider result with any stop reason: `model` is `[AssistantMessage]`. Written by generation. */export const AssistantEntry = defineEntry("pi.assistant");/** Positional prompt and tool change: `model` is `[SystemMessage]` with empty `content`. */export const SystemEntry = defineEntry("pi.system");/** * Tool result: `model` is `[ToolResultMessage]`, whose content ends with the rendered diagnostics block; `data` holds * the structured diagnostics, possibly none. Written by tool tasks, and by generation for calls it did not offer. */export const ToolResultEntry = defineEntry<{ diagnostics: ToolDiagnostic[] }>("pi.tool-result");/** * Start of a new context: always `head: "self"`, with `model` absent for a plain reset or `[UserMessage]` carrying the * handoff text. Written by `Conversation.reset()` and the `handoff` tool control. */export const ResetEntry = defineEntry("pi.reset");/** * Compaction summary: `model` is `[UserMessage]` with the wrapped summary, `head` the first kept entry. Written by * compaction tasks, directly or through a write submission. */export const CompactionEntry = defineEntry<{ reason: CompactionReason }>("pi.compaction");
Mỗi token là một cặp kind và hàm thu hẹp kiểu is(). Entry<D> mang kiểu của data: ToolResultEntry luôn có data.diagnostics, CompactionEntry có data.reason, các kind còn lại không có data (spec.md:3975–3979).
KindmodelAi ghi
pi.user[UserMessage], timestamp lấy từ đồng hồ Harness lúc đặt vào transcriptsubmission, continuation của hook onYield
pi.assistant[AssistantMessage] với mọi stop reason, kể cả lỗi và bị huỷgeneration
pi.system[SystemMessage] với content: ""; chỉ chứa section và thay đổi toolbước chuẩn bị của generation
pi.tool-result[ToolResultMessage]task tool; generation, cho lời gọi tới tool không được mời
pi.resetkhông có, hoặc [UserMessage] chứa ghi chú bàn giao; luôn head: "self"reset(), control handoff của tool
pi.compaction[UserMessage] chứa bản tóm tắt; head trỏ vào entry đầu tiên được giữtask compaction

Bảng trên tóm spec.md:3981–3988. Mọi câu trả lời của mô hình, kể cả lần thử lỗi, đều thành một entry pi.assistant; vì vậy không cần kind riêng cho "usage" hay "thông báo lỗi". Ứng dụng tự định nghĩa kind của mình bằng defineEntry<D>("app.note"); ví dụ 08-harness-conversations.ts:19 tạo kind message có data.from. Tiền tố pi. thuộc về các kind dựng sẵn theo quy ước; đặc tả nói rõ không có gì cưỡng chế điều đó (spec.md:5565–5568), trừ một chỗ: input gửi kèm entry.kind riêng bị từ chối nếu kind bắt đầu bằng pi. (submissions.ts:182–189).

Từ transcript tới ngữ cảnh của mô hình#

"Mô hình thấy các entry từ lần reset mới nhất trở đi" (README.md:96). Chính xác hơn, việc dẫn xuất ngữ cảnh lấy head marker mới nhất H, quét các entry nhìn thấy được từ H.head tới cuối, áp chỉnh sửa (chỉnh sửa mới nhất của mỗi đích thắng), rồi chuẩn hoá cho giao thức của provider:

deriveRange(): chỉnh sửa, entry đang hoạt động, đóng góp, message
pi/packages/durable/src/harness/context.tsdòng 143–155TypeScript
/** Derive the context view from the entries scanned within `bounds`. */function deriveRange(bounds: ContextBounds, entries: readonly EntryRecord[]): ContextRange {	const edits = new Map<EntryId, ContextEdit>();	// Edits of every entry in the range count, including older head markers that `selectActive()` drops.	for (const entry of entries) for (const edit of entry.edits ?? []) edits.set(edit.target, edit);	const active = selectActive(bounds.head, entries);	const contributions = active.map((entry) => contribute(entry, edits.get(entry.id)));	const { settled, open } = settle([], contributions.flat());	const baseline = findBaseline(bounds.head, active);	const messages = leadWithSystem([...settled, ...orderToolResults(open)], baseline);	const view = { head: bounds.head, entries: active, contributions, messages };	return { bounds, entries, view, edited: new Set(edits.keys()), settled, open, baseline };}
contribute() loại các message assistant có stop reason aborted, error hoặc deferred (context.ts:10); orderToolResults() đặt kết quả tool ngay sau lời gọi của nó và tự tổng hợp kết quả lỗi cho lời gọi không có kết quả (context.ts:299–321); leadWithSystem() đưa system message nền (baseline) lên đầu (context.ts:212–220).

Mười quy tắc đầy đủ nằm ở spec.md:267–302. Hai hệ quả đáng nhớ: một fork cắt ngay sau một lời gọi tool vẫn gửi được request hợp lệ, vì kết quả thiếu được tổng hợp; và view cho UI giữ entry thô, không phải message đã chuẩn hoá (spec.md:304–306). Ví dụ 09-context.ts dựng đủ các trường hợp: message bị huỷ, kết quả về lệch thứ tự, chỉnh sửa, fork ở lời gọi tool, và một head "self".

Document: trạng thái JSON có kiểu#

Bài toán: ứng dụng có state riêng, như danh sách việc cần làm của agent, lựa chọn mô hình của một hội thoại, hay hàng đợi tin nhắn chưa xử lý. State này phải sống sót qua crash cùng lúc với transcript, và khi fork hội thoại thì nhánh mới phải nhận state "đúng lúc" với entry nó tách ra. Nếu state nằm ở một kho riêng, sẽ có lúc transcript đã ghi mà state chưa ghi. Pi Durable giải bằng document: một object JSON có kiểu, gắn vào session, một hội thoại hoặc một task, và chỉ thay đổi bên trong commit, cùng commit với entry.

Ngữ nghĩa của document và CommonDocDefinition
pi/packages/durable/src/types.tsdòng 42–79TypeScript
/** Conversation document that retains only its current state. */export type LatestConversationSemantics = {	readonly scope: "conversation";	readonly history: "latest";	readonly fork: "current" | "initial";};/** Conversation document whose history remains addressable for as-of reads. */export type RewindableConversationSemantics = {	readonly scope: "conversation";	readonly history: "rewindable";	readonly fork: "asOf" | "current" | "initial";};/** Ownership and lifetime of a document; only conversation documents declare history and fork behavior. */export type DocumentSemantics =	| { readonly scope: "session"; readonly history?: never; readonly fork?: never }	| LatestConversationSemantics	| RewindableConversationSemantics	| { readonly scope: "task"; readonly history?: never; readonly fork?: never };/** Stored replay state supplied to a document's checkpoint predicate. */export type CheckpointInfo = {	/** Deltas already stored after the newest base, excluding the change being evaluated. */	readonly deltasSinceBase: number;};/** Definition fields shared by singleton documents and document families. */export type CommonDocDefinition<T extends JsonObject> = {	/** Stable persisted kind; part of the public protocol. */	readonly kind: string;	/** Positive integer version of the stored value shape. */	readonly version: number;	initial(): T;	migrate?(value: JsonObject, fromVersion: number): T;	/** Required policy: return true to store this ordinary change as a complete base instead of a delta. */	checkpointWhen(value: Readonly<T>, ops: readonly Op[], info: CheckpointInfo): boolean;};

Các trường của một định nghĩa:

kind, version
kind là tên được lưu, thuộc giao thức công khai, không đổi được bằng migration; version là số nguyên dương của hình dạng giá trị. Giá trị lưu ở version cũ hơn được chuyển qua migrate(value, fromVersion) khi truy cập (spec.md:1596–1601).
scope
session (một bản cho cả session), conversation (một bản cho mỗi hội thoại) hoặc task (sống và tự nghỉ hưu cùng task).
history, fork (chỉ document hội thoại)
latest chỉ giữ giá trị hiện tại; rewindable giữ lịch sử để đọc "giá trị lúc entry X" bằng snapshotAsOf(). fork nói nhánh mới bắt đầu bằng gì: initial (giá trị khởi tạo), current (giá trị hiện tại của cha khi fork) hoặc asOf (giá trị của cha tại entry fork, nên đòi rewindable). Ràng buộc này nằm ở kiểu: LatestConversationSemantics không cho fork: "asOf".
initial(), checkpointWhen()
initial() chạy khi document chưa có và được tạo lần đầu. checkpointWhen(value, ops, info) quyết định một thay đổi được lưu như một base (giá trị đầy đủ) hay một delta (các phép toán Chord), để chặn độ dài chuỗi phát lại (replay). Nó bắt buộc, không có mặc định (spec.md:1222–1227).
defineDoc(): bốn overload trả về bốn loại token
pi/packages/durable/src/documents.tsdòng 38–57, 95–99TypeScript
/** Define a Session-scoped singleton document. */export function defineDoc<T extends JsonObject>(	definition: CommonDocDefinition<T> & { readonly scope: "session" },): SessionDocToken<T>;/** Define a latest-only conversation singleton document. */export function defineDoc<T extends JsonObject>(	definition: CommonDocDefinition<T> & LatestConversationSemantics,): ConversationDocToken<T>;/** Define a rewindable conversation singleton document. */export function defineDoc<T extends JsonObject>(	definition: CommonDocDefinition<T> & RewindableConversationSemantics,): RewindableConversationDocToken<T>;/** Define a task-scoped singleton document. */export function defineDoc<T extends JsonObject>(	definition: CommonDocDefinition<T> & { readonly scope: "task" },): TaskDocToken<T>;export function defineDoc<T extends JsonObject>(definition: DocDefinition<T>): DocToken<T, DocDefinition<T>> {	validateDefinition(definition);	return { definition };}function validateDefinition(definition: AnyDocDefinition): void {	if (!Number.isSafeInteger(definition.version) || definition.version < 1) {		throw new TypeError(`Document ${definition.kind} version must be a positive integer`);	}}
Lúc chạy, defineDoc() chỉ kiểm tra version rồi trả về { definition }. Token không được đăng ký ở đâu: nó được truyền tường minh vào mọi lần truy cập (tx.doc(token, id), snapshot(token, id)), và Session không quét document lúc mở.

defineDocFamily() làm tương tự cho một họ document cùng phạm vi, phân biệt bằng một khoá chuỗi, với initial(seed). Task tool dùng một họ như vậy, pi.tool.nested-call, để lưu kết quả của từng lời gọi lồng (tool.ts:106–114). Cách ghi, đọc lịch sử, base, delta và fork bên trong được mổ xẻ ở chương storage.

Năm document dựng sẵn#

Mọi hội thoại do Harness tạo đều có năm document, tạo ngay trong commit tạo hội thoại bởi hook dựng sẵn:

Hook tạo hội thoại: năm document rồi tới hook của host
pi/packages/durable/src/harness/harness.tsdòng 420–432TypeScript
/** * The built-in creation hook, in every commit that creates or forks a conversation: empty `pi.live`, `pi.inbox`, and * `pi.usage`, a fresh `pi.provider`, the conversation's `pi.agent` (see `createAgent()`), then * `HarnessOptions.conversationCreated`. */protected override async conversationCreated(tx: Transaction, record: ConversationRecord): Promise<void> {	await tx.doc(LiveDoc, record.id);	await tx.doc(InboxDoc, record.id);	await tx.doc(UsageDoc, record.id);	await tx.doc(ProviderDoc, record.id);	await createAgent(tx, record);	await this.#options.conversationCreated?.(tx, record);}
Hook này chạy trong mọi commit tạo hoặc fork hội thoại, kể cả tx.createConversation() thô do một tool gọi (spec.md:768–783). Một Session trần (createSession()) không tạo document nào.
KindLịch sử / forkChứa gìLưu base khiĐịnh nghĩa
pi.agentrewindable / asOfLựa chọn của hội thoại, lưu theo tên: mô hình, mức suy nghĩ, extension, tool, instructions, cwdmọi thay đổiagent.ts:42–51
pi.providerlatest / initial{ sessionId }: UUIDv7 mà pi-ai nhận làm sessionId cho prompt cache và session affinitymọi thay đổiprovider.ts:11–20
pi.livelatest / initialQuyền điều khiển run, câu trả lời đang stream, các tool đang chạy, compaction đang chạykhông còn gì đang chạylive.ts:111–127
pi.inboxlatest / initialCác submission đang xếp hàng, theo thứ tự IDhàng đợi rỗnginbox.ts:21–29
pi.usagelatest / initialTổng token và chi phí theo provider/model và theo tên toolmọi thay đổiusage.ts:14–22

Chính sách fork của từng document là quyết định sản phẩm. pi.agent là asOf để một fork chạy với đúng agent mà cha có tại entry fork. Bốn document còn lại là initial: một fork bắt đầu với inbox rỗng (hàng đợi thuộc về hội thoại gốc), không có run, chi phí bằng không (để không đếm hai lần, spec.md:4433–4436) và một sessionId mới (spec.md:866–872). Riêng pi.agent của hội thoại do task sở hữu được sao chép từ hội thoại của task chủ, một lần, lúc tạo:

createAgent(): fork giữ bản asOf, hội thoại của task chép agent của chủ, hội thoại tự do bắt đầu rỗng
pi/packages/durable/src/harness/agent.tsdòng 169–180TypeScript
/** * Built-in part of every Harness commit that creates or forks a conversation, for `pi.agent`: a fork keeps its `asOf` * copy; a new task-owned conversation copies the stored agent of its owner task's conversation; a new ownerless one * starts empty. */export async function createAgent(tx: Tx, conversation: ConversationRecord): Promise<void> {	if (conversation.parent !== undefined) return;	const agent = await tx.doc(AgentDoc, conversation.id);	if (conversation.owner === undefined) return;	const owner = await tx.doc(AgentDoc, conversation.owner.conversationId);	Object.assign(agent, copyJson(owner) as AgentState);}

pi.live là document bận rộn nhất và cũng là định nghĩa của "đang bận":

LiveState: run, generation, tools, nestedTools, compactions
pi/packages/durable/src/harness/live.tsdòng 81–109TypeScript
/** Built-in live conversation state: run control and presentation of the current generation and tool round. */export type LiveState = {	/** Run control: the task that settles the run's inputs, and those inputs; present exactly while busy. */	run?: { taskId: TaskId; inputs: SubmissionId[] };	/** Presentation of the current generation attempt. */	generation?: {		attempt: number;		/** Committed throttled partial of the in-flight response. */		message?: JsonRepresentation<AssistantMessage>;		/** Durable backoff before the next attempt. */		retry?: { at: number; error: string };		/** Provider-side deferred response being polled. */		deferred?: { pollAt: number };	};	/** The current tool round in call order, from the tool-calling answer until the generation's `tools` phase ends it. */	tools?: ToolSlot[];	/**	 * Nested calls of running tool calls, in creation order, so a parent precedes its children and task IDs ascend. A	 * call's nested slots are removed when the call settles.	 */	nestedTools?: NestedToolSlot[];	/** Live compaction tasks in task ID order; absent when none. */	compactions?: CompactionStatus[];	/**	 * The newest compaction failure, so a client can show it after the task ended. A compaction that produces a summary	 * removes it; one that is aborted or finds nothing to compact leaves it.	 */	compactionFailure?: CompactionFailure;};
run có mặt đúng khi hội thoại bận; taskId là generation chịu trách nhiệm trả lời các inputs, và giá trị này được chuyển từ generation này sang generation kế tiếp suốt một run (spec.md:2528–2537).

Chính sách checkpoint của nó cho thấy cách chọn checkpointWhen có chủ đích: lưu base mỗi khi không còn gì đang chạy, tức lúc rảnh, lúc generation giao cho vòng tool, và giữa các tool. Vì một slot chỉ giữ output khi đang chạy, mọi base đều nhỏ, và chuỗi delta không bao giờ dài hơn một generation (live.ts:118–126; spec.md:4092–4101).

Ứng dụng định nghĩa document của mình theo cùng cách. README cho ví dụ app.todos sửa trong một commit rồi đọc lại bằng harness.snapshot() (README.md:728–747); HarnessOptions.conversationCreated cho phép tạo document của ứng dụng trong mọi hội thoại, và extension đọc document của mình qua api hoặc input.read, coi document vắng mặt là giá trị mặc định (11-extension-state.ts:32–56).

Commit: bề mặt giao dịch#

Mọi thay đổi đi qua một callback nhận tx (kiểu Tx). Những gì callback ghi được lưu cùng nhau khi nó trả về, hoặc bị bỏ hết nếu nó ném lỗi (README.md:97). Ví dụ 01-documents.ts:28–32 append một entry và sửa một document trong cùng một commit. Phần ghi của Tx:

Tx: tạo hội thoại, fork, append entry, tạo task
pi/packages/durable/src/types.tsdòng 932–952TypeScript
/** Create a conversation with explicitly selected ownership. */createConversation(options: { readonly ownership: ConversationOwnership }): Promise<ConversationRecord>;/** Create a history fork at one concrete visible entry with explicitly selected ownership. */forkConversation(	parentConversationId: ConversationId,	at: EntryId,	options: { readonly ownership: ConversationOwnership },): Promise<ConversationRecord>;/** Returned records are Session-owned immutable values and may be shared with commit listeners. */appendEntry(conversationId: ConversationId, value: EntryDraft): Promise<EntryRecord>;/** The token supplies `kind` and types `data`. */appendEntry<D extends JsonValue>(	token: Entry<D>,	conversationId: ConversationId,	value: TypedEntryDraft<NoInfer<D>>,): Promise<TypedEntry<D>>;createTask<I, S extends { phase: string }, R, H extends object>(	task: Task<I, S, R, H>,	input: I,	options: TaskOptions,): Promise<TaskId<R>>;
Phần đọc nằm ngay trên (types.ts:910–930); phần submission (createSubmission, settleSubmission, placeSubmission) và document (doc, retireDoc) nằm ngay dưới (types.ts:953–1005).

Có ba cửa vào cùng một cơ chế, khác nhau ở phạm vi mặc định:

  • harness.commit(tx => ...): commit của Session, không gắn với hội thoại nào; tx.createTask() khi đó phải được cho conversationId.
  • conversation.commit(tx => ...): cùng commit nhưng gắn với hội thoại đó, nên tx.createTask() mặc định thuộc hội thoại này (harness.ts:141–143).
  • runtime.commit((tx, current) => next) trong phase của task và api.commit() trong tool: commit còn đóng dấu byTaskId lên entry, và với task thì giá trị trả về là trạng thái kế tiếp của task, ghi trong cùng commit (types.ts:266–278; xem chương task).

Bốn quy tắc cần thuộc trước khi viết commit (chi tiết thực thi ở chương storage):

  1. Đọc bảng trước, ghi bảng sau. Bảng là hội thoại, entry, task và submission. Sau lần ghi bảng đầu tiên, mọi lần đọc bảng ném ReadAfterWrite; document vẫn đọc ghi được (spec.md:1728–1734; errors.ts:16–22).
  2. Không làm việc chậm bên trong. Callback giữ dòng commit của cả Session; không được chờ mô hình, tool, mạng, con người hay một commit lồng (spec.md:5521–5523).
  3. Đọc qua tx, không qua Session. snapshot(), getTask() và các lần đọc khác có thể xếp hàng sau chính commit đang giữ dòng; gọi chúng bên trong callback có thể treo vĩnh viễn, và chỉ treo khi trượt cache nên lỗi lúc có lúc không (spec.md:5524–5528).
  4. Draft chết khi callback xong. tx.doc() trả về một draft của Chord; giá trị gán vào được sao chép ngay và phải là JSON nghiêm ngặt; đọc hay ghi draft sau khi callback xong sẽ ném lỗi (spec.md:1519–1535).

Submission và inbox#

Host không append entry trực tiếp vào một hội thoại có thể đang bận. Nó gửi một submission: hoặc input của người dùng (có thể khởi động một run), hoặc một entry cần ghi (write). submit() trả về ngay sau khi submission được ghi bền vào storage (admission), không đợi câu trả lời; đối tượng Submission trả về là thứ để chờ (spec.md:1002–1011).

SubmissionDraft và Submission
pi/packages/durable/src/harness/types.tsdòng 58–95TypeScript
/** * The entry an input is placed as, instead of `pi.user`: an application kind, and data the transcript shows, such as its * author. Its model message is still the input's user message. */export type InputEntry = { readonly kind: string; readonly data?: JsonValue };/** Host submission: user input that may start a run, or a passive entry write. */export type SubmissionDraft = {	readonly requestId?: string;} & (	| {			readonly type: "input";			readonly content: UserInput;			readonly whenBusy?: "steer" | "followUp" | "reject";			/** Place the input as this entry; default `pi.user` without data. The kind must not start with `pi.`. */			readonly entry?: InputEntry;	  }	| {			readonly type: "write";			readonly entry: EntryDraft;			readonly content?: never;			readonly whenBusy?: never;	  });export type InputSubmissionDraft = Extract<SubmissionDraft, { readonly type: "input" }>;export type SettledSubmissionRecord = SubmissionRecord & {	readonly status: "done" | "unanswered";};/** Awaitable host object for one durably admitted submission. */export interface Submission {	readonly id: SubmissionId;	status(context: Context): Promise<SubmissionRecord>;	wait(context: Context): Promise<SettledSubmissionRecord>;	abort(context: Context): Promise<"aborted" | "already_placed" | "settled">;}

Mỗi submission có một bản ghi bền với vòng đời riêng. Với input: queued (đã nhận, chưa vào transcript), placed (đã thành entry, thuộc một run đang chạy), rồi done (kèm answer là ID entry câu trả lời) hoặc unanswered (kèm reason). Write không có trạng thái placed: đặt vào transcript là xong.

SubmissionRecord: bốn trạng thái của một input
pi/packages/durable/src/types.tsdòng 476–512TypeScript
export type SubmissionRecord =	| (SubmissionRecordBase & {			readonly type: "input";	  } & (				| {						/** Admitted but not yet represented in the transcript. */						readonly status: "queued";						readonly entry?: never;						readonly answer?: never;						readonly reason?: never;						readonly detail?: never;				  }				| {						/** Added to the transcript and owned by an active run. */						readonly status: "placed";						readonly entry: EntryId;						readonly answer?: never;						readonly reason?: never;						readonly detail?: never;				  }				| {						/** Successfully answered user input. */						readonly status: "done";						readonly entry: EntryId;						readonly answer: EntryId;						readonly reason?: never;						readonly detail?: never;				  }				| {						/** Terminal input that can no longer receive an answer. */						readonly status: "unanswered";						readonly entry?: EntryId;						readonly answer?: never;						readonly reason: string;						readonly detail?: JsonValue;				  }			))
Nhánh write nằm ngay sau (types.ts:513–540); trường chung id, conversationId, requestId, trace ở types.ts:465–473.

Admission: một commit quyết định số phận#

Conversation.submit() chuyển cho Submissions.submit(), vốn bật scheduler (một lời gọi xin tiến triển thì không bao giờ phải chờ một Harness đang tạm dừng) rồi chạy toàn bộ việc nhận trong một commit:

Submissions.submit(): resume() rồi một commit chạy admitSubmission()
pi/packages/durable/src/harness/submissions.tsdòng 75–93TypeScript
/** Admit a submission in one commit; see `admitSubmission()`. */async submit(conversationId: ConversationId, draft: SubmissionDraft, context: Context): Promise<Submission> {	this.#resume();	const parent = draft.type === "input" ? this.#telemetry : context.telemetry;	return createTypedSpanStarter(parent, [DURABLE_TELEMETRY_SCHEMA])(		"pi.durable.submit",		{			"submission.type": draft.type,			"conversation.id": conversationId,		},		async (span) => {			if (draft.type === "input") span.addLink({ context: context.telemetry });			try {				const { id, status } = await this.#session.commitWith(					(tx) => admitSubmission(tx, conversationId, draft, this.#now(), this.#queueModes(), span),					withTelemetry(span, context),				);				span.setAttributes({ "submission.id": id, result: status });				return new SubmissionHandle(id, this);
Span pi.durable.submit chỉ mang ID và trạng thái, không mang nội dung (README.md:84).

admitSubmission() đọc từ trên xuống như một cây quyết định. Đầu tiên là trùng requestId và write chỉ để hiển thị:

admitSubmission() (1): requestId trùng thì trả bản cũ; write chỉ để hiển thị được append ngay
pi/packages/durable/src/harness/submissions.tsdòng 209–228TypeScript
if (draft.type === "input" && draft.entry !== undefined) checkInputEntry(draft.entry);if (draft.requestId !== undefined) {	const existing = await tx.submissionByRequest(conversationId, draft.requestId);	if (existing !== undefined) {		if (existing.type !== draft.type) {			throw new Error(`Request ${draft.requestId} already identifies a submission of type ${existing.type}`);		}		return { id: existing.id, status: existing.status === "unanswered" ? "done" : existing.status };	}}const fields = { conversationId, requestId: draft.requestId, trace: span?.propagation?.inject(span) };if (	draft.type === "write" &&	isDisplayOnly(draft.entry) &&	!hasQueuedReset((await tx.doc(InboxDoc, conversationId)).items)) {	const entry = await tx.appendEntry(conversationId, draft.entry);	const write = { ...fields, type: "write", status: "done", entry: entry.id } as const;	return { id: (await tx.createSubmission(write)).id, status: "done" };}
Write "chỉ để hiển thị" là write chỉ có kind và data, không có model, head hay edits (inbox.ts:131–137). Nó không đổi gì mô hình thấy, nên được append và chốt done ngay, kể cả giữa một run; ngoại lệ duy nhất là khi có một reset đang xếp hàng.

Tiếp theo là trường hợp bận, hoặc rảnh nhưng inbox còn mục chờ:

admitSubmission() (2): bận thì xếp hàng; inbox còn mục thì xếp hàng rồi chạy boundary final
pi/packages/durable/src/harness/submissions.tsdòng 229–265TypeScript
const live = await tx.doc(LiveDoc, conversationId);const busy = live.run !== undefined;if (	draft.type === "input" &&	draft.whenBusy === "steer" &&	live.run !== undefined &&	span?.propagation !== undefined) {	const run = await tx.task(live.run.taskId);	if (run?.trace !== undefined) span.addLink({ context: span.propagation.extract(run.trace) });}if (busy && draft.type === "input" && draft.whenBusy === "reject") throw new ConversationBusy(conversationId);// A boundary reads the table, so it is prepared before the first table write; a busy one needs none.const boundary = busy ? undefined : await prepareBoundary(tx, conversationId, queueModes);if (boundary === undefined || boundary.inbox.items.length > 0) {	const { id } = await tx.createSubmission({		...fields,		type: draft.type,		status: "queued",	});	// Hosts may leave optional fields `undefined`; drafts take strict JSON.	const value = copyJson(draft.type === "write" ? draft.entry : draft.content, {		omitUndefinedProperties: true,	});	const items = (boundary?.inbox ?? (await tx.doc(InboxDoc, conversationId))).items;	if (draft.type === "write") items.push({ id, mode: "write", entry: value as JsonObject });	else {		const mode = draft.whenBusy === "steer" ? "steer" : "followUp";		const content = value as JsonRepresentation<UserInput>;		const entry = draft.entry === undefined ? {} : { entry: pickInputEntry(draft.entry) };		items.push({ id, mode, content, ...entry });	}	if (boundary === undefined) return { id, status: "queued" };	const { users } = await applyBoundary(tx, boundary, "final", now);	if (users.length > 0) await startRun(tx, conversationId, live, users);	return { id, status: draft.type === "write" ? "done" : users.at(-1) === id ? "placed" : "queued" };}
Bận được định nghĩa đúng một cách: pi.live.run có mặt. whenBusy: "reject" ném ConversationBusy và không ghi gì (errors.ts:48–57). Khi rảnh mà inbox còn mục (ví dụ sau một run thất bại), mục mới xếp sau chúng và một boundary final chạy ngay trong commit này.

Cuối cùng là trường hợp phổ biến nhất, hội thoại rảnh và inbox rỗng:

admitSubmission() (3): rảnh thì đặt entry ngay; input thì tạo luôn run
pi/packages/durable/src/harness/submissions.tsdòng 266–283TypeScript
	if (draft.type === "write") {		if (isStale(boundary, draft.entry)) {			const stale = { status: "unanswered", reason: "stale" } as const;			return {				id: (await tx.createSubmission({ ...fields, type: "write", ...stale })).id,				status: "done",			};		}		const entry = await tx.appendEntry(conversationId, draft.entry);		const write = { ...fields, type: "write", status: "done", entry: entry.id } as const;		return { id: (await tx.createSubmission(write)).id, status: "done" };	}	const entry = await appendInput(tx, conversationId, draft.content, draft.entry, now);	const input = { ...fields, type: "input", status: "placed", entry: entry.id } as const;	const { id } = await tx.createSubmission(input);	await startRun(tx, conversationId, live, [id]);	return { id, status: "placed" };}
Một write có head trỏ về trước phạm vi đang hoạt động là stale: đặt nó sẽ làm sống lại lịch sử mà head mới hơn đã cắt (inbox.ts:144–147). Với input, appendInput() ghi entry pi.user (hoặc kind riêng của input) và startRun() tạo task generation đầu tiên (generation.ts:752–760).

Bảng chuyển trạng thái đầy đủ ở spec.md:2539–2552. Tóm tắt các nhánh trên:

Tình huốngBản ghi submissionGhi kèm trong cùng commit
requestId đã có trong hội thoạikhông ghi gì, trả về submission cũkhông có
Write chỉ để hiển thị, không có reset đang chờdone, kèm entryentry
Input, rảnh, inbox rỗngplaced, kèm entryentry pi.user, task pi.generation, pi.live.run
Write khác, rảnh, inbox rỗngdone, hoặc unanswered lý do staleentry (nếu không stale)
Bậnqueuedmục mới trong pi.inbox
Rảnh nhưng inbox còn mụcqueued, rồi có thể thành placed hoặc done ngaymục mới xếp cuối pi.inbox, rồi một boundary final trong cùng commit đặt các mục được chọn và có thể mở run
Bận và whenBusy: "reject"không có bản ghiném ConversationBusy

Steer, follow-up và boundary#

Mục trong inbox được đặt vào transcript ở các boundary (ranh giới giữa các lượt). Boundary postTools chạy khi một vòng tool kết thúc mà run còn tiếp; boundary final chạy khi run trả lời xong. Write luôn được đặt; tin nhắn steer (chen vào lượt đang chạy) được đặt ở cả hai; follow-up (xếp hàng sau) chỉ ở final, và khởi động run kế tiếp:

applyBoundary(): chọn write, steer và follow-up
pi/packages/durable/src/harness/inbox.tsdòng 76–86TypeScript
const { conversationId, inbox } = boundary;const items = inbox.items;const reset = hasQueuedReset(items);const final = at === "final" || reset;const pick = (mode: "steer" | "followUp", queueMode: QueueMode): number[] => {	const indexes = items.flatMap((item, index) => (item.mode === mode ? [index] : []));	return queueMode === "all" ? indexes : indexes.slice(0, 1);};const writes = items.flatMap((item, index) => (item.mode === "write" ? [index] : []));const users = [...pick("steer", boundary.steeringMode), ...(final ? pick("followUp", boundary.followUpMode) : [])];users.sort((a, b) => a - b);
steeringMode và followUpMode mặc định là one-at-a-time: mỗi boundary lấy mục đầu tiên của loại đó; all lấy tất cả (agent.ts:78–79). Write được đặt trước, rồi tới input, đều theo thứ tự ID, nên input xếp trước một reset sẽ chạy trong ngữ cảnh mới (spec.md:2584–2592).

Một reset đang chờ biến boundary postTools thành final, và các input của run hiện tại kết thúc unanswered với lý do reset, vì ngữ cảnh của chúng đã bị cắt trước khi có câu trả lời (spec.md:2613–2617). Ví dụ 20-inbox.ts chạy đủ steer, follow-up, write và rút lại khi đang bận.

requestId, wait() và abort()#

requestId làm cho việc gửi lại idempotent (gọi lại nhiều lần vẫn cho một kết quả) trong phạm vi một hội thoại: lần gửi thứ hai với cùng ID, kể cả sau khi tiến trình khởi động lại, trả về đúng submission đã có (README.md:129–138). Dùng lại một requestId cho loại submission khác thì bị từ chối. Đây là cách host đạt "đúng một lần" (exactly-once): nếu commit có thể đã thành công trước khi tiến trình chết, cứ gửi lại với cùng requestId. Bài "Pi Durable on Cloudflare" áp dụng đúng mẫu này cho lệnh tạo hội thoại: client gửi lại các yêu cầu chưa được xác nhận với cùng request ID.

wait(), abort() và bộ quan sát commit giải phóng người chờ
pi/packages/durable/src/harness/submissions.tsdòng 115–150TypeScript
async wait(id: SubmissionId, context: Context): Promise<SettledSubmissionRecord> {	this.#resume();	// Check and register on the line so no settling publication falls between them.	const found = await this.#session.readOnLine(async () => {		const record = await this.#storage.submission(id, context);		if (record === undefined) throw new Error(`Submission ${id} does not exist`);		if (isSettled(record)) return { promise: Promise.resolve(record) };		// Close rejects registered waiters synchronously and may begin during the read.		if (this.#closed) throw closedError(this.#session);		return { promise: this.#waiters.add(id, context) };	});	return found.promise;}/** Withdraw a queued submission and remove its inbox item; placed inputs and settled submissions are reported. */abort(id: SubmissionId, context: Context, conversationId?: ConversationId): Promise<AbortResult | "not_found"> {	return this.#session.commitWith(async (tx) => {		const record = await tx.submission(id);		if (record === undefined || (conversationId !== undefined && record.conversationId !== conversationId)) {			return "not_found";		}		if (record.status === "queued") {			tx.settleSubmission(id, { status: "unanswered", reason: "aborted" });			await removeInboxItem(tx, record.conversationId, id);			return "aborted";		}		return record.status === "placed" ? "already_placed" : "settled";	}, context);}#observe(publication: CommitPublication): void {	for (const change of publication.changes) {		if (change.type !== "submission" || !isSettled(change.value)) continue;		this.#waiters.resolve(change.value.id, change.value);	}}
wait() kiểm tra và đăng ký người chờ trên dòng commit, nên không thể có commit chốt nào lọt vào giữa. Người chờ được giải phóng bởi một bộ nghe commit nội bộ, tức chỉ khi việc chốt đã nằm trong storage. Huỷ context của wait() chỉ huỷ việc chờ; rút lại submission là việc của abort(), và chỉ rút được khi còn queued.

Các lý do unanswered mà code v1.4.0 thực sự ghi:

Lý doKhi nàoNơi ghi
abortedRút lại khi còn xếp hàng; Conversation.abort(); task của run bị huỷsubmissions.ts:137, inbox.ts:160, generation.ts:312
staleWrite có head trỏ về trước phạm vi đang hoạt độnginbox.ts:93, submissions.ts:268
resetMột reset cắt ngữ cảnh giữa rungeneration.ts:726
model_error, no_modelMô hình trả lỗi không thử lại được; hội thoại không có mô hìnhgeneration.ts:584, generation.ts:396
run_failedRun trước thất bại và settings.afterFailedRun là withdrawgeneration.ts:424
faulted, hoặc lý do orphanTask của run lỗi lập trình, hoặc mất định nghĩa và bị huỷlive.ts:263–266

Mặc định (afterFailedRun: "keep"), một run thất bại để nguyên các mục xếp hàng sau nó cho tới khi có input mới hoặc write xếp hàng chạy boundary; continue chạy run kế tiếp với chúng; withdraw chốt chúng unanswered (README.md:220; generation.ts:415–428).

Extension và Registry#

Một mục tiêu của Pi Durable là đổi được code mà không dừng agent đang chạy. Nhưng state bền lại tham chiếu tới code: hội thoại chọn tool nào, task thuộc định nghĩa nào. Cách giải: tách hẳn hai thứ. Code (tool, section prompt, hook, wrapper, định nghĩa task) nằm trong extension có tên, cài vào một registry do ứng dụng sở hữu, chỉ sống trong bộ nhớ tiến trình. Storage chỉ lưu tên. Mỗi lần dùng, tên được giải (resolve) lại với registry hiện tại (spec.md:2644–2649).

Extension: tools, sections, hooks, wraps, tasks
pi/packages/durable/src/harness/types.tsdòng 363–381TypeScript
/** Built by `hook()`; matches tasks by name. */export type HookRegistration = { readonly task: string; readonly handlers: object };/** Built by `wrapTool()` and `wrapSection()`; targets a tool name or a section key. Wrappers are pure. */export type Wrap<Tool extends ToolRegistration = ToolRegistration> =	| { readonly tool: string; wrap(tool: Tool): Tool }	| { readonly section: string; wrap(section: PromptSection<Tool>): PromptSection<Tool> };/** Named bundle of code; installed in a registry and selected by conversations by name. */export interface Extension<Tool extends ToolRegistration = ToolRegistration> {	readonly name: string;	readonly tools?: readonly Tool[];	readonly sections?: readonly PromptSection<Tool>[];	readonly hooks?: readonly HookRegistration[];	/** Apply where this extension is selected, in order. */	readonly wraps?: readonly Wrap<Tool>[];	/** Resolved by name for every task, whichever conversations select this extension. */	readonly tasks?: readonly AnyTask[];}
  • tools: tool của extension; tool trùng tên của extension đứng sau thay tool của extension đứng trước, ở hội thoại chọn cả hai.
  • sections: các mảnh system prompt (mục System prompt bên dưới).
  • hooks: hook(GenerationTask, {...}) hay hook(ToolTask, {...}) đăng ký handler theo tên task; chỉ chạy ở hội thoại chọn extension này. Danh sách hook của các task dựng sẵn: harness/types.ts:851–907.
  • wraps: wrapTool(tool, fn) và wrapSection(key, fn) bọc một tool hay section theo tên, ở nơi extension bọc được chọn, và áp lên tool nào đang thắng.
  • tasks: định nghĩa task; khác với mọi thứ khác, chúng được giải theo tên cho mọi task, bất kể hội thoại nào chọn extension, vì một task đang chờ phải tìm lại được code của nó sau khởi động lại.
define.ts: các hàm định nghĩa chỉ là hàm đồng nhất có kiểu
pi/packages/durable/src/harness/define.tsdòng 5–10, 28–36TypeScript
/** Identity function that types an extension. */export function defineExtension<Tool extends ToolRegistration = ToolRegistration>(	extension: Extension<Tool>,): Extension<Tool> {	return extension;}/** Hook handlers for tasks with `task`'s name. */export function hook<K extends AnyTask>(task: K, handlers: Partial<HooksOf<K>>): HookRegistration {	return { task: task.definition.name, handlers };}/** Wrap the tool named like `tool` wherever the wrapping extension is selected. */export function wrapTool<Tool extends ToolRegistration>(tool: Tool, wrapper: (tool: Tool) => Tool): Wrap<Tool> {	return { tool: tool.name, wrap: wrapper };}
defineExtension() trả về chính đối số của nó, hook() chỉ ghi lại tên task. Mọi kiểm tra dồn về registry.install() (spec.md:2759–2763).
Registry: cài, thay tại chỗ, gỡ, công bố đồng bộ
pi/packages/durable/src/harness/registry.tsdòng 60–93TypeScript
class RegistryImpl<Tool extends ToolRegistration> implements Registry<Tool> {	#current = new RegistryState<Tool>([]);	readonly #listeners = new Set<() => void>();	snapshot(): RegistrySnapshot<Tool> {		return this.#current;	}	subscribe(listener: () => void): () => void {		this.#listeners.add(listener);		return () => this.#listeners.delete(listener);	}	install(extension: Extension<Tool>): void {		validateExtension(extension);		const current = this.#current.installed();		const index = current.findIndex((installed) => installed.name === extension.name);		const next =			index < 0 ? [...current, extension] : current.map((installed, at) => (at === index ? extension : installed));		this.#publish(next);	}	uninstall(extension: Extension): void {		const current = this.#current.installed();		if (!current.some((installed) => installed.name === extension.name)) return;		this.#publish(current.filter((installed) => installed.name !== extension.name));	}	/** Build and validate the next state, which throws on a task name collision, then publish it synchronously. */	#publish(extensions: readonly Extension<Tool>[]): void {		this.#current = new RegistryState(extensions);		for (const listener of [...this.#listeners]) listener();	}}
Cài một extension trùng tên thay nó tại chỗ, giữ vị trí cài đặt, trong một lần công bố; gỡ thì lọc theo tên, bất kể đối tượng nào được truyền vào. new RegistryState(...) ném lỗi nếu hai task trùng tên (registry.ts:18–31) trước khi #current bị gán, nên một lần cài hỏng không công bố gì.

validateExtension() đòi tên tool và khoá section không trùng trong một extension, khoá section khớp ^[a-z][a-z0-9_-]*$, và cấm khoá instructions, vốn dành cho instructions của agent (registry.ts:95–109). Ba task dựng sẵn pi.generation, pi.tool và pi.compaction luôn có trong mọi registry và không phải extension (registry.ts:9–10); Harness.open() từ chối registry thiếu chúng, nên hãy luôn tạo bằng createRegistry().

Thay code khi đang chạy (reload) theo một quy tắc đơn giản: việc đã bắt đầu giữ code nó đã lấy. Một lời gọi tool đang chạy kết thúc với cài đặt cũ; mỗi phase của task giải hook và agent một lần từ ảnh chụp (snapshot) registry lúc phase bắt đầu; phase, request hay lời gọi kế tiếp dùng code mới (README.md:464–472; spec.md:3799–3813). Sau khởi động lại, host cài lại cùng các extension; task đang chờ của một extension chạy tiếp khi extension đó được cài. Ví dụ 10-registry-reload.ts minh hoạ thay tại chỗ, wrapper và gỡ.

Agent của từng conversation#

Mỗi hội thoại có thể chạy với cấu hình riêng: subagent dùng mô hình rẻ hơn và không có tool subagent; một chế độ lập kế hoạch chỉ cho đọc; một hội thoại review có instructions riêng. Cấu hình này phải sống qua khởi động lại và theo đúng fork. Pi Durable lưu nó trong document pi.agent, dưới dạng tên:

AgentState: những gì một hội thoại đã chọn, lưu theo tên
pi/packages/durable/src/harness/types.tsdòng 411–433TypeScript
/** Stored choices of one conversation; names, not objects. Unset fields follow the host. */export type AgentState = {	model?: ModelRef;	thinkingLevel?: ModelThinkingLevel;	/** An array selects exactly these extensions, in order. An object edits the host default selection. */	extensions?: string[] | { add?: string[]; remove?: string[] };	/** Filters the selected extensions' tools: the enabled tools. An array enables exactly these, in order. */	tools?: string[] | { remove: string[] };	/**	 * Filters the enabled tools the model may call (`callers` includes `model`): the tools offered to it. Never widens:	 * other tools can still call the ones it leaves out.	 * - Unset: every such tool except deferred ones.	 * - An array: exactly these, in order, deferred ones too.	 * - An object: every such tool except deferred ones and those in `remove`, in registry order, followed by the	 *   deferred tools in `add` that are not in `remove`, in `add` order. Names of tools that are not deferred or not	 *   registered have no effect in `add`.	 */	modelTools?: string[] | { remove?: string[]; add?: string[] };	/** Rendered after every extension section, as the section `instructions`. */	instructions?: string;	/** Directory within the environment's file system, passed to `HarnessOptions.env`. */	cwd?: string;};

Trường nào không đặt thì theo host (cài đặt chung của Harness). Thay đổi đi qua configure(), nhận các đối tượng extension và tool rồi lưu tên của chúng, nên không gõ nhầm tên được: một trường được truyền thì thay trọn trường đã lưu, null xoá nó về mặc định, undefined giữ nguyên (harness/types.ts:435–451).

configure(): sửa pi.agent trong bất kỳ commit nào
pi/packages/durable/src/harness/agent.tsdòng 87–91, 120–127TypeScript
/** Apply one change to `pi.agent`: a given field replaces the stored one, `null` clears it, `undefined` changes nothing. */export async function configure(tx: Tx, conversationId: ConversationId, change: AgentChange): Promise<void> {	const state = await tx.doc(AgentDoc, conversationId);	applyChange(state, change);}function applyChange(state: Draft<AgentState>, change: AgentChange): void {	const set = <K extends keyof AgentState>(key: K, value: AgentState[K] | null | undefined) => {		if (value === undefined) return;		if (value === null) delete state[key];		else state[key] = value as Draft<AgentState>[K];	};	set("model", change.model === undefined || change.model === null ? change.model : { ...change.model });	set("thinkingLevel", change.thinkingLevel);
Vì là một hàm trên tx, configure() chạy được trong cả commit tạo hội thoại (tham số agent của root(), createConversation(), fork()); Conversation.configure() chỉ là bản chạy trong commit riêng (harness.ts:118–120).

Kết quả giải là kiểu Agent:

Agent: lựa chọn đã giải với registry và cài đặt
pi/packages/durable/src/harness/types.tsdòng 453–468TypeScript
/** A conversation's agent resolved against a registry snapshot and the settings. */export type Agent<Tool extends ToolRegistration = ToolRegistration> = {	readonly model?: ModelRef;	readonly thinkingLevel: ModelThinkingLevel;	readonly extensions: readonly Extension<Tool>[];	/** The tools a request offers, in order: enabled, callable by the model, and selected by `modelTools`. */	readonly tools: readonly Tool[];	/** The tools nested calls (`executeTool()`) resolve among, in order: enabled and callable by tools. */	readonly callable: readonly Tool[];	/** Enabled deferred tools the model may call and is not offered yet, in registry order: what `tool_search` loads. */	readonly deferred: readonly Tool[];	/** Extension sections, then `instructions` when set. */	readonly sections: readonly PromptSection<Tool>[];	readonly instructions?: string;	readonly cwd?: string;};

conversation.agent() đọc pi.agent đã commit, lấy ảnh chụp registry hiện tại và cài đặt hiện tại, rồi gọi resolveAgent(); nó không bao giờ ghi:

HarnessImpl.resolveAgent(): document đã commit cộng registry cộng settings
pi/packages/durable/src/harness/harness.tsdòng 266–277TypeScript
/** Resolve a conversation's committed `pi.agent` against `snapshot`, or the current one, and the current settings. */async resolveAgent(	id: ConversationId,	snapshot: RegistrySnapshot<Tool> | undefined,	context: Context,): Promise<Agent<Tool>> {	const registry = snapshot ?? this.#options.registry.snapshot();	const state = await this.snapshot(AgentDoc, id, context);	return resolveAgent(state, registry, resolveSettings(this.#hostSettings()), (error, extension) =>		this.#report(error, { type: "extension_error", conversationId: id, extension }, context),	);}
resolveAgent(): chọn extension, ghép tool theo tên, lọc thành tools, callable, deferred
pi/packages/durable/src/harness/agent.tsdòng 229–236, 259–270TypeScript
const extensions = selectExtensions(state?.extensions, snapshot, settings);const composed = new Map<string, Tool>();for (const extension of extensions) for (const tool of extension.tools ?? []) composed.set(tool.name, tool);const sections = new Map<string, PromptSection<Tool>>();for (const extension of extensions) {	for (const section of extension.sections ?? []) sections.set(section.key, section);}const enabled = filterTools([...composed.values()], state?.tools);const callableBy = (caller: "model" | "tools") => (tool: Tool) =>	tool.callers === undefined || tool.callers.includes(caller);const modelCallable = enabled.filter(callableBy("model"));const tools = offeredTools(modelCallable, state?.modelTools);const offered = new Set(tools);const deferred = modelCallable.filter((tool) => tool.deferred === true && !offered.has(tool));const callable = enabled.filter(callableBy("tools"));const instructions = state?.instructions;const agentSections = [...sections.values()];if (instructions !== undefined) agentSections.push({ key: INSTRUCTIONS_KEY, render: () => instructions });
Giữa hai đoạn là vòng áp wrapper: wrapper ném lỗi hoặc đổi tên tool thì tool đó bị bỏ khỏi lần giải này và lỗi được báo qua onReport (agent.ts:336–353).

Thuật toán đầy đủ có ở spec.md:2827–2843. Bốn danh sách tool dễ lẫn:

Danh sáchGồmDùng cho
enabled (nội bộ)Tool của các extension được chọn (trùng tên thì cái sau thắng tại chỗ), qua wrapper, rồi qua bộ lọc toolsGiới hạn trên của mọi thứ khác
toolsEnabled, callers có model, qua bộ lọc modelTools; tool deferred chỉ có mặt khi modelTools nêu tênTool được mời trong request tới mô hình
callableEnabled, callers có toolsLời gọi lồng qua api.executeTool(), script của code mode
deferredTool deferred mô hình được gọi nhưng chưa được mờiThứ tool_search có thể nạp

modelTools chỉ thu hẹp, không bao giờ mở rộng tools (README.md:347): configure({ modelTools: [codemode] }) cho mô hình chỉ thấy codemode trong khi script của nó vẫn gọi được read và bash. Phần cách tool chạy thuộc chương task.

Ai giải agent, và khi nào, quyết định một thay đổi có hiệu lực lúc nào (spec.md:2881–2893). Bước chuẩn bị của generation giải một lần cho mỗi request, rồi cố định mô hình, tùy chọn stream và tool được mời trong checkpoint của request. Mỗi phase của task giải nhiều nhất một lần. Task tool giải tên tool khi chạy; nếu tool không còn trong tools của agent (bộ lọc, lựa chọn hay registry đã đổi), lời gọi nhận kết quả lỗi tool_unavailable và run tiếp tục. Một cạm bẫy từ chính thiết kế "lưu tên": { add, remove } trong extensions luôn sửa lựa chọn mặc định của host, không sửa một lựa chọn đã chép từ hội thoại chủ (spec.md:837–841). Ví dụ 07-configuration.ts chạy qua mọi trường; 27-plan-mode.ts và 28-reviewer.ts dựng chế độ lập kế hoạch và một hội thoại review từ đó.

System prompt: section và entry hệ thống theo vị trí#

Prompt cache của provider là một tiền tố token khớp chính xác; đổi system prompt hay danh sách tool ở đầu request là trượt cache toàn bộ (bài "Prompt Caching In Agents"). Pi Durable xử lý việc này bằng cách không có state prompt bền nào. Trước mỗi request, các section của extension được chọn render ra prompt mong muốn; transcript chỉ lưu entry pi.system ở đúng vị trí của chúng, mỗi entry là một bản vá (patch) theo kiểu SystemMessage của pi-ai, và chỉ phần thay đổi mới được append (spec.md:3658–3662). Khái niệm system message giữa hội thoại của pi-ai được bàn ở chương Pi 1.0.

PromptInput và PromptSection
pi/packages/durable/src/harness/types.tsdòng 342–361TypeScript
/** Input to system prompt section rendering for one request preparation. */export type PromptInput<Tool extends ToolRegistration = ToolRegistration> = {	readonly conversationId: ConversationId;	/** The request's resolution; `agent.tools` are the tools offered in this request. */	readonly agent: Agent<Tool>;	/** Built by `HarnessOptions.env` for this preparation; `undefined` without an environment. */	readonly env: ExecutionEnv | undefined;	/** Sections already in effect after replaying the active transcript. */	readonly shown: Readonly<Record<string, string>>;	/** Committed document reads. */	readonly read: DocumentReader;};/** One system prompt section; the agent's sections render in order before each request. */export type PromptSection<Tool extends ToolRegistration = ToolRegistration> = {	readonly key: string;	render(input: PromptInput<Tool>, context: Context): string | undefined | Promise<string | undefined>;	/** Default true: wrap the text as `<key>\n...\n</key>`. */	readonly tag?: boolean;};
Section là hàm thuần của đầu vào: agent đã giải (kể cả tool được mời), môi trường dựng cho request này, các section đang hiển thị, và một bộ đọc document đã commit. Trả về undefined thì section bị bỏ.
renderSections(): render theo thứ tự, bọc thẻ, lỗi thì giữ chữ cũ
pi/packages/durable/src/harness/prompt.tsdòng 21–48TypeScript
/** * Render the agent's sections in order. `undefined` omits a section; tagged text is wrapped as `<key>\n...\n</key>`. A * section that throws keeps its shown text, if any, and is reported; errors after `context` is aborted propagate. */export async function renderSections<Tool extends ToolRegistration>(	sections: readonly PromptSection<Tool>[],	input: PromptInput<Tool>,	shown: ReadonlyMap<string, string>,	report: (error: unknown, section: PromptSection<Tool>) => void,	context: Context,): Promise<Map<string, string>> {	const desired = new Map<string, string>();	for (const section of sections) {		let text: string | undefined;		try {			text = await section.render(input, context);		} catch (error) {			if (context.abortSignal?.aborted) throw error;			report(error, section);			const kept = shown.get(section.key);			if (kept !== undefined) desired.set(section.key, kept);			continue;		}		if (text === undefined) continue;		desired.set(section.key, section.tag === false ? text : `<${section.key}>\n${text}\n</${section.key}>`);	}	return desired;}
Một section ném lỗi giữ nguyên chữ đang hiển thị (nếu có) và được báo, request vẫn được gửi; lỗi sau khi invocation bị huỷ thì lan ra ngoài. Instructions của agent luôn là section cuối, khoá instructions (agent.ts:268–270).

Rồi planSystemEntries() so prompt mong muốn với những gì phát lại (replay) từ các system message đang có trong ngữ cảnh:

planSystemEntries(): baseline sau head, cặp xoá/thêm khi đổi thứ tự, hoặc bản vá tối thiểu
pi/packages/durable/src/harness/prompt.tsdòng 54–93TypeScript
/** * Plan the `pi.system` entries that make the replayed sections and tools of `view` equal `desired` and `tools` in values * and order. * * - A head marker with no later `pi.system` entry in context: one complete baseline that omits every retained earlier *   `pi.system` entry, written even when it restates the replayed values. * - Otherwise, when a minimal section patch would leave a different order: remove every shown section, then re-add *   every desired section in order. * - Otherwise the minimal patch of changed values and `null` removals, or nothing. * * Tool changes ride on the last planned entry, or on one entry of their own. */export function planSystemEntries(	view: ContextView,	desired: ReadonlyMap<string, string>,	tools: readonly Tool[],	timestamp: number,): SystemDraft[] {	const head = view.head;	if (head !== undefined && !view.entries.some((entry) => SystemEntry.is(entry) && entry.id > head.id)) {		const edits: ContextEdit[] = view.entries			.filter((entry) => SystemEntry.is(entry))			.map((entry) => ({ target: entry.id, action: "omit" }));		const baseline = systemEntry(			Object.fromEntries(desired),			{ toolsRemoved: [], toolsAdded: tools.map(toToolDeclaration) },			timestamp,		);		return [edits.length === 0 ? baseline : { ...baseline, edits }];	}	const sections = planSections(replaySections(view.messages), desired);	const changes = planTools(getCurrentTools(view.messages), tools);	if (changes.toolsRemoved.length === 0 && changes.toolsAdded.length === 0) {		return sections.map((patch) => systemEntry(patch, undefined, timestamp));	}	if (sections.length === 0) return [systemEntry(undefined, changes, timestamp)];	return sections.map((patch, index) =>		systemEntry(patch, index === sections.length - 1 ? changes : undefined, timestamp),	);}
Mọi entry đều có content: "": mọi phần của prompt, kể cả lời mở đầu, là một section có tên (prompt.ts:144–158; spec.md:140–141).

Ba nhánh, theo thứ tự ưu tiên:

  1. Sau một head marker mà chưa có pi.system nào sau nó (vừa reset hay compaction): ghi một baseline đầy đủ mọi section và mọi tool, kèm chỉnh sửa omit cho mọi pi.system cũ còn sót trong phạm vi. Lý do: một SystemMessage luôn là bản vá, không thể tự xoá các bản vá trước nó (spec.md:3755–3766).
  2. Thứ tự section phải đổi: hai entry trong một commit, entry đầu xoá mọi section bằng null, entry sau thêm lại theo thứ tự mong muốn; nếu chỉ khẳng định lại giá trị thì phát lại sẽ không ra đúng thứ tự (spec.md:3748–3753).
  3. Còn lại: bản vá tối thiểu gồm giá trị đổi và null cho section bị bỏ; không đổi gì thì không có entry nào.

Thay đổi tool đi kèm entry cuối cùng (hoặc một entry riêng): khai báo được so bằng declarationsEqual() của pi-ai và ghi bằng toToolDeclaration(), nên metadata riêng của ứng dụng gắn trên tool không bao giờ lọt vào transcript (spec.md:3771–3778). Một tool thôi resolve được sẽ xuất hiện trong toolsRemoved của delta kế tiếp, và quay lại khi nó resolve được lần nữa.

Vì input của run được commit trước khi generation render prompt, entry pi.system đầu tiên nằm sau entry pi.user đầu tiên trong transcript. Bước dẫn xuất ngữ cảnh đưa baseline đó lên đầu request, vì provider chỉ coi system message đứng đầu là prompt và bộ tool ban đầu (spec.md:284–302); hai bản sửa lỗi ở 1.1.0 và 1.3.0 là về đúng chuyện này (CHANGELOG.md:123, CHANGELOG.md:56). Ví dụ 15-system-prompt.ts in ra các entry pi.system khi section, instructions và wrapper thay đổi.

Harness.open, HarnessOptions và Settings#

Harness.open(): kiểm registry, dựng HarnessImpl, hoà giải task còn sống
pi/packages/durable/src/harness/harness.tsdòng 538–563TypeScript
export const Harness = {	/** Open a Harness over storage. The registry may keep changing while the Harness runs. */	async open<Tool extends ToolRegistration>(		storage: Storage,		options: HarnessOptions<Tool>,		context: Context,	): Promise<Harness> {		context.abortSignal?.throwIfAborted();		const snapshot = options.registry.snapshot();		const missing = BUILTIN_TASKS.filter((task) => snapshot.task(task.definition.name) === undefined);		if (missing.length > 0) {			const names = missing.map((task) => task.definition.name).join(", ");			throw new Error(`Registry lacks built-in tasks ${names}; create it with createRegistry()`);		}		const harness = new HarnessImpl(storage, options, context);		try {			await harness.openTasks(context);		} catch (error) {			// The caller's context may be what failed open: close without it, and rethrow the open error.			// A failing Storage close fails the Session, which reports it.			await harness.close(withoutAbortSignal(context)).catch(() => {});			throw error;		}		return harness;	},};
openTasks() đổi các task còn ở running (tiến trình trước chết giữa chừng) về pending và đếm số lần khởi động lại; nó không chạy task nào. Scheduler chỉ bật khi host gọi resume() hoặc một lời gọi xin tiến triển như submit(), wait(), waitForTask(); bên xem chỉ đọc không bao giờ bật nó (spec.md:744–755). Nhờ vậy host có thể cài extension, gieo dữ liệu, rồi mới cho chạy.

Hội thoại gốc được tạo lười. Lần gọi root() đầu tiên tạo hội thoại ID 1, chạy hook tạo hội thoại, áp agent rồi chạy init, tất cả trong một commit; các lần sau, kể cả sau khi mở lại, chỉ trả về nó và bỏ qua cả hai tham số (spec.md:736–741):

#create(): root, hội thoại mới và fork dùng chung một commit
pi/packages/durable/src/harness/harness.tsdòng 401–418TypeScript
async #create(target: CreateTarget, options: CreateOptions, context: Context): Promise<Conversation> {	this.assertUsable();	const id = await this.commitWith(async (tx) => {		if (target.kind === "root" && (await tx.conversation(ROOT_CONVERSATION_ID)) !== undefined) {			return ROOT_CONVERSATION_ID;		}		const record =			target.kind === "root"				? await tx.createRootConversation()				: target.kind === "fork"					? await tx.forkConversation(target.parentId, target.at, { ownership: target.ownership })					: await tx.createConversation({ ownership: target.ownership });		if (options.agent !== undefined) await configure(tx, record.id, options.agent);		if (options.init !== undefined) await options.init(tx, record.id);		return record.id;	}, context);	return new ConversationImpl(id, this.#host);}
Handle trả về là ConversationImpl, chỉ giữ id và tham chiếu tới Harness (harness.ts:105–112). Handle thuộc về Harness đang mở; sau khi mở lại phải lấy lại (spec.md:1059–1060).

HarnessOptions#

HarnessOptions (1): telemetry, models, identity, registry, settings, env, conversationCreated
pi/packages/durable/src/harness/types.tsdòng 609–634TypeScript
export type HarnessOptions<Tool extends ToolRegistration = ToolRegistration> = {	/** Host-owned adapter for content-free admission/phase telemetry; propagation and metrics are optional. */	readonly telemetry?: TelemetryContext;	/**	 * pi-ai model access used by generation, compaction, hooks and tools: a `Models`, or a function returning the current	 * one, called at each use, so a host can replace its model configuration without reopening the Harness.	 */	readonly models: Models | (() => Models);	/**	 * How model requests identify the app to providers, merged field by field over `DEFAULT_HARNESS_IDENTITY`	 * (`{ attribution: PI_ATTRIBUTION }`, provider attribution to pi). It applies to every request through the Harness's	 * models, including hooks and tools, and overrides the identity the `Models` was created with; a request's own	 * `identity` overrides it. Pass `{ attribution: null }` to send no attribution headers.	 */	readonly identity?: ClientIdentity;	readonly registry: RegistryReader<Tool>;	/** Run settings, or a function returning the current ones; read at each use either way. */	readonly settings?: HarnessSettings | (() => HarnessSettings | undefined);	/** Builds a conversation's environment at each use. Never called on the Session line; may be async. */	readonly env?: (target: EnvTarget, context: Context) => ExecutionEnv | undefined | Promise<ExecutionEnv | undefined>;	/**	 * Runs in every commit that creates or forks a conversation, raw `tx.createConversation()` included, after the	 * built-in `pi.*` documents and before the conveniences apply `agent` and run `init`. A fork already has its copies.	 * Table reads throw `ReadAfterWrite`, as in `init`; a throw fails the creating commit.	 */	readonly conversationCreated?: (tx: Tx, conversation: ConversationRecord) => void | Promise<void>;
Phần còn lại là now, setTimer, onWake và onReport (harness/types.ts:635–663).
models, identity
Models của pi-ai (chương pi-ai), hoặc một hàm trả về nó, được gọi mỗi lần dùng. identity là một ClientIdentity được trộn lên DEFAULT_HARNESS_IDENTITY (ghi nhận nguồn là pi với OpenRouter, NVIDIA NIM, OpenCode) và áp cho mọi request đi qua Harness, kể cả từ hook và tool; { attribution: null } để không gửi gì (README.md:80; harness.ts:187–199).
telemetry
Một TelemetryContext của pi-telemetry do host sở hữu; bỏ trống thì không làm gì. Schema DURABLE_TELEMETRY_SCHEMA chỉ có ID và metadata điều khiển, không bao giờ có message, đối số hay kết quả (README.md:82–90). Chi tiết ở chương pi-env và telemetry.
env
Hàm dựng môi trường thực thi (ExecutionEnv) cho một hội thoại từ ID, cwd trong pi.agent và một bộ đọc document đã commit; không bao giờ được gọi trên dòng commit, được gọi lại mỗi lần dùng (harness.ts:279–285; spec.md:918–935). Môi trường Node và pi-env ở chương pi-env; cách tool dùng nó ở chương task.
conversationCreated
Chạy trong mọi commit tạo hoặc fork hội thoại, sau năm document dựng sẵn, để host tạo document của mình ở mọi hội thoại; đọc bảng ở đây ném ReadAfterWrite vì việc tạo hội thoại đã là một lần ghi bảng.
now, setTimer, onWake, onReport
Đồng hồ và bộ hẹn giờ của host (chạy được trên đồng hồ giả); onWake thay bộ hẹn giờ cho các task đang ngủ bằng báo thức của host, như alarm của Durable Object (mục Cloudflare ở chương storage); onReport nhận các lỗi không làm hỏng thao tác gọi, kèm ReportInfo cho biết chuyện gì xảy ra (types.ts:148–188).

Settings: đọc trực tiếp, không bao giờ lưu#

Chính sách chạy dùng chung cho mọi hội thoại (lựa chọn extension mặc định, tùy chọn stream, thử lại, ngưỡng compaction, nhịp commit tiến độ, cách chạy tool, chế độ hàng đợi, thời gian giữ bộ nhớ, giới hạn khởi động lại) đi qua settings. Nó được đọc lại ở mỗi lần dùng và không bao giờ được chép hay lưu, nên một object có getter hoặc một hàm làm cho cài đặt "sống" theo file cài đặt của người dùng mà không cần commit nào (README.md:403–425). Settings phải đồng bộ, vì chế độ hàng đợi được đọc ngay trên dòng commit.

resolveSettings(): mỗi trường trên giá trị mặc định, object thì trộn
pi/packages/durable/src/harness/agent.tsdòng 64–85TypeScript
/** Resolve the host settings: every field over its built-in default, object fields merged. */export function resolveSettings(settings: HarnessSettings | undefined): Settings {	const extensions = settings?.extensions;	return {		...(extensions === undefined ? {} : { extensions }),		stream: { ...settings?.stream },		retry: { ...DEFAULT_RETRY_POLICY, ...settings?.retry },		compaction: { ...DEFAULT_COMPACTION_POLICY, ...settings?.compaction },		// Field by field, so an explicitly undefined interval keeps its default.		progress: {			partialIntervalMs: settings?.progress?.partialIntervalMs ?? DEFAULT_PROGRESS_POLICY.partialIntervalMs,			outputIntervalMs: settings?.progress?.outputIntervalMs ?? DEFAULT_PROGRESS_POLICY.outputIntervalMs,		},		toolExecution: settings?.toolExecution ?? "parallel",		steeringMode: settings?.steeringMode ?? "one-at-a-time",		followUpMode: settings?.followUpMode ?? "one-at-a-time",		contextRetentionMs: settings?.contextRetentionMs ?? DEFAULT_CONTEXT_RETENTION_MS,		documentRetentionMs: documentRetention(settings?.documentRetentionMs),		afterFailedRun: settings?.afterFailedRun ?? "keep",		restartLimit: restartLimit(settings?.restartLimit),	};}
Mặc định: thử lại 3 lần, nền 2 giây; compaction giữ 16.384 token dự trữ, giữ nguyên khoảng 20.000 token gần nhất, chạy nền từ 32.768 token trước ngưỡng chặn; commit tiến độ mỗi 100 ms; tool chạy song song; lấy từng mục một khỏi hàng đợi; giữ ngữ cảnh và document rảnh 10 phút; afterFailedRun: "keep"; restartLimit 5 (agent.ts:20–37).

Hàm currentSettings() biến cả hai dạng thành một hàm gọi ở mỗi lần dùng (harness.ts:201–204); riêng documentRetentionMs được đọc ở mỗi commit (harness.ts:220–221). Đổi cài đặt không tạo commit nào và không phát sự kiện nào; một UI hiển thị mô hình hay tool đã giải phải gọi lại conversation.agent().

Quan sát: viewState, watch, watchEvents#

Mọi thứ một UI cần đều là state đã commit (README.md:476). Câu trả lời đang stream nằm trong pi.live.generation.message, output của tool đang chạy nằm trong slot của nó, hàng đợi nằm trong pi.inbox. Không có "kênh sự kiện" riêng nào chạy song song với storage. Vì vậy một client vào muộn, hay kết nối lại, chỉ cần lấy state hiện tại rồi nhận các thay đổi sau đó; không có gì phải phát lại. Đổi lại, tiến độ chỉ hiện ra theo nhịp commit: câu trả lời dở và output tool được commit nhiều nhất mỗi 100 ms theo mặc định, nên crash mất tối đa khoảng đó (README.md:502).

Có ba cách quan sát một hội thoại, cùng dựa trên một "mount" (bản dựng view):

ConversationView: bản ghi hội thoại, entry đang hoạt động, năm document dựng sẵn
pi/packages/durable/src/harness/view.tsdòng 25–32TypeScript
/** Structural mount of one conversation's active transcript and built-in documents (spec §9.3). */export type ConversationView = {	readonly conversation: ConversationRecord;	/** Raw active entries, as `ContextView.entries`: the head marker, then the non-head entries from its head. */	readonly entries: readonly EntryRecord[];	/** Built-in conversation documents keyed by kind; absent documents are absent. */	readonly docs: Readonly<Record<string, JsonObject>>;};
Kind và trường của năm document này là giao thức công khai; document của bên thứ ba không được mount mà được quan sát qua documentState() hay watchDoc() riêng (spec.md:4785–4786).
  1. viewState()Một state Chord chỉ đọc, có thể huỷ (AttachedReplicatedState<ConversationView>), cập nhật sau mỗi commit chạm tới hội thoại. Hợp cho UI cục bộ: view.subscribe(render).
  2. watch()Cùng view đó, giao từng frame một: giá trị bất biến cộng đúng các phép toán Chord của commit đã tạo ra nó, callback bất đồng bộ, không chồng nhau. Hợp để gửi ops cho một client từ xa áp lại (README.md:489–498).
  3. watchEvents()Bộ chuyển đổi thử nghiệm dịch các commit thành sự kiện có hình dạng như sự kiện của coding agent (message_start, tool_execution_update, ...), bắt đầu bằng một sự kiện snapshot.

Harness giữ nhiều nhất một mount cho mỗi hội thoại. Observer đầu tiên dựng nó trên dòng commit từ transcript và document đã commit; observer cuối cùng rời đi thì nó bị bỏ (view.ts:142–166). Mỗi revision tiếp theo được dẫn xuất từ publication của commit, không đọc lại storage:

advance(): dịch một publication thành phép toán trên view
pi/packages/durable/src/harness/view.tsdòng 196–232TypeScript
const docOps: Op[] = [];const entryOps: Op[] = [];let entries = mount.value.entries;// Entry writes are published in ID order.for (const change of publication.changes) {	if (change.type === "entry" && change.value.conversationId === id) {		const entry = change.value;		const value = entry as unknown as JsonValue;		if (entry.head === undefined) {			entryOps.push(["p", ["entries"], entries.length, 0, [value]]);			entries = [...entries, entry];			continue;		}		// A head marker keeps the non-head entries from its head, which are always a suffix, and goes in front.		const target = entry.head;		let kept = entries.findIndex((candidate) => candidate.head === undefined && candidate.id >= target);		if (kept < 0) kept = entries.length;		entryOps.push(["p", ["entries"], 0, kept, [value]]);		entries = [entry, ...entries.slice(kept)];		continue;	}	if (change.type !== "document" || change.conversationId !== id) continue;	const kind = change.record.kind;	if (!MOUNTED_KINDS.has(kind) || change.record.key !== undefined) continue;	const path: NonEmptyPath = ["docs", kind];	const mounted = mount.docs.get(kind);	if (change.value === null) {		if (mounted?.id !== change.record.id) continue;		mount.docs.delete(kind);		docOps.push(["d", path]);	} else if (mounted?.id === change.record.id && mounted.version === change.version) {		for (const op of change.ops) docOps.push(prefixed(op, path));	} else {		mount.docs.set(kind, { id: change.record.id, version: change.version! });		docOps.push(["s", path, change.value]);	}}
Entry không có head là một phép splice ở cuối entries. Một head marker được đặt lên đầu và các entry trước head bị cắt. Phép toán của document được dời xuống dưới ["docs", kind]; document mới tạo hay đổi version thì được đặt cả giá trị. Spec cho ví dụ ["s", ["generation", "message"], v] trở thành ["s", ["docs", "pi.live", "generation", "message"], v] (spec.md:4796–4799). Về bảy loại phép toán của Chord, xem chương Chord.

Áp lực ngược: quy tắc 100 frame#

Commit không bao giờ chờ người xem. Một watch chậm (hoặc chưa start()) giữ tối đa 100 frame chưa giao; frame thứ 101 thay toàn bộ phần chưa giao bằng một frame duy nhất chứa trọn view mới nhất dưới dạng phép thay gốc ["r", value]:

CommittedWatch.advance(): quá 100 frame thì gộp thành một phép thay gốc
pi/packages/durable/src/session/observation.tsdòng 226–237TypeScript
advance(value: T, ops: readonly Op[], context: Context): void {	if (this.#end !== undefined || this.#retired) return;	if (value === null) this.#retired = true;	if (this.#pending.length >= MAX_PENDING_WATCH_FRAMES) {		this.#pending.length = 0;		const replacement = this.#replace?.() ?? value;		this.#pending.push({ value: replacement, ops: [["r", replacement as JsonValue]], context });	} else {		this.#pending.push({ value, ops, context });	}	if (this.#started) this.#schedule();}
Giới hạn là hằng MAX_PENDING_WATCH_FRAMES = 100 (observation.ts:16). Không đo byte, không sao chép, không diff lại. Đây là quan sát hội tụ (convergent), không phải nhật ký kiểm toán: ai cần mọi chuyển trạng thái phải tự lưu chúng vào entry (spec.md:4742–4749).

watchEvents: sự kiện kiểu coding agent#

AgentEvent: snapshot cộng các sự kiện áp lên trên nó
pi/packages/durable/src/harness/events.tsdòng 60–99TypeScript
/** Experimental agent event, shaped like the coding agent's session events (spec §9.4). */export type AgentEvent =	| SnapshotEvent	| { type: "run_start"; inputs: readonly SubmissionId[] }	| { type: "run_end"; inputs: readonly SubmissionId[] }	| { type: "turn_start" }	| { type: "turn_end" }	| { type: "message_start"; message: Message }	/** `usage` is the partial's current usage, as in the coding agent's JSON mode. */	| { type: "message_update"; usage: Usage; changes: readonly MessageChange[] }	| { type: "message_end"; entry: EntryRecord }	| ({ type: "tool_execution_start"; args: JsonObject } & ToolEventCall)	| ({			type: "tool_execution_update";			/** A front trim and then an append of the retained window, or its replacement. */			output?: { trimStart?: number; append?: string } | { set: string };			details?: JsonValue;			diagnostics?: readonly ToolDiagnostic[];	  } & ToolEventCall)	/**	 * A model-issued call ends with its result `entry`, a nested call with its `result`. Both are absent when the tool	 * task faulted or was orphaned, or when the call's slot left `pi.live` unfinished: its run ended, or its parent	 * settled first.	 */	| ({ type: "tool_execution_end"; entry?: EntryRecord; result?: NestedToolExecutionResult } & ToolEventCall)	| { type: "inbox_update"; items: readonly QueuedItem[] }	| { type: "submission"; record: SubmissionRecord }	| { type: "auto_retry_start"; attempt: number; at: number; errorMessage: string }	| { type: "auto_retry_end"; attempt: number }	| { type: "deferred_poll"; pollAt: number }	| { type: "entry_appended"; entry: EntryRecord }	| { type: "agent_changed"; agent: AgentState }	| { type: "usage_changed"; usage: UsageState }	| { type: "task_failed"; taskId: TaskId; kind: string; message: string }	| { type: "compaction_start"; taskId: TaskId; reason: CompactionReason; blocking: boolean }	/**	 * The task's receipt tells whether it produced a summary; the summary entry has its own events. `error` is set when	 * the compaction failed, faulted, or was orphaned, as in `pi.live.compactionFailure`.	 */	| { type: "compaction_end"; taskId: TaskId; reason: CompactionReason; error?: string };

Mỗi commit sinh một lô (batch) sự kiện, theo một thứ tự cố định: bắt đầu tool, message dở, cập nhật tool, thử lại; rồi entry theo thứ tự append (kết thúc của một tool đứng ngay trước message_start của kết quả của nó, như coding agent); rồi kết thúc compaction, lỗi task, turn_end, run_end; rồi submission; rồi inbox, agent, usage; cuối cùng compaction_start, run_start, turn_start (spec.md:4970–4983). message_update chỉ mang delta: phần text nối thêm, phần đối số tool nối thêm, nên partial được commit dày cũng không nhân dữ liệu trên đường truyền.

watchEvents(): bám vào cùng mount, dịch từng publication
pi/packages/durable/src/harness/events.tsdòng 171–199TypeScript
const views = conversationViews(harness);let watch!: CommittedWatch<readonly AgentEvent[]>;let snapshot!: SnapshotEvent;await views.attach(	conversationId,	async (initial, release, storage) => {		// Generations whose held outcome already ended their turn, read on the line with the snapshot.		const query = { conversationId, kind: "pi.generation", status: "completing" } as const;		const completing = await scanAll((cursor) => storage.scanTasks(query, 100, cursor, context));		const held = new Set<TaskId>(completing.map((record) => record.id));		let current = initial;		snapshot = snapshotOf(initial);		// Batches are the watch's values; an overflow delivers a snapshot of the newest view instead.		watch = new CommittedWatch<readonly AgentEvent[]>(			[],			release,			(error) => views.report(error),			() => [snapshotOf(current)],		);		return {			publication: (before, after, ops, publication, commitContext) => {				current = after;				const events = translate(conversationId, before, after, ops, publication, held);				if (events.length > 0) watch.advance(events, [], commitContext);			},			closeSession: (failure) => watch.closeSession(failure),		};	},	context,
Lô tràn cũng theo quy tắc 100: tham số thứ tư của CommittedWatch thay các lô chưa giao bằng một snapshot của state mới nhất. Spec gọi bộ chuyển đổi này là thử nghiệm, chỉ phủ một hội thoại (không phủ cây hội thoại nó sở hữu), và giao thức có thể đổi không báo trước (spec.md:4823–4828).

Ví dụ 21-late-join.ts gắn một view và một luồng sự kiện vào giữa một run; 19-json.ts in toàn bộ luồng của một run dưới dạng sự kiện (--events) hoặc phép toán thô (--ops), như chế độ JSON của coding agent (chương Các chế độ chạy). Để quan sát một document riêng lẻ, dùng harness.documentState() hoặc harness.watchDoc(); cơ chế bên dưới được mổ xẻ ở chương storage.

Lần theo một input được trả lời#

README tóm vòng đời của một input bằng năm dòng:

README: một input được trả lời, dưới dạng entry và task
pi/packages/durable/README.mddòng 106–113Markdown
One answered input, as entries and tasks:```textsubmit(input) → pi.user  pi.generation → pi.system (only if the prompt or tools changed), pi.assistant (tool calls)    pi.tool × n → pi.tool-result × n   (owned by the generation, which waits for them)  pi.generation → pi.assistant (answer) → submission done```

Phần này mở rộng năm dòng đó thành từng commit, với code thật ở mỗi bước. Kịch bản: hội thoại gốc đang rảnh, đã cấu hình mô hình, registry có CodingTools (bốn tool read, write, edit, bash, không có section, tools/index.ts:31–39) và một extension có section preamble như ví dụ Coding của README (README.md:232–236). Người dùng gửi "Đọc notes.txt rồi tóm tắt". Mô hình gọi read một lần, nhận nội dung file, rồi trả lời. Phần bên trong task (reservation, wait, thử lại) chỉ được lướt qua; chương task đi sâu vào chúng.

Ví dụ minh hoạ: ID giả định storage mới, root() vừa tạo hội thoại 1 và năm document 2–6
(pi.live = 2, pi.inbox = 3, pi.usage = 4, pi.provider = 5, pi.agent = 6).

 #   ai commit          ghi gì                                                  sự kiện watchEvents
 C1  Submissions        entry 7 pi.user · submission 8 placed                    message_start/end(7)
                        task 9 pi.generation pending {prepare, attempt 1}        submission(8)
                        pi.live.run = {taskId 9, inputs [8]}                     run_start([8]) turn_start
 C2  scheduler          task 9 pending → running                                 (không có)
 C3  generation 9       entry 10 pi.system (baseline: section + toolsAdded)      message_start/end(10)
     (prepare)          task 9 checkpoint {request, model, cutoff 10}
 C4  generation 9       pi.live.generation = {attempt 1}                         (không có)
     (request)          ... các commit partial, mỗi ~100 ms ...                  message_start, message_update...
 C5  generation 9       entry 11 pi.assistant (toolCall read) · pi.usage         message_end(11) usage_changed
                        task 12 pi.tool pending, chủ là task 9
                        pi.live.tools = [read: pending] · bỏ generation
                        task 9 waiting on [12] (allSettled)
 C6  scheduler          task 12 pending → running                                (không có)
 C7  tool 12 (call)     pi.live.tools[0] running · checkpoint {execute, args}     tool_execution_start
                        ... execute(), output dở được commit theo nhịp ...       tool_execution_update...
 C8  tool 12            entry 13 pi.tool-result · slot done, entry 13             tool_execution_end
                        task 12 terminal {kind model, entryId 13}                message_start/end(13)
 C9  scheduler          task 9 waiting → running (phase tools)                   (không có)
 C10 generation 9       bỏ pi.live.tools · task 14 pi.generation pending          turn_end turn_start
     (tools)            run.taskId 9 → 14 · task 9 terminal {entryId 11}
 C11 scheduler          task 14 pending → running                                (không có)
 C12 generation 14      prompt và tool không đổi: không có pi.system mới          (không có)
     (prepare)          task 14 checkpoint {request, cutoff 13}
 C13 generation 14      pi.live.generation = {attempt 1}, rồi các partial         message_start, message_update...
 C14 generation 14      entry 15 pi.assistant (câu trả lời) · pi.usage            message_end(15) turn_end
     (answer)           submission 8 done {entry 7, answer 15}                   run_end([8]) submission(8)
                        bỏ pi.live.run, generation · task 14 terminal            usage_changed
     → Submission.wait() trả về {status "done", entry 7, answer 15}
Mỗi dòng là một commit nguyên tử. Transcript cuối cùng: 7 pi.user, 10 pi.system, 11 pi.assistant (lời gọi tool), 13 pi.tool-result, 15 pi.assistant. Thứ tự sự kiện trong một lô theo spec.md:4970–4979; message_start của câu trả lời chỉ xuất hiện riêng khi đã có partial được commit trước đó.

C1: admission tạo input, run và generation đầu tiên#

Đã xem ở mục Submission: hội thoại rảnh và inbox rỗng nên admitSubmission() append entry pi.user, tạo submission placed rồi gọi startRun(), vốn tạo task pi.generation thuộc hội thoại và đặt pi.live.run (generation.ts:752–769). Bốn bản ghi này lưu cùng nhau hoặc không lưu gì. Nếu tiến trình chết ngay sau C1, lần mở sau thấy một task pending và chạy nó; host gửi lại với cùng requestId thì nhận lại submission 8.

C2: scheduler giữ chỗ cho task#

Scheduler theo dõi các commit, thấy task 9 có thể chạy, và trong một commit của chính nó đổi trạng thái sang running rồi mới gọi handler của phase (scheduler.ts:1540–1547). Trạng thái running được lưu là để lần mở sau biết task này đã bị cắt ngang và đếm số lần khởi động lại.

C3: prepare ghi system prompt theo vị trí#

Phase prepare: render section, lập kế hoạch entry pi.system, chuyển sang request
pi/packages/durable/src/harness/generation.tsdòng 155–176, 190–207TypeScript
const view = await runtime.context(conversationId, context);const shown = replaySections(view.messages);let env: ExecutionEnv | undefined;try {	env = await runtime.env(context);} catch (error) {	if (context.abortSignal?.aborted) throw error;	runtime.report(error, { type: "host", callback: "env" });}const input: PromptInput = { conversationId, agent, env, shown: Object.fromEntries(shown), read: runtime };const desired = await renderSections(	agent.sections,	input,	shown,	(error, section) =>		runtime.report(error, {			type: "extension",			...optionalExtension(sectionExtension(agent, section.key)),		}),	context,);const entries = planSystemEntries(view, desired, agent.tools, runtime.now());await runtime.commit(async (tx) => {	let cutoff = (await tx.scanEntries({ conversationId }, 1)).items[0]?.id;	for (const entry of entries) cutoff = (await tx.appendEntry(SystemEntry, conversationId, entry)).id;	if (cutoff === undefined) throw new Error(`Conversation ${conversationId} has no entries to send`);	// Checked in this commit, so a compaction admitted during preparation counts.	if (threshold === "background" && (await tx.doc(LiveDoc, conversationId)).compactions === undefined) {		await createCompaction(tx, conversationId, { reason: "threshold" });	}	const request = {		attempt,		...(compacted === undefined ? {} : { compacted }),		model,		thinkingLevel,		streamOptions: settings.stream,		cutoff,	};	return { status: "running", checkpoint: { phase: "request", ...request } };}, context);
cutoff là entry mới nhất mà request sẽ thấy. Mô hình, mức suy nghĩ và tùy chọn stream được cố định trong checkpoint request, nên một lần gửi lại sau khôi phục dùng đúng chúng. Phần bị lược là kiểm tra ngưỡng compaction (generation.ts:177–189).

C4: request, stream và partial#

Phase request commit pi.live.generation = { attempt }, lấy ngữ cảnh tại cutoff, chạy hook beforeRequest, rồi gọi mô hình với sessionId lấy từ pi.provider (generation.ts:209–236). Trong lúc stream, partial được commit vào pi.live.generation.message với nhiều nhất một commit đang bay (generation.ts:441–498). Đầu phase, convertPartial() biến một partial còn sót từ lần thử bị cắt ngang thành entry pi.assistant có stop reason aborted (generation.ts:430–439): đó chính là "phần trả lời dở được giữ và đánh dấu bị huỷ" trong bài công bố.

C5: câu trả lời gọi tool mở một vòng tool#

startToolRound(): append câu trả lời, tạo task tool do generation sở hữu, chờ chúng
pi/packages/durable/src/harness/generation.tsdòng 648–674TypeScript
await runtime.commit(async (tx): Promise<Next> => {	const live = await tx.doc(LiveDoc, conversationId);	const entry = await appendAssistant(tx, conversationId, message);	const slots: ToolSlot[] = [];	const tools: TaskId<ToolTaskResult>[] = [];	const pending: string[] = [];	for (const call of calls) {		if (!offered.has(call.name)) {			const unavailable = harnessError("tool_unavailable", `Tool ${call.name} is not available`);			const result = await appendToolResult(tx, conversationId, call, unavailable, { timestamp: runtime.now() });			slots.push({ callId: call.id, name: call.name, status: "done", entry: result.id });			continue;		}		if (sequential && tools.length > 0) {			pending.push(call.id);			slots.push({ callId: call.id, name: call.name, status: "pending" });			continue;		}		const taskId = await createToolTask(tx, runtime, entry.id, call.id);		tools.push(taskId);		slots.push({ callId: call.id, name: call.name, taskId, status: "pending" });	}	delete live.generation;	live.tools = slots;	const checkpoint = { phase: "tools", assistant: entry.id, tools, pending } as const;	return { status: "waiting", checkpoint, on: tools, policy: "allSettled" };}, context);
Lời gọi tới một tool không có trong request nhận ngay kết quả tool_unavailable. Mọi lời gọi khác có một task pi.tool mà chủ là generation (ownership: { kind: "task" }, generation.ts:330–337). Generation không chạy code nào trong lúc waiting, và vẫn giữ pi.live.run.

C7 và C8: tool ghi ý định, chạy, rồi ghi kết quả#

Commit ý định: slot running và checkpoint execute, rồi mới execute()
pi/packages/durable/src/harness/tool.tsdòng 168–178TypeScript
await runtime.commit(async (tx) => {	const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId);	if (slot !== undefined) slot.status = "running";	// A nested slot shows the arguments the call runs with, which repair, hooks, and coercion may have changed.	// Compared with the input, which the slot got at admission: reading the draft would track every leaf.	const changed = task.input.kind === "nested" && !jsonEqual(task.input.call.arguments as JsonValue, final);	if (changed && slot !== undefined && "parentTaskId" in slot) slot.arguments = final;	const intent = { phase: "execute", arguments: final, replay: tool.replay ?? "unsafe" } as const;	return { status: "running", checkpoint: intent };}, context);await run(runtime, task.input, call, tool, final, context);
Ý định (đối số cuối cùng và chính sách replay) được lưu trước khi tool chạy. Nếu tiến trình chết sau commit này, lần mở sau vào phase execute: tool chạy lại chỉ khi cả chính sách đã lưu lẫn chính sách hiện tại là safe; nếu không, mô hình nhận kết quả lỗi interrupted kèm output đã commit (tool.ts:180–198).
settle(): entry pi.tool-result, slot done, task terminal trong một commit
pi/packages/durable/src/harness/tool.tsdòng 675–686, 696TypeScript
	const timing = { timestamp: runtime.now(), durationMs };	const entry = await appendToolResult(tx, conversationId, call, result, timing);	if (slot !== undefined && !("parentTaskId" in slot)) {		finishSlot(slot);		slot.entry = entry.id;	}	// Tools build control objects freely; drop keys set to undefined so the task result is strict JSON.	const control =		result.control === undefined || ending.status !== "completed"			? {}			: { control: copyJson(result.control as JsonValue, { omitUndefinedProperties: true }) as ToolControl };	settled = { kind: "model", entryId: entry.id, ...control };return { status: "terminal", outcome: { status: "completed", result: settled } };
appendToolResult() ghi entry có data.diagnostics và cộng usage của tool vào pi.usage trong cùng commit (tool.ts:885–913).

C10: hết vòng tool, giao run cho generation kế tiếp#

finishToolRound(): boundary postTools rồi handOver sang generation mới
pi/packages/durable/src/harness/generation.tsdòng 708–713, 722–736TypeScript
await runtime.commit(async (tx): Promise<Next> => {	const boundary = await prepareBoundary(tx, conversationId, runtime.settings);	if (added.length > 0) await addTools(tx, conversationId, added);	const live = await tx.doc(LiveDoc, conversationId);	const now = runtime.now();	if (terminate || handoff !== undefined) {	} else {		const { users, reset } = await applyBoundary(tx, boundary, "postTools", now);		if (reset) {			// The queued reset cut the run's context before an answer.			endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "reset" });			if (users.length > 0) await startRun(tx, conversationId, live, users);		} else {			delete live.tools;			delete live.nestedTools;			if (live.run?.taskId === runtime.taskId) live.run.inputs.push(...users);			handOver(live, runtime.taskId, await createGeneration(tx, conversationId));		}	}	return { status: "terminal", outcome: { status: "completed", result: { entryId: assistant } } };}, context);
Boundary postTools là nơi tin nhắn steer đang chờ được đặt vào transcript và nhập vào run hiện tại (live.run.inputs.push). Generation 9 kết thúc completed; quyền điều khiển run chuyển sang generation 14 mà không có lúc nào hội thoại "rảnh" (generation.ts:771–774).

Generation 14 lặp lại prepare; lần này prompt và tool không đổi nên planSystemEntries() trả về mảng rỗng và không có entry pi.system mới: prompt cache của provider vẫn ấm.

C14: câu trả lời cuối chốt submission#

answer(): append câu trả lời, boundary final, endRun, có thể mở run kế tiếp
pi/packages/durable/src/harness/generation.tsdòng 604–621TypeScript
await runtime.commit(async (tx): Promise<Next> => {	// Queue modes are read on the Session line, when the boundary is decided.	const boundary = await prepareBoundary(tx, conversationId, runtime.settings);	const live = await tx.doc(LiveDoc, conversationId);	const entry = await appendAssistant(tx, conversationId, message);	const result: Next = { status: "terminal", outcome: { status: "completed", result: { entryId: entry.id } } };	const { users, reset } = await applyBoundary(tx, boundary, "final", runtime.now());	if (continuation !== undefined && users.length === 0 && !reset) {		const user = { role: "user", content: continuation, timestamp: runtime.now() } as const;		await tx.appendEntry(UserEntry, conversationId, { model: [user] });		handOver(live, runtime.taskId, await createGeneration(tx, conversationId));		delete live.generation;		return result;	}	endRun(tx, live, runtime.taskId, { status: "done", answer: entry.id });	if (users.length > 0) await startRun(tx, conversationId, live, users);	return result;}, context);
Boundary được chuẩn bị trước lần ghi bảng đầu tiên vì nó đọc head marker (quy tắc đọc trước ghi sau). Nếu một follow-up đang chờ, applyBoundary(..., "final") đặt nó và startRun() mở run kế tiếp ngay trong commit này.
endRun(): chốt đúng các input của run, rồi dọn presentation
pi/packages/durable/src/harness/live.tsdòng 134–146TypeScript
/** * End the run owned by `taskId`: settle each of its inputs and remove `run`. Always removes `generation`, `tools`, and * `nestedTools`, whose presentation belongs to the ending run. */export function endRun(tx: Tx, live: Draft<LiveState>, taskId: TaskId, settlement: SubmissionSettlement): void {	if (live.run?.taskId === taskId) {		for (const id of live.run.inputs) tx.settleSubmission(id, settlement);		delete live.run;	}	delete live.generation;	delete live.tools;	delete live.nestedTools;}

Commit C14 chứa bản ghi submission 8 ở trạng thái done. Bộ quan sát commit của Submissions thấy nó trong publication và giải phóng mọi người đang wait() (submissions.ts:145–150). Host đọc câu trả lời bằng tx.entry(AssistantEntry, settled.answer), như Quick Start (README.md:62–67). Ví dụ chạy được gần nhất với trace này là 17-coding-tools.ts: một lượt dùng tool trên JSONL, để lại thư mục storage để bạn mở ra xem.

API công khai và các ví dụ#

Gốc package (@earendil-works/pi-durable) xuất mọi thứ cần để dựng một harness; phần đầu của index.ts:

index.ts: định nghĩa document và entry, lỗi, agent, compaction, các hàm define
pi/packages/durable/src/index.tsdòng 1–26TypeScript
export { defineDoc, defineDocFamily } from "./documents.ts";export {	AssistantEntry,	CompactionEntry,	defineEntry,	ResetEntry,	SystemEntry,	ToolResultEntry,	UserEntry,} from "./entries.ts";export { ConversationBusy, ReadAfterWrite, SessionFailed, StorageRequestError } from "./errors.ts";export {	AgentDoc,	configure,	DEFAULT_COMPACTION_POLICY,	DEFAULT_PROGRESS_POLICY,	DEFAULT_RESTART_LIMIT,	DEFAULT_RETRY_POLICY,} from "./harness/agent.ts";export {	type CompactionCheckpoint,	type CompactionInput,	CompactionTask,	type SummaryRequest,} from "./harness/compaction.ts";export { defineExtension, defineTool, hook, section, wrapSection, wrapTool } from "./harness/define.ts";
NhómGiá trị xuất raỞ đâu
Mở và chạyHarness, DEFAULT_HARNESS_IDENTITY, createRegistry, createSession, MemoryStorage, ROOT_CONVERSATION_IDindex.ts:41, :53, :126–127, :216
Định nghĩadefineDoc, defineDocFamily, defineEntry, defineExtension, defineTool, hook, section, wrapTool, wrapSection, defineTaskindex.ts:26, :128
Entry dựng sẵnUserEntry, AssistantEntry, SystemEntry, ToolResultEntry, ResetEntry, CompactionEntryindex.ts:2–10
Document dựng sẵnAgentDoc, LiveDoc, InboxDoc, ProviderDoc, UsageDoc, NestedCallDocindex.ts:42–52, :124
Task dựng sẵn (để đăng ký hook)GenerationTask, ToolTask, CompactionTaskindex.ts:35–40, :57–64
Cấu hìnhconfigure, DEFAULT_RETRY_POLICY, DEFAULT_COMPACTION_POLICY, DEFAULT_PROGRESS_POLICY, DEFAULT_RESTART_LIMITindex.ts:12–19
Quan sát, telemetrywatchEvents (thử nghiệm), DURABLE_TELEMETRY_SCHEMAindex.ts:27–34, :56
LỗiConversationBusy, ReadAfterWrite, SessionFailed, StorageRequestErrorindex.ts:11
KiểuBề mặt Harness (Conversation, Submission, Agent, HarnessOptions, ...) và bề mặt Session/storage (Tx, Storage, EntryRecord, TaskRecord, ...)index.ts:65–123, :129–215

Phần còn lại tách thành các subpath để không kéo phụ thuộc không cần (package.json:8–111): /tools (read, write, edit, bash, CodingTools), /codemode, /tool-search, /env và /env/node, /images (và /images/node, /images/cloudflare), các backend /storage/memory, /storage/indexeddb, /storage/jsonl(/node), /storage/sqlite(/node, /cloudflare), và /testing. Gốc package tải TypeBox vì task tool kiểm tra đối số; README ước khoảng 23 MB RSS đỉnh khi không bundle, khoảng 4 MB khi bundle có tree-shaking (README.md:817).

Các ví dụ trong test/examples#

Ba mươi hai ví dụ chạy được bằng node --conditions=source --experimental-strip-types test/examples/<file> từ thư mục package (README.md:839–843); đa số dùng provider giả (faux) của pi-ai, vài ví dụ dùng OpenAI khi có OPENAI_API_KEY. Mười bốn ví dụ đầu dạy từng tầng, từ dưới lên:

Ví dụDạy gì
00-conversationSession trần trên MemoryStorage; mọi lần ghi nằm trong commit(); hội thoại ownerless
01-documentsDocument rewindable; snapshot() và snapshotAsOf() tại từng entry
02-forksFork tại một entry: thấy entry của cha tới đó; document asOf được sao chép độc lập
03-owned-conversationsTask nền sở hữu một hội thoại con; task, hội thoại và document registry trong một commit
04-chord-statedocumentState(): state Chord chỉ đọc, không bao giờ tạo document
05-watcheswatchDoc(): revision gốc rồi frame tuần tự, quy tắc 100 frame
06-harnessHarness với registry; root() với agent và init; agent lưu tên, giải khi đọc
07-configurationconfigure(), settings có getter, metadata riêng trên tool, gỡ rồi cài lại extension
08-harness-conversationsHandle không giữ state, entry có kiểu, createConversation() và fork() với agent
09-contextDẫn xuất ngữ cảnh: head, edit, message bị loại, thứ tự kết quả tool, kết quả tổng hợp
10-registry-reloadThay extension tại chỗ, wrapper theo tên, gỡ
11-extension-stateExtension có document riêng: tool ghi, section hiển thị cho mô hình
12-tasksTask tự định nghĩa: lưu ý định, làm, lưu kết quả
13-recoveryĐóng giữa chừng trên SQLite, mở lại, task chạy tiếp; memo chống lặp hiệu ứng; sleep bền

Mười tám ví dụ sau (14 đến 31) là code kiểu sản phẩm, có bảng mô tả riêng trong README (README.md:845–864): một câu hỏi và câu trả lời (14), system prompt (15), mô hình thật (16), tool lập trình trên JSONL (17), chế độ in (18) và JSON (19) như pi -p và pi --mode json, inbox (20), vào muộn (21), subagent tiền cảnh (22) và subagent nền bền qua khởi động lại (23), task con (24), compaction (25), một coding agent nhỏ (26), plan mode (27), reviewer (28), sandbox theo hội thoại (29), thay tool cho một số hội thoại (30), và reload rồi khởi động lại (31).

Quyết định thiết kế#

Lưu trước, hiển thị sau, không có ngoại lệ#

Bất biến số 3 của đặc tả: "All visible progress is durable. There is no volatile publication path" (spec.md:67), và "visible-undurable publication" đứng trong danh sách không làm (spec.md:5606–5618). Lý do là các mục tiêu nhiều người dùng và vào muộn: nếu UI được phép thấy thứ chưa lưu, một client kết nối lại sau crash có thể thấy "quá khứ bị viết lại". Khi mọi thứ hiển thị đều đã lưu, kết nối lại chỉ là lấy state hiện tại. Cái giá là tiến độ đi theo nhịp commit, và mỗi token stream ra đều thành một lần ghi; Pi Durable trả giá đó bằng throttle 100 ms, delta của Chord và chính sách checkpoint của pi.live để chuỗi delta luôn ngắn.

Mọi thứ harness chạy là một task#

Bài công bố viết: "Everything the harness runs, from calling the model to executing a tool, is a task." Vòng lặp của pi-agent-core (chương agent-core) là một hàm async giữ state trong bộ nhớ: chết là mất. Ở đây mỗi bước là một phase của một máy trạng thái, và state cần để làm tiếp nằm trong checkpoint đã commit. Generation sở hữu các task tool của vòng nó; hội thoại sở hữu generation; huỷ chạy từ dưới lên. Thiết kế này cho khôi phục từ checkpoint cuối, nhưng buộc mọi hiệu ứng ngoài phải nghĩ về chạy lại: tool phải khai báo replay, task phải "lưu ý định, làm, lưu kết quả" (12-tasks.ts:10–14). Tên mã cũ của harness, Pico, vẫn còn trong vài chú thích (sqlite/storage.ts:151).

Lưu tên, không lưu code#

pi.agent chỉ chứa tên extension và tên tool; registry nói tiến trình này chạy được gì lúc này (spec.md:843–848). Nhờ vậy reload không cần migration và một hội thoại cũ mở được trong một tiến trình thiếu vài extension: tên chưa cài bị bỏ qua khi giải, và có hiệu lực lại khi được cài. Cái giá: không có gì báo lỗi khi một extension được chọn mà không có mặt, nên một extension bảo vệ (permission hook) vắng mặt nghĩa là chạy không có bảo vệ.

Prompt là bản vá theo vị trí#

Thay vì dựng lại system prompt mỗi request, transcript giữ đúng những gì mô hình đã thấy, ở đúng vị trí, và chỉ append phần khác. Lựa chọn này phục vụ prompt cache và tính di chuyển giữa provider: chuỗi section đã render là bản ghi có thẩm quyền, kể cả khi renderer sau này thay đổi (spec.md:3780–3784). Cái giá: section phải tất định, và đổi thứ tự tốn hai entry.

Checkpoint là chính sách của định nghĩa, không phải heuristic của backend#

Đặc tả cấm storage tự đếm byte hay tự chèn checkpoint, và coi một định nghĩa không bao giờ checkpoint là lỗi của định nghĩa (spec.md:1569–1576); "automatic checkpoint heuristic" nằm trong danh sách không làm (spec.md:5612). Người định nghĩa document biết nó thay đổi thế nào: pi.live chọn "khi rảnh", pi.inbox chọn "khi rỗng". Bản 1.4.0 bỏ giá trị mặc định để buộc mỗi định nghĩa trả lời câu hỏi đó. Cơ chế base và delta được mổ xẻ ở chương storage.

Sự kiện là bộ chuyển đổi, không phải nguồn sự thật#

Không có nhật ký sự kiện ngữ nghĩa trong lõi Session (spec.md:5610). watchEvents() dẫn xuất sự kiện từ commit và có thể bỏ qua chuyển trạng thái trung gian khi tràn; kết quả chính xác của một input luôn đến từ Submission.wait(). Một TUI vẽ từ ConversationView, sự kiện chỉ dùng cho hiệu ứng chuyển cảnh (spec.md:4986–4987).

Một tiến trình sở hữu một storage#

Không có khoá liên tiến trình (README.md:790), và trộn CRDT hay nhiều người ghi ngoại tuyến không nằm trong phạm vi. "Nhiều người cùng điều khiển một agent" nghĩa là nhiều client nói chuyện với một tiến trình sở hữu storage, như một Durable Object cho mỗi session trong bài "Pi Durable on Cloudflare".

Cạm bẫy và trường hợp biên#

  • Append entry thô vào hội thoại đang bận. Chỉ Harness được ghi ngữ cảnh mô hình vào một hội thoại có run; một entry chen vào lúc generation đang chuẩn bị có thể làm lệch entry pi.system của nó. Dùng write submission (spec.md:5470–5473).
  • Input gửi giữa run mặc định là follow-up. Nó chờ tới khi run trả lời mới được đặt. Muốn chen vào vòng tool hiện tại thì whenBusy: "steer".
  • Write chỉ để hiển thị vượt lên trước. Nó được append ngay, nên có thể đứng trước các mục gửi sớm hơn còn đang chờ, trừ khi có reset đang chờ (README.md:517).
  • Hàng đợi sau một run thất bại. Mặc định các follow-up nằm lại trong pi.inbox, và wait() của chúng không trả về, cho tới khi có input mới hoặc write xếp hàng chạy boundary (spec.md:5474–5480).
  • Huỷ wait() không rút submission. Rút là submission.abort(), và chỉ khi còn queued.
  • root() chỉ áp agent và init một lần. Lần gọi sau, kể cả sau khi đổi code, bỏ qua chúng; muốn đổi thì configure().
  • Lựa chọn và bản sao. { add, remove } luôn sửa lựa chọn mặc định của host; ghi nó vào một subagent đã chép lựa chọn từ chủ sẽ thay lựa chọn đã chép. Bản sao pi.agent của hội thoại do task sở hữu là bản sao một lần (spec.md:5571–5581).
  • Watch hội tụ, không kiểm toán. Quá 100 frame thì các trạng thái trung gian bị gộp. Cần mọi chuyển trạng thái thì ghi chúng thành entry (spec.md:5551–5555).
  • Handle không sống qua lần mở lại. Conversation, Submission thuộc Harness đã trả chúng; sau khi mở lại, lấy lại bằng harness.conversation(id) và harness.submission(id).
  • Đặt tên với tiền tố pi. Không bị chặn (trừ kind của input), nhưng va chạm với hành vi dựng sẵn, ví dụ một entry tự đặt kind pi.system sẽ bị phát lại như system message.

Đọc code ở đâu#

Mọi đường dẫn tính từ packages/durable/. Thứ tự dưới đây đi từ khái niệm tới hiện thực.

#FileĐọc để hiểu
1README.mdQuick Start, Concepts, rồi các mục Busy Conversations, Watching, Errors
2docs/spec.md §1–2Bất biến, bản ghi lõi, bề mặt công khai của Harness (đặc tả là chuẩn, code thắng khi lệch)
3src/types.tsMọi bản ghi: ID, hội thoại, entry, submission, task, document, Tx, Session, Storage
4src/entries.ts, src/documents.tsToken entry và document
5src/harness/types.tsBề mặt Harness: Conversation, Submission, Extension, AgentState, HarnessOptions, hook
6src/harness/harness.tsHarness.open, #create, hook tạo hội thoại, handle
7submissions.ts, inbox.tsAdmission, boundary, wait, abort
8live.ts, provider.ts, usage.tsDocument dựng sẵn và hàm tiện ích của chúng (endRun, recordUsage)
9registry.ts, define.ts, agent.tsRegistry, giải agent, settings
10prompt.ts, context.tsEntry pi.system theo vị trí, dẫn xuất ngữ cảnh
11view.ts, events.tsMount view và bộ chuyển đổi sự kiện
12generation.ts, tool.tsChỉ các commit trong trace ở trên; phần còn lại ở chương task
13src/index.ts, package.jsonBề mặt xuất ra và các subpath
14test/examples/00 tới 13 theo thứ tự, rồi 14, 17, 20, 21

Tóm tắt#

  • Pi Durable là một harness agent bền, thử nghiệm, phát hành cùng Pi 1.0; nó không dùng AgentSession hay pi-agent-core, và không thay thế coding agent.
  • Quy tắc lõi: entry bất biến, bản ghi task và document Chord được commit nguyên tử; chỉ state đã commit là quan sát được.
  • Hội thoại là transcript entry bất biến; parent cho fork, owner cho subagent; head marker định nghĩa ngữ cảnh đang hoạt động.
  • Document là JSON có kiểu với phạm vi, lịch sử, chính sách fork và chính sách checkpoint tường minh; năm document dựng sẵn mang agent, danh tính provider, state đang chạy, inbox và chi phí.
  • submit() nhận input hoặc write bền rồi trả về; requestId cho exactly-once; bận thì xếp hàng thành steer hoặc follow-up, đặt vào ở boundary.
  • Code nằm trong extension của registry tiến trình; hội thoại lưu tên, agent được giải lại mỗi lần dùng; system prompt là các entry pi.system theo vị trí, chỉ gửi phần thay đổi.
  • UI quan sát bằng viewState(), watch() hoặc watchEvents(), tất cả dẫn xuất từ commit, hội tụ, giới hạn 100 frame.

Mục lục

Giao diện