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.
Trong chương này23 mục
- Vai trò: mọi thứ harness chạy là một task
- Mô hình tư duy: phase, checkpoint và commit
- Data model: TaskRecord, TaskState, TaskOutcome
- Định nghĩa một task: defineTask và TaskDefinition
- TaskRuntime: những gì một phase được làm
- Cây sở hữu: task con, background, abort từ dưới lên
- Scheduler: open, reserve, run, step
- Sleep, timer và wake
- Restart, parking, task bị chặn và migration
- pi.generation: từ transcript tới câu trả lời
- pi.tool: ý định trước, hiệu ứng sau
- Lời gọi lồng nhau, callers và tool hoãn nạp
- Inbox: steer, follow-up và write được đặt ở đâu
- Compaction là một task
- Lần theo một SIGKILL giữa lời gọi tool
- Task con và subagent qua ví dụ
- Code mode bền
- ExecutionEnv và các tool dựng sẵn
- Task graph và telemetry
- Quyết định thiết kế
- Cạm bẫy và trường hợp biên
- Đọc code ở đâu
- 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.
/** 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
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 |
| record | TaskRecord: bản ghi JSON trong storage, được thay nguyên khối ở mỗi commit. | types.ts:690 |
| invocation | Mộ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 |
| reservation | Commit của scheduler đổi pending/waiting/sleeping thành running và tạo invocation. | scheduler.ts:1466 |
| step | Quyế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 |
| memo | Giá 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ường | Mỗ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, parked | Task 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.
### 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.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ế:
/** 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.failedlà thất bại nghiệp vụ có chủ đích (mô hình trả lỗi, thẻ bị từ chối).aborted: handlerabort()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.reasonlà 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).
/** Complete durable execution state of a task. */export type TaskState<S, R> = | { /** Eligible for scheduling. */ readonly status: "pending"; /** Complete durable state from which execution resumes. */ readonly checkpoint: S; readonly outcome?: never; } | { /** Reserved by one in-memory task invocation. */ readonly status: "running"; /** Complete durable state from which execution resumes. */ readonly checkpoint: S; readonly outcome?: never; } | { /** Parked without an invocation until every task in `on` is terminal; then resumes at `checkpoint`. */ readonly status: "waiting"; readonly checkpoint: S; readonly on: readonly TaskId[]; readonly policy: JoinPolicy; readonly outcome?: never; } | { /** * Parked without an invocation until the Harness clock reaches `until`, in milliseconds on the * `HarnessOptions.now` clock; then resumes at `checkpoint`. An abort mark does not wait for it. */ readonly status: "sleeping"; readonly checkpoint: S; readonly until: number; readonly outcome?: never; } | { /** Outcome decided; becomes terminal once no ordinary owned work below is live. Runs no more code. */ readonly status: "completing"; readonly checkpoint?: never; readonly outcome: TaskOutcome<R>; } | { /** Permanently settled durable result receipt. */ readonly status: "terminal"; readonly checkpoint?: never; readonly outcome: TaskOutcome<R>; };checkpoint; hai trạng thái cuối mang outcome và không còn checkpoint. sleeping mới có từ 1.4.0; until là mili giây trên đồng hồ HarnessOptions.now.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.
type TaskRecordBase<I, R> = { /** Adapter-owned context that created this task; phase invocations extract it as their parent. */ readonly trace?: TelemetryCarrier; /** Immutable creation-time links for the task's first invocation. */ readonly links?: readonly TelemetryCarrier[]; readonly id: TaskId<R>; readonly conversationId: ConversationId; /** Registered task definition name. */ readonly kind: string; /** Definition version used to migrate live input and checkpoints. */ readonly version: number; /** Original task input retained while the task is live or terminal. */ readonly input: I; /** Owning task of a child task; absent for a task its conversation owns. Immutable. */ readonly owner?: TaskId; /** Whether this conversation-owned task is excluded from ordinary idle waits, conversation aborts, and cascades. */ readonly background: boolean; /** Durable abort mark checked before run-mode progress is committed. */ readonly abortRequested: boolean; /** * Why the mark was set when not by an abort request: `restart` for a task abandoned after a restart * (`TaskOptions.abandonOnRestart`) or below one. Such a task waits for its definition instead of becoming `orphaned`. * A later abort request clears it. */ readonly abortReason?: "restart"; /** Set from `TaskOptions.abandonOnRestart`. Immutable. */ readonly abandonOnRestart?: true; /** * Wall-clock milliseconds of the first change to `running`, stamped by the Session. Kept through waits and recovery, * so the span to `endedAt` includes them. Absent before the task first runs, and on records written by earlier * versions. */ readonly startedAt?: number; /** Wall-clock milliseconds of the change to `terminal`, stamped by the Session; absent while live. */ readonly endedAt?: number; /** * How many times in a row a crash, eviction, or reset cut the task off while it ran, without the task changing its * state since; open counts them, and a close does not. At `HarnessSettings.restartLimit` the task is parked as blocked * until `Harness.retryTask()`. */ readonly restarts?: number;};startedAt/endedAt là thời điểm vòng đời do Session đóng dấu, không phải thời gian thực thi. restarts đếm số lần liên tiếp một crash cắt ngang task mà task chưa đổi trạng thái./** 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; } );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" }
}
}
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).
/** 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.
/** 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>;}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ũ.
/** 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 đủ:
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); },});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.
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)./** 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<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 }, );}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 đó:
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));}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: ((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, ),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:
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;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
Hai quy tắc suy ra từ cây (spec.md:2322–2354):
- 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 quaConversation.abort(context, { background: true }). - 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ỉ ghiterminalkhi 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:
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`, ); }}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.
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];}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:
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);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ó.
/** * 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:
/** * 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#
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();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(): 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(); }}#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#
await this.#session.commitWith(async (tx) => { if (!this.#enabled || this.#closing) return; for (const id of this.#abandoned) { const record = this.#live.get(id); if (record !== undefined && !record.abortRequested) tx.setTask(withAbortMark(record, "restart")); } if (this.#abandoned.size > 0) { abandoned = [...this.#abandoned]; return; } await this.#loadScopes(false); // Every pass picks up sleeps whose time has come: a pass started by any change may be the first to run after // a deadline, and the wake it schedules no longer keeps a timer for a due sleep. this.#wakeDue(); this.#unparkRaised(); // Taken once per pass, and only when some task is a candidate. let snapshot: RegistrySnapshot | undefined; for (const id of [...this.#runnable]) { const record = this.#live.get(id); if (record === undefined || record.state.status === "completing" || this.#invocations.has(id)) { this.#runnable.delete(id); continue; } // It returns once what it waits for ends, its record changes, or the work below it ends. if (this.#waitingOn(record, true).length > 0) { this.#runnable.delete(id); continue; } // It returns at the first pass after its time comes, which the timer, `resume()`, or any change starts. if (this.#notDue(record)) { this.#runnable.delete(id); continue; } // It returns when `retry()` clears its restart count, or at a pass after `settings.restartLimit` rose. if (this.#parked(record)) { this.#runnable.delete(id); // Newly parked without a commit, as after a lowered limit: the graph must hear it. if (!this.#parkedTasks.has(id)) queueMicrotask(() => this.#notifyBlocked()); this.#parkedTasks.add(id); continue; }abandonOnRestart còn sống, pass đầu chỉ đánh dấu chúng và không reserve gì (dòng 1477–1480), để pass sau nhìn thấy mark đó. const runnable = record as RunnableTaskRecord; const mode = record.abortRequested ? "abort" : "run"; // Work below an owner with cancellation intent waits for its cascade mark instead of running a phase; schedule // the cascade, which may not have run yet. if (mode === "run" && !record.background && this.#belowCancelled(parentOf(record))) { this.#cascadePending = true; this.#scheduleReconcile(); continue; } // An abort handler cut off `restartLimit` times in a row would run forever; nothing is left to park it for. if (mode === "abort" && (record.restarts ?? 0) >= this.#restartLimit()) { const message = `Abort handler of task ${id} (${record.kind}) was cut off ${record.restarts} times`; await this.#terminate(tx, record, { status: "faulted", error: { message, detail: { reason: "restart_limit" } }, }); faulted.push({ record, message }); continue; } snapshot ??= this.#registry.snapshot(); const resolution = this.#resolve(runnable, snapshot); if (resolution.kind === "blocked") { // An abandoned task waits for its definition, so its abort handler can clean up. if (mode === "abort" && record.abortReason === undefined) { await this.#terminate(tx, record, { status: "orphaned", reason: resolution.reason }); } continue; } if (resolution.record !== runnable || record.state.status !== "running") { tx.setTask( withState(resolution.record, { status: "running", checkpoint: resolution.record.state.checkpoint, }), ); } // Registered on the line, so marks and later reservations see it and close joins it; `#end` makes it a // candidate again. this.#runnable.delete(id); const invocation = this.#createInvocation(runnable, mode, installerOf(snapshot, resolution.task)); reservations.push({ invocation, task: resolution.task, snapshot });}running được ghi cùng nhau. Invocation được đăng ký ngay trên dòng commit (dòng 1551), nên mark và pass reservation sau thấy nó, và close() join được nó.#run() và #step(): một step trước mỗi phase#
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), ); }); }}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():
): 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;}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));#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.
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(() => {});}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).
untilphải là số hữu hạn không âm, và handlerabort()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(): 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 };}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:
#scheduleWake(): void { if (this.#wakeScheduled || this.#closing) return; this.#wakeScheduled = true; queueMicrotask(() => { this.#wakeScheduled = false; if (this.#closing) return; const wake = this.wake(); if (this.#onWake === undefined) { this.#armTimer(this.#enabled ? wake.at : undefined); return; } const last = this.#lastWake; if (last !== undefined && last.active === wake.active && last.at === wake.at) return; try { this.#onWake(wake); // Only a delivered wake counts: after a throw, the next change or kick offers it again. this.#lastWake = wake; } catch (error) { this.#report(error, ON_WAKE); } });}#armTimer(at: number | undefined): void { if (this.#timer !== undefined && this.#timer.at === at) return; this.#timer?.disarm(); this.#timer = undefined; if (at === undefined || this.#closing) return; const disarm = this.#timers.foreground(() => { this.#timer = undefined; this.#wakeDue(); this.#kick(); }, at - this.#now()); this.#timer = { at, disarm };}/** Make every sleeping task whose time has come a reservation candidate. */#wakeDue(): void { for (const id of this.#sleeping) { const record = this.#live.get(id); if (record !== undefined && !this.#notDue(record)) this.#runnable.add(id); }}#wakeDue() lọc qua #notDue()), nên reo sớm chỉ đặt lại timer. Mọi pass reservation cũng gọi #wakeDue(), nên một sleep đã tới hạn chạy ở pass đầu tiên sau deadline, dù pass đó do một thay đổi khác kích hoạt.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.
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 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 };}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:
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: 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);},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:
/** 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:
export type GenerationCheckpoint = | { phase: "prepare"; attempt: number; /** The blocking compaction this generation waited for; it starts no other compaction (spec §8.3). */ compacted?: TaskId<CompactionResult>; /** Error text of the overflow that started `compacted`; checked once when `prepare` resumes. */ overflow?: string; /** Set when the blocking threshold started `compacted`; its outcome is checked once when `prepare` resumes. */ threshold?: true; } | { phase: "request"; attempt: number; compacted?: TaskId<CompactionResult>; model: ModelRef; thinkingLevel: ModelThinkingLevel; /** The settings' request options when preparation committed; a resend after recovery uses them unchanged. */ streamOptions: ConversationStreamOptions; /** Newest entry included in the request. */ cutoff: EntryId; } | { phase: "retry"; attempt: number; compacted?: TaskId<CompactionResult>; until: number } | { phase: "poll"; attempt: number; compacted?: TaskId<CompactionResult>; model: ModelRef; cutoff: EntryId; handle: DeferredHandle; pollAt: number; } | { /** Waiting on the round's tool tasks, which the generation owns (spec §8.5). */ phase: "tools"; /** The tool-calling answer. */ assistant: EntryId; /** Tool tasks created so far, in call order; grows by one per started call of a sequential round. */ tools: TaskId<ToolTaskResult>[]; /** Calls of a sequential round not started yet, in call order. */ pending: string[]; };request ghim (pin) model, mức suy nghĩ, tuỳ chọn stream và cutoff (entry mới nhất được gửi). Gửi lại sau phục hồi dùng đúng các giá trị này, không đọc lại settings.
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
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:
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);request: gửi, stream, và phân loại#
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);},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#
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;}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:
const settings = runtime.settings;const overflow = message.stopReason === "error" && isContextOverflow(message);const compaction = compactionPolicy(settings, ref);if (overflow && compacted === undefined && compaction.enabled) { const view = await runtime.context(conversationId, context, { at: cutoff }); if (selectCut(view, compaction.keepRecentTokens) !== undefined) { const text = message.errorMessage ?? "Context overflow"; await runtime.commit(async (tx): Promise<Next> => { const live = await tx.doc(LiveDoc, conversationId); await appendAssistant(tx, conversationId, message); delete live.generation; const child = await createCompaction(tx, conversationId, { reason: "overflow" }, runtime.taskId); const checkpoint = { phase: "prepare", attempt, compacted: child, overflow: text } as const; return { status: "waiting", checkpoint, on: [child], policy: "allSettled" }; }, context); return; }}const policy = settings.retry;// An overflow is never retried: only a compaction can make the next request fit.const retry = message.stopReason === "error" && !overflow && isRetryableAssistantError(message) && policy.enabled && attempt <= policy.maxRetries;const until = retry ? runtime.now() + retryDelayMs(policy, attempt) : 0;await runtime.commit(async (tx): Promise<Next> => { // Read before the first table write; only a failure uses it. const failure = retry ? undefined : await prepareFailure(tx, runtime); const live = await tx.doc(LiveDoc, conversationId); await appendAssistant(tx, conversationId, message); if (failure === undefined) { live.generation = { attempt, retry: { at: until, error: message.errorMessage ?? "" } }; const checkpoint = { phase: "retry", attempt, ...(compacted === undefined ? {} : { compacted }), until, } as const; return { status: "sleeping", checkpoint, until }; }Đ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#
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);}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#
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);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:
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);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#
| Hook | Khi nào | Cách ghép nhiều handler |
|---|---|---|
beforeRequest | Trướ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 |
afterResponse | Mọi message kết thúc của provider, trước khi phân loại | Tất cả quan sát |
onYield | Câu trả lời cuối; { continue } nối một tin nhắn người dùng và tiếp tục run | Continuation đầu tiên thắng |
afterTools | Khi mọi tool của vòng đã terminal | Tấ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ó.
/** * 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" };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:
export type ToolRegistration< TParameters extends TSchema = TSchema, TDetails extends JsonValue = JsonValue,> = Tool<TParameters> & { /** Whether an interrupted execution may rerun on recovery. Default `unsafe`. */ readonly replay?: "safe" | "unsafe"; /** Default: the settings' `toolExecution`. One sequential call makes its whole round sequential. */ readonly executionMode?: ToolExecutionMode; /** * Repair arguments models commonly get wrong before validation, such as a JSON string where an array belongs. Must be * pure and must not mutate `args`: it runs again when a call is retried before its intent is recorded. Its result is * still validated against `parameters`. */ prepareArguments?(args: unknown): Static<TParameters>; /** * Schema of `structuredOutput`, which programs that call the tool receive. Omitted: they receive the result's bounded * output, success or error, as `NestedToolExecutionResult.structuredOutput` describes. */ readonly structuredOutputSchema?: TSchema; /** * Who may call the tool: the model, other tools through `executeTool()`, or both (default). A code mode tool is * `["model"]`, so scripts cannot start scripts; tools behind code mode, such as MCP tools, can be `["tools"]`, so the * model is never offered them. */ readonly callers?: readonly ("model" | "tools")[]; /** * Not offered to the model until loaded: the model gets the tool only when `modelTools` names it, in its array or its * `add`, which `control.addTools` (from a tool such as `tool_search`) writes. Other tools can call it any time. */ readonly deferred?: true; readonly outputLimits?: { readonly maxBytes?: number; readonly maxLines?: number; readonly retain?: "head" | "tail"; }; execute( args: Static<TParameters>, api: ToolExecutionApi<TDetails>, context: Context, ): Promise<ToolExecutionResult<TDetails>>;};replay thì là unsafe. Không khai báo outputLimits thì là 50 KiB, 2 000 dòng, giữ phần đầu (spec.md:3152–3155).Phase call: phân giải, kiểm tra, hook, ghi ý định, chạy#
call: async (task, runtime, context) => { const call = await readCall(runtime, task.input, context); const tool = await resolveTool(runtime, task.input, call.name, context); if (tool === undefined) { const error = harnessError("tool_unavailable", `Tool ${call.name} is not available`); return settle(runtime, task.input, call, COMPLETED, () => error, context); } const prepared = prepare(tool, call.arguments as JsonObject); const checked = "error" in prepared ? prepared : validate(tool, call, prepared.args); if ("error" in checked) return settle(runtime, task.input, call, COMPLETED, () => invalid(checked.error), context); let args = checked.args; let block: string | undefined; await runtime.hooks.each("beforeTool", async (hook) => { if (block !== undefined) return; try { const decision = await hook({ ...call, arguments: args }, runtime, context); if (decision?.block !== undefined) block = decision.block; else if (decision?.arguments !== undefined) args = decision.arguments; } catch (error) { if (runtime.signal.aborted) throw error; block = errorMessage(error); } }); if (block !== undefined) { const blocked = harnessError("blocked", `Tool call blocked: ${block}`); return settle(runtime, task.input, call, COMPLETED, () => blocked, context); } const validated = validate(tool, call, args); if ("error" in validated) return settle(runtime, task.input, call, COMPLETED, () => invalid(validated.error), context); // Strict JSON: repair may set optional properties to undefined, which the slot and checkpoint cannot hold. const final = copyJson(validated.args, { omitUndefinedProperties: true }) as JsonObject; await runtime.commit(async (tx) => { const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId); if (slot !== undefined) slot.status = "running"; // A nested slot shows the arguments the call runs with, which repair, hooks, and coercion may have changed. // Compared with the input, which the slot got at admission: reading the draft would track every leaf. const changed = task.input.kind === "nested" && !jsonEqual(task.input.call.arguments as JsonValue, final); if (changed && slot !== undefined && "parentTaskId" in slot) slot.arguments = final; const intent = { phase: "execute", arguments: final, replay: tool.replay ?? "unsafe" } as const; return { status: "running", checkpoint: intent }; }, context); await run(runtime, task.input, call, tool, final, context);},Đ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#
/** 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);},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:
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():
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();}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ả#
// 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);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:
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);});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: 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);},<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).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, });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ậnabandoned(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:
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):
// 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 } }), };},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).
| Boundary | write | steer | follow-up |
|---|---|---|---|
postTools (sau vòng tool) | tất cả | cái đầu hoặc tất cả, theo steeringMode | khô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 |
export async function applyBoundary( tx: Tx, boundary: Boundary, at: "postTools" | "final", now: number,): Promise<BoundaryResult> { const { conversationId, inbox } = boundary; const items = inbox.items; const reset = hasQueuedReset(items); const final = at === "final" || reset; const pick = (mode: "steer" | "followUp", queueMode: QueueMode): number[] => { const indexes = items.flatMap((item, index) => (item.mode === mode ? [index] : [])); return queueMode === "all" ? indexes : indexes.slice(0, 1); }; const writes = items.flatMap((item, index) => (item.mode === "write" ? [index] : [])); const users = [...pick("steer", boundary.steeringMode), ...(final ? pick("followUp", boundary.followUpMode) : [])]; users.sort((a, b) => a - b); // `appendEntry()` copies the drafts' values; the items are removed only afterwards. for (const index of writes) { const item = items[index] as Draft<Extract<InboxItem, { mode: "write" }>>; const draft = item.entry as unknown as EntryDraft; if (isStale(boundary, draft)) { tx.settleSubmission(item.id, { status: "unanswered", reason: "stale" }); continue; } const entry = await tx.appendEntry(conversationId, draft); if (draft.head !== undefined) boundary.head = draft.head === "self" ? entry.id : draft.head; tx.placeSubmission(item.id, entry.id); } const placed: SubmissionId[] = []; for (const index of users) { const item = items[index] as Draft<Extract<InboxItem, { mode: "steer" | "followUp" }>>; const entry = await appendInput(tx, conversationId, item.content as UserInput, item.entry as InputEntry, now); tx.placeSubmission(item.id, entry.id); placed.push(item.id); } const removed = [...writes, ...users].sort((a, b) => b - a); for (const index of removed) items.splice(index, 1); return { users: placed, reset };}postTools thành final (dòng 78–79).- 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àopi.live.run.inputs(dòng 731 củafinishToolRound()). 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()tronganswer()). - Write chỉ có
kindvàdata(khôngmodel,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,headhayeditschờ 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ởi | Owner | Background | Generation chờ? | Đặt bản tóm tắt |
|---|---|---|---|---|
Conversation.compact() | conversation | không | không | write submission |
| generation, trên ngưỡng nền | conversation | có | không | write submission |
| generation, trên ngưỡng chặn hoặc khi tràn | generation | không | có | nối thẳng |
/** * 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).
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.
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 } };}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, … }
npm test) mất theo tiến trình. Mọi thứ ở trên đều đã nằm trong SQLite.- 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), …).openkiểm tra ba task dựng sẵn và gọischeduler.open()(harness.ts:540–561). Đối soát hạ Ta, Tb, G2 từrunningxuốngpendingvớirestarts: 1; G1 đangwaitingnên giữ nguyên (scheduler.ts:394–398). Không handler nào chạy;pi.livevẫn hiển thị hai slotrunningvới đầu ra cũ, và đó đúng là trạng thái một UI mới kết nối sẽ thấy. - 2. resume()Host gọi
harness.resume()(README.md:124); mộtsubmission.wait()cũng có tác dụng tương tự. Pass reservation đầu tiên: không task nào mangabandonOnRestart(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 sangrunningvà tạo ba invocation (scheduler.ts:1488–1553). - 3. G2: generation đang streamPhase
requestchạy lại từ đầu. Commit đầu tiên của nó nối partial đã commit thành một entrypi.assistantvới stop reasonabortedtrong c2, có tính usage (generation.ts:430–439). Rồi nó gửi lại đúng các message tớicutoffđã 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ờistopđi vàoanswer(): một commit nối câu trả lời, settle S2done, xoáruncủa c2 và kết thúc G2completed. Trạng thái mới xoárestarts. - 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ạiexecute()với đối số đã lưu, không hỏi lạibeforeTool(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 IDsubagent:${api.taskId}làmsubmit()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ốipi.tool-resultcho lời gọi a và kết thúccompleted. 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ớiterminalthay vìcompleting. - 5. Tb: tool không replay-safePhase
executethấyreplay: "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áninterrupted(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ệnhbashthật (harness-tools-recovery.test.ts:338–364). Tb kết thúcfailedkèmentryId. - 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ấyonđã terminal hết, chuyển G1 từwaitingsangrunning, và phasetoolsgọifinishToolRound(): đọc kết quả, chạyafterTools, không cóterminatehayhandoff, nên áp boundarypostTools, tạo generation G3 và trao run cho nó. G1 kết thúccompleted; S1 vẫnplaced. - 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
bashvới phần đầu ra một phần. Mô hình tự quyết định chạy lạinpm testhay không. Khi G3 trả lời,answer()settle S1done. Host lấy lại submission bằngharness.submission(id)vàwait()(README.md:138); cuối cùngpi.livecủ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,beforeToollầ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
interruptedvà kết thúcfailed.failedlà ý đị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 S2unansweredvới lý doaborted; chỉ khi đó Ta mới terminal. Đây chính là lý do kết quả interrupted dùngfailed(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ọiretryTask().
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ó:
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); },});// 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); },});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):
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 } }; },}),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:
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);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, );},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:
/** * 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. */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:
* 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).
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 deadlinetimeout_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).
/** 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 {}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ùngidthấy cùng file ở cùng đường dẫn. MọiNodeExecutionEnvcục bộ có chung"node:local"(node.ts:612–626); mỗi container hay máy từ xa cóidriê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ạnonOutputghi nó đến từstdouthaystderr;spillghi toàn bộ đầu ra ra file khi vượt ngưỡng;windowcho 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 toolreadkhô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.NodeExecutionEnvkhông cài đặt nó;RemoteExecutionEnvvàLocalExecutionEnvcủa pi-env thì có, và người gọi tự duyệt khi thiếu hoặc gặpnot_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.
bashgiữ phần đuôi đầu ra (retain: "tail"), đẩy từng đoạn vàoapi.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ánfull_output, trảstructuredOutput: { output, truncated, fullOutputPath?, exitCode }, biến exit khác 0 thành kết quả lỗi có chẩn đoánexit_codenhưng vẫn mang đầu ra, và ném lỗi khi timeout (bash.ts:87–158).readđọc quaopenBinaryReader, 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).writevàedittuần tự hoá thao tác đọc-sửa-ghi trên cùng một file:
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); }}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:
/** 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.progresstồ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.livechứ không nghe một kênh sự kiện riêng, và kết quảinterruptedsau 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
deployhay 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
awaittrong 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
awaitmô hình, mạng, tiến trình hay một commit lồng trong đó. Đọc quasnapshot(),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 quatx(spec.md:5521–5528). - Tool dựng sẵn đều unsafe, kể cả
read. Đừng khai báoreplay: "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:
cwdcủ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
completingké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ằngwaitForTask()(spec.md:5513–5518). failFastchỉ á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 theoHarnessOptions.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ộtContextchỉ 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ả
usagecủa conversation con trong kết quả của nó:pi.usagecủ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úcstale. - 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 |
|---|---|---|
| 1 | README.md | Concepts, Persist and Resume (sleep, crash loop, blocked), Tools, Code Mode, Abort and Subagents, Child Tasks. Đọc trước để có từ vựng. |
| 2 | spec.md §5 | Mô 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). |
| 3 | types.ts | TaskRuntime, TaskDefinition, TaskOptions (208–391); TaskOutcome, TaskState, TaskRecord (554–704). |
| 4 | packages/durable/test/examples/13-recovery.ts, packages/durable/test/examples/24-child-tasks.ts | Hai 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ả. |
| 5 | packages/durable/src/harness/scheduler.ts | Theo 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). |
| 6 | packages/durable/src/harness/live.ts | pi.live, endRun() và việc dọn dẹp khi scheduler tự ghi faulted/orphaned. |
| 7 | packages/durable/src/harness/generation.ts | Năm phase, streamResponse(), classify(), answer(), vòng tool. |
| 8 | packages/durable/src/harness/tool.ts | Phase call/execute, run(), finalResult(), settle(), lời gọi lồng nhau. |
| 9 | packages/durable/src/harness/inbox.ts, submissions.ts:201 | Boundary postTools/final, write chỉ để hiển thị, stale. |
| 10 | packages/durable/src/harness/compaction.ts, packages/durable/src/harness/context.ts | Ba 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. |
| 11 | packages/durable/src/codemode/codemode.ts, vm.ts:23–37 | Journal, phát lại, phân kỳ; chế độ tất định của VM. |
| 12 | packages/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. |
| 13 | packages/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.ts | Bằ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. |
| 14 | packages/durable/src/harness/task-graph.ts, packages/durable/src/harness/telemetry.ts | Graph 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ả.
TaskStatecó sáu trạng thái. Code của task chỉ đổi trạng thái của chính nó quaruntime.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ảiawait;wake()/onWakecho 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.generationghim 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.toolghi đố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ảinterruptedvớ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 taskorphaned.