diff --git a/src/always-on/config/parseAlwaysOnConfig.ts b/src/always-on/config/parseAlwaysOnConfig.ts index 0dc4e965..60bd18bf 100644 --- a/src/always-on/config/parseAlwaysOnConfig.ts +++ b/src/always-on/config/parseAlwaysOnConfig.ts @@ -23,6 +23,7 @@ export type AlwaysOnWorkspaceConfig = { snapshotBaseDir?: string; snapshotMaxBytes: number; gitLfs: boolean; + maxPlansPerCycle: number; }; export type AlwaysOnExecutionConfig = { @@ -78,6 +79,7 @@ export function defaultAlwaysOnConfig(): AlwaysOnConfig { workspace: { snapshotMaxBytes: DEFAULT_SNAPSHOT_MAX_BYTES, gitLfs: false, + maxPlansPerCycle: 3, }, execution: { maxTurns: 30, @@ -340,6 +342,12 @@ function parseWorkspace( diagnostics, ); target.gitLfs = booleanField(raw, "gitLfs", target.gitLfs); + target.maxPlansPerCycle = positiveInteger( + raw.maxPlansPerCycle, + target.maxPlansPerCycle, + "alwaysOn.workspace.maxPlansPerCycle", + diagnostics, + ); } function parseExecution( diff --git a/src/always-on/protocol/types.ts b/src/always-on/protocol/types.ts index 33ca6876..2e5068a7 100644 --- a/src/always-on/protocol/types.ts +++ b/src/always-on/protocol/types.ts @@ -142,7 +142,8 @@ export type GateBlockReason = | "recent_user_msg" | "cooldown" | "daily_budget" - | "lock_busy"; + | "lock_busy" + | "cycle_full"; export type GateResult = | { ok: true; lease?: AlwaysOnChannelLease } diff --git a/src/always-on/runtime/AlwaysOnRuntime.ts b/src/always-on/runtime/AlwaysOnRuntime.ts index 2b57ccd3..2e4fe355 100644 --- a/src/always-on/runtime/AlwaysOnRuntime.ts +++ b/src/always-on/runtime/AlwaysOnRuntime.ts @@ -216,6 +216,7 @@ export class AlwaysOnRuntime { projectKey: this.projectKey, paths: this.paths, stateStore: this.stateStore, + cycleStore: this.cycleStore, leases: this.leases, fire: this.fire, uuid: this.uuid, diff --git a/src/always-on/runtime/DiscoveryScheduler.ts b/src/always-on/runtime/DiscoveryScheduler.ts index 560cfb48..4e098160 100644 --- a/src/always-on/runtime/DiscoveryScheduler.ts +++ b/src/always-on/runtime/DiscoveryScheduler.ts @@ -4,6 +4,7 @@ import { AlwaysOnError } from "../protocol/errors.js"; import type { GateBlockReason } from "../protocol/types.js"; import type { AlwaysOnPaths } from "../storage/AlwaysOnPaths.js"; import { DiscoveryStateStore } from "../storage/DiscoveryStateStore.js"; +import { WorkCycleStore } from "../storage/WorkCycleStore.js"; import type { ChannelLeaseRegistry } from "./ChannelLeaseRegistry.js"; import { acquireDiscoveryLock, @@ -23,6 +24,7 @@ export type DiscoverySchedulerDependencies = { projectKey: string; paths: AlwaysOnPaths; stateStore: DiscoveryStateStore; + cycleStore: WorkCycleStore; leases: ChannelLeaseRegistry; fire: DiscoveryFire; uuid: () => string; @@ -113,6 +115,18 @@ export class DiscoveryScheduler { return { outcome: "blocked", reason: evaluation.reason }; } + if (state.activeWorkCycleId) { + const activeCycle = await this.deps.cycleStore.getRecord(state.activeWorkCycleId); + if ( + activeCycle && + activeCycle.status === "active" && + activeCycle.planIds.length >= this.deps.config.workspace.maxPlansPerCycle + ) { + this.deps.logger.info("always-on gate blocked", { reason: "cycle_full" }); + return { outcome: "blocked", reason: "cycle_full" as GateBlockReason }; + } + } + const runId = this.deps.uuid(); const startedAt = this.deps.now();