/**
 * src/tasklist/stallDetector.ts
 *
 * v3.1 "停滞检测 + 唤醒" (docs/task-handle.md §12). Agent Team topic
 * collaboration breaks down in mundane ways: an @-mention gets missed, a
 * turn crashes, a discussion just goes quiet. An ALREADY-CLAIMED task is a
 * team commitment — the bridge mechanically detects the FACT that a claimed,
 * not-yet-complete task's thread has gone quiet for too long, and wakes the
 * claiming agent to decide HOW to proceed (re-@ someone, break the task
 * down, escalate to a human). The bridge never makes that judgment call —
 * see the module-level "iron rule" note on {@link StallDetector} below.
 *
 * Per-bot (NOT deduped by tasklistGuid the way TasklistPoller/CommentPoller
 * dedup cross-bot concerns) — TaskHandleStore is already per-bot, so a bot
 * only ever nudges tasks IT ITSELF claimed. There's no "N bots polling the
 * same shared list" storm risk here the way there is for TasklistPoller.
 *
 * ## Activity signal (investigated — docs/task-handle.md §12 has the full
 * writeup)
 *
 * The ONLY activity signal the bridge can cheaply and reliably observe is
 * SessionStore's `lastActiveTs` for the claim's thread — updated by every
 * DISPATCHED turn, regardless of source (a real @-mention, a synthesized
 * task-comment turn, or this module's own nudge turn). A human posting in
 * the Feishu topic WITHOUT @-mentioning the bot never reaches the bridge at
 * all (the Channel SDK only delivers @-mention-triggered events, plus
 * gap-fill's own catch-up scan of those same triggers) — that's a known,
 * accepted limitation, not something this feature works around.
 *
 * Task-side activity (a human completing the task in the Feishu UI directly)
 * is also observed, but narrowly: a claimed task whose `completed_at`
 * becomes non-empty is treated as the strongest possible "activity" —
 * genuinely not-a-claim-candidate-for-stalling anymore — and stall tracking
 * for it is cleared. Broader task-field-change detection (e.g. diffing the
 * description text) was deliberately NOT built: the bridge itself writes
 * that description on every turn (including nudge-triggered turns), so a
 * generic "did the description change" signal would be contaminated by the
 * very turns this feature causes, making it useless for distinguishing real
 * external progress from the nudge's own side effects.
 *
 * ## Wake-up mechanism (investigated — docs/task-handle.md §12)
 *
 * Reuses the exact synthetic-turn seam CommentPoller already established:
 * `ChannelClient.enqueueSyntheticEvent` pushes a synthesized `LarkMessageEvent`
 * onto the bridge's normal inbound queue, so handler.ts processes it as an
 * ordinary turn — same mechanism `synthesizeCardActionEvent` (card buttons)
 * and CommentPoller (task comments) already use, both already dogfooded.
 * There is no "topic-patrol"-style precedent in this repo to copy (a
 * scheduled/internally-triggered turn a la a private production deployment's
 * patrol bot) — this module IS that precedent now, for the open-source repo.
 * A bot @-mentioning itself/another bot to trigger a turn was not tested
 * end-to-end (no live Feishu access in this dev environment) and doesn't
 * need to be: the synthetic-event queue path bypasses real-message ingestion
 * entirely, so self-mention's likely anti-loop filtering is moot.
 *
 * **Real-deployment bug fix**: the synthetic event's `message_id` is a fake
 * string (`synthetic-task-stall-<threadId>-<ts>`), needed only so
 * ChannelClient's/handler.ts's dedup and in-flight tracking
 * (seenMessageIds/inFlightMessageIds/markHandled/markUnhandled) have a
 * unique key. It was ALSO — before this fix — the id the wake-up turn's
 * reply anchored on, which 400s against Feishu ("not a valid
 * open_message_id"): the agent ran and did real work, but nobody in the
 * topic ever saw the reply (静默漏管). `enqueueNudgeTurn`'s event now also
 * carries `reply_anchor_message_id` (the topic's own root message id,
 * `record.threadId`, always a genuine Feishu id) — `lark/message.ts`
 * prefers this for `ParsedMessage.messageId` (what every reply/card/reaction
 * call downstream actually anchors on) while `event.message_id` keeps its
 * fake-but-unique value for dedup, so the two concerns never collide.
 * CommentPoller's task_comment synthetic turns had the identical bug and
 * got the identical fix (main.ts) — it just hadn't been observed yet in
 * the deployment that caught this, since a new human task comment is a much
 * rarer trigger than this module's unconditional stall timer.
 *
 * ## Adversarial-review fixes (docs/task-handle.md §12)
 *
 * Three correctness bugs found by review, all fixed in this version:
 *
 *   1. **Cooldown no longer gates observation.** The nudge cooldown now ONLY
 *      gates the ACTION of sending another nudge/escalation — attribution
 *      and progress-reset run every cycle regardless of cooldown state. The
 *      earlier version gated attribution/reset behind the cooldown check,
 *      which meant real human progress that happened DURING the cooldown
 *      window got misattributed as "the nudge's own reply" the moment the
 *      cooldown finally elapsed, instead of being recognized immediately.
 *
 *   2. **getTask is now called only when about to act.** All local,
 *      in-memory checks (suppression / escalated-progress / threshold /
 *      attribution / cooldown) run BEFORE any network call — `getTask` fires
 *      only on the rare cycle where we're actually about to send a nudge or
 *      post an escalation comment. Combined with `stallSuppressUntilActivityAfter`
 *      (set once a task is confirmed completed, cleared only by new
 *      activity), a claim's steady-state API cost is ~zero once it's either
 *      actively worked or done — not one `getTask` per claim per cycle
 *      forever, which is what the earlier version did.
 *
 *   3. **Nudge count only increments on CONFIRMED dispatch.** `enqueueNudgeTurn`
 *      is fire-and-forget (an in-memory queue push, see channelClient.ts) —
 *      a bridge restart between enqueue and actual dispatch used to still
 *      count as "one real nudge" toward escalation, so two unlucky restarts
 *      could reach escalation without the agent ever having been woken up
 *      even once. `stallNudge.pendingSince` now marks a nudge as
 *      "sent, not yet confirmed"; `count` only increments once activity is
 *      actually observed after it (which also serves as the attribution
 *      step in one move); a pending nudge that gets no confirming activity
 *      within a bounded window is treated as lost and doesn't count.
 *
 * ## v3.2 交接断链检测 (docs/task-handle.md §13)
 *
 * Multi-agent handoff in a shared topic breaks in a way the general stall
 * check (above) is too slow to catch: bot A's completed turn @-mentions
 * peer bot B (asking it to pick up), but B never runs a turn in that thread
 * — because B crashed, because the @ never actually resolved to a real
 * Feishu mention (see docs/task-handle.md §13's investigation), or any
 * other reason. Tasks here are hour-scale, so the general 24h (or even the
 * 30min failure-fast) threshold is far too slow — this needs minute-scale
 * detection. `writeback.ts` mechanically string-matches each completed
 * turn's reply text against the bot's peer-name roster (no NLP) and
 * persists which peer(s) were mentioned (`TaskHandleRecord.lastTurnMentions`
 * + `lastTurnMentionsAt`, REPLACED not accumulated each turn). This module
 * checks whether ANY mentioned peer has RECEIVED an event in the SAME thread
 * since that mention (via `getPeerReceivedAt`); if not, the effective
 * threshold drops to `stallHandoffThresholdMs` — whichever of the applicable
 * thresholds is SHORTER always wins. Deliberately reuses the EXACT SAME
 * `stallNudge` state machine (cooldown, two-step confirmation, escalation)
 * as the general check — this is a different way to arrive at "time to
 * nudge," not a second, parallel counter. The wake-up is always the claiming
 * bot itself (whoever this StallDetector instance runs for) — since only ITS
 * OWN completed turns ever populate ITS OWN `lastTurnMentions`, "wake the bot
 * that sent the @" and "wake the claiming bot" are the same bot by
 * construction here.
 *
 * ### Revision 1 (2026-07): 15min → 5min default, and the physical floor
 *
 * Handoff is machine-to-machine — a real @-mention dispatches within seconds,
 * so there's no "give the human time to notice" grace period to respect the
 * way there is for the general stall check. The only real lower bound is
 * `channelClient.ts`'s periodic gap-fill/open-chat-discovery cycle
 * (`DEFAULT_OPEN_CHAT_DISCOVERY_MS` = 300s, open-mode bots only — see
 * `startOpenChatDiscovery()`'s `allowedChatIds.size > 0` guard): if the
 * handoff threshold is shorter than that cycle, a WS-disconnect window can
 * cause a nudge AND a gap-fill redelivery to both fire for the same missed
 * event, doing the same wake-up twice. Default is therefore 5min (300s + one
 * patrol-tick buffer, this module's own poll interval). Config CAN set it
 * lower — nothing enforces a floor at runtime, per this module's iron rule
 * that the bridge stays mechanical and doesn't second-guess operator config
 * — but going below the deployment's actual gap-fill cycle risks that
 * double-fire; docs/task-handle.md §13 carries the explicit warning. A bot
 * with an explicit `chats:` allowlist (not open mode) has no periodic
 * gap-fill sweep at all (only reconnect-triggered, not a fixed cycle), so its
 * practical floor relaxes to ~2min — verified via `channelClient.ts`'s own
 * guard clause, not assumed.
 *
 * Handoff detection only ever arms for a peer bot running in the SAME bridge
 * process: `taskHandleMentionRoster` (main.ts) is built by cross-referencing
 * `bot.peers` against the same process's full `bots` list, so
 * `lastTurnMentions` can structurally never contain a cross-bridge peer's id
 * in the first place — there's no separate check needed to enforce this, and
 * a cross-bridge @ (which this process has no way to observe the receipt of)
 * simply never enters the handoff path and falls back to the general
 * threshold above.
 *
 * ### Revision 2 (2026-07): received, not dispatched
 *
 * "Has peer B responded" is deliberately "B's bridge RECEIVED the mention
 * event" (`BridgeHandler.getThreadReceivedAt`, stamped in `run()`'s
 * for-await loop at the moment an event is pulled off the queue) — NOT "B's
 * turn started running." `handler.ts`'s `run()` is cross-thread-concurrent
 * but same-thread-serial, globally capped at `MAX_CONCURRENT = 5` concurrent
 * `handleOne()` calls, and a single turn can take 5-15 minutes (per that
 * file's own comments) — so a received event can sit queued behind other
 * work for well over 5 minutes even though the handoff link is completely
 * intact. Using dispatch-start as the signal would misjudge "received but
 * queued" as a broken handoff and fire a spurious nudge telling A to resend,
 * duplicating work. The actual defining trait of a truly broken @ (bad
 * format, lost message) is that the event NEVER enters B's queue at all —
 * so "received" is the only mechanically correct signal: received (even if
 * still queued) means the link isn't broken; never-received means it is.
 *
 * `getThreadReceivedAt` is in-memory and per-process, so it's empty right
 * after a bridge restart — an event that was queued or mid-redelivery when
 * the process died would read back as "never received" even though it may
 * arrive again shortly via gap-fill. To avoid false handoff-positives in
 * that window, `#effectiveThreshold` skips the handoff rule entirely for
 * `handoffStartupQuietMs` (default: the same 300s gap-fill cycle + a
 * buffer) after this StallDetector instance is constructed — the general
 * stall check is unaffected and still applies during the quiet period.
 * Per this feature's own product principle (over-nudging is a cheap,
 * harmless false positive the agent shrugs off; under-detecting a genuinely
 * broken handoff is the actual thing this exists to prevent — see
 * docs/task-handle.md §13), the quiet period intentionally errs toward
 * "wait a bit longer before trusting silence," not toward tight precision.
 *
 * ### Revision 3 (2026-07): receipt only DEFERS the handoff check, it never
 * permanently clears it
 *
 * Revision 2 (above) fixed one false positive (received-but-queued misread
 * as broken) but in doing so opened a reverse hole: if B's bridge receives
 * the event but B's turn then genuinely never finishes — crashes mid-run,
 * hangs, gets stuck behind a permission prompt — `anyPeerResponded` would
 * stay true FOREVER (a single receipt permanently satisfies it), so the
 * handoff would never be flagged again even though the task is now
 * genuinely stuck. "Received" must only buy the peer a bounded grace
 * period, not indefinite immunity.
 *
 * So the handoff condition is now evaluated per mentioned peer in three
 * tiers, using TWO signals: `getPeerReceivedAt` (event arrival, as above)
 * and `getPeerLastActiveTs` (the peer's OWN `SessionStore.lastActiveTs` —
 * confirmed via code inspection to be written only at true turn
 * COMPLETION, `handler.ts`'s "session persistence" step after the agent
 * subprocess has finished, never at dispatch start — so unlike revision 2's
 * concern, this signal genuinely means "the peer's turn finished", not
 * "started" or "is queued"):
 *
 *   1. **Never received** (`getPeerReceivedAt` undefined, or at/before the
 *      mention) — broken once `stallHandoffThresholdMs` (5min) has elapsed
 *      since the mention. Same as revision 2.
 *   2. **Received, within `handoffReceiptGraceMs`** (default 30min) of that
 *      receipt — provisionally NOT broken; the peer plausibly has a turn
 *      queued or in progress (turns run 5-15min per handler.ts). Falls back
 *      to the general threshold, same as "peer responded" in revision 2.
 *   3. **Received, past the grace window, with no completed turn since**
 *      (`getPeerLastActiveTs` undefined or at/before the receipt) —
 *      RE-ARMS: broken again, same as tier 1. If a completed turn DID
 *      happen after the receipt, the peer genuinely picked it up — stays
 *      resolved, no re-arm.
 *
 * Net effect, matching the product framing: never received → nudge in 5min;
 * received → wait patiently up to 30min; received but never wrapped up →
 * nudge fires anyway. `getPeerLastActiveTs` here is the SAME kind of signal
 * `getLastActiveTs` already reads for THIS bot's own thread (a completed-
 * turn timestamp) — it just wasn't safe to use it as the PRIMARY signal in
 * revision 2 (queued-but-not-yet-run would misfire); reintroducing it here
 * as a SECONDARY, delayed confirmation is safe precisely because it's now
 * gated behind the grace window, not read the instant a mention lands.
 *
 * ## v3.3 巡检策略加固 (docs/task-handle.md §14)
 *
 * Three additions to this module, all reusing the EXISTING `stallNudge` state
 * machine and mechanical-signal discipline — none of these add a second,
 * parallel counter or any judgment call:
 *
 * 1. **Due-date early stall.** `getTaskDueMs` (wired from TasklistPoller's
 *    free-with-every-page `due` observation, see tasklistPoller.ts's
 *    `#dueByGuid`) lets `#effectiveThreshold` treat an ALREADY-PAST due date
 *    as an immediately-applicable threshold (`ms: 0`) — "don't wait for the
 *    idle clock, this is late NOW" — while every guardrail (cooldown,
 *    two-step confirmation, escalation) still applies exactly as before.
 *    Reason `"due"` is the new shortest tier, ahead of handoff/failed/normal.
 * 2. **Nudge history in the description.** Every successful `#sendNudge`
 *    (any reason/tier) also appends one line to the task's rolling progress
 *    log via `mergeDescriptionSnapshot` — so a human scanning the task center
 *    can see how many times, and why, the bridge has had to intervene.
 *    Best-effort: a failed patch is logged and swallowed, never blocks the
 *    synthetic wake-up turn itself from being enqueued.
 * 3. **Hourly nudge fuse.** A per-instance (per-bot) sliding-window cap on
 *    how many synthetic stall/handoff/due turns THIS bot's StallDetector will
 *    enqueue within any trailing 60 minutes (`stallNudgeHourlyCap`, default
 *    6) — a circuit breaker against an unknown bug turning into a reasoning-
 *    budget-burning wake-up storm, not a normal-path control. Tripping it
 *    suppresses the send (no state mutation — next cycle just retries) and
 *    logs which task got suppressed. In-memory only; a restart clears the
 *    window, which is an accepted, deliberately cheap trade-off for a
 *    fuse whose entire job is "never fire on the happy path."
 *
 * ## Adversarial review round 2 fixes (2026-07, docs/task-handle.md §14)
 *
 * Two related fixes to tier 1 of the handoff check:
 *
 * 1. **Tier 1 now accepts a persisted completion, not just a fresh receipt.**
 *    `getPeerReceivedAt`'s backing map is in-memory and per-process — right
 *    after a bridge restart it's empty for EVERY thread, even ones whose
 *    handoff was completely healthy pre-restart and simply has no reason to
 *    receive a NEW event afterward. Tier 1 now also checks
 *    `getPeerLastActiveTs(peer, thread) > mentionAt` (the peer's own
 *    disk-persisted completion) before declaring "never received" — without
 *    this, every restart falsely re-armed tier 1 for every already-resolved
 *    handoff. This is a genuinely different check from tier 3's use of the
 *    same dep (tier 3 requires completion AFTER a receipt within the grace
 *    window; this is completion at all, any time after the mention).
 * 2. **The mention anchor moved from writeback-time to turn-start.** Before
 *    this fix, `TaskHandleRecord.lastTurnMentionsAt` was stamped via
 *    `Date.now()` inside writeback.ts's `applyTaskHandleWriteback` — which
 *    runs AFTER the agent subprocess finished AND after writeback's own
 *    `getTask` round-trip. A common, entirely healthy pattern — the agent
 *    @-mentioning peer B via `lark-cli` MID-turn — would then guarantee B's
 *    genuine receipt timestamp predates that (too-late) anchor, so tier 1
 *    misread a currently-healthy handoff as broken. The anchor is now
 *    `TaskHandleLifecyclePatch.turnReceivedAt` — THIS turn's own receipt
 *    timestamp, captured by handler.ts at turn START, before either the
 *    subprocess or the getTask round-trip. Combined with fix 1 above (peer
 *    completion also resolves tier 1), a mid-turn `lark-cli @` no longer
 *    misfires even in the timing window these two fixes don't fully close.
 *
 * Class shape (timer/start/stop/jitter) mirrors commentPoller.ts.
 */

