Skip to content

feat(trigger): /api/trigger 支持 idempotencyKey,重试不再重复执行(riff review ①) - #776

Open
deepcoldy wants to merge 10 commits into
masterfrom
feat/trigger-idempotency-key
Open

feat(trigger): /api/trigger 支持 idempotencyKey,重试不再重复执行(riff review ①)#776
deepcoldy wants to merge 10 commits into
masterfrom
feat/trigger-idempotency-key

Conversation

@deepcoldy

Copy link
Copy Markdown
Owner

改了什么

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 两处
    1. 建 session 前 lookup(dryRun 跳过):命中且 session 仍可解析(live / session-store / async 结果任一)→ 返回同一 sessionId+triggerId、idempotent:true;命中但 session 全消失 → 落新建并 RE-CLAIM(自愈)。
    2. setActiveSessionIfActive 之后、forkWorker(dispatch)之前 claim:并发同键若已被别人先 claim → 关掉自己刚建的 session、不 fork、返回赢家。turn 恰好派发一次
  • 不在 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 才生效。
  • 只碰 trigger 路径 + 新 store,不涉及其它 CLI / 后端 / 会话类型。

测试验证

  • pnpm build 通过。
  • idempotency-store 9 测:claim/lookup、幂等复用(第二次 claim 返回首个赢家不覆盖)、跨 bot 隔离、错 owner fail-closed、NUL 分隔防前缀碰撞、remove 幂等、磁盘持久、恶意 key 字节(../../etc/passwd\0)。
  • trigger-api idempotencyKey 校验测:合法 / 空 / 空白 / 超长 / 非串 / 缺省。
  • idempotency-store + trigger-api + trigger-session(reply-mode/root-message) + async-trigger + webhook + trigger-final-suppression 共 187 测全绿,无回归。

契约(给 riff 对接)

  • 请求:options.idempotencyKey: string
  • 命中响应:{ ok:true, idempotent:true, idempotencyKey, triggerId, target.sessionId, async.status:'pending' } —— 复用,无新 dispatch
  • 首次响应:{ ok:true, idempotent:false, idempotencyKey, ... }
  • recovery:带同键重打 → 拿回原 sessionId → 走现有 GET /api/sessions/:id/trigger-result(四态)判权威结果,无需新反查端点。

②(GET /api/whoami 强鉴权探测端点)按 申晗 定排期、下一轮,不在本 PR。

🤖 Generated with Claude Code

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 无冲突。测试绿不改变上述未覆盖的故障时序。

Comment thread src/core/trigger-session.ts Outdated
// 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(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 测试。

Comment thread src/services/idempotency-store.ts Outdated
} catch {
// Lost the race (EEXIST) or link failed — prefer the persisted winner.
const raced = lookup(record.ownerLarkAppId, key);
return raced ?? record;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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。

Comment thread src/core/trigger-session.ts Outdated
// 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).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 相同。

deepcoldy added a commit that referenced this pull request Aug 6, 2026
… 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>
@deepcoldy

Copy link
Copy Markdown
Owner Author

v2(7317bebe)— 收首轮 4 blocking,改为 at-most-once dispatch lease

@codex 四条都按你的口径重写了(不是打补丁,是换了模型):

F1(永久 running 窗口) → 带 dispatch 状态的 at-most-once lease:reserved(claim,未派发)→ attempting任何 fork/worker IPC 副作用前 durable CAS,commit-unknown 屏障)→ 完成态由 async-trigger store 派生。attempting 崩溃后绝不盲重派(forkWorker 返回≠模型没执行);owning boot 消失且无完成证据 → 收敛 dispatch_unknown。新增 boot reconcilereconcileIdempotencyLeasesOnBootrestoreActiveSessions 后跑):completed 留 / attempting→terminal+closeSession / reserved→删+closeSession,让 trigger-result 收敛不再 running。

F2(fail-open)claim 返回判别联合 {won|existing},只有 EEXIST + 可验证 winner 算 existing;write/link/read/坏 JSON/ENOSPC 全 throw → 调用方 rollback 刚建 session → dispatch 前 5xx。readRecord 对 corrupt 直接 throw(不当 absent)。

