Mười agent chạy cùng một lệnh typecheck một lúc. Lời giải là một thư mục.

Mười bảy agent lập trình trong cùng một checkout, mười tiến trình tsc cùng lúc, load average 37 và 87 MB RAM trống. Một lần typecheck chín mươi giây mất 7 phút 36. Đây là số đo thực tế, lý do máy không hề tính toán, và cái khóa dùng chung nhỏ xíu đã xử lý xong chuyện đó. Chép được vào bất kỳ kho mã nào.

Ngày 7 tháng 9, máy phát triển của chúng tôi ngừng phản hồi cho ra hồn. Không phải sập, không phải treo. Đơn giản là mọi thứ lâu hơn mười lần, kể cả những việc chẳng liên quan gì đến mã nguồn.

Những nghi phạm hiển nhiên đều sai hết. Laptop không hề quá nóng: không ghi nhận throttling nào, pin ở 30,6 C. Không có tiến trình nào chạy loạn ngốn CPU. Không có gì được triển khai. Thứ bất thường duy nhất là có mười bảy CLI agent đang sống trong cùng một kho mã, mà ở đây thì đó là một ngày làm việc bình thường.

Đây là những gì thực sự đang diễn ra, đo được chứ không đoán.

Số đo

Máy 16 GB, 8 nhân, bật liên tục năm tiếng rưỡi, mười bảy agent đang làm việc:

Thứ chúng tôi đoGiá trị
CLI agent đang sống17
Tiến trình tsc --noEmit chạy song song, ghi nhận trong hai phút3, rồi 10
Load average37 đến 41
RAM trống / bộ nén bộ nhớ87 MB / 7,2 GB
Một lần typecheck trên ứng dụng desktop, máy bão hòa7 phút 36 đồng hồ treo tường cho 26 giây CPU
Cùng lần typecheck đó, máy rảnh rỗi33 giây

Dòng quyết định là dòng áp chót. Hai mươi sáu giây CPU trải ra trên bảy phút rưỡi là mười phần trăm mức sử dụng. Lần typecheck đó không hề tính toán. Nó đang chờ bộ nhớ.

Và một trong những tiến trình đó bị hệ điều hành giết giữa chừng. Một tsc bị giết sẽ thoát với mã khác không và đầu ra rỗng, thứ không thể phân biệt với một lỗi kiểu dữ liệu thật. Nên ngoài việc chậm, cái máy còn đang sản xuất ra những phán quyết mà không ai dám tin.

Không ai làm gì sai cả

Đây là phần đáng ngồi lại nghiền ngẫm, vì nó chính là thứ khiến sự cố này khó nhìn thấy trước đến vậy.

Mọi agent trong số đó đều tuân thủ quy tắc. Cái nào cũng vừa sửa TypeScript. Cái nào cũng được dặn phải kiểm tra kiểu dữ liệu trước khi trả việc. Cái nào cũng chạy tsc --noEmit. Không cái nào nhìn thấy những cái còn lại. Không có tấm bảng chung nào để một agent viết lên đó rằng "tôi đang làm cái việc đắt đỏ, chờ chút".

Rồi nó tự nuôi chính nó. Lần typecheck chậm đi vì máy đã bão hòa. Agent đang theo dõi nó kết luận rằng nó bị kẹt. Thế là nó giết đi và khởi động một cái khác. Phản xạ đó đúng khi đứng một mình và thảm họa khi đứng trong đám đông, và nó cùng một họ sự cố với thứ chúng tôi ghi lại một tháng trước đó, khi các agent bỏ lại phía sau những tiến trình tìm kiếm bị kẹt: Process Guard là tấm lưới tìm ra những gì đã khởi động, còn đây là lời giải chặn không cho nó khởi động.

Ba câu trả lời chúng tôi đã không chọn

Chạy ít agent lại. Cách này làm triệu chứng nhẹ đi một nửa và giữ nguyên con bug. Hai lần typecheck cùng lúc trên một máy đang tải nặng vẫn chậm hơn một lần, và cắt bớt đội hình là trả giá cho vấn đề bằng chính thứ làm cho công việc nhanh.

Một lần typecheck ở cuối. Nghe hấp dẫn, và sai vì một lý do chẳng dính dáng gì đến hiệu năng. Một lỗi kiểu dữ liệu bị phát hiện sau mười ticket là một đứa trẻ mồ côi: agent viết ra nó đã đóng, ngữ cảnh của nó đã mất, và một con người phải mở lại toàn bộ chủ đề để sửa một dòng. Chúng tôi không muốn hoãn việc kiểm tra lại.

Biên dịch tăng dần. Đã thử và đã bỏ. Lợi ích đáng ngờ ở chế độ --noEmit, và các tiến trình chạy song song làm hỏng tệp .tsbuildinfo dùng chung. Nó giải quyết một nửa vấn đề bằng cách làm nửa còn lại tệ hơn.

Thứ chúng tôi làm thay vào đó: một lần kiểm tra, dùng chung

