diff --git a/.opencode/plugins/task-runtime-sqlite.ts b/.opencode/plugins/task-runtime-sqlite.ts new file mode 100644 index 0000000..975fc91 --- /dev/null +++ b/.opencode/plugins/task-runtime-sqlite.ts @@ -0,0 +1,139 @@ +import { + buildFollowUpTaskPrompt, + buildInitialTaskPrompt, +} from "./task-runtime-sqlite/prompt-builder.js"; +import { + createSessionRuntime, + type SessionRuntimeHost, +} from "./task-runtime-sqlite/session-runtime.js"; +import { + createTaskRepository, + TASK_STATUSES, + type TaskStatus, +} from "./task-runtime-sqlite/task-repository.js"; + +export type SqliteTaskRuntimePluginOptions = { + host?: SessionRuntimeHost; + projectDir: string; +}; + +type ToolContext = { + sessionID: string; +}; + +const createTaskBoundTools = (options: SqliteTaskRuntimePluginOptions) => { + const getRepository = () => + createTaskRepository({ + now: () => new Date().toISOString(), + projectDir: options.projectDir, + }); + const getSessionRuntime = () => createSessionRuntime(options.host); + + const dispatchTasks = async () => { + const repository = getRepository(); + const sessionRuntime = getSessionRuntime(); + const results: Array<{ + action: "continued" | "created" | "reclaimed" | "skipped_busy"; + sessionID: string; + taskID: string; + }> = []; + + for (const task of repository.listUnfinishedTasks()) { + if (task.session_id) { + const session = await sessionRuntime.getSession(task.session_id); + + if (session?.busy) { + results.push({ + action: "skipped_busy", + sessionID: task.session_id, + taskID: task.task_id, + }); + continue; + } + + if (session) { + await sessionRuntime.sendPrompt( + session.id, + buildFollowUpTaskPrompt(task), + ); + results.push({ + action: "continued", + sessionID: session.id, + taskID: task.task_id, + }); + continue; + } + } + + const session = await sessionRuntime.createSession(); + const previousSessionID = task.session_id; + + repository.assignSession(task.task_id, session.id); + + try { + await sessionRuntime.sendPrompt( + session.id, + buildInitialTaskPrompt({ ...task, session_id: session.id }), + ); + } catch (error) { + repository.assignSession(task.task_id, previousSessionID); + throw error; + } + + results.push({ + action: task.session_id ? "reclaimed" : "created", + sessionID: session.id, + taskID: task.task_id, + }); + } + + return results; + }; + + return { + "dispatch-tasks": { + schema: {}, + execute: dispatchTasks, + }, + "list-processing-tasks": { + schema: {}, + execute: () => getRepository().listProcessingTasks(), + }, + "get-current-task": { + schema: {}, + execute: (_input: Record, context: ToolContext) => + getRepository().getRequiredTaskBySessionID(context.sessionID), + }, + "mark-task-status": { + schema: { status: [...TASK_STATUSES] }, + execute: (input: { status: TaskStatus }, context: ToolContext) => + getRepository().markTaskStatus({ + sessionID: context.sessionID, + status: input.status, + }), + }, + "setup-worktree-path": { + schema: { worktreePath: true }, + execute: (input: { worktreePath: string }, context: ToolContext) => + getRepository().setupWorktreePath({ + sessionID: context.sessionID, + worktreePath: input.worktreePath, + }), + }, + "setup-pull-request-url": { + schema: { pullRequestURL: true }, + execute: (input: { pullRequestURL: string }, context: ToolContext) => + getRepository().setupPullRequestURL({ + sessionID: context.sessionID, + pullRequestURL: input.pullRequestURL, + }), + }, + }; +}; + +export const createSqliteTaskRuntimePlugin = ( + options: SqliteTaskRuntimePluginOptions, +) => ({ + name: "sqlite-task-runtime", + tools: createTaskBoundTools(options), +}); diff --git a/.opencode/plugins/task-runtime-sqlite/database.ts b/.opencode/plugins/task-runtime-sqlite/database.ts new file mode 100644 index 0000000..48d9479 --- /dev/null +++ b/.opencode/plugins/task-runtime-sqlite/database.ts @@ -0,0 +1,33 @@ +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +export type TaskRecord = { + task_id: string; + task_spec: string; + session_id: string | null; + worktree_path: string | null; + pull_request_url: string | null; + status: string | null; + done: boolean; + updated_at: string | null; +}; + +export type TaskRuntimeDatabase = { + projectDir: string; + path: string; + open(): DatabaseSync; +}; + +export const getTaskRuntimeDatabasePath = (projectDir: string) => + join(projectDir, "aim.sqlite"); + +export const openTaskRuntimeDatabase = (projectDir: string) => + new DatabaseSync(getTaskRuntimeDatabasePath(projectDir)); + +export const createTaskRuntimeDatabase = ( + projectDir: string, +): TaskRuntimeDatabase => ({ + projectDir, + path: getTaskRuntimeDatabasePath(projectDir), + open: () => openTaskRuntimeDatabase(projectDir), +}); diff --git a/.opencode/plugins/task-runtime-sqlite/prompt-builder.ts b/.opencode/plugins/task-runtime-sqlite/prompt-builder.ts new file mode 100644 index 0000000..9135f48 --- /dev/null +++ b/.opencode/plugins/task-runtime-sqlite/prompt-builder.ts @@ -0,0 +1,47 @@ +import type { TaskRecord } from "./database.js"; + +const buildCodeFence = (value: string) => { + const backtickRuns = value.match(/`+/g) ?? []; + const longestRun = backtickRuns.reduce( + (max, run) => Math.max(max, run.length), + 0, + ); + + return "`".repeat(Math.max(3, longestRun + 1)); +}; + +const formatTaskSnapshot = (task: TaskRecord) => + [ + `task_id: ${task.task_id}`, + "task_spec:", + `${buildCodeFence(task.task_spec)}text`, + task.task_spec, + buildCodeFence(task.task_spec), + `status: ${task.status ?? "unknown"}`, + `worktree_path: ${task.worktree_path ?? "not set"}`, + `pull_request_url: ${task.pull_request_url ?? "not set"}`, + ].join("\n"); + +export const buildInitialTaskPrompt = (task: TaskRecord) => + [ + "你正在处理一条已绑定的 Task。", + "请根据下面的任务快照开始推进当前任务:", + formatTaskSnapshot(task), + ].join("\n\n"); + +export const buildFollowUpTaskPrompt = (task: TaskRecord) => + [ + "继续推进当前 Task。", + "以下是当前任务快照:", + formatTaskSnapshot(task), + ].join("\n\n"); + +export type PromptBuilder = { + buildInitialTaskPrompt(task: TaskRecord): string; + buildFollowUpTaskPrompt(task: TaskRecord): string; +}; + +export const createPromptBuilder = (): PromptBuilder => ({ + buildInitialTaskPrompt, + buildFollowUpTaskPrompt, +}); diff --git a/.opencode/plugins/task-runtime-sqlite/session-runtime.ts b/.opencode/plugins/task-runtime-sqlite/session-runtime.ts new file mode 100644 index 0000000..9c2b495 --- /dev/null +++ b/.opencode/plugins/task-runtime-sqlite/session-runtime.ts @@ -0,0 +1,42 @@ +export type SessionRecord = { + id: string; + busy?: boolean; +}; + +export type SessionRuntimeHost = { + getSession?( + sessionID: string, + ): Promise | SessionRecord | null; + createSession(): Promise | SessionRecord; + sendPrompt(sessionID: string, prompt: string): Promise | void; +}; + +export type SessionRuntime = { + getSession(sessionID: string): Promise; + createSession(): Promise; + sendPrompt(sessionID: string, prompt: string): Promise; +}; + +export const createSessionRuntime = ( + host?: SessionRuntimeHost, +): SessionRuntime => ({ + async getSession(sessionID) { + return (await host?.getSession?.(sessionID)) ?? null; + }, + + async createSession() { + if (!host) { + throw new Error("Session host does not support createSession()"); + } + + return host.createSession(); + }, + + async sendPrompt(sessionID, prompt) { + if (!host) { + throw new Error("Session host does not support sendPrompt()"); + } + + await host.sendPrompt(sessionID, prompt); + }, +}); diff --git a/.opencode/plugins/task-runtime-sqlite/task-repository.ts b/.opencode/plugins/task-runtime-sqlite/task-repository.ts new file mode 100644 index 0000000..1f71471 --- /dev/null +++ b/.opencode/plugins/task-runtime-sqlite/task-repository.ts @@ -0,0 +1,309 @@ +import { openTaskRuntimeDatabase, type TaskRecord } from "./database.js"; + +type ProcessingTaskRecord = Pick< + TaskRecord, + | "task_id" + | "session_id" + | "worktree_path" + | "pull_request_url" + | "status" + | "updated_at" +>; + +type CreateTaskRepositoryOptions = { + projectDir: string; + now: () => string; +}; + +export const TASK_STATUSES = [ + "created", + "running", + "outbound", + "pr_following", + "closing", + "succeeded", + "failed", +] as const; + +export type TaskStatus = (typeof TASK_STATUSES)[number]; + +const TERMINAL_TASK_STATUSES = new Set(["succeeded", "failed"]); + +const isTaskStatus = (status: string): status is TaskStatus => + TASK_STATUSES.includes(status as TaskStatus); + +const assertTaskStatus = (status: string): TaskStatus => { + if (!isTaskStatus(status)) { + throw new Error(`Unsupported task status: ${status}`); + } + + return status; +}; + +type MarkTaskStatusInput = { + sessionID: string; + status: TaskStatus; +}; + +type SetupWorktreePathInput = { + sessionID: string; + worktreePath: string; +}; + +type SetupPullRequestURLInput = { + sessionID: string; + pullRequestURL: string; +}; + +export type TaskRepository = { + listUnfinishedTasks(): TaskRecord[]; + listProcessingTasks(): ProcessingTaskRecord[]; + getTaskBySessionID(sessionID: string): TaskRecord | undefined; + getRequiredTaskBySessionID(sessionID: string): TaskRecord; + assignSession(taskID: string, sessionID: string | null): void; + markTaskStatus(input: MarkTaskStatusInput): void; + setupWorktreePath(input: SetupWorktreePathInput): void; + setupPullRequestURL(input: SetupPullRequestURLInput): void; +}; + +const mapTaskRecord = (row: Record): TaskRecord => ({ + task_id: String(row.task_id), + task_spec: String(row.task_spec), + session_id: row.session_id === null ? null : String(row.session_id), + worktree_path: row.worktree_path === null ? null : String(row.worktree_path), + pull_request_url: + row.pull_request_url === null ? null : String(row.pull_request_url), + status: row.status === null ? null : String(row.status), + done: Boolean(Number(row.done)), + updated_at: row.updated_at === null ? null : String(row.updated_at), +}); + +const mapProcessingTaskRecord = ( + row: Record, +): ProcessingTaskRecord => ({ + task_id: String(row.task_id), + session_id: row.session_id === null ? null : String(row.session_id), + worktree_path: row.worktree_path === null ? null : String(row.worktree_path), + pull_request_url: + row.pull_request_url === null ? null : String(row.pull_request_url), + status: row.status === null ? null : String(row.status), + updated_at: row.updated_at === null ? null : String(row.updated_at), +}); + +const getTasksBySessionID = ( + projectDir: string, + sessionID: string, +): TaskRecord[] => { + const database = openTaskRuntimeDatabase(projectDir); + + try { + const statement = database.prepare( + ` + SELECT + task_id, + task_spec, + session_id, + worktree_path, + pull_request_url, + status, + done, + updated_at + FROM tasks + WHERE session_id = ? + `, + ); + + return statement + .all(sessionID) + .map((row) => mapTaskRecord(row as Record)); + } finally { + database.close(); + } +}; + +export const createTaskRepository = ({ + projectDir, + now, +}: CreateTaskRepositoryOptions): TaskRepository => { + const updateTask = ( + taskID: string, + query: string, + ...params: Array + ) => { + const database = openTaskRuntimeDatabase(projectDir); + + try { + database.prepare(query).run(...params, now(), taskID); + } finally { + database.close(); + } + }; + + return { + listUnfinishedTasks() { + const database = openTaskRuntimeDatabase(projectDir); + + try { + const statement = database.prepare( + ` + SELECT + task_id, + task_spec, + session_id, + worktree_path, + pull_request_url, + status, + done, + updated_at + FROM tasks + WHERE done = 0 + ORDER BY task_id + `, + ); + + return statement + .all() + .map((row) => mapTaskRecord(row as Record)); + } finally { + database.close(); + } + }, + + listProcessingTasks() { + const database = openTaskRuntimeDatabase(projectDir); + + try { + const statement = database.prepare( + ` + SELECT + task_id, + session_id, + worktree_path, + pull_request_url, + status, + updated_at + FROM tasks + WHERE done = 0 + ORDER BY task_id + `, + ); + + return statement + .all() + .map((row) => + mapProcessingTaskRecord(row as Record), + ); + } finally { + database.close(); + } + }, + + getTaskBySessionID(sessionID) { + const matches = getTasksBySessionID(projectDir, sessionID); + + if (matches.length > 1) { + throw new Error(`Multiple tasks are bound to session ${sessionID}`); + } + + return matches[0]; + }, + + getRequiredTaskBySessionID(sessionID) { + const matches = getTasksBySessionID(projectDir, sessionID); + + if (matches.length === 0) { + throw new Error("Current session is not bound to a task"); + } + + if (matches.length > 1) { + throw new Error(`Multiple tasks are bound to session ${sessionID}`); + } + + return matches[0]; + }, + + assignSession(taskID, sessionID) { + const database = openTaskRuntimeDatabase(projectDir); + + try { + const statement = database.prepare( + ` + UPDATE tasks + SET session_id = ?, updated_at = ? + WHERE task_id = ? + `, + ); + + statement.run(sessionID, now(), taskID); + } finally { + database.close(); + } + }, + + markTaskStatus({ sessionID, status }) { + const task = this.getRequiredTaskBySessionID(sessionID); + const nextStatus = assertTaskStatus(status); + const currentStatus = + task.status === null ? null : assertTaskStatus(task.status); + + if ( + currentStatus !== null && + TERMINAL_TASK_STATUSES.has(currentStatus) && + !TERMINAL_TASK_STATUSES.has(nextStatus) + ) { + throw new Error( + "Cannot move terminal task back to non-terminal status", + ); + } + + if ( + currentStatus !== null && + TERMINAL_TASK_STATUSES.has(currentStatus) && + currentStatus !== nextStatus + ) { + throw new Error("Cannot overwrite terminal task status"); + } + + const done = TERMINAL_TASK_STATUSES.has(nextStatus); + + updateTask( + task.task_id, + ` + UPDATE tasks + SET status = ?, done = ?, updated_at = ? + WHERE task_id = ? + `, + nextStatus, + done ? 1 : 0, + ); + }, + + setupWorktreePath({ sessionID, worktreePath }) { + const task = this.getRequiredTaskBySessionID(sessionID); + + updateTask( + task.task_id, + ` + UPDATE tasks + SET worktree_path = ?, updated_at = ? + WHERE task_id = ? + `, + worktreePath, + ); + }, + + setupPullRequestURL({ sessionID, pullRequestURL }) { + const task = this.getRequiredTaskBySessionID(sessionID); + + updateTask( + task.task_id, + ` + UPDATE tasks + SET pull_request_url = ?, updated_at = ? + WHERE task_id = ? + `, + pullRequestURL, + ); + }, + }; +}; diff --git a/docs/superpowers/plans/2026-04-18-sqlite-task-runtime.md b/docs/superpowers/plans/2026-04-18-sqlite-task-runtime.md new file mode 100644 index 0000000..2ccb51b --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-sqlite-task-runtime.md @@ -0,0 +1,527 @@ +# SQLite Task Runtime Plugin Implementation Plan + +> **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:** 在 `.opencode/plugins` 内落地一个固定使用 `/aim.sqlite` 的 SQLite-backed Task Runtime 插件,暴露 6 个最小 tools,并以 `sessionID` 绑定 Task 完成调度、恢复与状态写回闭环。 + +**Architecture:** 插件入口保持很薄,只负责注册 6 个 tools,并把 SQLite 读写、session 绑定解析、dispatch 决策与 prompt 生成委托给相邻的小文件。SQLite 仍然是唯一调度真相源;插件只读取 `/aim.sqlite` 的 `tasks` 行、根据当前 `sessionID` 解析绑定 Task、调用宿主 session API 恢复或创建 session,并把 `status`、`worktree_path`、`pull_request_url`、`updated_at`、`done` 回写到同一数据库。 + +**Tech Stack:** TypeScript、Node.js 24 `node:sqlite`、OpenCode plugin runtime、Vitest、pnpm workspace + +--- + +## 文件结构与职责映射 + +**新增文件** +- `.opencode/plugins/task-runtime-sqlite.ts`:插件入口,注册 `dispatch-tasks`、`list-processing-tasks`、`get-current-task`、`mark-task-status`、`setup-worktree-path`、`setup-pull-request-url` 六个 tools,并组装相邻运行时模块。 +- `.opencode/plugins/task-runtime-sqlite/database.ts`:固定解析 `/aim.sqlite`,创建 SQLite 连接,并封装共享行映射与时间戳写入辅助函数。 +- `.opencode/plugins/task-runtime-sqlite/task-repository.ts`:封装 `tasks` 表查询与更新,包括 `done = false` 扫描、按 `session_id` 绑定查询、状态写回、`worktree_path`/`pull_request_url` 写回、重认领 `session_id`。 +- `.opencode/plugins/task-runtime-sqlite/session-runtime.ts`:封装插件对宿主 session 能力的最小适配,负责查询 session 是否存在、是否 busy、恢复已有 session、创建新 session、发送 prompt。 +- `.opencode/plugins/task-runtime-sqlite/prompt-builder.ts`:把 Task 快照转换为首条 prompt 与跟进 prompt,保证只暴露 `task_id`、`task_spec`、`status`、`worktree_path`、`pull_request_url` 等运行快照,不暴露 SQLite / `dbPath` / SQL 细节。 +- `test/repo/sqlite-task-runtime-plugin.test.ts`:repo-level Vitest 测试,覆盖 SQLite 行读写、基于 `sessionID` 的 Task 绑定、终态 `done` 派生、dispatch 的认领/恢复/跳过/重认领分支,以及 prompt 内容边界。 + +**修改文件** +- 无预设必须修改文件;实现与验证都收敛在新增插件文件和 `test/repo/sqlite-task-runtime-plugin.test.ts`,不计划引入额外配置改动。 + +**只读参考文件** +- `docs/superpowers/specs/2026-04-18-sqlite-task-runtime-design.md`:本次 plan 的唯一需求来源,后续实现不得扩展到 daemon、跨 project、额外 tools 或可配置 `dbPath`。 +- `vitest.workspace.ts`:确认 `test/repo/**/*.test.ts` 已被 repo project 收录,因此新增测试文件无需再改 Vitest 发现路径。 +- `package.json`:确认仓库已要求 Node 24,可直接使用 `node:sqlite`,避免新增 SQLite 第三方依赖。 + +## 实施约束 + +- 固定读取 `${projectDir}/aim.sqlite`;不新增 `dbPath` 参数、环境变量或插件配置项。 +- 插件对外只暴露题述 6 个 tools,不新增调试、迁移、schema 管理或手工 `task_id` 操作工具。 +- 所有 task-bound tools 都只能从当前 tool context 的 `sessionID` 反查绑定 Task;若不存在唯一绑定,必须显式失败。 +- `mark-task-status` 只允许一个 `status` 参数,并由插件统一维护 `updated_at` 与 `done` 派生关系。 +- `dispatch-tasks` 必须覆盖 5 条分支:扫描 `done = false`、恢复已有 session、busy session 跳过、失效 session 重认领、未绑定 task 创建并绑定新 session 后发送首条 prompt。 +- prompt 只能暴露 Task 快照,不得出现 `aim.sqlite`、`dbPath`、`SELECT`、`UPDATE`、表名或 SQL 规则。 +- 验证只补最小 repo-level harness,不扩大到 daemon、e2e 平台联调或新的 workspace 包。 + +### Task 1: 建立 SQLite task runtime 插件骨架与 repo 级失败测试 + +**Files:** +- Create: `.opencode/plugins/task-runtime-sqlite.ts` +- Create: `.opencode/plugins/task-runtime-sqlite/database.ts` +- Create: `.opencode/plugins/task-runtime-sqlite/task-repository.ts` +- Create: `.opencode/plugins/task-runtime-sqlite/session-runtime.ts` +- Create: `.opencode/plugins/task-runtime-sqlite/prompt-builder.ts` +- Create: `test/repo/sqlite-task-runtime-plugin.test.ts` + +- [ ] **Step 1: 先写 repo 级失败测试,锁定插件入口与 6 个 tools 的公开边界** + +在 `test/repo/sqlite-task-runtime-plugin.test.ts` 新增一个源码约束测试,要求插件入口存在,并且只注册题述 6 个 tool 名称。测试中直接读取源码,先给后续实现提供明确失败信号。示例: + +```ts +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +describe("sqlite task runtime plugin", () => { + it("registers the approved six tools", async () => { + const pluginSource = await readFile( + `${process.cwd()}/.opencode/plugins/task-runtime-sqlite.ts`, + "utf8", + ); + + expect(pluginSource).toContain('"dispatch-tasks"'); + expect(pluginSource).toContain('"list-processing-tasks"'); + expect(pluginSource).toContain('"get-current-task"'); + expect(pluginSource).toContain('"mark-task-status"'); + expect(pluginSource).toContain('"setup-worktree-path"'); + expect(pluginSource).toContain('"setup-pull-request-url"'); + }); +}); +``` + +- [ ] **Step 2: 运行单个 repo 测试,确认在插件文件尚不存在时先失败** + +Run: `pnpm exec vitest run --config vitest.workspace.ts --project repo --testNamePattern "registers the approved six tools"` + +Expected: FAIL,报 `.opencode/plugins/task-runtime-sqlite.ts` 不存在,或源码中还没有六个目标 tool 名称。 + +- [ ] **Step 3: 建立最小插件骨架与相邻运行时文件** + +先只创建文件与最小导出,让测试从“文件缺失”前进到“行为待补齐”,不要在这一步实现完整逻辑。建议骨架如下: + +```ts +// .opencode/plugins/task-runtime-sqlite.ts +import { createTaskRepository } from "./task-runtime-sqlite/task-repository.js"; +import { createPromptBuilder } from "./task-runtime-sqlite/prompt-builder.js"; +import { createSessionRuntime } from "./task-runtime-sqlite/session-runtime.js"; + +export const sqliteTaskRuntimePlugin = { + name: "sqlite-task-runtime", + tools: { + "dispatch-tasks": {}, + "list-processing-tasks": {}, + "get-current-task": {}, + "mark-task-status": {}, + "setup-worktree-path": {}, + "setup-pull-request-url": {}, + }, +}; +``` + +```ts +// .opencode/plugins/task-runtime-sqlite/database.ts +import { DatabaseSync } from "node:sqlite"; +import { join } from "node:path"; + +export const getTaskRuntimeDatabasePath = (projectDir: string) => + join(projectDir, "aim.sqlite"); + +export const openTaskRuntimeDatabase = (projectDir: string) => + new DatabaseSync(getTaskRuntimeDatabasePath(projectDir)); +``` + +其他三个相邻文件此时先导出空工厂函数:`createTaskRepository()`、`createSessionRuntime()`、`createPromptBuilder()`,返回后续 Task 会补齐的方法对象。 + +- [ ] **Step 4: 重新运行同一条 repo 测试,确认插件入口骨架已建立** + +Run: `pnpm exec vitest run --config vitest.workspace.ts --project repo --testNamePattern "registers the approved six tools"` + +Expected: PASS。 + +- [ ] **Step 5: 提交插件骨架与测试基线** + +```bash +git add .opencode/plugins/task-runtime-sqlite.ts .opencode/plugins/task-runtime-sqlite/database.ts .opencode/plugins/task-runtime-sqlite/task-repository.ts .opencode/plugins/task-runtime-sqlite/session-runtime.ts .opencode/plugins/task-runtime-sqlite/prompt-builder.ts test/repo/sqlite-task-runtime-plugin.test.ts +git commit -m "feat: add sqlite task runtime plugin scaffold" +``` + +### Task 2: 实现固定 SQLite 路径、Task 仓储与基于 `sessionID` 的绑定规则 + +**Files:** +- Modify: `.opencode/plugins/task-runtime-sqlite/database.ts` +- Modify: `.opencode/plugins/task-runtime-sqlite/task-repository.ts` +- Modify: `test/repo/sqlite-task-runtime-plugin.test.ts` + +- [ ] **Step 1: 先补仓储失败测试,锁定固定 `aim.sqlite` 路径、`done = false` 扫描与唯一绑定规则** + +在 `test/repo/sqlite-task-runtime-plugin.test.ts` 新增三组单测,直接对 `database.ts` 与 `task-repository.ts` 的导出函数建约束: + +```ts +it("always opens aim.sqlite under the project directory", () => { + expect(getTaskRuntimeDatabasePath("/repo/worktree")).toBe( + "/repo/worktree/aim.sqlite", + ); +}); + +it("lists only unfinished tasks from sqlite", () => { + const repository = createTaskRepository({ projectDir, now: () => isoNow }); + + seedTasks([ + { task_id: "task-1", task_spec: "one", done: 0, status: "created" }, + { task_id: "task-2", task_spec: "two", done: 1, status: "succeeded" }, + ]); + + expect(repository.listUnfinishedTasks().map((task) => task.task_id)).toEqual([ + "task-1", + ]); +}); + +it("resolves exactly one task for the current session id", () => { + const repository = createTaskRepository({ projectDir, now: () => isoNow }); + + seedTasks([ + { + task_id: "task-1", + task_spec: "one", + done: 0, + status: "running", + session_id: "session-1", + }, + ]); + + expect(repository.getTaskBySessionID("session-1")?.task_id).toBe("task-1"); + expect(() => repository.getRequiredTaskBySessionID("missing-session")).toThrow( + "Current session is not bound to a task", + ); +}); +``` + +测试内使用 `node:sqlite` 在 repo 内临时目录(例如 `test/repo/.tmp/sqlite-task-runtime-plugin//aim.sqlite`)建表和插入样例行,避免把数据库写到仓库外。 + +- [ ] **Step 2: 运行仓储相关测试,确认当前实现先失败** + +Run: `pnpm exec vitest run --config vitest.workspace.ts --project repo --testNamePattern "aim.sqlite|unfinished tasks|current session id"` + +Expected: FAIL,提示 `createTaskRepository` 缺少目标方法,或绑定规则/扫描逻辑尚未实现。 + +- [ ] **Step 3: 在 `database.ts` 与 `task-repository.ts` 中补齐最小仓储能力** + +1. 在 `database.ts` 定义共享 `TaskRecord` 类型,字段至少包含 `task_id`、`task_spec`、`session_id`、`worktree_path`、`pull_request_url`、`status`、`done`、`updated_at`。 +2. 在 `task-repository.ts` 导出 `createTaskRepository({ projectDir, now })`,并实现: + - `listUnfinishedTasks()`:查询 `done = 0`。 + - `listProcessingTasks()`:查询 `done = 0` 的摘要视图。 + - `getTaskBySessionID(sessionID)`:按 `session_id = ?` 查询零条或一条。 + - `getRequiredTaskBySessionID(sessionID)`:零条时报 `Current session is not bound to a task`,多条时报 `Multiple tasks are bound to session `。 + - `assignSession(taskID, sessionID)`:给 task 写入新的 `session_id`。 + +建议核心接口如下: + +```ts +export type TaskRecord = { + task_id: string; + task_spec: string; + session_id: string | null; + worktree_path: string | null; + pull_request_url: string | null; + status: string | null; + done: boolean; + updated_at: string | null; +}; + +export type TaskRepository = { + listUnfinishedTasks(): TaskRecord[]; + listProcessingTasks(): Array< + Pick< + TaskRecord, + | "task_id" + | "status" + | "session_id" + | "worktree_path" + | "pull_request_url" + | "updated_at" + > + >; + getTaskBySessionID(sessionID: string): TaskRecord | null; + getRequiredTaskBySessionID(sessionID: string): TaskRecord; + assignSession(taskID: string, sessionID: string): void; +}; +``` + +- [ ] **Step 4: 重新运行仓储测试,确认固定路径与绑定规则成立** + +Run: `pnpm exec vitest run --config vitest.workspace.ts --project repo --testNamePattern "aim.sqlite|unfinished tasks|current session id"` + +Expected: PASS。 + +- [ ] **Step 5: 提交 SQLite 仓储与绑定规则实现** + +```bash +git add .opencode/plugins/task-runtime-sqlite/database.ts .opencode/plugins/task-runtime-sqlite/task-repository.ts test/repo/sqlite-task-runtime-plugin.test.ts +git commit -m "feat: add sqlite task repository" +``` + +### Task 3: 实现状态写回工具、运行产物写回工具与 prompt 边界 + +**Files:** +- Modify: `.opencode/plugins/task-runtime-sqlite/task-repository.ts` +- Modify: `.opencode/plugins/task-runtime-sqlite/prompt-builder.ts` +- Modify: `.opencode/plugins/task-runtime-sqlite.ts` +- Modify: `test/repo/sqlite-task-runtime-plugin.test.ts` + +- [ ] **Step 1: 先写失败测试,锁定 `mark-task-status` 的参数边界、`done` 派生与 prompt 脱敏** + +在 `test/repo/sqlite-task-runtime-plugin.test.ts` 增加四类测试: + +```ts +it("marks terminal statuses as done and refreshes updated_at", () => { + const repository = createTaskRepository({ projectDir, now: () => isoNow }); + + seedTasks([ + { task_id: "task-1", task_spec: "one", done: 0, status: "running", session_id: "session-1" }, + ]); + + repository.markTaskStatus({ sessionID: "session-1", status: "succeeded" }); + + expect(repository.getRequiredTaskBySessionID("session-1")).toMatchObject({ + status: "succeeded", + done: true, + updated_at: isoNow, + }); +}); + +it("keeps non-terminal statuses unfinished", () => { + repository.markTaskStatus({ sessionID: "session-1", status: "pr_following" }); + expect(repository.getRequiredTaskBySessionID("session-1").done).toBe(false); +}); + +it("stores worktree path and pull request url for the current session", () => { + repository.setupWorktreePath({ sessionID: "session-1", worktreePath: "/repo/.worktrees/task-1" }); + repository.setupPullRequestURL({ sessionID: "session-1", pullRequestURL: "https://github.com/acme/repo/pull/1" }); +}); + +it("builds prompts from task snapshots without sqlite internals", () => { + const prompt = buildInitialTaskPrompt({ + task_id: "task-1", + task_spec: "Fix the failing repo test", + status: "created", + worktree_path: null, + pull_request_url: null, + }); + + expect(prompt).toContain("task-1"); + expect(prompt).toContain("Fix the failing repo test"); + expect(prompt).not.toContain("aim.sqlite"); + expect(prompt).not.toContain("dbPath"); + expect(prompt).not.toContain("SELECT"); + expect(prompt).not.toContain("UPDATE tasks"); +}); +``` + +同时增加一个源码约束测试,要求 `.opencode/plugins/task-runtime-sqlite.ts` 中 `mark-task-status` 的 schema 只有 `status` 一个参数键,不接受 `task_id`、`done`、`updated_at`。 + +- [ ] **Step 2: 运行状态写回与 prompt 测试,确认当前实现先失败** + +Run: `pnpm exec vitest run --config vitest.workspace.ts --project repo --testNamePattern "terminal statuses|non-terminal statuses|worktree path|pull request url|without sqlite internals|schema only"` + +Expected: FAIL,提示仓储缺少状态写回方法、prompt builder 仍未实现,或插件入口尚未锁定 `mark-task-status` 参数边界。 + +- [ ] **Step 3: 补齐仓储写回接口、prompt builder 与 task-bound tools 注册** + +1. 在 `task-repository.ts` 新增: + - `markTaskStatus({ sessionID, status })` + - `setupWorktreePath({ sessionID, worktreePath })` + - `setupPullRequestURL({ sessionID, pullRequestURL })` +2. 三个接口都必须先通过 `getRequiredTaskBySessionID(sessionID)` 解析绑定 Task,再更新对应行并写入 `updated_at = now()`。 +3. `markTaskStatus` 必须派生:`status === "succeeded" || status === "failed"` 时 `done = 1`,否则 `done = 0`。 +4. 在 `prompt-builder.ts` 导出: + - `buildInitialTaskPrompt(task)` + - `buildFollowUpTaskPrompt(task)` +5. 两个 prompt builder 都只拼装 Task 快照与“继续推进当前任务”的文字,不写任何 SQLite/SQL/`dbPath` 语句。 +6. 在 `.opencode/plugins/task-runtime-sqlite.ts` 中把 `get-current-task`、`mark-task-status`、`setup-worktree-path`、`setup-pull-request-url` 实际绑定到上述仓储方法;所有 task-bound tools 都从 `context.sessionID` 取值。 + +建议 `markTaskStatus` 的核心逻辑如下: + +```ts +const done = status === "succeeded" || status === "failed"; + +statement.run(status, done ? 1 : 0, now(), task.task_id); +``` + +- [ ] **Step 4: 重新运行状态写回与 prompt 测试,确认工具边界符合 spec** + +Run: `pnpm exec vitest run --config vitest.workspace.ts --project repo --testNamePattern "terminal statuses|non-terminal statuses|worktree path|pull request url|without sqlite internals|schema only"` + +Expected: PASS。 + +- [ ] **Step 5: 提交状态写回工具与 prompt builder** + +```bash +git add .opencode/plugins/task-runtime-sqlite.ts .opencode/plugins/task-runtime-sqlite/task-repository.ts .opencode/plugins/task-runtime-sqlite/prompt-builder.ts test/repo/sqlite-task-runtime-plugin.test.ts +git commit -m "feat: add task runtime status tools" +``` + +### Task 4: 实现 `dispatch-tasks` 的恢复、跳过、重认领与首条 prompt 流程 + +**Files:** +- Modify: `.opencode/plugins/task-runtime-sqlite/session-runtime.ts` +- Modify: `.opencode/plugins/task-runtime-sqlite/task-repository.ts` +- Modify: `.opencode/plugins/task-runtime-sqlite/prompt-builder.ts` +- Modify: `.opencode/plugins/task-runtime-sqlite.ts` +- Modify: `test/repo/sqlite-task-runtime-plugin.test.ts` + +- [ ] **Step 1: 先写 dispatch 失败测试,逐条锁定五种调度分支** + +在 `test/repo/sqlite-task-runtime-plugin.test.ts` 使用 fake session runtime(`vi.fn()` 模拟 `getSession`、`createSession`、`sendPrompt`)新增以下测试: + +```ts +it("continues an existing idle session for an unfinished task", async () => { + seedTasks([ + { task_id: "task-1", task_spec: "one", done: 0, status: "running", session_id: "session-1" }, + ]); + + sessionRuntime.getSession.mockReturnValue({ id: "session-1", busy: false }); + + const result = await dispatchTasks(); + + expect(result).toEqual([ + { taskID: "task-1", action: "continued", sessionID: "session-1" }, + ]); + expect(sessionRuntime.sendPrompt).toHaveBeenCalledWith( + "session-1", + expect.stringContaining("task-1"), + ); +}); + +it("skips a busy session without sending a duplicate prompt", async () => { + sessionRuntime.getSession.mockReturnValue({ id: "session-1", busy: true }); + await dispatchTasks(); + expect(sessionRuntime.sendPrompt).not.toHaveBeenCalled(); +}); + +it("reclaims a task when its previous session no longer exists", async () => { + sessionRuntime.getSession.mockReturnValue(null); + sessionRuntime.createSession.mockResolvedValue({ id: "session-2" }); + await dispatchTasks(); + expect(repository.getTaskBySessionID("session-2")?.task_id).toBe("task-1"); +}); + +it("creates and assigns a new session for an unbound unfinished task", async () => { + seedTasks([{ task_id: "task-2", task_spec: "two", done: 0, status: "created", session_id: null }]); + sessionRuntime.createSession.mockResolvedValue({ id: "session-3" }); + await dispatchTasks(); + expect(repository.getTaskBySessionID("session-3")?.task_id).toBe("task-2"); +}); + +it("returns processing task summaries with the required fields", () => { + expect(repository.listProcessingTasks()[0]).toEqual({ + task_id: "task-1", + status: "running", + session_id: "session-1", + worktree_path: "/repo/.worktrees/task-1", + pull_request_url: "https://github.com/acme/repo/pull/1", + updated_at: isoNow, + }); +}); +``` + +再补一个 prompt 断言,要求 follow-up prompt 以“继续推进当前 Task”为中心,但不出现 `session_id =`、`tasks` 表名、数据库路径。 + +- [ ] **Step 2: 运行 dispatch 相关测试,确认当前实现先失败** + +Run: `pnpm exec vitest run --config vitest.workspace.ts --project repo --testNamePattern "idle session|busy session|reclaims a task|creates and assigns|processing task summaries|follow-up prompt"` + +Expected: FAIL,提示 `dispatch-tasks` 逻辑、session runtime 适配层或摘要返回尚未实现。 + +- [ ] **Step 3: 在插件入口与 session runtime 中补齐调度流程** + +1. 在 `session-runtime.ts` 导出 `createSessionRuntime(host)`,最少提供: + - `getSession(sessionID)` + - `createSession()` + - `sendPrompt(sessionID, prompt)` +2. 在 `.opencode/plugins/task-runtime-sqlite.ts` 中实现 `dispatch-tasks`: + - 调 `repository.listUnfinishedTasks()`。 + - 遍历每条 task,若存在 `session_id`,先 `getSession(session_id)`。 + - session 存在且 `busy === true` 时返回 `action: "skipped_busy"`,不发 prompt。 + - session 存在且不 busy 时发送 `buildFollowUpTaskPrompt(task)`,返回 `action: "continued"`。 + - session 不存在时创建新 session、`assignSession(task.task_id, newSession.id)`、发送 `buildInitialTaskPrompt()`,返回 `action: "reclaimed"`。 + - 没有 `session_id` 时创建新 session、绑定并发送首条 prompt,返回 `action: "created"`。 +3. `list-processing-tasks` 直接返回 `repository.listProcessingTasks()` 的摘要数组,不附带数据库实现细节。 + +建议调度分支骨架如下: + +```ts +for (const task of repository.listUnfinishedTasks()) { + if (task.session_id) { + const session = await sessionRuntime.getSession(task.session_id); + + if (session?.busy) { + results.push({ taskID: task.task_id, action: "skipped_busy", sessionID: task.session_id }); + continue; + } + + if (session) { + await sessionRuntime.sendPrompt(session.id, buildFollowUpTaskPrompt(task)); + results.push({ taskID: task.task_id, action: "continued", sessionID: session.id }); + continue; + } + } + + const session = await sessionRuntime.createSession(); + repository.assignSession(task.task_id, session.id); + await sessionRuntime.sendPrompt(session.id, buildInitialTaskPrompt({ ...task, session_id: session.id })); + results.push({ taskID: task.task_id, action: task.session_id ? "reclaimed" : "created", sessionID: session.id }); +} +``` + +- [ ] **Step 4: 重新运行 dispatch 相关测试,确认恢复/跳过/重认领闭环成立** + +Run: `pnpm exec vitest run --config vitest.workspace.ts --project repo --testNamePattern "idle session|busy session|reclaims a task|creates and assigns|processing task summaries|follow-up prompt"` + +Expected: PASS。 + +- [ ] **Step 5: 提交 dispatch 调度实现** + +```bash +git add .opencode/plugins/task-runtime-sqlite.ts .opencode/plugins/task-runtime-sqlite/session-runtime.ts .opencode/plugins/task-runtime-sqlite/task-repository.ts .opencode/plugins/task-runtime-sqlite/prompt-builder.ts test/repo/sqlite-task-runtime-plugin.test.ts +git commit -m "feat: add sqlite task dispatch flow" +``` + +### Task 5: 做插件级回归验证与实现自检 + +**Files:** +- Modify: `test/repo/sqlite-task-runtime-plugin.test.ts` +- Modify: `.opencode/plugins/task-runtime-sqlite.ts` +- Modify: `.opencode/plugins/task-runtime-sqlite/database.ts` +- Modify: `.opencode/plugins/task-runtime-sqlite/task-repository.ts` +- Modify: `.opencode/plugins/task-runtime-sqlite/session-runtime.ts` +- Modify: `.opencode/plugins/task-runtime-sqlite/prompt-builder.ts` + +- [ ] **Step 1: 补一条端到端 repo 测试,串起“创建 session -> 绑定 Task -> 写回终态 done”主路径** + +在 `test/repo/sqlite-task-runtime-plugin.test.ts` 增加一条组合测试:先向测试 SQLite 插入一条未绑定 task,执行 `dispatch-tasks` 创建 session,再用同一个 `sessionID` 调 `mark-task-status("succeeded")`,最后断言该行 `done = true`、`updated_at` 已刷新、`list-processing-tasks` 不再返回该 task。示例: + +```ts +it("completes the happy path from dispatch to terminal write-back", async () => { + seedTasks([{ task_id: "task-1", task_spec: "ship the docs", done: 0, status: "created", session_id: null }]); + sessionRuntime.createSession.mockResolvedValue({ id: "session-1" }); + + await dispatchTasks(); + repository.markTaskStatus({ sessionID: "session-1", status: "succeeded" }); + + expect(repository.getRequiredTaskBySessionID("session-1")).toMatchObject({ + status: "succeeded", + done: true, + }); + expect(repository.listProcessingTasks()).toEqual([]); +}); +``` + +- [ ] **Step 2: 运行完整 repo project,确认全部 SQLite task runtime 测试一起通过** + +Run: `pnpm exec vitest run --config vitest.workspace.ts --project repo` + +Expected: PASS,包含原有 `changeset-check.test.ts` 与新增 `sqlite-task-runtime-plugin.test.ts`。 + +- [ ] **Step 3: 做最小类型与格式校验,确认新增插件文件被当前仓库接受** + +Run: `pnpm run typecheck && pnpm run test:lint` + +Expected: PASS;若 `typecheck` 或 `Biome` 只因新增插件文件报错,则仅修复 `.opencode/plugins/task-runtime-sqlite*.ts` 与 `test/repo/sqlite-task-runtime-plugin.test.ts` 中的真实类型/格式问题,不扩大范围。 + +- [ ] **Step 4: 自检 spec 覆盖与命名一致性后提交最终实现** + +自检清单: +- 六个 tools 名称是否与 spec 完全一致。 +- task-bound tools 是否全部只依赖 `sessionID`,没有任何 `task_id` 输入。 +- `mark-task-status` 是否仍只有 `status` 参数,并自动写 `updated_at` 与 `done`。 +- `dispatch-tasks` 是否覆盖恢复、busy 跳过、失效重认领、未绑定创建四条分支,并只扫描 `done = false`。 +- prompt 是否只暴露 Task 快照,没有 `aim.sqlite`、`dbPath`、SQL 细节。 + +```bash +git add .opencode/plugins/task-runtime-sqlite.ts .opencode/plugins/task-runtime-sqlite/database.ts .opencode/plugins/task-runtime-sqlite/task-repository.ts .opencode/plugins/task-runtime-sqlite/session-runtime.ts .opencode/plugins/task-runtime-sqlite/prompt-builder.ts test/repo/sqlite-task-runtime-plugin.test.ts +git commit -m "feat: finalize sqlite task runtime plugin" +``` diff --git a/docs/superpowers/specs/2026-04-18-sqlite-task-runtime-design.md b/docs/superpowers/specs/2026-04-18-sqlite-task-runtime-design.md new file mode 100644 index 0000000..d757de2 --- /dev/null +++ b/docs/superpowers/specs/2026-04-18-sqlite-task-runtime-design.md @@ -0,0 +1,339 @@ +# Task Runtime + SQLite + OpenCode Plugin 自动推进闭环设计说明 + +## 背景 / 定位 + +当前仓库已经明确了两件事: + +1. `Task` 是调度器的基本执行单元,外部真相应保存在 SQLite,而不是散落在 Session 内存、Git 状态或 GitHub 平台状态里。 +2. 一条 `Task` 的目标不是“被某个 Agent 看见”,而是从写入队列开始,被无人值守地持续推进,直到进入成功或失败终态。 + +本次设计解决的是第一刀可落地闭环:外部系统只需向 `/aim.sqlite` 写入 `task_id` 与 `task_spec`,随后由 OpenCode Plugin 自动认领、恢复、推进、写回,直到该 Task 进入终态并表现为 `done = true`。 + +这里的关键定位不是做一个通用任务平台,也不是先抽象一个大而全的 runtime core,而是先把当前仓库内“Task 如何从 SQLite 被真正推进起来”这条主路径打通,并把运行时边界尽量压缩在 OpenCode Plugin 内。 + +## 目标 + +1. 固定使用 `/aim.sqlite` 作为当前 project 的 Task SQLite 路径,不向 Agent 暴露 `dbPath` 概念。 +2. 明确 SQLite 仍是调度真相源,Plugin 只是运行时桥接层,不复制一套独立真相。 +3. 通过 OpenCode Plugin 暴露最小 tool 集,支撑 Task 的认领、查询、状态写回与运行时上下文绑定。 +4. 让所有 task-bound tools 通过当前 tool context 中的 `sessionID` 反查绑定 Task,而不是要求 Agent 显式传入 `task_id`。 +5. 让 `dispatch-tasks` 作为统一调度入口,负责扫描未终态 Task、恢复已有 Session、在需要时重新认领,并避免对 busy session 重复发 prompt。 +6. 让外部调用方只承担“写入 Task”职责,不需要额外理解 session 恢复、worktree 协调、PR URL 写回或终态判定细节。 +7. 把第一刀范围收敛在单 project、纯插件闭环,不引入额外 daemon、复杂 lease、多租户隔离或跨 project 联动。 + +## 非目标 + +1. 不支持跨 project 联动,不扫描多个仓库,也不在本次设计中引入 project registry。 +2. 不向 Agent 暴露数据库路径、数据库 schema 管理职责或任何 SQLite 实现细节。 +3. 不在本次设计中拆出独立的 task runtime core package,供多种宿主复用。 +4. 不引入仓库外常驻 daemon、消息队列、HTTP 调度服务或复杂 watcher 进程。 +5. 不设计复杂 lease、心跳抢占、分布式锁或多实例并发仲裁协议。 +6. 不在本次设计中扩展到多租户、多工作目录池或跨 Session 的复杂优先级调度策略。 +7. 不在本次设计中增加除题述六个 tools 之外的额外插件接口。 + +## 核心概念与边界 + +### 1. SQLite 是唯一调度真相源 + +`tasks` 表中的 `task_id`、`task_spec`、`session_id`、`worktree_path`、`pull_request_url`、`status`、`done`、`updated_at` 等字段,仍然是调度层需要记住的事实。Plugin 读取这些事实、帮助推进流程,并把关键结果写回 SQLite;但 Plugin 自己不维护另一份持久状态。 + +因此: + +- Task 是否未结束,以 `done = false` 为准。 +- Task 当前执行主体是谁,以 `session_id` 为准。 +- Task 当前对外可见阶段,以 `status` 为准。 +- worktree 路径和 PR URL 是否已经建立,以 SQLite 当前字段为准。 + +GitHub checks、PR mergeability、session busy/idle 等动态状态仍是运行时按需查询的外部事实,不镜像为另一套持久调度模型。 + +### 2. Plugin 是运行时桥,不是第二调度器 + +本次 Plugin 的职责是: + +1. 把 SQLite 中的待推进 Task 与 OpenCode Session 绑定起来。 +2. 把 Task 快照转换成对 Session 可执行的 prompt。 +3. 把 Session 在推进过程中产生的关键结果写回 SQLite。 +4. 在 Session 可继续时恢复推进,在 Session 不可用时允许重新认领。 + +Plugin 不负责重新定义 Task 语义,也不替代 SQLite 作出持久化真相判断。它是“把调度真相转成 Agent 可执行上下文”的运行时层。 + +### 3. Agent 只看 Task 快照,不看数据库细节 + +发给 Session 的 prompt 只能暴露该 Task 的快照,例如: + +- `task_id` +- `task_spec` +- 当前 `status` +- 当前已记录的 `worktree_path` +- 当前已记录的 `pull_request_url` + +必要时可以包含少量运行期上下文提示,例如“你当前在处理一条已绑定的 Task”,但不能向 Agent 暴露 SQLite 文件路径、表结构、SQL 更新规则或“你需要回写某张表”的数据库实现细节。 + +### 4. 当前 project 边界固定 + +本版所有读写都固定发生在当前 project 下的 `/aim.sqlite`。外部系统只需要知道“把 Task 写到当前项目数据库里”,而不需要选择数据库路径。这样可以把第一刀范围限制在单 project 内,避免系统刚起步就暴露过多可变配置面。 + +## 推荐方案与备选方案比较 + +### 推荐方案:纯插件闭环 + +推荐把“扫描 SQLite、认领 Task、向 Session 发 prompt、把状态写回 SQLite”完整收敛在 OpenCode Plugin 内,形成纯插件闭环。 + +理由如下: + +1. 当前唯一必须打通的是单 project 内的自动推进链路,Plugin 已经天然处于“能看到 Session、能发 prompt、能暴露 tools”的最短路径上。 +2. SQLite 仍然保留真相源角色,因此 Plugin 闭环不会导致状态失真,只是把运行时桥接做薄、做近。 +3. 最小 tool 集足以支撑第一刀闭环,外部只写 Task,不需要额外协调进程、服务或协议。 +4. 把运行时边界放在 Plugin 内,可以最早验证“session 绑定 + 自动恢复 + 状态写回”这组核心假设,而不先投入到更抽象的可复用架构。 + +### 备选方案一:先拆 runtime core + +可选路径是先把调度与 Session 协调逻辑抽成独立 runtime core,再让 Plugin 只做一层薄适配。 + +本次不选,原因是: + +1. 当前尚未证明稳定边界在哪里,过早抽 core 很容易把 Session 语义、宿主能力和 SQLite 约束一起抽象错位。 +2. 第一刀真正需要验证的是端到端闭环是否成立,而不是多宿主复用能力。 +3. 先拆 core 会引入额外接口面、生命周期契约和测试成本,但并不能降低当下单 project 落地复杂度。 + +### 备选方案二:先做外部 daemon + +另一条路径是让 SQLite 被外部 daemon 持续扫描,再由 daemon 调 OpenCode 或其他宿主完成推进。 + +本次不选,原因是: + +1. daemon 会立刻引入进程管理、部署方式、日志持久化、重启恢复和权限边界等额外系统问题。 +2. 当前目标并不是服务化调度,而是尽快打通当前仓库中的无人值守主路径。 +3. 在单 project 场景下,daemon 提供的能力增量远小于它带来的复杂度;很多问题只是从 Plugin 内部复杂度转移成跨进程协调复杂度。 + +结论是:第一刀以纯插件闭环作为推荐方案,先验证最短主路径;待该路径稳定后,再决定是否有必要继续抽 core 或演化为外部服务。 + +## 插件运行时架构 + +整体运行时分为四层: + +1. **外部写入层**:外部系统向 `/aim.sqlite` 写入 `task_id` 与 `task_spec`,其余字段由运行时逐步补齐。 +2. **SQLite 调度层**:`tasks` 表保存当前 Task 的真相,包括绑定的 `session_id`、状态字段与运行产物路径。 +3. **Plugin 运行时层**:Plugin 提供调度入口、Task 绑定解析、状态写回与上下文补全。 +4. **Session 执行层**:OpenCode Session 接收 Task 快照 prompt,按仓库规范推进 worktree、提交、PR、跟进与收尾。 + +关键数据流如下: + +1. 外部写入新 Task,初始为 `done = false`。 +2. `dispatch-tasks` 扫描所有 `done = false` 的 Task。 +3. 对已绑定 `session_id` 的 Task,Plugin 先尝试继续该 Session;如果 Session 不存在或不可恢复,则允许重新认领。 +4. 对未绑定 `session_id` 的 Task,Plugin 自动创建新 Session,回写 `session_id`,并投递首条 prompt。 +5. Session 在推进过程中通过 task-bound tools 回写 `status`、`worktree_path`、`pull_request_url`。 +6. 当 Task 被标记为 `succeeded` 或 `failed` 时,Plugin 自动同步 `done = true`,形成终态闭环。 + +该架构的关键约束是:Task 的执行上下文绑定在 Session 上,而不是绑定在每次 tool 调用显式携带的 `task_id` 上。这样可以让 Agent 在 task-bound 工具层面始终面向“我当前在处理的那条 Task”,减少参数漂移和误写风险。 + +## Tool 契约 + +本次插件只暴露以下最小 tool 集。 + +### 1. `dispatch-tasks` + +定位:统一调度入口。 + +职责: + +1. 扫描 SQLite 中所有 `done = false` 的 Task。 +2. 优先尝试恢复已有 `session_id` 绑定的 Task。 +3. 当绑定 Session 不存在时,允许重新认领该 Task。 +4. 对尚未绑定 `session_id` 的 Task 自动创建 Session 并投递首条 prompt。 +5. 对已存在且 busy 的 Session 不重复投递 prompt。 + +返回结果应至少能说明本次调度对每条 Task 的处理结果,例如:继续已有 Session、跳过 busy Session、重新认领、创建新 Session、无可调度任务。 + +### 2. `list-processing-tasks` + +定位:观察当前处理中 Task 的最小只读窗口。 + +最少返回字段: + +- `task_id` +- `status` +- `session_id` +- `worktree_path` +- `pull_request_url` +- `updated_at` + +这里的“processing”指当前 `done = false` 且已经进入运行态观察范围的 Task。该工具的目标是提供运维和排查视图,而不是暴露完整数据库行。 + +### 3. `get-current-task` + +定位:返回当前 tool context 对应 Session 绑定的 Task 快照。 + +该工具不接受 `task_id` 参数。Plugin 必须通过当前 context 中的 `sessionID` 反查 SQLite 中绑定到该 Session 的那条 Task,并返回给 Agent 可见的任务快照。 + +返回内容应包含推进当前 Task 所需的最小上下文,例如:`task_id`、`task_spec`、当前 `status`、当前 `worktree_path`、当前 `pull_request_url`。 + +### 4. `mark-task-status` + +定位:让当前 Session 对绑定 Task 写回阶段状态。 + +参数约束: + +- 仅允许一个参数:`status` + +写回规则: + +1. Plugin 自动维护 `updated_at`。 +2. 当 `status` 为 `succeeded` 或 `failed` 时,自动写 `done = true`。 +3. 其他状态统一写 `done = false`。 + +该工具不接受 `task_id`、`done`、`updated_at` 等参数,避免 Agent 直接操纵终态布尔值或时间戳。 + +### 5. `setup-worktree-path` + +定位:把当前 Session 实际使用的 worktree 路径写回绑定 Task。 + +该工具的目标是让 SQLite 真相及时获得“当前 Task 的隔离执行环境在哪里”,便于恢复、观察与后续排查。 + +### 6. `setup-pull-request-url` + +定位:把当前 Session 创建或跟进的 PR URL 写回绑定 Task。 + +该工具的目标是让 SQLite 记录远端出站结果,并为后续 PR 跟进、状态恢复和人工排查保留稳定锚点。 + +## Task 绑定规则 + +所有 task-bound tools 都遵循同一条绑定规则: + +1. 从当前 tool context 读取 `sessionID`。 +2. 使用该 `sessionID` 查询 SQLite 中 `session_id` 等于它的 Task。 +3. 若能唯一定位,则该 Task 视为当前 Session 的绑定 Task。 +4. 若不存在绑定 Task,工具应显式报错,而不是要求 Agent 额外传 `task_id`。 +5. 若出现多条 Task 绑定同一 `session_id`,应视为运行时数据异常并显式失败上报。 + +这样设计的原因是:Task 绑定属于运行时上下文真相,而不是让 Agent 每次手工传参的应用层协议。只要 Session 已绑定 Task,工具就应自动找到正确目标。 + +## `dispatch-tasks` 调度流程 + +`dispatch-tasks` 是本次闭环的唯一调度入口,推荐流程如下: + +1. 读取 `/aim.sqlite` 中所有 `done = false` 的 Task。 +2. 按稳定顺序遍历这些 Task;排序策略可以简单,以 `updated_at` 或创建顺序为主,但不在本次设计中引入复杂优先级系统。 +3. 对每条 Task,先检查其是否已有 `session_id`。 +4. 若已有 `session_id`,先查询该 Session 是否仍存在。 +5. 若 Session 存在且当前 busy,则跳过,不重复发送 prompt。 +6. 若 Session 存在且可继续推进,则向该 Session 发送下一次跟进 prompt。 +7. 若 Session 不存在,则允许重新认领:创建新 Session,更新 SQLite 中的 `session_id`,并发送首条恢复 prompt。 +8. 若 Task 尚未绑定 `session_id`,则直接创建新 Session、写回 `session_id` 并发送首条 prompt。 + +这里有两个关键行为要求: + +1. “继续已有 Session”优先于“重新认领”。只要旧 Session 仍可用,就尽量利用其上下文连续性。 +2. busy Session 不重复 prompt。Plugin 必须把“当前是否适合再发一次消息”当成调度条件之一,避免对同一 Task 并发注入多条推进指令。 + +该流程不依赖外部 daemon。每次显式调用 `dispatch-tasks`,都等价于触发一次“扫描并尽力推进所有未终态 Task”的调度轮次。 + +## 状态写回规则 + +本次设计把状态写回收敛到最小协议,避免 Agent 直接操作过多持久字段。 + +### 1. `status` 是 Agent 唯一可显式写入的阶段字段 + +Agent 只能通过 `mark-task-status(status)` 写入当前阶段,例如: + +- `created` +- `running` +- `outbound` +- `pr_following` +- `closing` +- `succeeded` +- `failed` + +是否继续保留 `waiting_assumptions` 这类中间状态,由 SQLite 模型与实现阶段决定;本 spec 的硬约束是:无论使用哪些非终态值,`mark-task-status` 仍然只接收 `status` 一个参数。 + +### 2. `done` 由插件派生,不由 Agent 直接写入 + +写回语义固定为: + +- `status in {succeeded, failed}` -> `done = true` +- 其他状态 -> `done = false` + +这样可以避免 Agent 产生“状态写成 running,但 done 误设为 true”这类自相矛盾状态。 + +### 3. `updated_at` 由插件自动维护 + +任一写回动作发生时,Plugin 自动更新时间戳。Agent 不需要也不应该感知时间戳细节。 + +### 4. 结构化运行产物单独写回 + +`worktree_path` 与 `pull_request_url` 分别通过专用工具写回,而不是塞进 `mark-task-status`。这样可以保持 `status` 写回协议单一,也让观察视图更稳定。 + +## 自动跟进 Prompt 策略 + +### 1. 首条 prompt 只暴露 Task 快照 + +新认领 Task 时,Plugin 发送给 Session 的首条 prompt 应包含: + +1. 当前绑定的 Task 标识与快照。 +2. `task_spec` 原文或必要的完整内容。 +3. 当前已知状态、worktree 路径与 PR URL(若已有)。 +4. 为恢复当前 Task 推进所必需的最小上下文提示。 + +首条 prompt 不暴露 SQLite 路径、表名、SQL 规则、字段更新语义等数据库实现细节。 + +### 2. 跟进 prompt 仍以 Task 快照为中心 + +后续由 `dispatch-tasks` 对已有 Session 发出的跟进 prompt,也应继续围绕“当前这条 Task 需要继续推进什么”。可包含: + +- 当前 `status` +- 当前已记录的 `worktree_path` +- 当前已记录的 `pull_request_url` +- 调度器观察到该 Task 尚未终态,需要继续推进 + +但不应把 prompt 变成数据库操作说明书,也不应要求 Agent 自己理解“如何根据表字段判断下一步是否该写 SQL”。 + +### 3. Prompt 目标是恢复闭环,不是暴露内部机制 + +无论首条 prompt 还是跟进 prompt,其目标都应是帮助 Session 恢复“当前任务推进到了哪里、下一步应该做什么”,而不是让 Agent 学会 Plugin 内部实现。数据库细节应由 Plugin 吞掉,Task 快照才是 Agent 的工作合同。 + +## 错误与异常边界 + +本次设计只覆盖第一刀必须说明的异常边界。 + +### 1. SQLite 不可读写 + +若 `/aim.sqlite` 不存在、不可打开或 schema 不满足要求,Plugin 应直接报错并停止本轮调度,而不是尝试猜测替代路径或自动切到其他数据库。 + +### 2. Session 绑定失真 + +若某个 `session_id` 无法定位 Session,则 `dispatch-tasks` 可重新认领该 Task;若 task-bound tool 在当前 context 下找不到绑定 Task,则该 tool 调用应失败并显式暴露“当前 Session 未绑定 Task”。 + +### 3. busy Session 不重复注入 prompt + +若 Session 仍在处理中,则本轮跳过即可。这不是错误,而是正常并发保护行为。 + +### 4. 多条 Task 绑定同一 `session_id` + +这属于 SQLite 真相损坏或运行时写入异常。相关工具应停止并上报,不应让 Agent 在歧义绑定下继续执行。 + +### 5. Task 进入终态但 Session 仍存在 + +只要 `status` 已写成 `succeeded` 或 `failed`,Plugin 即视为该 Task 在 SQLite 层进入终态;后续是否保留 Session、如何回收 Session,不属于本次设计的核心范围。 + +### 6. 范围外系统问题不上升为本次设计扩展 + +若后续实现中发现需要跨 project 扫描、复杂 session lease、常驻 daemon 或更强并发仲裁,均应视为下一阶段设计议题,而不是在本次 spec 内预埋半套服务化机制。 + +## 验证重点 + +后续实现至少需要证明以下设计决策成立: + +1. 外部只写 `task_id` 和 `task_spec` 到 `/aim.sqlite`,就能触发完整推进闭环。 +2. `dispatch-tasks` 能正确扫描 `done = false` 的 Task,并优先恢复已有 Session。 +3. 对不存在的旧 Session,系统可以重新认领并继续推进,而不是让 Task 永久卡死在历史 `session_id` 上。 +4. busy Session 不会被重复 prompt。 +5. `get-current-task`、`mark-task-status`、`setup-worktree-path`、`setup-pull-request-url` 都能通过当前 `sessionID` 唯一绑定到正确 Task,而无需 Agent 传 `task_id`。 +6. `mark-task-status` 只接受 `status` 参数,且插件能自动维护 `updated_at` 与 `done` 派生关系。 +7. `list-processing-tasks` 至少能稳定返回 `task_id/status/session_id/worktree_path/pull_request_url/updated_at`,满足观察与排查需求。 +8. 发给 Session 的 prompt 只暴露 Task 快照,不暴露数据库实现细节。 +9. 整体实现保持在单 project、纯插件闭环范围内,没有偷偷引入 daemon、跨 project 路由或复杂调度服务。 + +## 实施边界提醒 + +后续实现必须严格以“`/aim.sqlite` + 最小插件 tool 集 + 自动推进闭环”为边界。以下方向都属于 scope drift:把数据库路径开放成可配置能力、为 Agent 暴露 `task_id` 传参协议、引入跨 project 扫描、抽象通用 runtime core、加入外部 daemon、设计复杂 lease 或分布式抢占。若实现中发现确实需要跨越这些边界,必须单独升级决策,而不是在当前任务内顺手扩展。 diff --git a/test/repo/sqlite-task-runtime-plugin.test.ts b/test/repo/sqlite-task-runtime-plugin.test.ts new file mode 100644 index 0000000..5f0f5b7 --- /dev/null +++ b/test/repo/sqlite-task-runtime-plugin.test.ts @@ -0,0 +1,1302 @@ +import { access, mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + getTaskRuntimeDatabasePath, + openTaskRuntimeDatabase, + type TaskRecord, +} from "../../.opencode/plugins/task-runtime-sqlite/database.ts"; +import { + buildFollowUpTaskPrompt, + buildInitialTaskPrompt, +} from "../../.opencode/plugins/task-runtime-sqlite/prompt-builder.ts"; +import { createSessionRuntime } from "../../.opencode/plugins/task-runtime-sqlite/session-runtime.ts"; +import { createTaskRepository } from "../../.opencode/plugins/task-runtime-sqlite/task-repository.ts"; + +const createTaskDatabase = async (caseName: string) => { + const projectDir = join( + process.cwd(), + "test/repo/.tmp/sqlite-task-runtime-plugin", + caseName, + ); + + await rm(projectDir, { force: true, recursive: true }); + await mkdir(projectDir, { recursive: true }); + + const database = openTaskRuntimeDatabase(projectDir); + database.exec(` + CREATE TABLE tasks ( + task_id TEXT NOT NULL, + task_spec TEXT NOT NULL, + session_id TEXT, + worktree_path TEXT, + pull_request_url TEXT, + status TEXT, + done INTEGER NOT NULL DEFAULT 0, + updated_at TEXT + ) + `); + + const seedTasks = ( + tasks: Array< + Partial & Pick + >, + ) => { + const insert = database.prepare(` + INSERT INTO tasks ( + task_id, + task_spec, + session_id, + worktree_path, + pull_request_url, + status, + done, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + + for (const task of tasks) { + insert.run( + task.task_id, + task.task_spec, + task.session_id ?? null, + task.worktree_path ?? null, + task.pull_request_url ?? null, + task.status ?? null, + task.done === undefined ? 0 : task.done ? 1 : 0, + task.updated_at ?? null, + ); + } + }; + + const cleanup = async () => { + database.close(); + await rm(projectDir, { force: true, recursive: true }); + }; + + return { cleanup, projectDir, seedTasks }; +}; + +describe("sqlite task runtime plugin", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.doUnmock("../../.opencode/plugins/task-runtime-sqlite/database.ts"); + vi.doUnmock( + "../../.opencode/plugins/task-runtime-sqlite/task-repository.ts", + ); + vi.doUnmock( + "../../.opencode/plugins/task-runtime-sqlite/session-runtime.ts", + ); + vi.doUnmock( + "../../.opencode/plugins/task-runtime-sqlite/prompt-builder.ts", + ); + }); + + it("registers the approved six tools on the plugin entrypoint", async () => { + const { createSqliteTaskRuntimePlugin } = await import( + "../../.opencode/plugins/task-runtime-sqlite.ts" + ); + const plugin = createSqliteTaskRuntimePlugin({ projectDir: process.cwd() }); + + const registeredTools = Object.keys(plugin.tools); + + expect(registeredTools).toEqual([ + "dispatch-tasks", + "list-processing-tasks", + "get-current-task", + "mark-task-status", + "setup-worktree-path", + "setup-pull-request-url", + ]); + }); + + it("does not eagerly initialize task runtime collaborators", async () => { + const createTaskRuntimeDatabase = vi.fn(() => { + throw new Error("should not initialize database for scaffold"); + }); + const createTaskRepository = vi.fn(() => { + throw new Error("should not initialize repository for scaffold"); + }); + const createSessionRuntime = vi.fn(() => { + throw new Error("should not initialize session runtime for scaffold"); + }); + const createPromptBuilder = vi.fn(() => { + throw new Error("should not initialize prompt builder for scaffold"); + }); + + vi.doMock( + "../../.opencode/plugins/task-runtime-sqlite/database.ts", + () => ({ + createTaskRuntimeDatabase, + }), + ); + vi.doMock( + "../../.opencode/plugins/task-runtime-sqlite/task-repository.ts", + () => ({ + createTaskRepository, + TASK_STATUSES: [ + "created", + "running", + "outbound", + "pr_following", + "closing", + "succeeded", + "failed", + ], + }), + ); + vi.doMock( + "../../.opencode/plugins/task-runtime-sqlite/session-runtime.ts", + () => ({ createSessionRuntime }), + ); + vi.doMock( + "../../.opencode/plugins/task-runtime-sqlite/prompt-builder.ts", + () => ({ createPromptBuilder }), + ); + + const { createSqliteTaskRuntimePlugin } = await import( + "../../.opencode/plugins/task-runtime-sqlite.ts" + ); + + expect(() => + createSqliteTaskRuntimePlugin({ projectDir: process.cwd() }), + ).not.toThrow(); + expect(createTaskRuntimeDatabase).not.toHaveBeenCalled(); + expect(createTaskRepository).not.toHaveBeenCalled(); + expect(createSessionRuntime).not.toHaveBeenCalled(); + expect(createPromptBuilder).not.toHaveBeenCalled(); + }); + + it("opens aim.sqlite under the project directory", async () => { + const projectDir = join( + process.cwd(), + "test/repo/.tmp/sqlite-task-runtime-plugin/database-helper", + ); + + await rm(projectDir, { force: true, recursive: true }); + await mkdir(projectDir, { recursive: true }); + + try { + const database = openTaskRuntimeDatabase(projectDir); + database.close(); + + await expect( + access(getTaskRuntimeDatabasePath(projectDir)), + ).resolves.toBeUndefined(); + } finally { + await rm(projectDir, { force: true, recursive: true }); + } + }); + + it("always resolves aim.sqlite under the project directory", () => { + expect(getTaskRuntimeDatabasePath("/repo/worktree")).toBe( + "/repo/worktree/aim.sqlite", + ); + }); + + it("lists only unfinished tasks from sqlite", async () => { + const taskDatabase = await createTaskDatabase("unfinished-tasks"); + + try { + const repository = createTaskRepository({ + now: () => "2026-04-18T00:00:00.000Z", + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { task_id: "task-1", task_spec: "one", done: 0, status: "created" }, + { + task_id: "task-2", + task_spec: "two", + done: 1, + status: "succeeded", + }, + ]); + + expect(repository.listUnfinishedTasks()).toEqual([ + expect.objectContaining({ task_id: "task-1", done: false }), + ]); + } finally { + await taskDatabase.cleanup(); + } + }); + + it("lists processing task summaries from unfinished tasks", async () => { + const taskDatabase = await createTaskDatabase("processing-tasks"); + + try { + const repository = createTaskRepository({ + now: () => "2026-04-18T00:00:00.000Z", + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { + task_id: "task-1", + task_spec: "one", + session_id: "session-1", + worktree_path: "/repo/.worktrees/task-1", + pull_request_url: "https://example.com/pr/1", + status: "running", + done: 0, + updated_at: "2026-04-18T01:00:00.000Z", + }, + { + task_id: "task-2", + task_spec: "two", + status: "succeeded", + done: 1, + }, + ]); + + expect(repository.listProcessingTasks()).toEqual([ + { + task_id: "task-1", + session_id: "session-1", + worktree_path: "/repo/.worktrees/task-1", + pull_request_url: "https://example.com/pr/1", + status: "running", + updated_at: "2026-04-18T01:00:00.000Z", + }, + ]); + } finally { + await taskDatabase.cleanup(); + } + }); + + it("resolves exactly one task for the current session id", async () => { + const taskDatabase = await createTaskDatabase("task-binding"); + + try { + const repository = createTaskRepository({ + now: () => "2026-04-18T00:00:00.000Z", + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { + task_id: "task-1", + task_spec: "one", + done: 0, + status: "running", + session_id: "session-1", + }, + ]); + + expect(repository.getTaskBySessionID("session-1")?.task_id).toBe( + "task-1", + ); + expect(repository.getTaskBySessionID("missing-session")).toBeUndefined(); + expect(() => + repository.getRequiredTaskBySessionID("missing-session"), + ).toThrow("Current session is not bound to a task"); + } finally { + await taskDatabase.cleanup(); + } + }); + + it("fails when multiple tasks are bound to the same session id", async () => { + const taskDatabase = await createTaskDatabase("duplicate-session-binding"); + + try { + const repository = createTaskRepository({ + now: () => "2026-04-18T00:00:00.000Z", + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { + task_id: "task-1", + task_spec: "one", + done: 0, + status: "running", + session_id: "session-1", + }, + { + task_id: "task-2", + task_spec: "two", + done: 0, + status: "running", + session_id: "session-1", + }, + ]); + + expect(() => repository.getRequiredTaskBySessionID("session-1")).toThrow( + "Multiple tasks are bound to session session-1", + ); + expect(() => repository.getTaskBySessionID("session-1")).toThrow( + "Multiple tasks are bound to session session-1", + ); + } finally { + await taskDatabase.cleanup(); + } + }); + + it("assigns a session id to a task", async () => { + const taskDatabase = await createTaskDatabase("assign-session"); + + try { + const repository = createTaskRepository({ + now: () => "2026-04-18T12:34:56.000Z", + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { task_id: "task-1", task_spec: "one", done: 0 }, + ]); + + repository.assignSession("task-1", "session-1"); + + expect(repository.getRequiredTaskBySessionID("session-1")).toMatchObject({ + session_id: "session-1", + task_id: "task-1", + updated_at: "2026-04-18T12:34:56.000Z", + }); + } finally { + await taskDatabase.cleanup(); + } + }); + + it("marks terminal statuses as done and refreshes updated_at", async () => { + const taskDatabase = await createTaskDatabase("mark-terminal-status"); + + try { + const repository = createTaskRepository({ + now: () => "2026-04-18T12:34:56.000Z", + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { + task_id: "task-1", + task_spec: "one", + done: 0, + status: "running", + session_id: "session-1", + }, + ]); + + repository.markTaskStatus({ + sessionID: "session-1", + status: "succeeded", + }); + + expect(repository.getRequiredTaskBySessionID("session-1")).toMatchObject({ + status: "succeeded", + done: true, + updated_at: "2026-04-18T12:34:56.000Z", + }); + } finally { + await taskDatabase.cleanup(); + } + }); + + it("keeps non-terminal statuses unfinished", async () => { + const taskDatabase = await createTaskDatabase("mark-non-terminal-status"); + + try { + const repository = createTaskRepository({ + now: () => "2026-04-18T12:34:56.000Z", + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { + task_id: "task-1", + task_spec: "one", + done: 0, + status: "running", + session_id: "session-1", + }, + ]); + + repository.markTaskStatus({ + sessionID: "session-1", + status: "pr_following", + }); + + expect(repository.getRequiredTaskBySessionID("session-1")).toMatchObject({ + status: "pr_following", + done: false, + updated_at: "2026-04-18T12:34:56.000Z", + }); + } finally { + await taskDatabase.cleanup(); + } + }); + + it("rejects statuses outside the approved task status set", async () => { + const taskDatabase = await createTaskDatabase("reject-invalid-status"); + + try { + const repository = createTaskRepository({ + now: () => "2026-04-18T12:34:56.000Z", + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { + task_id: "task-1", + task_spec: "one", + done: 0, + status: "running", + session_id: "session-1", + }, + ]); + + expect(() => + repository.markTaskStatus({ + sessionID: "session-1", + status: "unknown_status", + }), + ).toThrow("Unsupported task status: unknown_status"); + } finally { + await taskDatabase.cleanup(); + } + }); + + it("does not roll a terminal task back to unfinished", async () => { + const taskDatabase = await createTaskDatabase("preserve-terminal-status"); + + try { + const repository = createTaskRepository({ + now: () => "2026-04-18T12:34:56.000Z", + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { + task_id: "task-1", + task_spec: "one", + done: 1, + status: "succeeded", + session_id: "session-1", + updated_at: "2026-04-18T00:00:00.000Z", + }, + ]); + + expect(() => + repository.markTaskStatus({ + sessionID: "session-1", + status: "running", + }), + ).toThrow("Cannot move terminal task back to non-terminal status"); + + expect(repository.getRequiredTaskBySessionID("session-1")).toMatchObject({ + status: "succeeded", + done: true, + updated_at: "2026-04-18T00:00:00.000Z", + }); + } finally { + await taskDatabase.cleanup(); + } + }); + + it("rejects overwriting succeeded with failed", async () => { + const taskDatabase = await createTaskDatabase("reject-succeeded-to-failed"); + + try { + const repository = createTaskRepository({ + now: () => "2026-04-18T12:34:56.000Z", + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { + task_id: "task-1", + task_spec: "one", + done: 1, + status: "succeeded", + session_id: "session-1", + updated_at: "2026-04-18T00:00:00.000Z", + }, + ]); + + expect(() => + repository.markTaskStatus({ + sessionID: "session-1", + status: "failed", + }), + ).toThrow("Cannot overwrite terminal task status"); + + expect(repository.getRequiredTaskBySessionID("session-1")).toMatchObject({ + status: "succeeded", + done: true, + updated_at: "2026-04-18T00:00:00.000Z", + }); + } finally { + await taskDatabase.cleanup(); + } + }); + + it("rejects overwriting failed with succeeded", async () => { + const taskDatabase = await createTaskDatabase("reject-failed-to-succeeded"); + + try { + const repository = createTaskRepository({ + now: () => "2026-04-18T12:34:56.000Z", + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { + task_id: "task-1", + task_spec: "one", + done: 1, + status: "failed", + session_id: "session-1", + updated_at: "2026-04-18T00:00:00.000Z", + }, + ]); + + expect(() => + repository.markTaskStatus({ + sessionID: "session-1", + status: "succeeded", + }), + ).toThrow("Cannot overwrite terminal task status"); + + expect(repository.getRequiredTaskBySessionID("session-1")).toMatchObject({ + status: "failed", + done: true, + updated_at: "2026-04-18T00:00:00.000Z", + }); + } finally { + await taskDatabase.cleanup(); + } + }); + + it("stores worktree path and pull request url for the current session", async () => { + const taskDatabase = await createTaskDatabase("runtime-artifacts"); + + try { + const repository = createTaskRepository({ + now: () => "2026-04-18T12:34:56.000Z", + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { + task_id: "task-1", + task_spec: "one", + done: 0, + status: "running", + session_id: "session-1", + }, + ]); + + repository.setupWorktreePath({ + sessionID: "session-1", + worktreePath: "/repo/.worktrees/task-1", + }); + repository.setupPullRequestURL({ + sessionID: "session-1", + pullRequestURL: "https://github.com/acme/repo/pull/1", + }); + + expect(repository.getRequiredTaskBySessionID("session-1")).toMatchObject({ + worktree_path: "/repo/.worktrees/task-1", + pull_request_url: "https://github.com/acme/repo/pull/1", + updated_at: "2026-04-18T12:34:56.000Z", + }); + } finally { + await taskDatabase.cleanup(); + } + }); + + it("builds prompts from task snapshots without sqlite internals", () => { + const task = { + task_id: "task-1", + task_spec: "Fix the failing repo test", + session_id: "session-1", + worktree_path: "/repo/.worktrees/task-1", + pull_request_url: "https://github.com/acme/repo/pull/1", + status: "running", + done: false, + updated_at: "2026-04-18T12:34:56.000Z", + } satisfies TaskRecord; + + const initialPrompt = buildInitialTaskPrompt(task); + const followUpPrompt = buildFollowUpTaskPrompt(task); + + expect(initialPrompt).toContain("task-1"); + expect(initialPrompt).toContain("Fix the failing repo test"); + expect(followUpPrompt).toContain("继续推进当前 Task"); + + for (const prompt of [initialPrompt, followUpPrompt]) { + expect(prompt).not.toContain("aim.sqlite"); + expect(prompt).not.toContain("dbPath"); + expect(prompt).not.toContain("SELECT"); + expect(prompt).not.toContain("UPDATE tasks"); + } + }); + + it("renders multiline task specs inside a fenced block", () => { + const task = { + task_id: "task-1", + task_spec: [ + "First line", + "status: forged-field", + "worktree_path: /tmp/not-a-real-field", + ].join("\n"), + session_id: "session-1", + worktree_path: "/repo/.worktrees/task-1", + pull_request_url: null, + status: "running", + done: false, + updated_at: "2026-04-18T12:34:56.000Z", + } satisfies TaskRecord; + + const prompt = buildInitialTaskPrompt(task); + + expect(prompt).toContain("task_spec:\n```text\nFirst line"); + expect(prompt).toContain("status: running"); + expect(prompt).toContain("worktree_path: /repo/.worktrees/task-1"); + expect(prompt).toContain("status: forged-field"); + expect(prompt).toContain("worktree_path: /tmp/not-a-real-field"); + expect(prompt).toContain("```\nstatus: running"); + }); + + it("uses a stable fence when task specs contain triple backticks", () => { + const task = { + task_id: "task-1", + task_spec: ["Before fence", "```", "Inside spec fence"].join("\n"), + session_id: "session-1", + worktree_path: "/repo/.worktrees/task-1", + pull_request_url: null, + status: "running", + done: false, + updated_at: "2026-04-18T12:34:56.000Z", + } satisfies TaskRecord; + + const prompt = buildInitialTaskPrompt(task); + + expect(prompt).toContain( + "task_spec:\n````text\nBefore fence\n```\nInside spec fence\n````", + ); + expect(prompt).toContain("````\nstatus: running"); + }); + + it("exposes only status in the mark-task-status schema", async () => { + const { createSqliteTaskRuntimePlugin } = await import( + "../../.opencode/plugins/task-runtime-sqlite.ts" + ); + const plugin = createSqliteTaskRuntimePlugin({ projectDir: process.cwd() }); + const markTaskStatusTool = plugin.tools["mark-task-status"] as { + schema?: Record; + }; + + expect(Object.keys(markTaskStatusTool.schema ?? {})).toEqual(["status"]); + expect(markTaskStatusTool.schema).toMatchObject({ + status: [ + "created", + "running", + "outbound", + "pr_following", + "closing", + "succeeded", + "failed", + ], + }); + expect(markTaskStatusTool.schema).not.toHaveProperty("task_id"); + expect(markTaskStatusTool.schema).not.toHaveProperty("done"); + expect(markTaskStatusTool.schema).not.toHaveProperty("updated_at"); + }); + + it("binds task-bound tools to the current session id", async () => { + const taskDatabase = await createTaskDatabase("task-bound-tools"); + + try { + const { createSqliteTaskRuntimePlugin } = await import( + "../../.opencode/plugins/task-runtime-sqlite.ts" + ); + const plugin = createSqliteTaskRuntimePlugin({ + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { + task_id: "task-1", + task_spec: "one", + done: 0, + status: "running", + session_id: "session-1", + }, + ]); + + await ( + plugin.tools["setup-worktree-path"] as { + execute: ( + input: { worktreePath: string }, + context: { sessionID: string }, + ) => Promise | unknown; + } + ).execute( + { worktreePath: "/repo/.worktrees/task-1" }, + { sessionID: "session-1" }, + ); + + await ( + plugin.tools["setup-pull-request-url"] as { + execute: ( + input: { pullRequestURL: string }, + context: { sessionID: string }, + ) => Promise | unknown; + } + ).execute( + { pullRequestURL: "https://github.com/acme/repo/pull/1" }, + { sessionID: "session-1" }, + ); + + expect( + await ( + plugin.tools["get-current-task"] as { + execute: ( + input: Record, + context: { sessionID: string }, + ) => Promise | TaskRecord; + } + ).execute({}, { sessionID: "session-1" }), + ).toMatchObject({ + task_id: "task-1", + worktree_path: "/repo/.worktrees/task-1", + pull_request_url: "https://github.com/acme/repo/pull/1", + }); + + await ( + plugin.tools["mark-task-status"] as { + execute: ( + input: { status: string }, + context: { sessionID: string }, + ) => Promise | unknown; + } + ).execute({ status: "succeeded" }, { sessionID: "session-1" }); + + const repository = createTaskRepository({ + now: () => "2026-04-18T12:34:56.000Z", + projectDir: taskDatabase.projectDir, + }); + + expect(repository.getRequiredTaskBySessionID("session-1")).toMatchObject({ + status: "succeeded", + done: true, + }); + } finally { + await taskDatabase.cleanup(); + } + }); + + it("keeps only name and tools on the plugin public surface", async () => { + const { createSqliteTaskRuntimePlugin } = await import( + "../../.opencode/plugins/task-runtime-sqlite.ts" + ); + const plugin = createSqliteTaskRuntimePlugin({ projectDir: process.cwd() }); + + expect(Object.keys(plugin)).toEqual(["name", "tools"]); + }); + + it("continues an existing idle session for an unfinished task", async () => { + const taskDatabase = await createTaskDatabase("dispatch-continued-idle"); + + try { + const sessionRuntime = { + createSession: vi.fn(), + getSession: vi.fn().mockResolvedValue({ busy: false, id: "session-1" }), + sendPrompt: vi.fn().mockResolvedValue(undefined), + }; + const { createSqliteTaskRuntimePlugin } = await import( + "../../.opencode/plugins/task-runtime-sqlite.ts" + ); + const plugin = createSqliteTaskRuntimePlugin({ + host: sessionRuntime, + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { + task_id: "task-1", + task_spec: "Continue the queued task", + done: 0, + session_id: "session-1", + status: "running", + }, + ]); + + const result = await ( + plugin.tools["dispatch-tasks"] as { + execute: () => Promise; + } + ).execute(); + + expect(result).toEqual([ + { action: "continued", sessionID: "session-1", taskID: "task-1" }, + ]); + expect(sessionRuntime.getSession).toHaveBeenCalledWith("session-1"); + expect(sessionRuntime.createSession).not.toHaveBeenCalled(); + expect(sessionRuntime.sendPrompt).toHaveBeenCalledWith( + "session-1", + expect.stringContaining("继续推进当前 Task"), + ); + } finally { + await taskDatabase.cleanup(); + } + }); + + it("skips a busy session without sending a duplicate prompt", async () => { + const taskDatabase = await createTaskDatabase("dispatch-skipped-busy"); + + try { + const sessionRuntime = { + createSession: vi.fn(), + getSession: vi.fn().mockResolvedValue({ busy: true, id: "session-1" }), + sendPrompt: vi.fn().mockResolvedValue(undefined), + }; + const { createSqliteTaskRuntimePlugin } = await import( + "../../.opencode/plugins/task-runtime-sqlite.ts" + ); + const plugin = createSqliteTaskRuntimePlugin({ + host: sessionRuntime, + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { + task_id: "task-1", + task_spec: "Keep waiting", + done: 0, + session_id: "session-1", + status: "running", + }, + ]); + + const result = await ( + plugin.tools["dispatch-tasks"] as { + execute: () => Promise; + } + ).execute(); + + expect(result).toEqual([ + { + action: "skipped_busy", + sessionID: "session-1", + taskID: "task-1", + }, + ]); + expect(sessionRuntime.createSession).not.toHaveBeenCalled(); + expect(sessionRuntime.sendPrompt).not.toHaveBeenCalled(); + } finally { + await taskDatabase.cleanup(); + } + }); + + it("reclaims a task when its previous session no longer exists", async () => { + const taskDatabase = await createTaskDatabase("dispatch-reclaimed"); + + try { + const sessionRuntime = { + createSession: vi.fn().mockResolvedValue({ id: "session-2" }), + getSession: vi.fn().mockResolvedValue(null), + sendPrompt: vi.fn().mockResolvedValue(undefined), + }; + const { createSqliteTaskRuntimePlugin } = await import( + "../../.opencode/plugins/task-runtime-sqlite.ts" + ); + const plugin = createSqliteTaskRuntimePlugin({ + host: sessionRuntime, + projectDir: taskDatabase.projectDir, + }); + const repository = createTaskRepository({ + now: () => "2026-04-18T12:34:56.000Z", + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { + task_id: "task-1", + task_spec: "Resume after session loss", + done: 0, + session_id: "session-1", + status: "running", + }, + ]); + + const result = await ( + plugin.tools["dispatch-tasks"] as { + execute: () => Promise; + } + ).execute(); + + expect(result).toEqual([ + { action: "reclaimed", sessionID: "session-2", taskID: "task-1" }, + ]); + expect(repository.getTaskBySessionID("session-2")?.task_id).toBe( + "task-1", + ); + expect(sessionRuntime.sendPrompt).toHaveBeenCalledWith( + "session-2", + expect.stringContaining("Resume after session loss"), + ); + } finally { + await taskDatabase.cleanup(); + } + }); + + it("creates and assigns a new session for an unbound unfinished task", async () => { + const taskDatabase = await createTaskDatabase("dispatch-created"); + + try { + const sessionRuntime = { + createSession: vi.fn().mockResolvedValue({ id: "session-3" }), + getSession: vi.fn(), + sendPrompt: vi.fn().mockResolvedValue(undefined), + }; + const { createSqliteTaskRuntimePlugin } = await import( + "../../.opencode/plugins/task-runtime-sqlite.ts" + ); + const plugin = createSqliteTaskRuntimePlugin({ + host: sessionRuntime, + projectDir: taskDatabase.projectDir, + }); + const repository = createTaskRepository({ + now: () => "2026-04-18T12:34:56.000Z", + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { + task_id: "task-2", + task_spec: "Create a fresh session", + done: 0, + session_id: null, + status: "created", + }, + ]); + + const result = await ( + plugin.tools["dispatch-tasks"] as { + execute: () => Promise; + } + ).execute(); + + expect(result).toEqual([ + { action: "created", sessionID: "session-3", taskID: "task-2" }, + ]); + expect(sessionRuntime.getSession).not.toHaveBeenCalled(); + expect(repository.getTaskBySessionID("session-3")?.task_id).toBe( + "task-2", + ); + expect(sessionRuntime.sendPrompt).toHaveBeenCalledWith( + "session-3", + expect.stringContaining("Create a fresh session"), + ); + } finally { + await taskDatabase.cleanup(); + } + }); + + it("marks a dispatched task as succeeded and removes it from processing summaries", async () => { + const taskDatabase = await createTaskDatabase( + "dispatch-created-then-succeeded", + ); + + vi.useFakeTimers(); + + try { + const repository = createTaskRepository({ + now: () => "2026-04-18T12:35:56.000Z", + projectDir: taskDatabase.projectDir, + }); + const sessionRuntime = { + createSession: vi.fn().mockResolvedValue({ id: "session-3" }), + sendPrompt: vi.fn().mockImplementation(async (sessionID: string) => { + expect( + repository.getRequiredTaskBySessionID(sessionID), + ).toMatchObject({ + task_id: "task-2", + session_id: sessionID, + }); + }), + }; + const { createSqliteTaskRuntimePlugin } = await import( + "../../.opencode/plugins/task-runtime-sqlite.ts" + ); + const plugin = createSqliteTaskRuntimePlugin({ + host: sessionRuntime, + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { + task_id: "task-2", + task_spec: "Create a fresh session", + done: 0, + session_id: null, + status: "created", + updated_at: "2026-04-18T10:00:00.000Z", + }, + ]); + + vi.setSystemTime(new Date("2026-04-18T12:34:56.000Z")); + await ( + plugin.tools["dispatch-tasks"] as { + execute: () => Promise; + } + ).execute(); + + vi.setSystemTime(new Date("2026-04-18T12:35:56.000Z")); + await ( + plugin.tools["mark-task-status"] as { + execute: ( + input: { status: string }, + context: { sessionID: string }, + ) => Promise | unknown; + } + ).execute({ status: "succeeded" }, { sessionID: "session-3" }); + + expect(repository.getRequiredTaskBySessionID("session-3")).toMatchObject({ + task_id: "task-2", + status: "succeeded", + done: true, + updated_at: "2026-04-18T12:35:56.000Z", + }); + expect(repository.listProcessingTasks()).toEqual([]); + } finally { + vi.useRealTimers(); + await taskDatabase.cleanup(); + } + }); + + it("does not keep a new session binding when initial prompt delivery fails", async () => { + const taskDatabase = await createTaskDatabase( + "dispatch-created-prompt-failure", + ); + + try { + const sessionRuntime = { + createSession: vi.fn().mockResolvedValue({ id: "session-3" }), + sendPrompt: vi.fn().mockRejectedValue(new Error("prompt failed")), + }; + const { createSqliteTaskRuntimePlugin } = await import( + "../../.opencode/plugins/task-runtime-sqlite.ts" + ); + const plugin = createSqliteTaskRuntimePlugin({ + host: sessionRuntime, + projectDir: taskDatabase.projectDir, + }); + const repository = createTaskRepository({ + now: () => "2026-04-18T12:34:56.000Z", + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { + task_id: "task-2", + task_spec: "Create a fresh session", + done: 0, + session_id: null, + status: "created", + }, + ]); + + await expect( + ( + plugin.tools["dispatch-tasks"] as { + execute: () => Promise; + } + ).execute(), + ).rejects.toThrow("prompt failed"); + + expect(repository.getTaskBySessionID("session-3")).toBeUndefined(); + expect(repository.listUnfinishedTasks()).toEqual([ + expect.objectContaining({ session_id: null, task_id: "task-2" }), + ]); + } finally { + await taskDatabase.cleanup(); + } + }); + + it("does not replace the previous session binding when reclaim prompt delivery fails", async () => { + const taskDatabase = await createTaskDatabase( + "dispatch-reclaimed-prompt-failure", + ); + + try { + const sessionRuntime = { + createSession: vi.fn().mockResolvedValue({ id: "session-2" }), + getSession: vi.fn().mockResolvedValue(null), + sendPrompt: vi.fn().mockRejectedValue(new Error("prompt failed")), + }; + const { createSqliteTaskRuntimePlugin } = await import( + "../../.opencode/plugins/task-runtime-sqlite.ts" + ); + const plugin = createSqliteTaskRuntimePlugin({ + host: sessionRuntime, + projectDir: taskDatabase.projectDir, + }); + const repository = createTaskRepository({ + now: () => "2026-04-18T12:34:56.000Z", + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { + task_id: "task-1", + task_spec: "Resume after session loss", + done: 0, + session_id: "session-1", + status: "running", + }, + ]); + + await expect( + ( + plugin.tools["dispatch-tasks"] as { + execute: () => Promise; + } + ).execute(), + ).rejects.toThrow("prompt failed"); + + expect(repository.getTaskBySessionID("session-2")).toBeUndefined(); + expect(repository.getTaskBySessionID("session-1")).toEqual( + expect.objectContaining({ task_id: "task-1", session_id: "session-1" }), + ); + } finally { + await taskDatabase.cleanup(); + } + }); + + it("dispatches only unfinished tasks at the plugin level", async () => { + const taskDatabase = await createTaskDatabase("dispatch-only-unfinished"); + + try { + const sessionRuntime = { + createSession: vi + .fn() + .mockResolvedValueOnce({ id: "session-1" }) + .mockResolvedValueOnce({ id: "session-2" }), + sendPrompt: vi.fn().mockResolvedValue(undefined), + }; + const { createSqliteTaskRuntimePlugin } = await import( + "../../.opencode/plugins/task-runtime-sqlite.ts" + ); + const plugin = createSqliteTaskRuntimePlugin({ + host: sessionRuntime, + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { + task_id: "task-1", + task_spec: "still running", + done: 0, + session_id: null, + status: "created", + }, + { + task_id: "task-2", + task_spec: "already finished", + done: 1, + session_id: null, + status: "succeeded", + }, + ]); + + const result = await ( + plugin.tools["dispatch-tasks"] as { + execute: () => Promise; + } + ).execute(); + + expect(result).toEqual([ + { action: "created", sessionID: "session-1", taskID: "task-1" }, + ]); + expect(sessionRuntime.createSession).toHaveBeenCalledTimes(1); + expect(sessionRuntime.sendPrompt).toHaveBeenCalledTimes(1); + expect(sessionRuntime.sendPrompt).toHaveBeenCalledWith( + "session-1", + expect.stringContaining("still running"), + ); + } finally { + await taskDatabase.cleanup(); + } + }); + + it("treats a missing getSession host hook as a missing session", async () => { + const runtime = createSessionRuntime({ + createSession: async () => ({ id: "session-1" }), + sendPrompt: async () => {}, + }); + + await expect(runtime.getSession("missing-session")).resolves.toBeNull(); + }); + + it("returns processing task summaries with the required fields", async () => { + const taskDatabase = await createTaskDatabase( + "dispatch-processing-summary", + ); + + try { + const { createSqliteTaskRuntimePlugin } = await import( + "../../.opencode/plugins/task-runtime-sqlite.ts" + ); + const plugin = createSqliteTaskRuntimePlugin({ + projectDir: taskDatabase.projectDir, + }); + + taskDatabase.seedTasks([ + { + task_id: "task-1", + task_spec: "one", + session_id: "session-1", + worktree_path: "/repo/.worktrees/task-1", + pull_request_url: "https://github.com/acme/repo/pull/1", + status: "running", + done: 0, + updated_at: "2026-04-18T01:00:00.000Z", + }, + ]); + + expect( + ( + plugin.tools["list-processing-tasks"] as { + execute: () => Promise | unknown; + } + ).execute(), + ).toEqual([ + { + pull_request_url: "https://github.com/acme/repo/pull/1", + session_id: "session-1", + status: "running", + task_id: "task-1", + updated_at: "2026-04-18T01:00:00.000Z", + worktree_path: "/repo/.worktrees/task-1", + }, + ]); + } finally { + await taskDatabase.cleanup(); + } + }); + + it("keeps follow-up prompts focused on continuing the current task", () => { + const task = { + task_id: "task-1", + task_spec: "Drive the current task forward", + session_id: "session-1", + worktree_path: "/repo/.worktrees/task-1", + pull_request_url: "https://github.com/acme/repo/pull/1", + status: "running", + done: false, + updated_at: "2026-04-18T12:34:56.000Z", + } satisfies TaskRecord; + + const prompt = buildFollowUpTaskPrompt(task); + + expect(prompt).toContain("继续推进当前 Task"); + expect(prompt).toContain("task-1"); + expect(prompt).not.toContain("session_id ="); + expect(prompt).not.toContain("tasks"); + expect(prompt).not.toContain("aim.sqlite"); + }); +});