import type { TaskHandleRecord } from "./store.js";
import type { TaskHandleStore } from "./store.js";
import { TaskListClient, isPermissionDeniedError, type TaskSnapshot } from "./client.js";
import { mergeDescriptionSnapshot } from "./writeback.js";

const DEFAULT_INTERVAL_MS = 60_000;
const DEFAULT_JITTER_MS = 10_000;
// v4.2 (docs/task-handle.md §12, patrol-recovery hardening): the general
// threshold dropped 24h → 1h. 24h was calibrated for the human-paced 转任务
// era; 任务派单 tasks are hour-scale, and the user's own patrol principle
// (docs/task-handle.md §14 preamble) is explicitly asymmetric — a false nudge
// costs one cheap agent turn, a missed dead task costs the task. Cooldown
// follows (24h → 1h) or the shorter threshold would only ever fire once.
const DEFAULT_STALL_THRESHOLD_MS = 60 * 60_000; // 1h — general "topic went quiet"
const DEFAULT_STALL_FAST_THRESHOLD_MS = 30 * 60_000; // 30min — last turn crashed
const DEFAULT_STALL_HANDOFF_THRESHOLD_MS = 5 * 60_000; // 5min — mentioned peer hasn't received the event (v3.2 revision 1: 300s gap-fill cycle + one patrol-tick buffer)
const DEFAULT_NUDGE_COOLDOWN_MS = 60 * 60_000; // 1h — see the threshold comment above
// v4.2 round-2 fix: the due tier is a STANDING condition (threshold 0, never
// suppressed by activity — §14.2), so the shared cooldown is its ONLY brake.
// Dropping the shared default to 1h silently re-calibrated the §14.2-P0
// invariant 24×: an overdue task under active work would get a due-nudge turn
// every hour, forever. The due tier therefore keeps its own floor at the old
// 24h — "you're past due" is a once-a-day reminder, not an hourly alarm.
const DUE_NUDGE_COOLDOWN_FLOOR_MS = 24 * 60 * 60_000;
const DEFAULT_ESCALATE_AFTER_NUDGES = 2;
/** How long a nudge can sit "pending" (enqueued, unconfirmed) before being treated as lost — see the module doc's fix #3. Generous relative to a normal turn's duration, short relative to the 24h cooldown. Not exposed via bot yaml — an implementation detail of delivery confirmation, not a product-facing threshold. */
const DEFAULT_PENDING_CONFIRM_TIMEOUT_MS = 30 * 60_000;
/** v3.2 revision 2: how long after construction the handoff rule stays disarmed, so an empty post-restart `getPeerReceivedAt` map isn't mistaken for "never received." Mirrors channelClient.ts's 300s gap-fill cycle + a buffer — see the module doc's revision 2 section. Not exposed via bot yaml — an implementation detail of restart safety, not a product-facing threshold. */
const DEFAULT_HANDOFF_STARTUP_QUIET_MS = 6 * 60_000;
/** v3.2 revision 3: how long a peer's RECEIPT alone is trusted before requiring a genuinely completed turn as confirmation — see the module doc's revision 3 section. 30min is generous relative to handler.ts's own 5-15min turn duration comment. */
const DEFAULT_HANDOFF_RECEIPT_GRACE_MS = 30 * 60_000;
/** Mirrors commentPoller.ts's PERMISSION_BACKOFF_CEILING_MS — same rationale (§D). */
const PERMISSION_BACKOFF_CEILING_MS = 30 * 60_000;
/** v3.3 全局唤醒保险丝 — see the module doc's "hourly nudge fuse" section. A circuit breaker, not a product-facing tuning knob most deployments need to touch. */
const DEFAULT_STALL_NUDGE_HOURLY_CAP = 6;
const HOUR_MS = 60 * 60_000;

