agy-mcp-bridge 0.1.19 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Server TypeScript chạy độc lập trong terminal, dùng **MCP Streamable HTTP**. Codex, Claude Code hoặc agent điều phối khác kết nối tới cùng endpoint; không khởi chạy process bridge riêng. Bridge gọi CLI agent đã đăng nhập: `agy` (Antigravity) và/hoặc `codex exec` (Codex). Mỗi task chọn agent, chế độ cách ly và quyền tự duyệt; task worktree chạy song song trong git worktree riêng.
4
4
 
5
- `npm start` hiện chạy HTTP. STDIO cũ chỉ còn là chế độ tùy chọn `npm run start:stdio`, không dùng trong các cấu hình client bên dưới. Source bridge ở thư mục phát triển riêng `MCP-bridge`, ngoài repository ứng dụng được giao sửa. Kế hoạch nhiều agent và trạng thái từng phase ở [docs/MULTI-AGENT-PLAN.md](docs/MULTI-AGENT-PLAN.md).
5
+ `npm start` hiện chạy HTTP. STDIO cũ chỉ còn là chế độ tùy chọn `npm run start:stdio`, không dùng trong các cấu hình client bên dưới. Source bridge ở thư mục phát triển riêng `MCP-bridge`, ngoài repository ứng dụng được giao sửa. Kế hoạch nhiều agent và trạng thái từng phase ở [docs/MULTI-AGENT-PLAN.md](docs/MULTI-AGENT-PLAN.md). Những việc còn dở (dọn cây tiến trình agent, Job Object, cấu hình MCP toàn cục của `agy`) và bằng chứng kiểm chứng ở [docs/HANDOFF.md](docs/HANDOFF.md).
6
6
 
7
7
  ## Cài từ npm (người dùng)
8
8
 
@@ -297,6 +297,8 @@ TaskManager thuộc HTTP server, không thuộc phiên MCP. Server sử dụng h
297
297
 
298
298
  Task worktree tạo branch `bridge/<task_id>` và thư mục `.bridge-state/worktrees/<task_id>` tại HEAD của repo; thay đổi chưa commit trong workspace gốc **không** được copy sang. Sau khi task kết thúc, bridge so diff với `owns`, giữ nguyên worktree và branch để Tech Lead review/merge thủ công. Bridge không tự merge, không tự xóa branch, và không chạy hook của repo khi tạo worktree. Chỉ dùng worktree cho repo đã có ít nhất một commit; workspace là thư mục con của repo vẫn được hỗ trợ.
299
299
 
300
+ Agent CLI tự khởi động tiến trình con (MCP server, shell, công cụ) với thư mục làm việc đúng bằng worktree; trên Windows chúng có thể sống lâu hơn agent và **ghim** thư mục đó, khiến `git worktree remove`, `rm -rf` và cả `npm install -g` báo EBUSY dù thư mục trông có vẻ trống. Vì vậy bridge ghi lại cây tiến trình của agent trong lúc task chạy; khi agent thoát, các nhánh còn sống dưới cây đó bị `taskkill /T /F`, và kết quả (số cây đã giải phóng, số bị bỏ lại kèm lý do) xuất hiện trong trường `cleanup` của bản ghi task. Việc giải phóng chạy **sau** khi trạng thái cuối đã công bố nên không client nào chờ nó. Lúc khởi động, bridge dọn các thư mục worktree **rỗng** còn sót của lần chạy trước và in lý do cho thư mục còn giữ file; nội dung chờ review không bao giờ bị tự xóa.
301
+
300
302
  Mặc định giữ 50 task (tối đa cấu hình 200); task cũ hoàn tất bị loại bỏ. Bản ghi task được lưu tại `.bridge-state/tasks/<task_id>.json` (mode 0600 trên POSIX; nội dung đã qua lọc secret) và nạp lại khi khởi động, nên tra được kết quả sau restart. Task đang chạy lúc server dừng đột ngột được nạp lại với trạng thái `failed` ("Bridge restarted…"): tiến trình agent không được khôi phục, hãy kiểm tra diff dở dang. Đây là lịch sử kết quả, không phải durable task recovery. Task vẫn timeout nếu không có client theo dõi. Lane normal giữ khóa giữa các process dùng cùng bản cài tại `.bridge-state/execution.lock`; lane worktree dựa vào branch riêng và không dùng khóa này.
301
303
 
302
304
  Nhấn **Ctrl+C một lần** tại terminal server:
@@ -306,7 +308,7 @@ Nhấn **Ctrl+C một lần** tại terminal server:
306
308
  3. Đóng MCP handler, cho kết nối đang có tối đa 3 giây để drain sau khi cleanup; sau đó đóng cưỡng bức socket còn lại.
307
309
  4. In `[bridge] Stopped` khi thành công. Không rollback file đã sửa. Xem diff trước lần chạy tiếp theo.
308
310
 
309
- Nếu cleanup thất bại, báo lỗi và giữ execution lock khi process chưa đóng. Khi khởi động hoặc lấy lock, bridge chỉ tự thu hồi nếu đọc được owner hợp lệ, cả `server_pid` lẫn `child_pid` đều đã chết và record khớp lock. Lệnh `agy-mcp-bridge unlock` dùng cùng kiểm tra; PID còn sống, thiếu thông tin hoặc quyền sở hữu không rõ thì giữ lock và in lý do. Bridge không kill process Antigravity khác. PID có thể được tái sử dụng nên trường hợp không rõ cần kiểm tra thủ công. Force kill/OS crash vẫn không bảo đảm cleanup: chưa có Windows Job Object. POSIX gửi SIGTERM cho nhóm process, chờ tối đa 8 giây rồi SIGKILL; Windows giữ taskkill cây PID. Ctrl+C tiếp trong lúc cleanup chỉ báo `cleanup in progress, please wait`.
311
+ Nếu cleanup thất bại, báo lỗi và giữ execution lock khi process chưa đóng. Khi khởi động hoặc lấy lock, bridge chỉ tự thu hồi nếu đọc được owner hợp lệ, cả `server_pid` lẫn `child_pid` đều đã chết và record khớp lock. Lệnh `agy-mcp-bridge unlock` dùng cùng kiểm tra; PID còn sống, thiếu thông tin hoặc quyền sở hữu không rõ thì giữ lock và in lý do. Bridge không kill process Antigravity khác. PID có thể được tái sử dụng nên trường hợp không rõ cần kiểm tra thủ công. Force kill/OS crash vẫn không bảo đảm cleanup: chưa có Windows Job Object. POSIX gửi SIGTERM cho nhóm process, chờ tối đa 8 giây rồi SIGKILL. Windows khi hủy/tắt vẫn `taskkill` cây của child còn sống, và sau khi child tự thoát thì `taskkill /T /F` tiếp các nhánh mà watcher đã ghi danh trong lúc task chạy; PID bị tái sử dụng bị loại nhờ đối chiếu tên và thời điểm khởi tạo, process hệ thống và chính bridge không bao giờ bị nhắm. Ctrl+C tiếp trong lúc cleanup chỉ báo `cleanup in progress, please wait`.
310
312
 
311
313
  Giới hạn mặc định: CLI task 300s, tối đa 900s; watchdog của bridge cộng thêm 10s để CLI tự báo timeout trước. Chủ máy nâng mức tối đa (và mặc định) lên tới 3600s bằng `max_timeout_seconds` / `default_timeout_seconds` trong file cấu hình (khởi động lại bridge); mỗi task đặt `timeout_seconds` riêng, không vượt mức tối đa đó. Task hết thời hạn không bị hoàn tác: file đã sửa trước đó vẫn nằm trên đĩa (xem "Thay đổi trong workspace" hoặc `git diff`), trạng thái là `timeout`, và skill dặn Codex/Claude giao task mới hẹp hơn cho phần còn lại thay vì gửi lại y nguyên. Task lớn nên chia nhỏ hơn là chỉ tăng thời hạn. Output mỗi stream 256 KiB (cấu hình 1 KiB–1 MiB), vượt trần sẽ hủy task và trả failed/truncated. Tổng task JSON vẫn tối đa 24000 ký tự; HTTP body tối đa 128 KiB. Prompt mặc định đi qua argv với print/json (`prompt_transport: "argv"`, đường đã chạy thật với `agy` 1.4.3 và 1.4.7). Đặt `prompt_transport: "stdin"` để gửi qua stdin stream-json, không lộ prompt trong danh sách tiến trình; chế độ này chưa được chạy thử với `agy` thật, và stream gồm cả init/progress nên có thể chạm output cap sớm hơn. Snapshot log được cập nhật tối đa mỗi giây và flush khi kết thúc. Không dùng CLI detach/remote-control API chưa được xác minh.
312
314
 
@@ -340,7 +342,7 @@ npm test
340
342
  npm run test:live
