# 结果会话关联定时任务提示词 实现计划

> **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 任务设为 `resultSessionKey` 的可见会话）里发起交互轮时，注入一句简短提示，告知 AI 本会话关联了哪些定时任务（jobId 列表），并引导其用 cron 工具按需查看详情。

**Architecture:** 方案 A — 订阅 cron 服务事件维护标记。在持久化 `SessionEntry` 上新增 `linkedCronJobIds?: string[]`。一个事件维护者监听 cron 服务的 `added`/`updated`/`removed` 事件（这三类是所有创建/删除路径——UI、agent cron-tool、`deleteAfterRun` 自删——的唯一汇聚点），把 jobId 写入/移出目标结果会话的标记。读取侧在交互轮注入点读会话标记，非空则注入提示。维护者（事件→标记）与注入器（标记→提示）通过数据字段解耦、互不感知。

**Tech Stack:** TypeScript (ESM, `.js` import 后缀)，Vitest，现有 session store 运行时（`updateSessionStoreEntry`/`resolveStorePath`/`readSessionStoreReadOnly`/`resolveSessionStoreEntry`），cron 服务事件回调 `onEvent`。

**Spec:** [docs/superpowers/specs/2026-06-16-result-session-linked-cron-hint-design.md](../specs/2026-06-16-result-session-linked-cron-hint-design.md)

---

## File Structure

- **Create** `src/cron/result-session-link.ts` — 标记维护者。导出纯函数 `addLinkedCronJobId` / `removeLinkedCronJobId`（列表去重/移除逻辑）和 `applyCronEventToLinkedSession`（消费事件、写 session store，best-effort）。
- **Create** `src/cron/result-session-link.test.ts` — 维护者单元测试（纯函数 + 真实临时 store I/O + 错误吞掉 + deleteAfterRun removed 事件回归）。
- **Modify** `src/config/sessions/types.ts` — 在 `SessionEntry` 新增 `linkedCronJobIds?: string[]`。
- **Modify** `src/config/sessions/store.linked-cron.test.ts`（新建测试文件）— 字段 round-trip 保留测试。
- **Modify** `src/cron/service/state.ts` — `CronEvent` 新增 `previousResultSessionKey?: string`。
- **Modify** `src/cron/service/ops.ts` — `update()` 的 `emit` 补 `previousResultSessionKey: job.resultSessionKey`。
- **Modify** `src/gateway/server-cron.ts` — 在已有 `onEvent` 处理器内调用维护者。
- **Modify** `src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts` — 新增 `buildLinkedCronJobsPromptContextForSession`，并接入 `resolveAttemptPrependSystemContext`（新增 `config` 入参）。
- **Modify** `src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts`（若不存在则新建）— 注入器单元测试。
- **Modify** `src/agents/pi-embedded-runner/run/attempt.ts` — 在调用点（约 2610 行）把 `config: params.config` 传入 `resolveAttemptPrependSystemContext`。

---

## Task 1: 新增 `SessionEntry.linkedCronJobIds` 字段 + round-trip 测试

**Files:**
- Modify: `src/config/sessions/types.ts` (在 `outputFileManifest` 之后、`SessionEntry` 类型的闭合 `};` 之前)
- Test: `src/config/sessions/store.linked-cron.test.ts` (新建)

- [ ] **Step 1: 写失败测试 — 字段 round-trip 保留**

新建 `src/config/sessions/store.linked-cron.test.ts`。仿照现有 `store.skills-stripping.test.ts` 的临时 store 模式（用 `os.tmpdir()` + 唯一文件名建临时 storePath，afterEach 删除）。

```typescript
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { saveSessionStore } from "./store.js";
import { loadSessionStore } from "./store-load.js";
import type { SessionEntry } from "./types.js";

describe("SessionEntry.linkedCronJobIds 持久化", () => {
  let storePath: string;

  beforeEach(() => {
    storePath = path.join(
      os.tmpdir(),
      `linked-cron-${process.pid}-${process.hrtime.bigint()}.json`,
    );
  });

  afterEach(() => {
    try {
      fs.rmSync(storePath, { force: true });
    } catch {
      /* ignore */
    }
  });

  it("preserves linkedCronJobIds on store roundtrip", async () => {
    const entry: SessionEntry = {
      sessionId: "session-1",
      updatedAt: 1,
      linkedCronJobIds: ["job-a", "job-b"],
    };
    const store = { "agent:main:test:1": entry };

    await saveSessionStore(storePath, store, { skipMaintenance: true });
    const loaded = loadSessionStore(storePath, { skipCache: true });

    expect(loaded["agent:main:test:1"]?.linkedCronJobIds).toEqual(["job-a", "job-b"]);
  });
});
```

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