const STALL_NUDGE_PREFIX = "[停滞提醒] ";

export interface StallNudgeTurn {
  threadId: string;
  chatId: string;
  text: string;
}

export interface StallDetectorDeps {
  store: TaskHandleStore;
  client: TaskListClient;
  /**
   * Reads this bot's SessionStore for the thread's `lastActiveTs` — a plain
   * in-memory Map read, no I/O. Returns undefined if the thread has no
   * session record at all (shouldn't normally happen for a claimed thread —
   * a claim always postdates the thread's first session — but degrades to
   * "treat claimedTs as the baseline" rather than crashing).
   */
  getLastActiveTs: (threadId: string) => number | undefined;
  /** Pushes a synthesized turn onto the bridge's normal inbound queue — same mechanism as CommentPoller's enqueueSyntheticTurn. */
  enqueueNudgeTurn: (turn: StallNudgeTurn) => void;
  /**
   * v3.2 交接断链检测 (revision 2): reads ANOTHER bot's `BridgeHandler` for
   * when it last RECEIVED an event in this thread — a plain in-memory Map
   * read, no I/O (main.ts closes over a botId→BridgeHandler map populated
   * during startup). Deliberately "received," not "turn started" — see the
   * module doc's revision 2 section for why dispatch-start would misfire
   * under this bridge's concurrency model. Omit to disable handoff-break
   * detection entirely (the general stall check above is unaffected either
   * way).
   */
  getPeerReceivedAt?: (peerBotId: string, threadId: string) => number | undefined;
  /**
   * v3.2 交接断链检测 (revision 3): reads ANOTHER bot's OWN `lastActiveTs` for
   * this thread — i.e. the SAME kind of signal `getLastActiveTs` reads for
   * this bot's own thread, just cross-bot. Confirmed via code inspection to
   * be written only at true turn COMPLETION (handler.ts's session-persistence
   * step, after the agent subprocess finishes), never at dispatch start.
   * Used ONLY as a secondary, delayed confirmation once a peer's receipt is
   * older than `handoffReceiptGraceMs` — see the module doc's revision 3
   * section for why it wasn't safe to use as the PRIMARY signal (that's
   * exactly the revision-2 bug: a queued-but-not-yet-run turn would misfire).
   * Omit to skip the re-arm tier entirely — a peer's receipt then stays a
   * permanent (not just provisional) all-clear, matching revision 2's
   * behavior before this revision.
   */
  getPeerLastActiveTs?: (peerBotId: string, threadId: string) => number | undefined;
  /**
   * v4.2 (revision 5, 2026-07-08 real case): the OUTCOME of the peer's most
   * recent finished turn on this thread ("completed" | "failed"), from the
   * peer handler's in-memory finalize record. A peer turn that ENDED IN
   * FAILURE after the mention is a broken handoff RIGHT NOW — before this,
   * #effectiveThreshold counted any finished turn as "handoff resolved", so
   * a downstream crash (idle-kill, API-stream death) silently dropped the
   * task to the general tier. In-memory: undefined after a bridge restart →
   * falls back to the pre-revision-5 posture (completion evidence resolves),
   * same conservative restart stance as revision 4.
   */
  getPeerLastTurnOutcome?: (peerBotId: string, threadId: string) => "completed" | "failed" | undefined;
  /**
   * v3.3 due-date stall detection (docs/task-handle.md §14): plain in-memory
   * read of the task's `due` timestamp, as most recently observed by
   * TasklistPoller's `getDueTimestamp` (populated for free from the same
   * `listTasklistTasks` pages already fetched for candidate discovery — no
   * dedicated `getTask` call, keeping this module's "no forced network call
   * per claim per cycle" invariant intact). Undefined = no known due date
   * (or not yet observed this run) — the due-based tier simply doesn't apply.
   * Omit to disable due-date-based early stall entirely.
   */
  getTaskDueMs?: (taskGuid: string) => number | undefined;
  /**
   * Round-2 adversarial review fix: THIS bot's OWN `BridgeHandler.
   * getThreadReceivedAt` (the same "received" signal used cross-bot for
   * handoff detection, here read for the claiming bot's own thread). Used
   * ONLY to disambiguate a pending-nudge confirmation timeout: a synthetic
   * nudge turn can sit queued behind handler.ts's global MAX_CONCURRENT=5
   * semaphore for longer than `pendingConfirmTimeoutMs` even though it was
   * genuinely received and isn't lost — declaring it "lost" in that case
   * causes a duplicate wake-up turn once the first one finally runs. If this
   * shows the synthetic event WAS received after `pendingSince`, the pending
   * wait is extended instead of declaring it lost. Omit to keep the
   * pre-fix behavior (declare lost purely on elapsed time).
   */
  getOwnThreadReceivedAt?: (threadId: string) => number | undefined;
}

