diff --git a/docs/concurrency.md b/docs/concurrency.md new file mode 100644 index 0000000..d7b3019 --- /dev/null +++ b/docs/concurrency.md @@ -0,0 +1,38 @@ +# Concurrency safety + +CodeDecay evaluates **deterministic fixture oracles** for retries, duplicate +delivery, lost updates, and idempotency. It does not spawn a distributed +scheduler or contact production queues. + +## What it can establish + +- Experiment inputs: actors, operations, seeded schedules, retry/duplicate + policy, fault points, state oracle, bounds, cleanup +- Candidate surfaces from routes/jobs/locks/idempotency/retry mentions + (keyword = candidate, not proof) +- Falsifiable invariants: exactly-once, at-least-once-safe, no-lost-update, + monotonic state, bounded retries, compensating action +- Deterministic barrier schedules with reproducible seeds +- Bound gates for parallelism, repetitions, timeout, and network targets +- Distinction between confirmed race, passed oracle, inconclusive stress, + bounds-blocked, and needs-human +- Repair tasks that attach a durable regression test id after a confirmed defect + +## What it cannot establish + +- Absence of races in production +- Safety from low-repetition probabilistic stress alone +- A `fullyVerified: true` result (always false in this slice) +- Queue/webhook/cron/lock/outbox execution (extension boundaries only) + +## CLI / MCP + +```bash +codedecay concurrency --experiment experiment.json --surface src/jobs/payout.ts +codedecay concurrency --experiment experiment.json --target-kind fixture-local --format json +``` + +MCP tool: `concurrency_safety`. + +Approved bounded execution through the execution package remains a future +adapter; this slice stays local, deterministic, and command-free. diff --git a/packages/cli/src/commands/concurrency.ts b/packages/cli/src/commands/concurrency.ts new file mode 100644 index 0000000..5f9f9f9 --- /dev/null +++ b/packages/cli/src/commands/concurrency.ts @@ -0,0 +1,24 @@ +import { resolve } from "node:path"; +import { analyzeConcurrencySafety, renderConcurrencySafetyMarkdown } from "@submuxhq/codedecay-knowledge"; +import { parseConcurrencyArgs } from "../parsers/args"; +import type { CliCommandContext, CliRuntime, ConcurrencyOptions } from "../types"; + +export interface RunConcurrencyCommandDependencies { + resolveRepoRoot(cwd: string, options: ConcurrencyOptions): string; + writeOutput(input: { cwd: string; output?: string | undefined; rendered: string; runtime: CliRuntime }): void; +} + +export function runConcurrencyCommand(context: CliCommandContext, dependencies: RunConcurrencyCommandDependencies): void { + const options = parseConcurrencyArgs(context.args); + const cwd = resolve(context.runtimeCwd, options.cwd ?? "."); + const rootDir = dependencies.resolveRepoRoot(cwd, options); + const report = analyzeConcurrencySafety({ + rootDir, + experimentFile: options.experimentFile, + surfaceFiles: options.surfaceFiles, + targetKind: options.targetKind, + cleanupPlan: options.cleanupPlan + }); + const rendered = options.format === "json" ? `${JSON.stringify(report, null, 2)}\n` : renderConcurrencySafetyMarkdown(report); + dependencies.writeOutput({ cwd: rootDir, output: options.output, rendered, runtime: context.runtime }); +} diff --git a/packages/cli/src/commands/registry.ts b/packages/cli/src/commands/registry.ts index 84a37e8..cf3bf6b 100644 --- a/packages/cli/src/commands/registry.ts +++ b/packages/cli/src/commands/registry.ts @@ -17,6 +17,7 @@ import { } from "./memory"; import { runMcpCommand as runMcpCommandWithDependencies } from "./mcp"; import { runMigrationCommand as runMigrationCommandWithDependencies } from "./migration"; +import { runConcurrencyCommand as runConcurrencyCommandWithDependencies } from "./concurrency"; import { runProductCommand as runProductCommandWithDependencies } from "./product"; import { runRedteamCommand as runRedteamCommandWithDependencies } from "./redteam"; import { runRevalidateCommand as runRevalidateCommandWithDependencies } from "./revalidate"; @@ -99,6 +100,10 @@ export function createCommandHandlers(options: CommandRegistryOptions): Record runConcurrencyCommandWithDependencies(context, { + resolveRepoRoot: getRepoRootForCli, + writeOutput: writeCliOutput + }), memory: (context) => runMemoryCommandWithDependencies(context, { resolveRepoRoot: getRepoRootForCli }), "memory-import": (context) => runMemoryImportCommandWithDependencies(context, { resolveRepoRoot: getRepoRootForCli }), "memory-learn": (context) => runMemoryLearnCommandWithDependencies(context, { resolveRepoRoot: getRepoRootForCli }), diff --git a/packages/cli/src/docs/command-docs/analysis.ts b/packages/cli/src/docs/command-docs/analysis.ts index 1402a44..695d3ce 100644 --- a/packages/cli/src/docs/command-docs/analysis.ts +++ b/packages/cli/src/docs/command-docs/analysis.ts @@ -28,6 +28,31 @@ export const ANALYSIS_COMMAND_DOCS: Record = { "See docs/migration.md for what plan-ready vs fully-verified means." ] }, + concurrency: { + name: "concurrency", + summary: "Plan and evaluate deterministic concurrency/idempotency oracles.", + usage: ["codedecay concurrency [options]"], + description: [ + "Load a seeded concurrency experiment fixture, detect candidate surfaces, enforce disposable bounds, and evaluate duplicate-delivery / lost-update oracles without spawning a scheduler or contacting production queues." + ], + options: [ + { flag: "--experiment ", description: "Repo-local concurrency experiment JSON fixture" }, + { flag: "--surface ", description: "Source file to scan for concurrency candidates; repeatable" }, + { flag: "--target-kind ", description: "fixture-local | disposable-local | remote-unapproved | production-like | unspecified" }, + { flag: "--cleanup-plan ", description: "Disposable target cleanup plan" }, + { flag: "--cwd ", description: "Working directory" }, + { flag: "--format ", description: "Output format" }, + { flag: "--output ", description: "Write report to a file" } + ], + examples: [ + "codedecay concurrency --experiment .codedecay/concurrency/duplicate.json --surface src/jobs/payout.ts", + "codedecay concurrency --experiment experiment.json --target-kind fixture-local --format json" + ], + notes: [ + "This command is oracle/plan-only: it does not run parallel load generators or touch production queues.", + "Stress-only results stay inconclusive. See docs/concurrency.md." + ] + }, runtime: { name: "runtime", summary: "Ingest local runtime exports as redacted engineering evidence.", diff --git a/packages/cli/src/docs/command-docs/order.ts b/packages/cli/src/docs/command-docs/order.ts index 2e73c87..e6d25eb 100644 --- a/packages/cli/src/docs/command-docs/order.ts +++ b/packages/cli/src/docs/command-docs/order.ts @@ -1,3 +1,3 @@ -export const COMMAND_ORDER = ["ai", "session", "context", "analyze", "runtime", "migration", "topology", "benchmark", "snapshot", "redteam", "revalidate", "llm-review", "agent", "loop", "doctor", "config", "memory", "memory-import", "memory-learn", "execute", "differential", "product", "dashboard", "mcp"] as const; +export const COMMAND_ORDER = ["ai", "session", "context", "analyze", "runtime", "migration", "concurrency", "topology", "benchmark", "snapshot", "redteam", "revalidate", "llm-review", "agent", "loop", "doctor", "config", "memory", "memory-import", "memory-learn", "execute", "differential", "product", "dashboard", "mcp"] as const; export const UTILITY_COMMAND_ORDER = ["help", "man", "update", "uninstall", "version"] as const; export const ROOT_FLAG_ALIASES = ["--help", "-h", "--version", "-V"] as const; diff --git a/packages/cli/src/parsers/args.ts b/packages/cli/src/parsers/args.ts index 08e352b..a31f32b 100644 --- a/packages/cli/src/parsers/args.ts +++ b/packages/cli/src/parsers/args.ts @@ -13,6 +13,7 @@ export { parseLoopArgs } from "./loop"; export { parseMcpArgs } from "./mcp"; export { parseMemoryArgs, parseMemoryImportArgs, parseMemoryLearnArgs, parseMemoryLearningArgs, parseMemorySetupArgs } from "./memory"; export { parseMigrationArgs } from "./migration"; +export { parseConcurrencyArgs } from "./concurrency"; export { parseRevalidateArgs } from "./revalidate"; export { parseRuntimeArgs } from "./runtime"; export { parseProductArgs } from "./product"; diff --git a/packages/cli/src/parsers/concurrency.ts b/packages/cli/src/parsers/concurrency.ts new file mode 100644 index 0000000..213d01e --- /dev/null +++ b/packages/cli/src/parsers/concurrency.ts @@ -0,0 +1,50 @@ +import type { ConcurrencyOptions } from "../types"; +import { requireValue } from "./primitives"; +import { HelpRequested, throwUnknownOption } from "./shared"; + +export function parseConcurrencyArgs(args: string[]): ConcurrencyOptions { + const options: ConcurrencyOptions = { surfaceFiles: [], format: "markdown" }; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (!arg) continue; + if (arg === "--help" || arg === "-h") throw new HelpRequested(); + const [flag, inline] = splitArg(arg); + const value = () => inline ?? requireValue(args, index, flag); + if (flag === "--experiment") options.experimentFile = value(); + else if (flag === "--surface") options.surfaceFiles.push(value()); + else if (flag === "--cwd") options.cwd = value(); + else if (flag === "--output") options.output = value(); + else if (flag === "--format") options.format = parseFormat(value()); + else if (flag === "--target-kind") options.targetKind = parseTarget(value()); + else if (flag === "--cleanup-plan") options.cleanupPlan = value(); + else { + throwUnknownOption(arg, "concurrency"); + continue; + } + if (inline === undefined) index += 1; + } + return options; +} + +function splitArg(arg: string): [string, string | undefined] { + const index = arg.indexOf("="); + return index < 0 ? [arg, undefined] : [arg.slice(0, index), arg.slice(index + 1)]; +} + +function parseFormat(value: string): ConcurrencyOptions["format"] { + if (value === "json" || value === "markdown") return value; + throw new Error(`Invalid concurrency format "${value}". Expected json or markdown.`); +} + +function parseTarget(value: string): NonNullable { + if ( + value === "unspecified" || + value === "fixture-local" || + value === "disposable-local" || + value === "remote-unapproved" || + value === "production-like" + ) { + return value; + } + throw new Error(`Invalid concurrency target kind "${value}".`); +} diff --git a/packages/cli/src/types/concurrency.ts b/packages/cli/src/types/concurrency.ts new file mode 100644 index 0000000..c7939fa --- /dev/null +++ b/packages/cli/src/types/concurrency.ts @@ -0,0 +1,12 @@ +import type { ConfigFormat } from "./common"; +import type { ConcurrencyTargetKind } from "@submuxhq/codedecay-knowledge"; + +export interface ConcurrencyOptions { + cwd?: string | undefined; + experimentFile?: string | undefined; + surfaceFiles: string[]; + targetKind?: ConcurrencyTargetKind | undefined; + cleanupPlan?: string | undefined; + format: ConfigFormat; + output?: string | undefined; +} diff --git a/packages/cli/src/types/index.ts b/packages/cli/src/types/index.ts index 8e3ad34..7e29a8b 100644 --- a/packages/cli/src/types/index.ts +++ b/packages/cli/src/types/index.ts @@ -12,6 +12,7 @@ export * from "./execution"; export * from "./llm-review"; export * from "./loop"; export * from "./maintenance"; +export * from "./concurrency"; export * from "./migration"; export * from "./mcp"; export * from "./memory"; diff --git a/packages/cli/test/built-cli-concurrency.test.ts b/packages/cli/test/built-cli-concurrency.test.ts new file mode 100644 index 0000000..74ecd80 --- /dev/null +++ b/packages/cli/test/built-cli-concurrency.test.ts @@ -0,0 +1,48 @@ +import { copyFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { beforeAll, describe, expect, it } from "vitest"; +import { createRepo, ensureBuiltCli, runBuilt } from "./helpers/built-cli"; + +const fixtures = join(dirname(fileURLToPath(import.meta.url)), "../../knowledge/test/fixtures/concurrency"); + +beforeAll(ensureBuiltCli, 120_000); + +describe("built codedecay concurrency workflow", () => { + it("evaluates a fixture oracle from the bundled CLI without spawning a scheduler", () => { + const root = createRepo({ "README.md": "fixture\n" }); + mkdirSync(join(root, "experiments"), { recursive: true }); + copyFileSync(join(fixtures, "idempotent.json"), join(root, "experiments", "idempotent.json")); + const result = runBuilt([ + "concurrency", + "--cwd", + root, + "--experiment", + "experiments/idempotent.json", + "--format", + "json" + ]); + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const report = JSON.parse(result.stdout) as { + verdict: string; + fullyVerified: boolean; + safety: Record; + }; + expect(report.verdict).toBe("passed-oracle"); + expect(report.fullyVerified).toBe(false); + expect(report.safety).toMatchObject({ + commandsExecuted: false, + networkCalled: false, + schedulerSpawned: false + }); + }); + + it("exposes concurrency help from the bundled command registry", () => { + createRepo({ "README.md": "fixture\n" }); + const result = runBuilt(["concurrency", "--help"]); + expect(result.status).toBe(0); + expect(result.stdout).toContain("CodeDecay concurrency"); + expect(result.stdout).toContain("--experiment "); + }); +}); diff --git a/packages/cli/test/concurrency.test.ts b/packages/cli/test/concurrency.test.ts new file mode 100644 index 0000000..cff3c13 --- /dev/null +++ b/packages/cli/test/concurrency.test.ts @@ -0,0 +1,73 @@ +import { execFileSync } from "node:child_process"; +import { copyFileSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { runCli } from "../src/index"; + +const fixtures = join(dirname(fileURLToPath(import.meta.url)), "../../knowledge/test/fixtures/concurrency"); +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("codedecay concurrency CLI", () => { + it("evaluates a duplicate-delivery fixture in a child repository", async () => { + const root = createRepo(); + mkdirSync(join(root, "experiments"), { recursive: true }); + copyFileSync(join(fixtures, "duplicate-delivery.json"), join(root, "experiments", "duplicate.json")); + const result = await run([ + "concurrency", + "--cwd", + root, + "--experiment", + "experiments/duplicate.json", + "--format", + "json", + "--output", + "reports/concurrency.json" + ]); + const report = JSON.parse(readFileSync(join(root, "reports", "concurrency.json"), "utf8")) as { + verdict: string; + fullyVerified: boolean; + safety: Record; + }; + expect(result).toEqual({ exitCode: 0, stdout: "", stderr: "" }); + expect(report.verdict).toBe("confirmed-race"); + expect(report.fullyVerified).toBe(false); + expect(report.safety).toMatchObject({ + commandsExecuted: false, + networkCalled: false, + schedulerSpawned: false + }); + }); + + it("exposes concurrency help", async () => { + const result = await run(["concurrency", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("CodeDecay concurrency"); + }); +}); + +async function run(args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> { + let stdout = ""; + let stderr = ""; + const exitCode = await runCli(args, { + stdout: (text) => { + stdout += text; + }, + stderr: (text) => { + stderr += text; + } + }); + return { exitCode, stdout, stderr }; +} + +function createRepo(): string { + const root = join(tmpdir(), `codedecay-concurrency-cli-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(root, { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: root }); + roots.push(root); + return root; +} diff --git a/packages/knowledge/src/concurrency/analyze.ts b/packages/knowledge/src/concurrency/analyze.ts new file mode 100644 index 0000000..c027572 --- /dev/null +++ b/packages/knowledge/src/concurrency/analyze.ts @@ -0,0 +1,248 @@ +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { resolve } from "node:path"; +import { gateConcurrencyBounds } from "./bounds"; +import { detectConcurrencyCandidates } from "./detect"; +import { evaluateConcurrencyOracle } from "./oracles"; +import { + CONCURRENCY_DEFAULT_BOUNDS, + CONCURRENCY_EVIDENCE_SCHEMA_VERSION, + type ConcurrencyCleanupEvidence, + type ConcurrencyExperimentInput, + type ConcurrencyRepairTask, + type ConcurrencySafetyReport, + type ConcurrencyTargetKind, + type ConcurrencyVerdict +} from "./types"; + +export interface AnalyzeConcurrencySafetyOptions { + rootDir: string; + experimentFile?: string | undefined; + experiment?: ConcurrencyExperimentInput | undefined; + surfaceFiles?: string[] | undefined; + cleanupPlan?: string | undefined; + targetKind?: ConcurrencyTargetKind | undefined; + generatedAt?: string | undefined; +} + +export function analyzeConcurrencySafety(options: AnalyzeConcurrencySafetyOptions): ConcurrencySafetyReport { + const rootDir = realpathSync(options.rootDir); + const experiment = options.experiment ?? loadExperiment(rootDir, options.experimentFile); + const candidates = detectConcurrencyCandidates(rootDir, options.surfaceFiles ?? []); + const limitations = [ + "Deterministic fixture oracles do not prove production race freedom.", + "No distributed scheduler, production queue, or remote HTTP target was contacted.", + "Keyword candidate detection is not proof; attach a falsifiable invariant and approved experiment.", + "A passing low-repetition stress run cannot silently prove absence of races." + ]; + const blockers: string[] = []; + const investigationTasks: string[] = []; + const repairTasks: ConcurrencyRepairTask[] = []; + + if (!experiment) { + limitations.unshift("No concurrency experiment fixture was supplied."); + for (const candidate of candidates) { + investigationTasks.push( + `Map ${candidate.kind} surface ${candidate.surface} to invariant ${candidate.suggestedInvariant} and attach a seeded schedule oracle.` + ); + } + return baseReport({ + generatedAt: options.generatedAt, + verdict: candidates.length ? "plan-ready" : "needs-human", + bounds: { + maxParallelism: CONCURRENCY_DEFAULT_BOUNDS.maxParallelism, + repetitions: CONCURRENCY_DEFAULT_BOUNDS.repetitions, + timeoutMs: CONCURRENCY_DEFAULT_BOUNDS.timeoutMs, + targetKind: options.targetKind ?? "unspecified" + }, + boundsBlocked: false, + candidates, + cleanup: createCleanup(options.cleanupPlan, options.targetKind ?? "unspecified"), + repairTasks: [], + treeStatus: "unverified", + blockers: options.targetKind === "production-like" || options.targetKind === "remote-unapproved" + ? [`Target kind "${options.targetKind}" is blocked for concurrency experiments.`] + : [], + investigationTasks, + limitations + }); + } + + const bounds = { + ...experiment.bounds, + targetKind: options.targetKind ?? experiment.bounds.targetKind + }; + const gate = gateConcurrencyBounds(bounds); + const cleanup = createCleanup(options.cleanupPlan ?? experiment.cleanup?.plan, bounds.targetKind); + + if (gate.blocked) { + blockers.push(...gate.reasons); + return baseReport({ + generatedAt: options.generatedAt, + experimentId: experiment.id, + experimentKind: experiment.kind, + verdict: "bounds-blocked", + invariant: experiment.stateOracle.invariant, + bounds: gate.effective, + boundsBlocked: true, + candidates, + cleanup, + repairTasks: [], + treeStatus: "unverified", + blockers, + investigationTasks: [ + "Reduce parallelism, repetitions, timeout, or network target to configured disposable bounds before execution." + ], + limitations + }); + } + + const oracle = evaluateConcurrencyOracle({ ...experiment, bounds: gate.effective }); + let verdict: ConcurrencyVerdict = oracle.verdict; + + if (experiment.kind === "probabilistic-stress") { + verdict = "inconclusive-stress"; + limitations.push("Stress-only results stay inconclusive and cannot become verified safety."); + } + + if (verdict === "confirmed-race") { + investigationTasks.push( + `Confirmed race against invariant ${experiment.stateOracle.invariant}; capture timeline seed ${oracle.seed} and recommend a durable regression test.` + ); + repairTasks.push({ + id: `repair:${experiment.id}`, + title: `Fix confirmed concurrency defect ${experiment.id}`, + detail: `Oracle verdict confirmed-race for invariant ${experiment.stateOracle.invariant}. sideEffectCount=${oracle.sideEffectCount}, finalState=${oracle.finalState}.` + }); + } + + if (verdict === "passed-oracle") { + investigationTasks.push( + `Oracle passed for ${experiment.id}; keep the seeded schedule as a regression fixture and do not claim full production verification.` + ); + } + + if (verdict === "inconclusive-stress") { + investigationTasks.push( + "Replace stress-only evidence with a deterministic barrier schedule before treating the path as safe." + ); + } + + let treeStatus: ConcurrencySafetyReport["treeStatus"] = "unverified"; + if (verdict === "confirmed-race" && experiment.repair?.durableRegressionTestId) { + repairTasks.push({ + id: `regression:${experiment.id}`, + title: "Add durable concurrency regression test", + detail: `Attach and keep ${experiment.repair.durableRegressionTestId} as the durable regression for the confirmed defect.`, + durableRegressionTestId: experiment.repair.durableRegressionTestId + }); + if (experiment.repair.revalidated === true) { + treeStatus = "revalidated-fixture"; + investigationTasks.push( + `Revalidated fixture tree against ${experiment.repair.durableRegressionTestId} with the same seed plus alternate schedule evidence.` + ); + } + } + + if (cleanup.required && !cleanup.plan) { + blockers.push("Cleanup plan is required for disposable concurrency targets."); + verdict = "needs-human"; + } + + return baseReport({ + generatedAt: options.generatedAt, + experimentId: experiment.id, + experimentKind: experiment.kind, + verdict, + invariant: experiment.stateOracle.invariant, + bounds: gate.effective, + boundsBlocked: false, + candidates, + oracle, + cleanup, + repairTasks, + treeStatus, + blockers, + investigationTasks, + limitations + }); +} + +function loadExperiment(rootDir: string, file?: string): ConcurrencyExperimentInput | undefined { + if (!file) return undefined; + const absolute = resolve(rootDir, file); + if (!existsSync(absolute) || !statSync(absolute).isFile()) { + throw new Error(`Concurrency experiment file not found: ${file}`); + } + const parsed = JSON.parse(readFileSync(absolute, "utf8")) as ConcurrencyExperimentInput; + if (!parsed?.id || !parsed.kind || !parsed.stateOracle || !parsed.bounds || !parsed.schedule) { + throw new Error(`Concurrency experiment file is missing required fields: ${file}`); + } + return parsed; +} + +function createCleanup(plan: string | undefined, targetKind: ConcurrencyTargetKind): ConcurrencyCleanupEvidence { + const required = targetKind === "fixture-local" || targetKind === "disposable-local"; + return { + plan, + required, + proven: false, + requiredOnFailure: true, + limitations: [ + "Cleanup plans are recorded but not executed in this deterministic oracle slice.", + "Cleanup failure or ambiguous target forces needs-human before any future execution adapter." + ] + }; +} + +function baseReport(input: { + generatedAt?: string | undefined; + experimentId?: string | undefined; + experimentKind?: ConcurrencySafetyReport["experimentKind"]; + verdict: ConcurrencyVerdict; + invariant?: ConcurrencySafetyReport["invariant"]; + bounds: ConcurrencySafetyReport["bounds"]; + boundsBlocked: boolean; + candidates: ConcurrencySafetyReport["candidates"]; + oracle?: ConcurrencySafetyReport["oracle"]; + cleanup: ConcurrencyCleanupEvidence; + repairTasks: ConcurrencyRepairTask[]; + treeStatus: ConcurrencySafetyReport["treeStatus"]; + blockers: string[]; + investigationTasks: string[]; + limitations: string[]; +}): ConcurrencySafetyReport { + return { + tool: "CodeDecay", + schemaVersion: CONCURRENCY_EVIDENCE_SCHEMA_VERSION, + generatedAt: input.generatedAt ?? new Date().toISOString(), + experimentId: input.experimentId, + experimentKind: input.experimentKind, + verdict: input.verdict, + fullyVerified: false, + invariant: input.invariant, + bounds: input.bounds, + boundsBlocked: input.boundsBlocked, + candidates: input.candidates, + oracle: input.oracle, + cleanup: input.cleanup, + repairTasks: input.repairTasks, + treeStatus: input.treeStatus, + extensionBoundaries: [ + { id: "queues", status: "planned", detail: "Queue framework adapters remain extension points." }, + { id: "webhooks", status: "planned", detail: "Provider webhook redelivery adapters remain extension points." }, + { id: "cron-jobs", status: "planned", detail: "Cron overlap experiments remain extension points." }, + { id: "distributed-locks", status: "planned", detail: "Distributed lock adapters remain extension points." }, + { id: "transactional-outbox", status: "planned", detail: "Outbox dual-write adapters remain extension points." } + ], + blockers: input.blockers, + investigationTasks: input.investigationTasks, + limitations: input.limitations, + safety: { + commandsExecuted: false, + productionTargetAllowed: false, + networkCalled: false, + schedulerSpawned: false, + secretsRead: false + } + }; +} diff --git a/packages/knowledge/src/concurrency/bounds.ts b/packages/knowledge/src/concurrency/bounds.ts new file mode 100644 index 0000000..6193886 --- /dev/null +++ b/packages/knowledge/src/concurrency/bounds.ts @@ -0,0 +1,61 @@ +import { + CONCURRENCY_DEFAULT_BOUNDS, + type ConcurrencyBounds, + type ConcurrencyTargetKind +} from "./types"; + +export interface BoundsGateResult { + blocked: boolean; + reasons: string[]; + effective: ConcurrencyBounds; +} + +export function gateConcurrencyBounds(bounds: ConcurrencyBounds): BoundsGateResult { + const reasons: string[] = []; + const effective: ConcurrencyBounds = { + maxParallelism: bounds.maxParallelism, + repetitions: bounds.repetitions, + timeoutMs: bounds.timeoutMs, + targetKind: bounds.targetKind, + networkTarget: bounds.networkTarget + }; + + if (bounds.maxParallelism < 1 || bounds.maxParallelism > CONCURRENCY_DEFAULT_BOUNDS.maxParallelism) { + reasons.push( + `maxParallelism ${bounds.maxParallelism} is outside configured bound 1..${CONCURRENCY_DEFAULT_BOUNDS.maxParallelism}.` + ); + } + if (bounds.repetitions < 1 || bounds.repetitions > CONCURRENCY_DEFAULT_BOUNDS.repetitions) { + reasons.push( + `repetitions ${bounds.repetitions} is outside configured bound 1..${CONCURRENCY_DEFAULT_BOUNDS.repetitions}.` + ); + } + if (bounds.timeoutMs < 1 || bounds.timeoutMs > CONCURRENCY_DEFAULT_BOUNDS.timeoutMs) { + reasons.push( + `timeoutMs ${bounds.timeoutMs} is outside configured bound 1..${CONCURRENCY_DEFAULT_BOUNDS.timeoutMs}.` + ); + } + if (!isAllowedTarget(bounds.targetKind)) { + reasons.push(`Target kind "${bounds.targetKind}" is not allowed for concurrency experiments.`); + } + if (bounds.networkTarget && !isLocalNetworkTarget(bounds.networkTarget)) { + reasons.push(`Network target "${bounds.networkTarget}" is blocked; only fixture-local / localhost disposable targets are allowed.`); + } + + return { blocked: reasons.length > 0, reasons, effective }; +} + +function isAllowedTarget(kind: ConcurrencyTargetKind): boolean { + return kind === "fixture-local" || kind === "disposable-local"; +} + +function isLocalNetworkTarget(target: string): boolean { + const normalized = target.trim().toLowerCase(); + return ( + normalized === "fixture-local" || + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized.startsWith("http://127.0.0.1") || + normalized.startsWith("http://localhost") + ); +} diff --git a/packages/knowledge/src/concurrency/detect.ts b/packages/knowledge/src/concurrency/detect.ts new file mode 100644 index 0000000..9f74305 --- /dev/null +++ b/packages/knowledge/src/concurrency/detect.ts @@ -0,0 +1,117 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { relative, resolve } from "node:path"; +import type { ConcurrencyCandidate, ConcurrencyCandidateKind, ConcurrencyInvariant } from "./types"; + +const MAX_FILES = 50; +const MAX_FILE_BYTES = 1024 * 1024; + +interface Detector { + kind: ConcurrencyCandidateKind; + pattern: RegExp; + invariant: ConcurrencyInvariant; + note: string; +} + +const DETECTORS: Detector[] = [ + { + kind: "idempotency-key", + pattern: /\bidempotenc(y|yKey|y_key)\b/i, + invariant: "exactly-once-effect", + note: "Idempotency identity mentioned; keyword match is a candidate, not proof." + }, + { + kind: "retry", + pattern: /\b(retry|maxAttempts|max_retries)\b/i, + invariant: "bounded-retries", + note: "Retry configuration mentioned; confirm bounded attempts and duplicate-safe handling." + }, + { + kind: "job", + pattern: /\b(queue\.|BullMQ|SQS|consumeMessage|processJob)\b/i, + invariant: "at-least-once-safe", + note: "Queue/job surface mentioned; duplicate delivery must be oracle-tested." + }, + { + kind: "webhook", + pattern: /\bwebhook\b/i, + invariant: "exactly-once-effect", + note: "Webhook handler mentioned; providers commonly redeliver." + }, + { + kind: "transaction", + pattern: /\b(beginTransaction|withTransaction|START TRANSACTION)\b/i, + invariant: "no-lost-update", + note: "Transaction API mentioned; concurrent writers need an invariant." + }, + { + kind: "lock", + pattern: /\b(SELECT\s+FOR\s+UPDATE|advisory_lock|distributedLock|mutex)\b/i, + invariant: "no-lost-update", + note: "Lock API mentioned; verify coverage under concurrent schedules." + }, + { + kind: "outbox", + pattern: /\b(transactional\s+outbox|outbox_event|outbox)\b/i, + invariant: "at-least-once-safe", + note: "Outbox pattern mentioned; extension boundary for dual-write races." + }, + { + kind: "cron", + pattern: /\b(cron|schedule\.|node-cron)\b/i, + invariant: "exactly-once-effect", + note: "Cron/scheduler mention; overlapping runs need an invariant." + }, + { + kind: "route", + pattern: /\b(app\.(post|put|patch|delete)|router\.(post|put|patch|delete)|fastify\.(post|put|patch|delete))\b/i, + invariant: "exactly-once-effect", + note: "Mutating HTTP route mentioned; duplicate client retries are candidates." + } +]; + +export function detectConcurrencyCandidates(rootDir: string, files: string[]): ConcurrencyCandidate[] { + const root = realpathSync(rootDir); + const candidates: ConcurrencyCandidate[] = []; + for (const file of boundedFiles(root, files)) { + const absolute = resolve(root, file); + if (!existsSync(absolute) || !statSync(absolute).isFile()) continue; + if (statSync(absolute).size > MAX_FILE_BYTES) continue; + const content = readFileSync(absolute, "utf8"); + const relativePath = relative(root, absolute).replaceAll("\\", "/"); + for (const detector of DETECTORS) { + if (!detector.pattern.test(content)) continue; + const id = hashId(`${detector.kind}:${relativePath}:${detector.invariant}`); + candidates.push({ + id, + kind: detector.kind, + surface: relativePath, + sourceRef: relativePath, + citedEvidence: [`keyword:${detector.kind}`], + suggestedInvariant: detector.invariant, + note: detector.note + }); + } + } + return dedupe(candidates); +} + +function boundedFiles(root: string, files: string[]): string[] { + return files.slice(0, MAX_FILES).map((file) => relative(root, resolve(root, file)).replaceAll("\\", "/")); +} + +function hashId(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 12); +} + +function dedupe(candidates: ConcurrencyCandidate[]): ConcurrencyCandidate[] { + const seen = new Set(); + const out: ConcurrencyCandidate[] = []; + for (const item of candidates) { + const key = `${item.kind}:${item.surface}:${item.suggestedInvariant}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(item); + } + return out; +} diff --git a/packages/knowledge/src/concurrency/oracles.ts b/packages/knowledge/src/concurrency/oracles.ts new file mode 100644 index 0000000..395b601 --- /dev/null +++ b/packages/knowledge/src/concurrency/oracles.ts @@ -0,0 +1,185 @@ +import type { + ConcurrencyExperimentInput, + ConcurrencyOracleResult, + ConcurrencyTimelineEvent, + ConcurrencyVerdict +} from "./types"; + +const TOOL_VERSION = "codedecay-concurrency-oracle/1"; + +/** + * Deterministic in-process oracle over seeded fixture schedules. + * This is not a distributed scheduler; it evaluates declared implementation modes. + */ +export function evaluateConcurrencyOracle(experiment: ConcurrencyExperimentInput): ConcurrencyOracleResult { + if (experiment.kind === "probabilistic-stress") { + return { + verdict: "inconclusive-stress", + sideEffectCount: 0, + finalState: 0, + attemptIds: [], + timeline: [], + failures: [ + "Probabilistic stress-only evidence cannot prove absence of races; treat as inconclusive." + ], + seed: experiment.schedule.seed, + repetitions: experiment.bounds.repetitions, + toolVersion: TOOL_VERSION + }; + } + + if (experiment.schedule.steps.length === 0) { + return emptyResult(experiment, "unsupported-scheduler", ["Deterministic schedule has no steps."]); + } + + const sorted = [...experiment.schedule.steps].sort((a, b) => a.at - b.at || a.operationId.localeCompare(b.operationId)); + const operations = new Map(experiment.operations.map((op) => [op.id, op])); + const timeline: ConcurrencyTimelineEvent[] = []; + const attemptIds: string[] = []; + const seenKeys = new Set(); + let sideEffectCount = 0; + let state = 0; + const failures: string[] = []; + + // Group read-modify-write steps that share a barrier for lost-update simulation. + const sharedReadBaselines = new Map(); + + for (const step of sorted) { + const operation = operations.get(step.operationId); + if (!operation) { + failures.push(`Unknown operation ${step.operationId}.`); + continue; + } + const attemptId = `${experiment.schedule.seed}:${step.at}:${step.actor}:${operation.id}`; + attemptIds.push(attemptId); + const stateBefore = state; + let delta = 0; + + if (operation.type === "read-modify-write") { + const amount = operation.amount ?? 1; + if (experiment.implementation.mode === "lost-update") { + const barrierKey = step.barrier ?? `at:${step.at}`; + if (!sharedReadBaselines.has(barrierKey)) { + sharedReadBaselines.set(barrierKey, state); + } + const baseline = sharedReadBaselines.get(barrierKey) ?? state; + // Last writer wins from the shared read — classic lost update. + state = baseline + amount; + delta = amount; + sideEffectCount += 1; + } else if (experiment.implementation.mode === "versioned-update") { + state += amount; + delta = amount; + sideEffectCount += 1; + } else if (experiment.implementation.mode === "idempotent") { + const key = operation.payloadKey; + if (!seenKeys.has(key)) { + seenKeys.add(key); + state += amount; + delta = amount; + sideEffectCount += 1; + } + } else { + state += amount; + delta = amount; + sideEffectCount += 1; + } + } else { + const key = operation.payloadKey; + const amount = operation.amount ?? 1; + if (experiment.implementation.mode === "idempotent") { + if (!seenKeys.has(key)) { + seenKeys.add(key); + delta = amount; + sideEffectCount += 1; + state += amount; + } + } else { + delta = amount; + sideEffectCount += 1; + state += amount; + } + } + + timeline.push({ + at: step.at, + actor: step.actor, + operationId: operation.id, + attemptId, + barrier: step.barrier, + sideEffectDelta: delta, + stateBefore, + stateAfter: state + }); + } + + const verdict = resolveVerdict(experiment, sideEffectCount, state, failures); + return { + verdict, + sideEffectCount, + finalState: state, + attemptIds, + timeline, + failures, + seed: experiment.schedule.seed, + repetitions: experiment.bounds.repetitions, + toolVersion: TOOL_VERSION + }; +} + +function resolveVerdict( + experiment: ConcurrencyExperimentInput, + sideEffectCount: number, + finalState: number, + failures: string[] +): ConcurrencyVerdict { + if (failures.length) return "environment-failure"; + const oracle = experiment.stateOracle; + + if (oracle.invariant === "exactly-once-effect" || oracle.invariant === "at-least-once-safe") { + const expected = oracle.expectedSideEffects ?? 1; + if (sideEffectCount === expected) return "passed-oracle"; + return "confirmed-race"; + } + + if (oracle.invariant === "no-lost-update" || oracle.invariant === "monotonic-state") { + const expected = oracle.expectedFinalValue; + if (expected === undefined) { + failures.push("State oracle missing expectedFinalValue for lost-update / monotonic checks."); + return "environment-failure"; + } + if (finalState === expected) return "passed-oracle"; + return "confirmed-race"; + } + + if (oracle.invariant === "bounded-retries") { + const maxAttempts = experiment.retryPolicy?.maxAttempts ?? experiment.bounds.repetitions; + if (sideEffectCount <= maxAttempts) return "passed-oracle"; + return "confirmed-race"; + } + + if (oracle.invariant === "compensating-action") { + if (sideEffectCount <= (oracle.expectedSideEffects ?? 1)) return "passed-oracle"; + return "confirmed-race"; + } + + return "needs-human"; +} + +function emptyResult( + experiment: ConcurrencyExperimentInput, + verdict: ConcurrencyVerdict, + failures: string[] +): ConcurrencyOracleResult { + return { + verdict, + sideEffectCount: 0, + finalState: 0, + attemptIds: [], + timeline: [], + failures, + seed: experiment.schedule.seed, + repetitions: experiment.bounds.repetitions, + toolVersion: TOOL_VERSION + }; +} diff --git a/packages/knowledge/src/concurrency/render.ts b/packages/knowledge/src/concurrency/render.ts new file mode 100644 index 0000000..522cd57 --- /dev/null +++ b/packages/knowledge/src/concurrency/render.ts @@ -0,0 +1,65 @@ +import type { ConcurrencySafetyReport } from "./types"; + +export function renderConcurrencySafetyMarkdown(report: ConcurrencySafetyReport): string { + const lines = [ + "## CodeDecay Concurrency Safety", + "", + `Verdict: \`${report.verdict}\`; fullyVerified: \`${report.fullyVerified}\`; tree: \`${report.treeStatus}\`.`, + report.experimentId + ? `Experiment: \`${report.experimentId}\` (${report.experimentKind ?? "unknown"}); invariant: \`${report.invariant ?? "n/a"}\`.` + : "Experiment: none supplied.", + `Bounds: parallelism=${report.bounds.maxParallelism}, repetitions=${report.bounds.repetitions}, timeoutMs=${report.bounds.timeoutMs}, target=${report.bounds.targetKind}.`, + "Commands executed: no. Scheduler spawned: no. Network called: no.", + "", + "### Candidates", + "" + ]; + if (!report.candidates.length) lines.push("No concurrency candidates were detected from supplied surfaces."); + for (const candidate of report.candidates) { + lines.push( + `- \`${candidate.kind}\` \`${candidate.surface}\` → invariant \`${candidate.suggestedInvariant}\` (${candidate.note})` + ); + } + + lines.push("", "### Oracle", ""); + if (!report.oracle) { + lines.push("No oracle was evaluated."); + } else { + lines.push( + `Verdict \`${report.oracle.verdict}\`; sideEffects=${report.oracle.sideEffectCount}; finalState=${report.oracle.finalState}; seed=${report.oracle.seed}; tool=${report.oracle.toolVersion}.` + ); + for (const event of report.oracle.timeline) { + lines.push( + `- t=${event.at} actor=${event.actor} op=${event.operationId} attempt=${event.attemptId} Δ=${event.sideEffectDelta} state ${event.stateBefore}→${event.stateAfter}${event.barrier ? ` barrier=${event.barrier}` : ""}` + ); + } + for (const failure of report.oracle.failures) lines.push(`- failure: ${failure}`); + } + + lines.push("", "### Repair Tasks", ""); + if (!report.repairTasks.length) lines.push("No repair task was generated."); + for (const task of report.repairTasks) { + lines.push(`- **${task.title}**: ${task.detail}`); + } + + lines.push("", "### Blockers", ""); + if (!report.blockers.length) lines.push("No bound or cleanup blocker."); + for (const blocker of report.blockers) lines.push(`- ${blocker}`); + + lines.push("", "### Cleanup", ""); + lines.push(`- Required: \`${report.cleanup.required}\``); + lines.push(`- Plan: ${report.cleanup.plan ? `\`${report.cleanup.plan}\`` : "missing"}`); + lines.push(`- Proven: \`${report.cleanup.proven}\``); + + lines.push("", "### Extension Boundaries", ""); + for (const boundary of report.extensionBoundaries) { + lines.push(`- \`${boundary.id}\` (${boundary.status}): ${boundary.detail}`); + } + + lines.push("", "### Investigation Tasks", ""); + for (const task of report.investigationTasks) lines.push(`- ${task}`); + + lines.push("", "### Limitations", ""); + for (const limitation of report.limitations) lines.push(`- ${limitation}`); + return `${lines.join("\n")}\n`; +} diff --git a/packages/knowledge/src/concurrency/types.ts b/packages/knowledge/src/concurrency/types.ts new file mode 100644 index 0000000..61847fa --- /dev/null +++ b/packages/knowledge/src/concurrency/types.ts @@ -0,0 +1,193 @@ +export const CONCURRENCY_EVIDENCE_SCHEMA_VERSION = 1 as const; + +export const CONCURRENCY_DEFAULT_BOUNDS = { + maxParallelism: 8, + repetitions: 50, + timeoutMs: 30_000 +} as const; + +export type ConcurrencyTargetKind = + | "unspecified" + | "fixture-local" + | "disposable-local" + | "remote-unapproved" + | "production-like"; + +export type ConcurrencyExperimentKind = "deterministic-schedule" | "probabilistic-stress"; + +export type ConcurrencyInvariant = + | "exactly-once-effect" + | "at-least-once-safe" + | "no-lost-update" + | "monotonic-state" + | "bounded-retries" + | "compensating-action"; + +export type ConcurrencyImplementationMode = + | "non-idempotent" + | "idempotent" + | "lost-update" + | "versioned-update"; + +export type ConcurrencyVerdict = + | "confirmed-race" + | "passed-oracle" + | "flaky-suspicion" + | "inconclusive-stress" + | "environment-failure" + | "unsupported-scheduler" + | "bounds-blocked" + | "needs-human" + | "plan-ready"; + +export type ConcurrencyCandidateKind = + | "route" + | "job" + | "webhook" + | "transaction" + | "lock" + | "idempotency-key" + | "retry" + | "outbox" + | "cron"; + +export interface ConcurrencyBounds { + maxParallelism: number; + repetitions: number; + timeoutMs: number; + targetKind: ConcurrencyTargetKind; + networkTarget?: string | undefined; +} + +export interface ConcurrencyScheduleStep { + at: number; + operationId: string; + actor: string; + barrier?: string | undefined; +} + +export interface ConcurrencyOperation { + id: string; + type: "deliver-message" | "http-mutate" | "read-modify-write" | "retry"; + payloadKey: string; + amount?: number | undefined; +} + +export interface ConcurrencyStateOracle { + invariant: ConcurrencyInvariant; + expectedSideEffects?: number | undefined; + expectedFinalValue?: number | undefined; +} + +export interface ConcurrencyRepairEvidence { + durableRegressionTestId?: string | undefined; + revalidated?: boolean | undefined; +} + +export interface ConcurrencyExperimentInput { + id: string; + kind: ConcurrencyExperimentKind; + actors: string[]; + operations: ConcurrencyOperation[]; + schedule: { + seed: number; + steps: ConcurrencyScheduleStep[]; + }; + retryPolicy?: { + maxAttempts?: number | undefined; + duplicateDelivery?: boolean | undefined; + } | undefined; + faultPoints?: string[] | undefined; + stateOracle: ConcurrencyStateOracle; + implementation: { + mode: ConcurrencyImplementationMode; + }; + bounds: ConcurrencyBounds; + cleanup?: { + plan?: string | undefined; + } | undefined; + repair?: ConcurrencyRepairEvidence | undefined; +} + +export interface ConcurrencyCandidate { + id: string; + kind: ConcurrencyCandidateKind; + surface: string; + sourceRef: string; + citedEvidence: string[]; + suggestedInvariant: ConcurrencyInvariant; + note: string; +} + +export interface ConcurrencyTimelineEvent { + at: number; + actor: string; + operationId: string; + attemptId: string; + barrier?: string | undefined; + sideEffectDelta: number; + stateBefore: number; + stateAfter: number; +} + +export interface ConcurrencyOracleResult { + verdict: ConcurrencyVerdict; + sideEffectCount: number; + finalState: number; + attemptIds: string[]; + timeline: ConcurrencyTimelineEvent[]; + failures: string[]; + seed: number; + repetitions: number; + toolVersion: string; +} + +export interface ConcurrencyCleanupEvidence { + plan?: string | undefined; + required: boolean; + proven: false; + requiredOnFailure: true; + limitations: string[]; +} + +export interface ConcurrencyRepairTask { + id: string; + title: string; + detail: string; + durableRegressionTestId?: string | undefined; +} + +export interface ConcurrencyExtensionBoundary { + id: string; + status: "planned"; + detail: string; +} + +export interface ConcurrencySafetyReport { + tool: "CodeDecay"; + schemaVersion: typeof CONCURRENCY_EVIDENCE_SCHEMA_VERSION; + generatedAt: string; + experimentId?: string | undefined; + experimentKind?: ConcurrencyExperimentKind | undefined; + verdict: ConcurrencyVerdict; + fullyVerified: false; + invariant?: ConcurrencyInvariant | undefined; + bounds: ConcurrencyBounds; + boundsBlocked: boolean; + candidates: ConcurrencyCandidate[]; + oracle?: ConcurrencyOracleResult | undefined; + cleanup: ConcurrencyCleanupEvidence; + repairTasks: ConcurrencyRepairTask[]; + treeStatus: "unverified" | "revalidated-fixture"; + extensionBoundaries: ConcurrencyExtensionBoundary[]; + blockers: string[]; + investigationTasks: string[]; + limitations: string[]; + safety: { + commandsExecuted: false; + productionTargetAllowed: false; + networkCalled: false; + schedulerSpawned: false; + secretsRead: false; + }; +} diff --git a/packages/knowledge/src/index.ts b/packages/knowledge/src/index.ts index 6e0ead5..4aad425 100644 --- a/packages/knowledge/src/index.ts +++ b/packages/knowledge/src/index.ts @@ -85,6 +85,31 @@ export type { MigrationTargetKind, MigrationVerdict } from "./migration/types"; +export { analyzeConcurrencySafety } from "./concurrency/analyze"; +export type { AnalyzeConcurrencySafetyOptions } from "./concurrency/analyze"; +export { gateConcurrencyBounds } from "./concurrency/bounds"; +export { detectConcurrencyCandidates } from "./concurrency/detect"; +export { evaluateConcurrencyOracle } from "./concurrency/oracles"; +export { renderConcurrencySafetyMarkdown } from "./concurrency/render"; +export { + CONCURRENCY_DEFAULT_BOUNDS, + CONCURRENCY_EVIDENCE_SCHEMA_VERSION +} from "./concurrency/types"; +export type { + ConcurrencyBounds, + ConcurrencyCandidate, + ConcurrencyCandidateKind, + ConcurrencyCleanupEvidence, + ConcurrencyExperimentInput, + ConcurrencyExperimentKind, + ConcurrencyImplementationMode, + ConcurrencyInvariant, + ConcurrencyOracleResult, + ConcurrencyRepairTask, + ConcurrencySafetyReport, + ConcurrencyTargetKind, + ConcurrencyVerdict +} from "./concurrency/types"; export type { IngestRuntimeEvidenceOptions } from "./runtime/ingest"; export { renderRuntimeEvidenceMarkdown } from "./runtime/render"; export { RUNTIME_EVIDENCE_SCHEMA_VERSION } from "./runtime/types"; diff --git a/packages/knowledge/test/concurrency-safety.test.ts b/packages/knowledge/test/concurrency-safety.test.ts new file mode 100644 index 0000000..8f0fc12 --- /dev/null +++ b/packages/knowledge/test/concurrency-safety.test.ts @@ -0,0 +1,106 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { analyzeConcurrencySafety } from "../src/index"; + +const fixtures = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "concurrency"); +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("UAT concurrency safety (#687)", () => { + it("UAT-CONCURRENCY-1: duplicate delivery causes two side effects and is confirmed", () => { + const root = tempRoot(); + const report = analyzeConcurrencySafety({ + rootDir: root, + experimentFile: join(fixtures, "duplicate-delivery.json"), + cleanupPlan: "reset fixture side-effect counter", + generatedAt: "2026-08-06T00:00:00.000Z" + }); + expect(report.oracle?.sideEffectCount).toBe(2); + expect(report.verdict).toBe("confirmed-race"); + expect(report.fullyVerified).toBe(false); + expect(report.safety.commandsExecuted).toBe(false); + }); + + it("UAT-CONCURRENCY-2: concurrent updates expose a lost-update defect with a deterministic barrier", () => { + const root = tempRoot(); + const report = analyzeConcurrencySafety({ + rootDir: root, + experimentFile: join(fixtures, "lost-update.json") + }); + expect(report.oracle?.finalState).toBe(1); + expect(report.oracle?.timeline.every((event) => event.barrier === "shared-read")).toBe(true); + expect(report.verdict).toBe("confirmed-race"); + expect(report.invariant).toBe("no-lost-update"); + }); + + it("UAT-CONCURRENCY-3: an idempotent implementation passes the same oracle without false failure", () => { + const root = tempRoot(); + const report = analyzeConcurrencySafety({ + rootDir: root, + experimentFile: join(fixtures, "idempotent.json") + }); + expect(report.oracle?.sideEffectCount).toBe(1); + expect(report.verdict).toBe("passed-oracle"); + expect(report.fullyVerified).toBe(false); + }); + + it("UAT-CONCURRENCY-4: an inconclusive stress-only result cannot become verified safety", () => { + const root = tempRoot(); + const report = analyzeConcurrencySafety({ + rootDir: root, + experimentFile: join(fixtures, "stress-only.json") + }); + expect(report.verdict).toBe("inconclusive-stress"); + expect(report.experimentKind).toBe("probabilistic-stress"); + expect(report.fullyVerified).toBe(false); + expect(report.limitations.join(" ")).toMatch(/cannot become verified/i); + }); + + it("UAT-CONCURRENCY-5: parallelism, repetitions, timeout, network target, and cleanup stay inside configured bounds", () => { + const root = tempRoot(); + const report = analyzeConcurrencySafety({ + rootDir: root, + experimentFile: join(fixtures, "bounds-blocked.json") + }); + expect(report.verdict).toBe("bounds-blocked"); + expect(report.boundsBlocked).toBe(true); + expect(report.safety.commandsExecuted).toBe(false); + expect(report.safety.networkCalled).toBe(false); + expect(report.blockers.join(" ")).toMatch(/maxParallelism|repetitions|timeoutMs|Network target/i); + }); + + it("UAT-CONCURRENCY-6: the repair loop adds a durable regression test and revalidates the final tree", () => { + const root = tempRoot(); + write(root, "src/jobs/payout.ts", "export async function processJob() { /* retry queue idempotencyKey */ }\n"); + const report = analyzeConcurrencySafety({ + rootDir: root, + experimentFile: join(fixtures, "repair-loop.json"), + surfaceFiles: ["src/jobs/payout.ts"] + }); + expect(report.verdict).toBe("confirmed-race"); + expect(report.repairTasks.some((task) => task.durableRegressionTestId === "test/idempotency.regression.test.ts")).toBe(true); + expect(report.treeStatus).toBe("revalidated-fixture"); + expect(report.candidates.some((item) => item.kind === "job" || item.kind === "idempotency-key" || item.kind === "retry")).toBe(true); + expect(report.extensionBoundaries.map((item) => item.id)).toEqual( + expect.arrayContaining(["queues", "webhooks", "cron-jobs", "distributed-locks", "transactional-outbox"]) + ); + }); +}); + +function tempRoot(): string { + const root = join(tmpdir(), `codedecay-concurrency-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(root, { recursive: true }); + roots.push(root); + return root; +} + +function write(root: string, relativePath: string, contents: string): void { + const absolute = join(root, relativePath); + mkdirSync(dirname(absolute), { recursive: true }); + writeFileSync(absolute, contents, "utf8"); +} diff --git a/packages/knowledge/test/fixtures/concurrency/bounds-blocked.json b/packages/knowledge/test/fixtures/concurrency/bounds-blocked.json new file mode 100644 index 0000000..b177b8b --- /dev/null +++ b/packages/knowledge/test/fixtures/concurrency/bounds-blocked.json @@ -0,0 +1,24 @@ +{ + "id": "uat-concurrency-5-bounds", + "kind": "deterministic-schedule", + "actors": ["worker"], + "operations": [ + { "id": "deliver-1", "type": "deliver-message", "payloadKey": "job-1" } + ], + "schedule": { + "seed": 1, + "steps": [ + { "at": 0, "operationId": "deliver-1", "actor": "worker" } + ] + }, + "stateOracle": { "invariant": "exactly-once-effect", "expectedSideEffects": 1 }, + "implementation": { "mode": "idempotent" }, + "bounds": { + "maxParallelism": 64, + "repetitions": 500, + "timeoutMs": 120000, + "targetKind": "fixture-local", + "networkTarget": "https://api.production.example" + }, + "cleanup": { "plan": "n/a" } +} diff --git a/packages/knowledge/test/fixtures/concurrency/duplicate-delivery.json b/packages/knowledge/test/fixtures/concurrency/duplicate-delivery.json new file mode 100644 index 0000000..f140b4f --- /dev/null +++ b/packages/knowledge/test/fixtures/concurrency/duplicate-delivery.json @@ -0,0 +1,27 @@ +{ + "id": "uat-concurrency-1-duplicate-delivery", + "kind": "deterministic-schedule", + "actors": ["worker-a", "worker-b"], + "operations": [ + { "id": "deliver-1", "type": "deliver-message", "payloadKey": "job-42" }, + { "id": "deliver-2", "type": "deliver-message", "payloadKey": "job-42" } + ], + "schedule": { + "seed": 42, + "steps": [ + { "at": 0, "operationId": "deliver-1", "actor": "worker-a" }, + { "at": 1, "operationId": "deliver-2", "actor": "worker-b", "barrier": "after-first-ack" } + ] + }, + "retryPolicy": { "maxAttempts": 2, "duplicateDelivery": true }, + "faultPoints": ["duplicate-queue-redelivery"], + "stateOracle": { "invariant": "exactly-once-effect", "expectedSideEffects": 1 }, + "implementation": { "mode": "non-idempotent" }, + "bounds": { + "maxParallelism": 2, + "repetitions": 1, + "timeoutMs": 5000, + "targetKind": "fixture-local" + }, + "cleanup": { "plan": "reset fixture side-effect counter" } +} diff --git a/packages/knowledge/test/fixtures/concurrency/idempotent.json b/packages/knowledge/test/fixtures/concurrency/idempotent.json new file mode 100644 index 0000000..0fbe250 --- /dev/null +++ b/packages/knowledge/test/fixtures/concurrency/idempotent.json @@ -0,0 +1,27 @@ +{ + "id": "uat-concurrency-3-idempotent", + "kind": "deterministic-schedule", + "actors": ["worker-a", "worker-b"], + "operations": [ + { "id": "deliver-1", "type": "deliver-message", "payloadKey": "job-42" }, + { "id": "deliver-2", "type": "deliver-message", "payloadKey": "job-42" } + ], + "schedule": { + "seed": 42, + "steps": [ + { "at": 0, "operationId": "deliver-1", "actor": "worker-a" }, + { "at": 1, "operationId": "deliver-2", "actor": "worker-b", "barrier": "after-first-ack" } + ] + }, + "retryPolicy": { "maxAttempts": 2, "duplicateDelivery": true }, + "faultPoints": ["duplicate-queue-redelivery"], + "stateOracle": { "invariant": "exactly-once-effect", "expectedSideEffects": 1 }, + "implementation": { "mode": "idempotent" }, + "bounds": { + "maxParallelism": 2, + "repetitions": 1, + "timeoutMs": 5000, + "targetKind": "fixture-local" + }, + "cleanup": { "plan": "reset fixture side-effect counter" } +} diff --git a/packages/knowledge/test/fixtures/concurrency/lost-update.json b/packages/knowledge/test/fixtures/concurrency/lost-update.json new file mode 100644 index 0000000..e363a1a --- /dev/null +++ b/packages/knowledge/test/fixtures/concurrency/lost-update.json @@ -0,0 +1,26 @@ +{ + "id": "uat-concurrency-2-lost-update", + "kind": "deterministic-schedule", + "actors": ["client-a", "client-b"], + "operations": [ + { "id": "inc-a", "type": "read-modify-write", "payloadKey": "balance", "amount": 1 }, + { "id": "inc-b", "type": "read-modify-write", "payloadKey": "balance", "amount": 1 } + ], + "schedule": { + "seed": 7, + "steps": [ + { "at": 0, "operationId": "inc-a", "actor": "client-a", "barrier": "shared-read" }, + { "at": 0, "operationId": "inc-b", "actor": "client-b", "barrier": "shared-read" } + ] + }, + "faultPoints": ["lost-update-without-version"], + "stateOracle": { "invariant": "no-lost-update", "expectedFinalValue": 2 }, + "implementation": { "mode": "lost-update" }, + "bounds": { + "maxParallelism": 2, + "repetitions": 1, + "timeoutMs": 5000, + "targetKind": "fixture-local" + }, + "cleanup": { "plan": "reset fixture balance" } +} diff --git a/packages/knowledge/test/fixtures/concurrency/repair-loop.json b/packages/knowledge/test/fixtures/concurrency/repair-loop.json new file mode 100644 index 0000000..16ac38d --- /dev/null +++ b/packages/knowledge/test/fixtures/concurrency/repair-loop.json @@ -0,0 +1,31 @@ +{ + "id": "uat-concurrency-6-repair", + "kind": "deterministic-schedule", + "actors": ["worker-a", "worker-b"], + "operations": [ + { "id": "deliver-1", "type": "deliver-message", "payloadKey": "job-42" }, + { "id": "deliver-2", "type": "deliver-message", "payloadKey": "job-42" } + ], + "schedule": { + "seed": 42, + "steps": [ + { "at": 0, "operationId": "deliver-1", "actor": "worker-a" }, + { "at": 1, "operationId": "deliver-2", "actor": "worker-b", "barrier": "after-first-ack" } + ] + }, + "retryPolicy": { "maxAttempts": 2, "duplicateDelivery": true }, + "faultPoints": ["duplicate-queue-redelivery"], + "stateOracle": { "invariant": "exactly-once-effect", "expectedSideEffects": 1 }, + "implementation": { "mode": "non-idempotent" }, + "bounds": { + "maxParallelism": 2, + "repetitions": 1, + "timeoutMs": 5000, + "targetKind": "fixture-local" + }, + "cleanup": { "plan": "reset fixture side-effect counter" }, + "repair": { + "durableRegressionTestId": "test/idempotency.regression.test.ts", + "revalidated": true + } +} diff --git a/packages/knowledge/test/fixtures/concurrency/stress-only.json b/packages/knowledge/test/fixtures/concurrency/stress-only.json new file mode 100644 index 0000000..1bdba01 --- /dev/null +++ b/packages/knowledge/test/fixtures/concurrency/stress-only.json @@ -0,0 +1,23 @@ +{ + "id": "uat-concurrency-4-stress-only", + "kind": "probabilistic-stress", + "actors": ["hammer"], + "operations": [ + { "id": "hit", "type": "http-mutate", "payloadKey": "pay" } + ], + "schedule": { + "seed": 99, + "steps": [ + { "at": 0, "operationId": "hit", "actor": "hammer" } + ] + }, + "stateOracle": { "invariant": "exactly-once-effect", "expectedSideEffects": 1 }, + "implementation": { "mode": "idempotent" }, + "bounds": { + "maxParallelism": 4, + "repetitions": 10, + "timeoutMs": 5000, + "targetKind": "fixture-local" + }, + "cleanup": { "plan": "reset fixture" } +} diff --git a/packages/mcp/src/handlers/concurrency-safety.ts b/packages/mcp/src/handlers/concurrency-safety.ts new file mode 100644 index 0000000..f765a90 --- /dev/null +++ b/packages/mcp/src/handlers/concurrency-safety.ts @@ -0,0 +1,33 @@ +import { resolve } from "node:path"; +import { + analyzeConcurrencySafety, + renderConcurrencySafetyMarkdown +} from "@submuxhq/codedecay-knowledge"; +import type { StartMcpServerOptions } from "../server/types"; + +export interface ConcurrencySafetyToolInput { + cwd?: string | undefined; + format?: "markdown" | "json" | undefined; + experimentFile?: string | undefined; + surfaceFiles?: string[] | undefined; + targetKind?: "unspecified" | "fixture-local" | "disposable-local" | "remote-unapproved" | "production-like" | undefined; + cleanupPlan?: string | undefined; +} + +export async function runConcurrencySafetyTool( + options: StartMcpServerOptions, + input: ConcurrencySafetyToolInput +): Promise { + const rootDir = resolve(options.cwd ?? process.cwd(), input.cwd ?? "."); + const report = analyzeConcurrencySafety({ + rootDir, + experimentFile: input.experimentFile, + surfaceFiles: input.surfaceFiles, + targetKind: input.targetKind, + cleanupPlan: input.cleanupPlan + }); + if ((input.format ?? "markdown") === "json") { + return JSON.stringify(report, null, 2); + } + return renderConcurrencySafetyMarkdown(report); +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 446dd8b..82d9c61 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -31,6 +31,7 @@ import { runContextServiceTool } from "./handlers/context-service"; import { runServiceTopologyTool } from "./handlers/service-topology"; import { runRuntimeEvidenceTool } from "./handlers/runtime-evidence"; import { runMigrationSafetyTool } from "./handlers/migration-safety"; +import { runConcurrencySafetyTool } from "./handlers/concurrency-safety"; import type { StartMcpServerOptions } from "./server/types"; import { registerCodeDecayMcpTools } from "./tools/registry"; @@ -59,6 +60,7 @@ export { runContextServiceTool } from "./handlers/context-service"; export { runServiceTopologyTool } from "./handlers/service-topology"; export { runRuntimeEvidenceTool } from "./handlers/runtime-evidence"; export { runMigrationSafetyTool } from "./handlers/migration-safety"; +export { runConcurrencySafetyTool } from "./handlers/concurrency-safety"; export { runProductFailuresTool, runProductPlanTool, @@ -94,6 +96,7 @@ export function createCodeDecayMcpServer(options: StartMcpServerOptions): McpSer serviceTopology: (input) => runServiceTopologyTool(options, input), runtimeEvidence: (input) => runRuntimeEvidenceTool(options, input), migrationSafety: (input) => runMigrationSafetyTool(options, input), + concurrencySafety: (input) => runConcurrencySafetyTool(options, input), agentInvestigation: (input) => runAgentInvestigationTool(options, input), scopeCheck: (input) => runScopeCheckTool(options, input), designContractCheck: (input) => runDesignContractCheckTool(options, input), diff --git a/packages/mcp/src/tools/register-analysis.ts b/packages/mcp/src/tools/register-analysis.ts index 174dd5a..ec63cfc 100644 --- a/packages/mcp/src/tools/register-analysis.ts +++ b/packages/mcp/src/tools/register-analysis.ts @@ -15,7 +15,8 @@ import { contextServiceToolSchema, serviceTopologyToolSchema, runtimeEvidenceToolSchema, - migrationSafetyToolSchema + migrationSafetyToolSchema, + concurrencySafetyToolSchema } from "./schemas"; import type { AgentPreflightToolInput, @@ -33,7 +34,8 @@ import type { ContextServiceToolInput, ServiceTopologyToolInput, RuntimeEvidenceToolInput, - MigrationSafetyToolInput + MigrationSafetyToolInput, + ConcurrencySafetyToolInput } from "./types"; export function registerAnalysisMcpTools(server: McpServer, handlers: CodeDecayMcpToolHandlers): void { @@ -149,6 +151,13 @@ export function registerAnalysisMcpTools(server: McpServer, handlers: CodeDecayM async (input) => textResult(handlers.migrationSafety(input as MigrationSafetyToolInput)) ); + server.tool( + "concurrency_safety", + "Evaluate deterministic concurrency/idempotency experiment fixtures with bounds gates and state oracles. Does not spawn schedulers or contact production queues.", + concurrencySafetyToolSchema, + async (input) => textResult(handlers.concurrencySafety(input as ConcurrencySafetyToolInput)) + ); + server.tool( "scope_check", "Return a deterministic in-scope/out-of-scope verdict for the current PR or working tree.", diff --git a/packages/mcp/src/tools/registry.ts b/packages/mcp/src/tools/registry.ts index 2674627..f2271b7 100644 --- a/packages/mcp/src/tools/registry.ts +++ b/packages/mcp/src/tools/registry.ts @@ -22,7 +22,8 @@ import type { ContextServiceToolInput, ServiceTopologyToolInput, RuntimeEvidenceToolInput, - MigrationSafetyToolInput + MigrationSafetyToolInput, + ConcurrencySafetyToolInput } from "./types"; export interface CodeDecayMcpToolHandlers { @@ -41,6 +42,7 @@ export interface CodeDecayMcpToolHandlers { serviceTopology(input: ServiceTopologyToolInput): string | Promise; runtimeEvidence(input: RuntimeEvidenceToolInput): string | Promise; migrationSafety(input: MigrationSafetyToolInput): string | Promise; + concurrencySafety(input: ConcurrencySafetyToolInput): string | Promise; agentInvestigation(input: AgentInvestigationToolInput): string | Promise; scopeCheck(input: ScopeCheckToolInput): string | Promise; designContractCheck(input: DesignContractCheckToolInput): string | Promise; diff --git a/packages/mcp/src/tools/schemas.ts b/packages/mcp/src/tools/schemas.ts index 254a9a8..24041ae 100644 --- a/packages/mcp/src/tools/schemas.ts +++ b/packages/mcp/src/tools/schemas.ts @@ -157,6 +157,18 @@ export const migrationSafetyToolSchema = { rollbackFailed: z.boolean().optional().describe("Mark rollback as failed.") }; +export const concurrencySafetyToolSchema = { + cwd: cwdSchema, + format: formatSchema, + experimentFile: z.string().optional().describe("Repo-local concurrency experiment JSON fixture."), + surfaceFiles: z.array(z.string()).optional().describe("Source files to scan for concurrency candidates."), + targetKind: z + .enum(["unspecified", "fixture-local", "disposable-local", "remote-unapproved", "production-like"]) + .optional() + .describe("Concurrency experiment target classification."), + cleanupPlan: z.string().optional().describe("Disposable target cleanup plan.") +}; + export const agentSessionToolSchema = { cwd: cwdSchema, operation: z.enum(["start", "context", "checkpoint", "finish"]).describe("Session lifecycle operation."), diff --git a/packages/mcp/src/tools/types.ts b/packages/mcp/src/tools/types.ts index f18bb98..1491afd 100644 --- a/packages/mcp/src/tools/types.ts +++ b/packages/mcp/src/tools/types.ts @@ -86,6 +86,15 @@ export interface MigrationSafetyToolInput { rollbackFailed?: boolean | undefined; } +export interface ConcurrencySafetyToolInput { + cwd?: string | undefined; + format?: "markdown" | "json" | undefined; + experimentFile?: string | undefined; + surfaceFiles?: string[] | undefined; + targetKind?: "unspecified" | "fixture-local" | "disposable-local" | "remote-unapproved" | "production-like" | undefined; + cleanupPlan?: string | undefined; +} + export interface AgentSessionToolInput { cwd?: string | undefined; operation: "start" | "context" | "checkpoint" | "finish"; diff --git a/packages/mcp/test/mcp-concurrency-safety.test.ts b/packages/mcp/test/mcp-concurrency-safety.test.ts new file mode 100644 index 0000000..d8a78bb --- /dev/null +++ b/packages/mcp/test/mcp-concurrency-safety.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { copyFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runConcurrencySafetyTool } from "../src/index"; +import { createRepo } from "./helpers/mcp"; + +const fixtures = join(dirname(fileURLToPath(import.meta.url)), "../../knowledge/test/fixtures/concurrency"); + +describe("MCP concurrency_safety tool", () => { + it("confirms duplicate delivery without executing commands", async () => { + const repo = createRepo({ + "README.md": "# fixture\n" + }); + mkdirSync(join(repo, "experiments"), { recursive: true }); + copyFileSync(join(fixtures, "duplicate-delivery.json"), join(repo, "experiments", "duplicate.json")); + const output = await runConcurrencySafetyTool( + { cwd: repo }, + { + format: "json", + experimentFile: "experiments/duplicate.json", + cleanupPlan: "reset fixture" + } + ); + const report = JSON.parse(output) as { + verdict: string; + fullyVerified: boolean; + safety: { commandsExecuted: boolean; networkCalled: boolean }; + }; + expect(report.verdict).toBe("confirmed-race"); + expect(report.fullyVerified).toBe(false); + expect(report.safety.commandsExecuted).toBe(false); + expect(report.safety.networkCalled).toBe(false); + }); + + it("blocks over-budget experiments", async () => { + const repo = createRepo({ + "README.md": "# fixture\n" + }); + mkdirSync(join(repo, "experiments"), { recursive: true }); + copyFileSync(join(fixtures, "bounds-blocked.json"), join(repo, "experiments", "bounds.json")); + const output = await runConcurrencySafetyTool( + { cwd: repo }, + { format: "json", experimentFile: "experiments/bounds.json" } + ); + const report = JSON.parse(output) as { verdict: string; boundsBlocked: boolean }; + expect(report.verdict).toBe("bounds-blocked"); + expect(report.boundsBlocked).toBe(true); + }); +});