Skip to content
3 changes: 3 additions & 0 deletions apps/desktop/src/shell/DesktopShellEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,13 +249,16 @@ describe("DesktopShellEnvironment", () => {
"C:\\Windows\\System32",
"C:\\Program Files\\Git\\cmd",
"C:\\Program Files\\GitHub CLI",
"C:\\Program Files\\nodejs",
"C:\\Users\\testuser\\AppData\\Roaming\\npm",
"C:\\Users\\testuser\\AppData\\Local\\Microsoft\\WindowsApps",
"C:\\Users\\testuser\\AppData\\Local\\Programs\\Git\\cmd",
"C:\\Users\\testuser\\AppData\\Local\\Programs\\GitHub CLI",
"C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
"C:\\Users\\testuser\\AppData\\Local\\Programs\\OpenAI\\Codex\\bin",
"C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
"C:\\Users\\testuser\\AppData\\Local\\pnpm",
"C:\\Users\\testuser\\.local\\bin",
"C:\\Users\\testuser\\.bun\\bin",
"C:\\Users\\testuser\\scoop\\shims",
"C:\\Custom\\Bin",
Expand Down
20 changes: 5 additions & 15 deletions apps/desktop/src/shell/DesktopShellEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import { hideWindowsConsole } from "@threadlines/shared/childProcess";
import { resolveKnownWindowsCliDirs } from "@threadlines/shared/shell";
import {
buildWindowsEnvironmentCaptureCommand,
resolveKnownWindowsCliDirs,
} from "@threadlines/shared/shell";

import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";

Expand Down Expand Up @@ -120,19 +123,6 @@ const capturePosixEnvironmentCommand = (names: ReadonlyArray<string>) =>
})
.join("; ");

const captureWindowsEnvironmentCommand = (names: ReadonlyArray<string>) =>
[
"$ErrorActionPreference = 'Stop'",
...names.flatMap((name) => {
return [
`Write-Output '${startMarker(name)}'`,
`$value = [Environment]::GetEnvironmentVariable('${name}')`,
"if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }",
`Write-Output '${endMarker(name)}'`,
];
}),
].join("; ");

const extractEnvironment = (output: string, names: ReadonlyArray<string>): EnvironmentPatch => {
const environment: EnvironmentPatch = {};

Expand Down Expand Up @@ -219,7 +209,7 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn
...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)),
"-NonInteractive",
"-Command",
captureWindowsEnvironmentCommand(names),
buildWindowsEnvironmentCaptureCommand(names),
];

