Skip to content

Commit 8662eb6

Browse files
committed
fix(server): stop threads without resolving a run
Allow clients to stop a thread directly, even when its running command is outside the history window.
1 parent 87ae8d5 commit 8662eb6

9 files changed

Lines changed: 77 additions & 9 deletions

File tree

‎apps/server/src/environment/ServerEnvironment.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ export const make = Effect.gen(function* () {
255255
pullRequestStackActions: true,
256256
threadPullRequestLinking: true,
257257
serverResolvedCommandContext: true,
258+
threadStop: true,
258259
environmentIcon: true,
259260
projectCloneTracking: true,
260261
...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),

‎apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4993,7 +4993,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
49934993
);
49944994
},
49954995
);
4996-
it.effect("interrupts a completed run's background command through orchestration", () => {
4996+
it.effect("stops a completed run's background command without a client-supplied run", () => {
49974997
const transcript = makeBackgroundStopTranscript(true);
49984998
return Effect.scoped(
49994999
Effect.gen(function* () {
@@ -5091,10 +5091,9 @@ describe("CodexAdapterV2 post-settle continuation", () => {
50915091
Effect.forkChild({ startImmediately: true }),
50925092
);
50935093
yield* orchestrator.dispatch({
5094-
type: "run.interrupt",
5094+
type: "thread.stop",
50955095
commandId: CommandId.make("stop-background-command"),
50965096
threadId,
5097-
runId: run.id,
50985097
});
50995098
yield* worker.drain();
51005099
yield* Fiber.join(stopped);

‎apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ export class OrchestratorV2ScenarioStepError extends Schema.TaggedError<Orchestr
131131
function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray<ThreadId> {
132132
switch (command.type) {
133133
case "thread.create":
134+
case "thread.stop":
134135
case "thread.archive":
135136
case "thread.unarchive":
136137
case "thread.delete":

‎packages/client-runtime/src/operations/commands.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct
7979
readonly projection?: OrchestrationV2ThreadProjection;
8080
readonly projectionRequests?: ThreadId[];
8181
readonly advertiseServerResolvedCommandContext?: boolean;
82+
readonly advertiseThreadStop?: boolean;
8283
}) {
8384
const client = {
8485
[ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) =>
@@ -126,6 +127,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct
126127
environment: {
127128
capabilities: {
128129
repositoryIdentity: true,
130+
...(input.advertiseThreadStop === true ? { threadStop: true } : {}),
129131
...(input.advertiseServerResolvedCommandContext === false
130132
? {}
131133
: { serverResolvedCommandContext: true }),
@@ -1049,6 +1051,34 @@ describe("V2 environment commands", () => {
10491051
}).pipe(Effect.provide(layerTestCrypto)),
10501052
);
10511053

1054+
it.effect("stops a thread without relying on background work in the history window", () =>
1055+
Effect.gen(function* () {
1056+
const commands: OrchestrationV2Command[] = [];
1057+
const projectionRequests: ThreadId[] = [];
1058+
const supervisor = yield* makeSupervisor({
1059+
commands,
1060+
projects: [],
1061+
projectionRequests,
1062+
advertiseThreadStop: true,
1063+
});
1064+
1065+
const result = yield* interruptThreadTurn({
1066+
commandId: CommandId.make("stop-outside-history-window"),
1067+
threadId: v2ThreadId,
1068+
}).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
1069+
1070+
expect(result).toEqual({ sequence: 1 });
1071+
expect(projectionRequests).toEqual([]);
1072+
expect(commands).toEqual([
1073+
{
1074+
type: "thread.stop",
1075+
commandId: "stop-outside-history-window",
1076+
threadId: v2ThreadId,
1077+
},
1078+
]);
1079+
}).pipe(Effect.provide(layerTestCrypto)),
1080+
);
1081+
10521082
it.effect("interrupts a known run without fetching the full projection", () =>
10531083
Effect.gen(function* () {
10541084
const commands: OrchestrationV2Command[] = [];
@@ -1057,6 +1087,7 @@ describe("V2 environment commands", () => {
10571087
commands,
10581088
projects: [],
10591089
projectionRequests,
1090+
advertiseThreadStop: true,
10601091
});
10611092

10621093
yield* interruptThreadTurn({

‎packages/client-runtime/src/operations/commands.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,17 @@ export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThrea
782782
});
783783
}
784784
let runId = input.runId ?? (input.turnId as RunId | undefined);
785+
if (runId === undefined) {
786+
const config = yield* getInitialServerConfig();
787+
if (config.environment.capabilities.threadStop === true) {
788+
// A paginated projection can omit commands still running from earlier turns.
789+
return yield* dispatch({
790+
type: "thread.stop",
791+
commandId: yield* allocateCommandId(input),
792+
threadId: input.threadId,
793+
});
794+
}
795+
}
785796
if (runId === undefined) {
786797
const projection = yield* getProjection(input.threadId);
787798
runId = projection.runs.findLast(

‎packages/contracts/src/environment.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,14 @@ describe("ExecutionEnvironmentDescriptor", () => {
110110
}).capabilities.serverResolvedCommandContext,
111111
).toBe(true);
112112
});
113+
114+
it("enables thread Stop only when advertised by the server", () => {
115+
expect(decodeDescriptor(descriptor).capabilities.threadStop).toBeUndefined();
116+
expect(
117+
decodeDescriptor({
118+
...descriptor,
119+
capabilities: { ...descriptor.capabilities, threadStop: true },
120+
}).capabilities.threadStop,
121+
).toBe(true);
122+
});
113123
});

‎packages/contracts/src/environment.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
125125
threadVisitedTracking: Schema.optionalKey(Schema.Boolean),
126126
threadPullRequestLinking: Schema.optionalKey(Schema.Boolean),
127127
serverResolvedCommandContext: Schema.optionalKey(Schema.Boolean),
128+
threadStop: Schema.optionalKey(Schema.Boolean),
128129
threadPullRequests: Schema.optionalKey(Schema.Boolean),
129130
threadPullRequestWatch: Schema.optionalKey(Schema.Boolean),
130131
pullRequestStackActions: Schema.optionalKey(Schema.Boolean),

‎packages/contracts/src/orchestrationV2.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,20 @@ describe("orchestration V2 contracts", () => {
625625
expect(event.payload.id).toBe(RunId.make("run-1"));
626626
});
627627

628+
it("accepts a thread Stop without a client-resolved run", () => {
629+
expect(
630+
decodeOrchestrationV2Command({
631+
type: "thread.stop",
632+
commandId: "stop-thread",
633+
threadId: "thread-1",
634+
}),
635+
).toEqual({
636+
type: "thread.stop",
637+
commandId: "stop-thread",
638+
threadId: "thread-1",
639+
});
640+
});
641+
628642
it("decodes app-owned delegated task commands", () => {
629643
const command = decodeOrchestrationV2Command({
630644
type: "delegated_task.request",

‎packages/contracts/src/orchestrationV2.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2553,6 +2553,12 @@ export const OrchestrationV2StoredEventJson = Schema.Struct({
25532553
export type OrchestrationV2StoredEventJson = typeof OrchestrationV2StoredEventJson.Type;
25542554

25552555
export const OrchestrationV2Command = Schema.Union([
2556+
Schema.Struct({
2557+
type: Schema.Literal("thread.stop"),
2558+
commandId: CommandId,
2559+
threadId: ThreadId,
2560+
reason: Schema.optional(Schema.String),
2561+
}),
25562562
Schema.Struct({
25572563
type: Schema.Literal("thread.create"),
25582564
...OrchestrationV2CreationFields,
@@ -3036,12 +3042,6 @@ const OrchestrationV2InternalCommand = Schema.Union([
30363042
}),
30373043
),
30383044
}),
3039-
Schema.Struct({
3040-
type: Schema.Literal("thread.stop"),
3041-
commandId: CommandId,
3042-
threadId: ThreadId,
3043-
reason: Schema.optional(Schema.String),
3044-
}),
30453045
/**
30463046
* Internal so no client can mark a request saved without the value being
30473047
* stored.

0 commit comments

Comments
 (0)