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
6 changes: 6 additions & 0 deletions packages/api/src/api/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,10 @@ export type UpProjectRequest = {
}

export type ApplyProjectRequest = {
readonly cpuLimit?: string | undefined
readonly ramLimit?: string | undefined
readonly playwrightCpuLimit?: string | undefined
readonly playwrightRamLimit?: string | undefined
readonly gpu?: "none" | "all" | undefined
}

Expand Down Expand Up @@ -385,6 +389,8 @@ export type CreateProjectRequest = {
readonly codexHome?: string | undefined
readonly cpuLimit?: string | undefined
readonly ramLimit?: string | undefined
readonly playwrightCpuLimit?: string | undefined
readonly playwrightRamLimit?: string | undefined
readonly gpu?: "none" | "all" | undefined
readonly dockerNetworkMode?: string | undefined
readonly dockerSharedNetworkName?: string | undefined
Expand Down
6 changes: 6 additions & 0 deletions packages/api/src/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export const CreateProjectRequestSchema = Schema.Struct({
codexHome: OptionalString,
cpuLimit: OptionalString,
ramLimit: OptionalString,
playwrightCpuLimit: OptionalString,
playwrightRamLimit: OptionalString,
gpu: Schema.optional(Schema.Literal("none", "all")),
dockerNetworkMode: OptionalString,
dockerSharedNetworkName: OptionalString,
Expand Down Expand Up @@ -152,6 +154,10 @@ export const ApplyAllRequestSchema = Schema.Struct({
})

export const ApplyProjectRequestSchema = Schema.Struct({
cpuLimit: OptionalString,
ramLimit: OptionalString,
playwrightCpuLimit: OptionalString,
playwrightRamLimit: OptionalString,
gpu: Schema.optional(Schema.Literal("none", "all"))
})

Expand Down
47 changes: 34 additions & 13 deletions packages/api/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ import {
readProjectLogs,
readProjectPs,
recreateProject,
resumeProject,
suspendProject,
upProject
} from "./services/projects.js"
import { readProjectAuthSnapshot, runProjectAuthFlow } from "./services/project-auth.js"
Expand Down Expand Up @@ -1385,7 +1387,7 @@ export const makeRouter = () => {
)
)

const withProjectLifecycle = withProjectDatabases.pipe(
const withProjectLifecycleBase = withProjectDatabases.pipe(
HttpRouter.del(
"/projects/:projectId",
projectParams.pipe(
Expand All @@ -1407,25 +1409,44 @@ export const makeRouter = () => {
Effect.catchAll(errorResponse)
)
),
HttpRouter.post(
"/projects/:projectId/apply",
Effect.gen(function*(_) {
const { projectId } = yield* _(projectParams)
const request = yield* _(readApplyProjectRequest())
const project = yield* _(applyProjectById(projectId, request))
return yield* _(jsonResponse({ ok: true, project }, 200))
}).pipe(
Effect.catchAll(errorResponse)
)
),
HttpRouter.post(
"/projects/:projectId/resume",
projectParams.pipe(
Effect.flatMap(({ projectId }) => resumeProject(projectId)),
Effect.flatMap((project) => jsonResponse({ ok: true, project }, 200)),
Effect.catchAll(errorResponse)
)
),
HttpRouter.post(
"/projects/:projectId/apply",
Effect.gen(function*(_) {
const { projectId } = yield* _(projectParams)
const request = yield* _(readApplyProjectRequest())
const project = yield* _(applyProjectById(projectId, request))
return yield* _(jsonResponse({ ok: true, project }, 200))
}).pipe(
Effect.catchAll(errorResponse)
)
),
HttpRouter.post(
"/projects/:projectId/suspend",
projectParams.pipe(
Effect.flatMap(({ projectId }) => suspendProject(projectId)),
Effect.flatMap((project) => jsonResponse({ ok: true, project }, 200)),
Effect.catchAll(errorResponse)
)
),
HttpRouter.post(
"/projects/:projectId/down",
projectParams.pipe(
Effect.flatMap(({ projectId }) => downProject(projectId)),
Effect.flatMap(() => jsonResponse({ ok: true }, 200)),
Effect.catchAll(errorResponse)
)
),
)
)

const withProjectLifecycle = withProjectLifecycleBase.pipe(
HttpRouter.post(
"/projects/by-key/:projectKey/terminal-sessions",
projectKeyParams.pipe(
Expand Down
12 changes: 11 additions & 1 deletion packages/api/src/program.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { HttpMiddleware, HttpServer, HttpServerRequest } from "@effect/platform"
import { NodeHttpServer } from "@effect/platform-node"
import { NodeContext, NodeHttpServer } from "@effect/platform-node"
import { Console, Effect, Layer, Option } from "effect"
import { createServer } from "node:http"

import { makeRouter } from "./http.js"
import { initializeAgentState } from "./services/agents.js"
import { attachAuthTerminalWebSocketServer } from "./services/auth-terminal-sessions.js"
import { initializeFederationState, startOutboxPolling } from "./services/federation.js"
import { resolveProjectAutoSuspendConfig, startProjectAutoSuspendLoop } from "./services/project-auto-suspend.js"
import { attachProjectBrowserWebSocketServer } from "./services/project-browser.js"
import { attachProjectDatabaseWebSocketServer } from "./services/project-databases.js"
import { attachTerminalWebSocketServer } from "./services/terminal-sessions.js"
Expand Down Expand Up @@ -61,6 +62,7 @@ export const program = (() => {
const serverLayer = NodeHttpServer.layer(() => server, { port })

const pollingInterval = parseInt(process.env["DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS"] ?? "5000", 10)
const autoSuspendConfig = resolveProjectAutoSuspendConfig()

return Effect.scoped(
Console.log(`docker-git api boot port=${port}`).pipe(
Expand All @@ -72,6 +74,14 @@ export const program = (() => {
Effect.zipRight(
Effect.fork(startOutboxPolling(pollingInterval))
),
Effect.zipRight(
Console.log(
`docker-git auto-suspend enabled=${autoSuspendConfig.enabled} idleMs=${autoSuspendConfig.idleTimeoutMs} scanMs=${autoSuspendConfig.scanIntervalMs}`
)
),
Effect.zipRight(
Effect.fork(startProjectAutoSuspendLoop(autoSuspendConfig).pipe(Effect.provide(NodeContext.layer)))
),
Effect.zipRight(Layer.launch(Layer.provide(app, serverLayer)))
)
)
Expand Down
20 changes: 19 additions & 1 deletion packages/api/src/services/agents.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { recordProjectRuntimeActivity } from "@effect-template/lib"
import { runCommandWithExitCodes } from "@effect-template/lib/shell/command-runner"
import { CommandFailedError } from "@effect-template/lib/shell/errors"
import { defaultProjectsRoot } from "@effect-template/lib/usecases/path-helpers"
import { NodeContext } from "@effect/platform-node"
import { Effect } from "effect"
import { spawn, type ChildProcess } from "node:child_process"
import { randomUUID } from "node:crypto"
import { promises as fs } from "node:fs"
import { join } from "node:path"
import { spawn, type ChildProcess } from "node:child_process"

import type {
AgentLogLine,
Expand Down Expand Up @@ -194,6 +196,21 @@ const persistSnapshotBestEffort = (): void => {
})
}

const recordAgentActivityBestEffort = (projectId: string): void => {
Effect.runFork(
recordProjectRuntimeActivity(projectId, "agent").pipe(
Effect.provide(NodeContext.layer),
Effect.catchAll((error) =>
Effect.logWarning(
`[agents] Failed to record agent activity for project ${projectId}: ${
error instanceof Error ? error.message : String(error)
}`
)
)
)
)
}

const updateSession = (
record: AgentRecord,
patch: Partial<AgentSession>
Expand Down Expand Up @@ -428,6 +445,7 @@ export const startAgent = (
label,
command
})
recordAgentActivityBestEffort(project.id)

child.stdout.on("data", (chunk: Buffer) => {
consumeChunk(record, "stdout", chunk)
Expand Down
191 changes: 191 additions & 0 deletions packages/api/src/services/project-auto-suspend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { listProjectItems, readProjectRuntimeState, recordProjectRuntimeActivity } from "@effect-template/lib"
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
import { Duration, Effect, Match, Schedule } from "effect"

import { activeAgents } from "./container-tasks-core.js"
import { readContainerTaskSnapshot } from "./container-tasks.js"
import { hasLiveProjectBrowserSession } from "./project-browser.js"
import { decideProjectIdleAction } from "./project-idle-policy.js"
import { applyProjectResourceProfile, suspendProjectRuntime } from "./project-lifecycle-resources.js"
import { loadProjectRuntimeByProject, runtimeForProject } from "./project-runtime.js"
import { hasLiveProjectSkillerSession } from "./skiller.js"
import { hasLiveProjectTerminalSession } from "./terminal-sessions.js"

export type ProjectAutoSuspendConfig = {
readonly enabled: boolean
readonly idleTimeoutMs: number
readonly scanIntervalMs: number
readonly throttleInteractiveIdle: boolean
readonly interactiveIdleCpuFactor: number
}

const minuteMs = 60_000
const secondMs = 1_000
const defaultIdleTimeoutMinutes = 30
const defaultScanIntervalSeconds = 60
const defaultInteractiveIdleCpuFactor = 0.5

const parsePositiveIntegerEnv = (
key: string,
defaultValue: number
): number => {
const parsed = Number.parseInt(process.env[key] ?? "", 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue
}

const parsePositiveFractionEnv = (
key: string,
defaultValue: number
): number => {
const parsed = Number(process.env[key] ?? "")
return Number.isFinite(parsed) && parsed > 0 && parsed <= 1 ? parsed : defaultValue
}

const parseEnabledEnv = (
key: string,
defaultValue: boolean
): boolean => {
const raw = process.env[key]?.trim().toLowerCase()
if (raw === undefined || raw.length === 0) {
return defaultValue
}
return raw !== "0" && raw !== "false" && raw !== "off" && raw !== "no"
}

export const resolveProjectAutoSuspendConfig = (): ProjectAutoSuspendConfig => ({
enabled: parseEnabledEnv("DOCKER_GIT_AUTO_SUSPEND", true),
idleTimeoutMs: parsePositiveIntegerEnv("DOCKER_GIT_AGENT_IDLE_TIMEOUT_MINUTES", defaultIdleTimeoutMinutes) * minuteMs,
scanIntervalMs: parsePositiveIntegerEnv("DOCKER_GIT_IDLE_SCAN_INTERVAL_SECONDS", defaultScanIntervalSeconds) * secondMs,
throttleInteractiveIdle: parseEnabledEnv("DOCKER_GIT_INTERACTIVE_IDLE_THROTTLE", true),
interactiveIdleCpuFactor: parsePositiveFractionEnv(
"DOCKER_GIT_INTERACTIVE_IDLE_CPU_FACTOR",
defaultInteractiveIdleCpuFactor
)
})

const snapshotHasAgentTask = (
project: ProjectItem
) =>
readContainerTaskSnapshot(project.projectDir, false).pipe(
Effect.map((snapshot) =>
activeAgents(snapshot.agents).length > 0 || snapshot.tasks.some((task) => task.kind === "agent")
),
Effect.catchAll(() => Effect.succeed(false))
)
Comment on lines +66 to +74
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Не считайте ошибку инспекции признаком idle-состояния.

Если readContainerTaskSnapshot() падает, snapshotHasAgentTask() возвращает false, и дальше scan может выбрать SuspendIdle для проекта с реально работающим агентом. Здесь безопаснее пропускать решение для этого проекта и логировать warning, а не понижать состояние до “агента нет”.

💡 Возможная правка
 const snapshotHasAgentTask = (
   project: ProjectItem
 ) =>
   readContainerTaskSnapshot(project.projectDir, false).pipe(
     Effect.map((snapshot) =>
       activeAgents(snapshot.agents).length > 0 || snapshot.tasks.some((task) => task.kind === "agent")
-    ),
-    Effect.catchAll(() => Effect.succeed(false))
+    )
   )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/api/src/services/project-auto-suspend.ts` around lines 66 - 74,
snapshotHasAgentTask currently swallows any error from readContainerTaskSnapshot
and returns false, which can cause a live agent to be treated as absent; change
snapshotHasAgentTask to not treat inspection errors as "no agent": when
readContainerTaskSnapshot fails, log a warning (including project.projectDir and
the error) and propagate a failure or an explicit "unknown" result so the
upstream scanner (the code that decides SuspendIdle) can skip this project
instead of lowering its state; reference snapshotHasAgentTask and
readContainerTaskSnapshot and ensure the warning is emitted before returning the
non-boolean/failed Effect so callers can detect and skip this project.


const projectHasActiveAgent = (
project: ProjectItem
) =>
snapshotHasAgentTask(project)

const projectHasLiveInteractiveSession = (
project: ProjectItem,
sshSessions: number
): boolean =>
sshSessions > 0 ||
hasLiveProjectTerminalSession(project.projectDir) ||
hasLiveProjectBrowserSession(project.projectDir) ||
hasLiveProjectSkillerSession(project.projectDir)

const runProjectIdleDecision = (
project: ProjectItem,
config: ProjectAutoSuspendConfig,
running: boolean,
sshSessions: number,
startedAtEpochMs: number | null
) =>
Effect.gen(function*(_) {
const runtimeState = yield* _(readProjectRuntimeState(project.projectDir))
const activeAgent = yield* _(projectHasActiveAgent(project))
const liveInteractive = projectHasLiveInteractiveSession(project, sshSessions)
const decision = decideProjectIdleAction(
{
hasActiveAgent: activeAgent,
hasLiveInteractiveSession: liveInteractive,
lastAgentSeenAtEpochMs: runtimeState.lastAgentSeenAtEpochMs,
lastInteractiveSeenAtEpochMs: runtimeState.lastInteractiveSeenAtEpochMs,
resourceProfile: runtimeState.resourceProfile,
running,
startedAtEpochMs
},
{
agentIdleTimeoutMs: config.idleTimeoutMs,
nowEpochMs: Date.now()
}
)

return yield* _(
Match.value(decision).pipe(
Match.when({ _tag: "IgnoreStopped" }, () => Effect.void),
Match.when({ _tag: "KeepRunning" }, () =>
activeAgent
? recordProjectRuntimeActivity(project.projectDir, "agent").pipe(Effect.asVoid)
: Effect.void),
Match.when({ _tag: "RestoreNormalResources" }, () =>
recordProjectRuntimeActivity(project.projectDir, "agent").pipe(
Effect.zipRight(applyProjectResourceProfile(project, "normal", config.interactiveIdleCpuFactor))
)),
Match.when({ _tag: "ThrottleInteractiveIdle" }, () =>
config.throttleInteractiveIdle
? applyProjectResourceProfile(
project,
"interactive-idle-throttled",
config.interactiveIdleCpuFactor
)
: Effect.void),
Match.when({ _tag: "SuspendIdle" }, () =>
suspendProjectRuntime(project, "auto-suspend")),
Match.exhaustive
)
)
}).pipe(
Effect.catchAll((error) =>
Effect.logWarning(
`[auto-suspend] Failed to evaluate ${project.containerName}: ${
error instanceof Error ? error.message : String(error)
}`
)
)
)

export const scanProjectAutoSuspend = (
config: ProjectAutoSuspendConfig
) =>
Effect.gen(function*(_) {
if (!config.enabled) {
return
}
const projects = yield* _(listProjectItems)
const runtimeByProject = yield* _(loadProjectRuntimeByProject(projects))
yield* _(
Effect.forEach(
projects,
(project) => {
const runtime = runtimeForProject(runtimeByProject, project)
return runProjectIdleDecision(
project,
config,
runtime.running,
runtime.sshSessions,
runtime.startedAtEpochMs ?? project.lastStartedAtEpochMs
)
},
{ concurrency: 2, discard: true }
)
)
}).pipe(
Effect.catchAll((error) =>
Effect.logWarning(
`[auto-suspend] Scan failed: ${error instanceof Error ? error.message : String(error)}`
)
)
)

export const startProjectAutoSuspendLoop = (
config: ProjectAutoSuspendConfig
) =>
config.enabled
? scanProjectAutoSuspend(config).pipe(
Effect.repeat(Schedule.addDelay(Schedule.forever, () => Duration.millis(config.scanIntervalMs)))
)
: Effect.log("docker-git auto-suspend disabled.")
Loading
Loading