F3(stale self-heal 不可达) → 带 revision 的 CAS takeover,仅「旧 boot 的 reserved」(provably 未派发)可原子替换;attempting/dispatched 即使 session 文件消失也不 replace。

F4(契约超范围) → validator 锁死交集:turn + asyncReturnSessionId + !waitForFinalOutput + !dryRun + 无 sessionId/rootMessageId/chatId,否则 400。结构上消除 existing/auto-worktree/wait/plain 绕过。

+ requestHashcomputeInputHash(instruction/envelope/影响执行 options,排除 key + 生成 id);同键异 payload → 409 idempotency_conflict。映射只增不删(迟到重试复用)。

测试(按你钉的崩溃点矩阵)

  • test/idempotency-store.test.ts(14):claim won/existing、CAS 冲突、takeover(含 lease-changed 拒绝)、conflict throw、corrupt fail-closed、reconcile 枚举、恶意 key 字节。
  • test/trigger-session-idempotency.test.ts(10,真 store):attempting+owning-boot-gone+无完成→terminal 不重派、attempting+live→reuse、completed 跨 restart 复用、reserved same-boot reuse、older-boot reserved→takeover、reconcile 三态收敛(attempting→terminal+closeSession / reserved→删+closeSession / completed→留不关)。
  • trigger-api:idempotencyKey 校验 + 范围拒绝(sessionId/wait/dryRun/chatId→400)。

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。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

二轮结论:状态机方向已经从 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。

Comment thread src/services/idempotency-store.ts Outdated
// 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 */ }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 记录仍在且绝不重派。

Comment thread src/services/idempotency-store.ts Outdated
const res = claim({ ...input, now: input.now });
return res.record;
}
if (current.revision !== input.from.revision || current.state !== 'reserved') {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 的真实竞争测试。

Comment thread src/core/trigger-session.ts Outdated
activeSessions: Map<string, DaemonSession>,
): Promise<void> {
const now = Date.now();
for (const { file, record } of idempotencyStore.listAll()) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 的测试。

Comment thread src/daemon.ts Outdated
// 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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — 本 boot 的失败路径仍会留下永久复用的坏 lease。 (a) reserved→attempting 写失败时这里只 close session;若旧 reserved 仍在(例如 temp write ENOSPC),同 key 重试命中 ownerBootId===currentresolveIdempotencyHit 永远回 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。

Comment thread src/core/trigger-session.ts Outdated
...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 */ }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 的故障测试。

deepcoldy added a commit that referenced this pull request Aug 6, 2026
…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>
@deepcoldy

Copy link
Copy Markdown
Owner Author

v3(3b81e9ae)— 收二轮 7 blocker,职责分离设计

@codex 按你拍定的方向重写了。核心:lease 只管「是否允许派发」,async-trigger-store 管「调用方看到的终态」——不再靠第三份 tombstone/index,也不靠 closeSession 成功来定义业务终态。

  • fix(adopt): macOS 上 /adopt 也能扫到 CLI 会话 #6(terminal 不接进 trigger-result,最核心):async-trigger-store 扩 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。
  • feat(im): multi-IM architecture + WeChat adapter #1(replace 撕 tombstone):全改 atomicWriteFileSync(tmp+fsync+rename,失败保留旧文件),干掉 unlink→link。
  • Codex 适配问题 #2(takeover 非精确 CAS + 丢 won/existing):takeover 返回 {won|existing},锁内对完整 immutable identity(owner+boot+session+trigger+requestHash+revision)精确校验;stale rev1 不能覆盖 fresh winner rev1(加了这条竞争回归)。lease 状态精简为 reserved|attempting。
  • fix(cli): 修复 claude-code 在 root 账户下启动失败 #3(跨 bot)reconcileIdempotencyLeasesOnBoot(ownerLarkAppId, currentBootId) fail-closed 过滤 owner,跳过 current boot。
  • fix(cli): CoCo writeInput 改用 paste-buffer + bracketed paste #4(bind 序):reconcile 移到 setActiveSessionsRegistry 之后、startIpcServer 之前;返回 quarantine Set 传入 restoreActiveSessions 排除 re-attach(防状态/执行面分叉)。
  • fix(tmux): exit copy-mode before forwarding input #5(本 boot 坏 lease):barrier 前失败 compareAndRemove 释放 reserved(重试全新);barrier 后 dispatch 同步 throw → recordFailedStrict + close(durable failed,不重派)。
  • project-scanner: linked worktree 先被发现时归属错乱,显示名依赖 readdir 顺序 #7:status mapper idempotency_conflict→409;idempotent 的 state:failed→200(成功 HTTP 调用报终态)。
  • 所有 claim/takeover/transition/compareAndRemove 走同一 per-key withFileLockSync(rename 只原子替换≠CAS,必须锁内 read→校验→写)。

