diff --git a/docs/reports/e2e-smoke-2026-10-25.md b/docs/reports/e2e-smoke-2026-10-25.md index 4739e3c..008334e 100644 --- a/docs/reports/e2e-smoke-2026-10-25.md +++ b/docs/reports/e2e-smoke-2026-10-25.md @@ -49,3 +49,31 @@ Four earlier runs failed. Each failure was a real defect, not a flaky environmen - Other agents (`gemini`, `codex`) are mapped but untested; `--cwd` is still present in their builders and may be wrong. - Worktrees are created next to the repository (`-fsc-`), not inside it. - Scale: see issue #10. Nothing here says anything about 100 or 1000 agents. + +## Second run: tasks that conflict (2026-10-26) + +Scratch repo: `config.py` (`GREETING = "hello"`, `TIMEOUT = 5`) and `util.py` (a `double(x)` stub). Tasks given with `run --tasks tasks.json` so the conflict is guaranteed: + +- `greet-a`: set `GREETING` to `"hi there"` +- `greet-b`: set `GREETING` to `"good day"` (same line, different value) +- `double-c`: implement `double(x)` (independent) + +Result (3 real `claude` agents in parallel, 48 s, exit code 1): + +- `double-c` and `greet-a` merged at tier 1; `main` has `GREETING = "hi there"` and `return x * 2`. +- `greet-b` was **not** merged: `MERGE_CONFLICT (tier2 blocked): config.py`. The report names the file, prints the recovery steps (`git cherry-pick ` ...), and the work is kept on branch `fsc/greet-b` (one commit, `config.py` changed). Its worktree was removed; main is clean. +- Task order is by task id, so which of two conflicting tasks wins is decided by sorting, not by priority or quality. + +### Defect found in the first attempt of this run + +`double-c` was reported as merged, but `util.py` on `main` was unchanged. The merged commit (`auto: double-c completion`) contained only `__pycache__/util.cpython-312.pyc`: the auto-commit had swept an interpreter byproduct and counted it as the agent's work. Fixed: `__pycache__/`, `*.pyc` and `node_modules/` are never auto-committed, so such a task now fails with "The agent finished but made no changes". A regression test covers it. + +Why the agent did not edit `util.py` that time is **not known**: agent output was not saved then. It is saved now (`.fsc/logs/.log` in the repository) and the rerun above implemented `double` correctly, so the failure did not reproduce. + +### Observation, not yet acted on + +The spawned `claude` inherits the user's global configuration. In the rerun, the agent's final message was about Multica ticketing (an instruction from the author's global `CLAUDE.md`), so user-level hooks and instructions also apply inside FSC agents. That can slow a run or block it (a stop hook that demands an extra action), and may explain the one 16-minute analysis run in the first report; this is a hypothesis and was not tested. Candidate fix: run agents with `--setting-sources project` or an isolated config, to be decided. + +### Still untested + +Tier-2 auto-resolution on a real conflict, three or more conflicting tasks, an agent that fails or times out, `gemini`/`codex`. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b8cc5c4..63dc5a3 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -4,6 +4,7 @@ import { Command } from 'commander'; import { analyzeCodebase, analyzeCodebaseNative, + ignoreStateDir, UnifiedScheduler, TaskSolverManager, DistributedScheduler, @@ -75,11 +76,25 @@ export async function runFullAnalysis(): Promise { } // Step 1: analyze the codebase and get tasks - console.log('Analyzing the codebase...'); - const tasks: Task[] = - mode === 'docker' - ? await analyzeCodebase(config, gitRemoteUrl) - : await analyzeCodebaseNative(config, process.cwd()); + if (mode !== 'docker') ignoreStateDir(process.cwd()); + const tasksFile = process.env.FSC_TASKS_FILE; + let tasks: Task[]; + if (tasksFile) { + // Skip analysis: run exactly the tasks in this JSON file (an array of Task objects). + try { + tasks = JSON.parse(fs.readFileSync(tasksFile, 'utf-8')); + } catch (e) { + throw new Error(`Cannot read tasks from ${tasksFile}: ${e instanceof Error ? e.message : String(e)}`); + } + if (!Array.isArray(tasks) || tasks.length === 0) throw new Error(`${tasksFile} must contain a non-empty JSON array of tasks.`); + tasks = tasks.map((t, i) => ({ ...t, priority: t.priority ?? 1, ID: t.ID ?? `task-${i + 1}` })); + } else { + console.log('Analyzing the codebase...'); + tasks = + mode === 'docker' + ? await analyzeCodebase(config, gitRemoteUrl) + : await analyzeCodebaseNative(config, process.cwd()); + } console.log(`Found ${tasks.length} task(s):`); for (const t of tasks) console.log(` - [P${t.priority}] ${t.title}`); @@ -179,6 +194,7 @@ export function createCLI(): Command { .command('run') .description('Run the full self-coding analysis and task execution') .option('-c, --config ', 'Path to configuration file') + .option('--tasks ', 'Run the tasks in this JSON file instead of analyzing the codebase') .option('--mode ', 'Execution mode: worktree (native git worktree), bare, or docker (optional)', 'worktree') .action(async (options) => { try { @@ -186,6 +202,9 @@ export function createCLI(): Command { if (options.config) { process.argv.push('--config', options.config); } + if (options.tasks) { + process.env.FSC_TASKS_FILE = options.tasks; + } if (options.mode) { process.env.FSC_EXECUTION_MODE = options.mode; } diff --git a/packages/core/src/analyzerNative.ts b/packages/core/src/analyzerNative.ts index e280a7a..c28aa72 100644 --- a/packages/core/src/analyzerNative.ts +++ b/packages/core/src/analyzerNative.ts @@ -16,7 +16,7 @@ function git(args: string[], cwd: string): { ok: boolean; out: string } { } /** Keep our `.fsc/` runtime state out of `git status` without touching the user's .gitignore. */ -function ignoreStateDir(repo: string): void { +export function ignoreStateDir(repo: string): void { const p = git(['rev-parse', '--git-path', 'info/exclude'], repo); if (!p.ok) return; const file = resolve(repo, p.out.trim()); diff --git a/packages/core/src/execution/local.ts b/packages/core/src/execution/local.ts index ee43a68..1e77b06 100644 --- a/packages/core/src/execution/local.ts +++ b/packages/core/src/execution/local.ts @@ -10,6 +10,14 @@ import type { SalaciaPlugin } from "../salacia/plugin"; const FSC_AUTHOR = "FSC Worker "; +/** Never auto-committed: FSC state and build/interpreter byproducts that agents leave behind. */ +const AUTO_COMMIT_EXCLUDES = [ + ":(exclude).fsc", + ":(exclude)**/__pycache__/**", + ":(exclude)**/*.pyc", + ":(exclude)**/node_modules/**", +]; + export class LocalExecution implements ExecutionStrategy { private worktreeManager: WorktreeManager; private running: Map = new Map(); @@ -155,6 +163,28 @@ export class LocalExecution implements ExecutionStrategy { } : undefined, }; + // Keep the full agent output: the worktree (and its .fsc/) is removed after the run. + try { + const logDir = path.resolve(unit.repoPath, ".fsc", "logs"); + fs.mkdirSync(logDir, { recursive: true }); + const header = `# exit=${exitCode} timedOut=${timedOut} spawnError=${spawnError ?? ""}`; + const shown = cmd.map(c => (c.length > 80 ? `${c.slice(0, 80)}...` : c)).join(" "); + const body = [ + header, + `# cmd: ${shown}`, + "", + "## stdout", + stdoutChunks.join("\n"), + "", + "## stderr", + stderrChunks.join("\n"), + "", + ].join("\n"); + fs.writeFileSync(path.resolve(logDir, `${unit.id}.log`), body); + } catch { + // logging must never fail a run + } + // 8. Write .fsc/result.json fs.writeFileSync( path.resolve(fscDir, "result.json"), @@ -191,10 +221,10 @@ export class LocalExecution implements ExecutionStrategy { baseBranch: string, ): string | null { // Check for uncommitted changes (exclude .fsc/ which is FSC internal) - const status = gitExec(["status", "--porcelain", "--", ":(exclude).fsc"], worktreePath); + const status = gitExec(["status", "--porcelain", "--", ".", ...AUTO_COMMIT_EXCLUDES], worktreePath); if (status.stdout.trim()) { // Auto-commit (add everything except .fsc/) - gitExec(["add", "-A", "--", ":(exclude).fsc"], worktreePath); + gitExec(["add", "-A", "--", ".", ...AUTO_COMMIT_EXCLUDES], worktreePath); const commitResult = gitExec( ["commit", "--author", FSC_AUTHOR, "-m", `auto: ${taskId} completion`], worktreePath, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3b6089c..8581da1 100755 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,7 +2,7 @@ // Main engines export { default as analyzeCodebase } from './analyzer'; -export { analyzeCodebaseNative } from './analyzerNative'; +export { analyzeCodebaseNative, ignoreStateDir } from './analyzerNative'; export { TaskSolver } from './taskSolver'; export { TaskSolverManager } from './taskSolverManager'; export { CodeCommitter, type QualityGateHook, type TrustUpdateHook } from './codeCommitter'; diff --git a/packages/core/test/execution/local.test.ts b/packages/core/test/execution/local.test.ts index cc3f979..594261b 100644 --- a/packages/core/test/execution/local.test.ts +++ b/packages/core/test/execution/local.test.ts @@ -86,6 +86,35 @@ describe("LocalExecution", () => { expect(result.filesChanged).toContain("auto_commit_test.txt"); }); + test("interpreter byproducts alone are not a result", async () => { + const shell = process.platform === "win32" ? "bash" : "/bin/sh"; + const unit: WorkUnit = { + id: "local-test-pyc-only", + repoPath: testRepo, + baseBranch: "main", + taskPrompt: "test", + agentConfig: { + type: "custom", + costTier: "free", + memoryMB: 50, + command: shell, + buildArgs: () => ["-c", "mkdir -p __pycache__ && echo x > __pycache__/a.cpython-312.pyc"], + }, + constraints: { timeoutMs: 10_000 }, + }; + + const result = await execution.execute(unit); + expect(result.commitHash).toBeNull(); + expect(result.status).toBe("failure"); + expect(result.error?.message).toContain("no changes"); + }); + + test("agent output is kept in the repo's .fsc/logs", async () => { + const log = path.resolve(testRepo, ".fsc", "logs", "local-test-pyc-only.log"); + expect(fs.existsSync(log)).toBe(true); + expect(fs.readFileSync(log, "utf-8")).toContain("# exit=0"); + }); + test("handles timeout", async () => { const shell = process.platform === "win32" ? "bash" : "/bin/sh"; const unit: WorkUnit = {