Run: `npx vitest run src/config/sessions/store.linked-cron.test.ts`
Expected: FAIL — TypeScript 报 `linkedCronJobIds` 不存在于 `SessionEntry` 类型（编译期错误）。

- [ ] **Step 3: 在 `SessionEntry` 新增字段**

在 `src/config/sessions/types.ts` 中 `outputFileManifest?: Record<...>;` 行之后、`SessionEntry` 闭合 `};` 之前，加入：

```typescript
  /**
   * 将本会话设为 resultSessionKey 的 cron 任务 id 列表。
   * 由 cron 服务事件订阅者维护（add/update 时加入，remove 时剔除）。
   * 去重、保持插入顺序；列表清空时该字段被删除（不存空数组）。
   * 非空时，交互轮注入一句提示，告知 AI 可用 cron 工具查看这些任务。
   */
  linkedCronJobIds?: string[];
```

- [ ] **Step 4: 运行测试，确认通过**

Run: `npx vitest run src/config/sessions/store.linked-cron.test.ts`
Expected: PASS

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

```bash
git add src/config/sessions/types.ts src/config/sessions/store.linked-cron.test.ts
git commit -m "feat(sessions): add linkedCronJobIds field to SessionEntry"
```

---

## Task 2: `CronEvent` 补 `previousResultSessionKey` + `update` emit 携带

**背景：** `update()` 可能改动 `resultSessionKey`（从会话 A 改到会话 B）。维护者需要旧值才能精确从 A 剔除、向 B 加入。`updated` 事件里的 `job` 是更新后的快照，旧值已丢失，所以由 emit 端额外携带更新前的值。

**Files:**
- Modify: `src/cron/service/state.ts` (`CronEvent` 类型，约 18-39 行)
- Modify: `src/cron/service/ops.ts` (`update()` 的 emit，约 421-426 行)

- [ ] **Step 1: 在 `CronEvent` 类型新增可选字段**

在 `src/cron/service/state.ts` 的 `CronEvent` 类型里，`nextRunAtMs?: number;` 之后、闭合 `} & CronRunTelemetry;` 之前，加入：

```typescript
  /**
   * 仅 action="updated" 时可能出现：更新前 job 的 resultSessionKey。
   * 供 result-session-link 维护者在 resultSessionKey 改变时，从旧会话标记剔除 jobId。
   */
  previousResultSessionKey?: string;
```

- [ ] **Step 2: `update()` 的 emit 携带更新前的值**

在 `src/cron/service/ops.ts` 的 `update()` 中，定位现有 emit（约 421-426 行）：

```typescript
    emit(state, {
      jobId: id,
      action: "updated",
      job: nextJob,
      nextRunAtMs: nextJob.state.nextRunAtMs,
    });
```

改为携带更新前 `job` 的 resultSessionKey（`job` 是 `findJobOrThrow` 拿到的更新前快照，在作用域内）：

```typescript
    emit(state, {
      jobId: id,
      action: "updated",
      job: nextJob,
      nextRunAtMs: nextJob.state.nextRunAtMs,
      ...(job.resultSessionKey ? { previousResultSessionKey: job.resultSessionKey } : {}),
    });
```

- [ ] **Step 3: 类型检查通过**

Run: `npx tsc --noEmit -p tsconfig.json`
Expected: PASS（无与上述改动相关的类型错误）。

注：若全量 tsc 太慢或有无关报错，可只看本次改动文件无新增报错。

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

```bash
git add src/cron/service/state.ts src/cron/service/ops.ts
git commit -m "feat(cron): carry previousResultSessionKey on updated event"
```

---

## Task 3: 标记维护者 — 纯函数（去重加入/移除）

**背景：** 维护者拆成两层：纯函数层（列表去重/移除，无 I/O，易测）和事件消费层（Task 4，写 store + 错误吞掉）。本任务只做纯函数层。

**Files:**
- Create: `src/cron/result-session-link.ts`
- Test: `src/cron/result-session-link.test.ts`

- [ ] **Step 1: 写失败测试 — 纯函数行为**

新建 `src/cron/result-session-link.test.ts`：

