Pi từ mã nguồnpi v1.4.0

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

Durable: task, scheduler, generation và tool

Task, phase và checkpoint, scheduler, generation, tool, và sống sót qua SIGKILL.

  • 46 phút đọc
  • 71 đoạn code
  • 23 mục
Trong chương này23 mục
  1. Vai trò: mọi thứ harness chạy là một task
  2. Mô hình tư duy: phase, checkpoint và commit
  3. Data model: TaskRecord, TaskState, TaskOutcome
  4. Định nghĩa một task: defineTask và TaskDefinition
  5. TaskRuntime: những gì một phase được làm
  6. Cây sở hữu: task con, background, abort từ dưới lên
  7. Scheduler: open, reserve, run, step
  8. Sleep, timer và wake
  9. Restart, parking, task bị chặn và migration
  10. pi.generation: từ transcript tới câu trả lời
  11. pi.tool: ý định trước, hiệu ứng sau
  12. Lời gọi lồng nhau, callers và tool hoãn nạp
  13. Inbox: steer, follow-up và write được đặt ở đâu
  14. Compaction là một task
  15. Lần theo một SIGKILL giữa lời gọi tool
  16. Task con và subagent qua ví dụ
  17. Code mode bền
  18. ExecutionEnv và các tool dựng sẵn
  19. Task graph và telemetry
  20. Quyết định thiết kế
  21. Cạm bẫy và trường hợp biên
  22. Đọc code ở đâu
  23. Tóm tắt

Chương này đi vào phần chạy việc của Pi Durable: task là gì, scheduler chạy nó ra sao, và ba task dựng sẵn (generation, tool, compaction) biến một input thành câu trả lời theo cách sống sót được khi tiến trình chết. Trung tâm của chương là một lần theo dấu: tiến trình bị SIGKILL giữa một lời gọi tool, rồi một tiến trình mới mở lại storage và gọi resume().

Vai trò: mọi thứ harness chạy là một task#

Trong coding agent, vòng lặp agent sống trong bộ nhớ: lượt nào đang chạy, tool nào đang chờ, mô hình đã trả về được bao nhiêu chữ, tất cả là biến của một tiến trình (chương pi-agent-core). Tiến trình chết thì phần đó mất; file session chỉ giữ những message đã xong. Pi Durable muốn điều ngược lại: tiến trình bị giết giữa chừng, tiến trình sau mở lại cùng storage và làm tiếp đúng bước đang dở. Muốn vậy, "đang làm dở cái gì" phải là dữ liệu đã commit chứ không phải biến.

Câu trả lời là task: một máy trạng thái bền (durable state machine) lưu checkpoint ở mỗi bước, nên một tiến trình khởi động lại sẽ tiếp tục từ checkpoint cuối (README.md:99). Bài công bố "Pi Durable" gói ý này trong một câu: "everything the harness runs is a task". Một câu trả lời không phải một vòng lặp mà là một chuỗi task dựng sẵn: pi.generation gọi mô hình, sở hữu các task pi.tool ứng với từng lời gọi tool, chờ chúng xong rồi trao lần chạy (run) cho generation kế tiếp; pi.compaction tóm tắt transcript khi cửa sổ ngữ cảnh (context window) sắp đầy (spec.md:3945–3951).

Task đến từ ba nguồn. Harness tạo task dựng sẵn khi xử lý submission. Extension mang định nghĩa task riêng trong trường tasks. Host hoặc tool tạo task trong một commit bằng tx.createTask(). Chỉ một thành phần chạy chúng: TaskScheduler trong packages/durable/src/harness/scheduler.ts. Scheduler nằm giữa hai chương lân cận: bên dưới là Session, dòng commit nguyên tử và storage (chương Durable: session, commit, document và storage); bên trên là Harness, conversation, submission và extension (chương Pi Durable: mô hình khái niệm). Chương này không giải thích lại hai tầng đó mà chỉ đường sang khi cần.

