# 定时任务失败原因镜像到 resultSession — 实现计划

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 定时任务(cron isolated-agent turn)执行失败时,把失败原因作为一条带前缀的 assistant 消息追加到任务配置的结果会话(`job.resultSessionKey`),覆盖全部失败路径。

**Architecture:** 方案 A —— 在 `runCronIsolatedAgentTurn` 的返回边界集中处理。新增本地辅助 `finishWithMirror(result, agentId)`,把所有返回点(finalize、abort、外层 catch、早期准备失败)都接到同一个镜像出口;改造 `mirrorCronResultToSession` 根据 `result.status` 决定镜像内容(`ok` 镜像输出、`error` 镜像带前缀失败消息、`skipped` 不镜像)。复用现有 best-effort 镜像设施,不新增模块。

**Tech Stack:** TypeScript ESM(strict)、Vitest、现有 cron isolated-agent 测试 harness。

**关键事实(实现前必读):**
- 镜像入口:[src/cron/isolated-agent/run.ts:1064](../../../src/cron/isolated-agent/run.ts#L1064) `mirrorCronResultToSession`,经 [run.ts:90-91](../../../src/cron/isolated-agent/run.ts#L90) 的 `cronTranscriptRuntimeLoader` 懒加载 `../../config/sessions/transcript.runtime.js` 的 `appendAssistantMessageToSessionTranscript`。
- `runCronIsolatedAgentTurn` 的返回点:早期准备失败 [run.ts:1120-1122](../../../src/cron/isolated-agent/run.ts#L1120)、abort [run.ts:1166](../../../src/cron/isolated-agent/run.ts#L1166)、finalize [run.ts:1178](../../../src/cron/isolated-agent/run.ts#L1178)、外层 catch [run.ts:1189](../../../src/cron/isolated-agent/run.ts#L1189)。
- **`result.error` 在每条错误路径都已是归一化字符串**:abort 路径用 `abortReason()`(友好超时文案);外层 catch 用 `String(err)`;finalize error-payload 用 `embeddedRunError`;早期准备失败用 `resolvedModelSelection.error`。因此镜像直接读 `result.error`,**不导入** service 层的 `normalizeCronRunErrorText`(避免向上层导入 / import-cycle 失败)。
- `RunCronAgentTurnResult.status` 是 `"ok" | "error" | "skipped"`(来自 `CronRunOutcome`,见 [src/cron/types.ts:117](../../../src/cron/types.ts#L117))。
- `prepareCronRunContext` 在任何失败返回前(line 486)已算出 `agentId`,失败变体可顺带带出,供早期失败镜像用。
- 测试 harness:[run.test-harness.ts](../../../src/cron/isolated-agent/run.test-harness.ts) **未** mock transcript runtime —— 新测试文件需自行 `vi.mock` 它。job fixture [job-fixtures.ts](../../../src/cron/isolated-agent/job-fixtures.ts) 默认无 `resultSessionKey`,测试需显式注入。

---

## File Structure

- **Modify:** `src/cron/isolated-agent/run.ts`
  - 改造 `mirrorCronResultToSession`(status 驱动镜像内容)。
  - 新增本地 `finishWithMirror` 辅助,改写 4 个返回点。
  - 给 `CronPreparationResult` 失败变体加 `agentId`,并在两处失败返回带上。
- **Create:** `src/cron/isolated-agent/run.failure-mirror.test.ts`
  - 覆盖全部失败/成功镜像行为,mock transcript runtime。

每个文件单一职责:`run.ts` 保持现有 isolated-agent 运行编排职责,只在原镜像点扩展;测试独立成文件,沿用现有 harness。

---

## Task 1: 新建失败镜像测试文件骨架 + transcript runtime mock

**Files:**
- Create: `src/cron/isolated-agent/run.failure-mirror.test.ts`

先建测试文件,mock transcript runtime 并断言一个最小行为:**成功运行仍镜像 `outputText`**(回归保护,确保 mock 接线正确)。harness 默认不 mock transcript runtime,所以这里必须自己 mock。

- [ ] **Step 1: 写测试文件(含 mock + 成功回归用例)**

```typescript
import { describe, expect, it, vi } from "vitest";
import {
  makeIsolatedAgentTurnParams,
  setupRunCronIsolatedAgentTurnSuite,
} from "./run.suite-helpers.js";
import { loadRunCronIsolatedAgentTurn } from "./run.test-harness.js";

const appendAssistantMessageToSessionTranscriptMock = vi.fn();

vi.mock("../../config/sessions/transcript.runtime.js", () => ({
  appendAssistantMessageToSessionTranscript: (...args: unknown[]) =>
    appendAssistantMessageToSessionTranscriptMock(...args),
  appendExactAssistantMessageToSessionTranscript: vi.fn(),
}));

const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();

describe("runCronIsolatedAgentTurn - failure result mirroring", () => {
  setupRunCronIsolatedAgentTurnSuite();

  beforeEach(() => {
    appendAssistantMessageToSessionTranscriptMock.mockReset();
    appendAssistantMessageToSessionTranscriptMock.mockResolvedValue({
      ok: true,
      sessionFile: "/tmp/result.jsonl",
      messageId: "msg-1",
    });
  });

  it("mirrors successful output to the result session", async () => {
    const result = await runCronIsolatedAgentTurn(
      makeIsolatedAgentTurnParams({ job: { resultSessionKey: "cron:result" } }),
    );

    expect(result.status).toBe("ok");
    expect(appendAssistantMessageToSessionTranscriptMock).toHaveBeenCalledTimes(1);
    const call = appendAssistantMessageToSessionTranscriptMock.mock.calls[0][0];
    expect(call.sessionKey).toBe("cron:result");
    expect(call.text).toBe("test output");
  });
});
```

注意:`beforeEach` 需从 `vitest` 导入(在顶部 import 里补上 `beforeEach`)。

- [ ] **Step 2: 运行测试,确认通过(成功路径本就镜像)**

Run: `pnpm test src/cron/isolated-agent/run.failure-mirror.test.ts`
Expected: PASS(成功镜像是已有行为;本步验证 mock 接线正确)。

若 FAIL 且原因是 mock 未生效(实际调用了真实 transcript 写盘),检查 `vi.mock` 路径是否与 [run.ts:91](../../../src/cron/isolated-agent/run.ts#L91) 的 `import("../../config/sessions/transcript.runtime.js")` 完全一致。

- [ ] **Step 3: 提交**

```bash
scripts/committer "test(cron): scaffold failure-mirror suite with transcript mock" src/cron/isolated-agent/run.failure-mirror.test.ts
```

---

## Task 2: 改造 mirrorCronResultToSession 支持 status 驱动镜像

**Files:**
- Modify: `src/cron/isolated-agent/run.ts:1064-1096`(`mirrorCronResultToSession`)
- Test: `src/cron/isolated-agent/run.failure-mirror.test.ts`

让镜像函数根据 `result.status` 决定内容:`ok` → 镜像 `outputText ?? summary`(现状);`error` → 镜像带前缀失败消息;`skipped` → 不镜像。本任务只改函数内部,**暂不**改调用点(调用点仍在 finalize 成功路径,Task 3 才扩展到所有路径)。因此本任务的失败用例先用 **error-payload finalize 路径**(它已经走 finalize 调用点),验证函数内部 status 分支正确。

- [ ] **Step 1: 写失败测试 —— error-payload 运行镜像带前缀失败消息**

在 `run.failure-mirror.test.ts` 的 `describe` 内追加:

```typescript
  it("mirrors a prefixed failure message when the run errors", async () => {
    const { runWithModelFallbackMock } = await import("./run.test-harness.js");
    runWithModelFallbackMock.mockResolvedValueOnce({
      result: {
        payloads: [],
        meta: {
          error: { kind: "provider_error", message: "model provider unreachable" },
          agentMeta: { usage: { input: 0, output: 0 } },
        },
      },
      provider: "openai",
      model: "gpt-5.4",
      attempts: [],
    });

    const result = await runCronIsolatedAgentTurn(
      makeIsolatedAgentTurnParams({ job: makeIsolatedAgentTurnJob({ resultSessionKey: "cron:result" }) }),
    );

    expect(result.status).toBe("error");
    expect(appendAssistantMessageToSessionTranscriptMock).toHaveBeenCalledTimes(1);
    const call = appendAssistantMessageToSessionTranscriptMock.mock.calls[0][0];
    expect(call.sessionKey).toBe("cron:result");
    expect(call.text).toBe(
      "⚠️ 定时任务执行失败：cron isolated run failed: model provider unreachable",
    );
  });
```

- [ ] **Step 2: 运行测试,确认失败**

Run: `pnpm test src/cron/isolated-agent/run.failure-mirror.test.ts -t "prefixed failure message"`
Expected: FAIL —— 当前函数对 error 状态不会改写 text(error-payload 路径 `outputText` 已被设为裸错误文案,但没有 `⚠️ 定时任务执行失败：` 前缀),断言不匹配。

- [ ] **Step 3: 改写 `mirrorCronResultToSession`**

把 [run.ts:1064-1096](../../../src/cron/isolated-agent/run.ts#L1064) 整个函数体替换为:

```typescript
async function mirrorCronResultToSession(params: {
  job: CronJob;
  agentId: string;
  result: RunCronAgentTurnResult;
  cfg: OpenClawConfig;
}): Promise<string | undefined> {
  const resultSessionKey = normalizeOptionalString(params.job.resultSessionKey);
  if (!resultSessionKey) {
    return undefined;
  }
  // skipped 不是执行失败(如 preflight 模型不可用),镜像会污染结果会话。
  if (params.result.status === "skipped") {
    return undefined;
  }
  const text =
    params.result.status === "error"
      ? `⚠️ 定时任务执行失败：${
          normalizeOptionalString(params.result.error) ?? "未知错误"
        }`
      : normalizeOptionalString(params.result.outputText ?? params.result.summary);
  if (!text) {
    return undefined;
  }
  try {
    const { appendAssistantMessageToSessionTranscript } = await cronTranscriptRuntimeLoader.load();
    const appended = await appendAssistantMessageToSessionTranscript({
      agentId: params.agentId,
      sessionKey: resultSessionKey,
      text,
      idempotencyKey: params.result.sessionId
        ? `cron:${params.job.id}:run:${params.result.sessionId}`
        : undefined,
    });
    // The mirrored message's stable id lets the dashboard's "view chat" button
    // scroll straight to this run's output instead of landing on the latest
    // appended run (result sessions accumulate every run's output).
    return appended.ok ? normalizeOptionalString(appended.messageId) : undefined;
  } catch (err) {
    logWarn(`[cron:${params.job.id}] failed to mirror result to session: ${String(err)}`);
    return undefined;
  }
}
```

说明:`result.error` 在每条错误路径都已归一化为字符串(见计划头"关键事实"),因此直接读取,不导入 service 层的 `normalizeCronRunErrorText`。

- [ ] **Step 4: 运行测试,确认两条用例都通过**

Run: `pnpm test src/cron/isolated-agent/run.failure-mirror.test.ts`
Expected: PASS(成功镜像 + error-payload 带前缀镜像)。

- [ ] **Step 5: 提交**

```bash
scripts/committer "feat(cron): mirror prefixed failure message to result session" src/cron/isolated-agent/run.ts src/cron/isolated-agent/run.failure-mirror.test.ts
```

---

## Task 3: 把所有返回点接到 finishWithMirror 出口

**Files:**
- Modify: `src/cron/isolated-agent/run.ts`(`runCronIsolatedAgentTurn` 函数体 + `CronPreparationResult` 失败变体)
- Test: `src/cron/isolated-agent/run.failure-mirror.test.ts`

让 abort、外层 catch、早期准备失败三条路径也镜像。新增本地 `finishWithMirror`,所有返回点经它出口。早期准备失败发生在 `prepared.context` 之前,需让 `prepareCronRunContext` 的失败变体带出 `agentId`。

- [ ] **Step 1: 写失败测试 —— 外层 catch(运行抛异常)镜像失败原因**

在 `run.failure-mirror.test.ts` 追加:

```typescript
  it("mirrors the error when the run throws", async () => {
    const { runWithModelFallbackMock } = await import("./run.test-harness.js");
    runWithModelFallbackMock.mockReset();
    runWithModelFallbackMock.mockRejectedValueOnce(new Error("boom"));

    const result = await runCronIsolatedAgentTurn(
      makeIsolatedAgentTurnParams({ job: makeIsolatedAgentTurnJob({ resultSessionKey: "cron:result" }) }),
    );

    expect(result.status).toBe("error");
    expect(appendAssistantMessageToSessionTranscriptMock).toHaveBeenCalledTimes(1);
    const call = appendAssistantMessageToSessionTranscriptMock.mock.calls[0][0];
    expect(call.sessionKey).toBe("cron:result");
    expect(call.text).toContain("⚠️ 定时任务执行失败：");
    expect(call.text).toContain("boom");
  });
```

- [ ] **Step 2: 运行测试,确认失败**

Run: `pnpm test src/cron/isolated-agent/run.failure-mirror.test.ts -t "when the run throws"`
Expected: FAIL —— 外层 catch 路径(line 1189)目前不调用镜像,`appendAssistantMessageToSessionTranscriptMock` 调用次数为 0。

- [ ] **Step 3: 给 `CronPreparationResult` 失败变体加 `agentId`**

把 [run.ts:466-468](../../../src/cron/isolated-agent/run.ts#L466) 的类型改为:

```typescript
type CronPreparationResult =
  | { ok: true; context: PreparedCronRunContext }
  | { ok: false; agentId: string; result: RunCronAgentTurnResult };
```

在 `prepareCronRunContext` 内两处失败 `return`(model-selection 失败 [run.ts:579-589](../../../src/cron/isolated-agent/run.ts#L579) 与 preflight skipped [run.ts:603-614](../../../src/cron/isolated-agent/run.ts#L603))各补 `agentId`。`agentId` 在 line 486 已定义,直接加字段:

```typescript
    return {
      ok: false,
      agentId,
      result: withRunSession({
        status: "error",
        error: resolvedModelSelection.error,
        diagnostics: createCronRunDiagnosticsFromError(
          "cron-preflight",
          resolvedModelSelection.error,
        ),
      }),
    };
```

```typescript
    return {
      ok: false,
      agentId,
      result: withRunSession({
        status: "skipped",
        error: preflight.reason,
        diagnostics: createCronRunDiagnosticsFromError("model-preflight", preflight.reason, {
          severity: "warn",
        }),
        provider,
        model,
      }),
    };
```

- [ ] **Step 4: 在 `runCronIsolatedAgentTurn` 内新增 `finishWithMirror` 并改写返回点**

把 [run.ts:1119-1194](../../../src/cron/isolated-agent/run.ts#L1119)(从 `const prepared = await prepareCronRunContext(...)` 到外层 catch 结束)改为:

```typescript
  const prepared = await prepareCronRunContext({ input: params, isFastTestEnv });

  const finishWithMirror = async (
    agentId: string,
    result: RunCronAgentTurnResult,
  ): Promise<RunCronAgentTurnResult> => {
    const resultMessageId = await mirrorCronResultToSession({
      job: params.job,
      agentId,
      result,
      cfg: params.cfg,
    });
    if (resultMessageId) {
      result.resultMessageId = resultMessageId;
    }
    return result;
  };

  if (!prepared.ok) {
    return finishWithMirror(prepared.agentId, prepared.result);
  }
  const notifyExecutionStarted = () =>
    params.onExecutionStarted?.({
      jobId: params.job.id,
      agentId: prepared.context.agentId,
      sessionId: prepared.context.runSessionId,
      sessionKey: prepared.context.runSessionKey,
    });

  try {
    const { executeCronRun } = await loadCronExecutorRuntime();
    const execution = await executeCronRun({
      // ... 原参数原样保留 (run.ts:1134-1163) ...
    });
    if (isAborted()) {
      return finishWithMirror(
        prepared.context.agentId,
        prepared.context.withRunSession({
          status: "error",
          error: abortReason(),
          diagnostics: createCronRunDiagnosticsFromError("cron-setup", abortReason()),
        }),
      );
    }
    const finalized = await finalizeCronRun({
      prepared: prepared.context,
      execution,
      abortReason,
      isAborted,
    });
    return finishWithMirror(prepared.context.agentId, finalized);
  } catch (err) {
    return finishWithMirror(
      prepared.context.agentId,
      prepared.context.withRunSession({
        status: "error",
        error: String(err),
        diagnostics: createCronRunDiagnosticsFromError("agent-run", err),
      }),
    );
  }
```

注意:
- `executeCronRun({...})` 的参数块原样保留,只是它的结果 `execution` 处理方式如上。
- 删除原来 line 1178-1186 的独立 `mirrorCronResultToSession` 调用(已并入 `finishWithMirror`)。
- 外层 catch 用 `prepared.context.agentId`:`prepared` 在 `try` 外用 `if (!prepared.ok) return`提前返回,所以进入 `try` 时 `prepared.ok === true`,`prepared.context` 必定存在。

- [ ] **Step 5: 运行测试,确认通过**

Run: `pnpm test src/cron/isolated-agent/run.failure-mirror.test.ts`
Expected: PASS（成功、error-payload、外层 catch 三条用例)。

- [ ] **Step 6: 提交**

```bash
scripts/committer "feat(cron): route all run outcomes through failure-mirror funnel" src/cron/isolated-agent/run.ts src/cron/isolated-agent/run.failure-mirror.test.ts
```

---

## Task 4: 补齐边界用例(skipped / 无 resultSessionKey / best-effort 吞掉)

**Files:**
- Test: `src/cron/isolated-agent/run.failure-mirror.test.ts`

补齐 spec 测试计划里剩余的边界:`skipped` 不镜像、无 `resultSessionKey` 不镜像、镜像本身失败不抛二次异常。这些是纯断言,无生产代码改动(行为已由 Task 2/3 实现)。

- [ ] **Step 1: 追加三条边界用例**

```typescript
  it("does not mirror a skipped run", async () => {
    const { preflightCronModelProviderMock } = await import("./run.test-harness.js");
    preflightCronModelProviderMock.mockReset();
    preflightCronModelProviderMock.mockResolvedValue({
      status: "unavailable",
      reason: "model temporarily unavailable",
    });

    const result = await runCronIsolatedAgentTurn(
      makeIsolatedAgentTurnParams({ job: makeIsolatedAgentTurnJob({ resultSessionKey: "cron:result" }) }),
    );

    expect(result.status).toBe("skipped");
    expect(appendAssistantMessageToSessionTranscriptMock).not.toHaveBeenCalled();
  });

  it("does not mirror when the job has no resultSessionKey", async () => {
    const { runWithModelFallbackMock } = await import("./run.test-harness.js");
    runWithModelFallbackMock.mockReset();
    runWithModelFallbackMock.mockRejectedValueOnce(new Error("boom"));

    const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams());

    expect(result.status).toBe("error");
    expect(appendAssistantMessageToSessionTranscriptMock).not.toHaveBeenCalled();
  });

  it("never throws when mirroring itself fails", async () => {
    const { runWithModelFallbackMock } = await import("./run.test-harness.js");
    runWithModelFallbackMock.mockReset();
    runWithModelFallbackMock.mockRejectedValueOnce(new Error("boom"));
    appendAssistantMessageToSessionTranscriptMock.mockRejectedValueOnce(
      new Error("session store offline"),
    );

    const result = await runCronIsolatedAgentTurn(
      makeIsolatedAgentTurnParams({ job: makeIsolatedAgentTurnJob({ resultSessionKey: "cron:result" }) }),
    );

    // The run still resolves to its error outcome; the mirror failure is swallowed.
    expect(result.status).toBe("error");
    expect(result.resultMessageId).toBeUndefined();
  });
```

- [ ] **Step 2: 运行整组测试,确认全部通过**

Run: `pnpm test src/cron/isolated-agent/run.failure-mirror.test.ts`
Expected: PASS（6 条用例:成功镜像、error-payload、外层 catch、skipped 不镜像、无 resultSessionKey 不镜像、镜像失败不抛)。

- [ ] **Step 3: 提交**

```bash
scripts/committer "test(cron): cover skipped/no-key/swallow edge cases for failure mirror" src/cron/isolated-agent/run.failure-mirror.test.ts
```

---

## Task 5: 类型检查 + 回归 + 变更门

**Files:** 无新增改动,仅验证。

- [ ] **Step 1: 类型检查改动文件所在 lane**

Run: `pnpm tsgo`
Expected: 无新增类型错误。重点确认 `CronPreparationResult` 失败变体加 `agentId` 后,所有构造/解构处类型一致。

- [ ] **Step 2: 跑相邻已有 isolated-agent 测试,确认无回归**

Run: `pnpm test src/cron/isolated-agent/run.meta-error-status.test.ts src/cron/isolated-agent/run.failure-mirror.test.ts`
Expected: 全部 PASS。`run.meta-error-status.test.ts` 验证 error-payload 路径的 `status`/`error`/`outputText` 仍符合预期(本计划未改这些字段语义,只在镜像出口读取)。

- [ ] **Step 3: 变更门(默认 Testbox)**

Run: `pnpm check:changed`
Expected: 选中的 core prod + core tests lane 全绿。若本地 fan out 到广 lane,按 AGENTS.md 移到 Testbox。

- [ ] **Step 4: 全组 cron isolated-agent 测试收尾**

Run: `pnpm test src/cron/isolated-agent`
Expected: 全部 PASS。

---

## Self-Review

- **Spec coverage:** 失败消息格式(Task 2)、全部失败路径 finalize/abort/catch/early-prep(Task 3)、skipped 不镜像 + 无 key + best-effort 吞掉(Task 4)、`normalizeCronRunErrorText` 改为直接读已归一化的 `result.error`(头部"关键事实" + Task 2 说明)、幂等键沿用(Task 2 代码)、测试计划 6 条用例(Task 1/2/3/4)。全部对应到任务。
- **Type consistency:** `mirrorCronResultToSession` 签名(`job/agentId/result/cfg`)在 Task 2 定义、Task 3 的 `finishWithMirror` 调用一致;`CronPreparationResult` 失败变体 `{ ok:false; agentId; result }` 在 Task 3 Step 3 定义、Step 4 `prepared.agentId` 消费一致;`finishWithMirror(agentId, result)` 形参顺序前后一致。
- **Placeholder scan:** 无 TBD/TODO;`executeCronRun({...})` 处明确标注"原参数原样保留(run.ts:1134-1163)",非占位。