```typescript
import { describe, expect, it } from "vitest";
import { addLinkedCronJobId, removeLinkedCronJobId } from "./result-session-link.js";

describe("addLinkedCronJobId", () => {
  it("appends a new id to undefined list", () => {
    expect(addLinkedCronJobId(undefined, "job-a")).toEqual(["job-a"]);
  });
  it("dedupes an existing id, preserving order", () => {
    expect(addLinkedCronJobId(["job-a", "job-b"], "job-a")).toEqual(["job-a", "job-b"]);
  });
  it("appends in insertion order", () => {
    expect(addLinkedCronJobId(["job-a"], "job-b")).toEqual(["job-a", "job-b"]);
  });
});

describe("removeLinkedCronJobId", () => {
  it("removes the id, returns remaining list", () => {
    expect(removeLinkedCronJobId(["job-a", "job-b"], "job-a")).toEqual(["job-b"]);
  });
  it("returns undefined when list becomes empty (no empty array)", () => {
    expect(removeLinkedCronJobId(["job-a"], "job-a")).toBeUndefined();
  });
  it("returns undefined for undefined input", () => {
    expect(removeLinkedCronJobId(undefined, "job-a")).toBeUndefined();
  });
  it("is a no-op when id absent (returns same contents)", () => {
    expect(removeLinkedCronJobId(["job-a"], "job-x")).toEqual(["job-a"]);
  });
});
```

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

Run: `npx vitest run src/cron/result-session-link.test.ts`
Expected: FAIL — 模块 `./result-session-link.js` 不存在 / 函数未定义。

- [ ] **Step 3: 写纯函数实现**

新建 `src/cron/result-session-link.ts`：

```typescript
/**
 * 维护 SessionEntry.linkedCronJobIds 的纯逻辑：
 * - add: 去重追加，保持插入顺序。
 * - remove: 剔除指定 id；列表清空时返回 undefined（不留空数组）。
 * 事件消费 + session store 写入见 applyCronEventToLinkedSession（同文件下方）。
 */

/** 去重追加 jobId，保持插入顺序。 */
export function addLinkedCronJobId(
  existing: string[] | undefined,
  jobId: string,
): string[] {
  const list = existing ?? [];
  if (list.includes(jobId)) {
    return list;
  }
  return [...list, jobId];
}

/** 剔除 jobId；清空后返回 undefined（避免存空数组）。 */
export function removeLinkedCronJobId(
  existing: string[] | undefined,
  jobId: string,
): string[] | undefined {
  if (!existing || existing.length === 0) {
    return undefined;
  }
  const next = existing.filter((id) => id !== jobId);
  return next.length > 0 ? next : undefined;
}
```

- [ ] **Step 4: 运行测试，确认通过**

Run: `npx vitest run src/cron/result-session-link.test.ts`
Expected: PASS

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

```bash
git add src/cron/result-session-link.ts src/cron/result-session-link.test.ts
git commit -m "feat(cron): add linkedCronJobIds list helpers (pure)"
```

---

## Task 4: 标记维护者 — 事件消费层（写 store + 错误吞掉）

**背景：** 在 Task 3 的纯函数之上，加 `applyCronEventToLinkedSession(evt, deps)`：消费 `CronEvent`，按 `job.agentId` 解析 storePath，调 `updateSessionStoreEntry` 写标记。写失败被吞掉（logWarn），绝不冒泡。`deps` 注入 store 运行时与 logger，便于测试。

**Files:**
- Modify: `src/cron/result-session-link.ts`
- Modify: `src/cron/result-session-link.test.ts`

- [ ] **Step 1: 写失败测试 — 事件消费（真实临时 store I/O）**

在 `src/cron/result-session-link.test.ts` 顶部补充 import，并追加 describe 块。仿照 store 测试用临时文件；通过 `deps` 注入真实 `updateSessionStoreEntry` 与可控 logger。

