From 3a290818c37affb57641f21f007eb80c92fd649c Mon Sep 17 00:00:00 2001 From: Bradley Taylor Date: Sat, 15 Aug 2026 14:12:29 -0700 Subject: [PATCH 1/4] chore: start session session/20260815-141225 From 48fd56fe6e1c1e3fc5dcccb07dcfb311a1b80b47 Mon Sep 17 00:00:00 2001 From: Brad Taylor Date: Sat, 15 Aug 2026 14:34:40 -0700 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20fix:=20first=20run=20after=20contex?= =?UTF-8?q?t=20goes=20stale=20always=20aborts=20=E2=80=94=20refreshProject?= =?UTF-8?q?Context=20has=20no=20success=20path=20(closes=20#408)=20(#428)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: fix: first run after context goes stale always aborts — refreshProjectContext has no success path (closes #408) * fix: address review findings for #408 --- README.md | 4 +- docs/epics.md | 2 +- src/commands/run.ts | 149 ++++++++++++------- src/commands/scan.ts | 33 ++++- src/lib/session.ts | 63 +++++++- tests/commands/run.test.ts | 287 ++++++++++++++++++++++++++++++------ tests/commands/scan.test.ts | 31 +++- tests/lib/session.test.ts | 85 +++++++++++ 8 files changed, 542 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index 7cf55b8..d511017 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,8 @@ This lets you plan work in GitHub milestones and control exactly how much the lo When `auto_merge` is enabled (default), Alpha Loop creates a session branch (e.g., `session/20260331-002240`) and merges each issue's PR into it. This keeps your main branch clean until you're ready to merge the whole session. +If project context is stale when a session starts, Alpha Loop regenerates context and managed agent instructions inside the isolated session worktree. Valid generated changes become the session branch's first commit before that branch is pushed or its draft PR is opened, so they follow the normal review path and never dirty or push the base-branch checkout. Invalid or missing scan output stops the session before publication. + ### Learnings Each completed issue produces a learning file in `.alpha-loop/learnings/` that is committed with that issue's implementation PR. It includes: @@ -865,7 +867,7 @@ alpha-loop run --epics 205,166,214 The queue is validated before any work starts. Each listed issue must exist, be labeled `epic`, not be duplicated, and be open unless it is already closed as completed. Alpha Loop processes sequential queues in the given order, creates/finalizes one session branch and PR per epic, and stops on the first failure. By default, queues use `stacked` ancestry. For independent work, add `--queue-branch-mode independent --parallel 2`: Alpha Loop derives topological waves from queued `Depends on #N` references, runs up to two ready epics as child processes, and waits for each wave before starting dependents. A failed epic does not cancel siblings already running; unrelated later epics continue, failed dependents are recorded as skipped, and the queue exits nonzero. -Parallel worker output is stored in `.alpha-loop/sessions/queue-/epic-.log`. The atomic `queue.json` manifest records the concurrency limit, waves, dependencies, per-epic status and log path, and dependency-failure skips. Use `alpha-loop history queue-` to inspect progress. `--parallel` requires independent mode; stacked branches are inherently sequential. `--dry-run` prints the validated wave schedule without mutating GitHub or git state. +Parallel worker output is stored in `.alpha-loop/sessions/queue-/epic-.log`. The atomic `queue.json` manifest records the concurrency limit, waves, dependencies, per-epic status and log path, and dependency-failure skips. Each worker independently refreshes stale context only in its own session worktree and carries any generated changes on its own session branch; the coordinator does not write generated files to the shared checkout. Use `alpha-loop history queue-` to inspect progress. `--parallel` requires independent mode; stacked branches are inherently sequential. `--dry-run` prints the validated wave schedule without mutating GitHub or git state. Sub-issues are processed in checklist order (not issue-number order). Each sub-issue PR gets `Part of #165` appended, and the epic body's checkboxes auto-flip from `- [ ]` to `- [x]` as PRs merge. When every sub-issue has shipped, the loop runs a verification pass against each sub-issue's acceptance criteria — on `pass` the epic is auto-closed, on `partial` or `fail` it stays open with a `needs-human-input` label and a structured comment explaining the gaps. diff --git a/docs/epics.md b/docs/epics.md index 2c4d2ce..59fd469 100644 --- a/docs/epics.md +++ b/docs/epics.md @@ -180,7 +180,7 @@ Queue execution is fail-stop by default. Alpha Loop stops at the first epic that With `--parallel`, Alpha Loop parses queued `Depends on #N`, `after #N`, `requires #N`, and `blocked by #N` references into deterministic topological waves while preserving requested order within each wave. It starts at most `n` child `alpha-loop run --epic ` workers at once and waits for all started siblings before advancing. If one worker fails, its running siblings finish; epics that do not depend on the failure still run in later waves, while direct and transitive dependents are skipped with failure details. Any worker failure or dependency skip makes the final queue exit nonzero. -Before workers start, the coordinator syncs generated agent assets and refreshes project context once in the shared checkout. Each child then performs git mutations only in its session worktree and streams combined stdout/stderr to `.alpha-loop/sessions/queue-/epic-.log`; queue context and structured result artifacts live beside the log. The existing per-epic session lock remains the last-resort guard against two workers targeting the same epic. +The coordinator does not sync or refresh generated files in the shared checkout. Each child checks context freshness and runs any required context scan plus managed-agent-asset sync only in its isolated session worktree. Valid generated changes are committed to that worker's session branch before it is pushed or its draft PR is opened; invalid or missing scan output fails the worker before publication. Worker output streams to `.alpha-loop/sessions/queue-/epic-.log`, and queue context plus structured result artifacts live beside the log. The existing per-epic session lock remains the last-resort guard against two workers targeting the same epic. If the process crashes inside a child issue before its branch has a PR, run `alpha-loop resume` to recover stranded `agent/issue-*` branches as WIP PRs. Recovered PRs are not marked complete; run the project tests and final verification before merging, then inspect the queue manifest and continue with the remaining epic IDs. diff --git a/src/commands/run.ts b/src/commands/run.ts index 40b971b..5f199ce 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -44,7 +44,7 @@ import { import { hasVision } from '../lib/vision.js'; import { contextNeedsRefresh } from '../lib/context.js'; import { runPreflight } from '../lib/preflight.js'; -import { syncAgentAssets, resolveHarnesses } from './sync.js'; +import { HARNESS_REGISTRY, syncAgentAssets, resolveHarnesses } from './sync.js'; import { saveCapturedCase, detectFailureStep } from '../lib/eval.js'; import { readGateResult, formatGateFindings } from '../lib/pipeline.js'; import { spawnAgent } from '../lib/agent.js'; @@ -900,6 +900,28 @@ async function runIssueSession( : undefined; const queueContext = target.type === 'epic' ? target.queue : undefined; const activeMilestone = target.type === 'flat' ? target.activeMilestone : ''; + const refreshContextInSession = contextNeedsRefresh(process.cwd()); + + if (config.dryRun) { + if (config.autoMerge) { + log.dry('Would sync agent assets in the session worktree before run'); + if (refreshContextInSession) { + log.dry('Would refresh project context and instructions in the session worktree'); + } + } else if (refreshContextInSession) { + log.dry('Would stop because project context is stale and auto_merge is disabled'); + } else { + log.dry('Would sync agent assets before run'); + } + } else if (refreshContextInSession && !config.autoMerge) { + throw new CommandExitError({ + code: 'project-context-refresh-required', + message: 'Project context is stale, but auto_merge is disabled so Alpha Loop cannot carry the ' + + 'refresh on a reviewable session branch. Enable auto_merge or run "alpha-loop scan" on an issue branch.', + }); + } else if (!config.autoMerge) { + syncProjectAgentAssets(config); + } if (target.type === 'issue') { log.info(`Processing targeted issue #${target.issue.number}: ${target.issue.title}`); @@ -944,7 +966,13 @@ async function runIssueSession( try { if (config.autoMerge) { try { - await ensureSessionWorktree(session, config); + await ensureSessionWorktree(session, config, { + prepare: (worktreePath) => prepareSessionProjectFiles( + config, + worktreePath, + refreshContextInSession, + ), + }); } catch (err) { const message = `Failed to set up session worktree: ${err instanceof Error ? err.message : err}`; log.error(message); @@ -1002,45 +1030,69 @@ async function cleanupSessionWorktree( } function syncProjectAgentAssets(config: Config): void { - if (config.dryRun) { - log.dry('Would sync agent assets before run'); - return; - } - const syncResult = syncAgentAssets(resolveHarnesses(config.harnesses, config.agent)); if (syncResult.synced) { log.success('Agent assets synced before run'); } } -async function refreshProjectContext(config: Config): Promise { - if (!contextNeedsRefresh()) { - log.info('Project context is fresh'); - return; - } - - if (config.dryRun) { - log.dry('Would refresh project context and instructions'); - return; +function generatedSessionPaths(config: Config): string[] { + const paths = new Set([ + '.alpha-loop/context.md', + '.alpha-loop/templates/instructions.md', + ]); + for (const harness of resolveHarnesses(config.harnesses, config.agent)) { + const harnessConfig = HARNESS_REGISTRY[harness]; + if (!harnessConfig) continue; + paths.add(harnessConfig.skillsDir); + if (harnessConfig.agentsDir) paths.add(harnessConfig.agentsDir); + if (harnessConfig.instructionsFile) paths.add(harnessConfig.instructionsFile); } + return Array.from(paths); +} - log.info('Project context is stale or missing. Generating...'); - const { scanCommand } = await import('./scan.js'); - scanCommand(); - - const statusResult = exec('git status --porcelain .alpha-loop/ AGENTS.md CLAUDE.md'); - if (!statusResult.stdout.trim()) { - if (contextNeedsRefresh()) { +async function prepareSessionProjectFiles( + config: Config, + worktreePath: string, + refreshContext: boolean, +): Promise<{ commitMessage: string; paths: string[] } | undefined> { + if (refreshContext) { + log.info('Project context is stale or missing. Generating in the session worktree...'); + const { scanProject } = await import('./scan.js'); + const scanResult = scanProject(worktreePath, config); + const backupPath = join(worktreePath, '.alpha-loop', 'templates', 'instructions.md.bak'); + if (existsSync(backupPath)) unlinkSync(backupPath); + if (!scanResult.contextWritten) { throw new CommandExitError({ code: 'project-context-refresh-required', - message: 'Project context refresh did not produce a fresh context file. ' + - 'Run "alpha-loop scan", review the output, and commit it through a pull request before retrying.', + message: 'Project context refresh did not produce fresh project context. ' + + 'Review the scan output and retry; no session branch or pull request was published.', }); } - return; + } else { + log.info('Project context is fresh'); + } + + const syncResult = syncAgentAssets( + resolveHarnesses(config.harnesses, config.agent), + { projectDir: worktreePath }, + ); + if (syncResult.synced) { + log.success('Agent assets synced in the session worktree before run'); + } + + const generatedPaths = generatedSessionPaths(config); + const pathspecs = generatedPaths.map((generatedPath) => shellQuote(generatedPath)).join(' '); + const statusResult = exec(`git status --porcelain -- ${pathspecs}`, { cwd: worktreePath }); + if (statusResult.exitCode !== 0) { + throw new CommandExitError({ + code: 'project-context-refresh-required', + message: `Could not inspect generated session files: ${statusResult.stderr || statusResult.stdout}`, + }); } + if (!statusResult.stdout.trim()) return undefined; - const validation = validateGeneratedMarkdownForCommit(process.cwd(), statusResult.stdout); + const validation = validateGeneratedMarkdownForCommit(worktreePath, statusResult.stdout); if (!validation.valid) { log.warn('Generated context/instructions failed validation:'); for (const error of validation.errors) { @@ -1048,17 +1100,23 @@ async function refreshProjectContext(config: Config): Promise { } throw new CommandExitError({ code: 'project-context-refresh-required', - message: 'Project context refresh failed validation. Fix the generated files on an issue branch ' + - 'and commit them through a pull request before retrying.', + message: 'Generated project context or instructions failed validation. ' + + 'No session branch or pull request was published; review the scan output and retry.', }); } - throw new CommandExitError({ - code: 'project-context-refresh-required', - message: 'Project context was refreshed and left uncommitted. Review the generated files, ' + - 'commit them on an issue branch, and merge them through a pull request before retrying. ' + - `Alpha Loop will not push directly to ${config.baseBranch}.`, - }); + const addResult = exec(`git add -A -- ${pathspecs}`, { cwd: worktreePath }); + if (addResult.exitCode !== 0) { + throw new CommandExitError({ + code: 'project-context-refresh-required', + message: `Could not stage validated generated session files: ${addResult.stderr || addResult.stdout}`, + }); + } + + return { + commitMessage: 'chore: refresh project context and agent assets', + paths: generatedPaths, + }; } async function executeSessionRun( @@ -1204,14 +1262,6 @@ async function executeSessionRun( process.on('SIGINT', handleSigint); process.on('SIGTERM', handleSigterm); - // Parallel queue workers share a coordinator checkout. The coordinator performs - // generated-file preparation once before spawning them so workers only mutate - // their session worktrees. - const isParallelQueueWorker = options.queueContext !== undefined; - if (!isParallelQueueWorker) { - syncProjectAgentAssets(config); - } - // Pre-flight test validation log.step('Running pre-flight test validation...'); if (!config.skipPreflight && !config.skipTests) { @@ -1281,12 +1331,6 @@ async function executeSessionRun( } } - // Refresh project metadata before queue discovery. If generated content changes, - // stop so it can be reviewed and merged through a normal branch/PR first. - if (!isParallelQueueWorker) { - await refreshProjectContext(config); - } - // --- Fetch issue queue --- // When an epic is selected, the queue is its sub-issues in checklist order. // Otherwise, fetch via the usual project/label/milestone path and exclude @@ -3020,13 +3064,6 @@ async function runEpicQueue(config: Config, options: RunOptions): Promise return; } - if (parallelLimit !== undefined) { - // Shared generated assets and context live in the coordinator checkout. - // Prepare them once before children begin worktree-only execution. - syncProjectAgentAssets(config); - await refreshProjectContext(config); - } - const scheduledWaves = parallelLimit !== undefined ? buildEpicQueueWaves(validation.entries) : validation.entries.map((entry) => [entry]); diff --git a/src/commands/scan.ts b/src/commands/scan.ts index d7e783e..2aa8a73 100644 --- a/src/commands/scan.ts +++ b/src/commands/scan.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { exec } from '../lib/shell.js'; import { log } from '../lib/logger.js'; import { assertSafeShellArg, loadConfig } from '../lib/config.js'; +import type { Config } from '../lib/config.js'; import { buildOneShotCommand } from '../lib/agent.js'; import { excerptForLog, @@ -116,11 +117,22 @@ function logInvalidOutput(kind: string, reason: string | undefined, stdout: stri log.warn(`Agent stdout excerpt: ${excerptForLog(stdout)}`); } -export function scanCommand(): void { - const projectDir = process.cwd(); +export type ScanProjectResult = { + contextWritten: boolean; + instructionsWritten: boolean; +}; + +/** + * Generate project context and managed instructions in an explicit checkout. + * The CLI wrapper uses process.cwd(); session runs pass their isolated worktree. + */ +export function scanProject( + projectDir: string, + config: Pick, +): ScanProjectResult { const contextDir = path.join(projectDir, '.alpha-loop'); const contextFile = path.join(contextDir, 'context.md'); - const config = loadConfig(); + let contextWritten = false; fs.mkdirSync(contextDir, { recursive: true }); @@ -135,6 +147,7 @@ export function scanCommand(): void { const contextValidation = validateProjectContextMarkdown(contextOutput); if (contextValidation.valid) { fs.writeFileSync(contextFile, contextOutput + '\n'); + contextWritten = true; if (result.exitCode !== 0) { log.warn('Agent exited with errors but produced valid project context output'); } @@ -146,14 +159,20 @@ export function scanCommand(): void { } // --- Generate instructions file --- - generateInstructions(projectDir, config.agent, safeModel); + const instructionsWritten = generateInstructions(projectDir, config.agent, safeModel); + return { contextWritten, instructionsWritten }; +} + +/** No-argument CLI wrapper retained for `alpha-loop scan`. */ +export function scanCommand(): void { + scanProject(process.cwd(), loadConfig()); } /** * Generate or update the instructions file at .alpha-loop/templates/instructions.md. * If no existing file, generates from scratch. If existing, merges with fresh scan. */ -export function generateInstructions(projectDir: string, agent: 'claude' | 'codex' | 'opencode' | 'lmstudio' | 'ollama', model: string): void { +export function generateInstructions(projectDir: string, agent: 'claude' | 'codex' | 'opencode' | 'lmstudio' | 'ollama', model: string): boolean { const templatesDir = path.join(projectDir, '.alpha-loop', 'templates'); fs.mkdirSync(templatesDir, { recursive: true }); @@ -182,6 +201,7 @@ export function generateInstructions(projectDir: string, agent: 'claude' | 'code log.warn('Agent exited with errors but produced valid instructions output'); } log.success('Instructions file updated (backup at instructions.md.bak)'); + return true; } else if (output) { logInvalidOutput('Instructions merge', validation.reason, mergeResult.stdout); log.warn('Instructions merge failed validation, keeping existing file'); @@ -202,6 +222,7 @@ export function generateInstructions(projectDir: string, agent: 'claude' | 'code log.warn('Agent exited with errors but produced valid instructions output'); } log.success(`Instructions file generated: ${instructionsFile}`); + return true; } else if (output) { logInvalidOutput('Instructions generation', validation.reason, genResult.stdout); log.warn('Instructions generation failed validation — will retry on next scan'); @@ -209,4 +230,6 @@ export function generateInstructions(projectDir: string, agent: 'claude' | 'code log.warn('Instructions generation failed — will retry on next scan'); } } + + return false; } diff --git a/src/lib/session.ts b/src/lib/session.ts index 3468b43..9e73380 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -286,6 +286,21 @@ export type CreateSessionOptions = { queue?: QueueSessionContext; }; +export type SessionWorktreePreparation = { + /** Commit message used when preparation staged generated files. */ + commitMessage: string; + /** Worktree-relative generated paths that preparation validated and staged. */ + paths: string[]; +}; + +export type EnsureSessionWorktreeOptions = { + createDraftPr?: boolean; + /** Runs after checkout setup but before the session branch is pushed or its PR is opened. */ + prepare?: ( + worktreePath: string, + ) => SessionWorktreePreparation | void | Promise; +}; + const RESUMABLE_STATUSES = new Set([ 'running', 'human_input_requested', @@ -1232,7 +1247,7 @@ export function createSession(config: Config, options?: CreateSessionOptions): S export async function ensureSessionWorktree( session: SessionContext, config: Config, - options: { createDraftPr?: boolean } = {}, + options: EnsureSessionWorktreeOptions = {}, ): Promise { if (config.dryRun || session.branch === config.baseBranch) return null; if (session.worktreePath) return session.worktreePath; @@ -1257,6 +1272,13 @@ export async function ensureSessionWorktree( }); session.worktreePath = worktree.path; + if (config.autoMerge && remoteExists) { + const reconciled = reconcileSessionBranchWithRemote(session.branch, worktree.path); + if (!reconciled) { + throw new Error(`Failed to reconcile existing session branch ${session.branch} before preparation`); + } + } + if (config.autoMerge && !remoteExists) { if (worktree.resumed) { const reset = exec(`git reset --hard ${shellQuote(`origin/${branchSource}`)}`, { cwd: worktree.path }); @@ -1264,7 +1286,38 @@ export async function ensureSessionWorktree( throw new Error(`Failed to reset stale session branch ${session.branch}: ${reset.stderr}`); } } - exec(`git commit --allow-empty -m ${shellQuote(`chore: start session ${session.name}`)}`, { cwd: worktree.path }); + } + + const preparation = await options.prepare?.(worktree.path); + const preparationPaths = Array.from(new Set( + preparation?.paths.map((preparedPath) => preparedPath.trim()).filter(Boolean) ?? [], + )); + let preparedChanges = false; + if (preparationPaths.length > 0) { + const pathspecs = preparationPaths.map((preparedPath) => shellQuote(preparedPath)).join(' '); + const preparedDiff = exec(`git diff --cached --quiet -- ${pathspecs}`, { cwd: worktree.path }); + preparedChanges = preparedDiff.exitCode !== 0; + if (preparedChanges) { + const commitResult = exec( + `git commit -m ${shellQuote(preparation?.commitMessage ?? `chore: prepare session ${session.name}`)} -- ${pathspecs}`, + { cwd: worktree.path }, + ); + if (commitResult.exitCode !== 0) { + throw new Error(`Failed to commit prepared session files: ${commitResult.stderr || commitResult.stdout}`); + } + } + } + + if (config.autoMerge && !remoteExists) { + if (!preparedChanges) { + const startCommit = exec( + `git commit --allow-empty -m ${shellQuote(`chore: start session ${session.name}`)}`, + { cwd: worktree.path }, + ); + if (startCommit.exitCode !== 0) { + throw new Error(`Failed to create initial session commit: ${startCommit.stderr || startCommit.stdout}`); + } + } const pushResult = exec(`git push origin ${shellQuote(session.branch)}`, { cwd: worktree.path }); if (pushResult.exitCode !== 0) { throw new Error(`Failed to push session branch ${session.branch}: ${pushResult.stderr}`); @@ -1296,6 +1349,12 @@ export async function ensureSessionWorktree( log.warn('Could not create draft session PR — will create during finalization'); } } + } else if (config.autoMerge && preparedChanges) { + const pushResult = exec(`git push origin ${shellQuote(session.branch)}`, { cwd: worktree.path }); + if (pushResult.exitCode !== 0) { + throw new Error(`Failed to push prepared session files to ${session.branch}: ${pushResult.stderr}`); + } + log.success(`Updated session branch preparation: ${session.branch}`); } else if (config.autoMerge) { log.info(`Session branch already exists: ${session.branch}`); } diff --git a/tests/commands/run.test.ts b/tests/commands/run.test.ts index f422e92..3c2c865 100644 --- a/tests/commands/run.test.ts +++ b/tests/commands/run.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events'; import { join } from 'node:path'; -import { formatEpicPickerMeta, formatMilestonePickerMeta, runCommand, runSingleEpicExecution } from '../../src/commands/run'; +import { formatEpicPickerMeta, formatMilestonePickerMeta, runCommand, runSingleEpicExecution, runSingleIssueExecution } from '../../src/commands/run'; jest.mock('../../src/lib/shell', () => ({ exec: jest.fn(), @@ -114,12 +114,20 @@ jest.mock('../../src/lib/eval', () => ({ })); jest.mock('../../src/commands/sync', () => ({ + HARNESS_REGISTRY: { + codex: { skillsDir: '.agents/skills', agentsDir: '.codex/agents', instructionsFile: 'AGENTS.md' }, + }, syncAgentAssets: jest.fn().mockReturnValue({ synced: false, docSynced: false, skillsDirs: [] }), resolveHarnesses: jest.fn((harnesses: string[], _agent: string) => harnesses), })); jest.mock('../../src/commands/scan', () => ({ scanCommand: jest.fn(), + scanProject: jest.fn(), +})); + +jest.mock('../../src/lib/scan-validation', () => ({ + validateGeneratedMarkdownForCommit: jest.fn().mockReturnValue({ valid: true, errors: [] }), })); jest.mock('node:fs', () => ({ @@ -148,10 +156,12 @@ import { releaseSessionLock, SessionLockError } from '../../src/lib/session-lock import { extractLearnings, generateSessionSummary, repairSessionLearningArtifacts, repairSessionSummaryArtifact } from '../../src/lib/learning'; import { contextNeedsRefresh } from '../../src/lib/context'; import { syncAgentAssets } from '../../src/commands/sync'; -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; import { spawn } from 'node:child_process'; import { emitLifecycleEvent } from '../../src/lib/events'; import { spawnAgent } from '../../src/lib/agent'; +import { scanProject } from '../../src/commands/scan'; +import { validateGeneratedMarkdownForCommit } from '../../src/lib/scan-validation'; const mockExec = exec as jest.MockedFunction; const mockLog = log as jest.Mocked; @@ -188,8 +198,11 @@ const mockSyncAgentAssets = syncAgentAssets as jest.MockedFunction; const mockExistsSync = existsSync as jest.MockedFunction; const mockReadFileSync = readFileSync as jest.MockedFunction; +const mockUnlinkSync = unlinkSync as jest.MockedFunction; const mockEmitLifecycleEvent = emitLifecycleEvent as jest.MockedFunction; const mockSpawnAgent = spawnAgent as jest.MockedFunction; +const mockScanProject = scanProject as jest.MockedFunction; +const mockValidateGeneratedMarkdownForCommit = validateGeneratedMarkdownForCommit as jest.MockedFunction; const mockSpawn = spawn as jest.MockedFunction; function makeConfig(overrides: Record = {}) { @@ -256,7 +269,11 @@ beforeEach(() => { mockExistsSync.mockReturnValue(false); mockReadFileSync.mockReturnValue(''); - mockExec.mockReturnValue({ stdout: '/usr/bin/tool', stderr: '', exitCode: 0 }); + mockExec.mockImplementation((cmd: string) => ( + cmd.startsWith('git status --porcelain --') + ? { stdout: '', stderr: '', exitCode: 0 } + : { stdout: '/usr/bin/tool', stderr: '', exitCode: 0 } + )); mockLoadConfig.mockImplementation((overrides: any = {}) => makeConfig(overrides) as any); mockCreateSession.mockReturnValue({ name: 'session/20260330-143000', @@ -267,8 +284,10 @@ beforeEach(() => { pendingBackgroundTasks: [], }); mockFinalizeSession.mockResolvedValue(null); - mockEnsureSessionWorktree.mockImplementation(async (session) => { + mockEnsureSessionWorktree.mockImplementation(async (session, config, options) => { + if (config.dryRun) return null; session.worktreePath = `/tmp/${String(session.name).replace(/\//g, '-')}`; + await options?.prepare?.(session.worktreePath); return session.worktreePath; }); mockCleanupWorktree.mockResolvedValue({ status: 'removed', path: '/tmp/session-worktree' }); @@ -282,6 +301,8 @@ beforeEach(() => { mockReadGateResult.mockReturnValue({ passed: true, summary: 'Review passed', findings: [] }); mockContextNeedsRefresh.mockReturnValue(false); mockSyncAgentAssets.mockReturnValue({ synced: false, docSynced: false, skillsDirs: [] }); + mockScanProject.mockReturnValue({ contextWritten: true, instructionsWritten: true }); + mockValidateGeneratedMarkdownForCommit.mockReturnValue({ valid: true, errors: [] }); }); afterEach(() => { @@ -292,6 +313,179 @@ afterEach(() => { }); describe('runCommand', () => { + test('refreshes stale context inside the session worktree and continues the direct issue path used by daemon runs', async () => { + const sessionWorktree = '/tmp/session-20260330-143000'; + const config = makeConfig({ + autoMerge: true, + skipTests: true, + skipLearn: true, + skipVerify: true, + skipPostSessionReview: true, + harnesses: ['codex'], + }) as any; + const issue = { number: 408, title: 'Refresh context', body: 'Fix stale refresh', labels: ['ready'] }; + mockContextNeedsRefresh.mockReturnValue(true); + mockExistsSync.mockImplementation((filePath) => String(filePath).endsWith('instructions.md.bak')); + mockExec.mockImplementation((cmd: string, options?: { cwd?: string }) => { + if (cmd.startsWith('git status --porcelain --')) { + return options?.cwd === sessionWorktree + ? { stdout: ' M .alpha-loop/context.md\n M AGENTS.md', stderr: '', exitCode: 0 } + : { stdout: '', stderr: '', exitCode: 0 }; + } + return { stdout: '/usr/bin/tool', stderr: '', exitCode: 0 }; + }); + mockProcessIssue.mockResolvedValue({ + issueNum: 408, + title: issue.title, + status: 'success', + testsPassing: true, + verifyPassing: true, + verifySkipped: false, + duration: 1, + filesChanged: 1, + }); + + const result = await runSingleIssueExecution({ config, issueNumber: issue.number, issue }); + + expect(result.status).toBe('success'); + expect(mockScanProject).toHaveBeenCalledWith(sessionWorktree, config); + expect(mockSyncAgentAssets).toHaveBeenCalledWith(['codex'], { projectDir: sessionWorktree }); + expect(mockValidateGeneratedMarkdownForCommit).toHaveBeenCalledWith( + sessionWorktree, + ' M .alpha-loop/context.md\n M AGENTS.md', + ); + expect(mockExec).toHaveBeenCalledWith( + expect.stringMatching(/^git add -A -- /), + { cwd: sessionWorktree }, + ); + expect(mockUnlinkSync).toHaveBeenCalledWith( + '/tmp/session-20260330-143000/.alpha-loop/templates/instructions.md.bak', + ); + const generatedStatusCalls = mockExec.mock.calls.filter(([cmd]) => String(cmd).startsWith('git status --porcelain --')); + expect(generatedStatusCalls.every(([, options]) => options?.cwd === sessionWorktree)).toBe(true); + }); + + test('fails closed before issue processing when a stale scan does not write fresh context', async () => { + const config = makeConfig({ autoMerge: true, skipTests: true, skipPostSessionReview: true }) as any; + const issue = { number: 408, title: 'Refresh context', body: 'Fix stale refresh', labels: ['ready'] }; + mockContextNeedsRefresh.mockReturnValue(true); + mockScanProject.mockReturnValue({ contextWritten: false, instructionsWritten: true }); + + await expect(runSingleIssueExecution({ config, issueNumber: issue.number, issue })) + .rejects.toThrow(/did not produce fresh project context/i); + + expect(mockProcessIssue).not.toHaveBeenCalled(); + expect(mockFinalizeSession).not.toHaveBeenCalled(); + }); + + test('keeps dry-run and fresh-context paths free of scan writes', async () => { + const issue = { number: 408, title: 'Refresh context', body: 'Fix stale refresh', labels: ['ready'] }; + mockProcessIssue.mockResolvedValue({ + issueNum: 408, + title: issue.title, + status: 'success', + testsPassing: true, + verifyPassing: true, + verifySkipped: false, + duration: 1, + filesChanged: 0, + }); + + await runSingleIssueExecution({ + config: makeConfig({ autoMerge: true, dryRun: true, skipTests: true }) as any, + issueNumber: issue.number, + issue, + }); + expect(mockScanProject).not.toHaveBeenCalled(); + expect(mockLog.dry).toHaveBeenCalledWith('Would sync agent assets in the session worktree before run'); + + jest.clearAllMocks(); + mockContextNeedsRefresh.mockReturnValue(false); + mockEnsureSessionWorktree.mockImplementation(async (session, _config, options) => { + session.worktreePath = '/tmp/fresh-session'; + await options?.prepare?.(session.worktreePath); + return session.worktreePath; + }); + mockCreateSession.mockReturnValue({ + name: 'session/fresh', + branch: 'session/fresh', + resultsDir: '/tmp/sessions', + logsDir: '/tmp/sessions/logs', + results: [], + pendingBackgroundTasks: [], + }); + mockProcessIssue.mockResolvedValue({ + issueNum: 408, + title: issue.title, + status: 'success', + testsPassing: true, + verifyPassing: true, + verifySkipped: false, + duration: 1, + filesChanged: 0, + }); + mockFinalizeSession.mockResolvedValue(null); + mockSyncAgentAssets.mockReturnValue({ synced: false, docSynced: false, skillsDirs: [] }); + mockExec.mockImplementation((cmd: string) => ( + cmd.startsWith('git status --porcelain --') + ? { stdout: '', stderr: '', exitCode: 0 } + : { stdout: '/usr/bin/tool', stderr: '', exitCode: 0 } + )); + + await runSingleIssueExecution({ + config: makeConfig({ autoMerge: true, skipTests: true, skipLearn: true, skipVerify: true, skipPostSessionReview: true }) as any, + issueNumber: issue.number, + issue, + }); + + expect(mockScanProject).not.toHaveBeenCalled(); + expect(mockSyncAgentAssets).toHaveBeenCalledWith([], { projectDir: '/tmp/fresh-session' }); + }); + + test('syncs agent assets in the base checkout when auto-merge is disabled and context is fresh', async () => { + const config = makeConfig({ + autoMerge: false, + skipTests: true, + skipLearn: true, + skipVerify: true, + skipPostSessionReview: true, + harnesses: ['codex'], + }) as any; + const issue = { number: 408, title: 'Refresh context', body: 'Fix stale refresh', labels: ['ready'] }; + mockContextNeedsRefresh.mockReturnValue(false); + mockProcessIssue.mockResolvedValue({ + issueNum: 408, + title: issue.title, + status: 'success', + testsPassing: true, + verifyPassing: true, + verifySkipped: false, + duration: 1, + filesChanged: 0, + }); + + const result = await runSingleIssueExecution({ config, issueNumber: issue.number, issue }); + + expect(result.status).toBe('success'); + expect(mockSyncAgentAssets).toHaveBeenCalledWith(['codex']); + expect(mockScanProject).not.toHaveBeenCalled(); + expect(mockEnsureSessionWorktree).not.toHaveBeenCalled(); + }); + + test('does not sync base-checkout assets before rejecting a stale non-auto-merge run', async () => { + const config = makeConfig({ autoMerge: false, skipTests: true }) as any; + const issue = { number: 408, title: 'Refresh context', body: 'Fix stale refresh', labels: ['ready'] }; + mockContextNeedsRefresh.mockReturnValue(true); + + await expect(runSingleIssueExecution({ config, issueNumber: issue.number, issue })) + .rejects.toThrow(/auto_merge is disabled/i); + + expect(mockSyncAgentAssets).not.toHaveBeenCalled(); + expect(mockScanProject).not.toHaveBeenCalled(); + expect(mockCreateSession).not.toHaveBeenCalled(); + expect(mockEnsureSessionWorktree).not.toHaveBeenCalled(); + }); + test('records a durable failure when session worktree setup conflicts', async () => { const session = { name: 'session/epic-6-answer-engine', @@ -525,30 +719,28 @@ describe('runCommand', () => { expect(mockExit).not.toHaveBeenCalled(); }); - test('stops without git mutation when refreshed context fails validation', async () => { + test('stops before issue processing or publication when refreshed context fails validation', async () => { mockContextNeedsRefresh.mockReturnValue(true); mockLoadConfig.mockImplementation((overrides: any = {}) => makeConfig({ + autoMerge: true, skipPostSessionReview: true, ...overrides, }) as any); - mockExec.mockImplementation((cmd: string) => { - if (cmd === 'git status --porcelain .alpha-loop/ AGENTS.md CLAUDE.md') { + mockExec.mockImplementation((cmd: string, options?: { cwd?: string }) => { + if (cmd.startsWith('git status --porcelain --') && options?.cwd === '/tmp/session-20260330-143000') { return { stdout: ' M .alpha-loop/context.md\n', stderr: '', exitCode: 0 }; } return { stdout: '/usr/bin/tool', stderr: '', exitCode: 0 }; }); - mockExistsSync.mockImplementation((filePath: any) => String(filePath).endsWith('.alpha-loop/context.md')); - mockReadFileSync.mockImplementation((filePath: any) => { - if (String(filePath).endsWith('.alpha-loop/context.md')) { - return 'Wrote `PROJECT_CONTEXT.md` summarizing the current codebase.'; - } - return ''; + mockValidateGeneratedMarkdownForCommit.mockReturnValue({ + valid: false, + errors: ['.alpha-loop/context.md: missing required heading'], }); await runCommand({}); expect(mockLog.warn).toHaveBeenCalledWith('Generated context/instructions failed validation:'); - expect(mockLog.error).toHaveBeenCalledWith(expect.stringContaining('commit them through a pull request')); + expect(mockLog.error).toHaveBeenCalledWith(expect.stringContaining('No session branch or pull request was published')); expect(process.exitCode).toBe(1); expect(mockExec.mock.calls.some(([cmd]) => String(cmd).startsWith('git add '))).toBe(false); expect(mockExec.mock.calls.some(([cmd]) => String(cmd).startsWith('git commit '))).toBe(false); @@ -556,44 +748,32 @@ describe('runCommand', () => { expect(mockProcessIssue).not.toHaveBeenCalled(); }); - test('stops valid context refresh without pushing the configured base branch', async () => { - const validContext = [ - '## Architecture', - '- TypeScript CLI', - '## Conventions', - '- Jest tests', - '## Critical Rules', - '- PRs only', - '## Active State', - '- Context refreshed', - ].join('\n'); + test('continues after a valid context refresh without mutating the configured base checkout', async () => { mockContextNeedsRefresh.mockReturnValue(true); mockLoadConfig.mockImplementation((overrides: any = {}) => makeConfig({ + autoMerge: true, baseBranch: 'protected-main', skipPostSessionReview: true, ...overrides, }) as any); - mockExec.mockImplementation((cmd: string) => { - if (cmd === 'git status --porcelain .alpha-loop/ AGENTS.md CLAUDE.md') { + mockExec.mockImplementation((cmd: string, options?: { cwd?: string }) => { + if (cmd.startsWith('git status --porcelain --') && options?.cwd === '/tmp/session-20260330-143000') { return { stdout: ' M .alpha-loop/context.md\n', stderr: '', exitCode: 0 }; } - return { stdout: validContext, stderr: '', exitCode: 0 }; - }); - mockExistsSync.mockImplementation((filePath: any) => String(filePath).endsWith('.alpha-loop/context.md')); - mockReadFileSync.mockImplementation((filePath: any) => { - if (String(filePath).endsWith('.alpha-loop/context.md')) return validContext; - return ''; + return { stdout: '/usr/bin/tool', stderr: '', exitCode: 0 }; }); await runCommand({}); - expect(mockLog.error).toHaveBeenCalledWith(expect.stringContaining( - 'Alpha Loop will not push directly to protected-main', - )); - expect(process.exitCode).toBe(1); - expect(mockExec.mock.calls.some(([cmd]) => String(cmd).startsWith('git add '))).toBe(false); + expect(mockScanProject).toHaveBeenCalledWith('/tmp/session-20260330-143000', expect.objectContaining({ + baseBranch: 'protected-main', + })); + expect(process.exitCode).toBeUndefined(); + expect(mockExec).toHaveBeenCalledWith(expect.stringMatching(/^git add -A -- /), { + cwd: '/tmp/session-20260330-143000', + }); expect(mockExec.mock.calls.some(([cmd]) => String(cmd).startsWith('git commit '))).toBe(false); - expect(mockExec.mock.calls.some(([cmd]) => String(cmd).startsWith('git push '))).toBe(false); + expect(mockExec.mock.calls.some(([cmd]) => String(cmd).includes('git push') && String(cmd).includes('protected-main'))).toBe(false); expect(mockProcessIssue).not.toHaveBeenCalled(); }); @@ -717,8 +897,8 @@ describe('runCommand', () => { expect(manifest.epics[2].dependencyFailure.failedEpicIds).toEqual([10]); expect(manifest.epics[4].dependencyFailure.failedEpicIds).toEqual([10, 30]); expect(manifest.epics[1].logPath).toContain('epic-20.log'); - expect(mockSyncAgentAssets).toHaveBeenCalledTimes(1); - expect(mockContextNeedsRefresh).toHaveBeenCalledTimes(1); + expect(mockSyncAgentAssets).not.toHaveBeenCalled(); + expect(mockContextNeedsRefresh).not.toHaveBeenCalled(); }); test('internal queue workers persist a structured result artifact before exiting', async () => { @@ -766,10 +946,17 @@ describe('runCommand', () => { expect(process.exitCode).toBe(1); }); - test('internal queue workers leave shared project preparation to the coordinator', async () => { + test('internal queue workers prepare generated files only in their own session worktree', async () => { const queueDir = join(process.cwd(), '.alpha-loop', 'sessions', 'queue-test'); const contextPath = join(queueDir, 'epic-77-context.json'); const resultPath = join(queueDir, 'epic-77-result.json'); + mockContextNeedsRefresh.mockReturnValue(true); + mockExec.mockImplementation((cmd: string, options?: { cwd?: string }) => { + if (cmd.startsWith('git status --porcelain --') && options?.cwd === '/tmp/session-20260330-143000') { + return { stdout: ' M .alpha-loop/context.md\n', stderr: '', exitCode: 0 }; + } + return { stdout: '/usr/bin/tool', stderr: '', exitCode: 0 }; + }); mockLoadConfig.mockImplementation((overrides: any = {}) => makeConfig({ ...overrides, mergeTo: 'shared-integration-branch', @@ -821,8 +1008,14 @@ describe('runCommand', () => { skipLearn: true, }); - expect(mockSyncAgentAssets).not.toHaveBeenCalled(); - expect(mockContextNeedsRefresh).not.toHaveBeenCalled(); + expect(mockSyncAgentAssets).toHaveBeenCalledWith([], { + projectDir: '/tmp/session-20260330-143000', + }); + expect(mockContextNeedsRefresh).toHaveBeenCalledTimes(1); + expect(mockScanProject).toHaveBeenCalledWith( + '/tmp/session-20260330-143000', + expect.objectContaining({ autoMerge: true, mergeTo: '' }), + ); expect(mockCreateSession).toHaveBeenCalledWith( expect.objectContaining({ autoMerge: true, mergeTo: '' }), expect.objectContaining({ epicNum: 77 }), @@ -863,8 +1056,10 @@ describe('runCommand', () => { await runCommand({ dryRun: true, validate: true }); expect(mockSyncAgentAssets).not.toHaveBeenCalled(); - expect(mockLog.dry).toHaveBeenCalledWith('Would sync agent assets before run'); - expect(mockLog.dry).toHaveBeenCalledWith('Would refresh project context and instructions'); + expect(mockLog.dry).toHaveBeenCalledWith( + 'Would stop because project context is stale and auto_merge is disabled', + ); + expect(mockLog.dry).not.toHaveBeenCalledWith('Would sync agent assets before run'); expect(mockWriteFileSync).not.toHaveBeenCalled(); }); diff --git a/tests/commands/scan.test.ts b/tests/commands/scan.test.ts index a2ab89b..8c02ad5 100644 --- a/tests/commands/scan.test.ts +++ b/tests/commands/scan.test.ts @@ -17,7 +17,7 @@ jest.mock('../../src/lib/agent', () => ({ buildOneShotCommand: jest.fn(() => 'claude -p --allowedTools "" --output-format text'), })); -import { scanCommand } from '../../src/commands/scan'; +import { scanCommand, scanProject } from '../../src/commands/scan'; import { exec } from '../../src/lib/shell'; import { buildOneShotCommand } from '../../src/lib/agent'; @@ -71,6 +71,7 @@ describe('scan', () => { let origCwd: () => string; beforeEach(() => { + jest.clearAllMocks(); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scan-test-')); origCwd = process.cwd; process.cwd = () => tmpDir; @@ -103,6 +104,34 @@ describe('scan', () => { expect(mockBuildOneShotCommand).toHaveBeenCalledWith('claude', '', { textOnly: true }); }); + it('can scan an explicit project directory without changing the CLI cwd', () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scan-project-test-')); + mockExec.mockReturnValueOnce({ + stdout: validContext, + stderr: '', + exitCode: 0, + }).mockReturnValueOnce({ + stdout: validInstructions, + stderr: '', + exitCode: 0, + }); + + try { + const result = scanProject(projectDir, { agent: 'claude', model: '' } as any); + + expect(result).toEqual({ contextWritten: true, instructionsWritten: true }); + expect(fs.existsSync(path.join(projectDir, '.alpha-loop', 'context.md'))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, '.alpha-loop', 'context.md'))).toBe(false); + expect(mockExec).toHaveBeenNthCalledWith( + 1, + expect.any(String), + expect.objectContaining({ cwd: projectDir }), + ); + } finally { + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + it('overwrites existing context file on re-run', () => { const contextDir = path.join(tmpDir, '.alpha-loop'); fs.mkdirSync(contextDir, { recursive: true }); diff --git a/tests/lib/session.test.ts b/tests/lib/session.test.ts index 794227d..8d675eb 100644 --- a/tests/lib/session.test.ts +++ b/tests/lib/session.test.ts @@ -250,6 +250,91 @@ describe('createSession', () => { expect(mockExec.mock.calls.some(([cmd, options]) => String(cmd).includes('git checkout') && options?.cwd === process.cwd())).toBe(false); }); + test('commits prepared generated files before pushing and opening the draft PR', async () => { + const events: string[] = []; + mockExec.mockImplementation((cmd: string) => { + if (cmd.startsWith('git ls-remote --heads')) return { stdout: '', stderr: '', exitCode: 0 }; + if (cmd.startsWith('git diff --cached --quiet --')) return { stdout: '', stderr: '', exitCode: 1 }; + if (cmd.startsWith('git commit')) events.push('commit'); + if (cmd.startsWith('git push')) events.push('push'); + return { stdout: '', stderr: '', exitCode: 0 }; + }); + mockCreatePR.mockImplementation(() => { + events.push('pr'); + return 'https://github.com/owner/repo/pull/408'; + }); + const config = makeConfig({ autoMerge: true }); + const session = createSession(config, { epicNum: 408, epicTitle: 'Refresh Context' }); + + await ensureSessionWorktree(session, config, { + prepare: async (worktreePath) => { + events.push(`prepare:${worktreePath}`); + return { + commitMessage: 'chore: refresh project context', + paths: ['.alpha-loop/context.md', 'AGENTS.md'], + }; + }, + }); + + expect(events).toEqual([ + expect.stringMatching(/^prepare:.*session-epic-408-refresh-context$/), + 'commit', + 'push', + 'pr', + ]); + expect(mockExec).toHaveBeenCalledWith( + "git commit -m 'chore: refresh project context' -- '.alpha-loop/context.md' 'AGENTS.md'", + { cwd: expect.stringContaining('.worktrees/session-epic-408-refresh-context') }, + ); + expect(mockExec.mock.calls.some(([cmd]) => String(cmd).includes('git commit --allow-empty'))).toBe(false); + }); + + test('does not publish a new session branch when preparation fails', async () => { + const config = makeConfig({ autoMerge: true }); + const session = createSession(config, { epicNum: 408, epicTitle: 'Refresh Context' }); + + await expect(ensureSessionWorktree(session, config, { + prepare: () => { throw new Error('invalid generated markdown'); }, + })).rejects.toThrow('invalid generated markdown'); + + expect(mockExec.mock.calls.some(([cmd]) => String(cmd).startsWith('git push origin'))).toBe(false); + expect(mockCreatePR).not.toHaveBeenCalled(); + }); + + test('pushes prepared context onto an existing resumed session branch without opening another PR', async () => { + mockExec.mockImplementation((cmd: string) => { + if (cmd.startsWith('git ls-remote --heads')) { + return { stdout: 'abc refs/heads/session/epic-408-refresh-context', stderr: '', exitCode: 0 }; + } + if (cmd.startsWith('git diff --cached --quiet --')) return { stdout: '', stderr: '', exitCode: 1 }; + return { stdout: '', stderr: '', exitCode: 0 }; + }); + mockSetupWorktree.mockImplementation(async (options) => ({ + path: options.worktreePath ?? '/project/.worktrees/session-test', + branch: options.branch ?? `agent/issue-${options.issueNum}`, + resumed: true, + })); + const config = makeConfig({ autoMerge: true }); + const session = createSession(config, { epicNum: 408, epicTitle: 'Refresh Context' }); + + await ensureSessionWorktree(session, config, { + prepare: () => ({ + commitMessage: 'chore: refresh project context', + paths: ['.alpha-loop/context.md'], + }), + }); + + expect(mockExec).toHaveBeenCalledWith( + "git commit -m 'chore: refresh project context' -- '.alpha-loop/context.md'", + { cwd: expect.stringContaining('.worktrees/session-epic-408-refresh-context') }, + ); + expect(mockExec).toHaveBeenCalledWith( + "git push origin 'session/epic-408-refresh-context'", + { cwd: expect.stringContaining('.worktrees/session-epic-408-refresh-context') }, + ); + expect(mockCreatePR).not.toHaveBeenCalled(); + }); + test('does not create session branch in dryRun mode', () => { createSession(makeConfig({ autoMerge: true, dryRun: true })); From c6991ec02cc117293740806115bebd3a4ae5c3eb Mon Sep 17 00:00:00 2001 From: Bradley Taylor Date: Sat, 15 Aug 2026 14:40:33 -0700 Subject: [PATCH 3/4] fix: address session review findings --- src/commands/run.ts | 8 ++++++-- src/commands/scan.ts | 7 ++++--- tests/commands/run.test.ts | 23 ++++++++++++++++++++++- tests/commands/scan.test.ts | 22 ++++++++++++++++++++++ 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/commands/run.ts b/src/commands/run.ts index 5f199ce..747ebc6 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -1062,10 +1062,14 @@ async function prepareSessionProjectFiles( const scanResult = scanProject(worktreePath, config); const backupPath = join(worktreePath, '.alpha-loop', 'templates', 'instructions.md.bak'); if (existsSync(backupPath)) unlinkSync(backupPath); - if (!scanResult.contextWritten) { + if (!scanResult.contextWritten || !scanResult.instructionsWritten) { + const missingOutputs = [ + !scanResult.contextWritten ? 'project context' : '', + !scanResult.instructionsWritten ? 'managed instructions' : '', + ].filter(Boolean).join(' or '); throw new CommandExitError({ code: 'project-context-refresh-required', - message: 'Project context refresh did not produce fresh project context. ' + + message: `Project context refresh did not produce fresh ${missingOutputs}. ` + 'Review the scan output and retry; no session branch or pull request was published.', }); } diff --git a/src/commands/scan.ts b/src/commands/scan.ts index 2aa8a73..28c47b7 100644 --- a/src/commands/scan.ts +++ b/src/commands/scan.ts @@ -100,15 +100,16 @@ IMPORTANT: Do NOT add testing procedures, git workflow, code review checklists, const AGENT_TIMEOUT = 5 * 60 * 1000; function runPromptFile(projectDir: string, agentCmd: string, prompt: string, prefix: string) { - const promptFile = path.join(tmpdir(), `alpha-loop-${prefix}-${Date.now()}.txt`); - fs.writeFileSync(promptFile, prompt, 'utf-8'); + const promptDir = fs.mkdtempSync(path.join(tmpdir(), `alpha-loop-${prefix}-`)); + const promptFile = path.join(promptDir, 'prompt.txt'); try { + fs.writeFileSync(promptFile, prompt, 'utf-8'); return exec( `${agentCmd} < "${promptFile}" 2>/dev/null`, { cwd: projectDir, timeout: AGENT_TIMEOUT }, ); } finally { - try { fs.unlinkSync(promptFile); } catch { /* cleanup best-effort */ } + try { fs.rmSync(promptDir, { recursive: true, force: true }); } catch { /* cleanup best-effort */ } } } diff --git a/tests/commands/run.test.ts b/tests/commands/run.test.ts index 3c2c865..9854401 100644 --- a/tests/commands/run.test.ts +++ b/tests/commands/run.test.ts @@ -366,7 +366,11 @@ describe('runCommand', () => { }); test('fails closed before issue processing when a stale scan does not write fresh context', async () => { - const config = makeConfig({ autoMerge: true, skipTests: true, skipPostSessionReview: true }) as any; + const config = makeConfig({ + autoMerge: true, + skipTests: true, + skipPostSessionReview: true, + }) as unknown as ReturnType; const issue = { number: 408, title: 'Refresh context', body: 'Fix stale refresh', labels: ['ready'] }; mockContextNeedsRefresh.mockReturnValue(true); mockScanProject.mockReturnValue({ contextWritten: false, instructionsWritten: true }); @@ -378,6 +382,23 @@ describe('runCommand', () => { expect(mockFinalizeSession).not.toHaveBeenCalled(); }); + test('fails closed before issue processing when a stale scan does not write fresh instructions', async () => { + const config = makeConfig({ + autoMerge: true, + skipTests: true, + skipPostSessionReview: true, + }) as unknown as ReturnType; + const issue = { number: 408, title: 'Refresh context', body: 'Fix stale refresh', labels: ['ready'] }; + mockContextNeedsRefresh.mockReturnValue(true); + mockScanProject.mockReturnValue({ contextWritten: true, instructionsWritten: false }); + + await expect(runSingleIssueExecution({ config, issueNumber: issue.number, issue })) + .rejects.toThrow(/did not produce fresh managed instructions/i); + + expect(mockProcessIssue).not.toHaveBeenCalled(); + expect(mockFinalizeSession).not.toHaveBeenCalled(); + }); + test('keeps dry-run and fresh-context paths free of scan writes', async () => { const issue = { number: 408, title: 'Refresh context', body: 'Fix stale refresh', labels: ['ready'] }; mockProcessIssue.mockResolvedValue({ diff --git a/tests/commands/scan.test.ts b/tests/commands/scan.test.ts index 8c02ad5..760125f 100644 --- a/tests/commands/scan.test.ts +++ b/tests/commands/scan.test.ts @@ -132,6 +132,28 @@ describe('scan', () => { } }); + it('uses unique prompt files for concurrent workers even when timestamps match', () => { + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + mockExec.mockImplementation(() => { + const invocation = mockExec.mock.calls.length; + return { + stdout: invocation % 2 === 1 ? validContext : validInstructions, + stderr: '', + exitCode: 0, + }; + }); + + scanProject(tmpDir, { agent: 'claude', model: '' }); + scanProject(tmpDir, { agent: 'claude', model: '' }); + + const promptFiles = mockExec.mock.calls.map(([command]) => { + const match = command.match(/< "([^"]+)"/); + expect(match).not.toBeNull(); + return match?.[1]; + }); + expect(new Set(promptFiles).size).toBe(promptFiles.length); + }); + it('overwrites existing context file on re-run', () => { const contextDir = path.join(tmpDir, '.alpha-loop'); fs.mkdirSync(contextDir, { recursive: true }); From 360d6403b74bec2aeacecd3460205dc57d0373b0 Mon Sep 17 00:00:00 2001 From: Bradley Taylor Date: Sat, 15 Aug 2026 14:41:14 -0700 Subject: [PATCH 4/4] chore: learnings from session/20260815-141225 Processed 1 issue(s) in this session. --- .../learnings/issue-408-20260815-143441.md | 28 ++++++ .../session-session-20260815-141225.json | 89 +++++++++++++++++++ ...session-summary-session-20260815-141225.md | 16 ++++ 3 files changed, 133 insertions(+) create mode 100644 .alpha-loop/learnings/issue-408-20260815-143441.md create mode 100644 .alpha-loop/learnings/session-session-20260815-141225.json create mode 100644 .alpha-loop/learnings/session-summary-session-20260815-141225.md diff --git a/.alpha-loop/learnings/issue-408-20260815-143441.md b/.alpha-loop/learnings/issue-408-20260815-143441.md new file mode 100644 index 0000000..2b61b77 --- /dev/null +++ b/.alpha-loop/learnings/issue-408-20260815-143441.md @@ -0,0 +1,28 @@ +--- +issue: 408 +status: success +test_fix_retries: 0 +duration: 1311 +date: 2026-08-15 +retries: 0 +traces: + plan: .alpha-loop/sessions/session/20260815-141225/traces/prompts/plan-issue-408.md + implement: .alpha-loop/sessions/session/20260815-141225/logs/issue-408-implement.log + review: .alpha-loop/sessions/session/20260815-141225/traces/outputs/review-issue-408.log + diff: .alpha-loop/sessions/session/20260815-141225/diffs/issue-408.diff +--- + +## What Worked +- Stale context regeneration moved into the isolated session worktree and is committed to the session branch before publication. + +## What Failed +- Review found that fresh runs with `auto_merge` disabled no longer synchronized agent assets; the regression was fixed before completion. + +## Patterns +- Generated project files should be prepared, validated, and committed inside the session worktree rather than the base checkout. + +## Anti-Patterns +- Do not treat valid generated diffs as an unconditional failure after regeneration succeeds. + +## Suggested Skill Updates +- Update implementation-planning guidance to require an execution-mode matrix covering dry-run, auto-merge, non-auto-merge, and parallel-worker paths. diff --git a/.alpha-loop/learnings/session-session-20260815-141225.json b/.alpha-loop/learnings/session-session-20260815-141225.json new file mode 100644 index 0000000..1659faa --- /dev/null +++ b/.alpha-loop/learnings/session-session-20260815-141225.json @@ -0,0 +1,89 @@ +{ + "name": "session/20260815-141225", + "branch": "session/20260815-141225", + "completed": "2026-08-15T21:41:14.520Z", + "results": [ + { + "issueNum": 408, + "title": "fix: first run after context goes stale always aborts — refreshProjectContext has no success path", + "status": "success", + "prUrl": "https://github.com/bradtaylorsf/alpha-loop/pull/428", + "testsPassing": true, + "verifyPassing": true, + "duration": 1311, + "filesChanged": 3 + } + ], + "stages": [ + { + "stage": "plan", + "model": "", + "endpoint": "default", + "tokens_in": 78192, + "tokens_out": 60148, + "cost_usd": 0, + "wall_time_s": 247.448, + "tool_calls": 0, + "tool_errors": 0, + "stage_success": true, + "started_at": "2026-08-15T21:12:46.516Z", + "issue_num": 408 + }, + { + "stage": "implement", + "model": "", + "endpoint": "default", + "tokens_in": 697535, + "tokens_out": 536565, + "cost_usd": 0, + "wall_time_s": 700.915, + "tool_calls": 0, + "tool_errors": 0, + "stage_success": true, + "started_at": "2026-08-15T21:16:54.424Z", + "issue_num": 408 + }, + { + "stage": "review", + "model": "", + "endpoint": "default", + "tokens_in": 159723, + "tokens_out": 122864, + "cost_usd": 0, + "wall_time_s": 347.627, + "tool_calls": 0, + "tool_errors": 0, + "stage_success": true, + "started_at": "2026-08-15T21:28:39.753Z", + "issue_num": 408 + }, + { + "stage": "learn", + "model": "", + "endpoint": "default", + "tokens_in": 7072, + "tokens_out": 5440, + "cost_usd": 0, + "wall_time_s": 12.923, + "tool_calls": 0, + "tool_errors": 0, + "stage_success": true, + "started_at": "2026-08-15T21:34:41.690Z", + "issue_num": 408 + }, + { + "stage": "assumptions", + "model": "", + "endpoint": "default", + "tokens_in": 50865, + "tokens_out": 39127, + "cost_usd": 0, + "wall_time_s": 70.239, + "tool_calls": 0, + "tool_errors": 0, + "stage_success": true, + "started_at": "2026-08-15T21:34:41.715Z", + "issue_num": 408 + } + ] +} diff --git a/.alpha-loop/learnings/session-summary-session-20260815-141225.md b/.alpha-loop/learnings/session-summary-session-20260815-141225.md new file mode 100644 index 0000000..e34ee02 --- /dev/null +++ b/.alpha-loop/learnings/session-summary-session-20260815-141225.md @@ -0,0 +1,16 @@ +# Session Summary: session/20260815-141225 + +## Overview +- Issue 408 completed successfully in 22 minutes with no retries or test-fix cycles. + +## Recurring Patterns +- No cross-issue patterns could be established from a single issue. + +## Recurring Anti-Patterns +- No recurring cross-issue anti-patterns were observed. + +## Recommendations +- Update implementation-planning guidance to require an execution-mode matrix covering dry-run, auto-merge, non-auto-merge, and parallel-worker paths. + +## Metrics +| Metric | Value |