341
343
  ```
342
344
 
343
- `npm test` (cũng chạy trong [CI](.github/workflows/ci.yml) trên Ubuntu và Windows, Node 20/22): unit/integration dùng executable giả, không gọi AI. Bao gồm lifecycle/security cũ và HTTP startup/port conflict/PORT tùy chọn/Host/Origin/body limit/kết nối hai protocol era/client reconnect/task completion/cancel/server shutdown.
345
+ `npm test` (cũng chạy trong [CI](.github/workflows/ci.yml) trên Ubuntu và Windows, Node 20/22): unit/integration dùng executable giả, không gọi AI. Bao gồm lifecycle/security cũ và HTTP startup/port conflict/PORT tùy chọn/Host/Origin/body limit/kết nối hai protocol era/client reconnect/task completion/cancel/server shutdown. Suite chạy **tuần tự** (`--test-concurrency=1`, khoảng 158 s trên máy dev Windows): phần lớn test khởi động server thật trên cổng loopback rồi đo output theo thời gian, nên khi chạy song song chúng lỗi vì tải của máy chứ không vì code — một fail ở chế độ song song không phải bằng chứng.
344
346
 
345
347
  `test:live`: **gọi AI thật**, dùng cached login/quota. Test harness khởi động HTTP server riêng với PORT=0 và scratch workspace, dùng official SDK HTTP client, ngắt/kết nối lại khi task chạy, xác minh `BRIDGE_SMOKE_OK` và sentinel không đổi, hủy task thật, rồi kiểm tra shutdown có task đang chạy. Trên Windows, harness phát event SIGINT vào đúng handler của entry point qua IPC riêng của harness; kiểm tra Ctrl+C terminal được ghi riêng trong báo cáo. Đây không phải yêu cầu client production tự khởi động server. Kết quả ở `test-results/live-report.json`; phần sửa file thật vẫn bị chặn đến khi cấp quyền.
346
348
 
@@ -0,0 +1,245 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { TERMINATION_GRACE_MS } from '../antigravity/process.js';
3
+ /**
4
+ * OS singletons that must never be reaped even if a recycled pid claims them as children of our exited agent.
5
+ * `cmd.exe` and `powershell.exe` are absent on purpose: they are genuine members of a real agent tree.
6
+ */
7
+ export const PROTECTED_IMAGES = [
8
+ 'system', 'registry', 'smss.exe', 'csrss.exe', 'wininit.exe', 'winlogon.exe', 'services.exe', 'lsass.exe',
9
+ 'svchost.exe', 'fontdrvhost.exe', 'dwm.exe', 'explorer.exe', 'taskhostw.exe', 'sihost.exe', 'ctfmon.exe'
10
+ ];
11
+ /** Clock granularity plus scheduling slack, so a child started microseconds before the recorded start is still ours. */
12
+ const CREATION_SLACK_MS = 5000;
13
+ /** Pure so the safety rules are testable without touching the process table. */
14
+ export function reapGuard(child, guard, protectedImages = PROTECTED_IMAGES) {
15
+ if (!Number.isInteger(child.pid) || child.pid <= 1)
16
+ return 'pid is not a reapable process id';
17
+ if (child.pid === (guard.self_pid ?? process.pid))
18
+ return 'pid is this bridge process';
19
+ const name = child.name.toLowerCase();
20
+ if (protectedImages.includes(name))
21
+ return `${child.name} is a protected system process`;
22
+ if (child.created_ms !== null && child.created_ms < guard.started_at_ms - CREATION_SLACK_MS)
23
+ return 'process existed before this task started';
24
+ return null;
25
+ }
26
+ function powershell(command, timeout = 8000) {
27
+ return new Promise((resolve, reject) => {
28
+ const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], { shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
29
+ let text = '';
30
+ const timer = setTimeout(() => { try {
31
+ child.kill('SIGKILL');
32
+ }
33
+ catch { /* already gone */ } }, timeout);
34
+ child.stdout.on('data', (data) => { if (text.length < 262144)
35
+ text += data.toString('utf8'); });
36
+ child.once('error', error => { clearTimeout(timer); reject(error); });
37
+ child.once('close', () => { clearTimeout(timer); resolve(text); });
38
+ });
39
+ }
40
+ /**
41
+ * Tab-separated so a name containing spaces cannot be mis-split. The whole process table is cheaper than one
42
+ * query per pid, and CommandLine is left out because it roughly doubles the cost of a query that repeats.
43
+ */
44
+ function tableQuery() {
45
+ return 'Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,Name,CreationDate | ForEach-Object { ' +
46
+ '$d = if ($_.CreationDate) { [int64]([System.DateTimeOffset]::new([System.TimeZoneInfo]::ToUniversalTime($_.CreationDate)).ToUnixTimeMilliseconds()) } else { -1 }; ' +
47
+ '"{0}`t{1}`t{2}`t{3}" -f $_.ProcessId, $_.ParentProcessId, $_.Name, $d }';
48
+ }
49
+ function parseTable(text) {
50
+ const rows = [];
51
+ for (const line of text.split(/\r?\n/)) {
52
+ const columns = line.split('\t');
53
+ if (columns.length < 3)
54
+ continue;
55
+ const pid = Number.parseInt(columns[0], 10);
56
+ const parent_pid = Number.parseInt(columns[1], 10);
57
+ if (!Number.isInteger(pid) || !Number.isInteger(parent_pid))
58
+ continue;
59
+ const created = Number.parseInt(columns[3] ?? '', 10);
60
+ rows.push({ pid, parent_pid, name: columns[2] ?? '',
61
+ created_ms: Number.isInteger(created) && created >= 0 ? created : null });
62
+ }
63
+ return rows;
64
+ }
65
+ export async function listProcesses() {
66
+ return parseTable(await powershell(tableQuery()));
67
+ }
68
+ /** Every row below `root`, nearest first, without the root itself. */
69
+ export function descendants(table, root) {
70
+ const childrenOf = new Map();
71
+ for (const row of table) {
72
+ const siblings = childrenOf.get(row.parent_pid);
73
+ if (siblings)
74
+ siblings.push(row);
75
+ else
76
+ childrenOf.set(row.parent_pid, [row]);
77
+ }
78
+ const found = [];
79
+ const seen = new Set([root]);
80
+ for (let frontier = [root]; frontier.length;) {
81
+ const next = [];
82
+ for (const pid of frontier) {
83
+ for (const child of childrenOf.get(pid) ?? []) {
84
+ if (seen.has(child.pid))
85
+ continue;
86
+ seen.add(child.pid);
87
+ found.push(child);
88
+ next.push(child.pid);
89
+ }
90
+ }
91
+ frontier = next;
92
+ }
93
+ return found;
94
+ }
95
+ /**
96
+ * Denser while the CLI is booting its servers, then steady: a missed short-lived shell loses a whole subtree.
97
+ * Each gap is measured after the previous query finished, and one query costs about half a second, so the tail
98
+ * stays long on purpose to keep the watcher well under a tenth of a core.
99
+ */
100
+ export const WATCH_DELAYS_MS = [500, 1000, 2000, 4000, 10000];
101
+ /**
102
+ * Records the agent's descendant tree while it runs, because the shells that hold the deepest children exit before
103
+ * the agent does. Best effort on every axis: a failed query is skipped, the next tick can still see the tree.
104
+ */
105
+ export function startTreeWatch(pid, options = {}) {
106
+ const delays = options.delays ?? WATCH_DELAYS_MS;
107
+ if ((options.platform ?? process.platform) !== 'win32' || !Number.isInteger(pid) || pid <= 1)
108
+ return { stop: () => [] };
109
+ const list = options.list ?? listProcesses;
110
+ const observed = new Map();
111
+ let stopped = false;
112
+ let cancel = null;
113
+ const poll = async (index) => {
114
+ try {
115
+ for (const info of descendants(await list(), pid))
116
+ observed.set(info.pid, info);
117
+ }
118
+ catch { /* the next tick gets another chance; the release is a convenience, not a guarantee */ }
119
+ if (!stopped)
120
+ wait(index + 1);
121
+ };
122
+ const wait = (index) => {
123
+ const at = Math.min(index, delays.length - 1);
124
+ const schedule = options.schedule ?? ((callback, ms) => { const timer = setTimeout(callback, ms); timer.unref(); return () => { clearTimeout(timer); }; });
125
+ cancel = schedule(() => poll(at), delays[at]);
126
+ };
127
+ wait(0);
128
+ return {
129
+ stop() {
130
+ stopped = true;
131
+ cancel?.();
132
+ return [...observed.values()];
133
+ }
134
+ };
135
+ }
136
+ async function killTree(pid) {
137
+ await new Promise((resolve, reject) => {
138
+ const systemRoot = process.env.SystemRoot ?? 'C:\\Windows';
139
+ const killer = spawn(`${systemRoot}\\System32\\taskkill.exe`, ['/PID', String(pid), '/T', '/F'], { shell: false, windowsHide: true, stdio: 'ignore' });
140
+ killer.once('error', reject);
141
+ killer.once('close', code => code === 0 ? resolve() : reject(new Error(`taskkill exited ${code}`)));
142
+ });
143
+ }
144
+ const groupAlive = (pid) => {
145
+ try {
146
+ process.kill(-pid, 0);
147
+ return true;
148
+ }
149
+ catch {
150
+ return false;
151
+ }
152
+ };
153
+ /**
154
+ * Releases whatever the agent left behind after it exited. Candidates are the processes still hanging below the
155
+ * agent or below any process the watch recorded while it ran, and each subtree root must pass reapGuard, so this
156
+ * cannot wander into an unrelated tree. `taskkill /T` covers the children of a root, so only roots are killed.
157
+ */
158
+ export async function reapOwnedTree(pid, options) {
159
+ const platform = options.platform ?? process.platform;
160
+ if (!Number.isInteger(pid) || pid <= 1)
161
+ return { checked: false, reason: 'No usable process id was recorded for this task', reaped: [], skipped: [] };
162
+ const alive = options.isAlive ?? (value => {
163
+ try {
164
+ process.kill(value, 0);
165
+ return true;
166
+ }
167
+ catch (error) {
168
+ return error.code !== 'ESRCH';
169
+ }
170
+ });
171
+ if (alive(pid))
172
+ return { checked: false, reason: 'Owned process is still running; its tree is terminated by cancel or timeout instead', reaped: [], skipped: [] };
173
+ if (platform !== 'win32') {
174
+ // POSIX spawns the agent detached, so it led its own group and every descendant inherited that group id.
175
+ const inGroup = options.group_alive ?? groupAlive;
176
+ const signal = options.signal_group ?? ((target, value) => { process.kill(-target, value); });
177
+ if (!inGroup(pid))
178
+ return { checked: true, reaped: [], skipped: [], group_signalled: false };
179
+ try {
180
+ signal(pid, 'SIGTERM');
181
+ }
182
+ catch {
183
+ return { checked: true, reaped: [], skipped: [], group_signalled: false };
184
+ }
185
+ const sleep = options.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
186
+ const deadline = Date.now() + TERMINATION_GRACE_MS;
187
+ while (inGroup(pid)) {
188
+ if (Date.now() >= deadline) {
189
+ try {
190
+ signal(pid, 'SIGKILL');
191
+ }
192
+ catch { /* the group exited in the race */ }
193
+ break;
194
+ }
195
+ await sleep(50);
196
+ }
197
+ return { checked: true, reaped: [], skipped: [], group_signalled: true };
198
+ }
199
+ const observed = new Map(options.observed?.map(info => [info.pid, info]) ?? []);
200
+ let table;
201
+ try {
202
+ table = await (options.list ?? listProcesses)();
203
+ }
204
+ catch (error) {
205
+ return { checked: false, reason: `Could not enumerate the agent process tree: ${error instanceof Error ? error.message : 'query failed'}`, reaped: [], skipped: [], tracked: observed.size };
206
+ }
207
+ const tableByPid = new Map(table.map(row => [row.pid, row]));
208
+ const recycled = (info, current) => info.name.toLowerCase() !== current.name.toLowerCase()
209
+ || (info.created_ms !== null && current.created_ms !== null && info.created_ms !== current.created_ms);
210
+ // A recorded parent that is gone still names its children, which is exactly what a child query cannot recover.
211
+ // A recorded parent that is alive again under the same number but is a different process is not a parent of ours.
212
+ const roots = new Set([pid]);
213
+ for (const [recorded, info] of observed) {
214
+ const current = tableByPid.get(recorded);
215
+ if (!current || !recycled(info, current))
216
+ roots.add(recorded);
217
+ }
218
+ const candidates = table.filter(row => row.pid !== pid && roots.has(row.parent_pid));
219
+ const tops = candidates.filter(row => !candidates.some(other => other.pid === row.parent_pid));
220
+ const reaped = [];
221
+ const skipped = [];
222
+ const kill = options.kill ?? killTree;
223
+ for (const child of tops) {
224
+ const info = observed.get(child.pid);
225
+ if (info && recycled(info, child)) {
226
+ skipped.push({ pid: child.pid, name: child.name, guard: 'pid was recycled for an unrelated process after the task ended' });
227
+ continue;
228
+ }
229
+ const guard = reapGuard(child, { started_at_ms: options.started_at_ms, self_pid: options.self_pid });
230
+ if (guard) {
231
+ if (child.pid !== (options.self_pid ?? process.pid))
232
+ skipped.push({ pid: child.pid, name: child.name, guard });
233
+ continue;
234
+ }
235
+ try {
236
+ await kill(child.pid);
237
+ reaped.push(child.pid);
238
+ }
239
+ catch {
240
+ skipped.push({ pid: child.pid, name: child.name, guard: 'termination request failed (it may have exited on its own)' });
241
+ }
242
+ }
243
+ return { checked: true, reaped, skipped, tracked: observed.size };
244
+ }
245
+ //# sourceMappingURL=orphans.js.map
@@ -1,5 +1,5 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { mkdir, realpath } from 'node:fs/promises';
2
+ import { mkdir, readFile, readdir, realpath, rmdir, stat } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { workspaceChanges } from '../changes.js';
5
5
  import { inside } from '../security/workspace.js';
