From 47c29c723a5c8130987fa9f9a6014e9b6295e7a5 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 23 Mar 2026 13:17:11 +0800 Subject: [PATCH] feat: harden boss local v1 runtime --- .gitignore | 2 +- README.md | 62 +++- public/app.js | 614 +++++++++++++++++++++++++++++++------ public/favicon.svg | 14 + public/index.html | 17 +- public/styles.css | 106 +++++++ scripts/claude_executor.sh | 40 +++ scripts/codex_executor.sh | 40 +++ src/demo.ts | 1 + src/engine.ts | 60 +++- src/server.ts | 11 +- src/smoke.ts | 221 +++++++++++-- src/worker.ts | 271 ++++++++++++++-- 13 files changed, 1281 insertions(+), 178 deletions(-) create mode 100644 public/favicon.svg create mode 100755 scripts/claude_executor.sh create mode 100755 scripts/codex_executor.sh diff --git a/.gitignore b/.gitignore index 1f24404..3b88bf4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ node_modules dist .boss-data +.playwright-cli npm-debug.log* - diff --git a/README.md b/README.md index 1fa87d7..4dfe354 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Boss 是一个面向多设备开发协作的 agent control plane。 ## 当前状态 -当前仓库已经完成第一轮产品设计文档: +当前仓库已经包含产品设计文档与一版可直接跑起来的本地控制台: - [文档总览](./docs/README.md) - [竞品对比](./docs/competitor-comparison.md) @@ -16,13 +16,14 @@ Boss 是一个面向多设备开发协作的 agent control plane。 - [消息协议与状态机](./docs/message-protocol-and-state-machine.md) - [实施路线图](./docs/implementation-roadmap.md) -并且已经补入首版可运行原型: +当前原型能力: - Fastify API - 文件持久化状态存储 - SSE 实时事件流 - Web 控制台 - `boss-worker` 模拟执行器 +- `boss-worker` 外部命令执行模式,可接本地 Codex / Claude / 自定义脚本 - `npm run smoke` 自动跑端到端验证 - `Dockerfile` + `compose.yaml` 支持容器启动 @@ -34,15 +35,6 @@ Boss 是一个面向多设备开发协作的 agent control plane。 - 工具层:MCP - 调度:持久队列或工作流引擎 -## 下一步 - -建议直接开始: - -1. 建立 Web 控制台和后端骨架 -2. 实现 `boss-worker` 注册与心跳 -3. 打通会话、任务树和子任务分发 -4. 接入审批和中途重规划 - ## 本地运行 ```bash @@ -62,6 +54,12 @@ http://127.0.0.1:43210 npm run dev ``` +如果你想把数据写到独立文件,避免和默认 demo 数据混用: + +```bash +BOSS_DATA_FILE=.boss-data/local-dev.json npm run dev +``` + 如果你要手工启动 worker: ```bash @@ -70,6 +68,46 @@ npm run worker -- --name win-b --os windows --capability terminal --capability t npm run worker -- --name mac-a --os macos --capability terminal --capability test --capability browser ``` +如果你要接真实本地执行器,而不是模拟执行: + +```bash +npm run worker -- \ + --name mac-codex \ + --os macos \ + --capability terminal \ + --capability test \ + --mode command \ + --workspace /path/to/project \ + --executor ./scripts/codex_executor.sh +``` + +也可以接 Claude Code 或任意你自己的脚本,只要命令能从环境变量里读取任务上下文: + +- `BOSS_TASK_TITLE` +- `BOSS_TASK_DESCRIPTION` +- `BOSS_TASK_KIND` +- `BOSS_TASK_JSON` +- `BOSS_WORKSPACE` +- `BOSS_WORKER_NAME` + +仓库里已经自带两个可直接改造的适配脚本: + +- `./scripts/codex_executor.sh` +- `./scripts/claude_executor.sh` + +例如: + +```bash +npm run worker -- \ + --name win-claude \ + --os windows \ + --capability terminal \ + --capability browser \ + --mode command \ + --workspace /path/to/project \ + --executor ./scripts/claude_executor.sh +``` + 一键本地 demo: ```bash @@ -100,6 +138,8 @@ docker compose up --build - 创建项目会话并持续对话 - 自动生成任务树并调度到不同 worker - worker 心跳、掉线回收、任务重排 +- worker 真实外部命令执行,支持本地命令适配 - 审批、暂停、恢复、取消、重排 - SSE 实时事件流和 Web 控制台 +- 会话归档与恢复 - 一键 demo 启动 diff --git a/public/app.js b/public/app.js index 35244d9..920b5bb 100644 --- a/public/app.js +++ b/public/app.js @@ -6,8 +6,18 @@ const state = { workers: [], events: [], selectedSessionId: null, + connection: "connecting", + banner: null, }; +const TASK_GROUPS = [ + { title: "进行中", statuses: ["assigned", "running"] }, + { title: "等待处理", statuses: ["planning", "queued"] }, + { title: "等待审批或阻塞", statuses: ["waiting_approval", "blocked", "paused"] }, + { title: "已完成", statuses: ["completed"] }, + { title: "已结束", statuses: ["failed", "cancelled"] }, +]; + const elements = { sessionList: document.querySelector("#session-list"), workerList: document.querySelector("#worker-list"), @@ -28,8 +38,14 @@ const elements = { messageInput: document.querySelector("#message-input"), resetDemo: document.querySelector("#reset-demo"), archiveSession: document.querySelector("#archive-session"), + streamStatus: document.querySelector("#stream-status"), + appBanner: document.querySelector("#app-banner"), + onboarding: document.querySelector("#onboarding"), }; +let bannerTimer = null; +let fallbackPoll = null; + function escapeHtml(input) { return String(input) .replaceAll("&", "&") @@ -39,6 +55,40 @@ function escapeHtml(input) { .replaceAll("'", "'"); } +function setBanner(message, tone = "info", persistent = false) { + state.banner = { message, tone }; + renderBanner(); + + if (bannerTimer) { + window.clearTimeout(bannerTimer); + bannerTimer = null; + } + + if (!persistent) { + bannerTimer = window.setTimeout(() => { + if (state.banner?.message === message) { + clearBanner(); + } + }, 3200); + } +} + +function clearBanner() { + state.banner = null; + renderBanner(); +} + +function renderBanner() { + if (!state.banner) { + elements.appBanner.className = "banner hidden"; + elements.appBanner.textContent = ""; + return; + } + + elements.appBanner.className = `banner ${state.banner.tone}`; + elements.appBanner.textContent = state.banner.message; +} + async function request(url, options = {}) { const response = await fetch(url, { headers: { "Content-Type": "application/json" }, @@ -53,16 +103,57 @@ async function request(url, options = {}) { } if (!response.ok) { - throw new Error(payload.error || `${response.status} ${response.statusText}`); + const message = payload.message || payload.error || `${response.status} ${response.statusText}`; + throw new Error(message); } return payload; } +async function runAction(label, operation, successMessage, options = {}) { + const { + refreshBootstrap = true, + refreshSession = Boolean(state.selectedSessionId), + persistent = false, + } = options; + + setBanner(`${label}中...`, "info", true); + + try { + const result = await operation(); + if (refreshBootstrap) { + await loadBootstrap(); + } + if (refreshSession && state.selectedSessionId) { + await loadSession(state.selectedSessionId); + } + render(); + if (successMessage) { + setBanner(successMessage, "success", persistent); + } else { + clearBanner(); + } + return result; + } catch (error) { + console.error(error); + setBanner(`${label}失败:${error.message}`, "error", true); + render(); + return null; + } +} + function selectedSession() { return state.sessions.find((session) => session.id === state.selectedSessionId) ?? null; } +function findTask(taskId) { + return state.tasks.find((task) => task.id === taskId) ?? null; +} + +function findWorker(workerId) { + return state.workers.find((worker) => worker.id === workerId) ?? null; +} + function tasksForSelectedSession() { return state.tasks.filter((task) => task.sessionId === state.selectedSessionId); } @@ -82,6 +173,60 @@ function eventsForSelectedSession() { .reverse(); } +function formatClock(value) { + return new Date(value).toLocaleTimeString(); +} + +function formatAge(value) { + const diffMs = Date.now() - new Date(value).getTime(); + const seconds = Math.max(0, Math.round(diffMs / 1000)); + if (seconds < 5) { + return "刚刚"; + } + if (seconds < 60) { + return `${seconds} 秒前`; + } + const minutes = Math.round(seconds / 60); + if (minutes < 60) { + return `${minutes} 分钟前`; + } + const hours = Math.round(minutes / 60); + return `${hours} 小时前`; +} + +function workerHealth(worker) { + if (worker.status === "offline") { + return { + tone: "offline", + label: "离线", + description: "已手动下线或被系统判定为离线。", + }; + } + + const ageMs = Date.now() - new Date(worker.lastSeenAt).getTime(); + if (ageMs > 30_000) { + return { + tone: "stale", + label: "疑似掉线", + description: `最近心跳 ${formatAge(worker.lastSeenAt)}`, + }; + } + + if (ageMs > 10_000) { + return { + tone: "lagging", + label: "连接抖动", + description: `最近心跳 ${formatAge(worker.lastSeenAt)}`, + }; + } + + return { + tone: "live", + label: "在线", + description: `最近心跳 ${formatAge(worker.lastSeenAt)}`, + }; +} + function renderSessions() { if (state.sessions.length === 0) { elements.sessionList.innerHTML = `