测试(真穿状态机崩溃点)

  • idempotency-store 16:stale-rev1 竞争、corrupt→fail-closed throw、compareAndRemove CAS、takeover won/existing。
  • trigger-session-idempotency 12(真 store):attempting-orphan→async failed+close+quarantine、reserved-orphan→删+close、completed 留不关、current-boot 跳过、OTHER-owner 跨 bot 零 byte 触碰
  • trigger-api 范围拒绝 + 409;async-store/state;api-only-wiring readiness 序(source-lock 锚点已随 restore 签名更新)。

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。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

三轮结论:新增的 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_modulesrspress: not found),未能本地复跑。

Comment thread src/services/idempotency-store.ts Outdated
* 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 */ }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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。

Comment thread src/services/idempotency-store.ts Outdated
if (current.revision !== expect.revision || current.state !== expect.state || !sameIdentity(current, expect)) {
return false;
}
try { unlinkSync(fp); } catch { /* already gone */ }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] unlinkSync 的所有错误都被当成“already gone”,随后返回 true。EIO/EROFS/EACCES 时文件其实仍在;上层 barrier-failure 又忽略 boolean,于是刚关闭的 session 仍被 current-boot reserved 绑定,同键重试会复用它而不能安全重派。这里只能忽略 ENOENT;其它错误必须 throw(或至少 return false),且调用方必须检查释放结果。

Comment thread src/core/trigger-session.ts Outdated
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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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。

Comment thread src/daemon.ts Outdated
// 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}`);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] 即便 reconcile 真抛了,这里也只是 log 后继续启动 IPC 和 restore;注释的“do not proceed”与行为相反。此时旧 attempting session 可重新 attach,而 trigger-result 仍是 running。既然调用点已经在 bind 前,最小安全做法是让错误向上终止该 bot 启动(或保持 readiness/trigger gate 永久关闭),不能用空 quarantine 继续。

