Skip to content

Commit 68409a0

Browse files
authored
feat(tdd): E3 — Goal/FlowRun 终态绑定 + flow_run finalize + 文档同步
- Add transition: marks code→test→review→merge stages as pass, sets FlowRun to completed. Idempotent. - Add op to flow_control tool. - Goal complete validates FlowRun terminal state via . Blocks completion if FlowRunRef exists and FlowRun is not finalized. - Add hook to createGoalTool for pre-complete validation. - Export for testing. - Add 13 new tests (6 finalize + 7 blockers). - Update docs: architecture.md, configuration.md. Closes #80
1 parent 0c0320b commit 68409a0

9 files changed

Lines changed: 371 additions & 8 deletions

File tree

docs/guides/architecture.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ FlowRun 已实现完整的阶段/任务/Gate/Checkpoint 状态机,经 Spike
6666
| `github.ts` | Issue CRUD + 标签管理 + 乐观锁 |
6767
| `gate.ts` | 阶段/任务准入准出检查 |
6868
| `merge.ts` | PR 合并 + 分支保护 + 回滚 |
69+
| `transitions.ts` | 状态迁移函数(flowRunStart、flowRunFinalize 等) |
6970
| `validator.ts` | JSON Schema 校验 |
7071
| `resilience.ts` | 运行时检查 + 背压检测 |
7172
| `audit.ts` | 审计评论发布 |
@@ -194,6 +195,7 @@ FlowRun 写入时检测 Issue body 是否被其他进程修改,防止并发冲
194195

195196
- 子 agent 禁止调用 `goal({op:"create"|"pause"|"resume"|"cancel"})` — 生命周期操作限制在主 session
196197
- 只有 `@goal-verify` 可以调用 `goal({op:"complete"})` — 防止过早完成
198+
- `goal({op:"complete"})` 内部验证 FlowRun 终态:如绑定 FlowRunRef,要求 FlowRun 必须先由 `flow_control({op:"run-finalize"})` 完成终态绑定
197199
- Agent 工具权限由 frontmatter 控制(reviewer 只读,backend/frontend 读写)
198200

199201
## 依赖关系

docs/guides/configuration.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,16 @@ FlowRun 是插件的自动编排引擎,状态存储在 GitHub Issue body 中
9999
planned → running → blocked/merging → completed/cancelled
100100
```
101101

102+
### `flow_control` 操作
103+
104+
| 操作 | 说明 |
105+
|------|------|
106+
| `run-start` | 启动 FlowRun: planned → running,并绑定 Goal |
107+
| `stage-start` | 启动阶段(requirements/design/tasks/code) |
108+
| `stage-complete` | 完成阶段(requirements/design/tasks),验证所有 checkpoints |
109+
| `task-start` | 启动任务(pending/ready → running),冻结 TDD policy |
110+
| `run-finalize` | **终态绑定**:所有 Task merged 后,顺序标记 code→test→review→merge 为 pass,设置 FlowRun 为 completed。幂等。 |
111+
102112
### 7 个阶段
103113

104114
`requirements → design → tasks → code → test → review → merge`

src/flowrun/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export {
4646

4747
export {
4848
flowRunStart, flowStageStart, flowStageComplete, flowTaskStart,
49+
flowRunFinalize,
4950
type TransitionError, type TransitionResult,
5051
} from "./transitions.js"
5152

src/flowrun/transitions.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type {
33
FlowRunStatus, StageStatus, TaskStatus,
44
} from "./types.js"
55
import {
6-
canStartStage, canCompleteStage, canStartTask,
6+
canStartStage, canCompleteStage, canStartTask, canMerge,
77
} from "./gate.js"
88
import type { GateResult } from "./gate.js"
99

@@ -170,3 +170,50 @@ export function flowTaskStart(
170170

171171
return { ok: true, value: { flowRun, task } }
172172
}
173+
174+
// ─── FlowRun Finalize ───
175+
176+
/**
177+
* FlowRun 终态绑定:所有 Task merged 后,顺序标记 code→test→review→merge 为 pass,
178+
* 并将 FlowRun 状态设为 completed。
179+
*
180+
* 规则:
181+
* - canMerge() 必须通过(所有 Task merged + FlowRun status 为 running/merging)
182+
* - 对 code/test/review/merge 四个 stage:若未 pass 则逐个标记 pass
183+
* - FlowRun status → completed,设置 completedAt
184+
*
185+
* 幂等:已经是 completed 则直接返回(不修改任何字段)。
186+
*/
187+
export function flowRunFinalize(flowRun: FlowRun): TransitionResult<FlowRun> {
188+
// 幂等:已 completed
189+
if (flowRun.status === "completed") {
190+
return { ok: true, value: flowRun }
191+
}
192+
193+
// 前置检查:canMerge(所有 Task merged + FlowRun 状态正确)
194+
const mergeGate = canMerge(flowRun)
195+
if (!mergeGate.allowed) {
196+
return { ok: false, error: gateToError(mergeGate) }
197+
}
198+
199+
// 顺序标记 finalize stages
200+
const finalizeStages: FlowStage[] = ["code", "test", "review", "merge"]
201+
202+
for (const stage of finalizeStages) {
203+
const stageState = flowRun.stages[stage]
204+
if (!stageState) continue
205+
206+
// 已经是 pass → 跳过
207+
if (stageState.status === "pass") continue
208+
209+
// 标记 pass
210+
stageState.status = "pass"
211+
stageState.completedAt = new Date().toISOString()
212+
}
213+
214+
// FlowRun → completed
215+
flowRun.status = "completed"
216+
flowRun.completedAt = new Date().toISOString()
217+
218+
return { ok: true, value: flowRun }
219+
}

src/flowrun/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@ export type FlowControlOp =
357357
| "stage-complete"
358358
| "task-start"
359359
| "pr-create"
360+
| "run-finalize"
360361

361362
export interface FlowControlRequest {
362363
op: FlowControlOp

src/plugin/goal.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,28 @@ export function formatFlowRunRef(ref: GoalFlowRunRef): string {
205205
return `${ref.repo}#${ref.parentIssueNumber} (${ref.flowRunId})`
206206
}
207207