```typescript
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { saveSessionStore } from "../config/sessions/store.js";
import { loadSessionStore } from "../config/sessions/store-load.js";
import { updateSessionStoreEntry } from "../config/sessions/store.js";
import { applyCronEventToLinkedSession } from "./result-session-link.js";
import type { CronEvent } from "./service/state.js";

describe("applyCronEventToLinkedSession", () => {
  let storePath: string;
  const SESSION_KEY = "agent:main:result:1";
  const logWarn = vi.fn();
  const deps = {
    updateSessionStoreEntry,
    resolveStorePath: () => storePath,
    logWarn,
  };

  beforeEach(async () => {
    storePath = path.join(
      os.tmpdir(),
      `link-evt-${process.pid}-${process.hrtime.bigint()}.json`,
    );
    // 目标结果会话必须先存在（updateSessionStoreEntry 对缺失条目 no-op）。
    await saveSessionStore(
      storePath,
      { [SESSION_KEY]: { sessionId: "s1", updatedAt: 1 } },
      { skipMaintenance: true },
    );
    logWarn.mockClear();
  });

  afterEach(() => {
    try {
      fs.rmSync(storePath, { force: true });
    } catch {
      /* ignore */
    }
  });

  const job = (over: Partial<CronEvent["job"]> = {}) =>
    ({ id: "job-a", agentId: "main", resultSessionKey: SESSION_KEY, ...over }) as CronEvent["job"];

  it("added event writes jobId into linkedCronJobIds", async () => {
    await applyCronEventToLinkedSession({ jobId: "job-a", action: "added", job: job() }, deps);
    const loaded = loadSessionStore(storePath, { skipCache: true });
    expect(loaded[SESSION_KEY]?.linkedCronJobIds).toEqual(["job-a"]);
  });

  it("added is idempotent (no duplicate id)", async () => {
    const evt: CronEvent = { jobId: "job-a", action: "added", job: job() };
    await applyCronEventToLinkedSession(evt, deps);
    await applyCronEventToLinkedSession(evt, deps);
    const loaded = loadSessionStore(storePath, { skipCache: true });
    expect(loaded[SESSION_KEY]?.linkedCronJobIds).toEqual(["job-a"]);
  });

  it("removed event strips jobId; empty list deletes the field", async () => {
    await applyCronEventToLinkedSession({ jobId: "job-a", action: "added", job: job() }, deps);
    await applyCronEventToLinkedSession({ jobId: "job-a", action: "removed", job: job() }, deps);
    const loaded = loadSessionStore(storePath, { skipCache: true });
    expect(loaded[SESSION_KEY]?.linkedCronJobIds).toBeUndefined();
  });

  it("skips jobs without resultSessionKey", async () => {
    await applyCronEventToLinkedSession(
      { jobId: "job-a", action: "added", job: job({ resultSessionKey: undefined }) },
      deps,
    );
    const loaded = loadSessionStore(storePath, { skipCache: true });
    expect(loaded[SESSION_KEY]?.linkedCronJobIds).toBeUndefined();
  });

  it("swallows store write errors (never throws)", async () => {
    const throwingDeps = {
      ...deps,
      updateSessionStoreEntry: async () => {
        throw new Error("disk fail");
      },
    };
    await expect(
      applyCronEventToLinkedSession({ jobId: "job-a", action: "added", job: job() }, throwingDeps),
    ).resolves.toBeUndefined();
    expect(logWarn).toHaveBeenCalledOnce();
  });
});
```

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

Run: `npx vitest run src/cron/result-session-link.test.ts`
Expected: FAIL — `applyCronEventToLinkedSession` 未导出。

- [ ] **Step 3: 实现事件消费层**

在 `src/cron/result-session-link.ts` 顶部加 import，并在文件末尾追加。`deps` 默认绑定真实运行时，测试可覆盖。