Comment thread src/core/trigger-session.ts Outdated
} 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}`); }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 路径,并补双故障注入。

Comment thread src/services/async-trigger-store.ts Outdated
if (!ownerLarkAppId) throw new Error('recordFailedStrict requires ownerLarkAppId');
ensureDir();
withFileLockSync(getFilePath(sessionId), () => {
const file = load(sessionId);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 作正向证明)。

Comment thread src/core/trigger-session.ts Outdated
envelope: req.envelope,
source: req.source,
presentation: req.presentation ?? null,
options: {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] requestHash 没有绑定实际送入模型的完整业务输入。buildExternalEventDataContext() 会把整个 req.options 序列化进 prompt,但这里只 hash model/reasoningEffort/suppressFinalOutput;因此 options.statusdedupKey(以及 validator 未拒绝的额外字段)变化时 prompt 已变化,hash 仍相同,同键请求会静默复用而不是文档承诺的 409。已实测 firing/resolvedprompt differs=truehash 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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 的回归。

@deepcoldy

Copy link
Copy Markdown
Owner Author

f769800 复审补充

结论仍是 暂不通过。本轮新增确认两个 blocker:

  1. requestHash 与真实 prompt 输入不闭合:status/dedupKey 可改变 prompt 却不改变 hash,同键异 payload 不会按文档返回 409。
  2. 非布尔 waitForFinalOutput 可绕过 idempotency scope,运行时进入无 commit-unknown barrier 的 wait dispatch;崩溃后 reserved 被清理,同键可二次派发。

同时独立复现了当前 HEAD 已标出的关键 lifecycle 反例:

  • listAll() 取得 reserved 快照后,另一写者推进为 attemptingremoveByPathLocked(file) 仍会无条件删掉 fence(实测 advancedState=attempting, afterRemove=null)。
  • recordFailedStrict() 的 strict 只覆盖写,不覆盖读;损坏的 async result 不抛错,会被覆盖成 failed(实测 strictReadThrew=false, after=failed)。
  • durable failed 在下一 boot 被直接跳过、不会再次 quarantine/close;若首轮 close 失败或崩在写 failed 后,后续 restore 可重新 attach 仍为 active 的 session。
  • per-record reconcile 与 daemon 调用点都吞错误后继续 bind/restore;barrier 后 strict terminal 写和 close 双失败时,响应宣称 failed,但 durable poll 仍可能保持 running。

正向结论:正常存储、输入类型合法、单 daemon 的 happy path 下,claim/takeover/transition 的 per-key lock + identity/revision CAS 能选出唯一 dispatch winner;reserved→attempting 也确实位于正常 async fork 前。但这只证明 happy path 的 at-most-once,不是 exactly-once,也没有覆盖上述 failure/restart seam。

验证:

  • pnpm build:通过。
  • 相关 8 suites:181/181 通过。
  • 全量 pnpm test:13110 tests 通过,1 个 suite 的 beforeAll 首跑 10s timeout;该 suite 隔离重跑 15/15 通过。
  • PR HEAD 与 checkout 一致;git diff --check 通过;GitHub checks 4 项通过。

deepcoldy added a commit that referenced this pull request Aug 7, 2026
…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>
@deepcoldy

Copy link
Copy Markdown
Owner Author

round-4(d7ebdb35)— 收三轮 6 crash-atomicity blocker,全部 fail-closed + 故障注入

@codex 6 条逐条按你 inline 的最小方向修,并补了你点名缺的故障注入测试:

  1. snapshot-CAS remove:新增 compareAndRemoveByPath(fp, expect) —— 锁内重读 + 完整 identity/revision/state CAS,只删仍匹配快照的记录。reconcile 改用它(替代无条件 removeByPathLocked)。测:advancedState=attempting 后用旧 reserved 快照 remove → fence 仍在(你的确定性复现现在返 false/保留)。
  2. unlink 不吞错strictUnlink 仅 ENOENT 当已删,EIO/EROFS/EACCES throw;barrier-前释放检查返回值。
  3. failed 必 quarantine:reconcile 对 durable failed 也每轮 quarantine + 重试 closeSession(防「写 failed 后崩在 close 前」下轮 restore 重注册)。
  4. reconcile/daemon fail-closed:reconcile 收集硬失败、sweep 后 throw;listAll({throwOnCorrupt}) 对 corrupt lease 直接抛(不再 silent skip);daemon.ts 改为 reconcile 抛错 → throw 中止该 bot 启动(不再 log-and-continue 去 bind/restore)。
  5. 双故障不谎报:barrier 后 dispatch throw 时,只有 recordFailedStrict 成功(terminal 真 durable)才返回 state:failed;若终态写也失败 → 返 5xx trigger_failed(lease 留 attempting 交下轮 reconcile)。测:把 async 目标路径预置为目录使 strict 写失败 → 断言非 phantom failed、errorCode=trigger_failed
  6. strict RMW 配 strict readrecordFailedStrict 改用 loadStrict(仅 ENOENT 当 absent,corrupt/EIO/invalid-shape throw),覆盖前校验 owner 不匹配则 throw。测:corrupt 文件不被覆盖 / completed-wins / late-completed-wins / owner-proof / EIO throw。

验证: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

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

deepcoldy added a commit that referenced this pull request Aug 7, 2026
前 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 deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

四轮结论:本轮两项修复本身成立——requestHash 已覆盖完整 rendered options(排除 idempotencyKey),三个 mode flag 也已严格限制为 boolean;上轮 6 个 crash-atomicity finding 的主体修复保持。但从修复向外复扫后,仍有 4 组 blocking gap#776 暂不能 pass:

  1. attempt barrier 失败后的 release 仍把 compareAndRemovefalse 和异常一起吞掉,同 boot 重试会继续复用一个 closed/reserved 或 commit-unknown session,直到重启才可能收敛;
  2. boot reconcile 同样忽略 snapshot-CAS 的 false,而 path helper 还把锁内二次读取 corruption 折成 false,却继续把 sweep 当成功;
  3. idempotency hit/reconcile 直接信任共享 session/async stores,没有对 terminal evidence 做 owner positive-proof,能把另一个 bot 的 completed/failed 当成自己的;
  4. 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 时仍在跑。

Comment thread src/core/trigger-session.ts Outdated
markAttemptingBeforeDispatch();
} catch (err) {
if (idempotencyKey && idempotencyLease) {
try { idempotencyStore.compareAndRemove(larkAppId, idempotencyKey, idempotencyLease); } catch { /* best-effort release */ }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Store 侧已经把 remove 改成 strict + boolean,但这里仍把两种未释放结果都吞掉:false(记录已变/已是 attempting)和 EIO/EROFS 都被当作 best-effort cleanup 成功。确定性后果:transition 在 rename 前失败且 unlink EIO → current-boot reserved 仍绑着已关闭 session;同键重试在 resolveIdempotencyHitownerBootId===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 回归。

Comment thread src/core/trigger-session.ts Outdated
// 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);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 都不能返回成功。

Comment thread src/core/trigger-session.ts Outdated
): 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;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] 这里绕过了 async-trigger 查询已有的跨 bot positive-proof。asyncTriggerStore.lookup() 会返回 ownerLarkAppId,但这里只取 .result.status;上一行又用会 cross-scan sessions-*.jsonsessionStore.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}`);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 的矩阵。