208-
export function createGoalTool(client: ReturnType<typeof createOpencodeClient>) {
208+
/**
209+
* 验证 FlowRun 终态 — 检查是否满足 Goal complete 的前提条件。
210+
*
211+
* 返回 null 表示允许完成;
212+
* 返回错误信息字符串表示阻止完成。
213+
*
214+
* @param flowRunStatus FlowRun 当前状态,null 表示无绑定的 FlowRun
215+
*/
216+
export function checkFlowRunBlockers(flowRunStatus: string | null): string | null {
217+
if (flowRunStatus === null) return null // 无 FlowRun 绑定,允许完成
218+
219+
if (flowRunStatus !== "completed" && flowRunStatus !== "cancelled") {
220+
return `FlowRun is not in terminal state (status: ${flowRunStatus}). Run flow_control({op:"run-finalize"}) to finalize first.`
221+
}
222+
223+
return null
224+
}
225+
226+
export function createGoalTool(
227+
client: ReturnType<typeof createOpencodeClient>,
228+
onBeforeComplete?: (parentSessionID: string) => Promise<string | null>,
229+
) {
209230
return tool({
210231
description: `Manage the active goal-mode objective.
211232
@@ -242,6 +263,15 @@ Use a single op field:
242263
if (parent.goal.status !== "active") {
243264
return `Parent session goal is not active (status: ${parent.goal.status}).`
244265
}
266+
267+
// 验证 FlowRun 终态(如绑定了 FlowRunRef)
268+
if (onBeforeComplete) {
269+
const blockReason = await onBeforeComplete(targetSessionID)
270+
if (blockReason !== null) {
271+
return `Goal completion blocked: ${blockReason}`
272+
}
273+
}
274+
245275
parent.goal.status = "complete"
246276
await writeGoal(client, targetSessionID, parent.goal, parent.session)
247277
return `Goal completed and verified: "${parent.goal.objective}"`

src/plugin/server.ts

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,18 @@ import { loadCommands } from "./commands.js"
1010
import { setupSkillsDir } from "./skills.js"
1111
import { loadAgents } from "./agents.js"
1212
import { createIsolatedShellEnv, detectAmbientCredentials } from "./shell.js"
13-
import { createGoalClient, createGoalTool, readGoal, writeGoal, bindFlowRunRef, MAX_CONTINUATIONS, continuationPrompt, verifyAgentPrompt, formatGoal } from "./goal.js"
13+
import { createGoalClient, createGoalTool, readGoal, writeGoal, bindFlowRunRef, readFlowRunRef, checkFlowRunBlockers, MAX_CONTINUATIONS, continuationPrompt, verifyAgentPrompt, formatGoal } from "./goal.js"
1414
import { FlowBroker } from "./broker.js"
1515
import {
1616
flowRunStart,
1717
flowStageStart,
1818
flowStageComplete,
1919
flowTaskStart,
20+
flowRunFinalize,
2021
} from "../flowrun/transitions.js"
2122
import type { TaskExecutionBinding, FlowControlResponse } from "../flowrun/types.js"
2223
import { handleFlowPrCreateWithBroker } from "./flow-pr-tool.js"
24+
import { readFlowRun } from "../flowrun/github.js"
2325

2426
const abortedSessions = new Set<string>()
2527
const errorRetryCount = new Map<string, number>()
@@ -184,15 +186,16 @@ function createFlowControlTool(
184186
goalClient: ReturnType<typeof createGoalClient>,
185187
) {
186188
return tool({
187-
description: `Control the FlowRun lifecycle: start a FlowRun, transition stages, and start tasks.
189+
description: `Control the FlowRun lifecycle: start a FlowRun, transition stages, start tasks, and finalize completed runs.
188190
189191
Operations:
190192
- run-start: Transition FlowRun from planned → running. Binds Goal to FlowRun.
191193
- stage-start: Start a stage (requirements/design/tasks/code). Requires prerequisites met.
192194
- stage-complete: Complete a stage (requirements/design/tasks). Requires all checks pass.
193-
- task-start: Start a task (pending/ready → running). Freezes TDD policy and sets execution binding.`,
195+
- task-start: Start a task (pending/ready → running). Freezes TDD policy and sets execution binding.
196+
- run-finalize: Mark code→test→review→merge stages as pass, set FlowRun to completed. Requires all Tasks merged. Idempotent.`,
194197
args: {
195-
op: tool.schema.enum(["run-start", "stage-start", "stage-complete", "task-start"]).describe("Flow control operation"),
198+
op: tool.schema.enum(["run-start", "stage-start", "stage-complete", "task-start", "run-finalize"]).describe("Flow control operation"),
196199
parent_issue_number: tool.schema.number().describe("Parent GitHub Issue number containing the FlowRun"),
197200
stage: tool.schema.enum(["requirements", "design", "tasks", "code", "test", "review", "merge"]).optional().describe("Stage name (for stage-start/stage-complete)"),
198201
task_id: tool.schema.string().optional().describe("Task ID (for task-start)"),
@@ -220,6 +223,8 @@ Operations:
220223
return handleStageComplete(broker, parentIssueNumber, args.stage as string)
221224
case "task-start":
222225
return handleTaskStart(broker, parentIssueNumber, args.task_id as string, args.execution_binding as TaskExecutionBinding | undefined, args.tdd_policy_json as string | undefined)
226+
case "run-finalize":
227+
return handleRunFinalize(broker, parentIssueNumber)
223228
default:
224229
return errorResponse("UNKNOWN_OP", `Unknown operation: "${op}"`)
225230
}
@@ -409,6 +414,36 @@ async function handleTaskStart(
409414
})
410415
}
411416

417+
async function handleRunFinalize(
418+
broker: FlowBroker,
419+
parentIssueNumber: number,
420+
): Promise<string> {
421+
const writeResult = await broker.writeFlowRunWithLock<unknown>(parentIssueNumber, (flowRun) => {
422+
const res = flowRunFinalize(flowRun)
423+
if (!res.ok) {
424+
return { flowRun, result: res.error, shouldPersist: false }
425+
}
426+
return {
427+
flowRun: res.value,
428+
result: { status: res.value.status, completedAt: res.value.completedAt },
429+
shouldPersist: true,
430+
}
431+
})
432+
433+
if (!writeResult.ok) {
434+
return errorResponse(writeResult.code, writeResult.message)
435+
}
436+
437+
if (!writeResult.persisted && writeResult.result && typeof writeResult.result === "object" && "code" in writeResult.result) {
438+
const err = writeResult.result as { code: string; message: string }
439+
return errorResponse(err.code, err.message)
440+
}
441+
442+
return okResponse({
443+
flowRunStatus: writeResult.flowRun.status,
444+
})
445+
}
446+
412447
// ─── Flow PR Tool ───
413448

414449
function createFlowPRTool(broker: FlowBroker) {
@@ -476,7 +511,18 @@ export function createOpencodeCabbage(packageRoot: string): Plugin {
476511
const projectDir = ctx.worktree || ctx.directory
477512
const v1Client = (ctx.client as unknown as V1ClientContainer)._client
478513
const goalClient = createGoalClient(ctx.serverUrl, v1Client)
479-
const goalTool = createGoalTool(goalClient)
514+
const goalTool = createGoalTool(goalClient, async (parentSessionID) => {
515+
// 验证绑定的 FlowRun 是否已终态
516+
const ref = await readFlowRunRef(goalClient, parentSessionID)
517+
if (!ref) return null // 无 FlowRun 绑定,允许完成
518+
519+
// 读取 FlowRun 状态
520+
const flowResult = await readFlowRun(ref.parentIssueNumber)
521+
if (!flowResult.ok) {
522+
return `Failed to read FlowRun #${ref.parentIssueNumber}: ${flowResult.code}`
523+
}
524+
return checkFlowRunBlockers(flowResult.data.status)
525+
})
480526
const broker = new FlowBroker()
481527
const flowControlTool = createFlowControlTool(broker, goalClient)
482528
const flowPRTool = createFlowPRTool(broker)

0 commit comments

Comments
 (0)