From c4471620ec826463493977601b56df97434882ce Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 14 Sep 2026 11:12:44 -0400 Subject: [PATCH 1/7] fix: recover scheduled evolution reviews and close render locks --- docs/reference.md | 23 +++++++++ src/ai-cli.test.ts | 58 +++++++++++++++++++--- src/ai.ts | 61 ++++++++++++++++++++---- src/evolution-loop.test.ts | 54 +++++++++++++++++++++ src/evolution-loop.ts | 67 +++++++++++++++----------- src/evolution-preflight.test.ts | 78 ++++++++++++++++++++++++++++++ src/evolution-preflight.ts | 82 ++++++++++++++++++++++++++++++++ src/project-render-apply.test.ts | 3 ++ src/project-render-apply.ts | 30 ++++++------ src/remote.ts | 2 +- 10 files changed, 400 insertions(+), 58 deletions(-) create mode 100644 src/evolution-preflight.test.ts create mode 100644 src/evolution-preflight.ts diff --git a/docs/reference.md b/docs/reference.md index 8c2b4b1a..ebbd4b8a 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -461,3 +461,26 @@ Most commands accept the same root controls: - `--root /path/to/.ai`: use an explicit canonical root - `--scope merged|global|project`: choose a discovery view - `--source builtin|global|project`: filter provenance in list/find/show/graph flows + +### Scheduled review preflight + +Before a scheduled review, verify that `fclt --version` succeeds from its configured +working directory, then run `fclt ai loop preflight --project --root .ai --json` +in the same execution environment. Use `--global` with the global canonical root +for a global review. Stop if a runtime manager requires trust; do not automatically +trust a generated checkout or bypass the runtime manager. + +Preflight creates missing state/review directories and removes temporary write +probes. It does not invoke reconciliation, acquire the semantic loop lock, or +create queue state. Its `ready`/`blocked` result reports each required directory +and the recovery action. A successful probe is point-in-time evidence, not a +reservation or a guarantee against a later permission change. Configure narrowly +scoped host write allowances; fclt does not alter the host sandbox policy. + +Only after preflight succeeds, invoke `fclt ai loop run --project --root .ai --scheduled --json` once. Errors before a report exists return JSON with +`queueAvailable: false`; do not interpret missing queue data as an empty queue. +Large loop JSON is flushed before exit so it can be piped to a bounded projection. + +Automatic drafting supports Markdown targets. Other targets remain proposed and +visible for manual implementation; a skipped `draft-proposal` mutation explains +why no draft was produced. These proposals do not authorize a canonical edit. diff --git a/src/ai-cli.test.ts b/src/ai-cli.test.ts index f91862ac..b97aeec4 100644 --- a/src/ai-cli.test.ts +++ b/src/ai-cli.test.ts @@ -45,10 +45,18 @@ async function captureConsole(fn: () => Promise) { afterEach(async () => { process.chdir(originalCwd); - process.env.HOME = originalHome; - process.env.FACULT_ROOT_DIR = originalRoot; - process.env.FACULT_ROOT_SCOPE = originalRootScope; - process.env.FACULT_LOCAL_STATE_DIR = originalLocalState; + for (const [key, value] of Object.entries({ + HOME: originalHome, + FACULT_ROOT_DIR: originalRoot, + FACULT_ROOT_SCOPE: originalRootScope, + FACULT_LOCAL_STATE_DIR: originalLocalState, + })) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } process.exitCode = 0; if (tempHome) { await rm(tempHome, { recursive: true, force: true }); @@ -57,6 +65,42 @@ afterEach(async () => { }); describe("ai CLI", () => { + it("returns JSON recovery when the loop fails before a report exists", async () => { + tempHome = await makeTempHome(); + process.env.HOME = tempHome; + process.env.FACULT_ROOT_DIR = join(tempHome, ".ai"); + process.chdir(tempHome); + const out = await captureConsole(async () => { + await aiCommand(["loop", "run", "--global", "--json"]); + }); + expect(out.errors).toEqual([]); + expect(process.exitCode).toBe(1); + expect(JSON.parse(out.logs.join("\n"))).toMatchObject({ + status: "failed", + phase: "command", + queueAvailable: false, + error: expect.stringContaining("disabled"), + recovery: expect.stringContaining("preflight"), + }); + }); + + it("reports a disabled preflight without invoking the loop", async () => { + tempHome = await makeTempHome(); + process.env.HOME = tempHome; + process.env.FACULT_ROOT_DIR = join(tempHome, ".ai"); + process.chdir(tempHome); + const out = await captureConsole(async () => { + await aiCommand(["loop", "preflight", "--global", "--json"]); + }); + expect(out.errors).toEqual([]); + expect(process.exitCode).toBe(1); + expect(JSON.parse(out.logs.join("\n"))).toMatchObject({ + status: "blocked", + enabled: false, + loopInvoked: false, + }); + }); + it("initializes and runs a structured source review through the ai namespace", async () => { tempHome = await makeTempHome(); process.env.HOME = tempHome; @@ -289,7 +333,7 @@ describe("ai CLI", () => { `fclt ai loop run --global --root '${rootDir}' --scheduled --json` ); await aiCommand(["loop", "run", "--global", "--root", rootDir, "--json"]); - process.env.FACULT_ROOT_DIR = undefined; + Reflect.deleteProperty(process.env, "FACULT_ROOT_DIR"); const allActivityOut = await captureConsole(async () => { await aiCommand([ "loop", @@ -1162,8 +1206,8 @@ describe("ai CLI", () => { it("keeps custom-global writeback, review, and apply state in global scope", async () => { tempHome = await makeTempHome(); process.env.HOME = tempHome; - process.env.FACULT_ROOT_DIR = undefined; - process.env.FACULT_ROOT_SCOPE = undefined; + Reflect.deleteProperty(process.env, "FACULT_ROOT_DIR"); + Reflect.deleteProperty(process.env, "FACULT_ROOT_SCOPE"); process.env.FACULT_LOCAL_STATE_DIR = join(tempHome, "state"); const rootDir = join(tempHome, "shared", ".ai"); const defaultTarget = join( diff --git a/src/ai.ts b/src/ai.ts index d47cd08f..5a456e70 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -439,6 +439,15 @@ async function firstExistingFile(paths: string[]): Promise { return null; } +export class UnsupportedProposalTargetError extends Error { + constructor(pathValue: string) { + super( + `Automatic drafting and apply support markdown targets only: ${pathValue}. Keep this proposal for manual implementation.` + ); + this.name = "UnsupportedProposalTargetError"; + } +} + function supportedDraftTarget(pathValue: string): boolean { return pathValue.toLowerCase().endsWith(".md"); } @@ -2177,9 +2186,7 @@ async function resolveProposalTargetNode( throw new Error(`Could not resolve target path for ${target}`); } if (!supportedDraftTarget(pathValue)) { - throw new Error( - `Apply currently supports markdown targets only: ${pathValue}` - ); + throw new UnsupportedProposalTargetError(pathValue); } return { ...node, @@ -2825,6 +2832,7 @@ Usage: fclt ai loop activity [--all|--global|--project] [--json] fclt ai loop resolve [--json] fclt ai loop history [--all|--global|--project] [--since ] [--until ] [--item ] [--scope-id ] [--event ] [--limit <1-200>] [--cursor ] [--json] + fclt ai loop preflight [--json] fclt ai loop run [--since ] [--until ] [--source ] [--dry-run] [--scheduled] [--json] The loop keeps a full machine-local review queue and emits a delta for @@ -3011,6 +3019,23 @@ async function loopCommand(argv: string[]) { runEvolutionLoop, } = await import("./evolution-loop"); try { + if (sub === "preflight") { + const { preflightEvolutionLoop } = await import("./evolution-preflight"); + const result = await preflightEvolutionLoop({ + homeDir, + rootDir, + scope: loopScope, + }); + await writeCliOutput( + json + ? JSON.stringify(result, null, 2) + : `loop preflight: ${result.status}\n${result.recovery ?? "Required paths are writable"}` + ); + if (result.status !== "ready") { + process.exitCode = 1; + } + return; + } if (sub === "enable") { const result = await enableEvolutionLoop({ homeDir, @@ -3020,7 +3045,7 @@ async function loopCommand(argv: string[]) { sourceIds: parseRepeatedFlag(commandArgs, "--source"), dryRun: commandArgs.includes("--dry-run"), }); - console.log( + await writeCliOutput( json ? JSON.stringify(result, null, 2) : `${result.dryRun ? "Would enable" : "Enabled"} evolution loop at ${result.automationPath}` @@ -3037,7 +3062,7 @@ async function loopCommand(argv: string[]) { if (!(result.dryRun || result.scheduler?.paused || !result.config)) { process.exitCode = 1; } - console.log( + await writeCliOutput( json ? JSON.stringify(result, null, 2) : result.config @@ -3054,7 +3079,7 @@ async function loopCommand(argv: string[]) { rootDir, scope: loopScope, }); - console.log( + await writeCliOutput( json ? JSON.stringify(result, null, 2) : [ @@ -3075,7 +3100,7 @@ async function loopCommand(argv: string[]) { if (!result) { throw new Error("No evolution loop report has been recorded"); } - console.log( + await writeCliOutput( json ? JSON.stringify(result, null, 2) : [ @@ -3225,7 +3250,7 @@ async function loopCommand(argv: string[]) { dryRun: commandArgs.includes("--dry-run"), trigger: commandArgs.includes("--scheduled") ? "scheduled" : "manual", }); - console.log( + await writeCliOutput( json ? JSON.stringify(result, null, 2) : [ @@ -3243,7 +3268,25 @@ async function loopCommand(argv: string[]) { } throw new Error(`Unknown loop command: ${sub}`); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); + const message = error instanceof Error ? error.message : String(error); + if (json && (sub === "run" || sub === "preflight")) { + await writeCliOutput( + JSON.stringify( + { + status: "failed", + phase: "command", + queueAvailable: false, + error: message, + recovery: + "Run fclt ai loop preflight for this scope in the same execution environment before another review.", + }, + null, + 2 + ) + ); + } else { + console.error(message); + } process.exitCode = 1; } } diff --git a/src/evolution-loop.test.ts b/src/evolution-loop.test.ts index 8cea7cd8..ac206b91 100644 --- a/src/evolution-loop.test.ts +++ b/src/evolution-loop.test.ts @@ -1687,6 +1687,60 @@ describe("evolution loop", () => { }); }); + it.each([ + false, + true, + ])("keeps unsupported proposal targets pending without duplication (recovery=%s)", async (recover) => { + const project = await makeProject(); + const targetPath = join( + project.projectRoot, + ".github", + "workflows", + "release.yml" + ); + await mkdir(dirname(targetPath), { recursive: true }); + const original = "name: Release\non: push\n"; + await Bun.write(targetPath, original); + await Bun.write( + join(project.projectRoot, "review.md"), + "## 2026-01-02 Capability review\n\nThe rule in @project/.github/workflows/release.yml needs a durable verification loop.\n" + ); + await enableEvolutionLoop(project); + if (recover) { + const writeback = await addWriteback({ + ...project, + kind: "capability_gap", + summary: "The workflow needs a durable verification loop", + suggestedDestination: "@project/.github/workflows/release.yml", + evidence: [{ type: "session", ref: "interrupted-draft" }], + }); + await proposeEvolution({ ...project, writebackIds: [writeback.id] }); + expect(await listProposals(project)).toHaveLength(1); + } + for (const date of ["2026-01-04", "2026-01-05"]) { + const report = await runEvolutionLoop({ + ...project, + since: "2026-01-01", + until: "2026-01-03", + now: () => new Date(`${date}T00:00:00.000Z`), + }); + expect(report.status).toBe("complete"); + expect(report.coverageComplete).toBe(true); + if (date === "2026-01-04") { + expect(report.mutations).toContainEqual( + expect.objectContaining({ type: "draft-proposal", applied: false }) + ); + } + const proposals = await listProposals(project); + expect(proposals).toHaveLength(1); + expect(proposals[0]?.status).toBe("proposed"); + expect(report.queue).toContainEqual( + expect.objectContaining({ kind: "proposal", state: "approval_needed" }) + ); + expect(await readFile(targetPath, "utf8")).toBe(original); + } + }); + it("records committed mutations when a later materialization step fails", async () => { const project = await makeProject(); await mkdir(join(project.rootDir, "instructions"), { recursive: true }); diff --git a/src/evolution-loop.ts b/src/evolution-loop.ts index b61835fe..c74fe49a 100644 --- a/src/evolution-loop.ts +++ b/src/evolution-loop.ts @@ -23,6 +23,7 @@ import { listProposals, listWritebacks, proposeEvolution, + UnsupportedProposalTargetError, } from "./ai"; import { facultAiEvolutionLoopAuditPath, @@ -485,7 +486,7 @@ async function appendLoopAudit( ); } -async function loadConfig(args: { +export async function loadEvolutionLoopConfig(args: { homeDir: string; rootDir: string; }): Promise { @@ -598,7 +599,7 @@ async function enableEvolutionLoopScoped(args: { dryRun: boolean; }> { const now = (args.now?.() ?? new Date()).toISOString(); - const current = await loadConfig(args); + const current = await loadEvolutionLoopConfig(args); const inferredScope = projectRootFromAiRoot(args.rootDir, args.homeDir) ? "project" : "global"; @@ -730,7 +731,7 @@ async function disableEvolutionLoopScoped(args: { dryRun: boolean; scheduler: { paused: boolean; error?: string } | null; }> { - const current = await loadConfig(args); + const current = await loadEvolutionLoopConfig(args); if (!current) { return { config: null, @@ -1187,6 +1188,30 @@ async function materializeSignals(args: { if (!args.review.coverageComplete) { return plans; } + const draftForReview = async (proposal: AiProposalRecord, reason: string) => { + try { + const drafted = await draftProposal(proposal.id, { + homeDir: args.homeDir, + rootDir: args.rootDir, + }); + await recordPlan({ + type: "draft-proposal", + target: drafted.id, + reason, + applied: true, + }); + } catch (error) { + if (!(error instanceof UnsupportedProposalTargetError)) { + throw error; + } + await recordPlan({ + type: "draft-proposal", + target: proposal.id, + reason: error.message, + applied: false, + }); + } + }; const existing = await listWritebacks({ homeDir: args.homeDir, rootDir: args.rootDir, @@ -1336,16 +1361,10 @@ async function materializeSignals(args: { }); } if (activeProposal.status === "proposed") { - const drafted = await draftProposal(activeProposal.id, { - homeDir: args.homeDir, - rootDir: args.rootDir, - }); - await recordPlan({ - type: "draft-proposal", - target: drafted.id, - reason: "Recovered an existing undrafted proposal from a prior run", - applied: true, - }); + await draftForReview( + activeProposal, + "Recovered an existing undrafted proposal from a prior run" + ); } continue; } @@ -1361,16 +1380,10 @@ async function materializeSignals(args: { reason: `Assessment recommended a proposal for ${target}`, applied: true, }); - const drafted = await draftProposal(proposal.id, { - homeDir: args.homeDir, - rootDir: args.rootDir, - }); - await recordPlan({ - type: "draft-proposal", - target: drafted.id, - reason: "Drafted the review artifact; canonical apply remains gated", - applied: true, - }); + await draftForReview( + proposal, + "Drafted the review artifact; canonical apply remains gated" + ); } } const projectRoot = @@ -1687,7 +1700,7 @@ async function evolutionLoopStatusScoped(args: { auditPath: string; reportDir: string; }> { - const config = await loadConfig(args); + const config = await loadEvolutionLoopConfig(args); const state = await loadState(args); const scheduler = config ? await automationStatus({ @@ -1774,7 +1787,7 @@ export async function diagnoseEvolutionLoop(args: { async () => { let config: EvolutionLoopConfig | null; try { - config = await loadConfig(args); + config = await loadEvolutionLoopConfig(args); } catch (error) { return { configurationState: "invalid" as const, @@ -2030,7 +2043,7 @@ async function runEvolutionLoopScoped(args: { onLockAcquired?: () => void | Promise; openLockFile?: (path: string, flags: "wx") => Promise; }): Promise { - const loadedConfig = await loadConfig(args); + const loadedConfig = await loadEvolutionLoopConfig(args); if (!(loadedConfig?.enabled || args.dryRun)) { throw new Error( "Evolution loop is disabled. Run `fclt ai loop enable` first." @@ -2061,7 +2074,7 @@ async function runEvolutionLoopScoped(args: { const lockPath = facultAiEvolutionLoopLockPath(args.homeDir, args.rootDir); const execute = async (): Promise => { if (!args.dryRun) { - const lockedConfig = await loadConfig(args); + const lockedConfig = await loadEvolutionLoopConfig(args); if (!lockedConfig?.enabled) { throw new Error( "Evolution loop is disabled. Run `fclt ai loop enable` first." diff --git a/src/evolution-preflight.test.ts b/src/evolution-preflight.test.ts new file mode 100644 index 00000000..f2d0f2a5 --- /dev/null +++ b/src/evolution-preflight.test.ts @@ -0,0 +1,78 @@ +import { afterEach, expect, it } from "bun:test"; +import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { enableEvolutionLoop } from "./evolution-loop"; +import { preflightEvolutionLoop } from "./evolution-preflight"; +import { + facultAiEvolutionLoopConfigPath, + facultAiEvolutionLoopStatePath, + facultAiEvolutionReviewDir, +} from "./paths"; + +const roots: string[] = []; +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); +async function setup(scope: "project" | "global") { + const homeDir = await mkdtemp(join(tmpdir(), "fclt-preflight-test-")); + roots.push(homeDir); + const rootDir = + scope === "project" ? join(homeDir, "repo", ".ai") : join(homeDir, ".ai"); + await mkdir(rootDir, { recursive: true }); + await enableEvolutionLoop({ homeDir, rootDir, scope }); + return { homeDir, rootDir, scope }; +} +for (const scope of ["project", "global"] as const) { + it(`preflights ${scope} writes without creating queue state or leaving probes`, async () => { + const args = await setup(scope); + const result = await preflightEvolutionLoop(args); + expect(result.status).toBe("ready"); + expect(result.loopInvoked).toBe(false); + expect( + await Bun.file( + facultAiEvolutionLoopStatePath(args.homeDir, args.rootDir) + ).exists() + ).toBe(false); + for (const check of result.checks) { + expect(check.writable).toBe(true); + expect( + (await readdir(check.path)).some((name) => + name.startsWith(".fclt-preflight-") + ) + ).toBe(false); + } + }); +} +it("reports an inaccessible review destination before loop execution", async () => { + const args = await setup("project"); + const path = facultAiEvolutionReviewDir(args.homeDir, args.rootDir); + await mkdir(dirname(path), { recursive: true }); + await Bun.write(path, "occupied"); + const result = await preflightEvolutionLoop(args); + expect(result.status).toBe("blocked"); + expect(result.loopInvoked).toBe(false); + expect(result.checks).toContainEqual( + expect.objectContaining({ path, writable: false }) + ); + expect(result.recovery).toContain(path); + expect( + await Bun.file( + facultAiEvolutionLoopStatePath(args.homeDir, args.rootDir) + ).exists() + ).toBe(false); +}); + +it("does not report malformed enabled configuration as ready", async () => { + const args = await setup("project"); + await Bun.write( + facultAiEvolutionLoopConfigPath(args.homeDir, args.rootDir), + JSON.stringify({ enabled: true }) + ); + const result = await preflightEvolutionLoop(args); + expect(result.status).toBe("blocked"); + expect(result.configError).toContain("schema"); + expect(result.loopInvoked).toBe(false); +}); diff --git a/src/evolution-preflight.ts b/src/evolution-preflight.ts new file mode 100644 index 00000000..4e3ec158 --- /dev/null +++ b/src/evolution-preflight.ts @@ -0,0 +1,82 @@ +import { mkdir, mkdtemp, rmdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { loadEvolutionLoopConfig } from "./evolution-loop"; +import { + facultAiEvolutionLoopConfigPath, + facultAiEvolutionLoopLockPath, + facultAiEvolutionLoopReportDir, + facultAiEvolutionReviewDir, + facultAiReconciliationReviewDir, + facultAiWritebackReviewDir, + projectRootFromAiRoot, + withFacultRootScope, +} from "./paths"; + +/** Probe the actual execution environment without reconciling or creating queue state. */ +export async function preflightEvolutionLoop(args: { + homeDir: string; + rootDir: string; + scope?: "project" | "global"; +}) { + return await withFacultRootScope( + { + rootDir: args.rootDir, + scope: + args.scope ?? + (projectRootFromAiRoot(args.rootDir, args.homeDir) + ? "project" + : "global"), + }, + async () => { + const checks: Array<{ path: string; writable: boolean; error?: string }> = + []; + let enabled = false; + let configError: string | undefined; + try { + const config = await loadEvolutionLoopConfig(args); + enabled = config?.enabled === true; + } catch (error) { + configError = error instanceof Error ? error.message : String(error); + } + const paths = new Set([ + dirname(facultAiEvolutionLoopConfigPath(args.homeDir, args.rootDir)), + dirname(facultAiEvolutionLoopLockPath(args.homeDir, args.rootDir)), + facultAiEvolutionLoopReportDir(args.homeDir, args.rootDir), + facultAiEvolutionReviewDir(args.homeDir, args.rootDir), + facultAiReconciliationReviewDir(args.homeDir, args.rootDir), + facultAiWritebackReviewDir(args.homeDir, args.rootDir), + ]); + for (const path of paths) { + try { + await mkdir(path, { recursive: true }); + const probe = await mkdtemp(join(path, ".fclt-preflight-")); + await rmdir(probe); + checks.push({ path, writable: true }); + } catch (error) { + checks.push({ + path, + writable: false, + error: error instanceof Error ? error.message : String(error), + }); + } + } + const denied = checks.filter((check) => !check.writable); + return { + version: 1, + status: enabled && denied.length === 0 ? "ready" : "blocked", + queueAvailable: false, + loopInvoked: false, + runtime: process.execPath, + enabled, + configError, + checks, + recovery: + denied.length > 0 + ? `Authorize writes to these fclt state/review directories in the task execution environment: ${denied.map((check) => check.path).join(", ")}. Run preflight again before invoking the loop.` + : enabled + ? null + : "Inspect the loop configuration and enable the intended scope before invoking the loop.", + }; + } + ); +} diff --git a/src/project-render-apply.test.ts b/src/project-render-apply.test.ts index 575f9bdb..53bde9fc 100644 --- a/src/project-render-apply.test.ts +++ b/src/project-render-apply.test.ts @@ -550,6 +550,9 @@ sources = ["AGENTS.project.md"] ); unblock?.(); await first; + // A rejected contender must close its descriptor, not leave it to GC. + Bun.gc(true); + await Bun.sleep(20); }); it("fails closed on a malformed ownership receipt", async () => { diff --git a/src/project-render-apply.ts b/src/project-render-apply.ts index 001b460c..54e6a01c 100644 --- a/src/project-render-apply.ts +++ b/src/project-render-apply.ts @@ -933,22 +933,24 @@ async function withMutationLock( constants.O_CREAT + constants.O_RDWR + (constants.O_NOFOLLOW ?? 0), 0o600 ); - const metadata = await descriptor.stat(); - const expectedOwner = process.getuid?.(); - if ( - !metadata.isFile() || - metadata.nlink !== 1 || - metadata.mode % 0o100 !== 0 || - (expectedOwner !== undefined && metadata.uid !== expectedOwner) - ) { - await descriptor.close(); - throw new Error("Project render mutation lock is unsafe."); - } - const release = acquireExclusiveAdvisoryLock(descriptor.fd); try { - return await operation(); + const metadata = await descriptor.stat(); + const expectedOwner = process.getuid?.(); + if ( + !metadata.isFile() || + metadata.nlink !== 1 || + metadata.mode % 0o100 !== 0 || + (expectedOwner !== undefined && metadata.uid !== expectedOwner) + ) { + throw new Error("Project render mutation lock is unsafe."); + } + const release = acquireExclusiveAdvisoryLock(descriptor.fd); + try { + return await operation(); + } finally { + release(); + } } finally { - release(); await descriptor.close(); } } diff --git a/src/remote.ts b/src/remote.ts index f8432318..3ecf851c 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -602,7 +602,7 @@ Keep the result concise, continuity-aware, and operational. Retain coverage in t `, prompt: `Goal: run the configured fclt closed-loop evolution review for this cwd and report only decision-relevant changes. -Run \`fclt ai loop run {{loopScopeFlag}} {{loopRootArg}} --scheduled --json\` exactly once from the configured cwd. The rendered command uses the native shell contract: PowerShell on Windows and POSIX shell syntax elsewhere. +Before the review, run \`fclt --version\` from the configured cwd. If the runtime manager reports an untrusted configuration, stop and report that trust boundary; never automatically trust or bypass it. Then run \`fclt ai loop preflight {{loopScopeFlag}} {{loopRootArg}} --json\` in the same execution environment. This checks machine-local state and review-directory writes without invoking the loop. If preflight is blocked, report the exact paths and recovery action; do not invoke the loop or claim queue coverage. Do not change sandbox policy automatically.\n\nAfter preflight reports ready, run \`fclt ai loop run {{loopScopeFlag}} {{loopRootArg}} --scheduled --json\` exactly once from the configured cwd. The rendered command uses the native shell contract: PowerShell on Windows and POSIX shell syntax elsewhere. Use the returned full queue for current truth, but keep the user-facing notification delta-only: - report new or changed decisions, From e25be7f92948b9cd31e786678666ee76905d0e26 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 14 Sep 2026 11:18:58 -0400 Subject: [PATCH 2/7] test: preserve authored TOML across Bun parser versions --- src/projects.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/projects.test.ts b/src/projects.test.ts index bcf9ab53..fdb40fb4 100644 --- a/src/projects.test.ts +++ b/src/projects.test.ts @@ -1909,9 +1909,8 @@ ghs_APP_ID.${"a".repeat(240)}.${"b".repeat(240)} const updated = nextPlan.canonicalWrites[1]?.content ?? ""; expect(Bun.TOML.parse(updated)).toMatchObject({ "after]quoted": { owned: true }, - custom: { - description: "\n[project]\nthis is authored text, not a table header", - }, + custom: (Bun.TOML.parse(existing) as { custom: { description: string } }) + .custom, project: { cadence: "weekly" }, }); expect(updated).toContain(authoredPrefix); From 0060a6f3e9d1bbd6a87ec18425b1cf7c9d3e830f Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 14 Sep 2026 11:24:11 -0400 Subject: [PATCH 3/7] fix: cover runtime sinks and context failures in loop preflight --- src/ai-cli.test.ts | 22 ++++ src/ai.ts | 184 ++++++++++++++++++-------------- src/evolution-preflight.test.ts | 23 ++++ src/evolution-preflight.ts | 15 +++ src/paths.test.ts | 15 ++- 5 files changed, 178 insertions(+), 81 deletions(-) diff --git a/src/ai-cli.test.ts b/src/ai-cli.test.ts index b97aeec4..d6bad662 100644 --- a/src/ai-cli.test.ts +++ b/src/ai-cli.test.ts @@ -84,6 +84,28 @@ describe("ai CLI", () => { }); }); + it.each([ + "run", + "preflight", + ])("returns JSON for %s context resolution failures", async (sub) => { + tempHome = await makeTempHome(); + process.env.HOME = tempHome; + Reflect.deleteProperty(process.env, "FACULT_ROOT_DIR"); + Reflect.deleteProperty(process.env, "FACULT_ROOT_SCOPE"); + process.chdir(tempHome); + const out = await captureConsole(async () => { + await aiCommand(["loop", sub, "--project", "--json"]); + }); + expect(out.errors).toEqual([]); + expect(process.exitCode).toBe(1); + expect(JSON.parse(out.logs.join("\n"))).toMatchObject({ + status: "failed", + phase: "command", + queueAvailable: false, + error: expect.any(String), + }); + }); + it("reports a disabled preflight without invoking the loop", async () => { tempHome = await makeTempHome(); process.env.HOME = tempHome; diff --git a/src/ai.ts b/src/ai.ts index 5a456e70..36a7c2b8 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -2954,71 +2954,73 @@ function parseIntegerFlag(argv: string[], flag: string): number | undefined { } async function loopCommand(argv: string[]) { - const parsed = parseCliContextArgs(argv); - const [sub, ...commandArgs] = parsed.argv; - if (!sub || sub === "--help" || sub === "-h" || sub === "help") { - console.log(loopHelp()); - return; - } - if (commandArgs.includes("--help") || commandArgs.includes("-h")) { - console.log(loopHelp()); - return; - } - if (sub === "resolve") { - if (parsed.rootArg || parsed.scope !== "merged") { - throw new Error( - "Activity locator resolution does not accept caller-supplied root or scope authority" - ); + const json = argv.includes("--json"); + let sub = argv[0]; + try { + const parsed = parseCliContextArgs(argv); + const [parsedSub, ...commandArgs] = parsed.argv; + sub = parsedSub; + if (!sub || sub === "--help" || sub === "-h" || sub === "help") { + console.log(loopHelp()); + return; } - const locatorArgs = commandArgs.filter((arg) => arg !== "--json"); - const locator = locatorArgs[0]; - if ( - locatorArgs.length !== 1 || - !locator || - locator.startsWith("-") || - commandArgs.some((arg) => arg.startsWith("-") && arg !== "--json") - ) { - throw new Error( - "loop resolve accepts exactly one opaque locator and optional --json" + if (commandArgs.includes("--help") || commandArgs.includes("-h")) { + console.log(loopHelp()); + return; + } + if (sub === "resolve") { + if (parsed.rootArg || parsed.scope !== "merged") { + throw new Error( + "Activity locator resolution does not accept caller-supplied root or scope authority" + ); + } + const locatorArgs = commandArgs.filter((arg) => arg !== "--json"); + const locator = locatorArgs[0]; + if ( + locatorArgs.length !== 1 || + !locator || + locator.startsWith("-") || + commandArgs.some((arg) => arg.startsWith("-") && arg !== "--json") + ) { + throw new Error( + "loop resolve accepts exactly one opaque locator and optional --json" + ); + } + const { renderActivityActionResolution, resolveActivityActionLocator } = + await import("./activity-action"); + const result = await resolveActivityActionLocator({ + homeDir: process.env.HOME ?? "", + locator, + }); + console.log( + commandArgs.includes("--json") + ? JSON.stringify(result, null, 2) + : renderActivityActionResolution(result) ); + if (result.status === "rejected") { + process.exitCode = 1; + } + return; } - const { renderActivityActionResolution, resolveActivityActionLocator } = - await import("./activity-action"); - const result = await resolveActivityActionLocator({ - homeDir: process.env.HOME ?? "", - locator, + const rootDir = resolveCliContextRoot({ + rootArg: parsed.rootArg, + scope: parsed.scope, + cwd: process.cwd(), }); - console.log( - commandArgs.includes("--json") - ? JSON.stringify(result, null, 2) - : renderActivityActionResolution(result) - ); - if (result.status === "rejected") { - process.exitCode = 1; - } - return; - } - const rootDir = resolveCliContextRoot({ - rootArg: parsed.rootArg, - scope: parsed.scope, - cwd: process.cwd(), - }); - const homeDir = process.env.HOME ?? ""; - const loopScope = - parsed.scope === "global" || parsed.scope === "project" - ? parsed.scope - : projectRootFromAiRoot(rootDir, homeDir) - ? "project" - : "global"; - const json = commandArgs.includes("--json"); - const { - disableEvolutionLoop, - enableEvolutionLoop, - evolutionLoopStatus, - latestEvolutionLoopReport, - runEvolutionLoop, - } = await import("./evolution-loop"); - try { + const homeDir = process.env.HOME ?? ""; + const loopScope = + parsed.scope === "global" || parsed.scope === "project" + ? parsed.scope + : projectRootFromAiRoot(rootDir, homeDir) + ? "project" + : "global"; + const { + disableEvolutionLoop, + enableEvolutionLoop, + evolutionLoopStatus, + latestEvolutionLoopReport, + runEvolutionLoop, + } = await import("./evolution-loop"); if (sub === "preflight") { const { preflightEvolutionLoop } = await import("./evolution-preflight"); const result = await preflightEvolutionLoop({ @@ -3790,23 +3792,49 @@ export async function aiCommand( } if (!rootScopeActive) { - const parsed = parseCliContextArgs(rest); - const homeDir = process.env.HOME ?? ""; - const rootDir = resolveCliContextRoot({ - homeDir, - rootArg: parsed.rootArg, - scope: parsed.scope, - cwd: process.cwd(), - }); - const scope = resolveCliContextScope({ - homeDir, - rootDir, - scope: parsed.scope, - }); - await withFacultRootScope({ rootDir, scope }, async () => - aiCommand(argv, true) - ); - return; + try { + const parsed = parseCliContextArgs(rest); + const homeDir = process.env.HOME ?? ""; + const rootDir = resolveCliContextRoot({ + homeDir, + rootArg: parsed.rootArg, + scope: parsed.scope, + cwd: process.cwd(), + }); + const scope = resolveCliContextScope({ + homeDir, + rootDir, + scope: parsed.scope, + }); + await withFacultRootScope({ rootDir, scope }, async () => + aiCommand(argv, true) + ); + return; + } catch (error) { + if ( + sub !== "loop" || + !rest.includes("--json") || + (rest[0] !== "run" && rest[0] !== "preflight") + ) { + throw error; + } + await writeCliOutput( + JSON.stringify( + { + status: "failed", + phase: "command", + queueAvailable: false, + error: error instanceof Error ? error.message : String(error), + recovery: + "Resolve the project or global scope before running fclt ai loop preflight in the same execution environment.", + }, + null, + 2 + ) + ); + process.exitCode = 1; + return; + } } if (sub === "writeback") { diff --git a/src/evolution-preflight.test.ts b/src/evolution-preflight.test.ts index f2d0f2a5..0ad23a78 100644 --- a/src/evolution-preflight.test.ts +++ b/src/evolution-preflight.test.ts @@ -5,9 +5,12 @@ import { dirname, join } from "node:path"; import { enableEvolutionLoop } from "./evolution-loop"; import { preflightEvolutionLoop } from "./evolution-preflight"; import { + facultAiActivityHistorySegmentDir, + facultAiDraftDir, facultAiEvolutionLoopConfigPath, facultAiEvolutionLoopStatePath, facultAiEvolutionReviewDir, + facultAiReconciliationStatePath, } from "./paths"; const roots: string[] = []; @@ -76,3 +79,23 @@ it("does not report malformed enabled configuration as ready", async () => { expect(result.configError).toContain("schema"); expect(result.loopInvoked).toBe(false); }); + +for (const destination of [ + (home: string, root: string) => + dirname(facultAiReconciliationStatePath(home, root)), + facultAiActivityHistorySegmentDir, + facultAiDraftDir, +]) { + it("blocks on an unavailable runtime sibling even when the loop directory is writable", async () => { + const args = await setup("project"); + const path = destination(args.homeDir, args.rootDir); + await mkdir(dirname(path), { recursive: true }); + await Bun.write(path, "occupied"); + const result = await preflightEvolutionLoop(args); + expect(result.status).toBe("blocked"); + expect(result.checks).toContainEqual( + expect.objectContaining({ path, writable: false }) + ); + expect(result.loopInvoked).toBe(false); + }); +} diff --git a/src/evolution-preflight.ts b/src/evolution-preflight.ts index 4e3ec158..453a1f97 100644 --- a/src/evolution-preflight.ts +++ b/src/evolution-preflight.ts @@ -2,11 +2,18 @@ import { mkdir, mkdtemp, rmdir } from "node:fs/promises"; import { dirname, join } from "node:path"; import { loadEvolutionLoopConfig } from "./evolution-loop"; import { + facultAiActivityHistoryDir, + facultAiActivityHistorySegmentDir, + facultAiDraftDir, facultAiEvolutionLoopConfigPath, facultAiEvolutionLoopLockPath, facultAiEvolutionLoopReportDir, facultAiEvolutionReviewDir, + facultAiJournalPath, + facultAiProposalDir, facultAiReconciliationReviewDir, + facultAiReconciliationStatePath, + facultAiWritebackQueuePath, facultAiWritebackReviewDir, projectRootFromAiRoot, withFacultRootScope, @@ -39,6 +46,14 @@ export async function preflightEvolutionLoop(args: { configError = error instanceof Error ? error.message : String(error); } const paths = new Set([ + facultAiActivityHistoryDir(args.homeDir, args.rootDir), + facultAiActivityHistorySegmentDir(args.homeDir, args.rootDir), + facultAiDraftDir(args.homeDir, args.rootDir), + dirname(facultAiJournalPath(args.homeDir, args.rootDir)), + facultAiProposalDir(args.homeDir, args.rootDir), + dirname(facultAiReconciliationStatePath(args.homeDir, args.rootDir)), + dirname(facultAiWritebackQueuePath(args.homeDir, args.rootDir)), + dirname(facultAiEvolutionLoopConfigPath(args.homeDir, args.rootDir)), dirname(facultAiEvolutionLoopLockPath(args.homeDir, args.rootDir)), facultAiEvolutionLoopReportDir(args.homeDir, args.rootDir), diff --git a/src/paths.test.ts b/src/paths.test.ts index f147b762..19c3c33c 100644 --- a/src/paths.test.ts +++ b/src/paths.test.ts @@ -21,6 +21,7 @@ import { } from "./paths"; const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_ROOT = process.env.FACULT_ROOT_DIR; const ORIGINAL_ROOT_SCOPE = process.env.FACULT_ROOT_SCOPE; let tempHome: string | null = null; @@ -52,9 +53,17 @@ afterEach(async () => { await rm(tempHome, { recursive: true, force: true }); } tempHome = null; - process.env.HOME = ORIGINAL_HOME; - process.env.FACULT_ROOT_DIR = undefined; - process.env.FACULT_ROOT_SCOPE = ORIGINAL_ROOT_SCOPE; + for (const [key, value] of Object.entries({ + HOME: ORIGINAL_HOME, + FACULT_ROOT_DIR: ORIGINAL_ROOT, + FACULT_ROOT_SCOPE: ORIGINAL_ROOT_SCOPE, + })) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } }); describe("paths", () => { From 51895e1ccb6e523c4a32c41dd88c49b5a4614d99 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 14 Sep 2026 11:25:48 -0400 Subject: [PATCH 4/7] fix: retain scheduler ownership across native automation edits --- docs/reference.md | 18 ++++ src/ai.ts | 17 ++++ src/evolution-loop.test.ts | 148 +++++++++++++++++++++++++++----- src/evolution-loop.ts | 70 ++++++++++++--- src/paths.ts | 12 +++ src/remote.ts | 170 ++++++++++++++++++++++++++++++++++++- 6 files changed, 397 insertions(+), 38 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 8c2b4b1a..6c0f205b 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -461,3 +461,21 @@ Most commands accept the same root controls: - `--root /path/to/.ai`: use an explicit canonical root - `--scope merged|global|project`: choose a discovery view - `--source builtin|global|project`: filter provenance in list/find/show/graph flows + + +### Scheduler ownership recovery + +New evolution-loop enrollments store an identity-bound ownership receipt in +fclt machine-local state under `automations/codex/`. It binds the automation ID, creation time, +and working directories, so native edits that drop custom TOML fields do not +lose ownership. Re-enabling preserves the existing prompt, model, target, +notification settings, and memory; an explicit cadence update changes only the +requested cadence and status fields. + +For an older configured loop whose ownership marker was already lost, inspect +`fclt ai loop repair-scheduler --project --root .ai --dry-run --json`. After +confirming the exact task, use `--approve` instead of `--dry-run` to record its +ownership receipt. Global loops use `--global` and their canonical root. Repair +requires the configured task ID and expected working directory to match, rejects +an explicit different owner, and leaves the task contents and paused/active +status unchanged. It does not enable a schedule or adopt a task by name alone. diff --git a/src/ai.ts b/src/ai.ts index d47cd08f..fcbfa75a 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -2825,6 +2825,7 @@ Usage: fclt ai loop activity [--all|--global|--project] [--json] fclt ai loop resolve [--json] fclt ai loop history [--all|--global|--project] [--since ] [--until ] [--item ] [--scope-id ] [--event ] [--limit <1-200>] [--cursor ] [--json] + fclt ai loop repair-scheduler [--approve] [--dry-run] [--json] fclt ai loop run [--since ] [--until ] [--source ] [--dry-run] [--scheduled] [--json] The loop keeps a full machine-local review queue and emits a delta for @@ -3009,8 +3010,24 @@ async function loopCommand(argv: string[]) { evolutionLoopStatus, latestEvolutionLoopReport, runEvolutionLoop, + repairEvolutionLoopScheduler, } = await import("./evolution-loop"); try { + if (sub === "repair-scheduler") { + const result = await repairEvolutionLoopScheduler({ + homeDir, + rootDir, + scope: loopScope, + approve: commandArgs.includes("--approve"), + dryRun: commandArgs.includes("--dry-run"), + }); + await writeCliOutput( + json + ? JSON.stringify(result, null, 2) + : `${result.repaired ? "Repaired" : "Would repair"} scheduler ownership at ${result.automationPath}` + ); + return; + } if (sub === "enable") { const result = await enableEvolutionLoop({ homeDir, diff --git a/src/evolution-loop.test.ts b/src/evolution-loop.test.ts index 8cea7cd8..7e134186 100644 --- a/src/evolution-loop.test.ts +++ b/src/evolution-loop.test.ts @@ -33,6 +33,7 @@ import { disableEvolutionLoop, enableEvolutionLoop, evolutionLoopStatus, + repairEvolutionLoopScheduler, runEvolutionLoop, } from "./evolution-loop"; import { @@ -45,10 +46,17 @@ import { facultAiReconciliationLockPath, facultAiReconciliationStatePath, facultAiWritebackQueuePath, + facultCodexAutomationOwnershipPath, withFacultRootScope, } from "./paths"; import { reconcileSources } from "./reconciliation"; +const AUTOMATION_PROMPT_RE = /^prompt = .*$/m; +const AUTOMATION_MODEL_RE = /^model = .*$/m; +const AUTOMATION_REASONING_RE = /^reasoning_effort = .*$/m; +const AUTOMATION_RRULE_RE = /^rrule = .*$/m; +const AUTOMATION_CREATED_RE = /^created_at = .*$/m; +const AUTOMATION_CWDS_RE = /^cwds = .*$/m; const SIGNAL_FAMILY_ID_RE = /^SF-/; const COMPLETED_RUN_STATUS_RE = /^(complete|degraded)$/; const temporaryRoots: string[] = []; @@ -1350,33 +1358,131 @@ describe("evolution loop", () => { ).toBe(false); }); - it("refuses to update a scheduler after its ownership marker is removed", async () => { - const project = await makeProject(); - const enabled = await enableEvolutionLoop({ - ...project, - now: () => new Date("2026-01-03T00:00:00.000Z"), - }); + it.each([ + "project", + "global", + ] as const)("preserves %s scheduler ownership and native edits after a marker-stripping update", async (scope) => { + const fixture = await makeProject(); + const project = { + ...fixture, + rootDir: + scope === "global" ? join(fixture.homeDir, ".ai") : fixture.rootDir, + scope, + }; + await mkdir(project.rootDir, { recursive: true }); + const enabled = await enableEvolutionLoop(project); const automationPath = join(enabled.automationPath, "automation.toml"); - const current = await readFile(automationPath, "utf8"); + const updated = + (await readFile(automationPath, "utf8")) + .replace('managed_by = "fclt-evolution-loop"\n', "") + .replace( + AUTOMATION_PROMPT_RE, + 'prompt = "Custom approved review and archive policy"' + ) + .replace(AUTOMATION_MODEL_RE, 'model = "custom-model"') + .replace(AUTOMATION_REASONING_RE, 'reasoning_effort = "medium"') + .replace(AUTOMATION_RRULE_RE, 'rrule = "RRULE:FREQ=WEEKLY;BYDAY=FR"') + + '\nnotification_policy = "failed_runs_only"\nexecution_environment = "local"\ntarget = { type = "project", project_id = "saved-project" }\n'; + await Bun.write(automationPath, updated); await Bun.write( - automationPath, - current.replace('managed_by = "fclt-evolution-loop"\n', "") + join(enabled.automationPath, "memory.md"), + "Retained automation history\n" + ); + expect((await evolutionLoopStatus(project)).scheduler.registered).toBe( + true + ); + const reenabled = await enableEvolutionLoop(project); + expect(reenabled.config.rrule).toContain("WEEKLY"); + const after = Bun.TOML.parse( + await readFile(automationPath, "utf8") + ) as Record; + const before = Bun.TOML.parse(updated) as Record; + for (const key of [ + "prompt", + "model", + "reasoning_effort", + "cwds", + "rrule", + "created_at", + "target", + "notification_policy", + "execution_environment", + ]) { + expect(after[key]).toEqual(before[key]); + } + expect( + await readFile(join(enabled.automationPath, "memory.md"), "utf8") + ).toBe("Retained automation history\n"); + expect((await disableEvolutionLoop(project)).scheduler?.paused).toBe(true); + expect((await evolutionLoopStatus(project)).scheduler.registered).toBe( + true ); + }); + it("repairs legacy ownership only with explicit approval and preserves the complete paused task", async () => { + const project = await makeProject(); + const enabled = await enableEvolutionLoop(project); + const path = join(enabled.automationPath, "automation.toml"); + const current = (await readFile(path, "utf8")) + .replace('managed_by = "fclt-evolution-loop"\n', "") + .replace('status = "ACTIVE"', 'status = "PAUSED"'); + await Bun.write(path, current); + await rm( + facultCodexAutomationOwnershipPath( + project.homeDir, + enabled.config.automationName + ) + ); + expect((await evolutionLoopStatus(project)).scheduler.registered).toBe( + false + ); await expect( - enableEvolutionLoop({ - ...project, - rrule: "RRULE:FREQ=WEEKLY;BYDAY=FR", - }) - ).rejects.toThrow("not owned by the fclt evolution loop"); - expect(await readFile(automationPath, "utf8")).not.toContain( - "RRULE:FREQ=WEEKLY" + repairEvolutionLoopScheduler({ ...project, scope: "project" }) + ).rejects.toThrow("--approve"); + const preview = await repairEvolutionLoopScheduler({ + ...project, + scope: "project", + dryRun: true, + }); + expect(preview.repaired).toBe(false); + expect((await evolutionLoopStatus(project)).scheduler.registered).toBe( + false ); - const disabled = await disableEvolutionLoop(project); - expect(disabled.config?.enabled).toBe(false); - expect(disabled.scheduler?.paused).toBe(false); - expect(disabled.scheduler?.error).toContain("not owned"); - expect((await evolutionLoopStatus(project)).health).toBe("disabled"); + await repairEvolutionLoopScheduler({ + ...project, + scope: "project", + approve: true, + }); + expect((await evolutionLoopStatus(project)).scheduler).toMatchObject({ + registered: true, + status: "PAUSED", + }); + expect(await readFile(path, "utf8")).toBe(current); + }); + + it("rejects a replaced or moved scheduler despite a same-name ownership receipt", async () => { + const project = await makeProject(); + const enabled = await enableEvolutionLoop(project); + const path = join(enabled.automationPath, "automation.toml"); + const current = (await readFile(path, "utf8")).replace( + 'managed_by = "fclt-evolution-loop"\n', + "" + ); + for (const edited of [ + current.replace(AUTOMATION_CREATED_RE, "created_at = 1"), + current.replace(AUTOMATION_CWDS_RE, 'cwds = ["/different-project"]'), + `${current}\nmanaged_by = "another-owner"\n`, + ]) { + await Bun.write(path, edited); + expect((await evolutionLoopStatus(project)).scheduler.registered).toBe( + false + ); + await expect(enableEvolutionLoop(project)).rejects.toThrow("not owned"); + expect((await disableEvolutionLoop(project)).scheduler?.paused).toBe( + false + ); + expect(await readFile(path, "utf8")).toBe(edited); + } }); it("refuses to replace a partial scheduler directory", async () => { diff --git a/src/evolution-loop.ts b/src/evolution-loop.ts index b61835fe..5d1077a6 100644 --- a/src/evolution-loop.ts +++ b/src/evolution-loop.ts @@ -50,6 +50,8 @@ import type { } from "./reconciliation-types"; import { assertSafeCodexAutomationTarget, + hasCodexAutomationOwnership, + repairCodexAutomationOwnership, scaffoldCodexAutomationTemplate, setCodexAutomationStatus, } from "./remote"; @@ -527,6 +529,7 @@ async function automationStatus(args: { exists: boolean; registered: boolean; status?: "ACTIVE" | "PAUSED"; + rrule?: string; error?: string; }> { try { @@ -578,8 +581,9 @@ async function automationStatus(args: { : undefined; return { exists: true, - registered: parsed.managed_by === "fclt-evolution-loop", + registered: await hasCodexAutomationOwnership({ ...args, parsed }), status, + rrule: typeof parsed.rrule === "string" ? parsed.rrule : undefined, }; } @@ -665,19 +669,24 @@ async function enableEvolutionLoopScoped(args: { `Refusing to replace an automation not owned by the fclt evolution loop: ${name}` ); } - const scaffold = await scaffoldCodexAutomationTemplate({ - homeDir: args.homeDir, - cwd: projectRoot ?? args.homeDir, - templateId: "closed-loop-review", - name, - scope, - projectRoot, - rootDir: args.rootDir, - rrule: config.rrule, - status: "PAUSED", - force: existingAutomation.exists, - dryRun: args.dryRun, - }); + if (existingAutomation.exists && !args.rrule && existingAutomation.rrule) { + config.rrule = normalizeRrule(existingAutomation.rrule); + } + const scaffold = existingAutomation.exists + ? { path: join(args.homeDir, ".codex", "automations", name) } + : await scaffoldCodexAutomationTemplate({ + homeDir: args.homeDir, + cwd: projectRoot ?? args.homeDir, + templateId: "closed-loop-review", + name, + scope, + projectRoot, + rootDir: args.rootDir, + rrule: config.rrule, + status: "PAUSED", + force: existingAutomation.exists, + dryRun: args.dryRun, + }); if (!args.dryRun) { await atomicWrite( facultAiEvolutionLoopConfigPath(args.homeDir, args.rootDir), @@ -687,6 +696,7 @@ async function enableEvolutionLoopScoped(args: { homeDir: args.homeDir, name, status: "ACTIVE", + rrule: config.rrule, }); await appendLoopAudit(args, { generatedAt: now, @@ -2427,3 +2437,35 @@ export async function runEvolutionLoop( async () => await runEvolutionLoopScoped({ ...args, scope }) ); } + +export async function repairEvolutionLoopScheduler(args: { + homeDir: string; + rootDir: string; + scope: "global" | "project"; + approve?: boolean; + dryRun?: boolean; +}) { + return await withFacultRootScope( + { rootDir: args.rootDir, scope: args.scope }, + async () => { + const config = await loadConfig(args); + if (!config || config.scope !== args.scope) { + throw new Error("No matching configured loop to repair"); + } + const expectedCwd = + config.scope === "global" + ? args.homeDir + : projectRootFromAiRoot(args.rootDir, args.homeDir); + if (!expectedCwd) { + throw new Error( + "Cannot resolve the configured project for scheduler repair" + ); + } + return await repairCodexAutomationOwnership({ + ...args, + name: config.automationName, + expectedCwd, + }); + } + ); +} diff --git a/src/paths.ts b/src/paths.ts index 3ee37a4c..d54ed7cc 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -1035,3 +1035,15 @@ export function facultContextRootDir(args?: { return facultRootDir(home, config); } + +export function facultCodexAutomationOwnershipPath( + home: string, + name: string +): string { + return join( + facultLocalStateRoot(home), + "automations", + "codex", + `${encodeURIComponent(name)}.json` + ); +} diff --git a/src/remote.ts b/src/remote.ts index f8432318..b46e43da 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -47,6 +47,7 @@ import { legacyManagedMutationApproved, } from "./legacy-mutation-policy"; import { + facultCodexAutomationOwnershipPath, facultRootDir, pathsPhysicallyEquivalent, projectRootFromAiRoot, @@ -1431,6 +1432,13 @@ updated_at = ${timestamp} changedPaths.push(automationTomlPath); if (!args.dryRun) { await atomicWriteFile(automationTomlPath, `${automationToml}\n`); + if (template.id === "closed-loop-review") { + await recordAutomationOwnership( + home, + safeName, + Bun.TOML.parse(automationToml) as Record + ); + } } } @@ -1450,10 +1458,141 @@ updated_at = ${timestamp} }; } +const AUTOMATION_RRULE_LINE_RE = /^rrule\s*=.*$/m; + +function automationIdentity(parsed: Record): string | null { + if ( + typeof parsed.id !== "string" || + typeof parsed.created_at !== "number" || + !Number.isSafeInteger(parsed.created_at) || + !Array.isArray(parsed.cwds) || + parsed.cwds.length === 0 || + parsed.cwds.some((cwd) => typeof cwd !== "string" || !isAbsolute(cwd)) + ) { + return null; + } + return createHash("sha256") + .update( + JSON.stringify({ + id: parsed.id, + createdAt: parsed.created_at, + cwds: parsed.cwds, + }) + ) + .digest("hex"); +} + +async function recordAutomationOwnership( + home: string, + name: string, + parsed: Record +): Promise { + const identity = automationIdentity(parsed); + if (parsed.id !== name || !identity) { + throw new Error("Cannot record an invalid automation identity"); + } + await atomicWriteFile( + facultCodexAutomationOwnershipPath(home, name), + `${JSON.stringify({ version: 1, owner: "fclt-evolution-loop", identity })}\n` + ); +} + +export async function repairCodexAutomationOwnership(args: { + homeDir: string; + name: string; + expectedCwd: string; + approve?: boolean; + dryRun?: boolean; +}) { + if (!(args.approve || args.dryRun)) { + throw new Error("Scheduler ownership repair requires explicit --approve"); + } + const safeName = sanitizeAutomationName(args.name); + await assertSafeAutomationTarget({ home: args.homeDir, safeName }); + const path = join( + args.homeDir, + ".codex", + "automations", + safeName, + "automation.toml" + ); + const current = await readFile(path, "utf8"); + const parsed = Bun.TOML.parse(current) as Record; + if ( + safeName !== args.name || + parsed.id !== safeName || + (parsed.status !== "ACTIVE" && parsed.status !== "PAUSED") || + !automationIdentity(parsed) || + JSON.stringify(parsed.cwds) !== + JSON.stringify([resolve(args.expectedCwd)]) || + (parsed.managed_by !== undefined && + parsed.managed_by !== "fclt-evolution-loop") + ) { + throw new Error( + "Scheduler identity, cwd, or explicit owner does not match this configured loop" + ); + } + if (!args.dryRun) { + if ((await readFile(path, "utf8")) !== current) { + throw new Error("Automation changed during ownership repair"); + } + await recordAutomationOwnership(args.homeDir, safeName, parsed); + } + return { + repaired: !args.dryRun, + dryRun: Boolean(args.dryRun), + automationPath: path, + status: parsed.status, + }; +} + +export async function hasCodexAutomationOwnership(args: { + homeDir: string; + name: string; + parsed: Record; +}): Promise { + if ( + args.parsed.id !== args.name || + sanitizeAutomationName(args.name) !== args.name + ) { + return false; + } + if (args.parsed.managed_by === "fclt-evolution-loop") { + return true; + } + if (args.parsed.managed_by !== undefined) { + return false; + } + const identity = automationIdentity(args.parsed); + if (!identity) { + return false; + } + try { + await assertSafeAutomationTarget({ + home: args.homeDir, + safeName: sanitizeAutomationName(args.name), + }); + const receipt = JSON.parse( + await readFile( + facultCodexAutomationOwnershipPath(args.homeDir, args.name), + "utf8" + ) + ); + return ( + receipt.version === 1 && + receipt.owner === "fclt-evolution-loop" && + receipt.identity === identity + ); + } catch { + return false; + } +} + export async function setCodexAutomationStatus(args: { homeDir?: string; name: string; status: "ACTIVE" | "PAUSED"; + rrule?: string; dryRun?: boolean; }): Promise<{ path: string; @@ -1479,7 +1618,13 @@ export async function setCodexAutomationStatus(args: { if (parsed.id !== safeName) { throw new Error(`Codex automation id mismatch at ${pathValue}`); } - if (parsed.managed_by !== "fclt-evolution-loop") { + if ( + !(await hasCodexAutomationOwnership({ + homeDir: home, + name: safeName, + parsed, + })) + ) { throw new Error( `Refusing to change an automation not owned by the fclt evolution loop: ${pathValue}` ); @@ -1488,7 +1633,9 @@ export async function setCodexAutomationStatus(args: { if (currentStatus !== "ACTIVE" && currentStatus !== "PAUSED") { throw new Error(`Codex automation has an invalid status at ${pathValue}`); } - const changed = currentStatus !== args.status; + const changed = + currentStatus !== args.status || + (args.rrule !== undefined && parsed.rrule !== args.rrule); if (changed && !args.dryRun) { if ( !( @@ -1501,13 +1648,30 @@ export async function setCodexAutomationStatus(args: { ); } const timestamp = String(Date.now()); - const next = current + let next = current .replace(AUTOMATION_STATUS_LINE_RE, `status = "${args.status}"`) .replace(AUTOMATION_UPDATED_AT_LINE_RE, `updated_at = ${timestamp}`); + if (args.rrule !== undefined) { + if (!AUTOMATION_RRULE_LINE_RE.test(next)) { + throw new Error("Automation recurrence line is missing"); + } + const recurrence = args.rrule; + next = next.replace( + AUTOMATION_RRULE_LINE_RE, + () => `rrule = ${quoteTomlString(recurrence)}` + ); + } + if ((await readFile(pathValue, "utf8")) !== current) { + throw new Error("Automation changed during status update"); + } const temporaryPath = `${pathValue}.${process.pid}.${timestamp}.tmp`; await Bun.write(temporaryPath, next); await rename(temporaryPath, pathValue); } + if (!args.dryRun) { + await recordAutomationOwnership(home, safeName, parsed); + } + return { path: pathValue, status: args.status, From aff5493069e7272adc9eb0907c0b43ab408de294 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 14 Sep 2026 11:27:26 -0400 Subject: [PATCH 5/7] fix: preserve activity resolver rejection contracts --- src/ai.ts | 88 +++++++++++++++++++++++++++---------------------------- 1 file changed, 43 insertions(+), 45 deletions(-) diff --git a/src/ai.ts b/src/ai.ts index 36a7c2b8..1a239f61 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -2954,54 +2954,52 @@ function parseIntegerFlag(argv: string[], flag: string): number | undefined { } async function loopCommand(argv: string[]) { - const json = argv.includes("--json"); - let sub = argv[0]; - try { - const parsed = parseCliContextArgs(argv); - const [parsedSub, ...commandArgs] = parsed.argv; - sub = parsedSub; - if (!sub || sub === "--help" || sub === "-h" || sub === "help") { - console.log(loopHelp()); - return; - } - if (commandArgs.includes("--help") || commandArgs.includes("-h")) { - console.log(loopHelp()); - return; + const parsed = parseCliContextArgs(argv); + const [sub, ...commandArgs] = parsed.argv; + if (!sub || sub === "--help" || sub === "-h" || sub === "help") { + console.log(loopHelp()); + return; + } + if (commandArgs.includes("--help") || commandArgs.includes("-h")) { + console.log(loopHelp()); + return; + } + if (sub === "resolve") { + if (parsed.rootArg || parsed.scope !== "merged") { + throw new Error( + "Activity locator resolution does not accept caller-supplied root or scope authority" + ); } - if (sub === "resolve") { - if (parsed.rootArg || parsed.scope !== "merged") { - throw new Error( - "Activity locator resolution does not accept caller-supplied root or scope authority" - ); - } - const locatorArgs = commandArgs.filter((arg) => arg !== "--json"); - const locator = locatorArgs[0]; - if ( - locatorArgs.length !== 1 || - !locator || - locator.startsWith("-") || - commandArgs.some((arg) => arg.startsWith("-") && arg !== "--json") - ) { - throw new Error( - "loop resolve accepts exactly one opaque locator and optional --json" - ); - } - const { renderActivityActionResolution, resolveActivityActionLocator } = - await import("./activity-action"); - const result = await resolveActivityActionLocator({ - homeDir: process.env.HOME ?? "", - locator, - }); - console.log( - commandArgs.includes("--json") - ? JSON.stringify(result, null, 2) - : renderActivityActionResolution(result) + const locatorArgs = commandArgs.filter((arg) => arg !== "--json"); + const locator = locatorArgs[0]; + if ( + locatorArgs.length !== 1 || + !locator || + locator.startsWith("-") || + commandArgs.some((arg) => arg.startsWith("-") && arg !== "--json") + ) { + throw new Error( + "loop resolve accepts exactly one opaque locator and optional --json" ); - if (result.status === "rejected") { - process.exitCode = 1; - } - return; } + const { renderActivityActionResolution, resolveActivityActionLocator } = + await import("./activity-action"); + const result = await resolveActivityActionLocator({ + homeDir: process.env.HOME ?? "", + locator, + }); + console.log( + commandArgs.includes("--json") + ? JSON.stringify(result, null, 2) + : renderActivityActionResolution(result) + ); + if (result.status === "rejected") { + process.exitCode = 1; + } + return; + } + const json = commandArgs.includes("--json"); + try { const rootDir = resolveCliContextRoot({ rootArg: parsed.rootArg, scope: parsed.scope, From 492cd0463eb7273b75b849fd244da56bc5c4d4b6 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 14 Sep 2026 11:27:38 -0400 Subject: [PATCH 6/7] fix: reject scheduler edits that alter authored TOML values --- src/evolution-loop.test.ts | 13 +++++++++++++ src/remote.ts | 11 +++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/evolution-loop.test.ts b/src/evolution-loop.test.ts index 7e134186..79592397 100644 --- a/src/evolution-loop.test.ts +++ b/src/evolution-loop.test.ts @@ -1419,6 +1419,19 @@ describe("evolution loop", () => { ); }); + it("refuses a status edit that would match authored multiline prompt content", async () => { + const project = await makeProject(); + const enabled = await enableEvolutionLoop(project); + const path = join(enabled.automationPath, "automation.toml"); + const current = (await readFile(path, "utf8")).replace( + AUTOMATION_PROMPT_RE, + "prompt = '''\nstatus = \"ACTIVE\"\nupdated_at = 1\nKeep this authored example.\n'''" + ); + await Bun.write(path, current); + expect((await disableEvolutionLoop(project)).scheduler?.paused).toBe(false); + expect(await readFile(path, "utf8")).toBe(current); + }); + it("repairs legacy ownership only with explicit approval and preserves the complete paused task", async () => { const project = await makeProject(); const enabled = await enableEvolutionLoop(project); diff --git a/src/remote.ts b/src/remote.ts index b46e43da..b1888467 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -1661,6 +1661,17 @@ export async function setCodexAutomationStatus(args: { () => `rrule = ${quoteTomlString(recurrence)}` ); } + const expected = { + ...parsed, + status: args.status, + updated_at: Number(timestamp), + ...(args.rrule === undefined ? {} : { rrule: args.rrule }), + }; + if (JSON.stringify(Bun.TOML.parse(next)) !== JSON.stringify(expected)) { + throw new Error( + "Automation layout cannot be updated without changing authored fields" + ); + } if ((await readFile(pathValue, "utf8")) !== current) { throw new Error("Automation changed during status update"); } From 08072e1e42d40e36c4377ed4dcb79253bf7973cd Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 14 Sep 2026 11:35:34 -0400 Subject: [PATCH 7/7] fix: flush piped loop output regardless of response size --- src/activity-action.test.ts | 16 ++++++++++++++++ src/ai-cli.test.ts | 17 ++++++++++++++++- src/util/cli-output.test.ts | 8 +++++--- src/util/cli-output.ts | 7 ------- 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/activity-action.test.ts b/src/activity-action.test.ts index 527c3e23..00f3fb39 100644 --- a/src/activity-action.test.ts +++ b/src/activity-action.test.ts @@ -215,17 +215,33 @@ function captureConsole( const errors: string[] = []; const originalLog = console.log; const originalError = console.error; + const originalWrite = process.stdout.write; + process.stdout.write = (( + chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void + ) => { + logs.push( + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString() + ); + const done = + typeof encodingOrCallback === "function" ? encodingOrCallback : callback; + done?.(); + return true; + }) as typeof process.stdout.write; console.log = (...args: unknown[]) => logs.push(args.join(" ")); console.error = (...args: unknown[]) => errors.push(args.join(" ")); return operation().then( () => { console.log = originalLog; console.error = originalError; + process.stdout.write = originalWrite; return { errors, logs }; }, (error) => { console.log = originalLog; console.error = originalError; + process.stdout.write = originalWrite; throw error; } ); diff --git a/src/ai-cli.test.ts b/src/ai-cli.test.ts index f3a2b16a..913c9a32 100644 --- a/src/ai-cli.test.ts +++ b/src/ai-cli.test.ts @@ -28,6 +28,20 @@ async function captureConsole(fn: () => Promise) { const errors: string[] = []; const prevLog = console.log; const prevError = console.error; + const prevWrite = process.stdout.write; + process.stdout.write = (( + chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void + ) => { + logs.push( + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString() + ); + const done = + typeof encodingOrCallback === "function" ? encodingOrCallback : callback; + done?.(); + return true; + }) as typeof process.stdout.write; console.log = (...args: Parameters) => { logs.push(args.map((value) => String(value)).join(" ")); }; @@ -39,6 +53,7 @@ async function captureConsole(fn: () => Promise) { } finally { console.log = prevLog; console.error = prevError; + process.stdout.write = prevWrite; } return { logs, errors }; } @@ -473,7 +488,7 @@ describe("ai CLI", () => { automationPath, (await Bun.file(automationPath).text()).replace( 'managed_by = "fclt-evolution-loop"\n', - "" + 'managed_by = "another-owner"\n' ) ); const disabledOut = await captureConsole(async () => { diff --git a/src/util/cli-output.test.ts b/src/util/cli-output.test.ts index 09d7bb18..703301d9 100644 --- a/src/util/cli-output.test.ts +++ b/src/util/cli-output.test.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from "bun:test"; describe("writeCliOutput", () => { - it("flushes output larger than Bun's buffered stdout window", async () => { - const expectedBytes = 200_001; + it.each([ + 0, 512, 4096, 49_000, 65_536, 200_000, + ])("flushes %i bytes completely through a pipe", async (bytes) => { + const expectedBytes = bytes + 1; const proc = Bun.spawn( [ process.execPath, "-e", - 'import { writeCliOutput } from "./src/util/cli-output"; await writeCliOutput("x".repeat(200_000));', + `import { writeCliOutput } from "./src/util/cli-output"; await writeCliOutput("x".repeat(${bytes}));`, ], { cwd: process.cwd(), diff --git a/src/util/cli-output.ts b/src/util/cli-output.ts index 4d99bd2e..23cac399 100644 --- a/src/util/cli-output.ts +++ b/src/util/cli-output.ts @@ -1,12 +1,5 @@ -const BUFFERED_STDOUT_THRESHOLD_BYTES = 64 * 1024; - export async function writeCliOutput(output: string): Promise { const terminated = `${output}\n`; - if (Buffer.byteLength(terminated, "utf8") < BUFFERED_STDOUT_THRESHOLD_BYTES) { - console.log(output); - return; - } - await new Promise((resolve, reject) => { process.stdout.write(terminated, (error) => { if (error) {