ソースを参照

refactor: overhaul scheduler for AI-paced operation

Config changes:
- Remove dead auditCron (audit runs inside write pipeline)
- writeCron: daily → every 15 min
- radarCron: daily → every 6 hours
- Add chaptersPerCycle, retryDelayMs, cooldownAfterChapterMs,
  maxChaptersPerDay

Scheduler changes:
- Parallel book processing (Promise.all)
- Multi-chapter cycles per book
- Immediate retry with 30s delay (was 24h wait)
- Daily safety cap (default 50 chapters)
- Better cronToMs parsing for minute-level crons
majx_mac 8 ヶ月 前
親
コミット
3a56969159

+ 4 - 1
packages/cli/src/commands/daemon.ts

@@ -41,8 +41,11 @@ export const upCommand = new Command("up")
         modelOverrides: config.modelOverrides,
         modelOverrides: config.modelOverrides,
         radarCron: config.daemon.schedule.radarCron,
         radarCron: config.daemon.schedule.radarCron,
         writeCron: config.daemon.schedule.writeCron,
         writeCron: config.daemon.schedule.writeCron,
-        auditCron: config.daemon.schedule.auditCron,
         maxConcurrentBooks: config.daemon.maxConcurrentBooks,
         maxConcurrentBooks: config.daemon.maxConcurrentBooks,
