import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TaskHandleStore } from "./store.js";
import { TaskListClient, type LarkTaskRequestConfig, type LarkTaskRequester } from "./client.js";
import {
  StallDetector,
  formatDurationLabel,
  formatDueDate,
  renderStallEscalationComment,
  renderStallNudgeText,
} from "./stallDetector.js";

const HOUR = 3_600_000;
const DAY = 24 * HOUR;

// ---------------------------------------------------------------------------
// Pure renderers
// ---------------------------------------------------------------------------

describe("formatDurationLabel", () => {
  it("renders sub-hour durations in minutes, not a misleading rounded-to-hours value", () => {
    expect(formatDurationLabel(15 * 60_000)).toBe("15 分钟");
    expect(formatDurationLabel(30 * 60_000)).toBe("30 分钟");
  });

  it("renders hour-scale durations in hours", () => {
    expect(formatDurationLabel(24 * 60 * 60_000)).toBe("24 小时");
  });
});

describe("renderStallNudgeText", () => {
  it("includes the task title, idle duration, and nudge count", () => {
    const text = renderStallNudgeText({ summary: "帮我修一下登录页", idleMs: 24 * 60 * 60_000, nudgeCount: 1 });
    expect(text).toContain("帮我修一下登录页");
    expect(text).toContain("24 小时");
    expect(text).toContain("第 1 次提醒");
  });

  it("falls back to a placeholder title when summary is missing", () => {
    expect(renderStallNudgeText({ summary: undefined, idleMs: 60_000, nudgeCount: 1 })).toContain("(无标题)");
  });

  it("renders a 15-minute handoff threshold in minutes, not misleadingly rounded to '1 小时'", () => {
    const text = renderStallNudgeText({ summary: "任务A", idleMs: 15 * 60_000, nudgeCount: 1, reason: "handoff" });
    expect(text).toContain("15 分钟");
    expect(text).not.toContain("小时");
  });

  it("mentions the handoff-specific framing (协作对象...接手) only for reason='handoff'", () => {
    const handoff = renderStallNudgeText({ summary: "任务A", idleMs: 15 * 60_000, nudgeCount: 1, reason: "handoff" });
    expect(handoff).toContain("协作");
    expect(handoff).toContain("接手");

    const normal = renderStallNudgeText({ summary: "任务A", idleMs: DAY, nudgeCount: 1, reason: "normal" });
    expect(normal).not.toContain("协作");
  });
});

describe("renderStallEscalationComment", () => {
  it("includes the escalation threshold", () => {
    const text = renderStallEscalationComment({ summary: "帮我修一下登录页", escalateAfterNudges: 2 });
    expect(text).toContain("帮我修一下登录页");
    expect(text).toContain("2 次");
  });
});

// ---------------------------------------------------------------------------
// StallDetector — integration against a fake requester + fake clock
// ---------------------------------------------------------------------------

interface FakeTask {
  guid: string;
  summary?: string;
  description?: string;
  completed_at?: string;
}

function makeFakeRequester(tasksByGuid: Record<string, FakeTask | null>): {
  requester: LarkTaskRequester;
  calls: LarkTaskRequestConfig[];
} {
  const calls: LarkTaskRequestConfig[] = [];
  const request = vi.fn(async (config: LarkTaskRequestConfig) => {
    calls.push(config);
    if (config.method === "GET" && config.url.includes("/tasks/")) {
      const guid = config.url.split("/tasks/")[1]!;
      const task = tasksByGuid[guid];
      if (task === null || task === undefined) {
        const err: { response: { status: number } } = { response: { status: 404 } };
        throw err;
      }
      return { data: { task: { id: task.guid, ...task } } };
    }
    return { data: {} }; // addComment etc.
  });
  return { requester: { request: request as unknown as LarkTaskRequester["request"] }, calls };
}

let dir: string;

beforeEach(async () => {
  dir = await mkdtemp(join(tmpdir(), "larkway-stalldetector-"));
  vi.useFakeTimers();
});

afterEach(async () => {
  vi.useRealTimers();
  await rm(dir, { recursive: true, force: true });
});

