From d5244878b4a0d685a17353a1ce5b5613b648f72f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:16:32 +0000 Subject: [PATCH 1/5] Initial plan From a8b38319a4bc6315029cfe2f4510170a6b0394b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:26:54 +0000 Subject: [PATCH 2/5] Increase harness watchdog default and add frontmatter override Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- ...atchdog-timeout-default-and-frontmatter.md | 5 +++ actions/setup/js/codex_harness.test.cjs | 4 ++ actions/setup/js/process_runner.cjs | 2 +- docs/src/content/docs/reference/engines.md | 6 ++- .../docs/reference/frontmatter-full.md | 11 +++++ pkg/parser/schema_test.go | 34 ++++++++++++++++ pkg/parser/schemas/main_workflow_schema.json | 12 ++++++ pkg/workflow/engine.go | 6 ++- pkg/workflow/engine_config_test.go | 23 +++++++---- pkg/workflow/engine_helpers.go | 5 ++- pkg/workflow/engine_helpers_test.go | 40 +++++++++++++++++++ 11 files changed, 135 insertions(+), 13 deletions(-) create mode 100644 .changeset/patch-harness-watchdog-timeout-default-and-frontmatter.md diff --git a/.changeset/patch-harness-watchdog-timeout-default-and-frontmatter.md b/.changeset/patch-harness-watchdog-timeout-default-and-frontmatter.md new file mode 100644 index 00000000000..3e7b6279233 --- /dev/null +++ b/.changeset/patch-harness-watchdog-timeout-default-and-frontmatter.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Increased the shared post-result harness watchdog default idle timeout from 20 seconds to 2 minutes, and added `engine.harness.watchdog-timeout-ms` to configure `GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS` from workflow frontmatter. diff --git a/actions/setup/js/codex_harness.test.cjs b/actions/setup/js/codex_harness.test.cjs index 74ed3fe1b8c..d75e9cb583c 100644 --- a/actions/setup/js/codex_harness.test.cjs +++ b/actions/setup/js/codex_harness.test.cjs @@ -858,6 +858,10 @@ process.exit(1);`, }); describe("resolvePostResultWatchdogIdleTimeoutMs", () => { + it("uses a 2-minute shared default", () => { + expect(DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS).toBe(120000); + }); + it("returns the default when no env var is set", () => { expect(resolvePostResultWatchdogIdleTimeoutMs({})).toBe(DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS); }); diff --git a/actions/setup/js/process_runner.cjs b/actions/setup/js/process_runner.cjs index 2cdcdfe191a..018a75a2afa 100644 --- a/actions/setup/js/process_runner.cjs +++ b/actions/setup/js/process_runner.cjs @@ -205,7 +205,7 @@ function runProcess({ command, args, attempt, log, logArgs, env, postResultWatch // Post-result watchdog: shared constants and timeout resolver used by all harnesses. // These are kept here so both copilot_harness and codex_harness stay in sync. const MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS = 50; -const DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS = 20 * 1000; +const DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS = 2 * 60 * 1000; /** Maximum allowed value for GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS to prevent the watchdog from being * effectively disabled by an excessively large override (e.g. a stray zero). */ const MAX_POST_RESULT_WATCHDOG_TIMEOUT_MS = 10 * 60 * 1000; diff --git a/docs/src/content/docs/reference/engines.md b/docs/src/content/docs/reference/engines.md index 1c1e0028d87..c134f882bcb 100644 --- a/docs/src/content/docs/reference/engines.md +++ b/docs/src/content/docs/reference/engines.md @@ -343,7 +343,7 @@ The `use` value must be a bare filename — no directory separators, no `..`, an | Must start with `[A-Za-z0-9_]` | `harness.js` | `-harness.cjs` | | Must end with `.js`, `.cjs`, or `.mjs` | `wrapper.cjs` | `harness.sh` | -### Harness Retry Policy +### Harness Retry and Post-result Watchdog Policy The built-in Copilot, Claude, and Codex harnesses default to **3 retries** after the initial run (4 total attempts), with exponential backoff starting at 5 s (capped at 60 s). Use sub-keys under `engine.harness` to widen the retry window without replacing the harness: @@ -355,6 +355,7 @@ engine: initial-delay-ms: 10000 backoff-multiplier: 2 max-delay-ms: 180000 + watchdog-timeout-ms: 120000 ``` All four fields accept a literal integer or a GitHub Actions expression (e.g. `${{ vars.MY_RETRIES }}`): @@ -365,8 +366,9 @@ All four fields accept a literal integer or a GitHub Actions expression (e.g. `$ | `initial-delay-ms` | `5000` | Delay in ms before the first retry | | `backoff-multiplier` | `2` | Multiplier applied to the delay after each retry | | `max-delay-ms` | `60000` | Maximum delay cap in ms | +| `watchdog-timeout-ms` | `120000` | Post-result idle watchdog timeout before terminating a quiet process | -You can also set the underlying `GH_AW_HARNESS_*` env vars directly via `engine.env` when you need expression-level control. Explicit `engine.env` values take precedence over `engine.harness` sub-key values. +You can also set the underlying `GH_AW_HARNESS_*` env vars directly via `engine.env` when you need expression-level control, including `GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS` for the post-result watchdog. Explicit `engine.env` values take precedence over `engine.harness` sub-key values. ### Copilot SDK Support diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index e163e9170a5..f6b82c57e96 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -2495,6 +2495,17 @@ engine: # Format 2: string max-delay-ms: "example-value" + # Post-result idle watchdog timeout in ms. Accepts a literal integer or a + # GitHub Actions expression. + # (optional) + # Accepted formats: + + # Format 1: integer + watchdog-timeout-ms: 1 + + # Format 2: string + watchdog-timeout-ms: "example-value" + # Custom environment variables to pass to the AI engine, including secret # overrides (e.g., OPENAI_API_KEY: ${{ secrets.CUSTOM_KEY }}) # (optional) diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index 67f3ff2ec90..22ca1641cf5 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -356,6 +356,40 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_EngineHarnessPatte } } +func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_EngineHarnessWatchdogTimeout(t *testing.T) { + t.Parallel() + + validFrontmatter := map[string]any{ + "on": "push", + "engine": map[string]any{ + "id": "copilot", + "harness": map[string]any{ + "watchdog-timeout-ms": 120000, + }, + }, + } + + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(validFrontmatter, "/tmp/gh-aw/engine-harness-watchdog-timeout-valid-test.md") + if err != nil { + t.Fatalf("expected valid engine.harness.watchdog-timeout-ms to pass schema validation, got: %v", err) + } + + invalidFrontmatter := map[string]any{ + "on": "push", + "engine": map[string]any{ + "id": "copilot", + "harness": map[string]any{ + "watchdog-timeout-ms": 0, + }, + }, + } + + err = ValidateMainWorkflowFrontmatterWithSchemaAndLocation(invalidFrontmatter, "/tmp/gh-aw/engine-harness-watchdog-timeout-invalid-test.md") + if err == nil { + t.Fatal("expected non-positive engine.harness.watchdog-timeout-ms to fail schema validation") + } +} + func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_EngineDriverPattern(t *testing.T) { t.Parallel() diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 2ff6dc39b92..8b9045c2c2b 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -12797,6 +12797,18 @@ } ], "description": "Maximum delay cap in ms. Accepts a literal integer or a GitHub Actions expression." + }, + "watchdog-timeout-ms": { + "oneOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "string" + } + ], + "description": "Post-result idle watchdog timeout in ms. Accepts a literal integer or a GitHub Actions expression." } }, "additionalProperties": false diff --git a/pkg/workflow/engine.go b/pkg/workflow/engine.go index 98db36836c5..4a70d02f1b1 100644 --- a/pkg/workflow/engine.go +++ b/pkg/workflow/engine.go @@ -97,7 +97,7 @@ type EngineConfig struct { // Defaults to the repository workspace (GITHUB_WORKSPACE) when empty. Cwd string - // Harness retry policy fields — templatable integers (literal value or ${{ expr }}). + // Harness policy fields — templatable integers (literal value or ${{ expr }}). // When set, the value is injected as the corresponding GH_AW_HARNESS_* env var so // that all harness scripts (copilot, claude, codex) can read it from the environment. // The harness falls back to its built-in default when the env var is absent. @@ -106,6 +106,7 @@ type EngineConfig struct { HarnessInitialDelayMs string // engine.harness.initial-delay-ms → GH_AW_HARNESS_INITIAL_DELAY_MS HarnessBackoffMultiplier string // engine.harness.backoff-multiplier → GH_AW_HARNESS_BACKOFF_MULTIPLIER HarnessMaxDelayMs string // engine.harness.max-delay-ms → GH_AW_HARNESS_MAX_DELAY_MS + HarnessWatchdogTimeoutMs string // engine.harness.watchdog-timeout-ms → GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS } // InlineEngineDriver represents an inline engine.driver source block that gh-aw materializes @@ -527,6 +528,9 @@ func applyEngineHarnessField(config *EngineConfig, engineObj map[string]any) { if v, ok := h["max-delay-ms"]; ok { config.HarnessMaxDelayMs = parseMaxTurnsValue(v) } + if v, ok := h["watchdog-timeout-ms"]; ok { + config.HarnessWatchdogTimeoutMs = parseMaxTurnsValue(v) + } } } diff --git a/pkg/workflow/engine_config_test.go b/pkg/workflow/engine_config_test.go index 71ded5bebe8..a8cb410310c 100644 --- a/pkg/workflow/engine_config_test.go +++ b/pkg/workflow/engine_config_test.go @@ -538,10 +538,11 @@ func TestExtractEngineConfig(t *testing.T) { "engine": map[string]any{ "id": "copilot", "harness": map[string]any{ - "max-retries": 6, - "initial-delay-ms": 10000, - "backoff-multiplier": 2, - "max-delay-ms": 180000, + "max-retries": 6, + "initial-delay-ms": 10000, + "backoff-multiplier": 2, + "max-delay-ms": 180000, + "watchdog-timeout-ms": 120000, }, }, }, @@ -552,6 +553,7 @@ func TestExtractEngineConfig(t *testing.T) { HarnessInitialDelayMs: "10000", HarnessBackoffMultiplier: "2", HarnessMaxDelayMs: "180000", + HarnessWatchdogTimeoutMs: "120000", }, }, { @@ -560,10 +562,11 @@ func TestExtractEngineConfig(t *testing.T) { "engine": map[string]any{ "id": "claude", "harness": map[string]any{ - "max-retries": "${{ vars.RETRY_COUNT }}", - "initial-delay-ms": "${{ vars.RETRY_DELAY }}", - "backoff-multiplier": "${{ vars.BACKOFF }}", - "max-delay-ms": "${{ vars.MAX_DELAY }}", + "max-retries": "${{ vars.RETRY_COUNT }}", + "initial-delay-ms": "${{ vars.RETRY_DELAY }}", + "backoff-multiplier": "${{ vars.BACKOFF }}", + "max-delay-ms": "${{ vars.MAX_DELAY }}", + "watchdog-timeout-ms": "${{ vars.WATCHDOG_TIMEOUT_MS }}", }, }, }, @@ -574,6 +577,7 @@ func TestExtractEngineConfig(t *testing.T) { HarnessInitialDelayMs: "${{ vars.RETRY_DELAY }}", HarnessBackoffMultiplier: "${{ vars.BACKOFF }}", HarnessMaxDelayMs: "${{ vars.MAX_DELAY }}", + HarnessWatchdogTimeoutMs: "${{ vars.WATCHDOG_TIMEOUT_MS }}", }, }, { @@ -665,6 +669,9 @@ func TestExtractEngineConfig(t *testing.T) { if config.HarnessMaxDelayMs != test.expectedConfig.HarnessMaxDelayMs { t.Errorf("Expected config.HarnessMaxDelayMs '%s', got '%s'", test.expectedConfig.HarnessMaxDelayMs, config.HarnessMaxDelayMs) } + if config.HarnessWatchdogTimeoutMs != test.expectedConfig.HarnessWatchdogTimeoutMs { + t.Errorf("Expected config.HarnessWatchdogTimeoutMs '%s', got '%s'", test.expectedConfig.HarnessWatchdogTimeoutMs, config.HarnessWatchdogTimeoutMs) + } if len(config.Env) != len(test.expectedConfig.Env) { t.Errorf("Expected config.Env length %d, got %d", len(test.expectedConfig.Env), len(config.Env)) diff --git a/pkg/workflow/engine_helpers.go b/pkg/workflow/engine_helpers.go index 661dcb62d8c..92364c4187d 100644 --- a/pkg/workflow/engine_helpers.go +++ b/pkg/workflow/engine_helpers.go @@ -147,7 +147,7 @@ func applyEngineMaxTurnsEnv(env map[string]string, workflowData *WorkflowData) { } // applyEngineHarnessRetryEnv injects GH_AW_HARNESS_* environment variables from -// the engine frontmatter retry policy fields (engine.harness.max-retries, etc.). +// the engine frontmatter harness policy fields (engine.harness.max-retries, etc.). // Only fields that are explicitly set are injected; absent fields let the harness // fall back to its built-in defaults. Must be called before applyEngineAndAgentEnv // so that explicit engine.env overrides take precedence. @@ -168,6 +168,9 @@ func applyEngineHarnessRetryEnv(env map[string]string, workflowData *WorkflowDat if cfg.HarnessMaxDelayMs != "" { env["GH_AW_HARNESS_MAX_DELAY_MS"] = cfg.HarnessMaxDelayMs } + if cfg.HarnessWatchdogTimeoutMs != "" { + env["GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS"] = cfg.HarnessWatchdogTimeoutMs + } } // applyEngineAndAgentEnv merges custom environment variables from engine and agent configs. diff --git a/pkg/workflow/engine_helpers_test.go b/pkg/workflow/engine_helpers_test.go index 796ad1545c6..0b960712118 100644 --- a/pkg/workflow/engine_helpers_test.go +++ b/pkg/workflow/engine_helpers_test.go @@ -55,6 +55,46 @@ func TestResolveEngineID(t *testing.T) { } } +func TestApplyEngineHarnessRetryEnv(t *testing.T) { + t.Run("injects harness policy env vars including watchdog timeout", func(t *testing.T) { + env := map[string]string{} + workflowData := &WorkflowData{ + EngineConfig: &EngineConfig{ + HarnessMaxRetries: "6", + HarnessInitialDelayMs: "10000", + HarnessBackoffMultiplier: "2", + HarnessMaxDelayMs: "180000", + HarnessWatchdogTimeoutMs: "120000", + }, + } + + applyEngineHarnessRetryEnv(env, workflowData) + + assert.Equal(t, "6", env["GH_AW_HARNESS_MAX_RETRIES"]) + assert.Equal(t, "10000", env["GH_AW_HARNESS_INITIAL_DELAY_MS"]) + assert.Equal(t, "2", env["GH_AW_HARNESS_BACKOFF_MULTIPLIER"]) + assert.Equal(t, "180000", env["GH_AW_HARNESS_MAX_DELAY_MS"]) + assert.Equal(t, "120000", env["GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS"]) + }) + + t.Run("engine.env override still wins after harness policy env injection", func(t *testing.T) { + env := map[string]string{} + workflowData := &WorkflowData{ + EngineConfig: &EngineConfig{ + HarnessWatchdogTimeoutMs: "120000", + Env: map[string]string{ + "GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS": "90000", + }, + }, + } + + applyEngineHarnessRetryEnv(env, workflowData) + applyEngineAndAgentEnv(env, workflowData, nil) + + assert.Equal(t, "90000", env["GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS"]) + }) +} + func TestBuildStandardNpmEngineInstallStepsNoCooldown(t *testing.T) { steps := BuildStandardNpmEngineInstallStepsNoCooldown( "@github/copilot", From e70abf10ffd4c60b643237a872aa38d631e0fd48 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:45:38 +0000 Subject: [PATCH 3/5] Use seconds-based engine harness watchdog timeout Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- ...atchdog-timeout-default-and-frontmatter.md | 2 +- docs/src/content/docs/reference/engines.md | 4 ++-- .../docs/reference/frontmatter-full.md | 6 ++--- pkg/parser/schema_test.go | 8 +++---- pkg/parser/schemas/main_workflow_schema.json | 4 ++-- pkg/workflow/engine.go | 6 ++--- pkg/workflow/engine_config_parser.go | 24 +++++++++++++++++++ pkg/workflow/engine_config_test.go | 22 ++++++++--------- 8 files changed, 50 insertions(+), 26 deletions(-) diff --git a/.changeset/patch-harness-watchdog-timeout-default-and-frontmatter.md b/.changeset/patch-harness-watchdog-timeout-default-and-frontmatter.md index 3e7b6279233..a22946514b1 100644 --- a/.changeset/patch-harness-watchdog-timeout-default-and-frontmatter.md +++ b/.changeset/patch-harness-watchdog-timeout-default-and-frontmatter.md @@ -2,4 +2,4 @@ "gh-aw": patch --- -Increased the shared post-result harness watchdog default idle timeout from 20 seconds to 2 minutes, and added `engine.harness.watchdog-timeout-ms` to configure `GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS` from workflow frontmatter. +Increased the shared post-result harness watchdog default idle timeout from 20 seconds to 2 minutes, and added `engine.harness.watchdog-timeout` (seconds) to configure `GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS` from workflow frontmatter. diff --git a/docs/src/content/docs/reference/engines.md b/docs/src/content/docs/reference/engines.md index c134f882bcb..f58ffd2daf3 100644 --- a/docs/src/content/docs/reference/engines.md +++ b/docs/src/content/docs/reference/engines.md @@ -355,7 +355,7 @@ engine: initial-delay-ms: 10000 backoff-multiplier: 2 max-delay-ms: 180000 - watchdog-timeout-ms: 120000 + watchdog-timeout: 120 ``` All four fields accept a literal integer or a GitHub Actions expression (e.g. `${{ vars.MY_RETRIES }}`): @@ -366,7 +366,7 @@ All four fields accept a literal integer or a GitHub Actions expression (e.g. `$ | `initial-delay-ms` | `5000` | Delay in ms before the first retry | | `backoff-multiplier` | `2` | Multiplier applied to the delay after each retry | | `max-delay-ms` | `60000` | Maximum delay cap in ms | -| `watchdog-timeout-ms` | `120000` | Post-result idle watchdog timeout before terminating a quiet process | +| `watchdog-timeout` | `120` | Post-result idle watchdog timeout in seconds before terminating a quiet process | You can also set the underlying `GH_AW_HARNESS_*` env vars directly via `engine.env` when you need expression-level control, including `GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS` for the post-result watchdog. Explicit `engine.env` values take precedence over `engine.harness` sub-key values. diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index f6b82c57e96..8e8d8ccdc98 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -2495,16 +2495,16 @@ engine: # Format 2: string max-delay-ms: "example-value" - # Post-result idle watchdog timeout in ms. Accepts a literal integer or a + # Post-result idle watchdog timeout in seconds. Accepts a literal integer or a # GitHub Actions expression. # (optional) # Accepted formats: # Format 1: integer - watchdog-timeout-ms: 1 + watchdog-timeout: 1 # Format 2: string - watchdog-timeout-ms: "example-value" + watchdog-timeout: "example-value" # Custom environment variables to pass to the AI engine, including secret # overrides (e.g., OPENAI_API_KEY: ${{ secrets.CUSTOM_KEY }}) diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index 22ca1641cf5..28933618a1e 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -364,14 +364,14 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_EngineHarnessWatch "engine": map[string]any{ "id": "copilot", "harness": map[string]any{ - "watchdog-timeout-ms": 120000, + "watchdog-timeout": 120, }, }, } err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(validFrontmatter, "/tmp/gh-aw/engine-harness-watchdog-timeout-valid-test.md") if err != nil { - t.Fatalf("expected valid engine.harness.watchdog-timeout-ms to pass schema validation, got: %v", err) + t.Fatalf("expected valid engine.harness.watchdog-timeout to pass schema validation, got: %v", err) } invalidFrontmatter := map[string]any{ @@ -379,14 +379,14 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_EngineHarnessWatch "engine": map[string]any{ "id": "copilot", "harness": map[string]any{ - "watchdog-timeout-ms": 0, + "watchdog-timeout": 0, }, }, } err = ValidateMainWorkflowFrontmatterWithSchemaAndLocation(invalidFrontmatter, "/tmp/gh-aw/engine-harness-watchdog-timeout-invalid-test.md") if err == nil { - t.Fatal("expected non-positive engine.harness.watchdog-timeout-ms to fail schema validation") + t.Fatal("expected non-positive engine.harness.watchdog-timeout to fail schema validation") } } diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 8b9045c2c2b..73080f56270 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -12798,7 +12798,7 @@ ], "description": "Maximum delay cap in ms. Accepts a literal integer or a GitHub Actions expression." }, - "watchdog-timeout-ms": { + "watchdog-timeout": { "oneOf": [ { "type": "integer", @@ -12808,7 +12808,7 @@ "type": "string" } ], - "description": "Post-result idle watchdog timeout in ms. Accepts a literal integer or a GitHub Actions expression." + "description": "Post-result idle watchdog timeout in seconds. Accepts a literal integer or a GitHub Actions expression." } }, "additionalProperties": false diff --git a/pkg/workflow/engine.go b/pkg/workflow/engine.go index 4a70d02f1b1..2bd69e1a256 100644 --- a/pkg/workflow/engine.go +++ b/pkg/workflow/engine.go @@ -106,7 +106,7 @@ type EngineConfig struct { HarnessInitialDelayMs string // engine.harness.initial-delay-ms → GH_AW_HARNESS_INITIAL_DELAY_MS HarnessBackoffMultiplier string // engine.harness.backoff-multiplier → GH_AW_HARNESS_BACKOFF_MULTIPLIER HarnessMaxDelayMs string // engine.harness.max-delay-ms → GH_AW_HARNESS_MAX_DELAY_MS - HarnessWatchdogTimeoutMs string // engine.harness.watchdog-timeout-ms → GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS + HarnessWatchdogTimeoutMs string // engine.harness.watchdog-timeout (seconds) → GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS } // InlineEngineDriver represents an inline engine.driver source block that gh-aw materializes @@ -528,8 +528,8 @@ func applyEngineHarnessField(config *EngineConfig, engineObj map[string]any) { if v, ok := h["max-delay-ms"]; ok { config.HarnessMaxDelayMs = parseMaxTurnsValue(v) } - if v, ok := h["watchdog-timeout-ms"]; ok { - config.HarnessWatchdogTimeoutMs = parseMaxTurnsValue(v) + if v, ok := h["watchdog-timeout"]; ok { + config.HarnessWatchdogTimeoutMs = parseHarnessWatchdogTimeoutValue(v) } } } diff --git a/pkg/workflow/engine_config_parser.go b/pkg/workflow/engine_config_parser.go index aa87f02d9d6..ff31c4bd8aa 100644 --- a/pkg/workflow/engine_config_parser.go +++ b/pkg/workflow/engine_config_parser.go @@ -64,6 +64,30 @@ func parseHarnessMaxRetriesValue(raw any) string { return parseIntOrExpressionValue(raw, 0, "harness.max-retries") } +// parseHarnessWatchdogTimeoutValue parses harness.watchdog-timeout (seconds) +// and converts it to milliseconds for GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS. +// Accepts a positive integer or a GitHub Actions expression template (${{ ... }}). +func parseHarnessWatchdogTimeoutValue(raw any) string { + seconds := parseIntOrExpressionValue(raw, 1, "harness.watchdog-timeout") + if seconds == "" { + return "" + } + if inner, ok := extractWrappedGitHubExpression(seconds); ok { + return "${{ (" + inner + ") * 1000 }}" + } + parsedSeconds, err := strconv.ParseInt(seconds, 10, 64) + if err != nil { + engineLog.Printf("Ignoring invalid harness.watchdog-timeout value: %q", seconds) + return "" + } + const maxInt64Div1000 = int64((1<<63)-1) / 1000 + if parsedSeconds > maxInt64Div1000 { + engineLog.Printf("Ignoring out-of-range harness.watchdog-timeout value: %q", seconds) + return "" + } + return strconv.FormatInt(parsedSeconds*1000, 10) +} + func parseIntOrExpressionValue(raw any, minValue int, fieldName string) string { if val, ok := typeutil.ParseIntValue(raw); ok && val >= minValue { return strconv.Itoa(val) diff --git a/pkg/workflow/engine_config_test.go b/pkg/workflow/engine_config_test.go index a8cb410310c..c56b9ac43d5 100644 --- a/pkg/workflow/engine_config_test.go +++ b/pkg/workflow/engine_config_test.go @@ -538,11 +538,11 @@ func TestExtractEngineConfig(t *testing.T) { "engine": map[string]any{ "id": "copilot", "harness": map[string]any{ - "max-retries": 6, - "initial-delay-ms": 10000, - "backoff-multiplier": 2, - "max-delay-ms": 180000, - "watchdog-timeout-ms": 120000, + "max-retries": 6, + "initial-delay-ms": 10000, + "backoff-multiplier": 2, + "max-delay-ms": 180000, + "watchdog-timeout": 120, }, }, }, @@ -562,11 +562,11 @@ func TestExtractEngineConfig(t *testing.T) { "engine": map[string]any{ "id": "claude", "harness": map[string]any{ - "max-retries": "${{ vars.RETRY_COUNT }}", - "initial-delay-ms": "${{ vars.RETRY_DELAY }}", - "backoff-multiplier": "${{ vars.BACKOFF }}", - "max-delay-ms": "${{ vars.MAX_DELAY }}", - "watchdog-timeout-ms": "${{ vars.WATCHDOG_TIMEOUT_MS }}", + "max-retries": "${{ vars.RETRY_COUNT }}", + "initial-delay-ms": "${{ vars.RETRY_DELAY }}", + "backoff-multiplier": "${{ vars.BACKOFF }}", + "max-delay-ms": "${{ vars.MAX_DELAY }}", + "watchdog-timeout": "${{ vars.WATCHDOG_TIMEOUT_SEC }}", }, }, }, @@ -577,7 +577,7 @@ func TestExtractEngineConfig(t *testing.T) { HarnessInitialDelayMs: "${{ vars.RETRY_DELAY }}", HarnessBackoffMultiplier: "${{ vars.BACKOFF }}", HarnessMaxDelayMs: "${{ vars.MAX_DELAY }}", - HarnessWatchdogTimeoutMs: "${{ vars.WATCHDOG_TIMEOUT_MS }}", + HarnessWatchdogTimeoutMs: "${{ (vars.WATCHDOG_TIMEOUT_SEC) * 1000 }}", }, }, { From 78a5d9afc0215d58cb8760b05b238ac78408184d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 04:03:02 +0000 Subject: [PATCH 4/5] Add ADR-51292: increase harness watchdog default and add frontmatter timeout control Co-Authored-By: Claude Sonnet 4.6 --- ...dog-default-and-add-frontmatter-timeout.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/adr/51292-increase-harness-watchdog-default-and-add-frontmatter-timeout.md diff --git a/docs/adr/51292-increase-harness-watchdog-default-and-add-frontmatter-timeout.md b/docs/adr/51292-increase-harness-watchdog-default-and-add-frontmatter-timeout.md new file mode 100644 index 00000000000..04202eca248 --- /dev/null +++ b/docs/adr/51292-increase-harness-watchdog-default-and-add-frontmatter-timeout.md @@ -0,0 +1,46 @@ +# ADR-51292: Increase Harness Watchdog Default and Add Frontmatter Timeout Control + +**Date**: 2026-08-08 +**Status**: Draft +**Deciders**: pelikhan + +--- + +### Context + +The post-result harness watchdog kills any engine process that is silent (no stdio) for longer than a configured idle timeout after it has emitted a terminal safe output. The previous default was 20 seconds. In practice, large-repo workflows commonly pass through quiet shell phases (package installs, builds, index refreshes) that comfortably exceed 20 seconds without producing output, causing the watchdog to send a premature SIGTERM and leave runs incomplete. + +The only existing override surface was `GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS` set via `engine.env`, which requires the caller to know the env var name, know the unit is milliseconds, and accept that the override is indistinguishable from other env-var customizations. This surface is not surfaced in schema validation or reference docs in a discoverable way. + +### Decision + +We will raise `DEFAULT_POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS` from 20 seconds (20 000 ms) to 2 minutes (120 000 ms) in `process_runner.cjs` — the shared location read by all harnesses — and we will add `engine.harness.watchdog-timeout` as a first-class frontmatter integer field (unit: seconds) that compiles to `GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS` (unit: milliseconds). The new field sits alongside the existing retry-policy fields under `engine.harness`, follows the same literal-or-expression pattern, and is validated by the main workflow JSON schema. + +### Alternatives Considered + +#### Alternative 1: Raise the default only, no new frontmatter field + +Increasing the default to 120 s without adding a frontmatter override surface would fix the most common regression at minimal API cost. Workflows with genuine requirements for a shorter or longer timeout would still have to fall back to the raw `engine.env.GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS` override, with its undiscoverable name and millisecond unit. This option trades surface simplicity for discoverability and per-workflow configurability. + +#### Alternative 2: Keep the 20 s default, improve env-var documentation only + +Documenting `GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS` more prominently in reference docs and adding schema validation for it as an `engine.env` value would let advanced users tune the timeout without changing the default. This avoids the risk that a longer default masks genuinely stuck processes, but does not address the root cause: the 20 s window is too short for routine large-repo operations and results in hard-to-diagnose incomplete runs. + +### Consequences + +#### Positive +- Premature watchdog kills during legitimate quiet phases (installs, builds, index refreshes) are eliminated for typical large-repo workflows under the new 2-minute default. +- Workflow authors can tune per-workflow watchdog behavior via a discoverable, schema-validated frontmatter field without memorising env var names or unit conversions. +- Explicit `engine.env.GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS` overrides continue to take precedence, preserving backward compatibility for callers already using the env-var surface. + +#### Negative +- A genuinely stuck post-result process now takes up to 2 minutes (instead of 20 seconds) to be forcibly terminated under the new default, increasing the worst-case cost of a hung run. +- Adding `watchdog-timeout` to `engine.harness` extends the frontmatter API surface; future breaking changes to this field require a deprecation path. + +#### Neutral +- The `watchdog-timeout` frontmatter field accepts seconds; the runtime converts to milliseconds before injecting `GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS`. This unit difference is intentional (seconds are more human-readable in config) and is documented in schema and reference docs. +- The existing `MIN_POST_RESULT_WATCHDOG_TIMEOUT_MS` (50 ms) and `MAX_POST_RESULT_WATCHDOG_TIMEOUT_MS` (10 min) guards are unchanged and continue to prevent obviously invalid overrides. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* From cbafa8d5c3a912403186f0f024730f1c8fbd9d30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 8 Aug 2026 04:32:05 +0000 Subject: [PATCH 5/5] Fix expression pass-through for watchdog-timeout and update docs Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- docs/src/content/docs/reference/engines.md | 4 +++- pkg/workflow/engine_config_parser.go | 9 ++++++--- pkg/workflow/engine_config_test.go | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/src/content/docs/reference/engines.md b/docs/src/content/docs/reference/engines.md index f58ffd2daf3..f9e2145db55 100644 --- a/docs/src/content/docs/reference/engines.md +++ b/docs/src/content/docs/reference/engines.md @@ -358,7 +358,9 @@ engine: watchdog-timeout: 120 ``` -All four fields accept a literal integer or a GitHub Actions expression (e.g. `${{ vars.MY_RETRIES }}`): +All five fields accept a literal integer or a GitHub Actions expression (e.g. `${{ vars.MY_RETRIES }}`). +For `watchdog-timeout`, the value is treated as seconds when it is a literal integer. +When an expression is used, it must already be in milliseconds (GitHub Actions expressions do not support arithmetic operators): | Sub-key | Default | Description | |---|---|---| diff --git a/pkg/workflow/engine_config_parser.go b/pkg/workflow/engine_config_parser.go index ff31c4bd8aa..2544a782262 100644 --- a/pkg/workflow/engine_config_parser.go +++ b/pkg/workflow/engine_config_parser.go @@ -66,14 +66,17 @@ func parseHarnessMaxRetriesValue(raw any) string { // parseHarnessWatchdogTimeoutValue parses harness.watchdog-timeout (seconds) // and converts it to milliseconds for GH_AW_HARNESS_WATCHDOG_TIMEOUT_MS. -// Accepts a positive integer or a GitHub Actions expression template (${{ ... }}). +// Accepts a positive integer (converted seconds→ms) or a GitHub Actions expression +// template (${{ ... }}), which is passed through unchanged and must already be in ms. func parseHarnessWatchdogTimeoutValue(raw any) string { seconds := parseIntOrExpressionValue(raw, 1, "harness.watchdog-timeout") if seconds == "" { return "" } - if inner, ok := extractWrappedGitHubExpression(seconds); ok { - return "${{ (" + inner + ") * 1000 }}" + // GitHub Actions expressions do not support arithmetic operators; pass through + // unchanged. Callers using an expression must supply a value already in ms. + if _, ok := extractWrappedGitHubExpression(seconds); ok { + return seconds } parsedSeconds, err := strconv.ParseInt(seconds, 10, 64) if err != nil { diff --git a/pkg/workflow/engine_config_test.go b/pkg/workflow/engine_config_test.go index c56b9ac43d5..dd01ec8e572 100644 --- a/pkg/workflow/engine_config_test.go +++ b/pkg/workflow/engine_config_test.go @@ -577,7 +577,7 @@ func TestExtractEngineConfig(t *testing.T) { HarnessInitialDelayMs: "${{ vars.RETRY_DELAY }}", HarnessBackoffMultiplier: "${{ vars.BACKOFF }}", HarnessMaxDelayMs: "${{ vars.MAX_DELAY }}", - HarnessWatchdogTimeoutMs: "${{ (vars.WATCHDOG_TIMEOUT_SEC) * 1000 }}", + HarnessWatchdogTimeoutMs: "${{ vars.WATCHDOG_TIMEOUT_SEC }}", }, }, {