Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ All notable user-visible changes to CASCADE are documented here. The format is l

### Added

- **`authorMode` (own/external/all) extended to the CI-failure and conflict triggers, plus a fork write-access skip** ([MNG-1774](https://linear.app/issue/MNG-1774)). The `authorMode` trigger parameter — previously only on `review` (`scm:check-suite-success` / `scm:pr-opened`) — now also applies to `respond-to-ci` (`scm:check-suite-failure`) and `resolve-conflicts` (`scm:pr-conflict-detected`), surfaced in the metadata-driven trigger UI via the two agent YAMLs. It defaults to `own`, so existing projects are unchanged: `own` restricts dispatch to CASCADE-authored PRs, while `external`/`all` extend it to human-authored **same-repo** PRs. Because both agents *push commits* and CASCADE has no write access to a contributor's fork, a new shared `gateForkWriteAccess` gate turns a fork PR (under `external`/`all`) into a clean, self-explanatory skip (`PR #N head branch lives on fork <repo> — CASCADE has no write access to push fixes`) instead of firing an agent that fails mid-run at `git push`. Fork status comes from a small `getPR`/`PRDetails` extension (`isFork` + `headRepoFullName`). The author-mode logic — previously three near-duplicate copies — is consolidated into a single shared module at `src/triggers/shared/author-mode.ts` that every hard-gated caller (review, respond-to-ci, resolve-conflicts) delegates to. Set it with `cascade projects trigger-set <id> --agent respond-to-ci --event scm:check-suite-failure --enable --params '{"authorMode":"all"}'` (or `--agent resolve-conflicts --event scm:pr-conflict-detected`). **Caution:** `all`/`external` authorize CASCADE to write to human-authored same-repo branches — a conscious opt-in. Closes [MNG-1774](https://linear.app/mongrel/issue/MNG-1774).

- **Dashboard: build a project's worker image from a Dockerfile, with a source selector, live build status, and Rebuild** ([MNG-1725](https://linear.app/issue/MNG-1725), spec 023 plan 5 of 5 — final). The **Worker Image** card in **Project → Settings → General** now lets a **superadmin** choose the image **source** — **Global default**, **Referenced image** (the spec-022 control, unchanged), or **Dockerfile**. For the Dockerfile source, paste **only the extra layers** (RUN / COPY / ENV …) into a textarea and **Set** (calls `projects.update({workerDockerfile})`) — CASCADE supplies the pinned `FROM cascade-worker` base and builds the image router-side. The two override sources are **mutually exclusive**: selecting one hides the other's control, matching the backend invariant. The status display separates the **active image** (`workerImageStatus`: pending / building / verified / failed) from the **most recent build attempt** (`workerImageBuildStatus`: building / failed), so a project running its last-good image while a rebuild fails reads **"Verified … · last rebuild failed: `<reason>`"** rather than a misleading "Failed"; a **Building…** spinner shows for a first build and the card **polls** (`WORKER_IMAGE_POLL_MS`) while `workerImageStatus === 'building' || workerImageBuildStatus === 'building'`. A **Rebuild** button (Dockerfile source only) calls `projects.rebuildWorkerImage` to re-run the build against a refreshed base without editing the content. The whole card stays hidden for non-superadmins. Operator docs (`README.md`, `docs/getting-started.md`) walk through writing extra layers → save → watch build/verify (or read a failure) → rebuild, and call out the mutual exclusivity and the **single-daemon constraint** (a Dockerfile-built image is local to the router that built it). Completes the worker-Dockerfile feature end-to-end across schema, spawn resolution, build engine, set surfaces, and dashboard. Closes [MNG-1725](https://linear.app/mongrel/issue/MNG-1725).

- **Dashboard: set/clear a project's worker image with live verified/pending/failed status** ([MNG-1699](https://linear.app/issue/MNG-1699), spec 022 plan 4 of 4 — final). A **superadmin** can now manage a project's per-project worker image from the dashboard: a new **Worker Image** card in **Project → Settings → General** shows the global default as the input placeholder, accepts a reference (**Set**), reverts to the global default (**Clear**), and reflects the router-side validation lifecycle inline — a **Verifying…** spinner that polls while `pending` (same approach as the run-status pages), a **Verified — pinned to `@sha256:…`** badge once the digest is resolved, or a **Validation failed: `<reason>`** badge naming the missing requirement. The control is wired to the existing `projects.update` mutation (set sends `workerImage`, clear sends `null`) and is hidden entirely for non-superadmins, mirroring the backend gate. Completes the feature end-to-end across CLI, API, and dashboard; the operator walkthrough in `docs/getting-started.md` covers deriving a custom image `FROM` the Cascade worker base, making it available in both the registry-backed and self-hosted/local topologies, setting it from the dashboard, and confirming `verified`. Closes [MNG-1699](https://linear.app/mongrel/issue/MNG-1699).
Expand Down
14 changes: 12 additions & 2 deletions docs/architecture/03-trigger-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,24 @@ function registerBuiltInTriggers(registry: TriggerRegistry): void {
| Handler | Event | Agent |
|---------|-------|-------|
| `CheckSuiteSuccessTrigger` | CI passed | `review` (with `authorMode` param) |
| `CheckSuiteFailureTrigger` | CI failed | `respond-to-ci` |
| `CheckSuiteFailureTrigger` | CI failed | `respond-to-ci` (with `authorMode` param) |
| `PrReviewSubmittedTrigger` | Review with changes_requested | `respond-to-review` |
| `ReviewRequestedTrigger` | Bot requested as reviewer | `review` |
| `PrOpenedTrigger` | PR opened | `review` |
| `PrCommentMentionTrigger` | Bot @mentioned in PR comment | `respond-to-pr-comment` |
| `PrMergedTrigger` | PR merged | PM status update (no agent) |
| `PrReadyToMergeTrigger` | PR approved + checks pass | PM status update (no agent) |
| `PrConflictDetectedTrigger` | Merge conflict on PR | `resolve-conflicts` |
| `PrConflictDetectedTrigger` | Merge conflict on PR | `resolve-conflicts` (with `authorMode` param) |

The `authorMode` parameter (`own` / `external` / `all`, default `own`) filters PRs
by author type for `CheckSuiteSuccessTrigger`, `CheckSuiteFailureTrigger`, and
`PrConflictDetectedTrigger`. `own` restricts dispatch to CASCADE-authored PRs
(the historical behavior); `external`/`all` extend it to human-authored PRs. The
shared evaluator lives in `src/triggers/shared/author-mode.ts`. Because
`respond-to-ci` and `resolve-conflicts` *push commits* and CASCADE has no write
access to a contributor's fork, both apply a **fork write-access skip**
(`gateForkWriteAccess`) — a fork PR under `external`/`all` produces a clean,
self-explanatory skip instead of failing mid-run at `git push`.

### Linear triggers (`src/triggers/linear/`)

Expand Down
11 changes: 11 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,17 @@ node bin/cascade.js projects trigger-set my-project \
node bin/cascade.js projects trigger-set my-project \
--agent respond-to-ci --event scm:check-suite-failure --enable

# respond-to-ci and resolve-conflicts also accept the `authorMode` param
# (own/external/all, default own). `all`/`external` authorize CASCADE to WRITE
# to human-authored (same-repo) branches — a conscious opt-in. Fork PRs are
# always skipped (CASCADE cannot push to a contributor's fork).
node bin/cascade.js projects trigger-set my-project \
--agent respond-to-ci --event scm:check-suite-failure --enable \
--params '{"authorMode":"all"}'
node bin/cascade.js projects trigger-set my-project \
--agent resolve-conflicts --event scm:pr-conflict-detected --enable \
--params '{"authorMode":"all"}'

# Enable respond-to-review when the reviewer requests changes
node bin/cascade.js projects trigger-set my-project \
--agent respond-to-review --event scm:pr-review-submitted --enable
Expand Down
7 changes: 7 additions & 0 deletions src/agents/definitions/resolve-conflicts.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ triggers:
description: Trigger when a PR has merge conflicts with the base branch
defaultEnabled: false
providers: [github]
parameters:
- name: authorMode
type: select
label: Author Filter
description: Filter PRs by author type
options: [own, external, all]
defaultValue: own
contextPipeline: [prContext, directoryListing, contextFiles, workItem]

strategies: {}
Expand Down
7 changes: 7 additions & 0 deletions src/agents/definitions/respond-to-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ triggers:
description: Trigger when CI checks fail
defaultEnabled: false
providers: [github]
parameters:
- name: authorMode
type: select
label: Author Filter
description: Filter PRs by author type
options: [own, external, all]
defaultValue: own
contextPipeline: [prContext, directoryListing, contextFiles, workItem]

strategies: {}
Expand Down
28 changes: 28 additions & 0 deletions src/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ export interface PRDetails {
merged: boolean;
mergeable: boolean | null;
user: { login: string };
/**
* Full name (`owner/repo`) of the PR's head repository, or `null` when the
* head repo is unavailable (deleted fork). Optional so existing `getPR`
* mocks keep type-checking and default to "not a fork".
*/
headRepoFullName?: string | null;
/**
* True when the PR's head branch lives on a different repo than the base
* (a fork). CASCADE cannot push commits to a contributor's fork, so
* write-mode agents (respond-to-ci, resolve-conflicts) skip fork PRs
* cleanly instead of failing at push. Optional and defaults to non-fork.
*/
isFork?: boolean;
}

export interface PRReviewComment {
Expand Down Expand Up @@ -162,6 +175,19 @@ export const githubClient = {
repo,
pull_number: prNumber,
});
// Fork detection. The `null` (deleted fork) vs `undefined` (test mock)
// split below is DELIBERATE — do not collapse them:
// - head.repo === null → the fork was deleted; it is unpushable,
// so treat as a fork (skip write agents).
// - head.repo present → fork iff its full_name differs from base.
// - head.repo === undefined → test-mock payloads without a head repo;
// treat as non-fork so the many existing
// getPR mocks default to "not a fork".
const baseRepoFullName = data.base.repo?.full_name ?? `${owner}/${repo}`;
const headRepo = data.head.repo;
const headRepoFullName = headRepo?.full_name ?? null;
const isFork =
headRepo === null ? true : headRepo ? headRepo.full_name !== baseRepoFullName : false;
return {
number: data.number,
title: data.title,
Expand All @@ -174,6 +200,8 @@ export const githubClient = {
merged: data.merged ?? false,
mergeable: data.mergeable ?? null,
user: { login: data.user?.login || 'unknown' },
headRepoFullName,
isFork,
};
},

Expand Down
67 changes: 24 additions & 43 deletions src/triggers/github/check-suite-decision.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CheckSuiteStatus } from '../../github/client.js';
import { isCascadeBot, type PersonaIdentities } from '../../github/personas.js';
import type { ProjectConfig } from '../../types/index.js';
import { evaluateAuthorMode } from '../shared/author-mode.js';

export type CheckSuiteDecision =
| { action: 'defer'; incompleteChecks: string[]; message: string }
Expand All @@ -10,7 +11,7 @@ export type CheckSuiteDecision =

export type CheckSuiteDecisionMode =
| { kind: 'review'; parameters: Record<string, unknown> }
| { kind: 'respond-to-ci' };
| { kind: 'respond-to-ci'; parameters: Record<string, unknown> };

export interface DecideCheckSuiteOutcomeOptions {
prNumber: number;
Expand All @@ -27,68 +28,48 @@ export interface DecideCheckSuiteAggregateOptions extends DecideCheckSuiteOutcom
}

const FAILURE_CONCLUSIONS = new Set(['failure', 'timed_out', 'action_required']);
const VALID_AUTHOR_MODES = new Set(['own', 'external', 'all']);

function resolveAuthorMode(parameters: Record<string, unknown>): string {
const rawMode = parameters.authorMode;
return typeof rawMode === 'string' && VALID_AUTHOR_MODES.has(rawMode) ? rawMode : 'own';
}

/**
* Thin wrapper over the shared `evaluateAuthorMode`, adapting its result into a
* check-suite `skip` decision. Preserves the established skip-message text
* (including the `isCascadePR=` suffix) so webhook decision reasons are stable.
*/
function authorModeDecision(
prAuthorLogin: string,
personaIdentities: PersonaIdentities | undefined,
parameters: Record<string, unknown>,
prNumber: number,
handlerName: string,
): Extract<CheckSuiteDecision, { action: 'skip' }> | null {
if (!personaIdentities) {
const result = evaluateAuthorMode(prAuthorLogin, personaIdentities, parameters, handlerName);
if (!result) {
return {
action: 'skip',
message: 'Cascade persona identities could not be resolved (token / GitHub API issue)',
};
}

const authorMode = resolveAuthorMode(parameters);
const isCascadePR = isCascadeBot(prAuthorLogin, personaIdentities);
const shouldTrigger =
authorMode === 'all' ||
(authorMode === 'own' && isCascadePR) ||
(authorMode === 'external' && !isCascadePR);

if (shouldTrigger) return null;

if (result.shouldTrigger) return null;
return {
action: 'skip',
message: `PR #${prNumber} author ${prAuthorLogin} does not match configured authorMode '${authorMode}' (isCascadePR=${isCascadePR})`,
};
}

function cascadePersonaDecision(
prAuthorLogin: string,
personaIdentities: PersonaIdentities | undefined,
prNumber: number,
): Extract<CheckSuiteDecision, { action: 'skip' }> | null {
if (!personaIdentities) {
return {
action: 'skip',
message: 'Cascade persona identities could not be resolved (token / GitHub API issue)',
};
}
if (isCascadeBot(prAuthorLogin, personaIdentities)) return null;
return {
action: 'skip',
message: `PR #${prNumber} not authored by a cascade persona (author: ${prAuthorLogin})`,
message: `PR #${prNumber} author ${prAuthorLogin} does not match configured authorMode '${result.authorMode}' (isCascadePR=${result.isCascadePR})`,
};
}

export function decideCheckSuiteGates(
options: DecideCheckSuiteOutcomeOptions,
): Extract<CheckSuiteDecision, { action: 'skip' }> | null {
const { prNumber, prAuthorLogin, prBaseRef, project, personaIdentities, mode } = options;

const authorSkip =
mode.kind === 'review'
? authorModeDecision(prAuthorLogin, personaIdentities, mode.parameters, prNumber)
: cascadePersonaDecision(prAuthorLogin, personaIdentities, prNumber);
const { prNumber, prAuthorLogin, prBaseRef, project, personaIdentities, handlerName, mode } =
options;

// Both `review` and `respond-to-ci` modes now carry authorMode parameters
// and route through the shared author-mode evaluator (MNG-1774).
const authorSkip = authorModeDecision(
prAuthorLogin,
personaIdentities,
mode.parameters,
prNumber,
handlerName,
);
if (authorSkip) return authorSkip;

// Bug 2 (2026-05-11 prod incident on ucho PR #393, MNG-691):
Expand Down
53 changes: 28 additions & 25 deletions src/triggers/github/check-suite-failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ import { githubClient } from '../../github/client.js';
import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js';
import { logger } from '../../utils/logging.js';
import { parseRepoFullName } from '../../utils/repo.js';
import { gateCascadePersona, requirePersonaIdentities } from '../shared/gates.js';
import { buildDeferredRecheckResult } from '../shared/result-builders.js';
import { skip } from '../shared/skip.js';
import { checkTriggerEnabled } from '../shared/trigger-check.js';
import { decideCheckSuiteOutcome } from './check-suite-decision.js';
import { checkTriggerEnabledWithParams } from '../shared/trigger-check.js';
import { decideCheckSuiteGates, decideCheckSuiteOutcome } from './check-suite-decision.js';
import { resolveCheckSuitePRNumber } from './pr-resolution.js';
import { dispatchRespondToCi, resetFixAttempts } from './respond-to-ci-dispatch.js';
import { type GitHubCheckSuitePayload, isGitHubCheckSuitePayload } from './types.js';
Expand Down Expand Up @@ -40,14 +39,14 @@ export class CheckSuiteFailureTrigger implements TriggerHandler {
// Disabled-at-config returns null so the registry's first-match loop
// continues to the next matcher — see `src/triggers/shared/trigger-check.ts`
// for the disabled-shadowing contract.
if (
!(await checkTriggerEnabled(
ctx.project.id,
'respond-to-ci',
'scm:check-suite-failure',
this.name,
))
) {
// Check trigger config + get parameters (authorMode) in a single DB call.
const triggerConfig = await checkTriggerEnabledWithParams(
ctx.project.id,
'respond-to-ci',
'scm:check-suite-failure',
this.name,
);
if (!triggerConfig.enabled) {
return null;
}

Expand All @@ -72,20 +71,24 @@ export class CheckSuiteFailureTrigger implements TriggerHandler {
// Fetch PR details
const prDetails = await githubClient.getPR(owner, repo, prNumber);

const personasResult = requirePersonaIdentities(ctx.personaIdentities, prNumber, this.name);
if (!personasResult.ok) return personasResult.skip;

// Cascade-authored PRs bypass the base-branch gate — a cascade PR
// targeting a non-base branch is a stacked PR, not a drive-by.
// Non-cascade authors are filtered here and never reach the gate.
// Mirrors the authorIsCascade bypass in decideCheckSuiteGates (lines 101-109).
const cascadePersonaSkip = gateCascadePersona(
prDetails.user.login,
// Author-mode + base-branch gate BEFORE the checks API call (preserves
// the pre-API skip; mirrors check-suite-success). Handles the missing-
// personaIdentities case internally, so no separate requirePersonaIdentities
// call is needed here. `own` (default) filters to cascade-authored PRs;
// `external`/`all` now dispatch respond-to-ci on human same-repo PRs.
const mode = { kind: 'respond-to-ci', parameters: triggerConfig.parameters } as const;
const gateSkip = decideCheckSuiteGates({
prNumber,
personasResult.value,
this.name,
);
if (cascadePersonaSkip) return cascadePersonaSkip;
prAuthorLogin: prDetails.user.login,
prBaseRef: prDetails.baseRef,
project: ctx.project,
personaIdentities: ctx.personaIdentities,
handlerName: this.name,
mode,
});
if (gateSkip) {
return skip(this.name, gateSkip.message);
}

// Resolve work item from DB
const workItemId = await resolveWorkItemId(ctx.project.id, prNumber);
Expand All @@ -100,7 +103,7 @@ export class CheckSuiteFailureTrigger implements TriggerHandler {
project: ctx.project,
personaIdentities: ctx.personaIdentities,
handlerName: this.name,
mode: { kind: 'respond-to-ci' },
mode,
});

if (decision.action === 'defer') {
Expand Down
Loading
Loading