Skip to content

Commit b35f6f4

Browse files
t3dotggclaude
andcommitted
fix(server): goal Stop keeps the run's roots seen before the pause
A goal run can settle while Stop waits for the pause reply. Stop now records the run's root turns before the pause, so it still stops a subagent an earlier turn left running. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
1 parent 8b58a11 commit b35f6f4

2 files changed

Lines changed: 135 additions & 7 deletions

File tree

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

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7937,6 +7937,119 @@ describe("CodexAdapterV2 post-settle continuation", () => {
79377937
}).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
79387938
);
79397939

7940+
it.effect("stops an earlier goal turn's subagent when the run settles during the pause", () =>
7941+
Effect.gen(function* () {
7942+
const prompt = "Delegate the audit";
7943+
const childThreadId = "goal-race-child-thread";
7944+
const childTurnId = "goal-race-child-turn";
7945+
const transcript = makeCodexReplayTranscript({
7946+
scenario: "goal-stop-run-settles-during-pause",
7947+
entries: [
7948+
...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn-a", prompt }),
7949+
notification("subagent started", "item/completed", {
7950+
item: {
7951+
type: "subAgentActivity",
7952+
id: "goal-race-subagent-call",
7953+
kind: "started",
7954+
agentThreadId: childThreadId,
7955+
agentPath: "/root/audit",
7956+
},
7957+
threadId: nativeThreadId,
7958+
turnId: "goal-turn-a",
7959+
completedAtMs: 1782622441000,
7960+
}),
7961+
notification("child turn", "turn/started", {
7962+
threadId: childThreadId,
7963+
turn: makeCodexReplayTurn({ id: childTurnId, status: "inProgress" }),
7964+
}),
7965+
notification("goal active", "thread/goal/updated", {
7966+
threadId: nativeThreadId,
7967+
turnId: "goal-turn-a",
7968+
goal: codexGoal("active", 10),
7969+
}),
7970+
notification("first turn done", "turn/completed", {
7971+
threadId: nativeThreadId,
7972+
turn: makeCodexReplayTurn({ id: "goal-turn-a", status: "completed" }),
7973+
}),
7974+
notification("continuation", "turn/started", {
7975+
threadId: nativeThreadId,
7976+
turn: makeCodexReplayTurn({ id: "goal-turn-b", status: "inProgress" }),
7977+
}),
7978+
notification("goal accounted", "thread/goal/updated", {
7979+
threadId: nativeThreadId,
7980+
turnId: "goal-turn-b",
7981+
goal: codexGoal("active", 11),
7982+
}),
7983+
{
7984+
type: "expect_outbound",
7985+
label: "thread/goal/set",
7986+
frame: {
7987+
id: 4,
7988+
method: "thread/goal/set",
7989+
params: { threadId: nativeThreadId, status: "paused" },
7990+
},
7991+
},
7992+
notification("goal paused", "thread/goal/updated", {
7993+
threadId: nativeThreadId,
7994+
turnId: "goal-turn-b",
7995+
goal: codexGoal("paused", 11),
7996+
}),
7997+
notification("continuation done", "turn/completed", {
7998+
threadId: nativeThreadId,
7999+
turn: makeCodexReplayTurn({ id: "goal-turn-b", status: "completed" }),
8000+
}),
8001+
{
8002+
type: "emit_inbound",
8003+
label: "thread/goal/set",
8004+
frame: { id: 4, result: { goal: codexGoal("paused", 11) } },
8005+
},
8006+
...request(5, "turn/interrupt", { threadId: childThreadId, turnId: childTurnId }, {}),
8007+
notification("child interrupted", "turn/completed", {
8008+
threadId: childThreadId,
8009+
turn: makeCodexReplayTurn({ id: childTurnId, status: "interrupted" }),
8010+
}),
8011+
],
8012+
});
8013+
const harness = yield* makeCodexReplayHarness(transcript);
8014+
yield* harness.runtime.startTurn(
8015+
makeCodexTestTurnInput({
8016+
threadId: harness.threadId,
8017+
providerThread: harness.providerThread,
8018+
now: yield* DateTime.now,
8019+
attemptId: RunAttemptId.make("goal-settles-during-pause-attempt"),
8020+
text: prompt,
8021+
}),
8022+
);
8023+
yield* awaitUntil(
8024+
() =>
8025+
harness.events.some(
8026+
(event) =>
8027+
event.type === "provider_thread.updated" &&
8028+
event.providerThread.goal?.tokensUsed === 11,
8029+
),
8030+
"the continuation to be adopted",
8031+
);
8032+
yield* harness.runtime.interruptTurn({
8033+
providerThread: harness.providerThread,
8034+
providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
8035+
driver: CodexAdapterV2.CODEX_DRIVER_KIND,
8036+
nativeTurnId: "goal-turn-b",
8037+
}),
8038+
requestRuntimeRestart: true,
8039+
});
8040+
yield* awaitUntil(
8041+
() =>
8042+
harness.events.some(
8043+
(event) =>
8044+
event.type === "provider_turn.updated" &&
8045+
event.providerTurn.nativeTurnRef?.nativeId === childTurnId &&
8046+
event.providerTurn.status === "interrupted",
8047+
),
8048+
"the subagent turn to be interrupted",
8049+
);
8050+
}).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
8051+
);
8052+
79408053
it.effect("pauses an active goal before Stop interrupts its turn", () =>
79418054
Effect.gen(function* () {
79428055
const prompt = "Keep going";

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

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5719,7 +5719,8 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
57195719
* pause is in flight, so the target resolves after it: a held run
57205720
* settles here and leaves only its retained work to stop, and a
57215721
* continued run moves Stop to its newest turn. `goalTurns` lists the
5722-
* run's root turns, whose descendants Stop also reaches.
5722+
* run's root turns, seen before and after the pause, whose descendants
5723+
* Stop also reaches.
57235724
*/
57245725
const resolveGoalStopTarget = Effect.fnUntraced(function* (
57255726
turnInput: ProviderAdapterV2InterruptInput,
@@ -5730,6 +5731,16 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
57305731
}
57315732
goalStops.set(goalThreadId, (goalStops.get(goalThreadId) ?? 0) + 1);
57325733
return yield* Effect.gen(function* () {
5734+
// The run can settle while the pause is in flight, which drops its record.
5735+
const requestedTurnId = latestGoalTurnId(turnInput.providerTurnId);
5736+
const heldBefore = goalHolds.get(goalThreadId)?.context;
5737+
const turnsBefore = [
5738+
...(goalRuns.get(requestedTurnId) ?? []),
5739+
...Array.from((yield* Ref.get(activeTurns)).values()).filter(
5740+
(context) => context.providerTurnId === requestedTurnId,
5741+
),
5742+
...(heldBefore?.providerTurnId === requestedTurnId ? [heldBefore] : []),
5743+
];
57335744
const activation = goalActivations.get(goalThreadId);
57345745
if (activation !== undefined) activation.stopped = true;
57355746
if (goalsByNativeThread.get(goalThreadId)?.status === "active") {
@@ -5749,17 +5760,21 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
57495760
return yield* turnTerminalizationPermit.withPermits(1)(
57505761
Effect.gen(function* () {
57515762
const providerTurnId = latestGoalTurnId(turnInput.providerTurnId);
5752-
const goalTurns: ReadonlyArray<ActiveCodexTurnContext> =
5753-
goalRuns.get(providerTurnId) ?? [];
57545763
const hold = goalHolds.get(goalThreadId);
5755-
if (hold === undefined || hold.context.providerTurnId !== providerTurnId) {
5756-
return { turnInput: { ...turnInput, providerTurnId }, goalTurns };
5757-
}
5764+
const held = hold !== undefined && hold.context.providerTurnId === providerTurnId;
5765+
const goalTurns: ReadonlyArray<ActiveCodexTurnContext> = Array.from(
5766+
new Set([
5767+
...turnsBefore,
5768+
...(goalRuns.get(providerTurnId) ?? []),
5769+
...(held ? [hold.context] : []),
5770+
]),
5771+
);
5772+
if (!held) return { turnInput: { ...turnInput, providerTurnId }, goalTurns };
57585773
goalHolds.delete(goalThreadId);
57595774
yield* settleGoalHold(hold, "interrupted");
57605775
return {
57615776
turnInput: { ...turnInput, providerTurnId, requestRuntimeRestart: true },
5762-
goalTurns: goalTurns.length > 0 ? goalTurns : [hold.context],
5777+
goalTurns,
57635778
};
57645779
}),
57655780
);

0 commit comments

Comments
 (0)