Tình huống
Bạn mở terminal, gõ claude để bắt đầu một session interactive bình thường. Rồi mở thêm một terminal khác chạy claude agents để xem FleetView. Session vừa mở không có ở đó.
Đợi khoảng một phút, mở lại claude agents. Lần này nó xuất hiện.
Câu hỏi đơn giản: session đó được “tự động register” vào claude agents sau một độ trễ cố định, hay có cơ chế gì đứng sau?
Câu trả lời ngắn: có một daemon supervisor đứng giữa CLI và mọi session, và FleetView phải đợi daemon “đẩy” session khỏi pool dự trữ trước khi list ra. Phần còn lại của bài này là chi tiết.
Hai nguồn dữ liệu của claude agents
FleetView (chính là UI khi gõ claude agents) đọc song song từ hai nguồn:
~/.claude/jobs/<short>/state.json. Mỗi session có một thư mục dạng~/.claude/jobs/<8-hex>/chứastate.json,timeline.jsonl, các tool output. Filestate.jsonchứa intent, name, sessionId, cwd, template, backend, các timestamp.~/.claude/daemon/roster.json. Một file duy nhất do daemon supervisor maintain, liệt kê tất cả worker process đang sống, mỗi entry kèm pid, socket, cwd, và quan trọng nhất làdispatch.source.
Hai nguồn này không trùng nhau. Roster có thể có entry mà chưa có state.json tương ứng, và ngược lại có những state.json của session đã kết thúc nhưng vẫn còn trên disk.
Daemon là gì
Từ phiên bản 2.x, mỗi máy có một claude-daemon supervisor process chạy nền. Khi bạn gõ claude trong terminal, CLI không spawn một Node runtime mới. Nó kết nối tới daemon qua socket ở /tmp/cc-daemon-<uid>/, daemon hand cho bạn một worker process sẵn có, và terminal của bạn được attach vào PTY của worker đó.
Lý do tồn tại của daemon là tốc độ. Spawn một Node + load full bundle Claude Code mất 2-3 giây. Daemon giải quyết bằng cách pre-spawn sẵn vài worker idle, gọi là spare pool. Khi bạn cần session mới, daemon nhặt một spare đã warm sẵn, gửi cwd và args qua socket, và worker bắt đầu phục vụ gần như tức thì.
Có thể tự kiểm chứng bằng cách xem roster:
jq '.workers | to_entries[] | {short:.key, source:.value.dispatch.source, pid:.value.pid, cwd:.value.cwd}' \
~/.claude/daemon/roster.json
Ví dụ output (đã rút gọn):
{"short":"24824189","source":"spare","pid":55646,"cwd":"~/WORK/some-project"}
{"short":"49d1c2a1","source":"slash","pid":4831,"cwd":"~/WORK/some-project"}
{"short":"e7f11305","source":"fleet","pid":38278,"cwd":"~/.claude"}
{"short":"8424e42a","source":"spare","pid":62852,"cwd":"~/.claude"}
dispatch.source là field then chốt:
spare: worker đến từ spare pool, vừa được claim hoặc còn idle.slash: session được dispatch qua slash command như/agents, hoặc từ resume.fleet: session do FleetView dispatch (gõ Enter trong UIclaude agents).
Orphan adoption: vì sao có session bị filter
FleetView không phải cứ thấy worker trong roster là render. Có một hàm tên là Fp9 trong binary lo việc merge hai nguồn dữ liệu. Logic rút gọn:
function Fp9(jobsFromDisk, workersFromRoster) {
const known = new Set(jobsFromDisk.map(j => j.id));
const orphans = workersFromRoster.filter(w =>
!known.has(w.short) // roster có nhưng disk chưa có state.json
&& w.source !== "spare" // BỎ QUA spare workers
&& !w.dying // BỎ QUA worker đang shutdown
);
for (const w of orphans) {
mkdirSync(`~/.claude/jobs/${w.short}`, { recursive: true });
writeFileSync(`~/.claude/jobs/${w.short}/state.json`,
JSON.stringify(buildState(w)),
{ flag: "wx" }
);
emitTelemetry("tengu_bg_roster_orphan_adopted");
}
return [...jobsFromDisk, ...orphans];
}
Ba điểm cần chú ý:
- Filter
source !== "spare". Spare workers, dù đã ngồi trong roster, vẫn bị bỏ ngoài cho đến khi daemon update lại entry. Đây là lý do session foreground “không hiện ngay”. flag: "wx"(write exclusive). Nếustate.jsonđã tồn tại sẵn, ghi sẽ thất bại im lặng. Tôn trọng state do worker tự viết.- Telemetry
tengu_bg_roster_orphan_adopted. Sự kiện này được emit mỗi lần FleetView phát hiện một worker chưa có disk state và phải tự sinh. Nếu bật telemetry và filter event này, có thể đếm chính xác số lần FleetView “nhận nuôi” session.
Vì sao trễ chứ không phải không
Quay lại tình huống đầu bài. Khi bạn gõ claude trong terminal:
- CLI yêu cầu daemon
claimSpare. Daemon nhặt một spare worker, gửi cwd và args qua socket. - Roster vẫn ghi worker đó là
dispatch.source: "spare"ngay tại thời điểm bạn vừa được gắn vào. - Bạn mở
claude agents. FleetView load roster, chạyFp9. Filtersource !== "spare"loại worker này ra. Nó không xuất hiện. - Daemon update roster sau khi worker thực sự active (sau handshake post-claim, hoặc khi worker bắt đầu sinh activity). Lúc này entry không còn ở trạng thái spare nữa.
- Bạn refresh
claude agents(hoặc UI tự poll). Lần nàyFp9thấy worker không phải spare và chưa cóstate.jsontrên disk, nó adopt thành orphan và tạostate.json. - Session xuất hiện.
Độ trễ “khoảng một phút” mà bạn quan sát là tổng của: thời gian daemon hoàn tất handshake, chu kỳ poll của FleetView, và thời gian I/O ghi state.json. Không có timer cố định nào ở đây. Nếu refresh sớm, session sẽ vẫn ẩn. Nếu daemon chậm vì lý do nào đó, có thể đợi lâu hơn.
Bảng phân loại theo cách khởi tạo
Tổng kết khi nào session xuất hiện trong claude agents:
| Cách khởi tạo session | dispatch.source | Hiện trong claude agents? |
|---|---|---|
| Dispatch trực tiếp từ FleetView | fleet | Ngay (state.json có ngay từ đầu) |
Slash command /agents, RemoteTrigger, Cron, Telegram bridge | slash | Ngay |
| Resume một session cũ | slash | Ngay |
Gõ claude trong terminal (claim từ spare pool) | spare | Trễ (sau khi daemon update + FleetView refresh) |
| Worker còn ngồi idle trong spare pool | spare | Không (đúng nghĩa pre-warmed, chưa có session) |
claude -p print mode | (không vào roster) | Không (ephemeral, --no-session-persistence) |
| Worker đang dying | bất kỳ | Không (filter !dying) |
Một điểm phụ: session dispatch từ FleetView (source: "fleet") thực ra cũng đi qua spare pool, nhưng daemon update dispatch.source thành "fleet" ngay tại thời điểm claim. Đó là vì sao session từ FleetView không bị độ trễ này.
Verify thủ công
Hai lệnh giúp soi trực tiếp:
# Roster đang có worker nào, source là gì
jq '.workers | to_entries[] | {short:.key, source:.value.dispatch.source, cwd:.value.cwd, pid:.value.pid}' \
~/.claude/daemon/roster.json
# State.json đã được FleetView/worker ghi ra
ls -la ~/.claude/jobs/*/state.json 2>/dev/null
So sánh hai list:
- Worker có trong roster với
source != "spare"mà chưa cóstate.jsontương ứng: ứng viên sẽ được orphan adopt ở lần FleetView refresh tiếp theo. - Worker có
source: "spare"mà chưa cóstate.json: bị filter, không xuất hiện cho đến khi source được update. state.jsonkhông có entry roster tương ứng: session đã kết thúc, file disk còn lại như tổ giấy của một worker đã exit.
Tại sao thiết kế thế này
Vài quan sát về lựa chọn thiết kế:
Roster có thể phình to với nhiều spare. Mỗi spare worker là một Node process tốn vài chục MB RAM. Số lượng spare configurable, mặc định thường là 1-3. Nếu thấy roster có nhiều entry source: "spare" chưa được claim, đó là pool đang được làm đầy chứ không phải bug.
flag: "wx" rất quan trọng. Worker tự ghi state khi nó có context (ví dụ khi đặt tên session, khi user submit prompt đầu tiên). Nếu Fp9 ghi đè, sẽ làm mất nameSource và intent thật. Việc dùng exclusive write đảm bảo orphan adoption chỉ tạo file khi worker chưa kịp tự ghi.
Hai tầng visibility tách rời. Roster là sự thật runtime (worker đang sống), state.json là sự thật phục vụ UI (FleetView có gì để hiển thị). Tách rời cho phép FleetView render nhanh mà không phải hỏi daemon mỗi lần, và cho phép daemon restart mà không mất session view (vì state.json đã trên disk).
Khi nào nên quan tâm
Phần lớn thời gian, độ trễ này không ảnh hưởng workflow. Bạn quan tâm nó khi:
- Đang debug một session không hiện ra như mong đợi. Check roster trước, xem
dispatch.source. - Viết script monitor số session active. Đừng chỉ đếm
ls ~/.claude/jobs/, đó là chỉ disk view. Đếmjq '.workers | length' roster.jsonmới đúng là worker đang chạy. - Reap worker zombie. Nếu thấy roster còn nhiều entry mà supervisor pid không chạy, lệnh
claude daemon stopsẽ dọn (gặp ở case crash, không thường xuyên).
Kết
claude agents không phải view đơn giản trên disk, nó là kết quả của một thuật toán merge giữa roster runtime (daemon) và state disk (jobs). Spare worker pool là tối ưu cho startup time, đánh đổi bằng cú trễ visibility nhỏ khi gõ claude thẳng trong terminal. Hiểu tới đây thì những lúc session “biến mất” không còn là điều kỳ lạ, mà chỉ là một bước trong pipeline đang chạy.
Bài tiếp theo của series sẽ nói về setting worktree.bgIsolation thêm vào 2.1.143, cho phép background session edit working copy trực tiếp thay vì luôn lấy worktree riêng. Đó là chuyện isolation, không phải visibility, nên xứng đáng một bài riêng.
Bài thuộc series Claude Code từ zero. Series plan tại bài giới thiệu.