/** * localTaskRef 的**唯一**构造入口,且 `createBinding` 会自己调它——`CreateBindingInput` * 里没有 localTaskRef 字段,调用方**没有**手拼的机会。 * * 之所以做成内生而不是「约定调用方用这个函数」:拼反了顺序不会当场报错,要等平台 bind * 记下的 localTaskRef 与崩溃恢复时的重确认对不上,那条 held 会话被判 claim_mismatch 作废 * 才暴露——离出错点太远。直接复用 botmux 自己的 `sessionKey`,与 activeSessions 键同源。 */ export declare function buildLocalTaskRef(anchorId: string, larkAppId: string): string; /** 平台 IssueStatus 的线上取值(回写目标只用得到其中一部分,但解析要认全)。 */ export type IssueStatus = 'open' | 'claimed' | 'in_progress' | 'in_review' | 'done' | 'cancelling' | 'needs_attention' | 'reopened'; export type AttentionReason = 'cancel_delivery_timeout' | 'claim_activate_timeout' | 'task_blocked' | 'run_timeout'; /** * 一条 issue ↔ 本机会话的绑定。 * * `bindState` 对应 §六 的崩溃恢复分支,外加一个人为终态: * - `pending` :claim 成功、binding 已落盘,但还没向平台 bind(群可能还没建出来) * - `bound` :平台已接受 bind,本地会话可以 activate(发 kickoff @ bot) * - `void` :补 bind 被平台拒(issue 被回收 / 别人领走 / 代次过期),这条作废 * - `released`:人主动释放([[issue-release]]),issue 已退回平台待领取池 * - `done` :人验收通过(`/issue done`),平台已终结这条 issue * * 三个终态都不再回写,但**不能合并**:`void` 是"平台不认这次领取",对账见到它要考虑群是不是 * 白建了;`released` 是"领取本来是好的,人不做了",群里有正经的工作记录,对账不该去动它; * `done` 是"活干完并且验收了"。区分开也让日志能说清一个群到底是怎么结束的。 * * 加终态时只改 `isActiveBindState` 一处——散落的 `!== 'void'` 式判断漏改一处,就会出现 * 「已结束的 issue 仍被当成本机持有」,而且全程不报错。 */ export interface IssueBinding { /** * **路由锚点**——主键,也是平台契约里的「本地任务」标识。 * * 这里必须是 anchor 而**不是** botmux 的 session id:协议要求 bind(带 localTaskRef) * 发生在 activate 之前,而 botmux 的会话是 kickoff 消息把 bot @ 起来之后才由 daemon * 创建的——bind 那一刻根本还没有 session id。anchor 则在 kickoff 之前就存在: * - 拉群模式 → `chatId`(chat-scope 会话的路由键,建群即得) * - 话题模式 → `rootMessageId`(先发无 @ 的 seed 拿到,再发 threaded kickoff) * 见 core/types.ts 的 `sessionAnchorId` / `sessionKey`。 */ anchorId: string; /** 平台契约的 localTaskRef,取值就是 botmux 自己的 `sessionKey(anchorId, larkAppId)` * = `::`。复用它而不是另造一套:它本来就是本仓跨 bot 的规范会话 * 身份,且 `om_`(消息)与 `oc_`(群)两个地址空间不会碰撞。 */ localTaskRef: string; larkAppId: string; issueId: string; teamId: string; /** 绑到哪个平台(换绑/多平台时区分该往哪回写)。 */ platformBaseUrl: string; /** 领取幂等键,必须是高熵随机(禁止由 issueId 派生)。全表唯一。 */ claimId: string; /** 领取代次快照——回写栅栏,旧代次的迟到回写会被平台按 stale_epoch 丢弃。 */ claimEpoch: number; bindState: 'pending' | 'bound' | 'void' | 'released' | 'done'; /** 会话所在的群。拉群模式下 === anchorId;话题模式下是承载话题的那个群。 * 崩溃恢复据此复用已建的群而不是再建一个。 */ chatId?: string; /** 会话作用域:拉群 → 'chat'(一群一 issue 一会话);话题 → 'thread'。 */ scope: 'chat' | 'thread'; /** 本 binding 的 sourceSeq 分配计数器:单调、每 binding 唯一,平台的线上幂等只认它。 */ nextSourceSeq: number; /** 已成功回写的状态(离线期间不动——去重合并要拿它和「最新未完成目标」一起比)。 */ lastSyncedStatus?: IssueStatus; /** 上次回写后平台返回的 issue.stateRev,下次状态 CAS 用。 */ platformStateRev?: number; createdAt: number; updatedAt: number; } export interface IssueOutboxRow { /** 仅本地行主键(去重发送用),不上送平台——平台幂等只认 sourceSeq。 */ writeId: string; anchorId: string; sourceSeq: number; targetStatus: IssueStatus; attentionReason?: AttentionReason; claimId: string; claimEpoch: number; expectedStateRev?: number; state: 'pending' | 'inflight' | 'done' | 'failed'; attempts: number; nextRetryAt?: number; lastError?: string; createdAt: number; } export declare function listBindings(dataDir: string): IssueBinding[]; export declare function getBinding(dataDir: string, anchorId: string): IssueBinding | null; /** * 这条 binding 是否还代表「本机正持有这个 issue」。 * * 抽成一个谓词而不是到处写 `!== 'void'`:终态从一个变成两个(`void` / `released`)时, * 散落各处的判断只要漏改一处,就会出现「已释放的 issue 仍被当成已领取」——重领被拦、 * 回写继续发,而且全程不报错。加状态就改这一个地方。 */ export declare function isActiveBindState(state: IssueBinding['bindState']): boolean; /** 按 claimId 反查——崩溃恢复的入口(§六:不依赖平台「按 claimId 查 issue」的接口,那个接口不存在)。 */ export declare function findBindingByClaimId(dataDir: string, claimId: string): IssueBinding | null; /** 某 issue 当前在本机的活跃 binding(作废/已释放的不算)。用于「这个 issue 是不是我已经领了」。 */ export declare function findActiveBindingByIssue(dataDir: string, issueId: string): IssueBinding | null; export interface IssueClaimIntent { /** 主键。高熵随机,与平台 claim 用的是同一个值。 */ claimId: string; issueId: string; teamId: string; claimEpoch: number; platformBaseUrl: string; /** 平台 claim 返回的 stateRev,后续 bind 的 CAS 基线。 */ platformStateRev: number; /** 这个 issue 交给哪个 bot 跑(决定 localTaskRef 的 appId 段)。 */ larkAppId: string; scope: 'chat' | 'thread'; /** * 建群/发 seed 拿到 anchor 后回填。它出现 = 群已存在,对账时**不要再建一个**。 * 注意这份回填本身也可能崩在中间,所以对账不能只信它,还要按 claimId 去群里反查。 */ anchorId?: string; chatId?: string; createdAt: number; updatedAt: number; } export declare function listClaimIntents(dataDir: string): IssueClaimIntent[]; export declare function getClaimIntent(dataDir: string, claimId: string): IssueClaimIntent | null; /** * 记下一次领取意图。**必须在 platform claim 返回成功后立刻调用,早于任何建群动作**。 * * 同 claimId 重入返回既有记录(claim 本身幂等,重试不该产生第二条意图)。 */ export declare function recordClaimIntent(dataDir: string, input: Omit, now?: number): IssueClaimIntent; /** 回填 anchorId / chatId(建群成功后)。 */ export declare function updateClaimIntent(dataDir: string, claimId: string, patch: Partial>, now?: number): IssueClaimIntent | null; /** * 领取流程走完(binding 已 bound)后清掉意图——此后 binding 自己就是完整证据。 * * ⚠️ 清除必须**晚于** binding 落盘。反过来先清意图再写 binding,中间崩溃就同时丢掉了两份 * 证据,那个群彻底认不回来——这正是意图存在的意义。 */ export declare function clearClaimIntent(dataDir: string, claimId: string): boolean; /** 对账分类:意图还在、但没有对应 binding 的,就是需要人/流程接手的悬空领取。 */ export declare function listDanglingClaimIntents(dataDir: string): IssueClaimIntent[]; /** 注意没有 `localTaskRef`:它由 `buildLocalTaskRef(anchorId, larkAppId)` 内生,见上。 */ export type CreateBindingInput = Omit & { bindState?: IssueBinding['bindState']; }; /** * 写入一条 pending binding。**在建群/发 seed 拿到 anchorId 之后调用**——主键就是那个 * anchor,之前不可能有。放在 `createGroupWithBots` 的 `onChatCreated` 同步钩子里写, * 让它成为「群已存在」到「群已归属某个 issue」之间的 durable boundary(见文件头时序)。 * * 两条不变式都在这层强制,不指望调用方自觉: * - **claimId 唯一**:同 claimId 重入直接返回既有行(本地幂等,对应 Desktop 侧的 * `UNIQUE(claim_id)`)——领取重试不会产生第二条 binding、也不会重复建群。 * - **一 issue 一活跃 binding**:同 issue 已有非 void 的 binding 却拿着不同 claimId 进来, * 是调用方漏查(比如没先 `findActiveBindingByIssue`)。此时**抛错**而不是照写——写下去 * 就是同一个 issue 两个群、两个 agent 同时开工,而平台侧只认最后一次 claim,另一个群 * 会变成谁也不管的孤儿。抛在这里离原因最近;释放后重领是安全的(旧 binding 已置 void)。 */ export declare function createBinding(dataDir: string, input: CreateBindingInput, now?: number): IssueBinding; /** 局部更新一条 binding(read-modify-write,保住并发写入的其它字段)。 */ export declare function updateBinding(dataDir: string, anchorId: string, patch: Partial>, now?: number): IssueBinding | null; /** 删除一条 binding(会话彻底结束且 issue 已终态后清理)。返回是否删掉。 */ export declare function removeBinding(dataDir: string, anchorId: string): boolean; export declare function listOutbox(dataDir: string, anchorId?: string): IssueOutboxRow[]; /** * 投影一次「本会话应达的 issue 状态」到发件箱(§七 去重合并)。 * * 合并规则(**与最新未完成目标比,不是与 lastSyncedStatus 比**): * - 该 binding 已有 `pending` 行 → **就地覆盖**它的 targetStatus 并重分配 sourceSeq,不新增行; * - 否则仅当 desired 与「最新目标 ∪ lastSyncedStatus」都不同才排一条新行。 * * 为什么必须这样:离线期间 30s 一次的 tick 会反复投影,若每次都追加,恢复后会把一长串 * 早已过时的中间态依次发给平台——既刷无效自迁移,又让**最新**状态排在队尾迟迟到不了。 * * 返回排出的行;无需回写时返回 null。 */ export declare function enqueueDesiredStatus(dataDir: string, anchorId: string, desired: IssueStatus, opts?: { attentionReason?: AttentionReason; expectedStateRev?: number; }, now?: number): IssueOutboxRow | null; /** * 领取该 binding 的下一条待发行(`pending → inflight` 的 CAS)。 * * **串行**:同一 binding 已有 inflight 行时返回 null——平台侧要求同一 issue 的 sourceSeq * 单调到达,并发发送会让顺序乱掉。`nextRetryAt` 未到的行也不返回(退避)。 */ export declare function claimNextOutboxRow(dataDir: string, anchorId: string, now?: number): IssueOutboxRow | null; /** * 发送成功:标 done,并把平台返回的 stateRev / 已同步状态写回 binding。 * * `applied: false` 用于「这条行不必再发了,但平台**没有**采纳它」——目前只有一种情况: * 409 对账发现 claim 已易主。此时**绝不能**写 `lastSyncedStatus`:写了本地就以为 * "已经是 in_review 了",下一次交付会走幂等分支直接回成功,而平台上那条压根没变。 */ export declare function settleOutboxRow(dataDir: string, writeId: string, result: { platformStateRev?: number; platformLastSourceSeq?: number; applied?: boolean; }, now?: number): void; /** 发送失败:退回 pending + 指数退避(上限 5min)。`fatal` 用于平台明确拒绝、重试无意义的情况。 */ export declare function failOutboxRow(dataDir: string, writeId: string, error: string, opts?: { fatal?: boolean; }, now?: number): void; /** * 启动对账:把本机所有 `inflight` 退回 `pending`。 * * 进程在「标 inflight 后、记录响应前」崩溃时,那一行会永远停在 inflight 并**堵死该 * binding 的串行 pump**(claimNextOutboxRow 见到 inflight 就返回 null)。平台侧的 * sourceSeq 单调 + 终态幂等保证重复投递是安全的,所以无脑退回即可,不需要发送租约。 * * 返回被退回的行数。 */ export declare function resetInflightToPending(dataDir: string): number; /** 清理已完成的发件箱行(保留最近 `keep` 条便于排查)。返回清掉的行数。 */ export declare function pruneOutbox(dataDir: string, keep?: number): number; //# sourceMappingURL=issue-board-store.d.ts.map