```typescript
import { resolveStorePath } from "../config/sessions/paths.js";
import {
  updateSessionStoreEntry as defaultUpdateSessionStoreEntry,
} from "../config/sessions/store.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { CronEvent } from "./service/state.js";

type ApplyDeps = {
  updateSessionStoreEntry: typeof defaultUpdateSessionStoreEntry;
  resolveStorePath: (agentId?: string) => string;
  logWarn: (msg: string) => void;
};

// defaultDeps 仅作回退；生产路径由 server-cron.ts 注入 config-aware 的
// resolveStorePath（见 Task 5），以匹配读取侧 (Task 6) 的 store 解析。
// 这里的 resolveStorePath(undefined, …) 忽略自定义 session.store 模板，
// 故不要在有自定义 store 的部署里依赖 defaultDeps。
const defaultDeps: ApplyDeps = {
  updateSessionStoreEntry: defaultUpdateSessionStoreEntry,
  resolveStorePath: (agentId) => resolveStorePath(undefined, { agentId }),
  logWarn: (msg) => console.warn(msg),
};

async function mutateLinkedIds(params: {
  deps: ApplyDeps;
  agentId: string | undefined;
  sessionKey: string;
  jobId: string;
  op: "add" | "remove";
}): Promise<void> {
  const storePath = params.deps.resolveStorePath(params.agentId);
  await params.deps.updateSessionStoreEntry({
    storePath,
    sessionKey: params.sessionKey,
    update: async (entry: SessionEntry) => {
      const next =
        params.op === "add"
          ? addLinkedCronJobId(entry.linkedCronJobIds, params.jobId)
          : removeLinkedCronJobId(entry.linkedCronJobIds, params.jobId);
      // 写入时若清空则删除字段（mergeSessionEntry 不会删字段，但读取侧把
      // 空数组按缺失处理，详见 buildLinkedCronJobsPromptContextForSession）。
      return { linkedCronJobIds: next };
    },
  });
}

/**
 * 消费一个 cron 服务事件，维护目标结果会话的 linkedCronJobIds 标记。
 * best-effort：任何写失败都被吞掉，绝不冒泡到 cron 主流程。
 */
export async function applyCronEventToLinkedSession(
  evt: CronEvent,
  deps: ApplyDeps = defaultDeps,
): Promise<void> {
  try {
    const job = evt.job;
    if (!job) {
      return;
    }
    const agentId = job.agentId;
    const newKey = job.resultSessionKey?.trim();

    if (evt.action === "added" || evt.action === "updated") {
      // 迁移：updated 改了 resultSessionKey → 从旧会话剔除。
      const prevKey = evt.previousResultSessionKey?.trim();
      if (evt.action === "updated" && prevKey && prevKey !== newKey) {
        await mutateLinkedIds({ deps, agentId, sessionKey: prevKey, jobId: job.id, op: "remove" });
      }
      if (newKey) {
        await mutateLinkedIds({ deps, agentId, sessionKey: newKey, jobId: job.id, op: "add" });
      }
      return;
    }

    if (evt.action === "removed" && newKey) {
      await mutateLinkedIds({ deps, agentId, sessionKey: newKey, jobId: job.id, op: "remove" });
    }
  } catch (err) {
    deps.logWarn(`[result-session-link] failed to maintain marker for job ${evt.jobId}: ${String(err)}`);
  }
}
```

注：`update` 回调返回 `{ linkedCronJobIds: undefined }` 时，`mergeSessionEntry` 的行为需在 Step 4 验证——若它不删字段，读取侧已按「空/缺失等价」处理（Task 6 的注入器把空数组也视为不注入），测试 "empty list deletes the field" 验证最终落盘结果为 undefined。若该测试失败（字段残留为 `[]`），改为：当 `next` 为 undefined 时返回的 patch 仍设 `linkedCronJobIds: undefined`，并确认 `mergeSessionEntry`/序列化会丢弃 undefined 值（JSON.stringify 丢弃 undefined 字段，round-trip 后即为缺失）。

- [ ] **Step 4: 运行测试，确认通过**

Run: `npx vitest run src/cron/result-session-link.test.ts`
Expected: PASS（全部 add/remove/skip/迁移/错误吞掉用例）。

若 "empty list deletes the field" 失败，按 Step 3 注释调整后重跑。

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

```bash
git add src/cron/result-session-link.ts src/cron/result-session-link.test.ts
git commit -m "feat(cron): maintain linkedCronJobIds marker from cron events"
```

---

## Task 5: 在 gateway `onEvent` 处理器内装配维护者

**背景：** cron 服务的事件订阅是构造期单一 `onEvent` 回调（无多订阅者 `.on()`）。`server-cron.ts` 已有一个 `onEvent`（约 354 行）。维护者从该处理器内调用，覆盖所有 `added`/`updated`/`removed` 事件——包括 `deleteAfterRun` 自删 emit 的 `removed`。

**Files:**
- Modify: `src/gateway/server-cron.ts` (import 区 + `onEvent` 处理器约 354-355 行)

- [ ] **Step 1: 加 import**

在 `src/gateway/server-cron.ts` 顶部的 import 区（与其他 `../cron/...` import 相邻处）加入：

```typescript
import { applyCronEventToLinkedSession } from "../cron/result-session-link.js";
```

- [ ] **Step 2: 在 `onEvent` 内调用维护者**

定位现有 `onEvent` 开头（约 354-355 行）：

```typescript
    onEvent: (evt) => {
      params.broadcast("cron", evt, { dropIfSlow: true });
```

在 `broadcast` 之后插入（fire-and-forget，best-effort；维护者内部已吞错，这里再 `.catch` 兜底防止未处理 Promise 拒绝）。

**关键：必须传入 config-aware 的 `resolveStorePath`**，让写入侧与读取侧（Task 6 注入器用 `params.config?.session?.store`）解析到同一个 store 文件。维护者默认的 `resolveStorePath(undefined, …)` 会忽略自定义 store 模板，导致写读路径不一致——这里用 `params.cfg.session?.store` 显式覆盖：