+        chaptersPerCycle: config.daemon.chaptersPerCycle,
+        retryDelayMs: config.daemon.retryDelayMs,
+        cooldownAfterChapterMs: config.daemon.cooldownAfterChapterMs,
+        maxChaptersPerDay: config.daemon.maxChaptersPerDay,
         onChapterComplete: (bookId, chapter, status) => {
         onChapterComplete: (bookId, chapter, status) => {
           const icon = status === "approved" ? "+" : "!";
           const icon = status === "approved" ? "+" : "!";
           log(`  [${icon}] ${bookId} Ch.${chapter} — ${status}`);
           log(`  [${icon}] ${bookId} Ch.${chapter} — ${status}`);

+ 2 - 3
packages/cli/src/commands/init.ts

@@ -35,9 +35,8 @@ export const initCommand = new Command("init")
         notify: [],
         notify: [],
         daemon: {
         daemon: {
           schedule: {
           schedule: {
-            radarCron: "0 9 * * *",
-            writeCron: "0 14 * * *",
-            auditCron: "0 17 * * *",
+            radarCron: "0 */6 * * *",
+            writeCron: "*/15 * * * *",
           },
           },
           maxConcurrentBooks: 3,
           maxConcurrentBooks: 3,
         },
         },

+ 4 - 3
packages/core/src/__tests__/models.test.ts

@@ -292,9 +292,10 @@ describe("ProjectConfigSchema", () => {
   it("applies default daemon config", () => {
   it("applies default daemon config", () => {
     const result = ProjectConfigSchema.parse(validProject);
     const result = ProjectConfigSchema.parse(validProject);
     expect(result.daemon.maxConcurrentBooks).toBe(3);
     expect(result.daemon.maxConcurrentBooks).toBe(3);
-    expect(result.daemon.schedule.radarCron).toBe("0 9 * * *");
-    expect(result.daemon.schedule.writeCron).toBe("0 14 * * *");
-    expect(result.daemon.schedule.auditCron).toBe("0 17 * * *");
+    expect(result.daemon.schedule.radarCron).toBe("0 */6 * * *");
+    expect(result.daemon.schedule.writeCron).toBe("*/15 * * * *");
+    expect(result.daemon.chaptersPerCycle).toBe(1);
+    expect(result.daemon.maxChaptersPerDay).toBe(50);
   });
   });
 
 
   it("applies default empty notify array", () => {
   it("applies default empty notify array", () => {

+ 12 - 6
packages/core/src/models/project.ts

@@ -66,11 +66,14 @@ export const ProjectConfigSchema = z.object({
   modelOverrides: z.record(z.string(), z.string()).optional(),
   modelOverrides: z.record(z.string(), z.string()).optional(),
   daemon: z.object({
   daemon: z.object({
     schedule: z.object({
     schedule: z.object({
-      radarCron: z.string().default("0 9 * * *"),
-      writeCron: z.string().default("0 14 * * *"),
-      auditCron: z.string().default("0 17 * * *"),
+      radarCron: z.string().default("0 */6 * * *"),
+      writeCron: z.string().default("*/15 * * * *"),
     }),
     }),
     maxConcurrentBooks: z.number().int().min(1).default(3),
     maxConcurrentBooks: z.number().int().min(1).default(3),
+    chaptersPerCycle: z.number().int().min(1).max(20).default(1),
+    retryDelayMs: z.number().int().min(0).default(30_000),
+    cooldownAfterChapterMs: z.number().int().min(0).default(10_000),
+    maxChaptersPerDay: z.number().int().min(1).default(50),
     qualityGates: QualityGatesSchema.default({
     qualityGates: QualityGatesSchema.default({
       maxAuditRetries: 2,
       maxAuditRetries: 2,
       pauseAfterConsecutiveFailures: 3,
       pauseAfterConsecutiveFailures: 3,
@@ -78,11 +81,14 @@ export const ProjectConfigSchema = z.object({
     }),
     }),
   }).default({
   }).default({
     schedule: {
     schedule: {
-      radarCron: "0 9 * * *",
-      writeCron: "0 14 * * *",
-      auditCron: "0 17 * * *",
+      radarCron: "0 */6 * * *",
+      writeCron: "*/15 * * * *",
     },
     },
     maxConcurrentBooks: 3,
     maxConcurrentBooks: 3,
+    chaptersPerCycle: 1,
+    retryDelayMs: 30_000,
+    cooldownAfterChapterMs: 10_000,
+    maxChaptersPerDay: 50,
     qualityGates: {
     qualityGates: {
       maxAuditRetries: 2,
       maxAuditRetries: 2,
       pauseAfterConsecutiveFailures: 3,
       pauseAfterConsecutiveFailures: 3,

+ 153 - 71
packages/core/src/pipeline/scheduler.ts

@@ -9,8 +9,11 @@ import { detectChapter, detectAndRewrite } from "./detection-runner.js";
 export interface SchedulerConfig extends PipelineConfig {
 export interface SchedulerConfig extends PipelineConfig {
   readonly radarCron: string;
   readonly radarCron: string;
   readonly writeCron: string;
   readonly writeCron: string;
-  readonly auditCron: string;
   readonly maxConcurrentBooks: number;
   readonly maxConcurrentBooks: number;
+  readonly chaptersPerCycle: number;
+  readonly retryDelayMs: number;
+  readonly cooldownAfterChapterMs: number;
+  readonly maxChaptersPerDay: number;
   readonly qualityGates?: QualityGates;
   readonly qualityGates?: QualityGates;
   readonly detection?: DetectionConfig;
   readonly detection?: DetectionConfig;
   readonly onChapterComplete?: (bookId: string, chapter: number, status: string) => void;
   readonly onChapterComplete?: (bookId: string, chapter: number, status: string) => void;
@@ -36,6 +39,8 @@ export class Scheduler {
   private pausedBooks = new Set<string>();
   private pausedBooks = new Set<string>();
   // Failure clustering: bookId → (dimension → count)
   // Failure clustering: bookId → (dimension → count)
   private failureDimensions = new Map<string, Map<string, number>>();
   private failureDimensions = new Map<string, Map<string, number>>();
+  // Daily chapter counter: "YYYY-MM-DD" → count
+  private dailyChapterCount = new Map<string, number>();
 
 
   constructor(config: SchedulerConfig) {
   constructor(config: SchedulerConfig) {
     this.config = config;
     this.config = config;
@@ -50,7 +55,7 @@ export class Scheduler {
     // Run write cycle immediately on start, then schedule
     // Run write cycle immediately on start, then schedule
     await this.runWriteCycle();
     await this.runWriteCycle();
 
 
-    // Schedule recurring write cycle (default: every 2 hours)
+    // Schedule recurring write cycle
     const writeCycleMs = this.cronToMs(this.config.writeCron);
     const writeCycleMs = this.cronToMs(this.config.writeCron);
     const writeTask: ScheduledTask = {
     const writeTask: ScheduledTask = {
       name: "write-cycle",
       name: "write-cycle",
@@ -63,7 +68,7 @@ export class Scheduler {
     }, writeCycleMs);
     }, writeCycleMs);
     this.tasks.push(writeTask);
     this.tasks.push(writeTask);
 
 
-    // Schedule radar scan (default: daily)
+    // Schedule radar scan
     const radarMs = this.cronToMs(this.config.radarCron);
     const radarMs = this.cronToMs(this.config.radarCron);
     const radarTask: ScheduledTask = {
     const radarTask: ScheduledTask = {
       name: "radar-scan",
       name: "radar-scan",
@@ -109,12 +114,38 @@ export class Scheduler {
     };
     };
   }
   }
 
 
+  /** Check if daily cap is reached across all books. */
+  private isDailyCapReached(): boolean {
+    const today = new Date().toISOString().slice(0, 10);
+    const count = this.dailyChapterCount.get(today) ?? 0;
+    return count >= this.config.maxChaptersPerDay;
+  }
+
+  /** Increment daily chapter counter. */
+  private recordChapterWritten(): void {
+    const today = new Date().toISOString().slice(0, 10);
+    const count = this.dailyChapterCount.get(today) ?? 0;
+    this.dailyChapterCount.set(today, count + 1);
+
+    // Clean up old dates (keep only today)
+    for (const key of this.dailyChapterCount.keys()) {
+      if (key !== today) this.dailyChapterCount.delete(key);
+    }
+  }
+
   private async runWriteCycle(): Promise<void> {
   private async runWriteCycle(): Promise<void> {
+    if (this.isDailyCapReached()) {
+      process.stderr.write(
+        `[scheduler] Daily cap reached (${this.config.maxChaptersPerDay}), skipping cycle\n`,
+      );
+      return;
+    }
+
     const bookIds = await this.state.listBooks();
     const bookIds = await this.state.listBooks();
 
 
-    const activeBooks: Array<{ id: string; config: BookConfig }> = [];
+    const activeBooks: Array<{ readonly id: string; readonly config: BookConfig }> = [];
     for (const id of bookIds) {
     for (const id of bookIds) {
-      if (this.pausedBooks.has(id)) continue; // Skip paused books
+      if (this.pausedBooks.has(id)) continue;
       const config = await this.state.loadBookConfig(id);
       const config = await this.state.loadBookConfig(id);
       if (config.status === "active" || config.status === "outlining") {
       if (config.status === "active" || config.status === "outlining") {
         activeBooks.push({ id, config });
         activeBooks.push({ id, config });
@@ -123,59 +154,104 @@ export class Scheduler {
 
 
     const booksToWrite = activeBooks.slice(0, this.config.maxConcurrentBooks);
     const booksToWrite = activeBooks.slice(0, this.config.maxConcurrentBooks);
 
 
-    for (const book of booksToWrite) {
-      try {
-        // Compute temperature override: base 0.7 + failures * step
-        const failures = this.consecutiveFailures.get(book.id) ?? 0;
-        const tempOverride = failures > 0
-          ? Math.min(1.2, 0.7 + failures * this.gates.retryTemperatureStep)
-          : undefined;
-
-        const result = await this.pipeline.writeNextChapter(book.id, undefined, tempOverride);
-
-        if (result.status === "approved") {
-          // Reset failure counter on success
-          this.consecutiveFailures.delete(book.id);
-
-          // Feature #6: Auto-detection loop after successful audit
-          if (this.config.detection?.enabled) {
-            try {
-              const bookDir = this.state.bookDir(book.id);
-              const chapterContent = await this.readChapterContent(bookDir, result.chapterNumber);
-              const detResult = await detectChapter(
-                this.config.detection,
-                chapterContent,
-                result.chapterNumber,
-              );
-              if (!detResult.passed && this.config.detection.autoRewrite) {
-                await detectAndRewrite(
-                  this.config.detection,
-                  { client: this.config.client, model: this.config.model, projectRoot: this.config.projectRoot },
-                  bookDir,
-                  chapterContent,
-                  result.chapterNumber,
-                  book.config.genre,
-                );
-              }
-            } catch (e) {
-              this.config.onError?.(book.id, e as Error);
-            }
-          }
+    // Parallel book processing
+    await Promise.all(
+      booksToWrite.map((book) => this.processBook(book.id, book.config)),
+    );
+  }
+
+  /** Process a single book: write chaptersPerCycle chapters with retry + cooldown. */
+  private async processBook(bookId: string, bookConfig: BookConfig): Promise<void> {
+    for (let i = 0; i < this.config.chaptersPerCycle; i++) {
+      if (!this.running) return;
+      if (this.isDailyCapReached()) return;
+      if (this.pausedBooks.has(bookId)) return;
+
+      // Cooldown between chapters (skip for the first one)
+      if (i > 0 && this.config.cooldownAfterChapterMs > 0) {
+        await this.sleep(this.config.cooldownAfterChapterMs);
+      }
+
+      const success = await this.writeOneChapter(bookId, bookConfig);
+      if (!success) {
+        // Immediate retry with delay (if within retry limit)
+        const failures = this.consecutiveFailures.get(bookId) ?? 0;
+        if (failures <= this.gates.maxAuditRetries && this.config.retryDelayMs > 0) {
+          process.stderr.write(
+            `[scheduler] ${bookId} retrying in ${this.config.retryDelayMs}ms\n`,
+          );
+          await this.sleep(this.config.retryDelayMs);
+          const retrySuccess = await this.writeOneChapter(bookId, bookConfig);
+          if (!retrySuccess) break; // Stop this book's cycle on second failure
         } else {
         } else {
-          // Audit failed — apply quality gates
-          const issueCategories = result.auditResult.issues.map((i) => i.category);
-          await this.handleAuditFailure(book.id, result.chapterNumber, issueCategories);
+          break; // Stop this book's cycle
+        }
+      }
+    }
+  }
+
+  /** Write one chapter for a book. Returns true if approved. */
+  private async writeOneChapter(bookId: string, bookConfig: BookConfig): Promise<boolean> {
+    try {
+      // Compute temperature override: base 0.7 + failures * step
+      const failures = this.consecutiveFailures.get(bookId) ?? 0;
+      const tempOverride = failures > 0
+        ? Math.min(1.2, 0.7 + failures * this.gates.retryTemperatureStep)
+        : undefined;
+
+      const result = await this.pipeline.writeNextChapter(bookId, undefined, tempOverride);
+
+      if (result.status === "approved") {
+        this.consecutiveFailures.delete(bookId);
+        this.recordChapterWritten();
+
+        // Auto-detection loop after successful audit
+        if (this.config.detection?.enabled) {
+          await this.runDetection(bookId, bookConfig, result.chapterNumber);
         }
         }
 
 
-        this.config.onChapterComplete?.(
-          book.id,
-          result.chapterNumber,
-          result.status,
+        this.config.onChapterComplete?.(bookId, result.chapterNumber, result.status);
+        return true;
+      }
+
+      // Audit failed — apply quality gates
+      const issueCategories = result.auditResult.issues.map((i) => i.category);
+      await this.handleAuditFailure(bookId, result.chapterNumber, issueCategories);
+      this.config.onChapterComplete?.(bookId, result.chapterNumber, result.status);
+      return false;
+    } catch (e) {
+      this.config.onError?.(bookId, e as Error);
+      await this.handleAuditFailure(bookId, 0);
+      return false;
+    }
+  }
+
+  private async runDetection(
+    bookId: string,
+    bookConfig: BookConfig,
+    chapterNumber: number,
+  ): Promise<void> {
+    if (!this.config.detection) return;
+    try {
+      const bookDir = this.state.bookDir(bookId);
+      const chapterContent = await this.readChapterContent(bookDir, chapterNumber);
+      const detResult = await detectChapter(
+        this.config.detection,
+        chapterContent,
+        chapterNumber,
+      );
+      if (!detResult.passed && this.config.detection.autoRewrite) {
+        await detectAndRewrite(
+          this.config.detection,
+          { client: this.config.client, model: this.config.model, projectRoot: this.config.projectRoot },
+          bookDir,
+          chapterContent,
+          chapterNumber,
+          bookConfig.genre,
         );
         );
-      } catch (e) {
-        this.config.onError?.(book.id, e as Error);
-        await this.handleAuditFailure(book.id, 0);
       }
       }
+    } catch (e) {
+      this.config.onError?.(bookId, e as Error);
     }
     }
   }
   }
 
 
@@ -189,7 +265,8 @@ export class Scheduler {
 
 
     // Track failure dimensions for clustering
     // Track failure dimensions for clustering
     if (issueCategories.length > 0) {
     if (issueCategories.length > 0) {
-      const dimMap = this.failureDimensions.get(bookId) ?? new Map<string, number>();
+      const existing = this.failureDimensions.get(bookId);
+      const dimMap = existing ? new Map(existing) : new Map<string, number>();
       for (const cat of issueCategories) {
       for (const cat of issueCategories) {
         dimMap.set(cat, (dimMap.get(cat) ?? 0) + 1);
         dimMap.set(cat, (dimMap.get(cat) ?? 0) + 1);
       }
       }
@@ -205,13 +282,10 @@ export class Scheduler {
 
 
     const gates = this.gates;
     const gates = this.gates;
 
 
-    // Check if we should retry with higher temperature
     if (failures <= gates.maxAuditRetries) {
     if (failures <= gates.maxAuditRetries) {
       process.stderr.write(
       process.stderr.write(
-        `[scheduler] ${bookId} audit failed (${failures}/${gates.maxAuditRetries}), retrying with higher temperature\n`,
+        `[scheduler] ${bookId} audit failed (${failures}/${gates.maxAuditRetries}), will retry\n`,
       );
       );
-      // The retry will happen in the next write cycle with adjusted temperature
-      // (We don't retry immediately to avoid tight loops)
       return;
       return;
     }
     }
 
 
@@ -222,7 +296,6 @@ export class Scheduler {
       process.stderr.write(`[scheduler] ${bookId} PAUSED: ${reason}\n`);
       process.stderr.write(`[scheduler] ${bookId} PAUSED: ${reason}\n`);
       this.config.onPause?.(bookId, reason);
       this.config.onPause?.(bookId, reason);
 
 
-      // Emit webhook event
       if (this.config.notifyChannels && this.config.notifyChannels.length > 0) {
       if (this.config.notifyChannels && this.config.notifyChannels.length > 0) {
         await dispatchWebhookEvent(this.config.notifyChannels, {
         await dispatchWebhookEvent(this.config.notifyChannels, {
           event: "pipeline-error",
           event: "pipeline-error",
@@ -281,20 +354,29 @@ export class Scheduler {
   }
   }
 
 
   private cronToMs(cron: string): number {
   private cronToMs(cron: string): number {
-    // Simple cron-to-interval mapping for common patterns
-    // "0 9 * * *" = daily = 24h
-    // "0 14 * * *" = daily = 24h
-    // "0 */2 * * *" = every 2h
     const parts = cron.split(" ");
     const parts = cron.split(" ");
-    if (parts.length >= 5) {
-      const hour = parts[1]!;
-      if (hour.startsWith("*/")) {
-        const interval = parseInt(hour.slice(2), 10);
-        return interval * 60 * 60 * 1000;
-      }
-      // Default: treat as daily
-      return 24 * 60 * 60 * 1000;
+    if (parts.length < 5) return 24 * 60 * 60 * 1000;
+
+    const minute = parts[0]!;
+    const hour = parts[1]!;
+
+    // "*/N * * * *" → every N minutes
+    if (minute.startsWith("*/")) {
+      const interval = parseInt(minute.slice(2), 10);
+      return interval * 60 * 1000;
     }
     }
+
+    // "0 */N * * *" → every N hours
+    if (hour.startsWith("*/")) {
+      const interval = parseInt(hour.slice(2), 10);
+      return interval * 60 * 60 * 1000;
+    }
+
+    // Fixed time → treat as daily
     return 24 * 60 * 60 * 1000;
     return 24 * 60 * 60 * 1000;
   }
   }
+
+  private sleep(ms: number): Promise<void> {
+    return new Promise((resolve) => setTimeout(resolve, ms));
+  }
 }
 }

+ 17 - 0
test-project/inkos.json

@@ -0,0 +1,17 @@
+{
+  "name": "test-project",
+  "version": "0.1.0",
+  "llm": {
+    "provider": "openai",
+    "baseUrl": "https://api.openai.com/v1",
+    "model": "gpt-4o"
+  },
+  "notify": [],
+  "daemon": {
+    "schedule": {
+      "radarCron": "0 */6 * * *",
+      "writeCron": "*/15 * * * *"
+    },
+    "maxConcurrentBooks": 3
+  }
+}