# Điều phối agent phân tán

> A manager agent delegates to agents on other machines through a dispatch tool and the relay; how jobs are tracked and results returned.

# Distributed Agent Orchestration (Manager Role chia việc đa thiết bị)

## Mục đích

Cho phép một **Agent Role "Manager"** (một phiên `ai-chat`) **tự chẻ việc** rồi giao mỗi việc con cho
một **agent Claude Code tự chủ** chạy trên **máy hiện tại (local)** hoặc **máy khác của cùng tài khoản**
(remote, qua fleet relay). Kết quả gom về, Manager tổng hợp. Song song = Manager gọi `dispatch_agent`
nhiều lần trong một lượt.

Phạm vi build: **Sub 1 (nền tảng dispatch) + Sub 2 (tool orchestration)**. Xem plan
`plans/2026-07-15-distributed-agent-orchestration/`.

## Kiến trúc (async job + poll)

```
  MÁY A (Manager, ai-chat)            fleet-api (relay, đã có)        MÁY B (worker)
 ┌─────────────────────────┐         ┌──────────────────┐          ┌──────────────────────────┐
 │ agent-role MCP tools:    │         │ RelayDeviceDO(B) │          │ dashboard-server          │
 │  list_devices            │ POST    │ /d/<B>/api/*     │  tunnel  │  handleApiRoute:          │
 │  dispatch_agent ─────────┼─relay──►│ (đã tunnel /api) │─────────►│   POST /agent/dispatch    │
 │  agent_status            │         └──────────────────┘          │    → job nền              │
 │  agent_result (poll) ◄───┼─ GET /agent/result?jobId ────────────│    invokeHandler(         │
 │                          │                                       │      plugin:ai-chat:      │
 │ local: __kazuoAiChat     │                                       │      run-agent)           │
 │        .runAgent() ──────┼── in-process (không qua relay) ──────►│  (chạy trên máy B)        │
 └─────────────────────────┘                                       └──────────────────────────┘
```

- **Async**: `POST /agent/dispatch` khởi job nền, trả `{jobId}` NGAY (không giữ HTTP request lâu qua
  Cloudflare). `GET /agent/status|result?jobId=` poll bằng request ngắn.
- **Local** (máy A tự chạy): `dispatch_agent` gọi thẳng `globalThis.__kazuoAiChat.runAgent` in-process,
  KHÔNG qua relay.
- **Remote**: `dispatch_agent` gọi `POST ${FLEET}/d/<device>/api/agent/dispatch` (Bearer accountToken).

## Files