@@ -52,10 +52,117 @@ export async function createWorktree(options) {
52
52
  throw new Error('Worktree workspace resolves outside the worktree');
53
53
  return { root: realRoot, workspace: realWorkspace, toplevel, branch, prefix, base };
54
54
  }
55
- /** Best effort and only for a launch that never started a process: no files were touched by the agent. */
56
- export async function removeWorktree(worktree) {
57
- await git(worktree.toplevel, ['worktree', 'remove', '--force', worktree.root]).catch(() => { });
58
- await git(worktree.toplevel, ['branch', '-D', worktree.branch]).catch(() => { });
55
+ /**
56
+ * Best effort and only for a launch that never started a process: no files were touched by the agent.
57
+ * A Windows process that still holds the directory makes `git worktree remove` fail, so the attempt is retried
58
+ * briefly and the outcome is returned instead of dropped.
59
+ */
60
+ export async function removeWorktree(worktree, options = {}) {
61
+ const delays = options.retryDelayMs ?? [0, 200, 600];
62
+ let reason = '';
63
+ for (const delay of delays) {
64
+ if (delay)
65
+ await sleep(delay);
66
+ try {
67
+ await git(worktree.toplevel, ['worktree', 'remove', '--force', worktree.root]);
68
+ }
69
+ catch (error) {
70
+ reason = error instanceof Error ? error.message : 'git worktree remove failed';
71
+ }
72
+ if (!await exists(worktree.root)) {
73
+ await git(worktree.toplevel, ['branch', '-D', worktree.branch]).catch(() => { });
74
+ return { removed: true };
75
+ }
76
+ reason = `The worktree directory is still held by a running process${reason ? ` (last error: ${reason})` : ''}`;
77
+ }
78
+ return { removed: false, reason };
79
+ }
80
+ const exists = async (target) => {
81
+ try {
82
+ await stat(target);
83
+ return true;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ };
89
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
90
+ const TASK_ID = /^[0-9a-f]{8}-[0-9a-f-]+$/;
91
+ /**
92
+ * Drops worktree directories that a previous run could not remove because a process the bridge no longer owned
93
+ * was holding them open. Only genuinely empty directories go: anything with content is a diff someone may still
94
+ * be reviewing, and stale locks on real work are left for a human.
95
+ */
96
+ export async function sweepStaleWorktrees(stateDirectory, options = {}) {
97
+ const directory = path.join(stateDirectory, 'worktrees');
98
+ const report = { scanned: 0, removed: [], retained: [] };
99
+ let names;
100
+ try {
101
+ names = await readdir(directory);
102
+ }
103
+ catch {
104
+ return report;
105
+ }
106
+ const readStatus = options.readStatus ?? (async (taskId) => {
107
+ try {
108
+ const parsed = JSON.parse(await readFile(path.join(stateDirectory, 'tasks', `${taskId}.json`), 'utf8'));
109
+ return parsed.task?.status ?? parsed.status ?? null;
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ });
115
+ const remove = options.remove ?? ((target) => rmdir(target));
116
+ for (const name of names) {
117
+ const candidate = path.join(directory, name);
118
+ report.scanned += 1;
119
+ if (!TASK_ID.test(name)) {
120
+ report.retained.push({ path: candidate, reason: 'not a task worktree directory' });
121
+ continue;
122
+ }
123
+ let status;
124
+ try {
125
+ status = await readStatus(name);
126
+ }
127
+ catch {
128
+ report.retained.push({ path: candidate, reason: 'its task record could not be read' });
129
+ continue;
130
+ }
131
+ if (status === 'queued' || status === 'running') {
132
+ report.retained.push({ path: candidate, reason: `task is ${status}` });
133
+ continue;
134
+ }
135
+ let entries;
136
+ try {
137
+ entries = await readdir(candidate);
138
+ }
139
+ catch (error) {
140
+ report.retained.push({ path: candidate, reason: error instanceof Error ? error.message : 'directory could not be read' });
141
+ continue;
142
+ }
143
+ if (entries.length) {
144
+ report.retained.push({ path: candidate, reason: 'it still holds files for review' });
145
+ continue;
146
+ }
147
+ let reason = '';
148
+ for (const delay of options.retryDelayMs ?? [0, 200, 600]) {
149
+ if (delay)
150
+ await sleep(delay);
151
+ try {
152
+ await remove(candidate);
153
+ reason = '';
154
+ break;
155
+ }
156
+ catch (error) {
157
+ reason = error instanceof Error ? error.message : 'directory could not be removed';
158
+ }
159
+ }
160
+ if (reason)
161
+ report.retained.push({ path: candidate, reason });
162
+ else
163
+ report.removed.push(candidate);
164
+ }
165
+ return report;
59
166
  }
60
167
  function normalizeOwn(own) {
61
168
  return path.posix.normalize(own.replace(/\\/g, '/')).replace(/^\.\//, '').replace(/\/+$/, '');
@@ -3,6 +3,8 @@ import { helperDirectory, paths } from './paths.js';
3
3
  import { inspectCli, resolveCommand, startProcess, VERIFIED_AGY_VERSIONS } from './antigravity/process.js';
4
4
  import { createAgentRegistry, publicAgents } from './agents/registry.js';
5
5
  import { TaskManager } from './tasks/manager.js';
6
+ import { sweepStaleWorktrees } from './isolation/worktree.js';
7
+ import { startTreeWatch } from './isolation/orphans.js';
6
8
  import { recoverExecutionLock } from './tasks/execution-lock.js';
7
9
  export async function createRuntime() {
8
10
  const config = await loadConfig();
@@ -29,9 +31,17 @@ export async function createRuntime() {
29
31
  const maxParallelWorktrees = config.max_parallel_worktrees ?? 2;
30
32
  const manager = new TaskManager(config, {
31
33
  executable: antigravity.executable, supported: antigravity.supported, stateDirectory: paths.stateDirectory,
32
- agents: registry.agents, defaultAgent: registry.defaultAgent
34
+ agents: registry.agents, defaultAgent: registry.defaultAgent,
35
+ // Without the watch the release can only name the processes that still hang below the agent at the moment it
36
+ // exits, which is exactly the case that left the worktree pinned in a live run.
37
+ watchTree: startTreeWatch
33
38
  });
34
39
  await recoverExecutionLock(paths.stateDirectory);
40
+ const sweep = await sweepStaleWorktrees(paths.stateDirectory);
41
+ for (const removed of sweep.removed)
42
+ console.error(`[bridge] Removed a stale empty worktree directory: ${removed}`);
43
+ for (const kept of sweep.retained)
44
+ console.error(`[bridge] Kept worktree directory ${kept.path}: ${kept.reason}`);
35
45
  const restored = await manager.restore();
36
46
  if (restored)
37
47
  console.error(`[bridge] Restored ${restored} finished task record(s) from .bridge-state/tasks`);
@@ -64,7 +74,8 @@ export async function createRuntime() {
64
74
  'Existing agent permissions, hooks, MCP and credential access remain governed by each CLI configuration.',
65
75
  'Antigravity permission flags are off unless the bridge owner raises the level in the web UI (a client cannot change it). At the default level, actions that need approval are denied (see denied_actions).',
66
76
  'No permission bypass flags. Dangerous actions require separate interactive approval.',
67
- 'Windows uses taskkill on the live owned child tree; abrupt server termination may orphan processes.',
77
+ 'After an agent process exits, the bridge releases the process trees it left behind (Windows: taskkill on every subtree still hanging below the agent or below a process recorded while it ran; POSIX: the exited process group). A bridge killed abruptly can still orphan processes.',
78
+ 'Worktree directories of finished tasks are kept for review. Only empty leftovers are removed at startup, and a directory a live process still holds is reported instead of forced.',
68
79
  'Redaction recognizes known patterns only. Never include credentials in task data.'
69
80
  ]
70
81
  };
@@ -10,6 +10,7 @@ import { delegationState } from '../delegation.js';
10
10
  import { startProcess, terminateOwned } from '../antigravity/process.js';
11
11
  import { legacyAntigravityAgent } from '../agents/antigravity.js';
12
12
  import { createWorktree, inspectScope, removeWorktree } from '../isolation/worktree.js';
13
+ import { reapOwnedTree } from '../isolation/orphans.js';
13
14
  import { appendUsage, totalTokens } from '../usage.js';
14
15
  import { MAX_RECENT_STEPS, rawStep, readProgress, summarizeStep } from '../progress.js';
15
16
  import { helperDirectory } from '../paths.js';
@@ -20,6 +21,8 @@ import { lowerLevel, permissionFlags, permissionState } from '../permissions.js'
20
21
  import { listModels, readModel } from '../models.js';
21
22
  import { pidAlive, recoverExecutionLock } from './execution-lock.js';
22
23
  const TASK_FILE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json$/;
24
+ /** A finished task publishes its status as soon as this budget runs out, even if the process query is wedged. */
25
+ const REAP_BUDGET_MS = 6000;
23
26
  export class TaskManager {
24
27
  config;
25
28
  runtime;
@@ -285,6 +288,10 @@ export class TaskManager {
285
288
  task.pid = child.pid ?? null;
286
289
  task.process_running = Boolean(child.pid);
287
290
  const started = Date.now();
291
+ // Windows keeps a exited process's children addressed by their parent's pid, and that parent is often a shell
292
+ // that dies before the agent does, so the tree has to be recorded while the agent runs (500–10000 ms polls). The runtime owns the
293
+ // watcher: a task manager built without one releases only what a final process query can still attribute to it.
294
+ const watch = this.runtime.watchTree?.(child.pid ?? 0) ?? { stop: () => [] };
288
295
  const buffers = { stdout: [], stderr: [] };
289
296
  const lengths = { stdout: 0, stderr: 0 };
290
297
  const dirty = { stdout: false, stderr: false };
@@ -480,6 +487,22 @@ export class TaskManager {
480
487
  // "completed" while the owns-scope check on the worktree is still outstanding.
481
488
  task.status = finalStatus;
482
489
  await this.persist(task);
490
+ // The agent launches its own children (MCP servers, shells, tool helpers) whose working directory is this
491
+ // worktree, and on Windows they outlive it and pin the directory. Releasing them needs a process query, so
492
+ // it runs after the status is visible: no client waits on it, and the record is patched when it finishes.
493
+ const held = { request: entry.request ?? null, stats: entry.stats ?? null };
494
+ const observed = watch.stop();
495
+ void this.releaseOwnedTree(child.pid ?? null, started, observed).then(async (cleanup) => {
496
+ task.cleanup = cleanup;
497
+ // A report that reaped nothing and failed for no reason says nothing worth a second write: this one runs
498
+ // detached from the status publish, so a reader of the tasks directory can catch its swap mid-flight.
499
+ if (!cleanup.reaped.length && !cleanup.skipped.length && !cleanup.reason)
500
+ return;
501
+ // Retention can evict this task while the query is still running, and writing then would bring the record
502
+ // back from the dead; only a task the manager still keeps has a record to patch.
503
+ if (this.entries.has(task.task_id))
504
+ await this.persist(task, held);
505
+ }).catch(() => { });
483
506
  await appendUsage(this.runtime.stateDirectory, {
484
507
  at: task.ended_at ?? new Date().toISOString(), status: task.status, model: entry.request?.model ?? null, effort: entry.request?.effort ?? null,
485
508
  permission: entry.request?.permission ?? null, duration_ms: task.duration_ms, agy_duration_seconds: entry.stats?.agy_duration_seconds ?? null,
@@ -493,11 +516,34 @@ export class TaskManager {
493
516
  catch (error) {
494
517
  if (ownsLock)
495
518
  await rmdir(lock).catch(() => { });
496
- if (worktree && !entry.child)
497
- await removeWorktree(worktree).catch(() => { });
519
+ if (worktree && !entry.child) {
520
+ const removal = await removeWorktree(worktree);
521
+ if (!removal.removed)
522
+ console.error(`[bridge] Could not remove the worktree of a launch that never started: ${removal.reason}`);
523
+ }
498
524
  throw error;
499
525
  }
500
526
  }
527
+ /**
528
+ * Releases the process tree an agent CLI left behind after the owned process exited, using the descendants that
529
+ * were recorded while it ran. Bounded by REAP_BUDGET_MS: a wedged process query must never keep a finished task
530
+ * from publishing its status.
531
+ */
532
+ async releaseOwnedTree(pid, startedAtMs, observed) {
533
+ if (pid === null)
534
+ return { checked: false, reason: 'No process id was recorded for this task', reaped: [], skipped: [] };
535
+ const failure = (error) => ({ checked: false, reason: error instanceof Error ? redact(error.message) : 'Process tree release failed', reaped: [], skipped: [] });
536
+ const run = Promise.resolve().then(() => (this.runtime.reapTree ?? reapOwnedTree)(pid, { started_at_ms: startedAtMs, observed })).catch(failure);
537
+ const budget = new Promise(resolve => {
538
+ const timer = setTimeout(() => resolve({ checked: false, reason: `Process tree release did not finish within ${REAP_BUDGET_MS}ms`, reaped: [], skipped: [] }), REAP_BUDGET_MS);
539
+ timer.unref?.();
540
+ });
541
+ const report = await Promise.race([run, budget]);
542
+ if (report.reaped.length || report.skipped.length || report.reason) {
543
+ console.error(`[bridge] Task tree release: ${report.reaped.length} process tree(s) reaped, ${report.skipped.length} left running${report.reason ? `; ${report.reason}` : ''}`);
544
+ }
545
+ return report;
546
+ }
501
547
  /** Hands free lanes to the oldest waiting tasks that fit. A task that cannot start is failed here rather than thrown at a caller that has gone. */
502
548
  async startNext() {
503
549
  while (!this.closing && this.queue.length) {
@@ -686,16 +732,36 @@ export class TaskManager {
686
732
  get stateDirectory() { return this.runtime.stateDirectory; }
687
733
  get tasksDirectory() { return path.join(this.runtime.stateDirectory, 'tasks'); }
688
734
  // Best effort: a failed write must never fail or hide a task.
689
- async persist(task) {
735
+ // `held` carries the request and stats for a write that happens after the entry was released, so patching the
736
+ // record afterwards cannot blank them.
737
+ async persist(task, held) {
690
738
  if (!this.runtime.supported)
691
739
  return;
740
+ const file = path.join(this.tasksDirectory, `${task.task_id}.json`);
692
741
  try {
693
742
  await mkdir(this.tasksDirectory, { recursive: true, mode: 0o700 });
694
- const file = path.join(this.tasksDirectory, `${task.task_id}.json`);
695
- await writeFile(`${file}.tmp`, JSON.stringify({ task, request: this.entries.get(task.task_id)?.request ?? null, stats: this.entries.get(task.task_id)?.stats ?? null }), { mode: 0o600 });
696
- await rename(`${file}.tmp`, file);
743
+ const entry = this.entries.get(task.task_id);
744
+ const request = held ? held.request : entry?.request ?? null;
745
+ const stats = held ? held.stats : entry?.stats ?? null;
746
+ await writeFile(`${file}.tmp`, JSON.stringify({ task, request, stats }), { mode: 0o600 });
747
+ // Windows refuses to rename onto a record that another process holds open for reading, and both the web UI and
748
+ // a polling client do that. Retry the swap instead of dropping the update.
749
+ for (let attempt = 0;; attempt++) {
750
+ try {
751
+ await rename(`${file}.tmp`, file);
752
+ break;
753
+ }
754
+ catch (error) {
755
+ const code = error.code;
756
+ if (attempt >= 2 || (code !== 'EPERM' && code !== 'EACCES' && code !== 'EBUSY'))
757
+ throw error;
758
+ await new Promise(resolve => setTimeout(resolve, 50 * (attempt + 1)));
759
+ }
760
+ }
761
+ }
762
+ catch {
763
+ await unlink(`${file}.tmp`).catch(() => { }); // history is a convenience, not a correctness requirement
697
764
  }
698
- catch { /* history is a convenience, not a correctness requirement */ }
699
765
  }
700
766
  async forget(id) {
701
767
  if (this.runtime.supported)
@@ -0,0 +1,152 @@
1
+ # Handoff — dọn cây tiến trình agent và thư mục worktree (B + C)
2
+
3
+ Cập nhật: 2026-10-26. Nhánh làm việc: `deepseek` (chưa commit, chưa push, chưa release).
4
+ Người tiếp theo: đọc mục "Chưa làm" trước khi sửa thêm; mọi mục có cách kiểm lại độc lập.
5
+
6
+ ## 0. Vấn đề gốc (bằng chứng thật, không suy đoán)
7
+
8
+ Agent CLI không chỉ là một process. `agy` tự khởi động MCP server của nó
9
+ (`cmd /c npx -y open-figma-mcp@latest`, `npx -y chrome-devtools-mcp@latest`) với **thư mục làm việc = worktree**
10
+ được giao việc. Trên Windows, một process giữ thư mục làm việc đang mở thì thư mục đó **không xóa được, không
11
+ đổi tên được**: `git worktree remove`, `rm -rf`, `Remove-Item`, `MoveFile` đều fail (EBUSY / ERROR_ACCESS_DENIED)
12
+ dù thư mục trông trống hoàn toàn. Không log nào, không command line nào của process cho thấy điều đó; cách duy
13
+ nhất để tìm là đọc CWD của process khác qua PEB
14
+ (`OpenProcess(0x0410)` → `NtQueryInformationProcess(class 0)` → PEB+0x20 → +0x38 length, +0x40 buffer) — xem
15
+ `test-results/probe-cwd.ps1`.
16
+
17
+ Chuỗi tiến trình thực đo được từ một lần chạy cầu hình:
18
+
19
+ ```
20
+ agy (pid 3300, đã thoát)
21
+ └─ cmd.exe (pid 12848, ĐÃ THOÁT) ← mắt xích chết
22
+ └─ node.exe npx -y open-figma-mcp@latest (pid 29124, còn sống, cwd = worktree)
23
+ └─ cmd.exe /d /s /c open-figma-mcp (pid 4836, còn sống)
24
+ └─ node.exe …\open-figma-mcp\server\dist\index.js (pid 15532, còn sống)
25
+ ```
26
+
27
+ `ParentProcessId` của Windows là hằng do process ghi khai khi sinh; nó trỏ vào pid chứ không trỏ vào object, nên
28
+ khi mắt xích giữa chết thì cả nhánh con **vô hình** với mọi query "con của agent". Ba process đó ghim worktree cho
29
+ tới khi bị giết. `taskkill /T` từ gốc 29124 giết cả ba và `rm -rf` thành công ngay sau đó.
30
+
31
+ ## 1. Đã làm (code đã build, đã test đơn vị, đã live-test bằng `agy` thật)
32
+
33
+ | # | File | Nội dung |
34
+ |---|------|----------|
35
+ | 1 | `src/isolation/orphans.ts` (mới) | `reapGuard` (PID hợp lệ, không phải bridge, denylist process hệ thống, cửa sổ thời điểm khởi tạo ±5s), `listProcesses` (một query CIM cho toàn bảng), `descendants` (BFS thuần, chống chu cha con), `startTreeWatch` (ghi danh cây tiến trình **trong lúc task chạy**), `reapOwnedTree` (Windows: chỉ giết gốc của từng nhánh còn sống, đối chiếu name+creation để loại PID bị tái sử dụng; POSIX: signal process group của child đã thoát) |
36
+ | 2 | `src/isolation/worktree.ts` | `removeWorktree` trả `{removed, reason}` với retry `[0, 200, 600]` ms thay vì nuốt lỗi; `sweepStaleWorktrees` dọn lúc khởi động: chỉ thư mục **rỗng** mang tên task-id, bỏ lại thư mục còn file, bỏ qua task `queued`/`running`, đọc cả hai shape record |
37
+ | 3 | `src/tasks/manager.ts` | Nối watcher vào vòng đời task (`watchTree` seam, stop khi child đóng, `observed` đưa vào reap); `task.cleanup` là `TreeReport`; giải phóng chạy **sau** khi trạng thái cuối đã công bố và được patch vào record bằng `persist(task, held)`; `REAP_BUDGET_MS = 6000` với timer `unref`; `persist` retry rename khi `EPERM/EACCES/EBUSY` (Windows từ chối rename đè lên record mà UI/client đang mở để đọc); đường launch fail ghi rõ reason khi không xóa được worktree |
38
+ | 4 | `src/runtime.ts` | `watchTree: startTreeWatch` (manager không tự chọn cơ chế OS); gọi `sweepStaleWorktrees` sau `recoverExecutionLock` và log từng thư mục xóa/giữ; hai dòng `limitations` mô tả đúng hành vi mới |
39
+ | 5 | `tests/isolation-orphans.test.ts`, `tests/isolation-worktree.test.ts`, `tests/agents.test.ts`, `tests/hardening-output.test.ts` | Guard rules; giết đúng gốc nhánh; subtree dưới shell đã chết vẫn được giải phóng; PID tái sử dụng không biến cây lạ thành cây của mình; `descendants`; watch poll/accumulate/stop; worktree thật bằng git thật; busy simulation; sweep; thứ tự "status trước, cleanup sau" và request/stats không bị null sau patch; bound snapshot theo `duration_ms` thay vì hằng số 3 |
40
+
41
+ Hành vi không đổi, cố ý giữ: bind `127.0.0.1`, kiểm tra Host/Origin, **không** xác thực token, không cờ bypass
42
+ permission, không tự merge worktree, hard-deny deploy/publish/xóa dữ liệu/push nhánh bảo vệ/đọc credential/thay
43
+ đổi hệ thống ngoài workspace, và **trạng thái cuối chỉ xuất bản sau khi `inspectScope` xong**.
44
+
45
+ ## 2. Chưa làm (mỗi dòng là một việc, kèm cách kiểm)
46
+
47
+ 1. **Nguyên nhân gốc vẫn còn**: `open-figma-mcp` và `chrome-devtools-mcp` vẫn bật **toàn cục** trong
48
+ `~/.gemini/config/mcp_config.json` (thư mục cấu hình của user chạy bridge). bridge chỉ dọn *sau khi* agent thoát, không ngăn chúng khởi
49
+ động. Chưa tắt bằng `agy mcp disable open-figma-mcp` (lựa chọn A) vì lệnh đó đổi cấu hình cá nhân của người
50
+ dùng và `agy mcp` chỉ có tác dụng global — **không** có `--config-dir`/`--mcp-config` per-task (đã kiểm
51
+ `agy help` ở phiên trước). Hệ quả còn lại: một phiên `agy` chạy *ngoài* bridge vẫn để lại cây ghim thư mục.
52
+ 2. **Không có Windows Job Object** (lựa chọn D). Bridge bị giết đột ngột (`taskkill` chính nó, SIGKILL, OS crash,
53
+ mất điện) giữa lúc task chạy thì không có lần giải phóng nào; worktree bị ghim cho tới khi process giữ nó chết.
54
+ `KILL_ON_JOB_CLOSE` mới là bảo đảm thật. Node không có API job, cần native addon hoặc P/Invoke `CreateJobObject`
55
+ + `AssignProcessToJobObject` lúc spawn — chưa làm, chưa đánh giá chi phí/rủi ro.
56
+ 3. **Lỗ hổng của watcher** (có ghi trong mã, chưa bịt): process sinh ra **và** mắt xích cha của nó chết *giữa hai
57
+ nhịp poll* sẽ không bao giờ vào `observed`, nên không thành gốc lúc reap. Nhịp hiện tại `WATCH_DELAYS_MS =
58
+ [500, 1000, 2000, 4000, 10000]` ms (dày lúc `agy` boot MCP server, thưa dần về sau); chưa có bằng chứng cho CLI
59
+ khác (codex, OpenCode…) với mô hình spawn khác — nhịp là suy đoán từ `agy` 1.4.7.
60
+ 4. **PID reuse mới chỉ giảm rủi ro, chưa loại bỏ**: cửa sổ ±5s + denylist ảnh hệ thống + đối chiếu name/creation
61
+ giữa record và bảng lúc reap + chạy ngay sau khi child exit. Chưa so image path/handle. Trường hợp xấu nhất vẫn
62
+ là "PID được cấp cho process lạ trùng cửa sổ" — cần quyết định: siết thêm hay chấp nhận và tài liệu hóa.
63
+ 5. **Chi phí poll đã đo một lần, chưa tuning và không tắt được**: trên máy dev ngày 2026-10-26, một nhịp
64
+ `Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,Name,CreationDate` (toàn bảng, ~337 dòng) tốn
65
+ **555 ms**; bản lọc `-Filter "ParentProcessId=X"` tốn 172 ms nhưng chỉ tìm được con trực tiếp — không đủ cho mục
66
+ 0. Thêm `CommandLine` làm gần gấp đôi chi phí nên bảng không lấy cột đó. Dùng toàn bảng vì một bảng trả được cả
67
+ cây và các cha đã chết. Với nhịp hiện tại, task 300 s ≈ 40 nhịp ≈ 6% chu kỳ làm việc của một `powershell.exe`
68
+ riêng; số đo lấy lúc máy rảnh, chưa đo lúc tải cao hoặc máy chậm, và **chưa có config để tắt watcher hay đổi nhịp**.
69
+ 6. **Nhánh group-kill POSIX chưa bao giờ gặp một process group còn sống**: `group_alive`/`signal_group` vẫn chỉ được
70
+ inject trong unit test; trên Linux thật (WSL, Node 20.20.2 — xem mục 3) mock child đã thoát nên `inGroup(pid)` trả
71
+ `false` và release về báo cáo `{checked:true, reaped:[], skipped:[], group_signalled:false}` tức thì — vòng
72
+ `SIGTERM → grace → SIGKILL` chưa chạy lần nào. Chưa có E2E Linux có CLI thật. CI matrix Ubuntu đã chứng minh một
73
+ điều khác và quan trọng hơn: nó bắt được lỗi thật mà Windows không lộ (mục 4).
74
+ 7. **Báo cáo giải phóng không có ở UI**: `task.cleanup` tồn tại trong `antigravity_task_status` và
75
+ `.bridge-state/tasks/<id>.json`, nhưng `src/ui` không render (grep `cleanup` trong `src/ui` = 0 kết quả). Nếu
76
+ muốn hiển thị, làm chung với gate 6 trong `docs/MULTI-AGENT-PLAN.md` (UI hiển thị worktree/branch).
77
+ 8. **`cleanup` có thể mất**: reap chạy nền sau khi lane đã nhả; nếu server thoát trong ~6 s sau khi agent exit thì
78
+ bản vá record không kịp ghi (chưa có flush-on-shutdown cho reap đang chờ).
79
+ 9. **Sweep chỉ xóa thư mục rỗng**: worktree còn file mà không ai review thì còn mãi; branch `bridge/*` không bao giờ
80
+ bị xóa tự động (chính sách "không tự merge/xóa"). Chưa có TTL, chưa có nút/CLI dọn, và **`git worktree prune`
81
+ chưa từng được gọi** → sau khi xóa tay/`sweep`, bản ghi admin trong `<toplevel>/.git/worktrees/*` vẫn còn và
82
+ `git worktree list` báo prunable.
83
+ 10. **`removeWorktree` thực tế chỉ chạy ở một nhánh hẹp** (launch chưa kịp spawn). Worktree của task hoàn tất được
84
+ giữ lại có chủ đích, nên đường EBUSY của nó chỉ được chứng minh bằng test mô phỏng, chưa bằng production.
85
+ 11. **Chưa commit/push/release**; chưa xin xác nhận của người dùng cho lần thay đổi này (lần chấp nhận trước chỉ
86
+ covering thay đổi đã merge). Scripts kiểm chứng đang ở `test-results/` và **bị gitignore**:
87
+ `verify-reap.mjs` + `probe-cwd.ps1` là đường tái hiện lỗi (đừng xóa); `trace-ancestry.ps1`,
88
+ `list-test-runners.ps1`, `inspect-child.ps1` chỉ là trợ giúp một lần của phiên này — `inspect-child.ps1` còn
89
+ chưa chạy lần nào. Cần quyết: nâng thành `scripts/live-reap.ts` có gate, hay xóa sau khi xong.
90
+
91
+ ## 3. Bằng chứng
92
+
93
+ Unit (`npm test` = build + `node dist/scripts/run-tests.js`, 210 test):
94
+
95
+ - Sequential (`node --test --test-concurrency=1 dist/tests/*.test.js`): **207 pass, 0 fail, 3 skipped**, 164,9 s. Đã
96
+ chạy lại lần hai sau bản sửa cuối (nhịp poll thưa hơn) và vẫn 207/0/3 — hai lần độc lập, cùng số.
97
+ - Parallel (`npm test`) trên Windows, chạy liên tiếp 3 lần: `206/1 fail`, `205/2 fail`, `207/0 fail`. Đứng một mình, từng test lỗi đều xanh (progress 9/9 hai lần, bridge.test 27/27).
98
+ - Nhà lỗi luôn là nhóm nhạy cảm timing: `progress` (…signal counts as activity), `http` (startup warns about the local trust model), `soft-timeout` (last activity Ns ago), `hardening-output` (số snapshot). Chúng flake theo tải của runner, không theo code; ba phiên liên tiếp cho ba test khác nhau. Đứng riêng, `progress.test.js` xanh 3/3 lần (9 pass), `bridge.test.js` 27/27. Không nới assertion để né. **Đã chốt ngày 2026-10-27**: `scripts/run-tests.ts` truyền `--test-concurrency=1`, nên từ nay `npm test` — và CI — chạy tuần tự; thời gian trên máy dev Windows là ~158 s thay vì ~47 s. Hai gạch đầu dòng trên là số liệu lịch sử của chế độ song song, không còn tái hiện được bằng `npm test`. Xác nhận sau khi đổi: `npm test` cho **207 pass, 0 fail, 3 skipped** trong 161,7 s (ngày 2026-10-27). Cờ `--test-concurrency` chỉ tồn tại từ Node 20.10 / 18.19 / 21.0 trở đi; CI đặt `node: [20, 22]` nên luôn nhận cờ, còn dev dùng Node 20.0–20.9 sẽ gặp `bad option: --test-concurrency` — cố ý không feature-detect trong script và không nâng `engines.node`, vì đây chỉ là đường chạy test của người phát triển, không phải đường chạy của sản phẩm đã publish.
99
+ - Bộ mới viết xanh tuyệt đối khi chạy riêng: `tests/isolation-orphans.test.js` 9/9, `tests/isolation-worktree.test.js` và `tests/agents.test.js` chạy kèm (`23 pass, 0 fail`).
100
+ - Linux (WSL, Node **20.20.2**, `node --test --test-concurrency=1 dist/tests/*.test.js`) ngày 2026-10-27: **211 test, 210 pass, 0 fail, 1 skipped**, exit 0. Đây là nền mà CI Ubuntu báo đỏ trước đó; chỉ khác Windows ở chỗ nhóm test POSIX signal được chạy thật thay vì bỏ qua.
101
+ - Windows (`npm test`, Node 24.15.0) cùng ngày, sau bản sửa: **211 test, 208 pass, 0 fail, 3 skipped**, 157,8 s. Hai con số lệch nhau đúng 3 = ba test nhóm POSIX signal bỏ qua trên Windows.
102
+ - CI của PR #28 (ubuntu-20/ubuntu-22 **fail**, windows-20/windows-22 pass) hỏng đúng một test `evicted tasks lose their persisted record`, và `publish.yml` trên `main` cũng fail nên **không có bản npm nào được phát hành** (registry vẫn `0.1.19`). Nguyên nhân và cách tái hiện: mục 4; script đo `test-results/diag-evict.mjs`, `test-results/diag-gate.mjs`.
103
+
104
+ Live, `agy` 1.4.7 thật, MCP client `streamable-http`, scratch trong `%TEMP%` (không đụng repo), hai lần:
105
+
106
+ ```
107
+ sweep_log : Removed a stale empty worktree directory: …\worktrees\a52a2e42-…
108
+ : Kept worktree directory …\worktrees\0cfa0399-… : it still holds files for review
109
+ task : completed, pid 30288, scope {checked: true, changed: 1, out_of_scope: []}
110
+ cleanup : {checked: true, reaped: [30768, 24304], skipped: [], tracked: 14} (lần 1)
111
+ cleanup : {checked: true, reaped: [33960, 30004, …], skipped: [], tracked: 13} (lần 2, nhịp đã thưa hơn)
112
+ release log : [bridge] Task tree release: 2 process tree(s) reaped, 0 left running
113
+ cwd probe : HITS 0 ← không còn process nào giữ cwd trong scratch
114
+ note.txt : REAP_OK ; original workspace: [.git, a.txt] ← worktree không rò sang repo gốc
115
+ shutdown : POST /ui/api/shutdown → 202, exit code 0
116
+ scratch cleanup : cả hai cây %TEMP% "removed" (không cần force)
117
+ ```
118
+
119
+ Báo cáo đầy đủ: `test-results/reap-report.json` (gitignored). Chạy lại:
120
+
121
+ ```bash
122
+ npm run build && node test-results/verify-reap.mjs # cần agy đã đăng nhập; tạo scratch trong %TEMP%
123
+ powershell -NoProfile -ExecutionPolicy Bypass -File test-results/probe-cwd.ps1 -Match bridge-reap
124
+ ```
125
+
126
+ Lưu ý: run **trước** bản sửa cho thấy `HITS 3` đúng với chuỗi tiến trình ở mục 0 (`cleanup.reaped` chỉ có 1 PID)
127
+ — đó là lý do thiết kế chuyển từ "query con trực tiếp lúc exit" sang "ghi cây trong lúc chạy + một bảng tiến trình
128
+ lúc reap".
129
+
130
+ ## 4. Cách làm việc cho người tiếp theo
131
+
132
+ - Không tự nới quyền CLI khi gặp permission denial; không thêm cờ bypass; không mở bridge ra LAN/proxy công khai.
133
+ - Bridge cố ý không có token: chỉ dùng trên máy do chủ máy kiểm soát; bất kỳ tiến trình cục bộ cùng user đều điều
134
+ khiển được agent.
135
+ - Trước khi đụng `manager.ts`: giữ nguyên thứ tự `flushOutput → parseResult → inspectScope → release owns lock →
136
+ publish status → persist → appendUsage → release lane → finish → startNext`. Test
137
+ `a worktree task never reports a final status before its owns scope is attached`
138
+ và `a finished task records the agent process trees it released without delaying its status`
139
+ (`tests/agents.test.ts`) chèn cả hai chiều của ràng buộc đó.
140
+ - Test không được phép gọi `powershell.exe`: `TaskManager` chỉ dùng `runtime.watchTree` khi được nối, mặc định inert.
141
+ `createRuntime` (`src/runtime.ts`) là nơi nối `startTreeWatch`.
142
+ - Không xóa `test-results/` trước khi mục 2.11 có quyết định; các file đó là cách tái hiện lỗi.
143
+ - Bản ghi task swap bằng `<id>.json.tmp` rồi `rename`, nên `readdir(state/tasks)` **có thể** trả cả tên `.tmp` khi một
144
+ lần ghi đang chạy. Muốn đếm số bản ghi thì lọc `.json` — `restore()` đã làm đúng như vậy. Đây chính là cách
145
+ `evicted tasks lose their persisted record` hỏng trên Linux mà không hỏng trên Windows.
146
+ - Bản vá muộn sau khi publish status (ghi `task.cleanup`) chỉ được viết khi manager vẫn còn giữ task đó. Retention có
147
+ thể đuổi bản ghi trong lúc query tiến trình chưa về, và ghi tiếp sẽ **hồi sinh** record đã xóa. Test
148
+ `a tree report that lands after eviction patches the record it still owns` (`tests/bridge.test.ts`) dựng đúng cửa sổ
149
+ đó bằng cách cho `reapTree` chờ một gate; đã kiểm chứng là nó đỏ khi bỏ guard, trên cả Windows lẫn Linux.
150
+ - `gh pr merge --auto` merge **ngay lập tức** khi repo không có branch protection: nó đã gộp PR #28 khi 4 job CI còn
151
+ đang chạy. Muốn "gộp khi CI xanh" thì chờ `gh pr checks --watch` rồi merge thủ công, hoặc bật ruleset yêu cầu
152
+ `test (ubuntu/windows × 20/22)` — hiện repo chưa có ruleset nào.
package/docs/HARDENING.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  1. Workspace bỏ qua **nội dung** `node_modules` và `.git` khi quét. Link ngoài hai vùng này chỉ được chấp nhận nếu realpath nằm trong workspace; link hỏng bị từ chối. Link/junction trong đường dẫn input vẫn bị từ chối. Đây là preflight, không phải OS sandbox; vùng bỏ qua và thay link sau preflight vẫn là giới hạn.
8
8
  2. Task queued được validate lại trước launch. Chuyển sang running được ghi trước spawn để restore không kết luận sai rằng task chưa từng sửa file. Recheck trạng thái ngay trước spawn ngăn launch tiếp sau cancel/shutdown trong lúc chờ I/O.
9
- 3. POSIX SIGTERM cả nhóm, tối đa 8 giây rồi SIGKILL. Windows vẫn taskkill theo PID/cây thuộc server. Bridge watchdog chạy sau CLI timeout thêm 10 giây; cancellation có thể chờ thêm thời gian cleanup.
9
+ 3. POSIX SIGTERM cả nhóm, tối đa 8 giây rồi SIGKILL. Windows vẫn taskkill theo PID/cây thuộc server khi hủy; khi agent tự thoát, bridge còn giải phóng các cây con mà agent để lại (mục "Dọn cây tiến trình agent" dưới đây). Bridge watchdog chạy sau CLI timeout thêm 10 giây; cancellation có thể chờ thêm thời gian cleanup.
10
10
  4. Startup và acquisition tự thu hồi lock chỉ khi cả hai PID trong owner đều hợp lệ và không còn sống. Record thiếu/hỏng, PID còn sống, ownership đổi hoặc owner cũ hơn lock thì giữ nguyên. `agy-mcp-bridge unlock` dùng cùng logic và không kill process. PID reuse và server crash vẫn cần xử lý thận trọng; chưa có Windows Job Object.
11
11
  5. Family 1.4.x phải có đủ cờ bắt buộc trong help. Patch 1.4.3 và 1.4.7 đã được xác minh thực thi AI thật; patch 1.4.x khác có cảnh báo log/environment; minor/major khác không chạy.
12
12
  6. Môi trường child mặc định chỉ có PATH, HOME, USERPROFILE, APPDATA, LOCALAPPDATA, TEMP, TMP, TMPDIR, SystemRoot, ComSpec, LANG, LC_*, XDG_*, HTTP_PROXY, HTTPS_PROXY, NO_PROXY; tên không phân biệt hoa thường. Tên chứa TOKEN/SECRET/PASSWORD/API_KEY chỉ được truyền khi có trong `extra_env_passthrough`. Các thiết lập auth/tool phụ thuộc env khác có thể cần opt-in.
@@ -35,3 +35,15 @@ Chỉ thêm env khi cơ chế xác thực/tool của chính bạn cần nó. `"p
35
35
  - `agy --help` xác nhận `--input-format stream-json` đọc NDJSON từ stdin và cần output stream-json. Đối chiếu [tài liệu Google chính thức](https://antigravity.google/docs/cli/headless/) về user/message.content, result envelope và EOF.
36
36
  - Probe CLI thật dùng **control_request cố ý không hỗ trợ**, không gửi user prompt: exit 2, result ERROR, num_turns 0, tất cả usage counters 0. Xác minh parser/đường lỗi; thành công của AI qua stdin chỉ được test bằng executable giả trong đợt này. Không tuyên bố đã kiểm thử agent thật cho patch khác 1.4.3 và 1.4.7 hoặc transport mới.
37
37
  - Không thay bind 127.0.0.1 hoặc Host/Origin; không mở LAN. Bridge cố ý không có xác thực — chỉ dùng trên máy do chủ máy kiểm soát.
38
+
39
+ ## Dọn cây tiến trình agent (nhánh deepseek, 2026-10-26)
40
+
41
+ Hậu quả thực tế trên Windows: process con do `agy` tự khởi động (MCP server qua `cmd /c npx -y open-figma-mcp@latest`, chrome-devtools-mcp) lấy worktree làm thư mục làm việc và sống lâu hơn agent. Windows không cho xóa hoặc đổi tên một thư mục đang được process khác giữ làm cwd, nên worktree bị ghim: `git worktree remove`, `rm -rf`, `Remove-Item` đều fail dù thư mục trống; chính con đường này cũng chặn `npm install -g` trên cùng cây.
42
+
43
+ - `startTreeWatch` ghi danh toàn bộ cây tiến trình của agent **trong lúc task chạy** (chụp bảng tiến trình bằng `Get-CimInstance Win32_Process`, nhịp 500→10000 ms). Bắt buộc phải chụp trong lúc chạy vì mắt xích shell trung gian chết trước agent, và `ParentProcessId` của Windows chỉ là một con số: query "con của agent" lúc exit không còn thấy nhánh dưới shell đó.
44
+ - `reapOwnedTree` lúc agent đã thoát dùng bảng tiến trình mới nhất + bản ghi để giết **gốc** của từng nhánh còn sống bằng `taskkill /T /F` (`/T` phủ các con phía dưới); POSIX signal process group của child đã thoát (SIGTERM, tối đa `TERMINATION_GRACE_MS` rồi SIGKILL). Kết quả là `{checked, reaped, skipped[{pid,name,guard}], tracked}` trong field `cleanup` của task.
45
+ - Guard: PID hợp lệ, không phải pid của agent đã thoát, không phải chính bridge, không nằm trong denylist ảnh hệ thống (`reapGuard`), creation time nằm trong cửa sổ task (slack 5s), và PID đã bị tái sử dụng (khác tên hoặc khác thời điểm khởi tạo so với bản ghi) thì **không** giết và cũng không coi là cha hợp lệ.
46
+ - Giải phóng chạy **sau** khi trạng thái cuối đã công bố và bị chặn bởi `REAP_BUDGET_MS = 6000`; client không bao giờ chờ nó. `persist` được vá để giữ `request`/`stats` khi ghi bản vá sau này, và retry rename khi `EPERM/EACCES/EBUSY` vì Windows từ chối rename đè lên record mà UI hoặc client đang mở để đọc (đây là bug thật, do test mới lộ ra).
47
+ - `removeWorktree` trả `{removed, reason}` với ba lần thử `[0, 200, 600]` ms và không còn nuốt lỗi; chỉ được gọi cho launch chưa kịp spawn. `sweepStaleWorktrees` lúc khởi động xóa **chỉ** thư mục rỗng mang tên task-id, bỏ lại task `queued`/`running`, bỏ lại mọi thư mục còn file và in lý do.
48
+ - `git worktree prune` chưa từng được gọi; bản ghi admin trong `<toplevel>/.git/worktrees/*` vẫn còn sau khi xóa tay hoặc sau sweep (`git worktree list` sẽ báo prunable).
49
+ - Chưa có Windows Job Object: bridge bị giết đột ngột giữa lúc task chạy thì vẫn orphan tree và worktree bị ghim tới khi process giữ nó chết. `agy mcp disable …` (tắt MCP server nguồn gốc vấn đề) là thao tác toàn cục trên cấu hình cá nhân của người dùng nên **không** tự chạy. Chi tiết và việc còn lại: [docs/HANDOFF.md](HANDOFF.md).
@@ -23,7 +23,7 @@ Chính sách shutdown: ngừng nhận task mới, hủy task đang chạy, dọn
23
23
  ## Máy thực tế
24
24
 
25
25
  - Windows, PowerShell; Node `v24.15.0`, npm `11.12.1`, Codex CLI `0.163.0`.
26
- - `Get-Command agy`: `C:\Users\dotha\AppData\Local\agy\bin\agy.exe`.
26
+ - `Get-Command agy`: `%LOCALAPPDATA%\agy\bin\agy.exe` (đường dẫn ứng dụng của user đang chạy lệnh, không phải đường dẫn cố định).
27
27
  - `agy --version`: `1.4.3`.
28
28
  - `agy --help` có print không tương tác, JSON và stream-json; help của executable được ghi qua stderr, nên probe thu cả hai stream.
29
29
  - Tài khoản cached dùng được với prompt chỉ phản hồi; không đọc hoặc xuất credential.
@@ -50,6 +50,7 @@ Bridge chỉ dùng --version/--help và print/json/print-timeout/disable-slash-c
50
50
  3. `--print-timeout 1ms`: CLI trả JSON SUCCESS, response rỗng, num_turns 0, exit **0**, stderr báo print timeout. Bridge nhận diện đây là timeout, không thành công.
51
51
  4. MCP client → server → agy trong scratch workspace, yêu cầu tạo `bridge-smoke.txt`: CLI trả SUCCESS/exit 0 nhưng `denied_actions` chứa `write_file`, response rỗng. Bridge trả failed. Bằng chứng đầy đủ ở `test-results/write-permission-probe.json` (local, gitignored). Chưa chứng minh khả năng chỉnh sửa code qua bridge vì chưa được cấp quyền.
52
52
  5. Smoke chỉ phản hồi và hủy process thật: `npm run test:live` đạt ngày khảo sát. MCP client nhận `BRIDGE_SMOKE_OK` với completed/exit 0 (khoảng 13.5 giây); sentinel không đổi; task tiếp theo được hủy, status canceled/process_running false. Bằng chứng ở `test-results/live-report.json`. Đọc trường `success`, task.status, response_verified, workspace_unchanged và cancellation.status để biết lần chạy mới nhất.
53
+ 6. Dọn cây tiến trình và sweep worktree (2026-10-26, `agy` 1.4.7, scratch trong `%TEMP%`, repo git giả): bridge khởi động với một thư mục worktree rỗng còn sót và một thư mục còn file → log báo xóa đúng cái rỗng, giữ đúng cái có file. Task worktree thật trả `completed`, `scope.checked=true`, `changed=1`, `out_of_scope=[]`, `cleanup={checked:true, reaped:[30768,24304], skipped:[], tracked:14}` và log `[bridge] Task tree release: 2 process tree(s) reaped, 0 left running`. Probe đọc cwd của mọi process (PEB) trả `HITS 0`, `rm -rf` toàn bộ scratch thành công không cần force, `note.txt` trong worktree là `REAP_OK`, workspace gốc chỉ còn `[.git, a.txt]`, shutdown confirm trả 202 và server exit 0. Lần chạy **trước** bản sửa cho `HITS 3` với đúng chuỗi `agy → cmd (chết) → npx open-figma-mcp → cmd → node`, `cleanup.reaped` chỉ có 1 PID — đó là bằng chứng vì sao phải ghi cây tiến trình trong lúc task chạy. Bằng chứng: `test-results/reap-report.json` (gitignored), cách chạy lại ở `docs/HANDOFF.md`.
53
54
 
54
55
  Unit/integration dùng mock nằm ở `tests/bridge.test.ts`, được tách khỏi script live. Bao gồm validation, boundary/traversal/junction, lock, lifecycle, retention, timeout, cancel cây process và giữ process khác, exit 7, JSON lỗi, partial SUCCESS timeout, permission denial, output cap, redaction, unsupported/spawn failure và protocol stdout sạch.
55
56
 
@@ -61,7 +62,8 @@ Unit/integration dùng mock nằm ở `tests/bridge.test.ts`, được tách kh
61
62
  - Timeout/error có thể bị CLI biểu diễn là SUCCESS/exit 0. Không dùng exit code đơn lẻ để quyết định.
62
63
  - Không bật stream-json trong triển khai hiện tại: tài liệu mô tả cơ chế nhưng chưa cần kiểm thử để phục vụ print/json. Status chỉ có stdout/stderr đang nhận, không có phần trăm tiến độ. JSON cuối thường chỉ tới khi agent kết thúc.
63
64
  - --sandbox xuất hiện trong help nhưng chưa xác minh khả năng cách ly Windows cho luồng này, nên không bật hay tuyên bố có OS confinement. Allowlist/preflight không thay thế sandbox.
64
- - Hủy Windows dựa vào taskkill cây của child còn sống. Không bảo đảm xử lý hậu duệ đã detach, PID reuse cực ngắn giữa kiểm tra và taskkill, hoặc server crash. Nếu cần bảo đảm cleanup khi crash, bước tiếp theo là Windows Job Object hoặc worker trong môi trường OS cách ly.
65
+ - Hủy Windows vẫn dựa vào taskkill cây của child còn sống; khi child tự thoát, bridge giải phóng các nhánh đã được ghi danh trong lúc chạy (mục 6 ở trên). Không bảo đảm: process sinh ra **và** mất cha giữa hai nhịp poll, PID reuse trong cửa sổ rất ngắn giữa bảng tiến trình và `taskkill`, hay server bị giết đột ngột/crash — ba trường hợp này vẫn để lại tree ghim thư mục. Nếu cần bảo đảm cleanup kể cả khi crash, bước tiếp theo là Windows Job Object (`KILL_ON_JOB_CLOSE`) hoặc worker trong môi trường OS cách ly; chưa làm.
66
+ - Nguồn gốc tiến trình giữ cwd nằm ở MCP server mà `agy` tự khởi động (`open-figma-mcp`, `chrome-devtools-mcp`) theo cấu hình **toàn cục** `~/.gemini/config/mcp_config.json`. Bridge dọn sau khi agent thoát, không ngăn chúng khởi động; tắt chúng là thay đổi cấu hình cá nhân của người dùng nên chưa tự làm.
65
67
  - Lock bảo vệ các process dùng cùng bản cài bridge; không quản lý editor/agent khác hoặc bản cài khác.
66
68
  - Chưa đăng ký vào cấu hình Codex thật; MCP SDK client handshake thành công không tương đương tool đã có trong phiên Codex hiện tại.
67
69
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agy-mcp-bridge",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "Local loopback HTTP MCP bridge that delegates bounded coding tasks to multiple agent CLIs (Antigravity, Codex) with optional parallel git worktrees",
5
5
  "license": "MIT",
6
6
  "keywords": [