Files
uncle6me-web 7bd7b4b26a SDD 生命週期鐵律遷移:單一活性制度上線(portal-auth=active,其餘 paused/draft)
- 鋪檔(自 system-dev-template v1.15.0):SDD-LIFECYCLE.md+pending-changes.md
  +sdd-guard.sh(覆蓋舊版,加單一活性檢查)+sdd-check.md+sdd-active-check.sh
- settings.json PreToolUse(Write|Edit|MultiEdit)掛上 sdd-guard.sh
- 全部 SDD design.md 掛 frontmatter:portal-auth=active(現行 portal 線,
  #61 demo 四件套剛 merge);artifact-sharing=draft(零任務動工);
  其餘 16 份=paused(皆有未完成任務,無明顯死件,不硬 close)
- CLAUDE.md 加「SDD 生命週期鐵律」段(指向 SDD-LIFECYCLE.md+濃縮五條)
  +SDD 速查表改以 frontmatter status: active 為現行判準
- 驗證:sdd-active-check exit 0(恰 1 份 active);guard pipe-test code 檔
  exit 0 帶現行 SDD 提示;反向測試(造 2 份 active)guard exit 2/check exit 1 全擋

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:03:16 +08:00

14 KiB
Raw Permalink Blame History

status, superseded_by
status superseded_by
paused

SDD: Resumable Workflowwebhook callback 喚醒)

2026-05-07 建立。狗糧寫 wiki 合成 workflow 時,Mira daemon 對長草稿(>2KB)切非同步模式回 {pending, task_id, poll_url}cypher-executor 沒處理就直接傳下游。 本 SDD 解這層:workflow 跑到一半遇到 pending 任務 → 暫停 + 持久化狀態 → 外部 callback 進來時喚醒繼續。 範圍:兩家自家服務之間(Mira daemon ↔ cypher-executor)走 webhook 推。對外服務無 webhook 的場景留 wishlist 用 poll 解。


1. 問題

1.1 撞牆現場

wiki 合成 workflow 第一節點 claude_api(recipe:wiki_synthesis)

  • 短草稿(< 2KB)→ daemon 同步回 {success, data: {text}}recipe output parser 解 JSON 成功
  • 長草稿(> 2KB)→ daemon 估 75s,切非同步模式回:
{
  "success": true,
  "pending": true,
  "task_id": "task_14_1778133152480",
  "poll_url": "https://mira.uncle6.me/mira/execute/task_14_1778133152480",
  "estimated_seconds": 75
}

cypher-executor 拿到這個物件就當 result,但裡面沒 data.text,下游 recipe output parser 找不到要 parse 的東西,整個 workflow 算「success」但實際上 wiki 還沒生出來。

1.2 現有 toolkit 不夠

  • wait 零件:固定 sleep N ms,沒 retry / 條件判斷
  • http_request 零件:通用 HTTP,不認 daemon 的 polling 協議
  • cypher-executor visited Set:擋住節點重訪,沒辦法做迴圈式 poll
  • Worker CPU 30s 限制:同步 poll 75s 任務不可能

1.3 Push vs Pull 抉擇(2026-05-07 拍板)

