diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 91eba29..50a61dd 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -224,9 +224,10 @@ export class StepFailed extends Schema.TaggedError()("StepFailed", { }) {} /** - * The run could not do its job for a CAPACITY reason and is bowing out — the - * work was never attempted-and-failed, it was impossible to attempt (e.g. - * pr-review's diff exceeds the model's context window even after truncation). + * The run could not do its job and is bowing out — the work was never + * attempted-and-failed, it was impossible to attempt: a CAPACITY reason (e.g. + * pr-review's diff exceeds the model's context window even after truncation) + * or a repo that never opted in (offload-test with no configured command). * The dispatcher concludes the check-run `neutral` with `reason` in the * summary instead of `failure`: a review that didn't happen is not a failed * review, and a red that isn't actionable trains people to ignore the check. diff --git a/runs/offload-test.test.ts b/runs/offload-test.test.ts index 9adadbb..db6955d 100644 --- a/runs/offload-test.test.ts +++ b/runs/offload-test.test.ts @@ -517,23 +517,56 @@ describe("offload-test", () => { ); it.effect( - "command resolution — no `command` and no CONFIG_KV fails fast with StepFailed before checkout", + "command resolution — a webhook dispatch with no CONFIG_KV command skips with RunSkipped (neutral) before checkout", () => { const { layer, handles } = makeCFRuntimeTest({ sandboxProgram: { "pnpm test": { exitCode: 0 } }, // no `config` seed — neither command key resolves. }); + // Webhook-shaped: the trigger never passes `command`. const input = { repo: "owner/name", sha: "abc123", secrets: [] as readonly string[], install: false, - failOnNonZeroExit: false, + failOnNonZeroExit: true, }; return Effect.gen(function* () { const exit = yield* Effect.exit(offloadTest.run(input)); expect(Exit.isFailure(exit)).toBe(true); + const failure = Exit.isFailure(exit) + ? Option.getOrUndefined(Cause.failureOption(exit.cause)) + : undefined; + expect((failure as { _tag?: string })?._tag).toBe("RunSkipped"); + // The neutral check names the key that opts the repo in. + expect((failure as { reason?: string })?.reason).toContain( + "offload-test.command:owner/name", + ); + // Skipped before any work: never cloned, never exec'd. + expect(handles.sandbox.clones).toHaveLength(0); + expect(handles.sandbox.execs).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "command resolution — an Action dispatch passing an empty `command` fails fast with StepFailed", + () => { + const { layer, handles } = makeCFRuntimeTest({ + sandboxProgram: { "pnpm test": { exitCode: 0 } }, + }); + const input = { + repo: "owner/name", + sha: "abc123", + command: " ", + secrets: [] as readonly string[], + install: false, + failOnNonZeroExit: false, + }; + + return Effect.gen(function* () { + const exit = yield* Effect.exit(offloadTest.run(input)); const tag = Exit.isFailure(exit) ? Option.match(Cause.failureOption(exit.cause), { onSome: (f) => (f as { _tag?: string })._tag, @@ -541,7 +574,6 @@ describe("offload-test", () => { }) : undefined; expect(tag).toBe("StepFailed"); - // Fail-fast: never cloned, never exec'd. expect(handles.sandbox.clones).toHaveLength(0); expect(handles.sandbox.execs).toHaveLength(0); }).pipe(Effect.provide(layer)); diff --git a/runs/offload-test.ts b/runs/offload-test.ts index 8635bef..1dd834e 100644 --- a/runs/offload-test.ts +++ b/runs/offload-test.ts @@ -152,6 +152,7 @@ import { config, defineRun, io, + RunSkipped, sandbox, spawnChildRun, StepFailed, @@ -463,8 +464,9 @@ export const offloadTest = defineRun({ // `offload-test.command`). This step runs ONLY when the dispatch carried // no command, so Action-mode dispatches keep the exact // `checkout → exec → upload-log` step shape (the `??` short-circuits before - // the `yield*`). A command missing everywhere fails fast — running an empty - // command would post a meaningless green check. + // the `yield*`). A command missing everywhere never runs — an empty + // command would post a meaningless green check; the run skips instead + // (neutral check, see below). // `install` / `timeoutSec` are resolved INSIDE this same step, not a // second one. They are a webhook-mode concern, and webhook mode is // exactly the case that omits `command` — so folding them in here means @@ -589,6 +591,21 @@ export const offloadTest = defineRun({ // resolved command (the step above fails otherwise), and the unlabelled // command may legitimately be absent. if (stages === undefined && (command === undefined || command.trim().length === 0)) { + // A webhook dispatch reaches every repo the App is installed on, and a + // repo that never set a command has not opted in — its tests may run + // somewhere else entirely. That is a skip (neutral check naming the key + // to set), not a failure: a red that no PR author can act on trains + // people to ignore the check. An Action dispatch chose this run and + // passed an empty command, which is a broken caller, so it stays red. + if (input.command === undefined) { + return yield* Effect.fail( + new RunSkipped({ + reason: + `no offload-test command is configured for ${input.repo} — set CONFIG_KV ` + + `\`${repoCommandKey(input.repo)}\` or \`${COMMAND_KEY}\` to run it`, + }), + ); + } return yield* Effect.fail( new StepFailed({ step: "resolve-command",