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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions apps/server/src/orchestration-v2/CheckpointService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ThreadId,
type OrchestrationV2CheckpointScope,
VcsProcessTimeoutError,
VcsUnsupportedOperationError,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -103,3 +104,80 @@ it.effect.each([false, true, "interrupt"] as const)(
}).pipe(Effect.provide(layerTest));
},
);

it.effect(
"skips unsupported baseline and turn checkpoints without swallowing capture failures",
() =>
Effect.gen(function* () {
const scope: OrchestrationV2CheckpointScope = {
id: CheckpointScopeId.make("checkpoint-scope:capture-failure"),
threadId: ThreadId.make("thread:capture-failure"),
runId: RunId.make("run:capture-failure:1"),
nodeId: NodeId.make("node:capture-failure:1"),
parentScopeId: null,
providerThreadId: ProviderThreadId.make("provider-thread:capture-failure"),
kind: "root_run",
ordinalWithinParent: 0,
advancesAppRunCount: true,
cwd: "/repo/ignored",
createdAt: DateTime.makeUnsafe("2026-07-28T00:00:00.000Z"),
};
for (const captureError of [
new VcsProcessTimeoutError({
operation: "test.capture",
command: "git",
cwd: scope.cwd,
timeoutMs: 30000,
}),
new VcsUnsupportedOperationError({
operation: "test.capture",
kind: "git",
detail: "The workspace directory is ignored by its Git repository.",
}),
]) {
const layerTest = CheckpointService.layer.pipe(
Layer.provide(
Layer.mergeAll(
IdAllocator.layer,
Layer.mock(CheckpointStore.CheckpointStore)({
isGitRepository: () => Effect.succeed(true),
hasCheckpointRef: () => Effect.succeed(false),
captureCheckpoint: () => Effect.fail(captureError),
}),
),
),
Layer.provideMerge(NodeCrypto.layer),
);
yield* Effect.gen(function* () {
const checkpoints = yield* CheckpointService.CheckpointServiceV2;
const baseline = yield* Effect.result(
checkpoints.captureBaseline({ scope, ordinalWithinScope: 0 }),
);
const checkpoint = yield* checkpoints.capture({
scope,
ordinalWithinScope: 1,
runId: scope.runId!,
nodeId: scope.nodeId!,
appRunOrdinal: 1,
capturedAt: scope.createdAt,
});

assert.deepEqual(
{ baseline: baseline._tag, turn: checkpoint.status, files: checkpoint.files },
captureError._tag === "VcsUnsupportedOperationError"
? { baseline: "Success", turn: "missing", files: [] }
: { baseline: "Failure", turn: "error", files: [] },
);
if (baseline._tag === "Failure") {
assert.strictEqual(baseline.failure.cause, captureError);
} else {
const materialized = yield* checkpoints.materializeBaselineCheckpoint({
scope,
ordinalWithinScope: 0,
});
assert.strictEqual(materialized.status, "missing");
}
}).pipe(Effect.provide(layerTest));
}
}),
);
27 changes: 18 additions & 9 deletions apps/server/src/orchestration-v2/CheckpointService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,10 +284,16 @@ export const layer: Layer.Layer<
return;
}

yield* checkpointStore.captureCheckpoint({
cwd: input.scope.cwd,
checkpointRef,
});
yield* checkpointStore
.captureCheckpoint({
cwd: input.scope.cwd,
checkpointRef,
})
.pipe(
Effect.catchTags({
VcsUnsupportedOperationError: () => Effect.void,
}),
);
}),
).pipe(
Effect.mapError(
Expand Down Expand Up @@ -394,23 +400,26 @@ export const layer: Layer.Layer<
});
}

const captured = yield* checkpointStore
const captureStatus = yield* checkpointStore
.captureCheckpoint({
cwd: input.scope.cwd,
checkpointRef,
})
.pipe(
Effect.as(true),
Effect.as("ready" as const),
Effect.catchTags({
VcsUnsupportedOperationError: () => Effect.succeed("missing" as const),
}),
Effect.catch((cause) =>
Effect.logWarning("orchestration V2 checkpoint capture failed", {
scopeId: input.scope.id,
checkpointRef,
cause: String(cause),
}).pipe(Effect.as(false)),
}).pipe(Effect.as("error" as const)),
),
);

