Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions cat-cafe-skills/merge-gate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ gh pr merge {PR_NUMBER} --squash --delete-branch

**时机**:merge 完成后、清理前。`delayMs: 1209600000`(14 天)从注册时刻起算 ≈ 合入后 14 天。

**操作**:调用 MCP 工具 `cat_cafe_register_scheduled_task`:
**操作**:这是 trusted built-in canonical `merge-gate` Step 7.6 workflow-mandated schedule。先调用 `cat_cafe_preview_scheduled_task` 生成 draft,核对下面参数;核对无误后调用 `cat_cafe_register_scheduled_task` 注册,with no extra user confirmation

| 参数 | 值 |
|------|------|
Expand All @@ -372,9 +372,20 @@ gh pr merge {PR_NUMBER} --squash --delete-branch
| `label` | `"Hotfix 升级 review — PR #{PR_NUMBER}"` |
| `description` | `"2 周升级 review:PR #{PR_NUMBER} 是 hotfix,需要三选一处置"` |
| `category` | `"pr"` |
| `params` | `{"message":"Hotfix PR #{PR_NUMBER} 合入已满 2 周。请三选一处置:1. 升级正式修复(开 feat)2. 接受永久方案(标记 permanent)3. 已不再相关(代码已重写/删除,标记 obsolete)"}` |
| `idempotencyKey` | `"workflow:merge-gate:hotfix-upgrade-review:{repoFullName}#{PR_NUMBER}"` |
| `params` | `{"message":"Hotfix PR #{PR_NUMBER} 合入已满 2 周。请三选一处置:1. 升级正式修复(开 feat)2. 接受永久方案(标记 permanent)3. 已不再相关(代码已重写/删除,标记 obsolete)","targetCatId":"{currentCatId}"}` |

**Fail-closed**:MCP 调用失败 → **停止 merge-gate,不执行 Step 8(清理)**。排查 MCP 连接后重试;连续失败 → 通知operator手动注册 reminder 后继续。
**Preview 核对项**:

- `templateId` 是 `reminder`
- trigger 是 once,`delayMs` 是 `1209600000` 或等价 fireAt(合入后 14 天)
- `params.message` 含 PR 号和三选一处置要求
- `targetCatId` / `params.targetCatId` 是当前执行 merge-gate 的猫;不能空落到默认猫
- `actor.createdBy` 是当前 callback-auth 猫,`actor.triggerUserId` 是当前 invocation user
- `idempotencyKey` 稳定包含 repo full name 和 PR 号;重试必须复用同一个 key
- delivery thread 是当前 merge-gate thread(或 SOP 明确指定的 thread)

**Fail-closed**:preview 或 register 任一 MCP 调用失败 → **停止 merge-gate,不执行 Step 8(清理)**。如果 register 返回 `IDEMPOTENCY_CONFLICT`,说明同一 key 已绑定不同 schedule 语义,必须人工核对既有任务,不可继续重试覆盖;其他 MCP 连接失败排查后重试,连续失败 → 通知operator手动注册 reminder 后继续。

