Repository navigation
Expand file tree
/
Copy pathRunFinalizationService.ts
More file actions
232 lines (222 loc) · 9.63 KB
/
Copy pathRunFinalizationService.ts
File metadata and controls
232 lines (222 loc) · 9.63 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
import { CheckpointScopeId, ProjectId, RunId, ThreadId } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FiberMap from "effect/FiberMap";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Queue from "effect/Queue";
import * as Schema from "effect/Schema";
import * as CodeIntelligence from "../project/knowledge/CodeIntelligence.ts";
import * as ProjectEnrichment from "../project/ProjectEnrichmentService.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as PullRequestService from "../pullRequest/PullRequestService.ts";
import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts";
import * as ModelEvaluations from "../modelEvaluations/ModelEvaluations.ts";
import * as CheckpointCapture from "./CheckpointCaptureService.ts";
import * as ProjectionStore from "./ProjectionStore.ts";
import * as WorktreeBranches from "./WorktreeBranches.ts";
export class RunFinalizationError extends Schema.TaggedError<RunFinalizationError>()(
"RunFinalizationError",
{
threadId: ThreadId,
runId: RunId,
scopeId: CheckpointScopeId,
operation: Schema.Literals(["capture-checkpoint", "refresh-workspace"]),
cause: Schema.Defect(),
},
) {}
export class RunFinalizationRefreshError extends Schema.TaggedError<RunFinalizationRefreshError>()(
"RunFinalizationRefreshError",
{ cwd: Schema.String, cause: Schema.Defect() },
) {}
export class RunFinalizationObserver extends Context.Reference<{
readonly refreshAfterTurn: (projectId: ProjectId) => Effect.Effect<void>;
readonly refresh: (input: {
readonly cwd: string;
readonly threadId: ThreadId;
readonly runId: RunId;
}) => Effect.Effect<void, RunFinalizationRefreshError>;
}>("t3/orchestration-v2/RunFinalizationObserver", {
defaultValue: () => ({ refresh: () => Effect.void, refreshAfterTurn: () => Effect.void }),
}) {}
export class RunFinalizationService extends Context.Service<
RunFinalizationService,
{
readonly finalize: (input: {
readonly threadId: ThreadId;
readonly runId: RunId;
readonly scopeId: CheckpointScopeId;
}) => Effect.Effect<void, RunFinalizationError>;
}
>()("t3/orchestration-v2/RunFinalizationService") {}
const make = Effect.gen(function* () {
const checkpointCapture = yield* CheckpointCapture.CheckpointCaptureServiceV2;
const projections = yield* ProjectionStore.ProjectionStoreV2;
const observer = yield* RunFinalizationObserver;
const modelEvaluations = yield* ModelEvaluations.ModelEvaluations;
const worktreeBranches = yield* WorktreeBranches.WorktreeBranches;
const finalize: RunFinalizationService["Service"]["finalize"] = Effect.fn(
"RunFinalizationService.finalize",
)(function* (input) {
yield* checkpointCapture
.execute(input)
.pipe(
Effect.mapError(
(cause) => new RunFinalizationError({ ...input, operation: "capture-checkpoint", cause }),
),
);
const projection = yield* projections
.getCheckpointContext(input.threadId)
.pipe(
Effect.mapError(
(cause) => new RunFinalizationError({ ...input, operation: "refresh-workspace", cause }),
),
);
const cwd = projection.checkpointScopes.find((scope) => scope.id === input.scopeId)?.cwd;
const run = projection.runs.find((candidate) => candidate.id === input.runId);
if (
run !== undefined &&
(run.status === "completed" || run.status === "interrupted" || run.status === "cancelled")
) {
yield* modelEvaluations
.enqueueRun({ threadId: input.threadId, runId: input.runId, runOutcome: run.status })
.pipe(
Effect.catchTags({
ModelEvaluationPassError: (cause) =>
Effect.logWarning("Failed to schedule model evaluation after checkpoint capture", {
...input,
cause,
}),
}),
);
}
if (cwd !== undefined) {
yield* projections.getThreadShell(input.threadId).pipe(
Effect.flatMap((thread) =>
// An exact match excludes project-root threads and nested-repo checkpoint scopes.
thread?.worktreePath === cwd
? worktreeBranches.sync({ worktreePath: cwd, runId: input.runId })
: Effect.void,
),
Effect.catchCause((cause) =>
Effect.logWarning("Failed to sync worktree branch after checkpoint capture", {
...input,
cause,
}),
),
);
// Retrying a display refresh after the checkpoint is durable blocks the next turn.
yield* observer.refresh({ cwd, threadId: input.threadId, runId: input.runId }).pipe(
Effect.catchTags({
RunFinalizationRefreshError: (cause) =>
Effect.logWarning("Failed to refresh workspace after checkpoint capture", {
...input,
cause,
}),
}),
);
}
});
return RunFinalizationService.of({ finalize });
});
export const layer = Layer.effect(RunFinalizationService, make);
export const layerObserver = Layer.effect(
RunFinalizationObserver,
Effect.gen(function* () {
const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const vcsStatus = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
const projections = yield* ProjectionStore.ProjectionStoreV2;
const pullRequests = yield* PullRequestService.PullRequestService;
const codeIntelligence = yield* CodeIntelligence.CodeIntelligence;
// Many agents can finish in the same project at once. Invalidate once per project in a
// fixed window, with a trailing refresh even under continuous activity. Pushes and explicit
// PR mutations still call PullRequestService directly and refresh immediately.
const pendingProjects = new Set<ProjectId>();
const refreshes = yield* Queue.dropping<void>(1);
const refreshAfterTurn = Effect.fn("RunFinalizationService.refreshAfterTurn")(
(projectId: ProjectId) =>
Effect.sync(() => {
const first = pendingProjects.size === 0;
pendingProjects.add(projectId);
if (first) Queue.offerUnsafe(refreshes, undefined);
}),
);
yield* Effect.gen(function* () {
yield* Queue.take(refreshes);
yield* Effect.sleep("10 seconds");
const projects = Array.from(pendingProjects);
pendingProjects.clear();
yield* pullRequests.refreshAfterTurns(projects).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to refresh pull requests after completed turns", {
projectIds: projects,
cause,
}),
),
);
}).pipe(Effect.forever, Effect.forkScoped);
const projectService = yield* ProjectService.ProjectService;
const enrichment = yield* ProjectEnrichment.ProjectEnrichmentService;
const pullRequestLookups = yield* FiberMap.make<string>();
const identityRefreshes = yield* FiberMap.make<ProjectId>();
// A turn can add the first remote (`git remote add`, `gh repo create --push`), which the
// enrichment and resolver caches would otherwise keep reporting as missing for a minute.
const refreshMissingRepositoryIdentity = Effect.fn(
"RunFinalizationService.refreshMissingRepositoryIdentity",
)(
function* (projectId: ProjectId) {
const project = yield* projectService.getById(projectId);
if (Option.isNone(project)) return;
const { workspaceRoot } = project.value;
if ((yield* enrichment.readRepositoryIdentity(workspaceRoot)) !== null) return;
yield* enrichment.refreshRepositoryIdentity(workspaceRoot);
},
(effect, projectId) =>
effect.pipe(
Effect.catch((error) =>
Effect.logWarning("failed to refresh repository identity after run completion", {
projectId,
detail: error.message,
}),
),
),
);
return {
refreshAfterTurn,
refresh: Effect.fn("RunFinalizationService.refresh")(
function* ({ cwd, threadId, runId }) {
// The index builds in the code intelligence service, off this turn's lane.
yield* codeIntelligence.refreshIfIndexed(cwd);
const [, local] = yield* Effect.all(
[workspaceEntries.refresh(cwd), vcsStatus.refreshLocalStatus(cwd)],
{ concurrency: "unbounded" },
);
const thread = yield* projections.getThreadShell(threadId);
if (!thread) return;
// Forge discovery probes can take seconds; like the PR lookup, the next turn must not wait.
yield* refreshMissingRepositoryIdentity(thread.projectId).pipe(
FiberMap.run(identityRefreshes, thread.projectId),
);
if (local.refName === null || local.isDefaultRef) return;
if (thread.branch !== local.refName) return;
if (thread.activeRunId !== null && thread.activeRunId !== runId) return;
// The forge CLI can take minutes, so the next turn must not wait for it. A newer
// turn's lookup replaces a running one instead of queueing behind the cwd's lock.
yield* vcsStatus.refreshPullRequestStatus(cwd).pipe(
Effect.catch((error) =>
Effect.logWarning("failed to refresh pull request status after run completion", {
threadId,
cwd,
detail: error.message,
}),
),
FiberMap.run(pullRequestLookups, cwd),
);
},
(effect, { cwd }) =>
effect.pipe(Effect.mapError((cause) => new RunFinalizationRefreshError({ cwd, cause }))),
),
};
}),
);