Webhook 推 Poll 拉
適用 雙方都自家 對方無 callback 能力
Worker 時間消耗 趨近 0 全程占用
時長限制 Worker CPU 30s
工程位置 runtime 能力(cypher-executor 零件(poll_task

走 Webhook 推(自家服務優先,poll_task 進 wishlist)。


2. 設計

2.1 三層改動

A. Mira daemon 端(infra/cloud-cto

  • /mira/execute 接受新欄位 callback_url: stringoptional
  • task 完成時 POST 到 callback_urlbody
    {
      "task_id": "task_14_xxx",
      "success": true,
      "data": { "text": "..." }
    }
    
  • 失敗也要 callbackbody 含 error 欄位
  • 重試策略:3 次 backoff1s / 5s / 30s),最後失敗就放棄(task 狀態存進 daemon 自己 KV

B. cypher-executor 端(resumable runtime

新概念:workflow run 可以暫停

設計:

  1. 新 KV namespace(或用既有 EXEC_CONTEXT)存暫停的 run state
    • key: paused_run:{task_id}paused_run:{run_id}
    • value: { run_id, graph, paused_node_id, paused_node_pending_result, context, trace_so_far, kv_store_ref, expires_at }
  2. graph-executor 偵測節點 result 含 pending: true + task_id → 暫停 + 寫 KV + 回 {paused: true, task_id, run_id}
  3. 新 endpoint POST /workflows/resume
    • body: { task_id, result }result 是 daemon callback 給的完整資料)
    • 從 KV 拿 paused state → merge result 進 paused_node 的 output → 從下個節點繼續執行
  4. claude_api 容器呼叫 daemon 時自動帶 callback_url
    • https://cypher.arcrun.dev/workflows/resume?task_id={預先派發的 task_id}
    • 但 task_id 是 daemon 自己派的,cypher-executor 不知道。需先 daemon 派完 task_id 才能組 URL
    • 解:daemon 改成「先回 task_id,再啟動實際工作 + 完成時 callback」— 兩階段 hand-shake

實際流程(兩階段):

cypher-executor                 Mira daemon
       │                              │
       │ POST /mira/execute           │
       │ { prompt,                    │
       │   callback_url: "?run_id=R1" }
       ├─────────────────────────────>│
       │                              │ 立即回 task_id(決定走非同步)
       │<─────────────────────────────┤ { pending, task_id: T9 }
       │                              │
       ├─ 看到 pending → 寫 KV         │ 啟動實際 LLM 任務
       │  paused_run:T9 = {run R1,    │
       │  paused_node, ctx, ...}      │
       │                              │
       │ 立即回 client (MCP)         │
       │ { paused, task_id: T9 }       │
       │                              │
       ⋯⋯⋯⋯⋯ 75s 後 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯
       │                              │ task done
       │ POST /workflows/resume       │
       │ { task_id: T9, result: {...} }
       │<─────────────────────────────┤
       │                              │
       │ 從 KV 取 paused_run:T9       │
       │ → merge result 進 paused 節點 │
       │ → 從下個節點繼續              │
       │                              │
       │ run 跑完 → 寫 trace          │
       │ → 通知 client (?)            │
       │                              │

2.2 範圍邊界

第一版(v1)做:

  • 單節點 pending → resume(最常見:claude_api 拿到 daemon pending
  • daemon 加 callback_url 支援
  • cypher-executor /workflows/resume endpoint
  • run state 寫 EXEC_CONTEXT KV,含 24h TTL(避免 KV 累積)
  • 整合測:用 wiki 合成跑長草稿,驗 callback 進來能繼續

第一版不做:

  • 多節點都 pending 的 nested 場景(例如 claude_api → 又一個 claude_api)— v2
  • foreach 內 pendingitem-level resume)— v2
  • pending 期間用戶看到「進度」的前端 UI — 走 trace 有 paused 標記,前端 polling 自己做即可
  • pending callback 失敗時的 retry / DLQ — v2,先記 log

前置依賴:

  • recipe-system 已部署(cypher-executor 已會解 recipe
  • Mira daemon 在 Hetzner,可改 code

2.3 為什麼不用 Cloudflare Queues / Durable Objects

  • CF Queues:適合大量 fan-out,這裡是點對點 callbackKV 已夠
  • Durable Objectslong-lived state 比 KV 強,但成本高 + 複雜
  • EXEC_CONTEXT KV:既有 binding,工程量最小

未來真撞到 KV 限制(每 partner 寫入頻率上限)再升級。


3. 詳細設計

3.1 daemon 端 callback 機制

infra/cloud-cto/index.jsMira daemon):

// /mira/execute handler
{
  // 既有 input + 新加:
  callback_url: string  // optional
}

// 處理邏輯:
// 1. 啟動 task(既有邏輯)
// 2. 預估時間 > 30s → 切非同步:
//    - 立即回 { success: true, pending: true, task_id, poll_url, estimated_seconds }
//    - 背景 task 完成時:
//      if (callback_url) POST callback_url with { task_id, success, data, error? }
//      (不論用戶有沒有 poll,callback 一定會送)

callback 失敗策略:

  • 3 次重試(1s / 5s / 30s
  • 全失敗:task 狀態維持完成,等 client 主動 pollpoll_url 仍有效)
  • 超過 24h 沒被消化的 taskdaemon GC

3.2 cypher-executor 端 resumable runtime

3.2.1 偵測 pendinggraph-executor

在 Component caserunner 回傳後:

result = await runner(mergedContext);

// 偵測 pending patterndaemon 約定的回應結構)
if (isResumablePending(result)) {
  await persistPausedRun(this.env.EXEC_CONTEXT, taskIdFromResult(result), {
    run_id, graph, paused_node_id: node.id, paused_context: context,
    paused_result: result, trace_so_far: trace, expires_at: Date.now() + 24*60*60*1000
  });
  // 提早結束此 run,回 paused 狀態
  return { paused: true, task_id, run_id };
}

// ... 既有的 recipe output parsing / kvSetNodeOutput / 等

isResumablePending(result) = result?.pending === true && typeof result?.task_id === 'string'

3.2.2 callback URL 注入(claude_api 之前的 layer

問題:claude_api 容器發 daemon 請求時,要帶 callback_url。但 task_id 是 daemon 派的,URL 裡只能放 run_iddaemon 收到 callback 時填 task_id

callback_url = https://cypher.arcrun.dev/workflows/resume?run_id={current_run_id}

但 cypher-executor 端用 task_id 找 paused state(一個 run 可能多個 pending),所以 callback URL 應該是:

callback_url = https://cypher.arcrun.dev/workflows/resume(不帶 querytask_id 在 body

實作位置:在 graph-executor 呼叫 claude_api 前,自動注入 callback_url 到 mergedContext

if (node.componentId === 'claude_api' && this.env?.PUBLIC_BASE_URL) {
  mergedContext.callback_url = `${this.env.PUBLIC_BASE_URL}/workflows/resume`;
}

暫先用「componentId 寫死匹配」是 hacky,未來 component contract 加 supports_async_callback: true 標記就 generic 了。

3.2.3 resume endpoint

POST /workflows/resume

{
  task_id: string,  // daemon 給的
  success: boolean,
  data?: { text: string },  // 跟同步呼叫一樣的結構
  error?: string
}

處理:

  1. 從 EXEC_CONTEXT KV paused_run:{task_id} 拿 state
  2. 沒拿到(過期 / 重複 callback)→ 回 200 + log
  3. 把 callback 給的 result 當作 paused_node 的 output
  4. 重建 GraphExecutor,從下個節點繼續執行
  5. 跑完寫完整 trace

問題:resume 後沒辦法再回給原 client。 用戶最初打 /cypher/execute(同步),拿到 {paused, task_id} 之後就斷了;resume 跑完 result 沒地方送。

v1 解法resume 完寫進 analytics_kv 或 D1用戶要主動 query。簡單但 UX 差。 v2 想法resume 完發另一個 webhook 給原 clientclient 在 trigger 時帶 final_callback_url)。


4. 範圍

在本 SDD 範圍內:

  • 4.1 daemon /mira/execute 加 callback_url 支援
  • 4.2 cypher-executor 偵測 pending + 持久化 paused state
  • 4.3 cypher-executor /workflows/resume endpoint
  • 4.4 callback_url 自動注入(claude_api 場景)
  • 4.5 wiki 合成 workflow 用長草稿端對端測試

不在本 SDD 範圍:

  • nested pendingv2
  • foreach 內 pendingv2
  • final_callback 給原 clientv2
  • poll_task 零件(wishlist

5. 驗收標準

  1. wiki 合成 workflow 餵 5KB+ 草稿,跑完後 wiki page 有寫進 KBDB(不再 trace pending 假成功)
  2. trace 有 paused 紀錄,能看到 task_id
  3. 從 daemon 觸發 callback 後 < 5s 內 cypher-executor 把 paused state 撿起來繼續
  4. 24h 沒 callback 的 paused state KV 自動 expire(看 KV TTL 列表)

5. Trigger 端背景執行(?async=1)(leo 2026-05-24 拍板)

背景:Phase 1-4 的 resumable runtime 只在「daemon 主動切非同步回 pending」時生效。但實測 project_detector 的 detect 節點,daemon 在 31s 內同步回 done(短 prompt 不切非同步),整個 webhook trigger 同步阻塞 31 秒。前端 fire-and-forget 只等 1.5s 就刷新 → 中斷未回的連線 → workflow 中止 → tag 沒寫成。這是「河道貼文後 project_detector 完全沒留 tag」的根因(2026-05-24 對照實驗:全程等 31s 成功 / 2s 斷線失敗)。

5.1 問題定位

POST /webhooks/named/:name/triggerroutes/webhooks-named.tsawait executeWebhookGraph(...) 跑完整個 graph 才回 200。呼叫端連線必須存活到 workflow 結束。

resumable runtimepause/resume)解的是「單一 claude_api 節點 pending」;解不了「整個 workflow 同步跑很久、但沒有任何節點 pending」的情況。

5.2 解法:trigger route 加 ?async=1,背景執行立刻回 202

  • trigger route 偵測 query ?async=1:用 c.executionCtx.waitUntil(executeWebhookGraph(...)) 把執行丟背景,立刻回 202 {accepted:true},不等 workflow 結束。
  • workflow 在 CF Worker 背景跑(waitUntil 保證 Worker 不在回應後立即終止),不依賴呼叫端連線。前端 fire-and-forget 不論等多久都不影響。
  • 不帶 ?async=1 → 維持原同步行為(向後相容,既有呼叫端、MCP、CLI 不受影響)。
  • 與 resumable runtime 正交:背景 run 內若有節點 pending,照樣走 pause→KV→callback resume(背景 run 的後續由 callback 接續,更不靠連線)。

5.3 與 G1 拆條(mira project_detector)的關係

mira 的 project_detector 要對 todos[] FOREACH 各跑一次(design.md mira-app §3.7.5.7 G1)。foreach-level resume 是本 SDD v2 未做項(§v2 已記錄)。?async=1 正好繞開:整個 workflow(含 foreach)在背景一次跑完,trigger 早回 202,不需要 foreach 內 per-node pending。

5.4 套用範圍

  • mira 前端 triggerProjectDetector / triggerWikiSynthesis 觸發 URL 加 ?async=1feed/page.tsx)。
  • 其餘呼叫端不變。

→ 對應 tasks.md Phase 6


6. 風險

風險 緩解
?async=1 背景 run 失敗時呼叫端不知道(回了 202) 失敗寫 telemetry + execution verdict(既有);mira UI 靠 refetch tag 觀察結果(同 wiki 既有模式)
waitUntil 有 CF 背景執行時間上限 與既有同步執行同限;超長 workflow 仍靠 per-node pause/resumeresumable runtime)跨越
daemon callback 進來時 cypher-executor 重啟 → state 還在 KVOK KV 持久化
同 task_id 重複 callback(網路重試)→ 重複執行下游 resume endpoint idempotent:拿到 state 後立刻刪 KV,重複 callback 找不到 state
daemon callback 失敗(網路) daemon 端 3 retry + 24h GC,超過就需手動干預(v1 接受)
paused state 含敏感資料(partner key KV 有 24h TTL;不寫 plaintext secrets(既有 credential injection 在執行前才解,paused state 存的是執行前的 contextsecret 還沒解)

7. 變更紀錄

版本 日期 內容
v1.0 2026-05-07 初版。狗糧 wiki 合成撞 daemon 非同步 → 補 resumable workflow runtime。第一版只做單節點 pending + claude_api callback 注入。
v1.1 2026-05-24 補 §5 trigger 端背景執行(?async=1)。根因:project_detector daemon 同步回 done(不切非同步),webhook 同步阻塞 31s,前端 1.5s 斷線殺掉 workflow。解法:waitUntil 背景跑 + 立回 202,向後相容。