Chương 20Phần 5: Chord và Pi Durable
pi-env, telemetry và evals
Chạy tool qua SSH với pi-env, hợp đồng telemetry có kiểu, và đo hành vi bằng evals.
Trong chương này17 mục
- pi-env: tool chạy ở máy khác, phần bền ở lại
- Mô hình tư duy: client áp luật của Node, daemon làm system call
- Triển khai qua SSH
- Giao thức: dòng đồng bộ, frame và hello
- Bên trong daemon: luồng, huỷ, liveness
- RemoteExecutionEnv: hiện thực ExecutionEnv
- Lệnh và cửa sổ output
- Watch, listFiles và terminal
- Lần theo một lệnh bash trên máy từ xa
- pi-telemetry: hợp đồng span, sự kiện, metric
- Schema telemetry có kiểu
- Pi Durable và telemetry
- pi-evals: đo hành vi của coding agent
- 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 gom ba package đứng quanh lõi, mỗi cái trả lời một câu hỏi khác nhau. pi-env (@earendil-works/pi-env): làm sao để tool của một agent Pi Durable chạy trên máy khác, qua SSH, mà kết quả giống hệt khi chạy tại chỗ. pi-telemetry (@earendil-works/pi-telemetry): làm sao để các package của Pi mô tả những gì chúng làm (span, sự kiện (event), metric) mà không buộc vào một backend quan sát nào. pi-evals (packages/evals, không phát hành): làm sao đo hành vi của coding agent, đặc biệt là tài liệu của nó có giúp mô hình làm đúng việc hay không. Cách Pi Durable dùng một ExecutionEnv nằm ở chương Durable: task, scheduler, generation và tool; ở đây ta xem phần hiện thực từ xa.
pi-env: tool chạy ở máy khác, phần bền ở lại#
Bài toán: một worker Pi Durable chạy trên máy của bạn (hay trong một tiến trình server), giữ storage, khoá API và lịch sử hội thoại. Code cần sửa và lệnh cần chạy lại nằm trên một máy khác: một máy GPU, một điện thoại Android chạy Termux, một máy Windows. Mang cả worker sang đó nghĩa là mang theo credential và storage. pi-env tách hai thứ: "tool của agent chạy trên máy khác, thường qua SSH, còn worker Durable, storage và credential của nó ở lại máy cục bộ" (README.md:3–4).
- `pi-env` (`daemon/`): a small Rust program that runs on the remote machine. It speaks a framed protocol on stdin and stdout ([docs/protocol.md](docs/protocol.md)) and performs file operations and commands there.- `RemoteExecutionEnv`: a Durable `ExecutionEnv` that talks to the daemon through a `Connection`. Its results match `NodeExecutionEnv` running on the remote machine; only error messages may differ ([docs/semantics.md](docs/semantics.md)).RemoteExecutionEnv khớp với NodeExecutionEnv chạy trên máy từ xa; chỉ thông báo lỗi được phép khác.Package phụ thuộc Chord (chỉ để lấy kiểu Context) và pi-durable (để lấy giao diện ExecutionEnv và các kiểu kết quả) (package.json:47–50). Bản đầu tiên ra ở 1.0.4 (2026-10-05) với RemoteExecutionEnv, Connection và phần khởi động qua SSH; 1.2.0 thêm listFiles, LocalExecutionEnv và pseudo-terminal (CHANGELOG.md:7–24). Giao diện nó hiện thực thuộc Pi Durable, vốn được ghi là thử nghiệm, nên pi-env cũng di chuyển theo.
Mô hình tư duy: client áp luật của Node, daemon làm system call#
Máy cục bộ Máy từ xa
Harness · task pi.tool · tool bash/read/edit/write
│ ExecutionEnv (FileSystem + Shell)
RemoteExecutionEnv giải đường dẫn, điểm kiểm abort, map mã lỗi,
│ cỡ lần đọc của readFile, luật kết quả của exec
│ request(op, json, payload) · cancel · kill
Connection ── spawn: ssh -T … -- host '~/.pi/mobile/tools/pi-env-<sha>' serve --token <hex>
│ stdin: frame request · cancel · ping ──────▶ pi-env (Rust)
│ stdout: "PI-ENV <token>\n", rồi chỉ frame ◀────── system call · tiến trình · spill
│ watch (cổng của NodeFileWatcher)
storage · credential · gọi mô hình: ở lại listFiles · pty
Connection quản đúng một tiến trình daemon tại một thời điểm; daemon được khởi động lười ở request đầu và khởi động lại sau khi mất. ExecutionEnv của Durable được định nghĩa ở durable/src/env/index.ts:374.Chuẩn đối chiếu là NodeExecutionEnv của Durable chạy trên máy từ xa; bộ kiểm tuân thủ (conformance) cho env của Durable và một test vi sai (differential) kiểm từng luật (semantics.md:3–4). Để đạt điều đó, việc được chia rõ:
## Split of work| Concern | Where ||---|---|| Path resolution: `~` (remote home), `file://`, relative to `cwd`; on Windows drive-relative paths against the remote's working directories | client, the remote system's `path` rules || Abort checkpoints of each method | client, as `NodeExecutionEnv` || Error code mapping (`ENOENT` → `not_found`, ...) | client, as `NodeExecutionEnv`'s `toFileError` || `readFile`'s read sizes, size limit and decoding (`StringDecoder`, byte-order mark kept) over the daemon's `open(path, "r")` | client || `writeFile` in 512 KiB writes with an abort check before each; `appendFile` without checks | client, over one open file || Line reader | client, positional reads and Durable's `StreamDecoder` || Watching: snapshots, native events, polling | daemon, a port of Durable's `NodeFileWatcher` || Timeout validation, result precedence (callback error, timeout, abort, spill failure, exit code) | client || System calls, processes, spill files, line scans | daemon || Command output decoding: per stream, WHATWG UTF-8, only a leading byte-order mark dropped | daemon (`encoding_rs`) |readFile chia lần đọc); daemon làm system call, quản tiến trình, file tràn (spill: toàn bộ output được ghi ra một file tạm khi vượt ngưỡng), quét dòng, và chạy watcher ngay cạnh file.Lý do cách chia này cần thiết chứ không phải tuỳ chọn: nếu daemon tự diễn giải đường dẫn hay tự quyết khi nào kiểm abort, nó sẽ phải sao chép lại hành vi của Node bằng Rust, và mỗi khác biệt nhỏ là một kết quả tool khác đi. Giữ luật ở client, chạy đúng code TypeScript đó, thì chỉ còn system call là phải khớp, và system call được kiểm bằng conformance.
Triển khai qua SSH#
Mọi lần gọi ssh đi qua cùng một bộ tham số, chặt hơn mặc định ở mọi chỗ có thể:
/** * Arguments for `ssh` up to the host: no prompts, no forwarding of any kind, no shared connections, no commands from * the configuration, no locale forwarding (the remote uses its own), and host keys checked strictly against the * application's own file under a fixed alias. */export function sshArguments( target: SshTarget, strictHostKeys = true, knownHostsFile = target.knownHostsFile,): string[] { checkField("host", target.host); checkField("host key alias", target.hostKeyAlias); if (target.user !== undefined) checkField("user", target.user); if (target.port !== undefined && (!Number.isInteger(target.port) || target.port < 1 || target.port > 65535)) { throw new Error(`Invalid port: ${target.port}`); } const option = (setting: string) => ["-o", setting]; return [ ...(target.configFile === undefined ? [] : ["-F", target.configFile]), "-T", "-a", "-x", ...option("BatchMode=yes"), ...option("ClearAllForwardings=yes"), ...option("ForwardAgent=no"), ...option("ForwardX11=no"), ...option("ControlMaster=no"), ...option("ControlPath=none"), ...option("RemoteCommand=none"), ...option("PermitLocalCommand=no"), ...option("SendEnv=-*"), ...option("ServerAliveInterval=15"), ...option(`StrictHostKeyChecking=${strictHostKeys ? "yes" : "accept-new"}`), ...option(`UserKnownHostsFile=${configPath("known hosts file", knownHostsFile)}`), ...option("GlobalKnownHostsFile=none"), ...option("HashKnownHosts=no"), ...option(`HostKeyAlias=${target.hostKeyAlias}`), ...(target.user === undefined ? [] : ["-l", target.user]), ...(target.port === undefined ? [] : ["-p", String(target.port)]), ...(target.identityFile === undefined ? [] : [ ...option(`IdentityFile=${configPath("identity file", target.identityFile)}`), ...option("IdentitiesOnly=yes"), ]), "--", target.host, ];}SendEnv=-* bỏ mọi biến môi trường được gửi, kể cả locale: máy từ xa dùng locale của chính nó. Host key được kiểm theo file known-hosts riêng của ứng dụng dưới một alias cố định, độc lập với alias trong ~/.ssh/config, cổng hay jump host. Host, user và alias bị từ chối nếu bắt đầu bằng - hay chứa khoảng trắng (ssh.ts:59–64).Tin một host key#
Vì StrictHostKeyChecking=yes, máy lạ bị từ chối với HostKeyUnknownError. scanHostKey(target) kết nối một lần với accept-new vào một file known-hosts tạm, qua đúng đường mà kết nối thật sẽ đi, rồi trả về các dòng key và fingerprint (qua ssh-keygen -lf); comment ghi rõ hiển thị fingerprint không phải là xác thực, chủ máy phải so với một bản lấy qua kênh khác (ssh.ts:177–207). acceptHostKey chỉ nhận dòng thuần cho đúng alias, và từ chối với HostKeyChangedError nếu key cùng loại đã có mà khác; muốn đổi phải forgetHostKey trước (ssh.ts:253–286). File known-hosts được ghi lại nguyên tử, mỗi file một thay đổi tại một thời điểm (ssh.ts:224–251).
Phát hiện hệ thống và triển khai daemon#
detectPlatform() chạy một probe POSIX (uname -s, uname -m, uname -o, $HOME, $TMPDIR, $LD_PRELOAD) qua shell đăng nhập; không được thì thử PowerShell; gặp Git Bash làm shell mặc định của SSH trên Windows thì hỏi lại PowerShell; với Windows, echo %OS% phân biệt cmd.exe với PowerShell. Termux nhận thêm cảnh báo: thiếu TMPDIR, thiếu termux-exec, Android có thể tạm dừng Termux (ssh.ts:288–353). Package chở sẵn daemon cho mọi hệ được hỗ trợ trong bin/pi-env-<platform>-<arch>/ (ssh.ts:355–359).
/** Where a daemon with this content lives on the remote machine: named by its SHA-256, so versions never collide. */function daemonPath(remote: RemotePlatform, sha256: string): string { const name = `pi-env-${sha256.slice(0, 32)}`; return remote.platform === "windows" ? `${remote.home}\\.pi\\mobile\\tools\\${name}.exe` : `${remote.home}/.pi/mobile/tools/${name}`;}/** * Make sure the daemon is on the remote machine, verified by its SHA-256 before it ever runs, and remove daemons of * other contents. The upload goes to a new temporary file and is renamed into place only once its hash matches. * Returns the remote path of the binary. */export async function deployDaemon( target: SshTarget, remote: RemotePlatform, binary = packagedDaemon(remote),): Promise<string> { if (!existsSync(binary)) throw new Error(`No pi-env daemon for ${remote.platform}-${remote.arch} at ${binary}`); const bytes = await readFile(binary); const sha256 = createHash("sha256").update(bytes).digest("hex"); const file = daemonPath(remote, sha256); const state = await checkDaemon(target, remote, file, sha256); if (state === "present") return file; if (state === "nohash") throw new Error(`${target.host} has no sha256sum, shasum or openssl to verify pi-env`); const upload = [ "set -e", POSIX_HASH, `f=${quotePosix(file)}`, 'd=$(dirname "$f")', 'mkdir -p "$d"', 'chmod 700 "$d"', 't=$(mktemp "$d/.pi-env.XXXXXX")', 'cat > "$t"', `if [ "$(hash "$t")" != ${sha256} ]; then rm -f "$t"; echo "pi-env upload is corrupt" >&2; exit 1; fi`, 'chmod 700 "$t"', 'mv -f "$t" "$f"', // Older daemons; running ones keep their file open until they exit. 'for old in "$d"/pi-env-*; do [ "$old" = "$f" ] || rm -f "$old"; done', "echo deployed", ].join("\n"); await runSsh(target, `sh -c ${quotePosix(upload)}`, { stdin: bytes, timeoutMs: UPLOAD_TIMEOUT_MS }); return file;PI-ENV-END, vì sshd của Windows có thể không bao giờ chuyển tiếp lúc stdin kết thúc. Tên file dùng 32 ký tự hex đầu của SHA-256, nên hai phiên bản không bao giờ đè nhau.Kết nối thật là một Connection mà lệnh khởi động là một hàm, chạy lại ở mỗi lần khởi động: phát hiện hệ thống một lần, kiểm (và nếu cần triển khai) daemon, rồi trả về dòng lệnh ssh:
/** A `Connection` whose every start detects the remote system (once), verifies or deploys the daemon, then starts it. */function sshConnectionFor(options: SshConnectOptions, state: SshState): Connection { return new Connection({ command: async () => { if (state.remote === undefined) { const remote = await detectPlatform(options); for (const warning of remote.warnings) options.onLog?.(`${warning}\n`); state.remote = remote; } // A deployment that just verified the binary counts for the first start. const file = state.verified ?? (await deployDaemon(options, state.remote, options.binary ?? packagedDaemon(state.remote))); state.verified = undefined; return [ options.ssh ?? "ssh", ...sshArguments(options), launchCommand(state.remote, file, options.loginShell === true), ]; }, ...(options.onLog === undefined ? {} : { onLog: options.onLog }), });}state.verified), nên connectSsh không kiểm hai lần. Từ lần khởi động thứ hai, kiểm hash là một vòng SSH thêm trước khi chạy daemon.Hai cách lấy kết nối. connectSsh(target) phát hiện và triển khai ngay, và ném lỗi tại chỗ (ví dụ HostKeyUnknownError). sshConnection(target) không làm gì cho tới request đầu tiên; lỗi (mất mạng, host key chưa tin) thành lỗi spawn_error của request đó, và request sau thử lại (ssh.ts:513–539). Cách lười quan trọng với Durable: tạo một môi trường (trong HarnessOptions.env) không bao giờ cần mạng.
Giao thức: dòng đồng bộ, frame và hello#
Client chạy pi-env serve --token <hex> và nói chuyện qua stdin/stdout của tiến trình đó; stderr chỉ là chẩn đoán cho log (protocol.md:3–4). Vấn đề đầu tiên là file khởi động của shell: .bashrc của máy từ xa có thể in một banner lên stdout trước khi daemon chạy. Token giải quyết việc đó: dòng đầu tiên daemon in ra là PI-ENV <token>\n với token lấy từ dòng lệnh (daemon/src/main.rs:472–477), và client bỏ mọi thứ trước dòng đó:
#onData(session: Session, chunk: Buffer): void { if (!session.live) return; session.lastSeen = Date.now(); session.chunks.push(chunk); session.buffered += chunk.length; if (!session.synced) { // Shell startup files may print before the daemon runs; skip everything before its sync line. const marker = Buffer.from(`PI-ENV ${session.token}\n`, "utf8"); const all = session.peek(session.buffered); const index = all.indexOf(marker); if (index === -1) { if (session.buffered > 1024 * 1024) { session.consume(session.buffered - marker.length); } return; } const noise = all.subarray(0, index).toString("utf8").trim(); if (noise !== "") this.#options.onLog?.(noise); session.consume(index + marker.length); session.synced = true; }Sau dòng đó, stdout chỉ còn frame. Mọi frame, cả hai chiều, có cùng khung: u32 length, u8 type, u32 id, u32 jsonLength, JSON, rồi payload thô; số nguyên big-endian, frame tối đa 16 MiB (protocol.md:12–27). Phía client:
/** Lone surrogates become U+FFFD, as Node encodes strings for system calls. */function wellFormed(_key: string, value: unknown): unknown { return typeof value === "string" ? value.toWellFormed() : value;}function frame(type: number, id: number, json: Json, payload: Uint8Array = new Uint8Array(0)): Buffer { const body = Buffer.from(JSON.stringify(json, wellFormed), "utf8"); const header = Buffer.alloc(13); header.writeUInt32BE(9 + body.length + payload.length, 0); header.writeUInt8(type, 4); header.writeUInt32BE(id, 5); header.writeUInt32BE(body.length, 9); return Buffer.concat([header, body, payload]);}toWellFormed() đổi surrogate lẻ thành U+FFFD trước khi mã hoá UTF-8, đúng cách Node mã hoá chuỗi đường dẫn và tham số cho system call. Nội dung file và output đi dạng bytes thô trong payload, không bao giờ qua JSON.Phía daemon, read_frame kiểm độ dài; JSON không parse được vẫn giữ khung nguyên vẹn (frame tới với Value::Null và được trả lời lỗi EINVAL), còn lỗi khung thì kết thúc kết nối (daemon/src/frame.rs:49–82). Sáu loại frame:
| type | Tên | Chiều | Nghĩa |
|---|---|---|---|
| 1 | request | client → daemon | json.op là tên thao tác |
| 2 | result | daemon → client | Request id thành công và kết thúc |
| 3 | error | daemon → client | Request id thất bại và kết thúc: { code, message, syscall?, path? } |
| 4 | event | daemon → client | Tiến độ của request đang chạy (output của exec, pty; thay đổi của watch) |
| 5 | cancel | client → daemon | Huỷ request id; { mode: "kill" } là giết mà không huỷ |
| 6 | ping | cả hai | Còn sống; bên nhận bỏ qua |
Bảng gốc ở protocol.md:28–39. code là mã kiểu Node (ENOENT, EISDIR, ERR_FS_EISDIR...) hoặc một mã thực thi (shell_unavailable, spawn_error, timeout, aborted, unknown); chỉ mã là hợp đồng, thông báo là chẩn đoán. Đường dẫn luôn tuyệt đối: client giải ~, đường dẫn tương đối và URL file:// trước khi gửi.
Request đầu tiên là hello { protocol }; daemon trả về phiên bản giao thức và package, hệ điều hành và kiến trúc theo cách viết của Rust (linux, macos, x86_64, aarch64), thư mục home, tmpdir theo luật os.tmpdir() của Node, thư mục làm việc (kể cả thư mục làm việc theo ổ của Windows), pid, và features: các thao tác thêm vào giao thức 1 sau bản đầu (hiện chỉ listFiles) (main.rs:148–160; protocol.md:71–77). Client từ chối mọi phiên bản khác 1 (connection.ts:309–311), và nodePlatform(info) đổi tên sang cách viết của Node (win32, darwin, x64, arm64) (connection.ts:55–64).
Bên trong daemon: luồng, huỷ, liveness#
Daemon là một chương trình Rust nhỏ, phụ thuộc encoding_rs, ignore (crate của fd và ripgrep), notify, serde_json và libc hoặc windows-sys (daemon/Cargo.toml:8–25). Một luồng đọc frame từ stdin và phân phối:
fn serve_frames(server: &Arc<Server>, input: &mut impl Read) -> io::Result<()> { let jobs = start_workers(); while let Some(request) = frame::read_frame(input)? { match request.kind { REQUEST if matches!( request.json["op"].as_str(), Some("ptyWrite" | "ptyResize" | "ptyForeground") ) => { // Answered here, in arrival order: through the worker pool, two writes could swap. let reply = match request.json["op"].as_str() { Some("ptyWrite") => { pty::write(&server.terminals, &request.json, request.payload) } Some("ptyResize") => pty::resize(&server.terminals, &request.json), _ => pty::foreground(&server.terminals, &request.json), }; server.output.control(match reply { Ok(json) => Frame::new(RESULT, request.id, json), Err(failure) => Frame::new(ERROR, request.id, failure.to_json()), }); } REQUEST => { let id = request.id; // Registered before the request runs, so a cancel right behind it is not lost. let control = Arc::new(Control::default()); server.controls.lock().unwrap().insert(id, control.clone()); // Commands and watchers last; they get their own threads (watchers recurse through trees). if matches!(request.json["op"].as_str(), Some("exec" | "watch" | "pty")) { let worker = server.clone(); let spawned = thread::Builder::new() .stack_size(16 * 1024 * 1024) .spawn(move || respond(&worker, id, request, control)); if let Err(error) = spawned { server.controls.lock().unwrap().remove(&id); let failure = Failure::new("spawn_error", error.to_string()); server .output .bulk(Frame::new(ERROR, id, failure.to_json()), None); } } else { let worker = server.clone(); let serial = match request.json["op"].as_str() { Some("writeChunk") => server.file(&request.json).ok(), _ => None, }; let job: Job = Box::new(move || respond(&worker, id, request, control)); match serial { Some(handle) => run_in_order(&jobs, handle, job), None => { let _ = jobs.send(job); } } } } CANCEL => { let control = server.controls.lock().unwrap().get(&request.id).cloned(); if let Some(control) = control { control.cancel(request.json["mode"].as_str() == Some("kill")); } }exec, watch, pty chạy lâu nên có luồng riêng, với stack 16 MiB (comment ghi watcher đệ quy qua cây thư mục); thao tác file vào một pool 16 luồng, và writeChunk của cùng một handle được xếp hàng theo thứ tự đến (main.rs:377–402).Một Control được đăng ký cho mỗi request trước khi nó chạy, nên một cancel gửi ngay sau request không bao giờ mất (protocol.md:45–51; exec.rs:39–58). Huỷ exec hay pty giết process group của lệnh và kết thúc với aborted; scanLines dừng giữa các khúc, listFiles giữa các mục; watch dừng và trả {}.
Trả lời đi trước output#
//! The single writer of stdout. Control frames (results, errors, pings, watch events) always go before bulk command//! output, so a slow link delays output but never a cancel's result or a ping. Bulk output is bounded: readers of//! commands without an output window wait while too much of it is unsent, which slows the command down like a full//! pipe would. pub fn run(&self, writer: &mut impl Write) { loop { let (frame, unsent) = { let mut queues = self.queues.lock().unwrap(); loop { if let Some(frame) = queues.control.pop_front() { break (frame, None); } if let Some((frame, unsent)) = queues.bulk.pop_front() { queues.bulk_bytes -= frame.payload.len(); break (frame, unsent); } if queues.closed { return; } queues = self.queued.wait(queues).unwrap(); } };Liveness: không để lại tiến trình mồ côi#
Mỗi bên gửi ping mỗi 5 giây. Daemon giết mọi process group nó đã khởi động và thoát sau 30 giây không nhận được byte nào, hoặc khi stdin kết thúc (main.rs:494–520). Client tính ngược lại: 30 giây im lặng là kết nối đã mất, mọi request đang chờ bị từ chối với { code: "unknown", lost: true }, và request sau khởi động một daemon mới (connection.ts:301–307; protocol.md:62–67). Hệ quả có chủ đích: một điện thoại mất mạng giữa chừng không để lại lệnh nào chạy trên máy từ xa. Với lệnh có hiệu ứng, kết quả lúc đó là không biết: comment của Connection nói thẳng "for mutations their outcome is then unknown" (connection.ts:177–180).
RemoteExecutionEnv: hiện thực ExecutionEnv#
ExecutionEnv của Durable là FileSystem cộng Shell: khoảng hai mươi phương thức file (đọc, ghi, reader nhị phân và theo dòng, đọc thư mục, watch...) và exec cộng cleanup; mọi phương thức trả về Result chứ không ném lỗi, và nhận Context làm tham số cuối (durable/env/index.ts:209–284; chương task giải thích từng phương thức). RemoteExecutionEnv hiện thực đúng giao diện đó; cấu hình gồm connection, id (không gian tên file: hai môi trường cùng id thấy cùng file, ví dụ pi-env:gpu), cwd, và tuỳ chọn shellPath, shellEnv, watch (remote-env.ts:51–61).
Đường dẫn được giải ở client, theo luật của máy từ xa#
/** Node's `resolvePath` on the remote system, with its home directory and working directories. */async #resolve(path: string): Promise<string> { const info = await this.connection.info(); const windows = nodePlatform(info).platform === "win32"; const paths = windows ? win32 : posix; let normalized = path; if (normalized === "~") { normalized = info.home; } else if (normalized.startsWith("~/") || (windows && normalized.startsWith("~\\"))) { normalized = paths.join(info.home, normalized.slice(2)); } else if (normalized.startsWith("file://")) { try { normalized = fileURLToPath(normalized, { windows }); } catch { // Keep malformed URLs as ordinary paths, as Node does. } } if (paths.isAbsolute(normalized)) return paths.resolve(normalized); if (windows) { // A drive-relative path on another drive: Node on Windows resolves it against that drive's working directory // (`=D:`), else its own working directory if on that drive, else the drive's root. const drive = /^([a-zA-Z]:)(?![\\/])/.exec(normalized)?.[1]; if (drive !== undefined && drive.toLowerCase() !== this.cwd.slice(0, 2).toLowerCase()) { let base = info.driveCwds[drive.toUpperCase()] ?? info.cwd; if (base.slice(0, 2).toLowerCase() !== drive.toLowerCase() && base[2] === "\\") base = `${drive}\\`; return win32.resolve(base, normalized); } } return paths.resolve(this.cwd, normalized);}path.win32 hay path.posix được chọn theo hello, nên một worker trên macOS giải đúng D:foo của một máy Windows: theo thư mục làm việc của ổ D: (biến =D:), nếu không có thì theo thư mục làm việc của daemon nếu cùng ổ, nếu không thì gốc ổ.Mọi thao tác file đi qua #fileOp: giải đường dẫn, kiểm abort trước, chạy, và với vài thao tác kiểm abort cả sau, đúng các điểm kiểm của NodeExecutionEnv; lỗi được map về FileError (remote-env.ts:404–428). Bảng map mã lỗi là bảng của toFileError trong Node env: ENOENT thành not_found, EACCES/EPERM thành permission_denied, ENOTDIR, EISDIR, EINVAL... còn lại là unknown (errors.ts:8–34).
Handle chết theo daemon#
Reader nhị phân, reader theo dòng và reader thư mục giữ một handle số do daemon cấp. Handle chỉ có nghĩa với tiến trình daemon đã mở nó; sau khi kết nối mất và một daemon mới được khởi động, gửi handle cũ tới daemon mới sẽ đọc nhầm file khác. Connection chặn điều đó bằng id của session:
async request(op: string, json: Json, options: RequestOptions = {}): Promise<Reply> { if (options.session !== undefined) { const current = this.#session; if (current === undefined || current.id !== options.session || !current.live) { throw lost("pi-env connection lost"); } return this.#send(current, op, json, options); } const { session } = await this.#connect(); return this.#send(session, op, json, options);}Handle nhớ session của câu trả lời đã cấp nó và gửi mọi request của mình kèm session đó (remote-env.ts:133–158). Daemon giới hạn 4.096 handle mở mỗi kết nối, quá thì EMFILE (main.rs:42–43).Đọc và ghi theo khúc, nhiều khúc cùng bay#
Một vòng request qua SSH tốn thời gian, nên đọc tuần tự từng khúc sẽ chậm. readRange() giữ tới 8 lệnh pread cùng bay; một lần đọc ngắn (chạm cuối file) huỷ các giả định của những lần đang bay và đọc tiếp ngay sau nó, đúng như đọc tuần tự (remote-env.ts:94–131). readTextFile/readBinaryFile mở file như open(path, "r") của Node, rồi theo đúng cách readFile của Node chia lần đọc: kích thước 0 (FIFO, thiết bị) thì đọc tuần tự 64 KiB tới hết, không giải mã thì một lần cho file nhỏ hay các khúc 512 KiB, giải mã UTF-8 thì bằng StringDecoder (remote-env.ts:451–524). Ghi đi chiều ngược lại:
/** * Node's `writeFile` (abort checked before each 512 KiB write) or `appendFile` (no checks, one after): the first * request creates parents, opens, and writes the first chunk; later chunks go to the same open file. */async #write(path: string, content: string | Uint8Array, append: boolean, context: Context) { const bytes = typeof content === "string" ? Buffer.from(content, "utf8") : content; const signal = append ? undefined : context.abortSignal; return this.#fileOp( path, context, async (resolved) => { const first = bytes.subarray(0, WRITE_CHUNK); const keep = bytes.length > WRITE_CHUNK; const reply = await this.connection.request("write", { path: resolved, append, keep }, { payload: first }); if (!keep) return; const handle = new Handle(this, reply.json.handle as number, reply.session, resolved); const inFlight: Promise<unknown>[] = []; try { for (let offset = WRITE_CHUNK; offset < bytes.length; offset += WRITE_CHUNK) { if (signal?.aborted) { // A chunk that failed before this check fails the write, as it would have in sequence. const failed = (await Promise.allSettled(inFlight)).find((result) => result.status === "rejected"); if (failed !== undefined) throw failed.reason; throw new FileError("aborted", "The operation was aborted", resolved); } const written = handle.request( "writeChunk", {}, { payload: bytes.subarray(offset, offset + WRITE_CHUNK) }, ); written.catch(() => {}); inFlight.push(written); if (inFlight.length >= WRITE_DEPTH) await inFlight.shift(); } // The first failure; later chunks are refused by the daemon, so the file has no gaps. for (const written of inFlight) await written; } finally { await Promise.allSettled(inFlight); await handle.close(); } }, append ? "both" : "before", );}writeFile kiểm abort trước mỗi khúc 512 KiB như Node; appendFile không kiểm giữa chừng mà kiểm cả trước lẫn sau. Một khúc ghi lỗi làm daemon "đầu độc" handle: các khúc sau bị từ chối EBADF, nên file không bao giờ có lỗ (main.rs:172–182).Một chi tiết nhỏ cho thấy mức độ đòi hỏi "khớp với Node": listDir sắp tên theo bytes trên POSIX như scandir của libuv, dùng bytes gốc (raw) của tên không phải UTF-8; trên Windows thì giữ thứ tự của file system như daemon báo (remote-env.ts:704–710). Các luật mà thư viện chuẩn không tự làm (mkdir đệ quy, rm, mkdtemp, tmpdir, /dev/null, FIFO...) được liệt kê ở semantics.md:21–42.
Lệnh và cửa sổ output#
const controller = new AbortController();const onAbort = () => controller.abort();signal?.addEventListener("abort", onAbort, { once: true });try { const { json } = await this.connection.request( "exec", { ...(typeof command === "string" ? { command } : { argv: [...command] }), cwd, env, inheritEnv, ...(this.#shellPath === undefined ? {} : { shellPath: this.#shellPath }), ...(timeout === undefined ? {} : { timeoutMs: timeout * 1000 }), ...(options?.spill === undefined ? {} : { spill: options.spill }), ...(options?.window === undefined ? {} : { window: options.window }), }, { signal: controller.signal, onStart: (requestId, session) => { running = requestId; this.#running.set(requestId, session); }, onEvent: (event, payload) => { if (settled || callbackError !== undefined || event.kind !== "output") return; const text = Buffer.from(payload.buffer, payload.byteOffset, payload.length).toString("utf8"); if (text === "" || options?.onOutput === undefined) return; const info: ShellOutputInfo = { stream: event.stream === "stderr" ? "stderr" : "stdout" }; if (event.skipped !== undefined) info.skipped = event.skipped as ShellOutputSkip; try { options.onOutput(text, context, info); } catch (error) { const cause = error instanceof Error ? error : new Error(String(error)); callbackError = new ExecutionError("callback_error", cause.message, cause); controller.abort(); } }, }, ); if (callbackError) return err(callbackError); return ok({ exitCode: json.exitCode as number, ...(typeof json.spillPath === "string" ? { spillPath: json.spillPath } : {}), });NodeExecutionEnv làm (timeout hữu hạn, dương, không quá giới hạn của setTimeout) trước khi gửi (remote-env.ts:852–867). Callback onOutput ném lỗi thì lệnh bị huỷ và kết quả là callback_error. Lỗi của daemon được map về ExecutionError: timeout, aborted, shell_unavailable, spawn_error, còn lại unknown, kèm spillPath nếu có (remote-env.ts:928–942).cleanup() dành cho lúc chủ môi trường tắt: nó gửi cancel với mode: "kill" cho mọi lệnh môi trường này còn chạy, giết mà không huỷ, nên lệnh kết thúc với trạng thái của tiến trình bị giết (137), đúng như NodeExecutionEnv (remote-env.ts:950–954; connection.ts:221–225). Phía daemon, ngữ nghĩa lệnh theo semantics.md:44–60: chuỗi chạy qua /bin/bash, which bash, rồi sh với -c, mảng argv chạy thẳng; thư mục làm việc phải tồn tại; môi trường là của daemon, rồi shellEnv, rồi env. Mỗi lệnh có một session (process group) mới với signal mặc định và mask rỗng, nên timeout, abort và cleanup() giết cả cây bằng SIGKILL:
// SAFETY: only async-signal-safe calls between fork and exec. unsafe { command.pre_exec(|| { // A new session, so a kill reaches every descendant; default signal handling and an empty mask, as libuv sets. libc::setsid(); for signal in 1..32 { if signal != libc::SIGKILL && signal != libc::SIGSTOP { libc::signal(signal, libc::SIG_DFL); } } let mut mask: libc::sigset_t = std::mem::zeroed(); libc::sigemptyset(&mut mask); libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut()); Ok(()) }); }}pub fn after_spawn(_child: &Child) {}/// Kill the command's process group, or the process if the group is gone.pub fn kill_tree(pid: u32) { let pid = pid as i32; // SAFETY: plain signal delivery. unsafe { if libc::kill(-pid, libc::SIGKILL) != 0 { libc::kill(pid, libc::SIGKILL); } }}/// A process killed by a signal has no exit code; report 128 + the signal, as the shell does.pub fn exit_code(status: ExitStatus) -> i64 { i64::from( status .code() .unwrap_or_else(|| 128 + status.signal().unwrap_or(0)), )}Sau khi tiến trình thoát, output vẫn được gom cho tới khi cả hai pipe đóng, hoặc 100 ms không có output: một tiến trình con giữ pipe mở không làm lệnh treo mãi (exec.rs:382–385).
Cửa sổ output: không chuyển những gì sẽ bị bỏ#
Bài toán: yes | head -c 2G trên máy từ xa sinh hai gigabyte output, trong khi tool bash của Durable chỉ giữ phần đuôi (mặc định 2.000 dòng hoặc 50 KiB cuối, durable/harness/tool.ts:294–296; bash.ts:151) và commit tiến độ mỗi 100 ms. Gửi cả hai gigabyte qua SSH để rồi vứt gần hết là lãng phí, và nó làm nghẽn mọi thứ khác trên link. Giao diện của Durable có sẵn chỗ cho điều này:
spill?: ShellSpillOptions; /** * The caller keeps only this tail of the output, so the environment may omit output outside it and report the omission * as `info.skipped`. Without it, every chunk is delivered. */ window?: ShellOutputWindow;}/** * The tail of the combined output a caller keeps, and how often it samples it. An environment that transfers output * over a slow link uses it to omit what the caller would drop anyway and to send no faster than the caller commits. */export interface ShellOutputWindow { /** UTF-8 bytes of decoded text kept at the end of the output. */ maxBytes: number; /** Lines kept at the end of the output. */ maxLines: number; /** Minimum pause between the caller's samples of the output. */ minIntervalMs: number; /** Each sample also pauses the caller in proportion to its size at this rate. */ bytesPerSecond: number;}ShellOutputInfo.skipped (bytes, newlines, endsWithNewline), và chunk đi sau một lần bỏ luôn chứa nhiều hơn cửa sổ ít nhất một byte hoặc một dòng, nên phần bị bỏ chắc chắn không thể nằm trong phần đuôi được giữ (durable/env/index.ts:347–357).Durable dựng cửa sổ từ giới hạn output của tool (chỉ khi tool giữ phần đuôi) và nhịp commit tiến độ của nó (durable/harness/tool.ts:333–341), tool bash chuyển nó vào exec (durable/tools/bash.ts:104–107), và RemoteExecutionEnv gửi nguyên nó tới daemon. Ở đó, window.rs giữ output chưa giao, gộp nó lại, và thay phần chắc chắn nằm ngoài cửa sổ bằng số đếm:
//! Durable's output window (`ShellExecOptions.window`, `ShellOutputInfo.skipped`): undelivered command output is held//! and coalesced; text followed by more than the window (by a byte or a line) can never be in the caller's tail and is//! replaced by counts. Delivery follows the caller's commit pace, and waits while an earlier frame is still unsent. /// Whether the text after a cut still proves the cut text is outside the window. fn proves(&self, bytes: usize, newlines: usize) -> bool { bytes > self.window.max_bytes || newlines > self.window.max_lines } /// Replace the longest prefix whose remainder still proves exclusion by counts. fn trim(&mut self) { let mut removed = String::new(); /// Deliver everything held: one event carrying the skip and all text after it, or one event per segment. pub fn take(&mut self) -> Vec<Event> { let segments = std::mem::take(&mut self.segments); let delivered = self.bytes; self.bytes = 0; self.newlines = 0; // The caller writes at most the window per sample, and paces itself by what it wrote. let pace = Duration::from_secs_f64( delivered.min(self.window.max_bytes) as f64 / self.window.bytes_per_second, ); self.next_send = Instant::now() + self.window.min_interval.max(pace);skipped cùng toàn bộ text sau nó, rồi hẹn lần giao kế sau max(minIntervalMs, phần đã giao / bytesPerSecond). Vòng lặp của lệnh chỉ giao khi tới hạn và frame trước đã được ghi ra stdout, nên tối đa một frame output của mỗi lệnh đang chờ (exec.rs:386–392).Phía client, event.skipped thành info.skipped của onOutput, tool bash gọi api.output(text, info.skipped), và OutputBuffer.push(chunk, skipped) của Chord cộng số đếm vào tổng và bỏ phần đã lưu trước đó, vì không gì đứng trước chỗ bị bỏ còn có thể nằm trong cửa sổ (chord/src/output.ts:154–182). Kết quả: phần đuôi giữ lại và số byte, số dòng bị bỏ y hệt khi chạy tại chỗ, nhưng chỉ phần đuôi đi qua mạng. Đây là ý của dòng "honors output windows so dropped command output is not transferred" trong changelog bản đầu (CHANGELOG.md:23).
Watch, listFiles và terminal#
Watch chạy cạnh file#
Daemon chạy một bản Rust của NodeFileWatcher của Durable ngay cạnh file, nên một lần quét không tốn vòng request nào: sự kiện gốc (inotify, FSEvents) chỉ kích một lần quét lại có debounce, và thay đổi là hiệu giữa hai ảnh chụp cộng các đường dẫn của sự kiện (daemon/src/watch.rs:1–3). Windows và file system mạng hay FUSE thì poll; hết watch gốc thì chuyển sang poll và báo overflow (semantics.md:62–68). Request watch kéo dài tới khi bị huỷ: event ready khi đã phủ xong, rồi change với paths hoặc overflow. Phía client, RemoteWatcher lo phần mất kết nối:
this.#request = request.then( () => {}, (error: unknown) => { if (!ready) { reject(error); return; } if (this.#closed) return; if (isConnectionLost(error)) { void this.#reconnect(); return; } this.#deliver({ error: toFileError(error) }); this.#closed = true; }, );async #reconnect(): Promise<void> { for (let delay = RECONNECT_FIRST_MS; !this.#closed; delay = Math.min(delay * 2, RECONNECT_MAX_MS)) { await new Promise<void>((resolve) => { const timer = setTimeout(resolve, delay); timer.unref(); this.#wake = () => { clearTimeout(timer); resolve(); }; }); this.#wake = undefined; if (this.#closed) return; try { await this.#start(); // Changes while disconnected were not seen. this.#deliver({ overflow: true }); return; } catch (error) { if (isConnectionLost(error)) continue; this.#deliver({ error: toFileError(error) }); this.#closed = true; return; } }}overflow: người dùng watcher phải tự quét lại. Lỗi khác mất kết nối thì kết thúc watcher.listFiles và LocalExecutionEnv#
listFiles là phương thức tuỳ chọn của ExecutionEnv: liệt kê cả cây dưới một thư mục như fd --hidden --exclude .git mà không theo symlink, bỏ những gì git ignore bên trong work tree, có giới hạn maxEntries (mặc định 50.000), maxBytes (4 MiB) và timeoutMs (10 s) với cờ truncated (protocol.md:101–112). Daemon dùng crate ignore của fd và ripgrep (daemon/src/list.rs:1–2), và trả kết quả dạng bytes chứ không phải JSON:
const aborted = abortResult<FileListing>(context.abortSignal, path);if (aborted) return aborted;try { if (!(await connection.info()).features?.includes("listFiles")) { return err(new FileError("not_supported", "The pi-env daemon does not list files; update it", path)); } const { json, payload } = await connection.request( "listFiles", // Fields left undefined are not sent; the daemon applies the defaults. { path, hidden: options?.hidden, gitignore: options?.gitignore, exclude: options?.exclude, maxEntries: options?.maxEntries, maxBytes: options?.maxBytes, timeoutMs: options?.timeoutMs, }, context.abortSignal ? { signal: context.abortSignal } : {}, ); const afterAbort = abortResult<FileListing>(context.abortSignal, path); if (afterAbort) return afterAbort; // Records of a kind byte, the UTF-8 path, and a NUL. const entries: FileListEntry[] = []; const bytes = Buffer.from(payload.buffer, payload.byteOffset, payload.length); for (let start = 0; start < bytes.length; ) { const end = bytes.indexOf(0, start); if (end === -1) break; const kind = KINDS[bytes[start]!]; if (kind !== undefined) entries.push({ path: bytes.toString("utf8", start + 1, end), kind }); start = end + 1; } return ok({ entries, truncated: json.truncated === true });} catch (error) { return err(toFileError(error, path));}listFiles không có tên đó trong hello.features, nên client trả not_supported mà không gửi request, và người gọi tự duyệt cây (durable/README.md:456–460).Vì NodeExecutionEnv không có listFiles, pi-env thêm LocalExecutionEnv: NodeExecutionEnv cộng listFiles qua một daemon trên chính máy này (new Connection({ command: [localDaemon()] })), để môi trường cục bộ và từ xa liệt kê bằng cùng một code (list-files.ts:79–102). Connection thuộc về người gọi, và người gọi đóng nó.
Terminal#
openTerminal(connection, options) chạy một chương trình (mặc định shell đăng nhập của user) trong một pseudo-terminal của daemon, dẫn đầu một session mới với terminal làm terminal điều khiển; output là bytes thô của terminal, không giải mã như exec (protocol.md:134–151). RemoteTerminal có write() (chia input thành các khúc 1 MiB vì frame tối đa 16 MiB), resize() (chương trình nhận SIGWINCH), close() (giết cả session), và foreground() trả về process group đang ở tiền cảnh khi nó không phải chính chương trình, như một trình soạn thảo mà shell đang chạy (terminal.ts:26–90). ptyWrite, ptyResize, ptyForeground đánh địa chỉ request pty đang chạy bằng id của nó, và được trả lời theo thứ tự đến (đã thấy ở mục daemon). Terminal chưa hỗ trợ Windows (ENOTSUP).
Lần theo một lệnh bash trên máy từ xa#
Kịch bản: một Harness của Pi Durable chạy trên laptop, môi trường là new RemoteExecutionEnv({ connection: sshConnection(target).connection, id: "pi-env:gpu", cwd: "/home/me/project" }) như ví dụ của README (README.md:25–28), host key của gpu-box đã được tin, và đây là lệnh đầu tiên của tiến trình. Mô hình gọi tool bash với { "command": "pytest -q" } (lệnh là giả định). Các bước theo code, không phải in ra từ một lần chạy.
- 1. Tool bashTask
pi.toolcủa Durable chạyexecutecủa toolbash, vốn gọienv.exec(command, { cwd, env, inheritEnv, timeout, onOutput, spill, window }, context);windowlà đuôi 2.000 dòng / 50 KiB và nhịp 100 ms (bash.ts:95–110). - 2. Kiểm ở client
RemoteExecutionEnv.execkiểm timeout, giảicwdtheo luật của máy từ xa, dựng biến môi trường (shellEnvrồienvkhi kế thừa), rồi gọiconnection.request("exec", …)vớionStart(đểcleanup()biết lệnh này) vàonEvent. - 3. Khởi động lườiChưa có session, nên
#start()gọi hàm lệnh củasshConnectionFor:detectPlatform()(một vòng SSH),deployDaemon()kiểm hash ở xa và nếu thiếu thì tải lên, rồi trả về["ssh", …sshArguments(target), "'/home/me/.pi/mobile/tools/pi-env-<32 hex>'"].Connectionspawn nó vớiserve --token <hex>(connection.ts:268–283). - 4. Đồng bộ và hello
.bashrcở xa in một dòng chào; client chuyển nó vàoonLogkhi thấyPI-ENV <token>. Request đầu làhello { protocol: 1 }; thông tin máy (home, tmpdir, hệ điều hành) được nhớ cho mọi lần giải đường dẫn sau. - 5. Request exec
#sendcấp id, đăng ký pending, gắn listener huỷ (abort của Context thành một framecancel), rồi ghiframe(REQUEST, id, { …json, op: "exec" })vào stdin củassh(connection.ts:240–262). - 6. Daemon chạy lệnh
serve_framesđăng kýControl, choexecmột luồng riêng;exec::runchọn shell (/bin/bash -c "pytest -q"), kiểmcwdtồn tại, spawn vớisetsid, ghi pid vào tập process group, và mở hai luồng đọc pipe (exec.rs:242–320). - 7. Output qua cửa sổBytes từ pipe được giải mã UTF-8 theo từng luồng, đẩy vào
Pending; phần chắc chắn nằm ngoài đuôi được thay bằng số đếm. Tới hạn và frame trước đã ra stdout thì một event{ kind: "output", stream, skipped? }mang text làm payload được xếp vào hàng bulk. - 8. Về tới clientNgười ghi stdout của daemon gửi frame control trước, bulk sau;
#onDatatách frame,#dispatchgọionEvent,RemoteExecutionEnvgọionOutput(text, context, { stream, skipped }), toolbashgọiapi.output(text, skipped), vàOutputBuffercủa Chord giữ đuôi;Progressquyết định khi nào commit phần đã giữ vào storage (chương task). - 9. Kết quảTiến trình thoát; daemon gom output tới khi hai pipe đóng hoặc 100 ms im lặng, giao phần còn giữ, rồi gửi
result { exitCode }qua hàng bulk, nên nó tới sau output của chính lệnh đó.exectrảok({ exitCode }); tool dựng kết quả từ output đã giữ (bash.ts:126–135).
Ví dụ minh hoạ: các frame trên stdin/stdout của ssh (id và kích thước là giả định; ping bị lược)
stdout "Welcome to gpu-box\n" → onLog, bỏ qua
stdout "PI-ENV 3f9c…e1\n" → synced
stdin [len][1][id 1][jsonLen]{"protocol":1,"op":"hello"}
stdout [len][2][id 1][jsonLen]{"protocol":1,"version":"1.4.0","os":"linux","arch":"x86_64",
"home":"/home/me","tmpdir":"/tmp",…,"features":["listFiles"]}
stdin [len][1][id 2][jsonLen]{"command":"pytest -q","cwd":"/home/me/project","env":{},
"inheritEnv":true,"spill":{…},"window":{"maxBytes":51200,
"maxLines":2000,"minIntervalMs":100,"bytesPerSecond":102400},"op":"exec"}
stdout [len][4][id 2][jsonLen]{"kind":"output","stream":"stdout"} payload: "....F.\n"
stdout [len][4][id 2][jsonLen]{"kind":"output","stream":"stdout"} payload: "1 failed, 5 passed\n"
stdout [len][2][id 2][jsonLen]{"exitCode":1} → kết quả lỗi exit_code của tool
frame(): u32 độ dài, u8 loại, u32 id, u32 độ dài JSON, JSON, payload. Thứ tự khoá JSON là thứ tự JSON.stringify của { ...json, op }.Nếu Wi-Fi rớt giữa chừng: sau 30 giây không nhận được byte nào, daemon giết process group của pytest và thoát; client, cũng sau 30 giây im lặng, huỷ session và từ chối request exec với { code: "unknown", lost: true }. exec trả err(ExecutionError("unknown", …)), tool bash ném lại lỗi đó, và Durable biến nó thành một kết quả lỗi vẫn mang output đã giữ (bash.ts:138–143). Lệnh tiếp theo khởi động một daemon mới (kiểm lại hash, không tải lại nếu đã có). Nếu chính tiến trình Durable chết thì lại là chuyện khác: pipe stdin của ssh đóng theo nó, và giao thức hứa daemon giết mọi thứ khi stdin kết thúc; nếu EOF không tới được máy từ xa thì giới hạn 30 giây im lặng vẫn dọn dẹp (protocol.md:64–65; đường đi của EOF qua ssh là suy luận, không có test trong repo). Khi mở lại, Durable xử lý lời gọi tool bị cắt ngang theo chính sách replay của tool (chương task).
pi-telemetry: hợp đồng span, sự kiện, metric#
Bài toán: pi-ai, pi-agent-core và Pi Durable đều muốn mô tả việc chúng làm (một request tới mô hình mất bao lâu, một phase của task kết thúc ra sao), nhưng người dùng của chúng dùng backend quan sát khác nhau: OpenTelemetry, Sentry, log thuần, hay không gì cả. Gắn một SDK cụ thể vào thư viện là bắt mọi người theo nó. pi-telemetry là "vendor-neutral telemetry contracts and typed schema utilities": một hợp đồng span dựa trên callback, một context no-op dùng chung, propagation qua tiến trình, metric, một adapter tham chiếu trong bộ nhớ, schema có kiểu, và không có exporter, không có span "hiện tại" toàn cục, không phụ thuộc backend nào (README.md:3–15). Package được tách ra ở commit 6b461b75b "feat: extract telemetry package" (2026-08-05); bản 1.4.0 thêm propagation, link, metric, TelemetryStore và renderTrace (CHANGELOG.md:3–20).
Từ vựng theo bảng của README (README.md:48–55): span là bản ghi có thời gian của một thao tác; span lồng nhau thành cây; attribute là một sự kiện có tên gắn vào span (cache.hit: true); event là một điểm trong span, không có độ dài; status là ok hoặc error; context là nơi một span mới sẽ được gắn vào cây. Span là dữ liệu chẩn đoán, không phải state nghiệp vụ.
export type AttributeValue = string | number | boolean | readonly string[] | readonly number[] | readonly boolean[];export interface SpanAttributes { [name: string]: AttributeValue | undefined;}/** Opaque, JSON-serializable adapter-owned propagation data. */export type TelemetryCarrier = Record<string, string>;/** Optional transport capability implemented by the application's telemetry adapter. */export interface TelemetryPropagation { inject(context: TelemetryContext): TelemetryCarrier | undefined; /** Absent or unknown carriers use the root; recognized identifiers are opaque strings. */ extract(carrier: TelemetryCarrier | undefined): TelemetryContext;}export interface SpanLink { context: TelemetryContext; attributes?: SpanAttributes;}export interface SpanOptions { name: string; attributes?: SpanAttributes; links?: readonly SpanLink[];}export type SpanStatus = { status: "ok" } | { status: "error"; error?: { name: string; message: string } };/** Adapter contract for trusted typed inputs; malformed input behavior is unspecified. */export interface TelemetryContext { /** Adapter capabilities remain available on child spans and extracted parents. */ readonly metrics?: TelemetryMetrics; readonly propagation?: TelemetryPropagation; startSpan<T>(options: SpanOptions, callback: (span: TelemetrySpan) => T | Promise<T>): Promise<T>;}export interface TelemetrySpan extends TelemetryContext { addLink(link: SpanLink): void; addEvent(name: string, attributes?: SpanAttributes): void; setAttributes(attributes: SpanAttributes): void; setStatus(status: SpanStatus): void;}TelemetrySpan cũng là một TelemetryContext: span con được mở từ chính span cha, nên quan hệ cha con đi theo tham số chứ không theo một ngữ cảnh ngầm. Giá trị attribute chỉ là số, chuỗi, boolean hoặc mảng của chúng.Không có end(). startSpan(options, callback) giữ span mở cho tới khi giá trị hay promise của callback xong; thành công là ok, ném hay reject là error trừ khi đã đặt status tường minh (README.md:108–121). Hợp đồng của một adapter được liệt kê thành từng điều: gọi callback đồng bộ đúng một lần, giữ nguyên giá trị trả về và giá trị reject, gộp setAttributes (giá trị sau thay giá trị trước, undefined bị bỏ qua), các phương thức ghi là đồng bộ, và bỏ qua mọi lời gọi sau khi span đã xong (README.md:127–139). Đầu vào có kiểu được tin; adapter không cần kiểm hay nuốt lỗi của chính nó (CHANGELOG 1.4.0, CHANGELOG.md:7).
No-op và adapter trong bộ nhớ#
async function startNoopSpan<T>(_options: SpanOptions, callback: (span: TelemetrySpan) => T | Promise<T>): Promise<T> { return callback(noopTelemetrySpan);}const noopCounter: TelemetryCounter = Object.freeze({ add: () => {} });const noopUpDownCounter: TelemetryUpDownCounter = Object.freeze({ add: () => {} });const noopHistogram: TelemetryHistogram = Object.freeze({ record: () => {} });/** Shared instruments that never inspect or retain metadata, measurements, or contexts. */export const NOOP_TELEMETRY_METRICS: TelemetryMetrics = Object.freeze({ counter: () => noopCounter, upDownCounter: () => noopUpDownCounter, histogram: () => noopHistogram,});/** Shared propagation capability that neither inspects nor retains its inputs. */export const NOOP_TELEMETRY_PROPAGATION: TelemetryPropagation = Object.freeze({ inject: () => undefined, extract: () => NOOP_TELEMETRY_CONTEXT,});const noopTelemetrySpan: TelemetrySpan = { metrics: NOOP_TELEMETRY_METRICS, propagation: NOOP_TELEMETRY_PROPAGATION, startSpan: startNoopSpan, addLink: () => {}, addEvent: () => {}, setAttributes: () => {}, setStatus: () => {},};Object.freeze(noopTelemetrySpan);/** Shared telemetry context used when an application does not provide one. */export const NOOP_TELEMETRY_CONTEXT: TelemetrySpan = noopTelemetrySpan;async là có chủ đích: một lần ném đồng bộ trong callback thành một promise bị reject với đúng giá trị đó. Đây là span mà BACKGROUND_CONTEXT của Chord trả về khi không có span nào (chương Chord).InMemoryTelemetryContext là hiện thực tham chiếu, dùng cho test, chẩn đoán cục bộ, hoặc ứng dụng muốn ghi lại trong tiến trình mà không cần exporter. Nó cài đặt cùng lúc TelemetryContext, TelemetryPropagation, TelemetryMetrics và TelemetryStore, ghi span với id số tăng dần, và trả về ảnh chụp đã tách khỏi bộ nhớ của nó (memory.ts:282–299). Phần quyết định status và kết thúc:
async function startInMemorySpan<T>( state: InMemoryTelemetryState, parent: InMemoryParent | undefined, options: SpanOptions, callback: (span: TelemetrySpan) => T | Promise<T>,): Promise<T> { if (parent?.span?.settled) return startInertSpan(state, options, callback); const recordedSpan = createSpan(state, parent, options); state.spans.push(recordedSpan); contextCarriers.set(span, recordedSpan.context); try { return await callback(span); } catch (error) { if (!recordedSpan.explicitStatus) recordedSpan.status = error instanceof Error ? { status: "error", error: { name: error.name, message: error.message } } : { status: "error" }; throw error; } finally { recordedSpan.settled = true; recordedSpan.endedMs = recordedSpan.startedMs + performance.now() - recordedSpan.clock; recordedSpan.endSequence = state.nextEndSequence++; }}Propagation, link và metric#
Để một trace đi qua ranh giới tiến trình, adapter có thể cài đặt TelemetryPropagation: inject(context) trả về một carrier, một map chuỗi sang chuỗi an toàn với JSON mà định dạng do adapter quyết (W3C traceparent cũng vừa), và extract(carrier) trả về một context cha ở phía nhận; carrier lạ hay vắng thì rơi về gốc. Transport chỉ chở carrier, không bao giờ chở context hay span; và metadata của trace là chẩn đoán, không bao giờ là phân quyền (README.md:143–167). Adapter trong bộ nhớ dùng ba khoá pi-memory-scope, pi-memory-span, pi-memory-trace, nên carrier của nó sống qua JSON và qua các tiến trình độc lập (memory.ts:307–324). Link nối hai đoạn công việc mà không đổi cha, ví dụ một activation với mọi lời gọi đã kích nó (README.md:169–181).
Metric (counter, up-down counter, histogram) là một năng lực tuỳ chọn của cùng adapter, lộ ra qua context.metrics, để một lời gọi mô hình lồng sâu vẫn ghi được metric mà không cần cấu hình riêng (metrics.ts:10–41). Histogram nhận thêm một context để gắn phép đo với span (exemplar). Quy tắc: chỉ dùng thuộc tính có ít giá trị (hướng, phương thức), không dùng id request, id người dùng, prompt hay đường dẫn tuỳ ý (README.md:205–209).
Đọc lại: TelemetryStore và renderTrace#
TelemetryStore tách truy vấn khỏi ghi nhận: findTraces(query) (trace mới nhất trước, lọc theo tên span sớm nhất, khoảng thời gian, errors, thuộc tính, phân trang bằng cursor), getTrace(traceId), findMetrics(query) (index.ts:90–95). Hai hiện thực: adapter trong bộ nhớ và telemetry-sqlite của Chord (chương Chord). Store chỉ trả span đã xong; trace id là span id của gốc, nên span từ nhiều store được gộp theo trace id, và quan hệ cha con, không phải việc thời gian nằm lồng nhau, định nghĩa cây (README.md:289–297). renderTrace(spans) in một rừng span theo thời gian, coi cha vắng mặt là gốc, kèm thuộc tính, thời lượng, lỗi, offset của event và link (render.ts:9–49).
Bộ conformance#
@earendil-works/pi-telemetry/testing xuất createTelemetryAdapterConformance(factory): 14 ca kiểm độc lập với test runner, mỗi ca một fixture mới; fixture cung cấp context và đổi span đã xong của backend thành RecordedTelemetrySpan chuẩn hoá (testing/types.ts:4–21). Bộ ca kiểm năng lực được mang qua mọi context, việc nhận callback đồng bộ đúng một lần, danh tính kết quả và lỗi, status tự động và tường minh, gộp thuộc tính, thứ tự event, lời gọi trơ sau khi xong, quan hệ cha con lồng và đồng thời, link; với fixture có propagation và metrics thì kiểm thêm vòng JSON, nhiều chặng adapter, và thứ tự phép đo (README.md:341). Đây là cách một adapter OpenTelemetry hay Sentry do người khác viết chứng minh nó tuân hợp đồng.
Schema telemetry có kiểu#
API tầng thấp cố ý mở: tên span là chuỗi bất kỳ, thuộc tính là một túi bất kỳ, để adapter dùng chung được. Package miền (pi-ai, Durable) muốn ngược lại: một từ vựng đóng, để mọi chỗ phát ra đúng tên và đúng thuộc tính. Schema là dữ liệu JSON tuần tự hoá được, mô tả từng span (mô tả, quy tắc cha, thuộc tính lúc bắt đầu kèm required, thuộc tính lúc hoàn tất, event, quy tắc status) và từng metric (index.ts:128–177). defineTelemetrySchema() chỉ là hàm định danh có kiểu: nó trả lại chính đối số và không kiểm gì lúc chạy.
function bindTypedSpanStarter<Schemas extends TelemetrySchemaTuple>( telemetryContext: TelemetryContext,): TypedSpanStarter<Schemas> { const startSpan = ( name: SpanNameInSchemas<Schemas>, attributes: SpanAttributes, callback: ( span: SpanInSchema<Schemas[number], SpanNameInSchemas<Schemas>>, startChildSpan: TypedSpanStarter<Schemas>, ) => unknown, ): Promise<unknown> => telemetryContext.startSpan({ name, attributes }, (span) => callback( span as SpanInSchema<Schemas[number], SpanNameInSchemas<Schemas>>, bindTypedSpanStarter<Schemas>(span), ), ); return startSpan as TypedSpanStarter<Schemas>;}/** * Bind an explicit parent context to the combined span vocabulary of one or more schemas. * Schema values are used only for type inference; no runtime schema validation is performed. */export function createTypedSpanStarter<const Schemas extends TelemetrySchemaTuple>( telemetryContext: TelemetryContext, _schemas: Schemas & UniqueTelemetrySchemas<Schemas>,): TypedSpanStarter<Schemas> { return bindTypedSpanStarter<Schemas>(telemetryContext);}UniqueTelemetrySchemas).startAttributes và endAttributes chỉ nói khi nào thuộc tính thường đã biết; cả hai thành thuộc tính thường của cùng một span, và "end" nghĩa là làm giàu lúc hoàn tất, luôn tuỳ chọn, vì một đường lỗi sớm hay huỷ có thể không bao giờ có giá trị đó (README.md:424–453). createTypedMetrics(metrics, schemas) làm việc tương tự cho metric, khác ở chỗ nó đọc và giữ metadata của instrument (mô tả, đơn vị) lúc chạy để chuyển cho adapter; nó vẫn không kiểm thuộc tính lúc chạy (metrics.ts:128–145).
Pi Durable và telemetry#
Host đưa một adapter vào HarnessOptions.telemetry; vắng thì là no-op (durable/harness/types.ts:609–611). Durable dùng nó cho hai span ngắn và vài metric, mô tả bằng DURABLE_TELEMETRY_SCHEMA. Lời hứa quan trọng nhất nằm ở README: thuộc tính chỉ chứa ID và metadata điều khiển, không bao giờ chứa message, đối số hay kết quả; ID trỏ vào transcript (durable/README.md:82).
/** Durable's content-free span, event, and metric vocabulary. IDs address durable records, never content. */export const DURABLE_TELEMETRY_SCHEMA = defineTelemetrySchema({ version: 1, spans: { "pi.durable.phase": { description: "One task phase invocation, including its decision commit, never a wait or restart.", parents: { kind: "any" }, startAttributes: { "task.id": { type: "number", required: true, description: "Task ID.", cardinality: "high" }, "task.name": { type: "string", required: true, description: "Registered task name." }, phase: { type: "string", required: true, description: "Invoked checkpoint phase." }, kind: { type: "string", required: true, values: ["run", "abort"], description: "Invocation kind." }, attempt: { type: "number", required: false, description: "Checkpoint attempt, when present." }, resumed: { type: "boolean", required: true, description: "First invocation of a task loaded in this process.", }, }, endAttributes: { outcome: { type: "string", description: "next=<phase>, waiting, sleeping, done, faulted, or aborted." }, "tool.name": { type: "string", description: "Called tool name." }, "tool.call_id": { type: "string", description: "Transcript tool call ID.", cardinality: "high" }, }, events: { "pi.durable.entry": entry, "pi.durable.submission": submission, "pi.durable.report": report, "pi.durable.checkpoint": { description: "The running checkpoint's phase changed while the invocation continued.", attributes: { phase: { type: "string", required: true, description: "Committed checkpoint phase." } }, }, "pi.durable.replay": { description: "A nested tool call was answered from its stored result.", attributes: { "tool.name": { type: "string", required: true, description: "Called tool name." } }, }, }, status: { default: "ok", errorWhen: "Faulted, failed, and orphaned outcomes use task_faulted, task_failed, and task_orphaned with a diagnostic capped at 500 characters. Aborted outcomes keep status ok.", }, },pi.durable.submit, bao việc nhận một submission công khai và commit của nó; input mở trace gốc liên kết với người gọi, write là con của người gọi (telemetry.ts:53–76). Ba metric: pi.durable.reports (counter theo loại report), pi.durable.phase.duration (histogram, ms), pi.durable.tasks.active (up-down counter) (telemetry.ts:117–138).Span phase được mở trong scheduler. Đoạn đầu cho thấy hai ý thiết kế cùng lúc:
/** A phase owns its decision commit, not the next handler, a durable wait, or recovery. */#phase<T>(invocation: Invocation, task: ErasedRunningTask, run: () => Promise<T>): Promise<T> { const parent = task.trace === undefined ? this.#telemetry : (this.#telemetry.propagation?.extract(task.trace) ?? this.#telemetry); const checkpoint = task.state.checkpoint; const attempt = checkpoint.attempt; const resumed = this.#resumed.delete(task.id); return createTypedSpanStarter(parent, [DURABLE_TELEMETRY_SCHEMA])( "pi.durable.phase", { "task.id": task.id, "task.name": task.kind, phase: checkpoint.phase, kind: invocation.mode, resumed, ...(attempt === undefined ? {} : { attempt }), }, async (span) => { if (span.propagation !== undefined && invocation.links !== undefined) { for (const carrier of invocation.links) span.addLink({ context: span.propagation.extract(carrier) }); } const previous = invocation.context; invocation.context = withTelemetry(span, previous); const started = performance.now(); try {outcome, đặt status lỗi với tên task_faulted, task_failed, task_orphaned và thông báo cắt còn 500 ký tự, rồi ghi thời lượng vào histogram kèm span làm exemplar (scheduler.ts:1766–1802).Ý thứ nhất: cha của span không phải context đang chạy mà là task.trace, một carrier được inject lúc task được tạo và lưu trong bản ghi task (durable/types.ts:646–650; tạo ở transaction.ts:435–438). Một task chạy qua nhiều lần khởi động lại, ở nhiều tiến trình khác nhau; nhờ carrier nằm trong storage, phase chạy sau một lần crash vẫn gắn vào trace của nơi đã tạo ra task. README của pi-telemetry cho phép đúng điều này: context và span không bao giờ được lưu, nhưng một carrier đã inject có thể được lưu làm dữ liệu tương quan chẩn đoán tuỳ chọn (README.md:515). Submission cũng lưu carrier của span admission (submissions.ts:219). Ý thứ hai: withTelemetry(span, previous) của Chord đặt span vào Context của invocation, nên mọi thứ phase gọi xuống (pi-ai, tool) đều nhận span làm cha qua tham số, không qua ngữ cảnh ngầm.
Nguyên tắc "không chứa nội dung" được giữ cả ở đường lỗi: khi admission thất bại, span chỉ nhận status error mà không có thông báo, vì "Error text may contain caller content" (submissions.ts:93–97). Còn lưu trace ở đâu là việc của host: một adapter OpenTelemetry, hoặc createSqliteTelemetry(db) của Chord trên chính database SQLite của ứng dụng. Theo README, pi-ai nhận một TelemetrySpan cha qua tuỳ chọn telemetry của request và xuất AI_TELEMETRY_SCHEMA; pi-agent-core giữ instrumentation và schema riêng của nó (README.md:497–511).
pi-evals: đo hành vi của coding agent#
packages/evals là "behavioral evals for Pi's coding agent, built with vitest-evals" (README.md:3). Nó là package riêng tư, không phát hành (package.json:4), xuất hiện từ commit eafe11fb9 "feat(coding-agent): add vitest eval harness (#7085)" (2026-07-25). Đối tượng được đo là coding agent (AgentSession, xem chương AgentSession), không phải Pi Durable, và mỗi lần chạy gọi một mô hình thật: cần PI_PROVIDER và PI_MODEL cùng credential trong ~/.pi/agent/auth.json (README.md:22–28; docker.ts:67–89).
Hai loại eval#
- Host eval (
*.eval.ts), chạy bằng Vitest ngay trên máy (npm run eval:host):smoke.eval.tshỏi thủ đô của Pháp vớinoTools: "all"và kiểm câu trả lời, lỗi, provider, số token (smoke.eval.ts:5–18);documentation-audit.eval.tscho agent đối chiếu từng trang trongpackages/coding-agent/docsvới code, và nộp kết luậnmatch/mismatch/inconclusivequa một tool tuỳ chỉnh có schema chặt (documentation-audit.eval.ts:9–65). - Eval độ nâng của tài liệu (documentation lift,
*.docs.eval.ts): mỗi ca chạy trong hai container cô lập,without_docsvàwith_docs, và báo cáo chênh lệch tỉ lệ đạt (README.md:5–10). Năm bộ ở v1.4.0: custom provider, extension, model, provider OpenAI, TUI.
Một eval độ nâng trông như thế này: cấu hình một mô hình mới cho Pi đang chạy, nạp lại, rồi so cấu hình thật với kết quả mong đợi bằng một judge khớp chặt:
const harness = createPiDocumentationEvalHarness({ output: ({ session }) => inspectAddedModel(session.modelRuntime, PROVIDER_ID, MODEL_ID),});const judge = StructuredOutputJudge({ expected, match: "strict", allowExtras: false });describeEval("Add model to existing provider", { harness, judges: [judge], judgeThreshold: null }, (it) => { it("adds the model without replacing existing models", async ({ run }) => { await run([ { type: "prompt", content: `Configure this running Pi installation with a new \`${PROVIDER_ID}/${MODEL_ID}\` model. Do not create project-local configuration. Show it as “${MODEL_NAME}”. It accepts text, supports reasoning, has a 32,768-token context window and a 4,096-token maximum output, and has no usage cost.`, }, { type: "reload" }, ]); });});judgeThreshold: null nghĩa là điểm thấp là dữ liệu, không phải lỗi hạ tầng; assertion của Vitest chỉ dành cho bất biến của bộ test bị vỡ (README.md:127–129).Harness: một coding agent cô lập cho mỗi lần chạy#
createPiCodingAgentHarness() là bộ chuyển đổi cho vitest-evals. Mỗi lần chạy, runPiCodingAgent() tạo một thư mục gốc tạm với workspace, home và thư mục agent riêng, đổi HOME và PI_CODING_AGENT_DIR sang đó, nạp đúng credential của provider đã chọn vào một InMemoryCredentialStore, gieo file fixture vào workspace (từ chối đường dẫn thoát ra ngoài), dựng session với thinkingLevel: "off", và từ chối nếu có extension nào ngoài những extension inline mà chính harness thêm (harness.ts:272–365). Sau khi chạy, nó gom transcript thành sự kiện, usage (token, lời gọi tool, chi phí ước tính nếu mô hình có giá), hash SHA-256 của system prompt, và lưu file session.jsonl làm artifact (harness.ts:390–431). Biến thể tài liệu được dựng ở đây:
/** Documentation evals intentionally exclude shell and unrestricted network tools. */export const DOCUMENTATION_EVAL_TOOLS = ["read", "write", "edit", "grep", "find", "ls"] as const;export function resolveDocumentationVariant( value: string | undefined = process.env.PI_EVAL_VARIANT,): DocumentationVariant { if (value === "without_docs" || value === "with_docs") return value; throw new TypeError('PI_EVAL_VARIANT must be "without_docs" or "with_docs".');}export function excludePiDocumentation(defaultPrompt: string): string { const documentationStartMarker = "\n<docs>\n"; const documentationEndMarker = "\n</docs>"; const documentationStart = defaultPrompt.indexOf(documentationStartMarker); if (documentationStart === -1) throw new Error("Default Pi system prompt has no Pi documentation section."); const documentationEnd = defaultPrompt.indexOf(documentationEndMarker, documentationStart); if (documentationEnd === -1) throw new Error("Default Pi system prompt has no complete Pi documentation section."); const cwdStart = defaultPrompt.lastIndexOf("\n<cwd>\n"); if (cwdStart < documentationEnd) throw new Error("Default Pi system prompt has no working-directory section."); return ( defaultPrompt.slice(0, documentationStart) + defaultPrompt.slice(documentationEnd + documentationEndMarker.length) );}export function createPiDocumentationEvalHarness<TOutput extends JsonValue>( options: DocumentationHarnessOptions | DocumentationHarnessWithOutput<TOutput> = {},) { if (process.env.PI_EVAL_CONTAINER !== "1" || !resolveSandboxIdentity()) { throw new Error("Documentation evals must run in the isolated container sandbox."); } const variant = resolveDocumentationVariant(); return createPiCodingAgentHarness({ ...options, name: variant, tools: options.tools ?? [...DOCUMENTATION_EVAL_TOOLS], ...(variant === "without_docs" ? { transformSystemPrompt: excludePiDocumentation } : {}), expectedPiDocumentation: variant === "with_docs", });}without_docs còn xoá hẳn README.md, CHANGELOG.md, docs/ và examples/ của coding agent (README.md:82–90). verifySystemPrompt kiểm prompt thật của mỗi lần chạy có đúng biến thể không (harness.ts:257–270).Container: mô hình không đọc được bài chấm#
Một eval có thể bị "giải" bằng cách đọc judge hay fixture. Thiết kế chặn điều đó bằng quyền file. Dockerfile build workspace một lần, dựng hai target without_docs và with_docs từ cùng một runtime đã cài, và đặt mọi mã nguồn của evaluator ở chế độ chỉ root đọc được (docker/Dockerfile:12–44). Entrypoint kiểm nội dung image theo danh sách cho phép, kiểm tài liệu của package nội bộ không lộ cho mô hình, chạy thử một tiến trình với UID của sandbox để chắc nó không đọc được mã evaluator, và kiểm coding agent được nạp từ dist/ (docker/entrypoint.ts:27–105). Bên trong lần chạy, harness tự hạ quyền vĩnh viễn trước khi gửi prompt:
async function enterToolSandbox(root: string, identity: SandboxIdentity | undefined): Promise<void> { if (!identity) return; if ( typeof process.getuid !== "function" || typeof process.geteuid !== "function" || typeof process.setuid !== "function" || typeof process.setgid !== "function" || typeof process.setgroups !== "function" ) { throw new Error("The eval filesystem sandbox requires POSIX user APIs."); } if (process.getuid() !== 0 || process.geteuid() !== 0) { throw new Error("The eval runner must start as root before entering the unprivileged tool sandbox."); } const protectedPaths = await protectTransformedModules(); await chownTree(root, identity); process.setgroups([]); process.setgid(identity.gid); process.setuid(identity.uid); if (process.getuid() !== identity.uid || process.geteuid() !== identity.uid) { throw new Error("Failed to enter the unprivileged eval tool sandbox."); } for (const path of protectedPaths) { try { await readFile(path); } catch (error) { if (error instanceof Error && "code" in error && (error.code === "EACCES" || error.code === "ENOENT")) { continue; } throw error; } throw new Error(`The unprivileged eval process can read a transformed eval module: ${path}`); }}--read-only, /tmp là tmpfs, credential được mount chỉ đọc, UID sandbox là 65532 (docker.ts:91–124).README nói rõ giới hạn: tool của eval tài liệu chỉ gồm read, write, edit, grep, find, ls, nhưng container vẫn cần mạng để gọi provider, nên Docker một mình không chứng minh được code do agent viết không dùng mạng (README.md:90).
Kế hoạch, chạy, ghép cặp#
src/cli.ts điều phối một lần so sánh: build hai image (id phải khác nhau), phát hiện ca trong cả hai biến thể và đòi hai tập ca giống hệt, ghi protocol.json kèm digest, chạy từng nhiệm vụ trong một container mới (tuần tự), ghi observations.jsonl sau mỗi nhiệm vụ, rồi tóm tắt; còn cặp bị chặn thì thoát mã khác 0 (cli.ts:114–192). Kế hoạch được lập trước khi chạy bất cứ thứ gì:
export function createTaskPlan( cases: readonly DiscoveredEvalCase[], model: string, runsPerVariant: number,): EvalTask[] { if (!model.includes("/") || model.startsWith("/") || model.endsWith("/")) { throw new TypeError("Model identity must contain a provider and model."); } if (!Number.isSafeInteger(runsPerVariant) || runsPerVariant < 1) { throw new TypeError("Runs per variant must be a positive integer."); } const tasks: EvalTask[] = []; for (const evalCase of cases) { for (let runNumber = 1; runNumber <= runsPerVariant; runNumber += 1) { const variants: DocumentationVariant[] = runNumber % 2 === 1 ? ["without_docs", "with_docs"] : ["with_docs", "without_docs"]; for (const variant of variants) tasks.push({ ...evalCase, variant, model, runNumber }); } } return tasks;}without_docs/with_docs giữa các lần lặp là để giảm thiên lệch do thứ tự (README.md:80). Mặc định một lần mỗi biến thể; tăng bằng --runs-per-variant khi cần đo độ ổn định (README.md:57–63).Báo cáo ghép từng cặp (ca, mô hình, lần chạy). Một cặp chỉ được tính khi mỗi nhánh có đúng một quan sát có điểm; thiếu, trùng, bị bỏ qua, chưa chạy, không chấm hay lỗi đều làm cặp bị chặn, kèm lý do (report.ts:249–281). "Đạt" nghĩa là điểm ≥ 1. Tỉ lệ đạt của từng bộ và độ nâng (treatment − control) chỉ được công bố khi bộ đó không có cặp nào bị chặn; token, lời gọi tool, thời gian và chi phí được so trung bình theo cặp, và telemetry vắng mặt được giữ là "không có" chứ không coi là 0 (report.ts:359–413; README.md:103–105). Báo cáo còn gắn cờ no-lift, negative-delta, control-saturated, treatment-saturated và flaky (cùng ca, cùng biến thể mà lúc đạt lúc không) (report.ts:336–357). "Telemetry" trong ngữ cảnh này là số liệu usage do harness gom, không liên quan tới pi-telemetry.
Quyết định thiết kế#
Một chuẩn đối chiếu duy nhất, và test để giữ nó#
Mục tiêu của pi-env không phải "một môi trường từ xa hợp lý" mà là "giống hệt NodeExecutionEnv chạy ở đó". Đó là lý do có bảng chia việc: luật nằm ở client bằng TypeScript, system call ở daemon. Mục tiêu đó được kiểm bằng hai cách: bộ kiểm tuân thủ env của Durable chạy trên RemoteExecutionEnv qua một pipe và trên LocalExecutionEnv (test/conformance.test.ts:17–45), và một test vi sai dựng một cặp môi trường cục bộ và từ xa trên cùng máy rồi so các chuỗi thao tác ngẫu nhiên và chính các tool của Durable (test/differential.test.ts:24–33; README.md:63–66). Không có test như vậy, mọi khác biệt nhỏ (thứ tự readdir, một mã lỗi) sẽ lộ ra như một tool hành xử khác khi đổi máy.
SSH làm xác thực, daemon không mở cổng#
Daemon không lắng nghe trên mạng; nó chỉ nói chuyện qua stdin/stdout của một phiên SSH. Xác thực, mã hoá và quyền là việc của SSH, và pi-env siết SSH tới mức tối thiểu: không chuyển tiếp gì, không dùng chung kết nối, không lệnh từ cấu hình, host key nghiêm theo file và alias riêng, key đổi không bao giờ được tự chấp nhận. Binary được đặt tên theo nội dung và được kiểm hash trước mỗi lần khởi động, nên một binary bị sửa trên máy từ xa không được chạy, và hai phiên bản không bao giờ đè lên nhau (README.md:20–21).
Không mồ côi, đổi lại không tiếp tục#
Daemon giết mọi thứ khi kết nối im 30 giây hay stdin kết thúc. Lựa chọn ngược lại (để lệnh chạy tiếp và cho client nối lại) sẽ cần id phiên, đệm output và một giao thức tiếp tục; đổi lại, nó để lại tiến trình không ai quản khi một điện thoại mất mạng. pi-env chọn đơn giản và an toàn: lệnh chết theo kết nối, request đang bay thất bại với lost: true, và với thao tác có hiệu ứng, kết quả là "không biết". Đây cũng là ngữ nghĩa mà Durable đã có cho tool bị cắt ngang, nên hai tầng khớp nhau.
Câu trả lời nhỏ không chờ dữ liệu lớn#
Một hàng control và một hàng bulk ở daemon, cộng cửa sổ output, giải hai vấn đề của link chậm: ping và kết quả của một lệnh huỷ không bị kẹt sau megabyte output, và output mà người gọi sẽ vứt đi thì không bao giờ được gửi. Cả hai dựa vào thông tin chỉ người gọi biết (nó giữ phần đuôi bao nhiêu, commit nhanh cỡ nào), nên Durable mới thêm ShellOutputWindow vào giao diện ExecutionEnv.
Telemetry: callback, tham số tường minh, không chứa nội dung#
Span dựa trên callback (không có end()) nên không thể quên đóng span hay đóng hai lần, và status tự suy ra từ kết quả của callback. Context đi theo tham số chứ không theo AsyncLocalStorage, nên package chạy được trong Node, Bun, trình duyệt và worker (README.md:519), và hai lời gọi đồng thời không bao giờ lẫn cha. Đầu vào được tin để đường ghi không tốn công kiểm; kiểm nằm ở kiểu, qua schema. Và các schema của Pi cố ý chỉ chứa ID và metadata điều khiển, để bật telemetry không bao giờ có nghĩa là gửi prompt hay output của tool cho một bên thứ ba.
Eval: thà không có con số còn hơn một con số sai#
Ghép cặp chính xác theo (ca, mô hình, lần chạy), lập kế hoạch toàn bộ trước khi chạy, và giữ lại tỉ lệ đạt khi có bất kỳ cặp nào bị chặn: thiết kế này chấp nhận mất kết quả của cả một lần chạy dài thay vì công bố một độ nâng tính trên một tập con không đầy đủ. Cô lập bằng quyền file (mã evaluator chỉ root đọc được, harness hạ quyền trước khi gọi mô hình) để mô hình không thể đọc bài chấm.
Cạm bẫy và trường hợp biên#
- Mất kết nối giữa một thao tác ghi. Request đang bay thất bại với
{ code: "unknown", lost: true }; nó có thể đã chạy xong ở xa.isConnectionLost(error)phân biệt trường hợp này với một lỗi daemon trả về (connection.ts:125–128). - Handle không sống qua lần khởi động lại. Reader đang mở khi kết nối mất sẽ thất bại ở lần đọc kế, kể cả khi kết nối đã có lại; mở lại reader.
- Laptop ngủ là lệnh từ xa chết. 30 giây không có byte nào là daemon giết process group. Build dài trên máy từ xa không sống qua lúc client ngủ.
- Lần khởi động nào cũng tốn thêm vòng SSH. Phát hiện hệ thống chỉ một lần cho mỗi
sshConnection, nhưng kiểm hash daemon chạy lại ở mỗi lần khởi động, trước request đầu tiên sau một lần mất kết nối. - Môi trường đăng nhập. Mặc định daemon không chạy qua shell đăng nhập, nên lệnh không thấy những gì
~/.profileđặt; bậtloginShell: truenếu cần (ssh.ts:463–472). Locale cũng không được chuyển tiếp. - Daemon cũ.
listFilestrảnot_supportedvới daemon trước 1.2.0; người gọi phải có đường duyệt cây của mình. - Windows và Termux. Terminal chưa có trên Windows; chuỗi lệnh trên Windows chạy qua Git Bash như
NodeExecutionEnv, argv chạy thẳng (README.md:58–59). Termux cầnTMPDIR,termux-execvàtermux-wake-lock. - Known-hosts riêng.
~/.ssh/known_hostsvà file toàn cục không được đọc; key phải được chấp nhận vào file của ứng dụng, dưới alias của môi trường. - Telemetry không phải nơi chứa dữ liệu. Đừng lưu
TelemetryContexthay span vào bản ghi; chỉ carrier được phép, và chỉ như dữ liệu tương quan tuỳ chọn. Adapter trong bộ nhớ không có trần; metric chỉ nên có thuộc tính ít giá trị (README.md:513–517). - Adapter tự viết phải mang năng lực đi theo. Từ 1.4.0,
metricsvàpropagationphải có trên gốc, trên mọi span con và trên context đã extract, kể cả span trơ; bộ conformance kiểm điều đó (CHANGELOG.md:9). - Eval tốn tiền và lộ nội dung. Mỗi nhánh gọi mô hình thật; artifact chứa prompt, câu trả lời, code sinh ra và output của tool (README.md:107). Một lần lặp không chứng minh được độ ổn định. Eval tài liệu từ chối chạy ngoài container sandbox (harness.ts:526–528).
Tài liệu lệch code#
- README của pi-env nói daemon được "named by its SHA-256"; tên file thật dùng 32 ký tự hex đầu (128 bit) của hash (ssh.ts:380), còn việc kiểm trước khi chạy so cả hash đầy đủ.
- RFC 0019 ghi endpoint
pi.dev/install?version=…; coding agent ở v1.4.0 gọi/api/report-install(xem mục trên).
Đọc code ở đâu#
Thứ tự đọc cho từng package; đường dẫn tính từ gốc repo.
| # | File | Đọc để hiểu |
|---|---|---|
| 1 | env/README.md, docs/protocol.md, docs/semantics.md | Hai nửa của pi-env, giao thức frame, luật khớp với NodeExecutionEnv |
| 2 | durable/src/env/index.ts | Giao diện ExecutionEnv, ShellOutputWindow, ShellOutputInfo.skipped |
| 3 | env/src/connection.ts | Khởi động lười, dòng đồng bộ, frame, session, ping, lost |
| 4 | env/src/ssh.ts | Tham số SSH, host key, phát hiện hệ thống, triển khai daemon |
| 5 | env/src/remote-env.ts, errors.ts | Hiện thực ExecutionEnv: đường dẫn, đọc/ghi theo khúc, exec, cleanup |
| 6 | watch.ts, list-files.ts, terminal.ts | Watcher nối lại, listFiles, LocalExecutionEnv, terminal |
| 7 | daemon/src/main.rs, frame.rs, output.rs | Vòng frame, phân luồng, control trước bulk, liveness |
| 8 | exec.rs, window.rs, sys/unix.rs | Chạy lệnh, cửa sổ output, process group |
| 9 | watch.rs, list.rs, pty.rs | Watcher cạnh file, liệt kê theo gitignore, pseudo-terminal |
| 10 | test/conformance.test.ts, test/differential.test.ts | Cách lời hứa "giống NodeExecutionEnv" được kiểm |
| 11 | telemetry/README.md | Khái niệm, hợp đồng adapter, propagation, metric, store, schema, bảo mật |
| 12 | telemetry/src/index.ts, metrics.ts | Mọi kiểu của hợp đồng, schema và starter có kiểu |
| 13 | noop.ts, memory.ts, render.ts | Adapter no-op, adapter tham chiếu, in trace |
| 14 | testing/conformance.ts | Hợp đồng adapter ở dạng chạy được |
| 15 | durable/src/harness/telemetry.ts, submissions.ts | Schema của Durable; span submit; #phase trong scheduler.ts |
| 16 | evals/README.md | Hai loại eval, cách chạy, biến thể tài liệu, đọc kết quả |
| 17 | evals/src/harness.ts | Một coding agent cô lập cho mỗi lần chạy, sandbox, biến thể prompt |
| 18 | cli.ts, plan.ts, docker.ts, report.ts | Điều phối, kế hoạch, container, ghép cặp và độ nâng |
| 19 | docker/Dockerfile, entrypoint.ts, evals/ | Hai image, kiểm image, các bộ eval |
Tóm tắt#
- pi-env cho Pi Durable một
ExecutionEnvtrên máy khác: một daemon Rust nói giao thức frame qua stdin/stdout của SSH, còn worker, storage và credential ở lại máy cục bộ. - Client áp mọi luật của Node (đường dẫn, abort, mã lỗi, cách chia lần đọc), daemon làm system call; kết quả khớp
NodeExecutionEnvtrên máy đó, được giữ bằng conformance và test vi sai. - SSH bị siết: không chuyển tiếp, không chia sẻ kết nối, host key nghiêm theo file và alias riêng; daemon đặt tên theo hash và được kiểm trước mỗi lần khởi động.
- Frame có JSON và payload thô; trả lời đi trước output số lượng lớn; cửa sổ output bỏ phần người gọi sẽ vứt và báo lại bằng số đếm; 30 giây im lặng là daemon giết mọi thứ.
- pi-telemetry là hợp đồng span dựa trên callback, context tường minh, no-op và adapter trong bộ nhớ, propagation bằng carrier, link, metric, store và bộ conformance; schema có kiểu chỉ kiểm lúc biên dịch.
- Durable phát span
pi.durable.submitvàpi.durable.phasecùng ba metric, không chứa nội dung; carrier lưu trong bản ghi task giữ trace qua các lần khởi động lại. - pi-evals đo coding agent: host eval chạy tại chỗ, eval tài liệu so
with_docsvớiwithout_docstrong container cô lập, ghép cặp chính xác và giữ lại con số khi có cặp bị chặn.