export interface StallDetectorOptions {
  /** @default 60_000 */
  intervalMs?: number;
  /** First-run jitter cap, clamped to intervalMs. @default 10_000 */
  jitterMs?: number;
  /** @default 1h (v4.2; was 24h through v4.1) */
  stallThresholdMs?: number;
  /** @default 30min */
  stallFastThresholdMs?: number;
  /** @default 5min. v3.2 交接断链检测 — see the module doc's revision 1 section for the gap-fill-cycle floor reasoning; docs/task-handle.md §13 has the operator-facing warning. */
  stallHandoffThresholdMs?: number;
  /** @default 1h (v4.2; was 24h). The due tier keeps a 24h floor regardless — see DUE_NUDGE_COOLDOWN_FLOOR_MS. */
  nudgeCooldownMs?: number;
  /** @default 2 */
  escalateAfterNudges?: number;
  /** @default 30min. Primarily for tests — see DEFAULT_PENDING_CONFIRM_TIMEOUT_MS's doc. */
  pendingConfirmTimeoutMs?: number;
  /** @default 6min. v3.2 revision 2 — see DEFAULT_HANDOFF_STARTUP_QUIET_MS's doc. Primarily for tests (set to 0 to disarm the quiet period). */
  handoffStartupQuietMs?: number;
  /** @default 30min. v3.2 revision 3 — see DEFAULT_HANDOFF_RECEIPT_GRACE_MS's doc: how long a peer's receipt alone is trusted before requiring a genuinely completed turn as confirmation. */
  handoffReceiptGraceMs?: number;
  /** @default 6. v3.3 全局唤醒保险丝 — see DEFAULT_STALL_NUDGE_HOURLY_CAP's doc. A circuit breaker; the happy path never approaches it. */
  stallNudgeHourlyCap?: number;
}

/** `ms` rendered as a human-friendly duration label, minutes below an hour, hours at/above — a fixed 15min threshold reading "已超过 1 小时" (rounding-to-hours) would be actively misleading. Exported for direct unit testing — pure, no I/O. */
export function formatDurationLabel(ms: number): string {
  if (ms < 3_600_000) return `${Math.max(1, Math.round(ms / 60_000))} 分钟`;
  return `${Math.max(1, Math.round(ms / 3_600_000))} 小时`;
}

/** `dueMs` rendered as a plain `YYYY-MM-DD` date (bridge machine local time, matching writeback.ts's human-facing date rendering convention) — no time-of-day, since task v2's `due.is_all_day` case is common and a precise-looking timestamp would overstate the precision. Exported for direct unit testing — pure, no I/O. */
export function formatDueDate(dueMs: number): string {
  const d = new Date(dueMs);
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}