describe("StallDetector", () => {
  it("does nothing when idle time is under the threshold (no getTask call at all)", async () => {
    const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
    await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: Date.now() });
    const { requester, calls } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
    const client = new TaskListClient(requester);
    const enqueueNudgeTurn = vi.fn();
    const detector = new StallDetector(
      { store, client, getLastActiveTs: () => Date.now() - HOUR, enqueueNudgeTurn },
      { stallThresholdMs: DAY },
    );

    await detector.pollOnceForTest();

    expect(enqueueNudgeTurn).not.toHaveBeenCalled();
    expect(store.get("t1")?.stallNudge).toBeUndefined();
    expect(calls.length).toBe(0); // adversarial-review fix: local checks run before any network call
  });

  it("enqueues nudge #1 once idle time crosses the normal threshold — count stays 0 until confirmed", async () => {
    const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
    const claimedAt = Date.now();
    await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
    const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
    const client = new TaskListClient(requester);
    const enqueueNudgeTurn = vi.fn();
    const lastActiveTs = claimedAt;
    const detector = new StallDetector(
      { store, client, getLastActiveTs: () => lastActiveTs, enqueueNudgeTurn },
      { stallThresholdMs: DAY },
    );

    vi.setSystemTime(claimedAt + DAY + 1);
    await detector.pollOnceForTest();

    expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);
    expect(enqueueNudgeTurn).toHaveBeenCalledWith(
      expect.objectContaining({ threadId: "t1", chatId: "oc_1", text: expect.stringContaining("任务A") }),
    );
    const record = store.get("t1");
    // Adversarial-review fix (§12 #3): count only increments on CONFIRMED
    // dispatch — right after sending, it's still 0, with pendingSince set.
    expect(record?.stallNudge?.count).toBe(0);
    expect(record?.stallNudge?.pendingSince).toBeDefined();
    expect(record?.stallNudge?.escalated).toBe(false);
  });

  it("uses the shorter fast threshold when the last turn ended in failure", async () => {
    const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
    const claimedAt = Date.now();
    await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt, lastTurnOutcome: "failed" });
    const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
    const client = new TaskListClient(requester);
    const enqueueNudgeTurn = vi.fn();
    const detector = new StallDetector(
      { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn },
      { stallThresholdMs: DAY, stallFastThresholdMs: 30 * 60_000 },
    );

    // Well past the 30min fast threshold but nowhere near the 24h normal one.
    vi.setSystemTime(claimedAt + 31 * 60_000);
    await detector.pollOnceForTest();

    expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);
  });

  it("does not attempt a second nudge before the cooldown elapses (after the first is confirmed), even if still idle", async () => {
    const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
    const claimedAt = Date.now();
    await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
    const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
    const client = new TaskListClient(requester);
    const enqueueNudgeTurn = vi.fn();
    let lastActiveTs = claimedAt;
    const detector = new StallDetector(
      { store, client, getLastActiveTs: () => lastActiveTs, enqueueNudgeTurn },
      { stallThresholdMs: DAY, nudgeCooldownMs: DAY },
    );

    vi.setSystemTime(claimedAt + DAY + 1);
    await detector.pollOnceForTest(); // nudge #1 sent (pending)
    expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);

    lastActiveTs = claimedAt + DAY + HOUR; // nudge #1's own reply
    vi.setSystemTime(claimedAt + DAY + 2 * HOUR);
    await detector.pollOnceForTest(); // confirms nudge #1 — count becomes 1
    expect(store.get("t1")?.stallNudge?.count).toBe(1);

    // Still well within the 24h cooldown since the CONFIRMED send.
    vi.setSystemTime(claimedAt + DAY + 3 * HOUR);
    await detector.pollOnceForTest();
    expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1); // unchanged
  });

  it("full lifecycle: nudge #1 (pending→confirmed) → nudge #2 (pending→confirmed) → escalate → stays silent", async () => {
    const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
    const claimedAt = Date.now();
    await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
    const { requester, calls } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
    const client = new TaskListClient(requester);
    const enqueueNudgeTurn = vi.fn();
    let lastActiveTs = claimedAt;
    const detector = new StallDetector(
      { store, client, getLastActiveTs: () => lastActiveTs, enqueueNudgeTurn },
      { stallThresholdMs: DAY, nudgeCooldownMs: DAY, escalateAfterNudges: 2 },
    );

    // t = 24h: stalled → nudge #1 sent (pending, unconfirmed).
    vi.setSystemTime(claimedAt + DAY);
    await detector.pollOnceForTest();
    expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);
    expect(store.get("t1")?.stallNudge?.count).toBe(0);

    // Nudge #1's own triggered turn replies shortly after — confirms it.
    lastActiveTs = claimedAt + DAY + HOUR;
    vi.setSystemTime(claimedAt + DAY + 2 * HOUR);
    await detector.pollOnceForTest();
    expect(store.get("t1")?.stallNudge?.count).toBe(1);
    expect(store.get("t1")?.stallNudge?.pendingSince).toBeUndefined();
    expect(store.get("t1")?.stallNudge?.lastNudgeTurnActivityAt).toBe(lastActiveTs);

    // Still cooling down since the confirmed send (t = DAY, not t = DAY+2h).
    vi.setSystemTime(claimedAt + DAY + 3 * HOUR);
    await detector.pollOnceForTest();
    expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);

    // t = 48h: cooldown elapsed since nudge #1's CONFIRMED send time, no
    // further activity since confirmation → nudge #2 sent (pending again).
    vi.setSystemTime(claimedAt + 2 * DAY);
    await detector.pollOnceForTest();
    expect(enqueueNudgeTurn).toHaveBeenCalledTimes(2);
    expect(store.get("t1")?.stallNudge?.count).toBe(1); // not yet confirmed

    // Nudge #2's own reply confirms it.
    lastActiveTs = claimedAt + 2 * DAY + HOUR;
    vi.setSystemTime(claimedAt + 2 * DAY + 2 * HOUR);
    await detector.pollOnceForTest();
    expect(store.get("t1")?.stallNudge?.count).toBe(2);

    // t = 72h: cooldown elapsed since nudge #2's confirmed send, no further
    // activity, count(2) >= escalateAfterNudges(2) → escalate.
    vi.setSystemTime(claimedAt + 3 * DAY);
    await detector.pollOnceForTest();
    expect(enqueueNudgeTurn).toHaveBeenCalledTimes(2); // escalation is NOT a synthetic turn
    expect(store.get("t1")?.stallNudge?.escalated).toBe(true);
    const commentCall = calls.find((c) => c.url.includes("/comments"));
    expect(commentCall).toBeDefined();
    expect((commentCall!.data as { content: string }).content).toContain("任务A");

    // Further idle time — stays silent (no more nudges, no repeat escalation comments).
    vi.setSystemTime(claimedAt + 10 * DAY);
    await detector.pollOnceForTest();
    expect(enqueueNudgeTurn).toHaveBeenCalledTimes(2);
    const commentCallsAfter = calls.filter((c) => c.url.includes("/comments"));
    expect(commentCallsAfter.length).toBe(1); // no repeat
  });

  // Adversarial-review regression (§12 #1): progress detection must NOT be
  // gated by the cooldown — real activity has to be recognized as soon as
  // it's observed, not swallowed until the cooldown happens to elapse.
  it("real progress (activity beyond the confirmed nudge's baseline) resets nudge state EVEN INSIDE the cooldown window", async () => {
    const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
    const claimedAt = Date.now();
    await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
    const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
    const client = new TaskListClient(requester);
    const enqueueNudgeTurn = vi.fn();
    let lastActiveTs = claimedAt;
    const detector = new StallDetector(
      { store, client, getLastActiveTs: () => lastActiveTs, enqueueNudgeTurn },
      { stallThresholdMs: DAY, nudgeCooldownMs: DAY, escalateAfterNudges: 2 },
    );

    vi.setSystemTime(claimedAt + DAY);
    await detector.pollOnceForTest(); // nudge #1 sent (pending)

    lastActiveTs = claimedAt + DAY + HOUR;
    vi.setSystemTime(claimedAt + DAY + 2 * HOUR);
    await detector.pollOnceForTest(); // confirms nudge #1 — count becomes 1
    expect(store.get("t1")?.stallNudge?.count).toBe(1);

    // Genuine further activity happens WELL INSIDE the 24h cooldown window
    // (only 1 more hour has passed since confirmation, nowhere near 24h).
    lastActiveTs = claimedAt + DAY + 3 * HOUR;
    await detector.pollOnceForTest();

    expect(store.get("t1")?.stallNudge).toBeUndefined(); // fully reset, immediately — not swallowed by the cooldown
    expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1); // no second nudge fired
  });

  // Adversarial-review regression (§12 #3): a nudge that never gets a
  // confirming activity bump within the pending window is presumed lost
  // (e.g. a bridge restart between enqueue and actual dispatch) and must NOT
  // count toward escalation.
  it("a pending nudge that times out without confirmation does not count toward escalation, and may retry", async () => {
    const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
    const claimedAt = Date.now();
    await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
    const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
    const client = new TaskListClient(requester);
    const enqueueNudgeTurn = vi.fn();
    let lastActiveTs = claimedAt;
    const detector = new StallDetector(
      { store, client, getLastActiveTs: () => lastActiveTs, enqueueNudgeTurn },
      { stallThresholdMs: DAY, pendingConfirmTimeoutMs: 30 * 60_000 },
    );

    vi.setSystemTime(claimedAt + DAY + 1);
    await detector.pollOnceForTest(); // nudge attempt #1 — enqueued but never confirmed (simulates a lost/never-dispatched turn)
    expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);
    expect(store.get("t1")?.stallNudge?.count).toBe(0);

    // Past the pending-confirmation timeout, still no activity at all.
    vi.setSystemTime(claimedAt + DAY + 31 * 60_000);
    await detector.pollOnceForTest();

    // The lost attempt is retried (still genuinely stalled) — but it was
    // never counted, so this is attempt #2, still unconfirmed, not "2 confirmed nudges".
    expect(enqueueNudgeTurn).toHaveBeenCalledTimes(2);
    expect(store.get("t1")?.stallNudge?.count).toBe(0);

    // NOW the retry actually gets a reply — confirms as the FIRST real nudge.
    lastActiveTs = claimedAt + DAY + 32 * 60_000;
    vi.setSystemTime(claimedAt + DAY + 33 * 60_000);
    await detector.pollOnceForTest();
    expect(store.get("t1")?.stallNudge?.count).toBe(1); // exactly one confirmed nudge, not two
  });

  describe("round 2 adversarial review: pending-confirm timeout checks queue evidence before declaring lost", () => {
    it("does NOT declare a pending nudge lost (and does NOT resend) when this bot's own thread genuinely received the synthetic event after pendingSince — it's just still queued behind the semaphore", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
      const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
      const client = new TaskListClient(requester);
      const enqueueNudgeTurn = vi.fn();
      const detector = new StallDetector(
        {
          store,
          client,
          getLastActiveTs: () => claimedAt, // never advances — the queued turn hasn't completed
          enqueueNudgeTurn,
          // The synthetic nudge event WAS received by this bot's own bridge
          // shortly after being enqueued — it's just stuck behind
          // handler.ts's MAX_CONCURRENT=5 semaphore, not lost.
          getOwnThreadReceivedAt: () => claimedAt + DAY + 5_000,
        },
        { stallThresholdMs: DAY, pendingConfirmTimeoutMs: 30 * 60_000 },
      );

      vi.setSystemTime(claimedAt + DAY + 1);
      await detector.pollOnceForTest(); // nudge #1 enqueued, pendingSince = claimedAt+DAY+1
      expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);

      // Past the 30min pending-confirm timeout, with queue evidence present.
      vi.setSystemTime(claimedAt + DAY + 31 * 60_000);
      await detector.pollOnceForTest();

      expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1); // NOT resent — the receipt evidence extended the wait
      expect(store.get("t1")?.stallNudge?.pendingSince).toBeDefined(); // still pending, not cleared
    });

    it("still declares a pending nudge lost and resends when there is NO receipt evidence at all (genuinely lost, e.g. a restart before dispatch)", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
      const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
      const client = new TaskListClient(requester);
      const enqueueNudgeTurn = vi.fn();
      const detector = new StallDetector(
        {
          store,
          client,
          getLastActiveTs: () => claimedAt,
          enqueueNudgeTurn,
          getOwnThreadReceivedAt: () => undefined, // never received at all — this process has no record of it
        },
        { stallThresholdMs: DAY, pendingConfirmTimeoutMs: 30 * 60_000 },
      );

      vi.setSystemTime(claimedAt + DAY + 1);
      await detector.pollOnceForTest();
      expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);

      vi.setSystemTime(claimedAt + DAY + 31 * 60_000);
      await detector.pollOnceForTest();

      expect(enqueueNudgeTurn).toHaveBeenCalledTimes(2); // genuinely lost — resent, same as pre-fix behavior
    });

    it("omitting getOwnThreadReceivedAt keeps the pre-fix behavior (declare lost purely on elapsed time, backward compatible)", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
      const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
      const client = new TaskListClient(requester);
      const enqueueNudgeTurn = vi.fn();
      const detector = new StallDetector(
        { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn }, // no getOwnThreadReceivedAt at all
        { stallThresholdMs: DAY, pendingConfirmTimeoutMs: 30 * 60_000 },
      );

      vi.setSystemTime(claimedAt + DAY + 1);
      await detector.pollOnceForTest();
      vi.setSystemTime(claimedAt + DAY + 31 * 60_000);
      await detector.pollOnceForTest();

      expect(enqueueNudgeTurn).toHaveBeenCalledTimes(2); // unchanged from before this fix
    });
  });

  describe("round 2 adversarial review: escalate() only marks escalated on a genuinely successful comment post", () => {
    it("does NOT mark escalated (and retries later) when the escalation comment post fails", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      await store.put({
        threadId: "t1",
        taskGuid: "g1",
        chatId: "oc_1",
        claimedTs: claimedAt,
        stallNudge: { count: 2, lastNudgeSentAt: claimedAt, lastNudgeTurnActivityAt: claimedAt, escalated: false },
      });
      const client = new TaskListClient({
        request: vi.fn(async (config: LarkTaskRequestConfig) => {
          if (config.method === "GET" && config.url.includes("/tasks/")) {
            return { data: { task: { guid: "g1", summary: "任务A" } } };
          }
          if (config.method === "POST" && config.url.includes("/comments")) {
            throw new Error("network blip");
          }
          return { data: {} };
        }) as unknown as LarkTaskRequester["request"],
      });
      const enqueueNudgeTurn = vi.fn();
      const detector = new StallDetector(
        { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn },
        { stallThresholdMs: DAY, escalateAfterNudges: 2, nudgeCooldownMs: 0 },
      );

      vi.setSystemTime(claimedAt + 60_000);
      await detector.pollOnceForTest();

      // The comment failed to post, so escalated must stay false — a
      // permanently-silent task with NO human ever notified would be exactly
      // the 静默漏管 this feature exists to prevent.
      expect(store.get("t1")?.stallNudge?.escalated).toBe(false);
    });

    it("marks escalated only once the comment genuinely succeeds (on a later retry)", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      await store.put({
        threadId: "t1",
        taskGuid: "g1",
        chatId: "oc_1",
        claimedTs: claimedAt,
        stallNudge: { count: 2, lastNudgeSentAt: claimedAt, lastNudgeTurnActivityAt: claimedAt, escalated: false },
      });
      let shouldFail = true;
      const client = new TaskListClient({
        request: vi.fn(async (config: LarkTaskRequestConfig) => {
          if (config.method === "GET" && config.url.includes("/tasks/")) {
            return { data: { task: { guid: "g1", summary: "任务A" } } };
          }
          if (config.method === "POST" && config.url.includes("/comments")) {
            if (shouldFail) throw new Error("network blip");
            return { data: {} };
          }
          return { data: {} };
        }) as unknown as LarkTaskRequester["request"],
      });
      const enqueueNudgeTurn = vi.fn();
      const detector = new StallDetector(
        { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn },
        { stallThresholdMs: DAY, escalateAfterNudges: 2, nudgeCooldownMs: 0 },
      );

      vi.setSystemTime(claimedAt + 60_000);
      await detector.pollOnceForTest(); // fails, not escalated
      expect(store.get("t1")?.stallNudge?.escalated).toBe(false);

      shouldFail = false;
      vi.setSystemTime(claimedAt + 5 * 60_000); // past the intervalMs-based backoff (first failure backs off ~intervalMs=60s)
      await detector.pollOnceForTest();

      expect(store.get("t1")?.stallNudge?.escalated).toBe(true);
    });
  });

  it("discovering a task is independently completed (while about to nudge) clears tracking and suppresses future polling", async () => {
    const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
    const claimedAt = Date.now();
    await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
    const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A", completed_at: "12345" } });
    const client = new TaskListClient(requester);
    const enqueueNudgeTurn = vi.fn();
    const detector = new StallDetector(
      { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn },
      { stallThresholdMs: DAY },
    );

    vi.setSystemTime(claimedAt + DAY + 1); // stalled enough to trigger a nudge attempt
    await detector.pollOnceForTest();

    expect(enqueueNudgeTurn).not.toHaveBeenCalled(); // discovered completed before actually nudging
    expect(store.get("t1")?.stallNudge).toBeUndefined();
    expect(store.get("t1")?.stallSuppressUntilActivityAfter).toBe(claimedAt);
  });

  // Adversarial-review regression (§12 #2): a known-completed task must not
  // cost a getTask call every cycle forever — and must resume normal
  // checking once real new activity (e.g. a reopen) is observed.
  it("a suppressed (known-completed) task is never polled again while idle, and resumes once new activity is observed", async () => {
    const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
    const claimedAt = Date.now();
    let lastActiveTs = claimedAt;
    await store.put({
      threadId: "t1",
      taskGuid: "g1",
      chatId: "oc_1",
      claimedTs: claimedAt,
      stallSuppressUntilActivityAfter: claimedAt,
    });
    // Task snapshot says NOT completed this time — simulates a reopen the
    // suppressed detector never even asks about until activity resumes.
    const { requester, calls } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
    const client = new TaskListClient(requester);
    const enqueueNudgeTurn = vi.fn();
    const detector = new StallDetector(
      { store, client, getLastActiveTs: () => lastActiveTs, enqueueNudgeTurn },
      { stallThresholdMs: DAY },
    );

    vi.setSystemTime(claimedAt + 10 * DAY);
    await detector.pollOnceForTest();
    expect(calls.length).toBe(0); // suppressed — no getTask call at all
    expect(enqueueNudgeTurn).not.toHaveBeenCalled();

    // New activity (e.g. the task got reopened, a new turn ran) — suppression lifts.
    lastActiveTs = claimedAt + 11 * DAY;
    vi.setSystemTime(claimedAt + 11 * DAY + DAY + 1); // stalled again, relative to the NEW activity
    await detector.pollOnceForTest();
    expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1); // resumed normal checking
  });

  it("drops the mapping when the task is gone (404) — no auto-recreate", async () => {
    const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
    const claimedAt = Date.now();
    await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
    const { requester } = makeFakeRequester({ g1: null });
    const client = new TaskListClient(requester);
    const detector = new StallDetector(
      { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn: vi.fn() },
      { stallThresholdMs: DAY },
    );

    vi.setSystemTime(claimedAt + DAY + 1); // must be stalled to trigger the getTask call at all
    await detector.pollOnceForTest();

    expect(store.get("t1")).toBeUndefined();
  });

  it("permission-denied keeps the mapping and does not crash (backs off, per §D pattern)", async () => {
    const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
    const claimedAt = Date.now();
    await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
    const requester: LarkTaskRequester = {
      request: vi.fn(async () => {
        const err: { response: { status: number } } = { response: { status: 403 } };
        throw err;
      }),
    };
    const client = new TaskListClient(requester);
    const detector = new StallDetector(
      { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn: vi.fn() },
      { stallThresholdMs: DAY },
    );

    vi.setSystemTime(claimedAt + DAY + 1);
    await expect(detector.pollOnceForTest()).resolves.toBeUndefined();
    expect(store.get("t1")).toBeDefined();
  });

  it("falls back to claimedTs as the activity baseline when no session record is found", async () => {
    const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
    const claimedAt = Date.now();
    await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
    const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
    const client = new TaskListClient(requester);
    const enqueueNudgeTurn = vi.fn();
    const detector = new StallDetector(
      { store, client, getLastActiveTs: () => undefined, enqueueNudgeTurn }, // no session record
      { stallThresholdMs: DAY },
    );

    vi.setSystemTime(claimedAt + DAY + 1);
    await detector.pollOnceForTest();

    expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1); // still works, using claimedTs
  });

  it("pending-nudge state persists across a fresh StallDetector instance sharing the same store (restart-safe — doesn't immediately re-send or inflate count)", async () => {
    const storePath = join(dir, "task-handles.json");
    const store1 = await TaskHandleStore.load(storePath);
    const claimedAt = Date.now();
    await store1.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
    const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
    const client1 = new TaskListClient(requester);
    const enqueueNudgeTurn1 = vi.fn();
    const detector1 = new StallDetector(
      { store: store1, client: client1, getLastActiveTs: () => claimedAt, enqueueNudgeTurn: enqueueNudgeTurn1 },
      { stallThresholdMs: DAY },
    );

    vi.setSystemTime(claimedAt + DAY + 1);
    await detector1.pollOnceForTest(); // nudge #1 sent (pending, unconfirmed)
    expect(enqueueNudgeTurn1).toHaveBeenCalledTimes(1);
    expect(store1.get("t1")?.stallNudge?.pendingSince).toBeDefined();

    // Simulate a bridge restart: fresh store load from disk, fresh detector instance.
    const store2 = await TaskHandleStore.load(storePath);
    const client2 = new TaskListClient(requester);
    const enqueueNudgeTurn2 = vi.fn();
    const detector2 = new StallDetector(
      { store: store2, client: client2, getLastActiveTs: () => claimedAt, enqueueNudgeTurn: enqueueNudgeTurn2 },
      { stallThresholdMs: DAY },
    );

    // Still within the pending-confirmation window — must NOT re-send just because the process restarted.
    vi.setSystemTime(claimedAt + DAY + 60_000);
    await detector2.pollOnceForTest();
    expect(enqueueNudgeTurn2).not.toHaveBeenCalled();
    expect(store2.get("t1")?.stallNudge?.count).toBe(0); // still unconfirmed, not incremented by the restart
  });

  // v3.2 交接断链检测 (docs/task-handle.md §13)
  describe("handoff-break detection", () => {
    const HANDOFF = 15 * 60_000;

    it("uses the much shorter handoff threshold when the mentioned peer's bridge NEVER received an event in this thread (never entered its queue at all)", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      await store.put({
        threadId: "t1",
        taskGuid: "g1",
        chatId: "oc_1",
        claimedTs: claimedAt,
        lastTurnMentions: ["peer-bot"],
        lastTurnMentionsAt: claimedAt,
      });
      const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
      const client = new TaskListClient(requester);
      const enqueueNudgeTurn = vi.fn();
      const detector = new StallDetector(
        {
          store,
          client,
          getLastActiveTs: () => claimedAt,
          enqueueNudgeTurn,
          getPeerReceivedAt: () => undefined, // this is the true "broken @" signature: never enqueued at all
        },
        { stallThresholdMs: DAY, stallHandoffThresholdMs: HANDOFF },
      );

      // Only 16 minutes idle — nowhere near the 24h normal threshold, but past the 15min handoff one.
      vi.setSystemTime(claimedAt + HANDOFF + 60_000);
      await detector.pollOnceForTest();

      expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);
      const text = (enqueueNudgeTurn.mock.calls[0]![0] as { text: string }).text;
      expect(text).toContain("分钟");
      expect(text).toContain("协作");
    });

    it("does NOT use the handoff threshold once the mentioned peer's bridge has RECEIVED an event since the mention", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      await store.put({
        threadId: "t1",
        taskGuid: "g1",
        chatId: "oc_1",
        claimedTs: claimedAt,
        lastTurnMentions: ["peer-bot"],
        lastTurnMentionsAt: claimedAt,
      });
      const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
      const client = new TaskListClient(requester);
      const enqueueNudgeTurn = vi.fn();
      const detector = new StallDetector(
        {
          store,
          client,
          getLastActiveTs: () => claimedAt,
          enqueueNudgeTurn,
          getPeerReceivedAt: () => claimedAt + 60_000, // peer's bridge received an event shortly after the mention
        },
        { stallThresholdMs: DAY, stallHandoffThresholdMs: HANDOFF },
      );

      // Well past the 15min handoff window, but nowhere near the 24h normal one.
      vi.setSystemTime(claimedAt + HANDOFF + 60_000);
      await detector.pollOnceForTest();

      expect(enqueueNudgeTurn).not.toHaveBeenCalled();
    });

    it("does NOT trigger a handoff nudge when the peer received the event but is still queued, within the receipt-grace window (no dispatch/completion signal at all) — revision 2+3's 'received, provisionally enough' contract", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      await store.put({
        threadId: "t1",
        taskGuid: "g1",
        chatId: "oc_1",
        claimedTs: claimedAt,
        lastTurnMentions: ["peer-bot"],
        lastTurnMentionsAt: claimedAt,
      });
      const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
      const client = new TaskListClient(requester);
      const enqueueNudgeTurn = vi.fn();
      // Peer's bridge enqueued the event just after the mention (getThreadReceivedAt
      // would report this instant), but its turn never dispatches — imagine it's
      // stuck behind handler.ts's MAX_CONCURRENT=5 semaphore, or is itself a 5-15min
      // turn still running. No SessionStore.lastActiveTs (getPeerLastActiveTs) bump
      // ever happens for it. If the signal were "turn started/finished" instead of
      // "received", this would misjudge the (perfectly healthy, still-queued) handoff
      // as broken.
      const receivedAt = claimedAt + 5_000;
      const detector = new StallDetector(
        {
          store,
          client,
          getLastActiveTs: () => claimedAt,
          enqueueNudgeTurn,
          getPeerReceivedAt: () => receivedAt,
          getPeerLastActiveTs: () => undefined, // never completed a turn — but still within grace below
        },
        { stallThresholdMs: DAY, stallHandoffThresholdMs: HANDOFF, handoffReceiptGraceMs: 30 * 60_000 },
      );

      // Well past the 15min handoff threshold, but only 10min after receivedAt —
      // still inside the 30min receipt-grace window, so must NOT fire yet.
      vi.setSystemTime(claimedAt + 10 * 60_000);
      await detector.pollOnceForTest();

      expect(enqueueNudgeTurn).not.toHaveBeenCalled();
    });

    it("RE-ARMS and fires a handoff nudge once the receipt-grace window elapses with no genuinely completed turn since — revision 3's fix for the 'receipt = permanent immunity' loophole", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      await store.put({
        threadId: "t1",
        taskGuid: "g1",
        chatId: "oc_1",
        claimedTs: claimedAt,
        lastTurnMentions: ["peer-bot"],
        lastTurnMentionsAt: claimedAt,
      });
      const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
      const client = new TaskListClient(requester);
      const enqueueNudgeTurn = vi.fn();
      // Peer's bridge received the event, but its turn genuinely never wrapped up
      // (crashed, hung, stuck) — no getPeerLastActiveTs bump ever happens after
      // receivedAt. Without this re-arm, a single receipt would permanently clear
      // the handoff condition even though the task is now genuinely stuck.
      const receivedAt = claimedAt + 5_000;
      const detector = new StallDetector(
        {
          store,
          client,
          getLastActiveTs: () => claimedAt,
          enqueueNudgeTurn,
          getPeerReceivedAt: () => receivedAt,
          getPeerLastActiveTs: () => undefined,
        },
        { stallThresholdMs: DAY, stallHandoffThresholdMs: HANDOFF, handoffReceiptGraceMs: 30 * 60_000 },
      );

      // 35 minutes since receivedAt — past the 30min receipt-grace window, and
      // still no confirming completed-turn signal — must re-arm and fire.
      vi.setSystemTime(claimedAt + 35 * 60_000);
      await detector.pollOnceForTest();

      expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);
      const text = (enqueueNudgeTurn.mock.calls[0]![0] as { text: string }).text;
      expect(text).toContain("30 分钟"); // rendered using the receipt-grace floor, not the shorter initial handoff threshold
    });

    it("does NOT re-arm when the peer genuinely completed a turn after receiving (real progress, not just another receipt)", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      await store.put({
        threadId: "t1",
        taskGuid: "g1",
        chatId: "oc_1",
        claimedTs: claimedAt,
        lastTurnMentions: ["peer-bot"],
        lastTurnMentionsAt: claimedAt,
      });
      const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
      const client = new TaskListClient(requester);
      const enqueueNudgeTurn = vi.fn();
      const receivedAt = claimedAt + 5_000;
      const detector = new StallDetector(
        {
          store,
          client,
          getLastActiveTs: () => claimedAt,
          enqueueNudgeTurn,
          getPeerReceivedAt: () => receivedAt,
          getPeerLastActiveTs: () => receivedAt + 60_000, // peer's turn genuinely finished shortly after receiving
        },
        { stallThresholdMs: DAY, stallHandoffThresholdMs: HANDOFF, handoffReceiptGraceMs: 30 * 60_000 },
      );

      // Well past the 30min receipt-grace window — would have re-armed without
      // the completed-turn signal above.
      vi.setSystemTime(claimedAt + 35 * 60_000);
      await detector.pollOnceForTest();

      expect(enqueueNudgeTurn).not.toHaveBeenCalled();
    });

    it("falls back to the normal/fast threshold when getPeerReceivedAt is not wired (backward compatible)", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      await store.put({
        threadId: "t1",
        taskGuid: "g1",
        chatId: "oc_1",
        claimedTs: claimedAt,
        lastTurnMentions: ["peer-bot"],
        lastTurnMentionsAt: claimedAt,
      });
      const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
      const client = new TaskListClient(requester);
      const enqueueNudgeTurn = vi.fn();
      const detector = new StallDetector(
        { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn }, // no getPeerReceivedAt at all
        { stallThresholdMs: DAY, stallHandoffThresholdMs: HANDOFF },
      );

      vi.setSystemTime(claimedAt + HANDOFF + 60_000); // past handoff, nowhere near 24h
      await detector.pollOnceForTest();

      expect(enqueueNudgeTurn).not.toHaveBeenCalled(); // handoff rule never considered without the dep
    });

    it("picks whichever applicable threshold is SHORTEST — handoff (15min) beats fast-failure (30min)", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      await store.put({
        threadId: "t1",
        taskGuid: "g1",
        chatId: "oc_1",
        claimedTs: claimedAt,
        lastTurnOutcome: "failed",
        lastTurnMentions: ["peer-bot"],
        lastTurnMentionsAt: claimedAt,
      });
      const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
      const client = new TaskListClient(requester);
      const enqueueNudgeTurn = vi.fn();
      const detector = new StallDetector(
        { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn, getPeerReceivedAt: () => undefined },
        { stallThresholdMs: DAY, stallFastThresholdMs: 30 * 60_000, stallHandoffThresholdMs: HANDOFF },
      );

      // Past the 15min handoff threshold but well under the 30min fast-failure one.
      vi.setSystemTime(claimedAt + HANDOFF + 60_000);
      await detector.pollOnceForTest();

      expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);
      const text = (enqueueNudgeTurn.mock.calls[0]![0] as { text: string }).text;
      expect(text).toContain("分钟"); // rendered using the shorter (handoff) threshold, not the 30min one
    });

    it("a 'failed' turn clears lastTurnMentions — writeback.ts's job, but StallDetector must tolerate its absence gracefully", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      // Simulates the post-writeback state after a failed turn: no lastTurnMentions.
      await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt, lastTurnOutcome: "failed" });
      const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
      const client = new TaskListClient(requester);
      const enqueueNudgeTurn = vi.fn();
      const detector = new StallDetector(
        { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn, getPeerReceivedAt: () => undefined },
        { stallThresholdMs: DAY, stallFastThresholdMs: 30 * 60_000, stallHandoffThresholdMs: HANDOFF },
      );

      // Past the 15min handoff window but under the 30min fast-failure threshold — must NOT fire yet.
      vi.setSystemTime(claimedAt + HANDOFF + 60_000);
      await detector.pollOnceForTest();
      expect(enqueueNudgeTurn).not.toHaveBeenCalled();

      // Past the 30min fast-failure threshold now fires normally.
      vi.setSystemTime(claimedAt + 31 * 60_000);
      await detector.pollOnceForTest();
      expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);
    });

    it("revision 1: defaults to a 5-minute handoff threshold when stallHandoffThresholdMs isn't configured", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      await store.put({
        threadId: "t1",
        taskGuid: "g1",
        chatId: "oc_1",
        claimedTs: claimedAt,
        lastTurnMentions: ["peer-bot"],
        lastTurnMentionsAt: claimedAt,
      });
      const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
      const client = new TaskListClient(requester);
      const enqueueNudgeTurn = vi.fn();
      // No stallHandoffThresholdMs override at all — exercising the real default.
      const detector = new StallDetector(
        { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn, getPeerReceivedAt: () => undefined },
        { stallThresholdMs: DAY },
      );

      // 4 minutes in: under the 5min default, must not fire yet.
      vi.setSystemTime(claimedAt + 4 * 60_000);
      await detector.pollOnceForTest();
      expect(enqueueNudgeTurn).not.toHaveBeenCalled();

      // Past 5 minutes: fires.
      vi.setSystemTime(claimedAt + 6 * 60_000);
      await detector.pollOnceForTest();
      expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);
    });

    describe("revision 2: restart startup quiet period", () => {
      it("disarms the handoff rule (falls back to the normal/fast threshold) for handoffStartupQuietMs after construction, even if the peer never received anything", async () => {
        const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
        const claimedAt = Date.now();
        await store.put({
          threadId: "t1",
          taskGuid: "g1",
          chatId: "oc_1",
          claimedTs: claimedAt,
          lastTurnMentions: ["peer-bot"],
          lastTurnMentionsAt: claimedAt,
        });
        const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
        const client = new TaskListClient(requester);
        const enqueueNudgeTurn = vi.fn();
        // Simulates a just-restarted bridge: getPeerReceivedAt's backing map is
        // empty (undefined) simply because it hasn't had time to repopulate yet,
        // not because the handoff is actually broken.
        const detector = new StallDetector(
          { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn, getPeerReceivedAt: () => undefined },
          { stallThresholdMs: DAY, stallHandoffThresholdMs: HANDOFF, handoffStartupQuietMs: 20 * 60_000 },
        );

        // Well past the 15min handoff threshold, but still inside the 20min quiet
        // period — must NOT fire (falls back to the 24h normal threshold instead).
        vi.setSystemTime(claimedAt + HANDOFF + 60_000);
        await detector.pollOnceForTest();
        expect(enqueueNudgeTurn).not.toHaveBeenCalled();
      });

      it("arms the handoff rule normally once handoffStartupQuietMs has elapsed since construction", async () => {
        const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
        const claimedAt = Date.now();
        await store.put({
          threadId: "t1",
          taskGuid: "g1",
          chatId: "oc_1",
          claimedTs: claimedAt,
          lastTurnMentions: ["peer-bot"],
          lastTurnMentionsAt: claimedAt,
        });
        const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
        const client = new TaskListClient(requester);
        const enqueueNudgeTurn = vi.fn();
        const detector = new StallDetector(
          { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn, getPeerReceivedAt: () => undefined },
          { stallThresholdMs: DAY, stallHandoffThresholdMs: HANDOFF, handoffStartupQuietMs: 6 * 60_000 },
        );

        // Past both the 6min quiet period AND the 15min handoff threshold.
        vi.setSystemTime(claimedAt + 20 * 60_000);
        await detector.pollOnceForTest();
        expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);
      });
    });

    describe("round 2 adversarial review: tier 1 accepts a persisted completion, not just a fresh receipt", () => {
      it("does NOT fire a spurious handoff nudge after a restart when the peer already completed the handoff pre-restart (disk-persisted, receipt map empty)", async () => {
        const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
        const claimedAt = Date.now();
        await store.put({
          threadId: "t1",
          taskGuid: "g1",
          chatId: "oc_1",
          claimedTs: claimedAt,
          lastTurnMentions: ["peer-bot"],
          lastTurnMentionsAt: claimedAt,
        });
        const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
        const client = new TaskListClient(requester);
        const enqueueNudgeTurn = vi.fn();
        // Simulates: A @'d B, B received AND completed the turn — all healthy
        // — but then the bridge restarted. getPeerReceivedAt's in-memory map
        // is empty post-restart (undefined forever, this process never saw a
        // NEW event for B in this thread), but getPeerLastActiveTs (disk-
        // persisted SessionStore) shows the completion that already happened.
        const detector = new StallDetector(
          {
            store,
            client,
            getLastActiveTs: () => claimedAt,
            enqueueNudgeTurn,
            getPeerReceivedAt: () => undefined,
            getPeerLastActiveTs: () => claimedAt + 30_000, // completed shortly after the mention, pre-restart
          },
          { stallThresholdMs: DAY, stallHandoffThresholdMs: HANDOFF, handoffStartupQuietMs: 0 },
        );

        // Well past the handoff threshold — without the fix, tier 1 would
        // declare "never received" purely because the receipt map is empty.
        vi.setSystemTime(claimedAt + HANDOFF + 60_000);
        await detector.pollOnceForTest();

        expect(enqueueNudgeTurn).not.toHaveBeenCalled();
      });

      it("still fires when the peer neither received NOR ever completed anything (genuine break, regression check)", async () => {
        const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
        const claimedAt = Date.now();
        await store.put({
          threadId: "t1",
          taskGuid: "g1",
          chatId: "oc_1",
          claimedTs: claimedAt,
          lastTurnMentions: ["peer-bot"],
          lastTurnMentionsAt: claimedAt,
        });
        const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
        const client = new TaskListClient(requester);
        const enqueueNudgeTurn = vi.fn();
        const detector = new StallDetector(
          {
            store,
            client,
            getLastActiveTs: () => claimedAt,
            enqueueNudgeTurn,
            getPeerReceivedAt: () => undefined,
            getPeerLastActiveTs: () => undefined, // no completion signal either — truly broken
          },
          { stallThresholdMs: DAY, stallHandoffThresholdMs: HANDOFF, handoffStartupQuietMs: 0 },
        );

        vi.setSystemTime(claimedAt + HANDOFF + 60_000);
        await detector.pollOnceForTest();

        expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);
      });

      it("does NOT fire when the peer's completion is stale (BEFORE the mention, not after) — a pre-existing unrelated completion doesn't count", async () => {
        // Wait — this is the inverse: a completion timestamp at/before mentionAt
        // must NOT be treated as resolving the mention (it predates it, so it
        // can't possibly be evidence of picking up THIS handoff).
        const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
        const claimedAt = Date.now();
        await store.put({
          threadId: "t1",
          taskGuid: "g1",
          chatId: "oc_1",
          claimedTs: claimedAt,
          lastTurnMentions: ["peer-bot"],
          lastTurnMentionsAt: claimedAt,
        });
        const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
        const client = new TaskListClient(requester);
        const enqueueNudgeTurn = vi.fn();
        const detector = new StallDetector(
          {
            store,
            client,
            getLastActiveTs: () => claimedAt,
            enqueueNudgeTurn,
            getPeerReceivedAt: () => undefined,
            getPeerLastActiveTs: () => claimedAt - 60_000, // completion BEFORE the mention — stale, doesn't resolve it
          },
          { stallThresholdMs: DAY, stallHandoffThresholdMs: HANDOFF, handoffStartupQuietMs: 0 },
        );

        vi.setSystemTime(claimedAt + HANDOFF + 60_000);
        await detector.pollOnceForTest();

        expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1); // still fires — the stale completion doesn't count
      });
    });
  });

  describe("v3.3 due-date stall detection (docs/task-handle.md §14)", () => {
    it("fires IMMEDIATELY once due has passed, even with very recent activity (does not wait for the idle threshold)", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      const dueMs = claimedAt + 60_000; // due 1 minute after claim
      await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
      const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
      const client = new TaskListClient(requester);
      const enqueueNudgeTurn = vi.fn();
      const detector = new StallDetector(
        {
          store,
          client,
          getLastActiveTs: () => Date.now(), // active RIGHT NOW — the general/fast thresholds would never fire
          enqueueNudgeTurn,
          getTaskDueMs: () => dueMs,
        },
        { stallThresholdMs: DAY, stallFastThresholdMs: HOUR / 2 },
      );

      vi.setSystemTime(dueMs + 1_000); // just past due
      await detector.pollOnceForTest();

      expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);
      const text = (enqueueNudgeTurn.mock.calls[0]![0] as { text: string }).text;
      expect(text).toContain("已过期");
      expect(text).toContain(formatDueDate(dueMs));
    });

    it("does NOT fire when the due date is still in the future", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      const dueMs = claimedAt + DAY; // due tomorrow
      await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
      const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
      const client = new TaskListClient(requester);
      const enqueueNudgeTurn = vi.fn();
      const detector = new StallDetector(
        { store, client, getLastActiveTs: () => Date.now(), enqueueNudgeTurn, getTaskDueMs: () => dueMs },
        { stallThresholdMs: DAY },
      );

      vi.setSystemTime(claimedAt + 60_000);
      await detector.pollOnceForTest();

      expect(enqueueNudgeTurn).not.toHaveBeenCalled();
    });

    it("omitting getTaskDueMs disables due-based stall entirely (backward compatible)", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
      const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
      const client = new TaskListClient(requester);
      const enqueueNudgeTurn = vi.fn();
      const detector = new StallDetector(
        { store, client, getLastActiveTs: () => Date.now(), enqueueNudgeTurn }, // no getTaskDueMs at all
        { stallThresholdMs: DAY },
      );

      vi.setSystemTime(claimedAt + 60_000);
      await detector.pollOnceForTest();

      expect(enqueueNudgeTurn).not.toHaveBeenCalled();
    });

    it("respects the existing cooldown for repeat due-based nudges (no new counter, same guardrails)", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      const dueMs = claimedAt + 60_000;
      await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
      const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
      const client = new TaskListClient(requester);
      const enqueueNudgeTurn = vi.fn();
      const detector = new StallDetector(
        { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn, getTaskDueMs: () => dueMs },
        { stallThresholdMs: DAY, nudgeCooldownMs: DAY },
      );

      vi.setSystemTime(dueMs + 1_000);
      await detector.pollOnceForTest();
      // Confirm the nudge (activity bump after pendingSince).
      vi.setSystemTime(dueMs + 2_000);
      await detector.pollOnceForTest(); // still pending-confirm window, no new send

      vi.setSystemTime(dueMs + 60_000); // well past due, but within the 24h cooldown
      await detector.pollOnceForTest();

      expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1); // cooldown correctly gates the repeat
    });

    it("P0 invariant (adversarial review round 2): repeated REAL progress on an overdue task still only nudges once per cooldown — progress-reset must not defeat the due tier's cooldown anchor", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      const dueMs = claimedAt + 60_000;
      await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
      const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
      const client = new TaskListClient(requester);
      const enqueueNudgeTurn = vi.fn();
      let lastActiveTs = claimedAt;
      const detector = new StallDetector(
        { store, client, getLastActiveTs: () => lastActiveTs, enqueueNudgeTurn, getTaskDueMs: () => dueMs },
        { stallThresholdMs: DAY }, // default 24h nudgeCooldownMs — the invariant under test
      );

      // First due-based nudge fires.
      vi.setSystemTime(dueMs + 1_000);
      await detector.pollOnceForTest();
      expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);

      // Confirm it (activity bump after pendingSince).
      lastActiveTs = dueMs + 2_000;
      vi.setSystemTime(dueMs + 3_000);
      await detector.pollOnceForTest();
      expect(store.get("t1")?.stallNudge?.count).toBe(1);

      // Three rounds of REAL further progress (human @, agent turn completing,
      // etc.) — WITHOUT the P0 fix, each of these would wipe stallNudge
      // entirely, and the very next 60s cycle would immediately re-nudge
      // (threshold=0 for an overdue task, no cooldown consulted). The task is
      // STILL overdue throughout — only lastActiveTs is advancing.
      for (let round = 0; round < 3; round++) {
        lastActiveTs = dueMs + 10_000 + round * 10_000;
        vi.setSystemTime(lastActiveTs + 1_000);
        await detector.pollOnceForTest(); // progress-reset cycle: count -> 0, cooldown anchor preserved
        vi.setSystemTime(lastActiveTs + 2_000);
        await detector.pollOnceForTest(); // immediate re-evaluation: must NOT re-nudge
      }

      expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1); // still just the one nudge, despite 3 rounds of real progress
      expect(store.get("t1")?.stallNudge?.count).toBe(0); // count did reset (escalation restarts), unlike the cooldown anchor
    });
  });

  describe("v3.3 nudge history in task description (docs/task-handle.md §14)", () => {
    it("appends a rolling-log entry to the task description after a successful nudge send", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      await store.put({
        threadId: "t1",
        taskGuid: "g1",
        chatId: "oc_1",
        claimedTs: claimedAt,
        lastTurnMentions: ["peer-bot"],
        lastTurnMentionsAt: claimedAt,
      });
      const { requester, calls } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A", description: "原始描述" } });
      const client = new TaskListClient(requester);
      const enqueueNudgeTurn = vi.fn();
      const detector = new StallDetector(
        { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn, getPeerReceivedAt: () => undefined },
        { stallThresholdMs: DAY, stallHandoffThresholdMs: 5 * 60_000 },
      );

      vi.setSystemTime(claimedAt + 6 * 60_000);
      await detector.pollOnceForTest();

      const patchCall = calls.find((c) => c.method === "PATCH" && c.url === "/open-apis/task/v2/tasks/g1");
      expect(patchCall).toBeDefined();
      const patchedDescription = (patchCall!.data as { task: { description: string } }).task.description;
      expect(patchedDescription).toContain("原始描述");
      expect(patchedDescription).toContain("停滞唤醒 #1");
      expect(patchedDescription).toContain("断链");
    });

    it("swallows a description-patch failure without blocking the wake-up turn itself", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
      const client = new TaskListClient({
        request: vi.fn(async (config: LarkTaskRequestConfig) => {
          if (config.method === "GET" && config.url.includes("/tasks/")) {
            return { data: { task: { guid: "g1", summary: "任务A" } } };
          }
          if (config.method === "PATCH") throw new Error("network blip");
          return { data: {} };
        }) as unknown as LarkTaskRequester["request"],
      });
      const enqueueNudgeTurn = vi.fn();
      const detector = new StallDetector(
        { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn },
        { stallThresholdMs: 60_000 },
      );

      vi.setSystemTime(claimedAt + 2 * 60_000);
      await expect(detector.pollOnceForTest()).resolves.toBeUndefined(); // does not throw
      expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1); // the wake-up turn still went out
    });
  });

  describe("v3.3 hourly nudge fuse (docs/task-handle.md §14)", () => {
    it("suppresses a nudge once the trailing-hour cap is reached, without mutating any per-task state", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      // Two independent claims, both already past their (short) stall threshold this same cycle.
      await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
      await store.put({ threadId: "t2", taskGuid: "g2", chatId: "oc_1", claimedTs: claimedAt });
      const { requester } = makeFakeRequester({
        g1: { guid: "g1", summary: "任务A" },
        g2: { guid: "g2", summary: "任务B" },
      });
      const client = new TaskListClient(requester);
      const enqueueNudgeTurn = vi.fn();
      const detector = new StallDetector(
        { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn },
        { stallThresholdMs: 60_000, stallNudgeHourlyCap: 1 }, // cap=1 — the second claim must be suppressed
      );

      vi.setSystemTime(claimedAt + 2 * 60_000);
      await detector.pollOnceForTest(); // both claims are due for a nudge this cycle

      expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1); // fuse tripped for the second one

      // t1 is inserted first, so store.list() (Map insertion order) processes
      // it first — t1 consumes the cap=1 slot, t2 is the one the fuse
      // suppresses. Its state must be untouched — no pendingSince recorded —
      // so the very next cycle (fuse window unchanged) still tries it again
      // rather than treating it as already-sent-and-lost.
      expect(store.get("t2")?.stallNudge).toBeUndefined();
    });

    it("allows nudging again once the trailing-hour window slides past the earlier sends", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
      const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
      const client = new TaskListClient(requester);
      const enqueueNudgeTurn = vi.fn();
      const detector = new StallDetector(
        { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn },
        { stallThresholdMs: 60_000, stallNudgeHourlyCap: 1, nudgeCooldownMs: 0, pendingConfirmTimeoutMs: 1_000 },
      );

      vi.setSystemTime(claimedAt + 2 * 60_000);
      await detector.pollOnceForTest();
      expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);

      // Let the pending nudge time out (treated as lost, doesn't count toward
      // escalation) so the SAME claim is eligible to re-send, then jump past
      // the fuse's 1h trailing window from the first send.
      vi.setSystemTime(claimedAt + 65 * 60_000);
      await detector.pollOnceForTest();

      expect(enqueueNudgeTurn).toHaveBeenCalledTimes(2); // fuse window slid — no longer capped
    });

    it("defaults to a cap of 6 per hour when stallNudgeHourlyCap isn't configured", async () => {
      const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
      const claimedAt = Date.now();
      for (let i = 0; i < 7; i++) {
        await store.put({ threadId: `t${i}`, taskGuid: `g${i}`, chatId: "oc_1", claimedTs: claimedAt });
      }
      const tasksByGuid = Object.fromEntries(
        Array.from({ length: 7 }, (_, i) => [`g${i}`, { guid: `g${i}`, summary: `任务${i}` }]),
      );
      const { requester } = makeFakeRequester(tasksByGuid);
      const client = new TaskListClient(requester);
      const enqueueNudgeTurn = vi.fn();
      const detector = new StallDetector(
        { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn },
        { stallThresholdMs: 60_000 }, // no stallNudgeHourlyCap override — exercising the real default (6)
      );

      vi.setSystemTime(claimedAt + 2 * 60_000);
      await detector.pollOnceForTest();

      expect(enqueueNudgeTurn).toHaveBeenCalledTimes(6); // the 7th claim is suppressed by the default cap
    });
  });
});