Ba task dựng sẵn có mặt trong mọi registry; extension không được dùng lại tên của chúng
pi/packages/durable/src/harness/registry.tsdòng 9–10, 18–30TypeScript
/** Built-in task definitions every registry holds; they are not an extension and cannot be removed or replaced. */export const BUILTIN_TASKS: readonly AnyTask[] = [GenerationTask, ToolTask, CompactionTask];	constructor(extensions: readonly Extension<Tool>[]) {		this.#extensions = extensions;		this.#byName = new Map(extensions.map((extension) => [extension.name, extension]));		const tasks = new Map<string, AnyTask>();		for (const task of BUILTIN_TASKS) tasks.set(task.definition.name, task);		for (const extension of extensions) {			for (const task of extension.tasks ?? []) {				const name = task.definition.name;				if (tasks.has(name)) throw new Error(`Task ${name} of extension ${extension.name} is already installed`);				tasks.set(name, task);			}		}		this.#tasks = tasks;
Harness.open() từ chối registry thiếu một trong ba task này (harness.ts:546–551). Task của extension được tra theo tên lưu trong bản ghi, nên tên task là một phần của định dạng lưu trữ.

Mô hình tư duy: phase, checkpoint và commit#

Hãy hình dung một task như một hàm dài được cắt thành nhiều đoạn; mỗi đoạn là một phase. Giữa hai đoạn, mọi thứ đoạn sau cần được ghi vào một checkpoint JSON và commit. Tiến trình chết giữa một phase thì phase đó chạy lại từ đầu với checkpoint cuối đã commit. Không gì khác sống sót: biến cục bộ, promise, timer đều mất. Vì thế mọi thiết kế trong chương quy về một câu hỏi: trước khi làm việc có hậu quả bên ngoài (gọi API, chạy lệnh), task đã ghi lại những gì?

            tx.createTask() trong một commit
                       │
                       ▼
    ┌───────────► pending ◄─────────── open(): running → pending, restarts + 1
    │                  │                  close(): running → pending, không đếm
    │   handover       │ reservation (commit của scheduler)
    │                  ▼
    └──────────── running ── phase handler gọi runtime.commit(...) ──┐
                   │ │ │                                               │
                   │ │ └─► running, checkpoint mới ── phase kế, cùng invocation
                   │ ├───► waiting { on, policy } ── mọi task trong on xong ──► running
                   │ └───► sleeping { until } ─── đồng hồ tới until ────────► running
                   │
                   └─► kết quả ── còn việc sở hữu đang sống ──► completing ──┐
                            │                                                  ▼
                            └───────────────────────────────────────────► terminal
                                  completed · failed · aborted · orphaned · faulted
Vòng đời một task. Checkpoint mới, waiting, sleeping và kết quả là do code của task commit qua runtime.commit(); reservation, đối soát lúc mở và đóng, handover, completing → terminal, fault và orphan là do scheduler ghi.

Từ vựng dùng trong phần còn lại của chương:

TừNghĩaỞ đâu
definitionĐối tượng TaskDefinition: tên, version, initial(), bảng phase, abort(), migrate(). Sống trong registry, không được lưu.types.ts:334
recordTaskRecord: bản ghi JSON trong storage, được thay nguyên khối ở mỗi commit.types.ts:690
invocationMột lần chạy trong bộ nhớ của task, ở chế độ run hoặc abort, với AbortController riêng. Không bền.scheduler.ts:105
reservationCommit của scheduler đổi pending/waiting/sleeping thành running và tạo invocation.scheduler.ts:1466
stepQuyết định đồng bộ trên dòng commit (mutation line) của Session trước mỗi phase: chạy tiếp, dừng, hay ghi faulted.scheduler.ts:1886
memoGiá trị JSON nhỏ trong record, người ghi đầu tiên thắng; sống qua checkpoint, mất khi task kết thúc.spec.md:2053
owner, việc sở hữu thườngMỗi task thuộc về conversation của nó hoặc một task khác. "Việc sở hữu thường" (ordinary owned work) là các task sống bên dưới, không tính nhánh background.spec.md:2316
ý định huỷ (cancellation intent)Abort mark của một owner còn sống, hoặc kết quả đang giữ khác completed. Nó lan xuống việc sở hữu thường.scheduler.ts:2398
blocked, parkedTask không chạy được: thiếu hoặc lệch definition, migration hỏng, hoặc bị đỗ vì crash quá nhiều lần. Lý do được suy ra, không lưu.scheduler.ts:448

Spec gọi khuôn mẫu cốt lõi là "bánh mì kẹp hiệu ứng" (effect sandwich): commit ý định, làm hiệu ứng bên ngoài, commit kết quả. Mở lại khi đang ở phase ý định nghĩa là hiệu ứng có thể đã xảy ra, và phase phải tự chọn: thử lại an toàn, hỏi trạng thái bên ngoài, hoặc ghi nhận là bị gián đoạn. Ba lựa chọn này sẽ xuất hiện lại nguyên văn trong task pi.tool.

Spec §5.2: khuôn mẫu ý định, hiệu ứng, kết quả
pi/packages/durable/docs/spec.mddòng 2040–2051Markdown
### 5.2 Effect sandwich```textcommit intent phaseperform external effectcommit outcome or next phase```Reopening in an intent phase means the effect may have happened. The phasehandler retries safely, polls an external handle, or records interruption.Deferred providers are represented by a durable phase containing their handleand next poll time.
Câu cuối quan trọng: phản hồi hoãn (deferred) của provider cũng chỉ là một phase bền chứa handle và thời điểm hỏi lại. Generation dùng đúng cách này cho phase poll.

Data model: TaskRecord, TaskState, TaskOutcome#

Ba kiểu trong packages/durable/src/types.ts mô tả toàn bộ trạng thái bền của một task. Bắt đầu từ kết quả, vì năm loại kết thúc nói nhiều về triết lý thiết kế:

TaskOutcome: năm cách một task kết thúc
pi/packages/durable/src/types.tsdòng 561–596TypeScript
/** Durable reason and optional result recorded when a task becomes terminal. */export type TaskOutcome<R> =	| {			readonly status: "completed";			readonly result: R;			readonly error?: never;			readonly reason?: never;	  }	/** Expected task or domain failure explicitly committed by its implementation. */	| {			readonly status: "failed";			readonly error: TaskOutcomeError;			readonly result?: R;			readonly reason?: never;	  }	/** Explicit cancellation handled by the task's abort protocol. */	| {			readonly status: "aborted";			readonly reason?: string;			readonly result?: R;			readonly error?: never;	  }	/** Task that cannot resume because its definition or migration is unavailable. */	| {			readonly status: "orphaned";			readonly reason: string;			readonly result?: never;			readonly error?: never;	  }	/** Runtime-detected contract failure, such as an uncaught throw or no durable progress. */	| {			readonly status: "faulted";			readonly error: TaskOutcomeError;			readonly result?: never;			readonly reason?: never;	  };
failed và aborted có thể mang result: một tool bị gián đoạn kết thúc failed nhưng vẫn kèm ID của entry kết quả. orphaned và faulted không bao giờ có result, vì code của task không quyết định chúng.
  • completed, failed: do chính task commit. failed là thất bại nghiệp vụ có chủ đích (mô hình trả lỗi, thẻ bị từ chối).
  • aborted: handler abort() của task đã chạy và tự quyết định kết quả, tức là task đã có cơ hội dọn dẹp.
  • orphaned: task bị huỷ nhưng không có code nào chạy được (definition thiếu hoặc không nhận bản ghi), nên hiệu ứng bên ngoài có thể còn sót. reason là lý do bị chặn (spec.md:2229–2234).
  • faulted: runtime phát hiện vi phạm hợp đồng: phase ném lỗi, hoặc phase trả về mà không tiến bộ bền (scheduler.ts:1826–1832).

Bản ghi bọc trạng thái trong các trường định danh và lập lịch. Hai nhóm trường đáng chú ý: nhóm bất biến (conversationId, owner, background, abandonOnRestart) dựng nên cây sở hữu, và nhóm phục hồi (abortRequested, abortReason, restarts) cho scheduler biết phải làm gì khi mở lại.

TaskRecord: memo chỉ tồn tại khi task còn chạy được
pi/packages/durable/src/types.tsdòng 689–704TypeScript
/** Complete replacement record for one durable task state machine. */export type TaskRecord<I, S, R> = TaskRecordBase<I, R> &	(		| {				readonly state: Extract<					TaskState<S, R>,					{ readonly status: "pending" | "running" | "waiting" | "sleeping" }				>;				/** Small first-writer-wins values retained while the task can run. */				readonly memos?: Readonly<Record<string, JsonValue>>;		  }		| {				readonly state: Extract<TaskState<S, R>, { readonly status: "completing" | "terminal" }>;				readonly memos?: never;		  }	);
Kiểu hợp (union) buộc memos biến mất khi task đã có kết quả; scheduler làm điều đó trong withState() (scheduler.ts:2473–2478).

Ví dụ minh hoạ dưới đây là bản ghi của một lời gọi bash mà tiến trình trước bị giết sau khi đã ghi ý định. Đây là thứ tiến trình mới thấy trong storage sau khi open() chạy xong (các số ID là bịa, cấu trúc theo đúng kiểu ở trên):

Ví dụ minh hoạ
{
  "id": 57, "conversationId": 1, "kind": "pi.tool", "version": 2,
  "input": { "kind": "model", "assistant": 55, "callId": "call_b" },
  "owner": 54,                      // task pi.generation đã tạo lời gọi này
  "background": false, "abortRequested": false,
  "startedAt": 1761120000000, "restarts": 1,
  "state": {
    "status": "pending",            // open() vừa hạ running xuống pending
    "checkpoint": { "phase": "execute",
                    "arguments": { "command": "npm test" }, "replay": "unsafe" }
  }
}
Không có memos: tool chưa ghi memo nào. Không có kết quả: bản ghi vẫn sống.

Mỗi checkpoint là một bản thay thế đầy đủ, không phải bản vá (spec.md:1927–1928). Tx công khai cũng không có thao tác ghi đè task: task chỉ đổi trạng thái của chính nó qua runtime, còn mọi chuyển trạng thái khác (reservation, đối soát khi mở lại, handover, fault, orphan) thuộc về scheduler, và abort mark thuộc về abortTask() (spec.md:1945–1948).

Định nghĩa một task: defineTask và TaskDefinition#

defineTask() chỉ là một hàm đồng nhất (identity) để TypeScript suy ra kiểu; nó gói definition vào một đối tượng Task. Hàm này nằm trong src/tasks.ts, không phải src/harness/define.ts (file kia chứa defineTool, hook, section, wrapTool).

defineTask(): một hàm đồng nhất có kiểu
pi/packages/durable/src/tasks.tsdòng 3–8TypeScript
/** Define an executable task. Register it in the registry so a Harness can run tasks of its kind. */export function defineTask<I, S extends { phase: string }, R, H extends object = object>(	definition: TaskDefinition<I, S, R, H>,): Task<I, S, R, H> {	return { definition };}

Bốn tham số kiểu: I là input (bất biến, lưu trong bản ghi), S là kiểu hợp các checkpoint, mỗi nhánh có trường phân biệt phase, R là kết quả, H là giao diện hook mà task cho extension cắm vào.

TaskDefinition: tên, version, checkpoint đầu, bảng phase, abort, migrate
pi/packages/durable/src/types.tsdòng 333–362TypeScript
/** Executable durable state machine definition, registered in the registry by `name`. */export type TaskDefinition<I, S extends { phase: string }, R, H extends object> = {	/** Registered task kind persisted in `TaskRecord.kind`. */	readonly name: string;	/** Definition version persisted with live input and checkpoints. */	readonly version: number;	/** First durable checkpoint for a newly created task. */	initial(input: I): S;	/** Exhaustive phase map; each handler receives the task narrowed to its phase. */	readonly phases: {		readonly [P in S["phase"]]: PhaseHandler<I, Extract<S, { phase: P }>, S, R, H>;	};	/** Runs in a fresh invocation after an abort mark and must commit a terminal outcome. */	abort(task: RunningTask<I, S, R>, runtime: TaskRuntime<I, S, R, H>, context: Context): Promise<void>;	/** Convert a record stored by any older supported version; runs at reservation. */	migrate?(		input: JsonValue,		checkpoint: JsonValue,		fromVersion: number,	): {		input: I;		checkpoint: S;	};	readonly hooks?: H;};/** Typed executable task definition. */export interface Task<I, S extends { phase: string }, R, H extends object> {	readonly definition: TaskDefinition<I, S, R, H>;}
Bảng phase là kiểu ánh xạ trên S["phase"]: thiếu một phase là lỗi biên dịch, và mỗi handler nhận task đã được thu hẹp về đúng nhánh checkpoint của nó.

Hợp đồng của một phase handler được ghi ngay trên kiểu của nó: handler phải commit một checkpoint khác hoặc một kết quả qua runtime.commit(); trả về mà không tiến bộ bền thì task bị faulted (types.ts:222–230). Quy tắc này chặn một lỗi khó thấy: phase chạy xong mà quên commit; nếu không có nó, step sẽ thấy checkpoint cũ và gọi lại đúng phase đó, lặp vô hạn. abort() chạy trong một invocation mới sau khi có abort mark và cũng phải commit một kết quả. migrate() chạy lúc reservation để nâng bản ghi của version cũ.

TaskOwnership, JoinPolicy và TaskOptions
pi/packages/durable/src/types.tsdòng 364–391TypeScript
/** Who owns a task: its conversation (a top-level task) or another task of the same conversation (a child task). */export type TaskOwnership = { readonly kind: "conversation" } | { readonly kind: "task"; readonly taskId: TaskId };/** How a waiting task treats the tasks it waits on (spec §5.5). */export type JoinPolicy = "failFast" | "allSettled";/** Creation options for a durable task. */export type TaskOptions = {	/** Omitted: inherit the creator's carrier. "root": start at the adapter root without a stored carrier. */	readonly trace?: TelemetryCarrier | "root";	/** Immutable creation-time links, attached to the spans of this task's first invocation. */	readonly links?: readonly TelemetryCarrier[];	/** Required: a task always names its owner (spec §5.5). */	readonly ownership: TaskOwnership;	/**	 * Default: the owner task's conversation, or the transaction's bound conversation; required for conversation-owned	 * tasks created by Session commits that are not bound to a conversation.	 */	readonly conversationId?: ConversationId;	/** Conversation-owned tasks only: excluded from ordinary idle waits, conversation aborts, and cascades. */	readonly background?: boolean;	/**	 * Its creator awaits it only in memory and does not resume after a restart, so it is pointless once the Harness that	 * created it is gone. When scheduling starts in a later Harness, the task, if still live, is abort-marked with reason	 * `restart`, and the mark cascades to its ordinary owned work, before any of it runs again.	 */	readonly abandonOnRestart?: boolean;};
ownership là bắt buộc: không có task nào vô chủ. background chỉ dành cho task do conversation sở hữu; abandonOnRestart sẽ được giải thích ở mục restart.

Ví dụ hoàn chỉnh nhỏ nhất trong repo là task đếm nhịp của test/examples/13-recovery.ts. Nó in "tick n", ngủ 50 ms, rồi sang nhịp sau, cho tới khi đếm đủ:

Một task thật: memo trước hiệu ứng, sleep giữa các phase, kết quả ở phase cuối
pi/packages/durable/test/examples/13-recovery.tsdòng 21–54TypeScript
const Ticker = defineTask<{ to: number }, { phase: "tick"; n: number }, string>({	name: "example.ticker",	version: 1,	initial: () => ({ phase: "tick", n: 1 }),	phases: {		tick: async (task, runtime, taskContext) => {			const n = task.state.checkpoint.n;			// Save the intent before the effect. A memo keeps the first value			// written under its name, so if the process dies after printing but			// before the next checkpoint is saved, the rerun sees the memo and			// does not print the same tick twice.			if ((await runtime.memo(`printed-${n}`, taskContext)) === undefined) {				await runtime.memo(`printed-${n}`, true, taskContext);				console.log(`tick ${n}`);			}			reachedTick(n);			// Save the outcome: the final result, or the next tick after a short			// sleep. The sleep is stored with the checkpoint, so the task holds no			// invocation meanwhile, and a Harness opened later continues it once			// its time has come.			if (n === task.input.to) {				await runtime.commit(					() => ({ status: "terminal", outcome: { status: "completed", result: `counted to ${n}` } }),					taskContext,				);				return;			}			await runtime.sleepUntil(runtime.now() + 50, { phase: "tick", n: n + 1 }, taskContext);		},	},	abort: async (_task, runtime, taskContext) => {		await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext);	},});
Task chỉ có một phase tick nhưng checkpoint mang số n; mỗi lần sleepUntil() là một checkpoint mới, nên "đã tới nhịp nào" là dữ liệu bền.

Đọc ví dụ theo con mắt của một crash. Giả sử tiến trình chết ngay sau console.log("tick 2"), trước khi sleepUntil() commit. Checkpoint cuối đã lưu vẫn là { phase: "tick", n: 2 }, nên tiến trình sau chạy lại phase tick với n = 2. Nếu không có memo, "tick 2" sẽ in hai lần. Dòng 32 đọc memo printed-2: lần chạy đầu đã commit nó trước khi in, nên lần chạy lại thấy và bỏ qua. Ví dụ tự đóng Harness đúng ở thời điểm đó để mô phỏng crash (13-recovery.ts:60–62). Lưu ý giới hạn: memo đặt trước console.log nghĩa là nếu crash xảy ra giữa memo và lệnh in, "tick 2" sẽ không bao giờ được in. Memo chuyển "có thể in hai lần" thành "có thể không in lần nào"; chọn cái nào là việc của tác giả task.

TaskRuntime: những gì một phase được làm#

Mỗi phase handler nhận ba tham số: bản ghi đang chạy, một TaskRuntime, và một Chord Context (mang tín hiệu huỷ và span telemetry). Runtime là cánh cửa duy nhất của task ra thế giới bền.

TaskRuntime (phần 1): định danh, cấu hình, commit và memo
pi/packages/durable/src/types.tsdòng 245–282TypeScript
export interface TaskRuntime<I, S, R, H extends object> extends DocumentObserver, DocumentReader {	readonly taskId: TaskId<R>;	readonly conversationId: ConversationId;	/**	 * Aborted when the run is signalled by `abortTask()`, the Harness closes, or the invocation ends. Work still using it	 * after the invocation ended, such as a detached wait, is cancelled; it could not write anything anyway.	 */	readonly signal: AbortSignal;	/** Registry snapshot of the current phase; refreshed at every phase boundary. */	readonly registry: RegistrySnapshot;	/** The task's conversation's agent, resolved at most once per phase, at first use, and fixed for the phase. */	agent(context: Context): Promise<Agent>;	/** `HarnessOptions.settings`, resolved at each access. */	readonly settings: Settings;	/** `HarnessOptions.models`, resolved at each access. */	readonly models: Models;	/** Calls `HarnessOptions.env` for the task's conversation; rejects with its error. */	env(context: Context): Promise<ExecutionEnv | undefined>;	/** Handlers of this task's name from the extensions its conversation selects, in extension order. */	readonly hooks: HookRunner<H>;	/**	 * Commit on the Session line after rereading the task. Rejects when the task is terminal, the invocation ended, the	 * Harness is closing, or, in a run invocation, the task carries an abort mark. A returned state replaces the task's	 * state in the same commit; returning nothing leaves it unchanged. `tx.createTask()` defaults to the task's	 * conversation. Inside the callback read only through `tx`: `snapshot()`, `documentState()`, `watchDoc()`, `getTask()`, and the other Session and runtime reads may queue on the line the callback holds, and then never resolve.	 */	commit(		change: (			tx: Tx,			current: RunningTask<I, S, R>,		) => NextTaskState<S, R> | undefined | Promise<NextTaskState<S, R> | undefined>,		context: Context,	): Promise<void>;	/** Read a durable memo of this task. */	memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;	/** Store `candidate` unless a memo already exists; return the durable winner. */	memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
agent() được phân giải nhiều nhất một lần mỗi phase và cố định trong phase đó; settings và models được đọc lại ở mỗi lần truy cập. Vì vậy đổi model giữa chừng chỉ có hiệu lực từ request sau: agent của phase prepare đã cố định, và generation còn ghim model vào checkpoint request (xem mục generation).
TaskRuntime (phần 2): chờ, huỷ việc con, đọc kết quả, timer và sleep
pi/packages/durable/src/types.tsdòng 283–298, 312–317, 324–330TypeScript
/** Committed task record. */getTask<T>(id: TaskId<T>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, T> | undefined>;/** Resolve with the task's terminal receipt; rejects when the invocation ends. */waitForTask<T>(id: TaskId<T>, context: Context): Promise<SettledTask<T>>;/** * Abort a task this task owns, as `Harness.abortTask()` does, and resolve once it is terminal. Rejects for a task * another owns; resolves at once for a terminal one. */abortOwned(id: TaskId, context: Context): Promise<void>;/** * Live tasks this task owns directly, in ID order: also those an earlier invocation created, before a restart. Tasks * in conversations it owns are not included. Copies of the committed records. */ownedTasks(context: Context): Promise<TaskRecord<JsonValue, JsonValue, JsonValue>[]>;/** Outcomes of terminal tasks, in order; rejects when one is missing or not terminal. Used after a wait. */outcomes<T>(ids: readonly TaskId<T>[], context: Context): Promise<TaskOutcome<T>[]>;/** * Call `callback` once after about `delayMs` on the Harness's timer (`HarnessOptions.setTimer`), for pacing work * inside a phase, such as throttled progress commits. Not durable: the invocation's end disarms it, and after its end * nothing is armed. Returns a function that disarms it. Wait for time between phases with `sleepUntil()`. */setTimer(callback: () => void, delayMs: number): () => void;/** * Commit a sleep: the task holds no invocation until the Harness clock reaches `until`, then resumes at `checkpoint`. * Resolves once the sleep is stored, not when the time comes; the phase returns right after. Returning * `{ status: "sleeping", until, checkpoint }` from `commit()` is the same sleep, for one that must commit together * with other writes. */sleepUntil(until: number, checkpoint: S, context: Context): Promise<void>;
waitForTask() chỉ là chờ trong bộ nhớ, kết thúc cùng invocation. Muốn chờ bền thì commit trạng thái waiting; muốn chờ thời gian bền thì sleepUntil().

commit(): đọc lại, kiểm tra cổng, rồi thay trạng thái#

Bên trong, runtime.commit() là một commit trên dòng commit (xem chương storage) đi qua hàm #gated() của scheduler:

#gated(): mọi commit của runtime đọc lại task trên dòng commit trước khi chạy callback
pi/packages/durable/src/harness/scheduler.tsdòng 2327–2349TypeScript
#gated<T>(	invocation: Invocation,	change: (tx: Transaction, current: ErasedRunningTask) => T | Promise<T>,	context: Context,): Promise<T> {	if (invocation.ended) return Promise.reject(endedError(invocation));	return this.#session.commitWith(		async (tx) => {			if (invocation.ended) throw endedError(invocation);			if (this.#closing) throw closedError(this.#session);			const found = this.#live.get(invocation.taskId);			if (found === undefined) throw new Error(`Task ${invocation.taskId} is terminal`);			if (found.state.status !== "running") throw new Error(`Task ${invocation.taskId} is ${found.state.status}`);			const current = found as ErasedRunningTask;			if (invocation.mode === "run" && current.abortRequested) {				throw new Error(`Task ${invocation.taskId} has a durable abort mark`);			}			return change(tx, current);		},		context,		{ conversationId: invocation.conversationId, taskId: invocation.taskId },	);}
Phạm vi giao dịch (dòng 2347) gắn conversation và task: tx.createTask() mặc định vào conversation của task, và mọi entry nối thêm được ghi byTaskId.

Bốn điều kiện từ chối ở dòng 2335–2343 là hàng rào an toàn của cả hệ thống. Invocation đã kết thúc (ví dụ một promise bị bỏ rơi cố commit muộn) thì không được ghi. Harness đang đóng thì không được ghi. Task không còn running thì không được ghi. Và một invocation ở chế độ run không được commit khi bản ghi đã có abort mark: từ lúc mark được commit, chỉ handler abort() mới được quyết định. Khi callback trả về một trạng thái, #commitState() thay trạng thái trong cùng commit đó:

#commitState(): kiểm tra wait và sleep, giữ kết quả thành completing, xoá bộ đếm restart
pi/packages/durable/src/harness/scheduler.tsdòng 1942–1963TypeScript
async #commitState(	tx: Transaction,	invocation: Invocation,	current: ErasedRunningTask,	next: NextTaskState<Checkpoint, JsonValue>,): Promise<void> {	if (next.status === "waiting") await this.#validateWait(tx, invocation, current, next.on, next.policy);	if (next.status === "sleeping") validateSleep(invocation, current.id, next.until);	if (next.status === "terminal") {		const overlay = overlayOf(tx);		await this.#loadScopes(false);		for (const record of overlay.tasks.values()) await this.#loadChain(parentOf(record), overlay);		if (this.#hasOwnedLive(current.id, overlay)) {			tx.setTask(withState(progressed(current), { status: "completing", outcome: next.outcome }));			return;		}	}	// A state the task committed that differs from its current one is progress: its restart count starts over. The	// same checkpoint committed again, for example as intent before an effect, is not.	const same = jsonEqual(current.state as unknown as JsonValue, next as unknown as JsonValue);	tx.setTask(withState(same ? current : progressed(current), next));}
Dòng 1961: commit lại đúng checkpoint hiện tại (như một intent ghi trước hiệu ứng) không tính là tiến bộ; mọi trạng thái khác xoá restarts qua progressed().

Vì checkpoint, entry, document và task con nằm trong cùng một commit, "đã gọi model và nhận được câu trả lời" với "đã nối câu trả lời vào transcript" không bao giờ lệch nhau: hoặc cả hai được lưu, hoặc không cái nào (spec.md:1929–1937).

memo(), sleepUntil(), setTimer()#

memo() và sleepUntil() trong runtime
pi/packages/durable/src/harness/scheduler.tsdòng 2152–2173TypeScript
memo: ((name: string, ...rest: readonly unknown[]) => {	if (rest.length === 1) {		return this.#read(invocation, async () => memoOf(this.#live.get(invocation.taskId), name));	}	const candidate = rest[0] as JsonValue;	return this.#gated(		invocation,		(tx, current) => {			const winner = memoOf(current, name);			if (winner !== undefined) return winner;			tx.setTask({ ...current, memos: { ...current.memos, [name]: candidate } } as AnyTaskRecord);			return candidate;		},		rest[1] as Context,	);}) as ErasedRuntime["memo"],sleepUntil: (until, checkpoint, context) =>	this.#gated(		invocation,		(tx, current) => this.#commitState(tx, invocation, current, { status: "sleeping", checkpoint, until }),		context,	),
Ghi memo là một commit có cổng; nếu tên đã có giá trị thì trả về giá trị đó, nên hai ứng viên cạnh tranh luôn nhận cùng một người thắng (test ở harness-tasks.test.ts:208–211).

setTimer() thì ngược lại, không bền chút nào: nó chỉ để nhịp công việc bên trong một phase, như việc commit phần trả lời đang stream mỗi 100 ms. Invocation kết thúc là mọi timer của nó bị gỡ, và gọi sau khi kết thúc thì không đặt gì (scheduler.ts:2290–2302).

Tạo task con, đọc kết quả, môi trường#

TaskRuntime không có createTask(): task con được tạo bằng tx.createTask() bên trong runtime.commit(), cùng commit với checkpoint (tool thì có api.createTask(), xem mục tool). Transaction kiểm tra quan hệ sở hữu ngay khi tạo:

tx.createTask(): task con sống trong conversation của owner, không được là background, bắt đầu pending
pi/packages/durable/src/session/transaction.tsdòng 408–420, 425–447TypeScript
const ownership = options.ownership;let owner: AnyTaskRecord | undefined;if (ownership.kind === "task") {	// Validated again against the owner's final candidate during assembly.	owner = await this.#currentTask(ownership.taskId);	this.#assertOpen();	if (owner === undefined) throw new Error(`Task owner ${ownership.taskId} does not exist`);	if (options.background === true) throw new TypeError("A child task cannot be background");	if (options.conversationId !== undefined && options.conversationId !== owner.conversationId) {		throw new Error(`A child task lives in its owner's conversation ${owner.conversationId}`);	}}const conversationId = owner?.conversationId ?? options.conversationId ?? this.#scope.conversationId;const checkpoint = definition.initial(input);const id = await this.#host.storage.mintId<TaskId<R>>();this.#assertOpen();const record = copyJson(	{		id,		conversationId,		kind: definition.name,		version: definition.version,		input,		trace:			options.trace === "root"				? undefined				: (options.trace ?? this.#context.telemetry.propagation?.inject(this.#context.telemetry)),		links: options.links,		...(owner === undefined ? {} : { owner: owner.id }),		background: options.background ?? false,		abortRequested: false,		...(options.abandonOnRestart === true ? { abandonOnRestart: true } : {}),		state: { status: "pending", checkpoint },	},	TABLE_JSON_COPY_OPTIONS,) as unknown as AnyTaskRecord;
Dòng 425: checkpoint đầu do definition.initial(input) tính ngay khi tạo, nên bản ghi mới đã đủ để chạy mà không cần thêm thông tin nào.

Sau một lần chờ, task đọc kết quả của các task nó chờ bằng runtime.outcomes(ids), hàm này từ chối nếu có task chưa terminal (scheduler.ts:2220–2231). runtime.env() gọi HarnessOptions.env cho conversation của task, với cwd hiện tại của agent (harness.ts:280–285). runtime.conversation(id) trả về một handle gắn với invocation: mọi thao tác của nó bị từ chối sau khi invocation kết thúc, nhưng submission đã nhận vẫn bền (harness.ts:498–533). runtime.context() đọc transcript và context của mô hình; scheduler giữ dải entry đã đọc của mỗi conversation trong bộ nhớ để lần đọc sau chỉ quét phần mới (scheduler.ts:2258–2285).

Cây sở hữu: task con, background, abort từ dưới lên#

Task và conversation cùng tạo thành một cây sở hữu duy nhất. Mỗi task khai báo owner khi được tạo: conversation của nó (task cấp cao nhất) hoặc một task khác (task con, luôn sống trong conversation của owner). Mỗi conversation hoặc vô chủ (ownerless) hoặc thuộc về một task, và đó là cách duy nhất cây vượt qua ranh giới conversation. Cạnh owner bất biến (spec.md:2308–2314). Đừng nhầm với quan hệ parent của fork: fork quyết định entry nào được thừa kế, còn owner quyết định abort và idle lan tới đâu (spec.md:247–250).

 c1 (root, ownerless)
 ├── G  pi.generation            owner: conversation c1
 │   ├── T1 pi.tool "subagent"    owner: G
 │   │   └── c2 (owner: T1)       conversation của subagent
 │   │       └── G2 pi.generation owner: conversation c2  → vẫn là việc sở hữu thường của T1
 │   └── T2 pi.tool "bash"        owner: G
 └── B  app.anchor  background   owner: conversation c1, ranh giới
     └── c3 (owner: B)
         └── G3 pi.generation    không thuộc việc sở hữu thường của c1
Việc sở hữu thường của G gồm T1, T2 và G2 (đi qua conversation c2 mà T1 sở hữu). B là background nên mọi thứ dưới B bị loại khỏi phép duyệt thường.

Hai quy tắc suy ra từ cây (spec.md:2322–2354):

  1. Abort chảy xuốngÝ định huỷ của một owner còn sống (abort mark, hoặc kết quả đang giữ khác completed) đánh dấu abort cho toàn bộ việc sở hữu thường của nó. Task background là ranh giới, trừ khi bị abort trực tiếp hoặc qua Conversation.abort(context, { background: true }).
  2. Việc con không sống lâu hơn lúc owner kết thúcMột task commit kết quả trong khi việc sở hữu thường còn sống thì được lưu thành completing; scheduler chỉ ghi terminal khi bên dưới không còn gì sống. Đây là dòng 1954–1956 của #commitState() ở trên.

Chờ: waiting với on và policy#

Một phase có thể commit { status: "waiting", checkpoint, on, policy }. Task không giữ invocation nào trong lúc chờ và tiếp tục ở checkpoint khi mọi task trong on đã terminal. allSettled chờ tất cả; failFast thêm một việc: task đầu tiên trong on kết thúc (hoặc đang giữ) với kết quả khác completed làm mọi task còn sống khác trong on bị đánh dấu abort. Bản thân task đang chờ không bị đánh dấu; nó tỉnh dậy khi tất cả đã xong và tự đọc kết quả bằng runtime.outcomes() (spec.md:2364–2383). Scheduler kiểm tra lời chờ trước khi chấp nhận:

#validateWait(): task phải tồn tại, failFast chỉ trên task mình sở hữu, không được tạo chu trình chờ
pi/packages/durable/src/harness/scheduler.tsdòng 1973–2000TypeScript
async #validateWait(	tx: Transaction,	invocation: Invocation,	current: ErasedRunningTask,	on: readonly TaskId[],	policy: string,): Promise<void> {	if (invocation.mode === "abort") throw new Error(`Abort handler of task ${current.id} cannot wait`);	const overlay = overlayOf(tx);	const targets: TaskId[] = [];	for (const id of on) {		const member = overlay.tasks.get(id) ?? this.#live.get(id) ?? (await this.#storage.task(id, this.#context));		if (member === undefined) throw new Error(`Task ${id} does not exist`);		if (policy === "failFast" && member.owner !== current.id) {			throw new Error(`Task ${current.id} can wait failFast only on tasks it owns; ${id} is not one`);		}		// A live child already holds the waiter (task-owned tasks are never background): waiting on it adds no edge. A		// terminal task has no edges.		if (member.state.status !== "terminal" && member.owner !== current.id) targets.push(id);	}	if (targets.length === 0) return;	const cycle = await this.#waitCycle(current.id, targets, overlay);	if (cycle !== undefined) {		throw new Error(			`Task ${current.id} cannot wait on ${cycle[0]}: ${[current.id, ...cycle].join(" -> ")} is a wait cycle`,		);	}}
Chờ một task con đang sống không thêm cạnh mới (con vốn đã giữ cha ở completing), nên chỉ những task không thuộc quyền sở hữu mới đi qua phép tìm chu trình #waitCycle(). Handler abort() không được chờ.

Kết quả bị giữ: completing#

Sau mỗi commit có liên quan, một pass đối soát (reconcile; "pass" là một lần quét của scheduler, khác với lượt của mô hình) kiểm tra các task completing. Hàm #finalize() duyệt theo vòng: hoàn tất một task có thể giải phóng owner ngay phía trên nó, owner đó vào danh sách của vòng sau.

#finalize(): biến completing thành terminal khi bên dưới không còn việc sống
pi/packages/durable/src/harness/scheduler.tsdòng 856–879TypeScript
let round = [...this.#finalizeChecks];this.#finalizeChecks.clear();const done = new Set<TaskId>();const overlay = overlayOf(tx);while (round.length > 0) {	const next = new Set<TaskId>();	for (const id of round) {		const record = overlay.tasks.get(id) ?? this.#live.get(id);		if (record?.state.status !== "completing" || done.has(id)) continue;		if (this.#hasOwnedLive(id, overlay, done)) continue;		const outcome = record.state.outcome;		const terminal = withState(record, { status: "terminal", outcome });		tx.setTask(terminal);		(overlay.tasks as Map<TaskId, AnyTaskRecord>).set(id, terminal);		done.add(id);		// REMINDER: only the scheduler writes `faulted` and `orphaned` (spec §5.4); their cleanup waits for this commit.		if (outcome.status === "faulted" || outcome.status === "orphaned") {			await this.#settleOutcome(tx, record, outcome);		}		const above = this.#nearestLiveAbove(record, overlay);		if (above?.state.status === "completing") next.add(above.id);	}	round = [...next];}
Dòng 872–874: kết quả do scheduler tự ghi (faulted, orphaned) chỉ được dọn dẹp phía Harness (kết thúc run, đánh dấu slot tool) trong commit cuối này, nên một generation bị fault vẫn giữ pi.live.run cho tới khi các tool của nó xong.

Lan truyền abort và thứ tự từ dưới lên#

abortTask() chỉ commit abort mark rồi chờ invocation đang chạy của task kết thúc (scheduler.ts:545–572). Việc đánh dấu cả cây con nằm ở một commit riêng của pass đối soát:

#reconcile(): suy abort mark từ ý định huỷ, áp failFast, rút input đang xếp hàng, hoàn tất completing
pi/packages/durable/src/harness/scheduler.tsdòng 785–819TypeScript
await this.#session.commitWith(async (tx) => {	if (this.#closing) return;	const queued = await this.#loadScopes(cascade);	// A cascade from an abandoned owner passes its `restart` reason on; any other intent marks, or upgrades a	// `restart` mark, as a request. Collected first, so a request wins over a `restart` mark of the same pass.	const marks = new Map<TaskId, { readonly record: AnyTaskRecord; readonly reason: "restart" | undefined }>();	const mark = (record: AnyTaskRecord, reason: "restart" | undefined): void => {		const staged = marks.get(record.id);		if (staged !== undefined && staged.reason === undefined) return;		if (record.abortRequested && (record.abortReason === undefined || reason !== undefined)) return;		marks.set(record.id, { record, reason });	};	// Every live foreground task below a live owner with cancellation intent, which is its nearest one: a walk down	// from each owner with intent does not enter another, whose own walk covers what is below it. Derived again on	// every pass, as loading edges can reveal a cancelled owner.	for (const id of this.#intent) {		const owner = this.#live.get(id)!;		// Only an owner abandoned after a restart, and nothing else, passes its reason on.		const restart = owner.abortReason === "restart" && !failedOutcome(owner);		for (const { node, live } of this.#walkDown(id, { enter: (below) => !this.#intent.has(below) })) {			if (live !== undefined && !node.background) mark(live, restart ? "restart" : undefined);		}	}	for (const id of checks) {		const waiter = this.#live.get(id);		if (waiter?.state.status !== "waiting" || !(await this.#anyFailed(waiter.state.on))) continue;		// Every other live task: the failed one keeps its own outcome.		for (const member of waiter.state.on) {			const record = this.#live.get(member);			if (record !== undefined && !failedOutcome(record)) mark(record, undefined);		}	}	for (const { record, reason } of marks.values()) tx.setTask(withAbortMark(record, reason));	for (const id of queued) if (this.#belowCancelled({ conversation: id })) await this.#withdrawInputs(tx, id);	await this.#finalize(tx);
Mark được suy ra trong commit riêng thay vì trong commit của owner, nên crash ở giữa không làm mất gì: lần mở sau chạy lại đúng phép suy luận này từ bản ghi bền (spec.md:2156–2159).

Thứ tự từ dưới lên đến từ một dòng điều kiện: task có abort mark coi việc sở hữu thường còn sống của nó là thứ đang chờ, nên invocation abort của nó chỉ được reserve khi bên dưới đã terminal. Nhờ vậy handler abort() luôn thấy kết quả cuối cùng của các task con, và mỗi task tự hoàn tác hiệu ứng của chính nó.

#waitingOn(): task bị đánh dấu abort chờ việc con, task waiting chờ phần còn sống của on
pi/packages/durable/src/harness/scheduler.tsdòng 1576–1586TypeScript
/** * Live tasks a task waits for before its next invocation: its live ordinary owned work when abort-marked, since * abort runs bottom-up, otherwise the live part of the `on` of a wait. */#waitingOn(record: AnyTaskRecord, first = false): readonly TaskId[] {	if (record.abortRequested) return this.#ownedLive(record.id, { first });	if (record.state.status !== "waiting") return [];	if (!first) return record.state.on.filter((id) => this.#live.has(id));	const live = record.state.on.find((id) => this.#live.has(id));	return live === undefined ? [] : [live];}

Hệ quả cần nhớ: handler abort() không tạo được task con (task của nó đã mang abort mark, nên createTask với owner đó bị từ chối). Muốn bù trừ (compensate) ở mức cha thì làm ngay trong handler, hoặc tạo task background do conversation sở hữu và chờ bằng runtime.waitForTask() (spec.md:2385–2394).

Scheduler: open, reserve, run, step#

Mỗi Harness có một TaskScheduler, tạo trong constructor của HarnessImpl với các callback của Harness: phân giải agent, settings, môi trường, đồng hồ, onWake, timer, và settleOutcome để dọn dẹp phía Harness khi scheduler tự ghi kết quả (harness.ts:228–249). Scheduler không biết gì về run hay loại task; tri thức đó nằm trong callback (spec.md:2273). Bất biến trung tâm được ghi ngay ở đầu lớp:

Bất biến: mọi chuyển trạng thái do một callback tuần tự trên dòng commit quyết định và ghi
pi/packages/durable/src/harness/scheduler.tsdòng 231–252TypeScript
/** * Durable task scheduler of one Harness. * * `#live` mirrors every committed non-terminal task record: pending, running, waiting, sleeping, and completing. The * synchronous commit listener updates it on the Session line, so code running on the line reads exactly the committed * state from it. * * Tasks and conversations form one ownership tree (spec §5.5): a task's parent is its owner task, or its conversation; * a conversation's parent is its owner task, if any. Walks up that tree decide what reaches one task: a cancelling * owner, a scope. `#below` lists the tree downward around live work, so what lies below one node is a walk down from * it: a task's live ordinary owned work, which holds its outcome as `completing` and delays its abort handler, a * scope's tasks, a cascade's reach. No pass visits every live task; the sets below hold each pass's candidates. * * The ownership indexes are bounded by live work: an owner edge and an ended task's ownership fields are kept only while * something is listed below them in `#below`, so only the chains above live tasks are known, whole. Work that appears * later below a dropped node finds it unknown and loads its chain from storage again. * * Invariant: every task transition is decided and written by one callback serialized on the Session line. That covers * reservation, marks, runtime commits, finalization, and the synchronous step before each phase, which applies the * precedence rules and writes a fault or handover. Handlers and joins run off the line. An invocation ends inside the * step that decides its end, so a runtime commit it queued either lands before that decision or is rejected. */
#live là bản sao trong bộ nhớ của mọi bản ghi chưa terminal, cập nhật đồng bộ bởi listener commit #observe() (scheduler.ts:624), nên code chạy trên dòng commit đọc đúng trạng thái đã commit.

open(): đối soát sau crash#

open(): nạp task sống, hạ running xuống pending và đếm restart; không chạy gì
pi/packages/durable/src/harness/scheduler.tsdòng 374–405, 413–416TypeScript
async open(context: Context): Promise<void> {	this.#session.observeCommits((publication) => this.#observe(publication));	this.#session.subscribeClose(() => this.#seal());	this.#unsubscribeRegistry = this.#registry.subscribe(() => {		this.#notifyBlocked();		this.#kick();	});	const limit = this.#restartLimit();	const parked: AnyTaskRecord[] = [];	await this.#session.commitWith(async (tx) => {		// Every table read before the first write.		const scans = [];		for (const status of LIVE_STATUSES) {			scans.push(await scanAll((cursor) => tx.scanTasks({ status }, SCAN_PAGE_SIZE, cursor)));		}		for (const records of scans) {			for (const record of records) {				this.#track(undefined, record);				this.#resumed.add(record.id);				if (record.abandonOnRestart === true) this.#abandoned.add(record.id);				if (record.state.status === "running") {					const restarts = (record.restarts ?? 0) + 1;					const pending = withState(record, { status: "pending", checkpoint: record.state.checkpoint });					tx.setTask({ ...pending, restarts } as AnyTaskRecord);					if (restarts >= limit && !record.abortRequested) parked.push(record);				}				if (record.state.status === "waiting" && record.state.policy === "failFast") {					this.#failFastChecks.add(record.id);				}			}		}	}, context);	// Derive abort marks a crash left unapplied below cancelled owners, and finalize held outcomes.	this.#cascadePending = true;	this.#scheduleReconcile();	this.#scheduleWake();
Mọi lượt quét bảng chạy trước lần ghi đầu tiên trong commit (quy tắc đọc trước ghi của transaction). Không handler nào chạy trong open().

Đọc kỹ dòng 394–398: một bản ghi còn running khi mở nghĩa là tiến trình trước chết trong lúc chạy nó, vì close() luôn ghi các task nó dừng về pending (xem join() bên dưới). waiting và sleeping giữ nguyên; chúng không có invocation nên không có gì bị cắt ngang. Mở mà không gọi resume() thì không chạy gì cả, điều một công cụ chỉ đọc (inspector) cần (spec.md:744–755).

resume(), kick và drain#

resume() bật lập lịch, đánh thức các sleep đã tới hạn rồi gọi #kick() (scheduler.ts:423–427). submit() và Submission.wait() (submissions.ts:75–77, :115–116), waitForTask(), waitForIdle() (harness.ts:344–352), compact() và abort() của conversation cũng gọi nó, nên host chờ một submission sau khi mở lại thì công việc cũ cũng chạy tiếp.

#kick() và #drain(): một vòng lặp reservation, không bao giờ commit đồng bộ từ listener
pi/packages/durable/src/harness/scheduler.tsdòng 1437–1460TypeScript
#kick(): void {	this.#scheduleWake();	this.#dirty = true;	if (this.#draining || !this.#enabled || this.#closing) return;	this.#draining = true;	// Never commit synchronously from a commit or registry listener.	queueMicrotask(() => void this.#drain());}async #drain(): Promise<void> {	try {		while (this.#dirty && this.#enabled && !this.#closing) {			this.#dirty = false;			for (const reservation of await this.#reserve()) this.#start(reservation);		}	} catch (error) {		// As in `#reconcile()`: the reservation commit runs no extension code.		this.#failSession(error);	} finally {		this.#draining = false;		// A wakeup that arrived after the loop's last check still needs its pass.		if (this.#dirty) this.#kick();	}}
Mọi thay đổi (commit có task, đổi registry, timer) chỉ bật cờ #dirty; một microtask chạy #drain() cho tới khi không còn gì đổi.

#reserve(): chọn task chạy được trong một commit#

#run() và #step(): một step trước mỗi phase#

#run(): handler của phase hiện tại, rồi một step quyết định có chạy phase kế không
pi/packages/durable/src/harness/scheduler.tsdòng 1711–1737TypeScript
async #run(reservation: Reservation): Promise<void> {	const invocation = reservation.invocation;	const state = { task: reservation.task, snapshot: reservation.snapshot, reported: undefined as ReportedTask };	const phase: Phase = { snapshot: () => state.snapshot, task: () => state.task, agent: undefined };	const runtime = this.#runtime(invocation, phase);	let current = await this.#step(invocation, (tx, next) =>		this.#decide(tx, next, undefined, state, invocation.extension, invocation.context),	);	while (current !== undefined && !this.#closing) {		const task = current;		current = await this.#phase(invocation, task, async () => {			const checkpoint = task.state.checkpoint;			// Each phase handler resolves its agent afresh, at first use.			phase.agent = undefined;			let previous: PhaseResult;			try {				await erased(state.task).phases[checkpoint.phase]!(task, runtime, invocation.context);				previous = { checkpoint };			} catch (error) {				previous = { checkpoint, failure: { error } };			}			return this.#step(invocation, (tx, next) =>				this.#decide(tx, next, previous, state, invocation.extension, invocation.context),			);		});	}}
Một invocation có thể chạy nhiều phase liên tiếp: phase trả về, step thấy checkpoint đã đổi, và phase mới chạy ngay trong cùng invocation, không cần reserve lại.

Step là một commit trên dòng commit; nó đọc bản ghi đã commit và áp quy tắc đầu tiên khớp. Spec liệt kê sáu quy tắc (spec.md:1988–2012): (1) task đã terminal, completing, waiting hay sleeping thì dừng; (2) Harness đang đóng thì dừng, giữ checkpoint; (3) chế độ run mà có abort mark thì dừng, invocation abort sẽ chạy sau; (4) phase ném lỗi thì ghi faulted; (5) checkpoint đã đổi thì làm mới registry snapshot rồi chạy tiếp hoặc handover; (6) checkpoint không đổi thì ghi faulted. Quy tắc 1 và 2 nằm trong #step(), phần còn lại trong #decide():

#decide(): quy tắc 3 đến 6, và handover khi definition được thay
pi/packages/durable/src/harness/scheduler.tsdòng 1816–1855TypeScript
): Decision {	// 3. abort mark: end; a fresh abort invocation starts once the task's ordinary owned work is gone.	if (current.abortRequested) return false;	// An owner's cancellation intent ends it too, before its cascade marks it; reservation then holds it back.	if (!current.background && this.#belowCancelled(parentOf(current))) {		this.#cascadePending = true;		this.#scheduleReconcile();		return false;	}	if (previous === undefined) return true;	// 4. uncaught error.	if (previous.failure !== undefined) return { fault: previous.failure.error };	// 6. no durable progress.	if (jsonEqual(current.state.checkpoint, previous.checkpoint)) {		const message = `Task ${current.kind} phase ${previous.checkpoint.phase} returned without durable progress`;		return { fault: new Error(message) };	}	// 5. progress: refresh the snapshot; hand over to a replacement definition that can take the task.	state.snapshot = this.#registry.snapshot();	const next = state.snapshot.task(current.kind);	if (next !== state.task) {		if (next !== undefined && canReserve(next, current)) {			tx.setTask(withState(current, { status: "pending", checkpoint: current.state.checkpoint }));			return false;		}		if (state.reported === undefined || state.reported.task !== next) {			state.reported = { task: next };			this.#report(				new Error(`Task ${current.id} keeps running under its old ${current.kind} definition`),				{					type: "task_outdated",					...this.#aboutTask({ ...recordTask(current), extension }),					reason: next === undefined ? "missing_task" : "incompatible_task",				},				context,			);		}	}	return true;}
Dòng 1820–1824: task nằm dưới một owner có ý định huỷ cũng dừng ngay, kể cả khi cascade chưa kịp đánh dấu nó.
#step(): invocation kết thúc ngay bên trong callback quyết định việc kết thúc
pi/packages/durable/src/harness/scheduler.tsdòng 1886–1905TypeScript
async #step(	invocation: Invocation,	decide: (tx: Transaction, current: ErasedRunningTask) => Decision,): Promise<ErasedRunningTask | undefined> {	let fault: { readonly error: unknown } | undefined;	try {		const continued = await this.#session.commitWith(async (tx) => {			const found = this.#live.get(invocation.taskId);			const current = found?.state.status === "running" ? (found as ErasedRunningTask) : undefined;			const decision = current !== undefined && !this.#closing ? decide(tx, current) : false;			if (decision === true) return current;			this.#end(invocation);			if (decision !== false) {				fault = { error: decision.fault };				const message = errorMessage(decision.fault);				invocation.fault = { status: "faulted", error: { message } };				await this.#terminate(tx, current!, invocation.fault);			}			return undefined;		}, withoutAbortSignal(invocation.context));
Vì #end() chạy bên trong commit, một runtime.commit() mà invocation xếp hàng trước đó hoặc đã vào storage trước step (và được tính), hoặc tới sau và bị từ chối bởi #gated().

close(): không phải crash#

Khi Harness đóng, scheduler báo hiệu mọi invocation, chờ chúng trả về, rồi ghi các task bị dừng về pending thẳng vào storage. Vì thế lần mở sau không đếm restart cho chúng; chỉ một tiến trình chết thật mới để lại bản ghi running.

join(): báo hiệu, chờ, rồi ghi pending lúc đóng
pi/packages/durable/src/harness/scheduler.tsdòng 520–536TypeScript
async join(): Promise<void> {	const stopped = [...this.#invocations.values()];	for (const invocation of stopped) invocation.controller.abort();	await Promise.allSettled(stopped.map((invocation) => invocation.done));	if (stopped.length === 0) return;	// A close is not a crash: a task it stopped is `pending` again, so the next open counts no restart for it. Read	// on the line after commits admitted before the seal, such as an abort mark, so the write keeps them. A failure	// leaves them `running`, counted as if cut off; the Storage guard fails the Session and reports it.	await this.#writeAtClose(() =>		stopped.flatMap((invocation) => {			const record = this.#live.get(invocation.taskId);			if (record?.state.status !== "running") return [];			const value = withState(record, { status: "pending", checkpoint: record.state.checkpoint });			return [{ type: "task", value } as const];		}),	).catch(() => {});}
Đóng chờ cả code bỏ qua tín hiệu huỷ, nên một tool không tôn trọng AbortSignal làm close() treo (spec.md:5591–5593).

Sleep, timer và wake#

Trước 1.4.0, task có runtime.sleep(): chờ ngay trong phase. CHANGELOG giải thích vì sao nó bị xoá: chờ trong phase thì giữ một invocation, và sau restart phase chạy lại từ đầu nên lần chờ cũng bắt đầu lại (CHANGELOG.md:7). Một backoff 10 phút, mỗi lần tiến trình khởi động lại, lại đếm 10 phút từ đầu; và một Durable Object đang chờ không thể ngủ đông (hibernate). Bây giờ sleep là một trạng thái:

  • runtime.sleepUntil(until, checkpoint) commit { status: "sleeping", until, checkpoint } và resolve khi đã lưu, không phải khi tới giờ; phase trả về ngay sau đó. Muốn commit sleep cùng các thay đổi khác thì trả trạng thái đó từ runtime.commit() (README.md:162).
  • Việc sau lúc chờ nằm ở phase kế, và những gì nó cần nằm trong checkpoint. Deadline được tính một lần rồi lưu, nên không bị đẩy lùi mỗi lần chạy lại (spec.md:2417–2431).
  • until phải là số hữu hạn không âm, và handler abort() không được ngủ (scheduler.ts:2489–2494). Abort mark kết thúc giấc ngủ ngay: #notDue() coi task có mark là đã tới hạn (scheduler.ts:1430–1435).

Phần còn lại là ai đánh thức. Scheduler không biết tiến trình có còn sống tới lúc đó không, nên nó trả lời câu hỏi "khi nào Session cần một tiến trình chạy" bằng wake():

wake(): active khi có việc chạy được ngay, at là giấc ngủ sớm nhất chưa tới hạn
pi/packages/durable/src/harness/scheduler.tsdòng 477–513TypeScript
wake(): Wake {	const now = this.#now();	// A reconcile pass with work to do is work too: it finalizes held outcomes and marks cascades, at open as	// recovery. A process that dies before it lands must be woken again. A pass with nothing to do is not.	let active = this.#invocations.size > 0 || this.#reconcilingWork > 0 || this.#reconcileWork();	let at: number | undefined;	let snapshot: RegistrySnapshot | undefined;	let snapshotFailed = false;	for (const record of this.#live.values()) {		const state = record.state;		if (state.status === "completing") continue;		if (state.status === "waiting" && !record.abortRequested && state.on.some((id) => this.#live.has(id)))			continue;		if (state.status === "sleeping" && !record.abortRequested && state.until > now) {			at = at === undefined ? state.until : Math.min(at, state.until);			continue;		}		if (active) continue;		if (this.#parked(record)) continue;		// An abort invocation waits for the live work below it, which counts itself.		if (record.abortRequested && this.#waitingOn(record, true).length > 0) continue;		// A task no definition can take waits for a registry change, except an abort-marked one, which is orphaned.		if (!record.abortRequested || record.abortReason !== undefined) {			if (snapshot === undefined && !snapshotFailed) {				try {					snapshot = this.#registry.snapshot();				} catch (error) {					snapshotFailed = true;					this.#report(error, REGISTRY);				}			}			if (snapshot !== undefined && "reason" in this.#fit(record, snapshot.task(record.kind))) continue;		}		active = true;	}	return at === undefined ? { active } : { active, at };}
Task bị chặn, task completing và lời chờ trên task còn sống không tính là active; việc chúng chờ mới tính. Một pass đối soát đang chờ chạy cũng là active (dòng 481), vì nó có thể hoàn tất kết quả bị giữ sau crash.

Có hai cách dùng giá trị này. Mặc định, khi lập lịch đang bật, scheduler đặt một timer "foreground" cho at; timer này giữ tiến trình Node sống, vì một task đang ngủ là việc còn dang dở. Với HarnessOptions.onWake, scheduler không đặt timer nào mà báo cho host mỗi khi giá trị đổi; host tự đặt báo thức (alarm) và gọi resume() khi nó reo:

Mọi timer của Session và Harness đi qua HarnessOptions.setTimer, chia hai loại. Timer "background" chỉ giải phóng bộ nhớ (đuổi document, context không dùng); host có thể từ chối bằng cách trả undefined, và bản mặc định từ chối chúng trong Cloudflare Workers vì một timer đang chờ giữ Durable Object không cho ngủ đông. Timer "foreground" nhịp hoặc tiếp tục công việc; host ném lỗi hay từ chối thì Harness tự đặt bằng setTimeout và báo lỗi một lần (timers.ts:12–36, timers.ts:46–79). Truyền cả setTimer và now thì Harness chạy trên đồng hồ giả; mọi tiến trình mở cùng một storage phải dùng cùng một đồng hồ, vì deadline được lưu theo now (README.md:164).

Mẫu dùng onWake cho một Durable Object của Cloudflare (đặt báo thức dự phòng trước Harness.open, báo thức nhịp tim khi active, gọi resume() trong alarm()) nằm ở README.md:166–210; phần lưu trữ của nó thuộc chương storage. Test harness-generation-recovery.test.ts:165–210 cho thấy cả chu trình: một backoff retry được lưu thành sleeping tới 61 000, sau khi mở lại wake() trả { active: false, at: 61_000 }, gọi resume() sớm không gửi request nào, và khi đồng hồ tới 61 000 thì request được gửi lại.

Restart, parking, task bị chặn và migration#

Vòng lặp crash và việc đỗ task#

Có một mặt trái của "mở lại là chạy tiếp": công việc làm tiến trình chết một cách tất định, ví dụ cạn bộ nhớ trước checkpoint kế, sẽ được chạy tiếp và lại giết tiến trình, mãi mãi (README.md:216). Phòng tuyến là trường restarts. open() cộng một cho mỗi bản ghi còn running (đã thấy ở dòng 394–398); trạng thái khác mà task tự commit xoá bộ đếm (progressed()). Khi restarts chạm settings.restartLimit (mặc định 5, agent.ts:56–62), task bị đỗ: không được reserve, hiện trong inspect() và task graph với lý do restart_limit, không tính là active trong wake(), và được báo một lần qua onReport với kiểu task_parked.

retry(): Harness.retryTask() xoá bộ đếm của một task bị đỗ và bật lập lịch
pi/packages/durable/src/harness/scheduler.tsdòng 433–442TypeScript
async retry(id: TaskId, context: Context): Promise<void> {	await this.#session.commitWith(async (tx) => {		const current = await tx.task(id);		if (current === undefined) throw new Error(`Task ${id} does not exist`);		const status = current.state.status;		if (status === "terminal" || status === "completing" || !this.#parked(current)) return;		tx.setTask(progressed(current));	}, context);	this.resume();}

Hai chi tiết cho thấy cơ chế được nghĩ kỹ. Thứ nhất, mọi task đang chạy khi tiến trình chết đều bị đếm, không chỉ thủ phạm, nên một stream dài chạy cạnh thủ phạm cũng có thể bị đỗ; vì vậy host được khuyên hiển thị task bị đỗ và cho người dùng nút retryTask() (spec.md:2201–2206). Thứ hai, abort mark đặt lại bộ đếm (withAbortMark() xoá restarts, scheduler.ts:2389–2396), nên handler abort() của một task bị đỗ vẫn chạy; nhưng nếu chính handler đó bị cắt ngang đủ số lần, task kết thúc faulted với lý do restart_limit (dòng 1522–1529 của #reserve()). Test mô phỏng crash bằng một storage "chết" (mọi lời gọi treo, nên ngay cả bản ghi pending lúc đóng cũng không được ghi), mở lại ba lần và kiểm tra task bị đỗ với restarts: 3, rồi retryTask() chạy nó tới xong (harness-blocked.test.ts:135–177).

Definition thiếu, cũ hơn, hay cần migration#

Bản ghi lưu kind và version; code nằm trong registry của tiến trình. Hai thứ có thể lệch nhau: extension chưa được cài lại sau restart, hoặc đã được nâng version. Mỗi lần reserve, scheduler tra definition theo tên trong snapshot registry hiện tại:

#resolve() và #fit(): cùng version thì chạy, version cũ hơn thì migrate, còn lại thì bị chặn
pi/packages/durable/src/harness/scheduler.tsdòng 1588–1619TypeScript
/** Resolve the record's definition by kind, migrating an older stored version. */#resolve(record: RunnableTaskRecord, snapshot: RegistrySnapshot): Resolution {	const fit = this.#fit(record, snapshot.task(record.kind));	if ("reason" in fit) return { kind: "blocked", reason: fit.reason };	if (!fit.migrates) return { kind: "ready", task: fit.task, record };	const definition = erased(fit.task);	try {		if (definition.migrate === undefined) throw missingMigration(record, definition);		const migrated = definition.migrate(record.input, record.state.checkpoint, record.version);		const state = { ...record.state, checkpoint: copyJson(migrated.checkpoint) };		const migratedRecord = { ...record, version: definition.version, input: copyJson(migrated.input), state };		return { kind: "ready", task: fit.task, record: migratedRecord as RunnableTaskRecord };	} catch (error) {		this.#failedMigrations.set(record.id, { task: fit.task, error });		// Blocked now without a commit: the graph and the host's wake must hear it.		queueMicrotask(() => this.#notifyBlocked());		this.#scheduleWake();		const extension = installerOf(snapshot, fit.task);		this.#report(error, { type: "migration_failed", ...this.#aboutTask({ ...recordTask(record), extension }) });		return { kind: "blocked", reason: "migration_failed" };	}}#fit(record: Pick<AnyTaskRecord, "id" | "version">, task: AnyTask | undefined): Fit {	if (task === undefined) return { reason: "missing_task" };	const version = task.definition.version;	if (version === record.version) return { task, migrates: false };	if (version < record.version) return { reason: "task_too_old" };	const failed = this.#failedMigrations.get(record.id);	if (failed?.task === task) return { reason: "migration_failed", error: failed.error };	return { task, migrates: true };}
Migration chỉ được tính ở đây, bản ghi đã migrate được ghi cùng commit chuyển sang running. Migration hỏng được nhớ theo đối tượng definition, nên chỉ thử lại khi registry trả về một definition khác (dòng 1617).

Nguyên tắc quan trọng: Harness không bao giờ kết thúc một task chỉ vì thiếu code, cả lúc mở lẫn về sau. Task bị chặn giữ nguyên bản ghi, vẫn sống, vẫn làm idle wait phải chờ, và được xét lại mỗi khi registry đổi (spec.md:2218–2227). Chỉ khi task bị chặn bị huỷ thì nó mới kết thúc orphaned, vì không còn code nào để chạy handler abort() (abort() ở scheduler.ts:553–561, và dòng 1533–1537 của #reserve()). Ví dụ migration thật duy nhất trong các task dựng sẵn là pi.tool version 2, thêm trường phân biệt kind cho input:

pi.tool version 2 migrate bản ghi version 1: lời gọi cũ đều do mô hình phát ra
pi/packages/durable/src/harness/tool.tsdòng 125–134TypeScript
export const ToolTask = defineTask<ToolTaskInput, ToolTaskCheckpoint, ToolTaskResult, ToolHooks>({	name: "pi.tool",	version: 2,	initial: () => ({ phase: "call" }),	// Version 1 had only model-issued calls.	migrate: (input, checkpoint) => ({		input: { kind: "model", ...(input as { assistant: EntryId; callId: string }) },		checkpoint: checkpoint as ToolTaskCheckpoint,	}),	phases: {

Khi definition được thay ngay trong lúc chạy (extension được cài lại cùng tên), task đang chạy không bị ngắt: ở ranh giới phase kế, #decide() thấy đối tượng definition đã khác; nếu definition mới nhận được bản ghi thì step ghi task về pending (dòng 1837–1839) và pass reservation sau chạy nó bằng code mới. Nếu không nhận được, task chạy tiếp bằng code cũ và báo task_outdated (spec.md:2294–2304).

abandonOnRestart: task mà người tạo chỉ chờ trong bộ nhớ#

Một số task chỉ có ý nghĩa với người tạo ra chúng trong cùng tiến trình. Ví dụ: một tool không replay-safe gọi một tool lồng nhau rồi await kết quả. Sau restart, tool cha không chạy lại (nó sẽ nhận kết quả interrupted), nên chẳng ai cần kết quả của lời gọi con nữa; để nó chạy tiếp chỉ tốn tiền và có thể gây hiệu ứng thừa. TaskOptions.abandonOnRestart đánh dấu những task như vậy (types.ts:385–390). Khi một Harness sau bắt đầu lập lịch, pass reservation đầu tiên đánh dấu abort chúng với abortReason: "restart" (dòng 1473–1480 ở trên), và lý do này lan xuống việc sở hữu thường. Mở chỉ để đọc thì không đánh dấu gì (spec.md:2244–2256). Handler abort() của pi.tool đọc lý do đó để nói đúng điều đã xảy ra:

Abort của pi.tool: bị huỷ, bị gián đoạn bởi restart, hay chưa bao giờ bắt đầu
pi/packages/durable/src/harness/tool.tsdòng 200–211TypeScript
abort: async (task, runtime, context) => {	const call = await readCall(runtime, task.input, context);	// Abandoned after a restart (`TaskOptions.abandonOnRestart`): say whether the call may have run.	const [code, message] =		task.abortReason !== "restart"			? ["aborted", `Tool ${call.name} was aborted`]			: task.state.checkpoint.phase === "execute"				? ["interrupted", `Tool ${call.name} was interrupted by a restart and may have partially run`]				: ["abandoned", `Tool ${call.name} was not started: its caller ended with a restart`];	const ending = { status: "aborted" } as const;	await settle(runtime, task.input, call, ending, (slot) => fromSlot(slot, code, message), context);},
Đã ghi ý định (execute) thì tool có thể đã chạy một phần: interrupted. Chưa ghi (call) thì chắc chắn chưa chạy: abandoned.

Hai điểm khác biệt với abort thường. Task có mark restart mà thiếu definition thì chờ definition thay vì bị orphaned, để handler abort() của nó vẫn chạy và dọn dẹp được khi extension được cài lại; một yêu cầu abort thật (abortTask(), conversation abort) thay mark restart và khi đó task mới bị orphaned (scheduler.ts:448–453). Người dùng hiếm khi phải tự đặt cờ này: lời gọi lồng nhau và task con mà một tool không replay-safe tạo qua api.createTask() tự nhận nó (tool.ts:376).

pi.generation: từ transcript tới câu trả lời#

Một task pi.generation ứng với một câu trả lời của mô hình, kể cả các lần retry và poll của câu trả lời đó. Một lần chạy (run) gồm nhiều lượt (turn) nên gồm nhiều generation nối tiếp: generation trả lời bằng lời gọi tool sở hữu các task tool, chờ chúng, rồi trao run cho một generation mới. Input của task rỗng; các input của run nằm trong document pi.live, không nằm trong task (spec.md:4181–4182). Đây là document mà mọi task dựng sẵn cùng ghi:

LiveState (pi.live): điều khiển run và phần trình bày của generation, tool, compaction đang chạy
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 conversation bận: taskId là task đang chịu trách nhiệm settle các input. Mọi thứ khác là trình bày cho UI, và cũng là nơi lưu phần đầu ra dở dang để phục hồi.

Checkpoint của generation là một kiểu hợp năm phase:

 prepare ──commit pi.system, ghim cutoff──► request ──stream, partial ≤ 1 lần/100 ms──► classify
    │
    └─ vượt ngưỡng chặn ──► waiting trên compaction chặn ──► prepare

 classify chọn một nhánh, mỗi nhánh là một commit:
   stop / length .............. answer(): final boundary, settle input, có thể mở run mới
   toolUse + lời gọi .......... tools: waiting allSettled trên pi.tool × n
                                  └─► finishToolRound() ──► generation mới, cùng run
   error, tràn context ........ waiting trên compaction chặn (chỉ một lần) ──► prepare
   error, thử lại được ........ retry: sleeping tới until ──► prepare, attempt + 1
   deferred ................... poll: sleeping tới pollAt ──► fetchDeferred() ──► classify
   lỗi khác ................... kết thúc failed; input unanswered với model_error
Các phase của pi.generation. Mọi lần chờ (compaction, tool, backoff, poll) là trạng thái waiting hoặc sleeping đã commit, không có lần chờ nào trong bộ nhớ.

prepare: dựng system prompt và chốt request#

prepare phân giải agent của conversation (một lần cho phase), kiểm tra model có tồn tại không (không thì kết thúc failed với lý do no_model), render các section của system prompt và tính các entry pi.system cần thêm. Cơ chế section và entry hệ thống theo vị trí thuộc chương mô hình khái niệm; ở đây chỉ cần biết planSystemEntries() trả về những entry mô tả phần prompt và tool đã đổi (generation.ts:155–176). Phần cuối của phase là nơi các quyết định được ghi:

Cuối prepare: ngưỡng compaction, rồi một commit nối pi.system và chuyển sang request
pi/packages/durable/src/harness/generation.tsdòng 176–207TypeScript
const entries = planSystemEntries(view, desired, agent.tools, runtime.now());const threshold =	compacted === undefined		? thresholdCompaction(view, entries, resolved.contextWindow, compactionPolicy(settings, model))		: undefined;if (threshold === "blocking") {	// Compact first and prepare again; the transcript is unchanged until the compaction appends.	await runtime.commit(async (tx): Promise<Next> => {		const child = await createCompaction(tx, conversationId, { reason: "threshold" }, runtime.taskId);		const checkpoint = { phase: "prepare", attempt, compacted: child, threshold: true } as const;		return { status: "waiting", checkpoint, on: [child], policy: "allSettled" };	}, context);	return;}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);
Hai nhánh: vượt ngưỡng chặn thì không nối gì mà chờ một compaction do chính generation sở hữu (dòng 183–187); còn lại thì một commit nối entry hệ thống, có thể tạo compaction nền, và ghim request (dòng 190–207).

request: gửi, stream, và phân loại#

request: chuyển partial cũ thành entry, gửi context đến cutoff, cho beforeRequest thay message
pi/packages/durable/src/harness/generation.tsdòng 209–236TypeScript
request: async (task, runtime, context) => {	const { attempt, compacted, model: ref, thinkingLevel, streamOptions, cutoff } = task.state.checkpoint;	const conversationId = runtime.conversationId;	await runtime.commit(async (tx) => {		const live = await tx.doc(LiveDoc, conversationId);		await convertPartial(tx, live, conversationId);		live.generation = { attempt };		return undefined;	}, context);	const model = runtime.models.getModel(ref.provider, ref.modelId);	if (model === undefined) return failNoModel(runtime, ref, context);	const view = await runtime.context(conversationId, context, { at: cutoff });	let messages = view.messages;	await runtime.hooks.each("beforeRequest", async (hook) => {		const replaced = await hook({ messages }, runtime, context);		if (replaced !== undefined) messages = replaced.messages;	});	const options: SimpleStreamOptions = {		...streamOptions,		signal: runtime.signal,		telemetry: context.telemetry,		sessionId: await ensureProviderSessionId(runtime, context),		...(thinkingLevel === "off" ? {} : { reasoning: thinkingLevel }),	};	const message = await streamResponse(runtime, model, messages, options, attempt, context);	const request = { attempt, compacted, model: ref, cutoff, messages: view.messages };	await classify(runtime, request, message, context);},
Dòng 212–217 là phần phục hồi: nếu lần thử trước bị cắt ngang sau khi đã commit một phần câu trả lời, phần đó được nối thành một entry pi.assistant với stop reason aborted trước khi gửi lại.

Hai chi tiết đáng dừng lại. Context được đọc at: cutoff (dòng 220), không phải "transcript hiện tại", nên lần gửi lại sau crash gửi đúng những message đã ghim; entry partial vừa được chuyển đổi nằm sau cutoff, và context derivation cũng loại mọi message assistant có stop reason aborted, error hay deferred khỏi các request sau (context.ts:10, context.ts:222–229). Hook beforeRequest chạy lại ở mỗi lần thử, kể cả lần phục hồi, và kết quả của nó chỉ dùng cho request đó, không được lưu. Test harness-generation-recovery.test.ts:125–163 kiểm tra đúng điều này: sau khi mở lại, transcript là pi.user, pi.assistant (aborted), pi.assistant và request gửi lại chỉ chứa message của người dùng.

Stream: partial được commit tối đa 100 ms một lần#

streamResponse(): throttle partial, một commit đang bay, dừng sạch trước khi phân loại
pi/packages/durable/src/harness/generation.tsdòng 459–497TypeScript
const flush = (): void => {	timer = undefined;	const partial = pending;	pending = undefined;	if (partial === undefined || stopped) return;	inFlight = (async () => {		// Copy synchronously: the provider keeps mutating its partial.		const message = copyJson(partial, { omitUndefinedProperties: true });		await runtime.commit(async (tx) => {			const live = await tx.doc(LiveDoc, runtime.conversationId);			live.generation ??= { attempt };			assignJson(live.generation as Draft<Record<string, JsonValue>>, "message", message);			return undefined;		}, context);	})()		.catch((error: unknown) => {			// Rejections after an abort mark or close are expected; the committed state stays consistent.			if (!runtime.signal.aborted) runtime.report(error);		})		.finally(() => {			inFlight = undefined;			if (pending !== undefined && !stopped) timer = runtime.setTimer(flush, interval);		});};try {	const events = runtime.models.streamSimple(model, { messages: [...messages] }, options);	for await (const event of events) {		// A partial without content, such as pi-ai's opening `start` event, shows nothing; a deferred response		// never gets past it, so it never leaves a partial.		if (event.type === "done" || event.type === "error" || event.partial.content.length === 0) continue;		pending = event.partial;		if (timer === undefined && inFlight === undefined) timer = runtime.setTimer(flush, interval);	}	return await events.result();} finally {	stopped = true;	timer?.();	await inFlight;}
Bản sao đồng bộ ở dòng 466 cần thiết vì provider tiếp tục sửa đối tượng partial của nó. Khối finally dừng throttle và chờ commit đang bay, nên không có partial cũ nào rơi vào sau kết quả.

Khoảng cách là settings.progress.partialIntervalMs, mặc định 100 ms (agent.ts:34–37). Đây là thoả hiệp giữa độ mượt và số lần ghi: một crash mất tối đa khoảng đó, và host có storage ở xa có thể nới ra, ví dụ 500 ms (README.md:502). Timer dùng runtime.setTimer(), nên invocation kết thúc là throttle dừng theo.

classify: một commit cho mỗi kết cục#

classify() trước hết thoát nếu tín hiệu đã bị huỷ (abort mark hoặc đóng: invocation abort hoặc lần chạy sau sẽ xử lý), rồi xử lý deferred bằng một sleep tới pollAt (mặc định 5 giây sau, và luôn muộn hơn lần poll trước, generation.ts:511–531), gọi các hook afterResponse, và rẽ nhánh: toolUse có lời gọi sang vòng tool, stop/length sang trả lời. Nhánh lỗi là nơi retry và compaction gặp nhau:

Đi qua từng nhánh. Tràn context (pi-ai isContextOverflow()), khi compaction bật, generation chưa compaction lần nào (compacted rỗng) và phép chọn điểm cắt tìm được chỗ cắt: một commit nối entry lỗi, tạo compaction do generation sở hữu với lý do overflow, rồi chờ nó (allSettled) với checkpoint prepare mang theo compacted và văn bản lỗi. Khi prepare chạy lại, nó kiểm tra compaction có hoàn tất với một entryId không; không thì thất bại model_error (generation.ts:141–146). Lần tràn thứ hai thì không compaction nữa, vì compacted đã có giá trị. Lỗi tạm thời (isRetryableAssistantError()) mà còn lượt (attempt <= maxRetries, mặc định 3 lần thử lại với delay gốc 2 giây, agent.ts:20–25): nối entry lỗi và ngủ tới now + retryDelayMs(policy, attempt). Phase retry sau đó quay lại prepare với attempt kế, nên thay đổi agent hay settings trong lúc chờ có hiệu lực (generation.ts:238–250). Mọi trường hợp khác: nối entry lỗi, kết thúc run với input unanswered lý do model_error, và settings.afterFailedRun quyết định số phận của các input xếp hàng phía sau (generation.ts:411–428).

Mọi câu trả lời, kể cả lỗi và partial bị huỷ, đi qua appendAssistant(), hàm nối entry và cộng usage của message vào document pi.usage trong cùng commit, theo khoá provider/model (generation.ts:739–750, usage.ts:24–39). Vì vậy lần thử thất bại cũng được tính tiền, còn lần thử bị cắt ngang chưa có phản hồi thì không có gì để tính.

answer: final boundary và onYield#

answer(): hook onYield có thể tiếp tục run; nếu không, settle input done và mở run cho follow-up
pi/packages/durable/src/harness/generation.tsdòng 597–622TypeScript
async function answer(runtime: Runtime, message: AssistantMessage, context: Context): Promise<void> {	let continuation: UserInput | undefined;	await runtime.hooks.each("onYield", async (hook) => {		if (continuation !== undefined) return;		continuation = (await hook(message, runtime, context))?.continue;	});	const conversationId = runtime.conversationId;	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);}
Continuation của onYield chỉ được dùng khi boundary không chọn input nào của người dùng và không có reset; một continuation bị bỏ qua không được thử lại.

"Final boundary" là điểm đặt các mục đang xếp hàng trong inbox sau một câu trả lời (xem mục inbox). Nếu boundary chọn được follow-up, startRun() tạo một generation mới cho chúng ngay trong commit đó (generation.ts:752–769).

Vòng tool: generation sở hữu, chờ, rồi trao run#

startToolRound(): một commit nối câu trả lời, tạo task tool do generation sở hữu, rồi chờ
pi/packages/durable/src/harness/generation.tsdòng 636–674TypeScript
const conversationId = runtime.conversationId;const messages =	request.messages ?? (await runtime.context(conversationId, context, { at: request.cutoff })).messages;const offered = new Set(getCurrentTools(messages).map((tool) => tool.name));// Read as the round starts; a tool is resolved as its tool task resolves it.const tools = (await runtime.agent(context)).tools;const sequential =	runtime.settings.toolExecution === "sequential" ||	calls.some(		(call) =>			offered.has(call.name) && tools.find((tool) => tool.name === call.name)?.executionMode === "sequential",	);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);
Tool chỉ được chấp nhận nếu request đã mời (offer) nó: tập tool được phát lại từ chính context đã gửi (getCurrentTools(), dòng 639), không phải từ agent hiện tại. Lời gọi tới tool không được mời nhận kết quả tool_unavailable ngay, không có task.

Vòng là tuần tự khi settings.toolExecution là sequential hoặc bất kỳ tool nào được gọi khai báo executionMode: "sequential"; khi đó chỉ lời gọi đầu có task, các lời gọi còn lại nằm trong pending của checkpoint và phase tools tạo từng cái một (generation.ts:264–282). Khi mọi task tool đã terminal, phase tools kết thúc vòng:

finishToolRound(): afterTools, rồi terminate/handoff kết thúc run, hoặc postTools trao run cho generation mới
pi/packages/durable/src/harness/generation.tsdòng 700–736TypeScript
await runtime.hooks.each("afterTools", (hook) => hook(assistant, results, runtime, context));// Every call of the round, including those answered without a task, must ask to terminate.const terminate =	slots.length > 0 &&	slots.every((slot) => slot.taskId !== undefined && controls.get(slot.taskId)?.terminate === true);const added = [...controls.values()].flatMap((control) => control?.addTools ?? []);// The last handoff in call order wins.const handoff = [...controls.values()].findLast((control) => control?.handoff !== undefined)?.handoff;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) {		if (handoff !== undefined) {			const message = { role: "user", content: handoff, timestamp: now } as const;			const entry = await tx.appendEntry(ResetEntry, conversationId, { head: "self", model: [message] });			boundary.head = entry.id;		}		const { users } = await applyBoundary(tx, boundary, "final", now);		endRun(tx, live, runtime.taskId, { status: "done", answer: assistant });		if (users.length > 0) await startRun(tx, conversationId, live, users);	} 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);
Dòng 731: steer được chọn ở boundary postTools gia nhập input của run đang chạy. Dòng 732: handOver() chuyển pi.live.run.taskId sang generation mới; các input vẫn placed cho tới câu trả lời cuối.

Các control trong kết quả tool được áp dụng ở đây (spec.md:3586–3594): terminate chỉ có hiệu lực khi mọi lời gọi của vòng đều yêu cầu, giống vòng lặp agent của pi; handoff nào cũng đủ, lấy cái cuối theo thứ tự lời gọi, và nối một entry pi.reset với head: "self", đúng như reset(handoff); addTools ghi tên tool vào pi.agent để request sau mời chúng (agent.ts:93–118). Generation kết thúc completed với entryId của câu trả lời gọi tool; run thì sống tiếp trong generation mới.

Handler abort() của generation chỉ chạy sau khi các task tool của nó đã terminal (thứ tự từ dưới lên). Nó huỷ phản hồi deferred nếu đang poll, chuyển partial đã commit thành entry, nối kết quả aborted cho các lời gọi tuần tự chưa bắt đầu, và settle input unanswered lý do aborted (generation.ts:284–315).

Bốn hook của generation#

HookKhi nàoCách ghép nhiều handler
beforeRequestTrước mỗi lần thử, kể cả phục hồi; chỉ áp cho request đóChuỗi thay thế: handler sau nhận message của handler trước
afterResponseMọi message kết thúc của provider, trước khi phân loạiTất cả quan sát
onYieldCâu trả lời cuối; { continue } nối một tin nhắn người dùng và tiếp tục runContinuation đầu tiên thắng
afterToolsKhi mọi tool của vòng đã terminalTất cả quan sát

Bảng trên tóm tắt spec.md:2912–2920. Lỗi thường trong hook được báo qua onReport và handler kế vẫn chạy; lỗi huỷ thì luôn lan ra (scheduler.ts:2096–2113). Spec cũng nói rõ: hook chạy ngoài dòng commit, nên một crash trước commit tiêu thụ kết quả của nó có thể làm hook chạy lại (spec.md:2909–2910).

pi.tool: ý định trước, hiệu ứng sau#

Mỗi lời gọi tool là một task pi.tool riêng. Với lời gọi do mô hình phát ra, task thuộc về generation và input chỉ gồm ID của entry câu trả lời cùng callId: lời gọi được đọc lại từ entry đó, nên bản ghi terminal (được giữ mãi) vẫn nhỏ. Lời gọi lồng nhau (nested) phải mang lời gọi trong input vì không entry nào chứa nó.

Input, checkpoint và kết quả của pi.tool
pi/packages/durable/src/harness/tool.tsdòng 58–85TypeScript
/** * A model-issued call, read from its assistant entry, or a nested call a running tool made through `executeTool()`, * whose call the input carries because no entry holds it. */export type ToolTaskInput =	| { kind: "model"; assistant: EntryId; callId: string }	| {			kind: "nested";			parent: TaskId;			parentCallId: string;			key: string;			call: ToolCall;			/** `false`: commit no running output, details, or diagnostics to the slot; the result still gets them. */			progress?: false;	  };export type ToolTaskCheckpoint =	| { phase: "call" }	/** Durable intent: the final arguments and the replay policy recorded before execution. */	| { phase: "execute"; arguments: JsonObject; replay: "safe" | "unsafe" };/** * A model-issued call's result is its transcript entry; a nested call's result is in its caller's * `NestedCallDoc`, so the receipt stays small and the result retires with the caller. A version 1 task that was * already holding its outcome as `completing` when the Harness upgraded finishes with a version 1 result, * `{ entryId, control? }` without `kind`; treat a result without `kind` as a model-issued call's. */export type ToolTaskResult = { kind: "model"; entryId: EntryId; control?: ToolControl } | { kind: "nested" };
Toàn bộ ý tưởng phục hồi nằm ở phase execute: nó lưu đối số cuối cùng (sau khi sửa, hook và ép kiểu) và chính sách replay trước khi tool chạy.

Phía tác giả tool, mọi thứ khai báo trên ToolRegistration, mở rộng Tool của pi-ai:

Phase call: phân giải, kiểm tra, hook, ghi ý định, chạy#

Đi theo thứ tự. Tên tool được tra trong agent.tools (lời gọi của mô hình) hoặc agent.callable (lời gọi lồng nhau) của lần phân giải agent trong phase này (tool.ts:214–223); không thấy thì trả tool_unavailable. prepareArguments sửa các lỗi đối số mô hình hay mắc (như mảng gửi dưới dạng chuỗi JSON), rồi đối số được kiểm tra bằng validateToolArguments() của pi-ai. Chuỗi hook beforeTool có thể thay đối số hoặc chặn; chặn đầu tiên thắng, và hook ném lỗi thì lời gọi bị chặn với nội dung lỗi (dòng 154–157). Đối số được kiểm tra lại sau hook. Rồi đến commit then chốt ở dòng 168–177: slot của lời gọi trong pi.live.tools chuyển sang running và checkpoint thành { phase: "execute", arguments, replay }. Chỉ sau đó run() mới dựng môi trường và gọi execute().

Phase execute: chỉ phục hồi mới tới đây#

Phase execute: chạy lại chỉ khi cả chính sách đã lưu và khai báo hiện tại đều là safe
pi/packages/durable/src/harness/tool.tsdòng 180–198TypeScript
/** Recovery after intent: rerun only when the stored and the current policy both say `safe`. */execute: async (task, runtime, context) => {	const { arguments: args, replay } = task.state.checkpoint;	const call = await readCall(runtime, task.input, context);	const tool = await resolveTool(runtime, task.input, call.name, context);	if (replay === "safe" && tool?.replay === "safe") {		// The rerun reports from scratch; clear what the interrupted attempt published.		await runtime.commit(async (tx) => {			const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId);			if (slot !== undefined) clearProgress(slot);			return undefined;		}, context);		return run(runtime, task.input, call, tool, args, context);	}	const message = `Tool ${call.name} was interrupted and may have partially run`;	// `failed` records cancellation intent, so the call's owned conversations, left unsupervised, are aborted.	const ending = { status: "failed", message } as const;	await settle(runtime, task.input, call, ending, (slot) => fromSlot(slot, "interrupted", message), context);},
Lần chạy lại dùng đối số đã lưu và không gọi lại beforeTool. Không chạy lại thì kết quả là lỗi interrupted dựng từ phần đầu ra đã commit trong slot.

Quy tắc "cả hai đều safe" có hai chiều: khai báo hiện tại là unsafe có thể phủ quyết một replay đã được lưu là safe, nhưng khai báo hiện tại là safe không nâng cấp được một intent đã lưu là unsafe; tool không còn phân giải được (extension chưa cài lại, bị bỏ chọn) thì bị coi là unsafe (spec.md:3601–3607). Test chạy cả ba tổ hợp và đếm số lần chạy (harness-tools-recovery.test.ts:136–163).

Kết quả interrupted kết thúc task với trạng thái failed, không phải completed (dòng 196). Lý do có chủ đích: failed là ý định huỷ, nên mọi conversation mà lời gọi sở hữu (ví dụ subagent của nó), giờ không còn ai giám sát, bị abort theo. Một kết quả isError mà tool tự trả về thì vẫn completed và việc con của nó sống tiếp (spec.md:4356–4365). Thông điệp mà mô hình đọc được lấy từ test:

Mô hình nhận phần đầu ra đã commit, rồi khối chẩn đoán của harness
pi/packages/durable/test/harness-tools-recovery.test.tsdòng 128–131TypeScript
expect(result).toMatchObject({ isError: true, details: { run: 1 } });expect(text(result)).toBe(	"run 1\n|<harness>\n[error] Tool work was interrupted and may have partially run\n</harness>",);

Chạy tool: đầu ra, giới hạn, thời gian#

api.output(chunk) đẩy văn bản vào một OutputBuffer có giới hạn (của Chord) và đánh dấu tiến độ; các commit tiến độ ghi phần đầu ra được giữ lại vào slot. Nhịp commit thích nghi: thay đổi đầu tiên sau một lúc rảnh được commit ngay, sau đó mỗi commit đẩy lần kế ra ít nhất outputIntervalMs (mặc định 100 ms) và thêm thời gian theo kích thước đã ghi ở 100 KiB/s (spec.md:3307–3317; mã ở tool.ts:479–532). Vì thế một crash mất tối đa một cửa sổ throttle đầu ra, và kết quả interrupted mang đúng phần đã commit đó. Phần cuối của run():

run(): dựng môi trường ngay trước khi chạy, đo thời gian, biến lỗi ném ra thành kết quả lỗi
pi/packages/durable/src/harness/tool.tsdòng 421–458TypeScript
	let result: ToolExecutionResult;	let ending = COMPLETED;	// Execution time of this attempt; a rerun after recovery measures only itself.	let durationMs: number | undefined;	try {		env = await runtime.env(context);		const startedAt = performance.now();		try {			result = await tool.execute(args, api, context);		} finally {			durationMs = Math.round(performance.now() - startedAt);		}	} catch (error) {		if (runtime.signal.aborted) {			ended = true;			for (const waiter of await progress.stop()) waiter.reject(error);			throw error;		}		result = { isError: true, diagnostics: [toolDiagnostic("tool_error", errorMessage(error))] };		// A throw, from `execute()` or from building the environment, ends the task `failed`, which cancels what the call		// owned; it no longer supervises it. The error text is already in the result.		ending = { status: "failed", message: `Tool ${call.name} threw` };	}	ended = true;	// No admission starts after `ended`; let those underway commit, so settlement aborts and lists their calls.	await Promise.allSettled(admissions);	reported.output.end();	// Details still waiting for a progress commit settle with the terminal commit, the final flush.	const pending = await progress.stop();	try {		const settled = await finalResult(runtime, input, call, tool, result, reported, context);		await settle(runtime, input, call, ending, () => settled, context, durationMs);	} catch (error) {		for (const waiter of pending) waiter.reject(error);		throw error;	}	for (const waiter of pending) waiter.resolve();}
Dòng 434–438: lỗi xảy ra khi invocation đã bị báo hiệu (abort, đóng) thì lan ra thay vì thành kết quả; invocation abort hoặc lần mở sau sẽ quyết định. Dòng 446: chờ các lời gọi lồng nhau đang được nhận, để bước settle thấy hết chúng.

Môi trường được dựng bằng HarnessOptions.env cho mỗi lần chạy, từ cwd hiện tại của conversation, nên một lần chạy lại sau phục hồi có thể thấy thư mục khác lần đầu (test ở harness-tools-recovery.test.ts:165–211). durationMs đo bằng đồng hồ đơn điệu, chỉ cho lần thử này.

Ba kênh kết quả và structuredOutput#

Kết quả có ba kênh: output cho mô hình, structuredOutput cho chương trình gọi tool (lời gọi lồng nhau, script code mode), và details cho UI (types.ts:155–175). Transcript chỉ lưu output và details. Trước khi settle, finalResult() lấy phần đầu ra giữ lại làm output nếu tool không trả, chạy chuỗi afterTool, giới hạn văn bản, rồi kiểm tra structuredOutput với structuredOutputSchema (tool.ts:540–603, tool.ts:607–628). Giá trị sai hợp đồng làm lời gọi lồng nhau thành kết quả lỗi với chẩn đoán invalid_structured_output; với lời gọi của mô hình (vốn không bao giờ thấy kênh này) giá trị chỉ bị bỏ và host được báo qua onReport. Mọi chẩn đoán (diagnostic) được nối vào cuối nội dung thành một khối <harness>, nên message lưu trong transcript đúng bằng thứ mô hình đã đọc, còn danh sách có cấu trúc nằm trong data.diagnostics của entry (tool.ts:885–913).

settle(): dọn lời gọi con, rồi một commit kết quả#

settle(): huỷ lời gọi lồng nhau còn chạy, nối entry pi.tool-result, đánh dấu slot done, kết thúc task
pi/packages/durable/src/harness/tool.tsdòng 646–660, 674–697TypeScript
// A crash in between leaves the call in `execute`: a safe rerun reattaches to the calls, an interruption ends here.// Every live nested call the task owns, also those an earlier attempt made and this one never called again.const nestedIds = (await runtime.ownedTasks(context))	.filter((record) => record.kind === ToolTask.definition.name && (record.input as ToolTaskInput).kind === "nested")	.map((record) => record.id);// Read before the settling commit: no admission starts after `ended`, and those underway were awaited.const madeNested = (await runtime.memo(MADE_NESTED_CALLS, context)) === true;// Mark them all before waiting for any: one may run until a sibling is cancelled.await Promise.all(nestedIds.map((id) => runtime.abortOwned(id, context)));await runtime.commit(async (tx) => {	const conversationId = runtime.conversationId;	const live = await tx.doc(LiveDoc, conversationId);	const slot = toolSlot(live, runtime.taskId);	const result = build(slot);	let settled: ToolTaskResult;	} else {		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 };	}	// A call that made no nested calls, in any attempt, has no slots below it; skipping the scan keeps many leaves	// linear. Committed before the read: no nested call is admitted after `ended`.	if (madeNested) removeNestedSlots(live, runtime.taskId);	if (ending.status === "aborted") return { status: "terminal", outcome: { status: "aborted", result: settled } };	if (ending.status === "failed") {		const error = { message: ending.message };		return { status: "terminal", outcome: { status: "failed", error, result: settled } };	}	return { status: "terminal", outcome: { status: "completed", result: settled } };}, context);
Lời gọi lồng nhau được tìm qua ownedTasks(), tức là cả những lời gọi một lần thử trước đã tạo mà lần chạy lại không gọi nữa. Tất cả bị đánh dấu abort trước khi chờ cái nào, vì một lời gọi có thể chạy mãi cho tới khi anh em của nó bị huỷ.

Các control (terminate, handoff, addTools) chỉ được ghi vào kết quả khi task kết thúc completed (dòng 682–685); generation đọc chúng ở phase tools. usage của kết quả được cộng vào pi.usage.tools[tên tool] trong cùng commit.

Hook của tool và memo: quyết định không lặp lại sau crash#

Hook beforeTool (chặn hoặc sửa đối số) và afterTool (thay kết quả) chạy trong invocation của task tool, ngoài dòng commit. Harness không tự lưu quyết định của hook. Thứ nó lưu là hệ quả: đối số cuối cùng trong ý định. Vì vậy sau khi ý định đã được ghi, phục hồi không bao giờ gọi lại beforeTool (spec.md:3456–3464). Nhưng crash xảy ra trước ý định thì phase call chạy lại từ đầu và hook được hỏi lần nữa. Một hook mà câu trả lời phải ổn định, ví dụ xin người dùng phê duyệt, dùng api.memo(name, candidate) (mẫu approval:decision ở harness-tools.test.ts:810–816): HookApi chính là runtime của task, nên memo nằm trong TaskRecord.memos, người ghi đầu thắng, và tồn tại qua crash (spec.md:2922–2923). Test chứng minh cả hai nửa:

Phase call bị cắt ngang trước ý định: beforeTool được hỏi lại, memo giữ quyết định đầu, tool chỉ chạy một lần
pi/packages/durable/test/harness-tools-recovery.test.tsdòng 213–250TypeScript
it("reruns beforeTool when interrupted before intent, and executes once", async () => {	const path = await sqlitePath();	const setup = chatSetup();	let runs = 0;	addTool(		setup.registry,		tool("work", async () => {			runs++;			return { output: [] };		}),	);	const reached = deferred();	let asked = 0;	const decisions: string[] = [];	addHooks(setup.registry, ToolTask, {		beforeTool: async (_call, api, callContext) => {			asked++;			// A durable first-writer-wins decision survives the rerun.			decisions.push(await api.memo("test:decision", `attempt ${asked}`, callContext));			if (asked === 1) {				reached.resolve();				await aborted(callContext.abortSignal!);			}			return undefined;		},	});	setup.faux.setResponses([call("work"), DONE]);	let opened = await open(path, setup);	const id = (await opened.root.submit({ type: "input", content: "go" }, context)).id;	await reached.promise;	await opened.harness.close(context);	opened = await open(path, setup);	expect((await (await opened.harness.submission(id, context))!.wait(context)).status).toBe("done");	expect([asked, runs]).toEqual([2, 1]);	expect(decisions).toEqual(["attempt 1", "attempt 1"]);	await opened.harness.close(context);});
Test cắt ngang bằng close(); với việc phase chạy lại từ đầu thì close và crash như nhau, chỉ khác ở bộ đếm restart. Hook và task dùng chung một không gian tên memo, nên tác giả hook nên đặt tiền tố cho tên (ở đây là test:decision, spec.md:5519–5520).

Lời gọi lồng nhau, callers và tool hoãn nạp#

Một script code mode hay một cầu nối MCP gọi các tool khác từ bên trong một tool. Nếu những lời gọi đó là lời gọi hàm bình thường, crash làm mất dấu cái nào đã chạy, lần chạy lại sẽ chạy lại tất cả, và chúng không có hook, giới hạn đầu ra hay hiển thị riêng. Pi Durable biến mỗi lời gọi như vậy thành một task pi.tool riêng, do task của tool gọi sở hữu, qua api.executeTool():

executeTool(): khoá theo thứ tự gọi, nhận lời gọi trong một commit, chờ trong bộ nhớ, đọc kết quả từ NestedCallDoc
pi/packages/durable/src/harness/tool.tsdòng 390–418TypeScript
executeTool: async (name, toolArgs, callContext, options = {}) => {	assertLive();	const key = options.key ?? String(++sequence);	if (options.key !== undefined) checkKey(options.key);	// The memo first, once per attempt; a failed one is tried again by the next call.	madeNestedMemo ??= runtime.memo(MADE_NESTED_CALLS, true, callContext).catch((error: unknown) => {		madeNestedMemo = undefined;		throw error;	});	// Registered now, so cleanup waits for it.	const admission = madeNestedMemo.then(() =>		startNestedCall(			runtime,			call.id,			name,			toolArgs,			{ key, progress: options.progress, abandonOnRestart: !resumes },			callContext,		),	);	admissions.push(admission);	const id = await admission;	const settled = await runtime.waitForTask(id, callContext);	const stored = (await runtime.snapshot(NestedCallDoc, runtime.taskId, key, callContext))?.result;	// A copy the tool may change; a nested call the scheduler faulted or orphaned stored none.	return stored === undefined		? fallbackResult(id, name, settled.state.outcome)		: (copyJson(stored as unknown as JsonValue) as unknown as NestedToolExecutionResult);},
Khoá mặc định là vị trí của lời gọi trong lần thử này: 1, 2, ... Lời gọi lồng nhau có ID <callId>/<key>. Khoá tường minh không được là số nguyên dương, để không bao giờ trùng khoá mặc định (tool.ts:939–949).
startNestedCall(): khoá đã dùng thì gắn lại task cũ, khoá mới thì tạo task con
pi/packages/durable/src/harness/tool.tsdòng 744–770TypeScript
await runtime.commit(async (tx) => {	// Opened, or created empty for a key not made yet.	const state = await tx.doc(NestedCallDoc, runtime.taskId, key, null);	const existing = state.taskId;	if (existing !== undefined) {		const input = (await tx.task(existing))?.input as ToolTaskInput | undefined;		const same =			input?.kind === "nested" &&			input.call.name === name &&			jsonEqual(input.call.arguments as JsonValue, call.arguments as JsonValue);		if (!same) throw new Error(`Nested call ${call.id} was already made with another tool or other arguments`);		id = existing as TaskId<ToolTaskResult>;		replayed = state.result !== undefined;		return undefined;	}	const input: ToolTaskInput = {		kind: "nested",		parent: runtime.taskId,		parentCallId,		key,		call,		...(options.progress === false ? { progress: false } : {}),	};	const created = await tx.createTask(ToolTask, input, {		ownership: { kind: "task", taskId: runtime.taskId },		abandonOnRestart: options.abandonOnRestart,	});
Cùng khoá nhưng khác tên tool hay khác đối số thì ném lỗi: lần chạy lại đã đi đường khác. Dòng 769: lời gọi do một tool không replay-safe tạo ra nhận abandonOnRestart.

Mỗi lời gọi lồng nhau là một thành viên của NestedCallDoc, một họ document (document family) có phạm vi task của tool gọi, khoá theo khoá lời gọi, chứa { taskId, result? }. Một document cho mỗi lời gọi, nên nhận một lời gọi mới không phải viết lại các lời gọi khác; tất cả nghỉ hưu cùng tool gọi (tool.ts:101–114). Kết quả lưu ở đó đúng bằng thứ executeTool() trả về, không có output dành cho mô hình, nên một ảnh hay đuôi đầu ra bash chỉ được lưu một lần. Từ đó suy ra hai hành vi khi restart:

  • Tool gọi replay-safe chạy lại và gọi theo cùng thứ tự (hoặc cùng khoá): mỗi executeTool() gắn lại vào task đã có, đã xong hay còn chạy, thay vì tạo mới. Đây không phải exactly-once: một lời gọi con bị crash cắt ngang trước khi commit kết quả theo chính sách replay của nó (spec.md:3201–3209). Test: tool gọi chạy hai lần, con safe chạy hai lần, con unsafe một lần và trả interrupted, tổng cộng vẫn chỉ có hai task con (harness-nested-tools-restart.test.ts:400–463).
  • Tool gọi không replay-safe không bao giờ chạy lại, nên các lời gọi con chưa xong của nó bị bỏ (abandoned) trước khi kịp chạy lại: con đã ghi ý định nhận interrupted, con chưa ghi nhận abandoned (harness-nested-tools-restart.test.ts:93–166).

Ai được gọi tool nào#

Ba khái niệm liên quan: callers trên tool (mô hình, tool khác, hay cả hai), modelTools trên agent của conversation (thu hẹp những tool được mời mô hình), và deferred (tool chỉ được mời sau khi được nạp). Phép phân giải agent ghép chúng lại:

resolveAgent(): tools mời mô hình, deferred chờ nạp, callable cho lời gọi lồng nhau
pi/packages/durable/src/harness/agent.tsdòng 259–266TypeScript
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"));

Lời gọi của mô hình được tra trong tools, lời gọi lồng nhau trong callable. Cách dùng điển hình: tool codemode là callers: ["model"] nên script không khởi chạy script; các tool MCP có thể khai báo callers: ["tools"] để mô hình không bao giờ được mời chúng mà chỉ gọi qua script (README.md:333–347). Tool hoãn nạp được nạp bởi tool tool_search, bản sao của coding agent (chương Codemode, MCP và tool search):

tool_search: xếp hạng Agent.deferred bằng BM25 và nạp kết quả qua control.addTools
pi/packages/durable/src/tool-search/tool-search.tsdòng 60–85TypeScript
// Searching changes what the model is offered; scripts have their own `searchTools()`.callers: ["model"],replay: "safe",async execute({ query, limit }, api, context) {	if (query.trim() === "") throw new Error("query must not be empty");	const max = limit ?? DEFAULT_TOOL_SEARCH_LIMIT;	if (!Number.isInteger(max) || max <= 0) throw new Error("limit must be a positive integer");	const candidates = (await api.agent(context)).deferred;	const documents = candidates.map((tool) => ({ name: tool.name, text: searchText(tool) }));	const loaded = rankDocuments(query, documents, max);	const text =		loaded.length === 0			? "No matching tools found."			: `Loaded ${loaded.length} tool${loaded.length === 1 ? "" : "s"}. They are available from your next call:\n${loaded					.map((name) => {						const description = candidates.find((tool) => tool.name === name)?.description ?? "";						return `- ${name}: ${description.trim().split(/\r?\n/)[0]}`;					})					.join("\n")}`;	const details: ToolSearchToolDetails = { loaded };	return {		output: [{ type: "text", text }],		details,		...(loaded.length === 0 ? {} : { control: { addTools: loaded } }),	};},
Nạp bằng control thay vì sửa document trực tiếp: addTools được áp ở phase tools của generation, ghi tên vào modelTools.add của pi.agent.

Vì pi.agent là document rewindable và fork theo asOf, tool đã nạp thuộc về từng nhánh và vẫn được mời sau compaction. Tool đã nạp đứng sau các tool khác theo thứ tự nạp, nên một lần nạp chỉ nối thêm khai báo vào transcript và tiền tố đã cache ở provider vẫn giữ (spec.md:3270–3281), đúng lập luận của bài "Prompt Caching In Agents" về nạp tool cộng dồn.

Inbox: steer, follow-up và write được đặt ở đâu#

Người dùng gõ thêm trong lúc agent đang làm. Tin nhắn đó vào transcript ở đâu? Vị trí không tuỳ tiện: kết quả tool phải đứng ngay sau lời gọi của nó, và prompt cache của provider là một tiền tố chính xác. Pi Durable xếp input và write gửi tới một conversation bận (trừ write chỉ để hiển thị, xem bên dưới) vào document pi.inbox rồi đặt chúng ở hai loại ranh giới (boundary): postTools sau một vòng tool, và final sau một câu trả lời (spec.md:2579–2592).

Boundarywritesteerfollow-up
postTools (sau vòng tool)tất cảcái đầu hoặc tất cả, theo steeringModekhông
final (sau câu trả lời)tất cảcái đầu hoặc tất cảcái đầu hoặc tất cả, theo followUpMode
  • Steer (whenBusy: "steer") được đặt sau vòng tool hiện tại và gia nhập run đang chạy: ID của nó được nối vào pi.live.run.inputs (dòng 731 của finishToolRound()). Mặc định mỗi boundary chỉ đặt một steer (one-at-a-time, agent.ts:78–79).
  • Follow-up (mặc định) được đặt khi run trả lời, và mở run kế (startRun() trong answer()).
  • Write chỉ có kind và data (không model, head, edits) không đổi gì mô hình thấy, nên được nối ngay cả khi đang bận, không qua inbox, trừ khi đứng sau một reset đang xếp hàng (submissions.ts:220–228). Write có model, head hay edits chờ boundary như một steer.

Một write mang head trỏ về trước điểm bắt đầu của dải context hiện hành là "cũ" (stale): đặt nó sẽ mang lại phần lịch sử mà một head mới hơn đã cắt, nên nó được settle unanswered với lý do stale (inbox.ts:144–147). Bản tóm tắt compaction chính là head write, nên quy tắc này sắp xếp luôn các compaction chạy song song, như mục sau cho thấy. Khi run thất bại, các mục xếp hàng ở yên trong inbox tới input hay write kế tiếp, trừ khi settings.afterFailedRun nói khác (spec.md:2559–2566).

Compaction là một task#

Compaction (nén ngữ cảnh) ở Pi Durable dùng cùng ý tưởng với coding agent (chương Compaction): tóm tắt một tiền tố cũ của context, nối một entry pi.compaction chứa bản tóm tắt với head trỏ vào entry đầu tiên được giữ nguyên văn; lịch sử thô vẫn ở trong storage. Khác biệt là nó là một task bền pi.compaction, với ba cách khởi động (spec.md:4471–4475):

Khởi động bởiOwnerBackgroundGeneration chờ?Đặt bản tóm tắt
Conversation.compact()conversationkhôngkhôngwrite submission
generation, trên ngưỡng nềnconversationcókhôngwrite submission
generation, trên ngưỡng chặn hoặc khi tràngenerationkhôngcónối thẳng
createCompaction(): owner quyết định vừa có chặn hay không, vừa có background hay không
pi/packages/durable/src/harness/compaction.tsdòng 242–259TypeScript
/** * Create a compaction task with its status in this commit. `owner` is the generation that waits for it (a blocking * compaction); without one it is conversation-owned, and `background` unless it is manual. */export async function createCompaction(	tx: Tx,	conversationId: ConversationId,	input: CompactionInput,	owner?: TaskId,): Promise<TaskId<CompactionResult>> {	const ownership =		owner === undefined ? ({ kind: "conversation" } as const) : { kind: "task" as const, taskId: owner };	const background = owner === undefined && input.reason !== "manual";	const taskId = await tx.createTask(CompactionTask, input, { ownership, conversationId, background });	const status = { taskId, reason: input.reason, blocking: owner !== undefined, attempt: 1 };	addCompactionStatus(await tx.doc(LiveDoc, conversationId), status);	return taskId;}

Ngưỡng tính theo cửa sổ của mô hình hiện tại. Mặc định reserveTokens 16 384, keepRecentTokens 20 000, backgroundTokens 32 768 (agent.ts:27–32). Trên contextWindow - reserveTokens, request kế phải chờ một compaction chặn; trên contextWindow - reserveTokens - backgroundTokens (khi backgroundTokens > 0), một compaction nền bắt đầu trong khi conversation vẫn làm việc. Cả hai chỉ áp dụng khi phép chọn điểm cắt tìm được chỗ cắt (generation.ts:344–361). modelOverrides cho phép một harness phục vụ cả mô hình 32k lẫn 1M, theo khoá chính xác "provider/modelId" (compaction.ts:261–271). Ước lượng kích thước lấy usage thật của câu trả lời gần nhất sau head marker, cộng ước lượng của các message sau nó (compaction.ts:326–346).

Phase select: chọn điểm cắt, hỏi beforeCompact, rồi ghim request tóm tắt
pi/packages/durable/src/harness/compaction.tsdòng 117–156TypeScript
select: async (task, runtime, context) => {	const { conversationId } = runtime;	const agent = await runtime.agent(context);	const settings = runtime.settings;	const ref = agent.model;	const model = ref === undefined ? undefined : runtime.models.getModel(ref.provider, ref.modelId);	if (ref === undefined || model === undefined) return failNoModel(runtime, ref, context);	const policy = compactionPolicy(settings, ref);	const view = await runtime.context(conversationId, context);	const cut = selectCut(view, policy.keepRecentTokens);	if (cut === undefined) return complete(runtime, context);	const firstKept = view.entries[cut]!.id;	const { reason, instructions } = task.input;	const compaction = {		reason,		entries: view.entries.slice(0, cut),		messages: summarizedMessages(view, cut),		firstKept,		...(instructions === undefined ? {} : { instructions }),	};	let decision: { readonly decline: true } | { readonly summary: string } | undefined;	await runtime.hooks.each("beforeCompact", async (hook) => {		if (decision === undefined) decision = await hook(compaction, runtime, context);	});	if (decision !== undefined && "decline" in decision) return complete(runtime, context);	if (decision !== undefined) return place(runtime, firstKept, decision.summary, context);	const request: SummaryRequest = {		attempt: 1,		model: ref,		thinkingLevel: agent.thinkingLevel,		streamOptions: settings.stream,		maxTokens: Math.min(			Math.floor(0.8 * policy.reserveTokens),			model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,		),		tail: view.entries.reduce((tail, entry) => (entry.id > tail ? entry.id : tail), firstKept),		firstKept,	};	await runtime.commit(() => ({ status: "running", checkpoint: { phase: "summarize", ...request } }), context);},
beforeCompact: quyết định đầu tiên thắng, { decline: true } hoàn tất không tóm tắt, { summary } đặt bản tóm tắt do hook cung cấp ngay. maxTokens là min(floor(0.8 × reserveTokens), model.maxTokens).

Phase summarize dựng lại các message cần tóm tắt từ context tại tail (bất biến, nên đúng là dải mà select đã chọn), tuần tự hoá thành văn bản thuần ([User]: ..., [Tool result]: ... cắt ở 2 000 ký tự, bỏ system message và thinking), và gửi một request riêng không có tool với cacheRetention: "none" (compaction.ts:157–218). Usage của mọi lần thử được cộng vào pi.usage ngay cả khi bản tóm tắt bị bỏ. Lỗi tạm thời ngủ rồi thử lại như generation; thất bại thì ghi pi.live.compactionFailure cho UI.

placeSummary(): compaction chặn nối thẳng; compaction của conversation đi qua admission như một write
pi/packages/durable/src/harness/compaction.tsdòng 434–466TypeScript
async function placeSummary(	tx: Tx,	runtime: Runtime,	current: RunningTask<CompactionInput, CompactionCheckpoint, CompactionResult>,	live: Draft<LiveState>,	firstKept: EntryId,	summary: string,): Promise<Next> {	removeCompactionStatus(live, runtime.taskId);	delete live.compactionFailure;	const text = `${SUMMARY_PREFIX}${summary}${SUMMARY_SUFFIX}`;	const entry = {		kind: CompactionEntry.kind,		head: firstKept,		model: [{ role: "user", content: [{ type: "text", text }], timestamp: runtime.now() }],		data: { reason: current.input.reason },	} satisfies EntryDraft;	const result: CompactionResult =		current.owner === undefined			? {					submissionId: (						await admitSubmission(							tx,							runtime.conversationId,							{ type: "write", requestId: `compaction:${runtime.taskId}`, entry },							runtime.now(),							runtime.settings,						)					).id,				}			: { entryId: (await tx.appendEntry(runtime.conversationId, entry)).id };	return { status: "terminal", outcome: { status: "completed", result } };}
Việc nhận submission và kết quả completed nằm trong cùng một commit, nên crash không thể tách chúng. Kết quả trả về submissionId để host theo dõi lúc bản tóm tắt thực sự được đặt, hoặc settle stale (README.md:555–560).

Vì sao hai đường đặt khác nhau? Compaction chặn chạy khi generation đang giữ run và chờ nó, nên không ai khác viết vào conversation; nối thẳng là an toàn. Compaction nền hay thủ công chạy song song với run, mà chỉ Harness mới được nối vào một conversation đang bận, nên bản tóm tắt phải là một write submission đặt ở boundary kế (compaction.ts:428–433). Khi nhiều compaction cùng chạy, quy tắc stale chọn kẻ thắng mà không cần phối hợp: compaction nền B chọn ở tail 100, cắt ở 70; trong lúc B tóm tắt, compaction chặn A cắt ở 150 và nối bản tóm tắt; bản tóm tắt của B tới sau, head 70 nằm trước 150, nên settle stale (spec.md:2600–2611).

Lần theo một SIGKILL giữa lời gọi tool#

Giờ ghép mọi mảnh lại. Kịch bản: host Node dùng SQLite, registry có CodingTools (trong đó có bash) và extension subagent của 22-subagent-foreground.ts, tool khai báo replay: "safe". Người dùng gửi input S1. Generation G1 trả lời bằng hai lời gọi trong cùng một vòng song song: subagent (lời gọi a) và bash với npm test (lời gọi b). Subagent tạo conversation con c2, gửi submission S2, và generation G2 của c2 đang stream câu trả lời. npm test đã in vài dòng. Đúng lúc đó tiến trình bị kill -9.

Ví dụ minh hoạ: những gì đã commit lúc tiến trình chết
c1 pi.live.run   = { taskId: G1, inputs: [S1] }                    S1: placed
c1 pi.live.tools = [ { callId: a, taskId: Ta, running, details: { conversationId: c2 } },
                     { callId: b, taskId: Tb, running, output: "> npm test\n  12 passing\n" } ]
G1  pi.generation  waiting  { phase: tools, on: [Ta, Tb], policy: allSettled }
Ta  pi.tool        running  { phase: execute, arguments: { task: … }, replay: safe }
Tb  pi.tool        running  { phase: execute, arguments: { command: "npm test" }, replay: unsafe }
c2 (owner Ta)    pi.live.run = { taskId: G2, inputs: [S2] }        S2: placed, requestId "subagent:Ta"
                 pi.live.generation = { attempt: 1, message: partial đã commit }
G2  pi.generation  running  { phase: request, attempt: 1, model, cutoff, … }
Phần không được commit (vài chục mili giây token cuối của G2, vài dòng đầu ra cuối của npm test) mất theo tiến trình. Mọi thứ ở trên đều đã nằm trong SQLite.
  1. 1. Mở lạiTiến trình mới cài lại đúng các extension vào registry trước khi mở, để công việc phục hồi chạy được ngay (spec.md:732–734), rồi gọi Harness.open(await openNodeSqliteStorage(path), …). open kiểm tra ba task dựng sẵn và gọi scheduler.open() (harness.ts:540–561). Đối soát hạ Ta, Tb, G2 từ running xuống pending với restarts: 1; G1 đang waiting nên giữ nguyên (scheduler.ts:394–398). Không handler nào chạy; pi.live vẫn hiển thị hai slot running với đầu ra cũ, và đó đúng là trạng thái một UI mới kết nối sẽ thấy.
  2. 2. resume()Host gọi harness.resume() (README.md:124); một submission.wait() cũng có tác dụng tương tự. Pass reservation đầu tiên: không task nào mang abandonOnRestart (Ta là replay-safe, G2 do Harness tạo khi nhận S2), nên nó reserve luôn. G1 bị bỏ qua vì #waitingOn() còn thấy Ta sống. Ta, Tb, G2 cùng version với definition hiện tại, nên một commit chuyển cả ba sang running và tạo ba invocation (scheduler.ts:1488–1553).
  3. 3. G2: generation đang streamPhase request chạy lại từ đầu. Commit đầu tiên của nó nối partial đã commit thành một entry pi.assistant với stop reason aborted trong c2, có tính usage (generation.ts:430–439). Rồi nó gửi lại đúng các message tới cutoff đã ghim, với model, mức suy nghĩ và stream option đã ghim; entry aborted không vào request (generation.ts:209–236). Câu trả lời stop đi vào answer(): một commit nối câu trả lời, settle S2 done, xoá run của c2 và kết thúc G2 completed. Trạng thái mới xoá restarts.
  4. 4. Ta: tool replay-safeỞ phase execute, chính sách đã lưu và khai báo hiện tại đều là safe, nên Ta xoá tiến độ cũ của slot và gọi lại execute() với đối số đã lưu, không hỏi lại beforeTool (tool.ts:185–192). Tool subagent được viết để chạy lại vô hại: scanConversations({ ownerTaskId }) tìm thấy c2 thay vì tạo con mới, và request ID subagent:${api.taskId} làm submit() trả về chính S2 (22-subagent-foreground.ts:52–68; tra request ID ở submissions.ts:210–217). wait() trả về khi G2 settle S2. Ta nối pi.tool-result cho lời gọi a và kết thúc completed. Vì S2 được settle trong commit terminal của G2, lúc Ta settle thì c2 không còn việc sống, nên Ta đi thẳng tới terminal thay vì completing.
  5. 5. Tb: tool không replay-safePhase execute thấy replay: "unsafe", không chạy lại, mà dựng kết quả lỗi từ slot: đầu ra đã commit, details cuối, các chẩn đoán đã ghi, cộng chẩn đoán interrupted (tool.ts:194–197, tool.ts:824–840). Mô hình sẽ đọc phần đầu ra đã commit, rồi một khối <harness> chứa dòng [error] Tool bash was interrupted and may have partially run, đúng dạng mà test kiểm với một lệnh bash thật (harness-tools-recovery.test.ts:338–364). Tb kết thúc failed kèm entryId.
  6. 6. G1 tỉnh dậyCommit terminal của Ta và Tb đi qua #track(), hàm đưa task đang chờ chúng (G1) về danh sách chạy được (scheduler.ts:1026). Pass reservation kế thấy on đã terminal hết, chuyển G1 từ waiting sang running, và phase tools gọi finishToolRound(): đọc kết quả, chạy afterTools, không có terminate hay handoff, nên áp boundary postTools, tạo generation G3 và trao run cho nó. G1 kết thúc completed; S1 vẫn placed.
  7. 7. G3 và submissionG3 chuẩn bị request mới. Context có câu trả lời gọi tool của G1 và hai kết quả theo thứ tự lời gọi: câu trả lời của subagent, và lỗi interrupted của bash với phần đầu ra một phần. Mô hình tự quyết định chạy lại npm test hay không. Khi G3 trả lời, answer() settle S1 done. Host lấy lại submission bằng harness.submission(id) và wait() (README.md:138); cuối cùng pi.live của c1 trở về {}, như test khẳng định (harness-tools-recovery.test.ts:125–132).

Ba biến thể làm rõ vì sao từng chi tiết tồn tại:

  • Tiến trình chết trước khi Tb ghi ý định. Checkpoint vẫn là call, nên cả phase chạy lại: phân giải, kiểm tra, beforeTool lần nữa (memo giữ quyết định cũ), ghi ý định, rồi chạy lệnh lần đầu tiên. Không có lỗi interrupted, vì chắc chắn lệnh chưa chạy.
  • Không cài lại extension subagent. Tên tool của Ta không phân giải được, nên bị coi là unsafe: Ta nhận interrupted và kết thúc failed. failed là ý định huỷ; nếu lúc đó c2 còn G2 sống (thường là vậy, vì G2 đang gửi lại request), Ta giữ completing, pass đối soát đánh dấu abort G2, handler abort của G2 chuyển partial và settle S2 unanswered với lý do aborted; chỉ khi đó Ta mới terminal. Đây chính là lý do kết quả interrupted dùng failed (tool.ts:195–196).
  • Cùng một crash lặp lại năm lần liên tiếp trước khi Ta, Tb, G2 kịp commit trạng thái mới: cả ba bị đỗ với lý do restart_limit, conversation vẫn bận, và host phải gọi retryTask().

Task con và subagent qua ví dụ#

Checkout với bốn payment (ví dụ 24)#

test/examples/24-child-tasks.ts là ví dụ chuẩn của cây sở hữu bên trong một conversation. Mỗi payment trừ tiền (hiệu ứng), ngủ 100 ms chờ ngân hàng xác nhận mà không giữ invocation, rồi hoàn tất; thẻ hết hạn bị từ chối ngay. Handler abort() của payment tự hoàn tiền, tức là mỗi task hoàn tác hiệu ứng của chính nó:

Payment: thất bại ngay với thẻ hết hạn, ngủ chờ xác nhận, tự hoàn tiền khi bị huỷ
pi/packages/durable/test/examples/24-child-tasks.tsdòng 30–60TypeScript
const Payment = defineTask<PaymentInput, PaymentState, PaymentResult>({	name: "example.payment",	version: 1,	initial: () => ({ phase: "charge" }),	phases: {		charge: async (task, runtime, taskContext) => {			const card = task.input.card;			if (card.startsWith("expired")) {				const error = { message: `${card} declined` };				await runtime.commit(() => ({ status: "terminal", outcome: { status: "failed", error } }), taskContext);				return;			}			charged.add(card);			// The bank confirms after a moment: wait for it without holding an invocation.			await runtime.sleepUntil(runtime.now() + 100, { phase: "confirm" }, taskContext);		},		confirm: async (task, runtime, taskContext) => {			const card = task.input.card;			await runtime.commit(				() => ({ status: "terminal", outcome: { status: "completed", result: { card } } }),				taskContext,			);		},	},	// Each payment undoes its own effect when aborted.	abort: async (task, runtime, taskContext) => {		const refunded = charged.delete(task.input.card);		console.log(`  payment ${task.input.card} aborted${refunded ? ", refunded" : ""}`);		await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext);	},});
Checkout: tạo bốn task con và chờ failFast trong cùng một commit, rồi đọc kết quả ở phase decide
pi/packages/durable/test/examples/24-child-tasks.tsdòng 62–98TypeScript
// The checkout creates its payments as child tasks and waits for all of them.// With `failFast`, the first payment that fails aborts the others; the checkout// itself is not aborted and decides its outcome once every payment is done.type CheckoutState = { phase: "pay" } | { phase: "decide"; payments: TaskId<PaymentResult>[] };const Checkout = defineTask<{ cards: string[] }, CheckoutState, string>({	name: "example.checkout",	version: 1,	initial: () => ({ phase: "pay" }),	phases: {		pay: async (task, runtime, taskContext) => {			await runtime.commit(async (tx) => {				const payments: TaskId<PaymentResult>[] = [];				for (const card of task.input.cards) {					payments.push(await tx.createTask(Payment, { card }, { ownership: { kind: "task", taskId: task.id } }));				}				return { status: "waiting", checkpoint: { phase: "decide", payments }, on: payments, policy: "failFast" };			}, taskContext);		},		decide: async (task, runtime, taskContext) => {			const outcomes = await runtime.outcomes(task.state.checkpoint.payments, taskContext);			console.log(`  payments: ${outcomes.map((outcome) => outcome.status).join(", ")}`);			const paid = outcomes.every((outcome) => outcome.status === "completed");			await runtime.commit(				() =>					paid						? { status: "terminal", outcome: { status: "completed", result: "order placed" } }						: { status: "terminal", outcome: { status: "failed", error: { message: "payment failed" } } },				taskContext,			);		},	},	// Runs only after every payment is done, so the refunds have already happened.	abort: async (_task, runtime, taskContext) => {		console.log("  checkout aborted");		await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext);	},});
Bốn task con và trạng thái waiting nằm trong cùng một commit: không có thời điểm nào checkout đã tạo payment mà chưa ghi là đang chờ chúng.

Ví dụ chạy ba kịch bản. Một thẻ bị từ chối: payment của expired-2 kết thúc failed; pass đối soát thấy checkout đang chờ failFast và đánh dấu abort ba payment còn sống; chúng đang ngủ, mà abort mark kết thúc giấc ngủ, nên handler abort của từng cái chạy và hoàn tiền; khi cả bốn terminal, checkout tỉnh dậy ở decide và đọc [aborted, failed, aborted, aborted] (spec.md:2396–2402). Khách huỷ: abortTask(checkout) đánh dấu checkout, cascade đánh dấu các payment, handler abort của payment chạy trước, rồi mới tới Checkout.abort, nên dòng "checkout aborted" luôn in sau các dòng hoàn tiền. Tiến trình dừng giữa chừng: Harness đóng khi các payment đang ngủ; Harness mới chỉ cần waitForTask(id), lời gọi này bật lập lịch, và các deadline đã lưu đưa payment tới confirm (24-child-tasks.ts:137–143). Trong lúc chạy, ví dụ in cây task từ task graph (24-child-tasks.ts:106–116).

Subagent tiền cảnh (ví dụ 22)#

Subagent là một conversation con do task của lời gọi tool sở hữu. Tool tạo nó bên trong api.commit() với ownership: { kind: "task", taskId: api.taskId }, rồi điều khiển qua api.conversation(id):

Tool subagent replay-safe: tìm lại con cũ, request ID suy từ task ID, details để UI gắn vào con
pi/packages/durable/test/examples/22-subagent-foreground.tsdòng 42–75TypeScript
defineTool({	name: "subagent",	description: "Delegate a self-contained task to a subagent and get its answer back.",	parameters: Type.Object({ task: Type.String({ description: "What the subagent should do" }) }),	// Safe to rerun after a crash: a rerun finds the child it already created and the submission it already made.	replay: "safe",	execute: async (args, api, callContext) => {		const { task } = args;		// The child is owned by this tool call's task, so aborting the call aborts the child, and the call		// finishes only once the child's work is done.		const child = await api.commit(async (tx) => {			// Ownership records the child: a rerun of this call finds it instead of creating another.			const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];			if (existing !== undefined) return existing.id;			// Starts as a copy of this conversation's agent: model, thinking level, cwd, extensions, tools.			const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });			// Without this extension, the child is not offered this tool.			await configure(tx, created.id, { extensions: { remove: [Subagent] } });			return created.id;		}, callContext);		// A UI watching the parent sees this and can attach to the child.		await api.details({ conversationId: child }, callContext);		const handle = (await api.conversation(child, callContext))!;		// The request ID makes a rerun get back the submission it made before the crash.		const request = { type: "input", content: task, requestId: `subagent:${api.taskId}` } as const;		const settled = await (await handle.submit(request, callContext)).wait(callContext);		if (settled.status !== "done" || settled.type !== "input") {			throw new Error(`Subagent failed: ${settled.status}`);		}		const text = await answerText(api, settled.answer, callContext);		return { output: [{ type: "text", text }], details: { conversationId: child } };	},}),
Request ID dùng api.taskId, không dùng ID lời gọi của provider: ID đó không duy nhất trong một Session (spec.md:3500–3503).

Mỗi dòng có chủ đích phục hồi. scanConversations({ ownerTaskId }) dùng index sở hữu để lần chạy lại tìm lại con thay vì tạo con thứ hai; request ID ổn định làm submit() lần hai trả về submission cũ; configure(... { extensions: { remove: [Subagent] } }) chặn subagent tự gọi subagent. Quan hệ sở hữu cho hành vi còn lại miễn phí: huỷ lời gọi thì huỷ con, execute() ném lỗi hay bị interrupted (kết thúc failed) cũng huỷ con, và conversation cha chỉ idle khi con đã idle (README.md:672–676). UI tìm con qua details của lời gọi và in sự kiện của con thụt vào dưới lời gọi (22-subagent-foreground.ts:113–125).

Subagent nền bền vững (ví dụ 23)#

Subagent nền phải sống lâu hơn các lượt của agent chính: Esc của người dùng ở conversation chính và lệnh chờ idle không được chạm tới nó. Ví dụ 23 dùng hai task background do conversation chính sở hữu. Anchor sở hữu conversation con và chỉ có một phase commit completed ngay lần chạy đầu; chừng nào con còn việc sở hữu thường, kết quả đó được giữ ở completing, rồi anchor ở yên trạng thái terminal. Vì là background, nó là ranh giới mà phép duyệt thường dừng lại, nhưng abort({ background: true }) vẫn đi xuyên qua được, và một owner đã terminal thì không bao giờ lan abort xuống (23-subagent-background.ts:57–72). Mỗi tin nhắn gửi subagent do một reporter chuyển:

Một commit: anchor background, conversation con do anchor sở hữu, cấu hình con, và một reporter background
pi/packages/durable/test/examples/23-subagent-background.tsdòng 184–204TypeScript
const result = await api.commit(async (tx) => {	const state = await tx.doc(Subagents, api.conversationId);	// Both tasks belong to the main conversation and are background: its Esc and idle waits skip them.	const background = { ownership: { kind: "conversation" }, background: true } as const;	if (action === "spawn") {		if (Object.hasOwn(state.agents, name)) return `${name} already exists; use send.`;		const anchor = await tx.createTask(Anchor, null, background);		// Owned by a task of the main conversation: starts as a copy of the main agent.		const child = await tx.createConversation({ ownership: { kind: "task", taskId: anchor } });		// Subagents cannot start subagents, and know who they are.		await configure(tx, child.id, {			extensions: { remove: [SubagentTools] },			instructions: `You are the subagent "${name}". Answer the main agent's requests.`,		});		state.agents[name] = { conversationId: child.id, reported: [] };	}	const conversationId = state.agents[name]!.conversationId;	const input = { name, conversationId, message, followUp: action === "send" && followUp === true };	state.reporters[api.taskId] = await tx.createTask(Reporter, input, background);	return action === "send" ? `Sent to ${name}.` : `Started ${name}.`;}, callContext);
Reporter: gửi một lần nhờ request ID, quyết định báo cáo trong một commit, rồi đăng follow-up cho agent chính
pi/packages/durable/test/examples/23-subagent-background.tsdòng 86–108, 112–123TypeScript
deliver: async (reporter, runtime, taskContext) => {	const { name, conversationId, message, followUp } = reporter.input;	// While the subagent is busy, a steer reaches it at its next step and a follow-up after its current answer.	const subagent = (await runtime.conversation(conversationId, taskContext))!;	const request = { type: "input", content: message, whenBusy: followUp ? "followUp" : "steer" } as const;	const submission = await subagent.submit({ ...request, requestId: `subagent:${reporter.id}` }, taskContext);	const settled = await submission.wait(taskContext);	// One commit decides the report and records the answer as delivered, so a restart does not decide again.	await runtime.commit(async (tx) => {		const next = (report?: string) => ({ status: "running", checkpoint: { phase: "report", report } }) as const;		// `aborted`: stopped, or withdrawn while queued. Nothing to report.		if (settled.status === "unanswered") {			return next(settled.reason === "aborted" ? undefined : `[subagent ${name} failed: ${settled.reason}]`);		}		// Always an answered input here; the check tells TypeScript.		if (settled.type !== "input") return next();		const agent = (await tx.doc(Subagents, runtime.conversationId)).agents[name]!;		if (agent.reported.includes(settled.answer)) return next();		agent.reported.push(settled.answer);		const answer = (await tx.entry(AssistantEntry, settled.answer))?.model?.[0] as AssistantMessage;		return next(`[subagent ${name} answered, no reply needed] ${textOf(answer)}`);	}, taskContext);},report: async (reporter, runtime, taskContext) => {	const report = reporter.state.checkpoint.report;	if (report !== undefined) {		const main = (await runtime.conversation(runtime.conversationId, taskContext))!;		const input = { type: "input", content: report, whenBusy: "followUp" } as const;		await main.submit({ ...input, requestId: `subagent-report:${reporter.id}` }, taskContext);	}	await runtime.commit(		() => ({ status: "terminal", outcome: { status: "completed", result: null } }),		taskContext,	);},
Hai request ID khác nhau, cùng suy từ ID của reporter: một cho tin nhắn gửi subagent, một cho báo cáo gửi về. Crash trước mỗi lần nhận thì thử lại; crash sau thì nhận lại đúng submission cũ, nên subagent nhận tin một lần và agent chính nhận báo cáo một lần.

Một chi tiết ngược với ví dụ 22: tool quản lý subagent nền khai báo replay: "unsafe", vì chạy lại lệnh stop sau crash có thể dừng nhầm công việc mới hơn (23-subagent-background.ts:145–147). Mô hình thấy lời gọi bị gián đoạn và có thể hỏi status. Replay-safe hay không là quyết định nghiệp vụ, không phải mặc định nên bật.

Code mode bền#

Code mode cho mô hình viết JavaScript gọi các tool của conversation như await tools.read({ path }), chạy trong sandbox QuickJS của pi-codemode; cách codemode hoạt động trong coding agent nằm ở chương Codemode, MCP và tool search. Ở Pi Durable, vấn đề khó hơn: một script là trạng thái VM trong bộ nhớ, và chú thích đầu file nói thẳng vấn đề lẫn lời giải:

codemode.ts: phát lại tất định từ journal thay vì chụp trạng thái VM
pi/packages/durable/src/codemode/codemode.tsdòng 1–36TypeScript
/** * Durable code mode: a tool that runs model-written JavaScript in a QuickJS sandbox, whose script survives a restart. * * Problem: a script is in-memory VM state. A restart while * *     const results = await Promise.all(paths.map((path) => tools.read({ path }))); * * waits for the last of 200 reads would lose the script and every read it made. * * Solution: deterministic replay (`CodemodeSandboxBase.start()` in pi-codemode). The VM only runs when the host hands * it a result; given the same start data, clock, seed, and results in the same order, it makes the same calls with * the same arguments. The tool is replay-safe: after a restart, `execute()` runs again, starts the script again from * the stored start data, and hands it the results it got before, in the order it got them, from a journal written * before each delivery. Nested tool calls reattach to their tasks by key (`c<VM call id>`), so they never run twice * unless their own replay policy reruns them; their results come from `NestedCallDoc`. Then the script continues live. * * Documents, scoped to the code mode call's task and gone when it settles: * - `pi.codemode.run`: the start data, written once before the VM starts, and the outcome, written once when the call *   decides it (with the store writes, in one transaction), so a rerun after that returns it without a VM. * - `pi.codemode.journal`: one entry per delivered result, in delivery order: the VM call ID, the clock value, the *   calls the VM had issued after processing it (once acknowledged), and a global's outcome, which nothing else stores. * * Each result's acknowledged call count is recorded with the next journal commit, or at once when the VM has processed * everything delivered: a script waiting on a long call is where a restart usually finds it. A sequential script * therefore costs one journal commit and one count commit per result, on top of its nested calls' own; that is the * price of checking the most common crash point. * * A call is launched only once the count of calls issued in the drain that made it is committed. So every key an earlier * attempt admitted is at most its last recorded count; a replay checks every recorded count, so when it releases its * calls it has made every call an earlier attempt could have admitted, and later calls are new. * * A rerun refuses to replay (no new nested calls; nested calls admitted earlier may still resume on their own and are * aborted by the call's cleanup) when this module's version or the VM's fingerprint changed, when the deadline passed, * or when the script diverges: it makes another call, or calls in another order, than the journal says. Each ends the * call with an error result. */
Không cần chụp (snapshot) bộ nhớ của VM: chỉ cần lưu những thứ đi vào VM (dữ liệu khởi động và các kết quả theo thứ tự), vì VM tất định sẽ tự tái tạo phần còn lại.

Lời giải dựa trên một tiền đề: VM chỉ chạy khi host trao cho nó một kết quả, và mọi nguồn bất định khác đã bị khoá ở chế độ tất định của pi-codemode:

pi-codemode vm.ts: đồng hồ ảo, ngẫu nhiên có seed, UTC, không WeakRef, thứ tự giao kết quả cố định
pi/packages/codemode/src/runtime/vm.tsdòng 23–37TypeScript
* Deterministic mode (`data.deterministic`, used by `CodemodeSandboxBase.start()`): a script must make the same* calls with the same arguments every time it runs with the same results, so a host can replay it after a restart.* The VM is otherwise deterministic, because it only runs when the host delivers something and has no timers.* What remains is fixed here:* - Time: every WASI clock reads virtual time, `startedAt` and then the `now` of the result being processed, never going backwards.*   While the context is created, the realtime clock reads a value derived from the seed instead, because QuickJS*   seeds `Math.random()` from the first clock read, and which read comes first is the engine's business.* - Randomness: `random_get` draws from a PRNG over the seed.* - Timezone: UTC, not the host's.* - Garbage collection: `WeakRef` and `FinalizationRegistry` are not available; collection timing is not.* - Delivery order: results carry a `seq` and are applied strictly in that order, buffering early ones, because a*   transport (RPC replies) may deliver them in another order than the host sent them.* The VM reports `started` with its fingerprint before the script runs, and `idle` with the number of calls issued* after each drain, so the host can check a replay step by step.*/

Tool codemode là callers: ["model"] và replay: "safe" (codemode.ts:236–248). Mỗi tools.x() trong script là một lời gọi lồng nhau với khoá c<id>, trong đó id là số thứ tự VM đặt cho lời gọi. Ba document lưu những gì cần cho phát lại: pi.codemode.run (dữ liệu khởi động, ghi một lần trước khi VM chạy; và kết cục, ghi một lần), pi.codemode.journal (mỗi kết quả đã giao một mục, theo thứ tự giao), cả hai có phạm vi task; và pi.codemode.store cho store()/load(), phạm vi conversation, fork theo asOf (codemode.ts:98–171).

execute(): kết cục đã có thì trả ngay không cần VM; lần đầu ghi dữ liệu khởi động; lần chạy lại kiểm tra version và deadline
pi/packages/durable/src/codemode/codemode.tsdòng 408–435, 449–459TypeScript
const found = await api.commit(async (tx): Promise<Found> => {	const run = await tx.doc(CodemodeRunDoc, api.taskId);	if (run.result !== undefined) return { done: copyJson(run.result as JsonValue) as ToolExecutionResult };	const rerun = run.start !== undefined;	if (run.start === undefined) {		const store = await tx.doc(CodemodeStoreDoc, api.conversationId);		const timeout = sourceOptions.timeoutMs;		run.start = {			version: CODEMODE_MODULE_VERSION,			startedAt: now,			seed,			...(timeout === undefined ? {} : { deadline: now + timeout }),			tools,			namespaces,			globals,			maxOutputTokens: sourceOptions.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS,			store: copyJson(store.values as JsonValue) as Record<string, JsonValue>,		};	}	const journal = await tx.doc(CodemodeJournalDoc, api.taskId);	return {		start: copyJson(run.start as unknown as JsonValue) as unknown as Start,		journal: copyJson(journal.entries as unknown as JsonValue) as unknown as JournalEntry[],		firstIssued: journal.firstIssued,		rerun,	};}, context);if ("done" in found) return found.done;if (this.start.version !== CODEMODE_MODULE_VERSION) {	return this.decide(		errorResult(			`The script cannot resume: it started under code mode version ${this.start.version}, this is ${CODEMODE_MODULE_VERSION}.`,		),	);}const remaining = this.start.deadline === undefined ? undefined : this.start.deadline - this.api.now();if (remaining !== undefined && remaining <= 0) {	return this.decide(errorResult(`Script timed out: its deadline passed while it was interrupted.`));}
seed và startedAt được tính ở mỗi lần gọi execute() nhưng chỉ lần đầu được ghi; lần chạy lại dùng giá trị đã lưu, nên Date.now() và Math.random() trong script cho cùng kết quả.

Lúc chạy thật, một lời gọi xong được nối vào journal rồi mới giao cho VM, mỗi lúc chỉ một commit journal đang bay, và số lời gọi VM đã phát sau khi xử lý từng kết quả được ghi lại (codemode.ts:962–1006). Một lời gọi chỉ được khởi chạy khi con số đó đã commit, nên mọi khoá mà lần thử trước có thể đã nhận đều không vượt quá con số đã ghi cuối cùng. Sau restart, execute() chạy lại, khởi động VM từ start, giao lại các kết quả trong journal theo đúng thứ tự, mỗi kết quả khi VM đã phát lời gọi tương ứng; kết quả của tool lấy từ lời gọi lồng nhau theo khoá, của global lấy từ journal. Rồi script chạy tiếp như thật. Test: script echo a; hold; echo b bị cắt ở hold, chạy lại thì echo mỗi khoá chỉ chạy một lần, VM khởi động hai lần (codemode.test.ts:212–227); đồng hồ và số ngẫu nhiên giữ nguyên qua restart (codemode.test.ts:259–272).

Lần chạy lại kết thúc lời gọi bằng một kết quả lỗi đã lưu, thay vì phát lại, trong các trường hợp (spec.md:3916–3941):

  • Version của module code mode đã đổi (CODEMODE_MODULE_VERSION, hiện là 2, codemode.ts:82–86) hoặc deadline timeout_ms đã qua trong lúc bị gián đoạn: kiểm tra trước khi khởi động VM (dòng 449–459 ở trên).
  • Fingerprint của sandbox đã đổi: VM báo fingerprint trước khi script chạy; khác với cái đã ghi thì lời gọi kết thúc trước khi giao hay gắn lại bất cứ thứ gì (codemode.ts:615–642).
  • Script đi đường khác: số lời gọi sau một kết quả khác con số đã ghi, VM chờ mà không phát lời gọi mà journal có, một khoá hay global bị gọi với tên hoặc đối số khác, hay script kết thúc ở chỗ lần trước còn chạy tiếp (codemode.ts:699–735, codemode.ts:492–510). Khi đó kết quả lỗi được lưu, rồi VM bị dừng bằng một lỗi mà script không bắt được.

Một trường hợp ngược lại không phải kết cục: host đóng sandbox trong lúc script chạy (ví dụ khi tắt máy) thì lời gọi không lưu gì và kết thúc cùng invocation, để lần sau phát lại script (codemode.ts:481–491). Các lời gọi lồng nhau đang chạy lúc restart tự theo chính sách replay của chúng; bước dọn dẹp của tool code mode abort những cái lần chạy lại không dùng tới.

ExecutionEnv và các tool dựng sẵn#

Tool chạm vào file và tiến trình chỉ qua api.env, không bao giờ qua một môi trường bắt được lúc dựng tool. Task tool dựng môi trường cho từng lời gọi bằng HarnessOptions.env với ID và cwd của conversation, nên một thư mục hay một sandbox riêng cho mỗi conversation không cần wrapper nào (spec.md:3283–3290). Không có môi trường thì tool dựng sẵn ném lỗi, và lỗi đó thành một kết quả lỗi bình thường (tools/env.ts:4–8).

ExecutionEnv = FileSystem + Shell: id là không gian tên file, exec nhận chuỗi hoặc argv
pi/packages/durable/src/env/index.tsdòng 208–216, 359–374TypeScript
/** Portable filesystem capability. Operations return failures rather than throwing. */export interface FileSystem {	/**	 * The file namespace: equal ids see the same files at the same paths, whatever their `cwd`. Every local Node	 * environment shares one id; each container or remote host has its own.	 */	readonly id: string;	cwd: string;	absolutePath(path: string, context: Context): Promise<Result<string, FileError>>;export interface Shell {	/**	 * Run a command. A string runs through the environment's shell. An array runs `command[0]` directly with the rest	 * as its arguments, without a shell, so they reach the program unparsed. Aborting the context or a timeout kills	 * only this command's processes.	 */	exec(		command: string | readonly string[],		options: ShellExecOptions | undefined,		context: Context,	): Promise<Result<ShellExecResult, ExecutionError>>;	/** Kill every command this environment still runs; for its owner's shutdown, never for one request. */	cleanup(context: Context): Promise<void>;}export interface ExecutionEnv extends FileSystem, Shell {}
Mọi thao tác trả về Result thay vì ném lỗi cho các thất bại dự kiến (index.ts:3–17). Huỷ context chỉ dừng lời gọi đó; cleanup() là việc tắt máy của chủ sở hữu, không bao giờ của một request.

Những thành viên quan trọng ngoài phần đọc ghi cơ bản:

  • id: hai môi trường cùng id thấy cùng file ở cùng đường dẫn. Mọi NodeExecutionEnv cục bộ có chung "node:local" (node.ts:612–626); mỗi container hay máy từ xa có id riêng. Hàng đợi ghi file dựa vào nó (bên dưới).
  • exec(command, options, context): chuỗi thì chạy qua shell của môi trường, mảng argv thì chạy thẳng chương trình không qua shell, nên host dựng lệnh từ dữ liệu (tên file) không phải quote. Mỗi đoạn onOutput ghi nó đến từ stdout hay stderr; spill ghi toàn bộ đầu ra ra file khi vượt ngưỡng; window cho phép môi trường ở xa bỏ qua đầu ra nằm ngoài phần đuôi mà người gọi sẽ giữ (index.ts:300–331).
  • openBinaryReader(): đọc theo vị trí trên một file đã mở, kể cả khi đường dẫn bị đổi tên; scanLines đếm và định vị dòng trong một lượt bên trong môi trường, nên tool read không phải chuyển cả file (index.ts:95–108). openDirReader() đọc thư mục theo trang (index.ts:159–168).
  • listFiles(), tuỳ chọn: liệt kê cả cây như fd, bỏ những gì git ignore, có giới hạn. NodeExecutionEnv không cài đặt nó; RemoteExecutionEnv và LocalExecutionEnv của pi-env thì có, và người gọi tự duyệt khi thiếu hoặc gặp not_supported (README.md:456–460). Môi trường từ xa thuộc chương pi-env.

Môi trường là thứ được tin cậy, không phải ranh giới cô lập: kiểm tra đường dẫn chuẩn trước khi mở không ngăn được một lần đổi tên hay tráo symlink đồng thời (spec.md:3416–3419). Bộ test tuân thủ registerEnvConformance() cho phép kiểm một môi trường tự viết, giống như với storage (README.md:454).

Các tool dựng sẵn#

@earendil-works/pi-durable/tools cung cấp read, write, edit, bash (cùng powershell), port từ tool của coding agent (chương Công cụ), và extension CodingTools gồm bốn tool đầu; không có gì tự cài chúng (tools/index.ts:27–39). Không tool nào trong số này khai báo replay, nên tất cả đều là unsafe, kể cả read: một read bị gián đoạn cũng trả kết quả interrupted thay vì đọc lại.

  • bash giữ phần đuôi đầu ra (retain: "tail"), đẩy từng đoạn vào api.output(), spill toàn bộ ra file khi vượt 50 KiB hoặc 2 000 dòng và báo đường dẫn qua chẩn đoán full_output, trả structuredOutput: { output, truncated, fullOutputPath?, exitCode }, biến exit khác 0 thành kết quả lỗi có chẩn đoán exit_code nhưng vẫn mang đầu ra, và ném lỗi khi timeout (bash.ts:87–158).
  • read đọc qua openBinaryReader, phát hiện ảnh theo nội dung, và đọc lại một lần nếu file đổi trong lúc đọc (read.ts:89–131). Không có bộ xử lý ảnh, PNG, JPEG, GIF và WebP nằm trong giới hạn của mô hình được gửi nguyên, các ảnh khác bị từ chối; với bộ xử lý Photon (WebAssembly, chạy trong worker thread trên Node), ảnh được xoay theo EXIF, đổi định dạng và thu nhỏ cho vừa (README.md:249–258).
  • write và edit tuần tự hoá thao tác đọc-sửa-ghi trên cùng một file:
withFileMutationQueue(): một hàng đợi cho mỗi cặp (id của file system, đường dẫn chuẩn)
pi/packages/durable/src/tools/file-mutation-queue.tsdòng 33–55TypeScript
export async function withFileMutationQueue<T>(	env: ExecutionEnv,	path: string,	fn: () => Promise<T>,	context: Context,): Promise<T> {	const key = await mutationKey(env, path, context);	// Take the slot without awaiting, so no other call can take it in between.	const previous = queues.get(key) ?? Promise.resolve();	let release = (): void => {};	const done = new Promise<void>((resolve) => {		release = resolve;	});	const tail = previous.then(() => done);	queues.set(key, tail);	await previous;	try {		return await fn();	} finally {		release();		if (queues.get(key) === tail) queues.delete(key);	}}
Khoá là env.id cộng đường dẫn chuẩn (file-mutation-queue.ts:7–10), nên hai lời gọi với hai đối tượng môi trường mới cho cùng một file vẫn xếp hàng, còn file khác hay file system khác không bao giờ phải chờ. Đây không phải khoá chống lại bash hay tiến trình khác.

Task graph và telemetry#

harness.taskGraph(context) trả về mọi task đang sống của Session dưới dạng một Chord state (xem chương Chord), dành cho bảng task trong UI hay để gỡ lỗi; watchTaskGraph() trả cùng giá trị qua một watch. Mỗi nút giữ bản ghi đã bỏ input, payload checkpoint, payload kết quả và memo:

TaskGraphState và TaskGraphNode: trạng thái đã commit, cạnh owner, lý do bị chặn
pi/packages/durable/src/harness/task-graph.tsdòng 19–49TypeScript
/** A live task's durable status without its checkpoint and outcome payloads (spec §9.5). */export type TaskGraphState =	| { readonly status: "pending" | "running"; readonly phase: string }	| {			readonly status: "waiting";			readonly phase: string;			readonly on: readonly TaskId[];			readonly policy: JoinPolicy;	  }	/** Asleep until the Harness clock reaches `until`. */	| { readonly status: "sleeping"; readonly phase: string; readonly until: number }	/** Outcome held until its ordinary owned work drains. */	| { readonly status: "completing"; readonly outcome: TaskOutcome<JsonValue>["status"] };export type TaskGraphNode = {	readonly id: TaskId;	readonly kind: string;	readonly conversationId: ConversationId;	/** Owner task; absent for a conversation-owned task. */	readonly owner?: TaskId;	readonly background: boolean;	readonly abortRequested: boolean;	readonly state: TaskGraphState;	/**	 * Why it cannot run here, when it cannot: no registered definition takes it (`missing_task`, `task_too_old`,	 * `migration_failed`), or the restart limit parked it (`restart_limit`). Follows registry changes.	 */	readonly blocked?: BlockedReason;	/** Conversations this task owns, in ID order. */	readonly conversations: readonly ConversationId[];};
running là trạng thái bền: sau khi mở lại, task từng chạy hiện là pending cho tới khi được reserve. blocked được suy ra và đi theo thay đổi registry, kể cả khi không có commit nào (task-graph.ts:159).

Graph được dựng một lần trên dòng commit ở lần quan sát đầu, rồi tiến theo publication của từng commit: một nút xuất hiện cùng commit tạo task và biến mất cùng commit làm task terminal (task-graph.ts:203–251). Nó chỉ chứa task sống: khi task anchor của một subagent đã terminal, task sau trong conversation con là nút cấp cao nhất, và UI muốn đặt nó dưới cha phải đọc ConversationRecord.owner (spec.md:5052–5056).

Về telemetry, mỗi phase invocation là một span pi.durable.phase bao cả commit quyết định của nó, nhưng không bao phase kế, một lần chờ bền hay một restart. Thuộc tính gồm ID và tên task, phase, loại (run/abort), attempt, resumed (lần chạy đầu của một task nạp trong tiến trình này) và outcome (next=<phase>, waiting, sleeping, done, faulted, aborted); sự kiện pi.durable.checkpoint và pi.durable.replay đánh dấu đổi phase và lời gọi lồng nhau được trả từ kết quả đã lưu (telemetry.ts:77–115; span được mở trong scheduler.ts:1739–1803). Schema không chứa nội dung: chỉ ID và siêu dữ liệu điều khiển, không có message, đối số hay kết quả (README.md:82). pi-telemetry và cách host nối nó vào thuộc chương pi-env, telemetry và evals.

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

Vòng lặp agent thành dữ liệu, scheduler thì chung
Thay vì một vòng lặp bền đặc biệt cho mô hình và tool, Pi Durable cắt vòng lặp thành các task dựng sẵn và chạy chúng bằng một scheduler không biết gì về run hay loại task (spec.md:2273). Cùng một bộ máy phục hồi, abort, migration, đỗ task và task graph phục vụ generation, tool, compaction lẫn task của extension. Cái giá là số commit: mỗi lượt có vài commit cho checkpoint, cộng commit tiến độ tối đa 100 ms một lần trong lúc stream. settings.progress tồn tại vì giá đó đáng kể với storage ở xa (types.ts:524–533).
Không có đường hiển thị nào chưa bền
Bất biến 2 và 3 của spec: document chỉ được công bố sau khi storage commit thành công, và mọi tiến độ hiển thị đều bền (spec.md:63–68). Hệ quả là UI đọc pi.live chứ không nghe một kênh sự kiện riêng, và kết quả interrupted sau crash mang đúng phần đầu ra mà UI đã thấy, cộng một chẩn đoán nói lời gọi bị gián đoạn. Spec liệt kê "visible-undurable publication" trong các non-goal (spec.md:5604–5610).
Mặc định unsafe, và cả hai bên phải đồng ý mới replay
Harness không thể biết một hiệu ứng bên ngoài đã xảy ra hay chưa. Chạy lại mặc định sẽ nhân đôi deploy hay thanh toán; im lặng bỏ qua sẽ giấu sự thật khỏi mô hình. Pi Durable chọn nói thật: mô hình nhận kết quả "đã bị gián đoạn và có thể đã chạy một phần" kèm phần đầu ra đã commit, và tự quyết định bước tiếp (README.md:277). Replay là cam kết của tác giả tool, nên khai báo hiện tại được quyền phủ quyết.
Chờ là trạng thái, không phải await
Bản 1.4.0 xoá sleep trong phase vì nó giữ invocation và làm deadline trôi sau mỗi restart (CHANGELOG.md:7); retry backoff, poll phản hồi deferred và backoff của compaction cũng chuyển sang sleep (CHANGELOG.md:33). Cùng với onWake, một Durable Object có thể ngủ đông trong lúc chờ backoff 10 phút và chỉ thức dậy khi alarm reo.
Đồng thời có cấu trúc thay cho phụ thuộc tự do
Không có "task A phụ thuộc task B" tách rời; thứ tự đến từ việc một task chờ task khác, và mọi task đều có owner (spec.md:2091–2093). Đổi lại, ba điều tự đúng: không công việc nào rò rỉ sau khi owner kết thúc (completing), abort luôn tới được việc con, và việc bù trừ chạy từ dưới lên ở đúng tầng đã gây ra hiệu ứng.
Thiếu code thì chặn, không kết thúc
Một extension chưa được cài lại sau restart là tình huống phục hồi được, nên task chờ thay vì bị huỷ; chỉ một yêu cầu huỷ thật mới biến nó thành orphaned, và tên kết quả nói rõ không có code dọn dẹp nào đã chạy (spec.md:2218–2242). Điều này là điều kiện để tải lại extension ngay trong tiến trình mà không phải đóng Harness.
Phát lại tất định thay vì chụp VM
Code mode lưu dữ liệu vào và thứ tự kết quả, không lưu bộ nhớ của QuickJS. Journal nhỏ, đọc được, kiểm tra được từng bước (đếm lời gọi sau mỗi kết quả), và một sandbox khác (fingerprint khác) hay một module khác (version khác) bị từ chối thay vì phát lại sai.
Chỉ mục sở hữu giới hạn theo công việc đang sống
Scheduler không duyệt mọi task ở mỗi pass; cascade bắt đầu từ task có ý định huỷ, việc con và phạm vi idle là các phép đi xuống trên một chỉ mục trong bộ nhớ chỉ giữ chuỗi phía trên task còn sống. CHANGELOG 1.2.0 ghi lại hiệu quả: chuỗi 500 task sở hữu nhau từ 12 s xuống 50 ms, 8 000 lời gọi lồng nhau song song từ 18,8 s xuống 2,7 s (CHANGELOG.md:95–96).

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

  • Việc sau một await trong phase không bền. Phase chạy lại từ đầu; mọi thứ cần sau một lần chờ phải nằm trong checkpoint của phase kế (README.md:162). Giá trị ngẫu nhiên, thời điểm, hay giá trị đọc từ document mà lần chạy lại phải thấy giống hệt thì ghi vào memo, checkpoint hoặc input trước khi dùng (spec.md:3458–3462).
  • Phase quên commit thì bị faulted, không phải "chạy lại cho tới khi được" (quy tắc 6).
  • Giao dịch dài. Callback của commit giữ dòng commit; đừng await mô hình, mạng, tiến trình hay một commit lồng trong đó. Đọc qua snapshot(), getTask() bên trong callback có thể treo vì nó xếp hàng sau chính callback đang giữ dòng; hãy đọc qua tx (spec.md:5521–5528).
  • Tool dựng sẵn đều unsafe, kể cả read. Đừng khai báo replay: "safe" cho tool có hiệu ứng ngoài mà không có khoá idempotent; lời gọi lồng nhau của tool safe vẫn theo chính sách replay của riêng chúng.
  • Chạy lại thấy môi trường hiện tại: cwd của conversation có thể đã đổi giữa lần chạy đầu và lần chạy lại (spec.md:5582–5584).
  • Việc con giữ owner. Công việc tiền cảnh mà một extension khởi động trong conversation của subagent giữ lời gọi tool, và qua đó giữ cả run; hỏi tiếp subagent trong lúc tool đang completing kéo dài việc giữ. Việc không được giữ owner phải tạo do conversation sở hữu và background (spec.md:5502–5512).
  • Handler abort() không tạo được task con và không chờ, không ngủ được. Bù trừ ngay trong handler, hoặc qua task background do conversation sở hữu và chờ bằng waitForTask() (spec.md:5513–5518).
  • failFast chỉ áp cho task mình sở hữu, và lời chờ khép kín chu trình (chờ chính mình, chờ owner đang giữ mình) bị từ chối ngay khi commit.
  • Task bị đỗ vẫn giữ conversation bận. Một crash giết mọi task đang chạy, không chỉ thủ phạm; host nên hiển thị task bị đỗ và nút retryTask().
  • Mọi tiến trình mở cùng storage phải dùng cùng đồng hồ, vì until được lưu theo HarnessOptions.now. Và chỉ một tiến trình được sở hữu một storage tại một thời điểm; không có khoá giữa các tiến trình (README.md:790).
  • Code không tôn trọng tín hiệu huỷ làm close() treo, vì close join mọi invocation. Huỷ một Context chỉ huỷ lời gọi hay lần chờ đó, không bao giờ huỷ công việc chung (spec.md:2117–2120).
  • Tiền của subagent bị tính hai lần nếu tool trả usage của conversation con trong kết quả của nó: pi.usage của con đã tính rồi (spec.md:5499–5501). Compaction cũng tốn một request tóm tắt mỗi lần thử, kể cả khi bản tóm tắt kết thúc stale.
  • Tên bắt đầu bằng pi. cho task, document và entry thuộc về phần dựng sẵn theo quy ước; không có gì cưỡng chế (spec.md:5565–5568).

Đọc code ở đâu#

Thứ tựFileĐọc để hiểu
1README.mdConcepts, Persist and Resume (sleep, crash loop, blocked), Tools, Code Mode, Abort and Subagents, Child Tasks. Đọc trước để có từ vựng.
2spec.md §5Mô hình task chuẩn tắc: định nghĩa, effect sandwich, abort và sở hữu, đồng thời có cấu trúc, sleep và wake. Sau đó §7.3 (tool), §7.6 (code mode), §8 (task dựng sẵn).
3types.tsTaskRuntime, TaskDefinition, TaskOptions (208–391); TaskOutcome, TaskState, TaskRecord (554–704).
4packages/durable/test/examples/13-recovery.ts, packages/durable/test/examples/24-child-tasks.tsHai task viết tay ngắn: memo, sleep, phục hồi; task con, failFast, abort từ dưới lên. 12-tasks.ts là ví dụ ý định, hiệu ứng, kết quả.
5packages/durable/src/harness/scheduler.tsTheo thứ tự: chú thích lớp (231–252), open (374), #observe (624), #reconcile (776), #reserve (1466), #run/#decide/#step (1711–1921), #runtime (2085). Bỏ qua lúc đầu phần chỉ mục sở hữu (1000–1290).
6packages/durable/src/harness/live.tspi.live, endRun() và việc dọn dẹp khi scheduler tự ghi faulted/orphaned.
7packages/durable/src/harness/generation.tsNăm phase, streamResponse(), classify(), answer(), vòng tool.
8packages/durable/src/harness/tool.tsPhase call/execute, run(), finalResult(), settle(), lời gọi lồng nhau.
9packages/durable/src/harness/inbox.ts, submissions.ts:201Boundary postTools/final, write chỉ để hiển thị, stale.
10packages/durable/src/harness/compaction.ts, packages/durable/src/harness/context.tsBa cách khởi động compaction, chọn điểm cắt, đặt bản tóm tắt; cách context loại message lỗi và tự bù kết quả tool bị thiếu.
11packages/durable/src/codemode/codemode.ts, vm.ts:23–37Journal, phát lại, phân kỳ; chế độ tất định của VM.
12packages/durable/src/env/index.ts, packages/durable/src/env/node.ts, src/tools/Giao diện môi trường, bản cài Node, bốn tool dựng sẵn và hàng đợi ghi file.
13packages/durable/test/harness-tasks-recovery.test.ts, packages/durable/test/harness-tools-recovery.test.ts, packages/durable/test/harness-generation-recovery.test.ts, packages/durable/test/harness-nested-tools-restart.test.ts, packages/durable/test/harness-blocked.test.tsBằng chứng chạy được cho mọi hành vi phục hồi trong chương: mỗi tên test là một câu khẳng định.
14packages/durable/src/harness/task-graph.ts, packages/durable/src/harness/telemetry.tsGraph task sống và schema telemetry không chứa nội dung.

Tóm tắt#

  • Mọi thứ harness chạy là một task: một máy trạng thái bền mà mỗi bước là một checkpoint đã commit. Phase chạy lại từ đầu sau crash, nên việc có hậu quả đi theo khuôn ý định, hiệu ứng, kết quả.
  • TaskState có sáu trạng thái. Code của task chỉ đổi trạng thái của chính nó qua runtime.commit() (checkpoint mới, chờ, ngủ, kết quả); reservation, đối soát, handover, fault, orphan thuộc về scheduler; mọi chuyển trạng thái đều được quyết định trên dòng commit.
  • Chờ (waiting) và ngủ (sleeping) là trạng thái đã lưu, không phải await; wake()/onWake cho host biết khi nào cần một tiến trình.
  • Cây sở hữu làm cho abort chảy xuống, chạy từ dưới lên, và không việc con nào sống lâu hơn lúc owner kết thúc; background là ranh giới.
  • pi.generation ghim request, commit partial tối đa 100 ms một lần, biến retry và poll thành sleep, compaction rồi thử lại một lần khi tràn context, và trao run cho generation kế sau mỗi vòng tool.
  • pi.tool ghi đối số cuối và chính sách replay trước khi chạy; sau crash nó chỉ chạy lại khi cả hai bên nói safe, còn lại trả cho mô hình một kết quả interrupted với phần đầu ra đã commit. Lời gọi lồng nhau gắn lại theo khoá; code mode phát lại script tất định từ journal.
  • Restart được đếm; task bị crash cắt ngang năm lần liên tiếp bị đỗ chờ retryTask(). Thiếu definition thì chặn, không bao giờ tự kết thúc; chỉ một yêu cầu huỷ mới làm task orphaned.

Mục lục

Giao diện