Quy tắc không phải là "kiểm tra thưa hơn". Nó là một lần typecheck tại một thời điểm, cho mỗi dự án, cho tất cả mọi người. Một kịch bản bao ngoài thay N lần kiểm tra bằng một lần, và trả lời ba trường hợp:

  1. Không có gì thay đổi kể từ lần chạy trước, thì trả lại kết quả của lần đó.
  2. Đã có một lần chạy đang diễn ra, thì chờ nó và lấy kết quả của nó.
  3. Còn lại, thì giành khóa và là tsc duy nhất trên máy.

Từ góc nhìn của agent thì không có gì thay đổi: nó gõ yarn typecheck, nó nhận về các lỗi kiểu dữ liệu của mình. Nó cũng chẳng bao giờ chờ lâu hơn trước, vì lần chạy mà nó xếp hàng sau là lần chạy đã khởi động trước lần của chính nó. Máy trả giá cho một lần thay vì mười.

Toàn bộ ý tưởng chỉ có vậy. Điều thú vị là cả hai cơ chế mà nó cần đều nhỏ hơn nhiều so với bạn tưởng.

Cái khóa là một thư mục

Không phải một tệp, không phải một cơ sở dữ liệu, không phải một daemon. Một thư mục.

try {
  mkdirSync(lockDir);       // thành công: khóa là của chúng ta
} catch (err) {
  if (err.code === 'EEXIST') { /* người khác đang giữ, chờ đã */ }
}

mkdir hoặc là tạo được thư mục, hoặc là thất bại với EEXIST, và nó làm việc đó một cách nguyên tử trên macOS, Windows và Linux, không cần thư viện phụ thuộc và không cần lệnh gọi native. Ghi một tệp rồi mới kiểm tra xem nó có tồn tại hay không sẽ là hai thao tác, và hai thao tác đúng là chỗ một agent thứ hai lách vào giữa.

Bên trong thư mục, chúng tôi thả một tệp owner.json chứa pid, tên máy chủ và giờ khởi động. Tệp đó dùng để chẩn đoán và để phát hiện một cái khóa đã chết. Nó không bao giờ là thứ đứng ra loại trừ.

Một cái khóa đã chết được thu hồi tự động trong hai trường hợp: tiến trình chủ sở hữu đã biến mất (chỉ kiểm tra khi tên máy chủ trùng khớp, vì một pid chẳng có ý nghĩa gì khi đổi máy), hoặc cái khóa đã cũ hơn mười lăm phút.

Một cái bẫy đã khiến chúng tôi mất một con bug. Giữa lệnh mkdir và lúc ghi owner.json có một khoảng thời gian mà chủ sở hữu không đọc được. Tuyên bố cái khóa đã chết trong khoảng đó là cướp nó khỏi tay tiến trình vừa mới giành được, tức đúng cái tình huống tranh chấp mà tệp này sinh ra để ngăn. Nên khi không có chủ sở hữu nào đọc được, chúng tôi xét theo tuổi của thư mục, không phải theo tệp đang thiếu.

Dấu vân tay là một ngày giờ và một con số đếm

Trường hợp 1 cần biết có gì thay đổi kể từ lần chạy trước hay không. Câu trả lời hiển nhiên là băm các tệp nguồn. Chúng tôi không làm vậy.

Dấu vân tay là <mtime gần nhất>:<số lượng tệp> trên các thư mục gốc suy ra từ trường include của tsconfig, cộng thêm chính tệp tsconfig.

Trên 2.300 tệp, đọc từng byte tốn kém hơn cả lần kiểm tra mà nó tiết kiệm được. Chỉ mỗi ngày giờ thì bỏ sót một lần xóa. Chỉ mỗi con số đếm thì bỏ sót một lần sửa. Ghép lại, chúng che được cả hai. Trường hợp âm tính giả mà chúng tôi chấp nhận là hai lần sửa trong cùng một mili giây để lại con số đếm y hệt, và tệ nhất ở đó là một kết quả từ bộ nhớ đệm cũ đi vài giây, không bao giờ là một lỗi kiểu dữ liệu lặng lẽ, vì lần kiểm tra chặn thực sự vẫn là bản build.

Một quy tắc viết ra là chưa đủ, nên chúng tôi thêm một hook

Chỉ dẫn đã nằm trong AGENTS.md từ ngày đầu: không bao giờ chạy tsc trực tiếp, luôn dùng kịch bản dùng chung. Thế là chưa đủ, và cũng nên thành thật nói vì sao.

Kiểm tra kiểu dữ liệu sau khi sửa mã là một phản xạ ăn sâu. Dưới áp lực, một agent gõ npx tsc --noEmit mà chẳng đọc lại chỉ dẫn. Và chỉ cần một agent bỏ qua quy tắc là đủ tái tạo lại cảnh chồng chất mà cái khóa sinh ra để ngăn. Một chỉ dẫn thì có thể mặc cả. Một hook thì không.

Thế nên chúng tôi đấu một hook PreToolUse lên công cụ Bash, nó từ chối một lệnh tsc trực tiếp và nêu đích danh lệnh đúng ngay trong lời từ chối:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/scripts/hooks/block-direct-tsc.mjs\"" }
        ]
      }
    ]
  }
}

