import * as idempotencyStore from '../services/idempotency-store.js'; import { type ChatMode } from '../im/lark/client.js'; import { type ChatReplyMode } from '../services/chat-reply-mode-store.js'; import type { DaemonSession } from './types.js'; import type { TriggerRequest, TriggerResponse } from '../services/trigger-types.js'; export interface TriggerSessionDeps { larkAppId: string; activeSessions: Map; } /** Daemon-internal dispatch controls. These deliberately do not live in the * public TriggerRequest schema: an untrusted connector must not choose a turn * identity that participates in durable delivery reconciliation. */ export interface TriggerSessionInternalOptions { stableTurnId?: string; /** Synchronous write-ahead hook invoked immediately before worker IPC/fork. * Durable receivers use it to persist DISPATCHED with the exact worker * generation. Throwing aborts the dispatch. */ beforeDispatch?: (context: { sessionId: string; workerGeneration: number; }) => void | { dispatchAttempt: number; }; /** Suppress daemon-rendered final_output while preserving turn_terminal. * Used by analysis-only meeting consumers; explicit user IM turns do not * set it. */ suppressFinalOutput?: boolean; /** Meeting raw text is intentionally ephemeral receiver input. Keep it out * of botmux's persisted Session.lastUserPrompt/lastCliInput fields; receipt * recovery asks the hub to resend the frozen envelope instead. */ persistInputHistory?: boolean; } /** Small, human-readable text for Codex App's visible UserMessage. The full * legacy event envelope still travels as hidden untrusted context. */ export declare function buildExternalEventVisibleText(req: TriggerRequest, larkAppId?: string): string; /** Feishu topic seed for a new external-event session. `null` is an explicit * connector-owner choice to run without the otherwise required notice. */ export declare function buildExternalEventTopicMessage(req: TriggerRequest, larkAppId?: string): string | null; /** Connector-owner directives are trusted application context. Keep them * separate from the full legacy wrapper, which also contains untrusted event * bytes and therefore must never be promoted wholesale to developer context. */ export declare function buildExternalEventApplicationContext(req: TriggerRequest): string; export declare function buildUntrustedEventPrompt(req: TriggerRequest, triggerId: string): string; /** Data-only part of an external trigger. This is the only portion passed as * untrusted structured context; trusted connector instructions remain solely * in application context instead of being duplicated at user priority. */ export declare function buildExternalEventDataContext(req: TriggerRequest, triggerId: string): string; /** Whether a webhook external-event turn for this chat should open its own topic * + session (thread-scope) instead of folding into the group's one chat-scope * session. Mirrors the inbound @mention routing (event-dispatcher's * `regularGroupRouting`): a 话题群 always sessions per-topic, and a 普通群 only when * its reply mode is `new-topic`. The other 普通群 modes (chat / shared / chat-topic) * keep a top-level external event flat in the group chat-scope session, exactly * as they route a top-level @mention. Exported for unit tests. */ export declare function externalEventOpensOwnTopic(chatMode: ChatMode, regularGroupMode: ChatReplyMode): boolean; type IdempotencyHitDecision = { kind: 'reuse'; chatId: string; message: string; } | { kind: 'terminal'; chatId: string; message: string; } | { kind: 'takeover'; }; /** Decide what a same-payload idempotency-key HIT means (at-most-once). The * TERMINAL outcome is owned by async-trigger-store (completed / failed), not by * the lease — so a durable failed (dispatch_unknown) or completed is checked * FIRST and wins over any lease state. The lease only distinguishes "in flight * / reserved by me" (reuse) from "older-boot reserved" (takeover). Exported for tests. */ export declare function resolveIdempotencyHit(hit: idempotencyStore.IdempotencyRecord, ownerBootId: string, activeSessions: Map): IdempotencyHitDecision; /** * Boot reconcile for idempotency leases (at-most-once convergence). MUST run * after the session store + worker pool are initialized but BEFORE the IPC * server binds, and is scoped to a SINGLE owning bot (`ownerLarkAppId`) — the * dataDir is shared across bots, so a bot must never touch another's leases. * For each of THIS owner's leases left by a PREVIOUS boot: * - completed (async store proves it) → keep; a retry polls the completed result. * - attempting (commit-unknown, previous boot gone) → write a durable * `dispatch_unknown` FAILED into async-trigger-store (authoritative terminal, * so trigger-result converges to `failed` regardless of session close), then * best-effort close the orphan. NEVER re-dispatched. * - reserved (provably pre-dispatch) → CAS-remove the lease + best-effort close * the never-dispatched session, so a same-key retry starts fresh. * Returns the set of sessionIds terminalized/closed here so the caller can * quarantine them from re-attach in restoreActiveSessions. */ export declare function reconcileIdempotencyLeasesOnBoot(ownerLarkAppId: string, currentBootId: string, getSession?: (id: string) => { chatId?: string; } | undefined): Promise>; /** * Converge an INCOMPLETE idempotent async turn when its worker exits (codex #776 * round-6 finding #1). A worker that dies with no final_output sets ds.worker=null * but leaves the session in activeSessions and its async record `pending`, so * trigger-result would poll `running` and a same-key retry would `reuse` the dead * session — both forever, until the next daemon boot reconcile. Registry presence * is NOT execution liveness. * * We write the authoritative durable `dispatch_unknown` failed (the same terminal * the boot reconcile writes), which trigger-result reads BEFORE its running * branch and resolveIdempotencyHit reads as `terminal` — converging both without * a re-dispatch. Best-effort + fail-safe: only fires for the EXACT generation * that was stamped (a later generation that already completed/moved on is * ignored), and only when no completed evidence exists (completed always wins in * recordFailedStrict anyway). Idempotent: the stamp is cleared after. * * Called from the daemon's onWorkerExit/onCliExit callbacks. Returns a status so * the caller can react to a convergence WRITE FAILURE (EIO/ENOSPC): merely * logging + keeping the stamp is NOT enough, because the same Node worker * auto-restarts to a healthy idle CLI and per-item noReplay stops the keyed input * from re-running — so async-store stays `pending`, the session stays `open`, and * ds.worker becomes live again → trigger-result polls `running` forever and a * same-key retry reuses via liveWorker, with no automatic next-boot reconcile * trigger (codex #776 round-8 finding #2). On `write_failed` the caller MUST take * this session to an observable fail-closed terminal (close it → trigger-result's * closed-branch resolves `failed`), not wait for an unknown future restart. * - 'converged' → durable dispatch_unknown written (or owner-matched completed seen); stamp cleared. * - 'noop' → nothing to converge (no stamp, wrong generation). * - 'write_failed' → the strict durable write threw; stamp kept; caller must fail-closed the session. */ export type IdempotentExitConvergence = 'converged' | 'noop' | 'write_failed'; export declare function convergeIdempotentAsyncTurnOnWorkerExit(ds: DaemonSession, exitingWorkerGeneration: number): IdempotentExitConvergence; export declare function triggerSessionTurn(req: TriggerRequest, deps: TriggerSessionDeps, internal?: TriggerSessionInternalOptions): Promise; export {}; //# sourceMappingURL=trigger-session.d.ts.map