Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions project/ticket-100/README.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

77 changes: 77 additions & 0 deletions project/ticket-100/intent.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 25 additions & 3 deletions src/watch/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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.
}
}

Expand Down
52 changes: 52 additions & 0 deletions test/watch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
}
Loading