// ---------------------------------------------------------------------------
// v4.1 comment-mode delivery (docs/task-handle.md §15.3): doneDeclared stops
// patrol until re-engagement clears it.
// ---------------------------------------------------------------------------

describe("StallDetector — doneDeclared (v4.1 comment-mode delivery)", () => {
  it("skips a doneDeclared claim entirely — no nudge, no network — even when long past the threshold", async () => {
    const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
    const claimedAt = Date.now();
    await store.put({
      threadId: "t1",
      taskGuid: "g1",
      chatId: "oc_1",
      claimedTs: claimedAt,
      mode: "comment",
      doneDeclared: true,
    });
    const { requester, calls } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
    const client = new TaskListClient(requester);
    const enqueueNudgeTurn = vi.fn();
    const detector = new StallDetector(
      { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn },
      { stallThresholdMs: DAY },
    );

    vi.setSystemTime(claimedAt + 3 * DAY);
    await detector.pollOnceForTest();

    expect(enqueueNudgeTurn).not.toHaveBeenCalled();
    expect(calls.length).toBe(0);
  });

  it("resumes patrol once doneDeclared is cleared (writeback's received branch)", async () => {
    const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
    const claimedAt = Date.now();
    await store.put({
      threadId: "t1",
      taskGuid: "g1",
      chatId: "oc_1",
      claimedTs: claimedAt,
      mode: "comment",
      doneDeclared: true,
    });
    const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
    const client = new TaskListClient(requester);
    const enqueueNudgeTurn = vi.fn();
    const detector = new StallDetector(
      { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn },
      { stallThresholdMs: DAY },
    );

    vi.setSystemTime(claimedAt + 3 * DAY);
    await detector.pollOnceForTest();
    expect(enqueueNudgeTurn).not.toHaveBeenCalled();

    await store.update("t1", (r) => (r ? { ...r, doneDeclared: undefined } : r));
    await detector.pollOnceForTest();
    expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);
  });
});

