From ae0214b4e9b27c78c3c6a416afc648b276477ad6 Mon Sep 17 00:00:00 2001 From: RandomFish227 Date: Thu, 27 Aug 2026 14:26:29 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(forge):=20GitLab=20plan-run=20guard=20?= =?UTF-8?q?=E2=80=94=20autoMerge=20capability=20+=20dispatch=20refusal=20(?= =?UTF-8?q?warren-3e09)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ForgeCapabilities.autoMerge (true for GitHub/Fake, false for GitLab) and refuse plan-run dispatch up front when the forge cannot self-merge PRs, rather than silently timing out at parent_pr_merge_timeout with no cause named. Also: - Fix registry.ts doc: no chain walker exists; a null parseRepoRef means "this process's forge does not own the URL" - Update gitlab/provider.ts doc: PLAN-RUN LIMITATION reflects the new guard; adds NESTED-GROUP LAYOUT COLLISION note (fail-loud, tracked separately) - Add test pinning GitLab 409 duplicate recovery against a locked MR - ForgeCannotAutoMergeError → HTTP 424 (unsupported family) Co-Authored-By: Claude Sonnet 4.6 --- src/forge/contract.ts | 13 +++++++ src/forge/fake/fake-forge.test.ts | 1 + src/forge/fake/fake-forge.ts | 3 ++ src/forge/github-app/provider.test.ts | 1 + src/forge/github-app/provider.ts | 1 + src/forge/github/provider.test.ts | 1 + src/forge/github/provider.ts | 1 + src/forge/gitlab/merge-requests.test.ts | 52 ++++++++++++++++++++++++- src/forge/gitlab/provider.test.ts | 17 ++++++++ src/forge/gitlab/provider.ts | 32 ++++++++++----- src/forge/registry.ts | 11 +++--- src/plan-runs/create.test.ts | 36 ++++++++++++++++- src/plan-runs/create.ts | 30 +++++++++++++- src/plan-runs/errors.ts | 15 +++++++ src/server/errors.ts | 9 ++++- src/server/handlers/plan-runs.ts | 2 + 16 files changed, 206 insertions(+), 19 deletions(-) diff --git a/src/forge/contract.ts b/src/forge/contract.ts index 0dbcf6cc6..72fbaceeb 100644 --- a/src/forge/contract.ts +++ b/src/forge/contract.ts @@ -165,6 +165,19 @@ export interface ForgeCapabilities { botIdentity: boolean; /** drives the §4 re-mint: "static" skips it, "short-lived" requires it */ credentialLifetime: "static" | "short-lived"; + /** + * The forge's ecosystem can merge pull requests without a seam call — + * GitHub's auto-merge workflow is the canonical example. A plan-run gates + * each child on the previous PR merging; a forge with `autoMerge: false` + * has no such mechanism, so nothing would transition the PR and the + * coordinator would wait until `parent_pr_merge_timeout`. Dispatch is + * refused up front rather than silently timing out. Single runs are + * unaffected — they only need push + openPullRequest, both of which a + * GitLab forge provides. (GitLabForge: false; GitHub*: true; FakeForge: + * true — the store transitions PRs itself and acceptance scenarios depend + * on that.) + */ + autoMerge: boolean; } /** diff --git a/src/forge/fake/fake-forge.test.ts b/src/forge/fake/fake-forge.test.ts index 9e9c83a11..1f445fe60 100644 --- a/src/forge/fake/fake-forge.test.ts +++ b/src/forge/fake/fake-forge.test.ts @@ -55,6 +55,7 @@ describe("FakeForge capabilities", () => { branchDelete: true, botIdentity: true, credentialLifetime: "static", + autoMerge: true, }); }); }); diff --git a/src/forge/fake/fake-forge.ts b/src/forge/fake/fake-forge.ts index 9149dea5e..436352753 100644 --- a/src/forge/fake/fake-forge.ts +++ b/src/forge/fake/fake-forge.ts @@ -64,6 +64,9 @@ export class FakeForge implements Forge { branchDelete: true, botIdentity: true, credentialLifetime: "static", + // The store transitions PRs itself (markMerged), so the plan-run + // coordinator's merge poll resolves and acceptance scenarios work. + autoMerge: true, }; /** Exposed for the seeding seams; never serialized across the seam. */ diff --git a/src/forge/github-app/provider.test.ts b/src/forge/github-app/provider.test.ts index a439e652d..26b579b70 100644 --- a/src/forge/github-app/provider.test.ts +++ b/src/forge/github-app/provider.test.ts @@ -86,6 +86,7 @@ describe("GitHubAppForge capabilities (forge-contract.md §5)", () => { branchDelete: true, botIdentity: true, credentialLifetime: "short-lived", + autoMerge: true, }); }); }); diff --git a/src/forge/github-app/provider.ts b/src/forge/github-app/provider.ts index b01173896..e4e7ec4df 100644 --- a/src/forge/github-app/provider.ts +++ b/src/forge/github-app/provider.ts @@ -107,6 +107,7 @@ export class GitHubAppForge implements Forge { branchDelete: true, botIdentity: true, credentialLifetime: "short-lived", + autoMerge: true, }; private readonly appId: string; diff --git a/src/forge/github/provider.test.ts b/src/forge/github/provider.test.ts index fc178daf7..ef5705531 100644 --- a/src/forge/github/provider.test.ts +++ b/src/forge/github/provider.test.ts @@ -43,6 +43,7 @@ describe("GitHubForge capabilities", () => { branchDelete: true, botIdentity: false, credentialLifetime: "static", + autoMerge: true, }); }); diff --git a/src/forge/github/provider.ts b/src/forge/github/provider.ts index bdd8b81bf..d7416e91a 100644 --- a/src/forge/github/provider.ts +++ b/src/forge/github/provider.ts @@ -151,6 +151,7 @@ export class GitHubForge implements Forge { // names commits via WARREN_GIT_AUTHOR_* until App mode ships. botIdentity: false, credentialLifetime: "static", + autoMerge: true, }; } diff --git a/src/forge/gitlab/merge-requests.test.ts b/src/forge/gitlab/merge-requests.test.ts index 85380b65b..ba50812ae 100644 --- a/src/forge/gitlab/merge-requests.test.ts +++ b/src/forge/gitlab/merge-requests.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { toForgeError, toPullRequestRef, toPullRequestState } from "./merge-requests.ts"; +import { jsonResponse, recordingFetch } from "../http/test-helpers.ts"; +import type { GitLabCallContext } from "./merge-requests.ts"; +import { + createMergeRequest, + toForgeError, + toPullRequestRef, + toPullRequestState, +} from "./merge-requests.ts"; describe("toPullRequestRef", () => { test("uses iid — the per-project number — and never the global id", () => { @@ -87,6 +94,49 @@ describe("toPullRequestState", () => { }); }); +describe("createMergeRequest — 409 duplicate recovery", () => { + const BASE_CTX: GitLabCallContext = { + apiBase: "https://gitlab.example.com/api/v4", + projectPath: "group/project", + token: "glpat-x", + userAgent: "warren-forge-gitlab", + fetch: globalThis.fetch, + }; + + const LOCKED_MR = { + iid: 7, + state: "locked", + web_url: "https://gitlab.example.com/group/project/-/merge_requests/7", + source_branch: "warren/run_1", + target_branch: "main", + sha: "deadbeef", + }; + + test("409 recovery finds a LOCKED MR — GitLab locks an MR while a merge is in flight", async () => { + // GitLab flips an MR to `locked` while a merge is in flight. A + // re-dispatch racing that window hits 409 (duplicate source branch) + // and the recovery path calls findMergeRequest. matchesQueryState + // treats `locked` as `open`, so the recovery resolves to the locked MR + // rather than returning null and surfacing a conflict error. + const { fetch } = recordingFetch([ + jsonResponse(409, { message: "Another open merge request already exists" }), + jsonResponse(200, [LOCKED_MR]), + ]); + const ctx = { ...BASE_CTX, fetch }; + const result = await createMergeRequest(ctx, { + title: "warren: run_1", + body: "", + headBranch: "warren/run_1", + baseBranch: "main", + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.number).toBe(7); + expect(result.value.key).toBe("group/project!7"); + } + }); +}); + describe("toForgeError", () => { test("renames the transport kind and carries the status as detail", () => { expect( diff --git a/src/forge/gitlab/provider.test.ts b/src/forge/gitlab/provider.test.ts index b44cdba85..c6122e124 100644 --- a/src/forge/gitlab/provider.test.ts +++ b/src/forge/gitlab/provider.test.ts @@ -63,6 +63,23 @@ describe("GitLabForge capabilities", () => { expect(f.capabilities.botIdentity).toBe(false); expect(f.capabilities.credentialLifetime).toBe("static"); }); + + test("autoMerge is false — plan-runs are refused at dispatch; single-run PR ops are unaffected (warren-3e09)", async () => { + // The forge has no GitHub-style auto-merge workflow. The plan-run + // coordinator would poll forever, so createPlanRun refuses the dispatch. + // Single-run operations (parseRepoRef, openPullRequest) are not gated. + const { forge: f } = forge([jsonResponse(201, MR_JSON)]); + expect(f.capabilities.autoMerge).toBe(false); + // openPullRequest is the single-run PR seam — it must keep working. + const r = ref(f); + const result = await f.openPullRequest(r, { + title: "warren: run_1", + body: "", + headBranch: "warren/run_1", + baseBranch: "main", + }); + expect(result.ok).toBe(true); + }); }); describe("GitLabForge.parseRepoRef", () => { diff --git a/src/forge/gitlab/provider.ts b/src/forge/gitlab/provider.ts index a525b0520..09e29f8a2 100644 --- a/src/forge/gitlab/provider.ts +++ b/src/forge/gitlab/provider.ts @@ -22,15 +22,26 @@ * API (§6.7). The domain degrades per §5 either way: the CI-fixer poller stays * idle and the trigger emits one notice per project. * - * PLAN-RUN LIMITATION: this provider supports SINGLE runs. A plan-run gates - * each child on the previous PR merging, and warren performs that merge - * through GitHub's auto-merge workflow rather than through the seam — which is - * why the contract deliberately has no `mergePullRequest`. A GitLab project - * has no such workflow, so nothing transitions the MR and the plan-run waits - * to `parent_pr_merge_timeout`. Closing this needs either a GitLab - * merge-when-pipeline-succeeds call behind a new capability flag, or a - * dispatch-time guard that refuses a plan-run on a forge that cannot - * self-merge. Tracked on warren-75e8. + * PLAN-RUN LIMITATION: this provider supports SINGLE runs only. + * `capabilities.autoMerge` is false here. A plan-run gates each child on the + * previous PR merging; warren performs that merge through GitHub's auto-merge + * workflow, not through the seam (which is why the contract deliberately has + * no `mergePullRequest`). A GitLab project has no such workflow, so dispatch + * is refused up front with `ForgeCannotAutoMergeError` (HTTP 424) rather than + * accepted and left to time out at `parent_pr_merge_timeout`. Filling this gap + * needs a GitLab merge-when-pipeline-succeeds call added here and + * `autoMerge` flipped to true. Tracked on warren-75e8. + * + * NESTED-GROUP LAYOUT COLLISION (DO NOT FIX HERE): a project clones to + * `//`, and `parseForgeOwnedUrl` + * (`src/projects/url.ts`) derives those two segments from the LAST TWO path + * segments of the clone URL. GitLab paths are N segments deep, so + * `group-a/sub/project` and `group-b/sub/project` both lay out as + * `sub/project` and would collide. The collision fails loudly + * (`cloneProjectRepo` refuses an existing target path, `clone.ts:156`), so no + * data is lost, but the error names a path that mentions neither group. Fixing + * this needs the seam to expose a ref's layout path — a Forge contract change + * tracked separately. */ import type { @@ -181,6 +192,9 @@ export class GitLabForge implements Forge { // The token authorizes; it does not name the author (§6.8). botIdentity: false, credentialLifetime: "static", + // GitLab has no GitHub-style auto-merge workflow — see PLAN-RUN + // LIMITATION above. Single runs are unaffected. + autoMerge: false, }; } diff --git a/src/forge/registry.ts b/src/forge/registry.ts index 44ceec9d1..bebd8bd02 100644 --- a/src/forge/registry.ts +++ b/src/forge/registry.ts @@ -29,12 +29,11 @@ * back to the default, so a typo can't route runs onto the wrong forge). * * Registry CONSTRUCTION only: threading the resolved instance through boot - * wiring and `ServerDeps` is the next step (warren-6c4c). `parseRepoRef` - * chaining operates over the boot-registered forges in their fixed - * registration order (§1.1). The selector still resolves exactly ONE forge - * per process, so the chain has length one whichever kind is chosen; running - * GitHub and GitLab projects on a single instance needs the multi-instance - * config surface, not a second entry here (warren-f012). + * wiring and `ServerDeps` is the next step (warren-6c4c). The selector + * resolves exactly ONE forge per process. `parseRepoRef` returning null + * therefore means "this process's forge does not own the URL" — no chain + * walker exists. Running GitHub and GitLab projects on a single instance + * needs the multi-instance config surface, not a second entry here (warren-f012). */ import type { Forge } from "./contract.ts"; diff --git a/src/plan-runs/create.test.ts b/src/plan-runs/create.test.ts index ce8d80e49..bceca33e7 100644 --- a/src/plan-runs/create.test.ts +++ b/src/plan-runs/create.test.ts @@ -11,11 +11,16 @@ import type { } from "../core/wire.ts"; import { openDatabase, type WarrenDb } from "../db/client.ts"; import { createRepos, type Repos } from "../db/repos/index.ts"; +import type { Forge } from "../forge/contract.ts"; import type { SpawnFn, SpawnOptions, SpawnResult } from "../projects/clone.ts"; import type { IssueTracker, PlanCapableTracker } from "../tracker/contract.ts"; import { SeedsTracker } from "../tracker/seeds-tracker.ts"; import { createPlanRun } from "./create.ts"; -import { PlanHasNoOpenChildrenError, ProjectLacksTrackerError } from "./errors.ts"; +import { + ForgeCannotAutoMergeError, + PlanHasNoOpenChildrenError, + ProjectLacksTrackerError, +} from "./errors.ts"; /* ----------------------------------------------------------------------- */ /* Stubs (mirror the seeds CLI's wire envelopes without shelling out) */ @@ -333,6 +338,35 @@ describe("createPlanRun", () => { }), ).rejects.toThrow(); }); + + /* ----------- forge autoMerge gate (warren-3e09) ----------- */ + + test("refuses plan-run dispatch when the forge cannot auto-merge (warren-3e09)", async () => { + // A forge with autoMerge:false (e.g. GitLabForge) is refused at dispatch + // rather than accepted and left to time out at parent_pr_merge_timeout. + const noAutoMergeForge = { + capabilities: { autoMerge: false }, + } as unknown as Forge; + await expect(createPlanRun({ ...baseInput(), forge: noAutoMergeForge })).rejects.toBeInstanceOf( + ForgeCannotAutoMergeError, + ); + }); + + test("single runs are unaffected — autoMerge gate only fires in createPlanRun (warren-3e09)", async () => { + // A forge with autoMerge:false does NOT block a plan-run when the forge + // field is absent (backward compat / test paths). The single-run path + // (spawnRun) never calls createPlanRun, so it is structurally unaffected. + const result = await createPlanRun({ ...baseInput() }); + expect(result.planRun.state).toBe("queued"); + }); + + test("plan-run is accepted when forge supports auto-merge (warren-3e09)", async () => { + const autoMergeForge = { + capabilities: { autoMerge: true }, + } as unknown as Forge; + const result = await createPlanRun({ ...baseInput(), forge: autoMergeForge }); + expect(result.planRun.state).toBe("queued"); + }); }); /** Fake hosted (non-git-native) tracker double for the domain tests (warren-2d98). */ diff --git a/src/plan-runs/create.ts b/src/plan-runs/create.ts index 30332385f..2ec7e7412 100644 --- a/src/plan-runs/create.ts +++ b/src/plan-runs/create.ts @@ -34,13 +34,18 @@ import type { PlanRunSource, PlanStatus } from "../core/wire.ts"; import type { Repos } from "../db/repos/index.ts"; import type { CreatePlanRunResult } from "../db/repos/plan-runs.ts"; import type { ProjectRow } from "../db/schema.ts"; +import type { Forge } from "../forge/contract.ts"; import type { SpawnFn } from "../projects/clone.ts"; import type { ProjectsConfig } from "../projects/config.ts"; import { refreshProject } from "../projects/index.ts"; import type { IssueTracker, PlanCapableTracker, TrackerContext } from "../tracker/contract.ts"; import type { WarrenConfigCache } from "../warren-config/index.ts"; import type { GitSpawnCredential } from "../workspace/git/credential-env.ts"; -import { PlanHasNoOpenChildrenError, ProjectLacksTrackerError } from "./errors.ts"; +import { + ForgeCannotAutoMergeError, + PlanHasNoOpenChildrenError, + ProjectLacksTrackerError, +} from "./errors.ts"; export const PLAN_RUN_ACCEPTED_PLAN_STATUSES: readonly PlanStatus[] = [ "approved", @@ -81,6 +86,13 @@ export interface CreatePlanRunOrchestrationInput { readonly repos: Repos; /** Undefined ⇒ ValidationError — plan-runs require an issue tracker. */ readonly issueTracker: IssueTracker | undefined; + /** + * Boot-resolved forge (warren-3e09). When provided, `createPlanRun` + * checks `capabilities.autoMerge` and refuses the dispatch if false — + * plan-runs require a forge whose ecosystem can merge PRs automatically. + * Omitting it (legacy or test paths) skips the gate. + */ + readonly forge?: Forge; /** Git spawn seam. When wired, the host clone is refreshed before the plan walk (warren-6d60). */ readonly spawn?: SpawnFn; readonly projectsConfig: ProjectsConfig; @@ -289,6 +301,22 @@ async function requireTrackedProject( export async function createPlanRun( input: CreatePlanRunOrchestrationInput, ): Promise { + // warren-3e09: refuse up front if the forge cannot self-merge PRs. + // Plan-runs gate each child on the previous PR merging via the forge's + // ecosystem auto-merge mechanism (e.g. GitHub Actions). A forge with + // autoMerge:false has no such mechanism and would silently time out. + if (input.forge !== undefined && !input.forge.capabilities.autoMerge) { + throw new ForgeCannotAutoMergeError( + "plan-runs require a forge whose ecosystem can auto-merge pull requests; " + + "the configured forge reports autoMerge:false — dispatch refused to avoid a silent timeout", + { + recoveryHint: + "use a GitHub project (WARREN_FORGE=github or WARREN_FORGE=app), or wait for " + + "GitLab merge-when-pipeline-succeeds support (warren-75e8)", + }, + ); + } + if (input.promptTemplate !== undefined) assertPlanRunPromptTemplate(input.promptTemplate); const { project, tracker } = await requireTrackedProject(input); diff --git a/src/plan-runs/errors.ts b/src/plan-runs/errors.ts index a79adaa17..4a67c3ef7 100644 --- a/src/plan-runs/errors.ts +++ b/src/plan-runs/errors.ts @@ -33,3 +33,18 @@ export class ProjectLacksTrackerError extends WarrenError { export class PlanHasNoOpenChildrenError extends WarrenError { readonly code = "plan_has_no_open_children"; } + +/** + * `POST /plan-runs` rejection when the boot-resolved forge reports + * `capabilities.autoMerge: false` (warren-3e09). Plan-runs gate each child + * on the previous PR merging; warren performs that merge through the forge's + * ecosystem auto-merge mechanism (e.g. GitHub Actions). A forge that cannot + * self-merge would accept the dispatch and then time out at + * `parent_pr_merge_timeout` with no cause named. This error surfaces the + * incapacity up front so the operator can switch to a supported forge or wait + * for the missing capability to be implemented. HTTP 424 — the request is + * valid but unsatisfiable by this forge, matching the `unsupported` family. + */ +export class ForgeCannotAutoMergeError extends WarrenError { + readonly code = "forge_cannot_auto_merge"; +} diff --git a/src/server/errors.ts b/src/server/errors.ts index 19157d7ec..0e4a95c07 100644 --- a/src/server/errors.ts +++ b/src/server/errors.ts @@ -34,7 +34,11 @@ import { ValidationError, WarrenError, } from "../core/errors.ts"; -import { PlanHasNoOpenChildrenError, ProjectLacksTrackerError } from "../plan-runs/errors.ts"; +import { + ForgeCannotAutoMergeError, + PlanHasNoOpenChildrenError, + ProjectLacksTrackerError, +} from "../plan-runs/errors.ts"; import { ProjectUnavailableError } from "../projects/errors.ts"; import { AgentSchemaError } from "../registry/errors.ts"; import { RunSpawnError } from "../runs/errors.ts"; @@ -218,6 +222,9 @@ function warrenStatusFor(err: WarrenError): number { if (err instanceof ValidationError) return 400; if (err instanceof ProjectLacksTrackerError) return 400; if (err instanceof PlanHasNoOpenChildrenError) return 400; + // warren-3e09: 424 matches the `unsupported` ForgeError family — the + // request is valid but unsatisfiable by this forge configuration. + if (err instanceof ForgeCannotAutoMergeError) return 424; if (err instanceof StateTransitionError) return 409; // Provider-neutral runtime errors (warren-36cb): K8sProvider transport // failures ride `RuntimeUnreachableError`; run-not-found maps to 404 and a diff --git a/src/server/handlers/plan-runs.ts b/src/server/handlers/plan-runs.ts index 4e02344de..89bf99958 100644 --- a/src/server/handlers/plan-runs.ts +++ b/src/server/handlers/plan-runs.ts @@ -102,6 +102,8 @@ export function createPlanRunHandler(deps: ServerDeps): RouteHandler { ...(dispatcherHandle !== undefined ? { dispatcherHandle } : {}), repos: deps.repos, issueTracker: deps.issueTracker, + // warren-3e09: thread forge so createPlanRun can gate on autoMerge. + forge: deps.forge, projectsConfig: deps.projectsConfig, ...(deps.spawn !== undefined ? { spawn: deps.spawn } : {}), ...(gitSecret !== undefined ? { gitCredential: gitSecret } : {}), From 271c44c25d0b08b3bf54e652b2534933f6c0b722 Mon Sep 17 00:00:00 2001 From: warren Date: Thu, 27 Aug 2026 14:28:20 +0000 Subject: [PATCH 2/2] chore(warren): seeds state --- .seeds/issues.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.seeds/issues.jsonl b/.seeds/issues.jsonl index 3799bebc4..a657259b9 100644 --- a/.seeds/issues.jsonl +++ b/.seeds/issues.jsonl @@ -1558,6 +1558,6 @@ {"id":"warren-729e","title":"GKE us-west1-a capacity: parallel dispatch starved the control-plane rollout (PV zone quota exceeded)","status":"open","type":"task","priority":2,"createdAt":"2026-08-27T00:56:17.192Z","updatedAt":"2026-08-27T00:56:17.192Z","description":"During the pl-7e38 dogfood session (2026-08-26), nine parallel GLM run pods filled the us-west1-a nodes; a concurrent warren deploy rollout could not schedule its replacement pod (warren-data PV has topology.gke.io/zone us-west1-a affinity, autoscaler scale-up hit 'GCE quota exceeded'), leaving the control plane down ~5 min mid-session until a dead run pod freed memory. Mitigations to evaluate: GCE quota raise for us-west1-a, a PriorityClass so the control-plane pod preempts run pods, or admission caps tuned to reserve headroom on the PV zone."} {"id":"warren-87c0","title":"Root bun test picks up extensions/judge tests without their deps (@sinclair/typebox) in fresh environments","status":"open","type":"bug","priority":2,"createdAt":"2026-08-27T00:56:17.248Z","updatedAt":"2026-08-27T00:56:17.248Z","description":"Observed in run pods and the bundle-size-autoheal job during pl-7e38 (2026-08-26): check:coverage (bun test --coverage at root) collects extensions/judge test files, which fail with 'Cannot find module @sinclair/typebox' unless 'cd extensions/judge && bun install' ran first. Extensions are standalone packages with their own lockfiles (AGENTS.md extensions seam) — root test runs should exclude extensions/** or the environments must install extension deps. Multiple pl-7e38 page runs burned turns diagnosing this; the session worked around it via a prompt-level hint. Related: warren-8f8d (autoheal manifestation)."} {"id":"warren-59d5","title":"client-node-streams.test.ts fails on main after the @kubernetes/client-node 2.0.0 bump — blocks the check:all pre-commit hook","status":"closed","type":"bug","priority":2,"createdAt":"2026-08-26T14:29:19.055Z","updatedAt":"2026-08-27T12:36:06.502Z","description":"Reproduces on a clean upstream/main worktree (verified 2026-08-26), so it is not fork-local and not a regression from forge work.\n\nsrc/runtime/k8s/client-node-streams.test.ts > 'Watch v2 parses undici response lines and aborts through its controller' fails at line 96 with a DOMException-shaped mismatch. Arrived with #1042 (@kubernetes/client-node 1.4.0 -> 2.0.0, merged 2026-08-25).\n\nImpact is wider than one test: check:coverage is a check:all gate and check:all is armed as the git pre-commit hook, so every commit on main now needs --no-verify until this is fixed.\n\nLocal Bun is 1.3.14. Upstream CI merged #1042 green, so the first thing to check is whether the undici/AbortController behaviour the test asserts differs across Bun versions, and whether CI pins a different one.","labels":["k8s","deps"],"closedAt":"2026-08-27T12:36:06.502Z","closeReason":"Invalid — the test was never broken. Local node_modules had @kubernetes/client-node 1.4.0 installed against a bun.lock pinning 2.0.0; the suite exercised v2 semantics against the v1 package on disk. bun install --frozen-lockfile makes upstream/main pass unchanged (476/476 in src/runtime/k8s/, check:all 12/12). Upstream CI was green throughout. PR #11's fix reverted in c91581ab."} -{"id":"warren-3e09","title":"GitLab plan-run guard: a forge that cannot self-merge must refuse the dispatch, not time out","status":"open","type":"task","priority":1,"createdAt":"2026-08-27T13:47:11.595Z","updatedAt":"2026-08-27T13:48:01.290Z","description":"CONTEXT. The GitLab arm landed on main (see warren-75e8 BUILD PROGRESS): WARREN_FORGE=gitlab resolves end to end, src/forge/ is 345 tests green, check:all 12/12. Single runs work. Plan-runs DO NOT, and today they fail in the worst possible way — silently.\n\nTHE DEFECT. A plan-run gates each child on the previous PR merging. Warren performs that merge through GitHub's AUTO-MERGE WORKFLOW, which is a GitHub Actions feature of the target project — not through the Forge seam. src/forge/contract.ts:180 says so explicitly: \"There is deliberately no mergePullRequest: warren merges through GitHub's auto-merge workflow, not through the API.\" A GitLab project has no such workflow. Nothing transitions the MR to merged, so the plan-run coordinator waits until parent_pr_merge_timeout and the operator gets a timeout with no cause named.\n\nWHAT TO BUILD. Make the incapacity explicit and refuse early.\n\n1. Add a capability flag to ForgeCapabilities (src/forge/contract.ts). Suggested name: autoMerge. Document it the way the existing flags are documented — the flag plus its STATED domain fallback. GitHubForge and GitHubAppForge set it true; GitLabForge sets it false; FakeForge sets it true (its store transitions PRs itself, and the acceptance scenarios depend on that).\n\n2. Gate plan-run dispatch on it. A plan-run dispatched against a project whose forge reports autoMerge:false must be REFUSED at dispatch with a clear error naming the reason, not accepted and left to time out. Follow the admission-gate precedent: the K8s project-concurrency gate rejects with HTTP 429 and a machine-readable reason string (see docs/design/k8s-migration.md 3.3 and the admission code). Pick the right status for \"this forge cannot satisfy this request\" — the seam already uses 424 for the unsupported error kind (src/forge/errors.ts FORGE_ERROR_HTTP_STATUS), which is the closest existing precedent.\n\n3. Single runs must be UNAFFECTED. They need only push + openPullRequest, both of which GitLab does. Prove it with a test.\n\nALSO FIX, in the same PR — two doc inaccuracies in code that just landed, which you will read while doing the above and should not propagate:\n\na. src/forge/registry.ts module doc claims parseRepoRef \"chaining operates over the boot-registered forges in their fixed registration order\". NO CHAIN WALKER EXISTS. contract.ts:191 specifies that a null means the registry tries the next forge, but every consumer (src/runs/pr-merge.ts, src/ci-fixer/poller.ts, src/projects/url.ts) asks the single boot-resolved instance directly. Correct the doc to say a null currently means \"this process's forge does not own the URL\". Do not build the walker.\n\nb. src/forge/gitlab/provider.ts doc should record a collision the GitLab arm makes reachable: a project clones to //, and parseForgeOwnedUrl (src/projects/url.ts) derives those two segments from the LAST TWO path segments of the clone URL. GitLab paths are N segments deep, so group-a/sub/project and group-b/sub/project both lay out as sub/project. It fails loudly (cloneProjectRepo refuses an existing target path, clone.ts:156) so no data is lost, but the error names a path mentioning neither group. DO NOT fix it here — it needs the seam to expose a ref's layout, which is a Forge contract change tracked separately. Just document it.\n\nc. Add a test that the GitLab 409 duplicate recovery finds a LOCKED merge request. GitLab flips an MR to locked while a merge is in flight, which is exactly the window a re-dispatch races. src/forge/gitlab/merge-requests.ts matchesQueryState already treats locked as open; the test pins it.\n\nCONSTRAINTS.\n- Run bun run check:all before you finish. It is also the pre-commit hook. All 12 gates must pass. Do NOT use --no-verify.\n- Do NOT lower any floor in scripts/coverage-budgets.json, do NOT add a debt-marker allowlist entry, do NOT delete or skip a test to make a gate pass.\n- check:size caps files at 500 lines with no new budget entries for forge files. src/forge/gitlab/provider.ts is already ~320 lines.\n- Biome cognitive complexity ceiling is 15. resolveForge was recently refactored into per-arm builders for exactly this reason; do not re-inline it.\n- Do NOT edit src/forge/contract.ts's existing wording about mergePullRequest being deliberately absent. Adding a capability flag does not contradict it — the flag describes whether the forge's ECOSYSTEM self-merges, not whether the seam merges.\n- If you add a wire-visible enum value, it belongs in src/core/wire.ts and nowhere else (check:wire-types enforces this).","blocks":["warren-5bb5"]} +{"id":"warren-3e09","title":"GitLab plan-run guard: a forge that cannot self-merge must refuse the dispatch, not time out","status":"open","type":"task","priority":1,"createdAt":"2026-08-27T13:47:11.595Z","updatedAt":"2026-08-27T13:49:58.040Z","description":"CONTEXT. The GitLab arm landed on main (see warren-75e8 BUILD PROGRESS): WARREN_FORGE=gitlab resolves end to end, src/forge/ is 345 tests green, check:all 12/12. Single runs work. Plan-runs DO NOT, and today they fail in the worst possible way — silently.\n\nTHE DEFECT. A plan-run gates each child on the previous PR merging. Warren performs that merge through GitHub's AUTO-MERGE WORKFLOW, which is a GitHub Actions feature of the target project — not through the Forge seam. src/forge/contract.ts:180 says so explicitly: \"There is deliberately no mergePullRequest: warren merges through GitHub's auto-merge workflow, not through the API.\" A GitLab project has no such workflow. Nothing transitions the MR to merged, so the plan-run coordinator waits until parent_pr_merge_timeout and the operator gets a timeout with no cause named.\n\nWHAT TO BUILD. Make the incapacity explicit and refuse early.\n\n1. Add a capability flag to ForgeCapabilities (src/forge/contract.ts). Suggested name: autoMerge. Document it the way the existing flags are documented — the flag plus its STATED domain fallback. GitHubForge and GitHubAppForge set it true; GitLabForge sets it false; FakeForge sets it true (its store transitions PRs itself, and the acceptance scenarios depend on that).\n\n2. Gate plan-run dispatch on it. A plan-run dispatched against a project whose forge reports autoMerge:false must be REFUSED at dispatch with a clear error naming the reason, not accepted and left to time out. Follow the admission-gate precedent: the K8s project-concurrency gate rejects with HTTP 429 and a machine-readable reason string (see docs/design/k8s-migration.md 3.3 and the admission code). Pick the right status for \"this forge cannot satisfy this request\" — the seam already uses 424 for the unsupported error kind (src/forge/errors.ts FORGE_ERROR_HTTP_STATUS), which is the closest existing precedent.\n\n3. Single runs must be UNAFFECTED. They need only push + openPullRequest, both of which GitLab does. Prove it with a test.\n\nALSO FIX, in the same PR — two doc inaccuracies in code that just landed, which you will read while doing the above and should not propagate:\n\na. src/forge/registry.ts module doc claims parseRepoRef \"chaining operates over the boot-registered forges in their fixed registration order\". NO CHAIN WALKER EXISTS. contract.ts:191 specifies that a null means the registry tries the next forge, but every consumer (src/runs/pr-merge.ts, src/ci-fixer/poller.ts, src/projects/url.ts) asks the single boot-resolved instance directly. Correct the doc to say a null currently means \"this process's forge does not own the URL\". Do not build the walker.\n\nb. src/forge/gitlab/provider.ts doc should record a collision the GitLab arm makes reachable: a project clones to //, and parseForgeOwnedUrl (src/projects/url.ts) derives those two segments from the LAST TWO path segments of the clone URL. GitLab paths are N segments deep, so group-a/sub/project and group-b/sub/project both lay out as sub/project. It fails loudly (cloneProjectRepo refuses an existing target path, clone.ts:156) so no data is lost, but the error names a path mentioning neither group. DO NOT fix it here — it needs the seam to expose a ref's layout, which is a Forge contract change tracked separately. Just document it.\n\nc. Add a test that the GitLab 409 duplicate recovery finds a LOCKED merge request. GitLab flips an MR to locked while a merge is in flight, which is exactly the window a re-dispatch races. src/forge/gitlab/merge-requests.ts matchesQueryState already treats locked as open; the test pins it.\n\nCONSTRAINTS.\n- Run bun run check:all before you finish. It is also the pre-commit hook. All 12 gates must pass. Do NOT use --no-verify.\n- Do NOT lower any floor in scripts/coverage-budgets.json, do NOT add a debt-marker allowlist entry, do NOT delete or skip a test to make a gate pass.\n- check:size caps files at 500 lines with no new budget entries for forge files. src/forge/gitlab/provider.ts is already ~320 lines.\n- Biome cognitive complexity ceiling is 15. resolveForge was recently refactored into per-arm builders for exactly this reason; do not re-inline it.\n- Do NOT edit src/forge/contract.ts's existing wording about mergePullRequest being deliberately absent. Adding a capability flag does not contradict it — the flag describes whether the forge's ECOSYSTEM self-merges, not whether the seam merges.\n- If you add a wire-visible enum value, it belongs in src/core/wire.ts and nowhere else (check:wire-types enforces this).","blocks":["warren-5bb5"],"extensions":{"role":"claude-code","trigger":"cli","lastRunId":"run_1t1rfarfnrtq","lastRunAt":"2026-08-27T13:49:57.888Z"}} {"id":"warren-5717","title":"Forge layout seam: nested GitLab group paths collide on disk under parseForgeOwnedUrl","status":"open","type":"task","priority":2,"createdAt":"2026-08-27T13:47:40.662Z","updatedAt":"2026-08-27T13:47:40.662Z","description":"A project clones to //. parseForgeOwnedUrl (src/projects/url.ts:98) derives those two segments from the LAST TWO path segments of the clone URL. That is correct for GitHub, where the path is always host/owner/repo, and it is how the function drops the host WITHOUT knowing which forge it is talking to.\n\nGitLab paths are N segments deep because of nested groups, which are the normal shape rather than an edge case. So group-a/sub/project and group-b/sub/project both lay out as sub/project.\n\nSEVERITY: loud, not silent. cloneProjectRepo refuses a target path that already exists (src/projects/clone.ts:156, ProjectUnavailableError \"target path already exists\"), so the second registration fails and nothing is overwritten. The problem is the error names a path mentioning neither group, so the operator cannot tell what collided.\n\nREACHABILITY: this became reachable when WARREN_FORGE=gitlab landed. Before that, a GitLab URL was never forge-owned and parseForgeOwnedUrl never saw one.\n\nWHY IT IS NOT A ONE-CALLER PATCH. Preserving the full GitLab path means knowing how many leading segments are host versus project path, which is forge knowledge. parseForgeOwnedUrl is deliberately forge-agnostic. The fix therefore belongs on the Forge contract: expose the on-disk layout a RepoRef wants, so each provider answers for its own URL shape and the caller stops guessing from segment count.\n\nSKETCH, not a mandate: a seam method returning the layout segments for a ref, with GitHubForge returning owner/repo, GitLabForge returning the full group path, FakeForge returning its existing pair. Callers then join what the forge returned. Each segment still has to pass the existing path-safety validation (no empty, no . or .., no leading dash, SEGMENT regex) because that rule guards /data/projects path safety (mx-e741b0).\n\nWATCH OUT: warren-1b6f is UPSTREAM-AUTHORED and covers src/projects/url.ts as its item 2. Coordinate rather than collide. Never edit warren-1b6f's tracker row — the seeds driver is id-keyed newest-wins, so a fork edit silently overwrites the maintainer's version on the next sync.\n\nRecorded in the GitLabForge doc block on main. Companion to warren-75e8."} {"id":"warren-5bb5","title":"Acceptance scenario 40: GitLab arm falsification — a GitLab project dispatches to reap to push to MR","status":"open","type":"task","priority":2,"createdAt":"2026-08-27T13:47:40.955Z","updatedAt":"2026-08-27T13:48:01.290Z","description":"The GitLab provider (warren-7ba8 / warren-75e8) is unit-tested against a recorded fetch: src/forge/gitlab/ is 345 tests green covering URL grammars, the transport, the MR API translations, and the registry arm. What has NOT been proven is the seam claim itself — that a real foreign vendor completes the full run lifecycle with ZERO domain changes.\n\nThat is the falsification test the multi-forge design record (docs/design/multi-forge-support.md) exists to answer, and it is what turns \"a provider compiles\" into evidence.\n\nSCOPE: follow the existing acceptance harness conventions in scripts/acceptance/ — scenarios must be deterministic, idempotent, and clean up after themselves. Scenario 39 (39-public-exposure.ts) is the model for a scenario wired into CI; the rest run locally and nightly.\n\nMUST COVER, per the contract obligation warren-75e8 records from mx-9cf91f: a provider's parseRepoRef MUST round-trip its OWN merge-request web URLs, or the plan-run merge gate breaks. GitLab's infix is /-/merge_requests/, unlike GitHub's /pull/. This is the single most likely thing a new provider gets wrong.\n\nSINGLE RUNS ONLY. Do not write a plan-run leg — plan-runs are blocked on warren-3e09 (a GitLab project has no auto-merge workflow, so the gate would wait to parent_pr_merge_timeout). Scope this scenario to dispatch to reap to push to MR-open.\n\nOPEN QUESTION TO ANSWER FIRST, and say which you chose in the PR body: does this run against a real GitLab instance (needs a credential and a throwaway project, so probably nightly-only and skipped without WARREN_GITLAB_URL) or against a local stub server in the style of src/forge/github/stub-server.ts? The stub is deterministic and CI-safe; the real instance is the only thing that actually falsifies. A stub scenario that calls itself a falsification test would be worse than none.\n\nCONSTRAINTS: bun run check:all must pass, all 12 gates, no --no-verify, no lowered coverage floors, no skipped tests.","blockedBy":["warren-3e09"]}