From e2a031f8312e6b270466a1d71c4a89291ad66903 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 14:16:07 +0800 Subject: [PATCH 1/7] chore(ci): raise the dev PR gate to Typecheck + Unit Tests (linux) The Typecheck-only PR->dev gate let an assertion-level regression (#368, missing SPDX headers) merge and keep dev's push-triggered full run red for 75 minutes. ci-test.yml now triggers on PRs to dev (the whole matrix runs, but only Unit Tests (linux) becomes required); spec_git/policy.yaml follows so specgit finish enforces the same pair. E2E stays push-on-dev + dev->main. Closes #370 --- .github/workflows/ci-test.yml | 9 ++++++--- .specgit.yaml | 7 +++---- spec_git/policy.yaml | 1 + 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 04ff0a7295..9484aac7ab 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -2,7 +2,7 @@ # πŸ§ͺ CI Β· Test # ---------------------------------------------------------------------------- # Purpose : Run unit + Playwright e2e tests across Linux & Windows -# Trigger : Push to `main`/`dev`, PRs targeting `main`, manual dispatch +# Trigger : Push to `main`/`dev`, PRs targeting `main` and `dev`, manual dispatch # Jobs : unit β€” `bun turbo test` + config_assistant Go tests on linux # only (windows dropped β€” see # unit-tests matrix comment; free windows-latest runners @@ -10,8 +10,10 @@ # e2e β€” Playwright chromium on linux + windows (matrix) # Gate : Required status check on the `main` ruleset β€” full suite gates # dev β†’ main PRs. Pushes to `dev` also get a full run (dev is the -# integration/testing branch), but feat/fix β†’ dev PRs are gated by -# typecheck only (see ci-typecheck.yml) to keep CI budget sane. +# integration/testing branch). feat/fix β†’ dev PRs run the unit +# matrix as a required check (#370: the Typecheck-only gate let an +# assertion-level regression merge and keep dev red for 75min); +# E2E stays push-on-dev + devβ†’main only to keep CI budget sane. # Notes : `cancel-in-progress: false` β€” every main/dev push gets a full run # No trigger on feat/* or fix/* (frequent changes). # ============================================================================ @@ -26,6 +28,7 @@ on: pull_request: branches: - main + - dev workflow_dispatch: concurrency: diff --git a/.specgit.yaml b/.specgit.yaml index 7a1959b65f..e3ca10cf7a 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: issue368 +delivery: issue370 context: kind: branch - branch: feat/368-issue368 + branch: feat/370-issue370 issues: - - 368 -pr: 369 + - 370 diff --git a/spec_git/policy.yaml b/spec_git/policy.yaml index ff8aaa9c9f..fe3768c27e 100644 --- a/spec_git/policy.yaml +++ b/spec_git/policy.yaml @@ -1,3 +1,4 @@ version: 1 required_checks: - Typecheck + - Unit Tests (linux) From 44975d7ef20e00b63fc3116861bbd9878297b6d7 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 14:16:41 +0800 Subject: [PATCH 2/7] chore(specgit): record PR binding in delivery record --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index e3ca10cf7a..3e12d9b8aa 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/370-issue370 issues: - 370 +pr: 371 From 2f15445fbcfd2c4efca0722e384ebd5cc2efb3c3 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 14:52:54 +0800 Subject: [PATCH 3/7] fix(dag): dag.start treats model.unavailable as advisory, matching the tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new PR->dev unit gate caught it (#371's own run): the exerciser's CI context has no resolvable model, and the environment-profile validation made model.unavailable a blocking 400 β€” while the workflow tool's start action asks a question instead (an HTTP caller has no such interaction; spawn fails loudly at execution time if a model never resolves). All other diagnostic classes stay blocking. --- .../routes/instance/httpapi/handlers/dag.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/dag.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/dag.ts index 7915343edb..0ce7bc72ed 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/dag.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/dag.ts @@ -6,6 +6,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { InvalidRequestError, ConflictError, notFound } from "../errors" import { Dag } from "@/dag/dag" +import { DagValidation } from "@/dag/validation" import { WorkflowAuthoring } from "@/dag/authoring" import { DagEnvironmentCatalogs } from "@/dag/environment-catalogs" import { createAdmissionRecord } from "@/dag/admission" @@ -177,12 +178,22 @@ export const dagHandlers = HttpApiBuilder.group(InstanceHttpApi, "dag", (handler environment: { directory: session.directory, parent: session.model ?? undefined }, }) if (result.prepared?.action !== "start" || result.errors.length > 0) { - const diagnostics = result.errors - .map((diagnostic) => `- [${diagnostic.code}] ${diagnostic.path}: ${diagnostic.message}${diagnostic.hint ? ` (${diagnostic.hint})` : ""}`) - .join("\n") - return yield* Effect.fail( - new InvalidRequestError({ message: `start rejected by workflow validation:\n${diagnostics || "no prepared graph"}` }), + // Parity with the workflow tool's start action: model resolution is + // advisory over HTTP β€” the tool asks a question (no model configured + // yet), an API caller has no such interaction; the spawn path fails + // loudly (failWithoutFiber) at execution time if a model never + // resolves. Every other diagnostic class stays blocking. + const blocking = result.errors.filter( + (diagnostic) => diagnostic.code !== DagValidation.DIAGNOSTIC_CODES.modelUnavailable, ) + if (result.prepared?.action !== "start" || blocking.length > 0) { + const diagnostics = blocking + .map((diagnostic) => `- [${diagnostic.code}] ${diagnostic.path}: ${diagnostic.message}${diagnostic.hint ? ` (${diagnostic.hint})` : ""}`) + .join("\n") + return yield* Effect.fail( + new InvalidRequestError({ message: `start rejected by workflow validation:\n${diagnostics || "no prepared graph"}` }), + ) + } } const prepared = result.prepared const dagID = yield* dag From ed3903005eb82f03ced1c3ea907162e947ff66e6 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 15:45:52 +0800 Subject: [PATCH 4/7] fix(dag): dag.start keeps model.unavailable advisory; exerciser seeds a model-bearing session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI (no providers) exposed two layers: the advisory filter let a graph that never compiled (prepared === undefined) through to a confusing 'no prepared graph' 400 β€” a non-compiling graph is now always blocking regardless of diagnostic class; and the dag.start happy-path scenario relied on the pre-#344 behavior of never resolving a model. The scenario now runs under withLlm with an explicit session model so the parent resolution chain has something to resolve in the provider-less CI environment. --- .../routes/instance/httpapi/handlers/dag.ts | 32 +++++++++---------- .../test/server/httpapi-exercise/index.ts | 9 +++++- .../test/server/httpapi-exercise/runner.ts | 2 +- .../test/server/httpapi-exercise/types.ts | 2 +- 4 files changed, 26 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/dag.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/dag.ts index 0ce7bc72ed..04ef202fd6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/dag.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/dag.ts @@ -177,23 +177,23 @@ export const dagHandlers = HttpApiBuilder.group(InstanceHttpApi, "dag", (handler profile: "environment", environment: { directory: session.directory, parent: session.model ?? undefined }, }) - if (result.prepared?.action !== "start" || result.errors.length > 0) { - // Parity with the workflow tool's start action: model resolution is - // advisory over HTTP β€” the tool asks a question (no model configured - // yet), an API caller has no such interaction; the spawn path fails - // loudly (failWithoutFiber) at execution time if a model never - // resolves. Every other diagnostic class stays blocking. - const blocking = result.errors.filter( - (diagnostic) => diagnostic.code !== DagValidation.DIAGNOSTIC_CODES.modelUnavailable, + // Parity with the workflow tool's start action: model resolution is + // advisory over HTTP β€” the tool asks a question (no model configured + // yet), an API caller has no such interaction; the spawn path fails + // loudly (failWithoutFiber) at execution time if a model never + // resolves. Every other diagnostic class stays blocking, and a graph + // that did not COMPILE (prepared === undefined) is always blocking + // regardless of diagnostic classes. + const blocking = result.errors.filter( + (diagnostic) => diagnostic.code !== DagValidation.DIAGNOSTIC_CODES.modelUnavailable, + ) + if (result.prepared?.action !== "start" || blocking.length > 0) { + const diagnostics = blocking + .map((diagnostic) => `- [${diagnostic.code}] ${diagnostic.path}: ${diagnostic.message}${diagnostic.hint ? ` (${diagnostic.hint})` : ""}`) + .join("\n") + return yield* Effect.fail( + new InvalidRequestError({ message: `start rejected by workflow validation:\n${diagnostics || "no prepared graph"}` }), ) - if (result.prepared?.action !== "start" || blocking.length > 0) { - const diagnostics = blocking - .map((diagnostic) => `- [${diagnostic.code}] ${diagnostic.path}: ${diagnostic.message}${diagnostic.hint ? ` (${diagnostic.hint})` : ""}`) - .join("\n") - return yield* Effect.fail( - new InvalidRequestError({ message: `start rejected by workflow validation:\n${diagnostics || "no prepared graph"}` }), - ) - } } const prepared = result.prepared const dagID = yield* dag diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 5f06ae9769..81ed5b58d6 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1913,7 +1913,14 @@ const scenarios: Scenario[] = [ http.protected .post("/dag", "dag.start") .mutating() - .seeded((ctx) => ctx.session({ title: "DAG start owner" })) + .withLlm() + .seeded((ctx) => + // environment-profile authoring resolves each node's model through + // node -> tier -> agent -> parent(session.model); the exerciser's fake + // provider only exists under withLlm, and the parent chain needs the + // session to carry the fake model explicitly. + ctx.session({ title: "DAG start owner", model: { providerID: "test", modelID: "test-model" } }), + ) .at((ctx) => ({ path: "/dag", headers: ctx.headers(), diff --git a/packages/opencode/test/server/httpapi-exercise/runner.ts b/packages/opencode/test/server/httpapi-exercise/runner.ts index 408360d1f2..bc3b7effee 100644 --- a/packages/opencode/test/server/httpapi-exercise/runner.ts +++ b/packages/opencode/test/server/httpapi-exercise/runner.ts @@ -141,7 +141,7 @@ function withContext( return Bun.write(`${directory()}/${name}`, content) }).pipe(Effect.asVoid), session: (input) => - run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))), + run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID, model: input?.model as never }))), sessionGet: (sessionID) => run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe( Effect.catchCause(() => Effect.succeed(undefined)), diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts index b0dd647778..06b6ea1f8b 100644 --- a/packages/opencode/test/server/httpapi-exercise/types.ts +++ b/packages/opencode/test/server/httpapi-exercise/types.ts @@ -54,7 +54,7 @@ export type ScenarioContext = { directory: string | undefined headers: (extra?: Record) => Record file: (name: string, content: string) => Effect.Effect - session: (input?: { title?: string; parentID?: SessionID }) => Effect.Effect + session: (input?: { title?: string; parentID?: SessionID; model?: { providerID: string; modelID: string } }) => Effect.Effect sessionGet: (sessionID: SessionID) => Effect.Effect project: () => Effect.Effect message: (sessionID: SessionID, input?: { text?: string }) => Effect.Effect From 3731028ca4d9b36ccb7dbc90460e7d0f173f1d88 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 16:42:03 +0800 Subject: [PATCH 5/7] fix(test): session model field is {id, providerID}, matching Session's Model schema --- packages/opencode/test/server/httpapi-exercise/index.ts | 2 +- packages/opencode/test/server/httpapi-exercise/types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 81ed5b58d6..842399295f 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1919,7 +1919,7 @@ const scenarios: Scenario[] = [ // node -> tier -> agent -> parent(session.model); the exerciser's fake // provider only exists under withLlm, and the parent chain needs the // session to carry the fake model explicitly. - ctx.session({ title: "DAG start owner", model: { providerID: "test", modelID: "test-model" } }), + ctx.session({ title: "DAG start owner", model: { providerID: "test", id: "test-model" } }), ) .at((ctx) => ({ path: "/dag", diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts index 06b6ea1f8b..ee7a4c86b7 100644 --- a/packages/opencode/test/server/httpapi-exercise/types.ts +++ b/packages/opencode/test/server/httpapi-exercise/types.ts @@ -54,7 +54,7 @@ export type ScenarioContext = { directory: string | undefined headers: (extra?: Record) => Record file: (name: string, content: string) => Effect.Effect - session: (input?: { title?: string; parentID?: SessionID; model?: { providerID: string; modelID: string } }) => Effect.Effect + session: (input?: { title?: string; parentID?: SessionID; model?: { id: string; providerID: string } }) => Effect.Effect sessionGet: (sessionID: SessionID) => Effect.Effect project: () => Effect.Effect message: (sessionID: SessionID, input?: { text?: string }) => Effect.Effect From 6a3f3b22bed7f2ac79062f0048f3526176356eda Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 17:13:21 +0800 Subject: [PATCH 6/7] =?UTF-8?q?chore(specgit):=20acceptance=20timeout=2045?= =?UTF-8?q?min=20=E2=80=94=20must=20outlast=20Unit=20Tests=20(~28min)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verdict waits for every policy check to reach a terminal state; with Unit Tests (linux) now required on dev PRs, the 15min job cap timed out while the sibling was still running. --- .github/workflows/specgit-accept.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/specgit-accept.yml b/.github/workflows/specgit-accept.yml index 2f47923270..99eac183c8 100644 --- a/.github/workflows/specgit-accept.yml +++ b/.github/workflows/specgit-accept.yml @@ -13,7 +13,10 @@ jobs: specgit-acceptance: name: SpecGit Acceptance runs-on: ubuntu-latest - timeout-minutes: 15 + # Must exceed the slowest required sibling (Unit Tests (linux) runs + # ~28min on PRs): the verdict waits for every policy check to reach a + # terminal state before evaluating. + timeout-minutes: 45 steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 From 8d5bb3dca9b32245994741b40763e316d5ba700d Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 17:38:44 +0800 Subject: [PATCH 7/7] =?UTF-8?q?chore(specgit):=20wait-step=20deadline=2040?= =?UTF-8?q?min=20=E2=80=94=20the=2015min=20inline=20deadline=20was=20the?= =?UTF-8?q?=20real=20timeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job-level 45min bump was necessary but not sufficient: the sibling-wait script carries its own hardcoded 15-minute deadline and gives up while Unit Tests (linux) (~28min) is still running. --- .github/workflows/specgit-accept.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/specgit-accept.yml b/.github/workflows/specgit-accept.yml index 99eac183c8..6421b235aa 100644 --- a/.github/workflows/specgit-accept.yml +++ b/.github/workflows/specgit-accept.yml @@ -71,7 +71,9 @@ jobs: const retried = [...byName.keys()].find((k) => k.startsWith(name + ' (')); return retried !== undefined && terminal.has(byName.get(retried)); }; - const deadline = Date.now() + 15 * 60 * 1000; + // Must outlast the slowest required sibling (Unit Tests (linux) + // runs ~28min on PRs); the job timeout above bounds this too. + const deadline = Date.now() + 40 * 60 * 1000; while (Date.now() < deadline) { const res = await fetch(url, { headers }); if (!res.ok) throw new Error('check-runs API ' + res.status);