describe("StallDetector — comment-mode nudge leaves NO description trace (v4.1 §15.3)", () => {
  it("nudges a stalled comment-mode claim but never PATCHes the task description", async () => {
    const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
    const claimedAt = Date.now();
    await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt, mode: "comment" });
    const { requester, calls } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
    const client = new TaskListClient(requester);
    const enqueueNudgeTurn = vi.fn();
    const detector = new StallDetector(
      { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn },
      { stallThresholdMs: DAY },
    );

    vi.setSystemTime(claimedAt + DAY + 1);
    await detector.pollOnceForTest();

    expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1); // wake-up itself still fires
    expect(calls.some((c) => c.method === "PATCH")).toBe(false); // but no description trace
  });

  it("full-mode claims keep the v3.3 nudge-history description trace", async () => {
    const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
    const claimedAt = Date.now();
    await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: claimedAt });
    const { requester, calls } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
    const client = new TaskListClient(requester);
    const enqueueNudgeTurn = vi.fn();
    const detector = new StallDetector(
      { store, client, getLastActiveTs: () => claimedAt, enqueueNudgeTurn },
      { stallThresholdMs: DAY },
    );

    vi.setSystemTime(claimedAt + DAY + 1);
    await detector.pollOnceForTest();

    expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);
    expect(calls.some((c) => c.method === "PATCH")).toBe(true);
  });
});