/** Renders the synthesized "message" text the claiming agent sees on a nudge turn — a plain fact block, no instruction on WHAT to do (that's the SKILL's job). */
export function renderStallNudgeText(input: {
  summary: string | undefined;
  idleMs: number;
  nudgeCount: number;
  /** @default "normal" */
  reason?: "handoff" | "failed" | "normal" | "due";
  /** v3.3 due-date stall — only meaningful (and only rendered) when reason="due". */
  dueMs?: number;
}): string {
  const title = input.summary ? `“${input.summary}”` : "(无标题)";
  const idleLabel = formatDurationLabel(input.idleMs);
  const situation =
    input.reason === "due"
      ? `已过期${input.dueMs !== undefined ? `(截止 ${formatDueDate(input.dueMs)})` : ""}`
      : input.reason === "handoff"
        ? `你上一轮回复里 @ 的协作 bot,已超过 ${idleLabel}没有在这个话题接手`
        : `已超过 ${idleLabel}没有新动态`;
  const needsComma = input.reason === "handoff" || input.reason === "due";
  return (
    `${STALL_NUDGE_PREFIX}你认领的任务 ${title}${needsComma ? "," : ""} ${situation}` +
    `(第 ${input.nudgeCount} 次提醒)。请判断如何推进这项工作。`
  );
}

/** Renders the task comment posted when nudging has been exhausted without progress — a human-facing escalation, not a synthetic agent turn. */
export function renderStallEscalationComment(input: { summary: string | undefined; escalateAfterNudges: number }): string {
  const title = input.summary ? `“${input.summary}”` : "";
  return (
    `⚠️ 任务 ${title} 已连续 ${input.escalateAfterNudges} 次停滞提醒仍无新动态,` +
    "可能需要人工确认进展或调整安排。"
  );
}

export class StallDetector {
  readonly #deps: StallDetectorDeps;
  readonly #intervalMs: number;
  readonly #jitterMs: number;
  readonly #stallThresholdMs: number;
  readonly #stallFastThresholdMs: number;
  readonly #stallHandoffThresholdMs: number;
  readonly #nudgeCooldownMs: number;
  readonly #escalateAfterNudges: number;
  readonly #pendingConfirmTimeoutMs: number;
  readonly #handoffStartupQuietMs: number;
  readonly #handoffReceiptGraceMs: number;
  readonly #stallNudgeHourlyCap: number;
  /** Construction time — the baseline for the revision-2 startup quiet period, since this instance's getPeerReceivedAt-backed map starts empty either way (fresh process or fresh instance). */
  readonly #startedAt: number;
  #timer: ReturnType<typeof setTimeout> | ReturnType<typeof setInterval> | undefined;
  #running = false;
  /** See CommentPoller's identical field for why stop() must await this. */
  #inFlight: Promise<void> = Promise.resolve();
  /** Mirrors commentPoller.ts's permission-denied backoff — see its doc comment. */
  readonly #permissionBackoff = new Map<string, { nextAttemptAt: number; backoffMs: number }>();
  /**
   * Round-2 adversarial review fix: `#escalate`'s `addComment` failing must
   * NOT set `escalated: true` (that would permanently silence a task whose
   * human-facing escalation comment never actually landed — the exact
   * "静默漏管" this feature exists to prevent). Same doubling-backoff shape
   * as `#permissionBackoff`, kept separate because it's a distinct failure
   * class (a transient API error posting a comment, not a scope/permission
   * problem reading a task).
   */
  readonly #escalationCommentBackoff = new Map<string, { nextAttemptAt: number; backoffMs: number }>();
  /** v3.3 全局唤醒保险丝: ms-epoch timestamps of every synthetic nudge turn THIS instance has enqueued, pruned to the trailing hour on each check — see `#tripFuseOrRecord`. In-memory, per-instance (per-bot); a restart clears it, an accepted trade-off for a fuse that should never trip on the happy path anyway. */
  #recentNudgeSendTimestamps: number[] = [];

  constructor(deps: StallDetectorDeps, opts: StallDetectorOptions = {}) {
    this.#deps = deps;
    this.#intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
    this.#jitterMs = Math.min(opts.jitterMs ?? DEFAULT_JITTER_MS, this.#intervalMs);
    this.#stallThresholdMs = opts.stallThresholdMs ?? DEFAULT_STALL_THRESHOLD_MS;
    this.#stallFastThresholdMs = opts.stallFastThresholdMs ?? DEFAULT_STALL_FAST_THRESHOLD_MS;
    this.#stallHandoffThresholdMs = opts.stallHandoffThresholdMs ?? DEFAULT_STALL_HANDOFF_THRESHOLD_MS;
    this.#nudgeCooldownMs = opts.nudgeCooldownMs ?? DEFAULT_NUDGE_COOLDOWN_MS;
    this.#escalateAfterNudges = opts.escalateAfterNudges ?? DEFAULT_ESCALATE_AFTER_NUDGES;
    this.#pendingConfirmTimeoutMs = opts.pendingConfirmTimeoutMs ?? DEFAULT_PENDING_CONFIRM_TIMEOUT_MS;
    this.#handoffStartupQuietMs = opts.handoffStartupQuietMs ?? DEFAULT_HANDOFF_STARTUP_QUIET_MS;
    this.#handoffReceiptGraceMs = opts.handoffReceiptGraceMs ?? DEFAULT_HANDOFF_RECEIPT_GRACE_MS;
    this.#stallNudgeHourlyCap = opts.stallNudgeHourlyCap ?? DEFAULT_STALL_NUDGE_HOURLY_CAP;
    this.#startedAt = Date.now();
  }