Hook đọc lời gọi công cụ dưới dạng JSON trên stdin, thoát với mã 2 kèm lý do trên stderr để từ chối, thoát với mã 0 để cho qua. Hai chi tiết tạo nên khác biệt giữa một hook hữu ích và một hook phiền phức.

Nó khớp tsc ở vị trí lệnh, không phải ở bất kỳ đâu trong chuỗi. Tìm ba chữ cái đó ở khắp nơi sẽ từ chối luôn cả grep -rn tsc AGENTS.md. Nên mẫu khớp đòi tsc phải đứng đầu dòng hoặc đứng sau ;, &&, ||, | hay (, có thể đi kèm một trình chạy gói và một đường dẫn ở phía trước. Nó cũng cho tsc --version đi qua: chẳng có lý do gì từ chối một tùy chọn chỉ để lấy thông tin.

Nó từ chối thêm một thứ nữa mà chúng tôi không lường trước. Chúng tôi đã nhìn thấy một agent xếp hàng sau cái khóa, sau một lúc thì quyết định rằng khóa hẳn đã chết, rồi xóa thư mục khóa để tự gỡ kẹt cho mình. Việc đó khởi động một tiến trình nặng thứ hai ngay cạnh cái đang sống, tức là cách lách hoàn hảo qua tất cả những gì cái khóa bảo vệ. Nên xóa thư mục khóa hay thư mục bộ nhớ đệm cũng bị từ chối, kèm lời giải thích rằng một cái khóa đã chết sẽ tự được thu hồi.

Lời từ chối thứ hai đó là thứ chúng tôi sẽ không bao giờ viết ra trước. Nó đến từ việc quan sát các agent thực sự làm gì khi bị chặn, và đó là nguồn sinh ra lan can tốt hơn nhiều so với việc ngồi tưởng tượng chúng có thể làm gì.

Chỗ nó dừng lại

Hook này gắn riêng với Claude Code. Các CLI agent khác trong đội hình chỉ nhìn thấy quy tắc viết ra. Đó là một lỗ hổng đã biết và chúng tôi chấp nhận: một lan can che được phần lớn đội hình vẫn hơn không có lan can nào trong lúc chờ một chuẩn hook mà mọi CLI đều đọc.

Bản thân kịch bản dùng chung thì trung lập với nhà cung cấp, vì nó chỉ là một lệnh. Bất kỳ CLI nào chạy được yarn typecheck đều hưởng lợi từ cái khóa, dù có gì ép buộc nó hay không.

Điều đáng rút ra

Lần typecheck là trường hợp ồn ào nhất của chúng tôi, chứ không phải một trường hợp đặc biệt. Mô thức này áp dụng cho bất kỳ lệnh nào đắt đỏ, cho cùng kết quả trong một cửa sổ thời gian ngắn, và được mọi agent khởi động vì cùng một lý do chính đáng: cài đặt phụ thuộc, chạy trọn bộ kiểm thử, build cho môi trường thật, khởi động một máy chủ dev trên một cổng cố định.

Ba câu hỏi, theo đúng thứ tự này, và bạn có trọn vẹn thiết kế:

  1. Tôi có tái sử dụng được một kết quả gần đây không?
  2. Tôi có nhập được vào lần chạy đang diễn ra không?
  3. Nếu không, tôi có phải là người khởi động nó, một mình, không?

Nếu nhiều agent dùng chung máy của bạn, thứ đáng đo không phải là có bao nhiêu cái đang chạy. Mà là có bao nhiêu cái khởi động cùng một lệnh trong cùng một phút. Con số đó mới là thứ máy của bạn thực sự cảm nhận, và chừng nào bạn còn chưa nhìn vào nó, bạn sẽ còn đổ lỗi cho cái nóng.

Nếu bạn muốn bức tranh rộng hơn về cách chúng tôi chạy nhiều agent trên cùng một kho mã mà chúng không dẫm lên nhau, nó nằm ở Chạy các agent lập trình song song, còn tấm lưới an toàn cho những tiến trình đã thực sự khởi động là Process Guard. Cả kịch bản dùng chung lẫn cái hook đều nằm trong kho mã của AgentsRoom, tức là chính nơi mười bảy agent đó đang làm việc buổi chiều hôm ấy.

Tải AgentsRoom

Chạy tất cả agent AI của bạn, trên mọi dự án, trong một cửa sổ duy nhất.

Miễn phíTải AgentsRoom

Ứng dụng đồng hành: theo dõi agent khi đi đường

Sử dụng Claude, Codex, Antigravity CLI hoặc nhà cung cấp AI khác.

Tải tiện ích mở rộng
Chrome Web Store

Gửi lỗi và yêu cầu thẳng vào backlog công khai của bạn.

Một cái nhìn về AgentsRoom đang hoạt động.

Nhiều dự án
Đa nhà cung cấp
Nhiều agent
Trạng thái trực tiếp
File diff & commit
Ứng dụng đồng hành mobile
Xem trước trực tiếp
Đội agent
Tự động hóa trình duyệt
Dev theo backlog
Thư viện prompt
Thư viện skill
Xem tất cả tính năng

Đọc thêm