deepcoldy added a commit that referenced this pull request Aug 7, 2026
四处收口,全部 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>
@deepcoldy

Copy link
Copy Markdown
Owner Author

round-6(182917de)— 收五轮 4 blocking gap,向外复扫后全部 fail-closed + owner 正向背书

@codex 四处按你 round-5 的 inline 最小修法逐条收口,附缺失测试矩阵:

#1 attempt-barrier 失败释放(trigger-session.ts ~1122)
compareAndRemove 改返回判别式 RemoveByPathResultremoved|absent|changed(current)),不再吞 false/异常:

  • 干净 removed/absent → 重试全新(reserved 已真正释放)
  • changed 且 current 已 attempting(rename 落盘、post-rename fsync 抛,即已跨越的 commit-unknown fence)→ durable recordFailedStrict,绝不删 fence,并返回可观测 state:failed(携 sessionId,caller 可 poll),而非裸 5xx(对齐你 "若已 attempting 则 durable terminalize … 真正收敛 … 而不是 close 后返回普通 5xx")
  • compareAndRemove 抛(EIO / 锁内二次读取损坏)→ 诚实 5xx,lease 留给下轮 reconcile
  • 另修根因:resolveIdempotencyHit 改以 live-ness(而非 ownerBootId===current)判定"真正在飞"。fresh session 在 barrier 前就 setActiveSessionIfActive 注册,故真正在飞的 turn 一定 live;同 boot attempting/reserved无 live worker = 孤儿(释放没删成 fence,或 session 已关无 durable 终态)→ terminal,杜绝"同 boot commit-unknown fence 无限复用直到重启"

#2 boot reconcile 忽略 CAS 结果(trigger-session.ts ~346)
compareAndRemoveByPath 同样返回判别式结果;reconcile 对 changed→按 current 重分类:current attempting → durable terminalize(绝不删),current 属 current boot → 跳过(在飞),其余不可证明收敛 → fail-closed 抛(startup abort)。store 侧锁内二次读取 corruption 由"catch→false"改为 THROW(不再绕过上层 fail-closed)。

#3 hit/reconcile 未校验 async-store owner + 跨 bot getSession(trigger-session.ts:186/298)
async 终态证据仅在 asyncRec.ownerLarkAppId === lease owner 时采信(foreign completed/failed 一律忽略并 warn,修你复现的"A 采信 B 的 completed 压制 A dispatch");getSessiongetOwnedSession(不再跨 bot sessions-*.json 回退泄漏 chatId);terminalizeAttempting 遇 foreign-owned async 槽位跳过而非抛——at-most-once 已由 lease(not-live→terminal)+ poll 侧 decideAsyncOwnership 正向 owner-gate 双重保证,不需 durable 写;若在此抛会把 finding #4 的跨 bot 启动 DoS 形状重新引入。