for (const command of WINDOWS_SHELL_CANDIDATES) {
Expand Down
19 changes: 18 additions & 1 deletion apps/server/src/atomicWrite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { randomUUIDv4 } from "@threadlines/shared/uuid";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schedule from "effect/Schedule";

export const writeFileStringAtomically = (input: {
readonly filePath: string;
Expand All @@ -22,6 +23,22 @@ export const writeFileStringAtomically = (input: {
const tempPath = path.join(tempDirectory, `${tempFileId}.tmp`);

yield* fs.writeFileString(tempPath, input.contents);
yield* fs.rename(tempPath, input.filePath);
// Windows readers and virus scanners can briefly block replacement.
// Retry the rename while keeping both the old file and completed temp file.
yield* fs.rename(tempPath, input.filePath).pipe(
Effect.retry({
times: 10,
schedule: Schedule.spaced("50 millis"),
while: (error) => {
const cause = error.cause;
return (
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
(cause.code === "EPERM" || cause.code === "EACCES" || cause.code === "EBUSY")
);
},
}),
);
}),
);
Original file line number Diff line number Diff line change
Expand Up @@ -1774,7 +1774,7 @@ describe("CheckpointReactor", () => {
).toBe(false);
});

it("executes provider revert and emits thread.reverted for claude sessions", async () => {
it("rewinds Claude to the removed user message after a clock rollback", async () => {
const harness = await createHarness({
providerName: ProviderDriverKind.make("claudeAgent"),
projectWorkspaceRoot: path.join(os.tmpdir(), "t3-isolated-project-root-claude"),
Expand Down Expand Up @@ -1814,7 +1814,7 @@ describe("CheckpointReactor", () => {
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: "2026-01-01T00:00:01.000Z",
createdAt: "2026-01-01T00:00:10.000Z",
}),
);
await Effect.runPromise(
Expand Down
7 changes: 3 additions & 4 deletions apps/server/src/orchestration/Layers/CheckpointReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as Option from "effect/Option";
import * as Stream from "effect/Stream";
import { makeDrainableWorker } from "@threadlines/shared/DrainableWorker";
import { normalizeWorkspacePath } from "@threadlines/shared/path";
import { compareTranscriptOrder } from "@threadlines/shared/transcriptOrder";

import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts";
import { normalizeCheckpointFilePath } from "../../checkpointing/SelectiveRevert.ts";
Expand Down Expand Up @@ -116,16 +117,14 @@ function targetUserMessageIdForCheckpointRewind(input: {
readonly id: MessageId;
readonly role: string;
readonly createdAt: string;
readonly eventSequence?: number | undefined;
}>;
};
readonly targetTurnCount: number;
}): MessageId | undefined {
const userMessages = input.thread.messages
.filter((message) => message.role === "user")
.toSorted(
(left, right) =>
left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
);
.toSorted(compareTranscriptOrder);

// Native provider file checkpointing rewinds to the state at a user message.
// To keep turns 0..N, target the first user message being removed: N + 1.
Expand Down
229 changes: 229 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
import {
CheckpointRef,
CommandId,
Expand Down Expand Up @@ -2778,3 +2779,231 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
}),
);
});

it.effect("keeps first transcript event order after clock rollback, updates, and restart", () =>
Effect.gen(function* () {
const { dbPath } = yield* ServerConfig;
const persistence = makeSqlitePersistenceLive(dbPath);
const makeLayer = () =>
Layer.mergeAll(
OrchestrationProjectionPipelineLive,
OrchestrationProjectionSnapshotQueryLive,
).pipe(
Layer.provideMerge(OrchestrationEventStoreLive),
Layer.provide(RepositoryIdentityResolverLive),
Layer.provideMerge(persistence),
);
const threadId = ThreadId.make("thread-clock");
const projectId = ProjectId.make("project-clock");
const before = "2026-09-05T15:00:00.000Z";
const after = "2026-09-05T14:00:00.000Z";
const envelope = (id: string, occurredAt: string) => ({
eventId: EventId.make(id),
aggregateKind: "thread" as const,
aggregateId: threadId,
occurredAt,
commandId: null,
causationEventId: null,
correlationId: null,
metadata: {},
});
yield* Effect.gen(function* () {
const store = yield* OrchestrationEventStore;
const pipeline = yield* OrchestrationProjectionPipeline;
yield* store.append({
...envelope("clock-project", before),
type: "project.created",
aggregateKind: "project",
aggregateId: projectId,
payload: {
projectId,
title: "Clock",
workspaceRoot: "/tmp/clock",
defaultModelSelection: null,
scripts: [],
createdAt: before,
updatedAt: before,
},
});
yield* store.append({
...envelope("clock-thread", before),
type: "thread.created",
payload: {
threadId,
projectId,
title: "Clock",
modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" },
runtimeMode: "full-access",
branch: null,
worktreePath: null,
createdAt: before,
updatedAt: before,
},
});
yield* store.append({
...envelope("clock-user", before),
type: "thread.message-sent",
payload: {
threadId,
messageId: MessageId.make("user-clock"),
role: "user",
text: "hello",
turnId: null,
streaming: false,
createdAt: before,
updatedAt: before,
},
});
yield* store.append({
...envelope("clock-tool", after),
type: "thread.activity-appended",
payload: {
threadId,
activity: {
id: EventId.make("tool-clock"),
tone: "tool",
kind: "tool.started",
summary: "tool",
payload: {
itemType: "collab_agent_tool_call",
data: {
subagentLiveText: "Reading files",
item: {
id: "spawn-clock",
tool: "spawnAgent",
status: "inProgress",
agentThreadId: "agent-clock",
},
},
},
turnId: null,
sequence: 900,
createdAt: after,
},
},
});
yield* store.append({
...envelope("clock-assistant", after),
type: "thread.message-sent",
payload: {
threadId,
messageId: MessageId.make("assistant-clock"),
role: "assistant",
text: "reply",
turnId: null,
streaming: true,
createdAt: after,
updatedAt: after,
},
});
const proposedPlan = {
id: "plan-clock",
turnId: null,
planMarkdown: "plan",
implementedAt: null,
implementationThreadId: null,
dismissedAt: null,
createdAt: after,
updatedAt: after,
};
yield* store.append({
...envelope("clock-plan", after),
type: "thread.proposed-plan-upserted",
payload: { threadId, proposedPlan },
});
yield* store.append({
...envelope("clock-tool-update", after),
type: "thread.activity-appended",
payload: {
threadId,
activity: {
id: EventId.make("tool-clock"),
tone: "tool",
kind: "tool.completed",
summary: "done",
payload: {
itemType: "collab_agent_tool_call",
data: {
item: {
id: "spawn-clock",
tool: "spawnAgent",
status: "completed",
agentThreadId: "agent-clock",
agentsStates: { "agent-clock": { status: "completed", message: "Done" } },
},
},
},
turnId: null,
sequence: 901,
createdAt: after,
},
},
});
yield* store.append({
...envelope("clock-assistant-update", after),
type: "thread.message-sent",
payload: {
threadId,
messageId: MessageId.make("assistant-clock"),
role: "assistant",
text: "reply complete",
turnId: null,
streaming: false,
createdAt: after,
updatedAt: after,
},
});
yield* store.append({
...envelope("clock-plan-update", after),
type: "thread.proposed-plan-upserted",
payload: { threadId, proposedPlan: { ...proposedPlan, planMarkdown: "plan updated" } },
});
yield* pipeline.bootstrap;
}).pipe(Effect.provide(makeLayer()));
yield* Effect.gen(function* () {
const pipeline = yield* OrchestrationProjectionPipeline;
const query = yield* ProjectionSnapshotQuery;
yield* pipeline.bootstrap;
const fullSnapshot = yield* query.getSnapshot();
assert.deepStrictEqual(
fullSnapshot.threads
.find((thread) => thread.id === threadId)
?.subagents?.map((agent) => [
agent.id,
agent.resultEventSequence,
agent.liveEventSequence,
]),
[["agent-clock", 7, 4]],
);
assert.deepStrictEqual(
fullSnapshot.threads
.find((thread) => thread.id === threadId)
?.activities.map((activity) => [activity.id, activity.eventSequence, activity.sequence]),
[[EventId.make("tool-clock"), 4, 901]],
);
for (const snapshot of [fullSnapshot, yield* query.getCommandReadModel()]) {
const thread = snapshot.threads.find((entry) => entry.id === threadId);
assert.ok(thread);
assert.deepStrictEqual(
thread.messages.map((message) => [message.id, message.eventSequence, message.createdAt]),
[
["user-clock", 3, before],
["assistant-clock", 5, after],
],
);
assert.strictEqual(thread.messages[1]?.text, "reply complete");
assert.deepStrictEqual(
thread.proposedPlans.map((plan) => [plan.id, plan.eventSequence, plan.planMarkdown]),
[["plan-clock", 6, "plan updated"]],
);
}
}).pipe(Effect.provide(makeLayer()));
}).pipe(
Effect.provide(
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), { prefix: "threadlines-clock-restart-" }),
NodeServices.layer,
),
),
),
);
Loading
Loading