fix: 等待云端智能体资源释放后续接工具结果

This commit is contained in:
2026-09-24 17:02:24 +08:00
parent 0e22e0545d
commit 79914e1d06
4 changed files with 160 additions and 1 deletions

View File

@@ -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 决定是否推广。

View File

@@ -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,紧凑窗口仍使用覆盖式咨询。

View File

@@ -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

View File

@@ -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<ReturnType<typeof fixture>>,
intent: TeacherRequestIntent = 'question',