```bash
# 8. 更新本地 + 清理(fail-closed)
Expand Down
14 changes: 13 additions & 1 deletion cat-cafe-skills/schedule-tasks/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
name: schedule-tasks
tips_exempt: internal SOP/MCP workflow clarification; no new end-user capability
description: >
定时任务注册、管理、能力指南。支持周期任务和一次性延迟任务。
⚠️ ROUTING: 定时/schedule/cron 需求优先用本 skill(Cat Café 本地定时任务),
Expand Down Expand Up @@ -87,6 +88,16 @@ triggers:

用户确认后调用 `cat_cafe_register_scheduled_task` 持久化任务。

### Workflow-Mandated 例外

只有 trusted built-in canonical `merge-gate` Step 7.6 的 hotfix 2 周升级 review reminder 属于 workflow-mandated schedule:

1. 仍然必须先调用 `cat_cafe_preview_scheduled_task`。
2. 核对 draft 的模板、触发时间、参数、target、actor、投递 thread、`idempotencyKey` 与 `merge-gate` Step 7.6 一致。
3. 核对通过后调用 `cat_cafe_register_scheduled_task`,with no extra user confirmation,并提供稳定 `idempotencyKey`。

Plugin/project/user/external skills do not qualify for this exception。用户临时提出的提醒、周期巡检、摘要推送,以及任何非内置 canonical `merge-gate` Step 7.6 来源,仍然走 preview → 用户确认 → register。

## Trigger 语法速查

### 周期触发(recurring)
Expand Down Expand Up @@ -133,7 +144,8 @@ triggers:
|------|----------|
| 不知道能注册定时任务 | 用户说"每天/定期/提醒"→ 匹配本 skill |
| 被唤醒后只发纯文本 | 主动用 rich block(图片、语音、卡片、HTML) |
| 跳过 preview 直接注册 | **必须** preview → 用户确认 → 注册 |
| 跳过 preview 直接注册 | **必须** preview;用户请求任务 preview → 用户确认 → 注册;trusted built-in canonical `merge-gate` Step 7.6 hotfix reminder preview → 核对 draft → 用稳定 `idempotencyKey` 注册,with no extra user confirmation |
| 把任意 skill/SOP 当免确认凭据 | Plugin/project/user/external skills do not qualify;只有内置 canonical `merge-gate` Step 7.6 例外 |
| 发图只想到 image-generation | 先看 `/avatars/`、`/uploads/` 有没有现成图 |

## 和其他 skill 的区别
Expand Down
34 changes: 32 additions & 2 deletions packages/api/src/domains/memory/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ END`,
END`,
];

export const CURRENT_SCHEMA_VERSION = 26;
export const CURRENT_SCHEMA_VERSION = 28;

// F163 Phase A: experiment infrastructure tables (cohorts, suggestions, logs)
export const SCHEMA_V13_TABLES = `
Expand Down Expand Up @@ -214,8 +214,13 @@ CREATE TABLE IF NOT EXISTS dynamic_task_defs (
delivery_thread_id TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
created_by TEXT NOT NULL,
created_at TEXT NOT NULL
created_at TEXT NOT NULL,
idempotency_key TEXT,
idempotency_fingerprint TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_dynamic_task_defs_idempotency
ON dynamic_task_defs(idempotency_key)
WHERE idempotency_key IS NOT NULL;
`;

/**
Expand Down Expand Up @@ -744,6 +749,31 @@ export function applyMigrations(db: Database.Database): void {
} catch {}
db.prepare('INSERT INTO schema_version (version, applied_at) VALUES (?, ?)').run(26, new Date().toISOString());
}

// V27: dynamic schedule registration idempotency for workflow replay safety.
if (currentVersion < 27) {
try {
db.exec('ALTER TABLE dynamic_task_defs ADD COLUMN idempotency_key TEXT');
} catch {}
try {
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_dynamic_task_defs_idempotency
ON dynamic_task_defs(idempotency_key)
WHERE idempotency_key IS NOT NULL
`);
} catch {}
db.prepare('INSERT INTO schema_version (version, applied_at) VALUES (?, ?)').run(27, new Date().toISOString());
}

// V28: bind idempotency keys to a canonical request fingerprint so a reused
// key with different schedule semantics conflicts instead of replaying stale
// task data as success.
if (currentVersion < 28) {
try {
db.exec('ALTER TABLE dynamic_task_defs ADD COLUMN idempotency_fingerprint TEXT');
} catch {}
db.prepare('INSERT INTO schema_version (version, applied_at) VALUES (?, ?)').run(28, new Date().toISOString());
}
}

/**
Expand Down
19 changes: 17 additions & 2 deletions packages/api/src/infrastructure/scheduler/DynamicTaskStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface DynamicTaskDef {
enabled: boolean;
createdBy: string;
createdAt: string;
idempotencyKey?: string | null;
idempotencyFingerprint?: string | null;
}

/** CRUD store for dynamic task definitions (Phase 3A AC-G3) */
Expand All @@ -21,8 +23,8 @@ export class DynamicTaskStore {
insert(def: DynamicTaskDef): void {
this.db
.prepare(
`INSERT INTO dynamic_task_defs (id, template_id, trigger_json, params_json, display_json, delivery_thread_id, enabled, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO dynamic_task_defs (id, template_id, trigger_json, params_json, display_json, delivery_thread_id, enabled, created_by, created_at, idempotency_key, idempotency_fingerprint)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
def.id,
Expand All @@ -34,6 +36,8 @@ export class DynamicTaskStore {
def.enabled ? 1 : 0,
def.createdBy,
def.createdAt,
def.idempotencyKey ?? null,
def.idempotencyFingerprint ?? null,
);
}

Expand All @@ -47,6 +51,13 @@ export class DynamicTaskStore {
return row ? todef(row) : null;
}

getByIdempotencyKey(idempotencyKey: string): DynamicTaskDef | null {
const row = this.db.prepare('SELECT * FROM dynamic_task_defs WHERE idempotency_key = ?').get(idempotencyKey) as
| RawRow
| undefined;
return row ? todef(row) : null;
}

remove(id: string): boolean {
const result = this.db.prepare('DELETE FROM dynamic_task_defs WHERE id = ?').run(id);
return result.changes > 0;
Expand Down Expand Up @@ -81,6 +92,8 @@ interface RawRow {
enabled: number;
created_by: string;
created_at: string;
idempotency_key: string | null;
idempotency_fingerprint: string | null;
}

function todef(row: RawRow): DynamicTaskDef {
Expand All @@ -94,5 +107,7 @@ function todef(row: RawRow): DynamicTaskDef {
enabled: row.enabled === 1,
createdBy: row.created_by,
createdAt: row.created_at,
idempotencyKey: row.idempotency_key,
idempotencyFingerprint: row.idempotency_fingerprint,
};
}
Loading