From 05cad8df5655489c3d86e9e0449d9f3cb31674c1 Mon Sep 17 00:00:00 2001 From: Gucc1 <1006490933@qq.com> Date: Tue, 2 Jun 2026 19:13:28 +0800 Subject: [PATCH] feat(always-on): add maxPlansPerCycle config to cap plans per work cycle Block new discovery fires when the active cycle reaches the configured plan limit (default 3). The system resumes automatically after the user applies or archives the cycle. Co-authored-by: Cursor --- src/always-on/config/parseAlwaysOnConfig.ts | 8 ++++++++ src/always-on/protocol/types.ts | 3 ++- src/always-on/runtime/AlwaysOnRuntime.ts | 1 + src/always-on/runtime/DiscoveryScheduler.ts | 14 ++++++++++++++ 4 files changed, 25 insertions(+), 1 deletion(-) 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();