diff --git a/src/kernel/session-index.ts b/src/kernel/session-index.ts new file mode 100644 index 0000000..6be1b8c --- /dev/null +++ b/src/kernel/session-index.ts @@ -0,0 +1,164 @@ +/** + * Flow → session 轻量索引(替代 session-state.json)。 + * 路径:.opencode/opencode-cabbage/session-index.json + * 作用:parentIssueNumber → sessionID 绑定;双 session 检测(R7)。 + */ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises" +import path from "node:path" +import { PLUGIN_ID } from "../util/paths.js" + +export type FlowSessionStatus = "active" | "paused" | "completed" | "cancelled" + +export interface FlowSessionEntry { + sessionID: string + status: FlowSessionStatus + continuationCount: number + updatedAt: number +} + +export interface SessionIndex { + flows: Record +} + +export function sessionIndexPath(projectDir: string): string { + return path.join(projectDir, ".opencode", PLUGIN_ID, "session-index.json") +} + +/** 读取索引;文件不存在或损坏时返回空索引。 */ +export async function readIndex(projectDir: string): Promise { + try { + const parsed = JSON.parse(await readFile(sessionIndexPath(projectDir), "utf8")) as { flows?: unknown } + if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && parsed.flows && typeof parsed.flows === "object") { + return parsed as SessionIndex + } + return { flows: {} } + } catch { + return { flows: {} } + } +} + +/** 原子写:先写临时文件再 rename。 */ +export async function writeIndex(projectDir: string, index: SessionIndex): Promise { + const target = sessionIndexPath(projectDir) + const tmp = `${target}.tmp` + await mkdir(path.dirname(target), { recursive: true }) + await writeFile(tmp, JSON.stringify(index, null, 2), "utf8") + await rename(tmp, target) +} + +export function getFlowSession(index: SessionIndex, parentIssueNumber: number): FlowSessionEntry | null { + return index.flows[String(parentIssueNumber)] ?? null +} + +export type BindSessionResult = + | { ok: true; entry: FlowSessionEntry } + | { ok: false; code: "FLOW_SESSION_CONFLICT"; message: string } + | { ok: false; code: "TAKEOVER_NOT_CONFIRMED"; message: string } + +/** + * 绑定 parentIssueNumber → sessionID。 + * 双 session 检测(§10.3):该 flow 已被另一 active session 绑定时拒绝。 + */ +export async function bindSession( + projectDir: string, + parentIssueNumber: number, + sessionID: string, +): Promise { + const index = await readIndex(projectDir) + const key = String(parentIssueNumber) + const existing = index.flows[key] + + if (existing && existing.sessionID !== sessionID && existing.status === "active") { + return { + ok: false, + code: "FLOW_SESSION_CONFLICT", + message: `Flow #${parentIssueNumber} is already bound to active session "${existing.sessionID}". Use flow_control takeover with user confirmation to take over.`, + } + } + + const entry: FlowSessionEntry = { + sessionID, + status: "active", + continuationCount: existing && existing.sessionID === sessionID ? existing.continuationCount : 0, + updatedAt: Date.now(), + } + index.flows[key] = entry + await writeIndex(projectDir, index) + return { ok: true, entry } +} + +/** + * 接管绑定:旧 session 置 paused,新 session 绑定 active(R7)。 + * 旧 session 为另一 active session 时要求 userConfirmed(§10.3); + * 调用方还需将旧 session 的 goal status 置 paused(goalClient 层)。 + */ +export async function takeoverSession( + projectDir: string, + parentIssueNumber: number, + newSessionID: string, + userConfirmed: boolean, +): Promise { + const index = await readIndex(projectDir) + const key = String(parentIssueNumber) + const existing = index.flows[key] + + if (existing && existing.sessionID !== newSessionID && existing.status === "active" && !userConfirmed) { + return { + ok: false, + code: "TAKEOVER_NOT_CONFIRMED", + message: `Flow #${parentIssueNumber} is bound to active session "${existing.sessionID}". Takeover requires user confirmation (user_confirmed: true).`, + } + } + + if (existing) { + if (existing.sessionID === newSessionID) { + const entry: FlowSessionEntry = { ...existing, status: "active", updatedAt: Date.now() } + index.flows[key] = entry + await writeIndex(projectDir, index) + return { ok: true, entry } + } + existing.status = "paused" + existing.updatedAt = Date.now() + } + + const entry: FlowSessionEntry = { + sessionID: newSessionID, + status: "active", + continuationCount: 0, + updatedAt: Date.now(), + } + index.flows[key] = entry + await writeIndex(projectDir, index) + return { ok: true, entry } +} + +/** 更新已绑定 flow 的状态/计数;flow 未绑定时返回 null 且不创建 entry。 */ +export async function updateFlowSession( + projectDir: string, + parentIssueNumber: number, + patch: Partial>, +): Promise { + const index = await readIndex(projectDir) + const key = String(parentIssueNumber) + const existing = index.flows[key] + if (!existing) return null + const entry: FlowSessionEntry = { ...existing, ...patch, updatedAt: Date.now() } + index.flows[key] = entry + await writeIndex(projectDir, index) + return entry +} + +/** 解绑:仅当 sessionID 与当前绑定匹配时删除 entry。 */ +export async function unbindSession( + projectDir: string, + parentIssueNumber: number, + sessionID: string, +): Promise { + const index = await readIndex(projectDir) + const key = String(parentIssueNumber) + const existing = index.flows[key] + if (!existing || existing.sessionID !== sessionID) return false + delete index.flows[key] + await writeIndex(projectDir, index) + return true +} diff --git a/src/plugin/goal.ts b/src/plugin/goal.ts index ac04cdb..f782d0b 100644 --- a/src/plugin/goal.ts +++ b/src/plugin/goal.ts @@ -2,21 +2,19 @@ import type { ToolContext, ToolResult } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin/tool" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import type { Session } from "@opencode-ai/sdk/v2" -import type { GoalFlowRunRef } from "../flowrun/types.js" export type GoalStatus = "active" | "paused" | "complete" +/** 最小化 goal(§10.2):目标与验收条件从 Flow Record(Parent Issue body)读取 */ export interface GoalData { - objective: string - completionCriterion: string + parentIssueNumber: number status: GoalStatus continuationCount: number } -function createGoal(objective: string, completionCriterion: string): GoalData { +export function createGoal(parentIssueNumber: number): GoalData { return { - objective: objective.trim(), - completionCriterion: completionCriterion.trim(), + parentIssueNumber, status: "active", continuationCount: 0, } @@ -33,26 +31,24 @@ export function canTransitionTo(goal: GoalData, target: GoalStatus): boolean { export function formatGoal(goal: GoalData): string { return [ - `Goal: ${goal.objective}`, - `Completion criterion: ${goal.completionCriterion}`, + `Flow: #${goal.parentIssueNumber}`, `Status: ${goal.status}`, + `Continuations: ${goal.continuationCount}`, + `Objective/acceptance: read from Flow Record (Parent Issue #${goal.parentIssueNumber})`, ].join("\n") } export const MAX_CONTINUATIONS = 50 -export function continuationPrompt(objective: string, completionCriterion: string): string { +export function continuationPrompt(parentIssueNumber: number): string { return `Continue working toward the active goal. - -${objective} - +Read the Flow Record (Parent Issue #${parentIssueNumber}) for the objective and completion criteria. - -${completionCriterion} - + +#${parentIssueNumber} + -Keep the full objective intact. Do not redefine success around a smaller or easier task. Work from evidence — inspect the current state before relying on anything. If the work is not done, just keep working. Do not narrate that you are continuing — execute.` } @@ -64,22 +60,23 @@ You are the only agent authorized to call goal({op:"complete"}). Other agents (r You start with a FRESH context — do not assume any prior work was done correctly. -First step: Call goal({op:"get"}) to retrieve the objective and completion criterion. +First step: Call goal({op:"get"}) to retrieve the active flow's parent issue number, then read the Flow Record (Parent Issue body) for the objective and completion criteria. --- ## Verification Procedure -1. Call goal({op:"get"}) to retrieve the objective and completion criterion. -2. Break them into concrete, individual requirements. -3. For EACH requirement, gather evidence: +1. Call goal({op:"get"}) to retrieve the parent issue number. +2. Read the Flow Record (Parent Issue body) for the objective and completion criteria. +3. Break them into concrete, individual requirements. +4. For EACH requirement, gather evidence: - Read full files — not just snippets - Run tests, builds, lint - Check imports, exports, types resolve correctly -4. Classify each finding: SATISFIED / NOT SATISFIED / UNCERTAIN -5. If ALL requirements are SATISFIED: +5. Classify each finding: SATISFIED / NOT SATISFIED / UNCERTAIN +6. If ALL requirements are SATISFIED: Call goal({op:"complete"}) — only you can do this -6. If ANY requirement is NOT SATISFIED or UNCERTAIN: +7. If ANY requirement is NOT SATISFIED or UNCERTAIN: Do NOT call goal({op:"complete"}). Return a detailed report. --- @@ -127,111 +124,14 @@ export async function writeGoal( await client.session.update({ sessionID, metadata }) } -// ─── GoalFlowRunRef 绑定 ─── - -/** - * 读取 session metadata 中的 GoalFlowRunRef。 - * 返回 null 表示尚未绑定。 - */ -export async function readFlowRunRef( - client: ReturnType, - sessionID: string, -): Promise { - try { - const result = await client.session.get({ sessionID }) - const session = (result as { data?: Session | null })?.data ?? null - const ref = session?.metadata?.flowRunRef as GoalFlowRunRef | undefined - return ref ?? null - } catch { - return null - } -} - -/** - * Goal → FlowRun 绑定结果。 - */ -export type BindFlowRunResult = - | { ok: true } - | { ok: false; code: "GOAL_FLOW_CONFLICT"; message: string } - -/** - * 原子绑定 Goal → FlowRun。 - * - * 规则: - * - 未绑定 → 写入 flowRunRef,返回 ok - * - 已绑定同一 FlowRun(repo + parentIssueNumber + flowRunId 相同)→ 幂等,返回 ok - * - 已绑定不同 FlowRun → 返回 GOAL_FLOW_CONFLICT - */ -export async function bindFlowRunRef( - client: ReturnType, - sessionID: string, - ref: GoalFlowRunRef, -): Promise { - const result = await client.session.get({ sessionID }) - const session = (result as { data?: Session | null })?.data ?? null - if (!session) return { ok: false, code: "GOAL_FLOW_CONFLICT", message: "Session not found" } - - const existingRef = session.metadata?.flowRunRef as GoalFlowRunRef | undefined - - if (existingRef) { - const isSame = - existingRef.repo === ref.repo && - existingRef.parentIssueNumber === ref.parentIssueNumber && - existingRef.flowRunId === ref.flowRunId - - if (!isSame) { - return { - ok: false, - code: "GOAL_FLOW_CONFLICT", - message: `Goal is already bound to FlowRun "${existingRef.flowRunId}", cannot bind to "${ref.flowRunId}"`, - } - } - // 同一 FlowRun → 幂等 - return { ok: true } - } - - // 未绑定 → 写入 - const existing: Record = session.metadata ?? {} - const metadata = { ...existing, flowRunRef: ref } - await client.session.update({ sessionID, metadata }) - - return { ok: true } -} - -/** - * 解析 GoalFlowRunRef 的显示名称。 - */ -export function formatFlowRunRef(ref: GoalFlowRunRef): string { - return `${ref.repo}#${ref.parentIssueNumber} (${ref.flowRunId})` -} - -/** - * 验证 FlowRun 终态 — 检查是否满足 Goal complete 的前提条件。 - * - * 返回 null 表示允许完成; - * 返回错误信息字符串表示阻止完成。 - * - * @param flowRunStatus FlowRun 当前状态,null 表示无绑定的 FlowRun - */ -export function checkFlowRunBlockers(flowRunStatus: string | null): string | null { - if (flowRunStatus === null) return null // 无 FlowRun 绑定,允许完成 - - if (flowRunStatus !== "completed" && flowRunStatus !== "cancelled") { - return `FlowRun is not in terminal state (status: ${flowRunStatus}). Run flow_control({op:"run-finalize"}) to finalize first.` - } - - return null -} - export function createGoalTool( client: ReturnType, - onBeforeComplete?: (parentSessionID: string) => Promise, ) { return tool({ - description: `Manage the active goal-mode objective. + description: `Manage the active goal-mode objective (minimal: parentIssueNumber/status/continuationCount). Use a single op field: -- create: starts a goal. Requires both objective and completion_criterion. +- create: starts a goal bound to a Flow Record. Requires parent_issue_number. - get: returns the current goal. - resume: re-activates a paused goal. - cancel: discards the current goal. @@ -239,8 +139,7 @@ Use a single op field: - complete: marks the goal as completed. Follow the returned instructions.`, args: { op: tool.schema.enum(["create", "get", "complete", "resume", "cancel", "pause"]).describe("Goal operation"), - objective: tool.schema.string().describe("Goal objective (required for create)"), - completion_criterion: tool.schema.string().describe("Concrete, checkable conditions (required for create)"), + parent_issue_number: tool.schema.number().describe("Parent GitHub Issue number of the Flow Record (required for create)"), }, async execute(args: Record, ctx: ToolContext): Promise { const sessionID = (ctx as any).sessionID as string @@ -264,30 +163,21 @@ Use a single op field: return `Parent session goal is not active (status: ${parent.goal.status}).` } - // 验证 FlowRun 终态(如绑定了 FlowRunRef) - if (onBeforeComplete) { - const blockReason = await onBeforeComplete(targetSessionID) - if (blockReason !== null) { - return `Goal completion blocked: ${blockReason}` - } - } - parent.goal.status = "complete" await writeGoal(client, targetSessionID, parent.goal, parent.session) - return `Goal completed and verified: "${parent.goal.objective}"` + return `Goal completed and verified: Flow #${parent.goal.parentIssueNumber}` } switch (args.op) { case "create": { - if (!args.objective?.trim()) return "Error: objective is required" - if (!args.completion_criterion?.trim()) return "Error: completion_criterion is required" + if (!args.parent_issue_number) return "Error: parent_issue_number is required" const existing = (await readGoal(client, sessionID)).goal if (existing?.status === "active") { - return `Error: an active goal already exists: "${existing.objective}"` + return `Error: an active goal already exists for Flow #${existing.parentIssueNumber}` } - const goal = createGoal(args.objective, args.completion_criterion) + const goal = createGoal(Number(args.parent_issue_number)) await writeGoal(client, sessionID, goal) - return `Goal created: "${goal.objective}"\nStatus: active` + return `Goal created: Flow #${goal.parentIssueNumber}\nStatus: active` } case "get": { @@ -309,7 +199,7 @@ Use a single op field: goal.status = "active" goal.continuationCount = 0 await writeGoal(client, sessionID, goal, s) - return `Goal resumed: "${goal.objective}"\nStatus: active` + return `Goal resumed: Flow #${goal.parentIssueNumber}\nStatus: active` } case "pause": { @@ -318,14 +208,14 @@ Use a single op field: if (!canTransitionTo(goal, "paused")) return `Goal cannot be paused (status: ${goal.status}).` goal.status = "paused" await writeGoal(client, sessionID, goal, s) - return `Goal paused: "${goal.objective}"\nStatus: paused` + return `Goal paused: Flow #${goal.parentIssueNumber}\nStatus: paused` } case "cancel": { const { goal, session: s } = await readGoal(client, sessionID) if (!goal) return "No goal to cancel." await writeGoal(client, sessionID, null, s) - return `Goal cancelled: "${goal.objective}"` + return `Goal cancelled: Flow #${goal.parentIssueNumber}` } default: diff --git a/src/plugin/server.ts b/src/plugin/server.ts index 70ebe58..77e9b37 100644 --- a/src/plugin/server.ts +++ b/src/plugin/server.ts @@ -1,8 +1,6 @@ import type { Plugin } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin/tool" import path from "node:path" -import { readFile, writeFile, mkdir } from "node:fs/promises" -import { existsSync } from "node:fs" import { initPrompts } from "./prompts.js" import { initBootstrap, getBootstrapContent } from "./bootstrap.js" @@ -10,7 +8,8 @@ import { loadCommands } from "./commands.js" import { setupSkillsDir } from "./skills.js" import { loadAgents } from "./agents.js" import { createIsolatedShellEnv, detectAmbientCredentials } from "./shell.js" -import { createGoalClient, createGoalTool, readGoal, writeGoal, bindFlowRunRef, readFlowRunRef, checkFlowRunBlockers, MAX_CONTINUATIONS, continuationPrompt, formatGoal } from "./goal.js" +import { createGoalClient, createGoalTool, readGoal, writeGoal, MAX_CONTINUATIONS, continuationPrompt, formatGoal } from "./goal.js" +import { readIndex, updateFlowSession } from "../kernel/session-index.js" import { FlowBroker } from "./broker.js" import { flowRunStart, @@ -20,7 +19,6 @@ import { flowRunFinalize, } from "../flowrun/transitions.js" import type { TaskExecutionBinding, FlowControlResponse } from "../flowrun/types.js" -import { readFlowRun } from "../flowrun/github.js" const abortedSessions = new Set() const errorRetryCount = new Map() @@ -53,35 +51,6 @@ export function configureGoalTools(config: GoalToolConfig): void { } } -function sessionStatePath(projectDir: string) { - return path.join(projectDir, ".opencode", "opencode-cabbage", "session-state.json") -} - -async function saveSessionState(projectDir: string, sessionID: string, goal: { status: string }) { - const dir = path.dirname(sessionStatePath(projectDir)) - if (!existsSync(dir)) await mkdir(dir, { recursive: true }) - await writeFile(sessionStatePath(projectDir), JSON.stringify({ - sessionID, - status: goal.status, - updatedAt: Date.now(), - }), "utf8") -} - -async function loadLastSession(projectDir: string): Promise { - try { - const data = JSON.parse(await readFile(sessionStatePath(projectDir), "utf8")) - return data.status === "active" ? data.sessionID : null - } catch { - return null - } -} - -async function clearSessionState(projectDir: string) { - try { - await writeFile(sessionStatePath(projectDir), JSON.stringify({ status: "completed", updatedAt: Date.now() }), "utf8") - } catch {} -} - async function queueContinuation( client: ReturnType, sessionID: string, @@ -90,25 +59,28 @@ async function queueContinuation( const { goal } = await readGoal(client, sessionID) if (!goal || goal.status !== "active") return - if (projectDir) saveSessionState(projectDir, sessionID, goal) + if (projectDir) { + await updateFlowSession(projectDir, goal.parentIssueNumber, { status: goal.status, continuationCount: goal.continuationCount }) + } if (abortedSessions.has(sessionID)) { abortedSessions.delete(sessionID) goal.status = "paused" await writeGoal(client, sessionID, goal) - if (projectDir) clearSessionState(projectDir) + if (projectDir) await updateFlowSession(projectDir, goal.parentIssueNumber, { status: "paused" }) return } if (goal.continuationCount >= MAX_CONTINUATIONS) { goal.status = "paused" await writeGoal(client, sessionID, goal) - if (projectDir) clearSessionState(projectDir) + if (projectDir) await updateFlowSession(projectDir, goal.parentIssueNumber, { status: "paused" }) return } goal.continuationCount++ await writeGoal(client, sessionID, goal) + if (projectDir) await updateFlowSession(projectDir, goal.parentIssueNumber, { continuationCount: goal.continuationCount }) try { if (goal.continuationCount > 0 && goal.continuationCount % COMPACTION_THRESHOLD === 0) { @@ -122,7 +94,7 @@ async function queueContinuation( await client.session.promptAsync({ sessionID, - parts: [{ type: "text" as const, text: continuationPrompt(goal.objective, goal.completionCriterion), synthetic: true }], + parts: [{ type: "text" as const, text: continuationPrompt(goal.parentIssueNumber), synthetic: true }], }) } catch (err) { console.error("[cabbage] continuation failed:", err) @@ -130,26 +102,29 @@ async function queueContinuation( } async function autoResume(client: ReturnType, projectDir: string) { - const lastSessionID = await loadLastSession(projectDir) - if (!lastSessionID) return + const index = await readIndex(projectDir) - const { goal } = await readGoal(client, lastSessionID) - if (!goal || goal.status !== "active") { - await clearSessionState(projectDir) - return - } + for (const [parentIssueNumber, entry] of Object.entries(index.flows)) { + if (entry.status !== "active") continue - try { - await client.session.promptAsync({ - sessionID: lastSessionID, - parts: [{ - type: "text" as const, - text: `[auto-resume] Plugin restarted. Resuming previous goal:\n\n${formatGoal(goal)}\n\nContinue working.`, - synthetic: true, - }], - }) - } catch (err) { - console.error("[cabbage] auto-resume failed:", err) + const { goal } = await readGoal(client, entry.sessionID) + if (!goal || goal.status !== "active") { + await updateFlowSession(projectDir, Number(parentIssueNumber), { status: "paused" }) + continue + } + + try { + await client.session.promptAsync({ + sessionID: entry.sessionID, + parts: [{ + type: "text" as const, + text: `[auto-resume] Plugin restarted. Resuming previous goal:\n\n${formatGoal(goal)}\n\nContinue working.`, + synthetic: true, + }], + }) + } catch (err) { + console.error("[cabbage] auto-resume failed:", err) + } } } @@ -182,7 +157,6 @@ function startPeriodicCleanup() { function createFlowControlTool( broker: FlowBroker, - goalClient: ReturnType, ) { return tool({ description: `Control the FlowRun lifecycle: start a FlowRun, transition stages, start tasks, and finalize completed runs. @@ -208,14 +182,13 @@ Operations: tdd_policy_json: tool.schema.string().optional().describe("JSON string of frozen TDD policy (for task-start)"), }, async execute(args, ctx) { - const sessionID = (ctx as any).sessionID as string const op = args.op as string const parentIssueNumber = args.parent_issue_number as number try { switch (op) { case "run-start": - return handleRunStart(broker, goalClient, parentIssueNumber, sessionID) + return handleRunStart(broker, parentIssueNumber) case "stage-start": return handleStageStart(broker, parentIssueNumber, args.stage as string) case "stage-complete": @@ -246,9 +219,7 @@ function errorResponse(code: string, message: string): string { async function handleRunStart( broker: FlowBroker, - goalClient: ReturnType, parentIssueNumber: number, - sessionID: string, ): Promise { // 通过 broker 执行状态迁移 const writeResult = await broker.writeFlowRunWithLock(parentIssueNumber, (flowRun) => { @@ -268,18 +239,6 @@ async function handleRunStart( return errorResponse("INVALID_TRANSITION", "FlowRun is not in planned state") } - // 绑定 Goal → FlowRun - const actualFlowRunId = writeResult.flowRun.flowRunId - const bindResult = await bindFlowRunRef(goalClient, sessionID, { - repo: writeResult.flowRun.repo, - parentIssueNumber, - flowRunId: actualFlowRunId, - }) - - if (!bindResult.ok) { - return errorResponse(bindResult.code, bindResult.message) - } - return okResponse({ flowRunStatus: writeResult.flowRun.status, }) @@ -454,20 +413,9 @@ export function createOpencodeCabbage(packageRoot: string): Plugin { const projectDir = ctx.worktree || ctx.directory const v1Client = (ctx.client as unknown as V1ClientContainer)._client const goalClient = createGoalClient(ctx.serverUrl, v1Client) - const goalTool = createGoalTool(goalClient, async (parentSessionID) => { - // 验证绑定的 FlowRun 是否已终态 - const ref = await readFlowRunRef(goalClient, parentSessionID) - if (!ref) return null // 无 FlowRun 绑定,允许完成 - - // 读取 FlowRun 状态 - const flowResult = await readFlowRun(ref.parentIssueNumber) - if (!flowResult.ok) { - return `Failed to read FlowRun #${ref.parentIssueNumber}: ${flowResult.code}` - } - return checkFlowRunBlockers(flowResult.data.status) - }) + const goalTool = createGoalTool(goalClient) const broker = new FlowBroker() - const flowControlTool = createFlowControlTool(broker, goalClient) + const flowControlTool = createFlowControlTool(broker) const agentsDir = path.join(packageRoot, "assets", "agents") @@ -579,7 +527,7 @@ export function createOpencodeCabbage(packageRoot: string): Plugin { sessionID, parts: [{ type: "text" as const, - text: `[auto-retry] Previous attempt failed. Try a different approach.\n\nGoal: ${goal.objective}`, + text: `[auto-retry] Previous attempt failed. Try a different approach.\n\n${formatGoal(goal)}`, synthetic: true, }], }) @@ -588,7 +536,7 @@ export function createOpencodeCabbage(packageRoot: string): Plugin { sessionID, parts: [{ type: "text" as const, - text: `[skip] Skipping failed step. Continue with remaining work.\n\nGoal: ${goal.objective}`, + text: `[skip] Skipping failed step. Continue with remaining work.\n\n${formatGoal(goal)}`, synthetic: true, }], }) @@ -613,7 +561,7 @@ export function createOpencodeCabbage(packageRoot: string): Plugin { if (sessionID) { const { goal } = await readGoal(goalClient, sessionID) if (goal?.status === "complete") { - await clearSessionState(projectDir) + await updateFlowSession(projectDir, goal.parentIssueNumber, { status: "completed" }) } } } diff --git a/test/goal.test.ts b/test/goal.test.ts index ed9bcf2..097c6ae 100644 --- a/test/goal.test.ts +++ b/test/goal.test.ts @@ -1,13 +1,25 @@ import { describe, it, expect } from "vitest" -import { canTransitionTo, formatGoal, continuationPrompt, verifyAgentPrompt, MAX_CONTINUATIONS, checkFlowRunBlockers } from "../src/plugin/goal.js" +import { canTransitionTo, formatGoal, continuationPrompt, verifyAgentPrompt, MAX_CONTINUATIONS, createGoal } from "../src/plugin/goal.js" const activeGoal = () => ({ - objective: "Implement user authentication", - completionCriterion: "All auth tests pass, PR merged", + parentIssueNumber: 42, status: "active" as const, continuationCount: 0, }) +describe("GoalData 最小化", () => { + it("createGoal 只含 parentIssueNumber/status/continuationCount", () => { + const goal = createGoal(42) + expect(goal).toEqual({ parentIssueNumber: 42, status: "active", continuationCount: 0 }) + }) + + it("不再存储 objective/completionCriterion(从 Flow Record 读取)", () => { + const goal = createGoal(42) + expect("objective" in goal).toBe(false) + expect("completionCriterion" in goal).toBe(false) + }) +}) + describe("canTransitionTo", () => { it("allows active -> paused", () => { expect(canTransitionTo(activeGoal(), "paused")).toBe(true) @@ -44,19 +56,19 @@ describe("canTransitionTo", () => { }) describe("formatGoal", () => { - it("includes objective, criterion, and status", () => { + it("包含 parentIssueNumber 与 status,不含 objective", () => { const result = formatGoal(activeGoal()) - expect(result).toContain("Goal: Implement user authentication") - expect(result).toContain("Completion criterion: All auth tests pass, PR merged") + expect(result).toContain("#42") expect(result).toContain("Status: active") + expect(result).not.toContain("objective") }) }) describe("continuationPrompt", () => { - it("includes objective and criterion", () => { - const result = continuationPrompt("My objective", "My criterion") - expect(result).toContain("My objective") - expect(result).toContain("My criterion") + it("引用 Flow Record 的 parent issue number", () => { + const result = continuationPrompt(42) + expect(result).toContain("#42") + expect(result).toContain("Flow Record") }) }) @@ -96,42 +108,3 @@ describe("MAX_CONTINUATIONS", () => { expect(MAX_CONTINUATIONS).toBe(50) }) }) - -describe("checkFlowRunBlockers", () => { - it("允许完成:无 FlowRun 绑定(null)", () => { - expect(checkFlowRunBlockers(null)).toBeNull() - }) - - it("允许完成:FlowRun 状态为 completed", () => { - expect(checkFlowRunBlockers("completed")).toBeNull() - }) - - it("允许完成:FlowRun 状态为 cancelled", () => { - expect(checkFlowRunBlockers("cancelled")).toBeNull() - }) - - it("阻止完成:FlowRun 状态为 running", () => { - const result = checkFlowRunBlockers("running") - expect(result).not.toBeNull() - expect(result).toContain("terminal state") - expect(result).toContain("run-finalize") - }) - - it("阻止完成:FlowRun 状态为 merging", () => { - const result = checkFlowRunBlockers("merging") - expect(result).not.toBeNull() - expect(result).toContain("terminal state") - }) - - it("阻止完成:FlowRun 状态为 blocked", () => { - const result = checkFlowRunBlockers("blocked") - expect(result).not.toBeNull() - expect(result).toContain("terminal state") - }) - - it("阻止完成:FlowRun 状态为 planned", () => { - const result = checkFlowRunBlockers("planned") - expect(result).not.toBeNull() - expect(result).toContain("terminal state") - }) -}) diff --git a/test/kernel/session-index.test.ts b/test/kernel/session-index.test.ts new file mode 100644 index 0000000..fa38ed1 --- /dev/null +++ b/test/kernel/session-index.test.ts @@ -0,0 +1,251 @@ +import { afterEach, describe, expect, it } from "vitest" +import { mkdtemp, readFile, rm, access } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { + bindSession, + getFlowSession, + readIndex, + sessionIndexPath, + takeoverSession, + unbindSession, + updateFlowSession, + writeIndex, + type SessionIndex, +} from "../../src/kernel/session-index.js" + +let tmpDirs: string[] = [] + +async function makeProjectDir(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "session-index-test-")) + tmpDirs.push(dir) + return dir +} + +afterEach(async () => { + await Promise.all(tmpDirs.map(dir => rm(dir, { recursive: true, force: true }))) + tmpDirs = [] +}) + +async function fileExists(p: string): Promise { + try { + await access(p) + return true + } catch { + return false + } +} + +function entry(overrides: Partial> = {}) { + return { + sessionID: "sess_abc", + status: "active", + continuationCount: 0, + updatedAt: 1754000000000, + ...overrides, + } +} + +describe("sessionIndexPath", () => { + it("指向 .opencode/opencode-cabbage/session-index.json", () => { + expect(sessionIndexPath("/proj")).toBe(path.join("/proj", ".opencode", "opencode-cabbage", "session-index.json")) + }) +}) + +describe("readIndex", () => { + it("索引文件不存在时返回空索引", async () => { + const dir = await makeProjectDir() + expect(await readIndex(dir)).toEqual({ flows: {} }) + }) + + it("索引文件内容损坏时返回空索引", async () => { + const dir = await makeProjectDir() + const p = sessionIndexPath(dir) + const { mkdir, writeFile } = await import("node:fs/promises") + await mkdir(path.dirname(p), { recursive: true }) + await writeFile(p, "not json{{{", "utf8") + expect(await readIndex(dir)).toEqual({ flows: {} }) + }) + + it("索引文件结构不合法(flows 缺失)时返回空索引", async () => { + const dir = await makeProjectDir() + const p = sessionIndexPath(dir) + const { mkdir, writeFile } = await import("node:fs/promises") + await mkdir(path.dirname(p), { recursive: true }) + await writeFile(p, JSON.stringify({ other: true }), "utf8") + expect(await readIndex(dir)).toEqual({ flows: {} }) + }) +}) + +describe("writeIndex / readIndex 往返", () => { + it("写入后可读回相同内容", async () => { + const dir = await makeProjectDir() + const index: SessionIndex = { + flows: { "12": entry() as never }, + } + await writeIndex(dir, index) + expect(await readIndex(dir)).toEqual(index) + }) + + it("原子写:不残留 .tmp 临时文件", async () => { + const dir = await makeProjectDir() + await writeIndex(dir, { flows: { "1": entry() as never } }) + expect(await fileExists(sessionIndexPath(dir))).toBe(true) + expect(await fileExists(`${sessionIndexPath(dir)}.tmp`)).toBe(false) + }) + + it("重复写入覆盖旧索引", async () => { + const dir = await makeProjectDir() + await writeIndex(dir, { flows: { "1": entry() as never } }) + await writeIndex(dir, { flows: { "2": entry({ sessionID: "sess_2" }) as never } }) + const index = await readIndex(dir) + expect(index.flows["1"]).toBeUndefined() + expect(index.flows["2"]?.sessionID).toBe("sess_2") + }) +}) + +describe("getFlowSession", () => { + it("返回已绑定 flow 的 entry", () => { + const index: SessionIndex = { flows: { "12": entry() as never } } + expect(getFlowSession(index, 12)?.sessionID).toBe("sess_abc") + }) + + it("未绑定 flow 返回 null", () => { + expect(getFlowSession({ flows: {} }, 12)).toBeNull() + }) +}) + +describe("bindSession", () => { + it("未绑定 flow:绑定成功,status=active", async () => { + const dir = await makeProjectDir() + const result = await bindSession(dir, 12, "sess_new") + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.entry.sessionID).toBe("sess_new") + expect(result.entry.status).toBe("active") + expect(result.entry.continuationCount).toBe(0) + const index = await readIndex(dir) + expect(index.flows["12"]?.sessionID).toBe("sess_new") + }) + + it("同一 session 重复绑定:幂等成功并保留 continuationCount", async () => { + const dir = await makeProjectDir() + await bindSession(dir, 12, "sess_a") + await updateFlowSession(dir, 12, { continuationCount: 5 }) + const result = await bindSession(dir, 12, "sess_a") + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.entry.continuationCount).toBe(5) + }) + + it("另一 active session 绑定同一 flow:拒绝 FLOW_SESSION_CONFLICT", async () => { + const dir = await makeProjectDir() + await bindSession(dir, 12, "sess_active") + const result = await bindSession(dir, 12, "sess_other") + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.code).toBe("FLOW_SESSION_CONFLICT") + expect(result.message).toContain("sess_active") + }) + + it("旧 session 非 active(paused/completed/cancelled):允许覆盖绑定", async () => { + const dir = await makeProjectDir() + await bindSession(dir, 12, "sess_old") + await updateFlowSession(dir, 12, { status: "paused" }) + const result = await bindSession(dir, 12, "sess_new") + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.entry.sessionID).toBe("sess_new") + expect(result.entry.continuationCount).toBe(0) + }) +}) + +describe("takeoverSession", () => { + it("另一 active session 且未确认:拒绝 TAKEOVER_NOT_CONFIRMED", async () => { + const dir = await makeProjectDir() + await bindSession(dir, 12, "sess_old") + const result = await takeoverSession(dir, 12, "sess_new", false) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.code).toBe("TAKEOVER_NOT_CONFIRMED") + expect(result.message).toContain("sess_old") + }) + + it("另一 active session 且已确认:新 session 接管绑定(索引只保留当前绑定),旧 sessionID 被替换", async () => { + const dir = await makeProjectDir() + await bindSession(dir, 12, "sess_old") + const result = await takeoverSession(dir, 12, "sess_new", true) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.entry.sessionID).toBe("sess_new") + expect(result.entry.status).toBe("active") + const index = await readIndex(dir) + expect(index.flows["12"]?.sessionID).toBe("sess_new") + expect(index.flows["12"]?.status).toBe("active") + // 旧 session 置 paused 由 flow_control 写入旧 session goal metadata,索引不再保留旧 sessionID + expect(index.flows["12"]?.sessionID).not.toBe("sess_old") + }) + + it("新 session 已是绑定 session:幂等成功(无需确认)", async () => { + const dir = await makeProjectDir() + await bindSession(dir, 12, "sess_a") + const result = await takeoverSession(dir, 12, "sess_a", false) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.entry.sessionID).toBe("sess_a") + expect(result.entry.status).toBe("active") + }) + + it("未绑定 flow:直接绑定成功(无需确认)", async () => { + const dir = await makeProjectDir() + const result = await takeoverSession(dir, 12, "sess_new", false) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.entry.sessionID).toBe("sess_new") + }) + + it("旧 session 非 active:接管无需确认", async () => { + const dir = await makeProjectDir() + await bindSession(dir, 12, "sess_old") + await updateFlowSession(dir, 12, { status: "paused" }) + const result = await takeoverSession(dir, 12, "sess_new", false) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.entry.sessionID).toBe("sess_new") + }) +}) + +describe("updateFlowSession", () => { + it("更新 status 与 continuationCount 并刷新 updatedAt", async () => { + const dir = await makeProjectDir() + await bindSession(dir, 12, "sess_a") + const updated = await updateFlowSession(dir, 12, { status: "paused", continuationCount: 3 }) + expect(updated?.status).toBe("paused") + expect(updated?.continuationCount).toBe(3) + const index = await readIndex(dir) + expect(index.flows["12"]?.status).toBe("paused") + expect(index.flows["12"]?.continuationCount).toBe(3) + }) + + it("flow 未绑定时返回 null 且不创建 entry", async () => { + const dir = await makeProjectDir() + expect(await updateFlowSession(dir, 99, { status: "paused" })).toBeNull() + expect((await readIndex(dir)).flows["99"]).toBeUndefined() + }) +}) + +describe("unbindSession", () => { + it("sessionID 匹配:删除 entry 返回 true", async () => { + const dir = await makeProjectDir() + await bindSession(dir, 12, "sess_a") + expect(await unbindSession(dir, 12, "sess_a")).toBe(true) + expect((await readIndex(dir)).flows["12"]).toBeUndefined() + }) + + it("sessionID 不匹配:不动索引返回 false", async () => { + const dir = await makeProjectDir() + await bindSession(dir, 12, "sess_a") + expect(await unbindSession(dir, 12, "sess_other")).toBe(false) + expect((await readIndex(dir)).flows["12"]?.sessionID).toBe("sess_a") + }) +})