  start(): void {
    if (this.#timer !== undefined) return; // idempotent
    const firstDelay = Math.floor(Math.random() * this.#jitterMs);
    this.#timer = setTimeout(() => {
      void this.#pollOnce();
      this.#timer = setInterval(() => {
        void this.#pollOnce();
      }, this.#intervalMs);
      (this.#timer as ReturnType<typeof setInterval>).unref?.();
    }, firstDelay);
    (this.#timer as ReturnType<typeof setTimeout>).unref?.();
  }

  stop(): Promise<void> {
    if (this.#timer !== undefined) {
      clearTimeout(this.#timer as ReturnType<typeof setTimeout>);
      clearInterval(this.#timer as ReturnType<typeof setInterval>);
      this.#timer = undefined;
    }
    return this.#inFlight;
  }

  /** Exposed for tests — runs exactly one poll cycle over every claimed task. */
  async pollOnceForTest(): Promise<void> {
    await this.#pollOnce();
  }

  async #pollOnce(): Promise<void> {
    if (this.#running) return; // skip overlapping cycles
    this.#running = true;
    const run = this.#runPollCycle();
    this.#inFlight = run;
    try {
      await run;
    } finally {
      this.#running = false;
    }
  }

  async #runPollCycle(): Promise<void> {
    for (const record of this.#deps.store.list()) {
      await this.#pollOne(record.threadId);
    }
  }

  /**
   * All local (in-memory, zero-I/O) checks run FIRST, cheapest first; a
   * network call (`getTask`, inside `#sendNudge`/`#escalate`) only happens
   * on the cycle where we're actually about to act. See the module doc's
   * fixes #1 (cooldown ordering) and #2 (getTask deferral) for why.
   */
  async #pollOne(threadId: string): Promise<void> {
    let record = this.#deps.store.get(threadId);
    if (!record) return; // dropped between list() snapshot and now

    // v4.1 comment-mode delivery (docs/task-handle.md §15.3): the agent has
    // declared done and posted its own "已交付" comment — the human just
    // hasn't ticked complete yet. That window is NOT a stall; patrol resumes
    // only when writeback's "received" branch clears the flag (re-engagement).
    if (record.doneDeclared) return;

    const lastActiveTs = this.#deps.getLastActiveTs(threadId) ?? record.claimedTs;
    const now = Date.now();

    // Cheapest check: a task already confirmed completed, with nothing new
    // since — skip entirely, no getTask, no further evaluation.
    if (
      record.stallSuppressUntilActivityAfter !== undefined &&
      lastActiveTs <= record.stallSuppressUntilActivityAfter
    ) {
      return;
    }

    if (record.stallNudge?.escalated) {
      const nudge = record.stallNudge;
      if (nudge.lastNudgeTurnActivityAt !== undefined && lastActiveTs > nudge.lastNudgeTurnActivityAt) {
        await this.#deps.store.update(threadId, (r) =>
          r ? { ...r, stallNudge: undefined, stallSuppressUntilActivityAfter: undefined } : r,
        );
      }
      return; // silent otherwise — escalated tasks don't auto-nudge again
    }

    // Tracks whether THIS cycle just cleared a pending-nudge timeout below —
    // that fall-through retry must stay immediate (bounded only by
    // pendingConfirmTimeoutMs having already elapsed) regardless of reason,
    // never gated by the due-tier cooldown-preservation check further down
    // (see its own comment for why those two states are otherwise
    // indistinguishable by shape alone: both end up count:0 with a residual
    // lastNudgeSentAt).
    let justClearedPendingTimeout = false;

    if (record.stallNudge?.pendingSince !== undefined) {
      const pendingSince = record.stallNudge.pendingSince;
      if (lastActiveTs > pendingSince) {
        // Confirmed: this bump IS the nudge's own turn replying. Promote the
        // count and attribute the baseline in the SAME update (module doc
        // fix #3 — this merges "confirm dispatch" and "attribute the reply"
        // into one step, since they're the same observation).
        await this.#deps.store.update(threadId, (r) => {
          if (!r?.stallNudge) return r;
          return {
            ...r,
            stallNudge: {
              count: r.stallNudge.count + 1,
              lastNudgeSentAt: pendingSince,
              lastNudgeTurnActivityAt: lastActiveTs,
              escalated: false,
              pendingSince: undefined,
            },
          };
        });
        return; // don't also evaluate escalate/re-nudge this same cycle
      }
      if (now - pendingSince <= this.#pendingConfirmTimeoutMs) {
        return; // still waiting to see if it lands
      }
      // Round-2 adversarial review fix: the synthetic nudge turn's own
      // event can sit queued behind handler.ts's global MAX_CONCURRENT=5
      // semaphore for longer than pendingConfirmTimeoutMs even though it
      // was genuinely received — a busy deployment with a few long
      // (5-15min) turns in flight makes >30min queue wait realistic.
      // Declaring it "lost" here would #sendNudge a DUPLICATE wake-up this
      // same cycle, and the first turn's eventual completion would then
      // read as "real further progress" and wipe the state, resetting the
      // escalation counter for a task that made zero real progress. Before
      // declaring lost, check whether this bot's OWN thread genuinely
      // received the synthetic event after pendingSince — if so, that's
      // queue evidence it isn't lost, just still waiting on the semaphore;
      // extend the wait instead of resending.
      const ownReceivedAt = this.#deps.getOwnThreadReceivedAt?.(threadId);
      if (ownReceivedAt !== undefined && ownReceivedAt > pendingSince) {
        return; // received, still queued — keep waiting, do not declare lost
      }
      // No receipt evidence at all (or the dep isn't wired) — genuinely
      // treat as lost. Clear pendingSince WITHOUT incrementing count
      // (module doc fix #3), then keep evaluating below with the refreshed
      // record (may re-send this same cycle).
      const updated = await this.#deps.store.update(threadId, (r) =>
        r?.stallNudge ? { ...r, stallNudge: { ...r.stallNudge, pendingSince: undefined } } : r,
      );
      if (!updated) return;
      record = updated;
      justClearedPendingTimeout = true;
    }

    const nudge = record.stallNudge;
    const { ms: threshold, reason } = this.#effectiveThreshold(record, threadId);

    if (!nudge || nudge.count === 0) {
      if (now - lastActiveTs < threshold) return; // not stalled yet
      // v3.3 adversarial review round 2 (P0, docs/task-handle.md §14.2): a
      // due-tier progress-reset (below) preserves lastNudgeSentAt instead of
      // wiping stallNudge — so count can be 0 here with a cooldown anchor
      // still present. Due is a STANDING condition, not a silence measure;
      // the invariant is "the same task's due-based wake-ups are still ≥
      // nudgeCooldownMs apart, no matter how much real progress happens in
      // between" — without this check, threshold=0 would fire again within
      // one poll cycle of any progress bump, defeating the cooldown entirely.
      // Gated to reason==="due" AND !justClearedPendingTimeout: a lost
      // pending nudge (any tier, including due) must still retry immediately
      // once its own timeout has elapsed — that's a DIFFERENT guarantee
      // ("don't silently drop a nudge nobody ever saw") than "don't nudge
      // again within one cooldown period of the last CONFIRMED send", and
      // conflating them would turn a lost due-nudge into a genuine
      // under-detection (waiting a full cooldown to even retry once).
      if (
        reason === "due" &&
        !justClearedPendingTimeout &&
        nudge?.lastNudgeSentAt !== undefined &&
        now - nudge.lastNudgeSentAt < this.#cooldownFor(reason)
      ) {
        return;
      }
      await this.#sendNudge(threadId);
      return;
    }

    // count >= 1 with no pending nudge: lastNudgeTurnActivityAt is always
    // set here — confirmation (above) always sets both fields together.
    if (lastActiveTs > nudge.lastNudgeTurnActivityAt!) {
      await this.#deps.store.update(threadId, (r) => {
        if (!r) return r;
        if (reason === "due") {
          // P0 fix: due tier survives progress-reset. Only the escalation
          // COUNT resets (real progress genuinely means "start counting
          // escalation attempts over") — lastNudgeSentAt (the cooldown
          // anchor) is preserved, so the count===0 branch above still gates
          // the next send by the full cooldown instead of firing immediately.
          return {
            ...r,
            stallNudge: {
              count: 0,
              lastNudgeSentAt: r.stallNudge?.lastNudgeSentAt ?? now,
              lastNudgeTurnActivityAt: undefined,
              escalated: false,
              pendingSince: undefined,
            },
          };
        }
        return { ...r, stallNudge: undefined }; // real further progress (non-due tiers unaffected)
      });
      return;
    }

    // No progress since the confirmed nudge. NOW gate the actual action by
    // cooldown (module doc fix #1) — observation above never waits on this.
    if (now - nudge.lastNudgeSentAt < this.#cooldownFor(reason)) return;

    if (nudge.count >= this.#escalateAfterNudges) {
      await this.#escalate(threadId);
    } else {
      await this.#sendNudge(threadId);
    }
  }

  /**
   * The effective stall threshold for this record right now, and which rule
   * produced it (module doc's v3.2/v3.3 sections) — whichever applicable
   * rule is SHORTEST always wins.
   *
   * v3.3 due-date tier (checked FIRST, docs/task-handle.md §14): an
   * ALREADY-PAST due date is immediately applicable (`ms: 0`) — "don't wait
   * for the idle clock, this is late right now." Always the shortest
   * possible value, so it short-circuits before even evaluating handoff.
   *
   * Per mentioned peer (revision 3), the handoff check is a three-tier check
   * using precise timestamps (not the coarser `now - thisBot'sOwnLastActiveTs`
   * comparison `#pollOne` uses downstream — this method resolves the handoff
   * verdict itself using real anchors, then returns a threshold value that is
   * GUARANTEED to already be satisfied by that outer comparison once a tier
   * fires, since `stallHandoffThresholdMs`/`handoffReceiptGraceMs` are the
   * exact bounds this method itself already waited out):
   *
   *   1. Never received → broken once `stallHandoffThresholdMs` has elapsed
   *      since the mention.
   *   2. Received, within `handoffReceiptGraceMs` of that receipt → NOT
   *      broken (falls back to `base`) — the peer plausibly has a turn
   *      queued or in progress.
   *   3. Received, past the grace window, with no completed turn (per
   *      `getPeerLastActiveTs`) since the receipt → RE-ARMS, broken again.
   *
   * Disarmed entirely for `handoffStartupQuietMs` after construction
   * (revision 2) — `getPeerReceivedAt`'s backing map is process-local and
   * starts empty, so "peer hasn't received it" is not trustworthy evidence
   * until gap-fill has had a chance to redeliver anything in flight across
   * a restart.
   */
  /** Cooldown for a given tier — the due tier keeps a 24h floor (see DUE_NUDGE_COOLDOWN_FLOOR_MS). An explicit operator nudgeCooldownMs LONGER than the floor still wins. */
  #cooldownFor(reason: "due" | "handoff" | "failed" | "normal"): number {
    return reason === "due" ? Math.max(this.#nudgeCooldownMs, DUE_NUDGE_COOLDOWN_FLOOR_MS) : this.#nudgeCooldownMs;
  }

  #effectiveThreshold(
    record: TaskHandleRecord,
    threadId: string,
  ): { ms: number; reason: "due" | "handoff" | "failed" | "normal"; dueMs?: number } {
    const base: { ms: number; reason: "failed" | "normal" } =
      record.lastTurnOutcome === "failed"
        ? { ms: this.#stallFastThresholdMs, reason: "failed" }
        : { ms: this.#stallThresholdMs, reason: "normal" };

    const dueMs = this.#deps.getTaskDueMs?.(record.taskGuid);
    if (dueMs !== undefined && Date.now() >= dueMs) {
      return { ms: 0, reason: "due", dueMs };
    }

    if (
      !this.#deps.getPeerReceivedAt ||
      !record.lastTurnMentions?.length ||
      record.lastTurnMentionsAt === undefined ||
      Date.now() - this.#startedAt < this.#handoffStartupQuietMs
    ) {
      return base;
    }
    const mentionAt = record.lastTurnMentionsAt;
    const now = Date.now();

    // Per broken peer, which tier fired decides the DISPLAY floor ("已超过 X
    // 分钟" — tier 1 fired at stallHandoffThresholdMs, tier 3 fired at the
    // longer handoffReceiptGraceMs; showing the tier-appropriate floor is a
    // stronger, still-true claim than always showing the shorter one). The
    // outer numeric comparison in #pollOne/#sendNudge only needs SOME value
    // ≤ base — both floors already reflect real elapsed time this method
    // itself just verified, so either is safe there regardless of tier.
    let brokenFloorMs: number | undefined;
    for (const peerBotId of record.lastTurnMentions) {
      // Revision 5 (2026-07-08 real case: 派单下游被 idle-kill,巡检 24h 不闻不问):
      // a peer turn that finished in FAILURE after the mention is a broken
      // handoff immediately — neither the receipt grace (tier 2) nor the
      // finished-turn evidence (tier 1 restart check / tier 3) may resolve
      // it. Guarded on the peer's own lastActiveTs so a STALE failure from
      // before this mention can't re-arm.
      {
        const peerOutcome = this.#deps.getPeerLastTurnOutcome?.(peerBotId, threadId);
        const peerEndedAt = this.#deps.getPeerLastActiveTs?.(peerBotId, threadId);
        if (peerOutcome === "failed" && peerEndedAt !== undefined && peerEndedAt > mentionAt) {
          brokenFloorMs = this.#stallHandoffThresholdMs;
          break;
        }
      }
      const receivedAt = this.#deps.getPeerReceivedAt!(peerBotId, threadId);
      if (receivedAt === undefined || receivedAt <= mentionAt) {
        // Adversarial review round 2 (docs/task-handle.md §13.4): a
        // disk-persisted COMPLETED turn since the mention is equally strong
        // evidence the handoff succeeded as a fresh receipt would be — and
        // it's the ONLY evidence available across a bridge restart (the
        // receipt map is in-memory and empty right after restart, so
        // `receivedAt === undefined` there means "this process hasn't seen
        // one YET", not "the peer never got it"). Without this check, EVERY
        // bridge restart falsely re-arms tier 1 for any already-resolved
        // handoff whose peer has no reason to receive a LATER event.
        const completedAt = this.#deps.getPeerLastActiveTs?.(peerBotId, threadId);
        if (completedAt !== undefined && completedAt > mentionAt) continue; // resolved via persisted completion
        // Tier 1: never received — broken only once the handoff threshold
        // has actually elapsed since the mention (not the instant it lands).
        if (now - mentionAt >= this.#stallHandoffThresholdMs) {
          brokenFloorMs = this.#stallHandoffThresholdMs;
          break;
        }
        continue;
      }
      // Tier 2: received, still within the receipt grace window — not
      // broken; the peer plausibly has a turn queued or running.
      if (now - receivedAt < this.#handoffReceiptGraceMs) continue;
      // Tier 3: grace elapsed — only a genuinely COMPLETED turn since the
      // receipt (not another receipt) counts as real resolution.
      const completedAt = this.#deps.getPeerLastActiveTs?.(peerBotId, threadId);
      if (!(completedAt !== undefined && completedAt > receivedAt)) {
        brokenFloorMs = this.#handoffReceiptGraceMs;
        break;
      }
    }
    if (brokenFloorMs === undefined) return base;

    return brokenFloorMs < base.ms ? { ms: brokenFloorMs, reason: "handoff" } : base;
  }

  /**
   * Single choke point for every `getTask` call in this class — permission
   * backoff, not-found handling, and generic-failure logging all live here
   * once. Returns null on ANY failure (already logged/handled internally);
   * callers just bail out when they see null.
   */
  async #fetchTaskOrHandle(threadId: string, taskGuid: string): Promise<TaskSnapshot | null> {
    const backoff = this.#permissionBackoff.get(threadId);
    if (backoff && Date.now() < backoff.nextAttemptAt) return null; // still backing off a known scope denial

    let task: TaskSnapshot | null;
    try {
      task = await this.#deps.client.getTask(taskGuid);
      this.#permissionBackoff.delete(threadId); // a successful call clears any prior backoff
    } catch (err) {
      if (isPermissionDeniedError(err)) {
        const nextBackoffMs = Math.min(backoff ? backoff.backoffMs * 2 : this.#intervalMs, PERMISSION_BACKOFF_CEILING_MS);
        this.#permissionBackoff.set(threadId, { nextAttemptAt: Date.now() + nextBackoffMs, backoffMs: nextBackoffMs });
        console.warn(
          `[tasklist.stallDetector] permission denied reading task ${taskGuid} (thread ${threadId}) — ` +
            `likely a missing task:task:read scope grant. Backing off for ${Math.round(nextBackoffMs / 1000)}s.`,
          err,
        );
        return null;
      }
      console.warn(`[tasklist.stallDetector] poll failed for thread ${threadId} (continuing):`, err);
      return null;
    }
    // getTask() itself already resolves 404-shaped errors to null (client.ts's
    // isNotFoundLikeRaw pre-check) rather than throwing — so this, not a
    // caught exception, is where "task deleted" surfaces here.
    if (task === null) {
      console.warn(
        `[tasklist.stallDetector] task ${taskGuid} (thread ${threadId}) not found or inaccessible` +
          " — dropping claim mapping (no auto-recreate, per docs/task-handle.md §6.2)",
      );
      await this.#deps.store.delete(threadId).catch(() => {});
      return null;
    }
    return task;
  }

  /**
   * v3.3 全局唤醒保险丝: does THIS instance's trailing-hour nudge count already
   * sit at/above `stallNudgeHourlyCap`? Prunes timestamps older than an hour
   * first, so this is always evaluated against a genuinely trailing window,
   * not a fixed clock-hour bucket. Read-only — call {@link #recordNudgeSend}
   * separately, only after a nudge is actually about to be enqueued.
   */
  #isNudgeFuseTripped(now: number): boolean {
    const cutoff = now - HOUR_MS;
    this.#recentNudgeSendTimestamps = this.#recentNudgeSendTimestamps.filter((ts) => ts > cutoff);
    return this.#recentNudgeSendTimestamps.length >= this.#stallNudgeHourlyCap;
  }

  /** Only called when `#pollOne` has already decided (using local checks alone) that it's time to nudge. */
  async #sendNudge(threadId: string): Promise<void> {
    const record = this.#deps.store.get(threadId);
    if (!record) return;

    const now = Date.now();
    if (this.#isNudgeFuseTripped(now)) {
      console.warn(
        `[tasklist.stallDetector] hourly nudge fuse tripped (cap=${this.#stallNudgeHourlyCap}/hour) — ` +
          `suppressing this cycle's wake-up for task ${record.taskGuid} (thread ${threadId}); will re-evaluate next cycle.`,
      );
      return; // no state mutation — next cycle re-evaluates fresh, same as if this cycle never ran
    }

    const task = await this.#fetchTaskOrHandle(threadId, record.taskGuid);
    if (task === null) return;

    if (task.completedAt && task.completedAt !== "0") {
      // Discovered completed only now (getTask is deferred — module doc fix
      // #2) — stand down and suppress until real new activity.
      const activity = this.#deps.getLastActiveTs(threadId) ?? record.claimedTs;
      await this.#deps.store.update(threadId, (r) =>
        r ? { ...r, stallNudge: undefined, stallSuppressUntilActivityAfter: activity } : r,
      );
      return;
    }

    const { ms: threshold, reason, dueMs } = this.#effectiveThreshold(record, threadId);
    const prospectiveCount = (record.stallNudge?.count ?? 0) + 1; // display only — the persisted count increments on confirm, not here
    this.#deps.enqueueNudgeTurn({
      threadId: record.threadId,
      chatId: record.chatId,
      text: renderStallNudgeText({
        summary: task.summary,
        idleMs: threshold,
        nudgeCount: prospectiveCount,
        reason,
        dueMs,
      }),
    });
    this.#recentNudgeSendTimestamps.push(now); // counts toward the hourly fuse only once a nudge is actually enqueued
    await this.#deps.store.update(threadId, (r) =>
      r
        ? {
            ...r,
            stallNudge: {
              count: r.stallNudge?.count ?? 0,
              lastNudgeSentAt: r.stallNudge?.lastNudgeSentAt ?? now,
              lastNudgeTurnActivityAt: r.stallNudge?.lastNudgeTurnActivityAt,
              escalated: false,
              pendingSince: now,
            },
          }
        : r,
    );

    // v4.1 comment-mode (docs/task-handle.md §15.3/§15.6): NO description
    // writes on the 任务派单 main path, ever — "唤醒留痕在主路径不适用,略过
    // 即可". The wake-up turn itself (above) and the escalation comment are
    // the visible artifacts; skipping the trace here mirrors writeback.ts's
    // comment-mode exemption. Without this gate a typical share-only claim
    // burns a guaranteed-403 PATCH per nudge — and if the bot happens to
    // hold editor rights via a tasklist, it would REALLY write a status
    // block into a comment-mode task's description (拍板 violation).
    if (record.mode === "comment") return;

    // v3.3 item 2 (docs/task-handle.md §14): leave a durable trace in the
    // task's own rolling progress log, same mechanism writeback.ts already
    // uses for per-turn summaries — best-effort, never blocks the wake-up
    // turn itself (which was already enqueued above regardless of this).
    const logSummary = `停滞唤醒 #${prospectiveCount}(${this.#nudgeReasonLabel(reason, dueMs)})`;
    const nextDescription = mergeDescriptionSnapshot(task.description, {
      status: "in_progress",
      summary: logSummary,
      now: new Date(now),
    });
    await this.#deps.client.patchDescription(record.taskGuid, nextDescription).catch((err) => {
      console.warn(
        `[tasklist.stallDetector] nudge-history description patch failed for task ${record.taskGuid} (continuing):`,
        err,
      );
    });
  }

  /** Compact, human-facing reason label for the description log entry — mirrors renderStallNudgeText's per-reason framing but shortened for a one-line log. */
  #nudgeReasonLabel(reason: "due" | "handoff" | "failed" | "normal", dueMs: number | undefined): string {
    switch (reason) {
      case "due":
        return `任务已过期${dueMs !== undefined ? `,截止 ${formatDueDate(dueMs)}` : ""}`;
      case "handoff":
        return "断链:协作 bot 未接手/未收尾";
      case "failed":
        return "上一轮崩溃后超时无新动态";
      case "normal":
        return "超时无新动态";
    }
  }

  /**
   * Only called when `#pollOne` has already decided nudges are exhausted
   * with no progress. Round-2 adversarial review fix: `escalated: true` is
   * now set ONLY after `addComment` genuinely succeeds — a failed post used
   * to still mark the task escalated (permanently silent, per #pollOne's
   * "escalated tasks don't auto-nudge again"), meaning a transient API
   * failure at exactly the escalation moment silently dropped the ONE
   * human-facing signal this feature ever posts. A backoff (mirroring
   * `#fetchTaskOrHandle`'s permission-denied backoff) prevents hammering
   * `addComment` every poll cycle during an outage while still retrying.
   */
  async #escalate(threadId: string): Promise<void> {
    const record = this.#deps.store.get(threadId);
    if (!record) return;

    const backoff = this.#escalationCommentBackoff.get(threadId);
    if (backoff && Date.now() < backoff.nextAttemptAt) return; // still backing off a prior comment failure

    const task = await this.#fetchTaskOrHandle(threadId, record.taskGuid);
    if (task === null) return;

    try {
      await this.#deps.client.addComment(
        record.taskGuid,
        renderStallEscalationComment({ summary: task.summary, escalateAfterNudges: this.#escalateAfterNudges }),
      );
      this.#escalationCommentBackoff.delete(threadId); // a successful post clears any prior backoff
    } catch (err) {
      const nextBackoffMs = Math.min(backoff ? backoff.backoffMs * 2 : this.#intervalMs, PERMISSION_BACKOFF_CEILING_MS);
      this.#escalationCommentBackoff.set(threadId, { nextAttemptAt: Date.now() + nextBackoffMs, backoffMs: nextBackoffMs });
      console.warn(
        `[tasklist.stallDetector] escalation comment failed for task ${record.taskGuid} — NOT marking escalated ` +
          `(the human-facing notice never landed), retrying with backoff:`,
        err,
      );
      return; // do NOT set escalated:true — #pollOne will retry once the backoff/cooldown allows
    }

    await this.#deps.store.update(threadId, (r) =>
      r?.stallNudge ? { ...r, stallNudge: { ...r.stallNudge, escalated: true, pendingSince: undefined } } : r,
    );
  }
}