#4 flat 目录全量 strict parse 阻断所有 bot(idempotency-store.ts)
布局改 owner 分区 idempotency/<sha256(owner)>/<keyHash>.jsonlistAlllistAllForOwner 只枚举本 owner 子目录。任一 foreign/未知 owner 坏文件不再被打开、不阻断本 bot 启动;本 owner 坏文件仍 throwOnCorrupt fail-closed。该文件从未进过任何已发 tag、分支未并入 master,故无迁移(无 flat 遗留)。

测试(真穿 triggerSessionTurn + 真 store,temp dataDir)

请复扫。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. live 实际只是 active-session 存在,不是 live worker;worker 退出后 ds.worker=null 但 session 仍留在 active map,same-key retry 与 trigger-result 会继续 queued/running,直到 daemon 重启。
  2. changed(current) 的消费没有把 current identity/session 贯穿:reconcile 给 current attempting 写 failed,却 quarantine/close stale snapshot session;barrier 也把任意 changed→attempting 都当成自己 post-rename 的 fence。
  3. 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-oldsess-new 可在 restore 时重新 attach。详见 inline。

另有独立集成阻塞:PR 当前相对最新 origin/master@7ec6d4bdCONFLICTING,冲突在 src/daemon.ts 的 restore hunk;rebase 时需同时保留 restoreActiveSessions(activeSessions, idempotencyQuarantinedSessionIds) 与 master 新增的 sessionsRestored = true,再重跑启动/recovery 测试。

本轮验证:PR HEAD=182917de;9 个相关/shared suite 318/318 绿;pnpm buildgit diff --check 绿;GitHub CI/CodeQL 全绿。测试绿不覆盖上述 worker-exit、changed-different-identity 与 normalized-key seam。

Comment thread src/core/trigger-session.ts Outdated
// 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) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 的回归。

Comment thread src/core/trigger-session.ts Outdated
if (cur.ownerBootId === currentBootId) continue; // re-claimed by us → in-flight, untouched
if (cur.state === 'attempting') {
terminalizeAttempting(cur);
quarantined.add(record.sessionId);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] rm.current 可能是另一条 identity/session,但 terminalize 用 cur,quarantine/close 却仍用 stale snapshot record.sessionId。确定性复现:snapshot=sess-old reserved → takeover 成 sess-new reservedsess-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,所以没暴露。

Comment thread src/core/trigger-session.ts Outdated
// 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') {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 advanceddifferent 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>;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 回归。

deepcoldy added a commit that referenced this pull request Aug 7, 2026
… 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>
deepcoldy added a commit that referenced this pull request Aug 7, 2026
…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>
@deepcoldy
deepcoldy force-pushed the feat/trigger-idempotency-key branch from 182917d to d687f6a Compare August 7, 2026 07:11
deepcoldy added a commit that referenced this pull request Aug 7, 2026
…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>
deepcoldy added a commit that referenced this pull request Aug 7, 2026
前 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 added a commit that referenced this pull request Aug 7, 2026
四处收口,全部 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>
deepcoldy added a commit that referenced this pull request Aug 7, 2026
… 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>
@deepcoldy

Copy link
Copy Markdown
Owner Author

round-7(d687f6ad,已 rebase 到 master 75d86d0)— 收六轮 3 blocking gap + 集成冲突

@codex 三组按你 round-6 的 inline 逐条收口,并已 rebase 解决 daemon restore 冲突:

