Skip to content

Commit f979480

Browse files
Aitomatesclaude
andcommitted
feat(autonomy): Phase 2 Continuous Learning Pipeline — GitPoller + AUTO-03/04
- GitPoller (src/history/git-poller.ts): polls CommitIngester.ingestLatest every 30s in production; emits commits event when new commits detected (AUTO-03) - BackgroundAutonomyService: wires GitPoller via optional gitPollIntervalMs param (null = disabled, safe for fake-timer tests); server.ts passes 30_000 (AUTO-04) - 8 new tests in git-poller.test.ts: poll/event/error/start/stop/idempotent - 122/122 tests green Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d5bc815 commit f979480

5 files changed

Lines changed: 289 additions & 6 deletions

File tree

universal-refiner/src/core/background-service.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,35 @@ import * as chokidar from 'chokidar';
22
import { CommitIngester } from "../history/commit-ingest.js";
33
import { LessonExtractor } from "../history/lesson-extractor.js";
44
import { CorrelationEngine } from "../history/correlation-engine.js";
5+
import { GitPoller } from "../history/git-poller.js";
56
import { RuntimeLogger } from "./logger.js";
67
import { CommandCenterDashboard } from "./dashboard.js";
78
import { SerializedJobQueue } from "./job-queue.js";
89

910
export class BackgroundAutonomyService {
1011
private watcher: chokidar.FSWatcher | null = null;
12+
private gitPoller: GitPoller | null = null;
1113
private debounceTimer: NodeJS.Timeout | null = null;
1214
private rootPath: string;
1315
private requestModelText: (taskName: string, userPrompt: string, maxTokens: number) => Promise<string | null>;
1416
private queue = new SerializedJobQueue();
1517

18+
/**
19+
* @param gitPollIntervalMs — pass a number (ms) to enable git polling (AUTO-03).
20+
* Defaults to null (disabled) so tests that use vi.runAllTimersAsync() are not
21+
* affected by an infinite setInterval. The production server passes 30_000.
22+
*/
1623
constructor(
1724
rootPath: string,
18-
requestModelText: (taskName: string, userPrompt: string, maxTokens: number) => Promise<string | null>
25+
requestModelText: (taskName: string, userPrompt: string, maxTokens: number) => Promise<string | null>,
26+
gitPollIntervalMs: number | null = null,
1927
) {
2028
this.rootPath = rootPath;
2129
this.requestModelText = requestModelText;
30+
if (gitPollIntervalMs !== null) {
31+
this.gitPoller = new GitPoller(rootPath, gitPollIntervalMs);
32+
this.gitPoller.on("commits", () => this.triggerAutonomy());
33+
}
2234
}
2335

2436
public start() {
@@ -41,11 +53,11 @@ export class BackgroundAutonomyService {
4153
});
4254

4355
this.watcher.on('all', (event, filePath) => {
44-
// Don't log every single file change to dashboard to avoid noise, but log to RuntimeLogger
4556
RuntimeLogger.debug(`File change detected: ${event} ${filePath}`);
4657
this.triggerAutonomy();
4758
});
4859

60+
this.gitPoller?.start();
4961
this.queue.enqueue(`autonomy:${this.rootPath}`, () => this.runCycles());
5062
}
5163

