diff --git a/.project-docs/30-worklog/tasks/20260924-consultation-run-busy-9ac245f1.md b/.project-docs/30-worklog/tasks/20260924-consultation-run-busy-9ac245f1.md new file mode 100644 index 00000000..3ae7b9d3 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260924-consultation-run-busy-9ac245f1.md @@ -0,0 +1,44 @@ +# Task: Diagnose consultation thread busy after tool use + +## Identity + +- Task ID: 20260924-consultation-run-busy-9ac245f1 +- Mode: Feature +- Branch: codex/20260924-consultation-run-busy-9ac245f1-consultation-run-busy +- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260924-consultation-run-busy-9ac245f1 +- Base commit: 0e22e0545d6f58a17a8c85805570a550d1c14c43 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- 修复云端智能体使用三个本地只读工具后,客户端显示“该智能体线程正在运行,请等待、查询或取消当前运行后再继续”的续接失败。仅调整 Main 云端传输、相应回归测试和 README。 + +## Intent And Constraints + +- 用户要求检查截图报错;并发与 Planning Gate 已通过,在独立工作树修复。没有创建子智能体,没有修改其他任务的工作树。 +- 保持同一问题、同一工具结果批次和学生计费归属;保留云端线程互斥、原问题时限与取消机制。其他运行占用、结果冲突和新问题失败不自动重发。没有 API/schema、界面或模型配置变更。 + +## Outcome + +- 已确认触发路径:Yuxi 先提交 interrupted,再释放运行资源;GET 状态已可见 interrupted,而原 Run 的 runtime_cleanup_pending 仍为 true。客户端读完本地文件后提交 tool-results,云端原生互斥检查以同一个 interrupted Run 返回 409 run_busy。旧客户端把它当作不可恢复错误并取消问题。 +- 传输层只对 tool-results 返回的同一父 Run、interrupted 状态和明确 run_busy 等待 500ms 后重交原批次;等待不消耗普通网络重试次数,仍受调用方传入的原问题取消/超时信号控制,不重读文件、不重建问题。 +- 真实云端控制复现见 Yuxi 任务 20260924-teacher-run-busy-f327b9d4:暂停资源释放时 HTTP 409,释放后原请求 HTTP 200,并继续到 completed、真实输出及学生付费断言通过。直接控制 cleanup 变量已提供因果证据,因此未继续扩大到无关假设或 bisect。 +- README 已同步该续接行为;源码与测试自查完成。当前仅完成本地修复,未合并主分支、打包安装、推送或部署。 + +## Verification + +- 修复前:`pnpm exec vitest run tests/unit/coding-teacher-cloud.test.ts -t 'waits for the interrupted parent cleanup' --maxWorkers=1`,1 failed,失败文本为截图中的 run_busy;工具结果 POST 路径命中真实传输层。 +- 修复后:`pnpm exec vitest run teacher-cloud-activity coding-teacher --maxWorkers=1`,6 files / 183 passed。 +- 最终补齐“新问题不重试”负向例并调整类型收窄后:`pnpm exec vitest run tests/unit/coding-teacher-cloud.test.ts --maxWorkers=1`,29 passed。覆盖三工具完整续接、连续四次清理冲突后成功、本地文件变化不改变重交内容、不取消成功问题、其他运行/状态/冲突不重试、新问题不重试和等待取消。 +- `pnpm run typecheck` passed(Renderer);最终 `pnpm exec eslint electron/coding-teacher/cloud-runner.ts tests/unit/coding-teacher-cloud.test.ts` 和 `pnpm run build:vite` passed。没有声称全仓 Main 类型基线零错误。 +- 本次无界面布局/Host API 变化;已有 Electron E2E 用 Host API fixture 绕过真实云端传输,不能验证这个缺陷,未新增重复 UI 测试。未执行安装版或生产云端实机验收。 +- 仅依赖安装复用本地缓存,未变更清单或锁文件;文档门禁和 diff 空白检查在提交前执行。 + +## Follow-ups + +- 合并后构建/更新客户端,并在真实下发智能体中提问需要读取项目和会话的问题,确认三工具后继续输出。生产环境中的截图 Run 未直接读取;本地已复现相同错误及完整因果链。 + +## Promotion Candidates + +- Target: `.project-docs/30-worklog/current-state.md`。Proposal: 云端智能体工具续接能够等待原 interrupted Run 资源释放,复用结果批次并保留原时限/取消。Evidence: 真实 HTTP/PG/worker 控制实验与客户端回归。Future impact: 排查此类 409 时须区分原 Run 清理与另一运行占用。Semantic conflicts: 无;不改变云端互斥或付费规则。Human confirmation: 技术事实无额外确认要求,由后续 Integration Gate 决定是否推广。 diff --git a/README.md b/README.md index 0702e660..3d7504f0 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p ### 下发智能体咨询 - 新话题可选择运营下发的 Yuxi 智能体,默认使用指定智能体;提示词、Skills、知识库和模型配置由 Yuxi 管理,运营同步后生成新版本,已有话题固定原版本。Yuxi 智能体模型费用由当前学生的编程词元点数支付,个人 Agents 模块仍由创建者付费;不要求学生开通个人云智能体模块。停用的智能体仍可查看历史,不能借另一位智能体的可用状态继续提问。 -- Main 为智能体每轮请求绑定当前账号、项目和所选操作会话,提供 `list_project_files`、`read_project_file`、`read_conversation` 三个只读工具。云端通过持久中断请求整批读取,Main 返回配对结果并续接同一问题;本地旧版智能体也支持相同读取边界。可读当前项目源码及 `.makelore/project.json`,拒绝其他 `.makelore` 历史、Git 内部数据和越界路径;不提供写文件或命令执行。这些能力不按名称或人设区分。 +- Main 为智能体每轮请求绑定当前账号、项目和所选操作会话,提供 `list_project_files`、`read_project_file`、`read_conversation` 三个只读工具。云端通过持久中断请求整批读取,Main 返回配对结果并续接同一问题;若原运行仍在释放资源,保留同一批读取结果等待续接,等待期间仍支持取消并受原问题时限约束。本地旧版智能体也支持相同读取边界。可读当前项目源码及 `.makelore/project.json`,拒绝其他 `.makelore` 历史、Git 内部数据和越界路径;不提供写文件或命令执行。这些能力不按名称或人设区分。 - 文件预览最多 256 KiB,每次默认 60 行、最多 100 行及 2400 字节;长操作消息和智能体历史保留首尾节选及消息 ID,可按需读取本轮捕获的完整公开原文。每题最多六批本地读取;不上传 Pi 原始日志或思考。云端保留智能体对话及收到的片段,完整工程仍在本机。 - Code 课堂左侧项目栏默认宽 171px,可拖动右边缘在 128–256px 间调整,双击恢复默认并在本机记住宽度;标题栏同步对齐。项目名与会话名超长省略并可悬停查看;右侧咨询栏默认宽 508px,可拖动至 640px,紧凑窗口仍使用覆盖式咨询。 diff --git a/electron/coding-teacher/cloud-runner.ts b/electron/coding-teacher/cloud-runner.ts index e27e1ca2..3f319c66 100644 --- a/electron/coding-teacher/cloud-runner.ts +++ b/electron/coding-teacher/cloud-runner.ts @@ -92,6 +92,20 @@ export function teacherCloudTransport(account: TeacherAccount): TeacherCloudTran if (!response.ok) { const data = object(await response.json()); const detail = data.detail; + const conflict = detail && typeof detail === 'object' && !Array.isArray(detail) + ? object(detail) : undefined; + const pendingRun = /^\/runs\/([^/]+)\/tool-results$/.exec(path)?.[1]; + if (signal && response.status === 409 && pendingRun && body !== undefined + && conflict?.code === 'run_busy' + && conflict.active_run_id === decodeURIComponent(pendingRun) + && conflict.active_run_status === 'interrupted') { + // Yuxi commits the interrupt before releasing its runtime. Keep the + // same result batch while that parent still owns the cleanup fence. + // The question's original deadline/cancellation bounds this wait. + await delay(500, undefined, { signal }); + attempt--; + continue; + } const message = typeof detail === 'string' ? detail diff --git a/tests/unit/coding-teacher-cloud.test.ts b/tests/unit/coding-teacher-cloud.test.ts index 8ca61e37..73089041 100644 --- a/tests/unit/coding-teacher-cloud.test.ts +++ b/tests/unit/coding-teacher-cloud.test.ts @@ -5,9 +5,12 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { prepareCloudTeacher, + teacherCloudTransport, type TeacherCloudTransport, } from '../../electron/coding-teacher/cloud-runner'; import { TeacherError } from '../../electron/coding-teacher/config-client'; +import * as teacherConfig from '../../electron/coding-teacher/config-client'; +import * as cloudFetch from '../../electron/utils/proxy-fetch'; import { compileTeacherContext, estimateTeacherTokens } from '../../electron/coding-teacher/context'; import { discussionInstructions } from '../../electron/coding-teacher/discussion'; const TEACHER_BEHAVIOR_PROMPT = '你是麦洛的创作老师'; @@ -18,6 +21,7 @@ import type { TeacherRequestIntent, TeacherTopic } from '../../shared/coding-tea const roots: string[] = []; afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); + vi.restoreAllMocks(); }); const requestId = '11111111-1111-4111-8111-111111111111'; async function fixture() { @@ -68,6 +72,103 @@ async function fixture() { return { topic, access, account, progress, saveRequest }; } +function mockCloudSession() { + vi.spyOn(teacherConfig, 'assertTeacherAccount').mockImplementation(() => undefined); + vi.spyOn(teacherConfig, 'teacherCloudRequest').mockResolvedValue({ + access_token: 'test-session', expires_at: Math.floor(Date.now() / 1000) + 3600, + api_base_url: 'https://teacher.test', scope: 'makelore-teachers', + }); +} + +const cleanupBusy = { + code: 'run_busy', message: '该智能体线程正在运行,请等待、查询或取消当前运行后再继续', + active_run_id: 'run-1', active_run_status: 'interrupted', +}; + +it('waits for the interrupted parent cleanup and resubmits the same three read results without cancelling', async () => { + const f = await fixture(); + mockCloudSession(); + const resultBodies: string[] = [], paths: string[] = []; + vi.spyOn(cloudFetch, 'proxyAwareFetch').mockImplementation(async (input, init) => { + const pathname = new URL(String(input)).pathname.replace('/api/makelore/teachers', ''); + paths.push(pathname); + if (pathname === '/questions') return Response.json({ request_id: 'cloud-request', run_id: 'run-1' }); + if (pathname === '/runs/run-1') return Response.json({ + status: 'interrupted', thread_id: 'teacher-thread', + interrupt: { source: 'client_read_tools', context_id: requestId, calls: [ + { tool_call_id: 'files', name: 'list_project_files', arguments: { path: '.' } }, + { tool_call_id: 'file', name: 'read_project_file', arguments: { path: 'src/game.ts' } }, + { tool_call_id: 'history', name: 'read_conversation', arguments: { message_id: 'pi-message' } }, + ] }, + }); + if (pathname === '/runs/run-1/tool-results') { + resultBodies.push(String(init?.body)); + // A later local edit must not alter the batch that is already being resumed. + await writeFile(path.join(f.access.projectPath, 'src/game.ts'), 'const gravity = 9;'); + return resultBodies.length <= 4 + ? Response.json({ detail: cleanupBusy }, { status: 409 }) + : Response.json({ run_id: 'run-2' }); + } + if (pathname === '/runs/run-2') return Response.json({ status: 'completed', output: '项目与会话已读完。' }); + if (pathname.endsWith('/cancel')) return Response.json({ status: 'cancelled' }); + throw new Error('Unexpected request: ' + pathname); + }); + const text = vi.fn(); + await prepareCloudTeacher(f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, + teacherCloudTransport(f.account)) + .run([{ role: 'user', content: '我想结构化梳理一下' }], new AbortController().signal, text); + expect(text).toHaveBeenCalledWith('项目与会话已读完。'); + expect(resultBodies).toHaveLength(5); + expect(new Set(resultBodies).size).toBe(1); + expect(JSON.parse(resultBodies[0]).results).toHaveLength(3); + expect(resultBodies[0]).toContain('gravity = 0.6'); + expect(paths.filter(item => item === '/questions')).toHaveLength(1); + expect(paths.filter(item => item === '/runs/run-1')).toHaveLength(1); + expect(paths.some(item => item.endsWith('/cancel'))).toBe(false); +}); + +it.each([ + { ...cleanupBusy, active_run_id: 'other-run' }, + { ...cleanupBusy, active_run_status: 'running' }, + { ...cleanupBusy, code: 'operation_conflict' }, +])('does not retry a different execution or conflicting result: %j', async detail => { + const f = await fixture(); + mockCloudSession(); + const fetch = vi.spyOn(cloudFetch, 'proxyAwareFetch').mockImplementation(async () => + Response.json({ detail }, { status: 409 })); + await expect(teacherCloudTransport(f.account).json('/runs/run-1/tool-results', {}, new AbortController().signal)) + .rejects.toMatchObject({ status: 409 }); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +it('does not replay a new question on a busy-thread response', async () => { + const f = await fixture(); + mockCloudSession(); + const fetch = vi.spyOn(cloudFetch, 'proxyAwareFetch').mockImplementation(async () => + Response.json({ detail: cleanupBusy }, { status: 409 })); + await expect(teacherCloudTransport(f.account).json('/questions', { request_id: requestId }, new AbortController().signal)) + .rejects.toMatchObject({ status: 409 }); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +it('keeps cleanup waiting cancellable and does not resubmit after abort', async () => { + const f = await fixture(); + mockCloudSession(); + const controller = new AbortController(); + const reason = new Error('student stopped'); + const timer = setTimeout(() => controller.abort(reason), 50); + const fetch = vi.spyOn(cloudFetch, 'proxyAwareFetch').mockImplementation(async () => { + return Response.json({ detail: cleanupBusy }, { status: 409 }); + }); + try { + await expect(teacherCloudTransport(f.account).json('/runs/run-1/tool-results', {}, controller.signal)) + .rejects.toBe(reason); + } finally { + clearTimeout(timer); + } + expect(fetch).toHaveBeenCalledTimes(1); +}); + async function submitCompiledContext( f: Awaited>, intent: TeacherRequestIntent = 'question',