#1 worker-exit 收敛(live ≠ live worker)

  • resolveIdempotencyHit 的在飞判据由 registry presence 改为 liveWorker!!ds.worker && !ds.worker.killed)。worker 退出置 ds.worker=null 但仍在 activeSessions → 一律 terminal,不再永久 reuse/running。
  • 派发时给 ds 打 idempotentAsyncTurn{ownerLarkAppId,key,triggerId,workerGeneration} 戳;daemon onWorkerExitconvergeIdempotentAsyncTurnOnWorkerExit(ds, gen),对未完成的幂等 async turn 写权威 durable dispatch_unknown(仅收敛被戳的那个 generation;final_output 完成即清戳;completed 已存在则跳过;写失败不抛、留下轮 reconcile)。trigger-result 的 durable-failed 分支在 running 之前,故 poll 与 retry 双双 failed,不二次 fork。

#2 reconcile changed 贯穿 current identity/session

  • store RemoveByPathResult.changedsameIdentity 判别位。
  • reconcile:same-identity + attempting(我方 crossed fence)→ 按 cur.sessionId terminalize/quarantine/close(不再用 stale snapshot 的 sessionId,杜绝把已宣告 failed 的 winner session 再 attach);different-identity(takeover/re-claim 换了 session/trigger/boot)→ 收敛我方 orphan、放过 winner(不伪造本地 terminal、不抛,winner 由其自身 lease 收敛)。你复现的 sess-old/sess-new 分叉即此路径。

#3 barrier changed 同一 identity 绑定

  • 仅当 changed && sameIdentity && attempting 才按本地 crossed fence terminalize 并返回可观测 failed;否则(不同 winner / 非预期态)不伪造本地 terminal,只关本地 never-dispatched session 返回诚实 5xx,交由 winner 自身证据在 retry 时收敛。

#4 requestHash 与 rendered payload 闭合

  • buildExternalEventDataContext 也从 options 里剥掉 idempotencyKey(transport metadata)。'k'' k ' trim 后同 lease、hash 同、现在 prompt 也同 → 合法复用而非 prompt differs

rebase:onto origin/master@75d86d05;daemon restore 冲突按你提示同时保留 restoreActiveSessions(activeSessions, idempotencyQuarantinedSessionIds) 与 master 的 sessionsRestored=true。最终顺序:reconcile(早于 IPC bind)→ restore(带 quarantine)→ sessionsRestored;worker-exit 收敛在 onWorkerExit 回调头部。改回调签名 _dsds 后同步更新了 vc-meeting-worker-exit-recovery-wiring 源码锁测试。

测试矩阵(真穿 triggerSessionTurn + 真 store)

  • 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、recovery/restore/session-manager 135/135pnpm build 绿、git diff --check 绿。

请以 d687f6a 为准复扫。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy

Copy link
Copy Markdown
Owner Author

补充(HEAD 现为 5f47b61)— 自查发现 finding #1 的 onCliExit 半边,已一并收口

@codex 请以 5f47b61 为准复扫(在 d687f6a 之上多一个 commit)。

自查时发现 round-6 finding #1 的 worker-exit 收敛只挂了 onWorkerExit,漏了 onCliExit:persistent-pane / codex-app 的 managed CLI 可在 Node worker 仍存活时退出(worker-pool.ts 的 claude_exitcb.onCliExit),此时 onWorkerExit 不触发。未完成的幂等 async turn 若其 CLI 无 final_output 退出,trigger-result 仍 poll running、同键重试仍 reuse 坏 generation,直到下轮 reconcile——正是 finding #1 在 onCliExit 这条路径上的同构复现。

改法:onCliExit 回调同样调 convergeIdempotentAsyncTurnOnWorkerExit(ds, context.workerGeneration)。收敛函数 generation 门控 + completed 已清戳 → 幂等,对 onCliExit/onWorkerExit 双回调竞态安全(VC 侧注释已述该竞态 idempotent ordering-safe)。回调签名 _dsds 后同步更新源码锁测试,并加断言:两个回调都接了收敛。

persistent-pane 的 liveWorker 也已核对:此类 CLI 的 ds.worker 跨 turn 常驻属真实存活,其完成由 final_output→durable completed 证明(resolveIdempotencyHit 的 completed 分支先命中),liveWorker 兜底仅在无 durable 终态时生效,语义正确。

验证:affected+shared+recovery 346/346、build 绿。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审基线:5f47b611。上一轮三处修复(liveWorker、sameIdentity、render 时剥 idempotencyKey)均确认真实生效;build、diff-check 与 10 个相关套件 204/204 通过。继续沿 worker 重启、reconcile 换代、跨 bot owner-proof 三条边界向外扫,仍有 3 个 blocking finding,故本轮暂不能 pass。

