diff --git a/.agents/skills/unidesk-decision/SKILL.md b/.agents/skills/unidesk-decision/SKILL.md index 6f18ec23..f4a06e15 100644 --- a/.agents/skills/unidesk-decision/SKILL.md +++ b/.agents/skills/unidesk-decision/SKILL.md @@ -35,6 +35,8 @@ unidesk todo-note complete 大论文 unidesk todo-note remind 大论文 --at 2026-07-31T18:00:00+08:00 ``` +`complete/reopen` 是幂等资源命令:遇到上游代理 `502/503/504` 时,CLI 会先读取实际 todo 状态,未达到目标状态才重试一次,并在第二次异常后做最终复核。`add` 等可能重复创建资源的命令不做盲重试。 + --- ## 记录管理 diff --git a/scripts/src/todo-note.ts b/scripts/src/todo-note.ts index 87e3e137..33317a56 100644 --- a/scripts/src/todo-note.ts +++ b/scripts/src/todo-note.ts @@ -35,6 +35,13 @@ interface FlatTodo { type TodoNoteFetcher = (path: string, init?: { method?: string; body?: unknown }) => Promise; +class TodoNoteRequestError extends Error { + constructor(message: string, readonly status: number | null) { + super(message); + this.name = "TodoNoteRequestError"; + } +} + function asRecord(value: unknown): Record | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; } @@ -69,12 +76,22 @@ function errorMessage(body: unknown): string { return typeof record?.error === "string" ? record.error : typeof record?.message === "string" ? record.message : "request failed"; } +function responseStatus(value: unknown): number | null { + const status = typeof value === "number" ? value : Number(value); + return Number.isInteger(status) && status > 0 ? status : null; +} + +function isRetryableProxyError(error: unknown): boolean { + return error instanceof TodoNoteRequestError && error.status !== null && [502, 503, 504].includes(error.status); +} + async function request(fetcher: TodoNoteFetcher, path: string, init?: { method?: string; body?: unknown }): Promise { const response = asRecord(await fetcher(`/api/microservices/todo-note/proxy${path}`, init)); if (response === null) throw new Error(`todo-note ${path} returned an invalid response`); const body = response.body; if (response.ok !== true || asRecord(body)?.ok === false) { - throw new Error(`todo-note ${init?.method ?? "GET"} ${path} failed (${String(response.status ?? "unknown")}): ${errorMessage(body)}`); + const status = responseStatus(response.status); + throw new TodoNoteRequestError(`todo-note ${init?.method ?? "GET"} ${path} failed (${String(status ?? "unknown")}): ${errorMessage(body)}`, status); } return body; } @@ -170,6 +187,30 @@ async function applyAction(fetcher: TodoNoteFetcher, instanceId: string, action: }; } +async function applyCompletion( + fetcher: TodoNoteFetcher, + summary: TodoInstanceSummary, + todoId: string, + desiredCompleted: boolean, +): Promise<{ instance: TodoInstance; changed: boolean }> { + const apply = () => applyAction(fetcher, summary.id, { type: "toggleTodoCompleted", todoId }); + try { + return { instance: await apply(), changed: true }; + } catch (firstError) { + if (!isRetryableProxyError(firstError)) throw firstError; + const observed = await getInstance(fetcher, summary); + if (resolveTodo(observed, todoId).item.completed === desiredCompleted) return { instance: observed, changed: true }; + try { + return { instance: await apply(), changed: true }; + } catch (secondError) { + if (!isRetryableProxyError(secondError)) throw secondError; + const final = await getInstance(fetcher, summary); + if (resolveTodo(final, todoId).item.completed === desiredCompleted) return { instance: final, changed: true }; + throw secondError; + } + } +} + function short(value: string, max = 64): string { const compact = value.replace(/\s+/gu, " ").trim(); return compact.length <= max ? compact : `${compact.slice(0, Math.max(1, max - 3))}...`; @@ -376,9 +417,9 @@ async function runTodoNoteCommandInner(args: string[], fetcher: TodoNoteFetcher) ? { ok: true, changed: false, instance, todo: selected.item } : mutationResult(`todo-note ${action}`, action, instance, selected, false); } - const updated = await applyAction(fetcher, instance.id, { type: "toggleTodoCompleted", todoId: selected.item.id }); - const changed = resolveTodo(updated, selected.item.id); - return json ? { ok: true, changed: true, instance: updated, todo: changed.item } : mutationResult(`todo-note ${action}`, action, updated, changed); + const result = await applyCompletion(fetcher, summary, selected.item.id, desiredCompleted); + const changed = resolveTodo(result.instance, selected.item.id); + return json ? { ok: true, changed: result.changed, instance: result.instance, todo: changed.item } : mutationResult(`todo-note ${action}`, action, result.instance, changed, result.changed); } if (action === "remind") {