if (!captured) {
if (captureStatus !== "ready") {
return makeCheckpoint({
id: checkpointId,
scope: input.scope,
Expand All @@ -420,7 +429,7 @@ export const layer: Layer.Layer<
ordinalWithinScope: input.ordinalWithinScope,
appRunOrdinal: input.appRunOrdinal,
ref: checkpointRef,
status: "error",
status: captureStatus,
files: [],
capturedAt: input.capturedAt,
});
Expand Down
95 changes: 95 additions & 0 deletions apps/server/src/vcs/GitVcsDriver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,9 @@ it.effect("checkpoint capture still fails when a clean filter rejects a file", (
);

assert.strictEqual(result._tag, "Failure");
if (result._tag === "Failure") {
assert.strictEqual(result.failure._tag, "VcsProcessExitError");
}
assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex);
assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef }));
}).pipe(Effect.scoped, Effect.provide(layerGitContract)),
Expand Down Expand Up @@ -299,6 +302,98 @@ it.effect("checkpoint capture refuses a truncated nested repository listing", ()
}).pipe(Effect.scoped, Effect.provide(layerGitContract)),
);

it.effect("checkpoint capture skips an ignored project while keeping its allowed sibling", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const driver = yield* GitVcsDriver.makeVcsDriverShape();
const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-ignored-" });
const { git } = yield* makeCheckpointFixture(driver, parent);
yield* fs.writeFileString(
path.join(parent, ".gitignore"),
"*\n!.gitignore\n!file.txt\n!keep/\n!keep/**\n",
);
const ignoredCwd = path.join(parent, "ignored");
const keepCwd = path.join(parent, "keep");
yield* fs.makeDirectory(ignoredCwd);
yield* fs.makeDirectory(keepCwd);
yield* fs.writeFileString(path.join(ignoredCwd, "secret.txt"), "private\n");
yield* fs.writeFileString(path.join(keepCwd, "new.txt"), "new\n");
const originalIndex = yield* fs.readFile(path.join(parent, ".git", "index"));
const ignoredRef = CheckpointRef.make("refs/t3/checkpoints/ignored");
const keepRef = CheckpointRef.make("refs/t3/checkpoints/keep");

const ignored = yield* Effect.result(
driver.checkpoints.captureCheckpoint({ cwd: ignoredCwd, checkpointRef: ignoredRef }),
);
yield* driver.checkpoints.captureCheckpoint({ cwd: keepCwd, checkpointRef: keepRef });

assert.strictEqual((yield* git(["show", `${keepRef}:keep/new.txt`])).stdout, "new\n");
assert.strictEqual((yield* git(["ls-tree", "-r", keepRef, "--", "ignored"])).stdout, "");
assert.isFalse(
yield* driver.checkpoints.hasCheckpointRef({ cwd: parent, checkpointRef: ignoredRef }),
);
assert.deepEqual(yield* fs.readFile(path.join(parent, ".git", "index")), originalIndex);
assert.strictEqual(ignored._tag, "Failure");
if (ignored._tag === "Failure") {
assert.strictEqual(ignored.failure._tag, "VcsUnsupportedOperationError");
}
}).pipe(Effect.scoped, Effect.provide(layerGitContract)),
);

it.effect.each([
{ tracking: "committed-only", committed: ["committed.txt"], staged: [] },
{ tracking: "staged-only", committed: [], staged: ["staged.txt"] },
{ tracking: "mixed", committed: ["committed.txt"], staged: ["staged.txt"] },
])(
"checkpoint capture keeps $tracking tracked edits but excludes new files in a project ignored later",
({ committed, staged }) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const driver = yield* GitVcsDriver.makeVcsDriverShape();
const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-ignored-later-" });
const { git, checkpointRef } = yield* makeCheckpointFixture(driver, parent);
const cwd = path.join(parent, "sub");
yield* fs.makeDirectory(cwd);
for (const file of committed) {
yield* fs.writeFileString(path.join(cwd, file), "committed\n");
yield* git(["add", `sub/${file}`]);
}
if (committed.length > 0) {
yield* git(["commit", "-m", "track project"]);
}
for (const file of staged) {
yield* fs.writeFileString(path.join(cwd, file), "staged\n");
yield* git(["add", `sub/${file}`]);
}
const tracked = [...committed, ...staged];
yield* fs.writeFileString(path.join(parent, ".gitignore"), "sub/\n");
for (const file of tracked) {
yield* fs.writeFileString(path.join(cwd, file), `${file} working\n`);
}
yield* fs.writeFileString(path.join(cwd, "secret.txt"), "private\n");
const originalIndex = yield* fs.readFile(path.join(parent, ".git", "index"));

const captured = yield* Effect.result(
driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }),
);

assert.deepEqual(yield* fs.readFile(path.join(parent, ".git", "index")), originalIndex);
if (captured._tag === "Failure") return yield* captured.failure;
assert.strictEqual(
(yield* git(["ls-tree", "-r", "--name-only", checkpointRef, "--", "sub"])).stdout,
tracked.map((file) => `sub/${file}\n`).join(""),
);
for (const file of tracked) {
assert.strictEqual(
(yield* git(["show", `${checkpointRef}:sub/${file}`])).stdout,
`${file} working\n`,
);
}
}).pipe(Effect.scoped, Effect.provide(layerGitContract)),
);

it.effect("checkpoint recovery refuses excessive candidates before probing", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
62 changes: 61 additions & 1 deletion apps/server/src/vcs/GitVcsDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ChildProcessSpawner } from "effect/process";
import {
GitCommandError,
VcsProcessExitError,
VcsUnsupportedOperationError,
type VcsSwitchRefInput,
type VcsSwitchRefResult,
type VcsCreateRefInput,
Expand Down Expand Up @@ -965,6 +966,64 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
}
}

const workspaceIgnored = (yield* filterIgnoredPaths(input.cwd, ["."])).length === 0;
if (workspaceIgnored) {
// HEAD omits staged-only paths. Seed just this workspace's additions, keeping
// unrelated staged changes and the scratch index's sparse flags untouched.
const additions = yield* execute({
operation,
cwd: input.cwd,
args: [
"-c",
"core.quotePath=true",
"diff",
"--cached",
"--raw",
"--no-abbrev",
"--no-renames",
"--no-relative",
"--ignore-submodules=none",
"--no-color",
"--no-ext-diff",
"--no-textconv",
"--ita-visible-in-index",
"--diff-filter=A",
"--",
".",
],
maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES,
outputMode: "error",
});
if (additions.stdout.length > 0) {
// Git's quoted paths round-trip through index-info, including non-UTF-8 names.
yield* execute({
operation,
cwd: input.cwd,
args: [...indexConfig, "-c", "index.sparse=false", "update-index", "--index-info"],
stdin: additions.stdout.replace(
/^:000000 ([0-7]{6}) 0+ ([0-9a-f]+) A\t/gm,
"$1 $2\t",
),
env: commitEnv,
});
}
const tracked = yield* execute({
operation,
cwd: input.cwd,
args: [...indexConfig, "ls-files", "--cached", "-z", "--", "."],
env: commitEnv,
maxOutputBytes: 1,
outputMode: "truncate",
});
if (tracked.stdout.length === 0) {
return yield* new VcsUnsupportedOperationError({
operation,
kind: "git",
detail: "The workspace directory is ignored by its Git repository.",
});
}
}

const stageFiles = (exclusions: ReadonlyArray<string>) =>
execute({
operation,
Expand All @@ -975,7 +1034,8 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
...longPaths,
"add",
...(sparseCheckout ? ["--sparse"] : []),
"-A",
// add -A rejects ignored directories even when they contain tracked files.
workspaceIgnored ? "-u" : "-A",
"--",
".",
...exclusions,
Expand Down
Loading