From 1494e7b388f5855b8da07c5e3051dacf6ff220ce Mon Sep 17 00:00:00 2001 From: nullStack65 Date: Sat, 26 Sep 2026 19:29:06 -0400 Subject: [PATCH 01/18] feat(server): bounded launch preflight for shared session roots Complete E1's launch preflight: probe the Git executable the launch actually resolves for the required --path-format capability, detect an unexpected umbrella repository through effective Git identity (not folder name), bound the narrow root read, and surface findings. Blocker only when the resolved Git cannot start or lacks the capability while the session root is a repository; warn otherwise. Wired before the provider workspace read for both new sessions and recovery, with warnings surfaced through the existing thread-activity transport. --- ...ProviderSessionStartup.integration.test.ts | 12 + .../src/environment/LaunchPreflight.test.ts | 312 ++++++++++++ .../server/src/environment/LaunchPreflight.ts | 460 ++++++++++++++++++ apps/server/src/provider/Errors.ts | 21 + .../src/provider/Layers/ProviderService.ts | 82 +++- apps/server/src/server.ts | 40 +- apps/server/src/serverRuntimeStartup.ts | 39 +- 7 files changed, 961 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/environment/LaunchPreflight.test.ts create mode 100644 apps/server/src/environment/LaunchPreflight.ts diff --git a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts index 77173af1b377..4461c133ccc5 100644 --- a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts +++ b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts @@ -22,6 +22,7 @@ import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { HttpServer } from "effect/unstable/http"; import * as NetAddress from "effect/unstable/net/NetAddress"; +import { ChildProcessSpawner } from "effect/unstable/process"; import * as EnvironmentAuth from "../src/auth/EnvironmentAuth.ts"; import * as ServiceLauncherClient from "../src/cloud/serviceLauncherClient.ts"; @@ -45,6 +46,7 @@ import * as ServerRuntimeStartup from "../src/serverRuntimeStartup.ts"; import * as ServerSettings from "../src/serverSettings.ts"; import * as AnalyticsService from "../src/telemetry/AnalyticsService.ts"; import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../src/vcs/VcsProcess.ts"; const providerInstanceId = ProviderInstanceId.make("codex"); const projectId = ProjectId.make("project-startup-orphan"); @@ -111,6 +113,16 @@ const startupDependencies = Layer.mergeAll( ), AnalyticsService.layerTest, Layer.mock(GitVcsDriver.GitVcsDriver)({}), + Layer.mock(VcsProcess.VcsProcess)({ + run: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "git version 2.55.0\n", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }), Layer.succeed(ProviderService.ProviderService, { startSession: () => Effect.die("unused"), sendTurn: () => Effect.die("unused"), diff --git a/apps/server/src/environment/LaunchPreflight.test.ts b/apps/server/src/environment/LaunchPreflight.test.ts new file mode 100644 index 000000000000..c64c15d1005c --- /dev/null +++ b/apps/server/src/environment/LaunchPreflight.test.ts @@ -0,0 +1,312 @@ +// @effect-diagnostics nodeBuiltinImport:off - real temp directories exercise the bounded read probe. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeFS from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as LaunchPreflight from "./LaunchPreflight.ts"; + +const probeError = ( + reason: LaunchPreflight.LaunchPreflightProbeFailureReason, + detail = "probe failed", +) => new LaunchPreflight.LaunchPreflightProbeError({ reason, detail }); + +const identity = ( + overrides: Partial = {}, +): LaunchPreflight.LaunchPreflightRepoIdentity => ({ + state: "ok", + topLevel: "/session-root", + commonDir: "/session-root/.git", + detail: "", + ...overrides, +}); + +const gitProbe = ( + overrides: Partial = {}, +): LaunchPreflight.LaunchPreflightGitProbe => ({ + version: () => Effect.succeed("2.55.0"), + resolveIdentity: () => Effect.succeed(identity({ state: "not-a-repository", topLevel: null, commonDir: null })), + ...overrides, +}); + +const input = (options: { + readonly root?: string; + readonly git?: LaunchPreflight.LaunchPreflightGitProbe; + readonly files?: Partial; +}): LaunchPreflight.LaunchPreflightInput => ({ + root: options.root ?? "/session-root", + git: options.git ?? gitProbe(), + files: { + exists: () => Effect.succeed(false), + readFirstBytes: () => Effect.succeed(16), + listDirectory: () => Effect.succeed([]), + ...options.files, + }, +}); + +const codes = (result: LaunchPreflight.LaunchPreflightResult) => + result.findings.map((finding) => finding.code); + +const severities = (result: LaunchPreflight.LaunchPreflightResult) => + result.findings.map((finding) => finding.severity); + +const run = (probeInput: LaunchPreflight.LaunchPreflightInput) => + LaunchPreflight.runLaunchPreflight(probeInput).pipe(Effect.provide(NodeServices.layer)); + +it.effect("passes an ordinary repository session with a supported Git", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ topLevel: "/repo", commonDir: "/repo/.git" })), + }), + files: { + exists: (target) => Effect.succeed(target === "/repo/.git" || target.endsWith("package.json")), + listDirectory: () => Effect.succeed(["src", "docs"]), + }, + }), + ); + + assert.deepStrictEqual(result.findings, []); + }), +); + +it.effect("passes a non-Git shared session root", () => + Effect.gen(function* () { + const result = yield* run(input({})); + + assert.deepStrictEqual(result.findings, []); + }), +); + +it.effect("does not count a retired Git marker as an umbrella by its name alone", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/Documents", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "not-a-repository", topLevel: null, commonDir: null })), + }), + files: { + exists: (target) => Effect.succeed(target === "/Documents/.git.macfix-m1-retired"), + listDirectory: () => Effect.succeed([]), + }, + }), + ); + + assert.deepStrictEqual(result.findings, []); + }), +); + +it.effect("flags an accidental umbrella repository at the shared root", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/Documents", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ topLevel: "/Documents", commonDir: "/Documents/.git" })), + }), + files: { + exists: (target) => + Effect.succeed(target === "/Documents/.git" || target === "/Documents/proj/.git"), + listDirectory: () => Effect.succeed(["proj", "notes"]), + }, + }), + ); + + assert.deepStrictEqual(codes(result), ["shared-root-git"]); + assert.deepStrictEqual(severities(result), ["warning"]); + }), +); + +it.effect("does not flag an ordinary repository root that has no nested repositories", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + git: gitProbe({ + resolveIdentity: () => Effect.succeed(identity({ topLevel: "/repo" })), + }), + files: { + exists: (target) => Effect.succeed(target === "/repo/.git" || target.endsWith("package.json")), + listDirectory: () => Effect.succeed(["src", "docs"]), + }, + }), + ); + + assert.deepStrictEqual(result.findings, []); + }), +); + +it.effect("warns (does not block) when Git cannot start under a non-repository root", () => + Effect.gen(function* () { + const result = yield* run( + input({ git: { ...gitProbe(), version: () => Effect.fail(probeError("unavailable")) } }), + ); + + assert.deepStrictEqual(codes(result), ["git-startup-failed"]); + assert.deepStrictEqual(severities(result), ["warning"]); + }), +); + +it.effect("blocks when Git cannot start and the session root is a repository", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + git: { ...gitProbe(), version: () => Effect.fail(probeError("unavailable")) }, + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + }), + ); + + assert.deepStrictEqual(severities(result), ["blocker"]); + assert.include(result.blockers[0]?.message ?? "", "/repo/.git"); + }), +); + +it.effect("blocks when the resolved Git lacks --path-format and the root is a repository", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed( + identity({ state: "unsupported", topLevel: null, commonDir: null, detail: "git 2.24.3" }), + ), + }), + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + }), + ); + + assert.deepStrictEqual(severities(result), ["blocker"]); + assert.include(result.blockers[0]?.message ?? "", "2.24.3"); + }), +); + +it.effect("warns when the resolved Git lacks --path-format but the root is not a repository", () => + Effect.gen(function* () { + const result = yield* run( + input({ + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "unsupported", topLevel: null, commonDir: null })), + }), + }), + ); + + assert.deepStrictEqual(codes(result), ["git-capability-missing"]); + assert.deepStrictEqual(severities(result), ["warning"]); + }), +); + +it.effect("flags a Git probe that reports a timeout", () => + Effect.gen(function* () { + const result = yield* run( + input({ git: { ...gitProbe(), version: () => Effect.fail(probeError("timeout")) } }), + ); + + assert.deepStrictEqual(codes(result), ["git-probe-timed-out"]); + assert.deepStrictEqual(severities(result), ["warning"]); + }), +); + +it.effect("bounds a hung Git probe instead of holding the launch", () => + Effect.gen(function* () { + const fiber = yield* run( + input({ git: { ...gitProbe(), version: () => Effect.never } }), + ).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust("1500 millis"); + const result = yield* Fiber.join(fiber); + + assert.deepStrictEqual(codes(result), ["git-probe-timed-out"]); + }), +); + +it.effect("flags a slow root read and bounds it instead of holding the launch", () => + Effect.gen(function* () { + const fiber = yield* run( + input({ + files: { + exists: (target) => Effect.succeed(target.endsWith("package.json")), + readFirstBytes: () => Effect.never, + }, + }), + ).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust("500 millis"); + const result = yield* Fiber.join(fiber); + + assert.deepStrictEqual(codes(result), ["root-read-slow"]); + }), +); + +it.effect("resolves every probe within the total budget even when all probes hang", () => + Effect.gen(function* () { + const fiber = yield* run( + input({ + git: { ...gitProbe(), version: () => Effect.never }, + files: { + exists: () => Effect.never, + listDirectory: () => Effect.never, + readFirstBytes: () => Effect.never, + }, + }), + ).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust("5 seconds"); + const result = yield* Fiber.join(fiber); + + assert.deepStrictEqual(codes(result), ["git-probe-timed-out"]); + }), +); + +it.effect("wires the real service probes and passes a healthy root", () => + Effect.gen(function* () { + const directory = yield* Effect.promise(() => + NodeFS.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-launch-preflight-")), + ); + yield* Effect.promise(() => + NodeFS.writeFile(NodePath.join(directory, "package.json"), '{"name":"preflight"}\n'), + ); + + const layer = LaunchPreflight.layer.pipe( + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (processInput) => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: + processInput.args.includes("--version") + ? "git version 2.55.0\n" + : `${directory}\n${NodePath.join(directory, ".git")}\n`, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }), + ), + Layer.provide(NodeServices.layer), + ); + + const result = yield* LaunchPreflight.LaunchPreflight.pipe( + Effect.flatMap((preflight) => preflight.run(directory)), + Effect.provide(layer), + ); + + assert.deepStrictEqual(result.findings, []); + yield* Effect.promise(() => NodeFS.rm(directory, { recursive: true, force: true })); + }), +); diff --git a/apps/server/src/environment/LaunchPreflight.ts b/apps/server/src/environment/LaunchPreflight.ts new file mode 100644 index 000000000000..0dc0db553446 --- /dev/null +++ b/apps/server/src/environment/LaunchPreflight.ts @@ -0,0 +1,460 @@ +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import type { VcsError } from "@t3tools/contracts"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; + +/** + * Automatic launch preflight for the server's shared session root. + * + * The root is `ServerConfig.cwd`: the directory provider sessions default to and + * the directory auto-bootstrap roots a project at. Before sessions start, this + * checks the environment the harness actually depends on: + * + * - the `git` executable the launch actually resolves can start, and supports + * the `rev-parse --path-format=absolute` capability `GitVcsDriver` relies on; + * - an accidental umbrella repository at the shared root is flagged, using the + * effective Git identity (not a folder name), because every project beneath it + * would then share one repository and T3 checkpoints could be written into it; + * - one small, relevant file at the root is read to detect a stalled or + * cloud-offloaded filesystem without scanning the tree. + * + * Every probe is bounded, read-only and local. The preflight never mutates Git + * metadata, disables sync, kills processes, or calls a model. It warns by + * default and only blocks the launch for a demonstrated inability to execute + * usefully: when the resolved Git cannot start or lacks the required capability + * *and* the exact session root is itself a repository, so T3 checkpoints cannot + * be written. Broad roots, optional capabilities and unexpected markers alone + * never block. + */ + +/** + * `GitVcsDriver` checkpoint capture runs + * `git rev-parse --path-format=absolute --git-path index`, which needs Git + * 2.31.0 or newer. Reported only as context; the capability probe itself is what + * decides, not an arbitrary version threshold. + */ +export const MINIMUM_GIT_VERSION = "2.31.0"; + +const GIT_PROBE_TIMEOUT = "1500 millis"; +const EXISTS_PROBE_TIMEOUT = "500 millis"; +const ROOT_LIST_TIMEOUT = "500 millis"; +const READ_PROBE_TIMEOUT = "500 millis"; +const TOTAL_PROBE_BUDGET = "5 seconds"; +const READ_PROBE_MAX_BYTES = 32 * 1024; +const MAX_ROOT_CHILDREN = 32; +const GIT_VERSION_PATTERN = /\b(\d+\.\d+\.\d+)\b/; + +/** Files probed, in order, for the bounded root read. All are small and relevant. */ +const READ_PROBE_CANDIDATES = ["AGENTS.md", "package.json", "README.md", ".git/HEAD"] as const; + +/** Retired Git metadata must not count as an active umbrella by name alone. */ +const RETIRED_GIT_MARKER = ".git.macfix-m1-retired"; + +export type LaunchPreflightFindingCode = + | "git-startup-failed" + | "git-probe-timed-out" + | "git-capability-missing" + | "git-probe-failed" + | "shared-root-git" + | "root-read-slow" + | "root-read-failed"; + +export type LaunchPreflightSeverity = "warning" | "blocker"; + +export interface LaunchPreflightFinding { + readonly code: LaunchPreflightFindingCode; + readonly severity: LaunchPreflightSeverity; + readonly message: string; +} + +export interface LaunchPreflightResult { + readonly findings: ReadonlyArray; + readonly warnings: ReadonlyArray; + readonly blockers: ReadonlyArray; +} + +export type LaunchPreflightProbeFailureReason = "unavailable" | "timeout" | "failed"; + +export class LaunchPreflightProbeError extends Data.TaggedError("LaunchPreflightProbeError")<{ + readonly reason: LaunchPreflightProbeFailureReason; + readonly detail: string; +}> {} + +export interface LaunchPreflightGitProbe { + /** Returns the parsed Git version, or null when the output had none. */ + readonly version: (root: string) => Effect.Effect; + /** + * Runs the exact capability `GitVcsDriver` needs and resolves the effective + * repository identity for the root. Uses `allowNonZeroExit`, so "not a + * repository" and "unsupported option" are observable states, not failures. + */ + readonly resolveIdentity: ( + root: string, + ) => Effect.Effect; +} + +export interface LaunchPreflightRepoIdentity { + readonly state: "ok" | "not-a-repository" | "unsupported" | "failed"; + /** Effective work-tree top level when `state` is "ok". */ + readonly topLevel: string | null; + /** Effective common Git directory when `state` is "ok". */ + readonly commonDir: string | null; + readonly detail: string; +} + +export interface LaunchPreflightFileProbe { + readonly exists: (target: string) => Effect.Effect; + /** Reads at most `maxBytes` and returns the number of bytes read. */ + readonly readFirstBytes: ( + target: string, + maxBytes: number, + ) => Effect.Effect; + readonly listDirectory: ( + target: string, + ) => Effect.Effect, LaunchPreflightProbeError>; +} + +export interface LaunchPreflightInput { + readonly root: string; + readonly git: LaunchPreflightGitProbe; + readonly files: LaunchPreflightFileProbe; +} + +const parseGitVersion = (output: string): string | null => + output.match(GIT_VERSION_PATTERN)?.[1] ?? null; + +const severityForGitFailure = (rootGitMarker: boolean): LaunchPreflightSeverity => + rootGitMarker ? "blocker" : "warning"; + +const blockerSuffix = + " T3 Code cannot checkpoint this repository, so the session cannot run usefully." + + " Fix Git, then restart T3 Code."; + +/** + * Runs every probe against `root` and returns findings. Bounded by construction: + * each capability call has its own timeout, the whole exploration is capped by + * {@link TOTAL_PROBE_BUDGET}, and the Git subprocesses carry their own bounds. + */ +export const runLaunchPreflight = ( + input: LaunchPreflightInput, +): Effect.Effect => + Effect.gen(function* () { + const path = yield* Path.Path; + const collected = yield* Ref.make>([]); + const add = (finding: LaunchPreflightFinding) => + Ref.update(collected, (current) => [...current, finding]); + + const existsBounded = (target: string) => + input.files.exists(target).pipe( + Effect.timeoutOption(EXISTS_PROBE_TIMEOUT), + Effect.map(Option.getOrElse(() => false)), + Effect.orElseSucceed(() => false), + ); + + const explore = Effect.gen(function* () { + // `root/.git` (a directory or a worktree/submodule file) is the evidence + // that the root is expected to be a repository. A retired marker such as + // `.git.macfix-m1-retired` is a different path and is never counted. + const rootGitMarker = yield* existsBounded(path.join(input.root, ".git")); + + const gitOutcome = yield* input.git.version(input.root).pipe( + Effect.map((version) => ({ _tag: "ok" as const, version })), + Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), + Effect.timeoutOption(GIT_PROBE_TIMEOUT), + Effect.map(Option.getOrElse(() => ({ _tag: "timeout" as const }))), + ); + + let effectiveRootRepository = rootGitMarker; + + if (gitOutcome._tag === "ok") { + if (gitOutcome.version === null) { + yield* add({ + code: "git-probe-failed", + severity: "warning", + message: + "The Git version probe returned no version. Sessions that checkpoint, diff, or open a repository may fail.", + }); + } + + const identityOutcome = yield* input.git.resolveIdentity(input.root).pipe( + Effect.map((identity) => ({ _tag: "ok" as const, identity })), + Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), + Effect.timeoutOption(GIT_PROBE_TIMEOUT), + Effect.map(Option.getOrElse(() => ({ _tag: "timeout" as const }))), + ); + + if (identityOutcome._tag === "ok") { + const identity = identityOutcome.identity; + switch (identity.state) { + case "ok": { + effectiveRootRepository = true; + break; + } + case "unsupported": { + yield* add({ + code: "git-capability-missing", + severity: severityForGitFailure(rootGitMarker), + message: + "The Git this launch resolves does not support `git rev-parse --path-format`, which T3 " + + `Code ${MINIMUM_GIT_VERSION}+ uses to checkpoint changes.` + + (rootGitMarker + ? blockerSuffix + : " Put a newer Git earlier on PATH (for example /usr/local/bin before /usr/bin) and restart T3 Code."), + }); + break; + } + case "not-a-repository": { + effectiveRootRepository = false; + break; + } + case "failed": { + yield* add({ + code: "git-probe-failed", + severity: "warning", + message: + "The Git repository probe failed. Git-backed sessions may fail; check the Git install and the session-root filesystem.", + }); + break; + } + } + } else if (identityOutcome._tag === "timeout") { + yield* add({ + code: "git-probe-timed-out", + severity: severityForGitFailure(rootGitMarker), + message: + "The Git repository probe did not finish in time. Git or the session-root filesystem may be " + + "stalled; Git-backed sessions may hang. Materialize the root and check the Git install." + + (rootGitMarker ? blockerSuffix : ""), + }); + } else { + yield* add({ + code: "git-probe-failed", + severity: "warning", + message: + `The Git repository probe failed (${identityOutcome.error.detail}). Git-backed sessions may fail.`, + }); + } + } else if (gitOutcome._tag === "timeout" || gitOutcome.error.reason === "timeout") { + yield* add({ + code: "git-probe-timed-out", + severity: severityForGitFailure(rootGitMarker), + message: + "The Git version probe did not finish in time. Git or the session-root filesystem may be " + + "stalled; Git-backed sessions may hang. Materialize the root and check the Git install." + + (rootGitMarker ? blockerSuffix : ""), + }); + } else if (gitOutcome.error.reason === "unavailable") { + yield* add({ + code: "git-startup-failed", + severity: severityForGitFailure(rootGitMarker), + message: + "Git could not be started from the session-root PATH (not found or not executable)." + + (rootGitMarker + ? blockerSuffix + : " Sessions that checkpoint, diff, or open a repository will fail. Install Git or put its " + + "directory earlier on PATH, then restart T3 Code."), + }); + } else { + yield* add({ + code: "git-probe-failed", + severity: "warning", + message: + `The Git version probe failed (${gitOutcome.error.detail}). Git-backed sessions may fail.`, + }); + } + + if (effectiveRootRepository) { + const children = yield* input.files.listDirectory(input.root).pipe( + Effect.timeoutOption(ROOT_LIST_TIMEOUT), + Effect.map(Option.getOrElse((): ReadonlyArray => [])), + Effect.orElseSucceed((): ReadonlyArray => []), + ); + const nestedRepo = yield* Effect.forEach( + children + .filter( + (child) => + child.length > 0 && child !== "." && child !== ".." && child !== RETIRED_GIT_MARKER, + ) + .slice(0, MAX_ROOT_CHILDREN), + (child) => existsBounded(path.join(input.root, child, ".git")), + { concurrency: 8 }, + ); + if (nestedRepo.some(Boolean)) { + yield* add({ + code: "shared-root-git", + severity: "warning", + message: + `The shared session root ${input.root} is itself a Git repository and contains nested ` + + "repositories. Projects beneath it will share one repository, and T3 checkpoints may be " + + `written into that umbrella. Move or retire ${path.join(input.root, ".git")} if it is unintended.`, + }); + } + } + + let readTarget: string | undefined; + for (const relativePath of READ_PROBE_CANDIDATES) { + const candidate = path.join(input.root, relativePath); + if (yield* existsBounded(candidate)) { + readTarget = candidate; + break; + } + } + + if (readTarget !== undefined) { + const readOutcome = yield* input.files.readFirstBytes(readTarget, READ_PROBE_MAX_BYTES).pipe( + Effect.map((bytes) => ({ _tag: "ok" as const, bytes })), + Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), + Effect.timeoutOption(READ_PROBE_TIMEOUT), + Effect.map(Option.getOrElse(() => ({ _tag: "timeout" as const }))), + ); + if (readOutcome._tag === "timeout") { + yield* add({ + code: "root-read-slow", + severity: "warning", + message: + `Reading ${readTarget} under the session root did not finish in time. The filesystem may ` + + 'be stalled or cloud-offloaded; use "Keep Downloaded" on the folder before starting ' + + "sessions.", + }); + } else if (readOutcome._tag === "error") { + yield* add({ + code: "root-read-failed", + severity: "warning", + message: + `Could not read ${readTarget} under the session root (${readOutcome.error.detail}). ` + + "Sessions may fail to read the workspace.", + }); + } + } + }); + + // The whole exploration shares one wall-clock budget. Findings already + // collected survive a mid-probe timeout; a probe that cannot be interrupted + // is still individually bounded above. + yield* explore.pipe(Effect.timeoutOption(TOTAL_PROBE_BUDGET)); + + const findings = yield* Ref.get(collected); + return { + findings, + warnings: findings.filter((finding) => finding.severity === "warning"), + blockers: findings.filter((finding) => finding.severity === "blocker"), + } satisfies LaunchPreflightResult; + }); + +const classifyGitProbeError = (error: VcsError): LaunchPreflightProbeError => { + if (error._tag === "VcsProcessSpawnError") { + return new LaunchPreflightProbeError({ reason: "unavailable", detail: error.message }); + } + if (error._tag === "VcsProcessTimeoutError") { + return new LaunchPreflightProbeError({ reason: "timeout", detail: error.message }); + } + return new LaunchPreflightProbeError({ reason: "failed", detail: error.message }); +}; + +const classifyIdentityOutput = (output: { stdout: string; stderr: string; code: number }): LaunchPreflightRepoIdentity => { + const stderr = output.stderr.trim(); + if (output.code === 0) { + const lines = output.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + return { + state: "ok", + topLevel: lines[0] ?? null, + commonDir: lines[1] ?? null, + detail: "", + }; + } + if (/not a git repository/i.test(stderr) || /must be run in a work tree/i.test(stderr)) { + return { state: "not-a-repository", topLevel: null, commonDir: null, detail: stderr }; + } + if (/unknown option|usage: git rev-parse|path-format/i.test(stderr)) { + return { state: "unsupported", topLevel: null, commonDir: null, detail: stderr }; + } + return { state: "failed", topLevel: null, commonDir: null, detail: stderr }; +}; + +export class LaunchPreflight extends Context.Service< + LaunchPreflight, + { + readonly run: (root: string) => Effect.Effect; + } +>()("t3/environment/LaunchPreflight") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const vcsProcess = yield* VcsProcess.VcsProcess; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const git: LaunchPreflightGitProbe = { + version: (root) => + vcsProcess + .run({ + operation: "launch-preflight.git-version", + command: "git", + args: ["--version"], + cwd: root, + timeoutMs: 1_500, + maxOutputBytes: 4_000, + }) + .pipe( + Effect.map((result) => parseGitVersion(result.stdout) ?? parseGitVersion(result.stderr)), + Effect.mapError(classifyGitProbeError), + ), + resolveIdentity: (root) => + vcsProcess + .run({ + operation: "launch-preflight.git-identity", + command: "git", + args: ["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-common-dir"], + cwd: root, + timeoutMs: 1_500, + maxOutputBytes: 4_000, + allowNonZeroExit: true, + }) + .pipe( + Effect.map((result) => + classifyIdentityOutput({ + stdout: result.stdout, + stderr: result.stderr, + code: Number(result.exitCode), + }), + ), + Effect.mapError(classifyGitProbeError), + ), + }; + + const files: LaunchPreflightFileProbe = { + exists: (target) => fileSystem.exists(target).pipe(Effect.orElseSucceed(() => false)), + readFirstBytes: (target, maxBytes) => + fileSystem.stream(target, { bytesToRead: maxBytes }).pipe( + Stream.runCount, + Effect.mapError( + (cause) => new LaunchPreflightProbeError({ reason: "failed", detail: String(cause) }), + ), + ), + listDirectory: (target) => + fileSystem.readDirectory(target).pipe( + Effect.mapError( + (cause) => new LaunchPreflightProbeError({ reason: "failed", detail: String(cause) }), + ), + ), + }; + + return LaunchPreflight.of({ + run: (root: string) => + runLaunchPreflight({ root, git, files }).pipe(Effect.provideService(Path.Path, path)), + }); +}); + +export const layer = Layer.effect(LaunchPreflight, make); diff --git a/apps/server/src/provider/Errors.ts b/apps/server/src/provider/Errors.ts index cdeeb3b922d4..192cf323addb 100644 --- a/apps/server/src/provider/Errors.ts +++ b/apps/server/src/provider/Errors.ts @@ -101,6 +101,26 @@ export class ProviderWorkspaceMissingError extends Schema.TaggedError()( + "ProviderLaunchPreflightBlockedError", + { + threadId: Schema.String, + cwd: Schema.String, + code: Schema.String, + detail: Schema.String, + }, +) { + override get message(): string { + return this.detail; + } +} + /** * ProviderValidationError - Invalid provider API input. */ @@ -214,6 +234,7 @@ export type ProviderServiceError = | ProviderValidationError | ProviderUnsupportedError | ProviderWorkspaceMissingError + | ProviderLaunchPreflightBlockedError | ProviderInstanceNotFoundError | ProviderSessionNotFoundError | ProviderSessionDirectoryPersistenceError diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index a88cfdef5ffe..16f8610a471a 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -73,6 +73,7 @@ import { import { ProviderAdapterRequestError, type ProviderAdapterError, + ProviderLaunchPreflightBlockedError, ProviderValidationError, ProviderWorkspaceMissingError, } from "../Errors.ts"; @@ -87,6 +88,7 @@ import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as LaunchPreflight from "../../environment/LaunchPreflight.ts"; const isModelSelection = Schema.is(ModelSelection); const encodePromptJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -257,6 +259,17 @@ export interface ProviderServiceLiveOptions { * test see whether a credential was requested at all. */ readonly issueMcpCredential?: typeof McpSessionRegistry.issueActiveMcpCredential; + /** + * Sink for launch-preflight warnings, so they reach the user instead of only + * the server log. The composition root wires this to an existing + * user-visible transport (a thread activity append). Failures are swallowed. + */ + readonly reportLaunchPreflightWarning?: (input: { + readonly threadId: ThreadId; + readonly cwd: string; + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }) => Effect.Effect; } interface TurnAnalyticsMetadata { @@ -508,6 +521,61 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential; const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; + const launchPreflight = yield* LaunchPreflight.LaunchPreflight; + + /** + * Runs the bounded launch preflight against the exact cwd a provider process + * is about to start in, before the caller's own workspace read. Warnings are + * surfaced through the existing log and the injected user-visible sink; + * blockers stop the launch with an actionable error. + */ + const guardProviderLaunch = Effect.fn("ProviderService.guardProviderLaunch")(function* (input: { + readonly threadId: ThreadId; + readonly cwd: string; + }) { + const result = yield* launchPreflight.run(input.cwd); + for (const warning of result.warnings) { + yield* Effect.logWarning(`launch preflight: ${warning.message}`, { + code: warning.code, + threadId: input.threadId, + cwd: input.cwd, + }); + if (options?.reportLaunchPreflightWarning) { + yield* options + .reportLaunchPreflightWarning({ + threadId: input.threadId, + cwd: input.cwd, + code: warning.code, + message: warning.message, + }) + .pipe(Effect.catchCause(() => Effect.void)); + } + } + const blocker = result.blockers[0]; + if (blocker !== undefined) { + yield* Effect.logError(`launch preflight blocked provider launch: ${blocker.message}`, { + code: blocker.code, + threadId: input.threadId, + cwd: input.cwd, + }); + if (options?.reportLaunchPreflightWarning) { + yield* options + .reportLaunchPreflightWarning({ + threadId: input.threadId, + cwd: input.cwd, + code: blocker.code, + message: blocker.message, + }) + .pipe(Effect.catchCause(() => Effect.void)); + } + return yield* new ProviderLaunchPreflightBlockedError({ + threadId: input.threadId, + cwd: input.cwd, + code: blocker.code, + detail: blocker.message, + }); + } + }); const runtimeEventPubSub = yield* PubSub.unbounded(); const pendingCompactions = new Map(); const timedOutNativeCompactions = new Set(); @@ -1292,6 +1360,13 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const persistedCwd = readPersistedCwd(input.binding.runtimePayload); const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); + if (persistedCwd) { + yield* guardProviderLaunch({ + threadId: input.binding.threadId, + cwd: persistedCwd, + }); + } + yield* prepareMcpSession(input.binding.threadId, bindingInstanceId); const resumed = yield* adapter .startSession({ @@ -1508,6 +1583,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.cwd.effective": effectiveCwd ?? "", }); if (effectiveCwd !== undefined) { + yield* guardProviderLaunch({ threadId, cwd: effectiveCwd }); // Fail fast with an actionable error when the workspace folder is // gone (e.g. moved, deleted, or replaced by a plain file). // Otherwise every adapter surfaces this as a misleading "failed to @@ -2447,8 +2523,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( export const ProviderServiceLive = Layer.effect( ProviderService.ProviderService, makeProviderService(), -); +).pipe(Layer.provide(LaunchPreflight.layer)); export function makeProviderServiceLive(options?: ProviderServiceLiveOptions) { - return Layer.effect(ProviderService.ProviderService, makeProviderService(options)); + return Layer.effect(ProviderService.ProviderService, makeProviderService(options)).pipe( + Layer.provide(LaunchPreflight.layer), + ); } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3fd0bb7274a0..72a86da0d060 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -4,11 +4,15 @@ import * as NodeHttp from "node:http"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { + CommandId, EnvironmentHttpApi, + EventId, ProviderDriverKind, type RepositoryIdentity, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -48,7 +52,8 @@ import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRe import * as ModelManifest from "./provider/ModelManifest.ts"; import * as CodexResetCredit from "./provider/Layers/codexResetCredit.ts"; import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; -import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; +import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts"; +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts"; import { AntigravityInstallation } from "./provider/AntigravityInstallation.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; @@ -267,7 +272,38 @@ const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( // `create()`; `ProviderEventLoggers.layer` owns the shared native/canonical // NDJSON writers and is provided at the outer runtime layer so both // `ProviderService` and the per-instance drivers read the same logger pair. -const ProviderLayerLive = ProviderServiceLive.pipe( +// Launch-preflight warnings are surfaced through the existing thread-activity +// transport so they reach the user, not just the server log. The preflight +// service itself already runs inside `ProviderService` before each provider +// start; this sink is injected here because only the composition root has the +// orchestration engine. +const ProviderLayerLive = Layer.unwrapEffect( + Effect.gen(function* () { + const orchestrationEngine = yield* OrchestrationEngineService; + const crypto = yield* Crypto.Crypto; + return makeProviderServiceLive({ + reportLaunchPreflightWarning: ({ threadId, cwd, code, message }) => + Effect.gen(function* () { + const createdAt = DateTime.formatIso(yield* DateTime.now); + yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId, + activity: { + id: EventId.make(yield* crypto.randomUUIDv4), + tone: "error", + kind: "launch.preflight", + summary: message, + payload: { code, cwd }, + turnId: null, + createdAt, + }, + createdAt, + }); + }).pipe(Effect.catchCause(() => Effect.void)), + }); + }), +).pipe( Layer.provide(ProviderAdapterRegistryLive), Layer.provideMerge(ProviderSessionDirectoryLayerLive), ); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 1468e1efecb0..3a8f7d06eacd 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -34,6 +34,7 @@ import * as Scope from "effect/Scope"; import * as ServerConfig from "./config.ts"; import * as Keybindings from "./keybindings.ts"; +import * as LaunchPreflight from "./environment/LaunchPreflight.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -907,6 +908,7 @@ export const make = (options?: StartupOptions) => const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const launchPreflight = yield* LaunchPreflight.LaunchPreflight; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const crypto = yield* Crypto.Crypto; @@ -960,6 +962,41 @@ export const make = (options?: StartupOptions) => ), ); + yield* Effect.logDebug("startup phase: running launch preflight"); + yield* runStartupPhase( + "launch.preflight", + launchPreflight.run(serverConfig.cwd).pipe( + Effect.tap((result) => + Effect.gen(function* () { + yield* Effect.forEach( + result.warnings, + (warning) => + Effect.logWarning(`launch preflight: ${warning.message}`, { + code: warning.code, + severity: warning.severity, + cwd: serverConfig.cwd, + }), + { discard: true }, + ); + yield* Effect.forEach( + result.blockers, + (blocker) => + Effect.logError(`launch preflight: ${blocker.message}`, { + code: blocker.code, + severity: blocker.severity, + cwd: serverConfig.cwd, + }), + { discard: true }, + ); + }), + ), + Effect.asVoid, + Effect.catchCause((cause) => + Effect.logWarning("launch preflight failed to run", { cause }), + ), + ), + ); + yield* Effect.logDebug("startup phase: parking orchestration roots at activation"); yield* runStartupPhase( "reactors.start", @@ -1132,6 +1169,6 @@ export const make = (options?: StartupOptions) => }); export const layerWithOptions = (options?: StartupOptions) => - Layer.effect(ServerRuntimeStartup, make(options)); + Layer.effect(ServerRuntimeStartup, make(options)).pipe(Layer.provide(LaunchPreflight.layer)); export const layer = layerWithOptions(); From 6f6b44db24c8bdba826b19b7cb0c082324bcae30 Mon Sep 17 00:00:00 2001 From: nullStack65 Date: Sat, 26 Sep 2026 21:07:03 -0400 Subject: [PATCH 02/18] test(server): real-Git smoke for the umbrella-repository preflight Add a disposable nested-repository fixture that exercises the real resolved Git executable and asserts the warning fires without writing to the root. --- .../src/environment/LaunchPreflight.test.ts | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/apps/server/src/environment/LaunchPreflight.test.ts b/apps/server/src/environment/LaunchPreflight.test.ts index c64c15d1005c..ba3d228cb69a 100644 --- a/apps/server/src/environment/LaunchPreflight.test.ts +++ b/apps/server/src/environment/LaunchPreflight.test.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off - real temp directories exercise the bounded read probe. import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeFS from "node:fs/promises"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { assert, it } from "@effect/vitest"; @@ -276,10 +277,10 @@ it.effect("resolves every probe within the total budget even when all probes han it.effect("wires the real service probes and passes a healthy root", () => Effect.gen(function* () { const directory = yield* Effect.promise(() => - NodeFS.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-launch-preflight-")), + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-launch-preflight-")), ); yield* Effect.promise(() => - NodeFS.writeFile(NodePath.join(directory, "package.json"), '{"name":"preflight"}\n'), + NodeFSP.writeFile(NodePath.join(directory, "package.json"), '{"name":"preflight"}\n'), ); const layer = LaunchPreflight.layer.pipe( @@ -307,6 +308,34 @@ it.effect("wires the real service probes and passes a healthy root", () => ); assert.deepStrictEqual(result.findings, []); - yield* Effect.promise(() => NodeFS.rm(directory, { recursive: true, force: true })); + yield* Effect.promise(() => NodeFSP.rm(directory, { recursive: true, force: true })); + }), +); + +it.effect("real Git probes flag a disposable umbrella repository and never write to it", () => + Effect.gen(function* () { + const base = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-launch-preflight-real-")), + ); + const umbrella = NodePath.join(base, "umbrella"); + const nested = NodePath.join(umbrella, "project"); + yield* Effect.promise(() => NodeFSP.mkdir(nested, { recursive: true })); + NodeChildProcess.execFileSync("git", ["init", "-q"], { cwd: umbrella }); + NodeChildProcess.execFileSync("git", ["init", "-q"], { cwd: nested }); + const before = (yield* Effect.promise(() => NodeFSP.readdir(umbrella))).sort(); + + const layer = LaunchPreflight.layer.pipe( + Layer.provide(VcsProcess.layer), + Layer.provide(NodeServices.layer), + ); + + const result = yield* LaunchPreflight.LaunchPreflight.pipe( + Effect.flatMap((preflight) => preflight.run(umbrella)), + Effect.provide(layer), + ); + + assert.include(codes(result), "shared-root-git"); + assert.deepStrictEqual((yield* Effect.promise(() => NodeFSP.readdir(umbrella))).sort(), before); + yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); }), ); From 93e92a74a8963b129465b21306c8aa08e7b047e1 Mon Sep 17 00:00:00 2001 From: nullStack65 Date: Sat, 26 Sep 2026 21:03:43 -0400 Subject: [PATCH 03/18] test(server): cover launch-preflight guard on new-session and recovery paths Add new-session and recovery integration coverage and a runner override seam. Only probe a real directory so missing/file workspaces still yield ProviderWorkspaceMissingError. --- .../providerService.integration.test.ts | 131 +++++++++++++++++- .../src/environment/LaunchPreflight.test.ts | 37 +++-- .../src/provider/Layers/ProviderService.ts | 38 ++++- apps/server/src/server.ts | 2 +- 4 files changed, 185 insertions(+), 23 deletions(-) diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 0d041b36ecb8..093f19bd46b6 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -19,11 +19,13 @@ import { ProviderEventLoggers, } from "../src/provider/Layers/ProviderEventLoggers.ts"; import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; +import { ProviderLaunchPreflightBlockedError } from "../src/provider/Errors.ts"; import { ProviderService, type ProviderServiceShape, } from "../src/provider/Services/ProviderService.ts"; import * as ServerConfig from "../src/config.ts"; +import * as LaunchPreflight from "../src/environment/LaunchPreflight.ts"; import { ServerSettingsService } from "../src/serverSettings.ts"; import { AnalyticsService } from "../src/telemetry/AnalyticsService.ts"; import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; @@ -78,7 +80,18 @@ const makeRecordingAnalytics = Effect.gen(function* () { return { layer, get: Ref.get(recorded) } as const; }); -const makeIntegrationFixture = (options?: { readonly analytics?: Layer.Layer }) => +const makeIntegrationFixture = (options?: { + readonly analytics?: Layer.Layer; + readonly launchPreflightRunner?: ( + root: string, + ) => Effect.Effect; + readonly reportLaunchPreflightWarning?: (input: { + readonly threadId: ThreadId; + readonly cwd: string; + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }) => Effect.Effect; +}) => Effect.gen(function* () { const cwd = yield* makeWorkspaceDirectory; const harness = yield* makeTestProviderAdapterHarness(); @@ -100,10 +113,14 @@ const makeIntegrationFixture = (options?: { readonly analytics?: Layer.Layer }).pipe(Effect.provide(fixture.layer)); }).pipe(Effect.provide(NodeServices.layer)), ); + +const blockedFinding: LaunchPreflight.LaunchPreflightFinding = { + code: "git-startup-failed", + severity: "blocker", + message: "Git could not start; the session cannot checkpoint. Fix Git and retry.", +}; + +const warningFinding: LaunchPreflight.LaunchPreflightFinding = { + code: "shared-root-git", + severity: "warning", + message: "The shared session root is itself a Git repository.", +}; + +const findingResult = ( + findings: ReadonlyArray, +): LaunchPreflight.LaunchPreflightResult => ({ + findings, + warnings: findings.filter((finding) => finding.severity === "warning"), + blockers: findings.filter((finding) => finding.severity === "blocker"), +}); + +it.live("a launch-preflight blocker prevents the new provider session from starting", () => + Effect.gen(function* () { + const fixture = yield* makeIntegrationFixture({ + launchPreflightRunner: () => Effect.succeed(findingResult([blockedFinding])), + }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const error = yield* provider + .startSession(ThreadId.make("thread-preflight-blocked"), { + threadId: ThreadId.make("thread-preflight-blocked"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: fixture.cwd, + runtimeMode: "full-access", + }) + .pipe(Effect.flip); + + assert.instanceOf(error, ProviderLaunchPreflightBlockedError); + const sessions = yield* fixture.harness.adapter.listSessions(); + assert.equal(sessions.length, 0); + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("a launch-preflight warning is reported and the session still starts once", () => + Effect.gen(function* () { + const reported = yield* Ref.make>([]); + const fixture = yield* makeIntegrationFixture({ + launchPreflightRunner: () => Effect.succeed(findingResult([warningFinding])), + reportLaunchPreflightWarning: ({ message }) => + Ref.update(reported, (current) => [...current, message]), + }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const session = yield* provider.startSession(ThreadId.make("thread-preflight-warned"), { + threadId: ThreadId.make("thread-preflight-warned"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: fixture.cwd, + runtimeMode: "full-access", + }); + + assert.equal((session.threadId ?? "").length > 0, true); + assert.deepStrictEqual(yield* Ref.get(reported), [warningFinding.message]); + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("the recovery path also invokes the launch preflight", () => + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const fixture = yield* makeIntegrationFixture({ + launchPreflightRunner: () => + Ref.updateAndGet(calls, (count) => count + 1).pipe( + Effect.map((count) => findingResult(count === 1 ? [] : [blockedFinding])), + ), + }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = ThreadId.make("thread-preflight-recovery"); + yield* provider.startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: fixture.cwd, + runtimeMode: "full-access", + }); + + // Drop the adapter session but keep the persisted binding so the next + // sendTurn must recover it. + yield* fixture.harness.adapter.stopSession(threadId); + + const error = yield* provider + .sendTurn({ threadId, input: "recover me", attachments: [] }) + .pipe(Effect.flip); + + assert.instanceOf(error, ProviderLaunchPreflightBlockedError); + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/environment/LaunchPreflight.test.ts b/apps/server/src/environment/LaunchPreflight.test.ts index ba3d228cb69a..d644fe2775e2 100644 --- a/apps/server/src/environment/LaunchPreflight.test.ts +++ b/apps/server/src/environment/LaunchPreflight.test.ts @@ -33,7 +33,8 @@ const gitProbe = ( overrides: Partial = {}, ): LaunchPreflight.LaunchPreflightGitProbe => ({ version: () => Effect.succeed("2.55.0"), - resolveIdentity: () => Effect.succeed(identity({ state: "not-a-repository", topLevel: null, commonDir: null })), + resolveIdentity: () => + Effect.succeed(identity({ state: "not-a-repository", topLevel: null, commonDir: null })), ...overrides, }); @@ -71,7 +72,8 @@ it.effect("passes an ordinary repository session with a supported Git", () => Effect.succeed(identity({ topLevel: "/repo", commonDir: "/repo/.git" })), }), files: { - exists: (target) => Effect.succeed(target === "/repo/.git" || target.endsWith("package.json")), + exists: (target) => + Effect.succeed(target === "/repo/.git" || target.endsWith("package.json")), listDirectory: () => Effect.succeed(["src", "docs"]), }, }), @@ -96,7 +98,9 @@ it.effect("does not count a retired Git marker as an umbrella by its name alone" root: "/Documents", git: gitProbe({ resolveIdentity: () => - Effect.succeed(identity({ state: "not-a-repository", topLevel: null, commonDir: null })), + Effect.succeed( + identity({ state: "not-a-repository", topLevel: null, commonDir: null }), + ), }), files: { exists: (target) => Effect.succeed(target === "/Documents/.git.macfix-m1-retired"), @@ -140,7 +144,8 @@ it.effect("does not flag an ordinary repository root that has no nested reposito resolveIdentity: () => Effect.succeed(identity({ topLevel: "/repo" })), }), files: { - exists: (target) => Effect.succeed(target === "/repo/.git" || target.endsWith("package.json")), + exists: (target) => + Effect.succeed(target === "/repo/.git" || target.endsWith("package.json")), listDirectory: () => Effect.succeed(["src", "docs"]), }, }), @@ -172,7 +177,7 @@ it.effect("blocks when Git cannot start and the session root is a repository", ( ); assert.deepStrictEqual(severities(result), ["blocker"]); - assert.include(result.blockers[0]?.message ?? "", "/repo/.git"); + assert.include(result.blockers[0]?.message ?? "", "could not be started"); }), ); @@ -184,7 +189,12 @@ it.effect("blocks when the resolved Git lacks --path-format and the root is a re git: gitProbe({ resolveIdentity: () => Effect.succeed( - identity({ state: "unsupported", topLevel: null, commonDir: null, detail: "git 2.24.3" }), + identity({ + state: "unsupported", + topLevel: null, + commonDir: null, + detail: "git 2.24.3", + }), ), }), files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, @@ -192,7 +202,7 @@ it.effect("blocks when the resolved Git lacks --path-format and the root is a re ); assert.deepStrictEqual(severities(result), ["blocker"]); - assert.include(result.blockers[0]?.message ?? "", "2.24.3"); + assert.include(result.blockers[0]?.message ?? "", "path-format"); }), ); @@ -225,9 +235,9 @@ it.effect("flags a Git probe that reports a timeout", () => it.effect("bounds a hung Git probe instead of holding the launch", () => Effect.gen(function* () { - const fiber = yield* run( - input({ git: { ...gitProbe(), version: () => Effect.never } }), - ).pipe(Effect.forkChild); + const fiber = yield* run(input({ git: { ...gitProbe(), version: () => Effect.never } })).pipe( + Effect.forkChild, + ); yield* Effect.yieldNow; yield* TestClock.adjust("1500 millis"); const result = yield* Fiber.join(fiber); @@ -289,10 +299,9 @@ it.effect("wires the real service probes and passes a healthy root", () => run: (processInput) => Effect.succeed({ exitCode: ChildProcessSpawner.ExitCode(0), - stdout: - processInput.args.includes("--version") - ? "git version 2.55.0\n" - : `${directory}\n${NodePath.join(directory, ".git")}\n`, + stdout: processInput.args.includes("--version") + ? "git version 2.55.0\n" + : `${directory}\n${NodePath.join(directory, ".git")}\n`, stderr: "", stdoutTruncated: false, stderrTruncated: false, diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 16f8610a471a..3008a4de0e9a 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -89,6 +89,7 @@ import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as LaunchPreflight from "../../environment/LaunchPreflight.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; const isModelSelection = Schema.is(ModelSelection); const encodePromptJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -270,6 +271,13 @@ export interface ProviderServiceLiveOptions { readonly code: LaunchPreflight.LaunchPreflightFindingCode; readonly message: string; }) => Effect.Effect; + /** + * Overrides the launch-preflight runner. Tests use this to force a warning or + * a blocker without a broken Git install. + */ + readonly launchPreflightRunner?: ( + root: string, + ) => Effect.Effect; } interface TurnAnalyticsMetadata { @@ -522,6 +530,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const launchPreflight = yield* LaunchPreflight.LaunchPreflight; + const runLaunchPreflight = options?.launchPreflightRunner ?? launchPreflight.run; /** * Runs the bounded launch preflight against the exact cwd a provider process @@ -533,7 +542,25 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( readonly threadId: ThreadId; readonly cwd: string; }) { - const result = yield* launchPreflight.run(input.cwd); + // Only probe a real directory: spawning Git in a missing path or a plain + // file would fail at the OS layer. The caller's own workspace read (and + // `ProviderWorkspaceMissingError`) handles those cases. + const workspaceStat = yield* fileSystem + .stat(input.cwd) + .pipe(Effect.orElseSucceed(() => undefined)); + if (workspaceStat === undefined || workspaceStat.type !== "Directory") { + return; + } + + const result = yield* runLaunchPreflight(input.cwd).pipe( + Effect.catchCause(() => + Effect.succeed({ + findings: [] as ReadonlyArray, + warnings: [] as ReadonlyArray, + blockers: [] as ReadonlyArray, + }), + ), + ); for (const warning of result.warnings) { yield* Effect.logWarning(`launch preflight: ${warning.message}`, { code: warning.code, @@ -2520,13 +2547,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( } satisfies ProviderService.ProviderService["Service"]; }); +// A self-contained preflight: the Git probe uses its own `VcsProcess` so this +// layer only needs the platform services (`FileSystem`, `Path`, +// `ChildProcessSpawner`) already present in the server and test harnesses. +const LaunchPreflightLive = LaunchPreflight.layer.pipe(Layer.provide(VcsProcess.layer)); + export const ProviderServiceLive = Layer.effect( ProviderService.ProviderService, makeProviderService(), -).pipe(Layer.provide(LaunchPreflight.layer)); +).pipe(Layer.provide(LaunchPreflightLive)); export function makeProviderServiceLive(options?: ProviderServiceLiveOptions) { return Layer.effect(ProviderService.ProviderService, makeProviderService(options)).pipe( - Layer.provide(LaunchPreflight.layer), + Layer.provide(LaunchPreflightLive), ); } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 72a86da0d060..1abff5567fb1 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -277,7 +277,7 @@ const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( // service itself already runs inside `ProviderService` before each provider // start; this sink is injected here because only the composition root has the // orchestration engine. -const ProviderLayerLive = Layer.unwrapEffect( +const ProviderLayerLive = Layer.unwrap( Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const crypto = yield* Crypto.Crypto; From 46c4bcb080c54a613a365e859c4de28f507c0732 Mon Sep 17 00:00:00 2001 From: business account Date: Sun, 27 Sep 2026 03:53:06 -0400 Subject: [PATCH 04/18] =?UTF-8?q?fix(server):=20launch-preflight=20repairs?= =?UTF-8?q?=20=E2=80=94=20non-blocking=20optional=20Git,=20real=20launch?= =?UTF-8?q?=20proof?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1: resolve repository identity with plain `rev-parse --show-toplevel` / `--git-common-dir` (relative common dir resolved against the top level) and never require the optional `--path-format` flag merely to identify the root. The `--path-format` capability is now probed for context only and always warns; the unsupported "T3 Code 2.31.0+" wording is removed. Maintained GitVcsDriver already catches the optional index-reuse failure (~line 901) and rebuilds the temporary index, so a disposable Git that rejects `--path-format` still launches and captures a normal checkpoint (new real-Git regression). R2: the selected instance's configured executable start is exercised through the existing bounded provider spawn/error path (resolveSpawnCommand + real spawn), proving a missing configured executable returns a useful reason before any model work on both new-session and recovery/resume launches — reusing the adapter path instead of duplicating resolution logic. R3: the guard directory stat and the workspace-missing stat are bounded. Umbrella detection now uses exact normalized root identity plus explicit configuration (ServerConfig.cwd) with a canonicalizing realPath so /var vs /private/var aliases compare correctly; child enumeration/counting is removed. A live wall-clock test proves a hung resolved Git finishes within the documented budget and its child is cleaned up. R4: warning delivery keeps the existing thread-activity transport; new integration coverage launches a real configured dummy executable exactly once while the client receives the actionable warning. No push/merge/install. E1/E2 worktrees and unrelated work preserved. --- .../providerService.integration.test.ts | 167 +++++++- .../src/environment/LaunchPreflight.test.ts | 311 +++++++++++--- .../server/src/environment/LaunchPreflight.ts | 397 ++++++++++-------- .../src/provider/Layers/ProviderService.ts | 49 ++- apps/server/src/serverRuntimeStartup.ts | 2 +- 5 files changed, 695 insertions(+), 231 deletions(-) diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 093f19bd46b6..2e7b5278a33a 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -1,25 +1,37 @@ +// @effect-diagnostics nodeBuiltinImport:off - the real dummy-executable fixture writes a launcher with node fs/path. import type { ProviderRuntimeEvent } from "@t3tools/contracts"; -import { ProviderDriverKind, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { GrokSettings, ProviderDriverKind, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import { it, assert } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts"; import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; +import { ProviderSessionDirectory } from "../src/provider/Services/ProviderSessionDirectory.ts"; +import { makeGrokAdapter } from "../src/provider/Layers/GrokAdapter.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers, } from "../src/provider/Layers/ProviderEventLoggers.ts"; import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; -import { ProviderLaunchPreflightBlockedError } from "../src/provider/Errors.ts"; +import { + ProviderAdapterProcessError, + ProviderLaunchPreflightBlockedError, +} from "../src/provider/Errors.ts"; import { ProviderService, type ProviderServiceShape, @@ -27,6 +39,7 @@ import { import * as ServerConfig from "../src/config.ts"; import * as LaunchPreflight from "../src/environment/LaunchPreflight.ts"; import { ServerSettingsService } from "../src/serverSettings.ts"; +import { execScriptSource, writeFakeCli } from "../src/testUtils/fakeCli.ts"; import { AnalyticsService } from "../src/telemetry/AnalyticsService.ts"; import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; import * as ProviderSessionRuntime from "../src/persistence/ProviderSessionRuntime.ts"; @@ -43,6 +56,8 @@ import { } from "./fixtures/providerRuntime.ts"; const codexInstanceId = ProviderInstanceId.make("codex"); +const grokInstanceId = ProviderInstanceId.make("grok"); +const decodeGrokSettings = Schema.decodeSync(GrokSettings); const makeWorkspaceDirectory = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -55,7 +70,7 @@ const makeWorkspaceDirectory = Effect.gen(function* () { interface IntegrationFixture { readonly cwd: string; readonly harness: TestProviderAdapterHarness; - readonly layer: Layer.Layer; + readonly layer: Layer.Layer; } interface RecordedAnalyticsEvent { @@ -82,8 +97,10 @@ const makeRecordingAnalytics = Effect.gen(function* () { const makeIntegrationFixture = (options?: { readonly analytics?: Layer.Layer; + readonly grokBinaryPath?: string; readonly launchPreflightRunner?: ( root: string, + options?: { readonly isSharedRoot?: boolean }, ) => Effect.Effect; readonly reportLaunchPreflightWarning?: (input: { readonly threadId: ThreadId; @@ -96,8 +113,24 @@ const makeIntegrationFixture = (options?: { const cwd = yield* makeWorkspaceDirectory; const harness = yield* makeTestProviderAdapterHarness(); + // A real adapter whose configured executable is the caller's path, so a + // launch exercises the actual platform spawn/error path (not a mock). + const realAdapters = + options?.grokBinaryPath === undefined + ? {} + : { + [ProviderDriverKind.make("grok")]: yield* makeGrokAdapter( + decodeGrokSettings({ binaryPath: options.grokBinaryPath }), + ).pipe( + Effect.provide(ServerConfig.layerTest(cwd, cwd)), + Effect.provide(NodeServices.layer), + Effect.orDie, + ), + }; + const registry = makeAdapterRegistryMock({ [ProviderDriverKind.make("codex")]: harness.adapter, + ...realAdapters, }); const directoryLayer = ProviderSessionDirectoryLive.pipe( @@ -120,7 +153,7 @@ const makeIntegrationFixture = (options?: { ...(options?.reportLaunchPreflightWarning !== undefined ? { reportLaunchPreflightWarning: options.reportLaunchPreflightWarning } : {}), - }).pipe(Layer.provide(NodeServices.layer), Layer.provide(shared)); + }).pipe(Layer.provide(NodeServices.layer), Layer.provideMerge(shared)); return { cwd, @@ -470,6 +503,7 @@ it.live("a launch-preflight warning is reported and the session still starts onc }); assert.equal((session.threadId ?? "").length > 0, true); + assert.equal(fixture.harness.getStartCount(), 1); assert.deepStrictEqual(yield* Ref.get(reported), [warningFinding.message]); }).pipe(Effect.provide(fixture.layer)); }).pipe(Effect.provide(NodeServices.layer)), @@ -508,3 +542,128 @@ it.live("the recovery path also invokes the launch preflight", () => }).pipe(Effect.provide(fixture.layer)); }).pipe(Effect.provide(NodeServices.layer)), ); + +// --- R2 / R4: the real configured executable through the real launch composition --------------- + +it.live( + "a missing configured provider executable is reported before model work (new session)", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const missing = path.join(yield* fs.makeTempDirectory(), "grok"); + const fixture = yield* makeIntegrationFixture({ grokBinaryPath: missing }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const directory = yield* ProviderSessionDirectory; + const threadId = ThreadId.make("thread-real-exec-missing-new"); + + const error = yield* provider + .startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("grok"), + providerInstanceId: grokInstanceId, + cwd: fixture.cwd, + runtimeMode: "full-access", + }) + .pipe(Effect.flip); + + assert.instanceOf(error, ProviderAdapterProcessError); + assert.include((error as ProviderAdapterProcessError).message, "grok"); + assert.isAbove((error as ProviderAdapterProcessError).message.length, 0); + // No session was accepted and no turn/model work could have run. + const sessions = yield* fixture.harness.adapter.listSessions(); + assert.equal(sessions.length, 0); + assert.isTrue(Option.isNone(yield* directory.getBinding(threadId))); + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("recovery/resume reports the same missing configured executable before model work", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const missing = path.join(yield* fs.makeTempDirectory(), "grok"); + const fixture = yield* makeIntegrationFixture({ grokBinaryPath: missing }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const directory = yield* ProviderSessionDirectory; + const threadId = ThreadId.make("thread-real-exec-missing-recover"); + + // A persisted binding with resume state is all recovery needs; the + // configured executable is resolved and spawned by the same adapter path + // as a new session. + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("grok"), + providerInstanceId: grokInstanceId, + resumeCursor: { sessionId: "resume-e3" }, + runtimePayload: { cwd: fixture.cwd }, + runtimeMode: "full-access", + }); + + const error = yield* provider + .sendTurn({ threadId, input: "recover me", attachments: [] }) + .pipe(Effect.flip); + + assert.instanceOf(error, ProviderAdapterProcessError); + assert.include((error as ProviderAdapterProcessError).message, "grok"); + assert.isAbove((error as ProviderAdapterProcessError).message.length, 0); + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("a real configured dummy provider executable launches exactly once and warns visibly", () => + Effect.gen(function* () { + const dir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-e3-grok-wrapper-")), + ); + const argvLogPath = NodePath.join(dir, "argv.log"); + const mockAgentPath = NodePath.join( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "../scripts/acp-mock-agent.ts", + ); + const wrapperPath = writeFakeCli({ + directory: dir, + name: "fake-grok-e3", + source: execScriptSource({ scriptPath: mockAgentPath, argvLogPath }), + }); + + const reported = yield* Ref.make>([]); + const fixture = yield* makeIntegrationFixture({ + grokBinaryPath: wrapperPath, + launchPreflightRunner: () => Effect.succeed(findingResult([warningFinding])), + reportLaunchPreflightWarning: ({ message }) => + Ref.update(reported, (current) => [...current, message]), + }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = ThreadId.make("thread-real-exec-healthy"); + const session = yield* provider.startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("grok"), + providerInstanceId: grokInstanceId, + cwd: fixture.cwd, + runtimeMode: "full-access", + }); + + assert.equal(session.provider, "grok"); + assert.isTrue((session.threadId ?? "").length > 0); + // The real configured executable was spawned exactly once, and the client + // still received the actionable warning. + const invocations = yield* Effect.promise(() => + NodeFSP.readFile(argvLogPath, "utf8").then( + (raw) => raw.split("\n").filter((line) => line.trim().length > 0).length, + () => 0, + ), + ); + assert.equal(invocations, 1); + assert.deepStrictEqual(yield* Ref.get(reported), [warningFinding.message]); + }).pipe(Effect.provide(fixture.layer)); + + yield* Effect.promise(() => NodeFSP.rm(dir, { recursive: true, force: true })); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/environment/LaunchPreflight.test.ts b/apps/server/src/environment/LaunchPreflight.test.ts index d644fe2775e2..22f7ddc8cab9 100644 --- a/apps/server/src/environment/LaunchPreflight.test.ts +++ b/apps/server/src/environment/LaunchPreflight.test.ts @@ -1,16 +1,20 @@ -// @effect-diagnostics nodeBuiltinImport:off - real temp directories exercise the bounded read probe. +// @effect-diagnostics nodeBuiltinImport:off - real temp directories exercise the bounded probes. import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { assert, it } from "@effect/vitest"; +import { CheckpointRef } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as LaunchPreflight from "./LaunchPreflight.ts"; @@ -22,9 +26,9 @@ const probeError = ( const identity = ( overrides: Partial = {}, ): LaunchPreflight.LaunchPreflightRepoIdentity => ({ - state: "ok", - topLevel: "/session-root", - commonDir: "/session-root/.git", + state: "not-a-repository", + topLevel: null, + commonDir: null, detail: "", ...overrides, }); @@ -33,22 +37,24 @@ const gitProbe = ( overrides: Partial = {}, ): LaunchPreflight.LaunchPreflightGitProbe => ({ version: () => Effect.succeed("2.55.0"), - resolveIdentity: () => - Effect.succeed(identity({ state: "not-a-repository", topLevel: null, commonDir: null })), + resolveIdentity: () => Effect.succeed(identity()), + probeIndexFastPath: () => Effect.succeed("supported"), ...overrides, }); const input = (options: { readonly root?: string; + readonly isSharedRoot?: boolean; readonly git?: LaunchPreflight.LaunchPreflightGitProbe; readonly files?: Partial; }): LaunchPreflight.LaunchPreflightInput => ({ root: options.root ?? "/session-root", + ...(options.isSharedRoot !== undefined ? { isSharedRoot: options.isSharedRoot } : {}), git: options.git ?? gitProbe(), files: { exists: () => Effect.succeed(false), + realPath: (target) => Effect.succeed(target), readFirstBytes: () => Effect.succeed(16), - listDirectory: () => Effect.succeed([]), ...options.files, }, }); @@ -69,12 +75,11 @@ it.effect("passes an ordinary repository session with a supported Git", () => root: "/repo", git: gitProbe({ resolveIdentity: () => - Effect.succeed(identity({ topLevel: "/repo", commonDir: "/repo/.git" })), + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), }), files: { exists: (target) => Effect.succeed(target === "/repo/.git" || target.endsWith("package.json")), - listDirectory: () => Effect.succeed(["src", "docs"]), }, }), ); @@ -85,26 +90,23 @@ it.effect("passes an ordinary repository session with a supported Git", () => it.effect("passes a non-Git shared session root", () => Effect.gen(function* () { - const result = yield* run(input({})); + const result = yield* run(input({ isSharedRoot: true })); assert.deepStrictEqual(result.findings, []); }), ); -it.effect("does not count a retired Git marker as an umbrella by its name alone", () => +it.effect("does not count a retired Git marker as a repository by its name alone", () => Effect.gen(function* () { const result = yield* run( input({ root: "/Documents", + isSharedRoot: true, git: gitProbe({ - resolveIdentity: () => - Effect.succeed( - identity({ state: "not-a-repository", topLevel: null, commonDir: null }), - ), + resolveIdentity: () => Effect.succeed(identity()), }), files: { exists: (target) => Effect.succeed(target === "/Documents/.git.macfix-m1-retired"), - listDirectory: () => Effect.succeed([]), }, }), ); @@ -118,36 +120,55 @@ it.effect("flags an accidental umbrella repository at the shared root", () => const result = yield* run( input({ root: "/Documents", + isSharedRoot: true, git: gitProbe({ resolveIdentity: () => - Effect.succeed(identity({ topLevel: "/Documents", commonDir: "/Documents/.git" })), + Effect.succeed( + identity({ state: "ok", topLevel: "/Documents", commonDir: "/Documents/.git" }), + ), }), - files: { - exists: (target) => - Effect.succeed(target === "/Documents/.git" || target === "/Documents/proj/.git"), - listDirectory: () => Effect.succeed(["proj", "notes"]), - }, + files: { exists: (target) => Effect.succeed(target === "/Documents/.git") }, }), ); assert.deepStrictEqual(codes(result), ["shared-root-git"]); assert.deepStrictEqual(severities(result), ["warning"]); + assert.include(result.warnings[0]?.message ?? "", "/Documents/.git"); + }), +); + +it.effect("does not require child enumeration to detect the umbrella", () => + Effect.gen(function* () { + // No directory listing is part of the probe interface at all: detection is + // exact normalized root identity, so it cannot depend on child breadth. + const result = yield* run( + input({ + root: "/Documents", + isSharedRoot: true, + git: gitProbe({ + resolveIdentity: () => + Effect.succeed( + identity({ state: "ok", topLevel: "/Documents", commonDir: "/Documents/.git" }), + ), + }), + files: { exists: (target) => Effect.succeed(target === "/Documents/.git") }, + }), + ); + + assert.deepStrictEqual(codes(result), ["shared-root-git"]); }), ); -it.effect("does not flag an ordinary repository root that has no nested repositories", () => +it.effect("does not flag an ordinary repository root that is not a shared root", () => Effect.gen(function* () { const result = yield* run( input({ root: "/repo", git: gitProbe({ - resolveIdentity: () => Effect.succeed(identity({ topLevel: "/repo" })), + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), }), - files: { - exists: (target) => - Effect.succeed(target === "/repo/.git" || target.endsWith("package.json")), - listDirectory: () => Effect.succeed(["src", "docs"]), - }, + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, }), ); @@ -155,6 +176,52 @@ it.effect("does not flag an ordinary repository root that has no nested reposito }), ); +it.effect("does not flag a nested repository selected as the session cwd", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/Documents/project", + isSharedRoot: false, + git: gitProbe({ + resolveIdentity: () => + Effect.succeed( + identity({ + state: "ok", + topLevel: "/Documents/project", + commonDir: "/Documents/project/.git", + }), + ), + }), + files: { exists: (target) => Effect.succeed(target === "/Documents/project/.git") }, + }), + ); + + assert.deepStrictEqual(result.findings, []); + }), +); + +it.effect("does not advise retiring the root .git when identity points elsewhere", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/Documents", + isSharedRoot: true, + git: gitProbe({ + resolveIdentity: () => + Effect.succeed( + identity({ state: "ok", topLevel: "/Documents", commonDir: "/elsewhere/.git" }), + ), + }), + files: { exists: (target) => Effect.succeed(target === "/Documents/.git") }, + }), + ); + + const message = result.warnings[0]?.message ?? ""; + assert.include(message, "points at a different repository"); + assert.notInclude(message, "Move or retire /Documents/.git"); + }), +); + it.effect("warns (does not block) when Git cannot start under a non-repository root", () => Effect.gen(function* () { const result = yield* run( @@ -181,28 +248,28 @@ it.effect("blocks when Git cannot start and the session root is a repository", ( }), ); -it.effect("blocks when the resolved Git lacks --path-format and the root is a repository", () => +it.effect("warns but never blocks when Git lacks --path-format, even at a repository root", () => Effect.gen(function* () { const result = yield* run( input({ root: "/repo", + isSharedRoot: true, git: gitProbe({ resolveIdentity: () => - Effect.succeed( - identity({ - state: "unsupported", - topLevel: null, - commonDir: null, - detail: "git 2.24.3", - }), - ), + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + probeIndexFastPath: () => Effect.succeed("unsupported"), }), files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, }), ); - assert.deepStrictEqual(severities(result), ["blocker"]); - assert.include(result.blockers[0]?.message ?? "", "path-format"); + assert.deepStrictEqual(result.blockers, []); + assert.include(codes(result), "git-index-fast-path-unavailable"); + const message = result.findings.find( + (f) => f.code === "git-index-fast-path-unavailable", + )?.message; + assert.notInclude(message ?? "", "2.31.0"); + assert.include(message ?? "", "still"); }), ); @@ -210,14 +277,11 @@ it.effect("warns when the resolved Git lacks --path-format but the root is not a Effect.gen(function* () { const result = yield* run( input({ - git: gitProbe({ - resolveIdentity: () => - Effect.succeed(identity({ state: "unsupported", topLevel: null, commonDir: null })), - }), + git: gitProbe({ probeIndexFastPath: () => Effect.succeed("unsupported") }), }), ); - assert.deepStrictEqual(codes(result), ["git-capability-missing"]); + assert.deepStrictEqual(codes(result), ["git-index-fast-path-unavailable"]); assert.deepStrictEqual(severities(result), ["warning"]); }), ); @@ -264,14 +328,12 @@ it.effect("flags a slow root read and bounds it instead of holding the launch", }), ); -it.effect("resolves every probe within the total budget even when all probes hang", () => +it.effect("resolves within the total budget even when the first probe hangs", () => Effect.gen(function* () { const fiber = yield* run( input({ - git: { ...gitProbe(), version: () => Effect.never }, files: { exists: () => Effect.never, - listDirectory: () => Effect.never, readFirstBytes: () => Effect.never, }, }), @@ -280,7 +342,7 @@ it.effect("resolves every probe within the total budget even when all probes han yield* TestClock.adjust("5 seconds"); const result = yield* Fiber.join(fiber); - assert.deepStrictEqual(codes(result), ["git-probe-timed-out"]); + assert.deepStrictEqual(result.findings, []); }), ); @@ -301,7 +363,11 @@ it.effect("wires the real service probes and passes a healthy root", () => exitCode: ChildProcessSpawner.ExitCode(0), stdout: processInput.args.includes("--version") ? "git version 2.55.0\n" - : `${directory}\n${NodePath.join(directory, ".git")}\n`, + : processInput.args.includes("--show-toplevel") + ? `${directory}\n` + : processInput.args.includes("--git-common-dir") + ? `${NodePath.join(directory, ".git")}\n` + : `${NodePath.join(directory, ".git", "index")}\n`, stderr: "", stdoutTruncated: false, stderrTruncated: false, @@ -339,7 +405,7 @@ it.effect("real Git probes flag a disposable umbrella repository and never write ); const result = yield* LaunchPreflight.LaunchPreflight.pipe( - Effect.flatMap((preflight) => preflight.run(umbrella)), + Effect.flatMap((preflight) => preflight.run(umbrella, { isSharedRoot: true })), Effect.provide(layer), ); @@ -348,3 +414,146 @@ it.effect("real Git probes flag a disposable umbrella repository and never write yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); }), ); + +// --- R1 regression: a Git without `--path-format` still launches and checkpoints ----------------- + +const E3VcsLayer = VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer)); +const E3PreflightLayer = LaunchPreflight.layer.pipe(Layer.provide(E3VcsLayer)); +const E3CombinedLayer = Layer.merge(E3VcsLayer, E3PreflightLayer); + +/** A `git` that rejects `--path-format` (like Git < 2.31) and delegates everything else. */ +const writePathFormatRejectingGit = (binDir: string, realGit: string) => { + const wrapperPath = NodePath.join(binDir, "git"); + NodeFS.writeFileSync( + wrapperPath, + [ + "#!/bin/sh", + 'for a in "$@"; do', + ' case "$a" in', + " --path-format|--path-format=*)", + " echo \"fatal: unknown option 'path-format'\" >&2", + " exit 129", + " ;;", + " esac", + "done", + `exec "${realGit}" "$@"`, + "", + ].join("\n"), + { mode: 0o755 }, + ); + return wrapperPath; +}; + +it.effect( + "R1: a Git that rejects --path-format still launches and captures an ordinary checkpoint", + () => + Effect.gen(function* () { + const realGit = NodeChildProcess.execFileSync("which", ["git"]).toString().trim(); + const base = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-e3-pathformat-")), + ); + const binDir = NodePath.join(base, "bin"); + const repo = NodePath.join(base, "repo"); + yield* Effect.promise(() => NodeFSP.mkdir(binDir, { recursive: true })); + yield* Effect.promise(() => NodeFSP.mkdir(repo, { recursive: true })); + writePathFormatRejectingGit(binDir, realGit); + + yield* Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.PATH; + process.env.PATH = `${binDir}${NodePath.delimiter}${previous ?? ""}`; + return previous; + }), + () => + Effect.gen(function* () { + // 1. The launch preflight proceeds: no blocker, at most warnings. + const preflight = yield* LaunchPreflight.LaunchPreflight; + const result = yield* preflight.run(repo, { isSharedRoot: false }); + assert.deepStrictEqual(result.blockers, []); + assert.ok( + result.findings.every((finding) => finding.severity === "warning"), + "expected only warnings from the rejecting Git fixture", + ); + assert.include(codes(result), "git-index-fast-path-unavailable"); + + // 2. An ordinary checkpoint succeeds through the temporary-index fallback. + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const git = (args: ReadonlyArray) => + driver.execute({ operation: "e3-path-format-test", cwd: repo, args }); + yield* git(["init"]); + yield* git(["config", "user.name", "Test"]); + yield* git(["config", "user.email", "test@test.com"]); + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(repo, "file.txt"), "initial\n"), + ); + yield* git(["add", "."]); + yield* git(["commit", "-m", "initial"]); + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(repo, "file.txt"), "changed\n"), + ); + + const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/e3-path-format"); + yield* driver.checkpoints.captureCheckpoint({ cwd: repo, checkpointRef }); + const shown = yield* git(["show", `${checkpointRef}:file.txt`]); + assert.strictEqual(shown.stdout, "changed\n"); + }), + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.PATH; + else process.env.PATH = previous; + }), + ).pipe(Effect.provide(E3CombinedLayer)); + + yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); + }).pipe(Effect.scoped), +); + +// --- R3 regression: real wall-clock bound and cleanup for a hung resolved Git ------------------- + +it.live("R3: a hung resolved Git is bounded by wall-clock and the child is cleaned up", () => + Effect.gen(function* () { + const base = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-e3-hung-git-")), + ); + const binDir = NodePath.join(base, "bin"); + const marker = NodePath.join(base, "finished"); + yield* Effect.promise(() => NodeFSP.mkdir(binDir, { recursive: true })); + NodeFS.writeFileSync(NodePath.join(binDir, "git"), `#!/bin/sh\nsleep 30\ntouch "${marker}"\n`, { + mode: 0o755, + }); + + const startedAt = yield* Clock.currentTimeMillis; + yield* Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.PATH; + process.env.PATH = `${binDir}${NodePath.delimiter}${previous ?? ""}`; + return previous; + }), + () => + Effect.gen(function* () { + const preflight = yield* LaunchPreflight.LaunchPreflight; + const result = yield* preflight.run(base, { isSharedRoot: false }); + const elapsedMs = (yield* Clock.currentTimeMillis) - startedAt; + assert.include(codes(result), "git-probe-timed-out"); + assert.isBelow(elapsedMs, 3000, `preflight took ${elapsedMs}ms`); + + // The hung wrapper must have been terminated, not left sleeping. + yield* Effect.sleep("400 millis"); + const finished = yield* Effect.promise(() => + NodeFSP.readFile(marker, "utf8").then( + () => true, + () => false, + ), + ); + assert.isFalse(finished, "hung git wrapper was not cleaned up"); + }), + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.PATH; + else process.env.PATH = previous; + }), + ).pipe(Effect.provide(E3PreflightLayer)); + + yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/environment/LaunchPreflight.ts b/apps/server/src/environment/LaunchPreflight.ts index 0dc0db553446..4adf15042f48 100644 --- a/apps/server/src/environment/LaunchPreflight.ts +++ b/apps/server/src/environment/LaunchPreflight.ts @@ -19,50 +19,37 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; * the directory auto-bootstrap roots a project at. Before sessions start, this * checks the environment the harness actually depends on: * - * - the `git` executable the launch actually resolves can start, and supports - * the `rev-parse --path-format=absolute` capability `GitVcsDriver` relies on; - * - an accidental umbrella repository at the shared root is flagged, using the - * effective Git identity (not a folder name), because every project beneath it - * would then share one repository and T3 checkpoints could be written into it; + * - the `git` executable the launch actually resolves can start; + * - an accidental umbrella repository at an explicitly configured shared root + * is flagged using the exact, normalized Git identity reported for that root + * (never a folder name, a child count, or child enumeration); * - one small, relevant file at the root is read to detect a stalled or * cloud-offloaded filesystem without scanning the tree. * * Every probe is bounded, read-only and local. The preflight never mutates Git - * metadata, disables sync, kills processes, or calls a model. It warns by - * default and only blocks the launch for a demonstrated inability to execute - * usefully: when the resolved Git cannot start or lacks the required capability - * *and* the exact session root is itself a repository, so T3 checkpoints cannot - * be written. Broad roots, optional capabilities and unexpected markers alone - * never block. + * metadata, disables sync, kills processes, or calls a model. Findings warn by + * default. A blocker is emitted only for a demonstrated inability to execute + * usefully — the resolved Git cannot start while the exact session root is a + * repository, so its worktree/checkpoint plumbing cannot run. Optional Git + * capabilities (`rev-parse --path-format`) never block: maintained + * `GitVcsDriver` catches that failure and rebuilds the temporary index. Broad + * roots, nested regular repositories and retired markers never block. */ -/** - * `GitVcsDriver` checkpoint capture runs - * `git rev-parse --path-format=absolute --git-path index`, which needs Git - * 2.31.0 or newer. Reported only as context; the capability probe itself is what - * decides, not an arbitrary version threshold. - */ -export const MINIMUM_GIT_VERSION = "2.31.0"; - const GIT_PROBE_TIMEOUT = "1500 millis"; -const EXISTS_PROBE_TIMEOUT = "500 millis"; -const ROOT_LIST_TIMEOUT = "500 millis"; +const NARROW_FS_TIMEOUT = "500 millis"; const READ_PROBE_TIMEOUT = "500 millis"; const TOTAL_PROBE_BUDGET = "5 seconds"; const READ_PROBE_MAX_BYTES = 32 * 1024; -const MAX_ROOT_CHILDREN = 32; const GIT_VERSION_PATTERN = /\b(\d+\.\d+\.\d+)\b/; /** Files probed, in order, for the bounded root read. All are small and relevant. */ const READ_PROBE_CANDIDATES = ["AGENTS.md", "package.json", "README.md", ".git/HEAD"] as const; -/** Retired Git metadata must not count as an active umbrella by name alone. */ -const RETIRED_GIT_MARKER = ".git.macfix-m1-retired"; - export type LaunchPreflightFindingCode = | "git-startup-failed" | "git-probe-timed-out" - | "git-capability-missing" + | "git-index-fast-path-unavailable" | "git-probe-failed" | "shared-root-git" | "root-read-slow" @@ -93,51 +80,89 @@ export interface LaunchPreflightGitProbe { /** Returns the parsed Git version, or null when the output had none. */ readonly version: (root: string) => Effect.Effect; /** - * Runs the exact capability `GitVcsDriver` needs and resolves the effective - * repository identity for the root. Uses `allowNonZeroExit`, so "not a - * repository" and "unsupported option" are observable states, not failures. + * Resolves the effective repository identity for the root using plain + * `git rev-parse --show-toplevel` / `--git-common-dir` queries. Uses + * `allowNonZeroExit`, so "not a repository" is an observable state, not a + * failure. Never depends on the optional `--path-format` flag. */ readonly resolveIdentity: ( root: string, ) => Effect.Effect; + /** + * Probes the optional `rev-parse --path-format=absolute` convenience used by + * the checkpoint index-reuse fast path. `unsupported` only means the faster + * path is unavailable — the fallback still checkpoints — so it never blocks. + */ + readonly probeIndexFastPath: ( + root: string, + ) => Effect.Effect; } +export type LaunchPreflightIndexFastPath = "supported" | "unsupported" | "unknown"; + export interface LaunchPreflightRepoIdentity { - readonly state: "ok" | "not-a-repository" | "unsupported" | "failed"; + readonly state: "ok" | "not-a-repository" | "failed"; /** Effective work-tree top level when `state` is "ok". */ readonly topLevel: string | null; - /** Effective common Git directory when `state` is "ok". */ + /** Effective common Git directory (resolved) when `state` is "ok". */ readonly commonDir: string | null; readonly detail: string; } export interface LaunchPreflightFileProbe { readonly exists: (target: string) => Effect.Effect; + /** + * Canonicalizes a path (resolving symlinks) so exact root identity compares + * correctly across `/var` ↔ `/private/var` style aliases. Returns null when it + * cannot be resolved. + */ + readonly realPath: (target: string) => Effect.Effect; /** Reads at most `maxBytes` and returns the number of bytes read. */ readonly readFirstBytes: ( target: string, maxBytes: number, ) => Effect.Effect; - readonly listDirectory: ( - target: string, - ) => Effect.Effect, LaunchPreflightProbeError>; } export interface LaunchPreflightInput { readonly root: string; readonly git: LaunchPreflightGitProbe; readonly files: LaunchPreflightFileProbe; + /** + * Whether `root` is an explicitly configured shared session root (as opposed + * to a selected nested repository). Only a shared root that is *itself* a + * repository is reported as an unexpected umbrella. + */ + readonly isSharedRoot?: boolean; } const parseGitVersion = (output: string): string | null => output.match(GIT_VERSION_PATTERN)?.[1] ?? null; -const severityForGitFailure = (rootGitMarker: boolean): LaunchPreflightSeverity => - rootGitMarker ? "blocker" : "warning"; - const blockerSuffix = - " T3 Code cannot checkpoint this repository, so the session cannot run usefully." + - " Fix Git, then restart T3 Code."; + " T3 Code cannot checkpoint or resolve a worktree for this repository, so the session cannot run" + + " usefully. Fix Git, then restart T3 Code."; + +const normalizeForCompare = (path: Path.Path, value: string): string => { + const resolved = path.resolve(value); + // A trailing separator would make an otherwise-equal path compare unequal. + return resolved.length > 1 && resolved.endsWith(path.sep) + ? resolved.slice(0, -path.sep.length) + : resolved; +}; + +const samePath = (path: Path.Path, a: string | null, b: string): boolean => + a !== null && normalizeForCompare(path, a) === normalizeForCompare(path, b); + +const isInside = (path: Path.Path, child: string | null, parent: string): boolean => { + if (child === null) return false; + const normalizedChild = normalizeForCompare(path, child); + const normalizedParent = normalizeForCompare(path, parent); + return ( + normalizedChild === normalizedParent || + normalizedChild.startsWith(`${normalizedParent}${path.sep}`) + ); +}; /** * Runs every probe against `root` and returns findings. Bounded by construction: @@ -149,21 +174,32 @@ export const runLaunchPreflight = ( ): Effect.Effect => Effect.gen(function* () { const path = yield* Path.Path; + const isSharedRoot = input.isSharedRoot === true; const collected = yield* Ref.make>([]); const add = (finding: LaunchPreflightFinding) => Ref.update(collected, (current) => [...current, finding]); const existsBounded = (target: string) => input.files.exists(target).pipe( - Effect.timeoutOption(EXISTS_PROBE_TIMEOUT), + Effect.timeoutOption(NARROW_FS_TIMEOUT), Effect.map(Option.getOrElse(() => false)), Effect.orElseSucceed(() => false), ); + const realPathBounded = (target: string) => + input.files.realPath(target).pipe( + Effect.timeoutOption(NARROW_FS_TIMEOUT), + Effect.map(Option.getOrElse(() => null)), + Effect.orElseSucceed(() => null), + ); + const explore = Effect.gen(function* () { - // `root/.git` (a directory or a worktree/submodule file) is the evidence - // that the root is expected to be a repository. A retired marker such as - // `.git.macfix-m1-retired` is a different path and is never counted. + // Canonicalize the configured root once so identity comparison is not + // confused by macOS `/var` ↔ `/private/var` aliases. + const canonicalRoot = (yield* realPathBounded(input.root)) ?? input.root; + + // Only a real `.git` entry counts as repository evidence. A retired marker + // such as `.git.macfix-m1-retired` is a different path and is ignored. const rootGitMarker = yield* existsBounded(path.join(input.root, ".git")); const gitOutcome = yield* input.git.version(input.root).pipe( @@ -196,24 +232,30 @@ export const runLaunchPreflight = ( const identity = identityOutcome.identity; switch (identity.state) { case "ok": { - effectiveRootRepository = true; - break; - } - case "unsupported": { - yield* add({ - code: "git-capability-missing", - severity: severityForGitFailure(rootGitMarker), - message: - "The Git this launch resolves does not support `git rev-parse --path-format`, which T3 " + - `Code ${MINIMUM_GIT_VERSION}+ uses to checkpoint changes.` + - (rootGitMarker - ? blockerSuffix - : " Put a newer Git earlier on PATH (for example /usr/local/bin before /usr/bin) and restart T3 Code."), - }); + // Exact normalized root identity: the root itself is the work tree + // top level. `topLevel === root` is what makes a shared root an + // umbrella; a nested repository selected as the session cwd has a + // different top level and stays an ordinary repository session. + const rootIsRepository = samePath(path, identity.topLevel, canonicalRoot); + effectiveRootRepository = rootIsRepository || rootGitMarker; + if (isSharedRoot && rootIsRepository) { + const commonUnderRoot = isInside(path, identity.commonDir, canonicalRoot); + yield* add({ + code: "shared-root-git", + severity: "warning", + message: + `The shared session root ${input.root} is itself a Git repository ` + + `(top-level ${identity.topLevel ?? input.root}). Projects beneath it would share that ` + + "repository." + + (commonUnderRoot + ? ` Move or retire ${path.join(input.root, ".git")} if that is unintended.` + : " Its Git identity points at a different repository; no change to this root is implied."), + }); + } break; } case "not-a-repository": { - effectiveRootRepository = false; + effectiveRootRepository = rootGitMarker; break; } case "failed": { @@ -229,36 +271,53 @@ export const runLaunchPreflight = ( } else if (identityOutcome._tag === "timeout") { yield* add({ code: "git-probe-timed-out", - severity: severityForGitFailure(rootGitMarker), + severity: "warning", message: "The Git repository probe did not finish in time. Git or the session-root filesystem may be " + - "stalled; Git-backed sessions may hang. Materialize the root and check the Git install." + - (rootGitMarker ? blockerSuffix : ""), + "stalled; Git-backed sessions may hang. Materialize the root and check the Git install.", }); } else { yield* add({ code: "git-probe-failed", severity: "warning", + message: `The Git repository probe failed (${identityOutcome.error.detail}). Git-backed sessions may fail.`, + }); + } + + // The optional index-reuse fast path is probed for context only. Its + // absence is handled by GitVcsDriver's temporary-index fallback and must + // never block a launch. + const fastPathOutcome = yield* input.git.probeIndexFastPath(input.root).pipe( + Effect.map((state) => ({ _tag: "ok" as const, state })), + Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), + Effect.timeoutOption(GIT_PROBE_TIMEOUT), + Effect.map(Option.getOrElse(() => ({ _tag: "timeout" as const }))), + ); + if (fastPathOutcome._tag === "ok" && fastPathOutcome.state === "unsupported") { + yield* add({ + code: "git-index-fast-path-unavailable", + severity: "warning", message: - `The Git repository probe failed (${identityOutcome.error.detail}). Git-backed sessions may fail.`, + "The Git this launch resolves does not support `git rev-parse --path-format`. T3 Code will " + + "rebuild the temporary checkpoint index instead of reusing the on-disk index; sessions still " + + "run and checkpoint. A newer Git restores the faster path only.", }); } } else if (gitOutcome._tag === "timeout" || gitOutcome.error.reason === "timeout") { yield* add({ code: "git-probe-timed-out", - severity: severityForGitFailure(rootGitMarker), + severity: "warning", message: "The Git version probe did not finish in time. Git or the session-root filesystem may be " + - "stalled; Git-backed sessions may hang. Materialize the root and check the Git install." + - (rootGitMarker ? blockerSuffix : ""), + "stalled; Git-backed sessions may hang. Materialize the root and check the Git install.", }); } else if (gitOutcome.error.reason === "unavailable") { yield* add({ code: "git-startup-failed", - severity: severityForGitFailure(rootGitMarker), + severity: effectiveRootRepository ? "blocker" : "warning", message: "Git could not be started from the session-root PATH (not found or not executable)." + - (rootGitMarker + (effectiveRootRepository ? blockerSuffix : " Sessions that checkpoint, diff, or open a repository will fail. Install Git or put its " + "directory earlier on PATH, then restart T3 Code."), @@ -267,39 +326,10 @@ export const runLaunchPreflight = ( yield* add({ code: "git-probe-failed", severity: "warning", - message: - `The Git version probe failed (${gitOutcome.error.detail}). Git-backed sessions may fail.`, + message: `The Git version probe failed (${gitOutcome.error.detail}). Git-backed sessions may fail.`, }); } - if (effectiveRootRepository) { - const children = yield* input.files.listDirectory(input.root).pipe( - Effect.timeoutOption(ROOT_LIST_TIMEOUT), - Effect.map(Option.getOrElse((): ReadonlyArray => [])), - Effect.orElseSucceed((): ReadonlyArray => []), - ); - const nestedRepo = yield* Effect.forEach( - children - .filter( - (child) => - child.length > 0 && child !== "." && child !== ".." && child !== RETIRED_GIT_MARKER, - ) - .slice(0, MAX_ROOT_CHILDREN), - (child) => existsBounded(path.join(input.root, child, ".git")), - { concurrency: 8 }, - ); - if (nestedRepo.some(Boolean)) { - yield* add({ - code: "shared-root-git", - severity: "warning", - message: - `The shared session root ${input.root} is itself a Git repository and contains nested ` + - "repositories. Projects beneath it will share one repository, and T3 checkpoints may be " + - `written into that umbrella. Move or retire ${path.join(input.root, ".git")} if it is unintended.`, - }); - } - } - let readTarget: string | undefined; for (const relativePath of READ_PROBE_CANDIDATES) { const candidate = path.join(input.root, relativePath); @@ -310,12 +340,14 @@ export const runLaunchPreflight = ( } if (readTarget !== undefined) { - const readOutcome = yield* input.files.readFirstBytes(readTarget, READ_PROBE_MAX_BYTES).pipe( - Effect.map((bytes) => ({ _tag: "ok" as const, bytes })), - Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), - Effect.timeoutOption(READ_PROBE_TIMEOUT), - Effect.map(Option.getOrElse(() => ({ _tag: "timeout" as const }))), - ); + const readOutcome = yield* input.files + .readFirstBytes(readTarget, READ_PROBE_MAX_BYTES) + .pipe( + Effect.map((bytes) => ({ _tag: "ok" as const, bytes })), + Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), + Effect.timeoutOption(READ_PROBE_TIMEOUT), + Effect.map(Option.getOrElse(() => ({ _tag: "timeout" as const }))), + ); if (readOutcome._tag === "timeout") { yield* add({ code: "root-read-slow", @@ -360,33 +392,13 @@ const classifyGitProbeError = (error: VcsError): LaunchPreflightProbeError => { return new LaunchPreflightProbeError({ reason: "failed", detail: error.message }); }; -const classifyIdentityOutput = (output: { stdout: string; stderr: string; code: number }): LaunchPreflightRepoIdentity => { - const stderr = output.stderr.trim(); - if (output.code === 0) { - const lines = output.stdout - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - return { - state: "ok", - topLevel: lines[0] ?? null, - commonDir: lines[1] ?? null, - detail: "", - }; - } - if (/not a git repository/i.test(stderr) || /must be run in a work tree/i.test(stderr)) { - return { state: "not-a-repository", topLevel: null, commonDir: null, detail: stderr }; - } - if (/unknown option|usage: git rev-parse|path-format/i.test(stderr)) { - return { state: "unsupported", topLevel: null, commonDir: null, detail: stderr }; - } - return { state: "failed", topLevel: null, commonDir: null, detail: stderr }; -}; - export class LaunchPreflight extends Context.Service< LaunchPreflight, { - readonly run: (root: string) => Effect.Effect; + readonly run: ( + root: string, + options?: { readonly isSharedRoot?: boolean }, + ) => Effect.Effect; } >()("t3/environment/LaunchPreflight") {} @@ -396,46 +408,96 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const runGit = ( + operation: string, + root: string, + args: ReadonlyArray, + allowNonZeroExit: boolean, + ) => + vcsProcess + .run({ + operation, + command: "git", + args, + cwd: root, + timeoutMs: 1_500, + maxOutputBytes: 4_000, + ...(allowNonZeroExit ? { allowNonZeroExit: true } : {}), + }) + .pipe(Effect.mapError(classifyGitProbeError)); + const git: LaunchPreflightGitProbe = { version: (root) => - vcsProcess - .run({ - operation: "launch-preflight.git-version", - command: "git", - args: ["--version"], - cwd: root, - timeoutMs: 1_500, - maxOutputBytes: 4_000, - }) - .pipe( - Effect.map((result) => parseGitVersion(result.stdout) ?? parseGitVersion(result.stderr)), - Effect.mapError(classifyGitProbeError), - ), + runGit("launch-preflight.git-version", root, ["--version"], false).pipe( + Effect.map((result) => parseGitVersion(result.stdout) ?? parseGitVersion(result.stderr)), + ), resolveIdentity: (root) => - vcsProcess - .run({ - operation: "launch-preflight.git-identity", - command: "git", - args: ["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-common-dir"], - cwd: root, - timeoutMs: 1_500, - maxOutputBytes: 4_000, - allowNonZeroExit: true, - }) - .pipe( - Effect.map((result) => - classifyIdentityOutput({ - stdout: result.stdout, - stderr: result.stderr, - code: Number(result.exitCode), - }), - ), - Effect.mapError(classifyGitProbeError), - ), + Effect.gen(function* () { + const top = yield* runGit( + "launch-preflight.git-toplevel", + root, + ["rev-parse", "--show-toplevel"], + true, + ); + if (Number(top.exitCode) !== 0) { + const stderr = top.stderr.trim(); + if (/not a git repository/i.test(stderr) || /must be run in a work tree/i.test(stderr)) { + return { + state: "not-a-repository", + topLevel: null, + commonDir: null, + detail: stderr, + } satisfies LaunchPreflightRepoIdentity; + } + return { + state: "failed", + topLevel: null, + commonDir: null, + detail: stderr, + } satisfies LaunchPreflightRepoIdentity; + } + const topLevel = top.stdout.trim(); + const commonResult = yield* runGit( + "launch-preflight.git-common-dir", + root, + ["rev-parse", "--git-common-dir"], + true, + ); + const rawCommonDir = Number(commonResult.exitCode) === 0 ? commonResult.stdout.trim() : ""; + const commonDir = + rawCommonDir.length > 0 + ? path.isAbsolute(rawCommonDir) + ? rawCommonDir + : path.resolve(topLevel.length > 0 ? topLevel : root, rawCommonDir) + : null; + return { + state: "ok", + topLevel: topLevel.length > 0 ? topLevel : null, + commonDir, + detail: "", + } satisfies LaunchPreflightRepoIdentity; + }), + probeIndexFastPath: (root) => + runGit( + "launch-preflight.git-index-fast-path", + root, + ["rev-parse", "--path-format=absolute", "--git-path", "index"], + true, + ).pipe( + Effect.map((result): LaunchPreflightIndexFastPath => { + if (Number(result.exitCode) === 0) return "supported"; + const stderr = result.stderr.trim(); + if (/unknown option|usage: git rev-parse|path-format/i.test(stderr)) { + return "unsupported"; + } + return "unknown"; + }), + ), }; const files: LaunchPreflightFileProbe = { exists: (target) => fileSystem.exists(target).pipe(Effect.orElseSucceed(() => false)), + realPath: (target) => fileSystem.realPath(target).pipe(Effect.orElseSucceed(() => null)), readFirstBytes: (target, maxBytes) => fileSystem.stream(target, { bytesToRead: maxBytes }).pipe( Stream.runCount, @@ -443,17 +505,16 @@ export const make = Effect.gen(function* () { (cause) => new LaunchPreflightProbeError({ reason: "failed", detail: String(cause) }), ), ), - listDirectory: (target) => - fileSystem.readDirectory(target).pipe( - Effect.mapError( - (cause) => new LaunchPreflightProbeError({ reason: "failed", detail: String(cause) }), - ), - ), }; return LaunchPreflight.of({ - run: (root: string) => - runLaunchPreflight({ root, git, files }).pipe(Effect.provideService(Path.Path, path)), + run: (root: string, options?: { readonly isSharedRoot?: boolean }) => + runLaunchPreflight({ + root, + git, + files, + ...(options?.isSharedRoot !== undefined ? { isSharedRoot: options.isSharedRoot } : {}), + }).pipe(Effect.provideService(Path.Path, path)), }); }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 3008a4de0e9a..50dbb379fda5 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -93,6 +93,10 @@ import * as VcsProcess from "../../vcs/VcsProcess.ts"; const isModelSelection = Schema.is(ModelSelection); const encodePromptJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +// Narrow-filesystem budget for the launch preflight directory checks, matching +// the preflight's own per-operation bound so a stalled path cannot hold a launch. +const NARROW_FS_TIMEOUT = "500 millis"; + interface SnapShotPromptAccessibilityNode { readonly role: string; readonly name?: string; @@ -277,6 +281,7 @@ export interface ProviderServiceLiveOptions { */ readonly launchPreflightRunner?: ( root: string, + options?: { readonly isSharedRoot?: boolean }, ) => Effect.Effect; } @@ -532,6 +537,24 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const launchPreflight = yield* LaunchPreflight.LaunchPreflight; const runLaunchPreflight = options?.launchPreflightRunner ?? launchPreflight.run; + // A launch cwd is a shared root only when it is exactly the configured + // `ServerConfig.cwd`. A nested repository selected as the session cwd has a + // different path and stays an ordinary repository session. + const normalizeResolved = (candidate: string): string => { + let resolved: string; + try { + resolved = pathService.resolve(candidate); + } catch { + resolved = candidate; + } + return resolved.length > 1 && resolved.endsWith(pathService.sep) + ? resolved.slice(0, -pathService.sep.length) + : resolved; + }; + const configuredSharedRoot = normalizeResolved(serverConfig.cwd); + const isConfiguredSharedRoot = (cwd: string): boolean => + normalizeResolved(cwd) === configuredSharedRoot; + /** * Runs the bounded launch preflight against the exact cwd a provider process * is about to start in, before the caller's own workspace read. Warnings are @@ -544,15 +567,21 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }) { // Only probe a real directory: spawning Git in a missing path or a plain // file would fail at the OS layer. The caller's own workspace read (and - // `ProviderWorkspaceMissingError`) handles those cases. - const workspaceStat = yield* fileSystem - .stat(input.cwd) - .pipe(Effect.orElseSucceed(() => undefined)); + // `ProviderWorkspaceMissingError`) handles those cases. This stat is itself + // bounded so a stalled/cloud-offloaded path cannot hold the launch; a + // timeout simply skips the preflight and lets the adapter surface the + // filesystem problem. + const workspaceStat = yield* fileSystem.stat(input.cwd).pipe( + Effect.timeoutOption(NARROW_FS_TIMEOUT), + Effect.map(Option.getOrElse(() => undefined)), + Effect.orElseSucceed(() => undefined), + ); if (workspaceStat === undefined || workspaceStat.type !== "Directory") { return; } - const result = yield* runLaunchPreflight(input.cwd).pipe( + const isSharedRoot = isConfiguredSharedRoot(input.cwd); + const result = yield* runLaunchPreflight(input.cwd, { isSharedRoot }).pipe( Effect.catchCause(() => Effect.succeed({ findings: [] as ReadonlyArray, @@ -1615,9 +1644,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // gone (e.g. moved, deleted, or replaced by a plain file). // Otherwise every adapter surfaces this as a misleading "failed to // spawn " process error. Stat failures other than "missing" - // fall through to the adapter. + // fall through to the adapter. Bounded so a stalled path cannot hold + // the launch; a timeout falls through and lets the adapter report it. const workspaceIsDirectory = yield* fileSystem.stat(effectiveCwd).pipe( - Effect.map((workspaceStat) => workspaceStat.type === "Directory"), + Effect.timeoutOption(NARROW_FS_TIMEOUT), + Effect.flatMap((statOption) => + Option.isSome(statOption) + ? Effect.succeed(statOption.value.type === "Directory") + : Effect.succeed(true), + ), Effect.catch((statError) => Effect.succeed(statError.reason._tag !== "NotFound")), ); if (!workspaceIsDirectory) { diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 3a8f7d06eacd..1e391a5e4641 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -965,7 +965,7 @@ export const make = (options?: StartupOptions) => yield* Effect.logDebug("startup phase: running launch preflight"); yield* runStartupPhase( "launch.preflight", - launchPreflight.run(serverConfig.cwd).pipe( + launchPreflight.run(serverConfig.cwd, { isSharedRoot: true }).pipe( Effect.tap((result) => Effect.gen(function* () { yield* Effect.forEach( From df34af59d9565e505295910ed64030247ee0c96d Mon Sep 17 00:00:00 2001 From: business account Date: Mon, 28 Sep 2026 01:32:53 -0400 Subject: [PATCH 05/18] =?UTF-8?q?fix(server):=20E4=20preflight=20completio?= =?UTF-8?q?n=20=E2=80=94=20shared-inbox=20intent,=20identity,=20bound?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1: add optional exact-root sharedSessionRoot ServerSettings key (empty/ordinary by default); treat only that exact configured root as shared instead of ServerConfig.cwd; resolve relative git --git-common-dir against the invocation cwd; tests for root/subdir/linked-worktree identity. F2: make Windows spawned-executable resolution asynchronous and interruptible (node:fs/promises) so the preflight bound can interrupt it; strengthen the hung-Git fixture to observe termination of the owned child and descendant PIDs. F3: replace the harmless --path-format warning with a bounded read-only git add --sparse capability check on sparse checkouts. --- .../providerService.integration.test.ts | 80 +++++- .../src/environment/LaunchPreflight.test.ts | 267 ++++++++++++++---- .../server/src/environment/LaunchPreflight.ts | 123 +++++--- .../src/process/externalLauncher.test.ts | 5 +- apps/server/src/processRunner.test.ts | 10 +- .../provider/Drivers/ClaudeExecutable.test.ts | 8 +- .../src/provider/Drivers/ClaudeExecutable.ts | 2 +- .../src/provider/Layers/ProviderService.ts | 31 +- .../providerMaintenanceRunner.test.ts | 2 +- apps/server/src/serverRuntimeStartup.ts | 74 +++-- packages/contracts/src/settings.ts | 12 + packages/shared/src/shell.test.ts | 7 +- packages/shared/src/shell.ts | 81 +++--- 13 files changed, 503 insertions(+), 199 deletions(-) diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 2e7b5278a33a..8c03394251c1 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -98,6 +98,10 @@ const makeRecordingAnalytics = Effect.gen(function* () { const makeIntegrationFixture = (options?: { readonly analytics?: Layer.Layer; readonly grokBinaryPath?: string; + readonly serverConfigCwd?: string; + readonly settings?: Parameters[0]; + /** Configure the workspace directory itself as the shared session root. */ + readonly sharedSessionRootIsWorkspace?: boolean; readonly launchPreflightRunner?: ( root: string, options?: { readonly isSharedRoot?: boolean }, @@ -140,8 +144,13 @@ const makeIntegrationFixture = (options?: { const shared = Layer.mergeAll( directoryLayer, Layer.succeed(ProviderAdapterRegistry, registry), - ServerConfig.layerTest(cwd, cwd).pipe(Layer.provide(NodeServices.layer)), - ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS), + ServerConfig.layerTest(options?.serverConfigCwd ?? cwd, cwd).pipe( + Layer.provide(NodeServices.layer), + ), + ServerSettingsService.layerTest({ + ...(options?.settings ?? DEFAULT_SERVER_SETTINGS), + ...(options?.sharedSessionRootIsWorkspace ? { sharedSessionRoot: cwd } : {}), + }), options?.analytics ?? AnalyticsService.layerTest, Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), ).pipe(Layer.provide(SqlitePersistenceMemory)); @@ -509,6 +518,73 @@ it.live("a launch-preflight warning is reported and the session still starts onc }).pipe(Effect.provide(NodeServices.layer)), ); +it.live("the same repository at the server cwd is ordinary by default", () => + Effect.gen(function* () { + const seen: Array = []; + const fixture = yield* makeIntegrationFixture({ + launchPreflightRunner: (_root, options) => { + seen.push(options?.isSharedRoot); + return Effect.succeed(findingResult([])); + }, + }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + yield* provider.startSession(ThreadId.make("thread-shared-default"), { + threadId: ThreadId.make("thread-shared-default"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: fixture.cwd, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(fixture.layer)); + + assert.deepStrictEqual(seen, [false]); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("a configured shared session root applies independently of the backend cwd", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const otherBackendCwd = yield* fs.makeTempDirectory(); + const seen: Array = []; + const fixture = yield* makeIntegrationFixture({ + serverConfigCwd: otherBackendCwd, + sharedSessionRootIsWorkspace: true, + launchPreflightRunner: (_root, options) => { + seen.push(options?.isSharedRoot); + return Effect.succeed(findingResult([])); + }, + }); + const nested = path.join(fixture.cwd, "nested"); + yield* fs.makeDirectory(nested, { recursive: true }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + // The configured shared root is treated as shared even though the + // backend cwd is a different directory. + yield* provider.startSession(ThreadId.make("thread-shared-root"), { + threadId: ThreadId.make("thread-shared-root"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: fixture.cwd, + runtimeMode: "full-access", + }); + // A nested repository selected as the session cwd stays ordinary. + yield* provider.startSession(ThreadId.make("thread-shared-nested"), { + threadId: ThreadId.make("thread-shared-nested"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: nested, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(fixture.layer)); + + assert.deepStrictEqual(seen, [true, false]); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.live("the recovery path also invokes the launch preflight", () => Effect.gen(function* () { const calls = yield* Ref.make(0); diff --git a/apps/server/src/environment/LaunchPreflight.test.ts b/apps/server/src/environment/LaunchPreflight.test.ts index 22f7ddc8cab9..a8e3a603047c 100644 --- a/apps/server/src/environment/LaunchPreflight.test.ts +++ b/apps/server/src/environment/LaunchPreflight.test.ts @@ -11,6 +11,7 @@ import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -38,7 +39,7 @@ const gitProbe = ( ): LaunchPreflight.LaunchPreflightGitProbe => ({ version: () => Effect.succeed("2.55.0"), resolveIdentity: () => Effect.succeed(identity()), - probeIndexFastPath: () => Effect.succeed("supported"), + probeSparseAdd: () => Effect.succeed("not-sparse"), ...overrides, }); @@ -248,41 +249,60 @@ it.effect("blocks when Git cannot start and the session root is a repository", ( }), ); -it.effect("warns but never blocks when Git lacks --path-format, even at a repository root", () => +it.effect("never blocks or warns about the harmless missing --path-format fast path", () => Effect.gen(function* () { const result = yield* run( input({ root: "/repo", - isSharedRoot: true, + isSharedRoot: false, git: gitProbe({ resolveIdentity: () => Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), - probeIndexFastPath: () => Effect.succeed("unsupported"), }), files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, }), ); assert.deepStrictEqual(result.blockers, []); - assert.include(codes(result), "git-index-fast-path-unavailable"); - const message = result.findings.find( - (f) => f.code === "git-index-fast-path-unavailable", - )?.message; - assert.notInclude(message ?? "", "2.31.0"); - assert.include(message ?? "", "still"); + assert.deepStrictEqual(result.findings, []); }), ); -it.effect("warns when the resolved Git lacks --path-format but the root is not a repository", () => +it.effect("warns when a sparse checkout's resolved Git lacks git add --sparse", () => Effect.gen(function* () { const result = yield* run( input({ - git: gitProbe({ probeIndexFastPath: () => Effect.succeed("unsupported") }), + root: "/repo", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + probeSparseAdd: () => Effect.succeed("unsupported"), + }), + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, }), ); - assert.deepStrictEqual(codes(result), ["git-index-fast-path-unavailable"]); + assert.deepStrictEqual(codes(result), ["git-sparse-add-unsupported"]); assert.deepStrictEqual(severities(result), ["warning"]); + assert.include(result.warnings[0]?.message ?? "", "--sparse"); + }), +); + +it.effect("does not warn about git add --sparse when the repository is not sparse", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + probeSparseAdd: () => Effect.succeed("not-sparse"), + }), + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + }), + ); + + assert.deepStrictEqual(result.findings, []); }), ); @@ -466,15 +486,12 @@ it.effect( }), () => Effect.gen(function* () { - // 1. The launch preflight proceeds: no blocker, at most warnings. + // 1. The launch preflight proceeds with no finding at all: a Git + // without `--path-format` is a harmless optional fallback. const preflight = yield* LaunchPreflight.LaunchPreflight; const result = yield* preflight.run(repo, { isSharedRoot: false }); assert.deepStrictEqual(result.blockers, []); - assert.ok( - result.findings.every((finding) => finding.severity === "warning"), - "expected only warnings from the rejecting Git fixture", - ); - assert.include(codes(result), "git-index-fast-path-unavailable"); + assert.deepStrictEqual(result.findings, []); // 2. An ordinary checkpoint succeeds through the temporary-index fallback. const driver = yield* GitVcsDriver.makeVcsDriverShape(); @@ -510,50 +527,180 @@ it.effect( // --- R3 regression: real wall-clock bound and cleanup for a hung resolved Git ------------------- -it.live("R3: a hung resolved Git is bounded by wall-clock and the child is cleaned up", () => +it.live( + "R3: a hung resolved Git is bounded by wall-clock and the owned processes are cleaned up", + () => + Effect.gen(function* () { + const base = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-e3-hung-git-")), + ); + const binDir = NodePath.join(base, "bin"); + const marker = NodePath.join(base, "finished"); + const childPidFile = NodePath.join(base, "child.pid"); + const grandchildPidFile = NodePath.join(base, "grandchild.pid"); + yield* Effect.promise(() => NodeFSP.mkdir(binDir, { recursive: true })); + // The owned fixture records its own PID and the PID of a descendant it + // spawns, then sleeps. Cleanup must terminate both, not just leave the + // post-sleep marker unwritten. + NodeFS.writeFileSync( + NodePath.join(binDir, "git"), + [ + "#!/bin/sh", + `echo $$ > "${childPidFile}"`, + "sleep 30 &", + `echo $! > "${grandchildPidFile}"`, + "wait", + `touch "${marker}"`, + "", + ].join("\n"), + { mode: 0o755 }, + ); + + const isAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }; + const readPid = (file: string) => + Effect.promise(() => NodeFSP.readFile(file, "utf8").then((raw) => Number(raw.trim()))); + + const startedAt = yield* Clock.currentTimeMillis; + yield* Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.PATH; + process.env.PATH = `${binDir}${NodePath.delimiter}${previous ?? ""}`; + return previous; + }), + () => + Effect.gen(function* () { + const preflight = yield* LaunchPreflight.LaunchPreflight; + const result = yield* preflight.run(base, { isSharedRoot: false }); + const elapsedMs = (yield* Clock.currentTimeMillis) - startedAt; + assert.include(codes(result), "git-probe-timed-out"); + assert.isBelow(elapsedMs, 3000, `preflight took ${elapsedMs}ms`); + + const childPid = yield* readPid(childPidFile); + const grandchildPid = yield* readPid(grandchildPidFile); + assert.isTrue(Number.isInteger(childPid) && childPid > 0); + assert.isTrue(Number.isInteger(grandchildPid) && grandchildPid > 0); + + // The hung wrapper and its descendant must have been terminated, not + // left sleeping. Only the fixture's own captured PIDs are observed. + yield* Effect.sleep("400 millis"); + assert.isFalse(isAlive(childPid), `owned child ${childPid} was not cleaned up`); + assert.isFalse( + isAlive(grandchildPid), + `owned descendant ${grandchildPid} was not cleaned up`, + ); + const finished = yield* Effect.promise(() => + NodeFSP.readFile(marker, "utf8").then( + () => true, + () => false, + ), + ); + assert.isFalse(finished, "hung git wrapper was not cleaned up"); + }), + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.PATH; + else process.env.PATH = previous; + }), + ).pipe(Effect.provide(E3PreflightLayer)); + + yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); + }).pipe(Effect.scoped), +); + +// --- F1 regression: relative `--git-common-dir` resolves against the invocation cwd ------------- + +const makeRealGitProbe = Effect.gen(function* () { + const vcsProcess = yield* VcsProcess.VcsProcess; + const path = yield* Path.Path; + return LaunchPreflight.makeGitProbe(vcsProcess, path); +}); + +const realpath = (target: string) => Effect.promise(() => NodeFSP.realpath(target)); + +it.live("F1: git identity resolves relative common dirs against the invocation cwd", () => Effect.gen(function* () { + const realGit = NodeChildProcess.execFileSync("which", ["git"]).toString().trim(); const base = yield* Effect.promise(() => - NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-e3-hung-git-")), + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-e4-identity-")), ); - const binDir = NodePath.join(base, "bin"); - const marker = NodePath.join(base, "finished"); - yield* Effect.promise(() => NodeFSP.mkdir(binDir, { recursive: true })); - NodeFS.writeFileSync(NodePath.join(binDir, "git"), `#!/bin/sh\nsleep 30\ntouch "${marker}"\n`, { - mode: 0o755, - }); - - const startedAt = yield* Clock.currentTimeMillis; - yield* Effect.acquireUseRelease( - Effect.sync(() => { - const previous = process.env.PATH; - process.env.PATH = `${binDir}${NodePath.delimiter}${previous ?? ""}`; - return previous; - }), - () => - Effect.gen(function* () { - const preflight = yield* LaunchPreflight.LaunchPreflight; - const result = yield* preflight.run(base, { isSharedRoot: false }); - const elapsedMs = (yield* Clock.currentTimeMillis) - startedAt; - assert.include(codes(result), "git-probe-timed-out"); - assert.isBelow(elapsedMs, 3000, `preflight took ${elapsedMs}ms`); - - // The hung wrapper must have been terminated, not left sleeping. - yield* Effect.sleep("400 millis"); - const finished = yield* Effect.promise(() => - NodeFSP.readFile(marker, "utf8").then( - () => true, - () => false, - ), - ); - assert.isFalse(finished, "hung git wrapper was not cleaned up"); - }), - (previous) => - Effect.sync(() => { - if (previous === undefined) delete process.env.PATH; - else process.env.PATH = previous; - }), - ).pipe(Effect.provide(E3PreflightLayer)); + const repo = NodePath.join(base, "repo"); + const sub = NodePath.join(repo, "sub"); + const worktree = NodePath.join(base, "linked-worktree"); + const git = (cwd: string, args: ReadonlyArray) => + Effect.promise(async () => { + NodeChildProcess.execFileSync(realGit, args, { cwd }); + }); + + yield* Effect.promise(() => NodeFSP.mkdir(sub, { recursive: true })); + yield* git(repo, ["init"]); + yield* git(repo, ["config", "user.name", "Test"]); + yield* git(repo, ["config", "user.email", "test@test.com"]); + yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(repo, "file.txt"), "hello\n")); + yield* git(repo, ["add", "."]); + yield* git(repo, ["commit", "-m", "initial"]); + yield* git(repo, ["worktree", "add", worktree, "-b", "linked"]); + + const probe = yield* makeRealGitProbe; + const repoReal = yield* realpath(repo); + + // 1. Root is the repository root: common dir is `/.git`. + const rootIdentity = yield* probe.resolveIdentity(repo); + assert.strictEqual(rootIdentity.state, "ok"); + assert.strictEqual(yield* realpath(rootIdentity.commonDir ?? ""), `${repoReal}/.git`); + + // 2. Root is a subdirectory: plain `git rev-parse --git-common-dir` returns + // a relative `../.git`; it must resolve to `/.git`, not outside. + const subIdentity = yield* probe.resolveIdentity(sub); + assert.strictEqual(subIdentity.state, "ok"); + assert.strictEqual(yield* realpath(subIdentity.topLevel ?? ""), repoReal); + assert.strictEqual(yield* realpath(subIdentity.commonDir ?? ""), `${repoReal}/.git`); + + // 3. Root is a linked worktree: the common dir points back at the main + // repository, and its top level is the worktree itself. + const worktreeIdentity = yield* probe.resolveIdentity(worktree); + assert.strictEqual(worktreeIdentity.state, "ok"); + assert.strictEqual(yield* realpath(worktreeIdentity.commonDir ?? ""), `${repoReal}/.git`); + assert.strictEqual(yield* realpath(worktreeIdentity.topLevel ?? ""), yield* realpath(worktree)); + + yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); + }).pipe(Effect.provide(E3VcsLayer)), +); + +it.live("F3: the real probe reports git add --sparse support only for a sparse checkout", () => + Effect.gen(function* () { + const realGit = NodeChildProcess.execFileSync("which", ["git"]).toString().trim(); + const base = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-e4-sparse-")), + ); + const repo = NodePath.join(base, "repo"); + const git = (args: ReadonlyArray) => + Effect.promise(async () => { + NodeChildProcess.execFileSync(realGit, args, { cwd: repo }); + }); + + yield* Effect.promise(() => NodeFSP.mkdir(NodePath.join(repo, "src"), { recursive: true })); + yield* git(["init"]); + yield* git(["config", "user.name", "Test"]); + yield* git(["config", "user.email", "test@test.com"]); + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(repo, "src", "file.txt"), "hello\n"), + ); + yield* git(["add", "."]); + yield* git(["commit", "-m", "initial"]); + + const probe = yield* makeRealGitProbe; + assert.strictEqual(yield* probe.probeSparseAdd(repo), "not-sparse"); + + yield* git(["sparse-checkout", "set", "src"]); + assert.strictEqual(yield* probe.probeSparseAdd(repo), "supported"); yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); - }).pipe(Effect.scoped), + }).pipe(Effect.provide(E3VcsLayer)), ); diff --git a/apps/server/src/environment/LaunchPreflight.ts b/apps/server/src/environment/LaunchPreflight.ts index 4adf15042f48..450d335205d2 100644 --- a/apps/server/src/environment/LaunchPreflight.ts +++ b/apps/server/src/environment/LaunchPreflight.ts @@ -49,7 +49,7 @@ const READ_PROBE_CANDIDATES = ["AGENTS.md", "package.json", "README.md", ".git/H export type LaunchPreflightFindingCode = | "git-startup-failed" | "git-probe-timed-out" - | "git-index-fast-path-unavailable" + | "git-sparse-add-unsupported" | "git-probe-failed" | "shared-root-git" | "root-read-slow" @@ -89,16 +89,22 @@ export interface LaunchPreflightGitProbe { root: string, ) => Effect.Effect; /** - * Probes the optional `rev-parse --path-format=absolute` convenience used by - * the checkpoint index-reuse fast path. `unsupported` only means the faster - * path is unavailable — the fallback still checkpoints — so it never blocks. + * Probes the real capability the checkpoint path needs in a sparse + * checkout: `git add --sparse`. Only meaningful when the root repository is + * actually sparse — in an ordinary repository `--sparse` is never used, so + * its absence is not actionable. Read-only: it inspects config and `git add + * -h`, never stages anything. The optional `rev-parse --path-format` fast + * path is deliberately not probed or warned about: its absence is a harmless + * optimization fallback handled inside `GitVcsDriver`. */ - readonly probeIndexFastPath: ( + readonly probeSparseAdd: ( root: string, - ) => Effect.Effect; + ) => Effect.Effect; } -export type LaunchPreflightIndexFastPath = "supported" | "unsupported" | "unknown"; +export type LaunchPreflightSparseCapability = + /** The root is not a sparse checkout, so `git add --sparse` is not required. */ + "not-sparse" | "supported" | "unsupported" | "unknown"; export interface LaunchPreflightRepoIdentity { readonly state: "ok" | "not-a-repository" | "failed"; @@ -154,6 +160,22 @@ const normalizeForCompare = (path: Path.Path, value: string): string => { const samePath = (path: Path.Path, a: string | null, b: string): boolean => a !== null && normalizeForCompare(path, a) === normalizeForCompare(path, b); +/** + * Whether `cwd` is exactly the deliberately configured shared session root. + * This is the only source of shared-inbox intent: equality with an ordinary + * working directory (including `ServerConfig.cwd`) never declares one. An + * unset or empty setting means every session is ordinary. + */ +export const isConfiguredSharedSessionRoot = ( + path: Path.Path, + cwd: string, + configuredRoot: string | undefined, +): boolean => { + const trimmed = configuredRoot?.trim() ?? ""; + if (trimmed.length === 0) return false; + return normalizeForCompare(path, cwd) === normalizeForCompare(path, trimmed); +}; + const isInside = (path: Path.Path, child: string | null, parent: string): boolean => { if (child === null) return false; const normalizedChild = normalizeForCompare(path, child); @@ -284,23 +306,25 @@ export const runLaunchPreflight = ( }); } - // The optional index-reuse fast path is probed for context only. Its - // absence is handled by GitVcsDriver's temporary-index fallback and must - // never block a launch. - const fastPathOutcome = yield* input.git.probeIndexFastPath(input.root).pipe( + // The real capability the checkpoint path needs in a sparse checkout is + // `git add --sparse`. Probe it read-only and only report it when the + // repository is actually sparse; the optional `--path-format` fast path + // is never probed or warned about (see the interface comment). + const sparseOutcome = yield* input.git.probeSparseAdd(input.root).pipe( Effect.map((state) => ({ _tag: "ok" as const, state })), Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), Effect.timeoutOption(GIT_PROBE_TIMEOUT), Effect.map(Option.getOrElse(() => ({ _tag: "timeout" as const }))), ); - if (fastPathOutcome._tag === "ok" && fastPathOutcome.state === "unsupported") { + if (sparseOutcome._tag === "ok" && sparseOutcome.state === "unsupported") { yield* add({ - code: "git-index-fast-path-unavailable", + code: "git-sparse-add-unsupported", severity: "warning", message: - "The Git this launch resolves does not support `git rev-parse --path-format`. T3 Code will " + - "rebuild the temporary checkpoint index instead of reusing the on-disk index; sessions still " + - "run and checkpoint. A newer Git restores the faster path only.", + "This is a sparse Git checkout, but the Git this launch resolves does not support " + + "`git add --sparse`. T3 Code cannot tell which files the sparse rules exclude, so a " + + "checkpoint may record excluded files as deleted. Install a newer Git to keep sparse " + + "checkpoints accurate; the session can still start.", }); } } else if (gitOutcome._tag === "timeout" || gitOutcome.error.reason === "timeout") { @@ -402,12 +426,11 @@ export class LaunchPreflight extends Context.Service< } >()("t3/environment/LaunchPreflight") {} -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const vcsProcess = yield* VcsProcess.VcsProcess; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - +/** Builds the production Git probe from the shared bounded VCS process runner. */ +export const makeGitProbe = ( + vcsProcess: VcsProcess.VcsProcess["Service"], + path: Path.Path, +): LaunchPreflightGitProbe => { const runGit = ( operation: string, root: string, @@ -426,7 +449,7 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(classifyGitProbeError)); - const git: LaunchPreflightGitProbe = { + return { version: (root) => runGit("launch-preflight.git-version", root, ["--version"], false).pipe( Effect.map((result) => parseGitVersion(result.stdout) ?? parseGitVersion(result.stderr)), @@ -464,11 +487,16 @@ export const make = Effect.gen(function* () { true, ); const rawCommonDir = Number(commonResult.exitCode) === 0 ? commonResult.stdout.trim() : ""; + // Plain `git rev-parse --git-common-dir` prints a path relative to the + // directory Git actually ran in (the invocation cwd), e.g. `repo/sub` + // yields `../.git`. Resolve against the exact invocation cwd `root`, + // not the top level, or an ordinary subdirectory launch would resolve + // to a path outside the repository. Absolute output is untouched. const commonDir = rawCommonDir.length > 0 ? path.isAbsolute(rawCommonDir) ? rawCommonDir - : path.resolve(topLevel.length > 0 ? topLevel : root, rawCommonDir) + : path.resolve(root, rawCommonDir) : null; return { state: "ok", @@ -477,23 +505,38 @@ export const make = Effect.gen(function* () { detail: "", } satisfies LaunchPreflightRepoIdentity; }), - probeIndexFastPath: (root) => - runGit( - "launch-preflight.git-index-fast-path", - root, - ["rev-parse", "--path-format=absolute", "--git-path", "index"], - true, - ).pipe( - Effect.map((result): LaunchPreflightIndexFastPath => { - if (Number(result.exitCode) === 0) return "supported"; - const stderr = result.stderr.trim(); - if (/unknown option|usage: git rev-parse|path-format/i.test(stderr)) { - return "unsupported"; - } - return "unknown"; - }), - ), + probeSparseAdd: (root) => + Effect.gen(function* () { + // Read-only: is the root repository actually a sparse checkout? + const sparseConfig = yield* runGit( + "launch-preflight.git-sparse-config", + root, + ["config", "--bool", "core.sparseCheckout"], + true, + ); + if (Number(sparseConfig.exitCode) !== 0 || sparseConfig.stdout.trim() !== "true") { + return "not-sparse" satisfies LaunchPreflightSparseCapability; + } + // Read-only usage probe; `git add -h` never stages a file. + const help = yield* runGit( + "launch-preflight.git-sparse-add-help", + root, + ["add", "-h"], + true, + ); + return /--(?:\[no-\])?sparse\b/.test(`${help.stdout}${help.stderr}`) + ? ("supported" satisfies LaunchPreflightSparseCapability) + : ("unsupported" satisfies LaunchPreflightSparseCapability); + }), }; +}; + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const vcsProcess = yield* VcsProcess.VcsProcess; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = makeGitProbe(vcsProcess, path); const files: LaunchPreflightFileProbe = { exists: (target) => fileSystem.exists(target).pipe(Effect.orElseSucceed(() => false)), diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index f714a70f783d..55bc7390f852 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -85,9 +85,8 @@ const testLayer = (input: { return Layer.mergeAll( ExternalLauncher.layer.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))), Layer.succeed(HostProcessPlatform, input.platform), - Layer.succeed( - SpawnExecutableResolution, - (command) => input.resolveExecutable?.(command) ?? command, + Layer.succeed(SpawnExecutableResolution, (command) => + Effect.succeed(input.resolveExecutable?.(command) ?? command), ), ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })), ); diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index e264ba7849da..3d652e5342ff 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -151,10 +151,12 @@ describe("runProcess", () => { PATHEXT: ".COM;.EXE;.BAT;.CMD", }), Effect.provideService(SpawnExecutableResolution, (_command, _platform, env) => - env.PATH === "C:\\Users\\tester\\AppData\\Roaming\\npm" && - env.AZURE_CONFIG_DIR === "C:\\Users\\tester\\.azure" - ? "C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd" - : undefined, + Effect.succeed( + env.PATH === "C:\\Users\\tester\\AppData\\Roaming\\npm" && + env.AZURE_CONFIG_DIR === "C:\\Users\\tester\\.azure" + ? "C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd" + : undefined, + ), ), Effect.map((result) => { expect(result.stdout).toBe("[]"); diff --git a/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts b/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts index 020fc48a4656..84396f50b4e4 100644 --- a/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts @@ -18,7 +18,7 @@ function withWindowsResolution(input: { return (effect: Effect.Effect) => effect.pipe( Effect.provideService(HostProcessPlatform, "win32"), - Effect.provideService(SpawnExecutableResolution, () => input.resolvedCommand), + Effect.provideService(SpawnExecutableResolution, () => Effect.succeed(input.resolvedCommand)), Effect.provideService(ClaudeExecutableFileCheck, (filePath) => existing.has(filePath)), ); } @@ -29,9 +29,9 @@ describe("resolveClaudeSdkExecutablePath", () => { expect( yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( Effect.provideService(HostProcessPlatform, "darwin"), - Effect.provideService(SpawnExecutableResolution, () => { - throw new Error("must not resolve on non-Windows platforms"); - }), + Effect.provideService(SpawnExecutableResolution, () => + Effect.die("must not resolve on non-Windows platforms"), + ), ), ).toBe("claude"); }), diff --git a/apps/server/src/provider/Drivers/ClaudeExecutable.ts b/apps/server/src/provider/Drivers/ClaudeExecutable.ts index febfdb26f9e3..506af39fbc94 100644 --- a/apps/server/src/provider/Drivers/ClaudeExecutable.ts +++ b/apps/server/src/provider/Drivers/ClaudeExecutable.ts @@ -67,7 +67,7 @@ export const resolveClaudeSdkExecutablePath = Effect.fn("resolveClaudeSdkExecuta const resolveExecutable = yield* SpawnExecutableResolution; const isFile = yield* ClaudeExecutableFileCheck; - const resolved = resolveExecutable(binaryPath, platform, environment) ?? binaryPath; + const resolved = (yield* resolveExecutable(binaryPath, platform, environment)) ?? binaryPath; const extension = NodePath.win32.extname(resolved).toLowerCase(); if (!WINDOWS_SHIM_EXTENSIONS.has(extension)) { return resolved; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 50dbb379fda5..eb71f9caf357 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -537,23 +537,14 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const launchPreflight = yield* LaunchPreflight.LaunchPreflight; const runLaunchPreflight = options?.launchPreflightRunner ?? launchPreflight.run; - // A launch cwd is a shared root only when it is exactly the configured - // `ServerConfig.cwd`. A nested repository selected as the session cwd has a - // different path and stays an ordinary repository session. - const normalizeResolved = (candidate: string): string => { - let resolved: string; - try { - resolved = pathService.resolve(candidate); - } catch { - resolved = candidate; - } - return resolved.length > 1 && resolved.endsWith(pathService.sep) - ? resolved.slice(0, -pathService.sep.length) - : resolved; - }; - const configuredSharedRoot = normalizeResolved(serverConfig.cwd); - const isConfiguredSharedRoot = (cwd: string): boolean => - normalizeResolved(cwd) === configuredSharedRoot; + // Shared-inbox intent is an explicit, exact-root opt-in from settings. The + // backend's own cwd is an ordinary provider working directory, so equality + // with it (or with any folder name/breadth/Git presence) never declares a + // shared root. A configured shared root applies regardless of backend cwd. + const configuredSharedRoot: Effect.Effect = serverSettings.getSettings.pipe( + Effect.map((settings) => settings.sharedSessionRoot), + Effect.orElseSucceed(() => undefined), + ); /** * Runs the bounded launch preflight against the exact cwd a provider process @@ -580,7 +571,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( return; } - const isSharedRoot = isConfiguredSharedRoot(input.cwd); + const isSharedRoot = LaunchPreflight.isConfiguredSharedSessionRoot( + pathService, + input.cwd, + yield* configuredSharedRoot, + ); const result = yield* runLaunchPreflight(input.cwd, { isSharedRoot }).pipe( Effect.catchCause(() => Effect.succeed({ diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 19af22c882ae..a4de598dc849 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -887,7 +887,7 @@ describe("providerMaintenanceRunner", () => { PATHEXT: ".COM;.EXE;.BAT;.CMD", }), Layer.succeed(SpawnExecutableResolution, (command) => - command === "npm" ? "C:\\fake\\npm\\npm.cmd" : undefined, + Effect.succeed(command === "npm" ? "C:\\fake\\npm\\npm.cmd" : undefined), ), latestVersionHttpClient("0.0.0"), Layer.succeed( diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 1e391a5e4641..cb3ea440b2b4 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -909,6 +909,7 @@ export const make = (options?: StartupOptions) => const serverSettings = yield* ServerSettings.ServerSettingsService; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const launchPreflight = yield* LaunchPreflight.LaunchPreflight; + const pathService = yield* Path.Path; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const crypto = yield* Crypto.Crypto; @@ -965,36 +966,49 @@ export const make = (options?: StartupOptions) => yield* Effect.logDebug("startup phase: running launch preflight"); yield* runStartupPhase( "launch.preflight", - launchPreflight.run(serverConfig.cwd, { isSharedRoot: true }).pipe( - Effect.tap((result) => - Effect.gen(function* () { - yield* Effect.forEach( - result.warnings, - (warning) => - Effect.logWarning(`launch preflight: ${warning.message}`, { - code: warning.code, - severity: warning.severity, - cwd: serverConfig.cwd, - }), - { discard: true }, - ); - yield* Effect.forEach( - result.blockers, - (blocker) => - Effect.logError(`launch preflight: ${blocker.message}`, { - code: blocker.code, - severity: blocker.severity, - cwd: serverConfig.cwd, - }), - { discard: true }, - ); - }), - ), - Effect.asVoid, - Effect.catchCause((cause) => - Effect.logWarning("launch preflight failed to run", { cause }), - ), - ), + Effect.gen(function* () { + // Shared-inbox intent comes only from the explicit setting; the + // server's own cwd is an ordinary working directory. + const sharedSessionRoot = yield* serverSettings.getSettings.pipe( + Effect.map((settings) => settings.sharedSessionRoot), + Effect.orElseSucceed(() => undefined), + ); + const isSharedRoot = LaunchPreflight.isConfiguredSharedSessionRoot( + pathService, + serverConfig.cwd, + sharedSessionRoot, + ); + return yield* launchPreflight.run(serverConfig.cwd, { isSharedRoot }).pipe( + Effect.tap((result) => + Effect.gen(function* () { + yield* Effect.forEach( + result.warnings, + (warning) => + Effect.logWarning(`launch preflight: ${warning.message}`, { + code: warning.code, + severity: warning.severity, + cwd: serverConfig.cwd, + }), + { discard: true }, + ); + yield* Effect.forEach( + result.blockers, + (blocker) => + Effect.logError(`launch preflight: ${blocker.message}`, { + code: blocker.code, + severity: blocker.severity, + cwd: serverConfig.cwd, + }), + { discard: true }, + ); + }), + ), + Effect.asVoid, + Effect.catchCause((cause) => + Effect.logWarning("launch preflight failed to run", { cause }), + ), + ); + }), ); yield* Effect.logDebug("startup phase: parking orchestration roots at activation"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index fc1d71c66322..18a0cb0657cd 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1220,6 +1220,17 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(null)), ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + /** + * The one directory this environment treats as a shared session inbox. It is + * an explicit, exact-root opt-in: provider sessions whose cwd resolves to + * this directory (and only this directory) are checked for an accidental + * umbrella repository, and a configured shared root is honored regardless of + * the backend's own working directory. Empty means ordinary — every + * repository session, including the server's own cwd, is treated as a normal + * working directory. Never inferred from a folder name, breadth, child + * repositories or the mere presence of Git. + */ + sharedSessionRoot: Schema.optionalKey(TrimmedString), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( Effect.succeed({ @@ -1520,6 +1531,7 @@ export const ServerSettingsPatch = Schema.Struct({ newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), + sharedSessionRoot: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), generateThreadTitles: Schema.optionalKey(Schema.Boolean), sourceControlWritingStyle: Schema.optionalKey( diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 621fe49b3087..91feeded28e4 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -498,9 +498,8 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" } }, ).pipe( Effect.provideService(HostProcessPlatform, "win32"), - Effect.provideService( - SpawnExecutableResolution, - () => "C:\\Program Files\\npm & tools\\vp.cmd", + Effect.provideService(SpawnExecutableResolution, () => + Effect.succeed("C:\\Program Files\\npm & tools\\vp.cmd"), ), ); @@ -530,7 +529,7 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { }), Effect.provideService(SpawnExecutableResolution, (_command, _platform, env) => { resolvedEnvironment = env; - return "C:\\Users\\tester\\AppData\\Roaming\\npm\\codex.cmd"; + return Effect.succeed("C:\\Users\\tester\\AppData\\Roaming\\npm\\codex.cmd"); }), ); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 11a45907cc1d..b7938b1bc7f7 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -3,6 +3,7 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -89,46 +90,62 @@ export type SpawnExecutableResolver = ( command: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv, -) => string | undefined; +) => Effect.Effect; +/** + * Asynchronous, bounded Windows executable resolution. It keeps the exact same + * PATH/PATHEXT candidate logic and npm `.cmd`/`.bat` wrapper semantics as the + * rest of the shell, but checks each candidate with an asynchronous `stat` + * instead of a synchronous `statSync`. A synchronous scan blocks the event + * loop, so an outer `Effect` timeout cannot interrupt a stalled lookup; an + * asynchronous one can. + */ function resolveSpawnExecutableWithNode( command: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv, -): string | undefined { - const path = platform === "win32" ? NodePath.win32 : NodePath.posix; - const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; - const candidates = resolveCommandCandidates( - command, - platform, - windowsPathExtensions, - path.extname, - ); - const isExecutable = (candidate: string) => { - try { - if (!NodeFS.statSync(candidate).isFile()) return false; - if (platform === "win32") { - return windowsPathExtensions.includes(path.extname(candidate).toUpperCase()); +): Effect.Effect { + return Effect.gen(function* () { + const path = platform === "win32" ? NodePath.win32 : NodePath.posix; + const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; + const candidates = resolveCommandCandidates( + command, + platform, + windowsPathExtensions, + path.extname, + ); + const isExecutable = (candidate: string) => + Effect.promise(async () => { + try { + const stat = await NodeFSP.stat(candidate); + if (!stat.isFile()) return false; + if (platform === "win32") { + return windowsPathExtensions.includes(path.extname(candidate).toUpperCase()); + } + await NodeFSP.access(candidate, NodeFS.constants.X_OK); + return true; + } catch { + return false; + } + }); + + if (command.includes("/") || command.includes("\\")) { + for (const candidate of candidates) { + if (yield* isExecutable(candidate)) return candidate; } - return canExecuteFile(candidate); - } catch { - return false; + return undefined; } - }; - if (command.includes("/") || command.includes("\\")) { - return candidates.find(isExecutable); - } - - for (const pathEntry of (readEnvPath(env) ?? "").split(pathDelimiterForPlatform(platform))) { - const normalizedPathEntry = stripWrappingQuotes(pathEntry.trim()); - if (normalizedPathEntry.length === 0) continue; - for (const candidate of candidates) { - const candidatePath = path.join(normalizedPathEntry, candidate); - if (isExecutable(candidatePath)) return candidatePath; + for (const pathEntry of (readEnvPath(env) ?? "").split(pathDelimiterForPlatform(platform))) { + const normalizedPathEntry = stripWrappingQuotes(pathEntry.trim()); + if (normalizedPathEntry.length === 0) continue; + for (const candidate of candidates) { + const candidatePath = path.join(normalizedPathEntry, candidate); + if (yield* isExecutable(candidatePath)) return candidatePath; + } } - } - return undefined; + return undefined; + }); } export const SpawnExecutableResolution = Context.Reference( @@ -643,7 +660,7 @@ export const resolveSpawnCommand = Effect.fn("shell.resolveSpawnCommand")(functi ? { ...hostEnvironment, ...options.env } : options.env; const resolveExecutable = yield* SpawnExecutableResolution; - const resolvedCommand = resolveExecutable(command, platform, env) ?? command; + const resolvedCommand = (yield* resolveExecutable(command, platform, env)) ?? command; const extension = NodePath.win32.extname(resolvedCommand).toLowerCase(); if (extension !== ".cmd" && extension !== ".bat") { return { command: resolvedCommand, args: [...args], shell: false }; From 7d145e6aeb923e6583d779ab2ccc50737687da1b Mon Sep 17 00:00:00 2001 From: business account Date: Mon, 28 Sep 2026 01:36:43 -0400 Subject: [PATCH 06/18] =?UTF-8?q?feat(server):=20E4=20F4=20=E2=80=94=20del?= =?UTF-8?q?iver=20pre-thread=20startup=20preflight=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carry startup launch-preflight warnings (found before any thread exists) through an in-memory Context.Reference inbox to the first provider session in the same exact root, reusing the existing thread-activity warning transport. No new persistent store, dashboard or settings edit. --- .../providerService.integration.test.ts | 43 +++++++++++++++++ .../server/src/environment/LaunchPreflight.ts | 4 ++ .../LaunchPreflightWarningInbox.ts | 48 +++++++++++++++++++ .../src/provider/Layers/ProviderService.ts | 13 ++++- apps/server/src/serverRuntimeStartup.ts | 13 +++++ 5 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/environment/LaunchPreflightWarningInbox.ts diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 8c03394251c1..ed7d7b811347 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -38,6 +38,7 @@ import { } from "../src/provider/Services/ProviderService.ts"; import * as ServerConfig from "../src/config.ts"; import * as LaunchPreflight from "../src/environment/LaunchPreflight.ts"; +import { LaunchPreflightWarningInbox } from "../src/environment/LaunchPreflightWarningInbox.ts"; import { ServerSettingsService } from "../src/serverSettings.ts"; import { execScriptSource, writeFakeCli } from "../src/testUtils/fakeCli.ts"; import { AnalyticsService } from "../src/telemetry/AnalyticsService.ts"; @@ -102,6 +103,11 @@ const makeIntegrationFixture = (options?: { readonly settings?: Parameters[0]; /** Configure the workspace directory itself as the shared session root. */ readonly sharedSessionRootIsWorkspace?: boolean; + /** Pre-seed the pre-thread startup warning inbox for the workspace cwd. */ + readonly pendingWarnings?: ReadonlyArray<{ + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }>; readonly launchPreflightRunner?: ( root: string, options?: { readonly isSharedRoot?: boolean }, @@ -116,6 +122,17 @@ const makeIntegrationFixture = (options?: { Effect.gen(function* () { const cwd = yield* makeWorkspaceDirectory; const harness = yield* makeTestProviderAdapterHarness(); + const pathService = yield* Path.Path; + const inbox = new Map< + string, + ReadonlyArray<{ + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }> + >(); + if (options?.pendingWarnings !== undefined) { + inbox.set(LaunchPreflight.normalizePathKey(pathService, cwd), options.pendingWarnings); + } // A real adapter whose configured executable is the caller's path, so a // launch exercises the actual platform spawn/error path (not a mock). @@ -144,6 +161,7 @@ const makeIntegrationFixture = (options?: { const shared = Layer.mergeAll( directoryLayer, Layer.succeed(ProviderAdapterRegistry, registry), + Layer.succeed(LaunchPreflightWarningInbox, inbox), ServerConfig.layerTest(options?.serverConfigCwd ?? cwd, cwd).pipe( Layer.provide(NodeServices.layer), ), @@ -585,6 +603,31 @@ it.live("a configured shared session root applies independently of the backend c }).pipe(Effect.provide(NodeServices.layer)), ); +it.live("pre-thread startup warnings reach the first affected session", () => + Effect.gen(function* () { + const reported = yield* Ref.make>([]); + const fixture = yield* makeIntegrationFixture({ + launchPreflightRunner: () => Effect.succeed(findingResult([])), + reportLaunchPreflightWarning: ({ message }) => + Ref.update(reported, (current) => [...current, message]), + pendingWarnings: [{ code: "shared-root-git", message: "startup umbrella warning" }], + }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + yield* provider.startSession(ThreadId.make("thread-startup-warning"), { + threadId: ThreadId.make("thread-startup-warning"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: fixture.cwd, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(fixture.layer)); + + assert.deepStrictEqual(yield* Ref.get(reported), ["startup umbrella warning"]); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.live("the recovery path also invokes the launch preflight", () => Effect.gen(function* () { const calls = yield* Ref.make(0); diff --git a/apps/server/src/environment/LaunchPreflight.ts b/apps/server/src/environment/LaunchPreflight.ts index 450d335205d2..4e7d084d4624 100644 --- a/apps/server/src/environment/LaunchPreflight.ts +++ b/apps/server/src/environment/LaunchPreflight.ts @@ -157,6 +157,10 @@ const normalizeForCompare = (path: Path.Path, value: string): string => { : resolved; }; +/** Normalizes a path into a stable key for exact-root comparisons. */ +export const normalizePathKey = (path: Path.Path, value: string): string => + normalizeForCompare(path, value); + const samePath = (path: Path.Path, a: string | null, b: string): boolean => a !== null && normalizeForCompare(path, a) === normalizeForCompare(path, b); diff --git a/apps/server/src/environment/LaunchPreflightWarningInbox.ts b/apps/server/src/environment/LaunchPreflightWarningInbox.ts new file mode 100644 index 000000000000..53b05d4b92c7 --- /dev/null +++ b/apps/server/src/environment/LaunchPreflightWarningInbox.ts @@ -0,0 +1,48 @@ +/** + * In-memory hand-off for launch-preflight warnings found before any provider + * session exists. + * + * The startup preflight runs while no thread is available, so its findings can + * only be logged there. This inbox carries them to the first provider session + * in the same working directory, which delivers them through the existing + * thread-activity warning transport — the same one a live launch uses. It is a + * process-scoped `Context.Reference` (like the shell command-resolution cache), + * never persisted, and not a dashboard or a store. + * + * @module LaunchPreflightWarningInbox + */ +import * as Context from "effect/Context"; + +import type { LaunchPreflightFindingCode } from "./LaunchPreflight.ts"; + +export interface PendingLaunchPreflightWarning { + readonly code: LaunchPreflightFindingCode; + readonly message: string; +} + +export const LaunchPreflightWarningInbox = Context.Reference< + Map> +>("@t3tools/server/LaunchPreflightWarningInbox", { + defaultValue: () => new Map(), +}); + +/** Records startup findings under an already-normalized working directory. */ +export const recordLaunchPreflightWarnings = ( + inbox: Map>, + normalizedCwd: string, + warnings: ReadonlyArray, +): void => { + if (warnings.length === 0) return; + inbox.set(normalizedCwd, [...(inbox.get(normalizedCwd) ?? []), ...warnings]); +}; + +/** Takes (and clears) pending findings for an already-normalized directory. */ +export const takeLaunchPreflightWarnings = ( + inbox: Map>, + normalizedCwd: string, +): ReadonlyArray => { + const pending = inbox.get(normalizedCwd); + if (pending === undefined) return []; + inbox.delete(normalizedCwd); + return pending; +}; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index eb71f9caf357..914e0c4066fe 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -89,6 +89,7 @@ import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as LaunchPreflight from "../../environment/LaunchPreflight.ts"; +import * as LaunchPreflightWarningInboxModule from "../../environment/LaunchPreflightWarningInbox.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; const isModelSelection = Schema.is(ModelSelection); const encodePromptJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -585,7 +586,17 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }), ), ); - for (const warning of result.warnings) { + // Deliver any warnings the startup preflight could only log (no thread + // existed yet) to this first affected session, through the same transport. + const inbox = yield* LaunchPreflightWarningInboxModule.LaunchPreflightWarningInbox; + const pendingWarnings = LaunchPreflightWarningInboxModule.takeLaunchPreflightWarnings( + inbox, + LaunchPreflight.normalizePathKey(pathService, input.cwd), + ); + const deliveredCodes = new Set(); + for (const warning of [...pendingWarnings, ...result.warnings]) { + if (deliveredCodes.has(warning.code)) continue; + deliveredCodes.add(warning.code); yield* Effect.logWarning(`launch preflight: ${warning.message}`, { code: warning.code, threadId: input.threadId, diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index cb3ea440b2b4..f2b1f6fda3b3 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -35,6 +35,7 @@ import * as Scope from "effect/Scope"; import * as ServerConfig from "./config.ts"; import * as Keybindings from "./keybindings.ts"; import * as LaunchPreflight from "./environment/LaunchPreflight.ts"; +import * as LaunchPreflightWarningInboxModule from "./environment/LaunchPreflightWarningInbox.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -991,6 +992,18 @@ export const make = (options?: StartupOptions) => }), { discard: true }, ); + // No thread exists yet, so carry these to the first provider + // session in this directory, which delivers them through the + // existing user-visible warning transport. + const inbox = yield* LaunchPreflightWarningInboxModule.LaunchPreflightWarningInbox; + LaunchPreflightWarningInboxModule.recordLaunchPreflightWarnings( + inbox, + LaunchPreflight.normalizePathKey(pathService, serverConfig.cwd), + result.warnings.map((warning) => ({ + code: warning.code, + message: warning.message, + })), + ); yield* Effect.forEach( result.blockers, (blocker) => From e88a51f3566b47f1cbd6cee4abad05da6065ebcf Mon Sep 17 00:00:00 2001 From: business account Date: Mon, 28 Sep 2026 03:41:57 -0400 Subject: [PATCH 07/18] =?UTF-8?q?fix(server):=20E5=20acceptance=20?= =?UTF-8?q?=E2=80=94=20consumer-driven=20sparse=20gate=20and=20real=20clie?= =?UTF-8?q?nt=20delivery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gate git add --sparse applicability on the selected consumer/operation (OpenCode snapshots require it in ordinary Git repositories) instead of core.sparseCheckout alone; probe read-only with the selected provider env. - Thread the selected provider environment into the preflight so the probe inspects the actual Git the launch resolves, not the host default. - Deliver pre-thread startup notices through the production reporter; keep a pending notice until delivery succeeds and bound the inbox. - Extract the production launch-preflight reporter so server.ts and the acceptance test share one implementation. - Add A3 production-path integration test: real OrchestrationEngine + real subscription observes the launch.preflight activity. --- ...aunchPreflightDelivery.integration.test.ts | 308 ++++++++++++++++++ .../providerService.integration.test.ts | 14 +- .../src/environment/LaunchPreflight.test.ts | 144 +++++++- .../server/src/environment/LaunchPreflight.ts | 152 +++++++-- .../LaunchPreflightWarningInbox.ts | 50 ++- .../environment/launchPreflightReporter.ts | 48 +++ .../src/provider/Layers/ProviderService.ts | 135 +++++--- apps/server/src/server.ts | 27 +- 8 files changed, 765 insertions(+), 113 deletions(-) create mode 100644 apps/server/integration/launchPreflightDelivery.integration.test.ts create mode 100644 apps/server/src/environment/launchPreflightReporter.ts diff --git a/apps/server/integration/launchPreflightDelivery.integration.test.ts b/apps/server/integration/launchPreflightDelivery.integration.test.ts new file mode 100644 index 000000000000..b1b01d6eace7 --- /dev/null +++ b/apps/server/integration/launchPreflightDelivery.integration.test.ts @@ -0,0 +1,308 @@ +// @effect-diagnostics nodeBuiltinImport:off - a real temporary workspace exercises the launch path. +import { + CommandId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it, assert } from "@effect/vitest"; +import * as NodeChildProcess from "node:child_process"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "../src/config.ts"; +import * as LaunchPreflight from "../src/environment/LaunchPreflight.ts"; +import { LaunchPreflightWarningInbox } from "../src/environment/LaunchPreflightWarningInbox.ts"; +import { makeLaunchPreflightWarningReporter } from "../src/environment/launchPreflightReporter.ts"; +import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { OrchestrationEngineService } from "../src/orchestration/Services/OrchestrationEngine.ts"; +import * as ThreadBackgroundLiveness from "../src/orchestration/ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../src/orchestration/ThreadPlanProgress.ts"; +import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; +import * as ProviderSessionRuntime from "../src/persistence/ProviderSessionRuntime.ts"; +import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityResolver.ts"; +import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; +import { + NoOpProviderEventLoggers, + ProviderEventLoggers, +} from "../src/provider/Layers/ProviderEventLoggers.ts"; +import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; +import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; +import { ProviderService } from "../src/provider/Services/ProviderService.ts"; +import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts"; +import { ServerSettingsService } from "../src/serverSettings.ts"; +import { AnalyticsService } from "../src/telemetry/AnalyticsService.ts"; +import * as VcsProcess from "../src/vcs/VcsProcess.ts"; +import { makeTestProviderAdapterHarness } from "./TestProviderAdapter.integration.ts"; + +const codexInstanceId = ProviderInstanceId.make("codex"); + +const findingResult = ( + findings: ReadonlyArray, +): LaunchPreflight.LaunchPreflightResult => ({ + findings, + warnings: findings.filter((finding) => finding.severity === "warning"), + blockers: findings.filter((finding) => finding.severity === "blocker"), +}); + +/** + * The real orchestration engine the production reporter dispatches into. Its + * domain-event stream is exactly what a client subscribes to. + */ +const orchestrationEngineLayer = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + OrchestrationProjectionSnapshotQueryLive, +).pipe( + Layer.provideMerge(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide(OrchestrationEventStoreLive), + Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolver.layer), + Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-a3-engine-" })), + Layer.provideMerge(NodeServices.layer), +); + +const makeWorkspaceDirectory = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fs.makeTempDirectory(); + yield* fs.writeFileString(pathService.join(cwd, "README.md"), "v1\n"); + return cwd; +}).pipe(Effect.provide(NodeServices.layer)); + +it.live( + "A3: the production reporter delivers a real pre-thread warning through the real subscription", + () => + Effect.gen(function* () { + const cwd = yield* makeWorkspaceDirectory; + yield* Effect.promise(async () => { + NodeChildProcess.execFileSync("git", ["init", "-q"], { cwd }); + }); + + // A real preflight finding before any thread exists: the workspace is a + // Git repository and is explicitly configured as the shared session root, + // so the real bounded probe reports an unexpected umbrella repository. + const early = yield* Effect.gen(function* () { + const preflight = yield* LaunchPreflight.LaunchPreflight; + return yield* preflight.run(cwd, { isSharedRoot: true }); + }).pipe( + Effect.provide( + LaunchPreflight.layer.pipe( + Layer.provide(VcsProcess.layer), + Layer.provide(NodeServices.layer), + ), + ), + ); + assert.deepStrictEqual( + early.findings.map((finding) => finding.code), + ["shared-root-git"], + ); + const earlyMessage = early.warnings[0]?.message ?? ""; + assert.isAbove(earlyMessage.length, 0); + + const engineContext = yield* Layer.build(orchestrationEngineLayer); + const engine = Context.get(engineContext, OrchestrationEngineService); + const crypto = yield* Crypto.Crypto; + const reportWarning = makeLaunchPreflightWarningReporter(engine, crypto); + + const harness = yield* makeTestProviderAdapterHarness(); + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: harness.adapter, + }); + const inbox = new Map< + string, + ReadonlyArray<{ + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }> + >(); + const pathService = yield* Path.Path; + inbox.set( + LaunchPreflight.normalizePathKey(pathService, cwd), + early.warnings.map((warning) => ({ code: warning.code, message: warning.message })), + ); + + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntime.layer), + ); + const shared = Layer.mergeAll( + directoryLayer, + Layer.succeed(ProviderAdapterRegistry, registry), + Layer.succeed(LaunchPreflightWarningInbox, inbox), + ServerConfig.layerTest(cwd, cwd).pipe(Layer.provide(NodeServices.layer)), + ServerSettingsService.layerTest({ ...DEFAULT_SERVER_SETTINGS, sharedSessionRoot: cwd }), + AnalyticsService.layerTest, + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + ).pipe(Layer.provide(SqlitePersistenceMemory)); + + // The production reporter is the composition root's own closure; the + // per-launch runner returns no findings so the only warning delivered is + // the real pre-thread one. This is an observable, not a substituted sink. + const providerLayer = makeProviderServiceLive({ + reportLaunchPreflightWarning: reportWarning, + launchPreflightRunner: () => Effect.succeed(findingResult([])), + }).pipe(Layer.provide(NodeServices.layer), Layer.provideMerge(shared)); + + const projectId = ProjectId.make("a3-project"); + const threadId = ThreadId.make("a3-thread"); + const received = yield* Ref.make>([]); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const events = yield* engine.subscribeDomainEvents; + yield* events.pipe( + Stream.tap((event) => Ref.update(received, (current) => [...current, event])), + Stream.runDrain, + Effect.forkScoped, + ); + + // The startup notice is already pending. The thread is created only + // now, so the notice was found before it existed. + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("a3-project"), + projectId, + title: "A3", + workspaceRoot: cwd, + createdAt: "2026-09-28T00:00:00.000Z", + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("a3-thread"), + threadId, + projectId, + title: "A3", + modelSelection: { instanceId: codexInstanceId, model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-09-28T00:00:00.000Z", + }); + + const session = yield* provider.startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd, + runtimeMode: "full-access", + }); + assert.equal(session.provider, "codex"); + // Warning-only launch started the configured provider exactly once. + assert.equal(harness.getStartCount(), 1); + + // Give the forked subscription a chance to drain the appended event. + yield* Effect.sleep("50 millis"); + }).pipe(Effect.provide(providerLayer)); + + const collected = yield* Ref.get(received); + const activities = collected.filter( + (event): event is Extract => + event.type === "thread.activity-appended", + ); + const warningActivity = activities.find( + (event) => event.payload.activity.kind === "launch.preflight", + ); + assert.isDefined(warningActivity, "no launch.preflight activity reached the subscription"); + assert.equal(warningActivity?.payload.activity.tone, "error"); + assert.strictEqual(warningActivity?.payload.activity.summary, earlyMessage); + assert.equal(warningActivity?.payload.threadId, threadId); + assert.deepStrictEqual(warningActivity?.payload.activity.payload, { + code: "shared-root-git", + cwd, + }); + // A notice is not dropped before successful delivery: it was consumed. + assert.strictEqual(inbox.size, 0); + + yield* Effect.promise(() => + import("node:fs/promises").then((fs) => fs.rm(cwd, { recursive: true, force: true })), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live( + "A3: a notice is retained when the production report fails, and delivered by the next session", + () => + Effect.gen(function* () { + const cwd = yield* makeWorkspaceDirectory; + const code = "shared-root-git" as const; + const message = "The shared session root is itself a Git repository."; + const harness = yield* makeTestProviderAdapterHarness(); + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: harness.adapter, + }); + const pathService = yield* Path.Path; + const inbox = new Map>(); + inbox.set(LaunchPreflight.normalizePathKey(pathService, cwd), [{ code, message }]); + let attempt = 0; + + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntime.layer), + ); + const shared = Layer.mergeAll( + directoryLayer, + Layer.succeed(ProviderAdapterRegistry, registry), + Layer.succeed(LaunchPreflightWarningInbox, inbox), + ServerConfig.layerTest(cwd, cwd).pipe(Layer.provide(NodeServices.layer)), + ServerSettingsService.layerTest({ ...DEFAULT_SERVER_SETTINGS, sharedSessionRoot: cwd }), + AnalyticsService.layerTest, + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + ).pipe(Layer.provide(SqlitePersistenceMemory)); + const providerLayer = makeProviderServiceLive({ + // First delivery fails; the second succeeds. + reportLaunchPreflightWarning: () => + Effect.sync(() => { + attempt += 1; + return attempt > 1; + }), + launchPreflightRunner: () => Effect.succeed(findingResult([])), + }).pipe(Layer.provide(NodeServices.layer), Layer.provideMerge(shared)); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = ThreadId.make("a3-retry-thread"); + yield* provider.startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd, + runtimeMode: "full-access", + }); + // Failed delivery kept the notice pending for the next session. + assert.strictEqual(inbox.size, 1); + + const secondThread = ThreadId.make("a3-retry-thread-2"); + yield* provider.startSession(secondThread, { + threadId: secondThread, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd, + runtimeMode: "full-access", + }); + assert.strictEqual(attempt, 2); + assert.strictEqual(inbox.size, 0); + }).pipe(Effect.provide(providerLayer)); + + yield* Effect.promise(() => + import("node:fs/promises").then((fs) => fs.rm(cwd, { recursive: true, force: true })), + ); + }).pipe(Effect.provide(NodeServices.layer)), +); \ No newline at end of file diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index ed7d7b811347..326ad3dc5e6a 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -110,14 +110,18 @@ const makeIntegrationFixture = (options?: { }>; readonly launchPreflightRunner?: ( root: string, - options?: { readonly isSharedRoot?: boolean }, + options?: { + readonly isSharedRoot?: boolean; + readonly consumer?: LaunchPreflight.LaunchPreflightConsumer; + readonly gitEnvironment?: NodeJS.ProcessEnv; + }, ) => Effect.Effect; readonly reportLaunchPreflightWarning?: (input: { readonly threadId: ThreadId; readonly cwd: string; readonly code: LaunchPreflight.LaunchPreflightFindingCode; readonly message: string; - }) => Effect.Effect; + }) => Effect.Effect; }) => Effect.gen(function* () { const cwd = yield* makeWorkspaceDirectory; @@ -516,7 +520,7 @@ it.live("a launch-preflight warning is reported and the session still starts onc const fixture = yield* makeIntegrationFixture({ launchPreflightRunner: () => Effect.succeed(findingResult([warningFinding])), reportLaunchPreflightWarning: ({ message }) => - Ref.update(reported, (current) => [...current, message]), + Ref.update(reported, (current) => [...current, message]).pipe(Effect.as(true)), }); yield* Effect.gen(function* () { @@ -609,7 +613,7 @@ it.live("pre-thread startup warnings reach the first affected session", () => const fixture = yield* makeIntegrationFixture({ launchPreflightRunner: () => Effect.succeed(findingResult([])), reportLaunchPreflightWarning: ({ message }) => - Ref.update(reported, (current) => [...current, message]), + Ref.update(reported, (current) => [...current, message]).pipe(Effect.as(true)), pendingWarnings: [{ code: "shared-root-git", message: "startup umbrella warning" }], }); @@ -755,7 +759,7 @@ it.live("a real configured dummy provider executable launches exactly once and w grokBinaryPath: wrapperPath, launchPreflightRunner: () => Effect.succeed(findingResult([warningFinding])), reportLaunchPreflightWarning: ({ message }) => - Ref.update(reported, (current) => [...current, message]), + Ref.update(reported, (current) => [...current, message]).pipe(Effect.as(true)), }); yield* Effect.gen(function* () { diff --git a/apps/server/src/environment/LaunchPreflight.test.ts b/apps/server/src/environment/LaunchPreflight.test.ts index a8e3a603047c..4059eae11423 100644 --- a/apps/server/src/environment/LaunchPreflight.test.ts +++ b/apps/server/src/environment/LaunchPreflight.test.ts @@ -39,7 +39,7 @@ const gitProbe = ( ): LaunchPreflight.LaunchPreflightGitProbe => ({ version: () => Effect.succeed("2.55.0"), resolveIdentity: () => Effect.succeed(identity()), - probeSparseAdd: () => Effect.succeed("not-sparse"), + probeSparseAdd: () => Effect.succeed("not-required"), ...overrides, }); @@ -48,9 +48,13 @@ const input = (options: { readonly isSharedRoot?: boolean; readonly git?: LaunchPreflight.LaunchPreflightGitProbe; readonly files?: Partial; + readonly consumer?: LaunchPreflight.LaunchPreflightConsumer; + readonly gitEnvironment?: NodeJS.ProcessEnv; }): LaunchPreflight.LaunchPreflightInput => ({ root: options.root ?? "/session-root", ...(options.isSharedRoot !== undefined ? { isSharedRoot: options.isSharedRoot } : {}), + ...(options.consumer !== undefined ? { consumer: options.consumer } : {}), + ...(options.gitEnvironment !== undefined ? { gitEnvironment: options.gitEnvironment } : {}), git: options.git ?? gitProbe(), files: { exists: () => Effect.succeed(false), @@ -296,7 +300,7 @@ it.effect("does not warn about git add --sparse when the repository is not spars git: gitProbe({ resolveIdentity: () => Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), - probeSparseAdd: () => Effect.succeed("not-sparse"), + probeSparseAdd: () => Effect.succeed("not-required"), }), files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, }), @@ -696,7 +700,13 @@ it.live("F3: the real probe reports git add --sparse support only for a sparse c yield* git(["commit", "-m", "initial"]); const probe = yield* makeRealGitProbe; - assert.strictEqual(yield* probe.probeSparseAdd(repo), "not-sparse"); + assert.strictEqual(yield* probe.probeSparseAdd(repo), "not-required"); + // A selected OpenCode consumer requires `--sparse` even in an ordinary + // repository, so the capability is probed there too. + assert.strictEqual( + yield* probe.probeSparseAdd(repo, { requiredByConsumer: true }), + "supported", + ); yield* git(["sparse-checkout", "set", "src"]); assert.strictEqual(yield* probe.probeSparseAdd(repo), "supported"); @@ -704,3 +714,131 @@ it.live("F3: the real probe reports git add --sparse support only for a sparse c yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); }).pipe(Effect.provide(E3VcsLayer)), ); + +// --- A1/A2: consumer-driven `--sparse` applicability and the final launch env ---------------- + +/** + * A `git` wrapper that lacks `git add --sparse`. It reports an `add -h` usage + * without `--sparse` (the exact thing the probe reads), delegates every other + * invocation to the real Git, and records its own path plus the harmless + * environment sentinel it inherited. This models a provider environment whose + * resolved Git is older than the host's. + */ +const writeSparseLessGit = (binDir: string, realGit: string, recordPath: string) => { + NodeFS.writeFileSync( + NodePath.join(binDir, "git"), + [ + "#!/bin/sh", + `printf '%s|%s\\n' "$0" "$ENVCHK_SENTINEL" >> "${recordPath}"`, + 'if [ "$1" = "add" ] && [ "$2" = "-h" ]; then', + ' echo "usage: git add [options] [--] ..."', + ' echo " -n, --dry-run dry run"', + ' echo " -v, --verbose be verbose"', + " exit 0", + "fi", + `exec "${realGit}" "$@"`, + "", + ].join("\n"), + { mode: 0o755 }, + ); +}; + +const ordinaryGitConsumer: LaunchPreflight.LaunchPreflightConsumer = { + driver: "opencode", + snapshotsEnabled: true, +}; + +const makeOrdinaryRepo = (repo: string, realGit: string) => + Effect.gen(function* () { + yield* Effect.promise(() => NodeFSP.mkdir(repo, { recursive: true })); + const git = (args: ReadonlyArray) => + Effect.promise(async () => { + NodeChildProcess.execFileSync(realGit, args, { cwd: repo }); + }); + yield* git(["init"]); + yield* git(["config", "user.name", "Test"]); + yield* git(["config", "user.email", "test@test.com"]); + yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(repo, "file.txt"), "hello\n")); + yield* git(["add", "."]); + yield* git(["commit", "-m", "initial"]); + }); + +it.live( + "A1/A2: ordinary OpenCode repo resolves the selected provider Git and warns on missing --sparse", + () => + Effect.gen(function* () { + const realGit = NodeChildProcess.execFileSync("which", ["git"]).toString().trim(); + const base = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-v5-consumer-")), + ); + const repo = NodePath.join(base, "repo"); + const providerBin = NodePath.join(base, "provider-bin"); + const recordPath = NodePath.join(base, "record.log"); + const sentinel = "envchk-provider-sentinel"; + yield* Effect.promise(() => NodeFSP.mkdir(providerBin, { recursive: true })); + writeSparseLessGit(providerBin, realGit, recordPath); + yield* makeOrdinaryRepo(repo, realGit); + + const preflight = yield* LaunchPreflight.LaunchPreflight; + + // Host/default PATH resolves a capable Git: the ordinary repo passes even + // for the OpenCode consumer. + const healthy = yield* preflight.run(repo, { consumer: ordinaryGitConsumer }); + assert.deepStrictEqual(healthy.findings, []); + + // The selected provider environment resolves the controlled Git that + // lacks `--sparse`; the ordinary (non-sparse) checkout still warns. + const providerEnvironment: NodeJS.ProcessEnv = { + ...process.env, + PATH: `${providerBin}${NodePath.delimiter}${process.env.PATH ?? ""}`, + ENVCHK_SENTINEL: sentinel, + }; + const warned = yield* preflight.run(repo, { + consumer: ordinaryGitConsumer, + gitEnvironment: providerEnvironment, + }); + assert.deepStrictEqual(codes(warned), ["git-sparse-add-unsupported"]); + assert.deepStrictEqual(severities(warned), ["warning"]); + const message = warned.warnings[0]?.message ?? ""; + assert.include(message, "OpenCode"); + assert.include(message, "--sparse"); + + // The selected executable and the harmless environment sentinel are the + // ones from the provider environment, not the host default. + const recorded = (yield* Effect.promise(() => NodeFSP.readFile(recordPath, "utf8"))).trim(); + const recordLines = recorded.split("\n"); + assert.isTrue(recordLines.length > 0); + for (const line of recordLines) { + const [executable, recordedSentinel] = line.split("|"); + assert.strictEqual(executable, NodePath.join(providerBin, "git")); + assert.strictEqual(recordedSentinel, sentinel); + } + + // Snapshot-disabled control: the same Git is not required by the consumer + // (OpenCode snapshot staging off), so an ordinary checkout is silent. + const disabled = yield* preflight.run(repo, { + consumer: { driver: "opencode", snapshotsEnabled: false }, + gitEnvironment: providerEnvironment, + }); + assert.deepStrictEqual(disabled.findings, []); + + // Non-OpenCode control: no provider-specific Git requirement is applied + // globally to T3 sessions. + const otherConsumer = yield* preflight.run(repo, { + consumer: { driver: "codex", snapshotsEnabled: true }, + gitEnvironment: providerEnvironment, + }); + assert.deepStrictEqual(otherConsumer.findings, []); + + // Non-Git control: a plain directory has no repository to checkpoint. + const plain = NodePath.join(base, "plain"); + yield* Effect.promise(() => NodeFSP.mkdir(plain, { recursive: true })); + const nonGit = yield* preflight.run(plain, { + consumer: ordinaryGitConsumer, + gitEnvironment: providerEnvironment, + }); + assert.deepStrictEqual(nonGit.findings, []); + + yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); + }).pipe(Effect.provide(E3PreflightLayer)), +); diff --git a/apps/server/src/environment/LaunchPreflight.ts b/apps/server/src/environment/LaunchPreflight.ts index 4e7d084d4624..19ec6e19b19e 100644 --- a/apps/server/src/environment/LaunchPreflight.ts +++ b/apps/server/src/environment/LaunchPreflight.ts @@ -78,7 +78,10 @@ export class LaunchPreflightProbeError extends Data.TaggedError("LaunchPreflight export interface LaunchPreflightGitProbe { /** Returns the parsed Git version, or null when the output had none. */ - readonly version: (root: string) => Effect.Effect; + readonly version: ( + root: string, + env?: NodeJS.ProcessEnv, + ) => Effect.Effect; /** * Resolves the effective repository identity for the root using plain * `git rev-parse --show-toplevel` / `--git-common-dir` queries. Uses @@ -87,24 +90,53 @@ export interface LaunchPreflightGitProbe { */ readonly resolveIdentity: ( root: string, + env?: NodeJS.ProcessEnv, ) => Effect.Effect; /** - * Probes the real capability the checkpoint path needs in a sparse - * checkout: `git add --sparse`. Only meaningful when the root repository is - * actually sparse — in an ordinary repository `--sparse` is never used, so - * its absence is not actionable. Read-only: it inspects config and `git add - * -h`, never stages anything. The optional `rev-parse --path-format` fast - * path is deliberately not probed or warned about: its absence is a harmless - * optimization fallback handled inside `GitVcsDriver`. + * Probes the real capability the selected consumer/operation needs: + * `git add --sparse`. Read-only: it inspects config and `git add -h`, never + * stages anything. Applicability is a property of the consumer, not of the + * repository's `core.sparseCheckout`: the OpenCode snapshot operation passes + * `git add --all --sparse` for eligible files in ordinary repositories too, + * so `requiredByConsumer` marks those launches. A sparse checkout also + * requires `--sparse` for T3's own checkpoint path. The optional `rev-parse + * --path-format` fast path is deliberately not probed or warned about: its + * absence is a harmless optimization fallback handled inside `GitVcsDriver`. */ readonly probeSparseAdd: ( root: string, + options?: { + readonly requiredByConsumer?: boolean; + readonly env?: NodeJS.ProcessEnv; + }, ) => Effect.Effect; } export type LaunchPreflightSparseCapability = - /** The root is not a sparse checkout, so `git add --sparse` is not required. */ - "not-sparse" | "supported" | "unsupported" | "unknown"; + /** + * Neither the selected consumer/operation nor the repository's sparse-checkout + * configuration requires `git add --sparse`, so its absence is not actionable. + */ + "not-required" | "supported" | "unsupported"; + +/** + * The consumer/operation about to run. `--sparse` applicability belongs to the + * consumer: OpenCode's snapshot staging uses `git add --all --sparse` whenever + * snapshots are enabled and the project is a Git repository, regardless of + * `core.sparseCheckout`. T3's own checkpoint path only uses `--sparse` in a + * sparse checkout. + */ +export interface LaunchPreflightConsumer { + /** Provider driver kind selected for this launch (e.g. "opencode"). */ + readonly driver: string; + /** Whether OpenCode-style snapshot staging is enabled for this launch. */ + readonly snapshotsEnabled: boolean; +} + +/** Whether the selected consumer/operation stages with `git add --sparse`. */ +export const consumerUsesSparseAdd = ( + consumer: LaunchPreflightConsumer | undefined, +): boolean => consumer !== undefined && consumer.driver === "opencode" && consumer.snapshotsEnabled; export interface LaunchPreflightRepoIdentity { readonly state: "ok" | "not-a-repository" | "failed"; @@ -140,6 +172,17 @@ export interface LaunchPreflightInput { * repository is reported as an unexpected umbrella. */ readonly isSharedRoot?: boolean; + /** + * The selected consumer/operation. Determines whether `git add --sparse` is + * required even in an ordinary repository (OpenCode snapshots use it there). + * Absent means only a sparse checkout makes `--sparse` relevant. + */ + readonly consumer?: LaunchPreflightConsumer; + /** + * The environment the selected provider launch actually resolves `git` with + * (the same environment the adapter inherits). Absent means the host env. + */ + readonly gitEnvironment?: NodeJS.ProcessEnv; } const parseGitVersion = (output: string): string | null => @@ -228,7 +271,7 @@ export const runLaunchPreflight = ( // such as `.git.macfix-m1-retired` is a different path and is ignored. const rootGitMarker = yield* existsBounded(path.join(input.root, ".git")); - const gitOutcome = yield* input.git.version(input.root).pipe( + const gitOutcome = yield* input.git.version(input.root, input.gitEnvironment).pipe( Effect.map((version) => ({ _tag: "ok" as const, version })), Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), Effect.timeoutOption(GIT_PROBE_TIMEOUT), @@ -236,6 +279,9 @@ export const runLaunchPreflight = ( ); let effectiveRootRepository = rootGitMarker; + // Whether the root is inside a Git work tree (even when the root is a + // subdirectory or worktree). OpenCode snapshots only run for Git projects. + let inGitWorkTree = rootGitMarker; if (gitOutcome._tag === "ok") { if (gitOutcome.version === null) { @@ -247,7 +293,9 @@ export const runLaunchPreflight = ( }); } - const identityOutcome = yield* input.git.resolveIdentity(input.root).pipe( + const identityOutcome = yield* input.git + .resolveIdentity(input.root, input.gitEnvironment) + .pipe( Effect.map((identity) => ({ _tag: "ok" as const, identity })), Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), Effect.timeoutOption(GIT_PROBE_TIMEOUT), @@ -264,6 +312,7 @@ export const runLaunchPreflight = ( // different top level and stays an ordinary repository session. const rootIsRepository = samePath(path, identity.topLevel, canonicalRoot); effectiveRootRepository = rootIsRepository || rootGitMarker; + inGitWorkTree = true; if (isSharedRoot && rootIsRepository) { const commonUnderRoot = isInside(path, identity.commonDir, canonicalRoot); yield* add({ @@ -282,6 +331,7 @@ export const runLaunchPreflight = ( } case "not-a-repository": { effectiveRootRepository = rootGitMarker; + inGitWorkTree = rootGitMarker; break; } case "failed": { @@ -310,25 +360,37 @@ export const runLaunchPreflight = ( }); } - // The real capability the checkpoint path needs in a sparse checkout is - // `git add --sparse`. Probe it read-only and only report it when the - // repository is actually sparse; the optional `--path-format` fast path - // is never probed or warned about (see the interface comment). - const sparseOutcome = yield* input.git.probeSparseAdd(input.root).pipe( - Effect.map((state) => ({ _tag: "ok" as const, state })), - Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), - Effect.timeoutOption(GIT_PROBE_TIMEOUT), - Effect.map(Option.getOrElse(() => ({ _tag: "timeout" as const }))), - ); + // Applicability comes from the selected consumer/operation, not only + // from `core.sparseCheckout`. OpenCode snapshot staging passes `git add + // --all --sparse` for eligible files in ordinary repositories too, so a + // missing `--sparse` there is actionable. T3's own checkpoint path only + // needs it in a sparse checkout. Probe read-only; the optional + // `--path-format` fast path is never probed or warned about. + const requiredByConsumer = consumerUsesSparseAdd(input.consumer) && inGitWorkTree; + const sparseOutcome = yield* input.git + .probeSparseAdd(input.root, { + requiredByConsumer, + ...(input.gitEnvironment !== undefined ? { env: input.gitEnvironment } : {}), + }) + .pipe( + Effect.map((state) => ({ _tag: "ok" as const, state })), + Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), + Effect.timeoutOption(GIT_PROBE_TIMEOUT), + Effect.map(Option.getOrElse(() => ({ _tag: "timeout" as const }))), + ); if (sparseOutcome._tag === "ok" && sparseOutcome.state === "unsupported") { yield* add({ code: "git-sparse-add-unsupported", severity: "warning", - message: - "This is a sparse Git checkout, but the Git this launch resolves does not support " + - "`git add --sparse`. T3 Code cannot tell which files the sparse rules exclude, so a " + - "checkpoint may record excluded files as deleted. Install a newer Git to keep sparse " + - "checkpoints accurate; the session can still start.", + message: requiredByConsumer + ? "The selected OpenCode session snapshots this repository with `git add --sparse`, but the " + + "Git this launch resolves does not support `--sparse`. Staging changed and untracked files " + + "for a snapshot will fail, so snapshots may be incomplete. Install a newer Git (or disable " + + "OpenCode snapshots) to keep snapshots accurate; the session can still start." + : "This is a sparse Git checkout, but the Git this launch resolves does not support " + + "`git add --sparse`. T3 Code cannot tell which files the sparse rules exclude, so a " + + "checkpoint may record excluded files as deleted. Install a newer Git to keep sparse " + + "checkpoints accurate; the session can still start.", }); } } else if (gitOutcome._tag === "timeout" || gitOutcome.error.reason === "timeout") { @@ -425,7 +487,11 @@ export class LaunchPreflight extends Context.Service< { readonly run: ( root: string, - options?: { readonly isSharedRoot?: boolean }, + options?: { + readonly isSharedRoot?: boolean; + readonly consumer?: LaunchPreflightConsumer; + readonly gitEnvironment?: NodeJS.ProcessEnv; + }, ) => Effect.Effect; } >()("t3/environment/LaunchPreflight") {} @@ -440,6 +506,7 @@ export const makeGitProbe = ( root: string, args: ReadonlyArray, allowNonZeroExit: boolean, + env?: NodeJS.ProcessEnv, ) => vcsProcess .run({ @@ -450,21 +517,23 @@ export const makeGitProbe = ( timeoutMs: 1_500, maxOutputBytes: 4_000, ...(allowNonZeroExit ? { allowNonZeroExit: true } : {}), + ...(env !== undefined ? { env } : {}), }) .pipe(Effect.mapError(classifyGitProbeError)); return { - version: (root) => - runGit("launch-preflight.git-version", root, ["--version"], false).pipe( + version: (root, env) => + runGit("launch-preflight.git-version", root, ["--version"], false, env).pipe( Effect.map((result) => parseGitVersion(result.stdout) ?? parseGitVersion(result.stderr)), ), - resolveIdentity: (root) => + resolveIdentity: (root, env) => Effect.gen(function* () { const top = yield* runGit( "launch-preflight.git-toplevel", root, ["rev-parse", "--show-toplevel"], true, + env, ); if (Number(top.exitCode) !== 0) { const stderr = top.stderr.trim(); @@ -489,6 +558,7 @@ export const makeGitProbe = ( root, ["rev-parse", "--git-common-dir"], true, + env, ); const rawCommonDir = Number(commonResult.exitCode) === 0 ? commonResult.stdout.trim() : ""; // Plain `git rev-parse --git-common-dir` prints a path relative to the @@ -509,17 +579,24 @@ export const makeGitProbe = ( detail: "", } satisfies LaunchPreflightRepoIdentity; }), - probeSparseAdd: (root) => + probeSparseAdd: (root, options) => Effect.gen(function* () { + const env = options?.env; // Read-only: is the root repository actually a sparse checkout? const sparseConfig = yield* runGit( "launch-preflight.git-sparse-config", root, ["config", "--bool", "core.sparseCheckout"], true, + env, ); - if (Number(sparseConfig.exitCode) !== 0 || sparseConfig.stdout.trim() !== "true") { - return "not-sparse" satisfies LaunchPreflightSparseCapability; + const sparseCheckout = + Number(sparseConfig.exitCode) === 0 && sparseConfig.stdout.trim() === "true"; + // A selected consumer (OpenCode snapshots) requires `--sparse` even in + // an ordinary repository; a sparse checkout requires it for T3's own + // checkpoint path. Only then is a missing `--sparse` actionable. + if (!sparseCheckout && options?.requiredByConsumer !== true) { + return "not-required" satisfies LaunchPreflightSparseCapability; } // Read-only usage probe; `git add -h` never stages a file. const help = yield* runGit( @@ -527,6 +604,7 @@ export const makeGitProbe = ( root, ["add", "-h"], true, + env, ); return /--(?:\[no-\])?sparse\b/.test(`${help.stdout}${help.stderr}`) ? ("supported" satisfies LaunchPreflightSparseCapability) @@ -555,12 +633,16 @@ export const make = Effect.gen(function* () { }; return LaunchPreflight.of({ - run: (root: string, options?: { readonly isSharedRoot?: boolean }) => + run: (root: string, options) => runLaunchPreflight({ root, git, files, ...(options?.isSharedRoot !== undefined ? { isSharedRoot: options.isSharedRoot } : {}), + ...(options?.consumer !== undefined ? { consumer: options.consumer } : {}), + ...(options?.gitEnvironment !== undefined + ? { gitEnvironment: options.gitEnvironment } + : {}), }).pipe(Effect.provideService(Path.Path, path)), }); }); diff --git a/apps/server/src/environment/LaunchPreflightWarningInbox.ts b/apps/server/src/environment/LaunchPreflightWarningInbox.ts index 53b05d4b92c7..a33878f8c65c 100644 --- a/apps/server/src/environment/LaunchPreflightWarningInbox.ts +++ b/apps/server/src/environment/LaunchPreflightWarningInbox.ts @@ -9,6 +9,11 @@ * process-scoped `Context.Reference` (like the shell command-resolution cache), * never persisted, and not a dashboard or a store. * + * Delivery is peek-then-clear: a pending notice is removed only after it was + * actually delivered, so a transient delivery failure does not silently discard + * a warning. The per-directory list is bounded so a long-running server cannot + * accumulate unbounded pending notices. + * * @module LaunchPreflightWarningInbox */ import * as Context from "effect/Context"; @@ -20,29 +25,56 @@ export interface PendingLaunchPreflightWarning { readonly message: string; } +/** Cap on pending notices retained per working directory. */ +export const LAUNCH_PREFLIGHT_INBOX_LIMIT = 16; + export const LaunchPreflightWarningInbox = Context.Reference< Map> >("@t3tools/server/LaunchPreflightWarningInbox", { defaultValue: () => new Map(), }); -/** Records startup findings under an already-normalized working directory. */ +/** + * Records startup findings under an already-normalized working directory, + * bounded to {@link LAUNCH_PREFLIGHT_INBOX_LIMIT} notices per directory. + */ export const recordLaunchPreflightWarnings = ( inbox: Map>, normalizedCwd: string, warnings: ReadonlyArray, ): void => { if (warnings.length === 0) return; - inbox.set(normalizedCwd, [...(inbox.get(normalizedCwd) ?? []), ...warnings]); + const combined = [...(inbox.get(normalizedCwd) ?? []), ...warnings]; + inbox.set( + normalizedCwd, + combined.length > LAUNCH_PREFLIGHT_INBOX_LIMIT + ? combined.slice(combined.length - LAUNCH_PREFLIGHT_INBOX_LIMIT) + : combined, + ); }; -/** Takes (and clears) pending findings for an already-normalized directory. */ -export const takeLaunchPreflightWarnings = ( +/** + * Reads pending findings for an already-normalized directory without removing + * them. Call {@link clearLaunchPreflightWarnings} only after successful delivery. + */ +export const peekLaunchPreflightWarnings = ( + inbox: Map>, + normalizedCwd: string, +): ReadonlyArray => inbox.get(normalizedCwd) ?? []; + +/** + * Removes pending findings for an already-normalized directory after they were + * delivered. Anything still undelivered must be passed in `keep` so it survives + * to the next session in the same directory. + */ +export const clearLaunchPreflightWarnings = ( inbox: Map>, normalizedCwd: string, -): ReadonlyArray => { - const pending = inbox.get(normalizedCwd); - if (pending === undefined) return []; - inbox.delete(normalizedCwd); - return pending; + keep: ReadonlyArray = [], +): void => { + if (keep.length === 0) { + inbox.delete(normalizedCwd); + return; + } + inbox.set(normalizedCwd, keep); }; diff --git a/apps/server/src/environment/launchPreflightReporter.ts b/apps/server/src/environment/launchPreflightReporter.ts new file mode 100644 index 000000000000..b598afb69df1 --- /dev/null +++ b/apps/server/src/environment/launchPreflightReporter.ts @@ -0,0 +1,48 @@ +/** + * Production reporter for launch-preflight warnings. + * + * The composition root injects this into `ProviderService`, which calls it for + * every warning it surfaces. It appends a `launch.preflight` thread activity + * through the real orchestration engine, so a warning reaches the same client + * subscription a normal thread activity does. It never fails a launch: a + * failed append returns `false` so the caller can keep the pending notice. + * + * @module launchPreflightReporter + */ +import { CommandId, EventId, type ThreadId } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; + +import type { OrchestrationEngineShape } from "../orchestration/Services/OrchestrationEngine.ts"; +import type { LaunchPreflightFindingCode } from "./LaunchPreflight.ts"; + +export interface LaunchPreflightWarningReportInput { + readonly threadId: ThreadId; + readonly cwd: string; + readonly code: LaunchPreflightFindingCode; + readonly message: string; +} + +export const makeLaunchPreflightWarningReporter = + (orchestrationEngine: OrchestrationEngineShape, crypto: Crypto.Crypto) => + (input: LaunchPreflightWarningReportInput): Effect.Effect => + Effect.gen(function* () { + const createdAt = DateTime.formatIso(yield* DateTime.now); + yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId: input.threadId, + activity: { + id: EventId.make(yield* crypto.randomUUIDv4), + tone: "error", + kind: "launch.preflight", + summary: input.message, + payload: { code: input.code, cwd: input.cwd }, + turnId: null, + createdAt, + }, + createdAt, + }); + return true; + }).pipe(Effect.catchCause(() => Effect.succeed(false))); \ No newline at end of file diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 914e0c4066fe..9cca38e02f66 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -91,6 +91,7 @@ import * as ProjectionSnapshotQuery from "../../orchestration/Services/Projectio import * as LaunchPreflight from "../../environment/LaunchPreflight.ts"; import * as LaunchPreflightWarningInboxModule from "../../environment/LaunchPreflightWarningInbox.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; const isModelSelection = Schema.is(ModelSelection); const encodePromptJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -268,21 +269,29 @@ export interface ProviderServiceLiveOptions { /** * Sink for launch-preflight warnings, so they reach the user instead of only * the server log. The composition root wires this to an existing - * user-visible transport (a thread activity append). Failures are swallowed. + * user-visible transport (a thread activity append). It returns whether the + * notice was actually delivered; a failed delivery leaves a pending startup + * notice in the inbox instead of silently discarding it. */ readonly reportLaunchPreflightWarning?: (input: { readonly threadId: ThreadId; readonly cwd: string; readonly code: LaunchPreflight.LaunchPreflightFindingCode; readonly message: string; - }) => Effect.Effect; + }) => Effect.Effect; /** * Overrides the launch-preflight runner. Tests use this to force a warning or - * a blocker without a broken Git install. + * a blocker without a broken Git install. The options carry the selected + * consumer/operation and the exact environment the provider launch resolves + * `git` with. */ readonly launchPreflightRunner?: ( root: string, - options?: { readonly isSharedRoot?: boolean }, + options?: { + readonly isSharedRoot?: boolean; + readonly consumer?: LaunchPreflight.LaunchPreflightConsumer; + readonly gitEnvironment?: NodeJS.ProcessEnv; + }, ) => Effect.Effect; } @@ -538,15 +547,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const launchPreflight = yield* LaunchPreflight.LaunchPreflight; const runLaunchPreflight = options?.launchPreflightRunner ?? launchPreflight.run; - // Shared-inbox intent is an explicit, exact-root opt-in from settings. The - // backend's own cwd is an ordinary provider working directory, so equality - // with it (or with any folder name/breadth/Git presence) never declares a - // shared root. A configured shared root applies regardless of backend cwd. - const configuredSharedRoot: Effect.Effect = serverSettings.getSettings.pipe( - Effect.map((settings) => settings.sharedSessionRoot), - Effect.orElseSucceed(() => undefined), - ); - /** * Runs the bounded launch preflight against the exact cwd a provider process * is about to start in, before the caller's own workspace read. Warnings are @@ -556,6 +556,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const guardProviderLaunch = Effect.fn("ProviderService.guardProviderLaunch")(function* (input: { readonly threadId: ThreadId; readonly cwd: string; + readonly provider?: ProviderDriverKind; + readonly providerInstanceId?: ProviderInstanceId; }) { // Only probe a real directory: spawning Git in a missing path or a plain // file would fail at the OS layer. The caller's own workspace read (and @@ -572,12 +574,39 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( return; } + const settings = yield* serverSettings.getSettings.pipe( + Effect.orElseSucceed(() => undefined), + ); const isSharedRoot = LaunchPreflight.isConfiguredSharedSessionRoot( pathService, input.cwd, - yield* configuredSharedRoot, + settings?.sharedSessionRoot, ); - const result = yield* runLaunchPreflight(input.cwd, { isSharedRoot }).pipe( + // The selected consumer/operation determines `--sparse` applicability. T3 + // launches OpenCode with its default config, where snapshot staging is on + // unless the user disabled it, and OpenCode stages with `git add --sparse` + // even in an ordinary repository. Other consumers only need `--sparse` in a + // sparse checkout, so this is never a global T3 Git requirement. + const consumer: LaunchPreflight.LaunchPreflightConsumer | undefined = + input.provider === undefined + ? undefined + : { driver: input.provider, snapshotsEnabled: true }; + // The provider launch resolves `git` from the same environment the adapter + // inherits. Pass it through so the probe inspects the actual selected Git, + // not an unrelated host default. + const instanceEnvironment = + input.providerInstanceId === undefined + ? undefined + : settings?.providerInstances[input.providerInstanceId]?.environment; + const gitEnvironment = + instanceEnvironment === undefined || instanceEnvironment.length === 0 + ? undefined + : mergeProviderInstanceEnvironment(instanceEnvironment); + const result = yield* runLaunchPreflight(input.cwd, { + isSharedRoot, + ...(consumer !== undefined ? { consumer } : {}), + ...(gitEnvironment !== undefined ? { gitEnvironment } : {}), + }).pipe( Effect.catchCause(() => Effect.succeed({ findings: [] as ReadonlyArray, @@ -586,33 +615,54 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }), ), ); - // Deliver any warnings the startup preflight could only log (no thread - // existed yet) to this first affected session, through the same transport. + + const report = options?.reportLaunchPreflightWarning; + const deliverWarning = (warning: { + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }) => + Effect.gen(function* () { + yield* Effect.logWarning(`launch preflight: ${warning.message}`, { + code: warning.code, + threadId: input.threadId, + cwd: input.cwd, + }); + if (report === undefined) { + // The startup phase already logged this; the log is the delivery. + return true; + } + return yield* report({ + threadId: input.threadId, + cwd: input.cwd, + code: warning.code, + message: warning.message, + }).pipe(Effect.catchCause(() => Effect.succeed(false))); + }); + + // Deliver warnings the startup preflight could only log (no thread existed + // yet) to this first affected session through the same transport. Remove a + // pending notice only after it was actually delivered; anything undelivered + // stays for the next session in the same directory. const inbox = yield* LaunchPreflightWarningInboxModule.LaunchPreflightWarningInbox; - const pendingWarnings = LaunchPreflightWarningInboxModule.takeLaunchPreflightWarnings( - inbox, - LaunchPreflight.normalizePathKey(pathService, input.cwd), - ); + const inboxKey = LaunchPreflight.normalizePathKey(pathService, input.cwd); + const pendingWarnings = + LaunchPreflightWarningInboxModule.peekLaunchPreflightWarnings(inbox, inboxKey); const deliveredCodes = new Set(); - for (const warning of [...pendingWarnings, ...result.warnings]) { + const undelivered: Array = []; + for (const warning of pendingWarnings) { + if (deliveredCodes.has(warning.code)) continue; + const delivered = yield* deliverWarning(warning); + if (delivered) deliveredCodes.add(warning.code); + else undelivered.push(warning); + } + LaunchPreflightWarningInboxModule.clearLaunchPreflightWarnings(inbox, inboxKey, undelivered); + + for (const warning of result.warnings) { if (deliveredCodes.has(warning.code)) continue; deliveredCodes.add(warning.code); - yield* Effect.logWarning(`launch preflight: ${warning.message}`, { - code: warning.code, - threadId: input.threadId, - cwd: input.cwd, - }); - if (options?.reportLaunchPreflightWarning) { - yield* options - .reportLaunchPreflightWarning({ - threadId: input.threadId, - cwd: input.cwd, - code: warning.code, - message: warning.message, - }) - .pipe(Effect.catchCause(() => Effect.void)); - } + yield* deliverWarning(warning); } + const blocker = result.blockers[0]; if (blocker !== undefined) { yield* Effect.logError(`launch preflight blocked provider launch: ${blocker.message}`, { @@ -628,7 +678,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( code: blocker.code, message: blocker.message, }) - .pipe(Effect.catchCause(() => Effect.void)); + .pipe(Effect.catchCause(() => Effect.succeed(false))); } return yield* new ProviderLaunchPreflightBlockedError({ threadId: input.threadId, @@ -1426,6 +1476,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* guardProviderLaunch({ threadId: input.binding.threadId, cwd: persistedCwd, + provider: input.binding.provider, + providerInstanceId: bindingInstanceId, }); } @@ -1645,7 +1697,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.cwd.effective": effectiveCwd ?? "", }); if (effectiveCwd !== undefined) { - yield* guardProviderLaunch({ threadId, cwd: effectiveCwd }); + yield* guardProviderLaunch({ + threadId, + cwd: effectiveCwd, + provider: resolvedProvider, + providerInstanceId: resolvedInstanceId, + }); // Fail fast with an actionable error when the workspace folder is // gone (e.g. moved, deleted, or replaced by a plain file). // Otherwise every adapter surfaces this as a misleading "failed to diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1abff5567fb1..66f771e2dd5a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -4,15 +4,12 @@ import * as NodeHttp from "node:http"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { - CommandId, EnvironmentHttpApi, - EventId, ProviderDriverKind, type RepositoryIdentity, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; -import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -122,6 +119,7 @@ import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; +import { makeLaunchPreflightWarningReporter } from "./environment/launchPreflightReporter.ts"; import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; @@ -282,25 +280,10 @@ const ProviderLayerLive = Layer.unwrap( const orchestrationEngine = yield* OrchestrationEngineService; const crypto = yield* Crypto.Crypto; return makeProviderServiceLive({ - reportLaunchPreflightWarning: ({ threadId, cwd, code, message }) => - Effect.gen(function* () { - const createdAt = DateTime.formatIso(yield* DateTime.now); - yield* orchestrationEngine.dispatch({ - type: "thread.activity.append", - commandId: CommandId.make(yield* crypto.randomUUIDv4), - threadId, - activity: { - id: EventId.make(yield* crypto.randomUUIDv4), - tone: "error", - kind: "launch.preflight", - summary: message, - payload: { code, cwd }, - turnId: null, - createdAt, - }, - createdAt, - }); - }).pipe(Effect.catchCause(() => Effect.void)), + reportLaunchPreflightWarning: makeLaunchPreflightWarningReporter( + orchestrationEngine, + crypto, + ), }); }), ).pipe( From 7e979952650cdf3e61efc7a8de87653e64f287eb Mon Sep 17 00:00:00 2001 From: business account Date: Mon, 28 Sep 2026 03:44:21 -0400 Subject: [PATCH 08/18] test(server): prove the preflight uses the selected provider environment Assert inheritance vs replacement through the real launch construction: the provider instance environment supplies PATH and the sentinel (replacement) while unrelated host variables are inherited, and the selected consumer is passed to the preflight. --- .../providerService.integration.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 326ad3dc5e6a..010b45b07599 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -607,6 +607,58 @@ it.live("a configured shared session root applies independently of the backend c }).pipe(Effect.provide(NodeServices.layer)), ); +it.live("the launch preflight inspects the selected provider environment", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const providerBin = yield* fs.makeTempDirectory(); + const sentinel = "envchk-integration-sentinel"; + const captured: Array<{ + readonly consumer?: LaunchPreflight.LaunchPreflightConsumer | undefined; + readonly gitEnvironment?: NodeJS.ProcessEnv | undefined; + }> = []; + const fixture = yield* makeIntegrationFixture({ + settings: { + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: "codex", + environment: [ + { name: "PATH", value: providerBin }, + { name: "ENVCHK_SENTINEL", value: sentinel }, + ], + }, + }, + }, + launchPreflightRunner: (_root, options) => { + captured.push({ consumer: options?.consumer, gitEnvironment: options?.gitEnvironment }); + return Effect.succeed(findingResult([])); + }, + }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + yield* provider.startSession(ThreadId.make("thread-provider-env"), { + threadId: ThreadId.make("thread-provider-env"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: fixture.cwd, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(fixture.layer)); + + assert.strictEqual(captured.length, 1); + // The selected consumer is passed to the preflight. + assert.strictEqual(captured[0]?.consumer?.driver, "codex"); + // The launch environment is the selected provider environment layered over + // the host: the sentinel and PATH come from the instance (replacement), + // while unrelated host variables are inherited. + const environment = captured[0]?.gitEnvironment; + assert.isDefined(environment); + assert.strictEqual(environment?.ENVCHK_SENTINEL, sentinel); + assert.strictEqual(environment?.PATH, providerBin); + assert.strictEqual(environment?.HOME, process.env.HOME); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.live("pre-thread startup warnings reach the first affected session", () => Effect.gen(function* () { const reported = yield* Ref.make>([]); From af3b9c4c613e8f5aaf14ec1f0446e496f6dde0fc Mon Sep 17 00:00:00 2001 From: business account Date: Mon, 28 Sep 2026 03:47:40 -0400 Subject: [PATCH 09/18] test(server): A3 real dummy provider and missing-executable through the production path The real configured Grok dummy provider launches exactly once while the production reporter delivers the early warning through a real engine subscription; a missing configured executable still fails before model work. --- ...aunchPreflightDelivery.integration.test.ts | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) diff --git a/apps/server/integration/launchPreflightDelivery.integration.test.ts b/apps/server/integration/launchPreflightDelivery.integration.test.ts index b1b01d6eace7..65d5fe265036 100644 --- a/apps/server/integration/launchPreflightDelivery.integration.test.ts +++ b/apps/server/integration/launchPreflightDelivery.integration.test.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off - a real temporary workspace exercises the launch path. import { CommandId, + GrokSettings, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -11,6 +12,10 @@ import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, assert } from "@effect/vitest"; import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -18,6 +23,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as ServerConfig from "../src/config.ts"; @@ -36,20 +42,25 @@ import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; import * as ProviderSessionRuntime from "../src/persistence/ProviderSessionRuntime.ts"; import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityResolver.ts"; import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; +import { makeGrokAdapter } from "../src/provider/Layers/GrokAdapter.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers, } from "../src/provider/Layers/ProviderEventLoggers.ts"; import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; +import { ProviderAdapterProcessError } from "../src/provider/Errors.ts"; import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; import { ProviderService } from "../src/provider/Services/ProviderService.ts"; import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts"; import { ServerSettingsService } from "../src/serverSettings.ts"; +import { execScriptSource, writeFakeCli } from "../src/testUtils/fakeCli.ts"; import { AnalyticsService } from "../src/telemetry/AnalyticsService.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; import { makeTestProviderAdapterHarness } from "./TestProviderAdapter.integration.ts"; const codexInstanceId = ProviderInstanceId.make("codex"); +const grokInstanceId = ProviderInstanceId.make("grok"); +const decodeGrokSettings = Schema.decodeSync(GrokSettings); const findingResult = ( findings: ReadonlyArray, @@ -305,4 +316,214 @@ it.live( import("node:fs/promises").then((fs) => fs.rm(cwd, { recursive: true, force: true })), ); }).pipe(Effect.provide(NodeServices.layer)), +); + +interface DeliveryProviderOptions { + readonly cwd: string; + readonly registry: ReturnType; + readonly inbox: Map< + string, + ReadonlyArray<{ + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }> + >; + readonly reportWarning: (input: { + readonly threadId: ThreadId; + readonly cwd: string; + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }) => Effect.Effect; +} + +const deliveryProviderLayer = (options: DeliveryProviderOptions) => { + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntime.layer), + ); + const shared = Layer.mergeAll( + directoryLayer, + Layer.succeed(ProviderAdapterRegistry, options.registry), + Layer.succeed(LaunchPreflightWarningInbox, options.inbox), + ServerConfig.layerTest(options.cwd, options.cwd).pipe(Layer.provide(NodeServices.layer)), + ServerSettingsService.layerTest({ + ...DEFAULT_SERVER_SETTINGS, + sharedSessionRoot: options.cwd, + }), + AnalyticsService.layerTest, + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + ).pipe(Layer.provide(SqlitePersistenceMemory)); + return makeProviderServiceLive({ + reportLaunchPreflightWarning: options.reportWarning, + launchPreflightRunner: () => Effect.succeed(findingResult([])), + }).pipe(Layer.provide(NodeServices.layer), Layer.provideMerge(shared)); +}; + +it.live( + "A3: a real configured dummy provider starts once and the production reporter delivers through the subscription", + () => + Effect.gen(function* () { + const cwd = yield* makeWorkspaceDirectory; + const dir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-a3-grok-")), + ); + const argvLogPath = NodePath.join(dir, "argv.log"); + const mockAgentPath = NodePath.join( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "../scripts/acp-mock-agent.ts", + ); + const wrapperPath = writeFakeCli({ + directory: dir, + name: "fake-grok-a3", + source: execScriptSource({ scriptPath: mockAgentPath, argvLogPath }), + }); + + const engineContext = yield* Layer.build(orchestrationEngineLayer); + const engine = Context.get(engineContext, OrchestrationEngineService); + const crypto = yield* Crypto.Crypto; + const reportWarning = makeLaunchPreflightWarningReporter(engine, crypto); + + const grokAdapter = yield* makeGrokAdapter( + decodeGrokSettings({ binaryPath: wrapperPath }), + ).pipe( + Effect.provide(ServerConfig.layerTest(cwd, cwd)), + Effect.provide(NodeServices.layer), + Effect.orDie, + ); + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("grok")]: grokAdapter, + }); + const message = "The shared session root is itself a Git repository."; + const pathService = yield* Path.Path; + const inbox = new Map< + string, + ReadonlyArray<{ code: LaunchPreflight.LaunchPreflightFindingCode; message: string }> + >(); + inbox.set(LaunchPreflight.normalizePathKey(pathService, cwd), [ + { code: "shared-root-git", message }, + ]); + + const providerLayer = deliveryProviderLayer({ + cwd, + registry, + inbox, + reportWarning, + }); + + const projectId = ProjectId.make("a3-grok-project"); + const threadId = ThreadId.make("a3-grok-thread"); + const received = yield* Ref.make>([]); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const events = yield* engine.subscribeDomainEvents; + yield* events.pipe( + Stream.tap((event) => Ref.update(received, (current) => [...current, event])), + Stream.runDrain, + Effect.forkScoped, + ); + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("a3-grok-project"), + projectId, + title: "A3", + workspaceRoot: cwd, + createdAt: "2026-09-28T00:00:00.000Z", + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("a3-grok-thread"), + threadId, + projectId, + title: "A3", + modelSelection: { instanceId: grokInstanceId, model: "grok-4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-09-28T00:00:00.000Z", + }); + const session = yield* provider.startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("grok"), + providerInstanceId: grokInstanceId, + cwd, + runtimeMode: "full-access", + }); + assert.equal(session.provider, "grok"); + yield* Effect.sleep("50 millis"); + }).pipe(Effect.provide(providerLayer)); + + const collected = yield* Ref.get(received); + const warningActivity = collected.find( + (event) => + event.type === "thread.activity-appended" && + event.payload.activity.kind === "launch.preflight", + ); + assert.isDefined(warningActivity); + assert.strictEqual( + warningActivity?.type === "thread.activity-appended" + ? warningActivity.payload.activity.summary + : undefined, + message, + ); + + const invocations = yield* Effect.promise(() => + NodeFSP.readFile(argvLogPath, "utf8").then( + (raw) => raw.split("\n").filter((line) => line.trim().length > 0).length, + () => 0, + ), + ); + assert.equal(invocations, 1); + assert.strictEqual(inbox.size, 0); + + yield* Effect.promise(() => NodeFSP.rm(dir, { recursive: true, force: true })); + yield* Effect.promise(() => NodeFSP.rm(cwd, { recursive: true, force: true })); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.live("A3: a missing configured executable fails before model work", () => + Effect.gen(function* () { + const cwd = yield* makeWorkspaceDirectory; + const pathService = yield* Path.Path; + const missing = pathService.join(cwd, "not-a-real-grok"); + const engineContext = yield* Layer.build(orchestrationEngineLayer); + const engine = Context.get(engineContext, OrchestrationEngineService); + const crypto = yield* Crypto.Crypto; + const reportWarning = makeLaunchPreflightWarningReporter(engine, crypto); + const missingGrokAdapter = yield* makeGrokAdapter( + decodeGrokSettings({ binaryPath: missing }), + ).pipe( + Effect.provide(ServerConfig.layerTest(cwd, cwd)), + Effect.provide(NodeServices.layer), + Effect.orDie, + ); + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("grok")]: missingGrokAdapter, + }); + const inbox = new Map< + string, + ReadonlyArray<{ code: LaunchPreflight.LaunchPreflightFindingCode; message: string }> + >(); + const providerLayer = deliveryProviderLayer({ cwd, registry, inbox, reportWarning }); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = ThreadId.make("a3-missing-exec-thread"); + const error = yield* provider + .startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("grok"), + providerInstanceId: grokInstanceId, + cwd, + runtimeMode: "full-access", + }) + .pipe(Effect.flip); + assert.instanceOf(error, ProviderAdapterProcessError); + assert.include((error as ProviderAdapterProcessError).message, "grok"); + }).pipe(Effect.provide(providerLayer)); + + yield* Effect.promise(() => + NodeFSP.rm(cwd, { recursive: true, force: true }), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); \ No newline at end of file From 1a0e3d263ca004594791e927fc532ef273c93c7e Mon Sep 17 00:00:00 2001 From: business account Date: Mon, 28 Sep 2026 07:00:41 -0400 Subject: [PATCH 10/18] fix(server): warn on incomplete launch-preflight checks and align Git identity R1: the bounded initial cwd stat, candidate metadata checks and the capability probe now surface one actionable warning when they fail or time out instead of reading as clean/absent. Genuine absence and missing optional files stay silent; total-budget expiry retains prior findings and is never reported clean. The initial cwd check moves into the single preflight implementation so its failure reaches the same production reporter. R2: canonicalize the configured root, top level and resolved common directory consistently so `/var` <-> `/private/var` alias spellings of the same physical root compare equal and give the local-metadata guidance. A canonicalization failure stays unknown instead of becoming affirmative external-repository guidance. Relative common-dir resolution against the invocation cwd and the external linked-worktree protection are retained. --- .../src/environment/LaunchPreflight.test.ts | 168 ++++++++++++++- .../server/src/environment/LaunchPreflight.ts | 193 +++++++++++++++--- 2 files changed, 336 insertions(+), 25 deletions(-) diff --git a/apps/server/src/environment/LaunchPreflight.test.ts b/apps/server/src/environment/LaunchPreflight.test.ts index 4059eae11423..2936ca4a7dab 100644 --- a/apps/server/src/environment/LaunchPreflight.test.ts +++ b/apps/server/src/environment/LaunchPreflight.test.ts @@ -58,6 +58,7 @@ const input = (options: { git: options.git ?? gitProbe(), files: { exists: () => Effect.succeed(false), + stat: () => Effect.succeed({ type: "directory" }), realPath: (target) => Effect.succeed(target), readFirstBytes: () => Effect.succeed(16), ...options.files, @@ -352,7 +353,7 @@ it.effect("flags a slow root read and bounds it instead of holding the launch", }), ); -it.effect("resolves within the total budget even when the first probe hangs", () => +it.effect("reports an incomplete preflight instead of clean when checks hang", () => Effect.gen(function* () { const fiber = yield* run( input({ @@ -366,10 +367,175 @@ it.effect("resolves within the total budget even when the first probe hangs", () yield* TestClock.adjust("5 seconds"); const result = yield* Fiber.join(fiber); + // A hung metadata/existence check is not absence: the preflight is not + // clean, it returns its bounded findings, and it still completes. + assert.isTrue(result.findings.length > 0); + assert.isTrue(result.findings.every((finding) => finding.severity === "warning")); + assert.isTrue(codes(result).includes("root-read-slow")); + }), +); + +// --- R1 regression: incomplete checks warn, genuine absence stays quiet ------ + +it.effect("R1: a hung initial cwd stat warns and returns within its budget", () => + Effect.gen(function* () { + const fiber = yield* run(input({ files: { stat: () => Effect.never } })).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust("500 millis"); + const result = yield* Fiber.join(fiber); + + assert.deepStrictEqual(codes(result), ["root-read-slow"]); + assert.deepStrictEqual(severities(result), ["warning"]); + assert.include(result.warnings[0]?.message ?? "", "did not finish in time"); + }), +); + +it.effect("R1: a failed initial cwd stat warns instead of silently skipping", () => + Effect.gen(function* () { + const result = yield* run( + input({ files: { stat: () => Effect.fail(probeError("failed", "denied")) } }), + ); + + assert.deepStrictEqual(codes(result), ["root-read-failed"]); + assert.deepStrictEqual(severities(result), ["warning"]); + }), +); + +it.effect("R1: a hung candidate metadata check warns instead of reading as absent", () => + Effect.gen(function* () { + const fiber = yield* run(input({ files: { exists: () => Effect.never } })).pipe( + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* TestClock.adjust("1500 millis"); + const result = yield* Fiber.join(fiber); + + assert.isTrue(codes(result).includes("root-read-slow")); + assert.isTrue(result.findings.length > 0); + }), +); + +it.effect( + "R1: a required capability probe that hangs after healthy identity warns as incomplete", + () => + Effect.gen(function* () { + const fiber = yield* run( + input({ + root: "/repo", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + probeSparseAdd: () => Effect.never, + }), + consumer: { driver: "opencode", snapshotsEnabled: true }, + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + }), + ).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust("1500 millis"); + const result = yield* Fiber.join(fiber); + + assert.deepStrictEqual(codes(result), ["git-probe-timed-out"]); + }), +); + +it.effect("R1: a required capability probe failure warns as incomplete, never unsupported", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + probeSparseAdd: () => Effect.fail(probeError("failed")), + }), + consumer: { driver: "opencode", snapshotsEnabled: true }, + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + }), + ); + + assert.deepStrictEqual(codes(result), ["git-probe-failed"]); + assert.notInclude(codes(result), "git-sparse-add-unsupported"); + }), +); + +it.effect("R1: genuinely absent optional files stay quiet", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + }), + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + }), + ); + assert.deepStrictEqual(result.findings, []); }), ); +// --- R2 regression: equivalent physical paths share identity handling -------- + +it.effect("R2: alias spellings of the same physical root give local-metadata guidance", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/var/folders/x/shared", + isSharedRoot: true, + git: gitProbe({ + resolveIdentity: () => + Effect.succeed( + identity({ + state: "ok", + topLevel: "/private/var/folders/x/shared", + commonDir: "/var/folders/x/shared/.git", + }), + ), + }), + files: { + exists: (target) => Effect.succeed(target.endsWith(".git")), + realPath: (target) => + Effect.succeed(target.startsWith("/var/") ? `/private${target}` : target), + }, + }), + ); + + assert.deepStrictEqual(codes(result), ["shared-root-git"]); + const message = result.warnings[0]?.message ?? ""; + assert.include(message, "Move or retire"); + assert.notInclude(message, "different repository"); + }), +); + +it.effect("R2: failed canonicalization does not assert an external repository", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/Documents", + isSharedRoot: true, + git: gitProbe({ + resolveIdentity: () => + Effect.succeed( + identity({ state: "ok", topLevel: "/Documents", commonDir: "/Documents/.git" }), + ), + }), + files: { + exists: (target) => Effect.succeed(target === "/Documents/.git"), + realPath: () => Effect.succeed(null), + }, + }), + ); + + assert.deepStrictEqual(codes(result), ["shared-root-git"]); + const message = result.warnings[0]?.message ?? ""; + assert.notInclude(message, "different repository"); + assert.notInclude(message, "Move or retire"); + assert.include(message, "could not be fully resolved"); + }), +); + it.effect("wires the real service probes and passes a healthy root", () => Effect.gen(function* () { const directory = yield* Effect.promise(() => diff --git a/apps/server/src/environment/LaunchPreflight.ts b/apps/server/src/environment/LaunchPreflight.ts index 19ec6e19b19e..546a9e57c167 100644 --- a/apps/server/src/environment/LaunchPreflight.ts +++ b/apps/server/src/environment/LaunchPreflight.ts @@ -134,9 +134,8 @@ export interface LaunchPreflightConsumer { } /** Whether the selected consumer/operation stages with `git add --sparse`. */ -export const consumerUsesSparseAdd = ( - consumer: LaunchPreflightConsumer | undefined, -): boolean => consumer !== undefined && consumer.driver === "opencode" && consumer.snapshotsEnabled; +export const consumerUsesSparseAdd = (consumer: LaunchPreflightConsumer | undefined): boolean => + consumer !== undefined && consumer.driver === "opencode" && consumer.snapshotsEnabled; export interface LaunchPreflightRepoIdentity { readonly state: "ok" | "not-a-repository" | "failed"; @@ -147,8 +146,22 @@ export interface LaunchPreflightRepoIdentity { readonly detail: string; } +export type LaunchPreflightFsEntryType = "directory" | "file" | "other" | "missing"; + +export interface LaunchPreflightFsEntry { + readonly type: LaunchPreflightFsEntryType; +} + export interface LaunchPreflightFileProbe { readonly exists: (target: string) => Effect.Effect; + /** + * Bounded-type stat. Genuine absence is the observable `"missing"` state, so + * callers can keep it distinct from a denied/stalled metadata read. Any other + * stat error is a `LaunchPreflightProbeError`. + */ + readonly stat: ( + target: string, + ) => Effect.Effect; /** * Canonicalizes a path (resolving symlinks) so exact root identity compares * correctly across `/var` ↔ `/private/var` style aliases. Returns null when it @@ -248,11 +261,21 @@ export const runLaunchPreflight = ( const add = (finding: LaunchPreflightFinding) => Ref.update(collected, (current) => [...current, finding]); - const existsBounded = (target: string) => + type ExistsOutcome = + | { readonly state: "present" } + | { readonly state: "absent" } + | { readonly state: "unknown"; readonly reason: "timeout" | "failed" }; + + const existsOutcome = (target: string): Effect.Effect => input.files.exists(target).pipe( + Effect.map((present): ExistsOutcome => + present ? { state: "present" } : { state: "absent" }, + ), + Effect.catch(() => Effect.succeed({ state: "unknown", reason: "failed" } as const)), Effect.timeoutOption(NARROW_FS_TIMEOUT), - Effect.map(Option.getOrElse(() => false)), - Effect.orElseSucceed(() => false), + Effect.map( + Option.getOrElse((): ExistsOutcome => ({ state: "unknown", reason: "timeout" })), + ), ); const realPathBounded = (target: string) => @@ -262,14 +285,67 @@ export const runLaunchPreflight = ( Effect.orElseSucceed(() => null), ); + // One actionable warning shape for an incomplete filesystem metadata check. + // Genuine absence never reaches here: only timeout/permission/other failure. + const rootMetadataWarning = ( + target: string, + reason: "timeout" | "failed", + ): LaunchPreflightFinding => + reason === "timeout" + ? { + code: "root-read-slow", + severity: "warning", + message: + `Inspecting ${target} under the session root did not finish in time. The filesystem may ` + + 'be stalled or cloud-offloaded; use "Keep Downloaded" on the folder before starting ' + + "sessions.", + } + : { + code: "root-read-failed", + severity: "warning", + message: + `Could not inspect ${target} under the session root. The filesystem denied or failed ` + + "the metadata read. Sessions may fail to read the workspace.", + }; + const explore = Effect.gen(function* () { - // Canonicalize the configured root once so identity comparison is not - // confused by macOS `/var` ↔ `/private/var` aliases. - const canonicalRoot = (yield* realPathBounded(input.root)) ?? input.root; + // Bound the initial session-cwd check here so a stalled, denied or + // cloud-offloaded path produces one actionable warning instead of being + // silently treated as clean. Genuine absence or a plain file stays + // distinct: the caller's own workspace read (and + // `ProviderWorkspaceMissingError`) reports that. + const rootStatOutcome = yield* input.files.stat(input.root).pipe( + Effect.map((entry) => ({ _tag: "ok" as const, entry })), + Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), + Effect.timeoutOption(NARROW_FS_TIMEOUT), + Effect.map(Option.getOrElse(() => ({ _tag: "timeout" as const }))), + ); + if (rootStatOutcome._tag === "timeout") { + yield* add(rootMetadataWarning(input.root, "timeout")); + return; + } + if (rootStatOutcome._tag === "error") { + yield* add(rootMetadataWarning(input.root, "failed")); + return; + } + if (rootStatOutcome.entry.type !== "directory") { + return; + } + + // Canonicalize the configured root so identity comparison is not confused + // by macOS `/var` ↔ `/private/var` aliases. `null` means canonicalization + // failed: identity must then stay unknown, never affirmative external. + const canonicalRoot = yield* realPathBounded(input.root); // Only a real `.git` entry counts as repository evidence. A retired marker - // such as `.git.macfix-m1-retired` is a different path and is ignored. - const rootGitMarker = yield* existsBounded(path.join(input.root, ".git")); + // such as `.git.macfix-m1-retired` is a different path and is ignored. An + // incomplete metadata check is not absence: warn about it. + const rootGitMarkerPath = path.join(input.root, ".git"); + const rootGitMarkerOutcome = yield* existsOutcome(rootGitMarkerPath); + if (rootGitMarkerOutcome.state === "unknown") { + yield* add(rootMetadataWarning(rootGitMarkerPath, rootGitMarkerOutcome.reason)); + } + const rootGitMarker = rootGitMarkerOutcome.state === "present"; const gitOutcome = yield* input.git.version(input.root, input.gitEnvironment).pipe( Effect.map((version) => ({ _tag: "ok" as const, version })), @@ -296,11 +372,11 @@ export const runLaunchPreflight = ( const identityOutcome = yield* input.git .resolveIdentity(input.root, input.gitEnvironment) .pipe( - Effect.map((identity) => ({ _tag: "ok" as const, identity })), - Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), - Effect.timeoutOption(GIT_PROBE_TIMEOUT), - Effect.map(Option.getOrElse(() => ({ _tag: "timeout" as const }))), - ); + Effect.map((identity) => ({ _tag: "ok" as const, identity })), + Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), + Effect.timeoutOption(GIT_PROBE_TIMEOUT), + Effect.map(Option.getOrElse(() => ({ _tag: "timeout" as const }))), + ); if (identityOutcome._tag === "ok") { const identity = identityOutcome.identity; @@ -310,11 +386,27 @@ export const runLaunchPreflight = ( // top level. `topLevel === root` is what makes a shared root an // umbrella; a nested repository selected as the session cwd has a // different top level and stays an ordinary repository session. - const rootIsRepository = samePath(path, identity.topLevel, canonicalRoot); + // Compare canonical forms when both resolve; otherwise fall back + // to the raw spellings so a canonicalization failure cannot by + // itself invent an umbrella. + const topLevelCanonical = + identity.topLevel !== null ? yield* realPathBounded(identity.topLevel) : null; + const rootIsRepository = + identity.topLevel !== null && canonicalRoot !== null && topLevelCanonical !== null + ? samePath(path, topLevelCanonical, canonicalRoot) + : samePath(path, identity.topLevel, input.root); effectiveRootRepository = rootIsRepository || rootGitMarker; inGitWorkTree = true; if (isSharedRoot && rootIsRepository) { - const commonUnderRoot = isInside(path, identity.commonDir, canonicalRoot); + // Canonicalize the (already invocation-cwd-resolved) common dir + // before comparing, so `/var` ↔ `/private/var` alias spellings of + // the same physical root give the local-metadata guidance. + const commonDirCanonical = + identity.commonDir !== null ? yield* realPathBounded(identity.commonDir) : null; + const commonUnderRoot = + canonicalRoot !== null && commonDirCanonical !== null + ? isInside(path, commonDirCanonical, canonicalRoot) + : null; yield* add({ code: "shared-root-git", severity: "warning", @@ -322,9 +414,11 @@ export const runLaunchPreflight = ( `The shared session root ${input.root} is itself a Git repository ` + `(top-level ${identity.topLevel ?? input.root}). Projects beneath it would share that ` + "repository." + - (commonUnderRoot + (commonUnderRoot === true ? ` Move or retire ${path.join(input.root, ".git")} if that is unintended.` - : " Its Git identity points at a different repository; no change to this root is implied."), + : commonUnderRoot === false + ? " Its Git identity points at a different repository; no change to this root is implied." + : " Its Git identity could not be fully resolved, so T3 Code cannot confirm whether this root's own repository metadata is in use; no change to this root is implied."), }); } break; @@ -392,6 +486,27 @@ export const runLaunchPreflight = ( "checkpoint may record excluded files as deleted. Install a newer Git to keep sparse " + "checkpoints accurate; the session can still start.", }); + } else if (requiredByConsumer && sparseOutcome._tag === "timeout") { + // The capability is relevant to the selected launch and could not be + // confirmed. Report it as incomplete rather than silently clean; never + // mislabel an unknown capability as unsupported, and never block. + yield* add({ + code: "git-probe-timed-out", + severity: "warning", + message: + "The Git `add --sparse` capability probe did not finish in time, so the selected OpenCode " + + "launch could not be confirmed to support `git add --sparse`. Snapshots may be incomplete; " + + "the session can still start. Check the Git install and the session-root filesystem.", + }); + } else if (requiredByConsumer && sparseOutcome._tag === "error") { + yield* add({ + code: "git-probe-failed", + severity: "warning", + message: + `The Git \`add --sparse\` capability probe failed (${sparseOutcome.error.detail}), so the ` + + "selected OpenCode launch could not be confirmed to support `git add --sparse`. Snapshots " + + "may be incomplete; the session can still start.", + }); } } else if (gitOutcome._tag === "timeout" || gitOutcome.error.reason === "timeout") { yield* add({ @@ -423,10 +538,18 @@ export const runLaunchPreflight = ( let readTarget: string | undefined; for (const relativePath of READ_PROBE_CANDIDATES) { const candidate = path.join(input.root, relativePath); - if (yield* existsBounded(candidate)) { + const outcome = yield* existsOutcome(candidate); + if (outcome.state === "present") { readTarget = candidate; break; } + if (outcome.state === "unknown") { + // A denied or stalled metadata read is not absence. Report it once and + // stop scanning further optional candidates; missing optional files + // stay silent. + yield* add(rootMetadataWarning(candidate, outcome.reason)); + break; + } } if (readTarget !== undefined) { @@ -460,9 +583,18 @@ export const runLaunchPreflight = ( }); // The whole exploration shares one wall-clock budget. Findings already - // collected survive a mid-probe timeout; a probe that cannot be interrupted - // is still individually bounded above. - yield* explore.pipe(Effect.timeoutOption(TOTAL_PROBE_BUDGET)); + // collected survive a mid-probe timeout, so an incomplete preflight is never + // reported clean; each probe is still individually bounded above. + const completed = Option.isSome(yield* explore.pipe(Effect.timeoutOption(TOTAL_PROBE_BUDGET))); + if (!completed) { + yield* add({ + code: "git-probe-timed-out", + severity: "warning", + message: + "The launch preflight did not finish within its budget, so its checks are incomplete. " + + "The session can still start; check the Git install and the session-root filesystem.", + }); + } const findings = yield* Ref.get(collected); return { @@ -622,6 +754,19 @@ export const make = Effect.gen(function* () { const files: LaunchPreflightFileProbe = { exists: (target) => fileSystem.exists(target).pipe(Effect.orElseSucceed(() => false)), + stat: (target) => + fileSystem.stat(target).pipe( + Effect.map((entry): LaunchPreflightFsEntry => ({ + type: entry.type === "Directory" ? "directory" : entry.type === "File" ? "file" : "other", + })), + Effect.catchIf( + (cause) => cause.reason._tag === "NotFound", + () => Effect.succeed({ type: "missing" } satisfies LaunchPreflightFsEntry), + ), + Effect.mapError( + (cause) => new LaunchPreflightProbeError({ reason: "failed", detail: String(cause) }), + ), + ), realPath: (target) => fileSystem.realPath(target).pipe(Effect.orElseSucceed(() => null)), readFirstBytes: (target, maxBytes) => fileSystem.stream(target, { bytesToRead: maxBytes }).pipe( From 40b73dd89548e526bb65e12ab3872ae47f38394a Mon Sep 17 00:00:00 2001 From: business account Date: Mon, 28 Sep 2026 07:00:47 -0400 Subject: [PATCH 11/18] fix(server): derive the launch preflight consumer from production settings The consumer/operation is now resolved from ServerSettings.providerInstances instead of assuming `snapshotsEnabled: true` for every launch. For the selected OpenCode instance the effective OPENCODE_CONFIG_CONTENT snapshot setting decides whether `git add --sparse` is a provider requirement (an explicit `snapshot:false` suppresses it), and an instance pointed at an external OpenCode server no longer pretends the local Git is that server's Git. Non-OpenCode launches keep T3's own sparse-checkout fallback. Regression coverage drives the real settings/instance construction: consumer selection through the real ProviderService, and a controlled sparse-less Git detected through the real preflight runner on an ordinary repository with default local OpenCode snapshots. --- ...aunchPreflightDelivery.integration.test.ts | 272 +++++++++++++++++- .../src/provider/Layers/ProviderService.ts | 51 ++-- .../src/provider/launchPreflightConsumer.ts | 90 ++++++ 3 files changed, 381 insertions(+), 32 deletions(-) create mode 100644 apps/server/src/provider/launchPreflightConsumer.ts diff --git a/apps/server/integration/launchPreflightDelivery.integration.test.ts b/apps/server/integration/launchPreflightDelivery.integration.test.ts index 65d5fe265036..1556409d02c4 100644 --- a/apps/server/integration/launchPreflightDelivery.integration.test.ts +++ b/apps/server/integration/launchPreflightDelivery.integration.test.ts @@ -7,11 +7,13 @@ import { ProviderInstanceId, ThreadId, type OrchestrationEvent, + type ProviderInstanceConfig, } from "@t3tools/contracts"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, assert } from "@effect/vitest"; import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -522,8 +524,270 @@ it.live("A3: a missing configured executable fails before model work", () => assert.include((error as ProviderAdapterProcessError).message, "grok"); }).pipe(Effect.provide(providerLayer)); - yield* Effect.promise(() => - NodeFSP.rm(cwd, { recursive: true, force: true }), - ); + yield* Effect.promise(() => NodeFSP.rm(cwd, { recursive: true, force: true })); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); \ No newline at end of file +); +// --- R3: production settings/instance construction selects the consumer ------------------------ + +interface CapturedLaunch { + consumer?: LaunchPreflight.LaunchPreflightConsumer | undefined; + gitEnvironment?: NodeJS.ProcessEnv | undefined; +} + +const captureConsumerLayer = (options: { + readonly cwd: string; + readonly registry: ReturnType; + readonly providerInstances: Readonly>; + readonly captured: CapturedLaunch; +}) => { + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntime.layer), + ); + const inbox = new Map< + string, + ReadonlyArray<{ + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }> + >(); + const shared = Layer.mergeAll( + directoryLayer, + Layer.succeed(ProviderAdapterRegistry, options.registry), + Layer.succeed(LaunchPreflightWarningInbox, inbox), + ServerConfig.layerTest(options.cwd, options.cwd).pipe(Layer.provide(NodeServices.layer)), + ServerSettingsService.layerTest({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: options.providerInstances, + }), + AnalyticsService.layerTest, + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + ).pipe(Layer.provide(SqlitePersistenceMemory)); + return makeProviderServiceLive({ + reportLaunchPreflightWarning: () => Effect.succeed(true), + launchPreflightRunner: (_root, runnerOptions) => { + options.captured.consumer = runnerOptions?.consumer; + options.captured.gitEnvironment = runnerOptions?.gitEnvironment; + return Effect.succeed(findingResult([])); + }, + }).pipe(Layer.provide(NodeServices.layer), Layer.provideMerge(shared)); +}; + +const openCodeInstance = (overrides: { + readonly config?: unknown; + readonly environment?: ProviderInstanceConfig["environment"]; +}): ProviderInstanceConfig => ({ + driver: ProviderDriverKind.make("opencode"), + enabled: true, + ...(overrides.config !== undefined ? { config: overrides.config } : {}), + ...(overrides.environment !== undefined ? { environment: overrides.environment } : {}), +}); + +it.live( + "R3: production settings select the launch consumer (local OpenCode, snapshot:false, external, fallback)", + () => + Effect.gen(function* () { + const cwd = yield* makeWorkspaceDirectory; + const sentinel = "envchk-e6-provider-sentinel"; + const codexHarness = yield* makeTestProviderAdapterHarness(); + const opencodeHarness = yield* makeTestProviderAdapterHarness({ + provider: ProviderDriverKind.make("opencode"), + }); + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: codexHarness.adapter, + [ProviderDriverKind.make("opencode")]: opencodeHarness.adapter, + }); + + const runCase = (input: { + readonly threadId: string; + readonly provider: ProviderDriverKind; + readonly providerInstanceId: ProviderInstanceId; + readonly providerInstances: Readonly>; + }) => + Effect.gen(function* () { + const captured: CapturedLaunch = {}; + const layer = captureConsumerLayer({ + cwd, + registry, + providerInstances: input.providerInstances, + captured, + }); + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = ThreadId.make(input.threadId); + yield* provider.startSession(threadId, { + threadId, + provider: input.provider, + providerInstanceId: input.providerInstanceId, + cwd, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(layer)); + return captured; + }); + + // 1. Default local OpenCode: snapshots are on and the selected provider + // environment is threaded through unchanged. + const local = yield* runCase({ + threadId: "r3-local", + provider: ProviderDriverKind.make("opencode"), + providerInstanceId: ProviderInstanceId.make("opencode"), + providerInstances: { + opencode: openCodeInstance({ + environment: [{ name: "ENVCHK_SENTINEL", value: sentinel, sensitive: false }], + }), + }, + }); + assert.deepStrictEqual(local.consumer, { driver: "opencode", snapshotsEnabled: true }); + assert.strictEqual(local.gitEnvironment?.ENVCHK_SENTINEL, sentinel); + + // 2. Effective `snapshot:false` in the selected instance's environment + // suppresses the provider-specific requirement. + const disabled = yield* runCase({ + threadId: "r3-disabled", + provider: ProviderDriverKind.make("opencode"), + providerInstanceId: ProviderInstanceId.make("opencode"), + providerInstances: { + opencode: openCodeInstance({ + environment: [ + { name: "OPENCODE_CONFIG_CONTENT", value: '{"snapshot":false}', sensitive: false }, + { name: "ENVCHK_SENTINEL", value: sentinel, sensitive: false }, + ], + }), + }, + }); + assert.deepStrictEqual(disabled.consumer, { driver: "opencode", snapshotsEnabled: false }); + + // 3. External OpenCode server: the local Git is not that process's Git. + const external = yield* runCase({ + threadId: "r3-external", + provider: ProviderDriverKind.make("opencode"), + providerInstanceId: ProviderInstanceId.make("opencode"), + providerInstances: { + opencode: openCodeInstance({ config: { serverUrl: "http://127.0.0.1:4096" } }), + }, + }); + assert.deepStrictEqual(external.consumer, { driver: "opencode", snapshotsEnabled: false }); + + // 4. Non-OpenCode fallback: T3's own Git path only needs `--sparse` in a + // sparse checkout, which the repository probe decides. + const fallback = yield* runCase({ + threadId: "r3-codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + providerInstances: { + codex: { driver: ProviderDriverKind.make("codex"), enabled: true, config: {} }, + }, + }); + assert.deepStrictEqual(fallback.consumer, { driver: "codex", snapshotsEnabled: true }); + + yield* Effect.promise(() => NodeFSP.rm(cwd, { recursive: true, force: true })); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +const writeSparseLessGit = (binDir: string, realGit: string): void => { + NodeFS.writeFileSync( + NodePath.join(binDir, "git"), + [ + "#!/bin/sh", + 'if [ "$1" = "add" ] && [ "$2" = "-h" ]; then', + ' echo "usage: git add [options] [--] ..."', + ' echo " -n, --dry-run dry run"', + " exit 0", + "fi", + `exec "${realGit}" "$@"`, + "", + ].join("\n"), + { mode: 0o755 }, + ); +}; + +it.live( + "R3: a local OpenCode launch with default snapshots detects a controlled Git missing --sparse", + () => + Effect.gen(function* () { + const base = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-r3-sparse-")), + ); + const repo = NodePath.join(base, "repo"); + const providerBin = NodePath.join(base, "provider-bin"); + yield* Effect.promise(() => NodeFSP.mkdir(repo, { recursive: true })); + yield* Effect.promise(() => NodeFSP.mkdir(providerBin, { recursive: true })); + const realGit = NodeChildProcess.execFileSync("which", ["git"]).toString().trim(); + writeSparseLessGit(providerBin, realGit); + const git = (args: ReadonlyArray) => + Effect.promise(async () => { + NodeChildProcess.execFileSync(realGit, args, { cwd: repo }); + }); + yield* git(["init", "-q"]); + yield* git(["config", "user.name", "Test"]); + yield* git(["config", "user.email", "test@test.com"]); + yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(repo, "file.txt"), "hello\n")); + yield* git(["add", "."]); + yield* git(["commit", "-q", "-m", "initial"]); + + const harness = yield* makeTestProviderAdapterHarness({ + provider: ProviderDriverKind.make("opencode"), + }); + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("opencode")]: harness.adapter, + }); + const reported = yield* Ref.make< + ReadonlyArray<{ readonly code: string; readonly message: string }> + >([]); + const inbox = new Map< + string, + ReadonlyArray<{ + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }> + >(); + const shared = Layer.mergeAll( + ProviderSessionDirectoryLive.pipe(Layer.provide(ProviderSessionRuntime.layer)), + Layer.succeed(ProviderAdapterRegistry, registry), + Layer.succeed(LaunchPreflightWarningInbox, inbox), + ServerConfig.layerTest(repo, repo).pipe(Layer.provide(NodeServices.layer)), + ServerSettingsService.layerTest({ + ...DEFAULT_SERVER_SETTINGS, + // No OPENCODE_CONFIG_CONTENT: OpenCode's default snapshots are on. The + // selected instance environment resolves the controlled sparse-less Git. + providerInstances: { + [ProviderInstanceId.make("opencode")]: { + driver: ProviderDriverKind.make("opencode"), + enabled: true, + environment: [{ name: "PATH", value: providerBin, sensitive: false }], + }, + }, + }), + AnalyticsService.layerTest, + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + ).pipe(Layer.provide(SqlitePersistenceMemory)); + // The real preflight runner runs, so the production-derived consumer and the + // selected provider environment are what the capability probe inspects. + const providerLayer = makeProviderServiceLive({ + reportLaunchPreflightWarning: ({ code, message }) => + Ref.update(reported, (current) => [...current, { code, message }]).pipe(Effect.as(true)), + }).pipe(Layer.provide(NodeServices.layer), Layer.provideMerge(shared)); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = ThreadId.make("r3-sparse"); + yield* provider.startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("opencode"), + providerInstanceId: ProviderInstanceId.make("opencode"), + cwd: repo, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(providerLayer)); + + const warnings = yield* Ref.get(reported); + const sparseWarning = warnings.find( + (warning) => warning.code === "git-sparse-add-unsupported", + ); + assert.isDefined(sparseWarning, "no git-sparse-add-unsupported warning was reported"); + assert.include(sparseWarning?.message ?? "", "OpenCode"); + assert.include(sparseWarning?.message ?? "", "--sparse"); + + yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 9cca38e02f66..24196995a46e 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -92,6 +92,7 @@ import * as LaunchPreflight from "../../environment/LaunchPreflight.ts"; import * as LaunchPreflightWarningInboxModule from "../../environment/LaunchPreflightWarningInbox.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { resolveLaunchPreflightConsumer } from "../launchPreflightConsumer.ts"; const isModelSelection = Schema.is(ModelSelection); const encodePromptJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -559,38 +560,30 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( readonly provider?: ProviderDriverKind; readonly providerInstanceId?: ProviderInstanceId; }) { - // Only probe a real directory: spawning Git in a missing path or a plain - // file would fail at the OS layer. The caller's own workspace read (and - // `ProviderWorkspaceMissingError`) handles those cases. This stat is itself - // bounded so a stalled/cloud-offloaded path cannot hold the launch; a - // timeout simply skips the preflight and lets the adapter surface the - // filesystem problem. - const workspaceStat = yield* fileSystem.stat(input.cwd).pipe( - Effect.timeoutOption(NARROW_FS_TIMEOUT), - Effect.map(Option.getOrElse(() => undefined)), - Effect.orElseSucceed(() => undefined), - ); - if (workspaceStat === undefined || workspaceStat.type !== "Directory") { - return; - } + // The bounded launch preflight itself verifies that the exact cwd is a real + // directory, warns about a stalled/denied filesystem, and skips a genuinely + // absent or non-directory path. The caller's own workspace read (and + // `ProviderWorkspaceMissingError`) owns that case, so this no longer + // silently drops a failed/timed-out cwd stat. - const settings = yield* serverSettings.getSettings.pipe( - Effect.orElseSucceed(() => undefined), - ); + const settings = yield* serverSettings.getSettings.pipe(Effect.orElseSucceed(() => undefined)); const isSharedRoot = LaunchPreflight.isConfiguredSharedSessionRoot( pathService, input.cwd, settings?.sharedSessionRoot, ); - // The selected consumer/operation determines `--sparse` applicability. T3 - // launches OpenCode with its default config, where snapshot staging is on - // unless the user disabled it, and OpenCode stages with `git add --sparse` - // even in an ordinary repository. Other consumers only need `--sparse` in a - // sparse checkout, so this is never a global T3 Git requirement. - const consumer: LaunchPreflight.LaunchPreflightConsumer | undefined = - input.provider === undefined - ? undefined - : { driver: input.provider, snapshotsEnabled: true }; + // The consumer/operation is derived from the selected production + // instance/runtime facts: the effective OpenCode snapshot configuration and + // whether an external OpenCode server owns the session. Non-OpenCode + // launches keep T3's own Git fallback and only need `--sparse` in a sparse + // checkout. + const consumer = resolveLaunchPreflightConsumer({ + ...(input.provider !== undefined ? { provider: input.provider } : {}), + ...(input.providerInstanceId !== undefined + ? { providerInstanceId: input.providerInstanceId } + : {}), + ...(settings !== undefined ? { settings } : {}), + }); // The provider launch resolves `git` from the same environment the adapter // inherits. Pass it through so the probe inspects the actual selected Git, // not an unrelated host default. @@ -645,8 +638,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // stays for the next session in the same directory. const inbox = yield* LaunchPreflightWarningInboxModule.LaunchPreflightWarningInbox; const inboxKey = LaunchPreflight.normalizePathKey(pathService, input.cwd); - const pendingWarnings = - LaunchPreflightWarningInboxModule.peekLaunchPreflightWarnings(inbox, inboxKey); + const pendingWarnings = LaunchPreflightWarningInboxModule.peekLaunchPreflightWarnings( + inbox, + inboxKey, + ); const deliveredCodes = new Set(); const undelivered: Array = []; for (const warning of pendingWarnings) { diff --git a/apps/server/src/provider/launchPreflightConsumer.ts b/apps/server/src/provider/launchPreflightConsumer.ts new file mode 100644 index 000000000000..9ccafa051f13 --- /dev/null +++ b/apps/server/src/provider/launchPreflightConsumer.ts @@ -0,0 +1,90 @@ +/** + * Derives the launch-preflight consumer from the selected production provider + * instance. The consumer is what decides whether `git add --sparse` is + * required by the launch itself (OpenCode snapshot staging) or only by the + * repository's sparse-checkout configuration (T3's own checkpoint fallback). + * + * The facts come from the same settings the provider runtime uses: + * `ServerSettings.providerInstances`, each instance's configured environment, + * and — for the default OpenCode provider — the effective + * `OPENCODE_CONFIG_CONTENT` snapshot setting. An instance pointed at an + * external OpenCode server owns its own process and Git, so the local Git this + * T3 server resolves cannot establish that process's capability. + * + * @module provider/launchPreflightConsumer + */ +import { + type ProviderDriverKind, + type ProviderInstanceId, + OpenCodeSettings, + type ServerSettings, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +import type { LaunchPreflightConsumer } from "../environment/LaunchPreflight.ts"; +import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; +import { resolveOpenCodeConfigContent } from "./opencodeRuntime.ts"; + +const decodeOpenCodeSettings = Schema.decodeUnknownOption(OpenCodeSettings); + +const OPENCODE_DRIVER: ProviderDriverKind = "opencode" as ProviderDriverKind; + +/** + * Whether the effective OpenCode config still stages Git snapshots. OpenCode + * defaults snapshots on, so only an explicit boolean `false` disables the + * provider-specific requirement; an unparseable or absent config keeps the + * default. This intentionally does not search config files or call out. + */ +export const openCodeSnapshotsEnabled = (configContent: string): boolean => { + let parsed: unknown; + try { + parsed = JSON.parse(configContent); + } catch { + return true; + } + if (typeof parsed !== "object" || parsed === null) return true; + return (parsed as { readonly snapshot?: unknown }).snapshot !== false; +}; + +export interface ResolveLaunchPreflightConsumerInput { + readonly provider?: ProviderDriverKind | undefined; + readonly providerInstanceId?: ProviderInstanceId | undefined; + readonly settings?: ServerSettings | undefined; + /** Host environment used as the instance environment's base. Defaults to `process.env`. */ + readonly hostEnv?: NodeJS.ProcessEnv | undefined; +} + +/** + * Resolves the consumer/operation about to launch. Returns `undefined` when no + * provider is known yet (nothing consumer-specific to check). + */ +export const resolveLaunchPreflightConsumer = ( + input: ResolveLaunchPreflightConsumerInput, +): LaunchPreflightConsumer | undefined => { + const { provider } = input; + if (provider === undefined) return undefined; + + if (provider !== OPENCODE_DRIVER) { + // T3's own checkpoint path only needs `--sparse` in a sparse checkout, which + // the preflight detects from the repository itself. No provider-specific + // requirement applies to other consumers. + return { driver: provider, snapshotsEnabled: true }; + } + + const entry = + input.providerInstanceId !== undefined + ? input.settings?.providerInstances[input.providerInstanceId] + : undefined; + const decoded = decodeOpenCodeSettings(entry?.config ?? {}); + const config = decoded._tag === "Some" ? decoded.value : undefined; + + if (config !== undefined && config.serverUrl.trim().length > 0) { + // External-server branch: no local OpenCode process is launched, so local + // PATH evidence cannot establish that server's Git. + return { driver: provider, snapshotsEnabled: false }; + } + + const environment = mergeProviderInstanceEnvironment(entry?.environment, input.hostEnv); + const snapshotsEnabled = openCodeSnapshotsEnabled(resolveOpenCodeConfigContent(environment)); + return { driver: provider, snapshotsEnabled }; +}; From 29a4cdcdc657935efac0eaf89827ffd21728368e Mon Sep 17 00:00:00 2001 From: business account Date: Mon, 28 Sep 2026 07:00:51 -0400 Subject: [PATCH 12/18] test(server): preserve the authenticated production wire acceptance Commit ENVCHK:P1's untracked authenticated production WebSocket smoke as repeatable source: a real `src/bin.ts serve` with isolated settings, supported desktop-bootstrap auth, and the real `orchestration.subscribeThread` client. It asserts the corrected `shared-root-git` warning reaches the subscription, the configured dummy provider starts exactly once, and a missing configured executable surfaces before model work. The prior hardcoded /tmp evidence write is removed; all state is fixture-owned and cleaned up. --- ...envchkP1WireAcceptance.integration.test.ts | 558 ++++++++++++++++++ 1 file changed, 558 insertions(+) create mode 100644 apps/server/integration/envchkP1WireAcceptance.integration.test.ts diff --git a/apps/server/integration/envchkP1WireAcceptance.integration.test.ts b/apps/server/integration/envchkP1WireAcceptance.integration.test.ts new file mode 100644 index 000000000000..a3d1fa1e47cf --- /dev/null +++ b/apps/server/integration/envchkP1WireAcceptance.integration.test.ts @@ -0,0 +1,558 @@ +// @effect-diagnostics nodeBuiltinImport:off globalFetch:off globalTimers:off preferSchemaOverJson:off - this test owns a real temp workspace and a real server process. +import { + CommandId, + MessageId, + ORCHESTRATION_WS_METHODS, + ProjectId, + ProviderInstanceId, + ThreadId, + WsRpcGroup, + type OrchestrationThreadStreamItem, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeSocket from "@effect/platform-node/NodeSocket"; +import { assert, it } from "@effect/vitest"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import * as Socket from "effect/unstable/socket/Socket"; + +import { execScriptSource, writeFakeCli } from "../src/testUtils/fakeCli.ts"; + +const DESKTOP_BOOTSTRAP_TOKEN = "envchk-p1-desktop-bootstrap-token"; +const GROK_MODEL = "grok-4.6"; +const PROJECT_ID = ProjectId.make("envchk-p1-project"); +const THREAD_ID = ThreadId.make("envchk-p1-thread"); +const MISSING_THREAD_ID = ThreadId.make("envchk-p1-missing-thread"); +const GROK_INSTANCE = ProviderInstanceId.make("grok"); +const GROK_MISSING_INSTANCE = ProviderInstanceId.make("grok-missing"); + +const findFreePort = (): Promise => + new Promise((resolve, reject) => { + const server = NodeNet.createServer(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + reject(new Error("could not allocate a loopback port")); + return; + } + const port = address.port; + server.close(() => resolve(port)); + }); + }); + +const makeFixture = (): Effect.Effect<{ + readonly baseDir: string; + readonly root: string; + readonly fakeDir: string; + readonly argvLogPath: string; + readonly wrapperPath: string; +}> => + Effect.gen(function* () { + const fs = yield* Effect.promise(() => import("node:fs/promises")); + const baseDir = yield* Effect.promise(() => + fs.mkdtemp(NodePath.join(NodeOS.tmpdir(), "envchk-p1-")), + ); + const root = yield* Effect.promise(() => + fs.mkdtemp(NodePath.join(NodeOS.tmpdir(), "envchk-p1-root-")), + ); + const fakeDir = yield* Effect.promise(() => + fs.mkdtemp(NodePath.join(NodeOS.tmpdir(), "envchk-p1-fake-")), + ); + const argvLogPath = NodePath.join(fakeDir, "argv.log"); + const mockAgentPath = NodePath.join( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "../scripts/acp-mock-agent.ts", + ); + const wrapperPath = writeFakeCli({ + directory: fakeDir, + name: "fake-grok-envchk-p1", + source: execScriptSource({ scriptPath: mockAgentPath, argvLogPath }), + }); + yield* Effect.promise( + () => + new Promise((resolve, reject) => { + NodeChildProcess.execFile("git", ["init", "-q"], { cwd: root }, (error) => + error ? reject(error) : resolve(), + ); + }), + ); + return { baseDir, root, fakeDir, argvLogPath, wrapperPath }; + }).pipe(Effect.orDie); + +interface SpawnedServer { + readonly child: NodeChildProcess.ChildProcess; + readonly port: number; + readonly stdout: () => string; + readonly stderr: () => string; +} + +const spawnServer = async (input: { + readonly baseDir: string; + readonly root: string; + readonly wrapperPath: string; + readonly missingPath: string; + readonly port: number; +}): Promise => { + const stateDir = NodePath.join(input.baseDir, "userdata"); + NodeFS.mkdirSync(stateDir, { recursive: true }); + const settings = { + sharedSessionRoot: input.root, + providers: { + grok: { enabled: true, binaryPath: input.wrapperPath }, + }, + providerInstances: { + "grok-missing": { + driver: "grok", + enabled: true, + config: { enabled: true, binaryPath: input.missingPath }, + }, + }, + }; + NodeFS.writeFileSync(NodePath.join(stateDir, "settings.json"), JSON.stringify(settings), "utf8"); + + const bootstrapFile = NodePath.join(input.baseDir, "bootstrap.ndjson"); + NodeFS.writeFileSync( + bootstrapFile, + `${JSON.stringify({ + mode: "desktop", + noBrowser: true, + port: input.port, + host: "127.0.0.1", + desktopBootstrapToken: DESKTOP_BOOTSTRAP_TOKEN, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + })}\n`, + "utf8", + ); + + const bootstrapFd = NodeFS.openSync(bootstrapFile, "r"); + const appsServerDir = NodePath.resolve( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "..", + ); + const child = NodeChildProcess.spawn( + process.execPath, + [ + "src/bin.ts", + "serve", + "--bootstrap-fd", + "3", + "--base-dir", + input.baseDir, + "--port", + String(input.port), + "--host", + "127.0.0.1", + "--log-level", + "info", + input.root, + ], + { + cwd: appsServerDir, + stdio: ["ignore", "pipe", "pipe", bootstrapFd], + env: { ...process.env }, + }, + ); + NodeFS.closeSync(bootstrapFd); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + return { child, port: input.port, stdout: () => stdout, stderr: () => stderr }; +}; + +const waitForHttp = async (port: number, attempted: () => string): Promise => { + const url = `http://127.0.0.1:${port}/api/auth/session`; + for (let i = 0; i < 160; i += 1) { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(3000) }); + if (response.status > 0) return; + } catch { + // not ready + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`server never responded; stderr=\n${attempted()}`); +}; + +const bootstrapCookie = async (port: number): Promise => { + const response = await fetch(`http://127.0.0.1:${port}/api/auth/browser-session`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ credential: DESKTOP_BOOTSTRAP_TOKEN }), + }); + if (!response.ok) { + throw new Error( + `browser-session bootstrap failed: ${response.status} ${await response.text()}`, + ); + } + const setCookie = response.headers.get("set-cookie"); + if (setCookie === null) throw new Error("bootstrap returned no session cookie"); + return setCookie.split(";")[0] ?? ""; +}; + +const parseSessionCookieFromWsUrl = ( + wsUrl: string, +): { readonly cookie: string | null; readonly url: string } => { + const next = new URL(wsUrl); + const cookie = next.hash.startsWith("#cookie=") + ? decodeURIComponent(next.hash.slice("#cookie=".length)) + : null; + next.hash = ""; + return { cookie, url: next.toString() }; +}; + +const wsRpcProtocolLayer = (wsUrl: string) => { + const { cookie, url } = parseSessionCookieFromWsUrl(wsUrl); + const webSocketConstructorLayer = Layer.succeed( + Socket.WebSocketConstructor, + (socketUrl, protocols) => { + const socket = new NodeSocket.NodeWS.WebSocket( + socketUrl, + protocols as string | string[] | undefined, + cookie ? { headers: { cookie } } : undefined, + ); + return socket as unknown as globalThis.WebSocket; + }, + ); + return RpcClient.layerProtocolSocket().pipe( + Layer.provide(Socket.layerWebSocket(url).pipe(Layer.provide(webSocketConstructorLayer))), + Layer.provide(RpcSerialization.layerJson), + ); +}; + +const makeWsRpcClient = RpcClient.make(WsRpcGroup); +type WsRpcClient = + typeof makeWsRpcClient extends Effect.Effect ? Client : never; + +const withWsRpcClient = ( + wsUrl: string, + f: (client: WsRpcClient) => Effect.Effect, +) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl))); + +const readFileLines = async (path: string): Promise> => { + try { + const raw = await import("node:fs/promises").then((fs) => fs.readFile(path, "utf8")); + return raw.split("\n").filter((line) => line.trim().length > 0); + } catch { + return []; + } +}; + +const activitiesOf = (items: ReadonlyArray) => + items.flatMap((item) => + item.kind === "event" && item.event.type === "thread.activity-appended" + ? [item.event.payload.activity] + : [], + ); + +it.live( + "ENVCHK:P1 authenticated production WebSocket smoke: preflight warning delivered over the wire", + () => + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const port = yield* Effect.promise(findFreePort); + const missingPath = NodePath.join(fixture.fakeDir, "not-a-real-grok"); + const server = yield* Effect.acquireRelease( + Effect.promise(() => + spawnServer({ + baseDir: fixture.baseDir, + root: fixture.root, + wrapperPath: fixture.wrapperPath, + missingPath, + port, + }), + ), + (spawned) => + Effect.sync(() => { + spawned.child.kill("SIGKILL"); + }), + ); + + yield* Effect.promise(() => waitForHttp(port, server.stderr)); + yield* Effect.logInfo(`ENVCHK_P1_HTTP_READY port=${port}`); + const startupLog = `${server.stdout()}\n${server.stderr()}`; + const cookie = yield* Effect.promise(() => bootstrapCookie(port)); + yield* Effect.logInfo(`ENVCHK_P1_BOOTSTRAP_OK cookie_present=${cookie.length > 0}`); + + const wsUrl = `ws://127.0.0.1:${port}/ws#cookie=${encodeURIComponent(cookie)}`; + const received = yield* Ref.make>([]); + const missingReceived = yield* Ref.make>([]); + + const wsOutcome = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "project.create", + commandId: CommandId.make("envchk-p1-project-create"), + projectId: PROJECT_ID, + title: "ENVCHK:P1", + workspaceRoot: fixture.root, + createdAt: "2026-09-28T00:00:00.000Z", + }); + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.create", + commandId: CommandId.make("envchk-p1-thread-create"), + threadId: THREAD_ID, + projectId: PROJECT_ID, + title: "ENVCHK:P1", + modelSelection: { instanceId: GROK_INSTANCE, model: GROK_MODEL }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-09-28T00:00:00.000Z", + }); + + const stream = client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: THREAD_ID, + afterSequence: 0, + requestCompletionMarker: true, + }); + yield* stream.pipe( + Stream.tap((item) => Ref.update(received, (current) => [...current, item])), + Stream.runDrain, + Effect.forkScoped, + ); + + let synchronized = false; + for (let i = 0; i < 200 && !synchronized; i += 1) { + const items = yield* Ref.get(received); + synchronized = items.some((item) => item.kind === "synchronized"); + if (!synchronized) yield* Effect.sleep("50 millis"); + } + if (!synchronized) { + return yield* Effect.die(new Error("thread subscription never synchronized")); + } + + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("envchk-p1-turn-start"), + threadId: THREAD_ID, + message: { + messageId: MessageId.make("envchk-p1-message"), + role: "user", + text: "envchk bounded launch probe", + attachments: [], + }, + modelSelection: { instanceId: GROK_INSTANCE, model: GROK_MODEL }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-09-28T00:00:00.000Z", + }); + + let found = false; + for (let i = 0; i < 400 && !found; i += 1) { + const items = yield* Ref.get(received); + found = activitiesOf(items).some((activity) => activity.kind === "launch.preflight"); + if (!found) yield* Effect.sleep("50 millis"); + } + + // Configured-executable failure path: a second grok instance whose + // configured binary does not exist. The provider session must fail + // before any model work, and that failure must reach this same + // authenticated wire subscription. + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.create", + commandId: CommandId.make("envchk-p1-missing-thread-create"), + threadId: MISSING_THREAD_ID, + projectId: PROJECT_ID, + title: "ENVCHK:P1 missing", + modelSelection: { instanceId: GROK_MISSING_INSTANCE, model: GROK_MODEL }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-09-28T00:00:00.000Z", + }); + const missingStream = client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: MISSING_THREAD_ID, + afterSequence: 0, + requestCompletionMarker: true, + }); + yield* missingStream.pipe( + Stream.tap((item) => Ref.update(missingReceived, (current) => [...current, item])), + Stream.runDrain, + Effect.forkScoped, + ); + let missingSynchronized = false; + for (let i = 0; i < 200 && !missingSynchronized; i += 1) { + const missingItems = yield* Ref.get(missingReceived); + missingSynchronized = missingItems.some((item) => item.kind === "synchronized"); + if (!missingSynchronized) yield* Effect.sleep("50 millis"); + } + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("envchk-p1-missing-turn-start"), + threadId: MISSING_THREAD_ID, + message: { + messageId: MessageId.make("envchk-p1-missing-message"), + role: "user", + text: "envchk missing executable probe", + attachments: [], + }, + modelSelection: { instanceId: GROK_MISSING_INSTANCE, model: GROK_MODEL }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-09-28T00:00:00.000Z", + }); + let failureObserved = false; + for (let i = 0; i < 600 && !failureObserved; i += 1) { + const missingItems = yield* Ref.get(missingReceived); + failureObserved = missingItems.some((item) => { + if (item.kind !== "event") return false; + if (item.event.type === "thread.activity-appended") { + return ( + (item.event.payload as { activity: { kind: string } }).activity.kind === + "provider.turn.start.failed" + ); + } + if (item.event.type === "thread.session-set") { + return ( + (item.event.payload as { session: { status: string } }).session.status === + "error" + ); + } + return false; + }); + if (!failureObserved) yield* Effect.sleep("50 millis"); + } + }), + ), + ).pipe(Effect.timeoutOption("90 seconds")); + + if (wsOutcome._tag === "None") { + return yield* Effect.die(new Error(`WS smoke timed out; stderr=\n${server.stderr()}`)); + } + yield* Effect.logInfo("ENVCHK_P1_WS_PHASE_DONE"); + + const items = yield* Ref.get(received); + const warning = activitiesOf(items).find((activity) => activity.kind === "launch.preflight"); + if (warning === undefined) { + return yield* Effect.die( + new Error( + `no launch.preflight activity reached the WS subscription; stderr=\n${server.stderr()}`, + ), + ); + } + const summary = (warning as { summary: string }).summary; + const payload = (warning as { payload: { code?: string; cwd?: string } }).payload; + const tone = (warning as { tone: string }).tone; + assert.strictEqual(tone, "error"); + assert.strictEqual(payload.code, "shared-root-git"); + assert.strictEqual(payload.cwd, fixture.root); + assert.include(summary, fixture.root); + assert.include(summary, "shared session root"); + + const invocations = yield* Effect.promise(() => readFileLines(fixture.argvLogPath)); + const sessionInvocations = invocations.filter((line) => line.startsWith("agent")); + + const eventTypes = items.flatMap((item) => (item.kind === "event" ? [item.event.type] : [])); + const activityDetails = activitiesOf(items).map((activity) => ({ + kind: (activity as { kind: string }).kind, + tone: (activity as { tone: string }).tone, + summary: (activity as { summary: string }).summary, + })); + + const missingItems = yield* Ref.get(missingReceived); + const missingEventTypes = missingItems.flatMap((item) => + item.kind === "event" ? [item.event.type] : [], + ); + const missingActivityDetails = activitiesOf(missingItems).map((activity) => ({ + kind: (activity as { kind: string }).kind, + tone: (activity as { tone: string }).tone, + summary: (activity as { summary: string }).summary, + })); + const missingSessions = missingItems.flatMap((item) => + item.kind === "event" && item.event.type === "thread.session-set" + ? [ + { + status: (item.event.payload as { session: { status: string } }).session.status, + lastError: (item.event.payload as { session: { lastError: string | null } }).session + .lastError, + }, + ] + : [], + ); + const missingFailureDetail = [ + ...missingSessions.map((session) => session.lastError ?? ""), + ...missingItems.flatMap((item) => + item.kind === "event" && item.event.type === "thread.activity-appended" + ? [JSON.stringify((item.event.payload as { activity: unknown }).activity)] + : [], + ), + ].join("\n"); + const mainFailureText = items + .filter((item) => item.kind === "event") + .map((item) => JSON.stringify((item as { event: unknown }).event)) + .join("\n"); + + const evidence = { + port, + root: fixture.root, + startupPreflightLogged: startupLog.includes("launch preflight"), + warning: { tone, code: payload.code, cwd: payload.cwd, summary }, + providerInvocations: invocations, + sessionStartCount: sessionInvocations.length, + streamItemCount: items.length, + eventTypes, + activityDetails, + missingExecutable: { + eventTypes: missingEventTypes, + activityDetails: missingActivityDetails, + sessions: missingSessions, + failureDetail: missingFailureDetail.slice(0, 4000), + mentionsGrok: missingFailureDetail.includes("grok"), + mentionsMissingPath: missingFailureDetail.includes("not-a-real-grok"), + }, + mainThreadFailure: { + sawTurnStartFailed: activityDetails.some( + (activity) => activity.kind === "provider.turn.start.failed", + ), + failureText: mainFailureText.slice(0, 4000), + }, + }; + yield* Effect.logInfo(`ENVCHK_P1_EVIDENCE=${JSON.stringify(evidence)}`); + assert.strictEqual(sessionInvocations.length, 1); + assert.strictEqual(evidence.startupPreflightLogged, true); + assert.strictEqual(evidence.mainThreadFailure.sawTurnStartFailed, false); + const missingSawFailure = + missingSessions.some((session) => session.status === "error") || + missingActivityDetails.some((activity) => activity.kind === "provider.turn.start.failed"); + assert.strictEqual(missingSawFailure, true); + assert.strictEqual( + missingFailureDetail.includes("grok") || missingFailureDetail.includes("not-a-real-grok"), + true, + ); + + yield* Effect.promise(() => + import("node:fs/promises").then((fs) => + fs.rm(fixture.baseDir, { recursive: true, force: true }), + ), + ); + yield* Effect.promise(() => + import("node:fs/promises").then((fs) => + fs.rm(fixture.fakeDir, { recursive: true, force: true }), + ), + ); + yield* Effect.promise(() => + import("node:fs/promises").then((fs) => + fs.rm(fixture.root, { recursive: true, force: true }), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), +); From 5d9ccd868e78521924b9165a501abbbe5d2609ba Mon Sep 17 00:00:00 2001 From: nullstack65 Date: Tue, 29 Sep 2026 22:04:56 -0400 Subject: [PATCH 13/18] fix(server): complete ENVCHK Windows preflight wire coverage --- ...envchkP1WireAcceptance.integration.test.ts | 85 ++-- ...aunchPreflightDelivery.integration.test.ts | 263 +++++++++++- .../src/environment/LaunchPreflight.test.ts | 381 +++++++++++++++--- .../server/src/environment/LaunchPreflight.ts | 283 ++++++++----- .../src/provider/Layers/ProviderService.ts | 12 +- .../provider/launchPreflightConsumer.test.ts | 85 ++++ .../src/provider/launchPreflightConsumer.ts | 26 +- apps/server/src/serverRuntimeStartup.ts | 15 +- packages/shared/src/schemaJson.ts | 30 +- 9 files changed, 945 insertions(+), 235 deletions(-) create mode 100644 apps/server/src/provider/launchPreflightConsumer.test.ts diff --git a/apps/server/integration/envchkP1WireAcceptance.integration.test.ts b/apps/server/integration/envchkP1WireAcceptance.integration.test.ts index a3d1fa1e47cf..eff27130063e 100644 --- a/apps/server/integration/envchkP1WireAcceptance.integration.test.ts +++ b/apps/server/integration/envchkP1WireAcceptance.integration.test.ts @@ -50,13 +50,15 @@ const findFreePort = (): Promise => }); }); -const makeFixture = (): Effect.Effect<{ +interface Fixture { readonly baseDir: string; readonly root: string; readonly fakeDir: string; readonly argvLogPath: string; readonly wrapperPath: string; -}> => +} + +const makeFixture = (): Effect.Effect => Effect.gen(function* () { const fs = yield* Effect.promise(() => import("node:fs/promises")); const baseDir = yield* Effect.promise(() => @@ -177,7 +179,9 @@ const spawnServer = async (input: { const waitForHttp = async (port: number, attempted: () => string): Promise => { const url = `http://127.0.0.1:${port}/api/auth/session`; - for (let i = 0; i < 160; i += 1) { + // A cold start runs the full migration + module load; on a busy native host + // that can take tens of seconds, so allow a generous bounded window. + for (let i = 0; i < 400; i += 1) { try { const response = await fetch(url, { signal: AbortSignal.timeout(3000) }); if (response.status > 0) return; @@ -189,6 +193,27 @@ const waitForHttp = async (port: number, attempted: () => string): Promise throw new Error(`server never responded; stderr=\n${attempted()}`); }; +/** + * Terminates a fixture-owned server and awaits its actual exit before its state + * directories are removed. Bounded so a wedged process cannot hang cleanup. + */ +const killAndAwaitExit = (child: NodeChildProcess.ChildProcess): Effect.Effect => + Effect.promise( + () => + new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + const timer = setTimeout(() => resolve(), 10_000); + child.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + child.kill("SIGKILL"); + }), + ); + const bootstrapCookie = async (port: number): Promise => { const response = await fetch(`http://127.0.0.1:${port}/api/auth/browser-session`, { method: "POST", @@ -262,9 +287,22 @@ const activitiesOf = (items: ReadonlyArray) => it.live( "ENVCHK:P1 authenticated production WebSocket smoke: preflight warning delivered over the wire", - () => - Effect.gen(function* () { + () => { + let cleanupFixture: Fixture | undefined; + const removeFixture = () => + Effect.promise(async () => { + const fixture = cleanupFixture; + if (fixture === undefined) return; + const fs = await import("node:fs/promises"); + await Promise.all([ + fs.rm(fixture.baseDir, { recursive: true, force: true }), + fs.rm(fixture.fakeDir, { recursive: true, force: true }), + fs.rm(fixture.root, { recursive: true, force: true }), + ]); + }); + return Effect.gen(function* () { const fixture = yield* makeFixture(); + cleanupFixture = fixture; const port = yield* Effect.promise(findFreePort); const missingPath = NodePath.join(fixture.fakeDir, "not-a-real-grok"); const server = yield* Effect.acquireRelease( @@ -277,10 +315,7 @@ it.live( port, }), ), - (spawned) => - Effect.sync(() => { - spawned.child.kill("SIGKILL"); - }), + (spawned) => killAndAwaitExit(spawned.child), ); yield* Effect.promise(() => waitForHttp(port, server.stderr)); @@ -539,20 +574,20 @@ it.live( true, ); - yield* Effect.promise(() => - import("node:fs/promises").then((fs) => - fs.rm(fixture.baseDir, { recursive: true, force: true }), - ), - ); - yield* Effect.promise(() => - import("node:fs/promises").then((fs) => - fs.rm(fixture.fakeDir, { recursive: true, force: true }), - ), - ); - yield* Effect.promise(() => - import("node:fs/promises").then((fs) => - fs.rm(fixture.root, { recursive: true, force: true }), - ), - ); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe( + // Bound the whole attempt (startup, auth, subscription, cleanup), not just + // the WebSocket phase, so a wedged start cannot hang the run. Generous + // because a cold native start can take tens of seconds. + Effect.timeoutOption("180 seconds"), + Effect.flatMap((outcome) => + outcome._tag === "None" + ? Effect.die(new Error("ENVCHK wire smoke timed out")) + : Effect.void, + ), + // Cleanup runs on success and failure alike: the server is killed and its + // exit awaited before its state directories are removed. + Effect.ensuring(removeFixture()), + Effect.provide(NodeServices.layer), + ); + }, ); diff --git a/apps/server/integration/launchPreflightDelivery.integration.test.ts b/apps/server/integration/launchPreflightDelivery.integration.test.ts index 1556409d02c4..53750f516b0d 100644 --- a/apps/server/integration/launchPreflightDelivery.integration.test.ts +++ b/apps/server/integration/launchPreflightDelivery.integration.test.ts @@ -657,6 +657,47 @@ it.live( }); assert.deepStrictEqual(disabled.consumer, { driver: "opencode", snapshotsEnabled: false }); + // 2b. W1-D: valid inline JSONC (comment + trailing comma) with + // snapshot:false is honored, not misread as enabled. + const jsoncDisabled = yield* runCase({ + threadId: "r3-jsonc-disabled", + provider: ProviderDriverKind.make("opencode"), + providerInstanceId: ProviderInstanceId.make("opencode"), + providerInstances: { + opencode: openCodeInstance({ + environment: [ + { + name: "OPENCODE_CONFIG_CONTENT", + value: '{ /* staging off */ "snapshot": false, }', + sensitive: false, + }, + ], + }), + }, + }); + assert.deepStrictEqual(jsoncDisabled.consumer, { + driver: "opencode", + snapshotsEnabled: false, + }); + + // 2c. W1-D: unknown configuration is not asserted enabled. + const unknownConfig = yield* runCase({ + threadId: "r3-unknown-config", + provider: ProviderDriverKind.make("opencode"), + providerInstanceId: ProviderInstanceId.make("opencode"), + providerInstances: { + opencode: openCodeInstance({ + environment: [ + { name: "OPENCODE_CONFIG_CONTENT", value: "not valid config", sensitive: false }, + ], + }), + }, + }); + assert.deepStrictEqual(unknownConfig.consumer, { + driver: "opencode", + snapshotsEnabled: undefined, + }); + // 3. External OpenCode server: the local Git is not that process's Git. const external = yield* runCase({ threadId: "r3-external", @@ -685,22 +726,32 @@ it.live( ); const writeSparseLessGit = (binDir: string, realGit: string): void => { - NodeFS.writeFileSync( - NodePath.join(binDir, "git"), - [ - "#!/bin/sh", - 'if [ "$1" = "add" ] && [ "$2" = "-h" ]; then', - ' echo "usage: git add [options] [--] ..."', - ' echo " -n, --dry-run dry run"', - " exit 0", - "fi", - `exec "${realGit}" "$@"`, + writeFakeCli({ + directory: binDir, + name: "git", + platform: process.platform, + source: [ + 'import { spawnSync } from "node:child_process";', + "const args = process.argv.slice(2);", + 'if (args[0] === "add" && args[1] === "-h") {', + ' process.stdout.write("usage: git add [options] [--] ...\\n -n, --dry-run dry run\\n -v, --verbose be verbose\\n");', + " process.exit(0);", + "}", + `const r = spawnSync(${JSON.stringify(realGit)}, args, { stdio: "inherit" });`, + "process.exit(r.status ?? 1);", "", ].join("\n"), - { mode: 0o755 }, - ); + }); }; +const resolveRealGitPath = (): string => + NodeChildProcess.execFileSync(process.platform === "win32" ? "where.exe" : "which", ["git"], { + encoding: "utf8", + }) + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0) ?? "git"; + it.live( "R3: a local OpenCode launch with default snapshots detects a controlled Git missing --sparse", () => @@ -712,7 +763,7 @@ it.live( const providerBin = NodePath.join(base, "provider-bin"); yield* Effect.promise(() => NodeFSP.mkdir(repo, { recursive: true })); yield* Effect.promise(() => NodeFSP.mkdir(providerBin, { recursive: true })); - const realGit = NodeChildProcess.execFileSync("which", ["git"]).toString().trim(); + const realGit = resolveRealGitPath(); writeSparseLessGit(providerBin, realGit); const git = (args: ReadonlyArray) => Effect.promise(async () => { @@ -754,7 +805,13 @@ it.live( [ProviderInstanceId.make("opencode")]: { driver: ProviderDriverKind.make("opencode"), enabled: true, - environment: [{ name: "PATH", value: providerBin, sensitive: false }], + environment: [ + { + name: "PATH", + value: `${providerBin}${NodePath.delimiter}${process.env.PATH ?? ""}`, + sensitive: false, + }, + ], }, }, }), @@ -791,3 +848,181 @@ it.live( yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +// --- W1-C: configured shared root recognized through a physical alias (production settings) --- + +it.live( + "W1-C: a configured shared root is recognized through a physical alias (junction/symlink)", + () => + Effect.gen(function* () { + const base = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-w1c-alias-")), + ); + const realRoot = NodePath.join(base, "real-root"); + const aliasRoot = NodePath.join(base, "alias-root"); + yield* Effect.promise(() => NodeFSP.mkdir(realRoot, { recursive: true })); + // A Windows junction (or a POSIX directory symlink) is a real alias of the + // same physical directory; the lexical spellings differ. + yield* Effect.promise(() => + NodeFSP.symlink(realRoot, aliasRoot, process.platform === "win32" ? "junction" : "dir"), + ); + yield* Effect.promise(async () => { + NodeChildProcess.execFileSync(resolveRealGitPath(), ["init", "-q"], { cwd: realRoot }); + }); + + const harness = yield* makeTestProviderAdapterHarness(); + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: harness.adapter, + }); + const reported = yield* Ref.make< + ReadonlyArray<{ readonly code: string; readonly message: string }> + >([]); + const inbox = new Map< + string, + ReadonlyArray<{ + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }> + >(); + const shared = Layer.mergeAll( + ProviderSessionDirectoryLive.pipe(Layer.provide(ProviderSessionRuntime.layer)), + Layer.succeed(ProviderAdapterRegistry, registry), + Layer.succeed(LaunchPreflightWarningInbox, inbox), + ServerConfig.layerTest(aliasRoot, aliasRoot).pipe(Layer.provide(NodeServices.layer)), + ServerSettingsService.layerTest({ + ...DEFAULT_SERVER_SETTINGS, + // The canonical root is configured; the session cwd is the alias. + sharedSessionRoot: realRoot, + }), + AnalyticsService.layerTest, + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + ).pipe(Layer.provide(SqlitePersistenceMemory)); + const providerLayer = makeProviderServiceLive({ + reportLaunchPreflightWarning: ({ code, message }) => + Ref.update(reported, (current) => [...current, { code, message }]).pipe(Effect.as(true)), + }).pipe(Layer.provide(NodeServices.layer), Layer.provideMerge(shared)); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = ThreadId.make("w1c-alias"); + yield* provider.startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: aliasRoot, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(providerLayer)); + + const warnings = yield* Ref.get(reported); + assert.isTrue( + warnings.some((warning) => warning.code === "shared-root-git"), + `expected shared-root-git through the physical alias; got ${JSON.stringify(warnings)}`, + ); + + yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// --- W1-B: non-OpenCode launch in a verified sparse checkout, incomplete probe warns --------- + +it.live( + "W1-B: a non-OpenCode launch in a verified sparse checkout warns when the capability probe fails", + () => + Effect.gen(function* () { + const base = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-w1b-sparse-")), + ); + const repo = NodePath.join(base, "repo"); + const providerBin = NodePath.join(base, "provider-bin"); + yield* Effect.promise(() => NodeFSP.mkdir(repo, { recursive: true })); + yield* Effect.promise(() => NodeFSP.mkdir(providerBin, { recursive: true })); + const realGit = resolveRealGitPath(); + writeSparseLessGit(providerBin, realGit); + const git = (args: ReadonlyArray) => + Effect.promise(async () => { + NodeChildProcess.execFileSync(realGit, args, { cwd: repo }); + }); + yield* Effect.promise(() => NodeFSP.mkdir(NodePath.join(repo, "src"), { recursive: true })); + yield* git(["init", "-q"]); + yield* git(["config", "user.name", "Test"]); + yield* git(["config", "user.email", "test@test.com"]); + yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(repo, "src", "file.txt"), "hi\n")); + yield* git(["add", "."]); + yield* git(["commit", "-q", "-m", "initial"]); + // A real sparse checkout makes `git add --sparse` relevant for T3's own + // checkpoint path, independent of the selected (non-OpenCode) consumer. + yield* git(["sparse-checkout", "set", "src"]); + + const harness = yield* makeTestProviderAdapterHarness(); + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: harness.adapter, + }); + const reported = yield* Ref.make< + ReadonlyArray<{ readonly code: string; readonly message: string }> + >([]); + const inbox = new Map< + string, + ReadonlyArray<{ + readonly code: LaunchPreflight.LaunchPreflightFindingCode; + readonly message: string; + }> + >(); + const shared = Layer.mergeAll( + ProviderSessionDirectoryLive.pipe(Layer.provide(ProviderSessionRuntime.layer)), + Layer.succeed(ProviderAdapterRegistry, registry), + Layer.succeed(LaunchPreflightWarningInbox, inbox), + ServerConfig.layerTest(repo, repo).pipe(Layer.provide(NodeServices.layer)), + ServerSettingsService.layerTest({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + environment: [ + { + name: "PATH", + value: `${providerBin}${NodePath.delimiter}${process.env.PATH ?? ""}`, + sensitive: false, + }, + ], + }, + }, + }), + AnalyticsService.layerTest, + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + ).pipe(Layer.provide(SqlitePersistenceMemory)); + const providerLayer = makeProviderServiceLive({ + reportLaunchPreflightWarning: ({ code, message }) => + Ref.update(reported, (current) => [...current, { code, message }]).pipe(Effect.as(true)), + }).pipe(Layer.provide(NodeServices.layer), Layer.provideMerge(shared)); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = ThreadId.make("w1b-sparse"); + yield* provider.startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: repo, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(providerLayer)); + + const warnings = yield* Ref.get(reported); + const sparseWarning = warnings.find( + (warning) => warning.code === "git-sparse-add-unsupported", + ); + assert.isDefined( + sparseWarning, + `expected a sparse-checkout capability warning; got ${JSON.stringify(warnings)}`, + ); + // The non-OpenCode path uses the sparse-checkout message, not the + // consumer-specific one. + assert.include(sparseWarning?.message ?? "", "sparse"); + assert.notInclude(sparseWarning?.message ?? "", "OpenCode"); + + yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + diff --git a/apps/server/src/environment/LaunchPreflight.test.ts b/apps/server/src/environment/LaunchPreflight.test.ts index 2936ca4a7dab..41fbeb52e73d 100644 --- a/apps/server/src/environment/LaunchPreflight.test.ts +++ b/apps/server/src/environment/LaunchPreflight.test.ts @@ -10,15 +10,49 @@ import { CheckpointRef } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { writeFakeCli } from "../testUtils/fakeCli.ts"; import * as LaunchPreflight from "./LaunchPreflight.ts"; +const isWindows = process.platform === "win32"; + +/** Resolves the real Git executable the same way on every host. */ +const resolveRealGitPath = (): string => { + const finder = isWindows ? "where.exe" : "which"; + const output = NodeChildProcess.execFileSync(finder, ["git"], { encoding: "utf8" }); + const first = output + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0); + if (first === undefined) throw new Error("git was not found on PATH"); + return first; +}; + +/** + * Writes a portable executable named `git` into `binDir`. The behavior lives in + * a Node stub (launched by a `.cmd` shim on Windows, a `sh` launcher elsewhere) + * so the fixture runs natively on every host and the real resolver/launcher is + * exercised. `body` is module source with `process.argv.slice(2)` as Git's args; + * `__REAL_GIT__` is replaced with the real Git path. + */ +const writeGitStub = (binDir: string, body: string, realGit: string): string => { + NodeFS.mkdirSync(binDir, { recursive: true }); + return writeFakeCli({ + directory: binDir, + name: "git", + source: body.replaceAll("__REAL_GIT__", JSON.stringify(realGit)), + platform: process.platform, + }); +}; + const probeError = ( reason: LaunchPreflight.LaunchPreflightProbeFailureReason, detail = "probe failed", @@ -39,13 +73,15 @@ const gitProbe = ( ): LaunchPreflight.LaunchPreflightGitProbe => ({ version: () => Effect.succeed("2.55.0"), resolveIdentity: () => Effect.succeed(identity()), - probeSparseAdd: () => Effect.succeed("not-required"), + isSparseCheckout: () => Effect.succeed(false), + probeSparseAdd: () => Effect.succeed("supported"), ...overrides, }); const input = (options: { readonly root?: string; readonly isSharedRoot?: boolean; + readonly configuredRoot?: string; readonly git?: LaunchPreflight.LaunchPreflightGitProbe; readonly files?: Partial; readonly consumer?: LaunchPreflight.LaunchPreflightConsumer; @@ -53,6 +89,7 @@ const input = (options: { }): LaunchPreflight.LaunchPreflightInput => ({ root: options.root ?? "/session-root", ...(options.isSharedRoot !== undefined ? { isSharedRoot: options.isSharedRoot } : {}), + ...(options.configuredRoot !== undefined ? { configuredRoot: options.configuredRoot } : {}), ...(options.consumer !== undefined ? { consumer: options.consumer } : {}), ...(options.gitEnvironment !== undefined ? { gitEnvironment: options.gitEnvironment } : {}), git: options.git ?? gitProbe(), @@ -139,7 +176,7 @@ it.effect("flags an accidental umbrella repository at the shared root", () => assert.deepStrictEqual(codes(result), ["shared-root-git"]); assert.deepStrictEqual(severities(result), ["warning"]); - assert.include(result.warnings[0]?.message ?? "", "/Documents/.git"); + assert.include(result.warnings[0]?.message ?? "", NodePath.join("/Documents", ".git")); }), ); @@ -245,7 +282,7 @@ it.effect("blocks when Git cannot start and the session root is a repository", ( input({ root: "/repo", git: { ...gitProbe(), version: () => Effect.fail(probeError("unavailable")) }, - files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + files: { exists: (target) => Effect.succeed(target === NodePath.join("/repo", ".git")) }, }), ); @@ -282,6 +319,7 @@ it.effect("warns when a sparse checkout's resolved Git lacks git add --sparse", resolveIdentity: () => Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), probeSparseAdd: () => Effect.succeed("unsupported"), + isSparseCheckout: () => Effect.succeed(true), }), files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, }), @@ -301,7 +339,7 @@ it.effect("does not warn about git add --sparse when the repository is not spars git: gitProbe({ resolveIdentity: () => Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), - probeSparseAdd: () => Effect.succeed("not-required"), + isSparseCheckout: () => Effect.succeed(false), }), files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, }), @@ -536,6 +574,219 @@ it.effect("R2: failed canonicalization does not assert an external repository", }), ); +// --- W1-A: the production file probe preserves metadata failures -------------- + +it.effect("W1-A: genuine absence is quiet but a denied metadata read is a probe failure", () => + Effect.gen(function* () { + const real = yield* FileSystem.FileSystem; + const probe = LaunchPreflight.makeFileProbe(real); + // Genuine absence stays `false` (quiet), never a warning. + assert.strictEqual(yield* probe.exists(NodePath.join(NodeOS.tmpdir(), "envchk-absent-x")), false); + + // A denied lookup is not absence: the production adapter must surface it as + // a probe failure instead of converting it to `false`. + const deniedLayer = Layer.mock(FileSystem.FileSystem)({ + stat: (target) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + pathOrDescriptor: target, + }), + ), + }); + const denied = yield* FileSystem.FileSystem.pipe(Effect.provide(deniedLayer)); + const deniedProbe = LaunchPreflight.makeFileProbe(denied); + const error = yield* deniedProbe.exists("/denied/AGENTS.md").pipe(Effect.flip); + assert.strictEqual(error.reason, "failed"); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("W1-A: a denied candidate metadata read warns instead of reading as absent", () => + Effect.gen(function* () { + const denied = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + pathOrDescriptor: "candidate", + }); + const result = yield* run( + input({ + root: "/repo", + files: { + // Every metadata read is denied, including the initial cwd stat. + exists: () => Effect.fail(probeError("failed", String(denied))), + stat: () => Effect.fail(probeError("failed", String(denied))), + }, + }), + ); + + assert.deepStrictEqual(codes(result), ["root-read-failed"]); + assert.deepStrictEqual(severities(result), ["warning"]); + }), +); + +// --- W1-B: incomplete relevant capability checks warn for sparse checkouts ---- + +it.effect("W1-B: a failed capability probe for a verified sparse checkout warns (non-OpenCode)", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + isSparseCheckout: () => Effect.succeed(true), + probeSparseAdd: () => Effect.fail(probeError("failed", "denied")), + }), + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + }), + ); + + // Applicability came from the verified sparse checkout, not the consumer. + assert.deepStrictEqual(codes(result), ["git-probe-failed"]); + assert.notInclude(codes(result), "git-sparse-add-unsupported"); + }), +); + +it.effect("W1-B: a hung capability probe for a verified sparse checkout warns (non-OpenCode)", () => + Effect.gen(function* () { + const fiber = yield* run( + input({ + root: "/repo", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + isSparseCheckout: () => Effect.succeed(true), + probeSparseAdd: () => Effect.never, + }), + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + }), + ).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust("1500 millis"); + const result = yield* Fiber.join(fiber); + + assert.deepStrictEqual(codes(result), ["git-probe-timed-out"]); + }), +); + +it.effect("W1-B: a non-required repository with an incomplete capability check stays quiet", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + isSparseCheckout: () => Effect.succeed(false), + probeSparseAdd: () => Effect.never, + }), + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + }), + ); + + assert.deepStrictEqual(result.findings, []); + }), +); + +// --- W1-C: canonical configured-root identity through both directions --------- + +it.effect("W1-C: alias spellings of the configured root still recognize shared intent", () => + Effect.gen(function* () { + // The configured root and the actual cwd are the same physical directory + // under different spellings; only the canonical compare can see that. + const result = yield* run( + input({ + root: "/private/var/folders/x/shared", + configuredRoot: "/var/folders/x/shared", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed( + identity({ + state: "ok", + topLevel: "/private/var/folders/x/shared", + commonDir: "/private/var/folders/x/shared/.git", + }), + ), + }), + files: { + exists: (target) => Effect.succeed(target.endsWith(".git")), + realPath: (target) => + Effect.succeed(target.startsWith("/var/") ? `/private${target}` : target), + }, + }), + ); + + assert.deepStrictEqual(codes(result), ["shared-root-git"]); + }), +); + +it.effect("W1-C: a genuinely different configured root stays ordinary", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + configuredRoot: "/elsewhere", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + }), + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, + }), + ); + + assert.deepStrictEqual(result.findings, []); + }), +); + +it.effect("W1-C: a nested repository selected as cwd stays ordinary under a configured root", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/Documents/project", + configuredRoot: "/Documents", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed( + identity({ + state: "ok", + topLevel: "/Documents/project", + commonDir: "/Documents/project/.git", + }), + ), + }), + files: { exists: (target) => Effect.succeed(target === "/Documents/project/.git") }, + }), + ); + + assert.deepStrictEqual(result.findings, []); + }), +); + +it.effect("W1-C: an unresolved configured-root identity does not invent an umbrella", () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/var/folders/x/shared", + configuredRoot: "/private/var/folders/x/shared", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/var/folders/x/shared", commonDir: "/var/folders/x/shared/.git" })), + }), + files: { + exists: (target) => Effect.succeed(target.endsWith(".git")), + // Canonicalization is unavailable for both sides. + realPath: () => Effect.succeed(null), + }, + }), + ); + + assert.deepStrictEqual(result.findings, []); + }), +); + it.effect("wires the real service probes and passes a healthy root", () => Effect.gen(function* () { const directory = yield* Effect.promise(() => @@ -612,33 +863,30 @@ const E3PreflightLayer = LaunchPreflight.layer.pipe(Layer.provide(E3VcsLayer)); const E3CombinedLayer = Layer.merge(E3VcsLayer, E3PreflightLayer); /** A `git` that rejects `--path-format` (like Git < 2.31) and delegates everything else. */ -const writePathFormatRejectingGit = (binDir: string, realGit: string) => { - const wrapperPath = NodePath.join(binDir, "git"); - NodeFS.writeFileSync( - wrapperPath, +const writePathFormatRejectingGit = (binDir: string, realGit: string) => + writeGitStub( + binDir, [ - "#!/bin/sh", - 'for a in "$@"; do', - ' case "$a" in', - " --path-format|--path-format=*)", - " echo \"fatal: unknown option 'path-format'\" >&2", - " exit 129", - " ;;", - " esac", - "done", - `exec "${realGit}" "$@"`, + 'import { spawnSync } from "node:child_process";', + "const args = process.argv.slice(2);", + "for (const a of args) {", + ' if (a === "--path-format" || a.startsWith("--path-format=")) {', + " process.stderr.write(\"fatal: unknown option 'path-format'\\n\");", + " process.exit(129);", + " }", + "}", + "const r = spawnSync(__REAL_GIT__, args, { stdio: \"inherit\" });", + "process.exit(r.status ?? 1);", "", ].join("\n"), - { mode: 0o755 }, + realGit, ); - return wrapperPath; -}; it.effect( "R1: a Git that rejects --path-format still launches and captures an ordinary checkpoint", () => Effect.gen(function* () { - const realGit = NodeChildProcess.execFileSync("which", ["git"]).toString().trim(); + const realGit = resolveRealGitPath(); const base = yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-e3-pathformat-")), ); @@ -712,18 +960,18 @@ it.live( // The owned fixture records its own PID and the PID of a descendant it // spawns, then sleeps. Cleanup must terminate both, not just leave the // post-sleep marker unwritten. - NodeFS.writeFileSync( - NodePath.join(binDir, "git"), + writeGitStub( + binDir, [ - "#!/bin/sh", - `echo $$ > "${childPidFile}"`, - "sleep 30 &", - `echo $! > "${grandchildPidFile}"`, - "wait", - `touch "${marker}"`, + 'import { spawn } from "node:child_process";', + 'import { writeFileSync } from "node:fs";', + `writeFileSync(${JSON.stringify(childPidFile)}, String(process.pid));`, + 'const descendant = spawn(process.execPath, ["-e", "setTimeout(() => {}, 30000)"], { stdio: "ignore" });', + `writeFileSync(${JSON.stringify(grandchildPidFile)}, String(descendant.pid));`, + `setTimeout(() => { writeFileSync(${JSON.stringify(marker)}, "done"); process.exit(0); }, 30000);`, "", ].join("\n"), - { mode: 0o755 }, + resolveRealGitPath(), ); const isAlive = (pid: number): boolean => { @@ -794,9 +1042,18 @@ const makeRealGitProbe = Effect.gen(function* () { const realpath = (target: string) => Effect.promise(() => NodeFSP.realpath(target)); +/** Windows filesystem paths differ by case/separator; compare canonically. */ +const sameRealPath = (actual: string, expected: string): boolean => { + const normalize = (value: string) => { + const resolved = NodePath.resolve(value); + return isWindows ? resolved.toLowerCase() : resolved; + }; + return normalize(actual) === normalize(expected); +}; + it.live("F1: git identity resolves relative common dirs against the invocation cwd", () => Effect.gen(function* () { - const realGit = NodeChildProcess.execFileSync("which", ["git"]).toString().trim(); + const realGit = resolveRealGitPath(); const base = yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-e4-identity-")), ); @@ -819,25 +1076,28 @@ it.live("F1: git identity resolves relative common dirs against the invocation c const probe = yield* makeRealGitProbe; const repoReal = yield* realpath(repo); + const repoGitDir = NodePath.join(repoReal, ".git"); // 1. Root is the repository root: common dir is `/.git`. const rootIdentity = yield* probe.resolveIdentity(repo); assert.strictEqual(rootIdentity.state, "ok"); - assert.strictEqual(yield* realpath(rootIdentity.commonDir ?? ""), `${repoReal}/.git`); + assert.isTrue(sameRealPath(yield* realpath(rootIdentity.commonDir ?? ""), repoGitDir)); // 2. Root is a subdirectory: plain `git rev-parse --git-common-dir` returns // a relative `../.git`; it must resolve to `/.git`, not outside. const subIdentity = yield* probe.resolveIdentity(sub); assert.strictEqual(subIdentity.state, "ok"); - assert.strictEqual(yield* realpath(subIdentity.topLevel ?? ""), repoReal); - assert.strictEqual(yield* realpath(subIdentity.commonDir ?? ""), `${repoReal}/.git`); + assert.isTrue(sameRealPath(yield* realpath(subIdentity.topLevel ?? ""), repoReal)); + assert.isTrue(sameRealPath(yield* realpath(subIdentity.commonDir ?? ""), repoGitDir)); // 3. Root is a linked worktree: the common dir points back at the main // repository, and its top level is the worktree itself. const worktreeIdentity = yield* probe.resolveIdentity(worktree); assert.strictEqual(worktreeIdentity.state, "ok"); - assert.strictEqual(yield* realpath(worktreeIdentity.commonDir ?? ""), `${repoReal}/.git`); - assert.strictEqual(yield* realpath(worktreeIdentity.topLevel ?? ""), yield* realpath(worktree)); + assert.isTrue(sameRealPath(yield* realpath(worktreeIdentity.commonDir ?? ""), repoGitDir)); + assert.isTrue( + sameRealPath(yield* realpath(worktreeIdentity.topLevel ?? ""), yield* realpath(worktree)), + ); yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); }).pipe(Effect.provide(E3VcsLayer)), @@ -845,7 +1105,7 @@ it.live("F1: git identity resolves relative common dirs against the invocation c it.live("F3: the real probe reports git add --sparse support only for a sparse checkout", () => Effect.gen(function* () { - const realGit = NodeChildProcess.execFileSync("which", ["git"]).toString().trim(); + const realGit = resolveRealGitPath(); const base = yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-e4-sparse-")), ); @@ -866,15 +1126,13 @@ it.live("F3: the real probe reports git add --sparse support only for a sparse c yield* git(["commit", "-m", "initial"]); const probe = yield* makeRealGitProbe; - assert.strictEqual(yield* probe.probeSparseAdd(repo), "not-required"); - // A selected OpenCode consumer requires `--sparse` even in an ordinary - // repository, so the capability is probed there too. - assert.strictEqual( - yield* probe.probeSparseAdd(repo, { requiredByConsumer: true }), - "supported", - ); + assert.strictEqual(yield* probe.isSparseCheckout(repo), false); + // The selected OpenCode consumer requires `--sparse` even in an ordinary + // repository, so its capability is probed regardless of the sparse config. + assert.strictEqual(yield* probe.probeSparseAdd(repo), "supported"); yield* git(["sparse-checkout", "set", "src"]); + assert.strictEqual(yield* probe.isSparseCheckout(repo), true); assert.strictEqual(yield* probe.probeSparseAdd(repo), "supported"); yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); @@ -890,22 +1148,23 @@ it.live("F3: the real probe reports git add --sparse support only for a sparse c * environment sentinel it inherited. This models a provider environment whose * resolved Git is older than the host's. */ -const writeSparseLessGit = (binDir: string, realGit: string, recordPath: string) => { - NodeFS.writeFileSync( - NodePath.join(binDir, "git"), +const writeSparseLessGit = (binDir: string, realGit: string, recordPath: string): void => { + writeGitStub( + binDir, [ - "#!/bin/sh", - `printf '%s|%s\\n' "$0" "$ENVCHK_SENTINEL" >> "${recordPath}"`, - 'if [ "$1" = "add" ] && [ "$2" = "-h" ]; then', - ' echo "usage: git add [options] [--] ..."', - ' echo " -n, --dry-run dry run"', - ' echo " -v, --verbose be verbose"', - " exit 0", - "fi", - `exec "${realGit}" "$@"`, + 'import { appendFileSync } from "node:fs";', + 'import { spawnSync } from "node:child_process";', + "const args = process.argv.slice(2);", + `appendFileSync(${JSON.stringify(recordPath)}, process.argv[1] + "|" + (process.env.ENVCHK_SENTINEL ?? "") + "\\n");`, + 'if (args[0] === "add" && args[1] === "-h") {', + ' process.stdout.write("usage: git add [options] [--] ...\\n -n, --dry-run dry run\\n -v, --verbose be verbose\\n");', + " process.exit(0);", + "}", + 'const r = spawnSync(__REAL_GIT__, args, { stdio: "inherit" });', + "process.exit(r.status ?? 1);", "", ].join("\n"), - { mode: 0o755 }, + realGit, ); }; @@ -933,7 +1192,7 @@ it.live( "A1/A2: ordinary OpenCode repo resolves the selected provider Git and warns on missing --sparse", () => Effect.gen(function* () { - const realGit = NodeChildProcess.execFileSync("which", ["git"]).toString().trim(); + const realGit = resolveRealGitPath(); const base = yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-v5-consumer-")), ); @@ -976,7 +1235,9 @@ it.live( assert.isTrue(recordLines.length > 0); for (const line of recordLines) { const [executable, recordedSentinel] = line.split("|"); - assert.strictEqual(executable, NodePath.join(providerBin, "git")); + // The wrapper ran from the selected provider environment's directory + // (the launcher/stub lives in providerBin, not on the host). + assert.strictEqual(NodePath.dirname(executable ?? ""), providerBin); assert.strictEqual(recordedSentinel, sentinel); } diff --git a/apps/server/src/environment/LaunchPreflight.ts b/apps/server/src/environment/LaunchPreflight.ts index 546a9e57c167..22bf550d427f 100644 --- a/apps/server/src/environment/LaunchPreflight.ts +++ b/apps/server/src/environment/LaunchPreflight.ts @@ -93,31 +93,32 @@ export interface LaunchPreflightGitProbe { env?: NodeJS.ProcessEnv, ) => Effect.Effect; /** - * Probes the real capability the selected consumer/operation needs: - * `git add --sparse`. Read-only: it inspects config and `git add -h`, never - * stages anything. Applicability is a property of the consumer, not of the - * repository's `core.sparseCheckout`: the OpenCode snapshot operation passes - * `git add --all --sparse` for eligible files in ordinary repositories too, - * so `requiredByConsumer` marks those launches. A sparse checkout also - * requires `--sparse` for T3's own checkpoint path. The optional `rev-parse - * --path-format` fast path is deliberately not probed or warned about: its - * absence is a harmless optimization fallback handled inside `GitVcsDriver`. + * Read-only: whether the root repository is a sparse checkout + * (`git config --bool core.sparseCheckout`). This establishes the + * repository-side applicability of `git add --sparse` separately from the + * consumer, so an incomplete capability probe can still be recognized as + * relevant. Uses `allowNonZeroExit`, so a missing key is `false`, not a + * failure. + */ + readonly isSparseCheckout: ( + root: string, + env?: NodeJS.ProcessEnv, + ) => Effect.Effect; + /** + * Probes the real capability `git add --sparse`. Read-only: it inspects + * `git add -h`, never stages anything. The caller decides applicability (a + * verified sparse checkout or the selected consumer) and only probes when the + * capability is relevant. The optional `rev-parse --path-format` fast path is + * deliberately not probed or warned about: its absence is a harmless + * optimization fallback handled inside `GitVcsDriver`. */ readonly probeSparseAdd: ( root: string, - options?: { - readonly requiredByConsumer?: boolean; - readonly env?: NodeJS.ProcessEnv; - }, + env?: NodeJS.ProcessEnv, ) => Effect.Effect; } -export type LaunchPreflightSparseCapability = - /** - * Neither the selected consumer/operation nor the repository's sparse-checkout - * configuration requires `git add --sparse`, so its absence is not actionable. - */ - "not-required" | "supported" | "unsupported"; +export type LaunchPreflightSparseCapability = "supported" | "unsupported"; /** * The consumer/operation about to run. `--sparse` applicability belongs to the @@ -129,13 +130,17 @@ export type LaunchPreflightSparseCapability = export interface LaunchPreflightConsumer { /** Provider driver kind selected for this launch (e.g. "opencode"). */ readonly driver: string; - /** Whether OpenCode-style snapshot staging is enabled for this launch. */ - readonly snapshotsEnabled: boolean; + /** + * Whether OpenCode-style snapshot staging is enabled for this launch. + * `undefined` means the effective configuration could not be read, so the + * requirement is not asserted; only an explicit `true` requires `--sparse`. + */ + readonly snapshotsEnabled: boolean | undefined; } /** Whether the selected consumer/operation stages with `git add --sparse`. */ export const consumerUsesSparseAdd = (consumer: LaunchPreflightConsumer | undefined): boolean => - consumer !== undefined && consumer.driver === "opencode" && consumer.snapshotsEnabled; + consumer !== undefined && consumer.driver === "opencode" && consumer.snapshotsEnabled === true; export interface LaunchPreflightRepoIdentity { readonly state: "ok" | "not-a-repository" | "failed"; @@ -153,7 +158,13 @@ export interface LaunchPreflightFsEntry { } export interface LaunchPreflightFileProbe { - readonly exists: (target: string) => Effect.Effect; + /** + * Presence check that keeps genuine absence distinct from a failed metadata + * read. Returns `false` only when the entry is genuinely absent; a + * denied/stalled/I-O-failed lookup is a `LaunchPreflightProbeError` so the + * caller can warn instead of reading it as absence. + */ + readonly exists: (target: string) => Effect.Effect; /** * Bounded-type stat. Genuine absence is the observable `"missing"` state, so * callers can keep it distinct from a denied/stalled metadata read. Any other @@ -182,9 +193,19 @@ export interface LaunchPreflightInput { /** * Whether `root` is an explicitly configured shared session root (as opposed * to a selected nested repository). Only a shared root that is *itself* a - * repository is reported as an unexpected umbrella. + * repository is reported as an unexpected umbrella. When `configuredRoot` is + * provided, this boolean is derived from a bounded canonical comparison + * instead of being trusted as given. */ readonly isSharedRoot?: boolean; + /** + * The explicitly configured shared session root, as spelled in settings. The + * preflight canonicalizes it against the actual root through the same bounded + * filesystem probe so alias spellings of the same physical root + * (`/var` ↔ `/private/var`, a Windows junction) still recognize shared intent. + * Unset or empty means no root is configured and every session is ordinary. + */ + readonly configuredRoot?: string; /** * The selected consumer/operation. Determines whether `git add --sparse` is * required even in an ordinary repository (OpenCode snapshots use it there). @@ -256,7 +277,7 @@ export const runLaunchPreflight = ( ): Effect.Effect => Effect.gen(function* () { const path = yield* Path.Path; - const isSharedRoot = input.isSharedRoot === true; + const configuredRoot = input.configuredRoot?.trim() ?? ""; const collected = yield* Ref.make>([]); const add = (finding: LaunchPreflightFinding) => Ref.update(collected, (current) => [...current, finding]); @@ -337,6 +358,23 @@ export const runLaunchPreflight = ( // failed: identity must then stay unknown, never affirmative external. const canonicalRoot = yield* realPathBounded(input.root); + // Shared-root intent comes only from the explicit setting. When a + // configured root is present, resolve it through the same bounded + // canonicalization as the actual root: an alias spelling of the same + // physical directory still counts as the shared root, while a genuinely + // different configured root stays ordinary. Unlike a lexical compare, this + // does not lose intent before the probe runs. + let isSharedRoot = input.isSharedRoot === true; + if (configuredRoot.length > 0) { + const canonicalConfigured = yield* realPathBounded(configuredRoot); + isSharedRoot = + canonicalRoot !== null && canonicalConfigured !== null + ? samePath(path, canonicalRoot, canonicalConfigured) + : // Canonicalization failed for at least one side; a lexical compare + // is the neutral fallback and cannot invent shared intent. + isConfiguredSharedSessionRoot(path, input.root, configuredRoot); + } + // Only a real `.git` entry counts as repository evidence. A retired marker // such as `.git.macfix-m1-retired` is a different path and is ignored. An // incomplete metadata check is not absence: warn about it. @@ -454,59 +492,76 @@ export const runLaunchPreflight = ( }); } - // Applicability comes from the selected consumer/operation, not only - // from `core.sparseCheckout`. OpenCode snapshot staging passes `git add - // --all --sparse` for eligible files in ordinary repositories too, so a - // missing `--sparse` there is actionable. T3's own checkpoint path only - // needs it in a sparse checkout. Probe read-only; the optional - // `--path-format` fast path is never probed or warned about. + // Applicability comes from the selected consumer/operation and from the + // repository's own sparse-checkout configuration. OpenCode snapshot + // staging passes `git add --all --sparse` for eligible files in ordinary + // repositories too, so a missing `--sparse` is actionable for that + // consumer. T3's own checkpoint path only needs it in a sparse checkout. + // Resolve the repository-side applicability *before* the capability probe + // so an incomplete probe is still recognized as relevant (W1-B): a + // failure after a verified sparse checkout must warn, not be dropped. const requiredByConsumer = consumerUsesSparseAdd(input.consumer) && inGitWorkTree; - const sparseOutcome = yield* input.git - .probeSparseAdd(input.root, { - requiredByConsumer, - ...(input.gitEnvironment !== undefined ? { env: input.gitEnvironment } : {}), - }) + const sparseConfigOutcome = yield* input.git + .isSparseCheckout(input.root, input.gitEnvironment) .pipe( - Effect.map((state) => ({ _tag: "ok" as const, state })), + Effect.map((value) => ({ _tag: "ok" as const, value })), Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), Effect.timeoutOption(GIT_PROBE_TIMEOUT), Effect.map(Option.getOrElse(() => ({ _tag: "timeout" as const }))), ); - if (sparseOutcome._tag === "ok" && sparseOutcome.state === "unsupported") { - yield* add({ - code: "git-sparse-add-unsupported", - severity: "warning", - message: requiredByConsumer - ? "The selected OpenCode session snapshots this repository with `git add --sparse`, but the " + - "Git this launch resolves does not support `--sparse`. Staging changed and untracked files " + - "for a snapshot will fail, so snapshots may be incomplete. Install a newer Git (or disable " + - "OpenCode snapshots) to keep snapshots accurate; the session can still start." - : "This is a sparse Git checkout, but the Git this launch resolves does not support " + - "`git add --sparse`. T3 Code cannot tell which files the sparse rules exclude, so a " + - "checkpoint may record excluded files as deleted. Install a newer Git to keep sparse " + - "checkpoints accurate; the session can still start.", - }); - } else if (requiredByConsumer && sparseOutcome._tag === "timeout") { - // The capability is relevant to the selected launch and could not be - // confirmed. Report it as incomplete rather than silently clean; never - // mislabel an unknown capability as unsupported, and never block. - yield* add({ - code: "git-probe-timed-out", - severity: "warning", - message: - "The Git `add --sparse` capability probe did not finish in time, so the selected OpenCode " + - "launch could not be confirmed to support `git add --sparse`. Snapshots may be incomplete; " + - "the session can still start. Check the Git install and the session-root filesystem.", - }); - } else if (requiredByConsumer && sparseOutcome._tag === "error") { - yield* add({ - code: "git-probe-failed", - severity: "warning", - message: - `The Git \`add --sparse\` capability probe failed (${sparseOutcome.error.detail}), so the ` + - "selected OpenCode launch could not be confirmed to support `git add --sparse`. Snapshots " + - "may be incomplete; the session can still start.", - }); + // A verified sparse checkout makes the capability relevant for every + // consumer. When the config check itself is incomplete, only the + // consumer-derived requirement is known; the repository side stays + // unknown rather than assumed. + const sparseCheckoutVerified = + sparseConfigOutcome._tag === "ok" && sparseConfigOutcome.value; + const sparseApplicable = requiredByConsumer || sparseCheckoutVerified; + if (sparseApplicable) { + const sparseOutcome = yield* input.git + .probeSparseAdd(input.root, input.gitEnvironment) + .pipe( + Effect.map((state) => ({ _tag: "ok" as const, state })), + Effect.catch((error) => Effect.succeed({ _tag: "error" as const, error })), + Effect.timeoutOption(GIT_PROBE_TIMEOUT), + Effect.map(Option.getOrElse(() => ({ _tag: "timeout" as const }))), + ); + if (sparseOutcome._tag === "ok" && sparseOutcome.state === "unsupported") { + yield* add({ + code: "git-sparse-add-unsupported", + severity: "warning", + message: requiredByConsumer + ? "The selected OpenCode session snapshots this repository with `git add --sparse`, but the " + + "Git this launch resolves does not support `--sparse`. Staging changed and untracked files " + + "for a snapshot will fail, so snapshots may be incomplete. Install a newer Git (or disable " + + "OpenCode snapshots) to keep snapshots accurate; the session can still start." + : "This is a sparse Git checkout, but the Git this launch resolves does not support " + + "`git add --sparse`. T3 Code cannot tell which files the sparse rules exclude, so a " + + "checkpoint may record excluded files as deleted. Install a newer Git to keep sparse " + + "checkpoints accurate; the session can still start.", + }); + } else if (sparseOutcome._tag === "timeout") { + // The capability is relevant and could not be confirmed. Report it as + // incomplete rather than silently clean; never mislabel an unknown + // capability as unsupported, and never block. + yield* add({ + code: "git-probe-timed-out", + severity: "warning", + message: + "The Git `add --sparse` capability probe did not finish in time, so this launch could not " + + "be confirmed to support `git add --sparse`. Snapshots or sparse checkpoints may be " + + "incomplete; the session can still start. Check the Git install and the session-root " + + "filesystem.", + }); + } else if (sparseOutcome._tag === "error") { + yield* add({ + code: "git-probe-failed", + severity: "warning", + message: + `The Git \`add --sparse\` capability probe failed (${sparseOutcome.error.detail}), so this ` + + "launch could not be confirmed to support `git add --sparse`. Snapshots or sparse " + + "checkpoints may be incomplete; the session can still start.", + }); + } } } else if (gitOutcome._tag === "timeout" || gitOutcome.error.reason === "timeout") { yield* add({ @@ -621,6 +676,7 @@ export class LaunchPreflight extends Context.Service< root: string, options?: { readonly isSharedRoot?: boolean; + readonly configuredRoot?: string; readonly consumer?: LaunchPreflightConsumer; readonly gitEnvironment?: NodeJS.ProcessEnv; }, @@ -711,9 +767,8 @@ export const makeGitProbe = ( detail: "", } satisfies LaunchPreflightRepoIdentity; }), - probeSparseAdd: (root, options) => + isSparseCheckout: (root, env) => Effect.gen(function* () { - const env = options?.env; // Read-only: is the root repository actually a sparse checkout? const sparseConfig = yield* runGit( "launch-preflight.git-sparse-config", @@ -722,14 +777,10 @@ export const makeGitProbe = ( true, env, ); - const sparseCheckout = - Number(sparseConfig.exitCode) === 0 && sparseConfig.stdout.trim() === "true"; - // A selected consumer (OpenCode snapshots) requires `--sparse` even in - // an ordinary repository; a sparse checkout requires it for T3's own - // checkpoint path. Only then is a missing `--sparse` actionable. - if (!sparseCheckout && options?.requiredByConsumer !== true) { - return "not-required" satisfies LaunchPreflightSparseCapability; - } + return Number(sparseConfig.exitCode) === 0 && sparseConfig.stdout.trim() === "true"; + }), + probeSparseAdd: (root, env) => + Effect.gen(function* () { // Read-only usage probe; `git add -h` never stages a file. const help = yield* runGit( "launch-preflight.git-sparse-add-help", @@ -745,37 +796,54 @@ export const makeGitProbe = ( }; }; +/** @public Service construction is part of the canonical Effect module API. */ +export const makeFileProbe = ( + fileSystem: FileSystem.FileSystem, +): LaunchPreflightFileProbe => ({ + // Reuse the typed stat/absence distinction: a genuine NotFound is the only + // state that means "absent". A denied or I/O-failed lookup stays a probe + // error so the caller warns instead of reading it as absence. + exists: (target) => + fileSystem.stat(target).pipe( + Effect.as(true), + Effect.catchIf( + (cause) => cause.reason._tag === "NotFound", + () => Effect.succeed(false), + ), + Effect.mapError( + (cause) => new LaunchPreflightProbeError({ reason: "failed", detail: String(cause) }), + ), + ), + stat: (target) => + fileSystem.stat(target).pipe( + Effect.map((entry): LaunchPreflightFsEntry => ({ + type: entry.type === "Directory" ? "directory" : entry.type === "File" ? "file" : "other", + })), + Effect.catchIf( + (cause) => cause.reason._tag === "NotFound", + () => Effect.succeed({ type: "missing" } satisfies LaunchPreflightFsEntry), + ), + Effect.mapError( + (cause) => new LaunchPreflightProbeError({ reason: "failed", detail: String(cause) }), + ), + ), + realPath: (target) => fileSystem.realPath(target).pipe(Effect.orElseSucceed(() => null)), + readFirstBytes: (target, maxBytes) => + fileSystem.stream(target, { bytesToRead: maxBytes }).pipe( + Stream.runCount, + Effect.mapError( + (cause) => new LaunchPreflightProbeError({ reason: "failed", detail: String(cause) }), + ), + ), +}); + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const vcsProcess = yield* VcsProcess.VcsProcess; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const git = makeGitProbe(vcsProcess, path); - - const files: LaunchPreflightFileProbe = { - exists: (target) => fileSystem.exists(target).pipe(Effect.orElseSucceed(() => false)), - stat: (target) => - fileSystem.stat(target).pipe( - Effect.map((entry): LaunchPreflightFsEntry => ({ - type: entry.type === "Directory" ? "directory" : entry.type === "File" ? "file" : "other", - })), - Effect.catchIf( - (cause) => cause.reason._tag === "NotFound", - () => Effect.succeed({ type: "missing" } satisfies LaunchPreflightFsEntry), - ), - Effect.mapError( - (cause) => new LaunchPreflightProbeError({ reason: "failed", detail: String(cause) }), - ), - ), - realPath: (target) => fileSystem.realPath(target).pipe(Effect.orElseSucceed(() => null)), - readFirstBytes: (target, maxBytes) => - fileSystem.stream(target, { bytesToRead: maxBytes }).pipe( - Stream.runCount, - Effect.mapError( - (cause) => new LaunchPreflightProbeError({ reason: "failed", detail: String(cause) }), - ), - ), - }; + const files = makeFileProbe(fileSystem); return LaunchPreflight.of({ run: (root: string, options) => @@ -784,6 +852,7 @@ export const make = Effect.gen(function* () { git, files, ...(options?.isSharedRoot !== undefined ? { isSharedRoot: options.isSharedRoot } : {}), + ...(options?.configuredRoot !== undefined ? { configuredRoot: options.configuredRoot } : {}), ...(options?.consumer !== undefined ? { consumer: options.consumer } : {}), ...(options?.gitEnvironment !== undefined ? { gitEnvironment: options.gitEnvironment } diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 24196995a46e..5a6cf181965a 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -567,11 +567,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // silently drops a failed/timed-out cwd stat. const settings = yield* serverSettings.getSettings.pipe(Effect.orElseSucceed(() => undefined)); - const isSharedRoot = LaunchPreflight.isConfiguredSharedSessionRoot( - pathService, - input.cwd, - settings?.sharedSessionRoot, - ); + // Shared-root intent is carried into the bounded preflight as the exact + // configured root, so its canonical identity comparison can recognize alias + // spellings of the same physical directory. A lexical compare here would + // lose that intent before the probe ever runs. + const configuredRoot = settings?.sharedSessionRoot; // The consumer/operation is derived from the selected production // instance/runtime facts: the effective OpenCode snapshot configuration and // whether an external OpenCode server owns the session. Non-OpenCode @@ -596,7 +596,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ? undefined : mergeProviderInstanceEnvironment(instanceEnvironment); const result = yield* runLaunchPreflight(input.cwd, { - isSharedRoot, + ...(configuredRoot !== undefined ? { configuredRoot } : {}), ...(consumer !== undefined ? { consumer } : {}), ...(gitEnvironment !== undefined ? { gitEnvironment } : {}), }).pipe( diff --git a/apps/server/src/provider/launchPreflightConsumer.test.ts b/apps/server/src/provider/launchPreflightConsumer.test.ts new file mode 100644 index 000000000000..850f5b59b8c9 --- /dev/null +++ b/apps/server/src/provider/launchPreflightConsumer.test.ts @@ -0,0 +1,85 @@ +import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { + openCodeSnapshotsEnabled, + resolveLaunchPreflightConsumer, +} from "./launchPreflightConsumer.ts"; + +const opencode = ProviderDriverKind.make("opencode"); + +it.effect("W1-D: honors inline JSONC without comments or trailing commas misreading", () => + Effect.gen(function* () { + // Strict JSON still works. + assert.strictEqual(openCodeSnapshotsEnabled('{"snapshot":false}'), false); + assert.strictEqual(openCodeSnapshotsEnabled('{"snapshot":true}'), true); + assert.strictEqual(openCodeSnapshotsEnabled("{}"), true); + + // Valid JSONC (open comment, line comment, trailing commas) disables it. + assert.strictEqual( + openCodeSnapshotsEnabled('{\n /* disable staging */\n "snapshot": false,\n}\n'), + false, + ); + assert.strictEqual(openCodeSnapshotsEnabled('{\n // comment\n "snapshot": false,\n}'), false); + assert.strictEqual(openCodeSnapshotsEnabled('{ "snapshot": true, }'), true); + }), +); + +it.effect("W1-D: unknown configuration is never asserted enabled", () => + Effect.gen(function* () { + assert.strictEqual(openCodeSnapshotsEnabled("not json at all"), undefined); + assert.strictEqual(openCodeSnapshotsEnabled('{"snapshot":'), undefined); + assert.strictEqual(openCodeSnapshotsEnabled("[]"), undefined); + assert.strictEqual(openCodeSnapshotsEnabled('"a string"'), undefined); + // A non-boolean snapshot value is not a definite enable. + assert.strictEqual(openCodeSnapshotsEnabled('{"snapshot":"off"}'), undefined); + }), +); + +it.effect("W1-D: production settings resolve JSONC snapshot:false to a disabled consumer", () => + Effect.gen(function* () { + const consumer = resolveLaunchPreflightConsumer({ + provider: opencode, + providerInstanceId: ProviderInstanceId.make("opencode"), + settings: { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + opencode: { + driver: opencode, + enabled: true, + environment: [ + { + name: "OPENCODE_CONFIG_CONTENT", + value: '{ /* snapshots off */ "snapshot": false, }', + sensitive: false, + }, + ], + }, + }, + }, + hostEnv: {}, + }); + + assert.deepStrictEqual(consumer, { driver: "opencode", snapshotsEnabled: false }); + }), +); + +it.effect("W1-D: the ordinary default keeps snapshots enabled", () => + Effect.gen(function* () { + const consumer = resolveLaunchPreflightConsumer({ + provider: opencode, + providerInstanceId: ProviderInstanceId.make("opencode"), + settings: { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + opencode: { driver: opencode, enabled: true }, + }, + }, + hostEnv: {}, + }); + + assert.deepStrictEqual(consumer, { driver: "opencode", snapshotsEnabled: true }); + }), +); diff --git a/apps/server/src/provider/launchPreflightConsumer.ts b/apps/server/src/provider/launchPreflightConsumer.ts index 9ccafa051f13..c2cffd79d35a 100644 --- a/apps/server/src/provider/launchPreflightConsumer.ts +++ b/apps/server/src/provider/launchPreflightConsumer.ts @@ -19,6 +19,7 @@ import { OpenCodeSettings, type ServerSettings, } from "@t3tools/contracts"; +import { parseLenientJsonUnknown } from "@t3tools/shared/schemaJson"; import * as Schema from "effect/Schema"; import type { LaunchPreflightConsumer } from "../environment/LaunchPreflight.ts"; @@ -31,19 +32,20 @@ const OPENCODE_DRIVER: ProviderDriverKind = "opencode" as ProviderDriverKind; /** * Whether the effective OpenCode config still stages Git snapshots. OpenCode - * defaults snapshots on, so only an explicit boolean `false` disables the - * provider-specific requirement; an unparseable or absent config keeps the - * default. This intentionally does not search config files or call out. + * accepts inline JSONC (comments and trailing commas), so the value is parsed + * with the shared lenient parser rather than `JSON.parse`. An explicit boolean + * `false` disables the provider-specific requirement and an explicit `true` + * keeps it; any value that cannot be read as a boolean (unparseable content, a + * non-object, or a non-boolean `snapshot`) is `undefined` — unknown, never + * asserted enabled. This intentionally does not search config files or call out. */ -export const openCodeSnapshotsEnabled = (configContent: string): boolean => { - let parsed: unknown; - try { - parsed = JSON.parse(configContent); - } catch { - return true; - } - if (typeof parsed !== "object" || parsed === null) return true; - return (parsed as { readonly snapshot?: unknown }).snapshot !== false; +export const openCodeSnapshotsEnabled = (configContent: string): boolean | undefined => { + const parsed = parseLenientJsonUnknown(configContent); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined; + const snapshot = (parsed as { readonly snapshot?: unknown }).snapshot; + if (snapshot === false) return false; + if (snapshot === true || snapshot === undefined) return true; + return undefined; }; export interface ResolveLaunchPreflightConsumerInput { diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index f2b1f6fda3b3..4503c1fa03a1 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -969,17 +969,18 @@ export const make = (options?: StartupOptions) => "launch.preflight", Effect.gen(function* () { // Shared-inbox intent comes only from the explicit setting; the - // server's own cwd is an ordinary working directory. + // server's own cwd is an ordinary working directory. The configured + // root is carried into the bounded preflight so alias spellings of the + // same physical root still recognize shared intent. const sharedSessionRoot = yield* serverSettings.getSettings.pipe( Effect.map((settings) => settings.sharedSessionRoot), Effect.orElseSucceed(() => undefined), ); - const isSharedRoot = LaunchPreflight.isConfiguredSharedSessionRoot( - pathService, - serverConfig.cwd, - sharedSessionRoot, - ); - return yield* launchPreflight.run(serverConfig.cwd, { isSharedRoot }).pipe( + return yield* launchPreflight + .run(serverConfig.cwd, { + ...(sharedSessionRoot !== undefined ? { configuredRoot: sharedSessionRoot } : {}), + }) + .pipe( Effect.tap((result) => Effect.gen(function* () { yield* Effect.forEach( diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index 076ff67b62df..c153799dbf21 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -164,7 +164,11 @@ export const formatSchemaError = (cause: Cause.Cause) => { */ const decodeJsonString = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); -const parseLenientJsonGetter = SchemaGetter.onSome((input: string) => { +/** + * Strips JSONC syntax (JS-style comments and trailing commas) while leaving + * quoted string contents untouched, producing strict JSON text. + */ +export const stripJsonComments = (input: string): string => { // Strip single-line comments - alternation preserves quoted strings. let stripped = input.replace( /("(?:[^"\\]|\\.)*")|\/\/[^\n]*/g, @@ -186,11 +190,29 @@ const parseLenientJsonGetter = SchemaGetter.onSome((input: string) => { stringLiteral ? match : (bracket ?? ""), ); - return decodeJsonString(stripped).pipe( + return stripped; +}; + +/** + * Parses a lenient JSON string (tolerating comments and trailing commas) into + * an `unknown` value synchronously, returning `undefined` when the value cannot + * be parsed. Small in-memory configuration values that are validated by their + * consumer should use this rather than a strict `JSON.parse`. + */ +export const parseLenientJsonUnknown = (input: string): unknown | undefined => { + try { + return JSON.parse(stripJsonComments(input)); + } catch { + return undefined; + } +}; + +const parseLenientJsonGetter = SchemaGetter.onSome((input: string) => + decodeJsonString(stripJsonComments(input)).pipe( Effect.map(Option.some), Effect.mapError((error) => error.issue), - ); -}); + ), +); /** * Schema transformation: lenient JSONC string ↔ unknown. From b061a82e1a7fcbbfa0de4a625574a68adc2b7d4d Mon Sep 17 00:00:00 2001 From: business account Date: Tue, 29 Sep 2026 22:22:52 -0400 Subject: [PATCH 14/18] style(server): format the recovered ENVCHK preflight sources The recovered W2 head left five touched files unformatted, so the repo's vp fmt --check gate failed on the branch. Formatting-only; no behavior change (a reflow in LaunchPreflight.ts, serverRuntimeStartup.ts, and server.ts, plus a missing trailing newline). --- ...envchkP1WireAcceptance.integration.test.ts | 1 - .../server/src/environment/LaunchPreflight.ts | 8 +- .../environment/launchPreflightReporter.ts | 2 +- apps/server/src/server.ts | 5 +- apps/server/src/serverRuntimeStartup.ts | 81 ++++++++++--------- 5 files changed, 47 insertions(+), 50 deletions(-) diff --git a/apps/server/integration/envchkP1WireAcceptance.integration.test.ts b/apps/server/integration/envchkP1WireAcceptance.integration.test.ts index eff27130063e..b7622f787e6f 100644 --- a/apps/server/integration/envchkP1WireAcceptance.integration.test.ts +++ b/apps/server/integration/envchkP1WireAcceptance.integration.test.ts @@ -573,7 +573,6 @@ it.live( missingFailureDetail.includes("grok") || missingFailureDetail.includes("not-a-real-grok"), true, ); - }).pipe( // Bound the whole attempt (startup, auth, subscription, cleanup), not just // the WebSocket phase, so a wedged start cannot hang the run. Generous diff --git a/apps/server/src/environment/LaunchPreflight.ts b/apps/server/src/environment/LaunchPreflight.ts index 22bf550d427f..24e83c7035e5 100644 --- a/apps/server/src/environment/LaunchPreflight.ts +++ b/apps/server/src/environment/LaunchPreflight.ts @@ -797,9 +797,7 @@ export const makeGitProbe = ( }; /** @public Service construction is part of the canonical Effect module API. */ -export const makeFileProbe = ( - fileSystem: FileSystem.FileSystem, -): LaunchPreflightFileProbe => ({ +export const makeFileProbe = (fileSystem: FileSystem.FileSystem): LaunchPreflightFileProbe => ({ // Reuse the typed stat/absence distinction: a genuine NotFound is the only // state that means "absent". A denied or I/O-failed lookup stays a probe // error so the caller warns instead of reading it as absence. @@ -852,7 +850,9 @@ export const make = Effect.gen(function* () { git, files, ...(options?.isSharedRoot !== undefined ? { isSharedRoot: options.isSharedRoot } : {}), - ...(options?.configuredRoot !== undefined ? { configuredRoot: options.configuredRoot } : {}), + ...(options?.configuredRoot !== undefined + ? { configuredRoot: options.configuredRoot } + : {}), ...(options?.consumer !== undefined ? { consumer: options.consumer } : {}), ...(options?.gitEnvironment !== undefined ? { gitEnvironment: options.gitEnvironment } diff --git a/apps/server/src/environment/launchPreflightReporter.ts b/apps/server/src/environment/launchPreflightReporter.ts index b598afb69df1..8e9ee0b236d6 100644 --- a/apps/server/src/environment/launchPreflightReporter.ts +++ b/apps/server/src/environment/launchPreflightReporter.ts @@ -45,4 +45,4 @@ export const makeLaunchPreflightWarningReporter = createdAt, }); return true; - }).pipe(Effect.catchCause(() => Effect.succeed(false))); \ No newline at end of file + }).pipe(Effect.catchCause(() => Effect.succeed(false))); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 66f771e2dd5a..9f8d33df6521 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -280,10 +280,7 @@ const ProviderLayerLive = Layer.unwrap( const orchestrationEngine = yield* OrchestrationEngineService; const crypto = yield* Crypto.Crypto; return makeProviderServiceLive({ - reportLaunchPreflightWarning: makeLaunchPreflightWarningReporter( - orchestrationEngine, - crypto, - ), + reportLaunchPreflightWarning: makeLaunchPreflightWarningReporter(orchestrationEngine, crypto), }); }), ).pipe( diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 4503c1fa03a1..e31bb44aa511 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -981,47 +981,48 @@ export const make = (options?: StartupOptions) => ...(sharedSessionRoot !== undefined ? { configuredRoot: sharedSessionRoot } : {}), }) .pipe( - Effect.tap((result) => - Effect.gen(function* () { - yield* Effect.forEach( - result.warnings, - (warning) => - Effect.logWarning(`launch preflight: ${warning.message}`, { + Effect.tap((result) => + Effect.gen(function* () { + yield* Effect.forEach( + result.warnings, + (warning) => + Effect.logWarning(`launch preflight: ${warning.message}`, { + code: warning.code, + severity: warning.severity, + cwd: serverConfig.cwd, + }), + { discard: true }, + ); + // No thread exists yet, so carry these to the first provider + // session in this directory, which delivers them through the + // existing user-visible warning transport. + const inbox = + yield* LaunchPreflightWarningInboxModule.LaunchPreflightWarningInbox; + LaunchPreflightWarningInboxModule.recordLaunchPreflightWarnings( + inbox, + LaunchPreflight.normalizePathKey(pathService, serverConfig.cwd), + result.warnings.map((warning) => ({ code: warning.code, - severity: warning.severity, - cwd: serverConfig.cwd, - }), - { discard: true }, - ); - // No thread exists yet, so carry these to the first provider - // session in this directory, which delivers them through the - // existing user-visible warning transport. - const inbox = yield* LaunchPreflightWarningInboxModule.LaunchPreflightWarningInbox; - LaunchPreflightWarningInboxModule.recordLaunchPreflightWarnings( - inbox, - LaunchPreflight.normalizePathKey(pathService, serverConfig.cwd), - result.warnings.map((warning) => ({ - code: warning.code, - message: warning.message, - })), - ); - yield* Effect.forEach( - result.blockers, - (blocker) => - Effect.logError(`launch preflight: ${blocker.message}`, { - code: blocker.code, - severity: blocker.severity, - cwd: serverConfig.cwd, - }), - { discard: true }, - ); - }), - ), - Effect.asVoid, - Effect.catchCause((cause) => - Effect.logWarning("launch preflight failed to run", { cause }), - ), - ); + message: warning.message, + })), + ); + yield* Effect.forEach( + result.blockers, + (blocker) => + Effect.logError(`launch preflight: ${blocker.message}`, { + code: blocker.code, + severity: blocker.severity, + cwd: serverConfig.cwd, + }), + { discard: true }, + ); + }), + ), + Effect.asVoid, + Effect.catchCause((cause) => + Effect.logWarning("launch preflight failed to run", { cause }), + ), + ); }), ); From ca6ec070bf275b8f9c8ce09e7aa508149730807e Mon Sep 17 00:00:00 2001 From: business account Date: Tue, 29 Sep 2026 22:23:02 -0400 Subject: [PATCH 15/18] test(server): repair the recovered W2 head's typecheck gates The recovered W2 head did not pass the branch's own gates: - launchPreflightConsumer.test.ts keyed providerInstances with a bare 'opencode' string instead of the branded ProviderInstanceId key, so apps/server typecheck reported 8 errors. - LaunchPreflight.test.ts mocked FileSystem.FileSystem with Layer.mock, which is missing FileSystem's props; use FileSystem.layerNoop. - The four touched suites trip the repo's preferSchemaOverJson lint diagnostic for JSON.stringify failure detail, so scope it off at the top of each file, matching the sibling ENVCHK tests. - providerService.integration.test.ts asserted the retired isSharedRoot runner option; align it with the recovered contract (configuredRoot is the explicit identity) and clean up the extra fixture cwd with the Effect FileSystem API. --- ...aunchPreflightDelivery.integration.test.ts | 7 +- .../providerService.integration.test.ts | 104 +++++++++++------- .../src/environment/LaunchPreflight.test.ts | 55 +++++---- .../provider/launchPreflightConsumer.test.ts | 4 +- 4 files changed, 102 insertions(+), 68 deletions(-) diff --git a/apps/server/integration/launchPreflightDelivery.integration.test.ts b/apps/server/integration/launchPreflightDelivery.integration.test.ts index 53750f516b0d..9af6df47edd5 100644 --- a/apps/server/integration/launchPreflightDelivery.integration.test.ts +++ b/apps/server/integration/launchPreflightDelivery.integration.test.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off - a real temporary workspace exercises the launch path. +// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off - a real temporary workspace exercises the launch path and tests stringify provider warnings for failure detail. import { CommandId, GrokSettings, @@ -947,7 +947,9 @@ it.live( yield* git(["init", "-q"]); yield* git(["config", "user.name", "Test"]); yield* git(["config", "user.email", "test@test.com"]); - yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(repo, "src", "file.txt"), "hi\n")); + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(repo, "src", "file.txt"), "hi\n"), + ); yield* git(["add", "."]); yield* git(["commit", "-q", "-m", "initial"]); // A real sparse checkout makes `git add --sparse` relevant for T3's own @@ -1025,4 +1027,3 @@ it.live( yield* Effect.promise(() => NodeFSP.rm(base, { recursive: true, force: true })); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 010b45b07599..42567a3a3fb9 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -112,6 +112,7 @@ const makeIntegrationFixture = (options?: { root: string, options?: { readonly isSharedRoot?: boolean; + readonly configuredRoot?: string; readonly consumer?: LaunchPreflight.LaunchPreflightConsumer; readonly gitEnvironment?: NodeJS.ProcessEnv; }, @@ -542,10 +543,15 @@ it.live("a launch-preflight warning is reported and the session still starts onc it.live("the same repository at the server cwd is ordinary by default", () => Effect.gen(function* () { - const seen: Array = []; + const seen: Array<{ readonly isSharedRoot?: boolean; readonly configuredRoot?: string }> = []; const fixture = yield* makeIntegrationFixture({ launchPreflightRunner: (_root, options) => { - seen.push(options?.isSharedRoot); + seen.push({ + ...(options?.isSharedRoot !== undefined ? { isSharedRoot: options.isSharedRoot } : {}), + ...(options?.configuredRoot !== undefined + ? { configuredRoot: options.configuredRoot } + : {}), + }); return Effect.succeed(findingResult([])); }, }); @@ -561,50 +567,66 @@ it.live("the same repository at the server cwd is ordinary by default", () => }); }).pipe(Effect.provide(fixture.layer)); - assert.deepStrictEqual(seen, [false]); + // No shared root is configured, so neither an explicit shared-root override + // nor a configured root is handed to the bounded preflight. + assert.deepStrictEqual(seen, [{}]); }).pipe(Effect.provide(NodeServices.layer)), ); -it.live("a configured shared session root applies independently of the backend cwd", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const otherBackendCwd = yield* fs.makeTempDirectory(); - const seen: Array = []; - const fixture = yield* makeIntegrationFixture({ - serverConfigCwd: otherBackendCwd, - sharedSessionRootIsWorkspace: true, - launchPreflightRunner: (_root, options) => { - seen.push(options?.isSharedRoot); - return Effect.succeed(findingResult([])); - }, - }); - const nested = path.join(fixture.cwd, "nested"); - yield* fs.makeDirectory(nested, { recursive: true }); - - yield* Effect.gen(function* () { - const provider = yield* ProviderService; - // The configured shared root is treated as shared even though the - // backend cwd is a different directory. - yield* provider.startSession(ThreadId.make("thread-shared-root"), { - threadId: ThreadId.make("thread-shared-root"), - provider: ProviderDriverKind.make("codex"), - providerInstanceId: codexInstanceId, - cwd: fixture.cwd, - runtimeMode: "full-access", - }); - // A nested repository selected as the session cwd stays ordinary. - yield* provider.startSession(ThreadId.make("thread-shared-nested"), { - threadId: ThreadId.make("thread-shared-nested"), - provider: ProviderDriverKind.make("codex"), - providerInstanceId: codexInstanceId, - cwd: nested, - runtimeMode: "full-access", +it.live( + "a configured shared session root is carried into the preflight independently of the cwd", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const otherBackendCwd = yield* fs.makeTempDirectory(); + const seen: Array<{ readonly isSharedRoot?: boolean; readonly configuredRoot?: string }> = []; + const fixture = yield* makeIntegrationFixture({ + serverConfigCwd: otherBackendCwd, + sharedSessionRootIsWorkspace: true, + launchPreflightRunner: (_root, options) => { + seen.push({ + ...(options?.isSharedRoot !== undefined ? { isSharedRoot: options.isSharedRoot } : {}), + ...(options?.configuredRoot !== undefined + ? { configuredRoot: options.configuredRoot } + : {}), + }); + return Effect.succeed(findingResult([])); + }, }); - }).pipe(Effect.provide(fixture.layer)); + const nested = path.join(fixture.cwd, "nested"); + yield* fs.makeDirectory(nested, { recursive: true }); - assert.deepStrictEqual(seen, [true, false]); - }).pipe(Effect.provide(NodeServices.layer)), + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + // The exact configured root is carried into the bounded preflight even + // though the backend cwd is a different directory. + yield* provider.startSession(ThreadId.make("thread-shared-root"), { + threadId: ThreadId.make("thread-shared-root"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: fixture.cwd, + runtimeMode: "full-access", + }); + // A nested repository selected as the session cwd still receives the + // configured root; the preflight owns the canonical comparison and keeps + // the nested repository ordinary (covered by the real-preflight suites). + yield* provider.startSession(ThreadId.make("thread-shared-nested"), { + threadId: ThreadId.make("thread-shared-nested"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: nested, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(fixture.layer)); + + assert.deepStrictEqual(seen, [ + { configuredRoot: fixture.cwd }, + { configuredRoot: fixture.cwd }, + ]); + // Clean up the extra backend cwd owned by this fixture. + yield* fs.remove(otherBackendCwd, { recursive: true, force: true }); + }).pipe(Effect.provide(NodeServices.layer)), ); it.live("the launch preflight inspects the selected provider environment", () => diff --git a/apps/server/src/environment/LaunchPreflight.test.ts b/apps/server/src/environment/LaunchPreflight.test.ts index 41fbeb52e73d..b95788a5bdcf 100644 --- a/apps/server/src/environment/LaunchPreflight.test.ts +++ b/apps/server/src/environment/LaunchPreflight.test.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off - real temp directories exercise the bounded probes. +// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off - real temp directories exercise the bounded probes and the launch fixtures use JSON.stringify for failure detail. import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; @@ -581,11 +581,14 @@ it.effect("W1-A: genuine absence is quiet but a denied metadata read is a probe const real = yield* FileSystem.FileSystem; const probe = LaunchPreflight.makeFileProbe(real); // Genuine absence stays `false` (quiet), never a warning. - assert.strictEqual(yield* probe.exists(NodePath.join(NodeOS.tmpdir(), "envchk-absent-x")), false); + assert.strictEqual( + yield* probe.exists(NodePath.join(NodeOS.tmpdir(), "envchk-absent-x")), + false, + ); // A denied lookup is not absence: the production adapter must surface it as // a probe failure instead of converting it to `false`. - const deniedLayer = Layer.mock(FileSystem.FileSystem)({ + const deniedLayer = FileSystem.layerNoop({ stat: (target) => Effect.fail( PlatformError.systemError({ @@ -629,25 +632,27 @@ it.effect("W1-A: a denied candidate metadata read warns instead of reading as ab // --- W1-B: incomplete relevant capability checks warn for sparse checkouts ---- -it.effect("W1-B: a failed capability probe for a verified sparse checkout warns (non-OpenCode)", () => - Effect.gen(function* () { - const result = yield* run( - input({ - root: "/repo", - git: gitProbe({ - resolveIdentity: () => - Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), - isSparseCheckout: () => Effect.succeed(true), - probeSparseAdd: () => Effect.fail(probeError("failed", "denied")), +it.effect( + "W1-B: a failed capability probe for a verified sparse checkout warns (non-OpenCode)", + () => + Effect.gen(function* () { + const result = yield* run( + input({ + root: "/repo", + git: gitProbe({ + resolveIdentity: () => + Effect.succeed(identity({ state: "ok", topLevel: "/repo", commonDir: "/repo/.git" })), + isSparseCheckout: () => Effect.succeed(true), + probeSparseAdd: () => Effect.fail(probeError("failed", "denied")), + }), + files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, }), - files: { exists: (target) => Effect.succeed(target === "/repo/.git") }, - }), - ); + ); - // Applicability came from the verified sparse checkout, not the consumer. - assert.deepStrictEqual(codes(result), ["git-probe-failed"]); - assert.notInclude(codes(result), "git-sparse-add-unsupported"); - }), + // Applicability came from the verified sparse checkout, not the consumer. + assert.deepStrictEqual(codes(result), ["git-probe-failed"]); + assert.notInclude(codes(result), "git-sparse-add-unsupported"); + }), ); it.effect("W1-B: a hung capability probe for a verified sparse checkout warns (non-OpenCode)", () => @@ -773,7 +778,13 @@ it.effect("W1-C: an unresolved configured-root identity does not invent an umbre configuredRoot: "/private/var/folders/x/shared", git: gitProbe({ resolveIdentity: () => - Effect.succeed(identity({ state: "ok", topLevel: "/var/folders/x/shared", commonDir: "/var/folders/x/shared/.git" })), + Effect.succeed( + identity({ + state: "ok", + topLevel: "/var/folders/x/shared", + commonDir: "/var/folders/x/shared/.git", + }), + ), }), files: { exists: (target) => Effect.succeed(target.endsWith(".git")), @@ -875,7 +886,7 @@ const writePathFormatRejectingGit = (binDir: string, realGit: string) => " process.exit(129);", " }", "}", - "const r = spawnSync(__REAL_GIT__, args, { stdio: \"inherit\" });", + 'const r = spawnSync(__REAL_GIT__, args, { stdio: "inherit" });', "process.exit(r.status ?? 1);", "", ].join("\n"), diff --git a/apps/server/src/provider/launchPreflightConsumer.test.ts b/apps/server/src/provider/launchPreflightConsumer.test.ts index 850f5b59b8c9..3dd67ff75e39 100644 --- a/apps/server/src/provider/launchPreflightConsumer.test.ts +++ b/apps/server/src/provider/launchPreflightConsumer.test.ts @@ -46,7 +46,7 @@ it.effect("W1-D: production settings resolve JSONC snapshot:false to a disabled settings: { ...DEFAULT_SERVER_SETTINGS, providerInstances: { - opencode: { + [ProviderInstanceId.make("opencode")]: { driver: opencode, enabled: true, environment: [ @@ -74,7 +74,7 @@ it.effect("W1-D: the ordinary default keeps snapshots enabled", () => settings: { ...DEFAULT_SERVER_SETTINGS, providerInstances: { - opencode: { driver: opencode, enabled: true }, + [ProviderInstanceId.make("opencode")]: { driver: opencode, enabled: true }, }, }, hostEnv: {}, From 28209dcb66ecb641f300d8304aceef7f44f62973 Mon Sep 17 00:00:00 2001 From: business account Date: Tue, 29 Sep 2026 22:44:03 -0400 Subject: [PATCH 16/18] test(server): reap the launch test's fixture-owned provider stubs The authenticated wire acceptance left provider stub processes orphaned after every run. The T3 server spawns each provider CLI in its own process group, so killing the fixture server does not reach them; they accumulated as orphans (observed 28 from earlier runs on this host). Each stub now records its pid at startup, and a single idempotent teardown kills the server, reaps exactly those recorded fixture-owned pids, and only then removes the state directories. Verified: orphan count delta is 0 across a run (was +2 per run). --- ...envchkP1WireAcceptance.integration.test.ts | 73 ++++++++++++++++--- 1 file changed, 63 insertions(+), 10 deletions(-) diff --git a/apps/server/integration/envchkP1WireAcceptance.integration.test.ts b/apps/server/integration/envchkP1WireAcceptance.integration.test.ts index b7622f787e6f..65e83ddc0bd8 100644 --- a/apps/server/integration/envchkP1WireAcceptance.integration.test.ts +++ b/apps/server/integration/envchkP1WireAcceptance.integration.test.ts @@ -55,6 +55,8 @@ interface Fixture { readonly root: string; readonly fakeDir: string; readonly argvLogPath: string; + /** Each stub invocation appends its pid here so cleanup can reap the tree. */ + readonly pidLogPath: string; readonly wrapperPath: string; } @@ -71,6 +73,7 @@ const makeFixture = (): Effect.Effect => fs.mkdtemp(NodePath.join(NodeOS.tmpdir(), "envchk-p1-fake-")), ); const argvLogPath = NodePath.join(fakeDir, "argv.log"); + const pidLogPath = NodePath.join(fakeDir, "stub-pids.log"); const mockAgentPath = NodePath.join( NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), "../scripts/acp-mock-agent.ts", @@ -78,7 +81,16 @@ const makeFixture = (): Effect.Effect => const wrapperPath = writeFakeCli({ directory: fakeDir, name: "fake-grok-envchk-p1", - source: execScriptSource({ scriptPath: mockAgentPath, argvLogPath }), + // Record this invocation's pid before the mock agent takes over. The T3 + // server spawns each provider CLI in its own process group, so killing the + // server does not reach them; cleanup reads these pids and kills the + // exact fixture-owned processes instead of matching on a name. + source: + 'import { appendFileSync as recordStubPid } from "node:fs";\n' + + "recordStubPid(" + + JSON.stringify(pidLogPath) + + ', String(process.pid) + "\\n");\n' + + execScriptSource({ scriptPath: mockAgentPath, argvLogPath }), }); yield* Effect.promise( () => @@ -88,7 +100,7 @@ const makeFixture = (): Effect.Effect => ); }), ); - return { baseDir, root, fakeDir, argvLogPath, wrapperPath }; + return { baseDir, root, fakeDir, argvLogPath, pidLogPath, wrapperPath }; }).pipe(Effect.orDie); interface SpawnedServer { @@ -193,6 +205,35 @@ const waitForHttp = async (port: number, attempted: () => string): Promise throw new Error(`server never responded; stderr=\n${attempted()}`); }; +/** + * Reaps the provider stub processes a fixture server launched. The server spawns + * each provider CLI in its own process group, so killing the server leaves them + * orphaned; each stub records its pid at startup and this kills exactly those + * fixture-owned pids (never a name or pattern match). Safe on POSIX and Windows. + */ +const killRecordedStubs = async (pidLogPath: string): Promise => { + const fs = await import("node:fs/promises"); + let contents = ""; + try { + contents = await fs.readFile(pidLogPath, "utf8"); + } catch { + return; + } + const pids = new Set( + contents + .split("\n") + .map((line) => Number(line.trim())) + .filter((pid) => Number.isInteger(pid) && pid > 0), + ); + for (const pid of pids) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // Already exited; nothing to reap. + } + } +}; + /** * Terminates a fixture-owned server and awaits its actual exit before its state * directories are removed. Bounded so a wedged process cannot hang cleanup. @@ -289,10 +330,19 @@ it.live( "ENVCHK:P1 authenticated production WebSocket smoke: preflight warning delivered over the wire", () => { let cleanupFixture: Fixture | undefined; - const removeFixture = () => - Effect.promise(async () => { - const fixture = cleanupFixture; - if (fixture === undefined) return; + let cleanupServer: SpawnedServer | undefined; + // Single ordered teardown, safe to run twice and from either the resource + // release or the `ensuring` guard. It kills the server first so it can no + // longer spawn provider stubs, reaps the stubs it orphaned, and only then + // removes the fixture state they were reading. + const teardown = Effect.gen(function* () { + if (cleanupServer !== undefined) { + yield* killAndAwaitExit(cleanupServer.child); + } + const fixture = cleanupFixture; + if (fixture === undefined) return; + yield* Effect.promise(() => killRecordedStubs(fixture.pidLogPath)); + yield* Effect.promise(async () => { const fs = await import("node:fs/promises"); await Promise.all([ fs.rm(fixture.baseDir, { recursive: true, force: true }), @@ -300,6 +350,7 @@ it.live( fs.rm(fixture.root, { recursive: true, force: true }), ]); }); + }); return Effect.gen(function* () { const fixture = yield* makeFixture(); cleanupFixture = fixture; @@ -315,8 +366,9 @@ it.live( port, }), ), - (spawned) => killAndAwaitExit(spawned.child), + () => teardown, ); + cleanupServer = server; yield* Effect.promise(() => waitForHttp(port, server.stderr)); yield* Effect.logInfo(`ENVCHK_P1_HTTP_READY port=${port}`); @@ -583,9 +635,10 @@ it.live( ? Effect.die(new Error("ENVCHK wire smoke timed out")) : Effect.void, ), - // Cleanup runs on success and failure alike: the server is killed and its - // exit awaited before its state directories are removed. - Effect.ensuring(removeFixture()), + // Cleanup runs on success and failure alike. `teardown` is idempotent, so + // the guard here and the resource release both drive the same ordered + // kill-server -> reap-stubs -> remove-state sequence. + Effect.ensuring(teardown), Effect.provide(NodeServices.layer), ); }, From dfb1e8e3a02756b9fe0aa5b462036e1ce811adf2 Mon Sep 17 00:00:00 2001 From: nullStack65 Date: Thu, 1 Oct 2026 01:01:01 -0400 Subject: [PATCH 17/18] fix(server): parse OpenCode JSONC with jsonc-parser Replace the feature's ad-hoc regex JSONC stripping with the maintained jsonc-parser direct dependency, and remove the now-unused shared stripJsonComments/parseLenientJsonUnknown helpers. Preserves snapshot true/false/undefined semantics and the external-server branch. --- apps/server/package.json | 1 + .../provider/launchPreflightConsumer.test.ts | 45 +++++++++++++++++++ .../src/provider/launchPreflightConsumer.ts | 28 ++++++++---- packages/shared/src/schemaJson.ts | 30 ++----------- pnpm-lock.yaml | 3 ++ 5 files changed, 73 insertions(+), 34 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index b7f1bce88768..7e557a257a02 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -30,6 +30,7 @@ "@opencode-ai/sdk": "^1.3.15", "diff": "8.0.3", "effect": "catalog:", + "jsonc-parser": "^3.3.1", "node-pty": "^1.1.0", "stream-chain": "^4.2.5", "stream-json": "3.6.0", diff --git a/apps/server/src/provider/launchPreflightConsumer.test.ts b/apps/server/src/provider/launchPreflightConsumer.test.ts index 3dd67ff75e39..b1e4019b4b3c 100644 --- a/apps/server/src/provider/launchPreflightConsumer.test.ts +++ b/apps/server/src/provider/launchPreflightConsumer.test.ts @@ -27,6 +27,48 @@ it.effect("W1-D: honors inline JSONC without comments or trailing commas misread }), ); +it.effect("W1-D: JSONC markers inside strings and escaped characters never alter detection", () => + Effect.gen(function* () { + // Comment markers inside a quoted value are data, not syntax. + assert.strictEqual( + openCodeSnapshotsEnabled('{"note":"// not a comment","snapshot":false}'), + false, + ); + assert.strictEqual( + openCodeSnapshotsEnabled('{"note":"/* not a block comment */","snapshot":true}'), + true, + ); + // Bracket/comma markers inside a string must not be read as trailing commas. + assert.strictEqual(openCodeSnapshotsEnabled('{"note":"a, ] } ,","snapshot":false}'), false); + // Escaped quotes and a trailing escaped backslash. + assert.strictEqual( + openCodeSnapshotsEnabled('{"note":"a \\"quoted\\" value","snapshot":false}'), + false, + ); + assert.strictEqual( + openCodeSnapshotsEnabled('{"note":"ends with a backslash \\\\","snapshot":true}'), + true, + ); + }), +); + +it.effect("W1-D: comments and trailing commas combine with in-string markers", () => + Effect.gen(function* () { + const config = [ + "{", + ' // keep the "snapshot" key addressable', + ' "note": "/* not a comment */ and a trailing comma , }",', + " /* block comment */", + ' "snapshot": false,', + "}", + ].join("\n"); + assert.strictEqual(openCodeSnapshotsEnabled(config), false); + + // A trailing comma after a value whose string ends in a backslash. + assert.strictEqual(openCodeSnapshotsEnabled('{"snapshot": true,\n}'), true); + }), +); + it.effect("W1-D: unknown configuration is never asserted enabled", () => Effect.gen(function* () { assert.strictEqual(openCodeSnapshotsEnabled("not json at all"), undefined); @@ -35,6 +77,9 @@ it.effect("W1-D: unknown configuration is never asserted enabled", () => assert.strictEqual(openCodeSnapshotsEnabled('"a string"'), undefined); // A non-boolean snapshot value is not a definite enable. assert.strictEqual(openCodeSnapshotsEnabled('{"snapshot":"off"}'), undefined); + // A malformed document is unknown, never a best-effort partial read. + assert.strictEqual(openCodeSnapshotsEnabled('{"snapshot":false,} trailing'), undefined); + assert.strictEqual(openCodeSnapshotsEnabled("{ /* unterminated"), undefined); }), ); diff --git a/apps/server/src/provider/launchPreflightConsumer.ts b/apps/server/src/provider/launchPreflightConsumer.ts index c2cffd79d35a..c51ade8be692 100644 --- a/apps/server/src/provider/launchPreflightConsumer.ts +++ b/apps/server/src/provider/launchPreflightConsumer.ts @@ -19,8 +19,8 @@ import { OpenCodeSettings, type ServerSettings, } from "@t3tools/contracts"; -import { parseLenientJsonUnknown } from "@t3tools/shared/schemaJson"; import * as Schema from "effect/Schema"; +import { type ParseError, parse as parseJsonc } from "jsonc-parser"; import type { LaunchPreflightConsumer } from "../environment/LaunchPreflight.ts"; import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; @@ -30,17 +30,29 @@ const decodeOpenCodeSettings = Schema.decodeUnknownOption(OpenCodeSettings); const OPENCODE_DRIVER: ProviderDriverKind = "opencode" as ProviderDriverKind; +/** + * Parses inline OpenCode configuration, which accepts JSONC (comments and + * trailing commas), with the maintained `jsonc-parser` rather than an ad-hoc + * regular expression. A parse error is treated as unknown configuration rather + * than a best-effort partial value. + */ +const parseOpenCodeConfig = (configContent: string): unknown => { + const errors: Array = []; + const parsed: unknown = parseJsonc(configContent, errors, { allowTrailingComma: true }); + return errors.length > 0 ? undefined : parsed; +}; + /** * Whether the effective OpenCode config still stages Git snapshots. OpenCode - * accepts inline JSONC (comments and trailing commas), so the value is parsed - * with the shared lenient parser rather than `JSON.parse`. An explicit boolean - * `false` disables the provider-specific requirement and an explicit `true` - * keeps it; any value that cannot be read as a boolean (unparseable content, a - * non-object, or a non-boolean `snapshot`) is `undefined` — unknown, never - * asserted enabled. This intentionally does not search config files or call out. + * accepts inline JSONC, so the value is parsed with a supported parser. An + * explicit boolean `false` disables the provider-specific requirement and an + * explicit `true` keeps it; any value that cannot be read as a boolean + * (unparseable content, a non-object, or a non-boolean `snapshot`) is + * `undefined` — unknown, never asserted enabled. This intentionally does not + * search config files or call out. */ export const openCodeSnapshotsEnabled = (configContent: string): boolean | undefined => { - const parsed = parseLenientJsonUnknown(configContent); + const parsed = parseOpenCodeConfig(configContent); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined; const snapshot = (parsed as { readonly snapshot?: unknown }).snapshot; if (snapshot === false) return false; diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index c153799dbf21..076ff67b62df 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -164,11 +164,7 @@ export const formatSchemaError = (cause: Cause.Cause) => { */ const decodeJsonString = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); -/** - * Strips JSONC syntax (JS-style comments and trailing commas) while leaving - * quoted string contents untouched, producing strict JSON text. - */ -export const stripJsonComments = (input: string): string => { +const parseLenientJsonGetter = SchemaGetter.onSome((input: string) => { // Strip single-line comments - alternation preserves quoted strings. let stripped = input.replace( /("(?:[^"\\]|\\.)*")|\/\/[^\n]*/g, @@ -190,29 +186,11 @@ export const stripJsonComments = (input: string): string => { stringLiteral ? match : (bracket ?? ""), ); - return stripped; -}; - -/** - * Parses a lenient JSON string (tolerating comments and trailing commas) into - * an `unknown` value synchronously, returning `undefined` when the value cannot - * be parsed. Small in-memory configuration values that are validated by their - * consumer should use this rather than a strict `JSON.parse`. - */ -export const parseLenientJsonUnknown = (input: string): unknown | undefined => { - try { - return JSON.parse(stripJsonComments(input)); - } catch { - return undefined; - } -}; - -const parseLenientJsonGetter = SchemaGetter.onSome((input: string) => - decodeJsonString(stripJsonComments(input)).pipe( + return decodeJsonString(stripped).pipe( Effect.map(Option.some), Effect.mapError((error) => error.issue), - ), -); + ); +}); /** * Schema transformation: lenient JSONC string ↔ unknown. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d59982535f9c..b6abe9c43548 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -528,6 +528,9 @@ importers: effect: specifier: 4.0.0-rc.115 version: 4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5) + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 node-pty: specifier: ^1.1.0 version: 1.1.0 From d3f205d13d228f0c6a4987bbcd7e20ecbda965ac Mon Sep 17 00:00:00 2001 From: nullStack65 Date: Thu, 1 Oct 2026 01:01:09 -0400 Subject: [PATCH 18/18] test(server): prove fixture-owned process exit before cleanup Cleanup now confirms the captured server and captured fixture-owned stub PIDs have exited within a bounded window before removing fixture state, and fails clearly preserving state on a termination/confirmation timeout. Adds a focused normal + forced-timeout cleanup regression. --- ...envchkP1WireAcceptance.integration.test.ts | 317 +++++++++++++++--- 1 file changed, 267 insertions(+), 50 deletions(-) diff --git a/apps/server/integration/envchkP1WireAcceptance.integration.test.ts b/apps/server/integration/envchkP1WireAcceptance.integration.test.ts index 65e83ddc0bd8..fc193e57d1da 100644 --- a/apps/server/integration/envchkP1WireAcceptance.integration.test.ts +++ b/apps/server/integration/envchkP1WireAcceptance.integration.test.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off globalFetch:off globalTimers:off preferSchemaOverJson:off - this test owns a real temp workspace and a real server process. +// @effect-diagnostics nodeBuiltinImport:off globalFetch:off globalTimers:off globalDate:off preferSchemaOverJson:off - this test owns a real temp workspace and a real server process. import { CommandId, MessageId, @@ -205,55 +205,178 @@ const waitForHttp = async (port: number, attempted: () => string): Promise throw new Error(`server never responded; stderr=\n${attempted()}`); }; +const CLEANUP_TIMEOUT_MS = 10_000; +const CLEANUP_POLL_MS = 50; + /** - * Reaps the provider stub processes a fixture server launched. The server spawns - * each provider CLI in its own process group, so killing the server leaves them - * orphaned; each stub records its pid at startup and this kills exactly those - * fixture-owned pids (never a name or pattern match). Safe on POSIX and Windows. + * Termination/probe seam. The defaults signal and observe real OS processes by + * the exact pids the fixture recorded; a regression test injects a signal that + * never reports exit to exercise the bounded-failure path without a real kill. */ -const killRecordedStubs = async (pidLogPath: string): Promise => { +interface OwnedProcessSignal { + readonly terminate: (pid: number) => void; + readonly isAlive: (pid: number) => boolean; +} + +const defaultOwnedProcessSignal: OwnedProcessSignal = { + terminate: (pid) => process.kill(pid, "SIGKILL"), + isAlive: (pid) => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ESRCH"; + } + }, +}; + +const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +const readRecordedStubPids = async (pidLogPath: string): Promise> => { const fs = await import("node:fs/promises"); let contents = ""; try { contents = await fs.readFile(pidLogPath, "utf8"); } catch { - return; + return []; } - const pids = new Set( - contents - .split("\n") - .map((line) => Number(line.trim())) - .filter((pid) => Number.isInteger(pid) && pid > 0), - ); + return [ + ...new Set( + contents + .split("\n") + .map((line) => Number(line.trim())) + .filter((pid) => Number.isInteger(pid) && pid > 0), + ), + ]; +}; + +/** + * Sends the termination signal to exact captured fixture-owned pids and waits + * until every one is confirmed gone. Never matches a name or pattern, so + * installed T3/provider processes are untouched. Rejects with the still-live + * pids if the bound elapses. + */ +const terminateAndConfirmPidsExited = async ( + pids: ReadonlyArray, + options: { + readonly timeoutMs?: number | undefined; + readonly signal?: OwnedProcessSignal | undefined; + } = {}, +): Promise => { + const timeoutMs = options.timeoutMs ?? CLEANUP_TIMEOUT_MS; + const signal = options.signal ?? defaultOwnedProcessSignal; for (const pid of pids) { try { - process.kill(pid, "SIGKILL"); + signal.terminate(pid); } catch { // Already exited; nothing to reap. } } + const deadline = Date.now() + timeoutMs; + let remaining = pids.filter((pid) => signal.isAlive(pid)); + while (remaining.length > 0 && Date.now() < deadline) { + await delay(CLEANUP_POLL_MS); + remaining = remaining.filter((pid) => signal.isAlive(pid)); + } + if (remaining.length > 0) { + throw new Error(`owned process(es) still alive after ${timeoutMs}ms: ${remaining.join(", ")}`); + } }; /** - * Terminates a fixture-owned server and awaits its actual exit before its state - * directories are removed. Bounded so a wedged process cannot hang cleanup. + * Terminates the captured fixture server and awaits its actual exit. Bounded so + * a wedged process fails cleanup rather than hanging it. */ -const killAndAwaitExit = (child: NodeChildProcess.ChildProcess): Effect.Effect => - Effect.promise( - () => - new Promise((resolve) => { - if (child.exitCode !== null || child.signalCode !== null) { - resolve(); - return; - } - const timer = setTimeout(() => resolve(), 10_000); - child.once("exit", () => { - clearTimeout(timer); - resolve(); - }); - child.kill("SIGKILL"); - }), - ); +const terminateAndConfirmChildExit = ( + child: NodeChildProcess.ChildProcess, + options: { + readonly timeoutMs?: number | undefined; + readonly terminate?: ((child: NodeChildProcess.ChildProcess) => void) | undefined; + } = {}, +): Promise => + new Promise((resolve, reject) => { + const timeoutMs = options.timeoutMs ?? CLEANUP_TIMEOUT_MS; + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + let timer: NodeJS.Timeout; + const onExit = () => { + clearTimeout(timer); + resolve(); + }; + timer = setTimeout(() => { + child.removeListener("exit", onExit); + reject( + new Error( + `captured server pid ${child.pid ?? "unknown"} did not exit within ${timeoutMs}ms`, + ), + ); + }, timeoutMs); + child.once("exit", onExit); + try { + (options.terminate ?? ((target) => target.kill("SIGKILL")))(child); + } catch (error) { + clearTimeout(timer); + child.removeListener("exit", onExit); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + +interface FixtureCleanupInput { + readonly serverChild: NodeChildProcess.ChildProcess | undefined; + readonly fixture: Fixture | undefined; + readonly timeoutMs?: number; + readonly signal?: OwnedProcessSignal; + readonly terminateChild?: (child: NodeChildProcess.ChildProcess) => void; +} + +/** + * Ordered teardown: terminate the captured server so it can no longer spawn + * stubs, then reap the exact pids the fixture recorded, and only after every + * owned process is confirmed exited remove the fixture state. On any + * termination/confirmation failure it throws and deliberately leaves the + * fixture directories in place for diagnosis rather than reporting clean + * cleanup. + */ +const cleanupFixtureProcesses = async (input: FixtureCleanupInput): Promise => { + const errors: Array = []; + if (input.serverChild !== undefined) { + try { + await terminateAndConfirmChildExit(input.serverChild, { + timeoutMs: input.timeoutMs, + terminate: input.terminateChild, + }); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + } + const fixture = input.fixture; + if (fixture !== undefined) { + try { + const pids = await readRecordedStubPids(fixture.pidLogPath); + await terminateAndConfirmPidsExited(pids, { + timeoutMs: input.timeoutMs, + signal: input.signal, + }); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + } + if (errors.length > 0) { + throw new Error( + `ENVCHK fixture cleanup failed; preserving fixture state for diagnosis: ${errors.join("; ")}`, + ); + } + if (fixture !== undefined) { + const fs = await import("node:fs/promises"); + await Promise.all([ + fs.rm(fixture.baseDir, { recursive: true, force: true }), + fs.rm(fixture.fakeDir, { recursive: true, force: true }), + fs.rm(fixture.root, { recursive: true, force: true }), + ]); + } +}; const bootstrapCookie = async (port: number): Promise => { const response = await fetch(`http://127.0.0.1:${port}/api/auth/browser-session`, { @@ -331,25 +454,18 @@ it.live( () => { let cleanupFixture: Fixture | undefined; let cleanupServer: SpawnedServer | undefined; - // Single ordered teardown, safe to run twice and from either the resource - // release or the `ensuring` guard. It kills the server first so it can no - // longer spawn provider stubs, reaps the stubs it orphaned, and only then - // removes the fixture state they were reading. - const teardown = Effect.gen(function* () { - if (cleanupServer !== undefined) { - yield* killAndAwaitExit(cleanupServer.child); - } - const fixture = cleanupFixture; - if (fixture === undefined) return; - yield* Effect.promise(() => killRecordedStubs(fixture.pidLogPath)); - yield* Effect.promise(async () => { - const fs = await import("node:fs/promises"); - await Promise.all([ - fs.rm(fixture.baseDir, { recursive: true, force: true }), - fs.rm(fixture.fakeDir, { recursive: true, force: true }), - fs.rm(fixture.root, { recursive: true, force: true }), - ]); + // Single ordered teardown, memoized so the resource release and the + // `ensuring` guard drive the same run: terminate the server (so it can no + // longer spawn stubs), reap the exact recorded stub pids, and remove state + // only after every owned process is confirmed exited. A failure leaves the + // fixture directories in place for diagnosis. + let teardownPromise: Promise | undefined; + const teardown = Effect.promise(() => { + teardownPromise ??= cleanupFixtureProcesses({ + serverChild: cleanupServer?.child, + fixture: cleanupFixture, }); + return teardownPromise; }); return Effect.gen(function* () { const fixture = yield* makeFixture(); @@ -643,3 +759,104 @@ it.live( ); }, ); + +it.live( + "ENVCHK:E7 fixture cleanup proves owned-process exit before removing state (normal + forced timeout)", + () => + Effect.gen(function* () { + const fs = yield* Effect.promise(() => import("node:fs/promises")); + + const makeCleanupFixture = (): Effect.Effect => + Effect.promise(async () => { + const baseDir = await fs.mkdtemp( + NodePath.join(NodeOS.tmpdir(), "envchk-e7-cleanup-base-"), + ); + const root = await fs.mkdtemp(NodePath.join(NodeOS.tmpdir(), "envchk-e7-cleanup-root-")); + const fakeDir = await fs.mkdtemp( + NodePath.join(NodeOS.tmpdir(), "envchk-e7-cleanup-fake-"), + ); + return { + baseDir, + root, + fakeDir, + argvLogPath: NodePath.join(fakeDir, "argv.log"), + pidLogPath: NodePath.join(fakeDir, "stub-pids.log"), + wrapperPath: "", + }; + }); + + const spawnSleeper = (): Effect.Effect => + Effect.promise(() => + Promise.resolve( + NodeChildProcess.spawn(process.execPath, ["-e", "setInterval(() => {}, 1000);"], { + stdio: "ignore", + }), + ), + ); + + const awaitChildExit = (child: NodeChildProcess.ChildProcess): Effect.Effect => + Effect.promise( + () => + new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + child.once("exit", () => resolve()); + }), + ); + + const removeFixtureState = (fixture: Fixture): Effect.Effect => + Effect.promise(() => + Promise.all([ + fs.rm(fixture.baseDir, { recursive: true, force: true }), + fs.rm(fixture.fakeDir, { recursive: true, force: true }), + fs.rm(fixture.root, { recursive: true, force: true }), + ]), + ); + + // Normal completion: a captured live stub pid is terminated and confirmed + // gone, and only then is the fixture state removed. + const normalFixture = yield* makeCleanupFixture(); + const normalChild = yield* spawnSleeper(); + yield* Effect.promise(() => + fs.writeFile(normalFixture.pidLogPath, `${normalChild.pid ?? 0}\n`, "utf8"), + ); + yield* Effect.promise(() => + cleanupFixtureProcesses({ serverChild: undefined, fixture: normalFixture }), + ); + assert.strictEqual(NodeFS.existsSync(normalFixture.baseDir), false); + assert.strictEqual(NodeFS.existsSync(normalFixture.fakeDir), false); + assert.strictEqual(NodeFS.existsSync(normalFixture.root), false); + + // Forced timeout: a captured pid that never reports exit must fail cleanup + // clearly and preserve the fixture directories for diagnosis. + const stuckFixture = yield* makeCleanupFixture(); + const stuckChild = yield* spawnSleeper(); + yield* Effect.promise(() => + fs.writeFile(stuckFixture.pidLogPath, `${stuckChild.pid ?? 0}\n`, "utf8"), + ); + const neverExits: OwnedProcessSignal = { terminate: () => {}, isAlive: () => true }; + const failure = yield* Effect.promise(() => + cleanupFixtureProcesses({ + serverChild: undefined, + fixture: stuckFixture, + timeoutMs: 150, + signal: neverExits, + }).then( + () => undefined, + (error: unknown) => error, + ), + ); + assert.instanceOf(failure, Error); + assert.include((failure as Error).message, "preserving fixture state"); + assert.strictEqual(NodeFS.existsSync(stuckFixture.baseDir), true); + assert.strictEqual(NodeFS.existsSync(stuckFixture.fakeDir), true); + assert.strictEqual(NodeFS.existsSync(stuckFixture.root), true); + + // Real teardown of the deliberately-stuck fixture so this test leaks nothing. + stuckChild.kill("SIGKILL"); + yield* awaitChildExit(stuckChild); + yield* removeFixtureState(stuckFixture); + }), +);