From 7250645e94d6dbafa8b6f0a7bae17c0760f0db05 Mon Sep 17 00:00:00 2001 From: agentclear Date: Thu, 16 Apr 2026 10:37:28 -0700 Subject: [PATCH] feat: configurable setup script timeout (CYPACK-1080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setup scripts were killed after a hard-coded 5-minute timeout, which is too short for steps like full database restores. Expose two overrides in ~/.cyrus/config.json: - `setupScriptTimeoutMs` on a repository entry — controls the timeout for `cyrus-setup.sh`/`.ps1`/`.cmd`/`.bat` for that repo. - `global_setup_script_timeout_ms` at the top level — controls the timeout for `global_setup_script`. Both are in milliseconds and default to 300_000 (5 minutes), preserving existing behavior for users who don't set them. --- CHANGELOG.md | 3 + apps/cli/src/services/WorkerService.ts | 2 + docs/CONFIG_FILE.md | 14 +++ docs/SETUP_SCRIPTS.md | 42 ++++++++- packages/core/schemas/EdgeConfig.json | 10 +++ packages/core/schemas/EdgeConfigPayload.json | 10 +++ packages/core/schemas/RepositoryConfig.json | 5 ++ .../core/schemas/RepositoryConfigPayload.json | 5 ++ packages/core/src/config-schemas.ts | 14 +++ packages/core/test/json-schema-export.test.ts | 2 + packages/edge-worker/src/GitService.ts | 37 +++++++- packages/edge-worker/test/GitService.test.ts | 87 +++++++++++++++++++ 12 files changed, 226 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0522efced..4347bbf39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added +- **Configurable setup script timeout** — Setup scripts used to be killed after a hard-coded 5 minutes, which was too short for steps like full database restores. You can now raise the limit per repository with `setupScriptTimeoutMs` on a repository entry, or for the global setup script with `global_setup_script_timeout_ms`, both in `~/.cyrus/config.json`. Values are in milliseconds; the default is still 300000 (5 minutes). ([CYPACK-1080](https://linear.app/ceedar/issue/CYPACK-1080)) + ### Fixed - **Working directory context now shows actual path** — The `` in agent session prompts previously showed "Will be created based on issue" instead of the actual worktree path. It now correctly displays the real workspace directory. ([CYPACK-1088](https://linear.app/ceedar/issue/CYPACK-1088), [#1110](https://github.com/ceedaragents/cyrus/pull/1110)) diff --git a/apps/cli/src/services/WorkerService.ts b/apps/cli/src/services/WorkerService.ts index 96679cb7c..d8b004f34 100644 --- a/apps/cli/src/services/WorkerService.ts +++ b/apps/cli/src/services/WorkerService.ts @@ -275,6 +275,8 @@ export class WorkerService { ): Promise => { return this.gitService.createGitWorktree(issue, repositories, { globalSetupScript: edgeConfig.global_setup_script, + globalSetupScriptTimeoutMs: + edgeConfig.global_setup_script_timeout_ms, baseBranchOverrides: options?.baseBranchOverrides, }); }, diff --git a/docs/CONFIG_FILE.md b/docs/CONFIG_FILE.md index 23484fea5..3769cf948 100644 --- a/docs/CONFIG_FILE.md +++ b/docs/CONFIG_FILE.md @@ -70,6 +70,14 @@ Routes Linear issues with specific labels to this repository. This is useful whe Example: `["backend", "api"]` - Only process issues that have the "backend" or "api" label +### `setupScriptTimeoutMs` (number) + +Timeout in milliseconds for the repository setup script (`cyrus-setup.sh` et al.). Defaults to `300000` (5 minutes). Raise this when the setup script performs long-running work such as restoring a database dump. + +Example: `"setupScriptTimeoutMs": 1800000` - allow the setup script up to 30 minutes before it is killed + +See [Setup Scripts](./SETUP_SCRIPTS.md) for details on how setup scripts work. + --- ## Routing Priority Order @@ -382,6 +390,12 @@ Sets default allowed tools for each prompt type across all repositories. Reposit Path to a script that runs for all repositories when creating new worktrees. See the main README for details on setup scripts. +### `global_setup_script_timeout_ms` (number) + +Timeout in milliseconds for the global setup script. Defaults to `300000` (5 minutes). Raise this when the global setup script performs long-running work (for example restoring a shared database). + +Example: `"global_setup_script_timeout_ms": 1800000` - allow the global setup script up to 30 minutes before it is killed. + --- ## Tool Configuration Priority diff --git a/docs/SETUP_SCRIPTS.md b/docs/SETUP_SCRIPTS.md index e2bdd2f64..dd432823f 100644 --- a/docs/SETUP_SCRIPTS.md +++ b/docs/SETUP_SCRIPTS.md @@ -35,6 +35,27 @@ echo "Repository setup complete for issue: $LINEAR_ISSUE_IDENTIFIER" Make sure the script is executable: `chmod +x cyrus-setup.sh` +### Increasing the timeout + +By default the repository setup script is killed after 5 minutes. If your +setup does something longer-running (for example restoring a database dump), +add `setupScriptTimeoutMs` to the repository entry in `~/.cyrus/config.json`: + +```json +{ + "repositories": [ + { + "id": "workspace-123456", + "name": "my-app", + "repositoryPath": "/path/to/repo", + "setupScriptTimeoutMs": 1800000 + } + ] +} +``` + +The value is in milliseconds (`1800000` = 30 minutes). + --- ## Global Setup Script @@ -69,8 +90,27 @@ Both scripts receive the same environment variables and run in the worktree dire Make sure the script is executable: `chmod +x /opt/cyrus/bin/global-setup.sh` +### Increasing the timeout + +By default the global setup script is killed after 5 minutes. To raise the +limit (for example when the script restores a shared database), add +`global_setup_script_timeout_ms` to `~/.cyrus/config.json`: + +```json +{ + "repositories": [...], + "global_setup_script": "/opt/cyrus/bin/global-setup.sh", + "global_setup_script_timeout_ms": 1800000 +} +``` + +The value is in milliseconds (`1800000` = 30 minutes). Per-repository setup +scripts use `setupScriptTimeoutMs` on the repository entry instead. + ### Error Handling - If the global script fails, Cyrus logs the error but continues with repository script execution -- Both scripts have a 5-minute timeout to prevent hanging +- Both scripts default to a 5-minute timeout; raise it with + `global_setup_script_timeout_ms` (global) or `setupScriptTimeoutMs` + (per-repository) when longer setup steps are needed - Script failures don't prevent worktree creation diff --git a/packages/core/schemas/EdgeConfig.json b/packages/core/schemas/EdgeConfig.json index bcacfa8ed..6d1d7c5fb 100644 --- a/packages/core/schemas/EdgeConfig.json +++ b/packages/core/schemas/EdgeConfig.json @@ -475,6 +475,11 @@ } }, "additionalProperties": false + }, + "setupScriptTimeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 } }, "required": [ @@ -546,6 +551,11 @@ "global_setup_script": { "type": "string" }, + "global_setup_script_timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, "defaultAllowedTools": { "type": "array", "items": { diff --git a/packages/core/schemas/EdgeConfigPayload.json b/packages/core/schemas/EdgeConfigPayload.json index 3de40ddb6..44be8af96 100644 --- a/packages/core/schemas/EdgeConfigPayload.json +++ b/packages/core/schemas/EdgeConfigPayload.json @@ -475,6 +475,11 @@ } }, "additionalProperties": false + }, + "setupScriptTimeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 } }, "required": ["id", "name", "repositoryPath", "baseBranch"], @@ -540,6 +545,11 @@ "global_setup_script": { "type": "string" }, + "global_setup_script_timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, "defaultAllowedTools": { "type": "array", "items": { diff --git a/packages/core/schemas/RepositoryConfig.json b/packages/core/schemas/RepositoryConfig.json index cf71c0d57..92cf25c46 100644 --- a/packages/core/schemas/RepositoryConfig.json +++ b/packages/core/schemas/RepositoryConfig.json @@ -470,6 +470,11 @@ } }, "additionalProperties": false + }, + "setupScriptTimeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 } }, "required": [ diff --git a/packages/core/schemas/RepositoryConfigPayload.json b/packages/core/schemas/RepositoryConfigPayload.json index c37cbfd79..3215b48d4 100644 --- a/packages/core/schemas/RepositoryConfigPayload.json +++ b/packages/core/schemas/RepositoryConfigPayload.json @@ -470,6 +470,11 @@ } }, "additionalProperties": false + }, + "setupScriptTimeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 } }, "required": ["id", "name", "repositoryPath", "baseBranch"], diff --git a/packages/core/src/config-schemas.ts b/packages/core/src/config-schemas.ts index 681862a00..db78fa9b4 100644 --- a/packages/core/src/config-schemas.ts +++ b/packages/core/src/config-schemas.ts @@ -311,6 +311,13 @@ export const RepositoryConfigSchema = z.object({ // Repository-specific user access control userAccessControl: UserAccessControlConfigSchema.optional(), + + /** + * Timeout for the repository setup script (`cyrus-setup.sh` et al.) in + * milliseconds. Overrides the default 5-minute timeout, useful for + * long-running setup steps like database restores. Defaults to 300000. + */ + setupScriptTimeoutMs: z.number().int().positive().optional(), }); /** @@ -375,6 +382,13 @@ export const EdgeConfigSchema = z.object({ /** Optional path to global setup script that runs for all repositories */ global_setup_script: z.string().optional(), + /** + * Timeout for the global setup script in milliseconds. Overrides the + * default 5-minute timeout. Useful for long-running setup steps like + * database restores. Defaults to 300000. + */ + global_setup_script_timeout_ms: z.number().int().positive().optional(), + /** Default tools to allow across all repositories */ defaultAllowedTools: z.array(z.string()).optional(), diff --git a/packages/core/test/json-schema-export.test.ts b/packages/core/test/json-schema-export.test.ts index 8063930ca..a78612d27 100644 --- a/packages/core/test/json-schema-export.test.ts +++ b/packages/core/test/json-schema-export.test.ts @@ -47,6 +47,7 @@ describe("JSON Schema export", () => { "defaultModel", "defaultFallbackModel", "global_setup_script", + "global_setup_script_timeout_ms", "defaultAllowedTools", "defaultDisallowedTools", "issueUpdateTrigger", @@ -118,6 +119,7 @@ describe("JSON Schema export", () => { "promptTemplatePath", "labelPrompts", "userAccessControl", + "setupScriptTimeoutMs", ]; for (const field of fields) { expect(schema.properties).toHaveProperty(field); diff --git a/packages/edge-worker/src/GitService.ts b/packages/edge-worker/src/GitService.ts index 02f1729cd..56cb7efc6 100644 --- a/packages/edge-worker/src/GitService.ts +++ b/packages/edge-worker/src/GitService.ts @@ -19,8 +19,16 @@ import type { import { createLogger, getDefaultWorktreesDir, type ILogger } from "cyrus-core"; import { WorktreeIncludeService } from "./WorktreeIncludeService.js"; +/** Default setup script timeout: 5 minutes. */ +export const DEFAULT_SETUP_SCRIPT_TIMEOUT_MS = 5 * 60 * 1000; + export interface CreateGitWorktreeOptions { globalSetupScript?: string; + /** + * Timeout for the global setup script in milliseconds. + * Defaults to {@link DEFAULT_SETUP_SCRIPT_TIMEOUT_MS} (5 minutes). + */ + globalSetupScriptTimeoutMs?: number; /** * Override workspace base directory. Required for 0-repo workspaces. * For 1+ repos, defaults to the first repository's workspaceBaseDir. @@ -141,6 +149,7 @@ export class GitService { scriptType: "global" | "repository", workspacePath: string, issue: Issue, + timeoutMs: number = DEFAULT_SETUP_SCRIPT_TIMEOUT_MS, ): Promise { // Expand ~ to home directory const expandedPath = scriptPath.replace(/^~/, homedir()); @@ -202,16 +211,17 @@ export class GitService { LINEAR_ISSUE_IDENTIFIER: issue.identifier, LINEAR_ISSUE_TITLE: issue.title || "", }, - timeout: 5 * 60 * 1000, // 5 minute timeout + timeout: timeoutMs, }); this.logger.info( `✅ ${scriptType === "global" ? "Global" : "Repository"} setup script completed successfully`, ); } catch (error) { + const timeoutMinutes = Math.round((timeoutMs / 60_000) * 10) / 10; const errorMessage = (error as any).signal === "SIGTERM" - ? "Script execution timed out (exceeded 5 minutes)" + ? `Script execution timed out (exceeded ${timeoutMinutes} minutes)` : (error as Error).message; this.logger.error( @@ -469,6 +479,7 @@ export class GitService { ): Promise { const { globalSetupScript, + globalSetupScriptTimeoutMs, workspaceBaseDir: overrideBaseDir, baseBranchOverrides, } = options ?? {}; @@ -494,6 +505,7 @@ export class GitService { "global", workspacePath, issue, + globalSetupScriptTimeoutMs, ); } @@ -516,6 +528,7 @@ export class GitService { globalSetupScript, undefined, overrideValue, + globalSetupScriptTimeoutMs, ); } @@ -529,7 +542,13 @@ export class GitService { // Run global setup script once in the parent directory if (globalSetupScript) { - await this.runSetupScript(globalSetupScript, "global", parentPath, issue); + await this.runSetupScript( + globalSetupScript, + "global", + parentPath, + issue, + globalSetupScriptTimeoutMs, + ); } const repoPaths: Record = {}; @@ -587,6 +606,7 @@ export class GitService { globalSetupScript?: string, workspacePathOverride?: string, baseBranchOverride?: string, + globalSetupScriptTimeoutMs?: number, ): Promise { this.logger.info( `createSingleRepoWorktree for ${repository.name} (id=${repository.id}): baseBranchOverride=${baseBranchOverride ?? "undefined"}`, @@ -824,6 +844,7 @@ export class GitService { "global", workspacePath, issue, + globalSetupScriptTimeoutMs, ); } @@ -832,6 +853,7 @@ export class GitService { repository.repositoryPath, workspacePath, issue, + repository.setupScriptTimeoutMs, ); return { @@ -1036,6 +1058,7 @@ export class GitService { repositoryPath: string, workspacePath: string, issue: Issue, + timeoutMs?: number, ): Promise { const isWindows = process.platform === "win32"; const setupScripts = [ @@ -1079,7 +1102,13 @@ export class GitService { if (scriptToRun) { const scriptPath = join(repositoryPath, scriptToRun.file); - await this.runSetupScript(scriptPath, "repository", workspacePath, issue); + await this.runSetupScript( + scriptPath, + "repository", + workspacePath, + issue, + timeoutMs, + ); } } } diff --git a/packages/edge-worker/test/GitService.test.ts b/packages/edge-worker/test/GitService.test.ts index 4a1260828..64431e74b 100644 --- a/packages/edge-worker/test/GitService.test.ts +++ b/packages/edge-worker/test/GitService.test.ts @@ -726,6 +726,93 @@ describe("GitService", () => { }); }); + describe("setup script timeout", () => { + it("uses the default 5-minute timeout when no override is configured", async () => { + const issue = makeIssue(); + + mockExistsSync.mockReturnValue(true); + mockStatSync.mockReturnValue({ mode: 0o755 } as any); + mockExecSync.mockReturnValue(Buffer.from("")); + + await gitService.createGitWorktree(issue, [], { + workspaceBaseDir: "/home/user/.cyrus/worktrees", + globalSetupScript: "/home/user/setup.sh", + }); + + // The setup script should be invoked with the default 5-minute timeout + const setupCalls = mockExecSync.mock.calls.filter((call) => + String(call[0]).includes("/home/user/setup.sh"), + ); + expect(setupCalls.length).toBeGreaterThan(0); + expect(setupCalls[0]![1]).toMatchObject({ timeout: 5 * 60 * 1000 }); + }); + + it("honors globalSetupScriptTimeoutMs override for the global setup script", async () => { + const issue = makeIssue(); + + mockExistsSync.mockReturnValue(true); + mockStatSync.mockReturnValue({ mode: 0o755 } as any); + mockExecSync.mockReturnValue(Buffer.from("")); + + await gitService.createGitWorktree(issue, [], { + workspaceBaseDir: "/home/user/.cyrus/worktrees", + globalSetupScript: "/home/user/setup.sh", + globalSetupScriptTimeoutMs: 30 * 60 * 1000, // 30 minutes + }); + + const setupCalls = mockExecSync.mock.calls.filter((call) => + String(call[0]).includes("/home/user/setup.sh"), + ); + expect(setupCalls.length).toBeGreaterThan(0); + expect(setupCalls[0]![1]).toMatchObject({ timeout: 30 * 60 * 1000 }); + }); + + it("honors repository.setupScriptTimeoutMs for the repository setup script", async () => { + const issue = makeIssue(); + const repository = makeRepository({ + setupScriptTimeoutMs: 20 * 60 * 1000, // 20 minutes + }); + + mockStatSync.mockReturnValue({ mode: 0o755 } as any); + mockExecSync.mockImplementation((cmd: any) => { + const cmdStr = String(cmd); + if (cmdStr === "git rev-parse --git-dir") { + return Buffer.from(".git\n"); + } + if (cmdStr === "git worktree list --porcelain") { + return ""; + } + if (cmdStr.includes("git rev-parse --verify")) { + throw new Error("not found"); + } + if (cmdStr.includes("git fetch origin")) { + return Buffer.from(""); + } + if (cmdStr.includes("git ls-remote")) { + return Buffer.from("abc123\trefs/heads/main\n"); + } + if (cmdStr.includes("git worktree add")) { + return Buffer.from(""); + } + return Buffer.from(""); + }); + + // existsSync returns true for cyrus-setup.sh and executable checks + mockExistsSync.mockReturnValue(true); + + await gitService.createGitWorktree(issue, [repository]); + + // Find the exec call for the repository setup script (cyrus-setup.sh) + const repoSetupCalls = mockExecSync.mock.calls.filter((call) => + String(call[0]).includes("cyrus-setup.sh"), + ); + expect(repoSetupCalls.length).toBeGreaterThan(0); + expect(repoSetupCalls[0]![1]).toMatchObject({ + timeout: 20 * 60 * 1000, + }); + }); + }); + describe("createGitWorktree - N repos (multi-repo)", () => { it("creates parent folder with per-repo worktree subdirectories", async () => { const issue = makeIssue();