Comment thread src/daemon.ts
// 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);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 收不到该输入。

Comment thread src/core/trigger-session.ts Outdated
// 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`);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 不成功遗留。

Comment thread src/core/trigger-session.ts Outdated
// 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; }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 回归。

deepcoldy added a commit that referenced this pull request Aug 7, 2026
…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>
deepcoldy and others added 9 commits August 7, 2026 10:25
问题: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>
@deepcoldy
deepcoldy force-pushed the feat/trigger-idempotency-key branch from 720b8e1 to f88f929 Compare August 7, 2026 10:26
deepcoldy added a commit that referenced this pull request Aug 7, 2026
…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>
@deepcoldy

Copy link
Copy Markdown
Owner Author

round-8(f88f9297,已 rebase 到 master 10b065c)— 收七轮 3 blocking gap

@codex 三处按你 round-7 inline 逐条收口,请以 f88f929 复扫:

#1 carry-over 零重放(both queues)
独立 atMostOnce 标记(不借用带 VC receipt 语义的 dispatchAttempt):forkWorker 第三参 → init 消息 → lastInitConfig.atMostOnceInflightItem/PendingCliInputnoReplay 位。CLI 退出时:① inflight carry 谓词加 && !item.noReplay && !atMostOnceSession(worker.ts:10137);② 同时清空仍排队的 pendingMessages(fresh async virtual 单轮,清空不误伤别的 turn)。两条队列都堵,daemon 写 dispatch_unknown 后 replacement CLI 收不到该输入。

#2 reconcile different-identity winner 就地分类
你指出同 key takeover 覆盖同一 hashed file、leases 是一次性 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;reserved 有 live session(可能在跑)→fail-closed 抛。不再留给不存在的 re-scan。

#3 exit 收敛 stamp 清除加 owner 正向校验
只有 existing.ownerLarkAppId === turn.ownerLarkAppId 的 completed 才认作本 bot completion 并清 stamp;foreign/unstamped completed 不再清掉本 bot 唯一的 exit-convergence stamp(否则 onCliExit 后无 stamp → liveWorker 复用 attempting → onWorkerExit 无法收敛 → 永久 running)。

测试矩阵

  • inflight-input-tracker 14(+ noReplay 不 carry-over,用 worker 真实谓词 dispatchAttempt===undefined && !noReplay
  • 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=true、+ foreign completed 不清 stamp)
  • affected+shared 237/237pnpm build 绿、rebase 后核心套件 75/75 绿。

真 worker restart 集成回归(你要求的「keyed 输入 CLI exit 后 durable failed、replacement CLI 收不到」):daemon 侧断言 fork 带 atMostOnce(e2e),worker 侧碳排除谓词单测(inflight-input-tracker),两端拼合覆盖该路径。请复扫。

@chatgpt-codex-connector

Copy link
Copy Markdown

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>
@deepcoldy
deepcoldy force-pushed the feat/trigger-idempotency-key branch from f88f929 to 110ad87 Compare August 7, 2026 16:00
@deepcoldy

Copy link
Copy Markdown
Owner Author

补充:HEAD 现为 110ad87(在 f88f929 上加一个无行为变更的清理 commit)。自查发现两处 dead scaffolding,已收干净:

  • message DaemonToWorker 类型上的 atMostOnce 字段无任何 producer/reader(keyed turn 是 init-only 路径),删除。
  • PendingCliInput/InflightItem.noReplay 的注释改为诚实描述:它是 worker carry 谓词已 honor 的 per-item 原语 + 有单测覆盖,但当前 keyed fresh-async 是单轮、由整会话 init.atMostOnce 兜底,per-item flag 保留给未来 message-fold 路径(非当前 producer)。

carry-over 零重放的实际保护完全来自 init.atMostOnce(worker.ts:10142 起):inflight carry 谓词排除 + pendingMessages 清空,两条队列都堵。请以 110ad87 复扫。测试与验证同前(237/237、build 绿)。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant