diff --git a/project/ticket-100/README.md b/project/ticket-100/README.md new file mode 100644 index 00000000..94e3f20c --- /dev/null +++ b/project/ticket-100/README.md @@ -0,0 +1,15 @@ +# Ticket 100: Preserve changes during watch reports + +- **Status**: IN_PROGRESS +- **Workflow state**: PUBLICATION +- **Owner**: agent:codex + +User-authorized scope: complete PLF-13849 and blocking dependencies, including tests, push and independent protected merge. This is a session scope record (SESSION_EXECUTION_AUTHORIZATION), not approval or merge evidence. + +The required verify job for todo2code #118 lost a file change after initial report publication. The watcher refreshes its entire baseline after report completion, silently absorbing concurrent source edits. Preserve source changes for the next poll and suppress only declared report outputs. Keep polling, throttling, cancellation and ignore boundaries. + +Acceptance: a deterministic injected report that changes source triggers a follow-up report, including a failed report; generated outputs do not cause a feedback loop. Existing CLI integration and full verify pass. Scope is the watcher and its unit tests; no timeout increase or removal of assertions. + +Canonical final evidence: https://github.com/subactor/docs/blob/main/architecture/analysis/autonomy-acceptance.md. + +Validation: four deterministic regression cases failed before the fix; 15 watcher/CLI cases pass after it. Full verify after integrating published configuration support and analysis exclusions: 450 passed, zero failed, one optional Java skip. Offline smoke and Docker smoke pass. Required hosted Java 17 and independent exact-head Validator approval remain publication gates. diff --git a/project/ticket-100/intent.json b/project/ticket-100/intent.json new file mode 100644 index 00000000..173cad51 --- /dev/null +++ b/project/ticket-100/intent.json @@ -0,0 +1,77 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-100", + "summary": "Preserve source changes made during watch reports", + "workstream": "runtime", + "classification": { + "kind": "BUG", + "priority": "P1", + "origin": "requested" + }, + "allowedPaths": [ + "src/watch/watcher.ts", + "test/watch.test.ts", + "project/ticket-100/**" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [ + "node" + ], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "c1d6e1d41a3ee41219ca68872f32f67b1b8f8df1", + "targetBranch": "main", + "outcome": "Preserve concurrent source edits during successful and failed watch reports while excluding only generated reports and cache.", + "nonGoals": [ + "No timeout increases", + "No changes to provider routing or publication gates" + ], + "complexity": "S", + "estimatedMinutes": 20, + "budgets": { + "maxImplementationFiles": 2, + "maxAffectedComponents": 1, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Retain the pre-report source baseline and filter the pipeline-owned output and cache paths during scans.", + "components": [ + { + "name": "watcher", + "paths": [ + "src/watch/watcher.ts", + "test/watch.test.ts" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Revert the watcher change while preserving source files and previously generated reports." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "npm run verify", + "node --test dist/test/watch.test.js dist/test/cli-watch.test.js", + "make smoke", + "make docker-smoke" + ], + "evidence": "Concurrent edits survive successful and failed reports; generated report/cache files stay quiet; adjacent source paths remain watched." + } + ] + } +} diff --git a/src/watch/watcher.ts b/src/watch/watcher.ts index bc04e78c..be8fe4fd 100644 --- a/src/watch/watcher.ts +++ b/src/watch/watcher.ts @@ -172,7 +172,29 @@ async function createWatchRuntime(options: WatchOptions, config: T2CConfig): Pro return { runId: result.manifest.runId, summaryPath: result.summaryPath }; }); - const scanOptions: ScanOptions = { matcher, ...(options.maxFiles === undefined ? {} : { maxFiles: options.maxFiles }) }; + // The pipeline owns runs/ and latest.json; extractors also own the cache + // under config.outputDir, which can differ from the report output directory. + // Exclude those artifacts explicitly, including when outputDir is the root, + // so reports cannot feed themselves back into the source-change detector. + const outputRoot = path.resolve(options.pipeline.root, options.pipeline.outputDir); + const generatedDirectories = [ + path.join(outputRoot, 'runs'), + path.resolve(options.pipeline.root, config.outputDir, 'cache'), + ]; + const latestPath = path.join(outputRoot, 'latest.json'); + const scanMatcher: IgnoreMatcher = { + ...matcher, + ignores(relativePath, isDirectory) { + const absolute = path.resolve(root, relativePath); + return absolute === latestPath + || generatedDirectories.some(directory => absolute === directory || absolute.startsWith(`${directory}${path.sep}`)) + || matcher.ignores(relativePath, isDirectory); + }, + }; + const scanOptions: ScanOptions = { + matcher: scanMatcher, + ...(options.maxFiles === undefined ? {} : { maxFiles: options.maxFiles }), + }; return { root, minIntervalMs, scanIntervalMs, emit, now, sleep, signal, matcher, runReport, scanOptions }; } @@ -239,8 +261,8 @@ export async function watchRepository(options: WatchOptions, config: T2CConfig): } catch (error) { emit({ type: 'report:error', message: error instanceof Error ? error.message : String(error) }); } - // Changes written by the report itself must not trigger the next one. - snapshot = await scanTree(root, scanOptions); + // Keep the pre-report source snapshot: edits made while the report ran + // must be detected on the next poll, even if report generation failed. } } diff --git a/test/watch.test.ts b/test/watch.test.ts index 9e06ac01..73049048 100644 --- a/test/watch.test.ts +++ b/test/watch.test.ts @@ -300,3 +300,55 @@ test('Communication changes trigger watch and coalesce under the existing report assert.match(harness.reports[1] ?? '', /project\/WM-202\/agent\.plan\.md/); assert.ok(harness.events.some((event) => event.type === 'throttled')); }); + + +for (const outputDir of ['reports', '.']) { + for (const firstReportFails of [false, true]) { + test(`Changes during a ${firstReportFails ? 'failed' : 'successful'} report survive with output ${outputDir}`, async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 't2c-watch-during-report-')); + // A sibling sharing the runs prefix remains source even inside the + // configured output directory; only the generated namespace is ignored. + const source = path.join(root, outputDir, 'runs-source.ts'); + await fs.mkdir(path.dirname(source), { recursive: true }); + await fs.writeFile(source, 'export const before = true;\n'); + const harness = createHarness(); + const controller = new AbortController(); + let ticks = 0; + let reports = 0; + await watchRepository({ + root, + pipeline: { ...pipelineOptions(root), outputDir }, + minIntervalMs: 0, + signal: controller.signal, + now: harness.now, + onEvent: harness.onEvent, + sleep: async (ms) => { + await harness.sleep(ms); + if (++ticks === 4) controller.abort(); + }, + runReport: async (reason) => { + reports += 1; + harness.reports.push(reason); + const runDir = path.join(root, outputDir, 'runs', String(reports)); + await fs.mkdir(runDir, { recursive: true }); + await fs.writeFile(path.join(runDir, 'summary.md'), 'generated output'); + await fs.writeFile(path.join(root, outputDir, 'latest.json'), JSON.stringify({ reports })); + const cacheDir = path.join(root, makeConfig(root).outputDir, 'cache', 'v1'); + await fs.mkdir(cacheDir, { recursive: true }); + await fs.writeFile(path.join(cacheDir, `${reports}.json`), 'generated cache entry'); + if (reports === 1) { + // Deterministically reproduce a source edit after report output is + // published but before the report promise has settled. + await fs.writeFile(source, 'export const changedDuringReport = true;\n'); + if (firstReportFails) throw new Error('report failed after concurrent edit'); + } + return { runId: String(reports), summaryPath: path.join(runDir, 'summary.md') }; + }, + }, makeConfig(root)); + assert.equal(reports, 2, 'source edit must survive without an output feedback loop'); + const relativeSource = path.relative(root, source).replace(/\\/g, '/'); + assert.equal(harness.reports[1], `1 change(s): ~${relativeSource}`); + assert.equal(harness.events.some(event => event.type === 'report:error'), firstReportFails); + }); + } +}