- AI 应用
- 人工智能
- AI Agent
- 本地部署
- 前端
- 后端
- 工作流自动化
【免费下载链接】ekko-studio
Ekko Studio is a local-first AI workspace for multi-agent chat, coding, and visual workflows, available on desktop and the web.
导读
本文围绕 Hermes Studio(Ekko Studio 的桌面/Web 本地优先 AI 工作区)中的移动端授权卡片超时机制展开,聚焦"移动日历与提醒(Mobile Calendar & Reminder)同意等待时长"这一变更:将原本 30 秒的设备端硬上限统一为与 App 通用同意卡片一致的5 分钟(300,000ms)默认值,并使服务端与设备端 deadline 保持一致。读完本文,你将理解 MCP/OpenAPI 对超时参数的约束、服务端如何对timeout_ms做边界钳制(clamp)、expires_at_ms如何在事件中随卡片刻画 deadline,以及过期删除保护与未过期响应放行的完整链路,并可通过仓库中的测试用例进行验证。
本文的技术依据为仓库文档 docs/chat-chain-changes/2026-09-05-mobile-consent-timeout.md(变更记录),以及服务端源码 chat-run.ts 与对应测试 run-chat-mobile-calendar.test.ts。
变更背景:30 秒设备端上限带来的不一致
此前,移动日历与提醒(calendar / reminder)类请求的设备端同意等待被限制在 30 秒内,而 App 中其他通用同意卡片(generic consent card)则采用 5 分钟等待时长。这种不一致会造成两类问题:
- 用户体验不一致:用户在同一 App 内处理不同授权卡片时,等待规则完全不同,日历/提醒卡在 30 秒后即失效;
- 服务端与设备端 deadline 错位:设备端按 30 秒兜底,而服务端任务可能仍在等待或按其他规则判定,导致"卡片已过期但后端仍挂起"或"后端已超时但卡片仍展示"的竞态。
本次变更的核心诉求(见文档 front-matter 的impact字段)即:默认改为 5 分钟,与 App 通用同意卡片保持一致,并让服务端与设备端 deadline 相匹配,取代原先 30 秒的设备端上限。
超时参数的三重约束:MCP、OpenAPI 与服务端钳制
文档指出:"MCP and OpenAPI allow 3–300 seconds and advertise a five-minute default."这句话揭示了移动端同意超时的完整参数契约:
| 层 | 允许范围 | 默认值 | 说明 |
|---|---|---|---|
| MCP 工具契约 | 3–300 秒(即 3000–300000ms) | 300000ms(5 分钟) | Agent 调用移动日历/提醒工具时的timeout_ms参数约束 |
| OpenAPI 描述 | 3–300 秒 | 300000ms | 面向 App/第三方集成的接口文档同步声明 |
| 服务端实现 | 3–300 秒(钳制) | 300000ms | boundedMobileCalendarTimeout兜底与边界钳制 |
在服务端源码 chat-run.ts 中,这一契约被落实为三个常量与一个钳制函数:
const MOBILE_CALENDAR_MIN_TIMEOUT_MS = 3_000 const MOBILE_CALENDAR_MAX_TIMEOUT_MS = 300_000 const MOBILE_CALENDAR_DEFAULT_TIMEOUT_MS = 300_000 function boundedMobileCalendarTimeout(value: unknown): number { const numeric = Math.round(Number(value)) if (value == null || !Number.isFinite(numeric) || numeric <= 0) return MOBILE_CALENDAR_DEFAULT_TIMEOUT_MS return Math.max(MOBILE_CALENDAR_MIN_TIMEOUT_MS, Math.min(MOBILE_CALENDAR_MAX_TIMEOUT_MS, numeric)) }该函数的行为可以归纳为:
- 非法/缺省回退默认值:
timeout_ms为null、undefined、非有限数值或 ≤ 0 时,直接采用 5 分钟默认值; - 上限钳制:超过 300000ms 的显式值会被压回 300000ms(例如调用方传入 600000ms 时,实际生效 300000ms);
- 下限保护:小于 3000ms 的值会被提升到 3000ms,避免过短的同意窗口导致用户来不及操作;
- 取整:非整数毫秒数会被
Math.round归一。
文档特别强调"Explicit shorter waits remain supported"(显式更短的等待仍然受支持)——即 3–300 秒区间内的自定义值如 3000ms、15000ms 依然可以生效,只有越界与缺省才被钳制到边界或默认值。
服务端请求链路:timeout_ms 与 expires_at_ms 如何随事件下发
requestMobileCalendar与requestMobileHealth是服务端发起移动端同意等待的两条核心路径(chat-run.ts),二者共享同一套超时逻辑:
const requestId = randomUUID() const timeoutMs = boundedMobileCalendarTimeout(options.timeoutMs) const event = request.capability === 'reminder' ? 'reminder.requested' : 'calendar.requested' const idKey = request.capability === 'reminder' ? 'reminder_request_id' : 'calendar_request_id' return new Promise<MobileCalendarResponse>((resolve) => { const timer = setTimeout(() => { this.finishMobileCalendarRequest(requestId, { status: 'error', error: { code: 'calendar_failed' }, }) }, timeoutMs) timer.unref?.() this.pendingMobileCalendar.set(requestId, { sessionId, profile, target, request, resolve, timer }) this.emitMobileCalendarEvent(profile, sessionId, event, { event, [idKey]: requestId, ...request, target_device_id: target.deviceCode, target_user_id: target.userId, target_profile: target.profile, timeout_ms: timeoutMs, expires_at_ms: Date.now() + timeoutMs, }) })关键设计点:
- 单一超时源:
timeoutMs只计算一次,同时驱动服务端定时器、事件里的timeout_ms与expires_at_ms,从源头杜绝三者不一致; - 绝对 deadline 优先:
expires_at_ms = Date.now() + timeoutMs以绝对时间戳形式下发,设备端/App 只需与本地时钟比对即可判定过期,无需自行换算相对时长; - 请求去重:同一会话已存在 pending 的日历/提醒请求时,新请求会被拒绝("A mobile calendar or reminder request is already pending"),配合单会话单卡片语义避免同意风暴;
- 定时器不阻塞退出:
timer.unref?.()使该定时器不阻止进程退出,属于后台等待型定时器; - 设备路由隔离:事件通过
target_device_id / target_user_id / target_profile精确投递到对应移动设备房间(见 mobile-device-target.ts 的mobileDeviceRoom命名空间mobile-consent:<deviceId>:<profile>),非目标设备的迟到响应会被mobileEventAllowed拒绝。
移动健康(health)请求路径requestMobileHealth采用完全相同的boundedMobileCalendarTimeout钳制逻辑,超时时返回status: 'error'与error.code: 'health_timeout',仅额外要求目标平台为 iOS(iPhone/iPad)。
过期删除保护与未过期响应的放行语义
文档强调 "Expired delete protection remains"(过期删除保护保持不变)。这在 run-chat-mobile-calendar.test.ts 的用例 "requires fresh deletion confirmation and rejects late responses" 中得到了完整验证:
1. 发起 reminder delete 请求(timeoutMs: 3000),事件携带 expires_at_ms = now + 3000 2. 设备在过期前回复 status: 'denied' → 请求正常 resolve 为 denied 3. 同一会话再次发起删除请求,推进 3001ms 后 → 请求 resolve 为 status: 'error' 4. pending 表清空(pendingMobileCalendar.size === 0) 5. 此时设备端再以第一次的 reminder_request_id 回复 success → 因请求已过期删除,响应被丢弃,pending 表仍为 0这说明删除类敏感操作要求"新鲜确认":一旦卡片超时,服务端立即清理挂起状态,迟到响应无法再提交删除动作——这是对删除等不可逆操作的关键安全护栏。与之相对,未过期的响应(无论 accept 还是 denied)都可在剩余窗口内正常放行。
默认值与边界的可验证行为:测试用例视角
测试 run-chat-mobile-calendar.test.ts 直接对应本次变更的验收标准,覆盖了文档描述的"五分钟后端默认":
it.each([undefined, null, 0, 600000, 300000])('uses a matching five-minute card/server deadline for %s', async timeoutMs => { const promise = server.requestMobileCalendar({ sessionId, profile, capability: 'reminder', action: 'list', purpose: 'test', timeoutMs }) const event = emitted.find(entry => entry.event === 'reminder.requested')!.payload expect(event.timeout_ms).toBe(300000) expect(event.expires_at_ms).toBe(Date.now() + 300000) await vi.advanceTimersByTimeAsync(60001) expect((server as any).pendingMobileCalendar.size).toBe(1) // 60s 后仍存活 await vi.advanceTimersByTimeAsync(239999) await expect(promise).resolves.toMatchObject({ status: 'error' }) expect((server as any).pendingMobileCalendar.size).toBe(0) // 300s 整到期清理 })该用例用参数化方式断言:无论调用方传入undefined、null、0、600000还是300000,服务端最终下发的timeout_ms一律是300000,expires_at_ms一律是now + 300000。这正对应boundedMobileCalendarTimeout的三类行为——缺省/非法回退默认、上限钳制、默认值直通。时间推进验证还精确刻画了生命周期:60 秒时挂起请求仍在(印证从 30 秒放宽到 5 分钟后的存活区间),推进满 300 秒后请求以error收尾并清理。
App / 原生侧兼容性:无需重建
文档明确指出:"Current App already consumes timeout_ms/expires_at_ms so no App/native rebuild is required."即服务端下发协议字段timeout_ms/expires_at_ms早已被现有 App 消费,本次变更纯粹是服务端默认值与钳制逻辑的调整:
- 无协议破坏:字段名与语义未变,App 无需改版即可识别新的 5 分钟窗口;
- 无原生重建成本:iOS/Android 端只需按其既有逻辑渲染卡片倒计时(依据
expires_at_ms计算剩余时间),即可无缝获得更宽松、与通用卡片一致的等待体验; - 兼容旧行为:显式传入更短超时的调用方(如 Agent 侧指定 3000ms)依然按原样工作,符合 "Explicit shorter waits remain supported"。
与同类等待机制的横向对照
移动端同意超时并非孤立设计,仓库中还存在多个共享"5 分钟"语义的等待/授权机制,可作为理解本机制的参照:
| 机制 | 常量/默认值 | 位置 |
|---|---|---|
| 移动日历/提醒同意 | MOBILE_CALENDAR_DEFAULT_TIMEOUT_MS = 300_000 | chat-run.ts |
| Agent 澄清请求 | CLARIFICATION_TIMEOUT_MS = 300_000 | clarification-runs.ts |
| 设备配对请求 TTL | REQUEST_TTL_MS = 5 * 60 * 1000 | devices.ts |
| App 授权码 TTL | APP_AUTHORIZATION_CODE_TTL_SECONDS = 5 * 60 | app-connections-store.ts |
| 群聊上传会话 TTL | GROUP_CHAT_UPLOAD_SESSION_TTL_MS = 5 * 60 * 1000 | chunked-upload.ts |
从源码结构看,这些机制普遍采用"服务端定时器 + 绝对过期时间戳 + 挂起表清理"的同一模式,本变更把移动日历/提醒对齐到这一既有节奏,属于一致性收敛而非引入新范式。可以推断,统一 5 分钟窗口也有助于用户在锁屏/切后台后仍有余裕完成授权,同时绝对时间戳语义天然免疫相对计时的漂移误差。
总结与排查建议
本次变更最终落地为三点:
- 默认统一:移动日历/提醒同意等待默认 5 分钟,与 App 通用同意卡片一致,取代旧的 30 秒设备端上限;
- 边界钳制:
timeout_ms允许 3000–300000ms,越界钳制、缺省回退默认值,显式短等待仍受支持; - 协议兼容:服务端下发
timeout_ms与expires_at_ms供卡片渲染 deadline,App 无需重建,过期删除保护语义不变。
实际排查移动端同意失效问题时,建议按以下顺序核对:
- 事件负载中的
expires_at_ms是否等于Date.now() + timeout_ms(单点计算,正常应恒等); timeout_ms是否落回 300000ms(先确认调用方是否传入了越界值如 600000ms,其会被钳制为 300000ms 而非直接生效);- 设备端是否基于绝对时间戳而非相对时长计算剩余时间,避免时钟偏差导致提前过期;
- 删除类操作的响应是否在超时后到达——按设计会被静默丢弃,属预期安全行为。
如需深入验证或复现,可直接阅读服务端实现 chat-run.ts 与 L560-L676,并运行测试 run-chat-mobile-calendar.test.ts(关键用例位于 L124-L166)。
- AI 应用
- 人工智能
- AI Agent
- 本地部署
- 前端
- 后端
- 工作流自动化
【免费下载链接】ekko-studio
Ekko Studio is a local-first AI workspace for multi-agent chat, coding, and visual workflows, available on desktop and the web.
相关推荐
Hermes Studio(Ekko Studio)移动端日历与提醒:MCP 一次性授权访问机制深度解析
Hermes Studio(Ekko Studio)移动端日历与提醒:MCP 一次性授权访问机制深度解析 本文围绕 Hermes Studio(Ekko Stu
AI 应用人工智能AI Agent本地部署前端后端工作流自动化Hermes Studio 移动设备目标绑定:将日历与提醒授权锁定到经过验证的发起设备
Hermes Studio 移动设备目标绑定:将日历与提醒授权锁定到经过验证的发起设备 导读 本文基于 Hermes Studio 变更记录 docs/chat
AI 应用人工智能AI Agent本地部署前端后端工作流自动化Hermes Studio(Ekko Studio)移动端日历/提醒「单条确认删除」契约详解:精确身份校验、deleted=true 回报与有界确认期限
Hermes Studio(Ekko Studio)移动端日历/提醒「单条确认删除」契约详解:精确身份校验、deleted=true 回报与有界确认期限 本文围
AI 应用人工智能AI Agent本地部署前端后端工作流自动化
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考