**Worker (Electron main):**
- `electron/dashboard-server.ts` — job store `agentJobs` (Map, TTL 30') + `newAgentJobId()`
  (`randomBytes`) + `pruneAgentJobs()`; 3 case trong `handleApiRoute`: `POST /agent/dispatch`
  (fire-and-store `invokeHandler('plugin:ai-chat:run-agent', opts)`, unwrap `{result,found}`), `GET
  /agent/status`, `GET /agent/result`. cwd rỗng → `process.cwd()`; validate `fs.existsSync`.
- `electron/store.ts` — `AppConfig.acceptRemoteDispatch: boolean` (default `true`) — kill-switch nhận việc.

**Orchestrator (plugin agent-role):**
- `plugins-library/agent-role/main/index.ts` — `getMcpTools` export 4 tool: `list_devices`,
  `dispatch_agent`, `agent_status`, `agent_result`. Job registry cục bộ `dispatchJobs` (local + remote).
  Helper: `fleetFetch` (Bearer accountToken), `resolveRole` (id/name → systemPrompt+plugins+
  toolProfile+toolOverrides từ settings máy A), `selfDeviceId`, `accountToken`. jobId = `crypto.randomUUID`.
  Nhánh remote của `dispatch_agent` gửi `toolProfile`/`toolOverrides` trong body (by value) — xem
  `agent-role-tool-profiles.md`.
- `plugins-library/agent-role/package.json` — thêm `zod` (host chạy `npm install --omit=dev` khi cài plugin).

**Auto-log Task Manager:** `dispatch_agent` tự tạo 1 task trong `task-manager` khi phóng việc (status
`in_progress`, title = tóm tắt task, agentName = device/role) và tự cập nhật `completed`/`cancelled` +
comment khi job xong — KHÔNG phụ thuộc agent nhớ gọi `task_create`. Ghi trực tiếp qua
`pluginControl.getPluginSettingsFor/setPluginSettingsFor('task-manager')` (best-effort, no-op nếu
task-manager chưa bật). Xem `tmCreate`/`tmFinish`.

**Xem chi tiết agent (nút "Xem" trong Task Manager):** mỗi dispatch LOCAL lưu `conversationId` = sessionId
của worker + `cwd` vào task. Widget task-manager hiện nút **Xem** (khi có `conversationId`+`cwd`) →
`invoke('plugin:show-on-overlay','ai-chat')` (hiện widget ai-chat, reuse của Launcher) +
`invoke('plugin:ai-chat:reveal-session', {cwd, sessionId, configDir})`. ai-chat main re-broadcast
`{type:'reveal-session', cfg}` → widget ai-chat `revealSession()` mở/focus đúng phiên (full tool use +
thinking, tận dụng render sẵn). **Chỉ local** — remote worker chạy phiên trên máy khác nên không mở được
tại đây.

**Xem LIVE (lúc worker đang chạy):** dispatch local truyền `opts.tabId = 'dispatch:'+jobId` cho `runAgent`
+ lưu `task.liveTabId`. Nút "Xem live" (khi task `in_progress`) → `revealSession({liveTabId, cwd})` →
`attachLiveTab()` tạo 1 **tab view-only** (`t.viewOnly=true`) khớp tabId, KHÔNG gọi open-tab/connect →
event broadcast của worker (route theo tabId) render **realtime** vào tab. Guard: tab viewOnly khoá ô nhập
+ chặn `sendMessage` (tránh spawn agent trùng tabId); `closeTabUI` KHÔNG gọi `close-tab` cho viewOnly
(đóng tab live không giết worker); nút Stop vẫn cho phép interrupt worker. Giới hạn: mở muộn thì mất event
trước đó (xem từ lúc mở).

## Tab mở bằng code kế thừa `sessionDefaults` (fix lỗi "OAuth session expired")

`openTab` (chat-manager) trước đây chỉ có hard default khi caller không truyền: `envProfileId='auto'`,
model `sonnet`, mode `bypassPermissions`. Với người dùng chạy **env profile proxy** (vd "SMIT Router"),
mọi tab mở bằng code — **khung chat embed (Orchestrator, cron-job) VÀ worker dispatch** — lại chạy bằng
đăng nhập **OAuth mặc định**; OAuth hết hạn → `Failed to authenticate: OAuth session expired`, trong khi
chat tay của người dùng vẫn chạy bình thường (khó lần ra).

Nay `openTab` fallback vào **`sessionDefaults`** của chính người dùng (`envProfileId` → `model` → `mode`
→ `contextSuffix`) qua helper `tabDefaults()`. Tab mở tự động vì thế hành xử như tab người dùng tự mở.
**Lưu ý còn lại:** `runAgent` vẫn truyền `model`/`mode` tường minh (`DEFAULT_MODEL='sonnet'`), nên worker
dispatch chạy `sonnet` trừ khi caller chỉ định model khác — chỉ `envProfileId` là kế thừa.

## Bảo mật & gating (chốt)

- **POST /agent/dispatch** kế thừa gate có sẵn: `dashboard-server` chặn MỌI `POST /api/*` thiếu
  `Authorization: Bearer <dashboardToken>` (local) / appToken owner (qua relay) **trước** `handleApiRoute`.
- Thêm kill-switch `acceptRemoteDispatch !== false` (mặc định on) — đặt `false` để máy này từ chối nhận việc.
- **Remote** qua relay: fleet verify appToken + `device.account_id === accountId` (org-ownership) — nên chỉ
  máy của chính tài khoản mới dispatch được cho nhau. Đúng chủ trương "tất cả máy của mình + auto-trust".
- **Bridge server KHÔNG** dùng `handleApiRoute` (nó có bảng route riêng) → 3 route agent chỉ hiện
  trên dashboard server/relay, không lộ thêm qua bridge loopback.
- `GET /agent/status|result` đi qua cùng lớp bắt dashboard token như mọi `/api/*` (kể cả GET); jobId ngẫu
  nhiên + không CORS-read + relay đã auth là các lớp bổ sung.

## Dev Mode — KHÔNG cần

Verify khi build: đường MCP của ai-chat (`chat-manager.ts` `connectAgentFor` →
`buildPluginControlMcpServers`) **không** gate theo Dev Mode (không có ref `devMode` nào trong
`plugins-library/ai-chat/main/` hay `electron/plugin-host.ts`). Tool nạp khi plugin **được attach vào
bundle của session**. Do đó 4 tool orchestration có sẵn **bất kể Dev Mode on/off**, miễn role Manager
bundle `agent-role`. (README ai-chat ghi "Dev Mode only" đã lỗi thời cho phần này.)

## Cách dùng — tạo role Manager

1. Bật plugin **agent-role** (và **ai-chat**, **task-manager**).
2. Trong widget agent-role, tạo role "Manager": bundle plugin gồm `agent-role` (bắt buộc — để có tool) +
   `task-manager` (để track). systemPrompt gợi ý:
   > Bạn là Manager điều phối. Chẻ yêu cầu thành các việc con ĐỘC LẬP. Với mỗi việc gọi `dispatch_agent`
   > (đặt `device` nếu cần chạy máy khác — dùng `list_devices` để xem máy online). Gọi nhiều
   > `dispatch_agent` trong một lượt để chạy song song. Sau đó `agent_result(jobId)` từng việc, tổng hợp
   > kết quả. Dùng task-manager để ghi tiến độ.
3. Trong **ai-chat**, gán role Manager cho một phiên → chat giao việc lớn.

## Giới hạn đã biết

- **Máy B cần `claude` đã đăng nhập riêng** + cwd hợp lệ (mỗi máy 1 ghế subscription).
- **cwd cross-OS chưa auto-map** — truyền cwd đúng OS máy B, hoặc để rỗng (dùng project root máy B).
- **Role không đồng bộ giữa máy** — Manager resolve role→systemPrompt+plugins ở MÁY A rồi truyền
  `systemPrompt`/`attachedPlugins` sang worker; plugin attach chỉ hiệu lực nếu plugin đó cũng bật ở máy B.
  Hồ sơ tool (`toolProfile`/`toolOverrides`, xem `agent-role-tool-profiles.md`) đi theo đúng cách này —
  by value, không phải id — từ fix 2026-09-28: trước đó remote dispatch không truyền hồ sơ gì cả nên
  worker remote luôn chạy full quyền bất kể role khai `orchestrator`/`worker`.
- **Headless (Linux/WSL) chưa có relay-agent** (xem `multi-device-relay.md`) → máy B chạy bằng
  `npm run headless` chưa nhận remote dispatch. Máy B GUI OK — kể cả GUI trên Linux: lần verify
  2026-09-10 dispatch sang một máy `os: linux` qua relay chạy được, nên hạn chế này là ở **chế độ
  headless**, không phải ở hệ điều hành.
- **Ngoài phạm vi:** device-affinity/capability routing tự động, retry/hồi phục worker chết, UI tiến độ
  realtime, cột `device` trong task-manager, cap song song.

## Kết quả verify runtime (2026-09-10)

Chạy trên app thật đang mở, không khởi động lại. Toàn bộ qua REST để tách phần hạ tầng ra khỏi phần
MCP tool của `agent-role`.

| Việc | Kết quả |
|---|---|
| Local: `POST /api/agent/dispatch` → `{jobId}` → `GET /api/agent/result` | `{status:'done', ok:true, text:'ORCHESTRATION-OK'}` |
| Local fan-out 2 job song song | Cả hai `done`, `sessionId` khác nhau, mỗi job trả đúng chuỗi của nó |
| Relay tunnel A→B (truy vấn vô hại trước) | `GET /d/<B>/api/agent/status?jobId=<không tồn tại>` → HTTP 200 `{status:'not_found'}` — máy B trả lời, không phải máy A |
| **Remote thật 2 máy** A(win32) → B(linux) | `{status:'done', ok:true, text:'REMOTE-DEVICE-B-OK'}` |
| Job chạy đúng trên B, không phải A | `status.device` = id máy B; cùng `jobId` tra trên máy A → `not_found` |

Xác thực: local dùng `dashboardToken`, remote dùng `accountToken` qua `https://fleet.kaiitech.net/d/<deviceId>/api/*`.

Chưa verify bằng runtime: đường **MCP tool** (`dispatch_agent`/`agent_result` của `agent-role`) — mới
chỉ verify lớp REST mà các tool đó gọi xuống — và **live view**, vì cả hai đều cần một agent Manager
thật gọi MCP tool. Route REST `POST /api/agent/dispatch` không nhận `tabId` nên không dựng được live
tab qua đường đó; đúng thiết kế, live view chỉ dành cho worker local.

Live view bản thân nó **đã cài đặt xong** từ 2026-07-17 (commit `2e589c7`) và **đã verify chạy thật**
2026-09-10: gọi `reveal-session {cwd, liveTabId}` tạo tab `viewOnly` đúng như thiết kế —
`sessionId: null`, `disconnected`, composer khoá ở đúng pane, gọi lần hai chỉ focus lại. Quan trọng
hơn: mở khoá composer rồi cố gửi tin vẫn **không** spawn agent nào, tức chặn nằm ở tầng logic chứ
không chỉ ở UI. Bảng chi tiết trong `plans/2026-07-15-distributed-agent-orchestration/phase-04-live-view.md`.

## Test

- **Local (1 máy):** bật ai-chat+agent-role+task-manager → tạo role Manager → giao 1 việc chia được 2 phần
  → Manager gọi `dispatch_agent` 2 lần (local) → `agent_result` từng jobId → gom kết quả.
- **REST trực tiếp:** `POST /api/agent/dispatch` (dashboard server, loopback) (kèm `Authorization: Bearer
  <dashboardToken>`, body `{"task":"...","timeoutMs":60000}`) → `{jobId}`; `GET
  /api/agent/result?jobId=<id>` → `{status:'done', ok, text}`.
- **Remote (2 máy):** cả 2 online + đăng nhập cùng account; Manager `dispatch_agent({device:B,...})` →
  `agent_result` poll → kết quả từ máy B.
- Sau khi sửa `main/*.ts`: `npx tsc -p tsconfig.plugins.json` (plugin) / `tsc -p tsconfig.node.json`
  (electron); cài lại/đồng bộ plugin bản chạy (host `npm install` zod).