```typescript
    onEvent: (evt) => {
      params.broadcast("cron", evt, { dropIfSlow: true });
      void applyCronEventToLinkedSession(evt, {
        updateSessionStoreEntry,
        resolveStorePath: (agentId) => resolveStorePath(params.cfg.session?.store, { agentId }),
        logWarn: (msg) => cronLogger.warn(msg),
      }).catch(() => {
        /* maintainer is best-effort; it logs internally */
      });
```

import 区相应补入（若文件尚未导入）：

```typescript
import { resolveStorePath } from "../config/sessions/paths.js";
import { updateSessionStoreEntry } from "../config/sessions/store.js";
```

注：`cronLogger` 已在 `server-cron.ts` 作用域内（现有 `onEvent` 的 finished 分支用到）；若变量名不同，以文件内实际可用的 logger 为准。`params.cfg` 同样在作用域内（现有代码多处使用，如 `params.cfg.cron?.webhookToken`）。

- [ ] **Step 3: 类型检查 + 构建**

Run: `npx tsc --noEmit -p tsconfig.json`
Expected: PASS（无与本改动相关的类型错误）。

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

```bash
git add src/gateway/server-cron.ts
git commit -m "feat(gateway): wire linkedCronJobIds maintainer into cron onEvent"
```

---

## Task 6: 提示注入器 — `buildLinkedCronJobsPromptContextForSession`

**背景：** 读取侧。仿照 `buildActiveVideoGenerationTaskPromptContextForSession` 的模式，新增同步函数：按 sessionKey 读会话条目的 `linkedCronJobIds`，非空则返回提示串，否则 `undefined`。读 store 用 `readSessionStoreReadOnly`（直接读盘、出错返回 `{}`，比缓存版稳健），用 `resolveSessionStoreEntry` 做与写入侧一致的 key 归一化查找。

**Files:**
- Modify: `src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts`
- Test: `src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts`（若不存在则新建）

- [ ] **Step 1: 写失败测试 — 注入器**

新建/追加 `src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts`。注入器通过显式 storePath 入参测试（避免依赖全局 config 解析）：

```typescript
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { saveSessionStore } from "../../../config/sessions/store.js";
import { buildLinkedCronJobsPromptContextForSession } from "./attempt.prompt-helpers.js";

describe("buildLinkedCronJobsPromptContextForSession", () => {
  let storePath: string;
  const SESSION_KEY = "agent:main:result:1";

  beforeEach(() => {
    storePath = path.join(
      os.tmpdir(),
      `inject-${process.pid}-${process.hrtime.bigint()}.json`,
    );
  });
  afterEach(() => {
    try {
      fs.rmSync(storePath, { force: true });
    } catch {
      /* ignore */
    }
  });

  it("returns a hint string listing the jobIds when present", async () => {
    await saveSessionStore(
      storePath,
      { [SESSION_KEY]: { sessionId: "s1", updatedAt: 1, linkedCronJobIds: ["job-a", "job-b"] } },
      { skipMaintenance: true },
    );
    const out = buildLinkedCronJobsPromptContextForSession({ sessionKey: SESSION_KEY, storePath });
    expect(out).toContain("job-a");
    expect(out).toContain("job-b");
    expect(out).toContain("cron");
  });

  it("returns undefined when the list is empty or missing", async () => {
    await saveSessionStore(
      storePath,
      { [SESSION_KEY]: { sessionId: "s1", updatedAt: 1 } },
      { skipMaintenance: true },
    );
    expect(
      buildLinkedCronJobsPromptContextForSession({ sessionKey: SESSION_KEY, storePath }),
    ).toBeUndefined();
  });

  it("returns undefined when the session entry is absent", () => {
    expect(
      buildLinkedCronJobsPromptContextForSession({ sessionKey: "agent:main:nope:1", storePath }),
    ).toBeUndefined();
  });

  it("returns undefined for an empty sessionKey", () => {
    expect(buildLinkedCronJobsPromptContextForSession({ sessionKey: undefined, storePath })).toBeUndefined();
  });
});
```

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

Run: `npx vitest run src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts`
Expected: FAIL — `buildLinkedCronJobsPromptContextForSession` 未导出。

- [ ] **Step 3: 实现注入器**

在 `src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts` 顶部补 import：

