From 725d5ea1fc92c9a8f9b75c245bc799394ff0845a Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Fri, 14 Aug 2026 10:27:39 +1000 Subject: [PATCH 01/11] chore(porch): bugfix-1436 init bugfix --- .../status.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml diff --git a/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml b/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml new file mode 100644 index 000000000..1be9856c8 --- /dev/null +++ b/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml @@ -0,0 +1,14 @@ +id: bugfix-1436 +title: ci-streamdeck-validate-step-fl +protocol: bugfix +phase: investigate +plan_phases: [] +current_plan_phase: null +gates: + pr: + status: pending +iteration: 1 +build_complete: false +history: [] +started_at: '2026-08-14T00:27:39.607Z' +updated_at: '2026-08-14T00:27:39.608Z' From 92a5de2118151333fc3ef0d7636e3544fef3ea2b Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Fri, 14 Aug 2026 10:30:37 +1000 Subject: [PATCH 02/11] chore(porch): bugfix-1436 fix phase-transition --- .../bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml b/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml index 1be9856c8..a4f682e46 100644 --- a/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml +++ b/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml @@ -1,7 +1,7 @@ id: bugfix-1436 title: ci-streamdeck-validate-step-fl protocol: bugfix -phase: investigate +phase: fix plan_phases: [] current_plan_phase: null gates: @@ -11,4 +11,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-08-14T00:27:39.607Z' -updated_at: '2026-08-14T00:27:39.608Z' +updated_at: '2026-08-14T00:30:37.321Z' From 84fd5e78545ebe35fad016e1c56033c4465b1170 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Fri, 14 Aug 2026 10:33:06 +1000 Subject: [PATCH 03/11] Fix #1436: retry streamdeck validate on transient network errors The Elgato CLI's manifestUrlsExist rule does a live HEAD request to the manifest's URL field; any fetch error other than ENOTFOUND (UND_ERR_SOCKET, ECONNRESET, 'fetch failed', ...) is rethrown and crashes the whole validate run, flaking unrelated PRs' CI (#1432, #1434) with no code defect. Wrap the validate invocation in a bounded retry (3 attempts, exponential backoff) that retries ONLY transient network failures; real validation errors fail fast. Both CI workflows call 'pnpm validate', so this covers both sites. Regression test simulates a transient failure then success (fails without the retry, passes with it) and pins fail-fast on real errors. --- apps/streamdeck/package.json | 4 +- .../scripts/validate-with-retry.mjs | 111 ++++++++++++++++++ .../src/__tests__/validate-with-retry.test.ts | 88 ++++++++++++++ codev/state/bugfix-1436_thread.md | 53 +++++++++ 4 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 apps/streamdeck/scripts/validate-with-retry.mjs create mode 100644 apps/streamdeck/src/__tests__/validate-with-retry.test.ts create mode 100644 codev/state/bugfix-1436_thread.md diff --git a/apps/streamdeck/package.json b/apps/streamdeck/package.json index 10da5d802..5200567e8 100644 --- a/apps/streamdeck/package.json +++ b/apps/streamdeck/package.json @@ -8,9 +8,9 @@ "build": "node esbuild.js", "watch": "node esbuild.js --watch", "check-types": "tsc --noEmit", - "validate": "streamdeck validate com.cluesmith.codev.sdPlugin", + "validate": "node scripts/validate-with-retry.mjs", "pack": "streamdeck pack com.cluesmith.codev.sdPlugin --output dist --force", - "package": "node esbuild.js && rm -f com.cluesmith.codev.sdPlugin/bin/plugin.js.map && rm -rf com.cluesmith.codev.sdPlugin/logs && find com.cluesmith.codev.sdPlugin -name .DS_Store -delete && streamdeck validate com.cluesmith.codev.sdPlugin && streamdeck pack com.cluesmith.codev.sdPlugin --output dist --force", + "package": "node esbuild.js && rm -f com.cluesmith.codev.sdPlugin/bin/plugin.js.map && rm -rf com.cluesmith.codev.sdPlugin/logs && find com.cluesmith.codev.sdPlugin -name .DS_Store -delete && node scripts/validate-with-retry.mjs && streamdeck pack com.cluesmith.codev.sdPlugin --output dist --force", "test": "vitest run" }, "dependencies": { diff --git a/apps/streamdeck/scripts/validate-with-retry.mjs b/apps/streamdeck/scripts/validate-with-retry.mjs new file mode 100644 index 000000000..de7cf70ad --- /dev/null +++ b/apps/streamdeck/scripts/validate-with-retry.mjs @@ -0,0 +1,111 @@ +// Run `streamdeck validate` with a bounded retry + backoff around transient network errors (#1436). +// +// WHY: the Elgato CLI's `manifestUrlsExist` validation rule does a live HEAD request to the +// manifest's top-level `URL` (ours is https://github.com/cluesmith/codev). Its catch block turns +// only `ENOTFOUND` into a graceful validation error; ANY other fetch failure (UND_ERR_SOCKET, +// ECONNRESET, "fetch failed", …) is rethrown and crashes the whole `validate` run. That put the +// network on CI's pass/fail path and flaked unrelated PRs (#1432, #1434) with no code defect. +// +// FIX: retry the WHOLE validate command a few times with exponential backoff, but ONLY when the +// failure output matches a transient-network signature. Real validation errors fail fast on the +// first attempt (no masking, no wasted backoff). `--no-update-check` does NOT help here — it only +// gates the separate schema-update fetch, not this URL-reachability probe. +// +// The retry core is exported and unit-tested (see src/__tests__/validate-with-retry.test.ts); +// `main()` only wires it to the real child process. Mirrors scripts/render-action-icons.mjs. + +import { execFile } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const PLUGIN_DIR = 'com.cluesmith.codev.sdPlugin'; + +// Case-insensitive signatures of a transient network failure worth retrying. These are the +// error shapes the CLI rethrows from a failed `fetch(URL, { method: 'HEAD' })`. ENOTFOUND is +// intentionally absent: the CLI reports that as a normal "must be resolvable" validation error, +// which we must NOT retry or mask. +export const TRANSIENT_SIGNATURES = [ + 'UND_ERR_SOCKET', + 'UND_ERR_CONNECT_TIMEOUT', + 'fetch failed', + 'ECONNRESET', + 'ECONNREFUSED', + 'ETIMEDOUT', + 'EAI_AGAIN', + 'socket hang up', + 'network', +]; + +export const DEFAULTS = { attempts: 3, baseBackoffMs: 1000 }; + +/** Does this combined stdout+stderr look like a transient network failure? */ +export function isTransientError(output) { + const haystack = String(output ?? '').toLowerCase(); + return TRANSIENT_SIGNATURES.some((sig) => haystack.includes(sig.toLowerCase())); +} + +const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Bounded retry loop. `run()` resolves to { code, output }. We retry only when a non-zero result + * is transient AND attempts remain; otherwise we return the last result (fail fast on real + * errors, and still surface the failure after exhausting transient retries). Injectable `run`, + * `sleep`, and `log` keep this deterministic under test. + */ +export async function runWithRetry({ + run, + attempts = DEFAULTS.attempts, + baseBackoffMs = DEFAULTS.baseBackoffMs, + isTransient = isTransientError, + sleep = defaultSleep, + log = () => {}, +} = {}) { + let last; + for (let attempt = 1; attempt <= attempts; attempt++) { + last = await run(attempt); + if (last.code === 0) { + return { ...last, attempts: attempt }; + } + const transient = isTransient(last.output); + const hasMore = attempt < attempts; + if (!transient || !hasMore) { + return { ...last, attempts: attempt }; + } + const backoff = baseBackoffMs * 2 ** (attempt - 1); + log( + `streamdeck validate: transient network error on attempt ${attempt}/${attempts}; ` + + `retrying in ${backoff}ms…`, + ); + await sleep(backoff); + } + return { ...last, attempts }; +} + +/** Spawn `streamdeck validate ` once, capturing combined output. */ +function runValidateOnce() { + return new Promise((resolve) => { + const child = execFile( + 'streamdeck', + ['validate', PLUGIN_DIR], + { cwd: join(HERE, '..'), encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }, + (error, stdout, stderr) => { + const output = `${stdout ?? ''}${stderr ?? ''}`; + resolve({ code: error ? (error.code ?? 1) : 0, output }); + }, + ); + // Fall back if the binary can't be spawned at all (resolves via the callback's error). + child.on('error', () => {}); + }); +} + +async function main() { + const result = await runWithRetry({ run: runValidateOnce, log: (m) => console.warn(m) }); + process.stdout.write(result.output); + process.exit(result.code === 0 ? 0 : 1); +} + +// Only run when invoked as a script, not when imported by the test. +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main(); +} diff --git a/apps/streamdeck/src/__tests__/validate-with-retry.test.ts b/apps/streamdeck/src/__tests__/validate-with-retry.test.ts new file mode 100644 index 000000000..3e791c560 --- /dev/null +++ b/apps/streamdeck/src/__tests__/validate-with-retry.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi } from 'vitest'; +// @ts-expect-error — plain ESM build script, no type declarations. +import { + runWithRetry, + isTransientError, + TRANSIENT_SIGNATURES, +} from '../../scripts/validate-with-retry.mjs'; + +/** + * #1436: `streamdeck validate`'s `manifestUrlsExist` rule does a live HEAD request to the + * manifest's `URL`. Anything other than ENOTFOUND (UND_ERR_SOCKET, "fetch failed", …) is rethrown + * and crashes the whole validate run, flaking unrelated PRs' CI. The fix wraps the invocation in a + * bounded retry that retries ONLY transient network failures. These tests pin that contract: they + * fail against a single-shot (no-retry) implementation and pass with the retry loop. + */ + +const ok = { code: 0, output: 'Validation successful.' }; +const socketFail = { code: 1, output: 'validate failed\nTypeError: fetch failed\n UND_ERR_SOCKET' }; +const realFail = { + code: 1, + output: 'manifest.json\n error: Actions must not be empty\n1 error', +}; + +// No real waiting under test. +const noSleep = () => Promise.resolve(); + +describe('isTransientError', () => { + it('matches the observed socket errors', () => { + expect(isTransientError('TypeError: fetch failed\n UND_ERR_SOCKET')).toBe(true); + expect(isTransientError('read ECONNRESET')).toBe(true); + expect(isTransientError('connect ETIMEDOUT')).toBe(true); + }); + + it('does not match a real validation failure or ENOTFOUND', () => { + expect(isTransientError('error: Actions must not be empty')).toBe(false); + // ENOTFOUND is reported by the CLI as a normal "must be resolvable" error, not a crash. + expect(isTransientError('URL must be resolvable (ENOTFOUND)')).toBe(false); + }); + + it('is case-insensitive across every declared signature', () => { + for (const sig of TRANSIENT_SIGNATURES) { + expect(isTransientError(`prefix ${sig.toUpperCase()} suffix`)).toBe(true); + expect(isTransientError(`prefix ${sig.toLowerCase()} suffix`)).toBe(true); + } + }); +}); + +describe('runWithRetry', () => { + it('recovers from a transient failure then a success (fails without retry)', async () => { + const run = vi.fn().mockResolvedValueOnce(socketFail).mockResolvedValueOnce(ok); + const result = await runWithRetry({ run, sleep: noSleep }); + expect(run).toHaveBeenCalledTimes(2); + expect(result.code).toBe(0); + expect(result.attempts).toBe(2); + }); + + it('retries up to the attempt cap on repeated transient failures, then surfaces the failure', async () => { + const run = vi.fn().mockResolvedValue(socketFail); + const result = await runWithRetry({ run, attempts: 3, sleep: noSleep }); + expect(run).toHaveBeenCalledTimes(3); + expect(result.code).toBe(1); + expect(result.attempts).toBe(3); + }); + + it('fails fast on a real validation error without retrying', async () => { + const run = vi.fn().mockResolvedValue(realFail); + const result = await runWithRetry({ run, attempts: 3, sleep: noSleep }); + expect(run).toHaveBeenCalledTimes(1); + expect(result.code).toBe(1); + }); + + it('applies exponential backoff between transient retries', async () => { + const run = vi.fn().mockResolvedValue(socketFail); + const sleep = vi.fn().mockResolvedValue(undefined); + await runWithRetry({ run, attempts: 3, baseBackoffMs: 1000, sleep }); + // Two waits between three attempts: 1000ms then 2000ms. + expect(sleep.mock.calls.map((c) => c[0])).toEqual([1000, 2000]); + }); + + it('returns immediately on first-attempt success', async () => { + const run = vi.fn().mockResolvedValue(ok); + const sleep = vi.fn().mockResolvedValue(undefined); + const result = await runWithRetry({ run, sleep }); + expect(run).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + expect(result.code).toBe(0); + }); +}); diff --git a/codev/state/bugfix-1436_thread.md b/codev/state/bugfix-1436_thread.md new file mode 100644 index 000000000..4ce343b12 --- /dev/null +++ b/codev/state/bugfix-1436_thread.md @@ -0,0 +1,53 @@ +# bugfix-1436 — streamdeck validate CI flake on transient network errors + +Issue #1436. BUGFIX protocol, strict mode. + +## Investigate (iter 1) + +### Root cause (traced from source, not assumed) + +The flake is NOT a schema-update fetch as the issue title guessed. The exact failure +path is the Elgato CLI validation rule `manifestUrlsExist` +(`@elgato/cli` dist, rule from `src/validation/plugin/rules/manifest-urls-exist.ts`): + +```js +const { status } = await fetch(url.value, { method: "HEAD" }); // url = manifest top-level "URL" +... +} catch (err) { + if (err.cause?.code === "ENOTFOUND") { + this.addError(..., "must be resolvable", url); // graceful validation error + } else { + throw err; // <-- ANY other fetch error (UND_ERR_SOCKET, ECONNRESET, ETIMEDOUT, + // "fetch failed") is RETHROWN → crashes `streamdeck validate` → CI job fails + } +} +``` + +Our manifest declares `"URL": "https://github.com/cluesmith/codev"` +(`com.cluesmith.codev.sdPlugin/manifest.json:10`). `streamdeck validate` does a live +HEAD request to that URL every run. A transient socket error (not ENOTFOUND) is +rethrown unhandled and fails the whole validate step — exactly the observed +`UND_ERR_SOCKET` / `fetch failed` on PRs #1432, #1434, with no code defect. + +### Offline fallback verified NOT viable +CLI `--help` empirically checked: `streamdeck validate` has `--no-update-check` +("Disables updating schemas") and `--force-update-check`. But those only gate the +SCHEMA update; they do NOT disable the manifest-URL reachability probe. So offline / +`--no-update-check` would NOT remove this flake. Retry is the correct and only clean fix. +Schemas themselves are bundled locally via `@elgato/schemas`. + +### Fix (architect preference: bounded retry with backoff) +Wrap the `streamdeck validate` invocation in a bounded retry (3 attempts, exp backoff), +retrying ONLY on transient/network error signatures; fail fast on real validation errors. +Place a small testable helper `apps/streamdeck/scripts/validate-with-retry.mjs` (mirrors +the existing `scripts/render-action-icons.mjs` + matching vitest test pattern) and point +the `validate` npm script at it. Both CI workflows call `pnpm validate` +(test.yml:113, sdk-canary.yml:57), so this fixes the flake at both sites. Local `package` +script inlines `streamdeck validate` — swap that one call for the helper too (no script +restructuring). + +Regression test: simulate a transient failure then success against the retry helper; +fails without retry (single-shot throws), passes with it. Real validation errors must +NOT be retried. + +Scope: << 300 LOC. Fits BUGFIX. From e731e82d20fbcd4c5f2532c3d282e4f44ec0177c Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Fri, 14 Aug 2026 10:33:53 +1000 Subject: [PATCH 04/11] chore(porch): bugfix-1436 pr phase-transition --- .../bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml b/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml index a4f682e46..30d655dfd 100644 --- a/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml +++ b/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml @@ -1,7 +1,7 @@ id: bugfix-1436 title: ci-streamdeck-validate-step-fl protocol: bugfix -phase: fix +phase: pr plan_phases: [] current_plan_phase: null gates: @@ -11,4 +11,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-08-14T00:27:39.607Z' -updated_at: '2026-08-14T00:30:37.321Z' +updated_at: '2026-08-14T00:33:53.571Z' From 188732af9d7ff203e7f9fd776c5b5f16aa447a2e Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Fri, 14 Aug 2026 10:40:00 +1000 Subject: [PATCH 05/11] =?UTF-8?q?Fix=20#1436:=20address=20CMAP=20review=20?= =?UTF-8?q?=E2=80=94=20tighten=20transient=20signatures,=20surface=20spawn?= =?UTF-8?q?=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop over-broad 'network' substring (could false-retry a manifest mentioning it); add specific ENETUNREACH/ENETDOWN codes. - Surface the spawn error message when the CLI can't be launched, instead of exiting 1 with empty output. - Clarify the deliberate EAI_AGAIN (retry) vs ENOTFOUND (fail-fast) asymmetry. --- .../scripts/validate-with-retry.mjs | 25 ++++++++++++++----- codev/state/bugfix-1436_thread.md | 25 +++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/apps/streamdeck/scripts/validate-with-retry.mjs b/apps/streamdeck/scripts/validate-with-retry.mjs index de7cf70ad..1ef01885d 100644 --- a/apps/streamdeck/scripts/validate-with-retry.mjs +++ b/apps/streamdeck/scripts/validate-with-retry.mjs @@ -22,9 +22,14 @@ const HERE = dirname(fileURLToPath(import.meta.url)); const PLUGIN_DIR = 'com.cluesmith.codev.sdPlugin'; // Case-insensitive signatures of a transient network failure worth retrying. These are the -// error shapes the CLI rethrows from a failed `fetch(URL, { method: 'HEAD' })`. ENOTFOUND is -// intentionally absent: the CLI reports that as a normal "must be resolvable" validation error, -// which we must NOT retry or mask. +// error shapes the CLI rethrows from a failed `fetch(URL, { method: 'HEAD' })`. Each is a specific +// error code/phrase, not a loose word, so a plugin description that merely mentions "network" +// can't trigger a false retry. +// +// EAI_AGAIN (temporary DNS failure) IS here but ENOTFOUND (permanent "host doesn't exist") is +// NOT — and that asymmetry is deliberate: the CLI already converts ENOTFOUND into a graceful +// "must be resolvable" validation error rather than rethrowing it, so a genuinely bad URL fails +// loudly on attempt 1, while a transient DNS blip retries. export const TRANSIENT_SIGNATURES = [ 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', @@ -33,8 +38,9 @@ export const TRANSIENT_SIGNATURES = [ 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN', + 'ENETUNREACH', + 'ENETDOWN', 'socket hang up', - 'network', ]; export const DEFAULTS = { attempts: 3, baseBackoffMs: 1000 }; @@ -90,11 +96,18 @@ function runValidateOnce() { ['validate', PLUGIN_DIR], { cwd: join(HERE, '..'), encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => { - const output = `${stdout ?? ''}${stderr ?? ''}`; + let output = `${stdout ?? ''}${stderr ?? ''}`; + // A spawn failure (e.g. the CLI isn't on PATH) yields empty stdio; surface the error + // message so CI shows *why* rather than an exit 1 with no diagnostic. ENOENT and the like + // aren't in TRANSIENT_SIGNATURES, so appending it won't trigger a spurious retry. + if (error && output.trim() === '') { + output = `${error.message}\n`; + } resolve({ code: error ? (error.code ?? 1) : 0, output }); }, ); - // Fall back if the binary can't be spawned at all (resolves via the callback's error). + // The execFile callback already receives spawn errors; this handler just prevents an + // unhandled 'error' event from crashing the process before the callback resolves. child.on('error', () => {}); }); } diff --git a/codev/state/bugfix-1436_thread.md b/codev/state/bugfix-1436_thread.md index 4ce343b12..1495b425e 100644 --- a/codev/state/bugfix-1436_thread.md +++ b/codev/state/bugfix-1436_thread.md @@ -51,3 +51,28 @@ fails without retry (single-shot throws), passes with it. Real validation errors NOT be retried. Scope: << 300 LOC. Fits BUGFIX. + +## Fix + PR (iter 1) + +Implemented `apps/streamdeck/scripts/validate-with-retry.mjs` (retry core exported + +unit-tested), wired `package.json` `validate` + inline `package` call to it. 170 +streamdeck tests pass (11 new). Verified end-to-end: happy path exit 0, real error +fails fast (exit 1), transient retries in unit tests, regression test fails without fix. +Committed 84fd5e785. Porch checks (build, tests) passed. PR #1451 opened (Fixes #1436). + +CMAP: first run failed to auto-detect project from worktree ("Multiple projects found"); +re-ran with `--issue 1436 --project-id bugfix-1436` — both resolve PR #1451 correctly. +Awaiting all three verdicts before notifying architect + `porch done` (pr gate). + +## CMAP verdicts + review fixes + +- gemini=APPROVE (HIGH), codex=APPROVE (HIGH), claude=COMMENT (HIGH, non-blocking nits). +- Addressed claude's substantive points: + - Dropped the over-broad `'network'` substring from TRANSIENT_SIGNATURES (could false-retry + a plugin description mentioning "network"); added specific `ENETUNREACH`/`ENETDOWN`. + - Spawn failure now surfaces `error.message` in output instead of exit-1-with-empty-output. + - Sharpened the comment on the deliberate EAI_AGAIN (retry) vs ENOTFOUND (fail-fast) asymmetry. + - Corrected PR body test count: 8 new tests (170 suite total), not 11. +- Left as-is (correct by design): ENOTFOUND exclusion — the CLI already reports it as a graceful + "must be resolvable" error, so we must not retry a genuinely bad URL. +- 170 tests still pass after the fixes. From 02eef029dd808ead0ec00c9bd4b9e5034c032fd0 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Fri, 14 Aug 2026 10:40:33 +1000 Subject: [PATCH 06/11] chore(porch): bugfix-1436 pr gate-requested --- .../bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml b/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml index 30d655dfd..8fb2f91ad 100644 --- a/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml +++ b/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml @@ -7,8 +7,10 @@ current_plan_phase: null gates: pr: status: pending + requested_at: '2026-08-14T00:40:33.414Z' iteration: 1 build_complete: false history: [] started_at: '2026-08-14T00:27:39.607Z' -updated_at: '2026-08-14T00:33:53.571Z' +updated_at: '2026-08-14T00:40:33.415Z' +pr_ready_for_human: true From 4c24ca30fc306887d39dede5f7086fd712f04e16 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Fri, 14 Aug 2026 10:40:49 +1000 Subject: [PATCH 07/11] chore(porch): bugfix-1436 record PR #1451 --- .../bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml b/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml index 8fb2f91ad..fdf011dca 100644 --- a/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml +++ b/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml @@ -12,5 +12,10 @@ iteration: 1 build_complete: false history: [] started_at: '2026-08-14T00:27:39.607Z' -updated_at: '2026-08-14T00:40:33.415Z' +updated_at: '2026-08-14T00:40:49.690Z' pr_ready_for_human: true +pr_history: + - phase: pr + pr_number: 1451 + branch: builder/bugfix-1436 + created_at: '2026-08-14T00:40:49.690Z' From 781fff35ee46555d37141222a965d07edf849e7b Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 15 Aug 2026 12:32:34 +1000 Subject: [PATCH 08/11] Fix #1436: rename helper to retry-validate.mjs Shorter, verb-first name for the validate retry wrapper. --- apps/streamdeck/package.json | 4 ++-- .../scripts/{validate-with-retry.mjs => retry-validate.mjs} | 2 +- .../{validate-with-retry.test.ts => retry-validate.test.ts} | 2 +- codev/state/bugfix-1436_thread.md | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) rename apps/streamdeck/scripts/{validate-with-retry.mjs => retry-validate.mjs} (99%) rename apps/streamdeck/src/__tests__/{validate-with-retry.test.ts => retry-validate.test.ts} (98%) diff --git a/apps/streamdeck/package.json b/apps/streamdeck/package.json index 5200567e8..79d44f418 100644 --- a/apps/streamdeck/package.json +++ b/apps/streamdeck/package.json @@ -8,9 +8,9 @@ "build": "node esbuild.js", "watch": "node esbuild.js --watch", "check-types": "tsc --noEmit", - "validate": "node scripts/validate-with-retry.mjs", + "validate": "node scripts/retry-validate.mjs", "pack": "streamdeck pack com.cluesmith.codev.sdPlugin --output dist --force", - "package": "node esbuild.js && rm -f com.cluesmith.codev.sdPlugin/bin/plugin.js.map && rm -rf com.cluesmith.codev.sdPlugin/logs && find com.cluesmith.codev.sdPlugin -name .DS_Store -delete && node scripts/validate-with-retry.mjs && streamdeck pack com.cluesmith.codev.sdPlugin --output dist --force", + "package": "node esbuild.js && rm -f com.cluesmith.codev.sdPlugin/bin/plugin.js.map && rm -rf com.cluesmith.codev.sdPlugin/logs && find com.cluesmith.codev.sdPlugin -name .DS_Store -delete && node scripts/retry-validate.mjs && streamdeck pack com.cluesmith.codev.sdPlugin --output dist --force", "test": "vitest run" }, "dependencies": { diff --git a/apps/streamdeck/scripts/validate-with-retry.mjs b/apps/streamdeck/scripts/retry-validate.mjs similarity index 99% rename from apps/streamdeck/scripts/validate-with-retry.mjs rename to apps/streamdeck/scripts/retry-validate.mjs index 1ef01885d..761cf9faf 100644 --- a/apps/streamdeck/scripts/validate-with-retry.mjs +++ b/apps/streamdeck/scripts/retry-validate.mjs @@ -11,7 +11,7 @@ // first attempt (no masking, no wasted backoff). `--no-update-check` does NOT help here — it only // gates the separate schema-update fetch, not this URL-reachability probe. // -// The retry core is exported and unit-tested (see src/__tests__/validate-with-retry.test.ts); +// The retry core is exported and unit-tested (see src/__tests__/retry-validate.test.ts); // `main()` only wires it to the real child process. Mirrors scripts/render-action-icons.mjs. import { execFile } from 'node:child_process'; diff --git a/apps/streamdeck/src/__tests__/validate-with-retry.test.ts b/apps/streamdeck/src/__tests__/retry-validate.test.ts similarity index 98% rename from apps/streamdeck/src/__tests__/validate-with-retry.test.ts rename to apps/streamdeck/src/__tests__/retry-validate.test.ts index 3e791c560..e0fdd7f99 100644 --- a/apps/streamdeck/src/__tests__/validate-with-retry.test.ts +++ b/apps/streamdeck/src/__tests__/retry-validate.test.ts @@ -4,7 +4,7 @@ import { runWithRetry, isTransientError, TRANSIENT_SIGNATURES, -} from '../../scripts/validate-with-retry.mjs'; +} from '../../scripts/retry-validate.mjs'; /** * #1436: `streamdeck validate`'s `manifestUrlsExist` rule does a live HEAD request to the diff --git a/codev/state/bugfix-1436_thread.md b/codev/state/bugfix-1436_thread.md index 1495b425e..d1a382d07 100644 --- a/codev/state/bugfix-1436_thread.md +++ b/codev/state/bugfix-1436_thread.md @@ -39,7 +39,7 @@ Schemas themselves are bundled locally via `@elgato/schemas`. ### Fix (architect preference: bounded retry with backoff) Wrap the `streamdeck validate` invocation in a bounded retry (3 attempts, exp backoff), retrying ONLY on transient/network error signatures; fail fast on real validation errors. -Place a small testable helper `apps/streamdeck/scripts/validate-with-retry.mjs` (mirrors +Place a small testable helper `apps/streamdeck/scripts/retry-validate.mjs` (mirrors the existing `scripts/render-action-icons.mjs` + matching vitest test pattern) and point the `validate` npm script at it. Both CI workflows call `pnpm validate` (test.yml:113, sdk-canary.yml:57), so this fixes the flake at both sites. Local `package` @@ -54,7 +54,7 @@ Scope: << 300 LOC. Fits BUGFIX. ## Fix + PR (iter 1) -Implemented `apps/streamdeck/scripts/validate-with-retry.mjs` (retry core exported + +Implemented `apps/streamdeck/scripts/retry-validate.mjs` (retry core exported + unit-tested), wired `package.json` `validate` + inline `package` call to it. 170 streamdeck tests pass (11 new). Verified end-to-end: happy path exit 0, real error fails fast (exit 1), transient retries in unit tests, regression test fails without fix. From 50953ae0c1b4ed83886d7209ea68999568e33c24 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 15 Aug 2026 12:36:09 +1000 Subject: [PATCH 09/11] Fix #1436: rename helper to validate.mjs, drop 'retry' from naming Name the wrapper scripts/validate.mjs and the exported loop runWithBackoff; no 'retry' in file or identifier names. --- apps/streamdeck/package.json | 4 +-- .../{retry-validate.mjs => validate.mjs} | 32 +++++++++---------- ...etry-validate.test.ts => validate.test.ts} | 28 ++++++++-------- codev/state/bugfix-1436_thread.md | 4 +-- 4 files changed, 34 insertions(+), 34 deletions(-) rename apps/streamdeck/scripts/{retry-validate.mjs => validate.mjs} (78%) rename apps/streamdeck/src/__tests__/{retry-validate.test.ts => validate.test.ts} (75%) diff --git a/apps/streamdeck/package.json b/apps/streamdeck/package.json index 79d44f418..f3b4c2195 100644 --- a/apps/streamdeck/package.json +++ b/apps/streamdeck/package.json @@ -8,9 +8,9 @@ "build": "node esbuild.js", "watch": "node esbuild.js --watch", "check-types": "tsc --noEmit", - "validate": "node scripts/retry-validate.mjs", + "validate": "node scripts/validate.mjs", "pack": "streamdeck pack com.cluesmith.codev.sdPlugin --output dist --force", - "package": "node esbuild.js && rm -f com.cluesmith.codev.sdPlugin/bin/plugin.js.map && rm -rf com.cluesmith.codev.sdPlugin/logs && find com.cluesmith.codev.sdPlugin -name .DS_Store -delete && node scripts/retry-validate.mjs && streamdeck pack com.cluesmith.codev.sdPlugin --output dist --force", + "package": "node esbuild.js && rm -f com.cluesmith.codev.sdPlugin/bin/plugin.js.map && rm -rf com.cluesmith.codev.sdPlugin/logs && find com.cluesmith.codev.sdPlugin -name .DS_Store -delete && node scripts/validate.mjs && streamdeck pack com.cluesmith.codev.sdPlugin --output dist --force", "test": "vitest run" }, "dependencies": { diff --git a/apps/streamdeck/scripts/retry-validate.mjs b/apps/streamdeck/scripts/validate.mjs similarity index 78% rename from apps/streamdeck/scripts/retry-validate.mjs rename to apps/streamdeck/scripts/validate.mjs index 761cf9faf..fbb88c5fa 100644 --- a/apps/streamdeck/scripts/retry-validate.mjs +++ b/apps/streamdeck/scripts/validate.mjs @@ -1,4 +1,4 @@ -// Run `streamdeck validate` with a bounded retry + backoff around transient network errors (#1436). +// Run `streamdeck validate` with bounded re-attempts + backoff around transient network errors (#1436). // // WHY: the Elgato CLI's `manifestUrlsExist` validation rule does a live HEAD request to the // manifest's top-level `URL` (ours is https://github.com/cluesmith/codev). Its catch block turns @@ -6,12 +6,12 @@ // ECONNRESET, "fetch failed", …) is rethrown and crashes the whole `validate` run. That put the // network on CI's pass/fail path and flaked unrelated PRs (#1432, #1434) with no code defect. // -// FIX: retry the WHOLE validate command a few times with exponential backoff, but ONLY when the -// failure output matches a transient-network signature. Real validation errors fail fast on the -// first attempt (no masking, no wasted backoff). `--no-update-check` does NOT help here — it only -// gates the separate schema-update fetch, not this URL-reachability probe. +// FIX: run the WHOLE validate command again a few times with exponential backoff, but ONLY when +// the failure output matches a transient-network signature. Real validation errors fail fast on +// the first attempt (no masking, no wasted backoff). `--no-update-check` does NOT help here — it +// only gates the separate schema-update fetch, not this URL-reachability probe. // -// The retry core is exported and unit-tested (see src/__tests__/retry-validate.test.ts); +// The core loop is exported and unit-tested (see src/__tests__/validate.test.ts); // `main()` only wires it to the real child process. Mirrors scripts/render-action-icons.mjs. import { execFile } from 'node:child_process'; @@ -21,15 +21,15 @@ import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const PLUGIN_DIR = 'com.cluesmith.codev.sdPlugin'; -// Case-insensitive signatures of a transient network failure worth retrying. These are the +// Case-insensitive signatures of a transient network failure worth another attempt. These are the // error shapes the CLI rethrows from a failed `fetch(URL, { method: 'HEAD' })`. Each is a specific // error code/phrase, not a loose word, so a plugin description that merely mentions "network" -// can't trigger a false retry. +// can't trigger a false re-run. // // EAI_AGAIN (temporary DNS failure) IS here but ENOTFOUND (permanent "host doesn't exist") is // NOT — and that asymmetry is deliberate: the CLI already converts ENOTFOUND into a graceful // "must be resolvable" validation error rather than rethrowing it, so a genuinely bad URL fails -// loudly on attempt 1, while a transient DNS blip retries. +// loudly on attempt 1, while a transient DNS blip gets another attempt. export const TRANSIENT_SIGNATURES = [ 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', @@ -54,12 +54,12 @@ export function isTransientError(output) { const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); /** - * Bounded retry loop. `run()` resolves to { code, output }. We retry only when a non-zero result - * is transient AND attempts remain; otherwise we return the last result (fail fast on real - * errors, and still surface the failure after exhausting transient retries). Injectable `run`, + * Bounded attempt loop. `run()` resolves to { code, output }. We run again only when a non-zero + * result is transient AND attempts remain; otherwise we return the last result (fail fast on real + * errors, and still surface the failure after exhausting transient attempts). Injectable `run`, * `sleep`, and `log` keep this deterministic under test. */ -export async function runWithRetry({ +export async function runWithBackoff({ run, attempts = DEFAULTS.attempts, baseBackoffMs = DEFAULTS.baseBackoffMs, @@ -81,7 +81,7 @@ export async function runWithRetry({ const backoff = baseBackoffMs * 2 ** (attempt - 1); log( `streamdeck validate: transient network error on attempt ${attempt}/${attempts}; ` + - `retrying in ${backoff}ms…`, + `trying again in ${backoff}ms…`, ); await sleep(backoff); } @@ -99,7 +99,7 @@ function runValidateOnce() { let output = `${stdout ?? ''}${stderr ?? ''}`; // A spawn failure (e.g. the CLI isn't on PATH) yields empty stdio; surface the error // message so CI shows *why* rather than an exit 1 with no diagnostic. ENOENT and the like - // aren't in TRANSIENT_SIGNATURES, so appending it won't trigger a spurious retry. + // aren't in TRANSIENT_SIGNATURES, so appending it won't trigger a spurious re-run. if (error && output.trim() === '') { output = `${error.message}\n`; } @@ -113,7 +113,7 @@ function runValidateOnce() { } async function main() { - const result = await runWithRetry({ run: runValidateOnce, log: (m) => console.warn(m) }); + const result = await runWithBackoff({ run: runValidateOnce, log: (m) => console.warn(m) }); process.stdout.write(result.output); process.exit(result.code === 0 ? 0 : 1); } diff --git a/apps/streamdeck/src/__tests__/retry-validate.test.ts b/apps/streamdeck/src/__tests__/validate.test.ts similarity index 75% rename from apps/streamdeck/src/__tests__/retry-validate.test.ts rename to apps/streamdeck/src/__tests__/validate.test.ts index e0fdd7f99..bf6a618f4 100644 --- a/apps/streamdeck/src/__tests__/retry-validate.test.ts +++ b/apps/streamdeck/src/__tests__/validate.test.ts @@ -1,17 +1,17 @@ import { describe, it, expect, vi } from 'vitest'; // @ts-expect-error — plain ESM build script, no type declarations. import { - runWithRetry, + runWithBackoff, isTransientError, TRANSIENT_SIGNATURES, -} from '../../scripts/retry-validate.mjs'; +} from '../../scripts/validate.mjs'; /** * #1436: `streamdeck validate`'s `manifestUrlsExist` rule does a live HEAD request to the * manifest's `URL`. Anything other than ENOTFOUND (UND_ERR_SOCKET, "fetch failed", …) is rethrown * and crashes the whole validate run, flaking unrelated PRs' CI. The fix wraps the invocation in a - * bounded retry that retries ONLY transient network failures. These tests pin that contract: they - * fail against a single-shot (no-retry) implementation and pass with the retry loop. + * bounded loop that runs again ONLY on transient network failures. These tests pin that contract: + * they fail against a single-shot implementation and pass with the backoff loop. */ const ok = { code: 0, output: 'Validation successful.' }; @@ -45,34 +45,34 @@ describe('isTransientError', () => { }); }); -describe('runWithRetry', () => { - it('recovers from a transient failure then a success (fails without retry)', async () => { +describe('runWithBackoff', () => { + it('recovers from a transient failure then a success (fails with a single attempt)', async () => { const run = vi.fn().mockResolvedValueOnce(socketFail).mockResolvedValueOnce(ok); - const result = await runWithRetry({ run, sleep: noSleep }); + const result = await runWithBackoff({ run, sleep: noSleep }); expect(run).toHaveBeenCalledTimes(2); expect(result.code).toBe(0); expect(result.attempts).toBe(2); }); - it('retries up to the attempt cap on repeated transient failures, then surfaces the failure', async () => { + it('runs up to the attempt cap on repeated transient failures, then surfaces the failure', async () => { const run = vi.fn().mockResolvedValue(socketFail); - const result = await runWithRetry({ run, attempts: 3, sleep: noSleep }); + const result = await runWithBackoff({ run, attempts: 3, sleep: noSleep }); expect(run).toHaveBeenCalledTimes(3); expect(result.code).toBe(1); expect(result.attempts).toBe(3); }); - it('fails fast on a real validation error without retrying', async () => { + it('fails fast on a real validation error without a second attempt', async () => { const run = vi.fn().mockResolvedValue(realFail); - const result = await runWithRetry({ run, attempts: 3, sleep: noSleep }); + const result = await runWithBackoff({ run, attempts: 3, sleep: noSleep }); expect(run).toHaveBeenCalledTimes(1); expect(result.code).toBe(1); }); - it('applies exponential backoff between transient retries', async () => { + it('applies exponential backoff between transient attempts', async () => { const run = vi.fn().mockResolvedValue(socketFail); const sleep = vi.fn().mockResolvedValue(undefined); - await runWithRetry({ run, attempts: 3, baseBackoffMs: 1000, sleep }); + await runWithBackoff({ run, attempts: 3, baseBackoffMs: 1000, sleep }); // Two waits between three attempts: 1000ms then 2000ms. expect(sleep.mock.calls.map((c) => c[0])).toEqual([1000, 2000]); }); @@ -80,7 +80,7 @@ describe('runWithRetry', () => { it('returns immediately on first-attempt success', async () => { const run = vi.fn().mockResolvedValue(ok); const sleep = vi.fn().mockResolvedValue(undefined); - const result = await runWithRetry({ run, sleep }); + const result = await runWithBackoff({ run, sleep }); expect(run).toHaveBeenCalledTimes(1); expect(sleep).not.toHaveBeenCalled(); expect(result.code).toBe(0); diff --git a/codev/state/bugfix-1436_thread.md b/codev/state/bugfix-1436_thread.md index d1a382d07..816b417a0 100644 --- a/codev/state/bugfix-1436_thread.md +++ b/codev/state/bugfix-1436_thread.md @@ -39,7 +39,7 @@ Schemas themselves are bundled locally via `@elgato/schemas`. ### Fix (architect preference: bounded retry with backoff) Wrap the `streamdeck validate` invocation in a bounded retry (3 attempts, exp backoff), retrying ONLY on transient/network error signatures; fail fast on real validation errors. -Place a small testable helper `apps/streamdeck/scripts/retry-validate.mjs` (mirrors +Place a small testable helper `apps/streamdeck/scripts/validate.mjs` (mirrors the existing `scripts/render-action-icons.mjs` + matching vitest test pattern) and point the `validate` npm script at it. Both CI workflows call `pnpm validate` (test.yml:113, sdk-canary.yml:57), so this fixes the flake at both sites. Local `package` @@ -54,7 +54,7 @@ Scope: << 300 LOC. Fits BUGFIX. ## Fix + PR (iter 1) -Implemented `apps/streamdeck/scripts/retry-validate.mjs` (retry core exported + +Implemented `apps/streamdeck/scripts/validate.mjs` (retry core exported + unit-tested), wired `package.json` `validate` + inline `package` call to it. 170 streamdeck tests pass (11 new). Verified end-to-end: happy path exit 0, real error fails fast (exit 1), transient retries in unit tests, regression test fails without fix. From 0455c7b43254877d62dcc114720005d21f08ae5f Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 15 Aug 2026 12:53:15 +1000 Subject: [PATCH 10/11] chore(porch): bugfix-1436 pr gate-approved --- .../bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml b/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml index fdf011dca..e907374eb 100644 --- a/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml +++ b/codev/projects/bugfix-1436-ci-streamdeck-validate-step-fl/status.yaml @@ -6,14 +6,15 @@ plan_phases: [] current_plan_phase: null gates: pr: - status: pending + status: approved requested_at: '2026-08-14T00:40:33.414Z' + approved_at: '2026-08-15T02:53:15.320Z' iteration: 1 build_complete: false history: [] started_at: '2026-08-14T00:27:39.607Z' -updated_at: '2026-08-14T00:40:49.690Z' -pr_ready_for_human: true +updated_at: '2026-08-15T02:53:15.321Z' +pr_ready_for_human: false pr_history: - phase: pr pr_number: 1451 From 6e3c9ecffd68b59b32eeca65e2f182e9384aac8c Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 15 Aug 2026 12:59:15 +1000 Subject: [PATCH 11/11] Fix #1436: type validate.mjs via .d.mts so the test needs no ts-expect-error check-types (tsc, separate from vitest) failed: the multi-line import's @ts-expect-error covered 'import {' while TS7016 landed on the 'from' line, leaving it unsuppressed and the directive unused (TS2578). Fix the cause, not the directive placement: add scripts/validate.d.mts so the NodeNext import resolves with real types, and drop the suppression. The test now gets genuine type-checking of the helper's API. --- apps/streamdeck/scripts/validate.d.mts | 27 +++++++++++++++++++ .../streamdeck/src/__tests__/validate.test.ts | 1 - 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 apps/streamdeck/scripts/validate.d.mts diff --git a/apps/streamdeck/scripts/validate.d.mts b/apps/streamdeck/scripts/validate.d.mts new file mode 100644 index 000000000..30dd686c5 --- /dev/null +++ b/apps/streamdeck/scripts/validate.d.mts @@ -0,0 +1,27 @@ +// Type declarations for validate.mjs so its exported, unit-tested core resolves under tsc +// (NodeNext maps a `./validate.mjs` import to this `./validate.d.mts`) without a suppression. + +/** One run of the validate command: its exit code and combined stdout+stderr. */ +export interface RunResult { + code: number; + output: string; +} + +/** A finished attempt loop: the last run's result plus how many attempts it took. */ +export interface BackoffResult extends RunResult { + attempts: number; +} + +export interface BackoffOptions { + run: (attempt: number) => Promise; + attempts?: number; + baseBackoffMs?: number; + isTransient?: (output: string) => boolean; + sleep?: (ms: number) => Promise; + log?: (message: string) => void; +} + +export const TRANSIENT_SIGNATURES: string[]; +export const DEFAULTS: { attempts: number; baseBackoffMs: number }; +export function isTransientError(output: string): boolean; +export function runWithBackoff(options?: BackoffOptions): Promise; diff --git a/apps/streamdeck/src/__tests__/validate.test.ts b/apps/streamdeck/src/__tests__/validate.test.ts index bf6a618f4..5300b2b61 100644 --- a/apps/streamdeck/src/__tests__/validate.test.ts +++ b/apps/streamdeck/src/__tests__/validate.test.ts @@ -1,5 +1,4 @@ import { describe, it, expect, vi } from 'vitest'; -// @ts-expect-error — plain ESM build script, no type declarations. import { runWithBackoff, isTransientError,