// ---------------------------------------------------------------------------
// v4.2 revision 5: a peer turn that FINISHED IN FAILURE after the mention is
// a broken handoff right now — grace/completion evidence must not resolve it.
// ---------------------------------------------------------------------------

describe("StallDetector — peer failure re-arms the handoff tier (revision 5)", () => {
  const HANDOFF_MS = 5 * 60_000;

  function makeHandoffDetector(opts: {
    store: TaskHandleStore;
    client: TaskListClient;
    enqueueNudgeTurn: (turn: { threadId: string; chatId: string; text: string }) => void;
    lastActiveTs: number;
    peerReceivedAt?: number;
    peerLastActiveTs?: number;
    peerOutcome?: "completed" | "failed";
  }) {
    const d = new StallDetector(
      {
        store: opts.store,
        client: opts.client,
        getLastActiveTs: () => opts.lastActiveTs,
        enqueueNudgeTurn: opts.enqueueNudgeTurn,
        getPeerReceivedAt: () => opts.peerReceivedAt,
        getPeerLastActiveTs: () => opts.peerLastActiveTs,
        getPeerLastTurnOutcome: () => opts.peerOutcome,
      },
      { stallThresholdMs: DAY, stallHandoffThresholdMs: HANDOFF_MS, handoffStartupQuietMs: 0 },
    );
    return d;
  }

  it("peer received AND finished in FAILURE within the grace window → nudges at the handoff floor (was: silent until 24h)", async () => {
    const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
    const t0 = Date.now();
    await store.put({
      threadId: "t1",
      taskGuid: "g1",
      chatId: "oc_1",
      claimedTs: t0,
      mode: "comment",
      lastTurnOutcome: "completed", // the CLAIM HOLDER's own turn succeeded
      lastTurnMentions: ["peer-b"],
      lastTurnMentionsAt: t0,
    });
    const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
    const client = new TaskListClient(requester);
    const enqueueNudgeTurn = vi.fn();
    const detector = makeHandoffDetector({
      store,
      client,
      enqueueNudgeTurn,
      lastActiveTs: t0, // claim holder idle since the mention
      peerReceivedAt: t0 + 60_000, // peer received (in-memory receipt)
      peerLastActiveTs: t0 + 20 * 60_000, // peer's turn ENDED 20min in (within 30min grace)
      peerOutcome: "failed", // …in failure (idle-kill / crash)
    });

    vi.setSystemTime(t0 + 25 * 60_000); // 25min later: still inside receipt grace
    await detector.pollOnceForTest();

    expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);
    expect(enqueueNudgeTurn.mock.calls[0]![0].text).toContain("协作");
  });

  it("peer finished SUCCESSFULLY → handoff resolved, no nudge (unchanged)", async () => {
    const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
    const t0 = Date.now();
    await store.put({
      threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: t0,
      lastTurnOutcome: "completed", lastTurnMentions: ["peer-b"], lastTurnMentionsAt: t0,
    });
    const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
    const client = new TaskListClient(requester);
    const enqueueNudgeTurn = vi.fn();
    const detector = makeHandoffDetector({
      store, client, enqueueNudgeTurn,
      lastActiveTs: t0,
      peerReceivedAt: t0 + 60_000,
      peerLastActiveTs: t0 + 20 * 60_000,
      peerOutcome: "completed",
    });

    vi.setSystemTime(t0 + 25 * 60_000);
    await detector.pollOnceForTest();
    expect(enqueueNudgeTurn).not.toHaveBeenCalled();
  });

  it("outcome unknown after restart → conservative pre-revision-5 posture (completion evidence resolves)", async () => {
    const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
    const t0 = Date.now();
    await store.put({
      threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: t0,
      lastTurnOutcome: "completed", lastTurnMentions: ["peer-b"], lastTurnMentionsAt: t0,
    });
    const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
    const client = new TaskListClient(requester);
    const enqueueNudgeTurn = vi.fn();
    const detector = makeHandoffDetector({
      store, client, enqueueNudgeTurn,
      lastActiveTs: t0,
      peerReceivedAt: undefined, // in-memory receipt lost (restart)
      peerLastActiveTs: t0 + 20 * 60_000, // disk-persisted completion since mention
      peerOutcome: undefined, // outcome map also lost
    });

    vi.setSystemTime(t0 + 25 * 60_000);
    await detector.pollOnceForTest();
    expect(enqueueNudgeTurn).not.toHaveBeenCalled();
  });

  it("a STALE peer failure from before this mention does not re-arm", async () => {
    const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
    const t0 = Date.now();
    await store.put({
      threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: t0,
      lastTurnOutcome: "completed", lastTurnMentions: ["peer-b"], lastTurnMentionsAt: t0,
    });
    const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
    const client = new TaskListClient(requester);
    const enqueueNudgeTurn = vi.fn();
    const detector = makeHandoffDetector({
      store, client, enqueueNudgeTurn,
      lastActiveTs: t0 + 60_000,
      peerReceivedAt: t0 + 60_000, // received this mention, within grace
      peerLastActiveTs: t0 - 60_000, // last finished turn predates the mention
      peerOutcome: "failed", // stale failure
    });

    vi.setSystemTime(t0 + 10 * 60_000); // within grace → tier 2 not broken
    await detector.pollOnceForTest();
    expect(enqueueNudgeTurn).not.toHaveBeenCalled();
  });
});