```typescript
import { resolveStorePath } from "../../../config/sessions/paths.js";
import { readSessionStoreReadOnly } from "../../../config/sessions/store-read.js";
import { resolveSessionStoreEntry } from "../../../config/sessions/store-entry.js";
import { resolveSessionAgentId } from "../../agent-scope.js";
```

注：`resolveSessionAgentId` 的确切路径以 cron-tool.ts 中的 import 为准（`import { resolveSessionAgentId } from "../agent-scope.js";` 在 src/agents/tools/ 下，故从 src/agents/pi-embedded-runner/run/ 出发为 `../../agent-scope.js`）。实现前用 grep 确认：`grep -rn "export function resolveSessionAgentId" src/agents/`。

在文件中新增导出函数（放在 `resolveAttemptPrependSystemContext` 之前）：

```typescript
/**
 * 若本会话被一个或多个 cron 任务设为 resultSessionKey，返回一句提示，
 * 告知 AI 关联的 jobId 并引导其用 cron 工具按需查看；否则返回 undefined。
 *
 * 同步读盘（readSessionStoreReadOnly：出错返回 {}），永不阻塞或抛错。
 * storePath 可显式传入（测试用）；缺省时按 sessionKey 的 agentId 解析。
 */
export function buildLinkedCronJobsPromptContextForSession(params: {
  sessionKey?: string;
  config?: OpenClawConfig;
  storePath?: string;
}): string | undefined {
  const sessionKey = params.sessionKey?.trim();
  if (!sessionKey) {
    return undefined;
  }
  let ids: string[] | undefined;
  try {
    const agentId = resolveSessionAgentId({ sessionKey, config: params.config ?? {} });
    const storePath =
      params.storePath ?? resolveStorePath(params.config?.session?.store, { agentId });
    const store = readSessionStoreReadOnly(storePath);
    const entry = resolveSessionStoreEntry({ store: store as Record<string, never>, sessionKey })
      .existing;
    ids = entry?.linkedCronJobIds;
  } catch {
    return undefined;
  }
  if (!ids || ids.length === 0) {
    return undefined;
  }
  const idList = ids.join(", ");
  return [
    `本会话关联了 ${ids.length} 个定时任务（jobId：${idList}）。`,
    `如需查看它们的调度、下次运行时间或历史运行结果，可使用 cron 工具`,
    `（action=list 看配置、runs 看运行历史、status 看调度状态），传入上述 jobId。`,
  ].join("\n");
}
```

注：`OpenClawConfig` 类型若文件内尚未 import，需补 `import type { OpenClawConfig } from "../../../config/types.openclaw.js";`（路径以文件内其他 config import 为准）。`resolveSessionStoreEntry` 的 `store` 形参类型为 `Record<string, SessionEntry>`，而 `readSessionStoreReadOnly` 返回 `Record<string, SessionEntry | undefined>`；用 `as` 收窄或在调用处适配（以 tsc 报错为准，优先按实际类型调整而非滥用 any）。

- [ ] **Step 4: 运行测试，确认通过**

Run: `npx vitest run src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts`
Expected: PASS

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

```bash
git add src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts
git commit -m "feat(agent): add linked cron jobs prompt context builder"
```

---

## Task 7: 接入交互轮注入点 + 全量验证

**背景：** 把注入器接到 `resolveAttemptPrependSystemContext`，与现有媒体任务提示并列，仅 `trigger === "user" | "manual"` 时注入。该函数需新增 `config` 入参以便注入器解析 storePath；调用点 `attempt.ts` 把 `params.config` 传入。

**Files:**
- Modify: `src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts` (`resolveAttemptPrependSystemContext`，约 471-487 行)
- Modify: `src/agents/pi-embedded-runner/run/attempt.ts` (调用点，约 2610 行)

- [ ] **Step 1: 扩展 `resolveAttemptPrependSystemContext`**

定位现有实现（约 471-487 行）：

```typescript
export function resolveAttemptPrependSystemContext(params: {
  sessionKey?: string;
  trigger?: EmbeddedRunAttemptParams["trigger"];
  hookPrependSystemContext?: string;
}): string | undefined {
  const activeMediaTaskPromptContexts =
    params.trigger === "user" || params.trigger === "manual"
      ? [
          buildActiveVideoGenerationTaskPromptContextForSession(params.sessionKey),
          buildActiveMusicGenerationTaskPromptContextForSession(params.sessionKey),
        ]
      : [];
  return joinPresentTextSegments([
    ...activeMediaTaskPromptContexts,
    params.hookPrependSystemContext,
  ]);
}
```

改为新增 `config` 入参，并把关联 cron 任务提示并入 user/manual 分支：