@@ -65,9 +77,6 @@ export class BackgroundAutonomyService {
6577
private async runCycles() {
6678
try {
6779
CommandCenterDashboard.log("Background Autonomy: Change detected. Triggering intelligence cycles...");
68-
69-
// a) CommitIngester.ingestLatest()
70-
// We increase the limit significantly because the ingester now fetches only since last SHA
7180
const ingestedCount = await CommitIngester.ingestLatest(this.rootPath, 100);
7281
CommandCenterDashboard.log(`Background Autonomy: Ingested ${ingestedCount} commits.`);
7382

@@ -93,6 +102,7 @@ export class BackgroundAutonomyService {
93102
clearTimeout(this.debounceTimer);
94103
this.debounceTimer = null;
95104
}
105+
this.gitPoller?.stop();
96106
}
97107

98108
public async idle() {

universal-refiner/src/core/server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -737,7 +737,8 @@ Output ONLY the JSON array. If no gaps, return [].`,
737737
// Start Background Autonomy
738738
this.backgroundAutonomy = new BackgroundAutonomyService(
739739
this.rootPath,
740-
this.requestModelText.bind(this)
740+
this.requestModelText.bind(this),
741+
30_000, // AUTO-03: poll git every 30s
741742
);
742743
this.backgroundAutonomy.start();
743744

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { EventEmitter } from "node:events";
2+
import { CommitIngester } from "./commit-ingest.js";
3+
import { RuntimeLogger } from "../core/logger.js";
4+
import { CommandCenterDashboard } from "../core/dashboard.js";
5+
6+
export const DEFAULT_POLL_INTERVAL_MS = 30_000;
7+
8+
/**
9+
* GitPoller satisfies AUTO-03: continuously monitors a git repository for new
10+
* commits on a configurable interval, independent of file system events.
11+
*
12+
* Emits "commits" with the count of newly ingested commits whenever the poll
13+
* detects work. Consumers (BackgroundAutonomyService) subscribe to "commits"
14+
* to trigger the full learning pipeline (AUTO-04).
15+
*/
16+
export class GitPoller extends EventEmitter {
17+
private readonly repoPath: string;
18+
private readonly intervalMs: number;
19+
private timer: NodeJS.Timeout | null = null;
20+
private running = false;
21+
22+
constructor(repoPath: string, intervalMs = DEFAULT_POLL_INTERVAL_MS) {
23+
super();
24+
this.repoPath = repoPath;
25+
this.intervalMs = intervalMs;
26+
}
27+
28+
/** Start polling. Idempotent — calling twice is a no-op. */
29+
public start(): void {
30+
if (this.running) {
31+
return;
32+
}
33+
this.running = true;
34+
RuntimeLogger.info("[GitPoller] Starting git commit polling", {
35+
repoPath: this.repoPath,
36+
intervalMs: this.intervalMs,
37+
});
38+
CommandCenterDashboard.log(`Background Autonomy: Git polling every ${this.intervalMs / 1000}s.`);
39+
this.timer = setInterval(() => void this.poll(), this.intervalMs);
40+
}
41+
42+
/** Stop polling and release the interval. */
43+
public stop(): void {
44+
if (!this.running) {
45+
return;
46+
}
47+
this.running = false;
48+
if (this.timer !== null) {
49+
clearInterval(this.timer);
50+
this.timer = null;
51+
}
52+
RuntimeLogger.info("[GitPoller] Stopped git commit polling");
53+
}
54+
55+
/**
56+
* One-shot poll: ingest any new commits. If new commits are found, emits
57+
* "commits" with the count so subscribers can trigger downstream pipelines.
58+
* Safe to call externally for an immediate check.
59+
*/
60+
public async poll(): Promise<number> {
61+
try {
62+
const count = await CommitIngester.ingestLatest(this.repoPath, 50);
63+
if (count > 0) {
64+
RuntimeLogger.debug(`[GitPoller] Ingested ${count} new commit(s)`, { repoPath: this.repoPath });
65+
CommandCenterDashboard.log(`Background Autonomy: ${count} new commit(s) detected.`);
66+
this.emit("commits", count);
67+
}
68+
return count;
69+
} catch (err) {
70+
RuntimeLogger.error("[GitPoller] Poll failed", err);
71+
return 0;
72+
}
73+
}
74+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const mocks = vi.hoisted(() => ({
4+
watcher: { on: vi.fn(), close: vi.fn() },
5+
watch: vi.fn(),
6+
ingest: vi.fn(),
7+
correlate: vi.fn(),
8+
extract: vi.fn(),
9+
info: vi.fn(),
10+
debug: vi.fn(),
11+
error: vi.fn(),
12+
dashboard: vi.fn(),
13+
}));
14+
15+
vi.mock("chokidar", () => ({ watch: mocks.watch }));
16+
vi.mock("../src/history/commit-ingest.js", () => ({ CommitIngester: { ingestLatest: mocks.ingest } }));
17+
vi.mock("../src/history/correlation-engine.js", () => ({ CorrelationEngine: class { correlateAll = mocks.correlate; } }));
18+
vi.mock("../src/history/lesson-extractor.js", () => ({ LessonExtractor: class { extractNewLessons = mocks.extract; } }));
19+
vi.mock("../src/core/logger.js", () => ({ RuntimeLogger: { info: mocks.info, debug: mocks.debug, error: mocks.error } }));
20+
vi.mock("../src/core/dashboard.js", () => ({ CommandCenterDashboard: { log: mocks.dashboard } }));
21+
22+
import { BackgroundAutonomyService } from "../src/core/background-service.js";
23+
24+
describe("BackgroundAutonomyService", () => {
25+
beforeEach(() => {
26+
vi.clearAllMocks();
27+
mocks.watch.mockReturnValue(mocks.watcher);
28+
mocks.watcher.on.mockReturnValue(mocks.watcher);
29+
mocks.ingest.mockResolvedValue(2);
30+
mocks.correlate.mockResolvedValue(undefined);
31+
mocks.extract.mockResolvedValue(undefined);
32+
});
33+
34+
it("starts once, runs the initial serialized cycle, and stops the watcher", async () => {
35+
const service = new BackgroundAutonomyService("C:/repo", vi.fn());
36+
service.start();
37+
service.start();
38+
await service.idle();
39+
service.stop();
40+
41+
expect(mocks.watch).toHaveBeenCalledTimes(1);
42+
expect(mocks.ingest).toHaveBeenCalledWith("C:/repo", 100);
43+
expect(mocks.correlate).toHaveBeenCalledBefore(mocks.extract);
44+
expect(mocks.watcher.close).toHaveBeenCalledOnce();
45+
});
46+
47+
it("debounces file changes and logs cycle failures for queue retries", async () => {
48+
vi.useFakeTimers();
49+
mocks.correlate.mockRejectedValue(new Error("correlation failed"));
50+
const service = new BackgroundAutonomyService("C:/repo", vi.fn());
51+
service.start();
52+
const changeHandler = mocks.watcher.on.mock.calls.find(call => call[0] === "all")?.[1];
53+
changeHandler("change", "src/a.ts");
54+
changeHandler("change", "src/b.ts");
55+
await vi.runAllTimersAsync();
56+
await service.idle();
57+
service.stop();
58+
vi.useRealTimers();
59+
60+
expect(mocks.debug).toHaveBeenCalledWith(expect.stringContaining("src/b.ts"));
61+
expect(mocks.error).toHaveBeenCalledWith("Background Autonomy cycle failed", expect.any(Error));
62+
});
63+
});
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { GitPoller } from "../src/history/git-poller.js";
3+
import { CommitIngester } from "../src/history/commit-ingest.js";
4+
5+
// ---------------------------------------------------------------------------
6+
// Stub CommitIngester so tests don't touch the real DB / git log
7+
// ---------------------------------------------------------------------------
8+
9+
vi.mock("../src/history/commit-ingest.js", () => ({
10+
CommitIngester: {
11+
ingestLatest: vi.fn(),
12+
},
13+
}));
14+
15+
vi.mock("../src/core/logger.js", () => ({
16+
RuntimeLogger: { info: vi.fn(), debug: vi.fn(), error: vi.fn() },
17+
}));
18+
19+
vi.mock("../src/core/dashboard.js", () => ({
20+
CommandCenterDashboard: { log: vi.fn() },
21+
}));
22+
23+
const ingestLatest = vi.mocked(CommitIngester.ingestLatest);
24+
25+
// ---------------------------------------------------------------------------
26+
// Suite
27+
// ---------------------------------------------------------------------------
28+
29+
describe("GitPoller", () => {
30+
beforeEach(() => {
31+
vi.useFakeTimers();
32+
ingestLatest.mockResolvedValue(0);
33+
});
34+
35+
afterEach(() => {
36+
vi.useRealTimers();
37+
vi.clearAllMocks();
38+
});
39+
40+
// -------------------------------------------------------------------------
41+
// AUTO-03: poll() calls CommitIngester.ingestLatest
42+
// -------------------------------------------------------------------------
43+
44+
it("poll() calls CommitIngester.ingestLatest with the repo path (AUTO-03)", async () => {
45+
const poller = new GitPoller("/repo");
46+
await poller.poll();
47+
expect(ingestLatest).toHaveBeenCalledWith("/repo", 50);
48+
});
49+
50+
// -------------------------------------------------------------------------
51+
// AUTO-04: emits "commits" when new commits found
52+
// -------------------------------------------------------------------------
53+
54+
it("emits 'commits' event when ingestLatest returns > 0 (AUTO-04)", async () => {
55+
ingestLatest.mockResolvedValue(3);
56+
const poller = new GitPoller("/repo");
57+
const handler = vi.fn();
58+
poller.on("commits", handler);
59+
60+
await poller.poll();
61+
62+
expect(handler).toHaveBeenCalledWith(3);
63+
});
64+
65+
it("does NOT emit 'commits' when ingestLatest returns 0", async () => {
66+
ingestLatest.mockResolvedValue(0);
67+
const poller = new GitPoller("/repo");
68+
const handler = vi.fn();
69+
poller.on("commits", handler);
70+
71+
await poller.poll();
72+
73+
expect(handler).not.toHaveBeenCalled();
74+
});
75+
76+
// -------------------------------------------------------------------------
77+
// poll() return value
78+
// -------------------------------------------------------------------------
79+
80+
it("poll() returns the count from ingestLatest", async () => {
81+
ingestLatest.mockResolvedValue(7);
82+
const poller = new GitPoller("/repo");
83+
const count = await poller.poll();
84+
expect(count).toBe(7);
85+
});
86+
87+
it("poll() returns 0 and does not throw when ingestLatest rejects", async () => {
88+
ingestLatest.mockRejectedValue(new Error("git failure"));
89+
const poller = new GitPoller("/repo");
90+
const count = await poller.poll();
91+
expect(count).toBe(0);
92+
});
93+
94+
// -------------------------------------------------------------------------
95+
// AUTO-03: start() triggers polling on interval
96+
// -------------------------------------------------------------------------
97+
98+
it("start() triggers poll at the configured interval", async () => {
99+
const poller = new GitPoller("/repo", 1000);
100+
poller.start();
101+
102+
await vi.advanceTimersByTimeAsync(2100);
103+
poller.stop();
104+
105+
expect(ingestLatest.mock.calls.length).toBeGreaterThanOrEqual(2);
106+
});
107+
108+
it("start() is idempotent — calling twice does not double the interval", async () => {
109+
const poller = new GitPoller("/repo", 1000);
110+
poller.start();
111+
poller.start();
112+
113+
await vi.advanceTimersByTimeAsync(1000);
114+
poller.stop();
115+
116+
// One interval fired exactly once
117+
expect(ingestLatest.mock.calls.length).toBe(1);
118+
});
119+
120+
// -------------------------------------------------------------------------
121+
// stop() clears the interval
122+
// -------------------------------------------------------------------------
123+
124+
it("stop() prevents further interval polls", async () => {
125+
const poller = new GitPoller("/repo", 1000);
126+
poller.start();
127+
128+
await vi.advanceTimersByTimeAsync(1000);
129+
const callsBeforeStop = ingestLatest.mock.calls.length;
130+
poller.stop();
131+
132+
await vi.advanceTimersByTimeAsync(5000);
133+
expect(ingestLatest.mock.calls.length).toBe(callsBeforeStop);
134+
});
135+
});

0 commit comments

Comments
 (0)