describe("StallDetector — due tier keeps a 24h cooldown floor (v4.2 round-2)", () => {
  it("an overdue task under ACTIVE work is nudged at most once per 24h even with the 1h default cooldown", async () => {
    const store = await TaskHandleStore.load(join(dir, "task-handles.json"));
    const t0 = Date.now();
    await store.put({ threadId: "t1", taskGuid: "g1", chatId: "oc_1", claimedTs: t0 });
    const { requester } = makeFakeRequester({ g1: { guid: "g1", summary: "任务A" } });
    const client = new TaskListClient(requester);
    const enqueueNudgeTurn = vi.fn();
    let lastActiveTs = t0;
    const detector = new StallDetector(
      {
        store, client,
        getLastActiveTs: () => lastActiveTs,
        enqueueNudgeTurn,
        getTaskDueMs: () => t0 - 1, // already past due
      },
      { stallThresholdMs: DAY }, // cooldown left at the 1h default
    );

    vi.setSystemTime(t0 + 60_000);
    await detector.pollOnceForTest(); // due nudge #1
    expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1);

    lastActiveTs = t0 + 10 * 60_000; // nudge confirmed
    vi.setSystemTime(t0 + 20 * 60_000);
    await detector.pollOnceForTest(); // confirmation pass

    lastActiveTs = t0 + 30 * 60_000; // REAL progress (active work continues)
    vi.setSystemTime(t0 + 2 * HOUR); // 2h later — past the 1h shared cooldown
    await detector.pollOnceForTest(); // progress reset (count→0, anchor kept)
    await detector.pollOnceForTest(); // would re-nudge if only the 1h cooldown gated

    expect(enqueueNudgeTurn).toHaveBeenCalledTimes(1); // 24h due floor held

    vi.setSystemTime(t0 + 25 * HOUR); // past the 24h floor
    await detector.pollOnceForTest();
    expect(enqueueNudgeTurn).toHaveBeenCalledTimes(2);
  });
});
