From 8e03ebd6589bebabf4f059f5c9cf26586f224bda Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:02:37 +0800 Subject: [PATCH 1/2] refactor(kernel): extract KeyedMutex and add kernel base types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD self-report (mutex): - RED: test/kernel/mutex.test.ts — 同 key 串行/异 key 并行/结果返回/异常传播/数字 key - GREEN: src/kernel/mutex.ts + src/plugin/broker.ts 复用提取后的 KeyedMutex(删除本地副本,行为不变) - final-regression: 499 passed(468 既有 + 31 新增) - final-verification: mutex 互斥可用 ✓;broker 既有 26 测试保持绿色 ✓ kernel/types.ts 定义 FlowRecordRef / TaskRecordRef(Flow/Task Record 引用基础结构)。 --- src/kernel/mutex.ts | 23 +++++++++++++ src/kernel/types.ts | 12 +++++++ src/plugin/broker.ts | 29 +--------------- test/kernel/mutex.test.ts | 72 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 28 deletions(-) create mode 100644 src/kernel/mutex.ts create mode 100644 src/kernel/types.ts create mode 100644 test/kernel/mutex.test.ts diff --git a/src/kernel/mutex.ts b/src/kernel/mutex.ts new file mode 100644 index 0000000..effd6e4 --- /dev/null +++ b/src/kernel/mutex.ts @@ -0,0 +1,23 @@ +/** + * 按 key 隔离的互斥锁,用于单进程内串行化并发操作。 + * 相同 key 的操作依次执行,不同 key 的操作可并行。 + */ +export class KeyedMutex { + private locks = new Map>() + + async runExclusive(key: string | number, fn: () => Promise): Promise { + const prev = this.locks.get(key) ?? Promise.resolve() + let release: () => void + const next = new Promise(r => { + release = r + }) + this.locks.set(key, next) + + await prev + try { + return await fn() + } finally { + release!() + } + } +} diff --git a/src/kernel/types.ts b/src/kernel/types.ts new file mode 100644 index 0000000..7113c45 --- /dev/null +++ b/src/kernel/types.ts @@ -0,0 +1,12 @@ +/** Flow Record 引用:GitHub Parent Issue 承载 Flow 生命周期与阶段状态 */ +export interface FlowRecordRef { + parentIssueNumber: number + slug: string +} + +/** Task Record 引用:GitHub Sub Issue 承载 Task 生命周期与 TDD 证据 */ +export interface TaskRecordRef { + parentIssueNumber: number + taskId: string + issueNumber: number +} diff --git a/src/plugin/broker.ts b/src/plugin/broker.ts index 0ec7bcb..218cc2f 100644 --- a/src/plugin/broker.ts +++ b/src/plugin/broker.ts @@ -1,5 +1,6 @@ import type { FlowRun } from "../flowrun/types.js" import { readFlowRunWithLock, writeFlowRunWithLock as ghWriteFlowRunWithLock } from "../flowrun/github.js" +import { KeyedMutex } from "../kernel/mutex.js" // ─── 类型 ─── @@ -18,34 +19,6 @@ export type WriteResult = | { ok: true; flowRun: FlowRun; result: R; persisted: boolean } | { ok: false; code: "PERSIST_CONFLICT" | "READ_FAILED"; message: string } -// ─── Keyed Mutex ─── - -/** - * 按 key 隔离的互斥锁,用于单进程内串行化并发操作。 - * - * 每个 key(parentIssueNumber)维护独立的 promise 链, - * 相同 key 的操作依次执行,不同 key 的操作可以并行。 - */ -class KeyedMutex { - private locks = new Map>() - - async runExclusive(key: number, fn: () => Promise): Promise { - const prev = this.locks.get(key) ?? Promise.resolve() - let release: () => void - const next = new Promise(r => { - release = r - }) - this.locks.set(key, next) - - await prev - try { - return await fn() - } finally { - release!() - } - } -} - // ─── FlowBroker ─── /** diff --git a/test/kernel/mutex.test.ts b/test/kernel/mutex.test.ts new file mode 100644 index 0000000..8c7546b --- /dev/null +++ b/test/kernel/mutex.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest" +import { KeyedMutex } from "../../src/kernel/mutex.js" + +const tick = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +describe("KeyedMutex", () => { + it("serializes operations with the same key", async () => { + const mutex = new KeyedMutex() + let inFlight = 0 + let maxInFlight = 0 + + const op = async () => { + inFlight += 1 + maxInFlight = Math.max(maxInFlight, inFlight) + await tick(10) + inFlight -= 1 + } + + await Promise.all([mutex.runExclusive("k", op), mutex.runExclusive("k", op), mutex.runExclusive("k", op)]) + expect(maxInFlight).toBe(1) + }) + + it("runs operations with different keys in parallel", async () => { + const mutex = new KeyedMutex() + let inFlight = 0 + let maxInFlight = 0 + + const op = async () => { + inFlight += 1 + maxInFlight = Math.max(maxInFlight, inFlight) + await tick(10) + inFlight -= 1 + } + + await Promise.all([mutex.runExclusive("a", op), mutex.runExclusive("b", op), mutex.runExclusive("c", op)]) + expect(maxInFlight).toBe(3) + }) + + it("returns the operation result", async () => { + const mutex = new KeyedMutex() + const result = await mutex.runExclusive("k", async () => 42) + expect(result).toBe(42) + }) + + it("propagates errors and keeps the mutex usable afterwards", async () => { + const mutex = new KeyedMutex() + await expect( + mutex.runExclusive("k", async () => { + throw new Error("boom") + }), + ).rejects.toThrow("boom") + + const result = await mutex.runExclusive("k", async () => "ok") + expect(result).toBe("ok") + }) + + it("supports numeric keys", async () => { + const mutex = new KeyedMutex() + let inFlight = 0 + let maxInFlight = 0 + + const op = async () => { + inFlight += 1 + maxInFlight = Math.max(maxInFlight, inFlight) + await tick(5) + inFlight -= 1 + } + + await Promise.all([mutex.runExclusive(1, op), mutex.runExclusive(1, op)]) + expect(maxInFlight).toBe(1) + }) +}) From 29fea643f894e3d7ffcd49f0acba6291887b385f Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:02:45 +0800 Subject: [PATCH 2/2] feat(kernel): add functional slug derivation and validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD self-report (slug): - RED: test/kernel/slug.test.ts(26 用例)— module missing 失败 - GREEN: src/kernel/slug.ts — validateTitle/deriveSlug/branchFor/worktreeFor - final-regression: 499 passed(468 既有 + 31 新增),typecheck 通过 - final-verification: * 泛化名拒绝:task-001/feature-1/implement-task/fix-bug/add-docs/task/123 → GENERIC_TITLE;Task 001 → INVALID_FORMAT ✓ * 合法功能 slug 接受:validate-execution-binding → ok ✓ * 非法格式拒绝:大写/下划线/空格/连续连字符/首尾连字符/超长 ✓ * deriveSlug 严格派生:无 doc 兜底(无意义输入返回空串)、截断至 60 ✓ --- src/kernel/slug.ts | 81 ++++++++++++++++++++++++++++++++++++ test/kernel/slug.test.ts | 90 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 src/kernel/slug.ts create mode 100644 test/kernel/slug.test.ts diff --git a/src/kernel/slug.ts b/src/kernel/slug.ts new file mode 100644 index 0000000..bf591bf --- /dev/null +++ b/src/kernel/slug.ts @@ -0,0 +1,81 @@ +export const MAX_SLUG_LENGTH = 60 + +export type BranchType = "feat" | "fix" | "chore" | "docs" + +export type SlugValidationResult = + | { ok: true; slug: string } + | { ok: false; code: "EMPTY_TITLE" | "INVALID_FORMAT" | "SLUG_TOO_LONG" | "GENERIC_TITLE"; message: string } + +/** 合法 slug:小写字母/数字分段,段间单个连字符 */ +const SLUG_FORMAT = /^[a-z0-9]+(-[a-z0-9]+)*$/ + +/** 泛化/占位词:与数字或其他泛化词组合时无法表达具体功能 */ +const GENERIC_WORDS = new Set([ + "add", "bug", "bugs", "chore", "create", "delete", "docs", "feature", + "features", "fix", "fixes", "implement", "issue", "issues", "refactor", + "remove", "support", "task", "tasks", "test", "tests", "update", "wip", +]) + +/** + * 严格 slug 派生:沿用 util/paths.ts slugify 的归一化规则, + * 但不做 "doc" 兜底——无意义输入返回空串,由调用方判定。 + */ +export function deriveSlug(value: string): string { + let slug = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/--+/g, "-") + + if (slug.length > MAX_SLUG_LENGTH) { + const sliced = slug.slice(0, MAX_SLUG_LENGTH) + const lastDash = sliced.lastIndexOf("-") + slug = lastDash > MAX_SLUG_LENGTH / 2 ? sliced.slice(0, lastDash) : sliced + } + + return slug +} + +/** + * 校验功能标题(须已是 kebab-case slug): + * - 空标题、非法字符(大写/下划线/空格/连续连字符/首尾连字符)、过长(>60)→ 格式拒绝 + * - 纯序号或全部由泛化词组成(task-001、implement-task、fix-bug)→ GENERIC_TITLE + */ +export function validateTitle(title: string): SlugValidationResult { + if (title.trim() === "") { + return { ok: false, code: "EMPTY_TITLE", message: "Title must not be empty" } + } + if (title.length > MAX_SLUG_LENGTH) { + return { ok: false, code: "SLUG_TOO_LONG", message: `Title must be at most ${MAX_SLUG_LENGTH} characters` } + } + if (!SLUG_FORMAT.test(title)) { + return { + ok: false, + code: "INVALID_FORMAT", + message: "Title must be a kebab-case slug: lowercase letters, digits, and single hyphens between segments", + } + } + if (isGenericSlug(title)) { + return { + ok: false, + code: "GENERIC_TITLE", + message: "Title is too generic: use a functional slug that describes the concrete behavior", + } + } + return { ok: true, slug: title } +} + +/** 纯序号,或所有分段均为泛化词/数字 → 无法描述具体功能 */ +function isGenericSlug(slug: string): boolean { + const segments = slug.split("-") + return segments.every(seg => /^\d+$/.test(seg) || GENERIC_WORDS.has(seg)) +} + +export function branchFor(slug: string, type: BranchType): string { + return `${type}/${slug}` +} + +export function worktreeFor(slug: string): string { + return `.worktree/${slug}` +} diff --git a/test/kernel/slug.test.ts b/test/kernel/slug.test.ts new file mode 100644 index 0000000..1c9be5f --- /dev/null +++ b/test/kernel/slug.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from "vitest" +import { validateTitle, deriveSlug, branchFor, worktreeFor } from "../../src/kernel/slug.js" + +describe("validateTitle", () => { + it("accepts a valid functional slug", () => { + expect(validateTitle("validate-execution-binding")).toEqual({ + ok: true, + slug: "validate-execution-binding", + }) + }) + + it("accepts multi-word functional slugs with domain words", () => { + expect(validateTitle("onboarding-checklist").ok).toBe(true) + expect(validateTitle("v2-migration").ok).toBe(true) + }) + + it.each([ + "task-001", + "feature-1", + "implement-task", + "fix-bug", + "add-docs", + "task", + "123", + ])("rejects generic title %s", (title) => { + const result = validateTitle(title) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe("GENERIC_TITLE") + } + }) + + it("rejects empty or whitespace-only title", () => { + expect(validateTitle("").ok).toBe(false) + expect(validateTitle(" ").ok).toBe(false) + }) + + it.each([ + "Task 001", + "Validate-execution-binding", + "validate_Execution", + "validate--execution", + "-validate", + "validate-", + "validate binding", + "validate.execution", + ])("rejects invalid format %s", (title) => { + expect(validateTitle(title).ok).toBe(false) + }) + + it("rejects slug longer than 60 characters", () => { + expect(validateTitle("a".repeat(61)).ok).toBe(false) + }) + + it("accepts slug of exactly 60 characters", () => { + expect(validateTitle("a".repeat(60)).ok).toBe(true) + }) +}) + +describe("deriveSlug", () => { + it("derives kebab slug from a natural title", () => { + expect(deriveSlug("Validate Execution Binding")).toBe("validate-execution-binding") + }) + + it("collapses separators and strips leading/trailing dashes", () => { + expect(deriveSlug(" Fix -- Message__Ordering ")).toBe("fix-message-ordering") + }) + + it("returns empty string for meaningless input instead of a fallback", () => { + expect(deriveSlug("!!!")).toBe("") + expect(deriveSlug("")).toBe("") + }) + + it("truncates over-long slugs to at most 60 characters", () => { + expect(deriveSlug("a".repeat(80)).length).toBeLessThanOrEqual(60) + }) +}) + +describe("branchFor / worktreeFor", () => { + it("builds branch names with the type prefix", () => { + expect(branchFor("validate-execution-binding", "feat")).toBe("feat/validate-execution-binding") + expect(branchFor("validate-execution-binding", "fix")).toBe("fix/validate-execution-binding") + expect(branchFor("validate-execution-binding", "chore")).toBe("chore/validate-execution-binding") + expect(branchFor("validate-execution-binding", "docs")).toBe("docs/validate-execution-binding") + }) + + it("builds the worktree path", () => { + expect(worktreeFor("validate-execution-binding")).toBe(".worktree/validate-execution-binding") + }) +})