f1370e2275
leo 在 youlin stage 實測(只有 input >> wait 兩個節點): ms=3000 → 38.9s 後 503(1102) / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s 四個值同一種死法、與 ms 無關 ⇒ 病不是「等待很貴」,是「等待從來沒成功過」。 修法:wait 移進 BUILTIN_COMPONENTS,由引擎 await 一個 timer。 只花 wall-clock、不記 CPU ⇒ 等 30 秒與等 3 秒同價(皆 ≈0)。 I/O 契約沿用 component.contract.yaml,既有 workflow 的 wait 節點定義不必改。 🔴 誠實標明:原本註解斷言「Workers 時鐘在同步執行期間凍結,所以自旋永不結束」。 寫測試去證,反而被打臉——workerd 裡自旋 2553 圈後 Date.now() 就前進了。 那條假斷言已刪除(不是改鬆),完整機制降級為推測。修法不依賴它: 純 WASI 沙箱本來就沒有睡覺這個手段,會等的只有宿主。 實測: npx vitest run tests/wait-builtin.test.ts → 12 passed (12) npx vitest run(全套) → 386 passed / 14 failed (14 = 動工前的既有紅燈數,未新增) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
129 lines
6.3 KiB
TypeScript
129 lines
6.3 KiB
TypeScript
import type { ComponentRunner, EdgeType } from '../types';
|
||
|
||
export const VALID_EDGE_TYPES = new Set([
|
||
// 現有
|
||
'PIPE', 'IF', 'FOREACH', 'CONTINUE',
|
||
// 新增:執行語意
|
||
'IS_A', 'ON_SUCCESS', 'ON_FAIL',
|
||
// 新增:條件語意(SDD workflow-discovery 3.11)—— 讀上游 if_control/switch 的 branch
|
||
'ON_TRUE', 'ON_FALSE', 'ON_BRANCH',
|
||
// 新增:觸發語意
|
||
'ON_CLICK', 'CALLS_SUBFLOW',
|
||
// 新增:結構語意(記錄圖結構,不執行)
|
||
'CONTAINS', 'HAS_STYLE', 'HAS_BEHAVIOR',
|
||
]);
|
||
|
||
/** 內建零件 ID 集合(Worker 記憶體中已有實作)*/
|
||
export const BUILTIN_IDS = new Set([
|
||
'webhook', 'comp_passthrough', 'comp_uppercase', 'comp_counter',
|
||
]);
|
||
|
||
/** 語意邊 → EdgeType 映射(ADR-057 u6u L1:支援中文語意關係詞)
|
||
* 完成後 → PIPE(成功後觸發下一個)
|
||
* 失敗時 → CONTINUE(失敗後繼續)
|
||
* 對每個 → FOREACH(迭代執行)
|
||
* 條件滿足時 → IF(條件分支)
|
||
*/
|
||
export const SEMANTIC_EDGE_MAP: Record<string, EdgeType> = {
|
||
// 中文語意詞
|
||
'完成後': 'PIPE',
|
||
'失敗時': 'ON_FAIL',
|
||
'對每個': 'FOREACH',
|
||
'條件滿足時': 'IF',
|
||
// 條件分支語意(SDD workflow-discovery 3.11):讓意圖工作流寫得出兩條路
|
||
'成立時': 'ON_TRUE',
|
||
'為真時': 'ON_TRUE',
|
||
'不成立時': 'ON_FALSE',
|
||
'為假時': 'ON_FALSE',
|
||
'否則': 'ON_FALSE',
|
||
// 英文別名
|
||
'SUCCESS': 'ON_SUCCESS',
|
||
'FAIL': 'ON_FAIL',
|
||
'TRUE': 'ON_TRUE',
|
||
'FALSE': 'ON_FALSE',
|
||
'ELSE': 'ON_FALSE',
|
||
'BRANCH': 'ON_BRANCH',
|
||
'CLICK': 'ON_CLICK',
|
||
'SUBFLOW': 'CALLS_SUBFLOW',
|
||
};
|
||
|
||
/**
|
||
* wait 零件的等待上限(毫秒)。與 registry/components/wait/component.contract.yaml
|
||
* 逐字相同 —— 超過此值截斷、不報錯。**不可為了閃避資源上限調小**(Arcrun#101 紅線):
|
||
* 「等外部系統跟上」是這顆零件存在的理由,把上限砍掉等於把能力換掉。
|
||
*/
|
||
export const WAIT_MAX_MS = 30000;
|
||
|
||
/**
|
||
* 內建零件表(靜態函數)
|
||
* WASM 零件 = 各自獨立 Worker,cypher-executor 走 HTTP URL 呼叫(不從 R2 讀)
|
||
*/
|
||
export const BUILTIN_COMPONENTS = new Map<string, ComponentRunner>([
|
||
['comp_passthrough', (ctx) => ctx],
|
||
['comp_uppercase', (ctx) => {
|
||
const c = ctx as Record<string, unknown>;
|
||
return { ...c, text: String(c.text || '').toUpperCase() };
|
||
}],
|
||
['comp_counter', (ctx) => {
|
||
const c = ctx as Record<string, unknown>;
|
||
return { ...c, count: (Number(c.count) || 0) + 1 };
|
||
}],
|
||
|
||
// ── wait:等待 N 毫秒後繼續(Arcrun#101,2026-08-12)────────────────────────
|
||
//
|
||
// 為什麼「等待」搬進引擎,而不是修那顆 WASM:
|
||
//
|
||
// 舊實作是 registry/components/wait/main.go(TinyGo → WASM),用 time.Sleep。
|
||
// TinyGo 的 sleep 走 WASI `poll_oneoff`;而每顆 component worker 的 WASI shim 把
|
||
// poll_oneoff 實作成 ENOSYS(`.component-builds/*/src/index.ts`:`poll_oneoff: () => 76`)
|
||
// ⇒ TinyGo 排程器拿不到「睡到某個時間」的手段,退化成迴圈重讀 `clock_time_get`
|
||
// 自旋等時間到(wasm 內可見 runtime.sleepTicks / sleepQueue / runtime.ticks 符號)。
|
||
//
|
||
// 🔴 到這裡為止是**查得到原始碼的事實**。再往下「所以那個自旋迴圈的結束條件永遠
|
||
// 不成立」曾被當成結論寫在這裡,但**寫了測試去證,反而被打臉**:在
|
||
// vitest-pool-workers 的 workerd 裡,同步自旋 2553 圈之後 Date.now() 就前進了
|
||
// ⇒ 時鐘並沒有全程凍結。
|
||
// ⇒ 「為什麼三秒的等待會拖到 35 秒才死」的完整機制**目前仍是推測**,
|
||
// 證據只有下面 leo 的四次實測。別把它當定論往外傳。
|
||
//
|
||
// 所以症狀不是「等 N 秒花 N 秒 CPU」,而是「不管 ms 填多少都跑到 CPU 上限被砍」。
|
||
// leo 2026-08-12 在 youlin stage 實測(只有 input >> wait 兩個節點):
|
||
// ms=3000 → 38.9s 後 503 / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
|
||
// 四個值同一個死法、與 ms 無關 —— 3 秒的等待撐到 35 秒才死,就是「迴圈根本沒結束」
|
||
// 的證據(若成本與時長成正比,ms=3000 只會花 3 秒 CPU,根本不該死)。
|
||
// 也就是說 wait 零件在 Workers 上從來沒有真的等待成功過,不只是貴。
|
||
//
|
||
// 純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)本來就沒有「不花 CPU 地等」這種
|
||
// 東西 —— 會等的只有宿主。故 wait 與 trigger_workflow 同類:**是 orchestrator 的
|
||
// 執行排程職責,不是業務邏輯**(rule 02 §2.3 明列「workflow 執行排程」屬 cypher-executor
|
||
// 合法職責;§2.2 禁的是解密/簽章/template 展開/具體 API 呼叫,等待都不是)。
|
||
// 搬進引擎不違反「業務邏輯走 WASM」鐵律。引擎這側 await 一個 timer 只花 wall-clock、
|
||
// 不記 CPU ⇒ 等 30 秒與等 3 秒同價(皆 ≈0)。
|
||
//
|
||
// I/O 契約沿用 component.contract.yaml,既有 workflow 的 wait 節點定義不必改:
|
||
// 吃 ms(必填 > 0)+可選 context;ms > WAIT_MAX_MS 截斷;
|
||
// 回 { success: true, data: { ...context, waited_ms } };ms <= 0 回 success:false。
|
||
// 唯一刻意的放寬:ms 允許數字字串("3000")。WASM 版 json.Unmarshal 進 int 會直接
|
||
// 失敗,但 node.data 走 interpolateData 後 `ms: "{{input.delay}}"` 必然是字串
|
||
// ⇒ 收字串只會把「本來就跑不動的」變成跑得動,不會改變任何既有成功案例的行為。
|
||
['wait', async (ctx) => {
|
||
const c = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
|
||
|
||
const requested = typeof c.ms === 'number' ? c.ms : Number(c.ms);
|
||
if (!Number.isFinite(requested) || requested <= 0) {
|
||
return { success: false, error: 'ms 必須大於 0' };
|
||
}
|
||
const ms = Math.min(Math.floor(requested), WAIT_MAX_MS);
|
||
|
||
// 這一行就是整張票:await timer ⇒ 只走 wall-clock,不佔請求執行緒、不記 CPU。
|
||
await new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||
|
||
const passthrough = (c.context && typeof c.context === 'object' && !Array.isArray(c.context))
|
||
? c.context as Record<string, unknown>
|
||
: {};
|
||
return { success: true, data: { ...passthrough, waited_ms: ms } };
|
||
}],
|
||
]);
|
||
|
||
export const SCORE_THRESHOLD = 0.5;
|