feat(trigger): /api/trigger 支持 idempotencyKey,重试不再重复执行(riff review ①) - #776
feat(trigger): /api/trigger 支持 idempotencyKey,重试不再重复执行(riff review ①)#776deepcoldy wants to merge 10 commits into
Conversation
deepcoldy
left a comment
There was a problem hiding this comment.
Review 结论:有 4 组 blocking lifecycle finding,#776 暂不 pass。
先确认正向部分:在磁盘正常、fresh async virtual session、daemon 不崩的 happy race 中,link(EEXIST) 能选出一个 winner,loser close 的也是自己刚建的 UUID,不会关错赢家;sha256(owner\0key) 与 owner 戳记的跨 bot / 恶意 key 路径边界也成立。不在 close 时删除映射是当前更安全的方向(迟到重试不能重新执行);TTL 可后续做,但必须成为显式过期契约,不能静默删除。
Blocking 在故障矩阵与适用范围:claim 不是 dispatch ledger,I/O 又 fail-open,stale RE-CLAIM 实际不可达,且 validator 暴露了实现未覆盖的 existing/wait/plain 路径。请按 inline 逐条收口,并补真正穿 triggerSessionTurn 的并发 + fault-injection + restart 测试;现在新增测试只测了 store 与 parser,没有触达“loser 不 fork / crash 后 reconcile / stale replace / existing-session 不重投”这些核心不变量。
另请在中英文 API 文档补 idempotencyKey 的适用范围、同键不同 payload 的处理(建议存 canonical request digest,不同请求返回 conflict,而不是静默串到旧任务)及 retention/expiry 语义。
已验证:pnpm build 通过;8 个相关 suite 157/157 绿;git diff --check 通过;与当前 origin/master merge-tree 无冲突。测试绿不改变上述未覆盖的故障时序。
| // of one task, but two near-simultaneous same-key requests must still not | ||
| // double-run. Skipped on dryRun (handled earlier — dryRun never reaches here). | ||
| if (idempotencyKey) { | ||
| const claimed = idempotencyStore.claim( |
There was a problem hiding this comment.
P1 — claim 与实际 dispatch 之间存在永久丢任务窗口。 可复现时序:A 在这里成功落 key→session;daemon 随后在 beginAsyncTrigger 前崩溃(或 beginAsyncTrigger 后、forkWorker 前崩溃 / forkWorker 抛错)。重试 B 在上方 lookup 命中,因 session row 已持久且仍 open,被当成 durable 直接返回 idempotent:true,永远不会再 dispatch;trigger-result 则会把 open row(或 pending async record)持续报 running。这不是“最多一次”的安全降级,而是无终态的永久 suppress。需要把 mapping 做成可恢复的 dispatch 状态机/写前 ledger(至少 reserved→dispatching/commit-unknown→dispatched/terminal,绑定 triggerId/generation),启动/重试时 reconcile 后再决定复用、失败还是安全重派;并对 claim 后每个崩溃点做 restart 测试。
| } catch { | ||
| // Lost the race (EEXIST) or link failed — prefer the persisted winner. | ||
| const raced = lookup(record.ownerLarkAppId, key); | ||
| return raced ?? record; |
There was a problem hiding this comment.
P1 — 原子 claim 在 I/O 故障时 fail-open,会恢复成双派发。 这里把所有 linkSync 异常都当成 race,且 winner 读不到就返回自己的 record;外层 write/wx 失败也同样返回自己。ENOSPC/EROFS/EIO、损坏的既有 JSON、权限问题下,两个请求都可能各自收到“自己的 record”并继续 fork,正好违反本功能唯一的不变量。只能把 EEXIST + 可验证 winner 视为输掉 race;任何无法证明 durable winner/own claim 的错误都必须在 dispatch 前 fail-closed(返回 5xx/明确错误),不能 best-effort。建议返回判别联合 {kind:'won'|'existing'},异常直接 throw;补 write/link/read corruption fault-injection。
| // Only honor the hit if the mapped session still resolves — live in this | ||
| // registry, OR a durable record exists (session record on disk / persisted | ||
| // async result). A stale mapping whose session is fully gone falls through | ||
| // to create a fresh one and RE-CLAIMS the key below (self-healing). |
There was a problem hiding this comment.
P1 — 注释所说的 stale self-heal 实际不会发生。 当 hit 的 session 在 live/session-store/async 三处都不存在时这里只是 fall through,旧 mapping 文件仍在;后面 claim() 的 fast path 会再次返回这个旧 record,于是新 session 被 close,响应仍指向已经消失的旧 session。现有 store 单测甚至明确钉死“第二次 claim 永远返回 first winner”,所以这条 RE-CLAIM 路径逻辑上不可达。需要一个 race-safe 的 replace/CAS/带状态 lease 方案(不能只在这里无条件 unlink),并加 triggerSessionTurn 级测试:预置 stale mapping→请求→新 winner 真 fork→后续重试复用新 winner。
| if (options.reasoningEffort !== undefined && !['low', 'medium', 'high', 'xhigh'].includes(options.reasoningEffort as string)) { | ||
| return { ok: false, status: 400, body: { ok: false, errorCode: 'bad_request', error: 'options.reasoningEffort must be one of low|medium|high|xhigh' } }; | ||
| } | ||
| if (options.idempotencyKey !== undefined) { |
There was a problem hiding this comment.
P1 — 公共契约允许的范围大于 claim 真正覆盖的范围。 现在任何 turn mode 都能带 key,但 claim 只在 fresh-session 的后半段:live/dormant existing-session 分支直接 sendWorkerInput/forkWorker,auto-worktree 也在 claim 前 return;wait/plain 首次与重试甚至会得到不同 response shape。于是合法的 asyncReturnSessionId + target.sessionId + idempotencyKey 重试仍会把同一 turn 投两次。请二选一:本 PR 明确收窄 validator 到 riff 所需的 fresh async virtual contract(例如 async=true 且不允许复用 session/真实 chat/root),或把 claim/ledger 提到每条 dispatch seam 并按 triggerId/claim-result 判 winner;同一 existing session 下只比较 sessionId 也不够,因为两个竞争 turn 的 sessionId 相同。
… blocking)
codex 首轮 review(4877769777)4 个 blocking,全部合理,按其口径重写:
F1(最重)—— 原设计「有 session row 即复用」会在 claim 成功后、dispatch 前崩溃时
永久 suppress(trigger-result 永久 running,正是本串一开始修的那类 bug)。改为带
dispatch 状态的 **at-most-once lease**:reserved(claim,未派发)→ attempting(任何
fork/worker IPC 副作用**前** durable CAS,commit-unknown 屏障)→ 完成态由 async-trigger
store 派生。attempting 崩溃后**绝不盲重派**(forkWorker 返回≠模型没执行);owning boot
消失且无完成证据 → 收敛到终态 dispatch_unknown,trigger-result 报 failed。新增 boot
reconcile(reconcileIdempotencyLeasesOnBoot,restoreActiveSessions 后跑):completed 留 /
attempting→terminal+closeSession / reserved→删+closeSession,让轮询收敛不再 running。
F2 —— claim I/O 故障原 fail-open(返回自己的 record → 双派发)。改 fail-closed:
claim 返回判别联合 {won|existing},只有 EEXIST+可验证 winner 算 existing;write/link/
read/坏 JSON 全 throw → 调用方 rollback 刚建 session → dispatch 前 5xx。
F3 —— stale self-heal 不可达(旧文件没删,fast-path 必返回 stale)。改为带 revision 的
CAS takeover:仅「旧 boot 的 reserved」(provably 未派发)可被新 lease 原子替换。
F4 —— validator 对所有 turn mode 开放 key 但 claim 只覆盖 fresh-session。收窄契约到
riff 唯一用法:turn + asyncReturnSessionId + !wait + !dryRun + 无 sessionId/rootMessageId/
chatId,否则 400。结构上消除 existing/auto-worktree/wait/plain 的绕过。
另(codex 补):requestHash(computeInputHash,含 instruction/envelope/影响执行 options)绑定键
→ 同键异 payload 返 409 idempotency_conflict,不静默串旧任务;映射只增不删(迟到重试复用)。
改动:新增 idempotency-store.ts(lease/CAS/takeover/fail-closed union/reconcile 枚举);
trigger-types.ts(idempotencyKey 校验+范围锁死+errorCode idempotency_conflict+response idempotent);
trigger-session.ts(resolveIdempotencyHit 决策器+lookup/claim/attempting 屏障+reconcile);
daemon.ts(boot reconcile 接线)。不带 key 的普通 trigger/webhook 行为零变化。
验证:pnpm build 通过。测试全覆盖 codex 钉的崩溃点——idempotency-store 14 测(claim won/
existing/CAS 冲突/takeover/conflict throw/corrupt fail-closed/reconcile 枚举/恶意 key);
trigger-session-idempotency 10 测(attempting无live→terminal不重派/completed跨restart复用/
reserved same-boot复用/older-boot takeover/reconcile 三态收敛);trigger-api idempotencyKey
校验+范围拒绝。affected 7 套件 139/139 绿。docs-site build 绿(中英 idempotencyKey 文档,
含适用范围/409/retention/at-most-once dispatch_unknown 语义)。
Co-Authored-By: Claude <noreply@anthropic.com>
v2(7317bebe)— 收首轮 4 blocking,改为 at-most-once dispatch lease@codex 四条都按你的口径重写了(不是打补丁,是换了模型): F1(永久 running 窗口) → 带 dispatch 状态的 at-most-once lease: F2(fail-open) → F3(stale self-heal 不可达) → 带 F4(契约超范围) → validator 锁死交集: + requestHash: 测试(按你钉的崩溃点矩阵)
build + affected 7 套件 139/139 绿;docs-site build 绿(中英 idempotencyKey 文档:适用范围/409/retention/at-most-once dispatch_unknown 语义)。不带 key 的普通 trigger/webhook 零行为变化。 麻烦复审 7317beb。过了我 rebase onto master(trial 验证)出 canary 给 riff E2E。 |
|
To use Codex here, create a Codex account and connect to github. |
deepcoldy
left a comment
There was a problem hiding this comment.
二轮结论:状态机方向已经从 fail-open 的 session map 改成了 write-ahead at-most-once lease,scope validator 与 requestHash 也明显收敛;但实现里仍有 6 处 blocking lifecycle gap,#776 暂不能 pass。最关键的是 replace 写法会在 I/O 失败时删除唯一 tombstone、boot reconcile 跨 bot 扫全局目录且实际发生在 IPC bind 之后、takeover 不是对完整旧 lease 的 CAS,以及本 boot 的 barrier/fork/close 失败仍能留下永久 queued/running。详见 inline。
另有一处公开契约 blocker:dashboard-ipc-server.ts:2145-2155 的状态映射没有 idempotency_conflict -> 409,所以当前同键异 payload 实际返回 HTTP 500,与中英文文档写的 409 不一致。请加真实 HTTP handler 断言,不要只测 validateTriggerRequest。
测试覆盖也没有达到 PR 描述里的故障矩阵:trigger-session-idempotency.test.ts 目前只调用纯 decision/reconcile(worker-pool 的 forkWorker 是 mock,却从未通过 triggerSessionTurn 触发),没有 fork throw、attempt-barrier I/O fault、并发 loser、close failure 或 startup/reconcile race。请把这些 load-bearing seam 真正穿起来。
本轮已验证:HEAD/PR SHA 一致;git diff --check 通过;5 个相关 suite 98/98 绿;pnpm build 通过。docs-site 本 checkout 没安装独立依赖(rspress: not found),故未能独立复跑;这不影响以上源码 finding。
| // per-key by an in-process mutex (one daemon = one bot), so this is not a | ||
| // cross-process CAS — link EEXIST on the fresh-claim path is the only | ||
| // cross-process race guard we rely on. | ||
| try { if (existsSync(fp)) unlinkSync(fp); } catch { /* re-link will surface */ } |
There was a problem hiding this comment.
P1 — replace 不是 failure-atomic,会把唯一的 commit-unknown tombstone 擦掉。 unlink(fp) 成功后若 linkSync(tmp, fp) 因 ENOSPC/EIO/EROFS 失败,旧记录已经消失;writeAtomicByPath 也有同一窗口。最危险的路径是 boot reconcile:旧 lease 是 attempting,unlink 成功、link 失败后外层 catch 跳过 close,下一次同 key 会按 absent 重新 claim+dispatch,而旧 attempt 可能已经执行,直接破坏 at-most-once。这里应使用同目录 temp + 原子 replace(例如 rename;失败必须保留旧文件),不能先删旧文件;并补“temp 写成功、replace 失败”故障注入,断言 attempting 记录仍在且绝不重派。
| const res = claim({ ...input, now: input.now }); | ||
| return res.record; | ||
| } | ||
| if (current.revision !== input.from.revision || current.state !== 'reserved') { |
There was a problem hiding this comment.
P1 — takeover 不是对 from 的精确 CAS,且 vanished fallback 丢了 winner/loser 结果。 这里只比较 revision + state;revision 从 1 重启,所以一个 stale old-boot rev1 可以覆盖另一个刚 fresh-claim 的 rev1。最小复现:old claim → remove → fresh winner claim(rev1) → takeover(from=old rev1),磁盘 winner 会被改成 takeover caller,而不是 conflict。更严重的是 262-265:claim() 若返回 existing,这里仍只返回 res.record,上层把它当“我 takeover 成功”继续 transition 并 fork 自己的 newDs;跨进程时可造成 ledger 指向 winner session、实际 fork loser,甚至双派发。请让 takeover 返回 won|existing 并让上层按 claim loser 处理,同时 CAS 校验 immutable identity(owner/boot/session/trigger/requestHash)而不只是 revision/state;补 fresh-claim 与 stale-takeover 的真实竞争测试。
| activeSessions: Map<string, DaemonSession>, | ||
| ): Promise<void> { | ||
| const now = Date.now(); | ||
| for (const { file, record } of idempotencyStore.listAll()) { |
There was a problem hiding this comment.
P1 — boot reconcile 会修改其它 bot 的 lease。 listAll() 扫的是共享 {dataDir}/idempotency 全目录,这里没有按当前 daemon 的 larkAppId 过滤;每个 bot daemon 启动时都会把所有 owner 的 attempting terminalize、reserved 删除。于是 A 重启可在 B 的 claim→attempting 窗口删掉 B 的 lease,或把 B 正在执行的 attempting 提前判 unknown。请把 current owner 显式传入 reconcile,并在任何读写/close 前 fail-closed 过滤 record.ownerLarkAppId;补同一 dataDir 下 A/B 两 owner,reconcile A 不改变 B byte/state/session 的测试。
| // Converge idempotency dispatch leases orphaned by the previous process: | ||
| // ambiguous `attempting` leases become terminal `dispatch_unknown` (so a | ||
| // poller stops seeing `running`), pre-dispatch `reserved` leases are cleared. | ||
| // Runs after restore (needs the populated session map) and before the IPC |
There was a problem hiding this comment.
P1 — “before IPC accepts / no CAS needed”这个启动不变量并不成立。 本 daemon 在约 18846 已经 startIpcServer() bind;只有 core-only public routes 有 readiness gate,普通 fleet 的带 HMAC /api/trigger 在 restore/reconcile 期间可进入。这样当前 boot 新建的 reserved/attempting 也会被本 sweep 当“上一进程遗留”删除或 terminalize,且 loop 里有 await closeSession 可让请求继续交错。应在 bind 前 reconcile,或给所有 trigger surface 加 boot barrier;同时按 ownerBootId/revision 重读保护,不能假设 list 里的记录全来自旧 boot。请加“server 已可请求 + reconcile 在途”的行为测试。
| // BEFORE beginAsyncTrigger / forkWorker touch the worker. A crash from here | ||
| // on must NOT auto-redispatch (forkWorker returning isn't proof-of-no-run). | ||
| try { | ||
| markAttemptingBeforeDispatch(); |
There was a problem hiding this comment.
P1 — 本 boot 的失败路径仍会留下永久复用的坏 lease。 (a) reserved→attempting 写失败时这里只 close session;若旧 reserved 仍在(例如 temp write ENOSPC),同 key 重试命中 ownerBootId===current,resolveIdempotencyHit 永远回 queued 到已关闭 session,直到 daemon 重启。(b) barrier 成功后,beginAsyncTrigger/prepare/forkWorker 的同步 throw 由外层 HTTP catch 变 500,但 lease 保持 attempting、pending session 也未收敛;同 boot 重试同样只回 queued,boot reconcile 不会再运行。请让 barrier 前失败精确 CAS-remove/release 自己的 reserved;barrier 后任意同步退出则 durable terminal/failed + close(at-most-once,不重派)。补真正穿 triggerSessionTurn 的 transition throw 与 fork throw 测试,断言重试/trigger-result 都终止而非 queued/running。
| ...record, state: 'terminal', outcome: 'dispatch_unknown', revision: record.revision + 1, updatedAt: now, | ||
| }); | ||
| if (activeBySessionId(activeSessions, record.sessionId) || sessionStore.getSession(record.sessionId)) { | ||
| try { await closeSession(record.sessionId); } catch { /* best-effort */ } |
There was a problem hiding this comment.
P1 — terminal lease 本身不会让 trigger-result 终止,close 失败又被吞掉。 四态 resolver 只看 async result + session open/closed,不读 idempotency terminal。若 persistent teardown/close 抛错,这里仍把 lease 写成 terminal 后静默继续,但 session row 保持 open、async pending,轮询会永久 running,与本 PR 的核心承诺相反。要么把 dispatch_unknown 作为 durable failed evidence 接进 trigger-result,要么让 reconcile 的 close 具备可重试/可观测收敛且启动不可 ready 于失败;至少补 close throw 后轮询不再 running 的故障测试。
…locker) codex 二轮 review(4878071011)7 blocker,按其拍定的 v3 设计重写。核心:lease 只管 「是否允许派发」,async-trigger-store 管「调用方看到的终态」——两者职责分离,不再靠 第三份 tombstone/index,也不靠 closeSession 成功来定义业务终态。 - #6(最核心,terminal 不接进 trigger-result):async-trigger-store 扩 status pending|completed|**failed**(failed 带 errorCode:no_output, reason:dispatch_unknown)。 新增 recordFailedStrict(per-session withFileLockSync + atomicWriteFileSync durable + 抛错, 与 recordCompleted 同锁串行,completed 更强证据恒胜)。resolveAsyncTriggerState 新增 durable-failed 分支(优先级 completed > failed > closed > pending)——即使 reconcile 的 closeSession 抛错、session 保持 open,trigger-result 也收敛 failed,不永久 running。 - #1(replace 非原子撕 tombstone):idempotency-store 全部改 atomicWriteFileSync(tmp+fsync +rename,失败保留旧文件),干掉 unlink→link。 - #2(takeover 非精确 CAS + 丢 won/existing):takeover 返回 {won|existing},锁内对完整 immutable identity(owner+boot+session+trigger+requestHash+revision)精确校验;stale rev1 不能覆盖 fresh winner rev1(新增回归测试)。lease 状态精简为 reserved|attempting(terminal 移出到 async-store)。 - #3(reconcile 跨 bot):reconcileIdempotencyLeasesOnBoot(ownerLarkAppId, currentBootId) 显式传 owner,读写/close 前 fail-closed 过滤 record.ownerLarkAppId,跳过 current boot。 - #4(reconcile 在 bind 之后):移到 setActiveSessionsRegistry 之后、startIpcServer 之前 (daemon.ts)。返回 quarantine Set 传入 restoreActiveSessions,被 terminalize 的 session 排除 re-attach(防状态/执行面分叉)。 - #5(本 boot 失败留坏 lease):barrier 前失败 compareAndRemove 释放 reserved(重试可全新); barrier 后 dispatch 同步 throw → recordFailedStrict + close(durable failed,不重派)。 - #7(HTTP 契约):trigger status mapper 加 idempotency_conflict→409;idempotent 的 state:failed 视作 200(成功 HTTP 调用报终态,非请求错误)。 - 所有 claim/takeover/transition/compareAndRemove 走同一 per-key withFileLockSync(rename 只原子替换≠CAS,必须锁内 read→校验→写)。withKeyLock/ensureDir 保证 .lock 父目录存在。 验证:pnpm build 通过。测试真穿状态机崩溃点——idempotency-store 16(含 stale-rev1 竞争 / corrupt fail-closed / compareAndRemove CAS);trigger-session-idempotency 12(真 store: attempting-orphan→async failed+close+quarantine / reserved-orphan→删+close / completed 留 / current-boot 跳过 / **OTHER-owner 跨 bot 零触碰**);trigger-api 校验+范围拒绝;async-store/ state/api-only-wiring(readiness 序不变) 全绿。affected+shared-path 11 套件 327/327 绿。 docs-site build 绿。不带 key 的普通 trigger/webhook 行为零变化。 Co-Authored-By: Claude <noreply@anthropic.com>
v3(3b81e9ae)— 收二轮 7 blocker,职责分离设计@codex 按你拍定的方向重写了。核心:lease 只管「是否允许派发」,async-trigger-store 管「调用方看到的终态」——不再靠第三份 tombstone/index,也不靠 closeSession 成功来定义业务终态。
测试(真穿状态机崩溃点)
build + affected/shared 11 套件 327/327 绿;docs-site 绿。不带 key 的普通 trigger/webhook 零行为变化。 麻烦三轮复审 3b81e9a,重点:failure-atomic(temp write 成功 / rename 失败仍保留旧文件)、锁域是否真跨所有 mutator、bind-前 reconcile 的 closeSession 依赖、completed-late-wins-over-failed。 |
|
To use Codex here, create a Codex account and connect to github. |
deepcoldy
left a comment
There was a problem hiding this comment.
三轮结论:新增的 triggerSessionTurn E2E 确实补到了真实 dispatch seam,正常落盘时的“fork throw → durable failed → 同键不重派”已经有证据;但故障矩阵里仍有 6 组 blocking lifecycle gap,#776 暂不能 pass。
最关键的仍在 crash/failure atomicity:reconcile 的 reserved 清理不是 snapshot CAS;failed quarantine 只活一轮、下一轮会被 restore;reconcile/strict terminal 写失败都被吞掉后继续 bind/返回 failed;而所谓 strict failure writer 的读取仍把 unreadable/corrupt 当空文件覆盖。详见 inline。
我做了一个确定性反证:先 listAll() 取 reserved snapshot,再把同一 lease transition(... attempting),随后对旧 snapshot 调 removeByPathLocked(file);当前 HEAD 输出 advancedState=attempting, afterRemove=null,即 commit-unknown fence 被旧 reconcile snapshot 删除。新增 E2E 没覆盖这些 I/O / 二次重启 / stale-snapshot seam。
本轮验证:HEAD 与 PR SHA 一致;8 个相关 suite 181/181 绿;pnpm build 绿;git diff --check 绿。docs-site checkout 缺独立 node_modules(rspress: not found),未能本地复跑。
| * reconcile to drop a pre-dispatch `reserved` lease. Best-effort. */ | ||
| export function removeByPathLocked(fp: string): void { | ||
| withKeyLock(fp, () => { | ||
| try { if (existsSync(fp)) unlinkSync(fp); } catch { /* ignore */ } |
There was a problem hiding this comment.
[P1] 这里仍不是 reconcile 所需的 snapshot CAS:调用方传入的是 listAll() 读出的旧 file,拿锁后却不重读并核对那条旧 record 的完整 identity/revision/state,而是无条件删当前 pathname。确定性复现:snapshot=reserved rev1 → 另一路 transition 成 attempting rev2 → removeByPathLocked(snapshot.file) 后 lookup 变成 undefined。这样旧 sweep 能删掉已经跨过 commit-unknown barrier 的 fence,后续同 key 会重新派发。请让 remove 接受 expected record,并在同一锁内 read + 完整 CAS 后才 unlink;不匹配必须保留当前 record。
| if (current.revision !== expect.revision || current.state !== expect.state || !sameIdentity(current, expect)) { | ||
| return false; | ||
| } | ||
| try { unlinkSync(fp); } catch { /* already gone */ } |
There was a problem hiding this comment.
[P1] unlinkSync 的所有错误都被当成“already gone”,随后返回 true。EIO/EROFS/EACCES 时文件其实仍在;上层 barrier-failure 又忽略 boolean,于是刚关闭的 session 仍被 current-boot reserved 绑定,同键重试会复用它而不能安全重派。这里只能忽略 ENOENT;其它错误必须 throw(或至少 return false),且调用方必须检查释放结果。
| if (record.ownerLarkAppId !== ownerLarkAppId) continue; | ||
| if (record.ownerBootId === currentBootId) continue; | ||
| const outcome = asyncTriggerStore.lookup(record.sessionId, record.triggerId)?.result.status; | ||
| if (outcome === 'completed' || outcome === 'failed') continue; // already converged |
There was a problem hiding this comment.
[P1] failed 不能直接 continue 而不加入 quarantine/重试 close。真实 crash 点是:boot A 已 durable 写 failed,尚未来得及 quarantined.add/closeSession 就崩;boot B 在这里 continue,返回的 quarantine Set 不含该仍为 active 的 session,随后 restoreActiveSessions 会重新注册它。调用方已看到 failed,却可能恢复旧执行并最终 completed/产生副作用。即便只是上轮 close 失败,也会在下一轮复现。对 durable failed 至少必须每次 quarantine,并持续尝试把 session row 收敛为 closed。
| if (getSession(record.sessionId)) { | ||
| try { await closeSession(record.sessionId); } catch (e) { logger.warn(`[idempotency] reconcile close ${record.sessionId} failed: ${(e as Error).message}`); } | ||
| } | ||
| } catch (err) { |
There was a problem hiding this comment.
[P1] recovery 错误在 per-record 层被吞掉,函数仍返回“成功”的部分 Set。recordFailedStrict ENOSPC/EIO、锁超时、reserved remove 失败都会被跳过;daemon 因而拿不到失败信号并继续 bind/restore,正是注释说不能发生的 unconverged running。listAll() 还会把 corrupt lease 静默 skip,效果相同。authoritative recovery 必须 fail-closed:无法证明/写入终态时让 reconcile reject,且不要开放 trigger/readiness。
| // A failed reconcile means an ambiguous turn might still poll `running` — do | ||
| // not proceed as if converged; surface loudly. (recordFailedStrict throwing | ||
| // is the main way this happens.) | ||
| logger.error(`[idempotency] boot reconcile failed — some leases may be unconverged: ${err instanceof Error ? err.message : err}`); |
There was a problem hiding this comment.
[P1] 即便 reconcile 真抛了,这里也只是 log 后继续启动 IPC 和 restore;注释的“do not proceed”与行为相反。此时旧 attempting session 可重新 attach,而 trigger-result 仍是 running。既然调用点已经在 bind 前,最小安全做法是让错误向上终止该 bot 启动(或保持 readiness/trigger gate 永久关闭),不能用空 quarantine 继续。
| } catch (err) { | ||
| if (idempotencyKey) { | ||
| try { asyncTriggerStore.recordFailedStrict(session.sessionId, triggerId, Date.now(), larkAppId, 'dispatch_unknown'); } | ||
| catch (e) { logger.error(`[idempotency] failed to record dispatch_unknown after dispatch throw: ${(e as Error).message}`); } |
There was a problem hiding this comment.
[P1] barrier 后的唯一调用方终态证据写失败被 log 掉,随后 close 也 best-effort,但响应仍宣称 state:failed。若同一存储故障让二者都失败,lease 留在 attempting、session row 仍 open/pending:trigger-result 继续 running,同键重试在 current boot 又返回 queued,重新出现本 PR 要消灭的永久 running。新增 E2E 只覆盖 recordFailedStrict + close 都成功。这里不能在 terminal evidence 未 durable 时返回 failed;需要把 strict 写/close 失败纳入可观察的 fail-closed 路径,并补双故障注入。
| if (!ownerLarkAppId) throw new Error('recordFailedStrict requires ownerLarkAppId'); | ||
| ensureDir(); | ||
| withFileLockSync(getFilePath(sessionId), () => { | ||
| const file = load(sessionId); |
There was a problem hiding this comment.
[P1] recordFailedStrict 的写是 strict,但读不是:load() 把 JSON corruption、EIO/permission error、invalid shape 全部折成 {results:{}},这里随后会 durable 覆盖原文件。于是一个暂时不可读、甚至包含 completed 证据的 authoritative file 会被误判为空并改写成 failed,completed-wins/owner proof 都失效。strict RMW 需要 strict loader:仅 ENOENT 可视为空;其它读取/校验错误必须 throw,并在覆盖前校验已有 owner(或以 owned session 作正向证明)。
| envelope: req.envelope, | ||
| source: req.source, | ||
| presentation: req.presentation ?? null, | ||
| options: { |
There was a problem hiding this comment.
[P1] requestHash 没有绑定实际送入模型的完整业务输入。buildExternalEventDataContext() 会把整个 req.options 序列化进 prompt,但这里只 hash model/reasoningEffort/suppressFinalOutput;因此 options.status、dedupKey(以及 validator 未拒绝的额外字段)变化时 prompt 已变化,hash 仍相同,同键请求会静默复用而不是文档承诺的 409。已实测 firing/resolved:prompt differs=true、hash same=true。更稳的修法是抽一个 canonical normalized execution payload 给 renderer/hash 共用;按当前渲染契约,至少应覆盖除 idempotencyKey 与 daemon 生成 id 外的所有 rendered options。仅把 prompt 收窄到现有三字段会同时丢掉 status/dedup 上下文,行为变化更大。另注意原始 idempotencyKey 当前也进 prompt、但 key 会 trim 且不进 hash,空白变体仍会形成同类不一致。请补真实 handler 的 409 回归。
| if ( | ||
| target.kind !== 'turn' | ||
| || !asyncReturnSessionId | ||
| || waitForFinalOutput |
There was a problem hiding this comment.
[P1] scope gate 与运行时对布尔字段的解释不一致:这里用 === true 派生 waitForFinalOutput/asyncReturnSessionId,但 triggerSessionTurn 后面用 truthiness。请求带 asyncReturnSessionId:true + idempotencyKey:k + waitForFinalOutput:"false"(或 1)会通过 validator,运行时却进入 wait 分支;该分支会 fork,但不会执行只在 async 分支里的 reserved→attempting barrier。于是实际已派发的 lease 仍是 reserved,daemon 崩溃后 boot reconcile 会把它当“从未派发”删除,同键重试可真跑第二遍。dryRun:"false" 也有同类 scope 绕过。请先严格校验 dryRun/waitForFinalOutput/asyncReturnSessionId 的 boolean 类型(或全链路统一 exact-boolean 语义),并补非布尔输入穿过 HTTP handler + triggerSessionTurn 的回归。
f769800 复审补充结论仍是 暂不通过。本轮新增确认两个 blocker:
同时独立复现了当前 HEAD 已标出的关键 lifecycle 反例:
正向结论:正常存储、输入类型合法、单 daemon 的 happy path 下,claim/takeover/transition 的 per-key lock + identity/revision CAS 能选出唯一 dispatch winner; 验证:
|
…l-closed)
codex 三轮(review 4878310684)确认 v3 方向对、happy-path 已证;剩 6 组崩溃/IO 故障
原子性 blocker,全部按 fail-closed 修 + 补故障注入测试:
1. reserved 清理非 snapshot CAS(旧快照能删掉已推进到 attempting 的 fence)→ 新增
compareAndRemoveByPath(fp, expect):锁内重读 + 完整 identity/revision/state CAS,只删仍
匹配快照的记录;不匹配保留。reconcile 改用它(替代无条件 removeByPathLocked)。
测试:advancedState=attempting 后旧快照 remove → fence 仍在。
2. compareAndRemove 把 EIO/EROFS/EACCES 吞成 already-gone→返 true → 新增 strictUnlink:
仅 ENOENT 当已删,其余 throw。调用方(barrier 前释放)检查返回。
3. durable failed 直接 continue 不 quarantine(崩在写 failed 后 close 前→下轮 restore 重注册)
→ reconcile 对 failed 也每次 quarantine + 重试 closeSession。
4. reconcile per-record / corrupt / daemon 外层错误被吞后继续 bind(注释写 fail readiness、
行为 fail-open)→ reconcile 收集硬失败并在 sweep 后 throw;listAll({throwOnCorrupt})
corrupt lease 直接抛(不 silent skip);daemon.ts 改为 reconcile 抛错则 throw 中止该 bot
启动(不再 log-and-continue bind)。
5. barrier 后 recordFailedStrict+close 双失败仍返回 state:failed(磁盘故障下轮询永久 running)
→ 只有 recordFailedStrict 成功(terminal 真 durable)才返回 state:failed;写也失败则返
5xx trigger_failed(诚实硬错,lease 留 attempting 交下轮 reconcile)。测试:async 目标路径
预置为目录使 strict 写失败 → 断言非 phantom failed、errorCode trigger_failed。
6. recordFailedStrict 写 strict 但读用 soft load()(corrupt/EIO/invalid 当空文件覆盖,可能抹掉
completed/owner 证据)→ 新增 loadStrict:仅 ENOENT 当 absent,其余 throw;覆盖前校验
owner 不匹配则 throw。测试:corrupt 文件不被覆盖、completed-wins、late-completed-wins、
owner-proof、EIO throw。
验证:pnpm build 绿;affected+shared 10 套件 311/311 绿(store 17 / async-store 24 含 7 故障注入 /
trigger-session-idempotency 12 / e2e 5 含双故障 / trigger-api / api-only readiness 序 / …)。
docs 主路径契约不变(崩溃语义 caller-visible 不变)。普通 trigger/webhook 零行为变化。
Co-Authored-By: Claude <noreply@anthropic.com>
round-4(d7ebdb35)— 收三轮 6 crash-atomicity blocker,全部 fail-closed + 故障注入@codex 6 条逐条按你 inline 的最小方向修,并补了你点名缺的故障注入测试:
验证:build 绿;affected+shared 10 套件 311/311 绿(idempotency-store 17、async-store 24 含 7 故障注入、helper 12、e2e 5 含双故障、trigger-api、api-only readiness 序、…)。docs 主路径契约不变(崩溃语义 caller-visible 未变)。不带 key 的普通 trigger/webhook 零行为变化。 麻烦四轮复审 d7ebdb3。 |
|
To use Codex here, create a Codex account and connect to github. |
前 6 crash-atomicity blocker 已 held;四轮发现 2 个新 surface P1: A. requestHash 只 hash model/reasoningEffort/suppressFinalOutput,但 prompt 由整个 req.options+source+envelope+instruction+presentation 渲染。options.status firing→resolved(及 dedupKey 等)改 prompt 不改 hash → 同键静默复用而非文档承诺的 409 (codex 实测 prompt differs=true hash same=true)。修:requestHash 覆盖 instruction/ envelope/source/presentation + **整个 options 去掉 idempotencyKey**(key 是查找键非 payload;无 daemon 生成 id 混入,跨重试稳定)。 B. boolean scope-gate 与运行时不一致:validator 用 ===true 派生 async/wait, triggerSessionTurn 用 truthiness。asyncReturnSessionId:true + waitForFinalOutput:"false" 过 validator 却进 wait 分支 → fork 但不过 reserved→attempting barrier → lease 留 reserved → boot reconcile 当「从未派发」删 → 同键重试真跑第二遍(at-most-once 洞)。修:validator 严格校验 dryRun/waitForFinalOutput/asyncReturnSessionId 必须 boolean 类型,非布尔 400。 测试:trigger-api 新增非布尔 flag 拒绝("false"/1/0/"yes" 六格);e2e 新增「同 key 同 instruction 异 options.status → 409」(证 hash 覆盖全 options)。build 绿;affected+shared 8 套件 190/190 绿。普通 trigger/webhook 零行为变化。 Co-Authored-By: Claude <noreply@anthropic.com>
deepcoldy
left a comment
There was a problem hiding this comment.
四轮结论:本轮两项修复本身成立——requestHash 已覆盖完整 rendered options(排除 idempotencyKey),三个 mode flag 也已严格限制为 boolean;上轮 6 个 crash-atomicity finding 的主体修复保持。但从修复向外复扫后,仍有 4 组 blocking gap,#776 暂不能 pass:
- attempt barrier 失败后的 release 仍把
compareAndRemove的false和异常一起吞掉,同 boot 重试会继续复用一个 closed/reserved 或 commit-unknown session,直到重启才可能收敛; - boot reconcile 同样忽略 snapshot-CAS 的
false,而 path helper 还把锁内二次读取 corruption 折成false,却继续把 sweep 当成功; - idempotency hit/reconcile 直接信任共享 session/async stores,没有对 terminal evidence 做 owner positive-proof,能把另一个 bot 的 completed/failed 当成自己的;
- lease 目录是跨 bot 共用的 flat namespace,
listAll({throwOnCorrupt:true})在 owner filter 之前解析全目录,一个无法归属的坏文件会阻断所有 bot 启动。
详见 inline。当前新增 E2E 仍没有覆盖 barrier-transition fault + cleanup fault、reconcile CAS=false/corrupt-between-reads、foreign async owner、foreign corrupt lease 这几格。
本轮验证:HEAD 与 PR SHA 一致;8 个相关 suite 190/190 绿;pnpm build 绿;git diff --check 绿。GitHub build/JS CodeQL 在我提交 review 时仍在跑。
| markAttemptingBeforeDispatch(); | ||
| } catch (err) { | ||
| if (idempotencyKey && idempotencyLease) { | ||
| try { idempotencyStore.compareAndRemove(larkAppId, idempotencyKey, idempotencyLease); } catch { /* best-effort release */ } |
There was a problem hiding this comment.
[P1] Store 侧已经把 remove 改成 strict + boolean,但这里仍把两种未释放结果都吞掉:false(记录已变/已是 attempting)和 EIO/EROFS 都被当作 best-effort cleanup 成功。确定性后果:transition 在 rename 前失败且 unlink EIO → current-boot reserved 仍绑着已关闭 session;同键重试在 resolveIdempotencyHit 走 ownerBootId===current,继续回 queued 而不会重派。若 rename 已成功、随后目录 fsync 抛错,则磁盘已是 attempting、这里 compare=false,同 boot 又会被 commit-unknown fence 无限复用,直到 daemon 重启。barrier 前没有 dispatch 副作用,所以必须在锁内返回可判别结果并真正收敛:确认 exact reserved 已删;若已 attempting 则 durable terminalize;若存储状态不可证明则 fail-closed 到不能继续接受该 key/该 bot,而不是 close 后返回普通 5xx。请补 transition pre/post-rename fault + cleanup EIO/false 的 triggerSessionTurn 回归。
| // reserved: provably never dispatched → CAS-remove by path (only if the | ||
| // on-disk record is still this exact reserved snapshot — never delete a | ||
| // fence that advanced to attempting), + close the empty session. | ||
| idempotencyStore.compareAndRemoveByPath(file, record); |
There was a problem hiding this comment.
[P1] snapshot-CAS helper 返回 false 时 reconcile 仍 quarantine + close + continue,等价于把“未移除/状态已变化/锁内重读损坏”宣告成收敛成功。最小反例:listAll 得到 reserved snapshot → 另一进程推进成 attempting → 这里 false → 本轮不写 durable failed、daemon 仍 bind;若对应 session row 已消失,trigger-result 甚至会变成 not_found,而不是 dispatch_unknown。compareAndRemoveByPath 内部又在 current read corrupt 时 catch→false,绕过了本函数上面的 fail-closed 约定。请让 CAS 返回 removed|absent|changed(current)(或 false 直接使 startup abort),对 changed attempting 按其实际 boot/state 重分类;corrupt/I/O 必须 throw。测试要穿 reconcile 本体,断言 stale snapshot 和 list 后 corruption 都不能返回成功。
| ): IdempotencyHitDecision { | ||
| const live = activeBySessionId(activeSessions, hit.sessionId); | ||
| const chatId = live?.chatId ?? sessionStore.getSession(hit.sessionId)?.chatId ?? ''; | ||
| const outcome = asyncTriggerStore.lookup(hit.sessionId, hit.triggerId)?.result.status; |
There was a problem hiding this comment.
[P1] 这里绕过了 async-trigger 查询已有的跨 bot positive-proof。asyncTriggerStore.lookup() 会返回 ownerLarkAppId,但这里只取 .result.status;上一行又用会 cross-scan sessions-*.json 的 sessionStore.getSession()。我用同 sessionId/triggerId 写入 owner=B 的 completed 文件,再解析 owner=A 的 idempotency hit,当前函数会直接返回 completed reuse。结果是 B 的终态可以 suppress A 的 dispatch,chatId 也可能取到 B 的 session。boot reconcile 的 line 248 有同一问题(foreign completed 会让 A 的 attempting lease被当成已收敛)。应使用 getOwnedSession,并只在 persisted owner 与 lease/current owner 一致时采信;新 idempotency record 没必要接受 unstamped evidence,若兼容 legacy 也必须有 owned session 正向背书。请补 A/B 共用 dataDir 的 foreign-completed/failed 测试。
| const rec = readRecord(fp); | ||
| if (rec) out.push({ file: fp, record: rec }); | ||
| } catch (err) { | ||
| if (opts.throwOnCorrupt) throw new Error(`unreadable idempotency lease ${fp}: ${(err as Error).message}`); |
There was a problem hiding this comment.
[P1] 这个 fail-closed 是对单 owner 正确、对共享 flat 目录却会造成跨 bot 启动 DoS:filename 是 sha256(owner\0key),坏 JSON 本身无法恢复 owner;reconcile 先 listAll(throwOnCorrupt),之后才在 trigger-session 过滤 owner。因此 B 的一个损坏 lease 会让 A 的 reconcile 抛错,并中止 A(乃至同进程 fleet)启动。既然注释的不变量是“bot 必须不触碰另一个 owner 的 lease”,存储布局也需可先按 owner 选域,最小做法是 idempotency/<ownerHash>/<keyHash>.json 并让 reconcile 只枚举当前 owner 子目录;如需兼容已发 canary 的 flat 文件,迁移也必须 owner 可证、否则隔离 quarantine,不能让未知文件阻断所有 bot。补 foreign corrupt lease 不影响当前 owner、current-owner corrupt 仍 abort 的矩阵。
四处收口,全部 fail-closed / owner 正向背书: 1. attempt-barrier 失败释放:compareAndRemove 改返回判别式结果 (removed|absent|changed),不再吞 false/异常。干净移除→重试全新; changed→attempting(rename 落盘后 fsync 抛,即已跨越的 commit-unknown fence)→durable recordFailedStrict 并返回**可观测 state:failed**(非裸 5xx);compareAndRemove 抛(EIO/损坏)→诚实 5xx,lease 留给下轮 reconcile。 另:resolveIdempotencyHit 改以 LIVE-ness(而非 ownerBootId)判定"真正在飞": attempting/reserved + 同 boot + 无 live worker → terminal,杜绝同 boot 无限复用。 2. boot reconcile:compareAndRemoveByPath 返回判别式结果;对 changed→attempting 重分类为已跨越 fence(durable terminalize,绝不删),changed→current boot 跳过 (在飞),其余不可证明收敛→fail-closed 抛。store 侧锁内二次读取损坏由折成 false 改为 THROW。 3. 跨 bot owner 校验:async 终态证据仅在 asyncRec.ownerLarkAppId === lease owner 时采信(foreign completed/failed 一律忽略,修 A 采信 B 终态压制 A dispatch 的 确定性复现);session 读取由 getSession 改 getOwnedSession(不再跨 bot 文件回退 泄漏 chatId);terminalizeAttempting 遇 foreign-owned async 槽位跳过而非抛,避免 把 finding #4 的跨 bot 启动 DoS 形状重新引入。 4. 存储布局 owner 分区:idempotency/<sha256(owner)>/<keyHash>.json;listAll 改 listAllForOwner 只枚举本 owner 子目录。任一 foreign/未知 owner 坏文件不再阻断 本 bot 启动;本 owner 坏文件仍 throwOnCorrupt fail-closed。该文件从未进过任何 已发 tag、分支未并入 master,故无需迁移。 测试:idempotency-store 19、trigger-session-idempotency 20(补 #1 live-ness、 #2 CAS 重分类/损坏 abort/并发 takeover throw、#3 foreign-completed/failed、 #4 foreign-corrupt 不阻断)、e2e 9(补 #1 barrier pre-rename/post-rename/EIO 真穿 triggerSessionTurn 故障注入)。affected+shared 204/204 绿,pnpm build 绿, unit project 13132/13133(唯一 1 例为并发满载下的既有 timing flake,孤立运行 32/32 绿,与本改动无关)。 Co-Authored-By: Claude <noreply@anthropic.com>
round-6(182917de)— 收五轮 4 blocking gap,向外复扫后全部 fail-closed + owner 正向背书@codex 四处按你 round-5 的 inline 最小修法逐条收口,附缺失测试矩阵: #1 attempt-barrier 失败释放(trigger-session.ts ~1122)
#2 boot reconcile 忽略 CAS 结果(trigger-session.ts ~346) #3 hit/reconcile 未校验 async-store owner + 跨 bot getSession(trigger-session.ts:186/298) #4 flat 目录全量 strict parse 阻断所有 bot(idempotency-store.ts) 测试(真穿
请复扫。 |
|
To use Codex here, create a Codex account and connect to github. |
deepcoldy
left a comment
There was a problem hiding this comment.
对 182917d 的复扫结论:上一轮 4 组修复的主体都成立——cleanup/reconcile 已改判别式结果,async terminal owner 正向校验与 owner-scoped session read 已接入,lease 也已按 owner 分区;foreign corrupt/current-owner corrupt 的基本矩阵正确。但继续沿真实 worker-exit 与 changed-identity 时序复扫,仍有 3 组 blocking gap,暂不能 pass:
live实际只是 active-session 存在,不是 live worker;worker 退出后ds.worker=null但 session 仍留在 active map,same-key retry 与 trigger-result 会继续 queued/running,直到 daemon 重启。changed(current)的消费没有把 current identity/session 贯穿:reconcile 给 current attempting 写 failed,却 quarantine/close stale snapshot session;barrier 也把任意 changed→attempting 都当成自己 post-rename 的 fence。- requestHash 排除了 idempotencyKey,但 renderer 仍把 raw key 放进模型 prompt;trim 等价 key 可产生
prompt differs / hash same。
我做了确定性反证 #2:stale snapshot=sess-old/reserved,磁盘被 takeover 为 sess-new/attempting 后跑 reconcile;当前代码确实给 sess-new 写了 failed,但返回的 quarantine 只有 sess-old,close 也只调用 sess-old,sess-new 可在 restore 时重新 attach。详见 inline。
另有独立集成阻塞:PR 当前相对最新 origin/master@7ec6d4bd 为 CONFLICTING,冲突在 src/daemon.ts 的 restore hunk;rebase 时需同时保留 restoreActiveSessions(activeSessions, idempotencyQuarantinedSessionIds) 与 master 新增的 sessionsRestored = true,再重跑启动/recovery 测试。
本轮验证:PR HEAD=182917de;9 个相关/shared suite 318/318 绿;pnpm build、git diff --check 绿;GitHub CI/CodeQL 全绿。测试绿不覆盖上述 worker-exit、changed-different-identity 与 normalized-key seam。
| // session forever until the next boot's reconcile (codex #776 finding #1 | ||
| // reuse-forever). Treat any not-live attempting as terminal (at-most-once: | ||
| // never re-dispatch); reconcile / the poll-side resolver make it durable. | ||
| if (live) { |
There was a problem hiding this comment.
[P1] 这里的 live 不是注释所说的 live worker,只是 activeBySessionId() 找到任意 DaemonSession。真实 worker exit 在 worker-pool.ts:6506-6512 会把 ds.worker=null,但不会从 activeSessions 删除/close session;pending async result 也仍在。因此无 final_output 的 worker crash 后:这里继续返回 reuse/queued,而 trigger-result 又因 liveActive:!!ds + pending 返回 running,永久等到 daemon 重启才 reconcile。现有测试的 liveFor() 恰好构造了没有 worker 字段的 ds 并断言 reuse,反而把误判钉死了。需要把未完成 idempotent async turn 的 worker-exit/generation-exit接入 durable dispatch_unknown 收敛(或等价权威证据),不能用 registry presence 代替执行存活;仅改成 !!ds.worker 对 persistent pane 的 commit-unknown 也要谨慎。请补真 fork→worker exit/no final→same-key retry + trigger-result 均 failed、且不二次 fork 的回归。
| if (cur.ownerBootId === currentBootId) continue; // re-claimed by us → in-flight, untouched | ||
| if (cur.state === 'attempting') { | ||
| terminalizeAttempting(cur); | ||
| quarantined.add(record.sessionId); |
There was a problem hiding this comment.
[P1] rm.current 可能是另一条 identity/session,但 terminalize 用 cur,quarantine/close 却仍用 stale snapshot record.sessionId。确定性复现:snapshot=sess-old reserved → takeover 成 sess-new reserved → sess-new transition attempting → 让 reconcile 读旧 snapshot;结果 async failed 写在 sess-new/trg-new,quarantine Set 却只有 sess-old,close 也只调用 sess-old。随后 restore 可把已对调用方宣告 failed 的 sess-new 重新 attach 执行,正是状态/执行分叉。changed→attempting 分支至少必须以 cur.sessionId 做 quarantine/getSession/close(旧 snapshot orphan 也应独立收敛),并补 different-identity changed 的真实测试;当前测试只覆盖 same-session state advance,所以没暴露。
| // idempotencyLease is still the pre-transition `reserved` snapshot | ||
| // (markAttemptingBeforeDispatch only reassigns it on success). | ||
| const rm = idempotencyStore.compareAndRemove(larkAppId, idempotencyKey, idempotencyLease); | ||
| if (rm.kind === 'changed' && rm.current.state === 'attempting') { |
There was a problem hiding this comment.
[P1,同一 identity 绑定问题] 这里只看 rm.current.state==='attempting' 就假设“我的 rename 已落盘、仅 post-rename fsync 抛错”,却没证明 current 的 immutable identity 仍等于 idempotencyLease。若 transition 是因另一 winner/CAS 改写而抛,changed 可携带别人的 attempting;这里会给本地 loser session/trigger写 failed 并返回 terminal,而真正 current winner 继续跑。Store 的 changed 结果应区分 same identity advanced 与 different identity replaced(或调用方完整比较 owner/boot/session/trigger/requestHash);只有前者才可按本地 crossed fence terminalize,后者必须按实际 winner 处理,不能伪造本地 terminal。
| // e.g. options.status firing→resolved changed the prompt but not the hash | ||
| // (codex #776 round-4). No daemon-generated ids (session/chat/triggerId) are in | ||
| // these inputs, so the hash is stable across retries. | ||
| const { idempotencyKey: _omitKey, ...optionsForHash } = (req.options ?? {}) as Record<string, unknown>; |
There was a problem hiding this comment.
[P1] requestHash 仍没有与实际 rendered payload 闭合:这里排除了 idempotencyKey,但 buildExternalEventDataContext() 的 body.options 仍直接序列化原始 req.options,所以 raw key 会进模型 prompt。idempotencyKey:'k' 与 ' k ' 经 line 603 trim 后命中同一 lease,optionsForHash 也完全相同,但 prompt JSON 不同;第二个请求会静默复用而非 409(prompt differs=true, hash same=true)。既然 key 是 transport metadata、刻意不属于业务 hash,最小正确修法是也从 renderer 的 options 中剥掉它(或在 validator 后构造同一个 normalized execution payload供 renderer/hash 共用),并补 normalized-key whitespace 回归。
… blocking)
codex 首轮 review(4877769777)4 个 blocking,全部合理,按其口径重写:
F1(最重)—— 原设计「有 session row 即复用」会在 claim 成功后、dispatch 前崩溃时
永久 suppress(trigger-result 永久 running,正是本串一开始修的那类 bug)。改为带
dispatch 状态的 **at-most-once lease**:reserved(claim,未派发)→ attempting(任何
fork/worker IPC 副作用**前** durable CAS,commit-unknown 屏障)→ 完成态由 async-trigger
store 派生。attempting 崩溃后**绝不盲重派**(forkWorker 返回≠模型没执行);owning boot
消失且无完成证据 → 收敛到终态 dispatch_unknown,trigger-result 报 failed。新增 boot
reconcile(reconcileIdempotencyLeasesOnBoot,restoreActiveSessions 后跑):completed 留 /
attempting→terminal+closeSession / reserved→删+closeSession,让轮询收敛不再 running。
F2 —— claim I/O 故障原 fail-open(返回自己的 record → 双派发)。改 fail-closed:
claim 返回判别联合 {won|existing},只有 EEXIST+可验证 winner 算 existing;write/link/
read/坏 JSON 全 throw → 调用方 rollback 刚建 session → dispatch 前 5xx。
F3 —— stale self-heal 不可达(旧文件没删,fast-path 必返回 stale)。改为带 revision 的
CAS takeover:仅「旧 boot 的 reserved」(provably 未派发)可被新 lease 原子替换。
F4 —— validator 对所有 turn mode 开放 key 但 claim 只覆盖 fresh-session。收窄契约到
riff 唯一用法:turn + asyncReturnSessionId + !wait + !dryRun + 无 sessionId/rootMessageId/
chatId,否则 400。结构上消除 existing/auto-worktree/wait/plain 的绕过。
另(codex 补):requestHash(computeInputHash,含 instruction/envelope/影响执行 options)绑定键
→ 同键异 payload 返 409 idempotency_conflict,不静默串旧任务;映射只增不删(迟到重试复用)。
改动:新增 idempotency-store.ts(lease/CAS/takeover/fail-closed union/reconcile 枚举);
trigger-types.ts(idempotencyKey 校验+范围锁死+errorCode idempotency_conflict+response idempotent);
trigger-session.ts(resolveIdempotencyHit 决策器+lookup/claim/attempting 屏障+reconcile);
daemon.ts(boot reconcile 接线)。不带 key 的普通 trigger/webhook 行为零变化。
验证:pnpm build 通过。测试全覆盖 codex 钉的崩溃点——idempotency-store 14 测(claim won/
existing/CAS 冲突/takeover/conflict throw/corrupt fail-closed/reconcile 枚举/恶意 key);
trigger-session-idempotency 10 测(attempting无live→terminal不重派/completed跨restart复用/
reserved same-boot复用/older-boot takeover/reconcile 三态收敛);trigger-api idempotencyKey
校验+范围拒绝。affected 7 套件 139/139 绿。docs-site build 绿(中英 idempotencyKey 文档,
含适用范围/409/retention/at-most-once dispatch_unknown 语义)。
Co-Authored-By: Claude <noreply@anthropic.com>
…locker) codex 二轮 review(4878071011)7 blocker,按其拍定的 v3 设计重写。核心:lease 只管 「是否允许派发」,async-trigger-store 管「调用方看到的终态」——两者职责分离,不再靠 第三份 tombstone/index,也不靠 closeSession 成功来定义业务终态。 - #6(最核心,terminal 不接进 trigger-result):async-trigger-store 扩 status pending|completed|**failed**(failed 带 errorCode:no_output, reason:dispatch_unknown)。 新增 recordFailedStrict(per-session withFileLockSync + atomicWriteFileSync durable + 抛错, 与 recordCompleted 同锁串行,completed 更强证据恒胜)。resolveAsyncTriggerState 新增 durable-failed 分支(优先级 completed > failed > closed > pending)——即使 reconcile 的 closeSession 抛错、session 保持 open,trigger-result 也收敛 failed,不永久 running。 - #1(replace 非原子撕 tombstone):idempotency-store 全部改 atomicWriteFileSync(tmp+fsync +rename,失败保留旧文件),干掉 unlink→link。 - #2(takeover 非精确 CAS + 丢 won/existing):takeover 返回 {won|existing},锁内对完整 immutable identity(owner+boot+session+trigger+requestHash+revision)精确校验;stale rev1 不能覆盖 fresh winner rev1(新增回归测试)。lease 状态精简为 reserved|attempting(terminal 移出到 async-store)。 - #3(reconcile 跨 bot):reconcileIdempotencyLeasesOnBoot(ownerLarkAppId, currentBootId) 显式传 owner,读写/close 前 fail-closed 过滤 record.ownerLarkAppId,跳过 current boot。 - #4(reconcile 在 bind 之后):移到 setActiveSessionsRegistry 之后、startIpcServer 之前 (daemon.ts)。返回 quarantine Set 传入 restoreActiveSessions,被 terminalize 的 session 排除 re-attach(防状态/执行面分叉)。 - #5(本 boot 失败留坏 lease):barrier 前失败 compareAndRemove 释放 reserved(重试可全新); barrier 后 dispatch 同步 throw → recordFailedStrict + close(durable failed,不重派)。 - #7(HTTP 契约):trigger status mapper 加 idempotency_conflict→409;idempotent 的 state:failed 视作 200(成功 HTTP 调用报终态,非请求错误)。 - 所有 claim/takeover/transition/compareAndRemove 走同一 per-key withFileLockSync(rename 只原子替换≠CAS,必须锁内 read→校验→写)。withKeyLock/ensureDir 保证 .lock 父目录存在。 验证:pnpm build 通过。测试真穿状态机崩溃点——idempotency-store 16(含 stale-rev1 竞争 / corrupt fail-closed / compareAndRemove CAS);trigger-session-idempotency 12(真 store: attempting-orphan→async failed+close+quarantine / reserved-orphan→删+close / completed 留 / current-boot 跳过 / **OTHER-owner 跨 bot 零触碰**);trigger-api 校验+范围拒绝;async-store/ state/api-only-wiring(readiness 序不变) 全绿。affected+shared-path 11 套件 327/327 绿。 docs-site build 绿。不带 key 的普通 trigger/webhook 行为零变化。 Co-Authored-By: Claude <noreply@anthropic.com>
182917d to
d687f6a
Compare
…l-closed)
codex 三轮(review 4878310684)确认 v3 方向对、happy-path 已证;剩 6 组崩溃/IO 故障
原子性 blocker,全部按 fail-closed 修 + 补故障注入测试:
1. reserved 清理非 snapshot CAS(旧快照能删掉已推进到 attempting 的 fence)→ 新增
compareAndRemoveByPath(fp, expect):锁内重读 + 完整 identity/revision/state CAS,只删仍
匹配快照的记录;不匹配保留。reconcile 改用它(替代无条件 removeByPathLocked)。
测试:advancedState=attempting 后旧快照 remove → fence 仍在。
2. compareAndRemove 把 EIO/EROFS/EACCES 吞成 already-gone→返 true → 新增 strictUnlink:
仅 ENOENT 当已删,其余 throw。调用方(barrier 前释放)检查返回。
3. durable failed 直接 continue 不 quarantine(崩在写 failed 后 close 前→下轮 restore 重注册)
→ reconcile 对 failed 也每次 quarantine + 重试 closeSession。
4. reconcile per-record / corrupt / daemon 外层错误被吞后继续 bind(注释写 fail readiness、
行为 fail-open)→ reconcile 收集硬失败并在 sweep 后 throw;listAll({throwOnCorrupt})
corrupt lease 直接抛(不 silent skip);daemon.ts 改为 reconcile 抛错则 throw 中止该 bot
启动(不再 log-and-continue bind)。
5. barrier 后 recordFailedStrict+close 双失败仍返回 state:failed(磁盘故障下轮询永久 running)
→ 只有 recordFailedStrict 成功(terminal 真 durable)才返回 state:failed;写也失败则返
5xx trigger_failed(诚实硬错,lease 留 attempting 交下轮 reconcile)。测试:async 目标路径
预置为目录使 strict 写失败 → 断言非 phantom failed、errorCode trigger_failed。
6. recordFailedStrict 写 strict 但读用 soft load()(corrupt/EIO/invalid 当空文件覆盖,可能抹掉
completed/owner 证据)→ 新增 loadStrict:仅 ENOENT 当 absent,其余 throw;覆盖前校验
owner 不匹配则 throw。测试:corrupt 文件不被覆盖、completed-wins、late-completed-wins、
owner-proof、EIO throw。
验证:pnpm build 绿;affected+shared 10 套件 311/311 绿(store 17 / async-store 24 含 7 故障注入 /
trigger-session-idempotency 12 / e2e 5 含双故障 / trigger-api / api-only readiness 序 / …)。
docs 主路径契约不变(崩溃语义 caller-visible 不变)。普通 trigger/webhook 零行为变化。
Co-Authored-By: Claude <noreply@anthropic.com>
前 6 crash-atomicity blocker 已 held;四轮发现 2 个新 surface P1: A. requestHash 只 hash model/reasoningEffort/suppressFinalOutput,但 prompt 由整个 req.options+source+envelope+instruction+presentation 渲染。options.status firing→resolved(及 dedupKey 等)改 prompt 不改 hash → 同键静默复用而非文档承诺的 409 (codex 实测 prompt differs=true hash same=true)。修:requestHash 覆盖 instruction/ envelope/source/presentation + **整个 options 去掉 idempotencyKey**(key 是查找键非 payload;无 daemon 生成 id 混入,跨重试稳定)。 B. boolean scope-gate 与运行时不一致:validator 用 ===true 派生 async/wait, triggerSessionTurn 用 truthiness。asyncReturnSessionId:true + waitForFinalOutput:"false" 过 validator 却进 wait 分支 → fork 但不过 reserved→attempting barrier → lease 留 reserved → boot reconcile 当「从未派发」删 → 同键重试真跑第二遍(at-most-once 洞)。修:validator 严格校验 dryRun/waitForFinalOutput/asyncReturnSessionId 必须 boolean 类型,非布尔 400。 测试:trigger-api 新增非布尔 flag 拒绝("false"/1/0/"yes" 六格);e2e 新增「同 key 同 instruction 异 options.status → 409」(证 hash 覆盖全 options)。build 绿;affected+shared 8 套件 190/190 绿。普通 trigger/webhook 零行为变化。 Co-Authored-By: Claude <noreply@anthropic.com>
四处收口,全部 fail-closed / owner 正向背书: 1. attempt-barrier 失败释放:compareAndRemove 改返回判别式结果 (removed|absent|changed),不再吞 false/异常。干净移除→重试全新; changed→attempting(rename 落盘后 fsync 抛,即已跨越的 commit-unknown fence)→durable recordFailedStrict 并返回**可观测 state:failed**(非裸 5xx);compareAndRemove 抛(EIO/损坏)→诚实 5xx,lease 留给下轮 reconcile。 另:resolveIdempotencyHit 改以 LIVE-ness(而非 ownerBootId)判定"真正在飞": attempting/reserved + 同 boot + 无 live worker → terminal,杜绝同 boot 无限复用。 2. boot reconcile:compareAndRemoveByPath 返回判别式结果;对 changed→attempting 重分类为已跨越 fence(durable terminalize,绝不删),changed→current boot 跳过 (在飞),其余不可证明收敛→fail-closed 抛。store 侧锁内二次读取损坏由折成 false 改为 THROW。 3. 跨 bot owner 校验:async 终态证据仅在 asyncRec.ownerLarkAppId === lease owner 时采信(foreign completed/failed 一律忽略,修 A 采信 B 终态压制 A dispatch 的 确定性复现);session 读取由 getSession 改 getOwnedSession(不再跨 bot 文件回退 泄漏 chatId);terminalizeAttempting 遇 foreign-owned async 槽位跳过而非抛,避免 把 finding #4 的跨 bot 启动 DoS 形状重新引入。 4. 存储布局 owner 分区:idempotency/<sha256(owner)>/<keyHash>.json;listAll 改 listAllForOwner 只枚举本 owner 子目录。任一 foreign/未知 owner 坏文件不再阻断 本 bot 启动;本 owner 坏文件仍 throwOnCorrupt fail-closed。该文件从未进过任何 已发 tag、分支未并入 master,故无需迁移。 测试:idempotency-store 19、trigger-session-idempotency 20(补 #1 live-ness、 #2 CAS 重分类/损坏 abort/并发 takeover throw、#3 foreign-completed/failed、 #4 foreign-corrupt 不阻断)、e2e 9(补 #1 barrier pre-rename/post-rename/EIO 真穿 triggerSessionTurn 故障注入)。affected+shared 204/204 绿,pnpm build 绿, unit project 13132/13133(唯一 1 例为并发满载下的既有 timing flake,孤立运行 32/32 绿,与本改动无关)。 Co-Authored-By: Claude <noreply@anthropic.com>
… identity 贯穿 + key 不入 prompt) 沿真实 worker-exit 与 changed-identity 时序继续收口: 1. `live` 不再等同 registry presence——真实 worker exit 会置 ds.worker=null 却 保留 active session + pending async,旧判据会让 same-key retry 永远 reuse、 trigger-result 永远 running。改法两处:① resolveIdempotencyHit 的在飞判据 改为 liveWorker(非 killed 的真实 worker),registry 存在但 worker=null 一律 terminal;② 派发时给 ds 打 idempotentAsyncTurn 戳(owner/key/triggerId/gen), worker-exit 回调 convergeIdempotentAsyncTurnOnWorkerExit 对未完成的幂等 async turn 写权威 durable dispatch_unknown(仅收敛被戳的那个 generation,completed 已清戳;写失败不抛、留给下轮 reconcile)。final_output 完成即清戳。 2. changed(current) 贯穿 current identity/session:store 的 RemoveByPathResult 在 changed 上加 sameIdentity 判别位。① reconcile:same-identity 且已 attempting →按 cur.sessionId 做 terminalize/quarantine/close(不再用 stale snapshot 的 sessionId,杜绝把已宣告 failed 的 winner session 再 attach);different-identity →收敛我方 orphan、放过 winner(不伪造本地 terminal、不抛)。② barrier:仅当 changed 且 sameIdentity 且 attempting 才按本地 crossed fence terminalize,否则 不伪造本地 terminal、留 winner 自己收敛。 3. requestHash 与 rendered payload 闭合:buildExternalEventDataContext 也从 options 里剥掉 idempotencyKey(它是 transport metadata、刻意不入业务 hash)。 否则 'k' 与 ' k ' trim 后命中同 lease、hash 相同,但 raw key 进 prompt 导致 prompt 不同 → 静默复用而非 409。剥掉后 renderer 与 hash 共用同一 normalized execution payload,trim 等价 key 是合法复用。 测试:idempotency-store 20(补 sameIdentity 判别:same-identity advanced vs different-identity replaced)、trigger-session-idempotency 21(补 dead-worker registry-present→terminal、different-identity changed→收敛 orphan 放过 winner; afterEach restoreAllMocks 堵 spy 泄漏)、e2e 13(补真穿 worker-exit→durable dispatch_unknown→retry+poll 均 failed 不二次 fork、非匹配 generation 不误伤、 key 不入 prompt、trim 等价 key 合法复用不 409)。affected+shared 210/210 绿, pnpm build 绿。 Co-Authored-By: Claude <noreply@anthropic.com>
round-7(d687f6ad,已 rebase 到 master 75d86d0)— 收六轮 3 blocking gap + 集成冲突@codex 三组按你 round-6 的 inline 逐条收口,并已 rebase 解决 daemon restore 冲突: #1 worker-exit 收敛(
#2 reconcile changed 贯穿 current identity/session
#3 barrier changed 同一 identity 绑定
#4 requestHash 与 rendered payload 闭合
rebase:onto 测试矩阵(真穿 triggerSessionTurn + 真 store)
请以 d687f6a 为准复扫。 |
|
To use Codex here, create a Codex account and connect to github. |
补充(HEAD 现为 5f47b61)— 自查发现 finding #1 的 onCliExit 半边,已一并收口@codex 请以 5f47b61 为准复扫(在 d687f6a 之上多一个 commit)。 自查时发现 round-6 finding #1 的 worker-exit 收敛只挂了 改法: persistent-pane 的 liveWorker 也已核对:此类 CLI 的 验证:affected+shared+recovery 346/346、build 绿。 |
|
To use Codex here, create a Codex account and connect to github. |
deepcoldy
left a comment
There was a problem hiding this comment.
复审基线:5f47b611。上一轮三处修复(liveWorker、sameIdentity、render 时剥 idempotencyKey)均确认真实生效;build、diff-check 与 10 个相关套件 204/204 通过。继续沿 worker 重启、reconcile 换代、跨 bot owner-proof 三条边界向外扫,仍有 3 个 blocking finding,故本轮暂不能 pass。
| // finding #1 — the onCliExit half of that path). Idempotent + generation- | ||
| // gated: safe under the onCliExit/onWorkerExit double-callback race, and a | ||
| // no-op once final_output cleared the stamp. | ||
| convergeIdempotentAsyncTurnOnWorkerExit(ds, context.workerGeneration); |
There was a problem hiding this comment.
[P1] 这里先把 keyed turn 持久化成 dispatch_unknown,但 worker 随后的自动 CLI restart 会把同一输入重新执行。keyed fresh-async 当前 dispatchAttempt===undefined;worker.ts:10137 因而把它收入 inflight carry-over,worker-pool.ts:6166-6173 发 restart 后,spawnCli 在 worker.ts:7717-7720 又把它 unshift 回 pendingMessages。结果是调用方已看到 failed,任务却在新 CLI 上继续/再跑一次,违反 at-most-once。最小修法赞同独立 noReplay/atMostOnce 标记(不要借用有 VC receipt 语义的 dispatchAttempt),并要求同时从已写入的 inflight carry-over与仍排队的 pendingMessages 两处排除;仅在 daemon 写 failed 不足以阻止执行。请补真 worker restart 回归: keyed 输入 CLI exit 后 durable failed,replacement CLI 收不到该输入。
| // terminalize the winner. Converge OUR never-dispatched stale-snapshot | ||
| // orphan session independently; the winner is handled by its own lease | ||
| // (its own file if previous-boot, or skipped as in-flight if current-boot). | ||
| logger.warn(`[idempotency] reconcile: reserved snapshot for ${record.sessionId} was replaced by a different winner (session=${cur.sessionId} boot=${cur.ownerBootId} state=${cur.state}); converging our orphan, leaving the winner`); |
There was a problem hiding this comment.
[P1] different-identity winner 并不会被“this same sweep reach on its own file”:同 key 的 takeover 仍覆盖同一个 hashed file,而 leases 是 reconcile 开始时的一次 snapshot。确定性时序:snapshot=sess-old/reserved;CAS 前文件被替换为 sess-new/boot-OLD2,并已 advancing 到 attempting(甚至当前测试里的 old-boot reserved 也有问题);本分支只 quarantine sess-old 后 continue,sess-new 不在 snapshot 中,不会再被扫。随后 startup 成功,sess-new 可被 restore reattach;attempting 没 durable failed,reserved 还可能因 live worker 被复用,重新出现 running/执行分叉。应即时按 current record 分类(current boot 才可跳;旧 boot attempting→failed+quarantine,旧 boot reserved→对 current snapshot 再做 fenced remove+quarantine),或无法证明时中止启动;不能把它留给不存在的“own file”。请把现有 different-identity 测试扩到 winner=old-boot attempting,并断言 sweep 不成功遗留。
| // exists)? recordFailedStrict is completed-wins, but skip the write entirely | ||
| // when we can already see completion to avoid a pointless lock + log. | ||
| const existing = asyncTriggerStore.lookup(ds.session.sessionId, turn.triggerId); | ||
| if (existing?.result.status === 'completed') { ds.idempotentAsyncTurn = undefined; return; } |
There was a problem hiding this comment.
[P1] 这里看到 completed 就清 stamp,但没有验证 existing.ownerLarkAppId === turn.ownerLarkAppId。async-trigger-store 以 sessionId 为文件域;同 sessionId/triggerId 的 foreign completed(前几轮已用 owner positive-proof 作为显式不变量)会让 A bot 清掉唯一的 exit-convergence stamp。onCliExit 时 Node worker 仍 live,resolveIdempotencyHit 会忽略 foreign outcome、又因 liveWorker 复用 attempting;后续 onWorkerExit 已无 stamp,可能永久 running。请只在 owner 正向匹配时清 stamp;foreign/unstamped 不能作为本 bot completion 证据,并补 owner-mismatch 回归。
…le winner 就地分类 + exit stamp owner 校验) 1. keyed 幂等 async turn 的 at-most-once carry-over 零重放(daemon.ts + worker): CLI 退出写 dispatch_unknown 后,worker 自动重启会经 inflight carry-over (worker.ts:10137,keyed turn dispatchAttempt===undefined 被收入)与仍排队的 pendingMessages 两处把同一输入重投给新 CLI,导致调用方已见 failed、任务却再跑 一遍。改法:独立 atMostOnce 标记(不借用带 VC receipt 语义的 dispatchAttempt), forkWorker→init 消息→lastInitConfig 透传;InflightItem/PendingCliInput 增 noReplay 位;CLI exit 时 carry 谓词加 `&& !noReplay && !atMostOnceSession`, 并清空 pendingMessages(fresh async virtual 单轮,清空不误伤)。两条队列都堵。 2. reconcile different-identity winner 就地分类(trigger-session.ts):同 key 的 takeover 覆盖同一 hashed file,而 leases 是 reconcile 起点的一次性 snapshot, winner 永不会被「own file」二次扫到。改法:changed+different-identity 时先收敛 我方 never-dispatched orphan,再就地按 winner 的 current record 分类——current boot 跳过;old-boot attempting→durable failed+quarantine+close;old-boot reserved 无 live session→对 current snapshot fenced remove+quarantine;有 live session(可能在跑)→fail-closed 抛。不再留给不存在的 re-scan。 3. worker/CLI exit 收敛 stamp 清除加 owner 正向校验(trigger-session.ts): async-trigger-store 以 sessionId 为文件域,同 sessionId/triggerId 的 foreign completed 之前会清掉本 bot 唯一的 exit-convergence stamp → onCliExit 后无 stamp、 resolveIdempotencyHit 又因 liveWorker 复用 attempting、onWorkerExit 再也无法 收敛 → 永久 running。改法:仅当 existing.ownerLarkAppId===turn.ownerLarkAppId 才认作本 bot completion 并清 stamp;foreign/unstamped 不算。 测试:idempotency-store 20、trigger-session-idempotency 23(+ different-identity winner 三态矩阵:old-boot attempting→failed+双 quarantine、reserved+live→fail- closed、reserved+no-live→fenced remove)、e2e 15(+ 幂等 fork 带 atMostOnce、 foreign completed 不清 stamp)、inflight-input-tracker 14(+ noReplay 不 carry-over)。 affected+shared 237/237 绿,pnpm build 绿。 Co-Authored-By: Claude <noreply@anthropic.com>
问题:async caller(如 riff task runner)POST /api/trigger 后若 HTTP 响应丢包
(daemon 其实已建 session),caller 重试会建全新 session、turn 跑两遍 → 重复外部
副作用(发两次消息 / migration 跑两遍)。caller 自己的 dedup 挡不住——第一个
session 真在执行。
修法(方案 a,与 riff 对齐的契约):
- 新增 `options.idempotencyKey`(string,非空 ≤200 字符)。**不复用 options.dedupKey**
——那是 webhook-lifecycle 告警分组键(connectorId+dedupKey),语义不同。
- 新增 idempotency-store.ts:durable (ownerLarkAppId, key) → {sessionId, triggerId}
映射,原子 tmp+wx+link(2) 落盘(镜像 async-trigger-store),文件名 sha256(owner\0key)
——caller 任意 key 字节不进文件路径。ownerLarkAppId 戳记 → 跨 bot 查询 fail-closed。
- triggerSessionTurn:建 session 前查映射(dryRun 跳过),命中且 session 仍可解析
(live / session-store / async 结果任一)→ 返回同一 sessionId+triggerId、不新建不重投,
response 带 idempotencyKey + idempotent:true;未命中正常建。
- **并发同键**:在 setActiveSessionIfActive 之后、forkWorker(dispatch)之前 claim;
若并发同键已先 claim(返回别人的 record)→ 关掉自己刚建的 session、不 fork、返回赢家。
保证 turn 恰好派发一次(riff 单任务串行重试,daemon 侧仍做防御)。
- 陈旧映射自愈:命中但 session 完全消失 → 落到新建并 RE-CLAIM。
- **不在 session close 时删映射**(故意):async 结果本就持久保留(restart 存活),
turn 完成后的迟到重试仍须复用同 session、不能再建——与 async-trigger-store 同策略
(只增,未来 TTL 清扫统一处理)。
- response 新增 idempotencyKey 回显 + idempotent 标记。
影响面:普通 Lark trigger / webhook / schedule / vc 不带 key → 行为**零变化**
(idempotencyKey undefined 时整段逻辑跳过)。仅 async/wait HTTP trigger 显式带 key 才生效。
验证:pnpm build 通过;idempotency-store 9 测(claim/lookup/幂等复用/跨 bot 隔离/
NUL 分隔防前缀碰撞/remove/持久/恶意 key 字节)+ trigger-api idempotencyKey 校验测
(合法/空/超长/非串/缺省);trigger-api + trigger-session + async-trigger + webhook
共 187 测全绿,无回归。
② whoami 探测端点下一轮(申晗 已定排期、不阻塞)。
Co-Authored-By: Claude <noreply@anthropic.com>
… blocking)
codex 首轮 review(4877769777)4 个 blocking,全部合理,按其口径重写:
F1(最重)—— 原设计「有 session row 即复用」会在 claim 成功后、dispatch 前崩溃时
永久 suppress(trigger-result 永久 running,正是本串一开始修的那类 bug)。改为带
dispatch 状态的 **at-most-once lease**:reserved(claim,未派发)→ attempting(任何
fork/worker IPC 副作用**前** durable CAS,commit-unknown 屏障)→ 完成态由 async-trigger
store 派生。attempting 崩溃后**绝不盲重派**(forkWorker 返回≠模型没执行);owning boot
消失且无完成证据 → 收敛到终态 dispatch_unknown,trigger-result 报 failed。新增 boot
reconcile(reconcileIdempotencyLeasesOnBoot,restoreActiveSessions 后跑):completed 留 /
attempting→terminal+closeSession / reserved→删+closeSession,让轮询收敛不再 running。
F2 —— claim I/O 故障原 fail-open(返回自己的 record → 双派发)。改 fail-closed:
claim 返回判别联合 {won|existing},只有 EEXIST+可验证 winner 算 existing;write/link/
read/坏 JSON 全 throw → 调用方 rollback 刚建 session → dispatch 前 5xx。
F3 —— stale self-heal 不可达(旧文件没删,fast-path 必返回 stale)。改为带 revision 的
CAS takeover:仅「旧 boot 的 reserved」(provably 未派发)可被新 lease 原子替换。
F4 —— validator 对所有 turn mode 开放 key 但 claim 只覆盖 fresh-session。收窄契约到
riff 唯一用法:turn + asyncReturnSessionId + !wait + !dryRun + 无 sessionId/rootMessageId/
chatId,否则 400。结构上消除 existing/auto-worktree/wait/plain 的绕过。
另(codex 补):requestHash(computeInputHash,含 instruction/envelope/影响执行 options)绑定键
→ 同键异 payload 返 409 idempotency_conflict,不静默串旧任务;映射只增不删(迟到重试复用)。
改动:新增 idempotency-store.ts(lease/CAS/takeover/fail-closed union/reconcile 枚举);
trigger-types.ts(idempotencyKey 校验+范围锁死+errorCode idempotency_conflict+response idempotent);
trigger-session.ts(resolveIdempotencyHit 决策器+lookup/claim/attempting 屏障+reconcile);
daemon.ts(boot reconcile 接线)。不带 key 的普通 trigger/webhook 行为零变化。
验证:pnpm build 通过。测试全覆盖 codex 钉的崩溃点——idempotency-store 14 测(claim won/
existing/CAS 冲突/takeover/conflict throw/corrupt fail-closed/reconcile 枚举/恶意 key);
trigger-session-idempotency 10 测(attempting无live→terminal不重派/completed跨restart复用/
reserved same-boot复用/older-boot takeover/reconcile 三态收敛);trigger-api idempotencyKey
校验+范围拒绝。affected 7 套件 139/139 绿。docs-site build 绿(中英 idempotencyKey 文档,
含适用范围/409/retention/at-most-once dispatch_unknown 语义)。
Co-Authored-By: Claude <noreply@anthropic.com>
…locker) codex 二轮 review(4878071011)7 blocker,按其拍定的 v3 设计重写。核心:lease 只管 「是否允许派发」,async-trigger-store 管「调用方看到的终态」——两者职责分离,不再靠 第三份 tombstone/index,也不靠 closeSession 成功来定义业务终态。 - #6(最核心,terminal 不接进 trigger-result):async-trigger-store 扩 status pending|completed|**failed**(failed 带 errorCode:no_output, reason:dispatch_unknown)。 新增 recordFailedStrict(per-session withFileLockSync + atomicWriteFileSync durable + 抛错, 与 recordCompleted 同锁串行,completed 更强证据恒胜)。resolveAsyncTriggerState 新增 durable-failed 分支(优先级 completed > failed > closed > pending)——即使 reconcile 的 closeSession 抛错、session 保持 open,trigger-result 也收敛 failed,不永久 running。 - #1(replace 非原子撕 tombstone):idempotency-store 全部改 atomicWriteFileSync(tmp+fsync +rename,失败保留旧文件),干掉 unlink→link。 - #2(takeover 非精确 CAS + 丢 won/existing):takeover 返回 {won|existing},锁内对完整 immutable identity(owner+boot+session+trigger+requestHash+revision)精确校验;stale rev1 不能覆盖 fresh winner rev1(新增回归测试)。lease 状态精简为 reserved|attempting(terminal 移出到 async-store)。 - #3(reconcile 跨 bot):reconcileIdempotencyLeasesOnBoot(ownerLarkAppId, currentBootId) 显式传 owner,读写/close 前 fail-closed 过滤 record.ownerLarkAppId,跳过 current boot。 - #4(reconcile 在 bind 之后):移到 setActiveSessionsRegistry 之后、startIpcServer 之前 (daemon.ts)。返回 quarantine Set 传入 restoreActiveSessions,被 terminalize 的 session 排除 re-attach(防状态/执行面分叉)。 - #5(本 boot 失败留坏 lease):barrier 前失败 compareAndRemove 释放 reserved(重试可全新); barrier 后 dispatch 同步 throw → recordFailedStrict + close(durable failed,不重派)。 - #7(HTTP 契约):trigger status mapper 加 idempotency_conflict→409;idempotent 的 state:failed 视作 200(成功 HTTP 调用报终态,非请求错误)。 - 所有 claim/takeover/transition/compareAndRemove 走同一 per-key withFileLockSync(rename 只原子替换≠CAS,必须锁内 read→校验→写)。withKeyLock/ensureDir 保证 .lock 父目录存在。 验证:pnpm build 通过。测试真穿状态机崩溃点——idempotency-store 16(含 stale-rev1 竞争 / corrupt fail-closed / compareAndRemove CAS);trigger-session-idempotency 12(真 store: attempting-orphan→async failed+close+quarantine / reserved-orphan→删+close / completed 留 / current-boot 跳过 / **OTHER-owner 跨 bot 零触碰**);trigger-api 校验+范围拒绝;async-store/ state/api-only-wiring(readiness 序不变) 全绿。affected+shared-path 11 套件 327/327 绿。 docs-site build 绿。不带 key 的普通 trigger/webhook 行为零变化。 Co-Authored-By: Claude <noreply@anthropic.com>
…于 helper-only 测试的反复提醒) codex 两轮都指出:现有 idempotency 测试停在 helper 层(resolveIdempotencyHit / reconcile), 没真正驱动 triggerSessionTurn 的 claim→barrier→fork 及故障分支。补一个 e2e 用真 idempotency-store + async-trigger-store(temp SESSION_DATA_DIR)、mock 边界(lark/ session-store/worker-pool),forkWorker 可注入抛错,断言: - 首次调用只 fork 一次、lease 跨过 barrier 到 attempting、idempotent:false; - 同键同 payload 重试复用、**不二次 fork**; - 同键异 payload → 409 idempotency_conflict、不 fork; - **barrier 后 fork 同步抛错 → 写 durable async failed(dispatch_unknown) + closeSession, HTTP 报 state:failed 而非 queued;同键重试解析为 terminal、绝不 re-fork**(at-most-once 端到端)。 验证:4 e2e 全绿;idempotency+adjacent 8 套件 181/181 绿;build 绿。 Co-Authored-By: Claude <noreply@anthropic.com>
…l-closed)
codex 三轮(review 4878310684)确认 v3 方向对、happy-path 已证;剩 6 组崩溃/IO 故障
原子性 blocker,全部按 fail-closed 修 + 补故障注入测试:
1. reserved 清理非 snapshot CAS(旧快照能删掉已推进到 attempting 的 fence)→ 新增
compareAndRemoveByPath(fp, expect):锁内重读 + 完整 identity/revision/state CAS,只删仍
匹配快照的记录;不匹配保留。reconcile 改用它(替代无条件 removeByPathLocked)。
测试:advancedState=attempting 后旧快照 remove → fence 仍在。
2. compareAndRemove 把 EIO/EROFS/EACCES 吞成 already-gone→返 true → 新增 strictUnlink:
仅 ENOENT 当已删,其余 throw。调用方(barrier 前释放)检查返回。
3. durable failed 直接 continue 不 quarantine(崩在写 failed 后 close 前→下轮 restore 重注册)
→ reconcile 对 failed 也每次 quarantine + 重试 closeSession。
4. reconcile per-record / corrupt / daemon 外层错误被吞后继续 bind(注释写 fail readiness、
行为 fail-open)→ reconcile 收集硬失败并在 sweep 后 throw;listAll({throwOnCorrupt})
corrupt lease 直接抛(不 silent skip);daemon.ts 改为 reconcile 抛错则 throw 中止该 bot
启动(不再 log-and-continue bind)。
5. barrier 后 recordFailedStrict+close 双失败仍返回 state:failed(磁盘故障下轮询永久 running)
→ 只有 recordFailedStrict 成功(terminal 真 durable)才返回 state:failed;写也失败则返
5xx trigger_failed(诚实硬错,lease 留 attempting 交下轮 reconcile)。测试:async 目标路径
预置为目录使 strict 写失败 → 断言非 phantom failed、errorCode trigger_failed。
6. recordFailedStrict 写 strict 但读用 soft load()(corrupt/EIO/invalid 当空文件覆盖,可能抹掉
completed/owner 证据)→ 新增 loadStrict:仅 ENOENT 当 absent,其余 throw;覆盖前校验
owner 不匹配则 throw。测试:corrupt 文件不被覆盖、completed-wins、late-completed-wins、
owner-proof、EIO throw。
验证:pnpm build 绿;affected+shared 10 套件 311/311 绿(store 17 / async-store 24 含 7 故障注入 /
trigger-session-idempotency 12 / e2e 5 含双故障 / trigger-api / api-only readiness 序 / …)。
docs 主路径契约不变(崩溃语义 caller-visible 不变)。普通 trigger/webhook 零行为变化。
Co-Authored-By: Claude <noreply@anthropic.com>
前 6 crash-atomicity blocker 已 held;四轮发现 2 个新 surface P1: A. requestHash 只 hash model/reasoningEffort/suppressFinalOutput,但 prompt 由整个 req.options+source+envelope+instruction+presentation 渲染。options.status firing→resolved(及 dedupKey 等)改 prompt 不改 hash → 同键静默复用而非文档承诺的 409 (codex 实测 prompt differs=true hash same=true)。修:requestHash 覆盖 instruction/ envelope/source/presentation + **整个 options 去掉 idempotencyKey**(key 是查找键非 payload;无 daemon 生成 id 混入,跨重试稳定)。 B. boolean scope-gate 与运行时不一致:validator 用 ===true 派生 async/wait, triggerSessionTurn 用 truthiness。asyncReturnSessionId:true + waitForFinalOutput:"false" 过 validator 却进 wait 分支 → fork 但不过 reserved→attempting barrier → lease 留 reserved → boot reconcile 当「从未派发」删 → 同键重试真跑第二遍(at-most-once 洞)。修:validator 严格校验 dryRun/waitForFinalOutput/asyncReturnSessionId 必须 boolean 类型,非布尔 400。 测试:trigger-api 新增非布尔 flag 拒绝("false"/1/0/"yes" 六格);e2e 新增「同 key 同 instruction 异 options.status → 409」(证 hash 覆盖全 options)。build 绿;affected+shared 8 套件 190/190 绿。普通 trigger/webhook 零行为变化。 Co-Authored-By: Claude <noreply@anthropic.com>
四处收口,全部 fail-closed / owner 正向背书: 1. attempt-barrier 失败释放:compareAndRemove 改返回判别式结果 (removed|absent|changed),不再吞 false/异常。干净移除→重试全新; changed→attempting(rename 落盘后 fsync 抛,即已跨越的 commit-unknown fence)→durable recordFailedStrict 并返回**可观测 state:failed**(非裸 5xx);compareAndRemove 抛(EIO/损坏)→诚实 5xx,lease 留给下轮 reconcile。 另:resolveIdempotencyHit 改以 LIVE-ness(而非 ownerBootId)判定"真正在飞": attempting/reserved + 同 boot + 无 live worker → terminal,杜绝同 boot 无限复用。 2. boot reconcile:compareAndRemoveByPath 返回判别式结果;对 changed→attempting 重分类为已跨越 fence(durable terminalize,绝不删),changed→current boot 跳过 (在飞),其余不可证明收敛→fail-closed 抛。store 侧锁内二次读取损坏由折成 false 改为 THROW。 3. 跨 bot owner 校验:async 终态证据仅在 asyncRec.ownerLarkAppId === lease owner 时采信(foreign completed/failed 一律忽略,修 A 采信 B 终态压制 A dispatch 的 确定性复现);session 读取由 getSession 改 getOwnedSession(不再跨 bot 文件回退 泄漏 chatId);terminalizeAttempting 遇 foreign-owned async 槽位跳过而非抛,避免 把 finding #4 的跨 bot 启动 DoS 形状重新引入。 4. 存储布局 owner 分区:idempotency/<sha256(owner)>/<keyHash>.json;listAll 改 listAllForOwner 只枚举本 owner 子目录。任一 foreign/未知 owner 坏文件不再阻断 本 bot 启动;本 owner 坏文件仍 throwOnCorrupt fail-closed。该文件从未进过任何 已发 tag、分支未并入 master,故无需迁移。 测试:idempotency-store 19、trigger-session-idempotency 20(补 #1 live-ness、 #2 CAS 重分类/损坏 abort/并发 takeover throw、#3 foreign-completed/failed、 #4 foreign-corrupt 不阻断)、e2e 9(补 #1 barrier pre-rename/post-rename/EIO 真穿 triggerSessionTurn 故障注入)。affected+shared 204/204 绿,pnpm build 绿, unit project 13132/13133(唯一 1 例为并发满载下的既有 timing flake,孤立运行 32/32 绿,与本改动无关)。 Co-Authored-By: Claude <noreply@anthropic.com>
… identity 贯穿 + key 不入 prompt) 沿真实 worker-exit 与 changed-identity 时序继续收口: 1. `live` 不再等同 registry presence——真实 worker exit 会置 ds.worker=null 却 保留 active session + pending async,旧判据会让 same-key retry 永远 reuse、 trigger-result 永远 running。改法两处:① resolveIdempotencyHit 的在飞判据 改为 liveWorker(非 killed 的真实 worker),registry 存在但 worker=null 一律 terminal;② 派发时给 ds 打 idempotentAsyncTurn 戳(owner/key/triggerId/gen), worker-exit 回调 convergeIdempotentAsyncTurnOnWorkerExit 对未完成的幂等 async turn 写权威 durable dispatch_unknown(仅收敛被戳的那个 generation,completed 已清戳;写失败不抛、留给下轮 reconcile)。final_output 完成即清戳。 2. changed(current) 贯穿 current identity/session:store 的 RemoveByPathResult 在 changed 上加 sameIdentity 判别位。① reconcile:same-identity 且已 attempting →按 cur.sessionId 做 terminalize/quarantine/close(不再用 stale snapshot 的 sessionId,杜绝把已宣告 failed 的 winner session 再 attach);different-identity →收敛我方 orphan、放过 winner(不伪造本地 terminal、不抛)。② barrier:仅当 changed 且 sameIdentity 且 attempting 才按本地 crossed fence terminalize,否则 不伪造本地 terminal、留 winner 自己收敛。 3. requestHash 与 rendered payload 闭合:buildExternalEventDataContext 也从 options 里剥掉 idempotencyKey(它是 transport metadata、刻意不入业务 hash)。 否则 'k' 与 ' k ' trim 后命中同 lease、hash 相同,但 raw key 进 prompt 导致 prompt 不同 → 静默复用而非 409。剥掉后 renderer 与 hash 共用同一 normalized execution payload,trim 等价 key 是合法复用。 测试:idempotency-store 20(补 sameIdentity 判别:same-identity advanced vs different-identity replaced)、trigger-session-idempotency 21(补 dead-worker registry-present→terminal、different-identity changed→收敛 orphan 放过 winner; afterEach restoreAllMocks 堵 spy 泄漏)、e2e 13(补真穿 worker-exit→durable dispatch_unknown→retry+poll 均 failed 不二次 fork、非匹配 generation 不误伤、 key 不入 prompt、trim 等价 key 合法复用不 409)。affected+shared 210/210 绿, pnpm build 绿。 Co-Authored-By: Claude <noreply@anthropic.com>
round-6 finding #1 的 worker-exit 收敛只挂了 onWorkerExit,漏了 onCliExit: persistent-pane / codex-app 的 managed CLI 可在 Node worker 仍存活时退出,此时 onWorkerExit 不触发。未完成的幂等 async turn 若其 CLI 无 final_output 退出, trigger-result 仍 poll running、同键重试仍 reuse 坏 generation,直到下轮 reconcile。 改法:onCliExit 回调同样调 convergeIdempotentAsyncTurnOnWorkerExit(generation 门控 + completed 已清戳 → 幂等,且对 onCliExit/onWorkerExit 双回调竞态安全)。 回调签名 _ds→ds 后同步更新源码锁测试,并加一条断言两个回调都接了收敛。 验证:affected+shared+recovery 全绿(346/346 相关套件),pnpm build 绿。 Co-Authored-By: Claude <noreply@anthropic.com>
720b8e1 to
f88f929
Compare
…le winner 就地分类 + exit stamp owner 校验) 1. keyed 幂等 async turn 的 at-most-once carry-over 零重放(daemon.ts + worker): CLI 退出写 dispatch_unknown 后,worker 自动重启会经 inflight carry-over (worker.ts:10137,keyed turn dispatchAttempt===undefined 被收入)与仍排队的 pendingMessages 两处把同一输入重投给新 CLI,导致调用方已见 failed、任务却再跑 一遍。改法:独立 atMostOnce 标记(不借用带 VC receipt 语义的 dispatchAttempt), forkWorker→init 消息→lastInitConfig 透传;InflightItem/PendingCliInput 增 noReplay 位;CLI exit 时 carry 谓词加 `&& !noReplay && !atMostOnceSession`, 并清空 pendingMessages(fresh async virtual 单轮,清空不误伤)。两条队列都堵。 2. reconcile different-identity winner 就地分类(trigger-session.ts):同 key 的 takeover 覆盖同一 hashed file,而 leases 是 reconcile 起点的一次性 snapshot, winner 永不会被「own file」二次扫到。改法:changed+different-identity 时先收敛 我方 never-dispatched orphan,再就地按 winner 的 current record 分类——current boot 跳过;old-boot attempting→durable failed+quarantine+close;old-boot reserved 无 live session→对 current snapshot fenced remove+quarantine;有 live session(可能在跑)→fail-closed 抛。不再留给不存在的 re-scan。 3. worker/CLI exit 收敛 stamp 清除加 owner 正向校验(trigger-session.ts): async-trigger-store 以 sessionId 为文件域,同 sessionId/triggerId 的 foreign completed 之前会清掉本 bot 唯一的 exit-convergence stamp → onCliExit 后无 stamp、 resolveIdempotencyHit 又因 liveWorker 复用 attempting、onWorkerExit 再也无法 收敛 → 永久 running。改法:仅当 existing.ownerLarkAppId===turn.ownerLarkAppId 才认作本 bot completion 并清 stamp;foreign/unstamped 不算。 测试:idempotency-store 20、trigger-session-idempotency 23(+ different-identity winner 三态矩阵:old-boot attempting→failed+双 quarantine、reserved+live→fail- closed、reserved+no-live→fenced remove)、e2e 15(+ 幂等 fork 带 atMostOnce、 foreign completed 不清 stamp)、inflight-input-tracker 14(+ noReplay 不 carry-over)。 affected+shared 237/237 绿,pnpm build 绿。 Co-Authored-By: Claude <noreply@anthropic.com>
round-8(f88f9297,已 rebase 到 master 10b065c)— 收七轮 3 blocking gap@codex 三处按你 round-7 inline 逐条收口,请以 f88f929 复扫: #1 carry-over 零重放(both queues) #2 reconcile different-identity winner 就地分类 #3 exit 收敛 stamp 清除加 owner 正向校验 测试矩阵
真 worker restart 集成回归(你要求的「keyed 输入 CLI exit 后 durable failed、replacement CLI 收不到」):daemon 侧断言 fork 带 atMostOnce(e2e),worker 侧碳排除谓词单测(inflight-input-tracker),两端拼合覆盖该路径。请复扫。 |
|
To use Codex here, create a Codex account and connect to github. |
…le winner 就地分类 + exit stamp owner 校验) 1. keyed 幂等 async turn 的 at-most-once carry-over 零重放(daemon.ts + worker): CLI 退出写 dispatch_unknown 后,worker 自动重启会经 inflight carry-over (worker.ts:10137,keyed turn dispatchAttempt===undefined 被收入)与仍排队的 pendingMessages 两处把同一输入重投给新 CLI,导致调用方已见 failed、任务却再跑 一遍。改法:独立 atMostOnce 标记(不借用带 VC receipt 语义的 dispatchAttempt), forkWorker→init 消息→lastInitConfig 透传;InflightItem/PendingCliInput 增 noReplay 位;CLI exit 时 carry 谓词加 `&& !noReplay && !atMostOnceSession`, 并清空 pendingMessages(fresh async virtual 单轮,清空不误伤)。两条队列都堵。 2. reconcile different-identity winner 就地分类(trigger-session.ts):同 key 的 takeover 覆盖同一 hashed file,而 leases 是 reconcile 起点的一次性 snapshot, winner 永不会被「own file」二次扫到。改法:changed+different-identity 时先收敛 我方 never-dispatched orphan,再就地按 winner 的 current record 分类——current boot 跳过;old-boot attempting→durable failed+quarantine+close;old-boot reserved 无 live session→对 current snapshot fenced remove+quarantine;有 live session(可能在跑)→fail-closed 抛。不再留给不存在的 re-scan。 3. worker/CLI exit 收敛 stamp 清除加 owner 正向校验(trigger-session.ts): async-trigger-store 以 sessionId 为文件域,同 sessionId/triggerId 的 foreign completed 之前会清掉本 bot 唯一的 exit-convergence stamp → onCliExit 后无 stamp、 resolveIdempotencyHit 又因 liveWorker 复用 attempting、onWorkerExit 再也无法 收敛 → 永久 running。改法:仅当 existing.ownerLarkAppId===turn.ownerLarkAppId 才认作本 bot completion 并清 stamp;foreign/unstamped 不算。 测试:idempotency-store 20、trigger-session-idempotency 23(+ different-identity winner 三态矩阵:old-boot attempting→failed+双 quarantine、reserved+live→fail- closed、reserved+no-live→fenced remove)、e2e 15(+ 幂等 fork 带 atMostOnce、 foreign completed 不清 stamp)、inflight-input-tracker 14(+ noReplay 不 carry-over)。 affected+shared 237/237 绿,pnpm build 绿。 Co-Authored-By: Claude <noreply@anthropic.com>
f88f929 to
110ad87
Compare
|
补充:HEAD 现为 110ad87(在 f88f929 上加一个无行为变更的清理 commit)。自查发现两处 dead scaffolding,已收干净:
carry-over 零重放的实际保护完全来自 |
改了什么
给
POST /api/trigger加幂等键:caller 传options.idempotencyKey,同键重试返回同一个 session(不新建、不重投),从根上消除「HTTP 响应丢包 → caller 重试 → turn 跑两遍」的重复外部副作用。为什么
riff code review ①(高优先/唯一会「真跑两遍」的风险):async caller(riff task runner)POST /api/trigger 后若响应在网络中丢了(daemon 其实已建 session),caller 2min recovery 会再发一次 → 建全新 session、turn 执行两遍(发两次消息 / migration 跑两遍)。caller 自己的 executionEpoch 只挡 DB 覆盖,挡不住第一个 session 真在执行。契约已与 riff 对齐(方案 a)。
实现
options.idempotencyKey(string,非空 ≤200 字符)。不复用options.dedupKey——那是 webhook-lifecycle 告警分组(connectorId+dedupKey),语义不同、且 validateTriggerRequest 原本都没校验它。src/services/idempotency-store.ts:durable(ownerLarkAppId, key) → {sessionId, triggerId}映射。原子落盘writeFileSync(wx) + link(2)(EEXIST 选主,镜像loadOrCreateDashboardSecret/ async-trigger-store),文件名sha256(owner\0key)—— caller 任意 key 字节不进文件系统路径(防遍历/超长/非法字符),NUL 分隔防(a,bc)与(ab,c)碰撞。ownerLarkAppId戳记 → 跨 bot 查询 fail-closed。triggerSessionTurn两处:lookup(dryRun 跳过):命中且 session 仍可解析(live / session-store / async 结果任一)→ 返回同一 sessionId+triggerId、idempotent:true;命中但 session 全消失 → 落新建并 RE-CLAIM(自愈)。setActiveSessionIfActive之后、forkWorker(dispatch)之前claim:并发同键若已被别人先 claim → 关掉自己刚建的 session、不 fork、返回赢家。turn 恰好派发一次。idempotencyKey回显 +idempotent标记。影响面
idempotencyKeyundefined 时整段逻辑跳过)。仅 async/wait HTTP trigger 显式带 key 才生效。测试验证
pnpm build通过。../../etc/passwd\0)。契约(给 riff 对接)
options.idempotencyKey: string{ ok:true, idempotent:true, idempotencyKey, triggerId, target.sessionId, async.status:'pending' }—— 复用,无新 dispatch{ ok:true, idempotent:false, idempotencyKey, ... }GET /api/sessions/:id/trigger-result(四态)判权威结果,无需新反查端点。②(
GET /api/whoami强鉴权探测端点)按 申晗 定排期、下一轮,不在本 PR。🤖 Generated with Claude Code