先创建一个项目会话。

`; @@ -117,24 +262,116 @@ function renderWorkers() { return; } - elements.workerList.innerHTML = state.workers - .map( - (worker) => ` + elements.workerList.innerHTML = [...state.workers] + .sort((left, right) => left.name.localeCompare(right.name)) + .map((worker) => { + const currentTask = findTask(worker.currentTaskId); + const health = workerHealth(worker); + return `
${escapeHtml(worker.name)} - ${escapeHtml(worker.status)} +
+ ${escapeHtml(worker.status)} + ${escapeHtml(health.label)} +
${escapeHtml(worker.os)}
-
current: ${escapeHtml(worker.currentTaskId || "idle")}
-
last seen: ${new Date(worker.lastSeenAt).toLocaleTimeString()}
+
当前任务:${escapeHtml(currentTask?.title || "空闲")}
+
${escapeHtml(health.description)}
+
负载:${escapeHtml(worker.load)}
${worker.capabilities.map((capability) => `${escapeHtml(capability)}`).join("")}
+
+ ${ + worker.status !== "offline" + ? `` + : "" + } +
- `, - ) + `; + }) .join(""); + + elements.workerList.querySelectorAll("[data-worker-action]").forEach((button) => { + button.addEventListener("click", async () => { + const workerId = button.dataset.workerId; + await runAction( + "更新 worker 状态", + () => request(`/api/workers/${workerId}/offline`, { method: "POST", body: "{}" }), + "worker 已标记离线。", + { refreshSession: false }, + ); + }); + }); +} + +function renderStreamStatus() { + const labels = { + connecting: "连接中", + live: "实时同步", + reconnecting: "重连中", + }; + elements.streamStatus.className = `badge ${state.connection}`; + elements.streamStatus.textContent = labels[state.connection] ?? "未知状态"; +} + +function renderOnboarding() { + const needsOnboarding = state.workers.length === 0 || state.sessions.length === 0; + elements.onboarding.classList.toggle("hidden", !needsOnboarding); + + if (!needsOnboarding) { + elements.onboarding.innerHTML = ""; + return; + } + + const steps = [ + { + done: state.workers.length > 0, + title: "接入至少一台设备 worker", + detail: + state.workers.length > 0 + ? `当前已登记 ${state.workers.length} 台设备,可以开始接单。` + : "最快路径是运行 `npm run demo`,它会同时起服务和 3 台示例 worker。", + }, + { + done: state.sessions.length > 0, + title: "创建一个项目会话", + detail: + state.sessions.length > 0 + ? "会话已经创建,可以持续对话和改需求。" + : "左侧填写项目标题创建会话,之后右侧就能开始对话式调度。", + }, + { + done: Boolean(state.selectedSessionId && messagesForSelectedSession().length > 0), + title: "给主控发送第一条需求", + detail: + state.selectedSessionId && messagesForSelectedSession().length > 0 + ? "系统已经收到了需求,正在持续调度子任务。" + : "示例:先调研登录失败根因,不要急着改代码。", + }, + ]; + + elements.onboarding.innerHTML = ` +
+

首次上手

+ 按这 3 步就能跑通一轮协同开发 +
+
+ ${steps + .map( + (step) => ` +
+ ${step.done ? "已完成" : "待完成"} · ${escapeHtml(step.title)} +

${escapeHtml(step.detail)}

+
+ `, + ) + .join("")} +
+ `; } function renderSessionHeader() { @@ -143,6 +380,7 @@ function renderSessionHeader() { elements.sessionTitleDisplay.textContent = "选择一个项目会话"; elements.sessionSummary.textContent = "创建会话后,在这里持续对话并观察任务状态。"; elements.archiveSession.disabled = true; + elements.archiveSession.textContent = "归档会话"; elements.messageInput.disabled = true; elements.messageInput.placeholder = "先创建或选择一个项目会话。"; return; @@ -151,11 +389,12 @@ function renderSessionHeader() { elements.sessionTitleDisplay.textContent = `${session.title} (${session.status})`; elements.sessionSummary.textContent = session.lastPlannerSummary || session.activeObjective || "等待用户输入。"; - elements.archiveSession.disabled = session.status === "archived"; + elements.archiveSession.disabled = false; + elements.archiveSession.textContent = session.status === "archived" ? "恢复会话" : "归档会话"; elements.messageInput.disabled = session.status === "archived"; elements.messageInput.placeholder = session.status === "archived" - ? "当前会话已归档,不能继续发送消息。" + ? "当前会话已归档。先恢复会话,再继续发送消息。" : "输入需求。示例:先调研登录失败根因,不要急着改代码。"; } @@ -168,7 +407,7 @@ function renderMessages() {
${escapeHtml(message.role)} - ${new Date(message.createdAt).toLocaleTimeString()} + ${formatClock(message.createdAt)}

${escapeHtml(message.content)}

@@ -178,52 +417,101 @@ function renderMessages() { : `

当前没有消息。

`; } +function taskGroupSections() { + const tasks = tasksForSelectedSession(); + return TASK_GROUPS.map((group) => ({ + ...group, + tasks: tasks.filter((task) => group.statuses.includes(task.status)), + })).filter((group) => group.tasks.length > 0); +} + +function renderTaskCard(task) { + const worker = findWorker(task.assignedWorkerId); + const dependencyTitles = task.dependencyIds + .map((taskId) => findTask(taskId)?.title) + .filter(Boolean); + const pendingApproval = approvalsForSelectedSession().find( + (approval) => approval.taskId === task.id && approval.status === "pending", + ); + + return ` +
+
+ ${escapeHtml(task.title)} + ${escapeHtml(task.status)} +
+

${escapeHtml(task.description)}

+
+ worker:${escapeHtml(worker?.name || "未分配")} + 进度:${escapeHtml(task.progressPercent)}% + 下一步:${escapeHtml(task.nextStep || "暂无")} + 依赖:${escapeHtml(dependencyTitles.join("、") || "无")} + 审批:${escapeHtml(task.approvalStatus === "pending" ? "等待批准" : task.approvalStatus)} +
+
状态摘要:${escapeHtml(task.summary || "暂无")}
+ ${ + pendingApproval + ? `
当前被审批卡住:${escapeHtml(pendingApproval.summary)}
` + : "" + } +
+ ${ + ["queued", "assigned", "running"].includes(task.status) + ? `` + : "" + } + ${ + ["paused", "blocked", "waiting_approval"].includes(task.status) + ? `` + : "" + } + ${ + ["paused", "failed", "cancelled"].includes(task.status) + ? `` + : "" + } + ${ + !["completed", "cancelled", "failed"].includes(task.status) + ? `` + : "" + } +
+
+ `; +} + function renderTasks() { const latestPlan = [...state.events] .reverse() .find((event) => event.sessionId === state.selectedSessionId && event.type === "plan.created"); + if (latestPlan) { + const createdCount = Array.isArray(latestPlan.payload.taskIds) ? latestPlan.payload.taskIds.length : 0; const pausedCount = Array.isArray(latestPlan.payload.pausedTaskIds) ? latestPlan.payload.pausedTaskIds.length : 0; - elements.planHint.textContent = `最新计划创建了 ${latestPlan.payload.taskIds.length} 个任务,自动暂停旧任务 ${pausedCount} 个。`; + elements.planHint.textContent = + pausedCount > 0 + ? `本轮重规划新增 ${createdCount} 个任务,并暂停了 ${pausedCount} 个旧任务。` + : `本轮计划新增 ${createdCount} 个任务。`; } else { elements.planHint.textContent = ""; } - const tasks = tasksForSelectedSession(); - elements.taskList.innerHTML = tasks.length - ? tasks + const sections = taskGroupSections(); + elements.taskList.innerHTML = sections.length + ? sections .map( - (task) => ` -
+ (section) => ` +
- ${escapeHtml(task.title)} - ${escapeHtml(task.status)} +

${escapeHtml(section.title)}

+ ${section.tasks.length} 项
-

${escapeHtml(task.description)}

-
worker: ${escapeHtml(task.assignedWorkerId || "未分配")}
-
progress: ${task.progressPercent}%
-
summary: ${escapeHtml(task.summary || "暂无")}
-
next: ${escapeHtml(task.nextStep || "暂无")}
-
- ${ - ["queued", "assigned", "running"].includes(task.status) - ? `` - : "" - } - ${ - ["paused", "blocked"].includes(task.status) - ? `` - : "" - } - ${ - !["completed", "cancelled", "failed"].includes(task.status) - ? `` - : "" - } +
+ ${section.tasks.map((task) => renderTaskCard(task)).join("")}
-
+
`, ) .join("") @@ -233,10 +521,11 @@ function renderTasks() { button.addEventListener("click", async () => { const taskId = button.dataset.taskId; const action = button.dataset.action; - await request(`/api/tasks/${taskId}/${action}`, { method: "POST", body: "{}" }); - await loadSession(state.selectedSessionId); - await loadBootstrap(); - render(); + await runAction( + "更新任务", + () => request(`/api/tasks/${taskId}/${action}`, { method: "POST", body: "{}" }), + "任务状态已更新。", + ); }); }); } @@ -245,21 +534,41 @@ function renderApprovals() { const approvals = approvalsForSelectedSession(); elements.approvalList.innerHTML = approvals.length ? approvals - .map( - (approval) => ` + .map((approval) => { + const task = findTask(approval.taskId); + const followUpText = + approval.status === "pending" + ? "批准后任务会重新进入队列;拒绝后任务会被取消。" + : approval.status === "approved" + ? "这条审批已经通过,对应任务可继续执行。" + : "这条审批已被拒绝,对应任务已停止。"; + + return `
- ${escapeHtml(approval.summary)} + ${escapeHtml(task?.title || approval.summary)} ${escapeHtml(approval.status)}
-
risk: ${escapeHtml(approval.riskLevel)}
-
- - +

${escapeHtml(approval.summary)}

+
+ 风险等级:${escapeHtml(approval.riskLevel)} + 触发类型:${escapeHtml(approval.kind)} + 关联任务:${escapeHtml(task?.title || approval.taskId)}
+
${escapeHtml(followUpText)}
+ ${ + approval.status === "pending" + ? ` +
+ + +
+ ` + : "" + }
- `, - ) + `; + }) .join("") : `

当前没有待审批项。

`; @@ -267,13 +576,15 @@ function renderApprovals() { button.addEventListener("click", async () => { const approvalId = button.dataset.approvalId; const approved = button.dataset.approved === "true"; - await request(`/api/approvals/${approvalId}/respond`, { - method: "POST", - body: JSON.stringify({ approved, responder: "web-user" }), - }); - await loadSession(state.selectedSessionId); - await loadBootstrap(); - render(); + await runAction( + approved ? "批准审批" : "拒绝审批", + () => + request(`/api/approvals/${approvalId}/respond`, { + method: "POST", + body: JSON.stringify({ approved, responder: "web-user" }), + }), + approved ? "审批已通过。" : "审批已拒绝。", + ); }); }); } @@ -287,7 +598,7 @@ function renderEvents() {
${escapeHtml(event.type)} - ${new Date(event.timestamp).toLocaleTimeString()} + ${formatClock(event.timestamp)}
${escapeHtml(JSON.stringify(event.payload, null, 2))}
@@ -298,6 +609,9 @@ function renderEvents() { } function render() { + renderBanner(); + renderStreamStatus(); + renderOnboarding(); renderSessions(); renderWorkers(); renderSessionHeader(); @@ -324,27 +638,58 @@ async function loadSession(sessionId) { if (!sessionId) return; const details = await request(`/api/sessions/${sessionId}`); state.sessions = state.sessions.map((session) => (session.id === sessionId ? details.session : session)); - state.messages = [ - ...state.messages.filter((message) => message.sessionId !== sessionId), - ...details.messages, - ]; - state.tasks = [ - ...state.tasks.filter((task) => task.sessionId !== sessionId), - ...details.tasks, - ]; + state.messages = [...state.messages.filter((message) => message.sessionId !== sessionId), ...details.messages]; + state.tasks = [...state.tasks.filter((task) => task.sessionId !== sessionId), ...details.tasks]; state.approvals = [ ...state.approvals.filter((approval) => approval.sessionId !== sessionId), ...details.approvals, ]; } +function startFallbackPolling() { + if (fallbackPoll) { + return; + } + + fallbackPoll = window.setInterval(async () => { + try { + await loadBootstrap(); + if (state.selectedSessionId) { + await loadSession(state.selectedSessionId); + } + render(); + } catch (error) { + console.error(error); + } + }, 5000); +} + +function stopFallbackPolling() { + if (!fallbackPoll) { + return; + } + window.clearInterval(fallbackPoll); + fallbackPoll = null; +} + elements.createSessionForm.addEventListener("submit", async (event) => { event.preventDefault(); const title = elements.sessionTitleInput.value.trim(); - const details = await request("/api/sessions", { - method: "POST", - body: JSON.stringify({ title }), - }); + const details = await runAction( + "创建会话", + () => + request("/api/sessions", { + method: "POST", + body: JSON.stringify({ title }), + }), + "项目会话已创建。", + { refreshBootstrap: false, refreshSession: false }, + ); + + if (!details) { + return; + } + state.sessions.unshift(details.session); state.selectedSessionId = details.session.id; await loadSession(details.session.id); @@ -361,14 +706,24 @@ elements.createWorkerForm.addEventListener("submit", async (event) => { .split(",") .map((item) => item.trim()) .filter(Boolean); - await request("/api/workers/register", { - method: "POST", - body: JSON.stringify({ name, os, capabilities }), - }); + + const result = await runAction( + "注册 worker", + () => + request("/api/workers/register", { + method: "POST", + body: JSON.stringify({ name, os, capabilities }), + }), + "worker 已注册。", + { refreshSession: false }, + ); + + if (!result) { + return; + } + elements.workerName.value = ""; elements.workerCapabilities.value = "terminal"; - await loadBootstrap(); - render(); }); elements.messageForm.addEventListener("submit", async (event) => { @@ -376,40 +731,78 @@ elements.messageForm.addEventListener("submit", async (event) => { if (!state.selectedSessionId) return; const content = elements.messageInput.value.trim(); if (!content) return; - await request(`/api/sessions/${state.selectedSessionId}/messages`, { - method: "POST", - body: JSON.stringify({ content, channel: "web" }), - }); + + const result = await runAction( + "发送需求", + () => + request(`/api/sessions/${state.selectedSessionId}/messages`, { + method: "POST", + body: JSON.stringify({ content, channel: "web" }), + }), + "需求已发送给主控。", + ); + + if (!result) { + return; + } + elements.messageInput.value = ""; - await loadSession(state.selectedSessionId); - await loadBootstrap(); - render(); }); elements.archiveSession.addEventListener("click", async () => { - if (!state.selectedSessionId) return; - await request(`/api/sessions/${state.selectedSessionId}/archive`, { - method: "POST", - body: "{}", - }); - await loadSession(state.selectedSessionId); - await loadBootstrap(); - render(); + const session = selectedSession(); + if (!session) return; + + const archived = session.status === "archived"; + await runAction( + archived ? "恢复会话" : "归档会话", + () => + request(`/api/sessions/${state.selectedSessionId}/${archived ? "restore" : "archive"}`, { + method: "POST", + body: "{}", + }), + archived ? "会话已恢复。" : "会话已归档。", + ); }); elements.resetDemo.addEventListener("click", async () => { - await request("/api/demo/reset", { method: "POST", body: "{}" }); + const result = await runAction( + "重置 Demo", + () => + request("/api/demo/reset", { + method: "POST", + body: JSON.stringify({ preserveWorkers: true }), + }), + "Demo 数据已重置。", + { refreshSession: false }, + ); + + if (!result) { + return; + } + state.sessions = []; state.messages = []; state.tasks = []; - state.workers = []; state.approvals = []; state.events = []; state.selectedSessionId = null; + await loadBootstrap(); render(); }); const stream = new EventSource("/api/events/stream"); + +stream.onopen = () => { + const wasReconnecting = state.connection === "reconnecting"; + state.connection = "live"; + stopFallbackPolling(); + render(); + if (wasReconnecting) { + setBanner("实时事件流已恢复。", "success"); + } +}; + stream.onmessage = async (event) => { const payload = JSON.parse(event.data); state.events.push(payload); @@ -420,7 +813,24 @@ stream.onmessage = async (event) => { render(); }; -loadBootstrap().then(render).catch((error) => { - console.error(error); - elements.sessionSummary.textContent = error.message; -}); +stream.onerror = () => { + if (state.connection !== "reconnecting") { + state.connection = "reconnecting"; + setBanner("实时事件流暂时断开,正在重连并降级轮询。", "warn", true); + startFallbackPolling(); + render(); + } +}; + +loadBootstrap() + .then(async () => { + if (state.selectedSessionId) { + await loadSession(state.selectedSessionId); + } + render(); + }) + .catch((error) => { + console.error(error); + setBanner(`初始化失败:${error.message}`, "error", true); + render(); + }); diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..aa0b491 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/public/index.html b/public/index.html index 678cfae..43073c7 100644 --- a/public/index.html +++ b/public/index.html @@ -4,6 +4,7 @@ Boss Control Plane + @@ -48,17 +49,25 @@
-
-

选择一个项目会话

-

创建会话后,在这里持续对话并观察任务状态。

+
+
+

选择一个项目会话

+

创建会话后,在这里持续对话并观察任务状态。

+
+
+ 连接中 + +
+
+ +

对话

-
diff --git a/public/styles.css b/public/styles.css index 5ab52d6..5620c15 100644 --- a/public/styles.css +++ b/public/styles.css @@ -96,6 +96,10 @@ select { gap: 0.75rem; } +.row.tight { + gap: 0.4rem; +} + .between { justify-content: space-between; } @@ -104,6 +108,14 @@ select { padding: 1.5rem; } +.hero-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + justify-content: flex-end; +} + .grid { display: grid; gap: 1rem; @@ -119,6 +131,10 @@ select { margin-bottom: 1rem; } +.hidden { + display: none !important; +} + .list, .timeline { display: grid; @@ -184,6 +200,17 @@ select { color: var(--accent); } +.badge.connecting, +.badge.reconnecting, +.badge.lagging, +.badge.stale { + color: #8a6116; +} + +.badge.live { + color: #0f7b55; +} + .badge.failed, .badge.cancelled, .badge.rejected, @@ -226,6 +253,76 @@ select { color: var(--accent); } +.hint.subtle { + margin-top: 0.75rem; + margin-bottom: 0; +} + +.banner { + margin-top: 1rem; + border-radius: 16px; + padding: 0.85rem 1rem; + border: 1px solid var(--line); + background: rgba(31, 111, 235, 0.1); + color: var(--accent); +} + +.banner.success { + background: rgba(15, 123, 85, 0.1); + color: #0f7b55; +} + +.banner.error { + background: rgba(180, 35, 24, 0.1); + color: var(--danger); +} + +.banner.warn { + background: rgba(138, 97, 22, 0.12); + color: #8a6116; +} + +.onboarding { + display: grid; + gap: 1rem; +} + +.checklist { + display: grid; + gap: 0.75rem; +} + +.check-item { + border: 1px dashed var(--line); + border-radius: 18px; + padding: 1rem; + background: rgba(255, 255, 255, 0.66); +} + +.check-item.done { + border-style: solid; + border-color: rgba(15, 123, 85, 0.2); + background: rgba(15, 123, 85, 0.05); +} + +.task-group { + display: grid; + gap: 0.75rem; +} + +.task-group h3 { + margin: 0; + font-size: 1rem; +} + +.meta-list { + display: grid; + gap: 0.35rem; + color: var(--muted); + font-size: 0.92rem; + margin-bottom: 0.75rem; +} + pre { margin: 0; white-space: pre-wrap; @@ -240,4 +337,13 @@ pre { .grid { grid-template-columns: 1fr; } + + .hero .panel-header { + align-items: flex-start; + flex-direction: column; + } + + .hero-actions { + justify-content: flex-start; + } } diff --git a/scripts/claude_executor.sh b/scripts/claude_executor.sh new file mode 100755 index 0000000..2dd43d5 --- /dev/null +++ b/scripts/claude_executor.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +workspace="${BOSS_WORKSPACE:-$PWD}" +task_title="${BOSS_TASK_TITLE:-Untitled task}" +task_kind="${BOSS_TASK_KIND:-general}" +task_description="${BOSS_TASK_DESCRIPTION:-No description provided.}" + +if ! command -v claude >/dev/null 2>&1; then + echo "claude CLI not found in PATH" >&2 + exit 1 +fi + +cd "$workspace" + +prompt=$(cat </dev/null 2>&1; then + echo "codex CLI not found in PATH" >&2 + exit 1 +fi + +cd "$workspace" + +prompt=$(cat < { diff --git a/src/engine.ts b/src/engine.ts index b98367d..dbc2fa1 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -15,7 +15,9 @@ import { chooseAssignmentCandidates } from "./scheduler.js"; import { FileStore } from "./store.js"; import { createId, now } from "./utils.js"; -const DATA_FILE = resolve(process.cwd(), ".boss-data", "store.json"); +const DATA_FILE = process.env.BOSS_DATA_FILE + ? resolve(process.cwd(), process.env.BOSS_DATA_FILE) + : resolve(process.cwd(), ".boss-data", "store.json"); function isActiveTask(task: Task): boolean { return !["completed", "failed", "cancelled"].includes(task.status); @@ -34,6 +36,32 @@ export class BossEngine { return this.getState(); } + resetDemo(preserveWorkers = true): AppState { + this.commit((state) => { + state.sessions = []; + state.messages = []; + state.tasks = []; + state.approvals = []; + state.events = []; + + if (!preserveWorkers) { + state.workers = []; + return; + } + + const timestamp = now(); + state.workers = state.workers.map((worker) => ({ + ...worker, + currentTaskId: null, + load: 0, + status: worker.status === "offline" ? "offline" : "idle", + updatedAt: timestamp, + })); + }); + + return this.getState(); + } + createSession(title?: string): SessionDetails { const timestamp = now(); const session: Session = { @@ -134,6 +162,29 @@ export class BossEngine { return this.getSession(sessionId); } + restoreSession(sessionId: string): SessionDetails { + this.commit((state, addEvent) => { + const session = state.sessions.find((candidate) => candidate.id === sessionId); + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + + const timestamp = now(); + session.status = "active"; + session.updatedAt = timestamp; + + addEvent({ + sessionId, + taskId: null, + source: "system", + type: "session.restored", + payload: { sessionId }, + }); + }); + + return this.getSession(sessionId); + } + addMessage(sessionId: string, content: string, channel = "web"): SessionDetails { const session = this.getSession(sessionId).session; if (session.status === "archived") { @@ -415,6 +466,10 @@ export class BossEngine { if (task.status === "assigned") { task.status = "running"; } + worker.status = "busy"; + worker.currentTaskId = task.id; + worker.updatedAt = task.updatedAt; + worker.lastSeenAt = task.updatedAt; addEvent({ sessionId: task.sessionId, @@ -649,6 +704,7 @@ export class BossEngine { const timestamp = now(); this.commit((state, addEvent) => { + const pausedTaskIds: string[] = []; const mutableSession = state.sessions.find((candidate) => candidate.id === session.id); if (!mutableSession) { throw new Error(`Session not found: ${session.id}`); @@ -676,6 +732,7 @@ export class BossEngine { reason: "replan", }, }); + pausedTaskIds.push(task.id); } } } @@ -717,6 +774,7 @@ export class BossEngine { payload: { summary: result.summary, taskIds: tasks.map((task) => task.id), + pausedTaskIds, }, }); }); diff --git a/src/server.ts b/src/server.ts index 0026319..c750a10 100644 --- a/src/server.ts +++ b/src/server.ts @@ -71,6 +71,11 @@ app.post("/api/sessions/:sessionId/archive", async (request) => { return engine.archiveSession(params.sessionId); }); +app.post("/api/sessions/:sessionId/restore", async (request) => { + const params = request.params as { sessionId: string }; + return engine.restoreSession(params.sessionId); +}); + app.get("/api/sessions/:sessionId", async (request) => { const params = request.params as { sessionId: string }; return engine.getSession(params.sessionId); @@ -210,9 +215,9 @@ app.post("/api/approvals/:approvalId/respond", async (request) => { return engine.respondApproval(params.approvalId, body.approved, body.responder ?? "user"); }); -app.post("/api/demo/reset", async () => { - engine.store.reset(); - return ok(); +app.post("/api/demo/reset", async (request) => { + const body = (request.body ?? {}) as { preserveWorkers?: boolean }; + return engine.resetDemo(body.preserveWorkers ?? true); }); app.post("/api/reconcile", async () => engine.reconcileNow()); diff --git a/src/smoke.ts b/src/smoke.ts index 4ba3031..5c39381 100644 --- a/src/smoke.ts +++ b/src/smoke.ts @@ -1,6 +1,31 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { createServer } from "node:net"; import { setTimeout as delay } from "node:timers/promises"; -const baseUrl = process.env.BOSS_BASE_URL || "http://127.0.0.1:43210"; +const explicitBaseUrl = process.env.BOSS_BASE_URL?.replace(/\/$/, "") ?? ""; +const smokeWorkerMode = process.env.SMOKE_WORKER_MODE === "command" ? "command" : "simulate"; +const smokeExecutorCommand = process.env.SMOKE_EXECUTOR_COMMAND ?? ""; +const smokeWorkspace = process.env.SMOKE_WORKSPACE ?? process.cwd(); +let smokePort = process.env.SMOKE_PORT || ""; +let baseUrl = explicitBaseUrl; +const children: ChildProcess[] = []; +const workerSpecs = [ + { + name: "smoke-win-a", + os: "windows", + capabilities: ["terminal", "browser"], + }, + { + name: "smoke-win-b", + os: "windows", + capabilities: ["terminal", "test"], + }, + { + name: "smoke-mac-a", + os: "macos", + capabilities: ["terminal", "browser", "test"], + }, +] as const; async function request(path: string, options: RequestInit = {}) { const response = await fetch(`${baseUrl}${path}`, { @@ -9,12 +34,132 @@ async function request(path: string, options: RequestInit = {}) { }); if (!response.ok) { - throw new Error(`${response.status} ${response.statusText}`); + const text = await response.text(); + throw new Error(`${response.status} ${response.statusText} ${text}`.trim()); } return response.json(); } +function run(command: string, args: string[]) { + const child = spawn(command, args, { + stdio: "inherit", + shell: false, + env: { + ...process.env, + PORT: smokePort, + BOSS_DATA_FILE: process.env.BOSS_DATA_FILE ?? ".boss-data/smoke-store.json", + }, + }); + children.push(child); + return child; +} + +function portFromUrl(url: string) { + const parsed = new URL(url); + if (parsed.port) { + return parsed.port; + } + return parsed.protocol === "https:" ? "443" : "80"; +} + +async function chooseLocalPort() { + if (smokePort) { + return smokePort; + } + + return new Promise((resolve, reject) => { + const probe = createServer(); + probe.on("error", reject); + probe.listen(0, "127.0.0.1", () => { + const address = probe.address(); + if (!address || typeof address === "string") { + probe.close(() => reject(new Error("Unable to determine a free smoke port."))); + return; + } + + const selectedPort = String(address.port); + probe.close((error) => { + if (error) { + reject(error); + return; + } + resolve(selectedPort); + }); + }); + }); +} + +async function isHealthy() { + try { + const health = await request("/api/health"); + return health.status === "ok"; + } catch { + return false; + } +} + +async function waitForHealth(timeoutMs = 20_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await isHealthy()) { + return; + } + await delay(1_000); + } + throw new Error(`Server did not become healthy at ${baseUrl}`); +} + +async function ensureEnvironment() { + if (smokeWorkerMode === "command" && !smokeExecutorCommand.trim()) { + throw new Error("SMOKE_WORKER_MODE=command requires SMOKE_EXECUTOR_COMMAND."); + } + + if (explicitBaseUrl) { + smokePort = portFromUrl(baseUrl); + if (!(await isHealthy())) { + throw new Error(`Explicit BOSS_BASE_URL is not healthy: ${baseUrl}`); + } + } else { + smokePort = await chooseLocalPort(); + baseUrl = `http://127.0.0.1:${smokePort}`; + run(process.execPath, ["./node_modules/tsx/dist/cli.mjs", "src/server.ts"]); + await waitForHealth(); + } + + for (const spec of workerSpecs) { + const args = [ + "./node_modules/tsx/dist/cli.mjs", + "src/worker.ts", + "--name", + spec.name, + "--os", + spec.os, + ]; + + for (const capability of spec.capabilities) { + args.push("--capability", capability); + } + + if (smokeWorkerMode === "command") { + args.push("--mode", "command", "--workspace", smokeWorkspace, "--executor", smokeExecutorCommand); + } + + args.push("--server", baseUrl); + run(process.execPath, args); + } + + await delay(2_000); +} + +function shutdown() { + for (const child of children) { + if (!child.killed) { + child.kill("SIGTERM"); + } + } +} + async function waitForSessionSettled(sessionId: string, timeoutMs = 30_000) { const start = Date.now(); @@ -42,43 +187,51 @@ async function waitForSessionSettled(sessionId: string, timeoutMs = 30_000) { } async function main() { - await request("/api/demo/reset", { method: "POST", body: "{}" }); - const created = await request("/api/sessions", { - method: "POST", - body: JSON.stringify({ title: "Smoke Test" }), - }); - const sessionId = created.session.id; + try { + await ensureEnvironment(); + await request("/api/demo/reset", { + method: "POST", + body: JSON.stringify({ preserveWorkers: true }), + }); + const created = await request("/api/sessions", { + method: "POST", + body: JSON.stringify({ title: "Smoke Test" }), + }); + const sessionId = created.session.id; - await request(`/api/sessions/${sessionId}/messages`, { - method: "POST", - body: JSON.stringify({ - content: "先调研登录失败根因,然后 delete 旧缓存目录并做验证。", - channel: "smoke", - }), - }); + await request(`/api/sessions/${sessionId}/messages`, { + method: "POST", + body: JSON.stringify({ + content: "先调研登录失败根因,然后 delete 旧缓存目录并做验证。", + channel: "smoke", + }), + }); - const settled = await waitForSessionSettled(sessionId); - console.log( - JSON.stringify( - { - sessionId, - taskStatuses: settled.tasks.map((task: { title: string; status: string }) => ({ - title: task.title, - status: task.status, - })), - approvalStatuses: settled.approvals.map((approval: { id: string; status: string }) => ({ - id: approval.id, - status: approval.status, - })), - }, - null, - 2, - ), - ); + const settled = await waitForSessionSettled(sessionId); + console.log( + JSON.stringify( + { + baseUrl, + sessionId, + taskStatuses: settled.tasks.map((task: { title: string; status: string }) => ({ + title: task.title, + status: task.status, + })), + approvalStatuses: settled.approvals.map((approval: { id: string; status: string }) => ({ + id: approval.id, + status: approval.status, + })), + }, + null, + 2, + ), + ); + } finally { + shutdown(); + } } main().catch((error) => { console.error(error); process.exit(1); }); - diff --git a/src/worker.ts b/src/worker.ts index bf79557..3ca308c 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,3 +1,5 @@ +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; interface Task { @@ -7,12 +9,36 @@ interface Task { kind: string; } -function parseArgs(argv: string[]) { - const options = { +interface WorkerOptions { + name: string; + os: string; + capabilities: string[]; + server: string; + mode: "simulate" | "command"; + executorCommand: string; + workspace: string; + progressIntervalMs: number; +} + +class HttpError extends Error { + constructor( + readonly status: number, + readonly responseBody: string, + ) { + super(`Request failed: ${status} ${responseBody}`.trim()); + } +} + +function parseArgs(argv: string[]): WorkerOptions { + const options: WorkerOptions = { name: "", os: "linux", capabilities: ["terminal"], server: "http://127.0.0.1:43210", + mode: process.env.BOSS_WORKER_MODE === "command" ? "command" : "simulate", + executorCommand: process.env.BOSS_EXECUTOR_COMMAND ?? "", + workspace: resolve(process.env.BOSS_WORKSPACE ?? process.cwd()), + progressIntervalMs: Number(process.env.BOSS_PROGRESS_INTERVAL_MS ?? 4_000), }; for (let index = 0; index < argv.length; index += 1) { @@ -29,11 +55,27 @@ function parseArgs(argv: string[]) { } else if (token === "--server") { options.server = argv[index + 1] ?? options.server; index += 1; + } else if (token === "--mode") { + const candidate = argv[index + 1]; + options.mode = candidate === "command" ? "command" : "simulate"; + index += 1; + } else if (token === "--executor") { + options.executorCommand = argv[index + 1] ?? ""; + index += 1; + } else if (token === "--workspace") { + options.workspace = resolve(argv[index + 1] ?? options.workspace); + index += 1; } } if (!options.name) { - throw new Error("Usage: npm run worker -- --name [--os windows|macos|linux]"); + throw new Error( + "Usage: npm run worker -- --name [--os windows|macos|linux] [--mode simulate|command] [--executor \"cmd\"] [--workspace /path]", + ); + } + + if (options.mode === "command" && !options.executorCommand.trim()) { + throw new Error("Command mode requires --executor or BOSS_EXECUTOR_COMMAND."); } options.capabilities = Array.from(new Set(options.capabilities)); @@ -48,7 +90,8 @@ async function postJson(url: string, body: unknown) { }); if (!response.ok) { - throw new Error(`Request failed: ${response.status} ${response.statusText}`); + const text = await response.text(); + throw new HttpError(response.status, text); } return response.json(); @@ -57,14 +100,32 @@ async function postJson(url: string, body: unknown) { async function getJson(url: string) { const response = await fetch(url); if (!response.ok) { - throw new Error(`Request failed: ${response.status} ${response.statusText}`); + const text = await response.text(); + throw new HttpError(response.status, text); } return response.json(); } +async function registerWorker(options: WorkerOptions) { + const worker = (await postJson(`${options.server}/api/workers/register`, { + name: options.name, + os: options.os, + capabilities: options.capabilities, + })) as { id: string; name: string }; + console.log(`worker ready: ${worker.name} (${worker.id})`); + return worker; +} + async function taskStillRunnable(server: string, taskId: string) { - const task = await getJson(`${server}/api/tasks/${taskId}`); - return ["assigned", "running"].includes(task.status); + try { + const task = (await getJson(`${server}/api/tasks/${taskId}`)) as { status: string }; + return ["assigned", "running"].includes(task.status); + } catch (error) { + if (error instanceof HttpError && error.status === 404) { + return false; + } + throw error; + } } async function simulateTask(server: string, workerId: string, task: Task) { @@ -93,6 +154,7 @@ async function simulateTask(server: string, workerId: string, task: Task) { if (!(await taskStillRunnable(server, task.id))) { return; } + await delay(1_500); await postJson(`${server}/api/tasks/${task.id}/progress`, { workerId, @@ -103,6 +165,7 @@ async function simulateTask(server: string, workerId: string, task: Task) { if (!(await taskStillRunnable(server, task.id))) { return; } + await delay(1_000); await postJson(`${server}/api/tasks/${task.id}/complete`, { workerId, @@ -110,35 +173,199 @@ async function simulateTask(server: string, workerId: string, task: Task) { }); } +function appendOutput(lines: string[], chunk: Buffer | string, source: "stdout" | "stderr") { + for (const entry of String(chunk).split(/\r?\n/)) { + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + + lines.push(`${source}: ${trimmed}`); + if (lines.length > 30) { + lines.splice(0, lines.length - 30); + } + } +} + +function summarizeOutput(lines: string[], fallback: string) { + if (lines.length === 0) { + return fallback; + } + return lines.slice(-3).join(" | "); +} + +async function runCommandTask(options: WorkerOptions, workerId: string, task: Task) { + await postJson(`${options.server}/api/tasks/${task.id}/progress`, { + workerId, + progressPercent: 10, + summary: `${task.title}: 已启动外部执行器`, + currentStep: "boot_executor", + nextStep: "run_command", + }); + + const outputLines: string[] = []; + const child = spawn(options.executorCommand, [], { + cwd: options.workspace, + env: { + ...process.env, + BOSS_SERVER_URL: options.server, + BOSS_WORKER_ID: workerId, + BOSS_WORKER_NAME: options.name, + BOSS_WORKER_OS: options.os, + BOSS_WORKER_CAPABILITIES: options.capabilities.join(","), + BOSS_WORKSPACE: options.workspace, + BOSS_TASK_ID: task.id, + BOSS_TASK_TITLE: task.title, + BOSS_TASK_DESCRIPTION: task.description, + BOSS_TASK_KIND: task.kind, + BOSS_TASK_JSON: JSON.stringify(task), + }, + shell: true, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout?.on("data", (chunk) => { + appendOutput(outputLines, chunk, "stdout"); + }); + child.stderr?.on("data", (chunk) => { + appendOutput(outputLines, chunk, "stderr"); + }); + + const exitState: { + done: boolean; + code: number | null; + signal: NodeJS.Signals | null; + error: Error | null; + } = { + done: false, + code: null, + signal: null, + error: null, + }; + + child.on("error", (error) => { + exitState.done = true; + exitState.error = error; + }); + + child.on("exit", (code, signal) => { + exitState.done = true; + exitState.code = code; + exitState.signal = signal; + }); + + let cancelled = false; + const startedAt = Date.now(); + + while (!exitState.done) { + await delay(options.progressIntervalMs); + + if (exitState.done) { + break; + } + + if (!(await taskStillRunnable(options.server, task.id))) { + cancelled = true; + child.kill("SIGTERM"); + break; + } + + const elapsed = Date.now() - startedAt; + const progressPercent = Math.min(90, 20 + Math.floor(elapsed / options.progressIntervalMs) * 10); + await postJson(`${options.server}/api/tasks/${task.id}/progress`, { + workerId, + progressPercent, + summary: summarizeOutput(outputLines, `${task.title}: 外部执行器运行中`), + currentStep: "run_command", + nextStep: "await_exit", + }); + } + + while (!exitState.done) { + await delay(100); + } + + if (cancelled) { + return; + } + + if (exitState.error) { + throw exitState.error; + } + + if (exitState.signal && exitState.signal !== "SIGTERM") { + throw new Error(`Executor exited via signal ${exitState.signal}`); + } + + if ((exitState.code ?? 1) !== 0) { + throw new Error(summarizeOutput(outputLines, `Executor exited with code ${exitState.code ?? "unknown"}`)); + } + + if (!(await taskStillRunnable(options.server, task.id))) { + return; + } + + await postJson(`${options.server}/api/tasks/${task.id}/complete`, { + workerId, + summary: summarizeOutput(outputLines, `${task.title} 已通过外部执行器完成。`), + }); +} + +async function executeTask(options: WorkerOptions, workerId: string, task: Task) { + if (options.mode === "command") { + await runCommandTask(options, workerId, task); + return; + } + + await simulateTask(options.server, workerId, task); +} + async function main() { const options = parseArgs(process.argv.slice(2)); - const worker = (await postJson(`${options.server}/api/workers/register`, { - name: options.name, - os: options.os, - capabilities: options.capabilities, - })) as { id: string; name: string }; - - console.log(`worker ready: ${worker.name} (${worker.id})`); + let worker = await registerWorker(options); for (;;) { - await postJson(`${options.server}/api/workers/${worker.id}/heartbeat`, { load: 0 }); - const response = (await postJson(`${options.server}/api/workers/${worker.id}/claim-next`, {})) as { - task: Task | null; - }; + try { + await postJson(`${options.server}/api/workers/${worker.id}/heartbeat`, { load: 0 }); + const response = (await postJson(`${options.server}/api/workers/${worker.id}/claim-next`, {})) as { + task: Task | null; + }; + + if (!response.task) { + await delay(2_500); + continue; + } - if (response.task) { console.log(`claimed task: ${response.task.title}`); + try { - await simulateTask(options.server, worker.id, response.task); + await executeTask(options, worker.id, response.task); } catch (error) { + if (error instanceof HttpError && [404, 409].includes(error.status)) { + console.warn(`task abandoned: ${response.task.title} (${error.status})`); + continue; + } + const message = error instanceof Error ? error.message : "unknown error"; await postJson(`${options.server}/api/tasks/${response.task.id}/fail`, { workerId: worker.id, errorMessage: message, }); } - } else { - await delay(2_500); + } catch (error) { + if (error instanceof HttpError && error.status === 404) { + console.warn(`worker state lost, re-registering: ${options.name}`); + await delay(1_000); + worker = await registerWorker(options); + continue; + } + + if (error instanceof HttpError && error.status === 409) { + await delay(1_500); + continue; + } + + throw error; } } }