```typescript
export function resolveAttemptPrependSystemContext(params: {
  sessionKey?: string;
  trigger?: EmbeddedRunAttemptParams["trigger"];
  hookPrependSystemContext?: string;
  config?: OpenClawConfig;
}): string | undefined {
  const userOrManual = params.trigger === "user" || params.trigger === "manual";
  const activeMediaTaskPromptContexts = userOrManual
    ? [
        buildActiveVideoGenerationTaskPromptContextForSession(params.sessionKey),
        buildActiveMusicGenerationTaskPromptContextForSession(params.sessionKey),
      ]
    : [];
  const linkedCronContext = userOrManual
    ? buildLinkedCronJobsPromptContextForSession({
        sessionKey: params.sessionKey,
        config: params.config,
      })
    : undefined;
  return joinPresentTextSegments([
    ...activeMediaTaskPromptContexts,
    linkedCronContext,
    params.hookPrependSystemContext,
  ]);
}
```

（`OpenClawConfig` import 已在 Task 6 Step 3 补入；若当时未补，在此补上。）

- [ ] **Step 2: 调用点传入 config**

在 `src/agents/pi-embedded-runner/run/attempt.ts` 定位调用（约 2610 行）：

```typescript
            prependSystemContext: resolveAttemptPrependSystemContext({
              sessionKey: params.sessionKey,
              trigger: params.trigger,
              hookPrependSystemContext: hookResult?.prependSystemContext,
            }),
```

补一行 `config`：

```typescript
            prependSystemContext: resolveAttemptPrependSystemContext({
              sessionKey: params.sessionKey,
              trigger: params.trigger,
              hookPrependSystemContext: hookResult?.prependSystemContext,
              config: params.config,
            }),
```

- [ ] **Step 3: 为注入器的 trigger 门控加测试**

在 `src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts` 追加：验证非 user/manual trigger 不注入。由于 `resolveAttemptPrependSystemContext` 默认解析全局 storePath（测试环境通常无关联会话），这里聚焦「门控」语义——直接断言 heartbeat trigger 下不会包含 cron 提示文案。

```typescript
import { resolveAttemptPrependSystemContext } from "./attempt.prompt-helpers.js";

describe("resolveAttemptPrependSystemContext linked-cron gating", () => {
  it("does not inject linked-cron hint for non-user/manual triggers", () => {
    const out = resolveAttemptPrependSystemContext({
      sessionKey: "agent:main:result:1",
      trigger: "heartbeat",
      hookPrependSystemContext: "HOOK",
    });
    // heartbeat 轮不注入任何媒体/cron 提示，仅保留 hook 文案
    expect(out).toBe("HOOK");
  });
});
```

- [ ] **Step 4: 运行注入器全部测试**

Run: `npx vitest run src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts`
Expected: PASS

- [ ] **Step 5: 全量构建 + 类型检查**

Run: `npx tsc --noEmit -p tsconfig.json`
Expected: PASS（无与本特性相关的类型错误）。

- [ ] **Step 6: 跑相关测试套件回归**

Run: `npx vitest run src/cron src/config/sessions src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts`
Expected: PASS（cron、sessions、注入器测试全绿）。

- [ ] **Step 7: deleteAfterRun 端到端回归确认**

确认 Task 4 中 "removed event strips jobId" 用例覆盖了自删语义：`deleteAfterRun` 自删在 `ops.ts` emit 的是同样的 `{ action: "removed", job }` 事件（见 [src/cron/service/ops.ts](../../../src/cron/service/ops.ts) `finishPreparedManualRun` 约 808 行），维护者一处订阅即覆盖。无需额外集成测试——事件形状一致，单元测试已守住。在 plan 执行记录里记下这一确认。

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

```bash
git add src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts src/agents/pi-embedded-runner/run/attempt.ts src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts
git commit -m "feat(agent): inject linked cron jobs hint into interactive turns"
```

---

## 完成标准

- 用户在结果会话发 user/manual 轮 → 系统提示含「本会话关联了 N 个定时任务（jobId：…）」并引导用 cron 工具。
- 心跳轮 / 子代理轮 → 不注入。
- 创建任务即写标记；删除任务（含 deleteAfterRun 自删）即清标记；列表清空后字段消失、不再注入。
- update 改 resultSessionKey → 旧会话剔除、新会话加入。
- 标记维护任何失败都不影响 cron add/update/remove 主流程，也不影响聊天。
- 全部新增单元测试通过，tsc 无新增报错。
