Skip to content

Commit 1dfbdf8

Browse files
committed
fix(server): prune checkpoint refs owned by deleted threads
1 parent 66ee628 commit 1dfbdf8

4 files changed

Lines changed: 500 additions & 30 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { assert, it } from "@effect/vitest";
2+
import {
3+
CommandId,
4+
EventId,
5+
ProjectId,
6+
ProviderInstanceId,
7+
RunId,
8+
ThreadId,
9+
type OrchestrationV2AppThread,
10+
} from "@t3tools/contracts";
11+
import * as DateTime from "effect/DateTime";
12+
import * as Effect from "effect/Effect";
13+
import * as Layer from "effect/Layer";
14+
15+
import * as Sqlite from "../persistence/Sqlite.ts";
16+
import * as EventSink from "./EventSink.ts";
17+
import * as EventStore from "./EventStore.ts";
18+
import * as ProjectionStore from "./ProjectionStore.ts";
19+
20+
const layer = EventSink.layer.pipe(
21+
Layer.provideMerge(Layer.merge(EventStore.layer, ProjectionStore.layer)),
22+
Layer.provide(Sqlite.layerMemory),
23+
);
24+
25+
it.effect.each([false, true])(
26+
"checks fork source lifetime inside the commit transaction, deleted before commit=%s",
27+
(deleteFirst) =>
28+
Effect.gen(function* () {
29+
const sink = yield* EventSink.EventSinkV2;
30+
const projections = yield* ProjectionStore.ProjectionStoreV2;
31+
const now = yield* DateTime.now;
32+
const sourceId = ThreadId.make("fork-source");
33+
const targetId = ThreadId.make("fork-target");
34+
const providerInstanceId = ProviderInstanceId.make("codex");
35+
const source: OrchestrationV2AppThread = {
36+
id: sourceId,
37+
projectId: ProjectId.make("project"),
38+
title: "Source",
39+
createdBy: "user",
40+
creationSource: "web",
41+
providerInstanceId,
42+
modelSelection: { instanceId: providerInstanceId, model: "gpt-5" },
43+
runtimeMode: "full-access",
44+
interactionMode: "default",
45+
branch: null,
46+
worktreePath: null,
47+
activeProviderThreadId: null,
48+
lineage: { rootThreadId: sourceId, parentThreadId: null, relationshipToParent: null },
49+
forkedFrom: null,
50+
createdAt: now,
51+
updatedAt: now,
52+
archivedAt: null,
53+
deletedAt: null,
54+
settledOverride: null,
55+
settledAt: null,
56+
snoozedUntil: null,
57+
snoozedAt: null,
58+
lastVisitedAt: null,
59+
};
60+
yield* sink.write({
61+
events: [
62+
{
63+
id: EventId.make("source-created"),
64+
type: "thread.created",
65+
threadId: sourceId,
66+
occurredAt: now,
67+
payload: source,
68+
},
69+
],
70+
});
71+
const plannedFork = {
72+
commandId: CommandId.make("fork"),
73+
commandType: "thread.fork",
74+
threadId: targetId,
75+
acceptedAt: now,
76+
effects: [],
77+
events: [
78+
{
79+
id: EventId.make("fork-created"),
80+
type: "thread.created" as const,
81+
threadId: targetId,
82+
occurredAt: now,
83+
payload: {
84+
...source,
85+
id: targetId,
86+
forkedFrom: { type: "run" as const, threadId: sourceId, runId: RunId.make("run") },
87+
},
88+
},
89+
],
90+
};
91+
const deleteSource = sink.write({
92+
events: [
93+
{
94+
id: EventId.make("source-deleted"),
95+
type: "thread.deleted",
96+
threadId: sourceId,
97+
occurredAt: now,
98+
payload: { ...source, deletedAt: now },
99+
},
100+
],
101+
});
102+
if (deleteFirst) {
103+
yield* deleteSource;
104+
const error = yield* sink.commitCommand(plannedFork).pipe(Effect.flip);
105+
assert.instanceOf(error, EventSink.EventSinkWriteError);
106+
const missing = yield* projections.getThread(targetId).pipe(Effect.flip);
107+
assert.instanceOf(missing, ProjectionStore.ProjectionStoreThreadNotFoundError);
108+
} else {
109+
assert.isTrue((yield* sink.commitCommand(plannedFork)).committed);
110+
yield* deleteSource;
111+
// A receipt replay must still return the fork that committed before deletion.
112+
assert.isFalse((yield* sink.commitCommand(plannedFork)).committed);
113+
assert.deepEqual(
114+
(yield* projections.getThread(targetId)).forkedFrom,
115+
plannedFork.events[0]!.payload.forkedFrom,
116+
);
117+
}
118+
}).pipe(Effect.provide(layer)),
119+
);

‎apps/server/src/orchestration-v2/EventSink.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ export class EventSinkStreamError extends Schema.TaggedError<EventSinkStreamErro
6363
}
6464
}
6565

66+
class ForkSourceUnavailableError extends Schema.TaggedError<ForkSourceUnavailableError>()(
67+
"ForkSourceUnavailableError",
68+
{ threadId: ThreadId },
69+
) {
70+
override get message(): string {
71+
return "Cannot fork a missing or deleted thread.";
72+
}
73+
}
74+
6675
export const EventSinkV2Error = Schema.Union([EventSinkWriteError, EventSinkStreamError]);
6776
export type EventSinkV2Error = typeof EventSinkV2Error.Type;
6877

@@ -656,6 +665,22 @@ const layerBase: Layer.Layer<
656665
return { ...existing, committed: false as const, cancelledEffectIds: [] };
657666
}
658667

668+
// Planning does not lock the source. Once deletion commits, maintenance
669+
// can remove its checkpoints, so a new fork must not revive that history.
670+
if (input.commandType === "thread.fork") {
671+
for (const event of input.events) {
672+
if (event.type !== "thread.created" || event.payload.forkedFrom?.type !== "run")
673+
continue;
674+
const sourceId = event.payload.forkedFrom.threadId;
675+
const source = yield* sql`
676+
SELECT 1 FROM orchestration_v2_projection_threads
677+
WHERE thread_id = ${sourceId} AND deleted_at IS NULL
678+
`;
679+
if (source.length === 0)
680+
return yield* new ForkSourceUnavailableError({ threadId: sourceId });
681+
}
682+
}
683+
659684
const normalized = yield* normalizeEvents(input.events);
660685
const storedEvents = yield* eventStore.append({
661686
commandId: input.commandId,

0 commit comments

Comments
 (0)