diff --git a/CHANGELOG.md b/CHANGELOG.md index dc0bdc7f..7d6ae386 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ versioning theo [SemVer](https://semver.org). **8sync rule:** mỗi PR cập nh ## [Unreleased] +## [0.110.69] — 2026-10-22 +### Fixed +- **Guard tắt ngay trình duyệt của phòng ăn CPU khi máy quá ngưỡng** (owner: "cpu chạm 45% phải kill … fix triệt để"): một Edge headless của phòng QA vẽ WebGL bằng CPU, ăn 40% máy, CPU lên 94% dù ngưỡng 45%, mà guard chỉ hạ ưu tiên. Giờ khi CPU ≥ ngưỡng, trình duyệt nào do phòng mở ăn ≥ 15% máy bị tắt cả cây (phòng vẫn sống, lần chụp sau thử lại); trình duyệt của anh không bao giờ bị đụng. Test +1. +- **Dọn Chromium của `8sync shot` không qua WMI nữa**: luật mồ côi của v0.110.67/68 (#216, #217: wmic + taskkill) giờ đọc danh sách tiến trình native (8sync chủ đã chết mới dọn) thay cho WMI (`Get-CimInstance`/`wmic` liệt kê mọi tiến trình mỗi lần chụp); HQ không còn chạy PowerShell/WMI mỗi phút, việc dọn nằm trong `8sync ccc auto revive`. Đã chạy thật trên Windows: hai lần chụp cách 0,1 s đều ra ảnh, Chromium mồ côi bị dọn. Test +1. +- **"⚡ Làm ngay" trên card backlog có người nhận ngay** (owner 2026-10-22, 3d-game: "bấm khẩn cấp gấp đợi lâu kinh khủng không ai làm"): card backlog/blocked chỉ gọi lead/ba; lead đang trong một goal dài, không có ba, `rd` ngồi rảnh, card P0 nằm im. Giờ nút gọi thêm `rd` (R&D) làm thay lead: viết AC + lệnh check, tách card con, `auto move ready` ngay lượt này; lead vẫn nhận tin. Card ở cột khác không đổi. Test +1. +- **Máy bớt nặng tận gốc: guard không còn gọi WMI** (owner: "fix tận gốc … ram, cpu"): mỗi nhịp guard của mỗi dự án chạy `Get-CimInstance Win32_Process` kèm command line qua PowerShell; trên máy owner WmiPrvSE phình tới 12,7 GB RAM và 22% CPU. Giờ 8sync liệt kê process bằng Win32 API trực tiếp (ToolHelp, GetProcessTimes, bộ nhớ, NtQueryInformationProcess cho command line): khoảng 0,1 giây, không qua WMI. WMI chỉ còn là đường dự phòng khi cách mới trả rỗng. Test Windows thật +1. +- **Terminal phòng qua web VPS không còn bỏ cuộc sau 3 giây** (owner: "Fail connect terminal rất nhiều"): trang hiện "đang nối" và thử lại giãn dần (0,6 → 5 giây) suốt một phút; lỗi từ chối vẫn báo ngay. +- **Câu hỏi 5 lựa chọn trong terminal không còn bị cắt còn 3**: terminal xin ít nhất 22 dòng và không thu nhỏ khi bàn phím điện thoại mở. Test +2. +- **Dự án ghim hoặc trong scope mà 0 phòng vẫn có nút "Mở team"** (owner, ide: "mất nút mở lại team"): Văn phòng của dự án đang chọn hiện nút mở team dù dự án chỉ được ghim; `POST /api/team` nhận cả dự án ghim và dự án trong scope/team của auto. Test +1. +### Changed +- **Văn phòng 3D đẹp và dễ đọc hơn** (owner: "qúa xấu fix gấp đi"): phòng có tường, cửa sổ, sàn gỗ, bàn sáng màu, bóng mềm dưới bàn ghế; áo theo vai trò (lead xanh dương, dev xanh ngọc, qa cam, review đỏ, rd ô liu, ui tím); trạng thái hiện ở vòng sáng dưới ghế, màn hình và chấm trên bảng tên; bảng tên nhỏ gọn, đọc rõ ở cả nền sáng và tối, không che người. Test +1. +- **Nút tăng tốc ngay ở Tổng quan từng dự án**: "Ship card approved" và "Thao tác team" (Ship tốc độ cao, Sấm Sét, mở lại, dọn…) ở đầu mỗi khối dự án, đúng dự án đó. + ## [0.110.68] — 2026-10-22 ### Fixed - **v0.110.67 bị Windows Defender đỏ mừ trực tiếp** (chuỗi `powershell -Command … Stop-Process` nhúng trong binary → heuristic dropper: mọi máy Windows `8sync up` lên .67 bị quarantine mất exe — máy owner chết lệnh `8sync` hoàn toàn tối 22/10). Sweep zombie giờ dùng WMIC CSV + `taskkill` theo pid mồ côi (cha đã chết) — binary mới qua Defender (hash + chạy OK), logic tách hàm thuần `orphan_pids` + unit test: shot sống không bị đụng, mồ côi chết. diff --git a/Cargo.lock b/Cargo.lock index 632d2b9d..8e6f2a30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -200,7 +200,7 @@ dependencies = [ [[package]] name = "ccc-core" -version = "0.110.68" +version = "0.110.69" dependencies = [ "anyhow", "dirs", @@ -1503,7 +1503,7 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "su-code" -version = "0.110.68" +version = "0.110.69" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index cce74e75..0559ec27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/cli", "crates/ccc-core"] [workspace.package] -version = "0.110.68" +version = "0.110.69" edition = "2021" license = "PolyForm-Noncommercial-1.0.0" authors = ["8sync-dev <8sync.dev.1111@gmail.com>"] diff --git a/apps/hq-ui/app/api/team/route.ts b/apps/hq-ui/app/api/team/route.ts index ff53587d..1c61a0cc 100644 --- a/apps/hq-ui/app/api/team/route.ts +++ b/apps/hq-ui/app/api/team/route.ts @@ -1,6 +1,7 @@ import { withMissing, type ProjectRow } from "@/lib/server/db"; import { bad, json, open, readBody } from "@/lib/server/http"; -import { projectOf } from "@/lib/server/office"; +import { lastAutoStatus } from "@/lib/server/auto"; +import { projectOf, readPins } from "@/lib/server/office"; import { mergedSessions } from "@/lib/server/proc"; import { runBlocked } from "@/lib/server/power"; import { liveProjects } from "@/lib/server/targets"; @@ -30,8 +31,14 @@ export async function POST(req: Request) { const added = withMissing(o.db.prepare("SELECT id, path, name, added_at FROM projects").all() as ProjectRow[]) .filter((p) => !p.missing) .map((p) => p.path); - const want = root.toLowerCase(); - const known = [...live, ...added].find((r) => r.toLowerCase() === want); + // Also a pinned project and one in auto's scope or team roots: the sidebar shows them with 0 rooms, + // and "Mở team" there must work (owner 2026-10-22: ide pinned, team gone, no button). + const st = lastAutoStatus(); + const listed = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []); + const extraRoots = [...readPins().map((p) => p.root), ...listed(st?.repos), ...listed(st?.team_roots)].map((r) => r.replace(/^\\\\\?\\/, "")); + const norm = (r: string) => r.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); + const want = norm(root); + const known = [...live, ...added, ...extraRoots].find((r) => norm(r) === want); if (!known) return bad("thư mục này không có trong HQ: thêm nó ở mục Projects trước", 409); const r = await runTeam(known, action, typeof task === "string" ? task : null); return json(r, r.ok ? 200 : r.busy ? 409 : 502); diff --git a/apps/hq-ui/components/hq/live-terminal.tsx b/apps/hq-ui/components/hq/live-terminal.tsx index 4e124627..3dd8a225 100644 --- a/apps/hq-ui/components/hq/live-terminal.tsx +++ b/apps/hq-ui/components/hq/live-terminal.tsx @@ -14,15 +14,13 @@ import { FILES_MAX, fileError, toBase64 } from "@/lib/attach"; import { isLocalHost } from "@/lib/hosts"; import type { ActivityEvent } from "@/lib/server/activity"; import { activityScreen } from "@/lib/term-activity"; -import { afterStream } from "@/lib/term-stream"; +import { afterStream, giveUp, keyboardOpen, retryDelay, termRows } from "@/lib/term-stream"; /** Off loopback: no byte (not even the opening ping) this long = the proxy holds the stream back, so poll. */ const FIRST_BYTE_MS = 4000; /** Polling: gap while the terminal prints, and while it is quiet. */ const POLL_BUSY_MS = 150; const POLL_IDLE_MS = 600; -/** Polling: polls in a row that may fail (tunnel hiccup) before the terminal says so. */ -const POLL_TRIES = 5; /** Read-only view (an omp session open in another terminal): how often its Nhật ký is read again. */ const READ_ONLY_MS = 2000; @@ -78,7 +76,7 @@ function fit(el: HTMLElement, font: string): { cols: number; rows: number } { const cw = probe.getBoundingClientRect().width / 50 || 8; const ch = probe.getBoundingClientRect().height || 16; probe.remove(); - return { cols: Math.max(20, Math.floor((el.clientWidth - 16) / cw)), rows: Math.max(8, Math.floor((el.clientHeight - 8) / ch)) }; + return { cols: Math.max(20, Math.floor((el.clientWidth - 16) / cw)), rows: termRows(Math.floor((el.clientHeight - 8) / ch), null, false) }; } /** Decode SSE `data:` lines of `event: out` (base64 bytes) as they arrive. */ @@ -254,7 +252,9 @@ export function LiveTerminal({ url, kind = "room", token, onEnded }: { url: stri }); let last = size; ro = new ResizeObserver(() => { - const s = fit(el, font); + const f = fit(el, font); + // A phone keyboard makes the box short: keep the rows (the box scrolls) so a question's options stay whole. + const s = { cols: f.cols, rows: termRows(f.rows, last.rows, keyboardOpen(window.visualViewport?.height, window.innerHeight)) }; if (s.cols === last.cols && s.rows === last.rows) return; last = s; sizeRef.current = s; @@ -329,6 +329,7 @@ export function LiveTerminal({ url, kind = "room", token, onEnded }: { url: stri let gen = 0; let since = -1; let fails = 0; + let failingSince = 0; while (!ac.signal.aborted) { let p: PollJson; try { @@ -339,8 +340,12 @@ export function LiveTerminal({ url, kind = "room", token, onEnded }: { url: stri p = body; fails = 0; } catch (e) { - if (ac.signal.aborted || e instanceof PollRefused || ++fails >= POLL_TRIES) throw e; - await new Promise((r) => setTimeout(r, POLL_IDLE_MS)); + if (ac.signal.aborted || e instanceof PollRefused) throw e; + // The tunnel is down for a moment (phone asleep, Wi-Fi to 4G): say so and keep trying for a minute. + if (fails++ === 0) failingSince = Date.now(); + if (giveUp(failingSince, Date.now())) throw e; + setState("connecting"); + await new Promise((r) => setTimeout(r, retryDelay(fails))); continue; } setState("live"); diff --git a/apps/hq-ui/components/hq/office-3d.tsx b/apps/hq-ui/components/hq/office-3d.tsx index 59cdc53f..2ae465bf 100644 --- a/apps/hq-ui/components/hq/office-3d.tsx +++ b/apps/hq-ui/components/hq/office-3d.tsx @@ -7,7 +7,7 @@ import { BoxIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { NOW_LABEL, roster, type NowState } from "@/lib/now"; -import { cameraDistance, deskSign, flightPoint, handoffs, holders, layoutOffice, STATE_COLOR, type Desk, type GroupIn, type Handoff } from "@/lib/office-3d"; +import { cameraDistance, deskSign, flightPoint, handoffs, holders, layoutOffice, shirtOf, STATE_COLOR, type Desk, type GroupIn, type Handoff } from "@/lib/office-3d"; import { groupByRepo } from "@/lib/sidebar"; import { cn } from "@/lib/utils"; import { EmptyState } from "./bits"; @@ -20,30 +20,66 @@ type Hover = { key: string; label: string; state: NowState; project: string; car type Stats = { calls: number; ms: number; rooms: number }; /** A 2-line text sprite (canvas texture): 256×96 px, always faces the camera, no font loading. */ -function makeSign(T: Three, lines: [string, string], dark: boolean, loud: boolean) { +function makeSign(T: Three, lines: [string, string], dark: boolean, loud: boolean, dot: number) { + // Owner 2026-10-22 ("qúa xấu"): a small crisp name tag (2x canvas), a state dot, the card under it. const canvas = document.createElement("canvas"); - canvas.width = 256; - canvas.height = 96; + canvas.width = 512; + canvas.height = 128; const g = canvas.getContext("2d")!; - g.fillStyle = dark ? "rgba(20,24,28,0.85)" : "rgba(255,255,255,0.9)"; + g.fillStyle = dark ? "rgba(16,19,23,0.78)" : "rgba(255,255,255,0.88)"; g.beginPath(); - g.roundRect(4, 4, 248, 88, 12); + g.roundRect(8, 8, 496, 112, 56); g.fill(); - g.textAlign = "center"; - g.fillStyle = dark ? "#e8eaed" : "#1b1f24"; - g.font = "600 30px system-ui, sans-serif"; - g.fillText(lines[0].slice(0, 16), 128, 42); - g.fillStyle = loud ? "#d9822b" : dark ? "#aab0b8" : "#5c6470"; - g.font = "500 24px system-ui, sans-serif"; - g.fillText(lines[1].slice(0, 20), 128, 76); + g.fillStyle = `#${dot.toString(16).padStart(6, "0")}`; + g.beginPath(); + g.arc(58, 64, 14, 0, Math.PI * 2); + g.fill(); + g.textAlign = "left"; + g.fillStyle = dark ? "#f1f3f5" : "#14171b"; + g.font = "600 40px system-ui, sans-serif"; + g.fillText(lines[0].slice(0, 18), 92, 58); + g.fillStyle = loud ? "#e08a2c" : dark ? "#9aa3ad" : "#5c6470"; + g.font = "500 30px system-ui, sans-serif"; + g.fillText(lines[1].slice(0, 26), 92, 98); const tex = new T.CanvasTexture(canvas); tex.colorSpace = T.SRGBColorSpace; - const mat = new T.SpriteMaterial({ map: tex, transparent: true, depthTest: false }); + tex.anisotropy = 4; + const mat = new T.SpriteMaterial({ map: tex, transparent: true, depthTest: false, fog: false, toneMapped: false }); const sprite = new T.Sprite(mat); - sprite.scale.set(1.6, 0.6, 1); + sprite.scale.set(2, 0.5, 1); return sprite; } +/** A warm oak plank floor, drawn once on a canvas (no image asset). */ +function woodTexture(T: Three, dark: boolean) { + const c = document.createElement("canvas"); + c.width = 512; + c.height = 512; + const g = c.getContext("2d")!; + const base = dark ? [74, 58, 44] : [196, 162, 120]; + for (let i = 0; i < 8; i++) { + const v = ((i * 37) % 9) - 4; + g.fillStyle = `rgb(${base[0] + v * 3},${base[1] + v * 2},${base[2] + v * 2})`; + g.fillRect(0, i * 64, 512, 64); + g.fillStyle = dark ? "rgba(0,0,0,0.35)" : "rgba(60,40,20,0.25)"; + g.fillRect(0, i * 64, 512, 2); + g.fillRect(((i * 173) % 448) + 32, i * 64, 2, 64); + for (let k = 0; k < 14; k++) { + g.fillStyle = dark ? "rgba(255,255,255,0.025)" : "rgba(90,60,30,0.06)"; + g.fillRect(0, i * 64 + 4 + ((k * 29 + i * 11) % 56), 512, 1); + } + } + const tex = new T.CanvasTexture(c); + tex.colorSpace = T.SRGBColorSpace; + tex.wrapS = T.RepeatWrapping; + tex.wrapT = T.RepeatWrapping; + return tex; +} + +/** A little variety so a floor of people is not one clone. */ +const SKINS = [0xe0b18c, 0xc68d65, 0xf1c9a5, 0x9c6b4a]; +const HAIRS = [0x2a2019, 0x3b2a1d, 0x111111, 0x5a3a22]; + export function Office3D({ rooms, boards, now, repo, active, onOpen }: { rooms: Room[]; boards: BoardsState; now: number; repo: string | null; active: boolean; onOpen: (room: Room) => void }) { const host = useRef(null); const [hover, setHover] = useState(null); @@ -100,6 +136,8 @@ export function Office3D({ rooms, boards, now, repo, active, onOpen }: { rooms: renderer.shadowMap.type = T.PCFSoftShadowMap; el.appendChild(renderer.domElement); const scene = new T.Scene(); + const bg = dark ? 0x15181d : 0xeceff2; + scene.background = new T.Color(bg); const camera = new T.PerspectiveCamera(38, 1, 0.1, 200); scene.add(new T.HemisphereLight(dark ? 0xeef1f6 : 0xffffff, dark ? 0x1d1f22 : 0xd8d2c8, dark ? 1.3 : 1.5)); const sun = new T.DirectionalLight(dark ? 0xdfe6ff : 0xfff3e0, dark ? 1.6 : 2.2); @@ -111,15 +149,32 @@ export function Office3D({ rooms, boards, now, repo, active, onOpen }: { rooms: scene.add(sun, sun.target); const std = (color: number, roughness = 0.8, metalness = 0) => new T.MeshStandardMaterial({ color, roughness, metalness }); - const floorMat = std(dark ? 0x3b3e43 : 0xdfe3e6, 0.9); - const rugMat = std(dark ? 0x45484e : 0xcfd5da, 1); - const deskMat = std(dark ? 0x6b5845 : 0xc9a882, 0.55); + const wood = woodTexture(T, dark); + const floorMat = new T.MeshStandardMaterial({ map: wood, roughness: 0.7 }); + const rugMat = std(dark ? 0x2f3540 : 0xc9d1d8, 1); + const deskMat = std(dark ? 0xd9d3c7 : 0xf1ece3, 0.45); + const wallMat = std(dark ? 0x2a2e35 : 0xf4f1ec, 0.95); + const windowMat = new T.MeshStandardMaterial({ color: dark ? 0x1e2b3a : 0xbfd9ee, emissive: dark ? 0x3b5f86 : 0x9cc7ea, emissiveIntensity: dark ? 0.55 : 0.35, roughness: 0.15 }); + const blobMat = new T.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: dark ? 0.35 : 0.18, depthWrite: false }); + const ringMats = new Map>(); + const ringMat = (s: NowState) => { + let m = ringMats.get(s); + if (!m) { + m = new T.MeshBasicMaterial({ color: STATE_COLOR[s], transparent: true, opacity: s === "idle" || s === "waiting" ? 0.35 : 0.85, depthWrite: false }); + ringMats.set(s, m); + } + return m; + }; + const shirts = new Map>(); + const solid = (c: number, r = 0.7) => { + let m = shirts.get(c); + if (!m) shirts.set(c, (m = std(c, r))); + return m; + }; const metalMat = std(dark ? 0x3a3f46 : 0x5b6168, 0.4, 0.6); const plasticMat = std(0x1d2024, 0.5); const keyMat = std(dark ? 0x8a9099 : 0xd9dde1, 0.6); const chairMat = std(dark ? 0x23272d : 0x2f343b, 0.7); - const skinMat = std(0xe0b18c, 0.65); - const hairMat = std(0x2a2019, 0.9); const pantsMat = std(dark ? 0x2c3442 : 0x37425a, 0.85); const mugMat = std(0xf2efe9, 0.4); const potMat = std(dark ? 0x7a5a45 : 0xa8765a, 0.8); @@ -136,10 +191,13 @@ export function Office3D({ rooms, boards, now, repo, active, onOpen }: { rooms: const backGeo = box(0.46, 0.5, 0.05); const postGeo = new T.CylinderGeometry(0.03, 0.03, 0.4, 8); const footGeo = new T.CylinderGeometry(0.26, 0.26, 0.04, 16); - const torsoGeo = new T.CapsuleGeometry(0.17, 0.3, 4, 12); - const headGeo = new T.SphereGeometry(0.15, 16, 12); - const hairGeo = new T.SphereGeometry(0.155, 16, 8, 0, Math.PI * 2, 0, Math.PI / 2); - const armGeo = new T.CapsuleGeometry(0.05, 0.32, 3, 8); + const torsoGeo = new T.CapsuleGeometry(0.17, 0.3, 8, 20); + const headGeo = new T.SphereGeometry(0.15, 24, 18); + const hairGeo = new T.SphereGeometry(0.157, 24, 10, 0, Math.PI * 2, 0, Math.PI / 2); + const armGeo = new T.CapsuleGeometry(0.05, 0.32, 6, 12); + const wallGeo = box(1, 1, 1); + const blobGeo = new T.CircleGeometry(0.5, 24); + const ringGeo = new T.RingGeometry(0.36, 0.44, 32); const thighGeo = box(0.13, 0.12, 0.42); const shinGeo = box(0.11, 0.42, 0.11); const potGeo = new T.CylinderGeometry(0.16, 0.12, 0.3, 14); @@ -156,15 +214,6 @@ export function Office3D({ rooms, boards, now, repo, active, onOpen }: { rooms: } return m; }; - const matByState = new Map>(); - const mat = (s: NowState) => { - let m = matByState.get(s); - if (!m) { - m = new T.MeshStandardMaterial({ color: STATE_COLOR[s], roughness: 0.6, emissive: STATE_COLOR[s], emissiveIntensity: s === "needs" ? 0.35 : 0.08 }); - matByState.set(s, m); - } - return m; - }; const office = new T.Group(); scene.add(office); @@ -194,10 +243,28 @@ export function Office3D({ rooms, boards, now, repo, active, onOpen }: { rooms: }; let span = 4; for (const f of l.floors) { - const plate = new T.Mesh(new T.BoxGeometry(f.w, 0.2, f.d), floorMat); + const plateMat = floorMat.clone(); + plateMat.map = wood.clone(); + plateMat.map.repeat.set(f.w / 3, f.d / 3); + plateMat.map.needsUpdate = true; + const plate = new T.Mesh(new T.BoxGeometry(f.w, 0.2, f.d), plateMat); plate.position.set(f.x, -0.1, f.z); plate.receiveShadow = true; office.add(plate); + // Two walls (back and left) with a row of daylight windows: a room, not a slab in the void. + const H = 2.2; + const back = add(office, wallGeo, wallMat, f.x, H / 2, f.z - f.d / 2 - 0.06, false); + back.scale.set(f.w, H, 0.12); + const left = add(office, wallGeo, wallMat, f.x - f.w / 2 - 0.06, H / 2, f.z, false); + left.scale.set(0.12, H, f.d + 0.12); + for (let wx = f.x - f.w / 2 + 1.2; wx < f.x + f.w / 2 - 0.6; wx += 1.8) { + const w = add(office, wallGeo, windowMat, wx, 1.35, f.z - f.d / 2 + 0.005, false); + w.scale.set(1.2, 0.9, 0.02); + } + for (let wz = f.z - f.d / 2 + 1.2; wz < f.z + f.d / 2 - 0.6; wz += 1.8) { + const w = add(office, wallGeo, windowMat, f.x - f.w / 2 + 0.005, 1.35, wz, false); + w.scale.set(0.02, 0.9, 1.2); + } span = Math.max(span, Math.abs(f.x) + f.w / 2, Math.abs(f.z) + f.d / 2); // A plant at each corner of the project's floor. for (const [sx, sz] of [[-1, -1], [1, -1], [-1, 1], [1, 1]] as const) { @@ -219,6 +286,15 @@ export function Office3D({ rooms, boards, now, repo, active, onOpen }: { rooms: for (const d of l.desks) { // Workstation: rug, desk on four legs, monitor (glows by state), keyboard, mug, chair. add(office, rugGeo, rugMat, d.x, 0.005, d.z + 0.35, false); + // Soft contact shadows that work even where shadow maps are off, and the state ring on the floor. + const blob = add(office, blobGeo, blobMat, d.x, 0.012, d.z + 0.1, false); + blob.rotation.x = -Math.PI / 2; + blob.scale.set(1.6, 1.1, 1); + const chairBlob = add(office, blobGeo, blobMat, d.x, 0.013, d.z + 0.78, false); + chairBlob.rotation.x = -Math.PI / 2; + chairBlob.scale.setScalar(0.7); + const ring = add(office, ringGeo, ringMat(d.state), d.x, 0.016, d.z + 0.78, false); + ring.rotation.x = -Math.PI / 2; add(office, deskGeo, deskMat, d.x, 0.745, d.z); for (const [lx, lz] of [[-0.65, -0.35], [0.65, -0.35], [-0.65, 0.35], [0.65, 0.35]] as const) add(office, deskLegGeo, metalMat, d.x + lx, 0.36, d.z + lz); add(office, baseGeo, plasticMat, d.x, 0.78, d.z - 0.22); @@ -231,10 +307,13 @@ export function Office3D({ rooms, boards, now, repo, active, onOpen }: { rooms: add(office, postGeo, metalMat, d.x, 0.24, d.z + 0.78); add(office, seatGeo, chairMat, d.x, 0.47, d.z + 0.78); add(office, backGeo, chairMat, d.x, 0.8, d.z + 1.0); - // The person, seated facing the screen; the shirt carries the room's state colour. + // The person, seated facing the screen; the shirt says the role, the ring/screen/tag the state. const a = new T.Group(); a.position.set(d.x, 0, d.z + 0.72); - const shirt = mat(d.state); + const pick = Math.abs(Math.round(d.x * 13 + d.z * 7)); + const shirt = solid(shirtOf(d.role, d.label), 0.75); + const skinMat = solid(SKINS[pick % SKINS.length], 0.6); + const hairMat = solid(HAIRS[(pick >> 1) % HAIRS.length], 0.9); const torso = new T.Group(); torso.position.set(0, 0.52, 0.06); add(torso, torsoGeo, shirt, 0, 0.3, 0); @@ -264,11 +343,12 @@ export function Office3D({ rooms, boards, now, repo, active, onOpen }: { rooms: office.add(a); avatars.push({ mesh: a, desk: d, torso, armL, armR, head, seed: (d.x * 7.3 + d.z * 3.1) % 6.28 }); // The desk sign: room name and its card (owner 2026-10-22: a realistic working day, not just colours). - const sign = makeSign(T, deskSign(d), dark, d.state === "needs"); - sign.position.set(d.x, 1.35, d.z); + const sign = makeSign(T, deskSign(d), dark, d.state === "needs", STATE_COLOR[d.state]); + sign.position.set(d.x, 1.75, d.z - 0.2); office.add(sign); } const dist = cameraDistance(l); + scene.fog = new T.Fog(bg, dist * 1.1, dist * 2.4); // Closer than the plain fit: the workstations are the point, the floor edges may crop. camera.position.set(dist * 0.42, dist * 0.55, dist * 0.6); camera.lookAt(0, 0, 0); @@ -443,7 +523,7 @@ export function Office3D({ rooms, boards, now, repo, active, onOpen }: { rooms: )}

- {stats ? `${stats.rooms} phòng · ${stats.calls} draw call · ${stats.ms.toFixed(1)} ms/khung (GPU thấp, không bóng)` : "Đang dựng cảnh…"} · bấm một người để mở phòng + {stats ? `${stats.rooms} phòng · ${stats.calls} draw call · ${stats.ms.toFixed(1)} ms/khung` : "Đang dựng cảnh…"} · bấm một người để mở phòng

); diff --git a/apps/hq-ui/components/hq/office-view.tsx b/apps/hq-ui/components/hq/office-view.tsx index 6c5854b6..7aaafcd6 100644 --- a/apps/hq-ui/components/hq/office-view.tsx +++ b/apps/hq-ui/components/hq/office-view.tsx @@ -517,9 +517,16 @@ export function OfficeView({ const slash = (p: string) => p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); // Company mode: a folder already in the company's scope is served by the one team (no "Mở team" there). const scope = office?.company ? (office.scope ?? []) : []; - const idleFolders = projects.filter( + const added = projects.filter( (p) => !p.missing && !allGroups.some((g) => slash(g.project.root) === slash(p.path)) && (!repo || slash(p.path) === slash(repo)) && !inScope(scope, p.path), ); + // The project picked in the sidebar with no room running gets its team buttons too, even when it is + // only pinned or known (owner 2026-10-22, ide "0 phòng": "mất nút mở lại team"). + const picked = + repo && !allGroups.some((g) => slash(g.project.root) === slash(repo)) && !added.some((p) => slash(p.path) === slash(repo)) && !inScope(scope, repo) + ? [{ path: repo, name: repo.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || repo }] + : []; + const idleFolders = [...added, ...picked]; const labelOf = (key: string) => rooms.find((r) => r.key === key)?.label ?? key.slice(0, 8); const visits = office?.visits ?? []; const now = office?.at ?? 0; @@ -570,14 +577,20 @@ export function OfficeView({ ) : rooms.length === 0 ? ( <> - } title="Văn phòng đang trống" action={}> - {office.company + } + title={repo ? "Dự án này chưa có phòng nào chạy" : "Văn phòng đang trống"} + action={} + > + {repo && idleFolders.length + ? "Bấm “Mở team” bên dưới: phòng của dự án mở lại, nối tiếp hội thoại cũ nếu có." + : office.company ? "Công ty chưa có phòng nào chạy. Bấm “Mở team” ở một dự án: các phòng mở ra làm cho mọi dự án của công ty." : idleFolders.length ? "Bấm “Mở team” ở dự án bên dưới: HQ mở 4 phòng nền (lead, dev, qa, review) và mọi thứ hiện ở đây." : "Thêm thư mục dự án ở mục Projects bên trái, rồi bấm “Mở team” để mở 4 phòng nền (lead, dev, qa, review)."} - + ) : ( <> diff --git a/apps/hq-ui/components/hq/overview-view.tsx b/apps/hq-ui/components/hq/overview-view.tsx index 2aae8bf2..6b404133 100644 --- a/apps/hq-ui/components/hq/overview-view.tsx +++ b/apps/hq-ui/components/hq/overview-view.tsx @@ -23,8 +23,7 @@ import { PreviewImage } from "./preview-view"; import { StateTag, type RosterRow } from "./now-roster"; import { RoleIcon } from "./role-badge"; import type { Message } from "./types"; -import { ShipLoopChip } from "./ship-loop-chip"; -import { BoostButton, boostOn } from "./team-controls"; +import { TeamControls, boostOn } from "./team-controls"; import { makeApi } from "./use-hq"; import type { BoardsState } from "./use-board"; import type { OfficeState, Room } from "./use-office"; @@ -312,9 +311,9 @@ function ProjectBlock({ {rows.length ? rosterSummary(rows) : `${rooms.length} phòng`} {stuck > 0 && } - - + {/* This project's speed buttons right here (owner 2026-10-22: "các nút tăng tốc để trong văn phòng không ở ngoài tổng quan dự án"): Ship card approved; Ship tốc độ cao, Sấm Sét and the team actions behind Thao tác team. */} + {/* Hairlines between panels: each panel draws its right and bottom edge, the outer ones are clipped. */} diff --git a/apps/hq-ui/lib/office-3d.ts b/apps/hq-ui/lib/office-3d.ts index 6fd757c0..34357f9c 100644 --- a/apps/hq-ui/lib/office-3d.ts +++ b/apps/hq-ui/lib/office-3d.ts @@ -106,3 +106,11 @@ export function flightPoint(h: Handoff, p: number, height = 1.6): { x: number; y const e = 1 - Math.pow(1 - q, 3); return { x: h.from.x + (h.to.x - h.from.x) * e, y: 0.7 + Math.sin(Math.PI * q) * height, z: h.from.z + (h.to.z - h.from.z) * e }; } + +/** Shirts by role (who sits where reads at a glance); the state shows on the floor ring, the screen and the tag. */ +export const ROLE_SHIRT: Record = { + lead: 0x3f6fc0, ba: 0x4f86b8, plan: 0x4f86b8, dev: 0x2fa088, fullstack: 0x2fa088, ui: 0x9a66d6, ai: 0x9a66d6, + qa: 0xd98a4a, tester: 0xd98a4a, review: 0xc0566a, reviewer: 0xc0566a, rd: 0x8f9e3c, arch: 0x6a70c8, architect: 0x6a70c8, +}; +/** The room's role (`fullstack`, `tester`…) or its crew from the name (`dev2` → `dev`). */ +export const shirtOf = (role: string | null, label: string): number => ROLE_SHIRT[role ?? ""] ?? ROLE_SHIRT[label.toLowerCase().replace(/[0-9]+$/, "")] ?? 0x7a8696; diff --git a/apps/hq-ui/lib/server/auto-tick.ts b/apps/hq-ui/lib/server/auto-tick.ts index 8d7b03fe..08fb5248 100644 --- a/apps/hq-ui/lib/server/auto-tick.ts +++ b/apps/hq-ui/lib/server/auto-tick.ts @@ -123,16 +123,9 @@ async function tickOnce(status: AutoStatus, now: number): Promise { let revived = state.revived; if (now - revived >= REVIVE_MS) { revived = now; + // `revive` also sweeps headless Chromium left by an `8sync shot` whose 8sync died mid-render + // (native process list; the PowerShell/WMI sweep that ran here every minute is gone). void run("8sync", ["ccc", "auto", "revive"], REVIVE_MS); - // Headless Chromium left by an `8sync shot` whose 8sync died mid-render (a guard pause - // kills the room's shell tree; Chromium's children survive and burn GPU for hours — - // 2026-10-22, 47% GPU). Orphan rule only: the parent pid is gone; a live shot (any age, - // also a concurrent one) keeps its parent chain and is never touched. - void run( - "powershell", - ["-NoProfile", "-Command", `$p = Get-CimInstance Win32_Process; $alive = @({} + $p.ProcessId); $p | Where-Object { $_.Name -eq 'chrome.exe' -and $_.CommandLine -like '*8sync-shot-*' -and -not $alive.Contains($_.ParentProcessId) } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`], - REVIVE_MS, - ).catch(() => {}); } if (!office) { writeState({ ...state, at: now, revived }); diff --git a/apps/hq-ui/lib/term-stream.ts b/apps/hq-ui/lib/term-stream.ts index 38ce0ae6..7855976e 100644 --- a/apps/hq-ui/lib/term-stream.ts +++ b/apps/hq-ui/lib/term-stream.ts @@ -26,3 +26,30 @@ export function afterStream(s: StreamStop): "poll" | "fail" | "done" { if (s.remote) return "poll"; return s.threw ? "fail" : "done"; } + +/** + * Polling through the VPS tunnel (owner 2026-10-22: "Fail connect terminal rất nhiều"): a phone that + * sleeps or hops from Wi-Fi to 4G loses the tunnel for longer than five quick polls (~3 s). Keep trying + * with backoff for a minute before saying the terminal is gone. + */ +export const POLL_GIVE_UP_MS = 60_000; +/** Gap before the next try after `fails` failures in a row: 0.6 s doubling, at most 5 s. */ +export function retryDelay(fails: number): number { + return Math.min(5000, 600 * 2 ** Math.max(0, fails - 1)); +} +/** Give up only after failing without a break for POLL_GIVE_UP_MS. */ +export const giveUp = (failingSince: number, now: number): boolean => now - failingSince >= POLL_GIVE_UP_MS; + +/** + * Rows the terminal asks for (owner: "options chọn trong terminal ra thiếu có 5 toàn hiện có 3 cái + * đầu"): Claude Code draws a 5-option question in ~14 rows plus the input box, so never fewer than + * MIN_ROWS; the box scrolls instead. While a phone keyboard is open the box gets short: keep the + * rows it had rather than shrink and cut the list. + */ +export const MIN_ROWS = 22; +export function termRows(fitRows: number, prevRows: number | null, keyboardOpen: boolean): number { + const rows = Math.max(MIN_ROWS, fitRows); + return keyboardOpen && prevRows != null && rows < prevRows ? prevRows : rows; +} +/** A phone keyboard is open when the visual viewport is much shorter than the layout viewport. */ +export const keyboardOpen = (visual: number | null | undefined, layout: number): boolean => visual != null && layout > 0 && visual < layout * 0.75; diff --git a/apps/hq-ui/tests/office-3d.test.ts b/apps/hq-ui/tests/office-3d.test.ts index cc972806..e56836c0 100644 --- a/apps/hq-ui/tests/office-3d.test.ts +++ b/apps/hq-ui/tests/office-3d.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { DESK_GAP, FLOOR_PAD, MAX_COLS, STATE_COLOR, cameraDistance, deskSign, flightPoint, handoffs, holders, layoutOffice } from "@/lib/office-3d"; +import { DESK_GAP, FLOOR_PAD, MAX_COLS, STATE_COLOR, cameraDistance, deskSign, flightPoint, handoffs, holders, layoutOffice, ROLE_SHIRT, shirtOf } from "@/lib/office-3d"; import { NOW_STATES } from "@/lib/now"; const room = (i: number, state: "working" | "needs" | "idle" = "working") => ({ key: `r${i}`, label: `dev${i}`, state, role: "dev" }); @@ -68,3 +68,13 @@ describe("card handoffs between desks", () => { expect(flightPoint(h, 2)).toEqual(flightPoint(h, 1)); }); }); + +describe("shirtOf (owner 2026-10-22: the 3D office was one green clone, \"qúa xấu\")", () => { + it("dresses by role id, else by crew name with its number dropped, else a neutral grey", () => { + expect(shirtOf("tester", "anything")).toBe(ROLE_SHIRT.qa); + expect(shirtOf(null, "dev2")).toBe(ROLE_SHIRT.dev); + expect(shirtOf(null, "Lead")).toBe(ROLE_SHIRT.lead); + expect(shirtOf(null, "mystery")).toBe(0x7a8696); + expect(new Set(["lead", "dev", "qa", "review", "rd", "ui"].map((n) => shirtOf(null, n))).size).toBe(6); + }); +}); diff --git a/apps/hq-ui/tests/team.test.ts b/apps/hq-ui/tests/team.test.ts index 140cd9dc..d551a157 100644 --- a/apps/hq-ui/tests/team.test.ts +++ b/apps/hq-ui/tests/team.test.ts @@ -17,6 +17,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { POST } from "@/app/api/team/route"; import { BoostButton, BoostChoices, boostOn, TeamControls } from "@/components/hq/team-controls"; import { getToken } from "@/lib/server/auth"; +import { pinnedRootsPath } from "@/lib/server/paths"; import { onePerBoard, type ProjectBoard } from "@/lib/server/board"; import { BOOST_ONLY, BOOST_TEAM, FULL_TEAM, tail, teamArgs } from "@/lib/server/team"; import { ORIGIN, req, tempHome } from "./helpers"; @@ -98,6 +99,18 @@ describe("team", () => { expect(run).not.toHaveBeenCalled(); }); + it("also runs in a pinned project with no room running (the sidebar shows it with its team buttons)", async () => { + const pinned = fs.mkdtempSync(path.join(os.tmpdir(), "hq-team-pin-")); + expect((await post({ root: pinned, action: "up" })).status).toBe(409); + fs.mkdirSync(path.dirname(pinnedRootsPath()), { recursive: true }); + fs.writeFileSync(pinnedRootsPath(), JSON.stringify({ [pinned]: 1 })); + run.mockClear(); + const ok = await post({ root: pinned, action: "up" }); + expect(ok.status).toBe(200); + expect(run).toHaveBeenLastCalledWith("8sync", ["ccc", "auto", "up"], 300_000, pinned); + fs.rmSync(pinnedRootsPath()); + }); + it("refuses a second run in the same root while one is in flight (a second tab, a double click)", async () => { let finish: (v: unknown) => void = () => {}; run.mockImplementationOnce(() => new Promise((r) => (finish = r)) as never); diff --git a/apps/hq-ui/tests/term-stream.test.ts b/apps/hq-ui/tests/term-stream.test.ts index 48e2af24..8f995b4c 100644 --- a/apps/hq-ui/tests/term-stream.test.ts +++ b/apps/hq-ui/tests/term-stream.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { afterStream } from "@/lib/term-stream"; +import { MIN_ROWS, POLL_GIVE_UP_MS, afterStream, giveUp, keyboardOpen, retryDelay, termRows } from "@/lib/term-stream"; const base = { remote: true, aborted: false, refused: false, ended: false, threw: false }; @@ -18,3 +18,20 @@ describe("room terminal: when the stream stops", () => { expect(afterStream({ ...base, remote: false })).toBe("done"); }); }); + +describe("room terminal over a flaky tunnel", () => { + it("retries with backoff for a minute before giving up", () => { + expect([1, 2, 3, 4, 5, 9].map(retryDelay)).toEqual([600, 1200, 2400, 4800, 5000, 5000]); + expect(giveUp(0, POLL_GIVE_UP_MS - 1)).toBe(false); + expect(giveUp(0, POLL_GIVE_UP_MS)).toBe(true); + }); + it("never asks for so few rows that a 5-option question is cut, and does not shrink under a phone keyboard", () => { + expect(termRows(9, null, false)).toBe(MIN_ROWS); + expect(termRows(40, null, false)).toBe(40); + expect(termRows(12, 40, true)).toBe(40); + expect(termRows(30, 40, false)).toBe(30); + expect(keyboardOpen(300, 800)).toBe(true); + expect(keyboardOpen(780, 800)).toBe(false); + expect(keyboardOpen(null, 800)).toBe(false); + }); +}); diff --git a/assets/claude/.claude-plugin/plugin.json b/assets/claude/.claude-plugin/plugin.json index 55d3e443..126b8ed5 100644 --- a/assets/claude/.claude-plugin/plugin.json +++ b/assets/claude/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "8sync-kit", "displayName": "8sync kit", - "version": "0.110.68", + "version": "0.110.69", "description": "8 Sync kit for Claude Code: role-routed subagents (explore, task, quick-task, reviewer, designer, researcher, verifier, pm, architect, qa, critic) and the /ship /rules /sec /harden skills. Hooks, rules and the full skill set come from `8sync ccc kit`.", "author": { "name": "8 Sync", "url": "https://8syncdev.com" }, "homepage": "https://get.8syncdev.com", diff --git a/crates/cli/src/verbs/ccc.rs b/crates/cli/src/verbs/ccc.rs index 41db3dac..508f5801 100644 --- a/crates/cli/src/verbs/ccc.rs +++ b/crates/cli/src/verbs/ccc.rs @@ -54,7 +54,7 @@ mod ship_loop; mod ship_fast; pub(crate) mod resume; mod pressure; -mod reap; +pub(crate) mod reap; mod room; mod statusline; mod taskdone; @@ -245,6 +245,8 @@ pub fn run(a: Args) -> Result<()> { // scope moved here (the nested repo is out of auto by then, revive no longer opens it). let here = rest.first().is_some_and(|a| a == "on") && rest.iter().any(|a| a == "--here"); if rest.first().is_some_and(|a| a == "revive") { + // Shots whose 8sync died leave Chromium rendering: HQ runs revive every minute. + crate::verbs::shot::sweep_zombies(); // Only the roots `auto up` brought a team up in (HQ calls this without a cwd), one // revive at a time (HQ and a boot hook can both fire it). let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); diff --git a/crates/cli/src/verbs/ccc/auto.rs b/crates/cli/src/verbs/ccc/auto.rs index 46399a5d..77dd89dd 100644 --- a/crates/cli/src/verbs/ccc/auto.rs +++ b/crates/cli/src/verbs/ccc/auto.rs @@ -1177,9 +1177,13 @@ fn rush(home: &Path, root: &Path, rooms: &[(String, String)], t: &target::Target let cards = target::list(home, root)?; let early = matches!(t.lane.as_str(), "backlog" | "blocked"); let lane_of = |n: &str| super::room::find(n).and_then(|r| r.lane); + // A backlog/blocked P0 also goes to R&D (owner 2026-10-22, 3d-game: "bấm khẩn cấp gấp đợi lâu + // kinh khủng không ai làm": the lead sat in an hour-long goal while `rd` idled, and no `ba`). + // R&D writes the AC + check in the lead's place; the lead still gets its line. + let stand_in = |n: &str| early && super::room::crew(n) == "rd"; let takers: Vec<(String, String)> = rooms .iter() - .filter(|(n, _)| match lane_of(n) { + .filter(|(n, _)| stand_in(n) || match lane_of(n) { Some("backlog") => early, // The room holding the card is on it already: never called to claim it again. Some(lane) => !early && target::claimable(&cards, n, lane, now) == Some(t.id) && t.claim.as_ref().is_none_or(|c| &c.room != n), @@ -1207,7 +1211,12 @@ fn rush(home: &Path, root: &Path, rooms: &[(String, String)], t: &target::Target }; let others: Vec<(String, String)> = takers.into_iter().filter(|(n, _)| !is_lead(n)).collect(); let woke = queue_all(home, &others, now, None, |name| { - if early { + if stand_in(name) { + format!( + "{head}\nPhòng `{name}` (R&D) làm thay lead NGAY, lead có thể đang bận lượt dài: đọc kĩ card, viết AC + lệnh check, tách card con nếu lớn, rồi `8sync ccc auto move {id} ready --check \"\" --note \"AC: …\"` lượt này để builder nhận. Lead đã làm xong thì thôi.", + id = t.id + ) + } else if early { format!("{head}\n{}", target::now_prompt(t, name)) } else { format!( @@ -2607,6 +2616,32 @@ mod tests { let _ = std::fs::remove_dir_all(&h); } + #[test] + fn rush_on_a_backlog_card_also_calls_rd_to_stand_in_for_a_busy_lead() { + let h = home("rush-rd"); + let root = h.join("game"); + std::fs::create_dir_all(&root).unwrap(); + let names = ["lead", "rd", "dev", "qa"]; + let rooms: Vec<(String, String)> = names.iter().enumerate().map(|(i, n)| (n.to_string(), format!("b{i}830544-0719-4d6f-aaa3-644783ddb0b5"))).collect(); + let id = |n: &str| rooms.iter().find(|(x, _)| x == n).unwrap().1.clone(); + let inbox = |n: &str| std::fs::read_to_string(crate::verbs::hq::inbox_file(&h, &id(n)).unwrap()).unwrap_or_default(); + let (added, _) = target::add(&h, &root, "game quá xấu: làm lại", None, None, false, None, 100).unwrap(); + let t = target::list(&h, &root).unwrap().into_iter().find(|x| x.id == added.id).unwrap(); + assert_eq!(t.lane, "backlog"); + let r = rush(&h, &root, &rooms, &t, 100).unwrap(); + assert_eq!(r.lead.iter().map(|(n, _)| n.as_str()).collect::>(), ["lead"]); + assert_eq!(r.woke.iter().map(|(n, _)| n.as_str()).collect::>(), ["rd"]); + assert!(inbox("rd").contains("làm thay lead") && inbox("rd").contains(&format!("auto move {} ready", t.id)), "{}", inbox("rd")); + assert!(inbox("dev").is_empty() && inbox("qa").is_empty()); + // A ready card never calls rd: only the lane's free rooms. + let ready = target::list(&h, &root).unwrap().into_iter().find(|x| x.id == t.id).map(|mut x| { + x.lane = "ready".into(); + x + }).unwrap(); + let r2 = rush(&h, &root, &rooms, &ready, 800).unwrap(); + assert!(!r2.woke.iter().any(|(n, _)| n == "rd")); + } + /// zus/ide 2026-10-21: the owner pressed ⚡ Làm ngay on ready cards #20/#21 at P0 and nothing /// moved for 3 hours (`dev` idle after `auto move 15 qa`). The owner's P0 now tells the lead and /// calls the rooms that would take the card now; a room holding another card, a room of another diff --git a/crates/cli/src/verbs/ccc/pressure.rs b/crates/cli/src/verbs/ccc/pressure.rs index 6bf98583..585282ba 100644 --- a/crates/cli/src/verbs/ccc/pressure.rs +++ b/crates/cli/src/verbs/ccc/pressure.rs @@ -1847,6 +1847,29 @@ fn row(role: &str, state: String) -> Row { Row { name: "máy".into(), role: role.into(), model: String::new(), state, short: String::new() } } +/// A room's browser holding this share of the machine (0.1 %) on a CPU breach is a runaway. +const RUNAWAY_PERMILLE: u64 = 150; + +/// Browsers the rooms started (each tree from its top: a browser whose parent is not one) that used +/// at least [`RUNAWAY_PERMILLE`] of the machine this tick: (room, the browser's pids, share in 0.1 %). +fn runaway_browsers(procs: &[Proc], trees: &[(String, Vec)], deltas: &[(u32, u64)], ms: u64, cores: usize) -> Vec<(String, Vec, u64)> { + let mut out = Vec::new(); + for (room, t) in trees { + for p in procs.iter().filter(|p| t.contains(&p.pid) && is_browser(&p.name)) { + if procs.iter().any(|q| q.pid == p.ppid && is_browser(&q.name)) { + continue; + } + let tree = reap::tree_of(procs, p.pid, &|q| is_browser(&q.name)); + let d: u64 = deltas.iter().filter(|(pid, _)| tree.contains(pid)).map(|(_, d)| d).sum(); + let share = cpu_permille(d, ms, cores); + if share >= RUNAWAY_PERMILLE { + out.push((room.clone(), tree, share)); + } + } + } + out +} + /// CPU share in 0.1 % of the machine (the unit [`keep_count`] and [`room_fits`] take for CPU). fn cpu_permille(delta_100ns: u64, ms: u64, cores: usize) -> u64 { if ms == 0 || cores == 0 { @@ -2035,6 +2058,22 @@ fn pass(home: &Path, root: &Path, claude: &dyn Claude, registry: &[(String, Stri } } + // A browser a room started (Playwright, `8sync shot`, a QA script) holding a big share of the + // machine while CPU is over the owner's threshold is killed with its helpers, its room alive or + // not (owner 2026-10-22, "cpu chạm 45% phải kill … fix triệt để": a room's headless Edge drew + // software WebGL at 40% of 32 threads, CPU 94% with the threshold at 45%, and the guard only + // lowered its priority). The room's shot fails and is retried; the machine stays usable. + if breach.cpu.is_some() && fresh { + for (room, tree, share) in runaway_browsers(&procs, &trees, &deltas, ms, sample.cores) { + if reap::kill(&tree).first().copied().unwrap_or(false) { + *acted = true; + let text = format!("trình duyệt của phòng {room} ăn {}% CPU máy (CPU {cpu_s} ≥ ngưỡng {cpu_t}%): tắt {} tiến trình của nó", share / 10, tree.len()); + rows.push(row("CPU", text.clone())); + log(&mut st, now, &text); + } + } + } + // A high-RAM spell ends under L2: its restarts are forgotten (a room may be restarted again in // the next spell) and nothing stays asked. let mut restarted = st["restarted"].as_object().cloned().unwrap_or_default(); @@ -2401,6 +2440,36 @@ mod tests { Proc { pid, ppid, started: NOW - age_min * 60, rss: mb * MB, node: name == "node.exe", shell: matches!(name, "bash.exe" | "cmd.exe"), image, cmd: cmd.into(), name: name.into(), cpu: 0, normal: false, idle: false } } + #[test] + fn a_rooms_browser_eating_the_machine_is_a_runaway_the_owners_browser_never() { + let procs = [ + p(1, 0, 60, "claude.exe", "claude --bg", 300), + p(2, 1, 5, "node.exe", "node qa.mjs", 100), + // the room's headless Edge: main + GPU process drawing software WebGL + p(3, 2, 2, "msedge.exe", "msedge.exe --headless --user-data-dir=C:/j/tmp/edge-prof", 200), + p(4, 3, 2, "msedge.exe", "msedge.exe --type=gpu-process", 300), + p(5, 3, 2, "msedge.exe", "msedge.exe --type=renderer", 100), + // a quiet browser of another room + p(10, 0, 60, "claude.exe", "claude --bg", 300), + p(11, 10, 1, "chrome.exe", "chrome.exe --headless=new", 100), + // the owner's own Edge, busy too, in no room + p(20, 0, 600, "msedge.exe", "msedge.exe", 900), + p(21, 20, 600, "msedge.exe", "msedge.exe --type=gpu-process", 300), + ]; + let trees = vec![("qa".to_string(), vec![1, 2, 3, 4, 5]), ("dev".to_string(), vec![10, 11])]; + // 1 s on 32 cores: 32e7 units of 100 ns = the whole machine. The Edge GPU: 40 %. + let deltas = [(4, 128_000_000), (5, 3_000_000), (11, 3_000_000), (21, 128_000_000), (2, 30_000_000)]; + let r = runaway_browsers(&procs, &trees, &deltas, 1000, 32); + assert_eq!(r.len(), 1); + assert_eq!(r[0].0, "qa"); + let mut t = r[0].1.clone(); + t.sort(); + assert_eq!(t, [3, 4, 5], "the browser tree only, never the room's node or claude"); + assert_eq!(r[0].2 / 10, 40); + // under the bar: nothing + assert!(runaway_browsers(&procs, &trees, &[(4, 30_000_000)], 1000, 32).is_empty()); + } + #[test] fn breaches_act_at_once_and_escalate_on_time_held() { let t = Limits { ram: 60, cpu: 60 }; diff --git a/crates/cli/src/verbs/ccc/reap.rs b/crates/cli/src/verbs/ccc/reap.rs index 4050ef11..c0a4a9be 100644 --- a/crates/cli/src/verbs/ccc/reap.rs +++ b/crates/cli/src/verbs/ccc/reap.rs @@ -28,7 +28,7 @@ const PRESSURE_SERVER_AGE_S: u64 = 10 * 60; /// A process as the OS lists it (every image: a parent must be seen to tell an orphan). #[derive(Debug, Clone, PartialEq, Default)] -pub(super) struct Proc { +pub(crate) struct Proc { pub pid: u32, pub ppid: u32, /// Start time (unix seconds), 0 when the OS did not say. @@ -422,7 +422,7 @@ fn describe(p: &Proc, now: u64) -> String { /// Kills each pid alone (`taskkill /F` without `/T`, which would follow stale parent links); /// `true` where the kill went through. Never by image name. -pub(super) fn kill(pids: &[u32]) -> Vec { +pub(crate) fn kill(pids: &[u32]) -> Vec { #[cfg(windows)] { use std::os::windows::process::CommandExt; @@ -459,10 +459,18 @@ pub(super) fn kill(pids: &[u32]) -> Vec { } /// Every process on this machine with its command line (empty when the OS listing fails). -pub(super) fn list(now: u64) -> Vec { +pub(crate) fn list(now: u64) -> Vec { #[cfg(windows)] { let _ = now; + // Native first (owner 2026-10-22: "fix tận gốc … ram, cpu"): every guard tick of every + // project ran `Get-CimInstance Win32_Process` with command lines, and WmiPrvSE grew to + // 12.7 GB RAM / 22% CPU on the owner's PC. ToolHelp + per-process queries answer in + // milliseconds and never touch WMI. WMI stays only as the fallback when that comes back empty. + let native = win_procs::list(); + if !native.is_empty() { + return native; + } use std::os::windows::process::CommandExt; // UTF-8 out: in the OEM code page a room's command line (its Vietnamese goal prompt) came // out with `→` as a raw 0x1A, and the whole listing failed to parse (seen live 2026-10-21). @@ -500,6 +508,189 @@ pub(super) fn list(now: u64) -> Vec { } } +/// Every process through the Win32 API, no WMI: a ToolHelp snapshot (pid, parent, image, base +/// priority), then per process with `PROCESS_QUERY_LIMITED_INFORMATION`: times (start, CPU), working +/// set and the command line (`NtQueryInformationProcess` class 60, Windows 8.1+). A process this +/// user may not open keeps its snapshot fields, the rest 0 / empty, as WMI gave for it. +#[cfg(windows)] +mod win_procs { + use super::{image_of, is_shell, Proc}; + use std::ffi::c_void; + + type Handle = *mut c_void; + const INVALID: Handle = -1isize as Handle; + const TH32CS_SNAPPROCESS: u32 = 0x2; + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + const PROCESS_COMMAND_LINE_INFORMATION: i32 = 60; + const STATUS_INFO_LENGTH_MISMATCH: i32 = 0xC000_0004_u32 as i32; + /// FILETIME of 1970-01-01 (100 ns since 1601). + const UNIX_EPOCH_FT: u64 = 116_444_736_000_000_000; + + #[repr(C)] + struct ProcessEntry { + size: u32, + usage: u32, + pid: u32, + heap: usize, + module: u32, + threads: u32, + ppid: u32, + pri_base: i32, + flags: u32, + exe: [u16; 260], + } + #[repr(C)] + #[derive(Default, Clone, Copy)] + struct FileTime { + low: u32, + high: u32, + } + #[repr(C)] + #[derive(Default)] + struct MemCounters { + cb: u32, + page_faults: u32, + peak_ws: usize, + ws: usize, + quota_peak_paged: usize, + quota_paged: usize, + quota_peak_nonpaged: usize, + quota_nonpaged: usize, + pagefile: usize, + peak_pagefile: usize, + } + #[repr(C)] + struct UnicodeString { + len: u16, + max: u16, + buf: *const u16, + } + + #[link(name = "kernel32")] + extern "system" { + fn CreateToolhelp32Snapshot(flags: u32, pid: u32) -> Handle; + fn Process32FirstW(snap: Handle, entry: *mut ProcessEntry) -> i32; + fn Process32NextW(snap: Handle, entry: *mut ProcessEntry) -> i32; + fn OpenProcess(access: u32, inherit: i32, pid: u32) -> Handle; + fn GetProcessTimes(h: Handle, create: *mut FileTime, exit: *mut FileTime, kernel: *mut FileTime, user: *mut FileTime) -> i32; + fn K32GetProcessMemoryInfo(h: Handle, counters: *mut MemCounters, cb: u32) -> i32; + fn CloseHandle(h: Handle) -> i32; + } + #[link(name = "ntdll")] + extern "system" { + fn NtQueryInformationProcess(h: Handle, class: i32, info: *mut c_void, len: u32, ret: *mut u32) -> i32; + } + + fn ft(t: FileTime) -> u64 { + (u64::from(t.high) << 32) | u64::from(t.low) + } + + /// The command line of an open process handle; empty when the OS refuses. + fn command_line(h: Handle) -> String { + // u64 words keep the UNICODE_STRING header aligned at the start of the buffer. + let mut words: Vec = vec![0; 1024]; + for _ in 0..2 { + let bytes = (words.len() * 8) as u32; + let mut need = 0u32; + // SAFETY: `words` is a writable buffer of `bytes` bytes owned by us; `need` is a valid out pointer. + let st = unsafe { NtQueryInformationProcess(h, PROCESS_COMMAND_LINE_INFORMATION, words.as_mut_ptr().cast(), bytes, &mut need) }; + if st == STATUS_INFO_LENGTH_MISMATCH && need > bytes && need < 1 << 22 { + words = vec![0; (need as usize).div_ceil(8)]; + continue; + } + if st < 0 { + return String::new(); + } + // SAFETY: on success the buffer starts with a UNICODE_STRING whose `buf` points into the + // same buffer, `len` bytes long; both checked against the buffer bounds below. + let us = unsafe { &*(words.as_ptr().cast::()) }; + let base = words.as_ptr() as usize; + let end = base + words.len() * 8; + let start = us.buf as usize; + let n = usize::from(us.len) / 2; + if us.buf.is_null() || start < base || start + n * 2 > end { + return String::new(); + } + // SAFETY: bounds checked just above; u16 reads inside our own buffer. + let s = unsafe { std::slice::from_raw_parts(us.buf, n) }; + return String::from_utf16_lossy(s); + } + String::new() + } + + pub(super) fn list() -> Vec { + // SAFETY: plain snapshot call; the handle is closed below. + let snap = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snap == INVALID || snap.is_null() { + return Vec::new(); + } + let mut out = Vec::new(); + // SAFETY: zeroed POD struct with its size set, as the API requires. + let mut e: ProcessEntry = unsafe { std::mem::zeroed() }; + e.size = std::mem::size_of::() as u32; + // SAFETY: valid snapshot handle and entry pointer. + let mut ok = unsafe { Process32FirstW(snap, &mut e) } != 0; + while ok { + let len = e.exe.iter().position(|&c| c == 0).unwrap_or(e.exe.len()); + let name = String::from_utf16_lossy(&e.exe[..len]); + let (mut started, mut cpu, mut rss, mut cmd) = (0u64, 0u64, 0u64, String::new()); + // SAFETY: OpenProcess with limited query rights; a null handle means no access and is skipped. + let h = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, e.pid) }; + if !h.is_null() { + let (mut c, mut x, mut k, mut u) = (FileTime::default(), FileTime::default(), FileTime::default(), FileTime::default()); + // SAFETY: valid handle and four out pointers to our locals. + if unsafe { GetProcessTimes(h, &mut c, &mut x, &mut k, &mut u) } != 0 { + started = ft(c).saturating_sub(UNIX_EPOCH_FT) / 10_000_000; + cpu = ft(k) + ft(u); + } + let mut m = MemCounters { cb: std::mem::size_of::() as u32, ..Default::default() }; + // SAFETY: valid handle, counters struct with its size set. + if unsafe { K32GetProcessMemoryInfo(h, &mut m, m.cb) } != 0 { + rss = m.ws as u64; + } + cmd = command_line(h); + // SAFETY: closing the handle we opened. + unsafe { CloseHandle(h) }; + } + out.push(Proc { + pid: e.pid, + ppid: e.ppid, + started, + rss, + node: name.eq_ignore_ascii_case("node.exe"), + shell: is_shell(&name), + image: image_of(&name), + cmd, + name: name.to_ascii_lowercase(), + cpu, + // Base priority, as WMI's `Priority`: 8 normal, 10 above normal, 4 idle. + normal: (8..=10).contains(&e.pri_base), + idle: e.pri_base == 4, + }); + // SAFETY: same snapshot and entry. + ok = unsafe { Process32NextW(snap, &mut e) } != 0; + } + // SAFETY: closing the snapshot we created. + unsafe { CloseHandle(snap) }; + out + } + + #[cfg(test)] + mod tests { + #[test] + fn lists_this_process_with_its_command_line_and_parent_without_wmi() { + let me = std::process::id(); + let all = super::list(); + assert!(all.len() > 10, "a Windows machine runs more than 10 processes"); + let p = all.iter().find(|p| p.pid == me).expect("this test process is listed"); + assert!(p.ppid != 0); + assert!(p.started > 1_600_000_000, "start time in unix seconds"); + assert!(p.rss > 0); + assert!(p.cmd.to_lowercase().contains(&p.image), "command line names the image: {} / {}", p.cmd, p.image); + } + } +} + /// `ConvertTo-Json` of the `Win32_Process` rows: an array, or one object for a single process. A raw /// control character (never valid JSON, inside a string or out) reads as a space. #[cfg_attr(not(any(windows, test)), allow(dead_code))] diff --git a/crates/cli/src/verbs/shot.rs b/crates/cli/src/verbs/shot.rs index 17d942eb..4e3bf738 100644 --- a/crates/cli/src/verbs/shot.rs +++ b/crates/cli/src/verbs/shot.rs @@ -5,6 +5,7 @@ use std::process::Command; use std::time::{Duration, Instant}; use crate::ui; +use crate::verbs::ccc::reap::{self, Proc}; #[derive(ClapArgs, Debug)] #[command(after_help = indoc::indoc! {" @@ -117,7 +118,7 @@ pub fn run(a: Args) -> Result<()> { let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos()); let profile = std::env::temp_dir().join(format!("8sync-shot-{}-{nanos}", std::process::id())); // Shots whose 8sync died mid-render leave Chromium burning GPU (see [`sweep_zombies`]). - sweep_zombies(&profile); + sweep_zombies(); let mut cmd = low_priority(&chrome); cmd.args([ "--headless".into(), @@ -149,7 +150,7 @@ pub fn run(a: Args) -> Result<()> { _ => {} } // Helpers that outlived the browser (or an 8sync killed mid-render) must not keep rendering. - sweep_zombies(&profile); + sweep_zombies(); // Chromium's helpers may hold the profile a moment after the browser exits. for _ in 0..10 { if std::fs::remove_dir_all(&profile).is_ok() || !profile.exists() { @@ -214,66 +215,37 @@ fn kill_tree(child: &mut std::process::Child) { /// The marker every shot's throwaway profile carries (`--user-data-dir=…8sync-shot-…`). const SHOT_MARK: &str = "8sync-shot-"; -/// Kill Chromium processes left over from earlier shots whose `8sync` died mid-render (the auto -/// guard pausing a room kills the room's shell tree; Chromium's children survive it and a stuck one -/// burned ~20% GPU for hours, 2026-10-22). Ours (`keep`) is spared. Best effort, never fails a shot. -fn sweep_zombies(_keep: &std::path::Path) { - // Only true orphans: a marker Chromium whose parent pid is gone (the `8sync shot` that spawned - // it died — e.g. the RAM guard paused the room mid-render). A LIVE shot's Chromium keeps its - // parent chain, so concurrent shots (two rooms shooting at once, 2026-10-22) never kill each - // other — the v0.110.64 sweep matched every marker and did exactly that. - #[cfg(windows)] - { - // No embedded PowerShell here: Defender's heuristics flagged the whole v0.110.67 binary - // for the `powershell -Command … Stop-Process` string baked into it (2026-10-22: every - // Windows `8sync up` to .67 quarantined the exe). WMIC CSV + `taskkill` per orphan pid - // says the same thing without looking like a dropper; a machine without wmic skips. - let out = Command::new("wmic") - .args(["process", "get", "processid,parentprocessid,commandline", "/format:csv"]) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) - .output(); - if let Ok(o) = out { - for pid in orphan_pids(&String::from_utf8_lossy(&o.stdout), SHOT_MARK) { - let _ = Command::new("taskkill") - .args(["/F", "/PID", &pid.to_string()]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(); - } - } - } - #[cfg(not(windows))] - { - // Kill a marker Chromium only when its parent pid is gone (a live shot keeps its parent - // chain; concurrent shots never touch each other). - let _ = Command::new("sh") - .arg("-c") - .arg(format!("ps -eo pid=,ppid=,args= | grep '[c]hrome.*{SHOT_MARK}' | while read pid ppid rest; do kill -0 \"$ppid\" 2>/dev/null || kill \"$pid\" 2>/dev/null || true; done")) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(); - } +/// The 8sync that started a shot, from its profile in the command line (`8sync-shot--`). +fn shot_owner(cmd: &str) -> Option { + let i = cmd.find(SHOT_MARK)? + SHOT_MARK.len(); + cmd[i..].split('-').next()?.parse().ok() } -/// WMIC `process get … /format:csv`: the pids of rows that mention `mark` (a shot's Chromium) and -/// whose parent pid no longer exists in the same listing (a true orphan). A live shot keeps its -/// parent chain, so concurrent shots never match. -fn orphan_pids(csv: &str, mark: &str) -> Vec { - let alive: Vec = csv - .lines() - .filter_map(|l| l.rsplit(',').next()?.trim().parse::().ok()) - .collect(); - csv.lines() - .filter(|l| l.contains(mark) && l.to_ascii_lowercase().contains("chrome")) - .filter_map(|l| { - let pid = l.rsplit(',').next()?.trim().parse::().ok()?; - let ppid = l.rsplit(',').nth(1)?.trim().parse::().ok()?; - (!alive.contains(&ppid)).then_some(pid) - }) +/// Chromium processes of shots whose `8sync` is gone: the auto guard pausing a room kills the +/// room's shell tree, Chromium's children survive it and a stuck one burned ~20% GPU for hours +/// (2026-10-22). A shot whose 8sync still runs is never touched: the first sweep killed every +/// other shot, so rooms shooting at the same time killed each other's Chromium ("Chromium exited +/// non-zero" on every WebGL page, 3d-game #184/#185, 2026-10-22). +pub(crate) fn zombies(procs: &[Proc]) -> Vec { + let images: std::collections::HashMap = procs.iter().map(|p| (p.pid, p.image.as_str())).collect(); + procs + .iter() + .filter(|p| p.image.contains("chrom") || p.image.contains("msedge")) + .filter(|p| shot_owner(&p.cmd).is_some_and(|o| !images.get(&o).is_some_and(|i| i.contains("8sync")))) + .map(|p| p.pid) .collect() } +/// Kill [`zombies`] (native process list, no WMI). Best effort, never fails a shot. `8sync ccc auto +/// revive` (HQ, every minute) calls it too. +pub(crate) fn sweep_zombies() { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + let z = zombies(&reap::list(now)); + if !z.is_empty() { + let _ = reap::kill(&z); + } +} + #[cfg(test)] mod tests { use super::*; @@ -295,12 +267,24 @@ mod tests { } #[test] - fn orphan_sweep_kills_only_parentless_chromium() { - let csv = "Node,CommandLine,ParentProcessId,ProcessId\r\n\\HOST,8sync.exe shot,100,100\r\n\\HOST,\"chrome.exe\" --user-data-dir=C:\\Temp\\8sync-shot-1-a,100,101\r\n\\HOST,\"chrome.exe\" --type=gpu-process --user-data-dir=C:\\Temp\\8sync-shot-1-a,101,102\r\n\\HOST,\"chrome.exe\" --user-data-dir=C:\\Temp\\8sync-shot-2-b,999,201\r\n\\HOST,\"chrome.exe\" --user-data-dir=C:\\Temp\\8sync-shot-2-b,201,202\r\n\\HOST,explorer.exe,7,7\r\n"; - // Live shot 1-a (parent 100 alive): nothing. Shot 2-b's root parent 999 is gone: both its - // processes are orphans (201 parentless; 202's parent 201 still alive but 201 itself is a - // goner-to-be, so killing 201 suffices; 202 is spared this pass and dies with Chrome). - let pids = orphan_pids(csv, "8sync-shot-"); - assert_eq!(pids, vec![201]); + fn only_shots_whose_8sync_is_gone_are_zombies() { + let p = |pid: u32, image: &str, cmd: &str| Proc { pid, image: image.into(), cmd: cmd.into(), ..Default::default() }; + let procs = [ + p(10, "8sync", "8sync shot http://x"), + // a live shot of another room (owner 10 runs): spared + p(11, "chrome", r"chrome.exe --user-data-dir=C:\T\8sync-shot-10-123 --headless"), + // its owner died: swept, the GPU helper too + p(12, "chrome", r"chrome.exe --user-data-dir=C:\T\8sync-shot-77-5 --headless"), + p(13, "chrome", r"chrome.exe --type=gpu-process --user-data-dir=C:\T\8sync-shot-77-5"), + // the owner pid was reused by something else: still a zombie + p(20, "node", "node server.js"), + p(21, "chrome", "chrome --user-data-dir=/tmp/8sync-shot-20-9"), + // a browser of the owner's own and other processes naming the mark are not ours to kill + p(30, "chrome", "chrome.exe --profile-directory=Default"), + p(31, "bash", "grep 8sync-shot-77-"), + ]; + assert_eq!(zombies(&procs), [12, 13, 21]); + assert_eq!(shot_owner("x 8sync-shot-4242-77 y"), Some(4242)); + assert_eq!(shot_owner("8sync-shot-x"), None); } }