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:

  1. ~/.claude/jobs/<short>/state.json. Mỗi session có một thư mục dạng ~/.claude/jobs/<8-hex>/ chứa state.json, timeline.jsonl, các tool output. File state.json chứa intent, name, sessionId, cwd, template, backend, các timestamp.
  2. ~/.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 UI claude 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ú ý:

  1. 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”.
  2. flag: "wx" (write exclusive). Nếu state.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.
  3. 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:

  1. CLI yêu cầu daemon claimSpare. Daemon nhặt một spare worker, gửi cwd và args qua socket.
  2. 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.
  3. Bạn mở claude agents. FleetView load roster, chạy Fp9. Filter source !== "spare" loại worker này ra. Nó không xuất hiện.
  4. 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.
  5. Bạn refresh claude agents (hoặc UI tự poll). Lần này Fp9 thấy worker không phải spare và chưa có state.json trên disk, nó adopt thành orphan và tạo state.json.
  6. 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 sessiondispatch.sourceHiện trong claude agents?
Dispatch trực tiếp từ FleetViewfleetNgay (state.json có ngay từ đầu)
Slash command /agents, RemoteTrigger, Cron, Telegram bridgeslashNgay
Resume một session cũslashNgay
claude trong terminal (claim từ spare pool)spareTrễ (sau khi daemon update + FleetView refresh)
Worker còn ngồi idle trong spare poolspareKhô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 dyingbấ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.json tươ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.json khô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. Đếm jq '.workers | length' roster.json mớ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 stop sẽ 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.